- 原文:History and Background of JavaScript Module Loaders
- 作者:Elias Carlston
- 翻译:leotso
介绍
Web 应用程序的应用程序逻辑不断从后端移到浏览器端。但是,由于富客户端 JavaScript 应用程序的规模变得更大,它们遇到了类似于多年来传统应用所面临的挑战:共享代码以便重用,同时保持架构的隔离分层,并且足够灵活以便于轻松扩展。
这些挑战的一个解决方案是开发 JavaScript 模块和模块加载系统。这篇文章将着重于比较和对比过去 5 - 10 年的 JavaScript 模块加载系统。
这是一个综合性的主题,因为它跨越了开发和部署之间的交集。下面请看我们的表演:
- 对导致需要开发模块加载程序的问题的描述
- 快速复习一下模块定义格式
- JavaScript模块加载器综述-比较和对比
- 轻量级加载器(curl, LABjs, almond)
- RequireJS
- Browserify
- Webpack
- SystemJS
- 结论
面临的问题
如果您只有几个 JavaScript 模块,只需在页面中通过 <script> 标记来加载它们,这是一个很好的解决方案。
<head> <title>Wagon</title> <!-- cart requires axle --> <script src=“connectors/axle.js”></script> <script src=“frames/cart.js”></script> <!-- wagon-wheel depends on abstract-rolling-thing --> <script src=“rolling-things/abstract-rolling-thing.js”></script> <script src=“rolling-things/wheels/wagon-wheel.js”></script> <!-- our-wagon-init hooks up completed wheels to axle --> <script src=“vehicles/wagon/our-wagon-init.js”></script> </head>
然而, <script> 建立了一个新的HTTP连接,对于小文件来说建立一个连接可能比传输文件本身花费更多的时间。脚本下载的过程会阻塞页面的渲染(某些时候会导致文档样式闪烁[Flash Of Unstyled Content])。而且,直到 IE8/FF3 有并行两个下载数的限制。
下载时间的问题很大程度上可以通过将一组简单的模块连接到一个文件中,以及代码压缩来解决。
<head> <title>Wagon</title> <script src=“build/wagon-bundle.js”></script> </head>
然而,这种性能的提升是以牺牲灵活性为代价的。如果你的模块之间有相互依赖关系,那么这种灵活性的缺乏可能是一个值得注意的问题。假设你增加了一个 vehicle 类型:
<head> <title>Skateboard</title> <script src=“connectors/axle.js”></script> <script src=“frames/board.js”></script> <!-- skateboard-wheel and ball-bearing both depend on abstract-rolling-thing --> <script src=“rolling-things/abstract-rolling-thing.js”></script> <script src=“rolling-things/wheels/skateboard-wheel.js”></script> <!-- but if skateboard-wheel also depends on ball-bearing --> <!-- then having this script tag here could cause a problem --> <script src=“rolling-things/ball-bearing.js”></script> <!-- connect wheels to axle and axle to frame --> <script src=“vehicles/skateboard/our-sk8bd-init.js”></script> </head>
取决于 skateboard-wheel.js 中构造函数的设计,代码可能会失败,因为 ball-bearing 的<script>标签没有介于 abstract-whell 和 skateboard-whell 之间。 因此,对于中等规模的项目的管理脚本排序变得冗长乏味,并且在一个足够大的项目(50+文件)中,就有可能建立一种依赖关系,对于那些不存在一个合理的顺序来满足所有依赖关系的情况。
模块化编程
模块化编程(Modular programming),我们在以前的文章中探讨过,恰好满足那些管理需求。不过不要高兴得太早,尽管我们拥有组织良好并且解耦的代码库(codebase),我们任然需要将它们传递(/交付)给用户才行。
Web的无状态和异步环境有利于用户体验,而不是程序员的便利。例如,用户可以在Web页面的所有图片下载完成之前就开始阅读。但是引导一个模块程序并不能如此宽容:模块的依赖项必须在加载之前可用。由于 http 无法保证取回文件需要多长时间,因此等待依赖项就会变得很棘手。
JavaScript模块格式以及加载器
Asynchronous Module Definition(AMD)API 的出现解决了异步问题。 AMD 利用了 JavaScript 处理过程中的两个阶段:解析(parsing/ interpretation,when the code is checked for syntax)和执行(execution,when the interpreted code is run)。
amdjs-api Houses the Asynchronous Module Definition API |
在语法上,依赖关系通过一组字符串数组声明。由模块加载器来检查是否已经加载了依赖项,如果没有,则执行异步加载(fetch)。只有当所有的依赖项都可用(递归地包括所有依赖项)时,加载程序才会执行有效负载的函数部分, 将已初始化的依赖对象作为参数传递给有效负载函数。
// myAMDModule.js define([‘myDependencyStringName’, ‘jQuery’], function (myDepObj, $) { //...module code... })
此技术解决了文件排序的问题。你又可以把所有你的模块文件打包到一个大文件中(以任何顺序),加载程序会处理好它们之间的依赖。但它也提供了其他优势:突然间,使用了像jQuery和Bootstrap这样的公共库的公共托管版本,突然之间产生了网络效应。例如,如果用户在他们的机器上已经有了谷歌的CDN版本的jQuery,那么这个加载器就不需要去取它了。
最终,AMD的“杀手级应用”甚至都不是一个产品特性。使用模块加载器的最佳和最意想不到好处是在开发期间引用单个模块文件,然后无缝地过渡到生产中环境中的单个、连接和压缩的文件。
在服务器端,http 获取很少,因为大多数文件已经存在于本地机器上。CommonJS格式使用这种假设来驱动同步模型。与 cjs 兼容的加载程序将使可用的函数称为 require(),它可以从任何普通的 JavaScript 中调用来加载模块。
// myCommonJSModule.js var myDepObj = require(‘myDependencyStringName’); var $ = require(‘jQuery’); if ($.version <= 1.6) alert(‘old JQ!’);
CJS去除了AMD中繁琐的 define()语法,使用Python等语言的后端程序员通常对CJS模式更为适应。CJS也可以更容易地执行静态分析。例如,在上面的例子中,分析器可以推断出从 require(‘jQuery‘) 返回的对象应该有一个名为“version”的属性。IDEs 可以将此用于重构和自动完成等有用的特性。
由于 require() 是一个阻塞函数,它会导致JavaScript解释器暂停当前代码并切换执行上下文到请求目标。在上面的示例中,控制台日志将不会执行直到 myDependencyStringName.js 中的代码完成加载和执行。
在浏览器中,当文件被处理时,连续下载每个依赖关系将导致一个小程序的加载时间无法接受。但这并不意味着没有人可以在浏览器中使用CJS。这个技巧来自于在构建时进行递归分析 —— 当文件必须被压缩和连接的时候,分析器可以遍历抽象语法树(Abstract Syntax Tree)来处理所有的依赖关系,并确保所有的东西都被绑定到最终文件中。
最后,ES6,多年来对JavaScript最重要的更新,增加了新的关键字“module”来提供对模块的内建支持。ES6 Module 包含了许多从 AMD 和 CJS 中获得的经验教训,但更类似于CJS,特别是在加载方面。
不适用模块加载程序的理由
目前,模块化编程和模块加载器已经成为富Web应用程序的同义词。但是使用模块化编程并非必须要使用模块加载器。以我的经验,只有复杂模块的相互依赖问题才能成为绝对需要一个模块加载器的条件,然而许多项目都有许多他们并不需要的复杂的加载基础设施。
在你的技术栈中增加任何新技术都是有开销的:它即增加了可能出错的事情的数量,又增加了你需要理解的东西。加载器的许多好处仅仅是好处而不是需求。要注意那些听起来很容易的好处 —— 你不需要它,犹如这是一种微妙的过早的优化。
首先,在没有模块加载程序的情况下运行您的项目。您将对您的代码有更大的控制和洞察。如果发现你永远都不需要,就这样,以后有需要再加一个并不难。
同样的“你不需要他”(YAGNI)的逻辑适用于你选择的任何模块加载器的特性。我见过许多项目使用AMD的模块,但没有任何好处(而且这方面也有很大的成本)。KISS(Keep It Simple and Stupid)。
轻量级模块加载程序
早期,随着 AMD 成为领先的客户端模块格式,模块加载生态系统纷纷开始支持它。在这波浪潮中包括 LAB.js, curljs 和 Almond。每一个都有不同的方式,但有很多共同之处:它们很小(1-4kb),而且遵循 Unix 哲学——做一件事情,并且把它做好。
almond A minimal AMD API implementation for use after optimized builds |
curl curl.js is small, fast, extensible module loader that handles AMD, CommonJS Modules/1.1, CSS, HTML/text, and legacy scripts. github.com/cujojs/curl/wiki |
他们所做的事情是加载文件,按顺序,然后调用一个用户提供的回调函数。下面是来自LABjs github的一个例子:
<script src="LAB.js"></script> <script> $LAB .script("https://remote.tld/jquery.js").wait() .script("/local/plugin1.jquery.js") .script("/local/plugin2.jquery.js").wait() .script("/local/init.js").wait(function(){ initMyPage(); }); </script>
在这个例子中,LAB 开始获取jQuery,等到加载并执行完成再加载plugin1和plugin2,等到完成再加载init.js。最终,当init.js加载完成,回调函数中调用initMyPage方法。
所有这些加载器都使用相同的技术机制来获取内容:它们在页面的DOM中使用src属性以动态的方式写一个<script>标签。当脚本触发onReadyStateChange事件时,加载程序知道内容已经准备好执行。
LAB和curl不再被积极维护,但是它们非常简单,在今天的浏览器中可能仍然有效。Almond仍然以满足需求简约版本维护者。
RequireJS
RequireJS 出现于2009年,轻量级加载器的后来者,但由于其先进的特性,它获得了最大的吸引力。
在核心层面,RequireJS 与其他轻量级加载器没有本质的不同。它将<script>标签写入DOM,侦听完成事件,然后递归地从结果中加载依赖项。令RequireJS与众不同的是它的扩展性 ———— 一些人可能说令人困惑 ———— 一组配置选项和操作语法糖。例如,有两种方式启动加载过程:要么将加载 RequireJS 脚本的<script>标签的名为data-main的属性指向一个init脚本文件…
<script src=“tools/require.js” data-main=“myAppInit.js” ></script>
要么在一个普通的JavaScript脚本中调用 require() 方法...
<script src=“tools/require.js”></script> <script> require([‘myAppInit’, ‘libs/jQuery’], function (myApp, $) { //... }); </script>
但是文档不建议使用这两种方法。后来,它揭示了原因是 data-main 和 require() 都不能保证 require.config 配置在它们执行之前完成。因此,内联 require 调用还是推荐嵌套在 configuration 调用中:
<script src=“tools/require.js”></script> <script> require([‘scripts/config‘], function() { require([‘myAppInit’, ‘libs/jQuery’], function (myApp, $) { //... }); }); </script>
Require 是配置选项中的一把瑞士军刀,但是,一种无意识的不确定性笼罩在他们相互影响的多种使用方式上。例如:如果设置了 baseUrl 配置选项,它将提供一个用于搜索文件的位置的前缀。这是明智的,但是,如果 baseUrl 没有指定,默认值将是加载 require.js 的 HTML 页面的位置,除非你使用 data-main,在这种情况下该路径变为 baseUrl!Maps, shims, paths 和 path fallback configs 提供更多解决复杂问题的机会,同时引入不相关的问题。
值得一提的是它的约定中的“gotcha”,即“模块ID”的概念。跟随Node中的约定,Require 期待你去掉依赖声明中的‘.js‘扩展名。如果 Require 发现一个模块ID 以‘.js‘结尾,或者以一个下线或者http协议开头,它切换出模块ID模式,并将字符串值视为文字路径。
如果我们如下修改上面的例子:
require([‘myAppInit.js’, ‘libs/jQuery’], function (myApp, $) { //... });
Require 将几乎肯定无法找到myAppInit,除非它恰好在 baseUrl/data-main 算法会返回的目录中。由于习惯性地输入‘.js‘的肌肉记忆,这个错误会很烦人,除非你有避免它的习惯。
尽管它有各种特性,但它的能力和灵活性赢得了广泛的支持,它仍然是今天最受欢迎的加载程序之一。
Borwserify
node-browserify browser-side require() the node.js way browserify.org |
Browserify 允许在浏览器中使用 CommonJS 格式化的模块。因此,Browserify 相比模块加载器更像一个模块打包工具: Browserify完全是一个构建时工具,生成一组可以加载到客户端的代码。
从一台安装了node和npm的构建机器开始,获取如下包:
npm install -g –save-dev browserify
用 CommonJS 格式编写你自己的模块,并且通过如下命令打包捆绑:
browserify entry-point.js -o bundle-name.js
Browserify 递归地查找 entry-point 中的所有依赖项并将它们组装到单独的文件中:
<script src=”bundle-name.js”></script>
根据服务器端的模式,Browserify 确实需要一些方法的改变。使用AMD,你可能压缩并合并“核心”代码,并且允许加载可选的模块。使用 Browserify,所有的模块必须被捆绑打包;但是,指定一个入口点允许基于相关的功能块来组织包,这对于带宽关注和模块化编程来说都是有意义的。
在2011年推出,Browserify 变得更加强大。
Webpack
Webpack 跟随 Browserify 的思想是一个模块打包机, 但是添加了足够的功能来替换您的构建系统。 除了CJS之外,Webpack不仅支持AMD和ES6格式,而且支持非脚本资产,如样式表和HTML模板。
webpack A bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting allows to load parts for the application on demand. Through "loaders," modules can be CommonJs, AMD, ES6 modules, CSS, Images, JSON, Coffeescript, LESS, ... and your custom stuff. webpack.js.org |
Webpack运行在一个名为“loaders”的概念上,它是注册用来处理文件类型的插件。例如, 一个 loader 可以处理 ES6 的转译(Webpack 2.0 handles ES6 natively),或者 SCSS 的编译。
Loaders将数据输入到一个“chunk”,它从一个入口点开始——概念上类似于一个Browserify包。一旦建立了Webpack,当资产发生变化时,chunk会自动重新生成。这个功能非常强大,您不必记着去编辑chunk。
让每个人都很兴奋的特性是热模块替换。一旦Webpack负责你的chunk,当你运行webpack-dev-server时,如果你改变了源代码,它就知道应该要修改浏览器中的代码了。与其他的源代码监控类似,webpack-dev-server不要求浏览器重新加载,因此它属于生产率工具的范畴,可以在开发过程中节约时间。
基本用法很简单,webpack安装方式类似于Browserify:
npm install -g –save-dev webpack
给webpack命令传递一个入口点和一个输出文件:
webpack ./entry-point.js bundle-name.js
如果你限制使用Webpack令人印象深刻的默认设置,不过这种能力总是要付出代价的。在一个项目中,我们组面临几个疑难问题 – 经过Webpack编译的ES6代码不工作,SCSS 能在本地工作但是无法编译到云端。此外,Webpack 的 loader 插件语法重载了require()方法的参数列表, 因此,在没有修改的情况下,它不会在Webpack之外工作(这意味着你无法在客户端和服务器端共享代码)。
Webpack把它的目标放在下一代的网络编译器上,但是可能得等待下一个版本。
Google Trends’ Take - Source
SystemJS
Wikipedia 将 polyfill 定义为 “additional code which provides facilities that are not built into a web browser”,但是 SystemJS 将 ES6 Module Loader Polyfill 拓展到了浏览器之外。 一个很好的例子说明了现代Javascript是如何在环境中运行的,ES6 Module Loader Polyfill 也可以通过 npm 在 Node 环境中使用。
systemjs Dynamic ES module loader |
SystemJS 可以被认为是 ES6 Module Loader Polyfill 的浏览器接口。它的实现方式类似于 RequireJS:通过<script>标签来引入SystemJS,在配饰对象上设置选项,然后调用 System.import()来加载模块:
<script src="system.js"></script> <script> // set our baseURL reference path System.config({ baseURL: ‘/app‘ }); // loads /app/main.js System.import(‘main.js‘); </script>
SystemJS 是 Angular 2 的推荐模块加载程序,所以它已经获得了社区支持。和 Webpack 一样,它通过加载器插件支持非JavaScript文件类型。和 Require 一样,SystemJS 也提供了一个简单工具,systemjs-builder,来打包和优化你的文件。
然而,与SystemJS相关的最强大的组件是JSPM(or JavaScript Package Manager)。其建立在 ES6 Module Loader Polyfill、npm(the Node package manager)之上,JSPM 承诺使同构的Javascript成为现实。对JSPM的完整描述超出了本文的范围,但是在 jspm.io 上有大量的文档,和许多 how-to 文章。
Comparison Table
Loader Category |
Local module format |
Server files |
Server module format |
Loader code |
Tiny loaders |
Vanilla JS |
Same structure as local files |
Same format as local files |
curl(entryPoint.js’) |
RequireJS |
AMD |
Concatenated and minified |
AMD |
requirejs(‘entryPoint.js’, function (eP) {// startup code}); |
Browserify |
CommonJS |
Concatenated and minified |
CommonJSinside AMDwrapper |
<script src=”browserifyBundle.js”></script> |
Webpack |
AMD and/or CommonJs (mixed OK) |
“Chunked” – Concat and minify into feature groups |
Webpack proprietary wrapper |
<script src=”webpackChunk.js”></script> |
SystemJS |
Vanilla, AMD, CommonJS, or ES6 |
same as local |
SystemJS proprietary wrapper |
System.import(‘entryPoint.js’).then(function (eP) {// startup code}); |
总结
与几年前相比,今天过多的模块加载器构成了一种选择过多的尴尬。 希望这篇文章能帮助您理解模块加载器的存在以及主要的不同之处。
在为您的下一个项目选择模块加载程序时,小心落入分析瘫痪的陷阱。 首先尝试最简单的解决方案:完全跳过一个加载程序并坚持使用简单的旧脚本标签是没有问题的。如果你真的需要一种模块加载程序,那么 RequireJS + Almond 是一种可靠的、高性能的、支持良好的选择。如果你需要 CommonJS 支持那么选择 Browersify。只有当你遇到用其他模块加载器完全无法解决的问题时才升级到 SystemJS 或 Webpack。这些前沿系统的文档仍然缺乏。所以,用你使用合适的模块加载程序节省下来的时间提供一些酷炫的特性吧!