[Vite]Vite插件生命周期了解

[Vite]Vite插件生命周期了解

Chunk和Bundle的概念

  1. Chunk

    • 在 Vite 中,chunk 通常指的是應用程序中的一個代碼片段,它是通過 Rollup 或其他打包工具在構建過程中生成的。每個 chunk 通常包含應用程序的一部分邏輯,可能是一個路由視圖、一個組件或一組相關的模塊。
    • 與 Webpack 類似,Vite 也支持代碼分割(Code Splitting),可以將代碼拆分成不同的 chunk 以實現按需加載,從而優化應用的性能和加載時間。
  2. Bundle

    • bundle 是指最終的輸出文件,它是通過打包工具將多個模塊、庫和資源組合在一起形成的。在 Vite 的生產構建中,Rollup 會將應用程序的代碼和依賴項打包成一個或多個 bundle。
    • Bundle 可以包含一個或多個 chunk,并且可能還會包括一些額外的元數據和輔助代碼,如運行時代碼、模塊的映射信息等。
    • Vite 的輸出 bundle 旨在優化生產環境的加載和執行,通常會進行壓縮、樹搖(Tree Shaking)和模塊的合并等操作。

vite插件鉤子

Vite的插件可以有兩種形式,一種是vite插件,僅供vite使用;另一種則是rollup通用插件,它不使用 Vite 特有的鉤子,讓我們簡單介紹一下關于這兩種插件的生命周期:

所有鉤子
  1. apply - 插件的入口點,Vite 會調用每個插件的 apply 函數,傳入 Vite 的配置對象。
  2. config - 在 Vite 的配置被最終確定之前,允許插件修改配置。此鉤子接收當前配置并應返回新的配置。
  3. configResolved - 在配置被解析并確定后調用,允許插件訪問最終的配置。
  4. configureServer - 允許插件配置或修改 Vite 的開發服務器。
  5. transform - 在開發階段,Vite 調用此鉤子來請求插件對特定文件進行轉換。
  6. render - 在開發階段,Vite 調用此鉤子來請求插件對 HTML 模板進行渲染。
  7. buildStart - 在構建開始之前調用。
  8. build - 在構建過程中調用,允許插件參與構建流程。
  9. generateBundle - 在構建結束時調用,允許插件訪問或修改最終生成的 bundle。
  10. closeBundle - 在構建過程中,當一個 bundle 被寫入磁盤后調用。
  11. writeBundle - 在構建過程中,當 bundle 準備寫入磁盤時調用。
  12. optimizeDeps - 允許插件優化依賴,例如決定哪些依賴應該被包含在客戶端。
  13. load - 允許插件提供一個模塊的加載內容,而不是從文件系統中加載。
  14. resolveId - 允許插件介入模塊 ID 的解析過程。
  15. shouldHandleRequest - 允許插件決定是否處理特定的請求。
  16. handleHotUpdate - 在 HMR(熱模塊替換)過程中,允許插件處理更新。
  17. transformIndexHtml - 在開發階段,允許插件修改 HTML 模板。
  18. enforce - 指定插件應用的時機,可以是 'pre''post',分別表示在 Vite 默認插件之前或之后執行。
1. vite 獨有的鉤子
  1. enforce :值可以是prepostpre 會較于 post 先執行;
  2. apply :值可以是 buildserve 亦可以是一個函數,指明它們僅在 buildserve 模式時調用;
  3. config(config, env) :可以在 vite 被解析之前修改 vite 的相關配置。鉤子接收原始用戶配置 config 和一個描述配置環境的變量env;
  4. configResolved(resolvedConfig) :在解析 vite 配置后調用。使用這個鉤子讀取和存儲最終解析的配置。當插件需要根據運行的命令做一些不同的事情時,它很有用。
  5. configureServer(server) :主要用來配置開發服務器,為 dev-server (connect 應用程序) 添加自定義的中間件;
  6. transformIndexHtml(html) :轉換 index.html 的專用鉤子。鉤子接收當前的 HTML 字符串和轉換上下文;
  7. handleHotUpdate(ctx):執行自定義HMR更新,可以通過ws往客戶端發送自定義的事件;
2. vite 與 rollup 的通用鉤子之構建階段
  1. options(options) :在服務器啟動時被調用:獲取、操縱Rollup選項,嚴格意義上來講,它執行于屬于構建階段之前;
  2. buildStart(options):在每次開始構建時調用;
  3. resolveId(source, importer, options):在每個傳入模塊請求時被調用,創建自定義確認函數,可以用來定位第三方依賴;
  4. load(id):在每個傳入模塊請求時被調用,可以自定義加載器,可用來返回自定義的內容;
  5. transform(code, id):在每個傳入模塊請求時被調用,主要是用來轉換單個模塊;
  6. buildEnd(error?: Error):在構建階段結束后被調用,此處構建結束只是代表所有模塊轉義完成;
3. vite 與 rollup 的通用鉤子之輸出階段
  1. outputOptions(options):接受輸出參數;
  2. renderStart(outputOptions, inputOptions):每次 bundle.generate 和 bundle.write 調用時都會被觸發;
  3. augmentChunkHash(chunkInfo):用來給 chunk 增加 hash;
  4. renderChunk(code, chunk, options):轉譯單個的chunk時觸發。rollup 輸出每一個chunk文件的時候都會調用;
  5. generateBundle(options, bundle, isWrite):在調用 bundle.write 之前立即觸發這個 hook;
  6. writeBundle(options, bundle):在調用 bundle.write后,所有的chunk都寫入文件后,最后會調用一次 writeBundle;
  7. closeBundle():在服務器關閉時被調用
4. 插件鉤子函數 hooks 的執行順序

vite插件開發鉤子函數 (1).png

按照順序,首先是配置解析相關:

  1. config:vite專有
  2. configResolved :vite專有
  3. options
  4. configureServer :vite專有

接下來是構建階段的鉤子:

  1. buildStart
  2. Resolved
  3. load
  4. transform
  5. buildEnd

然后是輸出階段的鉤子:

  1. outputOptions
  2. renderStart
  3. augmentChunkHash
  4. renderChunk
  5. generateBundle
  6. transformIndexHtml
  7. writeBundle
  8. closeBundle
5. 插件的執行順序
  1. 別名處理Alias
  2. 用戶插件設置enforce: 'pre'
  3. vite 核心插件
  4. 用戶插件未設置enforce
  5. vite 構建插件
  6. 用戶插件設置enforce: 'post'
  7. 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
  1. 導入依賴:代碼開始部分導入了所需的 Node.js 內置模塊和第三方庫,以及 Vite 和 SWC 的相關 API。
  2. 定義插件函數plugin 函數接受一個可能包含用戶配置的對象 _options,并返回一個 Vite 插件數組。
  3. 配置選項合并:函數內部,用戶配置與默認配置合并,以設置插件的行為,例如編譯目標 target 和 JSX 導入源 jsxImportSource
  4. 創建過濾器:如果用戶提供了 includeexclude 規則,會創建一個過濾器 filter,用于決定哪些文件應該被插件處理。
  5. SWC 轉換函數transformWithSwc 異步函數接收文件名、代碼和 React 配置,調用 SWC 的 transform API 來編譯代碼。
  6. 錯誤處理:在 SWC 轉換過程中,如果出現錯誤,會嘗試提取錯誤消息中的錯誤行和列,并重新拋出格式化后的錯誤。
  7. 靜默警告silenceUseClientWarning 函數用于抑制 Rollup 的某些警告,特別是與 'use client' 指令相關的警告。
  8. 解析 SWC 輔助依賴resolveSwcHelpersDeps 函數嘗試解析 @swc/helpers 包中的輔助函數文件列表。
  9. 定義 Vite 插件對象:返回的插件數組中包括兩個主要的插件對象,一個用于開發環境,另一個用于構建環境。
  10. 開發環境插件
    • 設置了 nameapplyconfigtransformIndexHtmltransform 屬性來定義插件的行為。
    • 使用 resolveIdload 處理 React 快速刷新的運行時腳本。
    • transform 方法用于對代碼進行轉換,并添加了 React 快速刷新的相關代碼。
  11. 構建環境插件
    • 設置了 nameapplyconfigtransform 屬性。
    • 在構建配置中應用了靜默警告配置,并指定了編譯目標。
  12. React 快速刷新:在開發環境中,插件通過修改 transformIndexHtmltransform 方法來支持 React 快速刷新。
  13. 導出默認:最后,plugin 函數作為默認導出,使其可以在 Vite 配置中使用。

參考文章和地址

https://juejin.cn/post/7103165205483356168?searchId=20240706212202081D45CDF4733CF7923F#heading-17

https://article.juejin.cn/post/7211745375920586813

https://cn.vitejs.dev/

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/diannao/41954.shtml
繁體地址,請注明出處:http://hk.pswp.cn/diannao/41954.shtml
英文地址,請注明出處:http://en.pswp.cn/diannao/41954.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

【刷題匯總--大數加法、 鏈表相加(二)、大數乘法】

C日常刷題積累 今日刷題匯總 - day0061、大數加法1.1、題目1.2、思路1.3、程序實現 2、 鏈表相加(二)2.1、題目2.2、思路2.3、程序實現 3、大數乘法3.1、題目3.2、思路3.3、程序實現 4、題目鏈接 今日刷題匯總 - day006 1、大數加法 1.1、題目 1.2、思路 讀完題,明白大數相加…

使用空指針訪問成員函數

#include<iostream> #include<ctime> using namespace std; class Person { public:void outPr(){cout << "outPr()被調用" << endl;} };void test02() {Person* p1 NULL;p1->outPr(); }int main() {test02();return 0; }

郭明錤:蘋果將為Vision Pro推出紅外攝像頭款AirPods

在科技界,蘋果公司的每一次創新都備受矚目。近日,著名蘋果分析師郭明錤透露了一個令人振奮的消息:蘋果計劃在2026年推出配備紅外攝像頭的新款AirPods,這款耳機將特別優化與Apple Vision Pro頭顯的空間體驗。這一消息不僅預示著蘋果在音頻設備領域的又一次技術飛躍,也進一步…

策略為王股票軟件源代碼-----如何修改為自己軟件62----資訊菜單修改-----舉例---------調用同花順F10資訊------

//char szInfoF10[] "http://www.f10.com.cn/ggzx/ggzl.asp?zqdm%s"; char szInfoF10[] "http://basic.10jqka.com.cn/601899/"; // MENUITEM "F10資訊(&F)", ID_INFO_F10 MENUITEM &…

Lua 錯誤處理

Lua 錯誤處理 Lua是一種輕量級的編程語言&#xff0c;廣泛用于游戲開發、腳本編寫和其他應用程序中。在編程過程中&#xff0c;錯誤處理是一個重要的方面&#xff0c;它可以幫助開發者創建更健壯和可靠的程序。本文將詳細介紹Lua中的錯誤處理機制。 錯誤類型 在Lua中&#x…

VueDraggable拖拽

import { VueDraggable } from ‘vue-draggable-plus’ <VueDraggable style“display: flex;flex-wrap: wrap;” v-model“fileListResourcesImgs” end“onEnd”> <div class“icon-container” click“changeResourcesImgsIndex(index)”> <span class“del…

多功能工具網站

江下科技在線應用-免費PDF轉換成Word-word轉pdf-無需下載安裝 (onlinedo.cn)https://www.onlinedo.cn/

【OnlyOffice】桌面應用編輯器,插件開發大賽,等你來挑戰

OnlyOffice&#xff0c;桌面應用編輯器&#xff0c;最近版本已從8.0升級到了8.1 從PDF、Word、Excel、PPT等全面進行了升級。隨著AI應用持續的火熱&#xff0c;OnlyOffice也在不斷推出AI相關插件。 因此&#xff0c;在此給大家推薦一下OnlyOffice本次的插件開發大賽。 詳細信息…

52-4 內網代理1 - 內網代理簡介

一、正向連接 正向連接是指受控端主機監聽一個端口,由控制端主機主動發起連接的過程。這種連接方式適用于受控主機擁有公網IP地址的情況。例如,在攻擊者和受害者都具有公網IP的情況下,攻擊者可以直接通過受害者的公網IP地址訪問受害者主機,因此可以使用正向連接來建立控制通…

支持向量機(Support Vector Machine,SVM)及Python和MATLAB實現

支持向量機&#xff08;Support Vector Machine&#xff0c;SVM&#xff09;是一種經典的機器學習算法&#xff0c;廣泛應用于模式識別、數據分類和回歸分析等領域。SVM的背景可以追溯到1990s年代&#xff0c;由Vladimir Vapnik等人提出&#xff0c;并在之后不斷發展和完善。 …

HTML5使用<pre>標簽:保留原始排版方式

在網頁創作中&#xff0c;一般是通過各種標記對文字進行排版的。但是在實際應用中&#xff0c;往往需要一些特殊的排版效果&#xff0c;這樣使用標記控制起來會比較麻煩。解決的方法就是保留文本格式的排版效果&#xff0c;如空格、制表符等。 如果要保留原始的文本排版效果&a…

MySQL的count()方法慢

前言 mysql用count方法查全表數據&#xff0c;在不同的存儲引擎里實現不同&#xff0c;myisam有專門字段記錄全表的行數&#xff0c;直接讀這個字段就好了。而innodb則需要一行行去算。 比如說&#xff0c;你有一張短信表(sms)&#xff0c;里面放了各種需要發送的短信信息。 …

004 返回值處理

文章目錄 不使用注解修飾ModelAndViewvoidString&#xff08;推薦&#xff09; 使用注解修飾ResponseBody注解常用的HttpMessageConverter 不使用注解修飾 ModelAndView Controller方法中定義ModelAndView對象并返回&#xff0c;對象中可添加model數據、指定view。 void 在Co…

關于linux服務器更改鏡像后連接不上vscode問題

問題樣子解決辦法直接看 問題樣子 問題描述&#xff1a;從centos換到ubantu后&#xff0c;xshell能直接連接上&#xff08;沒有更改ssh配置信息&#xff09;&#xff0c;但是vscode連不上&#xff08;配置文件因為端口號和ip是一樣的&#xff0c;也沒法改&#xff09; 猜測…

基于支持向量機、孤立森林和LSTM自編碼器的機械狀態異常檢測(MATLAB R2021B)

異常檢測通常是根據已有的觀測數據建立正常行為模型&#xff0c;從而將不同機制下產生的遠離正常行為的數據劃分為異常類&#xff0c;進而實現對異常狀態的檢測。常用的異常檢測方法主要有&#xff1a;統計方法、信息度量方法、譜映射方法、聚類方法、近鄰方法和分類方法等。 …

Android仿今日頭條新聞(一)

新建一個側邊欄的文件&#xff0c;創建成功后直接運行。可以看到帶滑動的側邊欄功能如圖所示&#xff1a; 主體UI&#xff1a; 新聞UI的實現: 側邊欄&#xff1a; 更換一下顏色&#xff1a; 學習參考-浩宇開發

AI老照片生成視頻

地址&#xff1a;AI老照片 讓你的圖片動起來, 老照片修復與動態化

RK3568平臺(opencv篇)opencv處理圖像視頻

一.讀取圖像文件并展示 灰度圖像&#xff1a; 灰度圖需要用 8 位二進制來表示&#xff0c;取值范圍是 0-255。用 0 表示 0&#xff08;黑色&#xff09;&#xff0c; 用 255 表示 1&#xff08;白色&#xff09;&#xff0c;取值越大表示該點越亮。 RGB 彩色圖像&#xff1a;…

XPath 語法筆記

XPath 語法筆記 XPath 表達式運算符謂語&#xff08;Predicates&#xff09;1. 選擇所有本地名稱為特定名稱的元素2. 選擇具有特定屬性的元素3. 選擇屬性值不等于特定值的元素4. 選擇元素內容中包含特定字符串的節點5. 選擇元素內容中不包含特定字符串的節點6. 選擇特定命名空間…

一臺docker機器如何實現構建多平臺鏡像

1.實現方式 想在 x86_64/amd64 的一臺機器平臺上&#xff0c;構建適用于多個平臺的鏡像&#xff0c;例如 linux/amd64、linux/arm64&#xff0c;根據官方文檔&#xff0c;有三種方法可以使用&#xff1a; QEMU ??使用 QEMU 模擬構建多平臺映像是最簡單的入門方法&#xff0c…