淺探 Web Worker 與 JavaScript 沙箱

一些「炒冷飯」的背景介紹

本文並不會從頭開始介紹 Web Worker 的基礎知識和基本 API 的使用等(只是部分有涉及),若還未了解過 Web Worker,可參考查閱 W3C 標準 Workers 文檔 中的相關介紹。

自從 2014 年 HTML5 正式推薦標準發佈以來,HTML5 增長了愈來愈多強大的特性和功能,而在這其中,工做線程(Web Worker)概念的推出讓人眼前一亮,但不曾隨之激起多大的浪花,並被在其隨後工程側的 Angular、Vue、React 等框架的「革命」浪潮所淹沒。固然,咱們總會偶然看過一些文章介紹,或出於學習的目的作過一些應用場景下的練習,甚或在實際項目中的涉及大量數據計算場景中真的使用過。但相信也有不少人和我同樣茫然,找不到這種高大上的技術在實際項目場景中能有哪些能起到普遍做用的應用。css

究其緣由,Web Worker 獨立於 UI 主線程運行的特性使其被大量考慮進行性能優化方面的嘗試(好比一些圖像分析、3D 計算繪製等場景),以保證在進行大量計算的同時,頁面對用戶能有及時的響應。而這些性能優化的需求在前端側一方面涉及頻率低,另外一方面也能經過微任務或服務端側處理來解決,它並不能像 Web Socket 這種技術爲前端頁面下的輪詢場景的優化能帶來質的改變。html

直至 2019 年爆火的微前端架構的出現,基於微應用間 JavaScript 沙箱隔離的需求,Web Worker 才得以從新從邊緣化的位置躍入到個人中心視野。根據我已經瞭解到的 Web Worker 的相關知識,我知道了 Web Worker 是工做在一個獨立子線程下(雖然這個子線程比起 Java 等編譯型語言的子線程實現得還有點弱,如沒法加鎖等),線程之間自帶隔離的特性,那基於這種「物理」性的隔離,能不能實現 JavaScript 運行時的隔離呢?前端

本文接下來的內容,將介紹我在探索基於 Web Worker 實現 JavaScript 沙箱隔離方案過程當中的一些資料收集、理解以及個人踩坑和思考的過程。雖然可能整篇文章內容都在「炒冷飯」,但仍是但願個人探索方案的過程能對正在看這篇文章的你有所幫助。node

JavaScript 沙箱

在探索基於 Web Worker 的解決方案以前,咱們先要對當前要解決的問題——JavaScript 沙箱有所瞭解。react

提到沙箱,我會先想到出於興趣玩過的沙盒遊戲,但咱們要探索的 JavaScript 沙箱不一樣於沙盒遊戲,沙盒遊戲注重對世界基本元素的抽象、組合以及物理力系統的實現等,而 JavaScript 沙箱則更注重在使用共享數據時對操做狀態的隔離。webpack

在現實與 JavaScript 相關的場景中,咱們知道平時使用的瀏覽器就是一個沙箱,運行在瀏覽器中的 JavaScript 代碼沒法直接訪問文件系統、顯示器或其餘任何硬件。Chrome 瀏覽器中每一個標籤頁也是一個沙箱,各個標籤頁內的數據沒法直接相互影響,接口都在獨立的上下文中運行。而在同一個瀏覽器標籤頁下運行 HTML 頁面,有哪些更細節的、對沙箱現象有需求的場景呢?git

當咱們做爲前端開發人員較長一段時間後,咱們很輕易地就能想到在同一個頁面下,使用沙箱需求的諸多應用場景,譬如:github

  1. 執行從不受信的源獲取到的第三方 JavaScript 代碼時(好比引入插件、處理 jsonp 請求回來的數據等)。web

  2. 在線代碼編輯器場景(好比著名的 codesandbox)。ajax

  3. 使用服務端渲染方案。

  4. 模板字符串中的表達式的計算。

  5. ... ...

這裏咱們先回到開頭,先將前提假設在我正在面對的微前端架構設計下。在微前端架構(推薦文章 Thinking in Microfrontend擁抱雲時代的前端開發架構——微前端 等)中,其最關鍵的一個設計即是各個子應用間的調度實現以及其運行態的維護,而運行時各子應用使用全局事件監聽、使全局 CSS 樣式生效等常見的需求在多個子應用切換時便會成爲一種污染性的反作用,爲了解決這些反作用,後來出現的不少微前端架構(如 乾坤)有着各類各樣的實現。譬如 CSS 隔離中常見的命名空間前綴、Shadow DOM、 乾坤 sandbox css 的運行時動態增刪等,都有着確實行之有效的具體實踐,而這裏最麻煩棘手的,仍是微應用間的 JavaScript 的沙箱隔離。

在微前端架構中,JavaScript 沙箱隔離須要解決以下幾個問題:

  1. 掛在 window 上的全局方法/變量(如 setTimeout、滾動等全局事件監聽等)在子應用切換時的清理和還原。

  2. Cookie、LocalStorage 等的讀寫安全策略限制。

  3. 各子應用獨立路由的實現。

  4. 多個微應用共存時相互獨立的實現。

乾坤 架構設計中,關於沙箱有兩個入口文件須要關注,一個是 proxySandbox.ts,另外一個是 snapshotSandbox.ts,他們分別基於 Proxy 實現代理了 window 上經常使用的常量和方法以及不支持 Proxy 時降級經過快照實現備份還原。結合其相關開源文章分享,簡單總結下其實現思路:起第一版本使用了快照沙箱的概念,模擬 ES6 的 Proxy API,經過代理劫持 window ,當子應用修改或使用 window 上的屬性或方法時,把對應的操做記錄下來,每次子應用掛載/卸載時生成快照,當再次從外部切換到當前子應用時,再從記錄的快照中恢復,然後來爲了兼容多個子應用共存的狀況,又基於 Proxy 實現了代理全部全局性的常量和方法接口,爲每一個子應用構造了獨立的運行環境。

另一種值得借鑑的思路是阿里雲開發平臺的 Browser VM,其核心入口邏輯在 Context.js 文件中。它的具體實現思路是這樣的:

  1. 借鑑 with 的實現效果,在 webpack 編譯打包階段爲每一個子應用代碼包裹一層代碼(見其插件包 breezr-plugin-os 下相關文件),建立一個閉包,傳入本身模擬的 window、document、location、history 等全局對象(見 根目錄下 相關文件)。

  2. 在模擬的 Context 中,new 一個 iframe 對象,提供一個和宿主應用空的(about:blank) 同域 URL 來做爲這個 iframe 初始加載的 URL(空的 URL 不會發生資源加載,可是會產生和這個 iframe 中關聯的 history 不能被操做的問題,這時路由的變換隻支持 hash 模式),而後將其下的原生瀏覽器對象經過 contentWindow 取出來(由於 iframe 對象自然隔離,這裏省去了本身 Mock 實現全部 API 的成本)。

  3. 取出對應的 iframe 中原生的對象以後,繼續對特定須要隔離的對象生成對應的 Proxy,而後對一些屬性獲取和屬性設置,作一些特定的實現(好比 window.document 須要返回特定的沙箱 document 而不是當前瀏覽器的document 等)。

  4. 爲了文檔內容可以被加載在同一個 DOM 樹上,對於 document,大部分的 DOM 操做的屬性和方法仍舊直接使用宿主瀏覽器中的 document 的屬性和方法處理等。

總的來講,在 Browser VM 的實現中, 能夠看出其實現部分仍是借鑑了 乾坤 或者說其餘微前端架構的思路,好比常見全局對象的代理和攔截。而且藉助 Proxy 特性,針對 Cookie、LocalStorage 的讀寫一樣能作一些安全策略的實現等。但其最大的亮點仍是藉助 iframe 作了一些取巧的實現,當這個爲每一個子應用建立的 iframe 被移除時,寫在其下 window 上的變量和 setTimeout、全局事件監聽等也會一併被移除;另外基於 Proxy,DOM 事件在沙箱中作記錄,而後在宿主中生命週期中實現移除,可以以較小的開發成本實現整個 JavaScript 沙箱隔離的機制。

除了以上社區中如今比較火的方案,最近我也在 大型 Web 應用插件化架構探索 一文中瞭解到了 UI 設計領域的 Figma 產品也基於其插件系統產出了一種隔離方案。起初 Figma 一樣是將插件代碼放入 iframe 中執行並經過 postMessage 與主線程通訊,但因爲易用性以及 postMessage 序列化帶來的性能等問題,Figma 選擇仍是將插件放入主線程去執行。Figma 採用的方案是基於目前還在草案階段 Realm API,並將 JavaScript 解釋器的一種 C++ 實現 Duktape 編譯到了 WebAssembly,而後將其嵌入到 Realm 上下文中,實現了其產品下的三方插件的獨立運行。這種方案和探索的基於 Web Worker 的實現可能可以結合得更好,持續關注中。

Web Worker 與 DOM 渲染

在瞭解了 JavaScript 沙箱的「前世此生」以後,咱們將目光投回本文的主角——Web Worker 身上。

正如本文開頭所說,Web Worker 子線程的形式也是一種自然的沙箱隔離,理想的方式,是借鑑 Browser VM 的前段思路,在編譯階段經過 Webpack 插件爲每一個子應用包裹一層建立 Worker 對象的代碼,讓子應用運行在其對應的單個 Worker 實例中,好比:

__WRAP_WORKER__(`/* 打包代碼 */ }`);
​
function __WRAP_WORKER__(appCode) {
 var blob = new Blob([appCode]);
 var appWorker = new Worker(window.URL.createObjectURL(blob));
}

但在瞭解過微前端下 JavaScript 沙箱的實現過程後,咱們不難發現幾個在 Web Worker 下去實現微前端場景的 JavaScript 沙箱必然會遇到的幾個難題:

  1. 出於線程安全設計考慮,Web Worker 不支持 DOM 操做,必須經過 postMessage 通知 UI 主線程來實現。

  2. Web Worker 沒法訪問 window、document 之類的瀏覽器全局對象。

其餘諸如 Web Worker 沒法訪問頁面全局變量和函數、沒法調用 alert、confirm 等 BOM API 等問題,相對於沒法訪問 window、document 全局對象已是小問題了。不過可喜的是,Web Worker 中能夠正常使用 setTimeout、setInterval 等定時器函數,也仍能發送 ajax 請求。

因此,當先要解決問題,即是在單個 Web Worker 實例中執行 DOM 操做的問題了。首先咱們有一個大前提:Web Worker 中沒法渲染 DOM,因此,咱們須要基於實際的應用場景,將 DOM 操做進行拆分。

React Worker DOM

由於咱們微前端架構中的子應用侷限在 React 技術棧下,我先將目光放在了基於 React 框架的解決方案上。

在 React 中,咱們知道其將渲染階段分爲對 DOM 樹的改變進行 Diff 和實際渲染改變頁面 DOM 兩個階段這一基本事實,那能不能將 Diff 過程置於 Web Worker 中,再將渲染階段經過 postMessage 與主線程進行通訊後放在主線程進行呢?簡單一搜,頗爲汗顏,已經有大佬在 五、6 年前就有嘗試了。這裏咱們能夠參考下 react-worker-dom 的開源代碼。

react-worker-dom 中的實現思路很清晰。其在 common/channel.js 中統一封裝了子線程和主線程互相通訊的接口和序列化通訊數據的接口,而後咱們能夠看到其在 Worker 下實現 DOM 邏輯處理的總入口文件在 worker 目錄下,從該入口文件順藤摸瓜,能夠看到其實現了計算 DOM 後經過 postMessage 通知主線程進行渲染的入口文件 WorkerBridge.js 以及其餘基於 React 庫實現的 DOM 構造、Diff 操做、生命週期 Mock 接口等相關代碼,而接受渲染事件通訊的入口文件在 page 目錄下,該入口文件接受 node 操做事件後再結合 WorkerDomNodeImpl.js 中的接口代碼實現了 DOM 在主線程的實際渲染更新。

簡單作下總結。基於 React 技術棧,經過在 Web Worker 下實現 Diff 與渲染階段的進行分離,能夠作到必定程度的 DOM 沙箱,但這不是咱們想要的微前端架構下的 JavaScript 沙箱。先不談拆分 Diff 階段與渲染階段的成本與收益比,首先,基於技術棧框架的特殊性所作的這諸多努力,會隨着這個框架自己版本的升級存在着維護升級難以掌控的問題;其次,假如各個子應用使用的技術棧框架不一樣,要爲這些不一樣的框架分別封裝適配的接口,擴展性和普適性弱;最後,最爲重要的一點,這種方法暫時仍是沒有解決 window 下資源共享的問題,或者說,只是啓動了解決這個問題的第一步。

接下來,咱們先繼續探討 Worker 下實現 DOM 操做的另一種方案。window 下資源共享的問題咱們放在其後再做討論。

AMP WorkerDOM

在我開始糾結於如 react-worker-dom 這種思路實際落地開發的諸多「天塹」問題的同時,瀏覽過其餘 DOM 框架由於一樣具有插件機制偶然迸進了個人腦海,它是 Google 的 AMP

AMP 開源項目 中除了如 amphtml 這種通用的 Web 組件框架,還有不少其餘工程採用了 Shadow DOM、Web Component 等新技術,在項目下簡單刷了一眼後,我欣喜地看到了工程 worker-dom

粗略翻看下 worker-dom 源碼,咱們在 src 根目錄下能夠看到 main-threadworker-thread 兩個目錄,分別打開看了下後,能夠發現其實現拆分 DOM 相關邏輯和 DOM 渲染的思路和上面的 react-worker-dom 基本相似,但 worker-dom 由於和上層框架無關,其下的實現更爲貼近 DOM 底層。

先看 worker-thread DOM 邏輯層的相關代碼,能夠看到其下的 dom 目錄 下實現了基於 DOM 標準的全部相關的節點元素、屬性接口、document 對象等代碼,上一層目錄中也實現了 Canvas、CSS、事件、Storage 等全局屬性和方法。

接着看 main-thread,其關鍵功能一方面是提供加載 worker 文件從主線程渲染頁面的接口,另外一方面能夠從 worker.tsnodes.ts 兩個文件的代碼來理解。

worker.ts 中像我最初所設想的那樣包裹了一層代碼,用於自動生成 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 中,實現了真實元素節點的構造和存儲(基於存儲數據結構是否以及如何在渲染階段有優化還需進一步研究源碼)。

同時,在 transfer 目錄下的源碼,定義了邏輯層和 UI 渲染層的消息通訊的規範。

總的來看,AMP WorkerDOM 的方案拋棄了上層框架的約束,經過從底層構造了 DOM 全部相關 API 的方式,真正作到了與框架技術棧無關。它一方面徹底能夠做爲上層框架的底層實現,來支持各類上層框架的二次封裝遷移(如工程 amp-react-prototype),另外一方面結合了當前主流 JavaScript 沙箱方案,經過模擬 window、document 全局方法的並代理到主線程的方式實現了部分的 JavaScript 沙箱隔離(暫時沒看到路由隔離的相關代碼實現)。

固然,從我我的角度來看,AMP WorkerDOM 也有其當前在落地上必定的侷限性。一個是對當前主流上層框架如 Vue、React 等的遷移成本及社區生態的適配成本,另外一個是其在單頁應用下的還沒有看到有相關實現方案,在大型 PC 微前端應用的支持上還沒法找到更優方案。

其實,在瞭解完 AMP WorkerDOM 的實現方案以後,基於 react-worker-dom 思路的後續方案也能夠有個大概方向了:渲染通訊的後續過程,可考慮結合 Browser VM 的相關實現,在生成 Worker 對象的同時,也生成一個 iframe 對象,而後將 DOM 下的操做都經過 postMessage 發送到主線程後,以與其綁定的 iframe 兌現來執行,同時,經過代理將具體的渲染實現再轉發給原 WorkerDomNodeImpl.js 邏輯來實現 DOM 的實際更新。

小結與一些我的前瞻

首先聊一聊我的的一些總結。Web Worker 下實現微前端架構下的 JavaScript 沙箱最初是出於一點我的靈光的閃現,在深刻調研後,雖然最終仍是由於這樣那樣的問題致使在方案落地上沒法找到最優解從而放棄採用社區通用方案,但仍不妨礙我我的對 Web Worker 技術在實現插件類沙箱應用上的持續看好。插件機制在前端領域一直是津津樂道的一種設計,從 Webpack 編譯工具到 IDE 開發工具,從 Web 應用級的實體插件到應用架構設計中插件擴展設計,結合 WebAssembly 技術,Web Worker 無疑將在插件設計上佔據舉足輕重的地位。

其次是一些我的的一些前瞻思考。其實從 Web Worker 實現 DOM 渲染的調研過程當中能夠看到,基於邏輯與 UI 分離的思路,前端後續的架構設計有很大機會可以產生必定的變革。目前不論是盛行的 Vue 仍是 React 框架,其框架設計不管是 MVVM 仍是結合 Redux 以後的 Flux,其本質上仍舊仍是由 View 層驅動的框架設計(我的淺見),其具有靈活性的同時也產生着性能優化、大規模項目層級升上後的協做開發困難等問題,而基於 Web Worker 的邏輯與 UI 分離,將促使數據獲取、處理、消費整個流程的進一步的業務分層,從而固化出一整套的 MVX 設計思路。

固然,以上這些我我的還處於初步調研的階段,不成熟之處還需多加琢磨。且聽之,後續再實踐之。

做者:ES2049 / 靳志凱
文章可隨意轉載,但請保留此原文連接。
很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj@alibaba-inc.com
相關文章
相關標籤/搜索