1.微前端
1.1微前端的优势
- 无技术栈限制:主框架不限制接入应用的技术栈,子应用具备完全自主权
- 独立开发,独立部署,子应用的仓库独立,前后端可独立进行开发,部署完成后主框架自动完成同步更新
- 独立运行时,每个子应用之间状态隔离,运行时状态不共享
微前端架构解决方案大概分成两类场景
- 单实例:即同一时刻,只要一个子应用被展示,子应用具备一个完整的应用生命周期,通常基于url的变化来做子应用的切换
- 多实例:同一时刻可展示多个字应用。通常使用web components方案来做子应用封装,子应用更像是一个业务组件而不是应用。
1.2实现微前端方案
2018 single-spa 是一个用于前端微服务化的 javascript 前端解决方案,(本身没有处理样式隔离,js 执行隔离)实现了路由的劫持和应用加载
2019 qiankun 基于 single-spa 提供了更加开箱即用的 api(single-spa+sandbox+import-html-entry)做到了,技术战无关,并且接入简单
总结:子应用可以独立部署,运行时动态加载主子应用完全解耦,技术栈无关,靠的是协议接入(子应用必须导出 bootstrap,mount,unmount 方法)
2.single-spa
2.1解决的问题
single-spa 实现了路由劫持和应用加载的功能。 Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。 使用 single-spa 进行前端架构设计可以带来很多好处,例如:
- 在同一页面上使用多个前端框架而不用刷新页面] (react,vue等 )
- 独立部署每一个单页面应用
- 新功能使用新框架,旧的单页应用不用重写可以共存
- 改善初始加载时间,延迟加载代码
2.2实践
直接使用命令创建项目:
vue create main-vue(主应用)
vue create child-vue(子应用)
子应用内部
子应用需要抛出这几个方法 bootstrap,mount,unmount
single-spa 子应用需要导入一些方法
因为是在vue项目中,所以需要安装single-spa-vue
npm install single-spa-vue.
src/main.js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import singleSpaVue from 'single-spa-vue';
Vue.config.productionTip = false;
const appOptions = {
el: '#app', //挂载到父应用中的id为vue的标签中
router,
render: (h) => h(App),
};
const vueLifeCycle = singleSpaVue({
Vue,
appOptions,
});
// 如果是父应用引用我
if (window.singleSpaNavigate) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = 'http://localhost:10000/';
}
if (!window.singleSpaNavigate) {
delete appOptions.el;
new Vue(appOptions).$mount('#app');
}
// 协议接入 子应用定义了协议,父应用调用这些方法
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
需要父应用加载子应用,将子应用打包成一个个lib去给父应用使用
vue.config.js
const { defineConfig } = require('@vue/cli-service');
module.exports = defineConfig({
transpileDependencies: true,
configureWebpack: {
output: {
library: 'singleVue',
libraryTarget: 'umd',
},
devServer: {
port: 10000,
},
},
});
router/index.js
const router = new VueRouter({
mode: 'history',
// base: process.env.BASE_URL,
base: '/vue',
routes,
});
主应用内部
src/main.js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import { registerApplication, start } from 'single-spa';
Vue.config.productionTip = false;
async function loadScript(url) {
return new Promise((resolve, reject) => {
let script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
registerApplication(
'myVueApp',
async () => {
console.log('加载模块');
await loadScript(`http://192.168.31.83:10000/js/chunk-vendors.js`);
await loadScript(`http://192.168.31.83:10000/js/app.js`);
return window.singleVue; // bootstrap mount unmount
},
(location) => location.pathname.startsWith('/vue'),
// 用户切换到/vue的路径下,我需要加载刚才定义的子应用
);
start();
new Vue({
router,
render: (h) => h(App),
}).$mount('#app');
App.js
<template>
<div id="app">
<router-link to="/vue">加载vue应用</router-link>
</div>
</template>
<script>
export default {
name: 'App',
};
</script>
<style></style>
router/index.js
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes,
});
2.3页面效果
- 子应用页面:
-
主应用页面:
-
主应用点击按钮后:
3.qiankun
样式隔离
<body>
<p>hello word</p>
<div id="shadow"></div>
<script>
let shadowDOM = shadow.attachShadow({mode:'closed'});// 外界无法访问 shadowdom
let pElem = document.createElement('p');
pElem.innerHTML = 'hell';
let styleElm = document.createElement('style');
styleElm.textContent = `p{color:red}`;
shadowDOM.appendChild(styleElm);
shadowDOM.appendChild(pElem);
document.body.appendChild(pElem)
</script>
</body>
qiankun 可以确保子应用之间样式隔离,主应用跟子应用之间的样式冲突需要手动解决,以 antd 为例,主应用可以通过设置 prefixCls 样式前缀的方式避免冲突
挂在body上的话,是没有样式隔离的
document.body.appendChild(pElem)
如果上面的代码加上这句:
js隔离
- 单应用-快照沙箱
- 多应用- proxy代理沙箱
-
如果应用加载刚开始的时候加载a应用 window.a 跳转到b应用 (window.a也可以被获取到)
-
单个应用切换,怎么实现隔离,a切换到b,创造一个干净的环境给子应用使用,
-
当切换时,可以选择丢弃属性和恢复属性 js沙箱 和 proxy
//第一种,快照沙箱,之前拍一张,之后拍一张,做对比,将区别保存保存起来,再回到一年前
<body>
<!-- <p>hello word</p> -->
<div id="shadow"></div>
<script>
class SnapshotSandbox{
constructor () {
this.proxy = window; // window属性
this.modifyPropsMap = {};// 记录在window上的修改
this.active(); // 激活状态
}
active() {
this.windowSnapshot = {};// 拍照
for(const prop in window) { // 遍历window的属性
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop]; // 拍个照
}
}
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p]; // 恢复之前保存的属性
})
}
inactive() { // 失活状态
// 非激活状态,要把属性,有变化的地方先存储起来
for(const prop in window) {
if (window.hasOwnProperty(prop)) {
if (window[prop] !== this.windowSnapshot[prop]) {
this.modifyPropsMap[prop] = window[prop]; // 不相同的地方,存储起来
window[prop] = this.windowSnapshot[prop]; // 此时的状态,拍一个快照
}
}
}
}
}
let snadbox = new SnapshotSandbox();
// 应用的运行 从开始到结束,切换后不会影响到全局
((window) => {
window.a = 1;
window.b = 2;
console.log(window.a,window.b);
snadbox.inactive();
console.log(window.a,window.b);
snadbox.active();
console.log(window.a,window.b);
})(snadbox.proxy) // snadbox.proxy 就是window
</script>
</body>
如果是多应用就不能使用这种方法了 需要使用es6 proxy
代理沙箱,可以实现多应用沙箱,把不同的应用用不同的代理
实践
vue 主应用 qiankun-base
src/main.js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import Element from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
// import './styles/element-variables.scss';
import { registerMicroApps, start } from 'qiankun';
Vue.config.productionTip = false;
Vue.use(Element);
const apps = [
{
name: 'vueApp',
entry: '//localhost:10000', //默认会加载这个html,解析里面的js 动态执行
// 子应用需要解决跨域
container: '#vue', // 容器
activeRule: '/vue', // 激活路径
props: { a: 1 },
},
{
name: 'reactApp',
entry: '//localhost:20000', //默认会加载这个html,解析里面的js 动态执行
// 子应用需要解决跨域
container: '#react',
activeRule: '/react',
},
];
registerMicroApps(apps); // 注册应用
start(); // 开启
// 点击按钮再加载子应用
// start({
// prefetch: false, // 是否开启预加载
// });
new Vue({
router,
render: (h) => h(App),
}).$mount('#app');
src/App.js
<template>
<div>
<!-- 基座可以放自己的路由 -->
<el-menu :router="true" mode="horizontal">
<el-menu-item index="/">Home</el-menu-item>
<el-menu-item index="/vue">vue</el-menu-item>
<el-menu-item index="/react">react</el-menu-item>
</el-menu>
<router-view></router-view>
<!-- 基座可以放自己的路由,也可以引用其他子应用 -->
<div id="vue"></div>
<div id="react"></div>
</div>
</template>
vue 子应用 qiankun-vue
src/main.js
/* eslint-disable */
import Vue from 'vue';
import App from './App.vue';
import router from './router';
// Vue.config.productionTip = false
// new Vue({
// router,
// render: h => h(App)
// }).$mount('#app')
let instance = null;
function render() {
instance = new Vue({
router,
render: (h) => h(App),
}).$mount('#app'); // 这个是挂载到自己的html,基座会拿到挂载后的html中
}
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
// 子组件的协议
// eslint-disable-next-line
export async function bootstrap(props) {}
export async function mount(props) {
console.log('vue 启动');
render(props);
}
// eslint-disable-next-line
export async function unmount(props) {
console.log('vue 卸载');
instance.$destroy();
}
// 1.49
src/App.js
<template>
<div id="app">
<nav>
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</nav>
<router-view/>
</div>
</template>
vue.config.js
module.exports = {
devServer: {
port: 10000,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
configureWebpack: {
output: {
library: 'vueApp',
libraryTarget: 'umd',
},
},
};
router/index.js
const router = new VueRouter({
mode: 'history',
// base: process.env.BASE_URL,
base: '/vue',
routes,
});
react 子应用 qiankun-react
src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
function render() {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap(props) {}
export async function mount(props) {
console.log('react 启动');
render();
}
// eslint-disable-next-line
export async function unmount(props) {
console.log('react 卸载');
root.unmount();
}
reportWebVitals();
src/App.js
import logo from './logo.svg';
import './App.css';
import { BrowserRouter, Route, Link, Routes } from 'react-router-dom';
function App() {
return (
<BrowserRouter basename='/react'>
<Link to='/'>首页</Link>
<Link to='/about'>关于</Link>
<Routes>
<Route
path='/'
exact
element={<div>hhhhh王胖子主页</div>}></Route>
<Route
path='/about'
exact
element={<div>about页面哦</div>}></Route>
</Routes>
</BrowserRouter>
);
}
export default App;
按照react项目中,修改webpack配置的插件: react-app-rewired
并修改package.json配置:
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
},
新增:config-overrides.js文件
module.exports = {
webpack: (config) => {
config.output.library = 'reactApp';
config.output.libraryTarget = 'umd';
config.output.publicPath = 'http://localhost:20000/';
return config;
},
deServer: (configFunction) => {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.port = '20000';
config.headers = {
'Access-Control-Allow-Origin': '*',
};
return config;
};
},
};
新增.env文件:
PORT = 20000;
WDS_SOKECT_PORT = 20000;
页面效果
vue子应用页面:
react子应用页面:
vue主应用页面:
vue -> react:
react -> vue:
4. 微前端架构实践中的问题
主框架的定位:导航路由+资源加载框架,而要实现这样一套架构需要解决一些问题
4.1 路由系统及future state
我们在一个实现了微前端内核的产品中,正常访问一个子应用的页面时,可能会有这样一个链路:
- 访问 app.pay.com
- 点击导航中的某个子产品的链接app.pay.com/subApp
- subApp渲染并默认redirect到list页面app.pay.com/subApp/list
- 查看列表某一项信息app.pay.com/subApp/:id/…
此时浏览器的地址可能是app.pay.com/subApp/123/… ,想象下此时我们手动刷新一下浏览器,会发生什么?
由于子应用要都是lazy load的,当浏览器重新刷新时,主框架的资源会被重新加载,同时异步加载子应用的静态资源,由于此时主应用的路由系统已经激活,但子应用的资源可能还没有完全加载完毕,从而导致路由注册表里面发现没有能匹配子应用的/subApp/123/detail的规则,这时候会导致条notfound或者直接路由报错
这个问题在所有lazy load方式加载子应用的方案中都会碰到,future state
解决的思路,我们需要设计这样一套路由机制:
主框架配置子路由的路径为:
subApp:{
url:'/subAp/**',
entry:'./subApp.js'
}
则当浏览器的地址为/subAp/abc时,
- 框架需要先加载entry资源,待entry资源加载完毕,
- 确保子应用的路由系统注册进框架之后,
- 再去由子应用的路由系统接管url change事件,
- 同时在子应用切出时,主框架需要触发相应的destroy事件,
- 子应用监听到该事件时,调用自己的卸载方法卸载应用
如 React 场景下 destroy = () => ReactDOM.unmountAtNode(container)。
要实现这样一套机制,可以自己去劫持url change时间从而实现自己的路由系统,也可以基于社区react-route在v4之后实现的,需要复写一部分路由发现逻辑single-spa
app entry
解决了路由的问题后,主框架和子应用集成的方式,也会成为一个需要关注的技术决策
构建时组合 VS 运行时组合
微前端架构模式下,子应用打包的方式由两种
构建时打包:
子应用通过package registry (也可以是npm package) 的方式,与主应用一起打包发布
-
优点:主应用和子应用之间可以做答辩优化,依赖共享
-
缺点:主子应用之间产品工具链耦合,工具链也是技术栈的一部分
运行时打包:
子应用自己构建打包,主应用运行时动态加载子应用资源
-
优点:完全解耦,子应用完全和技术栈无关
-
缺点:多出运行时的复杂度和overhead
要真正实现技术栈无关和独立部署两个目标,大部分场景下需要使用运行时加载的方案。
js entry VS html entry
-
js entry :子应用将资源打成一个entry script,比如single-spa的example中的方式。但是这个方式限制很多,比如要求子应用所以的资源打包到一个js bundle,包括css图片等,除了打包出来的体检庞大,资源的并行价值等特性也无法利用上
-
html entry: 更加灵活,直接将子应用打出来的html 作用入口,主框架可以通过
fetch html的方式获取子应用的静态资源(涉及到跨域,需要配置),同时将html document作为子节点塞到主框架的容器中,这样不仅可以极大减少主应用的接入成本,子应用的开发方式和打包方式也基本不用跳转,而且解决子应用之间样式隔离的问题。
大概整理了一下这段时间学习微前端的一些笔记📒