关于javascript:详解-React-中的闭包问题

5次阅读

共计 11254 个字符,预计需要花费 29 分钟才能阅读完成。

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…

正文完
 0