vivo 悟空活动中台 - 微组件状态管理(上)

本文首发于 vivo互联网技术 微信公众号 
作者:悟空中台研发团队

一、背景

通过《揭秘 vivo 如何打造千万级 DAU 活动中台 - 启航篇》的技术揭秘,相信我们对于 RSC 有了更多的了解。RSC(remote service component) 即远程服务化组件,通过热插拔的机制,可视化配置,即插即用,快速构建活动页面,是活动页面的核心组成单元。

RSC 是一个高效的对活动页组成单元的抽象设计方案,最大程度上提升了开发效率,降低了开发者的心智负担。我们希望开发者在开发中遵循【高内聚,弱耦合】的设计理念,只需关心 RSC 组件内部的展示和逻辑的处理。

vivo 悟空活动中台 - 微组件状态管理(上)

(图1)

但在我们实际的业务开发中发现,如上图1 ,我们每天都要面对大量的相似场景,用户通过参与【大富翁游戏】获取了游戏的点数,然后【大富翁组件】就需要把游戏结果点数通知给【集卡组件】,然后【集卡组件】获取相应卡片,点击【翻卡】,通知【大富翁组件】更新剩余游戏次数。在这个活动页场景中涉及大量的组件之间的协作和数据共享。所以如果把活动看成一个小型的前端系统,RSC 只是构成系统的一个基本要素,还有一个非常重要的要素不能忽略,那就是 RSC 组件之间的连接。当然这种连接还和场景上下文相关联。所以在对 RSC 组件进行治理的过程中,首先需要解决的就是活动页内组件之间的数据状态的管理。

二、结果

通过不断的深入思考问题,探索现象背后的本质原理,从架构设计层面上很好的解决了组件在不同的场景上下文中的连接(状态管理)。例如:

  • 在活动页内,我们解决了 RSC 组件与组件之间的连接。

  • 在平台内,我们解决了 RSC 组件和平台之间的连接。业务上 RSC 组件需要感知到平台的关键动作,如活动保存,编辑器内组件删除等。

  • 在编辑器内的安全沙盒中,我们解决了组件和跨沙盒的配置面板之间的连接。

三、架构演进

今天就重点聊聊,在活动页内,RSC 组件与组件之间的连接。下一篇我们一起聊聊平台和沙箱环境下的 RSC 组件连接。

因为我们使用 Vue 作为我们前端的 ui 基础框架,所以下面技术方案都是基于 Vue 。

四、EventBus 事件总线

vivo 悟空活动中台 - 微组件状态管理(上)

(图2)

一图胜千言,如图 2 。当然我们想到的最简单的方案,通过实现一个中心化的事件处理中心,来记录组件内的订阅者,当需要协同时就通过自定义事件通知到各个相关的组件内部的订阅者。当然通知中可以携带 payload 参数信息,达到数据共享的目的。其实 Vue 本身也自带一个自定义事件系统, Vue 组件之间的自定义事件就是基于此来实现,详细 api 请参与 Vue 文档。我们可以基于 Vue 本身实现 EventBus 的机制,不需要引入新的依赖,减少 bundle 体积,api使用如下述代码。

const vm = new Vue()
// 注册订阅者
vm.$on('event-name', (payload) => {/*执行业务逻辑*/})
// 注册订阅者,执行一次后自动取消订阅者
vm.$once('some-event-name', (payload) => {/*执行业务逻辑*/})
// 取消某事件的某个订阅者
vm.$off('event-name',[callback])
// 通知各个订阅者执行相对应的业务逻辑
vm.$emit('event-name',payload)

1、架构上的优点

在实践中发现基于 EventBus 的数据状态管理模式的优点:

  • 代码的实现比较简单,设计方案容易理解
  • 很轻量的方式就可以完成组件之间的解耦,将组件之间的强耦合变成对 EventBus 的弱耦合。

2、实践中的痛点

当然EventBus方案的也会有些不足:

  • 因为业务逻辑分散在多个组件订阅者中,所以导致业务逻辑的处理变得碎片化,缺乏连贯的上下文。
  • 在阅读和维护代码时,需要在代码中不断去寻找订阅者,导致业务流程理解上的中断和注意力的分散。

3、反思改进

在认识到 EventBus 的架构设计上的不足时,我们也会 Eating our own dog food,实现了一套可视化的机制,通过对代码的抽象语法树的分析,提取订阅者和发送者的信息,可视化显示他们之间的关联关系,帮助我们快速理解问题。

另外,对于复杂的业务逻辑设计出【前置脚本】的改进方案。例如,活动页面虽然是由多个RSC组件构成,但是请求的服务端接口还是一个,包含了页面初始化状态的所有的数据,此时我们就可以在前置脚本中统一处理获取数据的逻辑,然后再同步到各个RSC组件内部。【前置脚本】的方式,就是抽取一个全局的对象,包含共享的状态和业务逻辑。多个组件依赖这个全局的对象,架构设计如图3,是对 EventBus 方案的一个补充。

vivo 悟空活动中台 - 微组件状态管理(上)

(图3)

4、总结

通过前置脚本,可以解决复杂业务难以维护理解的问题,但是也带来一些风险点如需要暴露全局对象,有被覆盖或者被修改的风险。经过前置脚本的改进之后,我们越来越清晰的感受到我们需要的状态管理模式是什么样子,那就是 Vuex 。那接下来我们就聊聊Vuex。

五、Vuex 状态管理

1、背景

Vuex  是什么?

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。Vuex 也集成到 Vue 的官方调试工具 devtools extension,提供了诸如零配置的 time-travel 调试、状态快照导入导出等高级调试功能。

Vuex 有哪些特点?

  1. 集中式的组件状态管理,支持动态注册 store
  2. 与 Vue 的匹配度高,底层基于 Vue 的响应式数据特性来实现,保持了和 Vue 一样的数据处理特点
  3. 熟悉 Vue 后可以快速上手 Vuex ,学习成本比较低
  4. 完善的开发体验,官方的 devtools 和 time-travel 调试等,帮助开发者梳理数据可预测的变化

2、在平台引入对 Vuex 的支持

Vuex 是一个通用状态管理框架,怎么无缝融入到我们的 RSC 组件体系中呢?我们需要在项目中引入对 Vuex 的依赖和支持,在顶层的 Vue 中添加对 store 的依赖。

我们项目的基本结构:

.
└── src
    ├── App.vue
    ├── app.less
    ├── assets
    ├── component
    ├── directive
    ├── main.js
    ├── stat
    ├── store
    └── utils
├── babel.config.js
├── package.json
├── public

2.1 添加依赖

根据规范,首先在我们的项目目录中的 package.json 中添加对 Vuex 的依赖

{
  "dependencies": {
    "vuex": "^3.0.1"
  }
}

2.2 创建 store 对象

//store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)
export const store = new Vuex.Store({
  // 状态数据
  state() {
    return {}
  },
  getters: {},
  mutations: {},
  actions: {},
})

2.3 顶层 Vue 对象注入 store

将上述创建 store对象注入到顶层的 Vue 对象中,这样所有的 Vue 组件就会通过 this.$store 获取顶层的 store 对象。另外, Vuex 还提供了好用的工具类方法 ( mapState , mapActions , mapGetters , mapMutations ) 来进行数据共享和协同。

// App.vue
import { store } from './store'

new Vue({
  // 注入 store
  // 在所有的改 Vue 管理的 vue 对象中都可以通过 this.$store 来获取
  store,
})

3、使用 Vuex 开发 RSC 组件

3.1 RSC 自有 store

我们还是希望在开发组件时,开发者大部分时间只关注自己的展现和业务逻辑,只是在组件在活动页中被渲染时,才将自身状态共享到顶层的 store 中去。所以组件具有自身的独立 store 状态管理,通过 namespace 命名空间进行模块的状态隔离,然后在组件的 beforeCreate 生命周期方法内,通过 Vuex 的 registerModule 进行动态的 store 的注册。

3.2 StoreMixin 注入

可以通过抽取公共 StoreMixin 来简化这一过程,还可以自动开启 namespaced: true 和针对当前的命名空间扩展快捷方法和属性。代码如下:

// store-mixn.js
export default function StoreMixin(ns, store) {
  return beforeCreate() {
    // 保证 namespace 唯一性
    // 开发者可以通过函数生成唯一的namespace
    // 框架可以生成唯一的namespace
    const namespace = isFn(ns) ? ns(this) : gen(ns)
    this.$ns = namespace
    store.namespaced = true
    this.$store.registerModule(namespace, store)

    // 扩展快捷方法和属性
    this.$state = this.$store.state[namespace]
    this.$dispatch = (action, payload) =>
      this.$store.dispatch(`${namespace}/${action}`, payload)
    this.$getter = //...
    this.$commit = //...
  }
}
//store.js
// 当前组件自有store
export default {
  // 组件自身的状态
  state() {
    return {}
  },
  mutations: {},
  getters: {},
  //...other things
}

// code.vue
// 组件对外的入口模块
import store from './store'
export default {
  mixins: [StoreMixin(/*namespace*/ 'hello', /* 组件的 store */ store)],
}

3.3 命名空间冲突,怎么解?

因为在一个活动中 RSC 组件会被重复加载多次,所有也会导致相同 namespace 的 store 模块重复加载导致模块覆盖。怎么保证 namespace 的唯一性呢?我们可以,在 StoreMixin 中进行 namespace 注册的时候,判断有没有相同的 namespace ,如果有就对 namespace 做一次重命名。比如在已经注册了 hello 为命令空间的 store 时,再次注册 namspace hello 自动会变成 hello1 ,自动做区分。简单的算法实现如下,

// gen.js
// 生成唯一的 namespace
const g = window || global
g.__namespaceCache__ = g.__namespaceCache__ || {}

/**
 * 生成唯一的 moduleName, 同名 name 默认自动增长
 * @param {*} name
 */
export default function genUniqueNamespace(name) {
  let cache = g.__namespaceCache__
  if (cache[name]) {
    cache[name].count += 1
  } else {
    cache[name] = {
      count: 0,
    }
  }
  return name + (cache[name].count === 0 ? '' : cache[name].count)
}

另外,开发者可以通过 store-mixin 中传递自定义函数来生成唯一的 namespace 标识。比如,如下代码,根据 vue-router 中的路由动态参数来设置 namespace

export default {
  mixins: [StoreMixin((vm) => vm.$router.params.spuId), store],
}

3.4 动态命名空间的挑战

因为动态 namespace 就会带来不确定性的问题,如下代码示例,假如hello被重命名为hello1, 另外在 Vuex 中 mapXXX ( mapState , mapMutations 等)方法时,需要精确传递 namespace 才能获取组件内 store 的上下文。

// code.vue
export default {
  mixins: [StoreMixin('hello', store)],
  computed: {
    ...mapGetters('hello', [
      /* hello namespace store getter */
    ]),
    ...mapState('hello', [
      /* hello namespace state property */
    ]),
  },
  methods: {
    ...mapActions('hello', [
      /* hello namespace actions method */
    ]),
    ...mapMutations('hello', [
      /* hello namespace mutations method */
    ]),
  },
}

3.5 扩展 Vuex 支持动态命名空间

怎么解决 Vuex mapXXX 方法中动态 namespace 的问题?首先我们我们想到的是在 StoreMixin 中将 namespace 设置在 Vue 的 this.$ns 对象上,这样被 StoreMixin 混入的组件就就可以动态获取 namespace 。

// store-mixn.js
export default function StoreMixin(ns, store) {
  return beforeCreate() {
    // 保证 namespace 唯一性
    const namespace = gen(ns)
    // 将重命名后的 namespace 挂载到当前 vue 对象的$ns 属性上
    this.$ns = namespace
    //...
  }
}

虽然我们可以在组件内通过 this.$ns 获取组件中的 store 的命名空间,假想着我们可以:

// code.vue
export default {
  computed: {
    ...mapGetter(this.$ns, [
      /* hello namespace store getter */
    ]),
    ...mapState(this.$ns, [
      /* hello namespace state property */
    ]),
  },
  methods: {
    ...mapActions(this.$ns, [
      /* hello namespace actions method */
    ]),
    ...mapMutations(this.$ns, [
      /* hello namespace mutations method */
    ]),
  },
}

很遗憾,在这个时刻 this 根本就不是当前 Vue 的实例,this.$ns 华丽丽的 undefined。那怎么办呢?JS 有很多函数式编程的特点,函数也是值,可以作为参数等进行传递,其实函数除了具有值特性外还有一个很重要的特性就是 lazy computed 惰性计算。基于这样的思考,对 mapXX 方法进行扩展,支持动态的 namespace 。然后在 mapXXX 方法中,等到 vm 是当前 Vue 的组件实例时,才去获取当前的组件的 namespace 。

// code.vue
import { mapGetters, mapState, mapActions, mapMutations } from 'vuex-helper-ext'

export default {
  computed: {
    ...mapGetters((vm) => vm.$ns, [
      /* hello namespace store getter */
    ]),
    ...mapState((vm) => vm.$ns, [
      /* hello namespace state property */
    ]),
  },
  methods: {
    ...mapActions((vm) => vm.$ns, [
      /* hello namespace actions method */
    ]),
    ...mapMutations((vm) => vm.$ns, [
      /* hello namespace mutations method */
    ]),
  },
}

3.6 父子组件如何传递动态命名空间

我相信你,肯定发现了其中一个问题,this.$ns 只能 StoreMixin 的组件内获取到,那该组件的子组件怎么办呢?怎么解决子组件获取父组件的 namespace ?这个时候我们就需要借助 Vue 强悍的 mixin 的体系了,设计一个全局 mixin ,在组件创建的时候判断父组件有没有 $ns 对象,如果存在就将当前的组件的 $ns 设置为父组件一致,如果没有就跳过。

function injectNamespace(Vue) {
  Vue.mixin({
    beforeCreate: function _injectNamespace() {
      const popts = this.$options.parent;
      if (popts && popts.$ns) {
        this.$ns = popts.$ns;
        const namespace = this.$ns;

        // 为组件扩展快捷方法和属性
        this.$state = this.$store.state[namespace]
        this.$dispatch = (action, payload) =>
                            this.$store.dispatch(`${namespace}/${action}`, payload)
        this.$getter = //...
        this.$commit = //...
      }
    }
  });
}
// main.js
Vue.use(injectNamespace);

这样子组件就会默认获取父组件设置的 namespace ,有了这个 mixin 的魔力,我们就可以把 mapXXX 方法的设计的扩展更优雅的一点,因为在 mapXX 方法中可以以 $ns 属性为默认的 namespace 。更清爽一点,保持和官方一致的风格, 这样才把 Vuex 更好的融入我们体系中去。

// code.vue
export default {
  computed: {
    ...mapGetter([
      /* hello namespace store getter */
    ]),
    ...mapState([
      /* hello namespace state property */
    ]),
  },
  methods: {
    ...mapActions([
      /* hello namespace actions method */
    ]),
    ...mapMutations([
      /* hello namespace mutations method */
    ]),
  },
}

3.7 最后一个完整的小栗子

通过下面的小栗子,我们可以看到对于开发者来说,只要按照标准的 Vuex 的开发方式来开发就可以了,好似什么都没有发生过 ^_^。其实在内部我们做了很多的努力,架构设计的目的就是【让简单的事情变得更加简单 , 让复杂的事情变得可能】。

store.js RSC 组件自有 store

export default {
  state() {
    return { mott: 'hello vue' }
  },
  mutations: {
    changeMott(state) {
      state.mott = 'hello vuex'
    },
  },
}

text.vue text 子组件,mapState 自动动态获取命名空间

<template>
  <div @click="changeMott">{{ mott }}</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex-helper-ext'
export default {
  computed: {
    ...mapState(['mott']),
  },
  methods: {
    ...mapMutations(['changeMott']),
  },
}
</script>

code.vue

<tempalte>
  <text></text>
</template>
<script>
import store from './store';
import text from './text';

export default {
  mixins: [StoreMixin('hello', store)],
  components: {
    text
  },
  methods: {
    // ....
  }
}
</script>

六、思考展望

本文写到了这里,渐进尾声,感谢相伴。我们一起回顾了RSC组件化方案,在解决悟空活动中台实际业务场景上走过的路,团队在技术上为努力解决 RSC 组件与组件之间状态管理上的思考。下一篇我们聊聊 RSC 组件与平台之间,与跨沙盒环境的连接上的状态管理,欢迎一起交流讨论。

上一篇:AQS实现原理及成果(有图有真相)


下一篇:深入理解 Promise 五部曲:2. 控制权转换问题