Skip to content
On this page

unplugin

Unified plugin system for Vite, Rollup, Webpack, esbuild, and more

我们知道每个打包工具都有它自己的插件系统,插件系统的 API 又是各种不一样。rollup esbuild 插件系统都还好并不多 api,老大哥 webpack 的插件系统那真是复杂得一批。

对于我自己来说我可能只关心加载和转换,这两件事情在webpack 中类似 loader 的功能。那我如果还在在插件方面做一些事情,这有涉及到 loaderplugin 通信想想头都大。

unplugin 对标 rollup 的插件系统,对市面上的打包工具都做了一些兼容性处理,这篇文章就来看看是如何兼容 webpack 生态的

源码分析

从本质上来说还是通过配置的hook 去找 webpack 合适的钩子函数进行监听。比如下方

ts
if (plugin.buildEnd) {
  compiler.hooks.emit.tapPromise(plugin.name, async (compilation) => {
    await plugin.buildEnd!.call(createContext(compilation));
  });
}

if (plugin.writeBundle) {
  compiler.hooks.afterEmit.tap(plugin.name, () => {
    plugin.writeBundle!();
  });
}

对于 rollup 来说会先调用 resolveId -> load -> transform

  • resolveId 将模块转换成绝对路径或者是 虚拟模块
  • load 对某些路径进行内容读取
  • transform 对读取的内容进行转换

这三部分熟悉 webpack 的都知道是 loader 的责任。unplugin 中如何在 plugin 中实现 loader 的功能是本篇文章的重点。

load

load 钩子在 rollup 中是通过路径,去获取文件内容。而在 webpackloader 中则是 webpack自身读取内容,再将内容通过参数传输给我们。省去读取的动作。

好在 webpackloader 中有include选项作为筛选处理的文件类型,这也正好对应 unpluginloadIncludes 的钩子。在插件中我们在最前面插入一个 自定义 loader ,这个自定义loader就做一件事情,调用 load 钩子,并处理返回值.

ts
compiler.options.module.rules.unshift({
  include(id) {
    if (plugin.loadInclude && !plugin.loadInclude(id)) return false;
    return true;
  },
  enforce: plugin.enforce,
  use: [{ loader: custom_loader }], // 自定义loader
});

function custom_loader(source, map) {
  const callback = this.async(); // 转换成异步loader
  const id = this.this.resource; // 获取转换模块的路径

  if (!plugin?.load || !id) return callback(null, source, map);

  const res = await plugin.load.call(
    Object.assign(
      this._compilation && (createContext(this._compilation) as any),
      context
    ),
    normalizeAbsolutePath(id)
  );

  if (res == null) callback(null, source, map);
  else if (typeof res !== "string") callback(null, res.code, res.map ?? map);
  else callback(null, res, map);
}

这种在plugin 中插入新 rule 的想法真是太棒了!

transform

通过上文的分析,我们也大概知道要怎么去做,以及思路。实际上 unplugin 也是通过动态加入新rules思路去实现 transform钩子.值得注意的点就是顺序问题,按照 rollup 的执行规则是先 load->transform. 而在 unplugin 的源码中因为 判断的位置原因导致变成 transform->load 。这里我也提了个pr 来解决这个问题。

js
const path = require("path");

function loaderPath(loaders) {
  return loaders.map((loader) =>
    path.resolve(__dirname, "loaders", loader + ".js")
  );
}
module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: loaderPath(["pre-loader1", "pre-loader2"]),
      },
      // normal loader
      {
        test: /\.js$/,
        use: loaderPath(["normal-loader1", "normal-loader2"]),
      },
      // post loader
      {
        test: /\.js$/,
        enforce: "post",
        use: loaderPath(["post-loader1", "post-loader2"]),
      },
    ],
  },
};

上面配置中需要注意的点

  1. 通过 enforce 属性,设置 loader 的执行顺序
  2. 通过!分割 inline-loader

看下运行结果

text
// index.js 执行的loader

normal-loader2
normal-loader1
pre-loader2
pre-loader1
post-loader2
post-loader1

虚拟模块

虚拟模块这一概念由 rollup 提出,是一种很实用的模式,使你可以对使用 ESM 语法的源文件传入一些编译时信息。

js
export default function myPlugin() {
  const virtualModuleId = "virtual:my-module";
  const resolvedVirtualModuleId = "\0" + virtualModuleId;

  return {
    name: "my-plugin", // 必须的,将会在 warning 和 error 中显示
    resolveId(id) {
      if (id === virtualModuleId) {
        return resolvedVirtualModuleId;
      }
    },
    load(id) {
      if (id === resolvedVirtualModuleId) {
        return `export const msg = "from virtual module"`;
      }
    },
  };
}

这使得可以在 JavaScript 中引入这些模块:

js
import { msg } from "virtual:my-module";

console.log(msg); // from virtual module

INFO

在内部,使用了虚拟模块的插件在解析时应该将模块 ID 加上前缀 \0,这一约定来自 rollup 生态。这避免了其他插件尝试处理这个 ID(比如 node 解析),而例如 sourcemap 这些核心功能可以利用这一信息来区别虚拟模块和正常文件。\0 在导入 URL 中不是一个被允许的字符,因此我们需要在导入分析时替换掉它们。

而这一特性在webpack 中通过 webpack-virtual-modules 插件实现还支持热更新。

那就让我们来看看 unplugin 中是怎么使用这个插件的

首先得知道的是怎么判断一个模块是否为虚拟模块

  1. fs 文件系统查找不到 fs.existsSync() === false
  2. 模块的路径(id)不是一个绝对路径

unplugin 中我们最早能拿到模块路径的钩子是 resolveId,所以我们重点来看看这个钩子

ts
if (plugin.resolveId) {
  // 找到用户是否已经注册了 虚拟模块插件
  let vfs = compiler.options.plugins.find(
    (i) => i instanceof VirtualModulesPlugin
  ) as VirtualModulesPlugin;

  if (!vfs) {
    // 没有则加上
    vfs = new VirtualModulesPlugin();
    compiler.options.plugins.push(vfs);
  }

  // 收集那些模块是虚拟模块
  plugin.__vfsModules = new Set();
  plugin.__vfs = vfs;

  // 在解析的时候添加一个  resolve 插件
  const resolverPlugin: ResolvePluginInstance = {
    apply(resolver) {
      const target = resolver.ensureHook("resolve");

      resolver
        .getHook("resolve")
        .tapAsync(plugin.name, async (request, resolveContext, callback) => {
          if (!request.request) return callback();

          // filter out invalid requests
          if (
            normalizeAbsolutePath(request.request).startsWith(
              plugin.__virtualModulePrefix
            )
          )
            return callback();
          // 去除绝对路径
          const id = normalizeAbsolutePath(request.request);

          const requestContext = (
            request as unknown as { context: { issuer: string } }
          ).context;

          const importer =
            requestContext.issuer !== "" ? requestContext.issuer : undefined;
          const isEntry = requestContext.issuer === "";

          // call hook
          const resolveIdResult = await plugin.resolveId!(id, importer, {
            isEntry,
          });

          if (resolveIdResult == null) return callback();

          let resolved =
            typeof resolveIdResult === "string"
              ? resolveIdResult
              : resolveIdResult.id;

          const isExternal =
            typeof resolveIdResult === "string"
              ? false
              : resolveIdResult.external === true;
          if (isExternal) externalModules.add(resolved);

          // If the resolved module does not exist,
          // we treat it as a virtual module
          // 如果返回的路径不是一个可查找的路径,那就当他是一个虚拟模块
          if (!fs.existsSync(resolved)) {
            resolved = normalizeAbsolutePath(
              plugin.__virtualModulePrefix + encodeURIComponent(resolved) // URI encode id so webpack doesn't think it's part of the path
            );

            // webpack virtual module should pass in the correct path
            // https://github.com/unjs/unplugin/pull/155
            if (!plugin.__vfsModules!.has(resolved)) {
              plugin.__vfs!.writeModule(resolved, "");
              plugin.__vfsModules!.add(resolved);
            }
          }

          // construct the new request
          const newRequest = {
            ...request,
            request: resolved,
          };

          // redirect the resolver
          resolver.doResolve(
            target,
            newRequest,
            null,
            resolveContext,
            callback
          );
        });
    },
  };

  compiler.options.resolve.plugins = compiler.options.resolve.plugins || [];
  compiler.options.resolve.plugins.push(resolverPlugin);
}

上面只知道我们确定了某些条件下这个模块就是一个虚拟模块,并且用 __vfs!.writeModule 根据路径写了一个空文件。那些真实的文件内容是什么时候写进去的呢?

//todo