news 2026/9/25 2:53:28

HowToGraphQL 之 TypeScript + Apollo Server 全栈 GraphQL 服务实战总结:从零搭建到生产部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HowToGraphQL 之 TypeScript + Apollo Server 全栈 GraphQL 服务实战总结:从零搭建到生产部署

【免费下载链接】howtographql

The Fullstack Tutorial for GraphQL

项目地址:https://gitcode.com/gh_mirrors/ho/howtographql
点击查看免费下载

本篇是 HowToGraphQL 仓库中 TypeScript + Apollo 系列教程 的收官总结,系统回顾以 TypeScript、Apollo Server、Nexus 与 Prisma 为核心技术栈,从零构建 Hacker News 克隆版 GraphQL 服务端 API 的完整技术路线,并归纳查询/变更解析、数据库接入、JWT 认证、多对多关系、自定义标量以及过滤/分页/排序等关键能力,帮助读者建立"GraphQL 服务端工程化"的整体认知。

一、本系列教程的最终技术栈与总体目标

本教程的目标是使用以下技术组合,从零搭建一个可投入生产实践的 GraphQL 服务端:

  • TypeScript:JavaScript 的强类型超集,提供类型安全与更优的开发体验;
  • Apollo Server:功能完整的 GraphQL 服务端库,内置基于 Web 的 GraphQL 客户端(Apollo Studio Explorer),支持 GraphQL 规范、查询性能追踪,并可部署到 Vercel、标准 VM、AWS Lambda、Heroku 等环境;
  • Nexus:采用 code-first 方式构建类型安全的 GraphQL schema 的库——你直接编写 TypeScript 代码来定义 schema,由 Nexus 自动生成 GraphQL SDL(schema.graphql)与 TypeScript 类型定义(nexus-typegen.ts),从根本上避免 GraphQL 类型与 TypeScript 类型失同步;
  • Prisma:下一代 Node.js/TypeScript ORM,教程中通过Prisma Client在 GraphQL resolver 内部访问数据库。

按照 入门章节 的规划,整个系列按以下路线推进:

  1. 理解 GraphQL 服务端工作原理,用 Nexus 定义 GraphQL schema 并编写对应的 resolver 函数(初期仅使用内存数据);
  2. 引入由 Prisma 管理的 SQLite 数据库,实现数据持久化;
  3. 实现 signup/login 认证功能,并据此做接口权限校验;
  4. 添加 vote 投票功能;
  5. 学习自定义 GraphQL 标量并接入应用;
  6. 为 API 增加排序、过滤与分页能力,让客户端可以约束返回列表;
  7. 使用 GitHub Actions 将 API 自动部署到云端(Heroku)。

二、回顾项目搭建:从空目录到可运行的 GraphQL 服务

2.1 环境与工程初始化

教程要求 Node.js 版本为v12及以上,可用node --version确认。随后创建项目目录并初始化package.json:

mkdir hackernews-typescript cd hackernews-typescript npm init -y

安装 TypeScript 相关工具链与核心依赖:

npm install --save-dev typescript@^4.3.5 ts-node-dev@^1.1.8 npm install apollo-server@^3.1.1 graphql@^15.5.1 nexus@^1.1.0

ts-node-dev负责在开发时即时转译 TS 文件并在代码变更后自动重启服务;tsconfig.json采用strict: true等标准选项,rootDir为.、outDir为dist。

2.2 Nexus 生成 schema 与两个产物文件

在src/schema.ts中通过makeSchema配置 Nexus:

import { makeSchema } from 'nexus' import { join } from 'path' export const schema = makeSchema({ types: [], outputs: { schema: join(process.cwd(), "schema.graphql"), typegen: join(process.cwd(), "nexus-typegen.ts"), }, })

运行npx ts-node --transpile-only src/schema后,根目录会生成两个文件:

  • schema.graphql:GraphQL SDL(Schema Definition Language),描述 API 的结构;
  • nexus-typegen.ts:为 schema 中所有类型生成的 TypeScript 类型定义,用于保证应用代码的类型安全。

随后在package.json中补充两个常用脚本:

"scripts": { "dev": "ts-node-dev --transpile-only --no-notify --exit-child src/index.ts", "generate": "ts-node --transpile-only src/schema.ts" }

2.3 用 Apollo Server 启动服务

src/index.ts中创建 ApolloServer 实例并监听端口:

import { ApolloServer } from "apollo-server"; import { schema } from "./schema"; export const server = new ApolloServer({ schema }); const port = 3000; server.listen({ port }).then(({ url }) => { console.log(`🚀 Server ready at ${url}`); });

运行npm run dev后访问http://localhost:3000/即可看到 Apollo Server 欢迎页,点击Query your server可进入 Apollo Studio Explorer 在线 GraphQL IDE。若希望使用无需联网的离线 IDE,可通过在 ApolloServer 中增加plugins: [ApolloServerPluginLandingPageGraphQLPlayground()]启用 GraphQL Playground。

三、Schema 基础:三个根类型与查询/变更解析机制

3.1 GraphQL schema 与根类型

每个 GraphQL schema 都有三个特殊的根类型:Query、Mutation和Subscription,分别对应 GraphQL 的三种操作类型。根类型上的字段称为根字段,它们定义了 API 可用的操作。当根字段的类型是对象类型时,查询中需要展开为选择集(selection set)。

教程中的初始 schema 只有默认的ok字段,类型为Boolean!(!表示非空,返回null会触发错误)。升级后的 schema 示例展示了列表与非空修饰符的组合语义,例如[User!]!表示"返回一个本身非空的列表,且列表元素也不可为 null"。

3.2 code-first 工作流

使用 Nexus 添加新 API 功能遵循三步流程:

  1. 用 Nexus 定义 schema 组件(types、fields、根对象类型等);
  2. 生成 GraphQL SDL 与类型定义;
  3. 为新增字段实现对应的 resolver 函数。

定义Link对象类型:

import { objectType } from "nexus"; export const Link = objectType({ name: "Link", definition(t) { t.nonNull.int("id"); t.nonNull.string("description"); t.nonNull.string("url"); }, });

所有类型通过src/graphql/index.ts统一导出,并作为types传入makeSchema:

import * as types from "./graphql"; export const schema = makeSchema({ types, outputs: { typegen: join(process.cwd(), "nexus-typegen.ts"), schema: join(process.cwd(), "schema.graphql"), }, });

3.3 feed 查询与查询解析过程

实现feed查询使用extendType扩展Query根类型,并定义 resolver:

let links: NexusGenObjects["Link"][] = [ { id: 1, url: "www.howtographql.com", description: "Fullstack tutorial for GraphQL" }, { id: 2, url: "graphql.org", description: "GraphQL official website" }, ]; export const LinkQuery = extendType({ type: "Query", definition(t) { t.nonNull.list.nonNull.field("feed", { type: "Link", resolve(parent, args, context, info) { return links; }, }); }, });

查询解析的本质是"编排 resolver 调用":每个查询字段都对应一个 resolver 函数,服务器按查询形状逐层调用并打包响应。每个 resolver 都接收四个参数——parent(上一层 resolver 的返回值)、args(本次操作的参数)、context(所有 resolver 共享的对象)与info。Link类型的id、description、url字段无需显式 resolver,GraphQL 类型系统会自动从返回对象的同名属性推断取值,这类 resolver 称为 trivial resolver(平凡解析器)。

3.4 post 变更:理解 args 参数

export const LinkMutation = extendType({ type: "Mutation", definition(t) { t.nonNull.field("post", { type: "Link", args: { description: nonNull(stringArg()), url: nonNull(stringArg()), }, resolve(parent, args, context) { const { description, url } = args; let idCount = links.length + 1; const link = { id: idCount, description, url }; links.push(link); return link; }, }); }, });

这里第二次用到 resolver 的args参数,它携带操作的参数值。由于返回类型被声明为非空Link,若将return link改为return null,IDE 会立即报类型错误——这正是 Nexus 类型安全的体现。教程还演示了客户端传参的两种方式:内联传值,以及通过变量($description、$url)分离数据与调用。

四、引入数据库:Prisma + SQLite 与 resolver 的数据库化改造

4.1 Prisma 工具集

教程使用的 Prisma 生态工具包括:

  • Prisma Client:自动生成、类型安全的 Node.js/TypeScript 查询构建器;
  • Prisma CLI:命令行工具,用于初始化与交互;
  • Prisma Migrate:声明式数据建模与迁移系统;
  • Prisma Studio:可视化的数据查看与编辑 GUI。

安装并初始化:

npm install prisma@^3.5.0 --save-dev npm install @prisma/client@^3.5.0 npx prisma init

4.2 定义 Prisma schema

prisma/schema.prisma相当于数据库 schema,包含三部分:数据源(Data source)、生成器(Generator)与数据模型(Data model):

datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model Link { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) description String url String }

@id声明主键,@default(autoincrement())表示自增,createdAt的@default(now())会在创建记录时自动填充 ISO 8601 格式时间戳。

4.3 迁移与 Prisma Client

执行npx prisma migrate dev --name "init"后,prisma/migrations目录会生成带.sql文件的迁移历史,数据库与 Prisma Client 自动同步。之后可用npx prisma generate独立重新生成 Client。

教程用一个独立的src/script.ts验证 Client 的增查能力:

import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const newLink = await prisma.link.create({ data: { description: 'Fullstack tutorial for GraphQL', url: 'www.howtographql.com', }, }); const allLinks = await prisma.link.findMany(); console.log(allLinks); } main() .catch((e) => { throw e; }) .finally(async () => { await prisma.$disconnect(); });

此后更新数据的标准工作流为:调整 Prisma 数据模型 → 用prisma migrate迁移数据库 →(重新)生成 Prisma Client → 在应用代码中使用 Client 访问数据库。

4.4 通过 context 把 Prisma Client 注入 resolver

src/context.ts定义 Context 接口并导出 context 对象:

import { PrismaClient } from "@prisma/client"; export const prisma = new PrismaClient(); export interface Context { prisma: PrismaClient; } export const context: Context = { prisma };

在schema.ts中通过contextType告知 Nexus 上下文类型,并在index.ts中把context传给 ApolloServer。随后 resolvers 即可通过第三个参数context.prisma访问数据库:

export const LinkQuery = extendType({ type: "Query", definition(t) { t.nonNull.list.nonNull.field("feed", { type: "Link", resolve(parent, args, context) { return context.prisma.link.findMany(); }, }); }, });

post变更同理改为context.prisma.link.create({ data: { description, url } })。注意 Prisma 查询返回的是 Promise,Apollo Server 会自动解析 resolver 返回的 Promise。至此内存数组links与idCount可以彻底删除,数据在服务重启后依然持久化。

五、认证与授权:JWT 方案完整落地

5.1 扩展数据模型与关系字段

在prisma/schema.prisma中新增User模型,并通过@relation建立Link与User的一对多关系(postedBy/postedById外键):

model Link { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) description String url String postedBy User? @relation(fields: [postedById], references: [id]) postedById Int? } model User { id Int @id @default(autoincrement()) name String email String @unique password String links Link[] }

执行npx prisma migrate dev --name "add-user-model"生成第二次迁移,Prisma Client 随之更新。

5.2 User/Link 类型与关系 resolver

GraphQL 侧新增User类型,其links字段需要显式 resolver(非平凡 resolver),通过parent.id结合 Prisma 的 Fluent API 链式获取关联数据:

export const User = objectType({ name: "User", definition(t) { t.nonNull.int("id"); t.nonNull.string("name"); t.nonNull.string("email"); t.nonNull.list.nonNull.field("links", { type: "Link", resolve(parent, args, context) { return context.prisma.user .findUnique({ where: { id: parent.id } }) .links(); }, }); }, });

Link类型对称地增加可空的postedBy字段,resolver 使用findUnique(...).postedBy()。这种链式关系查询即 Prisma 的 Fluent API,其批处理行为对解决 GraphQL 中常见的 "N+1 问题" 很有帮助。

5.3 signup 与 login 变更

安装认证依赖:

npm install bcryptjs@~2.4.0 jsonwebtoken@~8.5.0 npm install --save-dev @types/bcryptjs@~2.4.0 @types/jsonwebtoken@~8.5.0

先定义AuthPayload类型(包含token与user),再在src/graphql/Auth.ts中实现两个变更:

export const AuthMutation = extendType({ type: "Mutation", definition(t) { t.nonNull.field("signup", { type: "AuthPayload", args: { email: nonNull(stringArg()), password: nonNull(stringArg()), name: nonNull(stringArg()), }, async resolve(parent, args, context) { const { email, name } = args; const password = await bcrypt.hash(args.password, 10); const user = await context.prisma.user.create({ data: { email, name, password }, }); const token = jwt.sign({ userId: user.id }, APP_SECRET); return { token, user }; }, }); }, });

login变更则先按email用findUnique查用户,再通过bcrypt.compare校验密码,成功后签发同样的 JWT。两个 resolver 均为async,因为内部需要await多个异步操作。

APP_SECRET存放在src/utils/auth.ts:

export const APP_SECRET = "GraphQL-is-aw3some";

它用于签名与校验 JWT,教程强调该值必须保密(生产环境应放入.env等安全位置,避免随版本控制泄露)。

5.4 服务端校验客户端 JWT

通过 HTTPAuthorization头传递 Bearer Token。src/utils/auth.ts增加解码函数:

import * as jwt from "jsonwebtoken"; export interface AuthTokenPayload { userId: number; } export function decodeAuthHeader(authHeader: String): AuthTokenPayload { const token = authHeader.replace("Bearer ", ""); if (!token) { throw new Error("No token found"); } return jwt.verify(token, APP_SECRET) as AuthTokenPayload; }

context.ts从对象改为函数形态,由 Apollo Server 在收到请求时执行,把解码出的userId放入上下文:

export interface Context { prisma: PrismaClient; userId?: number; } export const context = ({ req }: { req: Request }): Context => { const token = req && req.headers.authorization ? decodeAuthHeader(req.headers.authorization) : null; return { prisma, userId: token?.userId }; };

5.5 保护 post 变更并测试完整认证流

postresolver 校验userId存在,并通过postedBy: { connect: { id: userId } }把新 Link 关联到当前用户:

if (!userId) { throw new Error("Cannot post without logging in."); } const newLink = context.prisma.link.create({ data: { description, url, postedBy: { connect: { id: userId } }, }, });

测试流程:先发送signup拿到 JWT → 在 Apollo Studio 的 Headers 中添加Authorization: Bearer <TOKEN>→ 调用带认证的post→ 用login验证user.links已包含新创建的 Link。移除或禁用该 Header 后再次post,会得到"Cannot post without logging in."错误。此外,运行npx prisma studio可在http://localhost:5555通过 GUI 浏览Link、User及二者关系的数据。

六、投票功能与自定义标量:多对多关系与 DateTime

6.1 隐式多对多关系

在 Prisma 模型中新增voters/votes关系字段,由于User与Link之间已有PostedBy关系,需用name属性区分两条关系:

model Link { ... postedBy User? @relation(name: "PostedBy", fields: [postedById], references: [id]) postedById Int? voters User[] @relation(name: "Votes") } model User { ... links Link[] @relation(name: "PostedBy") votes Link[] @relation(name: "Votes") }

这种由 Prisma 在底层自动维护关系表(join table)的多对多关系称为隐式多对多关系,适用于关系本身无需附加额外信息的场景;若需要在关系上挂载额外数据,则应使用显式多对多关系。执行npx prisma migrate dev --name "add-vote-relation"完成迁移。

6.2 vote 变更

export const VoteMutation = extendType({ type: "Mutation", definition(t) { t.field("vote", { type: "Vote", args: { linkId: nonNull(intArg()) }, async resolve(parent, args, context) { const { userId } = context; const { linkId } = args; if (!userId) { throw new Error("Cannot vote without logging in."); } const link = await context.prisma.link.update({ where: { id: linkId }, data: { voters: { connect: { id: userId } } }, }); const user = await context.prisma.user.findUnique({ where: { id: userId } }); return { link, user: user as User }; }, }); }, });

userId无需作为参数传入,因为它可以从 Authorization 头解码得到;connect用于把用户挂到链接的voters多对多关系上。随后在Link类型增加voters: [User!]!字段、在User类型增加votes: [Link!]!字段,resolver 同样基于 Fluent API 实现。

6.3 自定义标量与 DateTime 标量

GraphQL 内置Int、Float、String、Boolean、ID五种标量,自定义标量可同时定义数据的表示形式与校验逻辑。Nexus 底层使用graphql库的GraphQLScalarType,因此 Node 生态各库定义的标量通常相互兼容。

安装并接入graphql-scalars提供的DateTime:

npm install graphql-scalars@^1.14.1
// src/graphql/scalars/Date.ts import { asNexusMethod } from "nexus"; import { GraphQLDateTime } from "graphql-scalars"; export const GQLDate = asNexusMethod(GraphQLDateTime, "dateTime");

将scalars/Date加入src/graphql/index.ts的导出后,schema.graphql会出现scalar DateTime声明(遵循 RFC 3339/ISO 8601 规范,与 Prisma 的DateTime类型一致)。由于Link的 Prisma 模型本就带有createdAt字段,只需在 Nexus 类型定义中加入t.nonNull.dateTime("createdAt"),查询时即可自动解析。最终feed查询可返回createdAt字段,例如"2021-12-14T23:21:52.620Z"。

七、过滤、分页与排序:把 Prisma 能力映射到 GraphQL API

7.1 过滤

给feed增加可选字符串参数filter,在 resolver 中构造where条件,让description或url包含该字符串:

resolve(parent, args, context) { const where = args.filter ? { OR: [ { description: { contains: args.filter } }, { url: { contains: args.filter } }, ], } : {}; return context.prisma.link.findMany({ where }); }

7.2 分页(limit-offset)

API 设计中常见的两种分页是 limit-offset 与 cursor-based,Prisma 两者都支持。本教程实现 limit-offset:Prisma 中 limit 对应take(取多少个元素),offset 对应skip(跳过多少元素,默认 0)。

args: { filter: stringArg(), skip: intArg(), take: intArg(), }, resolve(parent, args, context) { ... return context.prisma.link.findMany({ where, skip: args?.skip as number | undefined, take: args?.take as number | undefined, }); }

注意 Prisma 对null与undefined语义区分严格:null是具体值,undefined表示"不做处理"。Nexus 生成类型包含null,因此需要类型断言为number | undefined。

7.3 排序

通过inputObjectType与enumType定义排序输入:

export const LinkOrderByInput = inputObjectType({ name: "LinkOrderByInput", definition(t) { t.field("description", { type: Sort }); t.field("url", { type: Sort }); t.field("createdAt", { type: Sort }); }, }); export const Sort = enumType({ name: "Sort", members: ["asc", "desc"], });

生成的 SDL 为input LinkOrderByInput { createdAt: Sort description: Sort url: Sort }与enum Sort { asc desc }。feed新增orderBy: arg({ type: list(nonNull(LinkOrderByInput)) }),支持按多个字段排序(如orderBy: [{ url: asc }, { createdAt: desc }]),resolver 中将其断言后传给 Prisma 的orderBy选项。

7.4 返回总数:重构为 Feed 类型

为了让客户端获知符合条件的总数(而非仅当前页数量),feed的返回类型重构为Feed:

export const Feed = objectType({ name: "Feed", definition(t) { t.nonNull.list.nonNull.field("links", { type: Link }); t.nonNull.int("count"); t.id("id"); }, });

resolver 中使用 Prisma 的countAPI 统计满足过滤条件的记录数(不受skip/take影响),并用序列化后的查询参数生成唯一id:

const links = await context.prisma.link.findMany({ where, skip, take, orderBy }); const count = await context.prisma.link.count({ where }); const id = `main-feed:${JSON.stringify(args)}`; return { links, count, id };

查询示例中feed(take: 1)返回count: 2但links只有 1 条,直观体现"总数"与"返回条数"的区别。

八、部署到生产:GitHub Actions 驱动的持续部署

教程最后讲解了把项目部署到 Heroku 并自动化部署流程:先git init并配置.gitignore(排除node_modules、.env、.vscode、dist),用gh repo create hackernews-typescript --source . --private在 GitHub 创建私有远程仓库,再将数据库从 SQLite 切换为 PostgreSQL(教程用 Docker/Docker Compose 运行 PostgreSQL 实例,也可原生安装),最后编写 GitHub Actions 工作流,在每次推送等可配置事件触发时自动构建并部署到 Heroku,实现持续部署(CD)。前置条件包括 GitHub/Heroku 账号、GitHub CLI/Heroku CLI 以及 Docker 与 Docker Compose。

九、最终架构与核心收获

9.1 分层职责

从本系列教程可以归纳出清晰的职责分层:

  1. GraphQL schema 层:由 Nexus(code-first)定义并生成 SDL 与类型定义;
  2. HTTP 服务层:Apollo Server 接收并执行 GraphQL 操作,负责解析请求头(如Authorization)并构建context;
  3. Resolver 层:每个字段的解析实现,通过parent、args、context三个参数驱动;
  4. 数据访问层:Prisma Client 作为类型安全的 CRUD API,承载全部数据库读写,其方法基于schema.prisma中的模型自动生成。

正如 总结章节 所述:apollo-server是快速、简单的 GraphQL 服务器库,自带基于 Web 的 GraphQL 客户端等多项特性;GraphQL 服务端的 resolver 使用 Prisma Client 实现,由它负责数据库访问。Prisma Client 暴露了针对 schema 中所有模型的 CRUD 操作,resolver 借助它执行查询与变更并将执行结果返回给客户端——这就是整个 Prisma/GraphQL 项目运行时的完整数据流。

9.2 本系列沉淀的最佳实践清单

  • 类型安全贯穿全链路:Nexus 保证 GraphQL SDL 与 TypeScript 类型单一来源、永不失同步;Prisma Client 保证数据库模型与查询代码类型安全;
  • context 是 resolver 之间的通信桥梁:可以在服务器初始化时把 Prisma Client、认证信息等写入 context,供所有 resolver 读取;
  • 认证先于授权:JWT 放在Authorization: Bearer头中,服务器解码后把userId注入 context,resolver 再做权限校验;
  • 关系建模分层决策:一对多关系用外键 +@relation,无附加信息的多对多关系用隐式关系表,有附加信息时改用显式关系;
  • 把数据库能力按需暴露给客户端:过滤(contains+OR)、分页(skip/take)、排序(orderBy+ input/enum)、聚合(count)都可以通过少量参数映射到 GraphQL API;
  • 持续部署是工程化的最后一公里:版本控制 + CI 工作流 + 云平台(Heroku),让 API 的每次变更自动进入生产环境。

9.3 进一步深入的方向

若希望继续深耕,可以在本仓库中按章节顺序完整复刻整个实现:从 开始章节 起步,依次经过 查询、变更、接入数据库、连接服务与数据库、认证、投票与自定义标量、过滤分页排序 与 部署。也可以横向对比仓库中其他语言/技术栈的实现(如 GraphQL Go、GraphQL Java、TypeScript Helix 等),从而理解不同生态下 GraphQL 服务端工程的共性与差异。

至此,你已经完整经历了"用 TypeScript + Apollo Server + Nexus + Prisma 从零构建并部署一个生产级 GraphQL API"的全过程——既掌握了 GraphQL schema、resolver、认证、关系、自定义标量与查询增强等核心机制,也建立了从数据建模到持续部署的工程化全局视角。

【免费下载链接】howtographql

The Fullstack Tutorial for GraphQL

项目地址:https://gitcode.com/gh_mirrors/ho/howtographql
点击查看免费下载
上一篇:Starship Plain Text Symbols 预设完全指南:在无 Unicode 环境下使用纯文本符号打造跨平台提示符
下一篇:UDEV Gothic字体测试与质量保证:确保跨平台兼容性的完整流程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 2:51:49

C语言面试题深度总结:从关键字到内存管理的核心考点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 2:50:08

从Anaconda到Miniconda:轻量级Python环境管理实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华