我终于搞懂JS的模块化了!
在写vue或者react项目的时候,我们经常会使用import * from *
之类的方式来引入第三方组件或者自己封装的组件,在写nodejs项目的时候,我们也会用require('*')
的方式来做其他模块的引用,而如果你使用原生JS来进行开发,代码直接在浏览器中运行的话,这两个方法就不那么管用了,其实这些都是JS的模块化方式。
什么是模块化?为什么要模块化?
模块化,顾名思义,就是将相同功能的代码封装为一个功能模块,以便在其他地方多次进行复用,不过代码复用并非模块化一条路可以走:
全局方法
在全局上下文中声明一个function
,它实际上就挂载到了全局对象window
下,只要没有手动销毁它,它就可以在任何时候,任何位置进行调用:
function sayHi() {
console.log("hi!")
}
sayHi() //hi!
function chenchenSayHi() {
console.log("chenchen:")
sayHi()
}
chenchenSayHi()
//chenchen:
//hi!
这样做有个问题,复用代码过于分散了,可能相同功能的方法会分散在各个地方,很难使用,因此考虑使用全局对象,将相同功能类型的方法放在同一个对象中。
全局对象
用同样的方式在全局声明一个对象,然后将需要复用的代码放进对象的方法里去:
const say = {
text: "hello",
hi: function() {
console.log("hi")
},
hello: function() {
console.log(this.text)
}
}
say.hi(); //hi
say.hello(); //hello
这样做确实使代码更加清晰了,但是依然有一个问题,text
的值可以在外部进行修改:
say.text = "noop"
say.hello(); //noop
也就是说text
在这个对象中应该是私有的,是只应该在say
对象内部进行调用和修改而不该暴露在外部的,say
中需要暴露在外部的只有hi
和hello
方法,保护一部分数据,暴露一部分数据,这样的需求让我们想到了闭包。
IIFE立即调用函数表达式
闭包简单来说是指在某个执行上下文结束后,将上下文中的某些方法或变量暴露在外部,从而导致上下文中的数据不被销毁又不能被直接访问的情况,这样既保证了功能,又保护了数据。(我写过一篇讲闭包的文章,有兴趣可以瞅瞅)
而最常用的闭包方式,便是立即调用函数了,也就是使用括号包裹函数体:
(function () {
let text = "hello"
function hello() {
console.log(text)
}
function hi() {
console.log("hi!")
}
window.hi = hi
window.hello = hello
})();
hi(); //hi!
hello(); //hello
text = "noop";// 这里的text已经不是函数中的text了,实际上是在全局上下文中重新声明了一个text,所以接下来执行hello方法后依然输出hello
hello(); //hello
在立即执行函数中,上下文执行完毕后由于将hi()
方法和hello()
方法暴露到了全局对象上,因此上下文中的变量text
无法被销毁却又没法直接进行访问,形成了闭包,从而保护了text
总结
那么进行到这里,似乎代码复用就可以了,似乎不需要模块化来帮助,但实际上,使用IIFE方式来复用代码还存在着问题:
- 所有复用代码都混合在IIFE中,难以维护
- 功能模块与功能模块间的依赖不够清晰,一旦出现多个模块之间相互依赖的情况会引发混乱
- IIFE中的代码只能以同步方式运行,代码过于复杂时会阻塞程序,影响性能
因此,模块化便应运而生了,常见的模块化有commonjs、AMD、CMD、es6 module四种规范。
commonjs
简称cjs,是nodejs中默认采用的模块化规范,也就是我们常见的require('**')
那种
cjs的基本格式
引用其他模块的方式是require
,后面的参数可以是第三方模块的名称(保证此时第三方模块已经安装在你的node_modules中),也可以是模块文件的地址,返回值是一个对象:
const axios = require("axios");// 引用axios包导出的对象
const subModule = require("./modules/subModule.js");// 引用./modules/subModule.js文件中导出的对象
导出的方式有两种,一种是使用module.exports
导出整个对象:
//./modules/subModule.js文件
let obj = {a: 1, b: 2}
module.exports = obj
或是使用exports
单独导出每一个key:
//./modules/subModule.js文件
exports.a = 1
exports.b = 2
当然无论何种方式导出,最终引用时获取到的都是一个对象:
// 可以用任何变量名接收
const subModule = require("./modules/subModule.js");
console.log(subModule.a);//1
console.log(subModule.b);//2
输出值拷贝而非地址的加载机制
既然输出的对象,那么便存在一个问题,我引用之后收到的是值还是地址呢,我们做个实验:
// lib.js
let count = 0;
function countPlus() {
count += 1;
}
module.exports = {countPlus, count}
// index.js
const countLib = require('./lib.js');
console.log(countLib.count) // 0
countLib.countPlus()
console.log(countLib.count) // 0
执行后我们发现,虽然调用了countPlus
方法改变了count
的值,但是在再次输出count
后没有出现变化,因此,我们可以确定,在使用cjs规范引用模块时,虽然引用得到一个对象,但是获得的是对象内值的复制,而并非是对象地址,因此无论之后如何改变,引用的值都不会变化。
(cjs、AMD、CMD规范均采用的是这种方式,而es6 module却有所不同,后面会提到)
在服务端使用cjs
完整呈现一下刚才的代码,展示了引用模块,在模块里引用模块的用法:
// index.js
const countLib = require('./lib.js');
console.log(countLib.count)
countLib.countPlus()
console.log(countLib.count)
console.log(countLib.submodule.name)
// lib.js
let count = 0;
function countPlus() {
count += 1;
}
let getCount = function() {
return count;
}
let submodule = require('./sublib');
exports.countPlus = countPlus
exports.count = count
exports.getCount = getCount
exports.submodule = submodule
// sublib.js
exports.name = 'I’m a sub module'
上文说到,cjs是nodejs采用的默认模块化规范,因此对于一个nodejs工程来说,无需进行任何配置,直接运行即可。
node index.js
在浏览器端使用cjs
刚才的代码,如果直接在浏览器中引用,就会出现require is not defined
的错误,因为浏览器是不支持cjs模块化规范的:
因此需要使用browserify
将cjs模块打包成浏览器支持的格式,首先在全局安装browserify
npm install browserify -g
然后输入命令:
browserify index.js -o index.prod.js
其中index.js
是源文件地址,index.prod.js
是输出文件的地址,然后我们就得到了可以在浏览器中直接运行的代码:
然后把这个文件通过<script>
标签引入到html文件中(这里偷个懒直接去控制台里敲了):
成功在浏览器中运行了使用cjs规范进行模块化的js代码。
AMD
在上一个章节中,我们实现了commonjs规范的JS模块化,并且通过browserify
编译之后就可以直接在浏览器中运行,那么,按理说问题此时就可以得到解决了,为什么还要引入其他的规范呢?这就要说到模块之间的依赖问题了:在cjs规范中,模块是同步加载的(毕竟它是设计给服务端nodejs的),也就是说,即使不需要依赖其他模块的代码,依然需要等待前面模块加载完毕之后才能开始执行,在模块越来越大之后无疑会损失更多性能,因此AMD(Asynchronous Module Definition 异步模块定义)便应运而生,顾名思义,这个规范实现了模块的异步加载。
AMD的基本格式
AMD规范中引入其他模块也是使用require
,但毕竟是异步加载,因此并不直接接收返回值,而是传入一个回调函数,回调函数的参数即为所加载模块导出的对象:
require(["lib1","lib2"], function(l1,l2) {
l1.exert();
l2.exert();
//........
})
其中lib1
、lib2
与cjs一样,可以是别名也可以是文件路径,回调函数中的l1
、l2
按照顺序代表模块导出的对象,在回调函数中执行模块加载后的代码,在外部执行无需模块依赖的代码,从而实现了异步。
而模块导出的方式则跟cjs完全不同,使用的是define
方法:
define([], function() {
let count = 0;
function countPlus() {
count += 1;
}
return {count, countPlus};
})
回调函数中返回的对象即为模块导出的对象。
这是该模块没有依赖其他模块的情形,如果模块之间还有依赖,则与require
类似,在第一个参数的数组中传入要依赖的模块,然后使用回调函数的参数来接收:
define(["subModule"], function(submodule) {
let mysSub = submodule.exert()
return {mySub};
})
在服务端使用AMD
上文提到,nodejs默认采用的是cjs规范,因此不能直接使用AMD模块化的方式,需要借助第三方库,常用的支持AMD模块化的库是requirejs
:
npm i requirejs --save
引入requirejs并对别名进行配置(模块所依赖的模块最好也配置别名),之后调用依赖:
// index.js
var requirejs = require('requirejs');
requirejs.config({
paths: {
libAMD: 'module/index',
subModule: 'module/index2'
}
})
requirejs(["libAMD"], function(libAMD) {
console.log(libAMD.count)
libAMD.countPlus()
console.log(libAMD.count)
console.log(libAMD.submodule.name)
})
// module/index.js
define(["subModule"], function(submodule) {
let count = 0;
function countPlus() {
count += 1;
}
return {count, countPlus, submodule};
})
// module/index2.js
define([], function() {
return {name: 'I’m a sub module'}
})
最后,执行node index.js
:
值得一提的是,与cjs逻辑相同,AMD规范导入的依然是值的复制而并非地址,因此count
在执行countPlus
方法后并未发生变化。
在浏览器端使用AMD
在浏览器端使用AMD的方式也比较简单,只要在主函数执行前加载requirejs
库即可:
<script src="https://requirejs.org/docs/release/2.3.6/minified/require.js"></script>
<script>
require.config({
baseUrl: 'module/',
paths: {
libAMD: './index',
subModule: './index2'
}
})
require(["libAMD"], function(libAMD) {
console.log(libAMD.count)
libAMD.countPlus()
console.log(libAMD.count)
console.log(libAMD.submodule.name)
})
</script>
引用和导出方式与在服务端使用AMD规范基本相同。
CMD
CMD的基本格式及运行
CMD的基本逻辑跟AMD是一致的,只不过在写法上CMD采用了cjs的部分语法,且CMD仅支持浏览器端使用,并在模块加载方面针对浏览器端运行的代码做了些许优化。同样需要引入三方库来支持CMD规范,比较常用的是sea.js
<script src="https://cdn.bootcdn.net/ajax/libs/seajs/3.0.3/sea.js"></script>
<script>
seajs.config({
base: './module/',
alias: {
libAMD: 'index.js',
subModule: 'index2.js'
}
})
seajs.use('./main.js')
</script>
// main.js
define(function(require) {
let libAMD = require("libAMD")
console.log(libAMD.count)
libAMD.countPlus()
console.log(libAMD.count)
console.log(libAMD.submodule.name)
})
在回调函数中获得require
方法,然后按照cjs的方式来使用。
// module/index.js
define(function(require, exports, module) {
let count = 0;
function countPlus() {
count += 1;
}
let submodule = require("subModule");
module.exports = {count, countPlus, submodule};
})
define(function(require, exports, module) {
exports.name = 'I’m a sub module'
})
模块导出则可以在回调函数中获得exports
和module
对象,依然按照cjs的方法进行使用。
es6模块化
最后,我们在写vue和react的时候经常用到的import
,则是es6的模块化规范了。
es6模块化的基本格式
首先说导出,导出使用的是export
,但与前三者直接导出一个对象的方式不同,export
需要导出的是单独的函数、对象或者类,具体的导出方式如下:
// module/index.js
// 在声明时直接导出
export let value = "ok"
export function getValue() {// 这里注意下不能使用匿名函数
return value
}
export class Obj{
constructor(name) {
this.name = name
}
}
// 先声明后导出,需要加上大括号,通过逗号分隔可以传入多个值
let name = "George"
export {name}
let value = 1;
function getValue() {
return value;
}
export {value, getValue}
// 重命名导出
export {name as myName, getValue as getMyValue}
以上几种方式叫做具名导出,也就是说每个导出的变量、类、函数都有自己的名字,因此在引用他们的时候,需要提供具体的名字:
import { value, getValue, Obj, name } from "./module/index.js"
也可以起别名,毕竟不同模块间导出的名称很有可能重复:
import { name as name1 } from "./module/index1.js"
import { name as name2 } from "./module/index2.js"
当然如果懒得写的话,也可以把某个文件里导出的所有内容作为一个对象导出然后给它们赋予一个别名:
import * as module from "./module/index.js"
module.getValue()
console.log(module.value)
既然有具名导出,相对的就有默认导出,也就是匿名导出,方式是在导出前加一个default
,此时需要导出的方法变量就不需要拥有名字了:
// 这里只是示例,同一个文件中默认导出只能用一个
export default {a:1}
export default function() { return value }
export default class {
constructor() {
this.a = 1;
}
}
相应地,默认导出引用时就不需要大括号{}
了,而是直接给一个自定义的名称即可使用:
import module from "./module/index";
console.log(module.a) // 1
输出地址而非值的加载机制
上文提到,无论是cjs、AMD还是CMD,实际上导出的都是值拷贝,也就是说当一个变量被引用后,在模块内部发生任何变化都不会引起该变量的变化,而es6的模块化却有所不同:
// module/index.js
let count = 0;
function countPlus() {
count += 1;
}
export { count, countPlus }
import { count, countPlus } from "./module/index.js"
console.log(count) // 0
countPlus()
console.log(count) // 1
可以看到count
的值发生了变化,也就是说,采用具名方式导出时,引用的是地址,这与cjs、AMD、CMD都是不同的,使用的时候值得注意。
但是,如果使用default
进行默认导出时,则与另外三者保持一致,使用的是值拷贝:
// module/index.js
let count = 0;
function countPlus() {
count += 1;
}
export {countPlus}
export default count;
import {countPlus} from './module/index.js';
import count from './module/index.js';
console.log(count) // 0
countPlus()
console.log(count) // 0
在服务端运行es6模块的两种方式
首先完整呈现一下刚才的代码,这里加入了一个模块引用模块的操作:
// module/index.js
let count = 0;
function countPlus() {
count += 1;
}
let getCount = function() {
return count;
}
import submodule from './index2.js';
export default {countPlus, count, getCount, submodule}
// moudle/index2.js
export default {name: "I’m a sub module"}
// index.js
import countLib1 from './module/index.js';
console.log(countLib1.count)
countLib1.countPlus()
console.log(countLib1.count)
console.log(countLib1.submodule.name)
上文提到,nodejs中默认采用的是cjs规范,因此在nodejs中使用es6模块需要进行一定修改,有以下两种方式:
- 第一种,将采用es6规范的js文件后缀名修改为mjs(记得中间引用的所有文件后缀名也要改)
- 第二种,修改package.json中module的值
module默认值为commonjs
,将其修改为module
:
然后node index.js
即可直接运行es6模块化的js文件:
此时,如果想要使用cjs规范的模块的话,则需要将其后缀名修改为cjs才行。
在浏览器中运行es6模块
在浏览器中使用es6模块,过去使用的方式同上面支持cjs类似,首先使用babel对代码进行打包转化为cjs,然后再使用browserify转换成浏览器可以直接运行的代码,但是现在,浏览器大多已经支持es6模块了:
可以看到,61以上版本的chrome,16以上的edge和60版本以上的火狐均支持es6模块(当然消失在历史长河中的IE不行),只需要在script标签中加入
type="module"
即可:
<script type="module">
import countLib1 from './module/index.js';
console.log(countLib1.count)
countLib1.countPlus()
console.log(countLib1.count)
console.log(countLib1.submodule.name)
</script>
值得一提的是,一旦使用了type="module"
,script标签就不能再忽略同源策略了,会出现跨源问题:
因此需要将其放在本地服务下进行调用:
访问http://192.168.6.174/index.html
,成功运行:
总结
本文介绍了js模块化的四种方式,commonjs、AMD、CMD、es6模块:
- nodejs直接支持commonjs和es6模块,浏览器端直接支持es6模块,其余情况下均需要第三方工具预编译或运行时支持
- commonjs采用同步方式加载模块,而其余三者则使用异步方式对模块进行加载
- commonjs、AMD和CMD模块导出的均是对象值的复制,而es6模块采用具名方式导出时导出的是地址,后者被导出内容在模块内发生变化时会反映到外部
转载自:https://juejin.cn/post/7195098658583838777