成熟项目接入微前端-qiankun

972 阅读8分钟

成熟项目接入微前端-qiankun

一、接入原因

  1. 部分企业希望基于主应用做二次开发。公司希望能够保留源码去接入企业二次开发新增的模块功能。
  2. 项目越来越大,运行和打包速度都很慢

二、配置主应用基座

1. 安装qiankun

yarn add qiankun

2. 注册接入的微应用

在src新建micro/index.js

import NProgress from 'nprogress';
import { start, registerMicroApps, addGlobalUncaughtErrorHandler } from 'qiankun';

/**
 * 注册微应用
 * 第一个参数 - 微应用的注册信息
 * 第二个参数 - 全局生命周期钩子
 */

registerMicroApps(
  [
    {
      name: 'MicroAppReact',
      // 开发环境入口
      entry: '//localhost:3200',
      // 主应用为打包后的代码时,便于nginx配置到统一域名
      // entry: '/micro/react/',
      container: '#container',
      activeRule: '/react',
    },
  ],
  {
    beforeLoad(app) {
      NProgress.start();
      console.log('before load', app.name);
      return Promise.resolve();
    },
    afterMount(app) {
      NProgress.done();
      console.log('after mount', app.name);
      return Promise.resolve();
    },
  },
);

/**
 * 添加全局的未捕获异常处理器
 */
addGlobalUncaughtErrorHandler((event) => {
  console.error(321, event);
});

// 导出 qiankun 的启动函数
export default start;

3. 添加微应用容器

在src/micro新建template.js

import React from 'react';

function Template() {
  return <div id="container" />;
}

export default Template;

4. 新增微应用路由

配置微应用在主应用的菜单

// /src/views/router.js
/* ----------------------微应用接入----------------------- */
  {
    key: 'invoice-record',
    loader: () => import('../micro/template'),
    path: '/react',
  },

5. 在入口文件启动qiankun

// src/index.jsx
import startQiankun from './micro';
 
startQiankun();

三、接入微应用-react

因为当前我们的技术栈是react,所以这里只做react的接入介绍。但是qiankun本身支持接入多种技术栈的微应用。如有需要自行查看官网

1.创建新项目

cra脚手架创建react项目

create-react-app react-micro

2.覆盖配置文件

可以直接npm run eject暴露webpack等配置项,也可以直接依赖第三方库覆盖配置

yarn add @rescripts/cli

根目录新建.rescriptsrc.js

const path = require('path')
const { name } = require('./package');

const resolve = dir => path.join(__dirname, dir)

const webpackConfig = {

  webpack: config => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';
    // config.output.publicPath = '/micro/react/',

    config.resolve.extensions = ['.js''.jsx']

    // 配置别名
    config.resolve.alias = {
      '@components': resolve('src/components'),
      '@config': resolve('src/config'),
      '@hooks': resolve('src/hooks'),
      '@redux': resolve('src/redux'),
      '@services': resolve('src/services'),
      // '@static': resolve('src/static'),
      '@utils': resolve('src/utils'),
      '@views': resolve('src/views'),
    }

    return config;
  },

  devServer: _ => {
    const config = _;
    config.headers = {
      'Access-Control-Allow-Origin': '*',
    };
    config.historyApiFallback = true;
    config.hot = false;
    config.watchContentBase = false;
    config.liveReload = false;
    // 配置代理服务
    config.proxy = {
      '/api': {
        target: 'https://test.abc.cn',
        changeOrigin: true,
        secure: false,
      },
    }
    return config;
  },
};

module.exports = [
  ['use-antd', {
    theme: {
      // 全局主色
      '@primary-color': '#0077FF',
      // 链接色
      '@link-color': '#0077FF',
      '@link-hover-color': '#3392FF',
      '@link-active-color': '#005FCC',
      // 成功色
      '@success-color': '#13CE66',
      // 警告色
      '@warning-color': '#F7BA2A',
      // 错误色
      '@error-color': '#FF4949',
      // 字体
      '@font-family':
        '"Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif',
      // 主字号
      '@font-size-base': '12px',
      // 组件/浮层圆角
      '@border-radius-base': '0',
      '@table-padding-horizontal': '12px',
      '@tabs-horizontal-margin': '0 20px 0 0',
      '@tabs-horizontal-padding-lg': '16px 20px',
      // 菜单
      '@menu-item-font-size': '13px',
      '@menu-item-height': '41px',
    }
  }],
  webpackConfig
]

3.修改package.json-为了执行到覆盖的配置

"scripts": {
  "start": "rescripts start",
  "build": "rescripts build",
  "test": "rescripts test",
  "eject": "react-scripts eject"
},

4.配置微应用运行端口--也就是开发环境主应用配置的entry

根目录新建.env.development

// .env.development
// 运行端口
PORT = 3200
// websocket监听热更新
WDS_SOCKET_PORT = 3200

5.入口文件导出微应用生命周期钩子

注意:要求微应用需要导出生命周期钩子函数,qiankun 内部通过 import-entry-html 加载微应用,如果微应用没有导出这三个生命周期钩子函数,则微应用会加载失败。

import './public-path'
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
 
function render(props) {
  const { container } = props;
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    container ? container.querySelector('#root') : document.querySelector('#root')
  );
}
 
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}
 
export async function bootstrap() {
  console.log('micro app react bootstrap')
}
 
export async function mount(props) {
  console.log('micro app react mount, props11111', props)
  render(props)
}
 
export async function unmount(props) {
  console.log('micro app react unmount, props', 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();

6.创建微应用页面

在src新建pages文件夹,新建Home.jsx, List.jsx

// Home.js
import React from 'react'
 
export default function Home() {
  return (
    <div>
      我是React微应用的主页
    </div>
  )
}

7.添加路由

修改App.js

import { BrowserRouter, Route } from 'react-router-dom'
import './App.css';
import Home from './pages/Home';
import List from './pages/List';
 
function App() {
  return (
    <BrowserRouter className="App" basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'} >
      <Route path="/" exact component={Home} />
      <Route path="/list" exact component={List} />
    </BrowserRouter>
  );
}
 
export default App;

到这里就已经完成了主应用接入微应用了,分别启动主应用和微应用,就能在主应用菜单下看到对应的微应用模块。

四、应用之间通信

1.通信方式

一种是官方提供的应用间通信方式 - Actions,另一种是Shared 通信,主应用和微应用各自维护redux状态池。我这里先介绍这次用到的Actions通

2.通信原理

image.png

  • 我们可以先注册 观察者 到观察者池中,然后通过修改 globalState 可以触发所有的 观察者 函数,从而达到组件间通信的效果。

  • qiankun 内部提供了 initGlobalState 方法用于注册 MicroAppStateActions 实例用于通信,该实例有三个方法,分别是:

  • setGlobalState:设置 globalState - 设置新的值时,内部将执行 浅检查,如果检查到 globalState 发生改变则触发通知,通知到所有的 观察者 函数。

  • onGlobalStateChange:注册 观察者 函数 - 响应 globalState 变化,在 globalState 发生改变时触发该 观察者函数。

  • offGlobalStateChange:取消 观察者 函数 - 该实例不再响应 globalState 变化

3.主应用配置

  • 在主应用中注册一个 MicroAppStateActions 实例并导
// src/shared/actions.jsx
import { initGlobalState } from 'qiankun';
 
const initialState = {};
const actions = initGlobalState(initialState);
 
export default actions;
  • 在注册 MicroAppStateActions 实例后,我们在需要通信的组件中使用该实例,并注册 观察者函数,我们这里以登录之后传递用户信息和权限信息给子应用为例。
import { generatePath } from 'react-router-dom';
import router from '@views/router';
// 引入actions
import actions from '../shared/actions';
 
/**
 * 按钮级别
 */
const BUTTON_LEVEL = 4;
const getRouter = (menuCode) => router.find((item) => item.key === menuCode);
const getChildren = (list = [], menuCode = null) => {
  const items = [];
 
  list.forEach((item) => {
    if (item.parentCode === menuCode && item.level !== BUTTON_LEVEL) {
      const routerItem = getRouter(item.menuCode) || {};
      const { path, params } = routerItem;
 
      items.push({
        ...item,
        ...routerItem,
        url: path ? generatePath(path, params) : '',
        children: getChildren(list, item.menuCode),
      });
    }
  });
 
  return items;
};
const getDefaultRouter = (items = []) => {
  let defaultRouter = null;
 
  items.forEach((item) => {
    if (!defaultRouter) {
      if (item.path) {
        defaultRouter = item;
      } else {
        defaultRouter = getDefaultRouter(item.children);
      }
    }
  });
 
  return defaultRouter;
};
 
export default {
  user: (state = null, { type, payload }) => {
    let navItems = [];
    let defaultRouter = null;
 
    switch (type) {
      case 'user/login':
        // 请求得到用户信息的时候放入全局状态池
        actions.setGlobalState({ ...payload });
        navItems = getChildren(payload.menuList);
        defaultRouter = getDefaultRouter(navItems);
        return {
          ...state,
          ...payload,
          defaultRouter,
          navItems,
        };
      case 'user/logout':
        return null;
      case 'user/set': {
        return {
          ...state,
          ...payload,
        };
      }
      default:
        return state;
    }
  },
};

4.微应用配置

此时主应用已经把用户信息和权限信息放在全局状态池中,微应用可以通过注册观察者去监听数据的变化

  • 设置一个 Actions 实例
const emptyAction = () => {
  // 警告:提示当前使用的是空 Action
  console.error('这个action是空的')
}
class Actions {
   // 默认值为空 Action
  actions = {
    setGlobalState: emptyAction,
    onGlobalStateChange: emptyAction,
  }
 
  // 设置 actions
  setActions(actions) {
    if (actions) {
      const { setGlobalState, onGlobalStateChange } = actions
      this.actions.setGlobalState = setGlobalState
      this.actions.onGlobalStateChange = onGlobalStateChange
    }
  }
 
  // 映射
  setGlobalState(...args) {
    return this.actions.setGlobalState?.(...args)
  }
 
  //映射
  onGlobalStateChange(...args) {
    return this.actions.onGlobalStateChange?.(...args)
  }
}
 
const actions = new Actions()
 
export default actions
  • 在入口文件 index.js 的 render 函数中注入真实的Actions
import './public-path'
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import actions from './shared';
 
function render(props) {
  if (props) {
    // 注入 actions 实例
    actions.setActions(props);
  }
  const { container } = props;
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    container ? container.querySelector('#root') : document.querySelector('#root')
  );
}
 
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}
 
export async function bootstrap() {
  console.log('micro app react bootstrap')
}
 
export async function mount(props) {
  console.log('micro app react mount, props', props)
  render(props)
}
 
export async function unmount(props) {
  console.log('micro app react unmount, props', 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();
  • 现在在页面就可以去获取全局状态池的数据了。
// src/pages/Home.jsx
import React, { useEffect, useState } from 'react'
import actions from '../shared';
 
export default function Home() {
  const [name, setName] = useState('');
  useEffect(() => {
    actions.onGlobalStateChange(state => {
      const {userName} = state
      console.log('micro app react state', state)
      setName(userName)
    }, true)
  }, [])
  return (
    <div>
      我是React微应用的主页---{name}
    </div>
  )
}

以上,就完成了主应用和微应用之间的通信。

五、配置热更新

1.热更新原理

webpack-dev-server运行时会启动一个http 的server和一个websocket的server,代码变更之后会重新构建产生新的hash值的文件,通过websocket告诉浏览器(在message里面通知,类似-----a["{"type":"hash","data":"f4fdfa57"}"]),客户端就会发起2个请求,也就是webpack的hot-update。

2.微应用配置

写死微应用websocket请求的端口

// src/.env.development
// 运行端口
PORT = 3200
// 热更新监听端口
WDS_SOCKET_PORT = 3200

六、部署--nginx配置

注意点:

  • 微应用配置publicPath与nginx配置的前缀保持一致
  • 主应用的entry与nginx配置的前缀保持一致
server {
     listen 8005;
     listen [::]:8005;
     server_name cmptest.jss.cn;
 
     location / {
         # 主应用静态资源路径
         root /Users/zengxiaobai/projects/nuonuo/qiankun_demo/react_main;
         index index.html index.htm;
         try_files $uri $uri/ /index.html;
     }
 
     location /micro/react {
         # 开发环境配置静态资源路径
         proxy_pass http://localhost:3200/;
         # 生产环境静态资源路径
         # root /Users/zengxiaobai/projects/nuonuo/qiankun_demo;
         # index index.html index.htm;
         # try_files $uri $uri/ /micro/react/index.html;
         add_header "Access-Control-Allow-Origin" $http_origin;
         # 允许请求方法
         add_header "Access-Control-Allow-Methods" "*";
         # 允许请求的 header
         add_header "Access-Control-Allow-Headers" "*";
     }
     location /baseweb/ {
         # 应用接口反向代理
         proxy_pass https://test.abc.cn;
     }
 
     location /customization/ {
         # 应用接口反向代理
         proxy_pass https://test.abc.cn;
     }
 }

七、demo地址

地址:

八、遇到的困难和解决方式

1.主应用的路由使用了Suspense,按需加载,刷新的时候处于loading状态,就找不到container容器引起报错

解决办法:在主应用入口文件限启动微应用的时间。

import startQiankun from './micro';
 
let container;
 
if (!container) {
  const interval = setInterval(() => {
    container = document.getElementById('container');
    if (container) {
      console.log(2);
      clearInterval(interval);
      startQiankun();
    }
  }, 100);
}

2.主应用只给压缩包的情况下,微应用需要主应用的权限,如何进项二次开发

解决办法:二次开发的团队启动一个本地nginx。微应用代理到localhost:3200,参考前面的nginx部署

源码解读