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

简介: 悟空活动中台作为vivo移动互联网首选的在线制作活动平台,有哪些关键能力支撑了它?

本文首发于 vivo互联网技术 微信公众号 
链接:[](https://mp.weixin.qq.com/s/Ka1pjJKuFwuVL8B-t7CwuA) https://mp.weixin.qq.com/s/1DzTYIExVbK0uE_Oc7IHYw[](https://mp.weixin.qq.com/s/1DzTYIExVbK0uE_Oc7IHYw)
作者:悟空中台研发团队

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

一、背景

在上一篇 【悟空活动中台 - 微组件状态管理(上)】中,我们一起回顾了活动页内微组件之间的状态管理和背后的设计思路。从最早的  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)

五、思考展望

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

相关实践学习
如何快速连接云数据库RDS MySQL
本场景介绍如何通过阿里云数据管理服务DMS快速连接云数据库RDS MySQL,然后进行数据表的CRUD操作。
目录
相关文章
|
前端开发 JavaScript Java
Docker打包前端vue代码推送镜像到远程仓库
Docker打包前端vue代码推送镜像到远程仓库 Docker打包前端vue代码推送镜像到远程仓库 业务场景:📝1.将前端代码www包解压后放在本地临时目录,然后创建一个dockerfile📜 2.登陆自己远程仓库📒3.构建镜像🔖4.给镜像打tag📖5.推送镜像到远程仓库🖊️最后总结 业务场景: 需要将本地前端代码推送到远程镜像仓库 📝1.将前端代码www包解压后放在本地临时目录,然后创建一个dockerfile
308 1
|
数据可视化
如何使用四分位距方法来识别数据中的异常值?
如何使用四分位距方法来识别数据中的异常值?
|
10月前
|
人工智能 测试技术 Windows
Windows 竞技场:面向下一代AI Agent的测试集
【10月更文挑战第25天】随着人工智能的发展,大型语言模型(LLMs)在多模态任务中展现出巨大潜力。为解决传统基准测试的局限性,研究人员提出了Windows Agent Arena,一个在真实Windows操作系统中评估AI代理性能的通用环境。该环境包含150多个多样化任务,支持快速并行化评估。研究团队还推出了多模态代理Navi,在Windows领域测试中成功率达到19.5%。尽管存在局限性,Windows Agent Arena仍为AI代理的评估和研究提供了新机遇。
207 3
|
9月前
|
人工智能 监控 数据可视化
绩效考核管理的动态调整与持续优化
本文探讨了绩效考核管理在现代企业管理中的重要性,从核心原则、流程设计、指标设定、沟通反馈及持续优化五个方面进行了详细阐述,并推荐了板栗看板作为提升绩效管理效率的工具。文章强调了公平公正、客观量化、战略导向、持续反馈和结果应用的原则,以及平衡计分卡、KPI、OKR和360度反馈等多种考核方法的应用。板栗看板以其强大的可视化、动态追踪、高效沟通和数据分析功能,助力企业实现高效的绩效管理。
|
11月前
|
机器学习/深度学习 存储 人工智能
用60%成本干80%的事,DeepSeek分享沉淀多年的高性能深度学习架构
【10月更文挑战第2天】近年来,深度学习(DL)与大型语言模型(LLMs)的发展推动了AI的进步,但也带来了计算资源的极大需求。为此,DeepSeek团队提出了Fire-Flyer AI-HPC架构,通过创新的软硬件协同设计,利用10,000个PCIe A100 GPU,实现了高性能且低成本的深度学习训练。相比NVIDIA的DGX-A100,其成本减半,能耗降低40%,并在网络设计、通信优化、并行计算和文件系统等方面进行了全面优化,确保系统的高效与稳定。[论文地址](https://arxiv.org/pdf/2408.14158)
613 4
|
缓存 移动开发 监控
淘宝页面首帧优化的经验和心得
淘宝页面首帧优化的经验和心得
444 9
|
移动开发 前端开发 JavaScript
纯web端实现二维码识别
最近公司的业务场景中有个生成二维码和识别二维码的需求。生成二维码之前有做过,选用的 qrcode.js这个前端库,操作比较简单。这里不再赘述。 刚开始看到二维识别这个需求觉得很简单,以为有相应的前端库直接用就行了。但当真正开始写功能时,发现二维识别会涉及到很多其他的功能。废话不再多说,还是来看看如何实现的吧。
|
Web App开发 监控 前端开发
如何优化淘宝直播 PC 推流端性能(下)
如何优化淘宝直播 PC 推流端性能(下)
407 3
|
小程序 安全 JavaScript
微信小程序授权登录--流程讲解
微信小程序授权登录--流程讲解
1275 0