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

本文首发于 vivo互联网技术 微信公众号 
连接: https://mp.weixin.qq.com/s/Ka1pjJKuFwuVL8B-t7CwuA
做者:悟空中台研发团队

1、背景

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

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

(图1)vue

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

2、结果

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

  • 在活动页内,咱们解决了 RSC 组件与组件之间的链接。
  • 在平台内,咱们解决了 RSC 组件和平台之间的链接。业务上 RSC 组件须要感知到平台的关键动做,如活动保存,编辑器内组件删除等。
  • 在编辑器内的安全沙盒中,咱们解决了组件和跨沙盒的配置面板之间的链接。

3、架构演进

今天就重点聊聊,在活动页内,RSC 组件与组件之间的链接。下一篇咱们一块儿聊聊平台和沙箱环境下的 RSC 组件链接。vue-router

由于咱们使用 Vue 做为咱们前端的 ui 基础框架,因此下面技术方案都是基于 Vue 。vuex

4、EventBus 事件总线

(图2)编程

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

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)

一、架构上的优势

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

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

二、实践中的痛点

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

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

三、反思改进

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

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

(图3)

四、总结

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

5、Vuex 状态管理

一、背景

Vuex  是什么?

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

Vuex 有哪些特色?

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

二、在平台引入对 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,
})

三、使用 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>

6、思考展望

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

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:Labs2020 联系。

相关文章
相关标签/搜索