Skip to content

第 16 章 · 面试题集(包管理与模块化)

Q1:npm、yarn、pnpm 三者有什么区别?

考察点:包管理器原理与选型

标准答案(适合背诵):

维度npmyarn (v1)pnpm
安装速度慢(早期串行,npm 7+ 改善)快(并行)最快(链接复用)
磁盘占用小(全局存储 + 硬链接)
依赖结构扁平化扁平化嵌套(避免幽灵依赖)
Monorepoworkspacesworkspaces原生最佳
lockfilepackage-lock.jsonyarn.lockpnpm-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 有什么区别?

考察点:模块化规范底层

标准答案

维度CommonJSES Modules
语法require / module.exportsimport / export
加载时机运行时动态加载编译时静态分析
加载方式同步异步
顶层 await
Tree Shaking✅(关键优势)
浏览器原生
this 指向module.exportsundefined
循环依赖返回当前已执行的 exportslive 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 的 importlive binding——导出的值变了,导入方拿到的也跟着变;CJS 是值的拷贝
  • Node 处理 .cjs 走 CJS、.mjs 走 ESM、.jspackage.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.0
  • 1.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 installnpm ci 的区别?

Q4:dependenciesdevDependencies 有什么区别?为什么还需要 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 标记为 optional
  • optionalDependencies:装失败也不报错(如某些平台特定的 native 模块)
  • 把 vite 误放进 dependencies 会让用户多下载 50MB+

追问

  • 如果一个库同时声明了 dependenciespeerDependencies 都包含 react 会怎样?
  • monorepo 里如何避免重复装 react?

Q5:lockfile 是什么?为什么必须提交到 git?

考察点:依赖锁定机制

标准答案

lockfile(package-lock.json / yarn.lock / pnpm-lock.yaml)记录了精确版本完整依赖树,包括:

  1. 你直接安装的包的精确版本
  2. 这些包的所有子依赖的精确版本
  3. 每个包的下载地址和完整性哈希(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 有三大优势:

  1. 多入口:一个包可以暴露多个子路径
  2. 条件导出:根据 ESM/CJS、开发/生产、浏览器/Node 自动选择不同文件
  3. 白名单封装:未在 exports 中列出的子路径禁止外部 import

优先级exports > main。一旦写了 exportsmain 失效。

通俗理解

  • 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:importrequiretypesbrowsernodedevelopmentproductiondefault
  • 顺序敏感:从上到下匹配,第一个命中即返回
  • 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 三步

  1. 根目录建 pnpm-workspace.yaml
  2. 子包放 packages/*apps/*
  3. 跨包引用用 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>。三大用途:

  1. 代码分割(Code Splitting):构建工具自动把动态 import 切成单独 chunk,减少首屏体积
  2. 按需加载:用到才下载(如点击按钮才加载图表库)
  3. 条件加载:根据运行时变量决定加载哪个模块(多语言、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 提升体验?