十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CSS与JS阻塞机制详解:从渲染路径到首屏优化实战

CSS与JS阻塞机制详解:从渲染路径到首屏优化实战 这个问题我在面试里被问过也在线上事故里被它坑过。很多人在背“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 relstylesheet hrefstyle.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 秒加载的 CSSbody 里没有任何脚本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 relstylesheet hrefprint.css mediaprint这个打印样式不会阻塞渲染因为媒体条件不匹配的时候浏览器知道这个样式当前用不上就会以低优先级异步加载。但如果你在代码里写了mediaall或者干脆不写那它就是标准的渲染阻塞资源。我见过不少项目里移动端页面默认加载了一套桌面端样式只是用 media 做响应式适配但 media 条件写得不合适导致桌面和移动的样式都被当成阻塞资源一起加载了。所以排查 CSS 阻塞问题时我会按这个顺序看一遍head 里 link 的数量、每个 link 有没有写必要的 media 属性、样式表内部有没有 import、以及有没有在 HTML 里内联一大坨实际上用不上的基础样式。3. JS 阻塞的是“读”和“写”而且更伤主线程3.1 经典脚本的执行时机拿到就执行不带任何属性、也即“经典脚本”的script srcapp.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 模块脚本、动态注入和 fetchpriorityES Module 模块脚本script typemodule天然具备 defer 行为默认异步加载不会阻塞解析执行时机在文档解析完成后。但它有一个额外开销——模块依赖需要逐个解析并请求如果模块图特别深实际完成时间可能比传统 defer 脚本更晚。动态创建的脚本也有坑。有些人喜欢在 JS 里动态创建 script 标签往 DOM 里插觉得这样就不会阻塞了。但动态脚本分两种情况如果你设置了script.async true行为接近 async如果不设置它其实还是异步下载、立刻执行的。默认情况下的动态脚本会被浏览器标记为 async 加载所以也别指望它能守规矩地按顺序执行。另外现在可以给 script 标签加fetchpriorityhigh来提示浏览器提高下载优先级。我在需要加载首屏关键脚本时会用这个属性但在给第三方脚本设置优先级时非常小心。因为任何脚本一旦提高优先级都可能抢占主带宽反而延迟 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 charsetUTF-8 titleBlocking Test/title link relstylesheet hrefstyle.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 h1CSS amp; JS Blocking Test/h1 script srcapp.js/script /body /html我在 head 里放了一个同步内联脚本做起点标记body 里放一个同步外部脚本。这样能看到 DOMContentLoaded 到底等了多久。4.2 实测结果不同的加载组合时间差多少本地跑出来的数据非常有说服力。可以一次性做三个对比组。第一组head 里放 CSS3秒延迟body 里放同步 JS2秒延迟。实测 DOMContentLoaded 大约在 5 秒左右触发。这个时间正好是“CSS 下载完 JS 下载并执行完”的串联时间。中间的等待过程在 Performance 面板里看得很清楚HTML 解析在遇到 body 的 script 时暂停一直等到 CSS 完成才继续。第二组head 里放 CSS3秒延迟body 里的 JS 加 defer。实测 DOMContentLoaded 大约 3 秒出头触发。因为同步脚本被改成了 deferHTML 解析完全不再等待 JSCSS 一旦完成解析继续走完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 relpreload asstyle预先加载这个我下一节细说。业务脚本全部 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或者内联关键脚本移动端流量下加载尤其慢加载了完整版 CSSmedia 条件没有适配或 CSS 里有 import用媒体查询分割样式替换 import这个表不是万能药但能覆盖大多数我见到过的页面卡顿问题。如果你排查时发现不符合任何一种情况再回去看 Network 瀑布图重点看长条的排队时间那里往往能暴露问题。5.2 两个实操排查方法从瀑布图到资源优先级第一个方法看 Network 面板的 Waterfall。按CtrlShiftR强刷页面打开 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 后首屏直接快了近一半。这类问题在真实项目里非常常见但只要你弄懂了背后的机制解决起来就是分分钟的事。
返回列表