likes
comments
collection
share

浅析webpack5原理-自定义loader及plugin

作者站长头像
站长
· 阅读数 9

自定义plugin及loader简述:

  • 自定义loader是定义一个js文件导出函数,只要在配置文件中符合test正则的文件就会进入loader对应的js文件(也就是函数),会经过loader文件中的函数处理了文件内容后返回处理之后的文件
  • 自定义plugin是定义一个js文件导出类,在webpack配置文件中new一个plugin就会进入plugin对应的类,类中的apply方法允许将该plugin插件及对应的处理代码挂载到compiler的钩子函数(生命周期函数)上,也就是不会立即执行plugin的处理代码,只有webpack执行到对应的钩子函数才会触发这个钩子函数上挂载的plugin
  • 自定义loader处理的是某一类满足test的文件,而plugin可以处理webpack执行阶段的所有文件及资源

项目目录

浅析webpack5原理-自定义loader及plugin

loader

loader执行顺序:从下到上,从右到左

loader简介--同步loader与异步loader

同步loader

//同步loader(当前loader内不能执行异步操作,同步loader不会等待异步操作被执行后再执行下一个loader)

//content 需要loader处理的源文件的内容
//map SourceMap 数据
//meta 传向下一个loader的数据,可以是任何内容
module.exports = function (content, map, meta) {
    console.log('test1');
    //return content
    //this.callback 方法则更灵活,因为它允许传递多个参数,而不仅仅是 content
    //执行this.callback即代表当前loader执行完毕,将参数传递给下一个loader执行
    //this.callback的第一个参数是error,代表错误
    this.callback(null, content, map, meta);
    return; // 当调用 callback() 函数时,总是返回 undefined
}

异步loader

//异步loader(当前loader内能执行异步操作,异步loader会等待异步操作被执行后再执行下一个loader)
module.exports = function (content, map, meta) {
    //this.async将loder转换为异步loader,返回this.callback
    const callback = this.async();
    // 进行异步操作(当前loader会等待异步操作执行结束再执行下一个loader)
    setTimeout(() => {
        console.log('test2');
        //callback会将参数传入下一个loader(callback中的参数和下一个loader中接收的参数对应)
        callback(null, content, map, meta);
    }, 1000);
};
自定义loader

项目根目录新建loader文件夹存放自定义loader文件

自定义babel-loader

自定义babel-loader:使用babel提供的预设和工具来实现es6转es5

新建loader/babel-loader文件夹新建loader/babel-loader/index.js存放自定义babel-loader逻辑代码

//loader/babel-loader/index.js
//使用babel提供的预设和工具来实现es6转es5
const schema = require("./schema.json");
const babel = require("@babel/core");

module.exports = function (content) {
    const options = this.getOptions(schema);
    // 使用异步loader
    const callback = this.async();
    // 使用babel对js代码进行编译
    babel.transform(content, options, function (err, result) {
        if (err) callback(err);
        else callback(err, result.code);
    });
};

由于loader可以传参数,自定义loader可以通过json文件约束参数信息新建loader/babel-loader/schema.json存放babel-loader参数信息

{
    //传入参数类型
    "type": "object",
    "properties": {
    //约束传入参数信息
        "presets": {
            "type": "array"
        }
    },
    //是否可以自定义传入参数
    "additionalProperties": true
}
自定义banner-loader

自定义banner-loader:在文件内容前加上描述信息(作者信息等)

新建loader/banner-loader文件夹新建loader/banner-loader/index.js存放自定义banner-loader逻辑代码

//loader/banner-loader/index.js
//在文件内容前加上描述信息(作者信息等)
const schema = require('./schema.json')
module.exports = function (content) {
    //this.getOptions获取传入的options参数,需要对webpack.config.js中loader的options传入的参数进行校验
    //在this.getOptions中传入校验规则,校验规则需要满足JSON规则
    //schema.json规则的type代表传入参数的类型,properties代表对象类型的属性的规则配置,additionalProperties表示是否可以添加属性
    const options = this.getOptions(schema)
    const prefix = `
    /*
    * Author: ${options.author}
    */
    `
    return `${prefix} \n ${content}`
}

新建loader/banner-loader/schema.json存放banner-loader参数信息

{
    "type": "object",
    "properties": {
        "author": {
            "type": "string"
        }
    },
    "additionalProperties": false
}
自定义file-loader

自定义file-loader:打包图片资源

新建loader/file-loader文件夹新建loader/file-loader/index.js存放自定义file-loader逻辑代码

//loader/file-loader/index.js
//打包图片资源
const loaderUtils = require("loader-utils");
const schema = require('./schema.json')

module.exports = function (content) {
    const options = this.getOptions(schema)
    // 1. 根据文件内容生成带hash值文件名(webpack提供的loaderUtils处理loader的一些工具)
    let interpolatedName = loaderUtils.interpolateName(this, "[hash].[ext][query]", {
        content,
    });
    //修改图片存放位置
    interpolatedName = `${options.filename}/${interpolatedName}`
    // console.log(interpolatedName);
    // 2. this.emitFile方法将文件输出出去(输出到对应目录)
    this.emitFile(interpolatedName, content);
    // 3. 返回:module.exports = "文件路径(文件名)"
    return `module.exports = "${interpolatedName}"`;
};

// 需要处理图片、字体等文件。它们都是buffer数据
// 需要使用raw loader才能处理buffer数据
module.exports.raw = true;

新建loader/file-loader/schema.json存放file-loader参数信息

{
    "type": "object",
    "properties": {
        "filename": {
            "type": "string"
        }
    },
    "additionalProperties": false
}
自定义style-loader

自定义style-loader:处理css样式文件

新建loader/style-loader文件夹新建loader/style-loader/index.js存放自定义style-loader逻辑代码

//loader/style-loader/index.js
module.exports = function (content) {
    /*
      1. 直接使用style-loader,只能处理样式
        不能处理样式中引入的其他资源
  
        use: ["./loaders/style-loader"],
  
      2. 借助css-loader解决样式中引入的其他资源的问题
  
        use: ["./loaders/style-loader", "css-loader"],
  
        问题是css-loader暴露了一段js代码,style-loader需要执行js代码,得到返回值,再动态创建style标签,插入到页面上
        不好操作
  
      3. style-loader使用pitch loader用法
    */
    // const script = `
    //   const styleEl = document.createElement('style');
    //   styleEl.innerHTML = ${JSON.stringify(content)};
    //   document.head.appendChild(styleEl);
    // `;
    // return script;
};

module.exports.pitch = function (remainingRequest) {
    // remainingRequest 剩下还需要处理的loader
    // console.log(remainingRequest); // C:\Users\86176\Desktop\webpack\source\node_modules\css-loader\dist\cjs.js!C:\Users\86176\Desktop\webpack\source\src\css\index.css

    // 1. 将 remainingRequest 中绝对路径改成相对路径(因为后面只能使用相对路径操作)
    const relativePath = remainingRequest
        .split("!")
        .map((absolutePath) => {
            // 返回相对路径
            return this.utils.contextify(this.context, absolutePath);
        })
        .join("!");

    // console.log(relativePath); // ../../node_modules/css-loader/dist/cjs.js!./index.css

    // 2. 引入css-loader处理后的资源(内联loader)
    // 3. 创建style,将内容插入页面中生效
    const script = `
      import style from "!!${relativePath}";
      const styleEl = document.createElement('style');
      styleEl.innerHTML = style;
      document.head.appendChild(styleEl);
    `;

    // 中止后面loader执行
    return script;
};
自定义clean-log-loader

自定义clean-log-loader:清除所有js文件中的console.log()代码

新建loader/clean-log-loader.js存放自定义clean-log-loader逻辑代码

//loader/clean-log-loader.js
//清除所有js文件中的console.log语句
module.exports = function (content) {
    //content是传入的js文件内的内容,对其做正则表达式全局替换console.log再返回就实现了清除所有js文件中的console.log语句了
    //正则表达式的.()等都需要\来进行转义处理
    return content.replace(/console.log(.*);?/g, "")
}

plugin

plugin简介-apply、compiler、compilation
  • 在整个webpack打包构建过程中compiler对象只有一个,而compilation可以有多个,每一个对应一块资源
  • compiler对象和compilation都提供了很多的钩子(生命周期),可以在不同的钩子上挂载不同的插件,在执行到对应钩子时就会执行插件代码
/*
  1. webpack加载webpack.config.js中所有配置,此时就会new TestPlugin(), 执行插件的constructor
  2. webpack创建compiler对象
  3. 遍历所有plugins中插件,调用插件的apply方法
  4. 执行剩下编译流程(触发各个hooks事件)
*/
class TestPlugin {
    constructor() {
        console.log("TestPlugin constructor");
    }
    //参数传入compiler对象
    //在整个webpack打包构建过程中compiler对象只有一个,而compilation可以有多个,每一个对应一块资源
    //compiler对象和compilation都提供了很多的钩子(生命周期),可以在不同的钩子上挂载不同的插件,在执行到对应钩子时就会执行插件代码
    apply(compiler) {
        //console.log("compiler", compiler);
        console.log("TestPlugin apply");

        // 由文档可知,compiler的environment是同步钩子(插件按顺序执行),所以需要使用tap注册
        //注册在钩子上的第一个参数是类名(即插件名)
        compiler.hooks.environment.tap("TestPlugin", () => {
            console.log("TestPlugin environment");
        });

        // 从文档可知, emit 是 AsyncSeriesHook, 也就是异步串行钩子,特点就是异步任务顺序执行
        compiler.hooks.emit.tap("TestPlugin", (compilation) => {
            //console.log("compilation", compilation);
            console.log("TestPlugin emit 111");
        });

        // 使用tapAsync、tapPromise注册,进行异步操作会等异步操作做完再继续往下执行
        compiler.hooks.emit.tapAsync("TestPlugin", (compilation, callback) => {
            setTimeout(() => {
                console.log("TestPlugin emit 222");
                callback();
            }, 2000);
        });

        compiler.hooks.emit.tapPromise("TestPlugin", (compilation) => {
            return new Promise((resolve) => {
                setTimeout(() => {
                    console.log("TestPlugin emit 333");
                    resolve();
                }, 1000);
            });
        });

        // 从文档可知, make 是 AsyncParallelHook, 也就是异步并行钩子, 特点就是异步任务同时执行
        // 可以使用 tap、tapAsync、tapPromise 注册。
        // 如果使用tap注册的话,进行异步操作是不会等待异步操作执行完成的。
        compiler.hooks.make.tapAsync("TestPlugin", (compilation, callback) => {
            // 需要在compilation hooks触发前注册才能使用
            compilation.hooks.seal.tap("TestPlugin", () => {
                console.log("TestPlugin seal");
            });

            setTimeout(() => {
                console.log("TestPlugin make 111");
                callback();
            }, 3000);
        });

        compiler.hooks.make.tapAsync("TestPlugin", (compilation, callback) => {
            setTimeout(() => {
                console.log("TestPlugin make 222");
                callback();
            }, 1000);
        });

        compiler.hooks.make.tapAsync("TestPlugin", (compilation, callback) => {
            setTimeout(() => {
                console.log("TestPlugin make 333");
                callback();
            }, 2000);
        });
    }
}

module.exports = TestPlugin;

自定义plugin

项目根目录新建plugin文件夹存放自定义plugin文件

自定义AnalyzeWebpackPlugin

自定义AnalyzeWebpackPlugin:生成打包文件信息(名称、文件大小)

新建plugin/analyze-webpack-plugin.js存放自定义AnalyzeWebpackPlugin逻辑代码

//plugin/analyze-webpack-plugin.js
class AnalyzeWebpackPlugin {
    apply(compiler) {
        compiler.hooks.emit.tap("AnalyzeWebpackPlugin", (compilation) => {
            // 1. 遍历所有即将输出文件,得到其大小
            /*
              将对象变成一个二维数组:
                对象:
                  {
                    key1: value1,
                    key2: value2 
                  }
                二维数组:
                  [
                    [key1, value1],
                    [key2, value2]
                  ]
            */
            const assets = Object.entries(compilation.assets);

            /*
                md中表格语法:
                  | 资源名称 | 资源大小 |
                  | --- | --- |
                  | xxx.js | 10kb |
            */
            let content = `| 资源名称 | 资源大小 |
  | --- | --- |`;
            // //根据文件大小排序(冒泡排序-大->小)
            for (let j = assets.length - 1; j >= 0; j--) {
                for (let i = 0; i < j; i++) {
                    if (Math.ceil(assets[i][1].size() / 1024) < Math.ceil(assets[i + 1][1].size() / 1024)) {
                        let temp = assets[i]
                        assets[i] = assets[i + 1]
                        assets[i + 1] = temp
                    }
                }
            }
            assets.forEach(([filename, file]) => {

                content += `\n| ${filename} | ${Math.ceil(file.size() / 1024)}kb |`;
            });

            // 2. 生成一个md文件
            compilation.assets["analyze.md"] = {
                source() {
                    return content;
                },
                size() {
                    return content.length;
                },
            };
        });
    }
}

module.exports = AnalyzeWebpackPlugin;

自定义BannerWebpackPlugin

自定义BannerWebpackPlugin:在生产环境中在css和js资源打包的文件中加上头部注释(作者信息等)

新建plugin/banner-webpack-plugin.js存放自定义BannerWebpackPlugin逻辑代码

//plugin/banner-webpack-plugin.js
//在生产环境中在css和js资源打包的文件中加上头部注释(作者信息等)
//之前自定义的banner-loader不能在生产环境中使用,在生产环境中使用会把loader添加的注释压缩舍弃掉
//所以banner-webpack-plugin插件需要在compiler打包之后,输出之前的emit钩子函数上挂载头部注释插件

class BannerWebpackPlugin {
    //定义的插件都是类,在webpack配置文件new插件传入的参数就会进入constructor构造函数中,要想插件中使用就加上this
    constructor(options = {}) {
        this.options = options
    }
    apply(compiler) {
        compiler.hooks.emit.tapAsync("BannerWebpackPlugin", (compilation, callback) => {
            //compilation.assets可以获取到打包的资源文件,是对象形式,键为文件名,值为文件信息
            //步骤:1、将对象的键(即文件名)使用filter过滤,只获取指定的以css和js后缀的文件
            //2、将获取到的文件名组成的数组遍历,通过compilation.assets[遍历的文件名]获取文件信息
            //3、文件信息是一个对象,对象的source方法可以获取文件内容,size方法可以获取文件大小
            //4、获取到文件内容之后和需要加上的头部注释拼接,然后将这个文件重新赋值一个新对象,包含source方法和size方法,再返回

            const extensions = ['css', 'js']
            //头部信息
            const content = `/*
* Author: ${this.options.author}
*/
`;
            //获取满足后缀条件的文件名
            const nameList = Object.keys(compilation.assets).filter((asset) => {
                const list = asset.split('.')
                //是否为正确的文件后缀名
                return extensions.includes(list[list.length - 1])
            })
            nameList.forEach((item) => {
                //获取原本的文件内容
                const oldfile = compilation.assets[item].source()
                //拼接新文件内容
                const newfile = content + oldfile
                //重新将该文件的信息整合替换
                compilation.assets[item] = {
                    //文件内容
                    source() {
                        return newfile
                    },
                    //文件大小(即内容的长度)
                    size() {
                        return newfile.length
                    }
                }
            })
            callback()
        })
    }
}

module.exports = BannerWebpackPlugin
自定义CleanWebpackPlugin

自定义CleanWebpackPlugin:打包之前清除上一次的打包文件夹dist

新建plugin/clean-webpack-plugin.js存放自定义CleanWebpackPlugin逻辑代码

//plugin/clean-webpack-plugin.js
//打包之前清除上一次的打包文件夹dist

class CleanWebpackPlugin {
    apply(compiler) {
        //需要在输出之前清空文件夹,因为如果在刚打包就清空上一次的文件夹,如果遇到打包错误终止了webpack,上一次的dist依旧会被删除
        //所以需要在输出之前emit上挂载插件
        //1、获取打包输出目录:通过 compiler 对象。
        //2、通过文件操作清空内容:通过 compiler.outputFileSystem 操作文件。
        // 获取操作文件的对象
        const fs = compiler.outputFileSystem;
        // emit是异步串行钩子
        compiler.hooks.emit.tapAsync("CleanWebpackPlugin", (compilation, callback) => {
            // 获取输出文件目录
            const outputPath = compiler.options.output.path;
            // 删除目录所有文件
            const err = this.removeFiles(fs, outputPath);
            // 执行成功err为undefined,执行失败err就是错误原因
            callback(err);
        });
    }
    //删除文件夹需要将文件夹下的文件全部清空才能删除掉,所以需要使用递归遍历文件夹下的所有文件
    removeFiles(fs, path) {
        try {
            // 读取当前目录下所有文件
            const files = fs.readdirSync(path); //['images','js','index.html']

            // 遍历文件,删除
            files.forEach((file) => {
                // 获取文件完整路径
                const filePath = `${path}/${file}`;   // dist/images
                // 分析文件
                const fileStat = fs.statSync(filePath);
                // 判断是否是文件夹
                if (fileStat.isDirectory()) {
                    // 是文件夹需要递归遍历删除下面所有文件
                    this.removeFiles(fs, filePath);
                } else {
                    // 不是文件夹就是文件,直接删除
                    fs.unlinkSync(filePath);
                }
            });

            // 最后删除当前目录
            fs.rmdirSync(path);
        } catch (e) {
            // 将产生的错误返回出去
            return e;
        }
    }
}

module.exports = CleanWebpackPlugin
自定义InlineChunkWebpackPlugin

自定义InlineChunkWebpackPlugin:将打包生成的小的js文件转换为html的内联js代码,不使用script标签引入,可以减少请求的发送(比如runtime文件)

新建plugin/inline-chunk-webpack-plugin.js存放自定义InlineChunkWebpackPlugin逻辑代码

//plugin/inline-chunk-webpack-plugin.js
//将打包生成的小的js文件转换为html的内联js代码,不使用script标签引入,可以减少请求的发送(比如runtime文件)
// plugins/inline-chunk-webpack-plugin.js
const HtmlWebpackPlugin = require("safe-require")("html-webpack-plugin");

class InlineChunkWebpackPlugin {
    constructor(tests) {
        this.tests = tests;
    }

    apply(compiler) {
        compiler.hooks.compilation.tap("InlineChunkWebpackPlugin", (compilation) => {
            //htmlwebpackplugin插件内置了几个钩子函数
            //由于htmlwebpackplugin插件是生成html文件且自动引入依赖文件
            //所以在htmlwebpackplugin插件执行插入依赖时将需要转换为内联代码的文件的内容插入到html中形成文件转内联代码
            const hooks = HtmlWebpackPlugin.getHooks(compilation);
            //在htmlpluginwebpack插件提供的钩子函数上注册插件(alterAssetTagGroups表示生成了标签分组的过程)
            hooks.alterAssetTagGroups.tap("InlineChunkWebpackPlugin", (assets) => {
                //assets.headTags获取head中的标签,assets.bodyTags获取body中的标签
                assets.headTags = this.getInlineTag(assets.headTags, compilation.assets);
                assets.bodyTags = this.getInlineTag(assets.bodyTags, compilation.assets);
            });
            //htmlwebpackplugin执行结束之后删除runtime文件(afterEmit表示插件执行结束的过程)
            hooks.afterEmit.tap("InlineChunkHtmlPlugin", () => {
                Object.keys(compilation.assets).forEach((assetName) => {
                    if (this.tests.some((test) => assetName.match(test))) {
                        delete compilation.assets[assetName];
                    }
                });
            });
        });
    }
    //找出所有要插入html中的标签中需要转换为内联代码的文件标签,将文件内容代码插入,而不是插入引入文件的标签
    getInlineTag(tags, assets) {
        /*
        目前tags:[
            {
            tagName: 'script',
            voidTag: false,
            meta: { plugin: 'html-webpack-plugin' },
            attributes: { defer: true, type: undefined, src: 'js/runtime~main.js.js' }
            },
        ]

        修改为:
            [
            {
                tagName: 'script',
                innerHTML: runtime文件的内容
                closeTag: true 
            },
            ]
        */
        return tags.map((tag) => {
            //判断是否是script标签
            if (tag.tagName !== "script") return tag;

            const scriptName = tag.attributes.src;
            //判断是否满足正则表达式的文件名
            if (!this.tests.some((test) => scriptName.match(test))) return tag;

            //以上都不满足则代表找到了目标文件,将目标文件内容以script标签插入为内联代码
            return {
                tagName: "script",
                innerHTML: assets[scriptName].source(),
                closeTag: true
            };
        });
    }
}

module.exports = InlineChunkWebpackPlugin;

webpack配置项使用上面自定义的plugin和loader

项目根目录新建webpack.config.js配置webpack

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const BannerWebpackPlugin = require('./plugin/banner-webpack-plugin')
const CleanWebpackPlugin = require('./plugin/clean-webpack-plugin')
const AnalyzeWebpackPlugin = require('./plugin/analyze-webpack-plugin')
const InlineChunkWebpackPlugin = require('./plugin/inline-chunk-webpack-plugin')

module.exports = {
    mode: "production",
    entry: path.resolve(__dirname, './src/main.js'),
    output: {
        path: path.resolve(__dirname, './dist'),
        filename: 'js/[name].js',
        clean: true
    },
    module: {
        //loader执行顺序;从下到上,从右到左(如果不使用oneof就会把所有test匹配的loader都执行)
        rules: [
            {
                test: /\.js$/,
                //use: ['./loader/demo/test1.js','./loader/demo/test2.js'],
                loader: "./loader/clean-log-loader.js"
            },
            // {
            //     test: /\.js$/,
            //     loader: "./loader/banner-loader",
            //     //传入loader的参数,必须要满足该loader的参数校验规则,不然会报错
            //     options: {
            //         author: "minus"
            //     }
            // },
            {
                test: /\.js$/,
                loader: './loader/babel-loader',
                options: {
                    //传入babel的预设
                    presets: ["@babel/preset-env"],
                }
            },
            {
                test: /\.png|jpe?g|gif$/,
                loader: './loader/file-loader',
                type: "javascript/auto", // 阻止webpack5默认处理图片资源,只使用自己配置的file-loader处理
                options: {
                    filename: "images"
                }
            },
            {
                test: /\.css$/,
                use: ['./loader/style-loader', 'css-loader']
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: path.resolve(__dirname, './public/index.html')
        }),
        //new TestPlugin(),
        //传参进入插件类的构造函数中
        new BannerWebpackPlugin({
            author: 'minus'
        }),
        new CleanWebpackPlugin(),
        new AnalyzeWebpackPlugin(),
        //通过传参的方式可以指定需要转为内联代码的文件
        new InlineChunkWebpackPlugin([/runtime(.*)\.js$/])
    ],
    optimization: {
        //分包
        splitChunks: {
            chunks: "all",
        },
        //生成runtime文件(由于runtime文件体积很小,现在需要将runtime文件转换为内联js代码,减少请求发送)
        runtimeChunk: {
            name: (entrypoint) => `runtime~${entrypoint.name}.js`,
        },
    },

}