编辑推荐: |
微前端是将Web应用由单一的单体应用转变为多个小型前端应用聚合为一的一种手段。本文从微前端的基础理论出发,对其核心技术进行阐述,最后结合项目进行简单的应用实践。
本文来自于51CTO ,由火龙果软件Alice编辑推荐。 |
|
一、微前端背景
(一)什么是微前端
微前端提供了一种技术:可以将多个独立的Web应用聚合到一起,提供统一的访问入口。一个微前端应用给用户的感观就是一个完整的应用,但是在技术角度上是由一个个独立的应用组合通过某种方式组合而成的。
为了防止概念有点抽象,可以看一个具体的例子:上图是一个微前端的demo,主应用中有导航栏,footer组件以及左边的侧边栏组件,而右面是子应用部分,这里的子应用并没有集成在主应用中,只是通过微前端的框架内嵌到主应用中,可是给用户的感受就是一个完整的项目。
(二)特点
目前的微前端框架一般都具有以下 三个 特点:
- 技术栈无关:主框架不限制接入应用的技术栈,子应用具备完全自主权。
- 独立性强:独立开发、独立部署,子应用仓库独立。
- 状态隔离:运行时每个子应用之间状态隔离。
(三)为什么出现微前端
2014年:Martin Fowler和James Lewis共同提出了 微服务 的概念。微服务是一种开发软件的架构和组织方法,其中软件由通过明确定义的API进行通信的 小型独立服务 组成。
微服务的主要思路是:
- 将应用 分解 为小的、互相连接的微服务,一个微服务完成某个 特定功能 。
- 每个微服务都有自己的业务逻辑和适配器,不同的微服务,可以使用 不同的技术 去实现。
- 使用 统一的网关 进行调用。
可以看到微服务的主要思路是化繁为简,通过更加细致的划分,使得服务内部更加内聚,服务之间耦合性降低,有利于项目的团队开发和后期维护。把微服务的概念应用到前端, 前端微服务/微前端服务 就诞生了,简称其为微前端。
2018年: 第一个微前端工具single-spa在github上开源。
2019年: 基于single-spa的qiankun问世。
2020年:Module Federation(webpack5)把项目中模块分为本地模块和远程模块,远程模块不属于当前构建,在运行时从所谓的容器加载。加载远程模块是异步操作。当使用远程模块时,这些异步操作将被放置在远程模块和入口之间的下一个chunk的加载操作中,从而实现微前端的构建。
二、微前端的实现方式
(一)服务端集成
微前端的第一种实现思路是服务端集成,即通过 Nginx 配置反向代理来实现不同路径映射到不同应用(如下图所示),这样可以实现项目的独立开发和部署。
但同时这种做法也会丢失SPA的体验,每一次命中路由都会重新请求资源,不能局部更新当前页面。
(二)运行时集成
另一种方法就是运行时集成,这种方法一种实现就是使用 iframe ,通过配置不同的src加载不同的子应用页面。
<iframe src="https://test.qq.com/a/index.html"></iframe> |
iframe 优点 :
- iframe 自带的样式、环境隔离机制使得它具备天然的 沙盒机制 。
- 嵌入 子应用比较 简单 。
iframe 缺点 :
- iframe功能之间的跳转是无效的,刷新页面 无法保存 状态。
- URL的记录完全无效,刷新会返回首页。
- 主应用 劫持 快捷键操作,事件冒泡 不穿透 到主文档树上。
- 模态弹窗的背景是无法覆盖到整个应用。
- iframe应用加载失败,内容发生错误主应用无法感知, 通信麻烦 。
综上,iframe也可以实现微前端,但是需要解决其自身的诸多弊端。公司的 无界微前端 就是基于iframe实现的。
三、现有开源方案
(一)single-spa
single-spa是一个用于 前端微服务化 的JavaScript前端解决方案。single-spa的核心就是定义了一套 协议 。协议包含主应用的配置信息和子应用的生命周期,通过这套协议,主应用可以方便的知道在什么情况下激活哪个子应用。
在single-spa中的配置信息也称为:Root Config,如下就是具体的配置项。需要配置子应用的名称,加载方式以及加载时机。
{
name: "subApp1", //子应用的名称
app: () =>//告诉主应用如何加载子应用的代码,
System.import("/a/b/subAPP/code"),
activeWhen: "/subApp1", //告诉主应用何时激活子应用
} |
single-spa提供registerApplication将子应用的信息注册到主应用中。
singleSpa.registerApplication(
{
name: 'appName',
app: () => System.import('appName'),
activeWhen: '/appName',
}) |
在上面的代码中System.import让人比较在意,这是什么呢 ?
这个问题要从主应用如何加载子应用说起,在single-spa中子应用要实现生命周期函数,然后导出给主应用使用。关键就是这个“导出”的实现,这涉及到 JavaScript 的模块化问题,即需要把子应用打包成一个包含生命周期的模块,让主应用引入。
JavaScript的模块化,如何在页面中引入模块 ?
JavaScript的模块化就是将JavaScript程序拆分为可按需导入的单独模块的机制。Node.js已经提供这个能力很长时间了,还有很多的Javascript库和框架已经开始了模块的使用(例如CommonJS和基于AMD的其他模块系统 如RequireJS,以及最新的Webpack和Babel)。目前最新的浏览器也开始原生支持模块功能。
- 在<script>标签上添加type=“module”来实现导入导出
<!doctype html>
<script type="module">
import {test} from './test.js';
document.body.innerHTML = test('1111');
</script> |
- 实现import axios from ‘axios'还需要借助于importmap
第一点虽然可以实现导入,但是每次Import都要写入固定的地址,或者在不同的script中多次引入时就要重复书写,这样造成代码的冗余,所以这里可以使用importmap,使变量名和其相应的地址一一映射,允许控制js的import语句或import()表达式获取的库的url,并允许在非导入上下文中重用这个映射,这样就不用重复书写地址了。
<script type="importmap">
{
"imports": {
"lodash": "/node_modules/lodash/lodash.js"
}
}
</script>
<script type="module">
import {hello} from 'lodash';
document.body.innerHTML = hello('John');
</script> |
import maps的兼容性如下图所示,所以想在生产环境下使用还是需要一些兼容实现方案,SystemJS就是解决这个问题的。
systemjs是一个模块加载器,和requirejs类似,systemjs参考import maps规范实现了自己的alias(类似requirejs-paths或者webpack alias)。具体用法在下面的demo中。
<script src="https://cdn.bootcss.com/systemjs/6.2.6/system.js"></script>
// 通过systemjs来引入别的文件
System.import('./test.js');
// systemjs也支持通过下面的方式定义资源 ,用来给资源定义一个key
<script type="systemjs-importmap">
{
"imports": {
"vue": "https://cdn.bootcss.com/vue/2.6.11/vue.js"
}
}
</script>
// 直接通过名称引用
System.import('vue');
|
这里 总结 一下single-spa是如何通过以上方法加载子应用的:
在主应用中注册子应用的配置信息,主应用运行时根据配置信息去请求子应用的manifest.json配置文件,这个文件中是子应用打包出的入口js和js文件的依赖关系,主应用通过动态的构造script标签去加载这些js文件,这里就完成了其注册过程。
这样在主应用检测路由命中子应用的规则之后就会触发其渲染函数,把子应用挂载到相应的dom下。
single-spa的另一个关键点就是生命周期,子应用生命周期包含bootstrap,mount,unmount三个回调函数。主应用在管理子应用的时候,通过子应用暴露的生命周期函数来实现子应用的启动和卸载。
- load:当应用匹配路由时就会加载脚本(非函数,只是一种状态)。
- bootstrap:应用内容首次挂载到页面前调用。
- Mount:当主应用判定需要激活这个子应用时会调用, 实现子应用的挂载、页面渲染等逻辑。
- unmount:当主应用判定需要卸载这个子应用时会调用, 实现组件卸载、清理事件监听等逻辑。
- unload:非必要函数,一般不使用。unload之后会重新启动bootstrap流程。
通过以上两点的分析,大致了解了一下sing-spa的主要思想,但是single-spa毕竟是第一个微前端框架,他也有一定的缺点。
- single-spa的文档略显凌乱,概念也比较多,在初次学习时上手难度较高。
- single-spa是通过js文件去加载子应用,当文件名是乱码名时,每次子应用更新,父应用要更新引入配置文件,更新多项目时比较麻烦。
- single-spa本身缺少js隔离和css隔离,虽然现在已经可以引入其他的包去解决,但是并没有做到开箱即用的程度。
所以在基本了解其思路之后,我们可以不妨看一下其他的方案都是如何实现和优化的。
(二)qiankun
qiankun是基于single-spa提出的微前端框架, 提供了更加开箱即用的API(single-spa+sandbox+import-html-entry)。
其主要有六个比较明显的特点,在下文依次展开。
首先是子应用的加载方式与single-spa有明显的不同,single-spa注册子应用本质上是JS Entry,即通过从某一地址引入js文件来加载整个子应用。
singleSpa.registerApplication({
'appName',
() => System.import('appName'),
location => location.pathname.startsWith('appName'),
}); |
但是qiankun注册子应用的方式是通过一个url,即使用HTML Entry的方式来引入子应用。
registerMicroApps([
{
name: 'react app',
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule'
},
]);
start(); |
通过固定的url引入的好处是:子项目大多是已经上线的项目,url是固定的,所以不用频繁更新主应用中的注册信息,便于主应用的整合和开发。
qiankun-子应用的加载
Html Entry方法的主要步骤如下:首先通过url获取到整个Html文件,从html中解析出html,js和css文本,在主应用中创建容器,把html更新到容器中,然后动态创建style和script标签,把子应用的css和js赋值在其中,最后把容器放置在主应用中。
// 解析 HTML,获取 html,js,css 文本
const {htmlText, jsText, cssText} = importHTMLEntry('https://xxxx.com')
// 创建容器
const $= document.querySelector(container)
$container.innerHTML = htmlText
// 创建 style 和 js 标签
const $style = createElement('style', cssText)
const $script = createElement('script', jsText)
$container.appendChild([$style, $script])
|
如何解析html ?
- 通过url请求到子应用的index.html。
- 用正则匹配到其中的js/css相关标签,进行记录,然后删去。
- 删去html/head/body等标签。
- 返回html文本。
如何解析js ?
- 使用正则匹配<script>标签。
- 对于内联js的内容会直接记录到一个对象中。
- 对于外链js会使用fetch请求到内容,然后记录到这个对象中。
- 最后在加载子应用时直接把内容赋值在动态构建的script中。
如何解析css ?
- 正则匹配<style><link>标签。
- 内联css (<style>标签)的内容会直接记录到一个对象中。
- 外链css ( <link>标签)则会使用fetch请到到内容(字符串),然后记录到这个对象中,执行时内容放到<style>标签,然后插入到页面,子项目卸载移除这些<style>标签,这样会把外链的css变成内联css,切换子系统,不用重复请求,直接应用css样式,让子项目加载得更快。
css隔离主要分为两种,一种是父子之间的隔离,另一种是子子之间的隔离。子应用之间的隔离,qiankun中并没有特别的提出,本质上就是在子应用加载时把其相应的样式加载进来,在卸载时进行移除即可。而父子之间的隔离在qiankun种有两种实现方法。
- strictStyleIsolation: Shadow DOM
第一种是严格样式隔离,核心是Shadow DOM。它可以让一个dom拥有自己的“影子”DOM树,这个DOM树不能在主文档中被任意访问,可以拥有局部样式规则, 天然实现了样式隔离 ,如上图所示,被代理的dom节点称为shadow host,shadow tree中的根节点称为shadow root。
比如我们常用的<video>标签,一个标签就实现了一个简易的播放器,但其实它是由一些看不到的dom封装而成的,这里就是使用了shadow DOM。
现在先来模拟一下父子的样式污染问题,在下面的demo中子应用的样式设置成所有字体颜色为红色,使得父元素和子元素所有的文字都为红色。
<div>
<h5>父元素</h5>
</div>
<div id="app1">
<style>
*{
color:red;
}
</style>
<h5>子元素</h5>
<p class="title">一行文字</p>
</div> |
这样的结果就是子样式污染了父样式(效果如下图)。
使用严格样式隔离解决一下这个问题:获取到子应用的根节点,然后打开影子模式,把原来的dom结构赋值到代理的影子根节点中,然后清空原来的dom结构。
function openShadow(domNode) {
var shadow = domNode.attachShadow({ mode: "open" });
shadow.innerHTML = domNode.innerHTML;
domNode.innerHTML = "";
}
var bodyNode = document.getElementById("app1");
openShadow(bodyNode); |
现在可以在dom树中看到,原来的子应用以及开启了影子模式,其中的子dom都在影子中,效果如右图所示,实现了父子之间的样式隔离。
- experimentalStyleIsolation
第二种父子样式隔离是实验性样式隔离 ,即通过运行时 修改CSS选择器 来实现子应用间的样式隔离。
下面也是一个模拟污染的demo,可以看到主应用和子应用有重名的选择器,子应用在后面,所以父样式被覆盖,造成了污染。
<head>
<style>
p.title {
color:red;
}
</style>
</head>
<body>
<p class="title">父应用</p>
<div id="data-qiankun-A">
<style>
p.title {
color:blue;
}
</style>
<p class="title">子应用</p> |
这里首先获取到子应用,然后通过正则匹配其中的所有<style>标签,给每一个标签加上前缀,从而缩小其样式应用的范围。
<head>
<style>
p.title {
color:red;
}
</style>
</head>
<body>
<p class="title">父应用</p>
<div id="data-qiankun-A">
<style>
p.title {
color:blue;
}
</style>
<p class="title">子应用</p> |
这里首先获取到子应用,然后通过正则匹配其中的所有<style>标签,给每一个标签加上前缀,从而缩小其样式应用的范围。
function scopeCss(styleNode, prefix) {
const css = ruleStyle(styleNode.sheet.cssRules[0], prefix);
styleNode.textContent = css;
}
function ruleStyle(rule, prefix) {
const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
let { cssText } = rule;
// 绑定选择器, a,span,p,div { ... }
cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
// 绑定 div,body,span { ... }
if (rootSelectorRE.test(item)) {
return item.replace(rootSelectorRE, (m) => {
// 不要丢失有效字符 如 body,html or *:not(:root)
const whitePrevChars = [",", "("];
if (m && whitePrevChars.includes(m[0])) {
return `${m[0]}${prefix}`;
}
// 用前缀替换根选择器
return prefix;
});
}
return `${p}${prefix} ${s.replace(/^ */, "")}`;
})
);
return cssText;
}
var container = document.getElementById("data-qiankun-A");
var styleNode = container.getElementsByTagName("style")[0];
scopeCss(styleNode, "#data-qiankun-A") |
效果如下图所示,可以看到子应用的<style>标签中的选择器都加上了前缀,使父应用的颜色保持原有的红色,子应用的颜色是新设置的蓝色。
js隔离是另一个在微前端中需要关注的问题,qiankun中有三种js隔离的做法。
第一种是快照沙箱,先来看一下具体demo:
let sandbox = new SnapshotSandbox();
var a = '主应用A';
var c = '主应用C';
console.log('主应用原来的Window:',a,c);
function beforeMounted(){
sandbox.active();
console.log("加载子应用前");
}
function beforeUnMounted(){
sandbox.inactive();
console.log("卸载子应用前");
}
function app1(){
beforeMounted();
window.a = 'app1A';//修改
window.c = null;//删除
window.d = 'app1D';//新增
console.log("子应用的Window:",window.a,window.c,window.d);
beforeUnMounted();
}
app1();
console.log('主应用现在的Window:',a,c,d); |
主应用中声明两个变量a和c,分别赋值主应用A和主应用C,然后加载子应用之后对全局变量ac进行修改,并且新增d,最后卸载时再打印acd,可以在左图看到主应用的变量被污染了。
这时候开启沙箱再运行一遍,可以在右图看到主应用被恢复回来了,解决了变量污染的问题。
沙箱快照的核心思想如下:在子应用挂在前对当前主应用的全局变量保存,然后恢复之前的子应用环境,在子应用运行期间则正常get和set,在卸载时保存当前变量恢复主应用变量,整个过程类似于中断和中断恢复。
具体代码可参考这个demo,但这里也有一个比较明显的缺点就是每次切换时需要去遍历window,这种做法会有较大的时间消耗。
class SnapshotSandbox {
constructor() {
this.proxy = window; //window属性
this.modifyPropsMap = {}; //记录在window上的修改
}
active() {//激活
this.windowSnapshot = {}; //快照
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop]
}
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p]
})
}
}
inactive() {//卸载
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
if (window[prop] != this.windowSnapshot[prop]) {
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
}
}
}
} |
第二种则是legacy沙箱,下面的demo比上一个稍微复杂一点。主要是加载了两次子应用,并且每次改变的变量值不同。
let {sandbox,fakeWindow} = new legacySandBox();
var a = '主应用A';
var c = '主应用C';
console.log('主应用原来的Window:',a,c);
function beforeMounted(){
sandbox.active();
console.log("加载子应用前");
}
function beforeUnMounted(){
sandbox.inactive();
console.log("卸载子应用前");
}
function app1(win=window){//这里使用了fakeWindow作为window
beforeMounted();
if(win.a === 'app1A'){
win.a = 'app1A-2';
win.c = '2';
win.d = 'app1D-2';
console.log("子应用第二次加载Window:",
win.a,win.c,win.d);
}else{
win.a = 'app1A';//修改
win.c = null;//删除
win.d = 'app1D';//新增
console.log("子应用第一次加载Window:",
win.a,win.c,win.d);
}
beforeUnMounted();
}
app1(fakeWindow);console.log('主应用现在的1Window:',a,c,d);
app1(fakeWindow);console.log('主应用现在的2Window:',a,c,d); |
左图显示的是主应用被污染的结果,右图是打开沙箱之后解决污染的结果。
legacy沙箱的主要原理是使用了ES6中的Proxy,把原来的window代理到fakeWindow上,这样就不用遍历整个window去应用和恢复环境了。除此之外,它还在沙箱内部设置了三个变量池:addedPropsMapinSandbox用于存放子应用运行期间新增的全局变量,用于在卸载子应用的时候删除;modifiedPropsOrginalMapInSandbox用于存放子应用运行期间修改的全局变量,用于卸载时进行恢复;currentUpdatedPropsValueMap用于存放子应用运行期间所有变化的变量,这样可以在加载子应用时恢复其上一次的环境。
下面是具体实现的沙箱demo:
class legacySandBox {
constructor() {
this.addedPropsMapInSandbox = new Map();//记录子应用运行期间新增的key
this.modifiedPropsOriginalValueMapInSandbox = new Map();//记录子应用运行期间修改的key
this.currentUpdatedPropsValueMap = new Map();//记录子应用运行期间的值
this.sandboxRunning = false;
const _this = this;
const fakeWindow = new Proxy(window, {
set(_, p, value){
if(_this.sandboxRunning){
if(!window.hasOwnProperty(p)){
_this.addedPropsMapInSandbox.set(p, value);
} else if(!_this.modifiedPropsOriginalValueMapInSandbox.has(p)){
const originalValue = window[p];
_this.modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
_this.currentUpdatedPropsValueMap.set(p, value);
window[p] = value;
return true;
}
return true;
},
get(_, p){
if(p === "top" || p === "window" || p === "self"){
return proxy;
}
return window[p];
}
})
return {sandbox:this,fakeWindow};
}
active() {//激活
if(!this.sandboxRunning){
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {//卸载
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((v, p) => this.setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
setWindowProp(p,v){
window[p] = v;
}
} |
第二种沙箱的实现对于单例模式来说已经比较完善了,但是不适用于多例模式,即同时有多个子应用在运行期间的时候,qiankun针对这个问题提出来proxySandbox。
proxySandbox依然是使用proxy代理window,但不同的是对于每个子应用都代理了一个fakeWindow,这样在查找变量的时候在本地的fakeWindow上查找,如果没有找到就到主应用的window上查找,而修改时只修改本地的fakeWindow,不会影响到其他的应用,在最终卸载时把fakeWindow删除即可。
qiankun的第三个特点是新增了生命周期。项目在迁移成子应用时,需要在入口的JS配合qiankun来做一些改动,而这些改动有可能 影响子应用的独立运行 。为了解决子应用也能独立运行的问题,qiankun注入了一些变量:window.__POWERED_BY_QIANKUN__,这样就可以判断当前应用是否在独立运行。
但是变量需要在运行时动态的注入,那么该变量设置的位置就需要考虑清楚,qiankun选择在single-spa提供的生命周期前进行变量的注入,在beforeLoad和beforeMount中把变量置为true,在beforeUnmount中把变量置为false, 最后qiankun暴露了五个生命周期钩子:beforeLoad、beforeMount、afterMount 、beforeUnmount和afterUnmount,这五个钩子可以在 主应用中注册子应用 时使用。
和single-spa一样的是 子应用的接入 必须暴露三个生命周期(毕竟是基于single-spa实现的):
- Bootstrap: 在这里做一些全局变量的初始化,比如不会在unmount阶段被销毁的应用级别的缓存。
- Mount:触发应用的渲染方法。
- Unmount:卸载微应用的应用实例。
- 子应用预加载
子应用预加载是一种优化策略,使用 requestIdleCallback 通过时间切片的方式去加载静态资源,在浏览器空闲时间去执行回调函数,避免浏览器卡顿,qiankun有四种预加载策略:
- 主应用执行start以后就直接开始预加载所有微应用的静态资源。
- 在第一个微应用挂载以后加载指定的微应用的静态资源。
- 第一个微应用挂载以后加载其它微应用的静态资源,利用single-spa提供的single-spa:first-mount事件来实现。
自定义函数,返回两个微应用组成的数组,criticalAppNames是关键微应用组成的数组,需要马上就执行预加载的微应用,minorAppsName是普通的微应用组成的数组,在第一个微应用挂载以后预加载这些微应用的静态资源。
在微前端中各个子应用需要和主应用进行通信,以获得必要的信息,子应用之间也可能会有少量的通信需要,在qiankun中使用的是一种订阅发布模式的通信方法。
// 触发全局监听,执行所有应用注册的回调函数
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
// 循环遍历,执行所有应用注册的回调函数
Object.keys(deps).forEach((id: string) => {
if (deps[id] instanceof Function) {
deps[id](cloneDeep(state), cloneDeep(prevState));
}
});
}
/**
* 定义全局状态,并返回通信方法,一般由主应用调用,微应用通过 props 获取通信方法。
* @param state 全局状态,{ key: value }
*/
export function initGlobalState(state: Record<string, any> = {}) {
if (state === globalState) {
console.warn('[qiankun] state has not changed!');
} else {
// 方法有可能被重复调用,将已有的全局状态克隆一份,
// 为空则是第一次调用 initGlobalState 方法
// 不为空则非第一次次调用
const prevGlobalState = cloneDeep(globalState);
// 将传递的状态克隆一份赋值为 globalState
globalState = cloneDeep(state);
// 触发全局监听
emitGlobal(globalState, prevGlobalState);
}
// 返回通信方法,参数表示应用 id,true 表示自己是主应用调用
return getMicroAppStateActions(`global-${+new Date()}`, true);
}
/**
* 返回通信方法
* @param id 应用 id
* @param isMaster 表明调用的应用是否为主应用,
* 在主应用初始化全局状态时,initGlobalState 内部调用该方法时会传递 true,其它都为 false
*/
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
return {
/**
* 全局依赖监听,为指定应用(id = 应用id)注册回调函数
* 依赖数据结构为:
* {
* {id}: callback
* }
*
* @param callback 注册的回调函数
* @param fireImmediately 是否立即执行回调
*/
onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
// 回调函数必须为 function
if (!(callback instanceof Function)) {
console.error('[qiankun] callback must be function!');
return;
}
// 如果回调函数已经存在,重复注册时给出覆盖提示信息
if (deps[id]) {
console.warn(`[qiankun] '${id}' global listener already exists before this`);
}
// id 为一个应用 id,一个应用对应一个回调
deps[id] = callback;
// 克隆全局状态
const cloneState = cloneDeep(globalState);
// 如果需要,立即出发回调执行
if (fireImmediately) {
callback(cloneState, cloneState);
}
},
/**
* setGlobalState 更新 store 数据
*
* 1. 对新输入 state 的第一层属性做校验,如果是主应用则可以添加新的一级属性进来,
* 也可以更新已存在的一级属性,
* 如果是微应用,则只能更新已存在的一级属性,不可以新增一级属性
* 2. 触发全局监听,执行所有应用注册的回调函数,以达到应用间通信的目的
*
* @param state 新的全局状态
*/
setGlobalState(state: Record<string, any> = {}) {
if (state === globalState) {
console.warn('[qiankun] state has not changed!');
return false;
}
// 记录旧的全局状态中被改变的 key
const changeKeys: string[] = [];
// 旧的全局状态
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(
// 循环遍历新状态中的所有 key
Object.keys(state).reduce((_globalState, changeKey) => {
if (isMaster || _globalState.hasOwnProperty(changeKey)) {
// 主应用 或者 旧的全局状态存在该 key 时才进来
// 说明只有主应用才可以新增属性,
// 微应用只可以更新已存在的属性值
// 记录被改变的key
changeKeys.push(changeKey);
// 更新旧状态中对应的 key value
return Object.assign(_globalState, { [changeKey]: state[changeKey] });
}
console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
return _globalState;
}, globalState),
);
if (changeKeys.length === 0) {
console.warn('[qiankun] state has not changed!');
return false;
}
// 触发全局监听
emitGlobal(globalState, prevGlobalState);
return true;
},
// 注销该应用下的依赖
offGlobalStateChange() {
delete deps[id];
return true;
},
};
}
|
所需要的状态都存在window的gloablState全局对象里,使用onGlobalStateChange 添加监听器。
当调用setGlobalState更新值后,会触发emitGlobal,执行所有对应的监听器。
调用offGlobalStateChange删掉监听器。
当运行中发生错误时,需要对其进行捕获,这里主要监听了error和unhandledrejection两个错误事件。
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', errorHandler); |
(三)Module Federation以及EMP
Module Federation是webpack5中的新特性, 主要是用来解决多个应用之间代码共享的问题,可以更加优雅的实现跨应用的代码共享,使用这个方法也可以实现微前端 。
这个方案中有两个主体: Remote 和 Host ,可以把Remote理解为想要引入的子应用,把Host理解为主应用(但是一个应用既可以是Remote也可以是Host,并不矛盾)。
Module Federation的核心在于ModuleFederationPlugin这个插件:
new ModuleFederationPlugin({
name: "App1",
library: { type: "var", name: "App1" },
filename: "remoteEntry.js",
remotes: {
app_02: 'App2',
app_03: 'App3',
},
exposes: {
antd: './src/antd',
button: './src/button',
},
shared: ['react', 'react-dom'],
})
|
- name,必须,唯一ID,作为输出的模块名,使用的时通过name/{expose}的方式使用;
- library,必须,其中这里的name为作为umd的name。
- remotes,可选,表示作为Host时,去消费哪些Remote。
- exposes,可选,表示作为Remote时,export哪些属性被消费。
- shared,可选,优先用Host的依赖,如果Host没有,再用自己的。
通过以上设置打包之后会生成三种文件:应用主文件:main.js、作为remote时被引用的文件remoteEntry.js和其他的异步加载文件。
这里通过一个例子简单概括一下其原理:下图是三个项目,在App1中它的remotes是App2和App3,没有exposes,shared是React和ReactDom,它作为host使用了remote的dialog组件和button组件;App2作为host使用了App3的button组件,作为remote导出来dialog组件,在App1中运行时如果需要使用React则会优先把App1中的React导入,这就复用了公共库;App3向外导出了dialog组件,只作为其他项目的remote。
这个新特性的提出促进了更多工具的发展,EMP就是以此为基础构建出的一个跨应用共享资源的框架,这个框架优化协作,加速开发的同时还可以保持UI的一致性。
(四)公司内以及其他微前端框架
在搜索相关资料的时候,关注到公司内部和其他企业也有很多优秀的微前端的解决方法,比如司内的微前端oteam,无界和Hel微前端等产品,美团的基于React的中心路由基座式微前端和Bifrost框架,字节的lModern.js和lGarfish。微前端的生态逐渐繁荣,之后也会继续学习其相关的知识。
四、实践探索
(一)技术选型
当理解完理论开始实践时,第一关便是技术选型,现有的备选方案如上一章所述,这里提出一些具体的 选型建议 :
single-spa:是第一个微前端框架,当前流行的大量框架都是single-spa的上层封装,但是如果作为生产选型,single-spa提供的是较为基础的api,应用在实际项目中需要进行大量封装且入侵性强,使用起来不太方便。但是如果想学习相关技术或者封装一套更灵活的解决方法还是很值得使用的。
qiankun: 阿里开源的一套框架,基于single-spa的上层封装,社区活跃度较高,在国内的生态较好,中文文档齐全,有大量的先行者铺路,比较适合用于生产环境。
EMP: 基于module federation实现的一套可以跨应用共享资源的框架,除了具备微前端的能力外,还实现了跨应用状态共享、跨框架组件调用的能力,属于司外最年轻的微前端框架(暂时不适用于生产环境中),目前在github上是1.6kstar (qiankun是11.9k)。
(二)qiankun Demo
实践部分可以分为两大步骤,首先是对主应用的改造:
- 设置微应用在主应用中对应的路由,微应用地址,并且下发方法
第二部分是子应用的改造:
这里打包成umd的格式是为了可以向主应用暴露其生命周期钩子。
以上就是qiankun实践的大概流程,不过有时候也会遇到各种各样的问题,大部分问题在其官网的issues中都有提到,可以根据其进行部分的调整。
(三)实践中的部分问题
一般上述的方式已经能满足大多数常见的应用场景来。但是如果微应用不是直接跟路由关联或是有需要手动触发子应用加载的场景,比如本文一开始的那张图。这时候qiankun提供了一个 loadMicroApp 的方法进行子应用的手动加载,本质上是利用single-spa的mountRootParcel api来实现的。
import { loadMicroApp } from 'qiankun';
loadMicroApp(
{
name: 'app',
entry: '//localhost:7100',
container: '#Container',
} ); |
使用loadMircoApp还需要在子应用中暴露update钩子。
// 增加 update 钩子以便主应用手动更新微应用
export async function update(props) {
renderPatch(props);
} |
五、总结——微前端意义
(一)化整为零
当下前端领域,单页面应已经成为流行的的项目形态之一,但是随着时间和技术的发展,原本单一的应用具备的功能愈加丰富起来,功能的丰富意味着越来越难以维护,从而变成一个巨石项目。以至于之后改一处而动全身,带来的发版成本也更高。微前端的意义就是将巨石应用进行拆分,化整为零把功能随之解耦,每个部分可以单独进行维护和部署,从而提升团队开发和维护效率。
(二)化零为整
在业务中或多或少会存在一些历史项目,这些项目也会使用不同的框架进行构建,在日常运营中,这些系统已经有固有的用户,但是诸多的应用对于用户来说也是一种成本,为了让旧项目焕发新生,这要求我们在不能抛弃原有项目的同时开发新的功能,把诸多较为零散的入口统一整合起来,从而提高系统的使用率,而微前端就是目前实现这一需求的较好方法。
综上, 微前端不一定是未来发展趋势的收束点,但是它在未来一定会发挥重要作用 。
|