当前位置: 首页 > Web前端 > HTML

前端状态管理与时空穿梭:San实践

时间:2023-03-28 01:52:22 HTML

本文将从前端状态管理的起源说起,然后简单介绍一下san-store作为san状态管理工具的实现思路,然后介绍时间旅行的概念以及与状态管理工具的关系,最后介绍三店时间旅行的实现思路和关键技术点。为什么需要状态管理?组件化的思想对于前端来说是向前迈进了一大步,更容易写出高内聚低耦合的代码。同时,随着各种框架的出现,开发者无需过多考虑底层的DOM操作,专注于数据状态的流动和处理。但是,组件化开发还是有痛点的。除了调试和单元测试,对业务功能影响最大的是组件(模块)之间的数据共享(状态管理),由此催生了很多状态管理。工具:flux、redux,甚至react都提供了framework级别的API,方便用户共享数据,但是正如redux作者MarkErikson[1]所说,reacthook并不是一个状态管理系统:useReducer加上useContext有点凑合状态管理系统。这更类似于Redux对React的处理,但Context本身并不是一个状态管理系统。flux和redux都强调数据的单向流动,目的有3个:提高数据一致性,让状态变化可控,更容易找到bug根源,让单元测试更有意义。依靠事件/回报/道具来实现,但通过统一的商店进行管理。这样降低了组件之间的耦合度,对数据部分进行单元测试更有意义也更方便。下图是flux的单向数据流示意图。Action是一个包含新数据和相应操作类型的简单对象。当用于交互时,视图可以生成修改视图的动作。所有的状态数据都会流过中心枢纽调度器,然后调度器会执行注册在store中的回调函数。在这些回调函数中,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';//注册actionstore.addAction('changeUserName',function(name){returnbuilder().set('user.name',name);});//订阅数据变化letUserNameEditor=connect.san({name:'user.name'})(san.defineComponent({template:'

{{name}}}
',submit(){//触发动作store.dispatch('changeUserName',this.data.get('name'));}}));下面简单介绍一下san的语法。san.defineComponent用于生成组件。该函数接收到的对象中的模板为组件模板,用于渲染来自san-store的状态数据名称。上述代码整体流程分为注册action和订阅数据变化两个阶段:通过san-store提供的addAction注册一个actionhandler;组件通过connect订阅san-store中的数据变化。组件触发action并更新视图:组件调用dispatch方法,需要传入action的名称和相关的payload。san-store会根据action名称调用之前注册的处理函数,并将payload传递给处理函数。处理函数计算得到一个新的状态,然后使用san-update生成并返回一个执行函数进行数据更新。san-store获取执行函数后,将当前状态传递给执行函数,获取diff数据和新状态,san-store会存储新旧状态和两者的diff数据,以及最后发布数据变化的消息,进而触发订阅了功能变化的组件的数据更新机制。上面说的san-update主要是用来保证数据不可变的。有兴趣的同学可以和immer对比一下。由于与本文主题关系不大,这里不再介绍其原理。时间旅行上面介绍了服务于san应用的状态管理工具san-store的实现和使用,那么状态管理和时间旅行有什么关系呢?其实早在2015年,DanAbramov就展示了redux-devtools可以让开发者在历史状态中自由穿梭,并称之为时间旅行。简而言之,时间旅行的目的是方便开发者轻松调试使用状态管理工具的前端应用程序。下面将介绍如何实现san-store的时间穿梭功能。什么是时间旅行根据维基百科中的描述:时间旅行一般是指人或物体从某个时间点到另一个时间点的运动。我们这里说的时间旅行就是将应用程序恢复到之前某个动作发生时的状态,就像回放录像带一样简单。为什么需要时间旅行那么为什么需要时间旅行?很多时候我们页面的状态是由多个动作决定的。当最终结果出现问题时,我们可能需要回到动作触发的那一刻,检查页面状态和对应的数据。所以在某些时候,时间旅行可以让我们更快地发现问题。在调试工具san-devtools中,我们实现了san-store的时间穿梭功能。下面我们简单介绍一下它的实现原理。实现时间旅行的思路其实就是利用了前面介绍的flux的思路,让所有的状态都可以预测,所以很容易想象,既然状态数据是可控可预测的,那么我们就可以让页面的状态到达状态前的某个时刻。根据上一节,我们知道组件需要主动调用store.dispatch触发store数据更新,但是timetravel不能主动调用dispatch触发action,而是直接回滚store数据到某个时刻,然后主动触发view更新。示意图如下:可以通过以下步骤实现:每次store状态发生变化,存储新的状态和旧的状态,称为日志数据,获取一个action对应的日志数据,替换storestate来计算新旧状态的差异数据主动触发组件视图更新。san-store已经完成了第一步,我们只需要关注接下来的四步。实现整个过程最简单的方法是使用猴子补丁替换san-store中的原型方法和属性。第4步的处理方法非常关键。精确区分两棵树的时间复杂度是O(n^3),这显然不可取。那我们应该怎么处理呢?如果我们换个角度想,如果我们只关心组件中哪些字段需要从store中获取数据,那么diffn个字段的时间复杂度就是O(n)。在上一节的示例中,组件仅在user.name数据更改时更新视图。因为第四步的关键不是新旧状态的完整diff,而是收集store中所有涉及视图更新的字段。那么下面,如果你对这部分代码感兴趣,请继续往下阅读。否则,您可以直接跳到总结和展望。获取日志数据阅读本文时,请确保您已阅读并理解san-store的代码。以下代码中涉及的关键变量含义如下:store:san-store实例store.stateChangeLogs:保存的状态快照数据store.raw:当前应用的状态数据statetree当我们获取到需要回滚的actionId时,首先需要获取对应的日志数据。getStateFromStateLogs的实现如下:privategetStateFromStateLogs(id){constlogs=store&&store.stateChangeLogs;如果(!Array.isArray(logs)){返回null;}returnlogs.find(item=>id===item.id);}replacestate因为store.raw存储的是状态数据,所以我们可以直接使用目标状态赋值,但是如果页面状态已经在一个回滚状态,新触发的动作应该基于一个非回滚状态,所以我们需要单独存储回滚状态。下面的代码会在san-store发送store-default-inited消息时执行。privatedecorateStore(){if('sanDevtoolsRaw'instore){返回;}conststoreProto=Object.getPrototypeOf(store);constoldProtoFn=storeProto.dispatch;storeProto.dispatch=function(...args:any){这个。旅行状态=空;返回oldProtoFn.call(this,...args);};store.sanDevtoolsRaw=store.raw;Object.defineProperty(store,'raw',{get(){if(store.traveledState){returnstore.traveledState;}returnthis.sanDevtoolsRaw;},set(state){this.sanDevtoolsRaw=state;}});接下来,我们将san-store中的state替换为如下方式:新旧状态,所以我们只需要关心订阅的数据,因为当组件订阅的数据发生变化时,会显示声明数据的来源,比如上例中的user.name,所以当san-store发送store-listened消息,需要调用collectMapStatePath来收集mapStates的数据,代码如下:ob项目对象]'){返回;}Object.values(mapStates).reduce((prev,cur)=>{constkey=cur;constvalue=cur.split('.');prev[key]=value;returnprev;},paths);}当需要计算两个状态的diff数据时,只需要根据this.paths中存储的mapStates进行计算即可。getDiff的代码如下:getDiff(newValue,oldValue,mapStatesPaths){constdiffs=[];for(letstateNameinmapStatesPaths){if(mapStatesPaths.hasOwnProperty(stateName)){constpath=mapStatesPaths[stateName];constnewData=getValueByPath(newValue,路径);constoldData=getValueByPath(oldValue,路径);让差异;if(oldData!==undefined&&newData!==undefined&&newData!==oldData){diff={$change:'change',newValue:newData,oldValue:oldData,target:pat};}elseif(oldData===undefined&&newData!==undefined){diff={$change:'add',newValue:newData,oldValue:旧数据,目标:路径};}elseif(oldData!==undefined&&newData===undefined){diff={$change:'remove',newValue:newData,oldValue:oldData,target:path};}diff&&diffs.push(diff);}}returndiffs;}省略的getValueByPath函数用于根据指定路径从对象中获取对应的属性值。diff数据有三种操作:change:修改值add:添加属性remove:删除属性san-store会根据这些类型调用san组件不同类型的数据操作指令,进行增删改查组件中的状态。触发更新尝试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获取stateconststate=getStateFromStateLogs(id);如果(!状态){返回;}//替换状态replaceState(state.newValue);//根据mapStates计算数据diffconstdiffs=getDiff(state.newValue,store.traveledState,paths);//触发视图更新store._fire(diffs);return;}在san-devtools中,我们只需要主动调用travelTo,传入一个action的唯一标签,就可以通过以上步骤将页面恢复到某个时刻之前的页面状态。总结本文介绍了为什么需要状态管理,并简要分析了状态管理系统flux的单项数据流模型,其中简单介绍了常用的基本概念:action、dispatcher、store、view等。然后是san-store基于通量模型的介绍。最后介绍了san-devtools是如何基于san-store实现时间穿梭功能的。我们这里介绍的时间旅行,就是通过更新页面的数据,回到之前的页面状态。该实现方式遇到状态本身涉及随机数据等复杂场景,无法准确还原页面状态。目前这个问题可以通过保存页面快照来解决,但同时也带来了新的问题。页面快照是页面的屏幕截图。每触发一个动作,都需要保存图片,播放时需要加载图片,无论是从内存考虑还是从响应速度考虑,都会大大降低体验。因此,我们还需要在时间旅行的实现上多做思考。7、参考文献【1】markerikson:https://changelog.com/person/...点击进入了解更多技术资料~~