人从出生到死亡走的这段路程,称为生命周期。
应用从启动到关闭经历的这段过程,也称为生命周期——因此这是一个仿生概念,基于相应结构的应用也会有与人类相似的行为特点。
初接触时,我们会为如何去更好的在这个过程中去实践状态管理焦头烂额,纠结于不同架构的各个节点对应的职责,特别是在涉及异步和副作用的处理的过程中,很难快速找到一个“最佳实践”。那么,既然应用具有仿生设计,我们自然可以从基于作为人类的自身的角度去理解它。
状态(State)
先来看一张图:
从上图可以看到,如果将一些过程和行为抽象出来,人与 App 是具有高度的相似性的。
其中:
Stage | Human | App |
---|---|---|
Born/Startup | 新生儿出现,有了人类初始特征,大脑开始工作后,便逐渐的产生本能、认知、意识、反应、情绪等元素,它们便是我们的初始状态。 | 注册进程或线程,静态资源加载,支撑应用的各个要素就绪,根据缓存或预置规则定义应用的初始状态 |
EE/FID | 早期教育,为投身到更复杂的环境层层递进的准备 | 获取初始数据,为更定制化的运行策略做准备 |
WL/RI | 不断学习、工作、修身、社交,处理事件 | 在运行过程中与后台交互、与用户交互、视图与状态的同步、与其他应用的交互,处理事件 |
Retirement/Unmount | 退休、处理各项工作时的羁绊、夕阳红、遗产处理 | 卸载服务、处理副作用、也启动一些服务(最令人发指的)、缓存等资源的处理 |
从书面意思理解:
<p align="center"><b>状态是人或事物表现出来的形态。是指现实(或虚拟)事物处于生成、生存、发展、消亡时期或各转化临界点时的形态或事物态势。</b></p>
简单来说,就是任一对象在特定状况下的存在形态。
具体到人,可以是情绪、职业、资产等。
具体到应用,可以是一些布尔值、状态码、具体数据等。
由这些可以对对象进行描述的单元结合,就构成人或应用。
全局状态(Global State)
- 在人类的角度来看,我们刚形成胚胎便会有了性别、肤色、瞳色等基因决定的特征,我们因而为人,这些生理特征体现在我们生命历程中的任何一个时刻。
- 对于应用来说,静态资源被运行环境执行的过程,就好比胚胎的生长过程,然后到了初始化状态容器(Store)的时候,便开始获取初始数据,这些数据就包含了一系列初始状态,它们可以是可用性、登录状态、角色策略、颜色主题、语言环境等等。
在人身上,本能、认知等因素往往是伴随一生的,他们的有效性是覆盖到所有其他情形下的,比如我吃饭的时候不知道美国总统是特朗普,那么我上厕所睡觉的时候同样不会知道;有一天我在吃饭的时候得知了这个消息,那么从此我上厕所睡觉的时候同样也知道了。
在应用中,登录状态、颜色主题、语言环境等也有这样的特点。在一个应用周期内,每一次修改这些状态,都是会应用到全局的,至少是主体同步。
对于这些状态,我们统称为全局状态
局部状态(Module/Feature/Partial)
我们上厕所的时候,一般会向”抽纸盒“发起请求,然后拿几张纸,这是在如厕时的”后事“预备状态;而我们在大街上则不会同样拿着纸准备擦屁股,我们可能因为口渴拿着水,因为购物拿着包袋。
在应用里,具有不同职责的页面展示的内容也不同,我们不会没事儿在首页展示用户的优惠券详情,也不会没事儿在课程详情页展示用户余额。
在这些具有不同职责的场景下“独有”的状态,我们称为局部状态
全局状态和局部状态的划分
两种状态的特点其实很好理解,其实它们的划分才是难点。
状态本身就具有“全局性”,因为状态一旦拿到,那么不管是否是在对应场景,它都存在于本次生命周期中,它随时可能在新需求来到的时候被其他场景需要,而你不一定总是能够事先知晓。
当然了,像用户信息、登录时间、语言环境等因素是很好区分的,但更多更细的状态是否需要放到全局,或者说由公共性更高的模块来管理,就很难一次性下定论了。
因此界定某个状态的类型,并不是一蹴而就的,而是要在长期的迭代中进行总结。
对于人来说,这个问题不算是个问题,因为我们拥有强大的复杂问题处理能力,而计算机几乎是没有这样的能力的,它们处理问题的方式都是人为定义的,即便 AI、ML 等技术蓬勃如今,也远远达不到人类的思维水平。
我们以一个真实应用里的一些实现为例:
这是 WPS精品课 产品的移动端 Web App。
以职责划分
第一张图中,在两个功能不同的页面(场景)中都出现了分类这一数据形式,并且数据是一致的,也就是说,这两个页面出现了公共状态。而这两个公共状态总是覆盖全局的,那么我们就应当将它们提升到更高一层的状态模块中。
注意,提升到更高一层并不意味着提升到 global 的级别。有时候,可能分类并不是一个简单的数据,它可能是根据不同的用户策略进行展示的,对于不同的用户级别,分类可能会呈现多态(比如普通用户看不到 VIP 专属的类别)。从前后端交互的角度来看,分类相关的接口往往也是独立于其他数据的。因此,当分类具有了一定的复杂性和具体规则,它应当有属于自己的管理单元,使得数据的吞吐和处理有更加清晰的思路,而不是去破坏性的影响全局状态的职责。
就像人类在左右脑的统一调配下,有视觉中枢、听觉中枢、运动中枢。如果它们产生了紊乱,使得脑功能失调,人就会出现各种各样的问题,如少儿多动症、认知障碍等。
以路由划分
为什么是一个圆圈呢?
其实这个页面虽然常见,但在状态管理中,它的确比较特殊,因为它既是一个路由单元,又是一个状态单元。如果说一个页面通常是由多个状态组合而成的,那么“我的”页面可能就只需要一个状态就够了——即用户状态。它往往包含了用户基本信息,信用卡信息,功能定制等——是的,往往我们就把它们放在 global 中。当然根据应用的类型和复杂度不同,用户信息也可能划分成若干单元,因此,如此形式的按路由划分,其实是按职责划分的一个变种。
然而,还有一种情况就不同了。比如某些活动型页面,它们可能只包含一些运营内容,有一套自己的逻辑和交互,独立于任何其他的页面,但页面本身的生命周期或许只有几个星期甚至几天,这时候可能就没有必要为其设计和维护一个状态单元了,得不偿失。
好比一个人要出国旅游一段时间,立马给手机开了一系列便捷的境外服务,但回国后往往就立马停掉了,而不是为这些长期用不到的东西付费。
改变状态
我们已经探讨了关于状态的一些基础内容,现在问题来到了如何对状态进行“改查”。
首先,状态是对象在特定环境和具体时机下的某种存在表现,随着环境和时机的改变,它便会发生相应的更新。
我们拿“时间”举例,它是最客观最不可阻挡的状态流。
对于人类,在一个时间单元内(指,年、月、日等)我们会根据具体的时间点调整我们自身的状态——睡觉、起床、工作、小憩等等;
对于应用,最常见的就是一些即时服务的开关。比如某购物 App,白天一直到晚上九点会有针对会员的“一小时送达”的服务,但过了这个时间点,这个服务便进入休眠状态。
那么从外部条件改变到对象自身的状态更改,中间经历了什么呢?
我们来看几个当下炙手可热的前端数据流模型:
So You See!
这里面似乎有一个恒定的范式:
<p align="center"><b>Action - Update state</b></p>
Store 和 State
Store
是状态中心,而State
就是这里面的一个个状态集合。
在 Flux 和 Redux 的模型中,我们可以显式的看到Store
节点,而 Mobx 和 Vuex 里这个节点似乎由State
代替了。这个是由于两种风格不同的状态声明方式导致的,这里以最常见的 Redux 和 Vuex 的Store
构建方式为例:
可以看到,源于 Flux 思想的 Redux 的Store
声明过程更像是将各个独立的状态单元(Reducer,详见后文)整合(combine)在一起,形成一个自Store
而下的状态树,实现单向数据流。其工作特点是所有的行为都要经过Store
。
PS:其中的Action Handlers
的实际形式其实是switch
语法下的一个个模式,并非具体函数或方法,这里只是根据其职责进行了类比理解。
而 Vuex 呢,其实也是源于 Flux 的,但它吸取了 Redux 样板代码繁琐的“教训”,将combine
的过程用声明的方式规避了,同时将状态单元细分成各个module
,每个module
包含了一套State
和对应的规则,比起 Redux 来说,是一种“高类聚、低耦合”的方案,节省了一些声明和管理状态的成本。工作特点眼下就是各个module
各司其职,只影响自己的State
。
然而,Vuex 在实际的工作过程中,其实还是由Store
作为中心进行分发,只是其构建方式让我们觉得Store
并没有被总是调起。
总的来说,Store
的地位如同我们的大脑,我们的任何决策、行为都会经过大脑进行评估、加工。但随着某种刺激的不断触发,其对应的反应行为也会出现得越来越快,等到形成相对固定的范式的时候,我们可能就感觉不到思维在这个过程中的行动了,体现为“反应快”。
对于普通应用开发来说,我们则可以直接定义这种“范式”,这更有利于我们整体上的把握应用的规则,强化和优化应用的逻辑。良好的状态管理实践会让应用更加高效,也更好维护。
Action
在上面的几种数据流模型中,在对状态进行修改前,都会经过一个叫Action的节点,这个节点我们可以理解成行为。
Action 即是向Store
发起更新请求的最小单元。
它的结构通常是:
// pureObject
const myAction = {
type: 'GO_TO_BED',
payload: Medicine.Estazolam
}
// functional
const myFunctionalAction = arg => {
let payload
// TODO
return {
type: 'GO_TO_BED',
payload
}
}
其中:
- type: 对这个行为的描述,Store 根据这个字段去寻找对应的处理方案
- payload?:荷载,携带实现该行为要使用的一些数据
这个比较好理解,要做一件事,得先明确这是什么事,如果有需要还要带上相应的东西。比如:大便要带纸;而小便可能带,也可能不带;只是去洗手就什么都不用带了。
可见,Action
最终只是一个对象,那它如何传递给Store
呢?
Dispatch
我们完成一个“刺激——反应”的时候,通常先是神经末梢收到接收刺激,然后大脑得到神经末梢发来的信息,做出反应。在这个过程中,携带信息的介质被称为神经递质,它活动在突触之间。
而在各类应用状态管理的模型中,通常都会有一个dispatch
方法,它就声明在Store
上,负责调用各个Action
,然后由Store
上对应的分发机制进行处理。同时,异步Action
的实现,即是将这个方法作为参数传给对应的ActionCreator
,然后等到异步工作流完成后,将最终的Action
传递给Store
。例如构建一个redux-thunk
中的异步Action
:
const asyncAction = id => {
// 集成 redux-thunk 后,redux 会将 dispatch 等一系列方法传递给 actionCreator 返回的函数,供异步工作完成后 actionCreator 能配合 Redux 进行工作
return dispatch => {
fetch('/getData?id=' + id)
.then(response => response.json())
.then(data => {
dispatch({
type: 'SET_DATA',
payload: data
})
})
}
}
Reducer / Mutation
现在到了更新状态的时候了,简单抽象出来就是newState = updatedState
,不难理解,主要看下实现。
在 Flux 和 Mobx 的模型中,对状态的修改比较直接,不多赘述,那么“矫情”一些的 Redux 和 Vuex 是如何实践的呢。
我们从其实现上分别说明它们的作用
Reducer
先来看一个简单 Reducer 实现:
function myReducer (state = { age: 1 }, action) {
switch (action.type) {
case 'HappyBirthDay': return {
age: ++state.age
}
default: return Object.assign({}, state)
}
}
Reducer 的工作方式是,接收一个Action
,然后在 switch 流中匹配action.type
,做出相应处理,然后返回一个新的对象。其源码可以看这里
为什么是新的对象呢?
因为 Redux 是一个实践函数式编程(FP)理念的库。函数式编程有个要素就是——纯函数不能有副作用,而副作用简单概括来说就是对该函数内部环境以外的变量进行了修改、销毁等操作。
回过头来,在 Redux 中,Reducer 原则上就是一个纯函数。
这有什么意义呢?
答案是数据不可变,它也是函数式编程中的一个要点。
函数式编程认为可变和共享是“万恶之源”,原数据的更新只能通过返回新的数据。否则随意修改的数据可能让应用产生难以预料的问题,而“共享”加“可变”带来的副作用更是容易容易让我们得到错误并且难以捕获的内容。
Mutaion
Vuex 是在Mutation
中修改状态的,其代码一般如下:
// module
export default {
//...
mutations: {
SET_DATA (state, payload) {
state.data = payload
}
},
actions: {
async getData ({ commit }, payload) {
const res = await api.data.get(payload.id)()
commit('SET_DATA', res.data)
}
}
}
// component
export default {
mounted () {
store.dispatch('getData', this.id)
}
}
其中,触发一个Action
依然是通过dispatch
方法,然而,修改状态为什么需要commit
一下呢?
其实我们直须将mutations
和actions
中的各个成员都理解成Action
,因为你也可以直接在Store
上调用commit
来修改状态。 commit
的职责相当简单,就是修改本地状态。
而Action
的职责在于可以实现异步流和Action流(在action中dispatch又一个(可以是自己)action),最后提交到Mutation
中来修改状态。但从源码来看,其实 Vuex 同样赋予了Action
改变状态的能力,它将State
作为第一个参数的其中一个属性传递给了Action
,其目的是为了你可以使用State
上的状态和数据,原则上这是只读的,但结合 MVVM 的特点,你在这里修改它,同样也会引起视图的改变。
源码里是这样写的:
function registerMutation (store, type, handler, local) {
const entry = store._mutations[type] || (store._mutations[type] = [])
entry.push(function wrappedMutationHandler (payload) {
// store 会调用 commit 方法来启用这个 mutation,并且只传入了本地 state 和一系列荷载
handler.call(store, local.state, payload)
})
}
function registerAction (store, type, handler, local) {
const entry = store._actions[type] || (store._actions[type] = [])
entry.push(function wrappedActionHandler (payload, cb) {
// action 就比较厉害了,这么多...
let res = handler.call(store, {
dispatch: local.dispatch,
commit: local.commit,
getters: local.getters,
state: local.state,
rootGetters: store.getters,
rootState: store.state
}, payload, cb)
if (!isPromise(res)) {
res = Promise.resolve(res)
}
if (store._devtoolHook) {
return res.catch(err => {
store._devtoolHook.emit('vuex:error', err)
throw err
})
} else {
return res
}
})
}
可见,你甚至可以不顾一切的在Action
中修改全局的State
。
在行为心理学中,其中一种行为的分类方式即是将行为分为外显行为和内隐行为。外显行为就是我们肉眼可见的,有明确外在表现的行为;内隐行为则是外表之下,发生于机体内部的情绪变化、思维运作、激素分泌等不会彰显出来的行为。但往往我们改变大脑中的某个状态,使之显于或不显于我们的姿态的时候,这些内隐行为是不可能避免的,因为我们的大脑活动就是各类递质工作下的一系列的化学反应。
这与Action
和Mutation
的关系很像,Vuex 告诉我们修改状态的唯一方法是提交Mutation
,也就是说你不应该在Action
中的直接修改State
,就好像我们的外显行为总是要经过内隐行为来提交给大脑一样。
当然了,既然职责不同,角色肯定就不同,理解成Action
是为了我们便于理解。
再次提醒,Vuex 明确告诉我们改变状态的唯一方法是提交Mutation
,因此我们应当遵循这个原则,将Action
中的各个响应式引用视为只读,以保证应用的逻辑性不会被破坏。(当然,直接 commit 啦,想想mapMutations
方法!)
总结
通过一张图来梳理一下状态管理与人类行为的共通之处:
PS
这不是一篇论述,状态管理也远远不止这几种模型,小生仅仅在前端应用及其比较有代表性的状态管理方案的背景下分享了这个角度,因此这个理解方式必然有一定的局限性或者是未被完全论证。如果能帮助到读者,小生就非常荣幸了。
理解状态管理的方式有很多,这只是其中一种思路,或许这种思路能在应用开发的同时也锻炼我们的逻辑思维。
同时,实际场景下的状态管理必然一个更加复杂的东西,随着应用的规模和深度越来越大,我们需要更深刻思考它,如何划分模块?如何共享模块?如何构建容器?如何提升效率?这都是需要逐步探索的,当然,最快的方式,就是在已有的状态管理范式中思考,组织、优化。
最后,要记住的是:
<p align="center"><b>你可能不需要状态管理</b></p>