之前我经常被webpack的配置搞糊涂,chunk、bundle、module的关系傻傻分不清楚,loader和plugin越搞越糊涂,今天要为了吃透webpack的构建原理,决定手撕webpack,做一个简易版的webpack!在开始准备工作之前,首先要了解ast抽象语法树,了解tapable的事件流机制。在编译过程中,webpack将文件转换成ast语法树进行分析修改,并使用tapable提供的hook方法在特定阶段广播事件,这篇文章Step-by-stepguideforwritingacustombabeltransformation推荐阅读可以更好的理解ast。安装webpack和ast相关依赖:npminstallwebpackwebpack-clibabylon@babel/coretapable-D分析模板文件webpack默认打包的bundle.js文件格式非常固定。我们可以尝试在根目录新建一个项目新建一个src文件夹和index.js和sum.js:src-index.js-sum.js-base-a.js//module.exports='a'-b.js//module.exports='b'//sum.jsleta=require("./base/a")letb=require("./base/b")module.exports=function(){returna+b}//index.jsletsum=require("./sum");console.log(sum());同时新建一个webpack.config.js,输入如下配置:const{resolve}=require("path");module.exports={mode:"development",entry:"./src/index.js",output:{filename:"bundle.js",path:resolve(__dirname,"./dist"),}}在控制台输入webpack打包dist文件文件夹,可以看到打包后的文件bundle。js,新建一个html文件,导入bundle.js,可以在控制台看到打印结果ab;我们这一步是分析bundle.js,bundle其实是在webpack打包之前写好的模板文件,包含几个关键信息:__webpack_require__方法,模板文件自带一个require方法,可以看到webpack实现了入口模块CommonJS规范入口文件自身内部,加载入口文件模块,引用路径和文件Content//bundle.js(function(module){...//加载入口模块and返回exportsreturn__webpack_require__(__webpack_require__.s="./src/index.js");})({"./src/index.js":(function(module,exports,__webpack_require__){eval("letsum=__webpack_require__('./src/sum.js\')")}),"./src/sum.js":(function(module,exports){eval("module.exports=function(){returna+b}")})})module其实就是引用路径和文件内容的组合:letmodule={"./index.js":function(module,export,require){},"./sum.js":function(module,export){},}分析到这里,我们知道这个template文件对我们来说很有用,接下来我们会写一个编译器,分析出入口文件和其他文件和内容的关系,然后导入到这个模板文件中,这样我们就可以新建一个模板文件,复制将以上内容保存在准备生成器的副本中。首先,如果我们想要像webpack一样,可以在控制台输入webpack命令来打包。文件,需要使用npmlink添加命令,我们新建一个项目,切换到工作目录控制台,输入npminit-y生成package.json文件,在package.json中添加如下内容:"bin":{"pack":"./bin/www"}切换到控制台,输入npmlink,就可以使用pack命令全局打包文件,然后创建脚本文件执行命令:bin-www//万维网#!/usr/bin/envnodeconst{resolve}=require("path");constconfig=require(resolve("webpack.config.js"));//需要获取当前执行命令脚本下的config配置参数。我们在当前目录下新建一个src文件来存放编译文件和之前保存的模板模板文件:src-Compiler.js-templateCompiler导出为我们的编译器:classCompiler{constructor(config){this.config=config;}run(){}}module.exports=编译器;wwwimport并执行://wwwconstCompiler=require("../src/Compiler");constcompiler=newCompiler(config);compiler.run();Analysisandbuildprocessbuilder编译器已经创建,然后analyzeandbuild流程结束:确定入口(entry)->编译模块(module)->输出资源(bundle)。我们刚才分析模板文件的时候就知道需要确定入口文件,确定各个模块的路径和内容。路径需要将require转换为__webpack_require__,导入地址需要转换为相对路径,最后渲染到模板文件并导出来确定入口。确定入口文件,我们需要知道两个必要的参数:entryName入口名称rootrootpathprocess.cwd()所以我们首先在构造函数中保存:classCompiler{constructor(config){this.config=config;this.entryName="";this.root=process.cwd();}}构建模块接下来当然是构建模块了,首先是找到入口文件,然后递归编译各个模块文件:constructor(config){this.modules={};//保存模块文件}run(){letentryPath=path.join(this.root,this.config.entry);这个.buildModule(entryPath,true);//入口文件}buildModule(modulePath,isEntry){//入口文件的相对路径constrelPath="./"+path.relative(this.root,modulePath);if(isEntry){//如果是入口文件,保存它this.entryName=relPath}//读取文件内容letsource=this.readSource(modulePath)//父文件路径,迭代时传入letdirPath=path.dirname(relPath)//编译文件let{code,dependencies}=this.parser(source,dirPath)//保存编译文件路径和内容this.modules[relPath]=code;//迭代dependencies.forEach((dep)=>{this.buildModule(path.join(this.root,dep))})}parser(source,parentPath){}parserparser负责编译文件。这里主要有两个步骤:1.转换成ast树,解析转换require和path2.存储文件依赖的脚本,返回buildModule继续迭代编译文件,这里用到的是babylon,你转换前可以把内容放到astexplorer中查看分析:parser(source,parentPath){letdependencies=[]//保存这个文件引用的依赖letast=babylon.parse(source);//将babylon转换为asttraverse(ast,{//在astexplorer中分析CallExpression(p)letnode=p.node;if(node.callee.name==="require"){//替换为__webpack_require__node.callee.name="__webpack_require__";//第一个参数是引用路径,转换后保存到dependenciesletliteralVal=node.arguments[0].value;literalVal=literalVal+(path.extname(literalVal)?"":".js");让depPath="./"+path.join(parentPath,literalVal);dependencies.push(depPath);node.arguments[0].value=depPath;}}})让{code}=generator(ast);return{code,dependencies}}readSourcereadSource方法直接读取文件的内容并返回。当然,这里有很大的操作空间。我觉得resolve等配置可以在这里拦截处理。有以下加载程序:readSource(p){returnfs.readFileSync(p,"utf-8");}输出资源通过buildModule方法,我们已经获取了entryName入口文件和modules构建依赖,接下来需要将其转换成输出文件,这时候我们就可以使用之前保存的模板文件了。渲染方法有很多种。我这里用的是ejs:npminstallejsrenametemplatetotemplate.ejs,然后写一个emit方法输出文件:emit(){//读取模板内容lettemplate=fs.readFileSync(path.resolve(__dirname,"./template.ejs"),"utf-8")//导入ejs并渲染模板letejs=require("ejs");让renderTmp=ejs.render(template,{entryName:this.entryName,modules:this.modules});//获取输出配置let{path:outPath,filename}=this.config.output;//使用assets保存输出资源,这里以后可能会有多个输出资源,方便用户处理this.assets[filename]=renderTmp;Object.keys(this.assets).forEach(assetName=>{//获取输出路径并生成文件letbundlePath=path.join(outPath,assetName);fs.writeFileSync(bundlePath,this.assets[assetName])})}//template.ejs//加载入口模块并返回出口return__webpack_require__("<%=entryName%>")...{<%for(letkeyinmodules){%>"<%=key%>":function(module,exports,__webpack_require__){eval(`<%-modules[key]%>`)},<%}%>}最后在run方法中执行emit方法:run(){letentryPath=path.join(this.root,this.config.entry)this.buildModule(entryPath,true);这个。发出();}Loader和Plugin写webpack当然少不了loader和plugin,那么两者有什么区别呢?可以看这两张图:LoaderLoader本质上是一个函数。在该函数中,对接收到的内容进行转换,并返回转换后的结果。因为Webpack只懂JavaScript,Loader就成了翻译器,对其他类型资源的翻译进行预处理。因为webpack不识别除JavaScript以外的其他内容,所以这里写几个loader来转换less,这里写一个loader来转换less的代码,这里先安装less转换less的依赖包,然后新建一个loaders文件夹,里面Create新的style-loader.js和less-loader.js:loaders-less-loader.js-style-loader.js修改webpack.config.js引入新的l??oader:module:{rules:[{test:/\.(less)$/,使用:[path.resolve(__dirname,"./loaders/style-loader.js"),path.resolve(__dirname,"./loaders/less-loader.js"),],},],},less-loaderconstless=require("less");functionloader(source){//转换less代码less.render(source,function(err,result){source=result.css});//返回转换后的结果returnsource;}module.exports=loader;style-loader前面提到webpack只识别js代码,所以最终返回的结果需要是webpack可以识别的js字符串functionloader(source){//把代码转成js字符串,让webpack可以识别letcode=`letstyleEl=document.createElement("style");styleEl.innerHTML=${JSON.stringify(source)};文档.head.appendChild(styleEl);`;//返回转换后的结果如果,换行返回code.replace(/\\n/g,'');}module.exports=loader;compiler.readSource接下来,我们回到编译方法。在readSource阶段之前,我们直接返回了源码,简单的说我们可以用源码做很多事情://oldreadSource(p){returnfs.readFileSync(p,"utf-8");}处这个时候loader就派上用场了,我们可以先将读取的文件内容传给loader处理,然后返回到后续的编译过程:readSource(p){letcontent=fs.readFileSync(p,"utf-8");//获取规则let{rules}=this.config.module;//遍历规则,但是这里webpack是从最后一个开始读取的,这里不做任何处理。rules.forEach(rule=>{let{test,use}=rule;//匹配到对应的文件if(test.test(p)){letlen=use.length-1;//从中取出从右到左for(leti=len;i>=0;i--){content=require(use[i])(content);}}});returncontent}至此,项目中的less代码可以成功识别并编译出PluginPlugin是一个插件,基于事件流框架Tapable,该插件可以扩展Webpack的功能,并在运行中运行Webpack循环中会广播很多事件,Plugin可以监听这些事件,并适时通过Webpack提供的API改变输出结果。什么是表格?我们可以理解为webpack的生命周期管理器。Tabable在webpack生命周期的每个阶段创建相应的发布钩子。用户可以订阅这些钩子函数来改变输出结果。从这张图可以看出webpack的生命周期会触发哪些hooks:在编译器中引入tapable,声明一些常用的hooks:const{SyncHook}=require("tapable");classCompiler{constructor(config){this.hooks={entryOption:newSyncHook(),run:newSyncHook(),emit:newSyncHook(),done:newSyncHook()}run(){this.hooks.run.call()让entryPath=路径。加入(this.root,this.config.entry)this.buildModule(entryPath,true);this.hooks.emit.call()this.emit();this.hooks.done.call()}}}当然webpack里面肯定有更多的hooks,具体需要查看文档。接下来就是执行plugins中的方法,我们可以在执行脚本中触发://wwwconst{resolve}=require("path");constconfig=require(resolve("webpack.config.js"))constCompiler=require("../src/Compiler");constcompiler=newCompiler(config);if(Array.isArray(config.plugins)){config.plugins.forEach(plugin=>{//插件应用该方法传递给编译器plugin.apply(compiler)})}compiler.hooks.entryOption.call()compiler.run();创建一个plugins文件夹并创建一个新的EmitPlugin.js脚本:("emitPlugin");})}}module.exports=EmitPlugin;autodll-webpack-plugin说到插件的最后,我会说说autodll-webpack-plugin。之前遇到过打包好的dll无法插入到html文件中的情况。看到有人在官方issues下提到html-webpack-plugin4.0以后的版本重新安装了beforeHtmlGenerationhook。它被命名为beforeAssetTagGeneration,autodll-webpack-plugin没有更新,仍然使用旧的钩子,现在你必须使用这个插件来确保html-webpack-plugin的版本在4以下。参考链接揭秘工作流程和原理webpackpluginWebpack原理分析webpack,module,chunk和bundle有什么区别?《吐血整理》另外十几个Webpack面试题
