狀態管理框架開發不完全指南

酷家樂平臺前端團隊發表於2019-04-28

原文連結

作者:非凡

導讀

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

文章比較長,所以列個大綱,讀者可以挑選自己感興趣的章節以節省閱讀時間。如果看完大綱你認為對你現在這個階段應該沒有什麼幫助,那麼也是一件好事,這一節就是為了幫助你節省下這個時間去做其他更有意義的事情。

前世今生

一個前端應用通常由許多不同的模組、元件通過不同的組合方式拼裝、渲染出來,在 react 應用生態中,一個 react 元件也是業務邏輯上最小的“內聚單元”,每個 react 元件都可以有自己內部的狀態和生命週期,這樣的設計有助於解耦、由全域性視角降低為區域性視角,更利於應用的維護。

複雜的業務永遠都是複雜的,模組化、元件化這些架構設計方法本身並不會降低一個系統的業務複雜度,但它的好處是可以降低系統的熵、系統維護成本、提升系統的擴充套件能力,將“關注點”降到最低。

這裡我就不擴充套件太多,相信大家在工作中都深有體會,比如 A 同學維護 B 同學寫的一個功能,A 同學只是想加一個按鈕的小功能,卻不得不把 B 同學寫的一整塊功能都看懂,這樣的設計顯然有些糟糕,浪費了 A 同學許多的時間,如果 A 同學只需要看懂某個元件或小模組的程式碼,那維護成本就很低了。

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

所以光有元件內部的狀態管理是不夠的,應用級別的全域性狀態管理在這種情況下就很有必要了,全域性只是更高層次的抽象,為了更方便通訊,倒不是簡單得把所有東西都扔到全域性,即使是全域性狀態,我們依然需要有模組、有規則得去管理起來,這就需要框架、工具去解決這類問題。

核心問題

狀態管理框架的核心其實就是釋出訂閱模式,不管是 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 || '@@sticky_component';
    const target = Target.prototype || Target;
    const baseRender = target.render;

    target.render = function () {
      const id = this.props['@@STICKY__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,
          '@@STICKY__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 支援時間旅行總是作為一項“優勢”去被對比。所以有多少人在業務開發中深度使用時間旅行功能,對於大部分流程不超過 2、3 個函式的業務,我覺得我根本不會用到時間旅行,並沒有帶來顛覆級的效率提升,但也不可否認,在一些富互動的協同軟體、工具軟體中,時間旅行確實會解決一些痛點,所以有些東西你覺得沒用可能是沒遇到場景,存在即合理,對待開源工具和框架始終保持嚴謹客觀的態度。

在框架中,其實同時支援 observable 和 time travel 也不是不可以,而是有沒有必要這麼做的問題,我在 sticky 框架中就同時支援了,實際上只要每次更新完成都去同步一份 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 || 'STICKY_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('[sticky]: ' + (message || OBFUSCATED_ERROR));
}

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

總結

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

相關文章