一、面试题:为什么要用 Monorepo 管理 Web、Node.js 中间层、Shared 和 Scripts?
核心思路(一句话)
Monorepo 的核心不是“代码放一个仓库”,而是把多个应用和共享包放进同一个依赖图,通过统一依赖管理、类型共享、构建编排、影响分析和缓存实现全站工程化。
解决方案架构图
Monorepo │ ┌──────────────┼──────────────┐ │ │ │ apps packages tools │ │ │ ┌────┴────┐ ┌────┴─────┐ │ │ │ │ │ │ web server shared config scripts │ │ │ │ │ ├── types │ │ ├── utils │ │ └── schemas │ │ │ └──────────────┐ │ │ └──────────────┬─────────┘ │ pnpm workspace │ ┌─────────┴─────────┐ │ │ 类型共享 依赖图 │ │ TypeScript affected analysis │ 增量构建 + 缓存典型目录:
repo/ ├── apps/ │ ├── web/ │ │ └── package.json │ └── server/ │ └── package.json │ ├── packages/ │ ├── shared/ │ │ ├── src/ │ │ └── package.json │ │ │ ├── browser/ │ │ └── package.json │ │ │ ├── node/ │ │ └── package.json │ │ │ └── config/ │ └── package.json │ ├── scripts/ │ └── ... │ ├── pnpm-workspace.yaml ├── package.json └── turbo.json二、面试题:Monorepo 和“单仓库多目录”有什么区别?
核心思路(一句话)
真正的 Monorepo 是“一个仓库 + 多个独立 Package + 明确依赖图 + 统一工程能力”,不是简单把多个项目放在一个 Git 仓库。
结构化理解
| 层次 | 普通单仓库 | Monorepo |
|---|---|---|
| Git | 一个仓库 | 一个仓库 |
| 项目 | 多目录 | 多个独立 Package |
| 依赖 | 容易混乱 | Package 间显式依赖 |
| 类型共享 | 手工复制/路径引用 | Workspace Package |
| 构建 | 通常全量 | 根据依赖图增量构建 |
| 缓存 | 项目自己处理 | Turborepo/Nx 等统一处理 |
| 影响分析 | 人工判断 | 根据依赖图计算 |
| CI | 容易全部执行 | affected + cache |
主要矛盾
不是: “代码有没有放到一个仓库?” 而是: “多个项目之间的依赖关系能不能被机器准确理解和调度?”这才是 Monorepo 工程化的核心。
三、面试题:pnpm Workspace 在 Monorepo 中解决什么问题?
核心思路(一句话)
pnpm Workspace 解决的是“多个 Package 如何统一管理依赖以及相互引用”的问题,而不是负责完整的增量构建系统。
流程图
pnpm-workspace.yaml ↓ 识别 workspace packages ↓ 每个目录拥有独立 package.json ↓ package A │ │ workspace: ↓ package B ↓ pnpm 建立 workspace 依赖关系 ↓ 形成项目依赖图例如:
# pnpm-workspace.yamlpackages:-"apps/*"-"packages/*"Web:
{"name":"@company/web","dependencies":{"@company/shared":"workspace:*"}}Server:
{"name":"@company/server","dependencies":{"@company/shared":"workspace:*"}}注意
workspace:*的作用主要是:
告诉包管理器: 这个依赖必须来自当前 Workspace 中的 Package。它不是:
自动解决 ESM/CommonJS 自动进行 TypeScript 编译 自动进行浏览器兼容 自动完成增量构建这些属于其他层次。
四、面试题:Web 和 Node.js 共享一个 Package,为什么容易出现 ESM/CommonJS 和 Node 原生模块问题?
核心思路(一句话)
真正的问题不是“共享代码”,而是同一个 Package 被两个运行环境消费:浏览器和 Node.js 对模块格式、运行时 API、构建方式的要求不同。
问题模型
@company/shared │ ┌────────┴────────┐ │ │ Browser Node.js │ │ ESM / Browser ESM / CJS │ │ 无 fs / path 等 可以使用 fs/path假设:
// shared/src/index.tsimportfsfrom"node:fs";exportfunctionreadConfig(){returnfs.readFileSync("./config.json","utf-8");}然后:
// web/src/index.tsimport{readConfig}from"@company/shared";问题:
Web ↓ shared ↓ fs ↓ Node.js 原生模块 ↓ 浏览器无法运行关键认知
TypeScript 类型共享 ≠ 运行时代码共享。
这是这类题非常重要的区分。
五、面试题:如何设计一个既能给浏览器又能给 Node.js 使用的 Shared Package?
核心思路(一句话)
优先从架构上隔离运行时能力,再通过exports条件导出让不同环境获得正确入口;不要单纯依赖构建工具“兜底”。
推荐:
packages/ ├── shared/ │ ├── types/ │ ├── schemas/ │ └── pure-utils/ │ ├── browser/ │ └── browser-only-utils/ │ └── node/ └── node-only-utils/架构:
Shared │ ┌────────────┼────────────┐ │ │ │ types pure-utils schemas │ │ │ └────── Browser + Node ───┘ Node-only │ fs / path / process Browser-only │ window / document这比:
一个 shared 包里面什么都放 ↓ 靠 Webpack/Vite 排除 fs更加稳定。
六、面试题:什么是package.json的exports条件导出?
核心思路(一句话)
exports可以根据消费者的解析条件,把同一个 Package 的不同入口暴露给不同运行环境。
例如:
{"name":"@company/utils","exports":{".":{"browser":"./dist/browser.js","node":"./dist/node.js","import":"./dist/index.mjs","require":"./dist/index.cjs","types":"./dist/index.d.ts","default":"./dist/index.mjs"}}}逻辑:
消费者 ↓ 解析 @company/utils ↓ exports ↓ 判断条件 │ ├── browser → browser.js │ ├── node → node.js │ ├── import → index.mjs │ ├── require → index.cjs │ └── types → index.d.ts但这里有一个非常重要的面试细节
exports条件由具体解析器/工具决定,不能简单理解成“浏览器自动选择 browser”。
例如:
Node.js Webpack Vite TypeScript Jest它们的解析条件可能不同。因此面试时最好说:
exports提供条件导出能力,最终选择哪个条件由 Node.js、打包器、TypeScript 等消费者的模块解析规则决定。
这比简单说“浏览器选择 browser,Node 选择 node”准确得多。
七、面试题:如果工具函数同时被前端和 Node.js 使用,应该怎么设计?
核心思路(一句话)
把“纯逻辑”和“环境能力”分离:纯逻辑共享,Node/Browser API 分层,再通过入口或条件导出连接。
推荐:
@company/utils │ ├── pure │ ├── formatDate │ ├── debounce │ └── validate │ ├── browser │ └── storage │ └── node ├── file └── process使用:
import{formatDate}from"@company/utils";Node:
import{readJson}from"@company/utils/node";Browser:
import{getStorage}from"@company/utils/browser";这样依赖边界非常清楚。
八、面试题:如何防止 Node.js 的fs被打进浏览器 Bundle?
核心思路(一句话)
第一优先级是让浏览器依赖图根本不要触达fs;external或排除配置只是兜底,不是架构解决方案。
正确优先级
第一层:架构隔离 ↓ Browser 不依赖 Node-only Package ↓ 第二层:exports 条件导出 ↓ Browser 解析 browser 入口 ↓ 第三层:构建工具 external / alias / exclude ↓ 防止错误依赖继续进入 Bundle例如 Vite/Rollup 场景:
// vite.config.tsimport{defineConfig}from"vite";exportdefaultdefineConfig({build:{rollupOptions:{// 这里只适用于明确知道某依赖不会由浏览器运行的情况。// 它不是解决 Node API 误进入浏览器依赖图的首选方案。external:["node:fs","node:path"]}}});更好的方式
不要:
web ↓ shared ↓ node-utils ↓ fs而应该:
web ↓ shared-browser ↓ 纯浏览器代码以及:
server ↓ shared-node ↓ node-utils ↓ fs九、面试题:Shared Package 为什么需要同时生成 ESM、CommonJS 和 TypeScript 类型声明?
核心思路(一句话)
ESM/CommonJS解决运行时模块兼容,.d.ts解决TypeScript类型消费,它们解决的是三个不同问题。
典型产物:
dist/ ├── index.mjs ├── index.cjs └── index.d.ts对应:
ESM消费者 ↓ index.mjs CommonJS消费者 ↓ index.cjs TypeScript ↓ index.d.tspackage.json
{"name":"@company/shared","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.cjs"}}}但要注意
如果整个项目:
Node.js ↓ 全部使用 ESM那么没有必要为了“看起来完整”强行生成 CommonJS。
是否需要双格式,要看:
消费者是谁? ↓ Node.js版本? ↓ 构建工具? ↓ 是否存在CommonJS消费者?十、面试题:如何实现一个完整的 Shared Package?
下面给一个比较适合面试讲解的最小完整方案。
目录
packages/shared/ ├── src/ │ ├── index.ts │ └── types.ts ├── tsconfig.json ├── package.json └── tsup.config.tssrc/types.ts
// 这个文件只保存类型定义。// 类型在 TypeScript 编译后不会产生 JavaScript 运行时代码,// 因此非常适合被 Web 和 Node.js 双方共享。exportinterfaceUser{id:string;name:string;}src/index.ts
// export type 明确表示这是纯类型导出。// TypeScript 编译后不会把 User 作为运行时代码打进 Bundle。exporttype{User};// 这是纯 JavaScript 逻辑。// 没有使用 fs、path、process、window、document 等环境相关 API。// 因此浏览器和 Node.js 都可以安全使用。exportfunctionformatUser(user:User):string{return`${user.id}:${user.name}`;}tsup.config.ts
import{defineConfig}from"tsup";exportdefaultdefineConfig({// 构建入口entry:["src/index.ts"],// 同时生成 ESM 和 CommonJS。// 实际项目是否需要两种格式,需要根据消费者决定。format:["esm","cjs"],// 生成 TypeScript 类型声明文件。dts:true,// 生产环境压缩。minify:true,// 清理旧的 dist。clean:true,// source map 方便调试。sourcemap:true});package.json
{"name":"@company/shared","version":"1.0.0","type":"module","main":"./dist/index.js","module":"./dist/index.mjs","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.mjs","require":"./dist/index.cjs"}},"scripts":{"build":"tsup"},"devDependencies":{"tsup":"^8.0.0","typescript":"^5.0.0"}}实际项目中应根据具体构建工具生成的文件名校正
exports路径,不能机械照抄。
十一、面试题:为什么“给 Shared 包配置 ESM + CommonJS 两套产物”仍然可能解决不了问题?
核心思路(一句话)
模块格式兼容和运行时环境兼容是两个问题。
例如:
shared ↓ index.mjs ↓ import fs from "node:fs"即使:
ESM ✔ CommonJS ✔浏览器仍然不能使用:
node:fs所以:
模块格式 + 运行时环境必须分别解决。
面试追问
“你生成了 ESM 和 CommonJS,是不是就能同时支持浏览器和 Node?”
正确回答:
不一定。ESM/CommonJS解决的是模块加载格式,而浏览器和Node.js的运行时能力不同。比如
fs属于Node.js运行时能力,即使把代码编译成ESM,浏览器仍然无法运行。因此我会优先拆分browser/node入口,再结合exports条件导出,最后才使用构建工具的external等配置作为兜底。
十二、面试题:Monorepo 中如何实现 CI 增量构建?
核心思路(一句话)
先通过 Git Diff 找到变化,再沿 Monorepo 依赖图向上计算受影响的 Package,最后只执行受影响任务,并结合缓存跳过已经完成的任务。
完整流程图
Git Push ↓ git diff ↓ 找到变化文件 ↓ 映射到 Package ↓ 计算依赖图 ↓ 找到受影响 Package ↓ ┌─────────────────────┐ │ affected packages │ │ │ │ web │ │ server │ │ shared │ └─────────────────────┘ ↓ 依赖拓扑排序 ↓ 检查任务缓存 ┌────┴────┐ ↓ ↓ Cache Hit Cache Miss ↓ ↓ 复用结果 执行构建 └────┬────┘ ↓ 测试 ↓ 部署十三、面试题:如果shared修改了,如何知道 Web 和 Server 都需要重新构建?
核心思路(一句话)
关键不是“看谁改了”,而是沿依赖图反向查找“谁依赖了它”。
假设:
web ──────┐ ↓ shared ↑ server ───┘修改:
shared影响:
shared ↓ web server所以:
shared changed ↓ affected(shared) ↓ shared + web + server十四、面试题:为什么不能只根据git diff判断构建哪些项目?
核心思路(一句话)
Git Diff只能告诉你“文件发生了变化”,依赖图才能告诉你“变化会影响谁”。
例如:
git diff ↓ packages/shared/src/types.ts不能简单得到:
只构建 shared因为:
web ───→ shared server ─→ shared真正需要:
shared ↓ 反向依赖 ↓ web + server因此:
Git Diff + Workspace Package Graph = Affected Analysis十五、面试题:Turborepo / Nx 在这里解决什么问题?
核心思路(一句话)
pnpm负责Workspace依赖管理,Turborepo/Nx负责任务编排、依赖拓扑、Affected分析和缓存;三者职责不同。
工具职责
Monorepo │ ┌───────────┼────────────┐ ↓ ↓ ↓ pnpm Turborepo Nx │ │ │ 依赖管理 任务编排 任务编排 workspace 依赖执行 影响分析 安装依赖 并行执行 缓存 本地缓存 远程缓存一个常见误区
不要说:
“pnpm负责Monorepo所有事情。”
更准确:
pnpm负责Workspace和依赖管理;Turborepo或Nx负责基于任务依赖图的构建编排、缓存和增量执行。
十六、面试题:什么是任务缓存?为什么能够把 CI 时间显著降低?
核心思路(一句话)
任务缓存的核心是:输入没有变化,输出就可以复用,不必重复执行构建。
流程
Package ↓ 源代码 + 配置 + 依赖 + 环境变量 ↓ 计算任务 Hash ↓ ┌─────────────┐ │ Cache Store │ └──────┬──────┘ │ ┌─────┴─────┐ ↓ ↓ Hash存在 Hash不存在 ↓ ↓ 恢复产物 执行任务 ↓ ↓ Cache Hit Cache Miss例如:
shared build Hash = ABC123 第一次: ABC123不存在 → build → 保存dist 第二次: ABC123存在 → 直接恢复dist → 不再build远程缓存
Developer A ↓ Build ↓ Remote Cache ↑ │ CI ↓ 发现相同 Hash ↓ 直接恢复构建结果这就是为什么团队规模变大以后,远程缓存价值非常高。
十七、面试题:如果修改的是 Shared 类型,影响分析应该怎么做?
这是这道题最值得深入回答的地方。
核心思路(一句话)
类型变化虽然可能不产生运行时代码,但它可能改变消费者的类型检查结果,因此不能简单认为“只改类型就不需要构建”。
例如:
// sharedexportinterfaceUser{id:string;}修改:
exportinterfaceUser{id:number;}依赖:
shared ├── web └── server那么:
shared type changed ↓ web type-check server type-check至少应该触发:
typecheck lint test至于:
production build是否一定执行,要看你的 CI 策略和任务依赖。
更成熟的任务模型
shared:typecheck ↓ web:typecheck server:typecheck shared:build ↓ web:build server:build可以把:
build test lint typecheck设计成不同任务,而不是所有变化都执行完整 Pipeline。
十八、面试题:如何处理“类型共享”和“运行时代码共享”?
核心思路(一句话)
类型共享和运行时共享应该分离设计,这是 Monorepo 中非常重要的架构边界。
推荐:
packages/ │ ├── types/ │ └── 纯TypeScript类型 │ ├── schemas/ │ └── 运行时Schema │ ├── utils/ │ └── 纯JS逻辑 │ ├── browser/ │ └── 浏览器API │ └── node/ └── Node.js API为什么 Schema 很重要?
例如前后端共享:
interfaceCreateUserRequest{name:string;}这个类型:
TypeScript编译后不存在运行时无法验证:
HTTP 请求到底是不是合法数据?可以使用:
Zod设计:
shared schema │ ┌──────────┴──────────┐ ↓ ↓ Browser Node.js │ │ 类型推导 请求校验 │ │ └──────────┬──────────┘ ↓ 同一份协议定义这比只共享.d.ts更完整。
十九、面试题:如果让你设计一个 AI 前端 + Node.js BFF 的 Monorepo,你会怎么设计?
核心思路(一句话)
按照“应用层—共享协议层—运行时能力层—工程工具层”分层,保证浏览器和 Node.js 的依赖边界清晰。
推荐架构
repo │ ├── apps │ ├── ai-web │ │ └── 浏览器应用 │ │ │ └── ai-server │ └── Node.js BFF │ ├── packages │ │ │ ├── contracts │ │ ├── types │ │ └── schemas │ │ │ ├── utils │ │ └── 纯逻辑 │ │ │ ├── browser │ │ └── 浏览器能力 │ │ │ ├── node │ │ └── Node.js能力 │ │ │ └── config │ └── ESLint / TypeScript等配置 │ └── scripts └── 构建、部署、代码生成依赖方向:
contracts ↙ ↘ ai-web ai-server ↓ ↓ browser node \ / utils严格避免:
ai-web ↓ ai-server ↓ node/fs以及:
contracts ↓ fs / path因为contracts应该尽可能成为跨运行环境的最底层共享协议层。
二十、这道题真正考什么?
主要矛盾
不是会不会写pnpm-workspace.yaml,而是能不能建立“多 Package + 多运行环境 + 依赖图 + 增量构建”的完整工程模型。
次要矛盾
① ESM / CommonJS ② exports 条件导出 ③ TypeScript 类型声明 ④ Browser / Node Runtime 隔离 ⑤ affected analysis ⑥ 本地/远程缓存 ⑦ CI 任务编排把它们串起来:
Monorepo │ ┌─────────┴─────────┐ ↓ ↓ Package Runtime 依赖图 环境边界 │ │ ↓ ↓ affected analysis exports │ │ ↓ ↓ 增量构建/测试 ESM/CommonJS │ │ └─────────┬─────────┘ ↓ Cache ↓ CI二十一、面试官继续追问:如果你只能选择一个方案,你怎么回答?
不要直接开始讲工具。
应该按照:
问题 ↓ 原因 ↓ 架构 ↓ 工具 ↓ 异常场景 ↓ 优化回答。
例如:
我不会把 Monorepo 简单理解成一个 Git 仓库。这个场景真正的问题是 Web 和 Node.js 处于不同运行环境,共享 Package 同时涉及模块格式、运行时依赖和类型共享,所以我首先会划清依赖边界。
Web 和 Node.js 都依赖纯类型、Schema 和纯工具函数;Node-only 的
fs、path等能力单独放到 Node Package,Browser-only 能力单独放到 Browser Package。对于确实需要同时支持不同模块消费者的 Package,再通过exports提供import、require、types等条件入口。Package 管理使用 pnpm Workspace,任务编排使用 Turborepo 或 Nx。CI 不根据 Git Diff 简单决定构建范围,而是先定位变化 Package,再沿依赖图计算 affected Package,最后结合任务缓存,只执行受影响且没有缓存的任务。
所以这套方案解决的其实是四个问题:运行环境隔离、模块格式兼容、依赖影响分析、增量构建缓存。
这个回答已经覆盖了整道题的核心。
二十二、最终「满分答案」——能背下来 + 能展开讲 + 能应对追问
面试题
如何使用 Monorepo 管理 AI 前端、Node.js 中间层、共享类型和工具脚本?
核心思路(一句话)
Monorepo 的核心不是“一仓多项目”,而是利用 Package 依赖图解决共享、运行时隔离、条件导出、影响分析和增量构建。
架构图
Monorepo │ ┌───────────────────┼───────────────────┐ ↓ ↓ ↓ apps packages scripts │ │ ┌────┴────┐ ┌──────┼────────┐ ↓ ↓ ↓ ↓ ↓ web server types utils node/browser │ │ │ │ └────┬────┴───────┴──────┘ ↓ Workspace ↓ Package Graph ↓ Git Diff → Affected Analysis ↓ 依赖拓扑排序 ↓ Cache Hit / Miss ↙ ↘ 复用结果 执行任务第一层:Package 划分
apps/web apps/server packages/types packages/utils packages/browser packages/node packages/config scripts/不要让一个shared包同时包含浏览器代码和 Node.js 的fs、path。
第二层:模块和运行时兼容
exports │ ├── types → .d.ts ├── import → ESM ├── require → CommonJS ├── browser → Browser入口 └── node → Node入口ESM/CommonJS解决模块格式;Browser/Node入口解决运行时环境。两者不能混为一谈。
第三层:依赖管理
pnpm Workspace ↓ workspace:* ↓ Package依赖图pnpm负责:
Workspace 依赖安装 Package引用Turborepo/Nx负责:
任务依赖 Affected 并行执行 缓存第四层:CI 增量构建
Git Diff ↓ 变化 Package ↓ 依赖图反向追踪 ↓ Affected Package ↓ typecheck / test / build ↓ 检查缓存 ↓ Cache Hit → 直接复用 Cache Miss → 执行并缓存例如:
shared 修改 ↓ shared ├──→ web └──→ server ↓ shared + web + server最后一针见血
普通开发者回答的是“pnpm Workspace 怎么配”;高级前端回答的是“不同运行环境如何隔离”;真正的工程化回答是“如何建立 Package 依赖图,并基于依赖图完成条件导出、影响分析、增量构建和缓存”。
这道题真正考的不是 Monorepo API,而是你能不能把“代码组织 → 模块解析 → 运行时边界 → 依赖图 → CI 增量构建”串成一套完整工程体系。