天天看点

N年前写的微前端笔记——4.消息总线-微前端应用之间的通信工具(四)

7.消息总线

其实微前端,就是将每个app装入了自己的黑匣子中,与外界相对隔离,相互之间无法通信,这其实与我们的现实并不符合。所以,微前端应用之间的相互通信,成了微前端架构的关键之一。

应用之间的通信,可以分为两类:

  • 每个黑匣子里面发生了什么,外面如何知道?
  • 每个黑匣子都是有生命周期的,当被卸载的时候,外面如何知道你已经被卸载了,卸载后又如何保证正常的通信?

解决方案

github上的​​single-spa-portal-example​​提供了解决方案。

该方案是基于redux的。

pre.了解redux的原理

  • 原理简单描述下:
这里有以下几类角色:
  • 组件:A、B、C
  • 仓库:store,里面有数据状态state
  • 管理员:reducer

reducer管理着仓库store中的状态state,只要reducer才能修改仓库中的状态

当组件A需要修改仓库中变量a的值,就告诉管理员reducer:我要把仓库中的a变量的值加1。

这时候reducer就会去查下有没有加这个动作,有的话,就给变量a加1,没有就不变。

由于其他组件B和C都一直观察者仓库的状态,一旦变化,就会更新自己用到的变量。

加入这时候reducer把a加1了,那么B和C立马会将自己组件内用到的a更新成最新值。

白话文往往是最好的解释,但程序员不得不将这白话文转成代码:

  • 首先我们创建如下目录结构:
reducers
├── actions
│ └── cart-actions.js
├── reducers
│ ├── cart-reducer.js
│ ├── index.js
│ └── products-reducer.js
├── index.js
└── store.js

      
  • 首先,我们需要创建一个仓库store——创建store.js
import { createStore } from 'redux';
import rootReducer from './reducers/index.js';

const store = createStore(rootReducer);
export default store;
      
  • 我们看到创建仓库store需要一份rootReducer,这个rootReducer就是仓库总管,为啥是总管,而不是简简单单的管理员?因为rootReducer管理着所有reducer。我们来看下reducers文件夹下的内容。
  • 在cart-reducer.js
import  { ADD_TO_CART }  from '../actions/cart-actions';
  let initialState = {
    cart: [
      {
        product: 'bread 700g',
        quantity: 2,
        unitCost: 90
      },
      {
        product: 'milk 500ml',
        quantity: 1,
        unitCost: 47
      }
    ]
  }

  /**
  *
  *
  * @export
  * @param {*} [state=initialState] 仓库总的数据,默认值为initialState
  * @param {*} action 操作,内有属性type和playload,分别表示动作类型和新数据
  */
  export default function(state=initialState, action){
    switch (action.type) {
      case ADD_TO_CART: 
        return {
          ...state,
          cart: [...state.cart, action.payload]
        };
      default:
        return state;
    }
  }      
  • 在products-reducer.js
export default function(state=[], action) {
    return state;
  }      
  • 然后将这两个reducer汇总,即index.js
import { combineReducers } from 'redux';
  import cartReducer from './cart-reducer.js';
  import productsReducer from './products-reducer.js';

  let allReducers = [
    cartReducer,
    productsReducer,
  ]

  const rootReducer = combineReducers(allReducers);
  export default rootReducer;      
这时候就产生了一个rootReducer。
  • 我们在上面的reducer代码中看到需要引入一个actions。这个actions是干嘛的,其实就是上面白话文重点的加1这个动作。这个动作包含两个内容,一个是"加"这个操作,一个是"1"这个载荷,所以,我们可以这么来写代码:
  • actions/cart-actions.js
export const ADD_TO_CART = 'ADD_TO_CART';

  export function addToCart(product, quantity, unitCost) {
    return {
      type: ADD_TO_CART,
      payload: { product, quantity, unitCost }
    }
  }      
  • 上面第一个export的是动作加,第二个export其实就是载荷。
  • 另外,我们还需要去订阅和派发修改数据。即在index.js中:
import store from './store.js';
import { addToCart } from './actions/cart-actions.js';

console.log("inital state", store.getState());// 获取全部数据

let unsubscribe = store.subscribe(() => {
  console.log(store.getState());
})

// 提交数据(修改仓库数据)
store.dispatch(addToCart('Coffee 500mg', 1, 250));
store.dispatch(addToCart('Flour 1kg', 2, 110));
store.dispatch(addToCart('Juice 2L', 1, 250));

// 取消观察
unsubscribe();
      
  • store.getState()获取仓库中的数据
  • store.subscribe订阅(观察)仓库,一旦有变化,就在回调内处理。
  • store.dispatch派发,修改数据,其中的参数就代表了修改数据的行为和数值,就是白话文中的"加1".

如何使用redux,成为微前端的消息总线

基本思路是这样的:
  1. 每个应用都暴露出自己的store.js,这个store.js内容其实就是暴露该应用自己的store(这句好像是废话,尴尬!),如下:
import { createStore, combineReducers } from 'redux'

const initialState = {
  app: 'react',
  refresh: 2
}

function reactRender(state = initialState, action) {
  switch (action.type) {
    case 'REFRESH':
      return { ...state,
        refresh: state.refresh + 1
      }
    default:
      return state
  }
}

// 向外输出 Reducer
export const storeInstance = createStore(combineReducers({ namespace: () => 'react', reactRender }));// 特别注意:这里的参数是对象,对象的value必须是函数
      
  • **特别注意: ** createStore(combineReducers())的参数是对象,对象的value必须是函数
  1. 注册应用: 在入口文件single-spa.config.js中,导入每个应用的配置所组成的数组,然后循环这个数组,对每份配置进行注册:
/* 以下是模块加载器版的config */
require('babel-polyfill')
import * as singleSpa from 'single-spa';
// 导入所有模块的配置集合
import projectConfig from './project.config.js';
import { registerApp } from './Register';
async function bootstrap(){
  // 批量注册:对所有模块依次注册
  projectConfig.forEach(config => {
    registerApp({
      name: config.name,
      main: config.main,
      url: config.prefix,
      store: config.store,
      base: config.base,
      path: config.path,
    })
  })
  singleSpa.start();
}

// 启动
bootstrap();
      
  1. 上面注册的核心是registerApp函数,该函数来自Register.js,但是在看Register.js之前,需要先来看一下GlobalEventDistributor这个类:
export class GlobalEventDistributor {

  constructor() {
      // 在函数实例化的时候,初始一个数组,保存所有模块的对外api
      this.stores = [];
  }

  // 注册
  registerStore(store) {
      this.stores.push(store);
  }

  // 触发,这个函数会被种到每一个模块当中.便于每一个模块可以调用其他模块的 api
  // 大致是每个模块都问一遍,是否有对应的事件触发.如果每个模块都有,都会被触发.
  dispatch(event) {
      this.stores.forEach((s) => {
          s.dispatch(event)
      });
  }

  // 获取所有模块当前的对外状态
  getState() {
      let state = {};
      this.stores.forEach((s) => {
          let currentState = s.getState();
        //   console.log('currentState', currentState)
          state[currentState.namespace] = currentState
      });
      return state
  }
}
      
Register.js如下:
// Register.js
import * as singleSpa from 'single-spa';
//全局的事件派发器 (新增)
import { GlobalEventDistributor } from './GlobalEventDistributor' 
const globalEventDistributor = new GlobalEventDistributor();


// hash 模式,项目路由用的是hash模式会用到该函数
export function hashPrefix(app) {
    return function (location) {
        let isShow = false
        //如果该应用 有多个需要匹配的路劲
        if(isArray(app.path)){
            app.path.forEach(path => {
                if(location.hash.startsWith(`#${path}`)){
                    isShow = true
                }
            });
        }
        // 普通情况
        else if(location.hash.startsWith(`#${app.path || app.url}`)){
            isShow = true
        }
        console.log('hashPrefix', isShow)
        return isShow;
    }
}

// pushState 模式
export function pathPrefix(app) {
    return function (location) {
        let isShow = false
        //如果该模块 有多个需要匹配的路径
        if(isArray(app.path)){
            app.path.forEach(path => {
                if(location.pathname.indexOf(`${path}`) === 0){
                    isShow = true
                }
            });
        }
        // 普通情况
        else if(location.pathname.indexOf(`${app.path || app.url}`) === 0){
            isShow = true
        }
        return isShow;
    }
}

// 应用注册
export async function registerApp(params) {
    // 导入派发器
    let storeModule = {}, customProps = { globalEventDistributor: globalEventDistributor };
    // 在这里,我们会用SystemJS来导入模块的对外输出的Reducer(后续会被称作模块对外API),统一挂载到消息总线上
    try {
        storeModule = params.store ? await import(`./src/${params.store}`) : { storeInstance: null };
    } catch (e) {
        console.log(`Could not load store of app ${params.name}.`, e);
        //如果失败则不注册该模块
        return
    }
    // 注册应用于事件派发器
    if (storeModule.storeInstance && globalEventDistributor) {
        //取出 redux storeInstance
        customProps.store = storeModule.storeInstance;

        // 注册到全局
        globalEventDistributor.registerStore(storeModule.storeInstance);
    }

    //当与派发器一起组装成一个对象之后,在这里以这种形式传入每一个单独模块
    customProps = { store: storeModule, globalEventDistributor: globalEventDistributor };

    // 在注册的时候传入 customProps
    singleSpa.registerApplication(params.name, () => import(`./src/${params.main}`), params.base ? (() => true) : pathPrefix(params), customProps);

}

//数组判断 用于判断是否有多个url前缀
function isArray(o){
    return Object.prototype.toString.call(o)=='[object Array]';
}
      
  • 上面registerApp这个函数,也就是真正注册的过程,具体步骤是这样的:
  1. 创建自定义属性集合customProps,这个customProps为对象,存放着全局的globalEventDistributor
  2. 创建派发器storeModule,这个派发器里面的storeInstance属性就存放着对应应用的仓库store
  3. 将storeModule.storeInstance挂在到指定以熟悉集合customProps的store属性上
  4. 将应用的仓库实例storeInstance全局注册
  5. 这时候自定义属性集合为{ store: storeModule, globalEventDistributor: globalEventDistributor },里面包含了当前应用的派发器,也包含了全局的globalEventDistributor,里面存放着所有应用的store
  6. 利用singleSpa.registerApplication真正注册应用
  1. 注册完毕后,在应用的入口文件,就能获取到全局所有应用的store,其实是拿到了刚刚传入的storeModule,这里面包含了所有应用的store。
  • 在vue中,入口文件应该这样写:
import Vue from 'vue';
  import singleSpaVue from 'single-spa-vue';
  import Hello from './main.vue'
  import createVuexStore from './vuexStore/index.js';
  import { CHANGE_ORIGIN, GLOBAL_PROPS } from './vuexStore/types.js';
  let store = createVuexStore();

  const vueLifecycles = singleSpaVue({
    Vue,
    appOptions: {
      el: '#vue',
      render: r => r(Hello),
      store,
    } 
  });

  export const bootstrap = [
    vueLifecycles.bootstrap,
  ];

  // export const mount = [
  //   vueLifecycles.mount,
  // ];
  export function mount(props) {
    console.log('传递进来的属性', props); // do something with the common authToken in app1
    // 这个props就是传递过来的globalEventDistributor,全局的数据,也就是Register.js中registerApplication时传入的第三个参数customProps
    // 这时候,就可以用vuex将数据传递到仓库中了
    let origin = props.globalEventDistributor.stores[0].getState().reactRender.app;
    store.commit('users/' + CHANGE_ORIGIN, origin);
    store.commit('users/' + GLOBAL_PROPS, props); // 将全局的globalEventDistributor挂到vue的store中,这样可以在vue中派发事件去更新其他应用的仓库,因为globalEventDistributor包含了所有应用的store。
    return vueLifecycles.mount(props);
  }
  export const unmount = [
    vueLifecycles.unmount,
  ];