进一步丰富和简化表单管理的组件:form.js
上文《简洁易用的表单数据设置和收集管理组件》介绍了我自己的表单管理的核心内容,本文在上文的基础上继续介绍自己关于表单初始值获取和设置以及表单数据提交等内容方面的做法,上文的组件粒度很小,都是跟单个表单元素相关的某种特定类型的组件,所以内容很多;本文要介绍的内容集中于整个表单组件本身,有点像上文介绍的formMap.js组件,但不同的是在我自己的项目中form.js用的更多,formMap几乎不用,因为在form的内部就有用到formMap组件的实例来管理表单的数据,之所以这么做,也是为了让各个组件的功能更加单一,方便今后的维护和重用。form.js的代码不多,只有200多行,该组件以及我提供的demo页面的js内都有比较详细的注释,方便有兴趣的朋友阅读参考。
form.js的代码地址:
https://github.com/liuyunzhuge/blog/blob/master/form/src/js/mod/form.js
demo地址:
新增模式:
http://liuyunzhuge.github.io/blog/form/dist/html/demo2.html?mode=1
编辑模式:
http://liuyunzhuge.github.io/blog/form/dist/html/demo2.html?mode=2&id=1
form.js解决的问题
在我自己以前开发项目的经验中,在开发一个表单的时候会遇到下列的一些问题:
1)表单各个字段的初始值如何设置?
虽然上文的内容已经解决了如何区分新增模式和编辑模式的初始值,但是还存在的问题是如果某个字段的初始值需要从后台返回该如何处理?传统项目中我们可以用后台模板来解决,但是假如是一个纯前后端分离的项目呢,那就没有后台模板可以利用了;还有,从后台返回的话,如果是获取编辑模式的初始值,意味着要后台从数据库查询相应的数据,这个时候如何规范传递给后台查询初始值的接口参数?
2)假如通过接口来获取初始值,编辑模式一定需要ajax,但是新增模式就不一定需要,意味着同样的一个功能,有的时候可能是异步的,有的时候可能是同步的,这种情况该如何统一?
3)如果新增模式和编辑模式要使用不同的接口来保存该如何处理?
4)一般表单在保存之后都会根据后台返回的响应添加一些交互逻辑,但是每个保存功能的交互逻辑都是不固定的,更别说项目之间的区别了,如何才能让表单的保存功能更加单一,不受其它功能的影响?
5)提交到后台的数据一般都是按照querystring的格式提交,但有时候为了方便,在php里面会将querystring的参数名都封装成数组索引的形式,比如有一个参数id=1,就会变成某个model名称加参数名称的形式如 User[id]=1,这么做是为了配合后台的数据解析功能;而在java里面,更喜欢直接把整个表单的所有参数合并成一个参数,把所有数据通过json字符串来传递,java后台也有好用的工具将数据直接解析成model,要考虑兼容这样的问题,表单组件在提交数据的时候该如何管理?
这些问题,我考虑的解决方法是:
1)使用以下几个option来管理通过接口初始值的获取和设置的功能:
queryUrl: '',//编辑模式时查询初始值的url
key: '',//编辑模式时使用它作为主键的值,跟在queryUrl后面传递到后台查询数据
keyName: '',//编辑模式时使用它作为主键的名称,跟在queryUrl后面传递到后台查询数据
defaultData: {},//新增模式时的默认值,可以是一个object,也可以是一个字符串,是字符串的时候表示一个后台查询的接口地址
看这部分的代码就能明白它们的实际作用了:
//获取表单初始化数据 getInitData: function () { var opts = this.options, mode = this.mode; //这个函数返回的格式,包含三个参数,各个参数的含义如下: //valid: true表示有效,false表示无效 //ajax: true表示data返回的是jquery创建的ajax对象,false表示不是 //data: 当ajax为true的时候返回jquery创建的ajax对象,否则直接返回一个object实例表示初始化数据 //新增模式,通过defaultData来获取初始值 if (mode == 1) { var defaultData = opts.defaultData; //如果defaultData是一个字符串,表示它需要从后台加载 if (typeof(defaultData) == 'string') { return { valid: true, ajax: true, data: Ajax.get(defaultData) }; } else { return { valid: true, ajax: false, data: defaultData }; } } else { //非新增模式,通过queryUrl来获取初始值 //此模式下必须跟后台传递keyName跟key值 //否则后台无法查询到要编辑的数据给前端返回 var url = $.trim(opts.queryUrl), keyName = $.trim(opts.keyName), key = $.trim(opts.key); if (!url || !key || !keyName) { //url key keyName 为falsy值时均返回valid:false,表示初始值无效 return { valid: false }; } else { var params = {}; params[keyName] = key; return { valid: true, ajax: true, data: Ajax.get(url, params) } } } }
2) 利用$.Deffered将同步的功能变成异步的功能,但是由于异步的功能里面调用异步回调传递的参数是$.ajax返回的对象,所以调用resolve方法时也必须传递同样的格式,才能保证功能的健壮:
//设置初始值 //方法名起的不好... //因为这是一个带返回值的设置型函数 //而且为了将初始化数据的设置统一成异步任务 //用到了$.Deferred() setInitData: function () { var $defer = $.Deferred(), initData = this.getInitData(), opts = this.options; if (!initData.valid) { setTimeout(function () { $defer.resolve({}); }, 0); } else if (initData.ajax) { initData.data.done(function (res) { var data = opts.parseInitResponse(res); $defer.resolve(data); }).fail(function () { $defer.resolve({}); }); } else { setTimeout(function () { $defer.resolve(initData.data); }, 0); } return $.when($defer); }
3)将表单保存的接口分成两个,一个postUrl用于新增模式新增,一个putUrl用于编辑模式新增,结合mode参数,通过下面的接口来返回实际发起ajax请求时的地址:
//获取表单保存时调用的接口地址 getSaveUrl: function () { var url = '', opts = this.options, mode = this.mode; //mode=1时用postUrl if (opts.postUrl && mode == 1) { url = opts.postUrl; } //mode!=1时用putUrl if (opts.putUrl && mode != 1) { url = opts.putUrl; } return url; }
4)保存的方法返回的时候直接返回$.ajax创建的对象,不考虑对外部提供任何的保存后的回调,目的就是为了让保存方法更加简单和单一:
//表单保存逻辑 save: function () { if (this.mode > 2) return false; var opts = this.options, formData = this.getData(), event; //触发beforeSave事件 this.trigger((event = $.Event('beforeSave')), formData); //方便外部对formData进行一些额外的处理 formData = opts.parseSubmitData(formData); //如果beforeSave事件默认行为被阻止,则直接返回 if (event.isDefaultPrevented()) { return false; } var url = this.getSaveUrl(); //发ajax请求保存,同时把Ajax组件创建的实例返回 //方便外部根据实际情况添加自己的回调 return Ajax[opts.ajaxMethod](url, formData); }
5)通过parseSubmitData回调来解决以何种结构传递数据到后台的问题,在上面的save方法的代码中,发起ajax请求前有一个调用parseSubmitData的代码,这个回调需返回一个有效的对象作为实际要传递的数据。formData在调用这个回调前是一个object实例,假如后台是php,我们可以把parseSubmitData定义成:
function (data) { var hasOwn = Object.prototype.hasOwnProperty; var ret = {}; for (var i in data) { if (hasOwn.call(data, i)) { ret['User[' + i + ']'] = data[i]; } } return ret; }
假如formData默认传递时是这样的结构:
调用parseSubmitData后将会是这样的结构:
从我自身的经验来说,一个表单管理的整体组件能够把以上问题解决,基本上功能就够了,因为表单管理的功能本身是比较单一的,当我想要往这个组件添加功能的时候,我总是想两个问题:
1. 是否违背单一原则,加东西会不会让这个组件今后的改动更不稳定
2. 是否能够添加新的组件来解决要添加的功能。
比如说,我原来想把表单校验的功能集成到form.js里面,后面我就发现这是个明显地违背单一原则的决定,最后将表单校验的功能单独出来形成了一个新的组件,这样做最大的好处就是两边的功能没有任何关联影响;而且分开之后,从代码印象上都感觉代码质量跟原来明显不同。
form.js的整体功能
首先它定义的option如下:
var DEFAULTS = { mode: 1, //同FormFieldBase的mod postUrl: '',//编辑时保存的url putUrl: '',//新增时保存的url queryUrl: '',//编辑模式时查询初始值的url key: '',//编辑模式时使用它作为主键的值,跟在queryUrl后面传递到后台查询数据 keyName: '',//编辑模式时使用它作为主键的名称,跟在queryUrl后面传递到后台查询数据 defaultData: {},//新增模式时的默认值,可以是一个object,也可以是一个字符串,是字符串的时候表示一个后台查询的接口地址 ajaxMethod: 'post',//发ajax请求的时候用的方法 fieldOptions: {},//各个字段的选项 parseData: $.noop,//获取初始化数据时,通过这个回调来解析初始化数据 parseSubmitData: function (data) { //保存提交数据到后台之前,可以通过这个回调对要提交的数据做些额外的处理 return data; }, parseInitResponse: function (res) { //使用这个回调来解析获取初始化数据时ajax返回的响应 if (res.code == 200) { return res.data; } else { return {}; } }, onInit: $.noop,//表单初始化完成后的事件回调 onBeforeSave: $.noop//表单保存接口调用前触发的回调 };
要说明的是form.js内部使用了formMap组件来管理表单元素的实例,所以以上option中的mode跟fieldOptions的用法跟上文中的一模一样。
form.js提供了以下api方法在实际工作中可以经常用到:
getMode(): 返回表单的模式:1 2 3
getData(): 获取表单数据
reset(): 表单重置
save(): 保存。
在源码中有一部分可能还需要解释一下:
//设置初始值 this.setInitData().always(function (data) { opts.parseData(data); var fields = {}; for (var i in data) { if (hasOwn.call(data, i)) { fields[i] = ''; } } for (var i in opts.fieldOptions) { if (hasOwn.call(opts.fieldOptions, i)) { fields[i] = ''; } } //解析字段的初始值 var fieldOptions = {}; for (var i in fields) { if (hasOwn.call(fields, i)) { fieldOptions[i] = (i in opts.fieldOptions) && opts.fieldOptions[i] || {}; (i in data ) && (fieldOptions[i][that.mode == 1 ? 'defaultValue' : 'value'] = data[i]); } } //初始值可能是异步获取的,所以必须在初始数据获取完毕之后再初始化formMap组件 that.formMap = new FormMap($element, { mode: that.mode, fieldOptions: fieldOptions }); //告诉外部初始化完成 that.trigger('formInit'); });
1)注意always这个方法使用,跟前面介绍的setInitData()的返回值有关系;
2)以上代码中的三个循环,前面2个是为了找出fieldOptions和initData中所有的字段,第三个是为了将字段的option跟initData中的值合并起来,以便最后实例化formMap的时候,直接把fieldOptions传递进去,这样里面的每个表单元素组件在实例化的时候就能得到外部表单组件获取的初始值。
3)还有一种做法:不在表单获取完initData后再去初始化formMap,而是之前就初始化好,然后当initData获取完以后再通过formMap的setData方法来设置初始值,这样有两个问题:
a. formMap提前初始化,各个表单元素组件的初始值都是空的,当form调用reset的时候,不会重置成form获取的值,而是reset成空值;
b. setData方法如果管理不好,会导致在初始化调用的时候触发各个表单元素实例的change事件,这对于初始化过程来说,是不应该的,因为那个时候的change事件不符合语义。
form.js的注意事项
form.js的使用方式可参考demo中的demo2.js。
http://liuyunzhuge.github.io/blog/form/dist/js/app/demo2.js
由于formMap的初始化以及form的init事件触发都是异步的,所以如果外部有些逻辑依赖formMap的话,要考虑把那些逻辑放到form的onInit事件回调里面去做,否则即使不报undefined错误,也达不到想要的功能。
本文小结
本文提供了一个代码跟功能都很简单的表单组件,它跟上文的那些组件一起,构成了我自己在工作中做表单开发的全部内容,由于它们跟我自身的开发经验有很大的关系,所以我也不敢保证这些东西对每个人来说都一定是好用的,但是至少启发作用还是有的,我写这些东西就是受曾经公司开发平台的启发以及后来的项目实际情况的影响,也许有人看到了这些,会写出更符合自己使用习惯的另一套组件出来,那样的话,对自己或者对工作,都会有很大的价值。
下一篇介绍如何自定义jquery.validation,来实现好看的带tooltip的表单校验,敬请关注:)