Introduction 最初的 JavaScript 设计时没有模块化相关的概念,但是随着前端的发展及其工程化的引进和借鉴,相关的模块化思想也被引入了前端领域,其中CommonJS
的模块化解决方案一直屹立不倒。为了更好的理解CommonJS
,本篇文章依据Module:CommonJS modules 相关文档,借鉴loader.js 的相关源码,自己手动实现一个CommonJS module
;
每个被 require 的模块,在执行之前都被一个函数包裹,类似于:
1 2 3 (function (exports , require , module , __filename, __dirname ) { });
这样做可以防止模块内的定义的变量提升到全局作用域,同时提供了module
,exports
等变量供当前模块使用
What require()
does: require()
加载模块的顺序具体可以参考这段伪代码 ,简单概括如下:
优先加载诸如path
,fs
等核心模块
先从缓存中找对应的模块是否已加载,没有则进行下一步
加载对应的以/
, ./
, ../
开头的文件,如果不带文件后缀,则按照.js
, .json
,.node
依次尝试,有的话加载对应的文件内容
加载对应的以/
, ./
, ../
开头的文件夹,先查找文件夹下的package.json
,如果package.json
定义了”main”,则加载”main”对应的内容,没有则寻找对应的文件夹下的 index 文件,然后按照第三步的加载文件顺序依次查找并加载
加载对应的node_modules
文件夹下的内容
抛出”not found”错误
对于最新版本的Node.js
则增加了ECMAScript modules 相关的加载规则,有兴趣的可以了解一下。
Module Caching 在加载模块时,require()
会先尝试从缓存中获取对应的 module,对应的每个缓存值的数据结构类似于:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 { id : '/xbrave/debug/foo.js' , path : '/xbrave/debug' , exports : {}, parent : Module { id : '.' , path : '/xbrave/debug' , exports : {}, parent : null , filename : '/xbrave/debug/index.js' , loaded : false , children : [Array ], paths : [Array ] }, filename : '/xbrave/debug/foo.js' , loaded : true , }
可以理解为模块的缓存是以文件的绝对路径形式为 key 值,将对应的 module 进行缓存。这种形式的缓存,使得 module 的表现类似于单例模式。但是需要注意的是:
对于大小写不敏感(case-insensitive)的文件系统或者操作系统(比如说 windows 操作系统),虽然require('./foo')
和require('./FOO')
实际上是加载的同一个文件的内容,但是加载的结果是两个不同的结果.
加载node_modules
文件夹下的内容,同样的require('foo')
可能返回不同的结果,具体取决于require()
时的文件路径
module.exports VS exports 初学者可能会对两者有所误解,只需要记住,exports
最初和module.exports
指向的是同一个对象,而最终暴露给其它模块使用的是module.exports
。
Build Your Own CommonJS Module 有了上述的一些基础和概述,我们尝试简单实现一下一个简单的CommonJS Module
MyOwnModule Class 参考上述Module Caching 所描述的 module 结构和源码 我们先定义一个 MyOwnModule 类:
1 2 3 4 5 6 7 8 9 const path = require ('path' );function MyOwnModule (id = '' ) { this .id = id; this .path = path.dirname (id); this .exports = {}; this .filename = null ; this .loaded = false ; }
这里省略了源码里 parent 和 children 相关的属性
MyOwnModule.prototype.require() require()
的作用和加载过程我们已经有所了解,中间涉及到一个缓存对象,这里我们定义为:
1 MyOwnModule ._cache = Object .create (null );
然后require
内部调用相关的_load()
方法, 主要做的工作是:
通过 _resolveFilename()
方法获取相应的文件绝对路径
然后从缓存中找模块是否已加载,加载了直接返回对应的缓存内容,否则新生成一个 Module 实例,再返回对应的加载结果:
1 2 3 4 5 6 7 8 9 10 11 MyOwnModule .prototype .require = function (id ) { return MyOwnModule ._load (id); }; MyOwnModule ._load = function (request ) { const filename = MyOwnModule ._resolveFilename (request); const cachedModule = MyOwnModule ._cache [filename]; if (cachedModule !== undefined ) return cachedModule.exports ; const module = new MyOwnModule (filename); module .load (filename); return module .exports ; };
MyOwnModule._resolveFilename() _resolveFilename
的方法在源码中比较复杂,这里我们简单实现一下,主要做的工作和之前说到加载顺序一致:
获取文件的 resolve 路径,并检测有没有文件后缀
如果有后缀直接返回,没有的按照_extensions
定义的顺序依次寻找,找到的直接返回对应的路径
1 2 3 4 5 6 7 8 9 10 11 12 MyOwnModule ._resolveFilename = function (request ) { const filename = path.resolve (request); const ext = path.extname (filename); if (!ext) { const extensions = Object .keys (MyOwnModule ._extensions ); for (let i = 0 ; i <= extensions; i++) { const currentFile = `${filename} ${extensions[i]} ` ; if (fs.existsSync (currentFile)) return currentFile; } } return filename; };
MyOwnModule.prototype.load() load()
方法主要是根据对应的文件后缀依次处理文件内容,然后将当前 Module 的内部属性loaded
置为 true。
1 2 3 4 5 MyOwnModule .prototype .load = function (filename ) { const extname = path.extname (filename); MyOwnModule ._extensions [extname](this , filename); this .loaded = true ; };
我们平常开发中一般涉及到的是.js
和.json
的文件后缀,我们分别对两个文件后缀的内容进行处理
官方释义: vm.runInThisContext() compiles code, runs it within the context of the current global and returns the result. Running code does not have access to local scope, but does have access to the current global object.
官方 demo: 1 2 3 4 5 6 7 8 const vm = require ('vm' );let localVar = 'initial value' ;const vmResult = vm.runInThisContext ('localVar = "vm";' );console .log (`vmResult: '${vmResult} ', localVar: '${localVar} '` );const evalResult = eval ('localVar = "eval";' );console .log (`evalResult: '${evalResult} ', localVar: '${localVar} '` );
.js
文件后缀对.js
文件后缀的处理,主要是调用了vm.runInThisContext
方法将拼接好的字符串代码转换为实际代码,并在最外层包裹了一层,将对应的五个变量注入了模块内部。其中vm.runInThisContext
此方法用于创建一个独立的沙箱运行空间,code 内的代码可以访问外部的global
对象,但是不能访问其他变量,类似于new Function()
。相关代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 MyOwnModule ._extensions = Object .create (null );MyOwnModule ._extensions ['.js' ] = function (module , filename ) { const fileContent = fs.readFileSync (filename, 'utf-8' ); module ._compile (fileContent, filename); }; MyOwnModule .wrapper = [ '(function (exports, require, module, __filename, __dirname) { ' , '\n});' , ]; MyOwnModule .prototype ._compile = function (content, filename ) { const wrappedContent = MyOwnModule .wrapper [0 ] + content + MyOwnModule .wrapper [1 ]; const compiler = vm.runInThisContext (wrappedContent, { filename, lineOffset : 0 , displayErrors : true , }); const dirname = path.dirname (filename); compiler.call ( this .exports , this .exports , this .require , this , filename, dirname ); };
.json
文件后缀对.json
文件后缀的文件处理就比较简单了,读取文件内容,然后调用JSON.parse()
方法返回处理之后的结果:
1 2 3 4 MyOwnModule ._extensions ['.json' ] = function (module , filename ) { const content = fs.readFileSync (filename, 'utf-8' ); module .exports = JSON .parse (content); };
Overall 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 const path = require ('path' );const fs = require ('fs' );const vm = require ('vm' );function MyOwnModule (id = '' ) { this .id = id; this .path = path.dirname (id); this .exports = {}; this .filename = null ; this .loaded = false ; } MyOwnModule ._cache = Object .create (null );MyOwnModule ._extensions = Object .create (null );MyOwnModule .prototype .require = function (id ) { return MyOwnModule ._load (id); }; MyOwnModule ._load = function (request ) { const filename = MyOwnModule ._resolveFilename (request); const cachedModule = MyOwnModule ._cache [filename]; if (cachedModule !== undefined ) return cachedModule.exports ; const module = new MyOwnModule (filename); MyOwnModule ._cache [filename] = module ; module .load (filename); return module .exports ; }; MyOwnModule ._resolveFilename = function (request ) { const filename = path.resolve (__dirname, request); const ext = path.extname (filename); if (!ext) { const extensions = Object .keys (MyOwnModule ._extensions ); for (let i = 0 ; i <= extensions; i++) { const currentFile = `${filename} ${extensions[i]} ` ; if (fs.existsSync (currentFile)) return currentFile; } } return filename; }; MyOwnModule .prototype .load = function (filename ) { const extname = path.extname (filename); MyOwnModule ._extensions [extname](this , filename); this .loaded = true ; }; MyOwnModule ._extensions ['.js' ] = function (module , filename ) { const fileContent = fs.readFileSync (filename, 'utf-8' ); module ._compile (fileContent, filename); }; MyOwnModule .wrapper = [ '(function (exports, require, module, __filename, __dirname) { ' , '\n});' , ]; MyOwnModule .prototype ._compile = function (content, filename ) { const wrappedContent = MyOwnModule .wrapper [0 ] + content + MyOwnModule .wrapper [1 ]; const compiler = vm.runInThisContext (wrappedContent, { filename, lineOffset : 0 , displayErrors : true , }); const dirname = path.dirname (filename); compiler.call ( this .exports , this .exports , this .require , this , filename, dirname ); }; MyOwnModule ._extensions ['.json' ] = function (module , filename ) { const fileContent = fs.readFileSync (filename, 'utf-8' ); module .exports = JSON .parse (fileContent); };
Summary
Node.js
的CommonJS module
并没有什么特别的地方,主要难点在于对于.js
文件的处理方式和vm.runInThisContext
的理解
每个模块对应的exports
,require
,module
, __filename
, __dirname
都不是全局变量,而是模块加载的时候注入的
Reference
Module:CommonJS modules official document
Node.js loader.js Source Code