如何优雅的设计 React 组件

时间: 2017-11-13阅读: 293标签: react作者: 晓冬

作者:晓冬
本文原创,转载请注明作者及出处

如今的 Web 前端已被 React、Vue 和 Angular 三分天下,一统江山十几年的 jQuery 显然已经很难满足现在的开发模式。那么,为什么大家会觉得 jQuery “过时了”呢?一来,文章《No JQuery! 原生 JavaScript 操作 DOM》就直截了当的告诉你,现在用原生 JavaScript 可以非常方便的操作 DOM 了。其次,jQuery 的便利性是建立在有一个基础 DOM 结构的前提下的,看上去是符合了样式、行为和结构分离,但其实 DOM 结构和 JavaScript 的代码逻辑是耦合的,你的开发思路会不断的在 DOM 结构和 JavaScript 之间来回切换。

尽管现在的 jQuery 已不再那么流行,但 jQuery 的设计思想还是非常值得致敬和学习的,特别是 jQuery 的插件化。如果大家开发过 jQuery 插件的话,想必都会知道,一个插件要足够灵活,需要有细颗粒度的参数化设计。一个灵活好用的 React 组件跟 jQuery 插件一样,都离不开合理的属性化(props)设计,但 React 组件的拆分和组合比起 jQuery 插件来说还是简单的令人发指。

So! 接下来我们就以万能的 TODO LIST 为例,一起来设计一款 React 的 TodoList 组件吧!

实现基本功能

TODO LIST 的功能想必我们应该都比较了解,也就是 TODO 的添加、删除、修改等等。本身的功能也比较简单,为了避免示例的复杂度,显示不同状态 TODO LIST 的导航(全部、已完成、未完成)的功能我们就不展开了。

约定目录结构

先假设我们已经拥有一个可以运行 React 项目的脚手架(ha~ 因为我不是来教你如何搭建脚手架的),然后项目的源码目录 src/ 下可能是这样的:

.
├── components
├── containers
│   └── App
│       ├── app.scss
│       └── index.js
├── index.html
└── index.js

我们先来简单解释下这个目录设定。我们看到根目录下的 index.js 文件是整个项目的入口模块,入口模块将会处理 DOM 的渲染和 React 组件的热更新(react-hot-loader)等设置。然后,index.html 是页面的 HTML 模版文件,这 2 个部分不是我们这次关心的重点,我们不再展开讨论。

入口模块 index.js 的代码大概是这样子的:

// import reset css, base css...

import React from 'react';
import ReactDom from 'react-dom';
import { AppContainer } from 'react-hot-loader';
import App from 'containers/App';

const render = (Component) => {
  ReactDom.render(
    <AppContainer>
      <Component />
    </AppContainer>,
    document.getElementById('app')
  );
};

render(App);

if (module.hot) {
  module.hot.accept('containers/App', () => {
    let nextApp = require('containers/App').default;
    
    render(nextApp);
  });
}

接下来看 containers/ 目录,它将放置我们的页面容器组件,业务逻辑、数据处理等会在这一层做处理,containers/App 将作为我们的页面主容器组件。作为通用组件,我们将它们放置于 components/ 目录下。

基本的目录结构看起来已经完成,接下来我们实现下主容器组件 containers/App。

实现主容器

我们先来看下主容器组件 containers/App/index.js 最初的代码实现:

import React, { Component } from 'react';
import styles from './app.scss';

class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      todos: []
    };
  }

  render() {
    return (
      <div className={styles.container}>
        <h2 className={styles.header}>Todo List Demo</h2>
        <div className={styles.content}>
          <header className={styles['todo-list-header']}>
            <input 
              type="text"
              className={styles.input}
              ref={(input) => this.input = input} 
            />
            <button 
              className={styles.button} 
              onClick={() => this.handleAdd()}
            >
              Add Todo
            </button>
          </header>
          <section className={styles['todo-list-content']}>
            <ul className={styles['todo-list-items']}>
              {this.state.todos.map((todo, i) => (
                <li key={`${todo.text}-${i}`}>
                  <em 
                    className={todo.completed ? styles.completed : ''} 
                    onClick={() => this.handleStateChange(i)}
                  >
                    {todo.text}
                  </em>
                  <button 
                    className={styles.button} 
                    onClick={() => this.handleRemove(i)}
                  >
                    Remove
                  </button>
                </li>
              ))}
            </ul>
          </section>
        </div>
      </div>
    );
  }

  handleAdd() {
    ...
  }

  handleRemove(index) {
    ...
  }

  handleStateChange(index) {
    ...
  }
}

export default App;

我们可以像上面这样把所有的业务逻辑一股脑的塞进主容器中,但我们要考虑到主容器随时会组装其他的组件进来,将各种逻辑堆放在一起,到时候这个组件就会变得无比庞大,直到“无法收拾”。所以,我们得分离出一个独立的 TodoList 组件。

分离组件

TodoList 组件

在 components/ 目录下,我们新建一个 TodoList 文件夹以及相关文件:

.
├── components
+│   └── TodoList
+│       ├── index.js
+│       └── todo-list.scss
├── containers
│   └── App
│       ├── app.scss
│       └── index.js
...

然后我们将 containers/App/index.js 下跟 TodoList 组件相关的功能抽离到 components/TodoList/index.js 中:

...
import styles from './todo-list.scss';

export default class TodoList extends Component {
  ...
  
  render() {
    return (
      <div className={styles.container}>
-       <header className={styles['todo-list-header']}>
+       <header className={styles.header}>
          <input 
            type="text"
            className={styles.input}
            ref={(input) => this.input = input} 
          />
          <button 
            className={styles.button} 
            onClick={() => this.handleAdd()}
          >
            Add Todo
          </button>
        </header>
-       <section className={styles['todo-list-content']}>
+       <section className={styles.content}>
-         <ul className={styles['todo-list-items']}>
+         <ul className={styles.items}>
            {this.state.todos.map((todo, i) => (
              <li key={`${todo}-${i}`}>
                <em 
                  className={todo.completed ? styles.completed : ''} 
                  onClick={() => this.handleStateChange(i)}
                >
                  {todo.text}
                </em>
                <button 
                  className={styles.button} 
                  onClick={() => this.handleRemove(i)}
                >
                  Remove
                </button>
              </li>
            ))}
          </ul>
        </section>
      </div>
    );
  }

  ...
}

有没有注意到上面 render 方法中的 className,我们省去了 todo-list* 前缀,由于我们用的是 CSS MODULES,所以当我们分离组件后,原先在主容器中定义的 todo-list* 前缀的 className ,可以很容易通过 webpack 的配置来实现:

...
module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.s?css/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: true,
              localIdentName: '[name]--[local]-[hash:base64:5]'
            }
          },
          ...
        ]
      }
    ]  
  }
  ...
};

我们再来看下该组件的代码输出后的结果:

<div data-reactroot="" class="app--container-YwMsF">
  ...
    <div class="todo-list--container-2PARV">
      <header class="todo-list--header-3KDD3">
        ...
      </header>
      <section class="todo-list--content-3xwvR">
        <ul class="todo-list--items-1SBi6">
          ...
        </ul>
      </section>
    </div>
</div>

从上面 webpack 的配置和输出的 HTML 中可以看到,className 的命名空间问题可以通过语义化 *.scss 文件名的方式来实现,比如 TodoList 的样式文件 todo-list.scss。这样一来,省去了我们定义组件 className 的命名空间带来的烦恼,从而只需要从组件内部的结构下手。

回到正题,我们再来看下分离 TodoList 组件后的 containers/App/index.js:

import TodoList from 'components/TodoList';
...

class App extends Component {
  render() {
    return (
      <div className={styles.container}>
        <h2 className={styles.header}>Todo List Demo</h2>
        <div className={styles.content}>
          <TodoList />
        </div>
      </div>
    );
  }
}

export default App;

抽离通用组件

作为一个项目,当前的 TodoList 组件包含了太多的子元素,如:input、button 等。为了让组件“一次编写,随处使用”的原则,我们可以进一步拆分 TodoList 组件以满足其他组件的使用。

但是,如何拆分组件才是最合理的呢?我觉得这个问题没有最好的答案,但我们可以从几个方面进行思考:可封装性、可重用性和灵活性。比如拿 h1 元素来讲,你可以封装成一个 Title 组件,然后这样 <Title text={title} /> 使用,又或者可以这样 <Title>{title}</Title> 来使用。但你有没有发现,这样实现的 Title 组件并没有起到简化和封装的作用,反而增加了使用的复杂度,对于 HTML 来讲,h1 本身也是一个组件,所以我们拆分组件也是需要掌握一个度的。

好,我们先拿 input 和 button 下手,在 components/ 目录下新建 2 个 Button 和 Input 组件:

.
├── components
+│   ├── Button
+│   │   ├── button.scss
+│   │   └── index.js
+│   ├── Input
+│   │   ├── index.js
+│   │   └── input.scss
│   └── TodoList
│       ├── index.js
│       └── todo-list.scss
...

Button/index.js 的代码:

...
export default class Button extends Component {
  render() {
    const { className, children, onClick } = this.props;

    return (
      <button 
        type="button" 
        className={cn(styles.normal, className)} 
        onClick={onClick}
      >
        {children}
      </button>
    );
  }
}

Input/index.js 的代码:

...
export default class Input extends Component {
  render() {
    const { className, value, inputRef } = this.props;

    return (
      <input 
        type="text"
        className={cn(styles.normal, className)}
        defaultValue={value}
        ref={inputRef} 
      />
    );
  }
}

由于这 2 个组件自身不涉及任何业务逻辑,应该属于纯渲染组件(木偶组件),我们可以使用 React 轻量的无状态组件的方式来声明:

...
const Button = ({ className, children, onClick }) => (
  <button 
    type="button" 
    className={cn(styles.normal, className)} 
    onClick={onClick}
  >
    {children}
  </button>
);

是不是觉得酷炫很多!

另外,从 Input 组件的示例代码中看到,我们使用了非受控组件,这里是为了降低示例代码的复杂度而特意为之,大家可以根据自己的实际情况来决定是否需要设计成受控组件。一般情况下,如果不需要获取实时输入值的话,我觉得使用非受控组件应该够用了。

我们再回到上面的 TodoList 组件,将之前分离的子组件 Button,Input 组装进来。

...
import Button from 'components/Button';
import Input from 'components/Input';
...

export default class TodoList extends Component {
  render() {
    return (
      <div className={styles.container}>
        <header className={styles.header}>
          <Input 
            className={styles.input} 
            inputRef={(input) => this.input = input} 
          />
          <Button onClick={() => this.handleAdd()}>
            Add Todo
          </Button>
        </header>
        ...
      </div>
    );
  }
}

...

拆分子组件

然后继续接着看 TodoList 的 items 部分,我们注意到这部分包含了较多的渲染逻辑在 render 中,导致我们需要浪费对这段代码与上下文之间会有过多的思考,所以,我们何不把它抽离出去:

...

export default class TodoList extends Component {
  render() {
    return (
      <div className={styles.container}>
        ...
        <section className={styles.content}>
          {this.renderItems()}
        </section>
      </div>
    );
  }

  renderItems() {
    return (
      <ul className={styles.items}>
        {this.state.todos.map((todo, i) => (
          <li key={`${todo}-${i}`}>
            ...
          </li>
        ))}
      </ul>
    );
  }
  
  ...
}

上面的代码看似降低了 render 的复杂度,但仍然没有让 TodoList 减少负担。既然我们要把这部分逻辑分离出去,我们何不创建一个 Todos 组件,把这部分逻辑拆分出去呢?so,我们以“就近声明”的原则在 components/TodoList/ 目录下创建一个子目录components/TodoList/components/ 来存放 TodoList 的子组件 。why?因为我觉得 组件 Todos 跟 TodoList 有紧密的父子关系,且跟其他组件间也不太会有任何交互,也可以认为它是 TodoList 私有的。

然后我们预览下现在的目录结构:

.
├── components
│   ...
│   └── TodoList
+│       ├── components
+│       │   └── Todos
+│       │       ├── index.js
+│       │       └── todos.scss
│       ├── index.js
│       └── todo-list.scss

Todos/index.js 的代码:

...
const Todos = ({ data: todos, onStateChange, onRemove }) => (
  <ul className={styles.items}>
    {todos.map((todo, i) => (
      <li key={`${todo}-${i}`}>
        <em 
          className={todo.completed ? styles.completed : ''} 
          onClick={() => onStateChange(i)}
        >
          {todo.text}
        </em>
        <Button onClick={() => onRemove(i)}>
          Remove
        </Button>
      </li>
    ))}
  </ul>
);
...

再看拆分后的 TodoList/index.js :

render() {
  return (
    <div className={styles.container}>
      ...
      <section className={styles.content}>
        <Todos 
          data={this.state.todos}
          onStateChange={(index) => this.handleStateChange(index)}
          onRemove={(index) => this.handleRemove(index)}
        />
      </section>
    </div>
  );
}

增强子组件

到目前为止,大体上的功能已经搞定,子组件看上去拆分的也算合理,这样就可以很容易的增强某个子组件的功能了。就拿 Todos 来说,在新增了一个 TODO 后,假如我们并没有完成这个 TODO,而我们又希望可以修改它的内容了。ha~不要着急,要不我们再拆分下这个 Todos,比如增加一个 Todo 组件:

.
├── components
│   ...
│   └── TodoList
│       ├── components
+│       │   ├── Todo
+│       │   │   ├── index.js
+│       │   │   └── todo.scss
│       │   └── Todos
│       │       ├── index.js
│       │       └── todos.scss
│       ├── index.js
│       └── todo-list.scss

先看下 Todos 组件在抽离了 Todo 后的样子:

...
import Todo from '../Todo';
...

const Todos = ({ data: todos, onStateChange, onRemove }) => (
  <ul className={styles.items}>
    {todos.map((todo, i) => (
      <li key={`${todo}-${i}`}>
        <Todo
          {...todo}
          onClick={() => onStateChange(i)}
        />
        <Button onClick={() => onRemove(i)}>
          Remove
        </Button>
      </li>
    ))}
  </ul>
);

export default Todos;

我们先不关心 Todo 内是何如实现的,就如我们上面说到的那样,我们需要对这个 Todo 增加一个可编辑的功能,从单纯的属性配置入手,我们只需要给它增加一个 editable 的属性:

<Todo
  {...todo}
+ editable={editable}
  onClick={() => onStateChange(i)}
/>

然后,我们再思考下,在 Todo 组件的内部,我们需要重新组织一些功能逻辑:

  • 根据传入的 editable 属性来判断是否需要显示编辑按钮

  • 根据组件内部的编辑状态,是显示文本输入框还是文本内容

  • 点击“更新”按钮后,需要通知父组件更新数据列表

我们先来实现下 Todo 的第一个功能点:

render() {
  const { completed, text, editable, onClick } = this.props;

  return (
    <span className={styles.wrapper}>
      <em
        className={completed ? styles.completed : ''} 
        onClick={onClick}
        >
        {text}
      </em>
      {editable && 
        <Button>
          Edit
        </Button>
      }
    </span>
  );
}

显然实现这一步似乎没什么 luan 用,我们还需要点击 Edit 按钮后能显示 Input 组件,使内容可修改。所以,简单的传递属性似乎无法满足该组件的功能,我们还需要一个内部状态来管理组件是否处于编辑中:

render() {
  const { completed, text, editable, onStateChange } = this.props,
    { editing } = this.state;

  return (
    <span className={styles.wrapper}>
      {editing ? 
        <Input 
          value={text}
          className={styles.input}
          inputRef={input => this.input = input}
        /> :
        <em
          className={completed ? styles.completed : ''} 
          onClick={onStateChange}
        >
          {text}
        </em>
      }
      {editable && 
        <Button onClick={() => this.handleEdit()}>
          {editing ? 'Update' : 'Edit'}
        </Button>
      }
    </span>
  );
}

最后,Todo 组件在点击 Update 按钮后需要通知父组件更新数据:

handleEdit() {
  const { text, onUpdate } = this.props;
  let { editing } = this.state;

  editing = !editing;

  this.setState({ editing });

  if (!editing && this.input.value !== text) {
    onUpdate(this.input.value);
  }
}

需要注意的是,我们传递的是更新后的内容,在数据没有任何变化的情况下通知父组件是毫无意义的。

我们再回过头来修改下 Todos 组件对 Todo 的调用。先增加一个由 TodoList 组件传递下来的回调属性 onUpdate,同时修改onClick 为 onStateChange,因为这时的 Todo 已不仅仅只有单个点击事件了,需要定义不同状态变更时的事件回调:

<Todo
  {...todo}
  editable={editable}
- onClick={() => onStateChange(i)}
+ onStateChange={() => onStateChange(i)}
+ onUpdate={(value) => onUpdate(i, value)}
/>

而最终我们又在 TodoList 组件中,增加 Todo 在数据更新后的业务逻辑。

TodoList 组件的 render 方法内的部分示例代码:

<Todos 
  editable
  data={this.state.todos}
+ onUpdate={(index, value) => this.handleUpdate(index, value)}
  onStateChange={(index) => this.handleStateChange(index)}
  onRemove={(index) => this.handleRemove(index)}
/>

TodoList 组件的 handleUpdate 方法的示例代码:

handleUpdate(index, value) {
  let todos = [...this.state.todos];
  const target = todos[index];

  todos = [
    ...todos.slice(0, index),
    {
      text: value,
      completed: target.completed
    },
    ...todos.slice(index + 1)
  ];

  this.setState({ todos });
}

组件数据管理

既然 TodoList 是一个组件,初始状态 this.state.todos 就有可能从外部传入。对于组件内部,我们不应该过多的关心这些数据从何而来(可能通过父容器直接 Ajax 调用后返回的数据,或者 Redux、MobX 等状态管理器获取的数据),我觉得组件的数据属性的设计可以从以下 3 个方面来考虑:

  • 在没有初始数据传入时应该提供一个默认值

  • 一旦数据在组件内部被更新后应该及时的通知父组件

  • 当有新的数据(从后端 API 请求的)传入组件后,应该重新更新组件内部状态

根据这几点,我们可以对 TodoList 再做一番改造。

首先,对 TodoList 增加一个 todos 的默认数据属性,使父组件在没有传入有效属性值时也不会影响该组件的使用:

export default class TodoList extends Component {
  constructor(props) {
    super(props);

    this.state = {
      todos: props.todos
    };
  }
  ...
}

TodoList.defaultProps = {
  todos: []
};

然后,再新增一个内部方法 this.update 和一个组件的更新事件回调属性 onUpdate,当数据状态更新时可以及时的通知父组件:

export default class TodoList extends Component {
  ...
  handleAdd() {
    ...
    this.update(todos);
  }

  handleUpdate(index, value) {
    ...
    this.update(todos);
  }

  handleRemove(index) {
    ...
    this.update(todos);
  }

  handleStateChange(index) {
    ...
    this.update(todos);
  }

  update(todos) {
    const { onUpdate } = this.props;

    this.setState({ todos });
    onUpdate && onUpdate(todos);
  }
}

这就完事儿了?No! No! No! 因为 this.state.todos 的初始状态是由外部 this.props 传入的,假如父组件重新更新了数据,会导致子组件的数据和父组件不同步。那么,如何解决?

我们回顾下 React 的生命周期,父组件传递到子组件的 props 的更新数据可以在 componentWillReceiveProps 中获取。所以我们有必要在这里重新更新下 TodoList 的数据,哦!千万别忘了判断传入的 todos 和当前的数据是否一致,因为,当任何传入的 props 更新时都会导致 componentWillReceiveProps 的触发。

componentWillReceiveProps(nextProps) {
  const nextTodos = nextProps.todos;

  if (Array.isArray(nextTodos) && !_.isEqual(this.state.todos, nextTodos)) {
    this.setState({ todos: nextTodos });
  }
}

注意代码中的 _.isEqual,该方法是 Lodash 中非常实用的一个函数,我经常拿来在这种场景下使用。

结尾

由于本人对 React 的了解有限,以上示例中的方案可能不一定最合适,但你也看到了 TodoList 组件,既可以是包含多个不同功能逻辑的大组件,也可以拆分为独立、灵巧的小组件,我觉得我们只需要掌握一个度。当然,如何设计取决于你自己的项目,正所谓:没有最好的,只有更合适的。还是希望本篇文章能给你带来些许的小收获。


React事件处理函数必须使用bind(this)的原因

学习React的过程中发现调用函数的时候必须使用bind(this),之后直接在class中声明函数即可正常使用,但是为什么呢,博主进行了一番查阅,总结如下。

React 新 Context API

React 新 Context API它更符合工程化, 不再是实验性的,现在它是一流的API! 并且它还使用了 RENDER PROP!你在react官网上听说过 context API?那么你为何要使用context?Context的重生

深入 React 高阶组件

本文面向想要探索 HOC 模式的进阶用户,如果你是 React 的初学者则应该从官方文档开始。高阶组件(Higher Order Components)是一种很棒的模式,已被很多 React 库证实是非常有价值的。

ReactJS Components: 基础指南

创建和管理React组件的各种方式,涌现的大量状态管理工具等等都是这些挑战的焦点。我们今天能做的就是在React(基于社区选择)中将最常用的做法引入桌面并讨论它们。

React事件绑定几种方法测试

es6写法的类方法默认没有绑定this,不手动绑定this值为undefined。因此讨论以下几种绑定方式。构造函数constructor中用bind绑定、在调用的时候使用bind绑定this、在调用的时候使用箭头函数绑定this、使用属性初始化器语法绑定this

react-router v4 按需加载的配置方法

在react项目开发中,当访问默认页面时会一次性请求所有的js资源,这会大大影响页面的加载速度和用户体验。所以添加按需加载功能是必要的,以下是配置按需加载的方法

react中的refs属性的使用方法

React 支持一种非常特殊的属性 Ref ,你可以用来绑定到 render() 输出的任何组件上。这个特殊的属性允许你引用 render() 返回的相应的支撑实例( backing instance )。这样就可以确保在任何时间总是拿到正确的实例

react 高阶组件的 理解和应用

react 高阶组件简单的理解是:一个包装了另一个基础组件的组件。高阶组件的两种形式:属性代理(Props Proxy)、反向继承 (Inheritance Inversion)

react router中页面传值的三种方法

这篇文章主要介绍React Router定义路由之后如何传值,有关React和React Router 。react router中页面传值的三种方法:props.params、query、state

React + Webpack 构建打包优化

React 相关的优化:使用 babel-react-optimize 对 React 代码进行优化,检查没有使用的库,去除 import 引用,按需打包所用的类库,比如 lodash 、echarts 等.Webpack 构建打包存在的问题两个方面:构建速度慢,打包后的文件体积过大