主题
实现commonjs中的模块机制
一. 模块化规范有哪些?为什么要有这些规范?它解 决了什么问题?
1 模块化规范的发展历程
最早的时候,没有模块化规范,开发时,因为一个项目是团队协作,会有很多人一起开发,虽然是不同的功能业务模块,但是每个人起的变量名可能一样,这就会存在一个问题,命名冲突,最初遇到这样的问题,可以采用命名空间来防止冲突,但是,命名空间也不能完全防止命名冲突,简单来说就是,用一个对象或函数,创造一个作用域,在里面实现逻辑,但是函数名或变量名还是很可能重复,而且用起来也不方便。
后来,用执行函数(IIFE)的方式来创造一个独立命名空间逐渐流行起来,
自执行函数也是前端模块化的实现方案之一,在早期前端没有模块化系统的时代,自执行函数可以很好的解决命名空间的问题,并且模块依赖的其他模块都可以通过参数传递进来。cmd和amd规范也都是依赖自执行函数实现的。
js
(function fn (){
....
})()
但是对于前端来说,因为有请求,需要动态的加载文件,不太好实现模块化,后面有了一些方案趋势线模块化,比如:
- CMD 规范 (serjs、依赖就近)阿里开发 已淘汰
- AMD 规范(requirejs、依赖前置)
上面的两种方式比较典型,也比较古老,现在90%以上没人用了
现在主流的模块化有:
- ES6Module (主要是前端)
- commonjs规范 (Nodejs)
- umd模块(叫统一模块化规范)umd = AMD + commonjs,因此它可以支持AMD,也可以支持commonjs规范
- systemjs(用的不多)
因此,目前主流的还是EM6模块化规范和Commonjs模块化规范
2 JavaScript弊端
- 浏览器端JavaScript在使用的时候存在两大问题, 命名冲突和文件依赖。
- 模块化开发需要依赖第三方库文件,具有代表性有require.js和sea.js。
3 模块化开发优势
- 解决命名冲突和文件依赖
- 将具有独立功能的代码分离到单独的文件中
- 项目代码易维护
二.简易版commonjs模块机制的实现
commonjs
规定,一个文件即是一个模块,在模块内部定义的变量和函数默认情况下在外部无法得到。 另一个文件要通过module.exports
[导出],在当前文件内使用require
[导入]关键字用来导入,才能在当前文件使用另一个文件内的数据
然而,Node.js天生就支持模块化开发,不需要引用额外的第三方库文件。
Node中的模块机制原理很简单,就是在每个模块文件外面包裹上一个函数,利用函数来实现代码间作用域的隔离。
但是饿哦们平时在写代码时候,就只是简单的使用module.export
和require
就解决了模块之间互相引入的问题,并感受不到这个包裹模块文件的函数的存在,是因为这是Node内部已经帮我们实现了,我们可以通过源码段调试来看到,
1 模块require加载原理
require
加载机制依赖于Node中的fs内置模块,首先利用fs.readFileSync
读取模块文件内容,读取到的内容是一个字符串。
fs.readFileSync
同步,读取文件返回文件内容fs.existsSync
同步,如果路径存在则返回 true,否则返回 false。
在javascrpt
中可以通过eval
或者new Function
的方式来将一个字符串转换成js
代码来运行。Node
中使用了vm
虚拟机内置模块来处理,vm
原理于new Function类似
1.1 eval
js
let a = 100
let str = "console.log(a)"
// 希望让str字符串执行,用了eval
eval(str)
// 会输出 100
eval
执行时候会查找上下文,会造成安全性问题,
1.2 new Function
new Function
是将字符串编程了函数,可以产生一个执行环境,且不依赖于外层作用域,缺点就是必须包装一层函数,一般在模板引擎中会使用到
js
let a = 100
let str = "console.log(a)"
let fn = new Function(str) // 会返回一个函数
console.log(fn.toString());
/*
输出:
function anonymous(
) {
console.log(a)
}
*/
fn() // 报错:a is not defined
如果这个函数需要传递参数,可以在new Function的时候依次传入参数,最后传入的是要执行的字符串。比如这里传入参数b,要执行的字符串str。
js
const b = 3;
const str = 'let a = 1; return a + b';
const fun = new Function('b', str);
console.log(fun(b, str)); // 4
1.3 vm虚拟机
模块
node里面有一个虚拟环境的概念,也就是vm,vm
是Nodejs
中的内置模块,可以创建一个隔离的上下文环境,用来运行额外的js文件,他可以保证javascript执行的独立性,不会被外部所影响。 Node官方vm(英文)中文VM
js
// 引入vm模块, 不需要安装,node 自建模块
const vm = require('vm');
let a = 100;
const str = 'console.log(a)';
vm.runInThisContext(str); // 报错:a is not defined
虽然外部定义了hello,但是str是一个独立的模块,并不在hello变量,所以会直接报错 所以node执行javascript模块时可以采用vm来实现。就可以保证模块的独立性了。
2 实现Node模块加载机制
2.1 要点
- require()方法接收一个path,可以是相对路径,也可以是绝对路径,最终返回路径下的模块对象,因此需要用到path模块
- require会通过path读取到模块的内容(同步),然后将内容包裹在一个函数内部,因此需要用到fs模块,fs.readFileSync
- require里面的path可以不带后缀,不带后缀,会首先找js文件,再找json文件
- 多次require,只有第一次会readFile,后面的会走缓存
- module.exports的优先级高于exports,且不能直接给exports赋值。
依赖项:path
fs
vm
三个模块
2.2 实现require加载器
首先导入依赖的三个模块path
fs
vm
,创建req加载函数,req函数接收一个modulePath
,表示要导入的文件路径。
js
const path = require('path');
const fs = require('file');
const vm = reuqire('vm');
function req(modulePath) {
}
req
中,首先会调用Module._resolveFilename
方法,将传入的modulePath
解析成带后缀名的绝对路径,调用Module
实例上的module.load
方法加载模块,load
方法会将模块内容放到module.exports
里面,然后req
函数返回module.exports
对象;
js
function req(modulePath) {
// 解析传入的路径成为绝对路径(且带后缀)
let filepath = Module._resolveFilename(modulePath)
// 创建模块,新建Module实例
const module = new Module(filepath);
// 加载当前模块
module.load();
// 返回exports对象
return module.exports;
}
Module._resolveFilename
是Module的
静态方法,传入相对路径,会返回绝对路径,我们也可以将后缀名省略,这个后面实现
js
Module._resolveFilename = function(modulePath) {
// 将传入的路径解析成绝对路径
modulePath = path.resolve(__dirname,modulePath)
// 判断路径时都存在,(是否有后缀名,有后缀可能就存在)
let isExist = fs.existsSync(modulePath)
// 存在就返回该路径
if(isExist) return modulePath
}
Module的实现很简单,其实就是给模块创建一个exports
对象,load
方法执行的时候,会读取模块中的内容,并给内容包裹一个函数,得到一个string类型的function,最后借助vm.runInThisContext
创建一个沙箱环境将函数执行,传入的参数有:exports
, require
, module
, __filename
, __dirname
,将运行后的内容加入到exports
中,new Module
时候传入的filepath
也就是id
就是模块带后缀的的绝对路径。
js
function Module(id){
this.id = id; // 要加载的模块路径
this.exports = {};
}
现在,Module
有了模块的绝对路径,可以用load
方法加载模块内容了,加载模块过程是同步的
js
Module.prototype.load = function() {
// 获取后缀名
const ext = path.extname(this.id);
Module._extensions[ext](this); // 根据不同后缀名加载模块
}
前面说过,Node
模块是将模块文件读取到,然后外面包裹一个函数,得到一个函数的string,然后利用vm
模块创建一个沙箱环境,隔绝了上下文,将函数执行。 这里给Module
挂载静态属性wrapper
,里面定义一下这个函数的字符串,wrapper
是一个数组,数组的第一个元素就包括函数的形参部分,其中有exports
,module
,require
,__dirname
,__filename
, 都是模块中常用的全局变量。注意这里传入的require
参数是定义的req
。第二个参数就是函数的结束部分。两部分都是字符串,使用的时候将他们包裹在模块的字符串外部就可以了。
js
Module.wrapper = [
"(function(exports, module, Require, __dirname, __filename) {",
"})"
]
_extensions
用于针对不同的模块扩展名使用不同的加载方式,比如JSON
和js
加载方式肯定是不同的。JSON
使用JSON.parse
来运行
js
Module._extensions = {
'.js'(module) {
const content = fs.readFileSync(module.id, {encoding:'utf-8'});
const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
const fn = vm.runInThisContext(fnStr);
let exports = module.exports
let dirname = path.dirname(module.id)
let filename = module.id
fn.call(exports,exports,module,req,dirname,filename)
},
'.json'(module) {
let jsonStr = fs.readFileSync(module.id,{encoding:'utf-8'})
module.exports = JSON.parse(jsonStr)
}
}
加载js文件的时候,fn.call(exports,exports,module,req,dirname,filename)
,其实就是用module.exports去调用新生成的函数,最终函数的结果就会挂载到module.exports上;
到这里,简单的require加载机制就实现了
js
const path = require('path');
const fs = require('fs');
const vm = require('vm');
function Module(id){
this.id = id; // 要加载的模块路径
this.exports = {};
}
Module.wrapper = [
"(function(exports, module, require, __dirname, __filename) {",
"})"
]
Module._extensions = {
'.js'(module) {
const content = fs.readFileSync(module.id, {encoding:'utf-8'});
const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
const fn = vm.runInThisContext(fnStr);
let exports = module.exports
let dirname = path.dirname(module.id)
let filename = module.id
fn.call(exports,exports,module,req,dirname,filename)
},
'.json'(module) {
let jsonStr = fs.readFileSync(module.id,{encoding:'utf-8'})
module.exports = JSON.parse(jsonStr)
}
}
Module.prototype.load = function() {
// 判断后缀名,根据后缀名加载模块
const ext = path.extname(this.id);
Module._extensions[ext](this); // 根据不同后缀名加载模块
}
Module._resolveFilename = function(modulePath) {
// 将传入的路径解析成绝对路径
modulePath = path.resolve(__dirname,modulePath)
// 判断路径时都存在,(是否有后缀名,有后缀可能就存在)
let isExist = fs.existsSync(modulePath)
// 存在就返回该路径
if(isExist) return modulePath
}
function req(modulePath) {
// 1. 解析传入的路径成为绝对路径(且带后缀)
let filepath = Module._resolveFilename(modulePath)
// 2. 根据文件路径,创建模块
const module = new Module(filepath);
// 加载模块
module.load()
// 最终会得到 exports对象,默认是空对象{}
return module.exports;
}
const a = req('./a.js')
console.log("----",a);
2.3 省略模块后缀名
上一步实现的模块加载机制,我们必须要传入完整的模块文件名,才能实现加载,但是真正的require是可以省略后缀的
省略后缀时候,首先会根据支持的后缀名依次给传入的模块名补充后缀,顺序是.js
-->.json
,这也是为什么在有同名的js和json文件情况下会优先加载js文件的原因,当补充后缀后,发现文件存在,就会去加载对应的文件。
上一步里面Module._resolveFilename
方法是根据传入的相对路径解析得到绝对路径的,那么可以在这个方法里面添加省略后缀的逻辑;
js
Module._resolveFilename = function(modulePath) {
// 将传入的路径解析成绝对路径
modulePath = path.resolve(__dirname,modulePath)
// 判断路径时都存在,(是否有后缀名,有后缀可能就存在)
let isExist = fs.existsSync(modulePath)
// 存在就返回该路径
if(isExist) return modulePath;
// 不存在就补充后缀名再解析
let extArr = Object.keys(Module._extensions);
for (let index = 0; index < extArr.length; index++) {
modulePath = modulePath + extArr[index];
if(fs.existsSync(modulePath)) return modulePath;
}
throw new Error('module not exists');
}
2.4 给模块添加缓存
原版的require
机制里面,若是写了多个require同一个模块,则只有第一次才会去load模块,后面的都会用缓存里面的东西。从而提高加载的性能。
添加缓存也比较简单,就是文件加载的时候将文件放入缓存在,再去加载模块时先看缓存中是否存在,如果存在直接使用,如果不存在再去重新嘉爱,加载之后再放入缓存。
js
function req(modulePath) {
// 1. 解析传入的路径成为绝对路径(且带后缀)
let filepath = Module._resolveFilename(modulePath)
let cachModule = Module._cache[filepath]
if(cachModule) return cachModule.exports // 若加载的是同一个模块,则走缓存
// 2. 根据文件路径,创建模块
const module = new Module(filepath);
Module._cache[filepath] = module; // 根据文件名添加缓存
// 加载模块
module.load()
// 最终会得到 exports对象,默认是空对象{}
return module.exports;
}
2.5 步骤总结
- 创建一个加载函数
req
,内部的Module._resolveFilename
会根据传入的模块路径判断模块是否存在,还会补齐后缀名,补齐的顺序是 .js --> .json; - 传入解析得到的模块路径创建一个模块对象
(new Module(modulePath))
。模块对象内部会创建一个exports
对象; - 调用加载模块的方法`Module.prototype.load,根据后缀名加载文件,并解析,用策略模式针对不同的后缀名匹配不同的加载方法,解耦代码;
- 加载完后,将加载的内容在一个隔绝上下文的环境运行,并将运行后的内容给
module.exports
,最终返回的就是module.exports
; - 在加完成后。会根据模块的绝对路径,将
module
存入缓存,再次加载时候会取缓存内的module,不会重新加载文件。
2.6 最终完整代码
js
const path = require('path');
const fs = require('fs');
const vm = require('vm');
function Module(id){
this.id = id; // 要加载的模块路径
this.exports = {};
}
Module.wrapper = [
"(function(exports, module, require, __dirname, __filename) {",
"})"
]
Module._extensions = {
'.js'(module) {
const content = fs.readFileSync(module.id, {encoding:'utf-8'});
const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
const fn = vm.runInThisContext(fnStr);
let exports = module.exports
let dirname = path.dirname(module.id)
let filename = module.id
fn.call(exports,exports,module,req,dirname,filename)
},
'.json'(module) {
let jsonStr = fs.readFileSync(module.id,{encoding:'utf-8'})
module.exports = JSON.parse(jsonStr)
}
}
Module.prototype.load = function() {
// 判断后缀名,根据后缀名加载模块
const ext = path.extname(this.id);
Module._extensions[ext](this); // 根据不同后缀名加载模块
}
Module._resolveFilename = function(modulePath) {
// 将传入的路径解析成绝对路径
modulePath = path.resolve(__dirname,modulePath)
// 判断路径时都存在,(是否有后缀名,有后缀可能就存在)
let isExist = fs.existsSync(modulePath)
// 存在就返回该路径
if(isExist) return modulePath;
// 不存在就补充后缀名再解析
let extArr = Object.keys(Module._extensions);
for (let index = 0; index < extArr.length; index++) {
modulePath = modulePath + extArr[index];
if(fs.existsSync(modulePath)) return modulePath;
}
throw new Error('module not exists');
}
Module._cache = {};
function req(modulePath) {
// 1. 解析传入的路径成为绝对路径(且带后缀)
let filepath = Module._resolveFilename(modulePath)
let cachModule = Module._cache[filepath]
if(cachModule) return cachModule.exports // 若加载的是同一个模块,则走缓存
// 2. 根据文件路径,创建模块
const module = new Module(filepath);
Module._cache[filepath] = module; // 根据文件名添加缓存
// 加载模块
module.load()
// 最终会得到 exports对象,默认是空对象{}
return module.exports;
}
三. exports 与 module.exports
在node中,exports
与 module.exports
都可以用于模块的导出。但是两种方式有区别,需要注意:
exports
是module.exports
的别名(地址引用关系),导出对象最终以module.exports
为准 1.1 所以exports = {}
是错误的写法 1.2 所以exports.fn = function () {}
和module.exports.fn = function (){}
两者等价- 当
module.exports
以{}
的方式整体导出时会覆盖exports
的属性和方法 - 如果一个模块没有导出任何成员, 导入的时候是空对象
- 可以用
this
代替exports
,例如:this.a=xxx;this.b=xxx
在require模块加载机制里面,module.exports 与 exports的关系,在函数内是这样的
js
// 伪代码
function (module){
let exports = module.exports;
let this = exports;
// 最终导出的是module.exports
return module.exports;
}
实际上,等价于this = exports = module.exports
;模块内exports
与module.exports
与this
都是指向同一块引用地址,默认是空对象{},是在Module里面声明的。 因此在写exports = xxx
的时候,会改变模块内this的指向,是不允许这样写的 若是写了exports.a = xxx
,相当于给他们共同维护的空间添加了一个a属性,值为xxx,最终导出的是module.exports
,也是{a:xxx}
同时写了exports
与module.exports
,module.exports
会覆盖exports
,因为最终返回的是module。exports
,因此,它优先级最高。即使exports.a={b:100}
,那么只是exports的指向被改变了,不会影响module.exports