在node项目中,require和module.exports使用非常普遍,js模块化带来的效率大大提升。一直很好奇require背后是怎样运行的,最近仔细看了看这部分的源码,然后参考了其他人的文章,还好node中的Module是JavaScript写的可以看懂。
CommonJs规范
commonJs规范可以说是js模块化中的里程碑,目前npm上面的包基本都支持该规范。在CommonJs中:
- 一个文件就是一个模块,拥有单独的作用域;
- 普通方式定义的变量、函数、对象都属于该模块内;
- 通过require来加载模块;
- 通过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的特点及实现思路:
- require和module是暴露的全局对象。
- require接受一个参数(path),这个参数可以是相对路径,也可以是自带模块或者是package.json中的插件。
- 这个参数如果不是完整的文件名可以实现模糊匹配。(后缀匹配和路径匹配)
- 匹配到文件之后,编译该文件。
- 文件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中主要的属性:(被加载的文件这里简称文件)
- id ‘.’ || 文件的绝对路径
- exports 文件暴露的变量,默认是{};
- parent 文件的调用者的module实例
- filename 文件的绝对路径
- loaded 是否加载完成
- 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方法是加载文件的主要流程的方法:
- resloveFilename方法根据输入的相对路径或者是包名字算出文件的绝对路径(filename)。
- 如果缓存中有直接返回缓存结果;
- 如果是自带模块直接返回结果;
- 否则创建一个module实例并缓存,尝试加载该文件,然后返回文件的暴露结果。
这就是require方法的工作主流程,这里很重要一点:文件被加载过会缓存结果。
该方法中重要的两个步骤:
- Module._resolveFilename() 处理文件路径
- 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处理文件的绝对路径方法,这个方法主要流程:
- 自带模块里面有的话 返回文件名;
- 算出这个文件可能的路径;
- 在可能路径中找出真正的路径并返回;
- 没有的话报错。
为什么要算出可能的路径呢,因为文件来源很多:相对路径下的文件;系统自带模块;还有可能是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;
};
这里面如何算出可能的路径:
- 自带模块可能路径为[]。
- 路径不是相对路径,可能路径是node环境的自带包(全局安装的包 npm i XXX -g)。
- 没有调用者的话,可能是项目node_module中的包。
- 否则根据调用者(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;
};
精确匹配路径过程则相对简单,就是一个个试:
- 文件后缀模糊匹配 .js,.json, .node
- 如果当前路径已在缓存中,就直接返回缓存;
- 依次遍历所有路径
- 文件是否在与路径直接匹配
- 路径加上后缀名是否匹配到
- 是否是package.json中的包
- 是否存在目录名 + index + 后缀名
- 将找到的文件路径存入返回缓存,然后返回
- 否则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模块的特点:
- 在commonjs规范中每个模块都是一个Module实例。
require方法调用__load方法加载模块文件
_resolveFilename解析文件的绝对路径
- _resolveLookupPaths解析文件肯能的绝对路径
- _findPath匹配尝试找到文件绝对路径
load解析文件
Module._extensions[extension]不同后缀尝试加载
- _compile沙箱编译
- require的返回值是module.exports || {};
使用注意点
然后通过源码的阅读,我也注意到了几个之前没有注意到的点:
- Module是全局对象,require不是。
require方法接受的路径可以是:
- 系统自带模块(fs,path)
- 全局安装的模块
- 项目安装的模块(node_modules)
- 可以通过相对/绝对路径匹配到模块文件
- 路径文件可以不写后缀,默认支持(.js, .json, .node)
- 路径可以不写index(./components => ./components/index)
- 通过沙箱编译方式实现模块化,require和module都是沙箱中注入的对象。
- 模块加载之后会被缓存,不会二次编译。
- require的返回值是module.eports || {};
- exports = module.exports;
最后我对源码进行了中文注释,方法重写排序,需要的github自取。