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 | <html> |
上面个包含了三个js文件的bundle是有Webpack打包,这些文件都使用了ESM(ES6 modules)
1 | // app/index.js |
app运行的结果是 ‘Hello world’,说明所有的文件已经加载了。
装载bundle
1 | // webpack.config.js |
这三个文件实际上很小总共才347个字节。
1 | $ ll app |
当我帮用Webpack跑一遍后我得到的bundle大小是865字节,里面大概有500字节的母版。这些额外的字节是可以接受的,因为和我们平时在大多数生产环境放的代码比不算什么。感谢Webpack,我们能提前用上ESM。
1 | $ webpack |
新方案,使用原生支持的ESM
对于所有的不支持ESM的浏览器我们有“传统的bundle”,但是现在我们要玩一些更酷的东西了。首先在index.html里面添加一个新的script标签指定其类型为ESM:type="module"
1 |
|
但我们打开Chrome的时候,我们看到好像没有什么事情发生。
bundle首先加载了,展示出了“Hello World”,仅仅这样而已。但是这就是浏览器的高明之处,他会忽略掉他不理解的标记类型而不是抛出错误。Chrome也一样他忽略了他不知道类型的script标签。
现在,我们来看看Safari预览版的效果:
很遗憾,没有展示出“Hello World”。原因在于webpack打包的文件和ESM的不同:webpack能在构建时候就为我们找到文件依赖,但是ESM需要我们手动的去定义准确的文件目录。
1 | // app/index.js |
调整之后工作正常了,期望的是Safari 预览版,加载bundle和三个独立的modules,也就是说程序会执行两次。
解决这个问题可以使用 nomodule
属性,它可以在script标签上控制bundle的加载。
这个属性的细则很快也会出来,,一月底Safari预览版已经支持了。它会告诉不支持ESM的Safari在ESM不能执行时才执行回退的bundle。
1 | <html> |
很好,有了这个组合我们既可以在不支持的浏览器里加载经典的bundle也可以在支持的浏览器加载ESM。
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 | <!-- not blocking with defer default behavior --> |
压缩 ES6代码
还没完,我们刚刚给出了一个压缩后的bundle给Chrome,也给Safari提供了独立的没有压缩的文件。但是我们怎么让他们体积变小呢?UglifyJS可以胜任这份工作吗?
事实证明不行,它还不能完全处理ES6的代码。UglifyJS有一个harmony
版本,但是在发稿前我试了还不能很好的压缩我的那仨个文件。
1 | $ uglifyjs dep-1.js -o dep-1.min.js |
但是,如今UglifyJS几乎在每一种工具链里面都能看到,怎么就不能处理用ES6写的代码呢?
通常的工作流是先用Babel这样的工具将其转化为ES5代码,然后再由UglifyJS来压缩ES5的代码。但是在这里,我想跳过这个步骤,我们在处理未来的问题。Chrome的ES6覆盖率达到97% Safari 预览版已经 100%了。
后来,我在Twitter圈里面问有没有好的办法来压缩ES6代码。Lars Graubner 给我介绍了Babili,使用它我们很容易压缩我们的ES6模块。
1 | // app/dep-2.js |
使用Babili CLI ,就更加方便了:
1 | $ babili app -d dist/modules |
结果为:
1 |
|
可以看到,压缩后的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 Denicola在github inssue关于这个问题的讨论。但至少我们知道rel="preload"
指令在处理一般script和ESM之间有很多不同。就在我写这篇问章的时候一个社区提了一个规范来解决这些问题,就是用一个新的rel="modulepreload"
指令。未来我们怎么预加载,让我们拭目以待。
引入真实的依赖
三个文件不能构成真正的App,我们加入真正的依赖。刚好,Lodash提供了他函数所有的ESM实现,我用Babili压缩了他们。接下来我们修改index.js,让其引入Lodash
方法。
1 | import dep1 from './dep-1.js'; |
关于isEmpty
使用在这里不重要了,我们来看一下加入真实依赖发生了生什么。
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。
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 | export default function() { |
对于Babili来说,它将会简单的压缩文件然后在Safari 里面将接收带很多行没有被用到的代码。但是一个webpack或者Rollup的bundle文件将不会包含unneededStuff
.Tree shaking提供了很强大的好处,绝对应该被用在真实的生产环境中来。
未来看似光明, 但是构建过程停滞在这里
所以,ESM将要到来,但是似乎不是所有的事情都会改变。我们为了保证压缩效果不希望装载数千个小文件,我们也不会抛弃这些带有可以剔除僵尸代码Tree shaking方案的构建方案。前端开发始终会是一个复杂的工程。
最终要记住的是权衡是成功的关键。不要拆分所有的事情并期望它会带来很多改进。不要因为我们将要支持ESM就以为我们可以摆脱构建步骤和“打包策略”。在这里我们将会坚持使用我们的构建方案,并且继续使用“打包策略”来加载我们的文件以及我们的Javascript SDKs。
到目前为止,我不得不承认前端开发依然伟大。JS在进化,我们最终将有一个方案来解决Module融入到这个语言的问题。我等不及想看到它带给JS生态的影响,未来一两年内会出现最佳实践。