如何“取巧”實(shí)現(xiàn)一個(gè)微前端沙箱?
如今微前端已經(jīng)成為前端領(lǐng)域比較火爆的話題,在技術(shù)方面,微前端有一個(gè)始終繞不過去的話題就是前端沙箱。本文將分享阿里云開放平臺(tái)微前端方案的沙箱實(shí)現(xiàn)原理,具體探討在微前端領(lǐng)域如何實(shí)現(xiàn)前端沙箱。
背景
應(yīng)用沙箱可能是微前端技術(shù)體系里面最有意思的部分。一般來說沙箱是微前端技術(shù)體系中不是必須要做的事情,因?yàn)槿绻?guī)范做的足夠好,是能夠避免掉一些變量沖突讀寫,CSS 樣式?jīng)_突的情況。但是如果你在一個(gè)足夠大的體系中,總不能僅僅通過規(guī)范來保證應(yīng)用的可靠性,還是需要技術(shù)手段去治理運(yùn)行時(shí)的一些沖突問題,這個(gè)也是沙箱方案成為微前端技術(shù)體系的一部分原因。
首先縱觀各類技術(shù)方案,有一個(gè)大前提決定了這個(gè)沙箱如何做:最終微應(yīng)用是單實(shí)例 or 多實(shí)例存在宿主應(yīng)用中。這個(gè)直接決定了這個(gè)沙箱的復(fù)雜度和技術(shù)方案。
- 單實(shí)例:同一個(gè)時(shí)刻只有一個(gè)微應(yīng)用實(shí)例存在,此刻瀏覽器所有瀏覽器資源都是這個(gè)應(yīng)用獨(dú)占的,方案要解決的很大程度是應(yīng)用切換的時(shí)候的清理和現(xiàn)場(chǎng)恢復(fù)。比較輕量,實(shí)現(xiàn)起來也簡(jiǎn)單。
- 多實(shí)例:資源不是應(yīng)用獨(dú)占,就要解決資源共享的情況,比如路由,樣式,全局變量讀寫,DOM??赡苄枰紤]的情況比較多,實(shí)現(xiàn)較為復(fù)雜。
最開始我們的想法是:
從業(yè)務(wù)場(chǎng)景:我們可能存在的情況是當(dāng)用戶操作一個(gè)產(chǎn)品 A 的同時(shí)和另一個(gè)產(chǎn)品 B 發(fā)生了關(guān)聯(lián)操作,需要喚醒應(yīng)用 B 做操作。雖然從產(chǎn)品維度可以規(guī)避掉,比如先切到 B,然后切回 A,但是從某種程度上因?yàn)榧夹g(shù)的原因,我們限制了產(chǎn)品交互的發(fā)揮。
從技術(shù)角度:解決了多實(shí)例當(dāng)然單實(shí)例的場(chǎng)景也不在話下,并且單實(shí)例的方案某種程度上給編碼上帶來了一定復(fù)雜度,比如業(yè)務(wù)代碼需要自己做業(yè)務(wù)上下文的切換。
最近 qiankun 2 也轉(zhuǎn)變了思路,從單實(shí)例的支持到開始支持多實(shí)例,多多少少也側(cè)面說明了,多實(shí)例是一個(gè)值得投入和技術(shù)攻克的場(chǎng)景。
基于上面的考量,我們就開始了我們 Browser VM 沙箱的實(shí)現(xiàn)探索??偨Y(jié)起來可以用下圖表示:
JavaScript 沙箱實(shí)現(xiàn)
沙箱環(huán)境構(gòu)造
要實(shí)現(xiàn)沙箱,我們需要隔離掉瀏覽器的原生對(duì)象,但是如何隔離,建立一個(gè)沙箱環(huán)境呢?Node 中 有 vm 模塊,來實(shí)現(xiàn)類似的能力,但是瀏覽器就不行了,但是我們可以利用閉包的能力、利用變量作用域去模擬一個(gè)沙箱環(huán)境,比如下面的代碼:
- function foo(window) {
- console.log(window.document);
- }
- foo({
- document: {};
- });
比如這段代碼的輸出一定是 {},而不是原生瀏覽器的 document。
所以 ConsoleOS(面向阿里云管體系的微前端方案) 實(shí)現(xiàn)了一個(gè) Wepback 的插件,在應(yīng)用代碼構(gòu)建的時(shí)候給子應(yīng)用代碼加上一層 wrap 代碼,創(chuàng)建一個(gè)閉包,把需要隔離的瀏覽器原生對(duì)象變成從下面函數(shù)閉包中獲取,從而我們可以在應(yīng)用加載的時(shí)候,傳入模擬的 window、document 之類的對(duì)象。
- // 打包代碼
- __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})
- }
當(dāng)然也可以不靠工程化的手段來實(shí)現(xiàn),也可以通過請(qǐng)求腳本,然后在運(yùn)行時(shí)拼接這段代碼,然后eval 或者 new Function 來達(dá)到相同的目的。
原生對(duì)象模擬
沙箱隔離能力有了,剩下的問題就是如何實(shí)現(xiàn)這一堆瀏覽器的原生對(duì)象了。最開始的想法是我們根據(jù) ECMA 的規(guī)范實(shí)現(xiàn)(現(xiàn)在仍然有類似的想法),但是發(fā)現(xiàn)成本太高。不過在我們各種實(shí)驗(yàn)之后,發(fā)現(xiàn)了一個(gè)很“取巧”的做法,我們可以 new iframe 對(duì)象,把里面的原生瀏覽器對(duì)象通過contentWindow 取出來,因?yàn)檫@些對(duì)象天然隔離,就省去了自己實(shí)現(xiàn)的成本。
- const iframe = document.createElement( 'iframe' );
當(dāng)然里面有很多的細(xì)節(jié)需要考量,比如:只有同域的 iframe 才能取出對(duì)應(yīng)的的contentWindow。所以需要提供一個(gè)宿主應(yīng)用空的同域 URL 來作為這個(gè) iframe 初始加載的 URL。當(dāng)然根據(jù) HTML 的規(guī)范,這個(gè) URL 用了 about:blank 一定保證同域,也不會(huì)發(fā)生資源加載,但是會(huì)發(fā)生和這個(gè) iframe 中關(guān)聯(lián)的 history 不能被操作,這個(gè)時(shí)候路由的變換只能變成 hash 模式。
如下圖所示,我們?nèi)〕鰧?duì)應(yīng)的 iframe 中原生的對(duì)象之后,就會(huì)對(duì)特定需要隔離的對(duì)象生成對(duì)應(yīng)的 Proxy, 然后對(duì)一些屬性獲取和屬性設(shè)置,做一些特定的設(shè)置,比如 window.document 需要返回特定的沙箱 document 而不是當(dāng)前瀏覽器的 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 ];
- }
- }
- });
- }
- }
對(duì)于每一個(gè)對(duì)象的實(shí)現(xiàn)這里不講細(xì)節(jié)了,有興趣可以看看我們的開源之后的代碼 :https://github.com/aliyun/alibabacloud-console-os/tree/master/packages/browser-vm
但是為了文檔能夠被加載在同一個(gè) DOM 樹上,對(duì)于 document,大部分的 DOM 操作的屬性和方法還是直接用的宿主瀏覽器中的 document 的屬性和方法。
由于子應(yīng)用有自己的沙箱環(huán)境,之前所有獨(dú)占式的資源現(xiàn)在都變成了應(yīng)用獨(dú)享(尤其是 location、history),所以子應(yīng)用也能同時(shí)被加載。并且對(duì)于一些變量,我們還能在 proxy 中設(shè)置一些訪問權(quán)限的事情,從而限制子應(yīng)用的能力,比如 Cookie, LocalStoage 讀寫。
當(dāng)這個(gè) iframe 被移除時(shí),寫在 window 的變量和設(shè)置的一些 timeout 時(shí)間也會(huì)一并被移除(當(dāng)然 DOM 事件需要沙箱記錄,然后在宿主中移除)。
總結(jié)一下,我們的沙箱可以做到如下的特性:
CSS 隔離
CSS 隔離方案相對(duì)來說比較常規(guī),常見的有:
- CSS Module
- 添加 CSS 的 namespace
- Dynamic StyleSheet
- Shadow DOM
CSS Module or CSS Namespace
通過修改基礎(chǔ)組件樣式前綴來實(shí)現(xiàn)框架和微應(yīng)用依賴基礎(chǔ)組件樣式的隔離性(依賴于工程上 CSS 的預(yù)處理器編譯和運(yùn)行時(shí)基礎(chǔ)組件庫配置),同時(shí)避免全局樣式的書寫(依賴于約定或工程 lint 手段)。
Dynamic StyleSheet
隔離方式是通過 JS 運(yùn)行時(shí)動(dòng)態(tài)加載卸載微應(yīng)用樣式表來避免樣式的沖突,局限性一是對(duì)于站點(diǎn)框架本身或其部件(header/menu/footer)與當(dāng)前運(yùn)行的微應(yīng)用間仍存在樣式?jīng)_突的可能性,二是沒有辦法支持多個(gè)微應(yīng)用同時(shí)運(yùn)行顯示的情況。
Shadow DOM
優(yōu)點(diǎn)是瀏覽器級(jí)別提供的樣式隔離能力,可以做到完全隔離。缺點(diǎn)在于,目前兼容性還是不太好,并且改造會(huì)涉及到舊應(yīng)用的業(yè)務(wù)代碼的改造,對(duì)子應(yīng)用侵入性比較高。
最終經(jīng)過實(shí)踐,我們選擇的方式是 CSS Module + 添加 CSS 的 namespace。CSS module 保證的是應(yīng)用業(yè)務(wù)樣式不沖突,Namespace 保證公共庫不沖突。我們實(shí)現(xiàn)了一個(gè) postcss 插件,會(huì)在應(yīng)用構(gòu)建的時(shí)候給所有的樣式都加上應(yīng)用前綴包括應(yīng)用公共庫的 CSS(這樣方便做到同一個(gè) 組件庫新舊版本樣式的兼容)。如下圖所示:
- // 宿主 host app
- .next-btn {
- color: #eee;
- }
- // 子應(yīng)用 sub app
- aliyun-slb .next-btn {
- color: #eee;
- }
- //宿主中生成的節(jié)點(diǎn)
- <aliyun-slb>
- <!-- 子應(yīng)用的節(jié)點(diǎn) -->
- </aliyun-slb>
這樣實(shí)現(xiàn)的好處在于:
- 每個(gè)應(yīng)用都有 namespace,可以多實(shí)例共存。
- 不依賴特定的 CSS 預(yù)處理器。
- 對(duì)于同一個(gè)庫不同版本的 CSS(如 fusion1 和 fusion2),可以做到徹底隔離。
- 鑒于上面 JS 沙箱的存在,對(duì)于一些彈窗類的組件,這個(gè)微應(yīng)用獲取的 body 實(shí)際上是宿主生成的節(jié)點(diǎn),所以彈窗會(huì)被添加到微應(yīng)用的節(jié)點(diǎn)(也就是上面的 aliyun-slb)這個(gè)節(jié)點(diǎn),樣式不會(huì)失效。
不過也會(huì)有一些問題,比如:
- 嵌套應(yīng)用組件樣式優(yōu)先級(jí)的問題。由于 CSS module 的存在,一般只會(huì)發(fā)生在公共 CSS 樣式中,這個(gè)就是只能盡量避免嵌套。
- fusion 不同版本庫公用字體的問題。目前的解決辦法:比較 hack,使用工程化的手段替換掉 next 字體的名字。
如何和其他體系結(jié)合
如果看完上面的文章,覺得這個(gè)沙箱方案不錯(cuò),但是又已經(jīng)有自己的微前端體系了,想套用咋辦?
目前 ConsoleOS 的代碼已經(jīng)在 Github 上開源:http://github.com/aliyun/alibabacloud-console-os,這里不妨可以嘗試試用一下。
JS 沙箱部分
如果看懂了上面關(guān)于原理的介紹可以看到其實(shí)沙箱實(shí)現(xiàn)包括兩個(gè)層面:
- 原生瀏覽器對(duì)象的模擬(Browser-VM)
- 如何構(gòu)建一個(gè)閉包環(huán)境
Browser-VM 可以直接用起來,這部分完全是通用普適的。但是涉及到閉包構(gòu)建的這部分,每個(gè)微前端體系不太一致,可能需要改造,比如:
- 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);
- // 操作虛擬化瀏覽器對(duì)象
- context.history.pushState(null, null, '/test');
- context.locaiton.hash = 'foo'
- // 銷毀一個(gè) context
- await removeContext( context );
當(dāng)然可以直接選擇沙箱提供好的 evalScripts 方法:
- import { evalScripts } from '@alicloud/console-os-browser-vm';
- const context = evalScripts('window.test = 1;')
- console.log(window.test === undefined) // true
CSS 沙箱
如果用 Webpack 構(gòu)建,可以直接配置如下:
- 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$/,
- }