• 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:

    CC BY-NC-SA 署名-非商业性使用-相同方式共享
  • 相关阅读:
    JS判断是否是ioS或者Android
    React+dva多图片上传
    Nginx的虚拟主机
    Nginx的动静分离
    Nginx的负载均衡
    Nginx的静态代理
    Java内存模型
    系统学习笔记漏掉的部分
    异常的统一处理
    webpack学习指南
  • 原文地址:https://www.cnblogs.com/Wayou/p/14635285.html
Copyright © 2020-2023  润新知