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 中的函数多次执行
相关文章
|
1月前
|
人工智能 安全 前端开发
D2大会 界面即推理:解读 Google A2UI 如何探索 Agent 交互新标准
Google A2UI开源项目,以声明式JSON协议让AI按需生成安全、跨平台的动态界面,破解Agent交互“最后一公里”难题。D2大会将揭秘其设计哲学、多智能体UI协作及开源路线图。
|
前端开发 测试技术 数据安全/隐私保护
使用 React-Hook-Form 让你的表单天生强大
使用 React-Hook-Form 让你的表单天生强大
2154 0
|
前端开发 API UED
React 路由守卫 Guarded Routes
【10月更文挑战第26天】本文介绍了 React 中的路由守卫(Guarded Routes),使用 `react-router-dom` 实现权限验证、登录验证和数据预加载等场景。通过创建 `AuthContext` 管理认证状态,实现 `PrivateRoute` 组件进行路由保护,并在 `App.js` 中使用。文章还讨论了常见问题和易错点,提供了处理异步操作的示例,帮助开发者提升应用的安全性和用户体验。
677 2
|
11月前
|
运维 监控 安全
【案例分享】中国通号卡斯柯公司:ZABBIX如何破解轨道交通监控难题
本文根据2023上海峰会上朱林贤的演讲整理,聚焦中国通号卡斯柯公司如何借助Zabbix实现轨道交通信号系统的智能化管理。作为中外合资企业,卡斯柯通过统一平台整合设备监控,大幅降低成本并提升灵活性,成功应用于国内外项目。文章探讨了传统监控系统的痛点、研发维护经验及国产化与开源技术挑战,为行业转型提供了宝贵启示。未来,开放协作将是推动轨道交通智能化发展的关键。
533 8
|
应用服务中间件 持续交付 nginx
[nginx]借助nginx实现自动获取本机IP
[nginx]借助nginx实现自动获取本机IP
332 5
|
JavaScript 前端开发 容器
微应用框架-----乾坤
微应用框架-----乾坤
|
运维 监控 Cloud Native
轻松构建全栈观测,从容应对咖啡产业竞争
轻松构建全栈观测,从容应对咖啡产业竞争
1449 110
|
前端开发 JavaScript 容器
第九章(应用场景篇)Qiankun微前端深度解析与实践教程
第九章(应用场景篇)Qiankun微前端深度解析与实践教程
1041 0
|
数据安全/隐私保护 Python
python基础考核试题及答案
python基础考核试题及答案
673 0
|
存储 前端开发
useSyncExternalStore,一个陌生但重要的 hook
useSyncExternalStore,一个陌生但重要的 hook
1153 0