【第1662期】编写有弹性的组件
前言
新一周了。今日早读文章由@Dan Abramov分享。
正文从这开始~~
当大家开始学习 React 时,常常问到风格指南。虽然呢,项目中应用一致的规则,是个不错的主意,但是很多规则挺随意的——所以 React 对这些并没有强烈的意见。
你可以使用不同的类型系统,使用函数声明或者箭头函数,也可以对你的属性按字母序或任何你愿意的其他顺序来排序。
这样的灵活性允许在项目中 整合 React 时,能应用已有的一些约定习惯。不过同时,这也会引发无休止的争论。
有一些每个组件都应极力遵守的设计原则。但我不认为风格指南能很好地捕捉这些原则。接下来,我们先谈谈风格指南,然后再看看真的有用的设计原则。
别被虚幻的问题分散了注意力
在我们讨论组件的设计原则前,我想先简单说说风格指南。这不是个流行的观点,但有人得说出来!
在 JavaScript 社区中,有一些由 linter 强制约束的样式指南。我个人的观察是,他们会产比他们价值更多的麻烦。我算不清有多少人向我展示过一些完全有效的代码,然后跟我说 “React 提示这有问题啊!”,但这其实是由他们的 lint 配置产生的!这就导致了三个问题:
人们习惯于将 linter 看作是一个过分热心又吵闹的看门人,而不是一个有用的工具。有用的警告被风格提示的海洋淹没了。因此,人们在调试时不看 linter 的提示,错过有用的信息。此外,之前不太写 JavaScript 的人群(例如,设计人员)也因此更难使用代码。
对于某种模式,大家不太学着区分有效和无效的用法。例如,有一条流行的规则是,禁止在 componentDidMount 中调用 setState。但如果这个用法总是 “错的”,那 React 根本不会允许它!这就有一个合法的用例,那就是测量 DOM 节点布局——例如,定位 tooltip。我见过有人添加 setTimeout 来 “解决” 这条规则,这完全搞错了。
最终,人们采用 “执法者心态”,对那些没带来有意义变化但在代码中易于发现的地方持批评态度。“你用了函数声明,但我们的项目用的是箭头函数。” 每次我有强烈意愿,想要强制执行类似的规则时,仔细想想就会发现,我把个人情绪投入到了这个规则中——然后又努力让这消失。这让我陷入虚假的成就感,而丝毫没有改进我的代码。
我这是在宣扬停止使用 linter 吗?不!
通过良好的配置,linter 是一个很好的工具,它可以在 bug 出现前就能发现它们。但它对代码风格的关注过多,使其变得会分散注意力。
整理你的 Lint 配置
这是我建议你在周一要做的事。把你的团队叫到一起花半个小时时间,一条条过一下你们项目中启用的 lint 规则,接着问问自己:“这条规则有帮我找到过 bug 吗?” 如果不是,关掉这条规则。(你也可以用 eslint-config-react-app 从头开始创建,里面不含任何代码风格的规则)
至少,你的团队应该有一个流程,会去删除引起干扰的规则项。不要假设一年前你或别人添加到你的 lint 配置中的任何东西,都是“最佳实践”。保持质疑,找到答案。别让任何人告诉你,你不够聪明,不能选择 lint 规则。
那代码格式化呢?用 Prettier 然后忘掉 “风格”。你完全不需要一个工具告诉你得在这加一个空格,如果有别的工具能为你修复它。用 linter 找 bug,而不是做 代 码 美 学。
当然,某些方面来说,编码风格和格式没有直接关系,但在整个项目中存在不一致时还是很恼人。
然而,它们中的许多都太微妙了,无法通过一条 lint 规则捕捉到。这就是为什么说,在团队成员之间建立信任,在 wiki 或简短的设计指南里分享有用的知识,是非常重要的事了。
不是一切都值得自动化!从 实际阅读 中获得的见解,这种指南中的理由可能比遵循 “规则” 更有价值。
如果遵循严格的风格指南是一种分心,那到底什么才是重要的呢?
这就是这篇文章的主题。
编写有弹性的组件
不论多少缩进或按字母序排列,都不能修复糟糕的设计。因此,我不会专注于某些代码看起来如何,而是专注于如何让它工作。这有一些组件设计原则我认为是非常有用:
不阻断数据流
时刻准备渲染
没有单例组件
隔离本地状态
即使你不使用 React,对于存在单向数据流的任何 UI 组件模型,可能也能通过反复试验发现相同的原则。
原则 1:不阻断数据流
渲染中不要阻断数据流
当别人使用你的组件时,他们的预期是,不论传递属性如何变化, 组件都将反映这些变化:
// isOk 也许在 state 里,随时会变化
<Button color={isOk ? 'blue' : 'red'} />
通常,这是 React 默认工作的方式。如果你在 Button 组件中使用 color,你会看到从上层为该渲染提供的值:
function Button({ color, children }) {
return (
// ✅ `color` 永远是新的
<button className={'Button-' + color}>
{children}
</button>
);
}
然而,学习 React 时常见的一个错误是,把 props 复制到 state:
class Button extends React.Component {
state = {
color: this.props.color
};
render() {
const { color } = this.state; // 🔴 `color` 不新鲜了!
return (
<button className={'Button-' + color}>
{this.props.children}
</button>
);
}
}
这也许看起来更直观,如果你使用过 React 之外的东西。但是,通过将 prop 复制到 state,你忽略了对它的所有更新。
// 🔴 上面的实现在更新时无法正常工作了
<Button color={isOk ? 'blue' : 'red'} />
很少情况下,这样的行为 是 有意为之的,请确认将这样的属性取名为 initialColor 或 defaultColor 来表明组件会忽略这个属性的改变。
但通常你会想在你的组件中 直接读取 props,避免复制 props(或从 props 中计算得到的值) 到 state:
function Button({ color, children }) {
return (
// ✅ `color` 永远是新的!
<button className={'Button-' + color}>
{children}
</button>
);
}
计算值是另一个大家可能会将 props 复制到 state 的场景。举例来说,想象一下 按钮文字 的颜色是根据 color 属性通过昂贵计算得来:
class Button extends React.Component {
state = {
textColor: slowlyCalculateTextColor(this.props.color)
};
render() {
return (
<button className={
'Button-' + this.props.color +
' Button-text-' + this.state.textColor // 🔴 `color` 改变的时候就不新鲜了
}>
{this.props.children}
</button>
);
}
}
这个组件有 bug,它在 color 属性改变时无法重新计算 this.state.textColor。最简单的修复是把 textColor 的计算放到 render 方法中,然后把组件改为 PureComponent:
class Button extends React.PureComponent {
render() {
const textColor = slowlyCalculateTextColor(this.props.color);
return (
<button className={
'Button-' + this.props.color +
' Button-text-' + textColor // ✅ 永远是新的
}>
{this.props.children}
</button>
);
}
}
问题解决了!现在当 props 改变时重新计算 textColor,但是在属性不变时,能避免重复进行昂贵计算。
然而,也许我们还能再优化一下。如果 children 改变了呢?很遗憾 textColor 在这种情况下会重复计算。我们第二次尝试可能是通过在 componentDidUpdate 中调用计算。
class Button extends React.Component {
state = {
textColor: slowlyCalculateTextColor(this.props.color)
};
componentDidUpdate(prevProps) {
if (prevProps.color !== this.props.color) {
// 😔 额外的重复渲染
this.setState({
textColor: slowlyCalculateTextColor(this.props.color),
});
}
}
render() {
return (
<button className={
'Button-' + this.props.color +
' Button-text-' + this.state.textColor // ✅ 在最后一次渲染后是新的
}>
{this.props.children}
</button>
);
}
}
然而,这也意味着我们的组件在每次更新后,都有两次 render 调用。如果我们试图优化它,那也不理想。
你可以使用已不推荐的 componentWillReceiveProps 生命周期函数。然而,大家经常把 side effects 放这。这反过来又往往会给即将到来的并发渲染 特性像 Time Slicing 和 Suspense 带来问题。而更 “安全” 的 getDerivedStateFromProps 又有点难用。
让我们退一步。实际上,我们想要 memoization。我们有一些输入,除非输入发生变化,否则我们不想重新计算输出。
使用类,你可以使用帮助程序完成 memoization。但是,Hooks 更进了一步,提供了一种记忆昂贵计算的内置方法:
function Button({ color, children }) {
const textColor = useMemo(
() => slowlyCalculateTextColor(color),
[color] // ✅ 除非 `color` 改变,不会重新计算
);
return (
<button className={'Button-' + color + ' Button-text-' + textColor}>
{children}
</button>
);
}
这就是所以你需要的全部代码了!
在 class 组件中,你可以使用帮助程序类似 memoize-one。在函数组件中,useMemo Hook 提供了类似的功能。
现在我们看到优化昂贵的计算也不是将 props 复制到 state 的好理由。我们的渲染结果应该响应 props 的变化。
不要在 Side Effects 里阻断数据流
目前为止,我们讨论了关于如何在属性变化时保持渲染结果的一致。避免复制 props 到 state 是一部分。然而,side effects(例如获取数据)也是数据流重要的一部分
看看这个 React 组件:
class SearchResults extends React.Component {
state = {
data: null
};
componentDidMount() {
this.fetchResults();
}
fetchResults() {
const url = this.getFetchUrl();
// 获取数据...
}
getFetchUrl() {
return 'http://myapi/results?query' + this.props.query;
}
render() {
// ...
}
}
很多 React 组件都像这样——但如果看看,会发现这有个 bug。fetchResults 方法使用了 query 属性来做数据获取:
getFetchUrl() {
return 'http://myapi/results?query' + this.props.query;
}
但如果 query 变了呢?在这个组件中,什么都不会发生。这意味着组件的 side effects 没有响应它 props 的变化。这在 React 应用中是常见 bug 的根源。
为了修复这个组件,我们需要:
查看 componentDidMount 和它所调用的所有方法
在我们的例子中,就是 fetchResults 和 getFetchUrl
写下所有被他们使用的 props 和 state
在我们的例子中,就是 this.props.query
确保不论 props 如何变化,都重新执行 side effect
我们可以通过 componentDidUpdate 方法实现
class SearchResults extends React.Component {
state = {
data: null
};
componentDidMount() {
this.fetchResults();
}
componentDidUpdate(prevProps) {
if (prevProps.query !== this.props.query) { // ✅ 重新获取数据
this.fetchResults();
}
}
fetchResults() {
const url = this.getFetchUrl();
// 获取数据...
}
getFetchUrl() {
return 'http://myapi/results?query' + this.props.query; // ✅ 更新也处理好了
}
render() {
// ...
}
}
现在我们的代码能响应所有的 props 变化了,甚至是 side effects。
然而,牢记不要再搞砸了,这真是个挑战。例如,也许又加了 currentPage 到 state,然后在 getFetchUrl 中使用了它:
class SearchResults extends React.Component {
state = {
data: null,
currentPage: 0,
};
componentDidMount() {
this.fetchResults();
}
componentDidUpdate(prevProps) {
if (prevProps.query !== this.props.query) {
this.fetchResults();
}
}
fetchResults() {
const url = this.getFetchUrl();
// 获取数据...
}
getFetchUrl() {
return (
'http://myapi/results?query' + this.props.query +
'&page=' + this.state.currentPage // 🔴 更新被忽略了
);
}
render() {
// ...
}
}
哎呀,我们的代码又有 bug 了,这是因为 side effect 没有响应 currentPage 的变化。
props 和 state 是 React 数据流的一部分。在这个数据流中,rendering 和 side effects 都应响应它们的变化,而不是忽略它们!
为了修复这个问题,可以重复上面的步骤:
查看 componentDidMount 和它所调用的所有方法
在我们的例子中,就是 fetchResults 和 getFetchUrl
写下所有被他们使用的 props 和 state
在我们的例子中,就是 this.props.query 和 this.state.currentPage
确保不论 props 如何变化,都重新执行 side effect
我们可以通过 componentDidUpdate 方法实现让我们修复我们的组件,用以处理 currentPage 的更新吧:
class SearchResults extends React.Component {
state = {
data: null,
currentPage: 0,
};
componentDidMount() {
this.fetchResults();
}
componentDidUpdate(prevProps, prevState) {
if (
prevState.currentPage !== this.state.currentPage || // ✅ 更新后重新获取数据
prevProps.query !== this.props.query
) {
this.fetchResults();
}
}
fetchResults() {
const url = this.getFetchUrl();
// 获取数据...
}
getFetchUrl() {
return (
'http://myapi/results?query' + this.props.query +
'&page=' + this.state.currentPage // ✅ 更新被处理了
);
}
render() {
// ...
}
}
如果我们能够以某种方式自动捕捉到这些错误,那不是很好吗?难道没有什么 linter 可以帮助我们处理这事吗?
遗憾的是,自动检查类组件的一致性太困难了。任何方法都可以调用任何其他方法。静态分析来自 componentDidMount 和 componentDidUpdate 的调用充满了误报。
但是,可能 可以设计一个 能 被静态分析的一致性 API。React useEffect Hook 就是这样 API 的一个例子:
function SearchResults({ query }) {
const [data, setData] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
useEffect(() => {
function fetchResults() {
const url = getFetchUrl();
// 数据获取...
}
function getFetchUrl() {
return (
'http://myapi/results?query' + query +
'&page=' + currentPage
);
}
fetchResults();
}, [currentPage, query]); // ✅ 更新后重新获取
// ...
}
我们将逻辑 放在 effect 中,这样可以更容易地看到它从 React 数据流中依赖了哪些值。这些值称为“依赖”,在我们的例子中它们是 [currentPage, query]。
注意这个“effect 依赖”数组并不是一个新概念。在类中,我们必须遍历所有方法调用,来搜索这些“依赖项”。useEffect API 只是显式地使用了相同的概念。
反过来说,这可以让我们自动验证它们:
(这个 demo 来自最新推荐的 exhaustive-deps lint 规则,这是 eslint-plugin-react-hooks 其中一部分。它将不久加入到 Create React App 中)
注意,无论是将组件编写为类还是函数,都必须为 effect 响应所有 props 和 state 的更新。
使用 class API,你必须自己考虑一致性,并验证对每个相关 prop 或 state 的更改是否该由 componentDidUpdate 处理。否则,组件对 prop 和 state 的更改不具有弹性。这甚至不是专属于 React 的问题。它适用于任何允许你单独处理 “创建” 和 “更新” 事件的 UI 库
useEffect API 通过鼓励一致性来翻转默认值。这 可能在开始时觉得会不熟悉,但这样的结果是你的组件对逻辑更改更具弹性了。由于 “依赖关系” 现在是显式的,我们可以使用 lint 规则检验是否一致。我们用 linter 来捕捉 bug!
不要在优化中阻断数据流
还有一种情况,你可能会意外忽略对 props 的更改。当你手动优化组件时,可能会发生这类错误。
注意,使用浅比较的优化方法(如 PureComponent 和 React.memo)与默认比较是安全的。
但是,如果你尝试通过编写自己的比较方法来 “优化” 组件,你可能会错误地忘记比较函数属性:
class Button extends React.Component {
shouldComponentUpdate(prevProps) {
// 🔴 没比较 this.props.onClick
return this.props.color !== prevProps.color;
}
render() {
const onClick = this.props.onClick; // 🔴 将不响应更新
const textColor = slowlyCalculateTextColor(this.props.color);
return (
<button
onClick={onClick}
className={'Button-' + this.props.color + ' Button-text-' + textColor}>
{this.props.children}
</button>
);
}
}
一开始很容易错过这个错误,因为对于类,你通常会传递一个方法,所以它会有相同的身份:
class MyForm extends React.Component {
handleClick = () => { // ✅ 总是同一个函数
// Do something
}
render() {
return (
<>
<h1>Hello!</h1>
<Button color='green' onClick={this.handleClick}>
Press me
</Button>
</>
)
}
}
所以我们的优化不会马上被破坏。但是,它将持续 “看到” 旧的 onClick 值,但其他 props 不会这样:
class MyForm extends React.Component {
state = {
isEnabled: true
};
handleClick = () => {
this.setState({ isEnabled: false });
// Do something
}
render() {
return (
<>
<h1>Hello!</h1>
<Button color='green' onClick={
// 🔴 Button 忽略了 onClick 的更新
this.state.isEnabled ? this.handleClick : null
}>
Press me
</Button>
</>
)
}
}
在这个例子中,应该禁用按钮点击事件——但这不会发生,因为 Button 组件忽略了对onClick 属性的任何更新。
如果函数标识本身依赖于可能随时间变化的东西,例如本例中的 draft.content,这可能会让人更加困惑:
drafts.map(draft =>
<Button
color='blue'
key={draft.id}
onClick={
// 🔴 Button 忽略了 onClick 的更新
this.handlePublish.bind(this, draft.content)
}>
Publish
</Button>
)
虽然 draft.content 可能随着时间的推移而改变,但是我们的 Button 组件忽略了对onClick 属性的改变,所以它继续看到 onClick 绑定方法的 “第一个版本” 与原始的draft.content。
那怎么避免这个问题?
我建议避免手动实现 shouldComponentUpdate ,也要避免在 React.memo() 中使用自定义的比较方法。React.memo 中默认的浅比较会响应函数标识的更新:
function Button({ onClick, color, children }) {
const textColor = slowlyCalculateTextColor(this.props.color);
return (
<button
onClick={onClick}
className={'Button-' + color + ' Button-text-' + textColor}>
{children}
</button>
);
}
export default React.memo(Button); // ✅ 使用浅比较
在类中,PureComponent 也有类似的行为.
这确保了传递不同的函数作为 prop,始终能有效工作。
如果你坚持使用自定义的比较,请确保不跳过函数:
shouldComponentUpdate(prevProps) {
// ✅ 比较 this.props.onClick
return (
this.props.color !== prevProps.color ||
this.props.onClick !== prevProps.onClick
);
}
正如之前提到的,在类组件中很容易错过这个问题,因为方法标识通常是稳定的(但并非总是如此——而这就是 debug 困难的地方)。有了Hooks,情况不同了:
function 在每个渲染中都不同,所以你能马上发现这个问题
通过 useCallback 和 useContext,你能 避免往下传递函数。这让你优化渲染时不用太担心函数的问题。
总结一下这部分,不要阻断数据流!
无论何时使用 props 和 state,请考虑如果它们发生变化会发生什么。在大多数情况下,组件不应以不同方式处理初始渲染和更新流程。这使它能够适应逻辑上的变化。
对于类,在生命周期方法中使用 props 和 state 时很容易忘记更新。Hooks 推动你做正确的事情——但是如果你不习惯于这样做,它会需要一些心理调整。
原则 2:时刻准备渲染
React 组件使你可以不用花太长时间就能编写渲染代码。你描述了在任何时刻 UI 应该 如何,接着 React 去完成。好好利用那个模型!
不要试图在组件行为中,假设任何不必要的时序信息。你的组件应该随时可以重新渲染。
违背这个原则的方式是什么样的?React 让这没那么容易发生——但你可以使用传统的componentWillReceiveProps 生命周期方法来实现它:
class TextInput extends React.Component {
state = {
value: ''
};
// 🔴 每次父节点渲染时重置本地状态
componentWillReceiveProps(nextProps) {
this.setState({ value: nextProps.value });
}
handleChange = (e) => {
this.setState({ value: e.target.value });
};
render() {
return (
<input
value={this.state.value}
onChange={this.handleChange}
/>
);
}
}
在这个例子中,我们将 value 放在 state 里,但我们 也 从 props 接收 value。每当我们 “接收新 props” 时,就会重置状态中的 value。
这种模式的问题在于它完全依赖于偶然的时间。
也许今天这个组件的父级很少更新,我们的 TextInput 仅在重要事件发生时 “接收 props”,比如保存表单。
但明天你可能会向 TextInput 的父组件加一些动画。如果父组件经常重渲染,它将 “污染” 子组件的状态!你可以在 “你可能不需要派生状态” 中阅读有关此问题的更多信息。
所以该怎么修复呢?
首先,我们得调整一下心理,需要停止将 “接收 props” 视为与 “渲染” 不同的东西。由父组件引起的重渲染不应与由本地状态更改引起的重渲染不同。组件应该具有弹性,能适应更少或更频繁地渲染,否则它们与特定父组件存在过多耦合。
(这个 demo 给我们展示了重渲染会如何毁坏脆弱的组件)
当你真正想从 props 派生 state 时,尽管有一些不同的解决方案,通常你应该使用一个完全受控制的组件:
// Option 1: Fully controlled component.
function TextInput({ value, onChange }) {
return (
<input
value={value}
onChange={onChange}
/>
);
}
或者使用一个不受控的组件,加上 key 来重置它:
// Option 2: Fully uncontrolled component.
function TextInput() {
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>
);
}
// 之后我们能通过更改 key 来重置内部 state:
<TextInput key={formId} />
本节的内容是,组件不应该因为它或其父组件频繁地重渲染而坏掉。如果想避免使用传统的 componentWillReceiveProps 生命周期方法,React API 的设计,可以轻松实现这个目的。
要对你的组件进行压力测试,可以将这段代码临时添加到它的父组件:
componentDidMount() {
// 之后别忘了删除这行!
setInterval(() => this.forceUpdate(), 100);
}
可别把这段代码保留在项目里——这只是用来检查父组件重渲染的频率超出预期时,会发生什么的快速方法。它不应该损坏子组件!
你可能会想:“那我在 props 改变时重置 state,再使用 PureComponent 来避免不必要的重渲染”。
这段代码应该能工作,对吧?
// 🤔 应该阻止不必要的重渲染... 对吧?
class TextInput extends React.PureComponent {
state = {
value: ''
};
// 🔴 在父组件更新时重置本地 state
componentWillReceiveProps(nextProps) {
this.setState({ value: nextProps.value });
}
handleChange = (e) => {
this.setState({ value: e.target.value });
};
render() {
return (
<input
value={this.state.value}
onChange={this.handleChange}
/>
);
}
}
乍一看,这个组件似乎解决了父级重渲染时 “污染” state 的问题。毕竟,如果 props 是相同的,我们会跳过更新——所以 componentWillReceiveProps 不会被调用。
但是这都是幻觉。这个组件对实际的 prop 更改仍然不具备弹性。例如,如果我们添加另一个经常变化的 prop,就像用来做动画的 style,我们还是会 “丢失” 内部状态:
<TextInput
style={{opacity: someValueFromState}}
value={
// 🔴 componentWillReceiveProps in TextInput
// resets to this value on every animation tick.
value
}
/>
所以这个方法仍然存在缺陷。我们可以看到各种优化,例如 PureComponent、shouldComponentUpdate 和 React.memo,它们不应该用于控制行为。只有提高性能的场景下,去使用它们。如果删除优化就会破坏某个组件,那么它就太脆弱了。
这里的解决方案和之前提到的一样。不要将 “接受 props” 视作特殊的事件。避免 “同步” props 和 state。大部分情况下,每个值都应该是完全控制的(通过 props),或者完全不受控制的(在本地 state 里)。可以的话,避免派生 state。当然,时刻准备渲染!
原则 3:没有单例组件
有时我们假设某个组件只会显示一次,如导航栏。在一段时间内这也许是对的,然而,这种假设导致的设计问题,常常会在后期显现。
例如,你可能需要在路由变化后,两个 Page 组件切换间实现动画——前一个 Page 和下一个 Page。它们都需要在动画期间被加载。但是,你可能会发现,每个组件都假定它是屏幕上唯一的 Page。
要重现这类问题也很容易,试试渲染你的应用两次:
ReactDOM.render(
<>
<MyApp />
<MyApp />
</>,
document.getElementById('root')
);
到处点击看看。(你可能需要为这个实验调整一些 CSS 样式)
你的应用仍然正常运行吗?或者你是否看到奇怪的崩溃和错误?偶尔对复杂组件进行压力测试是个好主意,可以确保组件存在多个拷贝时不会相互冲突。
我自己写过几次有问题的代码,例如在 componentWillUnmount 中执行全局状态 “清理”:
componentWillUnmount() {
// 重置 Redux store 里的一些东西
this.props.resetForm();
}
当然,如果页面上有两个这样的组件,卸载其中一个组件可能会破坏另一个组件。在 mount 后重置 “全局” 状态也不好:
componentDidMount() {
// 重置 Redux store 里的一些东西
this.props.resetForm();
}
在这情况下, 挂载 第二个 form 就会破坏前一个。
这些模式是检测组件是否脆弱的好指标。显示 或 隐藏 一颗树,不应该破坏树之外的组件。
无论你是否有计划将这个组件渲染两次,从长远来看,解决这些问题是值得的。它将引导你进行更具弹性的设计。
原则 4:隔离本地状态
假设有一个 POST 社交组件,它有一个 Comment 列表(可扩展)和一个 NewComment 输入框。
React 组件可能有本地状态。但是哪个状态真的是自己的呢?帖子内容本身是否为本地状态?评论列表呢?或者评论流的记录?或评论框里输入的值?
如果你习惯于将所有内容都放入 “状态管理”,那么可能很难回答这个问题。所以这有一个简单的决定方式。
如果你不确定某个状态是否属于本地,请问自己:“如果此组件呈现两次,交互是否应反映在另一个副本中?” 只要答案为“否”,那你就找到本地状态了。
For example, imagine we rendered the same Post twice. Let’s look at different things inside of it that can change. 例如,假设我们两次渲染相同的 Post。让我们看一下哪些内容会更新。
发布内容 :我们希望在一棵树中编辑帖子,在另一棵树中也更新。因此,它可能不是 Post 组件的本地状态。(相反,帖子内容可能存在于 Apollo、Relay 或 Redux等缓存中)
评论列表 :这与帖子内容类似。我们希望在一棵树中添加一条新注释,以反映在另一棵树中。理想情况下,我们会为它使用某种缓存,而 不应该是 我们 Post 的本地状态。
被展开的评论:如果在一棵树中展开评论也会在另一棵树中展开,这有点奇怪。在这种情况下,我们与特定的 Comment UI 元素 进行交互,而不是抽象的 “评论实体”。因此,“是否已展开” 应该 是 Comment 的本地状态。
输入的新评论的值:如果在一个输入框中输入评论,也会更新到另一个树中的输入框,这也有点奇怪。除非输入框明确地组合在一起,否则通常人们期望它们是独立的。所以输入值 应该 是 NewComment 组件的本地状态。
我不建议对这些规则进行教条式的解释。当然,在一个简单的应用程序中,你可能希望使用本地状态来处理所有事情,包括 “缓存”。我只是谈论在第一原则下,理想状态的用户体验。
别把该本地的状态全局化了 这涉及到我们的 “弹性” 主题:组件之间发生的意外同步更少。作为奖励,这也修复了一大类性能问题。当你的 state 在正确的地方时,“过度渲染” 都不成问题了。
回顾
让我们再一次回顾一下这些原则:
不阻断数据流 props 和 state 可能会更新,组件应该处理好这些更新,不论什么时候。
时刻准备渲染 一个组件不应该被或多或少的渲染而损坏。
没有单例组件 即使组件只渲染一次,但通过设计让它渲染两次也不会被破坏,是更好了。
隔离本地状态 想想哪个状态是特定 UI 展示下的本地状态——并且除非必要,不要将该状态提升到更高的地方。
这些原则能帮助你编写针对更新而优化的组件。这让添加,更改和删除那些组件更容易。
重要的是,一旦我们的组件具有了弹性,那就可以再回过头,到是否应按字母排序的紧迫困境中去了。
关于本文
作者:@Dan Abramov
原文:https://overreacted.io/zh-hans/writing-resilient-components/
为你推荐
【第1658期】 Chrome 专家调试法 - 介绍 Chrome 开发者工具的最佳用法