原文地址: medium.com/free-code-c…
译文地址:github.com/xiao-T/note…
本文版权归原作者所有,翻译仅用于学习。

最近,我采用了一种新的理念编写 React 组件。它不一定是必要的,但,是一种新的思维方式。
组件的黄金法则
用最自然的方式创建和定义组件,单纯的考虑它需要什么功能。
同样,描述很简短,你或许认为自己已经遵守了相同法则,但是,它很容易让你背道而驰。
例如,假设你有以下一个组件:

如果,你用非常“自然”的方式定义这个组件,这时,应该会写出以下代码:
PersonCard.propTypes = {
name: PropTypes.string.isRequired,
jobTitle: PropTypes.string.isRequired,
pictureUrl: PropTypes.string.isRequired,
};
非常直截了当,看一下它需要什么功能,你只是需要一个 name,job title 和 picture URL。
但是,假如说根据用户设置需要显示一张 “official” 图片。你就需要改写成以下的样子:
PersonCard.propTypes = {
name: PropTypes.string.isRequired,
jobTitle: PropTypes.string.isRequired,
officialPictureUrl: PropTypes.string.isRequired,
pictureUrl: PropTypes.string.isRequired,
preferOfficial: PropTypes.boolean.isRequired,
};
看起来组件需要这些额外的属性来完成工作,但是,事实上,组件没什么不同,并且不需要这些额外的属性。这些额外的属性 preferOfficial
跟组件耦合在一起,使得组件的上下文感觉并不真正的自然。
解耦
如果,切换图片 URL 的逻辑不属于组件本身,那它们应该属于哪呢?
属于 index
文件如何?
我会把组件放在一个以自身命名的文件夹中,其中有一个 index
文件,它负责把“自然”组件和外面的世界连接起来。我们把这个文件称为 “container”(概念来自 React Redux 的 “container” 组件)。
/PersonCard
-PersonCard.js ------ the "natural" component
-index.js ----------- the "container"
我们把 containers 做为连接组件和外部世界的桥梁。因为这个原因,我们有时也会叫做 “injectors”。
如果,组件只是为了展示一张图片,那么图片就是必要的(没有什么任何其它的逻辑,不关心如何获取数据,组件应该被放到什么地方 — 你所需关心就是它的功能)。
外部世界只是是一种统称,它代表着 APP 任何的资源(比如:Redux Store),它们可以被转化以便满足组件的需求。
**这篇文章的目的:**如何保证组件不被外部的垃圾资源污染?为什么这么做会更好?
**注意:**尽管受到 Dan’s Abramov 和 React Redux 的启发,我们定义的 “container” 还是有点不同的。
与 Dan Abramov’s container 的不同之处只是概念层面上。Dan 说只有两类组件:Presentational 组件和 Container 组件。我们将会更进一步,我们会有组件和 Container。
尽管,我用组件实现了 container,我并不认为 container 是概念上的组件。这就是为什么我推荐你把 container 放在
index
文件中 — 它是连接组件和外部世界的桥梁,并不能独立存在。
尽管本文的关注点是组件,但是,container 占据了大量篇幅。
为什么?
创建自然组件 — 简单,甚至有趣。
让你的组件与外部联系起来就有一点难了。
以我看,有 3 种主要的原因会导致外部资源污染自然组件。
- 糟糕的数据结构
- 来自组件外部需求(比如上面的演示)
- 在组件更新或者挂载的时候触发事件
接下来的篇幅中将会用不同的示例来演示如何解决上面的问题。
处理糟糕的数据结构
有时,为了渲染一些必要的信息,你需要合并一些数据,然后做一些转换让数据变得更加有意义。由于缺少更好的表达方式,你的组件就用了这种糟糕的数据结构。
直接把数据传递给组件并在组件内部做数据转化,这种方式非常方便,但是,这会导致混乱,而且,非常难以测试组件。
最近,我创建的一个组件,它从用于支持特定类型表单的特定数据结构中获取数据时,我就掉进了这种陷阱。

ChipField.propTypes = {
field: PropTypes.object.isRequired, // <-- the "weird" data structure
onEditField: PropTypes.func.isRequired, // <-- and a weird event too
};
组件将 field
这种糟糕的数据结构作为 prop。实际上,只是这样也没什么问题,但是,当我们需要把它用在没有相关数据结构的地方时,这就会有问题了。
由于,组件需要这种特定的数据结构,它不可能复用,也难以重构。因为需要 mock 这种数据结构,让我们原本写好的测试用例也变得混乱。最后导致,我们很难理解和重构它。
很不幸,糟糕的数据结构并不可避免,但是,使用 Container 可以很好的处理它们。这样做有一个好处是:你可以有选择的将组件抽象并让组件可以重用。如果,给组件传递一个糟糕的数据结构,就会失去这种好处。
**注意:**我不建议从一开始就把所有的组件都设计成通用型的。建议首先要考虑组件的功能,然后,尽可能的解耦。因此,你可以,有选择的,以最小的工作让组件进阶成可以复用的组件。
用函数组件实现 Container
如果,你需要严格映射 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
如果,你愿意这么做的话 — 你或许会想“太多东西了”,我也理解。因为,有更多的文件和一些间接的操作,看起来会有更多的事情要做,但是,这正是你缺失的地方:
不管,你是在组件外部处理数据转化,还是组件内部,工作量都是一样的。不同的是,当你在组件外部转化数据时,你可以更加明确测试数据是否正确,而且,还可以和其它点分离开。
超出组件范围的需求
就如上面的 Person Card 组件,当你按照“黄金法则”思考时,你就会意识到有些需求并不是组件应该关心的。因此,应该如何实现呢?
你已经猜到了: Containers 🎉
你可能需要一些额外的工作创建一个 Container,为了保证组件的独立。当你这么做时,最终,你会得到一个更加简单、更加关注功能的组件,Container 也更加容易测试。
我们来按照图示实现一个 PersonCard Container
用高阶组件实现 Container
React Redux 就是用高阶组件实现了 Container,然后,与 Redux Store 中的数据关联起来。由于,术语来自 React Redux,很自然的就会想到 React Reudx 中的 connect
就是一个 Container。
不管,你是用函数组件映射 props,还是,用高阶组件连接 Redux Store,黄金法则和 Container 的作用始终是一样的。首先,编写自然组件,然后,用高阶组件关联数据。
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,上面的演示你应该比较熟悉。黄金法则并不一定是新的主意,但是,它可以是一种全新的思考方式。
此外,当你使用高阶组件实现 Container 时,还是可以结合一些功能 — 通过高阶组件向下传递。曾经,我用多个高阶组件实现了一个 Container。
2019 提醒: React 社区已经不推荐高阶组件的设计模式了。
我也赞成少用高阶组件。根据我的经验来看,对于那些不熟悉函数组件的团队成员,他们很容易混乱,并且,由于嵌套太多,会掉进“嵌套黑洞”,从而引起严重的性能问题。
这里有一些相关的文章和资源:Hooks talk (2018) Recompose talk (2016) , Use a Render Prop! (2017), When to NOT use Render Props (2018).
Hooks
用 Hooks 实现 Container
为什么是 Hooks?因为,用 Hooks 实现 Container 更加容易。
如果,你还不熟悉 React Hooks,我推荐看一看:Dan Abramov 和 Ryan Florence 在 React Conf 2018 有关 Hooks 的演讲
针对 高阶组件和其它熟悉的模式中出现的问题,React 团队给出了解决方案:Hooks。对于大部分情况 React Hooks 是绝佳的替代方案。
也就是说用函数组件和 Hooks 实现 Container 会更好
接下来的示例中,我们 useRouter
和 useRedux
代表着“外部环境”,然后,用 getValues
映射外部环境中的数据到 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
Container 内触发事件
当我需要触发改变 props 或者组件 mount 的相关事件时,我发现已经脱离了自然组件,这是最后一种情况。
例如,假设,你有一个制作 dashboard 的任务。设计团队已经提供了相关 UI,然后,你需要制作成一个 React 组件。现在,你必须在 dashboard 中填充数据。
为了获取数据,需要在组件 mount 时,调用一个函数(比如:dispatch(fetchAction)
)。
在这种情况下,我发现需要添加 componentDidMount
和 componentDidUpdate
两个生命周期方法,并且,添加 onMount
或者 onDashboardIdChanged
相关 props,这是因为,我需要触发一些事件才能让组件与外部环境连接起来。
按照黄金法则,这些 onMount
和 onDashboardIdChanged
props 是违反法则的,因此,它们应该放在 Container。
可喜的是,Hooks 让触发事件 onMount
或者改变 prop 变得非常容易!
组件 mounted 时触发事件
调用 useEffect
方法,并设置第二个参数为空数组,这样,在组件 mount 时会触发事件。
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 = { /* ... */ };
Props 改变时触发事件:
useEffect
还可以监听 props 的改变,以便重新触发回调函数。
在 useEffect
出现之前,因为,在组件外部我无法确定 props 是否改变,我不得不违背黄金法则添加了生命周期方法和 onPropertyChanged
prop。
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
可以很简单的在 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
之前,我们有多种方式可以在 Container 中对比前后 prop 是否一致,比如:使用高阶组件(recompose 中的生命周期方法)或者像 react router 一样创建一个 Lifecycle 组件,但是,这些方法要么会让团队成员混乱,要么是非常规的。
有什么好处呢?
保持组件的乐趣
对于我来说,创建组件是前端开发中最有趣和满足的一部分。你可以把团队的主意和想法变成现实,我认为我们应该学会分享,这种感觉很好。
绝对不会出现组件 API 被“外部环境”破坏的情况。你的组件就如你想的那样,没有其它额外的 prop — 这就是黄金法则我最喜欢的地方。
更方便测试和复用
当你采用这种架构时,实质上你已经把数据独立到另外一个层面上了。在这一层,你只需关心哪些数据可以传递到组件,而不是,组件是如何工作的。
不管你是否关心,这一层已经存在你的应用中,但是,它有可能与一些呈现逻辑融合在一起。当我面对它时,我才发现它是什么,我可以做代码优化和重用一些逻辑,否则,在不知道共性的情况,我就会重写。
我认为,随着自定义 Hooks 的加入会变得更加显著。自定义 Hooks 提供了一种更加简单的方式抽象逻辑和订阅外部的改变 —— 这些 helper 函数做不到。
###提高团队效率
当团队一起工作时,你可以把 Container 和组件分开开发。如果,你们事先就对 API 达成了一致,你们可以同时工作:
- Web API
- 通过 API 获取数据和转化成适用组件的 API
- 组件
有什么例外的?
就像真正的黄金法则,这个法则也只是一种建议。有些时候为了减少转化数据的复杂度,编写一些非自然的组件也是有意义的。
一个简单的例子就是 prop 的名称。如果,开发者根据参数需要改变相应的数据 key,但是,还要保证组件的“自然”,这会变得更加复杂。
很有可能,这种想法过于抽象,导致掉入另外的陷阱。
最后
或多或少,这个“黄金法则”只是用全新的思路重新的对比了 Presentational 组件和 Container 组件。如果,你以最基本的方式去评估组件应该是什么样子,那么,你应该就会得到更简单、更易阅读的组件。
谢谢你们!