[1]MVC [2]Flux [3]Redux [4]容器和展示 [5]react-redux [6]模塊化應用 [7]reselect [8]常見錯誤 ...
前面的話
Redux是Flux思想的另一種實現方式。Flux是和React同時面世的。React用來替代jQuery,Flux用來替換Backbone.js等MVC框架。在MVC的世界里,React相當於V(view)的部分,只涉及頁面的渲染。一旦涉及應用的數據管理部分,還是交給Model和Controller。不過,Flux並不是一個MVC框架,它用一種新的思路來管理數據。本文將詳細介紹Redux的內容
MVC
MVC是業界廣泛接受的一種前端應用框架類型,這種框架把應用分為三個部分:
Model(模型)負責管理數據,大部分業務邏輯應該放在Model中
View(視圖)負責渲染用戶頁面,應該避免在View中涉及業務邏輯
Controller(控制器)負責接受用戶輸入,根據用戶輸入調用相應的Model部分邏輯,把產生的數據結果交給View部分,讓View渲染出必要的輸出
MVC框架提出的數據流很理想,用戶請求先到達Controller,由Controller調用Model獲得數據,然後把數據交給View。但是,在實際框架實現中,總是允許View和Model直接通信
然而,在MVC中讓View和Model直接對話就是災難
Flux
Facebook用Flux框架來替代原有的MVC框架,這種框架包含四個部分:
Dispatcher負責動作分發,維持Store之間的依賴關係
Store負責存儲數據和處理數據相關邏輯
Action驅動Dispatcher的javascript對象
View視圖負責顯示用戶界面
如果非要把Flux和MVC做一個對比。那麼,Flux的Dispatcher相當於MVC的Controller,Flux的store相當於MVC的model,Flux的View對應於MVC的View,Action對應給MVC框架的用戶請求
1、Dispatcher
import {Dispatcher} from 'flux'; export default new Dispatcher();
2、Action
定義Action通常需要兩個文件,一個定義action的類型,一個定義action的構造函數。分成兩個文件的原因是在Store中會根據action類型做不同操作,也就有單獨導入action類型的需要
export const INCREMENT = 'increment'; export const DECREMENT = 'decrement';
import * as ActionTypes from './ActionTypes.js'; import AppDispatcher from './AppDispatcher.js'; export const increment = (counterCaption) => { AppDispatcher.dispatch({ type: ActionTypes.INCREMENT, counterCaption: counterCaption }); }; export const decrement = (counterCaption) => { AppDispatcher.dispatch({ type: ActionTypes.DECREMENT, counterCaption: counterCaption }); };
3、Store
一個Store也是一個對象,這個對象用來存儲應用狀態,同時還要接受Dispatcher派發的動作,根據動作來決定是否要更新應用狀態
一個EventEmitter實例對象支持下列相關函數
emit函數:可以廣播一個特定事件,第一個參數是字元串類型的事件名稱
on函數:可以增加一個掛在這個EventEmitter對象特定事件上的處理函數,第一個參數是字元串類型的事件名稱,第二個參數是處理函數
removeListener函數: 和on函數做的事情相反,刪除掛在這個EventEmitter對象特定事件上的處理函數,和on函數一樣,第一個參數是事件名稱,第二個參數是處理函數
[註意]如果要調用removeListener函數,就一定要保留對處理函數的引用
import AppDispatcher from '../AppDispatcher.js'; import * as ActionTypes from '../ActionTypes.js'; import {EventEmitter} from 'events'; const CHANGE_EVENT = 'changed'; const counterValues = { 'First': 0, 'Second': 10, 'Third': 30 }; const CounterStore = Object.assign({}, EventEmitter.prototype, { getCounterValues: function() { return counterValues; }, emitChange: function() { this.emit(CHANGE_EVENT); }, addChangeListener: function(callback) { this.on(CHANGE_EVENT, callback); }, removeChangeListener: function(callback) { this.removeListener(CHANGE_EVENT, callback); } }); CounterStore.dispatchToken = AppDispatcher.register((action) => { if (action.type === ActionTypes.INCREMENT) { counterValues[action.counterCaption] ++; CounterStore.emitChange(); } else if (action.type === ActionTypes.DECREMENT) { counterValues[action.counterCaption] --; CounterStore.emitChange(); } }); export default CounterStore;
4、View
存在於Flux框架中的React組件需要實現以下幾個功能
(1)創建時讀取Store上狀態來初始化組件內部狀態
(2)當Store上狀態發生變化時,組件要立刻同步更新內部狀態保持一致
(3)View如果要改變Store狀態,必須而且只能派發action
// 父組件
class ControlPanel extends Component { render() { return ( <div style={style}> <Counter caption="First" /> <Counter caption="Second" /> <Counter caption="Third" /> <hr/> <Summary /> </div> ); } } export default ControlPanel;
// 子組件 class Counter extends Component { constructor(props) { super(props); this.onChange = this.onChange.bind(this); this.onClickIncrementButton = this.onClickIncrementButton.bind(this); this.onClickDecrementButton = this.onClickDecrementButton.bind(this); this.state = {count: CounterStore.getCounterValues()[props.caption]} } shouldComponentUpdate(nextProps, nextState) { return (nextProps.caption !== this.props.caption) || (nextState.count !== this.state.count); } componentDidMount() { CounterStore.addChangeListener(this.onChange); } componentWillUnmount() { CounterStore.removeChangeListener(this.onChange); } onChange() { const newCount = CounterStore.getCounterValues()[this.props.caption]; this.setState({count: newCount}); } onClickIncrementButton() { Actions.increment(this.props.caption); } onClickDecrementButton() { Actions.decrement(this.props.caption); } render() { const {caption} = this.props; return ( <div> <button style={buttonStyle} onClick={this.onClickIncrementButton}>+</button> <button style={buttonStyle} onClick={this.onClickDecrementButton}>-</button> <span>{caption} count: {this.state.count}</span> </div> ); } } Counter.propTypes = { caption: PropTypes.string.isRequired }; export default Counter;
【優勢】
在Flux中,Store只有get方法,沒有set方法,根本不可能直接去修改其內部狀態,View只能通過get方法獲取Store的狀態,無法直接去修改狀態,如果View想要修改Store的狀態,只能派發一個action對象給Dispatcher
【不足】
1、Store之間依賴關係
在Flux的體系中,如果兩個Store之間有邏輯依賴關係,就必須用上Dispatcher的waitFor函數
2、難以進行伺服器端渲染
3、Store混雜了邏輯和狀態
Redux
Redux的含義是Reducer+Flux。Reducer是一個電腦科學中的通用概念。以Javascript為例,數組類型有reduce函數,接受的參數是一個reducer,reducer做的事情就是把數組所有元素依次做規約,對每個元素都調用一次參數reducer,通過reducer函數完成規約所有元素的功能
Flux的基本原則是單向數據流,Redux在此基礎上強調三個基本原則:
1、唯一數據源
2、保持狀態只讀
3、數據改變只通過純函數完成
//actionTypes export const INCREMENT = 'increment'; export const DECREMENT = 'decrement';
//actions import * as ActionTypes from './ActionTypes.js'; export const increment = (counterCaption) => { return { type: ActionTypes.INCREMENT, counterCaption: counterCaption }; }; export const decrement = (counterCaption) => { return { type: ActionTypes.DECREMENT, counterCaption: counterCaption }; };
//store import {createStore} from 'redux'; import reducer from './Reducer.js'; const initValues = { 'First': 0, 'Second': 10, 'Third': 20 }; const store = createStore(reducer, initValues); export default store;
//reducer import * as ActionTypes from './ActionTypes.js'; export default (state, action) => { const {counterCaption} = action; switch (action.type) { case ActionTypes.INCREMENT: return {...state, [counterCaption]: state[counterCaption] + 1}; case ActionTypes.DECREMENT: return {...state, [counterCaption]: state[counterCaption] - 1}; default: return state } }
// 父組件 class ControlPanel extends Component { render() { return ( <div style={style}> <Counter caption="First" /> <Counter caption="Second" /> <Counter caption="Third" /> <hr/> <Summary /> </div> ); } }
// 子組件 class Counter extends Component { constructor(props) { super(props); this.onIncrement = this.onIncrement.bind(this); this.onDecrement = this.onDecrement.bind(this); this.onChange = this.onChange.bind(this); this.getOwnState = this.getOwnState.bind(this); this.state = this.getOwnState(); } getOwnState() { return {value: store.getState()[this.props.caption]}; } onIncrement() { store.dispatch(Actions.increment(this.props.caption)); } onDecrement() { store.dispatch(Actions.decrement(this.props.caption)); } onChange() { this.setState(this.getOwnState()); } shouldComponentUpdate(nextProps, nextState) { return (nextProps.caption !== this.props.caption) || (nextState.value !== this.state.value); } componentDidMount() { store.subscribe(this.onChange); } componentWillUnmount() { store.unsubscribe(this.onChange); } render() { const value = this.state.value; const {caption} = this.props; return ( <div> <button style={buttonStyle} onClick={this.onIncrement}>+</button> <button style={buttonStyle} onClick={this.onDecrement}>-</button> <span>{caption} count: {value}</span> </div> ); } } Counter.propTypes = { caption: PropTypes.string.isRequired };
容器和展示
一個React組件基本上要完成以下兩個功能:
1、讀取Store的狀態,用於初始化組件的狀態,同時還要監聽Store的狀態改變;當Store狀態發生變化時,需要更新組件狀態,從而驅動組件重新渲染;當需要更新Store狀態時,就要派發action對象
2、根據當前props和state,渲染出用戶界面
讓一個組件只專註做一件事。於是,按照這兩個功能拆分成兩個組件。這兩個組件是父子組件的關係。業界對於這樣的拆分有多種叫法,承擔第一個任務的組件,也就是負責和redux打交道的組件,處於外層,被稱為容器組件;只專業負責渲染界面的組件,處於內層,叫做展示組件
展示組件,又稱為傻瓜組件,就是一個純函數,根據props產生結果。實際上,讓展示組件無狀態,只根據props來渲染結果,是拆分的主要目的之一。狀態全部交給容器組件去處理
function Counter (props){ const {caption, onIncrement, onDecrement, value} = this.props; return ( <div> <button style={buttonStyle} onClick={onIncrement}>+</button> <button style={buttonStyle} onClick={onDecrement}>-</button> <span>{caption} count: {value}</span> </div> ); } }
或者,直接使用解構賦值的方法
function Counter ({caption, onIncrement, onDecrement, value} ){ return ( <div> <button style={buttonStyle} onClick={onIncrement}>+</button> <button style={buttonStyle} onClick={onDecrement}>-</button> <span>{caption} count: {value}</span> </div> ); } }
React-redux
react-redux遵循將組件分成展示組件和容器組件的規範。react-redux提供了兩個功能:
1、Provider組件,可以讓容器組件預設可以取得state,而不用當容器組件層級很深時,一級級將state傳下去
import React from 'react'; import ReactDOM from 'react-dom'; import {Provider} from 'react-redux'; import ControlPanel from './views/ControlPanel'; import store from './Store.js'; import './index.css'; ReactDOM.render( <Provider store={store}> <ControlPanel/> </Provider>, document.getElementById('root') );
2、connect方法,用於從展示組件生成容器組件。connect的意思就是將這兩種組件連接起來
import React, { PropTypes } from 'react'; import * as Actions from '../Actions.js'; import {connect} from 'react-redux'; const buttonStyle = { margin: '10px' }; function Counter({caption, onIncrement, onDecrement, value}) { return ( <div> <button style={buttonStyle} onClick={onIncrement}>+</button> <button style={buttonStyle} onClick={onDecrement}>-</button> <span>{caption} count: {value}</span> </div> ); } Counter.propTypes = { caption: PropTypes.string.isRequired, onIncrement: PropTypes.func.isRequired, onDecrement: PropTypes.func.isRequired, value: PropTypes.number.isRequired }; function mapStateToProps(state, ownProps) { return { value: state[ownProps.caption] } } function mapDispatchToProps(dispatch, ownProps) { return { onIncrement: () => { dispatch(Actions.increment(ownProps.caption)); }, onDecrement: () => { dispatch(Actions.decrement(ownProps.caption)); } } } export default connect(mapStateToProps, mapDispatchToProps)(Counter);
模塊化應用
從架構出發,開始一個新應用時,有幾件事情是一定要考慮清楚的:
1、代碼文件的組織結構
2、確定模塊的邊界
3、Store的狀態樹設計
【代碼文件的組織結構】
Redux應用適合於按功能組織,也就是把完成同一應用功能的代碼放在一個目錄下,一個應用功能包含多個角色的代碼。在Redux中,不同的角色就是reducer、actions和視圖。而應用功能對應的就是用戶界面上的交互模塊
以Todo應用為例,這個應用的兩個基本功能就是TodoList和Filter,所以代碼可以這樣組織:
todoList/ actions.js actionTypes.js index.js reduce.js views/ component.js container.js filter/ actions.js actionTypes.js index.js reduce.js views/ component.js container.js
【模塊介面】
不同功能模塊之間的依賴關係應該簡單而清晰,也就是所謂的保持模塊之間低耦合性;一個模塊應該把自己的功能封裝得很好,讓外界不要太依賴於自己內部的結構,這樣不會因為內部的變化而影響外部模塊的功能,這就是所謂的高內聚性
【狀態樹的設計】
狀態樹的設計需要遵循如下幾個原則:
1、一個模塊控制一個狀態節點
2、避免冗餘數據
3、樹形結構扁平
對於Todo應用的狀態樹設計如下
{ todos: [ { text: 'first todo', completed: false, id: 0 }, { text: 'second todo', completed: true, id: 1 }, ], // 'all'、'completed'、'uncompleted' filter: 'all' }
reselect
reselect庫的原理是只要相關狀態沒有改變,那就直接使用上一次的緩存結果。reselect用來創造選擇器,接收一個state作為參數的函數,返回的數據是某個mapStateToProps需要的結果
首先,安裝reselect庫
npm install --save reselect
reselect提供了創造選擇器的createSelector函數,這是一個高階函數,也就是接受函數為參數來產生一個新函數的函數
createSelector 接收一個 input-selectors 數組和一個轉換函數作為參數。如果 state tree 的改變會引起 input-selector 值變化,那麼 selector 會調用轉換函數,傳入 input-selectors 作為參數,並返回結果。如果 input-selectors 的值和前一次的一樣,它將會直接返回前一次計算的數據,而不會再調用一次轉換函數。
import { createSelector } from 'reselect' const getVisibilityFilter = (state) => state.visibilityFilter const getTodos = (state) => state.todos export const getVisibleTodos = createSelector( [ getVisibilityFilter, getTodos ], (visibilityFilter, todos) => { switch (visibilityFilter) { case 'SHOW_ALL': return todos case 'SHOW_COMPLETED': return todos.filter(t => t.completed) case 'SHOW_ACTIVE': return todos.filter(t => !t.completed) } } )
在上例中,getVisibilityFilter
和 getTodos
是 input-selector。因為他們並不轉換數據,所以被創建成普通的非記憶的 selector 函數。但是,getVisibleTodos
是一個可記憶的 selector。他接收 getVisibilityFilter
和 getTodos
為 input-selector,還有一個轉換函數來計算過濾的 todos 列表
reselect的典型應用如下所示
// selector export const getCategories = state => { return state.category } export const getCategoriesSortByNumber = createSelector(getCategories, categories => categories.sort((v1, v2) => { return v1.number - v2.number }) ) export const getCategoryDatas = createSelector(getCategoriesSortByNumber, categoriesSortByNumber => categoriesSortByNumber.map(t => { return $_setChildren(categoriesSortByNumber, t) }).map(t => { return Object.assign(t, { index: $_getIndex(t.number), des: t.children.length ? t.children.length : '', title: t.name, key: t.number, className: 'styled-categorylist', url: t.children.length ? `/category/${t.number}` : '', parentUrl: `/category/${$_getParentNumber(t)}`, nextChildNumber: $_getFirstChildNumber(t) }) }) ) export const getCategoryDatasByNumber = createSelector(getCategoryDatas, categoryDatas => categoryDatas.reduce((obj, t) => { obj[t.number] = t return obj }, {}) ) export const getCategoryRootDatas = createSelector(getCategoryDatas, categoryDatas => categoryDatas.filter(t => { return Number(String(t.number).slice(2)) === 0 }) ) export const getCategoryDatasById = createSelector(getCategoryDatas, categoryDatas => categoryDatas.reduce((obj, t) => { obj[t._id] = t return obj }, {}) )
常見錯誤
在使用redux的過程中,會出現如下的常見錯誤
【錯誤:reducers不能觸發actions】
Uncaught Error: Reducers may not dispatch actions.
一般來說,出現"Reducedrs may not dispatch actions"的錯誤,是因為reducer中出現路由跳轉語句,而跳轉到的語句正好發送了dispatch。從而,reducer不再是純函數
錯誤代碼如下所示:
export const logIn = admin => ({type: LOGIN, admin}) // reducer const login = (state = initialState, action) => { switch (action.type) { case LOGIN: let { token, user } = action.admin // 將用戶信息保存到sessionStorage中 sessionStorage.setItem('token', token) sessionStorage.setItem('user', JSON.stringify(user)) // 跳轉到首頁 history.push('/') return { token, user } ...
有兩種解決辦法
1、給路由跳轉語句設置延遲定時器,從而避免在當前reducer還沒有返回值的情況下,又發送新的dispatch
export const logIn = admin => ({type: LOGIN, admin}) // reducer const login = (state = initialState, action) => { switch (action.type) { case LOGIN: let { token, user } = action.admin // 將用戶信息保存到sessionStorage中 sessionStorage.setItem('token', token) sessionStorage.setItem('user', JSON.stringify(user)) // 跳轉到首頁 setTimeout(() => { history.push('/') },0) return { token, user } ...
2、將reducer中的邏輯放到dispatch中
export const logIn = (admin) => { let { token, user } = admin // 將用戶信息保存到sessionStorage中 sessionStorage.setItem('token', token) sessionStorage.setItem('user', JSON.stringify(user)) // 跳轉到首頁 history.push('/') return {type: LOGIN, admin} } // reducer const login = (state = initialState, action) => { switch (action.type) { case LOGIN: let { token, user } = action.admin return { token, user } ...
【action函數中無法執行return後的語句】
例如,在下麵代碼中,控制台只能輸入'111',而不能輸出'222'
export const updatePost = payload => { console.log('111') return dispatch => { console.log('222') fetchModule({ dispatch, url: `${BASE_POST_URL}/${payload._id}`, method: 'put', data: payload, headers: { Authorization: sessionStorage.getItem('token') }, success(result) { console.log(result) dispatch({ type: UPDATE_POST, doc: result.doc }) } }) } }
出現這個問題的原因非常簡單,是因為沒有使用this.props.updatePost,而直接使用了updatePost方法導致的
加入如下語句既可解決
let { updatePost } = this.props
【redux中的state發生變化,但頁面沒有重新渲染】
一般地,是因為增操作中,數組的展開運算符使用不當所至
代碼如下所示,一定要將...state放到最後一個條目位置,才能使用頁面重新渲染
case ADD_POST: return [action.doc, ...state]
而對於對象的展開運算符,則需要把...state放到第一個條目位置,因為後面的條目會覆蓋展開的部分
return {...item, completed: !item.completed}
【reducer中不能使用undefined】
1、reducer中state不能返回undefined,可以用null代替
// reducer const filter = (state = null, action) => { switch (action.type) { case SHOW_FILTER: return action.filter default: return state } }
2、同樣地,action.filter表示空值,不能為undefined,用null代替
export const setFilter = filter => ({type: SHOW_FILTER, filter})
this.props.setFilter(null)