微前端qiankun從搭建到部署的實踐


示例代碼: github.com/fengxianqi/…

在線demo:qiankun.fengxianqi.com/

單獨訪問在線子應用:

為什么要用qiankun

項目有個功能需求是需要內嵌公司內部的一個現有工具,該工具是獨立部署的且是用React寫的,而我們的項目主要技術選型是vue,因此需要考慮嵌入頁面的方案。主要有兩條路:

  • iframe方案
  • qiankun微前端方案

兩種方案都能滿足我們的需求且是可行的。不得不說,iframe方案雖然普通但很實用且成本也低,iframe方案能覆蓋大部分的微前端業務需求,而qiankun對技術要求更高一些。

技術同學對自身的成長也是有強烈需求的,因此在兩者都能滿足業務需求時,我們更希望能應用一些較新的技術,折騰一些未知的東西,因此我們決定選用qiankun

項目架構

后台系統一般都是上下或左右的布局。下圖粉紅色是基座,只負責頭部導航,綠色是掛載的整個子應用,點擊頭部導航可切換子應用。

參考官方的examples代碼,項目根目錄下有基座main和其他子應用sub-vuesub-react,搭建后的初始目錄結構如下:


├── common     //公共模塊
├── main       // 基座
├── sub-react  // react子應用
└── sub-vue    // vue子應用
復制代碼

基座是用vue搭建,子應用有reactvue

基座配置

基座main采用是的Vue-Cli3搭建的,它只負責導航的渲染和登錄態的下發,為子應用提供一個掛載的容器div,基座應該保持簡潔(qiankun官方demo甚至直接使用原生html搭建),不應該做涉及業務的操作。

qiankun這個庫只需要在基座引入,在main.js中注冊子應用,為了方便管理,我們將子應用的配置都放在:main/src/micro-app.js下。

const microApps = [ { name: 'sub-vue', entry: '//localhost:7777/', activeRule: '/sub-vue', container: '#subapp-viewport', // 子應用掛載的div props: { routerBase: '/sub-vue' // 下發路由給子應用,子應用根據該值去定義qiankun環境下的路由 } }, { name: 'sub-react', entry: '//localhost:7788/', activeRule: '/sub-react', container: '#subapp-viewport', // 子應用掛載的div props: { routerBase: '/sub-react' } } ] export default microApps 復制代碼

然后在src/main.js中引入

import Vue from 'vue'; import App from './App.vue'; import { registerMicroApps, start } from 'qiankun'; import microApps from './micro-app'; Vue.config.productionTip = false; new Vue({ render: h => h(App), }).$mount('#app'); registerMicroApps(microApps, { beforeLoad: app => { console.log('before load app.name====>>>>>', app.name) }, beforeMount: [ app => { console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name); }, ], afterMount: [ app => { console.log('[LifeCycle] after mount %c%s', 'color: green;', app.name); } ], afterUnmount: [ app => { console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name); }, ], }); start(); 復制代碼

App.vue中,需要聲明micro-app.js配置的子應用掛載div(注意id一定要一致),以及基座布局相關的,大概這樣:

<template> <div id="layout-wrapper"> <div class="layout-header">頭部導航</div> <div id="subapp-viewport"></div> </div> </template> 復制代碼

這樣,基座就算配置完成了。項目啟動后,子應用將會掛載到<div id="subapp-viewport"></div>中。

子應用配置

一、vue子應用

用Vue-cli在項目根目錄新建一個sub-vue的子應用,子應用的名稱最好與父應用在src/micro-app.js中配置的名稱一致(這樣可以直接使用package.json中的name作為output)。

  1. 新增vue.config.js,devServer的端口改為與主應用配置的一致,且加上跨域headersoutput配置。
// package.json的name需注意與主應用一致 const { name } = require('../package.json') module.exports = { configureWebpack: { output: { library: `${name}-[name]`, libraryTarget: 'umd', jsonpFunction: `webpackJsonp_${name}`, } }, devServer: { port: process.env.VUE_APP_PORT, // 在.env中VUE_APP_PORT=7788,與父應用的配置一致 headers: { 'Access-Control-Allow-Origin': '*' // 主應用獲取子應用時跨域響應頭 } } } 復制代碼
  1. 新增src/public-path.js
(function() { if (window.__POWERED_BY_QIANKUN__) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-undef __webpack_public_path__ = `//localhost:${process.env.VUE_APP_PORT}/`; return; } // eslint-disable-next-line no-undef __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } })(); 復制代碼
  1. src/router/index.js改為只暴露routes,new Router改到main.js中聲明。
  2. 改造main.js,引入上面的public-path.js,改寫render,添加生命周期函數等,最終如下:
import './public-path' // 注意需要引入public-path import Vue from 'vue' import App from './App.vue' import routes from './router' import store from './store' import VueRouter from 'vue-router' Vue.config.productionTip = false let instance = null function render (props = {}) { const { container, routerBase } = props const router = new VueRouter({ base: window.__POWERED_BY_QIANKUN__ ? routerBase : process.env.BASE_URL, mode: 'history', routes }) instance = new Vue({ router, store, render: (h) => h(App) }).$mount(container ? container.querySelector('#app') : '#app') } if (!window.__POWERED_BY_QIANKUN__) { render() } export async function bootstrap () { console.log('[vue] vue app bootstraped') } export async function mount (props) { console.log('[vue] props from main framework', props) render(props) } export async function unmount () { instance.$destroy() instance.$el.innerHTML = '' instance = null } 復制代碼

至此,基礎版本的vue子應用配置好了,如果routervuex不需用到,可以去掉。

二、react子應用

  1. 通過npx create-react-app sub-react新建一個react應用。
  2. 新增.env文件添加PORT變量,端口號與父應用配置的保持一致。
  3. 為了不eject所有webpack配置,我們用react-app-rewired方案復寫webpack就可以了。
  • 首先npm install react-app-rewired --save-dev
  • 新建sub-react/config-overrides.js
const { name } = require('./package.json'); module.exports = { webpack: function override(config, env) { // 解決主應用接入后會掛掉的問題:https://github.com/umijs/qiankun/issues/340 config.entry = config.entry.filter( (e) => !e.includes('webpackHotDevClient') ); config.output.library = `${name}-[name]`; config.output.libraryTarget = 'umd'; config.output.jsonpFunction = `webpackJsonp_${name}`; return config; }, devServer: (configFunction) => { return function (proxy, allowedHost) { const config = configFunction(proxy, allowedHost); config.open = false; config.hot = false; config.headers = { 'Access-Control-Allow-Origin': '*', }; return config; }; }, }; 復制代碼
  1. 新增src/public-path.js
if (window.__POWERED_BY_QIANKUN__) { // eslint-disable-next-line __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } 復制代碼
  1. 改造index.js,引入public-path.js,添加生命周期函數等。
import './public-path' import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; function render() { ReactDOM.render( <App />, document.getElementById('root') ); } if (!window.__POWERED_BY_QIANKUN__) { render(); } /** * bootstrap 只會在微應用初始化的時候調用一次,下次微應用重新進入時會直接調用 mount 鈎子,不會再重復觸發 bootstrap。 * 通常我們可以在這里做一些全局變量的初始化,比如不會在 unmount 階段被銷毀的應用級別的緩存等。 */ export async function bootstrap() { console.log('react app bootstraped'); } /** * 應用每次進入都會調用 mount 方法,通常我們在這里觸發應用的渲染方法 */ export async function mount(props) { console.log(props); render(); } /** * 應用每次 切出/卸載 會調用的方法,通常在這里我們會卸載微應用的應用實例 */ export async function unmount() { ReactDOM.unmountComponentAtNode(document.getElementById('root')); } /** * 可選生命周期鈎子,僅使用 loadMicroApp 方式加載微應用時生效 */ export async function update(props) { console.log('update props', props); } serviceWorker.unregister(); 復制代碼

至此,基礎版本的react子應用配置好了。

進階

全局狀態管理

qiankun通過initGlobalState, onGlobalStateChange, setGlobalState實現主應用的全局狀態管理,然后默認會通過props將通信方法傳遞給子應用。先看下官方的示例用法:

主應用:

// main/src/main.js import { initGlobalState } from 'qiankun'; // 初始化 state const initialState = { user: {} // 用戶信息 }; const actions = initGlobalState(initialState); actions.onGlobalStateChange((state, prev) => { // state: 變更后的狀態; prev 變更前的狀態 console.log(state, prev); }); actions.setGlobalState(state); actions.offGlobalStateChange(); 復制代碼

子應用:

// 從生命周期 mount 中獲取通信方法,props默認會有onGlobalStateChange和setGlobalState兩個api export function mount(props) { props.onGlobalStateChange((state, prev) => { // state: 變更后的狀態; prev 變更前的狀態 console.log(state, prev); }); props.setGlobalState(state); } 復制代碼

這兩段代碼不難理解,父子應用通過onGlobalStateChange這個方法進行通信,這其實是一個發布-訂閱的設計模式。

ok,官方的示例用法很簡單也完全夠用,純JavaScript的語法,不涉及任何的vue或react的東西,開發者可自由定制。

如果我們直接使用官方的這個示例,那么數據會比較松散且調用復雜,所有子應用都得聲明onGlobalStateChange對狀態進行監聽,再通過setGlobalState進行更新數據。

因此,我們很有必要對數據狀態做進一步的封裝設計。筆者這里主要考慮以下幾點:

  • 主應用要保持簡潔簡單,對子應用來說,主應用下發的數據就是一個很純粹的object,以便更好地支持不同框架的子應用,因此主應用不需用到vuex
  • vue子應用要做到能繼承父應用下發的數據,又支持獨立運行。

子應用在mount聲明周期可以獲取到最新的主應用下發的數據,然后將這份數據注冊到一個名為global的vuex module中,子應用通過global module的action動作進行數據的更新,更新的同時自動同步回父應用。

因此,對子應用來說,它不用知道自己是一個qiankun子應用還是一個獨立應用,它只是有一個名為global的module,它可通過action更新數據,且不再需要關心是否要同步到父應用(同步的動作會封裝在方法內部,調用者不需關心),這也是為后面支持子應用獨立啟動開發做准備

  • react子應用同理(筆者react用得不深就不說了)。

主應用的狀態封裝

主應用維護一個initialState的初始數據,它是一個object類型,會下發給子應用。

// main/src/store.js import { initGlobalState } from 'qiankun'; import Vue from 'vue' //父應用的初始state // Vue.observable是為了讓initialState變成可響應:https://cn.vuejs.org/v2/api/#Vue-observable。 let initialState = Vue.observable({ user: {}, }); const actions = initGlobalState(initialState); actions.onGlobalStateChange((newState, prev) => { // state: 變更后的狀態; prev 變更前的狀態 console.log('main change', JSON.stringify(newState), JSON.stringify(prev)); for (let key in newState) { initialState[key] = newState[key] } }); // 定義一個獲取state的方法下發到子應用 actions.getGlobalState = (key) => { // 有key,表示取globalState下的某個子級對象 // 無key,表示取全部 return key ? initialState[key] : initialState } export default actions; 復制代碼

這里有兩個注意的地方:

  • Vue.observable是為了讓父應用的state變成可響應式,如果不用Vue.observable包一層,它就只是一個純粹的object,子應用也能獲取到,但會失去響應式,意味着數據改變后,頁面不會更新
  • getGlobalState方法,這個是有爭議的,大家在github上有討論:github.com/umijs/qiank…

一方面,作者認為getGlobalState不是必須的,onGlobalStateChange其實已經夠用。

另一方面,筆者和其他提pr的同學覺得有必要提供一個getGlobalState的api,理由是get方法更方便使用,子應用有需求是不需一直監聽stateChange事件,它只需要在首次mount時通過getGlobalState初始化一次即可。在這里,筆者先堅持己見讓父應用下發一個getGlobalState的方法。

由於官方還不支持getGlobalState,所以需要顯示地在注冊子應用時通過props去下發該方法:

import store from './store'; const microApps = [ { name: 'sub-vue', entry: '//localhost:7777/', activeRule: '/sub-vue', }, { name: 'sub-react', entry: '//localhost:7788/', activeRule: '/sub-react', } ] const apps = microApps.map(item => { return { ...item, container: '#subapp-viewport', // 子應用掛載的div props: { routerBase: item.activeRule, // 下發基礎路由 getGlobalState: store.getGlobalState // 下發getGlobalState方法 }, } }) export default microApps 復制代碼

vue子應用的狀態封裝

前面說了,子應用在mount時會將父應用下發的state,注冊為一個叫global的vuex module,為了方便復用我們封裝一下:

// sub-vue/src/store/global-register.js /** * * @param {vuex實例} store * @param {qiankun下發的props} props */ function registerGlobalModule(store, props = {}) { if (!store || !store.hasModule) { return; } // 獲取初始化的state const initState = props.getGlobalState && props.getGlobalState() || { menu: [], user: {} }; // 將父應用的數據存儲到子應用中,命名空間固定為global if (!store.hasModule('global')) { const globalModule = { namespaced: true, state: initState, actions: { // 子應用改變state並通知父應用 setGlobalState({ commit }, payload) { commit('setGlobalState', payload); commit('emitGlobalState', payload); }, // 初始化,只用於mount時同步父應用的數據 initGlobalState({ commit }, payload) { commit('setGlobalState', payload); }, }, mutations: { setGlobalState(state, payload) { // eslint-disable-next-line state = Object.assign(state, payload); }, // 通知父應用 emitGlobalState(state) { if (props.setGlobalState) { props.setGlobalState(state); } }, }, }; store.registerModule('global', globalModule); } else { // 每次mount時,都同步一次父應用數據 store.dispatch('global/initGlobalState', initState); } }; export default registerGlobalModule; 復制代碼

main.js中添加global-module的使用:

import globalRegister from './store/global-register' export async function mount(props) { console.log('[vue] props from main framework', props) globalRegister(store, props) render(props) } 復制代碼

可以看到,該vuex模塊在子應用mount時,會調用initGlobalState將父應用下發的state初始化一遍,同時提供了setGlobalState方法供外部調用,內部自動通知同步到父應用。子應用在vue頁面使用時如下:

export default { computed: { ...mapState('global', { user: state => state.user, // 獲取父應用的user信息 }), }, methods: { ...mapActions('global', ['setGlobalState']), update () { this.setGlobalState('user', { name: '張三' }) } }, }; 復制代碼

這樣就達到了一個效果:子應用不用知道qiankun的存在,它只知道有這么一個global module可以存儲信息,父子之間的通信都封裝在方法本身了,它只關心本身的信息存儲就可以了。

ps: 該方案也是有缺點的,由於子應用是在mount時才會同步父應用下發的state的。因此,它只適合每次只mount一個子應用的架構(不適合多個子應用共存);若父應用數據有變化而子應用又沒觸發mount,則父應用最新的數據無法同步回子應用。想要做到多子應用共存且父動態傳子,子應用還是需要用到qiankun提供的onGlobalStateChange的api監聽才行,有更好方案的同學可以分享討論一下。該方案剛好符合筆者當前的項目需求,因此夠用了,請同學們根據自己的業務需求來封裝。

子應用切換Loading處理

子應用首次加載時相當於新加載一個項目,還是比較慢的,因此loading是不得不加上的。

官方的例子中有做了loading的處理,但是需要額外引入import Vue from 'vue/dist/vue.esm',這會增加主應用的打包體積(對比發現大概增加了100KB)。一個loading增加了100K,顯然代價有點無法接受,所以需要考慮一種更優一點的辦法。

我們的主應用是用vue搭建的,而且qiankun提供了loader方法可以獲取到子應用的加載狀態,所以自然而然地可以想到:main.js中子應用加載時,將loading 的狀態傳給Vue實例,讓Vue實例響應式地顯示loading。接下來先選一個loading組件:

  • 如果主應用使用了ElementUI或其他框架,可以直接使用UI庫提供的loading組件。
  • 如果主應用為了保持簡單沒有引入UI庫,可以考慮自己寫一個loading組件,或者找個小巧的loading庫,如筆者這里要用到的NProgress
npm install --save nprogress
復制代碼

接下來是想辦法如何把loading狀態傳給主應用的App.vue。經過筆者試驗發現,new Vue方法返回的vue實例可以通過instance.$children[0]來改變App.vue的數據,所以改造一下main.js

// 引入nprogress的css import 'nprogress/nprogress.css' import microApps from './micro-app'; // 獲取實例 const instance = new Vue({ render: h => h(App), }).$mount('#app'); // 定義loader方法,loading改變時,將變量賦值給App.vue的data中的isLoading function loader(loading) { if (instance && instance.$children) { // instance.$children[0] 是App.vue,此時直接改動App.vue的isLoading instance.$children[0].isLoading = loading } } // 給子應用配置加上loader方法 let apps = microApps.map(item => { return { ...item, loader } }) registerMicroApps(apps); start(); 復制代碼

PS: qiankun的registerMicroApps方法也監聽到子應用的beforeLoad、afterMount等生命周期,因此也可以使用這些方法記錄loading狀態,但更好的用法肯定是通過loader參數傳遞。

改造主應用的App.vue,通過watch監聽isLoading

<template> <div id="layout-wrapper"> <div class="layout-header">頭部導航</div> <div id="subapp-viewport"></div> </div> </template> <script> import NProgress from 'nprogress' export default { name: 'App', data () { return { isLoading: true } }, watch: { isLoading (val) { if (val) { NProgress.start() } else { this.$nextTick(() => { NProgress.done() }) } } }, components: {}, created () { NProgress.start() } } </script> 復制代碼

至此,loading效果就實現了。雖然instance.$children[0].isLoading的操作看起來比較騷,但確實比官方的提供的例子成本小很多(體積增加幾乎為0),若有更好的辦法,歡迎大家評論區分享。

抽取公共代碼

不可避免,有些方法或工具類是所有子應用都需要用到的,每個子應用都copy一份肯定是不好維護的,所以抽取公共代碼到一處是必要的一步。

根目錄下新建一個common文件夾用於存放公共代碼,如上面的多個vue子應用都可以共用的global-register.js,或者是可復用的request.jssdk之類的工具函數等。這里代碼不貼了,請直接看demo

公共代碼抽取后,其他的應用如何使用呢? 可以讓common發布為一個npm私包,npm私包有以下幾種組織形式:

  • npm指向本地file地址:npm install file:../common。直接在根目錄新建一個common目錄,然后npm直接依賴文件路徑。
  • npm指向私有git倉庫: npm install git+ssh://xxx-common.git
  • 發布到npm私服。

本demo因為是基座和子應用都集合在一個git倉庫上,所以采用了第一種方式,但實際應用時是發布到npm私服,因為后面我們會拆分基座和子應用為獨立的子倉庫,支持獨立開發,后文會講到。

需要注意的是,由於common是不經過babel和pollfy的,所以引用者需要在webpack打包時顯性指定該模塊需要編譯,如vue子應用的vue.config.js需要加上這句:

module.exports = { transpileDependencies: ['common'], } 復制代碼

子應用支持獨立開發

微前端一個很重要的概念是拆分,是分治的思想,把所有的業務拆分為一個個獨立可運行的模塊。

從開發者的角度看,整個系統可能有N個子應用,如果啟動整個系統可能會很慢很卡,而產品的某個需求可能只涉及到其中一個子應用,因此開發時只需啟動涉及到的子應用即可,獨立啟動專注開發,因此是很有必要支持子應用的獨立開發的。如果要支持,主要會遇到以下幾個問題:

  • 子應用的登錄態怎么維護?
  • 基座不啟動時,怎么獲取到基座下發的數據和能力?

在基座運行時,登錄態和用戶信息是存放在基座上的,然后基座通過props下發給子應用。但如果基座不啟動,只是子應用獨立啟動,子應用就沒法通過props獲取到所需的用戶信息了。因此,解決辦法只能是父子應用都得實現一套相同的登錄邏輯。為了可復用,可以把登錄邏輯封裝在common中,然后在子應用獨立運行的邏輯中添加登錄相關的邏輯。

// sub-vue/src/main.js import { store as commonStore } from 'common' import store from './store' if (!window.__POWERED_BY_QIANKUN__) { // 這里是子應用獨立運行的環境,實現子應用的登錄邏輯 // 獨立運行時,也注冊一個名為global的store module commonStore.globalRegister(store) // 模擬登錄后,存儲用戶信息到global module const userInfo = { name: '我是獨立運行時名字叫張三' } // 假設登錄后取到的用戶信息 store.commit('global/setGlobalState', { user: userInfo }) render() } // ... export async function mount (props) { console.log('[vue] props from main framework', props) commonStore.globalRegister(store, props) render(props) } // ... 復制代碼

!window.__POWERED_BY_QIANKUN__表示子應用處於非qiankun內的環境,即獨立運行時。此時我們依然要注冊一個名為global的vuex module,子應用內部同樣可以從global module中獲取用戶的信息,從而做到抹平qiankun和獨立運行時的環境差異。

PS:我們前面寫的global-register.js寫得很巧妙,能夠同時支持兩種環境,因此上面可以通過commonStore.globalRegister直接引用。

子應用獨立倉庫

隨着項目發展,子應用可能會越來越多,如果子應用和基座都集合在同一個git倉庫,就會越來越臃腫。

若項目有CI/CD,只修改了某個子應用的代碼,但代碼提交會同時觸發所有子應用構建,牽一發動全身,是不合理的。

同時,如果某些業務的子應用的開發是跨部門跨團隊的,代碼倉庫如何分權限管理又是一個問題。

基於以上問題,我們不得不考慮將各個應用遷移到獨立的git倉庫。由於我們獨立倉庫了,項目可能不會再放到同一個目錄下,因此前面通過npm i file:../common方式安裝的common就不適用了,所以最好還是發布到公司的npm私服或采用git地址形式。

qiankun-example為了更好展示,仍將所有應用都放在同一個git倉庫下,請各位同學不要照抄。

子應用獨立倉庫后聚合管理

子應用獨立git倉庫后,可以做到獨立啟動獨立開發了,這時候又會遇到問題:開發環境都是獨立的,無法一覽整個應用的全貌

雖然開發時專注於某個子應用時更好,但總有需要整個項目跑起來的時候,比如當多個子應用需要互相依賴跳轉時,所以還是要有一個整個項目對所有子應用git倉庫的聚合管理才行,該聚合倉庫要求做到能夠一鍵install所有的依賴(包括子應用),一鍵啟動整個項目。

這里主要考慮了三種方案:

  1. 使用git submodule
  2. 使用git subtree
  3. 單純地將所有子倉庫放到聚合目錄下並.gitignore掉。
  4. 使用lerna管理。

git submodulegit subtree都是很好的子倉庫管理方案,但缺點是每次子應用變更后,聚合庫還得同步一次變更。

考慮到並不是所有人都會使用該聚合倉庫,子倉庫獨立開發時往往不會主動同步到聚合庫,使用聚合庫的同學就得經常做同步的操作,比較耗時耗力,不算特別完美。

所以第三種方案比較符合筆者目前團隊的情況。聚合庫相當於是一個空目錄,在該目錄下clone所有子倉庫,並gitignore,子倉庫的代碼提交都在各自的倉庫目錄下進行操作,這樣聚合庫可以避免做同步的操作。

由於ignore了所有子倉庫,聚合庫clone下來后,仍是一個空目錄,此時我們可以寫個腳本scripts/clone-all.sh,把所有子倉庫的clone命令都寫上:

# 子倉庫一 git clone git@xxx1.git # 子倉庫二 git clone git@xxx2.git 復制代碼

然后在聚合庫也初始化一個package.json,scripts加上:

  "scripts": { "clone:all": "bash ./scripts/clone-all.sh", }, 復制代碼

這樣,git clone聚合庫下來后,再npm run clone:all就可以做到一鍵clone所有子倉庫了。

前面說到聚合庫要能夠做到一鍵install和一鍵啟動整個項目,我們參考qiankun的examples,使用npm-run-all來做這個事情。

  1. 聚合庫安裝npm i npm-run-all -D
  2. 聚合庫的package.json增加install和start命令:
  "scripts": { ... "install": "npm-run-all --serial install:*", "install:main": "cd main && npm i", "install:sub-vue": "cd sub-vue && npm i", "install:sub-react": "cd sub-react && npm i", "start": "npm-run-all --parallel start:*", "start:sub-react": "cd sub-react && npm start", "start:sub-vue": "cd sub-vue && npm start", "start:main": "cd main && npm start" }, 復制代碼

npm-run-all--serial表示有順序地一個個執行,--parallel表示同時並行地運行。

配好以上,一鍵安裝npm i,一鍵啟動npm start

vscode eslint配置

如果使用vscode,且使用了eslint的插件做自動修復,由於項目處於非根目錄,eslint沒法生效,所以還需要指定eslint的工作目錄:

// .vscode/settings.json { "eslint.workingDirectories": [ "./main", "./sub-vue", "./sub-react", "./common" ], "eslint.enable": true, "editor.formatOnSave": false, "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "search.useIgnoreFiles": false, "search.exclude": { "**/dist": true }, } 復制代碼

子應用互相跳轉

除了點擊頁面頂部的菜單切換子應用,我們的需求也要求子應用內部跳其他子應用,這會涉及到頂部菜單active狀態的展示問題:sub-vue切換到sub-react,此時頂部菜單需要將sub-react改為激活狀態。有兩種方案:

  • 子應用跳轉動作向上拋給父應用,由父應用做真正的跳轉,從而父應用知道要改變激活狀態,有點子組件$emit事件給父組件的意思。
  • 父應用監聽history.pushState事件,當發現路由換了,父應用從而知道要不要改變激活狀態。

由於qiankun暫時沒有封裝子應用向父應用拋出事件的api,如iframe的postMessage,所以方案一有些難度,不過可以將激活狀態放到狀態管理中,子應用通過改變vuex中的值讓父應用同步就行,做法可行但不太好,維護狀態在狀態管理中有點復雜了。

所以我們這里選方案二,子應用跳轉是通過history.pushState(null, '/sub-react', '/sub-react')的,因此父應用在mounted時想辦法監聽到history.pushState就可以了。由於history.popstate只能監聽back/forward/go卻不能監聽history.pushState,所以需要額外全局復寫一下history.pushState事件。

// main/src/App.vue export default { methods: { bindCurrent () { const path = window.location.pathname if (this.microApps.findIndex(item => item.activeRule === path) >= 0) { this.current = path } }, listenRouterChange () { const _wr = function (type) { const orig = history[type] return function () { const rv = orig.apply(this, arguments) const e = new Event(type) e.arguments = arguments window.dispatchEvent(e) return rv } } history.pushState = _wr('pushState') window.addEventListener('pushState', this.bindCurrent) window.addEventListener('popstate', this.bindCurrent) this.$once('hook:beforeDestroy', () => { window.removeEventListener('pushState', this.bindCurrent) window.removeEventListener('popstate', this.bindCurrent) }) } }, mounted () { this.listenRouterChange() } } 復制代碼

性能優化

每個子應用都是一個完整的應用,每個vue子應用都打包了一份vue/vue-router/vuex。從整個項目的角度,相當於將那些模塊打包了多次,會很浪費,所以這里可以進一步去優化性能。

首先我們能想到的是通過webpack的externals或主應用下發公共模塊進行復用。

但是要注意,如果所有子應用都共用一個相同的模塊,從長遠來看,不利於子應用的升級,難以兩全其美。

現在覺得比較好的做法是:主應用可以下發一些自身用到的模塊,子應用可以優先選擇主應用下發的模塊,當發現主應用沒有時則自己加載;子應用也可以直接使用最新的版本而不用父應用下發的。

這個方案參考自qiankun 微前端方案實踐及總結-子項目之間的公共插件如何共享,思路說得非常完整,大家可以看看,本項目暫時還沒加上該功能。

部署

現在網上qiankun部署相關的文章幾乎搜不到,可能是覺得簡單沒啥好說的吧。但對於還不太熟悉的同學來說,其實會比較糾結qiankun部署的最佳部署方案是怎樣的呢?所以覺得很有必要講一下筆者這里的部署方案,供大家參考。

方案如下:

考慮到主應用和子應用共用域名時可能會存在路由沖突的問題,子應用可能會源源不斷地添加進來,因此我們將子應用都放在xx.com/subapp/這個二級目錄下,根路徑/留給主應用。

步驟如下:

  1. 主應用main和所有子應用都打包出一份html,css,js,static,分目錄上傳到服務器,子應用統一放到subapp目錄下,最終如:
├── main
│   └── index.html
└── subapp
    ├── sub-react
    │   └── index.html
    └── sub-vue
        └── index.html
復制代碼
  1. 配置nginx,預期是xx.com根路徑指向主應用,xx.com/subapp指向子應用,子應用的配置只需寫一份,以后新增子應用也不需要改nginx配置,以下應該是微應用部署的最簡潔的一份nginx配置了。
server {
    listen       80;
    server_name qiankun.fengxianqi.com;
    location / {
        root   /data/web/qiankun/main;  # 主應用所在的目錄 index index.html; try_files $uri $uri/ /index.html; } location /subapp { alias /data/web/qiankun/subapp; try_files $uri $uri/ /index.html; } } 復制代碼

nginx -s reload后就可以了。

本文特地做了線上demo展示:

整站(主應用):qiankun.fengxianqi.com/

單獨訪問子應用:

遇到的問題

一、react子應用啟動后,主應用第一次渲染后會掛掉

子應用的熱重載居然會引得父應用直接掛掉,當時完全懵逼了。還好搜到了相關的issues/340,即在復寫react的webpack時禁用掉熱重載(加了下面配置禁用后會導致沒法熱重載,react應用在開發時得手動刷新了,是不是有點難受。。。):

module.exports = {
  webpack: function override(config, env) {
    // 解決主應用接入后會掛掉的問題:https://github.com/umijs/qiankun/issues/340
    config.entry = config.entry.filter(
      (e) => !e.includes('webpackHotDevClient')
    );
    // ...
    return config;
  }
};
復制代碼

二、Uncaught Error: application 'xx' died in status SKIP_BECAUSE_BROKEN: [qiankun] Target container with #subapp-viewport not existed while xx mounting!

在本地dev開發時是完全正常的,這個問題是部署后在首次打開頁面才會出現的,F5刷新后又會正常,只能在清掉緩存后復現一次。這個bug困擾了幾天。

錯誤信息很清晰,即主應用在掛載xx子應用時,用於裝載子應用的dom不存在。所以一開始以為是vue做主應用時,#subapp-viewport還沒來得及渲染,因此要嘗試確保主應用mount后再注冊子應用。

// 主應用的main.js
new Vue({
  render: h => h(App),
  mounted: () => {
    // mounted后再注冊子應用
    renderMicroApps();
  },
}).$mount('#root-app');
復制代碼

但該辦法不行,甚至setTimeout都用上了也不行,需另想辦法。

最后逐步調試發現是項目加載了一段高德地圖的js導致的,該js在首次加載時會使用document.write去復寫整個html,因此導致了#subapp-viewport不存在的報錯,所以最后是要想辦法去掉該js文件就可以了。

小插曲:為什么我們的項目會加載這個高德地圖js?我們項目也沒有用到啊,這時我們陷入了一個思維誤區:qiankun是阿里的,高德也是阿里的,qiankun不會偷偷在渲染時動態加載高德的js做些數據收集吧?非常慚愧會對一個開源項目有這個想法。。。實際上,是因為我司寫組件庫模板的小伙伴忘記移除調試時public/index.html用到的這個js了,當時還去評論issue了(捂臉哭)。把這個講出來,是想說遇到bug時還是要先檢查一下自己,別輕易就去質疑別人。

最后

本文從開始搭建到部署非常完整地分享了整個架構搭建的一些思路和實踐,希望能對大家有所幫助。要提醒一下的是,本示例可能不一定最佳的實踐,僅作為一個思路參考,架構是會隨着業務需求不斷調整變化的,只有合適的才是最好的。

示例代碼: github.com/fengxianqi/…

在線demo:qiankun.fengxianqi.com/

單獨訪問在線子應用:

最后的最后,喜歡本文的同學還請能順手給個贊和小星星鼓勵一下,非常感謝看到這里。

一些參考文章


作者:fengxianqi
鏈接:https://juejin.im/post/6875462470593904653
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM