本文要介绍的是网页中常见的图片上传后直接在页面生成小图预览的实现思路,考虑到该功能有一定的适用性,于是把相关的逻辑封装成了一个ImageUploadView组件,实际使用效果可查看下一段的git效果图。在实现这个组件的过程中,有用到前面几篇博客介绍的相关内容,比如继承库class.js,任意组件的事件管理库eventBase.js,同时包含进了自己对职责分离,表现与行为分离这两方面的一些思考,欢迎阅读与交流。
演示效果(代码下载):
注:由于演示的代码都是静态的,所以文件上传的组件是用setTimeout模拟的,不过它的调用方式跟我自己在实际工作中用上传组件完全相同,所以演示效果的代码实现完全符合真实的功能需求。
按照我以前博客的思路,先来介绍下这个上传预览功能的需求。
1. 需求分析
根据前面的演示效果图,分析需求如下:
1)初始时上传区域只显示一个可点击上传的按钮,当点击该按钮后,将上传成功的图片显示在后面的预览区域
2)上传的图片添加到预览区域以后,可以通过删除按钮来移除
3)当已上传的图片总数达到一定限制之后,比如演示中已上传的限制为4,就把上传按钮给移除掉;
4)当已上传的图片总数达到一定限制之时,如果通过删除操作移除了某张图片,还得再把上传按钮给显示出来。
以上需求是看的见的,根据经验,还可以分析得出的需求如下:
1)如果页面是编辑状态,也就是从数据库中查询出来的状态,只要图片列表不为空,初始时还得把图片显示出来;而且还要根据查出来的图片列表的长度跟上传限制去控制上传按钮是否显示;
2)如果当前页面是一种只能看不能改的状态,那么在初始时一定要把上传按钮和删除按钮移除掉。
需求分析完毕,接下来说明一下我的实现思路。
2. 实现思路
由于这是个表单页面,所以图片上传完以后如果要提交到后台,肯定得需要一个文本域,所以我在做静态页面的时候就把这个文本域考虑进去了,当上传完新的图片以及删除了图片之后都得去修改这个文本域的值。当时做静态页时这部分的结构如下:
<div class="holy-layout-am appForm-group appForm-group-img-upload clearfix"> <label class="holy-layout-al">法人身份证电子版</label> <div class="holy-layout-m"> <input id="legalPersonIDPic-input" name="legalPersonIDPic" class="form-control form-field" type="hidden"> <ul id="legalPersonIDPic-view" class="image-upload-view clearfix"> <li class="view-item-add"><a class="view-act-add" href="javascript:;" title="点击上传">+</a> </li> </ul> <p class="img-upload-msg"> 请确保图片清晰,文字可辨 <a href="#" title="查看示例"><i class="fa fa-question-circle"></i> 查看示例</a> </p> </div> </div>
从这个结构还可以看出,我把整个上传区域都放在一个ul里面,然后把ul的第一个li作为上传按钮来使用。为了完成这个功能,我们主要的任务是:上传及上传后的回调,新增或删除图片预览以及文本域值的管理。从这一点,结合职责分离的思想,这个功能至少需要三个组件,一个负责文件上传,一个负责图片预览的管理,一个负责文本域值的管理。千万不能把这三个功能,两两或者全部都封装在一起,那样的话功能耦合太强,写出来的组件可扩展性可重用性不高。如果这三个组件之间需要交互,我们只要借助回调函数或者发布-订阅模式定义它们给外部调用的接口即可。
不过文本域值的管理本身就很简单,写不写成组件都关系不大,但是至少函数级别的封装是得有的;文件上传组件虽然不是本文的重点,但是网上有很多现成的开源插件,比如webuploader,不管是直接用还是做二次封装都可以应用进来;图片预览的功能是本文的核心内容,ImageUploadView这个组件就是对它的封装,从需求来看,这个组件有语义的实例方法无非就是三个,分别是render, append, delItem,其中render用来在初始化完成之后显示初始的预览列表,append用来在上传成功后添加新的图片预览,delItem用来删除已有的图片预览,按照这个基本思路,我们只需要再结合需求和组件开发的经验为它设计好options和事件即可。
从前面的需求我们发现,这个ImageUploadView组件的render会受到页面状态的影响,当页面为查看模式时,这个组件不能做上传和删除的操作,所以可以考虑给它加一个readonly的option。同时它的上传和删除操作还会影响到上传按钮的UI逻辑,这个跟上传限制有关系,为了灵活性,也得把上传限制作为一个option。从前一段提到的三个实例方法来说,按照自己以前定义事件的经验,一般一个实例方法会定义一对事件,就像bootstrap的插件的做法一样,比如render方法,可以定义一个render.before,这个事件在render的主要逻辑执行前触发,如果外部监听器调用了这个事件的preventDefault()方法,那么render的主要逻辑都不会执行;还有一个render.after事件,这个事件在render的主要逻辑执行后触发。这种成对定义事件的好处是,既给外部提供扩展组件功能的方法,又能增加组件默认行为的管理。
最后从我之前的工作经验来说,除了有上传图片进行预览这样的功能,我曾经还做过上传视频,上传音频,上传普通文档等类似的,所以这一次碰到这个功能的时候我就觉得应该把这些功能里面相似的东西抽取出来,作为一个基类,图片上传,视频上传等分别继承这个基类去实现各自的逻辑。这个基类还有一个好处,就是能够让那些通用的逻辑完全与HTML结构分离,在这个基类里面只做一些通用的事情,比如options与组件行为(render, append, delItem)的定义,以及通用事件的监听和触发,它只要留有固定的接口留给子类来实现即可。在后面的实现中,我定义了一个FileUploadBaseView组件来完成这个基类的功能,这个基类不包含任何html或css处理的逻辑,它只是抽象了我们要完成的功能,不处理任何业务逻辑。根据业务逻辑实现的子类会受html结构的限制,所以子类的适用范围小;而基类因为做到了与html结构完全分离,所以有更大的适用范围。
3. 实现细节
从第2部分的实现思路,要实现的类有:FileUploadBaseView和ImageUploadView,前者是后者的基类。同时考虑到要给组件提供事件管理的功能,所以要用到上一篇博客的eventBase.js,FileUploadBaseView得继承该库的EventBase组件;考虑到要有类的定义和继承,还要用到之前写的继承库class.js来定义组件以及组件的继承关系。相关组件的继承关系为:ImageUploadView extend FileUploadBaseView extend EventBase。
(注:以下相关代码中模块化用的是seajs。)
FileUploadBaseView所做的事情有:
1)定义通用的option以及通用的事件管理
在该组件的DEFAULTS配置中可以看到所有的通用option和通用事件的定义:
var DEFAULTS = { data: [], //要展示的数据列表,列表元素必须是object类型的,如[{url: 'xxx.png'},{url: 'yyyy.png'}] sizeLimit: 0, //用来限制BaseView中的展示的元素个数,为0表示不限制 readonly: false, //用来控制BaseView中的元素是否允许增加和删除 onBeforeRender: $.noop, //对应render.before事件,在render方法调用前触发 onRender: $.noop, //对应render.after事件,在render方法调用后触发 onBeforeAppend: $.noop, //对应append.before事件,在append方法调用前触发 onAppend: $.noop, //对应append.after事件,在append方法调用后触发 onBeforeDelItem: $.noop, //对应delItem.before事件,在delItem方法调用前触发 onDelItem: $.noop //对应delItem.after事件,在delItem方法调用后触发 };
在该组件的init方法中可以看到对通用option和事件管理的初始化逻辑:
init: function (element, options) { //通过this.base调用父类EventBase的init方法 this.base(element); //实例属性 var opts = this.options = this.getOptions(options); this.data = resolveData(opts.data); delete opts.data; this.sizeLimit = opts.sizeLimit; this.readOnly = opts.readOnly; //绑定事件 this.on('render.before', $.proxy(opts.onBeforeRender, this)); this.on('render.after', $.proxy(opts.onRender, this)); this.on('append.before', $.proxy(opts.onBeforeAppend, this)); this.on('append.after', $.proxy(opts.onAppend, this)); this.on('delItem.before', $.proxy(opts.onBeforeDelItem, this)); this.on('delItem.after', $.proxy(opts.onDelItem, this)); },
2)定义组件的行为,预留可供子类实现的接口:
render: function () { /** * render是一个模板,子类不需要重写render方法,只需要重写_render方法 * 当调用子类的render方法时调用的是父类的render方法 * 但是执行到_render方法时,调用的是子类的_render方法 * 这样就能把before跟after事件的触发操作统一起来 */ var e; this.trigger(e = $.Event('render.before')); if (e.isDefaultPrevented()) return; this._render(); this.trigger($.Event('render.after')); }, //子类需实现_Render方法 _render: function () { }, append: function (item) { var e; if (!item) return; item = resolveDataItem(item); this.trigger(e = $.Event('append.before'), item); if (e.isDefaultPrevented()) return; this.data.push(item); this._append(item); this.trigger($.Event('append.after'), item); }, //子类需实现_append方法 _append: function (data) { }, delItem: function (uuid) { var e, item = this.getDataItem(uuid); if (!item) return; this.trigger(e = $.Event('delItem.before'), item); if (e.isDefaultPrevented()) return; this.data.splice(this.getDataItemIndex(uuid), 1); this._delItem(item); this.trigger($.Event('delItem.after'), item); }, //子类需实现_delItem方法 _delItem: function (data) { }
FileUploadBaseView整体实现如下:
define(function (require, exports, module) { var $ = require('jquery'); var Class = require('mod/class'); var EventBase = require('mod/eventBase'); var DEFAULTS = { data: [], //要展示的数据列表,列表元素必须是object类型的,如[{url: 'xxx.png'},{url: 'yyyy.png'}] sizeLimit: 0, //用来限制BaseView中的展示的元素个数,为0表示不限制 readonly: false, //用来控制BaseView中的元素是否允许增加和删除 onBeforeRender: $.noop, //对应render.before事件,在render方法调用前触发 onRender: $.noop, //对应render.after事件,在render方法调用后触发 onBeforeAppend: $.noop, //对应append.before事件,在append方法调用前触发 onAppend: $.noop, //对应append.after事件,在append方法调用后触发 onBeforeDelItem: $.noop, //对应delItem.before事件,在delItem方法调用前触发 onDelItem: $.noop //对应delItem.after事件,在delItem方法调用后触发 }; /** * 数据处理,给data的每条记录都添加一个_uuid的属性,方便查找 */ function resolveData(data) { var time = new Date().getTime(); return $.map(data, function (d) { return resolveDataItem(d, time); }); } function resolveDataItem(data, time) { time = time || new Date().getTime(); data._uuid = '_uuid' + time + Math.floor(Math.random() * 100000); return data; } var FileUploadBaseView = Class({ instanceMembers: { init: function (element, options) { //通过this.base调用父类EventBase的init方法 this.base(element); //实例属性 var opts = this.options = this.getOptions(options); this.data = resolveData(opts.data); delete opts.data; this.sizeLimit = opts.sizeLimit; this.readOnly = opts.readOnly; //绑定事件 this.on('render.before', $.proxy(opts.onBeforeRender, this)); this.on('render.after', $.proxy(opts.onRender, this)); this.on('append.before', $.proxy(opts.onBeforeAppend, this)); this.on('append.after', $.proxy(opts.onAppend, this)); this.on('delItem.before', $.proxy(opts.onBeforeDelItem, this)); this.on('delItem.after', $.proxy(opts.onDelItem, this)); }, getOptions: function (options) { return $.extend({}, this.getDefaults(), options); }, getDefaults: function () { return DEFAULTS; }, getDataItem: function (uuid) { //根据uuid获取dateItem return this.data.filter(function (item) { return item._uuid === uuid; })[0]; }, getDataItemIndex: function (uuid) { var ret; this.data.forEach(function (item, i) { item._uuid === uuid && (ret = i); }); return ret; }, render: function () { /** * render是一个模板,子类不需要重写render方法,只需要重写_render方法 * 当调用子类的render方法时调用的是父类的render方法 * 但是执行到_render方法时,调用的是子类的_render方法 * 这样就能把before跟after事件的触发操作统一起来 */ var e; this.trigger(e = $.Event('render.before')); if (e.isDefaultPrevented()) return; this._render(); this.trigger($.Event('render.after')); }, //子类需实现_Render方法 _render: function () { }, append: function (item) { var e; if (!item) return; item = resolveDataItem(item); this.trigger(e = $.Event('append.before'), item); if (e.isDefaultPrevented()) return; this.data.push(item); this._append(item); this.trigger($.Event('append.after'), item); }, //子类需实现_append方法 _append: function (data) { }, delItem: function (uuid) { var e, item = this.getDataItem(uuid); if (!item) return; this.trigger(e = $.Event('delItem.before'), item); if (e.isDefaultPrevented()) return; this.data.splice(this.getDataItemIndex(uuid), 1); this._delItem(item); this.trigger($.Event('delItem.after'), item); }, //子类需实现_delItem方法 _delItem: function (data) { } }, extend: EventBase, staticMembers: { DEFAULTS: DEFAULTS } }); return FileUploadBaseView; });
ImageUploadView 的实现就比较简单了,跟填空差不多,有几个点需要说明一下:
1)这个类的DEFAULTS需要扩展父类的DEFAULTS,以便添加这个子类的默认options,同时还保留父类默认options的定义;根据静态页面结构,新增了一个onAppendClick事件,外部可在这个事件中调用文件上传组件的相关方法:
//继承并扩展父类的默认DEFAULTS var DEFAULTS = $.extend({}, FileUploadBaseView.DEFAULTS, { onAppendClick: $.noop //点击上传按钮时候的回调 });
2)在init方法中,需要调用父类的init方法,才能完成那些通用的逻辑处理;同时在init的最后还得手动调用一下render方法,以便在组件实例化之后就能看到效果:
其它实现纯粹是业务逻辑实现,跟第2部分的需求密切相关。
ImageUploadView的整体实现如下:
define(function (require, exports, module) { var $ = require('jquery'); var Class = require('mod/class'); var FileUploadBaseView = require('mod/fileUploadBaseView'); //继承并扩展父类的默认DEFAULTS var DEFAULTS = $.extend({}, FileUploadBaseView.DEFAULTS, { onAppendClick: $.noop //点击上传按钮时候的回调 }); var ImageUploadView = Class({ instanceMembers: { init: function (element, options) { var $element = this.$element = $(element); var opts = this.getOptions(options); //调用父类的init方法完成options获取,data解析以及通用事件的监听处理 this.base(this.$element, options); //添加上传和删除的监听器及触发处理 if (!this.readOnly) { var that = this; that.on('appendClick', $.proxy(opts.onAppendClick, this)); $element.on('click.append', '.view-act-add', function (e) { e.preventDefault(); that.trigger('appendClick'); }); $element.on('click.remove', '.view-act-del', function (e) { var $this = $(e.currentTarget); that.delItem($this.data('uuid')); e.preventDefault(); }); } this.render(); }, getDefaults: function () { return DEFAULTS; }, _setItemAddHtml: function () { this.$element.prepend($('<li class="view-item-add"><a class="view-act-add" href="javascript:;" title="点击上传">+</a></li>')); }, _clearItemAddHtml: function ($itemAddLi) { $itemAddLi.remove(); }, _render: function () { var html = [], that = this; //如果不是只读的状态,并且还没有达到上传限制的话,就添加上传按钮 if (!(this.readOnly || (this.sizeLimit && this.sizeLimit <= this.data.length))) { this._setItemAddHtml(); } this.data.forEach(function (item) { html.push(that._getItemRenderHtml(item)) }); this.$element.append($(html.join(''))); }, _getItemRenderHtml: function (item) { return [ '<li id="', item._uuid, '"><a class="view-act-preview" href="javascript:;"><img alt="" src="', item.url, '">', this.readOnly ? '' : '<span class="view-act-del" data-uuid="', item._uuid, '">删除</span>', '</a></li>' ].join(''); }, _dealWithSizeLimit: function () { if (this.sizeLimit) { var $itemAddLi = this.$element.find('li.view-item-add'); //如果已经达到上传限制的话,就移除上传按钮 if (this.sizeLimit && this.sizeLimit <= this.data.length && $itemAddLi.length) { this._clearItemAddHtml($itemAddLi); } else if (!$itemAddLi.length) { this._setItemAddHtml(); } } }, _append: function (data) { this.$element.append($(this._getItemRenderHtml(data))); this._dealWithSizeLimit(); }, _delItem: function (data) { $('#' + data._uuid).remove(); this._dealWithSizeLimit(); } }, extend: FileUploadBaseView }); return ImageUploadView; });
4. 演示说明
演示的项目结构为:
框起来的就是演示的核心代码。其中fileUploadBaserView.js和imageUploadView.js是前面实现的两个核心组件。fileUploader.js是用来模拟上传组件的,它的实例有一个onSuccess的回调,表示上传成功;还有一个openChooseFileWin用来模拟真实的打开选择文件窗口并上传的这个过程:
define(function(require, exports, module) { return function() { var imgList = ['../img/1.jpg','../img/2.jpg','../img/3.jpg','../img/4.jpg'], i = 0; var that = this; that.onSuccess = function(uploadValue){} this.openChooseFileWin = function(){ setTimeout(function(){ that.onSuccess(imgList[i++]); if(i == imgList.length) { i = 0; } },1000); } } });
app/regist.js是演示页面的逻辑代码,关键的部分已用注释进行说明:
define(function (require, exports, module) { var $ = require('jquery'); var ImageUploadView = require('mod/imageUploadView'); var FileUploader = require('mod/fileUploader');//这是用异步任务模拟的文件上传组件 //$legalPersonIDPic,用来存储已上传的文件信息,上传组件上传成功之后以及ImageUploadView组件删除某个item之后会对$legalPersonIDPic的值产生影响 var $legalPersonIDPic = $('#legalPersonIDPic-input'), data = JSON.parse($legalPersonIDPic.val() || '[]');//data是初始值,比如当前页面有可能是从数据库加载的,需要用ImageUploadView组件呈现出来 //在文件上传成功之后,将刚上传的文件保存到$legalPersonIDPic的value中 //$legalPersonIDPic以json字符串的形式存储 var appendImageInputValue = function ($input, item) { var value = JSON.parse($input.val() || '[]'); value.push(item); $input.val(JSON.stringify(value)); }; //当调用ImageUploadView组件删除某个item之后,要同步把$legalPersonIDPic中已存储的信息清掉 var removeImageInputValue = function ($input, uuid) { var value = JSON.parse($input.val() || '[]'), index; value.forEach(function (item, i) { if (item._uuid === uuid) { index = i; } }); value.splice(index, 1); $input.val(JSON.stringify(value)); }; var fileUploader = new FileUploader(); fileUploader.onSuccess = function (uploadValue) { var item = {url: uploadValue}; legalPersonIDPicView.append(item); appendImageInputValue($legalPersonIDPic, item); }; var legalPersonIDPicView = new ImageUploadView('#legalPersonIDPic-view', { data: data, sizeLimit: 4, onAppendClick: function () { //打开选择文件的窗口 fileUploader.openChooseFileWin(); }, onDelItem: function (data) { removeImageInputValue($legalPersonIDPic, data._uuid); } }); });
5. 本文总结
ImageUploadView这个组件最终实现起来并不难,但是我也花了不少时间去琢磨它及其它父类的实现方法,大部分时间都花在对职责分离和行为分离的抽象过程中。我在本文表达的关于这两方面编程思想的观点也只是自己个人的实际体会,因为抽象层面的东西,每个人的思考方式不同最终理解的成果也就不会相同,所以我也不能直接说我的对还是不对,写出来的目的就是为了分享和交流,看看有没有其他有经验的朋友也愿意把自己在这方面的想法拿出来跟大家说一说,相信每个人看多了别人的思路之后,也会对自己的编程思想方面的锻炼带来帮助。
最后,希望本文的理论和实践,都能给大家带来实际的工作价值,谢谢阅读:)