📢 部落格首發 : SugarTurboS Blog
團隊的專案 A 經歷兩年需求的洗禮,一些問題也隨之暴露出來:
npm
包很多,業務程式碼也很多,有著向巨石應用發展的趨勢。巨石應用的一些典型問題如下:構建效率低下、dev-server 佔用記憶體大甚至記憶體洩露、維護成本急劇增加。npm
包版本可能不同,導致一些隱藏未知錯誤。對於微前端跟 iframe 的方案區別,為什麼用微前端這個問題,這裡不再累贅,qiankun
裡面有一篇文章已經說得非常不錯,有興趣可以去看看。
why not iframe
qiankun
qiankun
的接入對專案改動小,成本低。這個的坑不是指qiankun
的坑。當然,qiankun
這個框架還是有一些坑在的。這裡主要的指在專案重構的時候,遇到的一些坑及我們的解決方案,以供大家參考。
專案之前的結構,所有的包都安裝在根目錄的node_modules
,專案裡所有內容都指向一個React
。而用qiankun
重構後,我們定義每個子專案為一個相對獨立的專案(有獨立的package.json
檔案,獨立包管理),但子專案之前又會有一些公共的元件,我們把它放在以子專案同級的 common 資料夾,如下圖。
這時候就遇到一個問題:子專案參照自己package.json
目錄下的node_modules
裡面的React
,而 common 參照根目錄的package.json
目錄下node_modules
裡面的React
,當子專案參照 common 封裝的React 元件,子專案跑的時候會報同時引入了兩個React
,導致報錯。
一開始,我們想到的方案是這樣的,把全部包安裝在根目錄的node_modules
。但是,這個方案最大問題是所有子專案必須用同一版本React
、後期React
想升級,必須所有子專案做相容,但是有些子專案被劃分出來就是為了不再跟隨升級迭代,這就矛盾了。
後來,我們換了一個方案,我們在打包的時候,預先把 common 目錄 copy 一份到子專案,這樣就能保住都是參照一個React
。在開發的時候,額外啟動一個監聽服務 watch common 目錄,監聽到檔案變化的時候自動 copy 檔案到子專案,子專案的 common 目錄進行許可權控制,只能進行讀寫操作,無其他操作執行許可權。所有參照 common 通過@common 對映。這樣給到開發時,common 內容的更改只需要在根目錄 common 修改,子專案通過@common 參照不需要關注真實的 common 與子專案的目錄結構關係。
重構前,我們們只有一個 babel
設定。重構後,我們們的目錄結構是典型的 monorepo
結構。我們們只有子專案有 .babelrc.json
檔案,導致 common 往上查詢找不到設定報錯 (ps:我們們專案是使用babel7
構建)
一開始,是沿用babel6
時候的方式,使用.babelrc.json
檔案。根目錄及子專案分別有一個.babelrc.json
檔案,這樣的最大缺點是兩個.babelrc.json
檔案設定幾乎相同,後期維護改設定需要修改兩個檔案。
然後改用子專案 .babelrc.json
通過 extends
設定複用根目錄的.babelrc.json
設定。
後面發現,由於 babel
設定有一些是需要設定路徑,而json
只能設定相對路徑,於是改用js
格式設定。
我們們專案參照的一些 npm 包沒有轉es6
,我們們只能用 webpack
對這些包額外 babel
轉化一下。但是發現專案的 babel
設定對 npm 包並不生效。後來發現是因為 babel7
之後,.babelrc
不會對 node_modules
包起作用,必須改用babel.config.js
代替。
以上就是我們最終關於babel
的設定。
【記得
babel-loader
時要設定引數rootMode
為upward
,表示允許babel
往上查詢babel.config.js
檔案】,同樣子專案要設定extends
引數指向最外層的 babel 檔案路徑。
關於babel6.x
與babel7
的區別,babel
對於monorepo
專案的設定,官網上面這篇文章寫的是最詳細的。
qiankun
只給我們們提供了一個 initGlobalState
(初始化一個全域性state
)、onGlobalStateChange
(監聽變化)、setGlobalState
(更新state
)的全域性狀態管理,並不跟React
的狀態管理器做關聯。我們們要做的是把全域性state
與子應用redux
做一個雙向繫結。
// 這裡面state與globalState要進行深比較,如果是淺比較,會導致程式陷入死迴圈。
const [state, setState] = useState({}) // 這裡用state代替redux,做一個簡單演示。
let globalState = null
// 監聽globalState值變化,如果有變化則更新state
actions.onGlobalStateChange((newGlobalState) => {
globalState = newGlobalState
const diffState = getDiffState(globalState, state)
if (diffState) setState(diffState)
})
// 監聽state值變化,如果有變化則更新globalState
useEffect(() => {
const diffState = getDiffState(state, globalState)
if (diffState) actions.setGlobalState(diffState)
}, [state])
由於我們們專案之前是使用 webpack
的 import
實非同步載入。在使用qiankun
重構後,發現以下問題:
當前處於子應用 A,切換子應用 B,在非同步 js 還在載入過程中,快速切換回應用 A。待子應用 B 的非同步 js 載入完畢後,我們們切換回子應用 B,發現子應用那個非同步 js 載入的內容為空。
導致該問題原因是 A->B->A 過程後,子應用 B 的沙箱被移除了,非同步 js 缺少執行環境,導致非同步 js 執行的(window.webpackJsonp...
)已經找不到。
目前沒有找到有效的解決辦法,這可能是框架的一個隱藏坑,已提issue
,期望大佬們能協助解決。我們現在想到的可行方案是改用loadMicroApp
手動載入子應用。
在專案送測過程中,測試發現在某些瀏覽器(目前知道的是搜狗瀏覽器某個版本)會有相容性問題。後來追查發現,有些瀏覽器的 fetch
預設 credentials
不是 same-origin
,導致一些 cookie
的 header
資訊沒被帶上,後臺許可權認證一直不過。
解決方法就是呼叫 qiankun
的 start
是重寫fetch
,設定 credentials=same-origin
,保證瀏覽器的相容性。
start({
fetch(...args) {
const config = {
credentials: 'same-origin',
}
if (!args[1]) args[1] = {}
args[1] = {
...args[1],
...config,
}
return fetch(...args)
},
})
其實整體來說,接入qiankun
成本還是比較低的。遇到的問題大多不是qiankun
直接導致,而是用qiankun
重構後,專案結構發生變化帶來的一些問題。
專案重構後,因為整體結構的變化,出現的一些效能及開發體驗的問題。這裡主要說影響比較大的兩點。
1、有同事發現,專案用qiankun
重構後,在本地開發過程中,如果chrome tool
長期開啟,隨著頁面重新整理次數越多,chrome
的記憶體佔用會越來越嚴重。理論上來說,就算程式有記憶體洩露啥的,重新整理頁面也會釋放掉才對,為啥記憶體卻是越來越大呢?後來發現,只要不開啟chrome tool
,記憶體是正常的,重新整理記憶體就會降下來的。而且,我們們使用未重構的分支驗證,也是不會記憶體越來越大的。如圖:
2、子應用內容變更,是無法熱更新的。一開始以為是webpack
的設定沒有配對導致的。後來發現並不是webpack
,而是qiankun
使用的single-spa
框架的問題。詳細可見issue。裡面作者也提供了一個方案,就是允許你重新載入子應用。但是這樣就違背了熱更新的更新區域性的思想。而且,載入子應用跟重新整理並沒有太大差別了,開發體驗太差。
我們們討論發現,沒有什麼方案可以解決這個問題。只有一個規避的方案,就是我們們平時開發的時候,使用子應用路由進行開發,這樣就可以規避這兩個略為蛋疼的問題。當然,在一些場景下,如果主應用做一些許可權的東西,單獨跑子應用必須重寫一套許可權。我們目前做法是把這種模組掛公共目錄裡。後面還要繼續探索有沒有更好的方案。大家如果有更好的解決方案歡迎留言。
專案組的小夥伴吐槽說,我們們之前開發只需要npm run dev
一個命令列就可以搞定。qiankun
重構後,每個子應用啟動一個服務,qiankun
還要一個服務。如果要全套跑起來,我們需要開啟多個命令列視窗分別執行。這樣太麻煩了。針對這個問題,自然是引入npm-run-all
解決這個問題。一開始我們也是這樣做的,但是後面發現,實際開發過程中,有的時候小 A 只要開發子應用 A,小 B 只需要開發子應用 B,每個人都全部啟動,既浪費記憶體資源,也不優雅。那麼,要怎麼樣隨心所欲一行命令開啟你想要的服務呢?
我們們最後是使用npm-run-all
的 Node API。自主處理命令列,然後使用它提供的 API 動態啟動想要的服務。如npm start main A
,會啟動 qiankun 所在的服務及 A 微服務。
// package.json
"scripts": {
'start': 'node start.js',
"start:main": "cd client/main && npm run dev",
"start:A": "cd client/A && npm run dev",
"start:B": "cd client/B && npm run dev",
"start:C": "cd client/C && npm run dev",
}
// start.js
const runAll = require('npm-run-all')
function getApps() {
// 查詢命令列所帶的引數,如果沒有帶引數,然後啟動Main及A服務
let apps = process.argv.filter((arg) =>
['Main', 'A', 'B', 'C'].some((name) => name === arg)
)
if (apps.length <= 0) apps = ['Main', 'A']
return apps
}
function getTasks() {
let apps = getApps()
let tasks = apps.map((app) => `start:${app}`)
return tasks
}
runAll(getTasks(), {
parallel: true,
// stdout: writable,
// stderr: errWritable,
// printLabel: true,
})
.then((results) => {
console.log('done!', results)
})
.catch((err) => {
console.log('failed!', err)
})
重構後,我們發現有些包子應用都有使用,比如React
、antd
…,如果每個子應用都安裝依賴一個antd
,那就很浪費資源載入,也會影響使用者首屏等待時間。qiankun
沒有提供這方面的方案。我們最後是使用dll
方式,先把這些公共包提前打包後,在dll
到各個子專案。dll
的方式也是有一些不足的,因為dll
無法按需載入,只能參照整個包,同樣。dll
需要提前載入,如果dll
打包的東西不是使用很頻繁或首屏使用的,會特別浪費。所以我們一般只有滿足以下條件才會考慮dll
:
npm
包幾乎大部分功能都需要使用最後說說我們的想法。專案是否需要引入 qiankun,我們覺得關鍵還是要清楚引入 qiankun 後的收益及成本。拿我們們這個專案來說,因為可預見後面業務越來越大的時候,它肯定會變成巨石應用。qiankun 的接入在當前來看可能成本高於收益,但從長遠來說,收益是絕對高於成本的,所以我們們把它引到專案中去了。
最後,由於篇幅有限,很多細節的東西沒有在這裡展現。如果有興趣的,歡迎私下交流。
未經授權,禁止轉載~