写在前面:
本文源于个人对 React 的兴趣,听闻 React18 的新消息非常开心。但遍历了中文社区之后,其实都是一些总结性的文章,看完之后个人还是处于云里雾里的状态。因此还是自己去翻看了 Discuss,发现英文原文写的非常好,通俗易懂。所以决定在阅读过程中将它们翻译出来,但个人还是更希望大家去看原文,贴近社区。
系列:
正文:
概述
React18 通过默认进行更多的批处理来提供开箱即用(只要升级到18)的表现提升,我们可以因此移除那些在库和包里需要手动更新的代码了。这篇文章将会解释什么是批处理,以及它之前是怎么工作的,并且现在变成什么样了。
什么是批处理(What is batching)?
批处理(batching)是 React 为了更好的性能,将多次 state 更新合并到一次重新渲染中做的处理。
举个?:如果你在一个点击事件中变更了两个 state,那么 react 只会去渲染一次。如果你运行下面的代码,你会发现:每当你点击按钮的时候,App组件只会去渲染一次,尽管你在处理函数中更新了两次state。
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1); // 还没有渲染
setFlag(f => !f); // 还没有渲染
// React 会在最后合并成一个渲染!(这就是批处理)
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
复制代码
这种处理对性能来说是一种极大的提升,因为它避免了无效的渲染。同时也组织了你的组件在state更新到一半的时候进行渲染,那种情况下也可能会出现你意料之外的bug。这样的工作流程你可以想象为:餐厅的服务员不会在你每点一道菜就往厨房跑一趟去告诉厨师要做什么,而是把你要点的餐记录下来,然后一次性告诉厨房。
然而,React在批处理的表现上并不是一致的。再举个?:如果你需要从服务端取回数据,并且需要在你的handleClick
中更新state。这种情况下,react就不会对这些state的更新进行批处理。相反,react会表现成两次独立的渲染。
这是因为 React 只会对发生在浏览器事件中的 state 变更进行批处理(例如点击事件),而在我们上面说的这个例子当中,当我们进行 state 变更的时候,事实上这个点击事件已经被处理过了。
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
// 在 React17 以及以前的版本中,这儿不会进行批处理。
// 因为这个回调函数是在点击事件处理*之后*调用的,而不是*之中*被调用的。
setCount(c => c + 1); // 重新渲染
setFlag(f => !f); // 重新渲染
});
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
复制代码
? Demo: React 17 does NOT batch outside event handlers.
所以,一直到 React18 之前,React 的批处理都只会发生在原生事件内。对于promise、setTimeout、利用js添加的原生事件处理函数等等,都不会被React进行默认的批处理。
那什么是自动的批处理呢?
从 React18 的createRoot
开始,所有的更新都会自动的进行批处理,无论这个state更新是在哪里被触发的。
换句话说,无论是在 setTimeout 中的,还是 promise.then 中的,亦或者JS添加上的事件处理函数中的state更新,React都会对它们进行批处理更新。就像之前添加在React合成事件中的批处理更新一样。我们期望这样的优化会带来更少的更新,因此对你的应用带来更好的性能。
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
// 如果在 React18 之后像这样更新state
setCount(c => c + 1);
setFlag(f => !f);
// React 会自动合并成一个渲染(批处理!)
});
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
复制代码
- ✅ Demo: 在 React18 中并使用了
createRoot
并且在合成事件外的更新! - ? Demo: 尽管是在 React 18 中,但还是使用了老的
render
方法来进行更新,会跟以前的变现一样。
⚠️注意:我们希望你一使用 React18 就能升级到
createRoot
来挂载节点,旧的ReactDOM.render
方法之所以还存在的理由就是为了更容易的在两个版本下进行产品试验。
综上,React 会自动地做批处理更新,无论这个更新发生在哪儿。
所以:
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// 只会渲染一次
}
复制代码
会和下面这个表现一样:
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 只会渲染一次
}, 1000);
复制代码
和下面这个表现一样:
fetch(/*...*/).then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 只会渲染一次
})
复制代码
表现一样:
elm.addEventListener('click', () => {
setCount(c => c + 1);
setFlag(f => !f);
// 只会渲染一次
});
复制代码
注意:React 只会在大多数安全的情况下进行批处理更新。举个?:React 会确保每一个由用户发起的事件(如点击、按键)在下一个事件被触发之前,上一个事件的DOM都会完全的更新。再举个例子,这种特性会保证我们的表单不会因为批处理而被提交两次。
那如果我不想要批处理呢?
通常来说,批处理是安全的。但有时候我们需要从立马被更新的DOM结构中读取一些数据,在这种情况下,我们也提供了ReactDOM.flushSync()
来选择性的跳过批处理。
import { flushSync } from 'react-dom'; // 注意是react-dom不是react
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// 渲染更新
flushSync(() => {
setFlag(f => !f);
});
// 渲染更新
}
复制代码
但我们不希望这种用法被普遍使用。
批处理在Hooks中会引发bug吗?
如果你在使用Hooks,我们相信它在绝绝绝大多数时候都是完美工作的。(如果没有,请让我们知道。)
批处理在Classes中会引发bug吗?
首先,在以前的合成事件(onClick, onMouseDown等)中,批处理也是被默认进行的,所以在这些情况下和以前并没有什么区别。
但同时也存在一些边缘情况可能会成为一个问题。
根据以前的特性,发生在setTimeout
中的更新会同步的进行渲染。这也就意味着,你能够在连续两次setState
之间读取到新的state:
handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
// { count: 1, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};
复制代码
在 React18 中,将跟上面的表现完全不一样。因为现在,发生在setTimeout
中的setState
也会被 React 捕获并进行批处理。因此当你在两次 setState 之间去读取 state 的时候,React 事实上还没有将上一次的 state 进行更新。
handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
// { count: 0, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};
复制代码
这在第一次看来会有些奇怪,但如果你依然想达到上面的效果,可以使用ReactDOM.flushSync
来做到,但我们还是推荐你少用这个api:
handleClick = () => {
setTimeout(() => {
ReactDOM.flushSync(() => {
this.setState(({ count }) => ({ count: count + 1 }));
});
// { count: 1, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};
复制代码
这个例子在函数式组件中并没有这个问题,因为函数式组件通过useState
更新本就不会改变现有的变量:
function handleClick() {
setTimeout(() => {
console.log(count); // 0
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
console.log(count); // 0
}, 1000)
复制代码
还记得函数式组件的这种表现刚刚出现在Hooks中的时候你对此感觉到奇怪吗?其实恰恰是为现在的自动批处理铺路哦~
那unstable_batchedUpdates
咧?
有一些 React 库会使用这个未被记载在文档中的API,他可以在除了合成事件函数外的地方进行批处理更新state。
import { unstable_batchedUpdates } from 'react-dom';
unstable_batchedUpdates(() => {
setCount(c => c + 1);
setFlag(f => !f);
});
复制代码
该 API 在 React18 中依然会存在,但已经没有必要再使用它了。我们并没有计划在18版本中移除它,但会在当未来的时候。当 React 库们都不再使用它的时候,我们就会移除它啦。