漫谈构建工具(十):聊一聊常见的构建工具关于插件机制的那些通用套路

1,095 阅读23分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

之前在老项目中尝试接入 Vite 的时候,小编遇到了一个源文件 url 解析的问题。为了解决这个问题,小编自己动手写了一个插件。整个过程可以说是非常顺利,写的插件也达到了预期的目的。不过就在接入改造进入尾声的时候,小编心血来潮的为这个插件添加了 enforce: 'pre' 配置项,结果整个项目就报错运行不起来了。

这个问题的出现,引起了小编很大的兴趣。经过研究,小编找到了问题出现的原因,并在研究过程中,结合之前 Webpack、Rollup、Esbuild 等构建工具插件的使用经验,发现了这些构建工具插件机制的一些通用套路,收获直接拉满。

在这里,小编将对上面所述的问题、问题出现的原因以及构建工具插件机制的通用套路做一个详细梳理,希望能同样给到大家一些不一样的收获。

本文的目录结构如下:

构建工具插件机制的通用套路

首先,小编直接开门见山,给出自己总结的关于构建工具插件机制的通用套路, 总共有 4 个:

  • 插件的工作机制都是基于发布/订阅模式实现的。

    尽管各个构建工具在插件机制的实现方式上各不相同,但原理都是类似的。

    整个过程分为两部分:先根据实际需要,订阅构建工具某个工作阶段对应的 hook,提供一个 callback;然后等到构建工具工作到这个阶段时,触发对应的 hook 收集的 callback。

  • 多个插件可订阅同一个 hook,这使得 callback 会存在多个。不同的构建工具,对 callback 执行顺序的处理策略会有略微不同。

    默认情况下,所有的构建工具都是按照插件订阅 hook 的先后顺序按序执行 callback 的。

    相比 Webpack,Rollup 和 Vite 分别提供了额外配置,来调整 callback 的处理顺序:

    • Rollup,配置为 order = pre / null / post,可提供 hook level 的顺序调整;

    • Vite,配置为 enforce = pre / normal / post,可提供 plugin level 的顺序调整;

    这一块儿,小编会在后续的内容中详细说明。

  • 每个 hook,根据其收集的 callback 内部是否可以出现异步代码,分为 sync hook 和 async hook。

    sync,是 hook 最基本的特征,即按序处理的 callback,必须等上一个 callback 的所有同步代码执行完毕,才会开始处理下一个 callback。callback 中的异步代码不会影响下一个 callback 的处理。

    如果想要等上一个 callback 的异步代码执行完毕之后,再开始处理下一个 callback,那就需要借助 async 类型的 hook 了。

    基本上所有的构建工具,都提供了 async hook,使得我们可以在 callback 中实现异步逻辑。

  • 每个 hook,根据其收集的多个 callback 之间的依赖关系,又可以细分为 bail(first)、waterfall(sequential)、parallel 。

    bail, 是 webpack 里面的叫法, 在 rollup 中的叫法是 first。如果某一个 callback 有返回不是 undefined 的值,那么后面的所有 callback 都不处理。

    waterfall, 是 webpack 里面的叫法, 在 rollup 中的叫法是 sequential。上一个 callback 的返回值会作为下一个 callback 的入参。

    parallel, 在 Webpack 和 Rollup 中叫法一样,只能在 async hook 中使用。callback 可并行执行,即不用等上一个 callback 的异步代码执行,就可以开始处理下一个 callback。

可以这么说,只要知道了这 4 个通用套路, 那么不管是 bundle 类型还是 unbunlde 类型的构建工具,其插件机制都可以很快被我们所掌握, 唯一需要花点时间的就是了解一下各个构建工具具体提供的 hook 种类以及插件的写法了。

不信的话,我们就拿 Webpack、Rollup、Vite、Esbuild 这四个构建工具来试一下吧。

有一点小编要提前声明一下,本文不会对各个构建工具关于发布/订阅模式的具体实现展开说明。不知道内部实现,完全不影响我们熟练使用插件。

Webpack 的插件机制

谈到构建工具,Webpack 一定是绕不开的,所以小编就先和大家聊聊 Webpack 的插件机制。对比其他几个构建工具,Webpack 的插件机制可以说是最复杂,当然功能也是最强大的。

其复杂性和强大性主要体现在两个方面:

  • 数量庞大、种类繁多的 hooks;

  • hook 收集的 callback,既有 sync、async 两种类型,又存在 bail、waterfall、parallel 等多种依赖关系;

Webpack 各种类型的 hooks以及触发的时机,可以通过一张图来给大家展示一下:

webpack 打包全链路.png

其中,红色的是 Compiler 类型的 hook,在打包构建的开始阶段和结束阶段触发;绿色的是 normalModuleFactory 类型的 hook, 在构建模块时为源文件创建 module 对象时调用;紫色的是 JavascriptParser 类型的 hook, 在构建模块依赖图时解析源文件对应的 AST 时调用;蓝色的是 Compilation 类型的 hook,在使用 loader 处理源文件内容、解析源文件依赖关系、将模块依赖图分离为 chunks 时使用。

不同类型的 hook,订阅的时机不同。

订阅 Compiler 类型的 hook,需要在 compiler 实例构建完成,依次执行插件实例的 apply 方法。

举个 🌰,

class MyPlugin {
    
    // 插件实例的 apply 方法执行时,入参为 compiler 实例
    apply(compiler) {
        // 订阅 Compiler 类型的 initialize hook,在 compiler 完成初始化以后触发 callback
        compiler.hooks.initialize.tap('MyPlugin', () => {
            ...
        });
    }
}

NormalModuleFactory 类型的 hook,订阅起来要麻烦一些,过程如下:

  1. 先订阅 Compiler 的 normalModuleFactory hook;

  2. 等 compilationParams 中的 normalModuleFactory 对象构建以后,触发 normalModuleFactory hook 的 callback,将 normalModuleFactory 对象作为入参传入;

  3. 在 callback 中通过入参 normalModuleFactory 订阅;

ContextModuleFactory 类型的 hook 订阅过程和 NormalModuleFactory 的一样。

举个 🌰,

class MyPlugin {
    apply(compiler) {
        // 先订阅 Compiler 类型的 normalModuleFactory hook
        compiler.hooks.normalModuleFactory.tap('MyPlugin', (normalModuleFactory) => {
            // 当 compilationParams 中的 normalModuleFactory 对象构建完成以后,触发 callback 执行
            // 在 callback 中,通过入参 normalModuleFactory 订阅 NormalModuleFactory 类型的 hook
            normalModuleFactory.hooks.resolve.tapAsync('MyPlugin', (data, callback) => {
                // 当开始解析源文件的 url 时,触发 callback
                ...;
                callback();
            });
        });
    } 
}

Compilation 类型的 hook, 和 NormalModuleFactory 类型 hook 的订阅过程类似,如下:

  1. 先订阅 Compiler 的 compilation hook;

  2. 等 compilation 对象构建以后,触发 compilation hook 的 callback,将 compilation 对象作为入参传入;

  3. 在 callback 中通过入参 compilation 订阅;

举个 🌰,

class MyPlugin {
    apply(compiler) {
        // 先订阅 Compiler 类型的 compilation hook
        compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
            // 当 compilation 对象构建完成以后,触发 callback 执行
            // 在 callback 中,通过入参 compilation 订阅 Compilation 类型的 hook
            compilation.hooks.optimizeChunks.tap('MyPlugin', (chunks) => {
                // 当开始对 initial chunks 和 asyncs chunk 做自定义 chunk 分离时,触发 callback
                ...;
                callback();
            });
        });
    } 
}

JavaScriptParser 类型的 hook,订阅起来最为复杂:

  1. 先订阅 Compiler 的 normalModuleFactory hook;

  2. 等 compilationParams 中的 normalModuleFactory 对象构建以后,触发 normalModuleFactory hook 的 callback,将 normalModuleFactory 对象作为入参传入;

  3. 在 callback 中通过入参 normalModuleFactory 订阅 NormalModuleFactory 类型的 parser hook;

  4. 等源文件的 url 完成解析,构建好 parser 实例以后,触发 parser hook 的 callback,将 parser 对象作为入参传入;

  5. 在 callback 中通过入参 parser 订阅 JavascriptParser 类型的 hook;

举个 🌰,

class MyPlugin {
    apply(compiler) {
        // 先订阅 Compiler 类型的 normalModuleFactory hook
        compiler.hooks.normalModuleFactory.tap('MyPlugin', (normalModuleFactory) => {
            // 当 compilationParams 中的 normalModuleFactory 对象构建完成以后,触发 callback 执行
            // 在 callback 中,通过入参 normalModuleFactory 订阅 NormalModuleFactory 类型的 parser hook
            normalModuleFactory.hooks.parser.tap('MyPlugin', (parser) => {
                // 源文件的 url 解析完成,构建好 parser 实例以后,触发 callback 执行
                // 在 callback 中,通过入参 parser 可以订阅 JavascriptParser 类型的 hook
                parser.hooks.import.tap('MyPlugin', (statement, source) => {
                    // source == 'lodash'
                });
            });
        });
    } 
}

这里要吐槽一下,Webpack 的插件写起来真是太复杂了 !!!

知道了各种 hooks 的订阅时机和触发时机,我们就可以在实际开发过程中,根据自身需要因地制宜的编写各种插件,非常灵活。

聊完了 hooks 的种类以及订阅方式,我们再来看看 hook 对应的 callback 的处理逻辑。

首先,根据 callback 中是否可以存在异步代码,webpack 将 hook 又分为 sync 和 async 两类。这两者的唯一区别就是如果 callback 中如果存在异步代码,是否可以等异步代码执行完毕以后才开始处理下一个 callback。sync hook 不可以, async hook 可以。

这两种类型的 hook, 订阅的时候都有各自的 api。sync hook 使用 tap 订阅, async hook 使用 tapAsync、tapPromise 订阅。

有一点需要特别说明,async hook, 根据是否需要等 callback 中的异步代码执行完毕以后才开始处理下一个 callback,又分为 AsyncSeriesHook 和 AsyncParallelHook 两类。AsyncSeriesHook 需要, AsyncParallelHook 不需要。

不过需要注意哦,虽然 AsyncParallelHook 在使用时,不需要等待上一个 callback 的异步代码处理完毕就可以开始处理下一个 callback,但是必须要等所有的 callback 的异步代码全部处理完毕,打包构建才可以进入下一个阶段,类似 Promise.all。

sync hook 没有 parallel 一说, 同步代码肯定无法并行。

举个 🌰:

class MyPlugin {

    apply(compiler) {
        // compilation hook 是 sync 类型的 hook,使用 tap 订阅
        compiler.hooks.compilation.tap('MyPlugin', compilation => {
            // 异步代码不会影响下一个 callback 的处理
            ...
        });

        // afterCompile hook 是 AsyncSeriesHook 类型的 hook,可以通过 tapAsync 订阅
        compiler.hooks.afterCompile.tapAsync('MyPlugin', (compilation, callback) => {
            ...
            setTimeout(() => {
                // 只有主动调用 callback,才能开始处理 hook 收集的下一个回调
                // 如果没有调用,打包构建过程会被阻塞
                callback()
            }, 1000);
   
        });
        // make hook 是 AsyncParallelHook 类型的 hook,可以通过 tapAsync 订阅
        compiler.hooks.make.tapAsync('MyPlugin', (compilation) => {
            // hook 的回调中如果存在异步代码,下一个 callback 不需要等待上一个 callback 的异步代码执行完毕
            ...
        });

        // AsyncSeriesHook 类型的 hook 也可以用过 tapPromise 订阅,不过 callback 必须返回一个 promise 对象
        compiler.hooks.afterCompiler.tapPromise('MyPlugin', (compilation) => {
            return new Promise((resolve, reject) => {
                ...
            });
        });

        // 该种订阅方式和上面的相同
        compiler.hooks.afterCompiler.tapPromise('MyPlugin', async (compilation) => {
            ...
        });

        // AsyncParallelHook 类型的 hook 也可以通过 tapPromise 方式订阅
        compiler.hooks.make.tapPromise('MyPlugin', async (compilation) => {
            ...
        });
    }
}

另外,不管是 sync hook 还是 async hook,对应的 callback 之间还存在另外两种关系 - bail 和 waterfall。

bail,熔断,如果某一个 callback 有返回非 undefined 的值,那么后面的所有 callback 都不处理。 sync 对应 SyncBailHook, async 对应 AsyncSeriesBailHook。

举个 🌰,

class MyPlugin {
    apply(compiler) {
        compiler.hooks.normalModuleFactory.tap('MyPlugin', (normalModuleFactory) => {
            normalModuleFactory.hooks.parser.tap('MyPlugin', (parser) => {
                // statement 是 SyncBailHook 类型的 hook,只要能获取明确的 statement,hook 后续的 callback 就不需要触发了
                parser.hooks.statement.tap('MyPlugin', (statement) => {
                    return statement;  // 如果 statement 是一个非 undefined 的值,那么剩余的 callback 不会触发
                    // 反之, 如果没有 return 一个非 undefined 的值,剩余的 callback 依次触发
                });
            });

            // resolve 是一个 AsyncSeriesBailHook 类型的 hook,只有得到明确的 resovleData,hook 后续的 callback 就不触发
            normalModuleFactory.hooks.resolve.tapAsync('MyPlugin', (data, callback) {
                ...
                setTimeout(() => {
                    callbck(data);  // 如果 data 是一个非 undefined 的值,那么 hook 后续的回调不会触发
                    // 反之,如果 data 是一个 undefined 的值,hook 后续的回调会按序触发
                });
            });
        });
    } 
}

waterfall,瀑布,上一个 callback 的返回值会作为下一个 callback 的入参。 sync 对应 SyncWaterfallHook, async 对应 AsyncSeriesWaterfallHook。

举个 🌰:

class MyPlugin {
    apply(compiler) {
        compiler.hooks.normalModuleFactory.tap('MyPlugin', (normalModuleFactory) => {
            // module 是一个 SyncWaterfallHook
            normalModuleFactory.hooks.module.tap('MyPlugin', (module, createData, resolveData) => {
                const tempModule = module;
                return tempModule;  // 返回的 tempModule 会作为下一个 callback 的 module 入参
            });
        });

        compiler.hooks.contextModuleFactory.tap('MyPlugin', (contextlModuleFactory) => {
            // afterResolve 是一个 AsyncSeriesWaterfallHook
            normalModuleFactory.hooks.afterResolve.tapAsync('MyPlugin', (data, callback) => {
                callback(data); // 返回的 data 会作为 hook 下一个回调的 data 入参
            });
        });
    } 
}

总的来说, Webpack 常用的 hooks,就是 sync、async、bail、waterfall、series、parallel 这几个关键字的自由组合:

  • sync + series = SyncHook(sync 本身就有 series 的意思), hook 所有的 callback 按序执行,上一个 callback 中的异步代码不影响下一个 callback, 也不影响打包构建的下一个阶段。

    典型的有 Compiler 类型的 normalModuleFactory、compilation, Compilation 类型的 optimize、 afterOptimizeChunks 等。

  • sync + series + bail = SyncBailHook, 在 SyncHook 的基础上,如果上一个 callback 返回非 undefined 的值,后续 callback 不做处理;

    典型的有 Compilation 类型的 optimizeDependencies、optimizeModules、 optimizeChunks 等。

  • sync + series + waterfall = SyncWaterfallHook,在 SyncHook 的基础上,上一个 callback 的返回值会作为下一个 callback 的入参;

    典型的有 NormalModuleFactory 类型的 module,Compilation 类型的 assetPath 等。

  • async + series = AsyncSeriesHook,hook 所有的 callback 按序执行,如果 callback 中有异步代码,则必须等上一个 callback 的异步代码结束,才开始执行下一个 callback;

    典型的有 Compiler 类型的 run、beforeCompile、afterCompiler 等。

  • async + series + bail = AsyncSeriesBailHook,在 AsyncSeriesHook 的基础上,如果上一个 callback 返回非 undefined 的值,后续 callback 不做处理;

    典型的有 NormalModuleFactory类型的 beforeResolve、resolve、afterResolve 等。

  • async + series + waterfall = AsyncSeriesWaterfallHook,在 AsyncSeriesHook 的基础上,上一个 callback 的返回值会作为下一个 callback 的入参;

    典型的有 ContextModuleFactory 类型的 beforeResolve、afterResolve 等。

  • async + parallel = AsyncParallelHook,hook 所有的 callback 按序执行,如果 callback 中有异步代码,下一个 callback 中不需要等上一个 callback 的异步代码结束就可以开始,但必须等所有 callback 的异步代码结束才可以进入打包构建的下一个阶段。

    典型的有 Compiler 类型的 make。

在我们自定义插件时,一定要特别关注 bail、waterfall 类型的 hooks。这两类 hooks,前面 callback 的返回值会影响到后续 callback 的执行,如果返回的值不合理,可能会导致打包构建无法继续。这一点要切记噢。

Rollup 的 plugin 机制

聊完 Webpack 的插件机制,我们再来聊聊 Rollup 的插件机制。

和 Webpack 一样,作为 bundle 类型构建工具,Rollup 在打包构建过程中, 同样需要以指定入口为起点,构建模块依赖图,将模块依赖图分离为 initial chunk、async chunks、customer chunks。在构建模块依赖图时,也需要经历 url 解析为绝对路径、读取源文件内容、通过 AST 解析源文件的依赖关系。

同样的,Rollup 也提供了插件机制,使得我们有了介入上述各个阶段的机会,做一些自定义操作。不过相比 Webpack,Rollup 的插件机制相对要简单很多。

举个 🌰:

// 写一个插件
const myPlugin = {
    name: 'xxx',
    resolveId: async (id) => {
        ...
    },
    load: (id) => {
        ...
    }
}

// 使用一个插件
// config.js
{
    ...
    plugins: [myPlugin]
}

比起 Webpack 来, Rollup 插件的定义和使用是不是简单了很多呢。

使用 Rollup 完成打包构建非常简单,举个 🌰:

rollup.rollup(inputOptions).then(async bundle => {
    await bundle.write(outputOptions);  // 打包结果写到 output 指定位置
    // await bundle.generate(outputOptions);  // 打包结果写到内存中
});

Rollup 将整个打包构建过程分为两个阶段:

  • build 阶段 - 构建模块依赖图, API 为 Rollup.rollup(inputOptions)

  • output generation 阶段 - 分离 chunks并生成最后的 bundle 内容, API 为 bundle.write(outputOptions) 或者 bundle.generate(outputOptions)。

对应的 hooks 也分为这两类。

这里,我们贴两张 Rollup 官网的两张图。

image.png

上图是 build 阶段构建模块依赖图过程中涉及的所有 hooks, 其中:

  • options, 初始化 input options 以后,依次触发 options hook 的 callback,对 input options 做自定义更新;

  • buildStart, Rollup 在完成 build 阶段的初始化工作以后, 依次触发 buildStart hook 的 callback;

  • resolveId、resolveDynamicImport,将模块的静态依赖、动态依赖解析为绝对路径;

  • load, 根据模块的绝对路径加载源文件内容;

  • transform, 对源文件内容转换为浏览器可以识别的内容;

  • moduleParsed, 模块的依赖关系通过 AST 解析完毕,依次触发 moduleParsed hook 的 callback;

  • buildEnd, 模块依赖图构建完成,依次触发 buildEnd hook 的 callback;

其中,核心的几个 hook 是 resolveId、load、transform、moduleParsed。

image.png

上图是 output generation 阶段将模块依赖图分离为 chunks 并生成最后的 bundle 涉及的所有 hooks:

  • outputOptions, 初始化 output options 以后,依次触发 outputOptions hook 的 callback,对 output options 做自定义更新;

  • renderStart, Rollup 完成 output generation 阶段的初始化工作以后,依次触发 renderStart hook 的 callback;

  • renderDynamicImport/resolveFieUrl/resolveImportMeta, 在将模块依赖图分离为 chunks 的过程中,依次触发 hook 的 callback,对 import(...)、import.meta.url、import.meta 做自定义操作;

  • banner/footer/intro/outro, 等 chunks 分离完成以后, 依次触发 hook 的 callback,收集要给每一个 chunk 添加到注释、代码;

  • renderChunk, 根据每个 chunk 收集的 modules、banner、footer、intro、outro, 为每一个 chunk 构建内容;

  • augmentChunkHash, 为每一个 chunk 生成 chunk hash;

  • generateBundle, 所有的 chunk 的内容都完成构建以后,依次触发 generateBundle hook 的 callback;

  • writeBundle,将生成的 bundles 输出到指定位置以后,依次触发 writeBundle hook 的 callback;

  • closeBundle,output generation 阶段结束,依次触发 closeBundle hook 的 callback;

仔细观察,我们会发现 Rollup 提供的 hooks, 基本上都能在 Webpack 中找到匹配项,而且也分为 sync 和 async 两类,并且 hook 收集的 callback 也存在 first(bail)、sequential(waterfall)、parallel 依赖关系, 理解起来和 Webpack 完全一样。

举个 🌰:

  • Rollup 的 resolveId hook 实际对应 Webpack 的 normalModuleFactory.hooks.resolve,具备 asnyc + series + first(bail) 特征;

  • Rollup 的 load 对应 Webpack 读取源文件的过程(Webpack 没有相关 hook, 😅),具备 asnyc + series + first(bail) 特征;

  • Rollup 的 transform 对应 Webpack 使用 loader 处理源文件的过程,具备 asnyc + series + sequential(waterfall) 特征;

  • Rollup 的 buildStart 对应 Webpack 的 compiler.make, 具备 async + series + parallel 的特征;

  • ...

不过也有稍许不同, 主要体现在这 2 个方面:

  • async hooks,只基于 promise 实现,不像 Webpack 中还可以通过 tapAsync + callback 实现;

  • hook 对应的 callback 的执行顺序可以通过 order:pre/normal/post 实现 hook level 的调整;

举个 🌰:

{
    name: 'myPlugin',
    resolveId: {
        order: 'post',   // resolveId hook 会稍后订阅,对应的 callback 也会稍后触发
        handler: async (id) => {
            ...
        }
    },
    load: {
        order: 'pre', // load hook 会提前订阅,对应的 callback 也会提前触发
        handler: (id) => {
            ...
        }
    }
}

和 Webpack 一样,在使用自定义插件的时候,一定要注意 first、sequential 这两类 hook, 防止出现返回值不合适导致打包构建无法进行的情况。

Esbuild 的 plugin 机制

和 Webpack、Rollup 一样, Esbuild 也是 bundle 类型的构建工具,工作的时候同样要经历构建模块依赖和分离模块依赖图并生成 bundles 的过程。

相比 Webpack 和 Rollup, Esbuild 的插件机制要更加简单。

举个 🌰:

let myPlugin = {
    name: 'resolve',
    setup: (build) => {
        build.onResolve({ filter: /.*/ }, (args) => {
            ...
        });
        
        ...
        build.onLoad({ filter: /.txt$/ }, async (args) => {
            ...
        });
    }
}


esbuild.build({
    entryPoints: 'xxx',
    outdir: 'xxx',
    plugins: [myPlugin],
    ...
});

Esbuild 一共提供了 4 个 hooks 供外部调用,分别是:

  • onStart,build 开始时会触发;

  • onResolve,解析源文件 url 时调用,可自定义 url 如何解析;

  • onLoad, 加载源文件内容时调用,可自定义源文件如何加载;

  • onEnd,build 结束时会触发;

有了前面 Webpack、Rollup 的铺垫,我们可以顺利做出如下推测:

  • onStart hook,具备 async + series + parallel 特征;

  • onResolve hook, 具备 async + series + first(bail) 特征;

  • onLoad hook, 具备 async + series + first(bail) 特征;

  • onEnd hook, 具备 async + series + parallel 特征;

而实际也确实如此,小伙伴们可以自行尝试哦。

Vite 的 plugin 机制

Vite 的插件机制,和 Rollup 基本相同,有略微差别也仅仅是提供的 hooks 不同。

举个 🌰:

{
    name: 'myPlugin',
    config: async (config) => {
        ...
    },
    resolveId: async (id) => {
        ...
    }
}

由于 Vite 在开发模式下采取 unbundle 机制,生产模式下使用 bundle 模式,所以它的 hooks 也要分成两类,即 dev hooks 和 prod hooks。

2.x 版本,Vite 生产模式下基于 Rollup 实现打包构建,3.x 版本,则基于 Esbuild 实现打包构建,使用的 prod hooks 和 Rollup(Esbuild) 完全一致,这里小编就不再详细说明了。本文,我们重点关注 dev hooks。

由于 Vite 的插件机制和 Rollup 一脉相承,所以 dev hooks 也分为 sync 和 async 两类,并且 hook 收集的 callback 也同样存在 first(bail)、sequential(waterfall)、parallel 依赖关系。

根据 dev 模式下 Vite 的整个工作过程,其关键 dev hooks 按照触发顺序分为:

  • dev server 启动阶段

    • config hook, 在 Vite 读取 vite.config.ts 文件获取 config 配置以后触发,给开发人员再次需改 config 的机会,具备 async + series + sequential 特征;

    • configResolved hook, Vite 的 config 配置项初始化完成以后触发,具备 async + series + parallel 的特征;

    • configureServer hook, dev server 创建以后触发,用于给 dev server 添加自定义 middleware, 具备 async + series + sequential 特征;

    • buildStart hook, 等同于 Rollup 的 buildStart hook, unbundle 开始工作, 先完成预构建,然后启动 dev server;

  • dev server 工作阶段

    • transformIndexHtml hook,解析入口文件 index.html 时触发,可用于转换 html 文件内容, 具备 async + series + sequential 特征;

    • resolveId, 等同于 Rollup 的 resolveId, 解析请求文件的绝对路径时触发,具备 asnyc + series + first(bail) 特征;

    • load, 等同于 Rollup 的 load, 加载请求文件时触发,具备 asnyc + series + first(bail) 特征;

    • transform, 等同于 Rollup 的 transform,转换请求文件内容时触发,具备 async + series + sequential 特征;

  • dev server 关闭阶段

    • buildEnd、closeBundle, dev server 关闭时触发,具备 async + series + parallel 特征;

和 Rollup 一样, Vite 在定义插件的时候,也可以通过 enforce: pre / normal / post 属性来调整插件订阅 hook 的顺序。 不过,Vite 是 plugin level 的顺序调整,即如果某个插件设置了 enforce: pre,那么这个插件里面所有 hook 的 callback 的执行顺序都会提前,比起 Rollup 中 hook level 的顺序调整,有点不够灵活。

举个 🌰:

{
    name: 'myPlugin',
    enforce: 'pre',  // myPlugin 会提前订阅 hook,对应的 callback 会提前触发
    config: async (config) => {
        ...
    },
    resolveId: async (id) => {
        ...
    }
}

和 Webpack、Rollup 一样,在使用自定义插件的时候,一定要注意 first、sequential 这两类 hook, 防止出现返回值不合适导致打包构建无法进行的情况。

前言描述问题解析

了解了 Webpack、Rollup、Vite、Esbuild 这几个构建工具插件机制的通用套路以后,我们再回过头来,看看前言中提到的设置 enforce: pre 导致项目运行报错的问题。

小编先带大家了解一下上面提到的问题。

之前的老项目中,模块之间存在这样的 3 种依赖关系:

// App.tsx
import { useState } from 'react';   // 第三方依赖
import Layout from './components/layout';  // 相对路径
import Header from 'components/header'; // 特殊依赖
...

其中,第一种和第二种是我们最常见的第三方依赖、相对路径依赖。第三种比较特殊,这种写法是基于 tsconfig.json 中有 baseUrl 配置才可以正常使用的, 如下:

// tsconfig.json
{
    "compilerOptions": {
        ...
        baseUrl: 'src'
    }
}

在项目中配置好 vite.config.ts 文件以后,直接启动 vite server,控制台会出现如下报错信息:

The following dependencies are imported but could not be resolved:

    components/header (imported by /xx/xx/xx/App.tsx)

Are they installed?

之所以会出现这样的异常信息,是因为 Vite 在预构建的时候,将 components/header 当成了第三方依赖。当发现在 node_modules 文件夹中没有找到对应的源文件时,就抛出异常,给出第三方依赖是否安装的提示。

针对这种情况,我们可以定义一个自定义插件,在这个插件中设置 resolveId hook,来处理上面的特殊依赖。

代码如下:

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

function urlResolve(options?: any) {
  return {
    name: 'custome-plugin-url-resolve',
    resolveId: async (id: string) => {
      if (
        id.startsWith('.') ||
        id.startsWith('/') ||
        id.startsWith('http://') ||
        id.startsWith('https://')
      )
        // 相对路径或者绝对路径,直接返回
        return id;
      const pkg = require('../package.json');
      const firstPath = id.split('/')[0];
      // 判断 id 是否是三方库
      if (pkg.dependencies[firstPath]) return id;
      // 判断 id 是否是别名
      if (options && options.alias) {
        for (let item of options.alias) {
          if (item.find === firstPath) return id;
        }
      }
      const tspkg = require('../tsconfig.json');
      if (tspkg.compilerOptions && tspkg.compilerOptions.baseUrl) {
        let url = path.resolve('./', tspkg.compilerOptions.baseUrl) + '/' + id;
        if (path.extname(url)) {
          if (fs.existsSync(url)) {
            return url;
          }
        } else {
          if (options && options.extname) {
            for (let item of options.extname) {
              if (fs.existsSync(url + item)) {
                return url + item;
              }
              if (fs.existsSync(url + '/index' + item)) {
                return url + '/index' + item;
              }
            }
          }
        }
      }
      return id;
    },
  };
}

export default urlResolve;

用法如下:

{
    ...,
    plugins: [
        urlResolve({
            alias: [
              { find: '@', replacement: path.resolve('./', 'src') }
            ],
            extname: ['.ts', '.js', '.tsx', '.jsx', '.png'],
        })
    ]
}

经测试,这个插件可以很好的解决上面的问题,😄。

不过,当我们给这个插件设置 enforce: pre 以后,整个应用再运行时就报错了。

报错如下:

image.png

image.png

简单分析一下。这个报错的原因是 - 请求的入口文件是 ts 格式,没有转换成浏览器可识别的 js 格式,导致出现语法错误。

这只是表层原因,更深层次原因的其实是 enforce: pre 配置项导致 resolveId hook 解析请求文件的绝对路径出现了问题。

首先,小编先给大家罗列一下 dev 模式下涉及的插件订阅 hook 的顺序:

  • 路径别名插件

    • vite:pre-alias plugin,订阅 resolveId hook;

    • alias plugin,订阅 buildStart hook 和 resolvedId hook;

  • enforce:pre 类型的三方 plugin;

  • Vite 内部插件

    • vite:modulepreload-polyfill plugin,订阅 resolvedId hook 和 load hook;

    • vite:resolve plugin,订阅 resolvedId hook 和 load hook;

    • vite:optimized-deps plugin - 订阅 load hook;

    • vite:html-inline-proxy plugin - 订阅 resolveId hook 和 load hook;

    • vite:css plugin, 订阅 buildStart hook、transform hook;

    • vite:esbuild plugin, 订阅 buildEnd hook、 transform hook;

    • vite:json plugin, 订阅 transform hook;

    • vite:wasm plugin, 订阅 resolveId hook、load hook;

    • vite:worker plugin, 订阅 buildStart hook、load hook、transform hook、renderChunk hook;

    • vite:asset plugin, 订阅 buildStart hook、load hook、renderChunk hook、 generateBundle hook;

    • ...

  • enforce:normal 类型的三方 plugin

  • Vite 内部插件

    • vite:define plugin,订阅 transform hook;

    • vite:css-post plugin, 订阅 buildStart hook、load hook、renderChunk hook、 generateBundle hook;

    • vite:worker-import-meta-url plugin, 订阅 transform hook;

    • ...

  • enforce:post 类型的三方 plugin

  • Vite 内部插件

    • vite:client-inject plugin, 订阅 transfrom hook;

    • vite:import-analysis plugin, 订阅 transfrom hook;

    • ...

在这些插件中,vite:resolve 用于将源文件依赖中的相对路径解析为绝对路径, 如果是绝对路径或者第三方依赖,则返回 undefined。

正常情况下,当浏览器发起一个资源请求时,dev server 会做如下处理:

  1. 执行 vite:resolve 的 resolveId hook 收集的 callback,将请求路径解析为绝对路径;

  2. 执行 load hook 收集的 callback,根据绝对路径加载源文件内容;

  3. 执行 transform hook 收集的 callback,对源文件内容做转换;

  4. 将转换以后的内容返回给浏览器;

我们定义的 custome-plugin-url-resolve 插件, 开始时属于 enforce:normal 类型的第三方插件,在 vite:resolve 之后订阅 resolveId hook, 对应的 callback 执行顺序靠后。

由于 vite:resolve 无法处理项目中的特殊依赖,返回了 undefined,使得 customer-plugin-url-resolve 继续处理特殊依赖,返回需要的绝对路径,达到了预期的目的。

当我们给 custome-plugin-url-resolve 设置了 enforce:pre 属性后,会先与 vite:resolve 订阅 resolveId hook,导致对应的 callback 也优先处理。

在 custome-plugin-url-resolve 中,我们的逻辑是如果 url 是相对路径,直接返回入参 url。由于 resolveId hook 是 first 类型,当我们返回非 undefined 的值以后,后续的 callback 就不处理了,导致 dev server 无法得到请求文件的绝对路径,也就无法读取源文件内容、对源文件内容做转换了,所以最后返回的就是 ts 格式的内容。

这里也印证了我们上面提到的在使用自定义插件的时候,一定要注意 first、sequential 这两类 hook。

结束语

到这里,关于构建工具插件机制通用套路的梳理就结束了。相信通过本文的介绍,大家对上面提到的几个构建工具的插件使用也有了一些新的体会了吧。

这些套路,说白了也就那么一回事。有了这些套路,就算是再出现新的构建工具,其插件机制也很快会被我们熟悉,哈哈。