利用uplugin对比Webpack和Rollup插件系统

江湖上一直流传一种说法:Rollup 的插件系统设计,相比与 webpack,要更加科学顺手。(网络上对 webpack 插件编写的吐槽不计其数)Talk is cheap,本文基于 unplugin 这个三方库来对比研究一下二者的插件系统。Unplugin 是一个插件编写工具,它可以让开发者用一套代码同时为主流 bundler 编写插件,包括 webpack、Rollup、Vite、esbuild、Rspack。

Unplugin hooks

Unplugin 以 Rollup 的 hooks 为基础,总共有 9 个生命周期钩子函数,其中包含 6 个 build hooks,1 个 output generation hook,和 2 个独立 hooks。借此我们可以大致了解不同 bundler 之间的通用能力。下面将简要介绍包含 unplugin 自身逻辑的钩子函数,其余请参考 Rollup 官方文档。

buildStart 和 buildEnd

与 Rollup 的钩子函数相同,分别代表一次 build 准备开始和 build 结束。不同的是 unplugin 将函数的 this 指向了自身定义的UnpluginBuildContext:

export interface UnpluginBuildContext {
    
    
  addWatchFile: (id: string) => void
  emitFile: (emittedFile: EmittedAsset) => void
  getWatchFiles: () => string[]
  parse: (input: string, options?: any) => AcornNode
}

该上下文提供了四个方法,unplugin 为每个 bundler 都实现了一遍,按需使用。

loadInclude 和 transformInclude

专门为 webpack 适配的钩子函数,用来过滤需要 load 或者 transform 的模块。由于 webpack loader 和 plugin 分离的设计,load 和 transform 的功能实际被 loader 所承载。如果没有过滤函数,会导致所有模块都被插件加载,影响 webpack 性能。

Unplugin Webpack模块实现

深入看 unplugin 对 webpack 模块的实现,可以观察到 Rollup 类的钩子函数是如何转换到 webpack 系统中的。

首先了解 webpack plugin 的设计。官方文档中给出的示例比较传统:一个具有 apply 函数的 class,通过 constructor 接收用户对插件的自定义设置。实际上,webpack 只需要一个带有 apply 方法的对象就够了。Unplugin 还额外包了一层生成函数,将用户配置传递到每个 bundler 的插件定义函数中,此外还提供了meta参数表明它要为哪个 bunlder 生成插件:

export function getWebpackPlugin<UserOptions = {
      
      }>(
  factory: UnpluginFactory<UserOptions>,
): UnpluginInstance<UserOptions>['webpack'] {
    
    
  return (userOptions?: UserOptions) => {
    
    
      return {
    
    
          apply(compiler: WebpackCompiler) {
    
    
              // implementation
          }
      }
  }

代码中factory就是定义插件在各生命周期中执行具体逻辑的函数,例如:

(options, meta) => {
    return {
        load() {
            // load 钩子函数
        }
    }
}

在执行钩子函数之前,有一系列初始化工作。首先在 webpack compiler 中注入自身上下文。

const injected = compiler.$unpluginContext || {}
compiler.$unpluginContext = injected

接着调用factory函数拿到插件定义:

const rawPlugins = toArray(factory(userOptions!, meta))

unplugin 支持多个插件同时定义,所以这里统一用toArray转换成数组处理。然后遍历数组,给插件增加公共属性:

const plugin = Object.assign(
    rawPlugin,
    {
    
    
        __unpluginMeta: meta,
        __virtualModulePrefix: VIRTUAL_MODULE_PREFIX,
    },
) as ResolvedUnpluginOptions

// inject context object to share with loaders
injected[plugin.name] = plugin

compiler.hooks.thisCompilation.tap(plugin.name, (compilation) => {
    
    
    compilation.hooks.childCompiler.tap(plugin.name, (childCompiler) => {
    
    
        childCompiler.$unpluginContext = injected
    })
})

注意这里给 childCompiler 也同样注入了上下文。这一系列注入上下文的动作,是让整个 webpack 都能拿到插件的定义。这在 webpack loader 中拿到 plugin 的定义是有作用的,因为 loader 定义中,它只是一个接受 source code 的函数,然后返回转译过的 source code。通过全局注入,我们就能在 loader 的定义函数中拿到 plugin 的load函数和transform函数。

接下来按照钩子函数的执行顺序,逐一解析其源码。

buildStart

if (plugin.watchChange || plugin.buildStart) {
    
    
    compiler.hooks.make.tapPromise(plugin.name, async (compilation) => {
    
    
        const context = createContext(compilation)
        if (plugin.watchChange && (compiler.modifiedFiles || compiler.removedFiles)) {
    
    
            // implementation
        }

        if (plugin.buildStart)
            return await plugin.buildStart.call(context)
    })
}

buildStartwatchChange被放在一起处理,因为他们都要用到上下文。具体看buildStart,仅仅提供context并执行plugin.buildStart。对应到 webpack 插件生命周期是make。查阅 webpack 文档我们可以发现,unplugin 略过了一系列 webpack 初始化的钩子函数,例如读取 config,初始化 compiler,调用插件等等。因为这些是 webpack 的自有逻辑,和 Rollup 也无法兼容。make会在一次 compliation 创建完后触发,即将开始从 entry 读取文件。符合 Rollup 的buildStart定义。

watchChange

watchChange是独立于执行顺序之外的钩子函数。当 bundler 以 watch 模式运行时,当被监测的文件发生变化时触发。在 webpack 中,unplugin 利用了 compiler 的modifiedFilesremovedFiles来获取对应的文件。由于每次文件变化 Webpack 都会重新执行一次 compilation,因此modifiedFilesremovedFiles也对应更新。

modifiedFiles是 Webpack 5 新增的属性。

resolveId

Rollup 的resolveId存在三个入参sourceimporteroptions :

type ResolveIdHook = (
    source: string,
    importer: string | undefined,
    options: {
    
    
        assertions: Record<string, string>;
        custom?: {
    
     [plugin: string]: any };
        isEntry: boolean;
    }
) => ResolveIdResult;

Webpack 中 resolve 相关概念位于 config 中的 resolve 对象,比较常见的设置如 alias。Webpack 对 resolve 专门提供了一个插件的设置,它不同于普通的 plugin,属于ResolvePluginInstance,unplugin 利用这个设置传入resolveId函数。

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 })
    // ...
  }
// ...
compiler.options.resolve.plugins = compiler.options.resolve.plugins || []
compiler.options.resolve.plugins.push(resolverPlugin)

可以看到idimporter都来自于resolve这个钩子函数传入的参数,可惜在 webpack 文档中缺乏相关说明。options参数中,只提供了isEntry属性。最后我们看到resolverPlugin被手动创建出来后,放进了 compiler options 中。可见 webpack 插件的能力包括修改 config 文件,能力其实完全覆盖了 loader,这在后续的loadtransform函数中同样能见到。

从源码中我们会看到 virtual module 相关的代码,本文为简化场景会略过。下同。

load

Webpack 中 loader 定义在 config 中,例如:

module.exports = {
    
    
  module: {
    
    
    rules: [{
    
     test: /.txt$/, use: 'raw-loader' }],
  },
};

用正则表示文件类型,然后指定 loader。Unplugin 通过手动实现一个 loader,然后插入 rules 来实现load的功能:

if (plugin.load) {
    
    
  compiler.options.module.rules.unshift({
    
    
    include(id) {
    
    
      if (id.startsWith(plugin.__virtualModulePrefix))
        id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))

      // load include filter
      if (plugin.loadInclude && !plugin.loadInclude(id)) return false

      // Don't run load hook for external modules
      return !externalModules.has(id)
    },
    enforce: plugin.enforce,
    use: [
      {
    
    
        loader: LOAD_LOADER,
        options: {
    
    
          unpluginName: plugin.name,
        },
      },
    ],
  })
}

Loader 除了用test正则匹配外,也支持用函数过滤,以被 import 的资源 path 为入参,这正是loadInclude的设计来源。从上述代码中我还会发现一个属性enforce,它是用来控制 loader 执行时机的。这也是 unplugin 要用unshift插入 rules 数组的原因。(默认最后加载 unplugin 插件)

具体看下LOAD_LOADER实现:

export default async function load(this: LoaderContext<any>, source: string, map: any) {
    
    
  const callback = this.async()
  const {
    
     unpluginName } = this.query
  const plugin = this._compiler?.$unpluginContext[unpluginName]
  let id = this.resource

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

  const context: UnpluginContext = {
    
    
    error: error => this.emitError(typeof error === 'string' ? new Error(error) : error),
    warn: error => this.emitWarning(typeof error === 'string' ? new Error(error) : error),
  }

  if (id.startsWith(plugin.__virtualModulePrefix))
    id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))

  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)
}

通过 webpack 上下文,可以拿到资源 id、找到对应 unplugin 插件。接着就是提供 unplugin 自身上下文然后调用load函数。根据 Rollup 对load返回结果的定义,调用callback传参。

transform

transform 函数和load函数类似,同样是自定义一个 loader,然后插入rules。唯一的区别是处理 transform 逻辑时,没有用到 include 函数,而是在 use 函数中再执行transformInclude进行过滤。(这是令人困惑的地方,因为这和前文所述 unplugin 设计transformInclude的理由矛盾。没有 include 函数会导致所有模块都被插件加载)

Unplugin会先处理 transform 逻辑,由于用 unshift 插入 rules,会导致load生成的 rule 在transform 之前,按照 webpack 默认的加载 loader 顺序,transform 会先于 load 被触发。不知是 bug 还是 unplugin 的预期行为。

buildEnd

buildEnd对应 webpack 的emit钩子函数。

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

writeBundle

writeBundle对应 webpack 的afterEmit钩子函数。没有任何传参和上下文的调用,意味着拿不到所有 bundler 创建出的文件。

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

总结

我们可以发现 unplugin 实际用到的 webpack hooks 只有三个:make, emitafterEmitloadtransform的功能由 webpack loader 所承载。make对 webpack 是一个很关键钩子函数,它表明了 webpack 一系列初始化的工作已完成,开始从入口文件出发编译每一个模块。

webpack 的插件系统对比 rollup 来说,一大特点是钩子函数特别多。除了本文提到的 compiler 具有钩子函数外,包括 compilation、ContextModuleFactory、 NormalModuleFactory、甚至 JavaScript 的 parser 都有一系列钩子函数。同时 plugin、loader、resolver 分离的设计也增加了系统的复杂度。因此网络上的观点更偏爱于 rollup 也不无道理。复杂的系统不代表不好,但是无疑增加了用户的学习成本。

关于 OpenTiny

OpenTiny 是一套企业级组件库解决方案,适配 PC 端 / 移动端等多端,涵盖 Vue2 / Vue3 / Angular 多技术栈,拥有主题配置系统 / 中后台模板 / CLI 命令行等效率提升工具,可帮助开发者高效开发 Web 应用。

核心亮点:

  1. 跨端跨框架:使用 Renderless 无渲染组件设计架构,实现了一套代码同时支持 Vue2 / Vue3,PC / Mobile 端,并支持函数级别的逻辑定制和全模板替换,灵活性好、二次开发能力强。
  2. 组件丰富:PC 端有80+组件,移动端有30+组件,包含高频组件 Table、Tree、Select 等,内置虚拟滚动,保证大数据场景下的流畅体验,除了业界常见组件之外,我们还提供了一些独有的特色组件,如:Split 面板分割器、IpAddress IP地址输入框、Calendar 日历、Crop 图片裁切等
  3. 配置式组件:组件支持模板式和配置式两种使用方式,适合低代码平台,目前团队已经将 OpenTiny 集成到内部的低代码平台,针对低码平台做了大量优化
  4. 周边生态齐全:提供了基于 Angular + TypeScript 的 TinyNG 组件库,提供包含 10+ 实用功能、20+ 典型页面的 TinyPro 中后台模板,提供覆盖前端开发全流程的 TinyCLI 工程化工具,提供强大的在线主题配置平台 TinyTheme

联系我们:

更多视频内容也可以关注OpenTiny社区,B站/抖音/小红书/视频号。

猜你喜欢

转载自blog.csdn.net/OpenTiny/article/details/132163981