nodejs 中的 commonjs
在 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
是对同一对象的引用。
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.
const aForeignModule = require('path/to/target/module') // .js or .json or .node
本质上所有模块导入都是依赖于 require
函数的执行,即是 运行时
同步导入。通过在 vscode
中进行 nodejs
运行时进行断点可见,所有模块首先以字符串的形式被导入,之后由 compiler
执行。
-
根据最新
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])
-
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) } }
-
若在模块中调用
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
静态方法来实现脚本加载。
-
-
在
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 }
-
在没有模块缓存的前提下,模块将由 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 */ ) ) } }
-
以
.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) }
-
在通过
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 差异
-
同:
-
异:
-
生命周期
-
ES modules
静态语法在 编译时compile time
(编译为ast
时)确定模块之前的依赖关系,并建立依赖关系图; -
commonjs
本质上是依赖于函数执行来实现模块加载,那么它时基于 运行时runtime
确定模块的依赖关系;
-
-
模块的值
-
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()
-
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()
-
-
使用方式
-
ES modules
导入和导出语句始终只能在文件头部定义; -
commonjs
可在模块文件内任意位置调用require
函数实现导入模块;
-
-