柚木

精读《JavaScript 开销》

在 web 开发中,JavaScript 显然是一个不可或缺的语言,我们创建的应用已经重度依赖它。通常 JavaScript 的开销并不是那么的显而易见,需要我们借助一些工具分析,精读文章也提供了一些准则在移动设备上更加快速地加载和执行,当然很多在客户端开发中依然适用。

1.引言

本文从方法和理论上揭露了如何在移动端加速优化站点。优化箴言『更少的代码等于更少的编译时间、更少的加载时间以及更少的解压缩』文章围绕着下载和执行花销两个核心点展开了详细的论述。

2. 内容概要

传输内容的大小对网络来说是至关重要,解释和执行的时间对 CPU 来说同样重要。降低这两个可以帮我们解决很多问题。

网络请求

我们对网络本身几乎无计可施,我们无法要求用户使用 3G/4G/WiFi 网络来请求我们的站点,不得不承认有部分用户依旧在使用 2G 网络,或者像 2G 一样传输速度的网络。但可以通过有效的方式来降低 JavaScript 加载上的代价。

  • Code-split,在应用初始加载时仅仅加载用户需要的代码;

  • 压缩代码,通常使用 Uglify 压缩 ES5 代码,针对 ES6 可以使用 babel-minify或者uglify-es

  • 更优的压缩算法,常用的压缩算法有 Brotli、Sopfli 或者 gzip。Brotli 比 gzip 的压缩表现更好,相比与 gzip 而言,在实际应用中:

  • CertSimple 使用它提升了 17% 的压缩比率;

  • 而 LinkedIn 则节省了约 4% 的加载时间;

  • 删减无用的代码。我们可以借助于 chrome 开发者工具 DevTools code coverage 来查看应用 js 代码的覆盖情况,通常可以通过下面几点入手:

  • Tree ShakingClosure Compiler 优化方案;

  • 常用库,使用 lodash-babel-plugin 优化 lodash 库,使用 ContextReplacementPlugin 来优化 Moment.js;

  • 使用 babel-preset-env & browserlist 避免在当代浏览器中使用无用的 plugin;

  • 借助 analysis of their Webpack bundles 分析移除不必要的依赖;

  • 通过缓存来减小网络请求。

  • 使用 max-age 来决定让你的脚本在客户端存留时间,然后通过 ETag 校验来避免传送未发生变化的字节;

  • Service Worker 缓存可以实现网络按需请求;

解释和编译

代码下载到本地后,JavaScript 最大的开销就是代码的解释和编译了,我们可以借助 Chrome DevTools,在 Performance 选项卡里,我们能看到的黄色的『Scripting』部分就表示了解释和编译时间。

同样,你可以在 Performance > Bottom-Up 选项卡里面查看到解释、编译以及其他时间消耗。

下面这展图更加详细的展示了从 js 下载到解释编译执行,最后页面交互的全过程。

我们可以看出,js 解释和执行的时间如果过长会很严重的影响到用户交互的时间。同时,js 代码越多则解释和执行的时间越长,用户等待的时间也越长。

JavaScript 字节不同于 JEPG 字节

JavaScript 字节和图片字节在开销上区别很大。图片通常不会阻塞主线程或者阻断交互。然而,js 会受到解释、编译和执行的影响延迟交互的进行。

硬件因素影响

我们的多数用户可能用着很慢的 CPU 和 GPU 的设备,没有一级或者二级缓存可言,甚至于内存的使用都是受限制的。经过测试,1MB 大小的 js 文件在顶级的终端设备和平均水平的设备上的解释和执行速度有 2~5 倍的差距。

毫无疑问,针对平均水平的设备进行优化才是最关键的,Google Analytics 提供了分析站点『真正』用户的方法。让开发者有机会了解他们用户菜单 CPU 和 GPU 环境。

执行时间

除去了解释和执行的开销,执行代码也是一个必须在主线程上运行的开销。长时间的 js 代码执行同样会增加用户与站点交互所需的时间。

从经验上来讲,当 JavaScript 执行时间超过 50ms 时,用户交互的时间就会被 js 脚本所花费的下载、编译和执行时间挤占。

幸运的是,JavaScript 可以将代码分块执行,避免了将主线程锁死,也就是 js 的异步执行。

PRPL 模式

PRPL 是一种通过高效的 code-splitting 和 caching 交互优化方案:

  • 推送 - 为初始网址路由推送关键资源。
  • 渲染 - 渲染初始路由。
  • 预缓存 - 预缓存剩余路由。
  • 延迟加载 - 延迟加载并按需创建剩余路由。

其他开销

JavaScript 影响站点性能的其他方面:

内存

页面可能因为 GC(内存回收)而出现跳动或停顿。当浏览器开辟了内存,js 执行会停止,所以频繁的 GC 会产生频繁的代码执行终止。所以避免内存泄露和频繁的 GC 可以让页面更流畅。

连续的 JavaScript 执行

连续的 JavaScript 代码执行会阻塞主线程,导致页面卡顿,用户无法交互。使用 requestAnimationFrame 或者 requestIdleCallback 来优化动画不流畅和响应过慢的问题。

3. 精读

该部分会对文中两个核心『网络请求』和『解释和编译』中所涉及到的核心技术进行进一步的学习和扩充。

Brotli

Brotli 是 google 在 2015 年发布的压缩算法,到目前为止已经被大部分浏览器所支持。它的用法和 gzip 基本保持一致,不过事实证明其压缩比率更优于 gzip,实验数据表明可以在 gzip 的基础上将数据再压缩 20 ~ 25%,这在 web 端来说很客观。

我们可以在 Response Headers 中查看 content-encoding 确认是否使用了 Brotli 进行传输,br 表示 Brotli 算法。

在启用了 Brotli 压缩算法的浏览器下,我们同样可以在 Request Headers 的 accept-encoding 字段里看到 br

PRPL

PRPL 是一种用于结构化和提供 Progressive Web App 的模式,有 Polymer 团队在 Google IO 大会上提出,它将通用的优化手段进行了总结,得出了一个标准的范式:

  • 推送 - 为初始网址路由推送关键资源。
  • 渲染 - 渲染初始路由。
  • 预缓存 - 预缓存剩余路由。
  • 延迟加载 - 延迟加载并按需创建剩余路由。

PRPL 范式尽可能减少了交互时间,尤其是在第一次使用时。同时在发布更新时,提高了应用的缓存效率。

Push

使用 [<link rel="preload">](http://link.zhihu.com/?target=https%3A//developers.google.com/web/updates/2016/03/link-rel-preload)HTTP/2 Push 为当前路由预先缓存下一个路由的资源。

Preload 同样是一个新的 web 标准,开发者可以自定义资源的加载逻辑,提供更加细粒度的加载控制,提升性能。

**Preloader 简介
**
HTML 解析器在创建 DOM 时如果碰上同步脚本(synchronous script),解析器会停止创建 DOM,转而去执行脚本。所以,如果资源的获取只发生在解析器创建 DOM时,同步脚本的介入将使网络处于空置状态,尤其是对外部脚本资源来说,当然,页面内的脚本有时也会导致延迟。

预加载器(Preloader)的出现就是为了优化这个过程,预加载器通过分析浏览器对 HTML 文档的早期解析结果(这一阶段叫做“令牌化(tokenization)”),找到可能包含资源的标签(tag),并将这些资源的 URL 收集起来。令牌化阶段的输出将会送到真正的 HTML 解析器手中,而收集起来的资源 URLs 会和资源类型一起被送到读取器(fetcher)手中,读取器会根据这些资源对页面加载速度的影响进行有次序地加载。

此外,HTTP/2 Push 也可以做到主动将资源推送给浏览器,不过在某些场景下存在不足,无法考虑到浏览器的缓存等,推送的内容如果已经存在于客户端时就显得毫无意义。

Render

仅仅渲染路由所关联的页面。

Pre-cache

通过serviceWork来实现 Pre-cache。在当前页面加载完毕后,提前将下个页面的资源缓存起来 。这里需要考浏览器存储容量的问题,可以通过SW-ToolBox提供的 LRU 缓存策略和 TTL 缓存失效策略。

Lazy-load

Lazy-load 可以通过 code splitting 来完成,当路由命中时去异步加载路由内的资源。

requestAnimationFrame

requestAnimationFrame 是浏览器提供的一个动画接口,类似于 setTimeout 可以实现定时循环,它的优势在于充分的利用了显示器的刷新机制,每秒最多重绘 60 次或 75 次,与浏览器的刷新频率 60HZ/75HZ 保持一致,利用这个数显频率进行重绘。此外,当页面处于浏览器的非当前标签页时,所有刷新都会停止,节省了浏览器资源开销,提升网页的性能。

var requestId = requestAnimationFrame(fn);

这个方法会告知浏览器需要执行动画,浏览器会在下一次重绘之前来调用 fn 来更新动画 。

cancelAnimationFrame(requestId);

setTimeout 类似,我们可以通过 cancelAnimationFrame 方法来取消重绘。

requestIdleCallback

requestIdleCallback 可以理解为后台任务调度,只有在浏览器空闲的时候才会被执行。它和 requestAnimationFrame 有相似之处,会在一帧的结束后或者非活跃状态下处理我们的工作。

更加确切地说,只有当前帧的运行时间小于 16.66ms 时,requestIdleCallback(fn) 里面的 fn 才会调用。反之则会推迟在下一帧执行,如果下一帧仍没有空闲时间,则会继续推迟。

当然,我们可以给函数传递第二个参数,保证会在指定时间段内的所有帧都没有空闲时间时强制执行。

var requestId = requestIdleCallback(fn, 5000);

这个方法相当于让开发者得到了当前浏览器是否处于空闲状态,进而在空闲状态下完成一些『后台操作』,比如日志分析、数据预加载、客户端数据上传等操作。

requestIdleCallback 之前,我们只能在复杂并且计算量大的代码的情况下,使用 setTimeout 做一个延时处理,然而我们并不能确定这些代码会执行多久,同时用户在这些计算过程中有可能有其他复杂的交互,这种不确定的存在使得开发者显得更加被动。

当让,我们也可以通过函数调用返回的 id 将函数调用取消:

cancelIdleCallback(requestId);

4.总结

性能优化对前端来说任重而道远,精读文章从『网络请求』和『解释编译』两个方面为入口,揭示了缩减网络请求所需时间以及提高解释编译效率的方法以及开发工具。作为开发者我们确实需要把更多的时间聚焦在 JavaScript 开销上,掌握常用的优化技巧。

精读《The Cost Of JavaScript》 · Issue #61 · dt-fe/weekly​github.com图标

如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。