从模型的角度理解微前端,不失为一种简洁的方式。据我了解,微前端架构总体上分为两种
- 中心化Shell架构
- 分布式应用架构
中心化与分布式架构的区别
两者的区别,简单来说,就是主应用在UI层是否只提供Shell壳(页面头尾,导航等基础,没有主内容区)。顾名思义,中心化Shell架构只提供Shell壳,而分布式应用架构的主应用本身就是普通应用,与子应用没有差别,是对等的。
在项目前期规划时采用微前端的场景,多使用中心化Shell架构。分布式应用架构常见于,后期现成系统间微前端的集成。
下面的篇幅,我们一块来看下中心化Shell架构。
中心化Shell架构
基于自己的理解,构建了一张简单的中心化Shell架构的思维模型图,如下:
图上,有一个主应用APP Shell,三个子应用APP 1-3,他们被集成到主应用中。
主应用的构成
主应用分成两个部分:
- Shell壳
- 微前端工具集(图上Micro front-end toolset虚线框部分)
Shell壳
Shell壳,是一个基础layout布局,包括页面头/尾,导航,页面的主内容区域为空,比如下图,就是一个典型的Shell壳。内容区域一般会被集成进主应用的子应用填充。
微前端工具集
微前端工具集由微前端框架负责提供,通常包括加载模块、沙箱创建模块、路由模块、以及父子应用通信模块。社区常见的微前端框架,比如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)中间
如上图,沙箱层代理了主应用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 function和with语法的资料,他们能够帮助你更好地理解上述代码。
注入模块到子应用
既然我们为每个子应用创建了proxyWindow对象,它被作为代理环境传入子应用代码,我们自然可以通过它注入模块到子应用。这些模块包括
- 解决父子应用通信的communication模块,通常是察者模式的一个实现
- 解决子应用路由状态持有,以及父子路由跳转的router模块
- 微前端框架也会提供一个props参数,供开发者自定义需要传入子应用的值或功能实例,比如用户授权
运行子应用
可以把运行子应用,简单看做执行子应用JS。如下代码所示,把proxyWindow代理环境作为参数,执行上面步骤的childAppCodeWithInSandbox函数。其中的子应用代码对window,location, document的访问都会被相应的代理对象拦截,这样子应用运行在自己的沙箱中。
childAppCodeWithInSandbox(proxyWindow);
总结
我们简单解释了中心化Shell架构模型,社区的微前端框架基本是对这个模型的不同实现。当然为了简单说明,我们省去了很多不必要的部分。比如子应用生命周期管理,其中有意思的是,它在DOM原型层面对所有事件绑定的追踪,以及子应用销毁时自动解绑事件,有兴趣的可以学习下micro-app。下期,我们一起看下分布式应用架构,欢迎阅读!