去掉你代码里的 document.write("<script...
在传统的浏览器中,同步的 script 标签是会阻塞 HTML 解析器的,无论是内联的还是外链的,比如:
<script src="a.js"></script> <script src="b.js"></script> <script src="c.js"></script> <img src="a.jpg">
在这个例子中,HTML 解析器会先解析到第一个 script 标签,然后暂停解析,转而去下载 a.js,下载完后开始执行,执行完后,才会继续解析、下载、执行后面的两个 script 标签,最后解析那个 img 标签,下载图片,展现图片。假设每个文件的下载时间都是 1 秒,且忽略浏览器的执行耗时,那么你最终会在第 4 秒结束时看到 a.jpg 渲染在了浏览器上。
如今的浏览器已经不再这么线性的执行了,在遇到第一个 script 标签后,主线程中的解析器暂停解析,但浏览器会开启一个新的线程去于预解析后面的 HTML 源码,同时预加载遇到的CSS、JS、图片等资源文件,也就是说,在现代浏览器中,上面这个例子中的四个资源文件是会被并行下载的,所以不考虑浏览器的执行耗时的话,渲染出最后那张图片只需要 1 秒钟。
额外小知识:
但浏览器能做的仅仅是预解析和预加载,脚本的执行和 DOM 树的构建仍然必须是线性的,从而页面的渲染也必须是线性的。脚本必须顺序执行这很好理解,比如 b.js 很可能用到 a.js 里的变量;DOM 树不能提前构建的原因也能想到,a.js 里很可能去查询 DOM 树,在那时执行 querySelectorAll("script").length 必须是 1,img 的话必须是 0。
但还有一个东西也能解释上面两个优化不能做的原因,甚至也能让预解析和预加载这两个已经做了的优化失效的东西,那就是 document.write(),document.write 可以在当前执行的 script 标签之后插入任意的 HTML 源码,如果你插入一个 "<div>foo</div>" 那还好,但如果插入一个未闭合的开标签呢,比如:
<script> document.write("<textarea>") // 还可以是 document.write("<!--") 等 </script> <script src="a.js"></script> <script src="b.js"></script> <script src="c.js"></script> <img src="a.jpg">当第 1 个 script 标签执行完毕后,浏览器就会发现,因为 document.write 输出了一个未闭合的开标签,所以刚才做的预解析成果得全部扔掉,重新解析一次,第二次解析后 script 标签和 img 标签都成了 textarea 的内容了,因此预加载的 JS 和图片资源都白加载了。但这种情况毕竟是少数,预解析的利远远大于弊,所以浏览器们才做了这个优化,MDN 上有一篇文章列举了一些会让浏览器做的预解析优化失失效的代码。
本文的主角是用 document.write 输出一个 script 标签的情况,比如:
<script src="a.js"></script> <script> document.write('<script src="http://thirdparty.com/b.js"><\/script>') </script> <script src="c.js"></script>
这个例子中,由于 b.js 是通过 JS 代码插入的,HTML 预解析器是看不到的,所以只有当 a.js 下载并执行完毕,且第二个内联的 script 执行完毕后,b.js 才会开始下载,也就是说,b.js 不能和 a.js 及 c.js 并行下载了,从而导致页面展现变慢,同样假设每个文件的下载时间都是 1 秒,那么这三个文件下载执行完就需要两秒,就因为 b.js 不能预加载。在一个外链的 JS 文件比如 a.js 中执行 document.write("<script...) 也是类似的效果。
Chrome 的工程师们最近发现,因这种包含于 document.write() 中的 script 标签而导致的页面加载变慢的情况非常普遍,同时还发现了个普遍的规律,那就是这些脚本的 URL 如果不是本站的(跨站的),一般都是些广告和统计功能的第三方脚本,是对页面正常展现非必须的,如果是本站的,则更可能是当前页面展现所必须的脚本。
这些工程师们还在 Chrome for Android 中针对 2G 环境做了采样统计,发现有 7.6% 的页面包含了至少一个这样的 script 标签,而且发现假如禁止加载这些非必要的脚本后,页面本身的展现速度会有显著提升:
用 document.write 去加载脚本,绝大多数情况下都是错误的做法,是应该被优化的。那该怎么优化呢?改成普通的 script 标签放在 HTML 里面吗?不行也不该,先来说说为什么不行,一般来说,一个脚本之所以要放在 JS 里去加载,而不是直接放在 HTML 里,可能的原因有:
1. 脚本的 URL 是不能写死的,比如要动态添加一些参数,用户设备的分辨率啊,当前页面 URL 啊,防止缓存的时间戳啊之类的,这些参数只能先用 JS 获取到,再比如国内常见的 CNZZ 的统计代码:
<script> var cnzz_protocol = (("https:" == document.location.protocol) ? " https://" : " http://"); document.write(unescape("%3Cspan id='cnzz_stat_icon_30086426'%3E%3C/span%3E%3Cscript src='" + cnzz_protocol + "w.cnzz.com/c.php%3Fid%3D30086426' type='text/javascript'%3E%3C/script%3E")) </script>
它之所以为用户提供 JS 代码,而不是 HTML 代码,是为了先用 JS 判断出该用 http 还是 https 协议。
2. 在外链的脚本里加载另外一个脚本,这种情况就没法写在页面的 HTML 里面了,比如百度联盟的这个脚本里就可能用 document.write 去加载另外一个脚本:
再来说说为什么不该,即便真的有少数的代码可以优化成 HTML 代码,比如上面这个 CNZZ 的就可以改成:
<span id='cnzz_stat_icon_30086426'></span> <script src='//w.cnzz.com/c.php?id=30086426' type='text/javascript'></script>
这样浏览器就可以预加载了,算是进行优化了,但这并不是最佳的优化,因为,当你能明显感觉到你的页面因为第三方脚本的原因导致展现缓慢,通常都不是因为它没有被预加载,而是因为它的加载速度比你自己网站的脚本加载速度慢太多,再拿出这个例子:
<script src="a.js"></script> <script> document.write('<script src="http://thirdparty.com/b.js"><\/script>') </script> <script src="c.js"></script>
thirdparty.com 网站出问题的时候,a.js 和 c.js 1 秒就加载完了,而 b.js 也许需要 10 秒才能加载完,那 c.js 的执行以及后面的 HTML 的渲染就需要等 10 秒钟,极端情况就是 b.js 一直卡在那里直到超时,如果这些脚本是放在 head 里的,那用户永远不会看到你的页面,在国内的人应该早已深有体会,就是那些引用了 Google 统计、广告等同步版脚本的页面,这种情况下只靠预加载是解决不了根本问题的。
最佳的做法是把它改成异步执行的,异步的 script 根本不会阻塞 HTML 解析器,也就用不到预解析了。通过 HTML 载入的 script 可以用 async 属性将它变成异步的:
<span id='cnzz_stat_icon_30086426'></span> <script async src='//w.cnzz.com/c.php?id=30086426' type='text/javascript'></script>
当然,这个外链的脚本本身也可能需要做相应的调整,比如万一里面还有个 document.write,那整个页面就会被覆盖了。
上面也说到了,大部分第三方脚本都需要添加动态参数,没法修改成 HTML 的代码,所以更加常见的做法是用 document.createElement("script") 配合 appendChild/insertbefore 插入 script,以这种方式插入的 script 都是异步的,比如:
<span id='cnzz_stat_icon_30086426'></span> <script> document.head.appendChild(document.createElement('script')).src = '//w.cnzz.com/c.php?id=30086426' </script>
目前国内国外绝大多数的广告、统计服务提供商都有提供异步版本的代码,但也有可能没有,比如 CNZZ 的统计代码, 看这里和这里。
本着用户体验至上的原则,Chrome 的工程师们准备进行一个大胆的尝试,那就是屏蔽掉这种脚本,具体的屏蔽规则是,符合下面所有这些条件的 script 标签对应的脚本不会再被 Chrome 执行:
1. 是用 document.write 写入的
无法预解析和预加载
2. 同步加载的,也就是不带有 asyc 或 defer 属性的
即便写在 document.write 里,异步的 script 标签也不会阻塞后面脚本的执行以及后面 HTML 源码的解析
3. 外链的
内联的反正没有网络请求,不影响展现速度,况且谁会去写 <script>document.write("<script>alert('foo')<\/script>")</script> 这样的代码。。
4. 跨站的
上面说过了,跨站的脚本影响页面本身的内容展现的可能性更小,跨站和跨域的区别,请看我的这篇文章
5. 所在页面的此次加载不是通过刷新操作触发的
虽然说第三方脚本影响页面主体内容和功能的可能性不大,但仍然有这个可能,假如页面主体内容收到影响了,用户必然会点刷新,所以刷新的时候,这个屏蔽逻辑得关掉
6. 所在页面是顶层的(self === top),而不是 iframe
因为 iframe 往往是广告之类的小区块,而用户想看的主页面通常是这些 iframe 的父页面,且 iframe 内的脚本并不会阻塞父页面的渲染,所以没必要优化它们
7. 未被缓存
如果这个外链脚本已经被缓存了,当然可以直接拿来执行了。
但这毕竟是个 breaking change,考虑用户体验的同时也不能不考虑网站本身,所以这个改动会循序渐进的一步一步(我总结成了 4 步)执行,给开发者留出修改自己代码的时间,具体计划是:
1. 警告
从 Chrome 53,也就是目前的稳定版开始,开发者工具的控制台中会出现下面这样的警告(即便脚本已经被缓存或者页面是通过刷新操作打开的,也会出现这个警告):
2016.10.6 追加,从 Chrome 55 开始,除了上面的警告,这个被警告的脚本的 HTTP 请求会被添加一个额外的请求头,方便该脚本的维护者提前知道自己的脚本在未来会被屏蔽:
Intervention:<https://www.chromestatus.com/feature/5718547946799104>; level="warning"比如下面是百度首页一个被警告脚本的 HTTP 请求头截图:
2. 在 2G 网络下开启屏蔽(issue 640844)
从 Chrome 54(2016 年 10 月中旬发布)开始,在 2G 网络环境下开启屏蔽。需要指出的是,屏蔽一个脚本并不是真的不发起请求,而是会发一个异步的请求,且优先级很低(优先级为 0,Chrome 给每个 http 请求都标有优先级)。这个异步请求的目的不是为了去执行它(上面也说了,把一个同步脚本直接当成异步脚本去执行,是很可能会出问题的),而是为了:
(1)为了把脚本放到缓存里,也就是说,第一次屏蔽了,第二次翻页等操作后如果还需用到那个脚本,那它很可能已经在缓存里了,这也是为了减少 breaking 的概率。
(2)为了通知这个脚本所在的服务器,“你的脚本被我屏蔽了”。脚本被屏蔽后异步发起的请求会被 Chrome 添加一个特殊的请求头 Intervention,值是一个对应的 chromestatus 网址:
Intervention: <https://www.chromestatus.com/feature/5718547946799104>
如果你是一个第三方服务提供者,比如广告投放系统的负责人,你在你的服务器的访问日志里看到这个请求头,就说明你的脚本已经被屏蔽了,从 Referer 头里也能看到被屏蔽的脚本是在哪个页面里被引用的,然后你需要做的是就是让这个网站把你们提供的代码更新成异步版本的。
因为是 2G,所以肯定是移动版的 Chrome,也就是 Chrome for Android,Android WebView 不知道不会开启,在 6 月份 Chrome 官方发布的消息中说到还没有定要不要在 WebView 中开启:
Will this feature be supported on all six Blink platforms (Windows, Mac, Linux, Chrome OS, Android, and Android WebView)?
This feature will be enabled on Win, Mac, Linux, ChromeOS and Android, but we are still deciding whether it's appropriate to apply this intervention for WebView.
Chrome for IOS 内核不是 blink,不受影响。
2016.10.20 追加,推迟到了 Chrome 55。
为了方便调试,在 Chrome PC 版开发者工具中将网络切换成 2G 也能触发这个屏蔽规则(还在实现中)。
2016.10.6 追加,上面的这个 issue 已经 fixed 了,但我发现开发者工具模拟成 2G 并不能触发真实的屏蔽,可能人家只是为了方便自己写测试代码,开发者工具并没有支持,我在 issue 下面问了,目前还没回。不过我发现另外一个开启真实屏蔽的方法,就是打开 chrome://flags/#disallow-doc-written-script-loads,开启这个选项后,所有网络环境下符合那 7 个条件脚本都会被真实的屏蔽掉,比如百度首页这个脚本:
这两个请求的 URL 是一模一样的,上面那个是原来的请求,被屏蔽了,会报 ERR_CACHE_MISS 的错误,下面那个是异步发起的请求。
我自己看到的一个到时候可能受到影响的手机网站:https://sina.cn/
3. 在网速较差的 3G 和 WiFi 环境下开启屏蔽(issue 640846)
目前还没有决定从哪个版本开始,如果上一个 2G 阶段进行顺利,才可能会进入这个阶段,等有消息的时候我会在这里追加具体开启的版本号,PC 页面在这个阶段才会受到影响。
我自己看到的两个到时候可能受到影响的网站:https://www.baidu.com/ https://www.taobao.com/
4. 完全屏蔽
任何网络环境都开启屏蔽,这完全是我的猜测,还没有看到 Chrome 的人在讨论,但即便最后要这样做了,肯定也需要较长的过度时间。
有些同学可能会问:“我把它放在页面最底部,总该没事了吧”。别忘了同步的 script 会阻塞 DOMContentLoaded/load 事件,关掉 vpn 运行下面的 demo 试试:
<script> document.addEventListener("DOMContentLoaded", function(){ alert("执行异步渲染、绑定事件等操作") }) document.write("<script src=http://www.twitter.com><\/script>") </script>
用 jQuery 的话,所有 $(function(){}) 里的回调函数都会被卡主,问题依然很严重。
最后总结一下:“为什么说 document.write("<script...) 不好” - “因为它本来能够写成异步的,却写成了同步且不能预加载的”
PS:Chrome 还在做另外一个优化的尝试,就是开启一个单独的 V8 线程用来执行那些包含有 document.write("<script...) 字样的内联的 script 标签中的代码从而预加载那个脚本,但就像我上面说的(预加载不能解决阻塞问题),即便这个优化真做成了,意义也不大。
PPS:HTML 规范也做了对应的修改,说允许浏览器做这种优化。