[译]如何用React Hooks获取数据

1,357 阅读19分钟

如何用React Hooks获取数据

前言

  • 注意:笔者将会使用TypeScript来完成文章中的代码,并使用ant design来美化界面样式
  • 如果文章对你有用的话,欢迎`star`

在这个教程中,我想向您展示如何通过stateeffect钩子来获取数据。我们将会使用广为人知的Hacker News API从科技世界获取流行文章。通过这篇文章,你也能为数据获取实现自己的自定义hook,它可以在你的应用中的任何地方被复用或者发布到npm作为一个独立的node包。

如果你完全不了解这个React新特性,查阅这里:introduction to React Hooks。如果你想要查阅如何在React中使用hooks来获取数据示例的完成项目,可以查阅这个GitHub仓库

如果你只想准备去使用React Hook来获取数据:执行npm install use-data-api并且跟随文档来使用。如果你用到了它,别忘记为它点一个star

注意:在未来,React并不打算用React Hooks来获取数据。相反的,一个叫做Suspense的特性将会负责这件事。不过,下列的讲解是一个很好的用来学习更多关于Reactstateeffect hooks内容的方式。

用`React Hooks`来获取数据

如果你不熟悉React中的数据获取,可以查阅我的文章React中如何数据获取。它将带你了解如何使用React类组件来进行数据获取,如何使用Render Prop Components高阶组件(Higher-Order Component s)来使代码变的可复用,如何进行错误处理和加载loading状态。在这篇文章,我想用函数组件中的React Hooks来向你展示这些内容。

import React, { useEffect, useState } from 'react';

const App: React.FC = () => {
  const [data, setData] = useState<{ hits: any[] }>({ hits: [] });
  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectId}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>

  );
};
export default App;

App组件展示了所有项目的列表(hits = Hacker News articles)。state和更新state的函数来自于一个叫做useStatehook,它负责管理我们要为App组件取回数据的局部状态。初始状态用一个对象中空的hits数组来表示列表数据,还没有人为该数据设置任何状态。

我们将要使用axios来获取数据,但是想要使用其它的数据获取库或者浏览器原生的fetch API将取决于你。如果你还没有安装axios, 你可以通过命令行使用npm install axios来做这件事,然后实现获取数据的effect hook

import React, { useEffect, useState } from 'react';
import axios from 'axios';

const App: React.FC = () => {
  const [data, setData] = useState<{ hits: any[] }>({ hits: [] });
  useEffect(async() => {
    const result = await axios('https://hn.algolia.com/api/v1/search?query=react');
    setData(result.data);
  });
  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectId}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>

  );
};
export default App;

effect hook叫做useEffect,它可以用axiosAPI获取数据并且使用组件state hook的更新函数来设置该组件局部状态的数据。这里我们用async/await来解决异步的Promise

然而,当你运行你的应用的时候,你应该会陷入一个讨厌的循环。effect hook不仅会在组件挂载后运行,也会在组件更新后运行。由于我们在获取数据后使用setData设置了state,这会更新组件并且使effect再次运行。而当effect再次运行的时候,它又会再次获取数据并调用setData,周而复始。这是一个需要避免的bug我们只想在组件渲染后获取数据。这就是为什么要为effect hook传一个空数组([])作为第二个参数的原因:避免effect hook在组件更新后激活而仅仅是在组件挂载后激活。

import React, { useEffect, useState } from 'react';
import axios from 'axios';

const App: React.FC = () => {
  const [data, setData] = useState<{ hits: any[] }>({ hits: [] });
  useEffect(async() => {
    const result = await axios('https://hn.algolia.com/api/v1/search?query=react');
    setData(result.data);
  },[]);
  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectId}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>

  );
};
export default App;

第二个参数用来定义hook依赖的所有变量(分配给这个数组的)。如果其中的一个变量发生改变,hook将会再次运行。如果数组中的的变量为空,hook在组件更新后将不再运行,因为它没有必要监测任何变量。

还有最后一个陷阱。在代码中,我们使用async/await从第三方API获取数据。按照文档所说,每个用async标注的函数都会返回一个隐式的promise: "async函数声明定义一个返回AsyncFunction对象的异步函数。异步函数是通过事件循环异步执行并且使用一个隐式的promise返回结果的函数"。然而,一个effect hook应该什么都不返回或者返回一个清理函数。这就是为什么你可能会在你的开发者控制台日志看到如下警告:07:41:22.910 index.js Warning: useEffect function must return a cleanup function or nothing. Promises and useEffect(async() => …) are not supported, but you can call an async function inside an effect(useEffect函数必须返回一个清理函数或者什么都不返回。PromisesuseEffect(async() => ...))是不支持的,但是你可以在effect内部调用一个async函数).这也是为什么在useEffect函数里直接使用async关键字不被允许的原因。让我们为这种情况想一种变通方案:在effect内部使用async函数。(译注者:下边的代码使用了ant design,之后不再提示)

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Card, List } from 'antd';
import { IData } from '@/responseTypes';

const App: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios('https://hn.algolia.com/api/v1/search?query=react');
      setData({ hits: result.data.hits });
    };
    fetchData().then();
  }, []);
  return (
    <Card bordered={false}>
      <List dataSource={data.hits} renderItem={(item) => (
        <List.Item>
          <a href={item.url}>{item.title}</a>
        </List.Item>
      )}/>
    </Card>
  );
};
export default App;

译注者:hacker news的响应数据TypeScript类型如下:

// responseTypes.ts
export interface IData {hits: IHit[];}

interface IHit {
  title: string;
  url: string;
  author: string;
  points: number;
  story_text?: any;
  comment_text?: any;
  _tags: string[];
  num_comments: number;
  objectID: string;
  _highlightResult: IHighlightResult;
}

interface IHighlightResult {
  title: ITitle;
  url: ITitle;
  author: IAuthor;
}

interface IAuthor {
  value: string;
  matchLevel: string;
  matchedWords: string[];
}

interface ITitle {
  value: string;
  matchLevel: string;
  matchedWords: any[];
}

简单来说,这就是使用React hooks来获取数据。但是如果你对错误处理、展示loading状态、如何触发从表单获取数据、以及如何实现一个可复用的数据获取hook感兴趣的话,请继续阅读。

如何手动地/编程式地触发一个`Hook`

很好,一旦组件挂载我们就会去获取数据。但是使用一个输入字段来告诉API我们对哪个话题感兴趣呢?"Redux"被作为默认查询,但是关于"React"的话题呢?接下来,让我们实现一个input输入框,能够让用户获取除"Redux"外的其它相关文章。因此,我们为输入元素引入一个新的状态。

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const App: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios('https://hn.algolia.com/api/v1/search?query=redux');
      setData({ hits: result.data.hits });
    };
    fetchData().then();
  }, []);
  return (
    <Card bordered={false}>
      <Input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <List dataSource={data.hits} renderItem={(item) => (
        <List.Item>
          <a href={item.url}>{item.title}</a>
        </List.Item>
      )}/>
    </Card>
  );
};
export default App;

此时,俩个状态彼此之间互相独立,但是现在你想要组合它们只获取输入框中指定查询条件的文章数据。一旦组件挂载后,应该通过查询项来获取所有文章。

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const App: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(`https://hn.algolia.com/api/v1/search?query=${query}`);
      setData({ hits: result.data.hits });
    };
    fetchData().then();
  }, []);
  return (
    <Card bordered={false}>
      <Input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <List dataSource={data.hits} renderItem={(item) => (
        <List.Item>
          <a href={item.url}>{item.title}</a>
        </List.Item>
      )}/>
    </Card>
  );
};
export default App;

这里有一块儿被忽略了:当你尝试在输入框中输入一些文字后,组件重新渲染后并没有进行数据获取。那是因为你提供了空数组作为effect的第二个参数,effect不依赖于任何变量,因此它只会在组件挂载后触发。然而,effect现在应该依赖于query,一旦query改变,数据请求将会再次触发。

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const App: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(`https://hn.algolia.com/api/v1/search?query=${query}`);
      setData({ hits: result.data.hits });
    };
    fetchData().then();
  }, [query]);
  return (
    <Card bordered={false}>
      <Input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <List dataSource={data.hits} renderItem={(item) => (
        <List.Item>
          <a href={item.url}>{item.title}</a>
        </List.Item>
      )}/>
    </Card>
  );
};
export default App;

一旦你改变了输入框中的值数据将会重新获取。但是这也引发了另外一个问题:在你每为输入框输入一个字符的时候,effect将会被触发并且执行另一次数据获取请求。那么如何提供一个按钮来触发请求从而手动的地执行hook呢?

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Button, Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const App: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [search, setSearch] = useState('redux');
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(`https://hn.algolia.com/api/v1/search?query=${search}`);
      setData({ hits: result.data.hits });
    };
    fetchData().then();
  }, [search]);
  return (
    <Card bordered={false}>
      <Input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <Button onClick={() => setSearch(query)}>Search</Button>
      <List dataSource={data.hits} renderItem={(item) => (
        <List.Item>
          <a href={item.url}>{item.title}</a>
        </List.Item>
      )}/>
    </Card>
  );
};
export default App;

现在,我们使effect依赖于search状态而不是随着在输入框中每一次击键而发生变化的query状态。一旦用户点击按钮,新的search状态被设置并且手动地触发effect hook

search状态的初始值也被设置为和query状态一样。因为组件也在挂载后获取数据,并且结果应该和输入框中的值作为查询条件获取到的数据相同。然而,具有类似的querysearch状态有一点让人疑惑。为什么不设置当前的请求地址作为状态来替代search状态?

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Button, Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const App: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState('https://hn.algolia.com/api/v1/search?query=redux');
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(url);
      setData({ hits: result.data.hits });
    };
    fetchData().then();
  }, [url]);
  return (
    <Card bordered={false}>
      <Input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <Button onClick={() => setUrl(`https://hn.algolia.com/api/v1/search?query=${query}`)}>Search</Button>
      <List dataSource={data.hits} renderItem={(item) => (
        <List.Item>
          <a href={item.url}>{item.title}</a>
        </List.Item>
      )}/>
    </Card>
  );
};
export default App;

上面的例子就是用effect hook编程地/手动地获取数据。你可以决定effect依赖于哪一个状态。一旦你在点击事件或在其它的副作用(useEffect)中设置状态(setState),对应的effect将会再次运行。在上边的例子中,如果url状态发生改变,effect将会再次运行并从API中获取文章数据。

用`React Hooks`来显示`loading`

让我们继续介绍数据获取时的loading展示。本质上,loading只是被state hook所管理的另外一个state。在App组件中,loading标识用来渲染一个loading指示器。

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Button, Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const App: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState('https://hn.algolia.com/api/v1/search?query=redux');
  const [isLoading, setIsLoading] = useState(false);
  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true);
      const result = await axios(url);
      setData({ hits: result.data.hits });
      setIsLoading(false);
    };
    fetchData().then();
  }, [url]);
  return (
    <Card bordered={false}>
      <Input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <Button onClick={() => setUrl(`https://hn.algolia.com/api/v1/search?query=${query}`)}>Search</Button>
      <List dataSource={data.hits} loading={isLoading} renderItem={(item) => (
        <List.Item>
          <a href={item.url}>{item.title}</a>
        </List.Item>
      )}/>
    </Card>
  );
};
export default App;

当组件挂载或者url状态发生改变,effect被调用来获取数据,loading状态将被设置为true。一旦请求成功获取到数据,loading状态将会再次被设置为false

用`React Hooks`处理错误

怎样用React hook来为数据获取进行错误处理呢?error只是用state hook初始化的另外一个state,一旦有一个错误状态,App组件会为用户渲染错误页面。当使用async/await的时候,使用try/catch来进行错误处理是常见的,你可以在effect里来做这件事。

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Button, Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const FetchData: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState('https://hn.algolia.com/api/v1/search?query=redux');
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);
      try {
        const result = await axios(url);
        setData({ hits: result.data.hits });
      } catch (e) {
        setIsError(true);
      }
      setIsLoading(false);
    };
    fetchData().then();
  }, [url]);
  return (
    <Card bordered={false}>
      <Input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <Button onClick={() => setUrl(`https://hn.algolia.com/api/v1/search?query=${query}`)}>Search</Button>
      {isError ?
        <div>something went wrong...</div>
        :
        <List dataSource={data.hits} loading={isLoading} renderItem={(item) => (
          <List.Item>
            <a href={item.url}>{item.title}</a>
          </List.Item>
        )}/>}
    </Card>
  );
};
export default FetchData;

每次hooks再次运行的时候错误状态将会被重置。这是有用的,因为在请求失败后用户可能想要再次尝试,这个时候应该重置错误。为了强行制造一个错误你可以将url改为某些无效的值,然后检查错误信息是否展示。

在`React`中从表单获取数据

怎样用一个合适的表单来获取数据呢?目前为止,我们只有输入框和按钮进行组合。一旦你想要引入更多的input元素,你可能想要用一个form元素来包裹它们。此外,form也可以让你用回车键来触发search按钮。

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Button, Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const App: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState('https://hn.algolia.com/api/v1/search?query=redux');
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);
      try {
        const result = await axios(url);
        setData({ hits: result.data.hits });
      } catch (e) {
        setIsError(true);
      }
      setIsLoading(false);
    };
    fetchData().then();
  }, [url]);
  return (
    <Card bordered={false}>
      <form onSubmit={(e) => {
        setUrl(`https://hn.algolia.com/api/v1/search?query=${query}`);
      }}>
        <Input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
        />
        <Button htmlType="submit">Search</Button>
      </form>
      {isError ?
        <div>something went wrong...</div>
        :
        <List dataSource={data.hits} loading={isLoading} renderItem={(item) => (
          <List.Item>
            <a href={item.url}>{item.title}</a>
          </List.Item>
        )}/>}
    </Card>
  );
};
export default App;

但是现在当点击提交按钮的时候,浏览器将会重新加载,这是在提交一个表单时浏览器的默认行为。为了阻止默认行为,我们要在React事件内调用一个函数。在React类组件中你也可以这样做。

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Button, Card, Input, List } from 'antd';
import { IData } from '@/responseTypes';

const FetchData: React.FC = () => {
  const [data, setData] = useState<IData>({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState('https://hn.algolia.com/api/v1/search?query=redux');
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);
      try {
        const result = await axios(url);
        setData({ hits: result.data.hits });
      } catch (e) {
        setIsError(true);
      }
      setIsLoading(false);
    };
    fetchData().then();
  }, [url]);
  return (
    <Card bordered={false}>
      <form onSubmit={(e) => {
        e.preventDefault();
        setUrl(`https://hn.algolia.com/api/v1/search?query=${query}`);
      }}>
        <Input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
        />
        <Button htmlType="submit">Search</Button>
      </form>
      {isError ?
        <div>something went wrong...</div>
        :
        <List dataSource={data.hits} loading={isLoading} renderItem={(item) => (
          <List.Item>
            <a href={item.url}>{item.title}</a>
          </List.Item>
        )}/>}
    </Card>
  );
};
export default FetchData;

现在,当你点击提交按钮后浏览器不再会重新加载。它和之前一样工作,但这时是一个表单而不是原生输入框和按钮的组合,你也可以按下你键盘上的回车键来提交表单内容。

自定义数据获取`Hook`

为了为获取数据提取自定义hook,除了属于输入框的query状态,移动包括loading展示和错误处理在内所有属于数据获取的代码到它自己的函数中。当然,也要确保从函数中返回所有在App组件中所用到的必须的变量。

初始状态也能变得通用,只需要简单地将它传给新的自定义hook

import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import axios from 'axios';

interface IResult<T> {
  data: T;
  isLoading: boolean;
  isError: boolean;
}

const useHackerNewsApi = <T extends any> (initialData: T, initialUrl: string): [IResult<T>, Dispatch<SetStateAction<string>>] => {
  const [data, setData] = useState<T>(initialData);
  const [url, setUrl] = useState(initialUrl);
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);
      try {
        const result = await axios(url);
        setData(result.data);
      } catch (e) {
        setIsError(true);
      }
      setIsLoading(false);
    };
    fetchData().then();
  }, [url]);
  return [{ data, isLoading, isError }, setUrl];
};

export default useHackerNewsApi;

Tip: tsx中箭头函数定义泛型的语法

现在,你的新hook可以在App组件中再次使用:

import React, { useState } from 'react';
import { Button, Card, Input, List } from 'antd';
import useHackerNewsApi from '@/views/fetchData/useHackerNewsApi';
import { IData } from '@/responseTypes';

const FetchData: React.FC = () => {
  const [query, setQuery] = useState('redux');
  const [{ data, isLoading, isError }, setUrl] = useHackerNewsApi<IData>({ hits: [] }, 'https://hn.algolia.com/api/v1/search?query=redux');
  return (
    <Card bordered={false}>
      <form onSubmit={(e) => {
        e.preventDefault();
        setUrl(`https://hn.algolia.com/api/v1/search?query=${query}`);
      }}>
        <Input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
        />
        <Button htmlType="submit">Search</Button>
      </form>
      {isError ?
        <div>something went wrong...</div>
        :
        <List dataSource={data.hits} loading={isLoading} renderItem={(item) => (
          <List.Item>
            <a href={item.url}>{item.title}</a>
          </List.Item>
        )}/>}
    </Card>
  );
};
export default FetchData;

这就是用一个自定义hook来获取数据。hook本身并不知道关于API的任何内容,它接收所有来自外部的参数,并且只管理必要的状态,比如:data,loading,error。它就像自定义请求数据hook一样为使用到它的组件发起请求并且返回数据。

使用`reducer hook`获取数据

到目前为止,我们已经使用多个state hook来管理我们的数据获取状态data,加载状态loading和错误状态error。然而,用单独的state hook管理的所有这些状态都应该属于同一类,因为它们关心相同的问题。正如你看到的,他们都在数据获取函数中被用到。它们是一个接一个地使用的(比如:setIsError,setIsLoading),这可以很好的表明它们是在一起的。让我们将三个状态全部与Reducer Hook结合使用。

Reducer Hook为我们返回一个state对象以及一个更改state对象的函数。这个函数叫做派发(dispatch)函数,它接收一个拥有type和可选的payloadaction作为参数。所有的这些信息用来从之前的状态以及action的可选的payloadtype来提取一个新的state。让我们看一下这在代码中是如何工作的:

import React, {
  Fragment,
  useState,
  useEffect,
  useReducer,
from 'react';
import axios from 'axios';
const dataFetchReducer = (state, action) => {
  ...
};
const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoadingfalse,
    isErrorfalse,
    data: initialData,
  });
  ...
};

Reducer hook接受reducer函数和一个初始的state对象作为参数。在我们的例子中,data,loadingerror状态的初始参数是不会变化的,但是它们被聚合到了一个状态对象,通过一个reducer hook代替每个单独的state hook

const dataFetchReducer = (state, action) => {
  ...
};
const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoadingfalse,
    isErrorfalse,
    data: initialData,
  });
  useEffect(() => {
    const fetchData = async () => {
      dispatch({ type'FETCH_INIT' });
      try {
        const result = await axios(url);
        dispatch({ type'FETCH_SUCCESS'payload: result.data });
      } catch (error) {
        dispatch({ type'FETCH_FAILURE' });
      }
    };
    fetchData();
  }, [url]);
  ...
};

现在,当数据获取的时候,dispatch函数会发送信息到reducer函数。使用dispatch函数发送的对象有一个必需的type属性和一个可选的payload属性。type将会告诉reducer函数哪一个状态转换需要被应用,payload被用来从reducer提取新的state。最终,我们只有三种状态转换:初始化数据获取过程、数据获取结果成功的通知、数据获取结果异常的通知。

在自定义hook的最后,state像之前一样被返回,因为我们有一个state对象而不再是几个独立的state。通过这种方式,调用useDataApi自定义hook的组件仍然可以使用dataisLoadingisError

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoadingfalse,
    isErrorfalse,
    data: initialData,
  });
  ...
  return [state, setUrl];
};

最后很重要的一点,我们少了对reducer函数的实现。它需要处理三种不同的状态转换,分别是FETCH_INIT,FETCH_SUCCESSFETCH_FAILURE。每一种状态转换需要返回一个新的state对象。让我们看看如何通过switch case语句来实现它:

const dataFetchReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_INIT':
      return { ...state };
    case 'FETCH_SUCCESS':
      return { ...state };
    case 'FETCH_FAILURE':
      return { ...state };
    default:
      throw new Error();
  }
};

reducer函数可以通过参数来访问当前的状态state和执行dispatch时传入的action。目前为止,在switch case语句中每一个状态转换只返回了之前的state。展开语法用来保证state对象不可变(意味着state永远不能直接改变)以实施最佳实践。现在让我们覆盖一些当前状态的返回属性来改变每一次状态变换的state

const dataFetchReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_INIT':
      return {
        ...state,
        isLoadingtrue,
        isErrorfalse
      };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        isLoadingfalse,
        isErrorfalse,
        data: action.payload,
      };
    case 'FETCH_FAILURE':
      return {
        ...state,
        isLoadingfalse,
        isErrortrue,
      };
    default:
      throw new Error();
  }
};

现在,通过actiontype决定的每一次状态转换,将会基于之前的状态和可选的payload返回一个新的状态。比如,在一次成功请求的情况下,payload被用来设置新的状态对象的data属性。

总之,Reducer Hook确保这部分状态管理用它自己的逻辑来封装。通过提供action types和可选的payload,你将总会用一个可预测的状态改变来更新state。此外,你将永远不会遇到无效的状态。例如,在这之前,isLoadingisError状态可能会被意外的都设置为true。这种情况下UI应该显示什么呢?现在,通过reducer函数定义的每个状态变换都会指向一个有效的state对象。

译者注:所有的自定义hook的源码如下

import { Dispatch, Reducer, SetStateAction, useEffect, useReducer, useState } from 'react';
import axios from 'axios';

interface IResult<T = any> {
  data: T;
  isLoading: boolean;
  isError: boolean;
}

type IAction<T = any> = {
  type'FETCH_INIT';
} | {
  type'FETCH_SUCCESS';
  payload: T
} | {
  type'FETCH_FAILURE';
}
const dataFetchReducer = <T extends any> (state: IResult<T>, action: IAction<T>): IResult<T> => {
  switch (action.type) {
    case 'FETCH_INIT':
      return { ...state, isLoading: true };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        data: action.payload,
        isLoading: false,
        isError: false
      };
    case 'FETCH_FAILURE':
      return { ...state, isError: true };
    default:
      throw new Error();
  }
};

const useDataApi = <T extends any> (initialData: T, initialUrl: string): [IResult<T>, Dispatch<SetStateAction<string>>] => {
  const [state, dispatch] = useReducer<Reducer<IResult<T>, IAction<T>>>(dataFetchReducer, {
    data: initialData,
    isError: false,
    isLoading: false
  });
  const [url, setUrl] = useState(initialUrl);
  useEffect(() => {
    const fetchData = async () => {
      dispatch({ type: 'FETCH_INIT' });
      try {
        const result = await axios(url);
        dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
      } catch (e) {
        dispatch({ type: 'FETCH_FAILURE' });
      }
    };
    fetchData().then();
  }, [url]);
  return [state, setUrl];
};

export { useDataApi };

在组件中这样使用:

import React, { useState } from 'react';
import { Button, Card, Input, List } from 'antd';
import { useDataApi } from '@/views/fetchData/useHackerNewsApi';
import { IData } from '@/responseTypes';

const FetchData: React.FC = () => {
  const [query, setQuery] = useState('redux');
  const [{ data, isLoading, isError }, setUrl] = useDataApi<IData>({ hits: [] }, 'https://hn.algolia.com/api/v1/search?query=redux');
  return (
    <Card bordered={false}>
      <form onSubmit={(e) => {
        e.preventDefault();
        setUrl(`https://hn.algolia.com/api/v1/search?query=${query}`);
      }}>
        <Input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
        />
        <Button htmlType="submit">Search</Button>
      </form>
      {isError ?
        <div>something went wrong...</div>
        :
        <List dataSource={data.hits} loading={isLoading} renderItem={(item) => (
          <List.Item>
            <a href={item.url}>{item.title}</a>
          </List.Item>
        )}/>}
    </Card>
  );
};
export default FetchData;

在`effect hook`中终止数据获取

即使组件已经卸载(比如由于使用React Router导航离开当前组件)但是组件的状态还是会被设置,这在React中是一个常见的问题。我之前在这里写过关于这个问题的文章,它描述了在各种场景下如何阻止卸载组件设置状态。让我们看一下如何在获取数据的自定义hook中阻止设置状态:

const useDataApi = <T extends any> (initialData: T, initialUrl: string): [IResult<T>, Dispatch<SetStateAction<string>>] => {
  const [state, dispatch] = useReducer<Reducer<IResult<T>, IAction<T>>>(dataFetchReducer, {
    data: initialData,
    isError: false,
    isLoading: false
  });
  const [url, setUrl] = useState(initialUrl);
  useEffect(() => {
    let didCancel = false;
    const fetchData = async () => {
      dispatch({ type: 'FETCH_INIT' });
      try {
        const result = await axios(url);
        if (!didCancel) {
          dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
        }
      } catch (e) {
        if (!didCancel) {
          dispatch({ type: 'FETCH_FAILURE' });
        }
      }
    };
    fetchData().then();
    return () => {
      didCancel = true;
    };
  }, [url]);
  return [state, setUrl];
};

在组件卸载的时候,每一个Effect Hook所对应的清理函数将会运行。清理函数是一个从hook里返回的函数。在我们的例子中,我们使用一个叫做didCancel的布尔值作为标识来让数据获取逻辑知道组件的状态(挂载/卸载)。如果组件已经卸载了,应该将标记设置为true,从而阻止在异步解析数据之后设置组件的状态。

注意:实际上数据获取并没有被终止(终止数据获取可以用Axios Cancellation来实现),但是已经挂载的组件不再执行状态转换。因为Axios Cancellation在我眼中不是最好的API,这个布尔值也能做阻止组件设置状态的工作。