微前端中心化Shell架构模型

159 阅读8分钟

从模型的角度理解微前端,不失为一种简洁的方式。据我了解,微前端架构总体上分为两种

  • 中心化Shell架构
  • 分布式应用架构

中心化与分布式架构的区别

两者的区别,简单来说,就是主应用在UI层是否只提供Shell壳(页面头尾,导航等基础,没有主内容区)。顾名思义,中心化Shell架构只提供Shell壳,而分布式应用架构的主应用本身就是普通应用,与子应用没有差别,是对等的。

在项目前期规划时采用微前端的场景,多使用中心化Shell架构。分布式应用架构常见于,后期现成系统间微前端的集成。

下面的篇幅,我们一块来看下中心化Shell架构。

中心化Shell架构

基于自己的理解,构建了一张简单的中心化Shell架构的思维模型图,如下:

MFE-centerial-diagram.png

图上,有一个主应用APP Shell,三个子应用APP 1-3,他们被集成到主应用中。

主应用的构成

主应用分成两个部分:

  • Shell壳
  • 微前端工具集(图上Micro front-end toolset虚线框部分)

Shell壳

Shell壳,是一个基础layout布局,包括页面头/尾,导航,页面的主内容区域为空,比如下图,就是一个典型的Shell壳。内容区域一般会被集成进主应用的子应用填充。

MFE-centerial-diagram-shell.png

微前端工具集

微前端工具集由微前端框架负责提供,通常包括加载模块、沙箱创建模块、路由模块、以及父子应用通信模块。社区常见的微前端框架,比如Micro-app, Wujie等都提供了这些模块。他们运行在APP Shell主应用中,为集成子应用提供方便的工具。

下面,我们结合一个子应用集成进APP shell的过程,来看下这些模块分别起什么作用。

子应用如何集成到APP Shell

APP shell集成一个远端子应用的过程,需要经过以下几个步骤

  • 加载子应用
  • 创建沙箱
  • 注入模块到子应用
  • 运行子应用

加载子应用

第一步,得把子应用的资源从远端加载过来,这些资源包括html页面,JS脚本,CSS样式

加载模块(来自微前端工具集)使用fetch方法,访问子应用URL,加载它的html页面。进一步解析它的<script>,<link>,<style>标签,得到JS脚本与CSS样式URL,同样fetch加载他们。

  • 加载的html,在运行子应用阶段,作为template模版渲染到指定的元素下,这个元素一般叫作container。

  • JS脚本会被后面创建的沙箱包裹,构建出一个模拟的窗口环境,避免主子应用相互影响。

  • CSS样式需要做隔离,防止全局样式污染。

    常见的样式隔离有两种方式,一种是解析CSS,在每条CSS规则的选择器前,添加唯一的class类名前缀;另一种是使用Shadow DOM,利用DOM本身的隔离机制来隔离全局样式。

创建沙箱

谈起微前端,就会提到沙箱。那沙箱到底又是什么呢?相信你也会有这样的疑问。

沙箱是什么?

简单地说,沙箱就是一个模拟的窗口环境,沙箱之间相互独立且隔离。使得运行在里面的子应用无法感知主应用和其他子应用,从而减少相互影响。

深入一点讲,沙箱本质上是在运行时JS上下文,创建的一个新层。它位于子应用JS,与主应用App Shell窗口环境(包括window,document,location,history)中间

MFE-centerial-diagram-sandbox.png

如上图,沙箱层代理了主应用App Shell的窗口环境。具体地说,为每个子应用创建主应用环境对象(window,document,location,history)的代理对象,这样每个子应用都会有他们自己的window,document,location,history。

当子应用访问对象上的属性时,会优先到达沙箱层,由沙箱层决定是从代理对象上获取值,还是继续访问下一层的主应用环境。

沙箱怎么创建?

据我了解,沙箱的创建一般需要两个步骤

  • 创建一个代理环境
  • 包裹子应用代码到代理环境中

相信大家都了解,沙箱的创建通常有两种方式,一个是iframe,另一个是Pure JS proxy

众所周知,每个iframe有它自己的窗口环境, 等于说它具备自己的window,location等对象,所以省去了格外创建的成本。

这里,我们简单看下Pure JS proxy创建沙箱的过程。

创建一个代理环境

我们首先为主应用的location,document对象创建代理,例如

const rawLocation = window.location;
const rawDocument = window.document;

// Proxy for location
const proxyLocation = new Proxy({}, {
  get(_, prop) {
    return rawLocation[prop];
  },
  set(_, prop, value) {
    rawLocation[prop] = value;
    return true;
  }
});

// Proxy for document
const proxyDocument = new Proxy({}, {
  get(_, prop) {
    return rawDocument[prop];
  },
  set(_, prop, value) {
    rawDocument[prop] = value;
    return true;
  }
});

以上只是简单的例子,你可以根据实际情况在代理的方法中,写上自己的逻辑。

然后,我们再创建一个简单的window代理对象

const rawWindow = window;
// Define which properties should be faked (i.e., local to sandbox)
const fakeProps = new Set(['customVar', 'myAPI', 'sandboxOnly']);

const fakeWindow = {};

// Proxy for window
const proxyWindow = new Proxy(fakeWindow, {
  get(target, prop) {
    if (fakeProps.has(prop)) {
      return Reflect.get(target, prop);
    }

    if (prop === 'window') return proxyWindow;
    if (prop === 'location') return proxyLocation;
    if (prop === 'document') return proxyDocument;

    return rawWindow[prop];
  },
  set(target, prop, value) {
    if (fakeProps.has(prop)) {
      return Reflect.set(target, prop, value);
    }

    rawWindow[prop] = value;
    return true;
  },
  has(target, prop) {
    return prop in target || prop in rawWindow;
  }
});

第1行代码,我们保存原始window对象到rawWindow变量;

第3行代码,定义哪些属性需要保存到fakeWindow对象中(即第5行代码创建的fakeWindow,每个子应用都会有一个fakeWindow作为它的window对象);

第8行到结束,我们代理了这个fakeWindow对象,拦截它的get/set方法,对存在于fakeProps Set中的属性,设置值到fakeWindow对象,或者从那获取该属性值。

这里,你或许有疑问,这个fakeWindow对象到底起什么作用?

fakeWindow其实就对应于上图中的Sandbox layer层,对window属性的访问,由于代理对象的作用,都会优先经过这个fakeWindow对象(它就是子应用的window对象)

注意,其中的第14-16行,即

if (prop === 'window') return proxyWindow; 
if (prop === 'location') return proxyLocation; 
if (prop === 'document') return proxyDocument;

这里是为了,当下一步的子应用JS访问window,location, document时,能够分别指向proxyWindow、proxyLocation与proxyDocument代理对象。同时,proxyWindow持有了所有代理对象的引用,因此它可以代表整个代理环境,在下一步的childAppCodeWithInSandbox函数中,我们只需传入proxyWindow对象。

包裹子应用代码到代理环境中

在加载阶段,我们获取子应用JS脚本。这里,在JS脚本作用域链的最近的地方,加入上面创建的代理环境,这样脚本中对window, document等对象的访问,都会进入代理对象。我们可以使用new function + with语法来做到这一点,如下

// JS code from the child app.
const childAppCode = `  
  customVar = 123;                      // goes to sandbox
  console.log('customVar:', customVar); // from sandbox

  console.log('location.href:', location.href); 
  console.log('document.title:', document.title);

  console.log('Math.random:', Math.random()); // from real window

  myAPI = () => 'mocked';              // in sandbox only
  console.log('myAPI:', myAPI());      // uses sandbox value
`;

// wrap the code of child app into the sandbox
const childAppCodeWithInSandbox = new Function("proxyWindow", `
  with (proxyWindow) {
    "use strict";
    ${userCode}
  }
`);

如果你有疑惑,可以查阅下new functionwith语法的资料,他们能够帮助你更好地理解上述代码。

注入模块到子应用

既然我们为每个子应用创建了proxyWindow对象,它被作为代理环境传入子应用代码,我们自然可以通过它注入模块到子应用。这些模块包括

  • 解决父子应用通信的communication模块,通常是察者模式的一个实现
  • 解决子应用路由状态持有,以及父子路由跳转的router模块
  • 微前端框架也会提供一个props参数,供开发者自定义需要传入子应用的值或功能实例,比如用户授权

运行子应用

可以把运行子应用,简单看做执行子应用JS。如下代码所示,把proxyWindow代理环境作为参数,执行上面步骤的childAppCodeWithInSandbox函数。其中的子应用代码对window,location, document的访问都会被相应的代理对象拦截,这样子应用运行在自己的沙箱中。

childAppCodeWithInSandbox(proxyWindow);

总结

我们简单解释了中心化Shell架构模型,社区的微前端框架基本是对这个模型的不同实现。当然为了简单说明,我们省去了很多不必要的部分。比如子应用生命周期管理,其中有意思的是,它在DOM原型层面对所有事件绑定的追踪,以及子应用销毁时自动解绑事件,有兴趣的可以学习下micro-app。下期,我们一起看下分布式应用架构,欢迎阅读!