背景
今天又是碰到诡异bug的一天。我们运营会用到 Google Analytics 埋点。埋点sdk的安装脚本大体都是这样的:
html
<script>
!(function(w,d,s,l,i) {
w[l]=w[l]||[];
var f=d.getElementsByTagName(s)[0],
j=d.createElement(s);
j.async=true;
j.src=i;
f.parentNode.insertBefore(j,f);
})(window, document, 'script', 'dataLayer', 'https://www.google-analytics.com/analytics.js');
</script>
动态创建一个带 async 的 script 标签请求埋点脚本。正常来说,加了 async 的脚本下载是不会阻塞页面渲染的。
然而今天在 ios webview (wkwebview) 中,用国内网络访问这个国内不存在的资源时,长时间的loading阻塞了首屏渲染。而用移动端的 Safari 打开又不会有阻塞问题。并且也不是每个页面都出现这个情况。
一开始怀疑是业务代码的问题,于是一行行排查,但最后发现,删除页面css中的某一行时,页面就不阻塞了。并且每个有问题的页面,需要删除的css都不一致,没什么特殊性。
那完犊子了,一行css的增删怎么会阻塞页面渲染,而且只在ios webview中出现,十有八九是webview的bug了。
一番搜索,只找到个类似的问题 脚本下载会阻塞 Mobile Safari 首屏渲染,虽然不一样,但解决思路是可以借鉴的。就是通过> requestAnimationFrame 和 microTask 延迟了 script 脚本的下载,使其在首屏渲染之后再开始下载;
问题解决了,但引发了我对css和js加载导致页面阻塞这个经典问题的思考,之前只是死记一直没有实践过,本着没有困难创造困难也要上的精神,特此水文一篇!
前置知识
- 当页面因为加载外部资源而阻塞时,页面可能会优先渲染已经解析好的内容
以 chrome 为例,以下是一张经典老图,我们知道从文档开始解析到页面展示,主要经历了 html的解析,dom树的构建,css的解析,cssom的构建,再通过二者创建render tree,重排,重绘,渲染;而我们常遇到的阻塞问题,主要发生在 html解析 和 页面渲染。
需要注意的是,这是一个渐进的过程。为了更好的用户体验,渲染引擎会尽可能快的渲染内容,而不是等待所有的 html解析完成。也就是说,当页面因为加载外部资源而阻塞时,页面可能会优先渲染已经解析好的内容。
- 在解析文档时,浏览器会有额外的线程找出需要下载的外部资源(js/css/img),并开始并行下载,也就是说html解析阻塞并不影响资源下载;
环境准备
起一个 parsing-demo 文件夹,内容如下
- index.html
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>前端一块砖</title>
</head>
<body>
<div id="up">上</div>
<img width="50" height="50" src="http://www.test.com/test.jpg" alt="">
<div id="down">下</div>
</body>
</html>
- dist/test.js
js
console.log('-------- test 执行啦 ---------')
- dist/sleep.js
js
function sleep(delay) {
var start = new Date().getTime();
while (new Date().getTime() - start < delay) {
continue;
}
console.log('sleep done');
}
sleep(5000);
- dist/test.jpg
- dist/test.css
css
#up {
background-color: pink;
}
#down {
background-color: skyblue;
}
因为懒得再起个服务器,我这里主要用 Charles 打断点来模拟请求阻塞
我这里是macos,先用 helm 配置一下 www.test.com 的 DNS 解析,解析到本地
127.0.0.1 www.test.com
然后执行
shell
npx http-server dist -p 80
通过 http-server 起一个静态服务器,指定dist
为根目录,通过参数 -p
设置端口为 80,这样就可以模拟访问外部资源啦
再开个终端执行
shell
npx http-server
不指定额外参数,这样 http://127.0.0.1:8080/ 默认访问的就是我们的 index.html 了,测试下
页面可以正常访问;
最后再配置一个 Charles 的断点,用来拦截所有对 www.test.com
请求的响应,模拟下载阻塞(不会Charles的赶紧学啦!)
外部Css的阻塞情况分析
我们在head里加上css
html
<head>
<meta charset="utf-8" />
<title>前端一块砖</title>
<link rel="stylesheet" href="http://www.test.com/test.css">
</head>
可以看到css的响应被正常拦截,页面一片空白,而 elements 已经能看到 html 的全部内容了;
放开 test.css 的断点,可以看到页面正常渲染
这样就验证了,外部css的下载不会阻塞 html 的解析,但会阻塞页面的渲染;
这个很好理解,因为 css 并不能修改 dom,所以不应该阻塞 html 的解析,而 css 是可以修改页面样式的,所以会阻塞页面渲染;
大部分没有钻研精神的人都到此为止了。但!这只是把 <link> 放在 <head> 的情况,那么如果把 <link> 放在 <body> 里,结论还一样吗?(其实是我一开始手欠放错位置了...坑了我好久😭)
我们把 <link> 放到 <body> 里再试试看
可以看到页面在 link 加载时,阻塞了剩余 html 的解析,并且优先渲染了已解析的 html;这让我一度怀疑网上的文章是不是都是乱写的。我又拿 safari 和 firefox 测试了以下,发现三家表现都不一样;
safari 不阻塞解析,阻塞渲染
firefox 既不阻塞html解析 ,也不阻塞渲染
这时验证的结果已经和我们平时看到的文章有些出入了
script 的阻塞情况分析
我们知道 js 是可以修改 dom 的,为了防止html的解析和预期不一致,因此的 js 的下载和执行都会阻塞 html 解析,我们可以用以下例子验证。断点解开前,html解析阻塞;先解开 test.js 的断点,再解开 sleep.js 的断点,在sleep执行期间,html解析也会阻塞;
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>前端一块砖</title>
<script src="http://www.test.com/sleep.js"></script>
</head>
<body>
<div id="up">上</div>
<img width="50" height="50" src="http://www.test.com/test.jpg" alt="">
<script src="http://www.test.com/test.js"></script>
<div id="down">下</div>
</body>
</html>
那么会不会阻塞渲染呢?
为了让页面有东西可渲染,script 就要放到 body 里了,发现三家表现又不一致;
chrome 和 safari 阻塞了 html 解析,优先渲染了已解析的内容
而 firefox 既阻塞了解析又阻塞了已解析内容的渲染
验证到这里,事情已经开始有点不对劲了🤨
我们再验证一下 当文档 link的css后面有 script 时,script 需要等待前面的css下载执行完毕才会执行,也就是说css的下载会阻塞后方js的执行,导致html解析的阻塞,我们可以用下面这段例子验证
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>前端一块砖</title>
<link rel="stylesheet" href="http://www.test.com/test.css">
</head>
<body>
<div id="up">上</div>
<img width="50" height="50" src="http://www.test.com/test.jpg" alt="">
<script src="http://www.test.com/test.js"></script>
<div id="down">下</div>
</body>
</html>
可以看到 test.js 下载时阻塞了 html 的解析,我们先断点放行 js 的下载,继续阻塞 css,在 chrome 中页面依然没渲染,下载好的js也没有执行;
这是因为 css 可能会改变页面样式,而导致后面 js 获取的页面样式可能和预期不一致(因为 css 可能比js先下载好,也可能比js晚下载好),为了避免不符合预期的情况,js 的执行需要等待 css 下载执行完成(内联的 script 也是一样);
以上都是对于静态 script 标签的情况,而对于动态创建的 script 标签:无论是否带 async 标记的,标签创建完成都会立即开始下载,下载过程中都不会阻塞html解析和渲染,并且下载完成后立即执行
以上就是 css 和 script 在 html 解析过程中的大部分情况了;
回到上面各浏览器表现不一致的问题,其实就是各浏览器的实现不一致,各浏览器都有自家的渲染时机。可以自行验证一下,link 和 script 无论是放在 <head> 还是 <body> 中,各家表现其实都不一致。所以对于 css 和 script 导致的 html 解析阻塞和渲染阻塞并没有一个标准的答案。我觉得也没有必要去硬记各家的表现,具体情况具体分析就好了,如果说非要一个面试的答案的话,以下是基于 Chrome 的总结
总结
基于 chrome 119.0.6012.0(正式版本)canary (arm64)
- 阻塞通常发生在 html 解析 和 页面渲染阶段
- 文档解析时,浏览器会有额外线程找出需要下载的外部资源,提前开始下载,html解析阻塞不会影响外部资源下载
- css,当 link css 放在 head 时,css 的下载不会阻塞 html 的解析,但会阻塞页面的渲染,当 link 放在 body 时,阻塞 html 解析,不阻塞已解析部分的渲染
- script,静态script标签的下载和执行阻塞html的解析,但会先渲染已解析部分;script 之前有 link css 时,会等待css下载执行完成再执行
- 动态创建的script标签不会阻塞html解析,创建完成立即开始下载,下载完成立即执行,执行时阻塞html解析