网络日志

前端的状态管理与时间旅行:San实践篇

本文将会从前端状态管理的由来说起,然后简单介绍作为 san 的状态管理工具 san-store 的实现思想,接着将介绍时间旅行的概念以及与状态管理工具的关系,最后将介绍针对 san-store 的时间旅行的实现思路与关键技术点。

01 为什么需要状态管理

组件化的思想对于前端来说是一大进步,它使得编写高内聚,低耦合的代码更加容易。同时随着各个框架的出现,使得开发者不需要过多考虑底层的 DOM 操作,专注数据状态的流转与处理。但是组件化开发还是有其痛点所在,抛开调试与单元测试来说,对于业务功能影响最大的莫过于组件(模块)之间的数据共享(状态管理),因此催生出了非常多的状态管理工具: fluxredux,甚至 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 发布内部数据变化的消息,通知所有观察者组件进行数据的更新与后续逻辑。

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。上述代码的整体流程分为两个阶段:

  1. 注册 action 以及订阅数据的变化:通过 san-store 提供的 addAction 注册一个 action 处理函数;组件通过 connect 来订阅 san-store 中数据的变化。
  2. 组件触发 action 以及更新视图:组件调用 dispatch 方法,需要传入 action 的名称以及相关的 payloadsan-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 的数据回退到某个时刻,然后主动触发视图更新。其原理图如下:

可以通过如下几个步骤来实现:

  1. 在每次 store state 变化的时候,存储新的 state 以及旧的 state,称之为 log 数据
  2. 获取某个 action 对应的 log 数据
  3. 替换 store state
  4. 计算出新旧 statediff 数据
  5. 主动触发组件视图更新

其中第一步已经由 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 消息的时候,我们需要调用 collectMapStatePathmapStates 的数据收集起来,代码如下:

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);
}

当需要计算两个 statediff 数据的时候,只需要按照 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 数据有三种操作类型:

  1. change:修改值
  2. add:添加属性
  3. 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 的单项数据流模型,其中简要介绍了常用的基本概念:actiondispatcherstoreview 等。接着介绍了基于 flux 模型的 san-store。最后介绍了 san-devtools 是如何基于 san-store 实现时间旅行功能的。我们这里所介绍的时间旅行是通过更新页面的数据来回退到之前的页面状态,这种实现方式在遇到复杂的场景比如状态本身涉及了随机性的数据,那么页面状态是无法精确还原的,目前可以通过保存页面快照来解决这样的问题,但是同时带来了新的问题,页面快照是一张页面截图,每次 action 触发都需要保存图片,回放的时候需要加载图片,无论从内存考虑还是响应速度考虑,体验会大打折扣。因此还需要在时间旅行的实现方案上或许还有需要做更多的思考。

——————END——————

参考资料:[1] markerikson: https://changelog.com/person/...

推荐阅读:

百度App 低端机优化-启动性能优化(概述篇)

面向大规模数据的云端管理,百度沧海存储产品解析

增强分析在百度统计的实践

基于 TLS 1.3的百度安全通信协议 bdtls 介绍

百度用户产品流批一体的实时数仓实践

如何治理资源浪费?百度云原生成本优化最佳实践

面向大数据存算分离场景的数据湖加速方案