如何“取巧”实现一个微前端沙箱?

简介: 现在微前端已经成为前端领域比较火爆的话题,在技术方面,微前端有一个始终绕不过去的话题就是前端沙箱。本文将分享阿里云开放平台微前端方案的沙箱实现原理,具体探讨在微前端领域如何实现前端沙箱。(文末免费下载《2020 前端工程师必读手册》电子书)
css

image.png

背景

应用沙箱多是微前端技术体系里面最有意思的部分。通常来讲沙箱是微前端技术体系中不是必需要作的事情,由于若是规范作的足够好,是可以避免掉一些变量冲突读写,CSS 样式冲突的状况。可是若是你在一个足够大的体系中,总不能仅仅经过规范来保证应用的可靠性,仍是须要技术手段去治理运行时的一些冲突问题,这个也是沙箱方案成为微前端技术体系的一部分缘由。前端

首先纵观各种技术方案,有一个大前提决定了这个沙箱如何作:最终微应用是单实例 or 多实例存在宿主应用中。这个直接决定了这个沙箱的复杂度和技术方案。node

  • 单实例:同一个时刻只有一个微应用实例存在,此刻浏览器全部浏览器资源都是这个应用独占的,方案要解决的很大程度是应用切换的时候的清理和现场恢复。比较轻量,实现起来也简单。
  • 多实例:资源不是应用独占,就要解决资源共享的状况,好比路由,样式,全局变量读写,DOM。可能须要考虑的状况比较多,实现较为复杂。

最开始咱们的想法是:webpack

从业务场景:咱们可能存在的状况是当用户操做一个产品 A 的同时和另外一个产品 B 发生了关联操做,须要唤醒应用 B 作操做。虽然从产品维度能够规避掉,好比先切到 B,而后切回 A,可是从某种程度上由于技术的缘由,咱们限制了产品交互的发挥。git

从技术角度:解决了多实例固然单实例的场景也不在话下,而且单实例的方案某种程度上给编码上带来了必定复杂度,好比业务代码须要本身作业务上下文的切换。github

最近 qiankun 2 也转变了思路,从单实例的支持到开始支持多实例,多多少少也侧面说明了,多实例是一个值得投入和技术攻克的场景。web

基于上面的考量,咱们就开始了咱们 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 ];
        }
      }
    });
  }
}

对于每个对象的实现这里不讲细节了,有兴趣能够看看咱们的开源以后的代码 :
https://github.com/aliyun/alibabacloud-console-os/tree/master/packages/browser-vm

可是为了文档可以被加载在同一个 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 上开源:
http://github.com/aliyun/alibabacloud-console-os,这里不妨能够尝试试用一下。

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

或者试试生产环境的例子, 阿里云企业工做台 - 工具应用中心 (http://work.console.aliyun.com/), 为阿里云集成三方应用提供云管能力。这里每一个应用都是一个微应用,里面涵盖了 React、 Vue、Angular 三大技术栈。

image.png