前端工程化热门面试题
使用说明
本篇共 60 道题。
工程化面试重视体系和落地:为什么建、解决谁的问题、如何渐进推广、怎样度量。回答要避免只罗列工具名称。
Q1: 你如何理解前端工程化?
答案:
工程化是用规范、工具、平台和流程把开发、测试、构建、发布、监控和协作变得可重复、可度量、可治理。
目标不是“工具越多越专业”,而是降低交付成本和风险,让团队在规模扩大后仍能稳定迭代。
- 工程化覆盖从本地开发到生产反馈的完整链路:包管理、构建、规范、测试、CI/CD、发布、监控和依赖治理。
- 目标不是工具越多越好,而是用标准化和自动化降低交付周期、故障率与协作成本,并能用指标证明收益。
Q2: ESLint 和 Prettier 怎么分工?
答案:
ESLint 和 Prettier 的定位完全不同:
| 维度 | ESLint | Prettier |
|---|---|---|
| 核心职责 | 代码质量检查(逻辑错误、最佳实践) | 代码格式化(外观统一) |
| 典型规则 | no-unused-vars、no-implicit-coercion、react-hooks/exhaustive-deps | 缩进、引号、分号、换行、尾逗号 |
| 自动修复 | 部分规则支持 --fix | 100% 可自动修复 |
| 配置哲学 | 高度可配置,数百条规则 | 强约定,极少配置项 |
为什么要同时使用:
- ESLint 擅长发现代码质量问题(如未使用的变量、错误的 Hooks 使用方式、潜在的类型问题),这些 Prettier 无法处理。
- Prettier 擅长统一代码外观,且支持 CSS、JSON、Markdown 等多种语言,覆盖面比 ESLint 的格式规则更广。
- 单独使用 ESLint 做格式化会导致大量格式规则需要配置和维护;单独使用 Prettier 又无法发现逻辑问题。两者配合是最佳方案。
解决冲突的方法:
安装 eslint-config-prettier,它会关闭 ESLint 中所有与 Prettier 冲突的格式规则:
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)自动解决重复出现的冲突
# 开启 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 PnP | Plug'n'Play 模式,严格依赖解析 | 兼容性待验证 |
| 定期审计 | depcheck 工具检查未声明依赖 | 事后补救 |
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 核心原理:
// 简化版 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 | 未定义变量、类型错误 |
| 未捕获的 Promise | unhandledrejection | 未 catch 的 async 函数 |
| 资源加载错误 | addEventListener('error', ..., true) | 图片 404、CDN 挂了 |
| 框架组件错误 | Error Boundary / errorHandler | 渲染阶段异常 |
| 接口异常 | 拦截 fetch/XHR | 500 错误、超时 |
完整的错误监控实现:
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 合并到 main | merge --no-ff | 保留 feature 分支历史 |
| 在 feature 上同步 main 最新代码 | rebase | 保持 feature 分支线性 |
| 已推送到远程的公共分支 | merge | 不改写历史,安全 |
| 本地未推送的私有分支 | rebase | 线性历史,整洁 |
| 整理本地多个零碎 commit | rebase -i(交互式) | squash 合并提交 |
# 推荐工作流:在 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/样式选择器、设计令牌、类型、可访问性行为和运行环境都可能影响消费者,不只是函数签名。
治理方式通常包括:
- 用变更集记录每个包的版本影响和迁移说明。
- 废弃能力先告警并保留兼容周期,再在主版本移除。
- 为类型、渲染、交互和视觉结果建立回归测试,并用真实下游项目做 canary 验证。
- 提供 Codemod、自动修复或兼容层降低迁移成本。
- 明确 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. 安全前缀机制
| 工具 | 前缀 | 说明 |
|---|---|---|
| Vite | VITE_ | 只有 VITE_ 前缀的变量才暴露给前端代码 |
| CRA (Webpack) | REACT_APP_ | 只有 REACT_APP_ 前缀的变量才暴露 |
| Next.js | NEXT_PUBLIC_ | 只有 NEXT_PUBLIC_ 前缀的变量才暴露 |
3. 替换时机对比
// 源代码
if (import.meta.env.DEV) {
enableDevTools();
}
// 生产构建后:整个 if 块被移除(Tree Shaking)
// import.meta.env.DEV 被替换为 false,压缩工具识别为死代码
// 源代码
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 build | docker run |
CMD 与 ENTRYPOINT 的区别:
FROM node:20-alpine
CMD ["node", "server.js"]
# docker run myapp -> 执行 node server.js
# docker run myapp node test.js -> CMD 被覆盖,执行 node test.js
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 被覆盖)
| 对比项 | CMD | ENTRYPOINT |
|---|---|---|
| 是否可被覆盖 | 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 关联
- 固定的 Cluster IP 和 DNS 名称(如
简单类比: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 的核心区别在于底层架构和生态定位:
| 对比维度 | Jest | Vitest |
|---|---|---|
| 模块转换 | 通过 Babel/SWC 将 ESM 转为 CJS | 基于 Vite,原生支持 ESM |
| TypeScript | 需要 ts-jest 或 @swc/jest | 原生支持,零配置 |
| 配置 | 独立配置文件,需配置转换器和路径映射 | 复用 vite.config,配置统一 |
| 热重载 | 文件变更触发相关测试 | 基于 Vite HMR,速度更快 |
| 并发 | Worker 进程级隔离 | 线程级隔离,更轻量 |
Vitest 越来越流行的原因:
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 Actions | GitLab CI |
|---|---|---|
| 架构设计 | 事件驱动,基于 Event + Workflow | 阶段驱动,基于 Stage + Pipeline |
| 配置方式 | 多文件(.github/workflows/),按场景拆分 | 单文件(.gitlab-ci.yml),集中管理 |
| 复用机制 | Marketplace Action + Reusable Workflow | include + 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) 来判断缓存是否命中。
哈希指纹的组成:
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 | 同源跨标签页 | 原生支持、实时 | 仅同源 |
推荐的通信架构:
/** 类型安全的微前端 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)的核心区别在于:
| 特性 | sendBeacon | XHR/fetch |
|---|---|---|
| 页面卸载后是否继续 | ✅ 浏览器保证发送 | ❌ 可能被取消 |
| 是否阻塞页面关闭 | ❌ 异步、不阻塞 | 同步 XHR 会阻塞 |
| 请求类型 | POST | 任意方法 |
| 能否获取响应 | ❌ 没有回调 | ✅ 可获取响应 |
| 优先级 | 低优先级队列 | 正常优先级 |
| 数据量限制 | 通常 64KB | 无硬性限制 |
| 返回值 | boolean(是否成功入队) | Promise/回调 |
推荐使用 sendBeacon 的原因:
-
页面卸载时的可靠性:用户关闭页面、跳转时,普通 AJAX 请求可能被浏览器取消。sendBeacon 将请求放入浏览器内部的发送队列,即使页面已经卸载也会完成发送。
-
不阻塞页面关闭:早期为了保证数据上报,开发者使用同步 XHR 来阻塞页面关闭,这会严重影响用户体验。sendBeacon 天然异步且不阻塞。
-
对用户体验无影响: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 是一套结构化的提交信息格式规范,核心好处有三个:
- 自动生成 Changelog:工具(如 conventional-changelog)可以根据 commit type 自动分类生成版本变更记录
- 自动决定版本号:配合 Semantic Versioning,
fix触发 patch、feat触发 minor、BREAKING CHANGE触发 major - 提高可读性:团队成员一眼就能看出每个提交的类型和影响范围
BREAKING CHANGE(破坏性变更)指的是不向后兼容的 API 变更。标记方式有两种:
# 方式一:在 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)。
自动化工具链示例:
// 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 等不会被打包
组件库需要满足以下条件:
{
"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 Shaking | babel-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 | 创建新 Pod | K8s 创建一个新版本 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
统一最基础的编码格式,确保不同编辑器的开发者写出一致的代码:
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
- Yarn
- pnpm
- Bun
npm install --save-dev eslint typescript-eslint @eslint/js stylelint stylelint-config-standard-scss
yarn add --dev eslint typescript-eslint @eslint/js stylelint stylelint-config-standard-scss
pnpm add --save-dev eslint typescript-eslint @eslint/js stylelint stylelint-config-standard-scss
bun add --dev eslint typescript-eslint @eslint/js stylelint stylelint-config-standard-scss
3. 代码格式层 -- Prettier
统一代码风格,消除团队内的格式争论:
- npm
- Yarn
- pnpm
- Bun
npm install --save-dev prettier eslint-config-prettier
yarn add --dev prettier eslint-config-prettier
pnpm add --save-dev prettier eslint-config-prettier
bun add --dev prettier eslint-config-prettier
4. Git 提交层 -- Husky + lint-staged + commitlint
在提交时强制执行检查,确保不合规代码无法进入仓库:
- npm
- Yarn
- pnpm
- Bun
npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
yarn add --dev husky lint-staged @commitlint/cli @commitlint/config-conventional
pnpm add --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
bun add --dev husky lint-staged @commitlint/cli @commitlint/config-conventional
- npm
- yarn
- pnpm
npx husky init
yarn husky init
pnpm husky init
5. CI/CD 层 -- 流水线兜底
在 CI 中运行全量检查,作为最后一道防线:
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 在模块层替换导入。两者的根本区别如下:
| 对比维度 | MSW | vi.mock / jest.mock |
|---|---|---|
| 拦截层级 | 网络层(Service Worker) | 模块导入层 |
| HTTP 客户端 | 无关(fetch/axios/XHR 通用) | 需要 mock 具体的客户端模块 |
| 请求验证 | 可以验证请求体、请求头等 | 只能验证模块函数的调用参数 |
| 复用性 | 测试 + 开发环境通用 | 仅测试环境 |
| 真实度 | 更接近真实网络请求 | 完全跳过网络层 |
MSW 的核心优势在于与 HTTP 客户端解耦:
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 模块,耦合度更高:
// 如果业务代码从 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 已被新发布删除。
可靠策略包括:
- JS/CSS 使用内容哈希文件名并长期缓存,不覆盖同名文件。
- 先上传新静态资源,再切换 HTML/路由入口。
- 旧版本资源保留一个安全窗口,不在发布后立即清理。
- 动态导入失败时识别版本错配,提供一次受控刷新或恢复 UI,避免无限刷新。
- HTML 使用短缓存或可验证缓存,CDN 清理按入口与资源分开。
- 发布清单、监控和回滚都绑定同一版本号。
Q48: 在 Monorepo 中如何实现跨包的代码共享和类型安全?
答案:
在 Monorepo 中,跨包代码共享主要通过以下方式实现:
1. 使用 workspace 协议声明内部依赖:
{
"name": "@myorg/web",
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:*"
}
}
2. 内部包直接导出 TypeScript 源码(推荐方式):
这种方式不需要预先编译内部包,应用层直接引用源码:
{
"name": "@myorg/utils",
"version": "1.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
export function formatDate(date: Date, format: string): string {
// 实现...
return '';
}
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
// 直接引用内部包,获得完整的类型提示和代码跳转
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 项目引用保证类型安全:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist"
},
"references": [
{ "path": "../utils" },
{ "path": "../types" }
]
}
4. 共享类型定义包:
创建独立的 types 包来存放跨包共用的类型:
// 全局共享的类型定义
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>;
// 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 包 | 类型集中管理 | 多一个包要维护 | 类型需要多包复用 |
Q49: qiankun 和 Module Federation 有什么区别?如何选择?
答案:
qiankun 和 Module Federation 是两种不同层面的微前端方案,解决的核心问题不同:
| 对比维度 | qiankun | Module Federation |
|---|---|---|
| 定位 | 完整的微前端框架 | Webpack 5 模块共享能力 |
| 隔离能力 | JS 沙箱 + CSS 隔离 | 无隔离(共享运行环境) |
| 技术栈 | 技术栈无关 | 通常要求相同构建工具 |
| 加载方式 | HTML Entry(加载整个页面) | JS Entry(加载模块) |
| 共享粒度 | 应用级别 | 模块/组件级别 |
| 部署独立性 | 完全独立部署 | 独立部署,但共享依赖需版本协调 |
| 通信方式 | props / GlobalState / Event | 共享模块、直接 import |
| 构建工具 | 无要求 | 必须 Webpack 5+ / Rspack |
| 适用场景 | 大型异构系统集成 | 同技术栈的模块/组件共享 |
选型建议:
// 伪代码:选型决策树
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):
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 Variables | CSS-in-JS | 零运行时 CSS-in-JS |
|---|---|---|---|
| 运行时性能 | 极佳(原生) | 有开销 | 极佳(编译时生成) |
| 动态主题 | 支持 | 完全支持 | 支持 |
| 类型安全 | 不支持 | 完全支持 | 支持 |
| SSR 兼容 | 天然支持 | 需要额外配置 | 天然支持 |
| 包体积 | 零额外依赖 | 需引入运行时 | 接近零 |
| 代表方案 | Ant Design 5 | MUI、Emotion | vanilla-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 下的键名)作为主机名互相访问。
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:
通信链路:
关键要点:
depends_on:控制启动顺序(但不保证服务就绪,需配合healthcheck)exposevsports:expose仅在容器网络内开放端口,ports映射到宿主机- 服务名即主机名:
proxy_pass http://backend:3000中的backend就是服务名 - 环境变量注入:后端通过
DB_HOST=database连接数据库,database也是服务名
# 前端 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 中的后端服务:
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 Compose | Kubernetes |
|---|---|---|
| 运行环境 | 单台机器 | 多节点集群 |
| 高可用 | 无,单机故障即服务中断 | 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、getByLabelText | Enzyme 通常用 CSS 选择器或组件名 |
| 真实 DOM | 渲染到真实 DOM(jsdom),更接近浏览器 | Enzyme 的 shallow 不渲染子组件 |
查询方法优先级(面试必考):
// 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');
完整的组件测试示例:
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:
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 组件"时,核心回答三点:
- 使用 React Testing Library,按用户行为测试,不测实现细节
- 查询优先级:
getByRole>getByLabelText>getByText>getByTestId - 使用
userEvent(而非fireEvent)模拟用户交互,因为它更接近真实浏览器行为
Q59: CI 缓存为什么可能导致错误构建?怎样设计缓存键?
答案:
CI 缓存若没有包含真正影响产物的输入,可能把旧依赖、旧编译结果或错误平台二进制复用到新构建中;缓存键过细又会长期不命中。
缓存键通常包含:
- 操作系统、CPU 架构和关键运行时版本;
- 包管理器锁文件摘要;
- 构建工具及相关配置摘要;
- 必要的目标环境或 Feature Flag;
- 可接受的回退前缀,用于复用部分缓存。
依赖下载缓存与构建产物缓存应分开。缓存命中后仍要做类型检查、测试和产物验证,并提供一键绕过缓存的排障路径;Secret 不能进入可被不可信分支读取的共享缓存。
Q60: pnpm workspace 和 npm workspace 的区别?
答案:
pnpm workspace 和 npm workspace 都是包管理器内置的 Monorepo 支持,但在底层实现、依赖管理策略和功能丰富度上有显著差异。
核心区别对比:
| 对比维度 | pnpm workspace | npm workspace |
|---|---|---|
| node_modules 结构 | 非扁平(嵌套 + 符号链接) | 扁平化(hoisting) |
| 幽灵依赖 | 天然杜绝 | 存在风险 |
| 磁盘空间 | 全局 store + 硬链接,极省空间 | 每个项目独立副本 |
| 安装速度 | 快 2-3 倍 | 较慢 |
| workspace 协议 | workspace:* / workspace:^ | 无专用协议,使用 * 或文件路径 |
| 过滤器语法 | --filter 功能强大 | --workspace 较基础 |
| 严格模式 | 默认严格(非扁平) | 无严格模式 |
| .npmrc 配置 | 丰富的 hoist 控制选项 | 基础配置 |
| 配置文件 | pnpm-workspace.yaml | package.json 的 workspaces 字段 |
依赖结构对比:
// ===== 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 协议差异:
{
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:^1.0.0"
}
}
{
"dependencies": {
"@myorg/ui": "*",
"@myorg/utils": "file:../../packages/utils"
}
}
pnpm 的 workspace: 协议在 npm publish 时会被自动转换为真实版本号(如 workspace:* -> 1.3.0),而 npm workspace 使用 file: 或 * 引用,发布时需要手动处理。
过滤器命令对比:
# --- 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。