Skip to main content

模块化

模块化简述

模块化很好的解决了如下问题:

  • 变量名冲突
  • 作用域冲突
  • 引入多个 script

在社区中推广了许多模块化规范,如今最常用的两个就是 CommomJS 和 ES Module,其中 Node 的模块化使用的就是 CommonJS 规范。

CommonJS 与 Node

CommonJS 是一种模块化规范,简称 CJS,最初是在浏览器以外的地方使用的,Node 是 CommonJS 在服务端一个具有代表性的体现。

  • 在 Node 中 每一个 JS 文件 就是一个单独的模块
  • 模块中包含 CommonJS 核心变量:exportsmodule.exportsrequire

模块化导出

exportsmodule.exports 负责对模块中的内容进行导出,下面演示一下使用 CommonJS 规范将模块中的内容导出的基本方式。

只需要将当前需要导出的内容作为属性赋值给 exports 全局对象。

bar.js
const name = 'Yukee-798';
const age = 18;

let message = 'I am Yukee.';

function sayHello() {
console.log('hello' + name);
}

// CommonJS 导出规范
exports.name = name;
exports.age = age;
exports.message = message;
exports.sayHello = sayHello;

每个模块中都具有一个 exports 对象,默认情况下是一个不含有任何属性的对象 {},并且它们在堆内存中都是独一无二的,CommonJS 模块化规范导出模块的本质就是给模块中的 exports 对象添加相应的 key-value,其中 value 就是我们需要导出的内容。

简单来说,CommonJS 模块化规范实现的模块化本质就是 对象的引用赋值

module.exports 与 exports

下面我们来讨论一下 module.exports,有了模块中有了 exports 对象就可以正常导出模块中的内容了,那 module.exports 有啥用呢?

实际上在 CommonJS 规范中没有 module.exports 这个概念,它是在 Node 中单独实现的,本质上 module.exports === exports,而 module 对象就是当前模块对 Module 类的实例化,可以理解为在 Node 中每一个 js 文件对应独一无二的 module 对象。

在 Node 中的模块化导出,实际上导出的是 module.exports 这个变量,在初始状态下 module.exports = exports = {},因此才可以使用 exports 来操控导出的对象,不过一旦我们手动修改了 module.exports 的取值,那么 exports 就无法进行内容导出了,下面可以来看一下他们之间的区别。

// bad
exports = 123;

// good
module.exports = 123;

当我们执行 exports = 123 时,实际上并没有将 123 数值导出,此时 module.exports 还默认是一个空对象 {},因此外部引入该模块时依旧是一个空对象 {},因为本质上导出的是 module.exports 这个变量。

当执行 module.exports = 123 时,此时导出的数据就由空对象 {} 变为了 123。这样就很容易能够解释:

使用 exports 导出内容时,必须如像下面这样做,并且它只能够导出对象。

// ... 省略

exports.name = name;
exports.age = age;
exports.message = message;
exports.sayHello = sayHello;

使用 exports 是不能够直接赋值给一个对象来导出内容的。

// ... 省略

// bad
exports = {
name,
age,
message,
sayHello,
};

因为本质上导出的是 module.exports 变量,因此使用 module.exports 就可以这样做。

// ... 省略

// good
module.exports = {
name,
age,
message,
sayHello,
};

并且还可以导出非对象的数据。

module.exports = 213;

模块化导入

require 负责导入其他模块(自定义模块、系统模块、第三方库模块)内容到当前模块中,下面演示一下使用 CommonJS 规范将上述导出的内容导入到当前模块的基本方式。

需要调用 require 函数,它会返回目标模块中的 module.exports 变量。

main.js
const { name, age, message, sayHello } = require('./bar');

// 接下来就可以在 main.js 这个模块内部使用导入的数据了

这里 require('./bar') 的底层中所做的一切就是为了获取 bar.js 模块内部的 module.exports 变量,它的返回值就是 bar.js 模块内部的 module.exports 变量,并且在调用 require('./bar') 的时候还会完全执行一遍 bar.js 中的代码。

require 查找规则

require(X)

情况一

X 是 node 内置的核心模块,比如 pathfshttp 等,那么直接返回该模块。

情况二

X 以 /./../ 开头,说明我们引入的模块是一个本地模块,那么就会去对应的目录内查找,需要注意的是 / 表示从硬盘的根目录查找

  • 如果带有后缀名,比如 require(./bar.js) 则会按照后缀名格式查找对应文件
  • 如果没有后缀名,比如 require(./bar),会按如下顺序:
    1. 查找 ./bar 文件,找到则返回
    2. 查找 ./bar.js 文件,找到则返回
    3. 查找 ./bar.json 文件,找到则返回
    4. 查找 ./bar.node 文件,找到则返回
    5. 如果上述类型的文件都没有查到,则会查找文件夹 ./bar,然后在里面依次按照 index.jsindex.jsonindex.node 的顺序查找

情况三

X 不是核心模块,也不是本地模块,比如我们在 /Users/yukee-798/Downloads/daily/amain/Yukee-798.github.io/test 目录下的 main.js 文件中调用 require('abc'),那么会按照如下顺序去查找 abc 模块:

'/Users/yukee-798/Downloads/daily/amain/Yukee-798.github.io/test/node_modules',
'/Users/yukee-798/Downloads/daily/amain/Yukee-798.github.io/node_modules',
'/Users/yukee-798/Downloads/daily/amain/node_modules',
'/Users/yukee-798/Downloads/daily/node_modules',
'/Users/yukee-798/Downloads/node_modules',
'/Users/yukee-798/node_modules',
'/Users/node_modules',
'/node_modules'

即先查找当前文件夹下是否有 node_modules 文件夹,没有则到上层文件夹查找 node_modules ... 一直返回上层文件夹,直到找到 node_modules 文件夹为止,然后再在 node_modules 里面去查找 abc 文件夹,返回里面的 index.js 模块中的 module.exports 变量。

important

上面的所有路径实际上是 module.paths 属性取值,类型为一个数组。

情况四

如果按照上面所有规则来查找后都没有找到目标模块,则会抛出 Not Found 错误。

模块加载过程

同步性

前面谈到只要使用 require() 引入一个模块时,目标模块中的所有代码都会执行一遍,并且这里补充说明一点,调用 require() 函数加载模块的过程是 同步的

bar.js
console.log('bar');
main.js
require('./bar');
console.log('main')

当我们执行命令行 node ./main.js 时:

  • 调用 require 函数,发现是一个模块引入,会进行模块查找,然后到目标模块 bar.js 中执行代码,打印 'bar'
  • 目标模块所有代码执行结束,require 函数返回,接着执行 main.js 剩余代码,打印 'main'

缓存性

先说结论,当模块第一次被加载时会被缓存起来,后续再到别的地方被导入时不会再被执行,而是直接使用缓存的内容,因此一个模块无论被导入多少次,里面的代码也只会执行一次,请看如下例子。

pic.nm

main.js 中分别导入了 barfoofoo.js 中也导入了 bar,当我们执行命令行 node main.js 时:

  • 进入 main.js 执行到 require('./bar') 时,进入 bar.js 中执行代码
  • bar.js 里面到代码执行完毕后,接着返回 main.js 里面执行 require('./foo'),然后会进入 foo.js 中执行代码
  • foo.js 中会执行 require('.bar'),但是 bar 模块在之前已经被加载过了,因此只会返回缓存中的 bar 模块的 module.exports 变量,不会再执行一遍 bar.js 中的代码
important

在每个模块中的 module.loaded 中记录了当前模块是否被加载过,是一个布尔类型值。

循环引入解决方案

如果在模块导入的过程中产生了闭环,比如下面这样。

pic.sm

本质上还是利用的 缓存性,这样并不会导致模块被重复循环执行。

对于一些复杂的模块引入结构,比如下面这样。

pic.sm

main.js
require('./baz');
require('./foo');

本质上导入顺序就是图的深度优先遍历,main -> baz -> qux -> jay -> bar -> foo,简单了解下即可。

Node 环境中使用 ESModule

第一种方案就是将使用了 ESModule 的模块后缀名改为 .mjs

./modules/foo.mjs
const sayHi = () => {
console.log("hi");
};

export default sayHi;
index.mjs
import sayHi from "./modules/foo.mjs";
sayHi();

第二种方案是在 package.json 的 type 字段取值为 module

CommonJS 与 ESModule 相互转换