通過簡單的計數器應用來展示其使用。先來看沒有 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>
);
}
可以看到,數據被提升到了父組件中進行管理,而對數據的操作,也一並進行了提升,子組件中只負責觸發改變數據的動作 onAdd
,onSubtract
,而真實的加減操作則從父組件傳遞下去。
這無疑增加了父組件的負擔,一是這樣的邏輯上升沒有做好組件功能的內聚,二是父組件在最后會沉淀大量這種上升的邏輯,三是這種上升的操作不適用於組件深層嵌套的情況,因為要逐級傳遞屬性。
當然,這里可使用 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>;
}
不使用 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 來進行區分,點擊刷新按鈕后所有資源都重新發送了請求。
替換 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>
);
}
上面刷新函數中寫死了資源 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>
);
}
使用 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 中的記錄
相關資源
- Recoil
- React Doc -
useContext
- Error Boundaries
- Using an async selector in another selector, throws an Uncaught promise #694
The text was updated successfully, but these errors were encountered: