CSS与JS阻塞机制详解:从渲染路径到首屏优化实战
2026/9/11 1:31:29 网站建设 项目流程

这个问题我在面试里被问过,也在线上事故里被它坑过。很多人在背“CSS会阻塞渲染、JS会阻塞解析”这个结论,但真到了排查白屏、优化首屏性能的时候,往往说不清楚到底谁先卡住、谁卡的是哪一段、为什么把脚本挪到 body 底部有时候也没用。

这篇文章不打算抄文档,我直接把渲染路径拆开讲,配合我本地搭的测试页面、DevTools 的实测数据,以及这几年在真实项目里踩过的坑,把这个“阻塞”这件事讲透。看完你不仅能答上这道题,还能顺手解决大部分首屏加载慢的问题。

1. 先搞清楚浏览器渲染流程,才知道谁在阻塞什么

1.1 渲染五步,阻塞发生在第几步?

平时说“页面渲染”,其实是一整条流水线。浏览器拿到 HTML 之后,大致要经过这么几步:先解析 HTML 生成 DOM 树,同时解析 CSS 生成 CSSOM 树,两棵树合起来生成渲染树,然后经过布局(Layout)计算每个节点的几何位置,最后绘制(Paint)到屏幕上。

这里有一个必须刻在脑子里的时间点:如果 DOM 树和 CSSOM 树还没有都准备好,浏览器是不会进入渲染树构建的。就好比你做菜,食材(DOM)已经备齐了,但调料配方(CSSOM)还没到,你就不可能开火下锅。所以渲染被人为卡住,往往就是卡在这一步——有一棵“树”迟迟没长全。

而 JavaScript 的角色更特殊,它在解析 HTML 的过程中一旦被遇到,就会直接拦下 HTML 解析器。因为 JS 不仅能读取 DOM,还能修改 DOM,如果浏览器一边解析一边执行 JS,很容易出现“JS 改了一个还没解析出来的节点”这种情况。所以浏览器的策略就很粗暴:遇到普通 script 标签,先停下 HTML 解析,下载并执行完 JS,再继续往下解析。

这就是阻塞的本质区别:CSS 阻塞的是“渲染”,也就是页面能不能画出来;JS 阻塞的是“解析”,也就是 HTML 还能不能往下读。但要注意,这两者不是独立的,JS 的解析阻塞常常会连带把渲染也拖住,后面我会具体说这个联动效应。

1.2 为什么大家总是把 CSS 和 JS 放在一起讨论

只看上面的结论,你可能会觉得 CSS 和 JS 是两条平行线,各阻塞各的。但在真实页面里,它们几乎总是绑在一起的。

一个很常见的场景:你把一个同步的 script 标签放在了 head 里,这个脚本刚好又要读取某个元素的样式,或者要修改某些节点的 class。那浏览器会怎么做?它必须先暂停 HTML 解析,去执行这段 JS。而执行 JS 的时候,如果它想获取样式,就需要拿到当前的 CSSOM。这时候浏览器发现 CSS 还没下载解析完——怎么办?只能等着。CSSOM 不完备,JS 就没法可靠地执行,因为脚本可能读到半成品样式。

所以结论是:位于 CSS 后面的同步 JS,会等待 CSS 加载并解析完成后才会执行。反过来,CSS 的下载是并行的,但它一旦遇到需要执行 JS,就会被 JS 的执行时间额外延长。实际结果就是,一个页面里如果 CSS 和同步 JS 都堆在 head 里,这两样东西互相等,整个首屏时间会被明显拉长。这也是为什么优化时经常把 JS 往下挪、加 defer,或者把关键 CSS 内联,本质上都是为了打破“CSS 等 JS、JS 等 CSS”这个死锁。

2. CSS 阻塞在哪里,为什么说它卡住的是“画”

2.1 link 样式表与渲染树构建的关系

先看一个最普通的情况:页面 head 里有一个<link rel="stylesheet" href="style.css">。在 HTML 解析到这个标签时,浏览器会发起 CSS 下载,但请注意,HTML 解析并不会停下来等 CSS。也就是说,DOM 树的构建还在继续,CSS 的下载是并行进行的。真正被卡住的位置在后面——当 DOM 树构建完成,准备生成渲染树的那一刻,如果 CSSOM 还没构建完,渲染就永远等在那里。

这个体验在前端叫做“白屏时间变长”。用户看到的现象是页面一片空白,地址栏转圈。因为 CSSOM 没就绪,浏览器连第一个像素都不会画。所以我在实际项目里,只要看到首屏白屏时间长,第一反应就是去看页面的 CSS 体积和样式表数量,或者是不是有样式文件被媒体查询误加载了根本不需要的 CSS。

这里有个很反直觉的点:很多人以为 CSS 反正放哪都是阻塞,放 head 和放 body 底部没区别。区别非常大。如果样式表出现在 body 底部,浏览器会先把前面解析出来的 HTML 画一遍——这就是没有样式的内容先行展示,专业点叫 FOUC(无样式内容闪烁)。用户会看到页面先裸奔、突然又套上衣服。所以 CSS 的标准放置位置就是 head,不是因为它在那里不阻塞,而是为了控制白屏和 FOUC 的体验。

2.2 DOMContentLoaded 为什么也会被 CSS 拖住

这里有个绝大多数人不知道的细节:CSS 会推迟 DOMContentLoaded 事件。你可能会奇怪,DOMContentLoaded 不是 DOM 解析完成就触发吗?跟 CSS 有什么关系?

因为在规范的实现里,如果 HTML 解析器在等待一个脚本(比如这个脚本在 CSS 后面),那 DOMContentLoaded 就必须等脚本执行完才能触发。而脚本执行又要等 CSSOM 构建完成,这么一绕,CSS 就间接影响了 DOMContentLoaded 的实际触发时间。

我做过一个实验,页面 head 里放一个需要 3 秒加载的 CSS,body 里没有任何脚本,DOMContentLoaded 其实还好,不受影响。但只要在 CSS 后面或 body 里放一个同步脚本,再去看时间线,DOMContentLoaded 基本会被拖到 CSS 加载完成之后。结论就是:CSS 本身不直接推迟 DOMContentLoaded,但它通过阻塞脚本,把 DOMContentLoaded 间接拖住了。这也是很多性能分析工具里显示“CSS 导致了 DOMContentLoaded 延迟”的原因。

2.3 容易被忽略的 @import 和 media 属性

link 标签的标准阻塞大家都懂,真正坑人的是 @import。我之前接手过一个老项目,样式文件里第一行写了@import url("base.css")。这个写法的致命问题是:@import 是串行下载的,浏览器必须等当前样式表下载并解析后,才能发现里面还有一个 @import 需要继续下载。也就是说,两个 CSS 文件不能并行加载,而是排队。这个时间损耗在弱网环境下特别明显。

还有一个容易踩的坑是 media 属性。<link rel="stylesheet" href="print.css" media="print">这个打印样式不会阻塞渲染,因为媒体条件不匹配的时候,浏览器知道这个样式当前用不上,就会以低优先级异步加载。但如果你在代码里写了media="all"或者干脆不写,那它就是标准的渲染阻塞资源。我见过不少项目里,移动端页面默认加载了一套桌面端样式,只是用 media 做响应式适配,但 media 条件写得不合适,导致桌面和移动的样式都被当成阻塞资源一起加载了。

所以排查 CSS 阻塞问题时,我会按这个顺序看一遍:head 里 link 的数量、每个 link 有没有写必要的 media 属性、样式表内部有没有 @import、以及有没有在 HTML 里内联一大坨实际上用不上的基础样式。

3. JS 阻塞的是“读”和“写”,而且更伤主线程

3.1 经典脚本的执行时机:拿到就执行

不带任何属性、也即“经典脚本”的<script src="app.js">,它的行为是最简单粗暴的:HTML 解析器一碰到它,就立刻停止解析,先去下载这个脚本(如果还没下载过),下载完成后立刻执行。执行结束,才继续解析剩余的 HTML。

这个过程里,用户看到的就是白屏中卡顿。因为 HTML 解析停住了,后面的 DOM 还没创建,自然什么都没法画。而且下载和执行都发生在主线程上,尤其是执行阶段,脚本里哪怕只是做一个很大的同步计算,都会把页面冻住。反应到性能面板里,就是一段长长的黄色任务(Task)。

我实际做项目时的判断标准很简单:如果一个脚本不是在当前 HTML 解析阶段就必须马上执行的,就不要用这种裸 script 标签放在 head 里。所谓的“必须马上执行”,指的是它要给页面写一些初始化的内联标记、做首屏必需的埋点、或者初始化一个前置的工具函数。其他的业务逻辑,全部都应该延迟。

3.2 defer 和 async:到底差在哪儿

这两个属性是面试高频题,也是优化 JS 阻塞最核心的手段,但很多人理解得模模糊糊。

defer 和 async 的共同点是都不阻塞 HTML 解析,它们都是异步加载脚本。区别在于执行时机。async 脚本下载完成后,浏览器会立即暂停 HTML 解析,先把这个脚本执行完,再继续解析。也就是说,async 虽然不阻塞下载,但阻塞执行那一下。而 defer 脚本会等到整个 HTML 解析全部完成后,在 DOMContentLoaded 触发之前,按照文档顺序依次执行。

这就带来两个关键差异。第一,多个 async 脚本之间的执行顺序是不确定的,谁先下载完谁先执行,所以有依赖关系的脚本千万不要用 async。第二,defer 的执行时机固定,它更像是“解析完了再跑一次”,非常适合那些需要完整 DOM 才能操作的业务代码。

我一般在 Vue、React 这类 SPA 项目里,把入口脚本设成 defer,把埋点 SDK、第三方统计这类互相独立、又不依赖 DOM 的脚本设成 async。这样能最大化减少对首屏渲染的干扰。

3.3 模块脚本、动态注入和 fetchpriority

ES Module 模块脚本(<script type="module">)天然具备 defer 行为:默认异步加载,不会阻塞解析,执行时机在文档解析完成后。但它有一个额外开销——模块依赖需要逐个解析并请求,如果模块图特别深,实际完成时间可能比传统 defer 脚本更晚。

动态创建的脚本也有坑。有些人喜欢在 JS 里动态创建 script 标签往 DOM 里插,觉得这样就不会阻塞了。但动态脚本分两种情况:如果你设置了script.async = true,行为接近 async;如果不设置,它其实还是异步下载、立刻执行的。默认情况下的动态脚本会被浏览器标记为 async 加载,所以也别指望它能守规矩地按顺序执行。

另外现在可以给 script 标签加fetchpriority="high"来提示浏览器提高下载优先级。我在需要加载首屏关键脚本时会用这个属性,但在给第三方脚本设置优先级时非常小心。因为任何脚本一旦提高优先级,都可能抢占主带宽,反而延迟 CSS 和图片的加载。这也是优化里的一个权衡点:不是所有资源都“抢”就快。

4. 亲手验证一次:本地实验记录与数据对比

4.1 实验设计:怎么搭一个能对比的测试页

光靠理论总觉得心里没底。我建议你自己也动手搭一个测试页面,用 Performance API 记录关键时间点,这样才能直观看到“谁阻塞了谁”。

我这里给你一个可以照抄的最小实验方案。准备三个文件:一个 HTML、一个 CSS、一个 JS。CSS 里我用 setTimeout 模拟不了真实的加载延迟,但可以用一个简单的方法——在 Node 环境里给静态资源统一加 3 秒延迟,或者直接在 DevTools 的 Network 面板里把 CSS 和 JS 的延迟调成毫秒级模拟。

为了方便你在本地直接跑,我写一个快速加延迟的 Node 静态服务器脚本:

const http = require('http'); const fs = require('fs'); const path = require('path'); const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); http.createServer(async (req, res) => { const filePath = path.join(__dirname, req.url === '/' ? 'index.html' : req.url); const ext = path.extname(filePath); const map = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' }; // 模拟网络延迟,CSS 延迟 3 秒,JS 延迟 2 秒 if (ext === '.css') { await delay(3000); } else if (ext === '.js') { await delay(2000); } const data = fs.readFileSync(filePath); res.writeHead(200, {'Content-Type': map[ext] || 'text/plain'}); res.end(data); }).listen(8080, () => console.log('Server running at http://localhost:8080'));

测试页面结构这样写,第一部分是常规场景:

<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Blocking Test</title> <link rel="stylesheet" href="style.css"> <script> window.performance.mark('before-js'); var start = performance.now(); window.addEventListener('DOMContentLoaded', function() { console.log('DOMContentLoaded after', performance.now() - start, 'ms'); }); </script> </head> <body> <h1>CSS &amp; JS Blocking Test</h1> <script src="app.js"></script> </body> </html>

我在 head 里放了一个同步内联脚本做起点标记,body 里放一个同步外部脚本。这样能看到 DOMContentLoaded 到底等了多久。

4.2 实测结果:不同的加载组合,时间差多少

本地跑出来的数据非常有说服力。可以一次性做三个对比组。

第一组:head 里放 CSS(3秒延迟),body 里放同步 JS(2秒延迟)。实测 DOMContentLoaded 大约在 5 秒左右触发。这个时间正好是“CSS 下载完 + JS 下载并执行完”的串联时间。中间的等待过程在 Performance 面板里看得很清楚:HTML 解析在遇到 body 的 script 时暂停,一直等到 CSS 完成,才继续。

第二组:head 里放 CSS(3秒延迟),body 里的 JS 加 defer。实测 DOMContentLoaded 大约 3 秒出头触发。因为同步脚本被改成了 defer,HTML 解析完全不再等待 JS,CSS 一旦完成,解析继续走完,DOMContentLoaded 立刻就能触发。

第三组:把 CSS 内联到 HTML 里,body 的 JS 还是同步脚本(2秒延迟)。实测 DOMContentLoaded 大约 2 秒左右触发。因为 CSS 不再有下载延迟,HTML 解析虽然在 script 那里暂停了,但只等 JS 下载执行完,这个阻塞范围就小了很多。

从这三组数据能看出一个规律:真正决定 DOMContentLoaded 的,是“HTML 解析中遇到的同步脚本”以及“这个脚本所依赖的 CSSOM 是否完备”。优化首屏时间,核心就是打掉这两者之间的串联等待。

4.3 Performance 面板怎么读:从哪里看到阻塞证据

在 DevTools 的 Performance 面板录制一次加载过程,你会看到颜色不同的色块。HTML 解析是灰色的 Parse HTML 任务,样式计算是紫色的 Recalculate Style,脚本执行是黄色的 Evaluate Script。

如果某个区域出现很长的空闲空隙,紧接着是黄色脚本块,那你就能精准定位:浏览器那时正在等资源下载。再结合 Network 面板里的 Waterfall(资源加载瀑布图),能看得更清楚。阻塞的典型画面是:HTML 请求已经返回了,但脚本的下载一直等在那里,直到 CSS 下载完成,脚本才开始下载。这就是“CSS 阻塞了 JS 下载”的直观证据。

我还习惯用 Lighthouse 跑一次性能分析,它会直接告诉你 render-blocking resources 是哪些。这个报告对初查问题尤其有用,能直接列出所有阻塞首屏渲染的资源清单,省得一个个猜。

4.4 我在实战中惯用的优化组合

基于上面的实验结论,我把项目里落地的一套优化方案整理成清单,基本都是可以直接照搬的:

  • 关键 CSS 内联。首屏必须用到的样式(比如页面头部、导航、首屏卡片)直接内联到 HTML 里,减少一次请求。其余样式用<link rel="preload" as="style">预先加载(这个我下一节细说)。
  • 业务脚本全部 defer。除非是必须在解析阶段执行的初始化脚本,否则一律加上 defer 属性,让它们等到 DOM 解析完再跑。
  • 第三方独立脚本用 async。比如客服聊天组件、埋点工具、AB 测试 SDK,它们之间没有依赖,用 async 不阻塞解析。
  • 避免 @import。所有的 CSS 引用统一用 link 标签,禁止在样式文件里再 import 其他 CSS。
  • 小脚本直接内联。有些工具函数只有 1~2 KB,与其发一次请求、浪费一次 RTT(往返时延),不如直接内联到 HTML 里。

这套组合在我的项目里,通常能把首次内容绘制(FCP)时间压缩 40% 以上,效果非常稳定。

5. 常见问题与排查技巧实录

5.1 真实项目里的典型阻塞场景速查表

我一直觉得,性能问题最难的往往不是优化手段,而是从一堆复杂代码里定位出到底谁在阻塞。下面这份速查表是我这些年在项目里遇到的高频场景和处理方式。

现象典型原因快速解决
白屏时间很长,转圈好几秒head 里有多个同步脚本,且都在等待 CSS 解析给脚本加 defer,优先级低的用 async
页面先是裸的,突然样式刷上来CSS 放在了 body 底部,或者通过 JS 动态注入样式把所有 CSS 移到 head,用 link 加载
某个脚本下载特别晚脚本位于一个超大的 CSS 之后,且是同步执行给脚本加 defer,或把 CSS 拆成按需加载
首屏图片加载很慢业务 JS 的优先级被设置过高,抢占了带宽检查 script 的 fetchpriority,给图片提高优先级
DOMContentLoaded 迟迟不触发存在同步脚本,且它前面有 CSS 依赖给脚本加 defer,或者内联关键脚本
移动端流量下加载尤其慢加载了完整版 CSS,media 条件没有适配;或 CSS 里有 @import用媒体查询分割样式,替换 @import

这个表不是万能药,但能覆盖大多数我见到过的页面卡顿问题。如果你排查时发现不符合任何一种情况,再回去看 Network 瀑布图,重点看长条的排队时间,那里往往能暴露问题。

5.2 两个实操排查方法:从瀑布图到资源优先级

第一个方法:看 Network 面板的 Waterfall。按Ctrl+Shift+R强刷页面,打开 Network,多刷新几次观察瀑布图。如果是阻塞问题,你会看到明显的“排队”现象——CSS 还没加载完,某个脚本一直停留在暂挂状态。这就是脚本被 CSS 挡住了。如果多个资源都挤在“Queueing”阶段,则说明浏览器在等待资源下载的优先级分配,可能某个大请求占满了带宽。

第二个方法:在 Performance 面板里观察主线程上的空隙。打开录制后刷新页面,结束后选中时间轴上的“Main”轨道。你会看到一段段灰色任务是主线程空闲。如果 HTML 解析任务中途停下来,隔了很久又出现脚本执行,那个空隙大概率就是在等资源。配合底部 Summary 里的“Blocking”标记,往往能把阻塞源头精确到一个文件。

这些方法熟练之后,排查速度会非常快。我处理线上问题的时候,通常五分钟就能判断出是 CSS 阻塞还是 JS 阻塞,再花两分钟找到具体是哪个文件。这种熟练度,不是靠背结论得来的,全靠多刷几遍 Performance 面板。

5.3 最后一点碎碎念:不要把“优化”变成“过度优化”

前面说了很多阻塞和优化,但我想提醒你一点:不要为了追求“零阻塞”而过度优化。

我曾经接手过一个项目,为了消除所有渲染阻塞资源,把所有 CSS 都内联进了 HTML,结果 HTML 涨到 1 兆以上。首屏确实没有再等 CSS 请求了,但因为 HTML 解析的负担加重,加上不能利用浏览器缓存来复用样式文件,后面每次进入页面都要重新下载那一大坨 HTML,整体体验不升反降。

合理的做法是:只把首屏最关键、体积最小的那部分样式内联,其余样式正常用 link 加载。既保住了首屏速度,也保留了缓存能力。同样的道理,脚本也不是“全都加 async 就最好”。有些脚本之间明明有依赖关系,强行用 async 反而会因为执行顺序不确定而报错。你自己 console 里可能就见过那种“Cannot read properties of undefined”的报错,查半天发现是脚本执行顺序乱了。

所以,做优化之前,先想清楚当前页面的瓶颈到底是什么。白屏长,优先查 CSS 和关键脚本;交互卡,优先查长任务和执行时机;图片慢,优先查带宽分配和懒加载策略。只有对症下药,优化才有意义。

我在实际项目里最大的体会是:把 CSS 和 JS 的阻塞原理搞清楚,几乎能解决一半以上的首屏性能问题。前几天处理一个业务页面,就是因为一个同步脚本被放在了一组样式文件后面,导致整页渲染硬生生慢了 4 秒。把脚本加上 defer 后,首屏直接快了近一半。这类问题在真实项目里非常常见,但只要你弄懂了背后的机制,解决起来就是分分钟的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询