React Hooks的体系设计之三 - 什么是ref
原本应该继续讲状态管理,但是为了能完整地展开整个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示例:
这不仅仅存在于特定条件返回元素的情况下,还包含了不少其它的场景:
- 根据条件返回不同的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,能够有效提升业务开发的效率。