Recoil 的使用

 

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

首先建立示例項目html

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

計數器

考察以下計數器組件:node

Counter.tsxreact

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 的展現放到其餘組件時,就涉及到跨組件共享數據狀態的問題,通常地,能夠將須要共享的狀態向上提取到父組件中來實現。git

Counter.tsxgithub

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.tsxtypescript

export interface IDisplayProps {
  count: number;
}

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

App.tsxshell

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,而真實的加減操做則從父組件傳遞下去。json

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

固然,這裏可以使用 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:

相關文章
相關標籤/搜索