模块化的概念
如果你做过一个较为完整的网站项目的话,你会发现,无论是自己编写的JavaScript脚本还是开源的jQuery项目,我们都是通过script
标签直接引入。
同一个html
文件中,使用script
标签引入的脚本,后引入的脚本可以肆无忌惮地使用前面脚本的函数和变量。
这样就会造成一些问题:
- 如果你的项目较为庞大,可能会出现一些功能类似的函数或者变量,你可能会给它们赋予相同的函数名;
- 如果你引入了多个开源脚本,各脚本之间的函数名和变量名很容易产生冲突。
这对一个大型项目来说是致命的,尤其是在后期遇到这样的问题。
我们就思考,能否有一种方式让我们只“导入”我们需要的变量、函数或者类等,换个词来说就是“按需引入”。
CommonJS
早期的JavaScript本身并不支持模块化,于是一些开发者提出了一种方案,名为CommonJS
,这种规范由 Node.js 借鉴与实现。
这也就意味着,该规范是为服务端而产生的。
在CommonJS中,每个文件都被视为一个独立的模块,模块内部的所有代码都在独立的作用域中运行,不会污染全局作用域。模块通过require()函数进行导入,通过module.exports或exports对象进行导出。这种模块化方式使得代码的组织、重用和维护变得更加方便。
导出
在一个.js
文件中,我们通过module.exports
或者exports
对象将文件中的变量或者函数导出。
我们可以这样写:
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = {
add
}
exports.subtract = subtract;
module.exports
是Node.js
提供的用于导出模块的对象。每个文件在Node.js
中都被视为一个模块,模块内部的代码默认是私有的。通过module.exports
可以将模块中的某些功能或数据暴露出去,供其他模块使用。
exports
是module.exports
的一个引用。可以通过exports
添加属性,但最终导出的是 module.exports
。
我们需要重点关注一下module.exports
和exports
的区别。
上面说到,exports
是module.exports
的一个引用,这个“引用”的意思就是JavaScript语法中,对象的引用。
我们用代码模拟就是:
const exports = module.exports;
我们知道,在JavaScript中,将存储对象Obj的变量A直接通过赋值符号=
赋给变量B的时候,实际上是将对象的堆地址赋给了变量B,变量A和变量B存储了相同的堆地址。如果我们修改的变量B的属性,在获取的变量A的相同属性时会发现也被修改了。但是如果我们直接将的变量B赋为其他数据的时候,它的栈地址由原来对象Obj的堆地址变成了其他值,也就是失去了对象Obj的引用。
而在上面module.exports
和exports
的比较中,我们说“module.exports
是Node.js
提供的用于导出模块的对象”,因此module.exports
始终都是导出的数据,它可以被赋为任意类型的数据,无论是基本类型还是引用类型。
但是对于exports
来说就有问题了,默认情况下exports
是module.exports
的引用,我们可以给exports
添加属性,由于引用的存在,module.exports
也被修改,因此这样使用没什么问题。
如果直接给module.exports
赋值会怎样?
我们有一个utils.js
的模块,用于导出:
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = add;
exports.subtract = subtract;
这里我们已经将module.exports
变成了函数,从另一个文件index.js
导入:
const utils = require('./utils.js');
输出utils
你会发现,它是一个函数,这个函数就是上面的add
函数,那subtract
是不是不见了?
你可能会觉得,在JavaScript中函数也是对象,subtract
会成为utils
的一个方法,实际上,输出utils.subtract
得到的结果是undefined
。
我们上面说了,exports
是module.exports
的引用,它们指向共同的堆地址,但是它们不是一个变量,它们是两个,只要任何一个发生变化,都会与另一个失去引用。区别是module.exports
被直接赋值时,仍然能导出,而exports
无法导出而已。
那我们到底应该用哪一个来导出呢?
实际上,我们建议始终使用module.exports
,且以对象的形式进行导出,如:
module.exports = {
add,
subtract,
}
永远不要使用exports
可以解决所有问题。
导入
前面我们说utils.js
是一个模块,模块内部导出了一个对象,对象有add
和subtract
两个对象,那我们如何导入呢?
其实上面已经写过了,通require
方法进行导入,require
方法的参数是目标模块的路径,在Nodejs中,目标模块如果是.js
文件,可以省略后缀名,建议始终带上后缀名,方便与目录的index.js
进行区分。当然你也可以不带后缀名,但是不要混淆使用。
const utils = require('./utils.js');
utils.add(1, 2);
我们说导出的是对象,那么导入的自然也是对象,有的时候为了方便书写,我们可以用解构赋值的方式只提取我们所需要的数据:
const { add }= require('./utils.js');
add(1, 2);
总结
以上的CommonJS
规范下的模块化,适用于早期Nodejs的模块化。
ESM
ESM
,全称ECMAScript Modules
,ECMAScript
模块是 JavaScript 的官方模块化标准,自ECMAScript 2015(ES6)
起被正式引入。它为 JavaScript 提供了原生的模块系统,使得代码的组织、重用和维护更加高效。
如果你经常阅读一些前端工程化相关的项目文档的话,你是会经常看到esm
这个词的。
与CommonJS
的模块化不同的是,ESM
是JavaScript
语言本身的功能,不依赖于特定的运行环境(如浏览器或 Node.js),只要有支持ESM
的JavaScript
引擎即可使用。
那其实Nodejs
也是可以使用esm
的 ,有的同学会有疑问,自己在写Nodejs项目的时候偶尔会习惯性写esm
的模块化,为什么会报错?
原因不在于Nodejs
不支持esm
,而是Nodejs
对两种不同的模块化做了差异化,后面我们会进行对比。
导出
我们先来学习一下如何实现ESM
的导出,通过export
命令实现导出,注意后面没有s
:
export add;
遗憾的是,上面的写法是错误的。
ESM
的核心特性之一是支持静态分析。在代码执行前,JavaScript 引擎或打包工具(如 Webpack、Rollup)需要明确知道模块的导出内容(export)和导入依赖(import)。如果允许直接导出变量,模块的导出内容可能在运行时动态变化,破坏静态分析的确定性。
如果这里你无法理解,那么,你可能对变量有误解,变量只是一股标识符,用来存储数据,当使用变量时,实际上是取值后使用,我们将变量替换成数据就是:
export 10;
这样的模块化就失去了意义。
export
不允许导出字面量或者变量的另一个原因是,ESM
导出的是变量的绑定(binding),而不是值的快照。如果导出的是变量,引擎需要确保模块内外的变量保持引用关系。通过强制导出声明,可以明确绑定关系,避免歧义。
因此,正确的写法应该是:
export const a = 10;
export function add(x, y) {
return x + y;
}
那如果我们的代码已经写好了,需要导出怎么办?总不能挨个加export
吧?
我们可以这样写:
export {
add, subtract
}
这个写法又和module.exports
类似了,但是实际上,export
导出的并不是对象,它只是这样的一个语法而已,我们去掉里面的函数:
export {
}
通过import
导入你会发现,代码会报错:
import utils from './utils.js';
^^^^^
SyntaxError: The requested module './utils.js' does not provide an export named 'default'
意思是utils.js
并没有一个名叫default
的导出,我们暂时不用理解这个default
是什么,我们只需要知道,export{}
的大括号的含义并不是对象,它是export
的写法,就像for
循环或者function
的大括号一样。
我们称这种导出方式为具名导出
,你也可以叫它命名导出
。
除了具名导出
外,ESM
还提供了一个默认导出
,方法是:
export default const b = 10;
遗憾的是,上面的写法还是错误的。
正确的写法是:
export default 10;
这正好与export
写法相反。
这里长话短说,实际上,你可以将export default
理解为是一个简写,其完整写法是:
export const default = 10;
只不过我们在用的时候不能这样写。
我们建议如果导出的数据是独立的,没有与其相关联的其他数据,我们直接使用:
export const a = 10;
如果要导出一系列数据,写法与module.exports
一样:
export {
add, subtract
}
这里与代码风格和代码质量无关,一个模块就应该都是需要导出的数据,将具有关联性的数据放在一起导出完全是为了方便后期维护。
导入
我们通过import
实现导入,这里又会遇到一个问题,导出的写法我们列出来三种,那需要对应三种导入吗?
并不需要,虽然导入的方式也有多种,但是与导出并不对应。
我们先解决特殊情况,上面说的export default
默认导出,我们这样导入:
import utils from './utils.js';
注意,这里的utils
可以是任意的标识符,无论你改写成什么,最终都是export default
所导出的default
,相当于是一个重命名。如果没有export default
,也就是没有默认导出那就会出现上面所说的报错。
再看另一种导入方式:
import { a, add, subtract } from './utils.js';
大括号内的变量必须和export
导出的具名一致,这里就体现了上面所提到的“变量的绑定”。
注意,这里的大括号仍然是固定写法,虽然它长得很像对象的结构赋值,甚至功能都与解构赋值一样,但是它不是对象。
你可以尝试这样写:
import { a: b, add, subtract } from './utils.js';
如果是解构赋值,那么代码不会有问题,导出的具名变量a的值会赋给b,实际上,代码会报错。
那如果index.js
模块有一个同名的函数名或者变量名,导入的内容也有一个同名的标识符该怎么办?
从规范上来说,你应该修改同名标识符,避免出现相同情况。
如果无法避免,我们可以使用as
来重命名:
import { a as b, add, subtract } from './utils.js';
这个时候a
就不能用了,只能用b
,当然你还可以把a
再导入一次。
import { a as b, add, subtract, a } from './utils.js';
那如果需要导入的数据实在太多了,一个个写变量名太麻烦了怎么办?
我们还有一个终极方案:
import num, * as utils from './utils.js';
这里的num
用来接受默认导出,而* as utils
的意思是,将所有具名导出都放到对象utils
中,使用:
utils.add(1, 2);
重新导出
这个名词比较抽象,但是不难理解,我们看这样一个场景,如果有一个目录,里面有多个工具,如jwt.js
、date.js
、validate.js
等多个模块,如果我在一个模块中要导入这些模块,需要写三次import
,有没有一种方法能够让我们一次性导入呢?
我们可以这样实现,在这个目录下新建一个index.js
文件:
import { verify, generate } from './jwt.js';
import { getTime, getDate } from './date.js';
export {
verify, generate, getTime, getDate
}
这样我们就不需要考虑该目录下到底有多少模块需要导入,只需要从index.js
导入即可:
import * as utils from './utils/index.js';
// 也可以写成,这也是为什么上面让加上后缀名的原因,与目录区分
import * as utils from './utils';
不过上面的写法总归差点意思,如果能更简洁一点就好了:
export { verify, generate } from './jwt.js';
export { getTime, getDate } from './date.js';
再仔细想想,这个index.js
相当于一个中转站,火车站不需要知道火车里有哪些人,只知道中转车次即可,那我们再简介一点:
export * from './jwt.js';
export * from './date.js';
那这种情况下,默认导出怎么办?
默认导出会被遗漏,我们需要手动导出:
export * from './jwt.js';
export { default } from './jwt.js';
export * from './date.js';
很明显,这个写法不合适,其他模块也会有默认导出,我们可以给它重命名:
export * from './jwt.js';
export { default as jwtDefault } from './jwt.js';
export * from './date.js';
export { default as dateDefault } from './date.js';
实际上,大部分我们并不推荐混用默认导出和具名导出,它会影响我们判断,要么全部具名导出,要么全部默认导出:
export {}
export default {}
各位可以思考一下,export default {}
这里的大括号是固定写法还是对象?
Nodejs中的模块化应用
CommonJS modules are the original way to package JavaScript code for Node.js. Node.js also supports the ECMAScript modules standard used by browsers and other JavaScript runtimes.
文档中的意思是,CommonJS
是Nodejs最初的模块化方式,但是同样也支持ESM
的模块化标准。
那我们应该使用哪种方式呢?
并没有规定说哪种好哪种不好,根据自己的需求和实际项目进行选择,记住一点,不要混用。
官方为我们设定了两种扩展文件格式.cjs
和.mjs
用于区分两种模块化。
默认情况下,使用.js
默认为CommonJS
,但是不易判断。
.cjs
文件扩展名用于明确表示一个文件是CommonJS
模块。
如果你的项目需要兼容一些旧的代码或者插件可以使用.cjs
,如果你在.cjs
中使用了ESM
的语法,编译器将会报错。
.mjs
文件扩展名用于明确表示一个文件是 ECMAScript Modules (ESM)。
同样你在.mjs
文件中使用CommonJS
规范的语句,编译器也会报错:
ReferenceError: require is not defined in ES module scope, you can use import instead
项目中的配置
我们可以在package.json
中配置"type"
来决定使用哪种模块化,这也是为什么有的人在Nodejs中使用ESM
报错的原因。
设置 "type": "module"
,然后使用.js
文件扩展名,这样你就可以使用esm
,原来默认的CommonJS
规范会被替代。
在package.json
中设置 "type": "commonjs"
,用于使用CommonJS
的模块化,默认就是这个配置。
注意,如果使用ESM
记得一定要加扩展名,哪怕后缀名是.js
。
另外,即使你配置了 "type": "module"
,你仍然可以在两个.cjs
文件之间使用CommonJS
,因为该配置仅仅决定的是.js
文件的规范,反之亦然。
其他
最后还需要注意一点,由于ESM
是JavaScript原生的语法,因此它支持顶层 await,而CommonJS
不支持,相信你有自己的判断决定使用哪一个。
还有一点,如果你在web项目中使用模块化,web项目只能是浏览器端运行了,那你在使用script标签导入最顶层的模块时,需要加上属性type="module"
,如:
<script src="./utils.js" type="module"></script>
type="module"
这个属性告诉浏览器,utils.js
是一个ESM
模块。
如果省略了type="module"
,export
和import
将会失效,utils.js
中的变量也会变成全局变量。
模块化是前端工程化一个非常重要的功能。