微前端入门 - qiankun

566 阅读6分钟

qiankun 是一个基于 single-spa微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

什么是微前端

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

微前端架构具备以下几个核心价值:

  • 技术栈无关
  • 独立开发、独立部署
  • 增量升级
  • 独立运行时

安装qiankun

npm i qiankun -S

主应用

在主应用中注册微应用

import { registerMicroApps, start } from 'qiankun'

registerMicroApps([
  {
    name: 'react app', // app name registered
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/yourActiveRule',
  },
  {
    name: 'vue app',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
])

start()

微应用

webpack项目

新增public-path.js文件

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

react应用

设置 history 模式路由的 base

<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

function render(props) {
  const { container } = props;
  ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root'));
}

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'));
}
npm i -D @rescripts/cli

修改webpack配置

根目录新增 .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"

Vue应用

vue2应用

import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

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;
}

修改vue.config.js

const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
}

vue3应用

import './public-path.js'
import { createApp } from 'vue'
import App from './App.vue'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './router'

let router: any = null
let instance: any = null
function render (props: any = {}) {
  const { container } = props
  router = createRouter({
    history: createWebHistory((window as any).__POWERED_BY_QIANKUN__ ? '/vue3-app/' : '/'),
    routes
  })
  instance = createApp(App)
  instance.use(router).mount(container ? container.querySelector('#app') : '#app')
}

if (!(window as any).__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap () {
  console.log('[vue3] vue app bootstraped')
}
export async function mount (props: any) {
  console.log('[vue3] props from main framework', props)
  render(props)
}
export async function unmount () {
  instance.unmount()
  instance = null
  router = null
}

webpack配置文件修改vue2相同

angular应用

在 src 目录新增 public-path.js 文件

if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

设置 history 模式路由的 base,src/app/app-routing.module.ts 文件

+ import { APP_BASE_HREF } from '@angular/common';
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
  // @ts-ignore
+  providers: [{ provide: APP_BASE_HREF, useValue: window.__POWERED_BY_QIANKUN__ ? '/app-angular' : '/' }]
})

如果window上出现报错,可以在src目录下新建global.d.ts声明window对象

export {}
declare global{
    interface Window{
        __POWERED_BY_QIANKUN__?: string
    }
}

修改入口文件,src/main.ts 文件

import './public-path';
import { enableProdMode, NgModuleRef } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

let app: void | NgModuleRef<AppModule>;
async function render() {
  app = await platformBrowserDynamic()
    .bootstrapModule(AppModule)
    .catch((err) => console.error(err));
}
if (!(window as any).__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap(props: Object) {
  console.log(props);
}

export async function mount(props: Object) {
  render();
}

export async function unmount(props: Object) {
  console.log(props);
  // @ts-ignore
  app.destroy();
}

安装 @angular-builders/custom-webpack 插件, 注意:angular 9 项目只能安装 9.x 版本,angular 10 项目可以安装最新版。

npm i @angular-builders/custom-webpack -D

在根目录增加 custom-webpack.config.js ,内容为:

const appName = require('./package.json').name;
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  output: {
    library: `${appName}-[name]`,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${appName}`,
  },
};

修改 angular.json

- "builder": "@angular-devkit/build-angular:browser",
+ "builder": "@angular-builders/custom-webpack:browser",
  "options": {
+    "customWebpackConfig": {
+      "path": "./custom-webpack.config.js"
+    }
  }

解决 zone.js 的问题

  • 在父应用引入 zone.js需要在 import qiankun 之前引入。
  • 将微应用的 src/polyfills.ts 里面的引入 zone.js 代码删掉。
- import 'zone.js';

在微应用的 src/index.html 里面的 标签加上下面内容,微应用独立访问时使用。

<!-- 也可以使用其他的CDN/本地的包 -->
<script src="https://cdn.jsdelivr.net/npm/zone.js@0.11.4/dist/zone.min.js"></script>

修正ng build打包报错问题,修改tsconfig.json 文件

- "target": "es2015",
+ "target": "es5",
+ "typeRoots": [
+   "node_modules/@types"
+ ],

为了防止主应用或其他微应用也为 angular 时, 会冲突的问题,建议给 加上一个唯一的 id,比如说当前应用名称。

- <app-root></app-root>
+ <app-root id="angular9"></app-root>
- selector: 'app-root',
+ selector: '#angular9 app-root',

非webpack应用

接入非常简单,只需要额外声明一个 script,用于 export 相对应的 lifecycles

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Purehtml Example</title>
</head>
<body>
  <div>
    Purehtml Example
  </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script>
<!-- entry是用来告诉qiankun这是入口模块  -->  
+ <script src="//yourhost/entry.js" entry></script>
</html>

在 entry js 里声明 lifecycles

const render = ($) => {
  $('#purehtml-container').html('Hello, render with jQuery');
  return Promise.resolve();
};

((global) => {
  global['purehtml'] = {
    bootstrap: () => {
      console.log('purehtml bootstrap');
      return Promise.resolve();
    },
    mount: () => {
      console.log('purehtml mount');
      return render($);
    },
    unmount: () => {
      console.log('purehtml unmount');
      return Promise.resolve();
    },
  };
})(window);

如果使用vite,处理与webpack类似,打包需要选择使用umd的方式。

手动加载微应用

通常这种场景下微应用是一个不带路由的可独立运行的业务组件。微应用不宜拆分过细,建议按照业务域来做拆分。

在组件中可以调用loadMicroApp加载微应用。下面为示例代码

<script lang="ts">
import Vue from 'vue'
import { loadMicroApp } from 'qiankun'
export default Vue.extend({
  data () {
    return {
      microApp1: null as any,
      microApp2: null as any
    }
  },
  mounted () {
    this.loadMicroApp()
  },
  methods: {
    loadMicroApp () {
      this.microApp1 = loadMicroApp({
        name: 'app1',
        entry: '//localhost:3003',
        container: this.$refs.app1 as HTMLElement,
        props: { brand: 'qiankun' }
      })
      this.microApp2 = loadMicroApp({
        name: 'app2',
        entry: '//localhost:3002',
        container: this.$refs.app2 as HTMLElement,
        props: { brand: 'qiankun' }
      })
    }
  },
  destroyed () {
    this.microApp1.unmount()
    this.microApp2.unmount()
  }
})
</script>
<template>
  <div class="about">
    <h1>This is an about page</h1>
    <div ref="app1"></div>
    <div ref="app2"></div>
  </div>
</template>

手动加载方式也可以加载带路由的微应用。使用时需注意主应用与微应用的路由匹配。

预加载

start(opts?)

  • opts
    • prefetch
      • true,默认值。配置为 true ,则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
      • all,配置为all则主应用 start 后即开始预加载所有微应用静态资源
      • 配置为string[]则会在第一个微应用 mounted 后开始加载数组内的微应用资源
      • 配置为function则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import 'zone.js'
import { registerMicroApps, start } from 'qiankun'

Vue.config.productionTip = false

registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:3002',
    container: '#container',
    activeRule: '/react'
  },
  {
    name: 'hello',
    entry: '//localhost:3001',
    container: '#container',
    activeRule: '/hello'
  },
  {
    name: 'angular',
    entry: '//localhost:4200',
    container: '#container',
    activeRule: '/angular'
  },
  {
    name: 'jquery',
    entry: '//localhost:3003',
    container: '#container',
    activeRule: '/jquery'
  }
])
// 启动 qiankun
start({
  // 默认值为true,在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
  prefetch: true
})

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

微应用的路由模式选择

activeRule使用location.pathname区分微应用

主应用使用location.pathname,微应用可以是historyhash模式

registerMicroApps([
  {
    name: 'vue-app',
    entry: 'http://localhost:8081',
    container: '#container',
    activeRule: '/vue-app',
  },
]);
  • 当微应用是history模式时,设置路由 base 即可(参考之前的)
  • 当微应用是hash模式时,三种路由的表现不一致

Vue应用

修改路由模式即可

...
function render (props: any = {}) {
  const { container } = props
  router = new VueRouter({
    routes,
    // history模式时使用
    // base: window.__POWERED_BY_QIANKUN__ ? '/hello/' : '/',
    // hash模式
    mode: 'hash'
  })
  instance = new Vue({
    router,
    store,
    render: h => h(App)
  }).$mount(container ? container.querySelector('#app') : '#app')
}
...

React应用

修改路由模式即可

import { HashRouter } from 'react-router-dom'

function render(props) {
  const { container } = props

  ReactDOM.render(
    <React.StrictMode>
      <HashRouter
        // basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}
      >
        <App />
      </HashRouter>
    </React.StrictMode>,
    container ? container.querySelector('#root') : document.querySelector('#root')
  );
}

angular

修改angular.json文件

...
"build": {
  "builder": "@angular-builders/custom-webpack:browser",
  "options": {
+    "baseHref": "/angular",
  }
}
...

修改src/app/app-routing.module.ts文件

@NgModule({
  imports: [RouterModule.forRoot(routes, {
+    useHash: true
  })],
  exports: [RouterModule],
-  providers: [{ provide: APP_BASE_HREF, useValue: window.__POWERED_BY_QIANKUN__ ? '/angular/' : '/' }]
})

activeRule使用location.hashname区分微应用

{
  name: 'angular',
  entry: '//localhost:4200',
  container: '#container',
  activeRule: '/angular'
}

Vue应用

Vue应用需要修改路由mode为hash外,还需要修改下路由routes。

export const routes: Array<RouteConfig> = [
  {
    path: '/hello',
    component: () => import('../views/Layout.vue'),
    children: [{
      path: '',
      name: 'Home',
      component: Home
    }, {
      path: 'about',
      name: 'About',
      component: () => import( '../views/About.vue')
    }]
  }
]

微应用的导航链接要写完整路径或者可以直接通过传递name跳转

<div id="nav">
  <router-link :to="{name:'Home'}">Home</router-link> |
  <router-link to="/hello/about">About</router-link>
</div>

React应用

React不需要任何设置,和history模式一样,只需要加上basename就可以了

function render(props) {
  const { container } = props;

  ReactDOM.render(
    <React.StrictMode>
      <HashRouter
        basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}
      >
        <App />
      </HashRouter>
    </React.StrictMode>,
    container ? container.querySelector('#root') : document.querySelector('#root')
  );
}

Angular应用

Angular应用也很简单,和history模式一样。如果之间修改过angular.json文件的baseHref需要删掉

@NgModule({
  imports: [RouterModule.forRoot(routes, {
    useHash: true
  })],
  exports: [RouterModule],
  providers: [{ provide: APP_BASE_HREF, useValue: window.__POWERED_BY_QIANKUN__ ? '/angular' : '/' }]
})

同时存在多个微应用时

如果一个页面同时展示多个微应用,需要使用 loadMicroApp 来加载。

如果这些微应用都有路由跳转的需求,要保证这些路由能互不干扰,需要使用 momery 路由。vue-router 使用 abstract 模式,react-router 使用 memory history 模式,angular-router 不支持。

总结

主应用建议使用history模式。

从上面可以看到,history模式优势非常明显,微应用配置更简单。

开发启动

开发学习过程中,每次启动多个服务,一个一个启动有点麻烦,这时我们可以借助npm-run-all这个模块

npm i npm-run-all -D

在项目目录下package.json文件中设置启动命令。npm-run-all命令的文档参考 github.com/mysticatea/…

如下面代码所示,我们就可以通过npm start启动多个应用来进行开发调试,通过npm run build打包多个应用

{
  "name": "xxx",
  "version": "1.0.0",
  "scripts": {
    "preview": "serve static -p 5000",
    "start": "npm-run-all -p start:*",
    "start:main": "cd main && npm start",
    "start:react": "cd react-app && npm start",
    "start:vue": "cd vue-app && npm start",
    "build": "npm-run-all -p build:*",
    "build:main": "cd main && npm run build",
    "build:react": "cd react-app && npm run build",
    "build:vue": "cd vue-app && npm run build"
  },
  "devDependencies": {
    "npm-run-all": "^4.1.5",
  }
}

部署

建议:主应用和微应用都是独立开发和部署,即它们都属于不同的仓库和服务。

主应用和微应用部署到同一个服务器(同一个 IP 和端口)

主应用

Vue应用,配置Vue应用的环境变量。请注意,只有 NODE_ENVBASE_URL 和以 VUE_APP_ 开头的变量将通过 webpack.DefinePlugin 静态地嵌入到客户端侧的代码中

[
    {
      name: 'reactApp',
      // 最后必须以/结尾,否则会导致图片加载不成功
      entry: process.env.VUE_APP_REACT_ENTRY,
      container: '#container',
      activeRule: '/react-app'
    },
    {
      name: 'vueApp',
      entry: process.env.VUE_APP_VUE2_ENTRY,
      container: '#container',
      activeRule: '/vue-app'
    }
}

微应用

webpack打包publicPath配置

// vue.config.js
module.exports = {
  publicPath: '/child/vue-history/',
};

路由base设置

base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history/' : '/child/vue-history/',

React 应用,webpack配置和vue应用一样

module.exports = {
  output: {
    publicPath: '/child/react-history/',
  },
};

路由配置

<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react-history' : '/child/react-history/'}>

不在同一服务器上

  • 第一种方案是服务端设置允许跨域,这种方式不需要配置。
  • 微应用不允许跨域访问

允许跨域

如果服务器本身不支持跨域,可以设置允许跨域.以nginx为例

location / {
  # 允许的跨域请求地址,* 表示任意
  add_header Access-Control-Allow-Origin *;
  
  # 允许的跨域请求方法
  add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
  
  # 允许的跨域请求头
  add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';

  # 给 OPTIONS 添加 204的返回,是为了处理在发送 POST 请求时 Nginx 依然拒绝访问的错误
  if ($request_method = 'OPTIONS') {
    return 204;
  }
}

不允许跨域

例如主应用在 A 服务器,微应用在 B 服务器,使用路径 /app1 来区分微应用,即 A 服务器上所有 /app1 开头的请求都转发到 B 服务器上,即通过代理的方式解决跨域

nginx配置

location /app1/ {
  proxy_pass http://www.b.com/app1/;
  proxy_set_header Host $host:$server_port;
}

配置webpack的publicPath

module.exports = {
  output: {
    publicPath: `/app1/`,
  },
};

注意,微应用打包的 publicPath 加上 /app1/ 之后,必须部署在 /app1 目录,否则无法独立访问。

微应用间的通信

initGlobalState(state) 主应用

import { initGlobalState, MicroAppStateActions } from 'qiankun';

// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state)

actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev)
});
// 更新state时调用
actions.setGlobalState(state)
// 关闭状态检测
actions.offGlobalStateChange()

微应用,在mount钩子函数中调用

// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
  });

  props.setGlobalState(state);
}