6

在node项目中,require和module.exports使用非常普遍,js模块化带来的效率大大提升。一直很好奇require背后是怎样运行的,最近仔细看了看这部分的源码,然后参考了其他人的文章,还好node中的Module是JavaScript写的可以看懂。

CommonJs规范

commonJs规范可以说是js模块化中的里程碑,目前npm上面的包基本都支持该规范。在CommonJs中:

  1. 一个文件就是一个模块,拥有单独的作用域;
  2. 普通方式定义的变量、函数、对象都属于该模块内;
  3. 通过require来加载模块;
  4. 通过exports和module.exports来暴露模块中的内容;

举个🌰:a.js

var aParam = 23; 

exports.value = aParam;

module.exports = {
    calculate: function(param){
        return aParam + param;
    },
    getA: function(){
        return aParam;
    },
    value: aParam
};

然后入口文件:index.js

var a = require('./a');

console.log(a);
// {calculate: [Function], getA: [Function], value: 23}

console.log(a.calculate(2));
// 25

console.log(a.value);
// 23

ok很简单的小例子,我们知道了通过require方法可以加载一个js文件,该文件中exports出来的变量会当做文件运行结果。

思考思路

现在没有看源码,我们凭借着使用经验可以梳理一下require和module的特点及实现思路:

  1. require和module是暴露的全局对象。
  2. require接受一个参数(path),这个参数可以是相对路径,也可以是自带模块或者是package.json中的插件。
  3. 这个参数如果不是完整的文件名可以实现模糊匹配。(后缀匹配和路径匹配)
  4. 匹配到文件之后,编译该文件。
  5. 文件exports出来的变量作为require方法的返回值。

这相当于是一个小项目,需求都列出来了。先不着急自己动手实践,因为缺少一些条件写不出来,看看大神的代码怎么写的。

源码入手

再看源码之前,官方文档一定要准备好,里面用到了很多原生的api。Module源码在Node项目中,只有一个Module文件,全都包含在里面。该文件也引入了一些其他的依赖。

依赖引入

// 原生的模块
var NativeModule = require('native_module');
var util = require('util');
// vm沙箱
var runInThisContext = require('vm').runInThisContext;
var runInNewContext = require('vm').runInNewContext;
var assert = require('assert').ok;

var fs = require('fs');

function hasOwnProperty(obj, prop) {
    return Object.prototype.hasOwnProperty.call(obj, prop);
}

文件开头引入了一些要用的工具类和方法,注意这里的文件引入是直接使用require关键字引入的。

Module模块

/**
 * 大写的Module实际上是module的工厂
 * @param id 路径 
 * @param parent 调用者的module对象
 * @constructor
 */
function Module(id, parent) {
    this.id = id; // 文件验重的表示,字符串形式的绝对路径
    this.exports = {};
    this.parent = parent;
    if (parent && parent.children) {
        parent.children.push(this);
    }

    this.filename = null;
    this.loaded = false;
    this.children = [];
}

module.exports = Module;

// 初始化一些变量
Module._contextLoad = (+process.env['NODE_MODULE_CONTEXTS'] > 0);
Module._cache = {};
Module._pathCache = {};
Module._extensions = {};
// node_module文件夹可能的路径
var modulePaths = [];
Module.globalPaths = [];

Module.wrapper = NativeModule.wrapper;
Module.wrap = NativeModule.wrap;

var path = require('path');

然后定义了Module方法,Module是个工厂方法,module是工厂的实例。Module中主要的属性:(被加载的文件这里简称文件)

  1. id '.' || 文件的绝对路径
  2. exports 文件暴露的变量,默认是{};
  3. parent 文件的调用者的module实例
  4. filename 文件的绝对路径
  5. loaded 是否加载完成
  6. children 调用文件的关系集合

然后初始化了一些变量,下面会用到。

require方法

/**
 * 暴露的require方法
 * @param path 文件的相对路径或者是自带模块的名字
 */
Module.prototype.require = function(path) {
    return Module._load(path, this);
};

require方法实际上是调用了Module的私有方法。

/**
 * 私有的加载文件
 * @param request 需要的文件名字或路径
 * @param parent 对应文件的父节点
 * @param isMain 是否是入口调用
 * @private
 */
Module._load = function(request, parent, isMain) {

    // 根据路径解析出filename
    var filename = Module._resolveFilename(request, parent);

    // 从Module缓存中取出是否有缓存
    var cachedModule = Module._cache[filename];

    if (cachedModule) {
        return cachedModule.exports;
    }

    // 系统自带模块中是否有该文件
    if (NativeModule.exists(filename)) {

        // 单独对repl模块处理
        if (filename == 'repl') {
            // 编译
            var replModule = new Module('repl');
            replModule._compile(NativeModule.getSource('repl'), 'repl.js');
            NativeModule._cache.repl = replModule;
            return replModule.exports;
        }

        // 其他自带模块
        return NativeModule.require(filename);
    }

    // 不是自带模块 生成一个实例
    var module = new Module(filename, parent);

    // 如果是主程序入口 则确定当前位置
    if (isMain) {
        process.mainModule = module;
        module.id = '.';
    }

    // 缓存实例
    Module._cache[filename] = module;

    // 尝试加载该文件module,如果有错误则回滚该module
    var hadException = true;

    try {
        module.load(filename);
        hadException = false;
    } finally {
        if (hadException) {
            delete Module._cache[filename];
        }
    }

    // 暴露
    return module.exports;
};

_load方法是加载文件的主要流程的方法:

  1. resloveFilename方法根据输入的相对路径或者是包名字算出文件的绝对路径(filename)。
  2. 如果缓存中有直接返回缓存结果;
  3. 如果是自带模块直接返回结果;
  4. 否则创建一个module实例并缓存,尝试加载该文件,然后返回文件的暴露结果。

这就是require方法的工作主流程,这里很重要一点:文件被加载过会缓存结果。

该方法中重要的两个步骤:

  1. Module._resolveFilename() 处理文件路径
  2. module.load() 加载文件

我们一个个来看。

处理路径

/**
 * 确定模块的绝对路径(filename)
 * @param request 文件相对路径
 * @param parent 调用者的Module实例
 * @returns filename
 * @private
 */
Module._resolveFilename = function(request, parent) {
    // 如果是自带模块 filename 就是 request
    if (NativeModule.exists(request)) {
        return request;
    }

    // 确定request可能的路径有哪些
    var resolvedModule = Module._resolveLookupPaths(request, parent);
    var id = resolvedModule[0];
    var paths = resolvedModule[1];

    var filename = Module._findPath(request, paths);
    if (!filename) {
        var err = new Error("Cannot find module '" + request + "'");
        err.code = 'MODULE_NOT_FOUND';
        throw err;
    }

    // 解析后的文件的绝对路径
    return filename;
};

_resolveFilename处理文件的绝对路径方法,这个方法主要流程:

  1. 自带模块里面有的话 返回文件名;
  2. 算出这个文件可能的路径;
  3. 在可能路径中找出真正的路径并返回;
  4. 没有的话报错。

为什么要算出可能的路径呢,因为文件来源很多:相对路径下的文件;系统自带模块;还有可能是node_modules中的包。如果是第三种node_modules文件夹位置无法确定,所以要进行路径测算。

路径测算

路径测算两个方法:

/**
 * 查找文件可能的路径
 * @param request 文件的相对路径
 * @param parent 父节点的Module实例
 * @returns [request, paths] [文件的相对路径, 可能的路径(Array)]
 * @private
 */
Module._resolveLookupPaths = function(request, parent) {
    // 自带模块 返回request
    if (NativeModule.exists(request)) {
        return [request, []];
    }

    // 判断路径开头 不是相对路径 补充可能的路径(依赖包里的路径)
    var start = request.substring(0, 2);
    if (start !== './' && start !== '..') {
        var paths = modulePaths;
        if (parent) {
            if (!parent.paths) parent.paths = [];
            paths = parent.paths.concat(paths);
        }
        return [request, paths];
    }

    // 没有调用者 
    if (!parent || !parent.id || !parent.filename) {

        var mainPaths = ['.'].concat(modulePaths);
        mainPaths = Module._nodeModulePaths('.').concat(mainPaths);
        return [request, mainPaths];
    }

    // 路径是结尾是否是index
    var isIndex = /^index\.\w+?$/.test(path.basename(parent.filename));
    // 确定调用者(parent)的绝对路径
    var parentIdPath = isIndex ? parent.id : path.dirname(parent.id);
    var id = path.resolve(parentIdPath, request);

    if (parentIdPath === '.' && id.indexOf('/') === -1) {
        id = './' + id;
    }

    return [id, [path.dirname(parent.filename)]];
};

/**
 * 解析出node_modules目录可能的路径
 * @param from 当前模块的路径
 * @returns [Array]
 * @private
 */
Module._nodeModulePaths = function(from) {
    // 从from解析出绝对路径
    from = path.resolve(from);

    // 根据操作系统不同兼容处理 解析出可能的路径
    var splitRe = process.platform === 'win32' ? /[\/\\]/ : /\//;
    var paths = [];
    var parts = from.split(splitRe);

    /**
     * 这段比较有意思
     * 我们假设form的绝对路径是:
     * /Users/aus/Documents/node
     * 则项目的node_module文件夹路径可能是:
     * /Users/aus/Documents/node/node_modules
     * /Users/aus/Documents/node_modules
     * /Users/aus/node_modules
     * /Users/node_modules
     * /node_modules
     */
    for (var tip = parts.length - 1; tip >= 0; tip--) {
        // don't search in .../node_modules/node_modules
        if (parts[tip] === 'node_modules') continue;
        var dir = parts.slice(0, tip + 1).concat('node_modules').join(path.sep);
        paths.push(dir);
    }

    return paths;
};

这里面如何算出可能的路径:

  1. 自带模块可能路径为[]。
  2. 路径不是相对路径,可能路径是node环境的自带包(全局安装的包 npm i XXX -g)。
  3. 没有调用者的话,可能是项目node_module中的包。
  4. 否则根据调用者(parent)的路径算出绝对路径。

第二个方法Module._nodeModulePaths则是推测项目中node_modules文件夹的路径。

这块测算路径挺有意思的,值得多看一看。

精确匹配

然后如何在所有可能的路径中找到唯一的结果:

/**
 * 根据所有可能的路径确定真实的路径
 * @param request 文件的相对路径
 * @param paths 可能的路径
 * @returns filename 文件的绝对路径
 * @private
 */
Module._findPath = function(request, paths) {
    var exts = Object.keys(Module._extensions);

    // 如果是绝对路径,就不再搜索
    if (request.charAt(0) === '/') {
        paths = [''];
    }

    // 是否有后缀的目录斜杠
    var trailingSlash = (request.slice(-1) === '/');

    // 如果当前路径已在缓存中,就直接返回缓存
    var cacheKey = JSON.stringify({request: request, paths: paths});
    if (Module._pathCache[cacheKey]) {
        return Module._pathCache[cacheKey];
    }

    // For each path
    for (var i = 0, PL = paths.length; i < PL; i++) {
        var basePath = path.resolve(paths[i], request);
        var filename;

        if (!trailingSlash) {
            // try to join the request to the path
            filename = tryFile(basePath);

            if (!filename && !trailingSlash) {
                // try it with each of the extensions
                filename = tryExtensions(basePath, exts);
            }
        }

        // 是否是package.json中的文件
        if (!filename) {
            filename = tryPackage(basePath, exts);
        }

        // 是否存在目录名 + index + 后缀名
        if (!filename) {
            // try it with each of the extensions at "index"
            filename = tryExtensions(path.resolve(basePath, 'index'), exts);
        }

        // 找到文件路径了 缓存
        if (filename) {
            Module._pathCache[cacheKey] = filename;
            return filename;
        }
    }

    // 404
    return false;
};

精确匹配路径过程则相对简单,就是一个个试:

  1. 文件后缀模糊匹配 .js,.json, .node
  2. 如果当前路径已在缓存中,就直接返回缓存;
  3. 依次遍历所有路径
  4. 文件是否在与路径直接匹配
  5. 路径加上后缀名是否匹配到
  6. 是否是package.json中的包
  7. 是否存在目录名 + index + 后缀名
  8. 将找到的文件路径存入返回缓存,然后返回
  9. 否则404

通过上面的处理我们可以将一个需要加载的文件绝对路径算出来,下一步只要编译该文件即可。

编译文件

来到了module.load这个方法

/**
 * 根据文件路径名 尝试不同扩展名解析文件
 * @param filename 文件路径
 */
Module.prototype.load = function(filename) {

    assert(!this.loaded);
    this.filename = filename;
    this.paths = Module._nodeModulePaths(path.dirname(filename));

    var extension = path.extname(filename) || '.js';
    if (!Module._extensions[extension]) extension = '.js';
    Module._extensions[extension](this, filename);

    // 文件解析完毕
    this.loaded = true;
};

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
    var content = fs.readFileSync(filename, 'utf8');
    module._compile(stripBOM(content), filename);
};

// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
    var content = fs.readFileSync(filename, 'utf8');
    try {
        module.exports = JSON.parse(stripBOM(content));
    } catch (err) {
        err.message = filename + ': ' + err.message;
        throw err;
    }
};

//Native extension for .node
Module._extensions['.node'] = process.dlopen;

找到了文件要根据文件的不同后缀来处理;

其中js和json文件处理方法仔细看。

文件编码处理

/**
 * 剥离 utf8 编码特有的BOM文件头
 * @param content
 * @returns content 处理后的
 */
function stripBOM(content) {
    // Remove byte order marker. This catches EF BB BF (the UTF-8 BOM)
    // because the buffer-to-string conversion in `fs.readFileSync()`
    // translates it to FEFF, the UTF-16 BOM.
    if (content.charCodeAt(0) === 0xFEFF) {
        content = content.slice(1);
    }
    return content;
}

获取到文件,将文件转成二进制流要处理头信息。

为什么要处理头信息,看这里

沙箱编译

ok,到这里准备工作做完了要到了真正的编译环节。

/**
 * 将文件在沙箱里运行 将暴露的变量提取出来
 * @param content 文件字节流
 * @param filename 文件路径
 * @returns {*}
 * @private
 */
Module.prototype._compile = function(content, filename) {
    var self = this;
    // remove shebang
    content = content.replace(/^\#\!.*/, '');

    function require(path) {
        return self.require(path);
    }

    require.resolve = function(request) {
        return Module._resolveFilename(request, self);
    };

    Object.defineProperty(require, 'paths', { get: function() {
        throw new Error('require.paths is removed. Use ' +
            'node_modules folders, or the NODE_PATH ' +
            'environment variable instead.');
    }});

    require.main = process.mainModule;

    // Enable support to add extra extension types
    require.extensions = Module._extensions;
    require.registerExtension = function() {
        throw new Error('require.registerExtension() removed. Use ' +
            'require.extensions instead.');
    };

    require.cache = Module._cache;

    var dirname = path.dirname(filename);

    if (Module._contextLoad) {
        if (self.id !== '.') {
            debug('load submodule');
            // not root module
            var sandbox = {};
            for (var k in global) {
                sandbox[k] = global[k];
            }
            sandbox.require = require;
            sandbox.exports = self.exports;
            sandbox.__filename = filename;
            sandbox.__dirname = dirname;
            sandbox.module = self;
            sandbox.global = sandbox;
            sandbox.root = root;

            return runInNewContext(content, sandbox, { filename: filename });
        }

        debug('load root module');
        // root module
        global.require = require;
        global.exports = self.exports;
        global.__filename = filename;
        global.__dirname = dirname;
        global.module = self;

        return runInThisContext(content, { filename: filename });
    }

    // create wrapper function
    var wrapper = Module.wrap(content);

    var compiledWrapper = runInThisContext(wrapper, { filename: filename });
    if (global.v8debug) {
        if (!resolvedArgv) {
            // we enter the repl if we're not given a filename argument.
            if (process.argv[1]) {
                resolvedArgv = Module._resolveFilename(process.argv[1], null);
            } else {
                resolvedArgv = 'repl';
            }
        }

        // Set breakpoint on module start
        if (filename === resolvedArgv) {
            global.v8debug.Debug.setBreakPoint(compiledWrapper, 0, 0);
        }
    }
    var args = [self.exports, require, self, filename, dirname];
    return compiledWrapper.apply(self.exports, args);
};

这里编译主要是沙箱(沙盒)编译,文件的内容被嵌入到一个闭包盒子中,盒子中的运行结果不会对外部环境产生影响,通过module.exports通信,比如这样:

(function (exports, require, module, __filename, __dirname) {
    //原始文件内容
});

这样发现,在js文件里,require和module都是注入的变量,而不是真正的全局变量。通过这样闭包沙盒实现模块化。(这样看起来是不是跟requireJs很像)

初始化

最后初始化一下当前模块

// bootstrap main module.
Module.runMain = function() {
    // Load the main module--the command line argument.
    Module._load(process.argv[1], null, true);
    // Handle any nextTicks added in the first tick of the program
    process._tickCallback();
};

/**
 * 初始化node全局依赖
 * @private
 */
Module._initPaths = function() {
    var isWindows = process.platform === 'win32';

    if (isWindows) {
        var homeDir = process.env.USERPROFILE;
    } else {
        var homeDir = process.env.HOME;
    }

    var paths = [path.resolve(process.execPath, '..', '..', 'lib', 'node')];

    if (homeDir) {
        paths.unshift(path.resolve(homeDir, '.node_libraries'));
        paths.unshift(path.resolve(homeDir, '.node_modules'));
    }

    var nodePath = process.env['NODE_PATH'];
    if (nodePath) {
        paths = nodePath.split(path.delimiter).concat(paths);
    }

    modulePaths = paths;

    // clone as a read-only copy, for introspection.
    Module.globalPaths = modulePaths.slice(0);
};

// bootstrap repl
Module.requireRepl = function() {
    return Module._load('repl', '.');
};

Module._initPaths();

// backwards compatibility
Module.Module = Module;

总结

Module模块特点

整理一下Module模块的特点:

  1. 在commonjs规范中每个模块都是一个Module实例。
  2. require方法调用__load方法加载模块文件

    1. _resolveFilename解析文件的绝对路径

      1. _resolveLookupPaths解析文件肯能的绝对路径
      2. _findPath匹配尝试找到文件绝对路径
    2. load解析文件

      1. Module._extensions[extension]不同后缀尝试加载

        1. _compile沙箱编译
  3. require的返回值是module.exports || {};

使用注意点

然后通过源码的阅读,我也注意到了几个之前没有注意到的点:

  1. Module是全局对象,require不是。
  2. require方法接受的路径可以是:

    1. 系统自带模块(fs,path)
    2. 全局安装的模块
    3. 项目安装的模块(node_modules)
    4. 可以通过相对/绝对路径匹配到模块文件
    5. 路径文件可以不写后缀,默认支持(.js, .json, .node)
    6. 路径可以不写index(./components => ./components/index)
  3. 通过沙箱编译方式实现模块化,require和module都是沙箱中注入的对象。
  4. 模块加载之后会被缓存,不会二次编译。
  5. require的返回值是module.eports || {};
  6. exports = module.exports;

最后我对源码进行了中文注释,方法重写排序,需要的github自取。

参考

  1. require()源码解读
  2. NodeJS 模块加载方法 require 源码分析
  3. 通过源码解析 Node.js 中一个文件被 require 后所发生的故事
  4. 【NodeJS】浅析加载模块的机制

Aus0049
2.4k 声望231 粉丝

console.log(([][[]]+[])[+!![]]+([]+{})[!+[]+!![]])