React Hooks的体系设计之四 - 玩坏ref
上一节讲了useRef
到底是个什么东西,它可以生成一个与组件节点生命周期相同的存放可变内容的容器。
在这个认知的基础上,我们其实可以用useRef
做很多事情,所以打算写一个比较短的章节说一些比较常见或者比较奇葩的玩法。
可变对象
你看,更新一个数组或者对象,用不可变的方式还是比较容易的:
const newObject = {
...oldObject,
foo: newFoo,
};
但是如果遇到Map
和Set
这类东西,它天生是可变的集合容器,如果这样写代码:
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提供了useArray
、useMap
、useSet
等一系列集合相关的功能。
渲染计数
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]
);
这是有多蛋疼呢:
- 用
JSON.stringify
本身就消耗了性能,性能还不一定低于一次渲染。 - 为了躲ESLint的规则检查,再用
JSON.parse
转回来,再消耗一次性能。 - 我还没说
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领域的开发带来很大的便利和帮助。