自從 2014 年 HTML5 正式推薦標(biāo)準(zhǔn)發(fā)布以來,HTML5 增加了越來越多強(qiáng)大的特性和功能,而在這其中,工作線程(Web Worker)概念的推出讓人眼前一亮,但未曾隨之激起多大的浪花,并被在其隨后工程側(cè)的 Angular、Vue、React 等框架的「革命」浪潮所淹沒。
當(dāng)然,我們總會(huì)偶然看過一些文章介紹,或出于學(xué)習(xí)的目的做過一些應(yīng)用場景下的練習(xí),甚或在實(shí)際項(xiàng)目中的涉及大量數(shù)據(jù)計(jì)算場景中真的使用過。但相信也有很多人和我一樣茫然,找不到這種高大上的技術(shù)在實(shí)際項(xiàng)目場景中能有哪些能起到廣泛作用的應(yīng)用。
究其原因,Web Worker 獨(dú)立于 UI 主線程運(yùn)行的特性使其被大量考慮進(jìn)行性能優(yōu)化方面的嘗試(比如一些圖像分析、3D 計(jì)算繪制等場景),以保證在進(jìn)行大量計(jì)算的同時(shí),頁面對用戶能有及時(shí)的響應(yīng)。
而這些性能優(yōu)化的需求在前端側(cè)一方面涉及頻率低,另一方面也能通過微任務(wù)或服務(wù)端側(cè)處理來解決,它并不能像 Web Socket 這種技術(shù)為前端頁面下的輪詢場景的優(yōu)化能帶來質(zhì)的改變。
直至 2019 年爆火的微前端架構(gòu)的出現(xiàn),基于微應(yīng)用間 JavaScript 沙箱隔離的需求,Web Worker 才得以重新從邊緣化的位置躍入到我的中心視野。
根據(jù)我已經(jīng)了解到的 Web Worker 的相關(guān)知識(shí),我知道了 Web Worker 是工作在一個(gè)獨(dú)立子線程下(雖然這個(gè)子線程比起 Java 等編譯型語言的子線程實(shí)現(xiàn)得還有點(diǎn)弱,如無法加鎖等),線程之間自帶隔離的特性,那基于這種「物理」性的隔離,能不能實(shí)現(xiàn) JavaScript 運(yùn)行時(shí)的隔離呢?
本文接下來的內(nèi)容,將介紹我在探索基于 Web Worker 實(shí)現(xiàn) JavaScript 沙箱隔離方案過程中的一些資料收集、理解以及我的踩坑和思考的過程。雖然可能整篇文章內(nèi)容都在「炒冷飯」,但還是希望我的探索方案的過程能對正在看這篇文章的你有所幫助。
JavaScript 沙箱
在探索基于 Web Worker 的解決方案之前,我們先要對當(dāng)前要解決的問題——JavaScript 沙箱有所了解。
提到沙箱,我會(huì)先想到出于興趣玩過的沙盒游戲,但我們要探索的 JavaScript 沙箱不同于沙盒游戲,沙盒游戲注重對世界基本元素的抽象、組合以及物理力系統(tǒng)的實(shí)現(xiàn)等,而 JavaScript 沙箱則更注重在使用共享數(shù)據(jù)時(shí)對操作狀態(tài)的隔離。
在現(xiàn)實(shí)與 JavaScript 相關(guān)的場景中,我們知道平時(shí)使用的瀏覽器就是一個(gè)沙箱,運(yùn)行在瀏覽器中的 JavaScript 代碼無法直接訪問文件系統(tǒng)、顯示器或其他任何硬件。
Chrome 瀏覽器中每個(gè)標(biāo)簽頁也是一個(gè)沙箱,各個(gè)標(biāo)簽頁內(nèi)的數(shù)據(jù)無法直接相互影響,接口都在獨(dú)立的上下文中運(yùn)行。而在同一個(gè)瀏覽器標(biāo)簽頁下運(yùn)行 HTML 頁面,有哪些更細(xì)節(jié)的、對沙箱現(xiàn)象有需求的場景呢?
當(dāng)我們作為前端開發(fā)人員較長一段時(shí)間后,我們很輕易地就能想到在同一個(gè)頁面下,使用沙箱需求的諸多應(yīng)用場景,譬如:
- 執(zhí)行從不受信的源獲取到的第三方 JavaScript 代碼時(shí)(比如引入插件、處理 jsonp 請求回來的數(shù)據(jù)等)。
- 在線代碼編輯器場景(比如著名的 codesandbox)。
- 使用服務(wù)端渲染方案。
- 模板字符串中的表達(dá)式的計(jì)算。
- ... ...
這里我們先回到開頭,先將前提假設(shè)在我正在面對的微前端架構(gòu)設(shè)計(jì)下。在微前端架構(gòu)中,其最關(guān)鍵的一個(gè)設(shè)計(jì)便是各個(gè)子應(yīng)用間的調(diào)度實(shí)現(xiàn)以及其運(yùn)行態(tài)的維護(hù),而運(yùn)行時(shí)各子應(yīng)用使用全局事件監(jiān)聽、使全局 CSS 樣式生效等常見的需求在多個(gè)子應(yīng)用切換時(shí)便會(huì)成為一種污染性的副作用,為了解決這些副作用,后來出現(xiàn)的很多微前端架構(gòu)(如 乾坤)有著各種各樣的實(shí)現(xiàn)。
譬如 CSS 隔離中常見的命名空間前綴、Shadow DOM、 乾坤 sandbox css 的運(yùn)行時(shí)動(dòng)態(tài)增刪等,都有著確實(shí)行之有效的具體實(shí)踐,而這里最麻煩棘手的,還是微應(yīng)用間的 JavaScript 的沙箱隔離。
在微前端架構(gòu)中,JavaScript 沙箱隔離需要解決如下幾個(gè)問題:
- 掛在 window 上的全局方法/變量(如 setTimeout、滾動(dòng)等全局事件監(jiān)聽等)在子應(yīng)用切換時(shí)的清理和還原。
- Cookie、LocalStorage 等的讀寫安全策略限制。
- 各子應(yīng)用獨(dú)立路由的實(shí)現(xiàn)。
- 多個(gè)微應(yīng)用共存時(shí)相互獨(dú)立的實(shí)現(xiàn)。
在 乾坤 架構(gòu)設(shè)計(jì)中,關(guān)于沙箱有兩個(gè)入口文件需要關(guān)注,一個(gè)是 proxySandbox.ts,另一個(gè)是 snapshotSandbox.ts,他們分別基于 Proxy 實(shí)現(xiàn)代理了 window 上常用的常量和方法以及不支持 Proxy 時(shí)降級(jí)通過快照實(shí)現(xiàn)備份還原。
結(jié)合其相關(guān)開源文章分享,簡單總結(jié)下其實(shí)現(xiàn)思路:起初版本使用了快照沙箱的概念,模擬 ES6 的 Proxy API,通過代理劫持 window ,當(dāng)子應(yīng)用修改或使用 window 上的屬性或方法時(shí),把對應(yīng)的操作記錄下來,每次子應(yīng)用掛載/卸載時(shí)生成快照,當(dāng)再次從外部切換到當(dāng)前子應(yīng)用時(shí),再從記錄的快照中恢復(fù),而后來為了兼容多個(gè)子應(yīng)用共存的情況,又基于 Proxy 實(shí)現(xiàn)了代理所有全局性的常量和方法接口,為每個(gè)子應(yīng)用構(gòu)造了獨(dú)立的運(yùn)行環(huán)境。
另外一種值得借鑒的思路是阿里云開發(fā)平臺(tái)的 Browser VM,其核心入口邏輯在 Context.js 文件中。它的具體實(shí)現(xiàn)思路是這樣的:
1. 借鑒 with 的實(shí)現(xiàn)效果,在 webpack 編譯打包階段為每個(gè)子應(yīng)用代碼包裹一層代碼(見其插件包 breezr-plugin-os 下相關(guān)文件),創(chuàng)建一個(gè)閉包,傳入自己模擬的 window、document、location、history 等全局對象(見 根目錄下 相關(guān)文件)。
2. 在模擬的 Context 中,new 一個(gè) iframe 對象,提供一個(gè)和宿主應(yīng)用空的(about:blank) 同域 URL 來作為這個(gè) iframe 初始加載的 URL(空的 URL 不會(huì)發(fā)生資源加載,但是會(huì)產(chǎn)生和這個(gè) iframe 中關(guān)聯(lián)的 history 不能被操作的問題,這時(shí)路由的變換只支持 hash 模式),然后將其下的原生瀏覽器對象通過 contentWindow 取出來(因?yàn)?iframe 對象天然隔離,這里省去了自己 Mock 實(shí)現(xiàn)所有 API 的成本)。
3. 取出對應(yīng)的 iframe 中原生的對象之后,繼續(xù)對特定需要隔離的對象生成對應(yīng)的 Proxy,然后對一些屬性獲取和屬性設(shè)置,做一些特定的實(shí)現(xiàn)(比如 window.document 需要返回特定的沙箱 document 而不是當(dāng)前瀏覽器的document 等)。
4. 為了文檔內(nèi)容能夠被加載在同一個(gè) DOM 樹上,對于 document,大部分的 DOM 操作的屬性和方法仍舊直接使用宿主瀏覽器中的 document 的屬性和方法處理等。
總的來說,在 Browser VM 的實(shí)現(xiàn)中, 可以看出其實(shí)現(xiàn)部分還是借鑒了 乾坤 或者說其他微前端架構(gòu)的思路,比如常見全局對象的代理和攔截。并且借助 Proxy 特性,針對 Cookie、LocalStorage 的讀寫同樣能做一些安全策略的實(shí)現(xiàn)等。
但其最大的亮點(diǎn)還是借助 iframe 做了一些取巧的實(shí)現(xiàn),當(dāng)這個(gè)為每個(gè)子應(yīng)用創(chuàng)建的 iframe 被移除時(shí),寫在其下 window 上的變量和 setTimeout、全局事件監(jiān)聽等也會(huì)一并被移除;另外基于 Proxy,DOM 事件在沙箱中做記錄,然后在宿主中生命周期中實(shí)現(xiàn)移除,能夠以較小的開發(fā)成本實(shí)現(xiàn)整個(gè) JavaScript 沙箱隔離的機(jī)制。
除了以上現(xiàn)在比較火的方案,最近我也了解到了 UI 設(shè)計(jì)領(lǐng)域的 Figma 產(chǎn)品也基于其插件系統(tǒng)產(chǎn)出了一種隔離方案。起初 Figma 同樣是將插件代碼放入 iframe 中執(zhí)行并通過 postMessage 與主線程通信,但由于易用性以及 postMessage 序列化帶來的性能等問題,F(xiàn)igma 選擇還是將插件放入主線程去執(zhí)行。
Figma 采用的方案是基于目前還在草案階段 Realm API,并將 JavaScript 解釋器的一種 C++ 實(shí)現(xiàn) Duktape 編譯到了 WebAssembly,然后將其嵌入到 Realm 上下文中,實(shí)現(xiàn)了其產(chǎn)品下的三方插件的獨(dú)立運(yùn)行。這種方案和探索的基于 Web Worker 的實(shí)現(xiàn)可能能夠結(jié)合得更好,持續(xù)關(guān)注中。
Web Worker 與 DOM 渲染
在了解了 JavaScript 沙箱的「前世今生」之后,我們將目光投回本文的主角——Web Worker 身上。
正如本文開頭所說,Web Worker 子線程的形式也是一種天然的沙箱隔離,理想的方式,是借鑒 Browser VM 的前段思路,在編譯階段通過 Webpack 插件為每個(gè)子應(yīng)用包裹一層創(chuàng)建 Worker 對象的代碼,讓子應(yīng)用運(yùn)行在其對應(yīng)的單個(gè) Worker 實(shí)例中,比如:
- __WRAP_WORKER__(`/* 打包代碼 */ }`);
- function __WRAP_WORKER__(appCode) {
- var blob = new Blob([appCode]);
- var appWorker = new Worker(window.URL.createObjectURL(blob));
- }
但在了解過微前端下 JavaScript 沙箱的實(shí)現(xiàn)過程后,我們不難發(fā)現(xiàn)幾個(gè)在 Web Worker 下去實(shí)現(xiàn)微前端場景的 JavaScript 沙箱必然會(huì)遇到的幾個(gè)難題:
- 出于線程安全設(shè)計(jì)考慮,Web Worker 不支持 DOM 操作,必須通過 postMessage 通知 UI 主線程來實(shí)現(xiàn)。
- Web Worker 無法訪問 window、document 之類的瀏覽器全局對象。
其他諸如 Web Worker 無法訪問頁面全局變量和函數(shù)、無法調(diào)用 alert、confirm 等 BOM API 等問題,相對于無法訪問 window、document 全局對象已經(jīng)是小問題了。不過可喜的是,Web Worker 中可以正常使用 setTimeout、setInterval 等定時(shí)器函數(shù),也仍能發(fā)送 ajax 請求。
所以,當(dāng)先要解決問題,便是在單個(gè) Web Worker 實(shí)例中執(zhí)行 DOM 操作的問題了。首先我們有一個(gè)大前提:Web Worker 中無法渲染 DOM,所以,我們需要基于實(shí)際的應(yīng)用場景,將 DOM 操作進(jìn)行拆分。
React Worker DOM
因?yàn)槲覀兾⑶岸思軜?gòu)中的子應(yīng)用局限在 React 技術(shù)棧下,我先將目光放在了基于 React 框架的解決方案上。
在 React 中,我們知道其將渲染階段分為對 DOM 樹的改變進(jìn)行 Diff 和實(shí)際渲染改變頁面 DOM 兩個(gè)階段這一基本事實(shí),那能不能將 Diff 過程置于 Web Worker 中,再將渲染階段通過 postMessage 與主線程進(jìn)行通信后放在主線程進(jìn)行呢?簡單一搜,頗為汗顏,已經(jīng)有大佬在 5、6 年前就有嘗試了。這里我們可以參考下 react-worker-dom 的開源代碼。
react-worker-dom 中的實(shí)現(xiàn)思路很清晰。其在 common/channel.js 中統(tǒng)一封裝了子線程和主線程互相通信的接口和序列化通信數(shù)據(jù)的接口,然后我們可以看到其在 Worker 下實(shí)現(xiàn) DOM 邏輯處理的總?cè)肟谖募?worker 目錄下,
從該入口文件順藤摸瓜,可以看到其實(shí)現(xiàn)了計(jì)算 DOM 后通過 postMessage 通知主線程進(jìn)行渲染的入口文件 WorkerBridge.js 以及其他基于 React 庫實(shí)現(xiàn)的 DOM 構(gòu)造、Diff 操作、生命周期 Mock 接口等相關(guān)代碼,而接受渲染事件通信的入口文件在 page 目錄下,該入口文件接受 node 操作事件后再結(jié)合 WorkerDomNodeImpl.js 中的接口代碼實(shí)現(xiàn)了 DOM 在主線程的實(shí)際渲染更新。
簡單做下總結(jié)。基于 React 技術(shù)棧,通過在 Web Worker 下實(shí)現(xiàn) Diff 與渲染階段的進(jìn)行分離,可以做到一定程度的 DOM 沙箱,但這不是我們想要的微前端架構(gòu)下的 JavaScript 沙箱。
先不談拆分 Diff 階段與渲染階段的成本與收益比,首先,基于技術(shù)棧框架的特殊性所做的這諸多努力,會(huì)隨著這個(gè)框架本身版本的升級(jí)存在著維護(hù)升級(jí)難以掌控的問題;其次,假如各個(gè)子應(yīng)用使用的技術(shù)棧框架不同,要為這些不同的框架分別封裝適配的接口,擴(kuò)展性和普適性弱;最后,最為重要的一點(diǎn),這種方法暫時(shí)還是沒有解決 window 下資源共享的問題,或者說,只是啟動(dòng)了解決這個(gè)問題的第一步。
接下來,我們先繼續(xù)探討 Worker 下實(shí)現(xiàn) DOM 操作的另外一種方案。
AMP WorkerDOM
在我開始糾結(jié)于如 react-worker-dom 這種思路實(shí)際落地開發(fā)的諸多「天塹」問題的同時(shí),瀏覽過其他 DOM 框架因?yàn)橥瑯泳邆洳寮C(jī)制偶然迸進(jìn)了我的腦海,它是 Google 的 AMP。
AMP 開源項(xiàng)目 中除了如 amphtml 這種通用的 Web 組件框架,還有很多其他工程采用了 Shadow DOM、Web Component 等新技術(shù),在項(xiàng)目下簡單刷了一眼后,我欣喜地看到了工程 worker-dom。
粗略翻看下 worker-dom 源碼,我們在 src 根目錄下可以看到 main-thread 和 worker-thread 兩個(gè)目錄,分別打開看了下后,可以發(fā)現(xiàn)其實(shí)現(xiàn)拆分 DOM 相關(guān)邏輯和 DOM 渲染的思路和上面的 react-worker-dom 基本類似,但 worker-dom 因?yàn)楹蜕蠈涌蚣軣o關(guān),其下的實(shí)現(xiàn)更為貼近 DOM 底層。
先看 worker-thread DOM 邏輯層的相關(guān)代碼,可以看到其下的 dom 目錄 下實(shí)現(xiàn)了基于 DOM 標(biāo)準(zhǔn)的所有相關(guān)的節(jié)點(diǎn)元素、屬性接口、document 對象等代碼,上一層目錄中也實(shí)現(xiàn)了 Canvas、CSS、事件、Storage 等全局屬性和方法。
接著看 main-thread,其關(guān)鍵功能一方面是提供加載 worker 文件從主線程渲染頁面的接口,另一方面可以從 worker.ts 和 nodes.ts 兩個(gè)文件的代碼來理解。
在 worker.ts 中像我最初所設(shè)想的那樣包裹了一層代碼,用于自動(dòng)生成 Worker 對象,并將代碼中的所有 DOM 操作都代理到模擬的 WorkerDOM 對象上:
- const code = `
- 'use strict';
- (function(){
- ${workerDOMScript}
- self['window'] = self;
- var workerDOM = WorkerThread.workerDOM;
- WorkerThread.hydrate(
- workerDOM.document,
- ${JSON.stringify(strings)},
- ${JSON.stringify(skeleton)},
- ${JSON.stringify(cssKeys)},
- ${JSON.stringify(globalEventHandlerKeys)},
- [${window.innerWidth}, ${window.innerHeight}],
- ${JSON.stringify(localStorageInit)},
- ${JSON.stringify(sessionStorageInit)}
- );
- workerDOM.document[${TransferrableKeys.observe}](this);
- Object.keys(workerDOM).forEach(function(k){self[k]=workerDOM[k]});
- }).call(self);
- ${authorScript}
- //# sourceURL=${encodeURI(config.authorURL)}`;
- this[TransferrableKeys.worker] = new Worker(URL.createObjectURL(new Blob([code])));
在 nodes.ts 中,實(shí)現(xiàn)了真實(shí)元素節(jié)點(diǎn)的構(gòu)造和存儲(chǔ)(基于存儲(chǔ)數(shù)據(jù)結(jié)構(gòu)是否以及如何在渲染階段有優(yōu)化還需進(jìn)一步研究源碼)。
同時(shí),在 transfer 目錄下的源碼,定義了邏輯層和 UI 渲染層的消息通信的規(guī)范。
總的來看,AMP WorkerDOM 的方案拋棄了上層框架的約束,通過從底層構(gòu)造了 DOM 所有相關(guān) API 的方式,真正做到了與框架技術(shù)棧無關(guān)。
它一方面完全可以作為上層框架的底層實(shí)現(xiàn),來支持各種上層框架的二次封裝遷移(如工程 amp-react-prototype),另一方面結(jié)合了當(dāng)前主流 JavaScript 沙箱方案,通過模擬 window、document 全局方法的并代理到主線程的方式實(shí)現(xiàn)了部分的 JavaScript 沙箱隔離(暫時(shí)沒看到路由隔離的相關(guān)代碼實(shí)現(xiàn))。
當(dāng)然,從我個(gè)人角度來看,AMP WorkerDOM 也有其當(dāng)前在落地上一定的局限性。一個(gè)是對當(dāng)前主流上層框架如 Vue、React 等的遷移成本及生態(tài)的適配成本,另一個(gè)是其在單頁應(yīng)用下的尚未看到有相關(guān)實(shí)現(xiàn)方案,在大型 PC 微前端應(yīng)用的支持上還無法找到更優(yōu)方案。
其實(shí),在了解完 AMP WorkerDOM 的實(shí)現(xiàn)方案之后,基于 react-worker-dom 思路的后續(xù)方案也可以有個(gè)大概方向了:渲染通信的后續(xù)過程,可考慮結(jié)合 Browser VM 的相關(guān)實(shí)現(xiàn),在生成 Worker 對象的同時(shí),也生成一個(gè) iframe 對象,然后將 DOM 下的操作都通過 postMessage 發(fā)送到主線程后,以與其綁定的 iframe 兌現(xiàn)來執(zhí)行,同時(shí),通過代理將具體的渲染實(shí)現(xiàn)再轉(zhuǎn)發(fā)給原 WorkerDomNodeImpl.js 邏輯來實(shí)現(xiàn) DOM 的實(shí)際更新。
小結(jié)與一些個(gè)人前瞻
首先聊一聊個(gè)人的一些總結(jié)。Web Worker 下實(shí)現(xiàn)微前端架構(gòu)下的 JavaScript 沙箱最初是出于一點(diǎn)個(gè)人靈光的閃現(xiàn),在深入調(diào)研后,雖然最終還是因?yàn)檫@樣那樣的問題導(dǎo)致在方案落地上無法找到最優(yōu)解從而放棄采用通用方案,但仍不妨礙我個(gè)人對 Web Worker 技術(shù)在實(shí)現(xiàn)插件類沙箱應(yīng)用上的持續(xù)看好。
插件機(jī)制在前端領(lǐng)域一直是津津樂道的一種設(shè)計(jì),從 Webpack 編譯工具到 IDE 開發(fā)工具,從 Web 應(yīng)用級(jí)的實(shí)體插件到應(yīng)用架構(gòu)設(shè)計(jì)中插件擴(kuò)展設(shè)計(jì),結(jié)合 WebAssembly 技術(shù),Web Worker 無疑將在插件設(shè)計(jì)上占據(jù)舉足輕重的地位。
其次是一些個(gè)人的一些前瞻思考。其實(shí)從 Web Worker 實(shí)現(xiàn) DOM 渲染的調(diào)研過程中可以看到,基于邏輯與 UI 分離的思路,前端后續(xù)的架構(gòu)設(shè)計(jì)有很大機(jī)會(huì)能夠產(chǎn)生一定的變革。
目前不管是盛行的 Vue 還是 React 框架,其框架設(shè)計(jì)不論是 MVVM 還是結(jié)合 Redux 之后的 Flux,其本質(zhì)上仍舊還是由 View 層驅(qū)動(dòng)的框架設(shè)計(jì)(個(gè)人淺見),其具備靈活性的同時(shí)也產(chǎn)生著性能優(yōu)化、大規(guī)模項(xiàng)目層級(jí)升上后的協(xié)作開發(fā)困難等問題,而基于 Web Worker 的邏輯與 UI 分離,將促使數(shù)據(jù)獲取、處理、消費(fèi)整個(gè)流程的進(jìn)一步的業(yè)務(wù)分層,從而固化出一整套的 MVX 設(shè)計(jì)思路。
原文地址:https://mp.weixin.qq.com/s/BEBKoCKTKlk6fv5gbTdA3w