如何优雅的设计 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 个 ButtonInput 组件:

.
├── 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 组件,将之前分离的子组件 ButtonInput 组装进来。

...
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?因为我觉得 组件 TodosTodoList 有紧密的父子关系,且跟其他组件间也不太会有任何交互,也可以认为它是 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,同时修改 onClickonStateChange,因为这时的 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 组件,既可以是包含多个不同功能逻辑的大组件,也可以拆分为独立、灵巧的小组件,我觉得我们只需要掌握一个度。当然,如何设计取决于你自己的项目,正所谓:没有最好的,只有更合适的。还是希望本篇文章能给你带来些许的小收获。

iKcamp官网:http://www.ikcamp.com

访问官网更快阅读全部免费分享课程:《iKcamp出品|全网最新|微信小程序|基于最新版1.0开发者工具之初中级培训教程分享》。
包含:文章、视频、源代码

如何优雅的设计 React 组件

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。

相关推荐