Recoil 的使用

劉哇勇發表於2021-04-08

 

通過簡單的計數器應用來展示其使用。先來看沒有 Recoil 時如何實現。

首先建立示例專案

$ yarn create react-app recoil-app --template typescript

計數器

考察如下計數器元件:

Counter.tsx

import React, { useState } from "react";
export const Counter = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <span>{count}</span>
      <button
        onClick={() => {
          setCount((prev) => prev + 1);
        }}
      >
        +
      </button>
      <button
        onClick={() => {
          setCount((prev) => prev - 1);
        }}
      >
        -
      </button>
    </div>
  );
};

跨元件共享資料狀態

當想把 count 的展示放到其他元件時,就涉及到跨元件共享資料狀態的問題,一般地,可以將需要共享的狀態向上提取到父元件中來實現。

Counter.tsx

export interface ICounterProps {
  onAdd(): void;
  onSubtract(): void;
}

export const Counter = ({ onAdd, onSubtract }: ICounterProps) => {
  return (
    <div>
      <button onClick={onAdd}>+</button>
      <button onClick={onSubtract}>-</button>
    </div>
  );
};

Display.tsx

export interface IDisplayProps {
  count: number;
}

export const Display = ({ count }: IDisplayProps) => {
  return <div>{count}</div>;
};

App.tsx

export function App() {
  const [count, setCount] = useState(0);
  return (
    <div className="App">
      <Display count={count} />
      <Counter
        onAdd={() => {
          setCount((prev) => prev + 1);
        }}
        onSubtract={() => {
          setCount((prev) => prev - 1);
        }}
      />
    </div>
  );
}

可以看到,資料被提升到了父元件中進行管理,而對資料的操作,也一併進行了提升,子元件中只負責觸發改變資料的動作 onAddonSubtract,而真實的加減操作則從父元件傳遞下去。

這無疑增加了父元件的負擔,一是這樣的邏輯上升沒有做好元件功能的內聚,二是父元件在最後會沉澱大量這種上升的邏輯,三是這種上升的操作不適用於元件深層巢狀的情況,因為要逐級傳遞屬性。

當然,這裡可使用 Context 來解決。

使用 Context 進行資料狀態的共享

新增 Context 檔案儲存需要共享的狀態:

appContext.ts

import { createContext } from "react";

export const AppContext = createContext({
  count: 0,
  updateCount: (val: number) => {},
});

注意這裡建立 Context 時,為了讓子元件能夠更新 Context 中的值,還額外建立了一個回撥 updateCount

更新 App.tsx 向子元件傳遞 Context:

App.tsx

export function App() {
  const [count, setCount] = useState(0);
  const ctx = {
    count,
    updateCount: (val) => {
      setCount(val);
    },
  };
  return (
    <AppContext.Provider value={ctx}>
      <div className="App">
        <Display />
        <Counter />
      </div>
    </AppContext.Provider>
  );
}

更新 Counter.tsx 通過 Context 獲取需要的值和更新 Context 的回撥:

Counter.tsx

export const Counter = () => {
  const { count, updateCount } = useContext(AppContext);
  return (
    <div>
      <button
        onClick={() => {
          updateCount(count + 1);
        }}
      >
        +
      </button>
      <button
        onClick={() => {
          updateCount(count - 1);
        }}
      >
        -
      </button>
    </div>
  );
};

更新 Display.tsx 從 Conext 獲取需要展示的 count 欄位:

Display.tsx

export const Display = () => {
  const { count } = useContext(AppContext);
  return <div>{count}</div>;
};

可以看出,Context 解決了屬性傳遞的問題,但邏輯上升的問題仍然存在。

同時 Context 還面臨其他一些挑戰,

  • 更新 Context 需要單獨提供一個回撥以在子元件中進行呼叫
  • Context 只能存放單個值,你當然可以將所有欄位放到一個全域性物件中來管理,但無法做到打散來管理。如果非要打散,那需要巢狀多個 Context,比如像下面這樣:
export function App() {
  return (
    <ThemeContext.Provider value={theme}>
      <UserContext.Provider value={signedInUser}>
        <Layout />
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
}

Recoil 的使用

安裝

新增 Recoil 依賴:

$ yarn add recoil

RecoilRoot

類似 Context 需要將子元件包裹到 Provider 中,需要將元件包含在 <RecoilRoot> 中以使用 Recoil。

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById("root")
);

Atom & Selector

Recoil 中最小的資料元作為 Atom 存在,從 Atom 可派生出其他資料,比如這裡 count 就是最原子級別的資料。

建立 state 檔案用於存放這些 Recoil 原子資料:

appState.ts

import { atom } from "recoil";

export const countState = atom({
  key: "countState",
  default: 0,
});

通過 selector 可從基本的 atom 中派生出新的資料,假如還需要展示一個當前 count 的平方,則可建立如下的 selector:

import { atom, selector } from "recoil";

export const countState = atom({
  key: "countState",
  default: 0,
});

export const powerState = selector({
  key: "powerState",
  get: ({ get }) => {
    const count = get(countState);
    return count ** 2;
  },
});

selector 的存在意義在於,當它依賴的 atom 發生變更時,selector 代表的值會自動更新。這樣程式中無須關於這些資料上的依賴邏輯,只負責更新最基本的 atom 資料即可。

而使用時,和 React 原生的 useState 保持了 API 上的一致,使用 Recoil 中的 useRecoilState 可進行無縫替換。

import { useRecoilState } from "recoil";

...
const [count, setCount] = useRecoilState(countState)
...

當只需要使用值而不需要對值進行修改時,可使用 useRecoilValue

Display.tsx

import React from "react";
import { useRecoilValue } from "recoil";
import { countState, powerState } from "./appState";

export const Display = () => {
  const count = useRecoilValue(countState);
  const pwoer = useRecoilValue(powerState);
  return (
    <div>
      count:{count} power: {pwoer}
    </div>
  );
};

由上面的使用可看到,atom 建立的資料和 selector 建立的資料,在使用上無任何區別。

當只需要對值進行設定,而又不進行展示時,則可使用 useSetRecoilState

Conter.tsx

import React from "react";
import { useSetRecoilState } from "recoil";
import { countState } from "./appState";

export const Counter = () => {
  const setCount = useSetRecoilState(countState);
  return (
    <div>
      <button
        onClick={() => {
          setCount((prev) => prev + 1);
        }}
      >
        +
      </button>
      <button
        onClick={() => {
          setCount((prev) => prev - 1);
        }}
      >
        -
      </button>
    </div>
  );
};

非同步資料的處理

Recoil 最方便的地方在於,來自非同步操作的資料可直接引數到資料流中。這在有資料來自於請求的情況下,會非常方便。

export const todoQuery = selector({
  key: "todo",
  get: async ({ get }) => {
    const res = await fetch("https://jsonplaceholder.typicode.com/todos/1");
    const todos = res.json();
    return todos;
  },
});

使用時,和正常的 state 一樣:

TodoInfo.tsx

export function TodoInfo() {
  const todo = useRecoilValue(todoQuery);
  return <div>{todo.title}</div>;
}

但由於上面 TodoInfo 元件依賴的資料來自非同步,所以需要結合 React Suspense 來進行渲染。

App.tsx

import React, { Suspense } from "react";
import { TodoInfo } from "./TodoInfo";

export function App() {
  return (
    <div className="app">
      <Suspense fallback="loading...">
        <TodoInfo />
      </Suspense>
    </div>
  );
}

預設值

前面看到無論 atom 還是 selector 都可在建立時指定預設值。而這個預設值甚至可以是來自非同步資料。

appState.ts

export const todosQuery = selector({
  key: "todo",
  get: async ({ get }) => {
    const res = await fetch(`https://jsonplaceholder.typicode.com/todos`);
    const todos = res.json();
    return todos;
  },
});

export const todoState = atom({
  key: "todoState",
  default: selector({
    key: "todoState/default",
    get: ({ get }) => {
      const todos = get(todosQuery);
      return todos[0];
    },
  }),
});

使用:

TodoInfo.tsx

export function TodoInfo() {
  const todo = useRecoilValue(todoState);
  return <div>{todo.title}</div>;
}

default_value mov

不使用 Suspense 的示例

當然也可以不使用 React Suspense,此時需要使用 useRecoilValueLoadable 並且自己處理資料的狀態。

App.tsx

import React from "react";
import { useRecoilValueLoadable } from "recoil";
import "./App.css";
import { todoQuery } from "./appState";

export function TodoInfo() {
  const todoLodable = useRecoilValueLoadable(todoQuery);
  switch (todoLodable.state) {
    case "hasError":
      return "error";
    case "loading":
      return "loading...";
    case "hasValue":
      return <div>{todoLodable.contents.title}</div>;
    default:
      break;
  }
}

給 selector 傳參

上面請求 Todo 資料時 id 是寫死的,真實場景下,這個 id 會從介面進行獲取然後傳遞到請求的地方。

此時可先建立一個 atom 用以儲存該選中的 id。

export const idState = atom({
  key: "idState",
  default: 1,
});

export const todoQuery = selector({
  key: "todo",
  get: async ({ get }) => {
    const id = get(idState);
    const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
    const todos = res.json();
    return todos;
  },
});

介面上根據互動更新 id,因為 todoQuery 依賴於這個 id atom,當 id 變更後,會自動觸發新的請求從而更新 todo 資料。即,使用的地方只需要關注 id 的變更即可。

export function App() {
  const [id, setId] = useRecoilState(idState);
  return (
    <div className="app">
      <input
        type="text"
        value={id}
        onChange={(e) => {
          setId(Number(e.target.value));
        }}
      />
      <Suspense fallback="loading...">
        <TodoInfo />
      </Suspense>
    </div>
  );
}

另外處情況是直接將 id 傳遞到 selector,而不是依賴於另一個 atom。

export const todoQuery = selectorFamily<{ title: string }, { id: number }>({
  key: "todo",
  get: ({ id }) => async ({ get }) => {
    const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
    const todos = res.json();
    return todos;
  },
});

App.tsx

export function App() {
  return (
    <div className="app">
      <Suspense fallback="loading...">
        <TodoInfo id={1} />
        <TodoInfo id={2} />
        <TodoInfo id={3} />
      </Suspense>
    </div>
  );
}

請求的重新整理

selector 是冪等的,固定輸入會得到固定的輸出。即,拿上述情況舉例,對於給定的入參 id,其輸出永遠一樣。根據這個我,Recoil 預設會對請求的返回進行快取,在後續的請求中不會實際觸發請求。

這能滿足大部分場景,提升效能。但也有些情況,我們需要強制觸發重新整理,比如內容被編輯後,需要重新拉取。

有兩種方式來達到強制重新整理的目的,讓請求依賴一個人為的 RequestId,或使用 Atom 來存放請求結果,而非 selector。

RequestId

一是讓請求的 selector 依賴於另一個 atom,可把這個 atom 作為每次請求唯一的 ID 亦即 RequestId。

appState.ts

export const todoRequestIdState = atom({
  key: "todoRequestIdState",
  default: 0,
});

讓請求依賴於上面的 atom:

export const todoQuery = selectorFamily<{ title: string }, { id: number }>({
  key: "todo",
  get: ({ id }) => async ({ get }) => {
+   get(todoRequestIdState); // 新增對 RequestId 的依賴
    const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
    const todos = res.json();
    return todos;
  },
});

然後在需要重新整理請求的時候,更新 RequestId 即可。

App.tsx

export function App() {
  const setTodoRequestId = useSetRecoilState(todoRequestIdState);
  const refreshTodoInfo = useCallback(() => {
    setTodoRequestId((prev) => prev + 1);
  }, [setTodoRequestId]);

  return (
    <div className="app">
      <Suspense fallback="loading...">
        <TodoInfo id={1} />
        <TodoInfo id={2} />
        <TodoInfo id={3} />
        <button onClick={refreshTodoInfo}>refresh todo 1</button>
      </Suspense>
    </div>
  );
}

目前為止,雖然實現了請求的重新整理,但觀察發現,這裡的重新整理沒有按資源 ID 來進行區分,點選重新整理按鈕後所有資源都重新傳送了請求。

refresh_without_id mov

替換 atom 為 atomFamily 為其增加外部入參,這樣可根據引數來決定重新整理,而不是粗獷地全刷。

- export const todoRequestIdState = atom({
+ export const todoRequestIdState = atomFamily({
    key: "todoRequestIdState",
    default: 0,
  });

export const todoQuery = selectorFamily<{ title: string }, { id: number }>({
  key: "todo",
  get: ({ id }) => async ({ get }) => {
-   get(todoRequestIdState(id)); // 新增對 RequestId 的依賴
+   get(todoRequestIdState(id)); // 新增對 RequestId 的依賴
    const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
    const todos = res.json();
    return todos;
  },
});

更新 RequestId 時傳遞需要更新的資源:

export function App() {
- const setTodoRequestId = useSetRecoilState(todoRequestIdState);
+  const setTodoRequestId = useSetRecoilState(todoRequestIdState(1)); // 重新整理 id 為 1 的資源
  const refreshTodoInfo = useCallback(() => {
    setTodoRequestId((prev) => prev + 1);
  }, [setTodoRequestId]);

  return (
    <div className="app">
      <Suspense fallback="loading...">
        <TodoInfo id={1} />
        <TodoInfo id={2} />
        <TodoInfo id={3} />
        <button onClick={refreshTodoInfo}>refresh todo 1</button>
      </Suspense>
    </div>
  );
}

refresh_with_id mov

上面重新整理函式中寫死了資源 ID,真實場景下,你可能需要寫個自定義的 hook 來接收引數。

const useRefreshTodoInfo = (id: number) => {
  const setTodoRequestId = useSetRecoilState(todoRequestIdState(id));
  return () => {
    setTodoRequestId((prev) => prev + 1);
  };
};

export function App() {
  const [id, setId] = useState(1);
  const refreshTodoInfo = useRefreshTodoInfo(id);

  return (
    <div className="app">
      <label htmlFor="todoId">
        select todo:
        <select
          id="todoId"
          value={String(id)}
          onChange={(e) => {
            setId(Number(e.target.value));
          }}
        >
          <option value="1">1</option>
          <option value="2">2</option>
          <option value="3">3</option>
        </select>
      </label>
      <Suspense fallback="loading...">
        <TodoInfo id={id} />
        <button onClick={refreshTodoInfo}>refresh todo</button>
      </Suspense>
    </div>
  );
}

userefresh mov

使用 Atom 存放請求結果

首先將獲取 todo 的邏輯抽取單獨的方法,方便在不同地方呼叫,

export async function getTodo(id: number) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  const todos = res.json();
  return todos;
}

通過 atomFamily 建立一個存放請求結果的狀態:

export const todoState = atomFamily<any, number>({
  key: "todoState",
  default: (id: number) => {
    return getTodo(id);
  },
});

展示時通過這個 todoState 來獲取 todo 的詳情:

TodoInfo.tsx

export function TodoInfo({ id }: ITodoInfoProps) {
  const todo = useRecoilValue(todoState(id));
  return <div>{todo.title}</div>;
}

在需要重新整理的地方,更新 todoState 即可:

App.tsx

function useRefreshTodo(id: number) {
  const refreshTodoInfo = useRecoilCallback(({ set }) => async (id: number) => {
    const todo = await getTodo(id);
    set(todoState(id), todo);
  });
  return () => {
    refreshTodoInfo(id);
  };
}

export function App() {
  const [id, setId] = useState(1);
  const refreshTodo = useRefreshTodo(id);
  return (
    <div className="app">
      ...
      <Suspense fallback="loading...">
        <TodoInfo id={id} />
        <button onClick={refreshTodo}>refresh todo</button>
      </Suspense>
    </div>
  );
}

注意,因為請求回來之後更新的是 Recoil 狀態,所以需要在 useRecoilCallback 中進行。

異常處理

前面的使用展示了 Recoil 與 React Suspense 結合用起來是多少順滑,介面上的載入態就像呼吸一樣自然,完全不需要編寫額外邏輯就可獲得。但還缺少錯誤處理。即,這些來自 Recoil 的非同步資料請求出錯時,介面上需要呈現。

而結合 React Error Boundaries 可輕鬆處理這一場景。

ErrorBoundary.tsx

import React, { ReactNode } from "react";

// Error boundaries currently have to be classes.

/**
 * @see https://reactjs.org/docs/error-boundaries.html
 */
export class ErrorBoundary extends React.Component<
  {
    fallback: ReactNode,
    children: ReactNode,
  },
  { hasError: boolean, error: Error | null }
> {
  state = { hasError: false, error: null };
  // eslint-disable-next-line @typescript-eslint/member-ordering
  static getDerivedStateFromError(error: any) {
    return {
      hasError: true,
      error,
    };
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

在所有需要錯誤處理的地方使用即可,理論上亦即所有出現 <Suspense> 的地方:

App.tsx

<ErrorBoundary fallback="error :(">
  <Suspense fallback="loading...">
    <TodoInfo id={id} />
    <button onClick={refreshTodo}>refresh todo</button>
  </Suspense>
</ErrorBoundary>

ErrorBoudary 中展示錯誤詳情

上面的 ErrorBoundary 元件來自 React 官方文件,稍加改良可讓其支援在錯誤處理時展示錯誤的詳情:

ErrorBoundary.tsx

export class ErrorBoundary extends React.Component<
  {
    fallback: ReactNode | ((error: Error) => ReactNode);
    children: ReactNode;
  },
  { hasError: boolean; error: Error | null }
> {
  state = { hasError: false, error: null };
  // eslint-disable-next-line @typescript-eslint/member-ordering
  static getDerivedStateFromError(error: any) {
    return {
      hasError: true,
      error,
    };
  }
  render() {
    const { children, fallback } = this.props;
    const { hasError, error } = this.state;
    if (hasError) {
      return typeof fallback === "function" ? fallback(error!) : fallback;
    }
    return children;
  }
}

使用時接收錯誤引數並進行展示:

App.tsx

<ErrorBoundary fallback={(error: Error) => <div>{error.message}</div>}>
  <Suspense fallback="loading...">
    <TodoInfo id={id} />
    <button onClick={refreshTodo}>refresh todo</button>
  </Suspense>
</ErrorBoundary>

需要注意的問題

selector 的巢狀與 Promise 的問題

使用過程中遇到一個 selector 巢狀時 Promise 支援得不好的 bug,詳見 Using an async selector in another selector, throws an Uncaught promise #694

正如 bug 中所說,當 selector 返回非同步資料,其他 selector 依賴於這個 selector 時,後續的 selector 會報 Uncaught (in promise) 的錯誤。

不過我發現,如果在後續 selector 中不使用 async 而是直接返回原始的 Promise 可以臨時規避這一問題。

React Suspense 的 bug

當使用文章前面提到的重新整理功能時,資料重新整理後,Suspense 中元件重新渲染,特定操作下會報 Unable to find node on an unmounted component. 的錯誤。經後續定位與 Recoil 無關,實為 React Suspense 的 bug,已在 16.9 及之後的版本修復。

Fix a crash inside findDOMNode for components wrapped in . (@acdlite in #15312)
-- React 16.9 release change log 中的記錄

相關資源

The text was updated successfully, but these errors were encountered:

相關文章