狀態管理框架開發不徹底指南

原文連接前端

做者:非凡vue

框架傳送門:github.com/kujiale/tac…react

導讀

以前在公司自研了一款狀態管理框架,多多少少積累了一些寫框架的經驗,在這裏分享給你們,題目想了挺久,由於文章篇幅比較長,但又沒有一本書那麼長、那麼體系化,因此就起名叫不徹底指南吧,一點拙見還請多指教。git

文章比較長,因此列個大綱,讀者能夠挑選本身感興趣的章節以節省閱讀時間。若是看完大綱你認爲對你如今這個階段應該沒有什麼幫助,那麼也是一件好事,這一節就是爲了幫助你節省下這個時間去作其餘更有意義的事情。github

前世此生

一個前端應用一般由許多不一樣的模塊、組件經過不一樣的組合方式拼裝、渲染出來,在 react 應用生態中,一個 react 組件也是業務邏輯上最小的「內聚單元」,每一個 react 組件均可以有本身內部的狀態和生命週期,這樣的設計有助於解耦、由全局視角下降爲局部視角,更利於應用的維護。web

複雜的業務永遠都是複雜的,模塊化、組件化這些架構設計方法自己並不會下降一個系統的業務複雜度,但它的好處是能夠下降系統的熵、系統維護成本、提高系統的擴展能力,將「關注點」降到最低。vuex

這裏我就不擴展太多,相信你們在工做中都深有體會,好比 A 同窗維護 B 同窗寫的一個功能,A 同窗只是想加一個按鈕的小功能,卻不得不把 B 同窗寫的一整塊功能都看懂,這樣的設計顯然有些糟糕,浪費了 A 同窗許多的時間,若是 A 同窗只須要看懂某個組件或小模塊的代碼,那維護成本就很低了。typescript

說那麼多,跟狀態管理有啥關係呢?咱們知道任何一種設計都不是萬金油,拆分帶來了好處,天然也會帶來問題,我該如何跨模塊、跨組件通訊呢?我該如何在組件銷燬後,依然保持組件「操做」過的狀態呢?編程

因此光有組件內部的狀態管理是不夠的,應用級別的全局狀態管理在這種狀況下就頗有必要了,全局只是更高層次的抽象,爲了更方便通訊,倒不是簡單得把全部東西都扔到全局,即便是全局狀態,咱們依然須要有模塊、有規則得去管理起來,這就須要框架、工具去解決這類問題。redux

核心問題

狀態管理框架的核心其實就是發佈訂閱模式,無論是 redux、mobx 仍是 rxjs,萬變不離其宗,你就儘管花裏胡哨,各類變形,但總得解決根本問題,再去考慮別的能力。以下就是一個最簡單的發佈訂閱模式實現:

let Emitter = function() {
    this._listeners = {}
}
Emitter.prototype.on = function(eventName, callback) {
    let listeners = this._listeners[eventName] || []
    listeners.push(callback)
    this._listeners[eventName] = listeners
}
Emitter.prototype.emit = function(eventName) {
    let args = Array.prototype.slice.apply(arguments).slice(1)
    let listeners = this._listeners[eventName]
    let self = this
    if (!Array.isArray(listeners)) return
    listeners.forEach(function(callback) {
        try {
            callback.apply(this, args)
        } catch (e) {
            console.error(e)
        }
    })
}
複製代碼

因此你看,有的一次性的內部小項目、組件庫,其實根本都不須要用狀態管理框架,20 行代碼就能夠知足需求,經過 $emitter.emit('event-name') 發佈一個事件,$emitter.on('event-name', () => {}) 來接收事件,或者用 react 提供的 context、Provider、Consumer 等方案。

站在巨人肩膀上的時代,知足需求每每是容易的事情,這是從 0 -> 1,可是如何更好的知足需求並非一件容易的事情,這是從 1 -> 100,在實際業務開發中,簡單的發佈訂閱模式會讓代碼變得難以維護,容易寫出過程式代碼,因此就須要框架來進一步的封裝。

核心進階

知道了什麼是核心,咱們就比較容易想出如何將其應用到 react 項目中了。

訂閱者

咱們首先得有一個訂閱者,銷燬組件時還得把訂閱者卸載,以下僞代碼所示,咱們手動在組件中綁定訂閱者:

class Example extends React.Component<Props, State> {
  constructor(props) {
    super(props);
  }

  refreshView() {
    // 從新渲染 view,好比 this.forceUpdate() 或 this.setState()
  }

  componentDidMount() {
    this.unsubscribeHandler = store.subscribe(() => {
      this.refreshView();
    });
  }

  componentWillUnmount() {
    if (this.unsubscribeHandler) {
      this.unsubscribeHandler();
    }
  }

  render() {
    return (
      <div>balabala...</div>
    )
  }
}
複製代碼

或者像 mobx 經過 autoRun() 函數來實現訂閱,依賴到的屬性變更都將觸發 autoRun 的從新執行,這樣就能夠把從新渲染 view 的邏輯寫進去了。

亦或是 redux 中的 connect(a, b)(view) 函數來裝飾原始 view,隱藏了綁定訂閱者和觸發從新渲染的重複性代碼。

發佈者

訂閱者有了,咱們還得有個發佈者,能夠是任何形式,總之能讓訂閱者接收到就行,好比像 mobx 直接經過屬性賦值發佈消息(經過 Object.definePropertyProxy 實現),以下僞代碼(只是爲了容易理解,實際並非這樣):

@action
doSomething() {
  this.loading = true;
}
複製代碼
Object.defineProperty(this, 'loading', {
  enumerable: true,
  configurable: true,
  get: function () {
    // do something...
  },
  set: function (newVal) {
    store.dispatch(newVal);
  },
});
複製代碼

或者就是 redux 中直接調用 store.disaptch() 告訴訂閱者,形式不重要。

好,其實到這裏狀態管理框架的核心就完成了,雖然有點簡陋。但若是這篇文章就這麼結束了,對於大部分童鞋們並起不到什麼幫助,由於光了解這些皮毛,離開發一個完整框架還有些距離。因此接下來我會介紹一些更加細節的東西。

深刻細節

效率抉擇

前一節「核心進階」的例子我相信你們都看懂了,任何一個 dispatch 都會觸發全部 subscribe 的 listener,具體能夠去看一下 redux 是怎麼實現的,代碼不多,這裏就不擴展了,源碼傳送門:github.com/reduxjs/red…

redux 在觸發更新的做法上用了一層循環去遍歷全部的 listener:

const listeners = (currentListeners = nextListeners)
for (let i = 0; i < listeners.length; i++) {
  const listener = listeners[i]
  listener()
}
複製代碼

因此它的時間複雜度是 O(n),任何一次 dispatch 都會觸發全部 connect 的組件的訂閱者,不過 react-redux 在組件渲染以前仍是作了一層淺比較來優化性能,因此即便觸發了訂閱者,訂閱者觸發了視圖重繪,若是視圖的狀態並無發生改變,最終的重繪操做仍是會被攔截掉:

class Example extends React.Component<Props, State> {
  constructor(props) {
    super(props);
  }

  refreshView() {
  }
  // 若是先後狀態沒有發生變化,則阻止重繪
  shouldComponentUpdate() {
    return !shallowEqual(previousState, nextState);
  }

  componentDidMount() {
    this.unsubscribeHandler = store.subscribe(() => {
      this.refreshView();
    });
  }

  componentWillUnmount() {
    if (this.unsubscribeHandler) {
      this.unsubscribeHandler();
    }
  }

  render() {
    return (
      <div>balabala...</div>
    )
  }
}
複製代碼

淺比較的時間複雜度也爲 O(n),並且不受對象嵌套層級的影響,爲什麼不使用深比較呢?答案很明顯了,在嵌套層級特別深的狀況下,深比較的時間開銷是巨大的,比較數組也得一個一個遍歷過去,但淺比較畢竟不能精確比較,咱們怎麼才能在性能和精確中進行取捨?

其實過於精確的比較,在達到必定程度時,浪費的時間還不如直接從新生成虛擬 dom 再去 diff 一次,因此假如咱們能遵照 react 修改狀態始終拷貝一個新對象的規範,咱們就能夠直接比較對象的引用是否相同,這樣對於某個狀態屬性的比較,就是 O(1) 的時間複雜度,也算是當前這個問題的完美解決方案了。

mobx 在效率上算是另外一種流派「依賴收集」的實現,什麼是依賴收集呢?其實就是把依賴的映射關係在初始化或依賴發生改變時提早進行收集,這樣在更新時咱們就不用遍歷訂閱者了,能夠經過映射關係精肯定位須要觸發的訂閱者,舉個簡單的栗子:

class List extends React.Component {
  render() {
    return (
      <div> <span>{$tag.currentTagId}</span> <Pagination current={$column.current} defaultPageSize={$column.pageSize} totalPage={$column.totalPage} /> </div> ); } } 複製代碼

上面這個組件依賴了 $column 實例上的三個屬性,分別是 current, pageSize, totalPage,還依賴了 $tag 實例上的一個屬性 currentTagId, OK,那咱們就能夠把這個依賴關係存下來了,如何存呢?繼續搬出以前那個例子:

Object.defineProperty($column, 'current', {
  enumerable: true,
  configurable: true,
  get: function () {
    collector.collect(namespace, propertyName);
  },
  set: function (newVal) {
    store.dispatch(newVal);
  },
});
複製代碼

在訪問該屬性的時候,會觸發 getter 鉤子,這樣依賴就能夠收集到了,但咱們不可能每次訪問屬性都要收集吧?因此什麼時候收集依賴呢?並且咱們應該創建怎樣的映射關係?

思考一下比較容易得出,咱們應該創建 view 和 (命名空間/屬性)之間的關係,這樣更新了某個命名空間下的某個屬性,咱們就知道須要去從新渲染哪些 view 了,映射關係以下圖所示:

dependency

一個簡單的收集器實現:

class Collector {
  public dependencyMap = {};
  private isCollecting = false;
  private tempComponentInstanceId = null;
  // 須要一個攔截器,攔截什麼時候開始收集
  start(id) {
    this.isCollecting = true;
    this.tempComponentInstanceId = id;
  }

  collect(namespace, propertyName) {
    const uid = `${namespace}/${propertyName}`;
    if (this.isCollecting) {
      if (!this.dependencyMap[uid]) {
        this.dependencyMap[uid] = [];
      }
      if (this.dependencyMap[uid].indexOf(this.tempComponentInstanceId) > -1) {
        return;
      }
      this.dependencyMap[uid].push(this.tempComponentInstanceId);
    }
  }
  // 須要一個攔截器,攔截什麼時候結束收集
  end() {
    this.isCollecting = false;
  }
}

export default new Collector();
複製代碼

注意到上面收集器代碼的攔截器了嗎?這就解決了每次訪問屬性都要收集的問題,咱們能夠本身來控制是否須要收集依賴。接下來咱們就須要在 view 端來採集 viewId,並真正開始收集依賴,咱們能夠利用高階組件/裝飾器的做用來隱藏這些用戶無需關心的基礎性代碼:

let countId = 0;

export function stick() {
  return (Target: React.ComponentClass): React.ComponentClass => {
    const displayName: string = Target.displayName || Target.name || 'TACKY_component';
    const target = Target.prototype || Target;
    const baseRender = target.render;

    target.render = function () {
      const id = this.props['@@TACKY__componentInstanceUid'];
      collector.start(id);
      const result = baseRender.call(this);
      collector.end();
      return result;
    }

    return class extends React.Component<Props, State> {
      unsubscribeHandler?: () => void;
      componentInstanceUid: string = `@@${displayName}__${++countId}`;

      constructor(props) {
        super(props);
      }

      refreshView() {
        this.forceUpdate();
      }

      componentDidMount() {
        this.unsubscribeHandler = store.subscribe(() => {
          this.refreshView();
        }, this.componentInstanceUid);
        this.refreshView();
      }

      componentWillUnmount() {
        if (this.unsubscribeHandler) {
          this.unsubscribeHandler();
        }
      }

      render() {
        const props = {
          ...this.props,
          '@@TACKY__componentInstanceUid': this.componentInstanceUid,
        };
        return (
          <ErrorBoundary> <Target {...props} /> </ErrorBoundary> ) } } } } 複製代碼

解釋一下上面這段代碼的一些細節:

  • viewId 是如何生成的:componentInstanceUid 由計數器和組件的 displayName 組成,這樣設計的用意是一方面在開發環境單純一個 countId 不具備語義化,若是我想快速找到這個 view,displayName 更加友好。另外一方面單純的 displayName 也沒法保證每一個組件實例的惟一性,因此每一次渲染組件都會自增 countId 來確保惟一性
  • 依賴是什麼時候收集的:上面代碼能夠看到我是將目標組件的 render 函數重寫了,這樣在組件初次渲染時,咱們就開始收集依賴了,這裏咱們要感謝 react 把 componentWillMount 鉤子給去掉了,若是用戶在 componentWillMount 裏面就已經作了更新操做,就先於依賴的收集時機了,並且 componentWillMount 我至今沒有想出它的使用場景,幾乎都有辦法替代這種反模式,實際這個鉤子暴露給用戶是比較危險的,由於可能會致使流程沒法正常結束
  • 訂閱者發生了一些修改: store.subscribe(() => {}, viewId) 和以前的區別是多了一個 viewId 參數,這樣創建在有依賴關係 Map 的狀況下,每次修改狀態咱們都能經過 O(1) 的時間複雜度精肯定位哪些 view 須要更新,再經過 O(1) 的時間複雜度去精確觸發對應 view 的訂閱者
  • didMount 裏面多了一行 this.refreshView() 代碼:這行代碼是爲了解決目標組件發佈一次更新時,高階組件中的訂閱者還沒來得及綁定,這樣就會形成狀態不一樣步了。比較典型的場景是目標組件的 didMount 裏面直接 dispatch 消息,可是高階組件的 didMount 晚於目標組件 didMount 的執行,這個我想你們都瞭解,層級越深的組件越先完成渲染

綜合來看,依賴收集的更新效率、diff 效率理論上雖然比 redux 更好一些,但整個框架的複雜度要比 redux 高了不少,依賴收集的前置性能消耗也很高,咱們要搞清楚本身項目的 overhead 在什麼地方,選擇本身合適的實現方式。

充分利用裝飾器

mobx 中推薦你們使用裝飾器,裝飾器的用法確實很清真,好比咱們能夠這樣去設計一個領域模型:

class PrivilegeDomain extends Domain {
  @state() privilege = null;

  @mutation
  updateAwardStatus(id, level) {
    this.privilege[level].filter(r => r.obsId === id)[0].awardStatus = 1;
  }

  async fetchPrivilegeInfoFromRemote() {
    const privilege = await fetchPrivilege();
    this.$update({
      privilege,
    });
  }

  async getAward(id, level) {
    const code = await getAward(id);
    if (code === '0') {
      this.updateAwardStatus(id, level);
    }
  }
}
複製代碼

那麼與之對應,咱們須要去實現 @state()、@mutation() 裝飾器,這裏就不擴展怎麼使用裝飾器以及它的基本概念了,只是在框架中,須要注意一下 typescript 和 babel 對於裝飾器的實現略有區別,框架須要去兼容,下面貼個簡單的函數裝飾器例子:

function createMutation(target, name, original) {
  return function (...payload: any[]) {
    store.dispatch(original);
  };
}

export function mutation(target, name, descriptor) {
  invariant(!!descriptor, 'The descriptor of the @mutation handler have to exist.');

  // babel/typescript: @mutation method() {}
  if (descriptor.value) {
    const original: Mutation = descriptor.value;
    descriptor.value = createMutation(target, name, original);
    return descriptor;
  }

  // babel only: @mutation method = () => {}
  const { initializer } = descriptor;
  descriptor.initializer = function () {
    return createMutation(target, name, initializer && initializer.call(this));
  };

  return descriptor;
}
複製代碼

利用裝飾器,咱們能夠包裹原始函數,增強它的做用並對用戶隱藏實現細節,這其實有點面向切面編程的意思,每一個被修飾的函數均可以輕鬆得加鉤子了。上面的例子中,每一個被修飾的函數一旦被執行都會調用 disptach 發佈一條消息,這樣咱們就能夠實現諸如 @mutation、@reducer 等框架中處理更新邏輯的抽象概念了。

不過對於屬性裝飾器,會有一些坑,咱們仍是先看代碼再解釋:

export function state() {
  return function (target, property, descriptor) {
    // typescript only: (exp: @state() name: string = 'someone';)
    if (!descriptor) {
      const raw = undefined;
      Object.defineProperty(target, property, {
        enumerable: true,
        configurable: true,
        get: function () {
        },
        set: function (newVal) {
        },
      });
      return;
    }
    // babel only: (exp: @state() name = 'someone';)
    invariant(
      descriptor.initializer,
      'Your current environment don\'t support \"descriptor.initializer\" class property decorator, please make sure your babel plugin version.'
    );
    const raw = descriptor.initializer.call(this);
    return {
      enumerable: true,
      configurable: true,
      get: function () {
      },
      set: function (newVal) {
      },
    };
  }
}
複製代碼

babel / typescript 屬性裝飾器區別:在兼容 ts 的時候發現框架一直出問題,翻了 ts handbook 才發現 ts 屬性裝飾器的第三個參數 descriptor 並不存在,這跟 ts 的實現有關,而 babel 依賴於 plugin 的實現,在 class 構造函數初始化時會獲取當前實例屬性的 descriptor,大概是這樣:

let descriptor = Object.getPropertyDescriptor(this, prop);
this[prop] = descriptor.initializer.call(this);
複製代碼

因此 ts 中若是你不想改變 @state() API 的用法,你只能經過 Object.defineProperty 去自定義 descriptor。

但要注意,並無辦法能夠獲取到 raw,也就是最初的默認值,由於 ts 是在構造函數中才初始化默認值的,而裝飾器執行期間 class 尚未被實例化,因此這個值只能是 undefined,若是你想作到和 babel 同樣的效果,可能只能在裝飾器參數裏面傳默認值了,畢竟 ts 也沒有 initializer 這個屬性。

mutation 仍是 reducer

圖片名稱 圖片名稱

這塊其實爭議還挺大的,我說說我本身在業務中的感覺吧。其實 mutation 是 vuex 中的概念,在 mutation 中能夠直接對原對象作更改,不像 reducer 老是一個純函數去返回新的對象,但其實在業務開發中,這兩種形式差異已經不算很大了,reducer 配合 immutable 也很方便,只是理解上不太同樣,一種是「突變」,一種是「快照」,達到的目的是同樣的(忽略 switch case 這種難閱讀的寫法,稍微改造下成函數就好了),以下代碼:

// vuex
const store = new Vuex.Store({
  state: {
    count: 1
  },
  mutations: {
    increment (state) {
      // 變動狀態
      state.count++
    }
  }
})
// redux
const list = (state = {}, action) => {
  switch (action.type) {
    case 'Get_List_Results':
      if (action.offset === 0) {
        return Immutable.fromJS(state)
          .updateIn(['dataList'], list => action.payload)
          .toJSON()
      } else {
        return Immutable.fromJS(state)
          .updateIn(['dataList'], list => list.concat(action.payload))
          .toJSON()
      }
    default:
      return state
  }
}
複製代碼

其實我真正想對比的並非 vuex 中的 mutation,而是 mobx 中的 action,只不過 mobx 其實也是屬於「突變」的作法,mobx 推薦的寫法是這樣的:

@action toggleAgree = (event) => {
    const { checked } = event.target;
    this.agreed = checked;
}
複製代碼

全部的更新操做和各類業務邏輯、異步請求都混在一個 action 裏面,這樣作寫起來確實是比 vuex、redux 要快不少了,很無腦,可是也更容易面向過程編程了,一旦業務複雜起來,同一套邏輯可能獲得處改,徹底不考慮複用和拆分了,但 redux 被詬病最多的應該也是這個,即便只是改一個變量,也得一套流程寫下來,像這種單個變量的賦值其實根本沒有複用價值可言,因此針對這個痛點,我仍是把二者結合了一下,以下代碼所示:

class PrivilegeDomain extends Domain {
  @state() privilege = null;
  @state() result = 0;

  @mutation
  updateAwardStatus(id, level) {
    this.privilege[level].filter(r => r.obsId === id)[0].awardStatus = 1;
  }
  
  @reducer
  updateResult(state, index) {
    return fromJS(state).setIn(['result'], index).toJSON();
  }

  async fetchPrivilegeInfoFromRemote() {
    const privilege = await fetchPrivilege();
    this.$update({
      privilege,
    });
  }

  async getAward(id, level) {
    const code = await getAward(id);
    if (code === '0') {
      this.updateAwardStatus(id, level);
    }
  }
}
複製代碼

麻煩一些的更新操做,而且是屬於一組的更新操做,能夠放到一個 mutation 或者 reducer 裏面,看本身喜愛用哪一種形式,我以爲差很少,若是是簡單的賦值操做,我提供了一個簡易的語法糖 this.$update() 來達到一樣的更新效果,這樣代碼其實也更容易閱讀,什麼地方作了更新操做一目瞭然,固然有的人可能以爲沒啥意義,見仁見智吧這塊。

Observable 仍是 Time Travel

響應式以 mobx 爲表明,直接操做原實例對象,函數式以 redux 爲表明,每次拷貝新對象覆蓋原對象,這也讓 redux 支持時間旅行老是做爲一項「優點」去被對比。因此有多少人在業務開發中深度使用時間旅行功能,對於大部分流程不超過 二、3 個函數的業務,我以爲我根本不會用到時間旅行,並無帶來顛覆級的效率提高,但也不能否認,在一些富交互的協同軟件、工具軟件中,時間旅行確實會解決一些痛點,因此有些東西你以爲沒用多是沒遇到場景,存在即合理,對待開源工具和框架始終保持嚴謹客觀的態度。

在框架中,其實同時支持 observable 和 time travel 也不是不能夠,而是有沒有必要這麼作的問題,我在 tacky 框架中就同時支持了,實際上只要每次更新完成都去同步一份 snapshot 就能夠了,實現也不復雜,但這麼作的弊端是性能的損耗,你始終得去同步 snapshot 和實例對象上的值,因此框架必須提供一個開關,可讓用戶選擇是否開啓,這樣算是作到告終合二者的特色。

domain store 掛載到 props 上仍是直接引用

我曾經好像看到過某個文檔,說直接引用外部變量不走 props 是種反模式,但我本身一直沒想明白這樣作有什麼很差,實際上我自研的框架中就採用了第二種方式,我認爲若是該組件有從父組件傳過來的 props,那就仍是走 props,但全部走框架狀態管理相關的屬性和方法,所有從外部生成好的實例引入,不污染組件自己的 props,以下代碼所示:

import React from 'react';
import $column from '@domain/dwork/design-column/column';
import $tag from '@domain/dwork/design-column/tag';
import $list from '@processor/dwork/column-list/list';

@stick()
export default class List extends React.Component {
  componentDidMount() {
    $list.initLayoutState();
  }

  render() {
    const { fromParent } = this.props;
    return (
      <>
        <Radio.Group value={$tag.currentTagId} onChange={$list.changeTag}>
          <Radio value="">熱門推薦</Radio>
          <For
            each="item"
            index="idx"
            of={$tag.tags}
          >
            <Radio key={idx} value={item.id}>{item.tagName}</Radio>
          </For>
        </Radio.Group>
        <Skeleton
          styleName="column-list"
          when={$column.columnList.length > 0}
          render={<Columns showTag={$tag.currentTagId === ''} data={$column.columnList} />}
        />
        <Pagination
          current={$column.current}
          defaultPageSize={$column.pageSize}
          totalPage={$column.totalPage}
          onChange={$list.changePage}
          hideOnSinglePage={true}
        />
      </>
    );
  }
}
複製代碼

要實現這種效果,就得使用 react 提供的 forceUpdate() 方法了,畢竟這是一個外部數據,forceUpdate() 的機制咱們都瞭解,它會跳過 shouldComponentUpdate 強制渲染,因此數據 diff 須要框架本身去處理了,這點要注意。

在非 ts 項目中,mobx 將 store 掛載到組件的 props 上會讓編輯器直接喪失提示和跳轉,而 redux 的 mapDispatchToProps、mapStateToProps 就更麻煩了,不只沒提示,一個一個 pick 出來映射的做用始終不讓我以爲滿意。因此我更傾向於直接享受 vscode 對於 class 實例上的屬性和函數的原生提示,不須要任何工具和輔助代碼,即便是 js 項目也很是好維護,直接按住 alt 鍵作 navigation,這樣咱們也不須要人工記憶映射關係。

但掛載在 props 上仍是有好處的,起碼更符合 react 組件的規範,並且能夠總覽整個組件的 props 接口?(若是這算個好處的話)另外就是讓這些業務型組件變得能夠複用?

但以上幾個問題我其實都思考過,總覽組件的 props 我以爲不算是個好處,由於咱們在維護一個項目的時候,做爲前端第一時間基本都是去找那個對應「按鈕、列表」 UI 的 t(j)sx,而後從 t(j)sx 着手,沿着這條鏈路去修改邏輯,在那些業務的 class 裏面一個個函數和屬性都羅列的清清楚楚,這是維護方面的。

若是我要使用這個業務組件,一般只會傳幾個關鍵的 props 參數,其他的邏輯應該是足夠內聚的,使用者並不想關心,即便有定製化的邏輯,也應該讓組件經過 props 反向拋出來,真的會有人讓使用者本身去把一個容器組件和它對應的 store 手工拼裝映射起來用嗎?我想你會被那個使用者按在地上摩擦的。除非真的有很高的複用要求。

更多的狀況還得讓業務進一步驗證,目前暫時沒發現問題。

中間件系統

這一節其實不想過多擴展,社區有一大堆研究過 redux 中間件機制的文章,中間件的應用在不少場景都有,咱們只要知道它的做用就能夠了,框架裏面也能夠植入,不是很複雜。貼個 redux compose 函數吧:

export function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
複製代碼

更高層次的複用-多實例隔離以及命名空間

mobx 文檔裏面推薦一個 class 最好是單例的,若是是單例的,其實框架也會好寫不少,但咱們業務的場景仍是會有同一個 domain class 須要多實例的狀況,這實際上是爲了更高層次的複用,咱們但願在同一個應用中,能夠作到同一 feature class 的狀態隔離,好比某些場景,我就是想讓兩個相同業務邏輯的業務組件保持狀態不一樣步,獨立維護狀態。因此我以爲不能簡單的把狀態掛載到原型上,而是得利用實例化自然的隔離特性。因此我在 @state() 修飾器中是這麼作的:

export function state() {
  return function (target, property, descriptor) {
    // typescript only: (exp: @state() name: string = 'someone';)
    if (!descriptor) {
        // ...
    }
    // babel only: (exp: @state() name = 'someone';)
    invariant(
      descriptor.initializer,
      'Your current environment don\'t support \"descriptor.initializer\" class property decorator, please make sure your babel plugin version.'
    );
    const raw = descriptor.initializer.call(this);

    return {
      enumerable: true,
      configurable: true,
      get: function () {
        return observableStateFactory({
          currentInstance: this,
          target,
          property,
          raw: simpleClone(raw),
        }).get(true);
      },
      set: function (newVal) {
        setterBeforeHook({
          target,
        });
        if (isObject(newVal)) {
          observeObject({
            raw: newVal,
            target,
            currentInstance: this,
          });
        }
        return observableStateFactory({
          currentInstance: this,
          target,
          property,
          raw: simpleClone(raw),
        }).set(newVal);
      },
    };
  }
}
複製代碼

先把默認值拿到,而後不在這個裝飾器內部去維護 getter,setter 的變量,而是經過一個工廠去生產狀態變量,每次經過當前的上下文 this、target 原型以及屬性名和默認值的拷貝,來映射起來,這樣就作到即便是一樣的原型、屬性名,也會由於 this 的不一樣,而取到不一樣的狀態變量,達到了各自維護狀態的功能。

而後再說說命名空間,我是不但願讓用戶本身每次都要傳一個字符串去維護,這樣不只增長了使用成本,還得記憶當前應用中是否有衝突的命名空間,即便有報錯也是後置性的。這種背景下,要干預原生 class,我能想到的除了繼承就是裝飾器了,考慮到每一個 class 確實有一些公共函數,好比 this.$update(),而且不想喪失 vscode navigation 的功能,最後選擇了繼承,我是這麼實現的:

export class Domain {
  constructor() {
    const target = Object.getPrototypeOf(this);
    uid += 1;
    target[CURRENT_MATERIAL_TYPE] = MaterialType.Mutation;
    const domainName = target.constructor.name || 'TACKY_DOMAIN';
    const namespace = `${domainName}@@${uid}`;
    this[NAMESPACE] = namespace;
    StateTree.initInstanceStateTree(namespace, this);
  }

  $lazyLoad() {
    const target = Object.getPrototypeOf(this);
    target[CURRENT_MATERIAL_TYPE] = MaterialType.Noop;
    StateTree.initPlainObjectAndDefaultStateTreeFromInstance(this[NAMESPACE]);
  }

  $reset() {
    const atom = StateTree.globalStateTree[this[NAMESPACE]] as AtomStateTree;
    this.dispatch(atom.default);
  }

  $destroy() {
    StateTree.clearAll(this[NAMESPACE]);
  }

  $update(obj: object) {
    invariant(isObject(obj), 'resetState(...) param type error. Param should be a plain object.');
    this.dispatch(obj);
  }

  private dispatch(obj) {
    const target = Object.getPrototypeOf(this);
    const original = function () {
      for (const key in obj) {
        if (obj.hasOwnProperty(key)) {
          this[key] = obj[key];
        }
      }
    };
    target[CURRENT_MATERIAL_TYPE] = MaterialType.Mutation;
    // update state before render
    if (!store) {
      original.call(this);
      StateTree.syncPlainObjectStateTreeFromInstance(this[NAMESPACE]);
      target[CURRENT_MATERIAL_TYPE] = MaterialType.Noop;
      return;
    }
    // update state after render
    store.dispatch({
      payload: [],
      type: MaterialType.Mutation,
      namespace: this[NAMESPACE],
      original: bind(original, this) as Mutation
    });
    target[CURRENT_MATERIAL_TYPE] = MaterialType.Noop;
  }
}
複製代碼

這樣我就能夠隱性的給每一個實例加上一個 namespace,還能作不少其餘功能,不過這種方法仍是沒法把操做植入到子類的 constructor 完成的那一刻,我想了一下除了改寫子類的 constructor 貌似沒有其餘辦法,好在我暫時尚未這樣的需求。

複雜數據結構的處理

這裏指的是 @state() 修飾一些諸如嵌套對象、數組等的狀況,要想作到在原值上隨意修改,就得像 mobx 那樣處理了,但咱們業務對於 IE 沒有兼容性要求,因此我採用了 Proxy 去實現,這樣會省不少代碼,也會簡單不少,不過我只是用在了數組的處理上,以下所示:

class Observable {
  value: any = null;
  target: Object = {};
  currentInstance: Domain | null = null;

  constructor(raw, target, currentInstance) {
    this.target = target;
    this.currentInstance = currentInstance;
    this.value = Array.isArray(raw) ? this.arrayProxy(raw) : raw;
  }

  get(needCollect = false) {
    if (needCollect) {
      if (!this.currentInstance) {
        fail('Unexpected error. Observable current instance doesn\'t exists.');
        return;
      }
      collector.collect(this.currentInstance[NAMESPACE]);
    }

    return this.value;
  }

  setterHandler() {
    differ.collectDiff(true);
  }

  set(newVal) {
    const wpVal = Array.isArray(newVal) ? this.arrayProxy(newVal) : newVal;
    if (wpVal !== this.value) {
      this.setterHandler();
      this.value = wpVal;
    }
    setterAfterHook();
  }

  arrayProxy(array) {
    observeObject({ raw: array, target: this.target, currentInstance: this.currentInstance });

    return new Proxy(array, {
      set: (target, property, value, receiver) => {
        setterBeforeHook({
          target: this.target,
        });
        const previous = Reflect.get(target, property, receiver);
        let next = value;

        if (previous !== next) {
          this.setterHandler();
        }

        // set value is object
        if (isObject(next)) {
          observeObject({ raw: next, target: this.target, currentInstance: this.currentInstance });
        }
        // set value is array
        if (Array.isArray(next)) {
          next = this.arrayProxy(next);
        }

        const flag = Reflect.set(target, property, next);
        setterAfterHook();
        return flag;
      }
    });
  }
}
複製代碼

還有種狀況是嵌套對象,比較容易想到用遞歸去實現:

export function observeObjectProperty({ raw, target, currentInstance, property, }) {
  const subVal = raw[property];

  if (isObject(subVal)) {
    for (let prop in subVal) {
      if (subVal.hasOwnProperty(prop)) {
        observeObjectProperty({
          raw: subVal,
          target,
          currentInstance,
          property: prop,
        });
      }
    }
  } else {
    const observable = new Observable(subVal, target, currentInstance);

    Object.defineProperty(raw, property, {
      enumerable: true,
      configurable: true,
      get: function () {
        return observable.get();
      },
      set: function (newVal) {
        setterBeforeHook({
          target,
        });
        if (isObject(newVal)) {
          for (let prop in newVal) {
            if (newVal.hasOwnProperty(prop)) {
              observeObjectProperty({
                raw,
                target,
                currentInstance,
                property: prop,
              });
            }
          }
        }
        return observable.set(newVal);
      },
    });
  }
}

export function observeObject({ raw, target, currentInstance }) {
  for (let property in raw) {
    if (raw.hasOwnProperty(property)) {
      observeObjectProperty({
        raw,
        target,
        currentInstance,
        property,
      });
    }
  }
}
複製代碼

鉤子

咱們在上面的代碼中應該能夠發現諸如 setterBeforeHook, setterAfterHook 等函數,這其實就是 setter 處理器中的兩個鉤子,一個是修改值以前,一個是修改值以後,這個需求主要來源於我想禁止直接在非 mutation 函數中直接對 @state() 修飾過的狀態賦值,也就是說你這麼用會報錯:

class TagDomain extends Domain {
  @state() currentTagId = '';

  @mutation
  updateCurrentTagId(tagId) {
    this.currentTagId = tagId; // correct
  }

  async test() {
    this.currentTagId = 'aaa'; // error
  }
}
複製代碼

框架中只能經過 mutation 或者 this.$update() 來賦值更新,若是不限制,假如用戶進行非法操做,會形成狀態和視圖不一樣步的問題,因此仍是提示一個報錯會比較友好。

通用錯誤處理及工具函數

把框架中經常使用的工具和錯誤處理函數抽出來,便於複用和統一修改,能夠去一些優秀框架裏面扒一些,好比:

export function isObject(value: any): boolean {
  if (value === null || typeof value !== 'object') return false
  const proto = Object.getPrototypeOf(value)
  return proto === Object.prototype || proto === null
}

export function isPrimitive(value) {
  return value === null || (typeof value !== 'object' && typeof value !== 'function');
}

// From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js
export function is(x, y) {
  if (x === y) {
    return x !== 0 || 1 / x === 1 / y
  } else {
    return x !== x && y !== y
  }
}

export const OBFUSCATED_ERROR =
  'An invariant failed, however the error is obfuscated because this is an production build.';

export function invariant(check: boolean, message?: string | boolean) {
  if (!check) throw new Error('[tacky]: ' + (message || OBFUSCATED_ERROR));
}

export function fail(message: string | boolean): never {
  invariant(false, message);
  throw 'X';
}
複製代碼

總結

我相信總有能夠知足需求的輪子,只要你認認真真的找,但也永遠不存在一款完美的輪子,否則開源社區就像一灘死水,永遠沒有活躍度了,有時間那就本身去折騰去學習吧,重要的是你能從業務中發現痛點,有能力解決痛點,而且確實有收穫,那就足夠了,結果不必定很重要。要潑冷水實際上是很容易的,每家公司自研的輪子其實好用的很少,畢竟投入時間頗有限,但有時也不必定要被社區牽着鼻子走,本身動手多是每一個工程師工做中惟一的一點樂子了吧。

框架傳送門:github.com/kujiale/tac…

目前還有挺多問題的,很簡陋,主要靠做者空閒時間維護,歡迎你們來領 issue 一塊兒共建,喜歡的話也能夠來個 star

相關文章
相關標籤/搜索