原文:https://www.freecodecamp.org/news/how-the-golden-rule-of-react-components-can-help-you-write-better-code-127046b478eb/
作者:Rico Kahler
译者:ZhichengChen
校对者:Chengjun.L
提示:文中的蓝色字体可点击“阅读原文”访问更多内容
最近学到了一个新的理念,它改变了我创建组件的方式,它不仅是一个新的点子,还是一个新的视角。
组件的黄金法则
用更自然的方式创建和定义组件,组件只包含它们必需的代码。
这是很短的一句话,可能你觉得已经理解了,但却很容易违反这一原则。
比如,有如下组件:
PersonCard
如果自然地定义这个组件你可能会这样写:
PersonCard.propTypes = { name: PropTypes.string.isRequired, jobTitle: PropTypes.string.isRequired, pictureUrl: PropTypes.string.isRequired,};
代码很简单,每个属性都是它所必需的,如 name、job title 和 picturel URL。
假设现在需要添加用户可更改的另一个更正式的图片,可能最容易想到的就是:
PersonCard.propTypes = { name: PropTypes.string.isRequired, jobTitle: PropTypes.string.isRequired, officialPictureUrl: PropTypes.string.isRequired, pictureUrl: PropTypes.string.isRequired, preferOfficial: PropTypes.boolean.isRequired,};
看起来这些 props 是组件必需的,实际上,组件没有这些 props 也不会受到影响。而且添加了 preferOfficial
看似增加了灵活性,其实逻辑本来不该添加在这里,考虑复用的时候会发现这样做很不优雅。
如何改进
那么转换图片 URL 的逻辑不属于组件本身,那它属于哪里呢?
放在 index
里怎么样?
我们采用如下的目录结构,每个组件都有自己名字命名的文件夹,index
文件是沟通优雅组件和外部世界的桥梁。我们把这个文件叫做 “容器”(container)(参考了React Redux 的 “container” 组件概念)。
/PersonCard -PersonCard.js ------ the "natural" component -index.js ----------- the "container"
我们将容器(container)定义为连接优雅组件和外部世界的桥梁,正因为此,我们有时候又称之为 “注入(injectors)”。
优雅组件(natural component)代表你的代码只包含必需的部分(没有诸如怎样获取数据或者位置等细节——所有代码都是必需的)。
外部世界(outside world)可以将数据转换成符合优雅组件所需的 props。
这篇文章将讨论:怎样让组件不受外部世界的污染,以及这样做的好处。
注意:虽然灵感来自 Dan’s Abramov 和 React Redux’s 的理念,但我们的容器和它们的略微不同。
Dan Abramov 的容器和我们区别是在概念层面上。Dan 认为有两种组件:展示组件和容器组件。我们在这个基础上更进一步,认为先有组件,后有容器。
虽然我们用组件实现容器,但我们不认为容器是传统意义的组件。这就是为什么我们建议你把容器放在
index
文件里——因为它是优雅组件和外部世界的桥梁,并不独立存在。
所以这篇文章会有大量的组件、容器字眼。
为什么?
创建一个优雅组件——很容易、甚至还很有趣。
连接组件和外部世界——有点难。
依我之见,外部世界对优雅组件的污染,主要是这三种方式:
-
古怪的数据结构
-
组件 scope 之外的需求 (就像上面的代码那样)
-
在 update 或者 mount 时触发 event
接下来的几节将会说明这些情况,并用例子展示不同情况下的容器实现。
处理古怪的数据结构
有时候为了呈现需要的信息,需要把数据连在一起然后将其转换成特定的格式。由于没有更好的设计模式,“古怪的” 数据结构是最容易想到的的也是最不优雅的方式。
把古怪的数据结构直接传入组件然后在组件内部转换很诱人,但是这会让组件更复杂、更难测试。
我最近就掉进了这个坑里,我创建了一个组件,从一个特殊的数据结构获取数据,然后让它支持特殊的表单。
ChipField.propTypes = { field: PropTypes.object.isRequired, // <-- the "weird" data structure onEditField: PropTypes.func.isRequired, // <-- and a weird event too};
然后就诞生了这玩意,古怪的 field
数据结构做为 prop。另外,如果以后不需要再处理它了还好,但是当我们想要在另一个完全不相干的数据结构里复用它时,噩梦来了。
由于这个组件需要一个复杂的数据结构,复用几乎不可能,重构起来也很头大。之前写的测试也会很难看懂,因为它们 mock 了一个古怪的数据结构。在持续重构时测试逻辑很难懂也很难重写。
很不幸,古怪的数据结构很难避免,但是使用容器可以很好地驯服它。好处之一是你可以很好地复用组件了,但是如果之前直接把古怪的数据结构传入组件,就难以复用了。
注意: 我并不是说在创造组件的开始所有的组件就都应该是通用的,我的建议是好好考虑组件的基本功能,然后再开始编码,这样你可以通过少量工作写出一些高度可复用的组件。
使用函数组件实现容器
如果你 mapping 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 propsChipFieldContainer.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
你可能会说这也太麻烦了,看起来是多了一些文件,绕了很多弯,但是别忘了:
在组件外面转换数据和在组件内工作量是一致的,区别是,在组件外面转换数据时,你给了你自己一个更明确的点来测试转换是否正确,分离了关注点。
在组件 scope 的外部满足需要
和上面的 Person Card 一样,当你用 “黄金法则” 来思考的时候,很可能你会意识到需求是超出了组件的实际范围,该怎么实现呢?
没错,就是容器。
可以创建容器,通过少量的工作来保持组件的优雅。这样做的时候,你会解锁一个更专业的组件,这个组件也简单得多,同时容器也更易于测试。
让我们来写一个 PersonCard 容器来举栗说明。
使用高阶组件实现容器
React Redux 就是使用了高阶组件 ,实现了从 Redux store 里 push 和 map props 的容器。由于我们是从 React Redux 借鉴的这个理念,毫无疑问 React Redux 的 connect 就是这个容器。
无论你是使用函数组件来映射 props,还是使用高阶组件来连接 Redux strore,组件的黄金法则还是不变的。首先,编写优雅组件,然后用高阶组件连接二者。
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
注意:在这里,给
getPictureUrl
提供一个助手。这个逻辑很简单。你可能已经注意到了,无论 container 实现如何,文件结构几乎没变。
如果你之前用过 Redux,上面的例子你一定不陌生。再次重申,这个黄金法则不只是一个点子,它还提供了一个新思路。
另外,当使用高阶函数实现容器时,还可以把它们连在一起–把一个高阶组件做为 props 传递给下一个。我就曾经把多个高阶组件连在一起构成了一个单个的容器。
2019 注意:React 社区似乎正在让高阶组件规范成设计模式。
我也如此建议。我的经验是,对于那些不理解 functional composition 的人来说写代码很容易引发 “wrapper 地狱”,组件嵌套太多层从而引发了严重的性能问题。
这里是一些相关文章:Hooks talk (2018), Recompose talk (2016), Use a Render Prop! (2017),When to NOT use Render Props (2018)
说好的钩子来了
使用钩子实现容器
为什么在这里会讨论钩子呢?因为使用钩子实现容器真的很简单呀。
如果你对 React 钩子陌生,建议你看一看 Dan Abramov 和 Ryan Florence 在 2018 React Conf 上的谈话。
要点是,钩子是 React 团队为了解决高阶组件和类似模式的问题引入的。React 想要在类似的场景用钩子替代它们。
这意味着容器既可以用函数组件实现也可以用钩子来实现。
在下面的例子里,我们使用 useRoute
和 useRedux
钩子来代表"外部世界",使用工具类 getvalue
把外部世界映射为优雅组件的 props
。我们还使用了 transformValues
来将组件转换为外部世界的 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 = { /* ... */ };
下面是对应的目录结构:
/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 改变、组件 mounting 的时候触发事件。
以仪表盘为例,设计团队给了你原型图,需要你把它们转换成 React 组件,现在面临的问题是如何用数据填充仪表盘。
可能你已经意识到了可以在组件 mount 时调用函数(比如:dispatch(fetchAction)
) 来触发事件。
在类似的这种场景中,普遍做法是添加 componentDidMount
和 compoentDidUpdate
生命周期方法,以及 onMount
和 onDashboardIdChanged
props,因为我需要触发外部事件,才能建立组件和外部世界之间的连接。
根据黄金法则,这些 onMount
和 onDashboardIdChanged
props 很不优雅的,应该把它们放在容器里。
钩子厉害之处是它能让 onMount
或者 props 改变时的 dispatch 事件变得更容易实现!
在 mount 里触发事件
传入空数组调用 useEffect
来触发 mount 时的 event。
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 = { /* ... */ };
在 prop 改变时触发事件
useEffect
可以在重新渲染和函数调用时,监视 property 的改变。
在用 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 // ... }}
有了 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,};
声明:在
useEffect
调用前会对比容器里 prop 的异同,还可以使用其它方式,比如高阶组件(比如 recompose 的生命周期 ),或者像 react router 那样在内部创建一个生命周期组件,但是这些方法要么就是很麻烦要么就是很难理解。
好处是什么
组件保持有趣
对于我来说,创建组件是前端开发中很有趣的部分。能把团队的想法实现感觉很棒,这种感觉值得我们分享。
再也不要让外部世界把组件 API 搞砸了,组件应该和想象中一样没有额外的 props——这也是我从黄金法则里所学的。
更多的机会测试和复用
当你采用一个像这样的模式时,引入了一个新的 data-y 层,在这个层里你可以按需把数据转换成组件需要的形式。
不管你在不在乎,这个层已经在你的应用里存在了,但是这也可能会加重代码的逻辑。我的经验是当我关注到这一层时,我可以做大量的代码优化,可以复用大量的逻辑,现在当我知道组件之间有共性时我是不会重造轮子的。
我觉得这点在定制钩子上尤为明显。定制钩子给我们一个更简单的方式来抽出逻辑、监测外部的变化——更多时候靠 helper 函数是无法做到的。
最大化团队的输出
在团队协作里,你可以把组件和容器分开。如果事前沟通好 API,你可以同时开启如下工作:
-
Web API(如后端)
-
从 Web API 里获取数据(或者其它途径),然后转换数据以符合组件的 API
-
组件
有没有例外?
就像真正的黄金法则一样,这条黄金法则也有例外。在某些场景下,在组件里编写冗余的 API 以减少复杂性很有必要。
一个简单的例子就是 props 的命名,如果不在优雅的组件下面重新命名 data key 会让事情变得更复杂。
迷信金科玉律可能会更规范,但是同时也封杀了创造力。