编写弹性组件
当人们开始学习React时,他们经常会想要一份风格指南。虽然在项目中应用一些一致的规则是个好主意,但很多都是随意的 - 所以React对它们没有强烈的意见。
你可以使用不同的类型系统,偏向于函数声明或箭头函数,可以按字母顺序或你喜欢的顺序对props进行排序。
这种灵活性允许集成React到具有现有约定的项目中。但它也引发了无休止的争论。
每个组件都应该努力遵循一些重要的设计原则。但我不认为风格指南很好地捕获这些原则。我们先谈谈风格指南,然后再看看真正有用的原则。
不要被想象中的问题分散注意力
在我们讨论组件设计原则之前,我想先谈谈风格指南。这不是一个流行的观点,但有人需要说出来!
在JavaScript社区中,有一些由linter强制执行的样式指南。我个人的看法是,它们往往会产生比其价值更大的摩擦。我记不清有多少次有人向我展示了一些绝对有效的代码并说“React对这个有抱怨”,但这是他们的lint config 配置在抱怨!这导致了三个问题:
人们习惯了把linter看作是一个 过于热情且嘈杂的守卫 ,而不是一个有用的工具。有用的警告被风格的海洋淹没了。因此,人们在调试时不会扫描linter消息,并且会错过一些有用的提示。此外,不太习惯编写JavaScript的人(例如,设计人员)更难以使用代码。
人们没有学会 区分有效和无效用法 的特定模式。例如,有一个流行的规则禁止在
componentDidMount
中调用setState
。但如果它一直是“不好的”,React根本不会允许它这么使用!它有一个合法的用例,那就是测量DOM节点布局——例如定位一个工具提示。我见过有人通过添加setTimeout
“绕过”这条规则,这完全没有抓住重点。最终,人们采用了“强制执行者心态”,并且对那些 不会带来有意义的差异 但很容易在代码中扫描的事情产生了看法。“你使用了函数声明,但我们的项目使用了箭头函数。”每当我对执行这样的规则有强烈的感觉时,深入观察一下就会发现,我在这条规则上投入了情感的努力——并努力让它消失。它诱使我产生一种错误的成就感,而没有改进我的代码。
我是说我们应该停止linter?一点也不!
通过良好的配置,linter是一个很好的工具,可以在错误发生之前捕获它们。 它过于专注于 风格 ,使其变得分散注意力。
Marie Kondo Your Lint Config
这是我建议你周一做的。花半个小时召集你的团队,检查项目配置中启用的每一条lint规则,然后问自己: “这条规则是否曾帮助我们抓住了一个错误?” 如果没有,请将其关闭。(你也可以使用没有样式规则的eslint-config- response -app从头开始。)
至少,你的团队应该有一个消除导致摩擦的规则的过程。不要以为一年前你或别人添加到你的lint配置中的任何东西都是“最佳实践”。提问并寻找答案。不要让任何人告诉你你不够聪明,以至于不能选择你的lint规则。
但格式呢? 使用Prettier并忘记“风格”。如果另一个工具可以为你修复空间,你不需要一个工具来对你大喊大叫。使用linter查找错误,而不是强制执行错误。
当然,编码风格的某些方面与格式没有直接关系,但当整个项目不一致时,仍然会令人讨厌。
然而,它们中的许多都太微妙了,无法用lint规则捕捉。这就是为什么在团队成员之间 建立信任 以及以wiki页面或简短设计指南的形式分享有用的知识非常重要的原因。
并非一切都值得自动化!从 实际阅读 这种指南的基本原理中获得的见解可能比遵循“规则”更有价值。
但如果遵循严格的风格指南会分散注意力,那么什么才是真正重要的呢?
这是这篇文章的主题。
编写弹性组件
再多的缩进或按字母顺序对导入进行排序也无法修复破损的设计。因此,我不会关注某些代码的外观,而是关注它的工作原理。我发现有一些组件设计原则非常有用:
即使你不使用React,你也可能通过反复试验发现任何具有单向数据流的UI组件模型都遵循相同的原则。
原则1:不要停止数据流
不要在渲染中停止数据流
当有人使用你的组件时,他们希望可以随着时间的推移传递不同的props,并且该组件将反映这些更改:
通常,这是React默认工作的方式。如果在Button
组件中使用color
prop,你将看到上面为该渲染提供的值:
但是,学习React时常见的错误是将props复制到状态:
如果你使用React之外的类,乍一看这似乎更直观。但是,通过将prop复制到状态,你忽略了对它的所有更新。
在极少数情况下,这种行为是故意的,请确保调用该propinitialColor
或defaultColor
,以澄清对它的更改将被忽略。
但通常你会想 直接在你的组件中读取props ,并避免将props(或从props计算的任何东西)复制到状态:
计算值是人们有时尝试将props复制到州的另一个原因。例如,假设我们以背景color
为参数,通过代价高昂的计算来确定 按钮文本 的颜色:
这个组件有问题,因为它不会在color
prop 更改时重新计算this.state.textColor
。最简单的解决方法是将textColor
计算移动到render
方法中,并将其设为PureComponent
:
问题解决了!现在如果props改变了,我们将重新计算textColor
,但我们避免在相同的道具上进行昂贵的计算。
但是,我们可能希望进一步优化它。如果改变了children
prop怎么办?在这种情况下重新计算textColor
似乎很不好。我们的第二个尝试可能是调用componentDidUpdate
中的计算:
然而,这将意味着我们的组件在每次更改后都会重新呈现。如果我们要优化它,这也不理想。
你可以使用旧版componentWillReceiveProps
生命周期。然而,人们经常把副作用也放在那里。这反过来又会给即将到来的并发呈现特性带来问题,比如时间切片和Suspense。而“更安全”的getDerivedStateFromProps
方法很笨重。
让我们退一步吧。实际上,我们想要memoization。我们有一些输入,除非输入发生变化,否则我们不想重新计算输出。
使用类,你可以使用帮助程序进行记忆。但是,Hooks更进了一步,为你提供了一种内置方法去记忆昂贵的计算:
这就是你需要的所有代码!
在类的组件中,你可以使用memoize-one
之类的帮助程序。在函数组件中,useMemo
Hook为你提供类似的功能。
现在我们看到,即使优化昂贵的计算,也不是将props复制到状态的好理由。 我们的渲染结果应该遵守props的变化。
不要在副作用中停止数据流
到目前为止,我们已经讨论过如何使渲染结果与props变化保持一致。避免将道具复制到状态是其中的一部分。但是,重要的是 副作用(例如数据提取)也是数据流的一部分。
考虑一下这个React组件:
很多React组件都是这样的 - 但如果我们看得更细一点,我们会发现一个bug。fetchResults
方法使用query
prop来获取数据:
但是如果query
prop发生了变化呢?在我们的组件中,什么都不会发生。这意味着我们组件的副作用不会遵守其props的变化。 这是React应用程序中非常常见的错误来源。
为了修复我们的组件,我们需要:
查看
componentDidMount
以及从中调用的每个方法。在我们的示例中,是
fetchResults
和getFetchUrl
。
记下这些方法使用的所有props和状态。
在我们的例子中,这就是
this.props.query
。
确保每当props改变时,我们重新运行副作用。
我们可以通过添加
componentDidUpdate
方法来实现。
现在我们的代码遵守 props 的所有变化,甚至是副作用。
然而, 记住不要再打破它是很具有挑战性的。例如,我们可能会将currentPage
添加到本地状态,并在getFetchUrl
中使用它:
唉,我们的代码再次出错,因为我们的副作用不遵守对currentPage
的更改。
props和状态是React数据流的一部分。渲染和副作用都应该反映数据流的变化,而不是忽略它们!
要修复我们的代码,我们可以重复上述步骤:
查看
componentDidMount
以及从中调用的每个方法。在我们的示例中,是
fetchResults
和getFetchUrl
。
记下这些方法使用的所有props和状态。
在我们的例子中,这就是
this.props.query
和this.state.currentPage
。
确保每当props改变时,我们重新运行副作用。
我们可以通过添加
componentDidUpdate
方法来实现。
让我们修复我们的组件来处理currentPage
状态的更新:
如果我们能以某种方式自动捕捉这些错误,那不是很好吗? 这不是linter能帮我们的吗?
不幸的是,自动检查类组件的一致性太困难了。任何方法都可以调用任何其他方法。静态地分析来自componentDidMount
和componentDidUpdate
的调用充满了误报。
但是,可以 设计一个可以静态分析一致性的API。React useEffect
Hook就是这样一个API的一个例子:
我们将逻辑放在效果(effect)中,这样可以更容易地看到它依赖的React数据流中的哪些值 。这些值称为“依赖项”,在我们的示例中它们是[currentPage, query]
。
请注意,这个“效果依赖项”数组实际上并不是一个新概念。在类中,我们必须通过所有方法调用搜索这些“依赖项”。useEffect
API只是明确了相同的概念。
反过来,这可以让我们自动验证它们:
(这是一个新推荐的exhaustive-deps
lint规则的演示,它是eslint-plugin-react-hooks
的一部分。它很快将包含在Create React App中。)
请注意,无论是将组件编写为类还是函数,都必须遵守效果的所有prop和state更新。
使用类API,你必须自己考虑一致性,并验证componentDidUpdate
是否处理对每个相关prop或state的更改。否则,你的组件对prop和状态更改不具有弹性。这甚至不是特定于React的问题。它适用于任何允许你单独处理“创建”和“更新”的UI库。
useeffect
API通过鼓励一致性来翻转默认值。 一开始可能会感到陌生,但结果是你的组件对逻辑更改更具弹性。由于“依赖关系”现在是明确的,我们可以使用lint规则验证效果是否一致。我们用一个linter来捕获bug!
不要在优化中停止数据流
还有一种情况,你可能会意外地忽略对props的更改。当你手动优化组件时,可能会发生此错误。
s请注意,使用类似pureComponent
和react.memo
这样的浅比较的优化方法作为默认比较是安全的。
但是,如果你尝试通过编写自己的比较来“优化”组件,则可能会错误地忘记比较函数props:
一开始很容易忽略这个错误,因为在类中,你通常会传递一个方法,所以无论如何它都具有相同的标识:
所以我们的优化不会立即中断。然而,如果它随着时间的推移而改变,它将继续“看到”旧的onClick
值,但其他props没有改变:
在此示例中,单击该按钮应禁用它 - 但这不会发生,因为Button
组件忽略对onClick
prop的任何更新。
如果函数标识本身依赖于可能随时间变化的内容,例如本示例中的draft.content
,则可能会更加混乱:
虽然draft.content
可能会随着时间的推移而发生变化,但我们的Button
组件忽略了对onClick
prop的更改,因此它将继续看到onClick
绑定方法的“第一版”和原始draft.content
。
那么我们如何避免这个问题呢?
我建议避免手动实现shouldComponentUpdate
并避免指定React.memo()
的自定义比较。React.memo
中的默认浅比较将遵守更改的函数标识:
在类中,PureComponent
具有相同的行为。
这确保了将不同的函数作为prop传递将始终有效。
如果你坚持自定义一个比较,请确保你不要跳过函数 :
正如我前面提到的,在类组件中很容易忽略这个问题,因为方法标识通常是稳定的(但并不总是稳定的——这就是bug变得难以调试的地方)。有了Hooks,情况有点不同:
总结本节,不要停止数据流!
无论何时使用props和状态,请考虑如果它们发生变化会发生什么。在大多数情况下,组件不应以不同方式处理初始渲染和更新。这使它能够适应逻辑上的变化。
对于类,在生命周期方法中使用props和state时很容易忘记更新。Hooks会督促你去做正确的事情——但是如果你还不习惯这样做的话,就需要一些心理上的调整。
原则2:始终准备好渲染
React组件使你可以编写渲染代码而无需担心花费太多时间。你描述了UI在任何给定时刻 应该 展现的外观,而React使它成为现实。好好利用吧!
不要试图在组件行为中引入不必要的时间假设。你的组件应该随时可以重新渲染。
怎样才能违反这一原则?React并不容易实现,但您可以通过使用遗留组件willReceiveProps生命周期方法来实现:
在这个例子中,我们将value
保存在本地状态,但我们也从props获得value
。每当我们“收到新props”时,我们都会重置状态内的value
。
这种模式的问题在于它完全依赖于偶然的时间安排。
也许今天这个组件的父组件很少更新,所以我们的TextInput
只在一些重要的事情发生时“接收props”,比如保存表单。
但是明天你可能会向TextInput
的父级添加一些动画。如果它的父级经常重新渲染,它将继续“吹走”子级状态!你可以在“你可能不需要派生状态”中阅读有关此问题的更多信息。
那我们怎么解决这个问题呢?
首先,我们需要修复我们的心理模型。我们需要停止将“接收props”视为与“渲染”不同的东西。由父级引起的重新渲染不应与由我们自己的本地状态更改引起的重新渲染不同。组件应该能够弹性的地进行较少或更频繁的渲染,因为否则它们会与特定的父对象过度耦合。
(这个演示展示了重新渲染如何破坏脆弱的组件。)
当你真正想从props中派生状态时,有几种不同的解决方案,通常你应该使用完全受控的组件:
或者你可以使用一个不受控制的组件和一个键来重置它:
本节的内容是,你的组件不应该因为它或其父级更频繁地重新渲染而中断。如果你避免使用传统的componentWillReceiveProps
生命周期方法,则React API设计可以轻松实现。
要对组件进行压力测试,可以将此代码临时添加到其父级:
不要留下这些代码 - 这只是一种快速的方法,可以检查当父级重新渲染的次数超出预期时会发生什么。它不应该打破子级!
你可能会想:“我会在props改变时继续重置状态,但会阻止使用PureComponent
进行不必要的重新渲染”。
这段代码应该有用,对吧?
起初,看起来这个组件似乎解决了在父级重新渲染时“吹走”状态的问题。毕竟,如果props是相同的,我们只是跳过更新 - 因此不会调用componentWillReceiveProps
。
但是,这给了我们一种虚假的安全感。这个组件仍然不能适应prop改变。 例如,如果我们添加另一个经常变化的prop,比如动画style
,我们仍然会“丢失”内部状态:
所以这种方法仍然存在缺陷。我们可以看到,不应使用PureComponent
,shouldComponentUpdate
和React.memo
等各种优化来控制行为。 只在有帮助的地方使用它们来提高性能。如果删除一个优化会破坏一个组件,那么它就太脆弱了。
这里的解决方案与我们之前描述的相同。不要将“接受道具”视为特殊事件。避免“同步”props和状态。在大多数情况下,每个值都应该完全控制(通过props),或完全不受控制(在本地状态)。尽可能避免派生状态。并随时准备渲染!
原则3:没有组件是单例的
有时我们假设某个组件只显示一次。如导航栏。这可能在一段时间内是正确的。然而,这种假设常常会导致设计问题,而这些问题要到很久以后才会浮出水面。
例如,你可能需要在路由更改(上一Page
和下一Page
)的两个Page
组件之间实现动画。它们都需要在动画期间挂载(mounted)。但是,你可能会发现,每个组件都假定它是屏幕上唯一的页面。
检查这些问题很容易。只是为了好玩,尝试渲染你的应用程序两次:
点击他。(在这个实验中,你可能需要调整一些CSS。)
你的应用仍然按预期运行吗? 你看到奇怪的崩溃和错误吗?偶尔对复杂组件进行压力测试是一个好主意,并确保它们的多个副本不会相互冲突。
我自己写过几次的一个有问题的模式的例子是在componentWillUnmount
中执行全局状态“清理”:
当然,如果页面上有两个这样的组件,卸载其中一个组件会破坏另一个组件。在mount上重置“全局”状态也好不到哪里去:
在这种情况下,mounting第二个form将破坏第一个form。
这些模式很好地指示了我们的组件在哪些地方是脆弱的。显示或隐藏树不应该破坏该树之外的组件。
无论你是否计划将此组件呈现两次,从长远来看,解决这些问题都是值得的。它会让你的设计更具弹性。
原则4:保持本地状态隔离
考虑社交媒体Post
组件。它有一个Comment
列表(可以展开)和一个NewComment
输入框。
React组件可能具有本地状态。但是哪个状态才是本地的?帖子内容本身是否为本地状态?那么评论列表呢?或者扩展的评论的记录?或评论框输入的值?
如果你习惯了把一切都放在“状态管理器”中,那么回答这个问题可能会很有挑战性。所以这是一个简单的决定方式。
如果你不确定某个状态是否属于本地状态,请问自己:“如果这个组件被渲染两次,那么这种互动是否会反映在另一个副本中?” 每当答案为“否”时,你就会找到一些本地的状态。
例如,假设我们渲染了相同的Post
两次。让我们看看它里面不同的可以改变的东西。
Post content. 我们想要在一棵树中编辑帖子(post)以在另一棵树中更新它。因此,它可能 不应该 是
Post
组件的本地状态。(相反,帖子内容可能存在于Apollo,Relay或Redux等缓存中。)List of comments. 这类似于post content。我们希望在一棵树中添加一条新评论,以反映在另一棵树中。理想情况下,我们会为它使用某种缓存,它 不应该 是我们
Post
的本地状态。Which comments are expanded. 如果在一个树中展开评论也会在另一个树中展开,这将会很奇怪。在本例中,我们使用的是特定的
Comment
UI 表示 ,而不是抽象的“评论实体”。因此,“展开”标志 应该 是Comment
的本地状态。The value of new comment input. 如果在一个输入中键入评论也会更新另一个树中的输入,那将会很奇怪。除非输入明确地组合在一起,否则通常人们期望它们是独立的。因此输入值 应该 是
NewComment
组件的本地状态。
我不建议对这些规则进行教条式的解释。当然,在一个更简单的应用程序中,你可能希望对所有内容都使用本地状态,包括那些“缓存”。我只是从最基本的原则谈起理想的用户体验。
避免让真正的本地状态全局化。 这就涉及到我们的“弹性”主题: 组件之间很少发生同步。另外,这还修复了大量的性能问题。当你的状态处于正确的位置时,“过度渲染”就不是什么问题了。
概括
让我们再一次回顾这些原则:
不要停止数据流。props和状态可以改变,组件应该在发生时处理这些改变。
始终准备渲染。组件不应该破坏,因为它或多或少经常呈现。
没有组件是单例的。即使一个组件只渲染一次,如果渲染两次不会破坏它,那么你的设计也会得到改进。
保持本地状态隔离。考虑哪个状态是特定UI表示的本地状态——不要将该状态提升到不必要的高度。
这些原则可帮助你编写优化更改的组件。很容易添加、更改和删除它们。
最重要的是,一旦我们的组件恢复了弹性,我们就会回到一个紧迫的两难境地,即props是否应该按字母排序。
Last updated