【3】深入理解模块化-Nodejs开发入门

模块化的概念

如果你做过一个较为完整的网站项目的话,你会发现,无论是自己编写的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.exportsNode.js提供的用于导出模块的对象。每个文件在Node.js中都被视为一个模块,模块内部的代码默认是私有的。通过module.exports可以将模块中的某些功能或数据暴露出去,供其他模块使用。

exportsmodule.exports的一个引用。可以通过exports添加属性,但最终导出的是 module.exports

我们需要重点关注一下module.exportsexports的区别。

上面说到,exportsmodule.exports的一个引用,这个“引用”的意思就是JavaScript语法中,对象的引用。

我们用代码模拟就是:

const exports = module.exports;

我们知道,在JavaScript中,将存储对象Obj的变量A直接通过赋值符号=赋给变量B的时候,实际上是将对象的堆地址赋给了变量B,变量A和变量B存储了相同的堆地址。如果我们修改的变量B的属性,在获取的变量A的相同属性时会发现也被修改了。但是如果我们直接将的变量B赋为其他数据的时候,它的栈地址由原来对象Obj的堆地址变成了其他值,也就是失去了对象Obj的引用。

而在上面module.exportsexports的比较中,我们说“module.exportsNode.js提供的用于导出模块的对象”,因此module.exports始终都是导出的数据,它可以被赋为任意类型的数据,无论是基本类型还是引用类型。

但是对于exports来说就有问题了,默认情况下exportsmodule.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

我们上面说了,exportsmodule.exports的引用,它们指向共同的堆地址,但是它们不是一个变量,它们是两个,只要任何一个发生变化,都会与另一个失去引用。区别是module.exports被直接赋值时,仍然能导出,而exports无法导出而已。

那我们到底应该用哪一个来导出呢?

实际上,我们建议始终使用module.exports,且以对象的形式进行导出,如:

module.exports = {
    add,
    subtract,
}

永远不要使用exports可以解决所有问题。

导入

前面我们说utils.js是一个模块,模块内部导出了一个对象,对象有addsubtract两个对象,那我们如何导入呢?

其实上面已经写过了,通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 ModulesECMAScript模块是 JavaScript 的官方模块化标准,自ECMAScript 2015(ES6)起被正式引入。它为 JavaScript 提供了原生的模块系统,使得代码的组织、重用和维护更加高效。

如果你经常阅读一些前端工程化相关的项目文档的话,你是会经常看到esm这个词的。

CommonJS的模块化不同的是,ESMJavaScript语言本身的功能,不依赖于特定的运行环境(如浏览器或 Node.js),只要有支持ESMJavaScript引擎即可使用。

那其实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.jsdate.jsvalidate.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"exportimport将会失效,utils.js中的变量也会变成全局变量。

模块化是前端工程化一个非常重要的功能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小鱼计算机

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值