nodejs 中的 commonjs

Liu Bowen

Liu Bowen / 2020, 一月, 17

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

什么是 commonjs

2009commonjs 初版被定义为 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.exportsexports 对象来作为模块导出出口。其中 exports 对象本质上只是 module.exports 对象的 别名module.exportsexports 是对同一对象的引用。

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.exportsref

导入模块

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 时,首先会加载 commonjsloader如下

    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 loaderloadernodejs 在阶段仍然是默认选择 commonjsloader,反之使用 es moduleloader 来导入入口脚本文件:

    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. nodejscommonjsloader 实现中,全部依赖于 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 moduleses modules a cartoon deep dive(from mozilla blog)

ES module 与 commonjs 差异

  • 同:

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

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

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

  • 异:

    • 生命周期

      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