首发于FE FAME

React Hooks的体系设计之四 - 玩坏ref

上一节讲了useRef到底是个什么东西,它可以生成一个与组件节点生命周期相同的存放可变内容的容器。

在这个认知的基础上,我们其实可以用useRef做很多事情,所以打算写一个比较短的章节说一些比较常见或者比较奇葩的玩法。

可变对象

你看,更新一个数组或者对象,用不可变的方式还是比较容易的:

const newObject = {
    ...oldObject,
    foo: newFoo,
};

但是如果遇到MapSet这类东西,它天生是可变的集合容器,如果这样写代码:

const [items, setItems] = useState(new Set());
const addItem = useCallback(
    item => setItems(items => items.add(item)),
    []
);

其实并不会触发组件的更新,因为items.add前后并没有发生引用的变化,对React而言是同一个东西。

一个办法是immer提供了针对Map和Set的更新,但如果你不想用的话有没有啥其它手段呢?

其实我们可以用useRef来管理这样一个可变的状态,再想办法在状态更新的时候触发渲染就好了。为此,我们需要一个可以直接触发组件更新的手段,让组件更新最简单的办法就是改变一个状态,那什么样的状态是每一次都会变化的呢?

const useForceUpdate = () => useReducer(v => v + 1, 0)[1];

就这样搞定了,一个简单的递增的数字就行。我们通过@huse/update包中的useForceUpdate提供了这一能力。

再然后,把它们拼在一起试试:

const useSet = initialEntries => {
    const ref = useRef(new Set(initialEntries));
    const forceUpdate = useForceUpdate();
    const add = useCallback(
        item => {
            ref.current.add(item);
            forceUpdate();
        },
        [forceUpdate]
    );

    return [ref.current, {add}];
};

这样一个简单的对Set的操作就实现了。不过我还不是很确定在并发模式下这东西是靠谱的,有什么结论的同学可以回复讨论一下。我们也通过@huse/collection提供了useArrayuseMapuseSet等一系列集合相关的功能。

渲染计数

React一个很让人头疼的问题是,它的性能是薛定谔的状态,哪怕脑子再清醒犀利,你也很难去判断一个组件在一顿操作猛如虎之下会更新几次、渲染几次,直到哪天性能崩得受不住了你才会回头捡起来看看情况。

用Chrome的性能面板去看情况当然非常好非常专业,但其实成本也不小,录制、分析都挺花精力的。有时候我们只想看看一个组件到底渲染不渲染,渲染了几次,大致对性能有一个了解;或者就想研究一下实现的自定义hook会不会造成组件过多的更新,所以我们希望能有这样的东西:

const Foo = () => {
    const renderTimes = useRenderTimes();

    return (
        <div title={`Rendered ${renderTimes} times`}>
            ...各种内容...
        </div>
    );
};

然后操作一下,看看渲染次数有没有增长,快速地做一些定位和修复。

那么怎么实现这个东西呢,如果用状态的话:

const useRenderTimes = () => {
    const [times, increment] = useReducer(v => v + 1, 0);
    increment(); // 每次渲染的时候递增一下
    return times;
};

试一试?试试就逝世,保管你的浏览器卡得死死的关都不一定关得掉。这种在渲染中调用状态更新无疑会触发下一次渲染,形成一个死循环。

所以这时我们就要用到不会触发更新的可变容器:

const useRenderTimes = () => {
    const times = useRef(0);
    times.current++;
    return times.current;
};

你看不仅仅不会触发更新了,代码也清晰直观了很多。在@huse/debug包中就有这个useRenderTimes,附带的还有很多用于调试的工具,不过要记得部署到生产环境前把这些代码去掉哦。

前一次值

在class组件的时代,我们有不少方法是能拿到“前一次更新的值”的,比如:

class Foo extends Component {
    // 前一次的属性和状态全给了
    componentDidUpdate(prevProps, prevState, snapshot) {}
    // 这个反过来,给你下一次的,但this.props就是当前的了
    componentWillReceiveProps(nextProps) {}
}

然后到了函数组件的时候,一下子全没了,全没了……这可不是说需要的场景就真的消失了,场景多着呢。

所以我们想办法把这个功能再找回来,原理也很简单,拿一个容器存着前一次的值不就好了:

const usePreviousValue = value => {
    const previous = useRef(undefined);
    const previousValue = previous.current;
    previous.current = value;
    return previousValue;
};

@huse/previous-value中就给了这个能力,除此之外你还可以判断这个值是不是变了:

usePreviousEquals(value, deepEquals); // 甚至还能自定义比较函数

组件更新源

你可能会说,React不给你原始值一定有它设计的原因的,我肯定可以不用原始值活着的!那就来看一个比较经典的场景。

众所周知地再次强调,React的更新和渲染基本就是个薛定谔状态,经常会有“你觉得我不会更新但我更新了呵呵呵”这样的尴尬情况出现,想知道组件为什么发生了更新是几乎每一个React开发者的渴望,甚至因此活生生出现了why-did-you-update这种东西。

不过why-did-you-update这东西的侵入性着实有些高,我们不如用hook来做一个简单的实现:

const useUpdateCause = (props, print) => {
    const previousProps = usePreviousValue(props);
    const differences = findDifferences(previousProps, props);

    if (print) {
        printUpdateCause(differences);
    }

    return differences;
};

通过简单地将当前值与上一次值比较来找到变化的原因,甚至可以做更精确地判断,比如@huse/debug中的useUpdateCause就可以打印出这样的表格:

-----------------------------------------------------------------------
| (index) | previousValue | currentValue | shallowEquals | deepEquals |
-----------------------------------------------------------------------
|   foo   |    [Object]   |   [Object]   |     true      |    true    |
|   bar   |      1234     |     5678     |     true      |    true    |
-----------------------------------------------------------------------

帮助你一目了然地知道这些属性怎么变化。

对象追回

如果你通过useUpdateCause找到了一个属性变化,它虽然引用发生了变化,但是deepEquals列告诉你其实内容是一模一样的,这个变化完全不需要发生,要怎么办呢?

为了整个应用着想,我们会试图去追溯这个属性怎么来的,是不是在什么地方缺了useMemo或者reselect跨组件实例用了,没有做好缓存等等。但更多的时候,我们会发现外部的属性完全不是我们可控的,甚至可能来自于后端的返回,无论如何也做不到引用相同。

如果仅仅是触发了多次的更新,有些微的性能的损耗是小事,但如果这东西你用在了useEffect上,那可就要命了:

useEffect(
    () => {
        fetch('/users', params)
            .then(response => response.json())
            .then(list => setUserList(list));
    },
    [params] // 这东西要是引用不同怎么活
);

动不动就无限发请求,等着后端提刀子上门问候,这可不好。

我们要承认,这种情况在React生态里是随处可见的,甚至有为此而生的讨论串。有些开发者“机智”地用JSON.stringify去解决问题:

const paramsString = JSON.stringify(params);

useEffect(
    () => {
        const params = JSON.parse(paramsString);
        // ...
    },
    [paramsString]
);

这是有多蛋疼呢:

  1. JSON.stringify本身就消耗了性能,性能还不一定低于一次渲染。
  2. 为了躲ESLint的规则检查,再用JSON.parse转回来,再消耗一次性能。
  3. 我还没说JSON.stringify对属性是不排序的,这样搞依然有可能出现内容相同但paramString不同的情况,你还得用fast-json-stable-stringify这样的库帮你解决问题。

所以在此我要展示一个神奇的hook:

const useOriginalCopy = (value, equals = shallowEquals) => {
    const cache = useRef(undefined);

    if (equals(cache.current, value)) {
        return cache.current;
    }

    cache.current = value;
    return value;
};

它到底干嘛了呢?简单来说就是“上一次的值与这一次内容相同的话,就把上一次还给你好啦”。这样就能把最原始的那个引用都一样的对象给拿到手了:

const originalPrams = useOriginalCopy(params, deepEquals); // 用深比较
useEffect(
    () => {
        // ...
    },
    [originalPrams]
);

这样就能妥妥地安全使用。我们在@huse/previous-value里提供了这个能力,我愿意称之为我在hook领域上最伟大的发明,在社区上还没见过类似的实现。

使用useRef能实现的功能还有很多,比如@huse/derived-state能实现getDerivedStateFromProps的效果等等,学会使用它会给React领域的开发带来很大的便利和帮助。

编辑于 2020-03-06 22:40