Hooks 陷阱2-当 hooks 遇到 debounce

简介: react 中遇到的奇怪的问题,基本可以从两方面去思考:state 是否 immutable,以及是否形成了闭包。在 Hooks 陷阱中,分析了 hooks 的一些陷阱,其中已经提到了闭包的问题。而当 hooks 遇到 debounce 或者 throttle 等科里化函数的时候,外加一些 viewModel 抽象导致的变量依赖分散时,情况变得更为复杂和难以理解。本文以项目中遇到的实际案例为例子,

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 中传入的,完全看不到内部实现所依赖的变量。

小结

  1. 使用 useCallback 使得 debounce 函数只被调用了一次
  2. 如果想要 debounced 函数获取到正确的值,那么可以从外部将最新的值传入进去,否则它会使用闭包中它创建时的那个值
  3. 如果不想每次从外部传入最新的值,那么可以使用 useRef ,需要每次 re-render 时重新生成回调函数,并赋值给 ref,然后在 useCallback 的回调函数中调用 ref.current 使得每次调用的是最新的函数,使用的是最新的值
  4. 在 useEffect 中,特别需要注意其闭包中的函数调用的值,这些值都是在闭包创建时的值,如果要保证取到最新的值,那么可以将其添加到依赖数组中,不过这会导致 useEffect 中的函数多次执行
相关文章
|
6月前
|
JavaScript 前端开发
为什么使用Hooks?
为什么使用Hooks?
36 1
|
2月前
|
前端开发 数据格式
hooks实现左添右减
React Hooks实现左添右减组件,允许用户输入值并进行添加或删除操作,同时支持正则表达式校验和默认值设置,通过回调函数将数据传回父组件。
17 1
hooks实现左添右减
|
4月前
|
缓存 C++
hooks-riverpod 使用
hooks-riverpod 使用
|
4月前
|
前端开发
react18【系列实用教程】Hooks 闭包陷阱 (2024最新版)含useState 闭包陷阱,useEffect 闭包陷阱,useCallback 闭包陷阱
react18【系列实用教程】Hooks 闭包陷阱 (2024最新版)含useState 闭包陷阱,useEffect 闭包陷阱,useCallback 闭包陷阱
63 0
|
6月前
|
JavaScript
Vue 编写(preventReClick)防暴点 +防抖(debounce)和节流(throttle)函数
Vue 编写(preventReClick)防暴点 +防抖(debounce)和节流(throttle)函数
378 0
|
6月前
|
前端开发 JavaScript
【边做边学】React Hooks (二)——useEffect Hook
【边做边学】React Hooks (二)——useEffect Hook
|
6月前
|
前端开发 JavaScript
React中useEffect Hook使用纠错
React中useEffect Hook使用纠错
38 0
|
6月前
|
自然语言处理 前端开发 JavaScript
说说你对 React Hook的闭包陷阱的理解,有哪些解决方案?
说说你对 React Hook的闭包陷阱的理解,有哪些解决方案?
106 0
|
存储 前端开发 开发者
对 React Hook的闭包陷阱的理解,有哪些解决方案?
对 React Hook的闭包陷阱的理解,有哪些解决方案?
221 0
|
前端开发
react 进阶hook 之 useCallback hook
原因就是在于那个回调函数,还记得属性传入一个函数,如果是直接在事件后面绑定那么每一次render的时候就会重新生成一个函数。并且每一次的函数的地址都不一样,所以这就是为啥 父组件渲染,子组件也会跟着渲染的根本原因。
react 进阶hook 之 useCallback hook