在传统的浏览器中,同步的 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 网址:

如果你是一个第三方服务提供者,比如广告投放系统的负责人,你在你的服务器的访问日志里看到这个请求头,就说明你的脚本已经被屏蔽了,从 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 规范也做了对应的修改,说允许浏览器做这种优化。

去掉你代码里的 document.write("<script...的更多相关文章

  1. Jsoup代码解读之三-Document的输出

    Jsoup代码解读之三-Document的输出   Jsoup官方说明里,一个重要的功能就是output tidy HTML.这里我们看看Jsoup是如何输出HTML的. HTML相关知识 分析代码前 ...

  2. iOS 疑难杂症 — — 在 Storyboard 里 Add Size Class Customization 后再从代码里无法修改的问题

    前言 公司的产品同时适配 iPhone 和 iPad ,并坚持用 Storyboard 来做适配,今天又踩一个坑(以前遇到过)还以为是 XCode 的鬼毛病. 声明  欢迎转载,但请保留文章原始出处: ...

  3. window.jQuery || document.write("<script src='__PUBLIC__/assets/js/jquery.js'>"+"<"+"/script>")

    今天无意中看到这样一段代码 <script type="text/javascript"> window.jQuery || document.write(" ...

  4. 去掉tppabs冗余代码,怎样批量去掉tppabs代码

    去掉tppabs冗余代码,怎样批量去掉tppabs代码 刚用teleport pro拉了一个整站到本地 所有的超链都被强行加了一句tppabs="   就玩了一把dw的替换功能 查找范围:整 ...

  5. 实现textbox文本页面改变触发textchanged事件,代码里修改不触发

    今天弄控件遇到一个问题,就是TextChanged,如果在代码里或在页面修改修改text值,就会触发事情,但如果在textchanged里修改text,它会不会触发呢,不会,我调试跟踪,并没发现它会重 ...

  6. document.write('<script type=\"text/javascript\"><\/script>')

    document.write('<script type=\"text/javascript\"><\/script>')

  7. 一统江湖的大前端(5)editorconfig + eslint——你的代码里藏着你的优雅

    <一统江湖的大前端>系列是自己的前端学习笔记,旨在介绍javascript在非网页开发领域的应用案例和发现各类好玩的js库,不定期更新.如果你对前端的理解还是写写页面绑绑事件,那你真的是有 ...

  8. SAP MM MI01事务代码里的批次确定

    SAP MM MI01事务代码里的批次确定 1 – 批次管理启用之后果 一个物料如果启用了批次管理,那么库存管理以及盘点等诸多事务里都需要在批次的层次上进行. 货物移动的时候,需要在界面上指定相关货物 ...

  9. Unity代码里的Position和界面上的Position

    代码里的Position = 世界坐标 this.gameObject.transform.position 界面上的Position = localPosition

随机推荐

  1. Json解析工具Jackson(使用注解)

    原文http://blog.csdn.net/nomousewch/article/details/8955796 接上一篇文章Json解析工具Jackson(简单应用),jackson在实际应用中给 ...

  2. mybatis 快速入门

    1 . 定义  java实体类 User,建立user表 读者请自行完成准备工作.  User 类 有 id, name,age 属性  user  表 中 id,name,age字段  id自增长  ...

  3. Java关键字 ClassName.this(类名.this)的理解

    关键字this用于指代当前的对象.因此,类内部可以使用this作为前缀引用实例成员: this()代表了调用另一个构造函数,至于调用哪个构造函数根据参数表确定.this()调用只 能出现在构造函数的第 ...

  4. 更新chrom遇到flash过期解决办法

    更新chrom遇到flash过期解决办法 百度最新adobe flash player ppapi最新版 下载并安装,重启浏览器即可

  5. jquery的各种隐藏显现动画的区别

    <!DOCTYPE html> <html> <head lang="en"> <meta charset="utf-8&quo ...

  6. 2016网络大事记 mark

    记录2016年每天的大事件. 2016年01月07日     快播庭审.辩护人各种出彩. 2016年01月09日     乐视多个贴吧被爆.百度出面平息. 2016年01月10日     斗鱼TV造人 ...

  7. Servlet和JSP

    Servlet 一.Servlet 的生命周期. servlet 有良好的生存期的定义,包括加载和实例化.初始化.处理请求以及服务结束.这个生存期由javax.servlet.Servlet 接口 的 ...

  8. RapidJSON 代码剖析(一):混合任意类型的堆栈

    大家好,这个专栏会分析 RapidJSON (中文使用手册)中一些有趣的 C++ 代码,希望对读者有所裨益. C++ 语法解说 我们先来看一行代码(document.h): bool StartArr ...

  9. 回文自动机(BZOJ2565)

    #include <cstdio> #include <cstring> #include <iostream> using namespace std; ][], ...

  10. css 补漏

    1.box-sizing: width(宽) + padding(内边距) + border(边框) = 元素实际宽度    height(高) + padding(内边距) + border(边框) ...