谨以此文献给我的挚友乔治G同学!!!愿他战胜一切恶魔和狗腿
事情的起因是这样的,乔治G在他的电脑上做了一个小测试,但结果和预期的大不相同。
那么我们先来看看这个小测试都写了什么:
一共三个文件,代码总计不超过15行
parent.js
class Parent {}
module.exports = Parent
son.js
//加载时把模块文件名首字母大写了(不正确的)
const Parent = require('./Parent')
class Son extends Parent {}
module.exports = Son
test.js
//加载时把模块名首字母大写(不正确的)
const ParentIncorrect = require('./Parent')
//通过正确的模块文件名加载(正确)
const Parent = require('./parent')
const Son = require('./son')
const ss = new Son()
//测试结果
console.log(ss instanceof Parent) // false
console.log(ss instanceof ParentIncorrect) // true
乔治G同学有以下疑问:
-
son.js
和test.js
里都有错误的文件名(大小写问题)引用,为什么不报错? - 测试结果,为什么
ss instanceof ParentIncorrect === true
?不报错我忍了,为什么还认贼作父,说自己是那个通过不正确名字加载出来的模块的instance?
如果同学你对上述问题已经了然于胸,恭喜你,文能提笔安天,武能上马定乾坤;上炕认识娘们,下炕认识鞋!
但如果你也不是很清楚为什么?那么好了,我有的说,你有的看。
其实断症(装逼范儿的debug)之法和中医看病也有相似指出,望、闻、问、切四招可以按需选取一二来寻求答案。
望
代码不多,看了一会,即便没有我的注释,相信仔细的同学也都发现真正的文件名和代码中引入时有出入的,那么这里肯定是有问题的,问题记住,我们继续
闻
这个就算了,代码我也闻不出个什么鬼来
问
来吧,软件工程里很重要的一环,就是沟通,不见得是和遇到bug的同事,可能是自己,可能是QA,当然也可能是PM或者你的老板。你没问出自己想知道的问题;他没说清楚自己要回答的;都完蛋。。。。
那么我想知道什么呢?下面两件事作为debug的入口比较合理:
- 操作系统
- 运行环境 + 版本
- 你怎么测试的,命令行还是其他什么手段
答曰:macOS; node.js > 8.0
;命令行node test.js
切
激动人心的深刻到来了,我要动手了。(为了完整的描述debug
过程,我会假装这下面的所有事情我事先都是不知道的)
准备电脑,完毕
准备运行环境node.js > 9.3.0
, 完毕
复刻代码,完毕
运行,日了狗,果然没报错,而且运行结果就是乔治G说的那样。
为了证明我没瞎,我又尝试在test.js
里require
了一个压根不存在的文件require('./nidayede')
,运行代码。
还好这次报错了Error: Cannot find module './nidayede'
,所以我没疯。这点真令人高兴。
于是有了第一个问题
为什么狗日的模块名大小写都错了,还能加载?
会不会和操作系统有关系?来我们再找台ubuntu
试试,果然,到了ubuntu
上,大小写问题就是个问题了,Error: Cannot find module './Parent'
。(经朋友提醒,windows
也是默认大小写不敏感的,所以之前举例说windows
会报错,应该也是我自己早前修改过注册表缘故)。
那么macOS
到底在干什么?连个大小写都分不出来么?于是赶紧google
(别问我为什么不baidu)
原来人家牛逼的OS X
默认用了case-insensitive
的文件系统(详细文档)。
but why?这么反人类的设计到底是为了什么?
更多解释,来,走你
所以,这就是你不报错的理由?(对node.js
指责道),但这就是全部真相了。
但事情没完
那认贼作父又是个什么鬼?
依稀有听过node.js
里有什么缓存,是那个东西引起的么?于是抱着试试看的心情,我把const ParentIncorrect = require('./Parent')
和const Parent = require('./parent')
换了下位置,心想,这样最先按照正确的名字加载,会不会就对了呢?
果然,还是不对。靠猜和装逼是不能够真正解决问题的
那比比ParentIncorrect
和Parent
呢?于是我写了console.log(ParentIncorrect === Parent)
,结果为false
。所以他俩还真的不是同一个东西,那么说明问题可能在引入的部分喽?
于是一个装逼看node.js
源码的想法诞生了(其实不看,问题最终也能想明白)。 日了狗,怀着忐忑的心情,终于clone
了一把node.js
源码(花了好久,真tm慢)
来,我们一起进入神秘的node.js
源码世界。既然我们的问题是有关require
的,那就从她开始吧,不过找到require
定义的过程需要点耐心,这里不详述,只说查找的顺序吧
src/node_main.cc => src/node.cc => lib/internal/bootstrap_node.js => lib/module.js
找到咯,就是这个lib/module.js
,进入正题:
Module.prototype.require = function(path) {
assert(path, 'missing path');
assert(typeof path === 'string', 'path must be a string');
return Module._load(path, this, /* isMain */ false);
};
好像没什么卵用,对不对?她就调用了另一个方法_load
,永不放弃,继续
Module._load = function(request, parent, isMain) {
//debug代码,么卵用,跳过
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
}
if (isMain && experimentalModules) {
//...
//...
//这段是给ES module用的,不看了啊
}
//获取模块的完整路径
var filename = Module._resolveFilename(request, parent, isMain);
//缓存在这里啊?好激动有没有?!?终于见到她老人家了
//原来这是这样的,简单的一批,毫无神秘感啊有木有
var cachedModule = Module._cache[filename];
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
//加载native但非内部module的,不看
if (NativeModule.nonInternalExists(filename)) {
debug('load native module %s', request);
return NativeModule.require(filename);
}
//构造全新Module实例了
var module = new Module(filename, parent);
if (isMain) {
process.mainModule = module;
module.id = '.';
}
//先把实例引用加缓存里
Module._cache[filename] = module;
//尝试加载模块了
tryModuleLoad(module, filename);
return module.exports;
};
似乎到这里差不多了,不过我们再深入看看tryModuleLoad
lib/module.js => tryModuleLoad
function tryModuleLoad(module, filename) {
var threw = true;
try {
//加载模块
module.load(filename);
threw = false;
} finally {
//要是加载失败,从缓存里删除
if (threw) {
delete Module._cache[filename];
}
}
}
接下来就是真正的load
了,要不我们先停一停?
好了,分析问题的关键在于不忘初心,虽然到目前为止我们前进的比较顺利,也很爽对不对?。但我们的此行的目的并不是爽,好像是有个什么疑惑哦!于是,我们再次梳理下问题:
-
son.js
里用首字母大写(不正确)的模块名引用了parent.js
-
test.js
里,引用了两次parent.js
,一次用完全一致的模块名;一次用首字母大写的模块名。结果发现son instanceof require('./parent') === false
既然没报错的问题前面已经解决了,那么,现在看起来就是加载模块这个部分可能出问题了,那么问题到底是什么?我们怎么验证呢?
这个时候我看到了这么一句话var cachedModule = Module._cache[filename];
,文件名是作为缓存的key
,来吧,是时候看看Module._cache
里存的模块key
都是什么牛鬼蛇神了。于是怎么能够查看到Module._cache
就是我们的下一个探索目标。那么我们就得顺着刚才发现的,真正的load
继续看下去了。
Module.prototype.load = function(filename) {
debug('load %j for module %j', filename, this.id);
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;
//ES6 module相关,不看
if (ESMLoader) {
...
...
...
}
};
顺着这条路,我们现在应该去找那个Module._extensions['.js']
的实现了
lib/module.js => Module._extensions
Module._extensions['.js'] = function(module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(internalModule.stripBOM(content), filename);
};
至此,我们还没有发现如何从开发者角度访问到_cache
的踪迹,所以继续向下走
Module.prototype._compile = function(content, filename) {
content = internalModule.stripShebang(content);
// create wrapper function
//为了保证每个模块独立的作用域,这个有个wrapper的过程,
//相信了解browserify、webpack工作原理的朋友懂得
var wrapper = Module.wrap(content);
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
...
...
var dirname = path.dirname(filename);
//这个步骤是关键,看到了require,请允许我草率的决定进去看看这个makeRequireFunction
var require = internalModule.makeRequireFunction(this);
var depth = internalModule.requireDepth;
if (depth === 0) stat.cache = new Map();
var result;
if (inspectorWrapper) {
result = inspectorWrapper(compiledWrapper, this.exports, this.exports,
require, this, filename, dirname);
} else {
result = compiledWrapper.call(this.exports, this.exports, require, this,
filename, dirname);
}
if (depth === 0) stat.cache = null;
return result;
};
lib/internal/module.js => makeRequireFunction
function makeRequireFunction(mod) {
const Module = mod.constructor;
function require(path) {
try {
exports.requireDepth += 1;
return mod.require(path);
} finally {
exports.requireDepth -= 1;
}
}
function resolve(request, options) {
return Module._resolveFilename(request, mod, false, options);
}
require.resolve = resolve;
function paths(request) {
return Module._resolveLookupPaths(request, mod, true);
}
resolve.paths = paths;
require.main = process.mainModule;
// Enable support to add extra extension types.
require.extensions = Module._extensions;
//开心,我看到Module._cache被赋值到require上了
//接下来只要知道这个require是不是我们在使用时的那个就好了
require.cache = Module._cache;
return require;
}
我在这里可以明确告诉你,是的,这里的require
,就是我们代码里用到的require
。线索就在上面那步Module.prototype._compile
里,请仔细看var wrapper = Module.wrap(content);
和result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
两行内容
注:其实如果你熟读文档, 上述寻找_cache
访问方法的过程是不必要的,但为了保持叙事完整,我还是装了个逼,请见谅
打完收工,现在我们已经知道如何查看_cache
里的内容了,于是我在test.js
里最后面加了一句console.log(Object.keys(require.cache))
,我们看看打出了什么结果
false
true
[ '/Users/admin/codes/test/index.js',
'/Users/admin/codes/test/Parent.js',
'/Users/admin/codes/test/parent.js',
'/Users/admin/codes/test/son.js' ]
真相已经呼之欲出了,Module._cache
里真的出现了两个[p|P]arent
(macOS
默认不区分大小写,所以她找到的其实是同一个文件;但node.js
当真了,一看文件名不一样,就当成不同模块了),所以最后问题的关键就在于son.js
里到底引用时用了哪个名字(上面我们用了首字母大写的require('./Parent.js')
),这才导致了test.js
认贼作父的梗。
如果我们改改son.js
,把引用换成require('./parEND.js')
,再次执行下test.js
看看结果如何呢?
false
false
[ '/Users/haozuo/codes/test/index.js',
'/Users/haozuo/codes/test/Parent.js',
'/Users/haozuo/codes/test/parent.js',
'/Users/haozuo/codes/test/son.js',
'/Users/haozuo/codes/test/parENT.js' ]
没有认贼作父了对不对?再看Module._cache
里,原来是parENT.js
也被当成一个单独的模块了。
所以,假设你的模块文件名有n
个字符,理论上,在macOS
大小写不敏感的文件系统里,你能让node.js
将其弄出最大2
的n
次方个缓存来
是不是很惨!?还好macOS
还是可以改成大小写敏感的,格盘重装系统;新建分区都行。
问题虽然不难,但探究问题的决心和思路还是重要的。
最后祝愿大家前程似锦!!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。