一、目的
定义出一个专门用于处理二维数据的组件,所谓二维数据就是能用二维表格显示出来的数据,所谓处理就是增删改查,很简单。
二、约束
外部程序给该组件传入如下形式的对象,让该组件自行解析。
var testData = { metadata: [{name: 'fid', label: 'fid', datatype: 'string', visible: 'false'}, {name: 'fName', label: '名称', datatype: 'string', visible: 'true'}, {name: 'fAge', label: '年龄', datatype: 'int', visible: 'true'}], data: [{fid: 'id_1', fName: 'yi', fAge: 25}, {fid: 'id_2', fName: 'anna', fAge: 24}, {fid: 'id_3', fName: 'kate', fAge: 26}] };
testData分为metadata和data两部分,两部分均为数组。
metadata内的每一个元素可以对应于表格的一列,持有列标识(键名暂默认为name),列标签,数据类型,是否可见等属性,并不仅限于传入以上属性,外部程序想传入什么就传入什么,组件只管解析和缓存,不管功能。
data内每一个元素的键集合就是所有metadata的列标识,值就是对应的数据。
可以用表格把testData展现为如下外观,其中列明对应于metadata的列标签值(label)
fid | 名称 | 年龄 |
id_1 | yi | 25 |
id_2 | anna | 24 |
id_3 | kate | 26 |
三、实现思路
组件命名为DataModel,仅有一个实例,获取方式为DataModel.getInstance。并提供以下公共方法:
parse: 解析数据
size: 获取数据数量
getData(index, prop): 根据索引和属性获取数据值
getMetadata(key, prop): 根据键和属性获取元数据属性值
foreach(type, fn): 迭代数据或元数据。其中type仅限于'metadata'和'data',迭代过程中会给fn传入迭代对象
update(index, prop, newValue[, callback, url]): 更新数据,返回布尔值。其中callback会在更新成功后调用,url是服务端更新数据地址
addView(view): 添加视图,view必须定义render方法,DataModel更新后(update或parse执行)会触发调用,传入更新信息,通知视图更新
四、单元测试
这里采用TDD的方式,先写测试用例,再写实现。单元测试用例可以在一定程度上减轻手工测试的负担,快速验证基本功能的正确性。并且用例覆盖的场景越全面,质量的有力保障手段,有了它,对代码的修改不再像过去那般战战兢兢了。
1、场景设置
parse
预处理:插入预设数据
size
场景1:获取数据size
getData
场景2:根据索引和属性获取数据值
getMetadata
场景3:根据键和属性获取元数据属性值
foreach
场景4:迭代元数据
场景5:迭代数据
update
场景6:给字符串类型的字段更新数据
场景7:给int类型的字段更新字符串数据
场景8:给int类型的字段更新int数据
场景9:更新一个等于旧值的数据
addView
场景10:更新数据,所有注册于DataModel上的视图都会得到通知
2、qunit测试框架
这是jQuery团队所使用的单元测试框架,轻量级,入门简单,基本用法可以参阅cookbook,这里不作详细介绍。
测试DataModel的代码如下所示:

1 /** 2 * 测试DataModel 3 */ 4 5 // 预处理:插入预设数据 6 var testData = { 7 metadata: [{name: 'fid', label: 'fid', datatype: 'string', visible: 'false'}, 8 {name: 'fName', label: '名称', datatype: 'string', visible: 'true'}, 9 {name: 'fAge', label: '年龄', datatype: 'int', visible: 'true'}], 10 data: [{fid: 'id_1', fName: 'yi', fAge: 25}, 11 {fid: 'id_2', fName: 'anna', fAge: 24}, 12 {fid: 'id_3', fName: 'kate', fAge: 26}] 13 }; 14 15 // 获取DataModel实例 16 var model = DataModel.getInstance(); 17 model.parse(testData); 18 19 // 测试parse效果 20 // -场景1:获取数据size 21 test('data size', function(){ 22 var size = model.size(); 23 equal(size, 3, 'get data size'); 24 }); 25 26 // -场景2:根据索引和属性获取数据值 27 test('get data value by index and prop', function(){ 28 var data_1 = model.getData(1, 'fName'), 29 data_2 = model.getData(2, 'fAge'); 30 strictEqual(data_1, 'anna', 'OK, name is ' + data_1); 31 strictEqual(data_2, 26, 'OK, age is ' + data_2); 32 }); 33 34 // -场景3:根据键和属性获取元数据属性值 35 test('get metadata value by key and prop', function(){ 36 var val_1 = model.getMetadata('fid', 'visible'), 37 val_2 = model.getMetadata('fName', 'label'), 38 val_3 = model.getMetadata('fAge', 'datatype'); 39 strictEqual(val_1, false, 'OK, visible is ' + val_1); 40 strictEqual(val_2, '名称', 'OK, label is ' + val_2); 41 strictEqual(val_3, 'int', 'OK, datatype is ' + val_3); 42 }); 43 // -测试foreach 44 test('test foreach', 7, function(){ 45 var i = 0; 46 // 场景4:迭代元数据 47 model.foreach('metadata', function(obj){ 48 var result = {name: obj.name, label: obj.label, datatype: obj.datatype, visible: obj.visible}; 49 deepEqual(result, testData.metadata[i], 'for each one metadata testing'); 50 i++; 51 }); 52 // 场景5:迭代数据 53 i = 0; 54 model.foreach('data', function(obj){ 55 deepEqual(obj, testData.data[i], 'for each one data testing'); 56 i++; 57 }); 58 try{ 59 model.foreach('other', function(obj){ 60 ok(false, "shouldn't be called"); 61 }); 62 }catch(e){ 63 ok(true, 'should reach here'); 64 } 65 }); 66 67 // 写入数据 68 // -更新数据, 预期断言数是8,更新失败后回调不应该被调用,里面的断言也不会被执行。 69 test('update data by index, prop and new value', 9, function(){ 70 var newValue = 'yi_2', newValue_int = 30; 71 // 场景6:给字符串类型的字段更新数据 72 var result = model.update(0, 'fName', newValue); 73 strictEqual(result, true, 'update string value success'); 74 // 场景7:给int类型的字段更新字符串数据 75 result = model.update(1, 'fAge', newValue, function(obj){ 76 ok(false, "callback function shouldn't be called if update failed"); 77 }); 78 strictEqual(result, false, 'OK, update string value into int field failed'); 79 // 更新失败,保留的还是旧值 80 var data = model.getData(1, 'fAge'); 81 strictEqual(data, 24, 'ok, value is not changed if update failed'); 82 // 场景8:给int类型的字段更新int数据 83 result = model.update(1, 'fAge', newValue_int, function(obj){ 84 strictEqual(obj.index, 1, 'in calllback obj index is right'); 85 strictEqual(obj.newValue, 30, 'in callback obj newValue is right'); 86 strictEqual(obj.prop, 'fAge', 'in callback obj prop is right'); 87 }); 88 strictEqual(result, true, 'OK, update int value into int field success'); 89 // 更新成功,保留的是新值 90 data = model.getData(1, 'fAge'); 91 strictEqual(data, 30, 'ok, value is changed if update success'); 92 // 场景9:更新一个等于旧值的数据 93 result = model.update(1, 'fAge', 30, function(obj){ 94 ok(false, "this shouldn't be called if newValue is equal to oldValue"); 95 }); 96 strictEqual(result, true, "update should return true if old value is equals to new value"); 97 98 }); 99 // -更新数据,检验渲染视图 100 test('render data after update data model', 8, function(){ 101 // 场景10:更新数据,所有注册于DataModel上的视图都会得到通知 102 var view1 = {render: function(obj){checkRender(obj);}}, 103 view2 = {render: function(obj){checkRender(obj);}}; 104 model.addView(view1).addView(view2); 105 model.update(1, 'fAge', 28); 106 function checkRender(obj){ 107 strictEqual(obj.id, 'id_2', 'check id'); 108 strictEqual(obj.index, 1, 'check index'); 109 strictEqual(obj.prop, 'fAge', 'check prop'); 110 strictEqual(obj.newValue, 28, 'check newValue'); 111 } 112 });
严格来说,流程走到这里,因为还没有写实现,应该会报找不到符号之类的错才对。
还有一点要提一下,可以看出,把每个测试用例展开后显示的绿色字,就是给每个断言方法传入的第三个字符串参数。这个字符串更好的写法也许应该是对应于刚才列出的场景。这里没有改,我也是写到这里才想到这一点的。另外我也是第一次用这个东西,所以应该还会有更好的规范写法是我没有发现的,这点请大家自行思考。
五、具体实现
这里贴出DataModel的代码,还有很多功能点没有实现,例如删除,校验,还有parse的处理方式实在太蹩脚了,data只是单纯的赋值,按照数据类型校验或转换啥的都没有。这里的目的更多的是为了表达一种思想。另外这里贴出的肯定也不是最好的方式,因为本人也是缺少经验的js初学者,写的过程中经常发现这里不对,那种处理方式更好一些,大大小小的重构是经常的。这里之所以要写测试用例也是因为这个原因,管我怎么摆弄这些代码,写好了就run一下测试,只要公共接口能正确跑对就ok。

1 var DataModel = (function(){ 2 // 惰性加载 3 var _instance; 4 return { 5 getInstance: function(){ 6 if(!_instance) 7 _instance = constructor(); 8 return _instance; 9 } 10 }; 11 function constructor(){ 12 var metadata, data, 13 viewList = []; 14 function renderView(obj){ 15 for(var i=0, len=viewList.length; i<len; i++){ 16 viewList[i].render(obj); 17 } 18 } 19 return { 20 parse: function(obj){ 21 if(obj && obj.metadata){ 22 metadata = {}; 23 var meta, name, i, len; 24 for(i=0, len=obj.metadata.length; i<len; i++){ 25 meta = obj.metadata[i]; 26 name = meta.name; 27 metadata[name] = new Metadata(meta); 28 } 29 // 注意:data是可以被清空的 30 data = obj.data; 31 // 给注册的视图发送消息 32 renderView(); 33 }else{ 34 throw new Error('the param is invalid'); 35 } 36 }, 37 // 从服务端取数据 38 fetchDataFromServer: function(url){ 39 var self = this; 40 yi.xhr.request('POST', url, { 41 success: function(responseText){ 42 if(responseText){ 43 var obj = eval("(" + responseText + ")"); 44 self.parse(obj); 45 } 46 } 47 }); 48 }, 49 size: function(){ 50 if(data) 51 return data.length; 52 else 53 throw new Error('data is null'); 54 }, 55 getData: function(index, prop){ 56 if(data) 57 return data[index][prop]; 58 else 59 throw new Error('data is null'); 60 }, 61 getMetadata: function(key, prop){ 62 if(metadata) 63 if(metadata[key]) 64 if(typeof metadata[key][prop] == 'boolean' || metadata[key][prop]){ 65 var val = metadata[key][prop]; 66 if(val == 'true' ) 67 return true; 68 else if(val == 'false') 69 return false; 70 else 71 return val; 72 } 73 throw new Error('metadata[' + key + '] has not the prop: ' + prop); 74 throw new Error('metadata has not the key: ' + key); 75 throw new Error('metadata is null'); 76 }, 77 update: function(index, prop, newValue, callback, url){ 78 // 新旧值如果相等就直接返回true可以了 79 var oldValue = data[index][prop]; 80 if(oldValue === newValue) 81 return true; 82 var datatype = this.getMetadata(prop, 'datatype'); 83 // 根据数据类型校验 84 switch(datatype){ 85 case 'int': 86 if(isNaN(parseInt(newValue)) || parseFloat(newValue) != parseInt(newValue)){ 87 return false; 88 } 89 newValue = parseInt(newValue); 90 break; 91 case 'float': 92 case 'number': 93 if(isNaN(newValue = parseFloat(newValue))){ 94 return false; 95 } 96 break; 97 } 98 // 更新数据 99 data[index][prop] = newValue; 100 // 通知服务端更新数据 101 if(url){ 102 var id = data[index]['fid'], 103 req = 'id=' + id + '&key=' + prop + '&value=' + newValue, 104 self = this; 105 yi.xhr.request('post', url, { 106 failure: function(){return false;} 107 }, req); 108 } 109 var obj = {'id': data[index]['fid'], 'index': index, 'prop': prop, 'newValue': newValue}; 110 // 给注册的视图发送消息 111 renderView(obj); 112 // 更新后回调 113 if(callback) callback(obj); 114 return true; 115 }, 116 // 遍历metadata或者data 117 foreach: function(type, fn){ 118 if(!fn) throw new Error('param[fn] is required.'); 119 if(type === 'metadata'){ 120 for(var key in metadata){ 121 fn(metadata[key]); 122 } 123 }else if(type === 'data'){ 124 for(var i=0, len=data.length; i<len; i++){ 125 fn(data[i]); 126 } 127 }else{ 128 throw new Error('param[type] is only limit in "metadata" and "data".'); 129 } 130 }, 131 // 添加视图 132 addView: function(view){ 133 for(var i=0, len=viewList.length; i<len; i++){ 134 if(view == viewList[i]) 135 return this; 136 } 137 viewList.push(view); 138 return this; 139 } 140 }; 141 } 142 })(); 143 144 // 元数据数据结构 145 function Metadata(config){ 146 for(var prop in config){ 147 var val = config[prop]; 148 this[prop] = val; 149 } 150 // name属性必须指定 151 if(!this['name']) 152 throw new Error('name must be provided!'); 153 } 154 Metadata.prototype = { 155 constructor: Metadata, 156 // 默认属性 157 label: 'Default Label', 158 datatype: 'string', 159 visible: true 160 };
简单说明下这段代码的结构:
1、新建DataModel变量
首字母大写,因为它是个类。该变量的值由以下的结构返回
var DataModel = (function(){ // 惰性加载 var _instance; function constructor(){} return { getInstance: function(){ if(!_instance) _instance = constructor(); return _instance; } }; })();
=号右边的是一个定义后立刻执行的函数,起到闭包的作用(外部不能访问私有成员_instance和constructor;getInstance方法可以访问_instance和constructor,外部可以访问getInstance;这两个私有成员值不会消失)。
getInstance里面是经典的单例模式写法。由于constructor有可能是一个比较耗费资源的操作,因此不必在页面加载的时候立刻执行,延迟到用户需要的时候,再通过getInstance获取。
2、另一个闭包,constructor函数
function constructor(){ var metadata, data, viewList = []; function renderView(obj){ for(var i=0, len=viewList.length; i<len; i++){ viewList[i].render(obj); } } return{ // parse // fetchDataFromServer // size // getData // ... }; }
外部程序通过getInstance获得constructor返回的那个对象字面量,然后就可以访问组件的公共api(parse,getData,etc.)了。
可以通过addView给私有成员viewList添加成员。update或parse执行到最后会内部调用私有方法renderView,给所有注册的视图发送消息(观察者模式)。
注意,注册到DataModel的视图必须实现render方法。
3、另外,这个实现代码存在两个约束。
一是在update方法里面,通知服务端更新数据之后,构造更新信息obj那里,其中id值是取data[index]['fid']。也就是说,数据的主键名已经写死为fid了。这里我能够想到的就只有两种处理方式,一种就是现在所使用的,一种是生成传递给parse方法的参数obj(自己写或者服务端生成)时,指定一个元数据对象为主键(可以给主键元数据对象增加一个identity或primary属性)。经过考虑,我还是觉得用写死的处理方式比较好。二是元数据里面,列标识的键名也写死为name了,而且在构造Metadata对象时,参数必须指定name属性值。