聊聊微前端的原理和實踐

vivo網際網路技術發表於2020-07-27

本文首發於 vivo網際網路技術 微信公眾號 
連結: https://mp.weixin.qq.com/s/2qH9qMNpU_LuLEBTsDUKzA
作者:Tan Xin

本文對微前端的概念和場景進行科普,介紹一些主流的微前端的實現庫及其用法,並講解部分這些庫的原理和實踐知識。

一、微前端

在專案迭代中,隨著業務的發展壯大,專案的功能模組通常也會越來越多。可能原來所有的程式碼模組都在一個倉庫裡,由一個團隊負責。但隨著功能模組越來越多,一個團隊可能負責不過來,需要多個團隊來專門維護不同的模組。相應的程式碼也會被拆到多個倉庫裡,並且各模組能獨立開發、部署更新。通常雖然專案被拆成了多個模組,但為了維持整體統一性以及使用者體驗,各模組依然都會掛在統一的入口下。

上面所述場景就是典型的微前端場景,類似於後端的微服務架構,它將web應用由單一的單體應用轉變為多個小型前端應用聚合為一的應用。

通常,要實現上面類似的需求,我們很容易會想到使用iframe的方式來實現。在入口框架中用iframe來顯示子模組的頁面,切換子模組時,iframe也跟著切換成對應子模組頁面的url。

雖然iframe是比較容易實現的,但通常也會有一些問題:

  1. 顯示區域受限制,比如子專案中顯示彈窗蒙層時,蒙層只會覆蓋iframe區域,無法覆蓋整個頁面,內容也無法真正居中。
  2. 頁面瀏覽記錄無法自動被記錄,重新整理頁面後iframe又自動回到首頁。
  3. 全域性上下文完全隔離,變數不共享,頁面間通訊比較麻煩,比如子專案與主題框架、子專案之間通訊等,只能採用postMessage方式。
  4. 速度較慢,每次進入子應用時都要重建整個上下文。

上面所列問題,有些可以解決,有些甚至都沒法或者很難解決。總的來說,iframe是一個比較快捷的方案,但不是最好的方案,會對體驗有很多限制。如果強行打各種patch,複雜度又上來了,最後可能得不償失。

二、single-spa

剛才我們講了iframe實現微前端的一些弊端,主要原因就是這些應用還是在各自獨立的頁面內,這就導致了一些天然的限制。而single-spa微前端方案結合了MPA和SPA的優勢,可以在單個頁面內整合多個應用,並且是技術棧無關的。

 

如上圖就是採用single-spa實現微前端的整體流程:

資源模組載入器:用來載入子專案初始化資源。我們將子專案的入口js構建成umd格式,然後使用模組載入器遠端載入,通常會使用SystemJs(不是必須)通用模組載入器來進行載入。

子應用資源配置表:用來記錄各個子應用的入口資源url資訊,以便在切換不同子應用時使用模組載入器去遠端載入。因為每次子應用更新後入口資源的hash通常會變化,所以需要服務端定時去更新該配置表,以便框架能及時載入子應用最新的資源。

注意:single-spa本身是不支援子應用資源列表的,每個子應用只能將自己所有初始化資源打包到一個入口js中。如果子應用初始化資源有多個檔案(可以通過webpack-manifest-plugin生成應用初始化資源清單),就需要按照上述方式來新增額外處理。

1、框架入口

<!DOCTYPE html>
<html>
  
<head>
  <!-- 在systemjs中註冊模組 -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "app1": "http://localhost:8081/js/app.js",
        "app2": "http://localhost:8082/js/app.js",
        "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js",
        "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js",
        "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js",
        "vuex": "https://cdnjs.cloudflare.com/ajax/libs/vuex/3.1.2/vuex.min.js"
      }
    }
</script>
</head>
  
<body>
  <div></div>
  <!-- 載入systemjs -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/system.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/amd.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/named-exports.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/named-register.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/use-default.min.js"></script>
  <script>
    (function () {
      // 載入公共js庫
      Promise.all([System.import('single-spa'), System.import('vue'), System.import('vue-router'), System.import('vuex')]).then(function (modules) {
        var singleSpa = modules[0];
        var Vue = modules[1];
        var VueRouter = modules[2];
        var Vuex = modules[3];
  
        Vue.use(VueRouter)
        Vue.use(Vuex)
  
        // single-spa註冊子應用
        singleSpa.registerApplication(
          'app1',
          () => System.import('app1'),
          location => location.pathname.startsWith('/app1')
        )
  
        singleSpa.registerApplication(
          'app2',
          () => System.import('app2'),
          location => location.pathname.startsWith('/app2')
        )
  
        // 啟動
        singleSpa.start();
      })
    })()
</script>
</body>
  
</html>

為了簡單展示,上述只是框架入口html的一個簡單demo,並沒有解析子應用資源配置表來載入相應資源。在入口中我們註冊了子應用,並確定了子應用的啟用時機。

  • 子應用資源配置表是完全自定義的,只要入口載入器這邊按照約定的規範來解析載入資源,並按照single-spa的生命週期鉤子來處理好這些資源的掛載。

  • 我們還可以將一些公共的資源庫資源庫(如上vue、vue-router等)抽取到入口中,這樣各個子應用不需要再包含這些庫檔案了,可以減小資原始檔大小,提升載入速度。子應用中構建時要外接這些庫,比如用webpack構建時如下:

externals: ['vue', 'vue-router', 'vuex']

2、子應用入口

import './set-public-path'
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'
  
Vue.config.productionTip = false
  
if (process.env.NODE_ENV === 'development') {
  // 開發環境直接渲染
  new Vue({
    router,
    render: h => h(App)
  }).$mount('#app')
}
  
const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: {
    render: (h) => h(App),
    router
  }
})
  
export const bootstrap = vueLifecycles.bootstrap
export const mount = vueLifecycles.mount
export const unmount = vueLifecycles.unmount[object Object]

 

如上我們的子應用是vue開發的,需要用single-spa-vue來包裝下,然後匯出生命週期的鉤子函式。為了方便開發,我們可以判斷下執行環境,如果是開發環境的話,就直接渲染到頁面上。

set-public-path.js

細心的同學就會注意到,子應用程式碼中執行了set-public-path.js。那麼這個檔案是幹嘛用的呢?先來看下:

import { setPublicPath } from 'systemjs-webpack-interop'
setPublicPath('app1', 2)

從名字也能看出,systemjs-webpack-interop是針對在systemjs中使用webpack構建的bundle的場景的。眾所周知,webpack構建程式碼時,可以通過output.publicPath選項指定要載入資源的url字首,這在傳統的spa中不會有問題,但在single-spa的頁面中可能會有問題。比如output.publicPath: '/xx'的情況,webpack會認為非同步資源載入的url域名為當前頁面的域名,這在傳統spa中不會有問題,但在single-spa的場景下非同步資源就會載入失敗,因為子應用的非同步資源與框架頁面的url域名並不是一樣的。所以需要各個子應用自行在入口中執行上述程式碼,這會設定子應用的非同步資源url字首與子應用的入口js一致,這樣載入的路徑就不會錯誤了。

setPublicPath程式碼如下:

export function setPublicPath(systemjsModuleName, rootDirectoryLevel) {
  if (!rootDirectoryLevel) {
    rootDirectoryLevel = 1;
  }
 
 
  if (
    typeof systemjsModuleName !== "string" ||
    systemjsModuleName.trim().length === 0
 
  ) {
 
    throw Error(
      "systemjs-webpack-interop: setPublicPath(systemjsModuleName) must be called with a non-empty string 'systemjsModuleName'"
 
    );
 
  }
 
 
  if (
    typeof rootDirectoryLevel !== "number" ||
    rootDirectoryLevel <= 0 ||
    !Number.isInteger(rootDirectoryLevel)
  ) {
 
    throw Error(
      "systemjs-webpack-interop: setPublicPath(systemjsModuleName, rootDirectoryLevel) must be called with a positive integer 'rootDirectoryLevel'"
    );
 
  }
 
 
  let moduleUrl;
  try {
    moduleUrl = window.System.resolve(systemjsModuleName);
    if (!moduleUrl) {
      throw Error()
 
    }
 
 
  } catch (err) {
 
    throw Error(
      "systemjs-webpack-interop: There is no such module '" +
        systemjsModuleName +
        "' in the SystemJS registry. Did you misspell the name of your module?"
    );
 
 
  }
 
  __webpack_public_path__ = resolveDirectory(moduleUrl, rootDirectoryLevel);
 
}
 
function resolveDirectory(urlString, rootDirectoryLevel) {
  const url = new URL(urlString);
  const pathname = new URL(urlString).pathname;
  let numDirsProcessed = 0,
    index = pathname.length;
 
  while (numDirsProcessed !== rootDirectoryLevel && index >= 0) {
    const char = pathname[--index];
    if (char === "/") {
      numDirsProcessed++;
    }
  }
 
  if (numDirsProcessed !== rootDirectoryLevel) {
    throw Error(
      "systemjs-webpack-interop: rootDirectoryLevel (" +
        rootDirectoryLevel +
        ") is greater than the number of directories (" +
        numDirsProcessed +
        ") in the URL path " +
        fullUrl
    );
 
  }
 
  url.pathname = url.pathname.slice(0, index + 1);
  return url.href;
 
}

三、single-spa的不足

  1. 如上面提到過,如果子應用初始化資源有多個檔案(比如通常我們會將css、npm模組抽離成一個單獨的檔案),那麼我們就要自行維護一個子應用資源列表並做一些額外處理,這個工作往往也是比較繁瑣的;

  2. 將多個子應用都整合在一個頁面中,css和js都是很有可能產生衝突的。雖然我們可以制定規範,比如各子專案使用唯一地命名字首等,但這種人為約定往往又是不那麼靠譜。對於css,我們還可以在構建時使用一些工具自動新增字首,這樣可以比較靠譜的避免衝突;對於js來說,比較靠譜的方式可能就是人為製造沙箱,讓子應用的js都執行在各自的沙箱中,但這實現起來就比較複雜了。

四、qiankun

其實,已經有個基於single-spa的開源庫qiankun已經幫我們解決了上面提到的問題,其有如下特徵:

  • 解析子應用入口時,不是解析的js檔案,二是直接解析子應用的html檔案。就運算元應用更新了,其入口html檔案的url始終不會變,並且完整的包含了所有的初始化資源url,所以不用再自行維護子應用的資源列表了。

  • 子應用掛載時,會自動進行一些特殊處理,可以確保子應用所有的資源dom(包括js新增的style標籤等)都集中在子應用根節點dom下。子應用解除安裝時,對應的整個dom都移除了,這樣也就避免了樣式衝突。

  • 提供了js沙箱,子應用掛載時,會對全域性window物件代理、對全域性事件監聽進行劫持等,確保各子應用都執行在自己的沙箱內,這樣也就避免了js衝突。

包含多個spa應用的demo

 

子應用 dom 結構如下

 

當然,在前端越來越龐大複雜的場景中,微前端方案也不是銀彈,但確是值得探索實踐的方向。

五、參考文獻

  1. single-spa

  2. qiankun

  3. 可能是你見過最完善的微前端解決方案

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912579/viewspace-2705354/,如需轉載,請註明出處,否則將追究法律責任。

相關文章