qiankun 微前端:vue3 + vite + qiankun 实现主应用,接入 react18 和 vue3 微应用

617 阅读4分钟

以 vue3+vite+qiankun 为例实现微前端架构

项目源码地址:

项目源码地址:vue3 + vite + qiankun : vue3 + vite + qiankun 实现主应用,接入 react18 和 vue3 微应用 (gitee.com)

项目初始化

第一步 新建项目文件夹 /qiankun-micro-frontEnd

微前端-qiankun实现-1.png

第二步 初始化项目生成 package.json 文件

# 生成 package.json 文件
npm init
// package.json 文件配置如下
{
  "name": "package.json",
  "version": "1.0.0",
  "description": "使用 vue3 为主应用,react18 和 vue3 为微应用的 qiankun 微前端实现",
  "scripts": {
    "dev": "cd main-app && vite",
    "dev:sub-app-vue3": "cd sub-app-vue3 && vite",
    "dev:sub-app-react18": "cd sub-app-react18 && vite",
    "serve": "cd main-app && vite",
    "serve:sub-app-vue3": "cd sub-app-vue3 && vite",
    "serve:sub-app-react18": "cd sub-app-react18 && vite",
    "build": "cd main-app && vite build",
    "build:sub-app-vue3": "cd sub-app-vue3 && vite build",
    "build:sub-app-react18": "cd sub-app-react18 && vite build",
    "preview": "cd main-app && preview",
    "preview:sub-app-vue3": "cd sub-app-vue3 && preview",
    "preview:sub-app-react18": "cd sub-app-react18 && preview"
  },
  "author": "jianhaijiyusheng",
  "license": "MIT"
}

主应用配置

第一步 初始化一个 vue3 + vite 项目并在项目中安装 qiankun

vue3 + vite 项目框架代码示例:vue-project-framework(gitee.com)

npm i qiankun
# 当前示例所使用的 qiankun 版本为 2.10.16

第二步 在 src/utils 下新建一个 micro-app.ts 文件用于存放微前端配置

// micro-app.ts 文件内容如下:
const microApps = [{
    name: 'subVueAPP', // 子应用的唯一 id
    entry: '//localhost:3001', // 子应用的访问链接
    activeRule: '/vue-app', // 当访问的 url 中匹配到 activeRule 中的参数,就会到跳转到对应的子应用
}, {
    name: 'subReactAPP',
    entry: '//localhost:3002',
    activeRule: '/react-app'
}];
  
const apps = microApps.map(item => {
    return {
        ...item,
        container: "#sub-app", // 用于声明 子应用 应该挂载到 主应用 中的哪个 DOM 下
    };
});

export default apps;

第三步 配置主应用中的 main.ts

import { createApp } from 'vue'
import './style.scss'
import App from './App.vue'
import router from './router/index'
import { createPinia } from 'pinia'
import { Http } from '@/utils/http'
import components from '@/components/index'
import i18n from '@/i18n/index'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/display.css'
import ElementPlus from 'element-plus'
import directive from '@/directive'
import '@/plugin/last-commit-msg/toggle.js'
import { mockRuqest } from './mock'
import { registerMicroApps, start } from 'qiankun'
import microApp from './utils/micro-app'
import '@/utils/qiankun-action'

Http.init()
if (import.meta.env.MODE === 'mock') {
	mockRuqest()
}

const store = createPinia()
const app = createApp(App)

// ---- qiankun 主要配置 -------
// 配置微应用所需参数
registerMicroApps(microApp)
// 使用 qiankun 启动项目
start()

app.use(ElementPlus)
app.use(store)
app.use(router)
app.use(components)
app.use(i18n)
app.use(directive)
app.mount('#app')

第四步 增加子应用启动页面 micro-app.vue

// src/micro-app/micro-app.vue 文件内容如下
<template>
    <div>
        <h1>子应用</h1>
        <router-view></router-view>
    </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { qiankunWindow } from "vite-plugin-qiankun/dist/helper"
import { start } from 'qiankun'

onMounted(() => {
	// 通过 微应用的方式启动微前端
    if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
        qiankunWindow.__POWERED_BY_QIANKUN__ = true
        start()
    }
})
</script>

第四步 配置主应用路由

import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Layout from '@/page/layout/layout.vue'

const routes: Array<RouteRecordRaw> = [{
    path: '/',
    redirect: '/index',
}, {
    path: '/',
    name: 'layout',
    component: Layout, 
    children: [{
        path: 'index',
        name: 'index',
        component: () => import('@/page/main-app/index/index.vue'),
    }, {
        path: 'about',
        name: 'about',
        component: () => import('@/page/main-app/about/about.vue'),
    }, {
        path: 'setting',
        name: 'setting',
        component: () => import('@/page/main-app/setting/setting.vue'),
    }, {
        path: 'user-info',
        name: 'userInfo',
        component: () => import('@/page/main-app/user-info/user-info.vue'),
    }, {
        // vue 子应用路由
        path: 'vue-app/index',
        name: 'vueApp',
        component: () => import('@/page/micro-app/micro-app.vue'), // 子应用中转配置页面,用于启动子应用
    }, {
        // react 子应用路由
        path: 'react-app/index',
        name: 'reactApp',
        component: () => import('@/page/micro-app/micro-app.vue'), // 子应用中转配置页面,用于启动子应用
    }]
}]

const router = createRouter({
    history: createWebHistory(),
    routes,
})
router.beforeEach((to, from, next) => {
    next()
})
export default router

第五步 主应用路由跳转逻辑

跳转子应用可以使用路由方式跳转,也可以使用 window.history.pushState 跳转

1、state:一个与添加的记录相关联的状态对象,主要用于popstate事件。该事件触发时,该对象会传入回调函数。也就是说,浏览器会将这个对象序列化以后保留在本地,重新载入这个页面的时候,可以拿到这个对象。如果不需要这个对象,此处可以填null。

2、title:新页面的标题。但是,现在所有浏览器都忽视这个参数,所以这里可以填空字符串。

3、url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。

window.history.pushState(null, '', '/vue-app/index')

<template>
    <div class="layout">
        <div class="left-side">
            <ul>
                <li v-for="item in menuList" :key="item.id" @click="goToPage(item)">
                    {{ $t(item.name) }}
                </li>
            </ul>
        </div>
        <div class="right-content">
            <div class="layout-header">
                <div>
                    <button class="change-lan-btn" @click.prevent="changeLanguage">{{ language }}</button>
                </div>
            </div>
            <div class="layout-main">
                <!-- 主应用内置页面路由出口 -->
                <router-view></router-view>

                <!-- 子应用挂载区域,如果跳转的是子应用,子应用页面会挂载到这个DOM 下 -->
                <!-- 这个 dom 的id 需要和 @/util/micro-app.ts 中 container 字段值保持一致-->
                <div id="sub-app"></div>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import layoutStore from '@/store/index'
import { I18NType } from '@/app.config'
import { onMounted } from 'vue'

const router = useRouter()
const layoutSe = layoutStore()
const language = computed(() => layoutSe.getLanguage)
const changeLanguage = () => {
  layoutSe.setLanguage(language.value == I18NType.EN_US ? I18NType.ZH_CN : I18NType.EN_US)
}

// 菜单配置
const menuList = ref([
    { id: 0, name: 'layout.vueApp', router: 'vueApp', isSubApp: true, path: '/vue-app/index' },
    { id: 1, name: 'layout.reactApp', router: 'reactApp', isSubApp: true, path: '/react-app/index' },
    { id: 2, name: 'layout.index', router: 'index', isSubApp: false },
    { id: 3, name: 'layout.setting', router: 'setting', isSubApp: false },
    { id: 4, name: 'layout.about', router: 'about', isSubApp: false },
    { id: 5, name: 'layout.userInfo', router: 'userInfo', isSubApp: false },
])
const goToPage = (name: string) => {
    // 方法一:全部都通过 router.push 方式跳转
    // router.push({ name: item.router })
    // 方法二: 主应用内置页面通过路由跳转,子应用通过 window.history.pushState 跳转
    if(item.isSubApp) {
        window.history.pushState(null, '', item.path)
    } else {
        router.push({ name: item.router })  
    }
}
onMounted(() => {
    console.log('layout mounted', layoutSe.getMainApp)
})
</script>

<style lang="scss" scoped>
// 此处省略 scss 样式
</style>

子应用配置(vue3 + vite)

第一步 安装插件

npm i vite-plugin-qiankun

第二步 配置 vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import qiankun from 'vite-plugin-qiankun'

export default defineConfig({
  base: '/vue-app/',
  plugins: [
      // ...此处省略其他插件
      // qiankun(name, options) name 的取值需要和 主应用 @/utils/micro-app.ts 配置的 name 保持一致
      qiankun('subVueAPP', { useDevMode: true })
  ],
  build: {
    outDir: 'dist/sub-app-vue3'
  }
})

第三步 配置 main.ts

import { createApp } from 'vue'
import './style.scss'
import App from './App.vue'
import router from './router/index'
import { createPinia } from 'pinia'
import { Http } from '@/utils/http'
import components from '@/components/index'
import i18n from '@/i18n/index'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/display.css'
import ElementPlus from 'element-plus'
import directive from '@/directive'
import { renderWithQiankun, qiankunWindow } from "vite-plugin-qiankun/dist/helper"

Http.init()
let app: any = null

const render = (props?: any) => {
	const { container } = props ?? {}
	const store = createPinia()
	app = createApp(App)
	app.use(ElementPlus)
	app.use(store)
	app.use(router)
	app.use(components)
	app.use(i18n)
	app.use(directive)
	app?.mount(container ? container.querySelector('#container') : '#container');
}

renderWithQiankun({
	async mount(props: any): Promise<any> {
	//   await props.onGlobalStateChange((state: any) => {
	// 	console.log("子应用接收的参数", state);
	// 	state.publicPath && window.localStorage.setItem("mainJumpPublicPath", state.publicPath);
	//   }, true);
        return new Promise((resolve, reject) => {
		    resolve('mount');
		})
    },
	bootstrap(): Promise<any>  {
	  console.log("%c", "color:green;", " ChildOne bootstrap");
	  return new Promise((resolve, reject) => {
		resolve('bootstrap')
	  })
	},
	update() {
	  console.log("%c", "color:green;", " ChildOne update");
	},
	unmount(props: any) {
	  console.log("sub app vue3 unmount", props);
	  app.unmount();
	  app._container.innerHTML = "";
	  app = null;
      // 如果 子应用 是直接挂载到 主应用的 #app DOM 下,
      // 那从 子应用 通过浏览器回退按钮回退到 主应用 时会出现白屏,刷新后又正常显示
      // 这是因为通过浏览器回退按钮回退到 主应用 时主应用的 main.ts 文件不会重新执行,所以 DOM 被重新加载
      //  可以在子应用卸载后刷新页面
	  //   window.location.reload()
	}
});

// 判断是否是 qiankun 渲染
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
	render()
}

子应用配置(react18)

第一步 配置基本

使用的插件和vite.config.ts 文件中的配置同上述 vue3

但是如果在 vite.config.ts 中使用 @vitejs/plugin-react 会如下错误,在 qiankun 中运行时需要删除

[import-html-entry]: error occurs while executing normal script <script type="module">
import RefreshRuntime from "/react-app/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>

第二步 配置main.tsx 文件

import './public-path.js'
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './style.less';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { ConfigProvider } from 'antd';
import store from '@/store';
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper';

const render = (props: any = {}) => {
	const { container } = props;
	ReactDOM.createRoot(container ? container.querySelector('#root')! : document.getElementById('root')!).render(
		<Provider store={store}>
			<BrowserRouter>
				<ConfigProvider>
					<App />
				</ConfigProvider>
			</BrowserRouter>
		</Provider>
	);
};

if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
	render({});
}

renderWithQiankun({
	async mount(props: any): Promise<any> {
		render(props);
		// await props.onGlobalStateChange((state: any) => {
		// 	// console.log('子应用接收的参数', state);
		// });
        return new Promise((resolve, reject) => {
            resolve('mount')
        })
	},
	bootstrap(): Promise<any> {
		console.log('%c', 'color:green;', ' ChildOne bootstrap');
		return new Promise((resolve, reject) => {
			resolve('bootstrap');
		});
	},
	update() {
		console.log('%c', 'color:green;', ' ChildOne update');
	},
	unmount(props: any) {
		console.log('sub app vue3 unmount', props);
		// const { container } = props;
		// ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
		//   window.location.reload()
	},
});