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
中打印的 value
是 undefined
。但它不能是 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 中的过期闭包
在 useCallback
和 useMemo
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
属性一起传递给咱们的记忆组件。在比拟函数中,咱们仅比拟题目。它永远不会扭转,它只是一个字符串。比拟函数始终返回 true
,HeavyComponent
永远不会更新,因而它保留对第一个 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.memo
和 onClick
中的比拟函数。只是一个带有 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"
例子中,咱们在 useCallback
和 useEffect
中有完全相同的援用。因而,当咱们扭转 useEffect
中 ref
对象的以后属性时,咱们能够在 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 组件只是函数,因而外部创立的每个函数都会造成一个闭包,包含
useCallback
和useRef
等 hook - 当调用造成闭包的函数时,它四周的所有数据都被“解冻”,就像快照一样。
- 要更新该数据,咱们须要从新创立“敞开”函数。这就是
useCallback
等 hook 的依赖项容许咱们做的事件 - 如果咱们错过了依赖项,或者不刷新调配给
ref.current
的敞开函数,则闭包将变得“过期”。 - 咱们能够利用 Ref 是一个可变对象这一状况来逃离 React 中的“过期闭包”陷阱。咱们能够在过期闭包之外扭转
ref.current
,而后在外部拜访它。将会是最新的数据。
感谢您的观看,如果您对本篇文章有任何的意见或倡议,欢送关注我或者给我留言🌹
本文为翻译文,原文地址:https://medium.com/@adevnadia/fantastic-closures-and-how-to-f…