为什么 JS 脚本的执行会阻塞页面的渲染?
JS 脚本执行之所以会阻塞页面渲染,核心原因在于浏览器渲染进程中的GUI 渲染线程和 JS 引擎线程是互斥的(Mutually Exclusive)。
简单来说,浏览器为了保证页面内容的一致性,不允许一边画页面(渲染),一边改页面(JS 执行)。
以下是详细的深度解析:
1. 根本原因:线程互斥与“主线程”
在浏览器的每个渲染进程(Tab 页)中,有一个主线程(Main Thread)。这个主线程非常繁忙,它既要负责解析 HTML、计算样式、布局(Layout)、绘制(Paint),又要负责执行 JavaScript 代码。
- GUI 渲染线程:负责构建 DOM 树、CSSOM 树、渲染树以及绘制页面。
- JS 引擎线程:负责解析和执行 JavaScript 脚本。
这两个线程是互斥的。这意味着当 JS 引擎执行时,GUI 线程会被挂起(冻结),GUI 更新会被保存在一个队列中,等到 JS 引擎空闲时立即执行。反之亦然。
2. 为什么要设计成互斥?(为了数据一致性)
这是为了防止竞态条件(Race Condition)。
JavaScript 的主要作用之一就是操作 DOM(文档对象模型)和 CSSOM(CSS 对象模型)。
- JS 可以删除 DOM 节点。
- JS 可以添加 DOM 节点。
- JS 可以修改元素的样式(改变宽高等)。
试想一下,如果两者不互斥,并行执行会发生什么?
假设 GUI 线程正在渲染一个 <div>,准备把它画在屏幕上。与此同时,JS 线程正在执行代码,把这个 <div> 删除了,或者把它的背景色改了。
那么浏览器该听谁的?是画出旧的样式,还是画出新的?这会导致渲染结果不可预期,甚至导致渲染崩溃。
因此,浏览器采取了“同步阻塞”的策略:只要 JS 在执行,渲染就必须停下来,等待 JS 告诉它最终的 DOM 结构是什么样子的。
3. 阻塞的具体流程
当浏览器解析 HTML 文档时,流程如下:
- HTML 解析:浏览器从上到下解析 HTML,构建 DOM 树。
- 遇到
<script>:当解析器遇到<script>标签时,它不知道这段脚本会做什么(可能会调用document.write()重写整个页面,也可能修改全局样式)。 - 暂停渲染:为了保险起见,浏览器暂停 DOM 的构建和页面的渲染。
- 执行 JS:控制权移交给 JS 引擎。
- 如果是外部脚本(src),还需要先下载(下载过程通常也会阻塞 HTML 解析,虽然现代浏览器有预加载扫描器优化下载,但执行依然是阻塞的)。
- 下载完成后,立即执行 JS 代码。
- 恢复渲染:JS 执行完毕后,控制权交还给 GUI 线程,继续解析剩余的 HTML 并渲染。
4. 这种机制带来的问题
如果 JS 文件很大,或者 JS 执行时间过长(例如由于复杂的计算或死循环),就会导致:
- 白屏时间变长:用户长时间看不到页面内容。
- 页面卡顿(掉帧):如果页面已经渲染出来,但 JS 抢占了主线程,用户的滚动、点击等交互无法得到及时响应。
5. 解决方案(最佳实践)
为了避免 JS 阻塞关键渲染路径(Critical Rendering Path),开发者通常采用以下方法:
- 放在底部:将
<script>标签放在<body>标签的闭合前。这样浏览器会先解析完所有的 HTML 元素并显示出来,最后再加载 JS。 defer属性:<script defer src="...">。- 推荐使用。脚本会并行下载,但会等到 HTML 解析完成后(
DOMContentLoaded事件触发前)才执行。且保证执行顺序。
- 推荐使用。脚本会并行下载,但会等到 HTML 解析完成后(
async属性:<script async src="...">。- 脚本并行下载,下载完立即执行(此时依然会阻塞渲染)。不保证执行顺序,适用于统计代码等不依赖 DOM 的脚本。
总结
JS 阻塞渲染是因为JS 可以修改 DOM 和 CSS。为了确保渲染出的页面与 JS 修改后的状态一致,浏览器强制让 JS 执行和 UI 渲染在同一个主线程上互斥进行。