前言
随着前端应用越来越复杂,越来越庞大。前有巨石应用像滚雪球一般不断的叠高,后有中后台应用随着历史长河不断地积累负债,或者急需得到改善。微前端的工程方案在前端er心中像一道曙光不断的被提起,被实践,多年至今终于有了比较好的指引。它在解决大型应用之间复杂的依赖关系,或是解决我们技术栈的迁移历史负担,都在一定程度上扮演了极其关键的桥梁。
本文会先从复用组件,窥探到代码共享。聊一聊中后台项目在微前端的场景下,从工程化的角度下如何跨技术栈复用业务组件,再介绍一下其它的共享代码方案。
在正文开始之前,希望读者能对以下关键词有所了解,以便后文一起交流探讨
- 微前端
- 共享组件
- Garfish(字节开源的微前端框架)
- Webpack & module federation
- Bit
业务背景
如上图,我们先看这么个场景。这个 modal 被红色框起来的部分,其实是一个业务复杂较复杂的react组件来渲染的。在这里就需要渲染出5个react组件。同时这个modal是过去用vue实现的代码,我们的react组件是需要被渲染在vue代码中的,也就是 React in Vue。
在我们的中后台系统里,过去全都是vue的技术栈。而我们新的业务希望全面的往react迁移,其中不乏有比较复杂的业务组件。如下
基于微前端的工程方案,我们就可以尽可能少的修改vue的代码。同时,我们也能达到组件级别的嵌入。
从工程的角度解决微组件共享
项目介绍
先试想一下,其实大多数中后台项目,都是像如上的场景一般。我们可能仅是为了应用之间的解耦,这有利于构建,团队独立维护,改善项目结构,代码复用等等。其实更需要解决的是团队内部自身的工程问题,基本不会涉及到跨产品部门的复用或业务共享。我们更多关注的是,当下在不同repo之间的代码和在不同技术栈之间的组件,如何达到共享。那么我们需要共享微组件的职责就很清晰了。
在我们团队的中后台应用有三个repo,过去的巨石应用(vue),新建的两个monorepo(react)。(拆了两个是业务之间比较独立。)
在我们有了monorepo之后,其实所有的业务组件或者业务代码,都已经在物理的层面上可以良好的复用。剩下的问题就在于如何跨repo(跨物理层面)在过去的技术栈(vue)中直接复用。而我们的方式就是基于微前端来做。
当我们有了master这样的宿主介入之后,项目的可操作空间就不太一样了。微前端为的是能在同一个应用下,提供一个相同的运行环境。(本文不过多探讨iframe的方式。)
monorepo能很好地解决我们同一个repo下的代码复用问题。如果我们把每一个 repo 都抽象的看做一个模块,那就只需要想办法在这个模块能exports东西出去,不就可以达到跨repo之间的复用?同时它也是一种解决了物理层面上无法复用的手段。
所以我们的做法就变得很清晰了,在新的react repo里,其实我们就会自然的沉淀下许许多多的基础组件或者是带有复杂业务的业务组件。比如上图的biz-ui,每一个biz-ui里的组件,都是一个完整的业务组件。而我们最终的目标,就是想办法把这些业务组件通过微前端的方式,给其它项目使用。
Micro-components app 子应用,就是我们的exports,它也是一个子应用。所有需要在当前repo exports的业务组件,都可以在这里被注册。
利用子应用复用微组件
从一个用法开始
如果是一个组件很简单,也很好实现,我们知道garfish有提供loadApp的接口,我们可以直接通过加载一个子应用,这个子应用渲染某个react组件。大致代码如下
代码语言:javascript复制// loadApp.vue
<template>
<div :id="id"></div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
let id = 9999;
let beforeDestroy: (() => void) | undefined = undefined;
export default defineComponent({
props: [],
data() {
return {
id
};
},
async mounted() {
_const_ app = _await_ Garfish.loadApp('xxx', {
domGetter: () => document.getElementById(this.id),
})
_// 渲染:编译子应用的代码 -> 创建应用容器 -> 调用 provider.render 渲染_
_const_ success = _await_ app.mount();
},
beforeDestroy() {
console.info(this.microComponentKey, '微前端组件卸载');
beforeDestroy?.();
},
watch: {
},
});
</script>
这样的代码在我们系统里还是跑了几个月的,没有任何问题。但是如果有了多例就不一样了,我们会调用多次loadApp,加载了大量子应用的代码,导致性能很差,甚至直接卡死。有人说加cache行不行?其实也是不可行的,上述的代码过于简陋,我们还需要处理props变化的情况,以及loadApp,传递props给react的情况。如果单纯只是cashe解决不了这样的场景。
所以我们特意设计了一个子应用,这个子应用专门作为组件级别的渲染,暂且称之为 微组件子应用
而在vue那,我们需要保证全局只会load 一个微组件子应用,这个子应用的domGetter可挂在到body上,仅仅作为一个container。而我们的react组件,全通过portal的形式进行渲染到任意位置即可。
基于这个思路,我们需要去设计一个微组件渲染的数据结构。再看一眼这个图,我们这个数据结构会有哪些东西
每个组件其实所需要接收的参数有domId、props和事件或其它属性。所以我们的数据结构其实可以大致如下。
代码语言:javascript复制type Meta = {
domId: string;
componentKey: string; // 为了指定由哪个组件渲染
props?: Record<any, any>;
[_key_: string]: any; // 事件和其它透传属性
};
有了这个结构,我们 react 的 render 函数就简单了,统一渲染一个protal数组即可。
代码语言:javascript复制 portalRender.map(_meta_ => {
const { domId, componentKey, props: _props, ...rest } = _meta_;
const container = document.getElementById(domId);
if (!container) {
return null;
}
return ReactDOM.createPortal(
<Suspense _fallback_={null}>
<Portals
_componentKey_={componentKey}
{...{ domId, ..._props, ...rest }}
/>
</Suspense>,
container,
domId,
);
})
在vue这边,我们先设想一下应该如何使用这样的组件呢?当然肯定是和单纯的一个vue组件没有区别。比如这样。
所以我们就需要封装一个底层的vue组件去负责管理子应用的load和props的传递。
代码语言:javascript复制// loadCMSMicro.vue 伪代码
<template>
<div :id="id"></div>
</template>
<script>
import { microComponentManager } from '../src/MicroComponentManager';
let id = 0;
export default {
data() {
return {
id: `${ id}`,
beforeDestroy: undefined,
};
},
props: {
props: {
required: false,
default: () => ({}),
},
componentKey: {
type: String,
require: true,
},
subAppName: {
type: String,
require: true,
default: '',
},
},
async mounted() {
const { unMount, error } = await MicroComponent.loadComponent();
this.beforeDestroy = unMount;
},
beforeDestroy() {
this.beforeDestroy && this.beforeDestroy({ domId: this.id, type: 'remove' });
},
};
</script>
而MicroComponent,需要去负责保持只能load一个子应用单例以及props的传递和变化。
代码语言:javascript复制class MicroComponent {
private _loaded = false;
private _app: any;
private _count = 0;
async loadComponent() {
try {
this._count ;
if (!this._loaded) {
this._loaded = true;
this._app = await window.Garfish.loadApp(this._subAppName, {
domGetter: () => document.body,
props: {
subAppName: this._subAppName,
},
});
await this._app.mount();
}
const unMount = (_params_: PropsChange) => {
this.emitPropsChange(_params_);
this._count--;
if (this._count === 0) {
console.info('[微组件] 子应用卸载了');
this._app.unmount();
this._loaded = false;
this._app = null;
}
};
if (!this._app) {
return {
unMount,
};
}
console.info('[微组件] 加载完毕');
this._debounceEmitPropChange();
return {
unMount,
};
} catch (e) {
console.error(`[微组件] 子应用加载失败: ${e}`);
this._loaded = false;
this._app = null;
this._count = 0;
return {
error: 'CMS 加载子应用失败',
};
}
}
}
我们需要用两个flag来控制mount和unmunt。为了保证只能load一个子应用,用一个loaded开关来控制。而count是因为我们有多例其实就是个引用计数,必须保证每个微组件都卸载了,才能去unmount掉我们的子应用。
props如何传递呢?这里其实就是如何进行不同应用之间的数据共享,同时他是保持一份的。我们可以通过garfish提供的API来实现。
基于这2个API,我们可以在garfish上构建出这么个对象来传递我们的数据。在之前提到过,我们可能是多个子应用export出来的组件,其实这部分的数据存储就是一个二维结构。
代码语言:javascript复制garfish[subAppName][domId] = {
domId: 1,
props: {},
...rest,
}
当我们初始化一个vue的组件时,就需要把对应的meta数据挂载到garfish上。修改一下我们刚刚上面的组件代码
代码语言:javascript复制...
export default {
...
async mounted() {
const formatEvents = Object.keys(this.$listeners).reduce((_pre_, _cur_) => {
_pre_[toUpper(_cur_)] = this.$listeners[_cur_];
return _pre_;
}, {});
microComponentManager.setMeta(this.subAppName, this.id, {
...formatEvents,
...this.$props,
});
const module = microComponentManager.getSubApp(this.subAppName);
const { unMount, error } = await module.loadComponent();
this.beforeDestroy = unMount;
},
...
};
</script>
因为需要保持每一个子应用都是唯一的单例,我们继续引入microComponentManager来帮我们管理所有的子应用实例。
搞定了初始化和数据传递的的问题后,我们来思考一下props change的问题。其实也很简单,只要三个步骤。
- 监听vue组件的props变化,重新修改数据set到garfish上
- 发送事件,通知react获取最新的数据
- React rerender
<script>
// vue
export default {
...
watch: {
props: {
immediate: true,
deep: true,
handler(_newProps_) {
const module = microComponentManager.getSubApp(this.subAppName);
microComponentManager.setMeta(this.subAppName, this.id, {
...microComponentManager.getMeta(this.subAppName, this.id),
..._newProps_,
});
// 发送事件通知react
module.emitPropsChange({ domId: this.id, type: 'new' });
},
},
},
};
</script>
react组件则接收到事件后,对数据进行更新,重新渲染
代码语言:javascript复制// react
export const MicroContainer = (_props_: Props) => {
const { subAppName, microComponents } = _props_;
const [portalRender, setPortalRender] = useState<Meta[]>([]);
const pendingUpdate = useRef<Meta[]>([]);
const { run } = useDebounceFn(() => {
setPortalRender([...pendingUpdate.current]);
}, 10);
const onChange = (_params_: PropsChange[]) => {
const removeIds = _params_.filter(_item_ => _item_.type === 'remove');
const updateIds = _params_.filter(_item_ => _item_.type === 'new');
if (removeIds.length > 0) {
pendingUpdate.current = pendingUpdate.current.filter(_item_ => {
return removeIds.find(_elm_ => _elm_.domId !== _item_.domId);
});
}
updateIds.forEach(({ _domId_ }) => {
const meta = microComponentManager.getMeta(subAppName, _domId_);
const { componentKey, ...rest } = meta;
const target = pendingUpdate.current.find(_item_ => _item_.domId === _domId_);
if (target) {
Object.assign(target, rest);
} else {
pendingUpdate.current.push({
...rest,
domId,
componentKey,
});
}
});
run();
};
useEffect(() => {
microComponentManager.on(
MICRO_COMPONENT_EVENTS.PROPS_CHANGE,
onChange,
subAppName,
);
return () => {
microComponentManager.off(
MICRO_COMPONENT_EVENTS.PROPS_CHANGE,
onChange,
subAppName,
);
};
}, []);
return (
...
);
};
我们的MicroComponent也需要增加相应的事件发送代码。
代码语言:javascript复制export class MicroComponent {
private _loaded: boolean = false;
private _app: any;
private readonly _subAppName: string;
private _count: number = 0;
private _pendingPropsChange: PropsChange[] = [];
private readonly _debounceEmitPropChange: (..._args_: any[]) => void;
constructor(_subAppName_: string) {
this._subAppName = _subAppName_;
this._debounceEmitPropChange = debounce(
() => this._checkPendingProps(),
50,
);
}
async loadComponent() { ... }
emitPropsChange(_params_: PropsChange) {
this._pendingPropsChange.push(_params_);
this._debounceEmitPropChange();
}
private _checkPendingProps() {
setTimeout(() => {
_// 放到下一个 macrotask 里执行,等待微前端框架和子应用渲染完毕_
if (this._pendingPropsChange.length === 0 || !this._app) {
return;
}
this.emit(MICRO_COMPONENT_EVENTS.PROPS_CHANGE, this._pendingPropsChange);
this._pendingPropsChange = [];
});
}
emit(_event_: keyof typeof MICRO_COMPONENT_EVENTS, _params_?: PropsChange[]) {
window.Garfish.channel.emit(genEventKey(this._subAppName, _event_), _params_);
}
}
我们用一个pending队列来存放所有的事件,这是避免一瞬间发送过多事件导致无意义的开销。比如一个列表的页面,可能同时创建了100个微组件,此时如果不做一次debounce则会一瞬间发送100次。一个优化的小细节。
另外需要注意的是注意到我们发送事件的地方用了个setTimeout,这是由于我们的app.mount,其实仅仅只是把子应用给渲染完了,此时不代表react的组件被渲染完毕,我们在react里的useEffect还是没有执行的。所以我们需要放到下一个macroTask来发送事件,为了保证react里先监听。
以上其实就是整套方案的核心代码了
总结
总的来说,我们的实现方案就是基于loadApp,把一个子应用仅仅当做多应用之间渲染和通信的媒介挂在在了body上。所有的组件都通过portal的方式,挂载到指定的dom位置上。
优势
- 原理代码实现简单轻量,复用便捷,开发高效,无关技术栈
- 接入简单,可以实现ReactInVue,VueInReact
- 无论需要复用多少个组件,都只需要load1个子应用,开销低
- 可以挂载到任何garfish的应用里,组件复用,达到跨团队级别的复用
- 只需要发布一次,所有地方全都生效且最新版本
- 可以跨repo搭建自己需要共享的组件子应用
劣势
- 无法对组件版本进行管理
- 需要基于garfish的环境才能达到共享
- 需要创建一个子项目,相比共享组件的方案更重
- keep-alive场景下可能有问题
- 依赖管理不方便控制(React,组件库等)
可以看出这个方案也有一个最大的局限性。版本不可控,在我们的业务里是不需要对这样需要共享的组件进行版本管理的。以下介绍的方案大家需要注意下,如果你的共享组件需要版本管理则不可采用这种方案。所以,我们再来看看,现在共享组件的标准实现方案。
运行时组件市场
我们上述的方案,其实是通过组件复用的场景细分采用工程化的方案来解决物理隔离,技术栈不同的组件复用。而如果我们需要一个更加通用化的微组件方案,必然会需要平台的支持,版本的支持,loader的支持。所以我们来看看现有的组件市场的发展方向。
Garfish 提供了 loadComponent[1] 的API,可以直接远程加载一个组件资源。在现有的设计下,大多数这个资源都是一个已经被编译好的umd的js文件。
不过在字节内部的另一个微前端框架有另外一种设计,使用的API与 federation 非常相似。
以上的例子无论是哪种API的设计,都不妨碍我们深入理解微组件。不难发现,需要抽象一个微组件必须具备的API需要有
- Load(指定资源,无论是key还是url)
- mount/unmout (生命周期)
- update (props change)
当组件的API被合理的设计好之后,我们还有一个关键就在于如何管理这些组件。于是「组件市场」就这么诞生了。组件市场必须具备的职责只需要两点
- 组件的上传与下架
- 可以是以name的方式或者url的方式下载代码
以往我们已经现有的物料平台或者是区块平台,都可以很简单且自然的支持这两个功能。
共享代码
其实上面讲了两种微组件的方案。我们可以扩展性的思考一下,共享组件其实就是共享代码的一种细分,解决了共享代码,我们就顺便解决了共享组件的问题。而往往共享代码会有更大的使用场景。
Module Federation
概念
Module Federation(以下简称MF)的中文直译为“模块联邦”,从Webpack官网中我们可以找到使用其的动机:
Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually.
This is often known as Micro-Frontends, but is not limited to that. 可以看出MF想要达到的目的就是把多个无相互依赖、单独部署的应用合并为一个应用,即MF提供了在某个应用中可以远程加载其他服务器上应用的能力。对于MF来说,有两种角色:
- Host:引用了其他应用的应用
- Remote:被其他应用所使用的应用
同时,一个应用既可以作为host也可以作为remote,即可以利用MF实现一个去中心化的应用部署群。并且,MF允许应用之间共享依赖实例,例如:host使用了react,remote也使用了react,remote经过配置后,可以在被host加载运行时优先使用host的react实例,而不会重复加载,这样可以做到在一个应用中只存在一个react实例。
示例
我们将使用Webpack官网[2]给出的demo[3]作为示例,向大家展示如何使host应用(app1)在运行时动态加载并使用remote应用(app2)的内容。先来看看demo中的文件结构:
- app1
- App.js(react页面入口)
- bootstrap.js(项目启动文件)
- index.js(项目入口文件)
- src
- webpack.config.js(webpack配置文件)
- app2
- App.js(react页面入口)
- Button.js(Button Component)
- bootstrap.js(项目启动文件)
- index.js(项目入口文件)
- src
- webpack.config.js(webpack配置文件)
app1和app2是两个独立部署的应用。
下面来看看app1中的具体代码内容:
代码语言:javascript复制// app1 index.js
import bootstrap from "./bootstrap";
bootstrap(() => {});
// app1 bootstrap.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));
// app1 App.js
import React from "react";
const RemoteButton = React.lazy(() => import("app2/Button"));
const App = () => (
<div>
<h1>Basic Host-Remote</h1>
<h2>App 1</h2>
<React.Suspense fallback="Loading Button">
<RemoteButton />
</React.Suspense>
</div>
);
export default App;
可以发现App.js中有一行非常关键的代码:
代码语言:javascript复制const RemoteButton = React.lazy(() => import("app2/Button"));
那么问题来了:
- 这个
app2/Button
是从哪里来的呢? - 这一段引用的组件代码长啥样?
我们先来看看app2项目中的webpack配置(这里我们就不贴app2的代码内容了,因为没有什么特别的地方并且在这里并不需要关心):
代码语言:javascript复制// app2 webpack.config.js
// ...
new ModuleFederationPlugin({
// 作为remote时的模块名
name: "app2",
library: { type: "var", name: "app2" },
// export的内容被打成包时的文件名
filename: "remoteEntry.js",
// 作为remote时,export哪些内容被host消费
exposes: {
"./Button": "./src/Button",
},
// 作为remote时,优先使用host的这些依赖,若host没有,则再用自己的
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
}),
// ...
从上面配置可以知道:
- app2项目作为remote时的模块名是app2;
- export的内容是Button组件;
- 要export的内容会独立打包成一个名叫remoteEntry.js的文件;
- export的内容在被host消费时,会优先使用host的react和react-dom实例。
那么app1中又是如何配置使用app2模块的内容的呢,下面我们来看看app1的webpack配置中关于MF的部分:
代码语言:javascript复制// app1 webpack.config.js
// ...
new ModuleFederationPlugin({
// 作为remote时的模块名
name: "app1",
// 作为host时会消费哪些remote的资源
remotes: {
app2: 'app2@localhost://3002',
},
// 作为remote时,优先使用host的这些依赖,若host没有,则再用自己的
shared: {
react: { singleton: true },
"react-dom": { singleton: true }
},
}),
// ...
从上面配置中中可以知道app1中使用了跑在localhost:3002上的app2模块内容。至此,在app1如何配置使用app2内容的问题就解决了。
把项目跑起来,可以看到app1的页面,从前面的代码可以知道,App2 Button组件是来自app2中的。
并且可以看到,app1下载了app2的remoteEntry.js文件,并使用了里面的相关内容,共享代码成功。
实现原理
在讲MF的实现原理之前,我们先来简单简单讲下webpack的模块打包原理,这对理解MF的模块原理至关重要,如果你对这部分内容已经熟知,可以跳过。
先看个简单的栗子