hello 大家好,我是 superZidan,这篇文章想跟大家聊聊 React 中的闭包 这个话题,如果大家遇到任何问题,欢送 分割我

JavaScript 中的闭包肯定是最可怕的个性之一。 即便是无所不知的 ChatGPT 也会通知你这一点。 它也可能是最隐秘的语言概念之一。 每次编写任何 React 代码时,咱们都会用到它,大多数时候咱们甚至没有意识到。 但最终还是无奈解脱它们:如果咱们想编写简单且高性能的 React 应用程序,咱们就必须理解闭包。

因而,让咱们深入研究它,并在此过程中学习以下内容:

  • 什么是闭包,它们是怎么呈现的,为什么咱们须要它们
  • 什么是过期闭包,为什么它们会呈现
  • React 中哪些常见的场景会导致过期闭包,以及如何应答它们

正告:如果你从未解决过 React 中的闭包,这篇文章可能会让你的大脑爆炸。 当你浏览本文时,请确保随身携带足够的巧克力来刺激脑细胞。

问题呈现

设想一下你正在实现一个带有几个输入框的表单。 其中一个字段是来自某些内部库的十分重的组件。 你没有方法理解其内部结构,因而无奈修复其性能问题。 但你的表单中的确须要它,因而你决定将其包装在 React.memo 中,以在表单中的状态发生变化时最大水平地缩小从新渲染的频率。 像这样:

    const HeavyComponentMemo = React.memo(HeavyComponent);    const Form = () => {      const [value, setValue] = useState();      return (        <>          <input            type="text"            value={value}            onChange={(e) => setValue(e.target.value)}          />          <HeavyComponentMemo />        </>      );    };

到目前为止,所有都很好。 这个「十分重」的组件只接管一个字符串属性(比方 title)和一个 onClick 回调函数。 当单击这个组件内的 “实现” 按钮时会触发此回调函数。 并且心愿在产生此单击时提交表单数据。 也很简略:只需将题目和 onClick 属性传递给它即可。

    const HeavyComponentMemo = React.memo(HeavyComponent);    const Form = () => {      const [value, setValue] = useState();      const onClick = () => {        // 在这里提交表单数据        console.log(value);      };      return (        <>          <input            type="text"            value={value}            onChange={(e) => setValue(e.target.value)}          />          <HeavyComponentMemo            title="Welcome to the form"            onClick={onClick}          />        </>      );    };

当初你将面临两难地步。 家喻户晓,React.memo 中包装的组件上的每个 prop 都须要是原始值或在从新渲染之间放弃不变。 否则,记忆缓存将不起作用。 因而从技术上讲,咱们须要将 onClick 包装在 useCallback 中:

    const onClick = useCallback(() => {        // 在这里提交表单数据    }, []);

而且,咱们晓得 useCallback 这个 hook 应该在其依赖项数组中申明所有依赖项。 因而,如果咱们想在外部提交表单数据,咱们必须将该数据申明为依赖项:

    const onClick = useCallback(() => {        // 在这里提交表单数据      console.log(value);      // 增加数据作为依赖项    }, [value]);

这就是一个窘境:只管咱们的 onClick 被记忆缓存了,但每次有人在输入框中输出时它依然会发生变化。 所以咱们的性能优化是没有用的。

好吧,让咱们寻找其余解决方案。 React.memo 有一个叫做 comparison function 的货色。它容许咱们更精密地管制 React.memo 中的 props 比拟。通常,React 会自行将所有 “上一次更新”的 props 与所有 “下一次更新” props 进行比拟。 如果咱们应用了这个函数,它将依赖于它的返回后果。 如果它返回 true,那么 React 就会晓得 props 是雷同的,并且组件不应该被从新渲染。 这听起来正是咱们所须要的

咱们只关怀更新一个 props,即咱们的 title,所以它不会那么简单:

    const HeavyComponentMemo = React.memo(      HeavyComponent,      (before, after) => {        return before.title === after.title;      },    );

整个表单的代码将如下所示

    const HeavyComponentMemo = React.memo(      HeavyComponent,      (before, after) => {        return before.title === after.title;      },    );    const Form = () => {      const [value, setValue] = useState();      const onClick = () => {        // 在这里提交表单数据        console.log(value);      };      return (        <>          <input            type="text"            value={value}            onChange={(e) => setValue(e.target.value)}          />          <HeavyComponentMemo            title="Welcome to the form"            onClick={onClick}          />        </>      );    };

胜利了! 咱们在输出了一些内容,这个「十分重」的组件不会从新渲染,并且性能不会受到影响。

然而有一个小问题:它实际上并没有胜利。 如果你在输出某些内容,而后按下该按钮,则咱们在 onClick 中打印的 valueundefined 。 但它不能是undefined 的,然而如果我在 onClick 之外增加 console.log ,它会正确打印它。 在 onClick 外部则不正确。

这是怎么回事呢?

这就是所谓的“过期闭包”问题。 为了解决这个问题,咱们首先须要深入研究 JavaScript 中最令人恐怖的主题:闭包及其工作原理。

JavaScipt,作用域,闭包

让咱们从函数和变量开始。 当咱们在 JavaScript 中通过一般申明或箭头函数申明函数时会产生什么

    function something() {      //    }    const something = () => {};

通过这样做,咱们创立了一个部分作用域:代码中的一个作用域,其中申明的变量在内部是不可见的。

    const something = () => {      const value = 'text';    };    console.log(value); // 不起作用,value 是 something 函数的外部变量

每次咱们创立函数时都会产生这种状况。 在另一个函数外部创立的函数将具备本人的部分作用域,对于内部函数不可见

    const something = () => {      const inside = () => {        const value = 'text';      };      console.log(value); // 不起作用,value 是 inside 函数的外部变量    };

然而如果反过来,这是一条可行的路线。 最外面的函数将能「看到」内部申明的所有变量

    const something = () => {      const value = 'text';      const inside = () => {        // 十分好,value 能够在这里拜访到        console.log(value);      };    };

这是通过创立所谓的「闭包」来实现的。 外部函数「敞开」来自内部的所有数据。 它实质上是所有「内部」数据的快照,这些数据会被及时解冻并独自存储在内存中。

如果我不是在 something 函数内创立 value,而是将其作为参数传递并返回inside 函数:

    const something = (value) => {      const inside = () => {        // 十分好,value 能够在这里拜访到        console.log(value);      };      return inside;    };

咱们会失去这样的行为:

    const first = something('first');    const second = something('second');    first(); // 打印 "first"    second(); // 打印 "second"

咱们用字符串 “first” 作为参数调用 something 函数,并将后果赋值给了一个变量。 该变量则是对外部申明的函数的援用。 造成闭合。 从当初开始,只有保留该援用的 first 变量存在,咱们传递给它的字符串“first”就会被解冻,并且外部函数将能够拜访它

第二次调用也是同样的状况:咱们传递一个不同的值,造成一个闭包,并且返回的函数将永远能够拜访该变量。

对于在 something 函数内本地申明的任何变量都是如此:

    const something = (value) => {      const r = Math.random();      const inside = () => {        // ...            console.log(r)      };      return inside;    };    const first = something('first');    const second = something('second');    first(); // 打印一个随机数    second(); // 打印另外一个随机数

这就像拍摄一些动静场景的照片:只有按下按钮,整个场景就会永远“解冻”在照片中。 下次按下该按钮不会扭转之前拍摄的照片中的任何内容。

在 React 中,咱们始终在创立闭包,甚至没有意识到。 组件内申明的每个回调函数都是一个闭包:

    const Component = () => {      const onClick = () => {        // 闭包!      };      return <button onClick={onClick} />;    };

所有在 useEffect 或者 useCallback 中的都是闭包

    const Component = () => {      const onClick = useCallback(() => {        // 闭包!      });      useEffect(() => {        // 闭包!      });    };

所有闭包都能够拜访组件中申明的 state、props 和局部变量:

    const Component = () => {      const [state, setState] = useState();      const onClick = useCallback(() => {        // 没问题        console.log(state);      });      useEffect(() => {        // 没问题        console.log(state);      });

组件内的每个函数都是一个闭包,因为组件自身只是一个函数。

过期闭包问题

以上所有内容,即便你之前没有接触过太多闭包的概念,依然绝对简略。 你创立几个函数几次,它就会变得很天然。 很多年来,应用 React 编写应用程序甚至都不须要了解“闭包”的概念。

那么问题出在哪里呢? 为什么闭包是 JavaScript 中最可怕的事件之一,也是许多开发人员的苦楚之源?

这是因为只有闭包函数的援用存在,闭包就存在。 对函数的援用只是一个能够赋值给任何货色的值。 让咱们略微动动脑子。 这是下面咱们的函数,它返回一个闭包:

    const something = (value) => {      const inside = () => {        console.log(value);      };      return inside;    };

然而 inside 函数会随着每次 something 调用而从新创立。 如果我决定重构它并缓存它,会产生什么? 像这样:

    const cache = {};    const something = (value) => {      if (!cache.current) {        cache.current = () => {          console.log(value);        };      }      return cache.current;    };

外表上看,这个代码仿佛没什么问题。 咱们刚刚创立了一个名为 cache 的内部变量,并将外部函数赋值给 cache.current 属性。 当初,咱们只需返回已保留的值,而不是每次都从新创立该函数。

然而,如果咱们尝试调用它几次,咱们会看到一个奇怪的事件:

    const first = something('first');    const second = something('second');    const third = something('third');    first(); // 打印 "first"    second(); // 打印 "first"    third(); // 打印 "first"

无论咱们应用不同的参数调用 something 函数多少次,打印的值始终是第一个!

为了修复此行为,咱们心愿在每次入参发生变化时从新创立该函数及其闭包。 像这样:

    const cache = {};    let prevValue;    const something = (value) => {      // 查看值是否扭转      if (!cache.current || value !== prevValue) {        cache.current = () => {          console.log(value);        };      }      // 更新它      prevValue = value;      return cache.current;    };

将值保留在变量中,以便咱们能够将下一个入参加前一个入参进行比拟。 如果变量产生了变动,则更新 cache.current 闭包

当初它将正确打印变量,如果咱们比拟具备雷同入参的函数,则将返回 true

    const first = something('first');    const anotherFirst = something('first');    const second = something('second');    first(); // 打印 "first"    second(); // 打印 "second"    console.log(first === anotherFirst); // 返回 true

useCallback中的过期闭包

咱们刚刚实现了简直齐全一样的 useCallback hook 为咱们所做的事件! 每次咱们应用 useCallback 时,咱们都会创立一个闭包,并且咱们传递给它的函数会被缓存:

// 该内联函数的缓存与之前的局部完全相同const onClick = useCallback(() => {}, []);

如果咱们须要拜访此函数内的 state 或 props,咱们须要将它们增加到依赖项数组中:

    const Component = () => {      const [state, setState] = useState();      const onClick = useCallback(() => {        // 拜访外部 state        console.log(state);        // 须要增加到依赖数组外面      }, [state]);    };

这个依赖数组使得 React 刷新缓存的闭包,就像咱们比拟 value !== prevValue 时所做的那样。 如果我遗记填这个数组,咱们的闭包就会变得过期:

    const Component = () => {      const [state, setState] = useState();      const onClick = useCallback(() => {        // state 将永远都是初始值        // 闭包永远不会刷新        console.log(state);        // 遗记写依赖数组      }, []);    };

每次触发这个回调函数,都会打印 undefined

Refs 中的过期闭包

useCallbackuseMemo hook 之后,引入过期闭包问题的第二个最常见的办法是 Refs

如果我尝试对 onClick 回调函数应用 Ref 而不是 useCallback hook,会产生什么状况? 有时,网上的文章倡议这样做来缓存组件上的 props。 从外表上看,它的确看起来更简略:只需将一个函数传递给 useRef 并通过 ref.current 拜访它。 没有依赖,也不必放心。

    const Component = () => {      const ref = useRef(() => {        // 点击回调      });      // ref.current 存储了函数      return <HeavyComponent onClick={ref.current} />;    };

然而。 组件内的每个函数都会造成一个闭包,包含咱们传递给 useRef 的函数。 咱们的 ref 在创立时只会初始化一次,并且不会自行更新。 这基本上就是咱们一开始创立的逻辑。 只是咱们传递的不是 value,而是咱们想要保留的函数。 像这样:

    const ref = {};    const useRef = (callback) => {      if (!ref.current) {        ref.current = callback;      }      return ref.current;    };

因而,在这种状况下,在刚载入组件时一开始造成的闭包将被保留并且永远不会刷新。 当咱们尝试拜访存储在 Ref 中的函数内的 state 或 props 时,咱们只会取得它们的初始值:

    const Component = ({ someProp }) => {      const [state, setState] = useState();      const ref = useRef(() => {        // 所有都会被缓存并且永远不会扭转        console.log(someProp);        console.log(state);      });    };

为了解决这个问题,咱们须要确保每次尝试拜访外部内容发生变化时都会更新该援用值。 实质上,咱们须要实现 useCallback hook 中依赖数组所做的事件

    const Component = ({ someProp }) => {      // 初始化 ref - 创立闭包      const ref = useRef(() => {        // 所有都会被缓存并且永远不会扭转        console.log(someProp);        console.log(state);      });      useEffect(() => {        // 当 state 或者 props 更新时,及时更新闭包        ref.current = () => {          console.log(someProp);          console.log(state);        };      }, [state, someProp]);    };

React.memo 中的过期闭包

最初,咱们回到文章的结尾以及引发这所有的谜团。 咱们再看一下有问题的代码:

    const HeavyComponentMemo = React.memo(      HeavyComponent,      (before, after) => {        return before.title === after.title;      },    );    const Form = () => {      const [value, setValue] = useState();      const onClick = () => {        // submit our form data here        console.log(value);      };      return (        <>          <input            type="text"            value={value}            onChange={(e) => setValue(e.target.value)}          />          <HeavyComponentMemo            title="Welcome to the form"            onClick={onClick}          />        </>      );    };

每次咱们单击按钮时,咱们都会记录 “undefined”。 onClick 中咱们的 value 永远不会更新。 当初你能说出起因吗

当然,这又是一个过期的闭包。 当咱们创立 onClick 时,首先应用默认 state 值(即“ undefined ”)造成闭包。 咱们将该闭包与 title 属性一起传递给咱们的记忆组件。 在比拟函数中,咱们仅比拟题目。 它永远不会扭转,它只是一个字符串。 比拟函数始终返回 trueHeavyComponent 永远不会更新,因而它保留对第一个 onClick 闭包的援用,并具备解冻的 “undefined” 值。

既然咱们晓得了问题所在,那么咱们该如何解决呢? 这里说起来容易做起来难……

现实状况下,咱们应该在比拟函数中比拟每个 prop,因而咱们须要在其中蕴含 onClick

    (before, after) => {      return (        before.title === after.title &&        before.onClick === after.onClick      );    };

然而,在这种状况下,这意味着咱们只是从新实现 React 默认行为,并齐全执行没有比拟函数的 React.memo 的操作。 所以咱们能够放弃它,只将其保留为 React.memo(HeavyComponent)

但这样做意味着咱们须要将 onClick 包装在 useCallback 中。 但这取决于 state,因而它会随着每次击键而扭转。 咱们回到了第一点:咱们的「重组件」将在每次状态变动时从新渲染,这正是咱们试图防止的。

咱们能够尝试组合并尝试提取和隔离 state 或者是 HeavyComponent。 但这并不容易:输出和 HeavyComponent 都依赖于该 state。

咱们能够尝试很多其余的事件。 但咱们不须要进行任何大量的重构来解脱闭包陷阱。 有一个很酷的技巧能够在这里帮忙咱们。

应用 Refs 逃离闭包陷阱

这个技巧相对令人兴奋:它非常简单,但它能够永远扭转你在 React 中记忆函数的形式。 或者兴许没有……无论如何,它可能有用,所以让咱们深入研究它。

当初让咱们去掉 React.memoonClick 中的比拟函数。 只是一个带有 state 和记忆的 HeavyComponent 的纯组件:

    const HeavyComponentMemo = React.memo(HeavyComponent);    const Form = () => {      const [value, setValue] = useState();      return (        <>            <input type="text" value={value} onChange={(e) => setValue(e.target.value)} />            <HeavyComponentMemo title="Welcome to the form" onClick={...} />        </>      );    }

当初咱们须要增加一个 onClick 函数,该函数在从新渲染之间保持稳定,但也能够拜访最新状态而无需从新创立本身。

咱们将把它存储在 Ref 中,所以让咱们增加它。 临时为空:

    const Form = () => {      const [value, setValue] = useState();        // 增加一个空的 ref      const ref = useRef();    };

为了使函数可能拜访最新状态,须要在每次从新渲染时从新创立它。 这是无奈回避的,这是闭包的实质,与 React 无关。 咱们应该在 useEffect 中批改 Refs,而不是间接在渲染中批改,所以让咱们这样做

    const Form = () => {      const [value, setValue] = useState();      // 增加一个空的 ref      const ref = useRef();      useEffect(() => {            // 咱们须要去触发的回调函数            // 带上 state        ref.current = () => {          console.log(value);        };        // 没有依赖数组      });    };

不带依赖数组的 useEffect 将在每次从新渲染时触发。 这正是咱们想要的。 所以当初咱们的 ref.current 中,咱们有一个每次从新渲染都会从新创立的闭包,因而记录的状态始终是最新的。

但咱们不能只是将 ref.current 传递给记忆组件。 每次从新渲染时该值都会有所不同,因而缓存记忆是行不通的。

    const Form = () => {      const ref = useRef();      useEffect(() => {        ref.current = () => {          console.log(value);        };      });      return (        <>          {/* 不能这么做, 将会击穿缓存,让记忆生效 */}          <HeavyComponentMemo onClick={ref.current} />        </>      );    };

因而,咱们创立一个封装在 useCallback 中的小的空函数,并且不依赖它。

    const Form = () => {      const ref = useRef();      useEffect(() => {        ref.current = () => {          console.log(value);        };      });      const onClick = useCallback(() => {            // 依赖是空的,所以函数永远不会扭转      }, []);      return (        <>          {/* 当初缓存失效了, onClick 永远不会扭转 */}          <HeavyComponentMemo onClick={onClick} />        </>      );    };

当初,缓存记忆性能完满地起作用了—— onClick 永远不会扭转。 但有一个问题:它什么也不做。

这是一个魔术:让它工作所需的只是在该记忆回调函数中调用 ref.current

    useEffect(() => {      ref.current = () => {        console.log(value);      };    });    const onClick = useCallback(() => {      // 在这里调用 ref       ref.current();      // 仍然是空的依赖数组!    }, []);

请留神 ref 为何不在 useCallback 的依赖项中? 没必要这样。 ref 自身永远不会扭转。 它仅仅是 useRef hook 返回的可变对象的援用。

然而,当闭包解冻其四周的所有内容时,它不会使对象变得不可变或解冻。 对象存储在内存的不同局部,多个变量能够蕴含对完全相同对象的援用。

    const a = { value: 'one' };    // b 是不同的变量,指向雷同的对象    const b = a;

如果我通过其中一个援用扭转对象,而后通过另一个援用拜访它,则更改将能够失效:

    a.value = 'two';    console.log(b.value); // 失效了,打印 "two"

例子中,咱们在 useCallbackuseEffect 中有完全相同的援用。 因而,当咱们扭转 useEffectref 对象的以后属性时,咱们能够在 useCallback 中拜访该确切属性。 这个属性恰好是一个捕捉最新状态数据的闭包。

残缺的代码如下所示:

    const Form = () => {      const [value, setValue] = useState();      const ref = useRef();      useEffect(() => {        ref.current = () => {          // 最新的值          console.log(value);        };      });      const onClick = useCallback(() => {        // 最新的值        ref.current?.();      }, []);      return (        <>          <input            type="text"            value={value}            onChange={(e) => setValue(e.target.value)}          />          <HeavyComponentMemo            title="Welcome closures"            onClick={onClick}          />        </>      );    };

当初,咱们领有了两败俱伤的长处:重组件已被正确记忆缓存,并且不会随着每次状态更改而从新渲染。 它的 onClick 回调能够拜访组件中的最新数据,而不会击穿缓存。 当初能够平安地将咱们须要的所有发送到后端了!

总结

心愿所有这些都是有意义的,并且当初闭包对你来说很容易。 在你开始编码之前,请记住有敞开包的注意事项:

  • 每次在一个函数内创立另一个函数时都会造成闭包
  • 因为 React 组件只是函数,因而外部创立的每个函数都会造成一个闭包,包含 useCallbackuseRef 等 hook
  • 当调用造成闭包的函数时,它四周的所有数据都被“解冻”,就像快照一样。
  • 要更新该数据,咱们须要从新创立“敞开”函数。 这就是 useCallback 等 hook 的依赖项容许咱们做的事件
  • 如果咱们错过了依赖项,或者不刷新调配给 ref.current 的敞开函数,则闭包将变得“过期”。
  • 咱们能够利用 Ref 是一个可变对象这一状况来逃离 React 中的“过期闭包”陷阱。 咱们能够在过期闭包之外扭转 ref.current ,而后在外部拜访它。 将会是最新的数据。

感谢您的观看,如果您对本篇文章有任何的意见或倡议,欢送关注我或者给我留言

本文为翻译文,原文地址:https://medium.com/@adevnadia/fantastic-closures-and-how-to-f...