前言
為什么要使用 Redux?
組件化的開發思想解放了繁瑣低效的 DOM 操作,以 React 來說,一切皆為狀態,通過狀態可以控制視圖的變化,然后隨着應用項目的規模的不斷擴大和應用功能的不斷豐富,過多的狀態變得難以控制,以至於當不同的組件中觸發了同一個狀態的修改或者引發了視圖的更新,我們可能搞不清楚到底發生了什么,state 的變化已經變得有些難以預測和不受控制,因此 Redux 應運而生,通過對 Flux 思想的實踐和增強,對狀態更新發生的時間和方式進行限制,Redux 試圖讓 state 的變化變得可預測。
項目簡介
在學了一段時間 Redux 之后,開始嘗試對之前做過的 Todolist 單頁應用進行重構,需要說明的是,因為應用本身非常迷你,所以可能無法明顯地體現使用 Redux 的優勢,但是基本上能夠比較清晰得說明 Redux 的工作流程,相信各位在閱讀了下面對項目實用 Redux 重構過程的分析后,會有很大的收獲和體會。
技術棧: Node.js React Redux Webpack MongoDB
項目源代碼的 Github 地址:https://github.com/wx1993/Node-Redux-MongoDB-TodoList
項目的搭建和環境的配置,可參考上一篇博客: Node.js + React + MongoDB 實現 TodoList 單頁應用
相關的操作和配置可以參考博客:
Node 項目的創建:http://www.cnblogs.com/wx1993/p/5765301.html
MongoDB 的安裝和配置:http://www.cnblogs.com/wx1993/p/5187530.html (Mac)
http://www.cnblogs.com/wx1993/p/5206587.html(windows)
Git 入門和常用命令詳解:http://www.cnblogs.com/wx1993/p/6230435.html
參考資料
在學習的過程中,主要受了以下資料和博客的啟發:
《深入 React 技術棧》 第五章 <深入 Redux 應用架構>
Redux 基礎
Redux 的三大原則
1. 單一數據源。
應用只有唯一的數據源,整個應用的狀態都保存在一個對象中,為提取出整個應用的狀態進行持久化提供可能,同時 Redux 提供的 combineReducers 方法對數據源過於龐大的問題進行了有效的化解。
2. 狀態是只讀的。
在 Redux 中,無法直接通過 setState() 來修改狀態,而是通過定義 reducer ,根據當前觸發的 action 類型對當前的 state 進行迭代。reducer(previousState, action) => newState
3. 狀態修改由純函數完成。
狀態修改通過 reducer 來實現,每一個 reducer 都是純函數,當接受一定的 state 和 action,返回的 newState 都是固定不變的。
Redux 組成部分
1. store:由 createStore(reducer,initialState)方法生成,用於維護整個應用的 state。store 包含以下四個方法:
- getState():獲取 store 中當前的狀態
- dispatch(action):分發 action,更新 state
- subscribe(listener):注冊監聽器,在 store 變化的時候被調用
- replaceReducer(nextReducer):更新當前 store 中的 reducer,一般只在開發者模式中使用
2. action:一個 JavaScript 對象,用於描述一個事件(描述發生了什么)和需要改變的數據,必須有一個 type 字段,用來標識指令,其他元素是傳送這個指令的 state 值。由組件觸發,並傳送到 reducer
{ type: "ADD_TODO" text: "study Redux" }
3. reducer:一個包含 switch 的函數,描述數據如何變化,根據 action type 來進行響應的 state 更新操作(如果沒有更改,則返回當前 state 本身)。整個應用只有一個單一的 reducer 函數,因此需要 combileReducers()函數。
function counter(state = 0, action) { switch (action.type) { case 'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } }
Redux 數據流
Redux 數據流圖
這里給出的是一個簡單的 Redux 數據流圖,基本上可以描述 Redux 中各個部分是如何運行和協作的,關於每一個模塊的具體作用,在下文會結合代碼進行詳細的介紹和分析,相信在看完具體的分析之后,對於上圖你會有一定的理解和新的體會。
容器組件 & 展示組件
展示組件 | 容器組件 | |
作用 | 描述如何展現(標簽、樣式) | 描述如何運行(獲取數據、更新狀態) |
直接使用 Redux | 否 | 是 |
數據來源 | 從 this.props 中獲取 | 使用 connect 從 Redux 狀態樹中獲取 |
數據修改 | 調用從 props 中傳入的 action creator | 直接分發 action |
調用方式 | 開發者手動創建 | 由 React Redux 生成 |
簡單來說,容器型組件描述的是組件如何工作,即數據如何獲取合更新,一般不包含 Virtual DOM 的修改或組合,也不包含組件的樣式
展示型組件描述的是組件是如何渲染的,不依賴 store,一般包含 Virtual DOM 的修改或組合,以及組件的樣式,可以寫成無狀態函數。
在了解了上述的一些 Redux 相關的概念,下面將結合實例對 Redux 的使用進行具體的描述和分析。
TodoList
功能
- 添加 Todolist
- 刪除 Todolsit
運行
克隆出上面的 github 的項目后,進入項目,
安裝依賴
npm install
啟動MongoDB
mongod
項目打包
webpack -w
啟動項目
npm start
瀏覽器輸入 localhost:8080,查看效果
效果
目錄結構
入口文件
index.js
import React from 'react' import ReactDOM from 'react-dom' import { createStore, applyMiddleware } from 'redux' import thunkMiddleware from 'redux-thunk' import { createLogger } from 'redux-logger' import { Provider } from 'react-redux' import Todo from './containers/app' import rootReducer from './reducers/todoReducer' // 打印日志方法 const loggerMiddleware = createLogger() // applyMiddleware() 用來加載 middleWare const createStoreWithMiddleware = applyMiddleware(thunkMiddleware, loggerMiddleware)(createStore) // 創建 store 對象 const store = createStoreWithMiddleware(rootReducer) // 獲取到的 store 是空的? console.log(store.getState()) // 注冊 subcribe 函數,監聽 state 的每一次變化 const unsubscribe = store.subscribe(() => console.log(store.getState()) ); ReactDOM.render( <Provider store={store}> <Todo /> </Provider>, document.getElementById("app") );
在入口文件我們主要做了以下幾件事情:
1. 引入模塊;
2. 使用 thunkMiddleware(用於異步請求數據)和 loggerMiddleware(用於打印日志) 對 createStore 進行了增強;
3. 然后創建 store 對象,注冊監聽函數(在函數體內可以添加 state 變化時候的相關操作)
4. 引入 Provider 中間件,作為根組件的上層容器,接受 store 作為屬性,將 store 放在 context 中,提供給 connect 組件來連接容器組件。
5. 將應用組件掛載到頁面節點上
注:這里將 store 的相關配置也放在了 入口文件中,為了使文件結構更加清晰,可以考慮將 store 的相關配置單獨定義為 configureStore.js,然后在入口文件中引入。
Action
src/actions/todoAction.js
import $ from 'jquery' // 定義 action type 為常量 export const INIT_TODO = 'INIT_TODO' export const ADD_TODO = 'ADD_TODO' export const DELETE_TODO = 'DELETE_TODO' // create action export function initTodo () { // 這里的 action 是一個 Trunk 函數,可以將 dispatch 和 getState() 傳遞到函數內部 return (dispatch, getState) => { $.ajax({ url: '/getTodolsit', type: 'get', dataType: 'json', success: data => { // console.log(data) // 請求成功,分發 action, 這里的 dispatch 是通過 Redux Trunk Middleware 傳遞過來的 dispatch({ type: 'INIT_TODO', todolist: data.reverse() }) }, error: () => { console.log('獲取 todolist 失敗...') } }) } } export function addTodo (newTodo) { return (dispatch, getState) => { $.ajax({ url: '/addTodo', type: 'post', dataType: 'json', data: newTodo, success: data => { // console.log(data) dispatch({ type: 'ADD_TODO', todolist: data.reverse() }) }, error: () => { console.log(err) } }) } } export function deleteTodo (date) { console.log(date) return (dispatch, getState) => { $.ajax({ url: '/deleteTodo', type: 'post', dataType: 'json', data: date, success: data => { // console.log(data) dispatch({ type: 'DELETE_TODO', todolist: data.reverse() }) }, error: () => { console.log(err) } }) } }
可以看到,這里的 action 和我們上面講到的 action 不太一樣,因為這里用 ajax 進行了數據的異步請求,在前面的入口文件中我們實用了 trunkMiddleware 中間件(需要在index.js 中引入 redux-trunk),這個中間件就是為了異步請求用的,對應的異步 action 函數的形式如下:
export const asyncAction () => { return (dispatch, getState) => { // 在這里可以調用異步函數請求數據,並在合適的時機通過 dispatch 參數派發出新的 action 對象 } }
* redux-trunk 的工作是檢查 action 對象是不是函數,如果不是函數就放行,完成普通的 action 生命周期,如果是函數,則執行函數,並把 Store 的 dispatch 函數和 getState 函數作為參數傳遞到函數中去,並產生一個同步的 action 對象來對 redux 產生影響。
注:1. 如果涉及到的 action 類型名比較多,可以將它們單獨定義到一個文件中,然后在這里引入,以便於后面的管理。
2. 這里實用 jQuery 的 ajax 進行數據的請求,也可以嘗試引入 fetch 進行 Ajax
Reducer
reducers/todoReducer.js
import { combineReducers, createStore } from 'redux' import { INIT_TODO, ADD_TODO, DELETE_TODO } from '../actions/todoAction' // 在 reducer 第一次執行的時候,沒有任何的 previousState, 因此需要定義一個 initialState, // 下面使用 es6 的寫法為 state 賦初始值 function todoReducer (state = [], action) { console.log(action); switch (action.type) { case INIT_TODO: return action.todolist break case ADD_TODO: return action.todolist break case DELETE_TODO: return action.todolist break default: return state } } // 將多個 reducer 合並成一個 const rootReducer = combineReducers({ todoReducer }) export default rootReducer
在 reducer 中,首先引入 在 action 中定義的 type 參數,然后定義一個函數(純函數),接收 state 和 action 作為參數,通過 switch 判斷當前 action type,並返回不同的對象(更新 store)。
需要注意的是,當組件剛開始渲染的時候,store 中並沒有 state,所以針對這種情況,需要為 state 賦一個初始值,可以在函數體內通過 if 語句來判斷,但是 es6 提供了更為簡潔的寫法,在參數中直接賦值,當傳入的 state 為空的時候,直接使用初始值,當然這里默認為空數組。
combineReducers({...}):首先需要明確的是,整個應用只能有一個 reducer 函數。如果定義的 action type 有很多,那么針對不同的 type,需要寫很多的分支語句或者定義多個 reducer 文件,因此 Redux 提供 combineReducers 函數,來將多個 reducer 合並成一個。
容器組件
containers/app.js
import React from 'react' import PropTypes from 'prop-types' import ReactDOM from 'react-dom' import { connect } from 'react-redux' import { initTodo, addTodo, deleteTodo } from '../actions/todoAction' import TodoList from '../components/todolist' import TodoForm from '../components/todoform' class Todo extends React.Component { componentDidMount () { this.props.dispatch(initTodo()) } handleAddTodo (newTodo) { console.log('add new todo......'); console.log(newTodo); this.props.dispatch(addTodo(newTodo)) } handleDeleteTodo (date) { const delete_date = { date } this.props.dispatch(deleteTodo(delete_date)) } render() { // 這里的 todolist 是在 connect 中以 { todolist: state.todolist } 的形式作為屬性傳遞給 App 組件的 const { todolist } = this.props console.log(todolist); return ( <div className="container"> <h2 className="header">Todo List</h2> <TodoForm onAddTodo={this.handleAddTodo.bind(this)} /> <TodoList todolist={todolist} onDeleteTodo={this.handleDeleteTodo.bind(this)} /> </div> ) } } // 驗證組件中的參數類型 Todo.propTypes = { todolist: PropTypes.arrayOf( PropTypes.shape({ content: PropTypes.string.isRequired, date: PropTypes.string.isRequired }).isRequired ).isRequired } const getTodolist = state => { console.log(state); return { todolist : state.todoReducer } } export default connect(getTodolist)(Todo)
在容器組件中,我們定義了頁面的結構(標題、表單、列表),並定義了相關的方法和數據,通過 props 的方式傳遞給對應的子組件。在子組件通過觸發 props 中的回調函數時,在容器組件中接受到就會分發響應的 action,交由 reducer 進行處理(在 reducer 進行狀態的更新,然后同步到組件中,引起視圖的變化)。
添加了propTypes 驗證,這樣在組件中的屬性、方法以及其他定義的元素的類型不符的時候,瀏覽器會拋出警告,需要注意的是, 和 ReactDOM 一樣, propTypes 已經從 React分離出來了,因此使用的時候需要單獨引入模塊 prop-types(仍然使用 PropTypes from 'React'會有警告,但不影響使用)。
這里最為重要的是從 react-redux 中引入了 connect,通過 connect(selector)(App) 來連接 store 和 容器組件。其中,selector 是一個函數,接受 store 中的 state 作為參數,然后返回一個對象,將里面的參數以屬性的形式傳遞給連接的組件,同時還隱式地傳遞 一個 dispatch 方法,作為組件的屬性。如下所示:
需要注意的是,connect 函數產生的組件是一個高階組件,其完整的形式如下:
const mapStateToProps = (state) => { return { data: state } } const mapDispatchToProps = (dispatch) => { return { getCityWeather: (args) => { dispatch(asyncAction(args)) } } } export default connect(mapStateToProps, mapDispatchToProps)(App)
可以看出,connect 函數接受兩個參數:
1. mapStateToProps: 將 Store 上的狀態轉化為展示組件上的 props
2. mapDispatchToProps:將 Store 上的dispatch 動作轉化為展示組件上的 props
因此, App 組件可以獲得 store 中的 state並傳遞給子組件,並可以通過 dispatch() 方法來分發 action。如下所示:
展示組件
由容器組件中的 DOM 結構可以看出主要有 todoform 和 todolist 兩個組件,同時在 todolist 組件中,又再次划分出了 todo 組件,展示組件比較簡單,不擁有自己的狀態,主要是從父級獲取 props,並在 DOM 中進行展示,同時在組件中觸發事件,通過 this.props.eventHandler()的方式來通知父級,最后觸發 action 實現狀態的修改。
components/todoform.js
import React from 'React' import PropTypes from 'prop-types' class TodoForm extends React.Component { // 表單輸入時隱藏提示語 handleKeydown () { this.refs.tooltip.style.display = 'none' } // 提交表單操作 handleSubmit (e) { e.preventDefault(); // 表單輸入為空驗證 if(this.refs.content.value == '') { this.refs.content.focus() this.refs.tooltip.style.display = 'block' return ; } // 獲取時間並格式化 let month = new Date().getMonth() + 1; let date = new Date().getDate(); let hours = new Date().getHours(); let minutes = new Date().getMinutes(); let seconds = new Date().getSeconds(); if (hours < 10) { hours += '0'; } if (minutes < 10) { minutes += '0'; } if (seconds < 10) { seconds += '0'; } // 生成參數 const newTodo = { content: this.refs.content.value, date: month + "/" + date + " " + hours + ":" + minutes + ":" + seconds }; this.props.onAddTodo(newTodo) this.refs.todoForm.reset(); } render () { return ( <form className="todoForm" ref="todoForm" onSubmit={ this.handleSubmit.bind(this) }> <input ref="content" onKeyDown={this.handleKeydown.bind(this)} type="text" placeholder="Type content here..." className="todoContent" /> <span className="tooltip" ref="tooltip">Content is required !</span> </form> ) } } export default TodoForm
components/todolist.js
import React from 'react'; import PropTypes from 'prop-types' import Todo from './todo'; class TodoList extends React.Component { render() { const todolist = this.props.todolist; console.log(todolist); const todoItems = todolist.map((item, index) => { return ( <Todo key={index} content={item.content} date={item.date} onDeleteTodo={this.props.onDeleteTodo} /> ) }); return ( <div> { todoItems } </div> ) } } // propTypes 用於規范 props 的類型與必需的狀態,在開發環境下會對組件的 props 進行檢查, // 如果不能與之匹配,將會在控制台報 warning。在生產環境下不會進行檢查。(解決 JS 弱語言類型的問題) // arrayOf 表示數組類型, shape 表示對象類型 TodoList.propTypes = { todolist: PropTypes.arrayOf( PropTypes.shape({ content: PropTypes.string.isRequired, date: PropTypes.string.isRequired, }).isRequired ).isRequired } export default TodoList;
components/todo.js
import React from 'react' import PropTypes from 'prop-types' class TodoItem extends React.Component { handleDelete () { const date = this.props.date; this.props.onDeleteTodo(date); } render() { return ( <div className="todoItem"> <p> <span className="itemCont">{ this.props.content }</span> <span className="itemTime">{ this.props.date }</span> <button className="delBtn" onClick={this.handleDelete.bind(this)}> <img className="delIcon" src="/images/delete.png" /> </button> </p> </div> ) } } TodoItem.propTypes = { content: PropTypes.string.isRequired, date: PropTypes.string.isRequired, // handleDelete: PropTypes.func.isRequired } export default TodoItem;
數據庫操作
database/db.js
var mongoose = require('mongoose') // 定義數據模式,指定保存到 todo 集合 const TodoSchema = new mongoose.Schema({ content: { type: String, required: true }, date: { type: String, required: true } }, { collection: 'todo' }) // 定義數據集合的模型 const Todo = mongoose.model('TodoBox', TodoSchema) module.exports = Todo
這里就比較簡單了,只有兩個字段,都是 String 類型,並指定保存到 todo 這個集合中,最后通過一行代碼編譯成對應的模型並導出,這樣在 node 中就可以通過模型來操作數據庫了。
注:因為項目比較簡單,只涉及一個數據集合,所以直接將 schema 和 model 寫在一個文件中,如果涉及多個數據集合,建議將 schema 和 model 放在不同的文件中
接口封裝
routes/index.js
var express = require('express'); var Todo = require('../src/database/db') var router = express.Router(); router.get('/', (req, res, next) => { res.render('index', { title: 'React TodoList' }); }); // 獲取 todolist router.get('/getTodolsit', (req, res, next) => { Todo.find({}, (err,todolist) => { if (err) { console.log(err); }else { console.log(todolist); res.json(todolist); } }) }); // 添加 todo router.post('/addTodo', (req, res, next) => { const newItem = req.body; Todo.create(newItem, (err) => { if (err) { console.log(err); }else { Todo.find({}, (err, todolist) => { if (err) { console.log(err); }else { res.json(todolist); } }); } }) }) // 刪除 todo router.post('/deleteTodo', (req, res, next) => { const delete_date = req.body.date Todo.remove({date: delete_date}, (err, result) => { if (err) { console.log(err) }else { // 重新獲取 todolist Todo.find({}, (err, todolist) => { if (err) { console.log(err); }else { res.json(todolist); } }) } }); }); module.exports = router;
沒有任何魔法,只是簡單的數據庫增刪改查的操作,封裝成接口,來供 createAction 中通過 Ajax 來請求調用。
然后是 webpack 的配置和 CSS 的編寫,都比較簡單,和未使用 Redux 重構的代碼沒有任何修改,所以也就不貼代碼了。
測試
因為使用了 loggerMiddleware 中間件, 可以跟蹤 actoin 的變化並在瀏覽器控制台中打印出 state 信息,因此可以十分直觀地看到數據的變化。
下面就根據這里的打印信息,結合 React 的生命周期,來簡單捋一遍 Redux 的工作流程。
進入頁面
可以看到,打印出來的 state 是一個對象,並且最開始是空的數組對象,這是因為在頁面尚未渲染完畢的時候,即在 componentWillMount 階段,頁面並沒有任何的 state,直到渲染結束,即 componentDidMount 階段,容器組件主動的觸發了 INIT_TODO 的 action,reducer 接受到這個 action 后開始請求數據,更新 state,然后同步到頁面上來,這也是為什么打印出來的 state 在 todoReducer 這個對象中,因為 state 就是在 reducer 中進行處理和返回的。
添加 todo
可以看到,這里觸發了 ADD_TODO 的 action,在執行 reducer 的操作后,state 的中的數據由 6 條變成了 7 條。
刪除 todo
同樣,執行刪除操作時觸發了 DELETE_TODO 的 action,在執行 reducer 的操作后,state 的中的數據由 7 條變成了 6 條。
總結
1. Redux 是一個"可預測的狀態容器",由 Store、Action、Reducer 三部分組成。
2. Store 負責存儲狀態,通過 createStore(reducer, initialState) 生成。
3. Action 中聲明了數據的結構,不提供邏輯,在 createAciton 結合中間件可以發出異步請求。
4. Reducer 是一個純函數,每個應用只能有唯一的一個 reducer, 多個 reducer 使用 combineReducers() 方法進行合並。
5. react-redux 提供了 <Provider />組件和 connect () 方法實現 Redux 和 React 的綁定。
6. <Provider />接受一個 store 作為 props,是整個應用的頂層組件;connect () 提供了在整個 React 應用中熱議組件獲取 store 中數據的功能。
7. 容器型組件和 Redux 進行交互並獲取狀態,分發 action;展示型組件從傳入的 props 中獲取數據,通過容器組件下發的的回調函數來觸發事件,向上通知父級,從而觸發 action,實現狀態的更新。