雲計算

阿里雲開放平臺微前端方案的沙箱實現

作者 | 流司

image.png

背景

應用沙箱可能是微前端技術體系裡面最有意思的部分。一般來說沙箱是微前端技術體系中不是必須要做的事情,因為如果規範做的足夠好,是能夠避免掉一些變量衝突讀寫,CSS 樣式衝突的情況。但是如果你在一個足夠大的體系中,總不能僅僅通過規範來保證應用的可靠性,還是需要技術手段去治理運行時的一些衝突問題,這個也是沙箱方案成為微前端技術體系的一部分原因。

首先縱觀各類技術方案,有一個大前提決定了這個沙箱如何做:最終微應用是 單實例 or 多實例 存在宿主應用中。這個直接決定了這個沙箱的複雜度和技術方案。

  • 單實例:同一個時刻只有一個微應用實例存在,此刻瀏覽器所有瀏覽器資源都是這個應用獨佔的,方案要解決的很大程度是應用切換的時候的清理和現場恢復。比較輕量,實現起來也簡單。
  • 多實例:資源不是應用獨佔,就要解決資源共享的情況,比如路由,樣式,全局變量讀寫,DOM. 可能需要考慮的情況比較多,實現較為複雜。

最開始我們的想法是:

從業務場景:我們可能存在的情況是當用戶操作一個產品 A 的同時和另一個產品 B 發生了關聯操作,需要喚醒應用 B 做操作。雖然從產品維度可以規避掉,比如先切到 B, 然後切回 A, 但是從某種程度上因為技術的原因,我們限制了產品交互的發揮。

從技術角度:解決了多實例當然單實例的場景也不在話下,並且單實例的方案某種程度上給編碼上帶來了一定複雜度,比如業務代碼需要自己做業務上下文的切換。

最近 qiankun 2 也轉變了思路從單實例的支持到開始支持多實例,多多少少也側面說明了,多實例是個一個值得投入和技術攻克的場景。

基於上面的考量,我們就開始了我們 Browser VM 沙箱的實現探索。總結起來可以用下圖表示:

image.png

JavaScript 沙箱實現

沙箱環境構造

要實現沙箱,我們需要隔離掉瀏覽器的原生對象,但是如何隔離,建立一個沙箱環境呢?Node 中 有 vm 模塊,來實現類似的能力,但是瀏覽器就不行了,但是我們可以利用了閉包的能力,利用變量作用域去模擬一個沙箱環境,比如下面的代碼:


function foo(window) {
  console.log(window.document);
}

foo({
  document: {};
});

比如這段代碼的輸出一定是 {}. 而不是原生瀏覽器的 document.

所以 ConsoleOS 實現了一個 wepback 的插件在應用代碼構建的時候給子應用代碼加上一層 wrap 代碼,創建一個閉包,把需要隔離的瀏覽器原生對象變成從下面函數閉包中獲取的,從而我們可以在應用加載的時候,傳入模擬掉的 window,document 之類的對象。


// 打包代碼
__CONSOLE_OS_GLOBAL_HOOK__(id, function (require, module, exports, {window, document, location, history}) {
  /* 打包代碼 */
})
function __CONSOLE_OS_GLOBAL_HOOK__(id, entry) {
  entry(require, module, exports, {window, document, location, history})
}

當然也可以不靠工程化的手段來實現,也可以通過請求腳本,然後在運行時拼接這段代碼,然後eval 或者 new Function, 來達到相同的目的。

原生對象模擬

沙箱隔離能力有了,剩下的問題就是如何實現這一堆瀏覽器的原生對象了。最開始的想法是我們根據 ECMA 的規範實現(現在仍然有類似的想法),但是發現成本太高。不過在我們各種實驗之後,發現了一個很“取巧”的做法,我們可以 new iframe 對象,把裡面的原生瀏覽器對象通過 contentWindow 取出來,應為這些對象天然隔離,就省去了自己實現的成本。

const iframe = document.createElement( 'iframe' );

當然裡面有很多的細節需要考量,比如:

只有同域的 iframe 才能取出對應的的 contentWindow. 所以需要提供一個宿主應用空的同域URL來作為這個 iframe 初始加載的 URL. 當然根據 HTML 的規範 這個 URL 用了 about:blank 一定保證保證同域,也不會發生資源加載,但是會發生關聯的 和 這個iframe 中關聯的 history 不能被操作,這個時候路由的變換隻能變成 hash 模式。

如下圖所示,我們取出對應的 iframe 中原生的對象之後,就會對特定需要隔離的對象生成對應的 Proxy, 然後對一些屬性獲取和屬性設置,坐上一些特定的設置,比如 window.document 需要返回特定的沙箱 document 而不是當前瀏覽器的document.


class Window {
  constructor(options, context, frame) {
    return new Proxy(frame.contentWindow, {
      set(target, name, value) {
        target[name] = value;
        return true;
      },

      get(target, name) {
        switch( name ) {
          case 'document':
            return context.document;
          default:
        }

        if( typeof target[ name ] === 'function' && /^[a-z]/.test( name ) ){
          return target[ name ].bind && target[ name ].bind( target );
        }else{
          return target[ name ];
        }
      }
    });
  }
}

對於每一個對象的實現這裡不講細節了,有興趣可以看看我們的開源之後的代碼

但是為了文檔能夠被加載在同一個 DOM 樹上,對於 document, 大部分的 DOM 操作的屬性和方法還是直接用的宿主瀏覽器中的 document 的屬性和方法。

由於子應用有自己的沙箱環境,之前所有獨佔式的資源現在都變成了應用獨享(尤其是 location, history),所以子應用也能同時被加載. 並且對於一些變量的我們還能在 proxy 中設置一些訪問權限的事情,從而限制子應用的能力,比如 Cookie, LocalStoage 讀寫。

當這個 iframe 被移除時,寫在 window 的變量和設置的一些 timeout 時間也會一併被移除。(當然 DOM 事件需要沙箱記錄,然後在宿主中移除)。

總結一下,我們的沙箱可以做到如下的特性:

image.png

CSS 隔離

CSS 隔離方案相對來說比較常規,常見的有:

  • CSS Module
  • 添加 css 的 namespace
  • Dynamic StyleSheet
  • Shadow DOM

CSS Module or CSS Namespace: 通過修改基礎組件樣式前綴來實現框架和微應用依賴基礎組件樣式的隔離性(依賴於工程上 CSS 的預處理器編譯和運行時基礎組件庫配置),同時避免全局樣式的書寫(依賴於約定或工程 lint 手段)

Dynamic StyleSheet: 隔離方式是通過js運行時動態加載卸載微應用樣式表來避免樣式的衝突,侷限性一是在於站點框架本身或其部件(header/menu/footer)與當前運行的微應用間仍存在樣式衝突的可能性,二是沒有辦法支持多個微應用同時運行顯示的情況

Shadow DOM: 優點是瀏覽器級別提供的樣式隔離能力,可以做到完全隔離。缺點在於,目前兼容性還是夠不太好,並且改造會涉及到舊應用的業務代碼的改造,對子應用侵入性比較高。

最終經過實踐,我們選擇的方式是 CSS Module + 添加 CSS 的 namespace。CSS module 保證的是應用業務樣式不衝突,Namespace 保證公共庫不衝突。我們實現了一個 postcss 插件,會在應用構建的時候給所有的樣式都加上應用前綴包括應用公共庫的 CSS。(這樣方便做到同一個 組件庫 新舊版本樣式的兼容)。如下圖所示:


// 宿主 host app
.next-btn {
  color: #eee;
}

// 子應用 sub app
aliyun-slb .next-btn {
  color: #eee;
}

//宿主中生成的節點
<aliyun-slb>
  <!-- 子應用的節點 -->
</aliyun-slb>

這樣實現的好處在於:

  • 每個應用都有 namespace, 可以多實例共存。
  • 不依賴特定的 CSS 預處理器。
  • 對於同一個庫不同版本的 CSS(如 fusion1 和 fusion2), 可以做到徹底隔離。
  • 鑑於上面 JS 沙箱的存在,對於一些彈窗類的組件,這個微應用獲取的 body 實際上是宿主生成的節點,所以彈窗會被添加到微應用的節點(也就是上面的 aliyun-slb)這個節點,樣式不會失效。

不過也會有一些問題,比如:

  • 嵌套應用組件樣式優先級的問題。(由於 CSS module 的存在,一般只會發生在公共 css 樣式中,這個就是隻能儘量避免嵌套)。
  • fusion 不同版本庫公用字體的問題。(目前解法比較hack, 工程化手段替換掉了 next 字體的名字)。

如何和其他體系結合

一般來說,所以如果看完上面的文章,覺得這個沙箱方案不錯,但是又有自己的微前端體系了,想套用咋辦?

目前 ConsoleOS 的代碼已經在 Github 上開源,這裡不妨可以嘗試試用一下。

JS 沙箱部分

如果看懂了上面關於原理的介紹可以看到其實沙箱實現包括兩個層面

  • 原生瀏覽器對象的模擬(Browser-VM)的部分
  • 如何構建一個閉包環境的部分。

Browser-VM 可以直接用起來,這部分完全是通用普適的。但是涉及到閉包構建的這部分,每個微前端體系不太一致,可能需要改造比如:


import { createContext, removeContext } from '@alicloud/console-os-browser-vm';

const context = await createContext();

const run = window.eval(`
  (() => function({window, history, locaiton, document}) {
    window.test = 1;
  })()
`)
run(context);

console.log(context.window.test);
console.log(window.test);

// 操作虛擬化瀏覽器對象
context.history.pushState(null, null, '/test');
context.locaiton.hash = 'foo'

// 銷燬一個 context
await removeContext( context );

當然可以直接選擇沙箱提供好的 evalScripts 方法:

import { evalScripts } from '@alicloud/console-os-browser-vm';

const context = evalScripts('window.test = 1;')

console.log(window.test === undefined) // true

CSS 沙箱

如果用webpack 構建,可以直接配置如下:


const postcssWrap = require('@alicloud/console-toolkit-plugin-os/lib/postcssWrap')

// 下面是 webpack config
{
  test: /\.css$/,
  use: [
    'style-loader',
    {
      loader: 'postcss-loader',
      options: {
        plugins: [
          // 加入插件
          postcssWrap({
            stackableRoot: '.prefix',
            repeat: 1
          })
        ],
      },
    },
    'css-loader',
  ],
  exclude: /^node_modules$/,
}

Try Live Demo

與其乾巴巴的看文章,直接試試在線 Demo 可能更有體感一些:

http://github.com/aliyun/alibabacloud-console-os#try-live-demo

或者試試生產環境的例子, 阿里雲企業工作臺 - 工具應用中心 , 是阿里雲集成三方應用提供雲管能力。這裡每個應用都是一個微應用,裡面涵蓋了 React, Vue, Angular 三大技術棧。

image.png


image.png
關注「Alibaba F2E」
把握阿里巴巴前端新動向

Leave a Reply

Your email address will not be published. Required fields are marked *