入門微前端,從single-spa到qiankun 【Sort-Plan】

語言: CN / TW / HK

highlight: arduino-light

截圖2022-05-23 下午2.36.39.png

1.微前端

1.1微前端的優勢

  • 無技術棧限制:主框架不限制接入應用的技術棧,子應用具備完全自主權
  • 獨立開發,獨立部署,子應用的倉庫獨立,前後端可獨立進行開發,部署完成後主框架自動完成同步更新
  • 獨立執行時,每個子應用之間狀態隔離,執行時狀態不共享

微前端架構解決方案大概分成兩類場景 - 單例項:即同一時刻,只要一個子應用被展示,子應用具備一個完整的應用生命週期,通常基於url的變化來做子應用的切換 - 多例項:同一時刻可展示多個字應用。通常使用web components方案來做子應用封裝,子應用更像是一個業務元件而不是應用。

1.2實現微前端方案

2018 single-spa 是一個用於前端微服務化的 javascript 前端解決方案,(本身沒有處理樣式隔離,js 執行隔離)實現了路由的劫持和應用載入

2019 qiankun 基於 single-spa 提供了更加開箱即用的 api(single-spa+sandbox+import-html-entry)做到了,技術戰無關,並且接入簡單

總結:子應用可以獨立部署,執行時動態載入主子應用完全解耦,技術棧無關,靠的是協議接入(子應用必須匯出 bootstrap,mount,unmount 方法)

2.single-spa

2.1解決的問題

single-spa 實現了路由劫持和應用載入的功能。 Single-spa 是一個將多個單頁面應用聚合為一個整體應用的 JavaScript 微前端框架。 使用 single-spa 進行前端架構設計可以帶來很多好處,例如:

  • 在同一頁面上使用多個前端框架而不用重新整理頁面] (react,vue等 )
  • 獨立部署每一個單頁面應用
  • 新功能使用新框架,舊的單頁應用不用重寫可以共存
  • 改善初始載入時間,延遲載入程式碼

2.2實踐

截圖2022-05-23 下午2.57.42.png

直接使用命令建立專案:

vue create main-vue(主應用)

vue create child-vue(子應用)

子應用內部

子應用需要丟擲這幾個方法 bootstrap,mount,unmount

single-spa 子應用需要匯入一些方法

因為是在vue專案中,所以需要安裝single-spa-vue npm install single-spa-vue. src/main.js ```js import Vue from 'vue'; import App from './App.vue'; import router from './router'; import singleSpaVue from 'single-spa-vue';

Vue.config.productionTip = false;

const appOptions = { el: '#app', //掛載到父應用中的id為vue的標籤中 router, render: (h) => h(App), };

const vueLifeCycle = singleSpaVue({ Vue, appOptions, });

// 如果是父應用引用我 if (window.singleSpaNavigate) { // eslint-disable-next-line no-undef webpack_public_path = 'http://localhost:10000/'; }

if (!window.singleSpaNavigate) { delete appOptions.el; new Vue(appOptions).$mount('#app'); } // 協議接入 子應用定義了協議,父應用呼叫這些方法 export const bootstrap = vueLifeCycle.bootstrap; export const mount = vueLifeCycle.mount; export const unmount = vueLifeCycle.unmount; ```

需要父應用載入子應用,將子應用打包成一個個lib去給父應用使用

vue.config.js js const { defineConfig } = require('@vue/cli-service'); module.exports = defineConfig({ transpileDependencies: true, configureWebpack: { output: { library: 'singleVue', libraryTarget: 'umd', }, devServer: { port: 10000, }, }, });

router/index.js js const router = new VueRouter({ mode: 'history', // base: process.env.BASE_URL, base: '/vue', routes, });

主應用內部

src/main.js ```js import Vue from 'vue'; import App from './App.vue'; import router from './router'; import { registerApplication, start } from 'single-spa';

Vue.config.productionTip = false;

async function loadScript(url) { return new Promise((resolve, reject) => { let script = document.createElement('script'); script.src = url; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); }

registerApplication( 'myVueApp', async () => { console.log('載入模組'); await loadScript(http://192.168.31.83:10000/js/chunk-vendors.js); await loadScript(http://192.168.31.83:10000/js/app.js); return window.singleVue; // bootstrap mount unmount }, (location) => location.pathname.startsWith('/vue'), // 使用者切換到/vue的路徑下,我需要載入剛才定義的子應用 );

start();

new Vue({ router, render: (h) => h(App), }).$mount('#app');

```

App.js

```js

```

router/index.js js const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes, });

2.3頁面效果

  • 子應用頁面:

截圖2022-05-23 下午3.28.46.png

  • 主應用頁面: 截圖2022-05-23 下午3.26.35.png

  • 主應用點選按鈕後:

截圖2022-05-23 下午3.29.12.png

3.qiankun

樣式隔離

```html

hello word

```

截圖2022-05-23 下午4.14.45.png

qiankun 可以確保子應用之間樣式隔離,主應用跟子應用之間的樣式衝突需要手動解決,以 antd 為例,主應用可以通過設定 prefixCls 樣式字首的方式避免衝突

掛在body上的話,是沒有樣式隔離的 js document.body.appendChild(pElem) 如果上面的程式碼加上這句:

截圖2022-05-23 下午4.22.39.png

js隔離

  • 單應用-快照沙箱
  • 多應用- proxy代理沙箱

  • 如果應用載入剛開始的時候載入a應用 window.a 跳轉到b應用 (window.a也可以被獲取到)

  • 單個應用切換,怎麼實現隔離,a切換到b,創造一個乾淨的環境給子應用使用,
  • 當切換時,可以選擇丟棄屬性和恢復屬性 js沙箱 和 proxy
    //第一種,快照沙箱,之前拍一張,之後拍一張,做對比,將區別儲存儲存起來,再回到一年前
    

```js

```

如果是多應用就不能使用這種方法了 需要使用es6 proxy

代理沙箱,可以實現多應用沙箱,把不同的應用用不同的代理

截圖2022-05-23 下午4.15.43.png

實踐

截圖2022-05-23 下午3.50.54.png

vue 主應用 qiankun-base

src/main.js ```js import Vue from 'vue'; import App from './App.vue'; import router from './router'; import Element from 'element-ui'; import 'element-ui/lib/theme-chalk/index.css'; // import './styles/element-variables.scss';

import { registerMicroApps, start } from 'qiankun';

Vue.config.productionTip = false; Vue.use(Element);

const apps = [ { name: 'vueApp', entry: '//localhost:10000', //預設會載入這個html,解析裡面的js 動態執行 // 子應用需要解決跨域 container: '#vue', // 容器 activeRule: '/vue', // 啟用路徑 props: { a: 1 }, }, { name: 'reactApp', entry: '//localhost:20000', //預設會載入這個html,解析裡面的js 動態執行 // 子應用需要解決跨域 container: '#react', activeRule: '/react', }, ];

registerMicroApps(apps); // 註冊應用

start(); // 開啟

// 點選按鈕再載入子應用 // start({ // prefetch: false, // 是否開啟預載入 // });

new Vue({ router, render: (h) => h(App), }).$mount('#app');

```

src/App.js ```js

```

vue 子應用 qiankun-vue

src/main.js ```js / eslint-disable / import Vue from 'vue'; import App from './App.vue'; import router from './router';

// Vue.config.productionTip = false

// new Vue({ // router, // render: h => h(App) // }).$mount('#app')

let instance = null;

function render() { instance = new Vue({ router, render: (h) => h(App), }).$mount('#app'); // 這個是掛載到自己的html,基座會拿到掛載後的html中 } if (window.POWERED_BY_QIANKUN) { // eslint-disable-next-line webpack_public_path = window.INJECTED_PUBLIC_PATH_BY_QIANKUN; }

if (!window.POWERED_BY_QIANKUN) { render(); }

// 子元件的協議 // eslint-disable-next-line export async function bootstrap(props) {}

export async function mount(props) { console.log('vue 啟動'); render(props); } // eslint-disable-next-line export async function unmount(props) { console.log('vue 解除安裝'); instance.$destroy(); }

// 1.49

```

src/App.js ```js ```

vue.config.js ```js module.exports = { devServer: { port: 10000, headers: { 'Access-Control-Allow-Origin': '*', }, }, configureWebpack: { output: { library: 'vueApp', libraryTarget: 'umd', }, }, };

```

router/index.js js const router = new VueRouter({ mode: 'history', // base: process.env.BASE_URL, base: '/vue', routes, });

react 子應用 qiankun-react

src/index.js ```js import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));

function render() { root.render( , ); }

if (window.POWERED_BY_QIANKUN) { // eslint-disable-next-line webpack_public_path = window.INJECTED_PUBLIC_PATH_BY_QIANKUN; }

if (!window.POWERED_BY_QIANKUN) { render(); }

export async function bootstrap(props) {}

export async function mount(props) { console.log('react 啟動'); render(); } // eslint-disable-next-line export async function unmount(props) { console.log('react 解除安裝'); root.unmount(); }

reportWebVitals();

```

src/App.js ```js import logo from './logo.svg'; import './App.css'; import { BrowserRouter, Route, Link, Routes } from 'react-router-dom';

function App() { return ( 首頁 關於

        <Routes>
            <Route
                path='/'
                exact
                element={<div>hhhhh王胖子主頁</div>}></Route>

            <Route
                path='/about'
                exact
                element={<div>about頁面哦</div>}></Route>
        </Routes>
    </BrowserRouter>
);

}

export default App;

```

按照react專案中,修改webpack配置的外掛: react-app-rewired

並修改package.json配置: json "scripts": { "start": "react-app-rewired start", "build": "react-app-rewired build", "test": "react-app-rewired test", "eject": "react-app-rewired eject" },

新增:config-overrides.js檔案 js module.exports = { webpack: (config) => { config.output.library = 'reactApp'; config.output.libraryTarget = 'umd'; config.output.publicPath = 'http://localhost:20000/'; return config; }, deServer: (configFunction) => { return function (proxy, allowedHost) { const config = configFunction(proxy, allowedHost); config.port = '20000'; config.headers = { 'Access-Control-Allow-Origin': '*', }; return config; }; }, }; 新增.env檔案: js PORT = 20000; WDS_SOKECT_PORT = 20000;

頁面效果

vue子應用頁面:

截圖2022-05-23 下午3.55.34.png

react子應用頁面:

截圖2022-05-23 下午3.55.19.png vue主應用頁面:

截圖2022-05-23 下午3.55.42.png

vue -> react:

截圖2022-05-23 下午3.56.09.png

react -> vue:

截圖2022-05-23 下午3.56.18.png

4. 微前端架構實踐中的問題

主框架的定位:導航路由+資源載入框架,而要實現這樣一套架構需要解決一些問題

4.1 路由系統及future state

我們在一個實現了微前端核心的產品中,正常訪問一個子應用的頁面時,可能會有這樣一個鏈路:

  1. 訪問 https://app.pay.com
  2. 點選導航中的某個子產品的連結https://app.pay.com/subApp
  3. subApp渲染並預設redirect到list頁面https://app.pay.com/subApp/list
  4. 檢視列表某一項資訊https://app.pay.com/subApp/:id/detail

此時瀏覽器的地址可能是https://app.pay.com/subApp/123/detail ,想象下此時我們手動重新整理一下瀏覽器,會發生什麼?

由於子應用要都是lazy load的,當瀏覽器重新重新整理時,主框架的資源會被重新載入,同時非同步載入子應用的靜態資源,由於此時主應用的路由系統已經啟用,但子應用的資源可能還沒有完全載入完畢,從而導致路由登錄檔裡面發現沒有能匹配子應用的/subApp/123/detail的規則,這時候會導致條notfound或者直接路由報錯

這個問題在所有lazy load方式載入子應用的方案中都會碰到,future state

解決的思路,我們需要設計這樣一套路由機制:

主框架配置子路由的路徑為: ```js subApp:{ url:'/subAp/**', entry:'./subApp.js' } ```` 則當瀏覽器的地址為/subAp/abc時, 1. 框架需要先載入entry資源,待entry資源載入完畢, 2. 確保子應用的路由系統註冊進框架之後, 3. 再去由子應用的路由系統接管url change事件, 4. 同時在子應用切出時,主框架需要觸發相應的destroy事件, 5. 子應用監聽到該事件時,呼叫自己的解除安裝方法解除安裝應用

如 React 場景下 destroy = () => ReactDOM.unmountAtNode(container)。

要實現這樣一套機制,可以自己去劫持url change時間從而實現自己的路由系統,也可以基於社群react-route在v4之後實現的,需要複寫一部分路由發現邏輯single-spa

app entry

解決了路由的問題後,主框架和子應用整合的方式,也會成為一個需要關注的技術決策

構建時組合 VS 執行時組合

微前端架構模式下,子應用打包的方式由兩種

構建時打包:

子應用通過package registry (也可以是npm package) 的方式,與主應用一起打包釋出

  • 優點:主應用和子應用之間可以做答辯優化,依賴共享

  • 缺點:主子應用之間產品工具鏈耦合,工具鏈也是技術棧的一部分

執行時打包:

子應用自己構建打包,主應用執行時動態載入子應用資源

  • 優點:完全解耦,子應用完全和技術棧無關

  • 缺點:多出執行時的複雜度和overhead

要真正實現技術棧無關和獨立部署兩個目標,大部分場景下需要使用執行時載入的方案。

js entry VS html entry

  • js entry :子應用將資源打成一個entry script,比如single-spa的example中的方式。但是這個方式限制很多,比如要求子應用所以的資源打包到一個js bundle,包括css圖片等,除了打包出來的體檢龐大,資源的並行價值等特性也無法利用上

  • html entry: 更加靈活,直接將子應用打出來的html 作用入口,主框架可以通過fetch html的方式獲取子應用的靜態資源(涉及到跨域,需要配置),同時將html document作為子節點塞到主框架的容器中,這樣不僅可以極大減少主應用的接入成本,子應用的開發方式和打包方式也基本不用跳轉,而且解決子應用之間樣式隔離的問題。

大概整理了一下這段時間學習微前端的一些筆記📒