- 【翻译】柯里化与偏函数?
本文译自 Curry or Partial Application 译者 郭梓梁,首次发布于 MeloGuo Blog,转载请保留以上链接 许多刚开始学习函数式编程的人都会对柯里化和偏函数应用之间的区别感到困惑。实际上,直到最近也很少能看到在 JavaScript 中使用真正的柯里化,而且许多函数工具库所声称的的[代码]curry()[代码]方法并不是柯里化函数,而是偏函数应用! 如果你对这两者之间的区别感到困惑,那么接下来的内容将会为你解释清楚,但是在最开始,让我们先来点名词解释: 定义 应用(Application):调用一个函数并传入它的全部参数且得到返回值的过程 偏函数应用(Partial Application):调用一个函数并传入它的部分参数的过程。被部分调用的函数得到返回值后以备稍晚使用。换句话说,一个函数接收一个多参函数并且返回一个接收较少参数的函数。偏函数应用向返回的函数中传入了一个或多个的参数,并且被返回的函数接收剩余的参数来完成函数的执行调用。 柯里化(Curry):一个函数接收一个多参函数并且返回一个只为一个参数的函数。 为什么这事这么重要? James Coglan 在他精彩的演讲中帮助解释了为什么你应该在乎这件事: James Coglan: Practical functional programming: pick two James 讲到的特性都依赖于函数类型一致性。函数是有类型的。例如,一个函数接收了另一个函数和一个数组并且返回了一个数组([代码]Funciton.prototype.map()[代码]): (fn, array) -> array 一个偏函数应用可能有或可能没有一个可预知的返回类型 一个柯里化函数总是返回另一个只接收一个参数的函数,直到所有的参数都被传入 James 讲的所有的神奇实现都依赖于函数类型一致性。例如他谈到 promises 是如何能够按照时序来抽象你的程序依赖逻辑。 Promises 能做到是因为它提升了所有你调用的函数以便于返回值的类型总是一致的:所有的函数都返回 promises 同样的类型,这意味着你能够用一种标准的方式使用它们。James 谈论了许多关于 promises 的好处。 在这种情况下,promises 是像函子(functors)一样的容器,能够提供一种标准的方式去处理容器中的数据,不用关心数据的类型。 警告:谷歌一下函数式编程的术语会令人胆怯—但实际上它们都比学术上描述的容易很多。 通过操作容器而不是容器中的值,你能够创造许多通用的函数,它们将会通过使用容器的接口,统一的作用在任何值上。 正如 promises,柯里化函数均返回使用统一接口的容器。这种情况下,返回的函数都是容器。你只需要一直调用返回的函数,直到你已经传入所有的参数然后最终得到计算结果。 换句话说,一个柯里化函数是一个能够提升它自己所有参数的函数,以便于你能够通过一种标准的方式处理这些参数。 最常见的提升的例子便是函数组合(function composition)了,例如[代码]c(x) = f(g(x))[代码]。函数组合接收一个函数的返回值并且将其作为参数传给另一个函数。因为一个函数只能返回一个值,函数被调用时传入的参数也必须是一元的。 除此之外,柯里化函数还有内建的迭代器机制:一个柯里化函数一次调用将会部分的传入一个参数,从不做处理一个参数之外的更多工作。调用它返回的函数有着告诉函数去执行下一步操作的效果。 例如,柯里化函数a(b)(c);不必立即去传入c。它可以被分解成如下的样子: const next = a(b);doSomeStuff().then(() => next(c)); 你应该对偏函数应用和柯里化之间的不同有一个更加清晰地理解,以及为何你想要去柯里化你的函数。 在函数式编程中,最常见的使用柯里化的原因便是让函数更容易被组合。更多关于此话题的讨论,可阅读: Master the JavaScript Interview: What is Function Composition?
2019-05-11 - 你可能不知道的mpvue性能优化技巧
最近一直在折腾[代码]mpvue[代码]写的微信小程序的性能优化,分享下实战的过程。 先上个优化前后的图: [图片] 可以看到打包后的代码量从[代码]813KB[代码]减少到[代码]387KB[代码],Audits体验评分从[代码]B[代码]到[代码]A[代码],效果还是比较明显的。其实这个指标说明不了什么,而且轻易就可以做到,更重要的是优化小程序运行过程中的卡顿感,请耐心往下看。 常规优化 常规的Web端优化方法在小程序中也是适用的,而且不可忽视。 一、压缩图片 这一步最简单,但是容易被忽视。在tiny上在线压缩,然后下载替换即可。 [图片] 我这项目的压缩率高达[代码]72%[代码],可以说打包后的代码从[代码]813KB[代码]降到[代码]387KB[代码]大部分都是归功于压缩图片了。 二、移除无用的库 我之前在项目中使用了Vant Weapp,在[代码]static[代码]目录下引入了整个库,但实际上我只使用了[代码]button[代码],[代码]field[代码],[代码]dialog[代码]等几个组件,实在是没必要。 所以干脆移除掉了,微信小程序自身提供的[代码]button[代码],[代码]wx.showModal[代码]等一些组件基本可以满足需求,自己手写一下样式也不用花什么时间。 在这里建议大家,在微信小程序中,尽量避免使用过多的依赖库。 不要贪图方便而引入一些比较大的库,小程序不同于[代码]Web[代码],限制比较多,能自己写一下就尽量自己写一下吧。 小程序的优化 咱们首先得看一下官方优化建议,大多是围绕这个建议去做。 一、开启Vue.config._mpTrace = true 这个是[代码]mpvue[代码]性能优化的一个黑科技啊,可能大多数同学都不知道这个,我在官方文档都没有搜到到这个配置,我真的是服了。 我能找到这个配置也是Google机缘巧合下看到的,出处:mpvue重要更新,页面更新机制进行全面升级 具体做法是在[代码]/src/main.js[代码]添加[代码]Vue.config._mpTrace = true[代码],如: [代码]Vue.config._mpTrace = true Vue.config.productionTip = false App.mpType = 'app' [代码] 添加了[代码]Vue.config._mpTrace[代码]属性,这样就可以看到console里会打印每500ms更新的数据量。如图: [图片] 如果数据更新量很大,会明显感觉小程序运行卡顿,反之就流畅。因此我们可以根据这个指标,逐步找出性能瓶颈并解决掉。 二、精简data 1. 过滤api返回的冗余数据 后端的api可能是需要同时为iOS,Android,H5等提供服务的,往往会有些冗余的数据小程序是用不到的。比如api返回的一个[代码]文章列表[代码]数据有很多字段: [代码]this.articleList = [ { articleId: 1, desc: 'xxxxxx', author: 'fengxianqi', time: 'xxx', comments: [ { userId: 2, conent: 'xxx' } ] }, { articleId: 2 // ... }, // ... ] [代码] 假设我们在小程序中只需要用到列表中的部分字段,如果不对数据做处理,将整个[代码]articleList[代码]都[代码]setData[代码]进去,是不明智的。 小程序官方文档: 单次设置的数据不能超过1024kB,请尽量避免一次设置过多的数据。 可以看出,内存是很宝贵的,当[代码]articleList[代码]数据量非常大超过1M时,某些机型就会爆掉(我在iOS中遇到过很多次)。 因此,需要将接口返回的数据剔除掉不需要的,再[代码]setData[代码],回到我们上面的[代码]articleList[代码]例子,假设我们只需要用[代码]articleId[代码]和[代码]author[代码]这两个字段,可以这样: [代码]import { getArticleList } from '@/api/article' export default { data () { return { articleList: [] } } methods: { getList () { getArticleList().then(res => { let rawList = res.list this.articleList = this.simplifyArticleList(rawList) }) }, simplifyArticleList (list) { return list.map(item => { return { articleId: item.articleId, author: item.author // 需要哪些字段就加上哪些字段 } }) } } } [代码] 这里我们将返回的数据通过[代码]simplifyArticleList[代码]来精简数据,此时过滤后的[代码]articleList[代码]中的数据类似: [代码][ {articleId: 1, author: 'fengxianqi'}, {articleId: 2, author: 'others'} // ... ] [代码] 当然,如果你的需求中是所有数据都要用到(或者大部分数据),就没必要做一层精简了,收益不大。毕竟精简数据的函数中具体的字段,是会增加维护成本的。 PS: 在我个人的实际操作中,做数据过滤虽然增加了维护的成本,但一般收益都很大,因次这个方法比较推荐。 2. data()中只放需要的数据 [代码]import xx from 'xx.js' export default { data () { return { xx, otherXX: '2' } } } [代码] 有些同学可能会习惯将[代码]import[代码]的东西都先放进[代码]data[代码]中,再在[代码]methods[代码]中使用,在小程序中可能是个不好的习惯。 因为通过[代码]Vue.config._mpTrace = true[代码]在更新某个数据时,我对比放进data和不放进data中的两种情况会有差别。 所以我猜测可能是data是会一起更新的,比如只是想更新[代码]otherXX[代码]时,会同时将[代码]xx[代码]也一起合起来[代码]setData[代码]了。 3. 静态图片放进static 这个问题和上面的问题其实是一样的,有时候我们会通过[代码]import[代码]的方式引入,比如这样: [代码]<template> <img :src="UserIcon"> </template> <script> import UserIcon from '@/assets/images/user_icon.png' export default { data () { return { UserIcon } } } </script> [代码] 这样会导致打包后的代码,图片是[代码]base64[代码]形式(很长的一段字符串)存放在[代码]data[代码]中,不利于精简data。同时当该组件多个地方使用时,每个组件实例都会携带这一段很长的[代码]base64[代码]代码,进一步导致数据的冗余。 因此,建议将静态图片放到[代码]static[代码]目录下,这样引用: [代码]<template> <img src="/static/images/user_icon.png"> </template> [代码] 代码也更简洁清爽。 看一下做了上面操作的前后对比图,使用体验上也流畅了很多。 [图片] 三、swiper优化 小程序自身提供的[代码]swiper[代码]组件性能上不是很好,使用时要注意。参考着两个思路: 【优化】解决swiper渲染很多图片时的卡顿 想请教一下小程序swiper组件的问题 在我使用时,由于需求原因,动态删掉swiper-item的思路不可行(手滑时会造成抖动)。因此只能作罢。但仍然可以优化一下: 将未显示的[代码]swiper-item[代码]中的图片用[代码]v-if[代码]隐藏到,当判断到current时才显示,防止大量图片的渲染导致的性能问题。 四、vuex使用注意事项 我之前写过的一篇mpvue开发音频类小程序踩坑和建议里面有讲如何在小程序中使用[代码]vuex[代码]。但遇到了个比较严重的性能问题。 1. 问题描述 我开发的是一个音频类的小程序,所以需要将播放列表[代码]playList[代码],当前索引[代码]currentIndex[代码]和当前时长[代码]currentTime[代码]放进[代码]state.js[代码]中: [代码]const state = { currentIndex: 0, // playList当前索引 currentTime: 0, // 当前播放的进度 playList: [], // {title: '', url: '', singer: ''} } [代码] 每次用户点击播放音频时,都会先加载音频的播放列表[代码]playList[代码],然后播放时更新当前时长[代码]currentTime[代码],发现有时候播音频时整个小程序非常卡顿。 注意到,音频需每秒就得更新一次[代码]currentTime[代码],即每秒就做一次[代码]setData[代码]操作,稍微有些卡顿是可以理解的。但我发现是播放列表数据比较多时会特别卡,比如playList的长度是100条以上时。 2. 问题原因 我开启[代码]Vue.config._mpTrace = true[代码]后发现一个规律: 当[代码]palyList[代码]数据量小时,[代码]console[代码]显示造成的数据量更新数值比较小;当[代码]playList[代码]比较大时,[代码]console[代码]显示造成的数据量更新数值比较大。 PS: 我曾尝试将playList数据量增加到200条,每500ms的数据量更新达到800KB左右。 到这里基本可以确定一个事实就是:更新[代码]state[代码]中的任何一个字段,将导致整个[代码]state[代码]全量一起[代码]setData[代码]。在我这里的例子,虽然我每次只是更新[代码]currentTime[代码]这个字段的值,但依然导致将state中的其他字段如[代码]playList[代码],[代码]currentIndex[代码]都一起做了一次[代码]setData[代码]操作。 3. 解决问题 有两个思路: 精简state中保存的数据,即限制[代码]playList[代码]的数据不能太多,可将一些数据暂存在[代码]storage[代码]中 [代码]vuex[代码]采用[代码]Module[代码]的写法能改善这个问题,虽然使用时命名空间造成一定的麻烦。vuex传送门 一般情况下,推荐使用后者。我在项目中尝试使用了前者,同样能达到很好的效果,请继续看下面的分享。 五、善用storage 1.为什么说要善用storage 由于小程序的内存非常宝贵,占用内存过大会非常卡顿,因此最好尽可能少的将数据放到内存中,即[代码]vuex[代码]存的数据要尽可能少。而小程序的[代码]storage[代码]支持单个 key允许存储的最大数据长度为 [代码]1MB[代码],所有数据存储上限为 [代码]10MB[代码]。 所以可以将一些相对取用不频繁的数据放进[代码]storage[代码]中,需要时再将这些数据放进内存,从而缓解内存的紧张,有点类似Windows中[代码]虚拟内存[代码]的概念。 2.storage换内存的实例 这个例子讲的会有点啰嗦,真正能用到的朋友可以详细看下。 上面讲到[代码]playList[代码]数据量太多,播放一条音频时其实只需要最多保证3条数据在内存中即可,即[代码]上一首[代码],[代码]播放中的[代码],[代码]下一首[代码],我们可以将多余的播放列表存放在[代码]storage[代码]中。 PS: 为了保证更平滑地连续切换下一首,我们可以稍微保存多几条,比如我这里选择保存5条数据在vuex中,播放时始终保证当前播放的音频前后都有两条数据。 [代码]// 首次播放背景音频的方法 async function playAudio (audioId) { // 拿到播放列表,此时的playList最多只有5条数据。getPlayList方法看下面 const playList = await getPlayList(audioId) // 当前音频在vuex中的currentIndex const currentIndex = playList.findIndex(item => item.audioId === audioId) // 播放背景音频 this.audio = wx.getBackgroundAudioManager() this.audio.title = playList[currentIndex].title this.audio.src = playList[currentIndex].url // 通过mapActions将播放列表和currentIndex更新到vuex中 this.updateCurrentIndex(index) this.updatePlayList(playList) // updateCurrentIndex和updatePlayList是vuex写好的方法 } // 播放音频时获取播放列表的方法,将所有数据存在storage,然后返回当前音频的前后2条数据,保证最多5条数据 import { loadPlayList } from '@/api/audio' async function getPlayList (courseId, currentAudioId) { // 从api中请求得到播放列表 // loadPlayList是api的方法, courseId是获取列表的参数,表示当前课程下的播放列表 let rawList = await loadPlayList(courseId) // simplifyPlayList过滤掉一些字段 const list = this.simplifyPlayList(rawList) // 将列表存到storage中 wx.setStorage({ key: 'playList', data: list }) return subPlayList(list, currentAudioId) } [代码] 重点是[代码]subPlayList[代码]方法,这个方法保证了拿到的播放列表是最多5条数据。 [代码]function subPlayList(playList, currentAudioId) { let tempArr = [...playList] const count = 5 // 保持vuex中最多5条数据 const middle = parseInt(count / 2) // 中点的索引 const len = tempArr.length // 如果整个原始的播放列表本来就少于5条数据,说明不需要裁剪,直接返回 if (len <= count) { return tempArr } // 找到当前要播放的音频的所在位置 const index = tempArr.findIndex(item => item.audioId === currentAudioId) // 截取当前音频的前后两条数据 tempArr = tempArr.splice(Math.max(0, Math.min(len - count, index - middle)), count) return tempArr } [代码] [代码]tempArr.splice(Math.max(0, index - middle), count)[代码]可能有些同学比较难理解,需要仔细琢磨一下。假设[代码]playList[代码]有10条数据: 当前音频是列表中的第1条(索引是0),截取前5个:[代码]playList.splice(0, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]0[代码],没有[代码]上一首[代码],有4个[代码]下一首[代码] 当前音频是列表中的第2条(索引是1),截取前5个:[代码]playList.splice(0, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]1[代码],有1个[代码]上一首[代码],3个[代码]下一首[代码] 当前音频是列表中的第3条(索引是2),截取前5个:[代码]playList.splice(0, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]2[代码],有2个[代码]上一首[代码],2个[代码]下一首[代码] 当前音频是列表中的第4条(索引是3),截取第1到6个:[代码]playList.splice(1, 5)[代码] ,此时[代码]currentAudio[代码]在这5个数据的索引是[代码]2[代码],有2个[代码]上一首[代码],2个[代码]下一首[代码] 当前音频是列表中的第5条(索引是4),截取第2到7个:[代码]playList.splice(2, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]2[代码],有2个[代码]上一首[代码],2个[代码]下一首[代码] … 当前音频是列表中的第9条(索引是[代码]8[代码]),截取后5个:[代码]playList.splice(4, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]3[代码],有3个[代码]上一首[代码],1个[代码]下一首[代码] 当前音频是列表中的最后1条(索引是[代码]9[代码]),截取后的5个:[代码]playList.splice(4, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]4[代码],有4个[代码]上一首[代码],没有[代码]下一首[代码] 有点啰嗦,感兴趣的同学仔细琢磨下,无论当前音频在哪,都始终保证了拿到当前音频前后的最多5条数据。 接下来就是维护播放上一首或下一首时保证当前vuex中的[代码]playList[代码]始终是包含当前音频的前后2条。 播放下一首 [代码]function playNextAudio() { const nextIndex = this.currentIndex + 1 if (nextIndex < this.playList.length) { // 没有超出数组长度,说明在vuex的列表中,可以直接播放 this.audio = wx.getBackgroundAudioManager() this.audio.src = this.playList[nextIndex].url this.audio.title = this.playList[nextIndex].title this.updateCurrentIndex(nextIndex) // 当判断到已经到vuex的playList的边界了,重新从storage中拿数据补充到playList if (nextIndex === this.playList.length - 1 || nextIndex === 0) { // 拿到只有当前音频前后最多5条数据的列表 const newList = getPlayList(this.playList[nextIndex].courseId, this.playList[nextIndex].audioId) // 当前音频在这5条数据中的索引 const index = newList.findIndex(item => item.audioId === this.playList[nextIndex].audioId) // 更新到vuex this.updateCurrentIndex(index) this.updatePlayList(newList) } } } [代码] 这里的[代码]getPlayList[代码]方法是上面讲过的,本来是从api中直接获取的,为了避免每次都从api直接获取,所以需要改一下,先读storage,若无则从api获取: [代码]import { loadPlayList } from '@/api/audio' async function getPlayList (courseId, currentAudioId) { // 先从缓存列表中拿 const playList = wx.getStorageSync('playList') if (playList && playList.length > 0 && courseId === playList[0].courseId) { // 命中缓存,则从直接返回 return subPlayList(playList, currentAudioId) } else { // 没有命中缓存,则从api中获取 const list = await loadPlayList(courseId) wx.setStorage({ key: 'playList', data: list }) return subPlayList(list, currentAudioId) } } [代码] 播放上一首也是同理,就不赘述了。 PS: 将vuex中的数据精简后,我所做的小程序在播放音频时刷其他页面已经非常流畅啦,效果非常好。 六、动画优化 这个问题在mpvue开发音频类小程序踩坑和建议已经讲过了,感兴趣的可以移步看一眼,这里只写下概述: 如果要使用动画,尽量用css动画代替wx.createAnimation 使用css动画时建议开启硬件加速 最后 大致总结一下上面所讲的几个要点: 开发时打开[代码]Vue.config._mpTrace = true[代码]。 谨慎引入第三方库,权衡收益。 添加数据到data中时要克制,能精简尽量精简。 图片记得要压缩,图片在显示时才渲染。 vuex保持数据精简,必要时可先存storage。 性能优化是一个永不止步的话题,我也还在摸索,不足之处还请大家指点和分享。 欢迎关注,会持续分享前端实战中遇到的一些问题和解决办法。
2019-05-15 - Comi - 小程序 markdown 渲染和代码高亮解决方案
写在前面 Comi 读 ['kəʊmɪ],类似中文 科米,是腾讯 Omi 团队开发的小程序代码高亮和 markdown 渲染组件。有了这个组件加持,小程序技术社区可以开始搞起来了。 体验 [图片] 感谢【小程序•云开发】提供技术支持。 预览 [图片] Comi 基于下面的 5 个组件进行开发: prismjs wxParse remarkable html2json htmlparser 先看 Comi 使用,再分析原理。 使用 先拷贝 此目录 到你的项目。 js: [代码]const comi = require('../../comi/comi.js'); Page({ onLoad: function () { comi(`你要渲染的 md!`, this) } }) [代码] wxml: [代码]<include src="../../comi/comi.wxml" /> [代码] wxss: [代码]@import "../../comi/comi.wxss"; [代码] 简单把! 在 omip 中使用 先拷贝 此目录 到你的项目。 js: [代码]import { WeElement, define } from 'omi' import './index.css' import comi from '../../components/comi/comi' define('page-index', class extends WeElement { install() { comi(`你要渲染的 md`, this.$scope) } render() { return ( <view> <include src="../../components/comi/comi.wxml" /> </view> ) } }) [代码] WeElement 里的 this 并不是小程序里的 this,需要使用 [代码]this.$scope[代码] 访问小程序 Page或 Component 的 this。 css: [代码]@import '../../components/comi/comi.wxss'; [代码] 原理 在开发 Comi 之前,我们进行了预研,是否有必要造这个轮子。 代码高亮预研 wxParse 只是用标签包括代码,并未处理代码转成 WXML,所以渲染出的代码是没有颜色 老牌的 highlightjs 没有 WXML 对应的方案 老牌的 highlightjs 对 JSX 高亮支持太差 prismjs 是 react 官方使用的高亮插件,对 JSX 支持高亮很好 prismjs 支持几乎所有的语言,并且支持自定义扩展语言 prismjs 拥有 Line Highlight 插件(目前还未移植到 Comi) 综合上面信息,决定基于 prismjs 二次开发。 markdown 渲染预研 wxParse 老牌的渲染组件,支持 markdown wxParse 内置的 showdownjs 不满足代码高亮的格式需求(比如语言种类也会生成一个标签,当然可以通过 wxss 隐藏) 小程序基础库 1.4.0 开始支持 [代码]rich-text[代码] 组件展示富文本,但是格式需要转成 json 高性能 remarkable,Facebook 和 Docusaurus 都在使用,支持 md 语法修改和扩展 [代码]<rich-text nodes="{{nodes}}" bindtap="tap"></rich-text> [代码] [代码]Page({ data: { nodes: [{ name: 'div', attrs: { class: 'div_class', style: 'line-height: 60px; color: red;' }, children: [{ type: 'text', text: 'Hello World!' }] }] }, tap() { console.log('tap') } }) [代码] 综合上面信息,放弃 rich-text,决定基于 wxParse + remarkable 二次开发,移除 showdownjs。Comi 需要 remarkable 的高性能和灵活性。markdown 会持久化存在 db, 在小程序内运行时转换成 wxml,所以对性能还是有一定要求。 劫持 prismjs tokens [代码]tokens: function(text, grammar, language) { var env = { code: text, grammar: grammar, language: language }; _.hooks.run('before-tokenize', env); env.tokens = _.tokenize(env.code, env.grammar); _.hooks.run('after-tokenize', env); for (var i = 0, len = env.tokens.length; i < len; i++) { var v = env.tokens[i] if (Object.prototype.toString.call(v.content) === '[object Array]') { v.deep = true this._walkContent(v.content) } } return env.tokens }, [代码] [图片] 这段代码增加 tokens 方法到 prismjs 中,原库自带的 prism.highlight 的会把 tokens 转成 html,因为我们的目标的 wxml,所以这里提前把 tokens 作为方法返回值。当然还做了一件事,就是扩展了 token item 的 deep 属性来决定是否需要继续向下遍历生成 wxml。 原始的 jsx: [代码]render() { const { tks } = this.data return ( <view class='pre language-jsx'> <view class='code'> {tks.map(tk => { return tk.deep ? <text class={'token ' + tk.type}>{ tk.content.map(stk => { return stk.deep ? stk.content.map(sstk => { return <text class={'token ' + sstk.type}>{sstk.content || sstk}</text> }) : <text class={'token ' + stk.type}>{stk.content || stk}</text> })}</text> : <text class={'token ' + tk.type}>{tk.content || tk}</text> })} </view> </view> ) } [代码] jsx 编译出生成的 wxml,把这段 wxml 嵌入到 wxparse 里: [代码]<!-- 千万 不要格式化下面的 wxml,不然 text 嵌套 text 导致换行全部出来了 --> <template name="wxParseCode"> <view class="pre language-jsx"> <view class="code"> <block wx:for="{{item.tks}}" wx:for-item="tk"> <block wx:if="{{tk.deep}}"><text class="{{'token ' + tk.type}}"><block wx:for="{{tk.content}}" wx:for-item="stk"><block wx:if="{{stk.deep}}"><text class="{{'token ' + sstk.type}}" wx:for="{{stk.content}}" wx:for-item="sstk">{{sstk.content || sstk}}</text> </block> <block wx:else><text class="{{'token ' + stk.type}}">{{stk.content || stk}}</text> </block> </block> </text> </block> <block wx:else><text class="{{'token ' + tk.type}}">{{tk.content || tk}}</text> </block> </block> </view> </view> </template> [代码] 这段 wxml 不能进行格式化美化,不然多出许多换行符,因为 text 嵌套 text 会保留换行符!! 修改 wxparse 里的分支逻辑: [代码]<block wx:elif="{{item.tagType == 'block'}}"> <view class="{{item.classStr}} wxParse-{{item.tag}}" style="{{item.styleStr}}"> <block wx:if="{{item.tag == 'pre'}}"> <template is="wxParseCode" data="{{item}}" /> </block> <block wx:elif="{{item.tag != 'pre'}}" > <block wx:for="{{item.nodes}}" wx:for-item="item" wx:key=""> <template is="wxParse1" data="{{item}}" /> </block> </block> </view> </block> [代码] 当 [代码]item.tag[代码] 为 [代码]pre[代码] 的时候使用 wxParseCode 模板,数据传入 item。item 的数据从哪里来? 先修改 md 渲染器为 Remarkable: [代码]} else if (type == 'md' || type == 'markdown') { var converter = new Remarkable() var html = converter.render(data) transData = HtmlToJson.html2json(html, bindName); } [代码] 使用上面的 prism.tokens 计算出代码片段的 tokens,用于 wxparse 的模板渲染: [代码]function transPre(transData) { transData.nodes.forEach((node, index) => { if (node.tag == 'pre') { var lan = 'markup' if (node.nodes[0].classStr) { lan = node.nodes[0].classStr.split(' ')[0].replace('language-', '') } var tks = prism.tokens(node.nodes[0].nodes[0].text, prism.languages[lan], lan) transData.nodes[index].tks = tks } }) } [代码] language- 支持多少种呢?目前 comi 默认支持: markup css clike javascript bash json typescript jsx tsx 默认使用的主题 css 是 okaidia。如果 comi 默认的配置不支持你的需求,你可以: 进 https://prismjs.com/download.html 这里自行下载 劫持 prismjs tokens 拷贝进你下载的 prismjs 里 把 prismjs 拷贝替换掉 comi 自带的 prismjs 精简 comi 使用流程 WXML 提供两种文件引用方式 import 和 include。和 import 不同,include 可以将目标文件除了 template 和 wxs 外的整个代码引入,相当于是拷贝到 include 位置,如: [代码]<!-- index.wxml --> <include src="header.wxml" /> <view>body</view> <include src="footer.wxml" /> [代码] [代码]<!-- header.wxml --> <view>header</view> [代码] [代码]<!-- footer.wxml --> <view>footer</view> [代码] comi 利用了 import 和 include 特性简化使用流程: comi.wxml [代码]<import src="./wxParse.wxml"/> <template is="wxParse" data="{{wxParseData:article.nodes}}"/> [代码] comi.js [代码]var WxParse = require('./wxParse.js'); module.exports = function comi(md, scope) { WxParse.wxParse('article', 'md', md, scope, 5); } [代码] comi.wxss [代码]@import './wxParse.wxss'; @import './prism.wxss'; [代码] 使用时,只需要 : import [代码]comi.js[代码] include [代码]comi.wxml[代码] import [代码]comi.wxss[代码] 另外,在 omip 使用 comi 时候发现不会拷贝 include 的文件到 dist,发现 taro/omip 的正则没有去匹配 include 文件,所以,把: [代码]exports.REG_WXML_IMPORT = /<[import](.*)?src=(?:(?:'([^']*)')|(?:"([^"]*)"))/gi [代码] 改成: [代码]exports.REG_WXML_IMPORT = /<[import|inculde](.*)?src=(?:(?:'([^']*)')|(?:"([^"]*)"))/gi [代码] 搞定。 开始使用吧 Github Powered by Omi Team
2019-04-09 - 云开发,实现【最近搜索】和【大家在搜】的存储和读取逻辑
小程序【搜索】功能是很常见的,在开发公司的一个电商小程序的时候想尝试使用“云开发”做系统的后端,而首页顶端就有个搜索框,所以第一步想先解决【搜索】的一些前期工作:【最近搜索】和【大家在搜】关键词的存储和读取逻辑。 效果图如下: [图片] 效果视频请点击链接查看: https://v.vuevideo.net/share/post/-2263996935468719531 或者微信扫码观看: [图片] 为什么需要这两个功能? 【最近搜索】:可以帮助用户快速选择历史搜索记录(我这里只保存10个),搜索相同内容时减少打字操作,更加人性化; 【大家在搜】:这部分的数据可以是从所有用户的海量搜索中提取的前n名,也可以是运营者想给用户推荐的商品关键词,前者是真实的“大家在搜”,后者更像是一种推广。 具体实现 流程图: [图片] 可以结合效果图看流程图,用户触发操作有三种形式: 输入关键词,点击搜索按钮; 点击【大家在搜】列举的关键词; 点击【最近搜索】的关键词 这里发现1和2是一样的逻辑,而3更简单,所以把1和2归为一类(左边流程图),3单独一类(右边流程图) 两个流程图里都含有相同的片段(图中红色背景块部分):“找出这条记录”->“更新其时间”。故这部分可以封装成函数:updateTimeStamp()。 更新时间指的是更新该条记录的时间戳,每条记录包含以下字段:_id、keyword、openid、timeStamp。其中_id是自动生成的,openid是当前用户唯一标识,意味着关键词记录与人是绑定的,每个用户只能看到自己的搜索记录,timeStamp是触发搜索动作时的时间戳,记录时间是为了排序,即最近搜索的排在最前面。 代码结构: [图片] 云函数: cloudfunctions / searchHistory / index.js [代码]const cloud = require('wx-server-sdk') cloud.init() const db = cloud.database() exports.main = async (event, context) => { try { switch (event.type) { // 根据openid获取记录 case 'getByOpenid': return await db.collection('searchHistory').where({ openid: event.openid }).orderBy('timeStamp', 'desc').limit(10).get() break // 添加记录 case 'add': return await db.collection('searchHistory').add({ // data 字段表示需新增的 JSON 数据 data: { openid: event.openid, timeStamp: event.timeStamp, keyword: event.keyword } }) break // 根据openid和keyword找出记录,并更新其时间戳 case 'updateOfOpenidKeyword': return await db.collection('searchHistory').where({ openid: event.openid, keyword: event.keyword }).update({ data: { timeStamp: event.timeStamp } }) break // 根据openid和keyword能否找出这条记录(count是否大于0) case 'canFindIt': return await db.collection('searchHistory').where({ openid: event.openid, keyword: event.keyword }).count() break // 根据openid查询当前记录条数 case 'countOfOpenid': return await db.collection('searchHistory').where({ openid: event.openid }).count() break // 找出该openid下最早的一条记录 case 'getEarliestOfOpenid': return await db.collection('searchHistory').where({ openid: event.openid }).orderBy('timeStamp', 'asc').limit(1).get() break // 根据最早记录的id,删除这条记录 case 'removeOfId': return await db.collection('searchHistory').where({ _id: event._id }).remove() break // 删除该openid下的所有记录 case 'removeAllOfOpenid': return await db.collection('searchHistory').where({ openid: event.openid }).remove() break } } catch (e) { console.error('云函数【searchHistory】报错!!!', e) } } [代码] cloudfunctions / recommendedKeywords / index.js [代码]const cloud = require('wx-server-sdk') cloud.init() const db = cloud.database() exports.main = async (event, context) => await db.collection('recommendedKeywords') .orderBy('level', 'asc') .limit(10) .get() [代码] cloudfunctions / removeExpiredSearchHistory / config.json [代码]// 该定时触发器被设置成每天晚上23:00执行一次 index.js (删除3天前的数据) { "triggers": [ { "name": "remove expired search history", "type": "timer", "config": "0 0 23 * * * *" } ] } [代码] cloudfunctions / removeExpiredSearchHistory / index.js [代码]const cloud = require('wx-server-sdk') cloud.init() const db = cloud.database() const _ = db.command let timeStamp = new Date().getTime() // 删除3天前的数据 let duration = timeStamp - 1000 * 60 * 60 * 24 * 3 exports.main = async (event, context) => { try { let arr = await db.collection('searchHistory').where({ timeStamp: _.lt(duration) }).get() let idArr = arr.data.map(v => v._id) console.log('idArr=', idArr) for (let i = 0; i < idArr.length; i++) { await db.collection('searchHistory').where({ _id: idArr[i] }).remove() } } catch (e) { console.error(e) } } [代码] search.js 关键代码: // 输入关键词,点击搜索 [代码] tapSearch: function () { let that = this if (that.data.openid) { // 只为已登录的用户记录搜索历史 that.tapSearchOrRecommended(that.data.keyword, that.data.openid) } else { // 游客直接跳转 wx.navigateTo({ url: `../goods-list/goods-list?keyword=${that.data.keyword}`, }) } }, [代码] // 点击推荐的关键词 [代码]tabRecommended: function (e) { let that = this let keyword = e.currentTarget.dataset.keyword that.setData({ keyword }) if (that.data.openid) { // 只为已登录的用户记录搜索历史 that.tapSearchOrRecommended(keyword, that.data.openid) } else { // 游客直接跳转 wx.navigateTo({ url: `../goods-list/goods-list?keyword=${keyword}`, }) } }, [代码] // 点击历史关键词 [代码]tabHistory: function (e) { let that = this let keyword = e.currentTarget.dataset.keyword wx.navigateTo({ url: `../goods-list/goods-list?keyword=${keyword}`, }) that.updateTimeStamp(keyword, that.data.openid) }, [代码] // 获取历史记录和推荐 [代码]getHistoryAndRecommended: function () { let that = this try { // 只为已登录的用户获取搜索历史 if (that.data.openid) { let searchHistoryNeedUpdate = app.globalData.searchHistoryNeedUpdate const searchHistory = wx.getStorageSync('searchHistory') // 如果本地有历史并且还没有更新的历史 if (searchHistory && !searchHistoryNeedUpdate) { console.log('本地有搜索历史,暂时不需要更新') that.setData({ searchHistory }) } else { console.log('需要更新(或者本地没有搜索历史)') wx.cloud.callFunction({ name: 'searchHistory', data: { type: 'getByOpenid', openid: that.data.openid }, success(res) { console.log('云函数获取关键词记录成功') let searchHistory = [] for (let i = 0; i < res.result.data.length; i++) { searchHistory.push(res.result.data[i].keyword) } that.setData({ searchHistory }) wx.setStorage({ key: 'searchHistory', data: searchHistory, success(res) { console.log('searchHistory本地存储成功') // wx.stopPullDownRefresh() app.globalData.searchHistoryNeedUpdate = false }, fail: console.error }) }, fail: console.error }) } } // 获取推荐关键词 wx.cloud.callFunction({ name: 'recommendedKeywords', success(res) { console.log('云函数获取推荐关键词记录成功') let recommendedKeywords = [] for (let i = 0; i < res.result.data.length; i++) { recommendedKeywords.push(res.result.data[i].keyword) } that.setData({ recommendedKeywords }) }, fail: console.error }) } catch (e) { fail: console.error } }, [代码] // 添加该条新记录(tapSearchOrRecommended()要用到它两次) [代码]addRecord: function (keyword, timeStamp) { let that = this // 【添加】 wx.cloud.callFunction({ name: 'searchHistory', data: { type: 'add', openid: that.data.openid, keyword, timeStamp }, success(res) { console.log('云函数添加关键词成功') app.globalData.searchHistoryNeedUpdate = true }, fail: console.error }) }, [代码] // 根据openid和keyword找出记录,并更新其时间戳 [代码]updateTimeStamp: function (keyword, openid) { this.setData({ keyword }) let timeStamp = new Date().getTime() wx.cloud.callFunction({ name: 'searchHistory', data: { type: 'updateOfOpenidKeyword', openid, keyword, timeStamp, }, success(res) { console.log('云函数更新关键词时间戳成功') app.globalData.searchHistoryNeedUpdate = true }, fail: console.error }) }, [代码] // 输入关键词,点击搜索或者点击推荐的关键词 [代码]tapSearchOrRecommended: function (keyword, openid) { let that = this if (!keyword) { wx.showToast({ icon: 'none', title: '请输入商品关键词', }) setTimeout(function () { that.setData({ isFocus: true }) }, 1500) return false } wx.navigateTo({ url: `../goods-list/goods-list?keyword=${keyword}`, }) let timeStamp = new Date().getTime() // 【根据openid和keyword能否找出这条记录(count是否大于0)】 wx.cloud.callFunction({ name: 'searchHistory', data: { type: 'canFindIt', openid, keyword }, success(res) { console.log('res.result.total=', res.result.total) if (res.result.total === 0) { // 集合中没有 // 【根据openid查询当前记录条数】 wx.cloud.callFunction({ name: 'searchHistory', data: { type: 'countOfOpenid', openid }, success(res) { // 记录少于10条 if (res.result.total < 10) { // 【添加】 that.addRecord(keyword, timeStamp) } else { // 【找出该openid下最早的一条记录】 wx.cloud.callFunction({ name: 'searchHistory', data: { type: 'getEarliestOfOpenid', openid }, success(res) { console.log('云函数找出最早的一条关键词成功', res.result.data[0]) let _id = res.result.data[0]._id // 【根据最早记录的id,删除这条记录】 wx.cloud.callFunction({ name: 'searchHistory', data: { type: 'removeOfId', _id }, success(res) { console.log('云函数删除最早的一条关键词成功') // 【添加】 that.addRecord(keyword, timeStamp) }, fail: console.error }) }, fail: console.error }) } }, fail: console.error }) } else { // 【根据openid和keyword找出记录,并更新其时间戳】 that.updateTimeStamp(keyword, openid) } }, fail: console.error }) } [代码]
2019-03-28 - 如何打造一份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 - 云开发xWePY,快速实现Linux命令查询小程序
作者:白宦成 大家好,今天我来为大家分享一下, Linux 命令查询小程序中的 WePY 云开发实践。 [图片] [图片] [图片] Why WePY 首先,先分享一下为什么要选择 WePY ? 在项目开始进行选型的时候,我可选的底层框架有 WePy、MPVue、Taro、MinUI,这些框架都是工程化做得很好的框架,可以帮助小程序项目长期进行维护。其中,Taro 因为采用的是我所不熟悉的 React ,所以从一开始就被排除。MPVue 我看了以后,他更多是给 Web 开发者提供小程序转化工具,而不是给小程序开发者提供类 Vue 工具,所以,也被我排除。 MinUI 由于其本身仅仅是提供了组件化的方案和 npm 、ES6/ES7 的支持,其他的命令依然要延续使用小程序的函数,并没有提供更多的支持,整个生态也一般,所以就排除掉了 MinUI。 到最后,我选择了 WePY 。在下手之前,我研究了一下 WePY,来看看 WePY 中都有哪些优点。 总的来说,我认为 WePY 的优点如下: 提供了类似 Vue 的组件化方案:组件化开发可以提升项目的可维护程度,随着你开发周期的变长,组件化会非常大的影响你的开发体验。 提供了 ES6/ES7 语法的支持:JavaScript 为人诟病的 Callback 在 ES6、ES7 中有了更加优雅的实现。 提供了 Vue 的生态:和 MinUI 的孤军奋战不同,WePY有很多 Vue 社区生态的产品,比如 WePY-Redux、RxWX 等一系列 Vue 下,大家习惯使用的工具,这使得开发的流程更加顺畅,开发体验也更加一致。 对原生 API 的优化:在小程序官方提供的接口中,很多都是提供的 Callback 模式,并不提供 Promise ,我们在使用时往往需要自己再重新包一层,比较麻烦。在 WePY 当中, WePY 官方帮我们封装好了一层,你可以直接使用 WePY 所封装好的方法,减少了封装的工作量。 Vue 习惯的数据设定:在 WePY 中,你可以使用 [代码]this.xxx=xxx[代码] 的语法进行赋值操作,相比于原生的 setData 方法,有更加舒适的语法,可维护性也更高。 提供了 computed 方法:在开发小程序的时候,我们难免要对数据进行格式化,在传统的小程序开发中,我们需要对数据进行 map ,再进行修改,但是用了 WePY 以后,我们可以使用 computed 计算属性来进行数据的格式化和调整,大大的提升了代码的可读性。 上述是我所看重的 WePY 优势,接下来,我来说一说如何在 WePY 中使用云开发。 云开发 in WePY 我写过很多小程序,也讲过一些小程序课程,经常会有人问我,XXX可以用在XXX里么,放在这个场景中,就是云开发可以用在 WePY 中么? 答案当然是肯定的。 看待这个问题,你应该首先搞清楚,云开发所提供的到底是什么? 云开发提供的是数据存储、文件存储和计算能力 和 WePY 的定位提供微信小程序组件化开发的能力并不冲突,所以, WePY 和云开发并不冲突,你可以在 WePY 中使用云开发。 在 WePY 项目中启用云开发 由于 WePY 本身并没有提供云开发的模板(不过你现在可以使用 [代码]wepy init cloudkits/wepy-tcb-demo[代码] 命令来初始化一个包含了云开发示例的 WePY 项目),所以,我们需要自己在项目中添加云开发。 云开发本身而言,是集成在 [代码]wx.[代码] 的 namespace 内的,所以无需配置可以直接使用 [代码]wx.cloud.xxx[代码] 来调用云开发的各项命令。此外,比较特殊的是,你需要指定一下云函数目录,来确保微信小程序开发者工具能够识别出云函数目录。 此处需要注意的是, 因为云开发的命令本身就支持 Promise 和 Callback ,所以你可以直接使用 [代码]wx.cloud[代码] 来调用,而不是使用 [代码]wepy.cloud[代码] 来调用。WePY 官方也没有针对云开发进行再一次的封装。 你可以在小程序项目的根目录创建一个新的目录 cloudfunctions ,然后在 [代码]project.config.json[代码] 中添加一个新的配置项目 [代码]cloudfunctionRoot[代码],并将其值设置为 [代码]cloudfunctions[代码],这样,微信小程序开发者工具就能够识别出这个目录是云函数的目录,并为其加上特殊的目录名。 此处需要注意的是,云函数应当放在小程序的源码目录 src 之外,不然会导致编译报错。我试图寻找 wepy.config.js 的中关于屏蔽编译检查目录的配置项目,但是没有找到,所以我直接将这个目录放在了项目根目录,云函数和小程序源码的 src 同级。 这样,你就完成了 WePY 中的小程序·云开发的引用。 在开发过程中踩过的坑 this 赋值应先设置 data 使用 WePY 开发时,我们使用 [代码]this.xxx[代码] 来修改数据的值,但是在我一开始开发的时候,遇见的第一个问题时,使用 [代码]this.xxx[代码] 无法设置数据的值,在小程序界面中无法获取到对应的值。 后续才发现,原来如果你希望由 WePY 替你更新和管理数据,你需要将要传递到页面的数据放在页面实例中的 data 对象中,这样 WePY 才会帮你更新和管理数据。由于在文档中并没有注明这一点,所以我踩在了坑里。 后续对 WePY 进行分析后,理解了这样的做法,由于 WePY 中没有使用 setData,而是直接调用 this.xxx 来进行修改,那么 WePY 就需要知道哪些变量应该发送到页面,否则,将所有 this 中的数据都传递到页面中,将会导致传递的时间过长,容易让小程序退出,这时,使用 data 来限定数据的方法就可以理解了。 如何处理纯移动端数据的管理? 截止到目前,云开发并没有提供除了微信小程序官方控制台以外的管理方式,这就使得我们在构建应用的时候备受掣肘。 为了更好的提供服务,我们决定修改产品的模式。一开始我们考虑用户提交翻译,团队进行审核的模式,但是考虑到没有管理端和开发成本的问题。我们决定调整一下模式,改为社区自净化。我们完全开放编辑的能力,任何用户都可以提交数据。同时,也可以在国内实践一个完全由社区维护的应用。 但是,这种任何人都可以提交数据很有可能被人所利用,所以,我们引入了微信小程序官方提供的内容安全接口,来进行文本的安全检测,从而,尽可能的规避一些违法违规内容对小程序的影响。 [图片] 如果你用这个接口,你就会知道,接口的调用时需要使用 [代码]access_token[代码],而微信的 [代码]access_token[代码] 获取接口既有发起调用的地址限制(不能在小程序中调用),也有接口请求频率的限制(请求过快可能会导致无法获取到 Token),因此,我们决定使用云函数来处理这部分的功能。 我们在云函数内使用 [代码]got[代码] 这个库来请求微信提供的接口,进行 [代码]access_token[代码] 的获取,以及内容安全的检测。并且,为了确保 [代码]access_token[代码] 的请求不会频率过快,所以我们加入了一些代码,来进行 token 的缓存。 [代码] const result = await cache.get(); // cache 为对应 collection 的引用 const now = (new Date).valueOf(); const nextTime = now + 5400000; let accessToken = '' if (!result.data.length){ console.log("进入初次获取的流程") const result = await got(accessTokenUrl) accessToken = JSON.parse(result.body).access_token await cache.add({ data: { token: accessToken, time: nextTime } }) }else{ if (result.data[0].time > now) { console.log("已有 token 有效") accessToken = result.data[0].token } else { console.log("已有 token 无效") const tokenResult = await got(accessTokenUrl) accessToken = JSON.parse(tokenResult.body).access_token await cache.doc(result.data[0]._id).update({ data:{ token: accessToken, time: nextTime } }) } } [代码] 通过上述代码,实现了在云数据库中存储一个 token ,并比对其过期时间,如果发现 token 即将过期,就更新 token ,确保可以正常请求。 总结 回顾整个小程序的开发过程,WePY 的便利使得整个开发的过程无比的流畅,云开发的快速迭代的优势,帮助整个应用快速上线。Linux 小程序到正式发布时,总体的开时长不超过 24 小时!
2019-03-07 - Taro + 小程序云开发实战|日语用例助手
原创: Evont 前言小程序开放了云开发能力,为开发者提供了一个可以很快速构建小程序后端服务的能力,作为一名对新技术不倒腾不快的前端,对此也是很感兴趣的。 Taro 是凹凸实验室推出的,基于React 语法规范的多端开发解决方案,较之于mpvue或者wepy,由于年轻,坑还比较多,但是很适合我这种倾向用React 开发的人。 我结合这两者,使用cheerio和superagent 抓取了用例.jp, 开发了一个《日语用例助手》。 入门踩坑1.云开发篇1.1 环境搭建云开发可以通过下列两种方式创建: 1.使用quickstart(云开发快速启动模版)创建项目:[图片]这种方式会在目录下同时创建名为miniprogram ,带有云开发调用范例的小程序基础模板和名为cloudfuntions 的存放云函数的目录, 由此即可开始全新的项目。 2.基于现有的小程序使用云开发: [图片] 1.2 云函数编写使用微信开发者工具在云函数目录下创建一个云函数时,会根据名称创建一个目录,目录中包含一个index.js 和package.json。 在小程序中使用如下方式调用云函数: [代码]wx.cloud.callFunction({[代码] [代码] name: '云函数名称',[代码] [代码] data: {[代码] [代码] key1: 'value1',[代码] [代码] key2: 'value2'[代码] [代码] }[代码] [代码]}).then((res) => {[代码] [代码] console.log(res);[代码] [代码]}).catch((e) => {[代码] [代码] console.log(e);[代码] [代码]});[代码] index.js的入口函数如下所示: [代码]//云函数入口函数[代码] [代码]exports.main = async (event, context) => {[代码] [代码] // 参数获取在event 中获取,如使用上面的调用函数后,获取data使用 event.key1、event.key2即可[代码] [代码] const { key1, key2 } = event;[代码] [代码] return { query: { key1, key2 } }[代码] [代码]}[代码] 每个云函数可视为一个单独的服务,如果需要安装第三方依赖,只需要在该目录点击右键,选择 [代码]在终端中打开[代码], 并 [代码]npm install[代码]依赖即可。 需要注意的是,每个云函数都是独立的,所需要的依赖都需要在对应的目录下进行 [代码]npm install[代码],但这样就会使得项目变得十分庞大且不优雅。因此,接下来我介绍一下tcb-router。 1.3 使用tcb-router管理路由tcb-router 是腾讯云团队开发的,基于 koa 风格的小程序·云开发云函数轻量级类路由库,主要用于优化服务端函数处理逻辑。 使用tcb-router的方法很简单: [代码]const TcbRouter = require('tcb-router');[代码] [代码]exports.main = (event, context) => {[代码] [代码] const app = new TcbRouter({ event });[代码] [代码] app.router('路由名称', async (ctx) => {[代码] [代码] //原有的event需要通过ctx._req.event 获取[代码] [代码] const { param1, param2 } = ctx._req.event;[代码] [代码] ctx.body = { key1: value1 };[代码] [代码] });[代码] [代码]})[代码] 此时小程序的调用方式也需要改成: [代码]wx.cloud.callFunction({[代码] [代码] name: '云函数名称',[代码] [代码] data: {[代码] [代码] $url: '路由名称',[代码] [代码] // 其他数据[代码] [代码] param1: 'test1',[代码] [代码] param2: 'test2'[代码] [代码] },[代码] [代码] success: res => {},[代码] [代码] fail: err => {[代码] [代码] console.error(`[云函数] [${action}] 调用失败`, err)[代码] [代码] }[代码] [代码]})[代码] 2.Taro篇2.1 环境搭建[代码]npm install -g @tarojs/cli[代码] [代码]taro init myApp[代码] 2.2 遇到的坑1.API支持不足由于Taro 对微信的一些新api 并没有支持到,比如使用云开发时需要用到 [代码]wx.cloud[代码],Taro 并没有支持,但亲测是可以直接使用 [代码]wx[代码] 变量,但是会被eslint 提醒,看着十分不悦,可以在 [代码].eslintrc[代码] 文件中增加以下代码: [代码]"globals": {[代码] [代码] "wx": true[代码] [代码]},[代码] 2.不能使用 Array#map 之外的方法操作 JSX 数组。 3.不允许在 JSX 参数(props)中传入 JSX 元素(taro/no-jsx-in-props)。 3.爬虫篇3.1 superagentsuperagent 是一个非常实用的http请求模块,用来抓取网页十分有用,使用也十分简单,以下是我在抓取 [代码]yourei.jp[代码] 时使用的代码: [代码]// const superagent = require('superagent');[代码] [代码]// ...[代码] [代码]function crawler(url, cb) {[代码] [代码] return new Promise((resolve, reject) => {[代码] [代码] superagent.get(url).set({[代码] [代码] 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36'[代码] [代码] }).end(function (err, res) {[代码] [代码] if (err) {[代码] [代码] reject(err);[代码] [代码] return;[代码] [代码] }[代码] [代码] resolve(res);[代码] [代码] });[代码] [代码] });[代码] [代码]}[代码] 3.2 cheeriocheerio 是一个轻型灵活,类jQuery的对HTML元素分析操作的工具。在进行一些server端渲染的页面以及一些简单的小页面的爬取时,cheerio十分好用且高效。 在使用 [代码]superagent[代码] 抓取了网页内容后,可以使用如下方式解析页面代码: [代码] // const cheerio = require('cheerio');[代码] [代码] // ...[代码] [代码] const result = crawler(apiUrl).then((res) => {[代码] [代码] // 使用load 之后,$ 即可同jquery 一样使用选择器来选择元素了[代码] [代码] const $ = cheerio.load(res.text);[代码] [代码] const categories = [];[代码] [代码] $('[data-toggle]').each((i, ele) => {[代码] [代码] // 可以使用.text()、.html() 等方式获取元素的内容[代码] [代码] categories.push($(ele).attr('href'));[代码] [代码] });[代码] [代码] return {[代码] [代码] list: categories,[代码] [代码] };[代码] [代码] });[代码] 总结1.Taro如果你是React 开发者,需要开发多端小程序,或者原有React 项目想迁移到小程序,Taro 是个不错的选择,但还有很多坑没有填好,希望它的发展越来越好。 2.云开发如果你是个人开发者,想尝试小程序开发又不想或者难以自己搭建服务器,云开发是个好选择,容易上手且十分敏捷。
2019-03-25 - 拥抱更底层技术——从CSS变量到Houdini
0. 前言 平时写CSS,感觉有很多多余的代码或者不好实现的方法,于是有了预处理器的解决方案,主旨是write less &do more。其实原生css中,用上css变量也不差,加上bem命名规则只要嵌套不深也能和less、sass的嵌套媲美。在一些动画或者炫酷的特效中,不用js的话可能是用了css动画、svg的animation、过渡,复杂动画实现用了js的话可能用了canvas、直接修改style属性。用js的,然后有没有想过一个问题:“要是canvas那套放在dom上就爽了”。因为复杂的动画频繁操作了dom,违背了倒背如流的“性能优化之一:尽量少操作dom”的规矩,嘴上说着不要,手倒是很诚实地[代码]ele.style.prop = <newProp>[代码],可是要实现效果这又是无可奈何或者大大减小工作量的方法。 我们都知道,浏览器渲染的流程:解析html和css(parse),样式计算(style calculate),布局(layout),绘制(paint),合并(composite),修改了样式,改的环节越深代价越大。js改变样式,首先是操作dom,整个渲染流程马上重新走,可能走到样式计算到合并环节之间,代价大,性能差。然后痛点就来了,浏览器有没有能直接操作前面这些环节的方法呢而不是依靠js?有没有方法不用js操作dom改变style或者切换class来改变样式呢? 于是就有CSS Houdini了,它是W3C和那几个顶级公司的工程师组成的小组,让开发者可以通过新api操作CSS引擎,带来更多的自由度,让整个渲染流程都可以被开发者控制。上面的问题,不用js就可以实现曾经需要js的效果,而且只在渲染过程中,就已经按照开发者的代码渲染出结果,而不是渲染完成了再重新用js强行走一遍流程。 关于houdini最近动态可点击这里 上次CSS大会知道了有Houdini的存在,那时候只有cssom,layout和paint api。前几天突然发现,Animation api也有了,不得不说,以后很可能是Houdini遍地开花的时代,现在得进一步了解一下了。一句话:这是css in js到js in css的转变 1. CSS变量 如果你用less、sass只为了人家有变量和嵌套,那用原生css也是差不多的,因为原生css也有变量: 比如定义一个全局变量–color(css变量双横线开头) [代码]:root { --color: #f00; } [代码] 使用的时候只要var一下 [代码].f{ color: var(--color); } [代码] 我们的html: [代码]<div class="f">123</div> [代码] 于是,红色的123就出来了。 css变量还和js变量一样,有作用域的: [代码]:root { --color: #f00; } .f { --color: #aaa } .g{ color: var(--color); } .ft { color: var(--color); } [代码] html: [代码] <div className="f"> <div className="ft">123</div> </div> <div className=""> <div className="g">123</div> </div> [代码] 于是,是什么效果你应该也很容易就猜出来了: [图片] css能搞变量的话,我们就可以做到修改一处牵动多处的变动。比如我们做一个像准星一样的四个方向用准线锁定鼠标位置的效果: [图片] 用css变量的话,比传统一个个元素设置style优雅多了: [代码]<div id="shadow"> <div class="x"></div> <div class="y"></div> <div class="x_"></div> <div class="y_"></div> </div> [代码] [代码] :root{ --x: 0px; --y: 0px; } body{ margin: 0 } #shadow{ width: 50%; height: 600px; border: #000 1px solid; position: relative; margin: 0; } .x, .y, .x_, .y_ { position: absolute; border: #f00 2px solid; } .x { top: 0; left: var(--x); height: 20px; width: 0; } .y { top: var(--y); left: 0; height: 0; width: 20px; } .x_ { top: 600px; left: var(--x); height: 20px; width: 0; } .y_ { top: var(--y); left: 100%; height: 0; width: 20px; } [代码] [代码]const style = document.documentElement.style shadow.addEventListener('mousemove', e => { style.setProperty(`--x`, e.clientX + 'px') style.setProperty(`--y`, e.clientY + 'px') }) [代码] 那么,对于github的404页面这种内容和鼠标位置有关的页面,思路是不是一下子就出来了 2. CSS type OM 都有DOM了,那CSSOM也理所当然存在。我们平时改变css的时候,通常是直接修改style或者切换类,实际上就是操作DOM来间接操作CSSOM,而type om是一种把css的属性和值存在attributeStyleMap对象中,我们只要直接操作这个对象就可以做到之前的js改变css的操作。另外一个很重要的点,attributeStyleMap存的是css的数值而不是字符串,而且支持各种算数以及单位换算,比起操作字符串,性能明显更优。 接下来,基本脱离不了window下的CSS这个属性。在使用的时候,首先,我们可以采取渐进式的做法: [代码]if('CSS' in window){...}[代码] 2.1 单位 [代码]CSS.px(1); // 1px 返回的结果是:CSSUnitValue {value: 1, unit: "px"} CSS.number(0); // 0 比如top:0,也经常用到 CSS.rem(2); //2rem new CSSUnitValue(2, 'percent'); // 还可以用构造函数,这里的结果就是2% // 其他单位同理 [代码] 2.2 数学运算 自己在控制台输入CSSMath,可以看见的提示,就是数学运算 [代码]new CSSMathSum(CSS.rem(10), CSS.px(-1)) // calc(10rem - 1px),要用new不然报错 new CSSMathMax(CSS.px(1),CSS.px(2)) // 顾名思义,就是较大值,单位不同也可以进行比较 [代码] 2.3 怎么用 既然是新的东西,那就有它的使用规则。 获取值[代码]element.attributeStyleMap.get(attributeName)[代码],返回一个CSSUnitValue对象 设置值[代码]element.attributeStyleMap.set(attributeName, newValue)[代码],设置值,传入的值可以是css值字符串或者是CSSUnitValue对象 当然,第一次get是返回null的,因为你都没有set过。“那我还是要用一下getComputedStyle再set咯,这还不是和之前的差不多吗?” 实际上,有一个类似的方法:[代码]element.computedStyleMap[代码],返回的是CSSUnitValue对象,这就ok了。我们拿前面的第一部分CSS变量的代码测试一波 [代码]document.querySelector('.x').computedStyleMap().get('height') // CSSUnitValue {value: 20, unit: "px"} document.querySelector('.x').computedStyleMap().set('height', CSS.px(0)) // 不见了 [代码] 3. paint API paint、animation、layout API都是以worker的形式工作,具体有几个步骤: 建立一个worker.js,比如我们想用paint API,先在这个js文件注册这个模块registerPaint(‘mypaint’, class),这个class是一个类下面具体讲到 在html引入CSS.paintWorklet.addModule(‘worker.js’) 在css中使用,background: paint(mypaint) 主要的逻辑,全都写在传入registerPaint的class里面。paint API很像canvas那套,实际上可以当作自己画一个img。既然是img,那么在所有的能用到图片url的地方都适合用paint API,比如我们来自己画一个有点炫酷的背景(满屏随机颜色方块)。有空的话可以想一下js怎么做,再对比一下paint API的方案。 [图片] [代码]// worker.js class RandomColorPainter { // 可以获取的css属性,先写在这里 // 我这里定义宽高和间隔,从css获取 static get inputProperties() { return ['--w', '--h', '--spacing']; } /** * 绘制函数paint,最主要部分 * @param {PaintRenderingContext2D} ctx 类似canvas的ctx * @param {PaintSize} PaintSize 绘制范围大小(px) { width, height } * @param {StylePropertyMapReadOnly} props 前面inputProperties列举的属性,用get获取 */ paint(ctx, PaintSize, props) { const w = (props.get('--w') && +props.get('--w')[0].trim()) || 30; const h = (props.get('--h') && +props.get('--h')[0].trim()) || 30; const spacing = +props.get('--spacing')[0].trim() || 10; for (let x = 0; x < PaintSize.width / w; x++) { for (let y = 0; y < PaintSize.height / h; y++) { ctx.fillStyle = `#${Math.random().toString(16).slice(2, 8)}` ctx.beginPath(); ctx.rect(x * (w + spacing), y * (h + spacing), w, h); ctx.fill(); } } } } registerPaint('randomcolor', RandomColorPainter); [代码] 接着我们需要引入该worker: [代码]CSS && CSS.paintWorklet.addModule('worker.js');[代码] 最后我们在一个class为paint的div应用样式: [代码].paint{ background-image: paint(randomcolor); width: 100%; height: 600px; color: #000; --w: 50; --h: 50; --spacing: 10; } [代码] 再想想用js+div,是不是要先动态生成n个,然后各种计算各种操作dom,想想就可怕。如果是canvas,这可是canvas背景,你又要在上面放一个div,而且还要定位一波。 注意: worker是没有window的,所以想搞动画的就不能内部消化了。不过可以靠外面的css变量,我们用js操作css变量可以解决,也比传统的方法优雅 4. 自定义属性 支持情况 点击这里查看 首先,看一下支持度,目前浏览器并没有完全稳定使用,所以需要跟着它的提示:[代码]Experimental Web Platform features” on chrome://flags[代码],在chrome地址栏输入[代码]chrome://flags[代码]再找到[代码]Experimental Web Platform features[代码]并开启。 [代码]CSS.registerProperty({ name: '--myprop', //属性名字 syntax: '<length>', // 什么类型的单位,这里是长度 initialValue: '1px', // 默认值 inherits: true // 会不会继承,true为继承父元素 }); [代码] 说到继承,我们回到前面的css变量,已经说了变量是区分作用域的,其实父作用域定义变量,子元素使用该变量实际上是继承的作用。如果[代码]inherits: true[代码]那就是我们看见的作用域的效果,如果不是true则不会被父作用域影响,而且取默认值。 这个自定义属性,精辟在于,可以用永久循环的animation驱动一次性的transform。换句话说,我们如果用了css变量+transform,可以靠js改变这个变量达到花俏的效果。但是,现在不需要js,只要css内部消化,transform成为永动机。 [代码]// 我们先注册几种属性 ['x1','y1','z1','x2','y2','z2'].forEach(p => { CSS.registerProperty({ name: `--${p}`, syntax: '<angle>', inherits: false, initialValue: '0deg' }); }); [代码] 然后写个样式 [代码]#myprop, #myprop1 { width: 200px; border: 2px dashed #000; border-bottom: 10px solid #000; animation:myprop 3000ms alternate infinite ease-in-out; transform: rotateX(var(--x2)) rotateY(var(--y2)) rotateZ(var(--z2)) } [代码] 再来看看我们的动画,为了眼花缭乱,加了第二个改了一点数据的动画 [代码]@keyframes myprop { 25% { --x1: 20deg; --y1: 30deg; --z1: 40deg; } 50% { --x1: -20deg; --z1: -40deg; --y1: -30deg; } 75% { --x2: -200deg; --y2: 130deg; --z2: -350deg; } 100% { --x1: -200deg; --y1: 130deg; --z1: -350deg; } } @keyframes myprop1 { 25% { --x1: 20deg; --y1: 30deg; --z1: 40deg; } 50% { --x2: -200deg; --y2: 130deg; --z2: -350deg; } 75% { --x1: -20deg; --z1: -40deg; --y1: -30deg; } 100% { --x1: -200deg; --y1: 130deg; --z1: -350deg; } } [代码] html就两个div: [代码] <div id="myprop"></div> <div id="myprop1"></div> [代码] 效果是什么呢,自己可以跑一遍看看,反正功能很强大,但是想象力限制了发挥。 自己动手改的时候注意一下,动画关键帧里面,不能只存在1,那样子就不能驱动transform了,做不到永动机的效果,因为我的rotate写的是 rotateX(var(–x2))。接下来随意发挥吧 最后 再啰嗦一次 关于houdini最近动态可点击这里 关于houdini在浏览器的支持情况 ENJOY YOURSELF!!!
2019-03-25 - setData 学问多
为什么不能频繁 setData 先科普下 setData 做的事情: 在数据传输时,逻辑层会执行一次 JSON.stringify 来去除掉 setData 数据中不可传输的部分,之后将数据发送给视图层。同时,逻辑层还会将 setData 所设置的数据字段与 data 合并,使开发者可以用 this.data 读取到变更后的数据。 因此频繁调用,视图会一直更新,阻塞用户交互,引发性能问题。 但频繁调用是常见开发场景,能不能频繁调用的同时,视图延迟更新呢? 参考 Vue,我们能知道,Vue 每次赋值操作并不会直接更新视图,而是缓存到一个数据更新队列中,异步更新,再触发渲染,此时多次赋值,也只会渲染一次。 [代码]let newState = null; let timeout = null; const asyncSetData = ({ vm, newData, }) => { newState = { ...newState, ...newData, }; clearTimeout(timeout); timeout = setTimeout(() => { vm.setData({ ...newState, }); newState = null }, 0); }; [代码] 由于异步代码会在同步代码之后执行,因此,当你多次使用 asyncSetData 设置 newState 时,newState 都会被缓存起来,并异步 setData 一次 但同时,这个方案也会带来一个新的问题,同步代码会阻塞页面的渲染。 同步代码会阻塞页面的渲染的问题其实在浏览器中也存在,但在小程序中,由于是逻辑、视图双线程架构,因此逻辑并不会阻塞视图渲染,这是小程序的优点,但在这套方案将会丢失这个优点。 鱼与熊掌不可兼得也! 对于信息流页面,数据过多怎么办 单次设置的数据不能超过 1024kB,请尽量避免一次设置过多的数据 通常,我们拉取到分页的数据 newList,添加到数组里,一般是这么写: [代码]this.setData({ list: this.data.list.concat(newList) }) [代码] 随着分页次数的增加,list 会逐渐增大,当超过 1024 kb 时,程序会报 [代码]exceed max data size[代码] 错误。 为了避免这个问题,我们可以直接修改 list 的某项数据,而不是对整个 list 重新赋值: [代码]let length = this.data.list.length; let newData = newList.reduce((acc, v, i)=>{ acc[`list[${length+i}]`] = v; return acc; }, {}); this.setData(newData); [代码] 这看着似乎还有点繁琐,为了简化操作,我们可以把 list 的数据结构从一维数组改为二维数组:[代码]list = [newList, newList][代码], 每次分页,可以直接将整个 newList 赋值到 list 作为一个子数组,此时赋值方式为: [代码]let length = this.data.list.length; this.setData({ [`list[${length}]`]: newList }); [代码] 同时,模板也需要相应改成二重循环: [代码]<block wx:for="{{list}}" wx:for-item="listItem" wx:key="{{listItem}}"> <child wx:for="{{listItem}}" wx:key="{{item}}"></child> </block> [代码] 下拉加载,让我们一夜回到解放前 信息流产品,总避免不了要做下拉加载。 下拉加载的数据,需要插到 list 的最前面,所以我们应该这样做: [代码]this.setData({ `list[-1]`: newList }) [代码] 哦不,对不起,上面是错的,应该是下面这样: [代码]this.setData({ list: this.data.list.unshift(newList) }); [代码] 这下好,又是一次性修改整个数组,一夜回到解放前… 为了解决这个问题,这里需要一点奇淫巧技: 为下拉加载维护一个单独的二维数组 pullDownList 在渲染时,用 wxs 将 pullDownList reverse 一下 此时,当下拉加载时,便可以只修改数组的某个子项: [代码]let length = this.data.pullDownList.length; this.setData({ [`pullDownList[${length}]`]: newList }); [代码] 关键在于渲染时候的反向渲染: [代码]<wxs module="utils"> function reverseArr(arr) { return arr.reverse() } module.exports = { reverseArr: reverseArr } </wxs> <block wx:for="{{utils.reverseArr(pullDownList)}}" wx:for-item="listItem" wx:key="{{listItem}}"> <child wx:for="{{listItem}}" wx:key="{{item}}"></child> </block> [代码] 问题解决! 参考资料 终极蛇皮上帝视角之微信小程序之告别 setData, 佯真愚, 2018年08月12日
2019-04-11 - 一种小成本的线下定位方案 ---2019腾讯数字文创节小程序开发有感
去年的TGC小程序,我们采用了小成本的智能印章来连接线上线下(点此查看知晓程序的报道),今年,我们利用了成本更低的ibeacon设备,来做室内定位。先放码: [图片] 介绍下今年的TGC小程序 今年的2019腾讯数字文创节(以下简称TGC)的举办地点是在世界最大的单体建筑----成都环球中心里面,整个场馆区域非常大,同时场馆内有很多商区,为了能更加突出打卡TGC的整体性,我们将整个TGC所有的场馆地点设计在一个全场地图上,玩家可以很清楚的看到所有打卡点的分布和场馆的具体位置: [图片] 每年的TGC小程序我们在尝试一些新的技术形式的加入。今年TGC整体升级为腾讯数字文创节,整个活动以展会形式为主,整个TGC共设为值四大展区-----IP主题(该主题展区内有每个游戏ip单独布置的展区)、传统文化、竞技文化和未来探索,相较于去年的形式,今年更加侧重在和传统文化进行集合,所以我们在玩法上还是和去年一样,采用打卡的方式,但是在形式上,则采用了更加适合玩家感受游戏文化和传播内容的拍照打卡。通过打卡得积分、分享打卡照邀请好友点赞得积分和积分抽奖的方式,来带动活动线下的参与以及线上的传播。整体的效果图如下: [图片] 既然是玩家参观TGC场馆打卡得积分,那么如何验证玩家是否在该打卡点内呢? 小程序ibeacon ibeacon简介 ibeacon是2013年苹果提出来的一套可用于室内定位系统的协议,它可以以指定频率广播自身信号,信号本身带有设备的数据帧,只要手机设备支持解析这个数据帧,处于ibeacon信号广播范围的手机设备可以接收到这些数据信息,详细介绍请点击这里。 小程序ibeacon API提供的数据信息包含以下部分: UUID 设备的唯一通用标识符,一般不同的厂商不同的批次,这个编号也不一样,具体是设备厂商自己设置 Major 设备的主ID,这个一般代表设备的型号,同一批次的ibeacon设备,这个编号一般也一样 Minor 设备的次ID,每隔设备的这个编号都不同,一般用来指代唯一性 Accuracy 设备的距离,单位为米 Rssi 接收到的设备信号强度 设备就是联系供应商提供的,价格是45到60左右,成本非常低,就是这个小白盒子: [图片] 首先,因为活动的举办地点是在商场,可以预见的是,商场本身会有些商家会部署ibeacon设备来做活动营销(微信摇一摇),所以如何保证小程序接收到的ibeacon设备信息就一定是我们部署的ibeacon设备发射出来的呢?答案就是通过上面提到的Major。 收到设备后,我们首先设置这一批ibeacon设备的Major为我们特定的数字,在接收到的ibeacon信息后过滤掉不是我们制定Major号的设备信息即可。通过在数据库绑定打卡地点和设备Minor的关系,玩家手机接收到ibeacon设备的信号的时候,就可以通过接收到设备的Minor号判断玩家当前是在哪个体验点。坑爹的是,设备的厂家在出厂这些设备的时候,每个设备的Major和Minor号都是随机,幸好网上有很多ibeacon设备信息查看更改软件,推荐使用 “摇一摇助手”,app store和安卓应用商店都可以下载。 [图片] ibeacon踩坑 1、通常市面上的ibeacon设备是可以设置ibeacon设备的广播频率的,默认一般是500ms。为了提升感应玩家当前所在的地点的精度,我们会去调高ibeacon设备的广播频率。设备的广播频率是可以通过专门的ibeacon设置软件调整(PS:会导致设备更加费电,但是现在的ibeacon设备基本都可以用个好几年,如果不是长期使用的话,可以不care这个),但是小程序ibeacon API读取设备广播信息的频率是系统控制,也就是说,其实我们调整设备的广播频率是起不到作用的,因为最终读取ibeacon的广播信息频率是由系统所决定的,请教过微信的同学,安卓的是500ms,iOS 这边跟着系统走,目前观测是 1秒1次。 [图片] 2、接收ibeacon信息的API需要在wx.startBeaconDiscovery成功的回调中调用才能拿到ibeacon的设备信息。 [代码]wx.startBeaconDiscovery({ uuids: ['B9407F30-F5F8-466E-AFF9-25556B57FE6D'] // ibeacon uuid }).then((res) => { wx.onBeaconUpdate(() => { console.log('onBeaconUpdate') wx.getBeacons().then((res) => { let beacons = res.beacons, len = beacons.length, i = 0, nearbyBeacons = [], if (len === 0) { return } for (; i < len; i++) { if (beacons[i]['major'] !== 700) { continue } else { if (beacons[i]['accuracy'] > 0 && beacons[i]['accuracy'] < 8) { // 读取周围ibeacon设备的精度,可根据现场情况动态调整 nearbyBeacons.push(beacons[i]['minor']) } } } let _nearbyBeaconsResult = that.data.nearbyBeaconsResult if (_nearbyBeaconsResult.length >= 4) { _nearbyBeaconsResult.shift() } _nearbyBeaconsResult.push(nearbyBeacons) that.setData({ nearbyBeaconsResult: _nearbyBeaconsResult }) }) }) }) [代码] 在实际的开发过程中,我们发现,少数ibeacon设备的信息读取到的距离信息(accuracy)会出现负数,这个是因为之前提到的设备的广播频率的原因,如果在小程序内接收到设备的广播信息恰好出现在设备的广播周期之外,那么这个设备的的信息其实算作没读取到。为了规避这个问题,我们设置一个数组,这个数组存储最近4次接收到的ibeacon数据,按照一般ibeacon的广播频率的话,也就是2s的时间内,接收到的ibeacon设备信息。同时,我们在线下会针对体验点的区域的大小的不同,部署不同数量的ibeacon设备,这样可以大大的降低玩家接收不到ibeacon信息的概率。 现场方案执行 最基础的技术方案也想对容易实现一些,但是往年的线下活动的经验告诉我们,问题往往不是出现在实现的技术方面,我们需要考虑的往往是一些非技术侧的问题: 1、现场地形复杂,在什么地方部署ibeacon设备才算合适呢,不同的展区部署多少个点才算合适? 2、ibeacon的信息接收需要依赖蓝牙,玩家的手机因不明原因无法开启蓝牙或蓝牙功能失效了,如何处理? 第一个问题,我们在活动开始的前一个多礼拜,就带着我们做好的小程序测试版本去到了活动举办地成都环球中心进行了测试。 [图片] 实地的测试主要是对每个体验点的区域进行确定,再根据体验点的大小来决定需要放置多少ibeacon设备。 第二点在有了去年的经验,其实处理起来也有成熟的方案可以去执行。TGC的每个线下体验店会有我们Part Time(场馆负责人),还会有全场的巡视人员,我们只需要做一套后备方案,在少数玩家因设备问题无法打卡时,让现场我们的工作人员赋予玩家特殊的权限,可以让玩家不需要因设备的限制进行打卡即可。所以,我们做了一套玩家扫码打卡的后备方案,在活动开始之前,我们给予管理员特殊权限,它们可以在小程序的管理端选择打卡点二维码,玩家扫一扫即可完成定位打卡,当然每个小程序二维码只能够用一次,剩下的工作就是和现场的Part time进行培训。 [图片] 小程序云 这次活动的开发排期十分紧张,后台的开发人力又无法及时跟进项目,所以这次的整个活动的开发我们十分大胆的尝试了小程序云。其实在小程序云内部测试的时候,我们就已经有预研过小程序云。 小程序云提供了云存储、云函数和数据库,提供了较为完整的云端支持,还搭配了一套基础运维体系,开发者无需关心服务器搭建和代码部署。关于一些基础的类似云函数提供了鉴权的内容啥的,这里受限篇幅也就不再阐述,可自行查看开发者文档,这里讲下我在开发过程中的小总结吧。 1、小程序云环境 每个小程序账号开通了小程序云能力后会默认得到一套云开发环境,每个小程序账号最多可以创建两个云开发环境,一个云环境用于开发环境,一个云环境用于线上环境。 小程序端只需要在小程序云初始化函数配置当前运行的环境ID即可: [代码]// app.js App({ ...... globalData: { wxCloudEnv: 'tgc-production-xxxx' // 当前运行环境ID } }) // index.js wx.cloud.init({ env: app.globalData.wxCloudEnv }) [代码] 但是小程序云上,即使云函数当前运行的云环境不一样,也需要在每个云函数上显式的配置当前运行的云环境ID,要不然可能会在线上环境的云函数也会调用到测试环境的数据库和云存储。 现在每个云环境默认是基础的资源配额,可以自行发邮件申请. 2、小程序云的权限控制 小程序云提供的API分为小程序端API和服务端API,顾名思义,一套是在小程序的代码里调用的,一套是在服务端云函数调用的,两套API都可以进行数据库操作、云函数调用和云文件的操作。 [图片] 小程序端的数据库API在添加一条数据库记录的时候,该条数据库记录会默认加上_openid字段,值为该条记录创建者(也就是用户)的openid,而如果是服务端数据库API进行同样的操作就不会带上该字段。小程序云数据库的集合默认的权限都是设置为"仅创建者和管理员可读写",在小程序端如果通过数据库API访问数据库某个集合的数据时,只能访问到用户自己在小程序上调用数据库API创建的数据,写的逻辑也是一样,另外几个权限也是很好理解。小程序云管理后台可以设置数据库的操作权限,不同的用户对数据的读写权限不同,通过这个操作,可以灵活的调整数据库中的数据使用场景。 3、小程序云的局限性 小程序云暂时还不支持数据外部调用,所以,如果运营人员需要有很强的数据配置和数据管理的功能的话,小程序云想对做起来会很吃力(预计是年后会支持,大赞小程序云的团队)。而且,我们暂时还只能使用其提供的几项能力,如果我们想继续扩展我们的应用且需要在服务器上部署一些其他的服务的话,在小程序云上暂时还是做不到的。小程序云的数据库查询支持还是较为基础的,一些例如在关系型数据库常见的连表查询啥的还不支持,所以在做一些复杂的查询的时候,为了效率通常需要再一个数据库集合中增加一些冗余字段。 感觉小程序云还有很大的发展潜力,后续这些功能应该都会逐步的开放出来,相信小程云也会变得越来越强大。 线上结合线下ibeacon的优势 1、统计当前玩家所在的区域,绘制全场的玩家分布热力图,及时做好访问量较低的现场场馆引导。 [图片] 2、成本极低。成都环球中心在周末高峰时段是可以达到5~6w的人流量,在那么大的人流量下,我们也只需要在现场近20个体验点布置将近100个设备,就能覆盖我们全部的打卡体验点,按照每个设备本身价格近45元算,整个总成本可想而知。而且设备本身可以回收继续再利用,技术方案也是可以继续复用。 总结 今年是参与TGC小程序开发的第二年了,这种线下的项目最大的挑战在于需要对接的需求方非常之多,涉及到线上、线下、供应商等等多个角色。在开发周期如此紧张的情况下又需要同时兼顾前后端开发情况,对于个人来说是个非常大的挑战,但也是一个非常好的锻炼机会。有了去年TGC小程序的参与经验,今年在线下的部分也较为顺利的执行下去,但是也还是出现了一些线下培训细节没有勾兑清楚地额沟通问题。接下来,我也会写一篇开发线下项目的经验分享,也非常欢迎和期待各位小伙伴与我们在小程序上有更多的交流合作和技术探讨。
2019-03-19