如何優雅的設計 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
組件,既可以是包含多個不同功能邏輯的大組件,也可以拆分為獨立、靈巧的小組件,我覺得我們只需要掌握一個度。當然,如何設計取決於你自己的項目,正所謂:沒有最好的,只有更合適的。還是希望本篇文章能給你帶來些許的小收獲。