最近学到了一个新的理念,它改变了我创建组件的方式。它不仅是一个新的点子,还是一个新的视角。
组件的黄金法则
用更自然的方式创建和定义组件,组件只包含它们必需的代码
这是很短的一句话,可能你觉得已经理解了,但却很容易违反这一原则。
比如,有如下组件:

如果自然的定义这个组件你可能会这样写:
PersonCard.propTypes = {
name: PropTypes.string.isRequired,
jobTitle: PropTypes.string.isRequired,
pictureUrl: PropTypes.string.isRequired,
};
代码很简单,每个属性都是它所必需的,如 name、job title 和 picture 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 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
你可能会说,这也太麻烦了。看起来是多了一些文件,绕了很多弯,但是别忘了:
在组件外面转换数据和在组件内工作量是一致的。区别是,在组件外面转换数据时,你给了你自己一个更明确的点来测试转换是否正确,分离了关注点。
在组件 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 = { /* ... */ };
组件 mount 时通过钩子在容器里触发事件
在 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 // ...
}
}
老方法:当 props 改变的时候触发事件
有了 useEffect
更轻量级的方法来改变 prop ,组件也不必添加多余的 props 了。
mport 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,
};
现在的方法:当 props 改变的时候使用 useEffect
来触发事件
免责声明:在 useEffect
调用前会对比容器里 prop 的异同,还可以使用其它方式,比如高阶组件(比如 recompose 的生命周期 ),或者像 react router 那样在内部 创建一个生命周期组件,但是这些方法要么就是很麻烦要么就是很难理解。
好处是什么
组件保持有趣
对于我来说,创建组件是前端开发中很有趣的部分。能把团队的想法实现感觉很棒,这种感觉值得我们分享。
在也不要让外部世界把组件 API 搞砸了。组件应该和想象中一样没有额外的 props—这也是我从黄金法则里所学到。
更多的机会测试和复用
当你采用一个像这样的模式时,引入了一个新的 data-y 层。在这个层里你可以按需的把数据转换成组件需要的形式。
不管你在不在乎,这个层已经在你的应用里存在了,但是这也可能会加重代码的逻辑。我的经验是当我关注到这一层时,我可以做大量的代码优化,可以复用大量的逻辑,现在当我知道组件之间有共性时我是不会重造轮子的。
我觉得这点在定制钩子上尤为明显。定制钩子给我们一个更简单的方式来抽出逻辑、监测外部的变化—更多时候靠 helper 函数是无法做到的。
最大化团队的输出
在团队协作里,你可以把组件和容器分开。如果事前沟通好 API,你可以同时开启如下工作:
- Web API (如 后端)
- 从 Web API 里获取数据(或者其它途径)然后转换数据以符合组件的 API
- 组件
有没有例外?
就像真正的黄金法则一样,这条黄金法则也有例外。有某些的场景下,在组件里编写冗余的 API 以减少复杂性很有必要。
一个简单的例子就是 props 的命名。如果不在优雅的组件下面重新命名 data key 会让事情变得更复杂。
迷信金科玉律可能会更规范,但是同时也封杀了创造力。
分隔线
不管怎样,黄金法则只是简单的以一个新的角度重申了表现组件和容器组件。总之,在编码前增加基本的组件需求评估,会更容易写出优雅的代码。
感谢阅读。
原文:How the “Golden Rule” of React components can help you write better code,作者:Rico Kahler