悟空活動中臺 - 微元件狀態管理(上)

vivo網際網路技術發表於2020-04-20

本文首發於 vivo網際網路技術 微信公眾號 
連結: https://mp.weixin.qq.com/s/Ka1pjJKuFwuVL8B-t7CwuA
作者:悟空中臺研發團隊

一、背景

透過 《揭秘 vivo 如何打造千萬級 DAU 活動中臺 - 啟航篇》的技術揭秘,相信我們對於 RSC 有了更多的瞭解。RSC(remote service component) 即遠端服務化元件,透過熱插拔的機制,視覺化配置,即插即用,快速構建活動頁面,是活動頁面的核心組成單元。

RSC 是一個高效的對活動頁組成單元的抽象設計方案,最大程度上提升了開發效率,降低了開發者的心智負擔。我們希望開發者在開發中遵循【高內聚,弱耦合】的設計理念,只需關心 RSC 元件內部的展示和邏輯的處理。

(圖1)

但在我們實際的業務開發中發現,如上圖1 ,我們每天都要面對大量的相似場景,使用者透過參與【大富翁遊戲】獲取了遊戲的點數,然後【大富翁元件】就需要把遊戲結果點數通知給【集卡元件】,然後【集卡元件】獲取相應卡片,點選【翻卡】,通知【大富翁元件】更新剩餘遊戲次數。在這個活動頁場景中涉及大量的元件之間的協作和資料共享。所以如果把活動看成一個小型的前端系統,RSC 只是構成系統的一個基本要素,還有一個非常重要的要素不能忽略,那就是 RSC 元件之間的連線。當然這種連線還和場景上下文相關聯。所以在對 RSC 元件進行治理的過程中,首先需要解決的就是活動頁內元件之間的資料狀態的管理。

二、結果

透過不斷的深入思考問題,探索現象背後的本質原理,從架構設計層面上很好的解決了元件在不同的場景上下文中的連線(狀態管理)。例如:

  • 在活動頁內,我們解決了 RSC 元件與元件之間的連線。

  • 在平臺內,我們解決了 RSC 元件和平臺之間的連線。業務上 RSC 元件需要感知到平臺的關鍵動作,如活動儲存,編輯器內元件刪除等。

  • 在編輯器內的安全沙盒中,我們解決了元件和跨沙盒的配置皮膚之間的連線。

三、架構演進

今天就重點聊聊,在活動頁內,RSC 元件與元件之間的連線。下一篇我們一起聊聊平臺和沙箱環境下的 RSC 元件連線。

因為我們使用 Vue 作為我們前端的 ui 基礎框架,所以下面技術方案都是基於 Vue 。

四、EventBus 事件匯流排

(圖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 方案的一個補充。

(圖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.vueimport { 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.jsexport 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// 當前元件自有storeexport 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// 生成唯一的 namespaceconst 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.vueexport 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.jsexport default function StoreMixin(ns, store) {
  return beforeCreate() {    // 保證 namespace 唯一性
    const namespace = gen(ns)    // 將重新命名後的 namespace 掛載到當前 vue 物件的$ns 屬性上
    this.$ns = namespace    //...
  }
}


雖然我們可以在元件內透過 this.$ns 獲取元件中的 store 的名稱空間,假想著我們可以:

// code.vueexport 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.vueimport { 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.jsVue.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 元件與平臺之間,與跨沙盒環境的連線上的狀態管理,歡迎一起交流討論。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912579/viewspace-2687117/,如需轉載,請註明出處,否則將追究法律責任。

相關文章