Browser
1 浏览器渲染
浏览器访问渲染页面的过程
拆解为「导航阶段」(网络请求)和「渲染阶段」(浏览器解析)两个部分,并结合性能优化的实践点来详细剖析。
一、导航阶段:从 URL 到获取 HTML
-
用户输入解析:浏览器判断你输入的是搜索关键词还是合法的 URL。
-
DNS 解析:浏览器需要知道域名对应的服务器 IP 地址。
- 优化点:使用
dns-prefetch预解析域名。
- 优化点:使用
-
建立连接(TCP/TLS):通过三次握手建立连接。如果是 HTTPS(现在基本都是),还需要进行 TLS 握手进行加密。
- 优化点:开启 HTTP/2 或 HTTP/3,利用多路复用减少连接开销。
-
发送请求与响应:浏览器发送 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;,这会提示浏览器为其创建独立的合成层,减少对主文档流的影响。
四、总结流程图
- 解析 HTML → 生成 DOM
- 解析 CSS → 生成 CSSOM
- 合并 → 生成 Render Tree
- 布局(Layout)→ 计算位置大小
- 绘制(Paint)→ 生成像素数据
- 合成(Composite)→ GPU 显示到屏幕
既然你正在学习 React 和 Vue3,你可能会发现这些框架通过 Virtual DOM 来减少真实的 DOM 操作,本质上也是为了优化上述流程中的「重排」和「重绘」阶段。
2 Script 标签
浏览器解析 HTML 遇到 script 标签时的行为
浏览器在解析 HTML 的时候,遇到 script 脚本标签会怎么样?会阻塞整个的渲染过程吗?
一、为什么脚本会「阻塞」?
当浏览器解析 HTML 遇到一个普通的 <script> 标签时,它会暂停 DOM 的构建,立刻去做两件事:
- 下载 JS 文件。
- 执行 JS 代码。
为什么要暂停? 因为 JS 可能会通过 document.write() 修改 HTML 结构,或者通过 DOM API 改变还没生成的节点。为了保证一致性,浏览器必须等 JS 运行完再继续解析。
二、JS 资源的下载与执行:三种模式
为了优化性能,HTML5 引入了 async 和 defer 两个属性。它们改变了浏览器「下载」和「执行」脚本的时机。
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(过时):设置一个绝对过期时间。
- Cache-Control(主流):比如
2. 协商缓存(Negotiated Cache)
- 特点:强缓存失效后,浏览器发请求询问服务器:「我的资源过时了吗?」
- 304 Not Modified:如果没变,服务器返回 304,不返回资源内容,浏览器继续用本地缓存。
- 200 OK:如果变了,服务器返回最新资源。
ETag 的实现原理
你特别问到了 ETag。它像文件的「指纹」。
-
实现:服务器根据文件内容计算一个哈希值(Hash)。文件内容变了,ETag 必变。
-
过程:
- 服务器返回资源时带上
ETag: "abc12345"。 - 浏览器下次请求带上
If-None-Match: "abc12345"。 - 服务器对比:一致则返回 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-Method和Access-Control-Request-Headers。
-
第二步:服务器响应
- 如果允许,服务器返回 200,并带上
Access-Control-Allow-Origin等头信息。
- 如果允许,服务器返回 200,并带上
-
第三步:正式 POST 请求
- 只有预检通过,浏览器才会发送真正的 POST 数据。
三、跨域问题的解决思路
在工作中,我们通常有以下几种成熟方案:
1. CORS(后端配置 - 最通用)
后端在响应头中加入相应的字段。这是最标准、最推荐的方案。
Access-Control-Allow-Origin: https://your-site.comAccess-Control-Allow-Methods: GET, POST, PUTAccess-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 出现之前,我们用 setTimeout 或 setInterval 做动画。
- 痛点:定时器的回调执行时机是不确定的(受任务队列阻塞影响)。如果定时器在浏览器两帧渲染中间触发,会导致丢帧或卡顿。
- 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. 被遗忘的定时器或回调
setInterval或setTimeout未被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 开发中,有几个场景比前端更隐蔽:
- 缓存膨胀:为了提高性能,在内存里用 Object 做简单缓存,却没有设置过期时间或最大容量(如 LRU 策略),导致缓存无限增大。
- 监控/日志堆积:如果日志是先缓存再批量写入,由于写入速度跟不上产生速度,导致内存暴涨。
- 队列积压:高并发下,异步请求队列处理过慢,大量等待状态的 Promise 占用内存。
四、如何解决与避免内存泄漏?
1. 编码规范(预防胜于治疗)
- 手动清除定时器:在 Vue 的
beforeUnmount或 React 的useEffect返回函数中,务必清除 timer 和addEventListener。 - 使用 WeakMap/WeakSet:它们对对象的引用是「弱引用」,不计入垃圾回收。如果对象在别处被销毁,WeakMap 里的对应条目会自动消失。
- 及时释放引用:对于巨大的对象或数组,不再使用时手动赋值为
null。
2. 实战解决方案
- 针对 DOM 泄漏:在删除元素前,确保解除相关的 JS 引用。
- 针对闭包:注意变量的作用域,尽量避免在长生命周期的函数(如 window 上的事件监听)中持有大对象。
- 针对 Node.js 缓存:使用 Redis 替代进程内内存缓存,或使用 lru-cache 库限制最大条目数。
五、总结
