CommonJS,ES6 Module以及webpack模块打包原理
模块化历程
一个模块就是实现特定功能的文件,有了模块就可以更方便地使用别人的代码,想要什么功能就加载什么模块。前端模块经历的过程如下:
-
函数封装
在一个文件里面编写几个相关的函数就是最开始的模块function fn1(){ //do something } function fn2(){ //do other something }
这样,在需要使用的地方加载这个文件调用函数就可以了
缺点:无法保证不同模块之间的变量名不会发生冲突。 -
对象
为了解决上面的问题,对象的写法应运而生,可以把所有的模块成员封装在一个对象上let myModule = { var1: 1, var2: 2, fn1: function(){ }, fn2: function(){ } }
这样就可以在需要调用模块的地方引用这个文件然后
myModule.fn2()
这样使用就好了,主要保证模块名唯一就可以避免变量的污染。
缺点:外部引用的时候可以任意修改模块内部的成员值,myModule.var1 = 100
,这样就会产生意外的安全问题。 -
立即执行函数
可以通过立即执行函数来达到隐藏细节的目的(同上面闭包中描述的封装变量来达到构建“私有变量”的目的)let myModule = (function(){ var1: 1, var2: 2, fn1: function(){ }, fn2: function(){ } return { fn1: fn1, fn2: fn2 } })()
这样,在外部引用模块的时候就不能修改模块内部的变量、函数了。
上述做法(立即执行函数)是我们模块化的基础。
开始模块化开发的一个前提就是大家必须以同样的方式编写模块,否则各行其道就乱套了,于是就出现了各种模块化规范:CommonJS、ES6 Module、AMD、CMD等。
CommonJS
CommonJS 是 2009 年提出的包含模块、文件、IO、控制台在内的一系列标准。Node.js 的实现中采用了 CommonJS 标准的一部分,而非它的原始定义,现在一般谈到 CommonJS 其实是 Node.js 中的版本。
![](/qrcode.jpg)
CommonJS 最初只为服务端而设计(因为在服务端需要与操作系统和其他应用程序互动,否则无法编程),直到有了 Browserify(一个运行在Node.js环境下的模块打包工具,可以将 CommonJS 模块打包为浏览器可以运行的单个文件),这也就意味着客户端的代码也可以遵循 CommonJS 标准来编写了。而且 Node.js 的包管理器 npm 允许开发者获取他人的代码库,以及发布自己的代码库,这种共享的传播方式使 CommonJS 在前端开发更加流行起来。
模块
CommonJS 中规定每个文件就是一个模块,会形成一个属于模块自身的作用域,所有变量只有自己能访问,外部不可见。
导出
CommonJS 中通过 module.exports
(简化的为 exports
)导出模块中内容,导出是模块向外暴露自身的唯一方式。
注意:浏览器是无法识别 CommonJS 模块的,所有以下这些 demo 需要在 Node.js 环境中去测试。
module.exports = {
name: 'calculator',
add: (a, b) => a + b
}
可以理解为,CommonJS 模块内部会用一个 module
对象存放当前模块的信息,其中 module.exports
用来指定该模块要对外暴露的内容,简化的导出方式可以直接使用 exports
exports.name = 'calculator'
exports.add = (a, b) => a + b
这两段代码实现效果上没有任何不同,其内在机制是将 exports
指向 module.exports
每个模块的最开始定义可以理解为:
let module = {
// 模块内容
// ...
exports: {
} // 模块要暴露内容的地方
}
let exports = module.exports
因此,在使用 exports
时要注意不要直接给它赋值,否则会切断它和 module.exports
的关系而使其失效。
通过模块定义就可以判断,当一个模块中既有 exports
又有 module.exports
导出内容时,最终到底导出的内容是什么,比如:
exports.add = (a, b) => a + b
module.exports = {
name: 'calculator' // 只有这里的内容被导出了
}
另外,模块导出语句末尾的代码还是会照常执行的,只是,在实际使用中,为了提高可读性,不建议在导出语句后还写其他内容。
导入
CommonJS 中使用 require
语句进行模块导入,module.exports
对象作为其返回值返回。
// calculator.js
module.exports = {
add: (a, b) => a + b
}
// index.js
const calculator = require('./calculator')
console.log(calculator.add(1, 2))
执行:
node index.js
结果:
// 3
当使用 require
导入一个模块时有两种情况
- 该模块未曾被加载过,这时会首先执行该模块,然后获取到该模块最终导出的内容
- 该模块已经被加载过,这时该模块的代码不会再执行,而是直接获取该模块上一次导出的内容
请看下面的例子说明:
// calculator.js
console.log('我被执行啦~~~')
module.exports = {
name: 'calculator',
add: (a, b) => a + b
}
// index.js
const name = require('./calculator').name
console.log(name)
const add = require('./calculator').add
console.log(add(1, 2))
执行:
node index.js
结果:
// 我被执行啦~~~
// calculator
// 3
这是因为,前面我们说模块有一个 module
对象用来存放其信息,其中有一个属性 loaded
用于记录该模块是否被加载过,第一次被加载时值被赋为 true
,后面再次加载时检查这个值为 true
就不会再执行模块代码了。
有时候加载一个模块时,不需要获取其导出的内容,只需要执行这个模块代码,就直接导出 require
即可,并且 require
还可以接受表达式,例如:
const moduleNames = ['foo.js', 'bar.js']
moduleNames.forEach(name => {
require('./' + name)
})
ES6 Module
JavaScript 设计之初并没有包含模块的概念,基于越来越多的工程需要,为了使用模块化开发,JavaScript 社区涌现了多种模块标准,包括上述所说的 CommonJS。直到2015年,发布了 ES6(ECMAScript 6.0),自此 JavaScript 语言才具备了模块这一特性(JavaScript 模块)。
模块
ES6 Module 也是将每个文件作为一个模块,每个模块拥有自身的作用域,不同的是导入、导出语句。ES6 的导入导出语句是 import
和 export
。
ES6 Module 会自动采用严格模式,即,在 ES6 Module 中不管开头是否有 use strict
都会采用严格模式。
导出
ES6 Module 中使用 export
命令来导出模块,有两种方式
- 命名导出
- 默认导出
命名导出
一个模块可以有多个命名导出,有两种不同写法:
// 写法1
export const name = 'calculator'
export const add = (a, b) => a + b
// 写法2
const name = 'calculator'
const add = (a, b) => a + b
export {
name, add}
第1种写法是在声明变量的同时用 export
导出;第2种写法是先声明,再用同一个 export
语句导出,两种写法效果一样。
导出时,可以通过 as
关键字对变量重命名:
export {
name, add as getSum} // 导入时即为 name 和 getSum
默认导出
默认导出只能有一个
// 导出对象
export default {
name: 'calculator',
add: (a, b) => a + b
}
// 导出字符串
export default 'This is a string'
// 导出匿名函数
export default function() {
...}
可以将 export default
理解为对外输出了一个名为 default
的变量,因此不需要像命名导出那样进行变量声明,直接导出即可。
导入
ES6 Module 中使用 import
语法导入模块。
导入命名导出的模块
加载带有命名导出的模块时,导入变量的效果相当于在当前作用域下声明了这些变量,并且这些变量只读,不可对其进行更改,也可以通过 as
关键字对导入的变量重命名:
// calculator.js
const name = 'calculator'
const add = (a, b) => a + b
export {
name, add}
// index.js
import {
name as myName, add} from './calculator.js'
console.log(add(1, 2), myName)
在导入多个变量时,还可以采用整体导入的方式:
// index.js
import * as calculatorfrom './calculator.js'
console.log(calculator.add(1, 2), calculator.name)
因为 ES6 Module 是可以直接在浏览器中运行的模块方式,因此可以通过 HTML 文件直接引入这些脚本文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ES6 Module</title>
</head>
<body>
<script type="module" src="./index.js"></script>
</body>
</html>
直接通过浏览器打开 index.html
会报错
这是因为 type="module"
会造成所引用模块资源受限同源策略,是因为:
- 给引用文件设置
type="module"
等于给文件设置了私有作用域,index.js
文件成了单独的模块,运行在自己的私有作用域中,而不是全局作用域中,因此受限同源策略 - 虽然
script
标签自带跨域能力(这也是某些场合会通过jsonp
结合script
来请求资源的原因),但这种跨域能力仅限于http
协议(协议、域名、端口一致),file
协议是不支持跨域的
在MDN上也能找到给出的提示:
如果你通过本地加载 HTML 文件,你将会遇到 CORS 错误,因为JavaScript 模块安全性需要,你需要通过一个服务器来测试。
如果你用的是 VSCode,可以安装一个插件帮你启一个静态资源服务器,解决这个 CORS 错误
通过插件运行 index.html
文件,执行结果:
// 3 'calculator'
导入默认导出的模块
// calculator.js
export default {
name: 'calculator',
add: (a, b) => a + b
}
// index.js
import myCalculator from './calculator.js' // 注意需要写上 .js 后缀
console.log(myCalculator.add(1, 2))
对于默认导出来说,import
后面直接跟变量名,并且这个名字可以自由指定,它指代了 calculator.js
默认导出的值,从原理上可以这样理解:
import {
default as myCalculator} from './calculator.js'
通过插件运行 index.html
文件,执行结果:
// 3
CommonJS 与 ES6 Module 的区别
动态与静态
-
CommonJS 是“动态的”,即模块依赖关系的建立发生在代码运行阶段。前面的讲解中知道
require
甚至支持传入一个表达式,可以通过if
语句判断是否加载,因此,在 CommonJS 模块被执行前,并没有办法确定明确的依赖关系,模块的导入、导出发生在代码的运行阶段。 -
ES6 Module 是“静态的”,即模块依赖关系的建立发生在代码编译阶段。不支持表达式作为导入路径,且导入导出语句必须位于模块的顶层作用域(比如不能放
if
语句中),因此在 ES6 代码的编译阶段就可以分析出模块的依赖关系
值复制与动态映射
在导入一个模块时,CommonJS 获取的是一份导出值的副本;ES6 Module 是对值的动态只读映射。
举例说明
首先是 CommonJS 说明:
// calculator.js
let count = 0
module.exports = {
count: count,
add: (a, b) => {
count += 1
console.log('count', count)
return a + b
}
}
// index.js
let count = require('./calculator').count
let add = require('./calculator').add
console.log(count)
add(1, 2)
add(2, 3)
console.log(count)
console.log(++count)
执行:
node index.js
结果:
// 0
// count 1
// count 2
// 0
// 1
说明:index.js
中的 count
是 calculator.js
中 count
的一份副本,因此在调用 add
函数时,虽然更改了 calculator.js
中 count
的值,但是并不会对 index.js
中导入时创建的副本造成影响。
能够影响这个副本的操作是在 index.js
中对 count
这个副本直接操作 ++count
,这也从另一方面说明了,CommonJS 允许对导入的值进行更改。
ES6 Module 说明:
// calculator.js
let count = 0
const add = (a, b) => {
count += 1
console.log('count', count)
return a + b
}
export {
count, add}
// index.js
import {
count, add } from './calculator.js'
console.log(count)
add(1, 2)
add(2, 3)
console.log(count)
console.log(++count)
通过插件运行 index.html
文件,执行结果:
// 0
// count 1
// count 2
// 2
// Uncaught TypeError: Assignment to constant variable.
说明:index.js
中的 count
是对 calculator.js
中 count
值的实时反映,当通过调用 add
函数更改了 calculator.js
中的 count
值时,index.js
中的 count
的值也随之变化,并且通过在 index.js
中对 count
的操作 ++count
会导致报错信息可知,ES6 Module 规定不能对导入的变量进行修改。
循环依赖
从软件设计的角度看,循环依赖应该是尽量避免的,但是当工程复杂度上升到足够大时难免会出现隐藏的循环依赖关系(比如 A 依赖 B,B 依赖 C,C 依赖 D,D 又依赖 A)。
CommonJS 中循环依赖的例子:
// foo.js
const bar = require('./bar')
console.log('value of bar: ', bar)
module.exports = 'This is foo.js'
// bar.js
const foo = require('./foo')
console.log('value of foo: ', foo)
module.exports = 'This is bar.js'
// index.js
require('./foo')
执行
node index.js
结果:
// value of foo: {}
// value of bar: This is bar.js
为什么 foo
的值时一个空对象呢?从头梳理一下代码的实际执行顺序:
index.js
导入了foo.js
,此时开始执行foo.js
中的代码;foo.js
的第 1 句导入了bar.js
,这时foo.js
不会继续向下执行,而是会进入bar.js
内部;- 在
bar.js
中又对foo.js
进行了导入,这里产生了循环依赖,这里要注意的是此时的执行权不会再回交给foo.js
,而是直接取foo.js
的导出值,但是由于foo.js
还未执行完毕,导出值就是默认的空对象; bar.js
执行完毕,执行权交回给foo.js
,foo.js
向下执行打印出value of bar: This is bar.js
,流程结束。
由此可见,虽然循环依赖的模块都被执行了,但是执行结果却不是预想的。
从 Webpack 的实现角度来看这一段的原理:
// The require function
function __webpack_require__(moduleId) {
if(installedModules[moduleId]) {
return installedModules[moduleId].exports
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {
}
}
...
}
当 index.js
引用了 foo.js
之后,相当于执行了这个 __webpack_require__
函数,初始化了一个 module
对象并放入 installedModules
中。当 bar.js
再次引用 foo.js
时,又执行了该函数,但这次是直接从 installedModules
里面取值,此时它的 module.exports
是空对象,这就解释上上述步骤 3 的现象。
上面谈到,在导入一个模块时,CommonJS 获取到的是值的副本,ES6 Module 则是动态映射(所以在上述的 CommonJS 的循环依赖中,尽管后期程序的执行会改变这个模块的导出值,但是当下 CommonJS 只能获取到值的副本且不能动态映射,所以步骤 3 中只能获取到空对象),利用 ES6 Module 的这个特性如何使其支持循环依赖呢?看下面的例子:
// foo.js
import bar from './bar.js'
function foo(invoker) {
console.log(invoker + ' invokes foo.js')
bar('foo.js')
}
export default foo
// bar.js
import foo from './foo.js'
let invoked = false
function bar(invoker) {
if(!invoked) {
invoked = true
console.log(invoker + ' invokes bar.js')
foo('bar.js')
}
}
export default bar
// index.js
import foo from './foo.js'
foo('index.js')
通过插件运行 index.html
文件,执行结果:
// index.js invokes foo.js
// foo.js invokes bar.js
// bar.js invokes foo.js
可以看到,foo.js
和 bar.js
这对循环依赖得到了正确的导出值,下面分析一下代码的执行过程:
index.js
作为入口导入foo.js
,此时开始执行foo.js
中的代码foo.js
的开头导入bar.js
,执行权交给bar.js
- 在
bar.js
中一直执行到结束,完成bar()
函数的定义,此时继续回到未完成的foo.js
内容直到执行完成,完成foo()
函数的定义。由于 ES6 Module 动态映射的特性,此时bar.js
中的foo
的值已经成为了我们定义的函数了,这也是与 CommonJS 在解决循环依赖时的本质区别 - 执行权回到
index.js
并调用foo('index.js')
函数,此时会依次执行得到输出值
由上面的例子可以看出,ES6 Module 的特性使其可以更好地支持循环依赖,只是需要开发人员在代码中保证当导入的值被使用时已经设置好正确的导出值即可。
模块打包原理
面对工程中成百上千个模块,Webpack 究竟是如何将它们有序组织在一起并按照我们预想的顺序运行在浏览器上的呢?
// index.js
const calculator = require('./calculator')
const foo = require('./foo')
console.log('sum, ', calculator.add(1, 2))
console.log('minus, ', foo.minus(4, 2))
// calculator.js
module.exports = {
add: (a, b) => a + b
}
// foo.js
module.exports = {
minus: (a, b) => a - b
}
执行(不知道如何打包的可以参考webpack入门到实战中打包第一个应用那一章节):
npm run build
结果(dist/main.js
):
// 立即执行匿名函数
(() => {
var __webpack_modules__ = ({
"./calculator.js": ((module) => {
eval("module.exports = {\r\n add: (a, b) => a + b\r\n}\n\n//# sourceURL=webpack://demo/./calculator.js?")
}),
"./foo.js": ((module) => {
eval("module.exports = {\r\n minus: (a, b) => a - b\r\n}\n\n//# sourceURL=webpack://demo/./foo.js?")
}),
"./index.js": ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
eval("const calculator = __webpack_require__(/*! ./calculator */ \"./calculator.js\")\r\nconst foo = __webpack_require__(/*! ./foo */ \"./foo.js\")\r\nconsole.log('sum, ', calculator.add(1, 2))\r\nconsole.log('minus, ', foo.minus(4, 2))\n\n//# sourceURL=webpack://demo/./index.js?")
})
})
var __webpack_module_cache__ = {
}
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId]
if (cachedModule !== undefined) {
return cachedModule.exports
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {
}
}
// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)
// Return the exports of the module
return module.exports
}
var __webpack_exports__ = __webpack_require__("./index.js");
})()
这个,就是一个最简单的 Webpack 打包结果(bundle
)。
- 最外层匿名函数中初始化浏览器执行环境,包括定义
__webpack_module_cache__
对象、__webpack_require__
函数等,为模块的加载和执行做一些准备工作 - 加载入口模块
index.js
,浏览器从它开始执行 - 执行模块代码,如果执行到了
module.exports
则记录下模块的导出值;如果遇到__webpack_require__
函数,则会暂时交出执行权,进入函数体内进行加载其他模块的逻辑 - 在
__webpack_require__
函数中判断即将加载的模块是否存在于__webpack_module_cache__
中,如果存在则直接取导出值,否则回到第 3 步,执行该模块代码来获取导出值 - 所有依赖模块都已执行完毕后,最后执行权又回到入口模块,当入口模块代码执行完毕,也就意味着整个
bundle
运行结束。