译注:
在对代码的编译打包构建
这块,一直有两个英文单词概念比较容易混淆,bundler
跟packager
,按照我自己的理解,这两个单词侧重点不一样,后续统一翻译如下:
bundler
: 构建器,类似于babel-loader
的一个构建加载器,核心作用是完成一个目标单一的功能模块;packager
: 打包器,类似于webpack
,是基于多个构建器,外加上层的管理、组织及运行方案,统一对外输出加工后的代码成品。
正文从这里开始。
当刚开始开发CodeSandbox的时候,我完全是服务于React的开发的。期初,我们甚至将其命名为:“ReactSandbox”,但在最后一刻,我把它改成了CodeSandbox,这样我们就能扩展到其他的js框架了。我很自豪地说,我们现在在这方面取得了成功!
在过去的一周里,我们慢慢的推出了对其他框架的支持。我们现在支持React,Vue以及Preact的应用模板,并计划支持Angular,ReasonML和Svelte(如果您有其他建议,请告诉我)。为了实现这一点,我不得不从头开始重写构建器。在这篇文章中,我将主要解释新的构建器是如何建成的,以及为了实现这个目标我做了哪些抉择。
过去的方案
最初我们使用的构建器非常原始:对于每个请求的文件,我们首先会对其进行编译,执行,并缓存最终的结果。当一个文件发生改变的时候,我们会将所有依赖于 该文件 的文件的缓存结果丢弃掉,然后重新编译执行。这对Babel是有效的,但对其他需要异步编译的加载器(loader)(如Saas)则不起作用。在这套旧的系统中,创建像是Vue的编译器则会变得更加困难。很明显,如果我们想支持像是Vue这样的框架,我必须重新考虑构建的过程,而且这也给了我机会来改进构建器。
浏览器中的webpack(webpack in the browser)
我的第一个想法是让webpack运行在浏览器中。现在,几乎所有的命令行(CLI)都在使用webpack,而且如果webpack能运行在浏览器中,我们就不需要添加新的加载器(loader)。将加载功能配置进一个webpack.config.js文件也很容易,而且用户可以提供自己的配置。听起来很完美,对吧?我感觉也是!这听起来太完美,以至于看起来都不像是真的,最终我们发现,的确没有想象中的完美。
我将webpack在浏览器中运行了起来,然而,所有的代码在压缩丑化后都有3.5M。而且我不得不提供非常多的polyfills,同时,由于动态的加载需求,编译过程中抛出了十几个警告。此外,只有一半的加载器(loader)可以正常的工作。webpack假设是运行在一个Node.js环境的,结果我发现(起码在我看来)去模拟这个环境成本太大,无法从中获得优势。还有一个原因是,CodeSandbox是一个非常特殊的平台,如果我们自己去实现构建器,我们就掌握了优化该平台的绝对权限!
浏览器中的webpack loader API
我的第二个想法是实现自己的构建器(builder),但是loader的API跟webpack的loader非常接近。这样做的好处是,构建器看起来像是webpack,但是针对浏览器环境是做了优化的。编写加载器(loaders)将变得非常容易,我们可以使用现有的webpack loader,将所有的SSR(服务端渲染),Node以及生产逻辑(生产环境需要做些代码压缩混淆去注释之类的)都去掉,当然它也应该能在CodeSandbox中运行。另一个很大的优势是,我们假设的是浏览器环境,所以我们就可以随便的使用浏览器的API,比如:Web Workers, Service Workers以及代码分隔!
实现
在实际的实现过程中,我试图做到两全其美:跟webpack接近的loader API以及对CodeSandbox的全面优化。它应该比最初的构建器运行的更快,可以离线工作并且具有可扩展性。最终实现的构建器被分成了三个阶段:配置,编译跟执行。
配置
新的构建器是基于模板的模式实现的。对于每一个模板(当前已有的模板是React,Vue,Preact),我们会定义一个新的预设置(preset)。这些预设置包含了你在webpack中的配置项: aliases, 默认的loaders 以及默认的扩展项。预设的功能是配置一种类型的文件需要什么样的loaders,以及文件如何被解析。Preact类型的项目的预设如下:
import babelTranspiler from '../../transpilers/babel';
import jsonTranspiler from '../../transpilers/json';
import stylesTranspiler from '../../transpilers/css';
import sassTranspiler from '../../transpilers/sass';
import rawTranspiler from '../../transpilers/raw';
import stylusTranspiler from '../../transpilers/stylus';
import lessTranspiler from '../../transpilers/less';
import asyncTranspiler from './transpilers/async';
import Preset from '../';
const preactPreset = new Preset(
'preact-cli',
['js', 'jsx', 'ts', 'tsx', 'json', 'less', 'scss', 'sass', 'styl', 'css'],
{
preact$: 'preact',
// preact-compat aliases for supporting React dependencies:
react: 'preact-compat',
'react-dom': 'preact-compat',
'create-react-class': 'preact-compat/lib/create-react-class',
'react-addons-css-transition-group': 'preact-css-transition-group',
}
);
preactPreset.registerTranspiler(module => /\.jsx?$/.test(module.title), [
{
transpiler: babelTranspiler,
options: {
presets: [
// babel preset env starts with latest, then drops rules.
// We don't have env, so we just support latest
'latest',
'stage-1',
],
plugins: [
'transform-object-assign',
'transform-decorators-legacy',
['transform-react-jsx', { pragma: 'h' }],
[
'jsx-pragmatic',
{
module: 'preact',
export: 'h',
import: 'h',
},
],
],
},
},
]);
preactPreset.registerTranspiler(module => /\.s[a|c]ss/.test(module.title), [
{ transpiler: sassTranspiler },
{ transpiler: stylesTranspiler },
]);
preactPreset.registerTranspiler(module => /\.less/.test(module.title), [
{ transpiler: lessTranspiler },
{ transpiler: stylesTranspiler },
]);
preactPreset.registerTranspiler(module => /\.json/.test(module.title), [
{ transpiler: jsonTranspiler },
]);
preactPreset.registerTranspiler(module => /\.styl/.test(module.title), [
{ transpiler: stylusTranspiler },
{ transpiler: stylesTranspiler },
]);
// Support for !async statements
preactPreset.registerTranspiler(
() => false /* never load without explicit statement */,
[{ transpiler: asyncTranspiler }]
);
// This transpiler is backup for all other files
preactPreset.registerTranspiler(() => true, [{ transpiler: rawTranspiler }]);
export default preactPreset;
编译(Transpilation)
编译是最重要的阶段。就像名字所暗示的那样:它确实执行了编译,但是它也负责构建依赖关系图。对于每一个被编译的文件,我们遍历AST,搜索所有的require语句(require(‘xxx’);及import xxx from ‘yyy’;都是),并将他们添加进依赖关系树中。不只是发生在.js文件中,而且也发生在 TypeScript, Sass, LESS 以及 Stylus类型的文件中。在编译过程中构建依赖关系树的优点是,我们只需要构建一次AST。
编译的输出被保存在一个名叫TranspiledModule的类模块中。一个文件可以与多个TranspiledModules关联,因为文件可以以不同的方式被引用。比如,
require('raw-loader!./Hello.js')
跟:
require('./Hello.js')
并不一样。
Web Workers
一个非常重要的改进是,几乎所有的编译都是在一个web worker池管理池中并行进行的,worker的数量则取决于你的电脑的核数。这也就意味着我们使用不同的线程来做编译,因此编译默认是并行执行的。这种方式消除了UI线程的附在(减少了卡顿),也大大提高了编译时间:对我来说,编译时间(还有加载时间)缩短了2到4倍!编译是构建器三个阶段中唯一异步执行的。
将Babel迁移到一个web-worker中会对性能产生多大的影响?下面是一个在页面滚动的时候处理一个大型的UMD文件的示例。
— Brian Vaughn (@brian_d_vaughn) August 26, 2017
代码分割(code splitting)
每个loader都会根据其使用情况被动态加载。如果您的CodeSandbox项目只包含JavaScript文件,我们将只下载Babel loader。这节省了很多时间和带宽,因为编译器文件往往非常大。
离线支持(Offline Support)
构建器的需求之一是能支持离线工作,这就是为什么所有未使用到的loaders仍被service sorker在后台下载的原因。在一个项目里工作的时候是没有额外的依赖项的,因此在加载所有的编译器后,不管在何时何地都是可以做离线打包的。
执行(Evaluation)
尽管我已经把这个构建器称之为一个“构建器”,但是目前为止还没有真正的构建发生。我们已经可以拿到所有的代码,剩下的唯一的任务就是执行正确的文件。我们使用简单的eval来获取入口文件执行后的值,我们提供了一个可追踪的依赖(stubbed require)来获取到正确的TranspiledModule,然后要么执行它,要么返回缓存,如果缓存存在的话。
“热模块重新加载”(’Hot Module Reloading’)
我们对编译以及执行的输出做了缓存。当一个文件发生更改时,我们会丢弃该文件的已缓存的源代码,该文件和所有父文件(所有依赖该文件的文件)的编译结果。基于这一点,我们会再次从入口重新进行编译及执行。我给HMR加了引号,因为这与真正的HMR解决方案并不一样。我们没有module.hot,因为要让HMR在沙箱中工作需要单独设置,而我只希望它在非CodeSandbox环境中工作。(更新:从CodeSandbox 2.5版本开始,我们已经开始支持module.hot)
结论
我为这个构建器感到骄傲,因为它允许我们做更多的事情,而且比以前的版本运行速度要快得多。基于这个新的实现,我们做到了两全其美:我们有跟webpack接近的API,并且它针对CodeSandbox和浏览器进行了优化。当然,它不像webpack这样的解决方案那么先进,但是它却非常适合CodeSandbox。现在,添加一个新模板不会花费很长时间,都不需要超过一个小时,而且对于loaders来说也很容易从它们的webpack对应项导入。这使得我们在未来有非常大的灵活性。
性能(Performance)
新的构建起也提高了性能。因为要编译器文件要下载,所以初始的加载时间可能会比较长。不过所有的编译器都是使用service worker以及浏览器缓存来做缓存的,那么在后面的尝试中会快很多。在我的2015版的13寸的Macbook上,初始编译需要1到2秒,但是重新编译只需要35到40毫秒!这些测试是在基于Redux的TodoMVC实现上运行的。它运行的更快,是因为现在编译是在不同的线程上并行发生的,并且依赖项在被获取的时候编译已经开始执行了。
源代码(Source)
如果你想看到真正的成品,你可以在这里找到源代码。这里是Manager类,它负责跟沙箱(Sandbox),预设置(Preset),以及所有的TranspiledModules进行连接。
未来(Future)
新的构建器带来了许多令人兴奋的可能性。最大的两个是自定义模板支持和完全离线支持。
定制模板支持(Custom Template Support)
我们现在有的所有这些loaders,比如Sass和TypeScript,如果我们也可以为React沙盒解锁这些程序,那就太好了。CodeSandbox上应该有一个按钮来弹出一个沙箱,它允许你指定loaders和自定义Babel配置之类的事情。这方面的主要工作已经完成,我们接下来只需要实现相关API来支持它。
完全的离线支持(Full Offline Support)
所有操作都已经支持离线工作,但要获得完全的离线支持,我们需要允许您离线保存CodeSandbox上的项目。这允许您永远离线处理项目,并随时上传到CodeSandbox。唯一需要网络连接的功能是npm依赖项,但我们已经缓存了所有的npm模块。当您在计划去旅行或航班时,我们会给您提供预先缓存组合依赖模块的选项,这样即使在飞机上离线也能正常撸代码了。