状态提升是指把 state 从需要共享它的组件中移出,放到它们最近的共同父组件里。父组件保存这个值,通过 prop 传下去,同时传一个供子组件调用来修改它的函数,这样所有子组件都根据同一份 state 来渲染。
import { useState } from 'react';
function Panel({ title, isOpen, onOpen, children }) {
return (
<section style={{ borderBottom: '1px solid #ddd', padding: '8px 0' }}>
<h3 style={{ margin: 0 }}>{title}</h3>
{isOpen ? <p>{children}</p> : <button onClick={onOpen}>Show</button>}
</section>
);
}
export default function App() {
const [openIndex, setOpenIndex] = useState(0);
return (
<div>
<Panel title="Shipping" isOpen={openIndex === 0} onOpen={() => setOpenIndex(0)}>
Orders ship within two days.
</Panel>
<Panel title="Returns" isOpen={openIndex === 1} onOpen={() => setOpenIndex(1)}>
Return anything within 30 days.
</Panel>
<Panel title="Warranty" isOpen={openIndex === 2} onOpen={() => setOpenIndex(2)}>
Two years on every product.
</Panel>
</div>
);
}
点击任意面板上的“Show”:它会展开,原来展开的那个会收起。同一时间只能展开一个面板,因为这件事不由面板自己决定。App 持有 openIndex,每个 Panel 只接收 isOpen 和一个 onOpen 函数。
问题:本该保持一致的 state
从最直观的写法开始:每个面板都有自己的 isOpen state。每个面板都能正常工作,但面板之间互不知情,所以没有什么能阻止两个面板同时展开。
import { useState } from 'react';
function Panel({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<section style={{ borderBottom: '1px solid #ddd', padding: '8px 0' }}>
<h3 style={{ margin: 0 }}>{title}</h3>
{isOpen ? <p>{children}</p> : <button onClick={() => setIsOpen(true)}>Show</button>}
</section>
);
}
export default function App() {
return (
<div>
<Panel title="Shipping">Orders ship within two days.</Panel>
<Panel title="Returns">Return anything within 30 days.</Panel>
</div>
);
}
把两个面板都展开:它们会同时保持展开。React 中的 state 是声明它的组件私有的,兄弟组件既读不到,也重置不了。当两个组件需要保持一致时,state 就必须放在它们两者之上。
分三步提升 state
把第二个例子改成第一个,需要改三处。
- 从子组件中移除 state。 删掉
Panel 里的 useState,改为从 props 读取 isOpen。子组件不再决定自己是否展开。
- 由父组件传入值和修改它的方式。
Panel 接收 isOpen 和一个 onOpen 回调。按钮被点击时,子组件调用 onOpen();它并不知道父组件会拿这个做什么。
- 把 state 加到共同父组件中。
App 声明 openIndex,再把它转换成每个面板的 props:isOpen={openIndex === 1} 和 onOpen={() => setOpenIndex(1)}。
最近的共同父组件,是渲染了所有需要这份 state 的组件的最低一层组件。这里就是 App。如果这些面板放在一个 Faq 组件里,state 就应该放在 Faq 中,而不是更高的地方。
提升之后,Panel 就由父组件控制,和受控输入框是一个意思:它显示 props 给出的内容,并通过回调报告变化。受控与非受控组件一页介绍了表单元素中的同一思路。
单一数据源
当屏幕上的两个地方显示同一个事实时,只存一次这个事实,其他内容都从它计算出来。温度转换器是最典型的例子:摄氏度和华氏度两个输入框必须始终一致,所以它们不能各自保存一个数字。
import { useState } from 'react';
const round = (n) => Math.round(n * 10) / 10;
const toF = (c) => round((c * 9) / 5 + 32);
const toC = (f) => round(((f - 32) * 5) / 9);
function TemperatureInput({ label, value, onChange }) {
return (
<label style={{ display: 'block', marginBottom: 8 }}>
{label}{' '}
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ width: 80 }}
/>
</label>
);
}
export default function App() {
const [temp, setTemp] = useState({ value: '20', scale: 'c' });
const number = Number(temp.value);
const empty = temp.value === '';
const celsius = temp.scale === 'c' || empty ? temp.value : toC(number);
const fahrenheit = temp.scale === 'f' || empty ? temp.value : toF(number);
return (
<div>
<TemperatureInput
label="Celsius"
value={celsius}
onChange={(value) => setTemp({ value, scale: 'c' })}
/>
<TemperatureInput
label="Fahrenheit"
value={fahrenheit}
onChange={(value) => setTemp({ value, scale: 'f' })}
/>
<p>{Number(celsius) >= 100 ? 'Water boils.' : 'Water does not boil.'}</p>
</div>
);
}
在任意一个输入框里输入,另一个都会跟着变化。state 只是一个事实:用户最后输入的数字,以及它用的是哪种温标。另一个输入框的值在渲染时根据它计算出来,所以你正在输入的框始终保持你输入的原样。把初始 state 改成 { value: '212', scale: 'f' },输入框下方的提示就会变成“Water boils.”。
如果把摄氏度和华氏度分别存成两份 state,每个处理函数都得同时更新两者,一旦某个处理函数忘了,两个输入框就对不上了。只存一个值,它就不可能和自己不一致。
把 setter 传下去
子组件只能通过父组件给它的函数来修改父组件的 state。你可以直接传 setter(onSelect={setColor}),也可以传一个做更多事情的函数(onOpen={() => setOpenIndex(1)})。给这个 prop 起一个事件风格的名字,比如 onSelect 或 onChange,而不是 setSelected,这样子组件就不知道父组件如何存储这个值,父组件以后要改也不必动子组件。
import { useState } from 'react';
function ColorPicker({ colors, selected, onSelect }) {
return (
<div>
{colors.map((c) => (
<button
key={c}
onClick={() => onSelect(c)}
style={{ background: c, color: 'white', border: selected === c ? '3px solid black' : 'none', marginInlineEnd: 4 }}
>
{c}
</button>
))}
</div>
);
}
function Preview({ color }) {
return <p style={{ color, fontSize: 24, fontWeight: 700 }}>Hello in {color}</p>;
}
export default function App() {
const [color, setColor] = useState('teal');
function handleSelect(c) {
console.log('App sets color to', c);
setColor(c);
}
return (
<div>
<ColorPicker colors={['teal', 'tomato', 'slateblue']} selected={color} onSelect={handleSelect} />
<Preview color={color} />
</div>
);
}
ColorPicker 和 Preview 从不直接通信。选择器把选择报告给上层,App 存下来,新的值再向下流到两个组件。
什么时候不该提升 state
提升是有代价的。当 state 放在父组件中时,每次变化都会让父组件重新渲染,默认情况下它的所有子组件也会重新渲染,包括那些根本不用这份 state 的组件。只提升到最近的共同父组件为止,只有一个组件用到的 state 就留在那个组件里。
import { useState } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
console.log('render SearchBox');
return <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Type here" />;
}
function ProductList() {
console.log('render ProductList');
return (
<ul>
<li>Keyboard</li>
<li>Mouse</li>
</ul>
);
}
export default function App() {
return (
<div>
<SearchBox />
<ProductList />
</div>
);
}
在输入框里打字,同时观察控制台:每次按键只有 SearchBox 重新渲染。现在把 query 移到 App 中,再通过 props 传给 SearchBox。这样每次按键也会打印 ProductList,尽管列表根本不用这个查询。如果列表确实要按查询来过滤,那提升就是正确的选择,因为那时两个组件都依赖同一个值。
要问的问题是:“谁需要读取这个值?”如果答案是一个组件,state 就留在那里;如果是多个组件,就把它放到它们最近的共同父组件里。
提升得太高的时候
有时最近的共同父组件在组件树的很上层,值必须穿过好几个只负责转手的组件。这就是 prop drilling。几层 props 没问题,也很容易跟踪。当同一个值要穿过很多层,或者几乎每个组件都需要它(当前登录的用户、主题、语言)时,就用 useContext 来读取,而不是手动一层层传。context 改变的是值到达子组件的方式;state 本身仍然放在一个父组件里,所以它依然是被提升的。