利用 React 组件的“黄金法则"让代码更优雅之如何发挥钩子的作用

利用 React 组件的“黄金法则"让代码更优雅之如何发挥钩子的作用
0


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

组件的黄金法则

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

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

比如,有如下组件:

1_nF_5kuYHigZuwdq99vRJ8g

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 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 会让事情变得更复杂。

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

分隔线

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

Happy coding!

原文:https://medium.freecodecamp.org/how-the-golden-rule-of-react-components-can-help-you-write-better-code-127046b478eb

1赞