require源码(小白级教程)

393 阅读5分钟

前言

模块化是一个神奇的东西,好多语言里面都存在模块化,模块化的原理是什么,想必很多小伙伴都很感兴趣,今天我们就手写node的require.js来让大家更深入的理解模块化

完整代码可以参考 github代码库:github.com/Y-wson/Dail…

简单的例子

首先建立一个文件a

let a = 12;
a = a + 1;
module.exports = a;

在建立一个文件b

const a = require("./v");

console.log(a);

无疑答案是13,从上面步骤我们可以看出,先执行整个文件a,然后把a的执行结果,赋值给module.exports,文件b中在取出module.exports的值,赋值给变量a,最后打印变量a

接下来我们先来了解一些基本概念

模块分类

  1. 内置模块:就是Node.js原生提供的功能,比如fshttp等等,这些都是Node.js的核心模块,这些模块在Node.js进程起来时就加载了。
  2. 文件模块:第三方的模块以及自定义模块

执行流程

1.路径分析: 依据标识符确定模块位置

  1. 优先加载内置模块,即使有同名文件,也会优先使用内置模块。
  2. 不是内置模块,先去缓存找。
  3. 缓存没有就去找对应路径的文件。
  4. 不存在对应的文件,就将这个路径作为文件夹加载。
  5. 对应的文件和文件夹都找不到就去node_modules下面找。
  6. 还找不到就报错了。

加载文件夹

前面提到找不到文件就找文件夹,但是不可能将整个文件夹都加载进来,加载文件夹的时候也是有一个加载顺序的:

  1. 先看看这个文件夹下面有没有package.json,如果有就找里面的main字段,main字段有值就加载对应的文件。所以如果大家在看一些第三方库源码时找不到入口就看看他package.json里面的main字段吧,比如jquerymain字段就是这样:"main": "dist/jquery.js"
  2. 如果没有package.json或者package.json里面没有main就找index文件。
  3. 如果这两步都找不到就报错了。

2.文件定位: 确定目标模块中具体的文件及文件类型

支持的文件类型

require主要支持三种文件类型:

  1. .js.js文件是我们最常用的文件类型,加载的时候会先运行整个JS文件,然后将前面说的module.exports作为require的返回值。
  2. .json.json文件是一个普通的文本文件,直接用JSON.parse将其转化为对象返回就行。
  3. .node.node文件是C++编译后的二进制文件,纯前端一般很少接触这个类型。

3.编译执行: 采用对应的方式完成文件的编译执行

我们会用vm去执行文件

手写源码

模块加载的所有功能都在Module类里面,为了区别原生的Module类,我们给我们的模块取名为MyModule

首先建立一个MyModule.js文件,然后初始化我们的文件

function MyModule(id = "") {
    this.id = id;
    this.exports = {};
}

MyModule._extension = {
    ".js": () => {},
    ".json": () => {},
};

MyModule.require = (request) => {
    console.log(request);
};

接下来我们先进行第一步路径分析,此处我们只是小白级教程,所以只讲解加载自定义模块,那么我们自定义一个方法resolveFileName

MyModule.resolveFileName = (request) => {
    let filename = path.resolve(request);
    const extname = path.extname(filename);
    if (extname) {
        return filename;
    } else {
        const suffixArr = Object.keys(MyModule._extension);
        for (let suffix of suffixArr) {
            filename = `${filename}${suffix}`;
            if (fs.existsSync(filename)) {
                return filename;
            }
        }
    }
};

然后是看看缓存里面有没有这个模块,如果有的话,那么我们直接进行加载,如果没有的话,我们就开始load模块

MyModule._cache = [];

MyModule.require = (request) => {
    const filename = MyModule.resolveFileName(request);
    // 判断缓存里面是否有模块
    const cacheModule = MyModule._cache[filename];
    if (cacheModule) return cacheModule.exports;
    // 初始化一个新的模块
    const module = new MyModule(filename);
    // load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整
    MyModule._cache[filename] = module;
    module.load(module);
    // 返回模块的exports
    return module.exports;
};

load函数其实就是我们的第二步,确定文件类型,执行对应的加载文件的方法

MyModule.load = (module) => {
    const extname = path.extname(module.id);
    // 根据后缀执行文件内容
    MyModule._extension[extname](module);
};

接下来就是执行文件了,首先我们定义js文件如何执行

注意点: 我们知道在模块内有几个类似于全局变量的变量,比如__filename, __dirname,require等,这些都是如何来的呀,其实就是通过函数传过去的,并不是真正的全局变量

// 定义匿名函数

MyModule._wrapper = [
`(function(exports,require,module,__filename,__dirname){`,
`})`,
];


MyModule._extension = {
".js": (module) => {
        // 获取文件内容
        const content = fs.readFileSync(module.id, "utf-8");
        // 准备好全局变量传给模块内部
        const filename = module.id;
        const dirname = path.dirname(filename);
        // vm是用来执行字符串的库
        const fun = vm.runInThisContext(
        MyModule._wrapper[0] + content + MyModule._wrapper[1]
    );
    fun.call(
        module.exports,
        module.exports,
        MyModule.require,
        module,
        filename,
        dirname
    );
},
".json": (module) => {},
};

接下来定义一下json文件如何执行,json文件执行要比js简单的多

".json": (module) => {
    const content = fs.readFileSync(module.id, "utf-8");
    module.exports = JSON.parse(content);
},

好啦,我们自己的MyModule就大功告成了

小伙伴们可以自己引用测试一下

总结

  • 1.手写require源码的三个步骤1.路径分析2.文件定位3.编译执行
  • 2.编译执行其实是用到了vm这个库,来执行字符串的,相对于eval,function等更好用
  • 3.每个模块里面的exports, require, module, __filename, __dirname五个参数都不是全局变量,而是模块加载的时候注入的。

参考

深入Node.js的模块加载机制,手写require函数