[譯]使用React Hooks請求數據

原文:How to fetch data with React Hooks?

在這篇文章里,我將演示一下,如果通過使用 useState useEffect 等hooks,在 React Hook里請求數據。我們將使用 Hacker News API 來獲取最新流行的技術文章。我們將實現一個獲取非同步數據的自定義hook,能夠在我們APP里多個地方進行復用,或者作為單獨的包發布到npm上。

如果你還不了解 React Hooks,你可以通過我的 React Hooks 簡介 了解下。本文完整的demo代碼,在這個 github倉庫 。

注意:在將來,React Hooks的用處,不是請求數據。新的 Suspense 特性,將用來請求數據。本文主要是展示我們能用hooks做些什麼,來加深我們對hooks的理解。

在hooks里請求數據

如果你還不了解怎麼在react里請求數據,建議先看看我的 可擴展的數據請求這篇文章,它會介紹在react class組件里,怎麼進行數據請求,包括怎麼通過 Render Prop Component 和 高階組件HOC 封裝可服用的數據請求邏輯,以及怎樣進行 載入中和錯誤 的狀態展示。在本文里,我將展示如果在react 函數組件 里使用 hooks 來達到同樣的效果。

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;

我們demo里會展示一個hacker news的文章列表。我們使用 useState 來維護APP的state以及提供更新state的操作。state的默認值是一個空的數組。

我將使用 axios 來處理數據請求,當然你也可以使用自己習慣的其他庫,或者使用瀏覽器原生的 fetch 方法。下面我們看下加上 useEffect 來請求數據之後的代碼:

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

function App() {
const [data, setData] = useState({ hits: [] });

useEffect(async () => {
const result = await axios(
http://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 里,我們使用axios請求到數據之後,調用 useState 返回的 setData 方法,將新的數據更新到state上,從而觸發組件重新render。非同步函數我們使用 async/await 的語法,來簡化代碼。

然而,當你運行上面的代碼,你會進行死循環。上面的 useEffect 函數,在組件初次掛載和每次更新的時候,都會執行;在 useEffect 函數里,我們在請求到數據之後,更新了組件的state,導致組件重新渲染,組件渲染之後,又會調用 useEffect 函數。結果就是,組件一直在請求數據,刷新,請求數據,刷新……這當然是一個必須要解決掉的bug。我們只希望在組件初次掛載的時候,請求數據。下面,我們將給 useEffect 傳第二個空數組的參數,來實現這個效果:只在組件mount的時候,調用 useEffect 函數。

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

function App() {
const [data, setData] = useState({ hits: [] });

useEffect(async () => {
const result = await axios(
http://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依賴的所有變數。依賴項中只要有一個改變,就會重新調用 useEffect 。如果依賴項是空的數組,表明我們的hook不依賴任何變數,因此,該hook只會在組件初次mount的時候執行。

上面的代碼還有一個問題。我們使用了 async/await 來處理非同步操作,根據規範,async 函數會返回一個隱式的 Promise: " The async function declaration defines an asynchronous function, which returns an AsyncFunction object. An asynchronous function is a function which operates asynchronously via the event loop, using an implicit Promise to return its result. " 。然而, effect hook 要麼什麼都不返回,要麼返回一個清理函數。因此,運行上面的代碼,你會在 console 里看到這樣的警告: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 傳一個 async 函數,我們需要在 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(
http://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里請求數據的功能。如果你想知道怎麼進行載入中處理,錯誤處理,以及如果在 form 表單中觸發數據請求,如何將數據請求邏輯封裝成自定義的hook,就繼續往下看吧。

怎麼手動(或程序)觸發一個hook

OK,到目前為止,我們可以在組件mount之後,請求數據並且觸發組件更新。但是,怎麼實現使用輸入框來請求我們輸入的話題呢?之前代碼里使用「redux」 作為默認的話題。那我們怎麼修改這個話題呢,比如我想查詢 「react」 相關的文章呢?接下來我們增加一個輸入框,來允許用戶查詢自己感興趣的話題。為了保存用戶輸入的內容,我們新增加了一個state:

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

上面的代碼里,兩個state相互是獨立的,怎麼實現每次請求用戶輸入的主題數據呢?改動之後的代碼如下:

// 省略 ...

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;

一個新的問題:在組件mount之後,你在輸入框里輸入不同的值,不會觸發重新獲取相應的數據。因為我們在 useEffect 的第二個參數,是一個空數組,表明這個hook不依賴任何狀態,它只會在組件mount的時候執行一次。事實上,從上面代碼可以看出,我們的 useEffect 里,是依賴了 query 這個變數,因此,我們需要把 query 添加到 useEffect 的依賴里。每當 query 改變的時候,都會觸發effect重新執行:

// 省略相同代碼 ...

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;

運行上面的代碼,每當你在輸入框里輸入內容時,都會觸發hook重新請求數據。但這會帶來一個新的問題:我們在輸入過程中,每輸入一個字元,都會觸發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 (
<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>
);
}

我們新增了一個 state search 來保存當前要檢索的topic。當用戶點擊搜索按鈕的是,將輸入框的值更新到 search 中,觸發effect重新執行來請求相應數據。同時,我們將 search 的默認值設置為和 query 一樣,都是 redux,因為effect會在組件mount的時候執行一次,這時候拿到的 query 就是默認值。你可能會想,querysearch 要表達的幾乎是同一個東西,用兩個 state 似乎容易混淆,那我們可以把實際要請求的 url 作為第二個state,而不是 search,比如下面這樣:

function App() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState(redux);
const [url, setUrl] = useState(
http://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>
);
}

OK,到這裡,我們實現了通過事件來隱式的觸發effect執行,從而重新請求數據。下面我們看看,怎麼處理 載入中 這種狀態呢。

React hooks 數據請求實現載入中

我們再引用一個新的 state 來保存 載入中 這個狀態,通常我們會渲染一個載入中的指示器,來提示用戶網路請求正在處理中:

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(
http://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執行時,會設置 isLoadingtrue,當請求結束時,設置為false。

React hooks 數據請求的錯誤處理

通常在網路請求時,都必須要考慮到網路異常的情況,那我們在hook里怎麼來處理網路異常呢?和上面的載入中類似,我們只需要額外增加一個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(
http://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重新執行的時候,都會重置錯誤狀態。在通常情況下,用戶第一次遇到網路錯誤之後,可以重新發起一次請求,第二次請求是有可能成功的,因此需要在每次請求開始時,重置錯誤狀態。

結合 form 提交數據

在大多數時候,我們都會把用戶輸入項,放在一個 form 表單里,結合 form 表單之後的代碼如下:

function App() {
// 省略相同代碼 ...

const doFetch = () => {
setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`);
};

return (
<Fragment>
<form onSubmit={event => {
doFetch();

// 阻止瀏覽器默認刷新頁面
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,單獨提取出來,作為自定義的hook,就像下面這樣:

```javascript 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]);

const doFetch = url => { setUrl(url); };

return { data, isLoading, isError, doFetch }; };

function App() { const [query, setQuery] = useState(redux); const { data, isLoading, isError, doFetch } = useDataApi( hn.algolia.com/api/v1/s, { hits: [] }, );

return (

{ 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,返回一些內部的state以及修改state的方法。

Reducer Hook

到這裡,我們實現了使用多個state來維護我們組件的各種狀態:載入中,網路異常以及請求成功。但是,這3種狀態,我們使用了3個獨立的state,然而他們本質上是相互關聯的。正如你所看到的,這3個狀態都在我們的hook里維護,那我們何不通過 useReducer 這個hook來把這3個狀態組合成一個呢?

一個 useReducer hook,接收一個 reducer 函數以及初始狀態 ,返回當前的狀態以及修改狀態的 dispatch 函數。

import React, {
Fragment,
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);

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, doFetch };
};

總的說來,通過Reducer Hook,我們能夠把相關聯的狀態管理封裝在一起。通過 dispatch 事件的方式來觸髮狀態改變,讓我們的狀態更加可預測。

Effect Hook 里中斷網路請求

在開發React應用中,經過遇到這樣一種情況,我們在 componentDidMount 里發起非同步請求,在請求回來 之前,我們的組件被 unmount 了,等非同步請求完成時,再調用 setState,這時候會觸發警告,因為我們在一個已經銷毀的組件上更新state。接下來我們看看,如何在自定義hook里,變在組件unmount之後,還會更新state的問題:

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]);

const doFetch = url => {
setUrl(url);
};

return { ...state, doFetch };
};

每一個 effect hook都 可以 返回一個清理函數,這個函數會在組件unmount的時候被調用。在上面的代碼里,我們在effect里添加了一個標記,表明當前組件是否被unmount了,默認是false的,在effect清理函數里,會設置為true。當網路結束時,我們會先判斷這個標記,如果組件已經被unmount了,那麼就不用更新state了。

注意: 我們這裡並沒有真正的 中斷 網路請求,網路請求仍然完成了,我們只是在網路結束之後,不會去更新被unmount的組件狀態。

相關文檔

  • react fetching data
  • react-warning-cant-call-setstate-on-an-unmounted-component

推薦閱讀:

相关文章