之前常常被webpack的配置搞得头大,chunk
、bundle
和module
的关系傻傻分不清,loader
和plugin
越整越糊涂,优化配置一大堆,项目经理前面催,优化过后慢如龟!明天为了彻底搞明确webpack的构建原理,决定手撕webpack
,干一个简易版的webpack
进去!
筹备工作
在开始之前,还要先理解 ast
形象语法树和了解事件流机制 tapable
,webpack
在编译过程中将文件转化成ast
语法树进行剖析和批改,并在特定阶段利用tapable
提供的钩子办法播送事件,这篇文章 Step-by-step guide for writing a custom babel transformation 举荐浏览能够更好的了解ast。
装置 webpack
和 ast
相干依赖包:
npm install webpack webpack-cli babylon @babel/core tapable -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.jslet a = require("./base/a")let b = require("./base/b")module.exports = function () { return a + b}
// index.jslet sum = 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标准- 入口文件
entry module
,加载入口文件 module
,援用门路和文件内容
// bundle.js( function(module){ ... // Load entry module and return exports return __webpack_require__(__webpack_require__.s = "./src/index.js"); })({ "./src/index.js":(function(module, exports, __webpack_require__){eval("let sum = __webpack_require__('./src/sum.js\')")}), "./src/sum.js":(function(module, exports){eval("module.exports=function(){return a+b}")})})
module
其实就是援用门路和文件内容的关系组合:
let module = { "./index.js":function(module,export,require){}, "./sum.js":function(module,export){},}
剖析到这一步,咱们晓得这个模板文件对咱们来说很有用了,接下来咱们会写个编译器,剖析出入口文件和其它文件与内容的关联关系,再导入到这个模板文件中就好了,所以我么能够新建template
文件,将下面的内容复制进去先保留一份
筹备构建器
首先如果咱们要像webpack
一样,在控制台输出webpack
命令就能打包文件,就须要利用npm link
增加命令,咱们新建一个工程,切换到工作目录管制台下,输出npm init -y
生成package.json
文件,并在package.json
中增加上面内容:
"bin":{ "pack":"./bin/www"}
切换到控制台输出npm link
,就能够在全局应用pack
命令打包文件了,接下来还要创立执行命令的脚本文件:
bin - www
//www#! /usr/bin/env nodeconst {resolve} = require("path");const config = require(resolve("webpack.config.js")); // 须要拿到以后执行命令脚本下的config配置参数
咱们在当前目录下新建src文件,寄存编译器文件和之前保留的template
模板文件:
src - Compiler.js - template
Compiler
作为咱们的编译器导出:
class Compiler { constructor(config){ this.config = config; } run(){ }}module.exports = Compiler;
www
导入并执行:
// wwwconst Compiler = require("../src/Compiler");const compiler = new Compiler(config);compiler.run();
剖析构建流程
构建器 Compiler
曾经创立好了,接下来剖析构建流程了:
确定入口(entry) -> 编译模块(module) -> 输入资源(bundle)
方才剖析模板文件的时候晓得,咱们须要确定入口文件,并确定每个模块的门路和内容,门路须要将require
转换成__webpack_require__
,引入地址须要转换成相对路径,最终渲染到模板文件并导出
确定入口
确定入口文件,咱们须要晓得两个必要参数:
- entryName 入口名称
- root 根门路 process.cwd()
所以咱们先在constructor
保留:
class Compiler { constructor(config) { this.config = config; this.entryName = ""; this.root = process.cwd(); }}
构建module
接下来当然是构建module了,首先是找到入口文件,而后递归编译每一个模块文件:
constructor(config) { this.modules = {};// 保留模块文件 }run(){ let entryPath = path.join(this.root, this.config.entry); this.buildModule(entryPath, true); // 入口文件}buildModule(modulePath, isEntry) { // 入口文件相对路径 const relPath = "./"+path.relative(this.root,modulePath); if (isEntry) { // 如果是入口文件,保存起来 this.entryName = relPath } // 读取文件内容 let source = this.readSource(modulePath) // 父文件门路,迭代的时候传递 let dirPath = 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) {}
parser
parser
负责编译文件,这里次要有两步:
1、转换成ast树,剖析并转换require
和门路
2、存储该文件依赖的脚本,返回给buildModule
持续迭代
编译文件,这里用到的是babylon
,转换前能够先将内容放到astexplorer查看剖析:
parser(source, parentPath) { let dependencies = [] // 保留该文件援用的依赖 let ast = babylon.parse(source); // babylon转换成ast traverse(ast, { // 在 astexplorer 剖析 CallExpression(p){ let node = p.node; if (node.callee.name === "require") { // 替换成__webpack_require__ node.callee.name = "__webpack_require__"; // 第一个参数是援用门路,转换并保留到dependencies let literalVal = node.arguments[0].value; literalVal = literalVal + (path.extname(literalVal) ? "" :".js"); let depPath = "./"+path.join(parentPath,literalVal); dependencies.push(depPath); node.arguments[0].value = depPath; } } }) let {code}=generator(ast); return { code, dependencies } }
readSource
readSource
办法这里间接读取文件内容并返回,当然这里可操作的空间很大,像resolve
这些配置,我感觉都能够在这里截取并解决,还有前面的loader
:
readSource(p) { return fs.readFileSync(p, "utf-8"); }
输入资源
通过buildModule
办法,咱们曾经获取到了entryName
入口文件和modules
构建依赖,接下来须要转换成输入文件了,这时候咱们能够用到之前保留的template
文件,渲染的形式有很多,我这里应用的是ejs
:
npm install ejs
重命名template
为template.ejs
,接下来写个emit
办法输入文件:
emit() { // 读取模板内容 let template = fs.readFileSync( path.resolve(__dirname, "./template.ejs"), "utf-8" ) // 引入ejs并渲染模板 let ejs = require("ejs"); let 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=>{ // 获取输入门路并生成文件 let bundlePath = path.join(outPath, assetName); fs.writeFileSync(bundlePath, this.assets[assetName]) })}
// template.ejs // Load entry module and return exports return __webpack_require__("<%=entryName %>") ... { <% for(let key in modules){ %> "<%=key %>":function (module, exports, __webpack_require__) { eval(`<%- modules[key]%>`) }, <% } %> }
最初在 run
办法中执行 emit
办法:
run() { let entryPath = path.join(this.root, this.config.entry) this.buildModule(entryPath, true); this.emit(); }
Loader 和 Plugin
写webpack
当然少不了loader
和plugin
了,那么这两者到底有什么区别呢?能够看下这两张图:
Loader
Loader
实质就是一个函数,在该函数中对接管到的内容进行转换,返回转换后的后果。因为 Webpack 只意识 JavaScript,所以Loader
就成了翻译官,对其余类型的资源进行转译的预处理工作。
因为webpack
不意识除了JavaScript以外的其它内容,这里咱们写几个loader来对less
进行转换,这里写个loader
来转换less
代码,这里先装置 less
依赖包用来转换 less
,而后新建loaders
文件夹,外面新建style-loader.js
和less-loader.js
:
loaders - less-loader.js - style-loader.js
批改 webpack.config.js
,引入新建的loader
:
module: { rules: [ { test: /\.(less)$/, use: [ path.resolve(__dirname, "./loaders/style-loader.js"), path.resolve(__dirname, "./loaders/less-loader.js"), ], }, ],},
less-loader
const less = require("less");function loader(source) { // 转换 less 代码 less.render(source, function (err, result) { source = result.css }); // 返回转换后的后果 return source;}module.exports = loader;
style-loader
后面说了,webpack 只辨认 js 代码,所以最终返回的后果,须要是webpack能辨认的 js 字符串
function loader(source) { // 将代码转成js字符串,webpack能力辨认 let code = ` let styleEl = document.createElement("style"); styleEl.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(styleEl); `; // 返回转换后的后果,替换换行符 return code.replace(/\\n/g, '');}module.exports = loader;
compiler.readSource
接下来回到编译办法,之前在 readSource
阶段,咱们间接返回了源码,几乎暴殄天物,咱们明明能够对源码做很多事件:
// oldreadSource(p) { return fs.readFileSync(p, "utf-8");}
这时候 loader
派上用场了,咱们能够把读取的文件内容交给 loader
先解决一遍,再返回给前面的编译流程:
readSource(p) { let content = fs.readFileSync(p, "utf-8"); // 获取 rules let {rules} = this.config.module; // 对 rules 进行遍历,不过 webpack 外面这里是从最初一个开始读取的,这里没做解决了 rules.forEach(rule => { let { test, use } = rule; // 匹配到对应文件 if(test.test(p)){ let len = use.length-1; // 从右到左顺次取出 for(let i=len;i>=0;i--){ content = require(use[i])(content); } } }); return content }
到了这里,工程外面的less
代码就能够胜利辨认和编译了。
Plugin
Plugin 就是插件,基于事件流框架 Tapable,插件能够扩大 Webpack 的性能,在 Webpack 运行的生命周期中会播送出许多事件,Plugin 能够监听这些事件,在适合的机会通过 Webpack 提供的 API 扭转输入后果。
tabable
到底干嘛用的呢? 咱们能够把它了解成webpack
的生命周期管理器,tabable
在webpack
生命周期的每个阶段创立对应的公布钩子,用户能够通过订阅这些钩子函数,扭转输入的后果。
通过这张图能够看到 webpack
的生命周期会触发哪些钩子:
在编译器引入 tapable
,申明一些罕用的钩子吧:
const {SyncHook} = require("tapable");class Compiler { constructor(config) { this.hooks = { entryOption: new SyncHook(), run: new SyncHook(), emit: new SyncHook(), done: new SyncHook() } run(){ this.hooks.run.call() let entryPath = path.join(this.root, this.config.entry) this.buildModule(entryPath, true); this.hooks.emit.call() this.emit(); this.hooks.done.call() } }}
当然 webpack
外面的钩子必定不止这些,具体还须要查文档了。
接下来就是执行 plugins
外面的办法了,咱们能够在执行脚本外面触发它:
//wwwconst {resolve} = require("path");const config = require(resolve("webpack.config.js"))const Compiler = require("../src/Compiler");const compiler = new Compiler(config);if(Array.isArray(config.plugins)){ config.plugins.forEach(plugin=>{ // 插件的 apply 办法传入 compiler plugin.apply(compiler) })}compiler.hooks.entryOption.call()compiler.run();
创立 plugins 文件夹,新建一个 EmitPlugin.js
脚本:
plugins -EmitPlugin.js
// EmitPlugin.jsclass EmitPlugin { apply(compiler){ compiler.hooks.emit.tap("EmitPlugin",()=>{ console.log("emitPlugin"); }) }}module.exports = EmitPlugin;
autodll-webpack-plugin
说到插件最初还要讲讲 autodll-webpack-plugin
,之前遇到无奈将打包后的dll
插入到html文件的状况,到官网 issues 下看到有人提到 html-webpack-plugin
4.0 当前的版本把 beforeHtmlGeneration
钩子重命名成了 beforeAssetTagGeneration
,autodll-webpack-plugin
没有更新还是用的旧钩子,当初用这个插件还得保障html-webpack-plugin
在4以下的版本
参考链接
- 揭秘webpack插件工作流程和原理
- Webpack原理浅析
- webpack 中,module,chunk 和 bundle 的区别是什么?
- 「吐血整顿」再来一打Webpack面试题