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

語言: CN / TW / HK

我們知道,Vite 開發時,用的是 esbuild 進行構建,而在生產環境,則是使用 Rollup 進行打包。

為什麼生產環境仍需要打包?為什麼不用 esbuild 打包?

Vite 官方文檔已經做出解析:儘管原生 ESM 現在得到了廣泛支持,但由於嵌套導入會導致額外的網絡往返,在生產環境中發佈未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。為了在生產環境中獲得最佳的加載性能,最好還是將代碼進行 tree-shaking、懶加載和 chunk 分割(以獲得更好的緩存)

雖然 esbuild 快得驚人,並且已經是一個在構建庫方面比較出色的工具,但一些針對構建應用的重要功能仍然還在持續開發中 —— 特別是代碼分割和 CSS 處理方面。就目前來説,Rollup 在應用打包方面更加成熟和靈活。儘管如此,當未來這些功能穩定後,我們也不排除使用 esbuild 作為生產構建器的可能。

由於生產環境的打包,使用的是 Rollup,Vite 需要保證,同一套 Vite 配置文件和源碼,在開發環境和生產環境下的表現是一致的

想要達到這個效果,只能是 Vite 在開發環境模擬 Rollup 的行為 ,在生產環境打包時,將這部分替換成 Rollup 打包

image-20220614195140001

Vite 兼容了什麼

要講 Vite 如何進行兼容之前,首先要搞清楚,兼容了什麼?

我們用一個例子來類比一下:

image-20220614201308858

我們可以得到一下信息:

  • 洗烘一體機可以替代洗衣機,它們能做到一樣的效果
  • 洗烘一體機,可以使用洗衣機的生態

這時候我們可以説,洗烘一體機,兼容洗衣機的生態,洗烘一體機能完全替代洗衣機

兼容關係,是不同層級的東西進行兼容。

替代關係,是同一層級的東西進行替代

那回到 vite,我們根據 Rollup 和 Vite 的關係,可以推出:

  • Vite 不是兼容 rollup,説兼容 Rollup 其實是不嚴謹的
  • Vite 是部分兼容 Rollup 的插件生態
  • Vite 可以做到部分替代 Rollup

image-20220614203433307

這裏強調一下,是部分兼容、部分替代,不是完全的,因為 Vite 的部分實現是與 Rollup 不同的

如何兼容 Rollup 的插件生態

想要兼容 Rollup 生態,就必須要實現 Rollup 的插件機制

Rollup 插件是什麼?

Rollup 插件是一個對象,對象具有一個或多個屬性、build 構建鈎子output generation 輸出生成鈎子

插件應該作為一個包分發,它導出一個可以傳入特定選項對象的函數,並返回一個對象

下面是一個簡單的例子:

```typescript // rollup-plugin-my-example.js export default function myExample () { return { name: 'my-example', resolveId ( source ) { if (source === 'virtual-module') { return source; // 這表明 Rollup 不應該檢查文件系統來找到這個模塊的 id } return null; // 其他模塊照常處理 }, load ( id ) { if (id === 'virtual-module') { return 'export default "This is virtual!"'; // 返回 "virtual-module" 的代碼 } 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 一樣,能夠解析插件對象,並對插件的鈎子進行正確的執行和處理

image-20220614205848096

這需要 Vite 在其內部,實現一個模擬的 Rollup 插件機制,實現跟 Rollup 一樣的對外的插件行為,才能兼容 Rollup 的插件生態

Vite 裏面包含的一個模擬 rollup,由於只模擬插件部分,因此在 Vite 源碼中,它被稱為 PluginContainer(插件容器)

微觀層面的實現

實現 Rollup 的插件行為,實際上是實現相同的插件鈎子行為。

插件鈎子是在構建的不同階段調用的函數。鈎子可以影響構建的運行方式提供有關構建的信息或在構建完成後修改構建

鈎子行為,主要包括以下內容:

  • 實現 Rollup 插件鈎子的調度
  • 提供 Rollup 鈎子的 Context 上下文對象
  • 對鈎子的返回值進行相應處理
  • 實現鈎子的類型

什麼是鈎子的調度?

按照一定的規則,在構建對應的階段,執行對應的鈎子。

例如:當 Rollup 開始運行時,會先調用 options 鈎子,然後是 buildStart

下圖為 Rollup 的 build 構建鈎子(output generation 輸出生成鈎子不在下圖)

image-20220614212338842

什麼是鈎子的 Context 上下文對象?

在 Rollup 的鈎子函數中,可以調用 this.xxx 來使用一些 Rollup 提供的實用工具函數,Context 提供屬性/方法可以參考 Rollup 官方文檔

而這個 this 就是鈎子的 Context 上下文對象。

Vite 需要在運行時,實現一套相同的 Context 上下文對象,才能保證插件能夠正確地執行 Context 上下文對象的屬性/方法。

什麼是對鈎子的返回值做相應的處理?

部分鈎子的返回值,是會影響到 Rollup 的行為。

例如:

typescript 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 在其運行過程中,使用插件容器的方法實現的

插件容器的簡化實現如下:

```typescript 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 上下文對象,提供了很多實用工具函數

```typescript class Context implements PluginContext {

parse(code: string, opts: any = {}) { // 省略實現 }

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

// ...省略 } ```

我們大概知道有這麼個東西就行了,不需要知道具體的實現工具函數是怎麼實現的。感興趣的可以查看 Rollup 文檔

插件的調度是如何實現的?

插件容器要怎麼使用?

這兩個問題,其實是同一個問題,當需要調度時,就要使用插件容器了。

例如:當 Server 啟動時,會調用 listen 函數進行端口監聽,這時候就會調用 containerbuildStart 函數,執行插件的 buildStart 鈎子

typescript 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 能力

最後

如果這篇文章對您有所幫助,請幫忙點個贊👍,您的鼓勵是我創作路上的最大的動力。