- Ai onnx 推理实践课程
AI ONNX推理实践课程旨在帮助学员掌握ONNX模型推理的基本应用和实践技巧,从而能够在小程序中使用人工智能模型。本课程适合对模型部署感兴趣的初学者、开发者及研究人员。
07-22 - 小程序开发进阶之前端开发
4 节课,教你快速入门小程序前端。本系列视频,由腾讯课堂NEXT学院、微信学堂联合出品。
2021-12-14 - video设置试看时间
微信小程序项目中遇到了免费试看30秒的需求,看了文档,发现一个我使用开发者工具以来最鸡肋的属性,duration(指定视频时长),相信有不少朋友当初也以为这是限时播放时长的,万万没想到,只是看起来只能播放30秒,实际时长还是以视频时长为准;然后我就设置了bindtimeupdate(播放进度改变时触发),设置30秒的时候触发if(e.detail.currentTime>time){ pause()暂停当前视频,且弹窗提示 },time为当前限制播放时间;这里有个小bug,就是触发了bindtimeupdate函数视频也会播放一段时间(取决于函数执行耗费的时间),就会造成调用两次bindtimeupdate,所以我就在第一次触发的时候将time改变为time+1,函数只要不太复杂,执行时间应该不会超过一秒,弹窗提示是否支付之类的;要在之前就设置一个参数控制controls(播放按钮之类的控件)的false 或者 true; 这里有三种情况 一是直接按取消按钮,当客户按下取消按钮,将controls隐藏,且给整个video一个点击事件,弹窗提示支付;二是点击了支付,直接一气呵成,不用改变什么;三是点击支付之后没有支付,将controls隐藏,且给整个video一个点击事件,弹窗提示支付;差不多这样,可能后续还有改进 第一次写文章,希望对大家有所帮助
2020-03-20 - vue等前后端分离微信授权登录的解决方案
准备工作 前提:1、在阅读此实践方案之前,确保已经对OAuth2.0授权流程以及完整阅读了【开放平台-网站应用-微信登录功能-网站应用微信登录开发指南(点击红字去阅读)】并理解相应的授权流程 2、在开放平台注册并申请了相关到网站应用,填写了开发信息-授权回调域,获得了接口 (微信登录)。 原理简介 1.将微信登录二维码内嵌到自己的vue前端登录页面中,扫码后用户点击确认登录后会跳转回授权回调域(授权回调域设置与vue前端域名一致) 2,此时携带code和state参数,vue在路由拦截中判断code和state参数,并携带code和state跳转到登录页面实现后续登录逻辑 3,登录页面在钩子函数mounted()中判断是否是微信授权登录,通过相关接口传送code和state给服务端, 4.由服务端携带code通过请求access_token接口请求access_token并完成后续登录逻辑,返回相应结果给vue前端。此时授权登录完成。 实现1.将二维码内嵌到vue前端登录页面中:文档中已介绍相关案例代码如下步骤1:在页面中先引入如下JS文件(支持https) http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js 步骤2:在需要使用微信登录的地方实例以下JS对象: var obj = new WxLogin({ self_redirect:false, id:"login_container", appid: "", scope: "", redirect_uri: "", state: "", style: "", href: "" }); x相关参数说明请仔细阅读文档中的参数说明;其中参数self_redirect的参数为false 即确认登录后再top window跳转到redirect_uri 内嵌二维码示例: [图片] 程序实现片段:引入js文件 ,通过自定义组件,将js文件引入页面 export default { components: { 'remote-js': { render(createElement) { return createElement('script',{attrs:{type:'text/javascript',src:this.src}}); }, props:{ src:{type:String,required:true} } } } } 页面内添加js相关组件,并实例WxLogin:具体实现方式结合自己的登录页面业务逻辑实现:注意回调地址不支持#号输入,会将#号后内容清除 var obj = new WxLogin({ self_redirect: false, id: "wechat-qrcode", appid: res.data.appid, scope: "snsapi_login", redirect_uri: '', state: '' }); h回调后,路由拦截设置:获取code,以及state,注意vue会自动添加 '#/' 在url地址后边,注意获取参数时去除 通过next()设置query参数 将code,state传到登录页面, 登录页面钩子函数mounted()中判断并请求服务端相应接口,例如判断参数有误code和state等 进行请求 服务端相应接口。完成相应的登录逻辑 此方案已经实现并使用中,如果有相关咨询问题,欢迎各位开发者大拿进行讨论。
2020-03-19 - 小程序没有 DOM 接口,原因竟然是……?
拥有丰富的 Web 前端开发经验的工程师小赵今天刚刚来到新的部门,开始从事他之前没有接触过的微信小程序开发。在上手的第一天,他就向同办公室的小程序老手老李请教了自己的问题。 小赵:翻了一圈文档,小程序好像并不提供 DOM 接口?我还以为可以像之前一样用我喜欢的前端框架来做开发呢。老李,你说小程序为什么不给我们提供 DOM 接口呀。 老李:要提供 DOM 接口也没那么容易。你知道小程序的双线程模型吗?(小赵漏出了疑惑的表情)小程序是基于 Web 技术的,这你应该知道,但小程序和普通的移动端网页也不一样。你做了很多前端项目了,应该知道在浏览器里,UI 渲染和 JavaScript 逻辑都是在一个线程中执行的? 小赵:这我知道,在同一个线程中,UI 渲染和 JavaScript 逻辑交替执行,JavaScript 也可以通过 DOM 接口来对渲染进行控制。 老李:小程序使用的是一种两个线程并行执行的模式,叫做双线程模型。像我画的这样,两个线程合力完成小程序的渲染:一个线程专门负责渲染工作,我们一般称之为渲染层;而另外有一个线程执行我们的逻辑代码,我们一般叫做逻辑层。这两个线程同时运行,并通过微信客户端来交换数据。在小程序运行的时候,逻辑层执行我们编写的逻辑,将数据通过 setData 发送到渲染层;而渲染层解析我们的 WXML 和 WXSS,并结合数据渲染出页面。一方面,每个页面对应一个 WebView 渲染层,对于用户来说更加有页面的感觉,体验更好,而且也可以避免单个 WebView 的负担太重;另一方面,将小程序代码运行在独立的线程中的模式有更好的安全表现,允许有像 open-data 这样的组件可以在确保用户隐私的前提下让我们展示用户数据。 [图片] 小赵:怪不得所有和页面有关的改动都只能通过 setData 来完成。但是用两个线程来渲染我们平时用单线程来渲染的 Web 页面,会不会有些「浪费」?而且每一个页面有一个对应的渲染层,那页面变多的时候,岂不是会有很大的开销? 老李: 并不浪费,因为界面的渲染和后台的逻辑处理可以在同一时间运行了,这使得小程序整体的响应速度更快了。而在小程序的运行过程中,逻辑层需要常驻,但渲染层是可以回收的。实际上,当页面栈的层数比较高的时候,栈底页面的渲染层是会被慢慢回收的。 小赵: 原来如此。这么说的话,实际的 DOM 树是存在于渲染层的,逻辑层并不存在,所以逻辑层才没有任何的 DOM 接口,我明白了。但是……既然可以实现像 setData 这样的接口,为什么不能直接把 DOM 接口也代理到逻辑层呢?我觉得小程序可以做一个封装,让我们在逻辑层调用 DOM 接口,在渲染层调用接口后再把结果返回给我们呀。 老李:从理论上来说确实是可以的。但是线程之间的通信是需要时间的呀。将调用发送到渲染层,再将 DOM 调用结果发送回来,这中间由于线程通信发生的时间损耗可能会比这个接口本身需要的时间要多得多。如果以此为基础使用基于 DOM 接口的前端框架,大量的 DOM 调用可能会非常缓慢,让这个设计失去意义。 在实际测试中,如果每次 DOM 调用都进行一次线程通信,耗时大约是同等节点规模直接在渲染层调用的百倍以上;如果忽略通信需要的时间,一个实现良好的基于 DOM 代理的框架可以近似地看成一个动态模板的框架,而动态模板和静态模板相比要慢至少 50% 小赵:原来如此,线程通信的时间确实是我没有考虑到的问题。那现在的小程序框架中难道不存在这个问题吗? 老李: 在现在的小程序框架中,这个问题也是存在的,这也是现在的框架基于静态模板渲染的原因。静态模板可以在运行前就做好打包,直接注入到渲染层,省去线程传输的时间。在运行时,逻辑层只和渲染层进行最少的、必要的数据交换:也就是渲染用的数据,或者说 data 。另一方面,静态模板让两个线程都在启动时就拥有模板相关的所有数据,所以框架也充分利用了这一点,进行了很多优化。 小赵: 怪不得我在文档里发现很多和 setData 有关的性能提示,都提醒尽量减少设置不必要的数据,现在总算是知道为什么了。但是具体到实际开发里的时候,还是总觉得很难每次只设置需要的数据啊,像对象里或者数组里的数据怎么办呢? 老李: 如果只改变了对象里或者数组里的一部分数据,可以通过类似 array[2].message , a.b.c.d 这样的 数据路径 来进行「精准设置」。另外,现在自定义组件也支持 纯数据字段 了,只要在自定义组件的选项中设置好名为 pureDataPattern 的正则表达式, data 中匹配这个正则的字段将成为纯数据字段,例如,你可以用 /^_/ 来指定所有 开头的数据字段为纯数据字段。所有纯数据字段仅仅被记录在逻辑层的 this.data 中,而不会被发送到渲染层,也不参与任何界面渲染过程,节省了传输的时间,这样有助于提升页面更新性能。 小赵:小程序还有这样的功能,受教了。不过说来说去,我还是想在小程序里用我顺手的框架来开发,毕竟这样事半功倍嘛。我在网上搜索了一下,发现现在有很多支持用 Web 框架做小程序开发的框架,但好像都是将模板编译成 WXML,最终由小程序来做渲染,但这样的方法好像兼容性也不是很好。我在想,我们能不能在逻辑层仿造一套 DOM 接口,然后在运行时将 DOM 调用适配成小程序调用? 老李: 你的这个脑洞有一些意思。在逻辑层仿造一套 DOM 接口,直接维护一棵 DOM 树,这当然没问题。但是没有代理 DOM 接口,逻辑层的 DOM 树没法反映到渲染层,因为渲染层具体会出现什么样的组件,是运行时才能知道的,这不就没法生成静态模板了? 小赵:静态模板确实是没法生成了,但我看到小程序的框架支持自定义组件,我是不是可以做一个通用的自定义组件,让它根据传入的参数不同,变成不同的小程序内置组件。而且自定义组件还支持在自己的模板中引用自己,那么我只需要一个这个通用组件,然后从逻辑层用代码去控制当前组件应该渲染成什么内置组件,再根据它是否有子节点去递归引用自己进行渲染就可以了。你看这样可行吗? [图片] 老李: 这样的做法确实可行,而且微信官方已经按照这个思路推出小程序和 Web 端同构的解决方案 Kbone 了。Kbone 的原理就像你刚才说的那样,它提供一个 Webpack 插件,将项目编译成小程序项目;同时提供两个 npm 包,分别提供 DOM 接口模拟和你说的那个通用的自定义组件作为运行时依赖。要不你赶紧试试? 小赵:还有这么好的事,那我终于可以用我喜欢的框架开发小程序了!这么好的框架,为什么不直接内置到小程序的基础库里呀? 老李: 因为这样的功能完全可以用现在已有的基础库功能实现出来呀。Kbone 现在是 npm 包的形式,使得它的功能、问题修复可以随着自己的版本来发布,不需要依赖于基础库的更新和覆盖率,不是挺好的吗? 小赵: 好是好,但我担心的是代码包大小限制的问题。除了我们已经写好的业务逻辑之外,现在还得加上 Kbone,会不会装不下呀? 老李: 原来你是担心这个呀,放心,Kbone 现在已经可以在 扩展库 里一键搞定啦。扩展库是帮我们解决依赖的全新功能,只要在配置项中指定 Kbone 扩展库,就相当于引入了 Kbone 相关的最新版本的 npm 包,这样就不占用小程序的代码包体积了,快试试吧! 小赵:哇,那可太爽了,马上就搞起! 最后 如果你对 Kbone 感兴趣或者有相关问题需要咨询, 欢迎加入 Kbone 技术交流 QQ 群:926335938
2020-01-14 - 微信小程序UI组件库合集
UI组件库合集,大家有遇到好的组件库,欢迎留言评论然后加入到文档里。 第一款: 官方WeUI组件库,地址 https://developers.weixin.qq.com/miniprogram/dev/extended/weui/ 预览码: [图片] 第二款: ColorUI:地址 https://github.com/weilanwl/ColorUI 预览码: [图片] 第三款: vantUI(又名:ZanUI):地址 https://youzan.github.io/vant-weapp/#/intro 预览码: [图片] 第四款: MinUI: 地址 https://meili.github.io/min/docs/minui/index.html 预览码: [图片] 第五款: iview-weapp:地址 https://weapp.iviewui.com/docs/guide/start 预览码: [图片] 第六款: WXRUI:暂无地址 预览码: [图片] 第七款: WuxUI:地址https://www.wuxui.com/#/introduce 预览码: [图片] 第八款: WussUI:地址 https://phonycode.github.io/wuss-weapp/quickstart.html 预览码: [图片] 第九款: TouchUI:地址 https://github.com/uileader/touchwx 预览码: [图片] 第十款: Hello UniApp: 地址 https://m3w.cn/uniapp 预览码: [图片] 第十一款: TaroUI:地址 https://taro-ui.jd.com/#/docs/introduction 预览码: [图片] 第十二款: Thor UI: 地址 https://thorui.cn/doc/ 预览码: [图片] 第十三款: GUI:https://github.com/Gensp/GUI 预览码: [图片] 第十四款: QyUI:暂无地址 预览码: [图片] 第十五款: WxaUI:暂无地址 预览码: [图片] 第十六款: kaiUI: github地址 https://github.com/Chaunjie/kai-ui 组件库文档:https://chaunjie.github.io/kui/dist/#/start 预览码: [图片] 第十七款: YsUI:暂无地址 预览码: [图片] 第十八款: BeeUI:git地址 http://ued.local.17173.com/gitlab/wxc/beeui.git 预览码: [图片] 第十九款: AntUI: 暂无地址 预览码: [图片] 第二十款: BleuUI:暂无地址 预览码: [图片] 第二十一款: uniydUI:暂无地址 预览码: [图片] 第二十二款: RovingUI:暂无地址 预览码: [图片] 第二十三款: DojayUI:暂无地址 预览码: [图片] 第二十四款: SkyUI:暂无地址 预览码: [图片] 第二十五款: YuUI:暂无地址 预览码: [图片] 第二十六款: wePyUI:暂无地址 预览码: [图片] 第二十七款: WXDUI:暂无地址 预览码: [图片] 第二十八款: XviewUI:暂无地址 预览码: [图片] 第二十九款: MinaUI:暂无地址 预览码: [图片] 第三十款: InyUI:暂无地址 预览码: [图片] 第三十一款: easyUI:地址 https://github.com/qq865738120/easyUI 预览码: [图片] 第三十二款 Kbone-UI: 地址 https://wechat-miniprogram.github.io/kboneui/ui/#/ 暂无预览码 第三十三款 VtuUi: 地址 https://github.com/jisida/VtuWeapp 预览码: [图片] 第三十四款 Lin-UI 地址:http://doc.mini.talelin.com/ 预览码: [图片] 第三十五款 GraceUI 地址: http://grace.hcoder.net/ 这个是收费的哦~ 预览码: [图片] 第三十六款 anna-remax-ui npm:https://www.npmjs.com/package/anna-remax-ui/v/1.0.12 anna-remax-ui 地址: https://annasearl.github.io/anna-remax-ui/components/general/button 预览码 [图片] 第三十七款 Olympus UI 地址:暂无 网易严选出品。 预览码 [图片] 第三十八款 AiYunXiaoUI 地址暂无 预览码 [图片] 第三十九款 visionUI npm:https://www.npmjs.com/package/vision-ui 预览码: [图片] 第四十款 AnimaUI(灵动UI) 地址:https://github.com/AnimaUI/wechat-miniprogram 预览码: [图片] 第四十一款 uView 地址:http://uviewui.com/components/quickstart.html 预览码: [图片] 第四十二款 firstUI 地址:https://www.firstui.cn/ 预览码: [图片]
2023-01-10 - 继承数组
为什么要继承数组 我们可以定义“数组子类”作为创建从原生数组对象(在其原型链中具有 Array.prototype)继承的对象的过程,并遵循与原生数组相似(或相同)的行为。 关于类似于原生数组的行为非常重要,我们后面会看到。 拥有数组的“子类”可以被认为能够创建一个数组对象,而不是直接从 Array 继承的对象,而是从另一个对象继承,然后才从 Array 继承。 换句话说,我们需要类似这样的行为: [代码]var sub = new SubArray(1, 2, 3); sub; // [1, 2, 3] sub.length; // 3 sub[1]; // 2 sub.push(4); sub; // [1, 2, 3, 4] // 等等. sub intanceof SubArray; // true sub intanceof Array; // true [代码] 注意 SubArray 构造函数如何创建一个与数组行为相同的子对象(对象具有 “length” 属性,数字 “0”,“1”,“2” 属性,并继承 Array.prototype 上的方法)。 同时,SubArray 是直接继承的子对象,而不是 Array 。 那么做这一切的目的究竟是什么? 为什么以这种方式对数组进行继承? 通常有两个原因: 避免污染全局:利用 Javascript 原型扩展数组对象方法很方便。 如下代码: [代码]Array.prototype.last = function () { return this[this.length - 1]; }; // ... [1, 2, 3].last(); // 3 [代码] 但是,扩展 Array.prototype 有代价的。当脚本与应用程序中的其他脚本共存时,这些脚本有可能相互冲突。扩展 Array.prototype 虽然诱人并且看起来很有用,但不幸的是在多样化的环境中不是很安全。不同的脚本可能最终定义相同名称的方法,但具有不同的行为。这种情况往往会导致不一致的行为和难以追踪的错误。 使用 Array 以外的构造函数 - 但具有相同的行为 - 可以避免这种冲突。不是扩展 Array.prototype,而是扩展另一个对象(比如 SubArray.prototype),然后用来初始化(子)数组对象。任何依赖 Array.prototype 方法的第三方代码仍然能够安全地使用它们。 继承数组的数据结构方法,例如 Stack,List,Queue,Set (push,pop,shift,unshift 等)等方法。 天真的做法 我们可以使用原型式克隆方法: [代码]function clone(obj) { function F() { } F.prototype = obj; return new F(); } [代码] 然后设置如下的继承: [代码]function Child() { } Child.prototype = clone(Parent.prototype); [代码] 这里的原型链: [代码]new Child() | | [[Prototype]] | v Child.prototype | | [[Prototype]] | v Parent.prototype | | [[Prototype]] | v Object.prototype | | [[Prototype]] | v null [代码] 开始实现继承数组: [代码]function SubArray() { // 将传递给构造函数的任何参数添加到实例中 this.push.apply(this, arguments); } SubArray.prototype = clone(Array.prototype); var sub = new SubArray(1, 2, 3); [代码] 天真方法的问题 那么使用克隆方法继承数组究竟有什么错误? 让我们来看看之前声明的 SubArray 函数的行为。 我们将使用原生数组对象来进行比较。 [代码]var arr = new Array(1, 2, 3); var sub = new SubArray(1, 2, 3); arr.length; // 3 sub.length; // 0 (in IE<8) arr.length = 2; sub.length = 2; arr; // [1, 2] sub; // [1, 2, 3] arr[10] = 'foo'; sub[10] = 'foo'; arr.length; // 11 sub.length; // 2 Object.prototype.toString.call(arr); // [object Array] Object.prototype.toString.call(sub); // [object Object] [代码] 这里显然有些不一致。 即使我们忽略 IE < 8 中的错误。 但是,数组中的长度和数字属性之间的这种奇怪的关系是什么? 为什么不是和 Array 的行为相同? 为了理解这一点,我们需要查看 JavaScript 中的数组对象。 数组的特殊之处 在 Javascript 中的数组几乎就像普通的 Object 对象,除了行为上的一点小差异。 如下引自 es 规范 Array objects give special treatment to a certain class of property names. A property name P (in the form of a string value) is an array index if and only if ToString(ToUint32§) is equal to P and ToUint32§ is not equal to 2^32 - 1. Every Array object has a length property whose value is always a nonnegative integer less than 2^32. The value of the length property is numerically greater than the name of every property whose name is an array index; whenever a property of an Array object is created or changed, other properties are adjusted as necessary to maintain this invariant. Specifically, whenever a property is added whose name is an array index, the length property is changed, if necessary, to be one more than the numeric value of that array index; and whenever the length property is changed, every property whose name is an array index whose value is not smaller than the new length is automatically deleted. This constraint applies only to properties of the Array object itself and is unaffected by length or array index properties that may be inherited from its prototype. 可以概括为:数组对象以特殊的方式处理 “numeric” 属性。 只要这些属性发生变化,数组的 “length” 属性的值也会被调整; 它的调整是为了确保它总是比数组的最大索引大 1 。 类似地,当“长度”属性发生变化时,“numeric” 属性会相应地进行调整。 当创建数组对象时,其 “length” 属性设置为比数组最大索引大 1 。 [代码] var arr = ['x', 'y', 'z']; arr.length; // 3 arr = ['foo']; arr.length; // 1 [代码] 当 “numeric” 属性发生变化时,“长度”也会发生变化 - 以保持比最大索引大 1 的关系。 [代码]var arr = ['x', 'y']; arr.length; // 2 arr[2] = 'z'; arr.length; [代码] 当“length”属性改变时,“numeric” 属性会进行调整,使得最大索引比“length”的值小 1 。 [代码]var arr = ['x', 'y', 'z']; arr.length = 2; arr; // ['x', 'y'] arr.length = 4; arr; // ['x', 'y'] // “增加”长度不会影响数字属性... arr.join(); // "x,y,," // 但在其他情况下可以看到后果,例如使用 `Array.prototype.push` 时 arr.push('z'); arr; // ['x', 'y', undefined, undefined, 'z'] [代码] 现在你知道 Javascript 中的 Array 对象的“特殊”之处了,它处于 “length” 和 “numeric” 属性之间的关系中。 还有一个值得注意的细节是数组的 “length” 属性必须总是具有小于 2 ^ 32 的非负整数值。 只要违反这个条件,就会引发 RangeError 。 [代码]var arr = []; arr.length = Math.pow(2, 32); // RangeError arr.length; // 0 (长度仍然是0,就像它最初一样) arr.length = Math.pow(2, 32) - 1; // 将长度设置为最大允许值 arr.length++; // RangeError (明确设置长度时) arr.push(1); // RangeError (或者在隐式设置长度时) [代码] 函数对象和构造器 为什么通过 SubArray 和 Array 函数创建的对象的行为存在差异。即使 SubArray 创建了一个从 Array.prototype 继承的对象,该对象完全没有数组的特殊行为。 SubArray 实例只不过是一个普通的 Object 对象(就像它是通过对象字面量创建的一样)。 但为什么 SubArray 创建一个 Object 对象而不是一个 Array 对象?这个问题的核心是 ECMAScript 中函数的工作方式。 当 new 运算符应用于对象时(如在新的 SubArray 中),调用该对象的内部 [[Constructor]] 方法。在我们的例子中,它是 SubArray 函数的 [[Constructor]] 。 SubArray - 作为本地函数 - 具有 [[Constructor]],它指定创建一个普通的 Object 对象,并调用提供新创建对象的相应函数作为此值。任何本地函数(包括SubArray)都应创建一个 Object 对象并将其作为结果返回。 现在值得一提的是,可以通过从构造函数显式返回对象来对 [[Constructor]] 的返回值进行处理: [代码]function SubArray() { this.push.apply(this, arguments); // return Array.apply(this, arguments); return []; // 显式返回数组对象 } [代码] 但在这种情况下,返回的对象不会继承构造函数的“原型”(在这种情况下是 SubArray.prototype); 构造函数也不会被该对象调用。 [代码]var sub = new SubArray(1, 2, 3); // 对象没有 1,2,3,因为构造函数从未被调用,返回的是 this 值引用 object sub; // [] // SubArray 不在返回对象的原型链中 sub instanceof SubArray; // false [代码] 综上,创建一个从 Array.prototype 继承的对象只是开始。 最大的问题是保留长度和数字属性的特殊关系。 这就是为什么使用常规克隆方法不能完成的原因。 数组特殊行为的重要性 “为什么数组的特殊行为很重要”? 为什么当继承一个数组时,我们想要保持长度和数字属性之间的关系? 以 Array.prototype.push 为例, 要确定从哪个位置开始插入元素,push 将检索数组的 “length” 值。 如果长度未正确保存,则将元素插入错误的位置: [代码]var arr = ['x', 'y']; arr.length = 5; arr.push('z'); // 'z' 被插入到第 5 个索引处,因为这是 “length” 的值 arr; // ['x', 'y', undefined, undefined, undefined, 'z'] [代码] 采取另一种方法 Array.prototype.join ,Array.prototype.join 还使用 length 属性来确定何时停止连接值: [代码]var arr = ['x', 'y']; arr.join(); // "x,y" arr.length = 5; arr.join(); // "x,y,,," [代码] Array.prototype.concat 同样适用: [代码]var arr = ['x']; arr.length = 3; arr.concat('y'); // ['x', undefined, undefined, 'y'] [代码] 最后,特殊行为通常在其他情况下被巧妙利用,例如“清除”数组(即删除其所有数字属性): [代码]var arr = [1, 2, 3]; arr.length = 0; arr; // [] — 将长度设置为0会有效地移除数组的所有数值属性(元素 [代码] Dean Edwards 解决方案 一个受欢迎的解决方案是 Dean Edwards。 采用了完全不同的方法 - 不是创建一个从 Array.prototype 继承的对象,而是从另一个 iframe 的上下文中“借用”实际的 Array 构造函数。 [代码]// 创建一个 <iframe> var iframe = document.createElement("iframe"); iframe.style.display = "none"; document.body.appendChild(iframe); // 将脚本写入 iframe 并窃取其 Array 对象 frames[frames.length - 1].document.write( "<script>parent.Array2 = Array;<\/script>"; ); [代码] 这种“有效”的原因是由于浏览器为文档中的每个框架创建单独的执行环境。 每个这样的环境都有一套独立的内置和宿主对象。 内置对象包括全局数组构造函数等。 一个 iframe 的数组对象与另一个 iframe 的数组对象不同。 他们也没有任何种类的等级关系: [代码]// 假设 SubArray 是从另一个 iframe 借用的 var sub = new SubArray(1, 2, 3); sub instanceof SubArray; // true sub instanceof Array; // false sub instanceof Object; // false [代码] 注意 sub 为什么不是 Array 的一个实例,也不是 Object 的一个实例。 这是因为 Array 和 Object 都不在子对象的原型链中。 相反,原型链包含 SubArray.prototype,接着是来自另一个 iframe 的 Object .prototype: [代码]new SubArray() | | [[Prototype]] | v <another iframe>.Array.prototype | | [[Prototype]] | v <another iframe>.Object.prototype | | [[Prototype]] | v null [代码] 这使我们对这种方法有了一个疑问 - 难以确定从这种 iframe 派生的对象的性质。 不再可能使用 instanceof 或构造函数检查来确定对象是数组: [代码] // is this object an array? sub instanceof Array; // false sub.constructor === Array; // false [代码] 但是,仍然可以使用 [[Class]] 检查(稍后我们将讨论 [[Class]]: [代码] Object.prototype.toString.call(sub) === '[object Array]'; // true [代码] 这种方法的另一个比较大的缺点是它不适用于非浏览器环境(或者更确切地说,在任何不支持 iframe 的环境中)。 鉴于服务器端 Javascript 实现速度非常快,这个问题可能会变得更大。 ECMAScript 5 属性访问器 我们来谈谈 ECMAScript 5,正如我在一开始提到的,它带来了一些有助于继承数组的东西。这个“东西”其实不过是属性访问器。这些有用的语言结构已经在一些流行的实现(SpiderMonkey,JavaScriptCore 等)中作为非标准扩展出现了很长一段时间。现在它们已经在新版本实现了。 使用访问器,创建一个具有特殊长度/索引关系的 Object 对象是相当简单的 - 这与 Array 对象的关系相同!而且由于我们已经知道如何在其原型链中创建一个具有 Array.prototype 的对象,所以将这两个方面结合起来就可以完全模拟数组。 有一个关于实施的细节。由于 ECMAScript(包括 last,5th 版本)不提供任何 catch-all(aka noSuchMethod)机制,因此在修改 numeric 属性时无法更改对象的 length 属性值;换句话说,我们不能拦截 ‘0’,‘1’,‘2’,‘15’ 等属性被设置的场景。但是,访问器允许我们截取 length 属性的任何读取访问权限并返回适当的值,具体取决于当时具有哪个数字属性对象。而这是我们真正需要的。 这是它的一个实现,大约有45行代码: [代码]var makeSubArray = (function() { var MAX_SIGNED_INT_VALUE = Math.pow(2, 32) - 1, hasOwnProperty = Object.prototype.hasOwnProperty; function ToUint32(value) { return value >>> 0; } function getMaxIndexProperty(object) { var maxIndex = -1, isValidProperty; for (var prop in object) { isValidProperty = ( String(ToUint32(prop)) === prop && ToUint32(prop) !== MAX_SIGNED_INT_VALUE && hasOwnProperty.call(object, prop)); if (isValidProperty && prop > maxIndex) { maxIndex = prop; } } return maxIndex; } return function(methods) { var length = 0; methods = methods || { }; methods.length = { get: function() { var maxIndexProperty = +getMaxIndexProperty(this); return Math.max(length, maxIndexProperty + 1); }, set: function(value) { var constrainedValue = ToUint32(value); if (constrainedValue !== +value) { throw new RangeError(); } for (var i = constrainedValue, len = this.length; i < len; i++) { delete this[i]; } length = constrainedValue; } }; methods.toString = { value: Array.prototype.join }; return Object.create(Array.prototype, methods); }; })(); [代码] 我们现在可以通过 makeSubArray 函数创建“子数组”。 它接受一个参数 - 一个带有方法的对象,将其添加到 [[Prototype]] 返回的“子数组”中。 [代码]var subMethods = { last: { value: function() { return this[this.length - 1]; } } }; var sub = makeSubArray(subMethods); var sub2 = makeSubArray(subMethods); // 等等 [代码] 我们也可以将这个工厂方法隐藏在构造函数的后面,使其与 Array 的类似: [代码]var SubArray = (function () { var methods = { last: { value: function() { return this[this.length - 1]; } } }; return function() { var arr = makeSubArray(methods); arr.push.apply(arr, arguments); return arr; }; })(); [代码] 然后像使用常规 Array 构造函数一样使用它: [代码]var sub = new SubArray(1, 2, 3); sub.length; // 3 sub; // [1, 2, 3] sub.length = 1; sub; // [1] sub[10] = 'x'; // [0:1, 1:2, 2: 3, 10: "x"] sub.push(1); // [0:1, 1:2, 2: 3, 3: 1, 10: "x"] [代码] [[Class]] 1. [[Class]] 限制 我们刚刚看到利用属性访问器的实现。它不需要任何主机对象(如iframe);它保留长度和数字属性之间的关系;它甚至不允许长度或指数超出范围的值。它只需要支持 ES5(甚至只是Object.create方法)。 但是 [[Class]] 值 - ECMAScript 仍然没有完全控制。 在解释如何检测数组时,我之前曾写过 [[Class]] 。简而言之,[[Class]] 是 ECMAScript 中对象的内部属性。它的值从不直接暴露,但仍可以使用某些方法(例如 Object.prototype.toString)进行检查。 [[Class]] 的用处在于,它允许检测对象的类型,而不依赖于 instanceof 运算符或检查对象的构造函数 - 两者都不足以检测来自其他上下文(例如 iframe)的对象,如前所述。 现在,由于 makeSubArray 创建的对象只是普通的 Object 对象(只有特殊长度的 getter / setter),它们的 [[Class] ]也是 “Object” 而不是 “Array”!我们已经考虑了长度/索引关系,我们设置了 Array.prototype 继承,但是没有办法改变对象的 [[Class]] 值。所以这个解决方案不能说是完美的。 2. [[Class]] 是否重要? 您可能想知道 - 这些数组对象的 [Object] 的 [[Class]] 不是 “Array” 的实际含义是什么。实际上,不能继承[[Class]] 会有不能对象检测的问题。 // assuming that [代码]sub[代码] is a pseudo-array Object.prototype.toString.call(sub) === ‘[object Array]’; // false 另一个可能更重要的含义是,ECMAScript 中的一些方法实际上依赖于 [[Class]] 值。 例如,一个众所周知 Function.prototype.apply 接受一个数组作为它的第二个参数(以及一个参数对象)。 ES3 的 15.3.4.3节说 - “如果 argArray 既不是数组也不是参数对象(见10.1.8),则抛出 TypeError 异常”。 这意味着如果我们传递伪数组对象作为第二个参数来应用它将抛出 TypeError 。 应用程序不知道或关心一个对象是否从 Array.prototype 继承; 它也不关心实现特殊长度/指数行为的对象。 它所关心的只是对象是适当的类型 - 我们很遗憾不能模拟这种类型。 // 假设 [代码]sub[代码] 是一个伪数组 someFunction.apply(this, sub); // TypeError 这方面的规定有些模糊。 例如,在 Date.prototype.setTime spec 中说“如果这个值不是一个 Date 对象,则抛出一个 TypeError 异常”,但在 Date.prototype.getTime 中,它使用 [[Class]] 而不是 “not a Date 对象“ - ”如果此值不是其 [[Class]] 属性为 “Date” 的对象,则引发 TypeError 异常“。 假设这两个短语 - “ Date 对象”和 “Date ['Class] 中的对象”)具有相同的含义可能是安全的。 “ Array 对象”和“ Array [] 的 [[Class]] 对象”以及其他对象也是类似的。 Function.prototype.apply 不是对对象 [[Class]] 敏感的唯一方法。 例如,Array.prototype.concat 基于对象是否为数组(不管是否具有 [[Class] ]“Array”),都遵循不同的算法。 [代码]// array ([[Class]] == "Array") var arr = ['x', 'y']; // object with numeric properties ([[Class]] == "Object") var obj = { '0': 'x', '1': 'y' }; [1,2,3].concat(arr); // [1, 2, 3, 'x', 'y'] [1,2,3].concat(obj); // [1, 2, 3, { '0': 'x', '1': 'y' }] [代码] 正如你所看到的,数组的值是“扁平的”,而非数组的则保持不变。 当然可以给这些伪数组自定义 concat 实现(并“修复” Array.prototype 上方法中的任何其他方法),但是 Function.prototype.apply 的问题无法解决。 值得一提的是,基于存取器的数组方法的另一个缺点是性能。 我还没有做过任何测试,但很明显,每次访问 length 属性时必须枚举所有数字属性的实现并不会很好。 这就是为什么我不能推荐这个解决方案的原因。 包装, 直接属性注入 在 Javascript 中实现数组的继承有些徒劳无功,通常会使替代解决方案看起来非常有吸引力。 其中一种解决方案是使用包装。 包装方法避免了设置继承或模拟长度/索引关系。 相反,类似工厂的函数可以创建一个普通的 Array 对象,然后使用任何自定义方法直接对其进行扩充。 由于返回的对象是一个数组,所以它保持适当的长度/索引关系,以及“数组”的 [[Class]] 。 它也自然地从 Array.prototype 继承。 [代码]function makeSubArray() { var arr = [ ]; arr.push.apply(arr, arguments); arr.last = function() { return this[this.length - 1]; }; return arr; } var sub = makeSubArray(1, 2, 3); sub instanceof Array; // true sub.length; // 3 sub.last(); // 3 [代码] 尽管数组对象的直接扩展是一个美观,简单的解决方案,但它并非没有缺点。 主要缺点是每次调用构造函数时,需要使用 N 个方法来扩展数组。 创建数组所需的时间不再是常量(如果方法在 SubArray.prototype 上),而是与需要添加的方法的数量成正比。 包装, 原型链注入 为了克服“N方法”的问题,可以使用包装器的另一种变体 - 其中对象的原型链增加的变体,而不是对象本身。 让我们看看如何做到这一点: [代码]function SubArray() { } SubArray.prototype = new Array; SubArray.prototype.last = function() { return this[this.length - 1]; } }; function makeSubArray() { var arr = [ ]; arr.push.apply(arr, arguments); arr.__proto__ = SubArray.prototype; return arr; } [代码] 这个想法很简单。 当执行 makeSubArray 函数时,会发生两件事: 1)创建一个数组对象并使用任何传递的参数填充; 2)对象的原型链以这种方式增加,以便下一个对象是 SubArray.prototype,而不是原始 Array.prototype。 原型链的扩充是通过非标准 proto 属性完成的。 但是,makeSubArray 函数中发生的事情当然只是任务的一半。 为了确保该对象在其原型链中具有 Array.prototype,我们需要使 SubArray.prototype 从它继承。 这正是这段代码的第二行(SubArray.prototype = new Array)所做的。 从 makeSubArray 返回的对象的原型链如下所示: [代码]new SubArray() | | [[Prototype]] | v SubArray.prototype | | [[Prototype]] | v Array.prototype | | [[Prototype]] | v Object.prototype | | [[Prototype]] | v null [代码] 因为返回的对象实际上是一个数组,而不是对象,我们也得到长度/指数关系以及适当的 [[Class]] 值。 实际上,我们可以更进一步并将初始化逻辑移入 SubArray 构造函数本身: [代码]function SubArray() { var arr = [ ]; arr.push.apply(arr, arguments); arr.__proto__ = SubArray.prototype; return arr; } SubArray.prototype = new Array; SubArray.prototype.last = function() { return this[this.length - 1]; }; var sub = new SubArray(1, 2, 3); sub instanceof SubArray; // true sub instanceof Array; // true [代码] 尽管扩充原型链是一个更高性能的解决方案,但它有一个明显的缺点 - 它依赖于非标准的 proto 属性。 不幸的是,ECMAScript 不允许设置一个对象的 [[Prototype]] - 在其原型链中引用直接祖先的内部属性。 即使在第五版中也没有。 尽管 proto 被大量的实现支持,但它远没有真正兼容。 es6 继承数组的实现 [代码]class FakeArray extends Array { push (...args) { console.log('我被改变啦'); return super.push(...args); } } var list = [1, 2, 3]; var arr = new FakeArray(...list); console.log(arr.length); arr.push(3); console.log(arr); [代码] vue 2.x 中拦截数组 7 个变异方法的实现 [代码] // 要拦截的数组变异方法 const mutationMethods = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]; const arrayMethods = Object.create(Array.prototype); // 实现 arrayMethods.__proto__ === Array.prototype const arrayProto = Array.prototype; // 缓存 Array.prototype mutationMethods.forEach(method => { arrayMethods[method] = function (...args) { const result = arrayProto[method].apply(this, args); console.log(`执行了代理原型的 ${method} 函数`); return result; } }); const arr = []; arr.__proto__ = arrayMethods; arr.push(1); [代码]
2019-10-08 - JavaScript常用设计模式示例与应用
JavaScript常用设计模式实例与应用 前言 1. 什么是设计模式 小时候打游戏,我们总是追求快速完美通关;上下班交通,我们总是会选择最方便便捷乘车路线。我们总是追求一件事情的最优美便捷的解决方案,也就是其所谓的最佳实践。 一个设计模式就是一个可重用的方案,可应用于在软件设计中常见的问题,在本次分享主题中,就是编写JavaScript的web应用程序中常见的问题,设计模式的另一种解释就是一个我们如何解决问题的模板。那些在许多不同但类似的情况下使用的模板。 2. 为什么要学习设计模式 JavaScript是一门以原型为基础,面向对象的,动态数据类型语言。在把函数视为第一公民,支持函数式编程的同时也不排斥面向对象的开发方式,甚至在ES6+的标准中还引入了面向对象的一些原生支持。这使得JavaScript成为一门功能强大的语言同时也导致了编程风格的碎片化,同一个功能实现的多样性。对于一些传统的、强面向对象的设计模式会有各种类型的实现,有时候会让人感觉牵强。但是这些并不妨碍我们使用JavaScript来表达设计模式的理念、所要解决的问题以及它的核心思想,这才是我们所要关注的核心。 设计模式可以让我们站在巨人的肩膀上,获得前人的经验,保证我们以优雅的方式组织我们的代码并满足我们解决问题所需要的条件。 内容 一、设计原则 设计原则是指导思想,是我们在程序设计中尽可能要遵守的准则。设计模式就是这些设计原则的一些具体实现,所要达到的目标就是高内聚低耦合。在这里我简单介绍一些六大设计原则中的单一职责原则(SPR)、开放封闭原则(OCP)、最少知识原则(LKP)。 1. 单一职责原则 单一职责原则指的是一个类应该仅有一个引起它变化的原因,也就是说一个对象只做一件事情。这样做可以让我们对对象的维护变得简单,如果一个对象拥有多种职责,职责之间相互耦合,对一个职责的修改势必会影响到其他职责。也就是说,一个对象负责的职责越多,耦合越强,对模块的修改就越危险。 2. 开放封闭原则 开放封闭原则指的是一个模块应该在对扩展开放,而对修改封闭。当需要修改增加需求的时候,应该尽量通过扩展新代码的方式,而不是修改已有的代码。因为修改已有代码会给依赖原有代码的模块带来隐患,从而需要把依赖原有代码的模块重新测试一遍,加重测试成本。 3. 最少知识原则 最少知识原则指的是一个类应该对自己需要耦合或调用的类了解得尽可能少,调用者或依赖着仅需要知道他所需要的方法即可,其他的概不关心。因为类与类之间的关系越密切,耦合性越高,当一个类发生改变时,对另一个类的影响也越大。通常我们减少对象之间的联系的方法是引入一个第三者来帮助通信,阻隔对象之间的直接通信,从而减少耦合。 二、设计模式的分类 设计模式可以被分成几个不同的种类: 创建型设计模式 创建型设计模式关注的是对象创建的机制方法,一般会把对象的创建和使用分离,从而帮助创建类的实例对象。属于这一类的设计模式主要有:构造器模式、工厂模式、单例模式、建造者模式等。 结构型设计模式 结构型设计模式关注对象组成以及不同对象之间的关系。这类模式有助于在系统的某一部分发生变化时减少对整个系统结构的改变。主要包括:代理模式、享元模式、外观模式、适配器模式、装饰者模式等。 行为型设计模式 行为型设计模式关注对象之间的通信,描述对象之间如何相互协作。主要包括:发布订阅模式,策略模式,状态模式,迭代器模式,命令模式,职责链模式,中介者模式等。 三、设计模式示例 1. 单例模式 单例模式(Singleton Pattern)属于创建型设计模式,它限制一个类只能有一个实例化对象,并提供一个访问它的全局访问点。 单例模式可能是最简单的设计模式了,虽然简单,但在实际项目开发中是很常用的一种模式。 单例模式中有几个需要知道的概念: Singleton:特定的类,也就是我们需要访问的类,访问者要拿到的就是它的实例。 Instance: 单例,是特定类的唯一实例。 getInstance: 获取单例的方法。 代码示例 [代码]var GameManager = (function () { // 单例 var instance; function init() { // 私有变量和方法 var _saveData = { name: 'glenn', level: 1 }; function _privateMethod(){ console.log( "I am private function" ); } return { // 公有变量和方法 levelUp: function(){ _saveData.level ++; }, getCurLevel: function(){ return _saveData.level; }, getName: function(){ return _saveData.name; }, publicProperty: "this is a public prop", }; }; return { // 如果存在获取此单例实例,如果不存在创建一个单例实例 getInstance: function () { if ( !instance ) { instance = init(); } return instance; } }; })(); // 使用: var singleA = GameManager.getInstance(); singleA.levelUp(); var singleB = GameManager.getInstance(); console.log( singleA.getCurLevel() === singleB.getCurLevel() ); // true [代码] 在本例中,GameManager是一个单例类,我们首先使用立即调用函数IIFE把希望隐藏的单例示例instance隐藏起来,在init方法中定义该单例类的公有和私有方法变量,然后返回一个对象,把获取单例实例的方法getInstance暴露出去。在getInstance方法中,通过JavaScript的闭包特性把单例实例instance存进闭包中,在第一次获取实例时才初始化单例,并在之后的获取操作中返回的都是这个相同的实例。 可以看到,在使用单例的代码中,我们调用了两次getInstance获取的两个对象singleA和singleB指向的是同一个对象。 源码中的单例模式 以 ElementUI 为例,ElementUI中的全屏Loading蒙层使用服务的形式调用的使用方式示意: [代码]Vue.prototype.$loading = service; this.$loading({ fullscreen: true }); [代码] 我们可以看看这个loading在ElementUI2.9.2源码中是如何实现的。 下面是为了方便观看省略了部分代码后的源码 [代码]import Vue from 'vue'; import loadingVue from './loading.vue'; const LoadingConstructor = Vue.extend(loadingVue); //... //单例 let fullscreenLoading; LoadingConstructor.prototype.originalPosition = ''; LoadingConstructor.prototype.originalOverflow = ''; LoadingConstructor.prototype.close = function() { //... }; const addStyle = (options, parent, instance) => { //... }; const Loading = (options = {}) => { //... //判断示例是否已经初始化 if (options.fullscreen && fullscreenLoading) { return fullscreenLoading; } //一系列的初始化操作 let parent = options.body ? document.body : options.target; let instance = new LoadingConstructor({ el: document.createElement('div'), data: options }); addStyle(options, parent, instance); if (instance.originalPosition !== 'absolute' && instance.originalPosition !== 'fixed') { addClass(parent, 'el-loading-parent--relative'); } if (options.fullscreen && options.lock) { addClass(parent, 'el-loading-parent--hidden'); } parent.appendChild(instance.$el); Vue.nextTick(() => { instance.visible = true; }); //把初始化出来的实例缓存下来 if (options.fullscreen) { fullscreenLoading = instance; } return instance; }; export default Loading; [代码] 这里的单例是fullscreenLoading,缓存在闭包中。当用户调用时传入的options中fullscreen为true且之前已经创建并初始化过单例的情况下直接返回之前创建的单例,否则继续执行后面的初始化操作,并把创建的单例赋值给闭包中的fullscreenLoading后返回新创建的单例。 这是一个典型的单例模式应用,通过复用之前创建的全屏加载蒙层单例,不仅减少了重复实例化过程带来的额外开销,还保证了页面中不会出现重复的全屏加载蒙层。 单例模式的应用场景 当项目中需要一个公共的状态管理时,我们可以引入单例模式来确保访问的一致性。 当项目中存在一些同一时间只会出现一个且会重复出现的对象时,我们可以引入单例模式避免重复创建对象产生的多余开销,例如项目中的弹窗,消息框提醒等。 2. 外观模式 外观模式又叫门面模式,属于结构型模式,它将子系统的一系列复杂的接口集成起来组成一个更高级别的更舒适的高层接口,从而隐藏其真正的潜在复杂性,对外提供一个一致的外观。 外观模式让外界减少对子系统的直接交互,从而降低耦合,让外界可以轻松使用子系统,其本质是封装交互,简化调用。 代码示例 [代码]var module = (function() { var _sportsman = { speed: 5, height: 10, set : function(key, val) { this[key] = val; }, run : function() { console.log('运动呀正在以'+this.speed+'米每秒的速度向前跑着。'); }, jump: function(){ console.log( "运动员往上跳了"+this.height+'米'); } }; return { facade : function( args ) { args.speed != undefined && _sportsman.set('speed', args.speed); args.height != undefined && _sportsman.set('height', args.height); args.run && _sportsman.run(); args.jump && _sportsman.jump(); } }; }()); // Outputs: 运动呀正在以10米每秒的速度向前跑着。 // 运动员往上跳了5米 module.facade( {run: true, speed: 10, jump: true, height: 5} ); [代码] 这是表达外观模式一个简单的例子。在本例中,调用module的门面方法facede会触发运动员对象_sportsman中的一系列私有方法。但在这一次,用户不需要关心运动员对象内部方法的实现,就可以让运动员动起来。 源码中的外观模式 当我们使用Jquery的$(document).ready()来给浏览器加载完成添加事件回调时,Jquery会调用源码中的私有方法: [代码]// ... bindReady: function() { //... // Mozilla, Opera and webkit nightlies currently support this event if ( document.addEventListener ) { // Use the handy event callback document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false ); // A fallback to window.onload, that will always work window.addEventListener( "load", jQuery.ready, false ); // If IE event model is used } else if ( document.attachEvent ) { // ensure firing before onload, // maybe late but safe also for iframes document.attachEvent( "onreadystatechange", DOMContentLoaded ); // A fallback to window.onload, that will always work window.attachEvent( "onload", jQuery.ready ); // If IE and not a frame // continually check to see if the document is ready var toplevel = false; try { toplevel = window.frameElement == null; } catch(e) {} if ( document.documentElement.doScroll && toplevel ) { doScrollCheck(); } } } // ... [代码] 由于IE9之前的IE版本浏览器以及Opera7.0之前的Opera浏览器不支持addEventListener方法,在需要适配这些浏览器的项目中,我们需要自己手动判断浏览器版版本来决定使用什么事件绑定方法以及事件。而如果使用了Jquery库中提供的这个外观方法,用户则不需要关心浏览器的兼容问题,使用一致的外观接口$(document).ready()就可以实现监听浏览器加载完成事件的功能,从而简化了使用。 除了抹平浏览器的兼容性问题之外,Jquery还有一些其他的外观模式的应用: 比如设置或获取dom结点的内容和属性时使用的text()、html()和val()方法时,Jquery判断调用方法是否有传参数来确定是设置还是获取操作。这里Jquery把设置和获取操作对外提供了同一个外观接口,使调用简化了不少。 再比如Jquery的ajax的API[代码]$.ajax(url[,settings])[代码],当我们需要设置以JSONP的形式发送请求时,只需要传入[代码]dataType: 'jsonp'[代码]设置,jquery会进行额外的操作帮我们启动JSONP流程,而不需要调用者添加额外的代码。 外观模式的适用场景 维护设计粗糙和难以理解的上古系统,或者非常复杂的一些系统时,可以为这些系统设置一个外观模块,给外界提供清晰的接口,以后的新系统只需要与外观接口交互即可。 构建多层系统时,可以使用外观模式来将系统分层,让外观接口成为每一层的入口,简化层间调用,给层间松耦。 团队协作时,可以将各自负责的模块建立合适的外观,简化其他同事的使用,节约沟通时间。 发布订阅者模式 发布 - 订阅模式(Publish-Subscribe Pattern, pub-sub)又叫观察者模式(Observer Pattern),属于行为型模式,它定义了一种一对多的关系,让多个订阅者对象同时监听某一个发布者,或者叫主题对象,这个主题对象的状态发生变化时就会通知所有订阅自己的订阅者对象,使得它们能够自动更新自己。 发布 - 订阅模式有几个主要概念: Publisher:发布者,当消息发生时负责通知对应订阅者 Subscriber:订阅者,当消息发生时被通知的对象 SubscriberMap:以type为主键存储数组,每个数组存储所有对应type的订阅者 type: 消息类型,订阅者可以订阅的不同消息类型 subscribe:该方法可以将订阅者添加到SubscriberMap中对应的数组中 unSubscribe:该方法为SubscriberMap中删除订阅者 notify:该方法遍历通知SubscriberMap中对应type的所有订阅者 代码示例 [代码]var Publisher = (function() { var _subsMap = {} // 存储订阅者 return { /* 消息订阅 */ subscribe(type, cb) { if(_subsMap[type]){ if (!_subsMap[type].includes(cb)){ _subsMap[type].push(cb); } }else{ _subsMap[type] = [cb]; } }, /* 消息退订 */ unsubscribe(type, cb) { if(!_subsMap[type] || !_subsMap[type].includes(cb))return; var idx = _subsMap[type].indexOf(cb); _subsMap[type].splice(idx, 1); }, /* 消息发布 */ notify(type) { if (!_subsMap[type])return; var args = Array.prototype.slice.call(arguments, 1); _subsMap[type].forEach(function(cb){ cb.apply(this, args); }) } } })() Publisher.subscribe('运动鞋', function(message){console.log('111' + message)}); // 订阅运动鞋 Publisher.subscribe('运动鞋', function(message){console.log('222' + message)}); Publisher.subscribe('帆布鞋', function(message){console.log('333' + message)}); // 订阅帆布鞋 Publisher.notify('运动鞋', ' 运动鞋到货了 ~') // 打电话通知买家运动鞋消息 Publisher.notify('帆布鞋', ' 帆布鞋售罄了 T.T') // 打电话通知买家帆布鞋消息 [代码] 这是一个发布-订阅模式的通用代码实现,Publisher就是一个发布者,这里使用了立即调用函数IIFE方式来将不希望被外界调用的_subsMap隐藏。订阅者采用回调函数的形式,在消息发布时使用JavaScript的apply、call函数使发布的消息参数可以传到订阅者回调函数中去。 源码中的发布-订阅模式 我们使用Jquery的API可以轻松实现消息的订阅、发布以及退订操作: [代码]function eventHandler() { console.log('自定义方法') } /* ---- 事件订阅 ---- */ $('#app').on('myevent', eventHandler) // 发布 $('#app').trigger('myevent') // 输出:自定义方法 /* ---- 取消订阅 ---- */ $('#app').off('myevent') $('#app').trigger('myevent') // 没有输出 [代码] 对应api源码参见: event.js 其中add方法为on接口的内部直接绑定方法,remove方法对应off接口的内部实现。 发布-订阅模式的优缺点 发布-订阅模式最大优点就是解耦: 时间上的解耦:注册事件后,订阅者不需要持续关注发布者的动态,当事件触发时,发布者会通知对应的订阅者,调用对应的回调函数。 对象间的解耦: 发布者不需要提前知道事件的订阅者有哪些,当事件发生时直接遍历对应的订阅者回调函数来通知订阅者,从而解耦了发布者和订阅者之间的联系,使它们之间互不持有。 发布-订阅模式也有一些缺点: 增加消耗:创建结构和缓存订阅者两个过程都会消耗计算和内存资源,即时订阅后没有触发过,订阅者使用会存在内存中。 增加复杂度 :订阅者被缓存在一起,如果多个订阅者和发布者层层嵌套,那么程序将变得难以追踪和调试,参考一下 Vue 调试的时候你点开原型链时看到的那堆 deps/subs/watchers 们… 总结 设计模式能够让我站在巨人的肩膀上,享受其他开发者们长期以来在一些有挑战性问题上的解决方案以及优秀的架构。 对我们来讲,知道有这些设计模式是很重要的,但更重要的是应该知道怎样以及什么时候去使用它们。遵守设计原则,使用设计模式是好事,但是过犹不及,在实际项目中我们不能刻板的遵守这些设计原则以及使用设计模式,在想使用每个模式前先去了解下它的优缺点。要真正的理解模式能给你带来什么好处需要花时间去尝试,以实际情况中模式给你的程序带来的好处作为标准来选择。
2019-10-31 - 如何写出优雅的深复制
前言 无论在项目开发或者学习中,深拷贝已经是一个老生常谈的话题了,但是在实际中,如何优雅地写出深拷贝是我们值得思考的一个问题 内容 深拷贝 与 浅拷贝 深拷贝 将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象 浅拷贝 如果一个对象有着原始对象属性值的一份精确拷贝。如果这个对象属性是基本类型,那么拷贝的就是基本类型的值,如果属性是引用类型,那么拷贝的就是内存地址。 区别 其实深拷贝和浅拷贝的主要区别就是其在内存中的存储类型不同。 堆和栈都是内存中划分出来用来存储的区域。 栈(stack)为自动分配的内存空间,它由系统自动释放;而堆(heap)则是动态分配的内存,大小不定也不会自动释放。 对于js中的基本数据类型,他们的值被以键值对的形式保存在栈中。 [图片] 与基本类型不同的是,引用类型的值被保存在堆内存中,对象的引用被保存在栈内存中,而且我们不可以直接访问堆内存,只能访问栈内存。所以我们操作引用类型时实际操作的是对象的引用。 [图片] 了解相关的基础知识后,我们话不多说,直奔主图 简单版 在不使用第三方库的情况下,我们想要深拷贝一个对象,用的最多的就是下面这个方法。 [代码] JSON.parse(JSON.stringify()); [代码] 这种写法非常简单,而且可以应对大部分的应用场景,但是它还是有很大缺陷的,比如拷贝其他引用类型、拷贝函数、循环引用等情况。 基础版 如果是浅拷贝对象时,我们可以很容易的就写出代码 [代码]function clone(obj) { let cloneObj = {}; for (const key in obj) { cloneObj [key] = obj[key]; } return cloneObj ; }; [代码] 对于浅拷贝而言,只需要简单地将对象的每一个属性进行复制即可。然而,对于深拷贝而言,我们拷贝对象的话是需要知道目标对象的属性是否是基本数据类型以及对象的深度。这些我们可以通过递归的方法来实现。 [代码]/* * 作用: 深复制对象属性 */ function clone(obj) { if (typeof obj=== 'object') { let cloneObj = {}; for (const key in obj) { cloneObj [key] = clone(obj[key]); } return cloneObj ; } else { return obj; } }; [代码] 这时候,我们实现了一个基础的深复制,那么问题来了,对于数组,该如何实现呢? 加深版 在上面的版本中,我们的初始化结果只考虑了普通的object,下面我们只需要把初始化代码稍微一变,就可以兼容数组了: [代码]function clone(obj) { if (typeof obj=== 'object') { let cloneObj = Array.isArray(obj) ? [] : {}; for (const key in target) { cloneObj[key] = clone(obj[key]); } return cloneObj; } else { return obj; } }; [代码] 在判断目标对象是引用类型时,则通过Array.isArray方法判断是否是数组,如果是则赋值为空数组,否则赋值为空对象。 循环引用 当对象子属性的值是父对象时,则递归的方法将不再适用。原因就是对象存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况,这将导致递归进入死循环导致栈内存溢出。 为了解决这个问题,我们可以通过WeakMap这种数据结构来实现。首先我们通过WeakMap来存储当前对象和拷贝对象的对应关系。当需要拷贝当前对象时,先去WeakMap中找,有则返回,无则set。 [代码]WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。 [代码] [代码]function clone(target, weakMap = new WeakMap()) { if (typeof target === 'object') { let cloneTarget = Array.isArray(target) ? [] : {}; if (weakMap.get(target)) { return weakMap.get(target); } weakMap.set(target, cloneTarget); for (const key in target) { cloneTarget[key] = clone(target[key], weakMap); } return cloneTarget; } else { return target; } }; [代码] 考虑一下性能,while循环的性能要比for on的要好,因此改造一下 [代码]function forEach(array, iteratee) { let index = 0; while (index < array.length) { iteratee(index, array[index]); index++; } return array; } function clone(target, weakMap = new WeakMap()) { if (typeof target === 'object') { const isArray = Array.isArray(target); let cloneTarget = Array.isArray(target) ? [] : {}; if (weakMap.get(target)) { return weakMap.get(target); } weakMap.set(target, cloneTarget); const keyList = isArray ? undefined : Object.keys(target); forEach( keyList || target , function(key, value){ if(keyList){ // 对象而言,其值才是他的key key = value; } cloneTarget[key] = clone(target[key], weakMap); }) return cloneTarget; } else { return target; } }; [代码] 其他数据类型 综上我们考虑到的只是普通的object以及array俩种数据类型,但引用类型并不单只有这俩个,还有很多。。。 判断是否是引用类型 在判断是否是引用类型时,我们可以通过typeof字段,此时我们还需要考虑typeof可能返回’function’字符串以及对象有可能是null的情况,因此可写出判断函数如下所示 [代码]function isObject(target) { const type = typeof target; return target !== null && (type === 'object' || type === 'function'); } [代码] 获取数据类型 我们可以使用toString来获取准确的引用类型: [代码]function getType(target) { return Object.prototype.toString.call(target); } [代码] [图片] [图片] 根据上面的返回的字符串,我们可以抽离出一些常用的数据类型以便后面使用: [代码]const mapTag = '[object Map]'; const setTag = '[object Set]'; const arrayTag = '[object Array]'; const objectTag = '[object Object]'; const boolTag = '[object Boolean]'; const dateTag = '[object Date]'; const errorTag = '[object Error]'; const numberTag = '[object Number]'; const regexpTag = '[object RegExp]'; const stringTag = '[object String]'; const symbolTag = '[object Symbol]'; [代码] 在上面的集中类型中,我们简单将他们分为两类: 可以继续遍历的类型 不可以继续遍历的类型 可继续遍历的类型 上面我们已经考虑的object、array都属于可以继续遍历的类型,因为它们内存都还可以存储其他数据类型的数据,另外还有Map,Set等都是可以继续遍历的类型 这时候我们需要一个通过对象原型上的constructor属性获取构造函数,从而对要复制的对象进行初始化。方法如下: [代码]function getInit(target) { const Ctor = target.constructor; return new Ctor(); } [代码] 下面我们改写一下clone函数,让他兼容map,set。 [代码]const mapTag = '[object Map]'; const setTag = '[object Set]'; const arrayTag = '[object Array]'; const objectTag = '[object Object]'; const deepTag = [mapTag, setTag, arrayTag, objectTag]; function clone(target, weakMap = new WeakMap()) { // 克隆基本数据类型 if (!isObject(target)) { return target; } // 初始化 const type = getType(target); let cloneTarget; if (deepTag.includes(type)) { cloneTarget = getInit(target, type); } // 防止循环引用 if (weakMap.get(target)) { return weakMap.get(target); } weakMap.set(target, cloneTarget); // 克隆set if (type === setTag) { target.forEach(value => { cloneTarget.add(clone(value,weakMap)); }); return cloneTarget; } // 克隆map if (type === mapTag) { target.forEach((value, key) => { cloneTarget.set(key, clone(value,weakMap)); }); return cloneTarget; } // 克隆对象和数组 const keys = type === arrayTag ? undefined : Object.keys(target); forEach(keys || target, (value, key) => { if (keys) { key = value; } cloneTarget[key] = clone(target[key], weakMap); }); return cloneTarget; } [代码] 不可继续遍历的类型 其他剩余的类型我们把它们统一归类成不可处理的数据类型,我们依次进行处理: Bool、Number、String、String、Date、Error这几种类型我们都可以直接用构造函数和原始数据创建一个新对象: [代码]function cloneOtherType(targe, type) { const Ctor = targe.constructor; switch (type) { case boolTag: case numberTag: case stringTag: case errorTag: case dateTag: return new Ctor(targe); case regexpTag: return cloneReg(targe); case symbolTag: return cloneSymbol(targe); default: return null; } } function cloneSymbol(targe) { return Object(Symbol.prototype.valueOf.call(targe)); } //克隆正则 function cloneReg(targe) { const reFlags = /\w*$/; const result = new targe.constructor(targe.source, reFlags.exec(targe)); result.lastIndex = targe.lastIndex; return result; } [代码] 克隆函数 对于克隆函数,实际上是没有太大的意义。。。因为不同的俩个对象使用同一个函数是没有任何问题的。 首先,我们可以通过prototype来区分下箭头函数和普通函数,箭头函数是没有prototype的。 我们可以直接使用eval和函数字符串来重新生成一个箭头函数,注意这种方法是不适用于普通函数的。 [代码]function cloneFunction(func) { const bodyReg = /(?<={)(.|\n)+(?=})/m; const paramReg = /(?<=\().+(?=\)\s+{)/; const funcString = func.toString(); if (func.prototype) { // 普通函数 const param = paramReg.exec(funcString); const body = bodyReg.exec(funcString); if (body) { if (param) { const paramArr = param[0].split(','); return new Function(...paramArr, body[0]); } else { return new Function(body[0]); } } else { return null; } } else { // 箭头函数 return eval(funcString); } } [代码] 总结 综上,我们围绕深复制进行了解析,了解到了应该如何写出优雅的深复制,在实际开发中,可以根据不同的场景,合理的选择如何书写深复制。
2019-11-15 - 响应式原理解析
Vue响应式 前言 数据响应式系统是Vue最显著的一个特性,我们通过Vue官方文档回顾一下。 数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。 现在是时候深入一下了! 本文针对响应式系统的原理进行一个详细介绍。 响应式是什么 我们先来看一个例子 [代码]<div id="app"> <p>{{color}}</p> <button @click="changeColor">change color!</button> </div> new Vue({ el: '#app', data() { color: 'blue' }, methods: { changeColor() { this.color = 'yellow'; } } }) [代码] 当我们点击按钮的时候,视图的p标签文本就会从 [代码]blue[代码]改变成[代码]yellow[代码]。 Vue要完成这次更新,其实需要做两件事情: 监听数据[代码]color[代码]的变化。 当数据[代码]color[代码]更新变化时,自动通知依赖该数据的视图。 换成专业那么一点点点的名词就是利用[代码]数据劫持/数据代理[代码]去进行[代码]依赖收集[代码]、[代码]发布订阅模式[代码]。 我们只需要记住一句话:在getter中收集依赖,在setter中触发依赖 如何追踪侦测数据的变化 首先有个问题,如何侦测一个对象的变化? 目前来说,侦测对象变化有两种方法。大家都知道的! Object.defineProperty vue 2.x就是使用[代码]Object.defineProperty[代码]来数据响应式系统的。但是用此方法来来侦测变化会有很多缺陷。例如: Object.defineProperty无法监控到数组下标的变化,导致通过数组下标添加元素,不能实时响应; Object.defineProperty只能劫持对象的属性,从而需要对每个对象,每个属性进行遍历,如果,属性值是对象,还需要深度遍历。 … 本文也是利用Object.defineProperty来介绍响应式系统。 Proxy vue3就是通过proxy实现响应式系统的。而且在国庆期间已经发布pre-alpha版本。 相比旧的[代码]Object.defineProperty[代码], [代码]proxy[代码]可以代理整个对象,并且提供了多个[代码]traps[代码],可以实现诸多功能。此外,Proxy支持代理数组的变化等等 当然proxy也有一个致命的缺点,就是无法通过[代码]polyfill[代码]模拟,兼容性较差。 依赖收集的重要角色 Dep Watcher [代码]Dep[代码]、[代码]Watcher[代码]是数据响应式中两个比较重要的角色。 收集依赖的地方 Dep 因为在视图模板上可能有多处地方都引用同一个数据,所以要有一个地方去存放数据的依赖,这个地方就是Dep。 Dep主要维护一个依赖的数组,当我们利用render函数生成VNode的时候,会触发数据的getter,然后则会把依赖push到Dep的依赖数组中。 依赖是Watcher! 我们可以把[代码]Watcher[代码]理解成一个中介的角色,数据发生变化时,会触发数据的setter,然后通过遍历Dep中的依赖Watcher,然后Watcher通知额外的操作,额外的操作可能是更新视图、更新computed、更新watch等等… 原理实现 还是那句话:在getter中收集依赖,在setter中触发依赖。 下面我们看看代码: 代码有点长,下面会有步骤来讲解一次。 [代码]/* * 劫持数据的getter、setter */ function defineReactive(data, key, val) { // 1-1:把color数据变成响应式 const dep = new Dep(); Object.defineProperty(data, key, { enumerable: true, configurable: true, get() { // 3. 因为模板编译watcher访问到了color,从而触发get方法,触发了收集依赖的方法 dep.depend(); return val; }, set(newVal) { if (val === newVal) { return; } val = newVal; // 4-1. 假设我们通过 `this.color = 'yellow';`去更改`color`的值,就会触发set方法。 dep.notify(); } }); } /* * dep类,收集依赖,和触发依赖 */ class Dep { constructor() { this.subs = []; // 收集依赖的数组 } // 收集依赖 depend() { // 3-1. 通过外部的变量来添加到color的依赖中. if (window.target && !this.subs.includes(window.target)) { this.subs.push(window.target); } } // 通知依赖更新 notify() { // 4-2. 遍历 this.subs.forEach(watcher => { watcher.update(); }); } } /** * 数据与外部的中介 */ class Watcher { constructor(expr, cb) { this.cb = cb; this.expr = expr; // 2-1. 这里触发了get方法 this.value = this.get(); } get() { // 2-2. 这里把自己(watcher)赋值给了外部其中的一个变量 window.target = this; // 2-3. data[this.expr]触发了color的get const value = data[this.expr]; window.target = undefined; return value; } update() { this.value = this.get(); this.cb(this.value); } } [代码] 下面我们来走一次流程。 括号里面的1-1,2-2是对应代码的执行点。 1. 把数据变成响应式 利用[代码]defineReactive[代码]把color数据变成响应式(1-1),执行过这个方法后,我们调用[代码]console.log(this.color)[代码]的时候可以触发get方法。同理当我们[代码]this.color = 'yellow'[代码]。 注意:在[代码]Object.defineProperty[代码]上面初始化一个存放依赖的dep,这里其实是把dep作为数据[代码]color[代码]的一个私有变量,让get和set的方法可以访问到,也是我们经常说的闭包。 2. 编译模板创建watcher 假设我们现在编译模板遇到[代码]{{color}}[代码]。 Vue就会创建一个Watchter,伪代码如下: [代码]new Watcher('color', () => { // 当color发生变化的时候,会触发这里的方法。 }); [代码] 这里高能!! Watcher的构造函数里面调用了[代码]get()[代码]方法(2-2),把自己(watcher)赋值给了一个外部变量。 然后再触发get方法(2-3)。 3. get中收集依赖 因为模板编译watcher访问到了color,从而触发get方法,触发了收集依赖的方法。 进入到[代码]dep.depend[代码]方法中(3-1),这里因为在Watcher中把自己存到了外部变量中,所以在dep.depend方法中可以收集到依赖。 现在,依赖就被收集了。 4. 通过setter触发依赖 假设我们通过 [代码]this.color = 'yellow';[代码]去更改[代码]color[代码]的值,就会触发set方法,执行dep.notify(4-1)。 会遍历依赖数组,从而去触发Watcher的cb方法。 cb就是上面伪代码[代码]new Watcher[代码]的那个回调函数。 只要回调函数里面运行了操作dom方法,或者触发了diff算法更新dom,都可以把视图进行更新。 响应式简易流程大概就是这样了… 侦测数据变化的类型 其实数据监听变化有两种类型,一种是“推”(push),另一种是“拉”(pull)。 React和Angular中的变化侦测都属于“拉”,就是说在数据发生变化的时候,它不知道哪个数据变了,然后会发送一个信号给框架,框架收到信号后,会进行一个暴力比对来找出那些dom节点需要重新渲染。Angular中使用的是脏检查,在React使用虚拟dom的diff。 vue的数据监听属于“推”。当数据发生变化时,就知道哪个数据发生变化了,从而去更新有此数据依赖的视图。 因此,框架知道的数据更新信息越多,也就可以进行更细粒度的更新。比如,直接通过dom api操作dom。 相对“拉”的力度是最粗的。看到这里,是不是觉得vue的更新效率最快。 我们看看下面的例子 [代码]<template> <div>{{a}}</div> <div>{{b}}</div> </template> [代码] 这里我们看出模板只有a、b两个数据依赖,也就是说我们要创建两个闭包dep去存放两个watcher依赖,我们知道闭包的缺点就是内存泄露。如果有1000个数据依赖在模板上,每个数据所绑定的依赖就越多,依赖追踪在内存上的开销就会越大。 所以,从Vue.js2.0开始,它引入了虚拟dom,一个状态所绑定的依赖不再是具体的dom节点,而是一个组件,即一个组件一个Watcher。 这样状态变化后,会通知到组件,组件内部再使用虚拟 dom进行比对。这可以大大降低依赖数量,从而降低依赖追踪所消耗的内存。但并不是引入虚拟dom后,渲染速度变快了。准确的来说,应该是80%的场景下变得更快了,而剩下的20%反而变慢了。 个人觉得,鱼和熊掌不可兼得。 “推”是牺牲内存来换更新速度。 “拉”则是牺牲更新速度来获取内存。 总结 Vue响应式的灵魂:在getter中收集依赖,在setter中触发依赖。 [图片] 我们再看看图,回顾一下整个流程。 通过[代码]defineReactive[代码],遍历data里面的属性,把数据的getter/setter劫持,用来收集和触发依赖。 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到Dep依赖中。 当数据发生变化时,会触发setter,从而Dep会向依赖(Wachter)发送通知。 Watcher收到通知后,会向外界发送通知,变化通知到外界后可能接触视图更新,也有可能触发用户的某个回调函数等等。 参考文献:《深入浅出Vue.js》
2019-12-31 - 小程序模板消息能力调整通知
小程序模板消息能力在帮助小程序实现服务闭环的同时,也存在一些问题,如: 1. 部分开发者在用户无预期或未进行服务的情况下发送与用户无关的消息,对用户产生了骚扰; 2. 模板消息需在用户访问小程序后的 7 天内下发,不能满足部分业务的时间要求。 为提升小程序模板消息能力的使用体验,我们对模板消息的下发条件进行了调整,由用户自主订阅所需消息。 一次性订阅消息 一次性订阅消息用于解决用户使用小程序后,后续服务环节的通知问题。用户自主订阅后,开发者可不限时间地下发一条对应的服务消息;每条消息可单独订阅或退订。 [图片] (一次性订阅示例) 长期性订阅消息 一次性订阅消息可满足小程序的大部分服务场景需求,但线下公共服务领域存在一次性订阅无法满足的场景,如航班延误,需根据航班实时动态来多次发送消息提醒。为便于服务,我们提供了长期性订阅消息,用户订阅一次后,开发者可长期下发多条消息。 目前长期性订阅消息仅向政务民生、医疗、交通、金融、教育等线下公共服务开放,后期将逐步支持到其他线下公共服务业务。 调整计划 小程序订阅消息接口上线后,原先的模板消息接口将停止使用,详情如下: 1. 开发者可登录小程序管理后台开启订阅消息功能,接口开发可参考文档:《小程序订阅消息》 2. 开发者使用订阅消息能力时,需遵循运营规范,不可用奖励或其它形式强制用户订阅,不可下发与用户预期不符或违反国家法律法规的内容。具体可参考文档:《小程序订阅消息接口运营规范》 3. 原有的小程序模板消息接口将于 2020 年 1 月 10 日下线,届时将无法使用此接口发送模板消息,请各位开发者注意及时调整接口。 微信团队 2019.10.12
2019-10-13 - 微信小程序自定义导航栏组件(完美适配所有手机),可自定义实现任何你想要的功能
背景 在做小程序时,关于默认导航栏,我们遇到了以下的问题: Android、IOS手机对于页面title的展示不一致,安卓title的显示不居中 页面的title只支持纯文本级别的样式控制,不能够做更丰富的title效果 左上角的事件无法监听、定制 路由导航单一,只能够返回上一页,深层级页面的返回不够友好 探索 小程序自定义导航栏已开放许久>>了解一下,相信不少小伙伴已使用过这个功能,同时不少小伙伴也会发现一些坑: 机型多如牛毛:自定义导航栏高度在不同机型始终无法达到视觉上的统一 调皮的胶囊按钮:导航栏元素(文字,图标等)怎么也对不齐那该死的胶囊按钮 各种尺寸的全面屏,奇怪的刘海屏,简直要抓狂 一探究竟 为了搞明白原理,我先去翻了官方文档,>>飞机,点过去是不是很惊喜,很意外,通篇大文尽然只有最下方的一张图片与这个问题有关,并且啥也看不清,汗汗汗… 我特意找了一张图片来 [图片] 分析上图,我得到如下信息: Android跟iOS有差异,表现在顶部到胶囊按钮之间的距离差了6pt 胶囊按钮高度为32pt, iOS和Android一致 动手分析 我们写一个状态栏,通过wx.getSystemInfoSync().statusBarHeight设置高度 Android: [图片] iOS:[图片] 可以看出,iOS胶囊按钮与状态栏之间距离为:4px, Android为8px,是不是所有手机都是这种情况呢? 答案是:苹果手机确实都是4px,安卓大部分都是7和8 也会有其他的情况(可以自己打印getSystemInfo验证)如何快速便捷算出这个高度,请接着往下看 如何计算 导航栏分为状态栏和标题栏,只要能算出每台手机的导航栏高度问题就迎刃而解 导航栏高度 = 胶囊按钮高度 + 状态栏到胶囊按钮间距 * 2 + 状态栏高度 注:由于胶囊按钮是原生组件,为表现一致,其单位在各种手机中都为px,所以我们自定义导航栏的单位都必需是px(切记不能用rpx),才能完美适配。 解决问题 现在我们明白了原理,可以利用胶囊按钮的位置信息和statusBarHeight高度动态计算导航栏的高度,贴一个实现此功能最重要的方法 [代码]let systemInfo = wx.getSystemInfoSync(); let rect = wx.getMenuButtonBoundingClientRect ? wx.getMenuButtonBoundingClientRect() : null; //胶囊按钮位置信息 wx.getMenuButtonBoundingClientRect(); let navBarHeight = (function() { //导航栏高度 let gap = rect.top - systemInfo.statusBarHeight; //动态计算每台手机状态栏到胶囊按钮间距 return 2 * gap + rect.height; })(); [代码] gap信息就是不同的手机其状态栏到胶囊按钮间距,具体更多代码实现和使用demo请移步下方代码仓库,代码中还会有输入框文字跳动解决办法,安卓手机输入框文字飞出解决办法,左侧按钮边框太粗解决办法等等 胶囊信息报错和获取不到 问题就在于 getMenuButtonBoundingClientRect 这个方法,在某些机子和环境下会报错或者获取不到,对于此种情况完美可以模拟一个胶囊位置出来 [代码]try { rect = Taro.getMenuButtonBoundingClientRect ? Taro.getMenuButtonBoundingClientRect() : null; if (rect === null) { throw 'getMenuButtonBoundingClientRect error'; } //取值为0的情况 if (!rect.width) { throw 'getMenuButtonBoundingClientRect error'; } } catch (error) { let gap = ''; //胶囊按钮上下间距 使导航内容居中 let width = 96; //胶囊的宽度,android大部分96,ios为88 if (systemInfo.platform === 'android') { gap = 8; width = 96; } else if (systemInfo.platform === 'devtools') { if (ios) { gap = 5.5; //开发工具中ios手机 } else { gap = 7.5; //开发工具中android和其他手机 } } else { gap = 4; width = 88; } if (!systemInfo.statusBarHeight) { //开启wifi的情况下修复statusBarHeight值获取不到 systemInfo.statusBarHeight = systemInfo.screenHeight - systemInfo.windowHeight - 20; } rect = { //获取不到胶囊信息就自定义重置一个 bottom: systemInfo.statusBarHeight + gap + 32, height: 32, left: systemInfo.windowWidth - width - 10, right: systemInfo.windowWidth - 10, top: systemInfo.statusBarHeight + gap, width: width }; console.log('error', error); console.log('rect', rect); } [代码] 以上代码主要是借鉴了拼多多的默认值写法,android 机子中 gap 值大部分为 8,ios 都为 4,开发工具中 ios 为 5.5,android 为 7.5,这样处理之后自己模拟一个胶囊按钮的位置,这样在获取不到胶囊信息的情况下,可保证绝大多数机子完美显示导航头 吐槽 这么重要的问题,官方尽然没有提供解决方案…竟然提供了一张看不清的图片??? 网上有很多ios设置44,android设置48,还有根据不同的手机型号设置不同高度,通过长时间的开发和尝试,本人发现以上方案并不完美,并且bug很多 代码库 Taro组件gitHub地址详细用法请参考README 原生组件npm构建版本gitHub地址详细用法请参考README 原生组件简易版gitHub地址详细用法请参考README 由于本人精力有限,目前只计划发布维护好这2种组件,其他组件请自行修改代码,有问题请联系 备注 上方2种组件在最下方30多款手机测试情况表现良好 iPhone手机打电话和开热点导致导航栏样式错乱,问题已经解决啦,请去demo里测试,这里特别感谢moments网友提出的问题 本文章并无任何商业性质,如有侵权请联系本人修改或删除 文章少量部分内容是本人查询搜集而来 如有问题可以下方留言讨论,微信zhijunxh 比较 斗鱼: [图片] 虎牙: [图片] 微博: [图片] 酷狗: [图片] 知乎: [图片] [图片] 知乎是这里边做的最好的,但是我个人认为有几个可以优化的小问题 打电话或者开启热点导致样式错落,这也是大部门小程序的问题 导航栏下边距太小,看起来不舒服 搜索框距离2侧按钮组距离不对等 自定义返回和home按钮中的竖线颜色重了,并且感觉太粗 如果您看到了此篇文章,请赶快修改自己的代码,并运用在实践中吧 扫码体验我的小程序: [图片] 创作不易,如果对你有帮助,请移步Taro组件gitHub原生组件gitHub给个星星 star✨✨ 谢谢 测试信息 手机型号 胶囊位置信息 statusBarHeight 测试情况 iPhoneX 80 32 281 369 48 88 44 通过 iPhone8 plus 56 32 320 408 24 88 20 通过 iphone7 56 32 281 368 24 87 20 通过 iPhone6 plus 56 32 320 408 24 88 20 通过 iPhone6 56 32 281 368 24 87 20 通过 HUAWEI SLA-AL00 64 32 254 350 32 96 24 通过 HUAWEI VTR-AL00 64 32 254 350 32 96 24 通过 HUAWEI EVA-AL00 64 32 254 350 32 96 24 通过 HUAWEI EML-AL00 68 32 254 350 36 96 29 通过 HUAWEI VOG-AL00 65 32 254 350 33 96 25 通过 HUAWEI ATU-TL10 64 32 254 350 32 96 24 通过 HUAWEI SMARTISAN OS105 64 32 326 422 32 96 24 通过 XIAOMI MI6 59 28 265 352 31 87 23 通过 XIAOMI MI4LTE 60 32 254 350 28 96 20 通过 XIAOMI MIX3 74 32 287 383 42 96 35 通过 REDMI NOTE3 64 32 254 350 32 96 24 通过 REDMI NOTE4 64 32 254 350 32 96 24 通过 REDMI NOTE3 55 28 255 351 27 96 20 通过 REDMI 5plus 67 32 287 383 35 96 28 通过 MEIZU M571C 65 32 254 350 33 96 25 通过 MEIZU M6 NOTE 62 32 254 350 30 96 22 通过 MEIZU MX4 PRO 62 32 278 374 30 96 22 通过 OPPO A33 65 32 254 350 33 96 26 通过 OPPO R11 58 32 254 350 26 96 18 通过 VIVO Y55 64 32 254 350 32 96 24 通过 HONOR BLN-AL20 64 32 254 350 32 96 24 通过 HONOR NEM-AL10 59 28 265 352 31 87 24 通过 HONOR BND-AL10 64 32 254 350 32 96 24 通过 HONOR duk-al20 64 32 254 350 32 96 24 通过 SAMSUNG SM-G9550 64 32 305 401 32 96 24 通过 360 1801-A01 64 32 254 350 32 96 24 通过
2019-11-17 - 小程序setData
setData setData 是小程序开发中使用最频繁的接口,也是最容易引发性能问题的接口。 Object 以 key: value 的形式表示,将 this.data 中的 key 对应的值改变成 value。 具体的介绍请看微信开发文档:https://developers.weixin.qq.com/miniprogram/dev/framework/performance/tips.html 注意: 直接修改 this.data 而不调用 this.setData 是无法改变页面的状态的,还会造成数据不一致 单次设置的数据不能超过1024kB,请尽量避免一次设置过多的数据。 this.setData 两种情况赋值 触发渲染 页面 xxx.js 中 data 的数据 [代码]lunList: [[代码][代码] [代码][代码]{ id: 0, [代码][代码]choose: [代码][代码]false[代码][代码], [代码][代码]},[代码][代码] [代码][代码]{ id: 1, [代码][代码]choose: [代码][代码]false[代码][代码], [代码][代码]},[代码][代码] [代码][代码]{ id: 2, [代码][代码]choose: [代码][代码]false[代码][代码], [代码][代码]}[代码][代码] [代码][代码]][代码][代码]showlun: [代码][代码]function[代码] [代码](e) {[代码][代码] [代码][代码]var[代码] [代码]_this = [代码][代码]this[代码][代码], index = e.currentTarget.dataset.id;[代码][代码] [代码][代码]console.log(e, index) [代码][代码]//index,表示点击的第几个索引[代码][代码] [代码][代码]if[代码] [代码](_this.data.lunList[index].choose == [代码][代码]false[代码][代码]) {[代码][代码] [代码][代码]_this.data.lunList[index].choose=[代码][代码]true[代码][代码] [代码][代码]} [代码][代码]else[代码] [代码]{[代码][代码] [代码][代码]_this.data.lunList[index].choose=[代码][代码]false[代码][代码] [代码][代码]}[代码][代码] [代码][代码]_this.setData({[代码][代码] [代码][代码]lunList:_this.data.lunList[代码][代码] [代码][代码]})[代码][代码] [代码][代码]console.log(_this.data.lunList)[代码][代码] [代码][代码]}[代码]ps:数据一旦过大,再js中操作整个数据渲染页面 [代码]showlun: [代码][代码]function[代码] [代码](e) {[代码][代码] [代码][代码]var[代码] [代码]_this = [代码][代码]this[代码][代码], index = e.currentTarget.dataset.id;[代码][代码] [代码][代码]console.log(e, index)[代码][代码] [代码][代码]//重点 字符串组合的形式, this.setData 自动识别为 ["lunList[2].choose"] 这样直接操作 数据中的某一项直接赋值[代码][代码] [代码][代码]var[代码] [代码]ss = [代码][代码]'lunList['[代码] [代码]+ index + [代码][代码]'].choose'[代码] [代码] [代码][代码]if[代码] [代码](_this.data.lunList[index].choose == [代码][代码]false[代码][代码]) {[代码][代码] [代码][代码]_this.setData({[代码][代码] [代码][代码][ss]: [代码][代码]true[代码][代码] [代码][代码]})[代码][代码] [代码][代码]} [代码][代码]else[代码] [代码]{[代码][代码] [代码][代码]_this.setData({[代码][代码] [代码][代码][ss]: [代码][代码]false[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码] [代码][代码]console.log(_this.data.lunList)[代码][代码] [代码][代码]}[代码]ps:对于对象或数组字段,可以直接修改一个其下的子字段,这样做通常比修改整个对象或数组更好
2019-06-21 - 10行代码实现小程序支付功能!丨实战
前面给大家讲过一个借助小程序云开发实现微信支付的,但是那个操作稍微有点繁琐,并且还会经常出现问题,今天就给大家讲一个简单的,并且借助官方支付api实现小程序支付功能。 传送门: 借助小程序云开发实现小程序支付功能 老规矩,先看本节效果图 [图片] 我们实现这个支付功能完全是借助小程序云开发实现的,不用搭建自己的服务器,不用买域名,不用备案域名,不用支持https。只需要一个简单的云函数,就可以轻松的实现微信小程序支付功能。 核心代码就下面这些: [图片] 一、创建一个云开发小程序 关于如何创建云开发小程序,这里我就不再做具体讲解。不知道怎么创建云开发小程序的同学,可以去翻看腾讯云云开发公众号内菜单【技术交流-视频教程】中的教学视频。 创建云开发小程序有几点注意的 1.一定不要忘记在app.js里初始化云开发环境。 [图片] 2.创建完云函数后,一定要记得上传 二、创建支付的云函数 1.创建云函数pay [图片] [图片] 三、引入三方依赖tenpay 我们这里引入三方依赖的目的,是创建我们支付时需要的一些参数。我们安装依赖是使用里npm 而npm必须安装node,关于如何安装node,我这里不做讲解,百度一下,网上一大堆。 1.首先右键pay,然后选择在终端中打开 [图片] 2.我们使用npm来安装这个依赖。 在命令行里执行 npm i tenpay [图片] [图片] [图片] 安装完成后,我们的pay云函数会多出一个package.json 文件 [图片] 到这里我们的tenpay依赖就安装好了。 四、编写云函数pay [图片] 完整代码如下 [代码]//云开发实现支付 const cloud = require('wx-server-sdk') cloud.init() //1,引入支付的三方依赖 const tenpay = require('tenpay'); //2,配置支付信息 const config = { appid: '你的小程序appid', mchid: '你的微信商户号', partnerKey: '微信支付安全密钥', notify_url: '支付回调网址,这里可以先随意填一个网址', spbill_create_ip: '127.0.0.1' //这里填这个就可以 }; exports.main = async(event, context) => { const wxContext = cloud.getWXContext() let { orderid, money } = event; //3,初始化支付 const api = tenpay.init(config); let result = await api.getPayParams({ out_trade_no: orderid, body: '商品简单描述', total_fee: money, //订单金额(分), openid: wxContext.OPENID //付款用户的openid }); return result; } [代码] 一定要注意把appid,mchid,partnerKey换成你自己的。 到这里我们获取小程序支付所需参数的云函数代码就编写完成了。 不要忘记上传这个云函数。 [图片] 出现下图就代表上传成功 [图片] 五、写一个简单的页面,用来提交订单,调用pay云函数。 [图片] 这个页面很简单: 1.自己随便编写一个订单号(这个订单号要大于6位) 2.自己随便填写一个订单价(单位是分) 3.点击按钮,调用pay云函数。获取支付所需参数。 下图是官方支付api所需要的一些必须参数。 [图片] 下图是我们调用pay云函数获取的参数,和上图所需要的是不是一样。 [图片] 六、调用wx.requestPayment实现支付 下图是官方的示例代码: [图片] 这里不在做具体讲解了,把完整代码给大家贴出来 [代码]// pages/pay/pay.js Page({ //提交订单 formSubmit: function(e) { let that = this; let formData = e.detail.value console.log('form发生了submit事件,携带数据为:', formData) wx.cloud.callFunction({ name: "pay", data: { orderid: "" + formData.orderid, money: formData.money }, success(res) { console.log("提交成功", res.result) that.pay(res.result) }, fail(res) { console.log("提交失败", res) } }) }, //实现小程序支付 pay(payData) { //官方标准的支付方法 wx.requestPayment({ timeStamp: payData.timeStamp, nonceStr: payData.nonceStr, package: payData.package, //统一下单接口返回的 prepay_id 格式如:prepay_id=*** signType: 'MD5', paySign: payData.paySign, //签名 success(res) { console.log("支付成功", res) }, fail(res) { console.log("支付失败", res) }, complete(res) { console.log("支付完成", res) } }) } }) [代码] 到这里,云开发实现小程序支付的功能就完整实现了。 实现效果 1.调起支付键盘 [图片] 2.支付完成 [图片] 3.log日志,可以看出不同支付状态的回调 [图片] 上图是支付成功的回调,我们可以在支付成功回调时,改变订单支付状态。 下图是支付失败的回调: [图片] 下图是支付完成的状态: [图片] 到这里我们就轻松的实现了微信小程序的支付功能了,是不是很简单啊。 源码地址: https://github.com/TencentCloudBase/Good-practice-tutorial-recommended 如果你有关于使用云开发CloudBase相关的技术故事/技术实战经验想要跟大家分享,欢迎留言联系我们哦~比心! [图片]
2019-08-15 - 分享扫码打开小程序操作实战
这是我们工作过程中的经验总结,现在分享给大家 说明:生成能打开小程序的二维码分为两种:1. 服务端调用接口生成;2. 前端生成二维码 一、服务端调用接口生成 https://api.weixin.qq.com/wxa/getwxacode?access_token=ACCESS_TOKEN 适用于需要的码数量较少的业务场景 生成小程序码,可接受 path 参数较长。 永久有效,可生成的码数量限制为 100,000 https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=ACCESS_TOKEN 适用于需要的码数量极多的业务场景。 可接受页面参数较短,生成个数不受限。 永久有效,数量暂无限制 调用分钟频率受限(5000次/分钟) ,如需大量小程序码,预生成 https://api.weixin.qq.com/cgi-bin/wxaapp/createwxaqrcode?access_token=ACCESS_TOKEN 适用于需要的码数量较少的业务场景。 生成二维码,可接受 path 参数较长。 生成个数受限,可生成的码数量限制为 100,000 在小程序中接受参数方式:page onLoad函数中接受 [代码]Page({ onLoad(query) { // 需要使用 decodeURIComponent 才能获取到生成二维码时传入的 scene const scene = decodeURIComponent(query.scene) } }) [代码] 二、前端生成二维码 操作步骤: 1. 在小程序后台(mp.weixin.qq.com)配置链接 https://mp.weixin.qq.com/wxopen/devprofile?action=get_profile&token=1318106189&lang=zh_CN [图片] 2. 点击添加: [图片] 3. 添加完成后点击上线 4. 在JS中用第三方库将拼接好的URL(比如: https://xxxxx/homeindex?orderId=123)生成二维码 小程序获取二维码中携带的参数: app.js的 onshow/onLoad函数中获取参数,options.query.q是生成二维码的完成URL,后截取URL获取相应参数 [代码]if (!!options.query && !!options.query.q) { let url = decodeURIComponent(options.query.q) if (url.indexOf('orderId') > -1) { let index = url.slice(url.indexOf('orderId')) if (index.indexOf('&') > -1) { let orderObj = index.slice(0, index.indexOf('&')) this.globalData.orderId = orderObj.split('=')[1] } else { this.globalData.orderId = index.split('=')[1] } } } [代码]
2019-08-16 - 小打卡 | 如何基于微信原生构建应用级小程序底层架构(下)
点击查看:小打卡 | 如何基于微信原生构建应用级小程序底层架构(上)装饰模式[图片] 可能有朋友会好奇装饰器和我们接下来要做的原生函数扩展有什么关系? 先看下装饰模式的定义:在不改变原函数的基础上,附加一些额外的功能 方便更好的理解 创建一个函数,然后将存在originF这个变量里,并对它重新赋值,最后执行,最后的执行直接结果是什么? hello world!! 这样做的好处是什么,我们对函数f添加了一个新的功能,但是我们没有对原函数进行任何的修改 那么对于小程序的应用 照猫画虎一下,同样的将小程序原生的Page函数存起来,再重新赋值,看下结果,发现Page执行的时候每次都会console这句话,我这里有两个未分包的页面,就执行了2次 [图片] 回归到刚开始需要解决的问题,“多个页面pv,uv如何监控,不可能每个页面都要手动收集”, pv怎么算,pv的意思是页面浏览量,也就是需要页面生成时,对应到小程序生命周期就是onLoad 刚才我们做到了每个Page执行时添加功能,但是怎么在onLoad时进行数据统计呢? 同样的可以用装饰函数修饰page里面的函数方法 [图1] 这个data就是我们实际在pages下写的业务逻辑对象,我们先拿到该页面的key名来进行遍历,首先排除掉非函数,拿到onLoad函数,这时候对它进行扩展,这时候每一个页面执行onLoad的时候都会console一次字符串,当然也可以替换为你想要的任何功能 [图2] 其实我们可以再优化一下,比如抽出一个对象,将你想要装饰的函数写入其中,如果原函数存在则进行装饰,如果不存在则直接赋值,这个抽出来的对象其实可以算是另一种继承方式的实现 它的意义在于[图片] 帮助我们开辟了一块公有空间,帮助我们扩展Page对象,并且可以劫持任意方法,在不修改原业务函数代码的情况下增加新的逻辑 其次也是最重要的是,我们完全不需要处理历史包袱 一个应用场景[图片] 左边是微信原生的分享方法 遇到的一些问题: 不便于扩展,多个分享策略时,代码块过大,并且分享的通用数据需要每个页面单独处理,例如统计回流信息需要的分享页面信息,事件信息 右边是依赖于我们刚刚开辟的公有空间里填写的公共方法,函数很简单,获得参数,获得页面分享策略,执行,顺便还做了obj转url的处理,避免了纯url书写长路径时的尴尬 思考[图片] 既然能扩展Page对象,那么App,Component甚至wx全局对象下的方法呢? 有兴趣的朋友可以下来想一下 跨页面 / 多页面事件通知[图片] 我这边简单提一下跨页面通知的问题,这个应该算是很多小程序开发者遇到的通用问题,我问过的一些人,大部分是使用下面这两种方法,一种是getCurrentPages 页面栈队列,二种onShow upData global里的存储的数据,不管哪一种在业务复杂的情况下都会引起一定的问题,比如第一种的多级入口,第二种的话属于无效判断和及时性 小打卡这边用的简版的event Bus,一个只有80行代码的订阅方法, 左图是一个简单的示例,大致上就是分两种角色,订阅者和发布者,中间依靠任务队列联系,每次发布者推送消息,订阅者都会收到通知和相应的数据 [图片] 其实单纯的event bus也有很多的问题,比如:业务复杂情况下过于频繁,对业务代码侵入频繁,可以想像一下到处都是on,off, emit场景 解绑需要树立规范,但是人总是会犯错,容易绑定后忘记解绑或重复绑定的问题,比较浪费内存,对性能也有消耗 结合公共空间我们已知可以对生命周期进行扩展的时候怎么去解决这个问题 其实订阅必然是和页面结合,因为在页面不存在的情况下,发送通知也不会有反馈, 如何证明页面存在自然是onLoad 和 onUnload 按照这个逻辑我们只需要在onLoad和onShow做些处理即可 [图1] 先看右侧业务代码,按照策略注册了一个函数集合,在执行onLoad时,自动将业务内的onRss函数遍历,自定绑定订阅,并推到一个缓存数组内 onUnload时遍历缓存,自动解绑 这时订阅与页面的生命周期强绑定,我们不再需要处理解绑事件,也不需要在业务内插入订阅代码,只需要管理好当前页面的订阅策略即可 技术选型[图片] 没有哪一种一定正确的方案,在选型的时候可能需要考虑到上面大致6种元素, 总之选择最适合自己团队方案非常重要 一些小贴士:开发要尽量避免过度设计, 应根据实际需求, 比如之前在写现在这个简版的event bus库的时候设计了很多奇奇怪怪的 功能,现在2年过去还没用到 小程序和浏览器,node本质上是相通的,可以多借鉴和参考 The End今天我的分享就到此结束,算是这段时间对小程序开发上的一点心得体会,谢谢 [图片]
2019-04-22 - 小打卡 | 如何基于微信原生构建应用级小程序底层架构(上)
[图片] 大家好,我是小打卡的前端负责人金轩正,今天分享的主题是如何基于微信原生构建应用级小程序底层架构,这个命题看上去好像有些大,不过不要紧,这次分享我把它拆一下,大致从 小程序原生开发面临的问题 小打卡整体架构演进 开发中摸索与实践 这三个方面来看这个讲一下 [图片] 小程序原生开发面临的问题[图片] ok,首先第一个方面原生开发遇到的问题 小程序从17年诞生2年来一直处于互联网风口,不过对于开发者而言的整个开发体验不是特别友好,在17-18年之间我和很多开发小程序的小伙伴们聊过,大多数的反馈可能分为下面大致几类,当然还有更多: 没有父类,无法使用继承挂载全局方法,扩展生命周期没有父类,无法使用继承挂载全局方法,扩展生命周期 不支持跨页面/多页面通讯 setData的性能瓶颈 代码包大小限制 1/2/4/8 M,没有npm包 代码发布流程繁琐 其根本原因是将刚刚诞生的小程序与已经非常成熟的React,vue,angular作对比,而没有将小程序作为一个新的生态来看待,当然这个是一种看待事物的进步,并不是倒退,我在这里说这句话的意思是有更多的问题需要我们开发者主动去解决问题,推动整个生态的前进与发展 [图片] 其实这里可能有些朋友会问,已经有很多优秀的框架已经解决了这些问题,那么为什么还要使用原生开发? 确实在这段时间内出现了很多优秀的解决方案,我们不用并不是因为情怀哈(当然还是有那么一丢丢) 更多的是下面几点: 历史包袱,改造成本过高 小打卡在小程序刚出现的时候就进入开发了,当时框架还不成熟,而且对创业公司来说时间和迭代效率高于一切,在人手不足,业务模式尚未形成,还处于探索阶段的情况下花费大量时间去做对产品影响较小, 甚至delay迭代速度事情不是很赚 减少与第三方沟通成本 高速迭代的情况下,将时间尽可能的覆盖于业务上,避免在整个开发-上线闭环上增加节点 避免开发黑盒,控制风险 虽然整个社区是非常活跃的,fixed一个问题同样是需要花费一定时间,但是很多时候需求是不会等你bug fixed 如非必要,勿增实体 即“简单有效原理”,这句话还是我去年刚来公司的时候和阿赖聊他所说过的 放在项目开发上我的理解是在架构层面要做的尽可能的薄,避免过度设计 这样才有足够的扩展性,灵活性,容错性 这些框架虽好,但是对我们当前业务来说可能过于复杂,比如跨端在之前的阶段还没有这方面需求,而像组件化小程序已经支持,自动化构建我们自己也是可以搭建的并不复杂 相信微信小程序团队 是真正的想把这件事情做好,而且做的是一个生态,不论是小程序对于反馈响应速度,和迭代速度非常给力,还是对开发者社区运营,比如是社区活跃与审核速度挂钩,社区周刊,优质个人和优质企业 对齐web标准,并且更加开放 [图片] 小打卡整体架构演进其实小打卡整个架构并非一蹴而就的,就像前面所说的如非必要,勿增实体,而是大量的实际开发中遇到的共同问题解决方案的集合题 [图片] 常规架构这个是微信小程序给出的快速开发模版的一个开发模式: server模块提供数据,App作为全局对象直连所有的业务模块,工具函数提供api处理业务模块的需求 优点: 整个模型非常简单,上手快,学习成本 低结构清晰,在业务不复杂的情况下可以快速开发 不瞒大家其实小打卡在最初的半年内基本都是这套模式。 当然是在业务不复杂的情况下,复杂情况下会出现哪些问题呢? App作为全局对象在有大量业务模块连接的情况下,代码很容易膨胀,在多人开发的时候问题非常明显,无论是fixed bug还是正常的业务开发都会造成麻烦 页面之间独立,缺少公共模块,唯一的工具函数又要尽可能保持单一职责来提供服务(小打卡当时就是因为这个问题导致很多工具函数内部存储直接修改外部状态,导致大量强耦函数合无法拆分) 业务层直连server层,未拆分数据层的情况下,基本不存在复用性 上面所述的问题,从我接手这个项目到真正的调整持续了挺长一段时间,主要是缺乏一个契机来进行优化 优化的转折点 [图片] 然后突然有一天产品同学跑过来说: 我们要有自己的核心数据仓库,我们要看实时数据 ok,涉及到数据采集的问题了,我这边从浅到深大概列了几项: 最基础的多个页面pv,uv如何监控,不可能每个页面都要手动收集 为了统计页面和事件的分享和回流的数据,需要在分享事件携带大量的参数 微信的wx.previewImage, wx.chooseImage 等api对于用户session的收集造成很大麻烦 我们先解决第一个问题,如何收集页面pv,uv 容易陷入的误区 [图片] 在解决问题之前,我们先说一下开发小程序容易进入的误区 App 和 Page 等函数工厂是微信原生提供,不可修改 小程序项目结构是基于App, Page, 工具函数三个模块构建的 小程序的全局存储只有globalData和本地缓存 其实产生这些误区最根本的原因是小程序没有提供在复杂业务逻辑下的开发范式,比如vue,react有自己的通用开发模版 如果保持这些观念来进行开发的话,很容易将路子走窄,并且难以解决一些实际上的问题, 其实不论小程序和传统web有多少不同, 本质上还是在js环境下开发 小打卡架构图解 [图片] 为了更好的方便理解后面的具体实现,我提前放了一张目前小打卡的架构图 首先很熟悉的server这一边垫了一个数据层,主要将数据层和业务层解耦,提高复用性,并且提供一些通用功能,比如返回格式化数据问题,参数校验,日志监控... 在App对象和业务层同样增加了一个全局模块,提供独立于业务和工具类,只提供api之间双向通讯的渠道 工具模块的话其实就是对业务层的增强,比如常见的请求模块,上传模块,路由拦截等等 业务模块的话基本除了增加Component和中间层外没有太大变化 这个图上可能有两块可能大家觉得比较怪异,一个是global里面的函数重载,还有一个是业务模块的中间层是什么? 函数重载其实就是修改微信提供的App, Page, Component函数,使其更符合我们的业务场景, 业务模块的中间层就是依赖于函数重载的扩展 其实小打卡的整套架构都是基于这两个模块,这两个模块赋予了更多的可能性,然而实现却十分的简单 点击查看:小打卡 | 如何基于微信原生构建应用级小程序底层架构(下)
2019-04-22 - 小程序开发另类小技巧 --用户授权篇
小程序开发另类小技巧 --用户授权篇 getUserInfo较为特殊,不包含在本文范围内,主要针对需要授权的功能性api,例如:wx.startRecord,wx.saveImageToPhotosAlbum, wx.getLocation 原文地址:https://www.yuque.com/jinxuanzheng/gvhmm5/arexcn 仓库地址:https://github.com/jinxuanzheng01/weapp-auth-demo 背景 小程序内如果要调用部分接口需要用户进行授权,例如获取地理位置信息,收获地址,录音等等,但是小程序对于这些需要授权的接口并不是特别友好,最明显的有两点: 如果用户已拒绝授权,则不会出现弹窗,而是直接进入接口 fail 回调, 没有统一的错误信息提示,例如错误码 一般情况而言,每次授权时都应该激活弹窗进行提示,是否进行授权,例如: [图片] 而小程序内只有第一次进行授权时才会主动激活弹窗(微信提供的),其他情况下都会直接走fail回调,微信文档也在句末添加了一句请开发者兼容用户拒绝授权的场景, 这种未做兼容的情况下如果用户想要使用录音功能,第一次点击拒绝授权,那么之后无论如何也无法再次开启录音权限**,很明显不符合我们的预期。 所以我们需要一个可以进行二次授权的解决方案 常见处理方法 官方demo 下面这段代码是微信官方提供的授权代码, 可以看到也并没有兼容拒绝过授权的场景查询是否授权(即无法再次调起授权) [代码]// 可以通过 wx.getSetting 先查询一下用户是否授权了 "scope.record" 这个 scope wx.getSetting({ success(res) { if (!res.authSetting['scope.record']) { wx.authorize({ scope: 'scope.record', success () { // 用户已经同意小程序使用录音功能,后续调用 wx.startRecord 接口不会弹窗询问 wx.startRecord() } }) } } }) [代码] 一般处理方式 那么正常情况下我们该怎么做呢?以地理位置信息授权为例: [代码]wx.getLocation({ success(res) { console.log('success', res); }, fail(err) { // 检查是否是因为未授权引起的错误 wx.getSetting({ success (res) { // 当未授权时直接调用modal窗进行提示 !res.authSetting['scope.userLocation'] && wx.showModal({ content: '您暂未开启权限,是否开启', confirmColor: '#72bd4a', success: res => { // 用户确认授权后,进入设置列表 if (res.confirm) { wx.openSetting({ success(res){ // 查看设置结果 console.log(!!res.authSetting['scope.userLocation'] ? '设置成功' : '设置失败'); }, }); } } }); } }); } }); [代码] 上面代码,有些同学可能会对在fail回调里直接使用wx.getSetting有些疑问,这里主要是因为 微信返回的错误信息没有一个统一code errMsg又在不同平台有不同的表现 从埋点数据得出结论,调用这些api接口出错率基本集中在未授权的状态下 这里为了方便就直接调用权限检查了 ,也可以稍微封装一下,方便扩展和复用,变成: [代码] bindGetLocation(e) { let that = this; wx.getLocation({ success(res) { console.log('success', res); }, fail(err) { that.__authorization('scope.userLocation'); } }); }, bindGetAddress(e) { let that = this; wx.chooseAddress({ success(res) { console.log('success', res); }, fail(err) { that.__authorization('scope.address'); } }); }, __authorization(scope) { /** 为了节省行数,不细写了,可以参考上面的fail回调,大致替换了下变量res.authSetting[scope] **/ } [代码] 看上去好像没有什么问题,fail里只引入了一行代码, 这里如果只针对较少页面的话我认为已经够用了,毕竟**‘如非必要,勿增实体’,但是对于小打卡这个小程序来说可能涉及到的页面,需要调用的场景偏多**,我并不希望每次都人工去调用这些方法,毕竟人总会犯错 梳理目标 上文已经提到了背景和常见的处理方法,那么梳理一下我们的目标,我们到底是为了解决什么问题?列了下大致为下面三点: 兼容用户拒绝授权的场景,即提供二次授权 解决多场景,多页面调用没有统一规范的问题 在底层解决,业务层不需要关心二次授权的问题 扩展wx[funcName]方法 为了节省认知成本和减少出错概率,我希望他是这个api默认携带的功能,也就是说因未授权出现错误时自动调起是否开启授权的弹窗 为了实现这个功能,我们可能需要对wx的原生api进行一层包装了(关于页面的包装可以看:如何基于微信原生构建应用级小程序底层架构) 为wx.getLocation添加自己的方法 这里需要注意的一点是直接使用常见的装饰模式是会出现报错,因为wx这个对象在设置属性时没有设置set方法,这里需要单独处理一下 [代码]// 直接装饰,会报错 Cannot set property getLocation of #<Object> which has only a getter let $getLocation = wx.getLocation; wx.getLocation = function (obj) { $getLocation(obj); }; // 需要做一些小处理 wx = {...wx}; // 对wx对象重新赋值 let $getLocation = wx.getLocation; wx.getLocation = function (obj) { console.log('调用了wx.getLocation'); $getLocation(obj); }; // 再次调用时会在控制台打印出 '调用了wx.getLocation' 字样 wx.getLocation() [代码] 劫持fail方法 第一步我们已经控制了wx.getLocation这个api,接下来就是对于fail方法的劫持,因为我们需要在fail里加入我们自己的授权逻辑 [代码]// 方法劫持 wx.getLocation = function (obj) { let originFail = obj.fail; obj.fail = async function (errMsg) { // 0 => 已授权 1 => 拒绝授权 2 => 授权成功 let authState = await authorization('scope.userLocation'); // 已授权报错说明并不是权限问题引起,所以继续抛出错误 // 拒绝授权,走已有逻辑,继续排除错误 authState !== 2 && originFail(errMsg); }; $getLocation(obj); }; // 定义检查授权方法 function authorization(scope) { return new Promise((resolve, reject) => { wx.getSetting({ success (res) { !res.authSetting[scope] ? wx.showModal({ content: '您暂未开启权限,是否开启', confirmColor: '#72bd4a', success: res => { if (res.confirm) { wx.openSetting({ success(res){ !!res.authSetting[scope] ? resolve(2) : resolve(1) }, }); }else { resolve(1); } } }) : resolve(0); } }) }); } // 业务代码中的调用 bindGetLocation(e) { let that = this; wx.getLocation({ type: 'wgs84', success(res) { console.log('success', res); }, fail(err) { console.warn('fail', err); } }); } [代码] 可以看到现在已实现的功能已经达到了我们最开始的预期,即因授权报错作为了wx.getLocation默认携带的功能,我们在业务代码里再也不需要处理任何再次授权的逻辑 也意味着wx.getLocation这个api不论在任何页面,组件,出现频次如何,**我们都不需要关心它的授权逻辑(**效果本来想贴gif图的,后面发现有图点大,具体效果去git仓库跑一下demo吧) 让我们再优化一波 上面所述大致是整个原理的一个思路,但是应用到实际项目中还需要考虑到整体的扩展性和维护成本,那么就让我们再来优化一波 代码包结构: 本质上只要在app.js这个启动文件内,引用./x-wxx/index文件对原有的wx对象进行覆盖即可 [图片] **简单的代码逻辑: ** [代码]// 大致流程: //app.js wx = require('./x-wxx/index'); // 入口处引入文件 // x-wxx/index const apiExtend = require('./lib/api-extend'); module.exports = (function (wxx) { // 对原有方法进行扩展 wxx = {...wxx}; for (let key in wxx) { !!apiExtend[key] && (()=> { // 缓存原有函数 let originFunc = wxx[key]; // 装饰扩展的函数 wxx[key] = (...args) => apiExtend[key](...args, originFunc); })(); } return wxx; })(wx); // lib/api-extend const Func = require('./Func'); (function (exports) { // 需要扩展的api(类似于config) // 获取权限 exports.authorize = function (opts, done) { // 当调用为"确认授权方法时"直接执行,避免死循环 if (opts.$callee === 'isCheckAuthApiSetting') { console.log('optsopts', opts); done(opts); return; } Func.isCheckAuthApiSetting(opts.scope, () => done(opts)); }; // 选择地址 exports.chooseAddress = function (opts, done) { Func.isCheckAuthApiSetting('scope.address', () => done(opts)); }; // 获取位置信息 exports.getLocation = function (opts, done) { Func.isCheckAuthApiSetting('scope.userLocation', () => done(opts)); }; // 保存到相册 exports.saveImageToPhotosAlbum = function (opts, done) { Func.isCheckAuthApiSetting('scope.writePhotosAlbum', () => done(opts)); } // ...more })(module.exports); [代码] 更多的玩法 可以看到我们无论后续扩展任何的微信api,都只需要在lib/api-extend.js 配置即可,这里不仅仅局限于授权,也可以做一些日志,传参的调整,例如: [代码] // 读取本地缓存(同步) exports.getStorageSync = (key, done) => { let storage = null; try { storage = done(key); } catch (e) { wx.$logger.error('getStorageSync', {msg: e.type}); } return storage; }; [代码] 这样是不是很方便呢,至于Func.isCheckAuthApiSetting这个方法具体实现,为了节省文章行数请自行去git仓库里查看吧 关于音频授权 录音授权略为特殊,以wx.getRecorderManager为例,它并不能直接调起录音授权,所以并不能直接用上述的这种方法,不过我们可以曲线救国,达到类似的效果,还记得我们对于wx.authorize的包装么,本质上我们是可以直接使用它来进行授权的,比如将它用在我们已经封装好的录音管理器的start方法进行校验 [代码]wx.authorize({ scope: 'scope.record' }); [代码] 实际上,为方便统一管理,Func.isCheckAuthApiSetting方法其实都是使用wx.authorize来实现授权的 [代码]exports.isCheckAuthApiSetting = async function(type, cb) { // 简单的类型校验 if(!type && typeof type !== 'string') return; // 声明 let err, result; // 获取本地配置项 [err, result] = await to(getSetting()); // 这里可以做一层缓存,检查缓存的状态,如果已授权可以不必再次走下面的流程,直接return出去即可 if (err) { return cb('fail'); } // 当授权成功时,直接执行 if (result.authSetting[type]) { return cb('success'); } // 调用获取权限 [err, result] = await to(authorize({scope: type, $callee: 'isCheckAuthApiSetting'})); if (!err) { return cb('success'); } } [代码] 关于用户授权 用户授权极为特殊,因为微信将wx.getUserInfo升级了一版,没有办法直接唤起了,详见《公告》,所以需要单独处理,关于这里会拆出单独的一篇文章来写一些有趣的玩法 总结 最后稍微总结下,通过上述的方案,我们解决了最开始目标的同时,也为wx这个对象上的方法提供了统一的装饰接口(lib/api-extend文件),便于后续其他行为的操作比如埋点,日志,参数校验 还是那么一句话吧,小程序不管和web开发有多少不同,本质上都是在js环境上进行开发的,希望小程序的社区环境更加活跃,带来更多有趣的东西
2019-06-14 - 如何打造一份0 bug 的js代码!
说到程序开发,bug总是如影随形,开发过程中50%的时间在debug,30%是在修之前发布了的bug,毫不奇怪。 如何把bug见到最少,甚至是0 bug呢?看似遥不可及,但实际是可以追求的,方法就是完整科学的测试,事实上,测试和使用是证明代码没bug的唯一方式。 测试又分为白盒测试跟黑盒测试,一般来说,产品上线前经过测试同学测试做的功能测试都是黑盒测试,毕竟测试同学不可能了解所有的代码,而能做白盒测试的,都是最了解代码的人,也就是写代码的程序员。而只有最了解代码的人也才能写出最完善的测试用例。而单元测试就是白盒测试里面最重要的方法之一。 很多程序员,特别是前端程序员是没有写单元测试的习惯。诚然,写单元测试其实是比较费劲的事情,特别在前端领域,涉及大量ui及交互操作,写单元测试尤为困难。但是近年来js模块化越演越烈,模块化的同时也使得写单元测试变得更容易了。 本文就致力于探讨在当前的开发环境下做单元测试的一些实践方法。 先介绍下被测试的对象 —— 一个js工具库,这个公共库有几个比较重要的标签:ES6,移动端,浏览器端。 从最简单的讲起吧,js的测试框架其实不算少,比较有名的有mocha,Jasmine,Jest等,基本用法都比较简单明了,看看官方文档就大概能写出来一些测试用例了。下面我主要使用比较强大mocha来作为主要的测试框架。 下面是我项目中的一份test.js: [代码]import * as lib from './index'; import chai from 'chai'; let expect = chai.expect; describe('testcase',function(){ it('single one',function(){ let a = 'aer'; expect(a).to.be.a('string'); }); it('test /common/getUrlParam',function(){ let ret = lib.common.utils.getUrlParam('a','http://vip.qq.com?a=1'); expect(ret).to.equal('1'); ret = lib.common.utils.getUrlParam('a','http://vip.qq.com#a=1'); expect(ret).to.equal('1'); ret = lib.common.utils.getUrlParam('a','https://vip.qq.com?a=1'); expect(ret).to.equal('1'); ret = lib.common.utils.getUrlParam('b','http://vip.qq.com?a=1&b=2'); expect(ret).to.equal('2'); ret = lib.common.utils.getUrlParam('a','http://vip.qq.com?a=1#a=2'); expect(ret).to.equal('2'); }); }); [代码] ES6 这份test.js包含两个测试用例,第一用例是用于测试的,单纯试用下测试框架跟断言库的功能,第二个用例是对库文件中一个模块的一个方法的测试用例,估计前端大佬们看看方法名大概都能看懂是什么东西,我就不多说了。写好test.js后就跑了试试看,运行mocha,然后马上就报错了: [代码]JUSTYNCHEN-MC0:gxh-lib-es6 justynchen$ ./node_modules/.bin/mocha /path/to/your/project/somelib/test.js:1 (function (exports, require, module, __filename, __dirname) { import * as lib from './index'; ^ SyntaxError: Unexpected token * at new Script (vm.js:79:7) at createScript (vm.js:251:10) at Object.runInThisContext (vm.js:303:10) at Module._compile (internal/modules/cjs/loader.js:656:28) ... [代码] 我当前的node版本是v10.13.0(对,近日node的LTS版本已经升级到10.x了,没升的同学赶紧玩玩吧),按理说是默认支持import的。仔细一看报错,原来mocha不是直接执行test.js,而是把test.js的内容放到了一个沙箱里面执行的。那就有点蛋疼了,就算node新版本已经支持了也没法直接使用。第一想法就是先把代码编译了,然后再做测试,可是测试的代码都是编译后的代码,就算测试出什么问题,还要经过sourcemap才能找到源码中出错的位置,想想都蛋疼。 官方当然是不会这么蠢的,稍微找了下官方的方案,不难找到对es6的支持。babel提供了一个register,给到不同的应用去做转换,mocha同样也可以使用这个register先转换然后再跑。命令就变成了这个: [代码]mocha --require babel-core/register [代码] 同时,package.json里面的选项也要加上babel的选项: [代码]"babel": { "presets": [ "stage-3", "latest" ], }, [代码] 当然,相关的包(babel,babel-core)也要同时装上,大家都懂的后面我就不提了,缺啥装啥就对了,后面提到的工具如没特别说明都是指npm包。 到这里相关的资料还比较好找,接下来就是干货了。 browser 运行上面改造过的mocha命令,是不是就ok了呢?当然没那么顺利,这里遇到了第二个坑: [代码]./node_modules/.bin/mocha --require babel-core/register /path/to/your/project/somelib/common/cache.js:15 var storage = window[storageType]; ^ ReferenceError: window is not defined at initStorage (/Users/justynchen/....../cache.js:10:16) at Object.<anonymous> (/Users/justynchen/....../cache.js:41:16) at Module._compile (internal/modules/cjs/loader.js:688:30) [代码] 前面也说了这份库是给移动端浏览器用的,其中就免不了使用一些浏览器的API,这些API在node里面都是不存在的。解决方案有两个: 直接不测试使用了浏览器API的代码,使用前先做检测并return掉。 找一个模拟浏览器的环境,让浏览器的API也能正常执行。 方案一是我们不愿意看到的,特别是一份浏览器用的库,不测试浏览器相关的特性那跟咸鱼有什么区别【手动狗头】。那就按方案二的思路想走吧,想到node模拟浏览器的环境,脑中浮现的第一个名词估计大部分人跟我都一样 —— electron。作为业界最著名的“没有界面的浏览器”,用在这里再合适不过了。但是该怎么用呢,稍作搜索,果然已经有前人做了相应的工作,有一个electron-mocha的工具刚好就是把这两个东西合了起来。 然后命令就变成了这样: [代码]electron-mocha --renderer --require babel-core/register [代码] 然后终于得到了我们想要的结果: [代码]JUSTYNCHEN-MC0:somelib justynchen$ ./node_modules/.bin/electron-mocha --renderer --require babel-core/register testcase ✓ single one ✓ test /common/getUrlParam 2 passing (37ms) [代码] 完美~(请自动脑补金星脸) 可是,就这么完了是否有点意犹未尽? 是的,就是缺了点什么东西,说好的0 bug呢,写了测试用例就能保证0 bug了么?肯定不是的,如果有的地方就是有bug,只是用例没写好,并没有覆盖到有问题的地方怎么办?只有写“全”了的测试用例才能保证0 bug。如何确保用例写全了呢?请往下看。 代码测试覆盖率 这里引入一个概念,叫代码测试覆盖率,大概意思就是说,你的测试用例到底覆盖了多少的代码。理想情况下,肯定只有100%覆盖所有代码的用例,才能说自己经过测试的代码是0 bug的,当然现实中100%总是很难的,一般覆盖到90%以上已经是比较理想的情况了。 JS也有统计代码测试覆盖率的库 —— Istanbul。这库名也很有意思,库名直译是伊斯坦布尔,没错,就是那个正常中国人可能名字都没听说过的中东城市。这个地方有个特产是毯子,然后这个库的作者就想,覆盖就是毯子该做的事情嘛,脑洞一开就把库名起作Istanbul了。 继续“稍微读下文档”,哦,原来这个库有一个命令行工具,nyc,装上然后放到执行命令的前面就能做覆盖率统计了。然后就有了下面的命令 [代码]nyc --reporter=lcov --reporter=text electron-mocha --renderer --require babel-core/register [代码] 其中reporter是定制化报告的内容,默认是text,lcov就是生成一个网页版的覆盖率报告。 然而,跑完之后是酱婶儿的 [代码]JUSTYNCHEN-MC0:somelib justynchen$ ./node_modules/.bin/nyc ./node_modules/.bin/electron-mocha --renderer --require babel-core/register testcase ✓ single one ✓ test /common/getUrlParam ✓ aidMaker test 3 passing (18ms) ----------|----------|----------|----------|----------|-------------------| File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | ----------|----------|----------|----------|----------|-------------------| All files | 0 | 0 | 0 | 0 | | ----------|----------|----------|----------|----------|-------------------| [代码] 苍天大地啊,为啥啥都没有。。。。 这里就又开始苦逼的查资料环节,不得不吐槽一下,这个资料是真不好查,中文资料没有就算了,反正早习惯了,文档翻遍了还是没有。。。那就过分了。然后查了下别的资料,基本都是mocha跟Istanbul一起用的,也没有electron-mocha相关的。 最后还是找到了github的issue里面,果然有人是跟我有类似问题,找了几个提了没啥回音的,终于找到一个maintainer的回复。里面指向了一个插件 —— babel-plugin-istanbul。 皇天不负有心人,得益于之前已经引入了babel,这里只要加上这个插件就ok了,照例先装包,package.json里面的babel配置加上这个参数 [代码]"babel": { "presets": [ "stage-3", "latest" ], "env": { "test": { "plugins": [ "istanbul" ] } } }, [代码] 然后按照插件的README改一下命令,最终得到了这个 [代码]cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text electron-mocha --renderer --require babel-core/register [代码] 然后一跑。。。发现,x你x的。。。跟前面的输出一毛一样,啥的没有!!! 冷静一下,再回头看看maintainer回复的原话 Basically, yes. Note that if you’re using babel already you can now just use the excellent istanbul-plugin to instrument your code. If you do this, you really only need to write out the __coverage__ object after the tests have run. (I write them out for both renderer and main thread tests, then combine them using nyc report from the command line). 里面提到了一个奇怪的参数__coverage__,又查一波资料,在这个项目的某些commit comment里面看到这个__coverage__的蛛丝马迹,这东西好像是一个全局参数,那这个参数有啥用咧?管他有用没用,打出来看看再说,然后就在test.js里面吧这个参数打了下,发现,卧槽,还真有,而且里面不就是覆盖率的数据么???? 嗯,有数据。。。怎么生成报告呢?作者写的语焉不详。。。啥叫write out这个参数然后配合nyc report命令就能用了,write到啥地方啊,咋配合啊!!! 这个时候就想到了Istanbul的一些特性,其实它是会在测试后生成一个.nyc_output的文件夹的,打开一看,里面不就是一些json么!那是不是直接write进去就好了呢?文件该叫啥名字咧,原来的文件都是hash命名的,这hash哪来的呀。不管了写了再说,然后得到如下test.js [代码]import * as lib from './index'; import chai from 'chai'; import fs from 'fs'; let expect = chai.expect; describe('testcase',function(){ it('single one',function(){ let a = 'aer'; expect(a).to.be.a('string'); }); it('test /common/getUrlParam',function(){ let ret = lib.common.utils.getUrlParam('a','http://vip.qq.com?a=1'); expect(ret).to.equal('1'); ret = lib.common.utils.getUrlParam('a','http://vip.qq.com#a=1'); expect(ret).to.equal('1'); ret = lib.common.utils.getUrlParam('a','https://vip.qq.com?a=1'); expect(ret).to.equal('1'); ret = lib.common.utils.getUrlParam('b','http://vip.qq.com?a=1&b=2'); expect(ret).to.equal('2'); ret = lib.common.utils.getUrlParam('a','http://vip.qq.com?a=1#a=2'); expect(ret).to.equal('2'); }); after(function() { fs.writeFileSync('./.nyc_output/coverage.json',JSON.stringify(__coverage__)); }); }); [代码] 终于生效了 [代码]JUSTYNCHEN-MC0:somelib justynchen$ tnpm run test > @tencent/somelib@1.0.1 test /path/to/your/project/..... > cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text electron-mocha --renderer --require babel-core/register testcase ✓ single one ✓ test /common/getUrlParam 3 passing (26ms) ----------------------|----------|----------|----------|----------|-------------------| File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | ----------------------|----------|----------|----------|----------|-------------------| All files | 5.96 | 3.3 | 3.6 | 6.02 | | gxh-lib-es6 | 0 | 0 | 0 | 0 | | index.js | 0 | 0 | 0 | 0 | | gxh-lib-es6/business | 16.89 | 8.84 | 3.45 | 16.89 | | aid-maker.js | 80 | 66.67 | 100 | 80 | 325,340,341,344 | cgi-handler.js | 0 | 0 | 0 | 0 |... 57,159,160,161 | index.js | 0 | 0 | 0 | 0 | | pay.js | 0 | 0 | 0 | 0 |... 86,88,89,91,97 | .... [代码] 至此,终于可以说出那句 完美~ 附录 整体架构图 [图片] 后记:整个单元测试的技术其实都没什么困难的,基本上都有库可以用,主要把时间都花在了查询资料上面。写本文的时候好像是遇到问题马上就找到了解决方案,其实真实情况是,几乎遇到每个坑都会试了至少一两个走不通的方案,最后才找到正确的方案的,所以对于不经常关注社区的人来说,单靠文档是很难解决所有的问题的。单从前端领域来看,前端的技术日新月异,再完善的文档都很快会跟不上发展的步伐,还是要靠多关注社区的动向,甚至多参与社区的讨论和建设才不至于在需要用某些技术的时候无从下手。
2019-03-29 - 小程序开发之 web-view 的进阶玩法
背景 半年前写过一个项目,在京东手机的小程序里内嵌老罗的锤子发布会的活动页。前几天老罗又发布了他的加湿器,而这份关于锤子项目的迟到的总结,经过这几天在全网搜索的相关问题来看,依然有必要写一下。 本文主要从 web-view 与 JSSDK 的实现来展开,顺带过一下 web-view 的基础,最后在文末发放一些实用的小糖豆。 ok,废话不多说,开始吧。 web-view 中可用的 JSSDK 接口 1. web-view 的本质 与 JSSDK 我们已知,web-view 是闭环的小程序对外开启的一扇窗户,是小程序承载网页链接的容器。web-view 就像小程序里内置的浏览器内核,可以运行网页,其实 web-view 本质即是微信的 WKWebview 的实现。 区别:小程序框架系统包括两部分,视图层和逻辑层,两者对应的技术实现分别是 webview 和 JSCore;web-view本质上就是一个浏览器,承载网页,包括视图和逻辑实现。 [图片] 微信Webview 不仅应用于小程序的 web-view,也应用于公众号等所有微信里可以打开网页的位置。 微信Webview 不仅集成了普通 webkit 引擎的基础功能,还注入了微信JSBridge(JS-SDK)相关的脚本,提供给开发者更多高效的能力,如:拍照、语音、位置基于手机系统的能力;扫一扫、微信分享、卡券、支付等微信个性化能力。 而基于微信Webview 的 web-view 组件,除开放承载页面的功能外,也被赋予了一些 JS-SDK 的使用能力,尽管有一定的限制,但整理来看也使 web-view 的能力变得强大了。 2. web-view 中可用的 JSSDK 接口 本文从两个维度介绍 JSSDK 的接口。只简单列举几个,更多可支持的接口还请查看web-view的文档。 [图片] 1) 可用的通用JSSDK接口 接口模块 接口说明 接口名称 判断客户端是否支持js checkJSApi 图像接口 拍照或上传 chooseImage 图像接口 预览图片 previewImage 图像接口 上传/下载图片 uploadImage/downloadImage 图像接口 获取本地图片 getLocalImgData 音频接口 开始/停止录音 startRecord/stopRecord 音频接口 播放/暂停/停止语音 playVoice/pauseVoice/stopVoice 地理位置 使用内置地图/获取地理位置 getLocation/openLocation 蓝牙接口 开启/关闭/监听 start/stopSearchBeacons/onSearchBeacons 扫码 微信扫一扫 scanQRCode 卡券 列表/添加/打开 chooseCard/addCard/openCard 长按 小程序圆形码 智能接口 识别音频 translateVoice 2)与小程序相关的接口 除通用的JSSDK接口外,web-view 还支持和小程序跳转相关的接口,比如:navigateTo、redirectTo、switchTab、navigateBack,类似这种。具体写法都是加上 wx.miniProgram. 这样。 此外,关于小程序和web-view 的通信需要请求:wx.miniProgram.postMessage 方法,小程序侧进行侦听即可,具体方法可参看公众号的另一篇文章。 3)这些接口的可用性? 能够运行这些接口的最前提的条件就是,需要在小程序的环境里进行。那如何判断是否在小程序的环境里? 有两种方法: wx.miniProgram.getEnv [代码]wx.miniProgram.getEnv(function(res) { console.log(res.miniprogram) // true }) [代码] window.__wxjs_environment [代码]console.log(window.__wxjs_environment === 'miniprogram') [代码] web-view 如何使用 JSSDK 接口? 了解 web-view 可用的API 后,我们知道,在嵌入的H5 页面里,可以从相机里选择图片,或使用扫一扫的功能,那具体我们该如何实现呢? [图片] 微信公众平台给出了详尽的 JS-SDK 的实现方法,我们这里将几个当年踩过的要点给出。 1)绑定域名。 首先需要登录相关联的公众号,嗯!你没有看错,确实是公众号,(有很多人吐槽此事,但这确实是目前的事实。)登录后进入“公众号设置”-“功能设置”,填写“JS接口安全域名”。 [图片] 这一行为,建立了网页域名和 appId 之间的绑定关系,即,该appId 下可以打开这几个域名白名单里的网页。 同web-view 的业务域名配置一样,也需要将生成的校验文件拷贝到域名指向的 web 服务器的目录下。 2)签名的实现。 [图片] 签名是进入下一步的必要条件,这部分交由后端实现,了解它会提升你们的联调能力。 第一步,获取微信网页授权,拿到access_token值。 接口: https请求方式: [代码]GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET [代码] 参数 是否必须 说明 grant_type 是 获取access_token填写client_credential appid 是 第三方用户唯一凭证 secret 是 第三方用户唯一凭证密钥,即appsecret 公众号和小程序均可以使用 AppID 和 AppSecret 调用本接口来获取access_token。 AppID和AppSecret可在“微信公众平台-开发-基本配置”页中获得(需要已经成为开发者,且帐号没有异常状态)。 需要设置IP白名单:登录“微信公众平台-开发-基本配置”提前将服务器IP地址添加到IP白名单中,点击查看设置方法,否则将无法调用成功。小程序无需配置IP白名单。 入口: [图片] 具体设置: [图片] 第二步,获得jsapi_ticket。 用第一步拿到的access_token 采用http GET方式请求获得jsapi_ticket(有效期7200秒,开发者必须在自己的服务全局缓存jsapi_ticket): [代码]https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=ACCESS_TOKEN&type=jsapi [代码] [代码]{ "errcode":0, "errmsg":"ok", "ticket":"bxLdikRXVbTPdHSM05e5u5sUoXNKd8-41ZO3MhKoyN5OfkWITDGgnr2fwJ0m9E8NYzWKVZvdVtaUgWvsdshFKA", "expires_in":7200 } [代码] 第三步,生成签名。 初始字段:noncestr(随机字符串), 有效的jsapi_ticket, timestamp(时间戳), url(当前网页的URL,不包含#及其后面部分)。 [代码]noncestr=Wm3WZYTPz0wzccnW jsapi_ticket=sM4AOVdWfPE4DxkXGEs8VMCPGGVi4C3VM0P37wVUCFvkVAy_90u5h9nbSlYy3-Sl-HhTdfl2fzFy1AOcHKP7qg timestamp=1414587457 url=http://mp.weixin.qq.com?params=value [代码] 中间过程:对所有待签名参数按照字段名的ASCII 码从小到大排序(字典序)后,使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串。 [代码]jsapi_ticket=sM4AOVdWfPE4DxkXGEs8VMCPGGVi4C3VM0P37wVUCFvkVAy_90u5h9nbSlYy3-Sl-HhTdfl2fzFy1AOcHKP7qg&noncestr=Wm3WZYTPz0wzccnW×tamp=1414587457&url=http://mp.weixin.qq.com?params=value [代码] 生成签名:对生成的字符串作sha1加密,字段名和字段值都采用原始值,不进行URL 转义。 [代码]0f9de62fce790f9a083d5c99e95740ceb90c27ed [代码] 3)当前页面注入权限验证配置。 所有需要使用 JSSDK 的页面必须首先注入配置信息,否则无法调用 JSSDK 的 API。在2)中我们有生成的签名和生成签名依赖的时间戳和随机串,这些都是我们进行配置的必要入参。具体如下: [代码]wx.config({ debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。 appId: '', // 必填,公众号的唯一标识 timestamp: , // 必填,生成签名的时间戳 nonceStr: '', // 必填,生成签名的随机串 signature: '',// 必填,签名 jsApiList: [] // 必填,需要使用的JS接口列表 }); [代码] 其中 apilist 是本页面里支持使用的 JSSDK 的 API,如果不声明,是不可以被调用使用的。 在下一步4)中会有一个实例。 4)万事俱备之后 config 接口是异步请求的,所以增加 ready 的接口来判定config 是否已经具备;同时,也提供了error 接口来实现 config 失败后的处理。 哈哈,以上基本就是 JSSDK 在小程序 web-view 里的打通实现,因为中间牵扯前后端的实现,看起来篇幅长且凌乱,不知道你还能不能看到这里呢? 如果你是前端童鞋,先跟你报个喜,绑定域名和实现签名这些都不是你需要做的事情,把这些丢给你的后端童鞋,让他们来实现吧。但我还是提个醒,这些我们有必要知道,因为你在打通 web-view 和 JSSDK 时,可能无从下手。 5)一个例子帮你搞定 以下我们用一个例子,把前端方案的实现串起来,给大家一个直观的展示。 引入微信 JSSDK 库: [图片] 请求后端生成签名的接口,获取相关config依赖的字段: [图片] 在ready后,使用 wx.chooseImage() 选择本地相册的图片。 [图片] ok,到此为止,我们基本把 JSSDK 在 web-view 里的应用实现介绍完了。 总的来说,这是一个看起来比较简单,但实现起来可能有很多不可知问题的事情,我们当年苦于不了解需要在公众号下申请各种资源,不知道使用哪个 JSSDK 走了许多弯路,后来还踩过设置IP白名单的问题。这些都还是比较好解决的,本文也基本把这些很详尽的做了描述。在签名的实现中,还会有很多问题,还好我们当时也根据文档附录中的指点一一解除。 长征路漫漫,还好有分享~ 糖豆:web-view 在实际场景中的应用问题 [图片] 在这些和 web-view 相伴相生的日子里,也逐渐总结了一些问题,大致分为了几类,列举如下: 1. 开放能力的问题: 是否开放支付,是否支持直播,是否支持h5里添加分享按钮? 答案否! 插件禁用 webview 组件 个人账号可以测试,无法上线 小程序无法读取 web-view 的 cookie。 小程序不可以触发模板消息 类似以上问题,都可以归结为小程序的 web-view 对 JSSDK 的开放能力,须严格按微信的JSSDK提供的能力检查是否可行。 能否嵌套了其他的第三方网站的页面?答案:一切h5里引入的链接都需要加业务域名。 喜报:支持打开相关联公众号的文章!! web-view 对外部网页的开放能力都是基于业务域名的白名单设置! 感谢开放关联公众号的打开权限,运营小伙伴可以玩起来。 2. 白屏问题? 紧接上文,强化一下业务域名配置问题。很多业务域名配置失败的直接症状就是白屏,然后给一句话,引用非业务域名。问题直接明了!那如何解决? 1)优先查看业务配置是否ok,是否是https等。2)不能解决问题继续查看其他证书,如TSL版本。具体可查阅小程序HTTPS证书的约定。 3. 跳转问题。 首页是web-view 时没有回退按钮?层级过深时,回退过多? 这个是经常被问到的问题,首页web-view 没有回退,官方没有给出很明确的说法;而非首页的 web-view 一旦路由跳转建立起来,web-view 里的路由就默认加入了路由栈中。有什么好办法解决? 这个已跟小程序没关系了,唤醒你在浏览器里的处理吧。 4. 缓存、网页不刷新问题? 这个也可以归结为浏览器里如何处理缓存的问题,类似浏览器网页中的缓存,可以通过时间戳。另外看看是不是你的CDN 缓存了内容? 5. 分享问题? 分享接口拿到的 webViewUrl 只是第一个 url,如果你的页面已经进入到它的子页面中,再次分享时,这时候你分享得到的 url 没有变化。这时候 你需要在子页面中使用 postMessage,捕获到当前页面的路径,传递给小程序。当用户触发分享时,只要读取消息队列最近的一条数据即可。 记得拼接参数时需要encodeURIComponent。 6 web-view 改宽高? 不可以。默认铺满整屏。web-view 和 其他UI组件的关系是互斥的,并且小程序会优先选择展示 web-view。如果你想放多个web-view,很抱歉,它默认只展示第一个web-view 的内容,还会在控制台给个大大的报错。 7 web-view 提示“未绑定网页开发者”? 没有开发该网页的权限!需要在公众号的开发者工具里绑定开发者微信号。 8 web-view 的校验文件是什么格式? 官方回答:是一个含有普通字符串的txt文件,只是一个随机的字符串,与appid无关。放心使用~ 好啦~以上是对 web-view 常见问题的总结,总结不敢保证全对,另外一定是会过时的,所以,这只是你遇到问题的一个思考方向,具体方案还要请参看小程序官方文档。 如果你有更好的方案,欢迎回复我们,超级无敌感谢! 最后,感谢你能够读到这里,也愿你不枉此行。 扩展阅读 [1] 微信JS-SDK说明文档 https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421141115 [2] 微信小程序开放能力-web-view https://developers.weixin.qq.com/miniprogram/dev/component/web-view.html 文章原载于公众号:全栈探索。
2019-02-21 - 自定义导航栏所有机型的适配方案
写在前面的话 大家看到这个文章时一定会感觉这是在炒剩饭,社区中已经有那么多分享自定义导航适配的文章了,为什么我还要再写一个呢? 主要原因就是,社区中大部分的适配方案中给出的大小是不精确的,并不能完美适配各种场景。 社区中大部分文章给到的值是 iOS -> 44px , Android -> 48px 思路 正常来讲,iOS和Android下的胶囊按钮的位置以及大小都是相同且不变的,我们可以通过胶囊按钮的位置和大小再配合 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]statusBarHeight[代码] 来计算出导航栏的位置和大小。 小程序提供了一个获取菜单按钮(右上角胶囊按钮)的布局位置信息的API,可以通过这个API获取到胶囊按钮的位置信息,但是经过实际测试,这个接口目前存在BUG,得到的值经常是错误的(通过特殊手段可以偶尔拿到正确的值),这个接口目前是无法使用的,等待官方修复吧。 下面是我经过实际测试得到的准确数据: 真机和开发者工具模拟器上的胶囊按钮不一样 [代码]# iOS top 4px right 7px width 87px height 32px # Android top 8px right 10px width 95px height 32px # 开发者工具模拟器(iOS) top 6px right 10px width 87px height 32px # 开发者工具模拟器(Android) top 8px right 10px width 87px height 32px [代码] [代码]top[代码] 的值是从 [代码]statusBarHeight[代码] 作为原点开始计算的。 使用上面数据中胶囊按钮的高度加 [代码]top[代码] * 2 上再加上 [代码]statusBarHeight[代码] 的高度就可以得到整个导航栏的高度了。 为什么 [代码]top[代码] * 2 ?因为胶囊按钮是垂直居中在 title 那一栏中的,上下都要有边距。 扩展 通过胶囊按钮的 [代码]right[代码] 可以准确的算出自定义导航的 [代码]左边距[代码]。 通过胶囊按钮的 [代码]right[代码] + [代码]width[代码] 可以准确的算出自定义导航的 [代码]右边距[代码] 。 通过 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]windowWidth[代码] - 胶囊按钮的 [代码]right[代码] + [代码]width[代码] 可以准确的算出自定义导航的 [代码]width[代码] 。 再扩展 wx.getSystemInfo 或者 wx.getSystemInfoSync 中得到的 [代码]statusBarHeight[代码] 每个机型都不一样,刘海屏得到的数据也是准确的。 如果是自定义整个页面,iPhone X系列的刘海屏,底部要留 [代码]68px[代码] ,不要问我为什么! 代码片段 https://developers.weixin.qq.com/s/Q79g6kmo7w5J
2019-02-25 - Wxml2Canvas -- 快速生成小程序分享图通用方案
Wxml2Canvas库,可以将指定的wxml节点直接转换成canvas元素,并且保存成分享图,极大地提升了绘制分享图的效率。目前被应用于微信游戏圈、王者荣耀、刺激战场助手等小程序中。 github地址:https://github.com/wg-front/wxml2canvas 一、背景 随着小程序应用的日渐成熟,多处场景需要能够生成分享图便于用户进行二次传播,从而提升小程序的传播率以及加强品牌效应。 对于简单的分享图,比如固定大小的背景图加几行简短文字构成的分享小图,我们可以利用官方提供的canvas接口将元素直接绘制, 虽然繁琐了些,但能满足基本要求。 对于复杂的分享图,比如用户在微信游戏圈发表完话题后,需要将图文混排的富文本内容生成分享图,对于这种长度不定,内容动态变化的图片生成需求,直接利用官方的canvas接口绘制是十分困难的,包括但不限于文字换行、表情文字图片混排、文字加粗、子标题等元素都需要一一绘制。又如王者荣耀助手小程序,需要将十人对局的详细战绩绘制成分享图,包含英雄数据、装备、技能、对局结果等信息,要绘制100多张图片和大量的文字信息,如果依旧使用官方的接口一步一步绘制,对开发者来说简直就是一场噩梦。我们急需一种通用、高效的方式完成上述的工作。 在这样的背景下,wxml2cavnas诞生了,作为一种分享图绘制的通用方案,它不仅能快速的绘制简单的固定小图,还能直接将wxml元素真实地转换成canvas元素,并且适配各种机型。无论是复杂的图文混排的富文本内容,还是展现形式多样的战绩结果页,都可以利用wxml2cavnas完美地快速绘制并生成所期望的分享图片。 二、Wxml2Canvas介绍及示例 1. 介绍 Wxml2Cavnas库,是一个生成小程序分享图的通用方案,提供了两种绘制方式: 封装基础图形的绘制接口,包括矩形、圆形、线条、图片、圆角图片、纯文本等,使用时只需要声明元素类型并提供关键数据即可,不需要再关注canvas的具体绘制过程; wxml直接转换成canvas元素,使用时传入待绘制的wxml节点的class类名,并且声明绘制此节点的类型(图片、文字等),会自动读取此节点的computedStyle,利用这些数据完成元素的绘制。 2. 生成图示例 下面是两张极端复杂的分享图。 2.1 游戏圈话题 [图片] 点击查看完整长图 2.2.2 王者荣耀战绩 [图片] 点击查看完整大图 三、小程序的特性及局限 小程序提供了如下特性,可供我们便捷使用: measureText接口能直接测量出文本的宽度; SelectorQuery可以查询到节点对应的computedStyle。 利用第一条,我们在绘制超长文本时便于文本的省略或者换行,从而避免文字溢出。 利用第二条,我们可以根据class类名,直接拿到节点的样式,然后将style转换成canvas可识别的内容。 但是和html的canvas相比,小程序的canvas局限性很多。主要体现在如下几点: 不支持base64图片; 图片必须下载到本地后才能绘制到画布上; 图片域名需要在管理平台加入downFile安全域名; canvas属于原生组件,在移动端会置于最顶层; 通过SelectorQuery只能拿到节点的style,而无法获取文本节点的内容以及图片节点的链接。 针对以上问题,我们需要将base64图片转换jpg或png格式的图片,实现图片的统一下载逻辑,并且离屏绘制内容。针对第五条,好在SelectorQuery可以获取到节点的dataset属性,所以我们需要在待绘制的节点上显示地声明其类型(imgae、text等),并且显示地传入文本内容或图片链接,后文会有示例。 四、Wxml2Canvas使用方式 1. 初始化 首先在wxml中创建canvas节点,指定宽高: [代码] <canvas canvas-id="share" style="height: {{ height * zoom }}px; width: {{ width * zoom }}px;"> </canvas> [代码] 引入代码库,创建DrawImage实例,并传入如下参数: [代码] let DrawImage = require('./wxml2canvas/index.js'); let zoom = this.device.windowWidth / 375; let width = 375; let height = width * 3; let drawImage = new DrawImage({ element: 'share', // canvas节点的id, obj: this, // 在组件中使用时,需要传入当前组件的this width: width, // 宽高 height: height, background: '#161C3A', // 默认背景色 gradientBackground: { // 默认的渐变背景色,与background互斥 color: ['#17326b', '#340821'], line: [0, 0, 0, height] }, progress (percent) { // 绘制进度 }, finish (url) { // 画完后返回url }, error (res) { console.log(res); // 画失败的原因 } }); [代码] 所有的数字参数均以iphone6为基准,其中参数width和height决定了canvas画布的大小,规定值是在iphone6机型下的固定数值; zoom参数的作用是控制画布的缩放比例,如果要求画布自适应,则应传入 windowWidth / 375,windowWidth为手机屏幕的宽度。 2. 传入数据,生成图片 执行绘制操作: [代码] drawImage.draw(data, this); [代码] 执行绘制时需要传入数据data,数据的格式分为两种,下面展开介绍。 2.1 基础图形 第一种为基础的图形、图文绘制,直接使用官方提供接口,下面代码是一个基本的格式: [代码] let data = { list: [{ type: 'image', url: 'https://xxx', class: 'background_image', // delay: true, x: 0, y: 0, style: { width: width, height: width } }, { type: 'text', text: '文字', class: 'title', x: 0, y: 0, style: { fontSize: 14, lineHeight: 20, color: '#353535', fontFamily: 'PingFangSC-Regular' } }] } [代码] 如上,type声明了要元素的类型,有image、text、rect、line、circle、redius_image(圆角图)等,能满足绝大多数情况。 class类名指定了使用的样式,需要在style中写出,符合css样式规范。 delay参数用来异步绘制元素,会把此元素放在第二个循环中绘制。 x,y用来指定元素的起始坐标。 将css样式与元素分离的目的是便于管理与复用。 此种方式每个元素都相互独立,互不影响,能够满足自由度要求高的情况,可控性高。 2.2 wxml转换 第二种方式为指定wxml元素,自动获取,下面是示例: [代码] let data = { list: [{ type: 'wxml', class: '.panel .draw_canvas', limit: '.panel' x: 0, y: 0 }] } [代码] 如上,type声明为wxml时,会查找所有类名为draw_canvas的节点,并且加入到绘制队列中。 class传入的第一个类名限定了查询的范围,可以不传,第二个用来指定查找的节点,可以定义为任意不影响样式展现的通用类名。 limit属性用来限定相对位置,例如,一个文本的位置(left, top) = (50, 80), class为panel的节点的位置为(left, top) = (20, 40),则文本canvas上实际绘制的位置(x, y) = (50 - 20, 80 -40) = (30, 40)。如果不传入limit,则以实际的位置(x, y) = (50, 80)绘制。 由于小程序节点元素查询接口的局限,无法直接获取节点的文本内容和图片标签的src属性,也无法直接区分是文本还是图片,但是可以获取到dataset,所以我们需要在节点上显示地声明data-type来指明类型,再声明data-text传入文字或data-url传入图片链接。下面是个示例: [代码] <view class="panel"> <view class="panel__img draw_canvas" data-type="image" data-url="https://xxx"></view> <view class="panel__text draw_canvas" data-type="text" data-text="文字">文字</view> </view> [代码] 如上,会查询到两个节点符合条件,第一个为image图片,第二个为text文本,利用SelectorQuery查询它们的computedStyle,分别得到left、top、width、height等数据后,转换成canvas支持的格式,完成绘制。 除此之外,下面的示例功能更加丰富: [代码] <view class="panel"> <view class="panel__text draw_canvas" data-type="background-image" data-radius="1" data-shadow="" data-border="2px solid #000"></view> <view class="panel__text draw_canvas" data-type="text" data-background="#ffffff" data-padding="2 3 0 0" data-delay="1" data-left="10" data-top="10" data-maxlength="4" data-text="这是个文字">这是个文字</view> </view> [代码] 如上,第一个data-type为background-image,表示读取此节点的背景图片,因为可以通过computedStyle直接获取图片链接,所以不需要显示传入url。声明data-radius属性,表示要将此图绘成乘圆形图片。data-border属性表示要绘制图片的边框,虽然也可以通过computedStyle直接获取,但是为了避免非预期的结果,还是要声明传入,border格式应符合css标准。此外,图片的box-shadow等样式都会根据声明绘制出来。 第二个文本节点,声明了data-background,则会根据节点的位置属性给文字增加背景。 data-padding属性用来修正背景的位置和宽高。data-delay属性用来延迟绘制,可以根据值的大小,来控制元素的层级,data-left和data-top用来修正位置,支持负值。data-maxlength用来限制文本的最大长度,超长时会截取并追加’…’。 此外,data-type还有inline-text,inline-image等行内元素的绘制,其实现较为复杂,会在后文介绍。 五、Wxml2Canvas实现原理 1. 绘制流程 整个绘制流程如下: [图片] 因为小程序的限制,只能在画布上绘制本地图片,所以统一先对图片提前下载,然后再绘制,为了避免图片重复下载,内部维护一个图片列表,会对相同的图片链接去重,减少等待时间。 2. 基本图形的实现 基础图形的绘制比较简单,内部实现只是对基础能力的封装,使用者不用再关注canvas的绘制过程,只需要提供关键数据即可,下面是一个图片绘制的实现示例: [代码] function drawImage (item, style) { if(item.delay) { this.asyncList.push({item, style}); }else { if(item.y < 0) { item.y = this.height + item.y * zoom - style.height * zoom; }else { item.y = item.y * zoom; } if(item.x < 0) { item.x = this.width + item.x * zoom - style.width * zoom; }else { item.x = item.x * zoom; } ctx.drawImage(item.url, item.x, item.y, style.width * zoom, style.height * zoom); ctx.draw(true); } } [代码] 如上,x,y值坐标支持传入负值,表示从画布的底部和右侧计算位置。 3. Wxml转Canvas元素的实现 3.1 computedStyle的获取 首先需要获取wxml的样式,代码示例如下: [代码] query.selectAll(`${item.class}`).fields({ dataset: true, size: true, rect: true, computedStyle: ['width', 'height', ...] }, (res) => { self.drawWxml(res); }) [代码] 3.2 块级元素的绘制 对于声明为image、text的元素,默认为块级元素,它们的绘制都是独立进行的,不需要考虑其他的元素的影响,以wxml节点为圆形的image为例,下面是部分代码: [代码] if(sub.dataset.type === 'image') { let r = sub.width / 2; let x = sub.left + item.x * zoom; let y = sub.top + item.y * zoom; let leftFix = +sub.dataset.left || 0; let topFix = +sub.dataset.top || 0; let borderWidth = sub.borderWidth || 0; let borderColor = sub.borderColor; // 如果是圆形图片 if(sub.dataset.radius) { // 绘制圆形的border if(borderWidth) { ctx.beginPath() ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI) ctx.setStrokeStyle(borderColor) ctx.setLineWidth(borderWidth) ctx.stroke() ctx.closePath() } // 绘制圆形图片的阴影 if(sub.boxShadow !== 'none') { ctx.beginPath() ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI) ctx.setFillStyle(borderColor); setBoxShadow(sub.boxShadow); ctx.fill() ctx.closePath() } // 最后绘制圆形图片 ctx.save(); ctx.beginPath(); ctx.arc((x + r), (y + r) - limitTop, r, 0, 2 * Math.PI); ctx.clip(); ctx.drawImage(url, x + leftFix * zoom, y + topFix * zoom, sub.width, sub.height); ctx.closePath(); ctx.restore(); }else { // 常规图片 } } [代码] 如上,块级元素的绘制和基础图形的绘制差异不大,理解起来也很容易,不再多述。 3.3 令人头疼的行内元素的绘制 当wxml的data-type声明为inline-image或者inline-text时,我们认为是行内元素。行内元素的绘制是一个难点,因为元素之前存在关联,所以不得不考虑各种临界情况。下面展开细述。 3.3.1 纯文本换行 对于长度超过一行的行内元素,需要计算出合适的换行位置,下图所示的是两种临界情况: [图片] [图片] 如上图所示,第一种情况为最后一行只有一个文字,第二种情况最后一行的文字长度和宽度相同。虽然长度不同,但都可通过下面代码绘制: [代码] let lineNum = Math.ceil(measureWidth(text) / maxWidth); // 文字行数 let sinleLineLength = Math.floor(text.length / lineNume); // 向下取整,保证多于实际每行字数 let currentIndex = 0; // 记录文字的索引位置 for(let i = 0; i < lineNum; i++) { let offset = 0; // singleLineLength并不是精确的每行文字数,要校正 let endIndex = currentIndex + sinleLineLength + offset; let single = text.substring(currentIndex, endIndex); // 截取本行文字 let singleWidth = measureWidth(single); // 超长时,左移一位,直至正好 while(singleWidth > maxWidth) { offset--; endIndex = currentIndex + sinleLineLength + offset; single = text.substring(currentIndex, endIndex); singleWidth = measureWidth(single); } currentIndex = endIndex; ctx.fillText(single, item.x, item.y + i * style.lineHeight); } // 绘制剩余的 if(currentIndex < text.length) { let last = text.substring(currentIndex, text.length); ctx.fillText(last, item.x, item.y + lineNum * style.lineHeight); } [代码] 为了避免计算太多次,首先算出大致的行数,求出每行的文字数,然后移位索引下标,求出实际的每行的字数,再下移一行继续绘制,直到结束。 3.3.2 非换行的图文混排 [图片] 上图是一个包含表情图片和加粗文字的混排内容,当使用Wxml2Canvas查询元素时,会将第一行的内容分为五部分: 文本内容:这是段文字; 表情图片:发呆表情(非系统表情,image节点展现); 表情图片:发呆表情; 文本内容:这也; 加粗文本内容:是一段文字,这也是文字。 对于这种情况,执行查询computedStyle后,会返回相同的top值。我们把top值相同的元素聚合在一起,认为它们是同一行内容,事实也是如此。因为表情大小的差异以及其他影响,默认规定top值在±2的范围内都是同一行内容。然后将top值的聚合结果按照left的大小从左往右排列,再一一绘制,即可完美还原此种情况。 3.3.3 换行的图文混排 当混排内容出现了换行情况时,如下图所示: [图片] 此时的加粗内容占据了两行,当我们依旧根据top值归类时,却发现加粗文字的left值取的是第二行的left值。这就导致加粗文字和第一部分的文字的top值和left值相同,如果直接绘制,两部分会发生重叠。 为了避免这种尴尬的情况,我们可以利用加粗文字的height值与第一部分文字的height值比较,显然前者是后者的两倍,可以得知加粗部分出现了换行情况,直接将其放在同组top列表的最后位置。换行的部分根据lineHeight下移绘制,同时做记录。 最后一部分的文本内容也出现了换行情况,同样无法得到真正的起始left值,并且其top值与上一部分换行后的top值相同。此时应该将他的left值追加加粗换行部分的宽度,正好得到真正的left值,最后再绘制。 大多数的行内元素的展现形式都能以上述的逻辑完美还原。 六、总结 基于基础图形封装和wxml转换这两种绘制方式,可以满足绝大多数的场景,能够极大地减少工作量,而不需要再关注内部实现。在实际使用中,二者并非孤立存在,而更多的是一起使用。 [图片] 如上图所示,对于列表内容我们利用wxml读取绘制,对于下部的白色区域,不是wxml节点内容,我们可以使用基础图形绘制方式实现。二者的结合更加灵活高效。 目前Wxml2Canvas已经在公司内部开源,不久会放到github上,同时也在不断完善中,旨在实现更多的样式展现与提升稳定性和绘制速度。 如果有更好的建议与想法,请联系我。
2019-02-28 - 实现小程序canvas拖拽功能
组件地址 https://github.com/jasondu/wx-comp-canvas-drag 实现效果 [图片] 如何实现 使用canvas 使用movable-view标签 由于movable-view无法实现旋转,所以选择使用canvas 需要解决的问题 如何将多个元素渲染到canvas上 如何知道手指在元素上、如果多个元素重叠如何知道哪个元素在最上层 如何实现拖拽元素 如何缩放、旋转、删除元素 看起来挺简单的嘛,就把上面这几个问题解决了,就可以实现功能了;接下来我们一一解决。 如何将多个元素渲染到canvas上 定义一个DragGraph类,传入元素的各种属性(坐标、尺寸…)实例化后推入一个渲染数组里,然后再循环这个数组调用实例中的渲染方法,这样就可以把多个元素渲染到canvas上了。 如何知道手指在元素上、如果多个元素重叠如何知道哪个元素在最上层 在DragGraph类中定义了判断点击位置的方法,我们在canvas上绑定touchstart事件,将手指的坐标传入上面的方法,我们就可以知道手指是点击到元素本身,还是删除图标或者变换大小的图标上了,这个方法具体怎么判断后面会讲解。 通过循环渲染数组判断是非点击到哪个元素到,如果点击中了多个元素,也就是多个元素重叠,那第一个元素就是最上层的元素啦。 ###如何实现拖拽元素 通过上面我们可以判断手指是否在元素上,当touchstart事件触发时我们记录当前的手指坐标,当touchmove事件触发时,我们也知道这时的坐标,两个坐标取差值,就可以得出元素位移的距离啦,修改这个元素实例的x和y,再重新循环渲染渲染数组就可以实现拖拽的功能。 如何缩放、旋转、删除元素 这一步相对比较难一点,我会通过示意图跟大家讲解。 我们先讲缩放和旋转 [图片] 通过touchstart和touchmove我们可以获得旋转前的旋转后的坐标,图中的线A为元素的中点和旋转前点的连线;线B为元素中点和旋转后点的连线;我们只需要求A和B两条线的夹角就可以知道元素旋转的角度。缩放尺寸为A和B两条线长度之差。 计算旋转角度的代码如下: [代码]const centerX = (this.x + this.w) / 2; // 中点坐标 const centerY = (this.y + this.h) / 2; // 中点坐标 const diffXBefore = px - centerX; // 旋转前坐标 const diffYBefore = py - centerY; // 旋转前坐标 const diffXAfter = x - centerX; // 旋转后坐标 const diffYAfter = y - centerY; // 旋转后坐标 const angleBefore = Math.atan2(diffYBefore, diffXBefore) / Math.PI * 180; const angleAfter = Math.atan2(diffYAfter, diffXAfter) / Math.PI * 180; // 旋转的角度 this.rotate = currentGraph.rotate + angleAfter - angleBefore; [代码] 计算缩放尺寸的代码如下: [代码]// 放大 或 缩小 this.x = currentGraph.x - (x - px); this.y = currentGraph.y - (x - px); [代码]
2019-02-20 - 一次安全可靠的通信——HTTPS原理
我们知道小程序的wx.request网络接口只支持HTTPS协议(文档-小程序网络说明),为什么HTTPS协议就比HTTP安全呢?一次安全可靠的通信应该包含什么东西呢,这篇文章我会尝试讲清楚这些细节。 Alice与Bob的通信 我们以Alice与Bob一次通信来贯穿全文,一开始他们都是用明文的形式在网络传输通信内容。 [图片] 嗅探以及篡改 如果在他们的通信链路出现了一个Hacker,由于通信内容都是明文可见,所以Hacker可以嗅探看到这些内容,也可以篡改这些内容。 [图片] 公众号的文章之前就遇到很多被挟持篡改了内容,插入广告。 [图片] 加密解密 既然明文有问题,那就需要对明文进行加密处理,让中间人看不懂内容,于是乎要对原来的内容变成一段看不懂的内容,称为加密,反之则是解密。而本质其实就是一种数学运算的逆运算,类似加法减法,例如发送方可以将 abcd…xyz 每个字母+1映射成 bcd…yza,使得原文的字母变成看不懂的序列,而接收方只需要将每个字母-1就可以恢复成原来的序列,当然这种做法规律太容易被破解了,后边会有个案例示意图。 [图片] 对称加密 如果对2个二进制数A和B进行异或运算得到结果C, 那C和B再异或一次就会回到A,所以异或也可以作为加密解密的运算。 [图片] 把操作数A作为明文,操作数B作为密钥,结果C作为密文。可以看到加密解密运用同一个密钥B,把这种加解密都用同一个密钥的方式叫做对称加密。 [图片] 可以看到简单的异或加密/解密操作,需要密钥跟明文位数相同。为了克服这个缺点,需要改进一下,把明文进行分组,每组长度跟密钥一致,分别做异或操作就可以得到密文分片,再合并到一起就得到密文了。 [图片] 但是这种简单分组的模式也是很容易发现规律,可以从下图看到,中间采用对原图进行DES的ECB模式加密(就是上边提到简单分组的模式) [图片] 很明显,原图一些特征在加密后还是暴露无遗,因此需要再改进一把。一般的思路就是将上次分组运算的结果/中间结果参与到下次分组的运算中去,使得更随机混乱,更难破解。以下图片来自维基百科: [图片] 经过改良后,Alice与Bob如果能提前拿到一个对称加密的密钥,他们就可以通过加密明文来保证他们说话内容不会被Hacker看到了。 [图片] 非对称加密 刚刚还引发另一个问题,这个对称加密用到的密钥怎么互相告知呢?如果在传输真正的数据之前,先把密钥传过去,那Hacker还是能嗅探到,那之后就了无秘密了。于是乎出现另外一种手段: [图片] 这就是非对称加密,任何人都可以通过拿到Bob公开的公钥对内容进行加密,然后只有Bob自己私有的钥匙才能解密还原出原来内容。 [图片] RSA就是这样一个算法,具体数学证明利用了大质数乘法难以分解、费马小定理等数学理论支撑它难以破解。相对于前边的对称加密来说,其需要做乘法模除等操作,性能效率比对称加密差很多。 [图片] 由于非对称加密的性能低,因此我们用它来先协商对称加密的密钥即可,后续真正通信的内容还是用对称加密的手段,提高整体的性能。 [图片] 认证 上边虽然解决了密钥配送的问题,但是中间人还是可以欺骗双方,只要在Alice像Bob要公钥的时候,Hacker把自己公钥给了Alice,而Alice是不知道这个事情的,以为一直都是Bob跟她在通信。 [图片] 要怎么证明现在传过来的公钥就是Bob给的呢?在危险的网络环境下,还是没有解决这个问题。 [图片] 一般我们现实生活是怎么证明Bob就是Bob呢?一般都是政府给我们每个人发一个身份证(假设身份证没法伪造),我只要看到Bob身份证,就证明Bob就是Bob。 网络也可以这么做,如果有个大家都信任的组织CA给每个人出证明,那Alice只要拿到这个证明,检查一下是不是CA制作的Bob证书就可以证明Bob是Bob。所以这个证书里边需要有两个重要的东西:Bob的公钥+CA做的数字签名。 [图片] 前边说到用公钥进行加密,只有拥有私钥的人才能解密。数字证书有点反过来:用私钥进行加密,用公钥进行解密。CA用自己的私钥对Bob的信息(包含Bob公钥)进行加密,由于Alice无条件信任CA,所以已经提前知道CA的公钥,当她收到Bob证书的时候,只要用CA的公钥对Bob证书内容进行解密,发现能否成功解开(还需要校验完整性),此时说明Bob就是Bob,那之后用证书里边的Bob公钥来走之前的流程,就解决了中间人欺骗这个问题了。 这种方式也是一种防抵赖的方式,让对方把消息做一个数字签名,只要我收到消息,用对方的公钥成功解开校验这个签名,说明这个消息必然是对方发给我的,对方不可以抵赖这个行为,因为只有他才拥有做数字签名的私钥。 [图片] CA其实是有多级关系,顶层有个根CA,只要他信任B,B信任C,C信任D,那我们基本就可以认为D是可信的。 [图片] 完整性 上边基本上已经解决了保密性和认证,还有一个完整性没有保障。虽然Hacker还是看不懂内容,但是Hacker可以随便篡改通信内容的几个bit位,此时Bob解密看到的可能是很乱的内容,但是他也不知道这个究竟是Alice真实发的内容,还是被别人偷偷改了的内容。 [图片] 单向Hash函数可以把输入变成一个定长的输出串,其特点就是无法从这个输出还原回输入内容,并且不同的输入几乎不可能产生相同的输出,即便你要特意去找也非常难找到这样的输入(抗碰撞性),因此Alice只要将明文内容做一个Hash运算得到一个Hash值,并一起加密传递过去给Bob。Hacker即便篡改了内容,Bob解密之后发现拿到的内容以及对应计算出来的Hash值与传递过来的不一致,说明这个包的完整性被破坏了。 [图片] 一次安全可靠的通信 总结一下,安全可靠的保障: 对称加密以及非对称加密来解决:保密性 数字签名:认证、不可抵赖 单向Hash算法:完整性 来一张完整的图: [图片]
2019-02-20 - 【微信小程序】性能优化
为什么要做性能优化? 一切性能优化都是为了体验优化 1. 使用小程序时,是否会经常遇到如下问题? 打开是一直白屏 打开是loading态,转好几圈 我的页面点了怎么跳转这么慢? 我的列表怎么越滑越卡? 2. 我们优化的方向有哪些? 启动加载性能 渲染性能 3. 启动加载性能 1. 首次加载 你是否见过小程序首次加载时是这样的图? [图片] 这张图中的三种状态对应的都是什么呢? 小程序启动时,微信会为小程序展示一个固定的启动界面,界面内包含小程序的图标、名称和加载提示图标。此时,微信会在背后完成几项工作:[代码]下载小程序代码包[代码]、[代码]加载小程序代码包[代码]、[代码]初始化小程序首页[代码]。下载到的小程序代码包不是小程序的源代码,而是编译、压缩、打包之后的代码包。 2. 加载顺序 小程序加载的顺序是如何? 微信会在小程序启动前为小程序准备好通用的运行环境。这个运行环境包括几个供小程序使用的线程,并在其中完成小程序基础库的初始化,预先执行通用逻辑,尽可能做好小程序的启动准备。这样可以显著减少小程序的启动时间。 [图片] 通过2,我们知道了,问题1中第一张图是[代码]资源准备[代码](代码包下载);第二张图是[代码]业务代码的注入以及落地页首次渲染[代码];第三张图是[代码]落地页数据请求时的loading态[代码](部分小程序存在) 3. 控制包大小 提升体验最直接的方法是控制小程序包的大小,这是最显而易见的 勾选开发者工具中“上传代码时,压缩代码”选项; 及时清理无用的代码和资源文件(包括无用的日志代码) 减少资源包中的图片等资源的数量和大小(理论上除了小icon,其他图片资源从网络下载),图片资源压缩率有限 从开发者的角度看,控制代码包大小有助于减少小程序的启动时间。对低于1MB的代码包,其下载时间可以控制在929ms(iOS)、1500ms(Android)内。 4. 采用分包加载机制 根据业务场景,将用户访问率高的页面放在主包里,将访问率低的页面放入子包里,按需加载; [图片] 使用分包时需要注意代码和资源文件目录的划分。启动时需要访问的页面及其依赖的资源文件应放在主包中。 5 采用分包预加载技术 在4的基础上,当用户点击到子包的目录时,还是有一个代码包下载的过程,这会感觉到明显的卡顿,所以子包也不建议拆的太大,当然我们可以采用子包预加载技术,并不需要等到用户点击到子包页面后在下载子包,而是可以根据后期数据,做子包预加载,将用户在当先页可能点击的子包页面先加载,当用户点击后直接跳转; [图片] 这种基于配置的子包预加载技术,是可以根据用户网络类型来判断的,当用户处于网络条件好时才预加载;是灵活可控的 6. 采用独立分包技术 目前很多小程序[代码]主包+子包[代码](2M+6M)的方式,但是在做很多运营活动时,我们会发现活动(红包)是在子包里,但是运营、产品投放的落地页链接是子包链接,这是的用户在直达落地时,必须先下载主包内容(一般比较大),在下载子包内容(相对主包,较小),这使得在用户停留时间比较短的小程序场景中,用户体验不是很好,而且浪费了很大部分流量; [图片] 可以采用独立分包技术,区别于子包,和主包之间是无关的,在功能比较独立的子包里,使用户只需下载分包资源; 7. 首屏加载的优化建议 7.1 提前请求 异步请求可以在页面onLoad就加载,不需要等页面ready后在异步请求数据;当然,如果能在前置页面点击跳转时预请求当前页的核心异步请求,效果会更好; 7.2 利用缓存 利用storage API, 对变动频率比较低的异步数据进行缓存,二次启动时,先利用缓存数据进行初始化渲染,然后后台进行异步数据的更新,这不仅优化了性能,在无网环境下,用户也能很顺畅的使用到关键服务; 7.3 避免白屏 可以在前置页面将一些有用的字段带到当前页,进行首次渲染(列表页的某些数据–> 详情页),没有数据的模块可以进行骨架屏的占位,使用户不会等待的很焦虑,甚至走了; 7.4 及时反馈 及时的对需要用户等待的交互操作进行反馈,避免用户以为小程序卡了,无响应 渲染性能优化 1. 小程序渲染原理 双线程下的界面渲染,小程序的逻辑层和渲染层是分开的两个线程。在渲染层,宿主环境会把WXML转化成对应的JS对象,在逻辑层发生数据变更的时候,我们需要通过宿主环境提供的setData方法把数据从逻辑层传递到渲染层,再经过对比前后差异,把差异应用在原来的Dom树上,渲染出正确的UI界面。 [图片] 分析这个流程不难得知:页面初始化的时间大致由页面初始数据通信时间和初始渲染时间两部分构成。其中,数据通信的时间指数据从逻辑层开始组织数据到视图层完全接收完毕的时间,数据量小于64KB时总时长可以控制在30ms内。传输时间与数据量大体上呈现正相关关系,传输过大的数据将使这一时间显著增加。因而减少传输数据量是降低数据传输时间的有效方式。 [图片] 2. 避免使用不当setData 在数据传输时,逻辑层会执行一次[代码]JSON.stringify[代码]来去除掉[代码]setData[代码]数据中不可传输的部分,之后将数据发送给视图层。同时,逻辑层还会将[代码]setData[代码]所设置的数据字段与[代码]data[代码]合并,使开发者可以用[代码]this.data[代码]读取到变更后的数据。因此,为了提升数据更新的性能,开发者在执行[代码]setData[代码]调用时,最好遵循以下原则: 2.1 不要过于频繁调用setData,应考虑将多次setData合并成一次setData调用; [图片] 2.2 数据通信的性能与数据量正相关,因而如果有一些数据字段不在界面中展示且数据结构比较复杂或包含长字符串,则不应使用[代码]setData[代码]来设置这些数据; [图片] 2.3 与界面渲染无关的数据最好不要设置在data中,可以考虑设置在page对象的其他字段下 [图片] 提升数据更新性能方式的代码示例 [代码]Page({ onShow: function() { // 不要频繁调用setData this.setData({ a: 1 }) this.setData({ b: 2 }) // 绝大多数时候可优化为 this.setData({ a: 1, b: 2 }) // 不要设置不在界面渲染时使用的数据,并将界面无关的数据放在data外 this.setData({ myData: { a: '这个字符串在WXML中用到了', b: '这个字符串未在WXML中用到,而且它很长…………………………' } }) // 可以优化为 this.setData({ 'myData.a': '这个字符串在WXML中用到了' }) this._myData = { b: '这个字符串未在WXML中用到,而且它很长…………………………' } } }) [代码] 利用setData进行列表局部刷新 在一个列表中,有[代码]n[代码]条数据,采用上拉加载更多的方式,假如这个时候想对其中某一个数据进行点赞操作,还能及时看到点赞的效果 解决方法 1、可以采用setData全局刷新,点赞完成之后,重新获取数据,再次进行全局重新渲染,这样做的优点是:方便,快捷!缺点是:用户体验极其不好,当用户刷量100多条数据后,重新渲染量大会出现空白期(没有渲染过来) 2、说到重点了,就是利用[代码]setData[代码]局部刷新 [代码]> a.将点赞的`id`传过去,知道点的是那一条数据, 将点赞的`id`传过去,知道点的是那一条数据 [代码] [代码]<view wx:if="{{!item.status}}" class="btn" data-id="{{index}}" bindtap="couponTap">立即领取</view> [代码] [代码]> b.重新获取数据,查找相对应id的那条数据的下标(`index`是不会改变的) > c.用setData进行局部刷新 [代码] [代码]this.setData({ list[index] = newList[index] }) [代码] 其实这个小操作对刚刚接触到微信小程序的人来说应该是不容易发现的,不理解setData还有这样的写法。 2.4 切勿在后台页面进行setData 在一些页面会进行一些操作,而到页面跳转后,代码逻辑还在执行,此时多个[代码]webview[代码]是共享一个js进程;后台的[代码]setData[代码]操作会抢占前台页面的渲染资源; [图片] [图片] 3. 用户事件使用不当 视图层将事件反馈给逻辑层时,同样需要一个通信过程,通信的方向是从视图层到逻辑层。因为这个通信过程是异步的,会产生一定的延迟,延迟时间同样与传输的数据量正相关,数据量小于64KB时在30ms内。降低延迟时间的方法主要有两个。 1.去掉不必要的事件绑定(WXML中的[代码]bind[代码]和[代码]catch[代码]),从而减少通信的数据量和次数; 2.事件绑定时需要传输[代码]target[代码]和[代码]currentTarget[代码]的[代码]dataset[代码],因而不要在节点的[代码]data[代码]前缀属性中放置过大的数据。 [图片] 4. 视图层渲染原理 4.1首次渲染 初始渲染发生在页面刚刚创建时。初始渲染时,将初始数据套用在对应的WXML片段上生成节点树。节点树也就是在开发者工具WXML面板中看到的页面树结构,它包含页面内所有组件节点的名称、属性值和事件回调函数等信息。最后根据节点树包含的各个节点,在界面上依次创建出各个组件。 [图片] 在这整个流程中,时间开销大体上与节点树中节点的总量成正比例关系。因而减少WXML中节点的数量可以有效降低初始渲染和重渲染的时间开销,提升渲染性能。 简化WXML代码的例子 [代码]<view data-my-data="{{myData}}"> <!-- 这个 view 和下一行的 view 可以合并 --> <view class="my-class" data-my-data="{{myData}}" bindtap="onTap"> <text> <!-- 这个 text 通常是没必要的 --> {{myText}} </text> </view> </view> <!-- 可以简化为 --> <view class="my-class" data-my-data="{{myData}}" bindtap="onTap"> {{myText}} </view> [代码] 4.2 重渲染 初始渲染完毕后,视图层可以多次应用[代码]setData[代码]的数据。每次应用[代码]setData[代码]数据时,都会执行重渲染来更新界面。初始渲染中得到的data和当前节点树会保留下来用于重渲染。每次重渲染时,将[代码]data[代码]和[代码]setData[代码]数据套用在WXML片段上,得到一个新节点树。然后将新节点树与当前节点树进行比较,这样可以得到哪些节点的哪些属性需要更新、哪些节点需要添加或移除。最后,将[代码]setData[代码]数据合并到[代码]data[代码]中,并用新节点树替换旧节点树,用于下一次重渲染。 [图片] 在进行当前节点树与新节点树的比较时,会着重比较[代码]setData[代码]数据影响到的节点属性。因而,去掉不必要设置的数据、减少[代码]setData[代码]的数据量也有助于提升这一个步骤的性能。 5. 使用自定义组件 自定义组件的更新只在组件内部进行,不受页面其他不能分内容的影响;比如一些运营活动的定时模块可以单独抽出来,做成一个定时组件,定时组件的更新并不会影响页面上其他元素的更新;各个组件也将具有各自独立的逻辑空间。每个组件都分别拥有自己的独立的数据、setData调用。 [图片] 6. 避免不当的使用onPageScroll 每一次事件监听都是一次视图到逻辑的通信过程,所以只在必要的时候监听pageSrcoll [图片] 总结 小程序启动加载性能 控制代码包的大小 分包加载 首屏体验(预请求,利用缓存,避免白屏,及时反馈 小程序渲染性能 避免不当的使用setData 合理利用事件通信 避免不当的使用onPageScroll 优化视图节点 使用自定义组件
2019-03-07 - 小程序中一个用户操作后能不能通过微信公众号推送消息通知另一个用户?
A用户在小程序中做了一个操作,比如提交了一个表单,能不能通过微信公众号推送消息来通知B用户发生了什么操作?或者有没有能够通过其他基于微信的方式来推送消息呢?
2018-10-23