Vite 是如何兼容 Rollup 插件生态的

2022-07-18 11:56:06 浏览数 (1)

我们知道,Vite 开发时,用的是 esbuild 进行构建,而在生产环境,则是使用 Rollup 进行打包。

为什么生产环境仍需要打包?为什么不用 esbuild 打包?

Vite 官方文档[1]已经做出解析:尽管原生 ESM 现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking、懒加载和 chunk 分割(以获得更好的缓存)

虽然 esbuild 快得惊人,并且已经是一个在构建库方面比较出色的工具,但一些针对构建应用的重要功能仍然还在持续开发中 —— 特别是代码分割和 CSS 处理方面。就目前来说,Rollup 在应用打包方面更加成熟和灵活。尽管如此,当未来这些功能稳定后,我们也不排除使用 esbuild 作为生产构建器的可能。

由于生产环境的打包,使用的是 Rollup,Vite 需要保证,同一套 Vite 配置文件和源码,在开发环境和生产环境下的表现是一致的

想要达到这个效果,只能是 Vite 在开发环境模拟 Rollup 的行为 ,在生产环境打包时,将这部分替换成 Rollup 打包

Vite 兼容了什么

要讲 Vite 如何进行兼容之前,首先要搞清楚,兼容了什么?

我们用一个例子来类比一下:

我们可以得到一下信息:

•洗烘一体机可以替代洗衣机,它们能做到一样的效果•洗烘一体机,可以使用洗衣机的生态

这时候我们可以说,洗烘一体机,兼容洗衣机的生态,洗烘一体机能完全替代洗衣机

兼容关系,是不同层级的东西进行兼容。

替代关系,是同一层级的东西进行替代

那回到 vite,我们根据 Rollup 和 Vite 的关系,可以推出:

•Vite 不是兼容 rollup,说兼容 Rollup 其实是不严谨的•Vite 是部分兼容 Rollup 的插件生态•Vite 可以做到部分替代 Rollup

这里强调一下,是部分兼容、部分替代,不是完全的,因为 Vite 的部分实现是与 Rollup 不同的

如何兼容 Rollup 的插件生态

想要兼容 Rollup 生态,就必须要实现 Rollup 的插件机制

Rollup 插件是什么?

Rollup 插件是一个对象,对象具有一个或多个属性、build 构建钩子output generation 输出生成钩子

插件应该作为一个包分发,它导出一个可以传入特定选项对象的函数,并返回一个对象

下面是一个简单的例子:

代码语言:javascript复制
// rollup-plugin-my-example.js
export default function myExample () {
  return {
    name: 'my-example',
    resolveId ( source ) {
      if (source === 'virtual-module') {
        // 这表明 Rollup 不应该检查文件系统来找到这个模块的 id
        return source; 
      }
      // 其他模块照常处理
      return null; 
    },
    load ( id ) {
      if (id === 'virtual-module') {
        // 返回 "virtual-module" 的代码
        return 'export default "This is virtual!"'; 
      }
      // 其他模块照常处理
      return null; 
    }
  };
}

// rollup.config.js
import myExample from './rollup-plugin-my-example.js';
export default ({
  input: 'virtual-module', 
  // 使用插件
  plugins: [myExample()],    
  output: [{
    file: 'bundle.js',
    format: 'es'
  }]
});

// bundle.js
import text from "virtual-module"
console.log(text)    
// 输出:This is virtual!

import text from "virtual-module" 时,相当于引入了这段代码:export default "This is virtual!"

宏观层面的兼容架构

Vite 需要兼容 Rollup 插件生态,就需要 Vite 能够像 Rollup 一样,能够解析插件对象,并对插件的钩子进行正确的执行和处理

这需要 Vite 在其内部,实现一个模拟的 Rollup 插件机制,实现跟 Rollup 一样的对外的插件行为,才能兼容 Rollup 的插件生态

Vite 里面包含的一个模拟 rollup,由于只模拟插件部分,因此在 Vite 源码中,它被称为 PluginContainer(插件容器)

微观层面的实现

实现 Rollup 的插件行为,实际上是实现相同的插件钩子行为。

插件钩子是在构建的不同阶段调用的函数。钩子可以影响构建的运行方式提供有关构建的信息或在构建完成后修改构建

钩子行为,主要包括以下内容:

•实现 Rollup 插件钩子的调度•提供 Rollup 钩子的 Context 上下文对象•对钩子的返回值进行相应处理•实现钩子的类型

什么是钩子的调度?

按照一定的规则,在构建对应的阶段,执行对应的钩子。

例如:当 Rollup 开始运行时,会先调用 options 钩子,然后是 buildStart

下图为 Rollup 的 build 构建钩子(output generation 输出生成钩子不在下图)

什么是钩子的 Context 上下文对象?

在 Rollup 的钩子函数中,可以调用 this.xxx 来使用一些 Rollup 提供的实用工具函数,Context 提供属性/方法可以参考 Rollup 官方文档[2]

而这个 this 就是钩子的 Context 上下文对象。

Vite 需要在运行时,实现一套相同的 Context 上下文对象,才能保证插件能够正确地执行 Context 上下文对象的属性/方法。

什么是对钩子的返回值做相应的处理?

部分钩子的返回值,是会影响到 Rollup 的行为。

例如:

代码语言:javascript复制
export default function myExample () {
  return {
    name: 'my-example',
    options(options) {
      // 修改 options
      return options
    }
  };
}

options 钩子的返回值,会覆盖当前 Rollup 当前的运行配置,从而影响到 Rollup 的行为。

Vite 同样需要实现这个行为 —— 根据返回值做相应的处理。每个钩子的返回值(如果有),对应的处理是不同的,都需要实现

什么是钩子类型?

钩子分为 4 种类型:

async:钩子函数可以是 async 异步的,返回 Promise•first:如果多个插件都实现了这个钩子,那么这些钩子会依次运行直到一个钩子返回的不是 null 或 undefined的值为止。•sequential:如果有几个插件实现了这个钩子,串行执行这些钩子•parallel:如果多个插件都实现了这个钩子,并行执行这些钩子

例如: options 钩子,是 asyncsequential 类型,options 钩子可以是异步的,且是串行执行的,因为配置会按顺序依次被覆盖修改,如果是并行执行 options,那么最终的配置就会不可控

Vite 同样需要实现这些钩子类型

插件容器

前面小节已经说过,插件容器,是一个小的 Rollup,实现了 Rollup 的插件机制

插件容器实现的功能如下:

•提供 Rollup 钩子的 Context 上下文对象•对钩子的返回值进行相应处理•实现钩子的类型

注意:插件容器的实现,不包含调度。调度是 Vite 在其运行过程中,使用插件容器的方法实现的

插件容器的简化实现如下:

代码语言:javascript复制
const container = {

  // 钩子类型:异步、串行
  options: await (async () => {
    let options = rollupOptions
    for (const plugin of plugins) {
      if (!plugin.options) continue
      // 实现钩子类型:await 实现和异步和串行,下一个 options 钩子,需要等待当前钩子执行完成
      // 实现对返回值进行处理:options 钩子返回值,覆盖当前 options
      options = (
        await plugin.options.call(
          minimalContext, options
        )) 
        || options
    }
    return options;
  })(),

  // 钩子类型:异步、并行
  async buildStart() {
    // 实现并行的钩子类型:用 Promise.all 执行
    await Promise.all(
      plugins.map((plugin) => {
        if (plugin.buildStart) {
          return plugin.buildStart.call(
            new Context(plugin) as any,
            container.options as NormalizedInputOptions
          )
        }
      })
    )
  },
  // 钩子类型:异步、first 优先 
  async resolveId(rawId, importer) {
    // 上下文对象,后文介绍
    const ctx = new Context()

    let id: string | null = null
    const partial: Partial<PartialResolvedId> = {}
    for (const plugin of plugins) {
      const result = await plugin.resolveId.call(
        ctx as any,
        rawId,
        importer,
        { ssr }
      )
      // 如果有函数返回值 result,就直接 return,不执行后面钩子了
      if (!result) continue;
      return result;
    }
  }
  // 钩子类型:异步、优先
  async load(id, options) {
    const ctx = new Context()
    for (const plugin of plugins) {
      const result = await plugin.load.call(ctx as any, id, { ssr })
      if (result != null) {
        return result
      }
    }
    return null
  },
  // 钩子类型:异步、串行
  async transform(code, id, options) {

    // transform 钩子的上下文对象,不太一样,因为多了一些需要处理的工具函数。不需要深究
    const ctx = new TransformContext(id, code, map as SourceMap)

    for (const plugin of plugins) {
      let result: TransformResult | string | undefined
      try {
        result = await plugin.transform.call(ctx, code, id)
      } catch (e) {
        ctx.error(e)
      }
      if (!result) continue;
      code = result;
    }
    return {
      code,
      map: ctx._getCombinedSourcemap()
    }
  },

  // ...省略 buildEnd 和 closeBundle
}

上面代码,已经是实现了下面的两个内容:

•对钩子的返回值进行相应处理•实现钩子的类型

Context 上下文对象,提供了很多实用工具函数

代码语言:javascript复制
class Context implements PluginContext {

  parse(code: string, opts: any = {}) {
    // 省略实现
  }

  async resolve(
    id: string,
    importer?: string,
    options?: { skipSelf?: boolean }
  ) {
   // 省略实现
  }

  // ...省略
}

我们大概知道有这么个东西就行了,不需要知道具体的实现工具函数是怎么实现的。感兴趣的可以查看 Rollup 文档[3]

插件的调度是如何实现的? 插件容器要怎么使用?

这两个问题,其实是同一个问题,当需要调度时,就要使用插件容器了。

例如:当 Server 启动时,会调用 listen 函数进行端口监听,这时候就会调用 containerbuildStart 函数,执行插件的 buildStart 钩子

代码语言:javascript复制
httpServer.listen = (async (port: number, ...args: any[]) => {
  if (!isOptimized) {
    try {
      await container.buildStart({})
      // 其他逻辑
    } catch (e) {
      httpServer.emit('error', e)
      return
    }
  }
  return listen(port, ...args)
})

这就是在构建对应的阶段,执行对应的钩子

而在哪些阶段,分别调用了什么钩子,本篇文章则不过多介绍了

总结

至此,Vite 兼容 Rollup 的方式已经讲完了~

我们先介绍了兼容的概念, Vite 兼容的是 Rollup 插件生态,而不是 Rollup 这个工具。从而得出,Vite 需要实现 Rollup 插件生态的结论

然后围绕 Rollup 插件生态,我们介绍了什么是 Rollup 插件钩子,并从宏观和微观,分别介绍了兼容的架构(PluginContainer)和需要实现的细节:

•实现 Rollup 插件钩子的调度•提供 Rollup 钩子的 Context 上下文对象•对钩子的返回值进行相应处理•实现钩子的类型

最后用简单的代码,实现了一个 PluginContainer,并介绍了,如何实现插件钩子的调度。

学完本篇内容,大概也就知道了 Rollup 钩子的相关生态了,如果我们需要实现一套插件生态,也可以对 Rollup 进行模仿。另外也学会了,如何用一个工具,去兼容另外一套工具的生态 —— 实现其对外的 API 能力

References

[1] 官方文档: https://cn.vitejs.dev/guide/why.html#why-bundle-for-production [2] Rollup 官方文档: https://rollupjs.org/guide/en/#plugin-context [3] Rollup 文档: https://rollupjs.org/guide/en/#plugin-context

0 人点赞