更新跳过、PureComponent 与提交前快照
类组件收到新props或更新state,不等于每一层都必须重新计算全部UI。shouldComponentUpdate和PureComponent提供跳过机会;它们要求旧值仍能代表旧快照。若先原地修改对象,比较器就没有可靠证据,优化会把数据错误隐藏成“界面没刷新”。
先让浅比较暴露一次错误
完整替换 src/App.jsx。故意保留一个错误按钮,只用于对照;每组观察前刷新恢复初始值。
import { Component, PureComponent } from 'react'
export class Profile extends PureComponent {
render() {
return <p data-testid="profile">昵称:{this.props.person.name}</p>
}
}
export default class App extends Component {
state = { person: { name: 'Ada' }, tick: 0 }
mutate = () => {
this.state.person.name = 'Lin'
this.setState(previous => ({ tick: previous.tick + 1 }))
}
replace = () => {
this.setState(previous => ({ person: { ...previous.person, name: 'Lin' } }))
}
render() {
return <main>
<button onClick={this.mutate}>错误:原地修改</button>
<button onClick={this.replace}>正确:替换对象</button>
<output>父级刷新:{this.state.tick}</output>
<Profile person={this.state.person} />
</main>
}
}点错误按钮,父级刷新计数变为1,子组件仍显示Ada。父的state对象变了,但传给Profile的person仍是同一个引用。PureComponent逐项浅比较props与state,没有深入name;它看到person相同,于是可跳过。随后点正确按钮,新对象引用使子组件显示Lin。不要用forceUpdate“修好”这个例子,那只掩盖被污染的状态。
PureComponent不是深比较,也不是永不渲染的保证。自身state变化或读取的Context变化仍可引发更新;无变化时跳过只是优化。新建内联对象、数组或函数会改变引用,可能让优化失效;这和第三章memo前置相通。自写shouldComponentUpdate时参数是下一次值,this.props仍用于比较之前的值,返回false意味着允许跳过;少比较一个影响UI的字段就可能展示旧内容。不要同时继承PureComponent又重复手写另一套比较规则。
DOM变化前的旧值该在哪里读
假设一个只读结果框显示选中方案,我们想记录“DOM从哪段文字变成哪段文字”。render可以推导新文字,但不能假设它读取的DOM就是即将修改前的DOM;render与commit之间可能隔着调度。getSnapshotBeforeUpdate就在DOM更新前读取快照,返回值传入同次提交的componentDidUpdate第三个参数。
下面是第二个独立 src/App.jsx,使用文本而非布局尺寸,先把时序看清:
import { Component, createRef, useState } from 'react'
export class Receipt extends Component {
node = createRef()
getSnapshotBeforeUpdate(previousProps) {
if (previousProps.value === this.props.value) return null
return this.node.current.textContent
}
componentDidUpdate(previousProps, previousState, snapshot) {
if (snapshot !== null) {
this.props.onChange(`${snapshot} → ${this.node.current.textContent}`)
}
}
render() { return <p ref={this.node}>{this.props.value}</p> }
}
export default function App() {
const [value, setValue] = useState('方案A')
const [trace, setTrace] = useState('尚未变化')
return <main>
<button onClick={() => setValue(v => v === '方案A' ? '方案B' : '方案A')}>切换方案</button>
<Receipt value={value} onChange={setTrace} />
<output>{trace}</output>
</main>
}第一次点切换,snapshot是旧DOM的方案A,didUpdate读到的是方案B,output显示“方案A → 方案B”。onChange更新父级trace又造成一次渲染,但value没有变化,snapshot返回null,所以不再触发onChange。这里的条件同时保证记录只对应真正的内容切换,避免记录本身触发循环。
实际滚动列表可以在snapshot中读旧scrollHeight与scrollTop,再在didUpdate使用新高度修正位置;尺寸计算需要真实浏览器验证,文本实验只证明提交前后顺序,不能替滚动行为担保。useLayoutEffect在DOM修改后执行,不能把getSnapshotBeforeUpdate机械换成它并假设仍能读到旧DOM。
派生state不是同步props的默认手段
static getDerivedStateFromProps在渲染阶段计算少数需要保留历史信息的state,它没有实例this,不能做订阅和DOM操作。仅为显示props的名称而复制进state会产生两个数据源,父输入更新可能覆盖用户草稿。可以直接render props、由父控制输入,或在业务身份变化时用key重建;不要为了“类组件都要写生命周期”增加派生state。
练习:摘要比较也要包含实际输出
在第一个实验中给Profile增加 compact prop,控制它显示“简:Ada”或“完整:Ada”。父增加切换compact按钮,仍使用PureComponent;保证单独改变compact就更新文本,person不必重建。保留错误按钮作为反例,写出为何只比较person的自定义shouldComponentUpdate会漏掉这个变化。第二个实验再从B切回A,预测并观察完整trace。
查看答案。接着阅读Context与复用模式。