網頁是用戶與網站對接的入口,當咱們容許用戶在網頁上進行一些頻繁的操做時,對用戶而言,誤刪、誤操做是一件使人抓狂的事情,「若是時光能夠倒流,這一切能夠重來……」。
固然,時光不能倒流,而數據是能夠恢復的,好比採用 redux(https://redux.js.org/) 來管理頁面狀態,就能夠很愉快地實現撤銷與重作,可是傲嬌的我婉拒了redux的加持,手撕出一個 Javascript 狀態管理工具,鑑因而私有構造函數,怎麼命名不重要,就叫他李狗蛋好了,英文名就叫 —— DataSet。redux
DataSet並非被設計來存儲大量數據的,所以採用鍵值對的方式存儲也不會有任何問題,甚至連 W3C 支持的 IndexdDB 都懶得用,直接以對象存在內存中便可,遂有:數組
// 存儲具體數據的容器 this.dataBase = {};
另外,撤回與重作依賴於歷史數據,所以有必要將每次改動的數據存儲起來,在撤回/重作的時候按照先進後出的規則取出,爲此定義了兩個數組——撤回棧和重作棧,默承認以日後回退100步,固然,步長能夠傳入的參數 undoSize 自定義:安全
// 撤回與重作棧 this.undoStack = new Array(options.undoSize || 100); this.redoStack = new Array(options.undoSize || 100);
固然,一開始爲了開發方便,有時候須要查詢數據操做歷史,所以還開闢了日誌存儲的空間,可是目前這些日誌貌似沒有派上過用場,還白白佔用內存拖慢速度,有機會得把它移除掉。函數
咱們知道,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 參數,這個值爲真的時候存取數據不須要通過字符串的轉換,有助於提升運行效率。網站
前面已經說了棧實現的中心思想——先進後出,所以數據發生變化的時候,視狀況對兩個數組進行操做,採用數組的 push 方法存入,用 pop 方法取出便可,每次操做先後執行一下數組的 shift 或者 unshift方法,來保證數組長度的穩定(畢竟這個棧是假的)。實現代碼大體以下:ui
// 回退/重作操做 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); }
數據是以鍵值對存儲的,相應地,訂閱的時候也以鍵名爲準。因爲接觸過的諸多代碼都濫用了 jQuery 的 .on 方法,我決定本身實現的全部訂閱都必須是惟一的,所以這裏的每一個鍵名也只能訂閱一次。訂閱的接口以下:this
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; };
這樣就把回調函數與鍵名綁定了,對應數據發生改變的時候,即執行對應的回調函數:prototype
... 數據發生了改動 // 若是該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; }();