本人一向比较喜欢折腾,玩了这么久的knockoutjs,总觉得不够劲,于是又开始准备折腾自己了。
最近在完善Magicodes.WeiChat微信开发框架时,发现之前做的自定义菜单这块太不给力了,而各种第三方平台在这一块做得也比较渣,功能不全不说,界面还很不友好,于是决心重整一版,以满足需求。
下面先上图,新的UI界面如下所示:
如何实现这个功能呢?下面请等我一一道来吧。
左侧树形结构绑定
HTML模板如下所示:
<div class="dd" id="nestable2"> <ol class="dd-list" data-bind="foreach:Menus()"> <li class="dd-item lv1"> <div class="dd-handle"> <span class="pull-right"> <i class="fa fa-plus" data-bind="click:$root.AddClick"></i> <i class="fa fa-times" data-bind="click:$root.RemoveItem"></i> <i class="fa fa-pencil" data-bind="click:$root.ItemClick"></i> </span> <span> <span class="label label-info"><i class="fa" data-bind="css:$root.getIconCssByType(type)"></i></span><span data-bind="text:name,click:$root.ItemClick"></span> </span> </div> <!-- ko if:$data.sub_button !== undefined --> <ol class="dd-list" data-bind="foreach:$data.sub_button"> <li class="dd-item lv2" data-id="2"> <div class="dd-handle"> <span class="pull-right"> <i class="fa fa-times" data-bind="click:$root.RemoveItem"></i> <i class="fa fa-pencil" data-bind="click:$root.ItemClick"></i> </span> <span class="label label-success"><i class="fa" data-bind="css:$root.getIconCssByType(type)"></i></span> <span data-bind="text:name"></span> </div> </li> </ol> <!-- /ko --> </li> </ol> </div>
这里我解释一下,上述模板用到了两个foreach循环,以便绑定这个两级列表。实际上如果数据结构支持的话,ko是可以递归的绑定的。ko的强大性是毋庸置疑的。然后注意这个注释:“<!-- ko if:$data.sub_button !== undefined -->”,这个真的不是注释,这个是有用的。为了不产生脏元素,ko支持这种绑定写法。这里先用if做了判断,然后再绑定子集。其余的,就是简单的data-bind语法了。
通过上述模板,我们注意到数据结构中两个关键点:Menus和sub_button,那我们就来看看viewModel。viewModel中定义了Menus = ko.observableArray([]),然后使用Ajax获取数据来填充:
//初始化,加载数据 this.Init = function () { mwc.ui.setBusy(); self.Api.request('GET', { url: '/api/Menus', func: function (data) { mwc.ui.clearBusy(); $.each(data, function (i, v) { if (v.sub_button) { $.each(v.sub_button, function (i1, v1) { v.sub_button[i1] = $.extend(self.getModelTpl(), v1); }) } data[i] = $.extend(self.getModelTpl(), v); }); self.Menus(ko.mapping.fromJS(data)); } }); };
注意,因为方便,这里使用了knockout.mapping js,请注意ko.mapping.fromJS方法。
右侧编辑模板绑定
这块无疑是比较复杂的一块,我们先进行肢解:
- 通用模块:顶部按钮组、名称输入框、保存按钮
- 模板(按微信类型加载不同模板)
我们先来看看整体的编辑模板:
<div class="ibox-title"> <h5>按钮其他参数 </h5> </div> <div class="ibox-content" data-bind="with:EditModel" style="min-height: 600px;"> <form class="form-horizontal"> <!-- ko if:type() != 'empty' --> <buttonschoices params="SelectsModel: $root.SelectTypes,SelectValue:type"></buttonschoices> <div class="hr-line-dashed"></div> <div class="form-group"> <label class="col-sm-2 control-label">名称</label> <div class="col-sm-10"> <input type="text" class="form-control" data-bind="value:name" required> </div> </div> <div class="hr-line-dashed"></div> <!-- /ko --> <div data-bind="template:{name:$root.GetEditTemplateName,data:$root.EditModel,afterRender:$root.afterEditTemplateRender}"> </div> <!-- ko if:type() != 'empty' --> <div> <button class="btn btn-primary pull-right" type="button" data-bind="click:$root.Save"> <i class="fa fa-save"></i> <strong>保存</strong> </button> </div> <!-- /ko --> </form> </div>
由模板可知,整个编辑模块由类型按钮组、名称框、动态模板、保存按钮组成。接下来我就先介绍下类型按钮组的定义与绑定:
类型按钮组——knockout component
如上述代码中,使用了html标签buttonschoices。而这个标签就是我定义的knockout compoent。使用knockout compoent能做什么呢?就如上述代码中,我们可以知道以下几点:
- 返回HTML模板
- 传递参数,绑定compoent ViewModel
那么封装knockout compoent,有助于我们封装一些通用UI组件,就比如按钮组类型选择。我们先来一览代码:
//按钮组选择组件 ko.components.register('buttonschoices', { viewModel: function (params) { var self = this; //所选值 this.SelectValue = ko.observable(); //text:文本 //value:值 //icon:图标 //des:描述 this.SelectItem = ko.observable({ text: "", value: "", icon: "", des: "" }); //选择模型 this.SelectsModel = ko.observableArray([]); if (params && typeof (params.SelectsModel()) != "undefined") { self.SelectsModel(params.SelectsModel()); if (typeof (params.SelectValue()) != "undefined") { self.SelectValue(params.SelectValue()); self.SelectItem($.grep(self.SelectsModel(), function (v, i) { return v.value == self.SelectValue() })[0]); } } this.GetActiveCss = function (item) { return item.value == self.SelectValue() ? "active btn-primary" : ""; } this.buttonClick = function (item) { self.SelectValue(item.value); self.SelectItem(item); params.SelectValue(item.value); } }, template: '<div class="btn-group" data-bind="foreach: SelectsModel">' + '<button class="btn btn-white" data-bind="css:$parent.GetActiveCss($data),click:$parent.buttonClick"><i class="fa" data-bind="css:icon"></i> <span data-bind="text:text"></span></button>' + '</div>' + '<div class="well" data-bind="with:SelectItem">' + '<span data-bind="text:des"></span>' + '</div>' });
整个组件代码很简洁明了,通过ko.components.register注册组件,buttonschoices为组件名称,整个组件由两部分组成:
- viewModel:视图模型
- template:模板
其中,viewModel接收了传入参数,并且进行了处理。我们来依次解析这个viewModel:
- SelectValue:所选指。这个所选指会根据传入参数(还记得前面的“<buttonschoices params="SelectsModel: $root.SelectTypes,SelectValue:type"></buttonschoices>”吗,其中SelectValue:type就是传入了参数SelectValue)进行赋值,如右侧代码:self.SelectValue(params.SelectValue())。
- SelectItem:所选项。项结构为{ text: "", value: "", icon: "", des: "" },分别代表文本、值、图标和描述。
- SelectsModel:选择模型,就是列表模型。有多少个按钮,就看其有多少个项了。传入参数见“SelectsModel: $root.SelectTypes”。我们来看看这个$root.SelectTypes是怎么定义的:
//类型选择 this.SelectTypes = ko.observableArray([ { text: "点击推事件", value: "click", icon: "fa-font", des: "用户点击此类型按钮后,微信服务器会通过消息接口推送消息类型为event 的结构给开发者(参考消息接口指南),并且带上按钮中开发者填写的key值,开发者可以通过自定义的key值与用户进行交互" }, { text: "跳转URL", value: "view", icon: "fa-link", des: "用户点击此类型按钮后,微信客户端将会打开开发者在按钮中填写的网页URL,可与网页授权获取用户基本信息接口结合,获得用户基本信息。" }, { text: "扫码推事件", value: "scancode_push", icon: "fa-qrcode", des: "用户点击按钮后,微信客户端将调起扫一扫工具,完成扫码操作后显示扫描结果(如果是URL,将进入URL),且会将扫码的结果传给开发者,开发者可以下发消息。" }, { text: "扫码推事件且弹出“消息接收中”提示框", value: "scancode_waitmsg", icon: "fa-qrcode", des: "用户点击按钮后,微信客户端将调起扫一扫工具,完成扫码操作后,将扫码的结果传给开发者,同时收起扫一扫工具,然后弹出“消息接收中”提示框,随后可能会收到开发者下发的消息。" }, { text: "弹出系统拍照发图", value: "pic_sysphoto", icon: "fa-camera", des: "用户点击按钮后,微信客户端将调起系统相机,完成拍照操作后,会将拍摄的相片发送给开发者,并推送事件给开发者,同时收起系统相机,随后可能会收到开发者下发的消息。" }, { text: "弹出拍照或者相册发图", value: "pic_photo_or_album", icon: "fa-camera", des: "用户点击按钮后,微信客户端将弹出选择器供用户选择“拍照”或者“从手机相册选择”。用户选择后即走其他两种流程。" }, { text: "弹出微信相册发图器", value: "pic_weixin", icon: "fa-picture-o", des: "用户点击按钮后,微信客户端将调起微信相册,完成选择操作后,将选择的相片发送给开发者的服务器,并推送事件给开发者,同时收起相册,随后可能会收到开发者下发的消息。" }, { text: "弹出地理位置选择器", value: "location_select", icon: "fa-map-marker", des: "用户点击按钮后,微信客户端将调起地理位置选择工具,完成选择操作后,将选择的地理位置发送给开发者的服务器,同时收起位置选择工具,随后可能会收到开发者下发的消息。" }, { text: "下发消息(除文本消息)", value: "media_id", icon: "fa-newspaper-o", des: "用户点击按钮后,微信服务器会将开发者填写的永久素材id对应的素材下发给用户,永久素材类型可以是图片、音频、视频、图文消息。请注意:永久素材id必须是在“素材管理/新增永久素材”接口上传后获得的合法id。" }, { text: "跳转图文消息URL", value: "view_limited", icon: "fa-envelope", des: "用户点击按钮后,微信客户端将打开开发者在按钮中填写的永久素材id对应的图文消息URL,永久素材类型只支持图文消息。请注意:永久素材id必须是在“素材管理/新增永久素材”接口上传后获得的合法id。" } ]);
众所周知,微信自定义菜单支持10中类型的按钮,那么这里是其类型的定义。这也说明,这个按钮组是完全通用的,你只要给予与上述结构一致的数据,其就能显示成当前效果。
- GetActiveCss:获取当前所选样式。选中返回选中样式,否则返回空。
- buttonClick:按钮点击事件,这里拿到的是数据项,ko就是这么方便。然后值得注意的是,参数是双向的,我们可以利用“params.SelectValue(item.value);”来回写值,这样编辑模型的类型值才会产生改变。
viewModel很简单,template也很简单,就是将刚才所说的viewModel绑定,用到了BootStrap按钮组样式“btn-group”,用foreach绑定SelectsModel,然后逐个绑定。
注意:
$parent表示父级对象,即乃父,因为foreach之后,其实对象已经指定到了乃父的儿子(SelectsModel)的某个儿子($data)上,而GetActiveCss是viewModel的女儿,自然要通过乃父来获取了,毕竟其乃父的儿子的子孙并不是她。
$data表示当前项,即乃父的儿子的某个儿子,用于循环中获取当前项数据。
with类似于using命名空间一样,用了它,下面的元素都可以省却改命名空间了。
是不是很简单的样子。我们再来说说模板:
动态加载模板
首先,我们先聚焦到以下代码:
<div data-bind="template:{name:$root.GetEditTemplateName,data:$root.EditModel,afterRender:$root.afterEditTemplateRender}"> </div>
首先我们得明确以下内容:
template语法用于绑定模板,其中name用于指定模板名称,这里绑定了$root.GetEditTemplateName方法,data用于指定模板的viewModel。
然后我们再来看看GetEditTemplateName怎么回事?如下所示:
//根据类型获取编辑模板 this.GetEditTemplateName = function (data) { switch (data.type()) { case "empty": return "emptyTemplate"; case "media_id": case "view_limited": return "media_idTemplate"; case "view": return "urlTemplate" default: return "keyTemplate"; } };
看起来也蛮简单的样子,就返回了一个模板名称,那我们再继续来看看这些模板。
<script id="emptyTemplate" type="text/html"> <div class="well"> <h3>注意事项:</h3> 创建自定义菜单后,由于微信客户端缓存,需要24小时微信客户端才会展现出来。测试时可以尝试取消关注公众账号后再次关注,则可以看到创建后的效果。 </div> </script> <script id="keyTemplate" type="text/html"> <div class="form-group" id="buttonDetails_url_area"> <label class="col-sm-2 control-label">关键字</label> <div class="col-sm-10"> <input type="text" class="form-control" data-bind="value:key" /> </div> </div> </script> <script id="urlTemplate" type="text/html"> <div class="form-group" id="buttonDetails_url_area"> <label class="col-sm-2 control-label">链接</label> <div class="col-sm-10"> <input type="url" class="form-control" data-bind="value:url" /> </div> </div> </script> <script id="media_idTemplate" type="text/html"> <news-choice-button params="value: media_id"></news-choice-button> </script> <div data-bind="with:EditModel"> <news-choice-modal params="value: media_id"></news-choice-modal> </div>
模板的定义也蛮简单的,id和上面的字符串是一致的,类型必须为text/html。上面模板分别为空模板,关键字模板,链接模板和素材模板。
其中素材模板里面使用了自定义的component,和之前的buttonschoices一样,封装了多图文选择代码。
由于组件news-choice-button和news-choice-modal需要讲解的篇幅比较长,这里就暂不介绍了。
至于增删改查,对于ko来说,都是操作数据模型。比如左侧树形结构的增删,则是对Menus数组的增减操作,而编辑,则需要更新数组中的数据项。viewModel的修改,ko会自动重绘UI。这里就不多介绍了。
总结
通过使用knockoutjs 的动态模板,我们可以很方便的根据需要加载不同的模板进行绑定显示。而通过knockoutjs component的封装,我们可以很方便的实现对业务或者通用UI组件的封装,以达到重复使用的目的。