伴随着移动互联的大潮,当今越来越多的网站已经从网页模式进化到了 Webapp 模式。它们运行在现代的高级浏览器里,使用 HTML5、 CSS3、 ES6等更新的技术来开发丰富的功能,网页已经不仅仅是完成浏览的基本需求,并且webapp通常是一个单页面应用,每一个视图通过异步的方式加载,这导致页面初始化和使用过程中会加载越来越多的JavaScript 代码,这给前端开发的流程和资源组织带来了巨大的挑战。 前端开发和其他开发工作的主要区别,首先是前端是基于多语言、多层次的编码和组织工作,其次前端产品的交付是基于浏览器,这些资源是通过增量加载的方式运行到浏览器端,如何在开发环境组织好这些碎片化的代码和资源,并且保证他们在浏览器端快速、优雅的加载和更新,就需要一个模块化系统,这个理想中的模块化系统是前端工程师多年来一直探索的难题。 前端模块要在客户端中执行,所以他们需要加载到浏览器中。模块的加载和传输,我们首先能想到两种极端的方式,一种是每个模块文件都单独请求,另一种是把所有模块打包成一个文件然后只请求一次。显而易见,每个模块都发起单独的请求造成了请求次数过多,导致应用启动速度慢;一次请求加载所有模块导致流量浪费、初始化过程慢。这两种方式都不是好的解决方案,它们过于简单粗暴。分块传输,按需进行懒加载,在实际用到某些模块的时候再增量更新,才是较为合理的模块加载方案。
一、script标签
代码语言:javascript复制<script src="module1.js"></script>
<script src="module2.js"></script>
<script src="module3.js"></script>
这是最原始的 JavaScript 文件加载方式,如果把每一个文件看做是一个模块,那么他们的接口通常是暴露在全局作用域下,也就是定义在 window 对象中,不同模块的接口调用都是一个作用域中,一些复杂的框架,会使用命名空间的概念来组织这些模块的接口,典型的例子如 YUI 库。
缺点:
- 全局作用域下容易造成变量冲突
- 文件只能按照
<script>
的书写顺序进行加载 - 开发人员必须主观解决模块和代码库的依赖关系
二、CommonJS
服务器端的 Node.js 遵循 CommonJS规范,该规范的核心思想是允许模块通过 require 方法来同步加载所要依赖的其他模块,然后通过 exports 或 module.exports 来导出需要暴露的接口。CommonJS规范
代码语言:javascript复制// moduleA.js
module.exports = function( value ){
return value * 2;
}
// moduleB.js
var multiplyBy2 = require('./moduleA');
var result = multiplyBy2(4);
优点:
- 解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块在它自身的命名空间中执行
缺点:
- 同步加载模块,不能非阻塞的并行加载多个模块
实现:
- 服务器端的 Node.js
- Browserify
补充,exports与module.exports区别: 为了方便,Node为每个模块提供一个exports变量,指向module.exports。var exports = module.exports;造成的结果是,在对外输出模块接口时,可以向exports对象添加方法。
代码语言:javascript复制 exports.area = function (r) {
return Math.PI * r * r;
};
注意,不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系。
代码语言:javascript复制exports = function(x) {console.log(x)};
下面的写法也是无效的。
代码语言:javascript复制exports.hello = function() {
return 'hello';
};
module.exports = 'Hello world';
上面代码中,hello函数是无法对外输出的,因为module.exports被重新赋值了。这意味着,如果一个模块的对外接口,就是一个单一的值,不能使用exports输出,只能使用module.exports输出。
代码语言:javascript复制module.exports = function (x){ console.log(x);};
如果你觉得,exports与module.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports。
三、AMD
AMD 定义了一套 JavaScript 模块依赖异步加载标准,来解决同步加载的问题。https://github.com/amdjs/amdjs-api/wiki/AMD
代码语言:javascript复制define(id?: String, dependencies?: String[], factory: Function|Object);
- id 是模块的名字,它是可选的参数
- dependencies 指定了所要依赖的模块列表,它是一个数组,也是可选的参数,每个依赖的模块的输出将作为参数一次传入 factory 中。如果没有指定dependencies,那么它的默认值是 [“require”, “exports”, “module”]。
define(function(require, exports, module) {})
- factory 是最后一个参数,它包裹了模块的具体实现,它是一个函数或者对象。如果是函数,那么它的返回值就是模块的输出接口或值。
define("module", ["dep1", "dep2"], function(d1, d2) {
return someExportedValue;
});
注意:主模块module是在dep1、dep2加载完成并执行结束后才执行,即使处理函数中没有用到这两个模块,dep1、dep2一旦被依赖引用就会被加载执行!
代码语言:javascript复制require(["module", "../file"], function(module, file) { /* ... */ });
在模块定义内部引用依赖:
代码语言:javascript复制define(function(require) {
var $ = require('jquery');
$('body').text('hello world');
});
优点:
- 适合在浏览器环境中异步加载模块,可以并行加载多个模块
缺点:
- 提高了开发成本,代码的阅读和书写比较困难,模块定义方式的语义不顺畅,不符合通用的模块化思维方式
实现:
- RequireJS
- curl
四、CMD
CMD规范和AMD很相似,尽量把持简单,并与 CommonJS 和 Node.js 的 Modules 规范保持了很大的兼容性。遵循按需执行依赖的原则,只有在用到某个模块的时候才会执行模块内部的require语句,同时加载完某个依赖文件后并不立即执行,在所有依赖模块加载完成后进入主模块逻辑,遇到模块运行语句的时候才执行对应的模块https://github.com/seajs/seajs/issues/242
代码语言:javascript复制define(function(require, exports, module) {
var $ = require('jquery');
var Spinning = require('./spinning');
exports.doSomething = ...
module.exports = ...
})
优点:
- 依赖就近,延迟执行
- 可以很容易在 Node.js 中运行
缺点:
- 依赖 SPM 打包,模块的加载逻辑偏重
实现:
- Sea.js
- coolie
五、ES6模块
EcmaScript6 标准增加了 JavaScript 语言层面的模块体系定义。ES6 模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。
代码语言:javascript复制import "jquery";
export function doStuff() {}
module "localModule" {}
优点:
- 容易进行静态分析
- 面向未来的 EcmaScript 标准
缺点:
- 原生浏览器端还没有实现该标准
- 全新的命令字,新版的 Node.js才支持
实现:
- Babel
补充:
Webpack中提出了tree-shaking,其依赖ES6 modules的静态特性得以实现,ES6 modules 的 import 和 export statements相比完全动态的CommonJS、require,有着本质的区别:
- 只能作为模块顶层的语句出现,不能出现在 function 里面或是 if 里面。(ECMA-262 15.2)
- import 的模块名只能是字符串常量。(ECMA-262 15.2.2)
- 不管 import 的语句出现的位置在哪里,在模块初始化的时候所有的 import 都必须已经导入完成。换句话说,ES6 imports are hoisted。(ECMA-262 15.2.1.16.4 - 8.a)
- import binding 是 immutable 的,类似 const。比如说你不能 import { a } from ‘./a’ 然后给 a 赋值个其他什么东西。(ECMA-262 15.2.1.16.4 - 12.c.3)
注意:Babel 6的规范选择是使用预设es2015
:
{
presets: ['es2015'],
}
但是,该预设包含插件transform-es2015-modules-commonjs
,这意味着Babel会输出CommonJS模块,而webpack将无法进行tree-shaking。想要的是Babel的es2015
,但没有插件transform-es2015-modules-commonjs
。目前,唯一的办法就是提到配置数据中的所有预置插件,除了我们要排除的插件。预设的来源是GitHub,所以基本上是复制和粘贴的情况:
{
plugins: [
'transform-es2015-template-literals',
'transform-es2015-literals',
'transform-es2015-function-name',
'transform-es2015-arrow-functions',
'transform-es2015-block-scoped-functions',
'transform-es2015-classes',
'transform-es2015-object-super',
'transform-es2015-shorthand-properties',
'transform-es2015-computed-properties',
'transform-es2015-for-of',
'transform-es2015-sticky-regex',
'transform-es2015-unicode-regex',
'check-es2015-constants',
'transform-es2015-spread',
'transform-es2015-parameters',
'transform-es2015-destructuring',
'transform-es2015-block-scoping',
'transform-es2015-typeof-symbol',
['transform-regenerator', { async: false, asyncGenerators: false }],
],
}
六、Webpack
Webpack 是一个前端资源模块化管理和打包工具。它将根据模块的依赖关系进行静态分析,然后将这些模块按照指定的规则生成对应的静态资源。还可以将按需加载的模块进行代码分隔,等到实际需要的时候再异步加载。通过 loader 的转换,任何形式的资源都可以视作模块,比如 CommonJs 模块、 AMD 模块、 ES6 模块、CSS、图片、 JSON、Coffeescript、 LESS 等。
优点:
- 将依赖树拆分成按需加载的块
- 初始化加载的耗时尽量少
- 各种静态资源都可以视作模块
- 将第三方库整合成模块的能力
- 可以自定义打包逻辑的能力
- 适合大项目,无论是单页还是多页的 Web 应用