谈谈对前端模块化的理解是面试时经常会被问到的问题,我以面试者的口吻来写了如何分步骤回答这道问题。
第一步:模块化是什么?
将一个复杂程序安装一定的规则封装成几个块儿,并组合在一起。块的内部,数据和函数实现是私有的,只向外部暴露出来一些接口与外部的其他模块通信。
第二步:模块化的发展
全局function模式
把不同功能封装成不同的全局函数,污染全局命名空间,容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系。
namespace模式(命名空间)
简单对象封装,减少了全局变量,解决命名冲突,但是数据不安全,外部可以直接修改模块内部的数据。
IIFE模式:匿名函数自调用(闭包)
数据是私有的,外部只能通过暴露的方法操作,将数据和行为封装到一个函数内部,通过给window添加属性来向外暴露接口,但是如何解决模块依赖呢?
(function(window) {
let data = "hello world"
function sayHi() {
console.log(data)
}
window.myModule = { sayHi }
})(window)
myModule.sayHi()
IIFE增强模式:引入依赖
(function(window) {
const name = 'David'
window.myModule2 = { name }
})(window);
(function(window, myModule2) {
let data = "hello world"
function sayHi() {
console.log(`${myModule2.name} said: "${data}"`)
}
window.myModule = { sayHi }
})(window, myModule2)
myModule.sayHi()
存在的问题
- 每个模块都需要手动引入,引入过多的script,就会发送过多的请求。
- 依赖模糊,很难说清楚具体依赖关系是什么,也就是说很容易因为不了解他们之间的依赖关系导致加载先后顺序出错。
- 前两点问题导致难以维护多个引入的js文件。
- 因此需要一个好的模块化规范来约束我们实现模块化的方式。
第三步 模块化的好处
- 避免命名冲突。
- 更好的分离,按需加载。
- 更高的复用性。
- 高可维护性。
第四步 模块化规范
现在AMD和CMD已经逐渐退出历史舞台,我们主要介绍常用的两种规范:CommonJS和ES6模块化。
CommonJS
Node应用中的规范,每个文件就是一个模块,有自己的作用域。在文件中定义的变量,函数和类都是私有的,对其他文件是不可见的。其他文件只能引用它暴露的接口。在服务端,模块的加载是运行时同步加载的。
特点:所有代码都在其模块作用域不会污染全局,执行的顺序是模块出现的顺序。模块多次加载只会在第一次加载时运行一次,然后缓存。
基本语法:暴露模块:module.exports = xxx; exports.xxx = value。引入模块:require(xxx)。
- 匿名导出:
module.exports = function() {
console.log('hello world')
}
let sayHi = require('./test')
sayHi()
module.exports = 1
const num = require('./test')
console.log(num)
- 具名导出:
let sayHi = function() {
console.log('hello world')
}
let num = 5
module.exports = {
sayHi: sayHi,
num: num
}
let { sayHi, num } = require('./test')
sayHi()
console.log(num)
模块的加载机制:输入的是输出值的拷贝,一旦输出一个值,模块内部的变化影响不到已经输出的值。因为它只运行一次,之后都都用的是缓存中的值。
ES6模块化
ES6 模块的设计思想是尽量的静态化(编译时加载),使得编译时就能确定模块的依赖关系,以及输入和输出的变量。
特点:一个模块就是一个独立的文件,该文件内部的所有变量,外部无法获取。如果希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。export语句输出的接口与其对应的值是动态绑定关系,即通过该接口可以取到模块内部实时的值。
基本语法:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
- 具名导出的两种方式
export const firtsName = 'Wang'
export const secondName = 'Lin'
const firtsName = 'Wang'
const secondName = 'Lin'
export { firtsName, secondName }
import { firtsName, secondName } from './test.js'
console.log(firtsName + secondName)
特别注意,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
export 1 //报错
var m = 1; export m;//报错
//正确写法:
export var m = 1;
var m = 1; export {m}
var m = 1; export {n as m}
- 匿名导出:export default
使用import命令时用户需要知道多要加载的变量名和函数名,否则无法加载。可以用export default命令为模块指定默认输出。在import时就可以为该匿名函数指定任意名字。
export default function() {
console.log('hi')
}
import sayHi from './test.js'
sayHi()
一个模块只能有一个默认输出,因此export default就是输出一个叫做defalut的变量或方法。它只能使用一次,所以import命令后面才不用加大括号。
es6模块在浏览器中的加载规则,<script type=”module” src=”myModule.js”></script>
加一个type属性设为module,浏览器就会认为它是es6模块,默认它是异步加载,等同于打开了defer属性。
执行机制:遇到模块加载命令import就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用到被加载的模块中取值。ES6模块是动态引用,并不会缓存值,模块里面的变量绑定其所在的模块。由于ES6输入的模块变量只是一个符号链接,所以这个变量是只读的,对它重新赋值会报错。
第五步 commonJs和es6模块化的区别
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
第六步 循环加载
循环加载指的是,a脚本的执行依赖于b脚本,而b脚本的执行又依赖于a脚本。循环加载表示存在强耦合,如果处理不好,还可能导致递归加载,使得程序无法执行。ES6模块和CommonJS模块在处理循环加载时的方法是不一样的,返回的结果也不一样。
CommonJS模块的加载原理和循环加载
CommonJS的一个模块就是一个脚本文件。require命令第一次加载该脚本时就会执行整个脚本,然后在内存中生成一个对象。因此它的如下方法:
let { sayHi, num } = require('./test')
等同于:
let test = require('./test')
let sayHi = test.sayHi
let num = test.num
它生成一个对象之后,以后每次用到这个模块就会在这个对象中取值。无论CommonJS模块加载多少次,只有第一次加载时会运行一次,以后再加载时返回第一次运行的结果。除非手动清除缓存。
因为脚本代码在require的时候就会全部执行,一旦出现某个模块被循环加载,就只输出已经执行的部分,还未执行的部分不会输出。当它循环加载完成,两个脚本都会被全部执行一遍,因此循环加载的模块中的值可能会被改写。
//a.js
exports.done = false //第二步,设置done属性为false并导出
var b = require('./b.js') //第三步, 第一次require加载b.js脚本,进入到b.js中,a.js暂停在这里。
console.log('在a.js之中,b.done = %j', b.done) //第九步,执行完b.js继续执行a.js,此时从b.js导出的exports中的最终值done为true
exports.done = true //第十步,设置done属性为true并导出,这是a.js导出的最终值
console.log('a.js执行完毕') //第十一步,a.js执行完毕
//b.js
exports.done = false //第四步,设置done属性为false并导出
var a = require('./a.js') //第五步,第二次require加载a.js脚本,不会再运行a.js,直接从内存中exports中取值
console.log('在b.js之中,a.done = %j', a.done) //第六步,因a.js没有执行完,从exports中取到的值是已经执行的部分,而不是最后的值
exports.done = true //第七步,设置done属性为true并导出,这是b.js导出的最终值
console.log('b.js执行完毕') //第八步,b.js执行完毕
//main.js
var a = require('./a.js') //第一步,第一次require加载a.js脚本,进入到a.js中。第十二步,执行完a.js后,可以得到a.js的最终值,done为true
var b = require('./b.js') //第十三步,第二次require加载b.js脚本,不会再运行b.js,直接从内存中exports中取值
console.log('在main.js之中,a.done = %j, b.done = %j', a.done, b.done)// 第十四步,取到最终值输出。
//执行main.js
node main.js
//执行结果
在b.js之中,a.done = false
b.js执行完毕
在a.js之中,b.done = true
a.js执行完毕
在main.js之中,a.done = true, b.done = true
ES6模块循环加载
因为ES6的模块是动态引用,变量不会被缓存,而是成为一个指向被加载模块的引用。只要引用存在,代码就能执行。ES6加载的变量都是动态引用其所在模块的,只要引用存在,代码就能执行。而CommonJS中require时就会直接加载引用的模块,能够用到的只有已经执行的部分,如果用到还没有执行的部分就会报错。
//a.js
import { bar } from './b.js' //第一步,加载b.js,进入b.js
console.log('a.js') //第六步,执行完b.js,开始执行a.js
console.log(bar) //第七步,bar在b.js中的引用为'bar',输出bar
export let foo = 'foo' //第八步,具名导出foo的值为'foo'
//b.js
import { foo } from './b.js' //第二步,加载a.js,这时由于a.js已经开始执行,所以不会重复执行,继续执行b.js
console.log('b.js') // 第三步,输出b.js
console.log(foo) //第四步,输出foo的值,此时a.js还没有执行完,foo在./b.js中的引用为undefined,输出undefined
export let bar = 'bar' //第五步,具名导出bar的值为'bar'
//执行a.js
babel-node a.js
//执行结果
b.js
undefined
a.js
bar
总结对比
造成两种模块加载方案在处理“循环加载”时不同的原因在于,它们两者加载模块的不同,一个加载拿到的是值的拷贝,一次拿到,不会改变;一个拿到的是值的引用,会随着执行的过程发生变化。
- CommonJS只会在第一次require加载时执行一遍脚本,把执行结果exports的值缓存在内存中,之后再require,只会用到内存中的值,内存中的值是脚本中值的一份拷贝。在循环引用时,第一次require,脚本的内容执行中被暂停,内存中就只放已经执行完的部分的exports的值,还未执行的部分不会输出。若此时exports中还没有值,那么代码就会报错。
- Import加载时只会生成一个动态引用,而不会将值缓存在内存中。循环加载时,若引用到的值还没有被执行,那么该引用就不会拿到值,默认值为undefined,当代码执行完,就可以拿到值了。
参考
github.com/ljianshu/Bl…以及阮一峰《ES6标准入门》