68

前情回顾

一看就懂之webpack基础配置
一看就懂之webpack高级配置与优化

一、简介

本文主要讲述的是webpack的工作原理,及其打包流程,一步步分析其打包过程,然后模拟实现一个简单的webpack,主要是为了更深刻地了解其打包流程,为了充分体现其山寨的意义,故名称定为web-pack

二、webpack的一些特点

  1. webpack的配置文件是一个.js文件,其采用的是node语法主要是导出一个配置对象,并且其采用commonjs2规范进行导出,即以module.exports={}的方式导出配置对象,之所以采用这种方式是为了方便解析配置文件对象,webpack会找到配置文件然后以require的方式即可读取到配置文件对象
  2. webpack中所有的资源都可以通过require的方式引入,比如require一张图片,require一个css文件、一个scss文件等。
  3. 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()方法内主要就是: buildModuleemitFile。而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()方法,主要就是完成buildModuleemitFile,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;

JS_Even_JS
2.6k 声望3.7k 粉丝

前端工程师