Myblackat 2019-04-23
本文有关 React 的“黄金法则”只是以一个新的角度,对展示组件和容器组件的现有概念的重新阐述。
作者 | Rico Kahler
译者 | 苏本如
责编 | 屠敏
出品 | CSDN(ID:CSDNnews)
以下为译文:
最近,我在开发组件时采用了一种新的思路。这不一定是一种全新的想法,而是对原有思维方式的一种微妙调整。这种新的思路就是:
组件开发的黄金法则
以最自然的方式创建和定义组件,只需要考虑它们需要什么去实现功能。
再次澄清,这是对原有思维方式的一种微妙调整,你可能认为你已经遵循了它,但违背它也很容易。
举个例子,假设你有如下的PersonCard组件:
如果你想“自然地”定义这个组件,那么你可能会写出下面的API:
PersonCard.propTypes = { name: PropTypes.string.isRequired, jobTitle: PropTypes.string.isRequired, pictureUrl: PropTypes.string.isRequired, };
“自然的” PersonCard API
看上去非常直接,我们看看PersonCard这个组件需要什么属性就可以工作。这里,PersonCard组件只需要名称,职务和照片URL。
但假如你需要实现“根据用户的设置,决定是否显示正式照片”的功能。你就可能会尝试编写这样的API:
PersonCard.propTypes = { name: PropTypes.string.isRequired, jobTitle: PropTypes.string.isRequired, officialPictureUrl: PropTypes.string.isRequired, pictureUrl: PropTypes.string.isRequired, preferOfficial: PropTypes.boolean.isRequired, };
复杂化的PersonCard API
看起来似乎这个复杂化的组件需要那些额外的props来实现它的功能,但实际上,上面的组件看起来并没有什么不同,也不需要那些额外的props。这些额外的属性所做的是将这个preferOfficial设置与组件结合起来,但这样做会导致在其它场合(例如:不需要显示正式照片的场合)使用这个组件感到非常“不自然”。
容器的作用
那么,如果“决定显示哪个照片URL”的逻辑不属于这个组件本身,它应该属于哪里?
如果用一个index文件来替代怎么样?
我们采用了一种文件夹结构,其中每个组件都放在一个自命名的文件夹,index文件负责在你的“自然组件”和外部世界之间牵线搭桥。我们称此文件为“容器”(灵感来自于 React Redux的容器组件概念:https://redux.js.org/basics/usage-with-react#presentational-and-container-components)。
/PersonCard -PersonCard.js ------ the "natural" component -index.js ----------- the "container"
容器:我们将容器定义为插入在你的自然组件和外部世界之间(负责连接这两部分)的一小段代码。因为这个原因,有时候我们也把这些“容器”叫做“injector (注入器)”。
自然组件:如果你被要求一张照片,你的“自然组件”就是这些用来显示这张照片的代码,你不需要知道如何获得数据的细节,也不需要知道它在应用程序中的位置,所有你需要知道的是它应该起作用。
外部世界:这里指的是你的应用程序所拥有的任何资源(例如Redux Store),而这些资源是可以被转换以满足你的自然组件的props。
本文的目标:我们如何编写“自然的”组件并确保它不会被来自外部世界的垃圾污染?为什么这样做更好?
备注:本文中的“容器”概念虽然是受到这两个链接(Dan Abramov和React Redux)中的术语的启发,但是本文中的“容器”定义略有不同。
Dan Abramov的容器和本文中容器的唯一区别是在概念层面上。Dan在文章中说有两种组件:展示组件和容器组件。而本文我们更进一步,把它区分为组件和容器。
即使我们使用组件实现容器,我们也不会在概念层面将容器视为组件。这就是为什么我们建议将容器放在index文件中的原因,因为它是你的自然组件和外部世界之间的桥梁,并不是独立的。
你可能发现,本文的重点是关注组件,然而容器却占据了大半篇幅。为什么呢
这是因为:编写自然组件更容易,更有趣。而让你的组件连接到外部世界(容器的工作)有点难度。
依我看,导致你的“自然组件”被外部世界的垃圾污染的主要原因有三个:
接下来,我将尝试用不同类型容器的实现示例,对上述三种情形进行阐述。
使用奇怪的数据结构
有时候为了渲染所需要的信息,需要将不同类型的数据链接在一起,并将其转换成为更合理的格式。因为没有更合适的词,所以这里我用了“奇怪的”这个词。“奇怪的数据结构”就是那种让你的组件使用起来“不自然”的数据结构。
将“奇怪的”数据结构直接传递到组件中,并在组件内部进行转换是非常诱人的,但这会导致混乱,并且常常使得组件测试很难进行。
最近,当我被分配去创建一个组件,并从我们用来支持一个特定类型表单的特定数据结构中获取数据时,我发现自己落入了这个陷阱(示例如下)。
ChipField.propTypes = { field: PropTypes.object.isRequired, // <-- the "weird" data structure onEditField: PropTypes.func.isRequired, // <-- and a weird event too };
这个组件将这个奇怪的数据结构field作为一个props。事实上,如果我们后面不再用到这个组件的话,这样做是可以接受的。但是后来这个组件在一个与这个奇怪的数据结构无关的地方又用到了,这样它就成了一个真正的问题。
因为组件需要这个数据结构,所以重用它变得不可能,重构它也很困难。我们最初编写的测试程序也令人困惑,因为模拟了这种奇怪的数据结构。我们无法理解这些测试程序,并且在最终重构时无法重新编写它们。
不幸的是,奇怪的数据结构是不可避免的,但是使用容器是处理它们的一种很好的方法。我从这个例子学到的一点是,以这种方式构建组件,让你可以选择对组件进行提取,并将其转变为可重用的组件。但是如果你将一个奇怪的数据结构传递到一个组件中,你将失去这个选择。
注意:我并不是建议你所写的所有组件从一开始就应该是通用的。而是建议你想一想你的组件从根本上讲要实现什么,然后弥合间隙。如果你这样做了,您就会选择将你的组件转变为可重用的组件,这种转变需要的工作量很小。
使用函数组件实现容器
如果严格映射props,一个简单的实现方式是使用函数组件:
import React from 'react'; import PropTypes from 'prop-types'; import getValuesFromField from './helpers/getValuesFromField'; import transformValuesToField from './helpers/transformValuesToField'; import ChipField from './ChipField'; export default function ChipFieldContainer({ field, onEditField }) { const values = getValuesFromField(field); function handleOnChange(values) { onEditField(transformValuesToField(values)); } return <ChipField values={values} onChange={handleOnChange} />; } // external props ChipFieldContainer.propTypes = { field: PropTypes.object.isRequired, onEditField: PropTypes.func.isRequired, };
组件的文件夹结构像下面这样:
/ChipField -ChipField.js ------------------ the "natural" chip field -ChipField.test.js -index.js ---------------------- the "container" -index.test.js /helpers ----------------------- a folder for the helpers/utils -getValuesFromField.js -getValuesFromField.test.js -transformValuesToField.js -transformValuesToField.test.js
你可能在想“这种实现方式的要做的工作太多了”。你有这种想法很正常。是的,这里看起来要做的工作是多了,因为需要维护更多的文件,而且看上去也不那么直观。但是你可能忽视了一点:
其实真正的工作量并没有增加,不管你是在组件外部或者是在组件内部转换数据,需要的工作量其实都是一样的。区别只是在于,当你把数据转换放在组件外进行时,你就能够更准确地测试出你的数据转换是否正确,同时也分离了关注点。
组件之外实现需求
对于上面提到的PersonCard的示例,当你将组件开发的“黄金法则”应用其上时,你很可能会意识到某些需求超出了实际组件的范围。那么这些需求如何实现呢?
你应该猜到了答案:对,使用容器。
你可以通过创建容器来做一些额外的工作来保持组件的自然性。当你这样做的时候,你最终会得到一个更加简单而且功能单一的组件,和一个更好测试的容器。
让我们用一个PersonCard容器的实现来举例说明。
使用高阶组件实现容器
React Redux使用高阶组件来实现从Redux Store推送和映射props的容器。因为“容器”这个术语是从React Redux得到来的,所以毫不奇怪,React Redux的connect就是一个容器。
无论你是使用函数组件来映射props, 还是使用高阶组件connect到Redux Store,其中的黄金法则和容器的作用都是相同的。首先,编写你的自然组件,然后使用高阶组件来弥合间隙。
import { connect } from 'react-redux'; import getPictureUrl from './helpers/getPictureUrl'; import PersonCard from './PersonCard'; const mapStateToProps = (state, ownProps) => { const { person } = ownProps; const { name, jobTitle, customPictureUrl, officialPictureUrl } = person; const { preferOfficial } = state.settings; const pictureUrl = getPictureUrl(preferOfficial, customPictureUrl, officialPictureUrl); return { name, jobTitle, pictureUrl }; }; const mapDispatchToProps = null; export default connect( mapStateToProps, mapDispatchToProps, )(PersonCard);
使用高阶组件的容器实现
上述实现的文件夹结构如下:
/PersonCard -PersonCard.js ----------------- natural component -PersonCard.test.js -index.js ---------------------- container -index.test.js /helpers -getPictureUrl.js ------------ helper -getPictureUrl.test.js
注意:在这个示例中,/helpers文件夹里只有一个getPictureURL,真实情况应该还有其它helpers,这里只是演示你可以把这个逻辑分离出来。您还可能注意到,无论对于哪种容器实现,文件夹结构其实都是一样的。
如果你有Redux的使用经验,你可能已经熟悉上面的示例。这里再重复一次,黄金法则不是一种全新的想法,而是对原有思维方式的一种微妙调整。
此外,在你用高阶组件实现容器时,你还可以在功能上将高阶组件组合在一起,亦即你可以将props从一个高阶组件传递到下一个。在过去,我们曾经将多个高阶组件链接在一起,来实现单个容器。
备注:React社区似乎有正在远离高阶组件的趋势。
我也会推荐同这样做。我从使用高阶组件得来的经验是,对于不熟悉函数组件的团队成员来说,它们可能会令人困惑,并且会导致所谓的“封装地狱”,即组件被封装太多次,从而导致严重的性能问题。
这里我列出一些相关的文章和资源供参考:
好了,到了我们谈谈React Hooks(钩子)的时候了。
使用hooks实现容器
你可能问为什么本文要讨论Hooks, 原因是使用Hooks来实现容器要容易得多。
如果你还不熟悉React Hooks,那么我建议你观看Dan Abramov和Ryan Florence在React 2018大会上的演讲视频,视频中有React Hooks的概念介绍(https://youtu.be/dpw9EHDh2bM)。
简而言之,React团队引入Hooks是为了应对高阶组件以及类似的模式带来的问题。React Hooks旨在替代它们,成为大多数场景下的最佳解决方案。
这意味着我们可以通过使用一个函数组件和Hooks来实现容器。
在下面的示例中,我们使用了useRoute和useRedux这两个Hooks来表示“外部世界”,我们使用getValues这个helper将“外部世界”映射为你的自然组件可用的props。我们还使用transformValues这个helper将组件的输出转换为由dispatch表示的外部世界。
import React from 'react'; import PropTypes from 'prop-types'; import { useRouter } from 'react-router'; import { useRedux } from 'react-redux'; import actionCreator from 'your-redux-stuff'; import getValues from './helpers/getVaules'; import transformValues from './helpers/transformValues'; import FooComponent from './FooComponent'; export default function FooComponentContainer(props) { // hooks const { match } = useRouter({ path: /* ... */ }); // NOTE: `useRedux` does not exist yet and probably won't look like this const { state, dispatch } = useRedux(); // mapping const props = getValues(state, match); function handleChange(e) { const transformed = transformValues(e); dispatch(actionCreator(transformed)); } // natural component return <FooComponent {...props} onChange={handleChange} />; } FooComponentContainer.propTypes = { /* ... */ };
使用hooks来实现容器
参考文件夹结构如下:
/FooComponent ----------- the whole component for others to import -FooComponent.js ------ the "natural" part of the component -FooComponent.test.js -index.js ------------- the "container" that bridges the gap -index.js.test.js and provides dependencies /helpers -------------- isolated helpers that you can test easily -getValues.js -getValues.test.js -transformValues.js -transformValues.test.js
容器内触发事件
最后一种背离自然组件的情形,通常发生在需要触发一个与组件挂载和更新组件props相关的事件的时候。
举个例子,你的任务是开发一个“仪表板”的功能。设计团队给了一个“仪表板”的模板,你把它转换成一个React组件。现在,您必须用数据填充这个“仪表板”。
你发现,当你挂载这个组件,你需要调用一个函数(例如dispatch(fetchAction)),来得到这些数据。
在这个场景中,我添加了componentDidMount和componentDidUpdate两个生命周期方法,并添加了onMount或onDashboardIdChanged两个props,因为我需要一些事件来触发,以便将组件和外部世界链接起来。
按照黄金法则,onMount和onDashboardIdChanged这两个props是“不自然”的,因此应该放在容器中。
使用Hooks的好处在于,它使得在组件挂载(onMount)或者组件props更新时的事件分发变得更简单!
组件挂载时的事件触发:
若要在组件挂载时触发一个事件,就需要使用一个空的数组作为传入参数来调用Hook useEffect(如下图)。
import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useRedux } from 'react-redux'; import fetchSomething_reduxAction from 'your-redux-stuff'; import getValues from './helpers/getVaules'; import FooComponent from './FooComponent'; export default function FooComponentContainer(props) { // hooks // NOTE: `useRedux` does not exist yet and probably won't look like this const { state, dispatch } = useRedux(); // dispatch action onMount useEffect(() => { dispatch(fetchSomething_reduxAction); }, []); // the empty array tells react to only fire on mount // https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects // mapping const props = getValues(state, match); // natural component return <FooComponent {...props} />; } FooComponentContainer.propTypes = { /* ... */ };
使用Hooks的组件挂载时的容器内触发事件
组件prop更新时的事件触发:
useEffect这个Hook能够在重新渲染和属性更新时调用你提供的函数之间,检查属性的变化。
在使用Hook useEffect之前,我发现自己在组件中添加了“不自然”的生命周期方法和OnPropertyChanged属性,因为我没有什么办法在组件外部比较属性的差异(示例如下):
import React from 'react'; import PropTypes from 'prop-types'; /** * Before `useEffect`, I found myself adding "unnatural" props * to my components that only fired events when the props diffed. * * I'd find that the component's `render` didn't even use `id` * most of the time */ export default class BeforeUseEffect extends React.Component { static propTypes = { id: PropTypes.string.isRequired, onIdChange: PropTypes.func.isRequired, }; componentDidMount() { this.props.onIdChange(this.props.id); } componentDidUpdate(prevProps) { if (prevProps.id !== this.props.id) { this.props.onIdChange(this.props.id); } } render() { return // ... } }
旧方法: 使用类来实现属性更新时的事件触发
现在有了Hook useEffect,就有了一种非常轻量级的方法来实现prop更新时的事件触发,而不需要在真实组件中添加不必要的props(示例如下)。
import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useRedux } from 'react-redux'; import fetchSomething_reduxAction from 'your-redux-stuff'; import getValues from './helpers/getVaules'; import FooComponent from './FooComponent'; export default function FooComponentContainer({ id }) { // hooks // NOTE: `useRedux` does not exist yet and probably won't look like this const { state, dispatch } = useRedux(); // dispatch action onMount useEffect(() => { dispatch(fetchSomething_reduxAction); }, [id]); // `useEffect` will watch this `id` prop and fire the effect when it differs // https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects // mapping const props = getValues(state, match); // natural component return <FooComponent {...props} />; } FooComponentContainer.propTypes = { id: PropTypes.string.isRequired, };
新方法:使用hook useEffect来实现属性更新时的事件触发
免责声明:除了使用useEffect这个hook,也有其它一些方法可以在容器内使用其他高阶组件(如recompose’s lifecycle的示例:https://github.com/acdlite/recompose/blob/3db12ce7121a050b533476958ff3d66ded1c4bb8/docs/API.md#lifecycle)来比较prop的差异,或者创建一个生命周期组件(如react router内部实现的示例:https://github.com/ReactTraining/react-router/blob/89a72d58ac55b2d8640c25e86d1f1496e4ba8d6c/packages/react-router/modules/Lifecycle.js),但这些方法要么让团队成员感到困惑,要么是非常规做法。
这样做的好处是什么?
享受组件开发的乐趣
对我来说,组件开发是前端开发中最有趣和最令人开心的部分。你可以把你的团队的想法和梦想变成真实的体验,这是一种很好的感觉,我认为我们应该在团队内分享,因为这和我们每个人都是相关的。
你的组件API和体验被“外部世界”破坏的场景永远不会出现。你的组件正如你想要的那样,没有额外的props, 这是我最喜欢的黄金法则的地方。
更多测试和重用的机会
采用这样的体系结构,你基本上是在渲染时引入了一个新的数据层。而在这个“层”中,你可以将你的关注点在进入组件的数据的正确性和组件的工作方式之间进行切换。
不管你是否知道,这个层已经存在于你的应用程序中,但是它可能与表现逻辑结合在一起。我发现,当我渲染这一层时,我可以进行大量的代码优化,并重用大量的逻辑,否则我可能在不知道共性的情况下重复编写这些逻辑。
通过添加一些定制的Hooks,这些好处将变得更加明显。定制的Hooks为我们提供了一种更简单的方法来提取逻辑并支持外部更新,而这是helper函数无法做到的。
最大化团队产出
当一个团队共同工作时,你可以将容器开发和组件开发的工作分配给不同的团队成员。如果API的规格已经确定,那么下面三部分的开发工作可以同步进行:
有例外情形吗?
就像真正的黄金法则一样,这里的黄金法则也是一个黄金拇指法则。在某些场景,编写看似不自然的组件API以降低某些转换的复杂性也是一种合理的做法。
一个简单的例子是props的名称。如果开发人员以更“自然”的理由来对props重命名,那么将使事情变得更复杂。
过分地强调这个法则是可能发生的,结果是你可能陷入困境。
结束语
本文的“黄金法则”只是以一个新的角度,对展示组件和容器组件的现有概念的重新阐述。如果你能清楚你的组件到底要做什么,那么你可能会得到更简单易读的部分!
原文:https://medium.freecodecamp.org/how-the-golden-rule-of-react-components-can-help-you-write-better-code-127046b478eb
本文为CSDN翻译,转载请注明来源出处。