[Vite]Vite插件生命周期了解
Chunk和Bundle的概念
-
Chunk:
- 在 Vite 中,
chunk
通常指的是應用程序中的一個代碼片段,它是通過 Rollup 或其他打包工具在構建過程中生成的。每個 chunk 通常包含應用程序的一部分邏輯,可能是一個路由視圖、一個組件或一組相關的模塊。 - 與 Webpack 類似,Vite 也支持代碼分割(Code Splitting),可以將代碼拆分成不同的 chunk 以實現按需加載,從而優化應用的性能和加載時間。
- 在 Vite 中,
-
Bundle:
bundle
是指最終的輸出文件,它是通過打包工具將多個模塊、庫和資源組合在一起形成的。在 Vite 的生產構建中,Rollup 會將應用程序的代碼和依賴項打包成一個或多個 bundle。- Bundle 可以包含一個或多個 chunk,并且可能還會包括一些額外的元數據和輔助代碼,如運行時代碼、模塊的映射信息等。
- Vite 的輸出 bundle 旨在優化生產環境的加載和執行,通常會進行壓縮、樹搖(Tree Shaking)和模塊的合并等操作。
vite插件鉤子
Vite的插件可以有兩種形式,一種是vite插件,僅供vite使用;另一種則是rollup通用插件,它不使用 Vite 特有的鉤子,讓我們簡單介紹一下關于這兩種插件的生命周期:
所有鉤子
- apply - 插件的入口點,Vite 會調用每個插件的
apply
函數,傳入 Vite 的配置對象。 - config - 在 Vite 的配置被最終確定之前,允許插件修改配置。此鉤子接收當前配置并應返回新的配置。
- configResolved - 在配置被解析并確定后調用,允許插件訪問最終的配置。
- configureServer - 允許插件配置或修改 Vite 的開發服務器。
- transform - 在開發階段,Vite 調用此鉤子來請求插件對特定文件進行轉換。
- render - 在開發階段,Vite 調用此鉤子來請求插件對 HTML 模板進行渲染。
- buildStart - 在構建開始之前調用。
- build - 在構建過程中調用,允許插件參與構建流程。
- generateBundle - 在構建結束時調用,允許插件訪問或修改最終生成的 bundle。
- closeBundle - 在構建過程中,當一個 bundle 被寫入磁盤后調用。
- writeBundle - 在構建過程中,當 bundle 準備寫入磁盤時調用。
- optimizeDeps - 允許插件優化依賴,例如決定哪些依賴應該被包含在客戶端。
- load - 允許插件提供一個模塊的加載內容,而不是從文件系統中加載。
- resolveId - 允許插件介入模塊 ID 的解析過程。
- shouldHandleRequest - 允許插件決定是否處理特定的請求。
- handleHotUpdate - 在 HMR(熱模塊替換)過程中,允許插件處理更新。
- transformIndexHtml - 在開發階段,允許插件修改 HTML 模板。
- enforce - 指定插件應用的時機,可以是
'pre'
或'post'
,分別表示在 Vite 默認插件之前或之后執行。
1. vite 獨有的鉤子
enforce
:值可以是pre
或post
,pre
會較于post
先執行;apply
:值可以是build
或serve
亦可以是一個函數,指明它們僅在build
或serve
模式時調用;config(config, env)
:可以在 vite 被解析之前修改 vite 的相關配置。鉤子接收原始用戶配置 config 和一個描述配置環境的變量env;configResolved(resolvedConfig)
:在解析 vite 配置后調用。使用這個鉤子讀取和存儲最終解析的配置。當插件需要根據運行的命令做一些不同的事情時,它很有用。configureServer(server)
:主要用來配置開發服務器,為 dev-server (connect 應用程序) 添加自定義的中間件;transformIndexHtml(html)
:轉換 index.html 的專用鉤子。鉤子接收當前的 HTML 字符串和轉換上下文;handleHotUpdate(ctx)
:執行自定義HMR更新,可以通過ws往客戶端發送自定義的事件;
2. vite 與 rollup 的通用鉤子之構建階段
options(options)
:在服務器啟動時被調用:獲取、操縱Rollup選項,嚴格意義上來講,它執行于屬于構建階段之前;buildStart(options)
:在每次開始構建時調用;resolveId(source, importer, options)
:在每個傳入模塊請求時被調用,創建自定義確認函數,可以用來定位第三方依賴;load(id)
:在每個傳入模塊請求時被調用,可以自定義加載器,可用來返回自定義的內容;transform(code, id)
:在每個傳入模塊請求時被調用,主要是用來轉換單個模塊;buildEnd(error?: Error)
:在構建階段結束后被調用,此處構建結束只是代表所有模塊轉義完成;
3. vite 與 rollup 的通用鉤子之輸出階段
outputOptions(options)
:接受輸出參數;renderStart(outputOptions, inputOptions)
:每次 bundle.generate 和 bundle.write 調用時都會被觸發;augmentChunkHash(chunkInfo)
:用來給 chunk 增加 hash;renderChunk(code, chunk, options)
:轉譯單個的chunk時觸發。rollup 輸出每一個chunk文件的時候都會調用;generateBundle(options, bundle, isWrite)
:在調用 bundle.write 之前立即觸發這個 hook;writeBundle(options, bundle)
:在調用 bundle.write后,所有的chunk都寫入文件后,最后會調用一次 writeBundle;closeBundle()
:在服務器關閉時被調用
4. 插件鉤子函數 hooks 的執行順序
按照順序,首先是配置解析相關:
- config:vite專有
- configResolved :vite專有
- options
- configureServer :vite專有
接下來是構建階段的鉤子:
- buildStart
- Resolved
- load
- transform
- buildEnd
然后是輸出階段的鉤子:
- outputOptions
- renderStart
- augmentChunkHash
- renderChunk
- generateBundle
- transformIndexHtml
- writeBundle
- closeBundle
5. 插件的執行順序
- 別名處理Alias
- 用戶插件設置
enforce: 'pre'
- vite 核心插件
- 用戶插件未設置
enforce
- vite 構建插件
- 用戶插件設置
enforce: 'post'
- vite 構建后置插件(minify, manifest, reporting)
舉例
統計打包后dist大小
實現一個統計打包后dist大小的插件
主要使用的是closeBundle這個鉤子。
import fs from 'fs'
import path from 'path'
import { Plugin } from 'vite'function getFolderSize(folderPath: string): number {if (!fs.existsSync(folderPath) || !fs.lstatSync(folderPath).isDirectory()) {return 0}let totalSize = 0const files = fs.readdirSync(folderPath)files.forEach(file => {const filePath = path.join(folderPath, file)const stats = fs.statSync(filePath)if (stats.isFile()) {totalSize += stats.size} else if (stats.isDirectory()) {totalSize += getFolderSize(filePath)}})return totalSize
}function formatBytes(bytes: number, decimals: number = 2): string {if (bytes === 0) return '0.00'const megabytes = bytes / (1024 * 1024)return megabytes.toFixed(decimals)
}function calculateDistSizePlugin(): Plugin {let distPath = ''return {name: 'calculate-dist-size',enforce: 'post' as const,apply: 'build' as const,configResolved(config) {// 可以在這里獲取打包輸出的目錄const outDir = config.build.outDirdistPath = outDir},closeBundle() {if (!distPath) {console.error('Fail to get size of dist folder.')return}const distSize = getFolderSize(distPath)const formattedSize = formatBytes(distSize)console.log(`Size of dist folder: ${formattedSize} MB`)}}
}export default calculateDistSizePlugin
自己實現的React熱更新+SWC打包插件
這個插件利用 SWC 來編譯 JavaScript 和 TypeScript 代碼,并在 Vite 開發服務器中提供 React JSX熱更新。此外,它還處理構建配置,以確保代碼被正確地編譯為適用于生產環境的格式。
import { Output, ParserConfig, ReactConfig, transform } from '@swc/core'
import { readFileSync, readdirSync } from 'fs'
import { SourceMapPayload } from 'module'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { BuildOptions, PluginOption, UserConfig, createFilter } from 'vite'
import { createRequire } from 'node:module'
import { ViteOptions } from './type'
import { runtimePublicPath, preambleCode, refreshContentRE } from './const'const _dirname = typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url))const _resolve = typeof global.require !== 'undefined' ? global.require.resolve : createRequire(import.meta.url).resolveconst plugin = (_options?: ViteOptions): PluginOption[] => {const options = {..._options,target: _options?.target || 'es2017',jsxImportSource: _options?.jsxImportSource || 'react'}// vite配置中的buildTargetconst buildTarget = options.targetconst filter = options.include || options.exclude ? createFilter(options.include, options.exclude) : null// 核心函數:根據配置調用SWC編譯代碼const transformWithSwc = async (fileName: string, code: string, reactConfig: ReactConfig) => {if ((!filter && fileName.includes('node_modules')) || (filter && !filter(fileName))) returnconst decorators = trueconst parser: ParserConfig | undefined = fileName.endsWith('.tsx')? { syntax: 'typescript', tsx: true, decorators }: fileName.endsWith('.ts')? { syntax: 'typescript', tsx: false, decorators }: fileName.endsWith('.jsx')? { syntax: 'ecmascript', jsx: true }: fileName.endsWith('.mdx')? // JSX is required to trigger fast refresh transformations, even if MDX already transforms it{ syntax: 'ecmascript', jsx: true }: undefinedif (!parser) returnlet result: Outputtry {const swcTransformConfig: any = {// 允許被配置文件覆蓋swcrc: true,rootMode: 'upward-optional',filename: fileName,minify: false,jsc: {// target: buildTarget,parser,transform: {useDefineForClassFields: false,react: {...reactConfig,useBuiltins: true}}},env: {targets: {safari: '11',edge: '79',chrome: '73'},mode: 'usage',coreJs: '3.36'}}// 兩者不兼容,只能取其一if (swcTransformConfig.env && swcTransformConfig.jsc.target) {delete swcTransformConfig.jsc.target}result = await transform(code, swcTransformConfig)} catch (e: any) {// 輸出錯誤信息const message: string = e.messageconst fileStartIndex = message.indexOf('╭─[')if (fileStartIndex !== -1) {const match = message.slice(fileStartIndex).match(/:(\d+):(\d+)]/)if (match) {e.line = match[1]e.column = match[2]}}throw e}return result}const silenceUseClientWarning = (userConfig: UserConfig): BuildOptions => ({rollupOptions: {onwarn(warning, defaultHandler) {if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {return}if (userConfig.build?.rollupOptions?.onwarn) {userConfig.build.rollupOptions.onwarn(warning, defaultHandler)} else {defaultHandler(warning)}}}})const resolveSwcHelpersDeps = () => {let helperList: string[] = []try {const file = _resolve('@swc/helpers')if (file) {const dir = dirname(file)const files = readdirSync(dir)helperList = files.map(file => join(dir, file))}} catch (e) {console.error(e)}return helperList}return [// dev時熱更新1:加載熱更新功能{name: 'vite:swc:resolve-runtime',apply: 'serve',enforce: 'pre', // Run before Vite default resolve to avoid syscallsresolveId: id => (id === runtimePublicPath ? id : undefined),load: id => (id === runtimePublicPath ? readFileSync(join(_dirname, 'refresh-runtime.js'), 'utf-8') : undefined)},// dev時熱更新2:熱更新核心插件{name: 'vite:swc',apply: 'serve',config: userConfig => {const userOptimizeDepsConfig = userConfig?.optimizeDeps?.disabledconst optimizeDepsDisabled = userOptimizeDepsConfig === true || userOptimizeDepsConfig === 'dev'// 預編譯列表const optimizeDeps = !optimizeDepsDisabled? ['react', `${options.jsxImportSource}/jsx-dev-runtime`, ...resolveSwcHelpersDeps()]: undefinedreturn {esbuild: false,optimizeDeps: {include: optimizeDeps,esbuildOptions: {target: buildTarget,supported: {decorators: true // esbuild 0.19在使用target為es2017時,預構建會報錯,這里假定目標瀏覽器支持裝飾器,避開報錯}}},resolve: {dedupe: ['react', 'react-dom']}}},transformIndexHtml: (_, config) => [{tag: 'script',attrs: { type: 'module' },children: preambleCode.replace('__PATH__', config.server!.config.base + runtimePublicPath.slice(1))}],async transform(code, _id, transformOptions) {const id = _id.split('?')[0]const result = await transformWithSwc(id, code, {refresh: !transformOptions?.ssr,development: true,runtime: 'automatic',importSource: options.jsxImportSource})if (!result) returnif (transformOptions?.ssr || !refreshContentRE.test(result.code)) {return result}result.code = /*js*/ `import * as RefreshRuntime from "${runtimePublicPath}";if (!window.$RefreshReg$) throw new Error("React refresh preamble was not loaded. Something is wrong.");const prevRefreshReg = window.$RefreshReg$;const prevRefreshSig = window.$RefreshSig$;window.$RefreshReg$ = RefreshRuntime.getRefreshReg("${id}");window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;${result.code}window.$RefreshReg$ = prevRefreshReg;window.$RefreshSig$ = prevRefreshSig;RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {RefreshRuntime.registerExportsForReactRefresh("${id}", currentExports);import.meta.hot.accept((nextExports) => {if (!nextExports) return;const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports);if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);});});`if (result.map) {const sourceMap: SourceMapPayload = JSON.parse(result.map)sourceMap.mappings = ';;;;;;;;' + sourceMap.mappingsreturn { code: result.code, map: sourceMap }} else {return { code: result.code }}}},// 打包時候使用的插件{name: 'vite:swc',apply: 'build',enforce: 'post', // Run before esbuildconfig: userConfig => ({build: {...silenceUseClientWarning(userConfig),target: buildTarget},resolve: {dedupe: ['react', 'react-dom']}}),transform: (code, _id) =>transformWithSwc(_id.split('?')[0], code, {runtime: 'automatic',importSource: options.jsxImportSource})}]
}export default plugin
- 導入依賴:代碼開始部分導入了所需的 Node.js 內置模塊和第三方庫,以及 Vite 和 SWC 的相關 API。
- 定義插件函數:
plugin
函數接受一個可能包含用戶配置的對象_options
,并返回一個 Vite 插件數組。 - 配置選項合并:函數內部,用戶配置與默認配置合并,以設置插件的行為,例如編譯目標
target
和 JSX 導入源jsxImportSource
。 - 創建過濾器:如果用戶提供了
include
或exclude
規則,會創建一個過濾器filter
,用于決定哪些文件應該被插件處理。 - SWC 轉換函數:
transformWithSwc
異步函數接收文件名、代碼和 React 配置,調用 SWC 的transform
API 來編譯代碼。 - 錯誤處理:在 SWC 轉換過程中,如果出現錯誤,會嘗試提取錯誤消息中的錯誤行和列,并重新拋出格式化后的錯誤。
- 靜默警告:
silenceUseClientWarning
函數用于抑制 Rollup 的某些警告,特別是與'use client'
指令相關的警告。 - 解析 SWC 輔助依賴:
resolveSwcHelpersDeps
函數嘗試解析@swc/helpers
包中的輔助函數文件列表。 - 定義 Vite 插件對象:返回的插件數組中包括兩個主要的插件對象,一個用于開發環境,另一個用于構建環境。
- 開發環境插件:
- 設置了
name
、apply
、config
、transformIndexHtml
和transform
屬性來定義插件的行為。 - 使用
resolveId
和load
處理 React 快速刷新的運行時腳本。 transform
方法用于對代碼進行轉換,并添加了 React 快速刷新的相關代碼。
- 設置了
- 構建環境插件:
- 設置了
name
、apply
、config
和transform
屬性。 - 在構建配置中應用了靜默警告配置,并指定了編譯目標。
- 設置了
- React 快速刷新:在開發環境中,插件通過修改
transformIndexHtml
和transform
方法來支持 React 快速刷新。 - 導出默認:最后,
plugin
函數作為默認導出,使其可以在 Vite 配置中使用。
參考文章和地址
https://juejin.cn/post/7103165205483356168?searchId=20240706212202081D45CDF4733CF7923F#heading-17
https://article.juejin.cn/post/7211745375920586813
https://cn.vitejs.dev/