js - commonjs

js - commonjs

what

  • CommonJS】是一种规范,不是具体实现。就像 Promise a+ 规范一样
  • 【Node】实现了CommonJS这种规范

environment

需要在node环境中运行,浏览器环境默认不支持CommonJS

三个核心关键字

【module.exports】、【exports】、【require】

特点

  • 每一个 JavaScript 文件就是一个独立模块,其作用域仅在模块内,不会污染全局作用域。
  • 模块可以加载多次,但只会在第一次加载时运行一次,然后运行结果就会被缓存起来。下次再加载是直接读取缓存结果。模块缓存是可以被清除的。
  • 模块的加载是同步的,而且是按编写顺序进行加载
  • 一个模块包括 require、module、exports 三个核心变量。
  • 其中 module.exports、exports 负责模块的内容导出。后者只是前者的“别名”,若使用不当,还可能会导致无法导出预期内容。其中 require 负责其他模块内容的导入,而且其导入的是其他模块的 module.exports 对象。

Module 对象

前面打印的 module 就是 Module 的实例对象。每个模块内部,都有一个 module 对象,表示当前模块。它有以下属性:

// Module 构造函数
function Module(id = '', parent) {
  this.id = id
  this.path = path.dirname(id)
  this.exports = {}
  moduleParentCache.set(this, parent)
  updateChildren(parent, this, false)
  this.filename = null
  this.loaded = false
  this.children = []
}

源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)

module.id:返回字符串,表示模块的标识符,通常这是完全解析的文件名。
module.path:返回字符串,表示模块的目录名称,通常与 module.id 的 path.dirname() 相同。
module.exports:模块对外输出的接口,默认值为 {}。默认情况下,module.exports 与 exports 是相等的。
module.filename:返回字符串,表示模块的完全解析文件名(含绝对路径)。
module.loaded:返回布尔值,表示模块是否已完成加载或正在加载。
module.children:返回数组,表示当前模块引用的其他模块的实例对象。
module.parent:返回 null 或数组。若返回值为数组时,表示当前模块被其他模块引用了,而且每个数组元素表示被引用模块对应的实例对象。
module.paths:返回数组,表示模块的搜索路径(含绝对路径)。
module.isPreloading:返回布尔值,如果模块在 Node.js 预加载阶段运行,则为 true。

require

查找算法

require() 参数很简单,那么 require() 内部是如何查找模块的呢?

简单可以分为几类:

  • 加载 Node 内置模块
    形式如:require('fs')、require('http') 等。

  • 相对路径、绝对路径加载模块
    形式如:require('./file')、require('../file')、require('/file')。

  • 加载第三方模块(即非内置模块)
    形式如:require('react')、require('lodash/debounce')、require('some-library')、require('#some-library') 等。

其中,绝对路径形式在实际项目中几乎不会使用(反正我是没用过)、而 require('#some-library') 形式目前仍在试验阶段...

以下基于 Node.js 官网 相关内容翻译并整理的版本(存档)

场景:在 `Y.js` 文件下,`require(X)`,Node.js 内部模块查找算法:

1. 如果 `X` 为内置模块的话,立即返回该模块;

   因此,往 NPM 平台上发包的话,`package.json` 中的 `name` 字段不能与 Node.js 内置模块同名。

2. 如果 `X` 是以绝对路径或相对路径形式,根据 `Y` 所在目录以及 `X` 的值以确定所要查找的模块路径(称为 `Z`)。

  a. 将 `Z` 当作「文件」,按 `Z`、`Z.js`、`Z.json`、`Z.node` 顺序查找文件,若找到立即返回文件,否则继续往下查找;
  b. 将 `Z` 当作「目录」,
     1)查找 `Z/package.json` 是否存在,若 `package.json` 存在且其 `main` 字段值不为虚值,将会按照其值确定模块位置,否则继续往下;
     2)按 `Z/index.js`、`Z/index.json`、`Z/index.node` 顺序查找文件,若找到立即返回文件,否则会抛出异常 "not found"。

3. 若 `X` 是以 `#` 号开头的,将会查找最靠近 `Y` 的 `package.json` 中的 `imports` 字段中 `node`、`require` 字段的值确认模块的具体位置。
  (这一类现阶段用得比较少,后面再展开介绍一下)
   // https://github.com/nodejs/node/pull/34117

4. 加载自身引用 `LOAD_PACKAGE_SELF(X, dirname(Y))`

    a. 如果当前所在目录存在 `package.json` 文件,而且 `package.json` 中存在 `exports` 字段,
       其中 `name` 字段的值还要是 `X` 开头一部分,
       满足前置条件下,就会匹配 subpath 对应的模块(无匹配项会抛出异常)。
      (这里提到的 subpath 与 5.b.1).1.1 类似)
    b. 若不满足 a 中任意一个条件均不满足,步骤 4 执行完毕,继续往下查找。

5. 加载 node_modules `LOAD_NODE_MODULES(X, dirname(Y))`
   a. 从当前模块所在目录(即 `dirname(Y)`)开始,逐层查找是否 `node_modules/X` 是否存在,
      若找到就返回,否则继续往父级目录查找 `node_modules/X` ,依次类推,直到文件系统根目录。
   b. 从全局目录(指 `NODE_PATH` 环境变量相关的目录)继续查找。
  
   若 `LOAD_NODE_MODULES` 过程查找到模块 X(可得到 X 对应的绝对路径,假定为 M),将按以下步骤查找查找:
      1) 若 Node.js 版本支持 `exports` 字段(Node.js 12+),
          1.1 尝试将 `M` 拆分为 name 和 subpath 形式(下称 name 为 `NAME`)

              比如 `my-pkg` 拆分后,name 为 `my-pkg`,subpath 则为空(为空的话,对应  `exports` 的 "." 导出)。
              比如 `my-pkg/sub-module` 拆分后,name 为 `my-pkg`,subpath 为 `sub-module`。
              请注意带 Scope 的包,比如 `@myorg/my-pkg/sub-module` 拆分后 name 应为 `@myorg/my-pkg`,subpath 为 `sub-module`。

          1.2 如果在 M 目录下存在 `NAME/package.json` 文件,而且 `package.json` 的 `exports` 字段是真值,
              然后根据 subpath 匹配 `exports` 字段配置,找到对应的模块(若 subpath 匹配不上的将会抛出异常)。
              请注意,由于 `exports` 支持条件导出,而且这里查找的是 CommonJS 模块,
              因此 `exports` 的 `node`、`require`、`default` 字段都是支持的,键顺序更早定义的优先级更高。

          1.3 如果以上任意一个条件不满足的话,将继续执行 2) 步骤

      2) 将 X 以绝对路径的形式查找模块(即前面的步骤 2),若找不到步骤 5 执行完毕,将会跑到步骤 6。

6. 抛出异常 "not found"

如果不是开发 NPM 包,在实际使用中的话,要不并没有以上那么多复杂的步骤,很容易理解。但深入了解之后有助于平常遇到问题更快排查出原因并处理掉。如果你是发包的话,可以利用 exports 等按一定的策略导出模块。

源码

源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)

// Loads a module at the given file path. Returns that module's `exports` property.
Module.prototype.require = function (id) {
  validateString(id, 'id')
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string')
  }
  requireDepth++
  try {
    return Module._load(id, this, /* isMain */ false)
  } finally {
    requireDepth--
  }
}
/**
 * 检查所请求文件的缓存
 * 1. 如果缓存中已存在请求的文件,返回其导出对象(module.exports)
 * 2. 如果请求的是原生模块,调用 `NativeModule.prototype.compileForPublicLoader()` 并返回其导出对象
 * 3. 否则,为该文件创建一个新模块并将其保存到缓存中。 然后让它在返回其导出对象之前加载文件内容。
 */
Module._load = function (request, parent, isMain) {
  let relResolveCacheIdentifier
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id)
    // Fast path for (lazy loaded) modules in the same directory. The indirect
    // caching is required to allow cache invalidation without changing the old
    // cache key names.
    relResolveCacheIdentifier = `${parent.path}\x00${request}`
    const filename = relativeResolveCache[relResolveCacheIdentifier]
    if (filename !== undefined) {
      const cachedModule = Module._cache[filename]
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true)
        if (!cachedModule.loaded) return getExportsForCircularRequire(cachedModule)
        return cachedModule.exports
      }
      delete relativeResolveCache[relResolveCacheIdentifier]
    }
  }

  // 1️⃣ 获取 require(id) 中 id 的绝对路径(filename 作为模块的标识符)
  const filename = Module._resolveFilename(request, parent, isMain)

  if (StringPrototypeStartsWith(filename, 'node:')) {
    // Slice 'node:' prefix
    const id = StringPrototypeSlice(filename, 5)

    const module = loadNativeModule(id, request)
    if (!module?.canBeRequiredByUsers) {
      throw new ERR_UNKNOWN_BUILTIN_MODULE(filename)
    }

    return module.exports
  }

  // 2️⃣ 缓动是否存在缓存
  // 所有加载过的模块都缓存于 Module._cache 中,以模块的绝对路径作为键值(cache key)
  const cachedModule = Module._cache[filename]

  if (cachedModule !== undefined) {
    updateChildren(parent, cachedModule, true)
    if (!cachedModule.loaded) {
      const parseCachedModule = cjsParseCache.get(cachedModule)
      if (!parseCachedModule || parseCachedModule.loaded) return getExportsForCircularRequire(cachedModule)
      parseCachedModule.loaded = true
    } else {
      // 若该模块缓存过,则直接返回该模块的 module.exports 属性
      return cachedModule.exports
    }
  }

  // 3️⃣ 加载 Node.js 原生模块(内置模块)
  const mod = loadNativeModule(filename, request)
  if (mod?.canBeRequiredByUsers) return mod.exports

  // 4️⃣ 若请求模块无缓存,调用 Module 构造函数生成模块实例 module
  const module = cachedModule || new Module(filename, parent)

  // 如果是入口脚本,将入口模块的 id 置为 "."
  if (isMain) {
    process.mainModule = module
    module.id = '.'
  }

  // 5️⃣ 将模块存入缓存中
  // ⚠️⚠️⚠️ 在模块执行之前,提前放入缓存,以处理「循环引用」的问题
  // See, http://nodejs.cn/api/modules.html#cycles
  Module._cache[filename] = module
  if (parent !== undefined) {
    relativeResolveCache[relResolveCacheIdentifier] = filename
  }

  let threw = true
  try {
    // 6️⃣ 执行模块
    module.load(filename)
    threw = false
  } finally {
    if (threw) {
      delete Module._cache[filename]
      if (parent !== undefined) {
        delete relativeResolveCache[relResolveCacheIdentifier]
        const children = parent?.children
        if (ArrayIsArray(children)) {
          const index = ArrayPrototypeIndexOf(children, module)
          if (index !== -1) {
            ArrayPrototypeSplice(children, index, 1)
          }
        }
      }
    } else if (
      module.exports &&
      !isProxy(module.exports) &&
      ObjectGetPrototypeOf(module.exports) === CircularRequirePrototypeWarningProxy
    ) {
      ObjectSetPrototypeOf(module.exports, ObjectPrototype)
    }
  }

  // 7️⃣ 返回模块的输出接口
  return module.exports
}

notice

同步

赋值给 module.exports 必须立即完成,不能在任何回调中完成(应在同步任务中完成)。
比如,在 setTimeout 回调中对 module.exports 进行赋值是“不起作用”的,原因是 CommonJS 模块化是同步加载的。

// module-a.js
setTimeout(() => {
  module.exports = { welcome: 'Hello World' }
}, 0)

// module-b.js
const a = require('./a')
console.log(a.welcome) // undefined

// ❌ 错误示例

// module-a.js
const EventEmitter = require('events')
module.exports = new EventEmitter() // 同步任务中完成对 module.exports 的赋值

setTimeout(() => {
  module.exports.emit('ready') // ❓ 这个会生效吗?
}, 1000)

// module-b.js
const a = require('./module-a')
a.on('ready', () => {
  console.log('module a is ready')
})

// ⚠️ 执行 `node module-b.js` 命令运行脚本,以上 ready 事件可以正常响应,
// 原因 require() 会对模块输出值进行“浅拷贝”,因此 module-a.js 中的 setTimeout 是可以更新 EventEmitter 实例对象的。

module.exports 属性替换

当 module.exports 属性被新对象完全替换时,通常也会“自动”重新分配 exports(自动是指不显式分配新对象给 exports 变量的前提下)。但是,如果使用 exports 变量导出新对象,则必须“手动”关联 module.exprots 和 exports,否则无法按预期输出模块值。

// module-a.js
setTimeout(() => {
  module.exports = { welcome: 'Hello World' }
}, 0)

// module-b.js
const a = require('./a')
console.log(a.welcome) // undefined

// ❌ 错误示例
// module-a.js
const EventEmitter = require('events')
module.exports = new EventEmitter() // 同步任务中完成对 module.exports 的赋值

setTimeout(() => {
  module.exports.emit('ready') // ❓ 这个会生效吗?
}, 1000)

// module-b.js
const a = require('./module-a')
a.on('ready', () => {
  console.log('module a is ready')
})

// ⚠️ 执行 `node module-b.js` 命令运行脚本,以上 ready 事件可以正常响应,
// 原因 require() 会对模块输出值进行“浅拷贝”,因此 module-a.js 中的 setTimeout 是可以更新 EventEmitter 实例对象的。

module.exports 和 exports

  • module.exports 是真正决定导出对象的【重要角色】
  • exports 仅仅是 module.exports 的【一个引用】
// 源码
module.exports={}

exports = module.exports
module.exports = {
  name: 'Frankie',
  age: 20,
  sayHi: () => console.log('Hi~')
}

// 相当于
exports.name = 'Frankie'
exports.age = 20
exports.sayHi = () => console.log('Hi~')

若模块只对外输出一个接口,使用不当,可能会无法按预期工作。比如:

// ❌ 以下模块的输出是“无效”的,最终输出值仍是 {}
exports = function () { console.log('Hi~') }

原因很简单,在默认情况下 module.exports 属性和 exports 变量都是同一个空对象 {}(默认值)的引用(reference),即 module.exports === exports。

当对 exports 变量重新赋予一个基本值或引用值的时候, module.exports 和 exports 之间的联系被切断了,此时 module.exports !== exports,在当前模块下 module.exports 的值仍为 {},而 exports 变量的值变为函数。而 require() 方法的返回值是所引用模块的 module.exports 的浅拷贝结果。

正确姿势应该是:

module.exports = export = function () { console.log('Hi~') } // ✅

使用类似处理,使得 module.exports 与 exports 重新建立关联关系。

这里并不存在任何难点,仅仅是 JavaScript 基本数据类型和引用数据类型的特性罢了。如果你还是分不清楚的话,建议只使用 module.exports 进行导出,这样的话,就不会有问题了。

多次引入,模块代码会执行多次吗?

  • 【加载次数】每个模块只会加载运行一次;因为每个模块对象module内部都有一个loaded属性,用来保证仅仅加载一次。
  • 【加载顺序】按照深度优先搜索(DFS,depth first search)加载顺序

example

测试代码,require module开头的日志,会输出几次?

// module.js
console.log('module');
let name = 'name',
  age = 100;
console.log('module.exports');

module.exports = {
  name,
  age,
};
console.log('module.exports end');


// index.js
console.log('start');

let module1 = require('./module');
let module2 = require('./module');

console.log('index');
console.log(module1);
console.log(module2);
console.log('index end');
  • 只会输出一次

use

// index.js
const why = require("./why/why");
console.log(why);

// why.js
var name = "whyName";
var age = 18;
module.exports = {
  name,
  age,
};

// 输出结果
$ node index.js
{ name: 'whyName', age: 18 }

原理

理解CommonJS的内部原理图解【重要,其实也是内存布局图】

info、module.exports、why 指向内存中的同一个对象

缺点(对比ES Module)

  • ① 属于非官方的方案
  • ② 比较适用于node,因为CommonJS加载模块是同步的

参考资料

CommonJS规范
import 和 require区别
深入JavaScript Day25 - 模块化、CommonJS、module.exports、exports、require
细读 JS | JavaScript 模块化之路
浏览器加载 CommonJS 模块的原理与实现

posted @ 2022-03-23 15:58  zc-lee  阅读(120)  评论(0编辑  收藏  举报