Categories
程式開發

字節跳動的微前端沙盒實踐


1. 沙盒應該做什麼

首先從沙盒對微前端乃至前端的意義開始講起。沙盒對軟件工程來說其概念不算是新鮮事物,僅看前端對隔離的需求也由來已久。並且根據不同的實際業務場景,已經有過非常多和有特色的探索。

古老的 Iframe

一切都從 iframe 開始講起了,反正是個看上去很美的解決方案。沒有真的用過的人肯定都會這樣想像。但有些可能等到你要真的搞一個通過 iframe 全面聚合的才知道。單純的 iframe 聚合非常麻煩,需要很多補漏的勞動量。

舊的 iframe 的方案可以在一定程度上解決了耦合問題。具體是把一個站點頁面拆成 N 個 frame,每個 frame 單獨跑一個獨立的域名。

它的好處非常清楚,獨立上下,獨立運行,誰都不會妨礙誰。但是是不是這樣沙盒就完成了呢?這樣算不算沙盒就不好說了,會有很多不同的觀點和理論。比如一些觀點可能會覺得沙盒不是像這樣全獨立,而是以隔離模擬獨立。後面我們會再次談到這個觀點,並從實現角度分享一些我們的思考成果。

因為一個完整的項目包含大量公用的功能和代碼,例如登錄身份、站內信,業務模塊只是其中的一個部分。這部分完全用跨 window 通信實現起來很費時費力,並且單頁應用了 React 或類似的加載技術展示之後,iframe 的效果也遜色很多。想要突破這些限制,困難就很多了。

古老的困難

第一點不用多說各位都會想到 deeplinking 的問題,對吧,至少這點得做到才能算是一個工程,尤其 MVC 時代以來路由一直非常重要。

還有就是各種共享的東西,比如登錄怎麼共享。 iframe 當然也不是不行。和前面後面提到的諸多問題一樣都不是不行、而是很麻煩,要針對他去解決很多困難。從效果上講,最終也完全可以形成一個不錯的 iframe 沙盒。

另一顯然的困難是,組件庫、組件風格的父子傳遞,以及 React VUE 等渲染引擎的底層代碼、內存對象的傳遞。初步的實現是加入分片打包功能,拆 common chunk 並獨立部署 CDN 上,最後在加載時,通過瀏覽器自身的緩存能力加速訪問。但是運行時內存並不共享,對包的運行時修改也難以復用。

還有數據層的設計,數據 Store 等等。數據層至少要有一定的事件打通的功能。搞不好就牽一發動全身,改一個需求,不得不發布四五個項目。

2. 沙盒應該像什麼

前面提到過我們不希望沙盒是完全獨立的運行時環境,而是有分享和協同的可靠環境。這一章我們希望能說清我們希望沙盒提供怎樣的功能、如何使用。

虛擬化、容器化、Docker

到這裡開始講到美好的景物了,docker。從解耦的角度看,服務端的微服務主要是通過 docker 技術實現虛擬化的底層支持,使服務開發者可以體會不到環境的區別、抹平運行時差異的。可以說對微服務來說,docker 是這些年能得到如此的發展的一個基石。

單純說微服務的概念本身,很久之前也有這個概念的,有面向服務編程的理論。但是發展很少,兌現仍然很困難,搞虛擬機很麻煩。而且還包括開發體驗的東西,我打包的鏡像——要想交付一致得包含整個操作系統嗎?這對開發體驗影響很壞。

在 docker 得到普遍應用之前,微服務在服務端的使用主要基於虛機。相比之下使用非常複雜、維護成本提高。虛擬機不說多麻煩了,大家都懂。牠吃掉的資源,和容器化技術比完全不在一個量級。還有比如當你想要打個快照連磁盤都吃掉。並且多個服務之間的資源協調和有效分配,實現起來也極端困難。

諸多擴大的成本問題直到隨著 docker 的沙盒體系才得以解決。微服務才成為一個趨勢。可惜的是這樣的容器環境在前端瀏覽器內的運行時還不存在。

所以這裡已經可以看出我們提出的前端沙盒、對它的期望,是讓它就像 Docker(而 iframe 在這個比喻裡就相當於虛擬機)那樣。我們搞的這套機制是像 Docker 的前端運行時容器,像 Docker 一樣讓前端的拆分能輕鬆一點、分享容易一點、資源節約一點。當然這不是否定 iframe 的方案。

3. 沙盒應該怎麼做

那麼我們說的這種沙盒,這種輕量級、強調組件間協同溝通、非常節省資源的沙盒怎麼做呢,下面我分 3 個方向分別介紹。 (這塊還不會有具體的實現,主要從可能性上分析在瀏覽器裡怎麼造沙盒。)

3.1 單進程與多進程

參考單核、操作系統進程,模擬進程切換策略。我們的沙盒實質上在讓一個瀏覽器去跑多個“獨立”的應用,那麼這裡對操作系統的模仿、最終趨同一定避不開。在這個角度上,和其他語言相比 JavaScript 佔了一個獨特的執行特點的便宜:它自身是單線程的。我怎麼做實質上也都是在一個線程內。相當於我們這個操作系統從一開始就限定了單核只有一個出力氣。

那麼一個操作系統,怎麼做多進程並行呢,單進程可簡單通過根路由等等規則控制,每次只激活一個,大家做 context 切換即可;多進程並行就正好可以利用 JavaScript 的特性,我可以封裝每個獨立的事件循環。比如 setTimeout、各種事件回調的 handler,我們在實際 function 外面先切換 context,再執行你原本希望綁定的 function。這樣是線程安全的。總結下來就會是下面兩條:

  1. 用路由切換封裝,模擬單核單進程。
  2. 用事件循環的總體封裝,模擬單核多進程。

3.2 Context 切換

context 切換來模擬線程安全,具體的意思是在每個隔離的子應用“進程”即將開始激活時,先查找當前被激活的、其他的子應用,然後為這個將要退出的應用錄製“操作系統”的全現場狀態,保存為它的 context。最後為即將激活的新“進程”恢復、新建它自己的 context

如上面所說,我把當前狀態記錄為 context,保證每個子應用都適用在自己的 context 內,不影響和改變別人的 context。這個操作全部由託管了子應用的父系統統一來切換。

本次重點講落地實踐,和一些踩坑經驗。歡迎大家持續關注,我們會輸出更多關於這部分的實踐經驗。

體現刪除 key 必須要遍歷兩次,才能保證每個對像都遍歷到一遍。這塊要強調一個點,當你拿到新舊兩個對像比較,遍歷其中一個的 key、到另一個里面找,只這樣做是不夠的,因為有可能又刪掉的東西。刪掉導致 key 沒有了,自然也遍歷不到。想體現這個刪除,必須要遍歷兩次,新舊兩個對像都遍歷一遍,才能知道相比之下誰多了什麼誰少了什麼。尤其是大家做“空閒”到新開沙盒的比較的時候,特別容易忘了這個細節。

Context 切換的性能夠好嗎? 先說說這個快照的空間性能。如果你有 N 個沙盒需要有多少切換的組合呢,是不是 context 的全文、或者任意兩個沙盒之間的 context 差異都要完整儲存?實際上不用。我們只需要記錄差別、context 的變化,並且只記錄他對 “idle”狀態的差別。例如 A、B、C、D、E、F、G。我們不需要記錄 A→B A→C 這樣的切換,而是虛擬一個空閒狀態:O,都是 A→O,B→O,只保存他們和 O 之間的差別。需要記錄比較的變量數量從子應用個數的乘法變成了加法。寫一個循環就可以快速比較完變化。

綜上所述就是讓每個子應用的開始和結束、互相切換,都先回到一個虛擬的“初始狀態”,恢復現場,再進入被激活的沙盒狀態,每次切換僅記錄一個沙盒信息。避免了切換算法計算笛卡爾平方積導致比較、和保存沙盒切換信息過大的問題。

4. 字節跳動的沙盒採取的方案

雖然這個章節的名字是字節跳動的實現,並且前面提到這次我們主要分享實現層面的技術細節,但我們不會僅僅探討和分享自己落地採用的方案,探討的內容也會包括研究過和對比過的。如果我們認為有好的、適合其他場景的技術方案,我們也都盡量分享出來。

4.1 CSS 沙盒

先說 CSS 沙盒。這塊 webComponent 已經做了很多發展了很多了。這裡忍不住要說,web 標準裡一度有一個非常吸引我、讓我感覺非常有意思的內容是 scoped css——就是加個 Attribute 就能結合 DOM 樹限制 CSS 作用範圍。後來這個標準被取消了。因為讓路給了 ShadowDOM 體系。

這個我不是很理解:因為 scoped CSS 是外面的規則能進來、裡面的規則不出去,但 shadowDOM 是完全的割裂。這個巨大的區別使得它們的工程意義相差甚遠。後面我們會說到 css module,它的表現顯然和 scoped style 一樣,和 Shadow 不一樣。

CSS module 和 CSS in JS 都是把樣式寫成或編譯成腳本,同時把腳本生成的 DOM 的最外面一層加一個 nounce 的 attribute;然後再給所有受控的 CSS 規則都套上這個 “attribute”。缺點是相對麻煩了一點、並且要完全控制掌握所有 DOM 創建。在前端框架裡,Angular 這樣做很自然。

後面還會提到這方面最流行的 NPM 包有個好玩的 feature 可能會造成不小心的 bug。

我們採用的是 DOM 沙盒保護 head 內標籤。這樣的 style 和 link 本身都可以受到沙盒統一保護。在實際應用中我們的子應用開發者在業務組件裡也有用 CSS module 的,我們也不用管——反正去掉標籤這個事情最安全。

DOM 沙盒就看管好某個 DOM 標籤,誰要改了,沙盒切換時改回來。對絕大多數情況綁定的 style 和 link 標籤都有效。但這個只限於單進程的情景。

如果如前面提到的多進程的情況(就是理解成同時有 N 個沙盒在一起運行、並行的系統)。那 CSS 肯定不能和 JavaScript 的單線程運行時一樣那麼搞,所以一定要用 moduled CSS。也不難搞,很多開源庫可以用。即使出現大家引用同一個組件庫的不同版本、各自 hack 過、失手創造了什麼“么蛾子”也不用怕,因為他們都是編譯好、作用域有限了的。

用 NPM 上的 styled-component 包時要小心,他們會根據環境變量判斷環境;然後對 prod 環境啟用一個叫“speedy”的模式,它將不用 innerText 寫樣式規則,而是用 addRules 那一整套 API。但是這套標準似乎沒明確界定這個標籤被從文檔 DOM 樹里移除時的行為和表現,也許因為顯而易見 rules 也應當一起移除。但我們插回來時,這種含糊就亂套了。瀏覽器實際的表現是移除再插回的標籤 rules 都沒了。這裡顯然需要我們額外處理。

4.2 全局變量沙盒

另一個重要的是全局變量干擾問題。 Polyfill 等運行環境相關的全局對象、環境變量等具體實現上有非常大的差別,又全部作用於全局。它們對子應用、模塊化的子組件來說,又屬於自身全局外部的環境。

這塊是微前端實施的一大重點。我個人覺得是這樣的。可以看出來大家都不是很信。 “誰不知道不要寫全局變量啊?不會有這麼不靠譜的人”。事實上真的試過才會發現會有好多。例如頭條號裡面用到了某個剪裁圖片的插件庫。它是個非常完善、正派、和古典的包,同時支持 ReactJQuery。它給全局寫了一個單例的實現。並且在開發調試過程中我們不同業務線的團隊就真的用了這個包的不同版本。

當然這個不重要,也沒有造成問題。一個比較嚴峻的例子是這個—— reGeneratorRuntime。它是編譯 async 語法用的,在某個 config 下的 Babel 會 delete 這個對象。到底是啥原理不清楚也不需要多講,但是非常肯定會衝突並造成問題。曾經我們的西瓜號團隊的 polyfill 規則和另一個業務線就發生了這類衝突。所以要比較 delete,恢復刪除,切換回去西瓜再刪掉。

Identifier 是另一個關注點。你是否完全清楚 Identifier 是什麼? Identifier 就是在某個 scope 下起作用的變量名啊什麼的,包括 functionletclassconst。只有 var 出來的東西特殊一些、不會佔用 Identifier,以上幾個會,佔用後不可以重複用。

這些東西首先你遍歷不了,沒有枚舉器;其次他們不是某個對象的成員,而僅是編譯層面的名字。一旦產生了絕對刪不掉。

在全局作用域下 var a 的時候,實際上是生成了一個超範圍的 Identifier 並且額外在 global 上創建一個同名的 key,指向同一個地址。這是 var 語句額外的操作。這讓我們可以用遍歷 window 的方式來處置全局變量。

但如果你來個 const 就沒辦法了,沒有任何辦法。class 也一樣。沒辦法枚舉也沒辦法刪除。最多就是再用 class 關鍵字聲明一下覆蓋掉。

總之這個事不要多想,new function 包起來幾乎必不可少。還可以傳入如 setTimeout 這種入參,用來控制異步實現“多進程”並行。

還有個 location 不要挪,會刷新頁面。黑名單掉它。

還有個好玩的事:functionvar 一樣會額外在 window 上增加個 key。這個 propertyconfigurablefalse——也就是不能刪除。但是可以賦值。
所以如果你如果光 var a,就可以 delete window.a;再寫 a 就是 undefined。寫個 function a,再寫個 delete a 就無效。但如果你寫個 function a,再寫個 var a = 1。啥效果呢,你給 window 上綁了個刪不掉的數字,延續了 function a 的不可刪除屬性和 var a 的值。
更好玩的是 class,你 class B {} ,再 console log window.B,咋麼樣,undefind。再寫 B = 1;然後再看 window.B 怎麼樣?繼續 undefined 了, B = 1 沒有效果。
說明潛在的某個機制在 class 關鍵字執行的時候,給 global 綁上了個叫 B 的 property,但是是個無法枚舉和訪問的 propertypropertywritable true, enumerable: true, configurable: true 之外的隱藏屬性。

4.3 其他

還有好多需要進程安全的對象,比如 cookie,但這個其實不特別重要,簡單約定一個使用 path 就可以了——cookie 除了設置 domain 還可以設置 path。只不過大部分人都不設它(也就是設為根目錄“/”)。

localStorage 可以也保護一下。取決於你的業務。因為這些都屬於 windows 的全局變量,所以實現一個包裝過的 class 集成並模擬 localStorage 原本的行為就可以。讓它所有方法都先給 key 加 prefix,再執行方法的 super。這個 prefix 可以簡單地寫死當前沙盒的 uuid 即可,因為 window.localStorage 作為全局變量本身就在沙盒保護之內。

5. 沙盒的其他功能

下面是最後一章節,會講沙盒下有些特殊的東西,它們都需要額外處理。其中重點說一下埋點。多數微前端項目一個頁面裡的埋點已經屬於不同項目了,這塊就得搞清楚具體什麼子應用、用的什麼統計代碼、需要處理哪些級別的緩存。

5.1 埋點緩存系統

像前面說的,把 Storage 緩存全部用沙盒包裝過,對埋點體係來說還不算完。絕大多數埋點系統的事件發送都是異步、找網絡空閒的。並且這些源碼通常又在 SDK 裡面、不在父工程直接控制的代碼裡。所以可操作餘地不多。實際上只能是把緩存數據、項目信息都好好保存好,再把收集數據的緩存和產生數據時的沙盒狀態對應起來。

5.2 console

沙盒可能會包一層或者多層運行時,所以 console 讀會比較累。開發的時候可以額外處理一下,為開發者提供便利。而且現在的前端項目,線上希望 console 打印越少越好、內容越正規越好,並且調試又非常忌諱別人遺留的打印干擾。這些都是沙盒可以做的。我們甚至做了把內容直接對接到採集系統裡的上傳 log。

具體來說,為 log 注入 callstack 是用 new Error 的方式。這樣可以通過 error.stack 拿到調用堆棧。這個值直接是個字符串、是換行分割的 Markdown,可以寫鏈接進去,也能對應到調試窗口的 source code。

同理的是當遇到真的 exception 也應當如此管控。從 catch 到異常、再次 throw 出去之前,可以給 error.stack 值全都 hack 掉。去掉不必要的 stack——比如你包的那層 new Function,刪掉那一行。提示的內容也可以改。

5.3 sourceMapping

sourceMapping 是谷歌 closure 發明的一個、現在成為 ES6 標準的東西。原理是一個字符位置到字符位置的映射。那麼在 new Function 下的沙盒裡能不能用呢?當然可以。

我們先說說 new Function 的表現。在 chrome 裡它在調試中是一個新的匿名環境:anonymous,字符行列位置就從函數字符串開頭第一行開始算起。如果你是把編譯並且生成了 sourceMap 後的 bundle 放到 new function 裡執行,這個位置是完全對應的,不需要做任何額外的 hack。

這同時是因為 chrome 能正常識別 new Function 參數字符串末尾的 sourcemappingUrl= 的註釋。對應在 callstack 裡一切都對。有好多時候我們發現業務方會不放心這個事,會覺得我直接下載的 .js 被包了,不放心調試的 call stack 和 sourceMap。事實上沒問題。這兩方面都沒問題。

另外我們之前其他場合分享也提到了,我們認為微前端的諸多必備條件,其中一個是要有一個服務發現資源和版本管理平台,管理微前端的獨立發布、上下線和組合測試等等問題的。這樣順便也給了我們一個條件,可以給 sourceMap 管理起來。

結語

上面就是本次分享的全部,分享了關於微前端沙盒字節跳動兩年來使用和實現的經驗,以及面對這些挑戰時的思考過程。非常幸運我們的項目有足夠多給力的伙伴們支持,最終獲得了比較大的成功,也非常明顯地提升了重量級的產品的質量。

本文轉載自公眾號字節跳動技術團隊(ID:toutiaotechblog)。

原文鏈接

https://mp.weixin.qq.com/s/VlCK65cT7XikzFJpyks9Yg