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

最近負責的新項目用到了qiankun,寫篇文章分享下實戰中遇到的一些問題和思考。javascript

示例代碼: https://github.com/fengxianqi/qiankun-examplecss

在線demo:http://qiankun.fengxianqi.com/html

單獨訪問在線子應用:前端

爲何要用qiankun

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

  • iframe方案
  • qiankun微前端方案

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

技術同窗對自身的成長也是有強烈需求的,所以在二者都能知足業務需求時,咱們更但願能應用一些較新的技術,折騰一些未知的東西,所以咱們決定選用qiankunreact

項目架構

後臺系統通常都是上下或左右的佈局。下圖粉紅色是基座,只負責頭部導航,綠色是掛載的整個子應用,點擊頭部導航可切換子應用。
imagewebpack

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

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

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

基座配置

基座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>中。

子應用配置

1、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不需用到,能夠去掉。

2、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用得不深就不說了)。

image

主應用的狀態封裝

主應用維護一個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上有討論:https://github.com/umijs/qiankun/pull/729

一方面,做者認爲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展現:

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

單獨訪問子應用:

遇到的問題

1、react子應用啓動後,主應用第一次渲染後會掛掉

image
子應用的熱重載竟然會引得父應用直接掛掉,當時徹底懵逼了。還好搜到了相關的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;
  }
};

2、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時仍是要先檢查一下本身,別輕易就去質疑別人。

最後

本文從開始搭建到部署很是完整地分享了整個架構搭建的一些思路和實踐,但願能對你們有所幫助。要提醒一下的是,本示例可能不必定最佳的實踐,僅做爲一個思路參考,架構是會隨着業務需求不斷調整變化的,只有合適的纔是最好的。

示例代碼: https://github.com/fengxianqi/qiankun-example

在線demo:http://qiankun.fengxianqi.com/

單獨訪問在線子應用:

最後的最後,喜歡本文的同窗還請能順手給個贊和小星星鼓勵一下,很是感謝看到這裏。

一些參考文章