Recoil 的使用


 

通過簡單的計數器應用來展示其使用。先來看沒有 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:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM