如何優雅的設計 React 元件

iKcamp發表於2017-11-10

作者:Nicolas 本文原創,轉載請註明作者及出處

如今的 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官網:www.ikcamp.com

訪問官網更快閱讀全部免費分享課程:《iKcamp出品|全網最新|微信小程式|基於最新版1.0開發者工具之初中級培訓教程分享》。 包含:文章、視訊、原始碼

如何優雅的設計 React 元件

iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。


如何優雅的設計 React 元件

2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!

相關文章