微前端概念
什么是微前端
微前端可以把大型项目拆分成不同类型的子项目,子项目可以单独发布上线,解决项目中多个技术时无法进行融合。每个子项目是独立的,又能都挂载到主项目插座上。核心=>大项目拆分,拆后再合并。
为什么需要微前端?
- 不同团队之间,技术栈可能不同
- 项目开始周期点不同,技术栈不同,老项目代码无法重构
- 每个团队负责的项目都想独立开发,单独部署。
为了解决以上这些问题,就出现了微前端概念。可以将一个应用划分为多个子应用,将子应用打包成独立的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-vueimport { 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、unmountexport 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配置```javascriptimport { 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=8002WDS_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的statereturn next();});// 或者使用router.beforeEach((to, from, next) => {if (!history.state.current) {Object.assign(history.state, {current: from.fullPath,});}next();});
