聊聊微前端的原理和實(shí)踐

本文首發(fā)于 vivo互聯(lián)網(wǎng)技術(shù) 微信公眾號(hào)
鏈接:https://mp.weixin.qq.com/s/2qH9qMNpU_LuLEBTsDUKzA
作者:Tan Xin

本文對(duì)微前端的概念和場(chǎng)景進(jìn)行科普粟瞬,介紹一些主流的微前端的實(shí)現(xiàn)庫及其用法莲组,并講解部分這些庫的原理和實(shí)踐知識(shí)。

一济舆、微前端

在項(xiàng)目迭代中,隨著業(yè)務(wù)的發(fā)展壯大,項(xiàng)目的功能模塊通常也會(huì)越來越多滤蝠。可能原來所有的代碼模塊都在一個(gè)倉庫里授嘀,由一個(gè)團(tuán)隊(duì)負(fù)責(zé)物咳。但隨著功能模塊越來越多,一個(gè)團(tuán)隊(duì)可能負(fù)責(zé)不過來蹄皱,需要多個(gè)團(tuán)隊(duì)來專門維護(hù)不同的模塊所森。相應(yīng)的代碼也會(huì)被拆到多個(gè)倉庫里,并且各模塊能獨(dú)立開發(fā)夯接、部署更新。通常雖然項(xiàng)目被拆成了多個(gè)模塊纷妆,但為了維持整體統(tǒng)一性以及用戶體驗(yàn)盔几,各模塊依然都會(huì)掛在統(tǒng)一的入口下。

image

上面所述場(chǎng)景就是典型的微前端場(chǎng)景掩幢,類似于后端的微服務(wù)架構(gòu)逊拍,它將web應(yīng)用由單一的單體應(yīng)用轉(zhuǎn)變?yōu)槎鄠€(gè)小型前端應(yīng)用聚合為一的應(yīng)用。

通常际邻,要實(shí)現(xiàn)上面類似的需求芯丧,我們很容易會(huì)想到使用iframe的方式來實(shí)現(xiàn)。在入口框架中用iframe來顯示子模塊的頁面世曾,切換子模塊時(shí)缨恒,iframe也跟著切換成對(duì)應(yīng)子模塊頁面的url。

雖然iframe是比較容易實(shí)現(xiàn)的轮听,但通常也會(huì)有一些問題:

  1. 顯示區(qū)域受限制骗露,比如子項(xiàng)目中顯示彈窗蒙層時(shí),蒙層只會(huì)覆蓋iframe區(qū)域血巍,無法覆蓋整個(gè)頁面萧锉,內(nèi)容也無法真正居中。
  2. 頁面瀏覽記錄無法自動(dòng)被記錄述寡,刷新頁面后iframe又自動(dòng)回到首頁柿隙。
  3. 全局上下文完全隔離叶洞,變量不共享,頁面間通信比較麻煩禀崖,比如子項(xiàng)目與主題框架衩辟、子項(xiàng)目之間通信等,只能采用postMessage方式帆焕。
  4. 速度較慢惭婿,每次進(jìn)入子應(yīng)用時(shí)都要重建整個(gè)上下文。

上面所列問題叶雹,有些可以解決财饥,有些甚至都沒法或者很難解決≌刍蓿總的來說钥星,iframe是一個(gè)比較快捷的方案,但不是最好的方案满着,會(huì)對(duì)體驗(yàn)有很多限制谦炒。如果強(qiáng)行打各種patch,復(fù)雜度又上來了风喇,最后可能得不償失宁改。

二、single-spa

剛才我們講了iframe實(shí)現(xiàn)微前端的一些弊端魂莫,主要原因就是這些應(yīng)用還是在各自獨(dú)立的頁面內(nèi)还蹲,這就導(dǎo)致了一些天然的限制。而single-spa微前端方案結(jié)合了MPA和SPA的優(yōu)勢(shì)耙考,可以在單個(gè)頁面內(nèi)集成多個(gè)應(yīng)用谜喊,并且是技術(shù)棧無關(guān)的。

image

如上圖就是采用single-spa實(shí)現(xiàn)微前端的整體流程:

資源模塊加載器:用來加載子項(xiàng)目初始化資源倦始。我們將子項(xiàng)目的入口js構(gòu)建成umd格式斗遏,然后使用模塊加載器遠(yuǎn)程加載,通常會(huì)使用SystemJs(不是必須)通用模塊加載器來進(jìn)行加載鞋邑。

子應(yīng)用資源配置表:用來記錄各個(gè)子應(yīng)用的入口資源url信息诵次,以便在切換不同子應(yīng)用時(shí)使用模塊加載器去遠(yuǎn)程加載。因?yàn)槊看巫討?yīng)用更新后入口資源的hash通常會(huì)變化枚碗,所以需要服務(wù)端定時(shí)去更新該配置表藻懒,以便框架能及時(shí)加載子應(yīng)用最新的資源。

注意:single-spa本身是不支持子應(yīng)用資源列表的视译,每個(gè)子應(yīng)用只能將自己所有初始化資源打包到一個(gè)入口js中嬉荆。如果子應(yīng)用初始化資源有多個(gè)文件(可以通過webpack-manifest-plugin生成應(yīng)用初始化資源清單),就需要按照上述方式來添加額外處理酷含。

1鄙早、框架入口

<!DOCTYPE html>
<html>
  
<head>
  <!-- 在systemjs中注冊(cè)模塊 -->
  <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注冊(cè)子應(yīng)用
        singleSpa.registerApplication(
          'app1',
          () => System.import('app1'),
          location => location.pathname.startsWith('/app1')
        )
  
        singleSpa.registerApplication(
          'app2',
          () => System.import('app2'),
          location => location.pathname.startsWith('/app2')
        )
  
        // 啟動(dòng)
        singleSpa.start();
      })
    })()
</script>
</body>
  
</html>

為了簡(jiǎn)單展示汪茧,上述只是框架入口html的一個(gè)簡(jiǎn)單demo,并沒有解析子應(yīng)用資源配置表來加載相應(yīng)資源限番。在入口中我們注冊(cè)了子應(yīng)用舱污,并確定了子應(yīng)用的激活時(shí)機(jī)。

子應(yīng)用資源配置表是完全自定義的弥虐,只要入口加載器這邊按照約定的規(guī)范來解析加載資源扩灯,并按照single-spa的生命周期鉤子來處理好這些資源的掛載。

我們還可以將一些公共的資源庫資源庫(如上vue霜瘪、vue-router等)抽取到入口中珠插,這樣各個(gè)子應(yīng)用不需要再包含這些庫文件了,可以減小資源文件大小颖对,提升加載速度捻撑。子應(yīng)用中構(gòu)建時(shí)要外置這些庫,比如用webpack構(gòu)建時(shí)如下:

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

2缤底、子應(yīng)用入口

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') {
  // 開發(fā)環(huán)境直接渲染
  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

如上我們的子應(yīng)用是vue開發(fā)的顾患,需要用single-spa-vue來包裝下,然后導(dǎo)出生命周期的鉤子函數(shù)个唧。為了方便開發(fā)江解,我們可以判斷下運(yùn)行環(huán)境,如果是開發(fā)環(huán)境的話徙歼,就直接渲染到頁面上犁河。

set-public-path.js

細(xì)心的同學(xué)就會(huì)注意到,子應(yīng)用代碼中運(yùn)行了set-public-path.js鲁沥。那么這個(gè)文件是干嘛用的呢?先來看下:

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

從名字也能看出耕魄,systemjs-webpack-interop是針對(duì)在systemjs中使用webpack構(gòu)建的bundle的場(chǎng)景的画恰。眾所周知,webpack構(gòu)建代碼時(shí)吸奴,可以通過output.publicPath選項(xiàng)指定要加載資源的url前綴允扇,這在傳統(tǒng)的spa中不會(huì)有問題,但在single-spa的頁面中可能會(huì)有問題则奥。比如output.publicPath: '/xx'的情況考润,webpack會(huì)認(rèn)為異步資源加載的url域名為當(dāng)前頁面的域名,這在傳統(tǒng)spa中不會(huì)有問題读处,但在single-spa的場(chǎng)景下異步資源就會(huì)加載失敗糊治,因?yàn)樽討?yīng)用的異步資源與框架頁面的url域名并不是一樣的。所以需要各個(gè)子應(yīng)用自行在入口中執(zhí)行上述代碼罚舱,這會(huì)設(shè)置子應(yīng)用的異步資源url前綴與子應(yīng)用的入口js一致井辜,這樣加載的路徑就不會(huì)錯(cuò)誤了绎谦。

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. 如上面提到過粥脚,如果子應(yīng)用初始化資源有多個(gè)文件(比如通常我們會(huì)將css窃肠、npm模塊抽離成一個(gè)單獨(dú)的文件),那么我們就要自行維護(hù)一個(gè)子應(yīng)用資源列表并做一些額外處理刷允,這個(gè)工作往往也是比較繁瑣的冤留;

  2. 將多個(gè)子應(yīng)用都集成在一個(gè)頁面中,css和js都是很有可能產(chǎn)生沖突的树灶。雖然我們可以制定規(guī)范纤怒,比如各子項(xiàng)目使用唯一地命名前綴等,但這種人為約定往往又是不那么靠譜破托。對(duì)于css肪跋,我們還可以在構(gòu)建時(shí)使用一些工具自動(dòng)添加前綴,這樣可以比較靠譜的避免沖突土砂;對(duì)于js來說州既,比較靠譜的方式可能就是人為制造沙箱,讓子應(yīng)用的js都運(yùn)行在各自的沙箱中萝映,但這實(shí)現(xiàn)起來就比較復(fù)雜了吴叶。

四、qiankun

其實(shí)序臂,已經(jīng)有個(gè)基于single-spa的開源庫qiankun已經(jīng)幫我們解決了上面提到的問題蚌卤,其有如下特征:

  • 解析子應(yīng)用入口時(shí),不是解析的js文件奥秆,二是直接解析子應(yīng)用的html文件逊彭。就算子應(yīng)用更新了,其入口html文件的url始終不會(huì)變构订,并且完整的包含了所有的初始化資源url侮叮,所以不用再自行維護(hù)子應(yīng)用的資源列表了。

  • 子應(yīng)用掛載時(shí)悼瘾,會(huì)自動(dòng)進(jìn)行一些特殊處理囊榜,可以確保子應(yīng)用所有的資源dom(包括js添加的style標(biāo)簽等)都集中在子應(yīng)用根節(jié)點(diǎn)dom下。子應(yīng)用卸載時(shí)亥宿,對(duì)應(yīng)的整個(gè)dom都移除了卸勺,這樣也就避免了樣式?jīng)_突。

  • 提供了js沙箱烫扼,子應(yīng)用掛載時(shí)曙求,會(huì)對(duì)全局window對(duì)象代理、對(duì)全局事件監(jiān)聽進(jìn)行劫持等,確保各子應(yīng)用都運(yùn)行在自己的沙箱內(nèi)圆到,這樣也就避免了js沖突怎抛。

包含多個(gè)spa應(yīng)用的demo

image

子應(yīng)用 dom 結(jié)構(gòu)如下

image

當(dāng)然,在前端越來越龐大復(fù)雜的場(chǎng)景中芽淡,微前端方案也不是銀彈马绝,但確是值得探索實(shí)踐的方向。

五挣菲、參考文獻(xiàn)

  1. single-spa

  2. qiankun

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末富稻,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子白胀,更是在濱河造成了極大的恐慌椭赋,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,807評(píng)論 6 518
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件或杠,死亡現(xiàn)場(chǎng)離奇詭異哪怔,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)向抢,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,284評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門认境,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人挟鸠,你說我怎么就攤上這事叉信。” “怎么了艘希?”我有些...
    開封第一講書人閱讀 169,589評(píng)論 0 363
  • 文/不壞的土叔 我叫張陵硼身,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我覆享,道長(zhǎng)佳遂,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 60,188評(píng)論 1 300
  • 正文 為了忘掉前任撒顿,我火速辦了婚禮丑罪,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘核蘸。我一直安慰自己巍糯,他們只是感情好啸驯,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,185評(píng)論 6 398
  • 文/花漫 我一把揭開白布客扎。 她就那樣靜靜地躺著,像睡著了一般罚斗。 火紅的嫁衣襯著肌膚如雪徙鱼。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,785評(píng)論 1 314
  • 那天,我揣著相機(jī)與錄音袱吆,去河邊找鬼厌衙。 笑死,一個(gè)胖子當(dāng)著我的面吹牛绞绒,可吹牛的內(nèi)容都是我干的婶希。 我是一名探鬼主播,決...
    沈念sama閱讀 41,220評(píng)論 3 423
  • 文/蒼蘭香墨 我猛地睜開眼蓬衡,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼喻杈!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起狰晚,我...
    開封第一講書人閱讀 40,167評(píng)論 0 277
  • 序言:老撾萬榮一對(duì)情侶失蹤筒饰,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后壁晒,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體瓷们,經(jīng)...
    沈念sama閱讀 46,698評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,767評(píng)論 3 343
  • 正文 我和宋清朗相戀三年秒咐,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了谬晕。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,912評(píng)論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡反镇,死狀恐怖固蚤,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情歹茶,我是刑警寧澤翘鸭,帶...
    沈念sama閱讀 36,572評(píng)論 5 351
  • 正文 年R本政府宣布,位于F島的核電站盲厌,受9級(jí)特大地震影響坚俗,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜尸昧,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,254評(píng)論 3 336
  • 文/蒙蒙 一揩页、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧烹俗,春花似錦爆侣、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,746評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至蕉鸳,卻和暖如春乎赴,著一層夾襖步出監(jiān)牢的瞬間忍法,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,859評(píng)論 1 274
  • 我被黑心中介騙來泰國打工榕吼, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留饿序,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 49,359評(píng)論 3 379
  • 正文 我出身青樓羹蚣,卻偏偏與公主長(zhǎng)得像原探,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子顽素,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,922評(píng)論 2 361

推薦閱讀更多精彩內(nèi)容