跳到正文
前端知识库
浏览器

浏览器原理、性能与资深面试

从多进程架构、导航和渲染流水线深入到事件调度、缓存、安全、Web Vitals 与性能诊断。

18 分钟浏览器 · 渲染 · 性能 · 安全 · 面试

浏览器性能问题往往横跨网络、主线程、样式和资源加载。理解整条链路,才能判断优化应发生在哪一层。

从地址到响应

用户输入 URL 后,浏览器通常经历以下过程:

  1. 解析 URL,判断协议、主机、端口和路径。
  2. 查询内存、系统或网络中的 DNS 缓存,获得服务器地址。
  3. 建立 TCP 连接;HTTPS 还需要完成 TLS 握手。
  4. 发送 HTTP 请求,携带方法、请求头和可选请求体。
  5. 接收响应状态、响应头和响应体。
  6. 根据内容类型进入 HTML 解析、下载或其他处理流程。

HTTP/2 可以在单连接中多路复用请求;HTTP/3 基于 QUIC,减少连接建立和丢包阻塞的影响。但协议升级不能替代合理的资源体积和缓存策略。

HTML 与 CSS 解析

浏览器边接收 HTML 边构建 DOM。遇到样式表时下载并解析 CSS,生成 CSSOM。DOM 与 CSSOM 共同参与渲染树构建。

普通同步脚本会暂停 HTML 解析:

<script src="legacy.js"></script>

<!-- 下载时不阻塞解析,解析完成后按文档顺序执行 -->
<script defer src="app.js"></script>

<!-- 下载完成立即执行,不保证顺序 -->
<script async src="analytics.js"></script>

应用入口通常选择 defer 或原生 ES Module。async 更适合与页面逻辑无依赖的独立脚本。

设备能力与响应式判断

不要把“识别设备”理解成必须得到一个精确的机型字符串。navigator.userAgent 可以作为兼容性或统计线索,但它可能被伪装,且浏览器的 UA 逐渐减少可区分的信息。布局应由 CSS 媒体查询和实际能力决定,业务分支优先做能力检测:

const isCoarsePointer = window.matchMedia('(pointer: coarse)').matches
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches

function isSmallViewport() {
  return window.matchMedia('(max-width: 767px)').matches
}

需要根据视口变化更新布局时监听 MediaQueryListchange 事件,而不是只在初始化时读取 innerWidth。设备类型、视口大小和输入能力是不同维度;不能仅凭宽度断言“这是手机”,也不应为每一种设备复制一套页面。真正的触控、相机等能力还应检查对应 API 是否存在,并在调用时处理权限拒绝和运行时失败。

从渲染树到像素

主要渲染阶段包括:

  1. Style:计算每个元素最终使用的样式。
  2. Layout:计算元素尺寸和位置。
  3. Paint:把文本、颜色、边框、阴影等记录成绘制指令。
  4. Composite:合成不同图层并显示到屏幕。

改变宽高、位置或字体可能触发布局;改变颜色和阴影通常触发绘制;合适的 transformopacity 动画可只发生合成。

不要盲目添加 will-change。新图层会消耗内存,应只在确认热点且动画即将发生时使用。

布局抖动

交替读取布局信息和写入样式,会迫使浏览器反复同步计算布局:

// 不推荐:每轮写入后立即读取布局
items.forEach((item) => {
  item.style.width = '200px'
  console.log(item.offsetWidth)
})

把读取和写入分别批量执行:

const widths = items.map((item) => item.offsetWidth)

items.forEach((item, index) => {
  item.style.width = `${Math.max(widths[index], 200)}px`
})

现代框架会批量部分 DOM 更新,但业务代码仍应避免在热路径中频繁读取几何信息。

事件循环与渲染时机

主线程负责 JavaScript、样式计算、布局和部分绘制工作。一个长任务会推迟输入响应与下一帧渲染。

每轮事件循环大致处理一个任务,然后清空微任务队列,浏览器再选择合适时机渲染。requestAnimationFrame 回调发生在下一次绘制前,适合基于帧的视觉更新。

requestAnimationFrame(() => {
  element.style.transform = 'translateX(24px)'
})

大计算可以拆分成更小任务,或移动到 Web Worker。Worker 不能直接操作 DOM,需要通过消息传递数据。

缓存策略

强缓存允许浏览器在有效期内不发请求:

Cache-Control: public, max-age=31536000, immutable

带内容哈希的静态资源适合长期缓存。HTML 通常使用短缓存或协商缓存,以便及时获取新版本。

协商缓存由 ETag/If-None-MatchLast-Modified/If-Modified-Since 判断资源是否变化;未变化时服务器返回 304

Service Worker 可以实现离线缓存和请求代理,但也引入版本更新与陈旧资源风险,需要明确缓存淘汰策略。

Service Worker 与跨页面通信

Service Worker 没有 DOM,也不能直接访问 windowlocalStorage。它通过 CacheStorage、IndexedDB 和 fetch/message 事件工作,生命周期通常经过 install、等待、activate,只有被页面控制后才会拦截该页面的请求。缓存名应带版本,激活时删除旧版本;skipWaiting()clientsClaim() 会缩短切换时间,但可能让同一页面同时出现新旧资源,必须配合兼容的发布策略。

const CACHE = 'app-static-v3'

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(['/','/app.js'])))
})

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => Promise.all(
      keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)),
    )),
  )
})

同源标签页、窗口和 Worker 之间只需要广播事件时,可以使用 BroadcastChannel。它使用结构化克隆传递数据,发送者不会收到自己的消息;使用完要调用 close(),它不能跨源通信,也不应传递不能序列化的大对象。

const channel = new BroadcastChannel('session-events')
channel.addEventListener('message', ({ data }) => {
  if (data?.type === 'signed-out') window.location.assign('/login')
})

// 登出完成后通知其他同源页面
channel.postMessage({ type: 'signed-out', at: Date.now() })
window.addEventListener('pagehide', () => channel.close(), { once: true })

不同通信 API 的边界不同,不能只按“能不能发消息”来选:

机制 适合场景 关键边界
storage 事件 简单同步同源标签页的少量状态 只在其他文档触发,值只能是字符串,不能传递对象或确认对方已处理
BroadcastChannel 同源窗口、标签页和 Worker 的广播 不跨源;发送者不会收到自己的消息;使用后要 close()
window.postMessage 窗口与 iframe,尤其是跨源协作 接收方必须校验 originsource,发送方应使用明确的 targetOrigin
ServiceWorker 消息 页面与离线代理、多个受控客户端协作 只有被控制的页面才能由 SW 拦截请求;SW 没有 DOM,生命周期可能被浏览器暂停
SharedWorker 同源多个页面共享一个长生命周期 Worker 依赖端口连接和浏览器支持;要处理端口关闭、版本升级和无客户端时的回收

例如用 postMessage 与 iframe 协作时,消息协议和来源检查应同时存在:

const trustedOrigin = 'https://embed.example.com'
const frame = document.querySelector('iframe')

window.addEventListener('message', (event) => {
  if (event.origin !== trustedOrigin || event.source !== frame.contentWindow) return
  if (event.data?.type === 'ready') {
    frame.contentWindow.postMessage(
      { type: 'config', version: 1 },
      trustedOrigin,
    )
  }
})

不要用 '*' 接收或发送包含令牌、用户信息和支付状态的消息,也不要把 event.data 直接当作可信命令执行。storage 事件适合“失效通知”这类简单广播,真正的业务状态仍应由服务端或明确的状态存储确认;它不是跨标签页事务机制。

存储与安全边界

  • Cookie 会随匹配请求发送,适合服务端会话;敏感会话应使用 HttpOnlySecure 和合适的 SameSite
  • localStorage 是同步 API,会阻塞主线程,且脚本可读取,不应存放令牌等敏感信息。
  • IndexedDB 是异步的结构化存储,适合较大离线数据。
  • 同源策略限制不同源之间读取数据,CORS 是服务器声明的受控放行机制。

把不可信字符串插入 innerHTML 可能造成 XSS。优先创建文本节点或使用框架默认转义;确实需要 HTML 时必须在可信边界进行清洗。

核心性能指标

  • LCP:主要内容完成渲染的时间。
  • INP:用户交互到下一次可见反馈的延迟。
  • CLS:页面生命周期内意外布局偏移的程度。

常见改进方向:

  • LCP:优化首屏资源、图片尺寸、服务端响应和关键 CSS。
  • INP:拆分长任务,减少主线程 JavaScript,及时反馈交互。
  • CLS:为图片、广告和异步内容预留稳定尺寸。

排查工具

  1. Network 面板确认瀑布流、缓存命中和资源优先级。
  2. Performance 面板定位长任务、布局和绘制热点。
  3. Rendering 面板显示重绘区域和布局偏移。
  4. Lighthouse 用于建立基线,但结论要回到真实用户与真实设备验证。
  5. Memory 面板通过快照和时间线排查持续增长的对象。

多进程与线程模型

现代浏览器通常把职责拆到多个进程:浏览器进程负责窗口、导航和权限;网络进程处理网络请求;渲染进程负责站点内容;GPU 进程承担部分光栅化与合成工作。具体数量和边界由浏览器实现、站点隔离策略与设备资源决定,不能简单回答“一个标签页一定对应一个进程”。

渲染进程内部也有多类线程:

  • 主线程执行 JavaScript、DOM、样式与布局等工作。
  • 合成线程组织图层、处理部分滚动和动画。
  • 光栅线程把绘制指令转换成位图分块。
  • Worker 线程可执行独立 JavaScript,但不能直接访问 DOM。

进程隔离提升稳定性与安全性,也带来内存和通信成本。跨进程数据必须序列化或通过共享/转移机制传递,因此把大量数据频繁发给 Worker 不一定更快。

一次导航的完整链路

资深面试可以把“输入 URL 到页面显示”拆为可定位的阶段:

  1. 处理输入、补全 URL,检查 HSTS、缓存、Service Worker 与导航策略。
  2. 解析主机并建立连接。DNS、TCP、TLS 可以命中缓存或复用已有连接。
  3. 发送 HTTP 请求,经历代理、CDN、网关和源站,可能发生重定向。
  4. 根据状态码、Content-Type、下载策略和安全响应头决定如何处理响应。
  5. 为目标站点选择或创建渲染进程,提交导航并开始解析文档。
  6. 解析期间发现子资源,结合优先级、缓存和连接状态发起加载。
  7. 生成可见内容,达到首次绘制、LCP 和可交互等不同里程碑。

DNS 并非总在第一步实时查询,连接也未必新建。HTTP/2 多路复用解决应用层请求并发,但 TCP 丢包仍会影响同连接数据;HTTP/3 将传输建立在 QUIC 上,使独立流不因单个流丢包互相阻塞。

回答时应避免背固定顺序,说明哪些步骤可以缓存、复用、并行或被 Service Worker 改写,才体现对真实浏览器的理解。

关键渲染路径与资源提示

HTML 决定资源发现顺序。CSS 通常不阻塞 DOM 构建,但会阻塞渲染,并可能阻塞需要读取样式信息的脚本执行;同步脚本则可能阻塞 HTML 解析。

常见资源提示语义不同:

  • preconnect 提前建立与关键跨源的连接,只应用于确定很快需要的少数来源。
  • dns-prefetch 只尝试提前解析 DNS,收益和成本都较低。
  • preload 以当前页面高优先级提前获取已知资源,as 和 CORS 模式必须与真实请求匹配,否则可能重复下载。
  • prefetch 提示未来导航可能使用,优先级较低且浏览器可以忽略。
  • modulepreload 提前获取并处理模块依赖。

preload 不是“把所有资源都提前下载”。它必须和最终请求的 astype、跨源凭据模式一致,否则可能重复请求或浪费带宽;只预加载确定会出现在当前视口的关键资源。CDN 只改变资源到用户的传输路径,不会自动解决版本一致性或跨域授权。静态资源可用内容哈希配合长期缓存,CDN 回源失败时再按版本明确的备用源降级,不能把任意第三方地址当成无条件的 fallback。

关键 CSS 可以内联或尽早加载,但过量内联会失去跨页缓存并增大 HTML。字体优化要同时考虑子集、格式、预加载、font-display 和度量差异;只设置 swap 可能用字体切换换来 CLS。

渲染失效、图层与滚动

DOM 或样式改变后,浏览器会标记受影响范围,而不总是重做整页。选择器依赖、继承和布局关系决定失效传播范围。布局完成后生成绘制记录,内容被划分到图层,再切成 tiles 光栅化并由合成线程组合。

合成线程能在主线程繁忙时处理部分滚动和已合成动画,但以下情况仍会拉回主线程:

  • 非 passive 的触摸或滚轮监听器可能调用 preventDefault()
  • sticky、复杂裁剪或需要重新绘制的内容可能增加每帧成本。
  • 动画改变布局或绘制属性,而不是已有图层的合成属性。
window.addEventListener('touchstart', onTouchStart, { passive: true })

只有确定监听器不会阻止默认滚动时才声明 passive。图层不是越多越好:它们消耗显存、上传带宽和管理成本,应该由性能记录验证。

ResizeObserver 观察元素内容盒或边框盒的尺寸变化,适合组件容器自适应;它不是窗口 resize 的替代品,也不应在回调里无条件改尺寸,否则可能形成 ResizeObserver loop。窗口尺寸仍可用 matchMediaresize 事件(配合节流)处理,组件布局则优先观察自身容器。

const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.contentRect.width
    entry.target.toggleAttribute('data-compact', width < 480)
  }
})

observer.observe(document.querySelector('.card-grid'))

任务调度与长任务拆分

主线程长任务不仅推迟 JavaScript,也会推迟输入、布局和绘制。拆分策略取决于任务优先级:

  • 必须立即反馈的交互先更新最小 UI,再处理次要工作。
  • 大循环分块,并在块间把控制权还给事件循环。
  • 与下一帧视觉相关的读写放到合适的 requestAnimationFrame 阶段。
  • 纯计算且数据传输成本可控时使用 Web Worker。
  • 可延后任务使用调度 API 时准备降级,不依赖空闲时间一定出现。
async function processInChunks(items, handle) {
  for (let index = 0; index < items.length; index += 1) {
    handle(items[index])

    if (index % 100 === 99) {
      await new Promise((resolve) => setTimeout(resolve, 0))
    }
  }
}

示例表达“让出主线程”的思路,不代表 100 是通用批次。生产中应按时间预算、设备性能和任务优先级调整。用微任务让出无法给渲染机会,因为浏览器会先清空微任务队列。

requestAnimationFramerequestIdleCallback 与 Worker 如何选择

可以按任务的可见性和可中断性做选择:

  1. 必须在下一帧呈现的视觉更新:使用 requestAnimationFrame,在回调中集中读取并写入布局相关属性;不要在其中执行长时间 CPU 计算。
  2. 可以延后的低优先级工作:使用 requestIdleCallback(或调度器 polyfill),并设置 timeout。空闲时间可能长期不存在,回调必须有超时路径和不支持该 API 时的降级。
  3. 纯计算且数据量适合传输:使用 Web Worker,把可转移的 ArrayBuffer 等对象转移出去,避免频繁结构化克隆。Worker 不能直接操作 DOM。
  4. 需要分阶段更新且可被用户输入打断:用定时器、MessageChannelscheduler.postTask(若目标浏览器支持)拆成小任务,并保留取消标记。
function scheduleNonCritical(work) {
  if ('requestIdleCallback' in window) {
    return window.requestIdleCallback(work, { timeout: 2000 })
  }

  return window.setTimeout(() => work({
    didTimeout: true,
    timeRemaining: () => 0,
  }), 0)
}

const idleId = scheduleNonCritical((deadline) => {
  while (deadline.timeRemaining() > 0 && hasMoreWork()) {
    runOneStep()
  }
})

requestAnimationFrame 的时间点是绘制前,不等于“异步线程”;requestIdleCallback 也不保证一定在一帧内执行。批量任务应根据 Performance 记录调整预算,组件销毁或页面隐藏时取消未完成的回调,避免后台继续消耗资源。

大量 DOM 的分帧渲染

如果业务确实需要创建几万个节点,优先考虑分页或虚拟列表;必须全部展示时,可以把节点先放进 DocumentFragment,再按帧分批挂载。这样每一批之间会还给浏览器处理输入和绘制,批次大小应通过 Performance 面板和目标设备调整,而不是固定照搬某个数字。

function renderInFrames(container, items, createNode, options = {}) {
  const batchSize = Math.max(1, options.batchSize || 200)
  let cursor = 0
  let frameId = 0
  let cancelled = false

  function renderBatch() {
    if (cancelled) return

    const fragment = document.createDocumentFragment()
    const end = Math.min(cursor + batchSize, items.length)

    while (cursor < end) {
      fragment.appendChild(createNode(items[cursor], cursor))
      cursor += 1
    }
    container.appendChild(fragment)

    if (cursor < items.length) {
      frameId = requestAnimationFrame(renderBatch)
    }
  }

  frameId = requestAnimationFrame(renderBatch)
  return () => {
    cancelled = true
    cancelAnimationFrame(frameId)
  }
}

DocumentFragment 减少了把每个临时节点单独接入文档的次数,但不会让总布局和绘制成本消失;如果列表仍然很长,应继续减少实际 DOM 数量。取消函数要在页面或组件离开时调用,避免后台页面继续排队渲染。

请求去重与错误聚合

重复请求要先定义请求身份(通常是方法、规范化 URL、关键请求头和稳定序列化后的 body),再复用进行中的 Promise。请求完成后必须清理 Map;共享 Promise 时,一个调用方取消不能无条件中止其他调用方,必要时为消费者做引用计数或分别创建 AbortController

const pending = new Map()

function requestOnce(key, task) {
  if (pending.has(key)) return pending.get(key)

  const promise = Promise.resolve().then(task).finally(() => {
    pending.delete(key)
  })
  pending.set(key, promise)
  return promise
}

requestOnce('GET:/api/profile', () => fetch('/api/profile'))

批量请求的错误提示应由一个边界统一负责。不要在每个 catch 中直接弹 Toast,否则同一批失败会重复提示;同时要区分用户主动取消、网络失败和业务错误,只对需要用户处理的错误展示提示。

HTTP 缓存、CDN 与版本发布

缓存策略要同时考虑浏览器、共享缓存和发布一致性:

  • 带内容哈希的 JS/CSS/图片:public, max-age=31536000, immutable
  • HTML:通常短缓存或 no-cache,允许存储但每次复用前验证,以便引用最新哈希资源。
  • 私有用户响应:根据数据性质使用 privateno-store 和正确的 Vary
  • CDN:可用 s-maxage 单独控制共享缓存有效期,并设计 purge 或 stale 策略。

no-cache 不等于“不存储”,而是复用前必须验证;no-store 才要求不存储。Vary 会扩展缓存键,错误地 Vary: * 或纳入高基数字段会严重降低命中率。

发布时必须保证 HTML 引用的哈希资源在一段兼容窗口内仍可访问。先删除旧资源再切换 HTML,可能让尚未刷新或边缘节点上的旧 HTML 指向 404。Service Worker 应采用明确的版本激活、缓存清理和回滚策略,不能默认强制 skipWaiting 就一定更好。

安全模型:XSS、CSRF 与 CSP

同源由协议、主机、端口三元组决定。同源策略主要限制跨源读取,并不阻止所有跨源请求;CORS 是服务器通过响应头允许脚本读取跨源响应的协议,不是客户端“关闭安全限制”。

常见攻击边界:

  • XSS:不可信数据进入 HTML、脚本、URL 或 CSS 等执行上下文。防护依赖上下文正确转义、可信模板、富文本清洗和 CSP 纵深防御。
  • CSRF:浏览器自动携带身份凭证发起受害者未授权操作。使用合适的 SameSite Cookie、CSRF token,并验证 Origin/Referer;不能把 CORS 当成唯一 CSRF 防护。
  • 点击劫持:页面被嵌入透明 iframe 诱导操作。使用 CSP frame-ancestors,旧系统可配合 X-Frame-Options
  • 供应链脚本:第三方脚本拥有页面同等能力。减少来源,固定版本,静态 CDN 资源可使用 SRI,并建立 CSP 与监控。
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-random'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

真实 CSP 需要结合资源来源、nonce/hash 和上报逐步部署。随意加入 'unsafe-inline' 会显著削弱脚本限制。

Cookie 还应明确 SecureHttpOnlySameSite、Domain、Path 和生命周期。HttpOnly 降低令牌被脚本直接读取的风险,但无法阻止 XSS 以用户身份在页面中发请求。

Web Vitals 的诊断闭环

指标只是症状入口。优化流程应是:真实用户数据发现问题,按页面/设备/网络/版本分段,实验室工具复现并定位,发布后再次验证分位数变化。

LCP

把 LCP 拆为 TTFB、资源加载延迟、资源下载时间和元素渲染延迟。若 LCP 图片直到客户端脚本执行后才发现,单纯压缩图片不够;应让它出现在初始 HTML、使用正确优先级并减少前置阻塞。

INP

一次交互延迟包括输入延迟、事件处理时间和呈现延迟。需要分别检查:主线程是否被先前任务占用;监听器做了多少工作;更新后是否发生大范围渲染。立即给出视觉反馈并把非关键工作延后,常比只优化某个函数更有效。

CLS

定位每次 layout shift 的来源,而不是只看总分。图片和嵌入内容预留比例;动态横幅在初始布局中保留位置;字体使用接近的 fallback 度量。用户输入后短时间内发生的部分偏移可能不计入 CLS,但仍可能是糟糕体验。

真实用户数据至少观察 p75,并按页面类型和设备拆分。平均值会掩盖长尾问题;一次 Lighthouse 高分也不能代表线上用户体验。

内存泄漏与页面生命周期

判断泄漏要看多次执行相同流程后,堆内存是否无法回到稳定基线。典型保留路径包括全局集合、事件监听器、定时器、未完成请求、Observer、闭包与 detached DOM。

排查步骤:

  1. 在稳定操作前强制回收并记录基线。
  2. 重复打开/关闭目标页面或组件多次。
  3. 再次回收并比较堆快照、对象数量和 retained size。
  4. 从可疑对象沿 retaining path 找到真正的根引用。
  5. 修复清理逻辑后用同一脚本复测。

前进/后退缓存(bfcache)会冻结整页并在返回时快速恢复。unload 监听器等行为可能影响进入缓存;页面恢复时 pageshowpersisted 可帮助识别。应用要区分首次加载与从 bfcache 恢复,避免重复初始化连接或使用过期状态。

页面关闭、PV 与可靠上报

unloadbeforeunload 不能作为可靠的统计触发器:移动端可能根本不触发,而且会影响 bfcache。页面从前台转入后台时优先监听 visibilitychange,再用 pagehide 作为生命周期补充。navigator.sendBeacon() 会把小型 POST 数据交给浏览器排队,返回值只表示是否接受入队,不代表服务端已经收到;数据较大或需要读取响应时可使用带 keepalive: truefetch,并仍要限制请求体大小。

let reported = false

function reportPageView() {
  if (reported) return
  reported = true

  const body = JSON.stringify({
    path: location.pathname,
    referrer: document.referrer,
    at: Date.now(),
  })
  const accepted = navigator.sendBeacon(
    '/telemetry/page-view',
    new Blob([body], { type: 'application/json' }),
  )

  if (!accepted) {
    fetch('/telemetry/page-view', {
      method: 'POST',
      body,
      keepalive: true,
      headers: { 'Content-Type': 'application/json' },
    }).catch(() => {})
  }
}

window.addEventListener('pageshow', () => {
  reportPageView()
}, { once: true })

上例的 reported 只负责一次文档生命周期;页面隐藏或关闭时的积压事件应另外在 visibilitychange/pagehide 中 flush,不能把“页面加载 PV”和“离开页面事件”混为一次。SPA 的每次路由 PV 应由路由层产生稳定的页面 ID,并在切换时去重。上报失败不能阻塞页面关闭,也不能把敏感信息或未经处理的用户输入直接放入日志。

PerformanceObserver 与前端监控 SDK

PerformanceObserver 适合异步接收浏览器性能条目。长任务的定义是主线程任务持续至少 50 毫秒;资源条目中的跨源细节只有在目标响应提供 Timing-Allow-Origin 时才可读。应检查浏览器是否支持目标条目类型,并在组件或页面销毁时 disconnect()

if (PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.duration >= 50) {
        recordMetric('long-task', {
          duration: entry.duration,
          start: entry.startTime,
        })
      }
    }
  })

  observer.observe({ type: 'longtask', buffered: true })
  window.addEventListener('pagehide', () => observer.disconnect(), { once: true })
}

监控 SDK 的最小闭环通常包括:生成页面/会话/发布版本标识,采集错误、未处理 Promise、资源耗时和关键 Web Vitals,按采样率脱敏后进入有上限的内存队列,批量发送并在页面隐藏时尝试 sendBeacon。队列必须有丢弃和退避策略,不能无限缓存或重试;错误上报也应过滤密码、令牌、完整 Cookie 和大段用户输入。监控代码本身应避免阻塞首屏,并提供开关、采样和版本回滚能力。

资深面试追问

为什么操作 DOM 慢?

DOM API 调用本身并不必然慢,成本来自它可能触发跨引擎边界工作以及后续样式、布局和绘制。批量创建离线节点、减少布局依赖、分离读写有帮助,但最终要通过性能时间线确认。框架虚拟 DOM 也不会消除浏览器渲染成本。(知识点:从渲染树到像素

强缓存和协商缓存如何配合?

强缓存由 freshness 决定,有效期内无需访问服务器;过期或要求验证时,使用 ETag/Last-Modified 发起条件请求,未修改返回 304。带哈希资源用长强缓存,入口 HTML 保持可验证,是常见组合。还要考虑 CDN 缓存键、旧资源保留和 Service Worker 这一额外层。(知识点:HTTP 缓存、CDN 与版本发布

Web Worker 一定能提升性能吗?

不一定。它能把纯计算移出主线程,但启动、消息调度、结构化克隆和数据回传都有成本,也不能操作 DOM。适合 CPU 密集且可独立的数据处理;小任务或频繁传输大对象可能更慢。可通过 transferable 转移 ArrayBuffer 所有权以减少复制。(知识点:任务调度与长任务拆分

如何定位线上“页面偶尔卡顿”?

先采集真实用户 INP、长任务、页面和设备维度,确认发生范围;再关联版本、交互类型和主线程记录。实验室使用相同数据量与设备节流复现,检查任务归属、脚本调用栈、渲染成本和第三方代码。修复后用同一分段指标验证,而不是只看本地感觉。(知识点:Web Vitals 的诊断闭环

实践检查

  • 关键脚本不会无故阻塞 HTML 解析。
  • 静态资源使用内容哈希和长期缓存,HTML 保持可更新。
  • 图片声明稳定尺寸并按实际显示尺寸传输。
  • 交互路径中没有超过 50 毫秒的连续主线程任务。
  • 不可信内容不会直接进入 HTML 注入接口。
  • 优化结论由性能记录和用户指标支持,而不是只依赖经验。
  • 能把导航、解析、渲染和资源加载拆成可测量阶段。
  • 缓存策略覆盖浏览器、CDN、Service Worker 和版本回滚。
  • 安全方案能区分 XSS、CSRF、CORS 与 CSP 的职责边界。