徒手擼出Javascript 狀態管理工具 DataSet ,實現資料的訂閱、查詢、撤銷和恢復
網頁是使用者與網站對接的入口,當我們使用者在網頁上進行一些頻繁的操作時,對使用者而言,誤刪、誤操作是一件令人抓狂的事情,“如果時光可以倒流,這一切可以重來……”。
當然,時光不能倒流,而資料是可以恢復的,比如採用 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; }; }();