此文章为阅读《Webpack实战》时结合自己的理解与实践做的笔记,如果错误欢迎指正。
JS中有多种模块标准,首先先以此了解一下它们之间的特性和区别。
2.1 CommonJS
CommonJS为2009年提出的模块标准,Nodejs中使用了CommonJS中的一部分,并在其基础上做了一些调整。
2.1.1 模块
CommonJS中规定每一个文件就是一个模块,与使用script标签将js文件导入的方式不同的是,script标签导入的顶层作用域是全局作用域,而CommonJS模块导入的顶层作用域是模块本身,所有的变量和函数只能自己访问,对外是不可见的。
举个例子:calculator.js
var name = 'calculator.js'
index.js
var name="index.js"
require('./calculator.js')
console.log(name) //将会打印出index.js
这说明在calculator.js中声明的变量只会在它的作用域中生效,并不会到index.js的作用域中。
2.1.2 导出
前面说到CommonJS中的一个模块中的变量只会作用在文件中的作用域,那么想要从模块文件中导出内容就需要使用module.exports。
对于每个CommonJS文件,都会有一个module对象用于存储该模块的信息,module对象中的exports字段就说明了要对外暴露哪些内容。可以理解成在每个模块前都进行了如下的声明。
var module = { ... }
通过对module.exports的赋值来暴露变量:
module.exports = {
id: 'xxxxx',
add: function (x, y) {
return x+y
}
}
需要注意的是module.exports不代表代码的结束(即不能将其理解成return),其后面的代码还是会继续运行。
2.1.3 导入
在CommonJS中使用require对模块进行导入。当使用require导入一个模块时会有两种情况:
- require的模块是第一次被加载,这时会先执行该模块,然后导出内容。
- require的模块之前被加载过,这时该模块的代码不会再被执行,而是直接导出上次执行的结果。
也就是说在一个模块中多次导入其他的模块,被导入的模块只会执行一次。
那假如有c.js,b.js中导入了c.js,a.js中导入了b.js和c.js,那么在执行a.js时c.js会执行多少次呢?
// c.js
module.exports = {
name: 'c.js'
}
console.log("c.js is running")
// b.js
const cJS = require("./c.js")
console.log("I am b.js, I get " + cJS.name)
module.exports = {
name: 'b.js'
}
// a.js
const bJS = require("./b.js")
const cJS = require("./c.js")
console.log("I am a.js")
运行后输出将会是什么?答案如下:
c.js is running
I am b.js, I get c.js
I am a.js
原因是require的实现原理使用到了缓存,具体的原理可以看这一篇require的实现。每一个函数都是一个对象,require对象中存着一个名为cache的字段,会将所有导入过的模块以字典的形式记录下来(模块绝对路径->导出的module)。在a.js中:
导入b.js后,在导入c.js前会先查找require.cache[c.js的绝对路径]是否存在,由于c.js已经在cache中存在了,所以会直接使用cache中导出的产物。所以c.js不会再执行一遍。
2.2 ES6 Module
2.2.1 模块
ES6 module也是将每个文件作为一个模块,每个模块拥有自身的作用域,和CommonJS不同的是导入导出语句不同,ES6 使用的是import和export。
2.2.2 导出
在ES6中使用export导出时有两种形式:命名导出和默认导出
- 命名导出
一个模块可以有多个命名导出,有两种写法。
export const name = "calculator"
export const add = function(x, y) {return x+y}
const name = "calculator"
const add = function(x, y) {return x+y}
export {name, add as getAdd}
第一种写法是在声明的时候就进行导出,第二种是声明完之后一起导出。在使用命名导时还可以使用as对关键词进行变换。
- 默认导出
和命名导出不同的是,默认导出只能有一个,可以看作是导出了一个名为default的变量。注意看default后面直接跟的声明,而不需要变量赋值。
export default function() {}
export default class {...}
2.2.3 导入
- 对命名导出的导入
// 导出 calculator.js
export const name = "calculator"
export const add = function(x, y) {return x+y}
// 导入
import {name, add as myAdd} from "./calculator.js"
对于命名导出的导入,import后需要以大括号形式将变量导入进来,并且导入的变量名需要和导出的变量名完全一致,并且这些导入的变量是只读的,不可修改。并且也可以使用as关键字更改导入的变量名。
在导入多个变量时还可以采取整体导入的方式。
import * as cal from "./calculator.js"
console.log(cal.name)
这样可以将模块中的所有东西都一次性导入到一个模块变量中(比如例子中的cal),就可以避免污染作用域。
- 对默认导出的导入
// 导出
export default {
name: "cal.js"
}
// 导入
import myCal from "./cal.js"
- 命名导出和默认导出的结合
// a.js
export const add = (x, y) => x+y
export const name = "a.js"
export default {
a: 'hi'
}
//b.js 对a的正确导入
import aObj, { add } from "a.js"
//b.js 对a的错误导入
import {add}, obj from "./a"
es6 Module中的导出操作可以使用命名导出和默认导出两种方式,但在导入的时候一定要先导入默认导出的部分。
CommonJS与ES6 Module的区别
动态和静态
CommonJS和ES6 Module最本质的区别在于前者对模块依赖的解决方式是“动态的”,后者是“静态的”。动态的是指模块依赖关系发生在代码运行阶段,静态的则是指模块依赖关系建立在代码编译阶段。这里的理解可以结合webpack的打包结果来看。
// index.js
const cjs = require("./cjs")
import { name } from "./es6"
console.log(cjs.name)
console.log(name)
// es6.js
export const name = "es6"
// cjs.js
module.exports.name = "cjs"
按照生产模式去打包可以得到:
(() => {
var o = { 915: o => { o.exports.name = "cjs" } }, // cjs.js
e = {};
// require函数
function r(s) {
var t = e[s];
if (void 0 !== t) return t.exports;
var n = e[s] = { exports: {} };
return o[s](n, n.exports, r), n.exports
}
(() => {
"use strict";
const o = r(915);
console.log(o.name),
// 直接使用es6.js中的name变量
console.log("es6") })() })();
可以看到CommonJS是在运行时加载的,所以可以使用if语句来判断是否加载某个模块。而在es6 module直接就把模块内容直接写到index.js中了,也就是在代码编译阶段就完成了模块的导入,所以相比CommonJS有以下好处:
- 死代码的检测和排除,es6在编译阶段就可以知道哪些部分有被使用到,而不会被使用到的代码就是死区,在编译阶段就会去掉。
- 编译优化,ES6 支持直接导入变量,减少了引用层级。
值拷贝和动态映射
从上面编译结果就可以看出CommonJS的模块导入方式就是类似于工厂模式给传入的对象中的exports属性进行赋值,赋值过程就是浅拷贝。
// cjs.js中的内容
const name = "cjs"
const changeName = () => {
name += "hi"
}
module.exports.name = name
module.exports.changeName = changeName
// 编译出的结果为
o={915:o=>{
const e="cjs";
o.exports.name=e,
o.exports.changeName=()=>{e+="hi"}}}
// 所以我在外面调用changeName这个函数只会改变这个文件内作用域的变量,导出的name不会随着发生变化
// index.js
import { add, count } from "./es6"
console.log(count)
changeCount()
add()
add()
console.log(count)
// es6.js
export let count = 1
export const add = () => {
count += 1
}
export const changeCount = () => {
count = 10
}
编译结果为:
(()=>{"use strict";let o=1;const l=()=>{o+=1};console.log(o),o=10,l(),l(),console.log(o)})();
这里直接把es6中的变量拿到引入模块的作用域中了,所以更改es6.js中的变量就相当于改变当前作用域中变量的效果。【这里不知道是不是因为编译优化导致的打包效果,所以我还不知道es6导入模块是不是通过直接使用当前作用域的方法】
2.3.3 循环依赖
循环依赖是指A模块和B模块之间互相依赖。首先先看CommonJS中的循环依赖的例子,可以基于前面webpack对CommonJS的编译效果自己想一下输出。
// foo.js
const bar = require('./bar')
console.log(bar)
module.exports = "foo"
// bar.js
const foo = require('./foo')
console.log(foo)
module.exports = "bar"
// index.js
require("./foo")
代码执行的逻辑应该是:
- 先执行foo.js,执行到require("bar.js"),进入到bar.js中
- bar.js中执行require('./foo')会从缓存中拿,由于缓存中foo模块的exports初始化成了空对象,所以这里拿到的foo变量为空对象。
可以通过编译结果看出这个过程
(() => {
var o = {
825: (o, r, t) => {
const e = t(717);
console.log(e), (o.exports = "bar");
},
717: (o, r, t) => {
const e = t(825);
console.log(e), (o.exports = "foo");
},
},
r = {};
!(function t(e) {
var s = r[e];
if (void 0 !== s) return s.exports;
var n = (r[e] = { exports: {} });
return o[e](n, n.exports, t), n.exports;
})(717);
})();
// index.js
import foo from "./foo.js"
// foo.js
import bar from "./bar"
console.log(bar)
export default "foo"
// bar.js
import foo from "./foo"
console.log(foo)
export default "bar"
// 编译结果
(()=>{"use strict";console.log(o),console.log("bar");const o="foo"})();
所以打印foo为undefined,打印bar为正常值。