前情回顾
一看就懂之 webpack 基础配置
一看就懂之 webpack 高级配置与优化
一、简介
本文主要讲述的是 webpack 的 工作原理 ,及其 打包流程 ,一步步分析其打包过程,然后模拟实现一个简单的 webpack, 主要是为了更深刻地了解其打包流程 ,为了充分体现其 山寨 的意义,故名称定为web-pack。
二、webpack 的一些特点
- webpack 的配置文件是一个 .js 文件,其采用的是node 语法, 主要是导出一个配置对象 ,并且其采用commonjs2 规范进行导出,即以 module.exports={} 的方式导出配置对象,之所以采用这种方式是为了 方便解析配置文件对象 ,webpack 会找到配置文件然后 以 require 的方式即可读取到配置文件对象。
- webpack 中所有的资源都可以通过 require 的方式引入 ,比如 require 一张 图片 ,require 一个css 文件、一个 scss 文件等。
- webpack 中的 loader 是一个函数,主要为了 实现源码的转换 ,所以 loader 函数会 以源码作为参数 ,比如,将 ES6 转换为 ES5,将 less 转换为 css,然后再将 css 转换为 js,以便能 嵌入到 html 文件中 ,plugin 是一个类,类中有一个apply() 方法,主要用于Plugin 的安装,可以在其中监听一些来自编译器发出的事件,在合适的时机做一些事情。
三、webpack 打包原理解析
webpack 通过 自定义 了一个可以在 node 和浏览器环境都能执行 __webpack_require__函数 来模拟 Node.js 中的 require 语句,将源码中的所有 require 语句替换为__webpack_require__,同时从入口文件开始遍历查找入口文件依赖,并且 将入口文件及其依赖文件的路径和对应源码映射到一个 modules 对象上 ,当__webpack_require__执行的时候, 首先传入的是入口文件的 id,就 会从这个 modules 对象上去取源码 并执行,由于源码中的 require 语句都被替换为了__webpack_require__函数,所以 每当遇到__webpack_require__函数的时候都会从 modules 对象上获取到对应的源码并执行 ,从而实现模块的打包并且 保证源码执行顺序不变。
四、webpack 打包流程分析
webpack 启动文件:
webpack 首先会找到项目中的 webpack.config.js 配置文件,并 以 require(configPath)的方式 ,获取到整个 config 配置对象,接着创建 webpack 的编译器对象,并且 将获取到的 config 对象作为参数传入编译器对象中 ,即在创建 Compiler 对象的时候 将 config 对象作为参数传入 Compiler 类的构造函数中 ,编译器创建完成后调用其 run() 方法执行编译。
编译器构造函数:
编译器构造函数要做的事:创建编译器的时候,会将 config 对象传入编译器的构造函数内,所以 要将 config 对象进行保存 ,然后还需要保存两个特别重要的数据:
一个是 入口文件的 id,即 入口文件相对于根目录的相对路径 ,因为 webpack 打包输出的文件内是一个 匿名自执行函数 ,其 执行 的时候,首先是从入口文件开始的 ,会调用__webpack_require__(entryId) 这个函数,所以 需要告诉 webpack 入口文件的路径 。
另一个是 modules 对象,对象的属性为 入口文件及其所有依赖文件相对于根目录的相对路径 ,因为一个模块被__webpack_require__( 某个模块的相对路径 ) 的时候,webpack 会根据这个相对路径从 modules 对象中获取对应的源码并执行 ,对象的属性值为 一个函数 ,函数内容为 当前模块的 eval(源码
)。
总之,modules 对象保存的就是入口文件及其依赖模块的路径和源码对应关系 ,webpack 打包输出文件bundle.js 执行的时候就会执行匿名自执行函数中的__webpack_require__(entryId),从 modules 对象中找到入口文件对应的源码执行 ,执行入口文件的时候,发现其依赖,又继续执行__webpack_require__(dependId), 再从 modules 对象中获取 dependId 的源码执行,直到全部依赖都执行完成。
编译器构造函数中还有一个非常重要的事情要处理,那就是 安装插件 ,即 遍历配置文件中配置的 plugins 插件数组 ,然后 调用插件对象的 apply()方法 ,apply() 方法 会被传入 compiler 编译器对象 ,可以通过传入的 compiler 编译器对象进行 监听编译器发射出来的事件,插件就可以选择在特定的时机完成一些事情。
编译器 run:
编译器的 run()方法内主要就是: buildModule和 emitFile。而 buildModule 要做的就是 传入入口文件的绝对路径 ,然后根据入口文件路径 获取到入口文件的源码内容 ,然后 对源码进行解析 。
其中获取源码过程分为两步: 首先直接读出文件中的源码内容,然后根据配置的 loader 进行匹配,匹配成功后交给对应的 loader 函数进行处理,loader 处理完成后再返回最终处理过的源码 。
源码的解析,主要是: 将由 loader 处理过的源码内容 转换为 AST 抽象语法树 ,然后 遍历 AST 抽象语法树, 找到源码中的 require 语句 ,并 替换成 webpack 自己的 require 方法 ,即webpack_require,同时 将 require()的路径替换为相对于根目录的相对路径 ,替换完成后重新生成替换后的源码内容,在遍历过程中找到该模块所有依赖, 解析完成后返回替换后的源码和查找到的所以依赖 ,如果存在依赖则遍历依赖, 让其依赖模块也执行一遍 buildModule(),直到入口文件所有依赖都 buildModule 完成。
入口文件及其依赖模块都 build 完成后,就可以 emitFile 了,首先读取输出模板文件,然后传入 entryId 和 modules 对象作为数据进行渲染,主要就是 遍历 modules 对象生成 webpack 匿名自执行函数的参数对象 ,同时 填入 webpack 匿名自执行函数执行后要执行的__webpack_require__(entryId)入口文件 id。
五、实现一个简单的 webpack
① 让 web-pack 命令可执行
为了让 web-pack 命令可执行,我们 需要在其 package.json 中配置 bin,属性名为命令名称 即 web-pack,属性值为 web-pack 启动文件,即 ”./bin/index.js”,这样 web-pack 安装之后或者执行 npm link 命令之后,就会在 /usr/local/bin 目录下生产对应的命令,使得 web-pack 命令可以在全局使用,如:
// package.json
{
"bin": {"web-pack": "./bin/index.js"},
}
② 让 web-pack 启动文件可以在命令行直接执行
虽然 web-pack 命令可以执行了,但是该命令链接的文件是 ”./bin/index.js”,即 输入 web-pack 命令执行的是 ”./bin/index.js” 这个 js 文件 ,而 js 文件是无法直接在终端环境下执行的,所以 需要告诉终端该文件的执行环境为 node,所以需要在 ”./bin/index.js” 文件开头添加上#! /usr/bin/env node,即用 node 环境执行 ”./bin/index.js” 文件中的内容,如:
// ./bin/index.js
#! /usr/bin/env node
③ 获取配置文件,创建编译器并执行
// ./bin/index.js
#! /usr/bin/env node
const path = require("path");
const config = require(path.resolve("webpack.config.js")); // 获取到项目根目录下的 webpack.config.js 的配置文件
const Compiler = require("../lib/Compiler.js");// 引入 Compiler 编译器类
const compiler = new Compiler(config); // 传入 config 配置对象并创建编译器对象
compiler.run(); // 编译器对象调用 run()方法执行
④ 编译器构造函数
之前说过,编译器的构造函数主要就是 保存 config 对象 、 保存入口模块 id、保存所有模块依赖 (路径和源码映射)、 插件安装。
// ../lib/Compiler.js
class Compiler {constructor(config) {
this.config = config; // ① 保存配置文件对象
this.entryId; // ② 保存入口模块 id
this.modules = {} // ③ 保存所有模块依赖(路径和源码映射)
this.entry = config.entry; // 入口路径,即配置文件配置的入口文件的路径
this.root = process.cwd(); // 运行 web-pack 的工作路径,即要打包项目的根目录
// ④遍历配置的插件并安装
const plugins = this.config.plugins; // 获取使用的 plugins
if(Array.isArray(plugins)) {plugins.forEach((plugin) => {plugin.apply(this); // 调用 plugin 的 apply()方法安装插件});
}
}
}
⑤ 编译器 run()方法
编译器 run()方法,主要就是完成 buildModule 和emitFile,buildModule 的时候 需要从入口文件开始 ,即需要 传入文件的绝对路径 ,如果入口文件有依赖,那么buildModule() 会被递归调用 ,即 build 依赖模块,由于 还需要保存入口文件 id,所以需要有一个变量来 告诉传入的模块是否是入口文件。
// add run()方法
class Compiler {run() {this.buildModule(path.resolve(this.root, this.entry), true); // 传入入口文件的绝对路径,并且第二个参数为 ture,即是入口模块
this.emitFile(); // 模块 build 完成后发射文件,即将打包结果写入输出文件中}
}
⑥ 实现 buildModule()方法
buildModule 方法主要就是 获取源码内容 ,并且 对源码内容进行解析 ,解析完成后 拿到解析后的源码 以及 当前模块的依赖 ,将解析后的源码保存到 modules 对象中,并且 遍历依赖,继续 buildModule,如:
// add buildModule()方法
class Compiler {buildModule(modulePath, isEntry) { // 构造模块
const source = this.getSource(modulePath); // 根据模块绝对路径获取到对应的源码内容
const moduleName = "./" + path.relative(this.root, modulePath); // 获取当前 build 模块相对于根目录的相对路径
if (isEntry) { // 如果是入口模块
this.entryId = moduleName; // 保存入口的相对路径作为 entryId
}
const {sourceCode, dependencies} = this.parse(source, path.dirname(moduleName)); // 解析源码获取解析后的源码以及当前模块的依赖数组
this.modules[moduleName] = sourceCode; // 保存解析后的源码内容到 modules 中
dependencies.forEach((dep) => { // 遍历当前模块的依赖,如果有依赖则继续 build 依赖模块
this.buildModule(path.join(this.root, dep), false); // 依赖模块为非入口模块,故传入 false,不需要保存到 entryId 中
});
}
}
⑦ 实现获取源码内容 getSource()方法
获取源码主要做的就是,读取源码内容 , 遍历配置的 rules,再根据 rule 中的 test 正则表达式 与源码的文件格式进行匹配 ,如果匹配成功则交给对应的 loader 进行处理,如果有多个 loader 则 从最后一个 loader 开始递归调用依次执行所有的 loader。
// add getSource()方法
class Compiler {getSource(modulePath) {let content = fs.readFileSync(modulePath, "utf8"); // 读取源码内容
const rules = this.config.module.rules; // 获取到配置文件中配置的 rules
for (let i = 0; i< rules.length; i++) { // 遍历 rules
const rule = rules[i];
const {test, use} = rule;
let len = use.length -1; // 获取处理当前文件的最后一个 loader 的索引号
if (test.test(modulePath)) { // 根据源码文件的路径于 loader 配置进行匹配,交给匹配的 loader 进行处理
function startLoader() { // 开始执行 loader
// 引入 loader,loader 是一个函数,并将源码内容作为参数传递给 loader 函数进行处理
const loader = require(use[len--]);
content = loader(content);
if (len >= 0) { // 如果有多个 loader 则继续执行下一个 loader,startLoader(); // 从最后一个 loader 开始递归调用所有 loader}
}
startLoader(); // 开始执行 loader}
}
}
}
⑧ 解析源码并获取当前源码的依赖
解析源码主要就是 将源码转换为 AST 抽象语法树 ,然后 对 AST 抽象语法树进行遍历 , 找到 require 调用表达式节点,并将其替换为__webpack_require__,然后 找到 require 的参数节点 ,这是一个 字符串常量节点 ,将 require 的参数替换为相对于根目录下的路径, 操作 AST 语法树节点时候不能直接赋值为一个字符串常量 ,应该 用字符串常量生成一个字符串常量节点 进行替换。找到 require 节点的时候同时也就找到了当前模块的依赖,并 将依赖保存起来返回 ,以便 遍历依赖。
// add parse()方法
const babylon = require("babylon"); // 将源码解析为 AST 抽象语法树
const traverse = require("@babel/traverse").default; // 遍历 AST 语法树节点
const types = require("@babel/types"); // 生成一个各种类型的 AST 节点
const generator = require("@babel/generator").default; // 将 AST 语法树重新转换为源码
class Compiler {parse(source, parentPath) {const dependencies = []; // 保存当前模块依赖
const ast = babylon.parse(source); // 将源码解析为 AST 抽象语法树
traverse(ast, {CallExpression(p) { // 找到 require 表达式
const node = p.node; // 对应的节点
if (node.callee.name == "require") { // 把 require 替换成 webpack 自己的 require 方法,即__webpack_require__即
node.callee.name = "__webpack_require__";
let moduleName = node.arguments[0].value; // 获取 require 的模块名称
if (moduleName) {const extname = path.extname(moduleName) ? "":".js";
moduleName = moduleName + extname; // 如果引入的模块没有写后缀名,则给它加上后缀名
moduleName = "./" + path.join(parentPath, moduleName);
dependencies.push(moduleName); // 保存模块依赖
// 将依赖文件的路径替换为相对于入口文件所在目录
node.arguments = [types.stringLiteral(moduleName)];// 生成一个字符串常量节点进行替换,这里的 arguments 参数节点就是 require 的文件路径对应的字符串常量节点
}
}
}
});
const sourceCode = generator(ast).code; // 重新生成源码
return {sourceCode, dependencies};
}
}
⑨ emitFile 发射文件
获取到输出模板内容,这里采用 ejs 模板,然后 传入 entryId(入口文件 Id)和 modules 对象 (路径和源码映射对象), 对模板进行渲染出最终的输出内容,然后写入输出文件中,即 bundle.js 中。
// template.ejs
(function(modules) { // webpackBootstrap
// The module cache
var installedModules = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
if(installedModules[moduleId]) {return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}};
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports;
}
// Load entry module and return exports
return __webpack_require__(__webpack_require__.s = "<%-entryId%>");
})
({<%for(let key in modules) {%>
"<%-key%>":
(function(module, exports, __webpack_require__) {eval(`<%-modules[key]%>`);
}),
<%}%>
});
// add emitFile()方法
const ejs = require("ejs");
class Compiler {emitFile() { // 发射打包后的输出结果文件
// 获取输出文件路径
const outputFile = path.join(this.config.output.path, this.config.output.filename);
// 获取输出文件模板
const templateStr = this.getSource(path.join(__dirname, "template.ejs"));
// 渲染输出文件模板
const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.modules});
this.assets = {};
this.assets[outputFile] = code;
// 将渲染后的代码写入输出文件中
fs.writeFileSync(outputFile, this.assets[outputFile]);
}
}
这里没有对输出文件是否存在进行判断,所以 需要提前创建好一个空的输出文件
⑩ 编写 loader
为了便于测试,这里编写一个简单的 loader 来处理 css 即 style-loader,我们已经知道 loader 其实就是一个函数,其会接收源码进行相应的转换,也就是会将 css 源码传递给 style-loader 进行处理,而css 的执行需要放到 style 标签内,故需要通过 js 创建一个 style 标签,并将 css 源码嵌入到 style 标签内,如:
// style-loader
function loader(source) {
const style = `
let style = document.createElement("style");
style.innerHTML = ${JSON.stringify(source)};
document.head.appendChild(style);
`;
return style;
}
module.exports = loader;
⑪ 编写 Plugin
为了便于测试,这里编写一个 简单的插件结构 , 不处理具体的内容 ,只是让插件可以正常运行,我们已经知道插件是一个类,里面有一个 apply() 方法,webpack 插件主要是 通过 tapable 模块 ,tapable 模块会提供各种各样的钩子, 可以创建各种钩子对象 ,然后 在编译的时候通过调用钩子对象的 call()方法发射事件,然后插件监听到这些事件就可以做一些特定的事情。
// plugin.js
class Plugin {apply(compiler) {compiler.hooks.emit.tap("emit", function() { // 通过编译器对象获取 emit 钩子并监听 emit 事件
console.log("received emit hook.");
});
}
}
module.exports = Plugin;
tapable 原理就是 发布订阅机制 ,调用 tap 的时候就是注册事件, 会将事件函数存入数组中 ,当调用 call() 方法的时候,就会 遍历存入的事件函数依次执行,即事件的发射。
六、完整的编译器源码
const fs = require("fs");
const path = require("path");
// babylon 将源码转换为 AST 语法树
const babylon = require("babylon");
// @babel/traverse 遍历 AST 节点
const traverse = require("@babel/traverse").default;
// @babel/types 生成一个各种类型的 AST 节点
const types = require("@babel/types");
// @babel/generator 将 AST 语法树重新转换为源码
const generator = require("@babel/generator").default;
const ejs = require("ejs");
const {SyncHook} = require("tapable");
class Compiler {constructor(config) {
this.config = config; // 保存配置文件对象
// 保存入口文件的路径
this.entryId; // "./src/index.js"
// 存放所有的模块依赖,包括入口文件和入口文件的依赖,因为所有模块都要执行
this.modules = {}
this.entry = config.entry; // 入口路径,即配置文件配置的入口文件的路径
this.root = process.cwd(); // 运行 wb-pack 的工作路径,即要打包项目的根目录
this.hooks = {entryOption: new SyncHook(),
compile: new SyncHook(),
afterCompile: new SyncHook(),
afterPlugins: new SyncHook(),
run: new SyncHook(),
emit: new SyncHook(),
done: new SyncHook()}
// 遍历配置的插件并安装
const plugins = this.config.plugins; // 获取使用的 plugins
if(Array.isArray(plugins)) {plugins.forEach((plugin) => {plugin.apply(this); // 调用 plugin 的 apply()方法});
}
this.hooks.afterPlugins.call(); // 执行插件安装结束后的钩子}
// 获取源码内容,获取源码的过程中会根据 loader 的配置对匹配的文件交给相应的 loader 处理
getSource(modulePath) {console.log("get source start.");
// 获取源码内容
let content = fs.readFileSync(modulePath, "utf8");
// 遍历 loader
const rules = this.config.module.rules;
for (let i = 0; i< rules.length; i++) {const rule = rules[i];
const {test, use} = rule;
let len = use.length -1;
if (test.test(modulePath)) { // 根据源码文件的路径于 loader 配置进行匹配,交给匹配的 loader 进行处理
function startLoader() {
// 引入 loader,loader 是一个函数,并将源码内容作为参数传递给 loader 函数进行处理
const loader = require(use[len--]);
content = loader(content);
// console.log(content);
if (len >= 0) { // 如果有多个 loader 则继续执行下一个 loader
startLoader();}
}
startLoader();}
}
return content;
}
// 解析源码内容并获取其依赖
parse(source, parentPath) {console.log("parse start.");
console.log(`before parse ${source}`);
// ① 将源码内容解析为 AST 抽象语法树
const ast = babylon.parse(source);
// console.log(ast);
const dependencies = []; // 保存模块依赖
// ② 遍历 AST 抽象语法树
traverse(ast, {CallExpression(p) { // 找到 require 语句
const node = p.node; // 对应的节点
if (node.callee.name == "require") { // 把 require 替换成 webpack 自己的 require 方法,即__webpack_require__即
node.callee.name = "__webpack_require__";
let moduleName = node.arguments[0].value; // 获取 require 的模块名称
if (moduleName) {const extname = path.extname(moduleName) ? "":".js";
moduleName = moduleName + extname; // 如果引入的模块没有写后缀名,则给它加上后缀名
moduleName = "./" + path.join(parentPath, moduleName);
// console.log(moduleName);
dependencies.push(moduleName);
// 将依赖文件的路径替换为相对于入口文件所在目录
console.log(`moduleName is ${moduleName}`);
console.log(`types.stringLiteral(moduleName) is ${JSON.stringify(types.stringLiteral(moduleName))}`);
console.log(node);
console.log(node.arguments);
node.arguments = [types.stringLiteral(moduleName)];
}
}
}
});
// 处理完 AST 后,重新生成源码
const sourceCode = generator(ast).code;
console.log(`after parse ${sourceCode}`);
// 返回处理后的源码,和入口文件依赖
return {sourceCode, dependencies};
}
// 获取源码,交给 loader 处理,解析源码进行一些修改替换,找到模块依赖,遍历依赖继续解析依赖
buildModule(modulePath, isEntry) { // 创建模块的依赖关系
console.log("buildModule start.");
console.log(`modulePath is ${modulePath}`);
// 获取模块内容,即源码
const source = this.getSource(modulePath);
// 获取模块的相对路径
const moduleName = "./" + path.relative(this.root, modulePath); // 通过模块的绝对路径减去项目根目录路径,即可拿到模块相对于根目录的相对路径
if (isEntry) {this.entryId = moduleName; // 保存入口的相对路径作为 entryId}
// 解析源码内容,将源码中的依赖路径进行改造,并返回依赖列表
// console.log(path.dirname(moduleName));// 去除扩展名,返回目录名,即 "./src"
const {sourceCode, dependencies} = this.parse(source, path.dirname(moduleName));
console.log("source code");
console.log(sourceCode);
console.log(dependencies);
this.modules[moduleName] = sourceCode; // 保存源码
// 递归查找依赖关系
dependencies.forEach((dep) => {this.buildModule(path.join(this.root, dep), false);//("./src/a.js", false)("./src/index.less", false)
});
}
emitFile() { // 发射打包后的输出结果文件
console.log("emit file start.");
// 获取输出文件路径
const outputFile = path.join(this.config.output.path, this.config.output.filename);
// 获取输出文件模板
const templateStr = this.getSource(path.join(__dirname, "template.ejs"));
// 渲染输出文件模板
const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.modules});
this.assets = {};
this.assets[outputFile] = code;
// 将渲染后的代码写入输出文件中
fs.writeFileSync(outputFile, this.assets[outputFile]);
}
run() {this.hooks.compile.call(); // 执行编译前的钩子
// 传入入口文件的绝对路径
this.buildModule(path.resolve(this.root, this.entry), true);
this.hooks.afterCompile.call(); // 执行编译结束后的钩子
// console.log(this.modules, this.entryId);
this.emitFile();
this.hooks.emit.call(); // 执行文件发射完成后的钩子
this.hooks.done.call(); // 执行打包完成后的钩子}
}
module.exports = Compiler;