本文将会从前端状态治理的由来说起,而后简略介绍作为 san 的状态管理工具 san-store 的实现思维,接着将介绍工夫旅行的概念以及与状态管理工具的关系,最初将介绍针对 san-store 的工夫旅行的实现思路与关键技术点。
为什么须要状态治理
组件化的思维对于前端来说是一大提高,它使得编写高内聚,低耦合的代码更加容易。同时随着各个框架的呈现,使得开发者不须要过多思考底层的 DOM 操作,专一数据状态的流转与解决。然而组件化开发还是有其痛点所在,抛开调试与单元测试来说,对于业务性能影响最大的莫过于组件(模块)之间的数据共享(状态治理),因而催生出了十分多的状态管理工具:flux,redux,甚至 react 在框架层面提供了 API 供用户便捷的数据共享,然而正如 redux 作者 Mark Erikson[1] 所说,react hook 并非一个状态管理系统:
useReducer plus useContext 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 公布外部数据变动的音讯,告诉所有观察者组件进行数据的更新与后续逻辑。
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 来看,因为与本文的主题关系不大,因而这里将不会介绍其原理。
工夫旅行
上文介绍了服务于 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 的惟一标记,咱们就可能通过下面的个步骤,将页面还原到之前某个时刻的页面状态。
总结
本文介绍了为什么须要状态治理,简要剖析了状态管理系统 flux 的单项数据流模型,其中简要介绍了罕用的基本概念:action,dispatcher,store,view 等。接着介绍了基于 flux 模型的 san-store。最初介绍了 san-devtools 是如何基于 san-store 实现工夫旅行性能的。咱们这里所介绍的工夫旅行是通过更新页面的数据来回退到之前的页面状态,这种实现形式在遇到简单的场景比方状态自身波及了随机性的数据,那么页面状态是无奈准确还原的,目前能够通过保留页面快照来解决这样的问题,然而同时带来了新的问题,页面快照是一张页面截图,每次 action 触发都须要保留图片,回放的时候须要加载图片,无论从内存思考还是响应速度思考,体验会大打折扣。因而还须要在工夫旅行的实现计划上或者还有须要做更多的思考。
7. 参考资料
[1] markerikson: https://changelog.com/person/…
点击进入取得更多技术信息~~