之前常常被 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.js
let a = require("./base/a")
let b = require("./base/b")
module.exports = function () {return a + b}
// index.js
let 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 node
const {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
导入并执行:
// www
const 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
阶段,咱们间接返回了源码,几乎暴殄天物,咱们明明能够对源码做很多事件:
// old
readSource(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
外面的办法了,咱们能够在执行脚本外面触发它:
//www
const {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.js
class 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 面试题