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

avatar
vivo互联网技术 @vivo互联网

本文首发于 vivo互联网技术 微信公众号
链接: mp.weixin.qq.com/s/1DzTYIExV…
作者:悟空中台研发团队

【悟空活动中台】系列往期精彩文章:

一、背景

在上一篇 【悟空活动中台 - 微组件状态管理(上)】中,我们一起回顾了活动页内微组件之间的状态管理和背后的设计思路。从最早的 EventBus 升级迭代到【前置脚本方案】,最终回归到 Vuex 统一状态管理模式,针对平台的特点通过技术创新,使 Vuex 无缝集成到活动页的开发中。本文我们将一起继续探索平台和跨沙箱环境下的微组件状态管理。

二、结果

我们从实际业务场景入手,不断思考业务背后的诉求,在架构上合理设计最后很好的解决了在不同场景上下文中的状态管理。具体如下:

  1. 在平台内,我们解决了微组件和平台之间的连接和状态管理。比如,业务上微组件需要感知到平台的关键动作,如活动保存,编辑器内组件删除等。
  2. 在平台编辑器内的安全沙箱中,我们解决了微组件和跨沙箱的配置面板之间的连接以及状态管理。

三、微组件与平台之间的状态管理

(图1)

1、背景

如图 1 所示,这是我们的平台创建活动页的【编辑页】 ,左侧是可视化【编辑器】区域,右侧是【属性面板】区域可以针对当前选中的组件进行个性化设置。根据我们的业务诉求,组件要能感知到平台的一些核心动作,比如活动的保存,组件的删除等。微组件感知到这些操作后,就会执行相应的自定义业务逻辑,如参数校验,业务检查,错误提示等。

按照平台的开发规范一个标准的组件的结构是这样的:

hello-goku/          # 当前插件所在的目录
├── code.vue         # 当前插件的代码文件 - 在平台会显示在上图的左侧【编辑器区域】
├── prop.vue         # 对code组件配置的模块文件 - 显示在右侧【属性面板】区域 配置组件属性 感知平台的操作
└── setting.json     # 配置文件
├── package.json     # npm模块信息

通过上述背景介绍相信对业务场景有了感性的了解,抽象为技术方案就是怎么解决微组件和平台之间的连接,平台怎么管理这些状态呢?这个问题比较复杂,最终我们通过设计组件和平台之间的一种 hook 机制解决业务上的诉求。

2、难点

我们将面临哪些困难呢?

  1. 按照上述介绍的开发规范,当平台触发保存动作, prop.vue 插件的 hook 需要感知,所以平台需要提前能够搜集 prop.vue 内部的所有 hook 。但是 prop.vue 是异步加载的,只有当对应 code.vue组件在【编辑器中】被选中进行配置时,才会按需动态加载在属性面上。
  2. 当【编辑器】中删除组件时,被删除的组件要能够感知。
  3. 【编辑器】内微组件支持拖动改变渲染顺序,所以平台收集的 hook 要严格绑定渲染顺序,不然就会发生错误的 hook 的调用。

3、hook?

什么是 hook 机制呢?Hook, 就是微组件可以注册一系列的平台的生命周期方法,这些方法会自动被平台收集,在平台的关键节点被调用。

4、使用 hook

在 props.vue组件的 mixins 中, 通过 platformActionHook 这个 mixin 来注册各关键节点的生命周期方法。所有生命周期方法会自动注入 vue 的组件实例对象,可以直接通过 this对象进行访问,这样方便hook中生命周期方法获取vue实例的状态和方法。当 prop 组件被加载的时候, platformActionHook 会调用平台的能力自动的对内部的钩子方法进行自动收集。代码示例如下:

// prop.vue
export default {
  mixins: [
    platformActionHook({
      /*
       * 注册平台对于活动保存之前的hook
       */
      beforeSaveTopicHook() {/* TODO参数检查等业务逻辑处理 */},
      /**
       * 注册平台对于活动保存之后的hook
       */
      afterSaveTopicHook() {},
      /**
       * 注册平台删除当前插件的hook
       */
      beforeDeletePluginHook() {},
      /**
       * 注册平台删除当前插件之后的hook
       */
      afterDeletePluginHook() {}
    })
  ]
};

5、平台一次性收集所有hook

上文也提到,因为 prop.vue 是随着【编辑器】中对应的微组件选中之后动态加载渲染的,但是我们又需要一种机制可以一次性收集到组件中所有的钩子方法。怎么实现呢?【预渲染】,对的,答案就是 【预渲染】。平台预选获取组成活动页的所有插件( umd 模式),通过 new Function 将 umd 组件的字符串变成 Vue 的对象实例,这样就可以过滤出所有注册了 hook 的属性组件,然后在主界面预渲染一次(隐藏渲染),【属性组件】被预渲染时,platformActionHook会自动将hook的生命周期方法归集到平台。

6、预渲染 - 微组件的拿手好戏

通过设计 prerender-prop.vue 预渲染属性组件,借助 vue的强大的动态 component的能力,直达我们的问题痛点。如果我们不需要UI上的错误回溯,我们还可以覆写微组件的render方法,这样就不会生成任何dom节点,以此来减少 dom 的节点和渲染的开销。另外,因为包含 hook 的属性组件会被提前预渲染,当该组件再次在属性面板中渲染的时候,我们要防止 hook 方法数被重复注册,就如,如下代码可以通过 mixin 注入不同的参数,来控制 platformActionHook 在归集 hook 时,需不需要注册。

<template>
  <Component :is="prop" :item="item"></Component>
</template>
<script>
// prerender-prop.vue
export default {
  name: "DynamicProp",
  data() {
    return {
      prop: null
    }
  },
  /**
   * distProp: 是prop.vue打包后的umd文件的内容字符串
   */
  props: ['distProp', 'item', 'renderIndex'],
  watch: {
    distProp: {
      immediate: true,
      deep: true,
      handler(val) {
        // 获取组件umd.js, 预执行出组件对象
        const propComponent = this.preval(val)
        // 获取mixin
        const mixins = propComponent.prop.mixins || []
        // 判断mixin是不是包含hook的mixin
        // 在platformAction中会设置改属性
        const hasHook = mixins.filter(item => item.hook).length
        if (hasHook) {
          // 预渲染
          this.prop = {
            ...propComponent,
              mixins: [{ beforeCreate () { 
                 this.$options.registerHook = true; 
                 this.$options.renderIndex = this.renderIndex 
                 } 
              }, 
                ...mixins
              ],
            /*
            如果不需要UI上显示错误信息可以覆写render函数
            render() { return null }
            */
          }
        }
      }
    }
  },
  methods: {
    preval(js) {
      const mode= {}
      new Function("self", `return ${js}`)(mode)
      return mode.prop
    }
  }
};
</script>

7、platformActionHook如何自动归集

7.1 平台要提供归集能力

通过在平台的顶层 store 注册 hook store 模块。另外,在收集钩子的过程中不能简单的将钩子函数保存在一个队列,需要保持和渲染顺序完全一致。因为删除组件的时候需要根据索引精确查找删除组件的钩子函数。另外,我们编辑器支持拖动组件的位置进行重新排列组件渲染。

怎么样保证 hook 顺序和组件的渲染顺序一致呢?这就是【预渲染组件】中需要将 renderIndex 透传到属性组件,另外我们的数据结构要设计的更加的灵活,以满足顺序,删除,增加等。关键数据结构如下,

// hook-store.js
import Vue from 'vue'

export default {
  state () {
    return {
      // 收集所有的保存活动的钩子的队列
      beforeDeletePluginHook: [],
      // 收集平台对于活动保存之后的hook
      afterSaveTopicHook: [],
      //收集平台删除当前插件的hook
      beforeDeletePluginHook: [],
      // 收集平台删除当前插件之后的hook
      afterDeletePluginHook: [],
      // ...其他生命周期方法
      mapIndex: {
        /* {
         *  // 渲染顺序
         *  [renderIndex]: {
         *    当前渲染顺序下的beforeSaveTopicHook在队列中的索引
         *    hookIndex,
         *    // 调用钩子函数后有无错误返回,便于错误回溯
         *    err
         *  }
         }*/
        beforeDeletePluginHook: {},
        afterSaveTopicHook: {},
        beforeDeletePluginHook: {},
        afterDeletePluginHook: {},
        // ...其他生命周期方法
      }
    }
  },
  mutations: {
    register (state, { type, fn, registerHook, renderIndex }) {
      // 使用nextTick,确保编辑器添加删除组件时重新渲染时,先执行unregister
      Vue.nextTick(() => {
        const list = state[type]
        if (registerHook) {
          list.push(fn)
          const hookIndex = list.indexOf(fn)
          state['mapIndex'][type][renderIndex] = {
            hookIndex,
            err: false
          }
        }
      })
    },
    unregister (state, { type, fn }) {
      const list = state[type]
      const i = list.indexOf(fn)
      if (i > -1) list.splice(i, 1)

      const map = state.mapIndex[type]
      for (let renderIndex in map) {
        if (map.hasOwnProperty(renderIndex)) {
          const val = map[renderIndex]
          if (val.hookIndex === i) {
            delete map[renderIndex]
          }
        }
      }
    }
  }
}

7.2 platformActionHook调用平台能力归集

平台在顶层 store 提供了归集能力,platformActionHook调用平台能力可将关键信息沉淀在平台的store中,平台很容易通过mapState进行获取。

// platform-action-hook.js
export default function platformActionHook(params = {}) {
  let {
    beforeSaveTopicHook,
    afterSaveTopicHook,
    beforeDeletePluginHook,
    afterDeletePluginHook,
    // ... 其他生命周期方法
  } = params

  return {
    hook: true,
    beforeCreate() {
      // 预渲染传入 - dynamic-props.vue
      const renderIndex = this.$options.renderIndex
      // 在平台调度收获时候收集,什么时候取消收集,防止重复收集
      const registerHook = this.$options.registerHook
      if (isDef(beforeTopicSave)) {
        beforeSaveTopicHook = beforeSaveTopicHook.bind(this)
        // 调用平台的store进行钩子函数收集
        store.commit('hook/register', {
          type: 'beforeSaveTopicHook',
          fn: beforeSaveTopicHook,
          registerHook,
          renderIndex
        })

        // 其他钩子方法类似
      }
    }
  }
}

7.3 平台执行hook

平台可以通过mapState,获取hook store中的归集数据,进行相应业务逻辑的处理。

export default {
  computed: {
    ...mapState('hook', [
    'showPropHook',
    'mapIndex',
    'beforeSaveTopicHook',
    'afterSaveTopicHook'
   ])
  },
  methods: {
    saveTopic() {
      // 执行beforeSaveTopic一系列的hook
      save()
      // 执行afterSaveTopic一系列的hook
    }
  }
}

8、总结

有了预渲染,我们就有了完成的hook收集能力。有了上层的数据结构的保证,我们就可以很灵活的扩展我们错误回溯的能力。实时记住上次错误的组件索引当下次这个组件在属性面板中被正常渲染出来就调用内部的钩子函数进行错误回溯。就如上图,可以提示用户上次为什么保存活动不成功。

四、微组件跨沙盒数据通信

(图2)

1、背景

如上图,平台左侧的【编辑器】显示的当前活动的阅览效果,渲染在一个iframe沙箱中,右侧是属性配置面板,和左侧的【编辑器】不在一个窗口环境中。我们的微组件插件是插拔式的,如果【编辑器】面板和【属性面板】在同一个页面,会带来一些问题:

  • 微组件插件的 CSS 样式更改导致整个系统页面的 css 被修改
  • 插件设置跳转 location.href 导致整个系统跳出
  • 编辑器面板与预览面板代码需单独维护,容易出现不一致,非所见即所得的效果设计

2、跨iframe的数据管理?

如上述背景上的设计,我们需要在主系统和编辑器之间进行数据同步,数据流如下图,同步数据的目的:

  • 解决组件的可配置化

  • 通过同步活动页的配置数据自动生成活动的 UI

  • 将活动中数据和 UI 进行解耦

(图3)

3、跨沙盒的组件状态管理

因为有了 iframe 沙箱隔离环境,怎么解决跨沙盒的组件连接呢?是的,标准的方案就是 postMessage 。API如下,

otherWindow.postMessage(message, targetOrigin, [transfer]);

具体参数的详细解释见官方文档

因为我们使用 Vue,所以结合 Vue 中 watch 方法监听数据的变化,这样属性面板的数据变化通过postMessage 传递给编辑器的iframe环境。

watch: {
  //监听需要收集的依赖的变化
  'itemWatch': {
    handler: (val, oldVal) => {
      //发现数据的变化postmessage给子iframe
      const win = document.querySelector('.iframe').contentWindow
      win.postMessage({ action: 'syncItemInfo', params: val })
    },
    deep: true
  }
},

在【编辑器】子 iframe 监听 postMessage 中的事件,一旦接收到数据变化,则进行对应的处理。

export default {
  methods: {
    messageListener(ev) {
      if (ev.source != window.top) {
        return
      }
      let data = ev.data
      if (data.action == 'syncItemInfo') {
        this.num = data.params.numInfo.num
      }
    },
  },
  mounted() {
    window.addEventListener('message', this.messageListener, false)
  }
}

4、缺点

通过postMessage可以实决跨沙盒的组件状态管理,但也还是有一些缺点。

  1. 一定要等 A 页面嵌入的 B 页面加载完成之后,再进行 postMessage 跨域通信。
  2. 数据的传输是双向的,容易出现不一致的问题,很难定位产生的原因,数据的合并比较痛苦。

5、勇于探索,Vuex的跨iframe的数据管理

我们希望整体的组件状态管理方式回归在一种方式上,既然我们都使用了 Vuex, 所以我们希望探索以vuex为核心的跨iframe的数据管理方案。假如代码如下,父窗口暴露store对象给子iframe访问,在子窗口中获取数据,能保持数据的响应式嘛?

// code.vue
// 运行在一个iframe中
<template>
  <div>{{title}}</div>
</template>
<script>
export default {
   computed: {
     title() {
       // __store__子页面获取父页面的store对象
       // 能不能保证反应式 ?
       return __store__.state.title
     }
   }
}
</script>

6、回归原点

通过测试发现,上述代码并不能保持数据的响应式。那为什么呢?为什么 iframe 会中断 vuex 的响应式数据呢?这个时候,我们就需要回归原点,去理解 Vue 响应式数据的原理。如下图,


(图4)

在 Vue 组件初始化时,主要初始化生命周期,状态等,在初始化状态中,无论是 data 还是 props , Vue 会通过 observe 和 defineReactive 等一系列的操作把 data 和 props 的每个属性变成响应式数据。其中,defineReactive 函数是对数据进行双向绑定的核心函数。

defineReactive 函数内部先实例化一个 Dep 对象, Dep 是连接数据与 Watcher 的桥梁,同时也作为收集和存储 Watcher 的容器。随后,通过 Object.defineProperty 改写数据字段的 get 函数和 set 函数。当我们访问 vue data 数据时候,会触发 get 函数,get 函数内部和 set 函数内部都引用了 defineReactive 中 Dep 对象。

7、实践检验真理

(图5)

通过 Debug 发现,如上图,确实当 Vue 的 data 发生变化触发了 set 操作,dep 就会寻找 watcher,触发 watcher 的执行,然后更新 UI。因为 iframe 的关系父窗口的Dep.target获取值为null,导致父的dep对象收集不到子iframe中的watcher,阻断了响应式,关键代码如下图:

(图6)

8、守正出奇

我们能不能将中断的父子窗口的依赖收集,连接起来?

神器Vue.observable来帮忙

通过在子 iframe 中使用 Vue.observable 添加对父 store 的 state的包装,可以实现在子 iframe 保留一份响应式 Dep 的收集,这样父子窗口就呼应上了。不过因为 Vue 对数组数据收集依赖的方式不同,针对数组的改变需要返回一个新的数组对象,通过这个思路可以封装一组 vuex风格的api,这样整个数据管理都在vuex的模式下。

8.1 抽象parent-store-mixin

通过 parent-store-mixin 将父窗口的store挂载在子 iframe窗口内vue对象的$pstore属性上,方便 在vue组件内获取父窗口的store。

// parent-store-mixin.js
// 使用mixin的方式构造不同实例对象的store数据关联
module.exports = function ParentMixin(store) {
  return function(Vue) {
    Vue.mixin({
      beforeCreate: function ParerntMixin() {
        Vue.observable(store.state)
        this.$pstore = store
      }
    })
  }
}

8.2 封装工具方法

封装 vuex风格的工具方法,内部获取 this.$pstore

import {mapPrarentMutations, mapParentState} from 'vuex-parent-helper'

export default {
  computed:{
    ...mapParentState(['foo'])
    // ...mapParentGetters...
  },
  methods: {
    ...mapPrarentMutations(['fooChange'])
    // ...mapParentActions...
  }
}

9、完整的小栗子

<template>
  <div class="hello">
    <p>{{$pstore.state.top.test.hh}}</p>
    <h1>{{ foo }}</h1>
    <div>test:{{ testGetter }}</div>
    <h3 @click="fooChange(Date.now())">update</h3>
    <h3 @click="fooAction(Date.now())">action</h3>
  </div>
</template>

<script>
import Vue from 'vue'
import {
  mapParentMutations,
  mapParentActions,
  mapParentGetters,
  mapParentState,
  parentStoreMixin
} from 'vuex-parent-helper'

Vue.use(parentStoreMixin(window.top._store_))

export default {
  name: 'HelloWorld',
  computed: {
    ...mapParentState('top', ['foo']),
    ...mapParentGetters('top', ['testGetter'])
  },
  methods: {
    ...mapParentMutations('top', { fooChange: 'foo' }),
    ...mapParentActions('top', { fooAction: 'fooTest' })
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

父页面 store

import Vuex from 'vuex'
import Vue from 'vue'

Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    top: {
      namespaced: true,
      state() {
        return {
          foo: 'foo',
          test: {
            hh: ''
          }
        }
      },
      getters: {
        testGetter(state) {
          return state.test.hh || 'default'
        }
      },
      mutations: {
        foo(state, txt) {
          state.foo = txt
        },
        test(state) {
          Vue.set(state.test, 'hh', Date.now())
        }
      },
      actions: {
        fooTest(context) {
          context.commit('test')
        }
      }
    }
  }
})
todomvc小试牛刀

(图7)

五、思考展望

本文写到了这里,我们一起回溯了团队在技术上为努力解决微组件,与平台之间,跨沙盒环境下的思考和状态管理的探索。同时作为前端工程师,我相信我们的日常都很类似,都在思考,学习,实践,锤炼我们的技术和视野。那什么是技术呢?或许正如《技术的本质》中所诉,【从本质上看, 技术是被捕获并加以利用的现象的集合,或者说,技术是对现象有目的的编程】。后续还有一系列的主题文章分享给大家,欢迎交流讨论。

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

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