简介
首先什么是微前端?
他是一个软件架构模式。借鉴了后端的为服务架构思想,是将复杂单一的前端进行拆分成多个可以独立开发、部署、维护的小型应用。不同的应用关注不同的业务。最终将其集成到一个主框架里面。简单来说就是先分后合。
传统前端开发的痛点
有这么一个场景,你现在在实际工作当中维护了一个vue项目。这个项目里面现在有了业务A、业务B、业务C…,然后还有系统登录权限、公共配置等模块,就导致这个vue项目很大,尤其是对于vue2+webpack来说打包速度堪忧。这个时候微前端就有用武之地了。将每一个业务模块都拆分出来独立进行开发之后统一合并部署,大大的提升了维护,同时如果需要将其整体从vue2迁移到vue3同样也是如此逐渐迁移。
微前端实现方案
iframe
这个就比较简单,开发一个菜单然后主题内容就根据菜单的切换更换这个主体iframe包的url地址就好了。
为什么不用ifream
web Components
web组件:也就是基于原生来创建组件,像vue当中的组件也是这样实现的。
- 自定义元素:定义元素及其行为
- 影子Dom:将封装的“影子”DOM 树附加到元素并控制其关联的功能。使用这种方式保持元素的功能私有,不用担心与文档的其他部分发生冲突。
- html 模板:
<template>
和<slot>
元素可以作为标记模板
无界微前端组件实现案例:
假设我有一个叫child.html的文件,里面对h1元素的样式进行了改变,现在我有一个父页面需要加载child.html当中的代码进来并且要实现样式隔离。
- 创建class类继承HTMLElement。通过getAttribute可以获取到wu-jie标签当中的所有属性
- 通过loadFile方法获取child的html代码,通过attachShadow创建一个影子DOM
- 最后将这个影子DOM和child的代码合到一起加到主页面当中。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PCM Player</title>
</head>
<body>
<h1>我是一级标题</h1>
<wu-jie src="http://localhost:63342/CimH5/src/views/home/child.html"/>
<script>
const loadFile = async (src) => {
return await fetch(src).then(res => res.text());
};
const injectHTML = (shadowRoot, html) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
shadowRoot.appendChild(wrapper);
};
class WUJIE extends HTMLElement {
constructor() {
super();
this.src = this.getAttribute('src');
}
async connectedCallback() {
const html = await loadFile(this.src);
const shadowRoot = this.attachShadow({mode: 'open'});
injectHTML(shadowRoot, html);
console.log('得到子应用的代码 =====', html);
}
}
window.customElements.define('wu-jie', WUJIE);
</script>
</body>
</html>
这样也就完成了组件之间的样式隔离(也就是经常听到的沙箱)
沙箱:是一种用于隔离正在运行程序的安全机制,通常用于执行未经测试或者不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行。也就是不同应用之间虽然同时运行在同一个html当中但是js、css不会混淆到一起,互不影响。
single-spa(单页面)
通过路由劫持实现应用加载(采用systemjs)
- 优:基于props主子应用通信
- 缺:无沙箱
module federation
通过模块联邦将组件打包导出使用
- 优:共享模块的方式进行通信
- 缺:无沙箱,需要使用webpack5
single-spa
简介
spa(single page application)是一个将多个单页面应用聚合为一个整体应用的 js 微前端框架
single-spa会在基座应用中维护一个路由注册表,每个路由对应一个子应用。基座应用启动以后,当我们切换路由时,如果是一个新的子应用,会动态获取子应用的 js 脚本,然后执行脚本并渲染出相应的页面;如果是一个已经访问过的子应用,那么就会从缓存中获取已经缓存的子应用,激活子应用并渲染出对应的页面
实战
安装spa&初始化应用
全局安装脚手架
npm install --global create-single-spa
创建一个基座应用,在通过脚手架创建应用的时候会有这几个选项,可以看一下,分别就是用来创建子应用、基座、公共模块三个大类的
'single-spa-application / parcel':微前端架构中的微应用,可以使用 vue、react、angular 等框架。 'single-spa root config':创建微前端容器应用。 'utility modules':公共模块应用,非渲染组件,用于跨应用共享 javascript 逻辑的微应用
C:\Users\modify>create-single-spa base
? Select type to generate single-spa root config
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Would you like to use single-spa Layout Engine No
? Organization name (can use letters, numbers, dash or underscore) QmQ
Initialized empty Git repository in C:/Users/modify/base/.git/
创建子应用,这里创建一个vue和react的项目。下面演示一下创建react项目的,vue项目也是一样
D:\mygit\spa>create-single-spa react-app
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) modify
? Project name (can use letters, numbers, dash or underscore) react-app
Initialized empty Git repository in D:/mygit/spa/react-app/.git/
spa代码说明
base基座
在src下的index.ejs当中可以直接找到下面这个代码,这个代码就是用来给基座和子应用进行关联的。
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@modify/root-config": "//localhost:9000/modify-root-config.js",
}
}
</script>
<% } %>
然后看同样的src下的modify-root-config.js这个名字适合创建的时候设置的Organization name有关的,所以文件名是有区别的
import {registerApplication, start} from 'single-spa';
// 服务注册
// 这里用到的name属性也就一个标识,然后通过SystemJs导入一个外部js
registerApplication({
name: '@single-spa/welcome',
app: () =>
System.import(
'https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js'
),
// 这里表示路由,因为生成的路由是["/"]这个表示以/开头的都会走这个规则也就是访问上面那个js,所以这里进行了调整
activeWhen: location => location.pathname === '/'
});
// 启动
start({
urlRerouteOnly: true
});
vue项目
这里的vue3项目当中会有两个启动命令,一个serve
一个serve:standalone
。区别在于是否serve是基于微前端spa启动的,而standalone是独立启动,和平常我们使用的vue项目启动是一样的。
在使用命令serve:standalone
进行启动报错:
single-spa.dev.js:155 Uncaught TypeError: application '@modify/vue-app' died in status LOADING_SOURCE_CODE: Cannot read properties of undefined (reading 'meta')
xxxx
解决方案:修改vue.config.js文件,GitHub issues
const {defineConfig} = require('@vue/cli-service');
module.exports = defineConfig({
transpileDependencies: true,
devServer: {
port: 3000
},
configureWebpack: {
output: {
libraryTarget: 'system'
}
}
});
项目关联
关联vue项目
看了这些,那怎么把vue项目和基座项目关联起来呢?首先这里我的vue项目名是@modify/vue-app(看package.json的name属性)之后再base基座项目当中的modify-root-config.js添加配置,表示将vue项目注册到基座当中
registerApplication({
// vue项目名称
name: '@modify/vue-app',
app: () =>
// 导入模块
System.import(
'@modify/vue-app'
),
// 指定访问路径
activeWhen: location => location.pathname === '/vue'
});
在index.ejs代码当中添加导入关联配置
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@modify/root-config": "//localhost:9000/modify-root-config.js",
// 和上面System.import导入的名称一致。
"@modify/vue-app": "//localhost:3000/js/app.js"
}
}
</script>
<% } %>
这里就可能会有问题了,你指定了vue项目的端口是3000我知道,那你是怎么知道后面加/js/app.js这个路径呢?这个时候我们通过serve去启动vue项目,访问3000可以看到下面这个样子,简单翻译一下就懂了。最后访问基座项目加到对应路由也就是:localhost:9000/vue
也是能访问这个vue项目的,就说明配置关联成功了。
关联react项目
这个和vue项目是一样的,还是先添加注册的规则,然后启动react项目看微前端的react指向的地址,然后添加配置即可,也就是这样
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@modify/root-config": "//localhost:9000/modify-root-config.js",
"@modify/vue-app": "//localhost:3000/js/app.js",
"@modify/react-app": "//localhost:4000/modify-react-app.js"
}
}
</script>
<% } %>
基座与vue项目传参
还是在基座路由规则这添加一个customProps表示传递过去的数据,传的是一个对象。
registerApplication({
name: '@modify/vue-app',
app: () =>
System.import(
'@modify/vue-app'
),
activeWhen: location => location.pathname === '/vue',
customProps: {
a: 1
},
});
在vue项目当中的main.js文件当中可以进行接收,在这里可以通过this拿到a的值,之后在vue文件当中就可以通过defineProps拿到这个a了。
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
a: this.a
});
}
}
});
乾坤
乾坤demo地址:https://github.com/lizuoqun/spa/tree/master/qiankun-base
基座(主应用)
先构建一个vue3的前端项目,之后安装一下qiankun。用vue3项目当做一个主应用基座
npm create vite@latest qiankun-base
cd qiankun-base
npm install
npm i qiankun -S
npm run dev
入口改造
在这里简单说明一下,注册了一个react应用,react应用访问的地址是3000端口+react-app,这个在下面创建react主应用可以看到,然后配置了qiankun的生命周期。
import {createApp} from 'vue';
import './style.css';
import App from './App.vue';
import {registerMicroApps, start} from 'qiankun';
// 注册的所有子应用
const QIANKUN_APPS = [
{
name: 'react app', // 子应用名称
entry: '//localhost:3000', // 子应用的IP端口
container: '#reactAppContainer', // 子应用渲染到哪个容器当中
activeRule: '/react-app' // 子应用访问的路径
}
];
// qiankun生命周期
const QIANKUN_LIFECYCLE = {
beforeLoad: [async (app: any) => {
console.log('before load =====', app.name);
}],
beforeMount: [async (app: any) => {
console.log('before mount =====', app.name);
}],
afterUnmount: [async (app: any) => {
console.log('after unmount =====', app.name);
}]
};
registerMicroApps(QIANKUN_APPS, QIANKUN_LIFECYCLE);
start();
createApp(App).mount('#app');
React子应用
项目初始化
通过命令创建一个react项目
npm install -g create-react-app
create-react-app react-app
这里有可能创建之后启动项目会出现报错,直接全局搜索把引入这个web-vitals的代码注释掉即可。
Module not found: Error: Can't resolve 'web-vitals'
入口改造
修改index.js入口文件。
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {createRoot} from 'react-dom/client';
// 安装下 npm i react-router-dom
import {BrowserRouter} from 'react-router-dom';
import './public-path.js'
let root;
function render(props) {
// 判断子应用加载的时机(是嵌套在主应用下的还是直接单独运行加载的)
const {container} = props;
const dom = container ? container.querySelector('#root') : document.querySelector('#root');
root = createRoot(dom);
root.render(
// 默认路由
<BrowserRouter basename="/react-app">
<App/>
</BrowserRouter>
);
}
reportWebVitals();
// 判断是否在 qiankun 环境下运行
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
// 生命周期。
// 1. bootstrap: 在主应用加载子应用之前,子应用的生命周期将被调用。
// 2. mount: 在主应用加载子应用之后,子应用的生命周期将被调用。
// 3. unmount: 当子应用被卸载时,子应用的生命周期将被调用。
export async function bootstrap() {
console.log('[react16] react app bootstraped');
}
export async function mount(props) {
console.log('[react16] props from main framework', props);
render(props);
}
export async function unmount(props) {
const {container} = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}
添加public-path.js
在qiankun文档上面是没有eslint注释的代码,会导致项目运行出错,可以看 Github上看到的解决方案
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
修改webpack配置
安装插件 @rescripts/cli
,当然也可以选择其他的插件,例如 react-app-rewired
npm i -D @rescripts/cli
根目录新增 .rescriptsrc.js
const {name} = require('./package');
module.exports = {
webpack: (config) => {
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
// webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
config.output.chunkLoadingGlobal = `webpackJsonp_${name}`;
config.output.globalObject = 'window';
return config;
},
devServer: (_) => {
const config = _;
config.headers = {
'Access-Control-Allow-Origin': '*'
};
config.historyApiFallback = true;
config.hot = false;
// webpack5里面也没有这个watchContentBase配置了,索性也直接注释好了
// config.watchContentBase = false;
config.liveReload = false;
return config;
}
};
到这一步就完成了子应用的改造,启动项目访问 http://localhost:3000/react-app
Vue3子应用
项目初始化
npm create vite@latest vue-app
cd vue-app
npm i
npm install vue-plugin-qiankun
修改vite配置
关于 vite-plugin-qiankun 插件
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import qiankun from 'vite-plugin-qiankun';
// https://vitejs.dev/config/
export default defineConfig({
base: './vue-app',
plugins: [vue(), qiankun('vue-app', {
useDevMode: true
})],
server: {
port: 3001,
cors: true,
origin: 'http://localhost:3001'
}
});
入口改造
这里要注意:在引入了其他的库之后在if判断和else当中的createApp当中都要use一下对应的库。完成了这一步然后再基座应用当中和加react子应用一样把vue子应用加进去这就完成了微前端
import {createApp} from 'vue';
import './style.css';
import App from './App.vue';
import router from './router';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import {renderWithQiankun, qiankunWindow} from 'vite-plugin-qiankun/dist/helper';
let app: any;
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
createApp(App).use(router).use(ElementPlus).mount('#app');
} else {
renderWithQiankun({
mount(props) {
app = createApp(App).use(router).use(ElementPlus);
app.mount(props.container?.querySelector('#app'));
},
bootstrap() {
console.log('vue app bootstrap');
},
update() {
console.log('vue app update');
},
unmount(props) {
console.log('vue app unmount', props);
app.unmount();
}
});
}
子应用间跳转
vue跳react
这里安装vue-router和配置router路由就不说明了,主要看一下跳转代码。这里跳转到vue子应用当中的路由都是可以的,但是react子应用是跳转不过去的。
<script setup lang="ts">
import {ref} from 'vue';
import router from '@/router/index';
const activeName = ref('list');
const handleClick = (tab: any) => {
router.push({name: tab.paneName});
};
</script>
<template>
<div class="vue__app__title">这是一个vue3子应用</div>
<el-tabs v-model="activeName" class="demo-tabs" @tab-click="handleClick">
<el-tab-pane label="List" name="list"></el-tab-pane>
<el-tab-pane label="Detail" name="detail"></el-tab-pane>
<el-tab-pane label="Go React" name="react-app"></el-tab-pane>
</el-tabs>
<router-view/>
</template>
方案一:直接重定向
window.location.href = '/react-app';
方案二:pushState
语法
history.pushState(stateObject, title, url);
参数
参数 | 描述 |
---|---|
stateObject | 传入的状态对象。当前进(后退)到某一新的状态时,会触发popstate事件。此事件对象event.state存储的就是这个stateObject的值。 |
title | 新状态的标题。(目前,大多数浏览器并不支持该参数,建议传null值) |
url | 状态对应的历史记录的地址。 |
调整一下vue当中跳转的代码,重新匹配一下路由通过window.history.pushState跳到react子应用当中
const handleClick = (tab: any) => {
if (tab.paneName === 'react-app') {
window.history.pushState({}, '', '/react-app');
} else {
router.push({name: tab.paneName});
}
};
同样的在主应用当中我们可以拦截到这个pushState,并且在主应用里面进行一些相对应的处理:改一下基座的入口,添加以下代码
注意一下咯:这里通过apply重新设置值的时候使用的普通函数,要是使用箭头函数的话会改变this指向导致报错
// 重写history的pushState方法,触发自定义事件,并返回原方法的返回值
const write = (type: string) => {
const orig = (window as any).history[type];
return function () {
const rv = orig.apply(this, arguments);
const e: any = new Event(type);
e.arguments = arguments;
window.dispatchEvent(e);
return rv;
};
};
window.history.pushState = write('pushState');
window.addEventListener('pushState', () => {
console.log('base 基座监听到了pushState事件=====');
});
问题
react卸载报错
在从vue项目跳转到react当中,再从react返回到vue,会出现以下报错,就是react入口文件当中配置了卸载unmountComponentAtNode的时候出现了问题。
application 'react app' died in status UNMOUNTING: react_dom_client__WEBPACK_IMPORTED_MODULE_1__.unmountComponentAtNode is not a function
TypeError: react_dom_client__WEBPACK_IMPORTED_MODULE_1__.unmountComponentAtNode is not a function
这是因为在 React 18, unmountComponentAtNode
已被 root.unmount()
取代。那么在react项目当中改一下入口当中的卸载钩子
// 删掉这个
// export async function unmount(props) {
// const {container} = props;
// ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
// }
export async function unmount(props) {
root.unmount();
}
公共依赖加载
在开发过程中,不同的子应用还是会使用到很多相同的第三方库的依赖,比方说:axios、dayjs、lodash等。而微前端是统一加载的,这个时候我们可以将这些公共依赖只加载一次,而不是切换个子应用又加载一次。