微前端qiankun从搭建到部署的实践

最近负责的新项目用到了qiankun,写篇文章分享下实战中遇到的一些问题和思考。javascript

示例代码: https://github.com/fengxianqi/qiankun-examplecss

在线demo:http://qiankun.fengxianqi.com/html

单独访问在线子应用:前端

为何要用qiankun

项目有个功能需求是须要内嵌公司内部的一个现有工具,该工具是独立部署的且是用React写的,而咱们的项目主要技术选型是vue,所以须要考虑嵌入页面的方案。主要有两条路:vue

  • iframe方案
  • qiankun微前端方案

两种方案都能知足咱们的需求且是可行的。不得不说,iframe方案虽然普通但很实用且成本也低,iframe方案能覆盖大部分的微前端业务需求,而qiankun对技术要求更高一些。java

技术同窗对自身的成长也是有强烈需求的,所以在二者都能知足业务需求时,咱们更但愿能应用一些较新的技术,折腾一些未知的东西,所以咱们决定选用qiankunreact

项目架构

后台系统通常都是上下或左右的布局。下图粉红色是基座,只负责头部导航,绿色是挂载的整个子应用,点击头部导航可切换子应用。
imagewebpack

参考官方的examples代码,项目根目录下有基座main和其余子应用sub-vuesub-react,搭建后的初始目录结构以下:nginx

├── common     //公共模块
├── main       // 基座
├── sub-react  // react子应用
└── sub-vue    // vue子应用

基座是用vue搭建,子应用有reactvuegit

基座配置

基座main采用是的Vue-Cli3搭建的,它只负责导航的渲染和登陆态的下发,为子应用提供一个挂载的容器div,基座应该保持简洁(qiankun官方demo甚至直接使用原生html搭建),不该该作涉及业务的操做。

qiankun这个库只须要在基座引入,在main.js中注册子应用,为了方便管理,咱们将子应用的配置都放在:main/src/micro-app.js下。

const microApps = [
  {
    name: 'sub-vue',
    entry: '//localhost:7777/',
    activeRule: '/sub-vue',
    container: '#subapp-viewport', // 子应用挂载的div
    props: {
      routerBase: '/sub-vue' // 下发路由给子应用,子应用根据该值去定义qiankun环境下的路由
    }
  },
  {
    name: 'sub-react',
    entry: '//localhost:7788/',
    activeRule: '/sub-react',
    container: '#subapp-viewport', // 子应用挂载的div
    props: {
      routerBase: '/sub-react'
    }
  }
]

export default microApps

而后在src/main.js中引入

import Vue from 'vue';
import App from './App.vue';
import { registerMicroApps, start } from 'qiankun';
import microApps from './micro-app';

Vue.config.productionTip = false;

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


registerMicroApps(microApps, {
  beforeLoad: app => {
    console.log('before load app.name====>>>>>', app.name)
  },
  beforeMount: [
    app => {
      console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name);
    },
  ],
  afterMount: [
    app => {
      console.log('[LifeCycle] after mount %c%s', 'color: green;', app.name);
    }
  ],
  afterUnmount: [
    app => {
      console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name);
    },
  ],
});

start();

App.vue中,须要声明micro-app.js配置的子应用挂载div(注意id必定要一致),以及基座布局相关的,大概这样:

<template>
  <div id="layout-wrapper">
    <div class="layout-header">头部导航</div>
    <div id="subapp-viewport"></div>
  </div>
</template>

这样,基座就算配置完成了。项目启动后,子应用将会挂载到<div id="subapp-viewport"></div>中。

子应用配置

1、vue子应用

用Vue-cli在项目根目录新建一个sub-vue的子应用,子应用的名称最好与父应用在src/micro-app.js中配置的名称一致(这样能够直接使用package.json中的name做为output)。

  1. 新增vue.config.js,devServer的端口改成与主应用配置的一致,且加上跨域headersoutput配置。
// package.json的name需注意与主应用一致
const { name } = require('../package.json')

module.exports = {
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    }
  },
  devServer: {
    port: process.env.VUE_APP_PORT, // 在.env中VUE_APP_PORT=7788,与父应用的配置一致
    headers: {
      'Access-Control-Allow-Origin': '*' // 主应用获取子应用时跨域响应头
    }
  }
}
  1. 新增src/public-path.js
(function() {
  if (window.__POWERED_BY_QIANKUN__) {
    if (process.env.NODE_ENV === 'development') {
      // eslint-disable-next-line no-undef
      __webpack_public_path__ = `//localhost:${process.env.VUE_APP_PORT}/`;
      return;
    }
    // eslint-disable-next-line no-undef
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
  }
})();
  1. src/router/index.js改成只暴露routes,new Router改到main.js中声明。
  2. 改造main.js,引入上面的public-path.js,改写render,添加生命周期函数等,最终以下:
import './public-path' // 注意须要引入public-path
import Vue from 'vue'
import App from './App.vue'
import routes from './router'
import store from './store'
import VueRouter from 'vue-router'

Vue.config.productionTip = false
let instance = null

function render (props = {}) {
  const { container, routerBase } = props
  const router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? routerBase : process.env.BASE_URL,
    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
}

至此,基础版本的vue子应用配置好了,若是routervuex不需用到,能够去掉。

2、react子应用

  1. 经过npx create-react-app sub-react新建一个react应用。
  2. 新增.env文件添加PORT变量,端口号与父应用配置的保持一致。
  3. 为了避免eject全部webpack配置,咱们用react-app-rewired方案复写webpack就能够了。
  • 首先npm install react-app-rewired --save-dev
  • 新建sub-react/config-overrides.js
const { name } = require('./package.json');

module.exports = {
  webpack: function override(config, env) {
    // 解决主应用接入后会挂掉的问题:https://github.com/umijs/qiankun/issues/340
    config.entry = config.entry.filter(
      (e) => !e.includes('webpackHotDevClient')
    );
    
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    return config;
  },
  devServer: (configFunction) => {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.open = false;
      config.hot = false;
      config.headers = {
        'Access-Control-Allow-Origin': '*',
      };
      return config;
    };
  },
};
  1. 新增src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. 改造index.js,引入public-path.js,添加生命周期函数等。
import './public-path'
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

function render() {
  ReactDOM.render(
    <App />,
    document.getElementById('root')
  );
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用从新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 一般咱们能够在这里作一些全局变量的初始化,好比不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log('react app bootstraped');
}
/**
 * 应用每次进入都会调用 mount 方法,一般咱们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log(props);
  render();
}
/**
 * 应用每次 切出/卸载 会调用的方法,一般在这里咱们会卸载微应用的应用实例
 */
export async function unmount() {
  ReactDOM.unmountComponentAtNode(document.getElementById('root'));
}
/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {
  console.log('update props', props);
}

serviceWorker.unregister();

至此,基础版本的react子应用配置好了。

进阶

全局状态管理

qiankun经过initGlobalState, onGlobalStateChange, setGlobalState实现主应用的全局状态管理,而后默认会经过props将通讯方法传递给子应用。先看下官方的示例用法:

主应用:

// main/src/main.js
import { initGlobalState } from 'qiankun';
// 初始化 state
const initialState = {
  user: {} // 用户信息
};
const actions = initGlobalState(initialState);
actions.onGlobalStateChange((state, prev) => {
  // state: 变动后的状态; prev 变动前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();

子应用:

// 从生命周期 mount 中获取通讯方法,props默认会有onGlobalStateChange和setGlobalState两个api
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 变动后的状态; prev 变动前的状态
    console.log(state, prev);
  });
  props.setGlobalState(state);
}

这两段代码不难理解,父子应用经过onGlobalStateChange这个方法进行通讯,这实际上是一个发布-订阅的设计模式。

ok,官方的示例用法很简单也彻底够用,纯JavaScript的语法,不涉及任何的vue或react的东西,开发者可自由定制。

若是咱们直接使用官方的这个示例,那么数据会比较松散且调用复杂,全部子应用都得声明onGlobalStateChange对状态进行监听,再经过setGlobalState进行更新数据。

所以,咱们颇有必要对数据状态作进一步的封装设计。笔者这里主要考虑如下几点:

  • 主应用要保持简洁简单,对子应用来讲,主应用下发的数据就是一个很纯粹的object,以便更好地支持不一样框架的子应用,所以主应用不需用到vuex
  • vue子应用要作到能继承父应用下发的数据,又支持独立运行。

子应用在mount声明周期能够获取到最新的主应用下发的数据,而后将这份数据注册到一个名为global的vuex module中,子应用经过global module的action动做进行数据的更新,更新的同时自动同步回父应用。

所以,对子应用来讲,它不用知道本身是一个qiankun子应用仍是一个独立应用,它只是有一个名为global的module,它可经过action更新数据,且再也不须要关心是否要同步到父应用(同步的动做会封装在方法内部,调用者不需关心),这也是为后面支持子应用独立启动开发作准备

  • react子应用同理(笔者react用得不深就不说了)。

image

主应用的状态封装

主应用维护一个initialState的初始数据,它是一个object类型,会下发给子应用。

// main/src/store.js

import { initGlobalState } from 'qiankun';
import Vue from 'vue'

//父应用的初始state
// Vue.observable是为了让initialState变成可响应:https://cn.vuejs.org/v2/api/#Vue-observable。
let initialState = Vue.observable({
  user: {},
});

const actions = initGlobalState(initialState);

actions.onGlobalStateChange((newState, prev) => {
  // state: 变动后的状态; prev 变动前的状态
  console.log('main change', JSON.stringify(newState), JSON.stringify(prev));

  for (let key in newState) {
    initialState[key] = newState[key]
  }
});

// 定义一个获取state的方法下发到子应用
actions.getGlobalState = (key) => {
  // 有key,表示取globalState下的某个子级对象
  // 无key,表示取所有
  return key ? initialState[key] : initialState
}

export default actions;

这里有两个注意的地方:

  • Vue.observable是为了让父应用的state变成可响应式,若是不用Vue.observable包一层,它就只是一个纯粹的object,子应用也能获取到,但会失去响应式,意味着数据改变后,页面不会更新
  • getGlobalState方法,这个是有争议的,你们在github上有讨论:https://github.com/umijs/qiankun/pull/729

一方面,做者认为getGlobalState不是必须的,onGlobalStateChange其实已经够用。

另外一方面,笔者和其余提pr的同窗以为有必要提供一个getGlobalState的api,理由是get方法更方便使用,子应用有需求是不需一直监听stateChange事件,它只须要在首次mount时经过getGlobalState初始化一次便可。在这里,笔者先坚持己见让父应用下发一个getGlobalState的方法。

因为官方还不支持getGlobalState,因此须要显示地在注册子应用时经过props去下发该方法:

import store from './store';
const microApps = [
  {
    name: 'sub-vue',
    entry: '//localhost:7777/',
    activeRule: '/sub-vue',
  },
  {
    name: 'sub-react',
    entry: '//localhost:7788/',
    activeRule: '/sub-react',
  }
]

const apps = microApps.map(item => {
  return {
    ...item,
    container: '#subapp-viewport', // 子应用挂载的div
    props: {
      routerBase: item.activeRule, // 下发基础路由
      getGlobalState: store.getGlobalState // 下发getGlobalState方法
    },
  }
})

export default microApps

vue子应用的状态封装

前面说了,子应用在mount时会将父应用下发的state,注册为一个叫global的vuex module,为了方便复用咱们封装一下:

// sub-vue/src/store/global-register.js

/**
 * 
 * @param {vuex实例} store 
 * @param {qiankun下发的props} props 
 */
function registerGlobalModule(store, props = {}) {
  if (!store || !store.hasModule) {
    return;
  }

  // 获取初始化的state
  const initState = props.getGlobalState && props.getGlobalState() || {
    menu: [],
    user: {}
  };

  // 将父应用的数据存储到子应用中,命名空间固定为global
  if (!store.hasModule('global')) {
    const globalModule = {
      namespaced: true,
      state: initState,
      actions: {
        // 子应用改变state并通知父应用
        setGlobalState({ commit }, payload) {
          commit('setGlobalState', payload);
          commit('emitGlobalState', payload);
        },
        // 初始化,只用于mount时同步父应用的数据
        initGlobalState({ commit }, payload) {
          commit('setGlobalState', payload);
        },
      },
      mutations: {
        setGlobalState(state, payload) {
          // eslint-disable-next-line
          state = Object.assign(state, payload);
        },
        // 通知父应用
        emitGlobalState(state) {
          if (props.setGlobalState) {
            props.setGlobalState(state);
          }
        },
      },
    };
    store.registerModule('global', globalModule);
  } else {
    // 每次mount时,都同步一次父应用数据
    store.dispatch('global/initGlobalState', initState);
  }
};

export default registerGlobalModule;

main.js中添加global-module的使用:

import globalRegister from './store/global-register'

export async function mount(props) {
  console.log('[vue] props from main framework', props)
  globalRegister(store, props)
  render(props)
}

能够看到,该vuex模块在子应用mount时,会调用initGlobalState将父应用下发的state初始化一遍,同时提供了setGlobalState方法供外部调用,内部自动通知同步到父应用。子应用在vue页面使用时以下:

export default {
  computed: {
    ...mapState('global', {
      user: state => state.user, // 获取父应用的user信息
    }),
  },
  methods: {
    ...mapActions('global', ['setGlobalState']),
    update () {
        this.setGlobalState('user', { name: '张三' })
    }
  },
};

这样就达到了一个效果:子应用不用知道qiankun的存在,它只知道有这么一个global module能够存储信息,父子之间的通讯都封装在方法自己了,它只关心自己的信息存储就能够了。

ps: 该方案也是有缺点的,因为子应用是在mount时才会同步父应用下发的state的。所以,它只适合每次只mount一个子应用的架构(不适合多个子应用共存);若父应用数据有变化而子应用又没触发mount,则父应用最新的数据没法同步回子应用。想要作到多子应用共存且父动态传子,子应用仍是须要用到qiankun提供的 onGlobalStateChange的api监听才行,有更好方案的同窗能够分享讨论一下。该方案恰好符合笔者当前的项目需求,所以够用了,请同窗们根据本身的业务需求来封装。

子应用切换Loading处理

子应用首次加载时至关于新加载一个项目,仍是比较慢的,所以loading是不得不加上的。

官方的例子中有作了loading的处理,可是须要额外引入import Vue from 'vue/dist/vue.esm',这会增长主应用的打包体积(对比发现大概增长了100KB)。一个loading增长了100K,显然代价有点没法接受,因此须要考虑一种更优一点的办法。

咱们的主应用是用vue搭建的,并且qiankun提供了loader方法能够获取到子应用的加载状态,因此天然而然地能够想到:main.js中子应用加载时,将loading 的状态传给Vue实例,让Vue实例响应式地显示loading。接下来先选一个loading组件:

  • 若是主应用使用了ElementUI或其余框架,能够直接使用UI库提供的loading组件。
  • 若是主应用为了保持简单没有引入UI库,能够考虑本身写一个loading组件,或者找个小巧的loading库,如笔者这里要用到的NProgress
npm install --save nprogress

接下来是想办法如何把loading状态传给主应用的App.vue。通过笔者试验发现,new Vue方法返回的vue实例能够经过instance.$children[0]来改变App.vue的数据,因此改造一下main.js

// 引入nprogress的css
import 'nprogress/nprogress.css'
import microApps from './micro-app';

// 获取实例
const instance = new Vue({
  render: h => h(App),
}).$mount('#app');

// 定义loader方法,loading改变时,将变量赋值给App.vue的data中的isLoading
function loader(loading) {
  if (instance && instance.$children) {
    // instance.$children[0] 是App.vue,此时直接改动App.vue的isLoading
    instance.$children[0].isLoading = loading
  }
}

// 给子应用配置加上loader方法
let apps = microApps.map(item => {
  return {
    ...item,
    loader
  }
})
registerMicroApps(apps);

start();
PS: qiankun的registerMicroApps方法也监听到子应用的beforeLoad、afterMount等生命周期,所以也可使用这些方法记录loading状态,但更好的用法确定是经过loader参数传递。

改造主应用的App.vue,经过watch监听isLoading

<template>
  <div id="layout-wrapper">
    <div class="layout-header">头部导航</div>
    <div id="subapp-viewport"></div>
  </div>
</template>

<script>
import NProgress from 'nprogress'
export default {
  name: 'App',
  data () {
    return {
      isLoading: true
    }
  },
  watch: {
    isLoading (val) {
      if (val) {
        NProgress.start()
      } else {
        this.$nextTick(() => {
          NProgress.done()
        })
      }
    }
  },
  components: {},
  created () {
    NProgress.start()
  }
}
</script>

至此,loading效果就实现了。虽然instance.$children[0].isLoading的操做看起来比较骚,但确实比官方的提供的例子成本小不少(体积增长几乎为0),如有更好的办法,欢迎你们评论区分享。

抽取公共代码

不可避免,有些方法或工具类是全部子应用都须要用到的,每一个子应用都copy一份确定是很差维护的,因此抽取公共代码到一处是必要的一步。

根目录下新建一个common文件夹用于存放公共代码,如上面的多个vue子应用均可以共用的global-register.js,或者是可复用的request.jssdk之类的工具函数等。这里代码不贴了,请直接看demo

公共代码抽取后,其余的应用如何使用呢? 可让common发布为一个npm私包,npm私包有如下几种组织形式:

  • npm指向本地file地址:npm install file:../common。直接在根目录新建一个common目录,而后npm直接依赖文件路径。
  • npm指向私有git仓库: npm install git+ssh://xxx-common.git
  • 发布到npm私服。

本demo由于是基座和子应用都集合在一个git仓库上,因此采用了第一种方式,但实际应用时是发布到npm私服,由于后面咱们会拆分基座和子应用为独立的子仓库,支持独立开发,后文会讲到。

须要注意的是,因为common是不通过babel和pollfy的,因此引用者须要在webpack打包时显性指定该模块须要编译,如vue子应用的vue.config.js须要加上这句:

module.exports = {
  transpileDependencies: ['common'],
}

子应用支持独立开发

微前端一个很重要的概念是拆分,是分治的思想,把全部的业务拆分为一个个独立可运行的模块。

从开发者的角度看,整个系统可能有N个子应用,若是启动整个系统可能会很慢很卡,而产品的某个需求可能只涉及到其中一个子应用,所以开发时只需启动涉及到的子应用便可,独立启动专一开发,所以是颇有必要支持子应用的独立开发的。若是要支持,主要会遇到如下几个问题:

  • 子应用的登陆态怎么维护?
  • 基座不启动时,怎么获取到基座下发的数据和能力?

在基座运行时,登陆态和用户信息是存放在基座上的,而后基座经过props下发给子应用。但若是基座不启动,只是子应用独立启动,子应用就无法经过props获取到所需的用户信息了。所以,解决办法只能是父子应用都得实现一套相同的登陆逻辑。为了可复用,能够把登陆逻辑封装在common中,而后在子应用独立运行的逻辑中添加登陆相关的逻辑。

// sub-vue/src/main.js

import { store as commonStore } from 'common'
import store from './store'

if (!window.__POWERED_BY_QIANKUN__) {
  // 这里是子应用独立运行的环境,实现子应用的登陆逻辑
  
  // 独立运行时,也注册一个名为global的store module
  commonStore.globalRegister(store)
  // 模拟登陆后,存储用户信息到global module
  const userInfo = { name: '我是独立运行时名字叫张三' } // 假设登陆后取到的用户信息
  store.commit('global/setGlobalState', { user: userInfo })
  
  render()
}
// ...
export async function mount (props) {
  console.log('[vue] props from main framework', props)

  commonStore.globalRegister(store, props)

  render(props)
}
// ...

!window.__POWERED_BY_QIANKUN__表示子应用处于非qiankun内的环境,即独立运行时。此时咱们依然要注册一个名为global的vuex module,子应用内部一样能够从global module中获取用户的信息,从而作到抹平qiankun和独立运行时的环境差别。

PS:咱们前面写的 global-register.js写得很巧妙,可以同时支持两种环境,所以上面能够经过 commonStore.globalRegister直接引用。

子应用独立仓库

随着项目发展,子应用可能会愈来愈多,若是子应用和基座都集合在同一个git仓库,就会愈来愈臃肿。

若项目有CI/CD,只修改了某个子应用的代码,但代码提交会同时触发全部子应用构建,牵一发动全身,是不合理的。

同时,若是某些业务的子应用的开发是跨部门跨团队的,代码仓库如何分权限管理又是一个问题。

基于以上问题,咱们不得不考虑将各个应用迁移到独立的git仓库。因为咱们独立仓库了,项目可能不会再放到同一个目录下,所以前面经过npm i file:../common方式安装的common就不适用了,因此最好仍是发布到公司的npm私服或采用git地址形式。

qiankun-example为了更好展现,仍将全部应用都放在同一个git仓库下,请各位同窗不要照抄。

子应用独立仓库后聚合管理

子应用独立git仓库后,能够作到独立启动独立开发了,这时候又会遇到问题:开发环境都是独立的,没法一览整个应用的全貌

虽然开发时专一于某个子应用时更好,但总有须要整个项目跑起来的时候,好比当多个子应用须要互相依赖跳转时,因此仍是要有一个整个项目对全部子应用git仓库的聚合管理才行,该聚合仓库要求作到可以一键install全部的依赖(包括子应用),一键启动整个项目。

这里主要考虑了三种方案:

  1. 使用git submodule
  2. 使用git subtree
  3. 单纯地将全部子仓库放到聚合目录下并.gitignore掉。
  4. 使用lerna管理。

git submodulegit subtree都是很好的子仓库管理方案,但缺点是每次子应用变动后,聚合库还得同步一次变动。

考虑到并非全部人都会使用该聚合仓库,子仓库独立开发时每每不会主动同步到聚合库,使用聚合库的同窗就得常常作同步的操做,比较耗时耗力,不算特别完美。

因此第三种方案比较符合笔者目前团队的状况。聚合库至关因而一个空目录,在该目录下clone全部子仓库,并gitignore,子仓库的代码提交都在各自的仓库目录下进行操做,这样聚合库能够避免作同步的操做。

因为ignore了全部子仓库,聚合库clone下来后,还是一个空目录,此时咱们能够写个脚本scripts/clone-all.sh,把全部子仓库的clone命令都写上:

# 子仓库一
git clone git@xxx1.git

# 子仓库二
git clone git@xxx2.git

而后在聚合库也初始化一个package.json,scripts加上:

"scripts": {
    "clone:all": "bash ./scripts/clone-all.sh",
  },

这样,git clone聚合库下来后,再npm run clone:all就能够作到一键clone全部子仓库了。

前面说到聚合库要可以作到一键install和一键启动整个项目,咱们参考qiankun的examples,使用npm-run-all来作这个事情。

  1. 聚合库安装npm i npm-run-all -D
  2. 聚合库的package.json增长install和start命令:
"scripts": {
    ...
    "install": "npm-run-all --serial install:*",
    "install:main": "cd main && npm i",
    "install:sub-vue": "cd sub-vue && npm i",
    "install:sub-react": "cd sub-react && npm i",
    "start": "npm-run-all --parallel start:*",
    "start:sub-react": "cd sub-react && npm start",
    "start:sub-vue": "cd sub-vue && npm start",
    "start:main": "cd main && npm start"
  },
npm-run-all--serial表示有顺序地一个个执行, --parallel表示同时并行地运行。

配好以上,一键安装npm i,一键启动npm start

vscode eslint配置

若是使用vscode,且使用了eslint的插件作自动修复,因为项目处于非根目录,eslint无法生效,因此还须要指定eslint的工做目录:

// .vscode/settings.json
{
  "eslint.workingDirectories": [
    "./main",
    "./sub-vue",
    "./sub-react",
    "./common"
  ],
  "eslint.enable": true,
  "editor.formatOnSave": false,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "search.useIgnoreFiles": false,
  "search.exclude": {
    "**/dist": true
  },
}

子应用互相跳转

除了点击页面顶部的菜单切换子应用,咱们的需求也要求子应用内部跳其余子应用,这会涉及到顶部菜单active状态的展现问题:sub-vue切换到sub-react,此时顶部菜单须要将sub-react改成激活状态。有两种方案:

  • 子应用跳转动做向上抛给父应用,由父应用作真正的跳转,从而父应用知道要改变激活状态,有点子组件$emit事件给父组件的意思。
  • 父应用监听history.pushState事件,当发现路由换了,父应用从而知道要不要改变激活状态。

因为qiankun暂时没有封装子应用向父应用抛出事件的api,如iframe的postMessage,因此方案一有些难度,不过能够将激活状态放到状态管理中,子应用经过改变vuex中的值让父应用同步就行,作法可行但不太好,维护状态在状态管理中有点复杂了。

因此咱们这里选方案二,子应用跳转是经过history.pushState(null, '/sub-react', '/sub-react')的,所以父应用在mounted时想办法监听到history.pushState就能够了。因为history.popstate只能监听back/forward/go却不能监听history.pushState,因此须要额外全局复写一下history.pushState事件。

// main/src/App.vue
export default {
  methods: {
    bindCurrent () {
      const path = window.location.pathname
      if (this.microApps.findIndex(item => item.activeRule === path) >= 0) {
        this.current = path
      }
    },
    listenRouterChange () {
      const _wr = function (type) {
        const orig = history[type]
        return function () {
          const rv = orig.apply(this, arguments)
          const e = new Event(type)
          e.arguments = arguments
          window.dispatchEvent(e)
          return rv
        }
      }
      history.pushState = _wr('pushState')

      window.addEventListener('pushState', this.bindCurrent)
      window.addEventListener('popstate', this.bindCurrent)

      this.$once('hook:beforeDestroy', () => {
        window.removeEventListener('pushState', this.bindCurrent)
        window.removeEventListener('popstate', this.bindCurrent)
      })
    }
  },
  mounted () {
    this.listenRouterChange()
  }
}

性能优化

每一个子应用都是一个完整的应用,每一个vue子应用都打包了一份vue/vue-router/vuex。从整个项目的角度,至关于将那些模块打包了屡次,会很浪费,因此这里能够进一步去优化性能。

首先咱们能想到的是经过webpack的externals或主应用下发公共模块进行复用。

可是要注意,若是全部子应用都共用一个相同的模块,从长远来看,不利于子应用的升级,难以一箭双鵰。

如今以为比较好的作法是:主应用能够下发一些自身用到的模块,子应用能够优先选择主应用下发的模块,当发现主应用没有时则本身加载;子应用也能够直接使用最新的版本而不用父应用下发的。

这个方案参考自qiankun 微前端方案实践及总结-子项目之间的公共插件如何共享,思路说得很是完整,你们能够看看,本项目暂时还没加上该功能。

部署

如今网上qiankun部署相关的文章几乎搜不到,多是以为简单没啥好说的吧。但对于还不太熟悉的同窗来讲,其实会比较纠结qiankun部署的最佳部署方案是怎样的呢?因此以为颇有必要讲一下笔者这里的部署方案,供你们参考。

方案以下:

考虑到主应用和子应用共用域名时可能会存在路由冲突的问题,子应用可能会源源不断地添加进来,所以咱们将子应用都放在xx.com/subapp/这个二级目录下,根路径/留给主应用。

步骤以下:

  1. 主应用main和全部子应用都打包出一份html,css,js,static,分目录上传到服务器,子应用统一放到subapp目录下,最终如:
├── main
│   └── index.html
└── subapp
    ├── sub-react
    │   └── index.html
    └── sub-vue
        └── index.html
  1. 配置nginx,预期是xx.com根路径指向主应用,xx.com/subapp指向子应用,子应用的配置只需写一份,之后新增子应用也不须要改nginx配置,如下应该是微应用部署的最简洁的一份nginx配置了。
server {
    listen       80;
    server_name qiankun.fengxianqi.com;
    location / {
        root   /data/web/qiankun/main;  # 主应用所在的目录
        index index.html;
        try_files $uri $uri/ /index.html;
    }
    location /subapp {
        alias /data/web/qiankun/subapp;
        try_files $uri $uri/ /index.html;
    }

}

nginx -s reload后就能够了。

本文特意作了线上demo展现:

整站(主应用):http://qiankun.fengxianqi.com/

单独访问子应用:

遇到的问题

1、react子应用启动后,主应用第一次渲染后会挂掉

image
子应用的热重载竟然会引得父应用直接挂掉,当时彻底懵逼了。还好搜到了相关的issues/340,即在复写react的webpack时禁用掉热重载(加了下面配置禁用后会致使无法热重载,react应用在开发时得手动刷新了,是否是有点难受。。。):

module.exports = {
  webpack: function override(config, env) {
    // 解决主应用接入后会挂掉的问题:https://github.com/umijs/qiankun/issues/340
    config.entry = config.entry.filter(
      (e) => !e.includes('webpackHotDevClient')
    );
    // ...
    return config;
  }
};

2、Uncaught Error: application 'xx' died in status SKIP_BECAUSE_BROKEN: [qiankun] Target container with #subapp-viewport not existed while xx mounting!

在本地dev开发时是彻底正常的,这个问题是部署后在首次打开页面才会出现的,F5刷新后又会正常,只能在清掉缓存后复现一次。这个bug困扰了几天。

错误信息很清晰,即主应用在挂载xx子应用时,用于装载子应用的dom不存在。因此一开始觉得是vue作主应用时,#subapp-viewport还没来得及渲染,所以要尝试确保主应用mount后再注册子应用。

// 主应用的main.js
new Vue({
  render: h => h(App),
  mounted: () => {
    // mounted后再注册子应用
    renderMicroApps();
  },
}).$mount('#root-app');

但该办法不行,甚至setTimeout都用上了也不行,需另想办法。

最后逐步调试发现是项目加载了一段高德地图的js致使的,该js在首次加载时会使用document.write去复写整个html,所以致使了#subapp-viewport不存在的报错,因此最后是要想办法去掉该js文件就能够了。

小插曲:为何咱们的项目会加载这个高德地图js?咱们项目也没有用到啊,这时咱们陷入了一个思惟误区:qiankun是阿里的,高德也是阿里的,qiankun不会偷偷在渲染时动态加载高德的js作些数据收集吧?很是惭愧会对一个开源项目有这个想法。。。实际上,是由于我司写组件库模板的小伙伴忘记移除调试时 public/index.html用到的这个js了,当时还去评论 issue了(捂脸哭)。把这个讲出来,是想说遇到bug时仍是要先检查一下本身,别轻易就去质疑别人。

最后

本文从开始搭建到部署很是完整地分享了整个架构搭建的一些思路和实践,但愿能对你们有所帮助。要提醒一下的是,本示例可能不必定最佳的实践,仅做为一个思路参考,架构是会随着业务需求不断调整变化的,只有合适的才是最好的。

示例代码: https://github.com/fengxianqi/qiankun-example

在线demo:http://qiankun.fengxianqi.com/

单独访问在线子应用:

最后的最后,喜欢本文的同窗还请能顺手给个赞和小星星鼓励一下,很是感谢看到这里。

一些参考文章