
前端进阶(九)-微应用框架
安装
yarn add qiankun
npm i qiankun -S注册微应用并启动:
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'react app', // app name registered
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
},
{
name: 'vue app',
entry: { scripts: ['//localhost:7100/main.js'] },
container: '#yourContainer2',
activeRule: '/yourActiveRule2',
},
]);
start();微应用分为有 webpack 构建和无 webpack 构建项目,有 webpack 的微应用(主要是指 Vue、React、Angular)需要做的事情有:
public-path.js 文件,用于修改运行时的 publicPath。history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的。public-path.js,修改并导出三个生命周期函数。webpack 打包,允许开发环境跨域和 umd 打包。qiankun微应用的加载过程
首先读取 entry 的 HTML 文件,解析其中的内容。
抽取 HTML 文件内的 JS 文件请求,使用重构后的 fetch 请求进行 JS 资源拉取,并且对返回的 JS 代码进行沙箱隔离,上下文设置。
抽取 HTML 文件内的 CSS 文件请求,使用重构后的 fetch 请求进行 CSS 资源拉取,并且对返回的 CSS 代码进行样式沙箱隔离(shadow DOM)。
在 src 目录新增 public-path.js:
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}设置 history 模式路由的 base:
简单来说,多实例场景同时又有多个不同 react 版本加载,这个问题是依赖新的沙箱机制的,具体思路就是所有子应用的全局变量变更都是在闭包中产生的,不会真正回写到 window 上,这样就能避免多实例之间的污染了
https://github.com/umijs/qiankun/pull/130#issuecomment-549847590
qiankun有三大沙箱
SanpshotSandbox:快照沙箱。
它的原理是:把主应用的 window 对象做浅拷贝,将 window 的键值对存成一个 Hash Map。之后无论微应用对 window 做任何改动,当要在恢复环境时,把这个 Hash Map 又应用到 window 上就可以了。
微应用 mount 时
modifyPropsMap 应用到微应用的全局 window,没有则跳过window key-value 快照,用于下次恢复全局环境微应用 unmount 时
window 的 key-value 和 快照 的 key-value 进行 Diff,Diff 出来的结果用于下次恢复微应用环境的依据key-value 拷贝到主应用的 window 上,以此恢复环境SnapshotSandbox 有一个问题:每次微应用 unmount 时都要对每个属性值做一次 Diff,类似这样
如果有 1000 个属性就要对比 1000 次,不是那么优雅。
LegacySandbox 的想法则是 通过监听对 window 的修改来直接记录 Diff 内容,因为只要对 window 属性进行设置,那么就会有两种情况:
addedMap 里prevMap,把新的键值存到 newMap通过 addedMap, prevMap 和 newMap 这三个变量就能反推出微应用以及原来环境的变化,qiankun 也能以此作为恢复环境的依据。
前面两种沙箱都是 单例模式 下使用的沙箱。也即一个页面中只能同时展示一个微应用,而且无论是 set 还是 get 依然是直接操作 window 对象。
为了避免真实的 window 被污染,qiankun 实现了 ProxySandbox。它的想法是:
window 的一些原生属性(如document, location等)拷贝出来,单独放在一个对象上,这个对象也称为 fakeWindowfakeWindow当微应用修改全局变量时:
windowfakeWindow 里的内容微应用获取全局变量时:
window 里拿fakeWindow 里获取这样一来连恢复环境都不需要了,因为每个微应用都有自己一个环境,当在 active 时就给这个微应用分配一个 fakeWindow,当 inactive 时就把这个 fakeWindow 存起来,以便之后再利用
https://juejin.cn/post/7148075486403362846#heading-2
https://blog.csdn.net/qq_21567385/article/details/122656654
https://github.com/Tencent/wujie/issues/247
Micro app是京东开发的微前端app框架,2021年7月开源
qiankun 还是有一些缺点的:
在micro-app之前,业内已经有一些开源的微前端框架,比较流行的有2个:single-spa和qiankun。
single-spa是通过监听 url change 事件,在路由变化时匹配到渲染的子应用并进行渲染,这个思路也是目前实现微前端的主流方式。同时single-spa要求子应用修改渲染逻辑并暴露出三个方法:bootstrap、mount、unmount,分别对应初始化、渲染和卸载,这也导致子应用需要对入口文件进行修改。因为qiankun是基于single-spa进行封装,所以这些特点也被qiankun继承下来,并且需要对webpack配置进行一些修改。
micro-app并没有沿袭single-spa的思路,而是借鉴了WebComponent的思想,通过CustomElement结合自定义的ShadowDom,将微前端封装成一个类WebComponent组件,从而实现微前端的组件化渲染。并且由于自定义ShadowDom的隔离特性,micro-app不需要像single-spa和qiankun一样要求子应用修改渲染逻辑并暴露出方法,也不需要修改webpack配置,是目前市面上接入微前端成本最低的方案。
Micro App的特点:
安装micro app
yarn add @micro-zoe/micro-app在入口处引入
// index.js
import microApp from '@micro-zoe/micro-app'
microApp.start()分配一个路由给子应用
// router.js
import { BrowserRouter, Switch, Route } from 'react-router-dom'
import MyPage from './my-page'
export default function AppRoute () {
return (
<BrowserRouter>
<Switch>
// 👇 非严格匹配,/my-page/* 都指向 MyPage 页面
<Route path='/my-page'>
<MyPage />
</Route>
</Switch>
</BrowserRouter>
)
}在应用中嵌入子应用
// my-page.js
export function MyPage () {
return (
<div>
<h1>子应用</h1>
// name(必传):应用名称
// url(必传):应用地址,会被自动补全为http://localhost:3000/index.html
// baseroute(可选):基座应用分配给子应用的基础路由,就是上面的 `/my-page`
<micro-app name='app1' url='http://localhost:3000/' baseroute='/my-page'></micro-app>
</div>
)
}子应用设置基础路由
// router.js
import { BrowserRouter, Switch, Route } from 'react-router-dom'
export default function AppRoute () {
return (
// 👇 设置基础路由,子应用可以通过window.__MICRO_APP_BASE_ROUTE__获取基座下发的baseroute,如果没有设置baseroute属性,则此值默认为空字符串
<BrowserRouter basename={window.__MICRO_APP_BASE_ROUTE__ || '/'}>
...
</BrowserRouter>
)
}在webpack-dev-server的headers中设置跨域支持
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
}
},数据通信也是 micro-app 的一大亮点,在使用层面上比 qiankun 的 EventBus 要好用一点
父传子
在基座应用的容器组件里添加一个 microApp.setData 就好了
// ReactMicroApp
const ReactMicroApp = () => {
const [microAppState, setMicroAppState] = useState(microApp.getData('react-app'));
// 发送数据给 react-app
const sendReactAppData = () => {
// setData的值必须为对象
microApp.setData('react-app', {
name: `react-app 随机数: ${Math.random()}`
})
}
return (
<div>
<h1>react-app</h1>
<button onClick={sendReactAppData}>基座 => react-app</button>
<p>子应用消息 {microAppState ? microAppState.msg : '无'}</p>
<micro-app
name='react-app'
url='http://localhost:3001/'
baseroute='/react-app'
>
</micro-app>
</div>
)
}微应用在接收数据的时候也是一样通过事件监听addDataListener来获取
// react-app App.js
function App() {
const [mainAppData, setMainAppData] = useState( window.microApp.getData());
// 监听基座数据
useEffect(() => {
if (window.microApp) {
const dataListener = (data) => {
console.log('主应用传的数据', data);
setMainAppData(data);
}
window.microApp.addDataListener(dataListener)
return () => {
window.microApp.clearDataListener()
}
}
})
return (
<div>
<p>主应用的数据:{mainAppData ? mainAppData.name : '无'}</p>
<BrowserRouter basename={window.__MICRO_APP_BASE_ROUTE__ || '/'}>
<div>
<header className={styles.header}>
<Link to="/">react-app Home</Link>
<Link to="/about">react-app About</Link>
</header>
<Routes>
<Route path="/" element={<Home/>}/>
<Route path="/about" element={<About/>}/>
</Routes>
</div>
</BrowserRouter>
</div>
);
}子传父时微应用发送数据也是一行 window.microApp.dispatch 就 OK 了,而且入参必须也为对象
// react-app App.js
function App() {
const [mainAppData, setMainAppData] = useState( window.microApp.getData());
useEffect(() => {
if (window.microApp) {
const dataListener = (data) => {
console.log('主应用传的数据', data);
setMainAppData(data);
}
window.microApp.addDataListener(dataListener)
return () => {
window.microApp.clearDataListener()
}
}
})
// 发送数据给基座
const sendToMain = () => {
window.microApp.dispatch({msg: `我是 react-app 随机数 ${Math.random()}`})
}
return (
<div>
<p>主应用的数据:{mainAppData ? mainAppData.name : '无'}</p>
<button onClick={sendToMain}>给基座应用发送</button>
<BrowserRouter basename={window.__MICRO_APP_BASE_ROUTE__ || '/'}>
<div>
<header className={styles.header}>
<Link to="/">react-app Home</Link>
<Link to="/about">react-app About</Link>
</header>
<Routes>
<Route path="/" element={<Home/>}/>
<Route path="/about" element={<About/>}/>
</Routes>
</div>
</BrowserRouter>
</div>
);
}基座监听的方式有点hacky
虽然没有用到 jsxCustomEvent,但是也一定要 import 进来,且不能把注释给干掉!
// ReactMicroApp.js
import microApp from '@micro-zoe/micro-app'
import {useState} from "react";
/** @jsxRuntime classic */
/** @jsx jsxCustomEvent */
import jsxCustomEvent from '@micro-zoe/micro-app/polyfill/jsx-custom-event'
const ReactMicroApp = () => {
const [microAppState, setMicroAppState] = useState(microApp.getData('react-app'));
const sendReactAppData = () => {
microApp.setData('react-app', {
name: `react-app 随机数: ${Math.random()}`
})
}
const onDataChange = (e) => {
console.log('react-app onDataChange', e);
console.log('react-app 数据', e.detail.data);
setMicroAppState(e.detail.data);
}
return (
<div>
<h1>react-app</h1>
<button onClick={sendReactAppData}>基座 => react-app</button>
<p>子应用消息 {microAppState ? microAppState.msg : '无'}</p>
<micro-app
name='react-app'
url='http://localhost:3001/'
baseroute='/react-app'
onDataChange={onDataChange}
>
</micro-app>
</div>
)
}渲染模式
使用 globalAssets 共享资源:
// index.js
import microApp from '@micro-zoe/micro-app'
microApp.start({
globalAssets: {
js: ['js地址1', 'js地址2', ...], // js地址
css: ['css地址1', 'css地址2', ...], // css地址
}
})或者使用global属性
<link rel="stylesheet" href="xx.css" global>
<script src="xx.js" global></script>还有对资源的过滤
<link rel="stylesheet" href="xx.css" exclude>
<script src="xx.js" exclude></script>
<style exclude></style>micro app提供了不同在微应用的生命周期可以执行不同的命令
/** @jsxRuntime classic */
/** @jsx jsxCustomEvent */
import jsxCustomEvent from '@micro-zoe/micro-app/polyfill/jsx-custom-event'
const App = () => {
return (
<micro-app
name='xx'
url='xx'
onCreated={() => console.log('micro-app元素被创建')}
onBeforemount={() => console.log('即将被渲染,只在初始化时执行一次')}
onMounted={() => console.log('已经渲染完成,只在初始化时执行一次')}
onAfterhidden={() => console.log('已卸载')}
onBeforeshow={() => console.log('即将重新渲染,初始化时不执行')}
onAftershow={() => console.log('已经重新渲染,初始化时不执行')}
onError={() => console.log('渲染出错')}
/>
)
}应用之间跳转
隔离
JS 方面使用 Proxy 拦截了用户全局操作的行为,防止对 window 的访问和修改,避免全局变量污染。
CSS 方面有两种隔离:
元素隔离方面,micro-app 模拟实现了类似 ShadowDom 的功能,元素不会逃离 <micro-app> 元素边界,子应用只能对自身的元素进行增、删、改、查的操作。
keepAlive
保持微应用的状态 Keep-Alive
<micro-app name='xx' url='xx' keep-alive></micro-app>prefetch
qiankun侵入式较强
基于webpack(repack)和模块联邦实现微前端
在微前端架构中,微应用被打包为模块,但浏览器不支持模块化,需要使用 systemjs 实现浏览器中的模块化。
systemjs 是一个用于实现模块化的 JavaScript 库,有属于自己的模块化规范。
在开发阶段我们可以使用 ES 模块规范,然后使用 webpack 将其转换为 systemjs 支持的模块。
// webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = (env) => {
return {
mode: "development",
output: {
filename: "index.js",
path: path.resolve(__dirname, "dist"),
// 注意,这个libraryTarget是必须的
libraryTarget: env.production ? "system" : "",
},
module: {
rules: [
{
test: /.js$/,
use: { loader: "babel-loader" },
exclude: /node_modules/,
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/index.html",
})
],
// 这里不为空就行,如若是vue,改为[vue]即可
externals: env.production ? ["react", "react-dom"] : [],
};
};dist文件夹中会有两个文件,一个index.js,另一个是index.html
// index.js
System.register(
["react-dom", "react"],
function (__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {
var __WEBPACK_EXTERNAL_MODULE_react_dom__ = {};
var __WEBPACK_EXTERNAL_MODULE_react__ = {};
Object.defineProperty(__WEBPACK_EXTERNAL_MODULE_react_dom__, "__esModule", {
value: true,
});
Object.defineProperty(__WEBPACK_EXTERNAL_MODULE_react__, "__esModule", {
value: true,
});
return {
setters: [
function (module) {
Object.keys(module).forEach(function (key) {
__WEBPACK_EXTERNAL_MODULE_react_dom__[key] = module[key];
});
},
function (module) {
Object.keys(module).forEach(function (key) {
__WEBPACK_EXTERNAL_MODULE_react__[key] = module[key];
});
},
],
execute: function() { 这里是项目的打包后可执行的代码 },
}
},
);经过很轻微地简化,就是如上代码了,这个文件就一个register函数,这就是SystemJs的要求,这个脚本的内容改为被一个register函数包裹,然后第一个参数就是不被打包进来但通过特定方式进行加载(下文会讲到这个特定的加载方式),第二个参数一个函数,主要用来返回我们的项目代码,其中WEBPACKEXTERNALMODULEreactdom和WEBPACKEXTERNALMODULE_react则分别对应第一个参数中的需要特定方式加载回来的模块。
结合改造后的html文件和js文件,整个流程可以总结为以下这几步。
再往细了的讲,我们调用setters,其实就是将需要特定方式加载的包的内容保存到一个execute能调用到的地方。
我们把这种加载机制往微前端方向去思考下,我们将html文件和excute类比为基座,将额外加载的react和react-dom比作子应用,excute调用react/react-dom类比做渲染子应用的内容,是不是觉得微前端通了?事实上,single-spa正是这么做的。
腾讯方案:wujie
无界微前端方案基于 webcomponent 容器 + iframe 沙箱,能够完善的解决适配成本、样式隔离、运行性能、页面白屏、子应用通信、子应用保活、多应用激活、vite 框架支持、应用共享等用户的核心诉求
上述微前端方案问题:
qiankun 方案
qiankun 方案是基于 single-spa 的微前端方案。
特点
不足
micro-app 是基于 webcomponent + qiankun sandbox 的微前端方案。
特点
不足
EMP 方案是基于 webpack 5 module federation 的微前端方案。
特点
不足
wujie不支持三层嵌套:https://github.com/Tencent/wujie/issues/859