現在的 web 前端已被 React、Vue 和 Angular 三分天下,一統江山十幾年的 jQuery 顯然已經很難知足如今的開發模式。那麼,爲何你們會以爲 jQuery 「過期了」呢?一來,文章《No JQuery! 原生 JavaScript 操做 DOM》就直截了當的告訴你,如今用原生 JavaScript 能夠很是方便的操做 DOM 了。其次,jQuery 的便利性是創建在有一個基礎 DOM 結構的前提下的,看上去是符合了樣式、行爲和結構分離,但其實 DOM 結構和 JavaScript 的代碼邏輯是耦合的,你的開發思路會不斷的在 DOM 結構和 JavaScript 之間來回切換。javascript
儘管如今的 jQuery 已再也不那麼流行,但 jQuery 的設計思想仍是很是值得致敬和學習的,特別是 jQuery 的插件化。若是你們開發過 jQuery 插件的話,想必都會知道,一個插件要足夠靈活,須要有細顆粒度的參數化設計。一個靈活好用的 React 組件跟 jQuery 插件同樣,都離不開合理的屬性化(props
)設計,但 React 組件的拆分和組合比起 jQuery 插件來講仍是簡單的使人髮指。css
So! 接下來咱們就以萬能的 TODO LIST 爲例,一塊兒來設計一款 React 的 TodoList
組件吧!html
TODO LIST 的功能想必咱們應該都比較瞭解,也就是 TODO 的添加、刪除、修改等等。自己的功能也比較簡單,爲了不示例的複雜度,顯示不一樣狀態 TODO LIST 的導航(所有、已完成、未完成)的功能咱們就不展開了。前端
先假設咱們已經擁有一個能夠運行 React 項目的腳手架(ha~ 由於我不是來教你如何搭建腳手架的),而後項目的源碼目錄 src/
下多是這樣的:java
. ├── components ├── containers │ └── App │ ├── app.scss │ └── index.js ├── index.html └── index.js
咱們先來簡單解釋下這個目錄設定。咱們看到根目錄下的 index.js
文件是整個項目的入口模塊,入口模塊將會處理 DOM 的渲染和 React 組件的熱更新(react-hot-loader)等設置。而後,index.html
是頁面的 HTML 模版文件,這 2 個部分不是咱們此次關心的重點,咱們再也不展開討論。react
入口模塊 index.js
的代碼大概是這樣子的:jquery
// 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/
目錄下。webpack
基本的目錄結構看起來已經完成,接下來咱們實現下主容器組件 containers/App
。git
咱們先來看下主容器組件 containers/App/index.js
最初的代碼實現:github
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
組件。
在 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 個方面來考慮:
根據這幾點,咱們能夠對 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
組件,既能夠是包含多個不一樣功能邏輯的大組件,也能夠拆分爲獨立、靈巧的小組件,我以爲咱們只須要掌握一個度。固然,如何設計取決於你本身的項目,正所謂:沒有最好的,只有更合適的。仍是但願本篇文章能給你帶來些許的小收穫。