微前端概念
什么是微前端
微前端可以把大型项目拆分成不同类型的子项目,子项目可以单独发布上线,解决项目中多个技术时无法进行融合。每个子项目是独立的,又能都挂载到主项目插座上。核心=>大项目拆分,拆后再合并。
为什么需要微前端?
- 不同团队之间,技术栈可能不同
- 项目开始周期点不同,技术栈不同,老项目代码无法重构
- 每个团队负责的项目都想独立开发,单独部署。
为了解决以上这些问题,就出现了微前端概念。可以将一个应用划分为多个子应用,将子应用打包成独立的lib,然后挂载到window上。当路径切换时,加载不同的子应用。
怎么做微前端
最开始诞生的框架single-spa。single-spa是前端微服务化JavaScript解决方案,实现路由劫持和应用加载(没有实现样式隔离和js运行隔离)。
后来qiankun框架诞生,基于single-spa框架实现了开箱即用的api。(single-spa + sandbox + import-html-entry)
子应用可以独立构建,运行时动态加载。主子应用完全解耦,技术栈无关,靠的是协议接入(子应用必须导出 bootstrap、mount、unmount方法)
应用通信:
- 基于URL来进行数据传递,但是传递消息能力弱
- 基于CustomEvent实现通信
- 基于props主子应用间通信
-
公共依赖:
CDN - externals
- webpack联邦模块
变量隔离
快照沙箱
通过将window对象创建成快照保存下来。
缺点:性能不佳,需要将window的所有属性都进行保存。不支持多实例代理
可以使用于旧版本浏览器,做方案降级备选。class SnapShotSandBox {
constructor() {
// 将window保存到代理对象上
this.proxy = window;
this.snapshot = new Map();
// 自动调用active方法
this.active();
}
// 沙箱激活
active() {
for (const key in window) {
this.snapshot[key] = window[key];
}
}
// 沙箱销毁
deactive() {
for (let key in window) {
if (window[key] !== this.snapshot[key]) {
// 还原保存的原来属性数据
window[key] = this.snapshot[key];
}
}
}
}
代理沙箱
使用es6的新对象Proxy,代理window对象。let defaultValue = {};
class ProxySandbox {
constructor() {
this.proxy = null;
this.active();
}
active() {
this.proxy = new Proxy(window, {
get(target, key) {
if (typeof target[key] === "function") {
return target[key].bind(target);
}
return defaultValue[key] || target[key];
},
});
}
deactive() {
defaultValue = {}
}
}
样式隔离
- css-modules:将css分模块
- shadow dom:新语法,将dom挂载到shadow-root节点下
- minicssExtract:webpack插件
- css-in-js
子应用之间通信,观察者模式
利用window的全局对象进行调用。可以实现子应用之间的互相通信。class CustomEvent{
// 事件监听
on(eventName, cb){
window.addEventListener(name, (e)=>{
cb(e)
})
}
// 事件触发
emit(eventName, data){
const event = new CustomEvent(eventName, data);
window.dispatchEvent(eventName)
}
}
single-spa
构建子应用
构建spa-vue子应用
使用vue-cli创建项目,默认选择路由router功能vue create spa-vue
配置main.js
//首先安装single-spa-vue。yarn add single-spa-vue
import { createApp, h } from "vue";
import App from "./App.vue";
import router from "./router";
import singleSpaVue from "single-spa-vue";
// 在非子应用中正常挂载应用
if (!window.singleSpaNavigate) {
// delete appOptions.el;
createApp(App).use(router).mount("#app");
}
// 如果在父应用中引用的子引用,当路由发生调整时,做绝对路径设置
if (window.singleSpaNavigate) {
__webpack_public_path__ = "http://localhost:8001/";
}
const vueLifeCycle = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
name: this.name,
mountParcel: this.mountParcel,
singleSpa: this.singleSpa,
});
},
},
handleInstance: (app) => {
app.use(router);
},
});
// 子应用必须导出 以下生命周期 bootstrap、mount、unmount
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
export default vueLifeCycle;
添加vue.config.js配置文件
vue.config.js主要作用:
- 设置打包的名称和格式
- 设置开发环境的端口号,设置支持跨域 Access-Control-Allow-Origin
module.exports = {
configureWebpack: {
output: {
library: "singleVue",
libraryTarget: "umd",
},
devServer: {
port: 8001,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
},
};
修改路由的基础路径
由于在主模块下通过路由前缀进行判断需要加载的子模块,所以约定下子spa-vue的路由为/vue
开头const router = createRouter({
history: createWebHistory("/vue"),
routes
})
构建spa-react子应用
使用react脚手架创建项目,需要使用react18之前版本npx create-react-app spa-react
安装single-spa-react插件
修改src/index.js配置
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import singleSpaReact from "single-spa-react";
// 在非子应用中正常挂载应用
if (!window.singleSpaNavigate) {
ReactDOM.render(<App />, document.getElementById("root"))
}
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App,
errorBoundary(err, info, props) {
return <div>This renders when a catastrophic error occurs</div>;
},
});
export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;
添加react-app-rewired插件
由于需要修改react启动配置,react-app-rewired提供了个性化启动配置yarn add react-app-rewired
"scripts": {
"start": "PORT=8002 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 = "singleReact";
config.output.libraryTarget = "umd";
config.output.publicPath = "http://localhost:8002/";
return config;
},
devServer: (configFn) => {
return function (proxy, allowedHost) {
const config = configFn(proxy, allowedHost);
config.headers = {
"Accsss-Control-Allow-Origin": "*",
};
return config;
};
},
};
主应用构建
创建项目
vue create single-spa-main
默认选择包含路由router
添加single-spa插件
修改main.js文件
import { createApp } from "vue";
import App from "./App.vue";
import { registerApplication, start } from "single-spa";
import router from "./router"
createApp(App).use(router).mount("#app");
// 动态加载js文件
function loadScript(url) {
return new Promise((resolve, reject) => {
let script = document.createElement("script");
script.type = "text/javascript";
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.append(script);
});
}
// singleSpa 缺陷:样式不隔离,不能动态加载js,没有js沙箱机制
registerApplication(
"myVueApp",
async () => {
console.log('object');
await loadScript("http://localhost:8001/js/chunk-vendors.js");
await loadScript("http://localhost:8001/js/app.js");
return window.singleVue;
},
(location) => location.pathname.startsWith("/vue")
);
registerApplication(
"myReactApp",
async () => {
await loadScript("http://localhost:8002/static/js/bundle.js");
return window.singleReact;
},
(location) => location.pathname.startsWith("/react")
);
start();
修改App.vue文件
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/vue">Vue</router-link> |
<router-link to="/react">React</router-link>
</div>
<router-view/>
总结:
开发流程
- 通过将子模块进行打包编译,将打包出的内容动态加载到主模块上
- 打包的子模块,使用umd模式
-
single-spa缺陷
样式不隔离
- js无法自动导入
- js无隔离,添加多个全局变量
qiankun
使用主应用端口8080,加载子应用8001和8002;
8080使用vue创建
8001使用vue创建
8002使用react创建创建主应用
使用vue-cli创建vue主应用,默认安装vue-routervue create qiankun-base
安装qiankun插件yarn add qiankun
安装element-plus,设置el-menu 切换。yarn add element-plus
修改main.js配置
```javascript import { createApp } from “vue”; import App from “./App.vue”; import router from “./router”; import { registerMicroApps, start } from “qiankun”; import ElementUI from “element-plus”; import “element-plus/dist/index.css”; // 加载多个子应用 let apps = [ { name: “vueApp”, entry: “http://localhost:8001“, container: “#vue”, activeRule: “/vue”, }, { name: “reactApp”, entry: “http://localhost:8002“, container: “#react”, activeRule: “/react”, }, ]; // registerMicroApps注册子应用 registerMicroApps(apps); // 启动主应用 start();
createApp(App).use(router).use(ElementUI).mount(“#app”);
<a name="RwQ02"></a>
### 修改路由router配置
```javascript
import { createRouter, createWebHistory } from "vue-router";
import Home from "../views/Home.vue";
const routes = [
{
path: "/",
name: "Home",
component: Home,
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, from, next) => {
if (!history.state.current) {
Object.assign(history.state, {
current: from.fullPath,
});
}
next();
});
export default router;
修改首页App.vue配置
<div>
<el-menu :router="true" mode="horizontal">
<el-menu-item index="/">首页</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 v-show="$route.name"></router-view>
<div v-show="!$route.name" id="vue"></div>
<div v-show="!$route.name" id="react"></div>
</div>
创建子应用
创建vue子应用
使用vue-cli创建vue子应用,默认安装vue-routervue create module-vue
设置vue.config.js
qiankun加载子应用代码是通过fetch获取,所以子应用要设置跨域
module.exports = {
configureWebpack: {
output: {
library: "vueApp",
libraryTarget: "umd",
},
},
devServer: {
port: 8001,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
};
修改main.js配置
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
// 注意:子应用和主应用都使用了vue,不能挂载到同一个节点,否则会报错。把子应用挂载到vueapp,并修改public/index.html
// 不在qiankun框架下启动
if (!window.__POWERED_BY_QIANKUN__) {
createApp(App).use(router).mount("#vueapp");
} else {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
export async function bootstrap() {}
export async function mount(props) {
createApp(App).use(router).mount("#vueapp");
}
export async function unmount() {}
修改router的配置
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
const router = createRouter({
// 修改vue子应用的路由都以/vue开头
history: createWebHistory("/vue"),
routes
})
export default router
创建react子应用
创建react子应用,要使用react17.X 和react-dom:17.x,路由react-router-dom:5.x.xnpx create-react-app module-react
首先要确保版本的正确;修改package.json中对应版本号;
由于要修改react启动的配置,所以安装插件react-app-rewiredyarn add react-app-rewired
修改启动方式
"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:8002/";
return config;
},
devServer: function (configFunction) {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.headers = {
"Access-Control-Allow-Origin": "*",
};
return config;
};
},
};
修改启动端口号,创建.env文件
PORT=8002
WDS_SOCKET_PORT=8002
修改src/index.js配置
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
const render = () => {
ReactDOM.render(<App />, document.getElementById("root"));
};
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
if (window.__POWERED_BY_QIANKUN__) {
}
export async function bootstrap() {}
export async function mount(props) {
render();
}
export async function unmount() {}
修改src/App.js
import { BrowserRouter, Route, Link } from "react-router-dom";
// react子应用的路由都以/react 开头
const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : "/";
function App() {
console.log("BASE_NAME", BASE_NAME);
return (
<BrowserRouter basename={BASE_NAME}>
<Link to="/">首页</Link>
<Link to="/about">关于</Link>
<Route path="/" exact render={()=><Home />}></Route>
<Route path="/about" render={()=><About />}></Route>
</BrowserRouter>
);
}
function Home() {
return <div>Welcome home </div>;
}
function About() {
return <div>Welcome about </div>;
}
export default App;
常见问题
如果主模块是vue,使用vue-router,子模块使用react的路由react-router。如果切换react模块的子路由之后再切换回其他子模块,会发生错误Error with push/replace State DOMException: Failed to execute 'replaceState' on 'History': A history state object with URL '[http://localhost:8080undefined/](https://link.juejin.cn?target=http%3A%2F%2Flocalhost%3A8080undefined%2F)' cannot be created
原因是react-router和vue-router处理的差别,导致在页面跳转的时候一些内容的丢失。
解决办法参考资料
//主应用使用的嵌套路由
router.beforeEach((to, from, next) => {
if (!window.history.state.current) window.history.state.current = to.fullPath;
if (!window.history.state.back) window.history.state.back = from.fullPath;
// 手动修改history的state
return next();
});
// 或者使用
router.beforeEach((to, from, next) => {
if (!history.state.current) {
Object.assign(history.state, {
current: from.fullPath,
});
}
next();
});