本文将会从前端状态管理的由来说起,然后简单介绍作为 san
的状态管理工具 san-store
的实现思想,接着将介绍时间旅行的概念以及与状态管理工具的关系,最后将介绍针对 san-store
的时间旅行的实现思路与关键技术点。
01 为什么需要状态管理
组件化的思想对于前端来说是一大进步,它使得编写高内聚,低耦合的代码更加容易。同时随着各个框架的出现,使得开发者不需要过多考虑底层的 DOM 操作,专注数据状态的流转与处理。但是组件化开发还是有其痛点所在,抛开调试与单元测试来说,对于业务功能影响最大的莫过于组件(模块)之间的数据共享(状态管理),因此催生出了非常多的状态管理工具: flux
,redux
,甚至 react
在框架层面提供了 API 供用户便捷的数据共享,但是正如 redux
作者 Mark Erikson[1] 所说,react hook
并非一个状态管理系统:
useReducer
plususeContext
together kind of make up a state management system. And that one is more equivalent to what Redux does with React, but Context by itself is not a state management system.
无论是 flux
还是 redux
强调的都是单向数据流 (unidirectional data flow),目的有三个:
- 提高数据的一致性,让状态变化可控
- 更容易找出 BUG 的根源
- 使得单元测更有意义
在上图中,左右两图为使用 store
前后的数据流示意图,兄弟节点之间的状态传递不再依靠 事件 / 回掉 /props 的方式实现,而是通过统一的 store
进行管理。这样减少了组件之间的耦合,并且对数据部分进行单测更有意义与便捷。
下图为 flux
的单向数据流示意图,action
为一个简单的对象,包含了新的数据以及对应的操作类型。当用于交互的时候,视图可以产生一个 action
来修改视图。所有的状态数据都会流经中心枢纽 dispatcher
,接着 dispatcher
将会执行在 store
中注册的回调函数,在这些回调函数中,store
会处理每一个 action
中传递的状态数据。然后 store
将会向视图层派发一个数据变更的事件。视图层接收到事件之后,会向 store
获取各自关注的数据,获取之后执行视图层会利用前端框架的数据响应机制更新视图。如果是 react
则利用 setState/hook
更新数据,然后有需要更新的组件会被重新渲染;如果是 vue
则可以直接修改实例的值,通过其响应式机制更新视图;如果是 san
,则通过 this.data.set
等方式修改数据并触发视图更新。
上述流程中利用到了发布订阅设计模式以及观察者模式。发布订阅模式是视图层作为消息发布者通过 dispatch
通知 store
中存储的 action
订阅者。而观察者模式则是被观察者 store
发布内部数据变化的消息,通知所有观察者组件进行数据的更新与后续逻辑。
02 San中的状态管理
在 san
应用中,我们通常使用 san-store
作为应用的状态管理系统。该系统遵循了 flux
的架构,实现了上述流程,下图为其数据流示意图:
使用方式也非常简单,代码如下:
import {store, connect} from 'san-store';
import {builder} from 'san-update';
// 注册 action
store.addAction('changeUserName', function (name) {
return builder().set('user.name', name);
});
// 订阅数据变化
let UserNameEditor = connect.san({
name: 'user.name'
})(san.defineComponent({
template: '<div>{{name}}</div>',
submit() {
// 触发 action
store.dispatch('changeUserName', this.data.get('name'));
}}));
我们对 san
的语法做一个简单的介绍,san.defineComponent
用于生成一个组件,该函数接收的对象中 template
是组件模板,用于渲染来自 san-store
中的状态数据 name
。上述代码的整体流程分为两个阶段:
- 注册 action 以及订阅数据的变化:通过
san-store
提供的addAction
注册一个action
处理函数;组件通过connect
来订阅san-store
中数据的变化。 - 组件触发 action 以及更新视图:组件调用
dispatch
方法,需要传入action
的名称以及相关的payload
。san-store
会根据action
名称调用之前注册的处理函数,并将payload
传递给该处理函数。处理函数经过计算之后得到新的state
,然后利用san-update
生成并返回一个数据更新的执行函数。san-store
获取到该执行函数之后,将当前的state
传递给该执行函数,从而得到diff
数据,以及新的state
,并且san-store
会将新旧state
以及两者之间的diff
数据存储下来,最后发布数据变化的消息,依次触发订阅了函数变化的组件的数据更新机制。
上文提到的 san-update
主要是用于确保数据不可变,有兴趣的同学可以对比着 immer
来看,由于与本文的主题关系不大,因此这里将不会介绍其原理。
03 时间旅行
上文介绍了服务于 san 应用的状态管理工具san-store
的实现思路以及使用方式,那么状态管理与时间旅行之间的有什么关系呢?其实,早在 2015 年 Dan Abramov 就展示了通过redux-devtools 让开发者在历史状态中自由穿梭,并称之为时间旅行。简而言之,时间旅行的目的就是为了方便开发者能够轻松调试使用了状态管理工具的前端应用。下文将会介绍如何针对 san-store 实现时间旅行的功能。
什么是时间旅行
根据维基百科中所描述的:时间旅行泛指人或物体由某一时间点移至另一时间点,通俗的来讲就是回退。我们这里所说的时间旅行就是希望将应用恢复到之前某一个 action
发生时的状态,就像回放录像带那样简单。
为什需要时间旅行
那么为什么需要时间旅行呢,很多时候我们页面中的状态由多个 action
共同决定,当最终的结果出现问题的时候,我们可能会需要回到某个 action
触发的时刻,检查页面的状态以及对应的数据。所以在某些时刻,时间旅行能让我们更快速的发现问题。在调试工具 san-devtools
中我们已经实现了针对 san-store
的时间旅行的功能,下面我们简要介绍其实现原理。
实现时间旅行思路
其实通过之前介绍的 flux
的思想,让一切状态可预测,那么很容易能想到,既然状态数据是可控可预测的,那么我们就可以让页面的状态会到之前的某个时刻的状态。
根据上一小节,我们知道组件需要主动调用 store.dispatch
来触发 store
的数据更新,但是时间旅行不能主动调用 dispatch
触发 action
,而是直接将 store
的数据回退到某个时刻,然后主动触发视图更新。其原理图如下:
可以通过如下几个步骤来实现:
- 在每次
store state
变化的时候,存储新的state
以及旧的state
,称之为log
数据 - 获取某个
action
对应的log
数据 - 替换
store state
- 计算出新旧
state
的diff
数据 - 主动触发组件视图更新
其中第一步已经由 san-store
完成了,我们后续只需要关注后面的四个步骤。整个过程最简单的实现方式就是 利用 monkey patch 来替换掉 san-store
中的原型方法与属性。其中第 4 步的处理方式非常关键,对两棵树进行精确 diff 的时间复杂度在 O(n^3)
,显然是不可取的。那么我们应该如何处理呢?如果我们换个角度思考,如果我们只关心组件中需要从 store 中获取哪些字段的数据,那么 n 个字段的 diff,时间复杂度为 O(n)
。在上一节例子中组件只在 user.name
数据发生变化的时候更新视图。因为第四步的关键不是新旧 state 的完整 diff,而是收集所有涉及视图更新的 store 中的字段。那么下面,如果你对这部分的代码感兴趣,那么请接着下面的阅读。否则可以直接跳到总结与展望。
获取 log 数据
当阅读到这里的时候,确保你已经阅读了解了 san-store
的代码,下文代码涉及的关键变量的含义如下:
- store:
san-store
实例 - store.stateChangeLogs :保存的状态快照数据
- store.raw:当前应用的状态数据
- paths:存储了状态树中某个属性的路径
当我们获取到需要回退的 actionId
之后,首先需要获取对应的 log
数据,getStateFromStateLogs
的实现如下:
private getStateFromStateLogs(id) {
const logs = store && store.stateChangeLogs;
if (!Array.isArray(logs)) {
return null;
}
return logs.find(item => id === item.id);
}
替换 state
由于 store.raw
存储了 state
数据,因此我们可以直接用目标 state
进行赋值即可,但是页面状态如果在已经处于某个回退的状态,那么新触发的 action
应该基于非回退状态,所以我们需要将回退的状态单独存储。下面的代码会在 san-store
发送 store-default-inited
消息的时候会执行。
private decorateStore() {
if ('sanDevtoolsRaw' in store) {
return;
}
const storeProto = Object.getPrototypeOf(store);
const oldProtoFn = storeProto.dispatch;
storeProto.dispatch = function (...args: any) {
this.traveledState = null;
return oldProtoFn.call(this, ...args);
};
store.sanDevtoolsRaw = store.raw;
Object.defineProperty(store, 'raw', {
get() {
if (store.traveledState) {
return store.traveledState;
}
return this.sanDevtoolsRaw;
},
set(state) {
this.sanDevtoolsRaw = state;
}
});
}
接着,我们通过下面的方式替换 san-store
中的 state
:
private replaceState(state) {
store.traveledState = state;
}
计算 diff 数据
从 diff
算法的时间复杂度来看,全量 diff
新旧 state
显然是不可取的,因此我们只需要关心那些被订阅的数据,由于在组件订阅数据变化的时候,会显示的申明数据的来源,比如上面例子中的 user.name
,所以当 san-store
发送 store-listened
消息的时候,我们需要调用 collectMapStatePath
将 mapStates
的数据收集起来,代码如下:
collectMapStatePath(mapStates) {
if (Object.prototype.toString.call(mapStates).toLocaleLowerCase() !== '[object object]') {
return;
}
Object.values(mapStates).reduce((prev, cur) => {
const key = cur;
const value = cur.split('.');
prev[key] = value;
return prev;
}, paths);
}
当需要计算两个 state
的 diff
数据的时候,只需要按照 this.paths
中存储的 mapStates
来计算,getDiff
的代码如下:
getDiff(newValue, oldValue, mapStatesPaths) {
const diffs = [];
for (let stateName in mapStatesPaths) {
if (mapStatesPaths.hasOwnProperty(stateName)) {
const path = mapStatesPaths[stateName];
const newData = getValueByPath(newValue, path);
const oldData = getValueByPath(oldValue, path);
let diff;
if (oldData !== undefined && newData !== undefined && newData !== oldData) {
diff = {$change: 'change',newValue: newData,oldValue: oldData,target: pat};
} else if (oldData === undefined && newData !== undefined) {
diff = {$change: 'add',newValue: newData,oldValue: oldData,target: path};
} else if (oldData !== undefined && newData === undefined) {
diff = {$change: 'remove',newValue: newData,oldValue: oldData,target: path};
}
diff && diffs.push(diff);
}
}
return diffs;
}
其中省略的 getValueByPath
函数用于从一个对象中,按照指定的路径获取对应的属性值。 diff
数据有三种操作类型:
- change:修改值
- add:添加属性
- remove:删除属性
san-store
会按照这几种类型调用 san
组件的不同类型的数据操作指令,对组件中的 state
进行增删改查。
触发试图更新
当 diff
数据计算完成之后,需要主动调用 san-store
提供的_fire
方法通知所有订阅了数据变化的组件,进行相应的更新操作。当 diff
数据的操作类型是 change
的时候,会通过 this.data.set
修改属性值,当 diff
数据的操作类型是 add
或者 remove
的时候,会通过 this.data.splice
添加或者删除对应的属性。
最后 travelTo
的代码如下:
travelTo(id) {
if (!store || !store.stateChangeLogs || !paths) {
return;
}
// 根据 actionId 获取 state
const state = getStateFromStateLogs(id);
if (!state) {
return;
}
// 替换 state
replaceState(state.newValue);
// 根据 mapStates 计算数据 diff
const diffs = getDiff(state.newValue, store.traveledState, paths);
// 触发视图更新
store._fire(diffs);
return;
}
在 san-devtools
中,我们只需要主动调用 travelTo
,并传入某个 action
的唯一标记,我们就能够通过上面的个步骤,将页面还原到之前某个时刻的页面状态。
04 总结
本文介绍了为什么需要状态管理,简要分析了状态管理系统 flux
的单项数据流模型,其中简要介绍了常用的基本概念:action
,dispatcher
,store
,view
等。接着介绍了基于 flux
模型的 san-store
。最后介绍了 san-devtools
是如何基于 san-store
实现时间旅行功能的。我们这里所介绍的时间旅行是通过更新页面的数据来回退到之前的页面状态,这种实现方式在遇到复杂的场景比如状态本身涉及了随机性的数据,那么页面状态是无法精确还原的,目前可以通过保存页面快照来解决这样的问题,但是同时带来了新的问题,页面快照是一张页面截图,每次 action
触发都需要保存图片,回放的时候需要加载图片,无论从内存考虑还是响应速度考虑,体验会大打折扣。因此还需要在时间旅行的实现方案上或许还有需要做更多的思考。
——————END——————
参考资料:
[1] markerikson: https://changelog.com/person/...
推荐阅读:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。