高性能 React 应用的几个小技巧
使用 function 作为 useState 的初始值
计算机科学中存在一个求值策略的问题, 简单来说就是什么时候计算参数的值, 比如以下伪代码:
function fn(n) {
return n * 2
}
x = 1
y = fn(x + 1)
fn(x + 1)
中的 x + 1
是什么时候计算的呢? 目前有两种流派, 第一种是传值引用, 先对参数计算, 然后执行函数, 也就是 y = fn(x + 1) = fn(2)
. 第二种是传名引用, 把函数内用到参数的地方替换成具体的参数表达式, 函数运行时再进行计算, 可以表示为:
x = 1
y = f(x + 1) = fn() {
return (x + 1) * 2
}
传值引用实现起来比较简单, 但会有性能损耗, 比如 fn(x + 1, x + 2, x + 3)
后面两个的参数实际上不会用到也会进行计算. 传名引用可以做到按需计算, 但是实现起来比较复杂. 在 JavaScript 中采用的是传值引用.
JavaScript 也可以模拟传名引用, 具体可以查看阮一峰老师的 Thunk 函数的含义和用法
回到 React 相关的话题, 来看下面这个例子, Input
组件的初始值从 localStorage
获取, 后续根据用户输入更新:
const Input = () => {
const [value, setValue] = useState(
localStorage.getItem('initial_value') || '',
);
const onChange = (event) => setValue(event.target.value);
return (
<input type="text" value={value} onChange={onChange} />
);
};
假设用户输入了 abcde
这串字符, 那么一共读取 localStorage
多少次?
答案是 6 次. 可能有人会认为只有初始化的 1 次, 但是上面说到 JavaScript 是传值引用, useState
是一个函数, 所以每次调用的参数 localStorage.getItem("initial_value") || ""
都会计算一遍, 所以一共读取了 6 次 localStorage
. 我们可以稍微改造一下验证是否正确, 将 localStorage.getItem
的调用替换成自定义函数 getInitialState
:
当输入 abcde
这串字符, 可以看到 getInitialState
被调用了 6 次. 假如 getInitialState
计算量特别大的话将产生明显的性能问题.
useState
可以接受值作为初始值外, 还可以接受函数并自动调用该函数进行获得初始值, 所以上面的例子可以使用 function 作为 useState 的初始值进行优化:
import { useState } from 'react';
function getInitialState() {
return localStorage.getItem('initial_value') || '';
}
const Input = () => {
const [value, setValue] = useState(getInitialState);
const onChange = (event) => setValue(event.target.value);
return (
<input type="text" value={value} onChange={onChange} />
);
};
export default Input;
优化后的例子无论用户输入多少次都只有在初始化时读取 localStorage
一次.
合理使用 useCallback 和 useMemo
之前分析过这个问题, useCallback 的误区.
缩小 state 影响范围
import OtherComponent from 'other-component';
const Label = ({ label }) => <div>{label}</div>;
const InputField = () => {
const [value, setValue] = useState('');
const onChange = (event) => setValue(event.target.value);
return (
<OtherComponent>
<Label label="姓名" />
<input type="text" value={value} onChange={onChange} />
</OtherComponent>
);
};
观察上面例子, value
作为 InputField
的 state
发生变化会导致 OtherComponent
/Label
/input
重新渲染, 仔细分析可以发现, OtherComponent
和 Label
的渲染跟 value
没有关系, 我们可以通过 React.memo
避免这种无效的渲染, 不过更好的办法是将 value
的影响范围缩小:
import OtherComponent from 'other-component';
const Label = ({ label }) => <div>{label}</div>;
const Input = () => {
const [value, setValue] = useState('');
const onChange = (event) => setValue(event.target.value);
return (
<input type="text" value={value} onChange={onChange} />
);
};
const InputField = () => (
<OtherComponent>
<Label label="姓名" />
<Input />
</OtherComponent>
);
把 Input
独立成组件后, value
的影响范围局限在 Input
组件自身, 避免导致其他组件的无效渲染. 我们再看一个复杂点的例子:

一个组件表示为上图的树形结构, 假设有一个 state
被 A
/B
/C
所使用的, 因为 React 遵循自上到下的单向数据流, 为了保证 A
/B
/C
都能拿到 state
, 那么 state
只能定义在 Root
, 当 state
发生变化时整个组件树都会重新渲染. 不过仔细观察可以发现, 除了 A/B/C 外其他组件的重新渲染都是没有必要的.
那有没有办法避免这些不必要的渲染?
因为 React 父组件渲染的同时会导致子组件的渲染, 为了避免无效渲染只能将 state
克隆多份并下放到 A/B/C 自身, 那怎么保证 A/B/C state
的值是正确的以及如何及时更新?
可以通过广播. 我们可以在 Root
保存一份 state
的 ref
, 因为 ref
的变化不会导致重新渲染, 然后在 A/B/C 新建各自的 state
, stateRef
的值作为 props 往下传递作为 A/B/C state
的初始值, 当 stateRef
发生变化时进行广播, A/B/C 通过监听对应的广播对 state
进行更新.

这样的话达到了只更新对应组件的目的, 但是问题也很明显, 增加了代码的复杂度以及导致数据流难以追踪, 所以这个方法只建议用在影响范围大且更新频繁的 state
上.
组件内使用的可变变量不要放在组件外部
let timer;
const Calculagraph = () => {
const [seconds, setSeconds] = useState(0);
const onStart = () => {
window.clearInterval(timer);
timer = window.setInterval(
() => setSeconds((s) => s + 1),
1000,
);
};
const onPause = () => window.clearInterval(timer);
return (
<div>
seconds: {seconds}
<div>
<button type="button" onClick={onStart}>
start
</button>
<button type="button" onClick={onPause}>
pause
</button>
</div>
</div>
);
};
上面是一个计时器组件, 点击 start
后每 100 毫秒加一, 点击 pause
计时暂停.
不过这个计时器组件存在一个问题, 当同时存在多个实例时, start
任何一个计时器都会导致其他计时器的暂停:
原因在于 Caculagraph
是一个独立的模块, 模块顶层的 timer
变量是所有 Caculagraph
实例共用的, 所以任何一个计时器实例操作 timer
都会影响其他的计时器实例.
像这种组件内使用的可变变量不应该放在组件外部, 应该使用 ref
放在组件内部:
import { useState, useRef } from 'react';
const Caculagraph = () => {
const [seconds, setSeconds] = useState(0);
const timerRef = useRef();
const onStart = () => {
window.clearInterval(timerRef.current);
timerRef.current = window.setInterval(
() => setSeconds((s) => s + 1),
1000,
);
};
const onPause = () => window.clearInterval(timerRef.current);
// ...
};
上面的例子只是用来说明组件内使用的可变变量不要放在组件外部, 实际上这个组件的设计并不合理而且存在组件卸载定时器没有清除的问题, 最优代码应该是这样:
const Caculagraph = () => {
const [seconds, setSeconds] = useState(0);
const [running, setRunning] = useState(false);
const onStart = () => setRunning(true);
const onPause = () => setRunning(false);
useEffect(() => {
if (running) {
const timer = window.setInterval(
() => setSeconds((s) => s + 1),
1000,
);
return () => window.clearInterval(timer);
}
}, [running]);
// ...
};
不变的 props 可以提升为常量
上面的例子中, 使用 React.memo
对 Label
进行了优化, 在 Input
中因为 Label
的渲染跟 value
没有关系, 所以 value
变化不会导致 Label
重新渲染. 不过实际情况是 value
变化会导致 Label
重新渲染.
分析这个问题我们对 Input
组件进行分解:
const style = { color: 'red' };
return (
<div>
<Label style={style} />
<input type="text" value={value} onChange={onChange} />
</div>
);
可以发现每次渲染都会生成一个新的 style
对象, 前后两个 style
不相等导致 React.memo
失效.
因为每次渲染 style
的值是不变的, 像这种不变的 props
可以提升为常量从而实现优化.
const Label = memo(({ style }) => {
console.count('label render');
return <div style={style}>label</div>;
});
const labelStyle = { color: 'red' };
const Input = () => {
const [value, setValue] = useState('');
const onChange = (event) => setValue(event.target.value);
console.count('input render');
return (
<div>
<Label style={labelStyle} />
<input type="text" value={value} onChange={onChange} />
</div>
);
};
合并相关联 state 减少渲染次数
上面的例子模拟从接口获取数据然后展示, 请求中/请求成功/请求失败都有对应的 UI, 然后可以通过 reload
按钮重新发起请求. 可以看到, 无论请求成功还是请求失败都会产生 3 次渲染, 后续每次重新请求也是 3 次渲染, 组件初始化导致第一次渲染, 然后是 setError(null)
和 setLoading(true)
因为和初始值一致跳过渲染, 请求成功的 setData
或者请求失败的 setError
导致第二次渲染, setLoading(false)
导致第三次渲染, 后续重新请求也是同理.
仔细分析可以发现, setData
/setError
后面始终跟着 setLoading(false)
, 如果把 data
/error
和 loading
合并成一个 state
的话那就可以减少一次渲染.
const useData = () => {
const [data, setData] = useState<
/** 请求中 */
| {
loading: true;
error: null;
value: number;
}
/** 请求失败 */
| {
loading: false;
error: Error;
value: number;
}
/** 请求成功 */
| {
loading: false;
error: null;
value: number;
}
>({ loading: true, error: null, value: 0 });
const getData = useCallback(async () => {
setData({ loading: true, error: null, value: 0 });
try {
await new Promise((resolve) =>
window.setTimeout(resolve, 2000),
);
if (Math.random() > 0.6) {
throw new Error('mock error');
}
setData({
loading: false,
error: null,
value: Math.random(),
});
} catch (e) {
setData({ loading: false, error: e as Error, value: 0 });
}
}, []);
useEffect(() => {
getData();
}, [getData]);
return { data, reload: getData };
};
const App = () => {
const { data, reload } = useData();
console.count('render');
if (data.error) {
return (
<div>
<div>error: {data.error.message}</div>
<button type="button" onClick={reload}>
reload
</button>
</div>
);
}
if (data.loading) {
return <div>loading...</div>;
}
return (
<div>
<div>data: {data.value}</div>
<button type="button" onClick={reload}>
refresh
</button>
</div>
);
};
改造之后发现, 每次重新请求的渲染次数从 3 降到 2, 但是初始化依然是 3 次, 这是因为设置 loading 状态是生成一个新的对象 { loading: true, error: null, value: 0 }
与初始值不相等导致的, 而 loading 状态的值其实是一致的, 所以可以把 loading 状态保存为一个常量进行优化.
const loadingState = { loading: true, error: null, value: 0 };
const useData = () => {
const [data, setData] = useState(loadingState);
const getData = useCallback(async () => {
setData(loadingState);
// ...
}, []);
// ...
};