Skip to content

模块化

Nodejs主流支持两套模块化规范,社区维护的CommonJS以及官方标准的ES Module

目前主流的模块化规范:

  • CommonJS
  • ES Module
  • AMD
  • CMD
  • UMD

INFO

UMD规范实际上是一种通用写法,可以适配现存主流的模块规范AMD + CMD + 浏览器全局变量的结合。

模块就是一个存在使用导入或导出的JS文件,它实现部分功能,对外隐藏内部实现,向外暴漏接口供其他模块适用。

核心要素有两点:隐藏暴漏。使用模块的人只需要关心怎么使用,而不用关心内部实现。

CommonJS

CommonJS规范是一种运行时同步加载模块的方式。通过require引用加载模块和exports对外输出接口。

  • module.exports
  • exportsmodule.exports的引用
  • require用于加载模块文件(加载模块文件执行模块内的JavaScript代码并返回该模块的module.exports对象)

CommonJS细节

  • 模块为同步加载,加载顺序是由书写代码的顺序决定的。

  • 模块运行取决于该模块有没有被其他模块所引入。

  • exports实际是module.exports的引用,在默认情况下,模块内的module.exportsexportsthis这三者指向的是同一个引用,但如果给这三个任一个重新赋值,都会使它们的关系发生变化。module.exportsexports尽量避免修改引用。

    js
    // 默认情况下,this、module.exports、exports指向同一个对象(比如temp)
    this.a = 1 // temp = { a: 1 }
    exports.b = 2 // temp = { a: 1, b: 2 }
    // 引用关系发生改变,指向另外对象,如temp2
    exports = { // temp2 = { c: 3 }
      c: 3
    }
    // 引用关系发生改变,指向另外对象,如temp3
    module.exports = {//temp3 = { d: 4 }
      d: 4
    }
    exports.e = 5 // temp2 = { c: 3, e: 5 }
    this.f = 6 // temp = { a: 1, b: 2, f: 6 }
  • CommonJS的代码运行在模块作用域内,不会对全局进行变量污染

  • 模块可以多次加载,但只会在第一次加载时运行,然后会被缓存起来,之后的加载都会执行缓存结果。只有清除缓存才能让模块重新执行(通过操作require.cache可达到清除缓存)。

后缀名

在使用require进行导入时,如果省略了文件后缀名,Nodejs会按顺序分别尝试补全文件名:

  1. .js
  2. .json
  3. .node

如果在加载时,上述后缀名的文件都查找不到,会判断该路径是否为目录视为第三方模块,会去查找目录下的package.jsonmain字段,如果不存在,就会去加载目录下的index.[js|json|node]

TIP

举个🌰:require('./src/page')

./src/page => ./src/page.[js|json|node] => ./src/page/index.[js|json|node]

还有一种情况,在加载第三方模块时,该模块在其下的package.json指定了main字段的文件路径,在加载模块时,会优先去查找这个指定的文件路径,如果没有指定,则会去查找其下的index文件。

模块查找

Nodejs对于模块的查找,更多是npm对于模块查找的解释。

加载过程

实现解析

模块的导入,实际上是将开发人员编写的代码文件,用一个函数包裹,读入并执行这个函数,返回该模块的module.exports对象。

js
// .js文件处理
const wrapper = ["(function (exports, require, module, __filename, __dirname) { ", "\n})"];
// 3.将开发人员编写的代码,包裹在一个函数中,得到以下结果
//(function (exports, require, module, __filename, __dirname) {
// const xm = 18 => 开发人员自己写的代码,通过wrap方法组装起来
//\n});
let wrap = function (script) {
  return wrapper[0] + script + wrapper[1];
};

function wrapSafe(filename, content, cjsModuleInstance) {
  const wrapper = Module.wrap(content);

  // 将wrapper后的方法字符串,通过vm虚拟机的Script类,实例化为一个script执行结果,可以视为 window.eval()
  const script = new Script(wrapper, {
    filename,
  });

  //返回一个可执行的全局上下文函数
  return script.runInThisContext({
    displayErrors: true,
  });
}

Module.prototype._compile = function (content, filename) {
  let redirects;
  // 4.创建module对象
  const compiledWrapper = wrapSafe(filename, content, this);

  const dirname = path.dirname(filename);
  // 5.创建module对象
  const require = makeRequireFunction(this, redirects);
  const exports = this.exports;
  const thisValue = exports;
  // 这就是为什么在模块中用this操作属性会影响到module对象
  const module = this;

  // 5.将exports,require,module,filename,dirname映射参数到compiledWrapper方法中,实际上也就是wrapper[0]中的那几个参数:(exports, require, module, __filename, __dirname)
  let result = ReflectApply(compiledWrapper, thisValue, [exports, require, module, filename, dirname]);

  // 6.返回执行结果
  return result;
};

// ↑
Module._extensions[".js"] = function (module, filename) {
  const cached = cjsParseCache.get(module);
  let content;
  if (cached?.source) {
    // 1.如果存在缓存,直接返回缓存结果
    content = cached.source;
    cached.source = undefined;
  } else {
    // 2.否则从文件系统读取源代码
    content = fs.readFileSync(filename, "utf8");
  }

  //读取package.json文件
  const pkg = readPackageScope(filename);
  //如果package.json文件中有type字段,并且type字段的值为module,并且你使用了require
  //则抛出一个错误,提示不能在ES模块中使用require函数
  if (pkg?.data?.type === "module") {
    const parent = moduleParentCache.get(module);
    const parentPath = parent?.filename;
    const packageJsonPath = path.resolve(pkg.path, "package.json");
    const usesEsm = hasEsmSyntax(content);
    const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath, packageJsonPath);

    throw err;
  }

  // 3.调用_compile方法,将js源码编译一个模块函数包裹的代码
  module._compile(content, filename);
};

加载过程

  • 模块在首次被引入时,会执行一次,运行结果会被缓存起来,等下次再被引入,会使用缓存结果
  • 代码由上自下同步加载
  • 模块的加载过程实际上就是一个图形的数据结构遍历过程,会进行一个深度优先遍历加载每个模块。所以存在循环依赖问题,会在一次次的深度优先遍历标记已经访问中解决。

图数据结构深度优先遍历基本步骤:

  1. 从图中的某个顶点开始,标记该顶点为已访问。
  2. 选择一个与当前顶点相邻且未被访问的顶点,移动到该顶点,并标记为已访问——module.loaded
  3. 重复步骤2,直到当前顶点没有未被访问的相邻顶点。
  4. 回溯到上一个顶点,检查是否有未被访问的相邻顶点。
  5. 重复步骤2-4,直到所有顶点都被访问过。

ES Module

ESM是官方发布的标准模块化方案,是一种静态结构的模块化方案,在编译时就能确定模块的依赖关系。能更方便执行一些优化操作,比如:按需加载,方便Tree shaking

ESM是通过import引用加载模块和export对外输出接口。

  • 导入语句必须出现在模块顶层,导入时文件路径必须是确定的,不能是动态的
  • ESM导入的变量是引用传递
  • 每个ESM都有自己的作用域,作用域内部的变量不会污染全局,且顶层thisundefined
  • 支持动态导入,允许按需加载模块,返回一个Promise,包含了模块的默认导出和具名导出。

具名导出

  • 声明并导出/导入
js
// a.js
export const person = {
  name: '张三',
  age: 18
}

// index.js
// 在没有脚手架帮助的情况下,需要添加上后缀名
import { person } from './a.js'
  • 别名导出/导入
js
// a.js
const hp = 100
const attack = 50

// 用as可以对导出的内容别名
export {
  hp as heroHp,
  attack as heroAttack
}

// index.js
// 同样导入也可以进行别名
import { heroHp as heroHp2, heroAttack as heroAttack2 } from './a.js'

// 或者命名空间别名的导入
import * as paperInfo from './a.js'
const { heroHp, heroAttack } = paperInfo
  • 集中导出/导入
js
// a.js
const foo = 'foo'
const fn = function(){}

// 用as可以对导出的内容别名
export {
  foo,
  fn
}

// index.js
// 命名空间别名的导入
import * as aModule from './a.js'
const { foo, fn } = aModule
  • exportimport结合适用
js
// module/a.js
const foo = 'foo'
const fn = function(){}

export {
  foo,
  fn
}

// module/index.js
/**
 * 实际上是导入导出的结合体,分为两步走
 * import xxx from 'path'
 * export [default] xxx
 * 但是在中转层只能允许存在一个default默认导出
 */
export * from './a.js'
export { foo as default } from './a.js'

// main.js
import {} from './module'

默认导出

一个模块用default关键字表示默认导出,有且只能存在一个默认导出。

js
// a.js
// 默认导出可以具名也能匿名
// export default age = 18
// export default 18;
// export default {
//   age: 18,
// };
// export default function () {}
export default () => {};


// index.js
import aModule from './a.js'

导入并导出

exportimport可以结合一起使用,实际上是将两个步骤——导入并导出,合成了一个步骤的语法糖。

  1. 导入:import xxx from 'path'
  2. 导出:export [default] xxx
  3. 合成:export * from 'path'export { xxx } from 'path'
js
// module/a.js
const foo = 'foo'
const fn = function(){}

export {
  foo,
  fn
}

// module/index.js
// 导入并导出
export * from './a.js'
export { foo as default } from './a.js'
// 或者这种写法
// export * as default from './a.js'


// main.js
import defaultExport,{ fn } from './module'

这种写法存在多个默认导出时,必须要在中转层模块中声明指定的默认导出。

这种方式是为了方便统一暴漏所有接口,所以在模块之上加多了一层中转层模块统一导出。

动态导入

ESM支持用import动态导入模块,此时的import作为一个函数,返回一个Promise

用这种方式导入的模块是懒加载(也叫按需加载)。

js
(async function (){
  const rst = await import('./a.js')
})()

区别

  • CommonJS是模块属性值的浅拷贝(在webpack编译CommonJS的代码中有体现)

    js
    // a.js
    exports.num = 1
    
    // index.js
    const { num } = require("./a");
    console.log(num);
    
    // 打包后的代码
    (() => {
      var __webpack_modules__ = {
        967: (module) => {
          let num = 1;
          module.exports = {
            num,
          };
        },
      };
      var __webpack_module_cache__ = {};
    
      function __webpack_require__(moduleId) {
        var cachedModule = __webpack_module_cache__[moduleId];
        if (cachedModule !== undefined) {
          return cachedModule.exports;
        }
        var module = (__webpack_module_cache__[moduleId] = {
          exports: {},
        });
    
        __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    
        return module.exports;
      }
    
      // 浅拷贝的解构复制属性值
      const { num } = __webpack_require__(967);
      console.log(num);
    })();

    ESM是引用传递,所以尽量保持导出的内容是《纯》的,没有副作用的。

    js
    // a.js
    export let num = 1
    
    setTimeout(()=>{
      num += 1
    },1000)
    
    // index.js
    import { num } from './a.js'
    
    console.log(num) // 1
    
    setTimeout(()=>{
      console.log('index---',num) // 2
    },2000)
  • ESMCommonJS想要混合使用的前提是,需要打包工具的支持,不然只能两者选其一。

  • ESMexport、import导入导出,CommonJSmodule.exports、require导入导出。

  • ESM解析时处理模块依赖关系,CommonJS运行时导入加载依赖。

集成环境

Nodejs默认使用CommonJS规范,如果集成了TypeScript的话,TS是默认开启了ESM,所以会造成导入的报错,有三种常见的方式不需要编译也能在Nodejs中执行TS

ts-node

使用nodemon工具持续监听并更新代码,ts-node执行TS文件,TS提供类型帮助。

  1. 安装依赖

    shell
    pnpm add ts-node nodemon typescript -D
  2. 初始化tsconfig.json

    shell
    npx tsc init

    并添加以下配置

    json
    {  
        "compilerOptions": {
            "module": "CommonJS",
            "types": ["node"],
        },
        "ts-node": {
            "esm": true
        },
    }
  3. package.json添加启动脚本命令

    json
    {
      "engines": {
        "node": "<=18.18.0"
      },
      "script":{
        "start": "nodemon --watch src --ext ts,js --exec ts-node --esm ./src/index.ts"
      }
    }

image-20241224020513020

TIP

使用ts-node的方式要注意Node的版本^13-18.18.0

tsx

使用tsx相对ts-node就简单很多,不需要额外的配置

  1. 安装依赖

    shell
    pnpm add tsx -D
  2. 添加脚本命令

    json
    {
      "script":{
        "start": "tsx watch ./src/index.ts"
      }
    }

image-20241224023657645

如有转载或 CV 的请标注本站原文地址