之前经常被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以下的版本