nodejs 中的 commonjs

Liu Bowen

Liu Bowen / 2020, 一月, 17

在 ES module 模块语法还未进入 ECMA 262 标准之前,日益高涨的模块需求使得社区不断涌现了各种 JS module 的解决方案,如 commonjs,asynchronous module definition(AMD) 等解决方案。本文内容将主要涵盖现在仍然被广泛使用的 commonjs 来对比标准中的 ES module 定义。逐步分析 commonjs 与现今标准化的 ES module 的优劣。

什么是 commonjs

在 2009 年 commonjs 初版被定义为 ServerJS,主要设计的目的是为 非浏览器端 JS 运行时,如 nodejs ,提供 JS module 的解决方案。

本文将以 nodejs 运行时来作为 commonjs 的代表实现。

In the Node.js module system, each file is treated as a separate moduleref.

在 nodejs 中每一个单独的文件都是一个独立的模块。

commonjs 定义模块

In each module, the module free variable is a reference to the object representing the current module. For convenience, module.exports is also accessible via the exports module-global. module is not actually a global but rather local to each module.

在 commonjs 中每个 JS 文件都默认具有单独的使用函数来模拟的模块词法环境。在该环境中,存在 module.exports 和 exports 对象来作为模块导出出口。其中 exports 对象本质上只是 module.exports 对象的 别名,module.exports 与 exports 是对同一对象的引用。

js
let exports = module.exports

Assignment to module.exports must be done immediately. It cannot be done in any callbacks.

另外,module.exports 只能在模块词法作用域的被同步赋值,在任意的异步操作中赋值 module.exports 是无用的。

However, be aware that like any variable, if a new value is assigned to exports, it is no longer bound to module.exports.

需要声明的是,commonjs 始终默认以 module.exports 属性的值作为当前模块的导出出口ref。那么直接赋值 exports 是不会自动将 exports 对象的新值绑定到模块出口 module.exports 上ref。

导入模块

在 commonjs 中,所有的模块导入都是通过 require 函数来实现。在没有指定导入文件的拓展名的情况下,会按照 .js,.json,.node 的顺序进行查找。

If the exact filename is not found, then Node.js will attempt to load the required filename with the added extensions: .js, .json, and finally .noderef.

js
const aForeignModule = require('path/to/target/module') // .js or .json or .node

本质上所有模块导入都是依赖于 require 函数的执行,即是 运行时 同步导入。通过在 vscode 中进行 nodejs 运行时进行断点可见,所有模块首先以字符串的形式被导入,之后由 compiler 执行。

  1. 根据最新 nodejs 源码 v13.12.0,所有的形如 node index.js 命令在没有开启实验性的 ES module 时,首先会加载 commonjs 的 loader,如下:

    js
    // https://github.com/nodejs/node/blob/v13.12.0/lib/internal/main/run_main_module.js#L17
    const {
      prepareMainThreadExecution
    } = require('internal/bootstrap/pre_execution')
    
    prepareMainThreadExecution(true)
    
    markBootstrapComplete()
    
    // Note: this loads the module through the ESM loader if the module is
    // determined to be an ES module. This hangs from the CJS module loader
    // because we currently allow monkey-patching of the module loaders
    // in the preloaded scripts through require('module').
    // runMain here might be monkey-patched by users in --require.
    // XXX: the monkey-patchability here should probably be deprecated.
    require('internal/modules/cjs/loader').Module.runMain(process.argv[1])
    
  2. executeUserEntryPoint 函数相当于一个 module loader 的 loader,nodejs 在阶段仍然是默认选择 commonjs 的 loader,反之使用 es module 的 loader 来导入入口脚本文件:

    js
    // https://github.com/nodejs/node/blob/v13.12.0/lib/internal/modules/run_main.js#L71
    // For backwards compatibility, we have to run a bunch of
    // monkey-patchable code that belongs to the CJS loader (exposed by
    // `require('module')`) even when the entry point is ESM.
    function executeUserEntryPoint(main = process.argv[1]) {
      const resolvedMain = resolveMainPath(main)
      const useESMLoader = shouldUseESMLoader(resolvedMain)
      if (useESMLoader) {
        runMainESM(resolvedMain || main)
      } else {
        // Module._load is the monkey-patchable CJS module loader.
        Module._load(main, null, true)
      }
    }
    
    1. 若在模块中调用 require 函数时,本质上是在调用 Module.prototype.require 函数:

      js
      // 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--
        }
      }
      

      require 函数的核心,与执行 node index.js 所调用的 loader 逻辑一致,本质上都是通过 Module._load 静态方法来实现脚本加载。

  3. 在 nodejs 的 commonjs 的 loader 实现中,全部依赖于 Module._load 函数,该函数的核心功能不仅在于加载对应的模块文件,而且还构建各个模块间的依赖视图,并缓存已经导入过的模块:

    js
    // https://github.com/nodejs/node/blob/v13.12.0/lib/internal/modules/cjs/loader.js#L896
    // Check the cache for the requested file.
    // 1. If a module already exists in the cache: return its exports object.
    // 2. If the module is native: call
    //    `NativeModule.prototype.compileForPublicLoader()` and return the exports.
    // 3. Otherwise, create a new module for the file and save it to the cache.
    //    Then have it load  the file contents before returning its exports
    //    object.
    Module._load = function (request, parent, isMain) {
      let relResolveCacheIdentifier
      if (parent) {
        // ... 省略无关代码
      }
    
      const filename = Module._resolveFilename(request, parent, isMain)
    
      // 若存在模块缓存,那么直接导出缓存,而不是再次导入目标模块。
      const cachedModule = Module._cache[filename]
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true)
        return cachedModule.exports
      }
    
      const mod = loadNativeModule(filename, request)
      if (mod && mod.canBeRequiredByUsers) return mod.exports
    
      // Don't call updateChildren(), Module constructor already does.
      const module = new Module(filename, parent)
    
      if (isMain) {
        process.mainModule = module
        module.id = '.'
      }
    
      Module._cache[filename] = module
      if (parent !== undefined) {
        relativeResolveCache[relResolveCacheIdentifier] = filename
      }
    
      let threw = true
      try {
        // Intercept exceptions that occur during the first tick and rekey them
        // on error instance rather than module instance (which will immediately be
        // garbage collected).
        if (enableSourceMaps) {
          try {
            module.load(filename)
          } catch (err) {
            rekeySourceMap(Module._cache[filename], err)
            throw err /* node-do-not-add-exception-line */
          }
        } else {
          module.load(filename)
        }
        threw = false
      } finally {
        if (threw) {
          delete Module._cache[filename]
          if (parent !== undefined) {
            delete relativeResolveCache[relResolveCacheIdentifier]
          }
        }
      }
    
      return module.exports
    }
    
  4. 在没有模块缓存的前提下,模块将由 Module.prototype.load 来实现脚本文件的加载。

    js
    // Given a file name, pass it to the proper extension handler.
    Module.prototype.load = function (filename) {
      debug('load %j for module %j', filename, this.id)
    
      assert(!this.loaded)
      this.filename = filename
      this.paths = Module._nodeModulePaths(path.dirname(filename))
    
      const extension = findLongestRegisteredExtension(filename)
      // allow .mjs to be overridden
      if (filename.endsWith('.mjs') && !Module._extensions['.mjs']) {
        throw new ERR_REQUIRE_ESM(filename)
      }
    
      // 根据不同的文件拓展名调用对应的 loader
      Module._extensions[extension](this, filename)
      this.loaded = true
    
      const ESMLoader = asyncESM.ESMLoader
      const url = `${pathToFileURL(filename)}`
      const module = ESMLoader.moduleMap.get(url)
      // Create module entry at load time to snapshot exports correctly
      const exports = this.exports
      // Called from cjs translator
      if (module !== undefined && module.module !== undefined) {
        if (module.module.getStatus() >= kInstantiated)
          module.module.setExport('default', exports)
      } else {
        // Preemptively cache
        // We use a function to defer promise creation for async hooks.
        ESMLoader.moduleMap.set(
          url,
          // Module job creation will start promises.
          // We make it a function to lazily trigger those promises
          // for async hooks compatibility.
          () =>
            new ModuleJob(
              ESMLoader,
              url,
              () =>
                new ModuleWrap(url, undefined, ['default'], function () {
                  this.setExport('default', exports)
                }),
              false /* isMain */,
              false /* inspectBrk */
            )
        )
      }
    }
    
  5. 以 .js 模块为例,Module._extension['.js'] 是 JS 文件对应的 commonjs loader。

    js
    // Native extension for .js
    Module._extensions['.js'] = function (module, filename) {
      if (filename.endsWith('.js')) {
        const pkg = readPackageScope(filename)
        // Function require shouldn't be used in ES modules.
        if (pkg && pkg.data && pkg.data.type === 'module') {
          const parentPath = module.parent && module.parent.filename
          const packageJsonPath = path.resolve(pkg.path, 'package.json')
          throw new ERR_REQUIRE_ESM(filename, parentPath, packageJsonPath)
        }
      }
      const content = fs.readFileSync(filename, 'utf8')
      module._compile(content, filename)
    }
    
  6. 在通过 Module._extension[',js'] 完成 JS 文件导入之后,将通过 Module._compile 实现 JS 解释执行:

    js
    // Run the file contents in the correct scope or sandbox. Expose
    // the correct helper variables (require, module, exports) to
    // the file.
    // Returns exception, if any.
    Module.prototype._compile = function (content, filename) {
      // ... 省略无关代码
      if (getOptionValue('--inspect-brk') && process._eval == null) {
        // ... 省略无关代码
      }
      const dirname = path.dirname(filename)
      const require = makeRequireFunction(this, redirects)
      let result
      const exports = this.exports
      const thisValue = exports
      const module = this
      if (requireDepth === 0) statCache = new Map()
      if (inspectorWrapper) {
        // 执行模块脚本代码
        result = inspectorWrapper(
          compiledWrapper,
          thisValue,
          exports,
          require,
          module,
          filename,
          dirname
        )
      } else {
        result = compiledWrapper.call(
          thisValue,
          exports,
          require,
          module,
          filename,
          dirname
        )
      }
      hasLoadedAnyUserCJSModule = true
      if (requireDepth === 0) statCache = null
      return result
    }
    

由以上源码分析,不难看出,所有不论是通过 node index.js 命令还是通过 require 函数来加载脚本模块本质上都是在导入脚本模块的 副本。在导入脚本模块的同时,不仅会通过 v8 解释并执行脚本内容,而且还会将所有加载过的脚本文件的结果通过 key-value 的数据结构来做缓存。

第一次加载某个模块时,commonjs 会 缓存 该模块的输出值。在使用导入的模块时本质上调用的是 缓存 中该模块的 module.exports 值的 副本 。即使再次通过 require() 导入相同模块,也不会再次加载执行该模块,而是到 依赖视图的缓存 中取值。也就是说,commonjs 中模块无论加载多少次,都只会在第一次加载时真正解释执行,之后的重复加载,始终返回第一次的加载缓存。

ES module

ES module 本质上是 JS 解释器在编译解释阶段确定各个脚本模块之间的依赖关系,并根据依赖关系生成依赖视图。在 JS 代码运行之前所有的模块依赖关系都已经确定。推荐阅读 v8 JS modules 和 es modules a cartoon deep dive(from mozilla blog)。

ES module 与 commonjs 差异

  • 同:

    1. ES module 静态加载语法和 commonjs 的 require 函数均为同步加载;

      ES 2020 已经通过 import() 动态加载语法 实现 异步加载模块。

    2. ES module 静态加载语法和 commonjs 的 require 函数导入的模块都具有 模块词法环境,并且定义在模块中的顶级词法环境中所有变量,具有严格的模块作用域,即对模块外部都是不可见的。

  • 异:

    • 生命周期

      1. ES modules 静态语法在 编译时 compile time(编译为 ast 时)确定模块之前的依赖关系,并建立依赖关系图;

      2. commonjs 本质上是依赖于函数执行来实现模块加载,那么它时基于 运行时 runtime 确定模块的依赖关系;

    • 模块的值

      1. ES modules 导入时,导入的是模块的 只读 引用,每次在调用导入模块的值时,始终都将实时的获取模块的导出值,而不存在模块缓存,每次通过模块出口修改模块内的值,都将影响其他同样导入该模块的模块;

        js
        // dep.js
        let counter = 0
        
        export function mutateCounter() {
          counter++
        }
        
        export function getCounter() {
          return counter
        }
        
        js
        // module-a.js
        import { mutateCounter } from './dep.js'
        
        // 该函数将修改 dep.js 内部 counter 值,并导致所有引入了 getCounter 函数的模块
        // 的执行结果发生变化
        mutateCounter()
        
      2. commonjs 导入时,导入的时模块的值的副本,多次导入时,将始终返回第一次导入的结果。那么被加载模块内部值变化时,外部模块无法获取更新后的值(因为始终获取的是第一次导入时的缓存),而是始终获取的是初始导入该模块的初始导出值;

        js
        // dep.js
        let counter = 0
        
        exports.mutateCounter = function mutateCounter() {
          counter++
        }
        
        exports.getCounter = function getCounter() {
          return counter
        }
        
        js
        // module-a.js
        const { mutateCounter } = require('./dep')
        
        // 该函数将修改 dep.js 内部 counter 值,但不会影响其他模块引入 getCounter
        // 得到的结果
        mutateCounter()
        
    • 使用方式

      1. ES modules 导入和导出语句始终只能在文件头部定义;

      2. commonjs 可在模块文件内任意位置调用 require 函数实现导入模块;

References