网页是用户与网站对接的入口,当我们用户在网页上进行一些频繁的操作时,对用户而言,误删、误操作是一件令人抓狂的事情,“如果时光可以倒流,这一切可以重来……”。当然,时光不能倒流,而数据是可以恢复的,比如采用 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.parse(JSON.stringify(this.dataBase[key].value)) || this.dataBase[key].value; 取数据: var result= (!this.mutable) && JSON.parse(JSON.stringify(dataBase[’’ + key].value)) || dataBase[’’ + key].value; 鉴于部分情况下数据可以不进行隔离,我预留了 immutable 参数,为真的时候存取数据不需要经过 JSON,可能有助于提高运行速率。3. 撤回、重做栈管理前面已经说了栈实现的中心思想——先进后出,因此数据发生变化的时候,视情况对两个数组进行操作,采用数组的 push 方法存入,用 pop 方法取出即可,每次操作后执行以下数组的 shift 或者 unshift方法,来保证数组长度的稳定(谁让这个栈是假的呢)。实现代码大致如下: // 回退/重做操作 var undoStack = this.undoStack; var redoStack = this.redoStack; if(!undoFlag){ // 普通操作,undo栈记录,redo栈清空 undoStack.shift(); undoStack.push(formerData); delete this.redoStack; this.redoStack = new Array(undoStack.length); } 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 = JSON.parse(’{"’ + 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)); 以上基本概括 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; if(!undoFlag){ // 普通操作,undo栈记录,redo栈清空 undoStack.shift(); undoStack.push(formerData); delete this.redoStack; this.redoStack = new Array(undoStack.length); } 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) { var result = false; var dataBase = this.dataBase; for (var thisKey in dataBase){ if(thisKey === key){ result = true; } } return result; }; /* * @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; }; }();