资讯专栏INFORMATION COLUMN

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

suemi / 2599人阅读

摘要:实现代码大致如下回退重做操作普通操作,栈记录,栈清空撤回操作重做操作数据的订阅数据是以键值对存储的,相应地,订阅的时候也以键名为准。

网页是用户与网站对接的入口,当我们允许用户在网页上进行一些频繁的操作时,对用户而言,误删、误操作是一件令人抓狂的事情,“如果时光可以倒流,这一切可以重来……”。
当然,时光不能倒流,而数据是可以恢复的,比如采用 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://www.ucloud.cn/yun/103250.html

相关文章

  • 用Kolb学习模型来学编程

    摘要:会用其它人的分析结果,并付诸实践,更偏向于执行,通过错误来学习。四语言学习的方法有些人可能通过感受和观察就能很好的学习了,比如我们所熟知的一些学霸。 小推广讲堂《60分钟徒手撸出Spring框架》,别只会用,干脆自己撸一个轮子吧 一 前言 1984年, 大卫·库伯曾在他的著作《体验学习:体验——学习发展的源泉》提出了学习圈理论,与他认为经验学习过程是由四个适应性学习阶段构成的环形结构,...

    Flands 评论0 收藏0
  • Spring Cloud Hystrix入门Hystrix命令原理分析

    摘要:系统需要支持命令的撤销。第步计算断路器的健康度会将成功失败拒绝超时等信息报告给断路器,断路器会维护一组计数器来统计这些数据。第步,当前命令的线程池请求队列或者信号量被占满的时候。 断路由器模式 在分布式架构中,当某个服务单元发生故障之后,通过断路由器的故障监控(类似熔断保险丝),向调用方返回一个错误响应,而不是长时间的等待。这样就不会使得线程因调用故障服务被长时间占用不释放,避免了故障...

    Betta 评论0 收藏0
  • React组件设计实践总结05 - 状态管理

    摘要:要求通过要求数据变更函数使用装饰或放在函数中,目的就是让状态的变更根据可预测性单向数据流。同一份数据需要响应到多个视图,且被多个视图进行变更需要维护全局状态,并在他们变动时响应到视图数据流变得复杂,组件本身已经无法驾驭。今天是 520,这是本系列最后一篇文章,主要涵盖 React 状态管理的相关方案。 前几篇文章在掘金首发基本石沉大海, 没什么阅读量. 可能是文章篇幅太长了?掘金值太低了? ...

    ideaa 评论0 收藏0
  • 设计模式总结

    摘要:,命令模式,将行为请求者和行为实现者解耦,将行为抽象为对象。解释器模式,迭代器模式,将集合对象的存储数据和遍历数据职责分离。即将遍历的责任交给迭代器返回的迭代器,迭代器。 设计模式总结 创建型:除了直接new来实例化对象外,提供了多种隐藏创建逻辑的生成对象的方法 结构型:通过对象和类的组合,得到新的结构和功能 行为型:解决对象之间的通行和功能职责分配 详细分类 工厂 简单工厂...

    quietin 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<