ARTICLE DETAIL

资讯详情

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

CSS与JS如何阻塞浏览器渲染?一文看懂DOM解析与页面白屏

CSS与JS如何阻塞浏览器渲染?一文看懂DOM解析与页面白屏 1. 先把阻塞这事放到浏览器的工作流里看遇到这个标题我第一反应是很多前端同学在面试时被问过这个问题回答也很有意思有人说CSS阻塞、有人说JS阻塞、有人说都阻塞。其实都对但又都不全对。因为阻塞这个词太粗了你得分清楚它阻塞的是DOM解析还是页面渲染还是脚本执行。这三者的阻塞对象和后果完全不一样。所以这篇文章我不打算给你一个谁赢谁输的结论而是把整条链路拆开让你真正理解浏览器拿到HTML之后CSS和JavaScript分别在哪个环节卡住了浏览器又是怎么互相绊脚的。搞清楚这个你以后写页面、做性能优化、排查白屏和FOUC问题都会顺手很多。先说一个我自己的直观结论方便你带着框架往下看CSS主要阻塞渲染不直接阻塞DOM解析。JS主要阻塞DOM解析连带着阻塞它后面的渲染。但CSS会通过卡住JS的方式间接阻塞DOM解析这是很多人没意识到的关键。要理解这三句话得先把浏览器的解析流程过一遍。1.1 浏览器不是一个边看边画的页面阅读器很多人以为浏览器解析HTML就像人看报纸一样看一行画一行。实际不是。它有一套固定的流水线先解析HTML生成DOM树同时解析CSS生成CSSOM树然后两棵树合并成渲染树Render Tree再到布局Layout、绘制Paint。也就是说HTML负责内容骨架CSS负责这棵树的样式两者是两条并行管线。但关键在它们交汇的那一下浏览器无论如何不可能在CSSOM没准备好的时候就把带样式的页面画出来。这跟盖房子一个道理——施工队可以一边搭钢筋一边等装修图纸但你不可能在装修方案没定下来的时候就先刷墙。搭建骨架和等装修方案可以并行但交付一栋能住的房子必须等两个都齐。DOM解析就是搭骨架CSSOM构建就是出装修方案渲染就是交付入住。CSS阻塞的从来不是搭骨架这个过程它阻塞的是交付。有一个很常见的面试陷阱是问CSS会不会阻塞DOM解析标准答案是不会。因为HTML解析器在构建DOM树时根本不依赖CSSOM遇到link relstylesheet资源照样可以继续往下生成DOM节点。你放心吧骨架可以继续搭。1.2 那为什么感觉CSS慢页面就一直白屏既然CSS不阻塞DOM解析为什么CSS没加载完之前页面上什么都看不到因为浏览器有一条硬性原则在CSSOM构建完成之前它不会开始首次渲染。你可以把首次渲染理解成第一次给用户看到的画面。浏览器宁可让你看白屏也不愿意先把没有样式的裸内容显示出来。为什么因为如果先渲染无样式内容再等CSS到了重新渲染用户就会看到一个文字乱跳、布局乱闪的页面也就是所谓的FOUC无样式内容闪烁。为了体验浏览器选择全堵住。所以在实际页面里head里的CSS资源越重、下载越慢用户盯着白屏的时间就越长。这也是为什么性能优化里关键CSS内联一直排在很前面的位置——你要让首次渲染需要的那部分CSS在浏览器渲染之前就已经到齐。2. CSS它是渲染阻塞器但真正的坑在别处前面说CSS不阻塞DOM解析这话没错但它太简化了。真实页面的复杂之处在于HTML里不止有标签还有script。而一旦HTML解析器遇到脚本情况就变了。2.1 为什么浏览器遇到script要停下解析JS最大的特点是它能改DOM。它可以在文档任意位置执行document.write改写后续内容也可以通过getElementById、querySelector等API查找节点甚至读取计算样式。这就给浏览器出了一个难题如果HTML解析器不管JS、继续往下解析那脚本执行时文档后半段的状态是不确定的脚本拿到的结果是错的。所以浏览器做了一个很朴素的决定遇到一个普通的script先停下来等这个脚本下载完、执行完再继续解析后面的HTML。这就是大家常说的JS阻塞解析。它阻塞的不是渲染而是解析器前进的节奏。这造成的最直接后果是脚本后面的所有DOM节点在脚本执行完成之前根本不会被解析出来自然也就无法出现在渲染树里。如果一个执行了500毫秒的脚本放在head里那这500毫秒内整个页面后面都是空的。2.2 CSS不阻塞解析但它能截胡脚本现在把CSS和JS放在一起看。设想这样一个页面head link relstylesheet hrefapp.css script srcapp.js/script /head浏览器从头解析先遇到app.css开始下载它同时继续往下走。很快遇到app.js按照遇到脚本就停下的规则它应该立刻去下载并执行app.js对吧对但有一个前提。HTML规范里其实还藏着一条隐规则如果一个经典脚本指没有带async或defer的同步脚本前面有正在加载的样式表脚本必须等样式表加载完成并构建好CSSOM之后才能执行。为什么因为脚本完全可能去读取样式比如getComputedStyle。如果样式表还没就绪脚本读到的就是残缺的样式信息行为就会不一致。为了保证脚本执行时看得见的样式是确定的浏览器宁可让脚本多等一会儿。这就是那条最容易被忽视的阻塞链app.css没下载完app.js等着不动HTML解析器又因为app.js没执行而卡住后续所有DOM都出不来页面自然一直白屏所以严格说起来CSS确实不直接阻塞DOM解析但它通过阻塞后续JS的执行间接拖住了整个解析流程。这条链在实际项目中太常见了尤其是那些把所有CSS和JS都堆在head里的页面。2.3 内联脚本和外部脚本的表现还不一样很多人把内联脚本当成不要下载所以不阻塞的特例。这是另一个误区。内联脚本确实没有网络下载这一步但它在CSS阻塞这件事上反而更敏感。看这个例子head link relstylesheet hrefapp.css script const width document.querySelector(.box).offsetWidth; console.log(width); /script /head这段内联脚本在app.css之后。它不需要下载但浏览器依然会等app.css下载并解析完才允许这段脚本执行。因为脚本在运行时很可能会查询布局或样式浏览器必须先保证样式是完整的。所以别以为把脚本改成内联就能绕开CSS的阻塞影响——反而因为它跟CSS挨得更近更容易被卡。真正能绕开CSS阻塞JS这个问题的是async脚本。因为async脚本的语义就是下载完就执行我才不等你呢它不保证能看到完整的CSSOM。但也正因如此它执行时如果去读样式拿到的值可能是不准的所以async脚本里尽量别做依赖样式和布局的操作。3. JS真正的解析卡点但卡的方式不止一种JS阻塞规则看着简单实际上现代浏览器给了它三种不同的执行姿势阻塞行为也完全不同。3.1 同步脚本、async、defer三种姿势对比我直接给一张对比表这是理解和记忆的重点加载方式下载是否阻塞解析执行时机是否阻塞解析是否保证执行顺序普通script阻塞遇到就执行阻塞按文档顺序async不阻塞下载完立即执行执行瞬间会停一下不保证defer不阻塞DOM解析完成后执行不阻塞解析按文档顺序普通script是最笨但最可控的方式下载和执行都堵住解析器好处是脚本执行时文档一定解析到当前位置行为可预期。async是下载异步、执行不可预期它在后台下载下载完那一下立刻执行不管此时文档解析到哪了。所以它执行时DOM可能是残缺的不保证顺序也不保证能看到完整样式。好处是快适合独立性强、不依赖其他脚本和DOM状态的脚本比如埋点、统计脚本。defer是下载异步、执行延后它保证脚本在文档解析完成之后、DOMContentLoaded事件触发之前按顺序执行。此时整个DOM都在行为非常稳定。它也不阻塞解析流程。3.2 执行时阻塞和下载时阻塞是两回事我发现很多人在性能分析工具里看到脚本耗时高第一反应是这个JS下载太慢。其实下载慢和执行慢是完全两种开销而且对解析流程的影响也不一样。普通script在下载阻塞和执行阻塞两方面都堵。而async脚本虽然下载不阻塞解析但它下载完之后的执行那一瞬间依然会打断解析流程。如果一个async脚本体积巨大、执行时间极长那它对首屏的影响可能比一个体积小但下载慢的同步脚本还夸张。所以选型的时候不能只看async/defers 异步的还得评估脚本执行本身的开销。执行500毫秒的脚本无论你怎么加标签这500毫秒它就是占了主线程页面就是会卡。3.3 一个很容易被漏掉的细节普通脚本的下载依赖普通script还有一个特性容易被忽略它下载和执行之间的顺序受它前面所有阻塞资源影响。回到前面那个例子link relstylesheet hrefapp.css script srcapp.js/script如果app.css要2秒才能加载完那app.js的实际下载启动时间也在2秒之后。这就是最糟糕的串行等待CSS下载等2秒然后JS才开始下载。两个资源不能并行纯纯浪费网络。现代浏览器为了缓解这个问题引入了预加载扫描器preload scanner。主解析器虽然卡在app.js这儿但预加载扫描器会提前在后台把app.js的下载请求发出去甚至在解析早期就把app.css和app.js一起发起请求。这样至少下载可以并行但执行依然必须等CSS就绪。4. 完整阻塞链路推演到底谁的锅这一节我把常见场景都推演一遍你以后遇到白屏、卡顿可以直接按这个思路来定位。4.1 head里放CSS和JS的典型场景最经典的慢页面长这样!DOCTYPE html html head link relstylesheet hrefapp.css script srcapp.js/script /head body ...大量DOM... /body /html实际时间线大概是HTML开始解析发现app.css发起下载。解析器继续走发现app.js发起下载现代浏览器可以并行下载CSS和JS。但app.js要执行必须等app.css下载并构建完CSSOM。app.js在等待期间不执行解析器也被卡住body里的DOM一个都解析不出来。等app.css好了app.js执行完才继续解析后面的HTML。页面首次渲染要等DOM和CSSOM都就绪实际上比第5步更晚。白屏时间的核心贡献者既有app.css的加载时间也有app.js被CSS拖住后造成的解析停顿。如果你在DevTools Performance里看到一条长长的空白极大概率就是这条链在起作用。4.2 把脚本放到body底部的场景这是大多数团队的常规做法body div idapp/div script srcapp.js/script /body这个方案的好处是解析器从头开始把整个body里的DOM都解析完了才遇到app.js它后面也没有太多DOM可阻塞了。所以首屏的DOM结构能很快出来。但要注意如果页面在末尾还有一个CSS资源比如某个组件样式是临时引入的body div idapp/div link relstylesheet hreflate.css script srcapp.js/script /bodyapp.js依然可能要等late.css。而且DOM解析本身已经基本完成这里的等待更多影响的是脚本执行和DOMContentLoaded的触发时机。也就是说把脚本放底只能减少JS阻塞DOM解析的影响并不能完全消除CSS阻塞JS执行的影响。4.3 为什么现代浏览器很多时候看起来没那么卡你可能会说我实际测过head里放CSS和JS页面也没那么慢啊。对这要归功于几个现代浏览器机制预加载扫描器会提前发现app.js在它还没走到那个script标签时就把下载请求发出去了。浏览器对link relstylesheet和script会做资源优先级调度关键的CSS往往被标记为高优先级。HTTP/2多路复用让多个资源可以在同一条连接上并行传输。但要清醒这些机制优化的是下载阶段的并行度没法解决JS必须等CSS就绪才能执行这一语义约束。所以大体积CSS仍然会拖住大体积JS的执行该白屏还是白屏。4.4 还有一条容易被忽视的隐形链DOMContentLoadedDOMContentLoaded事件大家应该都熟。它什么时候触发不是DOM解析完立刻触发它还要等两件事所有带defer的脚本执行完。所有会阻塞脚本的样式表加载完成。也就是说如果页面里有一个加载很慢的CSS哪怕你的DOM早就解析完了DOMContentLoaded依然会等它。这在做埋点、统计、SPA应用初始化时会特别明显——你发现业务代码一直不执行排查半天最后发现是一张样式表卡住了事件。5. 实战落地方案CSS和JS到底怎么排布讲完原理直接给可执行的方案。这是我个人做前端优化时的一套兜底策略不一定适合所有项目但方向一定不会错。5.1 第一优先级先砍体积再谈排布不管CSS还是JS阻塞影响的本质是浏览器停下来等某个资源到位的时间。只要资源体积足够小、加载足够快阻塞的影响就趋近于零。所以第一步永远是做体积治理CSS去掉无用样式、提取公共样式、压缩合并、合理使用content属性而非冗余类名。JS按需加载、拆包、tree-shaking、路由级拆包。逻辑很简单一个1KB的CSS和一段500KB的JS排布再怎么差影响也有限。5.2 head里只留首屏关键CSShead里的CSS是渲染必需的这个没法省。但可以做到只保留首屏关键CSS其他样式异步加载。首屏关键CSS可以直接内联到HTML里省掉一次网络往返。内联关键CSS是首屏优化最强手段之一。你要做的就是把首屏渲染涉及的那部分样式抽出来直接以style形式放在head里。这样浏览器构建CSSOM时不需要等网络立即就绪渲染可以第一时间开始。非关键CSS怎么异步加载CSS本身没有async属性但业界有成熟的hacklink relpreload asstyle hrefnon-critical.css onloadthis.onloadnull;this.relstylesheet noscriptlink relstylesheet hrefnon-critical.css/noscript原理是先用preload把CSS当成纯资源提前加载等触发了onload事件再把rel改成stylesheet。这样CSS下载不阻塞渲染等下载完再应用样式。注意这种方式在样式应用前页面可能短暂出现无样式状态所以只适用于非关键样式比如弹窗、浮层、权限相关UI的CSS。5.3 脚本排布决策表脚本怎么排我给一张决策表场景推荐方式原因首屏必须立即执行的逻辑内联小脚本没有网络往返不产生额外请求业务主脚本依赖DOM和顺序defer放head不阻塞解析同时保证顺序统计、埋点等独立脚本async不阻塞、不期待顺序尽早执行页面底部收尾脚本普通script放body末尾天然不阻塞首屏关键资源特别强调一点在head里用defer而不是把大脚本堆到body末尾在现代浏览器里其实是更好的方案。因为defer脚本下载不阻塞解析而且能保证执行顺序还能提前发起下载请求避免了把脚本放底部时可能会晚一些才开始下载的问题。5.4 主动避免阻塞链形成的布局范例一个理想的HTML骨架长这样!DOCTYPE html html head meta charsetutf-8 title页面标题/title style /* 首屏关键CSS直接内联覆盖可见区域样式 */ /style link relpreload asstyle hrefnon-critical.css onloadthis.onloadnull;this.relstylesheet noscriptlink relstylesheet hrefnon-critical.css/noscript script defer srcapp.js/script /head body div idapp/div /body /html这套骨架实现的效果是CSS不会阻塞渲染关键部分已内联app.js不会阻塞解析defer非关键CSS异步加载不拖累首屏。这也是我在多数中后台项目里的默认模板。6. 高频问题速查与排障实录6.1 面试题/常见问题速查表我整理了几个经常被问到、也是实际开发中容易混淆的问题问题结论CSS会阻塞DOM解析吗不会直接阻塞但会阻塞后续JS执行间接阻塞解析CSS会阻塞渲染吗会CSSOM未就绪之前不会首次渲染JS会阻塞DOM解析吗普通同步JS会defer/async下载阶段不会但async执行瞬间会停JS会阻塞渲染吗会JS会阻塞解析而DOM不完整就无法渲染执行时也独占主线程为什么放在head里的CSS会让页面白屏因为首次渲染必须等CSSOM就绪为什么DOMContentLoaded老是不触发多半是样式表加载太慢或defer脚本执行太久defer和async哪个好没有绝对依赖顺序选defer独立脚本选async大部分业务脚本推荐defer6.2 用Performance面板定位阻塞源当页面卡顿时别靠猜。打开DevTools的Performance面板录制一次加载过程重点看三件事主线程上的长任务看是否有超过50ms的任务尤其关注脚本执行时段。资源加载瀑布流看CSS和JS之间的顺序关系。如果JS的下载或执行紧跟在CSS完成之后基本可以断定是JS等待CSS的链路在起作用。渲染时间点找到首次绘制First Paint的时刻反推它之前到底在等什么资源。我见过不少团队优化半天结果发现是一个1MB的CSS文件拖住了后面所有脚本执行。用Performance面板一眼就能定位。最后再给个小技巧在Chrome里直接搜索Network面板里的Bigger entities按传输大小排序往往能快速找到那个罪魁祸首。6.3 我踩过的一些坑最后说几个我实际踩过的坑。一个是过度依赖defer。我把所有脚本都改成defer结果发现有些脚本的执行顺序依赖页面结构中的状态defer又保证了解析完成后执行理论上顺序是一致的但架不住某些脚本自己不干净全局变量依赖前一个脚本的即时写入。所以defer改变了执行环境不等于无脑安全。另一个是CSS异步加载引发的样式闪烁。我用preload方式异步加载样式表结果弹窗组件在样式加载完成前先显示了布局错乱了好几秒。后来给这类组件加了样式就绪的标记在CSS加载期间先不渲染弹窗。还有一个典型的判断误区是只盯着JS看忽视CSS的间接影响。有些时候页面白屏问题不在JS执行而在JS等CSS的那段等待。排查时一定要把CSS资源加载时间一起看进去。如果你在某次优化后发现代码逻辑没变但页面变快了很可能就是等待链变短了而不是你的判断出了错。根据我个人经验这个知识点最大的价值不在于面试时能背出答案而在于你以后设计HTML结构时会在脑子里自动过一遍那条阻塞链。把CSS和JS的加载顺序、依赖关系理顺了页面性能的很多问题都会自然消失。
返回列表