我们之前对小程序做了基本学习:
- 1. 微信小程序开发07-列表页面怎么做
- 2. 微信小程序开发06-一个业务页面的完成
- 3. 微信小程序开发05-日历组件的实现
- 4. 微信小程序开发04-打造自己的UI库
- 5. 微信小程序开发03-这是一个组件
- 6. 微信小程序开发02-小程序基本介绍
- 7. 微信小程序开发01-小程序的执行流程是怎么样的?
阅读本文之前,如果大家想对小程序有更深入的了解,或者一些细节的了解可以先阅读上述文章,本文后面点需要对着代码调试阅读
对应的github地址是:https://github.com/yexiaochai/wxdemo
首先我们来一言以蔽之,什么是微信小程序?PS:这个问题问得好像有些扯:)
小程序是一个不需要下载安装就可使用的应用,它实现了应用触手可及的梦想,用户扫一扫或者搜一下即可打开应用。也体现了用完即走的理念,用户不用关心是否安装太多应用的问题。应用将无处不在,随时可用,但又无需安装卸载。从字面上看小程序具有类似Web应用的热部署能力,在功能上又接近于原生APP。
所以说,其实微信小程序是一套超级Hybrid的解决方案,现在看来,小程序应该是应用场景最广,也最为复杂的解决方案了。
很多公司都会有自己的Hybrid平台,我这里了解到比较不错的是携程的Hybrid平台、阿里的Weex、百度的糯米,但是从应用场景来说都没有微信来得丰富,这里根本的区别是:
微信小程序是给各个公司开发者接入的,其他公司平台多是给自己业务团队使用,这一根本区别,就造就了我们看到的很多小程序不一样的特性:
① 小程序定义了自己的标签语言WXML
② 小程序定义了自己的样式语言WXSS
③ 小程序提供了一套前端框架包括对应Native API
④ 禁用浏览器Dom API(这个区别,会影响我们的代码方式)
只要了解到这些区别就会知道为什么小程序会这么设计:
因为小程序是给各个公司的开发做的,其他公司的Hybrid方案是给公司业务团队用的,一般拥有Hybrid平台的公司实力都不错
但是开发小程序的公司实力良莠不齐,所以小程序要做绝对的限制,最大程度的保证框架层(小程序团队)对程序的控制
因为毕竟程序运行在微信这种体量的APP中
之前我也有一个疑惑为什么微信小程序会设计自己的标签语言,也在知乎看到各种各样的回答,但是如果出于设计层面以及应用层面考虑的话:这样会有更好的控制,而且我后面发现微信小程序事实上依旧使用的是webview做渲染(这个与我之前认为微信是NativeUI是向左的),但是如果我们使用的微信限制下面的标签,这个是有限的标签,后期想要换成NativeUI会变得更加轻易:
另一方面,经过之前的学习,我这边明确可以得出一个感受:
① 小程序的页面核心是标签,标签是不可控制的(我暂时没用到js操作元素的方法),只能按照微信给的玩法玩,标签控制显示是我们的view
② 标签的展示只与data有关联,和js是隔离的,没有办法在标签中调用js的方法
③ 而我们的js的唯一工作便是根据业务改变data,重新引发页面渲染,以后别想操作DOM,别想操作Window对象了,改变开发方式,改变开发方式,改变开发方式!
1 this.setData({'wxml': ` 2 <my-component> 3 <view>动态插入的节点</view> 4 </my-component> 5 `});
然后可以看到这个是一个MVC模型
每个页面的目录是这个样子的:
1 project 2 ├── pages 3 | ├── index 4 | | ├── index.json index 页面配置 5 | | ├── index.js index 页面逻辑 6 | | ├── index.wxml index 页面结构 7 | | └── index.wxss index 页面样式表 8 | └── log 9 | ├── log.json log 页面配置 10 | ├── log.wxml log 页面逻辑 11 | ├── log.js log 页面结构 12 | └── log.wxss log 页面样式表 13 ├── app.js 小程序逻辑 14 ├── app.json 小程序公共设置 15 └── app.wxss 小程序公共样式表
每个组件的目录也大概是这个样子的,大同小异,但是入口是Page层。
小程序打包后的结构(这里就真的不懂了,引用:小程序底层框架实现原理解析):
所有的小程序基本都最后都被打成上面的结构
1、WAService.js 框架JS库,提供逻辑层基础的API能力
2、WAWebview.js 框架JS库,提供视图层基础的API能力
3、WAConsole.js 框架JS库,控制台
4、app-config.js 小程序完整的配置,包含我们通过app.json里的所有配置,综合了默认配置型
5、app-service.js 我们自己的JS代码,全部打包到这个文件
6、page-frame.html 小程序视图的模板文件,所有的页面都使用此加载渲染,且所有的WXML都拆解为JS实现打包到这里
7、pages 所有的页面,这个不是我们之前的wxml文件了,主要是处理WXSS转换,使用js插入到header区域
从设计的角度上说,小程序采用的组件化开发的方案,除了页面级别的标签,后面全部是组件,而组件中的标签view、data、js的关系应该是与page是一致的,这个也是我们平时建议的开发方式,将一根页面拆分成一个个小的业务组件或者UI组件:
从我写业务代码过程中,觉得整体来说还是比较顺畅的,小程序是有自己一套完整的前端框架的,并且释放给业务代码的主要就是page,而page只能使用标签和组件,所以说框架的对业务的控制力度很好。
最后我们从工程角度来看微信小程序的架构就更加完美了,小程序从三个方面考虑了业务者的感受:
① 开发工具+调试工具
② 开发基本模型(开发基本标准WXML、WXSS、JS、JSON)
③ 完善的构建(对业务方透明)
④ 自动化上传离线包(对业务费透明离线包逻辑)
⑤ 监控统计逻辑
所以,微信小程序从架构上和使用场景来说是很令人惊艳的,至少惊艳了我......所以我们接下来在开发层面对他进行更加深入的剖析,我们这边最近一直在做基础服务,这一切都是为了完善技术体系,这里对于前端来说便是我们需要做一个Hybrid体系,如果做App,React Native也是不错的选择,但是一定要有完善的分层:
① 底层框架解决开发效率,将复杂的部分做成一个黑匣子,给页面开发展示的只是固定的三板斧,固定的模式下开发即可
② 工程部门为业务开发者封装最小化开发环境,最优为浏览器,确实不行便为其提供一个类似浏览器的调试环境
如此一来,业务便能快速迭代,因为业务开发者写的代码大同小异,所以底层框架配合工程团队(一般是同一个团队),便可以在底层做掉很多效率性能问题。
稍微大点的公司,稍微宽裕的团队,还会同步做很多后续的性能监控、错误日志工作,如此形成一套文档->开发->调试->构建->发布->监控、分析 为一套完善的技术体系
如果形成了这么一套体系,那么后续就算是内部框架更改、技术革新,也是在这个体系上改造,这块微信小程序是做的非常好的。但很可惜,很多其他公司团队只会在这个路径上做一部分,后面由于种种原因不在深入,有可能是感觉没价值,而最恐怖的行为是,自己的体系没形成就贸然的换基础框架,戒之慎之啊!好了闲话少说,我们继续接下来的学习。
我对小程序的理解有限,因为没有源码只能靠惊艳猜测,如果文中有误,请各位多多提点
文章更多面对初中级选手,如果对各位有用,麻烦点赞哟
微信小程序为了对业务方有更强的控制,App层做的工作很有限,我后面写demo的时候根本没有用到app.js,所以我这里认为app.js只是完成了一个路由以及初始化相关的工作,这个是我们看得到的,我们看不到的是底层框架会根据app.json的配置将所有页面js都准备好。
我这里要表达的是,我们这里配置了我们所有的路由:
"pages":[ "pages/index/index", "pages/list/list", "pages/logs/logs" ],
微信小程序一旦载入,会开3个webview,装载3个页面的逻辑,完成基本的实例化工作,只显示首页!这个是小程序为了优化页面打开速度所做的工作,也势必会浪费一些资源,所以到底是全部打开或者预加载几个,详细底层Native会根据实际情况动态变化,我们也可以看到,从业务层面来说,要了解小程序的执行流程,其实只要能了解Page的流程就好了,关于Page生命周期,除了释放出来的API:onLoad -> onShow -> onReady -> onHide等,官方还出了一张图进行说明:
Native层在载入小程序时候,起了两个线程一个的view Thread一个是AppService Thread,我这边理解下来应该就是程序逻辑执行与页面渲染分离,小程序的视图层目前使用 WebView 作为渲染载体,而逻辑层是由独立的 JavascriptCore 作为运行环境。在架构上,WebView 和 JavascriptCore 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 evaluateJavascript
所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 JS 脚本,再通过执行 JS 脚本的形式传递到两边独立环境。而 evaluateJavascript
的执行会受很多方面的影响,数据到达视图层并不是实时的。
因为之前我认为页面是使用NativeUI做渲染跟Webview没撒关系,便觉得这个图有问题,但是后面实际代码看到了熟悉的shadow-dom以及Android可以看到哪部分是Web的,其实小程序主体还是使用的浏览器渲染的方式,还是webview装载HTML和CSS的逻辑,最后我发现这张图是没有问题的,有问题的是我的理解,哈哈,这里我们重新解析这张图:
WXML先会被编译成JS文件,引入数据后在WebView中渲染,这里可以认为微信载入小程序时同时初始化了两个线程,分别执行彼此逻辑:
① WXML&CSS编译形成的JS View实例化结束,准备结束时向业务线程发送通知
② 业务线程中的JS Page部分同步完成实例化结束,这个时候接收到View线程部分的等待数据通知,将初始化data数据发送给View
③ View线程接到数据,开始渲染页面,渲染结束执行通知Page触发onReady事件
这里翻开源码,可以看到,应该是全局控制器完成的Page实例化,完成后便会执行onLoad事件,但是在执行前会往页面发通知:
1 __appServiceSDK__.invokeWebviewMethod({ 2 name: "appDataChange", 3 args: o({}, e, { 4 complete: n 5 }), 6 webviewIds: [t] 7 })
真实的逻辑是这样的,全局控制器会完成页面实例化,这个是根据app.json中来的,全部完成实例化存储起来然后选择第一个page实例执行一些逻辑,然后通知view线程,即将执行onLoad事件,因为view线程和业务线程是两个线程,所以不会造成阻塞,view线程根据初始数据完成渲染,而业务线程继续后续逻辑,执行onLoad,如果onLoad中有setData,那么会进入队列继续通知view线程更新。
所以我个人感觉微信官网那张图不太清晰,我这里重新画了一个图:
都这个时候了,不来个简单的小程序框架实现好像有点不对,我们做小程序实现的主要原因是想做到一端代码三端运行:web、小程序、Hybrid甚至Servce端
我们这里没有可能实现太复杂的功能,这里想的是就实现一个基本的页面展示带一个最基本的标签即可,只做Page一块的简单实现,让大家能了解到小程序可能的实现,以及如何将小程序直接转为H5的可能走法
1 <view> 2 <!-- 以下是对一个自定义组件的引用 --> 3 <my-component inner-text="组件数据"></my-component> 4 <view>{{pageData}}</view> 5 </view>
1 Page({ 2 data: { 3 pageData: '页面数据' 4 }, 5 onLoad: function () { 6 console.log('onLoad') 7 }, 8 })
1 <!-- 这是自定义组件的内部WXML结构 --> 2 <view class="inner"> 3 {{innerText}} 4 </view> 5 <slot></slot>
1 Component({ 2 properties: { 3 // 这里定义了innerText属性,属性值可以在组件使用时指定 4 innerText: { 5 type: String, 6 value: 'default value', 7 } 8 }, 9 data: { 10 // 这里是一些组件内部数据 11 someData: {} 12 }, 13 methods: { 14 // 这里是一个自定义方法 15 customMethod: function () { } 16 } 17 })
我们直接将小程序这些代码拷贝一份到我们的目录:
我们需要做的就是让这段代码运行起来,而这里的目录是我们最终看见的目录,真实运行的时候可能不是这个样,运行之前项目会通过我们的工程构建,变成可以直接运行的代码,而我这里思考的可以运行的代码事实上是一个模块,所以我们这里从最终结果反推、分拆到开发结构目录,我们首先将所有代码放到index.html,可能是这样的:
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>Title</title> 6 </head> 7 <body> 8 9 <script type="text/javascript" src="libs/zepto.js" ></script> 10 <script type="text/javascript"> 11 12 class View { 13 constructor(opts) { 14 this.template = '<view>{{pageShow}}</view><view class="ddd" is-show="{{pageShow}}" >{{pageShow}}<view class="c1">{{pageData}}</view></view>'; 15 16 //由控制器page传入的初始数据或者setData产生的数据 17 this.data = { 18 pageShow: 'pageshow', 19 pageData: 'pageData', 20 pageShow1: 'pageShow1' 21 }; 22 23 this.labelMap = { 24 'view': 'div', 25 '#text': 'span' 26 }; 27 28 this.nodes = {}; 29 this.nodeInfo = {}; 30 } 31 32 /* 33 传入一个节点,解析出一个节点,并且将节点中的数据以初始化数据改变 34 并且将其中包含{{}}标志的节点信息记录下来 35 */ 36 _handlerNode (node) { 37 38 let reg = /\{\{([\s\S]+?)\}\}/; 39 let result, name, value, n, map = {}; 40 let attrs , i, len, attr; 41 42 name = node.nodeName; 43 attrs = node.attributes; 44 value = node.nodeValue; 45 n = document.createElement(this.labelMap[name.toLowerCase()] || name); 46 47 //说明是文本,需要记录下来了 48 if(node.nodeType === 3) { 49 n.innerText = this.data[value] || ''; 50 51 result = reg.exec(value); 52 if(result) { 53 n.innerText = this.data[result[1]] || ''; 54 55 if(!map[result[1]]) map[result[1]] = []; 56 map[result[1]].push({ 57 type: 'text', 58 node: n 59 }); 60 } 61 } 62 63 if(attrs) { 64 //这里暂时只处理属性和值两种情况,多了就复杂10倍了 65 for (i = 0, len = attrs.length; i < len; i++) { 66 attr = attrs[i]; 67 result = reg.exec(attr.value); 68 69 n.setAttribute(attr.name, attr.value); 70 //如果有node需要处理则需要存下来标志 71 if (result) { 72 n.setAttribute(attr.name, this.data[result[1]] || ''); 73 74 //存储所有会用到的节点,以便后面动态更新 75 if (!map[result[1]]) map[result[1]] = []; 76 map[result[1]].push({ 77 type: 'attr', 78 name: attr.name, 79 node: n 80 }); 81 82 } 83 } 84 } 85 86 return { 87 node: n, 88 map: map 89 } 90 91 } 92 93 //遍历一个节点的所有子节点,如果有子节点继续遍历到没有为止 94 _runAllNode(node, map, root) { 95 96 let nodeInfo = this._handlerNode(node); 97 let _map = nodeInfo.map; 98 let n = nodeInfo.node; 99 let k, i, len, children = node.childNodes; 100 101 //先将该根节点插入到上一个节点中 102 root.appendChild(n); 103 104 //处理map数据,这里的map是根对象,最初的map 105 for(k in _map) { 106 if(map[k]) { 107 map[k].push(_map[k]); 108 } else { 109 map[k] = _map[k]; 110 } 111 } 112 113 for(i = 0, len = children.length; i < len; i++) { 114 this._runAllNode(children[i], map, n); 115 } 116 117 } 118 119 //处理每个节点,翻译为页面识别的节点,并且将需要操作的节点记录 120 splitTemplate () { 121 let nodes = $(this.template); 122 let map = {}, root = document.createElement('div'); 123 let i, len; 124 125 for(i = 0, len = nodes.length; i < len; i++) { 126 this._runAllNode(nodes[i], map, root); 127 } 128 129 window.map = map; 130 return root 131 } 132 133 //拆分目标形成node,这个方法过长,真实项目需要拆分 134 splitTemplate1 () { 135 let template = this.template; 136 let node = $(this.template)[0]; 137 let map = {}, n, name, root = document.createElement('div'); 138 let isEnd = false, index = 0, result; 139 140 let attrs, i, len, attr; 141 let reg = /\{\{([\s\S]+?)\}\}/; 142 143 window.map = map; 144 145 //开始遍历节点,处理 146 while (!isEnd) { 147 name = node.localName; 148 attrs = node.attributes; 149 value = node.nodeValue; 150 n = document.createElement(this.labelMap[name] || name); 151 152 //说明是文本,需要记录下来了 153 if(node.nodeType === 3) { 154 n.innerText = this.data[value] || ''; 155 156 result = reg.exec(value); 157 if(result) { 158 n.innerText = this.data[value] || ''; 159 160 if(!map[value]) map[value] = []; 161 map[value].push({ 162 type: 'text', 163 node: n 164 }); 165 } 166 } 167 168 //这里暂时只处理属性和值两种情况,多了就复杂10倍了 169 for(i = 0, len = attrs.length; i < len; i++) { 170 attr = attrs[i]; 171 result = reg.exec(attr.value); 172 173 n.setAttribute(attr.name, attr.value); 174 //如果有node需要处理则需要存下来标志 175 if(result) { 176 n.setAttribute(attr.name, this.data[result[1]] || ''); 177 178 //存储所有会用到的节点,以便后面动态更新 179 if(!map[result[1]]) map[result[1]] = []; 180 map[result[1]].push({ 181 type: 'attr', 182 name: attr.name, 183 node: n 184 }); 185 186 } 187 } 188 189 debugger 190 191 if(index === 0) root.appendChild(n); 192 isEnd = true; 193 index++; 194 195 } 196 197 return root; 198 199 200 console.log(node) 201 } 202 203 } 204 205 let view = new View(); 206 207 document.body.appendChild(window.node) 208 209 </script> 210 </body> 211 </html>
这段代码,非常简单:
① 设置了一段模板,甚至,我们这里根本不关系其格式化状态,直接写成一行方便处理
this.template = '<view>{{pageShow}}</view><view class="ddd" is-show="{{pageShow}}" >{{pageShow}}<view class="c1">{{pageData}}</view></view>';
② 然后我们将这段模板转为node节点(这里可以不用zepto,但是模拟实现怎么简单怎么来吧),然后遍历处理所有节点,我们就可以处理我们的数据了,最终形成了这个html:
1 <div><div><span>ffsd</span></div><div class="ddd" is-show="pageshow"><span>pageshow</span><div class="c1"><span>pageData</span></div></div></div>
③ 与此同时,我们存储了一个对象,这个对象包含所有与之相关的节点:
这个对象是所有setData会影响到node的一个映射表,后面调用setData的时候,便可以直接操作对应的数据了,介于此我们简单的模拟便先到此结束,这里结束的比较仓促有一些原因:
① 这段代码可以是最终打包构建形成的代码,但是我这里的完成度只有百分之一,后续需要大量的构建相关介入
② 这篇文章目的还是接受开发基础,而本章模拟实现太过复杂,如果篇幅大了会主旨不清
③ 这个是最重要的点,我一时也写不出来啊!!!,所以各位等下个长篇,小程序前端框架模拟实现吧
所以我们继续下章吧......
小程序的Page类是这样写的:
1 Page({ 2 data: { 3 pageData: '页面数据' 4 }, 5 onLoad: function () { 6 console.log('onLoad') 7 }, 8 })
传入的是一个对象,显然,我们为了更好的拆分页面逻辑,前面我们介绍了小程序是采用组件化开发的方式,这里的说法可以更进一步,小程序是采用标签化的方式开发,而标签对应的控制器js只会改变数据影响标签显示,所以某种程度小程序开发的特点是:先标签后js,我们构建一个页面,首先就应该思考这个页面有哪些标签,哪些标签是公共的标签,然后设计好标签再做实现。
比如我们一个页面中有比较复杂的日历相关模块,事实上这个日历模块也就是在操作日历标签的数据以及设置点击回调,那么我们就需要将页面分开
比如这里的业务日历模块仅仅是index的一部分(其他页面也可能用得到),所以我们实现了一个页面共用的记录,便与我们更好的分拆页面:
1 class Page { 2 constructor(opts) { 3 //用于基础page存储各种默认ui属性 4 this.isLoadingShow = 'none'; 5 this.isToastShow = 'none'; 6 this.isMessageShow = 'none'; 7 8 this.toastMessage = 'toast提示'; 9 10 this.alertTitle = ''; 11 this.alertMessage = 'alertMessage'; 12 this.alertBtn = []; 13 14 //通用方法列表配置,暂时约定用于点击 15 this.methodSet = [ 16 'onToastHide', 17 'showToast', 18 'hideToast', 19 'showLoading', 20 'hideLoading', 21 'onAlertBtnTap', 22 'showMessage', 23 'hideMessage' 24 ]; 25 26 //当前page对象 27 this.page = null; 28 } 29 //产出页面组件需要的参数 30 getPageData() { 31 return { 32 isMessageShow: this.isMessageShow, 33 alertTitle: this.alertTitle, 34 alertMessage: this.alertMessage, 35 alertBtn: this.alertBtn, 36 37 isLoadingShow: this.isLoadingShow, 38 isToastShow: this.isToastShow, 39 toastMessage: this.toastMessage 40 41 } 42 } 43 44 //pageData为页面级别数据,mod为模块数据,要求一定不能重复 45 initPage(pageData, mod) { 46 //debugger; 47 let _pageData = {}; 48 let key, value, k, v; 49 50 //为页面动态添加操作组件的方法 51 Object.assign(_pageData, this.getPageFuncs(), pageData); 52 53 //生成真实的页面数据 54 _pageData.data = {}; 55 Object.assign(_pageData.data, this.getPageData(), pageData.data || {}); 56 57 for( key in mod) { 58 value = mod[key]; 59 for(k in value) { 60 v = value[k]; 61 if(k === 'data') { 62 Object.assign(_pageData.data, v); 63 } else { 64 _pageData[k] = v; 65 } 66 } 67 } 68 69 console.log(_pageData); 70 return _pageData; 71 } 72 onAlertBtnTap(e) { 73 let type = e.detail.target.dataset.type; 74 if (type === 'default') { 75 this.hideMessage(); 76 } else if (type === 'ok') { 77 if (this.alertOkCallback) this.alertOkCallback.call(this); 78 } else if (type == 'cancel') { 79 if (this.alertCancelCallback) this.alertCancelCallback.call(this); 80 } 81 } 82 showMessage(msg) { 83 let alertBtn = [{ 84 type: 'default', 85 name: '知道了' 86 }]; 87 let message = msg; 88 this.alertOkCallback = null; 89 this.alertCancelCallback = null; 90 91 if (typeof msg === 'object') { 92 message = msg.message; 93 alertBtn = []; 94 msg.cancel.type = 'cancel'; 95 msg.ok.type = 'ok'; 96 97 alertBtn.push(msg.cancel); 98 alertBtn.push(msg.ok); 99 this.alertOkCallback = msg.ok.callback; 100 this.alertCancelCallback = msg.cancel.callback; 101 } 102 103 this.setData({ 104 alertBtn: alertBtn, 105 isMessageShow: '', 106 alertMessage: message 107 }); 108 } 109 hideMessage() { 110 this.setData({ 111 isMessageShow: 'none', 112 }); 113 } 114 //当关闭toast时触发的事件 115 onToastHide(e) { 116 this.hideToast(); 117 } 118 //设置页面可能使用的方法 119 getPageFuncs() { 120 let funcs = {}; 121 for (let i = 0, len = this.methodSet.length; i < len; i++) { 122 funcs[this.methodSet[i]] = this[this.methodSet[i]]; 123 } 124 return funcs; 125 } 126 127 showToast(message, callback) { 128 this.toastHideCallback = null; 129 if (callback) this.toastHideCallback = callback; 130 let scope = this; 131 this.setData({ 132 isToastShow: '', 133 toastMessage: message 134 }); 135 136 // 3秒后关闭loading 137 setTimeout(function() { 138 scope.hideToast(); 139 }, 3000); 140 } 141 hideToast() { 142 this.setData({ 143 isToastShow: 'none' 144 }); 145 if (this.toastHideCallback) this.toastHideCallback.call(this); 146 } 147 //需要传入page实例 148 showLoading() { 149 this.setData({ 150 isLoadingShow: '' 151 }); 152 } 153 //关闭loading 154 hideLoading() { 155 this.setData({ 156 isLoadingShow: 'none' 157 }); 158 } 159 } 160 //直接返回一个UI工具了类的实例 161 module.exports = new Page
其中页面会用到的一块核心就是:
1 //pageData为页面级别数据,mod为模块数据,要求一定不能重复 2 initPage(pageData, mod) { 3 //debugger; 4 let _pageData = {}; 5 let key, value, k, v; 6 7 //为页面动态添加操作组件的方法 8 Object.assign(_pageData, this.getPageFuncs(), pageData); 9 10 //生成真实的页面数据 11 _pageData.data = {}; 12 Object.assign(_pageData.data, this.getPageData(), pageData.data || {}); 13 14 for( key in mod) { 15 value = mod[key]; 16 for(k in value) { 17 v = value[k]; 18 if(k === 'data') { 19 Object.assign(_pageData.data, v); 20 } else { 21 _pageData[k] = v; 22 } 23 } 24 } 25 26 console.log(_pageData); 27 return _pageData; 28 }
调用方式是:
1 Page(_page.initPage({ 2 data: { 3 sss: 'sss' 4 }, 5 // methods: uiUtil.getPageMethods(), 6 methods: { 7 }, 8 goList: function () { 9 if(!this.data.cityStartId) { 10 this.showToast('请选择出发城市'); 11 return; 12 } 13 if(!this.data.cityArriveId) { 14 this.showToast('请选择到达城市'); 15 return; 16 } 17 18 wx.navigateTo({ 19 }) 20 21 } 22 }, { 23 modCalendar: modCalendar, 24 modCity: modCity 25 }))
可以看到,其他组件,如这里的日历模块只是一个对象而已:
1 module.exports = { 2 showCalendar: function () { 3 this.setData({ 4 isCalendarShow: '' 5 }); 6 }, 7 hideCalendar: function () { 8 this.setData({ 9 isCalendarShow: 'none' 10 }); 11 }, 12 preMonth: function () { 13 14 this.setData({ 15 calendarDisplayTime: util.dateUtil.preMonth(this.data.calendarDisplayTime).toString() 16 }); 17 }, 18 nextMonth: function () { 19 this.setData({ 20 calendarDisplayTime: util.dateUtil.nextMonth(this.data.calendarDisplayTime).toString() 21 }); 22 }, 23 onCalendarDayTap: function (e) { 24 let data = e.detail; 25 var date = new Date(data.year, data.month, data.day); 26 console.log(date) 27 28 //留下一个钩子函数 29 if(this.calendarHook) this.calendarHook(date); 30 this.setData({ 31 isCalendarShow: 'none', 32 calendarSelectedDate: date.toString(), 33 calendarSelectedDateStr: util.dateUtil.format(date, 'Y年M月D日') 34 }); 35 }, 36 onContainerHide: function () { 37 this.hideCalendar(); 38 }, 39 40 data: { 41 isCalendarShow: 'none', 42 calendarDisplayMonthNum: 1, 43 calendarDisplayTime: selectedDate, 44 calendarSelectedDate: selectedDate, 45 calendarSelectedDateStr: util.dateUtil.format(new Date(selectedDate), 'Y年M月D日') 46 } 47 }
但是在代码层面却帮我们做到了更好的封装,这个基类里面还包括我们自定义的常用组件,loading、toast等等:
page是最值得封装的部分,这里是基本page的封装,事实上,列表页是常用的一种业务页面,虽然各种列表页的筛选条件不一样,但是主体功能无非都是:
① 列表渲染
② 滚动加载
③ 条件筛选、重新渲染
所以说我们其实可以将其做成一个页面基类,跟abstract-page一个意思,这里留待我们下次来处理吧
请大家对着github中的代码调试阅读这里
前面已经说了,小程序的开发重点是一个个的标签的实现,我们这里将业务组件设置成了一个个mod,UI组件设置成了真正的标签,比如我们页面会有很多非业务类的UI组件:
① alert类弹出层
② loading类弹出层
③ 日历组件
④ toast&message类提示弹出组件
⑤ 容器类组件
⑥ ......
这些都可以我们自己去实现,但是微信其实提供给我们了系统级别的组件:
这里要不要用就看实际业务需求了,一般来说还是建议用的,我们这里为了帮助各位更好的了解小程序组件,特别实现了一个较为复杂,而小程序又没有提供的组件日历组件,首先我们这里先建立一个日历组件目录:
其次我们这里先做最简单实现:
1 let View = require('behavior-view'); 2 const util = require('../utils/util.js'); 3 4 // const dateUtil = util.dateUtil; 5 6 Component({ 7 behaviors: [ 8 View 9 ], 10 properties: { 11 12 }, 13 data: { 14 weekDayArr: ['日', '一', '二', '三', '四', '五', '六'], 15 displayMonthNum: 1, 16 17 //当前显示的时间 18 displayTime: null, 19 //可以选择的最早时间 20 startTime: null, 21 //最晚时间 22 endTime: null, 23 24 //当前时间,有时候是读取服务器端 25 curTime: new Date() 26 27 }, 28 29 attached: function () { 30 //console.log(this) 31 }, 32 methods: { 33 34 } 35 })
1 <wxs module="dateUtil"> 2 var isDate = function(date) { 3 return date && date.getMonth; 4 }; 5 6 var isLeapYear = function(year) { 7 //传入为时间格式需要处理 8 if (isDate(year)) year = year.getFullYear() 9 if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) return true; 10 return false; 11 }; 12 13 var getDaysOfMonth = function(date) { 14 var month = date.getMonth(); //注意此处月份要加1,所以我们要减一 15 var year = date.getFullYear(); 16 return [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month]; 17 } 18 19 var getBeginDayOfMouth = function(date) { 20 var month = date.getMonth(); 21 var year = date.getFullYear(); 22 var d = getDate(year, month, 1); 23 return d.getDay(); 24 } 25 26 var getDisplayInfo = function(date) { 27 if (!isDate(date)) { 28 date = getDate(date) 29 } 30 var year = date.getFullYear(); 31 32 var month = date.getMonth(); 33 var d = getDate(year, month); 34 35 //这个月一共多少天 36 var days = getDaysOfMonth(d); 37 38 //这个月是星期几开始的 39 var beginWeek = getBeginDayOfMouth(d); 40 41 /* 42 console.log('info',JSON.stringify( { 43 year: year, 44 month: month, 45 days: days, 46 beginWeek: beginWeek 47 })); 48 */ 49 50 return { 51 year: year, 52 month: month, 53 days: days, 54 beginWeek: beginWeek 55 } 56 } 57 58 module.exports = { 59 getDipalyInfo: getDisplayInfo 60 } 61 </wxs> 62 63 64 <view class="cm-calendar"> 65 <view class="cm-calendar-hd "> 66 <block wx:for="{{weekDayArr}}"> 67 <view class="item">{{item}}</view> 68 </block> 69 </view> 70 <view class="cm-calendar-bd "> 71 <view class="cm-month "> 72 </view> 73 <view class="cm-day-list"> 74 75 <block wx:for="{{dateUtil.getDipalyInfo(curTime).days + dateUtil.getDipalyInfo(curTime).beginWeek}}" wx:for-index="index"> 76 77 <view wx:if="{{index < dateUtil.getDipalyInfo(curTime).beginWeek }}" class="item active"></view> 78 <view wx:else class="item">{{index + 1 - dateUtil.getDipalyInfo(curTime).beginWeek}}</view> 79 80 </block> 81 82 <view class=" active cm-item--disabled " data-cndate="" data-date=""> 83 84 </view> 85 </view> 86 </view> 87 </view>
这个是非常简陋的日历雏形,在代码过程中有以下几点比较痛苦:
① WXML与js间应该只有数据传递,根本不能传递方法,应该是两个webview的通信,而日历组件这里在WXML层由不得不写一点逻辑
② 本来在WXML中写逻辑已经非常费劲了,而我们引入的WXS,使用与HTML中的js片段也有很大的不同,主要体现在日期操作
这些问题,一度让代码变得复杂,而可以看到一个简单的组件,还没有复杂功能,涉及到的文件都太多了,这里页面调用层引入标签后:
<ui-calendar is-show="" ></ui-calendar>
日历的基本页面就出来了:
这个日历组件应该是在小程序中写的最复杂的组件了,尤其是很多逻辑判断的代码都放在了WXML里面,根据之前的了解,小程序渲染在一个webview中,js逻辑在一个webview中,他这样做的目的可能是想让性能更好,这种UI组件使用的方式一般是直接使用,但是如果涉及到了页面业务,便需要独立出一个mod小模块去操作对应组件的数据,如图我们这里的日历组件一般
<import src="./mod.searchbox.wxml" /> <view> <template is="searchbox" /> </view> <include src="./mod/calendar.wxml"/> <include src="../../utils/abstract-page.wxml"/>
1 /* 2 事实上一个mod就只是一个对象,只不过为了方便拆分,将对象分拆成一个个的mod 3 一个mod对应一个wxml,但是共享外部的css,暂时如此设计 4 所有日历模块的需求全部再此实现 5 */ 6 module.exports = { 7 q: 1, 8 ddd: function(){}, 9 10 data: { 11 isCalendarShow: '', 12 CalendarDisplayMonthNum: 2, 13 CalendarDisplayTime: new Date(), 14 CalendarSelectedDate: null 15 } 16 }
于是代码便非常好拆分了,这里请各位对比着github中的代码阅读,最终使用效果:
小程序使用这个接口请求数据,这里需要设置域名白名单:
wx.request(OBJECT)
可以看到数据请求已经回来了,但是我们一般来说一个接口不止会用于一个地方,每次重新写好像有些费事,加之我这里想将重复的请求缓存起来,所以我们这里封装一套数据访问层出来
之前在浏览器中,我们一般使用localstorage存储一些不太更改的数据,微信里面提供了接口处理这一切:
wx.setStorage(OBJECT)
我们这里需要对其进行简单封装,便与后面更好的使用,一般来说有缓存就一定要有过期,所以我们动态给每个缓存对象增加一个过期时间:
1 class Store { 2 constructor(opts) { 3 if(typeof opts === 'string') this.key = opts; 4 else Object.assign(this, opts); 5 6 //如果没有传过期时间,则默认30分钟 7 if(!this.lifeTime) this.lifeTime = 1; 8 9 //本地缓存用以存放所有localstorage键值与过期日期的映射 10 this._keyCache = 'SYSTEM_KEY_TIMEOUT_MAP'; 11 12 } 13 //获取过期时间,单位为毫秒 14 _getDeadline() { 15 return this.lifeTime * 60 * 1000; 16 } 17 18 //获取一个数据缓存对象,存可以异步,获取我同步即可 19 get(sign){ 20 let key = this.key; 21 let now = new Date().getTime(); 22 var data = wx.getStorageSync(key); 23 if(!data) return null; 24 data = JSON.parse(data); 25 //数据过期 26 if (data.deadLine < now) { 27 this.removeOverdueCache(); 28 return null; 29 } 30 31 if(data.sign) { 32 if(sign === data.sign) return data.data; 33 else return null; 34 } 35 return null; 36 } 37 38 /*产出页面组件需要的参数 39 sign 为格式化后的请求参数,用于同一请求不同参数时候返回新数据,比如列表为北京的城市,后切换为上海,会判断tag不同而更新缓存数据,tag相当于签名 40 每一键值只会缓存一条信息 41 */ 42 set(data, sign) { 43 let timeout = new Date(); 44 let time = timeout.setTime(timeout.getTime() + this._getDeadline()); 45 this._saveData(data, time, sign); 46 } 47 _saveData(data, time, sign) { 48 let key = this.key; 49 let entity = { 50 deadLine: time, 51 data: data, 52 sign: sign 53 }; 54 let scope = this; 55 56 wx.setStorage({ 57 key: key, 58 data: JSON.stringify(entity), 59 success: function () { 60 //每次真实存入前,需要往系统中存储一个清单 61 scope._saveSysList(key, entity.deadLine); 62 } 63 }); 64 } 65 _saveSysList(key, timeout) { 66 if (!key || !timeout || timeout < new Date().getTime()) return; 67 let keyCache = this._keyCache; 68 wx.getStorage({ 69 key: keyCache, 70 complete: function (data) { 71 let oldData = {}; 72 if(data.data) oldData = JSON.parse(data.data); 73 oldData[key] = timeout; 74 wx.setStorage({ 75 key: keyCache, 76 data: JSON.stringify(oldData) 77 }); 78 } 79 }); 80 } 81 //删除过期缓存 82 removeOverdueCache() { 83 let now = new Date().getTime(); 84 let keyCache = this._keyCache; 85 wx.getStorage({ 86 key: keyCache, 87 success: function (data) { 88 if(data && data.data) data = JSON.parse(data.data); 89 for(let k in data) { 90 if(data[k] < now) { 91 delete data[k]; 92 wx.removeStorage({key: k, success: function(){}}); 93 } 94 } 95 wx.setStorage({ 96 key: keyCache, 97 data: JSON.stringify(data) 98 }); 99 } 100 }); 101 } 102 103 } 104 105 module.exports = Store
这个类的使用也非常简单,这里举个例子:
1 sss = new global.Store({key: 'qqq', lifeTime: 1}) 2 sss.set({a: 1}, 2) 3 sss.get()//因为没有秘钥会是null 4 sss.get(2)//sss.get(2)
这个时候我们开始写我们数据请求的类:
首先还是实现了一个抽象类和一个业务基类,然后开始在业务层请求数据:
1 class Model { 2 constructor() { 3 this.url = ''; 4 this.param = {}; 5 this.validates = []; 6 } 7 pushValidates(handler) { 8 if (typeof handler === 'function') { 9 this.validates.push(handler); 10 } 11 } 12 setParam(key, val) { 13 if (typeof key === 'object') { 14 Object.assign(this.param, key); 15 } else { 16 this.param[key] = val; 17 } 18 } 19 //@override 20 buildurl() { 21 return this.url; 22 } 23 onDataSuccess() { 24 } 25 //执行数据请求逻辑 26 execute(onComplete) { 27 let scope = this; 28 let _success = function(data) { 29 let _data = data; 30 if (typeof data == 'string') _data = JSON.parse(data); 31 32 // @description 开发者可以传入一组验证方法进行验证 33 for (let i = 0, len = scope.validates.length; i < len; i++) { 34 if (!scope.validates[i](data)) { 35 // @description 如果一个验证不通过就返回 36 if (typeof onError === 'function') { 37 return onError.call(scope || this, _data, data); 38 } else { 39 return false; 40 } 41 } 42 } 43 44 // @description 对获取的数据做字段映射 45 let datamodel = typeof scope.dataformat === 'function' ? scope.dataformat(_data) : _data; 46 47 if (scope.onDataSuccess) scope.onDataSuccess.call(scope, datamodel, data); 48 if (typeof onComplete === 'function') { 49 onComplete.call(scope, datamodel, data); 50 } 51 }; 52 this._sendRequest(_success); 53 } 54 55 //删除过期缓存 56 _sendRequest(callback) { 57 let url = this.buildurl(); 58 wx.request({ 59 url: this.buildurl(), 60 data: this.param, 61 success: function success(data) { 62 callback && callback(data); 63 } 64 }); 65 } 66 } 67 module.exports = Model
这里是业务基类的使用办法:
1 let Model = require('./abstract-model.js'); 2 3 class DemoModel extends Model { 4 constructor() { 5 super(); 6 let scope = this; 7 this.domain = 'https://apikuai.baidu.com'; 8 this.param = { 9 head: { 10 version: '1.0.1', 11 ct: 'ios' 12 } 13 }; 14 15 //如果需要缓存,可以在此设置缓存对象 16 this.cacheData = null; 17 18 this.pushValidates(function(data) { 19 return scope._baseDataValidate(data); 20 }); 21 } 22 23 //首轮处理返回数据,检查错误码做统一验证处理 24 _baseDataValidate(data) { 25 if (typeof data === 'string') data = JSON.parse(data); 26 if (data.data) data = data.data; 27 if (data.errno === 0) return true; 28 return false; 29 } 30 31 dataformat(data) { 32 if (typeof data === 'string') data = JSON.parse(data); 33 if (data.data) data = data.data; 34 if (data.data) data = data.data; 35 return data; 36 } 37 38 buildurl() { 39 return this.domain + this.url; 40 } 41 42 getSign() { 43 let param = this.getParam() || {}; 44 return JSON.stringify(param); 45 } 46 onDataSuccess(fdata, data) { 47 if (this.cacheData && this.cacheData.set) 48 this.cacheData.set(fdata, this.getSign()); 49 } 50 51 //如果有缓存直接读取缓存,没有才请求 52 execute(onComplete, ajaxOnly) { 53 let data = null; 54 if (!ajaxOnly && this.cacheData && this.cacheData.get) { 55 data = this.cacheData.get(this.getSign()); 56 if (data) { 57 onComplete(data); 58 return; 59 } 60 } 61 super.execute(onComplete); 62 } 63 64 } 65 66 class CityModel extends DemoModel { 67 constructor() { 68 super(); 69 this.url = '/city/getstartcitys'; 70 } 71 } 72 73 module.exports = { 74 cityModel: new CityModel 75 76 }
接下来是实际调用代码:
1 let model = models.cityModel; 2 model.setParam({ 3 type: 1 4 }); 5 model.execute(function(data) { 6 console.log(data); 7 debugger; 8 });
数据便请求结束了,有了这个类我们可以做非常多的工作,比如:
① 前端设置统一的错误码处理逻辑
② 前端打点,统计所有的接口响应状态
③ 每次请求相同参数做数据缓存
④ 这个对于错误处理很关键,一般来说前端出错很大可能都是后端数据接口字段有变化,而这种错误是比较难寻找的,如果我这里做一个统一的收口,每次数据返回记录所有的返回字段的标志上报呢,就以这个城市数据为例,我们可以这样做:
1 class CityModel extends DemoModel { 2 constructor() { 3 super(); 4 this.url = '/city/getstartcitys'; 5 } 6 //每次数据访问成功,错误码为0时皆会执行这个回调 7 onDataSuccess(fdata, data) { 8 super.onDataSuccess(fdata, data); 9 //开始执行自我逻辑 10 let o = { 11 _indate: new Date().getTime() 12 }; 13 for(let k in fdata) { 14 o[k] = typeof fdata[k]; 15 } 16 //执行数据上报逻辑 17 console.log(JSON.stringify(o)); 18 } 19 }
这里就会输出以下信息:
{"_indate":1533436847778,"cities":"object","hots":"object","total":"number","page":"string"}
如果对数据要求非常严苛,对某些接口做到字段层面的验证,那么加一个Validates验证即可,这样对接口的控制会最大化,就算哪次出问题,也能很好从数据分析系统之中可以查看到问题所在,如果我现在想要一个更为具体的功能呢?我想要首次请求一个接口时便将其数据记录下来,第二次便不再请求呢,这个时候我们之前设计的数据持久层便派上了用处:
1 let Store = require('./abstract-store.js'); 2 3 class CityStore extends Store { 4 constructor() { 5 super(); 6 this.key = 'DEMO_CITYLIST'; 7 //30分钟过期时间 8 this.lifeTime = 30; 9 } 10 } 11 12 module.exports = { 13 cityStore: new CityStore 14 }
1 class CityModel extends DemoModel { 2 constructor() { 3 super(); 4 this.url = '/city/getstartcitys'; 5 this.cacheData = Stores.cityStore; 6 } 7 //每次数据访问成功,错误码为0时皆会执行这个回调 8 onDataSuccess(fdata, data) { 9 super.onDataSuccess(fdata, data); 10 //开始执行自我逻辑 11 let o = { 12 _indate: new Date().getTime() 13 }; 14 for(let k in fdata) { 15 o[k] = typeof fdata[k]; 16 } 17 //执行数据上报逻辑 18 console.log(JSON.stringify(o)); 19 } 20 }
这个时候第二次请求时候便会直接读取缓存了
如果读到这里,我相信大家应该清楚了,30分钟当然是骗人的啦。。。。。。别说三十分钟了,三个小时这些东西都读不完,对于初学者的同学建议把代码下载下来一边调试一边对着这里的文章做思考,这样3天左右便可以吸收很多微信小程序的知识
写这篇文章说实话还比较辛苦,近期小钗这边工作繁忙,有几段都是在和老板开会的时候偷偷写的......,所以各位如果觉得文章还行麻烦帮忙点个赞
总结起来基本还是那句话,微信小程序从架构工程层面十分值得学习,而我这边不出意外时间允许会深入的探索前端框架的实现,争取实现一套能兼容小程序和web同时运行的代码
我们实际工作中会直接使用上面的代码,也会使用一些比较成熟的框架比如:https://tencent.github.io/wepy/,用什么,怎么做单看自己团队项目的需求
我们在学校过程中做了一个实际的项目,完成度有60%,实际工作中便只需要完善细节即可,我这里便没有再加强,一来是时间不足,二来是纯粹业务代码只会让学习的代码变得复杂,没什么太大的必要,希望对初学者有一定帮助: