最近学到了一个新的理念,它改变了我创建组件的方式。它不仅是一个新的点子,还是一个新的视角。

组件的黄金法则

用更自然的方式创建和定义组件,组件只包含它们必需的代码

这是很短的一句话,可能你觉得已经理解了,但却很容易违反这一原则。

比如,有如下组件:

1_nF_5kuYHigZuwdq99vRJ8g
PersonCard

如果自然的定义这个组件你可能会这样写:

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 AbramovReact Redux’s 的理念,但我们的容器和它们的略微不同。
Dan Abramov 的容器 和我们区别是在概念层面上。Dan 认为有两种组件:展示组件和容器组件。我们在这个基础上更进一步,认为先有组件,后有容器。
虽然我们用组件实现容器,但我们不认为容器是传统意义的组件。这就是为什么我们建议你把容器放在 index 文件里—因为它是优雅组件和外部世界的桥梁。并不独立存在。

所以这篇文章会有大量的组件、容器字眼。

为什么?

创建一个优雅组件–很容易、甚至还很有趣。

连接组件和外部世界–有点难。

依我之见,外部世界对优雅组件的污染,主要是这三种方式:

  1. 古怪的数据结构
  2. 组件 scope 之外的需求 (就像上面的代码那样)
  3. 在 update 或者 mount 时触发 event

接下来的几节将会说明这些情况,并用例子展示不同情况下的容器实现。

处理古怪的数据结构

有时候为了呈现需要的信息,需要把数据连在一起然后将其转换成特定的格式。由于没有更好的设计模式,“古怪的” 数据结构是最容易想到的的也是最不优雅的方式。

把古怪的数据结构直接传入组件然后在组件内部转换很诱人,但是这会让组件更复杂、更难测试。

我最近就掉进了这个坑里,我创建了一个组件,从一个特殊的数据结构获取数据,然后让它支持特殊的表单。

1_hFOPWOxkedUEb851jdAXjA
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 想要在类似的场景用钩子替代它们。

这意味着容器既可以用函数组件实现也可以用钩子来实现。

在下面的例子里,我们使用 useRouteuseRedux 钩子来代表"外部世界",使用工具类 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)) 来触发事件。

在类似的这种场景中,普遍做法是添加 componentDidMountcompoentDidUpdate 生命周期方法,以及 onMountonDashboardIdChanged props,因为我需要触发外部事件,才能建立组件和外部世界之间的连接。

根据黄金法则,这些 onMountonDashboardIdChanged 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,你可以同时开启如下工作:

  1. Web API (如 后端)
  2. 从 Web API 里获取数据(或者其它途径)然后转换数据以符合组件的 API
  3. 组件

有没有例外?

就像真正的黄金法则一样,这条黄金法则也有例外。有某些的场景下,在组件里编写冗余的 API 以减少复杂性很有必要。

一个简单的例子就是 props 的命名。如果不在优雅的组件下面重新命名 data key 会让事情变得更复杂。

迷信金科玉律可能会更规范,但是同时也封杀了创造力。

分隔线

不管怎样,黄金法则只是简单的以一个新的角度重申了表现组件和容器组件。总之,在编码前增加基本的组件需求评估,会更容易写出优雅的代码。

感谢阅读。

原文:How the “Golden Rule” of React components can help you write better code,作者:Rico Kahler