最近在维护公司一个老项目 React 工程(体积庞大),主要工作内容是对工程内的一些用户体验做调整和优化。
由于最初搭建程序数据流设计上的不完善以及开发人员的技术水平存在一定差距,用户反馈 Input 输出框 change 事件输入存在卡顿,且页面响应延迟。
这对于用户使用,或者作为开发人员来说也是不能够接受的。
本文将自上而下记录这一优化过程。
一个应用页面存在非常多的模块内容(组件),每个模块所依赖数据结构包括层级非常复杂(关键),每个模块都或多或少会读取全局状态管理(dva)中的 state,使用效果可能如下:
export default connect(state => state)(Component);
而对于页面上的输入框,Input onChange 事件每次都会通过修改全局状态池(dva)中的数据方式达到视图更新。
大家可以想到,如果采用这种更新视图方式,一次键盘输入,都可能会导致页面所有模块接收到通知去进行更新。
这个更新是大范围的,更新计算以及重渲染视图都需要时间,但用户操作仅仅是 Input 这一小块区域,这就造成了输入框输入卡顿延迟问题。
上述背景可以得知是由于使用了不合理的更新方式导致页面卡顿。
这时我们就会想到:如果进行小范围的视图更新,比如在发生 onChange 时只有 Input 这个组件进行视图更新,不用影响到其他组件的更新。
通常,我们都会对 Input 进行二次封装组件:接收 props.value 作为初始值,组件内部拥有自己的 state 变量,每次 change 时只需更新本组件 state 来触发输入框的视图更新。
另外对于更新 props.value,可借助对象引用地址特征,在 props.value 所在的顶层对象中同步 value 值的更新。这样就实现了仅 Input 组件小范围更新。
下面我们开始敲些代码加深理解。为了模拟场景,我们编写三个组件:Canvas(画布)、Setter(右侧设置区)、Input(设置区的输入框)。
其中 dva 中的数据我们使用 React.createContext Provide 方式来代替,由于工程技术偏老,代码中我们采用 class 组件 来完成组件编写。
首先,我们可以通过 create-react-app 创建一个应用,在入口文件 index.js 中通过 ReactContext.Provider 将数据向下传递:
// src/reactContext.js
import React from 'react';
export default React.createContext();
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Setter from './Setter';
import Canvas from './canvas';
import ReactContext from './ReactContext';
const block = { name: '输入框', value: '' };
ReactDOM.render({ block }}> , document.getElementById('root')
);
接着,我们封装 Input 组件并且在内部维护自己的 state,实现可能如下:
// src/Input.js
import React from 'react';
class Input extends React.PureComponent {constructor(props) {super(props);this.state = {value: props.value || props.defaultValue || '',}}componentWillReceiveProps(newProps) {if (newProps.value !== this.state.value) {this.setState({ value: newProps.value });}}handleChange(event) {const value = event.target.value;this.setState({ value });this.props.onChange && this.props.onChange(value);}render() {console.log('Input 组件 render。');return ()}
}
export default Input;
然后,我们在 Setter 中使用 Input 组件:
// src/Setter.js
import React from 'react';
import Input from './Input';
import ReactContext from './ReactContext';
class Setter extends React.PureComponent {static contextType = ReactContext;constructor(props) {super(props);}handleChange = value => {this.context.block.value = value;}render() {console.log('外部组件 render。', this.context);return ()}
}
export default Setter;
在上面我们分别在 Setter 和 Input 组件 render 方法中加入 console 输出,当触发 Input change 时,只会看到打印了 Input 组件 render。,说明 Input 组件更新只是小范围的,不会给外部带来影响。
如果场景只是一个简单的交互,在这里我们的优化就可以结束了。
但如果需要视图联动呢?比如 Input 输入的内容要同步到别处。例举一个场景:一个 lowcode 低代码 应用,右侧的属性设置器修改属性后,需要同步在中间画布区域。
刚刚我们只实现了封装后的 Input 组件内部视图更新达到优化目的。接下来需要想想办法,以最小化更新代价触发页面中间 画布 区域进行同步更新。
我们知道触发重渲染的动作是在 Input change 事件中,一个普普通通的 Input 组件如何能够通知 画布 组件进行更新渲染呢?
其中一个方式便是 发布订阅,这一方式不在本文的主题范围内,我们先不采用。
我们知道,触发一个组件进行更新不外乎两种情况:
props、key 发生改变;state 发生改变(setState);对于情况一,Input 和 画布组件不存在直接层级关系,因此不太适应去通过修改 props 实现;
对于情况二,state 是画布组件内部的,Input 无法直接调用,更不太可能实现了。
不过,不管是 class 组件,还是 Hooks 函数组件,都可以有一个 forceUpdate 强制更新本组件重渲染的方法,其作用和 setState 更新内部 state 带来的作用相同,都会触发组件 render 的重新执行。
在 class 组件的实例上本身具备 forceUpdate 方法,使用方式如下:
this.forceUpdate();
在 Hooks 函数组件中,可以通过 useReducer 来实现此功能:
const [, forceUpdate] = useReducer(v => v + 1, 0);
既然每个组件都拥有一个自更新的方法,画布 组件也不例外,于是我们想到:Input change 事件发生后:
setState 更新 Input 内部 state,使得 Input 的输入框视图实时更新;源数据 下的 value 值,使得数据能够同步过来;render()),以此来读取更新后的 源数据 进行视图更新。那么该如何调用画布组件的 forceUpdate 呢?最直观的一种方式就是 全局 拥有一个变量,可以存储画布组件的 forceUpdate,这样在任何地方都可以通过全局变量去更新画布视图。
这里的 全局 应该是谁呢?不会是 window 吧,好像也可以;不过上面我们提到了 dva,可以把 画布 forceUpdate 的函数引用存储在 dva.state 中。对于我们这个手写 demo,我们把引用存储在 ReactContext 中。
像下面这样:
// src/index.js
const block = { name: '输入框', value: '' };
const forceUpdates = {canvasUpdate: null,// ... more
}
// 局部更新
const localUpdate = (localList) => {localList = localList.length === 0 ? Object.keys(forceUpdates) : localList;localList.forEach(local => forceUpdates[local] && forceUpdates[local]());
}
ReactDOM.render({ block, forceUpdates, localUpdate }}>... , document.getElementById('root')
);
上面我们定义了 forceUpdates 来存储所需的更新方法,其中 canvasUpdate 保存了画布组件的强制更新函数的引用;另外还有一个执行存储的 forceUpdates 的方法:localUpdate。
下面我们在 Canvas 中绑定 forceUpdates.canvasUpdate 引用:
// src/Canvas.js
import React from 'react';
import ReactContext from './ReactContext';
class Canvas extends React.PureComponent {static contextType = ReactContext;constructor(props) {super(props);}componentDidMount() {const forceUpdate = () => this.forceUpdate();this.context.forceUpdates.canvasUpdate = forceUpdate;}componentWillUnmount() {this.context.forceUpdates.canvasUpdate = null;}render() {return (value 值:{this.context.block.value})}
}
export default Canvas;
我们在组件 Mount 时绑定 forceUpdate,对于 class 类组件考虑到 this 指向,这里使用了箭头函数;然后在组件 unmount 时将绑定的引用释放。
有了画布组件更新方法,我们就可以在 Input change 事件触发后,调用画布的 forceUpdate 进行更新。
// src/Setter.js
class Setter extends React.PureComponent {...handleChange = value => {this.context.block.value = value;// 通知画布组件进行重渲染this.context.forceUpdates.canvasUpdate();}
}
我们在 Canvas render 中使用到了 this.context.block.value,接下来我们在 Input 输入框中输入内容,可以看到 Canvas 的视图也会同步进行更新。
到这里,我们就实现了组件 小范围 更新,在一个非常庞大的页面,这将大大提升页面响应和用户体验。
不过,通常 Canvas 内容不会这么简单,应该会有多个 Block 子组件组合而成。接下来我们封装一个 Block 组件,在这个子组件中视图呈现 this.context.block.value。
我们新建一个 src/Block.js 文件,这是一个复用组件,因此设计上不能直接去访问 ReactContext 全局内容,我们采用 props.block 接收所需的 block 数据。
// src/Block.js
import React from 'react';
class Block extends React.PureComponent {constructor(props) {super(props);}render() {console.log('Block render。');return (value 值:{this.props.block.value})}
}
export default Block;
在 Canvas 中改造如下:
// src/Canvas.js
import Block from './Block';
class Canvas extends React.PureComponent {...render() {console.log('Canvas render。');return (
-{/* value 值:{this.context.block.value} */}
+ )}
}
这时,我们再回过来使用 Input 输入一些内容,你会发现 Canvas/Block 区域的 this.context.block.value 视图上没有更新。控制台打印输出如下:
Canvas render。
Input 组件 render。
可以看出,Input change 事件的确让 Canvas 进行了重渲染,但是 Block 组件并未重渲染输出 Block render。
这是什么原因呢?现在我们尝试给 Canvas Block 添加一个属性(没要求),属性值为一个空 箭头函数:
// src/Canvas.js
{}} />
此时再来触发 Input change 事件,Block 视图居然跟着 Input 同步更新了,并且控制台打印输出如下:
Canvas render。
Block render。
Input 组件 render。
添加了一个空的箭头函数居然可以实现 Block 的更新,那我们试一试 bind 是否可以呢?
// src/Canvas.js
class Canvas extends React.PureComponent {...fn() {}
}
效果与箭头函数一致,都可以触发 Block 的重渲染。
到这里其实也是强调了:通常,我们在 class 类组件中经常会为了解决 this 问题而去使用 箭头函数 或者 bind,在父组件重渲染时,组件将会被意外重渲染,这将会带来性能开销。
一般为了解决 this 指向问题,并且考虑性能问题,推荐使用创建一个实例方法,bind 原型方法:
// src/Canvas.js
class Canvas extends React.PureComponent {constructor(props) {super(props);this.fn = this.fn.bind(this);}...fn() {}
}
当然,为解决 Block 重渲染的方式绝不是我们为组件添加这样一个可跟随父组件重渲染而变动的 prop。
回到正题,输入框触发 change 事件,通知 Canvas 组件进行重渲染,由于 change 事件内直接通过对象属性赋值去更新 block.value,block 数据对象本身的引用地址没有发生改变;
所以即使 Canvas 触发了重渲染,对于 Block 组件而言,它的唯一属性 block,React 更新机制发现引用地址没有变化,不会触发视图更新。
注意,之所以:block.value(第一层属性)值发生了变化,block 引用地址未发生变化,没有触发 Block 组件重渲染,原因是我们对 Block 继承了
React.PureComponent,它会内置shouldComponentUpdate方法来对state和props进行判断,采用浅比较 === 判断 值或引用地址 是否变化,无变化则返回 false,不更新渲染。
所以,在选择使用
PureComponent时要明确以下两个问题,而在实际开发中通常会结合Immutable.js一起使用。
如果继承的是 React.Component,Block 将会被更新。这是因为,Component 默认提供的
shouldComponentUpdate会返回 true,只要你不重写该方法,组件每次都会更新 render。
即,对于继承 React.Component,即使子组件所接收的 prop 及其属性值没有被更改,只要父组件发生了更新,那么所有的子组件都会被无条件更新(
无条件 re-render)。如果使用的是这种继承,为考虑性能一般我们都会自己实现shouldComponentUpdate方法决定让组件什么时候进行重渲染。
现在我们移步到真实的
dva场景中:即使Block继承的是Component,但由于使用了dva.connect包裹组件,在connect中也用到类似于PureComponent的浅比较方法shallowEqual,所以呈现的效果与上例中对Block使用 PureComponent 相似。
import { connect } from 'dva';
...
export default connect(state => state)(Block);
所以,在这个场景下,为了实现 Block 的更新,依旧从 props 的角度思考,我们在 Input change 时除了执行 canvasUpdate 外,还需要为 this.context.block 赋予一个新的地址,使得 Block 能够感知 block prop 发生变化进行重渲染。(感谢评论区朋友提醒)
我们回到 Setter change 事件中,具体实现如下:
// src/Setter.js
class Setter extends React.PureComponent {...handleChange = value => {this.context.block.value = value;this.context.block = { ...this.context.block };this.context.forceUpdates.canvasUpdate();}
}
现在,我们在 Input 中输入内容,Block 的视图也会更新最新的 block.value 值。
在实际场景中,会有一个 blocks 数组,包含了多个 block 数据,因而 Canvas 中会存在多个 Block 组件,通过触发 Canvas 上级组件重渲染 + 指定 block 数据的更新,实现了只更新某个 Block 的视图重渲染,达到了很好的控制重渲染的更新范围,从而提升性能。
感谢阅读,如有不足之处,欢迎指正👏。