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

37次阅读

共计 7008 个字符,预计需要花费 18 分钟才能阅读完成。

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

正文完
 0