Webpack手写loader和plugin --- 闭门造车

319 阅读9分钟

我们在Webpack基础篇介绍了多种loader和plugin以及每种的用途;那么他们两者在webpack内部是如何进行工作的呢?让我们手写一个loader和plugin来看看它内部的原理,以便加深对webpack的理解。

手写loader

  我们在在Webpack配置基础篇介绍过,loader是链式传递的,对文件资源从上一个loader传递到下一个,而loader的处理也遵循着从下到上的顺序,我们简单了解一下loader的开发原则:

1、单一原则: 每个Loader只做一件事,简单易用,便于维护;

2、链式调用: Webpack 会按顺序链式调用每个Loader;

3、统一原则: 遵循Webpack制定的设计规则和结构,输入与输出均为字符串,各个Loader完全独立,即插即用;

4、无状态原则:在转换不同模块时,不应该在loader中保留状态;

  因此我们就来尝试写一个less-loader和style-loader,将less文件处理后通过style标签的方式渲染到页面上去。

同步loader

  loader默认导出一个函数,接受匹配到的文件资源字符串和SourceMap,我们可以修改文件内容字符串后再返回给下一个loader进行处理,因此最简单的一个loader如下:

module.exports = function(source, map){  
  return source
}

导出的loader函数不能使用箭头函数,很多loader内部的属性和方法都需要通过this进行调用,比如this.cacheable()来进行缓存、this.sourceMap判断是否需要生成sourceMap等。

  我们在项目中创建一个loader文件夹,用来存放我们自己写的loader,然后新建我们自己的style-loader:

//loader/style-loader.js
function loader(source, map) {
    let style =
        `
    let style = document.createElement('style');
    style.innerHTML = ${JSON.stringify(source)};
    document.head.appendChild(style)
  `;
    return style;
}
module.exports = loader;

  这里的source就可以看做是处理后的css文件字符串,我们把它通过style标签的形式插入到head中;同时我们也发现最后返回的是一个JS代码的字符串,webpack最后会将返回的字符串打包进模块中。

异步loader

  上面的style-loader都是同步操作,我们在处理source时,有时候会进行异步操作,一种方法是通过async/await,阻塞操作执行;另一种方法可以通过loader本身提供的回调函数callback。

//loader/less-loader
const less = require("less");

function loader(source) {
    const callback = this.async();
    less.render(source, function (err, res) {
        let {
            css
        } = res;
        callback(null, css);
    });
}
module.exports = loader;

  callback的详细传参方法如下:

callback({
    //当无法转换原内容时,给 Webpack 返回一个 Error
    error: Error | Null,
    //转换后的内容
    content: String | Buffer,
    //转换后的内容得出原内容的Source Map(可选)
    sourceMap ? : SourceMap,
    //原内容生成 AST语法树(可选)
    abstractSyntaxTree ? : AST
})

  有些时候,除了将原内容转换返回之外,还需要返回原内容对应的Source Map,比如我们转换less和scss代码,以及babel-loader转换ES6代码,为了方便调试,需要将Source Map也一起随着内容返回。

//loader/less-loader
const less = require("less");

function loader(source) {
    const callback = this.async();
    less.render(source, {
        sourceMap: {}
    }, function (err, res) {
        let {
            css,
            map
        } = res;
        callback(null, css, map);
    });
}
module.exports = loader;

  这样我们在下一个loader就能接收到less-loader返回的sourceMap了,但是需要注意的是:

Source Map生成很耗时,通常在开发环境下才会生成Source Map,其它环境下不用生成。Webpack为loader提供了this.sourceMap这个属性来告诉loader当前构建环境用户是否需要生成Source Map。

加载本地loader

  loader文件准备好了之后,我们需要将它们加载到webpack配置中去;在基础篇中,我们加载第三方的loader只需要安装后在loader属性中写loader名称即可,现在加载本地loader需要把loader的路径配置上。

module.exports = {
    module: {
        rules: [{
            test: /\.less/,
            use: [
                {
                    loader: './loader/style-loader.js',
        },
                {
                    loader: path.resolve(__dirname, "loader", "less-loader"),
        },
      ],
    }]
    }
}

  我们可以在loader中配置本地loader的相对路径或者绝对路径,但是这样写起来比较繁琐,我们可以利用webpack提供的resolveLoader属性,来告诉webpack应该去哪里解析本地loader。

module.exports = {
    module: {
        rules: [{
            test: /\.less/,
            use: [
                {
                    loader: 'style-loader',
        },
                {
                    loader: 'less-loader',
        },
      ],
    }]
    },
    resolveLoader: {
        modules: [path.resolve(__dirname, 'loader'), 'node_modules']
    }
}

  这样webpack会先去loader文件夹下找loader,没有找到才去node_modules;因此我们写的loader尽量不要和第三方loader重名,否则会导致第三方loader被覆盖加载。

处理参数

  我们在配置loader时,经常会给loader传递参数进行配置,一般是通过options属性来传递的,也有像url-loader通过字符串来传参:

{
    test: /\.(jpg|png|gif|bmp|jpeg)$/,
    use: 'url-loader?limt=1024&name=[hash:8].[ext]'
}

  webpack也提供了query属性来获取传参;但是query属性很不稳定,如果像上面的通过字符串来传参,query就返回字符串格式,通过options方式就会返回对象格式,这样不利于我们处理。因此我们借助一个官方的包loader-utils帮助处理,它还提供了很多有用的工具。

const {
    getOptions,
    parseQuery,
    stringifyRequest,
} = require("loader-utils");

module.exports = function (source, map) {
    //获取options参数
    const options = getOptions(this);
    //解析字符串为对象
    parseQuery("?param1=foo")
    //将绝对路由转换成相对路径
    //以便能在require或者import中使用以避免绝对路径
    stringifyRequest(this, "test/lib/index.js")
}

  常用的就是getOptions将处理后的参数返回出来,它内部的实现逻辑也非常的简单,也是根据query属性进行处理,如果是字符串的话调用parseQuery方法进行解析,源码如下:

//loader-utils/lib/getOptions.js
'use strict';
const parseQuery = require('./parseQuery');

function getOptions(loaderContext) {
    const query = loaderContext.query;
    if (typeof query === 'string' && query !== '') {
        return parseQuery(loaderContext.query);
    }
    if (!query || typeof query !== 'object') {
        return {};
    }
    return query;
}
module.exports = getOptions;

  获取到参数后,我们还需要对获取到的options参数进行完整性校验,避免有些参数漏传,如果一个个判断校验比较繁琐,这就用到另一个官方包schema-utils:

const {
    getOptions
} = require("loader-utils");
const {
    validate
} = require("schema-utils");
const schema = require("./schema.json");
module.exports = function (source, map) {
    const options = getOptions(this);
    const configuration = {
        name: "Loader Name"
    };
    validate(schema, options, configuration);
    //省略其他代码
}

  validate函数并没有返回值,打印返回值发现是undefined`,因为如果参数不通过的话直接会抛出ValidationError异常,直接进程中断;这里引入了一个schema.json,就是我们对options中参数进行校验的一个json格式的对应表:

{
    "type": "object",
    "properties": {
        "source": {
            "type": "boolean"
        },
        "name": {
            "type": "string"
        },
    },
    "additionalProperties": false
}

  properties中的健名就是我们需要检验的options中的字段名称,additionalProperties代表了是否允许options中还有其他额外的属性。

less-loader源码分析

  写完我们自己简单的less-loader,让我们来看一下官方的less-loader源码到底是怎么样的,这里贴上部分源码:

import less from 'less';
import {
    getOptions
} from 'loader-utils';
import {
    validate
} from 'schema-utils';
import schema from './options.json';
async function lessLoader(source) {
    const options = getOptions(this);
    //校验参数
    validate(schema, options, {
        name: 'Less Loader',
        baseDataPath: 'options',
    });
    const callback = this.async();
    //对options进一步处理,生成less渲染的参数
    const lessOptions = getLessOptions(this, options);
    //是否使用sourceMap,默认取options中的参数
    const useSourceMap =
        typeof options.sourceMap === 'boolean' ?
        options.sourceMap : this.sourceMap;
    //如果使用sourceMap,就在渲染参数加入
    if (useSourceMap) {
        lessOptions.sourceMap = {
            outputSourceFiles: true,
        };
    }
    let data = source;
    let result;
    try {
        result = await less.render(data, lessOptions);
    } catch (error) {}
    const {
        css,
        imports
    } = result;
    //有sourceMap就进行处理
    let map =
        typeof result.map === 'string' ?
        JSON.parse(result.map) : result.map;

    callback(null, css, map);
}
export default lessLoader;

  可以看到官方的less-loader和我们写的简单的loader本质上都是调用less.render函数,对文件资源字符串进行处理,然后将处理好后的字符串和sourceMap通过callback返回。

loader依赖

  在loader中,我们有时候也会使用到外部的资源文件,我们需要在loader对这些资源文件进行声明;这些声明信息主要用于使得缓存loader失效,以及在观察模式(watch mode)下重新编译。

  我们尝试写一个banner-loader,在每个js文件资源后面加上我们自定义的注释内容;如果传了filename,就从文件中获取预设好的banner内容,首先我们预设两个banner的txt:

//loader/banner1.txt
/* build from banner1 */

//loader/banner2.txt
/* build from banner2 */

  然后在我们的banner-loader中根据参数来进行判断:

//loader/banner-loader
const fs = require("fs");
const path = require("path");
const {
    getOptions
} = require("loader-utils");

module.exports = function (source) {
    const options = getOptions(this);
    if (options.filename) {
        let txt = "";
        if (options.filename == "banner1") {
            this.addDependency(path.resolve(__dirname, "./banner1.txt"));
            txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt"));
        } else if (options.filename == "banner2") {
            this.addDependency(path.resolve(__dirname, "./banner1.txt"));
            txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt"));
        }
        return source + txt;
    } else if (options.text) {
        return source + `/* ${options.text} */`;
    } else {
        return source;
    }
};

  这里使用了this.addDependency的API将当前处理的文件添加到文件依赖中(并不是项目的package.json)。如果在观察模式下,依赖的text文件发生了变化,那么打包生成的文件内容也随之变化。

如果不添加this.addDependency的话项目并不会报错,只是在观察模式下,如果依赖的文件发生了变化生成的bundle文件并不能及时更新。

缓存加速

  在有些情况下,loader处理需要大量的计算非常耗性能(比如babel-loader),如果每次构建都重新执行相同的转换操作每次构建都会非常慢。

  因此webpack默认会将loader的处理结果标记为可缓存,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时,它的输出结果必然是相同的;如果不想让webpack缓存该loader,可以禁用缓存:

module.exports = function (source) {
    // 强制不缓存
    this.cacheable(false);
    return source;
};

手写loader所有代码均在:

github.com/acexyf/Webp…

手写plugin

  在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过Webpack提供的API改变输出结果。和手写loader一样,我们先来写一个简单的plugin:

//plugins/MyPlugin.js
class MyPlugin {
    constructor() {
        console.log("Plugin被创建了");
    }
    apply(compiler) {}
}
module.exports = MyPlugin;

  plugin的本质是类;我们在定义plugin时,其实是在定义一个类;定义好plugin后就可以在webpack配置中使用这个插件:

//webpack.config.js
const MyPlugin = require('./plugins/MyPlugin')
module.exports = {
    plugins: [
    new MyPlugin()
  ],
}

  

这样我们的插件就在webpack中生效了;这时有些童鞋可能会想起来,我们在使用HtmlWebpackPlugin或者CleanWebpackPlugin等一些官方插件时,可以通过实例化插件传入参数;那么这里我们是否也能通过这种方式给我们的插件传参呢?

//plugins/MyPlugin.js
class MyPlugin {
    constructor(options) {
        console.log("Plugin被创建了");
        console.log(options);
        this.options = options;
    }
    apply(compiler) {}
}
//webpack.config.js
module.exports = {
    plugins: [
    new MyPlugin({
            title: 'MyPlugin'
        })
  ],
}

  我们在构建插件时就能通过options获取配置信息,对插件做一些初始化的工作。在构造函数中我们发现多了一个apply函数,它会在webpack运行时被调用,并且注入compiler对象;其工作流程如下:

1、webpack启动,执行new myPlugin(options),初始化插件并获取实例

2、初始化complier对象,调用myPlugin.apply(complier)给插件传入complier对象

3、插件实例获取complier,通过complier监听webpack广播的事件,通过complier对象操作webpack

  我们可以通过apply函数中注入的compiler对象进行注册事件:

class MyPlugin {
    apply(compiler) {
        //不推荐使用,plugin函数被废弃了
        // compiler.plugin("compile", (compilation) => {
        //   console.log("compile");
        // });
        //注册完成的钩子
        compiler.hooks.done.tap("MyPlugin", (compilation) => {
            console.log("compilation done");
        });
    }
}

  compiler不仅有同步的钩子,通过tap函数来注册,还有异步的钩子,通过tapAsync和tapPromise来注册:

class MyPlugin {
    apply(compiler) {
        compiler.hooks.run.tapAsync("MyPlugin", (compilation, callback) => {
            setTimeout(() => {
                console.log("compilation run");
                callback()
            }, 1000)
        });
        compiler.hooks.emit.tapPromise("MyPlugin", (compilation) => {
            return new Promise((resolve, reject) => {
                setTimeout(() => {
                    console.log("compilation emit");
                    resolve();
                }, 1000)
            });
        });
    }
}

  这里又有一个compilation对象,它和上面提到的compiler对象都是Plugin和webpack之间的桥梁:

1、compiler对象包含了 Webpack 环境所有的的配置信息。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。

2、compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。当运行webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

**  compiler和compilation的区别在于:**

3、compiler代表了整个webpack从启动到关闭的生命周期,而compilation只是代表了一次新的编译过程

4、compiler和compilation暴露出许多钩子,我们可以根据实际需求的场景进行自定义处理

手写FileListPlugin

  了解了compiler和compilation的区别,我们就来尝试一个简单的示例插件,在打包目录生成一个filelist.md文件,文件的内容是将所有构建生成文件展示在一个列表中:

class FileListPlugin {
    apply(compiler) {
        compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
            var filelist = 'In this build:\n\n';
            // 遍历所有编译过的资源文件,
            // 对于每个文件名称,都添加一行内容。
            for (var filename in compilation.assets) {
                filelist += '- ' + filename + '\n';
            }
            // 将这个列表作为一个新的文件资源,插入到 webpack 构建中:
            compilation.assets['filelist.md'] = {
                source: function () {
                    return filelist;
                },
                size: function () {
                    return filelist.length;
                }
            };
            callback();
        })
    }
}
module.exports = FileListPlugin

  我们这里用到了assets对象,它是所有构建文件的一个输出对象,打印出来大概长这样:

{
    'main.bundle.js': {
        source: [Function: source],
        size: [Function: size]
    },
    'index.html': {
        source: [Function: source],
        size: [Function: size]
    }
}

  我们手动加入一个filelist.md文件的输出;打包后我们在dist文件夹中会发现多了这个文件:

In this build:
- main.bundle.js
- index.html

  这个插件就完成了我们的预期任务了。

原文链接:mp.weixin.qq.com/s/vxAu3oqQ7…