通过简单的计数器应用来展示其使用。先来看没有 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>;
}

不使用 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 中的记录

相关资源

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

Recoil 的使用的更多相关文章

  1. 14072202(带IK的Recoil)

    [目标] 带IK的Recoil [思路] 1 继承于USkelControlLimb和UGameSkelCtrl_Recoil 2 效果对比  以这个骨骼为例 Recoil Limb 可见,Recoi ...

  2. JELLY技术周刊 Vol.24 -- 技术周刊 &#183; 实现 Recoil 只需百行代码?

    蒲公英 · JELLY技术周刊 Vol.24 理解一个轮子最好的方法就是仿造一个轮子,很多框架都因此应运而生,比如面向 JS 开发者的 AI 工具 Danfo.js:参考 qiankun 的微前端框架 ...

  3. Recoil & React official state management

    Recoil & React official state management Redux Recoil.js https://recoiljs.org/ A state managemen ...

  4. Recoil 默认值及数据级联的使用

    Recoil 中默认值及数据间的依赖 通过 Atom 可方便地设置数据的默认值, const fontSizeState = atom({ key: 'fontSizeState', default: ...

  5. Recoil 中多级数据联动及数据重置的合理做法

    前情回顾 书接上回,前面引出了在数据存在级联的情况下,各下拉框之间的默认值及值变化的处理.简单回顾一下: 场景是: 地域下拉决定可选的可用区 默认选中第一个地域,通过设置 atom 的 default ...

  6. Recoil 中默认值的正确处理

    继续使用 Recoil 默认值及数据级联的使用 的地域可用区级联的例子. 地域变更后可用区随之联动,两个下拉框皆默认选中第一个可选项. 从 URL 获取默认值 考虑这种情况,当 URL 中带了 que ...

  7. Recoil Input 光标位置被重置到末尾的问题

    考察如下代码,页面中有个输入框,通过 Recoil Atom 来存储输入的值. App.tsx function NameInput() { const [name, setName] = useRe ...

  8. 14073102(CCDIKRecoil)

    [目标] CCDIKRecoil [思路] 1 CCDIK和Recoil的结合 2 Recoil的回弹机制,逐渐回到原来位置 3 添加一个Recoil基类 [步骤] 1 将\Src\GameFrame ...

  9. apex-utility-ai-unity-survival-shooter

    The AI has the following actions available: Action Function Shoot Fires the Kalashnikov Reload Reloa ...

随机推荐

  1. npm & package.json & directories & files

    npm & package.json & directories & files package.json https://docs.npmjs.com/files/packa ...

  2. React Hooks +React Context vs Redux

    React Hooks +React Context vs Redux https://blog.logrocket.com/use-hooks-and-context-not-react-and-r ...

  3. lerna

    lerna A tool for managing JavaScript projects with multiple packages. https://lerna.js.org/ https:// ...

  4. React & redux-saga & effects & Generator function & React Hooks

    React & redux-saga & effects & Generator function & React Hooks demos https://github ...

  5. js & for & for of & for in & forEach, break

    js & for & for of & for in & forEach, break js for break https://stackoverflow.com/q ...

  6. vscode Paste Image插件使用

    Paste Image 在编写md需要插入图片,这个插件可以将粘贴板的图片保存到本地资源 假如我在/readme.md中编写文档,我需要将粘贴板的图片放在/images/下面,配置两个关键配置即可: ...

  7. Nodejs 使用 bcrypt 库加密和验证密码

    bcrypt install λ cnpm i bcrypt -S λ cnpm install --save @types/bcrypt example import * as bcrypt fro ...

  8. 「NGK每日快讯」2021.1.21日NGK公链第79期官方快讯!

  9. 18_MySQL之HAVING字句的使用

    本节涉及的sql语句: -- HAVING -- 错误示例 SELECT deptno FROM t_emp WHERE AVG(sal)>=2000 GROUP BY deptno; 因为wh ...

  10. teamviewer远程是账号密码都没错但是报正在初始化参数...

    1.出现这个原因,可能是 通过(mstsc)远程桌面方式运行了teamviewer,被远程控制电脑就会出现这个现象. 可以试一下 服务-teamviewer-属性-登录-本地系统账户 -允许服务与桌面 ...