React 组件的"黄金法则"

210 阅读13分钟

原文地址: 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 AbramovReact Redux 的启发,我们定义的 “container” 还是有点不同的。

Dan Abramov’s container 的不同之处只是概念层面上。Dan 说只有两类组件:Presentational 组件和 Container 组件。我们将会更进一步,我们会有组件和 Container。

尽管,我用组件实现了 container,我并不认为 container 是概念上的组件。这就是为什么我推荐你把 container 放在 index 文件中 — 它是连接组件和外部世界的桥梁,并不能独立存在。

尽管本文的关注点是组件,但是,container 占据了大量篇幅。

为什么?

创建自然组件 — 简单,甚至有趣。

让你的组件与外部联系起来就有一点难了。

以我看,有 3 种主要的原因会导致外部资源污染自然组件。

  1. 糟糕的数据结构
  2. 来自组件外部需求(比如上面的演示)
  3. 在组件更新或者挂载的时候触发事件

接下来的篇幅中将会用不同的示例来演示如何解决上面的问题。

处理糟糕的数据结构

有时,为了渲染一些必要的信息,你需要合并一些数据,然后做一些转换让数据变得更加有意义。由于缺少更好的表达方式,你的组件就用了这种糟糕的数据结构。

直接把数据传递给组件并在组件内部做数据转化,这种方式非常方便,但是,这会导致混乱,而且,非常难以测试组件。

最近,我创建的一个组件,它从用于支持特定类型表单的特定数据结构中获取数据时,我就掉进了这种陷阱。

img

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 会更好

接下来的示例中,我们 useRouteruseRedux 代表着“外部环境”,然后,用 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))。

在这种情况下,我发现需要添加 componentDidMountcomponentDidUpdate 两个生命周期方法,并且,添加 onMount 或者 onDashboardIdChanged 相关 props,这是因为,我需要触发一些事件才能让组件与外部环境连接起来。

按照黄金法则,这些 onMountonDashboardIdChanged 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 达成了一致,你们可以同时工作:

  1. Web API
  2. 通过 API 获取数据和转化成适用组件的 API
  3. 组件

有什么例外的?

就像真正的黄金法则,这个法则也只是一种建议。有些时候为了减少转化数据的复杂度,编写一些非自然的组件也是有意义的。

一个简单的例子就是 prop 的名称。如果,开发者根据参数需要改变相应的数据 key,但是,还要保证组件的“自然”,这会变得更加复杂。

很有可能,这种想法过于抽象,导致掉入另外的陷阱。

最后

或多或少,这个“黄金法则”只是用全新的思路重新的对比了 Presentational 组件和 Container 组件。如果,你以最基本的方式去评估组件应该是什么样子,那么,你应该就会得到更简单、更易阅读的组件。

谢谢你们!