reacthooks

react中的闭包

闭包

闭包就是一个函数记住了它定义时候的作用域,即使这个函数在定义环境之外执行,也能访问到原本作用域中的变量。

const outer = () => {
  let count = 0;
  const inner = () => {
    console.log(count);
  };
};

const f = outer();

f(); // 0

react为什么会遇到闭包问题

因为函数组件就是一个普通的函数,每次渲染的时候都会重新调用,渲染时用到的所有变量和state,都会在一次调用中固化下来

因此:

  1. 每次渲染都会重新创建闭包,捕获当时的props/state

  2. 之后再事件回调或是监听器中调用这个闭包的时候,拿到的都是当时捕获的变量值,不是更新后的新值

典型问题

最典型的问题

const App = () => {
  const [count, setCount] = useState(0);

  function alertCount() {
    setTimeout(() => {
      alert(count);
    }, 3000);
  }

  return (
    <>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>加一</button>
      <button onClick={alertCount}>3秒后弹出 count</button>
    </>
  );
};

弹出的时执行alertCount中的count值,而不是最新的count值,为了解决这个问题

原因:

  • 当 alertCount 在第一次渲染(或某次渲染)中被定义时,它捕获的是当时的 count

  • 即使 3 秒后 count 的 state 改变了,这个闭包里的 count 依然是那个旧值

最常见的闭包问题

  1. useEffect省略依赖
const [count, setCount] = useState(0);
useEffect(() => {
  const timer = setTimeout(() => {
    console.log(count);
  }, 1000);
}, []); // 没有依赖

改为

const [count, setCount] = useState(0);
useEffect(() => {
  const timer = setTimeout(() => {
    console.log(count);
  }, 1000);
}, [count]);
  1. setInterval/setTimeout 闭包问题

回调调用了旧的state/props值

import { useState, useEffect } from 'react';

const App = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('count in interval:', count);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // ❌ 空依赖数组,只在挂载时创建一次定时器

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>加1</button>
    </div>
  );
};

改为

const App = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('count in interval:', timer);
    });
  }, [count]);
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>加1</button>
    </div>
  );
};

这样就可以解决问题

  1. 事件回调

回调定义的时候捕获了旧值,触发时用的还是旧值

const App = () => {
  const [count, setCount] = useState(0);

  function handleClickLater() {
    setTimeout(() => {
      alert('count: ' + count); // 可能是旧值
    }, 3000);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>加1</button>
      <button onClick={handleClickLater}>三秒后弹出 count</button>
    </div>
  );
};

改为

const handleClickLater = useCallback(() => {
  setTimeout(() => {
    alert('count: ' + count);
  }, 3000);
}, [count]); // ✅ 每次 count 变,重建回调
  1. 异步回调

Promise resolve 之后回调闭包里也是旧值

const App = () => {
  const [name, setName] = useState('初始值');

  function fetchData() {
    fakeRequest().then(() => {
      alert('name: ' + name); // 旧的 name
    });
  }

  return (
    <div>
      <p>{name}</p>
      <button onClick={() => setName('更新值')}>更新 name</button>
      <button onClick={fetchData}>发请求</button>
    </div>
  );
};

const fakeRequest = () => {
  return new Promise((resolve) => {
    setTimeout(resolve, 3000); // 模拟3秒后返回
  });
};

改为

const nameRef = useRef(name);

useEffect(() => {
  nameRef.current = name;
}, [name]);

const fetchData = () => {
  fakeRequest().then(() => {
    alert('name: ' + nameRef.current); // ✅ 最新 name
  });
};

解决方法

  1. 把变量加入依赖数组

  2. 使用函数式更新,让state的更新基于最新之计算,而不依赖闭包捕获的旧值,这样即使闭包捕获了旧的count,也可以使用回调参数拿到最新的state

  3. 使用useRef存储最新值

  4. 使用useCallback保持回调引用稳定

react闭包的理解

把每次渲染看成一次快照

  • propsstate、定义的函数和变量等都是这次渲染的快照副本

  • 在渲染外部延迟执行的回调,不会自动更新到最新快照

  • 想用到最新值,要么重新生成回调,要么使用ref持有最新状态

总结

  1. 本质

本质是每次渲染的时候函数组件会创建一套新的变量环境,回调会捕获当时的值,之后不随state/props自动更新

  1. checklist
  • 副作用依赖数组写全、这样一直用最新值

  • state尽量用函数式更新

  • 在需要永远使用最新值的地方用useRef

  • 明确某个回调是否要随值更新而更新

基础

useState 在函数组件中添加局部状态

用来日俺家和管理状态,页面中插入的状态会随着状态的更新而更新

React 出于性能的考虑,会批量处理同一个事件处理函数里的多次 state 更新,并且如果是直接传值方式,后一次调用会覆盖前一次调用,因为它们都基于同一份旧状态做计算。

React 在一次事件中收集这个“批量更新”,最终取最后一次 setState 的值,所以两次结果都是 -1,最后就只会更新一次。

这就是为什么你觉得“只生效了一次”,实际上并不是失效,而是两次更新的结果是一样的,所以最终效果看起来像更新了一次。

但如果传入的是状态更新的函数,则会顺序执行两个更新函数,可以获得已更新但未提交到渲染的状态

const App = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <header>{'计数器'}</header>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        {'+'}
      </button>
      <div>{`当前数字为: ${count}`}</div>
      <button
        onClick={() => {
          setCount(count - 1);
        }}
      >
        {'-'}
      </button>
    </div>
  );
};

useEffect 处理副作用

纯渲染逻辑,也就是jsx是同步无副作用的,但是某些场景下需要执行副作用,就需要用useEffect管理副作用

  1. 访问dom

  2. eventListener

  3. ajax/fetch

  4. 定时器

  5. 订阅

useEffect的依赖,依赖是副作用中用到的外部变量,当变量发生变化的时候会重新执行副作用,反之则不执行,如果不传参数,则每次渲染后都会执行副作用, 依赖列表用来控制副作用的执行时机,如果不传数组,则每次渲染都执行,如果传入空数组,则只会在首次渲染的时候执行,如果传入依赖列表,则只会在依赖列表中的变量发生变化时执行

原则:如果不是稳定的,则必须把副作用中用到的所有组件作用域内部的变量/函数都写入依赖列表

useEffect的旧值问题,如果依赖数组不包含这个变量,则永远读到的都是旧值,因为只有旧值被闭包捕获,但是新值没有, 如果需要让依赖稳定,则可以使用useCallback来稳定函数的引用,或者使用useMemo来稳定状态的引用,用useRef来稳定可变值的应用

react内部只是在每次渲染时,记住依赖数组的当前值,然后在下一次渲染时对比新旧依赖数组是否“相等”,使用的是Object.is,是浅比较,比较的是引用。

useEffect的副作用函数可以返回一个函数,用来请求副作用,比如取消订阅,移除eventListener,清除定时器,断开长连接

useEffect(() => {
  const timer = setTimeout(() => {
    console.log('hello world');
  }, 1000);
  return () => {
    clearTimeout(timer);
  };
}, []);

useCallback 用于在多次渲染中缓存函数的reacthook

介绍

会在依赖不变的情况下,返回同一个函数引用,从而壁面函数在每次渲染时都被重新创建

函数也是js的引用类型,所以在react每次渲染的时候,同样的函数表达式实际上会生成新的引用

const App = () => {
  const handleClick = () => {
    console.log('click');
  };
};

这种新引用在某些情况下会导致子组件重复渲染,或者引发不必要的useEffect执行。

使用

语法:

const memoizedCallback = useCallback(fn, [...deps]);

fn: 你想缓存的回调函数

deps: 缓存函数的依赖数组,只有当依赖变化的时候,才会返回新的函数引用

返回值:函数引用

例子:

const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);
  • count变化时,handleClick会重新创建
  • count不变时,handleClick始终引用同一个函数对象

为什么需要useCallback

每次重复渲染的时候,所有内部的变量和函数都会重新创建

const Parent = () => {
  const handleClick = () => {
    console.log('click');
  };
  return <Child handleClick={handleClick} />;
};
  • 对浏览器来说,这个onClick每次都是一个新的函数引用
  • 如果Childmemo()包装的,即使逻辑上没变,props引用变化仍然会导致Child重新渲染
  • 同样如果依赖数组中有函数引用,也会导致useEffect/useMemo重复执行

useCallback会让函数在依赖不变时引用保持稳定,从而避免这些额外渲染或副作用

原理

useCallback(fn, deps)相当于

const useCallback = (fn, deps) => {
  return useMemo(() => fn, deps);
};

也就是说:

  • React 会记住上一次的 fn 以及依赖数组 deps
  • 新渲染时,如果 deps 没变,直接返回旧的 fn 引用
  • 如果依赖变化,返回新的 fn 引用并保存

使用场景

  • 防止子组件不必要的渲染
const Child = memo(({ onClick }) => {
  console.log('Child render');
  return <button onClick={onClick}>Click</button>;
});

function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []); // 不依赖 count

  return (
    <>
      <Child onClick={handleClick} />
      <button onClick={() => setCount(count + 1)}>+1</button>
    </>
  );
}
  • 如果不用useCallback,每次Parent渲染onClick都是新引用,Child会重渲染

  • 用了后Child引用稳定,不会重复渲染

  • 保证useEffect/useMemo等依赖数组不会因为函数引用变化频繁触发

useEffect(() => {
  fetchData();
}, [fetchData]); //使用useCallback包装的fetchData不变,就不会重复触发副作用
  • 配合稳定引用的缓存
const columns = useMemo(() => [{ title: 'name', render: (row) => <Cell onClick={handleClick} /> }], [handleClick]);

误区

  • 所有函数都需要useCallback包裹

不要

是性能优化工具,有记忆成本,让一个一函数不是传给子组件当props或者作为依赖,就不需要useCallback包裹

react的hook是以链表形式存储的,使用useCallback或增加链表遍历次数

  • 当成防闭包工具

useCallback不能自动获取最新的state,只会缓存当时生成函数的闭包,如果总是需要最新值,则需要用useRef或是函数式更新

useContext 订阅react context ,避免props 层层传递

额外的hook

常见问题

为什么只能顶层调用,不能在条件中调用

1. hooks的执行顺序是react匹配状态的唯一依据

当调用useState,useEffect,useMemo,useCallback时,react会根据组件的渲染顺序,从上到下,从左到右,依次执行hooks,并返回结果。

在第一次渲染时,会按照代码执行的前后顺序,把每个hook对应的数据存到一个列表里面

hooksList = [{ state: valueFromUseState1 }, { state: valueFromUseState2 }];

在下次渲染时,react会按完全相同的调用顺序读取这个列表

如果在条件或循环中调用hook,可能会导致每次渲染时调用顺序发生变化,造成状态错配,导致bug

2. 顶层调用可以保证顺序是恒定的

顶层调用就是

  • hooks不放在条件判断、循环、嵌套函数体内
  • 在组件渲染函数的一开始就顺序调用

这样可以保证每次hook调用的数量和顺序都是一直的,react才能用简单的链表来保存和读取状态

这样就不需要用复杂的id标识每个hook,因此可以提高运行时的性能和技术实现的简单性

3. 官方总结

官方给出的两条规则

  1. 只在react函数组件或自定义hook中调用hooks
  2. 只在顶层调用hooks,不要放在循环、条件和嵌套函数中