Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hooks 陷阱2-当 hooks 遇到 debounce #38

Open
shaozj opened this issue Oct 26, 2021 · 0 comments
Open

Hooks 陷阱2-当 hooks 遇到 debounce #38

shaozj opened this issue Oct 26, 2021 · 0 comments

Comments

@shaozj
Copy link
Owner

shaozj commented Oct 26, 2021

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

但是,当我们实际测试时,发现并没有减少网络请求:

image
image


这是因为,每次输入触发 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 中的函数多次执行
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant