Vite 微前端實踐,實現一個元件化的方案

語言: CN / TW / HK

highlight: androidstudio

什麼是微前端

微前端是一種多個團隊通過獨立釋出功能的方式來共同構建現代化 web 應用的技術手段及方法策略。

微前端借鑑了微服務的架構理念,將一個龐大的前端應用拆分為多個獨立靈活的小型應用,每個應用都可以獨立開發、獨立執行、獨立部署,再將這些小型應用聯合為一個完整的應用。微前端既可以將多個專案融合為一,又可以減少專案之間的耦合,提升專案擴充套件性,相比一整塊的前端倉庫,微前端架構下的前端倉庫傾向於更小更靈活。

特性

  • 技術棧無關 主框架不限制接入應用的技術棧,子應用可自主選擇技術棧
  • 獨立開發/部署 各個團隊之間倉庫獨立,單獨部署,互不依賴
  • 增量升級 當一個應用龐大之後,技術升級或重構相當麻煩,而微應用具備漸進式升級的特性
  • 獨立執行時 微應用之間執行時互不依賴,有獨立的狀態管理
  • 提升效率 應用越龐大,越難以維護,協作效率越低下。微應用可以很好拆分,提升效率

目前可用的微前端方案

微前端的方案目前有以下幾種型別:

基於 iframe 完全隔離的方案

作為前端開發,我們對 iframe 已經非常熟悉了,在一個應用中可以獨立執行另一個應用。它具有顯著的優點:

  1. 非常簡單,無需任何改造
  2. 完美隔離,JS、CSS 都是獨立的執行環境
  3. 不限制使用,頁面上可以放多個 iframe 來組合業務

當然,缺點也非常突出:

  1. 無法保持路由狀態,重新整理後路由狀態就丟失
  2. 完全的隔離導致與子應用的互動變得極其困難
  3. iframe 中的彈窗無法突破其本身
  4. 整個應用全量資源載入,載入太慢

這些顯著的缺點也催生了其他方案的產生。

基於 single-spa 路由劫持方案

single-spa 通過劫持路由的方式來做子應用之間的切換,但接入方式需要融合自身的路由,有一定的侷限性。

qiankun 孵化自螞蟻金融科技基於微前端架構的雲產品統一接入平臺。它對 single-spa 做了一層封裝。主要解決了 single-spa 的一些痛點和不足。通過 import-html-entry 包解析 HTML 獲取資源路徑,然後對資源進行解析、載入。

通過對執行環境的修改,它實現了 JS 沙箱樣式隔離 等特性。

京東 micro-app 方案

京東 micro-app 並沒有沿襲 single-spa 的思路,而是借鑑了 WebComponent 的思想,通過 CustomElement 結合自定義的 ShadowDom,將微前端封裝成一個類 webComponents 元件,從而實現微前端的元件化渲染。

Vite 上使用微前端

我們從 我們從 UmiJS 遷移到了 Vite 之後,微前端也成為了勢在必行,當時也調研了很多方案。

為什麼沒用 qiankun

qiankun 是目前是社群主流微前端方案。它雖然很完善、流行,但最大的問題就是不支援 Vite。它基於 import-html-entry 解析 HTML 來獲取資源,由於 qiankun 是通過 eval 來執行這些 js 的內容,而 Vite 中的 script 標籤型別是 type="module",裡面包含 import/export 等模組程式碼, 所以會報錯:不允許在非 type="module"script 裡面使用 import

退一步實現,我們採用了 single-spa 的方式,並使用 systemjs 的方式進行了微前端載入方案,也踩了不少的坑。single-spa 沒有一個友好的教程來接入,文件雖然多,但大多都在講概念,當時讓人覺得有一種深奧的感覺。

後來看了它的原始碼發現,這都是些什麼……裡面大部分程式碼都是圍繞路由劫持而展開的,根本沒有文件上那種高大上的感覺。而我們又用不到它路由劫持的功能,那我們為什麼要用它?

從元件化的層面來說 single-spa 這種方式實現得一點都不優雅。

  1. 它劫持了路由,與 react-router 和元件化的思維格格不入
  2. 接入方式一大堆繁雜的配置
  3. 單例項的方案,即同一時刻,只有一個子應用被展示

後來琢磨著 single-spa 的缺點,我們可以自己實現一個元件化的微前端方案。

如何實現一個簡單、透明、元件化的方案

通過元件化思維實現一個微應用非常簡單:子應用匯出一個方法,主應用載入子應用並呼叫該方法,並傳入一個 Element 節點引數,子應用得到該 Element 節點,將本身的元件 appendChildElement 節點上。

micro-app-2.png

型別約定

在此之前我們需要約定一個主應用與子應用之間的一個互動方式。主要通過三個鉤子來保證應用的正確執行、更新、和解除安裝。

型別定義:

tsx export interface AppConfig { // 掛載 mount?: (props: unknown) => void; // 更新 render?: (props: unknown) => ReactNode | void; // 解除安裝 unmount?: () => void; }

子應用匯出

通過型別的約定,我們可以將子應用匯出:mountrenderunmount 為主要鉤子。

React 子應用實現:

```tsx export default (container: HTMLElement) => { let handleRender: (props: AppProps) => void;

// 包裹一個新的元件,用作更新處理 function Main(props: AppProps) { const [state, setState] = React.useState(props); // 將 setState 方法提取給 render 函式呼叫,保持父子應用觸發更新 handleRender = setState; return ; }

return { mount(props: AppProps) { ReactDOM.render(

, container); }, render(props: AppProps) { handleRender?.(props); }, unmount() { ReactDOM.unmountComponentAtNode(container); }, }; }; ```

Vue 子應用實現:

```ts import { createApp } from 'vue'; import App from './App.vue';

export default (container: HTMLElement) => { // 建立 const app = createApp(App); return { mount() { // 裝載 app.mount(container); }, unmount() { // 解除安裝 app.unmount(); }, }; }; ```

主應用實現

React 實現

其核心程式碼僅十餘行,主要處理與子應用互動 (為了易讀性,隱藏了錯誤處理程式碼)

```tsx export function MicroApp({ entry, ...props }: MicroAppProps) { // 傳遞給子應用的節點 const containerRef = useRef(null); // 子應用配置 const configRef = useRef();

useLayoutEffect(() => { import(/ @vite-ignore / entry).then((res) => { // 將 div 傳給子應用渲染 const config = res.default(containerRef.current); // 呼叫子應用的裝載方法 config.mount?.(props); configRef.current = config; }); return () => { // 呼叫子應用的解除安裝方法 configRef.current?.unmount?.(); configRef.current = undefined; }; }, [entry]);

return

{configRef.current?.render?.(props)}
; } ```

完成,現在已經實現了主應用與子應用的裝載、更新、解除安裝的操作。現在,它是一個元件,可以同時渲染出多個不同的子應用,這點就比 single-spa 優雅很多。

entry 子應用地址,當然真實情況會根據 devprod 模式給出不同的地址:

tsx <MicroApp className="micro-app" entry="//localhost:3002/src/main.tsx" />

Vue 實現

```tsx

```

micro-app-demo.gif

如何讓子應用也能獨立執行

single-spa 等眾多方案,都是將一個變數掛載到 window 上,通過判斷該變數是否處於微前端環境,這樣很不優雅。在 ESM 中,我們可以通過 import.meta.url 傳入引數來判斷:

tsx if (!import.meta.url.includes('microAppEnv')) { ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root'), ); }

入口匯入修改:

tsx // 新增環境引數和當前時間避免被快取 import(/* @vite-ignore */ `${entry}?microAppEnv&t=${Date.now()}`);

瀏覽器相容性

IE 瀏覽器已經逐步退出我們的視野,基於 Vite,我們只需要支援 import 的特性瀏覽器就夠了。當然,如果考慮 IE 瀏覽器的話也不是不可以,很簡單:將上面程式碼的 import 替換為 System.importsystemjs,也是 single-spa 的所推崇的用法。

| 瀏覽器 | Chrome | Edge | Firefox | Internet Explorer | Safari | | :------------- | :----- | :--- | :------ | :---------------- | :----- | | import | 61 | 16 | 60 | No | 10.1 | | Dynamic import | 63 | 79 | 67 | No | 11.1 | | import.meta | 64 | 79 | 62 | No | 11.1 |

模組公用

我們的子元件必須要使用 mountunount 模式嗎?答案是不一定,如果我們的技術棧都是 React 的話。我們的子應用只匯出一個 render 就夠了。這樣用的就是同一個 React 來渲染,好處是子應用可以消費父應用的 Provider。但有個前提是兩個應用之間的 React 必須為同一個例項,否則就會報錯。

我們可以將 reactreact-domstyled-componets 等常用模組提前打包成 ESM 模組,然後放到檔案服務中使用。

更改 Vite 配置新增 alias

tsx defineConfig({ resolve: { alias: { react: '//localhost:8000/[email protected]', 'react-dom': '//localhost:8000/[email protected]', }, }, });

這樣就能愉快地使用同一份 React 程式碼了。還能抽離出主應用和子應用之間的公用模組,讓應用總體積更小。當然如果沒上 http2 的話,就需要考慮顆粒度的問題了。

線上 CDN 方案: http://esm.sh

還有個 importmap 方案,相容性不太好,但未來是趨勢:

```html

```

父子通訊

元件式微應用,可以傳遞引數而通訊,完全就是 React 元件通訊的模型。

資源路徑

Vitedev 模式中,子應用裡面靜態資源一般會這樣引入:

```js import logo from './images/logo.svg';

; ```

圖片的路徑: /basename/src/logo.svg,在主應用顯示就會 404。因為該路徑只是存在於子應用。我們需要配合 URL 模組使用,這樣路徑前面會帶上 origin 字首:

```js const logoURL = new URL(logo, import.meta.url);

; ```

當然這樣使用比較繁瑣,我們可以將其封裝為一個 Vite 外掛自動處理該場景。

路由同步

專案使用 react-router,那麼它可能會存在路由不同步的問題,因為不是同一個 react-router 例項。即路由之間出現不聯動的現象。

react-router 支援自定義 history 庫,我們可以建立:

```tsx import { createBrowserHistory } from 'history';

export const history = createBrowserHistory();

// 主應用:路由入口 {children};

// 主應用:傳遞給子應用 } />;

// 子應用:路由入口 {children} ; ```

最終子應用使用同一份 history 模組。當然這不是唯一的實現,也不是優雅的方式,我們可以將路由例項 navigate 傳遞給子應用,這樣也能實現路由的互動。

注意:子應用的 basename 必須與主應用的 path 名稱保持一致。這裡還需要修改 Vite 的配置 base 欄位:

tsx export default defineConfig({ base: '/child-app/', server: { port: 3002, }, plugins: [react()], });

JS 沙箱

因為沙箱在 ESM 下不支援,因為無法動態改變執行環境中模組 window 物件,也無法注入新的全域性物件。

一般 ReactVue 專案也很少修改全域性變數,做好程式碼規範檢查才是最主要的。

CSS 樣式隔離

自動 CSS 樣式隔離是有代價的,一般我們建議子應用使用不同的 CSS 字首,再配合 CSS Modules 基本上能實現需求。

打包部署

部署可以根據子應用的 base 放置在不同的目錄,並將名稱對應。配置好 nginx 轉發規則就可以了。我們可以將子應用統一路由字首,便於 nginx 將主應用區分開並配置通用規則。

比如將主應用放置在 system 目錄,子應用放置在 app- 開頭的目錄:

```bash location ~ ^/app-.*(..+)$ { root /usr/share/nginx/html; }

location / { try_files $uri $uri/ /index.html; root /usr/share/nginx/html/system; index index.html index.htm; } ```

優點

  • 簡單 核心不足 100 行程式碼,無需多餘的文件
  • 靈活 通過約定的方式接入,也可以漸進增強
  • 透明 無任何劫持方案,更多邏輯透明性
  • 元件化 元件化的渲染及引數通訊
  • 基於 ESM 支援 Vite,面向未來
  • 向下相容 可選 SystemJS 方案,相容低版本瀏覽器

有示例嗎

示例程式碼在 Github,感興趣的朋友可以 clone 下來學習。由於我們的技術棧是 React,所以這裡示例的主應用的實現用的是 React

  • 微前端元件(React): http://github.com/MinJieLiu/micro-app
  • 微前端示例: http://github.com/MinJieLiu/micro-app-demo

結語

微前端的方案適合團隊場景的最好,打造一個團隊能掌控的方案尤為重要。

參考資料:

  1. http://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import.meta
  2. http://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import

Vite 遷移實踐

我們從 UmiJS 遷移到了 Vite

點選 加 React 群 交流。歡迎關注公眾號: 前端星辰