Fork me on GitHub

深入理解前端模块加载机制,手写 node.js 的 require 函数

1. 模块化的演进

模块化特点的特点是什么? 防止变量污染

在以前的开发中,如果不用模块化,我们无法处理依赖关系,所以代码很难维护

那最早的时候我们是使用 单例 的方式解决,但是有个缺点,我们不能保证变量的唯一性,而且单例可能导致调用时浮躁,命名过长等问题, 如下方代码所示:

1
2
3
4
5
6
7
8
9
10
11
const a = {
m: 'test',
get: function() {
...
},
...
}

const a1234455 = {
....
}

当然,我们还有用到了 IFFE(立即执行函数)来处理这个问题

1
2
3
4
(function(a){
console.log('ddd', a);
.....
})()

再近点,就出现了一些解决模块化的第三方库,下面两种,在现在未前后端分离的项目(也就是 mvc 模式下 jquery 项目)还是会见到的。

  • seajs库 cmd(就近依赖,只有在用到某个模块的时候再去require)
  • requirejs库 amd(依赖前置, 在定义模块的时候就要声明其依赖的模块)

这里不多说了,感兴趣的可以百度了解下这两个库。

那当下 vue, react 框架流行下,我们是如何来处理模块化的呢?

结论是: umd(统一模块化),常见的是 es6 module(node 中无法使用) 和 commonjs 规范

如果想要在 node 中使用 es module,也就是 import 和 export,怎么办呢?

node 官网提供了一个 ECMAScript模块,它需要将文件后缀改成 .mjs,来支持 import 导入的方式,但是目前还是实验版。

还有一种方式,就是使用 babel-node 来转化 es6 模块。

1.1 commonjs 规范

特点是啥,往下看?

  • 每个 js 文件都是一个模块
  • 每个文件如果需要用到别的模块 require()
  • 想把代码给别人使用 需要导出模块 module.exports

那文件之间是怎么隔离的?

其实很简单,就是给当前文件代码加了个闭包来隔离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function(){
let module = {
exports: {}
}
module.exports = 'hello'
return module.exports
})()
let file = require('./a.js')

// 大概效果如下
let file = require((function(){
let module = {
exports: {}
}
module.exports = 'hello'
return module.exports
})())

2. fs path vm 等 node 模块

这里为什么单独介绍下这几个模块,因为这几个是核心模块,看过源码,你就知道, require 的实现就需要用到这几个模块。

  • path

path 模块提供了一些实用工具,用于处理文件和目录的路径

具体可看:path 文档

  • fs

fs 模块可用于与文件系统进行交互(以类似于标准 POSIX 函数的方式)。

具体可看:fs 文档

  • vm

vm 模块可在 V8 虚拟机上下文中编译和运行代码。 vm 模块不是安全的机制。 不要使用它来运行不受信任的代码。

具体可看:vm 文档

vm 模块这里单独提下,

我们回忆下让字符串执行的方法有哪些?

  1. eval
1
2
let a = 1;
eval('console.log(a)')
  1. new Function
1
2
let strFn = new Function('console.log(a)')
strFn();

vue 模板引擎的实现原理就是 new Function + with

  1. vm

vm 沙箱,创造一个干净的执行上下文环境,不会向上查找

1
vm.runInThisContext(str)

3. require 实现过程

3.1 断点调试

这里简单提下如何断点调试,不方便展示,大家可以网上翻阅下文档

我用的 vscode, 需要配置下 launch.json 文件,下面是我的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Client",
"program": "${workspaceFolder}/app.js"
}
]
}

然后运行 debug 模式,就可以查看源码,面板如下所示

require

3.2 require 执行过程

  1. 加载时 先看一下模块是否被缓存过 第一次没有缓存过
  2. Module._resolveFilename 解析出当前引用文件的绝对路径
  3. 是否是内置模块,不是就创建一个模块 模块有两个属性 一个叫 id = 文件名, exports = {}
  4. 将模块放到缓存中
  5. 加载这个文件 Module.load
  6. 拿到文件的扩展名 findLongestRegisteredExtension() 根据扩展名来调用对应的方法
  7. 会读取文件 差一个加一个自执行函数,将代码放入

3.3 手写 require 源码

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
// a.js 文件
module.exports = 'hello';
console.log('加载了一次');

// require.js 文件
let fs = require('fs');
let path = require('path');
let vm = require('vm');

function Module(id) {
this.id = id; // 文件名
this.exports = {}; // exports 导出对象
}

Module._resolveFilename = function(filename) {
// 应该去依次查找 Object.keys(Module._extensions)
// 默认先获取文件的名字
filename = path.resolve(filename);
// 获取文件的扩展名 并判断是否有,若没有就是.js,若有,就采用原来的名字
let flag = path.extname(filename);
let extname = flag ? flag : '.js';
return flag ? filename : (filename + extname);
}

Module._extensions = Object.create(null);

Module.wrapper = [
'(function(module,exports,require,__dirname,__filename){',
'})'
]

Module._extensions['.js'] = function(module) { // id exports
// module.exports = 'hello'
let content = fs.readFileSync(module.id, 'utf8')
let strTemplate = Module.wrapper[0] + content + Module.wrapper[1];
// console.log('111', strTemplate);
// 希望让这个函数执行,并且,我希望吧exports 传入进去
let fn = vm.runInThisContext(strTemplate);
// 模块中的 this 就是 module.exports的对象
fn.call(module.exports, module, module.exports, requireMe);
}

// json 就是直接将结果放到 module.exports 上
Module._extensions['.json'] = function(module) {
let content = fs.readFileSync(module.id, 'utf8');
module.exports = JSON.parse(content);
}

Module.prototype.load = function() {
// 获取文件的扩展名
let extname = path.extname(this.id);
Module._extensions[extname](this);
}

Module._cache = {}; // 缓存对象

function requireMe(filename) {
let absPath = Module._resolveFilename(filename);
// console.log(absPath);
if (Module._cache[absPath]) { // 如果缓存过了,直接将exports 对象返回
return Module._cache[absPath].exports;
}
let module = new Module(absPath);
// 增加缓存模块
Module._cache[absPath] = module;
// 加载
module.load();
return module.exports; // 用户将结果赋予给 exports 对象上 默认 require 方法会返回 module.exports 对象
}

let str = requireMe('./a');
str = requireMe('./a');
console.log('===', str);

4. module.exports 与 exports 关系

exports 是 module.exports 一个简写

  • 常用的导出方式

exports.xxx = xxx
module.exports
module.exports.a = xxx
global.a = xxx(可以,但不会用,全局污染)
exports = xxx(错误)

5. 模块查找方式

有几种模块:

  1. 内置模块 fs path vm
  2. 文件模块 自定义模块 ‘./‘
  3. 第三方模块 (bluebird….)必须安装才能使用, 用法和内置模块是一样的

方式:

  • 先查找当前文件下的文件存不存在,不存在 添加 .js .json 后缀 找到后就结束
  • 找不到后会找对应的文件夹,默认找索引文件,如果有package.json ,有这个文件,会查找 main对应的入口文件,去进行加载
  • 按照包的方法查找 多个文件组成一个包 npm init -y
  • 除了文件的查找方式 第三方模块的查找方式
1
2
3
let r = require('xxx') // xxx表示的是第三方文件夹的名字,找到名字后会找package.json ,如果找不到向上找,找不到就报错。

console.log(module.paths); // 查看效果
-------------本文结束感谢您的阅读-------------