本文将会从前端状态治理的由来说起,而后简略介绍作为 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/…
举荐浏览:
百度 App 低端机优化 - 启动性能优化(概述篇)
面向大规模数据的云端治理,百度桑田存储产品解析
加强剖析在百度统计的实际
基于 TLS 1.3 的百度平安通信协议 bdtls 介绍
百度用户产品流批一体的实时数仓实际
如何治理资源节约?百度云原生老本优化最佳实际
面向大数据存算拆散场景的数据湖减速计划