如何構建一個不到100行的小程式端mini版本redux

YaHuiLiang(Ryou)發表於2018-10-13

在剛剛加入這家公司的時候,技術Leader就和我說過一件事情,希望能夠落地前端的自動化,希望我能夠出一個可行方案。而此時,在公司前端團隊還非常年輕,但是業務的發展導致團隊規模擴大了一倍多。加上toC業務千變萬化,導致各種bug滿天飛。前端自動化測試的成本是非常高昂的,在長期加班感需求的情況下還要去顧及自動化測試的指令碼開發幾乎是很難實施的。如何去尋找一個低成本可行的自動化測試技術方案就是我做這個miniredux的初衷。之所以叫mini版。一方面是自己能力的不足還無法實現一套完整的小程式版本redux的技術實現,另一方面以漸進迭代的原則,現滿足基本需要前提下,根據後續使用情況,逐步優化。也有可能不久後dan大神自己出了一個也說不定。

例項以及原始碼

例項和原始碼

1. 網際網路 toC 應用研發之痛

缺人,缺人,我們缺少高質量前端,這可能是絕大多數技術管理者的訴求。面對系統中漫天的bug,蟑螂一樣殺不盡的低階錯誤,是否總是那麼的無能為力。雖然我們有很多測試工具以及自動化測試的庫,但是我們依然會困惑於為什麼做前端自動化測試實施起來這麼難?

在十一期間,我開發了一個mini版本的Redux用於解決這個問題,並且在這周團隊內部討論後方案是可行的,也開始準備運用到專案中去驗證。因此也將思路分享給大家

2. 前端應用自動化實施目前的困惑:

  • 前端的Javacript語言是一種若型別指令碼語言,而且很多錯誤是執行時才會被發現。因此這種特性也就導致了前端程式碼質量難以保證。因此有的團隊會毅然決然的選擇了TSTS確實極大的改善了這一狀況。
  • 由於現代前端對於程式碼分層掌握的不是很好,尤其以Vue專案最為嚴重。匯出都是耦合在一起的UI互動邏輯和業務邏輯。也導致了前端目前的一個困境,耦合嚴重。耦合就意味著每個地方的影響範圍都特別大。經常會導致,明明同樣的業務,這裡改了,另一個地方不該改的地方也受影響了。(這一點我們不得不承認Angular為什麼會被稱為企業級前端框架,它自身已經提供了自己的模組劃分標準和規則,React也有自己的Flux架構方法去指導大家,而Vue在這塊的缺失也使它在日漸複雜的系統中產生混亂,而尤大也說了Vuex並不適用於大型應用。也側面反映了這一點)
  • 絕大多數年輕前端對於業務模型的設計能力不足,也導致目前專案程式碼中狀態的混亂。雖然很多優秀的前端工程師能把mvvmvdom,雙向繫結的實現,單項資料流講的頭頭是道,甚至是自己都可以當場給你寫一套實現。但是在實踐中,因為缺乏對業務資料建模的理解。經常會發生混亂。這也是很多團隊實踐react-redux遇到的最大困惑
  • 一旦UI邏輯和業務邏輯耦合,那麼我們只能通過虛擬頁面DOM來進行測試,但是對於toC業務基本上每兩三個月就大變臉的UI來說,這種自動化測試方式的開發成本是非常巨大的。但是對業務邏輯的變化其實是很小的。

因此針對以上幾種問題,尋找一個方法將業務與檢視層解偶,只對業務層單獨進行測試,這樣既可以大大降低toC應用自動化測試開發成本,又可以極大的提高專案中業務的準確性。至於檢視層,因為基於MVVM的前端應用大多都是資料驅動的系統,因此只要業務資料模型的正確就可以極大的保證系統的健壯性。

3. 如何解偶檢視層與業務邏輯層

其實模組拆分一直都是一個軟體開發領域的難題,如何去解偶各個模組,雖然我們有各種方法論去指導我們去實施,但是方法論畢竟只是一個理論。真正做起來的時候會受各種因素影響,而並不是每個團隊都有具備這種能力的牛人。

而在前端領域,MVC過於複雜,對於前端來說太重,而長期關注與檢視的呈現,大部分人是缺乏這方面設計能力的。而MVVM在這方面給前端提供了一個方向(Flux全文翻譯):

Flux原文:

Flux eschews MVC in favor of a unidirectional data flow. When a user interacts with a React view, the view propagates an action through a central dispatcher, to the various stores that hold the application's data and business logic, which updates all of the views that are affected. This works especially well with React's declarative programming style, which allows the store to send updates without specifying how to transition views between states.

譯文:

Flux 避開了 MVC,採取了單向資料流,當使用者與 React 檢視進行互動的時候,檢視通過 dispatcher 方法傳遞一個 action 物件到儲存資料和業務邏輯的各個儲存物件區 store 中。這些儲存區的資料變化會影響所有檢視,並導致檢視發生更新。這與 React 的程式設計風格有關,該風格允許通過資料的變化來改變檢視,而不需要指定如何通過狀態切換檢視。

通過Flux的講解,我們可以清楚的意識到檢視中,在對使用者各種行為的響應中,通過派發器(dispatch)將新的業務資料以動作(action)為載體灌入用於處理和儲存業務資料模型(store)中。如下圖:

如何構建一個不到100行的小程式端mini版本redux

而在基於Flux的架構方法論基礎上衍生出來的Redux就是其中被人廣泛熟知的,而Vuex也隨著Vue的火爆而被人廣泛認識。

但是ReduxVuex雖然在開發上起著同樣的作用,但是在本質上卻存在著很大的不同,這也是為什麼Vuex不適用於大型前端應用:

  • Redux是框架無關的,Vuex需要依託於Vue的響應式屬性
  • Redux是純原生JS,與檢視無關,這也就意味著它可以幫助我們方便的剝離業務到Redux中。從而可以方便的複用到任何前端技術中去。而Vuex很難做到這一點。
  • Redux強調reduce必須是純函式,純函式意味著相同的引數會導致相同的結果,也就是結果是可以預知的,從而具有非常好的可測性,這也就滿足了我們對業務進行自動化測試的需求。而Vuex是依託於修改引數引用(mutations)的方式,並且actions是支援非同步導致了返回值的不確定性。

結合以上原因,一個小程式版本的Redux才是我們需要的。

4. 如何構建一個小程式版本的Redux

我相信大部分人都閱讀過Redux原始碼,當然我也寫過一篇關於Redux原始碼的文章,我相信原理大家都懂,但是如何去實現一個小程式版本的Redux的難點是我們如何實現一個類似於react-redux的東西將Redux結合到小程式裡面來。

我們面臨以下幾個技術問題:

  • store存在哪?
  • 如何暴露介面?
  • 如何將store中的資料與Page中的資料進行響應式?

其實就是做一個釋出訂閱模式的實現,但是我們要保證我們store內部的資料不能被隨意修改,這樣才能保證我們的業務穩定性。

我想,一提到小程式內部資料共享,大家肯定會想到globalData。但是globalData是依託於全域性app物件,而全域性變數的影響大家是心知肚明的,不一定哪個新手給你搞壞了也是說不定的。所以也就導致了狀態變化的不可跟蹤。

那麼如何避免使用全域性變數又能解決資料儲存的問題呢?答案是 ---- 沙盒模式

沙盒模式,是JS非常普遍的一個設計模式,它通過閉包的原理將資料維持在一個函式作用於中,而通過返回值內的函式引用這個函式包體內的變數的方式,形成閉包,而只有通過該函式的返回函式才能訪問和修改該閉包內的資料,從而起來了資料保護的作用。

function initMpState () { // mp-redux初始化函式,在這裡形成一個獨立作用域
  const reducers = {};  // 該函式作用域內的資料
  const finalState = {};  // 該函式作用域內的資料 
  const listeners = [];  // 該函式作用域內的資料
  let injectMethod = null;  // 該函式作用域內的數

  function getStore() { // 用於訪問沙盒內資料的介面
    return finalState;
  }

  function createStore(modules, injectFunc) { // 用於初始化沙盒內資料的介面
    ...
  }

  function dispatch(action) { // 用於操作沙盒內資料的介面
    ...
  }

  function connect(mapStoreToState, component) { // 用於關聯小程式Page物件的高階API
    ...
  }

  return {
    createStore,
    dispatch,
    connect,
    getStore
  }
}

module.exports = initMpState();
複製程式碼

通過沙盒模式,我們很好的保護了我們的資料,並且提供了有限的操作手段,安全又可靠的儲存了我們的業務資料模型中的資料。

5. 如何在小程式初始化我們的store

既然需要暴露介面,又要保持這個函式內的閉包。好複雜呀。但是commonjs在這方面起到了很好的幫助:

當我們require一個模組的時候,commonjs會維持這個模組在一個獨立的作用域中。並且一直存在。典型的應用場景就是Nodejs

所以通過commonjs,我們使用module.exports將我們的mp-redux初始化函式返回的api集合暴露給呼叫方:

  // mp-redux/index.js
  function initMpState() {
    ...
  }
  module.exports = initMpState();
複製程式碼

這樣我們就可以在任何地方視無忌憚的搞事情了(使用api操作store資料).

6. 初始化業務模型

store中的資料是根據業務而來,如何儲存業務模型將是我們的重點。而這些業務模型又會帶有很多業務邏輯資料處理。同時,我們還要保證業務的可測性。

因此reduxreduce方式是我們需求的絕佳選擇,因此每一個model必須是一個純函式,它需要每次操作後都要返回一個純物件,也就是業務資料模型。

  /* 
  modules,這裡參考redux,我們可以拆分很多業務模組,每個業務模組會有自己的業務模型,因此這裡的modules是一個物件,而key就是業務模組的名字value就是處理業務模型的純函式。
  
  這裡提供一injectFunc 主要是因為小程式在系統載入後就初始化,
  因此我們需要劫持特定api來在這個api中同步store中資料到當前顯示的頁面中。為什麼不寫死成小程式的onShow?主要是以後考慮百度小程式,支付寶小程式。這樣更靈活。
  */
  function createStore(modules, injectFunc) { 
     if (injectFunc && typeof injectFunc === 'string') {
      injectMethod = injectFunc;
    }
    // 我們將使用者自己定義的業務模型(model)儲存到沙盒內的reducers中
    if (modules && typeof modules === 'object') {
      const keys = Object.keys(modules);
      const len = keys.length;

      for (let i = 0; i < len; i++) {
        const key = keys[i];
        if (modules.hasOwnProperty(key) && typeof modules[key] === 'function') {
          reducers[key] = modules[key];
        }
      }
    }
    // 對store進行初始化
    dispatch({type: '@MPSTATE/INIT'});
  }
複製程式碼

7. 如何關聯store的資料到小程式頁面中,並且進行響應式處理?

小程式會自動訂閱Page引數中的data物件,因此我們只要在提供一個包裹函式將我們需要訂閱的store中的資料模型反映到小程式Page函式構建需要的引數中即可。並且注入dispatch方法,以及資料對映函式mapStoreToState

因為每個頁面只訂閱自己關心的業務資料狀態 ,因此我們不能把整個store都扔給人家。所以我們需要通過mapStoreToState來僅僅將使用者需要的業務資料狀態注入到頁面中去。

  /* 
   *mapStoreToState,用於使用者自己將自己關注的業務資料狀態訂閱到自己的頁面中
   */
  function connect(mapStoreToState, component) {
    if (!component || typeof component !== 'object') {
      throw new Error('mpState[connect]: Component must be a Object!');
    }

    if (!mapStoreToState || typeof mapStoreToState !== 'function') {
      throw new Error('mpState[connect]: mapStoreToState must be a Function!');
    }
    // 我們需要將redux相關的函式和狀態注入到使用者的page定義中
    const newComponent = { ...component };
    // 拿到使用者自己在頁面定義的data,我們需要保留原來的狀態
    const data = component.data || {};
    // 獲取使用者訂閱的store中的狀態
    const extraData = mapStoreToState(finalState);

    if (!extraData || typeof extraData !== 'object') {
      throw new Error('mpState[connect]: mapStoreToState must return a Object!');
    }
    // 合併使用者自己頁面中的狀態,和通過connect注入的store中的狀態,這裡我的實現有點不好
    let newData = null;

    if (typeof data === 'function') {
      newData = {
        ...data(),
        ...extraData
      }
    } else {
      newData = {
        ...data,
        ...extraData
      }
    }
    // 注入到Page物件中
    if (newData) {
      newComponent.data = newData;
    }
    // 獲取需要劫持的生命週期鉤子,因為每個頁面不一定都劫持同一個生命週期,因此提供了一個各個頁面可以自定義修改劫持鉤子的方法
    const injectFunc = component.getInjectMethod;

    const methods = component.methods || {};

    const newLiftMethod = injectFunc && injectFunc() || injectMethod;
    const oldLiftMethod = component[newLiftMethod];
    // 注入dispatch api
    methods.dispatch = dispatch;

    newComponent.methods = methods;
    newComponent.dispatch = dispatch;
    newComponent.mapStoreToState = mapStoreToState;
    //生命週期鉤子劫持
    if (newLiftMethod) {
      newComponent[newLiftMethod] = function() {
        if (this) {
          // 在劫持的鉤子中同步store的資料到頁面
          this.dispatch({});
          oldLiftMethod && oldLiftMethod.call(this, arguments);
        }
      }
    }
    // 返回新的Page物件
    return newComponent;
  }
複製程式碼
  // 使用connect來注入需要訂閱的狀態,並且mp-redux會在頁面物件中自動注入dispatch方法 
  const mpState = require('./../../mp-redux/index.js');
  const util = require('../../utils/util.js');
  const logActions = require('./../../action/logs.js');

  Page(mpState.connect((state) => {
    return {
      userInfo: state.userInfo.userInfo,
      logs: state.logs.logs
    }
  },
  { // 在這裡所有的業務資料都儲存在store中,所以頁面如果只有業務資料的話,是不需要data屬性的。
    clearLogs() {
      this.dispatch({ // 通過dispatch方法來發出action,從而更新store中的資料
        type: logActions.clearLogs
      })
    }
  }))
複製程式碼

8. 如何派發更新store中的資料,並且反應到小程式的頁面中來?

因為小程式的狀態更新需要通過setData這個api,因此,我們就需要在dispatch中通過該api來同步store中的資料狀態

  /*
   * 這裡一定要注意,action是一個原生JS物件,而不是函式,Redux的非同步是通過redux-thunk來實現的,但是我的訴求是需要讓我們應用中的業務邏輯更加容易被測試,因此也就沒有去提供支援,其實實現起來也很簡單。可以參考我做的[vue-with-redux原始碼](https://github.com/ryouaki/vue-with-redux/blob/master/src/index.js)
  */
  function dispatch(action) {
    // debugger
    const keys = Object.keys(reducers);
    const len = keys.length;
    // 這個迴圈用於遍歷model來重新計算出新的store
    for (let i = 0; i < len; i++) {
      const key = keys[i];
      const currentReduce = reducers[key];
      const currentState = finalState[key];

      const newState = currentReduce(currentState, action);

      finalState[key] = newState;
    }

    if (this) {
      // 這裡是根據元件內部的訂閱規則來將新的資料模型通過setData注入到頁面中
      const componentState = this.mapStoreToState(finalState) || {};
    // 這裡提供了對react和vue的支援,因此也就導致程式碼多了幾行,還在測試中。
      if (this.setData) { // 小程式
        this.setData({ ...componentState })
      } else if (this.setState) { // react什麼的吧
        this.setState({ ...componentState })
      } else { // VUE
        const propKeys = Object.keys(componentState);
        for ( let i = 0; i < propKeys.length; i++) {
          this[propKeys[i]] = componentState[propKeys[i]];
        }
      }
    }
  }
複製程式碼

其實通過上面的程式碼我們基本上就完成了一個簡單的釋出訂閱了。

9. actionmodel(我覺得modelreduce更容易理解,所以我叫model,哈哈)

不過這裡沒什麼好說的,都和redux一樣

const actions = require('./../action/logs.js');

const initState = {
  logs: []
}

module.exports = function (state = initState, action = {}) {
  const newState = { ...state };
  switch (action.type) {
    case actions.addLogs:
      const now = new Date();
      newState.logs.push({
        time: now.getHours() + ":" + now.getMinutes() + ":" + now.getSeconds(),
        value: action.data
      });
      return newState;
    case actions.clearLogs:
      newState.logs = [];
      return newState;
    default:
      return newState;
  }
}
複製程式碼

最後

通過這個mp-redux,實現了業務邏輯,資料與檢視的分離,而業務邏輯與資料都儲存在純js程式碼中。方便多平臺移植,而要做的只是做一個平臺資料響應式的適配。

更大的好處是解決了檢視與業務層耦合的痛點,並且將資料業務剝離到純函式中,大大提高了業務程式碼的可測是性。

由於提供了業務資料的獨立測試途徑,也降低了整體的測試成本。

另外

給團隊招人,途家網,地點國家會議中心,目前前端團隊非常年輕,我們有很多需求是沒有既有庫能夠滿足的,所以我們有很多技術創新的機會。愛折騰的就聯絡我吧。

另外我在搞前端微服務的實踐,而且已經成功,有興趣的一定要聯絡我呀。

再者,我們技術要求不高,我不在乎什麼Vue原始碼原理研究多深,也不在乎演算法多麼牛,JS用多溜,我期望那些熱愛技術,喜歡專研技術,喜歡通過團隊業務開發中痛點挖掘出技術創新點,提高團隊整體生產效率的人加入我們 (這是我的觀點,不代表老大是否贊同(-_-!))。

相關文章