如何在 React Hook 中请求数据?

斜体样式本文大致的翻译了这篇文章

本文将向你展示如何通过 react 的 state 和 effect hook 获取数据。本文将使用 Hacker News API 获取科技类的热门文章,带你实现自定义的 react hook 来获取数据。

react hook 之获取数据

import React, { useState } from 'react';
 
function App() {
  const [data, setData] = useState({ 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 文章)。useState 为函数组件提供状态和状态更新功能,它负责为 App 组件管理数据的局部状态。初始状态是 {hits: []},hits 为空数组。此时没有任何地方修改这个状态。

接下来使用 axios 这个库来获取数据,如果你还未安装,可以通过 npm install axios -S 安装一下。

import React, { useState, useEffect } from 'react';
import axios from 'axios';
 
function App() {
  const [data, setData] = useState({ hits: [] });
 
  useEffect(async () => {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=redux',
    );
 
    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 里通过 axios 请求数据,并通过 state hook 的更新函数 setData 来将请求到的数据设置到组件的局部状态 data 里。
但是这时运行起来的代码应该会陷入死循环,因为组件挂载后会运行 useEffect 里的 axios 代码获取数据,然后更新状态 data,继而引发组件更新,而组件更新后 useEffect 又会再次运行,再次获取数据,再次触发组件更新。如此反复,进入死循环。我们只希望在组件挂载时请求一次数据,后续不再触发。我们可以给 useEffect 的第二个参数传入一个空数组,如下:

import React, { useState, useEffect } from 'react';
import axios from 'axios';
 
function App() {
  const [data, setData] = useState({ hits: [] });
 
  useEffect(async () => {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=redux',
    );
 
    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;

第二个参数是一个数组,用来定义该 useEffect 依赖的变量,只要依赖的变量发生变化,这个 hook 就会再次运行。而如果这个数组是一个空数组,那么这个 hook 不会再组件更新时运行,因为它不依赖任何变量。
到这里还有最后一个问题,我们用了 async / await 来获取数据,按照标准 async 函数会返回一个隐式的 promise。然而,effect hook 不能返回任何任何东西,也不能返回函数。所以你会看到一个提示 Warning: An effect function must not return anything besides a function, which is used for clean-up. 所以直接在 useEffect 里使用 async 函数是不允许的,改下代码,如下:

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

以上就是在 react hook 里获取数据的简单示例。如果你对错误处理、loading 状态以及如何在 form 表单中获取数据和封装一个可复用的数据获取 hook 感兴趣的话,可以继续阅读下去。

如何自动或手动触发获取数据

现在通过这个组件已经能获取数据了,可是如何通过数据关键词来搜索想要的文章呢。我们添加一个输入框,用于输入搜索关键词,为此需要引入新的状态:

import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';
 
function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
 
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        'https://hn.algolia.com/api/v1/search?query=redux',
      );
 
      setData(result.data);
    };
 
    fetchData();
  }, []);
 
  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <ul>
        {data.hits.map(item => (
          <li key={item.objectID}>
            <a href={item.url}>{item.title}</a>
          </li>
        ))}
      </ul>
    </Fragment>
  );
}
 
export default App;

此时两个状态是相互独立的,但是我们想把他们结合起来,搜索想要的文章,做如下改动:

...
 
function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
 
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        `http://hn.algolia.com/api/v1/search?query=${query}`,
      );
 
      setData(result.data);
    };
 
    fetchData();
  }, []);
 
  return (
    ...
  );
}
 
export default App;

这样组件启动就会获取 redux 的文章,接下来给 useEffect 第二个参数传入变量 query,使得 query 改变就发出请求获取相应的文章。

...
 
function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
 
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        `http://hn.algolia.com/api/v1/search?query=${query}`,
      );
 
      setData(result.data);
    };
 
    fetchData();
  }, [query]);
 
  return (
    ...
  );
}
 
export default App;

这里虽然实现了输入搜索功能,不过打字过程会频繁修改 query 的值,导致频繁的发出接口请求。再加一个 按钮来手动触发请求可能会好点,如下:

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [search, setSearch] = useState('');
 
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        `http://hn.algolia.com/api/v1/search?query=${query}`,
      );
 
      setData(result.data);
    };
 
    fetchData();
  }, [query]);
 
  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button type="button" onClick={() => setSearch(query)}>
        Search
      </button>
 
      <ul>
        {data.hits.map(item => (
          <li key={item.objectID}>
            <a href={item.url}>{item.title}</a>
          </li>
        ))}
      </ul>
    </Fragment>
  );
}

现在让 useEffect 依赖 search 状态而不是输入框里频繁变化的值。一旦用户点击按钮,就会更新 search 的值,从而触发 effect hook 去请求数据。

...
 
function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [search, setSearch] = useState('redux');
 
  useEffect(() => {
    const fetchData = async () => {
      const result = await axios(
        `http://hn.algolia.com/api/v1/search?query=${search}`,
      );
 
      setData(result.data);
    };
 
    fetchData();
  }, [search]);
 
  return (
    ...
  );
}
 
export default App;

同样,为了在组件加载时就请求 redux 文章,search state 也被初始化为 redux。不过,这样 querysearch 这两个 state 会让人有点困惑,所以我们直接把 url 设置给 search state 吧。

function App() {
  const [data, setData] = useState({ 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(result.data);
    };
 
    fetchData();
  }, [url]);
 
  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>
 
      <ul>
        {data.hits.map(item => (
          <li key={item.objectID}>
            <a href={item.url}>{item.title}</a>
          </li>
        ))}
      </ul>
    </Fragment>
  );
}

react hook 之 loading 指示器

让我们为数据请求加一个 loading 指示器,只需增加一个状态用来控制 loading 指示器的显示或隐藏。

import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';
 
function App() {
  const [data, setData] = useState({ 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(result.data);
      setIsLoading(false);
    };
 
    fetchData();
  }, [url]);
 
  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>
 
      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}
 
export default App;

每次调用 effect hook(组件挂载或 url 改变时),loading state 会被设置为 true,等请求结束设置为 false。

react hook 之错误处理

错误处理也是同样通过 state hook 来实现,一旦有 error state,组件就会将其反馈给用户。使用 async / await 的时候,一般都会用 try / catch 来捕获异常。

import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';
 
function App() {
  const [data, setData] = useState({ 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(result.data);
      } catch (error) {
        setIsError(true);
      }
 
      setIsLoading(false);
    };
 
    fetchData();
  }, [url]);
 
  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>
 
      {isError && <div>Something went wrong ...</div>}
 
      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}
 
export default App;

每次 effect hook 重新运行都会重置 error state,这在用户发现报错后再次尝试请求数据是很有用。

react hook 之表单获取数据

上面只用了一个 input 和一个 button,如果想加入更多表单控件,可以考虑用一个 form 表单来包起来。而且,一个 form 表单使得可以通过回车来触发 button 发送请求。

function App() {
  ...
 
  return (
    <Fragment>
      <form
        onSubmit={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>
 
      {isError && <div>Something went wrong ...</div>}
 
      ...
    </Fragment>
  );
}

上面的代码有个问题,回车后页面会刷新,因为这是浏览器在表单提交时的默认行为。可以在 onSubmit 里阻止浏览器的默认行为。

function App() {
  ...
 
  return (
    <Fragment>
      <form onSubmit={event => {
        setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`);
 
        event.preventDefault();
      }}>
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>
 
      {isError && <div>Something went wrong ...</div>}
 
      ...
    </Fragment>
  );
}

现在点击提交按钮,浏览器不会再刷新了。

自定义获取数据的 React Hook

为了提取出自定义的 reach hook,将所有除了 query state之外属于数据获取的代码(包括 loading 指示器和错误处理)移入一个函数内。

const useHackerNewsApi = () => {
  const [data, setData] = useState({ hits: [] });
  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(result.data);
      } catch (error) {
        setIsError(true);
      }
 
      setIsLoading(false);
    };
 
    fetchData();
  }, [url]);
 
  return [{ data, isLoading, isError }, setUrl];
}

然后再 App 组件中使用这个 hook:

function App() {
  const [query, setQuery] = useState('redux');
  const [{ data, isLoading, isError }, doFetch] = useHackerNewsApi();
 
  return (
    <Fragment>
      <form onSubmit={event => {
        doFetch(`http://hn.algolia.com/api/v1/search?query=${query}`);
 
        event.preventDefault();
      }}>
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>
 
      ...
    </Fragment>
  );
}

初始值改造得更通用,简单的传参给 hook 函数就行了:

import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';
 
const useDataApi = (initialUrl, initialData) => {
  const [data, setData] = useState(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 (error) {
        setIsError(true);
      }
 
      setIsLoading(false);
    };
 
    fetchData();
  }, [url]);
 
  return [{ data, isLoading, isError }, setUrl];
};
 
function App() {
  const [query, setQuery] = useState('redux');
  const [{ data, isLoading, isError }, doFetch] = useDataApi(
    'https://hn.algolia.com/api/v1/search?query=redux',
    { hits: [] },
  );
 
  return (
    <Fragment>
      <form
        onSubmit={event => {
          doFetch(
            `http://hn.algolia.com/api/v1/search?query=${query}`,
          );
 
          event.preventDefault();
        }}
      >
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>
 
      {isError && <div>Something went wrong ...</div>}
 
      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}
 
export default App;

使用自定义钩子进行数据获取即已完成。hook 本身对 API 一无所知。它从外部接收所有参数,并且仅管理必要的状态,例如数据,加载和错误状态。它执行请求,并将数据用作自定义数据获取 hook,将数据返回给组件。

用于数据提取的 Reduce Hook

到目前为止,我们已经使用各种状态钩子来管理数据获取,包括数据状态,加载状态和错误状态。所有这些状态,虽然各自管理各自的状态,但是因为它们关心相同的原因可以归为一类。如你所见,它们都在用在同个数据获取函数中。可以用来判断它们是否可以归为一类的一个很好的指标,就是它们一个接一个的调用(例如 setIsError,setIsLoading)。让我们将它们三个用一个 Reducer Hook 结合起来使用。
Reducer Hook 返回一个状态对象和一个更改状态对象的函数。这个函数是 dispatch 函数,接收一个 action 参数,action 参数的属性有 type 操作类型和一个可选的 payload。所有这些信息都将在实际的 reducer 函数中使用,以从上一个状态,action 的可选 payload 和 type 中提取新状态。让我们看看这在代码中是如何工作的:

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, {
    isLoading: false,
    isError: false,
    data: initialData,
  });
 
  ...
};

Reducer Hook 接收一个 reducer 函数 and 一个初始的 state 对象作为参数。在这里,作为初始 state 参数的 data, loading,error 状态没有改变,只是合并到一个状态对象里,并由一个 reducer hook 而不是 state hook 来管理。

const dataFetchReducer = (state, action) => {
  ...
};
 
const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);
 
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    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 函数。信息包含一个必选属性 type 和一个可选属性 payload。type 告诉 reducer 函数使用哪个状态转换,还可以使用额外的 payload 来生成新的状态。
在自定义的钩子函数里,还是像之前一样返回 state。因为我们有一个 state 对象而不是多个独立的 state。这样一来,调用 useDataApi 还是能得到 data, isLoading 和 isError。

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

最后,还需要实现 reducer 函数,它需要实现三个动作来实现三种状态转换,包括 FETCH_INIT, FETCH_SUCCESSFETCH_FAILURE。每个状态转换都返回一个新的 state。

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 函数可以通过参数获取当前状态和一个 action。目前为止,在 switch case 语句中,每个状态转换仅返回上一个状态,通过解构语法,保证 state 对象不被修改(意味着 state 对象永远不会被直接修改,强制实施最佳实践)。现在,在状态转换中,覆盖掉当前状态的部分属性来修改当前状态。

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

现在,由 action 类型决定的每个状态转换都将基于先前的状态和可选的 payload 返回一个新的状态。例如,在成功请求的情况下,payload 用于设置新状态对象的数据。

总之,Reducer Hook 确保状态管理的这一部分使用其自己的逻辑进行封装。通过提供 action 类型和可选的 payload,你始终能得到一个可预测的状态。此外,您将永远不会得到无效状态。例如,以前可能会意外地将 isLoading 和 isError 状态都设置为true。在这种情况下,UI 中应显示什么?现在,由 reducer 函数定义的每个状态转换都会得到一个有效的状态对象。

在 React Hook 里中断接口请求

在 React 中,一个常见的问题是,即使组件已经卸载,组件的状态也会被设置(例如路由切换)。来看看如何解决:

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);
 
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  });
 
  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 (error) {
        if (!didCancel) {
          dispatch({ type: 'FETCH_FAILURE' });
        }
      }
    };
 
    fetchData();
 
    return () => {
      didCancel = true;
    };
  }, [url]);
 
  return [state, setUrl];
};

每个 effect hook 都带有清除函数,该函数在组件卸载时运行。清除函数是从 effect hook 返回的一个函数。在我们的例子中,使用一个布尔标志 didCancel 来使数据获取逻辑知道组件的状态(挂载/卸载)。如果确实卸载了组件,则应设置该标志为 true,以免接口异步请求返回数据之后设置组件状态。

注意:实际上,数据获取不会中止——这可以通过 Axios Cancellation 来实现——但是,已卸载的组件不再执行状态转换。Axios Cancellation 在我眼中并不是最好的 API,这个防止设置状态的布尔值标记也可以完成此工作了。

你可能感兴趣的:(前端,react,reactjs)