一、什么是微前端?
我們先來看兩個實際的場景:
1、復用別的的項目頁面
如果我們的項目需要開發某個新的功能,而這個功能另一個項目已經開發好,我們想直接復用時。注意:我們需要的只是別人項目的這個功能頁面的「內容部分」,不需要別人項目的頂部導航和菜單。
一個比較笨的辦法就是直接把別人項目這個頁面的代碼拷貝過來,但是萬一別人不是 vue
開發的,或者說 vue
版本、UI
庫等不同,以及別人的頁面加載之前操作(路由攔截,鑒權等)我們都需要拷貝過來,更重要的問題是,別人代碼有更新,我們如何做到同步更新。
長遠來看,代碼拷貝不太可行,問題的根本就是,我們需要做到讓他們的代碼運行在他們自己的環境之上,而我們對他們的頁面僅僅是“引用”。
這個環境包括各種插件( vue
、 vuex
、 vue-router
等),也包括加載前的邏輯(讀 cookie
,鑒權,路由攔截等)。私有 npm
可以共享組件,但是依然存在技術棧不同/UI庫不同等問題。
2、巨無霸項目的自由拆分組合
- 代碼越來越多,打包越來越慢,部署升級麻煩,一些插件的升級和公共組件的修改需要考慮的更多,很容易牽一發而動全身
- 項目太大,參與人員越多,代碼規范比較難管理,代碼沖突也頻繁。
- 產品功能齊全,但是客戶往往只需要其中的部分功能。剝離不需要的代碼后,需要獨立制定版本,獨立維護,增加人力成本。
舉個例子,你們的產品有幾百個頁面,功能齊全且強大,客戶只需要其中的部分頁面,而且需要你們提供源碼,這時候把所有代碼都給出去肯定是不可能的,只能挑出來客戶需要,這部分代碼需要另外制定版本維護,就很浪費。
二、常見微前端方案
微前端的誕生也是為了解決以上兩個問題:
(1)復用(嵌入)別人的項目頁面,但是別人的項目運行在他自己的環境之上。
(2)巨無霸應用拆分成一個個的小項目,這些小項目獨立開發部署,又可以自由組合進行售賣。
使用微前端的好處:
(1)技術棧無關,各個子項目可以自由選擇框架,可以自己制定開發規范。
(2)快速打包,獨立部署,互不影響,升級簡單。
(3)可以很方便的復用已有的功能模塊,避免重復開發。
目前微前端主要有兩種解決方案:iframe
方案和 single-spa
方案
1、iframe
方案
iframe
大家都很熟悉,使用簡單方便,提供天然的 js/css
隔離,也帶來了數據傳輸的不便,一些數據無法共享(主要是本地存儲、全局變量和公共插件),兩個項目不同源(跨域)情況下數據傳輸需要依賴 postMessage
。iframe
有很多坑,但是大多都有解決的辦法:
(1)頁面加載問題
iframe
和主頁面共享連接池,而瀏覽器對相同域的連接有限制,所以會影響頁面的並行加載,阻塞 onload
事件。每次點擊都需要重新加載,雖然可以采用 display:none
來做緩存,但是頁面緩存過多會導致電腦卡頓。「(無法解決)」
(2)布局問題
iframe
必須給一個指定的高度,否則會塌陷。
解決辦法:子項目實時計算高度並通過 postMessage
發送給主頁面,主頁面動態設置 iframe
高度。有些情況會出現多個滾動條,用戶體驗不佳。
(3)彈窗及遮罩層問題
彈窗只能在 iframe
范圍內垂直水平居中,沒法在整個頁面垂直水平居中。
解決辦法1:通過與框架頁面消息同步解決,將彈窗消息發送給主頁面,主頁面來彈窗,對原項目改動大且影響原項目的使用。
解決辦法2:修改彈窗的樣式:隱藏遮罩層,修改彈窗的位置。
(4)iframe
內的 div
無法全屏
彈窗的全屏,指的是在瀏覽器可視區全屏。這個全屏指的是占滿用戶屏幕。
全屏方案,原生方法使用的是 Element.requestFullscreen()
,插件:vue-fullscreen。當頁面在 iframe
里面時,全屏會報錯,且 dom
結構錯亂。
(5)瀏覽器前進/后退問題
iframe
和主頁面共用一個瀏覽歷史,iframe
會影響頁面的前進后退。大部分時候正常,iframe
多次重定向則會導致瀏覽器的前進后退功能無法正常使用。並且 iframe
頁面刷新會重置(比如說從列表頁跳轉到詳情頁,然后刷新,會返回到列表頁),因為瀏覽器的地址欄沒有變化,iframe
的 src
也沒有變化。
(6)iframe
加載失敗的情況不好處理
非同源的 iframe
在火狐及 chorme
都不支持 onerror
事件。
解決辦法1:onload
事件里面判斷頁面的標題,是否 404
或者 500
解決辦法2:使用 try catch
解決此問題,嘗試獲取 contentDocument
時將拋出異常。
解決辦法參考:stackoverflow上的問題:Catch error if iframe src fails to load
2、single-spa
微前端方案
spa
單頁應用時代,我們的頁面只有 index.html
這一個 html
文件,並且這個文件里面只有一個內容標簽 <div id="app"></div>
,用來充當其他內容的容器,而其他的內容都是通過 js
生成的。也就是說,我們只要拿到了子項目的容器 <div id="app"></div>
和生成內容的 js
,插入到主項目,就可以呈現出子項目的內容。
<link href=/css/app.c8c4d97c.css rel=stylesheet> <div id=app></div> <script src=/js/chunk-vendors.164d8230.js> </script> <script src=/js/app.6a6f1dda.js> </script>
我們只需要拿到子項目的上面四個標簽,插入到主項目的 HTML
中,就可以在父項目中展現出子項目。
這里有個問題,由於子項目的內容標簽是動態生成的,其中的 img/video/audio
等資源文件和按需加載的路由頁面 js/css
都是相對路徑,在子項目的 index.html
里面,可以正確請求,而在主項目的 index.html
里面,則不能。
舉個例子,假設我們主項目的網址是 www.baidu.com
,子項目的網址是 www.taobao.com
,在子項目的 index.html
里面有一張圖片 <img src="./logo.jpg">
,那么這張圖片的完整地址是 www.taobao.com/logo.jpg
,現在將這個圖片的 img
標簽生成到了父項目的 index.html
,那么圖片請求的地址是 www.baidu.com/logo.jpg
,很顯然,父項目服務器上並沒有這張圖
解決思路:
- 這里面的
js/css/img/video
等都是相對路徑,能否通過webpack
打包,將這些路徑全部打包成絕對路徑?這樣就可以解決文件請求失敗的問題。 - 能否手動(或借助
node
)將子項目的文件全部拷貝到主項目服務器上,node
監聽子項目文件有更新,就自動拷貝過來,並且按js/css/img
文件夾合並 - 能否像
CDN
一樣,一個服務器掛了,會去其他服務器上請求對應文件。或者說服務器之間的文件共享,主項目上的文件請求失敗會自動去子服務器上找到並返回。
通常做法是動態修改 webpack
打包的 publicPath
,然后就可以自動注入前綴給這些資源。
single-spa
是一個微前端框架,基本原理如上,在上述呈現子項目的基礎上,還新增了 bootstrap
、 mount
、 unmount
等生命周期。
相對於 iframe
,single-spa
讓父子項目屬於同一個 document
,這樣做既有好處,也有壞處。好處就是數據/文件都可以共享,公共插件共享,子項目加載就更快了,缺點是帶來了 js/css
污染。
single-spa
上手並不簡單,也不能開箱即用,開發部署更是需要修改大量的 webpack
配置,對子項目的改造也非常多。
三、qiankun
方案
qiankun
是螞蟻金服開源的一款框架,它是基於 single-spa
的。他在 single-spa
的基礎上,實現了開箱即用,除一些必要的修改外,子項目只需要做很少的改動,就能很容易的接入。如果說 single-spa
是自行車的話,qiankun
就是個汽車。
微前端中子項目的入口文件常見的有兩種方式:JS entry
和 HTML entry
純 single-spa
采用的是 JS entry
,而 qiankun
既支持 JS entry
,又支持 HTML entry
。
JS entry
的要求比較苛刻:
(1)將 css
打包到 js
里面
(2)去掉 chunk-vendors.js
,
(3)去掉文件名的 hash
值
(4)將 single-spa
模式的入口文件( app.js
)放置到 index.html
目錄,其他文件不變,原因是要截取 app.js
的路徑作為 publicPath
APP entry | 優點 | 缺點 |
---|---|---|
JS entry |
可以配合 systemJs ,按需加載公共依賴( vue , vuex , vue-router 等) |
需要各種打包配置配合,無法實現預加載 |
HTML entry |
打包配置無需做太多的修改,可以預加載 | 多一層請求,需要先請求到 HTML 文件,再用正則匹配到其中的 js 和 css |
其實 qiankun
還支持 config entry
:
{ entry: { scripts: [ "app.3249afbe.js" "chunk-vendors.75fba470.js", ], styles: [ "app.3249afbe.css" "chunk.75fba470.css", ], html: 'http://localhost:5000' } }
建議使用 HTML entry
,使用起來和 iframe
一樣簡單,但是用戶體驗比 iframe
強很多。
qiankun
請求到子項目的 index.html
之后,會先用正則匹配到其中的 js/css
相關標簽,然后替換掉,它需要自己加載 js
並運行,然后去掉 html/head/body
等標簽,剩下的內容原樣插入到子項目的容器中 :
使用 qiankun
的好處:
(1)qiankun
自帶 js/css
沙箱功能,singles-spa
可以解決 css
污染,但是需要子項目配合
(2)single-spa
方案只支持 JS entry
的特點,限制了它只能支持 vue
、 react
、 angular
等技術開發的項目,對一些 jQuery
老項目則無能為力。qiankun
則沒有限制
(3)qiankun
支持子項目預請求功能。
1、js
沙箱
js/css
污染是無法避免的,並且是一個可大可小的問題。就像一顆定時炸彈,不知道什么時候會出問題,排查也麻煩。作為一個基礎框架,解決這兩個污染非常重要,不能僅憑“規范”開發。
js
沙箱的原理是子項目加載之前,對 window
對象做一個快照,子項目卸載時恢復這個快照,如圖:
那么如何監測 window
對象的變化呢,直接將 window
對象進行一下深拷貝,然后深度對比各個屬性顯然可行性不高,qiankun
框架采用的是ES6
新特性,proxy
代理方法。但是 proxy
是不兼容 IE11
的,為了兼容,低版本 IE
采用了 diff
方法:淺拷貝 window
對象,然后對比每一個屬性。
2、css 沙箱
qiankun
的 css
沙箱的原理是重寫 HTMLHeadElement.prototype.appendChild
事件,記錄子項目運行時新增的 style/link
標簽,卸載子項目時移除這些標簽。
single-spa
方案中我用了換膚的思路來解決 css
污染:首先 css-scoped
解決大部分的污染,對於一些全局樣式,在子項目給 body/html
加一個唯一的 id/class
(正常開發部署用),然后這個全局的樣式前面加上這個 id/class
,而 single-spa
模式則在 mount
周期給 body/html
加上這個唯一的 id/class
,在 unmount
周期去掉,這樣就可以保證這個全局 css
只對這個項目生效了。
這兩個方案的致命點都在於無法解決多個子項目同時運行時的 css
污染,以及子項目對主項目的 css
污染。
雖然說兩個項目同時運行並不常見,但是如果想實現 keep-alive
,就需要使用 display: none
將子項目隱藏起來,子項目不需要卸載,這時候就會存在兩個子項目同時運行,只不過其中一個對用戶不可見。
css
沙箱還有個思路就是將子項目的樣式局限到子項目的容器范圍內生效,這樣只需要給不同的子項目不同的容器就可以了。但是這樣也會有新的問題,子項目中 append
到 body
的彈窗,樣式就無法生效。所以說樣式污染還需要制定規范才行,約定 class
命名前綴。
四、微前端方案實踐
改造已有的項目為qiankun子項目,由於我們是 vue
技術棧,所以我就以改造一個 vue
項目為例說明,其他的技術棧原理是一樣的。
1、在 src
目錄新增文件 public-path.js
if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }
2、修改 index.html
中項目初始化的容器,不要使用 #app
,避免與其他的項目沖突,建議換成項目 name
的駝峰寫法
3、修改入口文件 main.js
:
import './public-path'; import Vue from 'vue' import App from './App.vue' import VueRouter from 'vue-router' import store from './store'; Vue.use(VueRouter) Vue.config.productionTip = false let router = null; let instance = null; function render(parent = {}) { const router = new VueRouter({ // histroy模式的路由需要設置base,app-history-vue根據項目名稱來定 base: window.__POWERED_BY_QIANKUN__ ? '/app-history-vue' : '/', mode: 'history', // hash模式不需要上面兩行 routes: [] }) instance = new Vue({ router, store, render: h => h(App), data(){ return { parentRouter: parent.router, parentVuex: parent.store, } }, }).$mount('#appVueHistory'); } //全局變量來判斷環境,獨立運行時 if (!window.__POWERED_BY_QIANKUN__) { render(); } export async function bootstrap() { console.log('vue app bootstraped'); } export async function mount(props) { console.log('props from main framework', props); render(props.data); } export async function unmount() { instance.$destroy(); instance = null; router = null; }
主要改動是引入修改 publicPath
的文件和 export
三個生命周期。
注意:
webpack
的publicPath
值只能在入口文件修改,之所以單獨寫到一個文件並在入口文件最開始引入,是因為這樣做可以讓下面所有的代碼都能使用這個。- 路由文件需要
export
路由數據,而不是實例化的路由對象,路由的鈎子函數也需要移到入口文件。 - 在
mount
生命周期,可以拿到父項目傳遞過來的數據,router
用於跳轉到主項目/其他子項目的路由,store
是父項目的實例化的Vuex
。
4、修改打包配置 vue.config.js
:
const { name } = require('./package'); module.exports = { devServer: { headers: { 'Access-Control-Allow-Origin': '*', }, }, // 自定義webpack配置 configureWebpack: { output: { library: `${name}-[name]`, libraryTarget: 'umd',// 把子應用打包成 umd 庫格式 jsonpFunction: `webpackJsonp_${name}`, }, }, };
注: 這個 name
默認從 package.json
獲取,可以自定義,只要和父項目注冊時的 name
保持一致即可。
這個配置主要就兩個,一個是允許跨域,另一個是打包成 umd
格式。為什么要打包成 umd
格式呢?是為了讓 qiankun
拿到其 export
的生命周期函數。我們可以看下其打包后的 app.js
就知道了:
root
在瀏覽器環境就是 window
, qiankun
拿這三個生命周期,是根據注冊應用時,你給的 name
值,name
不一致則會導致拿不到生命周期函數
五、子項目開發的一些注意事項
1、所有的資源(圖片/音視頻等)都應該放到 src
目錄,不要放在 public
或 者static
資源放 src
目錄,會經過 webpack
處理,能統一注入 publicPath
。否則在主項目中會404。
參考:vue-cli3的官方文檔介紹:何時使用-public-文件夾
暴露給運維人員的配置文件 config.js
,可以放在 public
目錄,因為在 index.html
中 url
為相對鏈接的 js/css
資源,qiankun
會給其注入前綴。
2、請給 axios
實例添加攔截器,而不是 axios
對象
后續會考慮子項目共享公共插件,這時就需要避免公共插件的污染
// 正確做法:給 axios 實例添加攔截器 const instance = axios.create(); instance.interceptors.request.use(function () {/*...*/}); // 錯誤用法:直接給 axios 對象添加攔截器 axios.interceptors.request.use(function () {/*...*/});
3、避免 css
污染
組件內樣式的 css-scoped
是必須的。
對於一些插入到 body
的彈窗,無法使用 scoped
,請不要直接使用原 class
修改樣式,請添加自己的 class
,來修改樣式。
.el-dialog{ /* 不推薦使用組件原有的class */ } .my-el-dialog{ /* 推薦使用自定義組件的class */ }
4、謹慎使用 position:fixed
在父項目中,這個定位未必准確,應盡量避免使用,確有相對於瀏覽器窗口定位需求,可以用 position: sticky
,但是會有兼容性問題(IE不支持)。如果定位使用的是 bottom
和 right
,則問題不大。
還有個辦法,位置可以寫成動態綁定 style
的形式:<div :style="{ top: isisQiankun ? '10px' : '0'}">
5、給 body
、 document
等綁定的事件,請在 unmount
周期清除
js
沙箱只劫持了 window.addEventListener
,使用 document.body.addEventListener
或者 document.body.onClick
添加的事件並不會被沙箱移除,會對其他的頁面產生影響,請在 unmount
周期清除
原文鏈接:https://juejin.cn/post/6844904185910018062,作者:沉末_