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 中的函数多次执行