この記事で紹介するのは、Web ページに一般的な画像をアップロードした後、ページ上で小さな画像プレビューを直接生成する実装アイデアです。この機能には一定の適用性があることを考慮して、実際には関連するロジックが ImageUploadView コンポーネントにカプセル化されます。この効果は、次の段落の git レンダリングで確認できます。このコンポーネントを実装するプロセスでは、継承ライブラリ class.js やコンポーネントのイベント管理ライブラリ eventsBase.js など、以前のブログで紹介した関連コンテンツを使用し、独自の責任、パフォーマンス、パフォーマンスの分離も組み込みました。両方の側面に関する意見を読んで交換することを歓迎します。
デモ効果:
注: デモのコードはすべて静的であるため、ファイル アップロード コンポーネントは setTimeout を使用してシミュレートされていますが、その呼び出し方法は実際の作業で使用するアップロード コンポーネントとまったく同じであるため、デモのコードは効果があります。実際の機能要件を完全に満たします。
前回のブログの考え方に従って、まずこのアップロード プレビュー機能の要件を紹介します。
1. 需要分析
前のデモンストレーション レンダリングによると、分析要件は次のとおりです:
1) 最初、アップロード領域にはクリック可能なアップロード ボタンのみが表示されます。ボタンをクリックすると、アップロードに成功した画像が
の後ろのプレビュー領域に表示されます。
2) アップロードした画像がプレビュー領域に追加された後、削除ボタンを押すと削除できます
3) アップロードされる画像の総数が特定の制限に達すると、たとえばデモでのアップロード制限が 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) { }
为了统一处理行为前后的事件派发逻辑,将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这个组件最终实现起来并不难,但是我也花了不少时间去琢磨它及其它父类的实现方法,大部分时间都花在对职责分离和行为分离的抽象过程中。我在本文表达的关于这两方面编程思想的观点也只是自己个人的实际体会,因为抽象层面的东西,每个人的思考方式不同最终理解的成果也就不会相同,所以我也不能直接说我的对还是不对,写出来的目的就是为了分享和交流,看看有没有其他有经验的朋友也愿意把自己在这方面的想法拿出来跟大家说一说,相信每个人看多了别人的思路之后,也会对自己的编程思想方面的锻炼带来帮助。