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:

相关文章
相关标签/搜索