通过简单的计数器应用来展示其使用。先来看没有 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> ); }; 跨组件共享数据状态当想把 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> ); } 可以看到,数据被提升到了父组件中进行管理,而对数据的操作,也一并进行了提升,子组件中只负责触发改变数据的动作 这无疑增加了父组件的负担,一是这样的逻辑上升没有做好组件功能的内聚,二是父组件在最后会沉淀大量这种上升的逻辑,三是这种上升的操作不适用于组件深层嵌套的情况,因为要逐级传递属性。 当然,这里可使用 Context 来解决。 使用 Context 进行数据状态的共享添加 Context 文件保存需要共享的状态: appContext.ts import { createContext } from "react"; export const AppContext = createContext({ count: 0, updateCount: (val: number) => {}, }); 注意这里创建 Context 时,为了让子组件能够更新 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 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 export const Display = () => { const { count } = useContext(AppContext); return <div>{count}</div>; }; 可以看出,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 需要将子组件包裹到 ReactDOM.render( <React.StrictMode> <RecoilRoot> <App /> </RecoilRoot> </React.StrictMode>, document.getElementById("root") ); Atom & SelectorRecoil 中最小的数据元作为 Atom 存在,从 Atom 可派生出其他数据,比如这里 创建 state 文件用于存放这些 Recoil 原子数据: appState.ts import { atom } from "recoil"; export const countState = atom({ key: "countState", default: 0, }); 通过 selector 可从基本的 atom 中派生出新的数据,假如还需要展示一个当前 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 原生的 import { useRecoilState } from "recoil"; ... const [count, setCount] = useRecoilState(countState) ... 当只需要使用值而不需要对值进行修改时,可使用 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 创建的数据,在使用上无任何区别。 当只需要对值进行设置,而又不进行展示时,则可使用 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>; } 但由于上面 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,此时需要使用 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,因为 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); }, }); 展示时通过这个 TodoInfo.tsx export function TodoInfo({ id }: ITodoInfoProps) { const todo = useRecoilValue(todoState(id)); return <div>{todo.title}</div>; } 在需要刷新的地方,更新 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; } } 在所有需要错误处理的地方使用即可,理论上亦即所有出现 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 会报 不过我发现,如果在后续 selector 中不使用 React Suspense 的 bug当使用文章前面提到的刷新功能时,数据刷新后,Suspense 中组件重新渲染,特定操作下会报
相关资源 |
The text was updated successfully, but these errors were encountered: |