跳到主要内容

前端工程化热门面试题

使用说明​

本篇共 60 道题。

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

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

答案:

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

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

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

Q2: ESLint 和 Prettier 怎么分工?​

答案:

ESLint 和 Prettier 的定位完全不同:

维度ESLintPrettier
核心职责代码质量检查(逻辑错误、最佳实践)代码格式化(外观统一)
典型规则no-unused-vars、no-implicit-coercion、react-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.meta 是 ESM 标准 的一部分,是浏览器原生支持的语法。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-commit 和 commit-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
});
}
}
});
补充

fetch 的 keepalive: 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 源码,让应用层负责编译。
  • 需要发布的包:使用 tsup 或 unbuild 预编译,同时生成 .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_SECRET、SESSION_SECRET)
  • 第三方服务的 Secret Key(AWS_SECRET_ACCESS_KEY、STRIPE_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-secrets、detect-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 ports:expose 仅在容器网络内开放端口,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() 暴露内部
可访问性驱动优先使用 getByRole、getByLabelTextEnzyme 通常用 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.json 的 workspaces 字段

依赖结构对比:

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。

相关链接​