今天的主题是Redux,一开始我们先看它是如何运作的,Redux并不是只能在React应用中使用,而是可以在一般的应用中使用。第一个例子是一个简单的JavaScript应用。
这个程序最后的呈现结果,就像下面的动态图片这样,重点是在于下面有个Redux DevTools,它有时光旅行调试的功能,可以倒带重播你作过的任何数据上的变动:
这个简单的应用是让你学习Redux整个运作的过程用的,它只是个演示用的例子,在实际的应用中虽然会比较复杂,但基本的运作流程都是一样的。本章的下面附了一些详细的说明,建议你一定要看。Redux里面有很多基本的概念与专有名词,不学是很难看得到在说什么东西,代码通常写得很简洁,但概念都是要有些基础才会通的。
代码说明首先我们要使用的是用于写ES6用的脚手架webpack-es6-startkit,因为这个例子中并没有要用到React,所以也不用安装React。
接着我们要多安装redux套件进来,在项目目录里用命令列工具(终端机)输入以下的指令:
npm install --save redux
另外你也需要安装Chrome浏览器的插件 - Redux DevTools,这可以让你使用Redux中的时光旅行调试功能。
我在index.html中加了一个文本框itemtext
、按钮itemadd
,以及一个准备要显示项目列表的div区域itemlist
,代码如下:
index.html
<div>
<p>
<input type="text" id="itemtext" />
<button id="itemadd">Add</button>
</p>
</div>
<div id="itemlist">
</div>
代码档案只有一个,就是index.js,它是在src/目录下的,代码如下:
src/index.js
import { createStore } from 'redux'
// @Reducer
//
// Action Payload = action.text
// 使用纯函数的数组unshift,不能有副作用
// state(状态)一开始的值是空数组`state=[]`
function addItem(state = [], action) {
switch (action.type) {
case 'ADD_ITEM':
return [action.text, ...state]
default:
return state
}
}
// @Store
//
// store = createStore(reducer)
// 使用redux dev tools
// 如果要正常使用是使用 const store = createStore(addItem)
const store = createStore(addItem,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())
// @Render
//
// render(渲染)是从目前store中取出state数据,然后输出呈现在网页上
function render() {
const items = store.getState().map(item => (
(item) ? `<li>${item}</li>` : ''
))
document.getElementById('itemlist').innerHTML = `<ul>${items.join('')}</ul>`
}
// 第一次要调用一次render,让网页呈现数据
render()
// 订阅render到store,这会让store中如果有新的state(状态)时,会重新调用一次render()
store.subscribe(render)
// 监听事件到 "itemadd" 按钮,
// 点按按钮会触发 store.dispatch(action),发送一个动作,
// 例如 store.dispatch({ type: 'ADD_ITEM', textValue })
document.getElementById('itemadd')
.addEventListener('click', () => {
const itemText = document.getElementById('itemtext')
// 调用store dispatch方法
store.dispatch({ type: 'ADD_ITEM', text: itemText.value })
// 清空文本输入框中的字
itemText.value = ''
})
这个代码中我有排顺序与加上中文注释,因为你要启用Redux中的作用,是有顺序的。我们一步步看下来:
第一步,是要从redux中汇入createStore
方法,这很简单如下面的代码:
import { createStore } from 'redux'
第二步,是要创建一个reducer(归纳函数),reducer请求一定要是纯函数。那么到底什么是reducer的作用,就是传入之前的state(状态)与一个action(动作)对象,然后要返回一个新的state(状态)。
对我们这个简单的应用来说,它只会有一种这种行为,就是在文本框输入一些文字,按下按钮后,把这串文字值加到state(状态)中。
所以它的state(状态)是个数组,每次一发送动作时,就加到这个数组的最前面(索引值为0)一个成员,动作就是像下面这样的一个纯对象描述:
{
type: 'ADD_ITEM',
text
}
注: 上面的
{ text }
是{ text: text}
的简写语法,这是在ES6之后可以用的对象属性初始化简写语法。
reducer里面通常会以动作的类型(action.type),用switch
语句来区分要运行哪一段的代码,因为动作有可能会有很多不同的,像删除项目、刷新项目等等。代码如下:
// @Reducer
//
// Action Payload = action.text
// 使用纯函数的数组unshift,不能有副作用
// state(状态)一开始的值是空数组`state=[]`
function addItem(state = [], action) {
switch (action.type) {
case 'ADD_ITEM':
return [action.text, ...state]
default:
return state
}
}
上面的[action.text, ...state]
,它就是纯函数写法的数组unshift
方法。
这里要注意的是,state需要给个初始值,用的是ES6中的传参默认值的写法。store实际上在创建时,会进行state的初始化。
第三步,是由写好的reducer,创建store,其实这没什么好说的,就用汇入的createStore
方法把reducer传入就行了。正常情况下是用const store = createStore(addItem)
,因为你要使用浏览器中的Redux DevTools,所以要改写成下面这样的代码:
// @Store
//
// store = createStore(reducer)
// 使用redux dev tools
// 如果要正常使用是使用 const store = createStore(addItem)
const store = createStore(addItem,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())
第四步,是写一个render(渲染函数),这个函数是在如果状态上有新的变化时,要作输出呈现的动作。说穿了,这大概是仿照React应用的机制的作法,不过它这设计实际上与React差了十万八千里,这个渲染函数里最重要的是用store.getState()
方法取出目前store
里面的状态值,因为我们现在只有记一个state值,所以直接取出来就是刚刚在reducer里记录状态值的那个数组。剩下的就是一些格式的调整与输出工作而已。代码如下:
// @Render
//
// render(渲染)是从目前store中取出state数据,然后输出呈现在网页上
function render() {
const items = store.getState().map(item => (
(item) ? `<li>${item}</li>` : ''
))
document.getElementById('itemlist').innerHTML = `<ul>${items.join('')}</ul>`
}
第五步,第一次调用一下render,让目前的数据呈现在网页上。因为我们一开始在state里并没有数据(空数组),但也有可能原本是有一些数据的,这只是一个初始化数据的动作而已,也很简单,代码如下:
// 第一次要调用一次render,让网页呈现数据
render()
第六步,订阅render函数到store中,用的是store.subscribe
方法,这订阅的动作会让store中如果有新的state(状态)时,就会重新调用一次render()。这也是一个很像是从React中抄来的设计吧?"当React中的state值改变(用setState),就会触发重新渲染",不过在React中,setState你要自己作,没有自动的机制。实际上这是从一个设计模式学来的作法,这种设计模式称为pub-sub(发布-订阅)系统
,在Flux架构中就有这个设计,Redux中也有,不过它更简化了整个流程。代码也只有一行:
// 订阅render到store,这会让store中如果有新的state(状态)时,会重新调用一次render()
store.subscribe(render)
第七步,触发事件的时候要调用store.dispatch(action)
。在我们的这个简单的例子中,唯一会触发事件就是按下那个加入文字的按钮,按下后除了要抓取文本框的文字外,另外就是要调用store
要进行哪一个action
,这个动作用的是store.dispatch
方法,把action
值传入,action的格式上面有看到过了。代码如下:
// 监听事件到 "itemadd" 按钮,
// 点按按钮会触发 store.dispatch(action),发送一个动作,
// 例如 store.dispatch({ type: 'ADD_ITEM', textValue })
document.getElementById('itemadd')
.addEventListener('click', () => {
const itemText = document.getElementById('itemtext')
// 调用store dispatch方法
store.dispatch({ type: 'ADD_ITEM', text: itemText.value })
// 清空文本输入框中的字
itemText.value = ''
})
以上就是这七个步骤,在这个简单的小程序,你要套用Redux这个规模化的架构,自然是有些杀鸡用牛刀的感觉,但我们的目的是要学习它是怎么运作的,你可以看到整个运作的核心就是store
,数据(state)在里面,要与里面的数据(state)更动,也是得用store
附带的方法才行。实际上到React中也是类似的运作方式,不过因为又加了一些额外的辅助套件,会比目前看到的还会复杂些,基本的运作逻辑都差不多。
Redux中的store是一个保存整个应用state对象树的对象,其中包含了几个方法,它的原型如下:
type Store = {
dispatch: Dispatch
getState: () => State
subscribe: (listener: () => void) => () => void
replaceReducer: (reducer: Reducer) => void
}
各方法的解说,其中最重要的是前面两个,subscribe
之后在React中不需要使用:
- dispatch 用于发送action(动作)使用的的方法
- getState 取得目前state的方法
- subscribe 注册一个回调函数,当state有更动时会调用它
- replaceReducer 高级API,用于动态加载其它的reducer,一般情况不会用到
以下分别解说这三个会用到的方法,其中的S代表状态(State),A代表动作(Action)这两种自订的类型。
getState方法
getState(): S
回传目前state树的数据,相当于reducer最后回传出来的值。
dispatch方法
dispatch: (action: A) => A
dispatch是唯一可以触发更动state的方法。
dispatch一发送动作,store中的reducer将会同步传入目前的状态(getState()
),以及给定的action两者,开始计算新的状态并回传。回传后,更动的监听目标将会被通知(用subscribe注册的回调),再次调用getState()
可以得到新的状态值。
dispatch方法的传入类型是一个action(动作)对象,回传的类型也是一个action(动作),看起来好像有些多馀,在真实开发的情况中这中间有经过Action Creator(动作创建器)的设计,Action Creator(动作创建器)可以针对传入的action(动作),进行预先的处理,或是可以再透过中介软体(middleware)处理有副作用的动作,在处理数据后,确保action是纯对象再进入reducer作状态的更动。
所以dispatch方法中的传参通常是一个Action Creator的调用,这种样式它有个名称,叫作"函数合成",在中介软体中也有用类似的语法样式,JS语言中原本就可以这样作,这是一种在合并不同抽象逻辑很有用的工具,例如下面的例子:
function a(x) { return x*x }
function b(x) { return x*2 }
function c(x) { return x+1 }
a(b(c(10)))
实际来看dispatch的用法,以下的例子来自Redux官网,我加上了注解说明:
import { createStore } from 'redux'
// 由todos这个reducer创建store
// 第二个传参是初始的状态,是可选的
let store = createStore(todos, [ 'Use Redux' ])
// Action Creator(动作创建器)
function addTodo(text) {
return {
type: 'ADD_TODO',
text
}
}
// 用store.dispatch(action)发送动作
// 传参先用Action Creator(动作创建器)来创建出action的对象格式
store.dispatch(addTodo('Read the docs'))
store.dispatch(addTodo('Read about the middleware'))
dispatch方法在使用有一些限制,首要注意的不能在reducer中调用dispatch,这会产生错误,因为dispatch的整个过程从触发开始,到最后更动完状态,reducer算是dispatch动作的中途过程。订阅通常会在reducer回传新的状态后被调用,dispatch调用的位置可能通常会在订阅的监听者(subscription listeners)代码中。
reducerReducer<S, A> = (state: S, action: A) => S
上面是reducer的原型,初学Redux第一个面临的难题是reducer,reducer要求的是纯函数而且无副作用,大部份的初学者对于FP(函数式编程)的风格并不太熟悉。
要写出合适的reducer是需要经过实作练习与思考的,要先考量的是状态模型(State Shape),也就是具体的状态该是什么样的数据结构,
状态模型(State Shape)
在简单的应用中,例如一个Todo(待办事项)的应用,它在应用中的状态只会有一个记录每笔事项的数组,像下面这样:
const todos = [
{id: 1, text: 'buy car'},
{id: 2, text: 'learn redux' },
...
]
但在一个博客应用中,状态的模型就会复杂得多,像是下面这样的数组,其中的对象会有嵌套的深层数据结构:
const blogPosts = [
{
"id": "123",
"author": {
"id": "1",
"name": "Paul"
},
"title": "My awesome blog post",
"comments": [
{
"id": "324",
"commenter": {
"id": "2",
"name": "Nicole"
}
}
]
},
]
这样的结构建议要使用像normalizr库进行正规化,转变为下面的结构:
{
result: "123",
entities: {
"articles": {
"123": {
id: "123",
author: "1",
title: "My awesome blog post",
comments: [ "324" ]
}
},
"users": {
"1": { "id": "1", "name": "Paul" },
"2": { "id": "2", "name": "Nicole" }
},
"comments": {
"324": { id: "324", "commenter": "2" }
}
}
}
这在Redux中会更容易对数据进行处理,这部份属于高级的主题,有很多解决的方式,但这里先提出来说明一下。
状态的更动
reducer既然是FP的作法,在对状态的更动编程,也会使用FP的编写方式来撰写,最基本的几个例子,在这里先提出来。
首先有一个大的基本原则,就是用拷贝传入参数值的方式处理,这是一个通用的方式,不论是对象或是数组,能先掌握这基本的原则就不会乱了套。
第一个演示的例子,是要在reducer传入一个新的action,然后附加到原本的状态数组中,会这样写:
function addItem(state = [], action) {
return [action.payload, ...state]
}
这句[action.payload, ...state]
用的是ES6中的展开运算符的语法样式,写起来很简洁,它相当于下面的写法:
function addItem(state = [], action) {
// 拷贝出一个新的数组
// newState = [...state]语法也可以
// newState = state.concat()也可以
const newState = state.slice()
// 附加新成员在新的数组前面,
// 注意这是一个有副作用的数组方法
newState.unshift(action.payload)
// 回传处理过的新数组
return newState
}
当然你也可以用for语句来写这个处理的程序,不过会愈写代码愈长,FP的风格会追求简洁,尽可能利用无副作的JS内建方法,这部份是需要经过练习与学习的。
第二个演示的例子是要从数组中删除其中一个索引值为action.index
的成员,会这样写:
function removeItem(state = [], action) {
return state.filter((item, index) => index !== action.index)
}
用filter
这个JS中内建的数组方法,可能对初学者来说没那么直观,filter
会依照回调传参布尔结果进行过滤,产生一个全新的数组。
如果你不用这语法会怎么写?要用slice
这个用来拆分一个数组的为子数组的方法,像下面这样,你会发现代码又开始变长了:
function removeItem(state, action) {
return [
...state.slice(0, action.index),
...state.slice(action.index + 1)
]
}
当然你也可以用for语句来写,只要能保持上面说的原则,不要去更动到传入的state数组就可以了。
其它的还有在数组其中的一个索引值中进行插入,以及更动其中一个索引值的成员值(通常是对象),这部份就留作练习参考,我把两个语法写出来:
// 插入数组的其中一个索引值
function insertItem(state, action) {
return [
...state.slice(0, action.index),
action.item,
...state.slice(action.index)
]
}
function updateObjectInArray(state, action) {
return state.map( (item, index) => {
if(index !== action.index) {
// 这不是要更动的数组成员,直接回传
return item;
}
// 这是要更动的数组成员,作合成
return {
...item,
...action.item
}
})
}
至于如果state是一个对象值时,用的也是拷贝出一个新的对象的语法,这通常是使用Object.assign
这个JS的内建方法来作,例如以下的例子:
const initialState = {
fetching: false,
list: []
}
function addUser(state = initialState, action) {
return Object.assign({}, state, { fetching: true })
}
如果是复杂的状态对象,你要更动里面的数据,改采用Immutable.js来作会比较便利。如果最上层的状态并非JS的纯对象,还另外改用redux-immutable。
注意: 上述的数组与对象的拷贝都是浅拷贝的语法,深拷贝需要用自订撰写或使用额外的函数库来作。
热门评论
读了您的5篇关于redux的文章,获益匪浅,深表感谢!
有空可否出一篇关于react-redux的文章?