一句话总结
浏览器渲染的本质是一条流水线:HTML → DOM、CSS → CSSOM,两者合成渲染树 → 布局(几何信息)→ 绘制(像素指令)→ 合成(GPU 上屏)。JS 是唯一能随时插入打断这条流水线的能力——通过事件循环调度。理解渲染的关键是记住三件事:① 回流(Layout/Reflow)代价最高,改几何信息会触发其后所有阶段;② 重绘(Paint)次之,只影响像素;③ transform/opacity 在合成阶段完成,不走主线程布局绘制,所以动画首选它们。
# 一条 URL 到屏幕像素的完整链路(面试必考"输入 URL 发生了什么"):
# DNS 解析 → TCP 握手 →(HTTPS)TLS 握手 → HTTP 请求响应
# → HTML 解析构建 DOM(遇到 CSS 阻塞渲染、遇到 JS 阻塞解析)
# → DOM + CSSOM → 渲染树 → 布局 → 绘制 → 合成上屏 → 首次内容绘制 FCP → 最大内容绘制 LCP
初级理解
关键渲染路径五步
| 阶段 | 输入 | 输出 | 说明 |
| 解析 | HTML 字节 | DOM 树 | 字节→字符→词法→DOM 节点,容错解析 |
| 样式 | CSS 字节 | CSSOM | CSSOM 构建会阻塞后续 JS 与渲染 |
| 样式计算 | DOM+CSSOM | 渲染树 | display:none 的节点不进渲染树 |
| 布局 Layout | 渲染树 | 几何信息 | 每个节点的坐标与尺寸——发生"回流" |
| 绘制 Paint + 合成 Composite | 布局结果 | 屏幕像素 | 分层绘制,合成器把层交给 GPU 上屏 |
回流、重绘、合成的区别
# 回流(Reflow / Layout):几何信息变了——宽高、位置、增删节点、读 offsetHeight 强制同步
# 代价最高:本节点后续所有阶段重来,且可能波及兄弟/祖先节点
# 重绘(Paint):外观变了但几何没变——color、background、visibility
# 代价中等:重新生成绘制指令,不需要重新布局
# 合成(Composite):只动图层的 transform / opacity
# 代价最低:在合成器线程 + GPU 完成,不触发主线程布局与绘制
# 触发回流的高频操作:style.width = '100px'、className 整体替换、
# appendChild、window.resize、读 getBoundingClientRect(强制布局)
script 标签四种加载方式
| 方式 | 下载 | 执行时机 | 阻塞解析 |
| <script> | 并行 | 立即,阻塞后续解析 | 是 |
| async | 并行 | 下载完立即执行(顺序不定) | 是 |
| defer | 并行 | DOM 解析完后、DOMContentLoaded 前按序执行 | 否 |
| type=module | 并行 | 同 defer(默认)+ESM 语义 | 否 |
提示:CSS 在 head、JS 在 body 底部(或 defer)是经典组合:CSS 不阻塞解析但阻塞渲染(白屏),JS 阻塞解析所以放最后。
中级深入
事件循环:宏任务与微任务的执行顺序
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 输出:1 4 3 2
// 规则:同步代码 → 清空微任务队列(promise.then / MutationObserver / queueMicrotask)
# → 取一个宏任务(setTimeout / setInterval / IO / UI 事件)执行
# → 再清空微任务 →(浏览器可能渲染一帧)→ 下一个宏任务
// 面试进阶:async/await 的等待点
async function f() {
console.log('a');
await g(); // await 之后的代码等价于 .then 回调(微任务)
console.log('b'); // 微任务,先于下一个 setTimeout
}
f(); setTimeout(() => console.log('c'), 0); // a b c
浏览器缓存:强缓存与协商缓存
# 强缓存(不发请求,本地直接用):
# Cache-Control: max-age=31536000 (优先级高)
# Expires: Wed, 21 Oct 2026 07:28:00 GMT (HTTP/1.0 遗留)
# 命中时状态码 200,from disk cache / memory cache
# 协商缓存(发请求问服务器"变没变"):
# ETag / If-None-Match(内容指纹,优先) → 没变返回 304
# Last-Modified / If-Modified-Since(秒级时间戳)
# 注意:304 依然要走一次网络往返,只是没有响应体
# 实践口径:HTML 用 no-cache(必须协商),带 hash 的 js/css/img 用 max-age=31536000, immutable
# no-cache ≠ no-store:no-cache 是"可以存但用前必须验证",no-store 是"根本不许存"
CSS 优化为什么能加速渲染
# 1. 选择器代价:类选择器 O(1) 定位,后代链/通配符/:nth-child 回溯,避免长选择器
# 2. 重排成本远大于重绘:批量修改先读布局属性、改完一次性写(避免读写交错的强制同步布局)
# 3. 缓存几何值:循环里反复读 offsetWidth 会触发 N 次强制布局,提出循环外只读一次
# 4. containment:contain: layout paint 让元素自成渲染上下文,改动不外溢
# 5. will-change: transform 提前升层——但滥用会吃显存,用完记得移除
高级进阶
合成器线程与滚动/手势优化
# 主线程(渲染进程):解析、JS、样式、布局、绘制
# 合成器线程:图层管理、把绘制瓦片(tile)交给 GPU
# 为什么 transform 动画流畅:
# transform/opacity 只需合成器重新变换已有瓦片 → 不碰主线程 → 不被长 JS 任务卡住
# 为什么滚动可以"丝滑":
# 页面可滚动时滚动由合成器线程直接处理(不等主线程)
# 但 JS 里同步读 scrollLeft 会强制主线程与合成器同步 → 掉帧
# passive 事件监听器:承诺"不 preventDefault",合成器就不必等 JS
element.addEventListener('touchstart', onScroll, { passive: true });
同源策略与 CORS
# 同源 = 协议 + 域名 + 端口 全同。限制的是"读":DOM、Cookie、localStorage、Ajax 响应
# 不限制"写":表单提交、script/img 标签加载可以跨源(只是拿不到内容)
# CORS 流程(fetch/XHR):
# 简单请求(GET/HEAD/POST + 简单头)→ 直接带 Origin 请求头
# 响应需 Access-Control-Allow-Origin: https://xxx(不能同时 * 和 credentials)
# 预检请求 OPTIONS(自定义头、PUT/DELETE、content-type: application/json 等)
# 服务器回 Access-Control-Allow-Methods / Headers / Max-Age 后才发真实请求
# 面试追问 CSRF 与 XSS:
# XSS:恶意脚本注入页面执行 → 防:输出转义、CSP、HttpOnly Cookie
# CSRF:诱导已登录用户发"跨站但带 Cookie"的请求 → 防:SameSite Cookie、Token 校验、检查 Origin
性能指标与度量
| 指标 | 含义 | 参考值 |
| FCP | 首次内容绘制(白屏结束) | < 1.8s 良好 |
| LCP | 最大内容绘制(主体可见) | < 2.5s 良好 |
| CLS | 累计布局偏移(视觉稳定性) | < 0.1 良好 |
| INP | 交互到下一帧绘制(响应性,已取代 FID) | < 200ms 良好 |
// Performance API 直接取值(RUM 上报常用)
const paint = performance.getEntriesByType('paint');
const fcp = paint.find(e => e.name === 'first-contentful-paint')?.startTime;
const lcp = new PerformanceObserver(list => {
console.log(list.getEntries().at(-1).startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
实战场景
白屏排查流程
# 1. Network 面板看三个时间:HTML 是否秒回、静态资源是否排队(连接数上限)、是否大量 304/200
# 2. Performance 面板录一段:定位 FCP/LCP 对应事件,看主线程长任务(红条)是谁
# 3. 常见归因:
# - CSS/JS 过大 → 压缩、拆包、defer
# - 首屏接口串行 → 接口聚合、骨架屏先渲染
# - 字体 FOUT/FOIT → font-display: swap + 预加载关键字体
# - 第三方脚本(统计/广告)→ 异步加载、延迟到 onload 后
长任务与大列表优化
// 长任务(>50ms)会阻塞渲染与交互,切片执行
function sliceWork(total: number) {
const chunk = () => {
const deadline = (window as any).performance?.idleCallback
? undefined : undefined;
let end = performance.now() + 12; // 每片最多 12ms
while (performance.now() < end) { /* do a unit of work */ }
if (还有活) requestAnimationFrame(chunk); // 或 requestIdleCallback
};
requestAnimationFrame(chunk);
}
// 大列表:首屏虚拟列表(只渲染可视区 ± 缓冲区);图片 loading="lazy" + 宽高占位防 CLS
防抖与节流的场景选择
// 防抖 debounce:等"停手"后执行一次 —— 搜索联想、表单校验、窗口 resize 结束
// 节流 throttle:固定频率执行 —— 滚动埋点、拖拽动画、鼠标移动
const debounce = (fn, wait) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), wait); };
};
const throttle = (fn, wait) => {
let last = 0;
return (...a) => {
const now = Date.now();
if (now - last >= wait) { last = now; fn(...a); }
};
};
面试模拟
面试官:从在地址栏输入 URL 到页面显示,浏览器做了什么?
你:分网络和渲染两段。网络段:DNS 解析拿 IP → TCP 三次握手 → HTTPS 再加 TLS 握手 → 发 HTTP 请求,服务器返回 HTML。渲染段:浏览器把字节解析成 DOM;遇到 CSS 构建 CSSOM 并阻塞渲染和后续 JS;两者合成渲染树,经布局算出几何信息、绘制生成绘制指令、合成器把层交给 GPU 上屏,得到 FCP;随后 LCP 资源加载完主体可见。JS 通过事件循环穿插其中,defer 脚本在 DOM 解析完后执行。
面试官:回流和重绘有什么区别?怎么减少回流?
你:回流是元素几何信息(尺寸、位置)变化触发的重新布局,代价最高且可能波及周边节点;重绘只是外观变化重新生成像素,不涉及布局。减少回流:批量读写分离(先读后写,避免读写交错触发强制同步布局);用 transform/opacity 做动画;样式操作聚合(一次改 class 而不是循环改 style);频繁变化的元素脱离文档流(fixed/absolute);对复杂子树加 contain 约束影响范围。
面试官:说一下浏览器缓存的完整决策链。
你:先看 Cache-Control 的 max-age 是否在有效期内——在就走强缓存,直接读本地,不发请求,状态 200 from cache。过期后带 If-None-Match(或 If-Modified-Since)发协商请求,服务器对比 ETag 没变返回 304 继续用缓存,变了返回 200 + 新资源。策略上:带内容 hash 的静态资源 max-age 一年 + immutable,HTML 用 no-cache 强制每次协商,敏感数据 no-store。no-cache 是"存但用前须验证",和 no-store 不是一回事。