const HeavyComponentMemo = React.memo(HeavyComponent); // 堆代码 duidaima.com const Form = () => { const [value, setValue] = useState(); return ( <> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <HeavyComponentMemo /> </> ); };这个 Heavy 组件只接受一个字符串 props(比如 title)和一个 onClick 回调。当你点击该组件中的 "完成" 按钮时,就会触发这个回调。如果你想在点击时提交表单数据。这也很简单:只需将 title 和 onClick 这两个 props 传递给它即可。
const HeavyComponentMemo = React.memo(HeavyComponent); 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} /> </> ); };现在,你又会面临一个新的问题。我们知道,React.memo 封装的组件上的每个 props 都必须是原始值,或者在重新渲染时是保持不变的。否则,memoization 就是不起作用的。所以,从技术上讲,我们需要将 onClick 包装为 useCallback:
const onClick = useCallback(() => { // submit data here }, []);但我们也知道,useCallback 钩子应在其依赖关系数组中声明所有依赖关系。因此,如果我们想在其中提交表单数据,就必须将该数据声明为依赖项:
const onClick = useCallback(() => { // submit data here console.log(value); // adding value to the dependency }, [value]);现在的难题是:即使我们的 onClick 被 memo 化了,但每次表单有重新输入时,它仍然会发生变化。因此,我们的性能优化毫无用处。
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 = () => { // 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} /> </> ); };起作用了,我们在输入框中输入内容,Heavy 组件不会重新渲染,性能也不会受到影响。但是我们又遇到了新的问题:如果在输入框中输入内容,然后按下按钮,我们在 onClick 中打印的值是 undefined 。但它不可能是 undefined,如果我在 onClick 之外添加 console.log,它就会正确打印。
// those one logs it correctly console.log(value); const onClick = () => { // this is always undefined console.log(value); };这是怎么回事呢?这就是所谓的 "过期闭包 "问题。为了解决这个问题,我们首先需要了解一下 JavaScript 中最令人恐惧的话题:闭包及其工作原理。
function something() { // } const something = () => {};通过这样的操作,我们创建了一个局部作用域:代码中的一个区域,其中声明的变量从外部是不可见的。
const something = () => { const value = 'text'; }; console.log(value); // not going to work, "value" is local to "something" function每次我们创建函数时都会发生这种情况。在另一个函数内部创建的函数将具有自己的局部作用域,对于外部函数不可见。
const something = () => { const inside = () => { const value = 'text'; }; console.log(value); // not going to work, "value" is local to "inside" function };然而,在相反的方向就不一样了,最里面的函数可以访问到外部声明的所有变量。
const something = () => { const value = 'text'; const inside = () => { // perfectly fine, value is available here console.log(value); }; };这就是通过创建所谓的“闭包”来实现的。内部函数“闭包”了来自外部的所有数据,它本质上就是所有“外部”数据的快照,这些数据被冻结并单独存储在内存中。如果我们不是在 something 函数内创建该值,而是将其作为参数传递并返回内部函数呢:
const something = (value) => { const inside = () => { // perfectly fine, value is available here console.log(value); }; return inside; };我们会得到这样的行为:
const first = something('first'); const second = something('second'); first(); // logs "first" second(); // logs "second"我们调用 something 函数时传入值 first,并将结果分配给一个变量。结果是对内部声明的函数的引用,形成闭包。从现在开始,只要保存这个引用的第一个变量是存在的,我们传递给它的值 “first” 就会被冻结掉,并且内部函数将可以访问它。第二次调用也是同样的情况:我们传递了一个不同的值,形成一个闭包,返回的函数也将永远可以访问该变量。
const something = (value) => { const r = Math.random(); const inside = () => { // ... }; return inside; }; const first = something('first'); const second = something('second'); first(); // logs random number second(); // logs another random number这就像拍摄一些动态场景的照片一样:只要按下按钮,整个场景就会永远 “冻结” 在照片中。下次按下按钮不会改变之前拍摄的照片中的任何内容。
const Component = () => { const onClick = () => { // closure! }; return <button onClick={onClick} />; };useEffect 或 useCallback 钩子中的所有内容都是一个闭包:
const Component = () => { const onClick = useCallback(() => { // closure! }); useEffect(() => { // closure! }); };它们都可以访问组件中声明的 state、props 和局部变量:
const Component = () => { const [state, setState] = useState(); const onClick = useCallback(() => { // perfectly fine console.log(state); }); useEffect(() => { // perfectly fine console.log(state); }); };组件内的每个函数都是一个闭包,因为组件本身只是一个函数。
const something = (value) => { const inside = () => { console.log(value); }; return inside; };问题是每次调用都会重新创建内部函数,如果我决定尝试缓存它,会发生什么情况呢?类似这样:
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(); // logs "first" second(); // logs "first" third(); // logs "first"无论我们用不同的参数调用多少次 something 函数,记录的值始终是第一个参数!我们刚刚就创建了一个所谓的 "过期闭包"。每个闭包在创建时都是冻结的,当我们第一次调用 something 函数时,我们创建了一个值变量中包含 "first" 的闭包。然后,我们把它保存在 something 函数之外的一个对象中。
const cache = {}; let prevValue; const something = (value) => { // check whether the value has changed if (!cache.current || value !== prevValue) { cache.current = () => { console.log(value); }; } // refresh it prevValue = value; return cache.current; };将值保存在变量中,以便我们可以将下一个值与前一个值进行比较。如果变量发生了变化,则刷新 cache.current 闭包。现在,它就会正确打印变量,如果我们比较具有相同值的函数,比较结果将返回 true:
const first = something('first'); const anotherFirst = something('first'); const second = something('second'); first(); // logs "first" second(); // logs "second" console.log(first === anotherFirst); // will be trueReact 中的过期闭包:useCallback
// that inline function is cached exactly as in the section before const onClick = useCallback(() => { }, []);如果我们需要访问此函数内的 state 或 props,我们需要将它们添加到依赖项数组中:
const Component = () => { const [state, setState] = useState(); const onClick = useCallback(() => { // access to state inside console.log(state); // need to add this to the dependencies array }, [state]); };这个依赖关系数组会让 React 刷新缓存的闭包,就像我们在比较 value !== prevValue 时所做的一样。如果我忘记了这个数组,我们的闭包就会过期:
const Component = () => { const [state, setState] = useState(); const onClick = useCallback(() => { // state will always be the initial state value here // the closure is never refreshed console.log(state); // forgot about dependencies }, []); };每次我们触发该回调时,所有将被打印的内容都是 undefined。
const Component = () => { const ref = useRef(() => { // click handler }); // ref.current stores the function and is stable between re-renders return <HeavyComponent onClick={ref.current} />; };然而,组件内的每个函数都会形成一个闭包,包括我们传递给 useRef 的函数。我们的 ref 在创建时只会初始化一次,并且不会自行更新。这基本上就是我们一开始创建的逻辑,只是我们传递的不是值,而是我们想要保留的函数。像这样:
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(() => { // both of them will be stale and will never change console.log(someProp); console.log(state); }); };为了解决这个问题,我们需要确保每次我们试图访问的内容发生变化时,ref 值都会更新。本质上,我们需要实现 useCallback 钩子的依赖数组所做的事情。
const Component = ({ someProp }) => { // initialize ref - creates closure! const ref = useRef(() => { // both of them will be stale and will never change console.log(someProp); console.log(state); }); useEffect(() => { // update the closure when state or props change ref.current = () => { console.log(someProp); console.log(state); }; }, [state, someProp]); };React 中的过期闭包: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 中的值从未更新过,你能告诉我为什么吗?
(before, after) => { return ( before.title === after.title && before.onClick === after.onClick ); };不过,在这种情况下,这意味着我们只是重新实现了 React 的默认行为,做的事情与不带比较函数的 React.memo 完全一样。因此,我们可以放弃它,只保留 React.memo(HeavyComponent)。但这样做意味着我们需要将 onClick 包装为 useCallback。但这取决于 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(); // adding an empty ref const ref = useRef(); };为了让函数能够访问最新状态,每次重新渲染时都需要重新创建函数,这是无法避免的,这也是闭包的本质,与 React 无关。我们应该在 useEffect 中修改 Ref,而不是直接在渲染中修改 Ref,所以我们可以这样做:
const Form = () => { const [value, setValue] = useState(); // adding an empty ref const ref = useRef(); useEffect(() => { // our callback that we want to trigger // with state ref.current = () => { console.log(value); }; // no dependencies array! }); };不带依赖数组的 useEffect 会在每次重新渲染时触发。这正是我们想要的,所以现在在我们的 ref.current 中,我们有一个每次重新渲染都会重新创建的闭包,因此打印的 state 始终是最新的。
const Form = () => { const ref = useRef(); useEffect(() => { ref.current = () => { console.log(value); }; }); return ( <> {/* Can't do that, will break memoization */} <HeavyComponentMemo onClick={ref.current} /> </> ); };所以,我们创建一个封装在 useCallback 中的空函数,并且不依赖于此函数。
const Form = () => { const ref = useRef(); useEffect(() => { ref.current = () => { console.log(value); }; }); const onClick = useCallback(() => { // empty dependency! will never change }, []); return ( <> {/* Now memoization will work, onClick never changes */} <HeavyComponentMemo onClick={onClick} /> </> ); };现在,memoization 可以完美地工作,因为 onClick 从未改变。但有一个问题:它什么也会不做。
useEffect(() => { ref.current = () => { console.log(value); }; }); const onClick = useCallback(() => { // call the ref here ref.current(); // still empty dependencies array! }, []);注意到 ref 并不在 useCallback 的依赖关系中吗?ref 本身是不会改变的。它只是 useRef 钩子返回的一个可变对象的引用。但是,当闭包冻结周围的一切时,并不会使对象不可变或被冻结。对象存储在内存的不同部分,多个变量可以包含对完全相同对象的引用。
const a = { value: 'one' }; // b is a different variable that references the same object const b = a;如果我通过其中一个引用更改对象,然后通过另一个引用访问它,更改就会出现:
a.value = 'ConardLi'; console.log(b.value); // will be "ConardLi"在我们的案例中,这种情况并没有发生:我们在 useCallback 和 useEffect 中拥有完全相同的引用。因此,当我们更改 useEffect 中 ref 对象的 current 属性时,我们可以在 useCallback 中访问该属性,这个属性恰好是一个捕获了最新状态数据的闭包。完整代码如下:
const Form = () => { const [value, setValue] = useState(); const ref = useRef(); useEffect(() => { ref.current = () => { // will be latest console.log(value); }; }); const onClick = useCallback(() => { // will be latest ref.current?.(); }, []); return ( <> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <HeavyComponentMemo title="你好 code秘密花园" onClick={onClick} /> </> ); };现在,我们获得了两全其美的结果:Heavy 组件被适当地 memoization,不会因为每次状态变化而重新渲染。它的 onClick 回调可以访问组件中的最新数据,而不会破坏 memoization。现在,我们可以安全地将所需的一切发送到后端!
6.在 React 中,我们可以利用 Ref 是一个可变对象这一特性,从而摆脱 "过期闭包" 的问题。我们可以在过期闭包之外更改 ref.current,然后在闭包之内访问它,就可以获取最新的数据。