再見了 Redux、Recoil、MobX、Zustand、Jotai 還有 Valtio,狀態管理還可以這樣做?

烏柏木發表於2023-05-15

堅持在一線寫前端程式碼大概有七八年了,寫過一些專案,有過一些反思,越來越確信平日裡一直用得心安理得某些的東西也許存在著問題,比如:在 狀態管理 上一直比較流行的實踐 ?,所以試著分享出來探討一下。

為什麼要告別 Redux、Recoil、MobX、Zustand、Jotai 還有 Valtio

今天流行的狀態管理庫有很多,尤其在 React 中。為了把問題說得清晰一些,我想以 React 中的幾個主流庫切入聊起。

首先看一下 Redux。對於單個狀態的變化,可以 dispatch 簡單 action。想知道這個簡單 action 會改變什麼狀態,根據 Redux 的設計,檢查它宣告在哪個 slice 裡就可以了:

const checkboxSlice = createSlice({
  name: 'checkbox',
  initialState: {
    checked: false,
  },
  reducers: {
    check(state) {
      // ...
    },
  },
});

const { check } = checkboxSlice.actions;

// ...

dispatch(check());

// 因為 `check` 宣告在 `checkboxSlice` 裡,根據 Redux 的設計可以知道 `check` 改變的是 `checkboxSlice` 代表的狀態。

而對於多個狀態的變化,需要 dispatch 複雜 action。想知道這個複雜 action 會改變什麼狀態,只檢查它宣告在哪裡是不夠的:

const checkboxSlice = createSlice({
  name: 'checkbox',
  initialState: {
    checked: false,
  },
  reducers: {
    check(state) {
      // ...
    },
+
+    uncheck(state) {
+      // ...
+    }
  },
});

-const { check } = checkboxSlice.actions;
+const { check, uncheck } = checkboxSlice.actions;

// 先構建複雜 action `uncheckWithTextCleaned` 要呼叫的底層簡單 action `uncheck`,而這個簡單 action 大機率不會在別的地方用到了。
const textareaSlice = createSlice({
  name: 'textarea',
  initialState: {
    text: '',
  },
  reducers: {
    setText(state, action: PayloadAction<string>) {
      // ...
    },
  },
});

const { setText } = textareaSlice.actions;

function uncheckWithTextCleaned(): AppThunk {
  return (dispatch) => {
    // ...
  };
}

// ...

dispatch(uncheckWithTextCleaned());

// 在只檢查 `uncheckWithTextCleaned` 的函式宣告的情況下,無法知道這個複雜 action 會改變什麼狀態。

如果不追蹤函式體,就無法知道複雜 action 會改變什麼狀態,那麼狀態變化就變得不可預測了。如果追蹤了函式體,儘管可以知道會改變什麼狀態,但使用上的總體開發成本也就隨著加重了:

function uncheckWithTextCleaned(): AppThunk {
  return (dispatch) => {
    dispatch(uncheck());
    dispatch(setText(''));
  };
}

// ...

dispatch(uncheckWithTextCleaned());

// 透過追蹤函式體發現 `uncheckWithTextCleaned` 呼叫了 `uncheck` 和 `setText`,由於 `uncheck` 宣告在 `checkboxSlice` 裡,`setText` 宣告在 `textareaSlice`,可以知道 `uncheckWithTextCleaned` 改變的是 `checkboxSlice` 和 `textareaSlice` 代表的狀態。

此外,在複雜 action 要呼叫的底層簡單 action 還沒準備好的時候,就要先構建這些要呼叫的簡單 action,而這些簡單 action 大機率不會在別的地方用到了。這樣,複雜 action 就與底層 slice 高耦合了,會導致開發困難,也就使成本進一步加重了。

再看一下 Recoil 和 MobX。在 Recoil 中是透過自定義 hook 來封裝狀態變化的:

const checkboxState = atom({
  key: 'checkbox',
  default: {
    checked: false,
  },
});

const textareaState = atom({
  key: 'textarea',
  default: {
    text: '',
  },
});

// ...

function useSetText() {
  return useRecoilCallback(
    ({ set }) =>
      (text: string) => {
        // ...
      },
    []
  );
}

function useUncheckWithTextCleaned() {
  const setText = useSetText();

  return useRecoilCallback(
    ({ set }) =>
      () => {
        // ...
      },
    []
  );
}

// ...

const uncheckWithTextCleaned = useUncheckWithTextCleaned();

// ...

uncheckWithTextCleaned();

// 在只檢查 `uncheckWithTextCleaned` 或 `useUncheckWithTextCleaned` 的函式宣告的情況下,無法知道這個 hook 會改變什麼狀態。需要透過追蹤函式體發現直接或間接發起的 `set` 呼叫才能知道會改變什麼狀態。

在 MobX 中是透過類的方法來封裝狀態變化的:

class CheckboxStore {
  private textareaStore: TextareaStore;

  checked: boolean;

  constructor(textareaStore: TextareaStore) {
    makeAutoObservable(this);
    this.textareaStore = textareaStore;
    this.checked = false;
  }

  uncheckWithTextCleaned(): void {
    // ...
  }
}

class TextareaStore {
  text: string;

  constructor() {
    makeAutoObservable(this);
    this.text = '';
  }

  setText(text): void {
    // ...
  }
}

// ...

checkboxStore.uncheckWithTextCleaned();

// 在只檢查 `checkboxStore.uncheckWithTextCleaned` 的函式宣告的情況下,無法知道這個方法會改變什麼狀態。需要透過追蹤函式體發現直接或間接改變的 property 才能知道會改變什麼狀態。

與 Redux 類似地,如果不追蹤函式體,狀態變化就不可預測了。如果追蹤了函式體,使用成本就加重了。

此外,由於 Recoil 較多地考慮了非同步狀態變化,在自定義 hook 中獲取狀態會比較麻煩,由於 MobX 有獨立的訂閱機制,妥當使用需要準確理解。這些,都使成本進一步加重了。

而餘下的三個庫,Zustand、Jotai 和 Valtio,它們用起來分別非常像 Redux、Recoil 和 MobX。或許可以說,前幾者基本上是後幾者的簡化版。

小結一下,React 中的主流狀態管理庫在 (1) 狀態變化的可預測性 和 (2) 使用上的總體開發成本 上存在著問題。如果稍微看一下其他框架中最主流的狀態管理庫,會發現它們也有類似問題。所以可以說,這兩個問題是普遍存在的。

可預測性與副作用

當函式在輸出返回值之外還產生了其他效果,那這個函式就是有副作用(side effect)的。像上面例子中的函式,副作用都是改變狀態。

而函式有副作用不等同於函式行為是不可預測的。只要副作用是可控的,函式行為就是可預測的。像 Redux 例子中的簡單 action,根據 Redux 的設計只能改變宣告各自的 slice 所代表的狀態。但是,對函式的副作用不加以控制的話,隨著函式體的複雜度上升副作用的可控性就會下降,最終,不可控的副作用就會讓函式行為變得不可預測。

而函式沒有副作用的話,函式行為就自然而然的可預測了。

這麼想一想,要解決狀態變化的可預測性問題,要麼一直保持改變狀態的函式的副作用可控,要麼徹底去除改變狀態的函式的副作用。

使用上的總體開發成本與偏好

除了可預測性問題對使用上的總體開發成本的影響,狀態管理庫自身的偏好也會較大程度地影響使用上的總體開發成本。像 Redux 中建立一個新的 store、像 Recoil 中自定義 hook 訪問狀態、像 MobX 中妥當使用訂閱機制,都受到庫自身的偏好影響變得有些費時費力。

當由於狀態管理庫自身的偏好加重了最基本的狀態管理功能上的使用成本時,就會對這個狀態管理庫方方面面的使用產生負面影響,這是應該避免的。

狀態管理的新做法

分析好了問題,接下來就可以想一下狀態管理的新做法了,也就是,如何設計一個能夠解決以上兩個問題的新狀態管理庫。對於解決狀態變化的可預測性問題,上面提到的兩種做法儘管都可行,但出於對簡潔性的追求,先嚐試一下 “徹底去除改變狀態的函式的副作用” 的做法。

對於單個狀態的變化,可以引用以單個狀態為入參、以新的單個狀態為返回值的純函式來完成:

function check(checkboxState: CheckboxState): CheckboxState {
  return {
    /* ... */
  };
}

對於多個狀態的變化,可以引用以多個狀態為入參、以新的多個狀態為返回值的純函式來完成:

function uncheckWithTextCleaned([checkboxState, textareaState]: [
  CheckboxState,
  TextareaState
]): [CheckboxState, TextareaState] {
  return [
    /* ... */
  ];
}

同時這些函式還應該能夠接受除了狀態之外的更多引數:

function setText(textarea: TextareaState, text: string): TextareaState {
  return {
    /* ... */
  };
}

由於純函式沒有副作用,引用這些函式改變不論單個還是多個狀態都只會改變函式宣告中的狀態,也就是,可以在不追蹤函式體的情況下知道會改變什麼狀態。

然後,引用這些函式改變狀態的過程大致是這樣的,(1) 讀取狀態、(2) 將狀態傳入函式計算新的狀態 和 (3) 寫入新的狀態:

const oldCheckboxState = getState(keyOfCheckboxState);
const newCheckboxState = check(oldCheckboxState);
setState(keyOfCheckboxState, newCheckboxState);

這個過程可以進一步封裝成一個通用的函式 operate

operate(keyOfCheckboxState, check);
operate(keyOfTextareaState, setText, '');
operate([keyOfCheckboxState, keyOfTextareaState], uncheckWithTextCleaned);

這樣,新狀態管理庫的雛形就有了。

接下來,再從減輕使用成本的角度試著做一做最佳化。

稍微看一下 operate 的第一個引數 keyOf...,作用是 (1) 唯一標識狀態。但是單獨為了唯一標識狀態,就宣告一系列唯一的字串常量,成本是比較高的。而完整定義狀態,還需要 (2) 狀態的預設值 和 (3) 狀態的型別。如果把這三點關聯在一起的話,就會發現對應到了 JS 中的一個常用概念,Plain Old JavaScript Object(POJO)。那麼,透過 POJO 來定義狀態的話,使用成本就進一步減輕了:

interface CheckboxState {
  checked: boolean;
}

const defOfCheckboxState: CheckboxState = {
  checked: false,
};

interface TextareaState {
  text: string;
}

const defOfTextareaState: TextareaState = {
  text: '',
};

// ...

operate(defOfCheckboxState, check);
operate(defOfTextareaState, setText, '');
operate([defOfCheckboxState, defOfTextareaState], uncheckWithTextCleaned);

然後,再以無偏好地方式加上其他最基本的狀態管理功能,(1) 獲取狀態、(2) 訂閱狀態變化 和 (3) 取消訂閱:

const checkboxState1 = snapshot(defOfCheckboxState);
const textareaState1 = snapshot(defOfTextareaState);
const [checkboxState2, textareaState2] = snapshot([
  defOfCheckboxState,
  defOfTextareaState,
]);

const unsubscribeCheckboxStateChanges = subscribe(
  defOfCheckboxState,
  onCheckboxStateChange
);
const unsubscribeTextareaStateChanges = subscribe(
  defOfTextareaState,
  onTextareaStateChange
);
const unsubscribeCheckboxTextareaStatesChanges = subscribe(
  [defOfCheckboxState, defOfTextareaState],
  onCheckboxTextareaStatesChange
);

這樣,能夠解決 (1) 狀態變化的可預測性 和 (2) 使用上的總體開發成本 上問題的新狀態管理庫就大致做好了。

展望

在前端開發中狀態管理是非常基礎但極其重要的部分,而今天恰恰缺少了一個狀態變化可預測、使用總體成本低的狀態管理庫,這給前端開發帶來了許多挑戰。

好的前端應用需要好的狀態管理,作為前端開發者的我們也許都可以想一想怎麼做狀態管理才是好的。

此外,我也試著按照上面的思路寫了一個狀態管理庫 https://github.com/statofu/statofu ,方便一起進一步嘗試。

歡迎大家留言進一步探討。

相關文章