天天看點

JS 沙箱隔離

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

自從 2014 年 HTML5 正式推薦标準釋出以來,HTML5 增加了越來越多強大的特性和功能,而在這其中,工作線程(Web Worker)概念的推出讓人眼前一亮,但未曾随之激起多大的浪花,并被在其随後工程側的 Angular、Vue、React 等架構的「革命」浪潮所淹沒。當然,我們總會偶然看過一些文章介紹,或出于學習的目的做過一些應用場景下的練習,甚或在實際項目中的涉及大量資料計算場景中真的使用過。但相信也有很多人和我一樣茫然,找不到這種高大上的技術在實際項目場景中能有哪些能起到廣泛作用的應用。

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

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

本文接下來的内容,将介紹我在探索基于 Web Worker 實作 JavaScript 沙箱隔離方案過程中的一些資料收集、了解以及我的踩坑和思考的過程。雖然可能整篇文章内容都在「炒冷飯」,但還是希望我的探索方案的過程能對正在看這篇文章的你有所幫助。

JavaScript 沙箱

在探索基于 Web Worker 的解決方案之前,我們先要對目前要解決的問題——JavaScript 沙箱有所了解。

提到沙箱,我會先想到出于興趣玩過的沙盒遊戲,但我們要探索的 JavaScript 沙箱不同于沙盒遊戲,沙盒遊戲注重對世界基本元素的抽象、組合以及實體力系統的實作等,而 JavaScript 沙箱則更注重在使用共享資料時對操作狀态的隔離。

在現實與 JavaScript 相關的場景中,我們知道平時使用的浏覽器就是一個沙箱,運作在浏覽器中的 JavaScript 代碼無法直接通路檔案系統、顯示器或其他任何硬體。Chrome 浏覽器中每個标簽頁也是一個沙箱,各個标簽頁内的資料無法直接互相影響,接口都在獨立的上下文中運作。而在同一個浏覽器标簽頁下運作 HTML 頁面,有哪些更細節的、對沙箱現象有需求的場景呢?

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

  1. 執行從不受信的源擷取到的第三方 JavaScript 代碼時(比如引入插件、處理 jsonp 請求回來的資料等)。
  2. 線上代碼編輯器場景(比如著名的 codesandbox)。
  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 與主線程進行通信後放在主線程進行呢?簡單一搜,頗為汗顔,已經有大佬在 5、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-thread 和 worker-thread 兩個目錄,分别打開看了下後,可以發現其實作拆分 DOM 相關邏輯和 DOM 渲染的思路和上面的 react-worker-dom 基本類似,但 worker-dom 因為和上層架構無關,其下的實作更為貼近 DOM 底層。

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

接着看 main-thread,其關鍵功能一方面是提供加載 worker 檔案從主線程渲染頁面的接口,另一方面可以從 worker.ts 和 nodes.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 設計思路。

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

歡迎關注前端早茶,與廣東靓仔攜手共同進階

​​​​

前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~

公衆号作者:廣東靓仔

繼續閱讀