首发于FE FAME

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示例:

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

  1. 根据条件返回不同的DOM元素,如divspan换着来。
  2. 返回的元素有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中的作弊器”,我想这个形容是准确的,所谓的“作弊”,其它是指它打破了类似useCallbackuseEffect对闭包的约束,使用一个“可变的容器”让ref不需要成为闭包的依赖也可以在闭包中获得最新的内容。

这也是我们发布的@huse/timeout包的具体实现,我们同时提供了useTimeoutuseInterval,还附加一个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的使用方式,返回一个函数来销毁之前的副作用。但是前面说了,useRefuseEffect的配合是存在坑的,我们需要改造成函数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中实现了useElementSizeuseElementResize等hook,能够有效提升业务开发的效率。

编辑于 2020-02-28 20:24