
一、面试题为什么要用 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 仓库。结构化理解层次普通单仓库MonorepoGit一个仓库一个仓库项目多目录多个独立 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.tsimportfsfromnode:fs;exportfunctionreadConfig(){returnfs.readFileSync(./config.json,utf-8);}然后// web/src/index.tsimport{readConfig}fromcompany/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 等消费者的模块解析规则决定。这比简单说“浏览器选择 browserNode 选择 node”准确得多。七、面试题如果工具函数同时被前端和 Node.js 使用应该怎么设计核心思路一句话把“纯逻辑”和“环境能力”分离纯逻辑共享Node/Browser API 分层再通过入口或条件导出连接。推荐company/utils │ ├── pure │ ├── formatDate │ ├── debounce │ └── validate │ ├── browser │ └── storage │ └── node ├── file └── process使用import{formatDate}fromcompany/utils;Nodeimport{readJson}fromcompany/utils/node;Browserimport{getStorage}fromcompany/utils/browser;这样依赖边界非常清楚。八、面试题如何防止 Node.js 的fs被打进浏览器 Bundle核心思路一句话第一优先级是让浏览器依赖图根本不要触达fsexternal或排除配置只是兜底不是架构解决方案。正确优先级第一层架构隔离 ↓ Browser 不依赖 Node-only Package ↓ 第二层exports 条件导出 ↓ Browser 解析 browser 入口 ↓ 第三层构建工具 external / alias / exclude ↓ 防止错误依赖继续进入 Bundle例如 Vite/Rollup 场景// vite.config.tsimport{defineConfig}fromvite;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.tsimport{defineConfig}fromtsup;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 PackageBrowser-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 增量构建”串成一套完整工程体系。