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