obsidian-computer
前端识记前端面试

07-engineering

面向大厂 P6 前端面试的标准答案集。题目来源:腾讯 WXG / PDD / 字节真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 代码)→ 【追问 & 加分点】。

最后更新 2026年8月29日

工程化与性能

面向大厂 P6 前端面试的标准答案集。题目来源:腾讯 WXG / PDD / 字节真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 代码)→ 【追问 & 加分点】。

目录


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

  1. 依赖收集:Parser(acorn)遍历 AST,遇到 import() 调用表达式时,ImportParserPlugin 生成一个 ImportDependency(以及 ImportEagerDependency 等变体),并把 magic comment 解析出的 chunkName / mode / prefetch / preload / exports / include / exclude 挂到依赖上。

  2. 构建 ChunkGraph:compilation.seal() 阶段先按 entry 建立 initial chunk,然后遍历模块图,凡是通过异步依赖边(AsyncDependenciesBlock)到达的模块,都会新建一个 ChunkGroup(异步 chunk group),成为 initial chunk 的 child。这是"切一刀"的本质:异步依赖块 = chunk 边界。

  3. 模块归属:默认规则下,一个模块被放进「所有能到达它的 chunk」里。所以如果 a.js 同时被主 bundle 同步引用、又被某个异步 chunk 引用,它会同时存在于两个 chunk(这就是重复打包问题)。

  4. splitChunks 二次优化:SplitChunksPlugin 在 optimizeChunks 钩子上工作,按 cacheGroups(minSize / maxSize / minChunks / chunks: 'all'|'async'|'initial' / priority / test)把被多个 chunk 共享的模块抽出成新的公共 chunk,并把它挂到原 chunk group 的依赖上。注意 chunks: 'all' 才会跨越同步/异步边界去合并,默认的 'async' 只处理异步 chunk。

  5. 产物:每个 chunk 输出一个文件([name].[contenthash].js),同时 runtime chunk 里内联两张表:

    • chunkId → 文件名映射(__webpack_require__.u,通常是一个把 id 映射到 chunkName + hash 的函数)
    • 已安装 chunk 状态表(installedChunks)

阶段二:运行时怎么加载

编译后的调用点(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"。

【追问 & 加分点】

  1. chunk load 失败怎么办? 上面的 onError 会把 installedChunks[chunkId] 复位为 undefined,所以重试是可行的。生产上常见做法:包一层 retryImport(() => import('./x'), 3),或用 output.crossOriginLoading + chunkLoadTimeout 配合 CDN 多域名重试。React 里还要配合 lazy + ErrorBoundary,否则一次网络抖动就白屏。
  2. webpackPrefetch vs webpackPreload:prefetch 生成 <link rel="prefetch">,在父 chunk 加载完、浏览器空闲时下载,优先级最低,用于"下一个页面可能要用";preload 生成 <link rel="preload">,与父 chunk 并行下载、中等偏高优先级,用于"当前页面马上要用"。两者都只是下载进缓存,不执行。
  3. contenthash 与长缓存:异步 chunk 的文件名映射表在 runtime chunk 里,所以业务 chunk 一变,runtime 内容就变。要做长效缓存必须 optimization.runtimeChunk: 'single' 把 runtime 单独抽出来,否则 vendors 的 hash 会被"映射表变化"污染(webpack 5 用 realContentHash 已经缓解了大部分,但 runtimeChunk 仍是标准做法)。
  4. 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 会二次改写分包结果。

【追问 & 加分点】

  1. 怎么快速证明"某个模块没被拆出去"? webpack --json > stats.json 后用 statoscope 或在线 analyzer 打开;命令行更快的方式是 npx webpack --stats-reasons --stats-modules-space 999 直接看 reasons 链。CI 里可以加 size-limit / bundlesize 做体积回归卡口,这是 P6 答案里的加分项——不是靠人眼看,而是靠流水线守住。
  2. 为什么 minSize 拆不动的模块会"合并回去"而不是单独成包? 因为 HTTP 请求本身有开销(连接、头部、优先级调度),几 KB 的独立 chunk 收益为负。HTTP/2 多路复用缓解了但没消除(仍有帧调度与队头优先级问题)。
  3. import() 的返回值是 Module Namespace Object,不是 default 导出。const C = await import('./C') 拿到的是 { default: C, __esModule: true },React lazy 要求组件在 default 上,所以常见写法是 lazy(() => import('./C').then(m => ({ default: m.Named })))。
  4. 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 → 递归

为什么必须做依赖预构建(两个理由,都要说):

  1. 格式:npm 上大量包仍是 CJS/UMD,浏览器原生 ESM 不认 require/module.exports,必须转。
  2. 性能:lodash-es 有 600+ 个内部模块,不预构建则浏览器发 600+ 个请求,dev 直接卡死。预构建把它们合成一个文件。

Build 阶段

维度WebpackVite
打包器webpack 自身Rollup(Rolldown —— Rust 实现的 Rollup 兼容打包器 —— 可用 rolldown-vite 尝鲜,规划中逐步转正)
产物形态__webpack_require__ 运行时 + 模块工厂函数,模块被包成函数Rollup 的 scope hoisting(把模块拍平到同一作用域),产物更接近手写代码,体积通常更小
Tree shakingwebpack 4 起支持,依赖 sideEffects 字段 + ModuleConcatenationRollup 的强项,基于纯 ESM 静态分析,效果通常更彻底
代码分割splitChunks + optimization 一整套配置build.rollupOptions.output.manualChunks,默认策略更简单
压缩Terser(默认)/ SWC / esbuildesbuild(默认,快)或 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 阶段没有这个优势。

【追问 & 加分点】

  1. Vite 的 dev/build 行为不一致风险:dev 走原生 ESM + esbuild,build 走 Rollup + Terser,转换管线不同,历史上出过"dev 正常 build 挂"的问题(CJS/ESM interop、process.env 替换时机、循环依赖行为)。工程上的对策:CI 必须跑 build + 预览环境冒烟,不能只测 dev。
  2. webpack 5 的追赶手段:cache: { type: 'filesystem' }(二次启动提速数倍)、experiments.lazyCompilation(只编译当前访问到的入口/动态 import,思路上借鉴了 Vite)、swc-loader / esbuild-loader 替换 babel。老项目不一定要迁 Vite。
  3. esbuild 不做类型检查:Vite 里 TS 只被"擦除类型",类型错误不会阻断 dev。所以必须单独跑 tsc --noEmit(配 vite-plugin-checker 或放到 CI)。这是面试里很实的一个点。
  4. 下一代趋势: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-boundaries ESLint 规则,给包打 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。

代价(面试一定要主动说,否则显得没实践过)

  1. CI 时间膨胀:不做 affected 检测的话,改一行 README 全量跑一遍。对策:--filter='...[origin/main]' + 远程缓存 + 分片。
  2. Git 仓库变大:clone 慢。对策:CI 用 --filter=blob:none partial clone、sparse-checkout;本地可用 scalar。
  3. 权限粒度:Git 本身没有目录级权限。对策:CODEOWNERS + 分支保护 + review 强制,或平台层(GitLab 的 push rules)。
  4. 依赖升级的爆炸半径:升一个 React 版本影响所有 app。对策:catalog/overrides 统一 + 灰度分批(先在一个 app 里试)。
  5. 工具链复杂度:新人上手曲线陡,任何构建问题都是"全仓问题"。对策:文档 + doctor 脚本 + 平台团队兜底。
  6. IDE 性能:TS 全量类型检查慢。对策:Project References(composite: true + tsc -b)做增量类型检查,tsconfig.json 里各包互相 reference。

【追问 & 加分点】

  1. 包的产物形态怎么定? 三种:① 发布 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"。
  2. turbo 的 hash 为什么可能"该命中却没命中"? 常见原因:outputs 没声明全(产物没被缓存)、inputs 没收窄(无关文件变动触发 miss)、环境变量泄漏进构建(未在 env 声明的变量变了不 miss,反而更危险 —— 会回放错误产物)、.gitignore 外的脏文件参与 hash。turbo run build --dry=json 可以打印每个 task 的 hash 及其输入。
  3. Monorepo ≠ Monolith:仓库合并不等于运行时合并。部署仍然可以独立,每个 app 有自己的流水线,靠 affected 判断哪些需要重新部署。
  4. 什么时候不该用 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。这是前端部署最经典的线上事故。

灰度与回滚

灰度(渐进发布)的几种做法,从轻到重:

  1. 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;   # 便于监控按版本聚合
}
  1. 功能开关(Feature Flag):更细粒度,代码已上线但功能未开启,出问题关开关即可,无需回滚发版。适合有后端配合的大功能。
  2. 按用户维度定向:内部员工 → 白名单用户 → 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 倍 → 流水线自动回滚("自动熔断"),这是能明显拉开档次的加分点。

【追问 & 加分点】

  1. 为什么要"一次构建多环境部署"? 如果每个环境重新 build,测试环境验证过的产物和生产跑的不是同一个二进制,中间任何依赖漂移都可能引入差异。做法:把环境相关配置从构建期挪到运行期——注入 window.__APP_CONFIG__(HTML 里由服务端/边缘函数填充)或读一个不带 hash 的 /config.json。代价是这部分配置无法被 tree shake,且多一个请求(可以内联进 HTML 消除)。
  2. Loading chunk failed 怎么彻底治? 三层防御:① CDN 保留旧版本资源;② 前端捕获 chunk 加载错误后 location.reload()(但要防死循环,用 sessionStorage 记标记);③ 服务端推送版本号(SSE/轮询/X-App-Version 响应头),检测到新版本时提示用户"有新版本,点击刷新"。
  3. Secrets 管理:前端构建期注入的任何变量都会进产物、都是公开的。VITE_/NEXT_PUBLIC_ 前缀的变量绝不能放密钥。真正的密钥必须走 BFF。CI 里用 OIDC 换取云厂商临时凭证(而不是把长期 AK/SK 存在 secrets 里)是当前最佳实践。
  4. 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)
TTFBTime to First Byte,导航开始到收到第一个字节PerformanceNavigationTiming.responseStart - startTime≤ 800ms / > 1800ms
FCPFirst Contentful Paint,首个文本/图片/非白 canvas/SVG 绘制PerformanceObserver('paint')≤ 1.8s / > 3.0s
LCPLargest Contentful Paint,视口内最大内容元素的绘制时间PerformanceObserver('largest-contentful-paint')≤ 2.5s / > 4.0s
CLSCumulative Layout Shift,会话窗口内最大的一组布局偏移分数之和PerformanceObserver('layout-shift')≤ 0.1 / > 0.25
INPInteraction to Next Paint,交互到下一帧绘制的延迟(取页面生命周期内接近 p98 的那次交互)PerformanceObserver('event', {durationThreshold})≤ 200ms / > 500ms
TBTTotal Blocking Time,FCP 到 TTI 之间所有长任务超出 50ms 部分之和实验室(Lighthouse)≤ 200ms / > 600ms
TTITime to Interactive,主线程连续 5s 空闲且网络安静实验室;已从 Lighthouse 10 移除,稳定性差≤ 3.8s / > 7.3s
FIDFirst Input Delay,已被 INP 取代(2024-09 从 CrUX 移除)——

三个 Core Web Vitals:LCP(加载)、INP(交互)、CLS(视觉稳定)。

为什么常选 LCP 做衡量

  1. 贴近主观感受:用户说"这页面慢",说的基本就是"主要内容什么时候出来"。LCP 直接对应这句话,而 FCP 可能只是个 loading 骨架、TTFB 完全不可见。
  2. 可在真实用户环境测量(field-measurable):LCP 有原生 PerformanceObserver 支持,无需埋点侵入业务代码,采集成本低,且 Chrome 的 CrUX 数据集有全网基线可对比。相比之下 TTI 需要"网络安静"判断,在真实环境噪音大、不稳定(这也是它被 Lighthouse 移除的原因)。
  3. 可归因、可优化:LCP 可以拆成四段——TTFB / 资源加载延迟(load delay)/ 资源加载时长(load duration)/ 元素渲染延迟(render delay),每一段都有明确的优化手段。这种可拆解性让它能驱动工程行动。
  4. 与业务指标相关性强:多家(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);

浏览器内部停止上报新候选的条件(面试要能背出来):

  1. 用户首次交互——click、keydown、以及(Chrome 实现中)滚动。原因:一旦用户开始交互,后续渲染出来的大元素就不再属于"加载体验"了。
  2. 页面被隐藏(切后台 / 切标签页 / 锁屏),因为后台页面的渲染时序不可信。

这带来的实际影响:

  • 如果页面加载中用户很早就点了一下,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 });
});

【追问 & 加分点】

  1. SPA 的路由切换算不算新的 LCP? 标准 LCP 只针对硬导航(hard navigation),SPA 内部路由跳转不会重置 LCP。这是当前 Web Vitals 体系的公认短板。业界方案:Chrome 在推 Soft Navigations API(实验中,PerformanceObserver 可观测 soft-navigation 类型);工程上常用自定义指标——在路由切换时打点,配合 PerformanceObserver('element')(Element Timing API,给关键元素加 elementtiming 属性)测量"新页面主内容出现时间"。
  2. 实验室数据 vs 真实用户数据(Lab vs Field):Lighthouse 是模拟环境(固定的 4x CPU 降速、模拟慢 4G),用于回归防护和优化前后对比;RUM(Real User Monitoring)才是决策依据,因为它包含真实的设备分布、网络分布、缓存命中情况。二者数值不一致是正常的,不要试图对齐。看 RUM 一定看 p75 或 p90,不看均值——均值会被少数极端值和大量快速请求同时扭曲。
  3. CLS 的"会话窗口"细节:CLS 不是简单累加所有偏移。它把偏移分组:同一组内相邻偏移间隔 < 1s,整组时长 ≤ 5s,CLS 取所有组中分数最大的那一组。所以一个持续微小抖动的页面不会被无限累加。另外,用户交互后 500ms 内的偏移不计入(视为"预期内的变化")。
  4. 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 的函数。

【展开】

各轨道怎么看

轨道看什么
TimingsFP / 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。

【追问 & 加分点】

  1. CPU throttling 一定要开:开发机是 M 系列 / i9,永远看不到用户的中低端安卓机上的卡顿。录制前把 CPU 设为 4x 或 6x slowdown,网络设为 Slow 4G。这是"能不能复现用户问题"的前提。
  2. Long Animation Frame (LoAF) 比 Long Task 更实用:Long Task 只告诉你"有个 200ms 的任务",但不告诉你是谁。LoAF(PerformanceObserver({type:'long-animation-frame'}))会给出这一帧里的所有脚本条目(scripts[],含 sourceURL、invoker、forcedStyleAndLayoutDuration),能直接归因到具体文件和是否有强制回流。生产 RUM 里这是当前排 INP 的最佳信息源。
  3. 录制别太长:超过 10~20s 的 trace 会让面板本身卡顿且难以分析。正确做法是"先复现问题的最小操作路径,只录那 3~5 秒",必要时用 performance.mark 精确框定。
  4. 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
  • 字体: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%" 远胜于罗列十个技术名词。

【追问 & 加分点】

  1. 优化的负面代价要能说清:will-change 提升图层占显存(低端机可能反而更卡);过度代码分割导致请求瀑布;SSR 增加服务器成本和复杂度;预加载过多会抢占关键资源带宽(preload 滥用是常见反模式);memo 用错地方反而增加比较开销。能主动说出 trade-off 是 P6 与 P5 的分水岭。
  2. 别只优化"平均值":p50 好看不代表用户满意。真正的问题在 p75/p90——低端安卓 + 弱网 + 冷缓存的那部分用户。RUM 要按设备档位(可用 navigator.hardwareConcurrency、deviceMemory)和网络(navigator.connection.effectiveType)分层看。
  3. 首屏优化的"最短路径":如果只能做三件事,我会选 —— ① LCP 图片用 AVIF/WebP + preload + fetchpriority=high;② 首屏 JS 体积砍到 100KB 以内(gzip 后);③ 消除渲染阻塞资源(关键 CSS 内联 + 第三方脚本 defer)。这三件通常能吃掉 70% 的收益。
  4. 性能预算(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 是否真的改善(实验室 ≠ 真实用户)

场景一:首屏卡

症状:打开页面白屏久、内容出来后一段时间点不动。

定位工具与步骤:

  1. Lighthouse 跑一遍拿方向(Opportunities + Diagnostics),确认是加载问题还是执行问题。
  2. Network 面板看瀑布图:
    • 是否有长的串行链(HTML → CSS → JS → 接口 → 图片,一环等一环)
    • LCP 图片的请求什么时候才开始(发现太晚是最常见的问题)
    • 有没有渲染阻塞资源(Network 里的 render-blocking 列)
    • TTFB 是否本身就大(> 800ms 说明后端/CDN 有问题,前端怎么优化都白搭)
  3. Performance 面板看 Timings 轨道的 FCP/LCP 位置,再看 Main 轨道 FCP 之后有没有大块黄色(典型是 bundle 解析执行 + framework hydration)。
  4. Coverage 面板看首屏 JS/CSS 的未使用比例,> 50% 说明分割没做好。
  5. 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

通常收益最大的四条(面试可以直接给"我会先做这四件事"):

  1. 首屏 JS 体积砍到 gzip < 150KB(路由级分割 + 大依赖懒加载 + 去掉未用 polyfill)。
  2. LCP 图片格式 + preload + fetchpriority,去掉一切 lazy。
  3. 关键 CSS 内联 + 其余 CSS 异步;第三方脚本全部 defer/延后到 load 之后。
  4. 首屏数据接口做 BFF 聚合 或 SSR 直出(消掉"JS 下载执行完才发请求"这一整段瀑布)。

场景二:分页切换卡

症状:点分页/切 Tab 后有明显停顿,几百毫秒到 1s 无响应,然后内容才刷新。

定位工具与步骤:

  1. Performance → Interactions 轨道:看这次点击的 INP 分三段,判断是输入延迟(点击时主线程已经忙)、处理时长(handler 里干太多活)还是呈现延迟(渲染太重)。
  2. Main 轨道看点击后那个 Task 有多宽,展开火焰图找到最宽的子块。
  3. React DevTools Profiler:录制切换过程,看 Ranked 视图哪个组件 render 最久、为什么重渲染(开 "Record why each component rendered")。常见是"整棵树都重渲染了"。
  4. Network 面板:切页有没有重复请求已经拿过的数据(无缓存);接口是否串行。
  5. 检查 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。

定位工具与步骤:

  1. Performance 录制"点击加载 → 数据渲染完"全过程。
  2. 找到 XHR/Fetch 完成后的那个大 Task,展开火焰图。
  3. 切 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 万条数据的完整解法,可以直接说这套):

  1. 后端游标分页 —— 先别一次拿 10 万条,这是第一优先级(前端所有技巧都不如不传这些数据)。
  2. 确实要全量(如导出、离线分析)→ Worker 解析 + 加工,主线程不参与。
  3. 渲染层 虚拟列表,DOM 恒定在几十行。
  4. 首批数据 分片渲染 让首帧尽快出现。
  5. 滚动加载配 IntersectionObserver 哨兵(不用 scroll 监听)。
  6. 全过程配 骨架屏 + 加载态,让等待可感知。

【追问 & 加分点】

  1. 怎么区分"卡"是掉帧还是无响应? 掉帧(jank)= 动画不流畅但点击还有反应,看 Frames 轨道的红黄帧;无响应(unresponsive)= 长任务把主线程占死,点击事件排队,看 INP 和 Long Task。两者优化方向不同:掉帧优先看合成层和每帧工作量,无响应优先看长任务拆分。
  2. 生产环境怎么发现卡顿?(这题不答生产监控就不完整)线上用 PerformanceObserver 采集 long-animation-frame(含归因脚本 URL)+ event(INP)+ web-vitals 库;配合用户操作路径回放(rrweb)定位具体场景。不能依赖用户反馈——大多数用户卡了直接走人,不会反馈。
  3. 移动端 WebView 的额外因素:客户端容器的初始化耗时、JSBridge 通信开销、离线包命中率、WebView 内核版本差异(老 Android WebView 不支持很多现代 API 和格式)。排查时要先确认"是 H5 慢还是容器慢"——常用手段是在原生侧和 H5 侧各打点对齐时间轴。
  4. 不要过早优化,也不要"感觉优化":所有结论必须有 trace 佐证。面试里被问到具体项目时,能说出"我用 Performance 录到某个第三方 SDK 的 init 占了 320ms 的长任务,defer 之后 INP p75 从 380ms 降到 160ms",比说十条通用技巧更有说服力。

反向链接(0)

还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。

评论

关系图谱

01-js-core02-browser03-network04-css-html05-react06-vue07-engineering08-scenarios09-handwritten-js10-algorithms11-ai-frontend前端识记前端面试

On this page

工程化与性能目录1. Webpack 对动态 import() 是怎么处理的【核心答案】【展开】阶段一:编译期怎么拆 chunk阶段二:运行时怎么加载几个容易被追问的细节【追问 & 加分点】2. 哪些情况会导致动态 import 没拆出独立 chunk【核心答案】【展开】① 完全动态表达式:无法静态分析② 同时被同步引用(最常见的"业务事故")③ magic comment: webpackMode④ splitChunks / optimization 配置⑤ 其他容易踩的【追问 & 加分点】3. Webpack vs Vite 原理对比【核心答案】【展开】Dev 阶段Build 阶段一个必须澄清的常见误解【追问 & 加分点】4. Monorepo 大仓方案【核心答案】【展开】为什么要 Monorepo(问题驱动)三个方案的定位发版:Changesets代价(面试一定要主动说,否则显得没实践过)【追问 & 加分点】5. CI/CD 流水线与前端部署【核心答案】【展开】完整流水线缓存策略(面试高频,必须说准)灰度与回滚【追问 & 加分点】6. 前端性能指标体系与 LCP【核心答案】【展开】指标全景为什么常选 LCP 做衡量LCP 的测量终点:不是固定的(这是本题的题眼)【追问 & 加分点】7. Performance 面板用来定位什么问题【核心答案】【展开】各轨道怎么看火焰图(Flame Chart)怎么读四类典型问题的识别特征配套工具(不要只答 Performance 面板)【追问 & 加分点】8. 体系化性能优化清单【核心答案】【展开】第一层:网络层(目标:TTFB、资源到达时间)第二层:资源层(目标:FCP / LCP 的资源加载段)第三层:渲染层(目标:LCP 渲染段 / CLS)第四层:JS 执行层(目标:INP / TBT)第五层:感知优化(目标:主观体验)方法论(比清单本身更重要)【追问 & 加分点】9. 场景题:页面卡顿排查【核心答案】【展开】通用分诊流程(回答前先建立方法,不要一上来就报优化点)场景一:首屏卡场景二:分页切换卡场景三:加载数据时卡【追问 & 加分点】