React中的过时闭包:useCallback
如果你还记得 使用 useMemo、useCallback 和 React.memo 进行记忆化(缓存)章节,上面代码看起来会觉得很熟悉。事实上,我们不过是实现useCallback能为我们做的事。每次我们调用useCallback,我们都生成了一个闭包,而我们传递给它的函数则被缓存了:
// that inline function is cached exactly as in the section before
const onClick = useCallback(() => {}, []);
如果useCallback内的函数需要访问状态或者属性,我们则需要把这些状态或者属性传入到依赖数组里:
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
代码示例: advanced-react.com/examples/10…
React中的过时闭包:Refs
Refs是第二个容易出现过时闭包问题的API。
如果我用Ref来缓存onClick函数,会如何?这是网上一些文章推荐的、用来缓存组件属性的做法。表面上看,这样确实使代码更简单了:把函数传入useRef即可,通ref.current访问即可。而且,不用担心依赖数组的问题。
const Component = () => {
const ref = useRef(() => {
// click handler
});
return <HeavyComponent onClick={ref.current}>
}
然而,我们组件内的每个函数都会形成一个闭包,包括我们传递给 useRef 的函数也是如此。我们的 ref 在被创建时只会初始化一次,而且它自身永远不会更新。这基本上就是我们一开始创建时的逻辑。只不过,我们传递的不是(某个具体的)值,而是我们想要保留的函数。就像下面这样:
const ref = {};
const useRef = (callback) => {
if (!ref.current) {
ref.current = callback;
}
return ref.current;
}
在下面这个例子中,闭包是在组件一开始的时候创建的,当这个组件被挂载时,这个闭包会被保存,而且永远不会更新了。当我们尝试去做个函数内储存的状态和属性时,得到是其初始化时的值:
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钩子添加依赖数组一样,通过useEffect更新ref:
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)
});
useEffect(() => {
ref.current = () => {
console.log(someProp);
console.log(state);
}
}, [state, someProp])
}
代码示例: advanced-react.com/examples/10…
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函数内的value从不发生更新,你可以告诉我为什么吗?
这也是一个过时闭包。当我们创造了onClick函数后,一个value为默认值undefined的闭包就生成了。我们把这个闭包和title属性传递了被缓存的HeavyComponentMemo组件。而在比较函数内部,只对比了前后的title属性。而这个title一直没有变化,所以比较函数的返回值永远为true,因此HeavyComponent组件从不更新,所以onClick所指向的,还是那个默认值为undefined的闭包。
那么,我们该如何解决这个问题呢?
理论上,我们需要比较每一个属性的前后值,所以比较函数需要涵盖onClick函数:
(before, after) => {
return (
before.title === after.title
before.onClick === after.onClick
)
}
然而,在这种情况下,这意味着我们只是在重新实现 React 的默认行为,并且做的事情与不带比较函数的 React.memo所做的完全一样。所以我们可以直接舍弃它,只保留HeavyComponent这种形式就好了。
但是这么做的话,要把onClick包裹在useCallback里。而这个useCallback又依赖value的变化,所以每次键入都会引起更新。而此时,这个HeavyComponent组件也会因此重新渲染,又回到了最初的bug。
我们可以尝试运用组合的方式,试着提取并分离状态或者HeavyComponent。这是我们在前几章中探究过的技巧。但这并不容易:input 和 HeavyComponent两者都依赖于那个状态。
我们可以尝试很多其他办法。不过,我们并不需要进行大规模的重构来摆脱那个闭包陷阱。这里有一个很棒的窍门能帮到我们。
使用Refs来逃离闭包陷阱
这个技巧令人惊奇:它很简单,却可以永远地改变你在React中缓存函数的方式。也许,它对后面的章节也很重要,所以好好研究一下这个技巧吧。
让我们抛开React.memo的比较函数和如何实现onClick不谈,我们先实现一个有状态和缓存了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={...}
/>
</>
);
}
现在,我们需要实现一个函数,这个函数在重新渲染前后指向的是同一个地址,而它可以在不重新创建的前提下访问最新的状态。
我们需要把这个函数存在Ref里:
const Form = () => {
const [value, setValue] = useState();
// adding an empty ref
const ref = useRef();
}
为了使这个函数可以访问最新的状态,提需要在每次重新渲染时,被重新创建。我们无法逃离它,这是闭包的本质,React也无法改变它。我们可以在useEffect中更新Refs,而不是在渲染中更新:
const Form = () => {
const [value, setValue] = useState();
// adding an empty ref
const ref = useRef();
useEffect(() => {
ref.current = () => {
// our callback that we want to trigger
// with state
console.log(value);
}
// no dependencies array!
})
}
没有依赖数组的useEffect会在每次重新渲染时被触发。而这,正是我们想要的。如此一来,ref.current的闭包会在每次重新渲染时重新生成,这样就能拿到最新的状态了。
但我们现在还不能把ref.current直接丢给HeavyComponentMemo,因每次重新渲染ref.current的引用是不一样的,缓存也会因此失效。
const Form = () => {
const [value, setValue] = useState();
// adding an empty ref
const ref = useRef();
useEffect(() => {
ref.current = () => {
// our callback that we want to trigger
// with state
console.log(value);
}
// no dependencies array!
})
return (
<>
{/* Can't do that, will break memoization*/}
<HeavyComponentMemo onClick={ref.current} />
</>
)
}
那么,我们可以生成一个包裹在useCallback里的函数:
const Form = () => {
const [value, setValue] = useState();
// adding an empty ref
const ref = useRef();
useEffect(() => {
ref.current = () => {
// our callback that we want to trigger
// with state
console.log(value);
}
// no dependencies array!
})
const onClick = useCallback(() => {
// empty dependency ! will never change
}, [])
return (
<>
{/* Can't do that, will break memoization*/}
<HeavyComponentMemo onClick={onClick} />
</>
)
}
现在,onClick所指向的地址不会变化了。但是,这个函数啥用没有!
而接下来,正是使用魔法的时候:在这个被缓存的函数中,调用ref.current:
useEffect(() => {
ref.current = () => {
console.log(value);
};
});
const onClick = useCallback(() => {
// call the ref here
ref.current();
// still empty dependencies array!
}, []);
但是当一个闭包将其周围的所有东西都 “冻结” 时,它并不会使对象变成不可变的或者被冻结的状态。对象是存储在内存的不同部分的,而且多个变量可以包含对同一个对象的引用。
const a = { value: 'one' };
// b is a different variable that references the same object
const b = a;
如果我通过其中一个引用去改变这个对象,然后再通过另一个引用去访问它,那么所做的更改将会体现出来,具体如下:
a.value = "two";
console.log(b.value); // will be "two"
在我们所讨论的这种情况中,甚至都不会出现上述情况:在 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="Welcome closures"
onClick={onClick}
/>
</>
);
};
如此一来,我们达到了两个目标:HeavyComponet组件被合适地缓存了,不会因为状态变化而频繁地重新渲染:onClick函数也可以在不破坏缓存的情况下访问最新的数据。现在,我们可以安全地传输数据去后端了!
代码示例: advanced-react.com/examples/10…
知识概要
希望这篇文章对你有帮助。在下一章,我们将探讨另一个逃离闭包陷阱的方法:截流和防抖函数。在此之前,希望你记住:
- 每当在一个函数内生成另一个函数时,就生成了一个闭包。
- 自React组件是函数组件后,组件里的每一个函数都是一个闭包,包括
useCallback、useRef这些钩子。 - 当一个会生成闭包的函数被调用了,闭包内的数据就被“冻结了”,类似于一个快照。
- 为了更新闭包内的数据,我们需要重新调用这个能生成闭包的函数。而这正是
useCallback的依赖数组做的。 - 如果我们不为钩子添加依赖数组,或者不更新指向
ref.current的函数,这个闭包就“过时”了。 - 我们可以利用Ref的可变性来避免进入“过时闭包”陷阱。我们可以在过时闭包外改变
ref.current的指向,并访问它。