查看原文
其他

【第1872期】React Hooks的体系设计之三 - 什么是ref

张立理 前端早读课 2020-03-02

前言

React Hooks的体系设计又来了。今日早读文章由@张立理授权分享。

正文从这开始~~

原本应该继续讲状态管理,但是为了能完整地展开整个hook的生态,不得不先插入一个章节讲清楚一个概念,到底什么是ref,它有何用。

ref自React之初就不离不弃,最远古时代的字符串形式:

  1. <div ref="root" />

到函数的形式:

  1. <div ref={e => this.root = e} />

到createRef:

  1. class Foo extends Component {

  2. root = createRef();


  3. render() {

  4. return <div ref={this.root} />;

  5. }

  6. }

到useRef:

  1. const Foo = () => {

  2. const root = useRef();


  3. return <div ref={root} />;

  4. };

既然讲hook,重点就来说说useRef这东西。

DOM与坑

最常见的useRef的用法就是保存一个DOM元素的引用,然后拿着useEffect去访问:

  1. const Foo = ({text}) => {

  2. const [width, setWidth] = useState();

  3. const root = useRef(null);

  4. useLayoutEffect(

  5. () => {

  6. if (root.current) {

  7. setWidth(root.current.offsetWidth);

  8. }

  9. },

  10. []

  11. );


  12. return <span ref={root}>{text}</span>;

  13. };

一段很常见的,运作地很好的代码。但如果我们把需求做一些变化,增加一个visible: boolean属性,然后变成:

  1. return visible ? <span ref={root}>{text}</span> : null;

会发生什么呢?

很遗憾的是,这个组件如果第一次渲染的时候指定了visible={false}的话,是无法正常工作的,具体可以参考这个Sandbox示例:https://codesandbox.io/s/conditional-ref-and-effect-t3pmo

这不仅仅存在于特定条件返回元素的情况下,还包含了不少其它的场景:

  • 根据条件返回不同的DOM元素,如div和span换着来。

  • 返回的元素有key属性且会变化。

熟悉useEffect的人可能会发现,这个不执行的原因无非是没有传递依赖给useEffect函数,那么如果我们将ref.current传递过去呢?

  1. useLayoutEffect(

  2. () => {

  3. // ...

  4. },

  5. [ref.current]

  6. );

在一定的场景下,比如上面的示例,这种方式是可行的,因为当ref.current变化时,代表着渲染的元素发生了变化,这个变化一定是由一次渲染引起的,也一定会触发对应的useEffect执行。但也存在不可行的时候,有些DOM的变化并非由渲染引起,那么就不会有相应的useEffect被触发。

这是useRef的一个神奇之处,虽然从名字上来说它应当被广泛应用于和DOM元素建立关联,但往往拿它和DOM元素关联存在着会被坑的场景。

ref的真实身份

让我们回到class时代看看createRef的用法:

  1. class Foo extends Component {

  2. root = createRef();


  3. componentDidMount() {

  4. this.setState({width: this.root.current.offsetWidth});

  5. }


  6. render() {

  7. return <div ref={this.root} />;

  8. }

  9. }

仔细地观察一下,createRef是被用在什么地方的:它被放在了类的实例属性上面。

由此而得,一个快速的结论:

ref是一个与组件对应的React节点生命周期相同的,可用于存放自定义内容的容器。

在class时代,由于组件节点是通过class实例化而得,因此可以在类实例上存放内容,这些内容随着实例化产生,随着componentWillUnmount销毁。但是在hook的范围下,函数组件并没有this和对应的实例,因此useRef作为这一能力的弥补,扮演着跨多次渲染存放内容的角色。

每一个希望深入hook实践的开发者都必须记住这个结论,无法自如地使用useRef会让你失去hook将近一半的能力。

一个定时器

在知晓了ref的真实身份之后,来看一个实际的例子,试图实现一个useInterval以定期执行函数:

  1. const useInterval = (fn, time) => useEffect(

  2. () => {

  3. const tick = setInterval(fn);

  4. return () => clearInterval(tick);

  5. },

  6. [fn, time]

  7. );

这是一个基于useEffect的实现,如果你试图这样去使用它:

  1. useInterval(() => setCounter(counter => counter + 1));

你会发现和你预期的“每秒计数加一”不同,这个定时器执行频率会变得非常诡异。因为你传入的fn每一次都在变化,每一次都导致useEffect销毁前一个定时器,打开一个新的定时器,所以简而言之,如果1秒之内没有重新渲染,定时器会被执行,而如果有新的渲染,定时器会重头再来,这让频率变得不稳定。

为了修正频率的稳定性,我们可以要求使用者通过useCallback将传入的fn固定起来,但是总有百密一疏,且这样的问题难以发现。此时我们可以拿出useRef换一种玩法:

  1. const useTimeout = (fn, time) => {

  2. const callback = useRef(fn);

  3. callback.current = fn;

  4. useEffect(

  5. () => {

  6. const tick = setTimeout(callback.current);

  7. return () => clearTimeout(tick);

  8. },

  9. [time]

  10. );

  11. };

把fn放进一个ref当中,它就可以绕过useEffect的闭包问题,让useEffect回调每一次都能拿到正确的、最新的函数,却不需要将它作为依赖导致定时器频率不稳定。

React官方也曾经写过一些说明这一现象的博客,他们称useRef为“hook中的作弊器”,我想这个形容是准确的,所谓的“作弊”,其它是指它打破了类似useCallback、useEffect对闭包的约束,使用一个“可变的容器”让ref不需要成为闭包的依赖也可以在闭包中获得最新的内容。

这也是我们发布的@huse/timeout包的具体实现,我们同时提供了useTimeout和useInterval,还附加一个useStableInterval会感知函数的执行时间(包括异步函数)并确保更加稳定的函数执行间隔。

除此之外,@huse/poll是一个更为智能的定时实现,能够根据用户对页面的关注状态选择不同的频率,非常适用于定时拉取数据的场景。

useRef因为其可变内容、与组件节点保持相同生命周期的特点,其实有非常多的奇妙用法,这在后续我会专门拿出一个章节来讲。

回调ref

为了解决useRef与DOM元素关联时的坑,最保守的方式就是使用函数作为ref:

  1. const Foo = ({text, visible}) => {

  2. const [width, setWidth] = useState();

  3. const ref = useCallback(

  4. element => element && setWidth(element.offsetWidth),

  5. []

  6. );


  7. return visible ? <span ref={ref}>{text}</span> : null;

  8. };

函数的ref一定会在元素生成或销毁时被执行,可以确保追踪到最新的DOM元素。但它依然有一个缺点,例如我们想要实现这样的一个功能:

任意一段文字,通过计时器循环每个字符变色。

假设我们突发奇想不想用状态去控制变色的字符,我们就可以写出类似这样的代码:

  1. useEffect(

  2. () => {

  3. const element = ref.curent;

  4. const tick = setInterval(

  5. () => {

  6. // 循环取下一个字符变色

  7. },

  8. 1000

  9. );


  10. return () => clearInterval(tick);

  11. },

  12. []

  13. );

这是经典的useEffect的使用方式,返回一个函数来销毁之前的副作用。但是前面说了,useRef 和useEffect的配合是存在坑的,我们需要改造成函数ref,但是函数ref不支持销毁……

所以最后我们妥协了,依然使用useEffect,但在渲染时确保只生成一个DOM元素,让useEffect一定能生效:

  1. return <span ref={ref} style={{display: visible ? '' : 'none'}}>{text}</span>;

在这个场景下这样是可以“绕过”问题,并最终产出有效可用的代码的。但如果换一个场景呢:

使用jQuery LightBox插件,对一个图片增加点击预览功能。

现在我们面对的是一个img元素,在没有src的时候这东西可不是简单的display: none就能安分守己的,你不得不采取return null的形式解决问题,那么你依然会提上useEffect的局限性。

其实换个角度,我们真正缺失的是“将销毁函数保留下来以待执行”的功能,这是不是非常像useTimeout或者useInterval的功能?无非一个是延后一定时间执行,一个是延后到DOM元素销毁时执行。

也就是说,我们完全可以用useRef本身去保存一个销毁函数,来实现与useEffect等价的能力:

  1. const noop = () => undefined;


  2. const useEffectRef = callback => {

  3. const disposeRef = useRef(noop);

  4. const effect = useCallback(

  5. element => {

  6. disposeRef.current();

  7. // 确保这货只被调用一次,所以调用完就干掉

  8. disposeRef.current = noop;


  9. if (element) {

  10. const dispose = callback(element);


  11. if (typeof dispose === 'function') {

  12. disposeRef.current = dispose;

  13. }

  14. else if (dispose !== undefined) {

  15. console.warn('Effect ref callback must return undefined or a dispose function');

  16. }

  17. }

  18. },

  19. [callback]

  20. );


  21. return effect;

  22. };


  23. const Foo = ({visible, text}) => {

  24. const colorful = useCallback(

  25. element => {

  26. const tick = setInterval(

  27. () => {

  28. // 循环取下一个字符变色

  29. },

  30. 1000

  31. );


  32. return () => clearInterval(tick);

  33. },

  34. []

  35. );

  36. const ref = useEffectRef(colorful);


  37. return visible ? <span ref={ref}>{text}</span> : null;

  38. };

可以看到,就是将之前useEffect中的代码移到了useEffectRef里(要用useCallback包一下),代码很容易迁移,这也算是useRef的一个经典使用场景。

我们通过@huse/effect-ref提供了useEffectRef能力,同时基于它在@huse/element-size中实现了useElementSize、useElementResize等hook,能够有效提升业务开发的效率。

关于本文 作者:@张立理 原文:https://zhuanlan.zhihu.com/p/109742536

@张立理曾分享过


【第1866期】React Hooks的体系设计之二 - 状态粒度


【第1859期】React Hooks的体系设计之一 - 分层


为你推荐


【第1836期】Remax - 使用 React 开发小程序


【第1831期】React团队的技术准则


【第1795期】SWR:最具潜力的 React Hooks 数据请求库

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存