前情回顾
一看就懂之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、保存所有模块依赖(路径和源码映射)、插件安装。
插件的安装主要就是监听webpack编译器compiler发出的一些事件,等收到相应的事件后,插件就可以进行一些处理了。
// ../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;
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。