微前端进阶(一)
从架构设计到完整落地:手把手搭建一个生产可用的微前端 Portal 基座
目录
- 平台架构总览
- 基座应用项目初始化
- 统一布局框架
- 子应用注册中心
- 全局主题体系
- 国际化方案
- 错误边界与降级处理
- [全局 Loading 与过渡动画](#全局 Loading 与过渡动画)
- 基座应用发布与部署
一、平台架构总览
1.1 整体架构
┌──────────────────────────────────────────────────────────────────┐
│ 接入层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ Nginx │ │ 统一认证 │ │ 权限中心 │ │ API 网关 │ │
│ │ 反向代理 │ │ SSO │ │ RBAC │ │ Kong/APISIX │ │
│ └──────────┘ └──────────┘ └──────────┘ └────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ 基座应用(Portal) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 布局框架:Header + Sidebar + Content + Footer │ │
│ │ 全局功能:主题切换 / 国际化 / 用户菜单 / 消息通知 │ │
│ │ 子应用注册中心:动态注册、懒加载、生命周期管理 │ │
│ │ 基础设施:错误边界 / 全局 Loading / 路由守卫 │ │
│ └──────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ 子应用集群 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 子应用A │ │ 子应用B │ │ 子应用C │ │ 子应用D │ ... │
│ │ React │ │ Vue3 │ │ Angular │ │ 微前端 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ 基础设施层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ CI/CD │ │ Sentry │ │ Prometheus│ │ ELK │ │
│ │ 流水线 │ │ 错误监控 │ │ 性能监控 │ │ 日志收集│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────────┘
1.2 技术选型
| 层级 | 技术栈 | 选型理由 |
|---|---|---|
| 基座框架 | Vue 3 + TypeScript | 生态成熟、组合式 API 灵活、与 qiankun 配合最佳 |
| 微前端方案 | qiankun | 国内主流、JS 沙箱 + 样式隔离开箱即用 |
| 构建工具 | Vite | 开发冷启动快、HMR 体验好 |
| UI 组件 | Element Plus | Vue 3 生态首选,支持主题定制 |
| 状态管理 | Pinia | TypeScript 友好、简洁 |
| 路由 | Vue Router 4 | 与 Vue 3 深度集成 |
| HTTP 请求 | Axios | 拦截器机制完善,适合做统一鉴权 |
1.3 项目结构规划
portal-platform/
├── src/
│ ├── main.ts # 入口:注册 qiankun + vue 实例
│ ├── App.vue # 根组件
│ ├── assets/
│ │ ├── styles/
│ │ │ ├── variables.scss # 全局变量(主题色、间距等)
│ │ │ ├── reset.scss # 样式重置
│ │ │ └── global.scss # 全局样式
│ │ └── icons/ # SVG 图标
│ ├── layouts/
│ │ ├── MainLayout.vue # 主布局
│ │ ├── HeaderBar.vue # 顶栏
│ │ ├── SidebarNav.vue # 侧边导航
│ │ ├── ContentArea.vue # 内容区(子应用挂载点)
│ │ └── FooterBar.vue # 底栏
│ ├── micro/
│ │ ├── index.ts # qiankun 初始化
│ │ ├── apps.ts # 子应用注册配置
│ │ ├── loader.ts # 自定义加载器
│ │ ├── lifecycles.ts # 生命周期钩子
│ │ ├── sandbox.ts # 沙箱增强
│ │ └── prefetch.ts # 预加载策略
│ ├── router/
│ │ ├── index.ts # 路由配置
│ │ ├── guards.ts # 路由守卫(权限校验)
│ │ └── routes.ts # 路由表
│ ├── stores/
│ │ ├── user.ts # 用户状态
│ │ ├── theme.ts # 主题状态
│ │ ├── micro.ts # 子应用状态
│ │ └── permission.ts # 权限状态
│ ├── composables/
│ │ ├── useAuth.ts # 认证相关
│ │ ├── usePermission.ts # 权限校验
│ │ ├── useMicroApp.ts # 子应用操作
│ │ └── useTheme.ts # 主题切换
│ ├── services/
│ │ ├── auth.ts # 登录/登出 API
│ │ ├── user.ts # 用户信息 API
│ │ ├── permission.ts # 权限 API
│ │ └── micro.ts # 子应用配置 API(动态获取)
│ ├── components/
│ │ ├── AppLoading.vue # 全局 Loading
│ │ ├── ErrorBoundary.vue # 错误边界
│ │ ├── MicroAppFallback.vue # 子应用降级 UI
│ │ ├── AvatarMenu.vue # 用户头像下拉
│ │ └── ThemeToggle.vue # 主题切换按钮
│ └── utils/
│ ├── request.ts # Axios 封装
│ ├── storage.ts # 本地存储封装
│ └── helpers.ts # 工具函数
├── public/
│ └── cdn/ # 公共依赖 CDN 资源
├── package.json
├── tsconfig.json
├── vite.config.ts
└── .env.{development|production|staging}
二、基座应用项目初始化
2.1 创建项目
bash
# 使用 Vite 创建 Vue3 + TypeScript 项目
pnpm create vite portal-platform --template vue-ts
cd portal-platform
# 安装核心依赖
pnpm add vue-router@4 pinia element-plus @element-plus/icons-vue
pnpm add qiankun axios
pnpm add -D sass unplugin-auto-import unplugin-vue-components
2.2 环境变量配置
typescript
// .env.development
VITE_APP_TITLE=微前端平台(开发环境)
VITE_APP_BASE_URL=http://localhost:8080
VITE_APP_AUTH_URL=http://localhost:9090/auth
VITE_APP_API_BASE=http://localhost:9090/api
VITE_APP_SUB_APP_REACT=http://localhost:3001
VITE_APP_SUB_APP_VUE=http://localhost:3002
VITE_APP_ENV=development
typescript
// .env.production
VITE_APP_TITLE=企业微前端平台
VITE_APP_BASE_URL=https://portal.company.com
VITE_APP_AUTH_URL=https://sso.company.com/auth
VITE_APP_API_BASE=https://api.company.com
VITE_APP_ENV=production
typescript
// src/env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
readonly VITE_APP_BASE_URL: string;
readonly VITE_APP_AUTH_URL: string;
readonly VITE_APP_API_BASE: string;
readonly VITE_APP_SUB_APP_REACT: string;
readonly VITE_APP_SUB_APP_VUE: string;
readonly VITE_APP_ENV: 'development' | 'staging' | 'production';
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
2.3 Axios 封装(含统一鉴权)
typescript
// src/utils/request.ts
import axios, { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { storage } from './storage';
import { ElMessage } from 'element-plus';
import router from '@/router';
const service = axios.create({
baseURL: import.meta.env.VITE_APP_API_BASE,
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
});
// 请求拦截器:注入 Token
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = storage.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器:统一错误处理
service.interceptors.response.use(
(response) => {
const { code, data, message } = response.data;
if (code === 0) return data;
ElMessage.error(message || '请求失败');
return Promise.reject(new Error(message));
},
(error) => {
if (error.response?.status === 401) {
storage.clearToken();
router.push('/login');
ElMessage.error('登录已过期,请重新登录');
} else if (error.response?.status === 403) {
ElMessage.error('无权限访问');
} else if (error.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请稍后重试');
} else {
ElMessage.error(error.message || '网络错误');
}
return Promise.reject(error);
}
);
export const request = {
get<T = any>(url: string, config?: AxiosRequestConfig) {
return service.get<T>(url, config).then(res => res.data);
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig) {
return service.post<T>(url, data, config).then(res => res.data);
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig) {
return service.put<T>(url, data, config).then(res => res.data);
},
delete<T = any>(url: string, config?: AxiosRequestConfig) {
return service.delete<T>(url, config).then(res => res.data);
}
};
三、统一布局框架
3.1 主布局组件
vue
<!-- src/layouts/MainLayout.vue -->
<template>
<div class="main-layout" :class="{ collapsed: sidebarCollapsed }">
<HeaderBar
:user="userStore.userInfo"
:notifications="notificationStore.list"
@toggle-sidebar="sidebarCollapsed = !sidebarCollapsed"
@logout="handleLogout"
/>
<div class="main-layout__body">
<SidebarNav
:menus="permissionStore.menus"
:collapsed="sidebarCollapsed"
:active-route="route.path"
@menu-click="handleMenuClick"
/>
<div class="main-layout__content">
<ContentArea>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<keep-alive :include="cachedViews">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</ContentArea>
<FooterBar />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { usePermissionStore } from '@/stores/permission';
import { useNotificationStore } from '@/stores/notification';
import HeaderBar from './HeaderBar.vue';
import SidebarNav from './SidebarNav.vue';
import ContentArea from './ContentArea.vue';
import FooterBar from './FooterBar.vue';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const permissionStore = usePermissionStore();
const notificationStore = useNotificationStore();
const sidebarCollapsed = ref(false);
const cachedViews = ref<string[]>([]);
function handleMenuClick(menu: any) {
if (menu.microApp) {
router.push(menu.path);
}
}
async function handleLogout() {
await userStore.logout();
router.push('/login');
}
onMounted(() => {
userStore.fetchUserInfo();
permissionStore.fetchMenus();
});
</script>
<style lang="scss" scoped>
.main-layout {
height: 100vh;
display: flex;
flex-direction: column;
&__body {
flex: 1;
display: flex;
overflow: hidden;
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
background: var(--bg-color-page, #f5f7fa);
transition: margin-left 0.3s;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
3.2 顶栏组件
vue
<!-- src/layouts/HeaderBar.vue -->
<template>
<header class="header-bar">
<div class="header-bar__left">
<el-button
text
class="collapse-btn"
@click="$emit('toggle-sidebar')"
>
<el-icon :size="20">
<Fold v-if="!collapsed" />
<Expand v-else />
</el-icon>
</el-button>
<div class="header-bar__brand">
<img src="/logo.svg" alt="logo" class="logo" />
<span class="title">{{ appTitle }}</span>
</div>
</div>
<div class="header-bar__center">
<el-breadcrumb>
<el-breadcrumb-item
v-for="item in breadcrumbs"
:key="item.path"
:to="item.path"
>
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-bar__right">
<!-- 主题切换 -->
<ThemeToggle />
<!-- 国际化切换 -->
<el-dropdown trigger="click" @command="handleLangChange">
<el-button text>
<el-icon><Globe /></el-icon>
{{ currentLang.label }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="lang in languages"
:key="lang.value"
:command="lang.value"
>
{{ lang.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 消息通知 -->
<el-badge :value="unreadCount" :hidden="unreadCount === 0">
<el-button text @click="handleNotification">
<el-icon :size="20"><Bell /></el-icon>
</el-button>
</el-badge>
<!-- 用户菜单 -->
<el-dropdown trigger="click" @command="handleUserCommand">
<span class="user-profile">
<el-avatar :src="user?.avatar" :size="32">
{{ user?.name?.charAt(0) }}
</el-avatar>
<span class="user-name">{{ user?.name }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>个人中心
</el-dropdown-item>
<el-dropdown-item command="settings">
<el-icon><Setting /></el-icon>系统设置
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from '@/composables/useI18n';
import ThemeToggle from '@/components/ThemeToggle.vue';
defineProps<{
user?: { name: string; avatar?: string };
notifications?: any[];
}>();
defineEmits<{
'toggle-sidebar': [];
logout: [];
}>();
const route = useRoute();
const {
currentLang,
languages,
changeLang,
appTitle
} = useI18n();
const breadcrumbs = computed(() => {
return route.matched.map(r => ({
path: r.path,
title: (r.meta?.title as string) || r.name as string
}));
});
const unreadCount = computed(() => 0);
function handleLangChange(lang: string) {
changeLang(lang);
}
function handleNotification() {
// 打开通知面板
}
function handleUserCommand(command: string) {
// 处理用户菜单命令
}
</script>
3.3 侧边导航
vue
<!-- src/layouts/SidebarNav.vue -->
<template>
<aside class="sidebar" :class="{ collapsed }">
<el-menu
:default-active="activeRoute"
:collapse="collapsed"
:collapse-transition="false"
:router="true"
background-color="transparent"
@select="handleSelect"
>
<template v-for="menu in menus" :key="menu.path">
<!-- 有子菜单 -->
<el-sub-menu v-if="menu.children?.length" :index="menu.path">
<template #title>
<el-icon v-if="menu.icon">
<component :is="menu.icon" />
</el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.path"
:index="child.path"
>
<el-icon v-if="child.icon">
<component :is="child.icon" />
</el-icon>
<span>{{ child.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="menu.path">
<el-icon v-if="menu.icon">
<component :is="menu.icon" />
</el-icon>
<span>{{ menu.title }}</span>
</el-menu-item>
</template>
</el-menu>
</aside>
</template>
<script setup lang="ts">
import type { MenuItem } from '@/types/menu';
defineProps<{
menus: MenuItem[];
collapsed: boolean;
activeRoute: string;
}>();
const emit = defineEmits<{
'menu-click': [menu: MenuItem];
}>();
function handleSelect(index: string) {
// 如果菜单项标记为微应用,可以触发额外逻辑
}
</script>
<style lang="scss" scoped>
.sidebar {
width: var(--sidebar-width, 220px);
height: 100%;
background: var(--bg-color-sidebar, #001529);
transition: width 0.3s;
overflow-y: auto;
overflow-x: hidden;
&.collapsed {
width: var(--sidebar-collapsed-width, 64px);
}
:deep(.el-menu) {
border-right: none;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
color: rgba(255, 255, 255, 0.7);
&:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.08);
}
&.is-active {
color: #fff;
background-color: var(--el-color-primary);
}
}
}
</style>
3.4 内容区(子应用挂载点)
vue
<!-- src/layouts/ContentArea.vue -->
<template>
<main class="content-area">
<!-- 子应用挂载容器 -->
<div
v-if="isMicroRoute"
id="micro-app-container"
class="content-area__micro"
v-loading="microLoading"
>
<MicroAppFallback
v-if="microError"
:error="microError"
@retry="handleRetry"
/>
</div>
<!-- 基座自身路由内容 -->
<slot v-else />
</main>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMicroStore } from '@/stores/micro';
import MicroAppFallback from '@/components/MicroAppFallback.vue';
const route = useRoute();
const microStore = useMicroStore();
const isMicroRoute = computed(() => microStore.isMicroRoute(route.path));
const microLoading = computed(() => microStore.loading);
const microError = computed(() => microStore.error);
function handleRetry() {
microStore.retryLoad(route.path);
}
</script>
<style lang="scss" scoped>
.content-area {
flex: 1;
padding: 20px;
min-height: 0;
&__micro {
height: 100%;
min-height: calc(100vh - 120px);
position: relative;
}
}
</style>
3.5 路由配置
typescript
// src/router/routes.ts
import type { RouteRecordRaw } from 'vue-router';
export const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录', noAuth: true }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '工作台', icon: 'Odometer' }
},
// 本地模块路由
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Settings.vue'),
meta: { title: '系统设置', icon: 'Setting' }
},
// 微前端子应用路由(占位路由,实际由 qiankun 接管)
{
path: 'react/:pathMatch(.*)*',
name: 'MicroReact',
component: () => import('@/views/MicroPlaceholder.vue'),
meta: { title: 'React 应用', microApp: 'app-react' }
},
{
path: 'vue/:pathMatch(.*)*',
name: 'MicroVue',
component: () => import('@/views/MicroPlaceholder.vue'),
meta: { title: 'Vue 应用', microApp: 'app-vue' }
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: { title: '404' }
}
];
typescript
// src/router/guards.ts
import type { Router } from 'vue-router';
import { storage } from '@/utils/storage';
import { ElMessage } from 'element-plus';
export function setupRouterGuards(router: Router) {
// 白名单:不需要登录即可访问
const WHITE_LIST = ['/login'];
router.beforeEach(async (to, from, next) => {
// 设置页面标题
const title = to.meta?.title;
if (title) {
document.title = `${title} - ${import.meta.env.VITE_APP_TITLE}`;
}
// 检查是否需要认证
if (to.meta?.noAuth) {
next();
return;
}
const token = storage.getToken();
if (!token) {
// 未登录,跳转登录页
if (!WHITE_LIST.includes(to.path)) {
ElMessage.warning('请先登录');
next({ path: '/login', query: { redirect: to.fullPath } });
return;
}
next();
return;
}
// 已登录但去了登录页,重定向到首页
if (to.path === '/login') {
next('/');
return;
}
next();
});
router.afterEach((to) => {
// 触发微应用激活
const microApp = to.meta?.microApp as string | undefined;
if (microApp) {
window.dispatchEvent(new CustomEvent('micro:route-change', {
detail: { appName: microApp, path: to.fullPath }
}));
}
});
}
typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { routes } from './routes';
import { setupRouterGuards } from './guards';
const router = createRouter({
history: createWebHistory(import.meta.env.VITE_APP_BASE_URL),
routes,
scrollBehavior: () => ({ top: 0 })
});
setupRouterGuards(router);
export default router;
四、子应用注册中心
4.1 子应用配置
typescript
// src/micro/apps.ts
import type { MicroAppConfig } from '@/types/micro';
// 子应用配置清单
export const microAppConfigs: MicroAppConfig[] = [
{
name: 'app-react',
entry: import.meta.env.VITE_APP_SUB_APP_REACT,
container: '#micro-app-container',
activeRule: '/react',
props: {
appName: 'app-react',
platform: 'react'
}
},
{
name: 'app-vue',
entry: import.meta.env.VITE_APP_SUB_APP_VUE,
container: '#micro-app-container',
activeRule: '/vue',
props: {
appName: 'app-vue',
platform: 'vue'
}
},
{
name: 'app-angular',
entry: import.meta.env.VITE_APP_SUB_APP_ANGULAR,
container: '#micro-app-container',
activeRule: '/angular',
props: {
appName: 'app-angular',
platform: 'angular'
}
}
];
// 从服务端动态获取子应用配置
export async function fetchMicroAppConfigs(): Promise<MicroAppConfig[]> {
try {
const response = await fetch('/api/micro-apps/configs');
const configs = await response.json();
return configs.map((cfg: any) => ({
name: cfg.name,
entry: cfg.entry,
container: '#micro-app-container',
activeRule: cfg.activeRule,
props: {
appName: cfg.name,
platform: cfg.platform,
permissions: cfg.permissions,
features: cfg.features
}
}));
} catch {
// 动态获取失败,fallback 到本地配置
console.warn('[Micro] 动态获取子应用配置失败,使用本地配置');
return microAppConfigs;
}
}
4.2 qiankun 初始化
typescript
// src/micro/index.ts
import {
registerMicroApps,
start,
addGlobalUncaughtErrorHandler,
removeGlobalUncaughtErrorHandler
} from 'qiankun';
import { microAppConfigs, fetchMicroAppConfigs } from './apps';
import { microLifecycles } from './lifecycles';
import { useMicroStore } from '@/stores/micro';
import { prefetchStrategy } from './prefetch';
let isStarted = false;
// 启动微前端
export async function bootstrapMicroFrontend() {
const microStore = useMicroStore();
// 1. 获取子应用配置
const configs = await fetchMicroAppConfigs();
// 2. 注册子应用
registerMicroApps(configs, {
beforeLoad: async (app) => {
microStore.setLoading(app.name, true);
console.log(`[Micro] beforeLoad: ${app.name}`, app);
return microLifecycles.beforeLoad(app);
},
afterMount: async (app) => {
microStore.setLoading(app.name, false);
microStore.setMounted(app.name, true);
console.log(`[Micro] afterMount: ${app.name}`, app);
return microLifecycles.afterMount(app);
},
afterUnmount: async (app) => {
microStore.setMounted(app.name, false);
microStore.clearError(app.name);
console.log(`[Micro] afterUnmount: ${app.name}`, app);
return microLifecycles.afterUnmount(app);
}
});
// 3. 全局错误处理
addGlobalUncaughtErrorHandler((event) => {
const appName = (event as any)?.appOrParcelName;
const error = (event as any)?.error || event;
microStore.setError(appName || 'unknown', error);
console.error(`[Micro] 全局错误: ${appName}`, error);
});
// 4. 启动
if (!isStarted) {
start({
prefetch: prefetchStrategy,
sandbox: {
strictStyleIsolation: false,
experimentalStyleIsolation: true
},
singular: false
});
isStarted = true;
}
}
// 注销微前端(清理用)
export function destroyMicroFrontend() {
if (isStarted) {
removeGlobalUncaughtErrorHandler(() => {});
isStarted = false;
}
}
4.3 生命周期钩子
typescript
// src/micro/lifecycles.ts
import type { AppGlobalState } from '@/types/micro';
import { useUserStore } from '@/stores/user';
import { useThemeStore } from '@/stores/theme';
export const microLifecycles = {
async beforeLoad(app: any) {
// 注入全局 Props
const userStore = useUserStore();
const themeStore = useThemeStore();
app.props = {
...app.props,
// 用户信息
user: userStore.userInfo,
// 主题
theme: themeStore.currentTheme,
// 权限
permissions: userStore.permissions,
// 通信 API
globalState: {
get: () => ({
user: userStore.userInfo,
theme: themeStore.currentTheme,
token: userStore.token
}),
set: (state: Partial<AppGlobalState>) => {
if (state.theme) themeStore.setTheme(state.theme);
if (state.user) userStore.setUserInfo(state.user);
},
subscribe: (callback: (state: AppGlobalState) => void) => {
return userStore.$subscribe(() => {
callback({
user: userStore.userInfo,
theme: themeStore.currentTheme,
token: userStore.token
});
});
}
}
};
},
async afterMount(app: any) {
// 子应用挂载后的操作,如埋点
console.log(`[Lifecycle] 子应用 ${app.name} 挂载完成`);
},
async afterUnmount(app: any) {
// 子应用卸载后的清理
console.log(`[Lifecycle] 子应用 ${app.name} 已卸载`);
}
};
4.4 预加载策略
typescript
// src/micro/prefetch.ts
import type { App } from 'qiankun';
// 预加载优先级:根据用户行为预测
const PRIORITY_RULES: Record<string, {
level: 'critical' | 'high' | 'normal' | 'low';
}> = {
'app-dashboard': { level: 'critical' },
'app-react': { level: 'high' },
'app-vue': { level: 'high' },
'app-angular': { level: 'normal' },
'app-report': { level: 'low' }
};
export function prefetchStrategy(app: App) {
const rule = PRIORITY_RULES[app.name];
if (!rule) return false;
switch (rule.level) {
case 'critical':
return 'immediate'; // 立即预加载
case 'high':
return true; // 首个子应用挂载后预加载
case 'normal':
return 'idle'; // 空闲时预加载
case 'low':
return false; // 不预加载,访问时加载
default:
return false;
}
}
// 用户行为预测:鼠标悬停时预加载
export function setupPrefetchByHover() {
const navLinks = document.querySelectorAll('[data-micro-app]');
navLinks.forEach((link) => {
let prefetchTimer: ReturnType<typeof setTimeout> | null = null;
link.addEventListener('mouseenter', () => {
const appName = link.getAttribute('data-micro-app');
if (appName) {
prefetchTimer = setTimeout(() => {
import('qiankun').then(({ loadMicroApp }) => {
// 手动触发预加载
});
}, 300);
}
});
link.addEventListener('mouseleave', () => {
if (prefetchTimer) {
clearTimeout(prefetchTimer);
prefetchTimer = null;
}
});
});
}
五、全局主题体系
5.1 主题 Store
typescript
// src/stores/theme.ts
import { defineStore } from 'pinia';
import { storage } from '@/utils/storage';
export type ThemeMode = 'light' | 'dark';
export type ThemeColor =
| 'blue'
| 'green'
| 'orange'
| 'purple'
| 'red';
interface ThemeState {
mode: ThemeMode;
color: ThemeColor;
}
export const useThemeStore = defineStore('theme', {
state: (): ThemeState => ({
mode: storage.get('theme-mode') || 'light',
color: storage.get('theme-color') || 'blue'
}),
getters: {
currentTheme: (state) => state,
isDark: (state) => state.mode === 'dark'
},
actions: {
setMode(mode: ThemeMode) {
this.mode = mode;
storage.set('theme-mode', mode);
this.applyTheme();
},
setColor(color: ThemeColor) {
this.color = color;
storage.set('theme-color', color);
this.applyTheme();
},
toggleMode() {
this.setMode(this.mode === 'light' ? 'dark' : 'light');
},
applyTheme() {
const root = document.documentElement;
// 切换暗黑模式
if (this.mode === 'dark') {
root.classList.add('dark');
document.body.setAttribute('arco-theme', 'dark');
} else {
root.classList.remove('dark');
document.body.removeAttribute('arco-theme');
}
// 切换主题色
root.setAttribute('data-theme-color', this.color);
// 通知所有已挂载的子应用
window.dispatchEvent(new CustomEvent('micro:theme-change', {
detail: { mode: this.mode, color: this.color }
}));
},
// 初始化主题:从子应用同步
initTheme() {
this.applyTheme();
}
}
});
5.2 CSS 变量体系
scss
// src/assets/styles/variables.scss
// 亮色主题 - 蓝色(默认)
:root,
[data-theme-color='blue'] {
--color-primary: #1890ff;
--color-primary-hover: #40a9ff;
--color-primary-active: #096dd9;
--color-primary-light: #e6f7ff;
--color-primary-bg: #bae7ff;
--color-success: #52c41a;
--color-warning: #faad14;
--color-danger: #ff4d4f;
--color-info: #1890ff;
--bg-color-page: #f5f7fa;
--bg-color-container: #ffffff;
--bg-color-sidebar: #001529;
--bg-color-header: #ffffff;
--text-color-primary: #303133;
--text-color-regular: #606266;
--text-color-secondary: #909399;
--text-color-placeholder: #c0c4cc;
--border-color-base: #dcdfe6;
--border-color-light: #e4e7ed;
--border-color-lighter: #ebeef5;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--sidebar-width: 220px;
--sidebar-collapsed-width: 64px;
--header-height: 56px;
}
// 亮色主题 - 绿色
[data-theme-color='green'] {
--color-primary: #52c41a;
--color-primary-hover: #73d13d;
--color-primary-active: #389e0d;
--color-primary-light: #f6ffed;
--color-primary-bg: #d9f7be;
}
// 亮色主题 - 橙色
[data-theme-color='orange'] {
--color-primary: #fa8c16;
--color-primary-hover: #ffa940;
--color-primary-active: #d46b08;
--color-primary-light: #fff7e6;
--color-primary-bg: #ffe7ba;
}
// 暗黑主题覆盖
.dark {
--color-primary: #1890ff;
--color-primary-hover: #40a9ff;
--color-primary-active: #096dd9;
--color-primary-light: #111d2c;
--color-primary-bg: #153450;
--bg-color-page: #0f1117;
--bg-color-container: #1a1c23;
--bg-color-sidebar: #14161c;
--bg-color-header: #1a1c23;
--text-color-primary: #e5e5e5;
--text-color-regular: #b0b0b0;
--text-color-secondary: #808080;
--text-color-placeholder: #505050;
--border-color-base: #333;
--border-color-light: #3a3a3a;
--border-color-lighter: #444;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}
5.3 主题切换组件
vue
<!-- src/components/ThemeToggle.vue -->
<template>
<el-dropdown trigger="click" @command="handleCommand">
<el-button text>
<el-icon :size="18">
<Sunny v-if="!themeStore.isDark" />
<Moon v-else />
</el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="mode">
<el-icon>
<Sunny v-if="themeStore.isDark" />
<Moon v-else />
</el-icon>
{{ themeStore.isDark ? '切换亮色' : '切换暗黑' }}
</el-dropdown-item>
<el-dropdown-item divided>
<span>主题色</span>
</el-dropdown-item>
<el-dropdown-item
v-for="color in themeColors"
:key="color.value"
:command="`color-${color.value}`"
>
<span
class="theme-dot"
:style="{ background: color.hex }"
/>
{{ color.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { useThemeStore, type ThemeColor } from '@/stores/theme';
const themeStore = useThemeStore();
const themeColors: { label: string; value: ThemeColor; hex: string }[] = [
{ label: '默认蓝', value: 'blue', hex: '#1890ff' },
{ label: '极客绿', value: 'green', hex: '#52c41a' },
{ label: '活力橙', value: 'orange', hex: '#fa8c16' },
{ label: '优雅紫', value: 'purple', hex: '#722ed1' },
{ label: '烈焰红', value: 'red', hex: '#f5222d' }
];
function handleCommand(command: string) {
if (command === 'mode') {
themeStore.toggleMode();
} else if (command.startsWith('color-')) {
const color = command.replace('color-', '') as ThemeColor;
themeStore.setColor(color);
}
}
</script>
<style lang="scss" scoped>
.theme-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
</style>
六、国际化方案
6.1 国际化组合式函数
typescript
// src/composables/useI18n.ts
import { ref, computed } from 'vue';
import { storage } from '@/utils/storage';
type Lang = 'zh-CN' | 'en-US';
interface LangOption {
value: Lang;
label: string;
}
const messages: Record<Lang, Record<string, string>> = {
'zh-CN': {
'nav.dashboard': '工作台',
'nav.settings': '系统设置',
'nav.reactApp': 'React 应用',
'nav.vueApp': 'Vue 应用',
'user.profile': '个人中心',
'user.settings': '系统设置',
'user.logout': '退出登录',
'theme.light': '亮色模式',
'theme.dark': '暗黑模式',
'app.title': '微前端平台',
'micro.loading': '子应用加载中...',
'micro.error': '子应用加载失败',
'micro.retry': '点击重试',
'error.boundary': '页面出错了',
'error.boundary.desc': '请刷新页面或联系技术支持'
},
'en-US': {
'nav.dashboard': 'Dashboard',
'nav.settings': 'Settings',
'nav.reactApp': 'React App',
'nav.vueApp': 'Vue App',
'user.profile': 'Profile',
'user.settings': 'Settings',
'user.logout': 'Logout',
'theme.light': 'Light Mode',
'theme.dark': 'Dark Mode',
'app.title': 'Micro Frontend Platform',
'micro.loading': 'Loading micro app...',
'micro.error': 'Micro app failed to load',
'micro.retry': 'Click to retry',
'error.boundary': 'Something went wrong',
'error.boundary.desc': 'Please refresh or contact support'
}
};
const currentLang = ref<Lang>(storage.get('lang') || 'zh-CN');
const languages: LangOption[] = [
{ value: 'zh-CN', label: '简体中文' },
{ value: 'en-US', label: 'English' }
];
export function useI18n() {
const t = (key: string): string => {
return messages[currentLang.value]?.[key] || key;
};
const appTitle = computed(() => t('app.title'));
function changeLang(lang: Lang) {
currentLang.value = lang;
storage.set('lang', lang);
// 通知子应用
window.dispatchEvent(new CustomEvent('micro:lang-change', {
detail: { lang }
}));
}
return {
currentLang,
languages,
t,
appTitle,
changeLang
};
}
6.2 与子应用的国际化同步
typescript
// src/micro/i18n-sync.ts
import { useI18n } from '@/composables/useI18n';
// 监听子应用语言切换请求
window.addEventListener('micro:lang-change-request', (e: CustomEvent) => {
const { lang } = e.detail;
const { changeLang } = useI18n();
changeLang(lang);
});
// 向子应用广播当前语言
export function syncI18nToMicroApps() {
const { currentLang } = useI18n();
window.dispatchEvent(new CustomEvent('micro:lang-change', {
detail: { lang: currentLang.value }
}));
}
// 在子应用加载时同步
export function setupI18nSync() {
// 子应用挂载后自动同步
window.addEventListener('micro:app-mounted', () => {
syncI18nToMicroApps();
});
}
七、错误边界与降级处理
7.1 错误边界组件
vue
<!-- src/components/ErrorBoundary.vue -->
<template>
<div v-if="hasError" class="error-boundary">
<div class="error-boundary__content">
<el-result
icon="error"
:title="t('error.boundary')"
:sub-title="errorMessage"
>
<template #extra>
<el-button type="primary" @click="handleRefresh">
{{ t('micro.retry') }}
</el-button>
<el-button @click="handleGoHome">
返回首页
</el-button>
</template>
</el-result>
<details v-if="isDev" class="error-boundary__details">
<summary>错误详情(开发环境可见)</summary>
<pre>{{ errorDetail }}</pre>
</details>
</div>
</div>
<slot v-else />
</template>
<script setup lang="ts">
import { ref, computed, onErrorCaptured } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
fallback?: boolean;
}>();
const router = useRouter();
const { t } = useI18n();
const hasError = ref(false);
const errorMessage = ref('');
const errorDetail = ref('');
const isDev = computed(() =>
import.meta.env.VITE_APP_ENV === 'development'
);
onErrorCaptured((err, instance, info) => {
hasError.value = true;
errorMessage.value = err instanceof Error
? err.message
: '未知错误';
errorDetail.value = `
错误: ${err}
组件: ${instance?.$options?.name || '未知'}
信息: ${info}
时间: ${new Date().toISOString()}
`.trim();
return false;
});
function handleRefresh() {
hasError.value = false;
errorMessage.value = '';
errorDetail.value = '';
window.location.reload();
}
function handleGoHome() {
hasError.value = false;
router.push('/');
}
</script>
<style lang="scss" scoped>
.error-boundary {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 400px;
&__content {
text-align: center;
}
&__details {
margin-top: 20px;
text-align: left;
pre {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
font-size: 12px;
max-height: 300px;
overflow: auto;
}
}
}
</style>
7.2 子应用降级 UI
vue
<!-- src/components/MicroAppFallback.vue -->
<template>
<div class="micro-fallback">
<el-result
icon="warning"
:title="t('micro.error')"
:sub-title="errorMessage"
>
<template #extra>
<el-button type="primary" @click="$emit('retry')">
{{ t('micro.retry') }}
</el-button>
<el-button @click="handleSkip">
跳过此应用
</el-button>
</template>
</el-result>
<!-- 可选:显示降级后的静态内容 -->
<div v-if="degradedContent" class="micro-fallback__degraded">
<h3>降级内容</h3>
<p>{{ degradedContent }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
error: Error | string | null;
appName?: string;
}>();
defineEmits<{
retry: [];
}>();
const router = useRouter();
const { t } = useI18n();
const errorMessage = computed(() => {
if (!props.error) return t('micro.error');
if (typeof props.error === 'string') return props.error;
return props.error.message || t('micro.error');
});
const degradedContent = computed(() => {
// 根据子应用名称返回降级内容
const degradedMap: Record<string, string> = {
'app-dashboard': '仪表盘暂不可用,请稍后重试',
'app-report': '报表系统维护中'
};
return props.appName ? degradedMap[props.appName] : '';
});
function handleSkip() {
router.push('/');
}
</script>
<style lang="scss" scoped>
.micro-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
&__degraded {
margin-top: 24px;
padding: 20px;
background: var(--bg-color-container);
border-radius: 8px;
border: 1px dashed var(--border-color-base);
max-width: 480px;
}
}
</style>
7.3 全局错误处理
typescript
// src/utils/errorHandler.ts
import type { App } from 'vue';
export function setupGlobalErrorHandler(app: App) {
// Vue 全局错误
app.config.errorHandler = (err, instance, info) => {
console.error('[GlobalError] Vue 错误:', err);
console.error('[GlobalError] 组件:', instance?.$options?.name);
console.error('[GlobalError] 信息:', info);
// 上报监控平台
reportError({
type: 'vue',
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : '',
component: instance?.$options?.name,
info
});
};
// 未捕获的 Promise 错误
window.addEventListener('unhandledrejection', (event) => {
console.error('[GlobalError] 未捕获的 Promise 错误:', event.reason);
reportError({
type: 'promise',
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack || ''
});
});
// 运行时错误
window.addEventListener('error', (event) => {
if (event.target !== window) return; // 只处理全局错误
console.error('[GlobalError] 运行时错误:', event.error);
reportError({
type: 'runtime',
message: event.error?.message || String(event.error),
stack: event.error?.stack || ''
});
});
}
interface ErrorReport {
type: string;
message: string;
stack?: string;
component?: string;
info?: string;
timestamp?: number;
}
function reportError(report: ErrorReport) {
report.timestamp = Date.now();
// 发送到监控平台
if (window.__monitor__) {
window.__monitor__.reportError(report);
}
// 开发环境也打印到控制台
if (import.meta.env.DEV) {
console.table(report);
}
}
八、全局 Loading 与过渡动画
8.1 全局 Loading 组件
vue
<!-- src/components/AppLoading.vue -->
<template>
<transition name="loading-fade">
<div v-if="visible" class="app-loading">
<div class="app-loading__spinner">
<svg class="app-loading__icon" viewBox="0 0 50 50">
<circle
class="path"
cx="25" cy="25" r="20"
fill="none"
stroke="currentColor"
stroke-width="4"
/>
</svg>
<p class="app-loading__text">{{ text }}</p>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
defineProps<{
visible: boolean;
text?: string;
}>();
</script>
<style lang="scss" scoped>
.app-loading {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
background: var(--bg-color-page);
&__spinner {
text-align: center;
}
&__icon {
width: 40px;
height: 40px;
animation: rotate 2s linear infinite;
color: var(--color-primary);
.path {
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
}
&__text {
margin-top: 16px;
color: var(--text-color-secondary);
font-size: 14px;
}
}
@keyframes rotate {
100% { transform: rotate(360deg); }
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
.loading-fade-enter-active,
.loading-fade-leave-active {
transition: opacity 0.3s ease;
}
.loading-fade-enter-from,
.loading-fade-leave-to {
opacity: 0;
}
</style>
8.2 子应用切换过渡动画
typescript
// src/micro/transitions.ts
// 子应用切出前等待动画完成
const TRANSITION_DURATION = 300;
export function withTransition(
action: () => Promise<void>,
container: HTMLElement
): Promise<void> {
return new Promise((resolve) => {
container.classList.add('micro-transition-exit');
container.style.opacity = '0';
container.style.transform = 'translateX(20px)';
setTimeout(async () => {
await action();
container.classList.remove('micro-transition-exit');
container.classList.add('micro-transition-enter');
container.style.opacity = '1';
container.style.transform = 'translateX(0)';
setTimeout(() => {
container.classList.remove('micro-transition-enter');
resolve();
}, TRANSITION_DURATION);
}, TRANSITION_DURATION);
});
}
scss
// 全局样式补充
.micro-transition-exit {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.micro-transition-enter {
transition: opacity 0.3s ease, transform 0.3s ease;
}
九、基座应用发布与部署
9.1 Nginx 配置
nginx
# nginx/portal.conf
upstream portal_api {
server api.company.com:9090;
}
server {
listen 80;
server_name portal.company.com;
# 基座应用静态资源
location / {
root /data/www/portal/dist;
index index.html;
try_files $uri $uri/ /index.html;
# 强缓存:带 hash 的资源
location ~* \.(js|css|png|jpg|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# API 代理
location /api/ {
proxy_pass http://portal_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 子应用入口(CORS 配置)
location /micro/ {
proxy_pass http://portal_api;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization";
}
}
9.2 Docker 部署
dockerfile
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:stable-alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx/portal.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
9.3 构建脚本
json
// package.json scripts
{
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:staging": "vite build --mode staging",
"build:prod": "vite build --mode production",
"preview": "vite preview",
"docker:build": "docker build -t portal-platform:latest .",
"docker:run": "docker run -d -p 80:80 portal-platform:latest",
"analyze": "vite build --analyze"
}
}
通过本文,你已经搭建了一个生产可用的微前端 Portal 基座,包含:
- 完整的布局框架(Header + Sidebar + Content)
- 基于 qiankun 的子应用注册中心
- 动态加载与预加载策略
- 全局主题系统(暗黑模式 + 多主题色)
- 国际化方案
- 错误边界与降级处理
- 全局 Loading 与过渡动画
- 发布部署配置