Vite 微前端实践,实现一个组件化的方案
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端借鉴了微服务的架构理念,将一个庞大的前端应用拆分为多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用联合为一个完整的应用。微前端既可以将多个项目融合为一,又可以减少项目之间的耦合,提升项目扩展性,相比一整块的前端仓库,微前端架构下的前端仓库倾向于更小更灵活。
特性
技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈 独立开发/部署 各个团队之间仓库独立,单独部署,互不依赖 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性 独立运行时 微应用之间运行时互不依赖,有独立的状态管理 提升效率 应用越庞大,越难以维护,协作效率越低下。微应用可以很好拆分,提升效率
目前可用的微前端方案
微前端的方案目前有以下几种类型:
基于 iframe
完全隔离的方案
作为前端开发,我们对 iframe
已经非常熟悉了,在一个应用中可以独立运行另一个应用。它具有显著的优点:
非常简单,无需任何改造 完美隔离,JS、CSS 都是独立的运行环境 不限制使用,页面上可以放多个 iframe
来组合业务
当然,缺点也非常突出:
无法保持路由状态,刷新后路由状态就丢失 完全的隔离导致与子应用的交互变得极其困难 iframe
中的弹窗无法突破其本身整个应用全量资源加载,加载太慢
这些显著的缺点也催生了其他方案的产生。
基于 single-spa
路由劫持方案
single-spa
通过劫持路由的方式来做子应用之间的切换,但接入方式需要融合自身的路由,有一定的局限性。
qiankun
孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台。它对 single-spa
做了一层封装。主要解决了 single-spa
的一些痛点和不足。通过 import-html-entry
包解析 HTML
获取资源路径,然后对资源进行解析、加载。
通过对执行环境的修改,它实现了 JS 沙箱
、样式隔离
等特性。
京东 micro-app
方案
京东 micro-app
并没有沿袭 single-spa
的思路,而是借鉴了 WebComponent
的思想,通过 CustomElement
结合自定义的 ShadowDom
,将微前端封装成一个类 webComponents
组件,从而实现微前端的组件化渲染。
在 Vite
上使用微前端
我们从 我们从 UmiJS 迁移到了 Vite
之后,微前端也成为了势在必行,当时也调研了很多方案。
为什么没用 qiankun
qiankun
是目前是社区主流微前端方案。它虽然很完善、流行,但最大的问题就是不支持 Vite
。它基于 import-html-entry
解析 HTML 来获取资源,由于 qiankun
是通过 eval
来执行这些 js
的内容,而 Vite
中的 script
标签类型是 type="module"
,里面包含 import/export
等模块代码, 所以会报错:不允许在非 type="module"
的 script
里面使用 import
。
退一步实现,我们采用了 single-spa
的方式,并使用 systemjs
的方式进行了微前端加载方案,也踩了不少的坑。single-spa
没有一个友好的教程来接入,文档虽然多,但大多都在讲概念,当时让人觉得有一种深奥的感觉。
后来看了它的源码发现,这都是些什么……里面大部分代码都是围绕路由劫持而展开的,根本没有文档上那种高大上的感觉。而我们又用不到它路由劫持的功能,那我们为什么要用它?
从组件化的层面来说 single-spa
这种方式实现得一点都不优雅。
它劫持了路由,与 react-router
和组件化的思维格格不入接入方式一大堆繁杂的配置 单实例的方案,即同一时刻,只有一个子应用被展示
后来琢磨着 single-spa
的缺点,我们可以自己实现一个组件化的微前端方案。
如何实现一个简单、透明、组件化的方案
通过组件化思维实现一个微应用非常简单:子应用导出一个方法,主应用加载子应用并调用该方法,并传入一个 Element
节点参数,子应用得到该 Element
节点,将本身的组件 appendChild
到 Element
节点上。
类型约定
在此之前我们需要约定一个主应用与子应用之间的一个交互方式。主要通过三个钩子来保证应用的正确执行、更新、和卸载。
类型定义:
export interface AppConfig {
// 挂载
mount?: (props: unknown) => void;
// 更新
render?: (props: unknown) => ReactNode | void;
// 卸载
unmount?: () => void;
}
子应用导出
通过类型的约定,我们可以将子应用导出:mount
、render
、unmount
为主要钩子。
React
子应用实现:
export default (container: HTMLElement) => {
let handleRender: (props: AppProps) => void;
// 包裹一个新的组件,用作更新处理
function Main(props: AppProps) {
const [state, setState] = React.useState(props);
// 将 setState 方法提取给 render 函数调用,保持父子应用触发更新
handleRender = setState;
return <App {...state} />;
}
return {
mount(props: AppProps) {
ReactDOM.render(<Main {...props} />, container);
},
render(props: AppProps) {
handleRender?.(props);
},
unmount() {
ReactDOM.unmountComponentAtNode(container);
},
};
};
Vue 子应用实现:
import { createApp } from 'vue';
import App from './App.vue';
export default (container: HTMLElement) => {
// 创建
const app = createApp(App);
return {
mount() {
// 装载
app.mount(container);
},
unmount() {
// 卸载
app.unmount();
},
};
};
主应用实现
React
实现
其核心代码仅十余行,主要处理与子应用交互 (为了易读性,隐藏了错误处理代码):
export function MicroApp({ entry, ...props }: MicroAppProps) {
// 传递给子应用的节点
const containerRef = useRef(null);
// 子应用配置
const configRef = useRef();
useLayoutEffect(() => {
import(/* @vite-ignore */ entry).then((res) => {
// 将 div 传给子应用渲染
const config = res.default(containerRef.current);
// 调用子应用的装载方法
config.mount?.(props);
configRef.current = config;
});
return () => {
// 调用子应用的卸载方法
configRef.current?.unmount?.();
configRef.current = undefined;
};
}, [entry]);
return <div ref={containerRef}>{configRef.current?.render?.(props)}div>;
}
完成,现在已经实现了主应用与子应用的装载、更新、卸载的操作。现在,它是一个组件,可以同时渲染出多个不同的子应用,这点就比 single-spa
优雅很多。
entry 子应用地址,当然真实情况会根据 dev
和 prod
模式给出不同的地址:
"micro-app" entry="//localhost:3002/src/main.tsx" />
Vue
实现