主题
第 16 章 · 面试题集(包管理与模块化)
Q1:npm、yarn、pnpm 三者有什么区别?
考察点:包管理器原理与选型
标准答案(适合背诵):
| 维度 | npm | yarn (v1) | pnpm |
|---|---|---|---|
| 安装速度 | 慢(早期串行,npm 7+ 改善) | 快(并行) | 最快(链接复用) |
| 磁盘占用 | 大 | 大 | 小(全局存储 + 硬链接) |
| 依赖结构 | 扁平化 | 扁平化 | 嵌套(避免幽灵依赖) |
| Monorepo | workspaces | workspaces | 原生最佳 |
| lockfile | package-lock.json | yarn.lock | pnpm-lock.yaml |
核心区别在于 pnpm 用硬链接 + 嵌套 解决了传统包管理器的两大顽疾:磁盘浪费和幽灵依赖。
通俗理解(生活类比):
把 npm 比作"每户人家自己屯一套餐具",pnpm 是"小区共用一个餐具仓库,每户挂个钥匙"。十户人家原本要 10 套餐具,现在只要 1 套加 10 把钥匙,省钱(磁盘)又快(不用搬运)。
代码示例:
bash
# 同一个项目分别用三种方式安装
npm install lodash # node_modules/lodash 真实文件
yarn add lodash # node_modules/lodash 真实文件
pnpm add lodash # node_modules/lodash 是符号链接 → ~/.pnpm-store/.../lodash加分回答(拉开差距):
- pnpm 的硬链接(hard link)和符号链接(symlink)混用:内容文件是硬链接(节省空间),目录是 symlink(保持隔离)
- pnpm 默认禁止"幽灵依赖",使代码更可靠
- npm 7+ 已支持 workspaces,差距正在缩小,但 pnpm 仍领先在性能与严格性
- 如果项目用 React Native / Electron,需注意 pnpm 的符号链接结构有时会触发兼容性问题,需要
shamefully-hoist=true
追问:
- pnpm 怎么处理
peerDependencies的多版本冲突? - 用 yarn berry(v2+)的 PnP 模式有什么优劣?
Q2:CommonJS 和 ES Modules 有什么区别?
考察点:模块化规范底层
标准答案:
| 维度 | CommonJS | ES Modules |
|---|---|---|
| 语法 | require / module.exports | import / export |
| 加载时机 | 运行时动态加载 | 编译时静态分析 |
| 加载方式 | 同步 | 异步 |
| 顶层 await | ❌ | ✅ |
| Tree Shaking | ❌ | ✅(关键优势) |
| 浏览器原生 | ❌ | ✅ |
this 指向 | module.exports | undefined |
| 循环依赖 | 返回当前已执行的 exports | live binding(绑定地址) |
通俗理解(生活类比):
CommonJS 像"做饭时缺什么去隔壁借什么"——临时跑一趟、用完拿回来(同步阻塞)。
ESM 像"开餐前先列好所有食材清单,统一去市场采购"——所有依赖编译时就规划好(静态分析),上桌时按顺序摆好。
代码示例:
js
// CJS:可以放在条件里、也可以拼字符串
if (env === 'prod') {
const log = require('./logger-' + env);
}
// ESM:import 必须在文件顶层(静态)
import log from './logger-prod.js';
// ESM 想动态加载,用 import() 函数
const log = await import(`./logger-${env}.js`);加分回答:
- 静态分析是 Tree Shaking 的前提:只有编译期就能知道"用了什么、没用什么",构建工具才能裁掉死代码
- ESM 的
import是 live binding——导出的值变了,导入方拿到的也跟着变;CJS 是值的拷贝 - Node 处理
.cjs走 CJS、.mjs走 ESM、.js看package.json的"type"字段 - ESM 顶层 await 让动态配置加载更优雅,但会延迟整个模块图
追问:
- ESM 如何在 Node 中 require 一个 CJS 模块?反之呢?
- 为什么 Tree Shaking 对 CJS 不友好?
Q3:package.json 里 ^ 和 ~ 有什么区别?
考察点:语义化版本号(SemVer)
标准答案:
版本号格式:MAJOR.MINOR.PATCH(主.次.补丁)
^1.2.3→ 锁定 MAJOR,允许 MINOR + PATCH 升级,范围>=1.2.3 <2.0.0~1.2.3→ 锁定 MAJOR + MINOR,只允许 PATCH 升级,范围>=1.2.3 <1.3.01.2.3→ 精确锁定*→ 任意版本(极不推荐)
特殊规则:当 MAJOR 为 0 时(例如 ^0.5.0),^ 退化为类似 ~ 的行为,因为 0.x 视为 API 不稳定。
通俗理解:
把 1.2.3 当成"商品 → 型号 → 批次":
^= 同一商品,允许升级到新型号(功能更多但兼容)~= 同一商品同一型号,只允许换新批次(修了 bug)
代码示例:
json
{
"dependencies": {
"react": "^18.2.0", // 18.x.x 任意版本,但不会到 19
"axios": "~1.6.2", // 1.6.x,不会跳到 1.7
"lodash": "4.17.21" // 严格 4.17.21
}
}加分回答:
- 库作者必须严格遵守 SemVer,破坏性变更必须升 MAJOR
- 配合 lockfile,
^在生产环境也是安全的(lockfile 会锁住实际版本) npm update才会按^范围去更新;npm install已有 lockfile 时不会乱升- 关键依赖(如银行交易、支付)建议精确锁定,避免任何意外升级
- 用
npm-check-updates(ncu)工具可以批量检查可升级的依赖
追问:
- 如果两个依赖对同一个包要求不同版本(A 要 react@17,B 要 react@18)会怎样?
npm install和npm ci的区别?
Q4:dependencies 和 devDependencies 有什么区别?为什么还需要 peerDependencies?
考察点:依赖类型分类
标准答案:
| 类型 | 含义 | 谁会安装 | 典型例子 |
|---|---|---|---|
dependencies | 生产依赖 | 别人 npm install 你的包会装 | react、axios、lodash |
devDependencies | 开发依赖 | 别人 npm install 你的包不会装 | vite、eslint、vitest |
peerDependencies | 同行依赖 | 别人 npm install 你的包会警告让用户自己装 | 写组件库时声明 react |
为什么需要 peerDependencies:
避免"宿主项目 + 你的库"装出两份相同的依赖。例如你写一个 React 组件库,如果把 react 写进 dependencies,用户项目里就会出现两份 React,导致 hooks 错误("Invalid Hook Call")。
通俗理解:
dependencies= 做菜要用的食材(盐、油),客人吃到的菜里都包含devDependencies= 厨房工具(菜刀、灶台),客人不会带走peerDependencies= 餐厅里要求客人自带的酒水,"我能配你的酒,但你得自己带"
代码示例:
json
// 一个 React 组件库的 package.json
{
"name": "my-react-ui",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
},
"dependencies": {
"classnames": "^2.3.0"
},
"devDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vite": "^5.0.0"
}
}加分回答:
- npm 7+ 起会自动安装
peerDependencies(除非用户已装),减少了用户的负担 peerDependenciesMeta可以把 peer 标记为 optionaloptionalDependencies:装失败也不报错(如某些平台特定的 native 模块)- 把 vite 误放进 dependencies 会让用户多下载 50MB+
追问:
- 如果一个库同时声明了
dependencies和peerDependencies都包含 react 会怎样? - monorepo 里如何避免重复装 react?
Q5:lockfile 是什么?为什么必须提交到 git?
考察点:依赖锁定机制
标准答案:
lockfile(package-lock.json / yarn.lock / pnpm-lock.yaml)记录了精确版本和完整依赖树,包括:
- 你直接安装的包的精确版本
- 这些包的所有子依赖的精确版本
- 每个包的下载地址和完整性哈希(integrity)
作用:
- 可复现构建:任何人在任何机器上
npm ci都装到完全一样的版本树 - 安全性:integrity 哈希防止包内容被篡改
- CI 友好:CI 用
npm ci(严格按 lockfile)比npm install更快更稳
通俗理解:
package.json 像菜单("我要冰美式"),lockfile 像服务员复述并打印的小票("冰美式、不加糖、特浓、半杯冰、12 元")。下次换一家店,把小票递过去做出一模一样的咖啡。
代码示例:
bash
# 开发时常用
pnpm install # 会更新 lockfile(在 ^ 范围内取最新)
# CI/生产部署
pnpm install --frozen-lockfile # 严格按 lockfile,不一致就报错
npm ci # 等价命令,比 install 快加分回答:
npm ci会先删node_modules再装,保证干净- 多人合并 lockfile 冲突时,建议删了重新生成而不是手动改
- lockfile 中的
integrity字段是 SHA-512 校验和,能检测中间人攻击 - monorepo 里只在根目录有一个 lockfile,子包不要单独装
- pnpm 的 lockfile 是 YAML 格式,比 npm 的 JSON 更可读
追问:
- 如果 git 上没有 lockfile,新人 clone 项目首次 install 后会怎样?
- lockfile 冲突最佳解决姿势?
Q6:什么是 Tree Shaking?为什么 ESM 才能 Tree Shake,CJS 不行?
考察点:模块系统与构建优化
标准答案:
Tree Shaking = 基于静态分析,从 bundle 中移除未使用的代码(dead code elimination)。"摇树"——把没用的叶子摇掉。
为什么 ESM 能、CJS 不能:
ESM 的 import/export 是静态的:
- 必须出现在文件顶层
- 模块名必须是字符串字面量(不能拼变量)
- 编译期就能确定"导出什么、导入了什么"
CJS 的 require 是动态的:
- 可以放在 if 里、for 里、函数里
- 可以
require(变量) module.exports = ...可以是任意表达式(甚至运行时才确定)
构建工具(Rollup/Vite/Webpack)只能对静态结构做可靠的"未使用判定",所以 Tree Shaking 是 ESM 独有的优势。
通俗理解:
ESM 像"图书馆借书登记表"——每本书谁借了一目了然,没人借的能直接撤下书架。
CJS 像"图书馆里的人随便拿书也不登记"——管理员根本不敢撤任何书,万一有人正在用呢?
代码示例:
js
// utils.js (ESM)
export function used() { return 1; }
export function unused() { return 2; } // 没人用
export function alsoUnused() { return 3; }
// main.js
import { used } from './utils.js';
console.log(used());
// 打包后的 bundle 里只有 used(),unused/alsoUnused 被摇掉加分回答:
- 库作者要在
package.json加"sideEffects": false告诉打包器"我这个包没有副作用,可以放心摇" - 引用方式很重要:
import _ from 'lodash'(默认导出整个对象)摇不掉,要import { debounce } from 'lodash-es'(命名导出) - 副作用代码(
polyfill.js、CSS import)要在sideEffects数组里列出 - 仅静态分析仍可能漏摇(class、动态 key 访问),打包器会保守处理
追问:
- 如何判断一个 npm 包是否支持 Tree Shaking?
- 为什么 lodash 推荐用 lodash-es 而不是 lodash?
Q7:package.json 里的 exports 字段做什么用?和 main 有什么区别?
考察点:现代包入口设计
标准答案:
exports(Node 12+)是新一代包入口字段,相比 main 有三大优势:
- 多入口:一个包可以暴露多个子路径
- 条件导出:根据 ESM/CJS、开发/生产、浏览器/Node 自动选择不同文件
- 白名单封装:未在
exports中列出的子路径禁止外部 import
优先级:exports > main。一旦写了 exports,main 失效。
通俗理解:
main像"小卖部只有一个门"——所有人都从同一个门进exports像"商场分多个入口(生鲜区、家电区、儿童区)",且非营业入口直接锁起来不让进
代码示例:
json
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
}
}js
import lib from 'my-lib'; // → dist/index.mjs
import { x } from 'my-lib/utils'; // → dist/utils.mjs
const lib = require('my-lib'); // → dist/index.cjs
import 'my-lib/internal/secret'; // ❌ 报错,未在 exports 中加分回答:
- 条件 keys:
import、require、types、browser、node、development、production、default - 顺序敏感:从上到下匹配,第一个命中即返回
types必须放最前,TypeScript 才能正确解析类型package.json子路径默认禁止访问,需要手动暴露"./package.json": "./package.json"- 使用
@arethetypeswrong/cli工具检查包的 exports 配置是否正确
追问:
- TypeScript 的
moduleResolution: "bundler"与nodenext在解析 exports 上有什么差异? - 如何同时给浏览器和 Node 提供不同的实现?
Q8:什么是 Monorepo?pnpm workspace 怎么用?
考察点:现代项目组织
标准答案:
Monorepo = 一个 Git 仓库管理多个 npm 包。对应的是 Multirepo(每个包一个仓库)。
好处:
- 共享配置(ESLint、TS、CI)
- 跨包修改一次 commit
- 内部包联调不用
npm link - 统一版本管理
典型实践者:Vue(vuejs/core)、React、Babel、Next.js、shadcn/ui
pnpm workspace 三步:
- 根目录建
pnpm-workspace.yaml - 子包放
packages/*或apps/* - 跨包引用用
workspace:*协议
通俗理解:
Monorepo 像"一座综合体大厦"——餐饮、零售、办公在同一栋楼,水电网络共享,物业一站式。
Multirepo 像"分散在城市各处的独立店铺"——每家自己装修、自己管理,沟通成本高。
代码示例:
yaml
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'my-monorepo/
├── packages/
│ ├── ui/ (name: @my/ui)
│ └── utils/ (name: @my/utils)
└── apps/
└── web/json
// apps/web/package.json
{
"dependencies": {
"@my/ui": "workspace:*",
"@my/utils": "workspace:*"
}
}bash
pnpm install # 一次性装所有子包依赖
pnpm --filter @my/web dev # 只跑 web 应用
pnpm -r build # 递归对所有包跑 build
pnpm add lodash --filter @my/utils # 只给 utils 装 lodash加分回答:
- 配合 Turborepo 做任务编排:
turbo run build自动按依赖顺序、并行、缓存 - 配合 changesets 做版本管理与发布
- pnpm 的硬链接 + 嵌套结构是 Monorepo 的最佳搭配
workspace:^表示按 semver^范围(发布时会被替换成实际版本)- 大型 monorepo(如 Google)会用 Bazel 等更专业的构建系统
追问:
- Turborepo 的远程缓存是怎么工作的?
- pnpm workspace 和 yarn workspace 的差异?
Q9(加餐):动态导入 import() 有什么用?
考察点:代码分割与按需加载
标准答案:
import() 是 ESM 的动态导入语法,返回一个 Promise<Module>。三大用途:
- 代码分割(Code Splitting):构建工具自动把动态 import 切成单独 chunk,减少首屏体积
- 按需加载:用到才下载(如点击按钮才加载图表库)
- 条件加载:根据运行时变量决定加载哪个模块(多语言、A/B 测试)
通俗理解:
普通 import 像"早餐自助"——所有菜一开门全摆出来。
动态 import() 像"火锅点单"——客人想吃啥点啥,厨房现做。
代码示例:
js
// React 路由懒加载
const Dashboard = React.lazy(() => import('./Dashboard'));
// 按需加载重型库(图表只在分析页用)
button.onclick = async () => {
const { Chart } = await import('chart.js');
new Chart(...);
};
// 多语言按需加载
const lang = navigator.language;
const messages = await import(`./locales/${lang}.json`);加分回答:
- Webpack 的 magic comment 可控制 chunk 名:
import(/* webpackChunkName: "chart" */ 'chart.js') - Vite 自动按
import()切分 chunk,无需配置 - 动态 import 的路径要让打包器能"看出来"是个模式(变量必须是字面量拼接,不能完全任意)
- React
Suspense+lazy+import()是现代懒加载标准组合 - HTTP/2 多路复用让"多个小 chunk"成本更低,鼓励细粒度切分
追问:
- 动态 import 与 webpack 的
require.ensure有什么关系? - 如何 prefetch 动态 chunk 提升体验?