模块化伴随着前端的发展,从无到有,从“伪”到“真”,再到后来的有成熟体系和规范并且适用于浏览器环境下的模块化。让我们来看看模块化到底经历了什么。
什么是模块化?为什么需要模块化?
在最初的前端,js 只负责比较简单的交互,代码量非常有限,我们将所有代码都混在一起。但是随着前端技术的发展,js 可以做的事情也越来越多,这就导致 js 代码量激增。
这时对于一个复杂的应用程序,与其将所有代码一股脑地放在一个文件当中,不如按照一定的语法,遵循特定的规范将一个庞大的文件拆分为几个独立的文件。
这些文件应该具有相互独立和功能逻辑单一的特性,对外暴露数据或接口,在需要的时候再进行导入或引用。这就是模块化的概念。
前端模块化发展主要经历了三个阶段:
- 早期“伪”模块化时代;
- 多种多种规范标准时代;
- ES 原生时代。
“伪”模块化时代
借助函数作用域来模拟实现“伪”模块化,我称其为函数模式,即将不同功能封装成不同的函数:
function fn1() {
//...
}
function fn2() {
//...
}
其实这样的方式根本连“伪”都不算,各个函数在同一个文件中,混乱地互相调用,而且存在命名冲突和变量污染的问题,致命的缺点让开发者很快就将其抛弃。
很快就出现了第二种方式,姑且称它为对象模式,即利用对象实现“伪”模块化:
const module1 = {
data1: "data1",
fn1: function () {
//...
},
};
const module2 = {
data2: "data2",
fn2: function () {
//...
},
};
这种方式稍微有了那么一点模块的雏形,可是这样的方式也带来一个大的问题,数据安全性非常低,对象内部成员可以随意被改写。
如:
module2.data2 = "data1";
数据被随意改写会造成很多的问题,首先就是极容易造成 bug,勤劳的前端开发者怎么会任由 bug 横行呢。
在之前关于闭包的文章里有这样一句话“闭包简直就是为解决数据访问性问题而生的”。
我们通过立即执行函数构造一个私有的作用域,再通过闭包的特性,将需要对外暴露的数据和接口输出。
代码如下:
(function (window) {
var data = "data";
function showData() {
console.log(`data is ${data}`);
}
function updateData() {
data = "newData";
console.log(`data is ${data} `);
}
window.module1 = { showData, updateData };
})(window);
这样的实现,数据 data
完全做到了私有和独立,不会受到外界任何变量的干扰,外界无法随意修改 data
值,
只能通过调用模块module1
暴露给外界(window
)的函数修改 data
值。
module1.showData(); // data is data
修改 data 值的途径,也只能由模块 module1 提供:
module1.updateData(); // data is newData
jQuery
库也是如此方式实现的。
其实 jQuery
的做法就是使用了一个匿名函数形成一个闭包,然后自执行,所有逻辑都在这个闭包中完成,这样不会污染全局变量,也无法在其他地方访问闭包内的变量。最后将 jQuery
对象进行暴露,这样在外部就可以通过 jQuery
或者 $
访问闭包内的其他变量了。
代码片段如下:
(function (window, undefined) {
//...
if (typeof window === "object" && typeof window.document === "object") {
window.jQuery = window.$ = jQuery;
}
})(window);
很多人(包括我)最开始不能理解为什么自执行函数要传入 window
,主要有两个原因:
- 使
window
又全局变量变成局部变量,当内部代码访问window
对象时,不用顺着作用域链逐级查找,可以更快的访问window
;- 为了压缩代码时更好的优化;
另外传入 undefined 一部分原因是因为压缩优化,另一部分是由于一些低版本浏览器的兼容需要,不展开说了。
此时,模块化已经初具规模,已经可以实现一些基础功能。事实上,这就是现代模块化方案的基石。
多种规范标准时代 —— CommonJS
Node.js 无疑对前端的发展具有极大的促进作用,其中 CommonJS 模块化规范更是颠覆了人们对于模块化的认知:
Node.js应用由模块(采用的 CommonJS 模块规范)组成。即一个文件就是一个模块,拥有自己独立的作用域,变量和方法都是存在独立作用域内。
Node.js 中的 CommonJS 规范在浏览器端实现依靠的就是 module.exports
和 require
方法。
CommonJS 规范规定,每个模块内部,module
变量代表当前模块。这个变量是一个对象,它的 exports
属性(即 module.exports
)是对外的接口。
加载某个模块,其实是加载该模块的 module.exports
属性。使用 require
方法加载模块。
CommonJS 模块的特点如下:
- 所有代码都运行在模块作用域内,不会污染全局作用域;
- 模块加载的顺序,按照其在代码中引入的顺序;
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果会被缓存,之后不论加载几次,都会直接读取缓存。清除缓存后方可再次运行;
-
module.exports
属性输出的是值的拷贝,一旦输出操作完成,模块内发生的任何变化不会影响到已输出的值; - 注意
module.exports
和exports
的用法以及区别;
module.exports && exports 详解
module.exports:
module.exports
属性表示当前模块对外输出的接口,当其他文件加载该模块,实际上就是读取module.exports
这个属性;exports
node 为每一个模块提供了一个exports
对象 ,这个exports
对象的引用指向module.exports
。这相当于隐式的声明var exports = module.exports;
。
如此一来,在对外输出时,可以在这个变量上添加属性方法。
例如:exports.test = function () { // ... };
注意:不能把exports
直接指向一个值(exports = xxx
方式赋值),这样会改变exports
的引用地址,相当于切断了exports
和module.exports
的关系。
总结下 module.exports 和 exports 的区别就是:
-
exports = module.exports = {}
,exports
是module.exports
的一个引用 -
require
引用模块后,返回给调用者的是module.exports
而不是exports
;
3.exports.xxx
的方式更新属性,相当于修改了module.exports
,那么该属性对调用模块可见; -
exprots = xxx
的方式相当于给exports
重新赋值,改变引用,失去了之前的module.exports
引用,该属性对调用模块不可见;
如果你还是分不清,那么就使用 module.exports
。
多种规范标准时代 —— AMD
AMD 规范,全称为:Asynchronous Module Definition。存在即合理,从 Node.js 搬过来的 CommonJS 已经可以帮助前端实现模块化了,那 AMD 存在的意义又是什么呢?
这还要从 Node.js 自身说起,Node.js 运行于服务器端,文件都存在本地磁盘中,不需要去发起网络请求异步加载,所以 CommonJS 规范加载模块是同步的,对于 Node.js 来说自然没有问题,但是应用到浏览器环境中就显然不太合适了。 AMD 规范就是解决这一问题的。
AMD 不同于 CommonJS 规范,是异步的,可以说是专为浏览器环境定制的。AMD 规范中定义了如何创建模块、如何输出、如何导入依赖。
更加友好的是,require.js 库为我们准备好了一切,我们只需要通过define
方法,定义为模块;再通过require
方法,加载模块。
因为是异步的,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
define 定义模块
define 方法的第一个参数可以注入一些依赖的其他模块,如 jQuery 等
define([], function () {
// 模块可以直接返回函数,也可返回对象
return {
fn() {
// ...
},
};
});
AMD 规范也采用 require 方法加载模块
但是不同于 CommonJS 规范,它要求两个参数:
第一个参数就是要加载的模块的数组集合,第二个参数就是加载成功后的回调函数。
require([module], callback);
有精力的同学可以看看 require.js 的源码。
从源码中可以看到,require.js 在全局定义了 define
和 require
。并且在最外层包裹的是一个自执行函数,将 global
, setTimeout
传入其中。
以下为截取 define
方法内的一小段代码:
if (!deps && isFunction(callback)) {
deps = [];
if (callback.length) {
callback
.toString()
.replace(commentRegExp, commentReplace)
.replace(cjsRequireRegExp, function (match, dep) {
deps.push(dep);
});
deps = (callback.length === 1
? ["require"]
: ["require", "exports", "module"]
).concat(deps);
}
}
define
方法内部可以大致理解为对依赖的收集,deps.push(dep)
。
而 require
的主要作用是根据依赖创建 script 标签,请求模块,对模块进行加载和执行。值得注意的是所有模块在加载完成后都会执行 removeScript
方法。
该方法会将加载完成后的 script 标签移除,这也就是为什么require
中生成 script 标签加载模块,但是在代码中并没有出现这些标签,奥秘就在removeScript
中。
require.js 的源码非常绕,推荐有一些源码阅读经验的同学再尝试阅读。
多种规范标准时代 —— CMD
CMD 规范全称为:Common Module Definition,综合了 CommonJS 和 AMD 规范的特点,推崇 as lazy as possible。代表库为 sea.js 。
CMD 规范和 CMD 规范不同之处:
- AMD 需要异步加载模块,而 CMD 可以同步可以异步;
- CMD 推崇依赖就近,AMD 推崇依赖前置。
多种规范标准时代 —— UMD
UMD 叫做通用模块定义规范(Universal Module Definition)。
它可以通过运行编译时让同一个代码模块在使用 CommonJs、CMD 甚至是 AMD 的项目中运行。
这样就使得 JavaScript 包运行在浏览器端、服务区端甚至是 APP 端都只需要遵守同一个写法就行了。
他的规范就是综合其他的规范,没有自己专有得规范。
代码如下:
(function (root, factory) {
if (typeof define === "function" && define.amd) {
// AMD 规范
define(["b"], factory);
} else if (typeof module === "object" && module.exports) {
// 类 Node 环境,并不支持完全严格的 CommonJS 规范
// 但是属于 CommonJS-like 环境,支持 module.exports 用法
module.exports = factory(require("b"));
} else {
// 浏览器环境
root.returnExports = factory(root.b);
}
})(this, function (b) {
// 返回值作为 export 内容
return {};
});
在定义模块得时候会检测当前得环境,将不同的模块定义方式转换为同一种写法。
ES 原生模块化
ES 模块化最大的两个特点是:
1.ES 模块化规范中模块输出的是值的引用
复习下 CommonJS 规范下的使用:
module1.js 中:
var data = "data";
function updateData() {
data = "newData";
}
module.exports = {
data: data,
updateData: updateData,
};
index.js 中:
var myData = require("./module1").data;
var updateData = require("./module1").updateData;
console.log(myData); // data
updateData();
console.log(myData); // data
因为 CommonJS 规范下,输出的值只是拷贝,通过 updateData
方法改变了模块内的 data
的值,但是data
和 myData
并没有任何关联,只是一份拷贝,所以模块内的变量值修改,也就不会影响到修改之前就已经拷贝过来的 myData
啦。
再看 ES 模块化规范的表现
module1.js:
let data = "data";
function updateData() {
data = "newData";
}
export { data, updateData };
index.js:
import { data, updateData } from "./module1.js";
console.log(data); // data
updateData();
console.log(data); // newData
由于 ES 模块化规范中导出的值是引用,所以不论何时修改模块中的变量,在外部都会有体现。
2.静态化,编译时就确定模块之间的关系,每个模块的输入和输出变量也是确定的
ES 模块化设计成静态的目的何在?
首要目的就是为了实现 tree shaking 提升运行性能(下面会简单说 tree shaking)。
ES 模块化的静态特性也带来了局限:
-
import
依赖必须在文件顶部; -
export
导出的变量类型严格限制; - 依赖不可以动态确定。
ES 的 export
和 export default
要用谁?
ES 模块化导出有 export
和 export default
两种。这里我们建议减少使用 export default
导出!
原因很简单:
- 其一
export default
导出整体对象,不利于 tree shaking; - 其二
export default
导出的结果可以随意命名,不利于代码管理;
tree shaking
tree shaking 就是通过减少web项目中 JavaScript 的无用代码,以达到减少用户打开页面所需的等待时间,来增强用户体验。对于消除无用代码,并不是 JavaScript 专利,事实上业界对于该项操作有一个名字,叫做 DCE(dead code elemination) ,然而与其说 tree shaking 是 DCE 的一种实现,不如说 tree shaking 从另外一个思路达到了DCE的目的。
无用代码的减少意味着更小的代码体积,缩减 bundle size,从而获得更好的用户体验。
如何实现 tree shaking?
两个先决条件:
- 首先既然要实现的是减少浏览器下载的资源大小,因此要 tree shaking 的环境必然不能是浏览器,一般宿主环境是 Node;
- 其次,如果 JavaScript 是模块化的,那么必须遵从的是 ES 模块化规范,原因上面已经提到过了。
另外需要注意的是,对于单个文件和模块化来说 webpack 要实现 tree-shaking 必须依赖 uglifyJs。这里就不展开过多的阐述了,想了解更多内容可以阅读这篇文章《Tree-Shaking性能优化实践 - 原理篇》
目前各大浏览器早已在新版本中支持 ES 模块化了。如果我们想在浏览器中使用原生 ES 模块方案,只需要在 script 标签上添加一个 type="module"
属性。通过该属性,浏览器知道这个文件是以模块化的方式运行的。
<script type="module">
import module1 from './module1'
</script>
而对于不支持的浏览器,需要通过 nomodule 属性来指定某脚本为 fallback 方案:
<script nomodule>
alert('你的浏览器不支持 ES Module,请先升级!')
</script>
Node 也从 9.0 版本开始支持 ES 模块,可见 ES 模块化由于它的开箱即用的 tree shaking 和未来浏览器兼容性支持等优点,已经渐渐成为web项目的首选。