react 中遇到的奇怪的問題,基本可以從兩方面去思考:state 是否 immutable,以及是否形成了閉包。在 Hooks 陷阱 中,分析了 hooks 的一些陷阱,其中已經提到了閉包的問題。而當 hooks 遇到 debounce 或者 throttle 等科裡化函數的時候,外加一些 viewModel 抽象導緻的變量依賴分散時,情況變得更為複雜和難以了解。本文以項目中遇到的實際案例為例子,闡述如何在 hooks 遇到 debounce 等函數時進行程式設計的最佳實踐,避免一些出人意料的 bug。
場景 1:回調函數中使用 debounce 節流
下面是一個接近真實場景的例子,使用者在輸入框中輸入内容進行搜尋,我們需要對搜尋進行節流,防止太頻繁的網絡請求。
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { Select } from 'antd';
import { debounce } from 'lodash';
import jsonp from 'fetch-jsonp';
import querystring from 'querystring';
const { Option } = Select;
let currentValue;
function SearchInput(props) {
const [data, setData] = useState([]);
const [value, setValue] = useState();
const [loading, setLoading] = useState(false);
async function rawFetch(value, callback) {
console.log('====value====', value);
currentValue = value;
const str = querystring.encode({
code: 'utf-8',
q: value,
});
await jsonp(`https://suggest.taobao.com/sug?${str}`)
.then(response => response.json())
.then(d => {
if (currentValue === value) {
const { result } = d;
const data = [];
result.forEach(r => {
data.push({
value: r[0],
text: r[0],
});
});
callback(data);
setLoading(false);
}
});
}
const fetch = debounce(rawFetch, 300);
const handleSearch = value => {
setLoading(true);
if (value) {
fetch(value, data => setData(data));
} else {
setData([]);
}
};
const handleChange = value => {
setValue(value);
};
const options = data.map(d => <Option key={d.value}>{d.text}</Option>);
return (
<Select
showSearch
value={value}
style={props.style}
onSearch={handleSearch}
onChange={handleChange}
>
{options}
</Select>
);
}
ReactDOM.render(<SearchInput style={{ width: 200 }} />, mountNode);
但是,當我們實際測試時,發現并沒有減少網絡請求:

這是因為,每次輸入觸發 setLoading,元件 re-render,每次都重新生成一個 debounce 的 fetch。雖然每個 fetch 是節流的,但是這裡生成了 n 個 fetch,當 timeout 之後都觸發了請求。
如何解決這個問題?方法就是阻止每次都重新生成一個 debounce 後的 fetch。一個簡單的方案是直接将 fetch 相關的代碼挪出 SearchInput 函數,不過很多時候我們對 SearchInput 中的很多變量有依賴,全部傳遞顯得很麻煩。此時,我們可以使用 useCallback:
const fetch = useCallback(debounce(rawFetch, 300), []);
需要注意的是,在我們的 rawFetch 函數中,參數 value 和 callback 都是外部調用時傳入的。如果我們在 rawFetch 中直接取目前作用域下的 value 和 setData,那麼會形成閉包,此時搜尋時取到的值将會是 undefinded。在該例中,搜尋的 value 必須傳入,是以無法複現,用一個新的例子來展示:
import React, { useState, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import { debounce } from 'lodash';
function App() {
const [value, setValue] = useState();
const f = () => console.log(value);
const fn = useCallback(
debounce(f, 500),
[]);
return (
<div>
<input
value={value}
onChange={(event) => {
const _v = event.target.value;
setValue(_v);
fn();
}}
/>
<br />
<button onClick={() => setValue('')}>清空</button>
</div>
);
}
ReactDOM.render(<App />, mountNode);
如果我們不想每個依賴的參數都需要在回調函數中傳過去,那麼應該怎麼處理呢。此時就需要 useRef 來保證每次調用的都是最新的方法:
import React, { useState, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import { debounce } from 'lodash';
function App() {
const [value, setValue] = useState();
const fRef = useRef();
const f = () => console.log(value);
fRef.current = f;
const fn = useCallback(
debounce(() => fRef.current(), 500),
[]);
return (
<div>
<input
value={value}
onChange={(event) => {
const _v = event.target.value;
setValue(_v);
fn();
}}
/>
<br />
<button onClick={() => setValue('')}>清空</button>
</div>
);
}
ReactDOM.render(<App />, mountNode);
封裝 useDebounce:
function useDebounce(fn, ms) {
const fRef = useRef();
fRef.current = fn;
const result = useCallback(
debounce(() => fRef.current(), ms),
[]);
return result;
}
對比兩種解決方案,直接使用 useCallback 的情況,需要外部傳入最新的變量,保證調用時取到的是最新的變量。而使用 useRef 結合 useCallback 不需要外部傳入最新變量,但是每次都需要重新生成回調函數将其指派給 ref.current,性能上稍微差一點。
場景2:在 useEffect 中使用 throttle
當我們在 useEffect 中監聽 dom 事件,然後觸發回調,在回調中使用 throttle 來節流。
// Flow.jsx
useEffect(() => {
const onScroll = throttle(() => {
const result = findSelected();
if (result) {
setSelected(result);
} else if (container.scrollTop < window.innerHeight) {
setSelected(null);
}
// 滾動後隐藏工具欄
removeFadeOutByScroll();
}, 200);
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
};
}, [outline]);
// useViewModel.js
function removeFadeOutByScroll() {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
if (!hoverToolbar) {
setFadeOut(true);
}
}
這裡出現了一個讓人費解的問題,hoverToolbar 已經被設定為 true,但是在上方 removeFadeOutByScroll 的調用中,hoverToolbar 還是為 false。
其實這個問題還是由閉包引起的,useEffect 中的函數閉包使得裡面函數一直保留了第一次指派時的值,是以調用removeFadeOutByScroll 其中的 hoverToolbar 的值并不會因為 useViewModel 中通過 setState 改變了 hoverToolbar 而改變。為了解決這個問題,隻需要将 hoverToolbar 設定為 useEffect 的依賴。
useEffect(() => {
const onScroll = throttle(() => {
const result = findSelected();
if (result) {
setSelected(result);
} else if (container.scrollTop < window.innerHeight) {
setSelected(null);
}
// 滾動後隐藏工具欄
removeFadeOutByScroll();
}, 200);
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
};
}, [outline, hoverToolbar]);
看到這裡發現其實這個問題和 throttle 關系不大,主要還是 useEffect 的問題。這裡主要我們每次移除了監聽,這樣不會産生多個 throttle 後的回調函數。其實在 react 推薦的 eslint 中有一條配置就是所有 effect 中用到的變量都要放入依賴數組中。不過這裡實在太隐蔽了,函數是從 viewModel 中傳入的,完全看不到内部實作所依賴的變量。
小結
- 使用 useCallback 使得 debounce 函數隻被調用了一次
- 如果想要 debounced 函數擷取到正确的值,那麼可以從外部将最新的值傳入進去,否則它會使用閉包中它建立時的那個值
- 如果不想每次從外部傳入最新的值,那麼可以使用 useRef ,需要每次 re-render 時重新生成回調函數,并指派給 ref,然後在 useCallback 的回調函數中調用 ref.current 使得每次調用的是最新的函數,使用的是最新的值
- 在 useEffect 中,特别需要注意其閉包中的函數調用的值,這些值都是在閉包建立時的值,如果要保證取到最新的值,那麼可以将其添加到依賴數組中,不過這會導緻 useEffect 中的函數多次執行