07-engineering
面向大厂 P6 前端面试的标准答案集。题目来源:腾讯 WXG / PDD / 字节真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 代码)→ 【追问 & 加分点】。
工程化与性能
面向大厂 P6 前端面试的标准答案集。题目来源:腾讯 WXG / PDD / 字节真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 代码)→ 【追问 & 加分点】。
目录
- 1. Webpack 对动态 import() 是怎么处理的
- 2. 哪些情况会导致动态 import 没拆出独立 chunk
- 3. Webpack vs Vite 原理对比
- 4. Monorepo 大仓方案
- 5. CI/CD 流水线与前端部署
- 6. 前端性能指标体系与 LCP
- 7. Performance 面板用来定位什么问题
- 8. 体系化性能优化清单
- 9. 场景题:页面卡顿排查
1. Webpack 对动态 import() 是怎么处理的
【核心答案】
import() 在 webpack 里不是运行时的语法糖,而是编译期的代码分割信号。构建时 webpack 解析 AST 遇到 ImportDependency,会在依赖图上从该点切一刀,生成一个新的 chunk(异步 chunk / async chunk group),把这个模块及其独有依赖打进去;调用点被改写成 __webpack_require__.e(chunkId).then(() => __webpack_require__(moduleId))。运行时 .e 查 chunk 映射表拿到文件名,通过 JSONP(注入 <script>)或 ESM import()(取决于 output.chunkLoading)加载,脚本执行时调用 webpackChunk.push([[chunkIds], modules]) 把模块塞进 __webpack_modules__ 并 resolve 对应 Promise,然后同步执行模块工厂函数拿到 exports。整条链路是:AST 识别 → 切分依赖图 → splitChunks 二次优化 → 产出 chunk + 映射表 → 运行时 .e 加载 → JSONP 回调注册 → __webpack_require__ 执行。
【展开】
阶段一:编译期怎么拆 chunk
-
依赖收集:
Parser(acorn)遍历 AST,遇到import()调用表达式时,ImportParserPlugin生成一个ImportDependency(以及ImportEagerDependency等变体),并把 magic comment 解析出的chunkName / mode / prefetch / preload / exports / include / exclude挂到依赖上。 -
构建 ChunkGraph:
compilation.seal()阶段先按 entry 建立 initial chunk,然后遍历模块图,凡是通过异步依赖边(AsyncDependenciesBlock)到达的模块,都会新建一个 ChunkGroup(异步 chunk group),成为 initial chunk 的 child。这是"切一刀"的本质:异步依赖块 = chunk 边界。 -
模块归属:默认规则下,一个模块被放进「所有能到达它的 chunk」里。所以如果
a.js同时被主 bundle 同步引用、又被某个异步 chunk 引用,它会同时存在于两个 chunk(这就是重复打包问题)。 -
splitChunks 二次优化:
SplitChunksPlugin在optimizeChunks钩子上工作,按cacheGroups(minSize / maxSize / minChunks / chunks: 'all'|'async'|'initial' / priority / test)把被多个 chunk 共享的模块抽出成新的公共 chunk,并把它挂到原 chunk group 的依赖上。注意chunks: 'all'才会跨越同步/异步边界去合并,默认的'async'只处理异步 chunk。 -
产物:每个 chunk 输出一个文件(
[name].[contenthash].js),同时 runtime chunk 里内联两张表:- chunkId → 文件名映射(
__webpack_require__.u,通常是一个把 id 映射到chunkName + hash的函数) - 已安装 chunk 状态表(
installedChunks)
- chunkId → 文件名映射(
阶段二:运行时怎么加载
编译后的调用点(webpack 5,简化):
// 源码
const mod = await import(/* webpackChunkName: "editor" */ './editor');
// 编译后大致等价于
__webpack_require__
.e(/* import() | editor */ 792) // 加载 chunk 792
.then(__webpack_require__.bind(__webpack_require__, 5142)); // 执行模块 5142运行时核心实现(webpack 5 生成的 runtime,加了中文注释):
// ---- 1) chunk 加载状态表 ----
// 值的含义:
// undefined = 未加载
// null = 已通过 prefetch/preload 预加载但未安装
// 0 = 已安装完成
// [resolve, reject, promise] = 正在加载中
var installedChunks = { main: 0 };
// ---- 2) chunkId -> 文件名 ----
__webpack_require__.u = (chunkId) =>
'' + chunkId + '.' + { 792: 'a1b2c3d4' }[chunkId] + '.js';
// ---- 3) 通用 chunk 加载入口:ensure chunk ----
__webpack_require__.e = (chunkId) => {
return Promise.all(
// handlers 是一组「加载方式」,如 j = jsonp、css、miniCss 等
Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises);
return promises;
}, [])
);
};
// ---- 4) JSONP 方式的 handler ----
__webpack_require__.f.j = (chunkId, promises) => {
var installed = installedChunks[chunkId];
if (installed === 0) return; // 已装好,直接返回
if (installed) { // 正在加载,复用同一个 promise(天然去重)
promises.push(installed[2]);
return;
}
// 首次加载:先占坑,把 resolve/reject 存进状态表
var promise = new Promise((resolve, reject) => {
installed = installedChunks[chunkId] = [resolve, reject];
});
promises.push((installed[2] = promise));
// 注入 <script>
var url = __webpack_require__.p /* publicPath */ + __webpack_require__.u(chunkId);
var script = document.createElement('script');
script.src = url;
script.charset = 'utf-8';
script.timeout = 120;
if (__webpack_require__.nc) script.setAttribute('nonce', __webpack_require__.nc);
// 失败/超时处理:把状态表复位,并 reject,让业务侧可以重试
var onError = (event) => {
script.onerror = script.onload = null;
var chunkEntry = installedChunks[chunkId];
if (chunkEntry !== 0) {
if (chunkEntry) chunkEntry[1](new Error('Loading chunk ' + chunkId + ' failed.'));
installedChunks[chunkId] = undefined; // 复位,允许下次重新加载
}
};
script.onerror = onError;
document.head.appendChild(script);
};
// ---- 5) chunk 文件自身的「自注册」回调 ----
// 每个异步 chunk 文件的内容形如:
// (self.webpackChunkMyApp = self.webpackChunkMyApp || []).push([[792], { 5142: (m,e,r)=>{...} }]);
var chunkLoadingGlobal = (self.webpackChunkMyApp = self.webpackChunkMyApp || []);
var webpackJsonpCallback = (data) => {
var [chunkIds, moreModules, runtime] = data;
// 1. 把新模块工厂函数合并进模块表
for (var moduleId in moreModules) {
__webpack_require__.m[moduleId] = moreModules[moduleId];
}
if (runtime) runtime(__webpack_require__);
// 2. 把这些 chunk 标记为已安装,并 resolve 之前挂起的 promise
for (const chunkId of chunkIds) {
if (installedChunks[chunkId]) installedChunks[chunkId][0](); // resolve
installedChunks[chunkId] = 0;
}
};
// 劫持数组的 push:这样「先加载的 chunk 文件」也能被正确消费
chunkLoadingGlobal.forEach(webpackJsonpCallback);
chunkLoadingGlobal.push = webpackJsonpCallback;执行链路总结(一句话串起来):
import('./editor')
→ __webpack_require__.e(792)
→ f.j(792) → installedChunks[792] = [resolve, reject] → 注入 <script src="792.hash.js">
→ 792.hash.js 下载执行
→ webpackChunkMyApp.push([[792], { 5142: factory }])
→ 被劫持的 push = webpackJsonpCallback
→ __webpack_require__.m[5142] = factory
→ installedChunks[792][0]() // resolve
→ installedChunks[792] = 0
→ .then(() => __webpack_require__(5142))
→ 查 __webpack_module_cache__,未命中则执行 factory,缓存 module.exports
→ 拿到模块命名空间对象(webpack 会包一层 __webpack_require__.t 处理 ESM interop / __esModule 标记)几个容易被追问的细节
__webpack_require__.e只负责"chunk 文件到位",不负责"模块执行"。执行是后面的__webpack_require__(moduleId)干的,且是同步的。所以import()返回的 Promise,其 microtask 里包含了模块的同步求值。- 依赖的依赖:如果目标 chunk 依赖公共 chunk(splitChunks 抽出来的 vendors),
.e会返回Promise.all([...]),把所有前置 chunk 一起 ensure。 - 并发去重:靠
installedChunks[chunkId]里存的 promise,同一个 chunk 并发import()只会注入一次 script。 - webpack 5 支持多种 chunkLoading:
jsonp(浏览器默认)、import(output.module: true时用原生动态 import)、require(Node CJS)、import-scripts(Worker,用importScripts)。面试说"注入 script 标签"时最好补一句"这是 jsonp 模式,webpack 5 还可以输出原生 ESM"。
【追问 & 加分点】
- chunk load 失败怎么办? 上面的
onError会把installedChunks[chunkId]复位为undefined,所以重试是可行的。生产上常见做法:包一层retryImport(() => import('./x'), 3),或用output.crossOriginLoading+chunkLoadTimeout配合 CDN 多域名重试。React 里还要配合lazy+ErrorBoundary,否则一次网络抖动就白屏。 webpackPrefetchvswebpackPreload:prefetch 生成<link rel="prefetch">,在父 chunk 加载完、浏览器空闲时下载,优先级最低,用于"下一个页面可能要用";preload 生成<link rel="preload">,与父 chunk 并行下载、中等偏高优先级,用于"当前页面马上要用"。两者都只是下载进缓存,不执行。- contenthash 与长缓存:异步 chunk 的文件名映射表在 runtime chunk 里,所以业务 chunk 一变,runtime 内容就变。要做长效缓存必须
optimization.runtimeChunk: 'single'把 runtime 单独抽出来,否则 vendors 的 hash 会被"映射表变化"污染(webpack 5 用realContentHash已经缓解了大部分,但 runtimeChunk 仍是标准做法)。 - Module Federation 的关系:MF 的远程模块加载复用的正是这套
__webpack_require__.e+ chunk 映射机制,只是把publicPath换成远端容器的地址,并加了shared作用域的版本协商。
2. 哪些情况会导致动态 import 没拆出独立 chunk
【核心答案】
拆不出来无非三类原因:① webpack 静态分析不出目标(完全动态表达式)→ 要么打包整个目录,要么退化成运行时 require;② 同一模块已被同步引用 → 模块已在 initial chunk 里,异步 chunk 引用它时被判定为"已在父链上",不再重复产出有意义的分包(表现为异步 chunk 空壳甚至被优化掉);③ 配置/注释显式关掉了分包 → webpackMode: "eager"、splitChunks 的 minSize 阈值把小 chunk 合并回去、LimitChunkCountPlugin(maxChunks:1) 或 optimization.splitChunks: false 等。定位方法是 webpack --profile --json 出 stats,用 webpack-bundle-analyzer 看模块归属。
【展开】
① 完全动态表达式:无法静态分析
// ❌ 完全动态:webpack 无从下手
const name = getFromServer();
await import(name); // webpack 只能保留为运行时 require,产物里报 Critical dependency 警告
await import(`${base}/${name}`); // 同上(base 也是变量时)
// ⚠️ 部分动态:webpack 会做「上下文模块」(ContextModule)
await import(`./locales/${lang}.js`);
// webpack 扫描 ./locales 目录,把匹配 /^\.\/.*\.js$/ 的**所有文件**都打进来
// 每个文件是一个独立 chunk(这点常被误解),但如果目录里有 200 个语言包,
// 就会产出 200 个 chunk + 一张巨大的映射表,冷启动 stats 变慢。
// ✅ 正确姿势:用 magic comment 收窄范围
await import(
/* webpackInclude: /\.js$/ */
/* webpackExclude: /\.test\.js$/ */
/* webpackChunkName: "locale-[request]" */
`./locales/${lang}.js`
);
// ✅ 或者干脆用「静态映射表」把动态性挪到 JS 层
const loaders = {
zh: () => import(/* webpackChunkName: "locale-zh" */ './locales/zh'),
en: () => import(/* webpackChunkName: "locale-en" */ './locales/en'),
};
await loaders[lang]();关键点:
import()的参数中,至少要有一个静态的前缀(目录)部分,webpack 才能生成 ContextModule。纯变量会得到Critical dependency: the request of a dependency is an expression警告,最终变成同步 require 或运行时报错。
② 同时被同步引用(最常见的"业务事故")
// utils.js 被 App.js 同步 import
import { formatDate } from './utils'; // ← 进了 main chunk
// 另一处
const m = await import('./heavy-page'); // heavy-page 内部也 import 了 './utils'此时 utils 已在 main chunk(父 chunk) 中,webpack 判定"子 chunk 的父链上已有该模块",不会再打进 heavy-page 的 chunk —— 这是正确的优化。但真正的坑是反过来:
// ❌ 事故写法:既同步 import 又动态 import 同一个大模块
import Editor from './Editor'; // 同步引用 → Editor 进 main
// ...
const LazyEditor = lazy(() => import('./Editor')); // 动态引用 → 但模块已在 main,
// 生成的异步 chunk 只剩一个空壳/极小的 re-export结果就是:看起来做了懒加载,实际首屏体积一点没降。常见触发场景:
index.ts做了 barrel export(export * from './Editor'),别处import { something } from '@/components'把整个 barrel 拉进了同步图;- 某个
const routes = [...]配置文件顶部同步 import 了组件,路由里又写了lazy(() => import(...)); - 类型引用没用
import type,被 babel/ts 保留成了值引用(isolatedModules下尤其要注意,配importsNotUsedAsValues: 'error'或verbatimModuleSyntax可以拦住)。
排查手段:webpack-bundle-analyzer 里点开 main chunk 看目标模块在不在;或 stats.json 里搜模块的 chunks 字段看它属于哪些 chunk;--stats-reasons 能打出"谁引用了它"。
③ magic comment: webpackMode
// mode: 'lazy'(默认)—— 每个匹配的模块一个独立 chunk,按需加载
import(/* webpackMode: "lazy" */ `./pages/${p}`);
// mode: 'lazy-once' —— ContextModule 的所有匹配文件打成【一个】共享 chunk,
// 第一次调用加载,之后复用。适合"一定会全用到"的小集合。
import(/* webpackMode: "lazy-once" */ `./icons/${n}.svg`);
// mode: 'eager' —— ❗不产生任何独立 chunk,模块直接打进当前 chunk,
// 只是返回一个已 resolve 的 Promise。用于统一异步写法但不想分包。
import(/* webpackMode: "eager" */ './small-util');
// mode: 'weak' —— 完全不发起加载,模块必须已被别处加载好,否则 reject。
// 主要给 SSR 脱水/注水场景用。
import(/* webpackMode: "weak" */ './ssr-preloaded');eager 是最容易背锅的一个:有人为了消除某个 chunk 请求随手加了 eager,等于把懒加载完全废掉。
④ splitChunks / optimization 配置
module.exports = {
optimization: {
// ❌ 直接关掉分包
// splitChunks: false,
splitChunks: {
chunks: 'all',
// ⚠️ minSize 太大:小于该值的异步 chunk 不会被单独抽出,
// 会被合并回引用它的 chunk。webpack5 生产默认 20000 (20KB)。
minSize: 20000,
// ⚠️ maxAsyncRequests / maxInitialRequests:并行请求数上限,
// 超过后 webpack 会「放弃继续拆分」,把剩余模块合并。
// webpack5 默认 30/30,老项目从 webpack4 迁移时常见 5/3 导致拆不动。
maxAsyncRequests: 30,
maxInitialRequests: 30,
cacheGroups: {
// ⚠️ 一个 test 范围过大 + enforce:true 的 cacheGroup 会把
// 本来该独立的异步模块统统吸进同一个 vendors 包
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'all',
// enforce: true, // 忽略 minSize/maxSize 等限制,强制成组
},
},
},
},
plugins: [
// ❌ 这个插件会把 chunk 数量硬压到 1,动态 import 全部内联
// new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
],
};⑤ 其他容易踩的
- 同名
webpackChunkName:两个不同的import()写了同一个chunkName,webpack 会把它们合并成一个 chunk。有时是刻意为之(把几个小路由合并),有时是复制粘贴事故。 webpackChunkName: "[request]"在完全动态表达式下失效(没有 request 可插值)。- Babel 插件顺序:
@babel/plugin-transform-modules-commonjs如果作用到了源码(比如targets配置导致 ESM 被转 CJS),import()会被转成Promise.resolve().then(() => require(...)),webpack 就拆不出 chunk 了。修法:webpack 场景下 preset-env 必须modules: false。 - SSR / target: node +
output.library:chunkLoading: 'require'下虽然还会产出 chunk 文件,但对首屏体积没意义,容易误判。 - Next.js / Vite 等上层框架的额外策略:例如 Next 的
optimizePackageImports、Vite 的build.rollupOptions.output.manualChunks会二次改写分包结果。
【追问 & 加分点】
- 怎么快速证明"某个模块没被拆出去"?
webpack --json > stats.json后用statoscope或在线 analyzer 打开;命令行更快的方式是npx webpack --stats-reasons --stats-modules-space 999直接看 reasons 链。CI 里可以加size-limit/bundlesize做体积回归卡口,这是 P6 答案里的加分项——不是靠人眼看,而是靠流水线守住。 - 为什么
minSize拆不动的模块会"合并回去"而不是单独成包? 因为 HTTP 请求本身有开销(连接、头部、优先级调度),几 KB 的独立 chunk 收益为负。HTTP/2 多路复用缓解了但没消除(仍有帧调度与队头优先级问题)。 import()的返回值是 Module Namespace Object,不是 default 导出。const C = await import('./C')拿到的是{ default: C, __esModule: true },Reactlazy要求组件在default上,所以常见写法是lazy(() => import('./C').then(m => ({ default: m.Named })))。- Tree shaking 与动态 import 的交互:webpack 5 的
magic comment支持/* webpackExports: ["a", "b"] */,可以告诉 webpack 这次动态引入只用到哪些导出,从而对异步 chunk 也做 tree shaking——这是很少有人知道的加分点。
3. Webpack vs Vite 原理对比
【核心答案】
核心差异在 dev 阶段:webpack 是 bundle-based,启动时必须把整棵依赖图打包成内存产物才能服务第一个请求,所以冷启动时间 ∝ 项目模块数;Vite 是 native-ESM based(no-bundle),dev server 直接把源文件按 ESM 交给浏览器,浏览器沿 import 语句按需请求,Vite 只做单文件的按需转换(esbuild 转 TS/JSX),冷启动近似 O(1),HMR 只需失效一条模块链而不是重新 build 一个 chunk。build 阶段两者反而趋同:Vite 生产用 Rollup 做打包(Rust 版的 Rolldown 已可通过 rolldown-vite 尝鲜,官方计划在后续大版本成为默认),因为纯 ESM 在生产会产生瀑布式请求;webpack 5 也有持久化缓存和 lazyCompilation 来追赶 dev 体验。选型上:Vite 胜在 DX 和冷启动,webpack 胜在生态成熟度、复杂定制能力和大仓/微前端等重型场景。
【展开】
Dev 阶段
| 维度 | Webpack (dev-server) | Vite (dev) |
|---|---|---|
| 核心模型 | 打包后服务(bundle then serve) | 按需服务(serve on demand,no-bundle) |
| 冷启动 | 需构建完整 module graph + 生成 bundle,秒数 ∝ 模块数(大仓可到分钟级) | 仅启动 server + 依赖预构建,源码零打包,通常 < 1s |
| 转换器 | babel-loader / ts-loader / swc-loader(JS 实现居多,慢) | esbuild(Go,比 babel 快 10–100x),只做转译不做类型检查 |
| 依赖处理 | node_modules 与源码一视同仁,全进 bundle | 依赖预构建(pre-bundling):用 esbuild 把 CJS/UMD 依赖转 ESM,并把 lodash-es 这类"几百个小文件"的包打成单文件,避免浏览器发起几百个请求;结果缓存在 node_modules/.vite |
| 模块解析 | webpack 自己实现 resolver,产物是函数包裹的 __webpack_require__ | 重写 import 说明符为浏览器可识别的 URL:import x from 'vue' → /node_modules/.vite/deps/vue.js?v=hash |
| HMR | 需要重新构建受影响 chunk,随项目变大变慢 | 沿 import 关系图向上找最近的 HMR 边界(import.meta.hot.accept),只让那条链失效;浏览器重新 import 单个模块,速度与项目规模基本无关 |
| 缓存 | cache: { type: 'filesystem' } 持久化缓存(webpack 5) | HTTP 层:源码 304(协商缓存),依赖 Cache-Control: max-age=31536000, immutable(强缓存,靠 ?v=hash 破缓存) |
| 源码 sourcemap | 依赖 devtool 配置,eval-cheap-module-source-map 是常用折中 | esbuild 直出,基本无额外成本 |
Vite dev 的请求链路:
浏览器请求 /index.html
→ <script type="module" src="/src/main.ts">
→ GET /src/main.ts
→ Vite middleware:
1. 读文件
2. 走 plugin container 的 transform(esbuild 转 TS/JSX、Vue SFC 编译等)
3. **重写 import 说明符**(importAnalysis 插件):
'vue' → '/node_modules/.vite/deps/vue.js?v=abc'
'./App.vue' → '/src/App.vue'
4. 注入 HMR client 代码(import.meta.hot)
→ 返回标准 ESM
→ 浏览器沿 import 继续请求 /src/App.vue → 递归为什么必须做依赖预构建(两个理由,都要说):
- 格式:npm 上大量包仍是 CJS/UMD,浏览器原生 ESM 不认
require/module.exports,必须转。 - 性能:
lodash-es有 600+ 个内部模块,不预构建则浏览器发 600+ 个请求,dev 直接卡死。预构建把它们合成一个文件。
Build 阶段
| 维度 | Webpack | Vite |
|---|---|---|
| 打包器 | webpack 自身 | Rollup(Rolldown —— Rust 实现的 Rollup 兼容打包器 —— 可用 rolldown-vite 尝鲜,规划中逐步转正) |
| 产物形态 | __webpack_require__ 运行时 + 模块工厂函数,模块被包成函数 | Rollup 的 scope hoisting(把模块拍平到同一作用域),产物更接近手写代码,体积通常更小 |
| Tree shaking | webpack 4 起支持,依赖 sideEffects 字段 + ModuleConcatenation | Rollup 的强项,基于纯 ESM 静态分析,效果通常更彻底 |
| 代码分割 | splitChunks + optimization 一整套配置 | build.rollupOptions.output.manualChunks,默认策略更简单 |
| 压缩 | Terser(默认)/ SWC / esbuild | esbuild(默认,快)或 Terser(build.minify: 'terser',体积略小) |
| CSS | 靠 loader 链(css-loader / postcss-loader / MiniCssExtractPlugin) | 内置支持 CSS/PostCSS/预处理器/CSS Modules,开箱 |
| 兼容性 | 依赖 babel + core-js,可下探到很老的浏览器 | 默认 target 为支持原生 ESM 的浏览器;老浏览器要 @vitejs/plugin-legacy(会额外产出 legacy bundle + polyfill) |
为什么 Vite 生产不用 no-bundle:
- 请求瀑布:ESM 的依赖是逐层发现的,深层依赖要等父层下载解析完才知道存在。1000 个模块 = 1000 个请求 + 多层往返,在弱网/高 RTT 下灾难性。
- 无法做全局优化:tree shaking、scope hoisting、公共模块提取都要全图信息。
- HTTP/2 也救不了:多路复用解决的是连接数,解决不了「串行发现」和「每个请求的固定开销」。
一个必须澄清的常见误解
"Vite 快是因为用了 esbuild"
只对了一半。Vite dev 快的主因是 no-bundle 架构(不做全量打包),esbuild 只是让"单文件转换"和"依赖预构建"这两步更快的加速器。证据:Vite 生产构建用的是 Rollup(JS 实现),并不比 webpack 快多少,有些大项目甚至更慢——因为 dev 的优势来自架构,build 阶段没有这个优势。
【追问 & 加分点】
- Vite 的 dev/build 行为不一致风险:dev 走原生 ESM + esbuild,build 走 Rollup + Terser,转换管线不同,历史上出过"dev 正常 build 挂"的问题(CJS/ESM interop、
process.env替换时机、循环依赖行为)。工程上的对策:CI 必须跑 build + 预览环境冒烟,不能只测 dev。 - webpack 5 的追赶手段:
cache: { type: 'filesystem' }(二次启动提速数倍)、experiments.lazyCompilation(只编译当前访问到的入口/动态 import,思路上借鉴了 Vite)、swc-loader/esbuild-loader替换 babel。老项目不一定要迁 Vite。 - esbuild 不做类型检查:Vite 里 TS 只被"擦除类型",类型错误不会阻断 dev。所以必须单独跑
tsc --noEmit(配vite-plugin-checker或放到 CI)。这是面试里很实的一个点。 - 下一代趋势:Rspack(字节,Rust 实现的 webpack 兼容打包器,配置基本可平移)、Turbopack(Vercel)、Rolldown(Vite 团队)。回答时点出"兼容既有 webpack 配置生态是 Rspack 的核心卖点,大仓迁移成本最低"能显示对工程实际的理解。
4. Monorepo 大仓方案
【核心答案】
Monorepo 解决的是多包协同问题:跨包改动的原子提交、统一的依赖版本与工具链、免去 npm link 的本地联调地狱、代码复用与统一发版。技术栈通常分两层:包管理层用 pnpm workspace(硬链接 + 符号链接的内容寻址 store,磁盘占用和安装速度都最优,且默认严格 —— 不允许访问未声明的依赖,杜绝幽灵依赖);任务编排层用 Turborepo 或 Nx(基于任务依赖图做拓扑排序、并行执行、内容哈希的本地/远程构建缓存,配合 affected 检测只跑受影响的包)。代价是:仓库体积和 CI 时间会膨胀(需要 affected + 缓存 + sparse checkout 来对冲)、权限粒度变粗、工具链复杂度上升、发版策略(changesets)需要额外治理。
【展开】
为什么要 Monorepo(问题驱动)
| 痛点(Multi-repo) | Monorepo 的解法 |
|---|---|
| 改一个基础库要发版 → 下游一个个升版本,一次改动横跨 5 个 PR | 一次提交跨包原子修改,CI 一次性验证全链路 |
本地联调靠 npm link,符号链接导致 React 双实例、类型不一致 | workspace 协议直接引用源码/构建产物,workspace:* |
| 各仓 ESLint/TS/构建配置漂移 | 抽 @company/eslint-config、@company/tsconfig 共享包 |
| 依赖版本碎片(同一个 lodash 有 5 个版本) | 统一 lockfile + pnpm.overrides / catalog 收敛 |
| 跨仓重构(改接口)不敢做 | 全局搜索 + 全量类型检查,重构可验证 |
三个方案的定位
pnpm workspace(包管理,几乎是必选项)
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
# pnpm 9+ 的 catalog:统一版本,各包引用 catalog:
catalog:
react: ^18.3.1
typescript: ^5.5.4// packages/ui/package.json
{
"name": "@company/ui",
"dependencies": {
"@company/utils": "workspace:*", // 指向本地包,发版时自动替换为真实版本号
"react": "catalog:"
}
}pnpm 的两个关键机制:
- 内容寻址 store:所有包的文件按 hash 存在全局 store(
~/.pnpm-store),项目里用硬链接指过去。同一个版本在 100 个项目里只占一份磁盘。 - 严格的 node_modules 布局:
node_modules/顶层只放直接依赖的符号链接,真实包在node_modules/.pnpm/下按name@version平铺。结果是未在 package.json 声明的包无法 require 到(消灭幽灵依赖)。代价是少数假设扁平结构的老工具会挂,需要node-linker=hoisted或public-hoist-pattern兜底。
Turborepo(任务编排,轻量)
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
// ^build 表示:先跑「所有依赖包」的 build(拓扑序)
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
// 参与 hash 计算的环境变量:变了就 cache miss
"env": ["NODE_ENV", "NEXT_PUBLIC_*"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"inputs": ["src/**", "test/**", "vitest.config.ts"]
},
"lint": { "dependsOn": [] },
"dev": { "cache": false, "persistent": true }
}
}# 只跑受本次改动影响的包(对比 origin/main)
turbo run build test --filter='...[origin/main]'
# 只跑某个 app 及其依赖
turbo run build --filter=@company/web...缓存原理:对每个 task 计算一个 hash = 包内文件内容 + 依赖包的 hash + 相关 env + task 配置。命中则直接回放产物文件和终端输出("replayed from cache"),耗时接近 0。--remote-cache(Vercel 或自建 S3/turborepo-remote-cache)让 CI 与本地共享缓存,这是 CI 提速最大的一招。
Nx(任务编排 + 代码生成 + 架构约束,重量级)
比 Turborepo 多出的能力:
- 项目图 + 模块边界约束:
@nx/enforce-module-boundariesESLint 规则,给包打 tag(scope:web、type:ui),禁止scope:web引用scope:admin、禁止type:ui引用type:feature。这是大仓治理里最值钱的能力,防止依赖关系腐化成一团。 - 代码生成器(generators):
nx g @nx/react:lib my-lib,保证新包结构统一。 - Nx Cloud 的分布式任务执行(DTE):把一次 CI 的任务图切片分发到多台 agent,比单纯缓存更进一步。
- 代价:概念多(executor / generator / plugin / inferred targets),侵入性强,配置心智负担大。
选型建议:小到中等规模(< 30 个包)、团队想要低心智负担 → pnpm + Turborepo;大仓(几十上百包)、需要架构约束和统一脚手架、有平台团队维护 → pnpm/yarn berry + Nx。
发版:Changesets
pnpm changeset # 交互式选包 + 选 major/minor/patch + 写变更说明,生成 .changeset/*.md
pnpm changeset version # 消费 md,bump 版本号、更新 CHANGELOG、把 workspace:* 替换为具体版本
pnpm changeset publish # 按拓扑序发布到 npm关键点:changesets 会自动处理依赖联动——@company/utils 发了 patch,依赖它的 @company/ui 也会自动 bump patch。
代价(面试一定要主动说,否则显得没实践过)
- CI 时间膨胀:不做 affected 检测的话,改一行 README 全量跑一遍。对策:
--filter='...[origin/main]'+ 远程缓存 + 分片。 - Git 仓库变大:clone 慢。对策:CI 用
--filter=blob:nonepartial clone、sparse-checkout;本地可用 scalar。 - 权限粒度:Git 本身没有目录级权限。对策:CODEOWNERS + 分支保护 + review 强制,或平台层(GitLab 的 push rules)。
- 依赖升级的爆炸半径:升一个 React 版本影响所有 app。对策:catalog/overrides 统一 + 灰度分批(先在一个 app 里试)。
- 工具链复杂度:新人上手曲线陡,任何构建问题都是"全仓问题"。对策:文档 +
doctor脚本 + 平台团队兜底。 - IDE 性能:TS 全量类型检查慢。对策:Project References(
composite: true+tsc -b)做增量类型检查,tsconfig.json里各包互相 reference。
【追问 & 加分点】
- 包的产物形态怎么定? 三种:① 发布 dist(
main/module/exports指向构建产物)—— 消费方无需构建,但每次改动要重新 build;② 直接 export 源码 TS(internal packages 模式,"main": "./src/index.ts")—— 免构建、HMR 顺滑,但要求所有消费方的构建工具都能处理 TS,且不能发布到 npm;③ publishConfig 双形态——dev 用源码、发布时用publishConfig.main切到 dist。方案 ② 在纯内部大仓里体验最好,是 Turborepo 官方推荐的 "Just-in-Time Packages"。 - turbo 的 hash 为什么可能"该命中却没命中"? 常见原因:
outputs没声明全(产物没被缓存)、inputs没收窄(无关文件变动触发 miss)、环境变量泄漏进构建(未在env声明的变量变了不 miss,反而更危险 —— 会回放错误产物)、.gitignore外的脏文件参与 hash。turbo run build --dry=json可以打印每个 task 的 hash 及其输入。 - Monorepo ≠ Monolith:仓库合并不等于运行时合并。部署仍然可以独立,每个 app 有自己的流水线,靠 affected 判断哪些需要重新部署。
- 什么时候不该用 Monorepo? 包之间几乎无依赖、团队完全独立、发版节奏差异极大、有强合规隔离要求时,多仓 + 私有 registry 更简单。不要为了"大厂都用"而用。
5. CI/CD 流水线与前端部署
【核心答案】
前端流水线的标准形态是 install(带缓存)→ 静态检查(lint / typecheck / format)→ 测试(unit → 集成 → E2E)→ build(注入版本号与 sourcemap)→ 产物归档(不可变制品,含 commit sha)→ 部署(静态资源上 CDN + HTML 入口切换)→ 灰度与回滚 → 监控验证。核心工程原则有三条:① 一次构建、多环境部署(配置运行时注入,不为每个环境重复构建);② 制品不可变(版本化目录 + 内容 hash 文件名);③ HTML 与静态资源的缓存策略必须分离——index.html 用 no-cache(协商缓存),带 hash 的 JS/CSS 用 max-age=31536000, immutable。回滚就等于把 HTML 指回上一个版本目录,秒级生效且资源仍在 CDN 上。
【展开】
完整流水线
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push: { branches: [main] }
concurrency: # 同一分支新 push 取消旧运行,省 CI 资源
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ---------- 1. 静态检查:快、并行、先失败 ----------
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # turbo/nx 的 affected 检测需要历史
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile # 锁定依赖,lockfile 不一致直接失败
- run: pnpm turbo run lint typecheck --filter='...[origin/main]'
# ---------- 2. 测试:分层,快的先跑 ----------
test:
needs: verify
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 --frozen-lockfile
- run: pnpm turbo run test -- --coverage
- name: 覆盖率卡口
run: npx nyc check-coverage --lines 70 --branches 60
# ---------- 3. 构建 + 制品 ----------
build:
needs: test
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- id: meta
run: echo "version=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: 构建(把版本号编进产物,供前端上报和排障)
env:
VITE_APP_VERSION: ${{ steps.meta.outputs.version }}
# publicPath 指向带版本的 CDN 目录 —— 这是「不可变制品」的关键
VITE_PUBLIC_PATH: https://cdn.example.com/app/${{ steps.meta.outputs.version }}/
run: pnpm turbo run build
- name: 体积回归卡口(防止 bundle 悄悄变胖)
run: npx size-limit --json
- name: 上传 sourcemap 到 Sentry 后从产物中删除
run: |
npx sentry-cli sourcemaps inject ./dist
npx sentry-cli sourcemaps upload ./dist --release "${{ steps.meta.outputs.version }}"
find ./dist -name '*.map' -delete # sourcemap 不发到公网
- uses: actions/upload-artifact@v4
with:
name: dist-${{ steps.meta.outputs.version }}
path: dist# .github/workflows/deploy.yml —— 复用同一份制品,不重新构建
jobs:
deploy:
environment: production # 走 GitHub Environment 的人工审批
steps:
- uses: actions/download-artifact@v4
with: { name: dist-${{ inputs.version }} }
# ① 先传静态资源到「版本目录」,长缓存
- name: 上传 assets
run: |
aws s3 sync ./assets "s3://bucket/app/${VERSION}/assets" \
--cache-control "public,max-age=31536000,immutable"
# ② 再传 HTML 入口,短缓存 —— 顺序不能反!
# 先传 HTML 会导致 HTML 引用了还没上传的资源 → 白屏
- name: 上传 HTML
run: |
aws s3 cp ./index.html "s3://bucket/app/${VERSION}/index.html" \
--cache-control "no-cache" --content-type "text/html"
# ③ 切换线上指针(原子操作)
- name: 切流
run: ./scripts/switch-version.sh "$VERSION"缓存策略(面试高频,必须说准)
# index.html:协商缓存。每次都问服务器"变了没",没变返回 304(几百字节)
location = /index.html {
add_header Cache-Control "no-cache"; # 注意:no-cache ≠ no-store
# no-cache = 可以缓存但每次必须校验
# no-store = 完全不缓存(HTML 一般不用这个)
etag on;
}
# 带 contenthash 的静态资源:强缓存一年 + immutable
# immutable 告诉浏览器"连 revalidate 都不用发",即使用户按刷新
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}为什么这样分:HTML 是"版本指针",必须能秒级更新;JS/CSS 文件名里带 contenthash,内容变 = 文件名变 = 新 URL,所以旧 URL 的内容永远不会变,可以放心永久缓存。这套组合让"发版即时生效"和"资源零重复下载"同时成立。
CDN 侧要做的:
- 发版后只刷新 HTML 的 CDN 缓存(
aws cloudfront create-invalidation --paths "/index.html"),资源不刷(也不该刷)。 - 开启 Brotli/Gzip、HTTP/2 或 HTTP/3。
- 静态资源用独立域名或至少配好
Access-Control-Allow-Origin(crossorigin脚本要报错信息需要)。 - 老版本资源保留至少 2 个发版周期(推荐 7~30 天),否则发版瞬间还停留在旧页面的用户点击懒加载路由会
Loading chunk failed。这是前端部署最经典的线上事故。
灰度与回滚
灰度(渐进发布)的几种做法,从轻到重:
- Nginx / CDN 边缘按权重分流(改的是 HTML 指向哪个版本目录):
# 用 split_clients 按用户 cookie/IP hash 稳定分流,保证同一用户一直落在同一版本
split_clients "${remote_addr}${http_user_agent}" $version {
5% "v2026-08-29-a1b2c3d"; # 新版本 5%
* "v2026-08-20-9f8e7d6"; # 老版本兜底
}
location = / {
proxy_pass http://oss-origin/app/$version/index.html;
add_header X-App-Version $version; # 便于监控按版本聚合
}- 功能开关(Feature Flag):更细粒度,代码已上线但功能未开启,出问题关开关即可,无需回滚发版。适合有后端配合的大功能。
- 按用户维度定向:内部员工 → 白名单用户 → 1% → 10% → 50% → 100%,每一档观察 15~30 分钟。
回滚:
# 因为制品不可变且按版本目录存放,回滚 = 改指针,秒级
./scripts/switch-version.sh v2026-08-20-9f8e7d6
# 只需失效 HTML 的 CDN 缓存
aws cloudfront create-invalidation --distribution-id XXX --paths "/index.html" "/"灰度期间要盯的指标(有这段才算完整答案):
- JS 错误率(按版本维度切分)、白屏率
- 核心接口成功率与 P95 耗时
- Web Vitals(LCP / INP / CLS)按版本对比
- 业务转化漏斗(有的性能"优化"会掉转化)
自动化:错误率超过基线 N 倍 → 流水线自动回滚("自动熔断"),这是能明显拉开档次的加分点。
【追问 & 加分点】
- 为什么要"一次构建多环境部署"? 如果每个环境重新 build,测试环境验证过的产物和生产跑的不是同一个二进制,中间任何依赖漂移都可能引入差异。做法:把环境相关配置从构建期挪到运行期——注入
window.__APP_CONFIG__(HTML 里由服务端/边缘函数填充)或读一个不带 hash 的/config.json。代价是这部分配置无法被 tree shake,且多一个请求(可以内联进 HTML 消除)。 Loading chunk failed怎么彻底治? 三层防御:① CDN 保留旧版本资源;② 前端捕获 chunk 加载错误后location.reload()(但要防死循环,用 sessionStorage 记标记);③ 服务端推送版本号(SSE/轮询/X-App-Version响应头),检测到新版本时提示用户"有新版本,点击刷新"。- Secrets 管理:前端构建期注入的任何变量都会进产物、都是公开的。
VITE_/NEXT_PUBLIC_前缀的变量绝不能放密钥。真正的密钥必须走 BFF。CI 里用 OIDC 换取云厂商临时凭证(而不是把长期 AK/SK 存在 secrets 里)是当前最佳实践。 - E2E 放在哪一环? 全量 E2E 太慢,放在 PR 上会拖垮迭代。常见分层:PR 跑 unit + 少量关键路径 E2E(冒烟);merge 到 main 后跑全量 E2E;部署到预发后跑生产环境冒烟(smoke test)。配合 Playwright 的 sharding 并行。
6. 前端性能指标体系与 LCP
【核心答案】
指标按用户感知的时间线分层:TTFB(网络与后端响应,navigationStart → 首字节)→ FCP(首次绘制任何内容,"页面开始有反应了")→ LCP(最大内容元素渲染完成,"主要内容看到了",Core Web Vitals,好 ≤ 2.5s)→ TTI / TBT(可交互性,TBT 是实验室里 INP 的代理指标)→ INP(Interaction to Next Paint,2024 年 3 月正式取代 FID 成为 Core Web Vitals 的响应性指标,衡量整个会话里交互响应的第 98 百分位,好 ≤ 200ms)→ CLS(累积布局偏移,视觉稳定性,好 ≤ 0.1)。选 LCP 作为主指标是因为它最贴近用户主观的"页面加载完了"、可在真实用户端稳定测量、且可归因可优化。LCP 的测量终点不是固定的:浏览器在每一帧持续上报新的最大内容候选元素,只有当用户首次交互(点击/按键/滚动)或页面进入后台时才停止监听,最后一个候选即为最终 LCP。
【展开】
指标全景
| 指标 | 全称 / 含义 | 测量方式 | 好/差阈值 (p75) |
|---|---|---|---|
| TTFB | Time to First Byte,导航开始到收到第一个字节 | PerformanceNavigationTiming.responseStart - startTime | ≤ 800ms / > 1800ms |
| FCP | First Contentful Paint,首个文本/图片/非白 canvas/SVG 绘制 | PerformanceObserver('paint') | ≤ 1.8s / > 3.0s |
| LCP | Largest Contentful Paint,视口内最大内容元素的绘制时间 | PerformanceObserver('largest-contentful-paint') | ≤ 2.5s / > 4.0s |
| CLS | Cumulative Layout Shift,会话窗口内最大的一组布局偏移分数之和 | PerformanceObserver('layout-shift') | ≤ 0.1 / > 0.25 |
| INP | Interaction to Next Paint,交互到下一帧绘制的延迟(取页面生命周期内接近 p98 的那次交互) | PerformanceObserver('event', {durationThreshold}) | ≤ 200ms / > 500ms |
| TBT | Total Blocking Time,FCP 到 TTI 之间所有长任务超出 50ms 部分之和 | 实验室(Lighthouse) | ≤ 200ms / > 600ms |
| TTI | Time to Interactive,主线程连续 5s 空闲且网络安静 | 实验室;已从 Lighthouse 10 移除,稳定性差 | ≤ 3.8s / > 7.3s |
| FID | First Input Delay,已被 INP 取代(2024-09 从 CrUX 移除) | — | — |
三个 Core Web Vitals:LCP(加载)、INP(交互)、CLS(视觉稳定)。
为什么常选 LCP 做衡量
- 贴近主观感受:用户说"这页面慢",说的基本就是"主要内容什么时候出来"。LCP 直接对应这句话,而 FCP 可能只是个 loading 骨架、TTFB 完全不可见。
- 可在真实用户环境测量(field-measurable):LCP 有原生
PerformanceObserver支持,无需埋点侵入业务代码,采集成本低,且 Chrome 的 CrUX 数据集有全网基线可对比。相比之下 TTI 需要"网络安静"判断,在真实环境噪音大、不稳定(这也是它被 Lighthouse 移除的原因)。 - 可归因、可优化:LCP 可以拆成四段——TTFB / 资源加载延迟(load delay)/ 资源加载时长(load duration)/ 元素渲染延迟(render delay),每一段都有明确的优化手段。这种可拆解性让它能驱动工程行动。
- 与业务指标相关性强:多家(Vodafone、Yahoo Japan 等)公开的 A/B 数据显示 LCP 改善与转化率提升正相关。
但只看 LCP 是不够的——LCP 好但 INP 差的页面(内容出来了但点不动)体验一样糟。P6 的回答应该是:"LCP 做主指标,INP/CLS 做守门指标(guardrail),任何优化不能以牺牲后两者为代价。"
LCP 的测量终点:不是固定的(这是本题的题眼)
候选元素类型(并非所有元素都算):
<img>、<image>(SVG 内)<video>的封面图poster(以及 Chrome 后来支持的首帧)- 通过
url()设置了 background-image 的元素 - 包含文本节点的块级元素(文本的尺寸按其子文本节点的边界框算)
尺寸计算规则(容易被追问):
- 只算视口内可见部分,溢出视口的部分不计入。
- 图片的有效尺寸取
min(实际展示尺寸, 内在尺寸)—— 一张 4000×3000 的图缩到 100×100 显示,只按 100×100 算。这防止"塞一张巨图"刷分。 - 被
opacity: 0隐藏的元素不算;纯背景色(无图)的元素不算。
动态更新与停止时机:
// LCP 的正确采集方式
let lcp = 0;
const po = new PerformanceObserver((list) => {
const entries = list.getEntries();
// ⚠️ 浏览器会多次触发回调,每次带来一个「更大的」候选
// 必须取【最后一个】entry,而不是第一个
const last = entries[entries.length - 1];
lcp = last.startTime; // startTime 即渲染时间点
// last.element 是对应的 DOM 元素 —— 排障时最有用的信息
// last.url 是图片资源 URL(如果是图片)
console.log('LCP candidate:', lcp, last.element);
});
// buffered: true —— 拿到 observer 创建之前就已经产生的 entry(很关键,否则会漏)
po.observe({ type: 'largest-contentful-paint', buffered: true });
// 停止监听的时机:页面进入后台 / 被隐藏时,此刻的 lcp 即为最终值
addEventListener('visibilitychange', function onHide() {
if (document.visibilityState === 'hidden') {
po.takeRecords(); // 取出还没派发的 entry
po.disconnect();
report('LCP', lcp); // 用 navigator.sendBeacon 上报
removeEventListener('visibilitychange', onHide, true);
}
}, true);浏览器内部停止上报新候选的条件(面试要能背出来):
- 用户首次交互——
click、keydown、以及(Chrome 实现中)滚动。原因:一旦用户开始交互,后续渲染出来的大元素就不再属于"加载体验"了。 - 页面被隐藏(切后台 / 切标签页 / 锁屏),因为后台页面的渲染时序不可信。
这带来的实际影响:
- 如果页面加载中用户很早就点了一下,LCP 会偏小(提前定格)。反过来说,故意在页面上放个"点我"引诱早点击可以刷低 LCP —— 属于作弊,Chrome 也在持续收紧。
- 懒加载首屏大图是 LCP 反模式:
loading="lazy"会推迟图片请求,导致 LCP 元素晚出现。首屏 LCP 图应该用fetchpriority="high"+<link rel="preload">,绝不加 lazy。 - 骨架屏/占位符会先成为候选,后被真实内容替换——所以中间过程的候选值没有意义,只有最后一个算数。
LCP 的四段归因拆解(用它指导优化方向):
TTFB ──▶ 资源加载延迟 ──▶ 资源加载时长 ──▶ 元素渲染延迟 ──▶ LCP
│ │ │ │
│ │ │ └─ 主线程被长任务占住、字体阻塞、
│ │ │ CSS-in-JS 运行时开销、水合(hydration)
│ │ └─ 图片太大/未压缩/未用 AVIF-WebP/CDN 慢
│ └─ 资源发现太晚:藏在 CSS background 里、由 JS 动态插入、
│ 被前面的资源抢了带宽 → 用 preload + fetchpriority=high 解决
└─ 后端慢、无 CDN、重定向链、DNS/TLS 握手 → 边缘缓存、HTTP/3、减少重定向用 web-vitals 库的 attribution build 可以直接拿到这四段:
import { onLCP } from 'web-vitals/attribution';
onLCP(({ value, attribution }) => {
const { element, url, timeToFirstByte, resourceLoadDelay,
resourceLoadDuration, elementRenderDelay } = attribution;
// 直接上报这五个值,就能在监控大盘上定位"LCP 慢在哪一段"
report({ value, element, url, timeToFirstByte, resourceLoadDelay,
resourceLoadDuration, elementRenderDelay });
});【追问 & 加分点】
- SPA 的路由切换算不算新的 LCP? 标准 LCP 只针对硬导航(hard navigation),SPA 内部路由跳转不会重置 LCP。这是当前 Web Vitals 体系的公认短板。业界方案:Chrome 在推 Soft Navigations API(实验中,
PerformanceObserver可观测soft-navigation类型);工程上常用自定义指标——在路由切换时打点,配合PerformanceObserver('element')(Element Timing API,给关键元素加elementtiming属性)测量"新页面主内容出现时间"。 - 实验室数据 vs 真实用户数据(Lab vs Field):Lighthouse 是模拟环境(固定的 4x CPU 降速、模拟慢 4G),用于回归防护和优化前后对比;RUM(Real User Monitoring)才是决策依据,因为它包含真实的设备分布、网络分布、缓存命中情况。二者数值不一致是正常的,不要试图对齐。看 RUM 一定看 p75 或 p90,不看均值——均值会被少数极端值和大量快速请求同时扭曲。
- CLS 的"会话窗口"细节:CLS 不是简单累加所有偏移。它把偏移分组:同一组内相邻偏移间隔 < 1s,整组时长 ≤ 5s,CLS 取所有组中分数最大的那一组。所以一个持续微小抖动的页面不会被无限累加。另外,用户交互后 500ms 内的偏移不计入(视为"预期内的变化")。
- INP 为什么取代 FID? FID 只测"第一次交互的输入延迟"(从事件产生到事件处理器开始执行),既不看处理耗时,也不看后续渲染,绝大多数页面 FID 都很好看,区分度差。INP 测的是从交互开始到下一帧绘制完成的完整链路(输入延迟 + 处理时间 + 呈现延迟),覆盖整个页面生命周期的所有交互,能真实反映"点了没反应"。优化 INP 的关键手段:拆分长任务(
scheduler.yield()/await new Promise(r => setTimeout(r, 0)))、把非紧急更新降级(React 的useTransition/startTransition)、减少 DOM 规模、避免在事件处理器里做同步布局。
7. Performance 面板用来定位什么问题
【核心答案】
Performance 面板解决的是"时间花在哪、主线程被谁占住了"这类问题,具体四类:① 长任务(Long Task,> 50ms 的任务)导致的交互卡顿和掉帧;② 脚本执行耗时归因(哪个函数、哪个第三方 SDK、哪次 hydration 吃掉了 CPU);③ 强制同步布局(Forced Reflow / Layout Thrashing)与样式重算,火焰图上表现为紫色 Layout 块 + 红色三角警告;④ 渲染管线问题(不必要的 Paint/Composite、非合成动画、Long Animation Frame)。读法是"自上而下定位区间,自下而上定位函数":先看 Frames/Timings 轨道找到卡的那一段,再展开 Main 轨道的火焰图(横向 = 时间,纵向 = 调用栈深度,宽的块才是问题),最后切到 Bottom-Up 按 Self Time 排序找到真正吃 CPU 的函数。
【展开】
各轨道怎么看
| 轨道 | 看什么 |
|---|---|
| Timings | FP / FCP / LCP / DCL / Load 的时间打点,以及 performance.mark/measure 打的自定义标记。先在这里定位"用户感知的关键时刻" |
| Frames | 每一帧的耗时。绿色 = 正常帧,黄色/红色(Dropped Frame / Partially Presented)= 掉帧。悬停能看到具体 fps |
| Interactions(新版) | 每次交互的 INP 分段:输入延迟 / 处理时长 / 呈现延迟。排 INP 问题第一个看这里 |
| Main | 主线程火焰图,核心区域。任务块右上角有红色三角 = 该任务有问题(长任务 / 强制回流) |
| Network | 请求瀑布,能看到优先级、排队时间、是否被阻塞 |
| GPU / Raster / Compositor | 合成线程与光栅化线程,判断动画是否走了合成器(不占主线程) |
| Layers / Rendering 侧栏 | 图层数量、重绘区域(Paint flashing)、图层边界 |
火焰图(Flame Chart)怎么读
时间轴 ────────────────────────────────────────▶
┌──────────────────────────────────────────┐
│ Task (220ms) ⚠️ 长任务 │ ← 顶层:一个宏任务
├──────────────────────────────────────────┤
│ Function Call / Event: click │ ← 触发源
├──────────────────────────────────────────┤
│ handleSubmit (200ms) │ ← 业务函数
├──────────┬───────────────┬───────────────┤
│ parse(30)│ heavyCalc(120)│ render(50) │ ← 越宽 = 耗时越长 = 越值得优化
├──────────┴──┬────────────┴───┬───────────┤
│ JSON.parse │ sortBigArray │ Layout ⚠️ │ ← 叶子节点是真正在执行的代码
└─────────────┴────────────────┴───────────┘
纵向 = 调用栈深度(谁调用了谁),不代表耗时
横向 = 时间,宽度 = 耗时。【只看宽的,忽略高的】颜色约定(Chrome):
- 🟨 黄色 = Scripting(JS 执行、编译)
- 🟪 紫色 = Rendering(Recalculate Style 样式重算、Layout 布局)
- 🟩 绿色 = Painting(Paint 绘制、Composite Layers 合成)
- 🟦 蓝色 = Loading(解析 HTML、下载资源)
- ⬜ 灰色 = System/Other/Idle
三个视图的分工(Bottom-Up / Call Tree / Event Log):
- Call Tree(自上而下):从入口往下看调用关系,适合回答"这段时间在做什么流程"。
- Bottom-Up(自下而上):按 Self Time 聚合排序,适合回答"到底哪个函数吃 CPU"。这是最常用的定位视图 —— 火焰图里一个函数可能被调用几百次,每次都很短,火焰图上看不出来,但 Bottom-Up 一聚合就暴露了。
- Self Time vs Total Time:Self 是函数自身代码耗时(不含子调用),Total 含子调用。优化要盯 Self Time 高的。
四类典型问题的识别特征
① 长任务(Long Task)
特征:Main 轨道上一个宽度 > 50ms 的灰色 Task 块,右上角有红色三角,悬停提示 "Long task took XXX ms"。
危害:长任务期间主线程无法响应输入 → INP 变差、动画掉帧。
定位后的优化:
// ❌ 一次性处理 10000 条,主线程阻塞 800ms
function processAll(items) {
return items.map(heavyTransform);
}
// ✅ 方案 A:分片 + 让出主线程
async function processAll(items) {
const out = [];
for (let i = 0; i < items.length; i++) {
out.push(heavyTransform(items[i]));
// 每 50 条让出一次主线程,给输入事件和渲染机会
if (i % 50 === 49) await yieldToMain();
}
return out;
}
function yieldToMain() {
// scheduler.yield() 是新标准 API:让出后【保持在队列前部】,
// 不会被后来的任务插队,比 setTimeout(0) 更优
if ('scheduler' in globalThis && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
// ✅ 方案 B:纯计算搬进 Web Worker(不占主线程,最彻底)
// ✅ 方案 C:React 里用 startTransition 把更新标记为非紧急,可被中断② 强制同步布局(Forced Synchronous Layout / Layout Thrashing)
特征:Main 轨道上出现紫色 "Layout" 或 "Recalculate Style" 块,且位于 JS 函数调用栈内部(不是在帧末尾的正常位置),Summary 里明确写 Forced reflow is a likely performance bottleneck。Chrome 还会在旁边标注触发的源码行。
原因:在写了 DOM 之后立即读取会触发布局的属性(offsetWidth/Height/Top/Left、scrollTop/Height、clientWidth、getBoundingClientRect()、getComputedStyle()、offsetParent 等),浏览器为了给出正确答案必须立刻同步执行布局,打断了正常的批处理。
// ❌ 读-写-读-写 交替 → 每次循环强制一次同步布局,N 个元素 = N 次 reflow
for (const el of els) {
const w = el.offsetWidth; // 读(强制 layout)
el.style.width = w * 2 + 'px'; // 写(使布局失效)
}
// ✅ 批量读 → 批量写(读写分离)
const widths = els.map((el) => el.offsetWidth); // 集中读,只触发一次 layout
els.forEach((el, i) => { // 集中写
el.style.width = widths[i] * 2 + 'px';
});
// ✅ 更规范:用 rAF 把写操作推到下一帧,彻底分离读写阶段
function batchUpdate(els) {
const widths = els.map((el) => el.getBoundingClientRect().width); // 读阶段
requestAnimationFrame(() => { // 写阶段
els.forEach((el, i) => { el.style.width = `${widths[i] * 2}px`; });
});
}③ 掉帧 / 动画不流畅
特征:Frames 轨道出现红/黄帧;Main 轨道每帧内的工作超过 ~16.7ms;或者 Compositor 轨道空闲但 Main 忙 → 说明动画走的是主线程。
判断"动画有没有走合成器":Rendering 侧栏勾选 Paint flashing(重绘区域高亮绿色),如果动画时整块区域一直闪 = 每帧都在重绘。另外 Layers 面板能看到元素有没有独立图层。
/* ❌ 触发 Layout + Paint + Composite 全流程,每帧都要重排 */
.box { left: 0; transition: left .3s; }
.box.move { left: 200px; }
/* ✅ 只触发 Composite,由合成线程处理,不占主线程 */
.box { transform: translateX(0); transition: transform .3s; will-change: transform; }
.box.move { transform: translateX(200px); }
/* 注意:will-change 会提升图层、占显存,不能滥用(不要写 will-change: all
或给几百个元素都加),用完应移除 */只有 transform 和 opacity(以及 filter 的部分场景)能被合成器独立处理。改 width/height/top/left/margin 一定触发 Layout。
④ 脚本耗时归因(第三方 / hydration)
用 Bottom-Up + Group by: URL/Domain 一眼看出哪个域名的脚本吃掉了多少 CPU。这是治理第三方 SDK(埋点、广告、客服组件)的标准手法。React 项目还可以配合 React DevTools Profiler 看具体是哪个组件 render 慢。
配套工具(不要只答 Performance 面板)
performance.mark/measure:给业务关键路径打点,会直接显示在 Timings 轨道上。生产环境也能用(配合PerformanceObserver采集上报)。PerformanceObserver('longtask')/long-animation-frame(LoAF,新,更好用):生产环境采集长任务,能拿到归因脚本的 URL 和函数名。- Lighthouse:一键体检,给方向;但它是模拟环境,别当决策依据。
- Coverage 面板:找出未使用的 JS/CSS 占比,指导代码分割。
- Memory 面板 + Performance 的 Memory 勾选项:内存泄漏排查(堆快照对比、Detached DOM 节点)。
- Rendering 面板:FPS meter、Paint flashing、Layout Shift Regions(可视化 CLS)、Core Web Vitals overlay。
【追问 & 加分点】
- CPU throttling 一定要开:开发机是 M 系列 / i9,永远看不到用户的中低端安卓机上的卡顿。录制前把 CPU 设为 4x 或 6x slowdown,网络设为 Slow 4G。这是"能不能复现用户问题"的前提。
- Long Animation Frame (LoAF) 比 Long Task 更实用:Long Task 只告诉你"有个 200ms 的任务",但不告诉你是谁。LoAF(
PerformanceObserver({type:'long-animation-frame'}))会给出这一帧里的所有脚本条目(scripts[],含sourceURL、invoker、forcedStyleAndLayoutDuration),能直接归因到具体文件和是否有强制回流。生产 RUM 里这是当前排 INP 的最佳信息源。 - 录制别太长:超过 10~20s 的 trace 会让面板本身卡顿且难以分析。正确做法是"先复现问题的最小操作路径,只录那 3~5 秒",必要时用
performance.mark精确框定。 - Performance 面板解决不了什么:网络层的细节(用 Network 面板 +
Resource Timing)、内存泄漏的对象保留路径(用 Memory 面板的 Heap Snapshot)、后端慢(看 TTFB 和服务端 trace)。诊断第一步永远是先分清是网络问题、主线程问题、还是后端问题——三个方向的工具完全不同。
8. 体系化性能优化清单
【核心答案】
我把优化拆成五层,每层有明确的指标归属,避免变成零散的技巧堆砌:① 网络层(减少 RTT 与传输量:CDN/HTTP2-3/压缩/缓存/预连接,对应 TTFB);② 资源层(减少和优化字节:代码分割、tree shaking、图片现代格式与响应式、字体子集化,对应 LCP/FCP);③ 渲染层(更快出像素:关键 CSS 内联、避免布局抖动、合成层动画、虚拟列表、content-visibility,对应 LCP/CLS);④ JS 执行层(不阻塞主线程:拆长任务、Web Worker、按需 hydration、减少重渲染,对应 INP/TBT);⑤ 感知层(用户主观更快:骨架屏、乐观更新、预加载预测、渐进式渲染)。方法论上先测量后优化——先用 RUM 找到 p75 差的场景、用 LCP 四段归因定位瓶颈层,再动手,并用 CI 体积卡口和监控大盘防回归。
【展开】
第一层:网络层(目标:TTFB、资源到达时间)
| 手段 | 说明 | 收益 |
|---|---|---|
| CDN + 边缘缓存 | 静态资源就近分发;HTML 也可以走边缘缓存(配合 stale-while-revalidate) | TTFB 最大单项优化 |
| HTTP/2 / HTTP/3 | 多路复用消除队头阻塞;H3 基于 QUIC,弱网和切网场景优势明显 | 并发请求场景显著 |
| 压缩 | 文本资源开 Brotli(比 gzip 小 15~20%),静态资源用 br 预压缩(构建期生成 .br,避免运行时 CPU 开销) | 传输量 -20% |
| 缓存策略 | HTML no-cache + 带 hash 资源 max-age=31536000, immutable;API 用 stale-while-revalidate | 回访几乎零下载 |
preconnect / dns-prefetch | 提前完成 DNS + TCP + TLS 握手(可省 100~300ms) | 跨域资源必备 |
preload / fetchpriority | 提升关键资源发现时机与优先级 | LCP 直接受益 |
| 减少重定向 | 每次重定向 = 一个完整 RTT | |
| 接口聚合 / BFF | 首屏多个串行接口合成一个,消除请求瀑布 | 首屏数据到达提前 |
<!-- 提前握手(第三方域名、CDN、API 域名) -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="dns-prefetch" href="https://api.example.com">
<!-- 首屏 LCP 图:高优先级 + 预加载,绝不加 loading="lazy" -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high"
imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
imagesizes="100vw">
<!-- 字体:preload + crossorigin(字体请求是匿名 CORS,漏了 crossorigin 会重复下载) -->
<link rel="preload" as="font" type="font/woff2" href="/f.woff2" crossorigin>
<!-- 非关键第三方脚本:defer 或 async,绝不放在 head 里同步加载 -->
<script src="/analytics.js" defer></script>第二层:资源层(目标:FCP / LCP 的资源加载段)
- 代码分割:路由级
React.lazy+Suspense;组件级对大依赖(富文本编辑器、图表库、地图)懒加载;splitChunks分离 vendor 与业务。 - Tree shaking:确保 ESM 输出(
preset-env的modules: false);package.json正确声明"sideEffects": false(或列出有副作用的文件);避免 barrel file 导致的整包引入。 - 依赖瘦身:
moment→dayjs(-60KB);lodash→lodash-es+ 按需引入;用bundlephobia/webpack-bundle-analyzer找出体积大户;polyfill 用core-js+ browserslist 精确控制(别无脑全量)。 - 图片(往往是 LCP 元素,收益最大的一层):
- 格式:AVIF > WebP > JPEG/PNG,用
<picture>做优雅降级 - 响应式:
srcset+sizes,别给手机发 2000px 的图 - 首屏外图片:
loading="lazy"+decoding="async" - 必须给
width/height或aspect-ratio,否则图片加载完会撑开布局 → CLS
- 格式:AVIF > WebP > JPEG/PNG,用
- 字体:
font-display: swap(先用兜底字体渲染,避免 FOIT 空白);子集化(中文字体尤其重要,全字库几 MB → 常用字几百 KB);size-adjust/ascent-override减少字体切换时的布局跳动。
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<!-- width/height 用于占位,防止 CLS;实际尺寸由 CSS 控制 -->
<img src="/hero.jpg" width="1200" height="630" alt="产品图"
fetchpriority="high" decoding="async">
</picture>第三层:渲染层(目标:LCP 渲染段 / CLS)
- 关键 CSS 内联:首屏所需 CSS 内联进 HTML(
<style>),其余 CSS 异步加载,消除渲染阻塞。 - 避免布局抖动:读写分离(见第 7 题);给图片/广告位/动态内容预留固定尺寸容器。
- 合成层动画:只动
transform/opacity;慎用will-change。 - 长列表虚拟化:只渲染可视区(见 08 文档第 1 题)。
content-visibility: auto:对屏幕外的大块内容跳过渲染工作(配合contain-intrinsic-size给出占位尺寸,否则滚动条会跳)。
.long-section {
content-visibility: auto;
/* 必须给预估尺寸,否则未渲染时高度为 0,滚动条长度会剧烈跳变 */
contain-intrinsic-size: auto 800px;
}- 减少 DOM 规模:DOM 节点数直接影响样式重算和布局耗时(Lighthouse 的建议阈值是 < 1500 节点、深度 < 32)。
- CSS 选择器与 CSS-in-JS:运行时 CSS-in-JS(styled-components 的运行时模式)会在渲染时生成并插入样式,是 INP 的常见隐形杀手;优先用零运行时方案(vanilla-extract / Tailwind / CSS Modules)。
第四层:JS 执行层(目标:INP / TBT)
- 拆长任务:
scheduler.yield()/ 分片 /requestIdleCallback处理低优先级工作。 - Web Worker:大数据解析、加解密、图像处理、diff 计算搬出主线程(配合 Comlink 降低通信心智成本)。注意通信有序列化开销,大数据用
Transferable(ArrayBuffer)或SharedArrayBuffer。 - 减少不必要的重渲染(React):
memo/useMemo/useCallback用在真正昂贵的地方;状态下沉(把频繁变化的状态放到最小的子树);context 拆分(避免一个大 context 导致全树重渲染);列表 key 稳定。 - 并发特性:
useTransition/startTransition把搜索过滤、大列表更新标记为可中断的低优先级更新;useDeferredValue让输入框保持跟手。 - SSR / 流式 SSR / RSC:把首屏渲染工作挪到服务端,减少客户端 JS 与 hydration 成本;
Suspense流式输出让内容分块到达。 - 事件处理:滚动/resize/输入用节流防抖;滚动监听加
{ passive: true }(否则浏览器要等你的 handler 决定是否preventDefault,阻塞滚动)。 - 避免主线程上的同步大计算:
JSON.parse一个 5MB 的响应也是长任务。
第五层:感知优化(目标:主观体验)
- 骨架屏:结构与真实内容一致(否则内容出现时会跳),比 spinner 感知更快。
- 乐观更新(Optimistic UI):点赞、收藏这类高成功率操作立刻更新 UI,失败再回滚。
- 渐进式/流式渲染:先出框架和最重要的模块,次要模块流式补齐。
- 预测性预加载:鼠标 hover 链接 / 进入视口时预取下一页的 chunk 和数据(
quicklink思路,或 Chrome 的 Speculation Rules API)。 - 保持响应:任何 > 100ms 的操作要有即时反馈(按钮 loading 态);> 1s 要有进度指示。
- 滚动恢复:返回上一页时恢复滚动位置和列表数据,避免"回到顶部"的挫败感。
方法论(比清单本身更重要)
1. 测量 → RUM (web-vitals) 采集 LCP/INP/CLS,按页面、设备、网络、版本分维度看 p75
2. 定位 → LCP 四段归因确定瓶颈在哪一层;LoAF/Performance 面板归因 INP
3. 排序 → 按「收益 × 覆盖用户数 ÷ 成本」排优先级,不要先做最有意思的
4. 优化 → 单点改动,A/B 或灰度验证
5. 验证 → 对比 p75 变化 + 业务指标(转化率、跳出率)
6. 防回归 → CI 加 size-limit 体积卡口 + Lighthouse CI 分数卡口 + 监控告警必须强调的一点:性能优化要能说出业务价值。"我把 LCP 从 4.2s 降到 1.8s,首屏跳出率下降 12%,转化率提升 3%" 远胜于罗列十个技术名词。
【追问 & 加分点】
- 优化的负面代价要能说清:
will-change提升图层占显存(低端机可能反而更卡);过度代码分割导致请求瀑布;SSR 增加服务器成本和复杂度;预加载过多会抢占关键资源带宽(preload 滥用是常见反模式);memo用错地方反而增加比较开销。能主动说出 trade-off 是 P6 与 P5 的分水岭。 - 别只优化"平均值":p50 好看不代表用户满意。真正的问题在 p75/p90——低端安卓 + 弱网 + 冷缓存的那部分用户。RUM 要按设备档位(可用
navigator.hardwareConcurrency、deviceMemory)和网络(navigator.connection.effectiveType)分层看。 - 首屏优化的"最短路径":如果只能做三件事,我会选 —— ① LCP 图片用 AVIF/WebP + preload + fetchpriority=high;② 首屏 JS 体积砍到 100KB 以内(gzip 后);③ 消除渲染阻塞资源(关键 CSS 内联 + 第三方脚本 defer)。这三件通常能吃掉 70% 的收益。
- 性能预算(Performance Budget):把"首屏 JS ≤ 170KB gzip、LCP p75 ≤ 2.5s、CLS ≤ 0.1"写进 CI,超标就阻断合并。没有预算的优化一定会回退——这是工程化视角的答案。
9. 场景题:页面卡顿排查
字节原题:页面卡顿排查——首屏卡、分页切换卡、加载数据时卡,三种分别怎么定位和优化。
【核心答案】
先用一个统一的分诊框架把问题归到三个域之一:网络域(资源/接口慢,看 Network 面板 + TTFB)、主线程域(JS 执行/渲染阻塞,看 Performance 面板的 Main 轨道和长任务)、后端域(接口本身慢,看 TTFB + 服务端 trace)。然后三种卡顿分别对应不同的主因:首屏卡 = 关键路径太长(资源大 + 渲染阻塞 + hydration 长任务),用 LCP 四段归因定位;分页切换卡 = 大量 DOM 的销毁与重建 + 组件重渲染 + 无缓存重复请求,用 React Profiler + Interactions 轨道定位;加载数据时卡 = 主线程被数据处理(JSON.parse、排序、格式化)和一次性大批量渲染占满,用 Bottom-Up 的 Self Time 定位。对应的三套解法是:首屏 → 减字节 + 提优先级 + 拆长任务;切页 → 虚拟化 + keep-alive 缓存 + 骨架占位 + 并发降级;加载 → Worker + 分片渲染 + 分页/虚拟化 + 增量更新。
【展开】
通用分诊流程(回答前先建立方法,不要一上来就报优化点)
Step 0 复现:在【中低端安卓 / CPU 4x throttle / Slow 4G / 无缓存】下复现
—— 开发机上不卡不代表用户不卡
Step 1 分域:打开 Performance 录制那 3~5 秒,看主线程忙不忙
├─ 主线程大面积空闲,时间都在等 Network → 网络域 / 后端域
│ └─ 看 Network 面板:TTFB 长 → 后端;下载时长长 → 资源大/CDN 差
└─ 主线程被黄色(Scripting)/紫色(Rendering)块占满 → 主线程域
├─ 黄色多 → JS 执行问题,切 Bottom-Up 按 Self Time 找函数
└─ 紫色多 → 样式/布局问题,查强制回流、DOM 规模、复杂选择器
Step 2 归因:拿到具体的函数名 / 资源 URL / 接口,而不是"感觉是渲染慢"
Step 3 验证:改完在同样的降速条件下重测,对比同一指标(不是"感觉快了")
Step 4 上线后看 RUM p75 是否真的改善(实验室 ≠ 真实用户)场景一:首屏卡
症状:打开页面白屏久、内容出来后一段时间点不动。
定位工具与步骤:
- Lighthouse 跑一遍拿方向(Opportunities + Diagnostics),确认是加载问题还是执行问题。
- Network 面板看瀑布图:
- 是否有长的串行链(HTML → CSS → JS → 接口 → 图片,一环等一环)
- LCP 图片的请求什么时候才开始(发现太晚是最常见的问题)
- 有没有渲染阻塞资源(Network 里的
render-blocking列) - TTFB 是否本身就大(> 800ms 说明后端/CDN 有问题,前端怎么优化都白搭)
- Performance 面板看 Timings 轨道的 FCP/LCP 位置,再看 Main 轨道 FCP 之后有没有大块黄色(典型是 bundle 解析执行 + framework hydration)。
- Coverage 面板看首屏 JS/CSS 的未使用比例,> 50% 说明分割没做好。
- RUM 的 LCP 四段归因确认瓶颈落在哪一段。
按归因分段给药:
| 归因段 | 症状 | 优化 |
|---|---|---|
| TTFB 大 | 首字节就慢 | CDN 边缘缓存 HTML、减少重定向、后端优化/加缓存、HTTP/3、SSR 时避免服务端串行请求 |
| 资源加载延迟大 | LCP 图很晚才开始下载 | <link rel="preload" as="image" fetchpriority="high">;LCP 图别用 CSS background(要等 CSSOM + 匹配才发现);别对首屏图用 loading="lazy";别靠 JS 动态插入 |
| 资源加载时长大 | 图/JS 下载慢 | AVIF/WebP + 响应式 srcset + 压缩;Brotli;代码分割降低首屏 JS |
| 元素渲染延迟大 | 资源早就下完了,但很晚才画出来 | 关键 CSS 内联消除阻塞;字体 font-display: swap;拆分 hydration 长任务;减少首屏 DOM 规模;第三方脚本 defer |
通常收益最大的四条(面试可以直接给"我会先做这四件事"):
- 首屏 JS 体积砍到 gzip < 150KB(路由级分割 + 大依赖懒加载 + 去掉未用 polyfill)。
- LCP 图片格式 + preload + fetchpriority,去掉一切 lazy。
- 关键 CSS 内联 + 其余 CSS 异步;第三方脚本全部
defer/延后到load之后。 - 首屏数据接口做 BFF 聚合 或 SSR 直出(消掉"JS 下载执行完才发请求"这一整段瀑布)。
场景二:分页切换卡
症状:点分页/切 Tab 后有明显停顿,几百毫秒到 1s 无响应,然后内容才刷新。
定位工具与步骤:
- Performance → Interactions 轨道:看这次点击的 INP 分三段,判断是输入延迟(点击时主线程已经忙)、处理时长(handler 里干太多活)还是呈现延迟(渲染太重)。
- Main 轨道看点击后那个 Task 有多宽,展开火焰图找到最宽的子块。
- React DevTools Profiler:录制切换过程,看 Ranked 视图哪个组件 render 最久、为什么重渲染(开 "Record why each component rendered")。常见是"整棵树都重渲染了"。
- Network 面板:切页有没有重复请求已经拿过的数据(无缓存);接口是否串行。
- 检查 DOM 节点数:
document.querySelectorAll('*').length。上千行表格切换 = 销毁几万个节点再建几万个,这本身就是几百 ms。
主因与对策:
| 主因 | 判断依据 | 优化 |
|---|---|---|
| DOM 销毁/重建量太大 | Main 轨道有大块 Recalculate Style + Layout;DOM 节点数上万 | 虚拟列表 / 虚拟表格(只渲染可视区,DOM 数量恒定);减少每行的 DOM 层级和包裹元素 |
| 组件树整体重渲染 | Profiler 显示大量无关组件重渲染 | 状态下沉、memo + 稳定 props(useCallback)、拆 context、列表 key 用稳定 id 而非 index |
| 渲染是同步阻塞的 | 一个连续的长任务把点击到绘制全包了 | startTransition 把列表更新标为非紧急,让点击反馈(如高亮当前页码)先绘制出来;配合 useDeferredValue |
| 每次切页都重新请求 | Network 里同一个 URL 反复出现 | SWR / React Query 做缓存 + keepPreviousData(切页时先展示旧数据,新数据到了再替换,视觉上不闪不卡);预取下一页 |
| 切换时布局抖动 | Layout Shift Regions 高亮 | 容器固定高度/min-height,切换期间显示等高骨架屏,避免高度塌陷再撑开 |
| 每行都有昂贵计算 | Bottom-Up 里 formatter/moment 类函数 Self Time 高 | 把格式化结果 memo 化或在数据层预处理一次;换轻量日期库 |
一段能直接讲的实现要点:
// 切页体验优化的组合拳
function DataTable() {
const [page, setPage] = useState(1);
const [isPending, startTransition] = useTransition();
// ① keepPreviousData:切页时保留旧数据,避免「空白 → 骨架 → 内容」的两次跳变
const { data } = useQuery({
queryKey: ['list', page],
queryFn: () => fetchList(page),
placeholderData: keepPreviousData,
staleTime: 60_000, // ② 1 分钟内切回来直接命中缓存,零请求
});
const onPageChange = (p) => {
// ③ 分页器高亮这类「紧急更新」立刻生效,用户感觉跟手
setPage(p);
// ④ 大列表重渲染标记为非紧急,可被后续交互中断
startTransition(() => { /* 触发下游重计算的状态更新放这里 */ });
};
return (
<div style={{ minHeight: 600 }}> {/* ⑤ 固定高度防止塌陷抖动 */}
{/* ⑥ 表格本身虚拟化,DOM 数量与数据量解耦 */}
<VirtualTable rows={data.rows} dimmed={isPending} />
<Pager page={page} onChange={onPageChange} />
</div>
);
}场景三:加载数据时卡
症状:请求回来的那一刻页面冻住,滚动、输入全部无响应,持续几百 ms 到几秒。
关键判断:卡在"数据回来之后"而不是"等数据的时候" → 这是主线程被 CPU 密集工作占满,不是网络问题。Performance 上表现为:Network 请求结束的时间点之后,紧跟一个巨大的黄色 Task。
定位工具与步骤:
- Performance 录制"点击加载 → 数据渲染完"全过程。
- 找到 XHR/Fetch 完成后的那个大 Task,展开火焰图。
- 切 Bottom-Up,按 Self Time 排序,看是谁:
JSON.parse很大 → 响应体太大(几 MB 的 JSON)- 自己的
transform/sort/groupBy→ 数据处理逻辑重 Recalculate Style/Layout很大 → 一次性插入了几万个 DOM- React 的
commitWork/beginWork很大 → 一次性渲染太多组件
分主因给药:
主因 A:响应体太大,JSON.parse 就是长任务
- 后端做字段裁剪(GraphQL / BFF 只返回前端要的字段)、分页/游标替代全量。
- 数据量确实必须大 → 搬进 Web Worker 解析,主线程只拿结果。
- 极端场景用流式解析(
ReadableStream+ 增量 JSON 解析)或换二进制协议(Protobuf/FlatBuffers)。
主因 B:数据加工重(排序、聚合、格式化 10 万条)
// worker.js —— 把 CPU 密集处理搬出主线程
self.onmessage = ({ data: { raw } }) => {
// 解析 + 加工全在 worker 里做,主线程期间完全空闲,可以响应交互
const list = JSON.parse(raw);
const result = list
.map((it) => ({ ...it, dateText: formatDate(it.ts) }))
.sort((a, b) => b.score - a.score);
self.postMessage(result);
};
// 主线程
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
async function load() {
const res = await fetch('/api/list');
const raw = await res.text(); // 注意:用 text() 而非 json(),
// 把 parse 也让给 worker
worker.postMessage({ raw });
worker.onmessage = ({ data }) => setList(data);
}通信开销提醒:
postMessage是结构化克隆,大对象有序列化成本。传大 buffer 时用Transferable(零拷贝):worker.postMessage(buf, [buf])。如果加工结果本身就是几十 MB 的对象,克隆开销可能吃掉一半收益,此时应该在 worker 里就完成"降采样/聚合",只传回渲染需要的那部分。
主因 C:一次性渲染太多 DOM
// 分片渲染:把「一次插入 10000 个」拆成「每帧插入一批」
function renderInChunks(items, renderOne, container, batchSize = 100) {
let i = 0;
function step() {
const frag = document.createDocumentFragment(); // 用 fragment 批量插入,只触发一次 reflow
const end = Math.min(i + batchSize, items.length);
for (; i < end; i++) frag.appendChild(renderOne(items[i]));
container.appendChild(frag);
// 还有剩余就排到下一帧,把主线程还给浏览器 → 期间用户可以滚动/点击
if (i < items.length) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}但更根本的解法是虚拟列表——分片渲染只是把卡顿摊薄了,DOM 总量还是那么多,滚动和后续操作依然重。虚拟化让 DOM 数量与数据量彻底解耦(详见 08 文档第 1 题)。
主因 D:渲染优先级问题(React)
// 数据回来后一次性 setState 触发同步的大规模渲染 → 长任务
// 用 startTransition 标记为可中断的低优先级更新
import { startTransition } from 'react';
fetchData().then((list) => {
startTransition(() => setList(list)); // React 可以在渲染中途让出主线程响应输入
});组合方案(10 万条数据的完整解法,可以直接说这套):
- 后端游标分页 —— 先别一次拿 10 万条,这是第一优先级(前端所有技巧都不如不传这些数据)。
- 确实要全量(如导出、离线分析)→ Worker 解析 + 加工,主线程不参与。
- 渲染层 虚拟列表,DOM 恒定在几十行。
- 首批数据 分片渲染 让首帧尽快出现。
- 滚动加载配 IntersectionObserver 哨兵(不用 scroll 监听)。
- 全过程配 骨架屏 + 加载态,让等待可感知。
【追问 & 加分点】
- 怎么区分"卡"是掉帧还是无响应? 掉帧(jank)= 动画不流畅但点击还有反应,看 Frames 轨道的红黄帧;无响应(unresponsive)= 长任务把主线程占死,点击事件排队,看 INP 和 Long Task。两者优化方向不同:掉帧优先看合成层和每帧工作量,无响应优先看长任务拆分。
- 生产环境怎么发现卡顿?(这题不答生产监控就不完整)线上用
PerformanceObserver采集long-animation-frame(含归因脚本 URL)+event(INP)+web-vitals库;配合用户操作路径回放(rrweb)定位具体场景。不能依赖用户反馈——大多数用户卡了直接走人,不会反馈。 - 移动端 WebView 的额外因素:客户端容器的初始化耗时、JSBridge 通信开销、离线包命中率、WebView 内核版本差异(老 Android WebView 不支持很多现代 API 和格式)。排查时要先确认"是 H5 慢还是容器慢"——常用手段是在原生侧和 H5 侧各打点对齐时间轴。
- 不要过早优化,也不要"感觉优化":所有结论必须有 trace 佐证。面试里被问到具体项目时,能说出"我用 Performance 录到某个第三方 SDK 的 init 占了 320ms 的长任务,defer 之后 INP p75 从 380ms 降到 160ms",比说十条通用技巧更有说服力。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。