Hooks 陷阱2-当 hooks 遇到 debounce-阿里云开发者社区

开发者社区> 过客匆匆_> 正文

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

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
阿里云服务器怎么设置密码?怎么停机?怎么重启服务器?
如果在创建实例时没有设置密码,或者密码丢失,您可以在控制台上重新设置实例的登录密码。本文仅描述如何在 ECS 管理控制台上修改实例登录密码。
9575 0
使用SSH远程登录阿里云ECS服务器
远程连接服务器以及配置环境
2465 0
windows server 2008阿里云ECS服务器安全设置
最近我们Sinesafe安全公司在为客户使用阿里云ecs服务器做安全的过程中,发现服务器基础安全性都没有做。为了为站长们提供更加有效的安全基础解决方案,我们Sinesafe将对阿里云服务器win2008 系统进行基础安全部署实战过程! 比较重要的几部分 1.
9078 0
阿里云服务器如何登录?阿里云服务器的三种登录方法
购买阿里云ECS云服务器后如何登录?场景不同,阿里云优惠总结大概有三种登录方式: 登录到ECS云服务器控制台 在ECS云服务器控制台用户可以更改密码、更换系.
13279 0
阿里云服务器如何登录?阿里云服务器的三种登录方法
购买阿里云ECS云服务器后如何登录?场景不同,云吞铺子总结大概有三种登录方式: 登录到ECS云服务器控制台 在ECS云服务器控制台用户可以更改密码、更换系统盘、创建快照、配置安全组等操作如何登录ECS云服务器控制台? 1、先登录到阿里云ECS服务器控制台 2、点击顶部的“控制台” 3、通过左侧栏,切换到“云服务器ECS”即可,如下图所示 通过ECS控制台的远程连接来登录到云服务器 阿里云ECS云服务器自带远程连接功能,使用该功能可以登录到云服务器,简单且方便,如下图:点击“远程连接”,第一次连接会自动生成6位数字密码,输入密码即可登录到云服务器上。
22000 0
阿里云服务器ECS登录用户名是什么?系统不同默认账号也不同
阿里云服务器Windows系统默认用户名administrator,Linux镜像服务器用户名root
4091 0
阿里云ECS云服务器初始化设置教程方法
阿里云ECS云服务器初始化是指将云服务器系统恢复到最初状态的过程,阿里云的服务器初始化是通过更换系统盘来实现的,是免费的,阿里云百科网分享服务器初始化教程: 服务器初始化教程方法 本文的服务器初始化是指将ECS云服务器系统恢复到最初状态,服务器中的数据也会被清空,所以初始化之前一定要先备份好。
6993 0
8
文章
0
问答
文章排行榜
最热
最新
相关电子书
更多
《2021云上架构与运维峰会演讲合集》
立即下载
《零基础CSS入门教程》
立即下载
《零基础HTML入门教程》
立即下载