1. 什么是强制重新渲染?
在React的项目中,React通常擅长知道自己应该在什么时候更新组件,但是有时候我们自己想控制重新渲染的时机,强制React刷新界面的某部分,这就像是我们在浏览网页的时候,我们点了一下浏览器的刷新按钮。在本文中,我们将从React的渲染机制开始,一点点的进行探讨这一点。
2. React的渲染机制
React渲染机制的核心是虚拟DOM(Virtual DOM)
,它是一种编程概念,是真实DOM(Document Object Model)
的JavaScript对象表示形式。你可以把它想象成实际DOM的克隆,就像是真实DOM结构的一个"内存中的模型"。例如,在 HTML 中有一个<div><p>Hello</p></div>
这样的结构。在虚拟DOM中,它可能会被表示成一个JavaScript对象:
css
{
type: 'div',
props: {},
children: [{
type: 'p',
props: {},
children: ['Hello']
}]
}
这里type表示节点类型(如元素标签名),props包含了节点的属性(如class、id等),children则是子节点的数组表示。
那么,React是如何知道需要在什么时候刷新虚拟DOM并对实际网页进行必要的更改呢?这过程可以大致分三个阶段:
- 首先React会将新的虚拟DOM与之前的虚拟DOM进行diff比较,计算出差异,确定你的组件UI界面哪些部分由于数据更新应该发生变化。
- 识别出差异之后,React计算出更新实际DOM的最有效方式,最大限度地减少不必要的工作,确保只更新受数据更改所影响的元素。
- 最后React将之前计算出的变化应用到真正的DOM上,从而完成界面的更新。
了解React如何刷新虚拟DOM以及渲染新的界面非常重要,这能够帮助我们知道应该在何时以及如何在必要时干预React强制重新渲染。接下来我们会进一步探讨什么时候我们可能希望引导React完成这些阶段,并确保我们的组件能够与应用程序的状态保持同步。
3. 为什么React组件没有重新渲染?
在开始进入主题之前,必须强调一下,通常来说,即使React无法自动更新组件,我们立马就强制干预React组件重新渲染并不是一个最佳实践。因此,在我们考虑强制重新渲染之前,应该先分析我们的代码,毕竟React本身的渲染机制会比我们做的更好。
接下来我们来看一下没有触发组件重新渲染的示例。
3.1 在React中错误的更新状态
让我们构建一个简单的demo来演示组件无法正常更新的情况。我们先构建一个简单的组件,这个组件显示用户名Jack和一个更新按钮,我们期望按下按钮后,用户名将更改为John。
javascript
const Component1 = () => {
const [user, setUser] = useState({
name: 'Jack'
})
function changeUserName() {
user.name = "John";
setUser(user);
}
return (
<div>
<div>{user.name}</div>
<button onClick={changeUserName}>Change Name After Click</button>
</div>
)
}
但是这个组件我们在点击按钮之后,并没有出现我们更改后的名字,也就是说,组件并没有在数据改变之后重新渲染组件,这是为什么呢?因为React是听过检查数据的浅引用相等性来确定state是否发生变化,也就是说,对于对象类型的state,React实际上比较的是这个state是否引用了同一个对象,而不是比较这个对象的某个属性是否发生变化。在上面的demo中,我们可以看到,当去通过setUser
重新设置user.name
时,我们只是改变了user
的name
属性,但是对象的引用没有发生变化,因此,React没有检查到state发生了变化,自然组件就没有发生更新。
在React文档中,state应该视为是不可变的,那么我们应该如何去修复上面的问题呢?很简单,我们可以通过扩展运算符...
对旧的user
状态(state
)进行解构,创建一个新的user
状态,并同时更新新的属性,如下:
php
function changeUserName() {
setUser({
...user,
name: "John"
});
}
现在我们可以看到,React能够在按钮点击之后正常更新组件了。
3.2 没有发生状态变化但props错误修改了
没有发生状态变化但props却被错误修改了,这听起来不太可能,但是确实可能会发生这样的情况。下面我们来看一个demo示例:
javascript
const Clock = (props) => {
return (
<div>{props.curTime.toString()}</div>
)
}
const Component3 = () => {
let currentTime;
function setCurrentTime() {
currentTime = new Date();
setTimeout(() => {
setCurrentTime();
}, 1000);
}
setCurrentTime();
return (
<Clock curTime={currentTime} />
)
}
在这个示例中,我们创建了一个定时器,每隔一秒钟会去更新一下currentTime
,然后将currentTime
通过props
传入到<Clock />
组件中并进行展示出来。但是上面的示例是一个不生效的示例,我们会发现,页面上显示的时间在第一次页面渲染之后,将不会有任何更新。
出现这种情况的原因是prop
是state
的映射,直接改变prop
的值不会触发组件的重新渲染,因此我们需要完全重写上面的代码逻辑才能实现正确的功能。
在新的demo中,我们通过state来管理currentTime
,并且使用useEffect
来启动和清除计时器,以避免在组件重新渲染时出现错误。
javascript
const Component4 = () => {
const [currentTime, setCurTime] = useState(new Date());
useEffect(() => {
let timer = setInterval(() => setCurrentTime(), 1000);
return () => clearInterval(timer);
});
function setCurrentTime() {
setCurTime(new Date());
}
return (
<Clock curTime={currentTime} />
)
}
在我们重构完组件代码之后,现在我们可以看到组件能够在时间更新后正常的触发重新渲染了。
4. 触发React组件重新渲染的方式
在React中,它向我们提供了多种方式来判断什么时候重新渲染组件,例如下面的三种方式:
-
setState
方式:setState
方式是触发组件重新渲染的常见场景,当我们调用setState
方式时,一般来说都会导致组件的state
或者props
发生变化,这时React就会检测到组件的变化,从而触发组件的重新渲染。(在类组件中是setState
方法,在函数组件中就是useState
返回的第二个参数)。 -
forceUpdate
方式:某些情况下,我们可能想要没有发生state
改变或者props
改变就想让组件重新渲染。React类组件中提供了一个forceUpdate
方法,能够命令类组件不管发生了什么都直接强制重新渲染。不到万不得已,不推荐使用这个方法,因为会破坏React底层的优化。 -
key
操作:在React中进行列表渲染时,我们会在控制台收到警告来提醒我们给列表每一项设置一个单独的key
,这是React的一种优化方式,当列表上某个组件的key
发生变化时,这个组件就会重新渲染。因此,如果我们手动去改变key
的值,也会导致重新渲染,当然这只会在一些特定场景下才这么干,正常不会也不推荐这么做。
上面这些方式能够帮助我们控制组件重新渲染的时机和方式,但是必须要知道的是,过度使用这些方法也会导致一些问题,我们在后文中将有相关讨论。
5. 强制React组件重新渲染
上文中我们已经比较详细地提到了为什么React组件没有重新渲染,React中有哪些方式会触发重新渲染。铺垫了这么多,就是想要表明,一般来说,强制组件重新渲染不是一件好的事情,React自动重新渲染的失败通常是由于我们代码写的有bug,希望大家尽可能从代码层面修复问题,而不是强制干涉React组件重新渲染流程。但是,如果我们迫不得已有强制React组件重新渲染的需求,下面有几种方法可以做到这一点。
5.1 类组件(Class)强制重新渲染
如果我们在项目中使用了Class类组件,我们可以使用React官方提供的API来强制重新渲染组件。
javascript
handleForceUpdate() {
// 没有发生state改变也重新渲染
this.forceUpdate();
}
我们可以在任意的方法中调用this.forceUpdate()
方法,这会导致组件跳过shouldComponentUpdate()
方法,直接执行render()
方法,从而强制React重新计算虚拟DOM和DOM状态。下面是使用该方法时的一些注意事项:
-
调用这个方法强制刷新React组件也会触发其子组件正常的生命周期方法及渲染,包括
shouldComponentUpdate()
声明周期,因此我们控制只强制当前组件重新渲染。 -
虚拟DOM仍然会校验它的DOM状态,所以React只会在标记更改时更新DOM。
5.2 函数组件强制重新渲染
React没有提供官方的API或者React Hook来强制重新渲染函数组件,但是我们可以使用一些技巧来告诉React应该重新渲染组件。
5.2.1 使用新实例替换旧的状态对象
正如上文中演示的那样,我们可以解构一个state对象来刷新组件:
php
function changeUserName() {
setUser({
...user,
name: "John"
});
}
因为user是一个对象,所以我们复制一个新对象并将其设置为新的state,这种方式不仅仅适用于对象,同样也适用于数组。
5.2.2 使用一个空的状态变量触发渲染
这种方式非常有意思,因为我们通过useState
创建了一个state对象,但是我们并不关心它的值怎样,我们只关心它的更新方法:
scss
const [, updateState] = React.useState();
const forceUpdate = React.useCallback(() => updateState({}), []);
在这段代码中,我们使用useCallback
来缓存forceUpdate方法,从而让它在整个组件生命周期中都保持不变,并且能够通过props
安全的传递给子组件。下面我们结合之前不能重新渲染组件的示例来展示其使用效果:
javascript
// 上文示例一演示效果,正常不推荐这种写法
const Component5 = () => {
const [user, setUser] = useState({
name: 'Jack'
})
const [, updateState] = React.useState();
const forceUpdate = React.useCallback(() => updateState({}), []);
function changeUserName() {
user.name = "John";
setUser(user);
}
return (
<div>
<div>{user.name}</div>
<button onClick={changeUserName}>Change Name After Click</button>
<button onClick={forceUpdate}>Force Update</button>
</div>
)
}
在上文例子中,我们点了改变user.name
的按钮之后,页面没有刷新,但是这个例子中,当我们再点击强制刷新按钮之后,我们会发现,页面进行了重新渲染,展示了更改后的name值。
6. 确定组件何时渲染完毕
在React中,官方提供了两个API:<Profiler>
和performance.now()
用来测量组件的渲染性能。如果我们用<Profiler>
组件包裹一个组件,我们就可以跟踪这个组件的渲染持续时间并记录完成所需的时间。<Profiler>
提供的onRender()
回调提供了每次渲染的详细信息,包括了渲染所花费的时间。下面让我们通过一个示例来仔细看看它是如何工作的:
javascript
function Component6() {
const [count, setCount] = useState(0);
const onRenderCallback = (
id, // <Profiler /> 上定义的id
phase, // 阶段,是mount(首次渲染)还是update(更新重新渲染)
actualDuration, // 渲染完成花费的时间
baseDuration, // 无缓存渲染整个组件树的预计时间
startTime, // 本次更新组件开始渲染的时间
commitTime, // React提交更新的时间
interactions // 属于本次更新的交互
) => {
console.log(`Render ID: ${id}, Duration: ${actualDuration}`);
};
return (
<Profiler id="Counter" onRender={onRenderCallback}>
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
</Profiler>
);
}
接下来,我们点击增加按钮,在看到页面上数字每次加1的同时,我们还会在控制台看到这些信息:
在控制台输出的信息中,我们可以看到,第一次渲染花费了大约1.9毫秒,而之后的渲染会更快。这是正常的现象,因为第一次渲染mount
阶段通常会涉及更多的工作,如果要获得更精确的计时时间,我们可以考虑使用performance.now()
API,这是一种更精确的时间戳方法,用来测量React中特定代码执行的开始和结束时间。例:
javascript
function Component7() {
const [count, setCount] = useState(0);
const renderStartRef = useRef(0); // 存储开始时间
// 获取渲染开始时间
useEffect(() => {
renderStartRef.current = performance.now();
console.log("Render start time:", renderStartRef.current.toFixed(4), "ms");
});
// 每次渲染之后,获取渲染结束时间,并计算渲染花费的时间
useEffect(() => {
const renderEnd = performance.now();
const renderDuration = renderEnd - renderStartRef.current;
console.log("Render end time:", renderEnd.toFixed(4), "ms");
console.log("Render duration:", renderDuration.toFixed(4), "ms");
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
同样的,我们可以在控制台看见这些信息:
通过结合React的<Profiler>
和performance.now()
API,我们可以更深入地了解组件需要多长时间进行渲染,并且来做相应的性能优化。
7. React 18的并发渲染对组件重新渲染的影响
在React 18发布后,React新引入了几个优化功能,其中就有并发渲染,该功能能够让React将复杂繁重的渲染任务分解成更小的切片,让渲染的过程更高效安全,从而改善用户的体验。
为了实现这一点,React在做渲染任务时,它会优先考虑渲染更紧急的更新,在处理完这些紧急更新的渲染任务之后,再去处理不太重要更新的渲染任务。UseTrantion() Hook
能够帮助我们管理那些不紧急的更新。当状态更改被包裹在startTransition()
中时,React会延迟低优先级的更新,直到紧急更新完成。虽然这个功能会导致更频繁的重新渲染,但由于不太关键的更新是在后台处理,因此整体性能和体验反而更流畅。
本文中,对于React的并发渲染只是简单的介绍,不再赘述。如果想要了解更多相关知识,可以参考官方文档。
8. 总结
本文中结合介绍了React虚拟DOM和渲染机制相关的内容,并使用了几个示例演示在React中状态更新但是却没有重新渲染组件,然后完整展示了在类组件和函数组件中如何在组件状态没有发生改变的情况下,强制重新渲染组件。虽然我们有方法能够干涉React组件的渲染时机和方式,但是还是尽可能的把渲染的控制权交到React手中,如果遇到状态更新了但是组件没有重新渲染的情况,优先去检查我们的code是不是有问题!
文中如有错误,敬请指正。