Node.js是一个基于ChromeV8引擎的JavaScript运行环境。早期的Node.js采用CommonJS模块规范,从Nodev13.2.0开始正式支持ESModules特性。直到v15.3.0版本,ESModules特性才稳定下来并兼容NPM生态。(图片来源:https://nodejs.org/api/esm.html)本文将介绍Node.js中require函数的工作流程,如何让Node.js直接执行ts文件以及如何劫持Node.jsrequire正确的函数,从而实现钩子的功能。下面先介绍一下require函数。requirefunctionNode.js应用程序由模块组成,每个文件都是一个模块。对于CommonJS模块规范,我们使用require函数来导入模块。那么当我们使用require函数导入一个模块时,函数内部会发生什么?这里我们通过调用栈来理解require的过程:从上图可以看出,当使用require导入一个模块时,会调用Module对象的load方法来加载模块,这个方法的实现如下所示://lib/internal/modules/cjs/loader.jsModule.prototype.load=function(filename){this.filename=filename;this.paths=Module._nodeModulePaths(path.dirname(filename));constextension=findLongestRegisteredExtension(文件名);Module._extensions[extension](this,filename);this.loaded=true;//省略了一些代码};注:本文引用的Node.js源码对应版本为v16.13.1上面代码中,重要的两步是:第一步:根据文件名找到扩展名;第二步:通过解析出的扩展设备在Module._extensions对象中找到匹配的负载。Node.js内置了3种不同的加载器,用于加载node、json和js文件。节点文件加载器//lib/internal/modules/cjs/loader.jsModule._extensions['.node']=function(module,filename){returnprocess.dlopen(module,path.toNamespacedPath(filename));};json文件加载器//lib/internal/modules/cjs/loader.jsModule._extensions['.json']=function(module,filename){constcontent=fs.readFileSync(filename,'utf8');尝试{模块.exports=JSONParse(stripBOM(content));}catch(err){err.message=文件名+':'+err.message;抛出错误;}};js文件加载器//lib/internal/modules/cjs/loader.jsModule._extensions['.js']=function(module,filename){//如果已经分析了源,那么它将被缓存。constcached=cjsParseCache.get(module);让内容;如果(缓存?.source){content=cached.source;cached.source=undefined;}else{content=fs.readFileSync(filename,'utf8');}//省略部分代码module._compile(content,filename);};下面来分析比较重要的js文件加载器。通过观察上面的代码,我们知道js加载器的核心处理流程也可以分为两步:第一步:使用fs.readFileSync方法加载js文件的内容;第二步:使用module._compile方法编译加载的js代码。那么了解了以上知识之后,对我们有什么用呢?其实理解了require函数的工作流程后,我们就可以扩展Node.js的loader。比如让Node.js可以运行ts文件。//register.jsconstfs=require("fs");constModule=require("module");const{transformSync}=require("esbuild");Module._extensions[".ts"]=function(module,文件名){constcontent=fs.readFileSync(文件名,"utf8");const{code}=transformSync(content,{sourcefile:文件名,sourcemap:"both",loader:"ts",格式:"cjs",});模块._compile(代码,文件名);};在上面的代码中,我们引入了内置的module模块,然后使用模块的_extensions对象来注册我们自定义的tsloader。其实loader的本质就是一个函数。在这个函数内部,我们使用esbuild模块提供的transformSyncAPI来实现ts->js代码的转换。代码转换完成后,会调用module._compile方法编译代码。看到这里,相信有小伙伴也想到了Webpack中对应的loader。如果想了解更多,可以看多图详解,一下子看懂WebpackLoader。限于篇幅,具体的编译过程我们就不介绍了。下面来看看如何让自定义的tsloader生效。为了让Node.js能够执行ts代码,我们需要在执行ts代码之前完成自定义tsloader的注册。幸运的是,Node.js为我们提供了模块预加载机制:$node--help|greppreload-r,--require=...要预加载的模块(选项可以重复)使用-r,--require命令行配置项,我们可以预加载指定的模块。了解了相关知识后,我们来测试一下自定义的tsloader。首先创建一个index.ts文件并输入以下内容://index.tsconstadd=(a:number,b:number)=>a+b;console.log("add(a,b)=",add(3,5));然后在命令行输入如下命令:$node-r./register.jsindex.ts上面的命令运行成功后,控制台会输出如下:add(a,b)=8显然我们自定义的ts文件加载器已经生效,这个扩展机制值得学习。另外需要注意的是,在load方法中,findLongestRegisteredExtension函数会判断文件的扩展名是否已经注册到Module._extensions对象中。如果没有注册,默认返回.js字符串。//lib/internal/modules/cjs/loader.jsModule.prototype.load=function(filename){this.filename=filename;this.paths=Module._nodeModulePaths(path.dirname(filename));constextension=findLongestRegisteredExtension(文件名);Module._extensions[extension](this,filename);this.loaded=true;//省略一些代码};这意味着只要文件中包含有效的js代码,require函数就可以正常加载。例如下面的一个.txt文件:module.exports="helloworld";我相信你已经了解require函数如何加载模块以及如何自定义Node.js文件加载器。那么Node.js有没有更优雅、更简单的方案来支持加载ts、png、css等其他类型的文件呢?答案是肯定的,我们可以使用第三方库pirates。module.exports="你好世界";pirates是什么pirates这个库允许我们正确地劫持Node.js的require函数。使用这个库,我们可以轻松扩展Node.js加载器的功能。pirates的使用可以使用npm来安装pirates:npminstall--savepiratespirates库安装成功后,可以使用模块export提供的addHook函数添加hooks://register.jsconstaddHook=require("pirates").addHook;constrevert=addHook((code,filename)=>code.replace("@@foo","console.log('foo');"),{exts:[".js"]});需要注意的是,调用addHook后,会返回一个revert函数,取消对require函数的劫持操作。接下来我们来验证一下盗版库是否正常运行。首先新建一个index.js文件,输入以下内容://index.jsconsole.log("@@foo")然后在命令行输入以下命令:$node-r./register.jsindex.js当上述命令运行成功后,控制台会输出如下内容:console.log('foo');观察上面的结果,我们可以看到我们通过addHook函数添加的钩子已经生效了。是不是觉得很神奇,接下来我们就来分析一下海盗的工作原理。盗版是如何工作的盗版底层是利用Node.js内置的module模块提供的扩展机制来实现Hook功能。我们已经介绍过,当使用require函数加载模块时,Node.js会根据文件扩展名匹配相应的加载器。其实盗版的源代码并不复杂。下面重点分析addHook函数的核心处理逻辑://src/index.jsexportfunctionaddHook(hook,opts={}){letreversed=false;常量装载机=[];//存储新的loaderconstoldLoaders=[];//存储旧的加载器letexts;constoriginalJSLoader=Module._extensions['.js'];//原始JSLoaderconstmatcher=opts.matcher||无效的;constignoreNodeModules=opts.ignoreNodeModules!==false;exts=opts.extensions||选择.exts||opts.扩展||选择.ext||['.js'];如果(!Array.isArray(exts)){exts=[exts];}exts.forEach((ext){//...}}为了提高执行效率,addHook函数提供了matcher和ignoreNodeModules配置项来实现文件过滤操作,获取exts扩展名列表后,新的loader会被用于替换现有的装载机。exts.forEach((ext)=>{if(typeofext!=='string'){thrownewTypeError(`InvalidExtension:${ext}`);}//获取注册的加载器,如果没有找到,通过默认,JSLoaderconstoldLoader=Module._extensions[ext]||originalJSLoader;oldLoaders[ext]=Module._extensions[ext];loaders[ext]=Module._extensions[ext]=functionnewLoader(mod,filename){letcompile;if(!reverted){if(shouldCompile(filename,exts,matcher,ignoreNodeModules)){compile=mod._compile;mod._compile=function_compile(code){//这里需要恢复到原来的_compile函数,否则会死循环mod._compile=compile;//在编译前执行自定义的钩子函数constnewCode=hook(code,filename);if(typeofnewCode!=='string'){thrownewError(HOOK_RETURNED_NOTHING_ERROR_MESSAGE);}returnmod._compile(newCode,filename);};}}oldLoader(mod,filename);};});观察以上从代码中可以看出hook函数是通过替换addHook函数内部的mod._compile方法实现的,即在调用原来的mod._compile方法编译之前,会调用hook(code,filename)函数首先执行用户自定义的钩子函数,对代码进行处理。好了,至此本文的主要内容介绍完毕。实际工作中,如果想让Node.js直接执行ts文件,可以使用ts-node或者esbuild-register这两个库。esbuild-register库内部使用了盗版者提供的Hook机制来实现相应的功能。
