本文首发于:https://github.com/bigo-frontend/blog/ 欢迎关注、转载。
微前端的实践分享
需求场景
Brpc服务管理平台想集成开源项目Jaeger(分布式链路追踪系统)的功能,搭建自己的调用链平台,方便使用Brpc框架用户查询自己的服务调用链,以及在此基础上,二次开发,接入公司分布式日志系统的功能,用户可以在本平台便捷地查看业务trace及业务日志,掌握相关信息,快速定位问题。
为什么用微前端
为什么不自行开发
需求与开源项目现成的功能基本一致,简单估了下开发时间,全部自己开发至少需要一个月。
为什么不用iframe
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中。
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
https://www.yuque.com/kuitos/gky7yw/gesexv
为什么不直接搬运代码
项目也是基于react与antd开发的。但是如果直接搬运代码,代码很容易水土不服,包括redux使用方式,api的拦截方式等,2个系统都自成一套;另一方面不方便后期更新,假设开源项目做了重大更新,而我们又需要他的功能,那又要搬运代码。
使用微前端-qiankun
基于以上问题,微前端是相对比较好的解决方案,微前端的理念如下:
- 高隔离性:主应用与子应用,各自运行在沙箱中,互不影响,并且应用的技术栈,也可不相同,做到技术栈无关。
- 低耦合度:各个应用,可独立开发、部署、访问。
- 高扩展性:子应用拔插方便,可灵活扩展。
- 低侵入性:无论是主应用还是子应用,侵入的代码量都很少,接入成本低。
我用的是qiankun,该方案优点如下:
- jaeger与brpc服务管理平*立可以开发与部署,互不影响。
- jaeger做为子项目嵌入到brpc服务管理平台,可以共用cas。
- iframe存在的问题,微前端都能很好得解决。例如应用通信,qiankun自带了应用通讯API;资源缓存,资源只需加载一次;使用浏览器的前进、后退功能不会导致状态丢失等。
- 节省开发时间,使用微前端,基于开源二次开发,从调研方案到实现落地,总时间也就一周多一点。
qiankun 接入
看文档,相对常规的接入方式,是一个空白的项目作为主应用,也就是常说的基座应用,通过监听路由切换,激活不同的子应用。
而我这个需求场景相对特殊,我把Brpc服务管理平台作为主应用,jaeger作为微应用,通过监听路由切换,激活jaeger调用链平台,嵌入到主应用的内容渲染区域。
核心API
// 注册微应用
registerMicroApps(apps, lifecycles);
// apps - Array<RegistrableApp> - 必选,微应用的一些注册信息
// lifeCycles - LifeCycles - 可选,全局的微应用生命周期钩子
// 添加全局异常捕获
addGlobalUncaughtErrorHandler(errHandle)
// 启动qiankun
start()
详细API:qiankun-API
主应用配置
- 注册微应用,并启动qiankun。
// 配置文件
import * as NProgress from "nprogress";
export const TRACE_JAEGER_ENTRY =
process.env.NODE_ENV === "development"
? "http://localhost:3001"
: `${window.location.origin}/trace`;
export const TRACE_JAEGER_NAME = "micro-app-jaeger";
export const MAIN_CONTENT_AREA_ID = "main-app-content";
export const MICRO_APPS = [
{
name: TRACE_JAEGER_NAME,
entry: TRACE_JAEGER_ENTRY,
container: `#${MAIN_CONTENT_AREA_ID}`,
activeRule: "/jaeger",
},
];
export const LIFE_CYCLES = {
// qiankun 生命周期钩子 - 微应用加载前
beforeLoad: () => {
// 加载微应用前,加载进度条
NProgress.start();
return Promise.resolve();
},
// qiankun 生命周期钩子 - 微应用挂载后
afterMount: () => {
// 加载微应用前,进度条加载完成
NProgress.done();
return Promise.resolve();
},
};
// index.tsx
// 注册微应用
registerMicroApps(MICRO_APPS, LIFE_CYCLES);
/**
* 添加全局的未捕获异常处理器
*/
addGlobalUncaughtErrorHandler((event: Event | string) => {
const { message: msg } = event as any;
// 加载失败时提示
if (msg && msg.includes("died in status LOADING_SOURCE_CODE")) {
message.error("微应用加载失败,请检查应用是否可运行!");
}
});
// 启动qiankun
start();
- 在内容区域新增微应用挂载点。
// App.tsx
const App: React.FC<IProps> = ({ history }) => {
return (
<Layout style={{ maxHeight: "100vh" }}>
<Sider collapsed={collapsed}>
<Menu
theme="dark"
selectedKeys={[menuKey]}
defaultOpenKeys={menuOpenKeys}
mode="inline"
onClick={onClikcMenus}
>
主应用menu菜单栏
</Menu>
</Sider>
<Layout className="site-layout">
<Header className="site-layout-header">header</Header>
<Content>
{/** 组件渲染区域 */}
<div className="site-layout-content">
{/** 微前端挂载点 */}
<div id={MAIN_CONTENT_AREA_ID} />
<Switch>
<Route exact path="/" component={Service} />
</Switch>
</div>
</Content>
<Footer className="site-layout-footer">
{`BIGO © 2020~${new Date().getFullYear()} 基础架构团队`}
</Footer>
</Layout>
</Layout>
);
};
export default withRouter(App);
- antd 样式隔离
微应用之间的样式是隔离的,但是默认情况下,主应用与微应用有冲突,需要手动解决,解决方案:主应用样式配置prefixCls前缀。
antd组件DOM节点classname插入前缀:
// index.tsx
//设置 Modal、Message、Notification rootPrefixCls。(4.13.0+)
ConfigProvider.config({
prefixCls: "main-app",
});
// 设置其余组件、icon 的rootPrefixCls。
<ConfigProvider
prefixCls="main-app"
iconPrefixCls="main-app"
locale={zhCN}
>
<App />
</ConfigProvider>
运行时或打包,给样式文件中的classname添加前缀:
// craco.config.js,主应用使用 craco,注入webpack配置。
// 新增配置
const CracoLessPlugin = require("craco-less");
plugins: [
{
plugin: CracoLessPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
javascriptEnabled: true,
modifyVars: {
"@ant-prefix": "main-app",
"@iconfont-css-prefix": "main-app"
},
},
},
},
},
],
子应用配置
- 修改root节点id。
// index.html,修改root节点id,防止冲突
<div id="micro-app"></div>
- 配置运行时public-path,保证微应用动态载入的 脚本、样式、图片 等地址正确。
// src目录下,新建 public-path.js 文件
/* eslint-disable camelcase */
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
- 修改应用渲染方式,新增微应用生命周期
// index.js
// 在微应用顶部引入publicPath
import './public-path';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import ReactDOM from 'react-dom';
import JaegerUIApp from './components/App';
const UI_ROOT_ID = "#micro-app";
function render(props) {
const { container } = props;
ReactDOM.render(
<BrowserRouter>
<JaegerUIApp />
</BrowserRouter>,
container ? container.querySelector(UI_ROOT_ID) : document.querySelector(UI_ROOT_ID)
);
}
// 支持独立访问
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
/**
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() { }
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
render(props);
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount(props) {
const { container } = props;
if(container){
ReactDOM.unmountComponentAtNode(container.querySelector(UI_ROOT_ID));
}
}
- webpack配置
// 微应用 webpack 配置// config-overrides.js,微应用使用的是customize-cra,注入webpack配置。// 新增配置function webpack(_config) { let config = _config; // 微应用包,`${packageName}-[name]`.func()调用微应用方法。 config.output.library = `${packageName}-[name]`; //umd通用规范,将 library 暴露为所有的模块定义下都可运行的方式。它将在 CommonJS, AMD 环境下运行,或将模块导出到 global 下的变量。 config.output.libraryTarget = 'umd'; // 用于异步加载(async loading)chunk的JSONP函数 config.output.jsonpFunction = `webpackJsonp_${packageName}`; // 当输出为 library 时,尤其是当 libraryTarget 为 'umd'时,此选项将决定使用哪个全局对象来挂载 library。 config.output.globalObject = 'window'; return config;}const devServerConfig = () => config => { return { ...config, headers: { // 开发环境跨域处理 'Access-Control-Allow-Origin': '*', }, };};module.exports = { webpack, devServer: overrideDevServer(devServerConfig())};
部署
nginx配置主应用、子应用路径如下:
server {
listen 80;
server_name domain.test.com;
location / {
root /path/service_fe;
try_files $uri $uri/ /index.html;
index index.html;
}
location /trace {
alias /path/jaeger_fe/;
try_files $uri $uri/ /index.html;
index index.html;
}
}
qiankun 原理
qiankun是基于single-spa开发的,single-spa 约定了应用、包的生命周期,并调度着应用、包生命周期周转(路由匹配机制)的流程。至于应用、包的加载函数、路由匹配策略及其生命周期函数则由开发者手动实现。qiankun在 single-spa 的基础上优化了使用体验并扩展了功能,提供了开箱即用的API,使接入更简单。
应用接入原理
使用import-html-entry
,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。子应用激活时,调用importEntry方法:
const { template, execScripts, assetPublicPath } = await importEntry( entry, importEntryOpts);
其中返回了三个字段
- template:被处理后的 html 模板字符串,外联的样式文件被替换为内联样式,然后通过DOM操作将template添加到主应用。
- execScripts:执行 execScripts 方法得到微应用导出的生命周期方法,并且还顺便解决了 JS 全局污染的问题,因为执行 execScripts 方法的时候可以通过 proxy 参数指定 JS 的执行上下文。execScripts(scripts, fetch, error) 函数执行时,获取指定的所有外部脚本的内容也就是scripts,并设置每个脚本的执行上下文global,然后通过 eval 函数运行。
- assetPublicPath:静态资源的 baseURL。
隔离原理
css隔离
qiankun 提供了 3 种模式来实现不同效果的样式隔离:
-
动态载入样式表(默认) :这种模式的做法是直接将子应用的样式全部直接加载到子应用挂载的 DOM 节点内,这样在卸载子应用的时候,移除该 DOM 节点,就可以自动去掉子应用使用的 css。默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。
-
Shadow DOM 样式隔离 :配置
sandbox.strictStyleIsolation = true
,这种模式表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。 -
Scoped CSS 样式隔离(实验性方案) :代码中的配置为
sandbox.experimentalStyleIsolation = true
。核心原理:改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,与vue scoped类似,被隔离的应用的样式表会被特定规则改写成如下模式:
假设应用名是 react16
app-main { font-size: 14px;}
div[data-qiankun-react16] .app-main { font-size: 14px;}
为什么 qiankun 默认的是第 1 种方案:
如果使用第2、3种方案,一些组件可能会越过 Shadow Boundary 到外部 Document Tree 插入节点,而这部分节点的样式就会丢失;比如 antd 的 Modal
就会渲染节点至 ducument.body
,引发样式丢失;但另外一些其他的组件库,或者你的一些代码也会遇到同样的问题,需要额外处理。
部分对比效果如下:
js隔离
qiankun框架为了实现js隔离,提供了三种不同场景使用的沙箱,分别是 snapshotSandbox
、legacySandbox
、proxySandbox
。
legacySandbox
、proxySandbox
都是基于ES6的Proxy实现的。
- 快照沙箱(snapshotSandbox):IE 浏览器默认使用此沙箱。因为 IE 不支持
Proxy
。此沙箱的原理是在子应用启动时,创建基座 window 的快照,存到一个变量中,子应用的 window 操作实质上是对这个变量操作。SnapshotSandbox
同样会将子应用运行期间的修改存储至modifyPropsMap
中,以便在子应用创建和销毁时还原。优缺点:会污染window。 - 代理沙箱(legacySandbox):
legacySandbox
设置了三个参数来记录全局变量,分别是记录沙箱新增的全局变量addedPropsMapInSandbox
、记录沙箱更新的全局变量modifiedPropsOriginalValueMapInSandbox
、持续记录更新的(新增和修改的)全局变量,用于在任意时刻做snapshot的currentUpdatedPropsValueMap
。优缺点:同样会对window造成污染,但是性能比快照沙箱好,不用遍历window对象。 - 代理沙箱(proxySandbox):激活沙箱后,每次对
window
取值的时候,先从自己沙箱环境的fakeWindow
里面找,如果不存在,就从rawWindow
(外部的window
)里去找;当对沙箱内部的window
对象赋值的时候,会直接操作fakeWindow
,而不会影响到rawWindow
。优缺点:不会影响到window。
通信、路由原理
通信原理
qiankun 内部提供了 initGlobalState
方法用于注册 MicroAppStateActions
实例用于通信,该实例有三个方法,分别是:
-
setGlobalState
:设置globalState
新的值时,内部将执行浅比较,如果检查到globalState
发生改变则触发通知,通知到所有的观察者函数。 -
onGlobalStateChange(state, prev)
:注册观察者函数,响应globalState
变化,在globalState
发生改变时触发该观察者函数。 -
offGlobalStateChange
:取消观察者函数,该实例不再响应globalState
变化,微应用 umount 时会默认调用。
我们从上图可以看出,我们可以先注册观察者到观察者池中,然后通过修改 globalState
可以触发所有的观察者函数,从而达到组件间通信的效果。
路由原理
single-spa内部会对浏览器的路由进行劫持,所有的路由方法和路由事件都确保先进入single-spa进行统一调度。对于 qiankun 来说,路由劫持是在 single-spa 上去做的,而 qiankun 给我们提供的能力,主要是子应用的加载和沙箱隔离。
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
总结微前端适用场景
应用解耦拆分
将巨石应用拆分成若干可独立开发、部署、访问的应用。
应用聚合
将若干功能关联的应用,对入口进行聚合,使应用功能更紧密,操作流程更顺畅,不必在浏览器不同tab间来回跳转。
应用增量重构
假设有个巨石应用,想按功能模块进行拆分重构,可按功能模块开发子应用,开发完插入到主应用中,对原有模块进行替换。
引用
万字长文+图文并茂+全面解析微前端框架 qiankun 源码 - qiankun 篇
以上,如有错误,欢迎指正~
欢迎大家留言讨论,祝工作顺利、生活愉快!
我是bigo前端,下期见。