如何优雅的设计 React 组件

做者:Nicolas 本文原创,转载请注明做者及出处javascript

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

尽管如今的 jQuery 已再也不那么流行,但 jQuery 的设计思想仍是很是值得致敬和学习的,特别是 jQuery 的插件化。若是你们开发过 jQuery 插件的话,想必都会知道,一个插件要足够灵活,须要有细颗粒度的参数化设计。一个灵活好用的 React 组件跟 jQuery 插件同样,都离不开合理的属性化(props)设计,但 React 组件的拆分和组合比起 jQuery 插件来讲仍是简单的使人发指。html

So! 接下来咱们就以万能的 TODO LIST 为例,一块儿来设计一款 React 的 TodoList 组件吧!前端

实现基本功能

TODO LIST 的功能想必咱们应该都比较了解,也就是 TODO 的添加、删除、修改等等。自己的功能也比较简单,为了不示例的复杂度,显示不一样状态 TODO LIST 的导航(所有、已完成、未完成)的功能咱们就不展开了。java

约定目录结构

先假设咱们已经拥有一个能够运行 React 项目的脚手架(ha~ 由于我不是来教你如何搭建脚手架的),而后项目的源码目录 src/ 下多是这样的:react

.
├── components
├── containers
│   └── App
│       ├── app.scss
│       └── index.js
├── index.html
└── index.js
复制代码

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

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

// 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/ 目录下。git

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

实现主容器

咱们先来看下主容器组件 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官网:www.ikcamp.com

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

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


2019年,iKcamp原创新书《Koa与Node.js开发实战》已在京东、天猫、亚马逊、当当开售啦!

相关文章
相关标签/搜索