ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CSS与JS阻塞机制全解析:从渲染流水线到性能优化实战

CSS与JS阻塞机制全解析:从渲染流水线到性能优化实战 CSS 和 JS 的阻塞之争我在实际项目里被问过很多次。很多人背过结论——“CSS 阻塞渲染JS 阻塞解析”可真拿到一个慢页面对着 Performance 面板还是一头雾水。这个系列聊到第 27 篇正好把这个经典问题摊开讲透CSS 和 JS 到底谁阻塞、阻塞的是哪一段、两者凑在一起又会闹出什么连锁反应最后落到怎么定位和优化。适合所有写页面的人看不管你是刚入门还是已经写了几年把这条链路的原理吃透优化性能时就能少走一大半弯路。1. 先说清楚“阻塞”发生在哪个环节一条浏览器的解析流水线想搞清楚 CSS 和 JS 谁阻塞第一步不是背结论而是先看清浏览器拿到 HTML 之后到底在干什么。这一步搞懂了后面的所有问题都迎刃而解。1.1 从字节流到像素中间隔了几个关键节点浏览器从网络层拿到 HTML 字节流之后会经过一个固定的处理管线HTML 解析器把字节流拆成标签逐个构建 DOM 节点形成 DOM 树遇到style或link relstylesheet时浏览器会启动 CSS 解析把样式表转成 CSSOMCSS Object ModelDOM 和 CSSOM 合并成渲染树Render Tree渲染树只包含可见节点和对应的样式接着是布局Layout计算每个节点的几何位置再往下是绘制Paint和合成Composite最终把像素显示到屏幕上。这里有两个容易搞混的对象DOM 和 CSSOM 是并行构建的渲染树依赖两者都就绪。HTML 解析器的主要产出是 DOM样式表解析器的主要产出是 CSSOM两个过程不算严格并行——因为解析器只有一个主线程但它们的逻辑是独立的。1.2 记住两个事实后面所有推理都基于它们第一个事实HTML 解析是边下载边解析的。浏览器不会傻等整个 HTML 文件下载完才开始建 DOM而是收到一部分字节流就解析一部分这也是浏览器能快速展示首屏内容的基础。第二个事实“渲染”和“解析”不是一回事。解析Parsing是把 HTML 变成 DOM渲染Rendering是把 DOM 和样式变成像素。CSS 阻塞的是渲染JS 阻塞的却是解析。这两个词弄混后面的分析全乱。我用一个生活化的场景帮你记把 HTML 解析器想象成一条装配流水线它一路把零件节点组装成产品DOM 树CSSOM 是图纸渲染是把图纸和零件合在一起完成总装。JS 则是站在流水线旁边的质检员它随时可能伸手改动流水线上的零件。流水线为了安全看到质检员过来必须暂停而图纸如果还没到齐总装车间宁可停工也不肯先装一半。这个类比对应到浏览器行为上就是 JS 阻塞解析、CSS 阻塞渲染的底层逻辑。2. CSS 的“堵”不在下载而在构建CSSOM 不完整就别想画页面CSS 的阻塞行为有一个很容易被误解的点很多人以为link标签会让 HTML 解析器停下来等它其实不是。CSS 文件下载期间HTML 解析器照样往后跑DOM 照常构建真正被卡住的是“把 DOM 画出来”这个动作。2.1 CSS 阻塞的边界不挡 DOM 构建却按下渲染的暂停键我在本地跑过一个很直观的测试把一个大 CSS 文件放在head里故意让它加载 3 秒然后在body里放一大段静态 HTML。打开 Performance 面板能看到HTML 解析任务蓝色的 Parse HTML 块在这 3 秒内一直在持续执行DOM 树早就建完了但从用户视角看页面是白屏的。为什么浏览器不先把没有样式的 HTML 画出来因为那会导致严重的闪烁感FOUC。想象一下页面先以无样式的纯文本形式闪一下然后 CSS 加载完突然跳成完整排版这种体验比多等几秒还糟糕。所以浏览器的策略是渲染树必须等 CSSOM 构建完成才能开始在此之前一味憋着像素不发。这条规则会直接影响性能指标。首屏内容绘制FCP被 CSS 拖住不是因为 CSS 文件体积大到下载了很久而是因为“构建渲染树”这个步骤在等待 CSSOM 完整。一个 50KB 的 CSS 如果放在关键路径上它造成的白屏时间基本等于它的下载时间加解析时间。2.2 link 和 import 在阻塞行为上的巨大差异CSS 的引入方式不同阻塞效果完全不一样这里有一个经常被忽视的坑import。link是 HTML 层面声明的资源浏览器可以在解析 HTML 时提前发现它并发的网络请求很快就能发出。import是写在 CSS 文件内部的规则浏览器必须先把外层 CSS 下载并解析完才会发现里面的import再发一个新的请求去取下一层 CSS。这意味着import天然是串行下载的。我在一个老项目里见过三层import嵌套最后整个样式链路的加载时间拉长到原来的三倍以上。更麻烦的是只要这条import链上任何一个环节没完成渲染就被卡住不动。所以我的原则很简单生产环境不要用import引样式不要让它出现在关键路径上。如果你在维护老代码发现页面白屏时间异常但 CSS 文件本身不大先去翻一翻有没有嵌套的import。2.3 media 属性很容易被忽略的“非阻塞”写法link标签上的media属性其实是一个很实用的优化点。当media条件不匹配时浏览器依然会下载这个 CSS 文件但它不会阻塞渲染。举个例子link relstylesheet hrefprint.css mediaprint link relstylesheet hrefmobile.css media(max-width: 640px)在桌面端浏览器里print.css和mobile.css依然会被下载但浏览器不会因为等它们而阻塞渲染。这种机制诞生之初是为了适配不同设备后来被聪明人反过来用把非关键的样式包进一个初始不匹配的media里等页面关键内容画完再通过 JS 把media改成all实现“加载但不阻塞”。不过提醒一句这个技巧用起来要谨慎切换media的时机如果处理不好会出现样式跳变。它适合那种“有更好、没有也能先用”的渐进增强型样式不适合首屏必须依赖的核心布局。3. JS 的“堵”是硬性的碰到脚本解析器直接原地暂停说完了 CSS 的软阻塞再来看 JS 的硬阻塞。JS 对 HTML 解析器的打断是强制性的这背后的原因涉及浏览器设计的一个基本约束。3.1 为什么脚本必须打断解析document.write 和 DOM 修改的现实约束HTML 解析器遇到script标签时无论它是内联的还是外部的都必须先停下来。如果是外部脚本解析器会停下等它下载完再执行然后才继续解析后面的 HTML。这个设计看起来很“笨”但如果你站在浏览器设计者的角度想就明白了JS 在执行时可以干两件大事——通过document.write向解析流里写入新的 HTML或者通过 DOM API 修改已经解析出来的节点。解析器无法预测脚本会做什么为了确保脚本执行时能拿到完整、一致的 DOM 状态只能选择“在脚本执行期间暂停一切解析动作”。这带来一个实际影响一段放在head里的同步脚本会直接推迟整个页面所有后续节点的解析。哪怕这段脚本只是console.log(hello)它也会让body里的首屏 HTML 晚一点变成 DOM。脚本越小暂停越短但它绝对是“阻塞”的。3.2 执行顺序的隐形规则脚本会等样式表就绪JS 的阻塞还有一个更隐蔽的规则同步脚本会等它之前的所有样式表下载并构建完 CSSOM然后才执行。原因是脚本在执行时可能会读取样式相关的值比如getComputedStyle、offsetWidth、clientHeight。如果 CSSOM 还没构建完脚本能拿到的就是一份残缺的样式数据这会导致脚本逻辑出错或者让布局数据不一致。浏览器为了稳妥干脆规定脚本必须等前置 CSSOM 就绪。这个规则在实际页面里制造了一个典型的拥堵点head里先有一个大 CSS后面跟着一个同步 JS。看起来这个 JS 只是被 CSS 挡住了执行但从用户视角看白屏时间等于“CSS 下载 JS 下载 JS 执行”的总和而 HTML 解析器从 JS 开始下载那一刻起就完全停住了。我在排查一个页面的时候就遇到过head 里放了一行负责“判断 UA 字符串是否包含某个关键词”的小脚本这个脚本本身只有几百字节下载瞬间完成但它前面排着一个慢 CSS于是这行本该毫秒级执行完的小判断硬是被拖到 CSS 加载完才跑白屏时间凭空多出两秒。这种“小脚本被大 CSS 连坐”的场景在真实项目里非常常见。3.3 一个实测对比脚本放在不同位置的差别我做一个简单的实验记录你在自己电脑上也能复现。用一个 HTML 页面head里放一个加载 1 秒的外部脚本看 Performance脚本在headHTML 解析器在脚本处暂停等待下载执行之后才继续解析body首屏出现的总时间明显变长脚本在body底部HTML 解析器已经把所有 DOM 建完遇到脚本时暂停解析后面的内容通常已经没有多少内容影响大幅变小脚本加defer下载不阻塞解析执行延后到 DOM 解析完成后脚本加async下载不阻塞解析下载完立即执行执行时会暂停解析器。只看这个实验结论似乎很简单把脚本放底部就行。但真正到了复杂页面事情远没有这么简单因为 CSS 会横插一杠这就是我在下一节要说的叠加效应。4. 两者的叠加效应当 CSS 拖住 JS当 JS 挡住整个页面前面分别讲了 CSS 和 JS 各自的阻塞行为但真实页面里它们从来不是孤立的。CSS 和 JS 凑在一根关键路径上时会形成一条环环相扣的锁链而这条锁链才是大多数“白屏好几秒”页面的真正元凶。4.1 一个典型连锁反应拆解假设一个页面的head里有这样一段结构head link relstylesheet hrefapp.css script srcapp.js/script /head body !-- 大量首屏内容 -- /body加载过程是这样的HTML 解析器发现app.css开始下载但解析器不等待继续往后走解析器发现app.js这时 HTML 解析被迫暂停开始下载app.js但app.js下载完成后还不能立即执行因为它前面有一个样式表app.css还没构建完 CSSOM它必须等CSS 终于加载完CSSOM 构建完成app.js开始执行app.js执行期间HTML 解析器依然处于暂停状态脚本执行完解析器继续解析bodyDOM 建完、CSSOM 已就绪浏览器才开始渲染。这条链路的阻塞总时长 ≈app.css下载解析时间 app.js下载时间 app.js执行时间。更关键的是HTML 解析器从第 2 步开始就停了那么body里所有内容变成 DOM 的时间全都被推迟了。从用户视角看页面就是一片空白什么都等不到。我见过不少性能排查案例单独看 CSS 不慢、JS 也不大但组合起来就是慢原因就在这个“等”字上。CSS 没有直接阻塞解析但它通过“拖住脚本执行”间接让解析停得更久。4.2 为什么“把 JS 放底部”也不是万能解既然脚本会阻塞解析那把所有 JS 都放到body底部是不是就万事大吉了现实情况往往更复杂我踩过的坑大致有三类。第一类逻辑上必须在解析过程中执行的脚本。比如某些组件需要在特定 DOM 节点创建时立即初始化或者有脚本依赖document.write非常老派的用法。这类脚本放到底部会直接改变执行时机引发 bug。第二类HTML 解析期间的动态注入。页面里某个节点内部有一段代码需要动态创建一个script并插入文档中来加载依赖。这种动态脚本如果在解析早期被触发它依然会阻塞后续解析位置根本救不了。第三类底部脚本依然影响 DOMContentLoaded。同步脚本无论放在哪里都会让浏览器的 DOMContentLoaded 事件延后到它执行完。如果你的业务需要尽快触发 DOMContentLoaded比如埋点上报、脚本初始化底部同步脚本依然是瓶颈。理解了这些你就会明白优化脚本加载不能只靠“挪位置”而是要理解脚本在关键路径上的真实角色再决定用defer、async、动态插入还是彻底移出关键路径。5. 现代加载策略用 defer 和 async 打破阻塞链既然阻塞的根源是“解析器遇到脚本必须暂停”和“渲染必须等 CSSOM 完整”那优化方向就非常清晰了要么让脚本不再阻塞解析要么让样式不再阻塞渲染。现代浏览器提供了好几种标准手段。5.1 从“碰见就停”到“排好队再跑”defer 与 async 的差异defer和async是治疗脚本阻塞的两个常用药但它们的药效完全不同用错反而会添乱。属性下载是否阻塞解析执行时机执行顺序适用场景无默认是下载完立即执行按文档顺序需要立即生效、依赖前置 DOM 的脚本defer否文档解析完成后DOMContentLoaded之前按文档顺序需要操作 DOM、对顺序有要求的脚本async否下载完立即执行完全不保证顺序独立的第三方脚本、统计埋点、广告defer的核心价值是“延迟执行但保持顺序”。它非常适合那些依赖 DOM 结构、并且多个脚本之间有先后依赖的项目业务代码。async的核心价值是“下载完就跑”。它适合完全独立的脚本比如数据上报、AB 测试 SDK、第三方聊天组件。这类脚本不关心 DOM 是否完整也不依赖其他脚本早跑晚跑都不会出错。这里有一个很容易踩的坑把有依赖关系的脚本同时加上async。async不保证执行顺序如果a.js依赖b.js提供的全局变量运气不好时a.js先执行就直接报undefined is not a function。排查这种问题时非常痛苦因为错误是间歇性的——有时正常有时白屏很难稳定复现。我的建议是凡是脚本之间有依赖一律用defer不要用async凡是动了执行顺序的优化改完一定要多次刷新验证稳定性。另外补充一个现代知识点script typemodule默认就是defer行为ES Module 之间天然有依赖关系浏览器会帮你处理好顺序。5.2 让 CSS 不再卡渲染媒体查询切换法与 preload 思路CSS 这边也有对应的优化手段核心思路是“让非关键 CSS 不参与首次渲染的等待”。最基础的一招是内联关键 CSS把首屏真正需要的、体积可控的关键样式直接写在style标签里放进head。这样浏览器不需要额外发请求就能立刻构建 CSSOM首屏渲染几乎不被 CSS 拖累。非关键样式比如弹窗、折叠区、复杂动画的样式可以单独打包放到底部或者异步加载。第二招是前面提过的媒体查询切换法。把非关键 CSS 包进一个初始不匹配的media里让它下载但不阻塞渲染然后在合适时机切换回all。一个兼容性比较好的写法是使用preloadlink relpreload hrefextra.css asstyle onloadthis.onloadnull;this.relstylesheet noscriptlink relstylesheet hrefextra.css/noscript这个技巧的工作方式preload让浏览器提前下载 CSS 但默认不应用onload触发后把rel改成stylesheet样式在下载完成后立刻生效noscript兜底保证无 JS 环境下样式也能正常加载。注意这个写法有一个坑如果extra.css加载失败onload不会触发页面就会缺样式。所以生产环境用这个技巧时最好给preload加上onerror兜底或者慎用在核心样式上。我自己的习惯是只在纯增强型样式上用它核心布局样式一律内联或同步加载。第三招是合理拆分 CSS。把大 CSS 拆成“首屏关键样式”和“次要样式”关键样式走普通link或内联次要样式走异步加载。拆分的粒度取决于你的页面结构不要为了拆而拆拆太碎会带来大量小请求反而影响加载。5.3 常见优化的“反直觉”陷阱优化做多了你会发现有些操作看起来合理实际效果反而不行。一个是对字体文件用preload。字体加载本身不阻塞渲染但它会影响文字的呈现时机。CSS 里的font-display: swap可以避免字体加载期间文字不可见这比单纯preload字体更直接有效。如果你发现页面文字在切换字体时闪跳优先检查font-display而不是盲目预加载。另一个是过度内联 CSS。把整个项目所有 CSS 都内联进 HTML确实消灭了 CSS 请求但 HTML 体积会急剧膨胀首包传输变慢用户体验反而变差。内联的关键是“只内联首屏关键样式”其余交给异步加载。再一个是只看 Network 不看 Performance。Network 面板只能告诉你资源下载的起止时间但你无法从里面看出 CSS 卡住了哪段 JS、JS 卡住了哪段 HTML 解析。定位阻塞问题必须结合 Performance 面板看主线程的时间线否则你只是在猜。6. 实战排查定位一个页面到底被什么卡住了理论讲了这么多最后落回实操。如果你现在手上有一个慢页面怀疑 CSS 或 JS 阻塞了渲染该怎么一步步找到真凶6.1 Performance 面板阻塞问题的主线程验尸报告打开 Chrome DevTools → Performance → 点录制 → 刷新页面 → 停止录制然后看主线程时间线。你需要重点关注三种类型的任务片段蓝色的 Parse HTML 块这是 HTML 解析器在构建 DOM。如果蓝色块是连续的一大段说明解析过程很顺畅如果蓝色块中间插入了一大段黄色Script Evaluation或紫色Style / Layout任务说明执行被打断了。黄色 / 紫色的长任务脚本执行、样式重算、布局这些任务如果单次超过 50ms就会明显卡顿用户能感知到。任务之间的空隙如果时间线上有一段长时间的主线程空闲但页面又没渲染出来通常是在等网络请求比如等 CSS 或 JS 下载。我排查时的固定动作是先找主线程时间线上的第一段黄色长任务看它的前后关系——它的“前任”是谁如果黄色任务前面跟着一段等待网络间隙再前面是一个 CSS 请求那这就不是脚本本身的问题而是脚本在等 CSSOM。6.2 Network 瀑布图里的三类关键特征Network 面板的瀑布图也能看出很多信息关键看三条线的关系第一HTML 文档的下载结束时间和第一个可交互事件之间的间隔。如果 HTML 早就下载完了但页面迟迟没渲染大概率是同步脚本或 CSS 在阻塞。第二CSS 请求和它后面的 JS 执行点。找到head里的 JS 请求看它的下载结束时间和实际执行开始时间。如果这两个时间之间有明显间隔再往前看是不是有几个大 CSS 还在加载——这就是前面说的“脚本等 CSSOM”。第三资源加载的顺序。如果发现某些脚本的请求明明可以提前发出却排在后面等别的资源可能是defer或async缺失或者有动态脚本注入导致请求时序被动。6.3 从指标到结论拿到一个慢页面该怎么下判断综合前面所有信息我给出一套简化的判断流程照着走基本能把大多数阻塞问题定位出来先看 FCP首屏内容绘制和 LCP最大内容绘制确认问题出在白屏阶段还是内容显现阶段打开 Performance看 FCP 之前主线程有没有长任务如果有点开看是 Script Evaluation 还是 Style / Layout打开 Network确认这个长任务对应的资源请求从什么时候开始、什么时候结束判断瓶颈在下载还是执行检查head里的同步脚本和关键 CSS 是否都参与了第一次渲染等待凡是无关的全部用defer、async或异步 CSS 方案移出关键路径改完再录一次 Performance对比 FCP 变化。我处理过一个真实的优化案例一个后台管理系统登录后白屏接近 3 秒。排查后发现head里有一个第三方客服脚本用了同步加载同时前面排着一个约 200KB 的 CSSCSS 里还嵌套了两层import。第三方脚本其实完全可以用async加载那两层import更是完全没有必要。改完之后首屏时间从 3 秒降到 1.2 秒左右而且这个页面代码一行业务逻辑都没动只是调整了资源加载方式。做性能优化这几年我的一个固定习惯是给页面新增任何一个外部 SDK 或脚本之前先本地开 Performance 录一遍看它在加载早期占用了多少主线程时间。绝大多数第三方脚本都可以安全地改成async有些甚至应该直接移到 DOMContentLoaded 之后动态插入。要是每次都等到线上用户抱怨页面卡了才去查那排查成本可比提前预防高太多了。现在再回到标题那个问题CSS 和 JS 谁会阻塞答案其实不是二选一。CSS 负责堵住渲染让页面画不出来JS 负责堵住解析让 DOM 长不出来。而真正的麻烦是 CSS 堵了之后还会顺手拦住 JS 的执行JS 一停下来整个页面解析都跟着静止。优化时不要只盯着某一个资源要顺着关键路径把这条锁链整个看一遍找出真正卡脖子的那一环。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进