一、背景
對於大多數有點歷史的複雜前端項目來說,應該已經經歷了從刀耕火種的大型單倉庫構建到多業務應用獨立開發部署的過程。當用戶訪問頁面時,由 nigix
等負責根據路由分發到不同的業務應用,由各個業務應用完成資源的組裝後返回給瀏覽器。這種情況下,開發、構建已經可以各自獨立進行,在這樣一套健全體系下的開發者們,想必是很幸福的。
以有贊微商城後臺爲例,針對B端業務,我們就已經劃分了數十個的應用,可獨立進行開發、打包和部署。如下圖所示:
但在業務日趨複雜,頁面依賴資源越來越多的情況下,翻開 頁面加載優化
的萬能工具箱,用盡各種招數,都很難達到接近單頁的效果。畢竟, MPA
架構的前端不是 生而爲快
,其最大的優勢在於開發和維護的高效。
那麼,在面對一個大型的 MPA
架構前,我們的頁面還可以再快一點嗎?對於有讚的前端體系來講,在進行業務域的拆分應用後,業務級別的獨立開發、部署已經變成了日常。在單個業務域內,其實也存在SPA的模式,但大都僅限於一個功能點下的列表——詳情頁的跳轉。要完成業務域內的全單頁,需要完成的工作量和踩的坑已不敢想象,更別說僅實現了業務域內單頁,帶來的實際體驗提升並不大。那我們還有別的辦法嗎?
這時候天空飄來了兩個字—— MicroFrontend,微前端。微前端的定義想必大家都看了很多,大多數是起源自於 micro-frontends.org 和各個大牛自己的獨到見解。本文所介紹的方案並非全套的微前端方案,不包含獨立發佈、部署、依賴拆分這一部分的內容。這次分享的目標是以有贊微商城後臺的改造爲例,提供一些可參考的經驗,如何在一個已經完成獨立發佈、部署的MPA體系下,實現微前端中的子頁面分發和組合的部分,實現接近單頁的效果。
二、概述
目前業界已經產出了一些優秀的微前端方案,比如熱門的基於 single-spa
的 qiankun
。在分析了這些微前端方案的實現,並結合團隊內的現狀後,我們實現了自己的漸進式微前端方案—— ZanSpa (命名就是這麼簡單樸素)。主要設計思路如下圖所示:
其中核心模塊爲 RouteMonitor
和 PageLoader
兩部分,分別負責路由導航和子頁面資源的解析組裝。好了,有了整體的印象,接下來會依次介紹各個主要模塊和流程的實現。
三、說細點
3.1 子頁面組合方式
微前端的子頁面組合方式:包含構建時組合和運行時組合,既然是低成本接入,基於已有的業務獨立打包的形式,同時能做到真正的技術棧無關跟獨立部署,運行時組合自然成了我們的首選。
3.2 子頁面拆分
開始前,我們對現有的頁面加載流程做一些簡單分析。
我們在瀏覽器輸入youzan.com,請求經過無線網有線網,A機房B機房,終於到了我們穩定的有贊服務器,接入層根據請求路由特徵,轉發到對應業務應用的機器,業務應用的 node
服務再組裝返回 html
。其中包含了微商城後臺前端各業務都會依賴的公共資源,包括腳本和樣式,和業務頁面自身依賴的資源。
對於上圖中所示的靜態資源,這裏我們以業務A(對應路由/routeA)和業務B(對應路由/routeB)爲例,可以分爲三類:
- 跨業務共用資源(shared-css、shared-js),routeA和routeB下頁面都會請求
- 業務應用內基礎資源(base-css、base-js),routeA路由下子頁面都會請求
- 頁面級資源(page-css、page-js),routeA路由下的頁面C才需要,同是routeA下的頁面D可能就不需要
在頁面切換中,對於在微商城後臺內所有的業務,跨業務的共用資源其實只需要被加載一次,而業務內的基礎資源,在業務域的頁面間跳轉時,比如從 /routeA/list
到 /routeA/detail
也只需要加載一次。這樣,最優的情況下,我們只需要加載頁面本身需要的 page-css
和 page-js
,從而極大的提高頁面切換加載的速度。
於是我們對 html
模板的生成邏輯進行拆分,服務器在面對同樣一個路由時,根據固定參數 zanPageMode
決定是需要子頁面形式的頁面還是完整的頁面(可在基座外獨立打開)。如果是子頁面資源的請求,則使用精簡後的模板,其中去除了跨業務共用資源引用,這些資源只需首屏加載即可。對於業務內的基礎資源,在頁面切換時,對子頁面依賴的資源進行diff,如果是已加載的樣式或腳本資源,則保留,僅對頁面級的資源進行替換,如 pageA.css
和 pageA.js
更新爲 pageB.css
和 pageB,js
。
3.3 子頁面資源格式
3.3.1 config-entry
我們起初也嘗試了使用 config-entry
(json格式的子頁面配置信息)進行子頁面信息的傳輸,形式如下:其中 bodyClass
的作用是我們存在部分子頁面需要指定全局樣式主題。
{
"title": "有贊微商城",
"template": "<div></div>",
"bodyClass": "blue-thme",
"scripts": "yzcdn.com/routeA/base.js, yzcdn.com/routeA/pageA.js",
"styles": "yzcdn.com/routeA/base.css, yzcdn.com/routeA/pageA.css",
}
在使用 config-entry
時遇到了幾個很難優雅處理的問題:
-
模板標籤的雙向轉義
服務端在返回子頁面信息的json時,由於
template
是html
格式,其中可能存在雙引號、換行符等特殊字符,需要先將template
內的換行符進行替換,將雙引號進行轉義,基座應用在獲取到子頁面數據後需要再對相應的特殊字符進行反轉義和替換。 -
內聯腳本
我們子頁面依賴的
scripts
資源中還存在內聯腳本的情況,同樣存在與模板相似的問題。且內聯腳本中的js
代碼各種字符都可能存在,一味的轉義處理不當可能就會造成數據或執行錯誤。 -
複用性
考慮到我們業務的頁面還會被其他二方的平臺引用,如果將頁面模板輸出拆分爲目前基於
Nunjucks
的html
和json
兩套,由於格式的不同,很難做到其中一些模板片段和邏輯的複用,對於其中一些資源位置或形式的改動,可能兩種格式需要分開處理。
3.3.2 html-entry
在使用 json
格式踩坑無數後,我們最終採用了 qiankun
類似的方案, html-entry
。使用html格式進行子頁面資源的組織,可讀性和維護性更高,更接近最後頁面掛載後的效果,也不存在需要雙向轉義的問題。且與現有 nunjucks
模板無縫銜接,只需要做一些很小的改動,就可以將原有的頁面模板,經過冗餘資源的拆分後,輸出爲子頁面的 html-entry
。
3.3.3 DOMParser
本着不重複造輪子(拿來主義)的原則,對 html-entry
的解析開始也使用了 qiankun 內部使用的 import-html-entry
模塊。但由於我們的部分頁面爲了提高首屏打開速度,會將一些依賴的全局數據塞到一個內聯腳本中作爲 window
變量進行初始化,而 import-html-entry
內部使用了正則表達式進行 style
、 link
和 script
標籤的提取,在內聯腳本中數據量較大(100k左右)時正則提取存在明顯的性能問題,導致頁面加載過程肉眼可見的延長。於是我們轉而找到了另外的替代方案—— DOMParser 。
與 DOMParser
類似的還有 div.innerHtml
或使用 Range.createContextualFragment
。但在實際使用中,雖然 DOMParser
相對於使用 div.innerHtml
傳入需要解析的模板和 Range.createContextualFragment
性能會較差一些,不過在也就是幾毫秒到十幾毫秒的區別。而且 DOMParser
強大的解析能力,可以充分解析 html-entry
中標籤及其屬性,最後獲取到的就是一個 document
對象,使用我們熟悉的 DOM api 即可訪問或修改相關數據。
!!! 前方踩坑警告
但DOMParser也不是完美的,在解析自閉合的 div
標籤時(如 <div/>
),會導致結構錯亂,原因可能是 DOMParser
在解析div時默認其是存在結束標籤的。解決方案是在獲取到 html-entry
後,先進行一次全局的替換,補充結束標籤。使用如下的正則簡單處理即可,基本不會影響解析性能。
html = html.replace(/<(div)([^>]*)\/>/, '<$1$2></$1>');
3.4 基座改造 RouteMonitor & PageLoader
整個單頁容器的部分我們封裝成了 ZanSpa
模塊,對外僅提供 init(options)
的方法,支持一系列的自定義行爲和生命週期鉤子。
ZanSpa
啓動時,會實例化內部的兩個核心模塊 RouteMonitor
和 PageLoader
。RouteMonitor
負責路由切換的監聽,決定什麼時候應該切換子頁面。PageLoader
負責在路由切換時,加載並解析相應的子頁面,並處理子頁面間的副作用和生命週期的更替。
3.4.1 RouteMonitor
該模塊的作用是攔截可能修改當前路由的事件及行爲,並判斷路由的改變是否需要出發子頁面的更新。
1.監聽全局點擊事件,判斷如果要走子頁面的更新邏輯,則攔截後調用 PageLoader
進行更新。這裏需要注意的是如果同時按住 cmd
、 ctrl
或者 shift
鍵的點擊會打開新 tab ,需要保持原有行爲。
每次點擊後會通過 validateUrlChange
方法判斷路由的變更是否需要按照子頁面形式進行切換。該方法會解析判斷新老的 url( sourceUrl
和 destUrl
),判斷兩者是否相同(除 hash
外)。
- pathname是否相同
- 是否都存在hash
sourceUrl
有hash
而distUrl
沒有hash
!!! 前方踩坑警告
第 3 點需要特別注意,對於 hash
的變更,理論上我們是不應該干預的,避免影響 react-route
之類的基於 hash
實現的單頁和瀏覽器的默認 hash
跳轉行爲。但對於 pathname
相同的 url 間跳轉時,如果 sourceUrl
有 hash
,而 destUrl
沒有 hash
的情況,是需要進行劫持的,否則瀏覽器的默認行爲就是頁面的重載。
2.攔截原生 history
變更
- 監聽全局
popstate
事件,並在state
統一返回頁面url,方便瀏覽器前進後退時通過 url 獲取相應的子頁面。
const globalHistory = window.history;
window.addEventListener('popstate', () => {
const url = window.location.href;
globalHistory.replaceState({ url }, '', url);
});
- 劫持
history
的pushState
和replaceState
方法,然後從state
中獲取url
,調用PageLoader.loadPagesOfUrl
進行子頁面的更新。
3.提供統一的跳轉方法 ZanSpa.navigateTo(urlOrEvent)
。由於 window.location
爲native對象,無法被劫持,所以子頁面通過 window.location.href='/routeB/pageC'
進行跳轉的地方需要使用該方法進行替換。ZanSpa.navigateTo(urlOrEvent)
的實現也很簡單,基於 window.history.pushState
API,支持 MouseEvent
類型的參數,可以直接作爲 a 標籤的點擊事件的回調。
3.4.2 PageLoader
PageLoader主要負責子頁面資源的獲取、解析和生命週期管理,對外提供 ZanSpa.registerPage()
進行頁面註冊。子頁面通過該API聲明子頁面的路由匹配規則,掛載和卸載邏輯。
ZanSpa.registerPage()
的參數定義如下:
export interface IMicroPageProps {
/**
* 頁面註冊name,爲避免重複,**推薦按照biz-feature-page的形式命名
*/
name: string;
/**
* 沒有特別邏輯可以不傳。聲明路由匹配規則,可以使用字符串、正則表達式或函數;
*/
activeRoute: string | RegExp | ((url: string) => boolean);
/**
* 頁面初始化生命週期回調
*/
bootstrap?: LifecycleCallback;
/**
* 頁面掛載時的生命週期回調。如果使用的是react,這裏可以使用ReactDOM.render進行根節點渲染。
*/
mount: LifecycleCallback;
/**
* 頁面卸載時的生命週期回調。如果使用的是react,這裏可以使用unmountComponentAtNode進行react組件的清理。
*/
unmount: LifecycleCallback;
/**
* 自定義參數
*/
customProps?: any;
}
下面是 PageLoader
的頁面更新流程:
1、請求子頁面資源
在 RouteMonitor
監聽到跳轉行爲或外部通過 ZanSpa.navigateTo
進行跳轉後,如果 RouteMonitor
認爲此次路由變更需要頁面更新,則會調用 PageLoader
的 loadPageOfUrl
進行加載,並顯示頁面加載的 Loading
。
我們這裏沒有引入中心化的路由-子頁面配置管理,因爲現有的統一接入層已處理了類似的邏輯,對於到來的請求,根據其路由特徵轉發到對應的 node 服務,由 node 服務再根據內部路由規則返回相應的資源。所以 PageLoader
在處理新的路由請求時,需要通過 loadPageOfUrl
拼接特殊參數後將請求發出,node 端收到頁面請求包含該參數時即返回子頁面模板實例化後的 html-entry
。
2、子頁面資源解析&diff更新
在成功獲取 html-entry
後, PageLoader
會通過上述的 DOMParser
將其解析爲一個 document
對象(與全局document對象類似),內部再進一步解析出其 entry
中包含的樣式、腳本、模板資源,分別由相應的方法進行 diff 更新。
- 樣式和腳本:具體的 diff 規則也很簡單,對於
link
標籤就判斷href
屬性,對於script
標籤就判斷src
屬性,內聯的樣式和腳本不做 diff 。然後按照約定樣式插入到掛載節點的頂部,腳本則插入到掛載節點的尾部。 - 模板:模板則根據
ZanSpa
初始化時傳入的容器節點 ID,清空容器節點後填充進新的模板。
3、子頁面註冊
在上一步中,資源解析並且 diff 更新後,樣式、腳本和模板加載完成。隨着業務 chunks 腳本執行,此時就會觸發業務頁面入口處調用的 ZanSpa.registerPage(pageInfo)
,子頁面的自動註冊完成。所以我們子頁面的配置收集是動態完成的,不需要集中式統一維護子頁面配置,只需由子頁面各自進行維護, html-entry
加載完成同時也加載了子頁面配置信息。但同時因爲加載前不知道子頁面的具體信息,目前還無法做到指定子頁面的預加載。
爲了減少業務接入成本時,調用 registerPage
時的 activeRoute
參數默認會使用頁面加載時的 pathname
。該參數支持 string
、正則表達式和函數三種類型。如果子頁面中存在衝突的,可以自定義 activeRoute
來解決。註冊完成後,將子頁面信息存入 microPages
數組,以方便之後的生命週期更新。
4、生命週期管理
子頁面資源加載並且更新完成後,同時新的子頁面通過 registerPage
已完成註冊。此時就需要根據各個子頁面的 activeRoute
進行生命週期的更新了。這個過程核心代碼如下,這裏所有的資源加載和生命週期會被包裝成 Promise
,以便於異步的處理,也可根據需要直接使用 async/await
。
this._unmountPages()
.then(() => {
// bootstrap
return this._bootstrapPage(pageResouce);
})
.then(() => {
// mount
return this._mountPages();
});
})
unmountPages
:該方法會遍歷所有目前已註冊的子頁面,判斷其是否應該被卸載,然後調用其聲明的unmount
方法進行卸載。mountPages
:舊的不去,新的不來。當舊頁面被卸載後,此時按照類似的邏輯,找出需要被掛載的子頁面,並行的調用其mount
生命週期回調。掛載的過程一旦出錯,會暫時將該子頁面狀態設置爲PageStatus.Mounted
,然後嘗試將其卸載。- 副作用處理:頁面在通過
registerPage
註冊時,會對其生命週期進行包裹,以便於在其mount
時啓動全局事件和定時器的收集,並在其卸載時清理收集到的全局事件監聽器和定時器。這個方案並不是完整的沙箱,目前不支持window變量的收集與恢復。 - 開發調試信息:
RouteMonitor
和PageLoader
中記錄了頁面各階段加載耗時,資源複用情況,全局事件和定時器清理統計及內存佔用情況(開發環境)。考慮到單頁化改造後,難免有一定的內存泄漏,再內存佔比達到一定閾值時,在頁面跳轉時強制進行整頁刷新。該特性通過performance.memory
API 實現,瀏覽器兼容性較差,僅作輔助使用。
3.5 其他坑
3.5.1 全局組件清理
對於不在容器節點內的全局組件如 Notify
和 Dialog
,子頁面 unmount
時也需要自動清理。即使在確定這些組件是React組件和掛載節點的情況下,由於基座和子頁面React實例隔離,基座內的 unmountComponentAtNode
並不能徹底清理這些組件實例。我們的解決辦法是,業務應用在 registerPage
時,在 customProps
中的 unmountComponent
回傳業務方卸載方法,例如 React
就是 unmountComponentAtNode
,然後在 ZanSpa
的 beforeUnmount
鉤子中處理需要清理的組件,這個可以視具體技術棧和組件庫而定。
3.5.2 子頁面間通信
我們子頁面間通信的場景較少,目前採用的是自定義事件,並以 zan-spa:
作爲統一的事件前綴。
3.5.3 pushState跨域問題
需要注意業務內有沒有跨域的鏈接存在,如果跳轉時是一個跨域的 url , pushState
的調用會出現安全錯誤, SecurityError:Failedto execute'pushState'on'History'
,導致整個流程停止,用戶點擊後無反應。我們的 ZanSpa
提供了 beforeLoad
的鉤子,其中可以處理不允許走單頁加載的情況。RouteMonitor
在跳轉前會調用該鉤子,如果其返回false,則通過 window.location.href
打開該鏈接不走單頁模式。
MPA模式下,開發者其實無需考慮很多副作用,如全局事件監聽器和輪詢的定時器,都會隨着頁面刷新煙消雲散。但進入到微前端的 SPA 時代後,雖然基座本身也會處理子頁面的副作用,但仍需要業務頁面的開發者隨時保持
我註冊,我清理
的意識,不留隱患。
3.5.4 灰度控制規則
由於上線後影響面較大(每個頁面),也要支持各個業務應用的分開接入,所以在灰度和開關控制上我們也考慮了很多,以支持一旦發現線上有意料外的 “feature”,可以精確的控制某個店鋪或者頁面是否開啓。由於邏輯也比較複雜,這裏就不展開了,感興趣的可以私下溝通。
3.5.5 基座更新策略
由於基座承擔着管理子頁面的職責,並且在子頁面更新時並不會更新,如果我們修改了基座的代碼,怎麼實現基座的更新呢?這裏我們利用現有的打包流程,會將當前的基座資源版本信息在基座已有的配置信息接口中返回。該接口中還包含了導航菜單和權限的最新數據,這個接口會在每次子頁面切換後更新(5秒的 debounce
處理),再下次子頁面切換時,如果發現基座版本已落後,則強制走 MPA 模式加載。
3.5.6 快速連擊的防禦
單頁化後,用戶的每次跳轉由瀏覽器處理變成了 ZanSpa
處理,而其中 PageLoader
對子頁面的 bootstrap
(資源diff後的更新)過程是不適宜被中斷的,所以考慮到穩定性的問題,在用戶點擊跳轉時,需要確認是否有頁面正出於 bootstrap
狀態,如果有則需要攔截並進行提示。
3.7 效果展示
頁面切換速度提高明顯,而且對於基座本身依賴的一些接口請求(僅限時效性要求不高的接口),在單頁化後基本只需訪問一次,大大地件減少了基座依賴接口的後端壓力。
下面是改造前後的對比圖,測試前已清除緩存。在頁面靜態資源已緩存的情況下,速度的差異較小,但相對於多頁切換時的整頁白屏,改造後的體驗要好很多。
改造前:
改造後:
四、待完成的事項
如果要作爲完整的微前端方案,還有不少的事情要做,這是接下來的一些計劃,歡迎有興趣的有贊同學來提想法和添磚加瓦~
config-entry
形式的資源和預加載的支持- 沙箱支持子頁面的上下文快照
- 多子頁面共存及嵌套的支持
- 骨架屏自動生成
- 與Webpack federation的結合
本文轉載自公衆號有贊coder(ID:youzan_coder)。
原文鏈接: