Javascript 状态管理工具 DataSet ,实现数据的订阅、查询、撤销和恢复

更新日期: 2019-04-07阅读: 2k标签: 状态

网页是用户与网站对接的入口,当我们允许用户在网页上进行一些频繁的操作时,对用户而言,误删、误操作是一件令人抓狂的事情,“如果时光可以倒流,这一切可以重来……”。
当然,时光不能倒流,而数据是可以恢复的,比如采用 redux(https://redux.js.org/) 来管理页面状态,就可以很愉快地实现撤销与重做,但是傲娇的我婉拒了redux的加持,手撕出一个 Javascript 状态管理工具,鉴于是私有构造函数,怎么命名不重要,就叫他李狗蛋好了,英文名就叫 —— DataSet。


1. 数据的存储

DataSet并不是被设计来存储大量数据的,因此采用键值对的方式存储也不会有任何问题,甚至连 W3C 支持的 IndexdDB 都懒得用,直接以对象存在内存中即可,遂有:

// 存储具体数据的容器
this.dataBase = {};               

另外,撤回与重做依赖于历史数据,因此有必要将每次改动的数据存储起来,在撤回/重做的时候按照先进后出的规则取出,为此定义了两个数组——撤回栈和重做栈,默认可以往后回退100步,当然,步长可以传入的参数 undoSize 自定义:

// 撤回与重做栈
this.undoStack = new Array(options.undoSize || 100);
this.redoStack = new Array(options.undoSize || 100);               

当然,一开始为了开发方便,有时候需要查询数据操作历史,因此还开辟了日志存储的空间,但是目前这些日志貌似没有派上过用场,还白白占用内存拖慢速度,有机会得把它移除掉。


2. 数据隔离

我们知道,Javascipt 变量实际上只是对内存引用的一个句柄,因此当你把对象“存”起来之后,在外部对该对象的改动仍旧是会影响存储的数据的,因此多数情况下需要对存入的对象进行深拷贝,由于需要保存的对象通常只是用来描述状态,因此不应包含方法,所以是可以转为符串再存储的,取用数据的时候再把它转为对象即可,所以数据的出入分别采用了 JSON.stringify 和JSON.parse 方法。
存数据:

this.dataBase[key].value = this.immutable &&
JSON.stringify(this.dataBase[key].value) || this.dataBase[key].value;

取数据:

var result= (!this.mutable) &&
JSON.parse(dataBase['' + key].value) ||  dataBase['' + key].value;   

鉴于部分情况下数据可以不进行隔离,比如存储AJAX获取到的数据,为此我预留了 immutable 参数,这个值为真的时候存取数据不需要经过字符串的转换,有助于提高运行效率。


3. 撤回、重做栈管理

前面已经说了栈实现的中心思想——先进后出,因此数据发生变化的时候,视情况对两个数组进行操作,采用数组的 push 方法存入,用 pop 方法取出即可,每次操作前后执行一下数组的 shift 或者 unshift方法,来保证数组长度的稳定(毕竟这个栈是假的)。实现代码大致如下:

// 回退/重做操作
var undoStack = this.undoStack;
var redoStack = this.redoStack;
var undoLength = undoStack.length;
if(!undoFlag){
    // 普通操作,undo栈记录,redo栈清空
    undoStack.shift();
    undoStack.push(formerData);
    if(!!redoStack.length){
        redoStack.splice(0);
        redoStack.length = undoLength 
    }
} else if(undoFlag === 1){
    // 撤回操作
    redoStack.shift();
    redoStack.push(formerData);
} else {
    // 重做操作
    undoStack.shift();
    undoStack.push(formerData);
}


4. 数据的订阅

数据是以键值对存储的,相应地,订阅的时候也以键名为准。由于接触过的诸多代码都滥用了 jquery 的 .on 方法,我决定自己实现的所有订阅都必须是唯一的,因此这里的每个键名也只能订阅一次。订阅的接口如下:

function subscribe(key, callback) {
    if(typeof key !== 'string'){
        console.warn('DataSet.prototype.subscribe: required a "key" as a string.');
        return null;
    }

    if(callback && callback instanceof Function){
        try{
            if(this.hasData(key)){
                this.dataBase[key].subscribe = callback;
            } else {
                var newData = {};
                newData['' + key] = null;
                this.setData(newData, false);
                this.dataBase[key].subscribe = callback;
            }
        } catch (err) {

        }
    }

    return null;
};


这样就把回调函数与键名绑定了,对应数据发生改变的时候,即执行对应的回调函数:

... 数据发生了改动
// 如果该data被设置订阅,执行订阅回调函数
var subscribe = dataBase[key].subscribe;
(!BETA_silence) && (subscribe instanceof Function) && (subscribe(newData, ver));

你可能注意到了这里有个 BETA_silence 参数。这是为了方法复用而预留的参数,适用于数据已在外部修改的情形,只需在内部同步一下数据即可,触发订阅可能引起bug,此时将 silence 设为true即可。不过我认为应当尽量减少方法内部的判断,因此 silence 添加了 BETA_ 前缀,提醒自己有时间的话还是另增一个专门的方法。

以上基本概括 DataSet 的设计思想,剩下的就是更加具体的实现和接口的设计,就不再细说,下面贴出完整代码,实现有些仓促,欢迎批评与指正。
代码:

/**
 * @constructor DataSet 数据集管理
 * @description 对数据的所有修改历史进行记录,提供撤回、重做等功能
 * @description 内部采用 JSON.stringify 和 JSON.parse对对象进行引用隔离,因此存在性能问题,不适用于大规模的数据存储
 * */
function DataSet(param){
    return this._init(param);
}

!function(){
    'use strict''
    /**
     * @method 初始化
     * @param {Object} options 配置项
     * @return {Null}
     * */
    DataSet.prototype._init = function init(options) {
        try{
            // 存储具体数据的容器
            this.dataBase = {};

            // 日志存储
            this.log = [
                {
                    action: 'initial',
                    data: JSON.stringify(options).substr(137) + '...',
                    success: true
                },
            ];

            // 撤回与重做栈
            this.undoStack = new Array(options.undoSize || 100);
            this.redoStack = new Array(options.undoSize || 100);

            this.mutable = !!options.mutable;

            // 初始化的时候可以传入原始值
            if(options.data){
                this.setData(options.data);
            }
        } catch(err) {
            this.log = [
                {
                    action: 'initial',
                    data: 'error:' + err,
                    success: false
                },
            ]  // 操作日志
        }
        return this;
    };

    /**
     * @method 设置数据
     * @param {Object|JSON} data 数据必须以键值对格式传入,数据只能是纯粹的Object或Array,不能有循环引用、不能有方法和Symbol
     * @param {Number|*} [undoFlag] 用来标识对历史栈的更改, 1-undo 2-redo 0|undefined-just 默认不进行栈操作
     * @param {Boolean} [BETA_silence] 静默更新,即不触发订阅事件,该方法不够安全,慎用
     * @return {Boolean} 以示成败
     * */
    DataSet.prototype.setData = function setData(data, undoFlag, BETA_silence) {
        // try{
            var val = null;
            try {
                val = JSON.stringify(data);
            }catch(err) {
                console.error('DataSet.prototype.setData: the data cannot be parsed to JSON string!');
                return false;
            }
            var dataBase = this.dataBase;
            var formerData = {};
            for(var handle in data) {
                var key = '' + handle;
                var immutable = !this.mutable;
                // 保存到撤回/重做栈
                var thisData = dataBase[key];
                var newData = immutable && JSON.parse(JSON.stringify(data[key])) || data[key];
                if(this.dataBase[key]){
                    formerData[key] = immutable &&
                     JSON.parse(JSON.stringify(this.dataBase[key].value)) ||
                      this.dataBase[key].value;
                      
                    // 撤回时版本号减一,否则加一
                    var ver = thisData.version + ((undoFlag !== 1) && 1 || -1);  
                    dataBase[key].value = newData;
                    dataBase[key].version = ver;

                    // 如果该data被设置订阅,执行订阅回调函数
                    var subscribe = dataBase[key].subscribe;
                    (!BETA_silence) &&
                    (subscribe instanceof Function) &&
                    (subscribe(newData, ver));
                } else {
                    this.dataBase[key] = {
                        origin: newData,
                        version: 0,
                        value: newData,
                    }
                }
            }

            // 回退操作
            var undoStack = this.undoStack;
            var redoStack = this.redoStack;
            var undoLength = undoStack.length;
            if(!undoFlag){
                // 普通操作,undo栈记录,redo栈清空
                undoStack.shift();
                undoStack.push(formerData);
                if(!!redoStack.length){
                    redoStack.splice(0);
                    redoStack.length = undoLength;
                }
            } else if(undoFlag === 1){
                // 撤回操作
                redoStack.shift();
                redoStack.push(formerData);
            } else {
                // 重做操作
                undoStack.shift();
                undoStack.push(formerData);
            }

            // 记录操作日志
            this.log.push({
                action: 'setData',
                data: val.substr(137) + '...',
                success: true
            });

            return true;
        // } catch (err){
        //     // 记录失败日志
        //     this.log.push({
        //         action: 'setData',
        //         data: 'error:' + err,
        //         success: false
        //     });
        //
        //     throw new Error(err);
        // }
    };

    /**
     * @method 获取数据
     * @param {String|Array} param
     * @return {Object|*} 返回数据依原始数据而定
     * */
    DataSet.prototype.getData = function getData(param) {
        try{
            var dataBase = this.dataBase;

            /**
             * @function 获取单个数据
             * */
            var getItem = function getItem(key) {
                var data = undefined;

                try{
                    data = (!this.mutable) && 
                        JSON.parse(JSON.stringify(dataBase['' + key].value)) ||
                        dataBase['' + key].value;
                } catch(err){
                }

                return data;
            };

            var result = [];

            if(/string|number/.test(typeof param)){
                result = getItem(param);
            } else if(param instanceof Array){
                result = [];
                for(var cnt = 0; cnt < param.length; cnt++) {
                    if(/string|number/.test(typeof param[cnt])) {
                        result.push(getItem(param[cnt]))
                    }else {
                        console.error('DataSet.prototype.getData: requires param(s) ,which typeof string|Number');
                    }
                }
            } else {
                console.error('DataSet.prototype.getData: requires param(s) ,which typeof string|Number');
            }

            this.log.push({
                action: 'getData',
                data: JSON.stringify(result || []).substr(137) + '...',
                success: true
            });

            return result;
        } catch(err) {
            this.log.push({
                action: 'getData',
                data: 'error:' + err,
                success: false
            });
            console.error(err);

            return false;
        }
    };

    /**
     * @method 判断DataSet中是否有某个键
     * @param {String} key
     * @return {Boolean}
     * */
    DataSet.prototype.hasData = function hasData(key) {
        return this.dataBase.hasOwnProperty(key);
    };

    /**
     * @method 撤回操作
     * */
    DataSet.prototype.undo = function undo() {
        var self = this;
        var undoStack = self.undoStack;

        // 获取上一次的操作
        var curActive = undoStack.pop();
        undoStack.unshift(null);

        // 撤回生效
        if(curActive){
            self.setData(curActive, 1);
            return true;
        }
        return null;
    };

    /**
     * @method 重做操作
     * */
    DataSet.prototype.redo = function redo() {
        var self = this;
        var redoStack = self.redoStack;
        redoStack.unshift(null);
        var curActive = redoStack.pop();

        // 重做生效
        if(curActive){
            this.setData(curActive, 2);
            return true;
        }
        return null;
    };

    /**
     * @method 订阅数据
     * @description 注意每个key只能被订阅一次,多次订阅将只有最后一次生效
     * @param {String} key
     * @param {Function} callback 在订阅的值发生变化的时候执行,参数为所订阅的值
     * @return {Null}
     * */
    DataSet.prototype.subscribe = function subscribe(key, callback) {
        if(typeof key !== 'string'){
            console.warn('DataSet.prototype.subscribe: required a "key" as a string.');
            return null;
        }

        if(callback && callback instanceof Function){
            try{
                if(this.hasData(key)){
                    this.dataBase[key].subscribe = callback;
                } else {
                    var newData = JSON.parse('{"' + key + '":null}');
                    this.setData(newData, false);
                    this.dataBase[key].subscribe = callback;
                }
            } catch (err) {

            }
        }

        return null;
    };
    
    return null;
}();


来自:https://segmentfault.com/a/1190000018769683


链接: https://www.fly63.com/article/detial/2736

理解 React 轻量状态管理库 Unstated

在React写应用的时候,难免遇到跨组件通信的问题。现在已经有很多的解决方案。React本身的Context,Redux结合React-redux,Mobx结合mobx-react

你再也不用使用 Redux、Mobx、Flux 等状态管理了

这个库的作者希望使用 React 内置 API ,直接实现状态管理的功能。看完这个库的说明后,没有想到代码可以这个玩。短短几行代码,仅仅使用 React Hooks ,就实现了状态管理的功能。

为什么要使用状态管理

我们平时开发的大部分项目,由于复杂度不够, 很少使用 Vuex、Redux 等状态管理库,就算引入了 Vuex 这些库,也只是当作一个全局数据引用,并非对应用状态进行管理。但一旦页面的复杂度比较高,必然要引入状态管理,今天就聊聊我理解中的状态管理。

React使用Hooks与Context替代Redux状态管理

React Hooks 在 2018 年年底就已经公布了,正式发布是在 2019 年 5 月,关于它到底能做什么用,并不在本文的探讨范围之内,本文旨在摸索,如何基于 Hooks 以及 Context,实现多组件的状态共享,完成一个精简版的 Redux。

如何使用react hooks来进行状态管理?

首先要明确为什么要使用redux,这一点很重要,如果不知道为什么使用redux,那么在开发的过程中肯定不能合理的使用redux.首先来看redux的本质:redux做为一款状态管理工具,主要是为了解决组件间通信的问题。

Flutter基础--状态管理

当我们使用编译器创建一个新Flutter应用的时候,我们可以在主界面看到两个小部件StatelessWidget和StatefulWidget。这是两个最常见使用最频繁的小部件了。StatelessWidget ,StatefulWidget

共享可变状态中出现的问题以及如何避免?

本文回答了以下问题:么是共享可变状态?为什么会出现问题?如何避免其问题?标有(高级)的部分会更深入,如果你想更快地阅读本文,可以跳过。

使用Observable实现Vue全局状态共享

项目不大, 又不想用Vuex, 那么使用Observable来实现状态共享也不失为一个选择。用法 :让一个对象可响应。Vue 内部会用它来处理 data 函数返回的对象

node如何实现保持登录状态?

当我们登录成功,在这个页面刷新,页面并没有保存登录状态;今天我们就来看一下如何在后台使用cookie保存用户登录状态。做到刷新页面仍然显示在用户登录界面。node实现保持登录状态的方法如下:

3条简单的React状态管规则

React组件内部的状态是在渲染之间保持不变的封装数据。useState()是React钩子,负责管理功能组件内部的状态。我喜欢useState()确实使状态处理变得非常容易。但是我经常遇到类似的问题:

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!