ES6 modules 即将到来,现在该考虑新的打包方案了嘛?

Posted by Damon on 2017-06-05

ES6 modules support lands in browsers: is it time to rethink bundling?

近年来,构建高性能JavaScript应用是一个复杂的工程。几年前,从为了节省HTTP开销做代码合并开始到压缩混淆变量名来挤出最后一bit的代码放在我们的工程里。

现在我们需要tree shaking我们的代码以及打包我们的模块,然后回过头来,为了不阻塞主进程加快首屏加载速度做代码拆分。我们同时也更换了所有的东西:使用上未来的一些特性?答案是肯定的,这得归功于Babel!

ES6 modules已经定义到了ECMAScript规范里面有段时间了。社区的人们也写了很多文章布道怎么配合Babel来使用以及说明了import和node里的require的区别。但是在浏览器端完整的实现还需要一点时间。
很欣喜的看到Safari第一个在其工程预览版搭载ES6 modules,现在Edge 和 Firefox Nightly也搭载了这个功能。在经历过使用像RequireJs Browserify(还记得AMD和common js之争吗)似乎modules终将到达浏览器领地,那么就让我们来看看未来将会带给我们什么吧

常规的设置

通常构建web应用的方式是加载Browserify, Rollup 和 Webpack (or 或者市面上的其他工具)build出来的bundle。一个经典网站(非SPA)就是由一个服务端渲染的HTML里面加载了一个单文件的JS bundle。

1
2
3
4
5
6
7
8
9
10
<html>
<head>
<title>ES6 modules tryout</title>
<!-- defer to not block rendering -->
<script src="dist/bundle.js" defer></script>
</head>
<body>
<!-- ... -->
</body>
</html>

上面个包含了三个js文件的bundle是有Webpack打包,这些文件都使用了ESM(ES6 modules)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/index.js
import dep1 from './dep-1';

function getComponent () {
var element = document.createElement('div');
element.innerHTML = dep1();
return element;
}

document.body.appendChild(getComponent());

// app/dep-1.js
import dep2 from './dep-2';

export default function() {
return dep2();
}

// app/dep-2.js
export default function() {
return 'Hello World, dependencies loaded!';
}

app运行的结果是 ‘Hello world’,说明所有的文件已经加载了。

装载bundle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js

const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
entry: './app/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new UglifyJSPlugin()
]
};

这三个文件实际上很小总共才347个字节。

1
2
3
4
5
$ ll app
total 24
-rw-r--r-- 1 stefanjudis staff 75B Mar 16 19:33 dep-1.js
-rw-r--r-- 1 stefanjudis staff 75B Mar 7 21:56 dep-2.js
-rw-r--r-- 1 stefanjudis staff 197B Mar 16 19:33 index.js

当我帮用Webpack跑一遍后我得到的bundle大小是865字节,里面大概有500字节的母版。这些额外的字节是可以接受的,因为和我们平时在大多数生产环境放的代码比不算什么。感谢Webpack,我们能提前用上ESM。

1
2
3
4
5
6
7
8
9
$ webpack
Hash: 4a237b1d69f142c78884
Version: webpack 2.2.1
Time: 114ms
Asset Size Chunks Chunk Names
bundle.js 856 bytes 0 [emitted] main
[0] ./app/dep-1.js 78 bytes {0} [built]
[1] ./app/dep-2.js 75 bytes {0} [built]
[2] ./app/index.js 202 bytes {0} [built]

新方案,使用原生支持的ESM

对于所有的不支持ESM的浏览器我们有“传统的bundle”,但是现在我们要玩一些更酷的东西了。首先在index.html里面添加一个新的script标签指定其类型为ESM:type="module"

1
2
3
4
5
6
7
8
9
10
11
12

<html>
<head>
<title>ES6 modules tryout</title>
<!-- in case ES6 modules are supported -->
<script src="app/index.js" type="module"></script>
<script src="dist/bundle.js" defer></script>
</head>
<body>
<!-- ... -->
</body>
</html>

但我们打开Chrome的时候,我们看到好像没有什么事情发生。

image01

bundle首先加载了,展示出了“Hello World”,仅仅这样而已。但是这就是浏览器的高明之处,他会忽略掉他不理解的标记类型而不是抛出错误。Chrome也一样他忽略了他不知道类型的script标签。

现在,我们来看看Safari预览版的效果:

Safari预览版的效果

很遗憾,没有展示出“Hello World”。原因在于webpack打包的文件和ESM的不同:webpack能在构建时候就为我们找到文件依赖,但是ESM需要我们手动的去定义准确的文件目录。

1
2
3
4
5
6
7
// app/index.js

// This needs to be changed
// import dep1 from './dep-1';

// This works
import dep1 from './dep-1.js';

调整之后工作正常了,期望的是Safari 预览版,加载bundle和三个独立的modules,也就是说程序会执行两次。

image02

解决这个问题可以使用 nomodule属性,它可以在script标签上控制bundle的加载。
这个属性的细则很快也会出来,,一月底Safari预览版已经支持了。它会告诉不支持ESM的Safari在ESM不能执行时才执行回退的bundle。

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<head>
<title>ES6 modules tryout</title>
<!-- in case ES6 modules are supported -->
<script src="app/index.js" type="module"></script>
<!-- in case ES6 modules aren't supported -->
<script src="dist/bundle.js" defer nomodule></script>
</head>
<body>
<!-- ... -->
</body>
</html>

image03

很好,有了这个组合我们既可以在不支持的浏览器里加载经典的bundle也可以在支持的浏览器加载ESM。

Demo地址.

modules和script的不同

这里有一些陷阱。首先跑在ESM下的JS和一般的script标签里面的些许不同。Axel Rauschmayer总结了几点不同在他的新书 《探索ES6》里面。我推荐大家去看看,在这里我列举一下主要的不同。

  • ESM 默认在严格模式下运行(无须指明‘use strict
  • ’)
  • 顶级This是undefined
  • 顶级变量是相对于module的局部变量
  • ESM是在浏览器解析万HTML加载并且异步执行的。

我觉得这些特点有很大的优势,Modules是局部的,就不需要在外面包一层IIFE,也不用担心全局变量污染,还能少些很多‘use strict’ 声明。。

从页面性能来看(这个可能是最重要的点)模块默认是懒加载和懒执行。这样当我们使用type="module"时候,就避免了意外加入一段阻塞页面的script的可能并且也将不会有SPOF问题。当然我们也可以设置异步属性:async,他将替代默认的延迟属性。但是还是得强调,延迟是一个好的选择

1
2
3
4
5
6
7
8
9
10
11
12
<!-- not blocking with defer default behavior -->
<script src="app/index.js" type="module"></script>

<!-- executed after HTML is parsed -->
<script type="module">
console.log('js module');
</script>


<!-- executed immediately -->
<script>
console.log('standard module');
</script>

压缩 ES6代码

还没完,我们刚刚给出了一个压缩后的bundle给Chrome,也给Safari提供了独立的没有压缩的文件。但是我们怎么让他们体积变小呢?UglifyJS可以胜任这份工作吗?

事实证明不行,它还不能完全处理ES6的代码。UglifyJS有一个harmony版本,但是在发稿前我试了还不能很好的压缩我的那仨个文件。

1
2
3
4
5
6
7
$ uglifyjs dep-1.js -o dep-1.min.js
Parse error at dep-1.js:3,23
export default function() {
^
SyntaxError: Unexpected token: punc (()
// ..
FAIL: 1

但是,如今UglifyJS几乎在每一种工具链里面都能看到,怎么就不能处理用ES6写的代码呢?

通常的工作流是先用Babel这样的工具将其转化为ES5代码,然后再由UglifyJS来压缩ES5的代码。但是在这里,我想跳过这个步骤,我们在处理未来的问题。Chrome的ES6覆盖率达到97% Safari 预览版已经 100%了。

后来,我在Twitter圈里面问有没有好的办法来压缩ES6代码。Lars Graubner 给我介绍了Babili,使用它我们很容易压缩我们的ES6模块。

1
2
3
4
5
6
7
8
// app/dep-2.js

export default function() {
return 'Hello World. dependencies loaded.';
}

// dist/modules/dep-2.js
export default function(){return 'Hello World. dependencies loaded.'}

使用Babili CLI ,就更加方便了:

1
2
3
4
$ babili app -d dist/modules
app/dep-1.js -> dist/modules/dep-1.js
app/dep-2.js -> dist/modules/dep-2.js
app/index.js -> dist/modules/index.js

结果为:

1
2
3
4
5
6
7
8

$ ll dist
-rw-r--r-- 1 stefanjudis staff 856B Mar 16 22:32 bundle.js

$ ll dist/modules
-rw-r--r-- 1 stefanjudis staff 69B Mar 16 22:32 dep-1.js
-rw-r--r-- 1 stefanjudis staff 68B Mar 16 22:32 dep-2.js
-rw-r--r-- 1 stefanjudis staff 161B Mar 16 22:32 index.js

可以看到,压缩后的bundle依然有850B,但是单独三个文件加起来才300B。这里我忽略了GZIP压缩,因为这对于小文件来说影响甚微

用rel=preload? 加速ES6 modules

压缩单文件取得成功,这是一个 298B vs. 856B的优化。但是,我们还可以做得更好,让其速度更快。使用ESM我们可以加载更少的代码,但是当我们打开调试面板的瀑布图,我们可以看到文件请求是根据模块定义的依赖链循序的加载。

我们可不可以添加一个标签 <link rel="preload" as="script"> 用来提前告诉浏览器未来会发出一些额外的请求? Addy Osmani 的 Webpack preload plugin就是干这个的。那么有没有类似的东西应用到ESM来呢?以防你不知道rel="preload"怎么回事,可以看看Yoav Weiss 在 Smashing Magazine的文章

很不幸的是, 预加载的功能造ESM不容易实现,因为他们不像一般的script。问题在于一个 带有preload属性link元素怎么处理一个ESM?是不是要加载所有的以来文件?答案很明显的,但是如果要加入预加载指令浏览器端实现也会有很多问题需要解决。

如果你对这个话题感兴趣可以Domenic Denicolagithub inssue关于这个问题的讨论。但至少我们知道rel="preload"指令在处理一般script和ESM之间有很多不同。就在我写这篇问章的时候一个社区提了一个规范来解决这些问题,就是用一个新的rel="modulepreload"指令。未来我们怎么预加载,让我们拭目以待。

引入真实的依赖

三个文件不能构成真正的App,我们加入真正的依赖。刚好,Lodash提供了他函数所有的ESM实现,我用Babili压缩了他们。接下来我们修改index.js,让其引入Lodash方法。

1
2
3
4
5
6
7
8
9
10
11
import dep1 from './dep-1.js';
import isEmpty from './lodash/isEmpty.js';

function getComponent() {
const element = document.createElement('div');
element.innerHTML = dep1() + ' ' + isEmpty([]);

return element;
}

document.body.appendChild(getComponent());

关于isEmpty使用在这里不重要了,我们来看一下加入真实依赖发生了生什么。
image07

The use of isEmpty is trivial in this case, but let’s see what happens now after adding this dependency.

image07
请求一下子增长到了超过40个,在一般的WiFi环境下页面加载速度从100ms涨到400ms到800ms,并且所有装载文件加起来达到了大约12KB,没有被压缩。很不幸Safari不支持WebPagetest来跑分。

Chrome接收到的bundle文件差不多为8KB。
//images.contentful.com/256tjdsmm689/6xxfWBW9nqAeqQ8ck0MqU/62a74102e9247d785a61a84766356f51/image05.png

4KB的差距,足以让我们去查一下原因在哪。demo地址

只有在大文件的压缩效果好

如果你细心的话可以发现在Safari开发者工具的那张截图里面,transferred的文件大小实际上比source文件还要大。特别是在引入很多小的文件块的大型JS App里面这个变化更加明显,究其原因就是因为,GZIP只有在大文件的压缩效果好。

不久前Khan Academy 也发现了这个问题 当他在使用 HTTP/2做实验时. 加载更小的文件的方案是为了提高缓存的命中率,但是到头来,总是需要一个权衡并且取决于很多因素。
对于大型代码库,将其拆分成很多文件是有必要的,但是装载上千个不能更好的压缩的小文件的确不是一个好的办法。

Tree shaking 是个好孩子

还有一个事情值得一提,那就是得益于相对新颖的Tree shakin解决方案,在构建过程中可以去除屌那些没有使用或者被引用的代码。第一个支持这个方案的是Rollup,现在Webpack2.0也已经支持了——只要我们在bable里面禁止掉module选项

举个例子,我们修改dep-2.js 加入一个在dep-1.js 没有被调用的函数

1
2
3
4
5
6
7
export default function() {
return 'Hello World. dependencies loaded.';
}

export const unneededStuff = [
'unneeded stuff'
];

对于Babili来说,它将会简单的压缩文件然后在Safari 里面将接收带很多行没有被用到的代码。但是一个webpack或者Rollup的bundle文件将不会包含unneededStuff.Tree shaking提供了很强大的好处,绝对应该被用在真实的生产环境中来。

未来看似光明, 但是构建过程停滞在这里

所以,ESM将要到来,但是似乎不是所有的事情都会改变。我们为了保证压缩效果不希望装载数千个小文件,我们也不会抛弃这些带有可以剔除僵尸代码Tree shaking方案的构建方案。前端开发始终会是一个复杂的工程

最终要记住的是权衡是成功的关键。不要拆分所有的事情并期望它会带来很多改进。不要因为我们将要支持ESM就以为我们可以摆脱构建步骤和“打包策略”。在这里我们将会坚持使用我们的构建方案,并且继续使用“打包策略”来加载我们的文件以及我们的Javascript SDKs。

到目前为止,我不得不承认前端开发依然伟大。JS在进化,我们最终将有一个方案来解决Module融入到这个语言的问题。我等不及想看到它带给JS生态的影响,未来一两年内会出现最佳实践。

延伸阅读