微前端重構實踐落地總結

語言: CN / TW / HK

前言

大家好。最近換到了新部門,在做智慧平臺相關的內容。我接到的第一個任務就是把以前前端的專案重構一次。

說是重構,不如說是重寫一遍。因為原來的專案是 ant-design-vue + vue 全家桶 ,要切換成 ant-design + ant-design-pro + react 全家桶

更讓人頭疼的是,產品經理並不會讓我們有大把大把時間專門搞重構,我們要邊重構邊做需求。在這樣的挑戰下,我想到了微前端解決方案,下面就跟大家分享這次 微前端在重構上的落地實踐吧

這次實踐我簡化了一下,放在 Github 上,大家可以自行 clone 來玩玩。

技術棧

首先,來講講技術棧,老專案主要用了下面的技術:

  • 框架

    • Vue

    • vuex

    • vue-router

  • 樣式

    • scss

  • UI

    • ant-design-vue

    • ant-design-pro for vue

  • 腳手架

    • vue-cli

新專案需要用到的技術有:

  • 框架

    • React

    • redux + redux-toolkit

    • react-router

  • 新式

    • less

  • UI

    • react-design-react

    • react-design-pro for react

  • 腳手架

    • 團隊內部自創腳手架

可以看到兩個專案除了業務之外,幾乎沒什麼交集了。

微前端策略

老專案作為主應用,通過 qiankun 去載入新專案(子應用)裡的頁面。

  • 當沒有需求時,在新專案(子應用)重寫頁面,重寫完了之後,在老專案(主應用)中載入新專案的頁面,下掉老專案的頁面

  • 當有需求時,也是在新專案(子應用)重寫面面再做對應需求(向產品要多點時間),重寫完了之後,在老專案(主應用)中載入新專案的頁面

這樣一來就可以避免 “我要一整個月都做重構” 的局面,而是可以做到一個頁面一個頁地慢慢遷移。最終等所有頁面都在新專案寫好之後,直接把老專案下掉,新專案就可以從幕後站出來了。相當於從重寫的第一天開始,老專案就成替身了。

如果只看上面畫的架構圖,會覺得:啊,不就引入一個 qiankun 就完事了麼?實際上還有很細節和問題需要注意的。

升級版架構

上圖的架構有一個問題就是,當每次點選側邊欄的 MenuItem 時,都會載入一次微應用的子頁面,也即:

微應用子頁面之間的切換,其實就是在微應用里路由切換嘛,大可不需要通過重新載入一次微應用來做微應用子頁面的切換。

所以,我想了一個辦法:我在 <router-view> 旁邊放了一個元件 Container 。進入主應用後,這個元件先直接把微應用整個都載入了。

<a-layout>
  <!--  頁面    -->
  <a-layout-content>
    <!--   子應用容器     -->
    <micro-app-container></micro-app-container>
    <!--   主應用路由     -->
    <router-view/>
  </a-layout-content>
</a-layout>

當展示老頁面時,把這個 Container 高度設為 0 ,要展示新頁面時,再把 Container 高度自動撐開。

// micro-app-container

<template>
<div class="container" :style="{ height: visible ? '100%' : 0 }">
  <div id="micro-app-container"></div>
</div>
</template>

<script>
import { registerMicroApps, start } from 'qiankun'

export default {
  name: "Container",
  props: {
    visible: {
      type: Boolean,
      defaultValue: false,
    }
  },
  mounted() {
    registerMicroApps([
      {
        name: 'microReactApp',
        entry: '//localhost:3000',
        container: '#micro-app-container',
        activeRule: '/#/micro-react-app',
      },
    ])
    start()
  },
}
</script>

這樣一來,當進入老專案時,這個 Container 自動被 mounted 後就會地去載入子應用了。當在切換新頁面時,本質上是在子應用裡做路由切換,而不是從 A 應用切換到 B 應用了。

子應用的佈局

由於新的專案(子應用)裡的頁面要供給老專案(主應用)來使用的,所以子應用也應該有兩套佈局:

第一套標準的管理後臺佈局,有 SiderHeader 還有 Content ,另一套側作為子應用時,只展示 Content 部分的佈局。

// 單獨執行時的佈局
export const StandaloneLayout: FC = () => {
  return (
    <AntLayout className={styles.layout}>
      <Sider/>
      <AntLayout>
        <Header />
        <Content />
      </AntLayout>
    </AntLayout>
  )
}

// 作為子應用時的佈局
export const MicroAppLayout = () => {
  return (
    <Content />
  )
}
單獨執行時的佈局
作為微應用時的佈局

最後通過 window.__POWERED_BY_QIANKUN__ 就可以切換不同的佈局了。

import { StandaloneLayout, MicroAppLayout } from "./components/Layout";

const Layout = window.__POWERED_BY_QIANKUN__ ? MicroAppLayout : StandaloneLayout;

function App() {
  return (
    <Layout/>
  );
}

樣式衝突

qiankun 是預設開啟 JS 隔離(沙箱),關閉 CSS 樣式隔離的。為什麼這麼做呢?因為 CSS 的隔離是不能無腦做去做的,下面來講講這方面的問題。

qiankun 一共提供了兩種 CSS 隔離方法(沙箱): 嚴格沙箱 以及 實驗性沙箱

嚴格沙箱

開啟程式碼:

start({
  sandbox: {
    strictStyleIsolation: true,
  }
})

嚴格沙箱主要通過 ShadowDOM 來實現 CSS 樣式隔離,效果是當子應用被掛在到 ShadowDOM 上,主子應用的樣式 完完全全 地被隔離,無法互相影響。你說:這不是很好麼?No No No。

這種沙箱的優點也成為了它自己的缺點:除了樣式的硬隔離,DOM 元素也直接硬隔離了,導致子應用的一些 ModalPopoverDrawer 元件會因為找不到主應用的 body 而丟失,甚至跑到整個螢幕之外。

還記得我剛說主應用和子應用都用了 ant-design 麼?ant-design 的 ModalPopover Drawer 的實現方式就是要掛在到 document.body 上的,這麼一隔離,它們一掛在整個元素起飛了。

實驗性沙箱

開啟程式碼:

start({
  sandbox: {
    experimentalStyleIsolation: true,
  }
})

這種沙箱實現方式就是給子應用的樣式加字尾標籤,有點像 Vue 裡的 scoped ,通過名字來做樣式 “軟隔離”,比如像這樣:

其實這種方式已經很好地做了樣式隔離,但是主應用裡經常有人喜歡寫 !important 來覆蓋 ant-design 的元件原樣式:

.ant-xxx {
   color: white: !important;
}

!importnant 的優先順序是最高的,如果微應用也用了這個 .ant-xxx 類,就很容易被主應用的樣式影響了。所以在載入微應用時,還需要處理 ant-design 之間的樣式衝突問題。

ant-design 樣式衝突

ant-design 提供了一個非常好的類名字首功能:用 prefixCls 來做樣式隔離,我自然也用上了:

// 自定義字首
const prefixCls = 'cmsAnt';

// 設定 Modal、Message、Notification rootPrefixCls
ConfigProvider.config({
  prefixCls,
})

// 渲染
function render(props: any) {
  const { container, state, commit, dispatch } = props;

  const value = { state, commit, dispatch };

  const root = (
    <ConfigProvider prefixCls={prefixCls}>
      <HashRouter basename={basename}>
        <MicroAppContext.Provider value={value}>
          <App />
        </MicroAppContext.Provider>
      </HashRouter>
    </ConfigProvider>
  );

  ReactDOM.render(root, container
    ? container.querySelector('#root')
    : document.querySelector('#root'));
}
@ant-prefix: cmsAnt; // 引入來改變全域性變數值

但是不知道為什麼,在 less 檔案中改了 ant-prefix 變數後,ant-design-pro 的樣式還是老樣子,有的元件樣式改變了,有的沒變化。

最後,我是通過 less-loadermodifyVars 在打包時來更新全域性的 ant-prefix less 變數才搞定的:

var webpackConfig = {
  test: /.(less)$/,
  use: [
    ...
    {
      loader: 'less-loader',
      options: {
        lessOptions: {
          modifyVars: {
            'ant-prefix': 'cmsAnt'
          },
          sourceMap: true,
          javascriptEnabled: true,
        }
      }
    }
  ]
}

具體 Issue 看 Issue: ant-design 改了 prefixCls 後 ant-design-pro 不生效。

主子應用狀態管理

老專案(主應用)用到了 vuex 全域性狀態管理,所以新專案頁面(子應用)裡有時需要更改主應用裡的狀態,這裡我用了 qiankun 的 globalState 來處理。

首先在 Container 裡建立了 globalActions ,再監聽 vuex 狀態變更,每次變更都通知子應用,同時把 vuex 的 commitdispatch 函式傳給子應用:

import {initGlobalState, registerMicroApps, start} from 'qiankun'

const globalActions = initGlobalState({
  state: {},
  commit: null,
  dispatch: null,
});

export default {
  name: "Container",
  props: {
    visible: {
      type: Boolean,
      defaultValue: false,
    }
  },
  mounted() {
    const { dispatch, commit, state } = this.$store;
    registerMicroApps([
      {
        name: 'microReactApp',
        entry: '//localhost:3000',
        container: '#micro-app-container',
        activeRule: '/#/micro-react-app',
        // 初始化時就傳入主應用的狀態和 commit, dispatch
        props: {
          state,
          dispatch,
          commit,
        }
      },
    ])
    
    start()
    
    // vuex 的 store 變更後再次傳入主應用的狀態和 commit, dispatch
    this.$store.watch((state) => {
      console.log('state', state);
      globalActions.setGlobalState({
        state,
        commit,
        dispatch
      });
    })
  },
}

子應用裡接收主應用傳來的 statecommit 以及 dispatch 函式,同時新起一個 Context,把這些東西都放到 MicroAppContext 裡。(Redux 因為不支援存放函式這種 nonserializable 的值,所以只能先存到 Context 裡)

// 渲染
function render(props: any) {
  const { container, state, commit, dispatch } = props;

  const value = { state, commit, dispatch };

  const root = (
    <HashRouter basename={basename}>
      <MicroAppContext.Provider value={value}>
        <App />
      </MicroAppContext.Provider>
    </HashRouter>
  );

  ReactDOM.render(root, container
    ? container.querySelector('#root')
    : document.querySelector('#root'));
}

// mount 時監聽 globalState,只要一改再次渲染 App
export async function mount(props: any) {
  console.log('[micro-react-app] mount', props);
  props.onGlobalStateChange((state: any) => {
    console.log('[micro-react-app] vuex 狀態更新')
    render(state);
  })
  render(props);
}

這樣一來,子應用也可以通過 commit ,和 dispatch 來更改主應用的值了。

const OrderList: FC = () => {
  const { state, commit } = useContext(MicroAppContext);

  return (
    <div>
      <h1 className="title">【微應用】訂單列表</h1>

      <div>
        <p>主應用的 Counter: {state.counter}</p>
        <Button type="primary" onClick={() => commit('increment')}>【微應用】+1</Button>
        <Button danger onClick={() => commit('decrement')}>【微應用】-1</Button>
      </div>
    </div>
  )
}

當然了,這樣的實踐也是我自己 “發明” 的,不知道這是不是一個好的實踐,我只能說這樣能 Work。

全域性變數報錯

另一個問題就是當子應用隱式使用全域性變數時, import-html-entry 執行 JS 時會直接爆炸。比如微應用有如下 <script> 的程式碼:

var x = {}; // 報錯,要改成 window.x = {};

x.a = 1 // 報錯,要改成 window.x.a = 1;

function a() {} // 要改成 window.a = () => {}

a() // 報錯,要改成 window.a()

在主應用載入微應用後,上面的 xa 全都會報 xxx is undefined ,這是因為 qiankun 在載入微應用時,會執行這部分 JS 程式碼,而此時 var 宣告的變數不再是全域性變數,其他的檔案無法獲取到。

解決方法就是使用 window.xxx 來顯式定義/使用全域性變數。具體可見 Issue: 子應用全域性變數 undefined

主應用切換路由時不更新子應用路由

只要主子應用都用上了 Hash 路由,那麼很大概率會遇到這個問題。

比如你主應用有 /micro-app/home/micro-app/user 兩個路由, actvieRule/#/micro-app ,子應用也有對應的 /micro-app/home/micro-app/user 兩個路由。

那麼如果 在主應用裡/micro-app/home 切換到 /micro-app/user ,會發現子應用的路由並沒有改變。但如果你 在主應用的子應用裡 去切換,那麼就能切換成功。

這是因為在主應用切換路由時不是通過 location.url 這種可以觸發 hash change 事件的方式來變更路由,而 react-router 只監聽了 hash change 事件,所以當主應用切換路由時,沒有觸發 hash change 事件,導致子應用的監聽不到路由變化,也就不會做頁面切換了。

具體可見:Issue: 載入子應用正常,但主應用切換路由,子應用不跳轉,瀏覽器返回前進可觸發子應用跳轉。

解決方法很簡單,下面三選一:

  • 將 vue 主應用中的 Link 超鏈方式替換成原生的 a 標籤,從而觸發瀏覽器的 hash change 事件

  • 主應用手動監聽路由變更,同時手動觸發 hash change 事件

  • 主應用跟子應用都改用 browser history 模式

載入狀態

主應用在載入子應用時還是需要不少時間的,所以最好要展示一個載入中的狀態,qiankun 正好提供了一個 loader 回撥來讓我們控制子應用的載入狀態:

<div class="container" :style="{ height: visible ? '100%' : 0 }">
  <a-spin v-if="loading"></a-spin>
  <div id="micro-app-container"></div>
</div>
registerMicroApps([
  {
    name: 'microReactApp',
    entry: '//localhost:3000',
    container: '#micro-app-container',
    activeRule: '/#/micro-react-app',
    props: {
      state,
      dispatch,
      commit,
    },
    loader: (loading) => {
      this.loading = loading // 控制載入狀態
    }
  },
])
start()

總結

總的來說,微前端在解構巨石應用的幫助真的很大。像我們這種要重構整個應用的情況,部門肯定不會先暫停業務,給開發一整個月來專門重構的,只能在評新需求的時候多給你一兩天時間而已。

微前端就可以解決重構的過程中邊做新需求邊重構的問題,使得新老頁面都能共存,不會一下子整個業務都停掉來做重構工作。

如果你也喜歡我的文章,可以給個在看、點贊、關注哦~