⚡qiankun微前端中的应用通信-不受框架限制的响应式数据管理__Vue.js__前端
发布于 2 个月前 作者 banyungong 144 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

theme: smartblue highlight: a11y-dark

引言

对于微前端来说,应用间通信(主要为主应用-微应用)往往是架构设计之初就要考虑的核心需求,并且这种通信需求往往不是一个简单的传参就能满足的(如果是的话,路由localstorage就可以满足),因此这就要求我们在进行技术选型,调研微前端解决方案时,通信方案也要一并考虑。

背景

在调研了众多微前端解决方案后,我们初步选择了qiankun框架,但是qiankun自身在通信方面并不提供完整的解决方案,更多的是提供api,通过这些api可以快捷地实现通讯功能,但是对于更丰富、未来可能更复杂地业务需求,依照qiankun文档中的示例,恐怕难以满足。因此在横向对比了众多方案后(其中一篇文章给了我很大的启发-基于 qiankun 的微前端最佳实践(图文并茂) - 应用间通信篇

image.png

最终选择使用redux作为微前端的通信解决方案。

特点

该方案以redux为核心,采用发布-订阅模式进行封装,实现应用间通信数据上的响应式,并在代码结构上实现模块化,api方面仿照vuex,降低上手难度, 并可适用多框架(如vue、react).

实现

设计思路

image.png

  1. Shared实例基于BaseShared基类生成

    • BaseShared基类接收两个构造参数: PoolAction

    • Pool由reduxcreateStore生成,所需参数为所有module的reducer, 也就是说Pool是所有reducer的合集

    • Action负责管理所有module的action

  2. 每个Module均包含两个部分, reduceraction

    • reducer 即为redux中的reducer类型,可实现对状态树的操作,并最终导出给Pool模块交由reduxcreateStore进行生成, 对reducer有疑问的,可以参考redux文档
    • action 类似vuex的action, 用于提交mutation(即交由reducer来更改状态),同时action中的api也将是暴露给使用者的接口(也就是使用过程中是无法直接操作reducer的,只能调用action)

注意: Pool即为Redux文档中的store, 只是一般项目中的状态模块均命名为store, 因此为了避免混淆, 取名为Pool

实际代码

注意: 本次演示我创建了两个项目, 一个叫plat,一个叫micro, 顾名思义, plat项目即为主应用, micro为微应用

目录

主应用项目代码里的shared目录 @/shared

shared
 ├── action.ts
 ├── base.ts
 ├── index.ts
 ├── pool.ts
 └── modules
     ├── locale
     │   ├── action.ts
     │   └── reducer.ts
     └── user
         ├── action.ts
         └── reducer.ts

开发流程

  1. 以开发user模块开始
// @/shared/modules/user/reducer.ts
interface UserInfo {
    username: string,
}

interface State {
    userinfo?: UserInfo | Record<string, never>
}

const state:State = {
    userinfo: {},
};

type Mutation = {
    type: string;
    payload: any;
}

const reducer = (userState: State = state, mutation: Mutation): State => {
    switch (mutation.type) {
    case 'SET_USERINFO': return {
        ...userState,
        userinfo: mutation.payload,
    }; break;
    default: return userState;
    }
};

export default reducer;

// @/shared/modules/user/action.ts
import pool from '../../pool';

interface UserInfo {
    username: string,
}

export const userAction = {
    name: 'user',// 模块名称,shared将根据名称区分不同模块的action
    getUserinfo: (): UserInfo | Record<string, never> => {
        const state = pool.getState();
        return state.user.userinfo || {};
    },
    setUserinfo: (userinfo: UserInfo): void => {
        pool.dispatch({
            type: 'SET_USERINFO',
            payload: userinfo,
        });
    },
};
  1. 将user模块的reducer 和 action 分别导入到pool模块和action模块
// @/shared/pool.ts
import { combineReducers, createStore } from 'redux';
import userReducer from './modules/user/reducer';
import localeReducer from './modules/locale/reducer';

const staticReducers = combineReducers({
    user: userReducer,
    locale: localeReducer,
});

const pool = createStore(staticReducers);

export default pool;

// @/shared/action.ts
import { localeAction } from './modules/locale/action';
import { userAction } from './modules/user/action';

const actionList = [
    localeAction,
    userAction,
];

const actions = new Map();

actionList.forEach((obj: any) => {
    const { name } = obj;
    Object.keys(obj).forEach((key) => {
        if (key !== 'name') actions.set(`${name}/${key}`, obj[key]);
    });
});

export default actions;
  1. 将pool模块和action模块导入到index.ts中,由BaseShared基类构造为shared实例
// @/shared/index.ts
import BaseShared from './base';
import pool from './pool';
import actions from './action';

const shared = new BaseShared(pool as any, actions);

export default shared;
// @/shared/base.ts
import { Store } from 'redux';

export default class BaseShared {
    static pool: Store;

    static actions = new Map();

    constructor(Pool: Store, action = new Map()) {
        BaseShared.pool = Pool;
        BaseShared.actions = action;
    }

    public init(listener: any): void {
        BaseShared.pool.subscribe(listener);
    }

    public dispatch(target: string, param: any = ''):any {
        const res:any = BaseShared.actions.get(target)(param);
        return res;
    }
}

BaseShared基类是整个shared模块的核心,实现action的分发(dispatch), redux订阅事件的初始化(init)

到这里为止,shared核心内容已经完成,接下来要做的,就是将shared对接qiankun,并在子应用中接收该实例了

  1. 在主应用项目中,进行qiankun的微应用注册的地方
import { registerMicroApps, start } from 'qiankun';
import shared from '@/shared';

registerMicroApps([
  {
    name: 'micro',
    entry: '//localhost:8888',
    container: '#nav',
    activeRule: '/micro',
    props: {
        shared
    },
  },
]);

start();
  1. 在微应用中,接收shared实例
// @/main.ts 已隐藏无关代码
import SharedModule from '@/shared';

function render(props: any = {}) {
    const { container, shared = SharedModule.getShared() } = props;
    SharedModule.overloadShared(shared);
}

SharedModule是什么? 是微应用用于管理shared实例的模块

微应用中的SharedModule目录如下

shared
 ├── index.ts
 └── shared.ts // 当微应用独立运行时(即不存在主应用的传参), 替代主应用的shared
// @/shared/index.ts
import { Shared } from './shared';// 若不需要微应用独立运行,那么此处可以忽视

class SharedModule {
    static shared = new Shared();// shared实例

    static listener: Array<any> = [];// 监听事件列表

    /**
     * 重载 shared
     */
    static overloadShared(shared) {
        SharedModule.shared = shared;
        shared.init(() => {
            SharedModule.listener.forEach((fn) => {
                fn();
            });
        });
    }

    /**
     * 初始化监听事件列表
     */
    static subscribe(fn: any) {
        if (!fn) throw Error('缺少参数');
        if (fn.length) {
            SharedModule.listener.push(...fn);
        } else {
            SharedModule.listener.push(fn);
        }
    }

    /**
     * 获取 shared 实例
     */
    static getShared() {
        return SharedModule.shared;
    }
}

export default SharedModule;
  1. 在微应用的store中,使用shared实例
// @/store/index.ts
import Vue from 'vue';
import Vuex from 'vuex';
import SharedModule from '@/shared';

Vue.use(Vuex);

let shared:any = null;

export interface UserInfo {
    username: string,
}

interface State {
    locale: string,
    userinfo: UserInfo | Record<string, never>,
}

export default new Vuex.Store({
    state: {
        locale: '',
        userinfo: {},
    },
    mutations: {
        SET_LOCALE: (state: State, locale: string) => {
            state.locale = locale;
        },
        SET_USERINFO: (state: State, userinfo: UserInfo) => {
            state.userinfo = userinfo;
        },
    },
    actions: {
        initShared() {
            shared = SharedModule.getShared();
            // 通过 SharedModule.subscribe 传入回调函数进行订阅, 可以数组形式批量传入
            // 当pool内数据有变化时(监听到redux提供的set方法执行了),会通过回调函数统一发布
            this.dispatch('setLocale');
            this.dispatch('setUserinfo');
            SharedModule.subscribe([
                () => {
                    this.dispatch('setLocale');
                },
                () => {
                    this.dispatch('setUserinfo');
                },
            ]);
        },
        setLocale({ commit }) {
            const locale = shared.dispatch('locale/getLocale');
            commit('SET_LOCALE', locale);
        },
        setUserinfo({ commit }) {
            const userinfo = shared.dispatch('user/getUserinfo');
            commit('SET_USERINFO', userinfo);
        },
    },
    getters: {
        locale: (state: State) => state.locale,
        userinfo: (state: State) => state.userinfo,
    },
    modules: {
    },
});

至此已通过shared实例,完成主、微应用之间的通信

  1. 在页面中实际使用
// 主应用的App.vue
<template>
  <div id="app">
      <h2>plat</h2>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
      <p>
          <span>username:</span>
          <input v-model="username"/>
          <button @click="handleSubmit">submit</button>
      </p>
      <p>
          userinfo: {{ userinfo }}
      </p>
      <p>
          language: {{ locale }}
          <button @click="handleChange">change</button>
      </p>
    <div id="nav">
        <router-view/>
    </div>
    
  </div>
</template>
<script>
import { mapGetters } from 'vuex';

export default {
    data() {
        return {
            username: '',
        };
    },
    computed: {
        ...mapGetters(['userinfo', 'locale']),
    },
    methods: {
        handleSubmit() {
            const userinfo = {
                username: this.username,
            };
            this.$store.dispatch('getInfo', userinfo);
        },
        handleChange() {
            let locale = ''
            if (this.locale === 'zh') {
                locale = 'en';
            } else {
                locale = 'zh';
            }
            this.$store.dispatch('setLocale', locale);
        }
    },
};
</script>

// 微应用的HelloWorld.vue
<template>
  <div class="hello">
      <h2>micro</h2>
      <p>userinfo: {{ userinfo }}</p>
      <p>language: {{ locale }}</p>
  </div>
</template>

<script>
import { mapGetters } from 'vuex';

export default {
    computed: {
        ...mapGetters(['userinfo', 'locale']),
    },
};
</script>

image.png

api介绍

api 说明
SharedModule.overloadShared 用于重载SharedModule内的shared实例
SharedModule.getShared() 获取SharedModule内部的shared实例
SharedModule.subscribe() 注册订阅事件(可传数组),在state发生改变后会触发此处订阅的事件
shared.dispatch() 类似vuex的store.dispatch,用于调用不同模块的action

api的设计思路:

该shared通信模块,依据redux本身的(state + action => reducer)结合vuex的(state + mutation + action), 最终设计结构为(state + mutation => reducer + action)。

即:只有mutation能够操作state, 而mutation需要action调用,微应用只能使用主应用暴露出来的action,而主应用可自行决定要暴露的action。

总结&Todo

  1. 这个设计还有个问题,由于该响应式是通过发布-订阅模式实现的, 而该模式依赖于redux提供的subscribe这个api, 但仅凭这个api,我们无法精确地得知究竟哪个属性发生改变, 也就是说只要是state任何属性发生改变, 都会触发subscribe, 当state变得庞大时,该通信模块地性能将不可避免地下降(当然,要非常庞大),因此按模块发布,将会是后续改良的重点
  2. 目前在使用或者说上手难度上来说,还有点复杂, 后续看看能不能更高度的抽象, 暴露更简洁的api
  3. 目前演示demo已经上传至gitee: 主应用微应用

    版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 圊妖 原文链接:https://juejin.im/post/6989132702775525407

回到顶部