4523 字
23 分钟
Browser
2026-02-19
2026-02-19
统计加载中...

Browser#

1 浏览器渲染#

浏览器访问渲染页面的过程#

拆解为「导航阶段」(网络请求)和「渲染阶段」(浏览器解析)两个部分,并结合性能优化的实践点来详细剖析。


一、导航阶段:从 URL 到获取 HTML#

  1. 用户输入解析:浏览器判断你输入的是搜索关键词还是合法的 URL。

  2. DNS 解析:浏览器需要知道域名对应的服务器 IP 地址。

    • 优化点:使用 dns-prefetch 预解析域名。
  3. 建立连接(TCP/TLS):通过三次握手建立连接。如果是 HTTPS(现在基本都是),还需要进行 TLS 握手进行加密。

    • 优化点:开启 HTTP/2 或 HTTP/3,利用多路复用减少连接开销。
  4. 发送请求与响应:浏览器发送 HTTP 请求,服务器处理后返回 HTML 文档。

    • 优化点:利用 CDN 加速、开启 Gzip/Brotli 压缩、合理的 Cache-Control 强缓存策略。

二、渲染阶段:从 HTML 到像素点#

拿到 HTML 后,浏览器的渲染引擎(如 Chrome 的 Blink)开始工作。这可以拆分为以下核心步骤:

1. 构建对象模型(DOM & CSSOM)#

浏览器不能直接理解文本,需要将其转化为树状结构。

  • DOM Tree:解析 HTML 标签,生成 DOM 树。
  • CSSOM Tree:解析 CSS,生成 CSS 规则树。
  • 注意:DOM 构建是增量的,但 CSSOM 构建会阻塞渲染(必须等 CSS 加载完才能确定样式)。

2. 生成渲染树(Render Tree)#

将 DOM 和 CSSOM 合并。渲染引擎会遍历 DOM 树,去掉那些不可见的节点(如 <script><meta> 以及 display: none 的元素),并应用对应的 CSS 样式。

3. 布局(Layout/重流)#

计算每个节点在屏幕上的几何尺寸和位置。

4. 分层与绘制(Layering & Painting)#

浏览器会将复杂的页面分成多个层(Layers,类似 Photoshop 的图层)。随后,渲染引擎会为每个层生成「绘制指令」。

5. 合成(Composition)#

这是最关键的一步。主线程将绘制指令交给合成线程。合成线程将图层划分为图块(Tiles),利用 GPU 快速将其渲染到屏幕上。


三、结合性能优化的深度思考#

作为一名开发者,了解过程是为了更好地控制它。以下是几个关键的优化策略:

1. 减少「关键渲染路径」的阻塞#

  • CSS 放头部,JS 放底部:让 CSS 尽早加载,避免 DOM 解析完了还在等样式;JS 放在底部或使用 defer/async,防止脚本阻塞 HTML 解析。
  • 内联关键 CSS:针对首屏(Above the Fold)的样式,可以直接写在 <style> 标签里,省去一次网络请求。

2. 避免重排(Reflow)与重绘(Repaint)#

  • 重排:改变了元素的几何属性(宽、高、位置)。这会导致浏览器重新计算布局,性能开销最大。

  • 重绘:只改变外观(颜色、背景色)。

  • 优化

    • 使用 transform 代替 top/left 做动画(transform 会跳过布局和绘制,直接在合成阶段由 GPU 处理)。
    • 使用 flexbox 布局通常比传统的浮动布局性能更好。
    • 批量修改 DOM,或者使用 requestAnimationFrame

3. 利用合成加速(GPU Acceleration)#

  • 给复杂的动画元素添加 will-change: transform;,这会提示浏览器为其创建独立的合成层,减少对主文档流的影响。

四、总结流程图#

  1. 解析 HTML → 生成 DOM
  2. 解析 CSS → 生成 CSSOM
  3. 合并 → 生成 Render Tree
  4. 布局(Layout)→ 计算位置大小
  5. 绘制(Paint)→ 生成像素数据
  6. 合成(Composite)→ GPU 显示到屏幕

既然你正在学习 React 和 Vue3,你可能会发现这些框架通过 Virtual DOM 来减少真实的 DOM 操作,本质上也是为了优化上述流程中的「重排」和「重绘」阶段。


2 Script 标签#

浏览器解析 HTML 遇到 script 标签时的行为#

浏览器在解析 HTML 的时候,遇到 script 脚本标签会怎么样?会阻塞整个的渲染过程吗?

一、为什么脚本会「阻塞」?#

当浏览器解析 HTML 遇到一个普通的 <script> 标签时,它会暂停 DOM 的构建,立刻去做两件事:

  1. 下载 JS 文件。
  2. 执行 JS 代码。

为什么要暂停? 因为 JS 可能会通过 document.write() 修改 HTML 结构,或者通过 DOM API 改变还没生成的节点。为了保证一致性,浏览器必须等 JS 运行完再继续解析。


二、JS 资源的下载与执行:三种模式#

为了优化性能,HTML5 引入了 asyncdefer 两个属性。它们改变了浏览器「下载」和「执行」脚本的时机。

1. 默认模式(Normal Script)#

  • 行为:停止解析 HTML → 联网下载 → 执行 JS → 继续解析 HTML。
  • 后果:如果脚本很大或者网络慢,用户会看到一个半截的白屏页面。

2. defer(延迟执行)—— 推荐用于 React/Vue 项目#

  • 行为:浏览器发现 defer 脚本后,会异步下载它,同时继续解析 HTML。
  • 执行时机:等到 HTML 完全解析完毕(DOM 构建完成),在 DOMContentLoaded 事件触发之前,按照脚本在页面出现的顺序依次执行。
  • 优点:不会阻塞 DOM 构建,且保证了脚本之间的执行顺序。

3. async(异步执行)—— 常用作第三方统计/广告#

  • 行为:异步下载脚本,下载过程中不阻塞 HTML 解析。
  • 执行时机:一旦下载完成,立刻暂停 HTML 解析并执行脚本。
  • 后果:执行时机不可控。如果脚本下载很快,它依然会阻塞 HTML 解析;且多个 async 脚本谁先下载完谁就先执行,不保证顺序。


3 浏览器缓存#

一、浏览器缓存的分类与区别#

浏览器缓存主要分为两大类:HTTP 缓存(由服务器控制)和 浏览器本地存储(由前端代码控制)。


二、HTTP 缓存:强缓存 vs 协商缓存#

互补关系:浏览器先看强缓存,失效后再去询问服务器(协商缓存)。

1. 强缓存(Strong Cache)#

  • 特点:直接从本地获取,不发请求到服务器,速度最快。状态码显示为 200 OK (from disk cache)

  • 如何设置:由后端在 HTTP 响应头中设置。

    • Cache-Control(主流):比如 max-age=31536000(单位秒),代表一年内不再请求服务器。
    • Expires(过时):设置一个绝对过期时间。

2. 协商缓存(Negotiated Cache)#

  • 特点:强缓存失效后,浏览器发请求询问服务器:「我的资源过时了吗?」
  • 304 Not Modified:如果没变,服务器返回 304,不返回资源内容,浏览器继续用本地缓存。
  • 200 OK:如果变了,服务器返回最新资源。

ETag 的实现原理#

你特别问到了 ETag。它像文件的「指纹」。

  • 实现:服务器根据文件内容计算一个哈希值(Hash)。文件内容变了,ETag 必变。

  • 过程

    1. 服务器返回资源时带上 ETag: "abc12345"
    2. 浏览器下次请求带上 If-None-Match: "abc12345"
    3. 服务器对比:一致则返回 304,不一致则返回 200。

三、HTTP 状态码:301、302 与 304#

  • 301(Permanent Redirect):永久重定向。比如旧域名弃用,永久跳到新域名。搜索引擎会更新索引。
  • 302(Found/Temporary Redirect):临时重定向。比如未登录用户临时跳到登录页。
  • 304(Not Modified):资源未修改。这是协商缓存命中的标志,意味着「你可以省点流量,直接用旧的」。

四、实际工作中的应用场景#

1. 强缓存:给 JS/CSS/图片加 Hash#

在 Webpack 或 Vite 打包时,文件名会带上 Hash(如 main.a7b2c9.js)。

  • 策略:设置 Cache-Control: max-age=31536000, immutable
  • 逻辑:因为内容变了文件名就会变,旧文件永远不需要更新,直接强缓存一年。

2. 协商缓存:给 HTML 设置#

HTML 是应用的入口,不能轻易强缓存,否则你发了新版,用户还在看旧页面。

  • 策略:设置 Cache-Control: no-cache
  • 逻辑:这代表「每次都必须去服务器问一下(协商)」,如果后端发现 HTML 没变,返回 304,速度依然很快。

3. 如何配置 HTML「不缓存」?#

如果你想彻底让 HTML 每次都拿最新的,不走任何缓存:

Nginx 配置:

location / {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
  • no-store 是最严格的,禁止任何形式的缓存。

五、常见误区解答#

Q:强缓存和协商缓存是我去设置的吗?

A:作为一个前端,通常是通过配置打包工具(如文件名加 Hash)和配合后端/运维人员(配置 Nginx 或 CDN 响应头)来完成的。你需要告诉运维人员:「这一类静态资源请开启强缓存,HTML 请走协商缓存」。

Q:LocalStorage 会影响页面渲染吗?

A:LocalStorage 的读取是同步的。如果在首屏渲染时读取超大数据量,可能会造成轻微阻塞,所以通常只存简单的 token 或配置。


4 跨域#

跨域(CORS)是前端开发绕不开的「安全围栏」。它本质上是浏览器的 同源策略(Same-Origin Policy) 在起作用,目的是防止恶意网站通过脚本读取另一个网站的敏感数据。


一、跨域的本质:同源策略#

同源要求三个要素完全一致:协议(http/https)、域名(domain)和 端口(port)。只要有一个不同,就是跨域。

误区纠正:跨域时,请求其实已经发出去了,服务器也可能正常响应了,但浏览器在接收到响应后,发现没有正确的权限标识,于是拦截了结果并报错。


二、POST 请求的跨域行为:预检请求(Preflight)#

当你发出一个跨域的 POST 请求时,浏览器的行为取决于该请求是否为「简单请求」。

1. 简单请求 vs 非简单请求#

大部分现代前端项目(使用 application/json 或自定义 Header)都属于非简单请求。

2. 预检过程:先探测,后执行#

如果是非简单请求,浏览器会先自动发送一个 OPTIONS 请求,这就是「预检请求」。

  • 第一步:OPTIONS 请求

    • 浏览器询问服务器:「我准备用 POST 方式,带上 Content-Type: application/json 发数据,你允许吗?」
    • 关键 Header:Access-Control-Request-MethodAccess-Control-Request-Headers
  • 第二步:服务器响应

    • 如果允许,服务器返回 200,并带上 Access-Control-Allow-Origin 等头信息。
  • 第三步:正式 POST 请求

    • 只有预检通过,浏览器才会发送真正的 POST 数据。

三、跨域问题的解决思路#

在工作中,我们通常有以下几种成熟方案:

1. CORS(后端配置 - 最通用)#

后端在响应头中加入相应的字段。这是最标准、最推荐的方案。

  • Access-Control-Allow-Origin: https://your-site.com
  • Access-Control-Allow-Methods: GET, POST, PUT
  • Access-Control-Allow-Credentials: true(如果需要带 Cookie)

2. Nginx 反向代理(运维/前端配置 - 最常用)#

既然浏览器限制跨域,那我们找个「传声筒」。

  • 前端请求 /api/user(同源)。
  • Nginx 接收后,在后台转发给 https://server.com/user(服务器间通信不触发跨域)。
  • 场景:生产环境首选。

3. Webpack / Vite Proxy(开发环境)#

在开发 React 或 Vue 项目时,利用开发服务器的代理功能。

  • 配置 vite.config.ts 中的 server.proxy
  • 原理:和 Nginx 类似,由本地开发服务器代理请求。

四、两个跨域 iframe 之间的通信#

如果两个页面属于不同域名,直接通过 window.parent 操作 DOM 是会被浏览器阻止的。

核心方案:postMessage#

HTML5 提供的 window.postMessage 是跨文档通信的官方唯一指定工具。

发送方:

const iframe = document.getElementById('my-iframe');
iframe.contentWindow.postMessage('Hello from parent', 'https://receiver-domain.com');

接收方:

window.addEventListener('message', event => {
if (event.origin !== 'https://sender-domain.com') return; // 安全校验
console.log('Received data:', event.data);
});
  • 优点:安全、异步,且支持跨域。

5 浏览器 API#

requestAnimationFrame、requestIdleCallback、实现一个准确的倒计时#

一、requestAnimationFrame(rAF):丝滑动画的守护者#

1. 它解决了什么问题?#

在 rAF 出现之前,我们用 setTimeoutsetInterval 做动画。

  • 痛点:定时器的回调执行时机是不确定的(受任务队列阻塞影响)。如果定时器在浏览器两帧渲染中间触发,会导致丢帧或卡顿。
  • rAF 的优势:它能保证回调函数在浏览器下一次重绘之前执行。它跟随浏览器的刷新率(通常是 60Hz,即每 16.7ms 触发一次),能够自动匹配显示器的频率,让动画极其丝滑。

2. 核心特性#

  • 节省 CPU:如果页面切换到后台标签页,rAF 会暂停,而定时器会继续跑,浪费资源。
  • 合并渲染:浏览器会将同一帧内的多个 DOM 操作合并,一次性渲染。

二、requestIdleCallback(rIcb):利用浏览器的「午休时间」#

1. 这是什么?#

它允许你在浏览器的空闲时段执行低优先级的后台任务,而不影响关键事件(如动画和输入响应)。

2. 浏览器怎么认为自己「空闲」?#

浏览器的每一帧(假设 16.7ms)需要处理:输入事件 → JS 执行 → 帧开始(rAF)→ 布局 → 绘制。

  • 如果上述操作只用了 10ms,剩下的 6.7ms 就是空闲时间,这时 requestIdleCallback 就会触发。
  • 如果浏览器一直很忙,它提供了一个 timeout 参数,强制在某个时间后必须执行。

3. 应用场景#

  • React 的 Fiber 架构 灵感就来源于此(虽然 React 后来自己实现了一套更复杂的调度逻辑 Scheduler)。
  • 发送非紧急的埋点统计数据。
  • 预加载某些组件。

三、rAF 与 rIcb 的区别总结#


四、如何实现一个「准确」的倒计时?#

这是面试中常见的实战题。普通的 setInterval 是不准的,因为它只负责把任务塞进队列,并不保证立即执行。

1. 错误的方案:setInterval(fn, 1000)#

由于 JS 单线程阻塞,1000ms 的间隔可能会累积误差,跑着跑着就慢了几秒。

2. 准确的方案:基于系统时间补偿(rAF 结合 Date)#

核心思想是:不再依赖固定间隔,而是每一帧都去对比当前的系统时间与结束时间。

function countdown(duration) {
const startTime = Date.now();
const endTime = startTime + duration;
function update() {
const now = Date.now();
const remaining = endTime - now;
if (remaining <= 0) {
console.log('倒计时结束');
return;
}
// 格式化剩余时间
const seconds = Math.floor(remaining / 1000);
console.log(`剩余时间: ${seconds}s`);
// 每一帧都请求下一次更新,确保在渲染循环中
requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
// 开启一个 10 秒倒计时
countdown(10 * 1000);

3. 为什么这样更准?#

  • 时间源准确:使用 Date.now() 获取系统时间,消除累积误差。
  • 更新及时requestAnimationFrame 保证了 UI 更新的频率最高且最省性能。即使 JS 主线程短时间卡顿,下一帧渲染时也会根据最新的 Date.now() 瞬间修正到正确的时间点。

经验总结#

在工作中:

  • 做动画/交互:闭眼选 requestAnimationFrame
  • 大量数据处理/埋点:考虑 requestIdleCallback 防止页面掉帧。
  • 倒计时/校准:永远以系统时间戳为准,不要相信定时器的步长。

6 内存泄漏#

一、内存泄漏发生的四大核心原因#

在 JavaScript 中,垃圾回收(GC)主要依靠「标记-清除」算法,但以下场景会让 GC 产生误判,认为某些对象「还有用」:

1. 意外的全局变量#

  • 在函数中漏写 let/const,变量挂载到 window 上,除非关闭浏览器,否则永不释放。

2. 被遗忘的定时器或回调#

  • setIntervalsetTimeout 未被 clearInterval/clearTimeout。如果定时器的回调函数闭包引用了外部的大对象,该对象将一直留在内存中。

3. 闭包的过度使用#

  • 闭包可以让函数访问外部变量,但如果闭包生命周期过长,且引用了巨大的变量,会导致该变量无法被回收。

4. 脱离 DOM 的引用#

  • 在 JS 里保存了一个 DOM 节点的引用(例如 let btn = document.getElementById('btn')),随后在页面中删除了该节点。由于 btn 变量依然指向它,这个 DOM 节点就成了「游离状态」,无法回收。

二、如何发现内存泄漏?(排查手段)#

1. 浏览器端(Chrome DevTools)#

  • Performance 面板:勾选 Memory 选项进行录制。如果看到内存占用曲线(JS Heap)阶梯式上升,且在 GC 后没有回到基准线,说明有泄漏。

  • Memory 面板(Heap Snapshot)

    • 堆快照对比:录制快照 A → 执行某些操作 → 录制快照 B。通过 Comparison 视图查看哪些对象增多了,重点关注 Detached HTMLDivElement(脱离文档流的 DOM)。

2. Node.js 端#

Node.js 端内存泄漏更致命,因为它作为服务端长期运行。

  • process.memoryUsage():实时监控 heapUsed。
  • node-inspect + Chrome DevTools:使用 --inspect 启动,连接 Chrome 远程调试。
  • heapdump:手动导出堆快照文件进行分析。

三、Node.js 端的特殊内存泄漏#

Node.js 开发中,有几个场景比前端更隐蔽:

  1. 缓存膨胀:为了提高性能,在内存里用 Object 做简单缓存,却没有设置过期时间或最大容量(如 LRU 策略),导致缓存无限增大。
  2. 监控/日志堆积:如果日志是先缓存再批量写入,由于写入速度跟不上产生速度,导致内存暴涨。
  3. 队列积压:高并发下,异步请求队列处理过慢,大量等待状态的 Promise 占用内存。

四、如何解决与避免内存泄漏?#

1. 编码规范(预防胜于治疗)#

  • 手动清除定时器:在 Vue 的 beforeUnmount 或 React 的 useEffect 返回函数中,务必清除 timer 和 addEventListener
  • 使用 WeakMap/WeakSet:它们对对象的引用是「弱引用」,不计入垃圾回收。如果对象在别处被销毁,WeakMap 里的对应条目会自动消失。
  • 及时释放引用:对于巨大的对象或数组,不再使用时手动赋值为 null

2. 实战解决方案#

  • 针对 DOM 泄漏:在删除元素前,确保解除相关的 JS 引用。
  • 针对闭包:注意变量的作用域,尽量避免在长生命周期的函数(如 window 上的事件监听)中持有大对象。
  • 针对 Node.js 缓存:使用 Redis 替代进程内内存缓存,或使用 lru-cache 库限制最大条目数。

五、总结#