如何使用useEffect,useState,useReducer获取数据

2,783 阅读10分钟

   在本教程中,我想告诉你如何在React中使用state hook和effect hooks去获取异步数据,这也是我们日常业务需求里最常用到的场景,全文例子会由浅入深逐步进行,最终带你实现一个自定义的hook用于数据获取,可以根据个人情况通过目录跳转到感兴趣的地方。

  本文适合了解过React hook这一新特性的开发者,如果你还没接触过,可以先阅读 官方文档 。本文来源于官方博客加上个人使用理解的理解,文章里有可以在线调试体验的在线链接可供查看体验~

使用React Hook进行数据获取

  如果你对React的数据获取不是很熟悉,可以去详读这篇文章,它会引导你使用React 的class组件进行数据获取,如何使用render prop组件或者hoc组件创建可以复用的组件,还有如何解决处理中和等待中的状态图标,我想在函数式组件中使用React Hooks想你展示上述的功能(指class组件获取数据)

import React, { useState } from 'react';
 // 这是一个展示list列表的组件,它使用useState这个hooks提供的功能去维护和更新本地的state状态,这个钩子函数可以接收一个默认值为[]数组,该组件现在还没有使用setData设置任何状态值;
function App() {
  // hits = Hacker News articles
  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;

错误示例🔗 

下面代码里有2处错误,可以先自行发现哈

import React, { useState, useEffect } from 'react';
// npm install axios 
//使用axios去获取数据,你也可以使用fetch之类的api
import axios from 'axios';
 
function App() {
  const [data, setData] = useState({ hits: [] });
    //使用useEffect配合axios去获取api的数据并且将返回值设置为 state hook的值,使用async/await去写异步代码
  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里设置了setData修改状态,每当状态更新时都会触发函数组件执行这个effect hooks,不停的去fetch数据,我们需要解决这个bug,因为我们只希望在组件完成挂载的时候执行hooks里的aioxs获取数据,可以通过传递useEffect的第二个参数为[]来让这个hook仅仅在挂载完成时执行;如下:

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才会再次执行,如果这个变量为空,hook将不会在组件更新的时候再次执行,因为这个hook没有监听任何变量;

上面的代码里还有一个新手经常遇到的陷阱(idel会报错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),我们知道async函数会隐式的返回一个Promise对象,而useEffect希望他的第一个参数什么都不返回或者返回一个清除函数(会在组件状态更新渲染后执行),因此useEffect函数中不允许直接使用async函数包装第一个参数,我们可以像下面这样在useEffect中使用async/await

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();
    //匿名方法可以使用自执行的方式 async()();
  }, []);
 
  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}
 
export default App;

  

  接下来讲一下如何在hooks里处理错误状态,loading状态,如何使用表单配合表单去获取数据,还有如何实现一个可以复用的获取数据的hook

手动触发hook  代码示例🔗

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=${query}`,
      );
 
      setData(result.data);
    };
 
    fetchData();
  }, [query]);
 
  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;

上面的🌰里,函数组件加载完成时获取获取数据,而我们可以使用input组件去告诉 API 我们感兴趣的文章是什么,初始内容是‘Redux’,当我们想查询其他文章,例如'react'时,我们需要告诉函数组件,于是新增加了一个state状态,query;我们希望组件挂载完成后会按照query的值来获取文章列表的数据,因此这个effect hook依赖于query,不再是[],在组件重新渲染时将监听query变量是否变化;

但是我们可能希望调用API的操作是可控的,不想要每次往input输入时都会发起API请求(必要场景可以使用防抖函数),因此可以再增加一个state:search,在我们点击查询按钮再调用API请求

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(
        `https://hn.algolia.com/api/v1/search?query=${search}`,
      );
 
      setData(result.data);
    };
 
    fetchData();
  //此时我们的hook依赖于search变量的值
  }, [search]);
 
  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>
  );
}
 
export default App;

  这样看起来有点奇怪,我们会维护两个值完全相同的状态,如果依赖项更多表单状态值的话页面state就会变得混乱,我们可以把API的地址提取出来作为hook依赖的变量,点击查询按钮时如果发现查询参数改变,url发生变化才会执行useEffect这个hook,这样看起来似乎更合理一些了~

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

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(`https://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;

错误处理

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);
                //throw new Error('error')  为了能显示错误状态的效果可以手动抛一个错误
        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;


使用From表单

上面的例子里,我们使用了input和button的组合去让组件显示想要的数据,现在我们把这些表单组件放到Form组件里,注意,和之前不同的是,使用Form我们将会支持使用键盘上的 enter 来进行submit的操作。注意我们也需要防止点击按钮导致页面刷新的问题。

function App() {
  ...
 
  return (
    <Fragment>
      <form
        onSubmit={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
                // 和类组件一样,为了组织Form表单的默认行为导致页面刷新,需要添加下面这行
                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>
  );
}

自定义获取数据的hook 自定义HOOK🔗

为了提取一个自定义的hook用于数据获取,需要把所有用于数据获取的内容(包括loading状态和错误处理状态,除了由input控制的query字段)移到我们自定义的函数中,并且自定义的hook需要返回app组件必要的变量

//useHackerNesAPi.js
import React, { Fragment, useState, useEffect } from 'react';
import axios from 'axios';

const useHackerNesAPi=()=>{
  const [data,setDasta]=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=asnyc ()=>{
      setIsError(false);
      setIsLoading(true);
      try{
        const result=await axios(url);
        setData(result.data);
      }catch(error){
        setIsError(true);
      }
      setIsLoading(false);
    }
  },[url]);
  return [{data,isLoading,isError},setUrl];
}

//app.js
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>
  );
}

  如果我们在app组件里给hook里的url state传递初始值或者要查询的API地址状态,那么我们就可以获得一个更通用的自定义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,这个hook对要查询的API一无所知,它所有的参数均来自外部,并且只需要管理类似data,loading,error这样的必要状态,它执行API请求并将得到的数据返回给使用这个自定义hook的组件;


使用Reducer hook 代码示例🔗

进行到这一步,我们已经多种state hook来管理数据我们sh数据获取,loading和error的状态,但是,如你所见,所有这些状态都用于获取数据的函数,他们由各自的state hook管理却因为共同关注的问题(data fetch)而紧密联系成一体(这些state,如setIsError、setIsLoading被一个接一个的使用连成一体),现在让我们使用useReduce将他们组合起来

当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer去替换它们,为何要在这个例子里使用useReducer可以看看这篇文章:什么情况下使用useReducer/useState

一个Reduce hook向我们返回一个state对象和一个用于操作state dispatch函数(dispatch包含了action和payload参数),dispatch的action和payload参数用于hook里的reducer函数,通过之前的state获取一个全新的state

import { useState, useEffect, useReducer } from "react";
import axios from "axios";

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();
  }
};

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);

  //useReducer第一个参数是一个reudce函数,第二个参数是初始状态
  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]);

  return [state, setUrl];
};
export default useDataApi;

  通过上面的思路我们可以提取一个自定义的hook组件,依然会返回data,loading,error状态,但是通过reducer,我们返回的是一个 state Object包含这三个状态;

  通过swtich语句,我们可以根据action和state参数的不同分别返回state Object,


  ok。现在我们例子中所有的state的变化都取决于action 的type,根据之前的state和payload参数返回一个新的state,例如,当请求成功时,payload用于设置全新的state object;

  总之,Reducer Hook让我们用自己的逻辑代码压缩了部分状态管理hook,通过提供action type和可选的payload参数,你将始终可以得到一个符合预期的的state修改,另外可以避免当你意外的将类似isLoading和isError这样的state设置错误时页面UI如何显示的问题,但是现在因为reducer函数,每一种UI状态都可以指向一个特定的状态下的state object的。



竞态问题

  这是一个React组件中的常见问题,如何避免异步请求的不可预测导致页面错误的UI更新,例如上面例子中连续查询2个你感兴趣的词汇,但是第一个请求的调用返回在第二次调用返回之后,下面是一个在hook组件中处理竞态问题的例子,当然这实际上并没有取消api的调用终止数据获取的行为(基于XHR对象可以使用abort api来阻止数据获取,例如axios),但是因为dispatch没有执行,页面的ui渲染就被阻止了。

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);
 
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  });
 
  useEffect(() => {
    //添加一个flag来处理竞态问题
    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();
        //如果组件没有挂载完成,在状态改变重新渲染时会先执行上次hook的callback方法,上一次组件的加载过程就像是: fetch data 返回前设置didCancel为true,这样就无需执行dipatch函数更新页面UI了
    return () => {
      didCancel = true;
    };
  }, [url]);
 
  return [state, setUrl];
};