跳到主要内容

前端工程化热门面试题

使用说明

本篇共 60 道题。

工程化面试重视体系和落地:为什么建、解决谁的问题、如何渐进推广、怎样度量。回答要避免只罗列工具名称。

Q1: 你如何理解前端工程化?

答案

工程化是用规范、工具、平台和流程把开发、测试、构建、发布、监控和协作变得可重复、可度量、可治理。

目标不是“工具越多越专业”,而是降低交付成本和风险,让团队在规模扩大后仍能稳定迭代。

  • 工程化覆盖从本地开发到生产反馈的完整链路:包管理、构建、规范、测试、CI/CD、发布、监控和依赖治理。
  • 目标不是工具越多越好,而是用标准化和自动化降低交付周期、故障率与协作成本,并能用指标证明收益。

Q2: ESLint 和 Prettier 怎么分工?

答案

ESLint 和 Prettier 的定位完全不同:

维度ESLintPrettier
核心职责代码质量检查(逻辑错误、最佳实践)代码格式化(外观统一)
典型规则no-unused-varsno-implicit-coercionreact-hooks/exhaustive-deps缩进、引号、分号、换行、尾逗号
自动修复部分规则支持 --fix100% 可自动修复
配置哲学高度可配置,数百条规则强约定,极少配置项

为什么要同时使用:

  • ESLint 擅长发现代码质量问题(如未使用的变量、错误的 Hooks 使用方式、潜在的类型问题),这些 Prettier 无法处理。
  • Prettier 擅长统一代码外观,且支持 CSS、JSON、Markdown 等多种语言,覆盖面比 ESLint 的格式规则更广。
  • 单独使用 ESLint 做格式化会导致大量格式规则需要配置和维护;单独使用 Prettier 又无法发现逻辑问题。两者配合是最佳方案。

解决冲突的方法:

安装 eslint-config-prettier,它会关闭 ESLint 中所有与 Prettier 冲突的格式规则:

eslint.config.mjs
import eslint from "@eslint/js";
import prettierConfig from "eslint-config-prettier";
import tseslint from "typescript-eslint";

export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
prettierConfig, // 必须放在最后
);

这样 ESLint 只管质量,Prettier 只管格式,各司其职,互不冲突。

Q3: 如何设计前端测试金字塔?

答案

大量快速单元测试覆盖纯逻辑和边界,适量组件/集成测试覆盖模块协作,少量 E2E 覆盖关键用户链路。

比例不是固定数字。越接近真实环境越可信但越慢、越脆,应把每类风险放到成本最低且足够真实的测试层。

  • 单元测试覆盖纯逻辑和边界,组件/集成测试覆盖模块协作,少量 E2E 验证登录、支付等关键用户路径。
  • 数量应呈金字塔分布,但不是固定比例;把测试放在最便宜且能建立信心的层级,避免所有问题都依赖慢而脆的 E2E。

Q4: 什么样的测试才有价值?

答案

测试应围绕可观察行为和业务风险,在错误实现上能失败,失败信息可定位,并且维护成本可接受。

避免测试内部实现细节、复制生产逻辑和大量无意义快照。线上事故和回归问题应转化为长期测试样本。

  • 有价值的测试验证用户可观察行为和业务不变量,失败时能说明真实回归,而不是绑定内部实现。
  • 测试应稳定、可读、执行快,并覆盖风险最高的路径和边界;单纯追求覆盖率容易产生大量无断言或低价值用例。

Q5: 一条合理的 CI/CD 流水线包含什么?

答案

代码检查、类型与测试、构建和安全扫描生成不可变产物,再按环境部署、执行冒烟/健康检查、渐进放量并监控,失败可回滚。

流水线要缓存和并行以缩短反馈,但门禁顺序应先快后慢。部署权限、审批和审计按风险分级。

  • 常见阶段是安装与缓存、Lint/类型检查、单元与集成测试、构建、安全扫描、预览环境、E2E、发布和部署后验证。
  • 每个产物只构建一次并逐环境晋级;生产发布配合审批、灰度、健康检查和自动回滚,避免在发布机临时重新打包。

Q6: 如何提高 CI 速度?

答案

先量每个 Job 和步骤,再做依赖缓存、任务并行、受影响范围计算、构建产物复用和测试分片。

缓存键要可靠,不能用过期结果换速度。关键全量检查可在合并队列或夜间补充,但主分支必须保持可发布。

  • 先测量各阶段耗时,再通过依赖缓存、任务缓存、按变更范围执行和并行矩阵缩短关键路径。
  • 缓存键必须包含锁文件、工具版本与配置;不稳定测试要修复或隔离,不能靠无限重试掩盖。还要控制并发避免把 Runner 或测试环境打满。

Q7: Monorepo 的价值和代价是什么?

答案

价值是共享代码、统一工具链、原子跨包修改和可见依赖图;代价是仓库规模、权限、CI、版本发布和工具复杂度。

需要配套 Workspace、任务缓存、边界规则、Owner 和受影响构建。把多个仓库简单搬进一个目录不等于治理完成。

  • Monorepo 统一依赖、规范和原子提交,适合共享组件与多应用协作;代价是仓库体积、权限边界、CI 调度和工具复杂度。
  • 是否采用取决于团队协作关系,不是项目数量。落地需要明确包边界、版本策略、任务缓存与受影响项目计算。

Q8: Monorepo 中如何防止模块随意依赖?

答案

定义层级和公开入口,用 ESLint/构建图限制跨层与深层路径导入,包通过 exports 暴露稳定 API,并由 CODEOWNERS 审查关键边界。

依赖循环应在 CI 检测。边界规则要与团队职责一致,否则只会被不断绕过。

  • 用 package exports 和目录边界只暴露公共 API,再通过 ESLint、依赖图或 Nx/Turborepo 规则禁止跨层和反向依赖。
  • CI 检查循环依赖与未声明依赖;架构规则应少而稳定,并提供正确依赖路径,不能只靠文档提醒。

Q9: 微前端解决什么问题?

答案

它主要解决大型组织中前端应用的独立开发、发布和技术治理,而不是单纯把页面拆成组件。

收益来自团队自治,代价是运行时、依赖、路由、样式、状态、监控和用户体验的协调。小团队单应用通常不需要。

  • 微前端解决的是多个团队对大型前端按业务域独立开发、发布和演进的问题,不只是把页面拆成 iframe。
  • 只有组织和发布边界确实独立时收益才明显;小团队使用会额外承担路由、样式、依赖、通信、认证和观测整合成本。

Q10: 微前端有哪些集成方式?

答案

常见有构建时包集成、运行时 JavaScript/Module Federation、iframe 隔离和 Web Components 等。

选择看发布独立性、隔离强度、性能、通信和框架兼容。iframe 隔离强但体验与通信成本高;运行时集成顺滑但治理复杂。

Q11: 前端监控体系应该采集什么?

答案

错误、性能、资源和请求、用户行为与发布版本,并通过会话/Trace ID 关联。指标要能回答影响了谁、从哪个版本开始、根因在哪。

采集要限量、脱敏和采样,不能把完整用户输入、Token 或响应全量上报。

  • 采集 JS/资源/接口错误、白屏、Core Web Vitals、关键行为和发布版本,同时关联用户、页面、设备、Trace ID。
  • 上报要采样、限流、脱敏并离线缓冲;告警按影响用户数和 SLO 聚合,避免每个错误都发通知造成告警疲劳。

Q12: Source Map 在线上监控中怎么使用?

答案

构建生成 Source Map,发布时与版本/产物关联上传到错误平台,客户端只上报压缩栈,平台还原源码位置。

通常不公开 Source Map;上传成功、版本唯一性和保留策略要纳入发布验证。

  • 构建时生成并把 Source Map 与 release 版本上传到监控平台,线上只上报压缩代码位置和版本,由平台私下符号化。
  • Map 不应随静态资源公开发布;必须校验产物与 Map 完全对应,并在上传后按保留期清理,防止源码泄露。

Q13: 组件库建设的核心是什么?

答案

不是组件数量,而是稳定的设计 Token、可组合 API、可访问性、主题、文档、测试、版本和升级机制。

先从高频基础组件和真实业务共性开始,明确哪些是无业务组件、哪些是领域组件,避免做成无法演进的大而全平台。

  • 组件库的核心是设计 Token、可访问性、稳定 API、主题能力、文档和发布治理,而不只是堆组件数量。
  • 组件要覆盖交互状态和组合边界,并通过视觉回归、单测和真实业务试用验证;破坏性变更必须有迁移指南和版本策略。

Q14: 如何管理多环境配置?

答案

构建时配置和运行时配置分开;公开前端配置可以注入,Secrets 留在服务端或密钥系统。每个环境使用同一构建产物更利于一致性。

配置要有 Schema、默认值、版本和启动校验,不能依靠散落的环境判断。

  • 环境差异分为构建时公开配置、运行时业务配置和 Secrets。公开配置可随前端部署,运行时配置通过配置文件或接口加载,Secrets 只留服务端。
  • 每项配置要有 Schema、默认值和校验,发布时记录实际版本;避免用大量条件分支让不同环境执行完全不同代码。

Q15: Docker 对前端工程有什么价值?

答案

它统一构建与运行环境,便于缓存、CI 和部署。多阶段构建可把依赖和编译留在构建层,运行镜像只保留静态服务器或 Node 产物。

镜像要固定基础版本、非 root 运行、最小化文件和扫描漏洞;不要把 Secrets 烘焙进镜像层。

  • Docker 为 Node/SSR 服务提供一致运行环境,也可用多阶段构建前端静态产物,再由轻量 Web Server 镜像托管。
  • 镜像应固定基础版本、使用非 root 用户、只复制必要文件并做漏洞扫描;纯静态站点不一定需要在每个实例中保留 Node。

Q16: Kubernetes 中前端/Node 服务要关注什么?

答案

资源请求与限制、就绪/存活探针、滚动更新、优雅停机、配置/Secret、日志和水平扩容。

静态站点未必需要复杂 K8s;选择应看组织平台和运维成本。Node 服务还要让终止宽限期覆盖请求排空。

  • 前端静态资源通常交给对象存储/CDN;Node 服务则要配置 requests/limits、readiness/liveness、优雅停机、滚动发布和水平扩缩。
  • 配置与 Secret 分离,日志写标准输出,并确保探针、负载均衡超时和应用超时一致,避免发布期间流量打到未就绪实例。

Q17: Git 工作流怎么选?

答案

这是一道开放性题目,建议从以下几个层面组织回答:

1. 分支策略

根据项目特点选择合适的工作流。Web 项目通常选择 GitHub Flow,从 main 拉出 feature 分支,通过 PR 合并:

# 分支命名规范
feat/user-auth # 新功能
fix/login-redirect # Bug 修复
refactor/api-client # 重构
chore/update-deps # 依赖更新
docs/api-guide # 文档

2. 提交规范

使用 Conventional Commits + commitlint + husky 强制校验:

提交规范工具链
// 完整工具链
const toolchain = {
commitlint: '校验 commit message 格式',
husky: 'Git hooks 管理,commit-msg 钩子触发 commitlint',
'lint-staged': 'pre-commit 钩子只检查暂存文件',
czg: '交互式 commit 辅助工具(替代 commitizen)',
};

3. PR / Code Review 流程

  • PR 使用统一模板,包含变更描述、影响范围、测试方式
  • PR 大小控制在 200-400 行,过大则拆分
  • 至少 1 人 Approve 才能合并
  • CI 必须全部通过(lint、type-check、test、build)
  • 合并策略使用 Squash and Merge,保持 main 分支每个 commit 对应一个完整功能

4. 自动化

自动化配置一览
const automation = {
'pre-commit': 'lint-staged(ESLint + Prettier)',
'commit-msg': 'commitlint 校验提交信息',
'CI/CD': 'GitHub Actions(lint -> test -> build -> deploy)',
'preview': 'Vercel Preview Deployments(每个 PR 自动部署预览)',
'release': 'changesets 或 semantic-release 自动版本管理',
'dependabot': '自动依赖更新 PR',
};

5. 冲突处理

  • 鼓励频繁 rebase main 到 feature 分支,减少最终合并冲突
  • 大的重构提前通知团队,避免多人修改同一文件
  • 使用 git rerere(reuse recorded resolution)自动解决重复出现的冲突
enable-rerere.sh
# 开启 rerere,Git 会记住你的冲突解决方式
git config --global rerere.enabled true

Q18: 如何治理前端依赖和供应链?

答案

减少依赖,固定锁文件和可信 Registry,自动扫描漏洞/许可证,升级经过测试和 Review,发布包校验来源与完整性。

高风险安装脚本、废弃包和单维护者核心依赖要重点审查,并准备替代或隔离方案。

  • 锁定版本和完整性,使用可信 Registry、依赖审计、许可证检查、最小发布权限与短期 Token。
  • 升级通过自动 PR 配合测试,不盲目追最新;对安装脚本、拼写相似包和长期无人维护依赖重点审查,并准备关键依赖替代方案。

Q19: 如何从 0 到 1 建设前端基础设施?

答案

先访谈和量化当前最大痛点,优先统一仓库模板、质量门禁、CI/CD、监控和文档,再建设组件库、脚手架与平台能力。

每一步提供迁移路径、Owner 和指标,先在一个项目试点,避免一次性推翻所有团队习惯。

  • 先访谈并量化最痛的交付瓶颈,再确定最小闭环,例如统一脚手架、质量门禁、CI 和监控,而不是一次建设“大平台”。
  • 从一个代表项目试点,提供迁移工具和文档,收集采用率与交付指标后再推广;平台能力要有负责人、版本和退出机制。

Q20: 如何衡量工程化建设是否有效?

答案

看交付 Lead Time、构建/CI 时间、部署频率、失败率、恢复时间、缺陷、重复代码、组件复用和开发者满意度。

指标要有建设前基线和业务切片。产出多少规则、脚本或平台页面不是效果,真实采用和稳定性才是。

  • 同时看速度、质量、可靠性和体验:交付前置时间、部署频率、变更失败率、恢复时间、缺陷逃逸率、CI 时长和开发者满意度。
  • 指标要有建设前基线并按团队规模归一化;如果只提高流水线速度却增加失败和维护成本,不能算真正有效。

Q21: ESLint Flat Config 相比传统配置有什么变化?

答案

Flat Config 使用 JavaScript 配置数组按顺序组合规则,文件匹配、忽略、插件对象和语言选项都显式写在配置项中,不再依赖多层 extends 的隐式级联和目录查找。

迁移重点不是改文件名:

  • 把插件作为对象导入,确认规则名与插件版本。
  • files/ignores 明确适用范围,避免忽略规则失效。
  • TypeScript、React、测试和 Node 文件分别设置解析器及 globals。
  • 检查旧插件是否兼容 Flat Config。
  • 打印最终配置并在 CI 固定 ESLint 与插件版本。

大型仓库应抽成可复用配置函数,但要让每个包仍能看清自己的文件边界和规则来源。

Q22: 前后端契约测试解决什么问题?和端到端测试有什么区别?

答案

契约测试验证提供方和消费方对请求、响应、字段约束及错误语义的约定是否兼容,目标是在不启动整套系统的情况下尽早发现接口破坏,适合多个团队或服务独立发布的场景。

端到端测试从用户入口验证整条链路,覆盖真实集成但更慢、更脆弱,失败定位也更难。合理分工是:

  • Schema 或消费者驱动契约测试守住接口兼容边界。
  • 少量 E2E 覆盖核心用户旅程和关键基础设施集成。
  • 单元与集成测试验证服务内部行为。

契约通过不代表业务一定正确;提供方必须验证契约用例能在当前实现上通过,并管理版本兼容窗口。

Q23: CI 如何避免同一分支的过时任务继续占用资源或错误发布?

答案

对 PR 校验可以按“仓库 + 分支或 PR”设置并发组,新提交到来时取消尚未完成的旧任务;但部署任务不能简单取消到一半,需要区分可中断构建和不可中断发布阶段。

稳妥设计包括:

  • 校验任务携带提交 SHA,产物、报告和状态都绑定该 SHA。
  • 发布前再次确认目标分支当前提交与待发布 SHA 一致。
  • 构建产物一次生成并晋级,不在各环境重新构建。
  • 部署使用环境锁或队列,定义超时、回滚和幂等步骤。
  • 旧任务不能覆盖新任务的状态或缓存。

并发控制解决资源和竞态问题,不能替代分支保护、审批和发布审计。

Q24: 什么是幽灵依赖?如何解决?

答案

幽灵依赖(Phantom Dependencies) 是指在代码中引用了未在当前包的 package.json 中显式声明的依赖,但由于 node_modules 的扁平化提升(hoisting)机制,这些依赖被提升到了上层目录中,代码可以意外地访问到它们。

产生原因:npm 和 yarn v1 使用扁平化的 node_modules 结构。当包 A 依赖了 lodash,npm/yarn 会将 lodash 提升到根 node_modules,此时包 B 虽然没有声明依赖 lodash,却也能 import lodash 成功。

幽灵依赖示例
// packages/my-app/src/index.ts
import dayjs from 'dayjs'; // 编译通过,但 package.json 中未声明 dayjs

// 某天其他包移除了 dayjs → 突然编译失败
// 或者 dayjs 被升级到不兼容版本 → 运行时报错

解决方案(按推荐度排序):

方案说明推荐度
使用 pnpm非扁平 node_modules 结构,天然隔离最推荐
ESLint 规则import/no-extraneous-dependencies辅助手段
yarn PnPPlug'n'Play 模式,严格依赖解析兼容性待验证
定期审计depcheck 工具检查未声明依赖事后补救

pnpm 的 node_modules 结构:

pnpm 的 node_modules 结构
node_modules/
├── .pnpm/ # 所有依赖的真实存储(硬链接到全局 store)
│ ├── lodash@4.17.21/
│ │ └── node_modules/
│ │ └── lodash/ # 真实文件
│ └── dayjs@1.11.10/
│ └── node_modules/
│ └── dayjs/
├── @myorg/
│ └── ui -> ../.pnpm/@myorg+ui/ # 符号链接
└── lodash -> .pnpm/lodash@4.17.21/ # 只有显式声明的才有链接

关键:每个包的 node_modules 下只会出现该包 package.json 中显式声明的依赖的符号链接,未声明的依赖不会出现。

Q25: qiankun 的 JS 沙箱是如何实现的?为什么需要 JS 沙箱?

答案

JS 沙箱的核心目的是隔离子应用对 window 全局变量的修改,防止多个子应用之间互相污染。

qiankun 提供了三种沙箱实现:

沙箱原理多实例兼容 IE
SnapshotSandbox激活时保存 window 快照,失活时 diff 还原不支持支持
LegacySandbox用 Proxy 代理 window,记录增/改/删操作不支持不支持
ProxySandbox为每个子应用创建 fakeWindow 代理支持不支持

ProxySandbox 核心原理

sandbox/proxy-sandbox-core.ts
// 简化版 ProxySandbox
class SimplifiedProxySandbox {
private fakeWindow: Record<string, unknown> = {};

createProxy(): Window {
const fakeWindow = this.fakeWindow;
return new Proxy(window, {
get(_target, key: string) {
// 优先读 fakeWindow(子应用修改过的值)
return key in fakeWindow ? fakeWindow[key] : (window as any)[key];
},
set(_target, key: string, value: unknown) {
// 写操作只影响 fakeWindow,不污染真实 window
fakeWindow[key] = value;
return true;
},
}) as unknown as Window;
}
}

// 每个子应用拥有独立的代理,互不干扰
const sandbox1 = new SimplifiedProxySandbox();
const sandbox2 = new SimplifiedProxySandbox();
const proxy1 = sandbox1.createProxy();
const proxy2 = sandbox2.createProxy();

(proxy1 as any).foo = 'bar'; // 只影响 sandbox1 的 fakeWindow
console.log((proxy2 as any).foo); // undefined
console.log((window as any).foo); // undefined

为什么需要 JS 沙箱

  • 子应用可能修改 window.xxx 全局变量,影响其他子应用
  • 子应用可能注册全局事件监听器(如 window.addEventListener),导致内存泄漏
  • 子应用可能修改原生 API(如 Array.prototype.xxx),导致其他应用行为异常
  • 子应用可能设置全局定时器(setInterval),卸载后仍在执行

Q26: 前端如何做全链路错误监控?需要覆盖哪些类型的错误?

答案

全链路错误监控需要覆盖以下 5 种主要错误类型,每种错误使用不同的捕获方式:

错误类型捕获方式示例
JS 运行时错误window.onerror未定义变量、类型错误
未捕获的 Promiseunhandledrejection未 catch 的 async 函数
资源加载错误addEventListener('error', ..., true)图片 404、CDN 挂了
框架组件错误Error Boundary / errorHandler渲染阶段异常
接口异常拦截 fetch/XHR500 错误、超时

完整的错误监控实现:

full-error-monitor.ts
function initErrorMonitor(): void {
// 1. JS 运行时错误
window.onerror = (message, source, lineno, colno, error) => {
report({ type: 'js_error', message: String(message), stack: error?.stack ?? '' });
return true;
};

// 2. Promise 未处理异常
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
report({
type: 'promise_error',
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack ?? '' : '',
});
});

// 3. 资源加载错误(捕获阶段)
window.addEventListener('error', (event) => {
const target = event.target as HTMLElement;
if (target instanceof HTMLScriptElement
|| target instanceof HTMLLinkElement
|| target instanceof HTMLImageElement) {
report({
type: 'resource_error',
tagName: target.tagName,
src: (target as HTMLImageElement).src || (target as HTMLLinkElement).href,
});
}
}, true);

// 4. 拦截 fetch 监控接口异常
const originalFetch = window.fetch;
window.fetch = async (...args) => {
try {
const response = await originalFetch(...args);
if (!response.ok) {
report({ type: 'api_error', status: response.status, url: String(args[0]) });
}
return response;
} catch (error) {
report({ type: 'network_error', message: (error as Error).message, url: String(args[0]) });
throw error;
}
};
}

此外还需注意:

  • 跨域脚本需要添加 crossorigin="anonymous" 才能获取详细错误信息
  • 上报时需要携带用户标识、页面 URL、设备信息、错误堆栈等上下文
  • 建议上传 Source Map 到 Sentry 等平台以便还原压缩代码的错误位置
  • 错误需要做聚合去重,避免同一个错误重复告警

Q27: git rebase 和 git merge 有什么区别?什么时候用 rebase,什么时候用 merge?

答案

两者都是整合分支的方式,但原理和效果完全不同:

git merge 会创建一个合并提交(merge commit),保留完整的分支拓扑历史:

# merge 前
# main: A---B---C
# feature: \---D---E

# merge 后
# main: A---B---C-------F (merge commit)
# feature: \---D---E-/

git rebase 会把当前分支的 commits 「移植」到目标分支的末端,产生线性历史:

# rebase 前
# main: A---B---C
# feature: \---D---E

# rebase 后(在 feature 上执行 git rebase main)
# main: A---B---C
# feature: \---D'---E' (新的 commit hash)

使用场景对比

场景推荐方式原因
feature 合并到 mainmerge --no-ff保留 feature 分支历史
在 feature 上同步 main 最新代码rebase保持 feature 分支线性
已推送到远程的公共分支merge不改写历史,安全
本地未推送的私有分支rebase线性历史,整洁
整理本地多个零碎 commitrebase -i(交互式)squash 合并提交
recommended-workflow.sh
# 推荐工作流:在 feature 分支上用 rebase 同步 main
git checkout feature/login
git rebase main # 把 feature 的 commits 移到 main 最新提交之后

# 合并时用 merge --no-ff 保留历史
git checkout main
git merge --no-ff feature/login

核心原则:永远不要对已推送到远程的公共分支执行 rebase,因为 rebase 会改写 commit hash,导致其他协作者出现冲突。

Q28: 组件库如何管理 SemVer 和 Breaking Change?

答案

先定义公共契约:组件 props、事件、DOM/样式选择器、设计令牌、类型、可访问性行为和运行环境都可能影响消费者,不只是函数签名。

治理方式通常包括:

  1. 用变更集记录每个包的版本影响和迁移说明。
  2. 废弃能力先告警并保留兼容周期,再在主版本移除。
  3. 为类型、渲染、交互和视觉结果建立回归测试,并用真实下游项目做 canary 验证。
  4. 提供 Codemod、自动修复或兼容层降低迁移成本。
  5. 明确 Monorepo 使用独立版本还是统一版本策略。

SemVer 是沟通协议,不会自动判断破坏性。默认样式、焦点行为或类型收窄也可能是 Breaking Change。

Q29: Vite 的 import.meta.env 和 Webpack 的 process.env 有什么区别?为什么 Vite 要用 import.meta.env

答案

两者本质上都是编译时静态替换,但在实现机制和设计理念上有显著差异。

1. 标准差异

  • process.env 是 Node.js 的全局对象,浏览器中原本不存在。Webpack 通过 DefinePlugin 模拟了这个对象,本质是文本字符串替换。
  • import.metaESM 标准 的一部分,是浏览器原生支持的语法。Vite 选择 import.meta.env 更符合现代 Web 标准。

2. 安全前缀机制

工具前缀说明
ViteVITE_只有 VITE_ 前缀的变量才暴露给前端代码
CRA (Webpack)REACT_APP_只有 REACT_APP_ 前缀的变量才暴露
Next.jsNEXT_PUBLIC_只有 NEXT_PUBLIC_ 前缀的变量才暴露

3. 替换时机对比

Vite 的替换
// 源代码
if (import.meta.env.DEV) {
enableDevTools();
}

// 生产构建后:整个 if 块被移除(Tree Shaking)
// import.meta.env.DEV 被替换为 false,压缩工具识别为死代码
Webpack 的替换
// 源代码
if (process.env.NODE_ENV === 'development') {
enableDevTools();
}

// 生产构建后:DefinePlugin 替换为字面量
// if ("production" === "development") { enableDevTools(); }
// 压缩工具(Terser)识别为恒假条件,移除整个块

4. 为什么 Vite 不用 process.env

Vite 在开发模式下使用原生 ESM,不做完整的打包。浏览器没有 process 全局对象。如果使用 process.env,开发模式下直接访问会报 ReferenceError: process is not defined。而 import.meta 是浏览器原生支持的,开发和生产模式行为一致。

Q30: Docker 镜像和容器的区别是什么?Dockerfile 中 CMD 和 ENTRYPOINT 有什么区别?

答案

镜像与容器的区别:

对比项镜像(Image)容器(Container)
本质只读模板(多层文件系统)镜像的运行实例
状态静态,不可修改动态,可写层
存储磁盘上的分层文件运行在内存中的进程
生命周期持久存在,可复用创建、运行、停止、删除
类比类(Class)实例(Instance)
创建方式docker builddocker run

CMD 与 ENTRYPOINT 的区别:

CMD 示例
FROM node:20-alpine
CMD ["node", "server.js"]

# docker run myapp -> 执行 node server.js
# docker run myapp node test.js -> CMD 被覆盖,执行 node test.js
ENTRYPOINT 示例
FROM node:20-alpine
ENTRYPOINT ["node"]
CMD ["server.js"]

# docker run myapp -> 执行 node server.js
# docker run myapp test.js -> 执行 node test.js(ENTRYPOINT 不变,CMD 被覆盖)
对比项CMDENTRYPOINT
是否可被覆盖docker run 参数会覆盖不会被覆盖(除非 --entrypoint
使用场景提供默认命令或默认参数定义容器的主进程
组合使用作为 ENTRYPOINT 的默认参数与 CMD 搭配使用

Q31: K8s 中 Pod、Deployment、Service 之间的关系是什么?

答案

这三者的关系是层层递进、各司其职的:

  • Pod 是最小部署单元,包含一个或多个容器,直接运行应用。Pod 的 IP 是临时的,重启后会变化。
  • Deployment 是 Pod 的控制器,负责:
    • 维护指定数量的 Pod 副本
    • 滚动更新:逐步替换旧版本 Pod
    • 自动恢复:Pod 异常时自动创建新的
    • 版本回滚:支持回退到历史版本
  • Service 为 Deployment 管理的一组 Pod 提供稳定的访问入口:
    • 固定的 Cluster IP 和 DNS 名称(如 frontend-service.frontend-prod.svc.cluster.local
    • 在多个 Pod 之间做负载均衡
    • 通过 selector 标签与 Pod 关联

简单类比:Deployment 是"部门主管"管理团队(Pod),Service 是"前台接待"统一接收请求并分配给团队成员。

关键配置对应关系:

# Deployment 通过 template.metadata.labels 给 Pod 打标签
Deployment.spec.template.metadata.labels:
app: frontend

# Service 通过 selector 选择标签匹配的 Pod
Service.spec.selector:
app: frontend # 与 Pod 标签一致

Q32: 什么是前端基建?为什么需要前端基建?

答案

前端基建是为前端研发团队提供的一套标准化、自动化、平台化的工程基础设施。它覆盖了开发规范、脚手架、构建发布、监控告警、组件物料等完整链路。

为什么需要

问题没有基建有基建
项目启动从零配置,1-2 天脚手架创建,5 分钟
代码质量全靠 Code Review 人肉把关ESLint + Prettier 自动检查
部署手动打包上传服务器CI/CD 自动化,合并即部署
线上监控用户反馈才知道出了问题秒级告警,主动发现
组件复用各写各的,大量重复代码统一组件库,按需引入

前端基建的核心价值可以用三个词概括:提效(减少重复劳动)、提质(保障代码质量)、降本(降低协作和运维成本)。

Q33: Husky + lint-staged 的工作原理是什么?为什么不直接对全量代码运行 ESLint?

答案

Husky 的原理:

Husky 利用 Git 的 Hooks 机制。Git 在执行特定操作(如 commit、push)前后会触发对应的钩子脚本。Husky 通过在 .husky/ 目录下创建钩子脚本,在 git commit 时自动触发 pre-commitcommit-msg 钩子。

package.json 中的 "prepare": "husky" 脚本会在 pnpm install 后自动执行,确保每个克隆项目的开发者都会安装 Git Hooks。

lint-staged 的原理:

lint-staged 通过 git diff --staged --name-only 获取暂存区文件列表,然后只对这些文件执行指定的命令。

为什么不全量检查:

对比分析
// 假设项目有 500 个 TS 文件,本次只修改了 3 个

// 方案一:全量检查(不推荐)
// eslint src/ --fix
// 耗时:可能 30 秒以上
// 问题:历史遗留代码的错误会阻塞当前提交

// 方案二:lint-staged(推荐)
// 只检查 3 个暂存文件
// 耗时:1-2 秒
// 优势:不受历史代码影响,开发体验好
方案检查范围耗时历史代码影响
全量 eslint src/全部文件较慢历史错误会阻塞提交
lint-staged仅暂存文件极快不受影响

全量检查更适合在 CI/CD 流水线中执行(作为兜底),而 lint-staged 用于本地开发提交时的快速检查。

Q34: Jest 和 Vitest 有什么区别?为什么越来越多项目选择 Vitest?

答案

Jest 和 Vitest 的核心区别在于底层架构和生态定位

对比维度JestVitest
模块转换通过 Babel/SWC 将 ESM 转为 CJS基于 Vite,原生支持 ESM
TypeScript需要 ts-jest@swc/jest原生支持,零配置
配置独立配置文件,需配置转换器和路径映射复用 vite.config,配置统一
热重载文件变更触发相关测试基于 Vite HMR,速度更快
并发Worker 进程级隔离线程级隔离,更轻量

Vitest 越来越流行的原因:

vitest.config.ts
import { defineConfig } from 'vitest/config';

// 1. 复用 Vite 配置,不需要重复配置路径别名、插件等
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
// 2. 原生支持 ESM 和 TypeScript,无需转换器
// 3. API 与 Jest 几乎完全兼容,迁移成本低
// 4. 内置覆盖率支持(v8/istanbul)
coverage: {
provider: 'v8',
},
},
});

迁移建议:新的 Vite 项目直接使用 Vitest;已有 Jest 项目可以渐进迁移,因为 API 高度兼容,通常只需替换导入语句:

// Jest
// import { describe, it, expect, jest } from '@jest/globals';

// Vitest(兼容写法)
import { describe, it, expect, vi } from 'vitest';
// jest.fn() -> vi.fn()
// jest.mock() -> vi.mock()
// jest.spyOn() -> vi.spyOn()

Q35: GitHub Actions 和 GitLab CI 的核心区别是什么?你会如何选择?

答案

两者都是成熟的 CI/CD 平台,核心区别体现在以下几个方面:

维度GitHub ActionsGitLab CI
架构设计事件驱动,基于 Event + Workflow阶段驱动,基于 Stage + Pipeline
配置方式多文件(.github/workflows/),按场景拆分单文件(.gitlab-ci.yml),集中管理
复用机制Marketplace Action + Reusable Workflowinclude + extends + YAML 锚点
执行模型Jobs 默认并行,needs 声明依赖同 stage 并行,不同 stage 串行
缓存需要 actions/cache Action内置 cache 关键字
容器支持可选使用 container默认在 Docker 容器中运行
生态系统Marketplace 非常丰富内置功能更全面

选择建议

选择决策函数
type CIPlatform = 'GitHub Actions' | 'GitLab CI';

function chooseCIPlatform(context: {
codeHost: 'GitHub' | 'GitLab' | 'Self-hosted';
teamSize: 'small' | 'medium' | 'large';
needsCompliance: boolean;
preferSimplicity: boolean;
}): CIPlatform {
// 规则 1:代码托管在哪就用哪个平台的 CI
if (context.codeHost === 'GitHub') return 'GitHub Actions';
if (context.codeHost === 'GitLab') return 'GitLab CI';

// 规则 2:需要合规审计,选 GitLab(内置更多企业特性)
if (context.needsCompliance) return 'GitLab CI';

// 规则 3:小团队追求简单,选 GitHub Actions(生态好)
if (context.teamSize === 'small' && context.preferSimplicity) {
return 'GitHub Actions';
}

// 默认:GitHub Actions(社区生态更丰富)
return 'GitHub Actions';
}

核心结论:代码托管在哪里,就优先使用那个平台的 CI/CD 工具。跨平台使用(如代码在 GitHub、CI 用 Jenkins)会增加维护成本和复杂度。

Q36: Turborepo 的缓存机制是如何工作的?它如何判断缓存是否有效?

答案

Turborepo 的缓存机制基于内容寻址的思想,通过计算每个任务的哈希指纹(hash) 来判断缓存是否命中。

哈希指纹的组成:

Turborepo 任务指纹的计算要素
interface TaskFingerprint {
// 1. 输入文件内容的哈希值
inputFiles: string[]; // turbo.json 中 inputs 指定的文件

// 2. 依赖的上游任务的哈希值
upstreamHashes: string[]; // dependsOn 指定的任务产物

// 3. 环境变量
envVars: Record<string, string>; // globalEnv + task-level env

// 4. 任务配置本身
taskDefinition: string; // turbo.json 中的任务配置

// 5. lockfile 的哈希(确保依赖版本一致)
lockfileHash: string;
}

缓存命中的判断流程:

缓存存储位置:

  • 本地缓存node_modules/.cache/turbo/ 目录中,每个任务的产物(outputs)和终端日志按 hash 存储。
  • 远程缓存:Vercel 提供的远程缓存服务,或自建的 HTTP 缓存服务器。

缓存失效的场景:

场景原因
修改了 inputs 范围内的源文件输入文件哈希变化
更新了依赖版本(lockfile 变化)lockfile 哈希变化
修改了环境变量环境变量部分哈希变化
修改了 turbo.json 任务配置任务定义哈希变化
上游依赖包重新构建上游哈希变化导致下游级联失效

实际效果示例:

# 首次构建(无缓存)
$ turbo run build
# @myorg/utils:build - 5.2s
# @myorg/ui:build - 8.1s
# @myorg/web:build - 12.3s
# Total: 25.6s

# 修改 utils 后再次构建
$ turbo run build
# @myorg/utils:build - 5.2s (重新构建,因为源码变了)
# @myorg/ui:build - 8.1s (重新构建,依赖了 utils)
# @myorg/web:build - cache hit ⚡ (虽然依赖 ui,但 web 自身代码没变 + ui 的产物哈希不变时)

# 无任何变更时
$ turbo run build
# @myorg/utils:build - cache hit ⚡
# @myorg/ui:build - cache hit ⚡
# @myorg/web:build - cache hit ⚡
# Total: 0.8s

Q37: 微前端中如何实现子应用之间的通信?

答案

微前端通信主要有以下几种方式:

方式适用场景优点缺点
Props 传递主->子通信简单直接、类型安全只支持主->子单向
全局状态多应用共享状态支持双向、可监听需要框架支持(qiankun)
CustomEvent任意应用通信无框架依赖、灵活缺乏类型约束
URL 参数简单数据传递天然持久化数据量有限
localStorage跨标签页共享持久化、简单无实时监听(需轮询)
BroadcastChannel同源跨标签页原生支持、实时仅同源

推荐的通信架构

communication/event-bus.ts
/** 类型安全的微前端 EventBus */
interface EventMap {
'user:login': { userId: string; token: string };
'user:logout': undefined;
'theme:change': { theme: 'light' | 'dark' };
'language:change': { lang: 'zh-CN' | 'en-US' };
}

class MicroEventBus {
private handlers = new Map<string, Set<Function>>();

/** 监听事件 */
on<K extends keyof EventMap>(
event: K,
handler: (payload: EventMap[K]) => void
): () => void {
if (!this.handlers.has(event as string)) {
this.handlers.set(event as string, new Set());
}
this.handlers.get(event as string)!.add(handler);

// 返回取消订阅函数
return () => {
this.handlers.get(event as string)?.delete(handler);
};
}

/** 触发事件 */
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
this.handlers.get(event as string)?.forEach((handler) => {
handler(payload);
});
}

/** 清除所有监听 */
clear(): void {
this.handlers.clear();
}
}

// 挂载到 window 上供所有子应用使用
const eventBus = new MicroEventBus();
(window as any).__MICRO_EVENT_BUS__ = eventBus;

// 子应用 A 发送
eventBus.emit('user:login', { userId: '001', token: 'xxx' });

// 子应用 B 监听
const unsubscribe = eventBus.on('user:login', (data) => {
console.log(`用户登录: ${data.userId}`);
});

Q38: sendBeacon 和普通的 AJAX 请求有什么区别?为什么监控上报推荐使用 sendBeacon?

答案

navigator.sendBeacon() 是专为数据上报场景设计的 API,与普通 AJAX(XHR/fetch)的核心区别在于:

特性sendBeaconXHR/fetch
页面卸载后是否继续✅ 浏览器保证发送❌ 可能被取消
是否阻塞页面关闭❌ 异步、不阻塞同步 XHR 会阻塞
请求类型POST任意方法
能否获取响应❌ 没有回调✅ 可获取响应
优先级低优先级队列正常优先级
数据量限制通常 64KB无硬性限制
返回值boolean(是否成功入队)Promise/回调

推荐使用 sendBeacon 的原因:

  1. 页面卸载时的可靠性:用户关闭页面、跳转时,普通 AJAX 请求可能被浏览器取消。sendBeacon 将请求放入浏览器内部的发送队列,即使页面已经卸载也会完成发送。

  2. 不阻塞页面关闭:早期为了保证数据上报,开发者使用同步 XHR 来阻塞页面关闭,这会严重影响用户体验。sendBeacon 天然异步且不阻塞。

  3. 对用户体验无影响:sendBeacon 的请求优先级低,不会与关键业务请求争抢带宽。

// 最佳实践:组合使用 visibilitychange + sendBeacon
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 页面进入后台或即将关闭时,批量上报缓冲区数据
const data = getBufferedData();
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const success = navigator.sendBeacon('/api/report', blob);

if (!success) {
// sendBeacon 失败(数据量过大等原因)时降级
fetch('/api/report', {
method: 'POST',
body: blob,
keepalive: true, // fetch 的 keepalive 选项类似 sendBeacon
});
}
}
});
补充

fetchkeepalive: true 选项功能类似 sendBeacon,也能在页面卸载后继续发送请求,但数据量限制为所有 keepalive 请求总和 64KB

Q39: Conventional Commits 规范有什么好处?BREAKING CHANGE 是什么?

答案

Conventional Commits 是一套结构化的提交信息格式规范,核心好处有三个:

  1. 自动生成 Changelog:工具(如 conventional-changelog)可以根据 commit type 自动分类生成版本变更记录
  2. 自动决定版本号:配合 Semantic Versioning,fix 触发 patch、feat 触发 minor、BREAKING CHANGE 触发 major
  3. 提高可读性:团队成员一眼就能看出每个提交的类型和影响范围

BREAKING CHANGE(破坏性变更)指的是不向后兼容的 API 变更。标记方式有两种:

breaking-change-examples.sh
# 方式一:在 type 后加 !
git commit -m "feat(api)!: change response format to envelope pattern"

# 方式二:在 footer 中写 BREAKING CHANGE
git commit -m "feat(api): change response format

BREAKING CHANGE: Response now uses { data, meta, error } envelope.
Old format { result, status } is removed."

两种方式都会触发主版本号升级(如 1.x.x -> 2.0.0)。

自动化工具链示例

release-config.ts
// semantic-release 配置
const config = {
branches: ['main'],
plugins: [
'@semantic-release/commit-analyzer', // 分析 commit 类型
'@semantic-release/release-notes-generator', // 生成 release notes
'@semantic-release/changelog', // 更新 CHANGELOG.md
'@semantic-release/npm', // 发布到 npm
'@semantic-release/github', // 创建 GitHub Release
'@semantic-release/git', // 提交版本号变更
],
};

export default config;

配合 commitlint + husky,可以在 git commit 时自动校验提交信息是否符合规范,不符合则拒绝提交。

Q40: 组件库如何实现按需加载?Tree Shaking 和 babel-plugin-import 有什么区别?

答案

按需加载有两种主流方案:

方案一:Tree Shaking(推荐)

依赖 ESM 的静态分析能力,打包工具自动移除未使用的导出。

使用方式
// 使用方这样写即可,打包工具自动 Tree Shaking
import { Button, Input } from '@myorg/ui';
// 未使用的 Modal、Table 等不会被打包

组件库需要满足以下条件:

package.json
{
"module": "dist/index.esm.js",
"sideEffects": ["*.css"],
"exports": {
".": {
"import": "./dist/index.esm.js"
}
}
}

方案二:babel-plugin-import / unplugin

在编译阶段将整包引入转换为路径引入。

// 编译前
import { Button } from 'antd';

// 编译后(babel-plugin-import 自动转换)
import Button from 'antd/es/button';
import 'antd/es/button/style/css';

对比

对比项Tree Shakingbabel-plugin-import
额外配置无需(原生支持)需要 Babel 插件
CSS 处理需手动引入或全局引入自动引入组件样式
适用场景现代打包工具Babel 编译场景
粒度export 级别文件级别
趋势主流方向逐渐被 Tree Shaking 替代
提示

Ant Design 5 已经不再需要 babel-plugin-import,完全依赖 Tree Shaking。新项目推荐直接使用 Tree Shaking 方案。

Q41: Feature Flags 有哪些使用场景?在前端项目中如何实现灰度发布?

答案

Feature Flags 的核心价值在于将代码部署功能发布解耦。常见使用场景包括:

1. 灰度发布(渐进式发布)

先对 1% 用户开放新功能,观察监控指标,逐步扩大到 10% -> 50% -> 100%。如果出现问题,可以立即关闭开关进行回滚,无需重新部署。

2. A/B 测试

通过 Feature Flag 将用户随机分到实验组和对照组,对比不同方案的数据表现(转化率、停留时间等)。

3. 长期功能开关

权限控制、付费功能等需要长期存在的功能开关。例如:只有 VIP 用户才能看到某些功能。

4. Kill Switch

当某个功能出现严重问题时,通过关闭 Feature Flag 立即下线该功能。

灰度发布的实现要点:

灰度发布核心逻辑
interface GrayReleaseRule {
percentage: number; // 灰度比例 0-100
whitelist: string[]; // 白名单用户
blacklist: string[]; // 黑名单用户
conditions: {
// 条件规则
field: string; // 如 'region', 'platform', 'version'
operator: 'eq' | 'neq' | 'in' | 'gt' | 'lt';
value: string | string[] | number;
}[];
}

function shouldEnableForUser(
rule: GrayReleaseRule,
userId: string,
userProps: Record<string, string | number>
): boolean {
// 1. 黑名单直接拒绝
if (rule.blacklist.includes(userId)) return false;

// 2. 白名单直接通过
if (rule.whitelist.includes(userId)) return true;

// 3. 条件规则匹配
const conditionsMet = rule.conditions.every((cond) => {
const actual = userProps[cond.field];
switch (cond.operator) {
case 'eq':
return actual === cond.value;
case 'neq':
return actual !== cond.value;
case 'in':
return (cond.value as string[]).includes(String(actual));
case 'gt':
return Number(actual) > Number(cond.value);
case 'lt':
return Number(actual) < Number(cond.value);
default:
return false;
}
});
if (!conditionsMet) return false;

// 4. 灰度百分比(确定性哈希)
const hash = deterministicHash(userId);
return hash % 100 < rule.percentage;
}

// 确定性哈希:确保同一个用户每次结果一致
function deterministicHash(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return Math.abs(hash);
}
关键设计要点

灰度发布的哈希必须是确定性的(同一用户 ID 每次计算结果相同),否则用户刷新页面可能看到不同的功能版本,体验非常糟糕。常用的方法是对用户 ID 进行哈希取模。

Q42: 前端项目如何优化 Docker 镜像体积?为什么要使用多阶段构建?

答案

前端 Docker 镜像优化的核心思路是减少不必要的文件和层

1. 使用多阶段构建(最重要)

多阶段构建对比
# 单阶段:最终镜像包含 Node.js + node_modules + 源代码 ≈ 1.2GB
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["npx", "serve", "dist"]

# ---

# 多阶段:最终镜像仅有 Nginx + 静态文件 ≈ 25MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM nginx:1.25-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

2. 优化缓存分层

将不常变动的操作(安装依赖)放在前面,频繁变动的操作(复制源代码)放在后面,最大化利用 Docker 缓存:

缓存分层示意
COPY package.json pnpm-lock.yaml ./   # 依赖文件变动少 -> 缓存命中率高
RUN pnpm install --frozen-lockfile # 依赖未变时完全复用缓存

COPY . . # 源代码经常变 -> 仅从此层开始重建
RUN pnpm build

3. 完整优化清单

优化手段效果优先级
多阶段构建1.2GB -> 25MB必须
Alpine 基础镜像减少 80% 基础体积必须
.dockerignore减少构建上下文必须
缓存分层优化重复构建加速 50%+推荐
合并 RUN 指令减少中间层推荐
非 root 用户提升安全性推荐

Q43: 前端项目如何实现零停机部署?

答案

零停机部署依赖 K8s 的滚动更新机制和健康检查配合工作:

1. 配置滚动更新策略:

零停机部署关键配置
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 更新时最多多创建 1 个新 Pod
maxUnavailable: 0 # 不允许有 Pod 不可用(关键!)
template:
spec:
containers:
- name: frontend
image: registry.example.com/frontend:v1.3.0
# 就绪探针:新 Pod 通过检查后才接收流量
readinessProbe:
httpGet:
path: /healthz
port: 80
initialDelaySeconds: 5
periodSeconds: 5
# 存活探针:持续检测容器健康状态
livenessProbe:
httpGet:
path: /healthz
port: 80
initialDelaySeconds: 10
periodSeconds: 30
# 优雅终止:给旧 Pod 时间处理完已有请求
terminationGracePeriodSeconds: 30

2. 零停机的完整流程:

步骤行为说明
1创建新 PodK8s 创建一个新版本 Pod
2就绪检查readinessProbe 检测新 Pod 是否就绪
3加入 Service新 Pod 通过检查后,Service 将流量导入
4停止旧 Pod旧 Pod 从 Service 中移除,不再接收新请求
5优雅终止旧 Pod 在 terminationGracePeriodSeconds 内处理完已有请求
6循环替换重复以上步骤直到所有旧 Pod 被替换

3. 关键要素总结:

  • maxUnavailable: 0 确保始终有足够的 Pod 处理请求
  • readinessProbe 确保新 Pod 完全就绪后才接收流量
  • terminationGracePeriodSeconds 确保旧 Pod 优雅退出
  • 前端静态资源带 hash 指纹避免缓存问题(如 app.abc123.js

Q44: 什么是 ADR?前端团队为什么需要记录架构决策?

答案

ADR(Architecture Decision Record)用简短、可版本化的文档记录一个重要技术决策的背景、约束、备选方案、取舍、结论和后果。它解释“为什么这样做”,而不是重复代码已经表达的“做了什么”。

适合记录构建工具迁移、状态管理、微前端边界和浏览器兼容策略。实践上应:

  • 一个 ADR 聚焦一个决策,标明 Proposed、Accepted、Superseded 等状态。
  • 写清当时证据和不可控约束,避免事后包装。
  • 决策变化时新增 ADR 并链接替代关系,不静默改写历史。
  • 只记录值得长期追踪的取舍,普通实现细节无需文档化。

ADR 不能替代 RFC 讨论和验证,但能减少重复争论并帮助新人理解架构演进。

Q45: 如何从零搭建一套完整的团队代码规范方案?需要考虑哪些方面?

答案

搭建团队代码规范需要覆盖以下 5 个层面:

1. 编辑器层 -- EditorConfig

统一最基础的编码格式,确保不同编辑器的开发者写出一致的代码:

.editorconfig
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

2. 代码质量层 -- ESLint + Stylelint

检查 JS/TS 和 CSS 的代码质量:

npm install --save-dev eslint typescript-eslint @eslint/js stylelint stylelint-config-standard-scss

3. 代码格式层 -- Prettier

统一代码风格,消除团队内的格式争论:

npm install --save-dev prettier eslint-config-prettier

4. Git 提交层 -- Husky + lint-staged + commitlint

在提交时强制执行检查,确保不合规代码无法进入仓库:

npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
npx husky init

5. CI/CD 层 -- 流水线兜底

在 CI 中运行全量检查,作为最后一道防线:

.github/workflows/lint.yml
name: Lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install
- run: pnpm lint
- run: pnpm lint:style

实施建议:

阶段建议
新项目一开始就配置完整的工具链,全量规则开启
老项目迁移先以 warn 级别引入,逐步升级为 error
团队推广提供 VSCode 配置和初始化脚本,降低接入成本
持续维护定期更新工具版本,关注 ESLint Flat Config 生态

Q46: 如何使用 MSW 做 API Mock?它相比 jest.mock 有什么优势?

答案

MSW(Mock Service Worker)在网络层拦截 HTTP 请求,而 jest.mock/vi.mock模块层替换导入。两者的根本区别如下:

对比维度MSWvi.mock / jest.mock
拦截层级网络层(Service Worker)模块导入层
HTTP 客户端无关(fetch/axios/XHR 通用)需要 mock 具体的客户端模块
请求验证可以验证请求体、请求头等只能验证模块函数的调用参数
复用性测试 + 开发环境通用仅测试环境
真实度更接近真实网络请求完全跳过网络层

MSW 的核心优势在于与 HTTP 客户端解耦

services/__tests__/api.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

// 无论业务代码使用 fetch 还是 axios,mock 方式完全相同
const server = setupServer(
http.get('/api/products', () => {
return HttpResponse.json([
{ id: 1, name: '商品A', price: 100 },
{ id: 2, name: '商品B', price: 200 },
]);
})
);

beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

// 测试 fetchProducts —— 不关心它内部用的是 fetch 还是 axios
import { fetchProducts } from '../productService';

describe('productService', () => {
it('should return product list', async () => {
const products = await fetchProducts();
expect(products).toHaveLength(2);
expect(products[0].name).toBe('商品A');
});

it('should handle network error', async () => {
// 针对这个测试模拟网络错误
server.use(
http.get('/api/products', () => {
return HttpResponse.error();
})
);

await expect(fetchProducts()).rejects.toThrow();
});

it('should handle specific HTTP status', async () => {
server.use(
http.get('/api/products', () => {
return HttpResponse.json(
{ message: 'Unauthorized' },
{ status: 401 }
);
})
);

await expect(fetchProducts()).rejects.toThrow('认证失败');
});
});

使用 vi.mock 实现相同功能则需要 mock 具体的 HTTP 模块,耦合度更高:

对比:vi.mock 方式
// 如果业务代码从 fetch 切换到 axios,这里也得改
vi.mock('axios', () => ({
default: {
get: vi.fn().mockResolvedValue({
data: [{ id: 1, name: '商品A', price: 100 }],
}),
},
}));

最佳实践:API 相关的测试优先使用 MSW,模块内部逻辑的隔离使用 vi.mock。两者结合使用能达到最好的效果。

Q47: 前端部署如何避免 HTML 与静态资源版本错配?

答案

前端常见故障是新 HTML 引用了尚未到达所有 CDN 节点的资源,或旧页面动态加载的 Chunk 已被新发布删除。

可靠策略包括:

  1. JS/CSS 使用内容哈希文件名并长期缓存,不覆盖同名文件。
  2. 先上传新静态资源,再切换 HTML/路由入口。
  3. 旧版本资源保留一个安全窗口,不在发布后立即清理。
  4. 动态导入失败时识别版本错配,提供一次受控刷新或恢复 UI,避免无限刷新。
  5. HTML 使用短缓存或可验证缓存,CDN 清理按入口与资源分开。
  6. 发布清单、监控和回滚都绑定同一版本号。

Q48: 在 Monorepo 中如何实现跨包的代码共享和类型安全?

答案

在 Monorepo 中,跨包代码共享主要通过以下方式实现:

1. 使用 workspace 协议声明内部依赖:

apps/web/package.json
{
"name": "@myorg/web",
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:*"
}
}

2. 内部包直接导出 TypeScript 源码(推荐方式):

这种方式不需要预先编译内部包,应用层直接引用源码:

packages/utils/package.json
{
"name": "@myorg/utils",
"version": "1.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
packages/utils/src/index.ts
export function formatDate(date: Date, format: string): string {
// 实现...
return '';
}

export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
apps/web/src/App.tsx
// 直接引用内部包,获得完整的类型提示和代码跳转
import { formatDate, type ApiResponse } from '@myorg/utils';

// TypeScript 编译器直接处理源码
// → 类型检查、自动补全、跳转定义全部可用
const result: ApiResponse<{ name: string }> = await fetchData();
console.log(formatDate(new Date(), 'YYYY-MM-DD'));

3. 使用 TypeScript 项目引用保证类型安全:

packages/ui/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist"
},
"references": [
{ "path": "../utils" },
{ "path": "../types" }
]
}

4. 共享类型定义包:

创建独立的 types 包来存放跨包共用的类型:

packages/types/src/index.ts
// 全局共享的类型定义
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}

export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}

export type AsyncFunction<T = void> = () => Promise<T>;
packages/ui/src/UserCard.tsx
// UI 组件直接使用共享类型
import type { User } from '@myorg/types';

interface UserCardProps {
user: User;
onEdit?: (user: User) => void;
}

export function UserCard({ user, onEdit }: UserCardProps): JSX.Element {
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
<span>{user.role}</span>
{onEdit && <button onClick={() => onEdit(user)}>编辑</button>}
</div>
);
}

方案对比:

方案优点缺点适用场景
直接引用源码零构建延迟、类型完整应用构建稍慢内部包不发布到 npm
预构建 + 声明文件应用构建快需要 watch 模式包要发布到 npm
TypeScript 项目引用增量类型检查配置较复杂大型 Monorepo
共享 types 包类型集中管理多一个包要维护类型需要多包复用
最佳实践
  • 内部使用的包(不发布到 npm):直接导出 TypeScript 源码,让应用层负责编译。
  • 需要发布的包:使用 tsupunbuild 预编译,同时生成 .d.ts 声明文件。
  • 无论哪种方式,都要配合 workspace 协议 + TypeScript paths 确保引用路径正确。

Q49: qiankun 和 Module Federation 有什么区别?如何选择?

答案

qiankun 和 Module Federation 是两种不同层面的微前端方案,解决的核心问题不同:

对比维度qiankunModule Federation
定位完整的微前端框架Webpack 5 模块共享能力
隔离能力JS 沙箱 + CSS 隔离无隔离(共享运行环境)
技术栈技术栈无关通常要求相同构建工具
加载方式HTML Entry(加载整个页面)JS Entry(加载模块)
共享粒度应用级别模块/组件级别
部署独立性完全独立部署独立部署,但共享依赖需版本协调
通信方式props / GlobalState / Event共享模块、直接 import
构建工具无要求必须 Webpack 5+ / Rspack
适用场景大型异构系统集成同技术栈的模块/组件共享

选型建议

selection-guide.ts
// 伪代码:选型决策树
function chooseMicroFrontendSolution(project: {
techStackUnified: boolean;
needStrongIsolation: boolean;
teamSize: 'small' | 'medium' | 'large';
hasLegacyApps: boolean;
buildTool: 'webpack5' | 'vite' | 'other';
}): string {
// 有遗留系统需要渐进迁移 -> qiankun
if (project.hasLegacyApps) {
return 'qiankun(技术栈无关,支持渐进迁移)';
}

// 技术栈统一,仅需模块级共享 -> Module Federation
if (project.techStackUnified && !project.needStrongIsolation) {
if (project.buildTool === 'webpack5') {
return 'Module Federation(模块级共享,零隔离开销)';
}
}

// 需要强隔离 -> wujie 或 qiankun
if (project.needStrongIsolation) {
return 'wujie(iframe + Shadow DOM,隔离最彻底)';
}

// 追求接入简单 -> micro-app
if (project.teamSize === 'small') {
return 'micro-app(类 WebComponent,接入成本最低)';
}

// 默认推荐 qiankun(社区成熟度最高)
return 'qiankun(社区成熟、文档完善、生态丰富)';
}

实践中常见的组合方案

  • qiankun + Module Federation:用 qiankun 管理应用生命周期和隔离,用 Module Federation 共享公共组件库
  • qiankun + monorepo:用 monorepo 管理所有子应用代码,用 qiankun 做运行时集成
  • Module Federation + Rspack:Rspack 原生支持 Module Federation,构建速度比 Webpack 快

Q50: 如何设计一个前端监控 SDK?需要考虑哪些方面?

答案

设计前端监控 SDK 需要从以下几个维度考虑:

1. 架构设计 — 插件化

// 核心只提供生命周期管理、数据缓冲、上报通道
// 各功能模块作为插件按需引入
const sdk = new MonitorSDK({
appId: 'my-app',
reportUrl: '/api/report',
plugins: [
new ErrorPlugin(), // 错误监控
new PerfPlugin(), // 性能监控
new BehaviorPlugin(), // 行为监控
],
});

2. 数据采集 — 全面且不侵入

  • 错误采集:onerror + unhandledrejection + addEventListener('error', ..., true)
  • 性能采集:PerformanceObserver + web-vitals
  • 行为采集:全局事件委托 + 路由监听

3. 数据上报 — 高效且可靠

策略说明
批量上报缓冲队列 + 定时/定量触发
采样控制全局采样 + 事件级采样 + 关键事件强制上报
上报方式降级sendBeacon -> fetch(keepalive) -> XHR -> img
失败重试存入 localStorage,下次访问时重试
页面卸载兜底visibilitychange 事件触发上报

4. 性能影响最小化

  • SDK 体积控制在 10KB 以内(gzip 后)
  • 采集逻辑放在 requestIdleCallback 中执行
  • 避免同步操作阻塞主线程
  • 使用 Web Worker 处理数据序列化

5. 数据安全与隐私

  • beforeSend 钩子支持数据脱敏(去除密码、Token 等)
  • 遵循 GDPR 等隐私法规,提供关闭监控的开关
  • 用户标识匿名化处理

6. 错误聚合与告警

  • 相同错误通过 message + stack 计算指纹进行聚合
  • 设置告警阈值(如错误率超过 1% 时触发)
  • 支持多渠道通知(钉钉、飞书、邮件、短信)

核心原则是:对业务代码零侵入、对页面性能零感知、对错误数据零遗漏

Q51: Trunk-based Development 为什么常和 Feature Flag 配合?

答案

Trunk-based 强调小批量、频繁合并到主干,减少长期分支的合并冲突。尚未完成或需要灰度的功能不能长期藏在分支里,因此常用 Feature Flag 把“代码是否合并”与“功能是否对用户开放”分离。

落地要点:

  • Flag 有负责人、默认值、环境策略和过期时间。
  • 服务端权限与数据迁移不能只靠前端隐藏。
  • 测试覆盖开关两侧和回滚路径。
  • 发布后及时删除旧分支代码,避免 Flag 永久堆积。
  • 高风险能力使用服务端可控的 Kill Switch。

它适合自动化测试和发布能力成熟的团队,不等于取消 Code Review 或直接把半成品暴露给用户。

Q52: 组件库的样式方案怎么选?CSS-in-JS 和 CSS Variables 各有什么优缺点?

答案

CSS Variables 方案(Ant Design 5、Arco Design):

主题切换示例
// 通过修改 CSS Variables 实现主题切换
function toggleTheme(mode: 'light' | 'dark'): void {
document.documentElement.setAttribute('data-theme', mode);
}

// 或者通过 JS 动态修改单个变量
function setPrimaryColor(color: string): void {
document.documentElement.style.setProperty('--color-primary', color);
}

CSS-in-JS 方案(MUI、Chakra UI):

styled-components 主题
import styled, { ThemeProvider } from 'styled-components';

const theme = {
colors: { primary: '#1677ff' },
spacing: { md: '16px' },
};

const StyledButton = styled.button`
background: ${(props) => props.theme.colors.primary};
padding: ${(props) => props.theme.spacing.md};
`;

// 使用
<ThemeProvider theme={theme}>
<StyledButton>Themed Button</StyledButton>
</ThemeProvider>

完整对比

维度CSS VariablesCSS-in-JS零运行时 CSS-in-JS
运行时性能极佳(原生)有开销极佳(编译时生成)
动态主题支持完全支持支持
类型安全不支持完全支持支持
SSR 兼容天然支持需要额外配置天然支持
包体积零额外依赖需引入运行时接近零
代表方案Ant Design 5MUI、Emotionvanilla-extract、Panda CSS

面试推荐回答:目前社区趋势是 CSS Variables + 零运行时 CSS-in-JS。CSS Variables 天然支持运行时主题切换且零性能开销,配合 Design Token 系统可以满足大多数定制需求。如果团队需要类型安全的样式开发体验,可以考虑 vanilla-extract 或 Panda CSS 等零运行时方案。

Q53: 如何保证前端环境变量的安全性?哪些信息不能放在前端环境变量中?

答案

核心原则:所有注入到前端构建产物中的环境变量,都会暴露给用户。 无论是 VITE_ 前缀还是 REACT_APP_ 前缀的变量,构建后都会以明文形式存在于 JS 文件中,任何人都可以通过 DevTools 或直接阅读源码获取。

绝对不能放在前端的信息:

  • 数据库连接串(DATABASE_URL
  • 服务端密钥(JWT_SECRETSESSION_SECRET
  • 第三方服务的 Secret Key(AWS_SECRET_ACCESS_KEYSTRIPE_SECRET_KEY
  • 内网服务地址(INTERNAL_API_URL
  • OAuth Client Secret

可以放在前端的信息:

  • 公开的 API 地址
  • 公开的第三方 Key(如 Google Maps API Key,但需配合域名白名单、配额限制)
  • CDN 地址
  • 应用版本号
  • 功能开关标识

安全防护策略:

策略说明示例
前缀过滤只有特定前缀变量暴露给前端VITE_REACT_APP_
.gitignore包含真实密钥的文件不提交.env.local.env.*.local
CI/CD 注入敏感变量通过 CI/CD 平台的 Secret 管理GitHub Secrets、GitLab CI Variables
后端代理需要密钥的第三方 API 通过后端代理访问前端调后端,后端带密钥调第三方
密钥轮换定期更换密钥,减少泄露影响每季度更换 API Key
预提交检查在 Git Hook 中扫描敏感信息git-secretsdetect-secrets
后端代理示例(避免前端暴露密钥)
// 前端代码:不需要知道第三方 API 密钥
async function getMapData(location: string): Promise<MapData> {
// 调用自己的后端接口
const response = await fetch(
`/api/map?location=${encodeURIComponent(location)}`
);
return response.json();
}

// 后端代码(Node.js):密钥安全地保存在服务端
import express from 'express';
const app = express();

app.get('/api/map', async (req, res) => {
const { location } = req.query;
// 密钥只存在于服务端环境变量中,前端永远看不到
const apiKey = process.env.GOOGLE_MAPS_SECRET_KEY;
const response = await fetch(
`https://maps.googleapis.com/maps/api/geocode/json?address=${location}&key=${apiKey}`
);
const data = await response.json();
res.json(data);
});

Q54: Docker Compose 中如何实现前端和后端的联调?服务之间如何通信?

答案

Docker Compose 中的服务通信基于自定义网络服务发现机制。

核心原理:同一个 docker-compose.yml 中定义的服务默认处于同一个 bridge 网络,容器之间可以用服务名(即 services 下的键名)作为主机名互相访问。

docker-compose.yml
version: '3.8'

services:
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- app-net

# Nginx 中配置 proxy_pass http://backend:3000
backend:
build: ./backend
# 注意:不需要暴露 3000 端口到宿主机
# 只在内部网络中通信即可
expose:
- "3000"
environment:
- DB_HOST=database
- DB_PORT=5432
depends_on:
- database
networks:
- app-net

database:
image: postgres:16-alpine
expose:
- "5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- app-net

networks:
app-net:
driver: bridge

volumes:
pgdata:

通信链路:

关键要点:

  1. depends_on:控制启动顺序(但不保证服务就绪,需配合 healthcheck
  2. expose vs portsexpose 仅在容器网络内开放端口,ports 映射到宿主机
  3. 服务名即主机名proxy_pass http://backend:3000 中的 backend 就是服务名
  4. 环境变量注入:后端通过 DB_HOST=database 连接数据库,database 也是服务名
nginx.conf 中的反向代理
# 前端 Nginx 将 /api 开头的请求转发给后端
location /api/ {
proxy_pass http://backend:3000/; # backend 是 Docker Compose 中的服务名
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
开发环境联调技巧

在本地开发时,可以用 docker compose 启动后端和数据库,前端使用 vite dev 本地开发,通过 Vite 的 proxy 配置代理请求到 Docker 中的后端服务:

vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000', // Docker 映射到宿主机的端口
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
},
},
},
});

Q55: K8s 和 Docker Compose 的核心区别是什么?什么场景用哪个?

答案

两者定位完全不同,Docker Compose 面向单机开发环境,K8s 面向生产级容器编排

维度Docker ComposeKubernetes
运行环境单台机器多节点集群
高可用无,单机故障即服务中断Pod 自动重启 + 重新调度到其他节点
负载均衡无内建支持Service 内建 L4 负载均衡
自动扩缩HPA 根据 CPU/内存自动扩缩
滚动更新需停机重启零停机滚动更新
服务发现容器名 DNS 解析Service DNS + CoreDNS
配置管理.env 文件ConfigMap + Secret(支持热更新)
部署复杂度低,一个 YAML 文件高,多个资源文件或 Helm Chart

使用场景建议:

选择策略
type Environment = 'local-dev' | 'ci-test' | 'staging' | 'production';

function chooseOrchestration(env: Environment): string {
switch (env) {
case 'local-dev':
// 本地开发:Docker Compose 启动前后端 + 数据库
return 'Docker Compose';
case 'ci-test':
// CI 中运行集成测试:Docker Compose 快速启动依赖
return 'Docker Compose';
case 'staging':
// 预发环境:与生产保持一致用 K8s
return 'Kubernetes';
case 'production':
// 生产环境:K8s 提供高可用、自动扩缩
return 'Kubernetes';
}
}

核心总结:Docker Compose 解决的是"如何方便地在本地启动多个服务",K8s 解决的是"如何在生产环境可靠地运行和管理大量容器"。两者互补而非竞争 -- 开发用 Compose,部署用 K8s。

Q56: 如何设计一个前端脚手架(CLI 工具)?核心功能有哪些?

答案

前端脚手架的本质是把团队最佳实践固化为工具。核心功能如下:

命令功能说明
create创建项目交互式选择模板,拉取远程模板,初始化配置
generate生成代码生成页面/组件/Store 等模板代码
doctor环境检查检查 Node/pnpm/Git 版本是否满足要求
upgrade升级模板对比当前项目和最新模板的差异,增量升级

技术栈选择

脚手架核心依赖
// 命令行框架
import { Command } from 'commander'; // 命令行参数解析
import inquirer from 'inquirer'; // 交互式提问
import ora from 'ora'; // Loading 动画
import chalk from 'chalk'; // 终端文字着色

// 模板处理
import degit from 'degit'; // 快速拉取 Git 模板(不带 .git 历史)
import ejs from 'ejs'; // 模板引擎,动态填充变量

模板管理策略:模板放在远程 Git 仓库,脚手架每次创建项目时实时拉取,保证模板始终是最新版本。支持通过 Git tag 指定版本:

模板版本管理
// 拉取指定版本的模板
const emitter = degit('company/templates/react-admin#v2.0.0', {
cache: false,
force: true,
});
await emitter.clone(targetDir);

完整实现代码参见上方「脚手架设计」章节。

Q57: 如何治理 npm 生命周期脚本的供应链风险?

答案

依赖安装时的 preinstall/install/postinstall 可以执行本机代码,风险不只来自直接依赖,也可能来自传递依赖和被劫持的版本。

治理手段:

  • 在 CI 使用锁文件和冻结安装,审查锁文件中的新增包与完整性变化。
  • 对高风险环境禁用或白名单安装脚本,再为确有必要的原生构建单独授权。
  • 使用最小权限、隔离网络和无长期凭据的构建容器。
  • 持续做依赖漏洞、恶意包、许可证和维护状态扫描。
  • 内部 Registry、制品签名和来源证明用于提升可追溯性。
  • 不把“通过 npm audit”视为完整供应链安全。

Q58: 如何测试 React 组件?React Testing Library 的核心理念?

答案

React Testing Library(RTL)是当前 React 组件测试的标准工具。它的核心理念是 "The more your tests resemble the way your software is used, the more confidence they can give you"——测试越接近用户的真实使用方式,测试就越有价值。

核心理念

理念说明对比 Enzyme
按用户行为测试通过角色、文本等用户可感知的信息查询元素Enzyme 鼓励测试组件内部 state 和 props
不测试实现细节不关心组件内部状态、生命周期、方法名Enzyme 的 shallow / instance() 暴露内部
可访问性驱动优先使用 getByRolegetByLabelTextEnzyme 通常用 CSS 选择器或组件名
真实 DOM渲染到真实 DOM(jsdom),更接近浏览器Enzyme 的 shallow 不渲染子组件

查询方法优先级(面试必考)

testing/query-priority.ts
// 1. getByRole —— 最推荐,按 ARIA 角色查询
screen.getByRole('button', { name: '提交' });
screen.getByRole('heading', { level: 2 });
screen.getByRole('textbox', { name: '用户名' });

// 2. getByLabelText —— 表单元素首选
screen.getByLabelText('邮箱');

// 3. getByPlaceholderText —— 没有 label 时的备选
screen.getByPlaceholderText('请输入搜索关键词');

// 4. getByText —— 按可见文本查询
screen.getByText('登录成功');
screen.getByText(/欢迎.*张三/); // 支持正则

// 5. getByDisplayValue —— 按表单当前值
screen.getByDisplayValue('test@example.com');

// 6. getByTestId —— 兜底方案,不推荐滥用
screen.getByTestId('custom-element');

完整的组件测试示例

components/__tests__/SearchForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';

interface SearchResult {
id: string;
title: string;
}

interface SearchFormProps {
onSearch: (query: string) => Promise<SearchResult[]>;
}

function SearchForm({ onSearch }: SearchFormProps) {
const [query, setQuery] = React.useState('');
const [results, setResults] = React.useState<SearchResult[]>([]);
const [loading, setLoading] = React.useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const data = await onSearch(query);
setResults(data);
setLoading(false);
};

return (
<form onSubmit={handleSubmit}>
<label htmlFor="search">搜索</label>
<input
id="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={!query}>搜索</button>
{loading && <p>加载中...</p>}
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</form>
);
}

import React from 'react';

describe('SearchForm', () => {
it('should disable button when input is empty', () => {
render(<SearchForm onSearch={vi.fn()} />);
// 用 getByRole 查询按钮,而非 CSS 选择器
expect(screen.getByRole('button', { name: '搜索' })).toBeDisabled();
});

it('should call onSearch with query and display results', async () => {
const user = userEvent.setup();
const mockSearch = vi.fn().mockResolvedValue([
{ id: '1', title: 'React 入门' },
{ id: '2', title: 'React 进阶' },
]);

render(<SearchForm onSearch={mockSearch} />);

// 用 getByLabelText 查询输入框
await user.type(screen.getByLabelText('搜索'), 'React');
await user.click(screen.getByRole('button', { name: '搜索' }));

// 验证 onSearch 被正确调用
expect(mockSearch).toHaveBeenCalledWith('React');

// 等待异步结果渲染
await waitFor(() => {
expect(screen.getByText('React 入门')).toBeInTheDocument();
expect(screen.getByText('React 进阶')).toBeInTheDocument();
});
});

it('should show loading state during search', async () => {
const user = userEvent.setup();
// 模拟延迟返回
const mockSearch = vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 100))
);

render(<SearchForm onSearch={mockSearch} />);

await user.type(screen.getByLabelText('搜索'), 'test');
await user.click(screen.getByRole('button', { name: '搜索' }));

// 验证 loading 状态
expect(screen.getByText('加载中...')).toBeInTheDocument();

await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
});
});

测试自定义 Hooks

hooks/__tests__/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useLocalStorage } from '../useLocalStorage';

describe('useLocalStorage', () => {
it('should return initial value', () => {
const { result } = renderHook(() =>
useLocalStorage('key', 'default')
);
expect(result.current[0]).toBe('default');
});

it('should update value and persist to localStorage', () => {
const { result } = renderHook(() =>
useLocalStorage('theme', 'light')
);

// act 包裹状态更新
act(() => {
result.current[1]('dark');
});

expect(result.current[0]).toBe('dark');
expect(localStorage.getItem('theme')).toBe('"dark"');
});
});
面试要点

面试中被问到"如何测试 React 组件"时,核心回答三点:

  1. 使用 React Testing Library,按用户行为测试,不测实现细节
  2. 查询优先级:getByRole > getByLabelText > getByText > getByTestId
  3. 使用 userEvent(而非 fireEvent)模拟用户交互,因为它更接近真实浏览器行为

Q59: CI 缓存为什么可能导致错误构建?怎样设计缓存键?

答案

CI 缓存若没有包含真正影响产物的输入,可能把旧依赖、旧编译结果或错误平台二进制复用到新构建中;缓存键过细又会长期不命中。

缓存键通常包含:

  • 操作系统、CPU 架构和关键运行时版本;
  • 包管理器锁文件摘要;
  • 构建工具及相关配置摘要;
  • 必要的目标环境或 Feature Flag;
  • 可接受的回退前缀,用于复用部分缓存。

依赖下载缓存与构建产物缓存应分开。缓存命中后仍要做类型检查、测试和产物验证,并提供一键绕过缓存的排障路径;Secret 不能进入可被不可信分支读取的共享缓存。

Q60: pnpm workspace 和 npm workspace 的区别?

答案

pnpm workspace 和 npm workspace 都是包管理器内置的 Monorepo 支持,但在底层实现、依赖管理策略和功能丰富度上有显著差异。

核心区别对比

对比维度pnpm workspacenpm workspace
node_modules 结构非扁平(嵌套 + 符号链接)扁平化(hoisting)
幽灵依赖天然杜绝存在风险
磁盘空间全局 store + 硬链接,极省空间每个项目独立副本
安装速度快 2-3 倍较慢
workspace 协议workspace:* / workspace:^无专用协议,使用 * 或文件路径
过滤器语法--filter 功能强大--workspace 较基础
严格模式默认严格(非扁平)无严格模式
.npmrc 配置丰富的 hoist 控制选项基础配置
配置文件pnpm-workspace.yamlpackage.jsonworkspaces 字段

依赖结构对比

workspace/dependency-comparison.ts
// ===== npm workspace 的 node_modules 结构 =====
// 扁平化 -> 所有依赖提升到根 node_modules
// 问题:packages/app 没有声明 lodash,却能 import lodash
//
// node_modules/
// ├── react/ ← 提升到根
// ├── lodash/ ← 提升到根(幽灵依赖!)
// └── @myorg/
// └── ui -> ../../packages/ui ← 符号链接

// ===== pnpm workspace 的 node_modules 结构 =====
// 非扁平 -> 每个包只能访问自己声明的依赖
//
// node_modules/
// ├── .pnpm/ ← 虚拟存储(硬链接到全局 store)
// │ ├── react@18.2.0/
// │ │ └── node_modules/react/ ← 真实文件
// │ └── lodash@4.17.21/
// │ └── node_modules/lodash/
// └── @myorg/
// └── ui -> .pnpm/@myorg+ui/ ← 只有声明了的才有链接
//
// packages/ui/node_modules/
// └── react -> ../../node_modules/.pnpm/react@18.2.0/
// ← 只有 ui 的 package.json 中声明了 react 才会有这个链接

workspace 协议差异

pnpm workspace 的引用方式
{
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:^1.0.0"
}
}
npm workspace 的引用方式
{
"dependencies": {
"@myorg/ui": "*",
"@myorg/utils": "file:../../packages/utils"
}
}

pnpm 的 workspace: 协议在 npm publish 时会被自动转换为真实版本号(如 workspace:* -> 1.3.0),而 npm workspace 使用 file:* 引用,发布时需要手动处理。

过滤器命令对比

pnpm vs npm workspace 命令
# --- pnpm (功能更强大) ---
pnpm --filter @myorg/web dev # 运行指定包
pnpm --filter @myorg/web... build # 运行指定包及其所有依赖
pnpm --filter ...@myorg/ui build # 运行依赖指定包的所有包
pnpm --filter "./packages/*" lint # glob 过滤
pnpm --filter ...[origin/main] test # 只运行 git 变更涉及的包

# --- npm (相对基础) ---
npm run dev --workspace=@myorg/web # 运行指定包
npm run build --workspaces # 运行所有包
npm run lint --workspace=packages/ui # 通过路径指定包
# npm 不支持依赖链过滤和 git diff 过滤
选择建议
  • 新项目:强烈推荐 pnpm workspace。非扁平结构杜绝幽灵依赖、workspace: 协议语义清晰、--filter 功能强大、磁盘和速度优势明显。
  • 已有 npm 项目:如果项目简单、包数量少,npm workspace 够用;如果遇到幽灵依赖或性能问题,建议迁移到 pnpm。
  • yarn:yarn v1 的 workspace 与 npm 类似(扁平化),yarn berry (v2+) 的 PnP 模式也能解决幽灵依赖,但兼容性不如 pnpm。

相关链接