VUE3+TS+Vite+微前端(qiankun)

1,617 阅读6分钟

概述:

公司的旧项目采用vue2+element-ui,项目重,性能较差,考虑到vue3逐渐成熟,而且很好的支持了tree shaking优化,开始起步。新的模块单独开个vue3项目,构建工具使用vite(启动速度更快,热更新),将vue3集成在vue2中使用微前端qiankun(很多坑,慢慢填)

1. 旧项目改造,使vue2支持vue3语法

npm install @vue/composition-api
npm install unplugin-vue2-script-setup //支持vue3 setup语法糖
// main.js
import VueCompositionApi from '@vue/composition-api' 
Vue.use(VueCompositionApi)
const ScriptSetup = require('unplugin-vue2-script-setup/webpack').default 
module.exports = { configureWebpack: (config)=>{ config.plugins.push(ScriptSetup({})); }, }

以上3步完成后就可以这样写代码啦

<script setup>
</script>
<template>
  <div>
  </div>
</template>
<style scoped>
</style>

目前遗留问题:打包到生产时有时会报语法错误,未解决... (已更换为原vue2语法)

2.将vue2项目设为基座(父应用)

1.引入qiankun

npm i qiankun -S

2.给定一个容器来承载这个子应用

<div id="subAPP"></div>
此容器可写在app.vue页面,也可写在layout页面,具体情况根据项目位置定。
//由于此项目要作为子菜单渲染(包含layot),我是写在了layout router-view下面占位。

<section class="app-main">
<transition name="fade-transform" mode="out-in">
  <keep-alive :include="cachedViews" v-if="isReload">
    <router-view :key="key" />
  </keep-alive>
</transition>
<div id="subAPP" class="subAPP"></div>
</section>

3.main.js注册

import { registerMicroApps, start } from "qiankun";
registerMicroApps([
  {
    name: "subAPP",
    entry: process.env.VUE_APP_SECRET || 'http://127.0.0.1:9000/',
    container: "#subAPP",
    activeRule: "/#/subAPP",
    props: {
      parentActions: actions
    }
  }
]);
// 注册微前端名称为subAPP  container容器的id是subAPP 
// entry为微应用的入口,此处是子应用抛出的地址
// activeRule是校验规则 当匹配到路由时渲染子应用,类似路由
start();
上述逻辑要写在new Vue的render前面,否则进入子应用页面时会闪动一下(先渲染了子应用)
new Vue({
  el: "#app",
  router,
  store,
  render: h => h(App)
});

4.router js添加路由匹配

  {
    path: "/subAPP/:chapters*",  path修改为'/subAPP/*' 前者会导致子路由刷新地址栏/变为编码导致路由丢失
    name: "subAPP",
    component: Layout
  }
  // 浏览器输入以/subAPP开始的路由都可以正常进入页面,内容由微应用自己渲染,layout页面带有菜单栏

3.搭建新项目,将其作为父应用的子应用。

1.构建vite基础项目

由于vite本身不支持qiankun,安装插件vite-plugin-qiankun。如使用webpack构建可直接参考微前端qiankun官网qiankun.umijs.org/zh/guide/ge…

npm create vite@latest   (vite官网:https://cn.vitejs.dev/guide/)  
npm install vite-plugin-qiankun

2.viteconfig.js文件

1.从插件中引入qiankun,在plugins添加 qiankun('subAPP', {useDevMode: true})
2.修改serve的host和父应用相同,同时配置headers允许跨域
下面贴出全部代码

import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import path from 'path';
import qiankun from "vite-plugin-qiankun"
const optimizeDepsElementPlusIncludes = ["element-plus/es"]
fs.readdirSync("node_modules/element-plus/es/components").map((dirname) => {
  fs.access(
    `node_modules/element-plus/es/components/${dirname}/style/css.mjs`,
    (err) => {
      if (!err) {
        optimizeDepsElementPlusIncludes.push(
          `element-plus/es/components/${dirname}/style/css`
        )
      }
    }
  )
})
export default ({ command, mode }: any) => {
  return defineConfig({
  //  env文件配置的环境地址,
    base: loadEnv(mode, process.cwd()).VITE_APP_BASE_PATH,
    // 强制预加载   微应用首次打开会很慢,
    optimizeDeps: {
      include: optimizeDepsElementPlusIncludes
    },
    plugins: [
      vue(),
      vueJsx(),
      qiankun('subAPP', {
        useDevMode: true
      }),
    ],
    server: {
      host: '127.0.0.1',
      port: Number(loadEnv(mode, process.cwd()).VITE_APP_PORT) || 3000,
      headers: {
        'Access-Control-Allow-Origin': '*'
      },
      https: false,
      open: true,
      proxy: {
        '/api': {
          target: 'http://172.19.128.97:8086',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/api/, '')
        },
      },
      hmr: {
        overlay: true
      }
    },
    resolve: {
      alias: {
        '@': path.resolve(__dirname, './src')
      }
    }
  })
}

3.main.ts文件变更

1.引入qiankunWindow和renderWithQiankun
2.重写render方法

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import "@/utils/auth.js";
import {
    qiankunWindow,
    renderWithQiankun,
} from 'vite-plugin-qiankun/dist/helper';
let app: any;
function render(props: any) {
    const { container, parentActions } = props;
    app = createApp(App);
    app.use(router).mount(container instanceof Element
        ? (container.querySelector("#app"))
        : (container)
    );
}
如果qiankun实例不存在__POWERED_BY_QIANKUN__属性说明是子应用独立运行,此时直接将子应用挂载到子应用的app根节点
如果存在说明有父应用,直接挂载父应用传递下来的container容器(父容器设置的承载容器)
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
    render({ container: "#app" });
} else {
    renderWithQiankun({
        mount(props) {
            每次挂载都重新执行render方法
            render(props)
        },
        bootstrap() {
        },
        update() {
        },
        unmount() {
            卸载时同时卸载子应用实例
            app.unmount();
        }
    });
}

4.子应用路由文件

const router = createRouter({
  history: createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? '/#/subAPP2/' : '/'),
  routes
})
//后经验证 上面createWebHistory的参数有大坑,他会作为base 将/#/subAPP拼接至浏览器地址栏,影响浏览器history.pushState的调用(其实是影响了浏览器回车时候的url hash变更 导致路由错误)
// 所以改成下面这种写法

// 路由跳转需加 /subApp_cockpit 前缀   批量给路由加上前缀,而不去改base的值,经测试 浏览器回车跳转正常
const prefix = qiankunWindow.__POWERED_BY_QIANKUN__ ? '/subApp_cockpit' : ''
const addAlias = (routes: any): any => {
  if (!qiankunWindow.__POWERED_BY_QIANKUN__) return
  for (let route of routes) {
    let path = route.path
    if (path && path.startsWith('/') && !path.startsWith(prefix)) {
      route.alias = prefix + path
    }
    if (route.children) {
      addAlias(route.children)
    }
  }
}
addAlias(routes)
const router = createRouter({
  history: createWebHashHistory(),
  routes
})
// 路由跳转前将路由信息传递给父应用, 作菜单显示
router.beforeEach((to: any, from, next) => {
  actions.setGlobalState({ subRoute: to })
  next()
})
export default router

5.子应用actions文件封装(不进行父子交互的话可不封装)

action.ts文件 
function emptyAction() {
}
class Actions {
    actions = {
        onGlobalStateChange: emptyAction,
        setGlobalState: emptyAction,
    };
    setActions(actions) {
        this.actions = actions;
    }
    onGlobalStateChange() {
        return this.actions.onGlobalStateChange(...arguments);
    }
    setGlobalState() {
        return this.actions.setGlobalState(...arguments);
    }
}

const actions = new Actions();
export default actions;

main.ts
import actions from './utils/action'
  const { container, parentActions } = props;
  parentStoreData.value = props.parentStore
  if (parentActions) {
    actions.setActions(parentActions)
  }

4.微应用一系列问题处理

上述操作做完之后,可以看到浏览器把配置子应用路由的菜单渲染出来,但是敏锐的你会发现引发了一系列问题。

1.样式污染

子应用渲染出来后对父应用样式产生了影响,其他非微应用页面样式全部错乱,通过排查,发现影响的样式都来自于子应用的ui样式文件。 (父应用的样式也有了子应用的element plus的样式) 首先能想到的肯定是沙箱隔离,在qiankun官网提供了sandbox,可以start({strictStyleIsolation: true, experimentalStyleIsolation: true})开启沙箱模式,遗憾的是问题并没有解决,子应用页面样式失效。 去翻element plus发现有一个命名空间

1.app.vue采用 elment-plus组件包裹

  <el-config-provider namespace="ep">
    <router-view />
  </el-config-provider>
  此时打开f12可以看到子应用的ui样式前缀全部变成了ep开头,但同时样式也失效了

2.设置 SCSS 和 CSS 变量

创建 styles/element/index.scss

@forward 'element-plus/theme-chalk/src/mixins/config.scss' with (
  $namespace: 'ep'
);
// 固定写法

3.在 vite.config.ts 中导入 styles/element/index.scss

import { defineConfig } from 'vite'
export default defineConfig({
  // ...
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "~/styles/element/index.scss" as *;`,
        // 路径和上述新建文件保持一致
      },
    },
  },
  // ...
})
//此时独立打开子应用发现样式已经有了,前缀也是ep开头,但是在父应用中样式还是有污染

4.将element-plus全局导入改为按需导入

1.安装unplugin-vue-components 和 unplugin-auto-import这两款插件
npm install -D unplugin-vue-components unplugin-auto-import
2.然后把下列代码插入到 vite 的配置文件中,viteconfig.ts
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  // ...
  plugins: [
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      extensions: ['vue', 'md'],
        include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
        resolvers: [
          ElementPlusResolver({
            importStyle: 'sass',
          }),
        ],
    }),
  ],
})

此时再看子组件样式已经生效,父应用的样式也不会受到影响

5.此时发现其他ui样式均生效,但是ElMessage的相关类名上去了,样式却丢失(按需导入引起)

// main.js引入
import "element-plus/theme-chalk/src/message.scss"

2.与父应用数据交互

写到3-5了

3.刷新后报错 未找到容器

application 'subAPP' died in status LOADING_SOURCE_CODE: [qiankun]
将start调用时机从main.js放到layout页,由于是子路由页面渲染,需在加载layout后再调用start钩子
import {  start } from "qiankun"
mounted () {
    start()
},

5.使用svg

1.安装svg vite插件

npm install vite-plugin-svg-icons

2.viteconfig文件配置

1.import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
2.plugins添加
  plugins: [
      vue(),
      // svg文件存放目录
      createSvgIconsPlugin({
        iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
        symbolId: 'icon-[name]'
      })
    ],

3. svg组件代码 index.vue

//  packages/svgIcon/src/index.vue
<template>
  <svg aria-hidden="true" width="20px" height="20px">
    <use :class="fillClass" :xlink:href="symbolId" :fill="color" />
  </svg>
</template>

<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
  name: {
    type: String,
    required: true
  },
  color: {
    type: String
  },
  fillClass: {
    type: String
  },
  width: {
    type: [String, Number],
    default: 20
  },
  height: {
    type: [String, Number],
    default: 20
  },
})
const symbolId = computed(() => `#icon-${props.name}`)
</script>

4.svg组件注册

//  packages/svgIcon/index.ts 将svg文件导出
import SvgIcon from './src/index.vue'
import { withInstall } from '../withInstall'

const LbSvgIcon = withInstall(SvgIcon)
export default LbSvgIcon

// packages/withInstall.ts  
import { App, Plugin } from 'vue'
type SFCWithInstall<T> = T & Plugin
export const withInstall = <T, E extends Record<string, any>>(
  main: T,
  extra?: E
) => {
  ; (main as SFCWithInstall<T>).install = (app: App) => {
    for (const comp of [main, ...Object.values(extra ?? {})]) {
      app.component(comp.name, comp)
    }
  }
  if (extra) {
    for (const [compName, comp] of Object.entries(extra)) {
      ; (main as Record<string, any>)[compName] = comp
    }
  }
  return main as SFCWithInstall<T> & E
}
// packages/index.ts文件  将组件导出
import LbSvgIcon from './svgIcon'
const components: {
  [propName: string]: Component
} = {
  LbSvgIcon,
}
const installComponents: any = (app: App) => {
  for (const key in components) {
    app.component(key, components[key])
  }
}
const install: any = (app: any, router?: any) => {
  installComponents(app)
}
export {
  LbSvgIcon,
}
export default {
  install,
}  

// packages/components.d.ts 声明文件,组件可以高亮
import LbSvgIcon from './svgIcon/src/index.vue'
declare module 'vue' {
  export interface GlobalComponents {
    LbSvgIcon: typeof LbSvgIcon
  }
}

5.main.ts导入

import LbUi from './packages'
app = createApp(App);
app.use(LbUi)

6.main.ts完整代码

import { createApp, ref } from 'vue'
import 'tailwindcss/tailwind.css'
import '@/assets/common.css'
import App from './App.vue'
import router from './router'
import {
  qiankunWindow,
  renderWithQiankun,
} from 'vite-plugin-qiankun/dist/helper';
import 'ant-design-vue/dist/reset.css';
import actions from './utils/action'
import { createPinia } from 'pinia'

import adaptive from './directive/adaptive'
import { clear } from './utils/tools'
import LbUi from './packages'

let app: any;
const parentStoreData: any = ref();
router.afterEach(() => {
  if (!parentStoreData.value) return
  parentStoreData.value.dispatch("settings/changeSetting", {
    key: "subAppLoading",
    value: false
  })
})
function render(props: any) {
  const { container, parentActions } = props;
  parentStoreData.value = props.parentStore
  if (parentActions) {
    actions.setActions(parentActions)
  }
  app = createApp(App);
  app.config.devtools = true
  app.config.globalProperties.$parentActions = props.parentActions
  app.config.globalProperties.$parentRouter = props.parentRouter
  app.config.globalProperties.$clear = clear
  app.directive('adaptive', adaptive)
  app.use(createPinia())
  app.use(LbUi)
  app.use(router).mount(container instanceof Element
    ? (container.querySelector("#app"))
    : (container)
  );
}
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render({ container: "#app" });
} else {
  renderWithQiankun({
    mount(props) {
      render(props)
    },
    bootstrap() {
    },
    update() {
    },
    unmount() {
      app.unmount();
    }
  });
}
export { parentStoreData }