Day16 X Polyfill-less Bundling Script & File Compression

今天是 Build Optimizations 主题的最後一篇了,到目前为止我们已经认识了 Code Splitting, Dynamic Import 还有 Tree Shaking 等技巧,那还有没有办法再更进一步缩小 JavaScript bundle 的档案大小呢?

当然是有的,今天就来认识 Polyfill-less Bundling ScriptFile Compression 两种 Build Optimizations 的技巧吧!

Polyfill-less Bundling Script

你知道哪些 feature 属於 Modern JavaScript 吗?下面这段 code 你觉得属於 Modern JavaScript 吗?

var name = 'kyle';

console.log(name);

肯定不是,因为我们都知道 JavaScript 在 ES6 後都建议使用 let 与 const 来更严谨得宣告变数,上面这段 code 使用 var 宣告变数,所以可以很肯定它不能被算到 Modern JavaScript 的范畴中。

所以说是不是使用 ES6 之後的语法就算是 Modern JavaScript 呢?

也不对,根据 Google Chrome Developer 的定义:

Modern JavaScript is JavaScript code written in syntax that is supported in all modern browser.

而所谓 modern browser 代表的是 Chrome、Firefox、Safari、Edge 这四个市占率最高的浏览器(大约占了所有浏览器市场的 90%)再加上其他比较没那麽知名,但是底层使用的浏览器引擎与前四大家大致相同(代表它们对语法与功能的支援度也会与前四大家差不多)的非主流浏览器(大约占浏览器市场的 5 %)。

按照这样推论,Modern JavaScript 还可以有一个广义的定义:

Modern JavaScript 不是专指哪一个版本的 JavaScript 标准,而是一个会变动的 target,在众多新版本的 JavaScript 标准中,能被大约 95% 左右的浏览器所支援的新语法或功能,就可以称作 Modern JavaScript。

目前 ES2021 的语法大约只有 70% 的浏览器支援度,虽然仍然过半,却没办法依赖它提供稳定的 feature。而 ES2017 却有高达约 95% 的支援度,因此目前可以认定 ES2017 是最接近 Modern Syntax 的版本。

不过 Modern JavaScript 这个定义跟今天的主题又有什麽关联啊?

Transpilation

在开发专案的时候,为了确保产出的程序可以顺利运行在对新版本 JavaScript 支援度比较低的浏览器上,例如早期前端开发者的恶梦 - IE 浏览器(IE 终於要在 2022 年终止服务了,目前开发基本上可以不用再考虑 IE 的支援度了),通常会把程序码丢进像 babel 这样的 transpiler 里转译成较旧版本的 JavaScript 例如 ES5,不过从新语法转译成旧语法通常会导致程序码的长度变长。

为什麽程序码会变长呢?通常如果浏览器没有支援新的语法,就得透过 polyfill 的方式为旧浏览器实现或模拟现有版本已实现之功能的程序码片段

例如从 ES6 开始实现的 string.repeat() 这个函式,如果要在没有支援 ES6 的浏览器实作这个功能,得透过以下冗长的 polyfill 来实现:

if (!String.prototype.repeat) {
  String.prototype.repeat = function(count) {
    'use strict';
    if (this == null)
      throw new TypeError('can\'t convert ' + this + ' to object');

    var str = '' + this;
    // To convert string to integer.
    count = +count;
    // Check NaN
    if (count != count)
      count = 0;

    if (count < 0)
      throw new RangeError('repeat count must be non-negative');

    if (count == Infinity)
      throw new RangeError('repeat count must be less than infinity');

    count = Math.floor(count);
    if (str.length == 0 || count == 0)
      return '';

    // Ensuring count is a 31-bit integer allows us to heavily optimize the
    // main part. But anyway, most current (August 2014) browsers can't handle
    // strings 1 << 28 chars or longer, so:
    if (str.length * count >= 1 << 28)
      throw new RangeError('repeat count must not overflow maximum string size');

    var maxCount = str.length * count;
    count = Math.floor(Math.log(count) / Math.log(2));
    while (count) {
       str += str;
       count--;
    }
    str += str.substring(0, maxCount - str.length);
    return str;
  }
}

再来看看实际用 transpilier 转译新语法到旧语法的例子。一段 ES2017 的 class 语法透过 babel 转译回 ES5 版本的前後差异如下:

其实在复杂的专案下这样的程序码长度差异会对 bundle size 产生很大的影响。如果我们可以不追求要达到所有的浏览器都能支援的程度,就以现今拥有 95% 浏览器支援度的 ES2017 为转译的目标,应该可以减少一定程度的 bundle size。

Instagram 就曾针对它们的 web app 做了一个研究,如果把 transpile 的最低 target 设为 ES2017,相较於转译到 ES5 的版本,减少了 5.7% 左右的 bundle size,并且让使用 modern browser 的 user 的 page speed 提升了 3%,以网站效能来说是非常可观的进步。

所以,为了效能,我们需要牺牲旧版浏览器使用者的权利吗?

其实是有机会扞卫他们的权利的!? 我们可以在打包时分成两份 bundle,一份转译成 Modern JavaScript 例如 ES2017 的版本,让使用 Modern Browser 的使用者载入,另一份 bundle 则转译成较旧的 ES5 版本,专门给使用旧版浏览器的使用者载入。

那要怎麽根据用户的浏览器版本来决定要载入哪个版本的 JavaScript Bundle 呢?

<script type="module" src="modern_module.js"></script>
<script nomodule src="fallback.js"></script>

在设有 type="module" 的 script 带入编译成 ES2017 的 bundle,含有 nomodule 属性的 script 带入编译成 ES5 版本的 bundle,如果是看得懂 type="module" 的浏览器就会载入这个 script 而自动忽略 nomodule 的 script,相对的如果是看不懂 type="module" 的旧型浏览器,就会忽略它并载入拥有 nomodule property 的 script 当作 fallback。

如果是自己发布一个 npm package 的状况呢?

在发布一个 npm package 的时候,通常会在 package.json 中加入

{
  "main": "./index.js"
}

来指定这个 package 的 entry point。
不过其实还可以再设置一些进阶的设定:

{
  "name": "foo",
  "exports": "./modern.js",
  "main": "./legacy.cjs"
}

exports 中放入的是 Modern JavaScript 的版本,main 则是放入 fallback 的 legacy JavaScript 版本。

不过这还不是完全体,还存在一些优化的空间

{
  "name": "foo",
  "exports": "./modern.js",
  "main": "./legacy.cjs",
  "module": "./module.js"
}

这次多出了一个 module 字段,是不是觉得似曾相似啊...?没错,昨天在介绍 Tree Shaking 时有提过它,这个字段给的 bundle 通常会是非常类似 main 字段给的 legacy bundle,也就是说它仍然是一个没有 modern syntax 的 legacy bundle,不过差别在於它使用了 import 与 export 的语法,这也代表着它可以做 Tree Shaking,在浏览器不支援 Modern JavaScript 的状况下也可以尽量做到优化。

Estimator.dev

关於采用 Modern JavaScript 的 bundle 对网站效能的影响,Google Chrome 团队推出了一个蛮有趣的专案 - Estimator.dev,我们可以在这个服务中贴上想要检测的网址

它会透过 reverse transpiling 的方式,推论出如果这个网站都使用 Modern JavaScript,大约可以减少多少 JavaScript 的 bundle size,进而提升多少效能。

自己觉得蛮有趣的,推荐大家去体验看看罗~
https://estimator.dev/


File Compression

接下来进入本日的第二部份,档案压缩 File Compression。

档案压缩是一个可以非常简单又高效的减少 Network Bandwidth 还有 Page Speed 的方法 (当然,压缩演算法本身是复杂的),基本上现在的网站在传输资源时一定会做档案的压缩,让我们马上来一探究竟吧!

为什麽要做压缩?

一般来说使用者对资源发出请求的流程大致上会是以下这样

这个流程是可以 work 的,不过有点没有效率,因为回传的档案大小有点大,如果是 html 档,回传所需要的时间越久,连带会影响到要载入的 CSS 与 JS 或是其他静态资源的载入时间,拖垮网页整体的效能。

既然问题出在回传的档案大小太大,我们就试着来缩小它,这时候档案压缩的技巧就派上用场啦!使用压缩後的流程会变成以下这样

看起来顺利解决了档案大小太大的问题。

压缩的种类

File Format Compression

基本上每一种文件类型都会有浪费的储存空间,可以透过压缩来重新排列达到节省空间的优化。而图片、影片、音讯等档案格式透过压缩可以减少的档案大小又比文字档案(text file)来的更多更明显。

还记得在 Day06 图片最佳化的时候有提到图片的压缩吗?基本上用於文件的压缩演算法可以分为两种:

  • 有损压缩:在压缩与解压缩的过程中,会对原本的数据进行修改,但是会以使用者无法察觉的方式进行。例如 jpeg 的压缩就是采用这种方式。我们也可以控制有损压缩的程度,一般来说压缩越多,对於原本文件品质的影响就越大。为了使我们的网站能取得效能与品质的平衡,理想状况是维持可以接受的品质水准的前提下,尽可能提高压缩的比率。

  • 无损压缩:在压缩与解压缩的过程中,不会对要压缩的数据进行修改,也就是压缩後的数据与原来的数据是一致的(bit 与 bit 间的对应不会改变),gif 与 png 就是采用这种无损压缩的演算法。

而有损压缩演算法的效率通常会比无损压缩还要高一点,通常这两种演算法如果运用在已经经过压缩的档案上不但档案大小不一定会更小,还有机会适得其反让档案大小变得更大(跟压缩演算法需要用到的资料结构有关),这点也在 Day06 有稍微提过,已经压缩过的 png 与 jpeg 通常不会再经过 gzip 压缩就是因为这个原因。

End To End Compression

比较各种压缩方式,End To End Compression 是在 Web 应用中对效能提升最有帮助的方法,也就是上方 HTTP Request & Response With Compression 图片中的方法。

所谓 end to end compression 指的是由 client side 发起压缩请求,在 server side 完成压缩,直到回传给 client side 才会由 client 解压缩得到原始档案,中间不管经过几个 proxy node 都不会改变压缩後的 response body。

基本上现代浏览器与 web 服务器都已经支援了这项技术,差别在於支援了哪些压缩的演算法,目前最广为应用的压缩演算法广为人知的有 gzip 还有身为後起之秀并号称效能比 gzip 还要好的 brotli。

要做到 End To End Compression,需要靠浏览器与服务器之间的协商。首先浏览器在发出请求时在 request header 带上 Accept-Encoding 这个 header,并给出浏览器本身有支援的压缩演算法(因为浏览器必须自己进行档案的解压缩)。

# http header
# 演算法列的先後顺序也代表期望的优先层级,以下代表希望优先使用 brotil,如果服务器不支援再用 gzip

Accept-Encoding: br, gzip

服务器在收到这个 header 後,按照浏览器提供的优先级选择一种压缩演算法对 response body 进行压缩,并在 response header 中带入 Content-Encoding 这个 header 告诉浏览器它最後选择了哪种压缩演算法,让浏览器知道如何正确解压缩。在 response header 中,还可以加入 Vary 的 header,并且带入 Accept-Encoding,如此一来,浏览器就可以针对经过不同压缩演算法的文件分别进行快取。

End To End Compression 这个技术可以对网站的效能带来很大的优化,一般会建议除了刚刚提到的已经经过压缩的档案格式(例如 PNG、JPEG 图片)以外,其余档案都可以透过这种压缩方式来达到效能的提升。

如何实现压缩 ?

要实现档案压缩可以在 application server 这一端实现,例如前端开发者较熟悉的 Node.js 後端框架 express 与 koa 都有实作压缩的 middleware 可以使用,例如:

...
const compression = require('compression')
const express = require('express')

const app = express()
app.use(compression({ filter: shouldCompress }))

function shouldCompress (req, res) {
  if (req.headers['x-no-compression']) {
    // don't compress responses with this request header
    return false
  }

  // fallback to standard filter function
  return compression.filter(req, res)
}

不过也有一种说法认为压缩是一个极其耗费资源的行为,在 production 环境中面对大流量的情况下,对特性为 single thread 的 Node.js 来说会是一种负担,因此会建议把压缩拉到 reverse proxy 层级实作,例如压缩的任务其实可以交给 Nginx Web Server 去完成:

server {
...
...

    gzip on;
    gzip_types text/plain application/xml application/json;
    gzip_comp_level 9;
    gzip_min_length 1000;

...
...
}

有兴趣的读者可以参考 nginx 的 ngx_http_gzip_module 或是 brotli 演算法的 module

如果你不是自己架服务器来部署网站,而是使用一些第三方服务,通常它们都会帮我们做好档案的压缩,可以到 devtool 的 network tab 找找资源的 response header 有没有刚刚提到的 Content-Encoding header,使用的又是哪种压缩演算法。

本日小结

Build Optimizations 篇章在今天告一个段落了,今天说明的 Transitioning To Modern JavaScript 与 File Compression 都是很简单实用就可以大幅减少档案大小来提升网页效能的方法。

明天之後的章节我认为有些主题可能会让读者有一些疑惑:「这些真的是一般前端工程师要懂的概念吗?」的确,有些主题例如 HTTP2 Networking 的概念可能需要後端的配合才能达成,但我认为要成为一个顶尖的 Web 开发者,不管是前端後端还是全端,都应该对 Web 整体架构有一定的了解。这次的前端效能优化系列文的主题规划也是以一个「我理想中的顶尖前端开发者应该要拥有的技能树」去规划的,况且前端能做的事越来越多也越来越复杂了,想到就让我热血沸腾了,一起继续探索後面的主题吧!明天见~

References & 图片来源

https://www.youtube.com/watch?v=cLxNdLK--yI&t=631s

https://instagram-engineering.com/making-instagram-com-faster-code-size-and-execution-optimizations-part-4-57668be796a8

https://web.dev/publish-modern-javascript/

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Compression


<<:  No Time To Die在线看

>>:  Hello, OS!

Day 16 编辑器 — VSCode

俗话说工欲善其事,必先利其器。当我们在进行程序编译时,总会选择自己最舒服的程序编辑器,今天来介绍一下...

JS 40 - 向下滚动网页即自动隐藏导览列

大家好! 今天我们要实作向下滚动网页时,自动隐藏页手或导览列的效果。 我们进入今天的主题吧! 样式 ...

Day 4 jest的生命周期

BeforeAll、BeforeEach、AfterEach、AfterAll 的四个生命周期,这四...

<Day23> 如何 更新 or 修改or 取消 委托单?

● 这章会示范如何更新及修改委托单 接续上一章 我们学会了如何下单 这章会示范怎麽去更新及修改已下好...

Day 19 利用transformer自己实作一个翻译程序(一)

前言 当初想说将每天学到的东西打成一篇文章,纪录看看30天後学会了什麽 但是最近翻自己的文章就发现内...