微前端 - qiankun

132 阅读3分钟

什么是微前端?

个人理解:微前端是一种策略,通过多个团队独立开发部署的方式,共同打造一个web应用的策略。

微前端有什么好处?

  • 不限技术
  • 独立开发、独立部署
  • 独立运行时,每个应用状态隔离,互不影响

项目实战(主应用:vite+vue3;微应用:react、vue2)

创建简单的空架子

首先,按官网项目实战步骤走:

主应用

主应用不区分技术栈,所以这里采用了最近比较火的vite + vue3 + ts

  1. 先安装qiankun
yarn add qiankun # 或者 npm i qiankun -S
  1. 在入口文件main.ts中注册并启动
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
  {
    name: 'app-react', // 微应用在package.json中的name
    entry: '//localhost:3000', // 微应用的路径
    container: '#reactContainer', // 微应用挂载的位置
    activeRule: '/micro-app-react', // 路由匹配规则(当匹配到此路径时,表示要加载此规则对应的微应用)
  },
  {
    name: 'app-vue',
    entry: '//localhost:8080',
    container: '#vueContainer',
    activeRule: '/micro-app-vue',
  },
]);
// 启动 qiankun
start();
  1. 创建微应用挂载位置的DOM
// App.vue
<template>
  <PrimaryMenu />
  <router-view></router-view>
  <div id="reactContainer"></div> // DOM挂载位置(react)
  <div id="vueContainer"></div>  // DOM挂载位置(vue2)
</template>

注意router-view和微应用#reactContainer的挂载位置,一般情况下,如果匹配了微应用的activeRule,就不会显示主应用的router-view

微应用

此示例中用有webpack的构建项目:react和vue2

react v18.2 + react-router-dom v6.8.1

  1. src下新建public-path.js文件
if (window.__POWERED_BY_QIANKUN__ && __webpack_public_path__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

此时,eslint会有报错,需要在package.json文件添加如下配置

"eslintConfig": {
    "globals": {
      "__webpack_public_path__": true
    },
  },
  1. 设置路由模式为history模式,并设置路由的basename
// router/index.js
import { createBrowserRouter } from 'react-router-dom';
import App from '../App';
import Test from '../components/Test';
const config = [
  {
    path: '/',
    element: <App />,
  },
  {
    path: 'test',
    element: <Test />,
  },
];
const router = createBrowserRouter(config, { basename: window.__POWERED_BY_QIANKUN__ ? '/micro-app-react' : '/' });

注意/micro-app-react要和主应用设置的activeRule相对应

  1. 入口文件index.js修改
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import './index.css';
import reportWebVitals from './reportWebVitals';
import router from './router';
​
function render(props) {
  const { container } = props;
  // 避免根id #root 与其他的 DOM 冲突
  const root = ReactDOM.createRoot(container ? container.querySelector('#root') : document.querySelector('#root'));
  // 挂载router
  root.render(<RouterProvider router={router} />);
}
​
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}
​
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'));
}
​
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
​
  1. 修改webpack配置

安装插件 @rescripts/cli

npm i -D @rescripts/cli

根目录新增 .rescriptsrc.js

const { name } = require('./package');module.exports = {
  webpack: (config) => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';
​
    return config;
  },
​
  devServer: (_) => {
    const config = _;
​
    config.headers = {
      'Access-Control-Allow-Origin': '*',
    };
    config.historyApiFallback = true;
    config.hot = false;
    config.watchContentBase = false;
    config.liveReload = false;
​
    return config;
  },
};

修改 package.json

-   "start": "react-scripts start",
+   "start": "rescripts start",
-   "build": "react-scripts build",
+   "build": "rescripts build",
-   "test": "react-scripts test",
+   "test": "rescripts test",
-   "eject": "react-scripts eject"

vue2

使用的是vue-cli5.0的版本

  1. src 目录新增 public-path.js

    if (window.__POWERED_BY_QIANKUN__) {
      __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
    }
    

    eslint报错webpack_public_path__未声明的问题,处理方式同react

  2. 入口文件 main.js 修改,为了避免根 id #app 与其他的 DOM 冲突,需要限制查找范围。

    import './public-path';
    // import Vue from 'vue';
    import Vue from "vue/dist/vue.esm.js"; // 注意这里:vue-cli5引入Vue的方式不一样
    import VueRouter from 'vue-router';
    import App from './App.vue';
    import routes from './router';
    // import store from './store';
    ​
    ​
    Vue.config.productionTip = false;
    Vue.use(VueRouter); // 注意这里:解决<router-view />标签报错解析不了的问题let router = null;
    let instance = null;
    function render(props = {}) {
      const { container } = props;
      router = new VueRouter({
        base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
        mode: 'history',
        routes,
      });
    ​
    ​
      instance = new Vue({
        router,
        // store,
        render: (h) => h(App),
      }).$mount(container ? container.querySelector('#app') : '#app');
    }
    ​
    ​
    // 独立运行时
    if (!window.__POWERED_BY_QIANKUN__) {
      render();
    }
    ​
    ​
    export async function bootstrap() {
      console.log('[vue] vue app bootstraped');
    }
    export async function mount(props) {
      console.log('[vue] props from main framework', props);
      render(props);
    }
    export async function unmount() {
      instance.$destroy();
      instance.$el.innerHTML = '';
      instance = null;
      router = null;
    }
    
  3. 打包配置修改(vue.config.js):

    const { defineConfig } = require('@vue/cli-service')
    const { name } = require('./package');
    module.exports = defineConfig({
      transpileDependencies: true,
      devServer: {
        headers: {
          'Access-Control-Allow-Origin': '*',
        },
      },
      configureWebpack: {
        output: {
          library: `${name}-[name]`,
          libraryTarget: 'umd', // 把微应用打包成 umd 库格式
          // jsonpFunction: `webpackJsonp_${name}`, // 此配置项有问题,暂不清楚咋回事???
        },
      },
    })
    ​
    

好了,到这里已经可以看到效果了

qiankun-初版效果.gif

再补充个vite和umi微应用

vite

懒了,不再详细补充了,给一个亲测有效的链接,上边也有demo

小坑:1. 主应用无法加载子应用的图片issues,查看加载路径是主应用路径/xxx.png; 2. 跨域

vite.config.ts

...
server: {
  origin: '微应用地址', // 解决主应用无法加载子应用图片问题
  cors: true, // 解决跨域
}

umi

umi专门有一个接入qiankun的插件,官网就有实现,链接

小坑:主应用请求子应用的接口404

app.tsx

export const request: RequestConfig = {
  prefix: '微应用地址', // 解决主应用请求子应用的接口404问题
  ...
}

核心功能填充

上边已经把架子搭好了,然后我们还需要使用很多的功能,所以下边我们填充核心功能

路由跳转

主应用

主应用的跳转没什么特别的,我直接用的el-menu

<el-menu
    router
    :default-active="activeIndex"
    mode="horizontal"
    :ellipsis="false"
    @select="handleSelect"
  >
    <el-menu-item index="/primary-app">主应用页面</el-menu-item>
    <div class="flex-grow" />
    <el-menu-item index="/micro-app-react">微应用-react</el-menu-item>
    <div class="flex-grow" />
    <el-menu-item index="/micro-app-vue">微应用-vue</el-menu-item>
  </el-menu>

微应用

正常写的情况下:

import { Link } from 'react-router-dom';
​
<Link to="/test" >To Test</Link> // 这里直接用Link标签写的,用编程式导航也一样

主应用跳微应用,以及只在当前微应用中跳转时没问题的,但是跳转再跳其他应用(比如主应用)就报错了Uncaught (in promise) DOMException: Failed to execute 'replace' on 'Location': 'http://127.0.0.1:5173undefined' is not a valid URL.

qiankun-路由跳转报错.gif

官网,俩方案,都可以:

注意:不论用哪种方案,跳转的路径必须是全的

  1. history.pushState()mdn 用法介绍

使用history.pushState()方法时要注意,主应用和微应用都要用这个方法进行路由跳转

主应用

<script setup lang="ts">
import { ref } from 'vue';
const activeIndex = ref('');
const handleSelect = (key: string, keyPath: string[]) => {
  window.history.pushState({}, '', key);
};
</script>
​
<template>
  <el-menu
    :default-active="activeIndex"
    mode="horizontal"
    :ellipsis="false"
    @select="handleSelect"
  >
    <el-menu-item index="/primary-app">主应用页面</el-menu-item>
    <div class="flex-grow" />
    <el-menu-item index="/micro-app-react">微应用-react</el-menu-item>
    <div class="flex-grow" />
    <el-menu-item index="/micro-app-vue">微应用-vue</el-menu-item>
  </el-menu>
</template>
​

微应用(用react举例)

...
<span onClick={() => {
  window.history.pushState({}, '', '/micro-app-react/else');
}}>导航去Else页面</span>
...
  1. 将主应用的路由实例通过 props 传给微应用,微应用这个路由实例跳转。

主应用:

// router.js
...
const router = VueRouter.createRouter({ history: createWebHistory(), routes });
...
​
// index.js
...
import router from './router.js';
​
registerMicroApps([
  {
    name: 'app-react',
    entry: '//localhost:3000',
    container: '#reactContainer',
    activeRule: '/micro-app-react',
    // 通过props传给微应用
    props: {
      pRouter: router,
    }
  },
  {
    name: 'app-vue',
    entry: '//localhost:8080',
    container: '#vueContainer',
    activeRule: '/micro-app-vue',
    props: {
      pRouter: router,
    }
  },
]);
...

微应用

  • react通过context放到全局
// context.js
import { createContext } from 'react';
const pRouterContext = createContext(null);
​
export {
  pRouterContext,
};
​
// index.js
import { pRouterContext } from './context';
​
function render(props) {
  // pRouter就是主应用传过来的
  const { container, pRouter } = props;
  const root = ReactDOM.createRoot(container ? container.querySelector('#root') : document.querySelector('#root'));
  root.render(
    <pRouterContext.Provider value={{ pRouter }}>
      <RouterProvider router={router} />
    </pRouterContext.Provider>
  );
}
​
// App.js
...
import { pRouterContext } from './context';
​
<span onClick={() => {
    pRouter.push('/micro-app-react/else'); // 注意:路径要写全
  }}>导航去Else页面</span>
...
​
  • vue2通过挂载到Vue原型上来实现

main.js

...
function render(props = {}) {
  const { container, pRouter } = props;
  // 挂载到Vue原型上
  Vue.prototype.$pRouter = pRouter;
​
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/micro-app-vue/' : '/',
    mode: 'history',
    routes,
  });
  
  instance = new Vue({
    router,
    // store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}
...

App.vue

<template>
  <div id="app">
    ...
    <span @click="handleClick">跳转bar页面</span>
    <router-view></router-view>
  </div>
</template><script>
export default {
  name: 'App',
  methods: {
    handleClick() {
      // 使用
      this.$pRouter.push('/micro-app-vue/bar');
      // window.history.pushState({}, '', '/micro-app-vue/bar') // 也可用window.history.pushState方法
    }
  }
}
</script>

有博客说在使用父传子路由的方法时,遇到子跳转父,样式加载的问题,我暂时没碰到,有类似问题的可参考

Api

主应用没啥特别的,该咋用杂用。微应用唯一的注意点:baseUrl必须是包含origin的完整的url

api.js

import axios from 'axios';
const baseURL = window.__POWERED_BY_QIANKUN__ ? 'http://localhost:3000/api' : '/api';
const instance = axios.create({
  baseURL,
  // timeout: 60000,
});
​
export default instance;

使用

import api from '@/api';
import { useEffect } from 'react';
function Test() {
  useEffect(() => {
    api.get('/currentUser').then(res => {
      console.log(res);
    })
  }, []);
  return <div>测试</div>
}
​
export default Test;

先这么多,待再补充...

Demo

git地址:github.com/MengfeiCao/…