【第1872期】React Hooks的体系设计之三 - 什么是ref
前言
React Hooks的体系设计又来了。今日早读文章由@张立理授权分享。
正文从这开始~~
原本应该继续讲状态管理,但是为了能完整地展开整个hook的生态,不得不先插入一个章节讲清楚一个概念,到底什么是ref,它有何用。
ref自React之初就不离不弃,最远古时代的字符串形式:
<div ref="root" />
到函数的形式:
<div ref={e => this.root = e} />
到createRef:
class Foo extends Component {
root = createRef();
render() {
return <div ref={this.root} />;
}
}
到useRef:
const Foo = () => {
const root = useRef();
return <div ref={root} />;
};
既然讲hook,重点就来说说useRef这东西。
DOM与坑
最常见的useRef的用法就是保存一个DOM元素的引用,然后拿着useEffect去访问:
const Foo = ({text}) => {
const [width, setWidth] = useState();
const root = useRef(null);
useLayoutEffect(
() => {
if (root.current) {
setWidth(root.current.offsetWidth);
}
},
[]
);
return <span ref={root}>{text}</span>;
};
一段很常见的,运作地很好的代码。但如果我们把需求做一些变化,增加一个visible: boolean属性,然后变成:
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传递过去呢?
useLayoutEffect(
() => {
// ...
},
[ref.current]
);
在一定的场景下,比如上面的示例,这种方式是可行的,因为当ref.current变化时,代表着渲染的元素发生了变化,这个变化一定是由一次渲染引起的,也一定会触发对应的useEffect执行。但也存在不可行的时候,有些DOM的变化并非由渲染引起,那么就不会有相应的useEffect被触发。
这是useRef的一个神奇之处,虽然从名字上来说它应当被广泛应用于和DOM元素建立关联,但往往拿它和DOM元素关联存在着会被坑的场景。
ref的真实身份
让我们回到class时代看看createRef的用法:
class Foo extends Component {
root = createRef();
componentDidMount() {
this.setState({width: this.root.current.offsetWidth});
}
render() {
return <div ref={this.root} />;
}
}
仔细地观察一下,createRef是被用在什么地方的:它被放在了类的实例属性上面。
由此而得,一个快速的结论:
ref是一个与组件对应的React节点生命周期相同的,可用于存放自定义内容的容器。
在class时代,由于组件节点是通过class实例化而得,因此可以在类实例上存放内容,这些内容随着实例化产生,随着componentWillUnmount销毁。但是在hook的范围下,函数组件并没有this和对应的实例,因此useRef作为这一能力的弥补,扮演着跨多次渲染存放内容的角色。
每一个希望深入hook实践的开发者都必须记住这个结论,无法自如地使用useRef会让你失去hook将近一半的能力。
一个定时器
在知晓了ref的真实身份之后,来看一个实际的例子,试图实现一个useInterval以定期执行函数:
const useInterval = (fn, time) => useEffect(
() => {
const tick = setInterval(fn);
return () => clearInterval(tick);
},
[fn, time]
);
这是一个基于useEffect的实现,如果你试图这样去使用它:
useInterval(() => setCounter(counter => counter + 1));
你会发现和你预期的“每秒计数加一”不同,这个定时器执行频率会变得非常诡异。因为你传入的fn每一次都在变化,每一次都导致useEffect销毁前一个定时器,打开一个新的定时器,所以简而言之,如果1秒之内没有重新渲染,定时器会被执行,而如果有新的渲染,定时器会重头再来,这让频率变得不稳定。
为了修正频率的稳定性,我们可以要求使用者通过useCallback将传入的fn固定起来,但是总有百密一疏,且这样的问题难以发现。此时我们可以拿出useRef换一种玩法:
const useTimeout = (fn, time) => {
const callback = useRef(fn);
callback.current = fn;
useEffect(
() => {
const tick = setTimeout(callback.current);
return () => clearTimeout(tick);
},
[time]
);
};
把fn放进一个ref当中,它就可以绕过useEffect的闭包问题,让useEffect回调每一次都能拿到正确的、最新的函数,却不需要将它作为依赖导致定时器频率不稳定。
React官方也曾经写过一些说明这一现象的博客,他们称useRef为“hook中的作弊器”,我想这个形容是准确的,所谓的“作弊”,其它是指它打破了类似useCallback、useEffect对闭包的约束,使用一个“可变的容器”让ref不需要成为闭包的依赖也可以在闭包中获得最新的内容。
这也是我们发布的@huse/timeout包的具体实现,我们同时提供了useTimeout和useInterval,还附加一个useStableInterval会感知函数的执行时间(包括异步函数)并确保更加稳定的函数执行间隔。
除此之外,@huse/poll是一个更为智能的定时实现,能够根据用户对页面的关注状态选择不同的频率,非常适用于定时拉取数据的场景。
useRef因为其可变内容、与组件节点保持相同生命周期的特点,其实有非常多的奇妙用法,这在后续我会专门拿出一个章节来讲。
回调ref
为了解决useRef与DOM元素关联时的坑,最保守的方式就是使用函数作为ref:
const Foo = ({text, visible}) => {
const [width, setWidth] = useState();
const ref = useCallback(
element => element && setWidth(element.offsetWidth),
[]
);
return visible ? <span ref={ref}>{text}</span> : null;
};
函数的ref一定会在元素生成或销毁时被执行,可以确保追踪到最新的DOM元素。但它依然有一个缺点,例如我们想要实现这样的一个功能:
任意一段文字,通过计时器循环每个字符变色。
假设我们突发奇想不想用状态去控制变色的字符,我们就可以写出类似这样的代码:
useEffect(
() => {
const element = ref.curent;
const tick = setInterval(
() => {
// 循环取下一个字符变色
},
1000
);
return () => clearInterval(tick);
},
[]
);
这是经典的useEffect的使用方式,返回一个函数来销毁之前的副作用。但是前面说了,useRef 和useEffect的配合是存在坑的,我们需要改造成函数ref,但是函数ref不支持销毁……
所以最后我们妥协了,依然使用useEffect,但在渲染时确保只生成一个DOM元素,让useEffect一定能生效:
return <span ref={ref} style={{display: visible ? '' : 'none'}}>{text}</span>;
在这个场景下这样是可以“绕过”问题,并最终产出有效可用的代码的。但如果换一个场景呢:
使用jQuery LightBox插件,对一个图片增加点击预览功能。
现在我们面对的是一个img元素,在没有src的时候这东西可不是简单的display: none就能安分守己的,你不得不采取return null的形式解决问题,那么你依然会提上useEffect的局限性。
其实换个角度,我们真正缺失的是“将销毁函数保留下来以待执行”的功能,这是不是非常像useTimeout或者useInterval的功能?无非一个是延后一定时间执行,一个是延后到DOM元素销毁时执行。
也就是说,我们完全可以用useRef本身去保存一个销毁函数,来实现与useEffect等价的能力:
const noop = () => undefined;
const useEffectRef = callback => {
const disposeRef = useRef(noop);
const effect = useCallback(
element => {
disposeRef.current();
// 确保这货只被调用一次,所以调用完就干掉
disposeRef.current = noop;
if (element) {
const dispose = callback(element);
if (typeof dispose === 'function') {
disposeRef.current = dispose;
}
else if (dispose !== undefined) {
console.warn('Effect ref callback must return undefined or a dispose function');
}
}
},
[callback]
);
return effect;
};
const Foo = ({visible, text}) => {
const colorful = useCallback(
element => {
const tick = setInterval(
() => {
// 循环取下一个字符变色
},
1000
);
return () => clearInterval(tick);
},
[]
);
const ref = useEffectRef(colorful);
return visible ? <span ref={ref}>{text}</span> : null;
};
可以看到,就是将之前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 开发小程序