likes
comments
collection
share

搞懂前端模块化之 CommonJS 篇

作者站长头像
站长
· 阅读数 14

前言

大家好,我是一知。

在 ES6 的 ES Module 出现之前,CommonJS一直是主流的模块化规范,虽然现在 ES Module 的使用越来越广泛,但由于历史原因,很多 Node.js 中使用的库都是以前以 CommonJS 规范实现的,完全更新换代也是需要时间的,所以在将来的较长一段时间里 CommonJS 还将一直存在,因此我们仍然有必要对 CommonJS 有一定的了解。

1. 什么是模块(化)?

模块,是能够单独命名并独立地完成一定功能的程序语句的集合(即程序代码和数据结构的集合体)。比如我们开发的一个应用程序通常是由不同的小模块组成的,每个小模块都可以单独实现某些逻辑,在使用时,我们通常可以不必关注其内部具体的实现细节,只需要通过它对外暴露的接口进行交互。

2. 为什么需要模块化(解决了什么问题)?

在 JavaScript 发展早期,是没有使用模块化的,我们在开发一个网页时可能会存在引入多个 js 文件的情况,比如:

<body>
    <script src="./a.js"></script>
    <script src="./b.js"></script>
    <script src="./c.js"></script>
    ...
</body>

如上,在没有使用模块化的情况下,我们虽然可以将应用中的逻辑拆分到了不同的 js 文件中,但实际上在每个 js 文件中的代码运行时的顶层作用域都是同一个全局作用域(此处是 window ),与将所有代码写在同一个 js 文件中无异。这样的方式会造成全局变量污染以及依赖管理混乱等问题,尤其是在多人协作开发以及应用程序较复杂时,这种问题会变得更加棘手。

2.1 全局变量污染

拿上面这种情况来举例,假如a.js是由我开发的:

// a.js
var someName = '一知'
setTimeout(() => {
    console.log(someName)
}, 2000)

然后b.js是团队里的张三开发的:

// b.js
function someName() {
    // ...
}

如上代码,我在a.js中声明了一个叫someName的字符串变量,并且在 2 秒种后打印它的值,而张三在不知情的情况下在b.js中声明了一个叫someName的函数,这将导致在 2 秒钟后打印的someName其实是张三在b.js中声明的函数,而不是我预期的在a.js中声明的字符串。这仅仅是一个简单的 demo,真实的开发场景远比这个复杂得多,如果也碰到这种变量污染的情况,排查起来简直是噩梦。

当然,这个问题可以通过自执行的匿名函数来曲线救国,比如这样:

// a.js
(function(){
    var someName = '一知'
    setTimeout(() => {
        console.log(someName)
    }, 2000)
})()

// b.js
(function(){
    function someName() {
       // ...
    }
})()

将每个 js 文件中的代码都分别放在一个自执行的匿名函数里,形成一个函数作用域,可以一定程度上解决全局变量污染问题,但是这又存在一个代码共享复用的问题,a.js中的变量、函数等只能在a.js中使用,b.js同理,因此多个代码文件如果想复用某些代码就变得不太方便。这些都是模块化开发将站出来解决的问题。

2.2 依赖管理混乱

在上面的html代码中,通过 <script></script>标签引入了3个 js 文件,按照浏览器的解析过程,会按照从上到下的顺序依次加载并执行a.jsb.jsc.js,也就意味着对代码间的依赖关系也是存在一定要求的,后加载的代码可以使用先加载的代码中声明的变量和函数等,但反过来则不行,比如b.js中可以使用a.js中声明的变量和函数,但是反过来a.js中则不可以使用b.jsc.js中声明的变量和函数等。

基于这些痛点,JavaScript 社区中逐渐提出了一些模块化的解决方案,比如CommonJSAMDCMDUMD等,以及后来ES6推出的 ES Module 官方模块标准,随着 JavaScript 生态的发展,现在主流的两种模块化规范是CommonJSES Module,本文重点聊聊 CommonJS 规范,下篇文章再单独介绍 ES Module。

3. CommonJS 规范

CommonJS 在 Node.js 中被原生支持,而浏览器原生是不支持 CommonJS 的,因为 CommonJS 适用于加载本地模块,是一个同步加载的过程,比如 Node.js 中加载模块其实是一个读取本地文件并执行的同步过程,而在浏览器中要获取资源通常是需要异步请求获取的,所以以前才会出现用于浏览器端的模块加载规范—— AMD(Asynchronous Module Definition 异步模块定义)。但今天我们的主角是 CommonJS,因此对 AMD 就不过多讨论了。现在常见的打包器如 webpack、rollup 等都实现了对 CommonJS 模块的支持和转换,所以我们也可以在用于浏览器端的前端应用里使用 CommonJS 规范来编写模块代码,因为打包器会实现一套模拟 CommonJS 的机制来处理我们编写的 CommonJS 规范的代码,关于webpack等打包器是如何实现对 CommonJS 的支持和转换的,计划后面单独写篇文章介绍。

我们通常见到的 CommonJS 模块一般是这样的:

// bar.js
module.exports = () => {
    // ...
}

或者这样:

const bar = require('./bar.js')

bar()

exports.foo = 'foo'

exports.fn = () => { ... }

如果你使用过 CommonJS,对于上面这段代码应该不会感到陌生,但我相信很多人可能和我以前一样,只是知道module.exportsexports用于导出,require用于导入,仅此而已。这个时候不妨问问自己:我对 CommonJS 真的了解吗?

4. 来做个自测吧

我们不妨先来个简单的自测,看看你对 CommonJS 真正了解多少:

  1. module是个什么东西?
  2. modulemodule.exportsexportsrequire这些变量和方法为什么我们不用声明就可以直接使用它们?是全局变量吗?它们是由谁提供的?
  3. 为什么这种方式可以实现模块化的效果?
  4. exportsmodule.exports有什么联系和区别?使用时有哪些要注意的地方
  5. require的查找机制是怎样的?
  6. 如果出现循环引用会怎样?

看完这些问题,如果你感觉自己回答不上来,或者似懂非懂、支支吾吾的话,没关系,认真看完这篇文章,相信这些问题你都能得到答案。

5. 从源码开始

在 Node.js 源码中可以找到其对 CommonJS 模块机制的具体实现,源码中的具体实现相对较复杂,为了方便大家更容易理解 CommonJS 的核心实现原理,我对源码做了一些精简。如果你想了解更具体的实现细节,可以后面再自行阅读 Node.js 源码。

精简后的代码:

function Module(id = '', parent) {
	// 模块文件的绝对路径
	this.id = id;
	// 模块所在的目录
	this.path = path.dirname(id);
	// 导出内容
	this.exports = {};
	// 将该模块添加到父模块的children数组中
	updateChildren(parent, this, false);
	this.filename = null;
	// 是否加载完成
	this.loaded = false;
	// 引用的子模块,即在当前模块中通过require加载的模块
	this.children = [];
}

// 用于包裹模块代码
Module.wrap = function (script) {
	return (
		'(function (exports, require, module, __filename, __dirname) {' +
		script +
		'\n})'
	);
};

// 模块缓存
Module._cache = Object.create(null);

Module._load = function (request, parent) {
	// Module._resolveFilename 用于将 require 的模块地址解析成能定位到模块的绝对路径
	const filename = Module._resolveFilename(request, parent);

	const cachedModule = Module._cache[filename];
	// 命中缓存直接返回
	if (cachedModule) {
		return cachedModule.exports;
	}

	// 未命中缓存,则创建一个新的模块实例
	const module = new Module(filename, parent);

	// 将该模块记录到模块缓存中
	Module._cache[filename] = module;

	// 获取到模块代码内容
	const content = fs.readFileSync(filename, 'utf8');

	// 将参数传入并执行模块代码,可以理解为eval,但源码中的实现并不是eval,而是更安全的执行方式
	runInThisContext(Module.wrap(content))(
		module.exports, // 对应包裹函数中接收的 exports 形参
		module.require, // 对应包裹函数中接收的 require 形参
		module, // 对应包裹函数中接收的 module 形参
		filename, // 对应包裹函数中接收的 __filename 形参
		module.path // 对应包裹函数中接收的 __dirname 形参
	);

	// 将该模块标识为加载完成
	module.loaded = true;

	// 最后返回执行完该模块内代码后的模块导出内容
	return module.exports;
};

// 模块原型上的require方法
Module.prototype.require = function (id) {
	// ...
	return Module._load(id, this);
};

让我们来理解一下上面的代码:

  1. 首先声明了一个Module构造函数,将会用于后面创建模块实例。所有非 Node.js 原生模块(如fspath等等)都是Module的实例;
  2. Module构造函数上添加了一个wrap静态方法,作用是将我们编写的模块代码包裹在一个包含 5 个形参的匿名函数的字符串里,这 5 个形参分别为exports, require, module, __filename, __dirname
  3. Module构造函数上添加了一个_cache静态属性,值为一个 prototype 为 null 的空对象,用于缓存已创建的模块;
  4. Module构造函数上添加了一个_load静态方法,并且给Module的原型上添加了一个require方法,在require方法里面调用了Module._load()方法;
  5. 重点来看看Module._load()。该方法接收两个参数requestparentrequest是要引入的模块的标识符字符串,比如原生模块fs、本地相对路径模块./foo.js、绝对路径模块/path/to/module/bar.js或者第三方库模块koa等;parent是当前模块,即使用require的这个模块,比如在foo.jsrequire('./bar.js'),在这个过程中request就是'./bar.js', parent就是模块foo.js。在Module._load()中首先会尝试将传入的模块标识符解析成一个能够定位到目标模块的绝对路径filename,然后将filename作为缓存标识到Module._cache中查找是否存在该模块的缓存,如果缓存命中,则直接返回缓存内该模块的module.exports属性,require结束;如果未命中缓存,则通过new Module()创建一个模块实例,并将该模块以filename为标识存入到模块缓存中,接着通过filename读取到模块文件中的代码内容,然后调用runInThisContext(Module.wrap(content))(module.exports, module.require, module, filename, module.path)执行包裹后的模块代码,这个过程可以理解为eval(当然源码中的实现并不是直接eval,而是更安全的执行方式),注意到此时传入了5个参数module.exports, module.require, module, filename, module.path,对应的就是模块包裹函数接收的那5个形参:
    // 用于包裹模块代码
    Module.wrap = function(script) {
            return '(function (exports, require, module, __filename, __dirname) {' +
            script +
        '\n})'
    }
    
    执行模块代码的过程中,导出内容会被挂载到module.exports属性上,所以模块代码执行完后,将module.loaded标识为true,并返回module.exports,require就完成了。

接下来我们结合上面的源码来一步步分析。

6. module 是个什么东西?

通过上面的源码我们应该知道了,module是通过new Module()实例化得到的一个CommonJS模块实例,它包含了该模块的一些信息,下面我打印了一下module

搞懂前端模块化之 CommonJS 篇

module.id

module.id是每个模块的唯一标识ID,通常是模块文件的在文件系统中的绝对路径,但是也存在module.id是'.'的模块,这表示该模块是执行的入口模块process.mainModule,比如你在命令行中执行:

$ node main.js

那么main.js这个模块就是Node进程的入口模块。在后面讲解require的时候会再次介绍到它。

6.2 module.path

模块的目录名,通常与path.dirname(module.id)相同。

6.3 module.exports

模块导出的内容,默认是一个对象{},你也可以将任何你想导出的内容赋值给module.exports,以将其作为模块的导出内容。

6.4 module.filename

模块的完整解析文件名,一个绝对路径,通过它能够准确定位到模块文件。

6.5 module.loaded

一个用于标识模块是否加载完成的状态标识,为true时表示该模块已经加载完成。

6.6 module.children

该模块的子模块数组,比如foo模块中加载了bar模块和baz模块,则bar模块和baz模块都是foo模块的子模块。

6.7 module.paths

模块查找的路径数组,更准确地说,是第三方模块的查找路径数组。

6.8 module.parent

模块的父模块,比如你在模块a.js中require了模块b.js,那么模块b.js的父模块就是模块a.js。注意:该属性从Node.js v14.6.0、v12.19.0版本开始已弃用。

7. 为什么可以直接使用这些变量?

modulemodule.exportsexportsrequire这些变量为什么可以直接使用它们?是全局变量吗?它们是由谁提供的?

如果不了解CommonJS模块系统的工作原理,我们可能会误认为这些变量都是全局变量,但是通过我们上面的源码分析,我们可以知道,之所以能直接在模块代码中使用modulemodule.exportsexportsrequire,是因为在模块加载过程中CommonJS模块系统帮我们包裹了一层匿名函数,并在执行该匿名函数时将其接收的5个参数传入,这5个参数分别是:

  1. exports,等同于module.exports,作为module.exports的快捷访问;
  2. require,通常为模块原型上的require方法,用于加载其他模块;
  3. module,当前模块对象,通过new Module()实例化创建的;
  4. __filename,当前模块的绝对路径文件名;
  5. __dirname,当前模块所在目录的绝对路径; 所以我们编写的代码实际上是在该函数内部执行的,我们使用的module、exports、require等其实都是该函数的形参,并不是什么全局变量。通过上面的源码我们还可以知道,除了modulemodule.exportsexportsrequire,还有__filename__dirname这两个变量我们也是可以直接在模块代码中使用的。

8. 为什么这种方式可以实现模块化的效果?

为什么这种方式可以实现模块化的效果?

这是因为我们的模块代码最终是被放在一个匿名函数内部执行的,每个模块都处于单独的函数作用域,所以不会造成污染全局变量的问题。并且通过module.exports实现了模块导出,可以将模块内部的某些代码共享给其他模块复用,通过require可以方便地加载其他模块的导出内容供当前模块使用。

9. exports和module.exports有什么联系和区别?

exportsmodule.exports有什么联系和区别?使用时有哪些要注意的地方?

module.exports是整个模块的导出内容,默认是个{}exports其实就是module.exports,是对同一个对象的引用,它可以作为对module.exports的快捷访问。但是需要注意的是,如果你对module.export或者exports进行了一些破坏性的赋值操作,这种操作将导致exportsmodule.exports引用的不再是同一个对象,也就意味着exportsmodule.exports之间的联系被切断了,我们结合wrap函数来理解:

function wrap(exports, require, module, __filename, __dirname) {
    // exports 只是 wrap 函数的一个形参,就相当于在函数内部存在的一个变量
    // 此时它被赋值为一个新对象,那么它跟 module.exports 就没有关系了
    exports = {
        name: '一知'
    }

    // 这个 age 只会出现在 module.exports 上,在 exports 中不存在
    module.exports.age = 18
}

// 之所以 exports 会等于 module.exports,是因为在执行 wrap 函数时传入的 exports 实参是 module.exports
wrap(module.exports, require, module, __filename, __dirname)

比如下面这样,module.exports被赋值为一个新的对象,而此时exports还是最开始module.exports引用的那个对象,所以之后我们再给exports对象上添加age属性,也并不会影响到module.exports,因此该模块的导出内容(即module.exports)实际上是{ name: '一知' },而不是{ name: '一知', age: 18 }

// bar.js
exports = {
    name: '一知'
}

module.exports.age = 18
// foo.js
const bar = require('./bar.js')

console.log(bar) // { age: 18 }

下面这样的用法也是同样的问题:

// bar.js
module.exports = {
    name: '一知'
}

exports.age = 18
// foo.js
const bar = require('./bar.js')

console.log(bar) // { name: '一知' }

所以我们一般会这样用:

module.exports = {
    name: '一知',
    age: 18,
    sayHello() {
        // ...
    }
    ...
}

或者:

exports.name = '一知'
exports.age = 18
exports.sayHello = function sayHello() {
    // ...
}
...

module.exports通常会是一个对象,但也可以是其他任何值,比如函数、字符串等等。

// 导出一个函数
module.exports = function() {
    // ...
}

10. require的查找机制是怎样的?

在回答这个问题之前,我们先来了解一下关于require的几个辅助属性。

10.1 require.main

等同于process.mainModule,二者是对同一个对象的引用。表示 Node.js 进程启动时加载的入口脚本的模块对象,如果程序的入口点不是 CommonJS 模块,则为 undefined。 比如:

bar.js:

module.exports = 1

foo.js:

const bar = require('bar')
exports.foo = 2 + bar // 3

那么当我们在命令行中执行如下命令时

$ node foo.js

foo.js这个模块将作为入口模块,即process.mainModule,也即require.main

10.2 require.cache

等同于前面源码中的Module._cache,同一个对象引用,记录了模块加载的缓存。

10.3 require.resolve

一个辅助方法,用于将模块标识符解析成一个可以定位到模块文件的filename(比如原生模块'fs'、绝对路径/path/to/module/xxx.js),其内部实现也是调用了前面源码中的Module._resolveFilename方法。

10.4 require过程回顾

我们前面分析了require一个模块的过程,简单回顾一下大致过程:

  1. 首先,将模块标识解析(通过Module._resolveFilename())成能定位到模块文件的一个filename字符串,通常为一个绝对路径或者原生模块标识符如'fs';
  2. 通过filename读取到模块内容(针对非原生模块);
  3. 将模块内容包裹在module wrapper函数中执行(针对非原生模块);
  4. 返回module.exports作为导出内容,require加载完毕。

那么当我们require(X)时,CommonJS模块系统是如何将这个模块标识符X解析成相应模块的地址,从而找到这个模块的呢?

我们通常require的场景有这几种:

  1. 加载Node.js内置模块,如fspath等;
  2. 加载相对路径或绝对路径文件模块,如./xxx../xxx/xxx等;
  3. 加载第三方模块,比如koa等。
// 内置模块
const fs = require('fs')
// 相对路径文件模块
const foo = require('./foo.js')
// 第三方模块
const koa = require('koa')

以下是Node.js中CommonJS模块系统的require的解析及查找过程:

我们分别对上面这三种场景进行分析:

10.4.1 加载原生模块

const path = require('path')
const fs = require('node:fs')

如果模块加载标识符是原生模块标识符如fs,那么经过Module._resolveFilename方法解析后得到的filename还是fs,然后判断Module_cache['fs']是否命中缓存,如果缓存命中,则返回该缓存模块的module.exports,require结束;若未命中缓存则加载该原生模块,然后返回其module.exports,require结束。在Node.js中原生模块被编译成了二进制代码,所以相较于非原生模块加载速度更快。

从Node.js v16.0.0和v14.18.0开始,加载原生内置模块可以加上node:前缀,这种方式可以跳过模块缓存检查,直接加载相应原生模块,例如:

 const realFs = require('node:fs')

const fakeFs = {}
require.cache.fs = { exports: fakeFs }

console.log(require('fs') === fakeFs) // true
console.log(require('node:fs') === fakeFs) // false
console.log(require('node:fs') === realFs) // true

我们可以这样获取到Node.js中的原生模块,通过require('module')获取到Module注意,这个module并不是我们上面介绍的模块代码中module.exports中的那个module,而是用来创建模块的new Module()的这个Module构造函数。其中Module.builtinModules数组就是Node.js提供的所有原生内置模块的标识符,我们最常用的fs和path等都在其中。

    const mod = require('module')

    console.log(mod.builtinModules)
    // 以下为所有内置元素模块:
[
    	  '_http_agent',         '_http_client',        '_http_common',
    	  '_http_incoming',      '_http_outgoing',      '_http_server',
    	  '_stream_duplex',      '_stream_passthrough', '_stream_readable',
    	  '_stream_transform',   '_stream_wrap',        '_stream_writable',
    	  '_tls_common',         '_tls_wrap',           'assert',
    	  'assert/strict',       'async_hooks',         'buffer',
    	  'child_process',       'cluster',             'console',
    	  'constants',           'crypto',              'dgram',
    	  'diagnostics_channel', 'dns',                 'dns/promises',
    	  'domain',              'events',              'fs',
    	  'fs/promises',         'http',                'http2',
    	  'https',               'inspector',           'module',
    	  'net',                 'os',                  'path',
    	  'path/posix',          'path/win32',          'perf_hooks',
    	  'process',             'punycode',            'querystring',
    	  'readline',            'repl',                'stream',
    	  'stream/consumers',    'stream/promises',     'stream/web',
    	  'string_decoder',      'sys',                 'timers',
    	  'timers/promises',     'tls',                 'trace_events',
    	  'tty',                 'url',                 'util',
    	  'util/types',          'v8',                  'vm',
    	  'worker_threads',      'zlib'
]

10.4.2 加载相对路径或绝对路径的本地模块

const foo = require('./foo')
const absolutePathModule = require('/absolute/path/to/module')
  1. 如果X是相对路径或者绝对路径,则首先会解析得到一个绝对路径如/path/to/module/foo

  2. 这个绝对路径定位到的有可能是一个文件,也有可能是一个目录。模块系统会优先尝试将其作为一个文件去查找,看是否存在对应文件,如果不存在,则依次给它加上.js.json.node文件扩展名去查找看是否存在对应文件,如果存在,则返回对应的完整绝对路径作为filename,比如/path/to/module/foo.js

  3. 如果上面都没有找到的话,就尝试将其作为一个目录去查找,如果存在该目录,则尝试查找该目录下的package.json文件:

    • 如果不存在package.json文件的话,就查找该目录下的index文件,如/path/to/module/foo/index,同样,这个路径依然可能是个文件或者目录,所以再进行上面步骤2的查找过程。
    • 如果存在package.json文件,则看package.json文件中是否指定了main字段,main字段可以为一个目录路径如lib,也可以是个省略了文件扩展名的文件路径./lib/main,也可以是完整的文件路径./lib/main.js。如果未指定,则与上面没有package.json的处理方式一致,查找该目录下的index。如果指定了main字段如main: "./lib/main",则继续查找的路径变为/path/to/module/foo/lib/main,这个路径依然可能是文件或者目录,所有还需要尝试进行步骤2、3的查找过程。
  4. 如果经过上面这些查找过程还是没有找到,那就是找不到,抛出MODULE_NOT_FOUND的错误。

  5. 如果上面某个过程中找到了有效的filename,则判断Module_cache[filename]是否命中缓存,如果命中缓存,则直接返回该缓存模块的module.exports,而不再执行该模块代码,require结束;若未命中,则读取该文件内容并在包裹wrapper函数后执行,然后返回module.exports属性,require结束。

10.4.3 加载第三方模块

// foo.js
const koa = require('koa')

如果是第三方模块,则模块系统将从当前目录下的node_modules目录中开始,查找是否存在该第三方模块标识符对应的文件或者目录,如果找不到,则继续往上一级的node_modules目录找,一直到根目录下的node_modules目录,如果根目录下的node_modules中也没找到,则抛出MODULE_NOT_FOUND的错误。 比如你当前执行require('koa')的加载模块的文件是/root/path/to/module/foo.js,那么模块系统将依次尝试在以下node_modules目录中查找对应模块:

[
    '/root/path/to/module/node_modules',
    '/root/path/to/node_modules',
    '/root/path/node_modules',
    '/root/node_modules',
    '/node_modules',
]

node_modules目录下查找这个模块时的过程,与前面相对路径和绝对路径的查找过程一样,会进行文件推断、添加文件扩展名、目录推断、package.json等尝试,直到找到该模块,然后后面的加载执行的过程和上面一样。

11. 如果出现循环引用会怎样?

如果出现循环引用会怎样?

如果你通过前面的分析后已经理解了 CommonJS 的加载过程,那么这个问题你肯定已经有答案了。

答案就是,即使出现循环引用,也不会出现无限循环的问题

为什么?

我们上面讲到了,在使用require()加载一个模块时,由于存在缓存(require.cache,即Module._cache)机制,加载模块时会先判断是否存在该模块的缓存,如果存在,就会直接返回缓存中该模块的module.exports,而不会再次执行模块代码。也就是说,只有在第一次加载模块A时,模块 A 的代码才会被执行,之后不管你再require('A')多少次都不会再执行模块 A 的代码。但如果某些代码你就是想要多次执行怎么办?也是可以实现的,你可以导出一个函数,然后多次调用该函数即可。

另外,CommonJS 中模块加载的策略是深度优先遍历,类似递归的过程。举个例子:

a.js:

console.log('a.js 开始执行');
exports.done = false;
const b = require('./b.js');
console.log('a.js 中, b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完成');

b.js:

console.log('b.js 开始执行');
exports.done = false;
const a = require('./a.js');
console.log('b.js 中, a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完成');

main.js:

console.log('main.js 开始执行');
const a = require('./a.js');
const b = require('./b.js');
console.log('main.js 中, a.done = %j, b.done = %j', a.done, b.done);

运行一下:

$ node main.js
main.js 开始执行
a.js 开始执行
b.js 开始执行
b.js 中, a.done = false
b.js 执行完成
a.js 中, b.done = true
a.js 执行完成
main.js 中, a.done = true, b.done = true

在这个例子中,入口模块是main.js,main.js中分别加载了a.js和b.js,在a.js中加载了b.js,b.js中又加载了a.js。通过这个例子我们可以看出:

  1. a.js和b.js都被引用了2次,且出现循环引用,但是它们的模块代码均只执行了一次
  2. require('x')是个同步的过程,并且是一个深度优先的过程。

12. 什么样的文件会被作为CommonJS模块?

默认情况下,Node.js 会将以下内容视为 CommonJS 模块:

  • 扩展名为.cjs的文件;
  • 扩展名为.js的文件,并且离它最近的(与它同级或在它的父级目录)package.json文件中包含了值为"commonjs"的顶级type字段;
  • 扩展名为.js的文件,并且离它最近的(与它同级或在它的父级目录)package.json文件中未指定顶级"type"字段;
  • 扩展名为 .mjs.cjs.json.node.js 的文件,当离它最近的(与它同级或在它的父级目录) package.json 文件包含值为"module"的顶级字段"type"时,这些文件只有在require它们时才会被识别为 CommonJS 模块,而不是在用作程序的命令行入口点时)。

总结

本文分析了为什么需要使用模块化开发,详细讲解了 CommonJS 规范的实现原理和一些核心的知识点,希望通过这篇文章可以让你对CommonJS有更深的认识。

如果这篇文章有帮助到你,希望能给我个点赞👍🏻+收藏☆鼓励一下,后续将努力继续更新更多硬核文章。

下一篇文章将分享 ES6 推出的前端模块化标准 ES Module 的知识点,彻底搞懂前端模块化的两大知识点,敬请期待!

参考资料: