什么是微前端
微前端的核心理念是将前端项目按照不同的功能按或者维度拆分为多个独立的子应用,通过主应用来加载这些子应用,微前端的核心在于 如何拆 和 拆完后如何合
微前端架构具备以下几个核心价值
-
技术栈无关: 主框架不限制接入应用的技术栈,不同子应用可以选择各自适合的技术栈
-
独立开发、独立部署: 每个子应用可以由独立的团队开发维护、独立部署,部署完成后主框架自动完成同步更新
-
实现增量迁移: 通过微前端,可以逐步重构老旧系统,而无需一次性完成整个系统的技术栈迁移
为什么不是iframe
通过 iframe 去加载子应用,这应该是最简单的微前端方案。尤其是 iframe 天生具有完美的沙箱机制,能够自动实现应用隔离;通信也可以通过 postMessage
实现,完成 iframe 与宿主页面之间的通信
但是! 它也带来了通信复杂性、用户体验方面的问题
-
通信复杂性: 尽管可以通过
postMessage
实现父子应用通信,但消息的传递逻辑较为复杂,尤其是在需要频繁、双向通信的场景下,代码会变得难以维护 -
样式隔离问题: 虽然 iframe 提供了独立的样式隔离,但有时我们希望不同子应用和主应用共享相同的样式和主题,这种情况下处理起来会更加困难
-
路由管理和URL: 每个 iframe 都有独立的 URL 和浏览器历史记录,难以与主应用的路由系统结合
-
性能问题: 每个 iframe 都会创建独立的浏览器上下文和资源池,因此加载多个 iframe 时,可能会导致性能下降
运行原理
按照不同的功能按或者维度拆分为多个独立的子应用,将子应用打包成一个个模块,当路由切换时加载不同的子应用
javascript
registerMicroApps([
{
name: 'reactApp', // 微应用的名称,微应用之间必须确保唯一
entry: '//localhost:40000', // 微应用的入口
activeRule: '/react', // 微应用的激活规则,当路径以 /react 为前缀时启动
container: '#container', // 微应用的容器节点的选择器或者 Element 实例
loader, // loading 状态发生变化时会调用的方法
props: { userInfo:{ name: 'burc', password: 'xxxxxx'} }, // 主应用需要传递给微应用的数据
}
])
-
注册子应用: 通过 qiankun API
registerMicroApps
注册子应用,注册应用的逻辑采用的是 single-spa 的registerApplication
方法 -
启动qiankun : 通过 qiankun API
start
去启动 qiankun,内部采用的也是 single-spa 的start
方法 -
监听路由: 在 single-spa 内部去做监听路由的操作
-
匹配子应用: 根据配置的
activeRule
匹配子应用,匹配成功后,通过import-html-entry
加载子应用,然后注入到指定的容器元素container
中
注意:我们需要修改 webpack 配置,将子应用打包为 umd 格式
核心技术解析
乾坤是基于 single-spa 实现的微前端框架,而 single-spa 用到了 SystemJS 作为主要的模块加载工具
在进一步深入微前端关键技术之前,我们先来了解下 SystemJS 和 single-spa 的工作原理
SystemJS
详细内容可以参考柏成之前写的文章 - SystemJS 和 single-spa | qiankun 源码解析
是一个可运行于浏览器端的模块加载器,让我们可以在浏览器中使用 ES6 import/export
语法
我们可以通过 systemjs-importmap
指定依赖库的地址,也可以在 script标签里 System.import('./index.js')
直接导入某个模块,具体语法可以参考下面代码
html
<body>
<h3>主应用,也叫基座,用来加载子应用的 webpack importMap</h3>
<div id="root"></div>
<!-- 可以在浏览器使用 ES6 的 import/export 语法, 通过 systemjs-importmap 指定依赖库的地址 -->
<script type="systemjs-importmap">
{
"imports": {
"react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js",
"react": "https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js"
}
}
</script>
<script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.10.1/system.min.js"></script>
<script>
// 直接加载子应用, 导入打包后的包来进行加载, 采用是 system规范
System.import('./index.js')
</script>
</body>
SystemJS 和 single-spa 没有任何关系,只是它的 in-browser import/export 和 single-spa 倡导的 in-browser run time 相符合,所以 single-spa 将其作为主要的导入导出工具 (并不是必须的!!!)
single-spa
single-spa 通过路由劫持实现应用的加载(采用SystemJS),提供应用间公共组件加载以及公共业务逻辑处理。子应用需要遵循特定的接入协议,即暴露固定的生命周期钩子(bootstrap、mount、unmount)💯
无沙箱机制,需要实现自己的JS沙箱以及CSS沙箱
index.html(主应用)
负责声明资源路径
javascript
<script type="systemjs-importmap">
{
"imports": {
"@burc/root-config": "//localhost:9000/burc-root-config.js",
"@burc/react":"//localhost:3000/react.js",
"@burc/vue":"//localhost:4000/js/app.js"
}
}
</script>
main.js(主应用)
负责注册子应用和启动主应用 Application
javascript
import {
registerApplication,
start
} from "single-spa";
registerApplication({
name: "@burc/react", // 不重名即可
app: () =>
System.import('@burc/react'),
activeWhen: (location) => location.pathname.startsWith('/react'),
});
registerApplication({
name: "@burc/vue", // 不重名即可
app: () =>
System.import('@burc/vue'),
activeWhen: (location) => location.pathname.startsWith('/vue'),
});
start({
urlRerouteOnly: true,
});
sing-spa 只做了两件事: 一是提供生命周期概念,负责调度子应用的生命周期;二是劫持 url 变化事件,url 变化时匹配对应子应用,执行生命周期流程
Root Config: 指主应用的 index.html + main.js。HTML 负责声明资源路径,JS 负责注册子应用和启动主应用
Application: 子应用要暴露 bootstrap,mount,unmount 三个生命周期(接入协议)
importEntry
乾坤相比于 single-spa 有两大特色,一个是实现了 JS 和 CSS沙箱机制;
另一个就是使用 import-html-entry 实现了 HTML entry,而 single-spa 只能是 JS entry 的形式来加载子应用
- JS Entry。 通常将子应用的所有资源打包成一个入口文件,在 single-spa 的很多样例中就使用了这种方式
- HTML Entry。 子应用构建输出的是一个 HTML 文件,主应用通过加载这个 HTML 文件完成子应用的加载
我们可以通过 importEntry 加载解析子应用的入口HTML文件,获取解析后的html文件、并且拿到js脚本的执行器、和额外的js脚本
import-html-entry到底干了些什么?
import-html-entry,它可以从指定的 URL 加载解析 HTML 文件,返回值如下:
- template: 是注释掉了 js脚本,并将外部css样式转化为内部css样式之后的 html
- assetPublicPath: 静态资源的公共路径
- execScripts: Promise<> ,执行js脚本的函数(包括内部脚本和外部脚本)
- getExternalScripts: Promise<> Scripts URL from template,返回 html 文件的所有js脚本
- getExternalStyleSheets: Promise<> StyleSheets URL from template,返回 html 文件的外部css样式表
文字描述看起来可能有点晦涩,柏成在这里贴了好多截图,可移步 - importEntry(加载HTML) | qiankun 源码解析
JS沙箱
三种JS沙箱是如何实现的,可以参考柏成之前写的文章 - qiankun 的 JS 沙箱隔离机制
乾坤目前存在三种 JS隔离机制,分别是 SnapshotSandbox
、LegacySandbox
和 `ProxySandbox``
-
SnapshotSandbox(快照沙箱)
这是乾坤最早期的沙箱机制,在每次应用激活和失活时遍历
window
对象的所有属性,记录并恢复其状态 。性能很差,浪费内存;优点是可以兼容不支持
Proxy
的旧版浏览器 -
LegacySandbox(单应用代理沙箱)
在 SnapshotSandbox 基础上进行优化的一种沙箱机制,通过 ES6 的
Proxy
对window
对象进行更高效的代理。然而,它仍会读写
window
对象,存在全局污染的问题;并且只能支持单个微应用的运行,意味着在一个页面上不能同时运行多个微应用事实上,LegacySandbox 在未来应该会消失, 逐渐被能够同时支持多个微应用的 ProxySandbox 所取代
-
ProxySandbox(多应用代理沙箱)
是乾坤最先进的一种 JS沙箱隔离机制,通过
Proxy
对象为每个微应用创建了一个独立的虚拟window
。不会操作window
对象,不存在全局污染的问题;而且在同一页面上也支持多个微应用的同时运行缺点则是不兼容不支持
proxy
旧版浏览器
CSS沙箱
三种CSS沙箱是如何实现的,可以参考柏成之前写的文章 - qiankun 的 CSS 沙箱隔离机制
乾坤目前存在三种 CSS 隔离机制,分别是动态样式隔离、影子DOM沙箱和作用域沙箱
-
动态样式隔离:qiankun 默认开启,可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离
-
影子DOM沙箱(Shadow DOM) :手动开启 ,qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响
-
作用域沙箱(Scope CSS):手动开启 ,qiankun 会改写子应用所添加的样式,为所有样式规则增加一个特殊的选择器规则来限定其影响范围
应用通信
通过 props 传递数据
主应用在加载子应用时可以通过 props
向子应用传递数据。这种方式简单直接,适合用于主应用向子应用单向传递静态数据(例如用户数据)
在主应用中注册子应用时
javascript
registerMicroApps(
[
{
name: 'vueApp',
entry: '//localhost:20000', // 默认react启动的入口是10000端口
activeRule: '/vue', // 当路径是 /react的时候启动
container: '#container', // 应用挂载的位置
loader,
// 通过 props 向子应用传递数据
props: { userInfo:{ name: 'burc', password: 'xxxxxx'}},
},
],
)
子应用接入协议
javascript
// 子应用暴露的接入协议,即生命周期钩子,可以接收 props
export async function bootstrap(props) {
}
export async function mount(props) {
}
export async function unmount(props) {
}
通过 GlobalState 实现共享状态
qiankun 提供了一个 initGlobalState
API,允许主应用和子应用之间共享全局状态,并可以响应状态的变化
主应用
javascript
import { initGlobalState } from 'qiankun';
// 初始化 state
const initialState = { user: 'admin' };
const actions = initGlobalState(initialState);
// 响应状态变化
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
// 修改全局状态
actions.setGlobalState({ user: 'guest' });
子应用
javascript
// 子应用暴露的生命周期钩子
export function mount(props) {
// 响应状态变化
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
// 修改全局状态
props.setGlobalState({ user: 'new user from sub-app' });
}
如何封装公共组件
在微前端架构中,主应用和子应用之间可以通过封装共享组件来提高代码复用性
-
发布到npm仓库
将公共组件封装成一个库, 发布到 npm 仓库,子应用都可以
install
安装并引入 -
monorepo共享包
在 monorepo 中创建一个目录(如
packages/ui-components
),专门用于存放共享的组件monorepo 架构还有一个好处,就是微应用之间可以共享依赖,比如主/子应用都需要
lodash
,那我们也只需安装一次即可关于 monorepo 架构如何搭建,以及如何共享子包?可以参考柏成这篇 vue3 + pnpm 打造一个 monorepo 项目
-
模块联邦
联邦模块是 webpack5 提供的一个新特性。通过 webpack 原生提供的
ModuleFederationPlugin
插件,可以在多个子应用间共享逻辑/资源