Webpack5.0学习总结-进阶篇

·  阅读 3125
Webpack5.0学习总结-进阶篇

前言

Vue项目开发一直使用的脚手架,对Webpack这个黑匣子知之甚少,碰到问题总是一头雾水,所以趁着Webpack5.0发布不久,较完整地学习了一遍。本篇文章总结一下学习成果。整体大纲如下图,本文为进阶篇,基础篇请按传送门Webpack5.0学习总结-基础篇Webpack.png

窥探 webpack 原理

如何开发一个 loader

loader 本质上是一个函数,它的作用就是将匹配到的源文件内容做一些处理然后输出。当某个规则使用了多个loader处理时,就会按照从下往上的顺序依次执行,后一步拿到的都是前一步处理完成的内容。可以理解为链式调用。所以开发loader时,最要关心的就是它的输入与输出。
下面就用实例分步介绍开发一个loader的过程

  1. 在webpack配置文件中引入自己编写的loader,并在某个规则中使用。
  2. 编写自定义loader。
  3. 对比loader使用前后,bundle文件(main.js)的差异,验证loader效果。

首先明确下编写的这个loader想要实现什么功能。本示例中,简单实现删除js注释的功能,以此来介绍loader编写流程。

一、 配置文件中引入loader

在webpack.config.js中引入loader,这里说明一下resolveLoader,它的作用是配置loader的查找路径,若未配置resolveLoader,rules中的loader参数,需要填写完整的loader文件路径。

// webpack.config.js

const path = require("path");
module.exports = {
    mode: "none", //mode设置为none,不启用任何默认配置,防止Webpack自动处理干扰loader效果。
    /* 解析loader的规则 */
    resolveLoader: {
        // loader查找路径,默认是node_modules,所以我们平常写loader(如babel-loader)时实际都会去node_modules里找
        modules: ["node_modules", path.resolve(__dirname, "loaders")], // 增加查找路径。顺序是从前往后
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                // 因为配置了resolveLoader,在loaders文件夹下找到了myLoader
                loader: "myLoader",
                options:{
                    oneLine: true, // 是否删除单行注释
                    multiline: true, // 是否删除多行注释
                }
            }
        ]
    },
}
复制代码

二、 编写自定义loader

// myLoader.js

module.exports = function (source) {
    // Webpack5.0开始,不在需要使用工具获取option了
    // 获取到webpack.config.js中配置的options
    let options = this.getOptions();
    let result = source;
    // 默认单行和多行注释都删除
    const defaultOption = {
        oneLine: true,
        multiline: true,
    }
    options = Object.assign({}, defaultOption, options);
    if (options.oneLine) {
        // 去除单行注释
        result = result.replace(/\/\/.*/g, "")
    }
    if (options.multiline) {
        // 去除多行注释
        result = result.replace(/\/\*.*?\*\//g, "")
    }
    // loader必须要有输出,否则Webpack构建报错
    return result
}
复制代码

三、 对比打包输出的bundle,验证loader效果。

为了让对比更清晰简洁,源代码index.js中的内容非常简单。

  • 源代码
// index.js

/* 增加多行注释,用于测试 */
const x = 100;
let y = x; // 行内单行测试
// 单行注释测试
console.log(y);

复制代码
  • 未使用loader时的输出文件,可以看到源代码中的注释都保留着。
// main.js

/******/ (function() { // webpackBootstrap
var __webpack_exports__ = {};
/* 增加多行注释,用于测试 */
const x = 100;
let y = x; // 行内单行测试
// 单行注释测试
console.log(y);

/******/ })()
;
复制代码
  • 使用loader时的输出文件,很明显源代码中的注释都被删除了,loader生效。
// main.js

/******/ (function() { // webpackBootstrap
var __webpack_exports__ = {};

const x = 100;
let y = x; 

console.log(y);

/******/ })()
;
复制代码

以上就是编写一个loader的基本过程,还有几点补充说明下:

  • options参数校验:可以使用三方库schema-utils对options设置的参数进行校验。
  • 同步和异步:loader分为同步loader和异步loader,上文写的是同步loader。而有些场景下可能需要使用异步loader。如下所示:
module.exports = function (source) {
    // 生成一个异步回调函数。
    const callback = this.async();
    setTimeout(() => {
        // 回调函数的第一个参数是错误信息,第二个参数为输出结果,第三个参数是source-map
        callback(null, source);
    }, 1000);
};
复制代码
  • 在开发一个loader时,要尽量使它的职责单一。即一个loader只做一个任务。这样可以使loader更容易维护并且可以在更多的场景下复用。

如何开发一个插件

Webpack的打包过程就像一个产品的流水线,按部就班地执行一个又一个环节。而插件就是在这条流水线各个阶段插入的额外功能,Webpack以此来扩展自身的功能。
在实例介绍之前,需要先简单了解下插件是如何在Webpack打包的不同阶段准确插入其中的。它使用的是 Tapable 工具类,compiler和compilation类都扩展自Tapable类。

Tapable简介

Tapable 用法个人理解类似发布订阅模式,不同插件可以订阅同一个事件,当Webpack执行到该事件时,分发给各个注册的插件。Tapable提供的钩子类型很多,总体可以分为同步和异步,它们的注册方式不同。同步钩子通过tap注册,异步钩子通过tapAsync或tapPromise,两者的区别在于前者使用回调函数,后者使用Promise。
Tapable本身还细分很多类型,比如Bail类型的钩子,可以终止此类注册事件的调用(某个Bail钩子注册的事件中有return,就不再执行其他注册事件),具体的这里不再展开。下面通过读取文件的例子具体看一下Tapable钩子的用法

const { SyncHook, AsyncSeriesHook } = require("tapable");
const fs = require("fs");

// 钩子存放容器
const hooks = {
    beforeRead: new SyncHook(["param"]), // 同步钩子,数组代表注册时,回调函数的参数。
    afterRead: new AsyncSeriesHook(["param"]) // 异步顺序执行钩子
}
// 订阅beforeRead
hooks.beforeRead.tap("name", (param) => {
    console.log(param, "beforeRead执行触发回调");
})
// 订阅afterRead
hooks.afterRead.tapAsync("name", (param, callback) => {
    console.log(param, "afterRead执行触发回调");
    setTimeout(() => {
        // 回调执行完毕
        callback()
    }, 1000);
})

// 读取文件前调用beforeRead,注册事件按照注册顺序同步执行
hooks.beforeRead.call("开始读取")
fs.readFile("package.json", ((err, data) => {
    if (err) {
        throw new Error(err)
    }
    // 读取文件后执行afterRead钩子
    hooks.afterRead.callAsync(data, () => {
        // 所有注册事件执行完后调用,类似Promise.all
        console.log("afterRead end~");
    })
}))
复制代码

在读取文件的两个阶段,执行相应钩子,执行时广播通知到所有注册事件。执行完后再继续下面的步骤。

自定义插件编写

插件本质上是一个构造函数,它的原型上必须有一个apply方法。在Webpack初始化compiler对象之后会调用插件实例的apply方法,传入compiler对象。然后插件就可以在compiler上注册想要注册的钩子,Webpack会在执行到对应阶段时触发注册事件。下面用两个简单的插件实例演示这个过程。

插件一:删除输出文件夹内的文件

模仿CleanWebpackPlugin插件,但是不删除文件夹,因为Node只能删除空文件夹,需要使用递归才能完整实现CleanWebpackPlugin的功能,这里重点演示插件编写流程,所以就简化为只删除文件。

// RmFilePlugin.js

const path = require("path");
const fs = require("fs");

class RmFilePlugin {
    constructor(options = {}) {
        // 插件的options
        this.options = options;
    }
    // Webpack会自动调用插件的apply方法,并给这个方法传入compiler参数
    apply(compiler) {
        // 拿到webpack的所有配置
        const webpackOptions = compiler.options;
        // context为Webpack的执行环境(执行文件夹路径)
        const { context } = webpackOptions
        // 在compiler对象的beforeRun钩子上注册事件
        compiler.hooks.beforeRun.tap("RmFilePlugin", (compiler) => {
            // 获取打包输出路径
            const outputPath = webpackOptions.output.path || path.resolve(context, "dist");
            const fileList = fs.readdirSync(outputPath, { withFileTypes: true });
            fileList.forEach(item => {
                // 只删除文件,不对文件夹做递归删除,简化逻辑
                if (item.isFile()) {
                    const delPath = path.resolve(outputPath, item.name)
                    fs.unlinkSync(delPath);
                }
            })
        });
    }
};

// 导出 Plugin
module.exports = RmFilePlugin;
复制代码

这个例子很简单,只用到了compiler对象,在实际开发插件的过程中,大多数情况下还需要使用compilation对象,那么它和compiler有什么不同?

  • 个人理解,compiler 代表了Webpack从启动到关闭的整个完整生命周期,它上面的钩子是基于 Webpack 运行自身的,比如打包环境是否准备好,是否开始编译了等。而compilation专注于编译阶段,它的钩子存在于编译的各个细节中,如模块被加载(load)、优化(optimize)、 分块(chunk)等。

下面这个例子就用到了compilation对象

插件二:删除js注释

这个插件的功能在上文loader中实现过,在plugin里又实现一遍,是想说明loader能做到的事plugin都能做到,并且plugin可以做的更彻底。

// DelCommentPlugin.js

const { sources } = require('webpack');

class DelCommentPlugin {
    constructor(options) {
        this.options = options
    }

    apply(compiler) {
        // compilation 创建之后执行注册事件
        compiler.hooks.compilation.tap("DelCommentPlugin", (compilation) => {
            // 处理asset
            compilation.hooks.processAssets.tap(
                {
                    name: 'DelCommentPlugin', //插件名称
                    //要对asset做哪种类型的处理,这里的填值代表的是对asset 进行了基础预处理
                    stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS,
                },
                (assets) => {
                    for (const name in assets) {
                        // 只对js资产做处理
                        if (name.endsWith(".js")) {
                            if (Object.hasOwnProperty.call(assets, name)) {
                                const asset = compilation.getAsset(name); // 通过asset名称获取到asset
                                const contents = asset.source.source(); // 获取到asset的内容
                                const result = contents.replace(/\/\/.*/g, "").replace(/\/\*.*?\*\//g, "");//删除注释
                                // 更新asset的内容
                                compilation.updateAsset(
                                    name,
                                    new sources.RawSource(result)
                                );
                            }
                        }
                    }
                }
            );
        })
    }
}
module.exports = DelCommentPlugin
复制代码

跟loader一样,对比一下使用了这个插件后的输出。

// main.js

 (function() { 
var __webpack_exports__ = {};

const x = 100;
let y = x; 

console.log(y);



 })()
;
复制代码

很明显,删除注释没有问题,并且可以看到,它把main.js文件内的注释都删除了,而loader只能删除源代码中的注释。plugin却可以直接改变最终输出的bundle内容。

手写一个简易 Webpack

Webpack是一个Node应用,所以本质上它就是在Node环境上跑了一段(一大大大段)js代码,看上去就像这样。

// built.js

const myWebpack = require("../lib/myWebpack");
// 引入自定义配置
const config = require("../config/webpack.config.js");


const compiler = myWebpack(config);
// 开始webpack打包
compiler.run();

复制代码

向myWebpack函数里传入配置config,然后构造一个compiler对象,执行它的run方法。run方法重点做两个事情,一是根据入口文件找出并记录所有依赖,二是用字符串组装最后输出的boundle函数,这个函数的主要功能就是根据依赖关系实现require和export功能。下面就按照这两步分析下代码:

根据入口文件分析出依赖关系表

// myWebpack.js

const fs = require("fs");
const path = require("path");
const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAstSync } = require("@babel/core")

// Compiler构造函数
class Compiler {
    constructor(options = {}) {
        this.options = options; // 获得webpack配置
        this.entry = this.options.entry || "./src/index.js" // 获取入口文件,不存在则使用默认值
        this.entryDir = path.dirname(this.entry) 
        this.depsGraph = {}; //依赖关系表,第一步的产出
    }
    // 启动webpack打包
    async run() {
        const { entry, entryDir } = this
        // 从入口文件开始获取模块信息
        this.getModuleInfo(entry, entryDir);
        console.log(this.depsGraph);
        // 获取到模块信息后生成构建内容,第二步的内容,先注释。
        // this.outputBuild()
    }
    // 根据文件路径获取模块信息
    getModuleInfo(modulePath, dirname) {
        const { depsGraph } = this
        /* 
        利用fs模块和文件路径可以读取到文件内容,然后根据文件内容(import和export)又可以分析出模块之间的依赖关系。
        自己去做这步是没有任何问题的。只是这里为了方便,就利用babelParser库生成一个抽象的模型ast(抽象语法树)。
        ast将我们的代码抽象出来,方便我们操作。
        */
        const ast = getAst(modulePath);
        // 利用ast和traverse库获得该模块的依赖。原理就是分析了代码中的"import"语句。
        const deps = getDeps(ast, dirname);
        // 利用ast和babel/core将源代通过babel编码输出。如果不用ast也可以直接使用babel/core的transform方法将源代码转码
        const code = getParseCode(ast)
        // depsGraph保存的模块信息就是code源代码和它的依赖关系
        depsGraph[modulePath] = {
            deps,
            code
        }
        // 如果该模块存在依赖deps,就通过递归继续找出它下面的依赖,这样循环就找出了入口文件开始的所有依赖。
        if (Object.keys(deps).length) {
            for (const key in deps) {
                if (Object.hasOwnProperty.call(deps, key)) {
                    // 递归获取模块信息
                    this.getModuleInfo(deps[key], dirname)
                }
            }
        }
    }
}

// getModuleInfo中用到的三个工具函数
// 根据文件路径获取抽象语法树
const getAst = (modulePath) => {
    const file = fs.readFileSync(modulePath, "utf-8");
    // 2. 将其解析成ast抽象语法树
    const ast = babelParser.parse(file, {
        sourceType: "module", // 要解析的是 es6 module(默认为commonJs)
    });
    return ast
};
// 根据抽象语法树ast获取依赖关系
const getDeps = (ast, dirname) => {
    // 该模块依赖合集
    const dependSet = {
    }
    // 利用traverse这个库收集依赖,自己收集也可以,不管是抽象语法树还是源代码中都是可以拿到依赖关系的。现成的库比较方便
    traverse(ast, {
        // 内部遍历ast中的program.body,判断里面语句类型
        // 如果type为ImportDeclaration 就会触发当前函数
        ImportDeclaration({ node }) {
            const relativePath = node.source.value //import文件的相对路径
            const absolutePath = path.resolve(dirname, relativePath) 
            dependSet[relativePath] = absolutePath // 依赖中记录文件的绝对路径
        }
    })
    return dependSet
};
// 根据抽象语法树,获取最终输出代码
const getParseCode = (ast) => {
    // 编译代码,将现代浏览器不能识别的语法进行编译处理
    // @babel/core可以直接将ast抽象语法树编译成兼容代码
    /* 编译完成,可输出 */
    const { code } = transformFromAstSync(ast, null, {
        presets: ["@babel/preset-env"]
    })
    return code
}

// 该模块要输出的myWebpack函数
const myWebpack = (config) => {
    return new Compiler(config);
};
module.exports = myWebpack;
复制代码

如果现在运行一下上面的built.js,就会打印出依赖关系表,它大概长这样。

depsGraph = {
    './src/index.js': {
        deps: {
            './add.js': 'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\add.js',
            './sub.js': 'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\sub.js'
        },
        code: '"use strict";\n' +
            '\n' +
            'var _add = _interopRequireDefault(require("./add.js"));\n' +
            '\n' +
            'var _sub = _interopRequireDefault(require("./sub.js"));\n' +
            '\n' +
            'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
            '\n' +
            'console.log((0, _add.default)(1, 2));\n' +
            'console.log((0, _sub.default)(3, 1));'
    },
    'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\add.js': {
        deps: {},
        code: '"use strict";\n' +
            '\n' +
            'Object.defineProperty(exports, "__esModule", {\n' +
            '  value: true\n' +
            '});\n' +
            'exports.default = _default;\n' +
            '\n' +
            'function _default(x, y) {\n' +
            '  return x + y;\n' +
            '}'
    },
    'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\sub.js': {
        deps: {},
        code: '"use strict";\n' +
            '\n' +
            'Object.defineProperty(exports, "__esModule", {\n' +
            '  value: true\n' +
            '});\n' +
            'exports.default = _default;\n' +
            '\n' +
            'function _default(x, y) {\n' +
            '  return x - y;\n' +
            '}'
    }
}
复制代码

第二步要做的事,就是根据依赖关闭表,输出最后的bundle文件。

组装输出函数

如果直接用字符串组装输出函数,可能会有点不好理解。所以先在一个js中实现想要输出的函数。这个函数以依赖关系表为参数,内部实现require和export函数,因为babel转码输出后的代码中使用的就是CommonJs规则。

(function (depsGraph) {
    // 为了加载入口文件
    function require(module) {
        // 定义模块内部的require函数
        function localRequire(relativePath) {
            // 为了找到要引入模块的绝对路径,通过require加载
            return require(depsGraph[module].deps[relativePath])
        };
        // 定义暴露对象
        var exports = {};
        /* 
        模块内部要自定义localRequire,而不是直接用require函数,原因是使用babell转化后的code,require传参时使用的是
        相对路径,而我们内部依赖表中,是根据绝对路径找到code,所以要实现一层转化
        */
        (function (require, exports, code) {
            // code是字符串,用eval执行
            eval(code)
        })(localRequire, exports, depsGraph[module].code);

        // 作为require函数的返回值返回出去
        // 后面的require函数能得到暴露的内容
        return exports;
    }
    // 加载入口文件
    require("./src/index.js")
})(depsGraph);
复制代码

这个就是最后要输出的bundle,如果把第一步中获取到的依赖关系表拿过来,直接执行这个函数,就可以和执行源代码取得同样的效果。最后要做的就是在myWebpack.js中用字符串拼装出这个函数。下面是myWebpack.js中的完整代码。

myWebpack完整源代码

const fs = require("fs");
const path = require("path");

const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAstSync } = require("@babel/core")


const myWebpack = (config) => {
    return new Compiler(config);
};

// Compiler构造函数
class Compiler {
    constructor(options = {}) {
        this.options = options; // 获得webpack配置
        this.entry = this.options.entry || "./src/index.js" // 获取入口文件,不存在则使用默认值
        this.entryDir = path.dirname(this.entry) 
        this.depsGraph = {}; //依赖关系表,第一步的产出
    }
    // 启动webpack打包
    async run() {
        const { entry, entryDir } = this
        // 从入口文件开始获取模块信息
        this.getModuleInfo(entry, entryDir);
        // 获取到模块信息后生成构建内容
        this.outputBuild()
    }
    // 根据文件路径获取模块信息
    getModuleInfo(modulePath, dirname) {
        const { depsGraph } = this
        const ast = getAst(modulePath);
        const deps = getDeps(ast, dirname);
        const code = getParseCode(ast)
        // depsGraph保存的模块信息就是code源代码和它的依赖关系
        depsGraph[modulePath] = {
            deps,
            code
        }
        // 如果该模块存在依赖deps,就通过递归继续找出它下面的依赖,这样循环就找出了入口文件开始的所有依赖。
        if (Object.keys(deps).length) {
            for (const key in deps) {
                if (Object.hasOwnProperty.call(deps, key)) {
                    // 递归获取模块信息
                    this.getModuleInfo(deps[key], dirname)
                }
            }
        }
    }
    // 最后一步,利用fs输出js文件
    outputBuild() {
        const build = `(function (depsGraph) {
            function require(module) {
                function localRequire(relativePath) {
                    // 为了找到要引入模块的绝对路径,通过require加载
                    return require(depsGraph[module].deps[relativePath])
                };
                // 定义暴露对象
                var exports = {};
                (function (require, exports, code) {
                    // code是字符串,要eval执行
                    eval(code)
                })(localRequire, exports, depsGraph[module].code);
        
                return exports;
            }
            require("${this.options.entry}")
        })((${JSON.stringify(this.depsGraph)}))`;
        let outputPath = path.resolve(this.options.output.path, this.options.output.filename)
        fs.writeFileSync(outputPath, build, "utf-8")
    }
}
// 根据文件路径获取抽象语法树
const getAst = (modulePath) => {
    // 1.读取入口文件内容
    /* 第二个参数如果不写,默认返回Buffer数据,如果写了utf-8解码,则返回字符串数据 */
    //  注意:从这个入口文件读取可以看出来,node针对的所有相对路径,都是根据运行环境来的,在这里就是package.json目录,
    // 即myWebpack目录
    const module = fs.readFileSync(modulePath, "utf-8");
    // 2. 将其解析成ast抽象语法树
    const ast = babelParser.parse(module, {
        sourceType: "module", // 要解析的是 es6 module(默认为commonJs)
    });
    return ast
};
// 根据抽象语法树ast获取依赖关系
const getDeps = (ast, dirname) => {
    // 依赖合集
    const dependSet = {
    }
    // 利用traverse这个库收集依赖,自己收集其实也可以,不管是抽象语法树还是import源代码中都是可以拿到依赖关系的。现成的库比较方便
    traverse(ast, {
        // 内部遍历ast中的program.body,判断里面语句类型
        // 如果type:ImportDeclaration 就会触发当前函数
        ImportDeclaration({ node }) {
            // 模块相对路径"./add.js"
            const relativePath = node.source.value
            const absolutePath = path.resolve(dirname, relativePath)
            dependSet[relativePath] = absolutePath
        }
    })
    return dependSet
};
// 根据抽象语法树,获取最终输出代码
const getParseCode = (ast) => {
    // 编译代码,将现代浏览器不能识别的语法进行编译处理
    // @babel/core可以直接将ast抽象语法树编译成兼容代码
    /* 编译完成,可输出 */
    const { code } = transformFromAstSync(ast, null, {
        presets: ["@babel/preset-env"]
    })
    return code
}


module.exports = myWebpack;
复制代码

结语

完整的Webpack需要编写的代码十分庞大,上文只是其最简单的整体架构。但即使如此,仍然感觉到比做基础篇的总结难度大一些,可能也会出现一些错误,欢迎大家指正!

分类:
前端
标签:
分类:
前端
标签: