What this article will introduce is the implementation idea of generating small image previews directly on the page after uploading common images on web pages. Considering that this function has certain applicability, the relevant logic is encapsulated into an ImageUploadView component for actual use. The effect can be viewed in the git rendering in the next paragraph. In the process of implementing this component, we used the relevant content introduced in the previous blogs, such as the inheritance library class.js, the event management library eventBase.js for any component, and also included our own separation of responsibilities, performance and behavior. Some thoughts on both aspects are welcome to read and exchange.
Demo effect:
Note: Since the code for the demonstration is all static, the file upload component is simulated using setTimeout. However, its calling method is exactly the same as the upload component I use in actual work, so the code for the demonstration effect is completely realized. Meet real functional requirements.
Following the ideas of my previous blog, let me first introduce the requirements for this upload preview function.
1. Demand analysis
According to the previous demonstration renderings, the analysis requirements are as follows:
1) Initially, the upload area only displays a clickable upload button. When the button is clicked, the successfully uploaded image will be displayed in the preview area at the back
2) After the uploaded image is added to the preview area, it can be removed by pressing the delete button
3) When the total number of uploaded images reaches a certain limit, for example, the uploaded limit in the demo is 4, the upload button will be removed;
4) When the total number of uploaded pictures reaches a certain limit, if a picture is removed through a delete operation, the upload button must be displayed again.
The above requirements are visible. Based on experience, the requirements that can be analyzed are as follows:
1) If the page is in editing status, which is the status queried from the database, as long as the picture list is not empty, the pictures must be displayed initially; and the upload must be based on the length of the picture list found. Limit to control whether the upload button is displayed;
2) If the current page is in a state that can only be viewed and cannot be changed, then the upload button and delete button must be removed initially.
Now that the requirements analysis is complete, let me explain my implementation ideas.
2. Implementation ideas
Since this is a form page, if you want to submit the image to the backend after uploading it, you will definitely need a text field, so I took this text field into consideration when making the static page. When the new image is uploaded, Even after deleting the image, you have to modify the value of this text field. When making static pages, the structure of this part was as follows:
<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) { }
为了统一处理行为前后的事件派发逻辑,将render, append ,delItem的主要逻辑抽出来成为需被子类实现的方法_render, _append和_delItem。当调用子类的render方法时,调用的实际上父类的方法,但是当父类执行到_render方法时,执行的就是子类的方法,另外两个方法也是类似的处理。需要注意的是子类不能去覆盖render, append ,delItem三个方法,否则就得自己去处理相关事件的触发逻辑。
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这个组件最终实现起来并不难,但是我也花了不少时间去琢磨它及其它父类的实现方法,大部分时间都花在对职责分离和行为分离的抽象过程中。我在本文表达的关于这两方面编程思想的观点也只是自己个人的实际体会,因为抽象层面的东西,每个人的思考方式不同最终理解的成果也就不会相同,所以我也不能直接说我的对还是不对,写出来的目的就是为了分享和交流,看看有没有其他有经验的朋友也愿意把自己在这方面的想法拿出来跟大家说一说,相信每个人看多了别人的思路之后,也会对自己的编程思想方面的锻炼带来帮助。