- 发布日期
第 03 讲|源码主入口与执行流(dev / build / start 三条线)
梳理 dev / build / start 三条执行主线的源码入口与流程走向
阶段一:框架认知与源码导航 · 第 3 / 40 讲 难度:⭐⭐⭐ · 预计耗时:2 小时
学习目标
- 能默写
next dev/next build/next start三条命令对应的源码调用栈。 - 理解 Next.js 的"父子进程"模型:CLI 进程、router-server、render-server 各自的职责。
- 能在
app-render.tsx入口处打断点,看到一次完整请求是怎么走过来的。 - 区分清楚
Server(抽象) /NextNodeServer(生产 Node) /DevServer(开发) 的继承关系。
1. CLI 总入口:bin/next
所有命令都从一个二进制脚本开始:
- 源码:
packages/next/src/bin/next.ts(编译产物:packages/next/dist/bin/next.js) - 包:
packages/next的package.json里通过"bin": { "next": "dist/bin/next.js" }暴露给 npm(详见上一讲)。
它做的事很简单:解析 process.argv[2] 取子命令名(dev / build / start / export / ...),require 对应的 cli/next-*.ts 文件并把剩余 argv 传过去。packages/next/src/cli/ 下你能看到一一对应:
src/cli/
├── next-dev.ts ← next dev
├── next-build.ts ← next build
├── next-start.ts ← next start
├── next-export.ts ← next export
├── next-info.ts ← next info
├── next-telemetry.ts ← next telemetry
├── next-test.ts ← next test (实验)
├── next-typegen.ts ← next typegen
└── next-upgrade.ts ← next upgrade
下面我们重点拆 dev / build / start 这三条主线。
2. next dev:开发服务器
2.1 一行调用图
bin/next (CLI 路由)
└─ cli/next-dev.ts (父进程 - watcher / restart 逻辑)
└─ fork() → 子进程 ← 真正跑服务的进程
└─ server/lib/start-server.ts
└─ server/lib/router-server.ts (initialize)
├─ setupFsCheck (静态文件 / public 索引)
├─ setupDevBundler (webpack/turbopack/rspack 选择)
├─ render-server.ts (initialize - 起渲染 worker)
└─ requestListener (HTTP server 真正绑定)
2.2 关键源码
第 1 跳:CLI 解析与子进程 fork
next-dev.ts 的关键能力是「自重启」:当你修改 next.config.js、.env 或 server 内存阈值触发时,它需要重启子进程;为此它在父进程里只做监控,真正跑服务的是 child_process.fork() 出来的子进程。
import { initialEnv } from '@next/env'
import { fork } from 'child_process'
import type { ChildProcess } from 'child_process'
import {
getReservedPortExplanation,
isPortIsReserved,
} from '../lib/helpers/get-reserved-port'
import { getCacheDirectory } from '../lib/helpers/get-cache-directory'
import { getGitBranch } from '../lib/helpers/git'
import os from 'os'
import fs from 'node:fs'
import { once } from 'node:events'
import { clearTimeout } from 'timers'
import { trace, initializeTraceState, exportTraceState } from '../trace'
import { traceId } from '../trace/shared'
import { Bundler, parseBundlerArgs } from '../lib/bundler'
父子进程是 dev 模式独有的设计——
next start不 fork,因为它不需要自重启。
第 2 跳:start-server 起 HTTP 监听
子进程 require start-server.ts,由它在 Node 中创建真正的 http.createServer():
export async function startServer(
serverOptions: StartServerOptions
): Promise<StartServerResult> {
const {
dir,
isDev,
hostname,
minimalMode,
allowRetry,
keepAliveTimeout,
selfSignedCertificate,
serverFastRefresh,
} = serverOptions
let { port } = serverOptions
process.title = `next-server (v${process.env.__NEXT_VERSION})`
let handlersReady = () => {}
let handlersError = () => {}
let handlersPromise: Promise<void> | undefined = new Promise<void>(
(resolve, reject) => {
handlersReady = resolve
handlersError = reject
}
)
let requestHandler: WorkerRequestHandler = async (
req: IncomingMessage,
res: ServerResponse
): Promise<void> => {
// ... defer until handlers are ready
}
注意 requestHandler 一开始是个 placeholder(异步等待 handlersPromise)——这是为了让 socket 尽早 listen,避免端口被其他进程抢占;真正的 handler 在 router-server.initialize() 完成后被赋值。
第 3 跳:router-server 初始化
router-server.ts 是 dev/start 共用的"接请求中心"。initialize() 是其入口:
export async function initialize(opts: {
// ... options
}) {
// ...
let experimentalFeatures: ConfiguredExperimentalFeature[] = []
const config = await loadConfig(
opts.dev ? PHASE_DEVELOPMENT_SERVER : PHASE_PRODUCTION_SERVER,
opts.dir,
{
silent: false,
reportExperimentalFeatures(features) {
experimentalFeatures = features.toSorted(({ key: a }, { key: b }) =>
a.localeCompare(b)
)
},
}
)
它依次做:
loadConfig()加载next.config.js;setupFsCheck()扫描静态资源、public/;- (dev 才有)
setupDevBundler()起 webpack/turbopack/rspack; render-server.initialize()起渲染服务;- 返回一个
requestHandler,挂回 start-server 的 placeholder。
第 4 跳:render-server → BaseServer
render-server.ts 内会实例化 NextNodeServer(生产)或 DevServer(开发):
export default class NextNodeServer extends BaseServer<
Options,
NodeNextRequest,
NodeNextResponse
> {
export default abstract class Server<
ServerOptions extends Options = Options,
ServerRequest extends BaseNextRequest = BaseNextRequest,
ServerResponse extends BaseNextResponse = BaseNextResponse,
> {
注意:base-server.ts 里类名其实是 Server,但在 next-server.ts 里被 import as BaseServer。这是 Next.js 一个很容易混淆的命名陷阱:
- 文件
base-server.ts→export default abstract class Server← 抽象基类 - 文件
next-server.ts→export default class NextNodeServer extends BaseServer - 文件
dev/next-dev-server.ts→export default class DevServer extends Server
"Server" / "NextServer" / "NextNodeServer" / "DevServer" / "BaseServer" 这些词在源码里各有所指,看到时先确认是从哪个文件 import 的,否则会迷路。
2.3 dev 特有:HMR、on-demand entries
dev 模式比 start 多两块逻辑:
- on-demand entries(
src/server/dev/on-demand-entry-handler.ts):路由没被访问过时不编译,访问时再触发 webpack 增量编译。 - HMR(
src/server/dev/hot-reloader-*.ts):监听文件变更,通过 WebSocket 向_next/webpack-hmr推送增量。
第 34 讲会专门讲 dev 调试体系。
3. next build:构建管线
3.1 一行调用图
bin/next
└─ cli/next-build.ts
└─ build/index.ts ← 主入口(4000+ 行的大块头)
├─ loadConfig + nextBuildSpan.trace(...) (trace 全程)
├─ collectPages (扫 app/ + pages/)
├─ runWebpackCompiler / runTurbopackBuild (3 套 compiler 并行/串行)
├─ writeManifests (12+ 个 manifest 落盘)
├─ generateStaticPages (worker) (调用 worker.ts,并行预渲染)
├─ collectBuildTraces (生成 .nft.json)
└─ printTreeView (终端表格)
3.2 关键源码
入口:next-build.ts 主要做 flag 校验,最后调用:
#!/usr/bin/env node
import { saveCpuProfile } from '../server/lib/cpu-profile'
import { existsSync } from 'fs'
import { italic } from '../lib/picocolors'
import build from '../build'
import { warn } from '../build/output/log'
import { printAndExit } from '../server/lib/utils'
import build from '../build' 这一行进入了 build/index.ts —— 这是整个构建管线的"心脏",光这一个文件就有 4000+ 行。
worker 化静态生成:build 阶段会 fork 多个 worker 进程同时跑 getStaticPaths / generateStaticParams / generateMetadata,源码 src/build/worker.ts + src/server/dev/static-paths-worker.ts。
这一讲不展开 build,阶段四(第 23–28 讲)会专门拆 build 流程。
4. next start:生产服务器
4.1 一行调用图
bin/next
└─ cli/next-start.ts
└─ server/lib/start-server.ts (与 dev 共用!isDev=false)
└─ router-server.ts initialize
└─ render-server.ts initialize
└─ NextNodeServer (不是 DevServer)
└─ BaseServer (公共逻辑)
next-start.ts 比 next-dev.ts 短得多——94 行,只解析 port/hostname、设置 inspector,最后直接 await startServer({ isDev: false }):
const nextStart = async (options: NextStartOptions, directory?: string) => {
const dir = getProjectDir(directory)
const hostname = options.hostname
const inspect = options.inspect
const port = options.port
const keepAliveTimeout = options.keepAliveTimeout
if (isPortIsReserved(port)) {
printAndExit(getReservedPortExplanation(port), 1)
}
// ... inspector setup ...
await startServer({
dir,
isDev: false,
hostname,
port,
keepAliveTimeout,
})
}
4.2 dev vs start 的"分叉点"
二者共用 start-server.ts 和 router-server.ts 的入口,但分叉发生在两处:
router-server.ts内部:opts.dev决定是否调用setupDevBundler、是否注入 HMR 路由。render-server.ts内部:根据devflag 实例化DevServer还是NextNodeServer。
所以 dev / start 的源码差异其实非常少。这也是为什么 pnpm test-dev-turbo 和 pnpm test-start-turbo 测试同一份代码就能验证两种模式。
5. 一次请求的"全栈"调用路径(App Router)
把以上整合起来,画一次 GET /products/123 的完整路径:
HTTP socket
↓
http.createServer(requestListener) ← start-server.ts:240
↓
requestHandler (placeholder → 真实 handler) ← start-server.ts:209
↓
router-server.ts resolveRoutes ← 检查 rewrite / redirect / public / API
↓
NextNodeServer.handleRequest ← next-server.ts (继承 base-server.ts)
↓ 匹配到 app/products/[id]/page.tsx
RouteModule (app-page module) ← route-modules/app-page/module.ts
↓
app-render.tsx renderToHTMLOrFlight ← 真正的渲染入口
↓
React renderToReadableStream + RSC payload ← compiled/react-server-dom-webpack/
↓
pipeReadable → http.ServerResponse ← pipe-readable.ts
↓
浏览器收到 HTML + 内嵌 RSC payload
这个图你要刻在脑子里——后面 35 讲都是在某一个箭头上深挖。
6. 实操:亲手打断点跑一遍
步骤 1:确保 packages/next 已构建
pnpm --filter=next build
步骤 2:起一个 fixture(用上一讲生成的也行)
cd test/e2e/lecture-01
步骤 3:用 inspect 模式启动
node --inspect-brk=9229 ../../../packages/next/dist/bin/next.js dev --port 3001
打开 Chrome chrome://inspect,attach 到 9229 端口。在以下文件里下断点:
dist/server/lib/start-server.js(对应start-server.ts第 240 行的requestListener)dist/server/lib/router-server.js(initialize 入口)dist/server/next-server.js(NextNodeServer 类)dist/server/app-render/app-render.js(最终的渲染入口)
按 F8 继续执行直到 server ready,然后 curl http://localhost:3001/,观察依次命中哪些断点。
Tip:dist 目录是 build 出来的 JS(不是 TS),但行号和源码大致对应;如果开启了 source map(
pnpm --filter=next build默认开),DevTools 会直接显示原始 TS。
步骤 4:加一个观察日志
不想用 debugger 也可以直接打 log。在 packages/next/src/server/app-render/app-render.tsx 文件最上面加:
console.log("[app-render] start at", new Date().toISOString());
然后 pnpm --filter=next build(如果开了 watch mode 就不用),跑请求就能看到日志。这是最朴素也最有效的源码学习手段。
7. 重难点小结
7.1 "Server" 这个词的歧义
源码里至少有 4 种用法:
| 名字(import as) | 实际类 | 文件 |
|---|---|---|
Server / BaseServer | abstract class Server | server/base-server.ts |
NextNodeServer | concrete class | server/next-server.ts |
DevServer | concrete class | server/dev/next-dev-server.ts |
NextServer(外部 API) | 封装类 | server/next.ts |
WebServer(Edge) | concrete class | server/web-server.ts |
每次看到 class XxxServer 都问自己一句:在哪个文件、继承自谁?
7.2 父进程 / 子进程 / worker 三层
父进程 next-dev (CLI)
├─ 子进程 next-server (主)
│ ├─ worker static-paths-worker (build 时)
│ ├─ worker render-server worker(部分配置下)
│ └─ worker use-cache-probe-worker(实验)
└─ 父进程发现子进程 crash → fork 重启
子进程是默认形态;某些配置(自定义 server、minimalMode)下渲染会进一步分到 worker 进程。
7.3 phase 这个隐藏参数
loadConfig(phase, ...) 的第一个参数:
PHASE_DEVELOPMENT_SERVERPHASE_PRODUCTION_SERVERPHASE_PRODUCTION_BUILDPHASE_EXPORTPHASE_TEST
next.config.js 可以导出函数 (phase) => config,根据 phase 返回不同配置。这是排查"我 dev 没事 build 失败"问题的重要线索。
8. 检验问题
next dev与next start共用哪几个源码文件?分叉发生在何处?- 为什么
next dev要 fork 子进程?next start为什么不 fork? BaseServer/NextNodeServer/DevServer三者继承关系?分别在哪个文件?requestHandler在start-server.ts里为什么先是一个 placeholder 函数,等什么时机被替换?loadConfig(phase, ...)的 phase 有几种?这与next.config.js导出函数有什么关系?- App Router 一次请求从 socket 到
app-render.tsx至少经过几层?哪一层负责路由匹配? - 怎么用最少的代码改动在请求最早进入 Next.js 时打一个
console.log?
9. 延伸阅读
packages/next/src/server/next.ts(对外暴露的NextServer封装类)packages/next/src/server/lib/render-server.ts(render-server initialize 逻辑)- 仓库
AGENTS.md的「Development Tips」与「NODE_ENVvs__NEXT_DEV_SERVER」
下一讲预告
第 04 讲|本地开发环境与测试体系:把 pnpm --filter=next dev 的 watch 模式、4 种测试矩阵、new-test 模板生成、NEXT_SKIP_ISOLATE 等开发工具吃透,为后续阶段提供可重现的实验环境。