模块化的思想在绝大多数的编程语言中应该都很重要。在ES6/Node.js出现之前,js实现代码封装,避免变量泄漏和变量名污染的方式大多是使用IFEE(立即执行函数)。jQuery大量的使用了这种方式。
JS发展迅速,对好用的模块化标准的需求越来越大。在ES6没有出来之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种,前者用于服务器,后者用于浏览器,ES6在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案
在Node.js的中,模块化的标准采用CommonJS的标准,所以这里主要讨论CommonJS标准和ES6中制定的新标准。
NodeJS模块化
NodeJS实现模块化通过module.export 和 require()实现。在NodeJS中一个文件即为一个模块。
在每个模块中, module 的自由变量是对表示当前模块的对象的引用。 为方便起见,还可以通过全局模块的 exports 访问 module.exports。 module 实际上不是全局的,而是每个模块本地的
下面代码中写了四种方式去进行模块的暴露。第一种无疑是正确的,但是第二种是否正确呢?
如果在另一个文件中引用module.js,会发现通过require()方法所获取到的module是一个空的对象。原因可以从上面对NodeJS的文档中一部分的引用推理出——module 的自由变量是对表示当前模块的对象的引用。require引用的是文件所代表的真正的模块,而module只不过是对真实对象的一个引用。对module的重新赋值只会丧失对真实模块的引用,而不能起到导出内容的目的。
// module.js
let func = function () {}
module.exports = {func} // 1
module = {exports : {func} }// 2
exports.func = func // 3
exports = {func} // 4
// 5
module = {}
exports.func = func
export = nodule.export = xxx
// require.js
let module = require('./module')
console.log(module) // 2/4情况下 output: {}
再看第三和第四种方式。第三种也是正确的,但是第四种就犯了和第二种一样的错误。exports同样是对module.exports的一个引用。对exports的重新赋值也只是使exports指向另一个对象,指向另一块内存区。
我们可以再玩一些骚操作。我们对module重新赋值,使其不指向真实的模块对象。然后使用exports进行模块的导出会成功吗?
结果令人惊喜,我们在require.js能得到正常的引用。也就是说,exports指向的不是module.exports,是真实模块对象的exports属性。
require 和 module的一些属性,能在使用中带来便利。
- 想要获得调用
require()时加载的确切的文件名(完整的路径),使用require.resolve()函数。
require.resolve('./module')
require.main,Module 对象,表示当 Node.js 进程启动时加载的入口脚本。如果a.js中引用了b.js,a.js是直接运行的模块。那么在b.js中访问require.main得到的是a。使用require.main === module可以判断当前模块是不是根入口模块。require.cache
被引入的模块将被缓存在这个对象中。 从此对象中删除键值对将会导致下一次 require 重新加载被删除的模块。 这不适用于原生插件,因为它们的重载将会导致错误。可以添加或替换入口。 在加载原生模块之前会检查此缓存,如果将与原生模块匹配的名称添加到缓存中,则引入调用将不再获取原生模块。 谨慎使用!
更多的属性可以通过Nodejs的官方文档了解
ES6模块化
ES6 模块化实现方式是使用export/import语法。
// export.js
export default obj // 1. 匿名导出
export default {a: 1}
export { obj, obj as obj1 } // 2. 命名导出
export let a = 1
let b = 2
export b
export 3 // error!
export { default } from 'other-module' // 3. 重定向
// import.js
import anyName from './export' // 1
import { obj as myObj, obj1 } from './export' // 2
import * from './export'
import * as myModule from './export'
import 'jquery.js'; //
export语法有很多种方式使用。
- 和
default关键字一起使用,后面跟的是Object对象,导出的就是一个匿名对象。在使用import的时候,可以指定这个对象的名字。 - 命名导出,使用花括号导出多个接口(导出一个列表)。在导入时,必须指定需要导入的名字,因为多个接口可能带来混淆,当然也可以重新指定别名。如果需要全部导入,使用*。在使用命名导出的时候,可以多次的实用
export。当然个人觉得在文件的最后使用花括号一次性导出列表的方式更好,可读性好。 - 重定向导出,用于把另一个模块的内容,在本模块整理导出。
如果我们想要在当前模块中,导出指定导入模块的默认导出(等于是创建了一个“重定向”)
- 有的时候,导入一个模块,不是直接使用导出的变量。或者实际上也没有使用
export导出接口。而只是需要执行这个模块的代码进行初始化(目的是使用导入的副作用)。例如导入的jquer,相关api都挂载载$上。
注意,import语法必须在文件的开头写。如果在import语句前有其他代码属于语法错误。
如果钻一下牛角尖,同时使用了命名导出和匿名导出呢?经过测试,匿名导出的优先级似乎高于命名导出。同时进行两种方式,不论先后,模块的导出是遵循匿名导出的方式。
两种方式的根本性差异
-
静态导入和动态导入
nodeJs采用的require/modile.exports语法可以在模块内任意地方进行模块的导出和导入。也就是说采用这种方式,依赖时运行时决定的。比如下面这段代码,可以动态地决定导入的模块。
if (isIE) {
require('compatible.js')
}
而export/import语法,在编译的时候就决定了决定了依赖。静态的方式决定了它可以按需加载,不必加载整个目标模块(例如在导入部分命名接口时,只加载这部分)。这部分拓展开来可以去看看tree-shaking。在某种程度上,静态更高效。
从规范与实现定义来说 require是动态加载,import是静态加载,从底层的运行来讲,require是在程序运行的时候去解析而import是在编译的时候去做解析请求包,require是请求整个包对象而import是只请求模块中需要的请求的部分。
import 是解构过程并且是编译时执行。
当然ESModule也支持动态导入。使用import()方法实现动态的导入。
-
值引用和值拷贝。
import传的是值引用,require是值拷贝。ESModule 的导出是把变量的申明也一起导出了。
// module.mjs
export let count = 1
// 这里导出的是值加上声明 let
setTimeout(() => {
count++
}, 100)
// import.mjs
// 使用 import 的语法并不会重新申明一个 count ,count 还是在 module.mjs 中声明
import { count } from './module.mjs'
console.log(count)
setTimeout(() => {
console.log(count) // 这里能够实时拿到最新的值
}, 1000)
相比之下 CommonJS 的规范则是值的传递
// common.js
let count = 1
exports.count = count // 这里导出的只是一个值
setTimeout(() => {
count++
}, 100)
// require.js
// count 在导入的时候被重新声明了
let { count } = require('./common.js')
console.log(count)
setTimeout(() => {
console.log(count) // 输出的始终都是1
}, 1000)
总结
上面就是目前主流的两种模块化的方式。值得注意的是,目前(2020年)无论是NodeJ还是浏览器Js引擎,都还没有直接支持export/import语法。在前端工程中使用这个语法,基本上使用babel编译成了module/require语法,再交由打包工具,打包输出。