闭包和闭包在 React 中的问题

3,288 阅读6分钟

什么是闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

MDN 对闭包的定义是这样的,可以说 JavaScript 每个函数都是一个闭包。闭包包含函数本身和函数的作用域,对于定义在全局的函数,它就包含函数本身和函数内部变量还有全局变量的引用

闭包常见的考题

比较常见的闭包题目如下:

for (var i = 0; i < 10; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}

// 类似这种题目,有个比较明显的特征,循环体用的是 var
// 然后大部分人都能想到这里有个闭包问题
// 1 秒后 输出 10 个 10

通常还有第二问,上面这道题,如果希望按顺序输出 0 - 9 有什么改造方法

// 改动最小的方案, var 改成 let 就可以
// let 是块级作用域
for (let i = 0; i < 10; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}

// 使用 iife 实现块级作用域
// 这里其实也是使用了闭包
//  _i 保存这对 iife _i 这个变量的引用
for (var i = 0; i < 10; i++) {
  ((_i) => {
    setTimeout(() => {
      console.log(_i);
    }, 1000);
  })(i);
}

// 当然,也可以利用 setTimeout rest 参数解决这个问题,可以看下 setTimeout 的函数签名,这个是支持传参给回调函数的
function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number
for (var i = 0; i < 10; i++) {
  setTimeout((_i) => {
      console.log(_i);
    }, 1000, i);
}

react 和闭包问题

看到上面那种题目,很多人总以为闭包和自己没啥关系似的,一个是现在的 letconst 大家都在使用了,再不用就配个 no-vares-lint,想用 var 就报警报了,所以上面那种问题日常编码中大概率不会遇到,但是我们真不会遇到闭包带来的问题么?

useState 闭包

function Counter() {
  const [count, setCount] = useState(0);
  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(count);
    }, 3000);
  };
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={log}>Click me</button>    
    </div>
  );
}

class Counter extends Component {
  state = { count: 0 };
  log = () => {
    this.setState({
      count: this.state.count + 1,
    });
    setTimeout(() => {
      console.log(this.state.count);
    }, 3000);
  };
  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={this.log}>Click me</button>      
      </div>
    );
  }
}

上面这两个组件,都是 React 的组件,一个是函数组件一个是类组件,如果快速点击三次,看看结果,看看打印在控制台的结果是怎样的

  • 类组件输出是 3 3 3,因为类组件 3 秒后执行 setTimeout 的回调的时候, this.state.count 变成 3 了,这个没问题

  • 正当你以为函数组件也是输出 3 3 3 的时候,其实实际是输出 0 1 2,为什么呢?

  • 这时候看下联想一下和本文章相关的问题,是不是能想象到,这里也是一个闭包呢?每次渲染都需要重新执行一次函数组件,setTimeout 的回调函数通过作用域链去获取到本次渲染的 stateprop

  • 从闭包的角度上想,确实是对的,我们来拆解一下函数组件的渲染过程

    • 三次点击,共 4 次渲染,count0 变为 3

    • 页面第一次渲染,页面看到 的 count = 0

    • 第一次点击,事件处理器获取的 count = 0count 变成 1,第二次渲染,渲染后页面的看到 count = 1

    • 第二次点击,事件处理器获取的 count = 1count 变成 2, 第三次渲染,渲染后页面看到 count = 2

    • 第三次点击,事件处理器获取的 count = 2count 变成 3, 第四次渲染,渲染后页面看到 count = 3

  • 每次渲染,调用函数组件,回调函数通过闭包去获取本次渲染的 stateprop,这个导致了输出和类组件不一样。但是如果希望和类组件一样都是输出 3 3 3,实现方式也可以参考类组件的 thisthis 在整个类组件生命周期中,都指向自身。函数组件其实有个类似的 useRef ,通过 useRef包裹的值,返回一个被包裹的对象,这个对象在整个生命周期是不会改变的,通过这个可以模拟 this,就可以简单的实现输出 3 3 3,如下:

function Counter() {
  const count = useRef(0);
  const log = () => {
    count.current++;
    setTimeout(() => {
      console.log(count.current);
    }, 3000);
  };
  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button onClick={log}>Click me</button> 
    </div>
  );
}

useState 闭包在 useEffect 中

接下来我们看下 useEffectuseState 结合起来有多少有哪些问题

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
  }, []);
  return <h1>{count}</h1>;
}

这个组件写的很简单,实现的功能也简单,就是每一秒中, setState 一次,按照期望应该组件 count 会从 0 开始无限递增,但是实际上,页面变成 1之后,页面就不变了,难道是定时器坏了?其实并不是。

当尝试在定时器回调中输出 count,发现每次定时器执行的时候,count的值都是 0,所以定时器每次执行的都是 setCount(0 + 1),所以才发现页面一直都是 1

这里也是一个闭包问题,setInterval 在组件渲染完之后,初始化了一次,然后定时器的回调函数每次执行的时候,通过作用域链去获取到的 count 都是页面首次渲染后的 count,所以获取到的才是 0

这里要实现原本的效果也很简单

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      // setCount(count + 1);
      setCount((c) => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <h1>{count}</h1>;
}

setCount 改一下调用方式,通过回调函数来调用,回调函数返回的是最新的 state,当编码中有遇到需要获取最新的 state 的时候,类似这种,可以使用回调函数,也可以使用 useRef 缓存一下 state 的值,下面示范一下怎么通过回调函数获取最新的 state 并且不重新渲染组件

let latestState;
setCount((c) => {
  latestState = c;
  return c;
});

调用 State Hook 的更新函数并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行。

如上面代码和 react 官方文档的说明,如果传入一个同样的 state,该次子组件不会重新渲染,通过这个方式可以获取到最新的 state,如上述代码中的 latestState。 但是这样写不太直观,也不好理解,可以用 useRef 来处理一下 ,当 state 改变的之后,改变 useRef 的值就好

总结

闭包其实在日常生活中用处非常多,并不只限于某些题目中,闭包是作用域链带来的一个副作用,但是利用好闭包可以做很多事情