single-spa示例
single-spa 示例
项目地址
搭建基座项目
在命令行中运行以下命令
pnpm create vue@latest跟着步骤搭建项目
基座应用改造
删除无用文件,仅保留 App.vue,删除 router/index.ts 中无用路由,删除 src/main.css 中无用样式
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [],
});
export default router;/* src/main.css */
@import './base.css';
#app {
  width: 100%;
  height: 100vh;
}安装个性化插件
安装 ui 框架和 sass 处理器
pnpm install ant-design-vue@4.x @ant-design/icons-vue --save
pnpm install sass sass-loader --save-dev改造 App.vue
<script setup lang="ts">
import { ref, type CSSProperties, VueElement, reactive } from 'vue';
import type { ItemType, MenuProps } from 'ant-design-vue';
import router from './router';
const layoutStyle: CSSProperties = {
  height: '100%',
};
const headerStyle: CSSProperties = {
  textAlign: 'center',
  lineHeight: '60px',
  color: '#fff',
};
const contentStyle: CSSProperties = {};
const siderStyle: CSSProperties = {};
const selectedKeys = ref<string[]>(['Vue2']);
const openKeys = ref<string[]>(['1']);
function getItem(
  label: VueElement | string,
  key: string,
  icon?: any,
  children?: ItemType[],
  type?: 'group'
): ItemType {
  return {
    key,
    icon,
    children,
    label,
    type,
  } as ItemType;
}
const items: ItemType[] = reactive([
  getItem('子项目', '1', null, [
    getItem('Vue', 'vue', null, [], 'group'),
    getItem('React', 'react', null, [], 'group'),
  ]),
]);
const handleClick: MenuProps['onClick'] = (e) => {
  router.push({
    path: `/${e.key}`,
  });
};
</script>
<template>
  <a-layout :style="layoutStyle">
    <a-layout-sider :style="siderStyle">
      <a-menu
        v-model:openKeys="openKeys"
        v-model:selectedKeys="selectedKeys"
        mode="inline"
        :items="items"
        @click="handleClick"
      ></a-menu
    ></a-layout-sider>
    <a-layout>
      <a-layout-header :style="headerStyle">子项目</a-layout-header>
      <!-- #microApp 为子项目挂载/渲染容器 -->
      <a-layout-content :style="contentStyle" id="microApp"></a-layout-content>
    </a-layout>
  </a-layout>
</template>
<style lang="scss">
.ant-layout-content {
  overflow-y: auto;
}
</style>使用 single-spa
安装 single-spa
pnpm install single-spa改造 main.ts
import './assets/main.css';
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import Antd from 'ant-design-vue';
import { registerApplication, start } from 'single-spa';
const apps = [];
// 注册子应用
for (let i = apps.length - 1; i >= 0; i--) {
  registerApplication(apps[i]);
}
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(Antd);
app.mount('#app');
start();集合 Vue2(webpack) 子项目
这里使用了现成的模板项目
引入 single-spa-vue
在子项目中引入 single-spa-vue
pnpm install single-spa-vue改造 main.ts
import singleSpaVue from 'single-spa-vue';
const appOptions = {
  el: '#microApp', // 这里对应基座项目中用于渲染子项目的id
  router,
  render: (h: any) => h(App),
};
const init = async () => {
  await getConfig(() => {
    // 单独运行
    if (!(window as any).singleSpaNavigate) {
      new Vue({
        router,
        render: (h) => h(App),
      }).$mount('#app');
    }
  });
};
init();
// 基于基座应用,导出生命周期函数
const vueLifecycle = singleSpaVue({
  Vue,
  appOptions,
});
export function bootstrap(props: any) {
  console.log('app1 bootstrap');
  return vueLifecycle.bootstrap(() => {});
}
export function mount(props: any) {
  console.log('app1 mount');
  return vueLifecycle.mount(() => {});
}
export function unmount(props: any) {
  console.log('app1 unmount');
  return vueLifecycle.unmount(() => {});
}改造 vue.config.js
通常情况下,通过 webpack 构建工具生成的 js 脚本,各个子应用对应的 js 脚本执行时是相互隔离的。
为了打破这种隔离,我们就需要对 output 配置项做改造,添加 libaray、libraryTarget 配置项,将子应用入口文件的返回值即生命周期方法暴露给 window,这样基座应用就可以从 window 中获取子应用的生命周期方法。
// vue.config.js
const { defineConfig } = require('@vue/cli-service');
const package = require('./package.json');
module.exports = defineConfig({
  configureWebpack: {
    publicPath: 'http://localhost:8081',
    output: {
      library: package.name,
      libraryTarget: 'umd',
    },
  },
});配置环境变量
配置环境变量,分为独立运行和微前端运行
在子项目根目录创建 .env 独立启动环境变量
NODE_ENV=development
VUE_APP_BASE_URL=/NODE_ENV=development
VUE_APP_BASE_URL=/app1NODE_ENV=development
VUE_APP_BASE_URL=/app1改造路由
路由使用 history 模式,并使用环境变量中配置好的 BASE_URL
const createRouter = () =>
  new VueRouter({
    mode: 'history', // history模式
    scrollBehavior: (to, from, savedPosition) => {
      if (savedPosition) {
        return savedPosition;
      } else {
        return { x: 0, y: 0 };
      }
    },
    base: process.env.VUE_APP_BASE_URL, // 使用环境变量中配置的
    routes: routers,
  });修改 package.json
修改 name,并配置微前端启动命令
{
  "name": "app1",
  "description": "single-spa子应用1(vue2)",
  "scripts": {
    "serve": "vue-cli-service serve",
    "serve:micro": "vue-cli-service serve --mode micro",
    "build": "vue-cli-service build --mode buildMicro"
  }
}运行项目
单独运行时
pnpm run serve作为微前端子应用运行时
pnpm run serve:micro两者没有太大区别,只不过会在 pathname 的开头加了/app1 前缀
注册子应用
// layout/src/main.ts
const createScript = (url: string) => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    const firstScript = document.getElementsByTagName('script')[0];
    firstScript.parentNode?.insertBefore(script, firstScript);
  });
};
function loadVue2App(url: string, globalVar: string) {
  // 支持远程加载子应用
  return async () => {
    await createScript(url + '/js/chunk-vendors.js');
    await createScript(url + '/js/app.js');
    // 这里的return很重要,需要从这个全局对象中拿到子应用暴露出来的生命周期函数
    return window[globalVar as keyof typeof window];
  };
}
const apps = [
  {
    name: 'app1',
    app: loadVue2App('http://localhost:8081', 'app1'),
    activeWhen: (location: any) => location.pathname.startsWith('/app1'),
    customProps: {},
  },
];
// 注册子应用
for (let i = apps.length - 1; i >= 0; i--) {
  registerApplication(apps[i]);
}测试效果
在 layout/App.vue 中增加入口
const items: ItemType[] = reactive([
  getItem('子项目', '1', null, [
    getItem('Vue', 'vue', null, [getItem('Vue2-1', 'app1')], 'group'),
    getItem('React', 'react', null, []),
  ]),
]);在子应用中添加组件、路由跳转等功能。
切换子应用
根据集合 Vue2(webpack) 子项目相同的步骤,创建子应用 app2,并将 app2 集合到基座应用
const apps = [
  {
    name: 'app1',
    app: loadVue2App('http://localhost:8081', 'app1'),
    activeWhen: (location: any) => location.pathname.startsWith('/app1'),
    customProps: {},
  },
  {
    name: 'app2',
    app: loadVue2App('http://localhost:8082', 'app2'),
    activeWhen: (location: any) => location.pathname.startsWith('/app2'),
    customProps: {},
  },
];const items: ItemType[] = reactive([
  getItem('子项目', '1', null, [
    getItem(
      'Vue',
      'vue',
      null,
      [getItem('Vue2-1', 'app1'), getItem('Vue2-2', 'app2')],
      'group'
    ),
    getItem('React', 'react', null, [], 'group'),
  ]),
]);子应用路由跳转报错
此时发现子应用中路由变更后切换子应用会出现报错的问题,详见single-spa 子应用路由跳转报错
集合 Vue3(vite+rollup) 子项目
搭建项目
在命令行中运行以下命令
pnpm create vue@latest跟着步骤搭建项目
改造项目
删除 main.css 中会印象基座应用的样式
安装 single-spa-vue
pnpm install single-spa-vue修改 main.ts
import './assets/main.css';
import { createApp, h } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import singleSpaVue from 'single-spa-vue';
// 单独运行
if (!(window as any).singleSpaNavigate) {
  const app = createApp(App);
  app.use(createPinia());
  app.use(router);
  app.mount('#app');
}
// 基于基座应用,导出生命周期函数
const vueLifecycle = singleSpaVue({
  createApp,
  appOptions: {
    el: '#microApp',
    render() {
      return h(App, {
        name: this.name,
      });
    },
  },
  handleInstance(app) {
    app.use(createPinia());
    app.use(router);
  },
});
export const bootstrap = vueLifecycle.bootstrap;
export const mount = vueLifecycle.mount;
export const unmount = vueLifecycle.unmount;配置环境变量
配置环境变量,分为独立运行和微前端运行
在子项目根目录创建 .env 独立启动环境变量
NODE_ENV=development
VITE_APP_BASE_URL=/NODE_ENV=development
VITE_APP_BASE_URL=/app3NODE_ENV=development
VITE_APP_BASE_URL=/app3将配置好的 BASE_URL 配置到路由中
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
  history: createWebHistory(import.meta.env.VITE_APP_BASE_URL),
  routes: [],
});注册子应用
function loadVue3App(url: string) {
  return async () => {
    return import(`${url}/src/main.ts`);
  };
}
const apps = [
  ...,
  {
    name: 'app3',
    app: loadVue3App('http://localhost:5173'),
    activeWhen: (location: any) => location.pathname.startsWith('/app3'),
    customProps: {},
  },
];集合 React18(vite+ts) 子项目
搭建项目
使用 vite 搭建 React18 项目
pnpm create vite选择 React 框架,并选择使用 TypeScript
警告
选择 TypeScript,不要选择 TypeScript + SWC,实测会出现问题导致子应用加载失败
构建完成后进入项目目录并安装依赖
cd ./app4
pnpm install引入 single-spa-react
引入 single-spa-react
pnpm i single-spa-react改造项目
先定义环境变量
NODE_ENV=development
VITE_APP_BASE_URL=/NODE_ENV=development
VITE_APP_BASE_URL=/app4NODE_ENV=development
VITE_APP_BASE_URL=/app4这里引入 antd 作为 ui 框架,并使用 react-router-dom 进行路由管理
import { ConfigEnv, defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig((env: ConfigEnv) => {
  const { mode } = env;
  return {
    plugins: [react()],
    base: loadEnv(mode, process.cwd()).VITE_APP_BASE_URL,
    server: {
      port: 8084,
    },
  };
});import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import 'dayjs/locale/zh-cn';
import router from './router';
import { RouterProvider } from 'react-router-dom';
interface ISingle {
  singleSpaNavigate: boolean;
}
type IWindow = ISingle & Window & typeof globalThis;
import singleSpaReact from 'single-spa-react';
console.log(router);
const rootComponent = () => {
  return (
    <React.StrictMode>
      <ConfigProvider locale={zhCN}>
        <RouterProvider router={router}></RouterProvider>
      </ConfigProvider>
    </React.StrictMode>
  );
};
if (!(window as IWindow).singleSpaNavigate) {
  ReactDOM.createRoot(document.getElementById('root')!).render(rootComponent());
}
const reactLifecycle = singleSpaReact({
  React,
  ReactDOMClient: ReactDOM,
  rootComponent: rootComponent,
  domElementGetter() {
    return document.getElementById('microApp')!;
  },
  errorBoundary(err, errInfo, props) {
    console.log(err, errInfo, props);
    return <p>is Error</p>;
  },
});
export const bootstrap = async (props: any) => {
  return reactLifecycle.bootstrap(props);
};
export const mount = async (props: any) => {
  console.log(props);
  return reactLifecycle.mount(props);
};
export const unmount = async (props: any) => {
  return reactLifecycle.unmount(props);
};import { createBrowserRouter } from 'react-router-dom';
import Root from '../views/root';
import NextPage from '../views/next';
const router = createBrowserRouter(
  [
    {
      path: '/',
      element: <Root />,
    },
    {
      path: '/nextPage',
      element: <NextPage />,
    },
  ],
  {
    basename: import.meta.env.VITE_APP_BASE_URL,
  }
);
export default router;注册子应用
function loadReactApp(url: string) {
  return async () => {
    return import(url + '/src/main.tsx')
  }
}
const apps = [
  ...,
  {
    name: 'app4',
    app: loadReactApp('http://localhost:8084/app4'),
    activeWhen: (location: any) => location.pathname.startsWith('/app4'),
    customProps: {}
  }
]这里选用的是 vite 构建的项目,会出现问题,详见single-spa 使用 vite-react 作为子项目开发环境报错
