- java springboot环境下,处理获取小程序码返回的Buffer数据
文档链接:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/qr-code/wxacode.getUnlimited.html 注意请求成功时,只会返回buffer数据 可以用字节数组接收返回数据。当字节数组长度比较小时,判断为请求失败。 [代码]/** * 获取小程序码 base64编码 * * @param scene 小程序码中的参数 * @param page 小程序页面 * @return 小程序码 base64编码数据 */ public String getMiniProgramCode(String scene, String page) { Object val = redisService.getValue(ACCESS_TOKEN_REDIS_KEY); String accessToken = Optional.ofNullable(val).map(String::valueOf).orElse(null); if (StringUtils.isBlank(accessToken)) { accessToken = getAccessToken(); } HashMap<String, Object> body = new HashMap<>(); body.put("scene", scene); body.put("page", page); body.put("env_version", miniProgramConfig.getEnvVersion()); body.put("width", 114); HttpEntity<HashMap<String, Object>> httpEntity = new HttpEntity<>(body, null); ResponseEntity<byte[]> entity = restTemplate.exchange(GET_MINI_PROGRAM_CODE_URL + "?access_token=" + accessToken, HttpMethod.POST, httpEntity, byte[].class); byte[] buffer = entity.getBody(); // 成功时微信服务直接返回小程序码的二进制数据,字节数组的长度会很大(大概是60000多) if (entity.getStatusCode() != HttpStatus.OK || buffer == null || buffer.length < 200) { log.error("请求获取小程序码失败, response: {}", Optional.ofNullable(buffer).map(String::new).orElse("response is null")); throw new InternalErrorException(BusinessCode.NOT_IMPLEMENTED); } return Base64.getEncoder().encodeToString(buffer); }[代码]
2021-12-30 - 解锁小程序中使用SVG新姿势
SVG 的优势 清晰度: 可以进行放大,而不失真 更小的文件体积 可扩展性,可以动态颜色 动效 可以添加动效 在小程序中使用 目前小程序 的image标签已经支持了 svg 的显示 [代码] <image src="./xx.svg"/> [代码] 如何动态的改变 svg 属性呢? 大体思路:把svg转成 base64 然后通过 image标签 src设置图片,再动态赋值svg颜色 把svg转成base64 如下一个svg 代码文件 [代码]<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="24 24 48 48"><animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" from="0" to="360" dur="1400ms"></animateTransform><circle cx="48" cy="48" r="20" fill="none" stroke="#eeeeee" stroke-width="2" transform="translate\(0,0\)"><animate attributeName="stroke-dasharray" values="1px, 200px;100px, 200px;100px, 200px" dur="1400ms" repeatCount="indefinite"></animate><animate attributeName="stroke-dashoffset" values="0px;-15px;-125px" dur="1400ms" repeatCount="indefinite"></animate></circle></svg> [代码] 转成base64,其实就是 对这个svg进行 encodeURIComponent 得到 如下代码 [代码]%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20viewBox%3D%2224%2024%2048%2048%22%3E%3CanimateTransform%20attributeName%3D%22transform%22%20type%3D%22rotate%22%20repeatCount%3D%22indefinite%22%20from%3D%220%22%20to%3D%22360%22%20dur%3D%221400ms%22%3E%3C%2FanimateTransform%3E%3Ccircle%20cx%3D%2248%22%20cy%3D%2248%22%20r%3D%2220%22%20fill%3D%22none%22%20stroke%3D%22%23eeeeee%22%20stroke-width%3D%222%22%20transform%3D%22translate%5C(0%2C0%5C)%22%3E%3Canimate%20attributeName%3D%22stroke-dasharray%22%20values%3D%221px%2C%20200px%3B100px%2C%20200px%3B100px%2C%20200px%22%20dur%3D%221400ms%22%20repeatCount%3D%22indefinite%22%3E%3C%2Fanimate%3E%3Canimate%20attributeName%3D%22stroke-dashoffset%22%20values%3D%220px%3B-15px%3B-125px%22%20dur%3D%221400ms%22%20repeatCount%3D%22indefinite%22%3E%3C%2Fanimate%3E%3C%2Fcircle%3E%3C%2Fsvg%3E [代码] 拼接base64 [代码] data:image/svg+xml;charset=utf-8,encodeURIComponent后的代码 [代码] 在对应svg属性上动态设置颜色,比如这里用到的是填充颜色 在js文件 data中定义 color 状态 在wxml中动态渲染 [代码] <image src="data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20viewBox%3D%2224%2024%2048%2048%22%3E%3CanimateTransform%20attributeName%3D%22transform%22%20type%3D%22rotate%22%20repeatCount%3D%22indefinite%22%20from%3D%220%22%20to%3D%22360%22%20dur%3D%221400ms%22%3E%3C%2FanimateTransform%3E%3Ccircle%20cx%3D%2248%22%20cy%3D%2248%22%20r%3D%2220%22%20fill%3D%22none%22%20stroke%3D%22%23{{color}}%22%20stroke-width%3D%222%22%20transform%3D%22translate%5C(0%2C0%5C)%22%3E%3Canimate%20attributeName%3D%22stroke-dasharray%22%20values%3D%221px%2C%20200px%3B100px%2C%20200px%3B100px%2C%20200px%22%20dur%3D%221400ms%22%20repeatCount%3D%22indefinite%22%3E%3C%2Fanimate%3E%3Canimate%20attributeName%3D%22stroke-dashoffset%22%20values%3D%220px%3B-15px%3B-125px%22%20dur%3D%221400ms%22%20repeatCount%3D%22indefinite%22%3E%3C%2Fanimate%3E%3C%2Fcircle%3E%3C%2Fsvg%3E" /> [代码] [代码]注意:这里的颜色 由于是已经被编码了,所以# 已经被转义了 %23, 直接写颜色数字即可[代码] 当然你也可以 去掉%23 自己实现一个内部方法 [代码] if (color && color.startsWith('#')) { return `%23${color.slice(1)}`; } [代码] 这样其实就实现了 svg的动态渲染,可是这种写法,写在wxml中 不是特别的优雅,那么如何重构下让我们的代码看起来更优雅呢? 把 svg 单独存放 支持动态返回 动态赋值 image src 属性 svg 动态函数 loading.svg.js 文件 [代码]export const loadingSvg = (color='#ddd') =>{ const svgXml = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="24 24 48 48"><animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" from="0" to="360" dur="1400ms"></animateTransform><circle cx="48" cy="48" r="20" fill="none" stroke="${color}" stroke-width="2" transform="translate\(0,0\)"><animate attributeName="stroke-dasharray" values="1px, 200px;100px, 200px;100px, 200px" dur="1400ms" repeatCount="indefinite"></animate><animate attributeName="stroke-dashoffset" values="0px;-15px;-125px" dur="1400ms" repeatCount="indefinite"></animate></circle></svg>` return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgXml)}` } [代码] 逻辑层引入,setData [代码] onLoad(){ const { loadingSvg } = require('./loading.svg.js') const svgImg = loadingSvg('#eee') this.setData({svgImg}) }, [代码] 渲染层使用 [代码] <image src="{{svgImg}}"/> [代码] github 使用案例 demoFormpSvg
2022-04-30 - 通过实时数据推送承载千人活动
某年的腾讯互娱市场体系年会, tgideas 团队制作了一款以线下服务为主的微信小程序,参与到员工大会以及晚宴的多项环节中,应用的技术包括:公司智能网关+iBeacon 判定身份,小程序拉起导航,小程序云函数,云数据库,云储存,多场景使用实时数据库,小程序支付能力,内嵌 H5 小游戏,小程序红包能力。 背景 本小程序是服务于 TIEM 市场体系年会的辅助工具和互动工具。年会参与员工 1200+人,还有几十个外部嘉宾,年会分下午的员工大会,红地毯以及晚宴节目表演等环节。而小程序在其中的具体功能点,以及对应的核心技术如下: 智能网关判定是否员工,iBeacon判断是否现场嘉宾。实时数据库更新会议+红地毯+晚宴节目的进程。现场辩论赛环节,实时数据库展示投票并实时呈现在舞台侧屏(类奇葩说)。实时数据库更新红地毯进程(部门每一个团队排队入场的过程)。精彩现场通过云储存以瀑布流方式,提供员工上传照片和预览照片功能。晚宴节目里,可以对员工表演的节目进行免费支持和付费打赏,并通过实时数据库在小程序以及舞台侧屏呈现。舞台大屏每次的 web 抽奖结果通过HTTP API同步到小程序云数据库。晚宴期间全场 H5 互动小游戏,每个人的游戏分数以HTTP API汇总在他选择的桌总分中,以桌为单位角逐第一,并在舞台大屏通过实时数据库能力呈现前三名的桌号和总分。每一个体验过游戏的人,在回到小程序的游戏大厅后会分配到一个由小程序红包能力的现金红包。 云开发 第一次接触云开发,1 个月内要从无到有并完成任务,在任务重、时间紧的双压下,使得开发者对于云开发的能力担心起来。结果证明小程序·云开发的开箱即用、原生的微信能力集成、云函数自带鉴权以及可以获取 openid 等强大的能力,对于没有后台开发经验的前端开发来说是一个很好的方案选择。 实时数据库 本次分享的就是腾讯云联合微信小程序推出的能力--实时数据推送。 微信小程序提供的实时推送能力,很简单,只有一个 API,就是 watch[1] 记住这个关键词,它就是实时数据推送的代名词 官方自研能力,开箱即用,无需管理长连,无需编写服务端代码,而且不占用数据库连接数,简而言之就是官方赠送的。从官方的云开发 demo 中包含的一个聊天场景就能看出,实时数据推送能力对聊天室、聊天模块等的需要即时通讯功能天生友好。对于年会小程序“打赏后即时反馈”的功能以及小程序里的游戏大厅功能也很契合。接下来具体说说,如何应用实时数据推送能力。 watch 应用——年会全程节奏掌控 在小程序 relaunch 的时候,起了一个 watch,监听了 adminConfig,并把监听到的数据变化,写入到当前页面的 data 里,这样就可以改变界面状态。 先上一个动图,可以感受得到左边的小程序管理端只要有所操作,右边的所有用户的小程序端都会即时产生对应的变化: [图片] 1//业务逻辑太复杂,这里写伪代码 2//app.js 3App({ 4 loadConfig:function(){ 5 //可以把它当成是一个setInterval,设置一个ID给它,方便关闭它 6 this.globalData.adminWatch = db.collection("adminConfig").watch({ 7 onChange: function(res) { 8 let { 9 _id, 10 ...adminConfig 11 } = res.docs[0]; 12 console.log('配置改变:', adminConfig) 13 const _page = getCurrentPages(); 14 if (_page) { 15 _page[_page.length - 1].setData({ //这里很关键,把监听到的变化驱动当前界面的变化 16 adminConfig 17 }) 18 } 19 }, 20 onShow:function(){ 21 this.loadConfig() //每次唤起小程序都开启监听 22 }, 23 onHide:function(){ 24 this.globalData.adminWatch.close() //每次隐藏小程序都关闭监听 25 } 26}) 27 28//某个具体页面的比如game.xml,就可以依赖以上的adminConfig做状态判断了。 29游戏正式开始 30游戏试玩 前文说过,该小程序管理端页面,其实都是在对一个名为 adminConfig 的 colletion 做修改。而在打开小程序的第一时间就开启监听此 adminConfig,当它有什么变化,则会驱动我们的界面相应变化。而亦需要设计小程序的每一个需要被控制的页面,结构与逻辑都想办法依赖这个 adminConfig,不然没办法驱动此页面的界面变化。比如下图,在管理端点击“小游戏入口开启”后,大家的小程序界面瞬间多了一个 banner 入口;点击“游戏开始”后,大家的小程序界面则会瞬间从“开始试玩”变成“点击进入”。 [图片] 通过实时数据推送,做到在每一个员工/嘉宾的小程序界面上,实时更改他们的界面,改变员工大会晚宴节目进程。 在 watch 面前,连 setData 都有可能成为性能瓶颈 打赏页面的逻辑是,用户在打赏的时候,就在 colletion 为 rewards 的集合里 add 一条记录。然后实时数据推送 watch 将监听整个 rewards 的数据改变,watch 的 onChange 事件给出了什么数据,就把这些数据洗一遍,然后直接 setData 到界面上。 看起来没问题,测试的时候也没问题,每次送上小爱心,界面的打赏记录也会瞬间反馈我的打赏行为。这一切都如此合理。 但其实这里存在了一个很隐蔽的问题,如果不是利用年会演习的机会测试了一次,如果不是总PM心细如发体验到了此问题,估计到了现场将会是一个灾难。 存在的危机:当有很多人同时在打赏的时候,watch 的 onChange 几乎是无微(每条记录)不至(推送到达)的,在每一次 onChange 都会反馈到小程序端,也就是每次都会触发 setData 去驱动界面渲染一次。watch 并不是我们想象的,会缓存一波数据改变再推送过来,它的反馈是如此直接暴力,用户 add 一条,它就推送一条,数据落盘 DB 到数据从服务端推送出去,这里仅仅是 5-10ms,也就是说除非在这 5-10ms 内有多个用户同时 add 数据,才会出现返回的记录 length 是 2 的情况。试想如果每秒都有 10 个记录反馈,那 setData 就得操作 10 次。而 setData 是一个异步行为,也就意味着它的执行是需要时间的。如果操作唤起“立即打赏”面板(其实也是一次 setData 操作),则会面临,需要在 1 秒 10 次的 setData 间隙中,插入这次的唤起界面的操作,但是试想怎么能有设备快?setData 一旦空闲,立刻就被 onChange 事件的反馈自动执行并占用了。而唤起界面的操作就被阻挡在外了,连排队的资格都没有(setData 没有排队的设定)。 如果在现场大家想打赏却唤不起打赏面板,那将是很尴尬的事情。如果涉及到收入什么的(比如此处涉及到了微信支付),那就是运营事故了。 问题已经发现并抛出来了,解决起来就简单了。解决方式是: 1onChange推送过来的数据堆在一个arr变量里: 2onChange:function(res){ 3 _this.arr.push(res.doc) 4} 5然后setInterval一个函数,每秒执行一次: 6 { 7 把arr的值setData 8 然后再清空arr 9 } 以这种自然语言描述代码,是不是更直观?这样就很难在点击唤起界面的 setData 执行的时候,watch 正在占用 setData 了。 这里还要补充一个注意事项,watch 有一个能力限制,只能监视一个 collection 的 5000 条记录。比如上面的打赏数据库,设计的是每一次打赏都 add 一条记录的话,如果超过 5000 条后,watch 事件就会报错。 [图片] 所以我们一定要注意数据库的设计,避免 watch 的 collection 超过 5000 条记录的情况! watch 走另一个通道,不占用套餐里标识的同时连接数,但是默认最高支持1W 的监听数。比如我之前说的 adminConfig 里的设计,只有一条记录,即便是全局 watch,也毫无压力。 watch 应用——游戏大厅 游戏大厅可能是我在整个小程序里花费最多的一个环节。这里先上个界面: [图片] [图片] 刚开始进到这个界面,自己是没有入座的,自己现场就餐的桌号,对应此处的桌号,选择桌号入座(主持人会宣读);入座后如果发现自己桌号不对,可以直接点击正确的桌号进行再次入座,也可以先点离开,再选择正确桌号入座。这里就涉及到 2 次云函数的调用了,在弱网环境可能造成 1 秒的延迟,而如果延迟过程有人跟你选择同样的桌号,而此桌已经 11 人了(每桌限制 12 人),则会造成数据错误。所以这里的查询条件以及 update 方式都有讲究。经过了 3 次半的改版,才将体验优化到勉强能让大家接收的地步。 第一次:表的设计本身不合理,只是为了能跑通整个交互,达到加入,离开,直接开始的基础目标,但是不光是性能不行,点击入座到界面展示完成需要 1.5+秒,前端的表现也出现不一致性,主要体现在头像已经在桌位上了,但是延迟 0.5 秒后才显示“直接开始”和“离开”按钮。 第二次:优化了表结构,每桌作为一个固定的表记录,每一个玩家只是这个记录里的一个 member 字段数组里的一个对象,但是这次优化出现,当频繁点击“加入”别的桌,会有多个自己头像的 BUG。也就是一个用户可以占多桌,这明显是不合理的。而且换桌的逻辑涉及到了 4 次云函数的调用计算,云函数计算时间 800ms 左右。 [图片] 第三次:对云数据库的了解更深入后,对云函数的优化从换桌的 4 次调用变成 2 次。之前是先查询目标桌是否满座,然后再进行插入数据;优化后则是直接插入,如果插入不了,则返回 status='0'的状态码通知前端。避免了换桌出现多个自己头像的 bug,避免 11 人一桌,2 人同时抢桌造成数据异常的情况。云函数计算时间也缩短到 400ms 以内。在跳桌的时候,先做目标桌加入本人信息的操作,再做当前桌 delete 本人信息操作,让 delete 的时间不需要等待。 [图片] 第四次:优化了交互,点击加入的时候增加一个 showLoading,避免静态等待的同时,也避免了弱网下反复跳桌造成的渲染 bug。 补充,以上的截图为云函数调试界面的控制台截图,其中因为是开发工具的缘故,耗时远大于实体机器上的真正耗时。开发者可以参考数据,达到优化目的。 总结一下游戏大厅的关键优化点: 数据表的结构设计,最好以可以估算上限的单位作为记录本身,比如桌子数量;加入桌子的数据操作是 update,member 的增加用数据库 API 自增_.inc,多个用户同时写,对数据库来说都是将字段自增,不会有后来者覆写前者的情况;如果是跳桌,先运行加入桌逻辑,再走离开当前桌逻辑;要有针对防止多次点击的设计;分区(1-30 桌,31-60 桌)渲染,分区 watch,细节做到位,就不需要一直 watch 所有(1-120 桌)的桌子了;更少的查询次数,更详细的查询条件;在适当的环节加入带 mask 的 showLoading,可以避免用户行为的互相干扰。对这种多人参与,彼此会互相关系,即时性要求也比较高,对数据一致性也要保障的小程序场景,后续建议给自己评估多一倍的时间去应对,这样才能保障最终的落地,带来良好的体验。一味的从一个方向去优化,也许并不是一个好办法。比如云函数的操作瓶颈是,至少 150+ms 的回调时间,那么你再怎么优化体验,也不会超出这个范畴;而如果通过加入适当的 loading 动画,屏蔽其他操作,则无形中让用户少了尴尬的等待,也提高了交互的稳定性。 数据 在 3 小时的年会活动中该小程序承载了86175的 PV 和1577的 UV,在这么短的时间内如此大的访问量下云开发非常轻松的完成了此次任务。 总结 此次 TIEM 的年会小程序,从无到有,从设计到研发到测试,大概就是 1 个月的时间,当团队怀着忐忑的心情,直到年会结束,顺利完成了这个稍稍超出预估范畴的任务后,大伙才放下了一颗悬着的心。毕竟复盘的时候,才发现还有很多没做到位,比如线下活动的弱网环境测试。所以一些同事在游戏大厅,开启不了 H5 游戏。 而领导和同事们给予的肯定以及提出一些中肯的建议,这都是整个团队的收获。
2020-11-30 - 发现固定底部的可点击组件可能不在iPhone X安全区域内?
底部的可交互组件如果渲染在iPhone X的安全区域外,容易误触Home Indicator
2020-05-24 - 云函数“层”功能开发小结
项目中如果有多个云函数,它们经常会用到很多公共的代码模块,比如查询用户信息、鉴权等等。在小程序中,我们可以通过require导入这些公共模块。但云函数之间是互相独立的,每个云函数只能访问自己的代码包,我们需要手动把这些公共的代码模块复制到每个云函数中,容易造成文件混乱。“层”正是为了解决云函数间代码复用的问题。 “层”是什么 小程序开发文档中并没有“层”的介绍,不过我们可以在腾讯云开发文档中找到相关的介绍(传送门)。之前社区里也有大神分享过相关的文章(传送门),可以看到当时开发者工具创建的云开发环境并不能很好地支持“层”功能,这可能也是小程序开发文档没有“层”的相关介绍的原因。幸好,最近实测,开发者工具创建的云开发环境也可以正常使用“层”功能了。 简而言之,“层”就是每个云函数都可能访问的一个公共空间,它会在云函数启动时加载到云函数中。这样就可以保证每个云函数访问的都是同一个代码模块,不需要我们去手动同步代码文件。 “层”的创建和绑定 开发者工具并没有集成“层”功能,我们需要到腾讯云控制台来创建和管理。 第一步:使用小程序账号登录腾讯云控制台 [图片] 第二步:点击云开发CloudBase,选择环境 [图片] [图片] 第三步:选择云函数>层管理>新建 注意“层”的运行环境要和云函数相同,一般是Nodejs12.16,可以在“云函数管理”中查看云函数的运行环境。 [图片] [图片] 第四步:点击云函数管理,选择要绑定的云函数 [图片] 第五步:选择层管理>绑定,选择对应的层 [图片] [图片] “层”的访问 云函数触发时,“层”的代码包会加载到云函数中,这时云函数的目录结构是: [代码]var user 云函数代码(/var/user/) opt 层代码(/opt/) [代码] 这样我们就可以require导入我们的公共模块了。 举个例子,云函数的目录结构如下: [代码]var user index.js opt common.js [代码] 我们要在index.js中访问common.js文件,可以: [代码]const common = require('../../opt/common.js') // 云函数不支持“/”访问根目录 [代码] 因为云函数的环境变量包括“层”的路径,所以也可以: [代码]const common = require('common.js') // 直接引用文件名 [代码] “层”的调试 很可惜,开发者工具并不支持本地调试“层”,而且本地调试和云端运行的目录结构并不相同。下面是我的调试思路: 把“层”代码包放在云函数的根目录 [代码]opt common.js index.js [代码] 自定义环境变量ENV,根据ENV选择require的路径 [代码]// DEV:调试环境,引入云函数代码包里的opt文件夹 // PRO:生产环境,引入真正的“层” // 注意:本地调试不能获取环境变量 const common = require(process.env.ENV == 'PRO' ? '' : './opt/' + 'common.js') [代码] 不知大家还有没有其它思路,欢迎一起讨论。 多翻翻腾讯云的文档,说不定还有惊喜~ 相关文档 云开发层管理
2021-12-21 - 30分钟实现小程序打卡签到送积分功能,提升用户留存利器!
前言在小程序运营中,为了提升用户留存,通常会自建打卡签到模块送积分功能。用户可以通过打卡签到获取到积分,然后积分可以兑换礼物,从而实现用户留存的效果。 效果在我的页面加入了一个签到入口,点击进入打卡签到页面。进入页面后会自动签到,引导用户进行二次提醒推送。可在后台灵活配置每天签到的积分及抽奖、奖品配置。 [图片] 今天我来带大家看看如果快速实现这个功能,在这里需要用到小程序的「单页模版」功能。来看看官方文档介绍: 目前「单页模版」处于内测阶段,我这个是内测版本,所以大家在开发工具中没有也是正常的。 小程序开发过程中,有很多通用的业务模块,例如:打卡签到、邀请有礼、趣味小游戏、运营 banner 配置等。这些模块业务模型具备通用性,但是前端每个小程序都有自己的样式设计。因此,每个小程序都需要重复性的进行开发。单页模板致力于帮助小程序开发者聚焦前端交互展示,无需关注于实现接口以及管理端。开通单页模板后,运营人员可直接在管理端配置新功能,小程序前端源码组件可导入到小程序内快速接入,也可以对前端组件进行二开以满足业务需求。 简单来说就是把整套打卡签到的前端代码和后端业务代码插入到你的小程序中,自己可以对前端组件做二次开发。 接下来我就带你来看看如何使用: 目前该功能还在内测阶段,你的微信开发功能没有是正常的。在这里我只是演示内测版本,后续云开发团队会持续更新,正式上线。第一步:开通单页模版开发设置面板点击扩展设置找到其他组件安装单页模版[图片] 当你开通完成后来,选中miniprogram文件夹右键呼出菜单,选择「配置单页模版」点击免费使用 [图片] 则会进入腾讯云登录界面,扫码验证选择自己的小程序即可。验证成功后则会进入单页模板控制台的小程序组件页面。 [图片] 第二步:后台配置规则点击「前往配置」则会进入签到配置页面,分别可以对:奖品管理、签到记录、签到规则、签到配置。 奖品管理奖品列表【新增、删除、导出】[图片] 奖品新增,在这里要注意填写奖品总数直接和剩余数量填写相等即可,当用户中奖后会自动将剩余数量-1[图片] 签到记录显示所有的签到记录、中奖记录,可以通过搜索条件对openid、连续天数、奖品描述进行过滤,而从显示对具体信息的快速查找。 [图片] 签到规则这里编辑的内容会在小程序页面用户点击签到规则时进行弹出显示。 [图片] 签到配置在这里可以配置签到活动的开关,签到的积分奖励,目前支持积分和虚拟物品,下周会上线实体物品。还有就是抽奖设置,包括概率和触发条件。 [图片] 当配置一切搞定之后,接下来我们来看下如何导入前端的签到打卡组件。 第三步:导入代码我们回到刚才的控制面板,切换到第二个菜单导入小程序组件,点击导入组件到IDE。 [图片] [图片] 点击查看详情,会有这个组件的介绍。 [图片] 点击导入IDE会直接导入在小程序的miniprogram目录下 [图片] 在这里如果你想直接导入在pages下面在进入单页模版配置的时候直接选择pages文件夹即可,或者移动文件夹到pages下面也可以,因为通常来说我们的页面都是放在pages下面来进行管理的。 看下代码以及预览效果 [图片] 该组件代码的目录: . ├── README.md ├── cloudfunctions # 云开发云函数目录 ├── miniprogram # 小程序前端代码 │ ├── miniprogram_npm │ ├── node_modules # 如果希望在 miniprogram/pages 下调用 @cloudbase/saas-module,需要在 miniprogram 下安装依赖 │ ├── pages │ │ ├── index │ │ ├── page_module_sign_up # 导入目录规范 page_module_${模块名} │ │ │ ├── README.md │ │ │ ├── components # 模块内的组件 │ │ │ ├── config.js │ │ │ ├── images │ │ │ ├── miniprogram_npm # 构建后的npm包,包含 @cloudbase/page-module │ │ │ ├── package.json │ │ │ └── pages # 模块内示例页面 │ │ └── demo │ └── sitemap.json ├── project.config.json 从代码上来看,在这里使用 page_module_sign_up/pages/index/index.wxml 是直接使用的自定义组件如果想要在前端进行样式的调整可以在 page_module_sign_up/components进行相关组件的代码修改。 [图片]这就是在文档中提到的可以对前端组件进行二开以满足业务需求。除了前端之外我们来看看后端配置 第四步:接口配置回到单页模版页面,选择接口自定义接口。 设置消息提醒首先选择一个具体的云环境进行部署云函数 [图片] 部署成功后进行代码下载 [图片] [图片] 这样你就可以得到发送订阅消息的代码,这里要注意就是要修改成自己的订阅模版。登录当前小程序的 mp 管理系统,从菜单中找到订阅消息-公共模版库-搜索【签到模版】-选中第一个进行选用。 [图片] 选用后三个字段 活动名称签到奖励温馨提示[图片] 提交即可,模版申请成功后会在我的模版中显示 [图片] 复制模版ID去小程序前端代码中进行替换,找到page_module_sign_up/config.js [图片] 替换temId和你想要用户点击推送订阅消息卡片跳转的页面page [图片] 除了小程序前端之外还需要在云函数page_module_tcb_sign_up/api/sendmsg.js进行修改。 [图片][图片] 在这里需要和管理后台的模版详情的字段序号对应上,默认下载的代码是 温馨提示:{{thing2.DATA}} 签到奖励:{{thing1.DATA}} 活动名称:{{thing3.DATA}} 代码中的逻辑是第二天发送模版消息,但是实际上测试肯定不会等到第二天要不然效率就太慢了,可以在 page_module_tcb_sign_up/api/set_remind.js 的主题逻辑run方法中修改 addDelayedFunctionTask 的 delayTime 参数,比如:我想点击10秒后发就可以设置为10。 [图片] addDelayedFunctionTask:用于延时调用云函数 delayTime:延迟时间,单位:秒,合法范围:6s-30天 设置完成后,点击明天提醒就可以立即收到模版消息推送了。 [图片] 在这里点击卡片的时候如果你签到打卡是一个新的页面会提醒找不到这个页面,因为发送推送消息默认环境跳转到正式环境去了,所以需要在 page_module_tcb_sign_up/api/sendmsg.js 改动下跳转环境。 [图片] subscribeMessage:发送订阅消息 miniprogramState:developer为开发版;trial为体验版;formal为正式版;默认为正式版 这样调试起来就不会有问题了,但是一定要记得上线后记得把时间和环境都调整过来。 接下来再来看看监听积分数据和奖品数据。 监听积分与奖品这两个监听相对简单,而且方法都是一样的所以就一起讲解。 监听积分发放需要在云函数下新建 /api/send_face_value.js 文件并实现该接口。 [图片] 监听奖品发放需要在云函数下新建 /api/send_prize.js 文件并实现该接口。 [图片] 具体实现,两种只是入参不一样其他都是一样的,以下为官方给出的代码示例。 const objCloud = require('wx-server-sdk'); /** * 具体的业务函数,在这里实现您发奖,发积分的逻辑,data 入参是固定的,出参必须遵循规范 * @param { object } data - 业务入参 * @returns { object } - 返回参数 * @returns { number } code 返回的状态标记,成功返回0, 非0代表错误 * @returns { string } msg 如果成功,则可以不返回,如果失败把相应的错误原因中文描述放在这里 * @returns { object } result 接口调用返回的信息 * */ module.exports = async (data) => { console.log("参数:", data); // 这里实现您具体的业务逻辑,例如发积分,发奖 return { code:0, msg:'suc', // 出参需要遵守自定接口规范 result: { sendResult: true }}; }; 我基于这个案例写了一个实际的业务,当用户签到获取到积分的时候我就存在我的积分表里面。 // 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init() const db = cloud.database() module.exports = async (data) => { console.log("参数:", data); // 具体业务 const integral = await db.collection('integral') await integral.add({ data: data }) return { code: 0, msg: 'suc', result: { sendResult: true } }; }; 数据结构: [图片] 当然你自己也可以根据自己的业务来新增相关字段,做到这一步基于「单页模版」的签到打卡送积分功能就全部完成了。 注意我已经上线使用了1周了,发现了一个问题。 [图片] 用了单页模式新增了一个lowcode环境,所以在实现礼物接口和积分接口的时候需要指定下环境,要不然会出现概率报错找不到数据库。 [图片] [图片] 最后其实我很早就想实现自己的积分体系,但是由于时间不够的原因,导致迟迟没有去迈出第一步,正好遇到的单页模版的签到打卡送积分这个功能,于是在wilsonsliu的高度配合下完成了积分体系v1.0上线了。同时也非常期待后续的单页模块的邀请有礼、趣味小游戏、运营 banner 配置等上线。 只要你的小程序想要提高用户留存都需要搭建自己的积分体系,而单页模版的签到打卡功能可以成为用户获取积分的途径之一,要有积分才能去消费,具体消费积分场景可以配合积分商城或特殊功能上去进行消费。
2022-01-14 - 手把手教你搭建消防安全答题小程序-用云开发实现查询题库功能
手把手教你搭建答题活动小程序系列文章,最前面的三章是界面设计篇,分别描写了如何搭建答题小程序前端界面。 现在已经进入功能交互篇了,此为功能交互篇的第三章,如何用云开发实现查询题库功能。 其实,说白了就是相当于,前后端分离架构中的发送异步请求。先看看官方文档怎么说,再看看我是怎么理解和怎么做的,希望大家能从中得到启发,然后找到适合自己的学习方法。 软件架构:微信原生小程序+云开发戳源码地址,获取源码,版本持续迭代中... 前期准备工作按照惯例,我们先看看官方文档怎么说。不一定需要通读文档,可以遵循“用到什么查什么”的原则去针对性的查阅文档。此处应该有表情包,鱿鱼兮“看文档”.jpg 关于云开发是什么,云开发能力有哪些,是这样说的,巴拉巴拉~ [图片] 关于数据库是什么,小程序怎么调用,是这样说的,巴拉巴拉~ [图片] [图片] 好了,有兴趣的话,其他的你也可以仔细阅读阅读。 不吹不黑,毕竟,微信小程序开发的官方文档,是我看过的官方技术文档中描写最详细的文档。 当然,也有的地方一笔带过,我希望它可以更加详细一些。 不成文的分析云开发能力,包含云数据库、云存储、云函数、云调用等等。可谓五花八门,这么多概念,眼花缭乱,晕乎所以了吧。 其实,大可不必,有的可用可不用。可以组合使用,也可以只用其一,这就“仁者见仁,智者见智”了。而这里,我们使用云数据库的小程序端SDK就行了。 如果你想免费、快速的开发出一个完整的答题小程序项目,用小程序的云开发可能是最好的选择。小程序的云开发所用到的主要是前端开发的知识,是的,你没听错没看错,划重点吧。 从此,摆脱“前端小哥哥小姐姐”、“后端小哥哥小姐姐”笼罩下的阴影,可以硬气一把了,整个项目自己一把梭,solo~ 云开发快速查询题库所谓“兵马未动,粮草先行”。若要调用数据库,则需要先有数据库。这句看似废话,其实是隐喻一系列的操作。 不禁发出灵魂三问: 你开通云开发服务了吗? 你创建数据库集合了吗? 你添加题目数据了吗? 没有?!没有?!没有?! 还有谁 1、手把手教你操作数据库1)点击微信开发者工具的云开发图标,打开云开发控制台。 [图片] 2)点击数据库图标进入到数据库管理页,点击集合名称右侧的+号图标,就可以创建一个数据集合了。 [图片] 3)这里我们只需要添加一个activityQuestion的集合即可,这个集合就是存放题库用的。 [图片] 4)添加题目数据,或者,导入题库,两种方式均可。 ①添加记录,一题一题地手动添加,一题一题地一题一题地...... ②导入题库,嗖的一声直接导入事先准备好的题库json文件。 [图片] 5)大佬喝茶~哦,不对。大佬,记得设置数据权限吖。不然它默认是“仅创建者可读写”,到时查不到数据就GG了。别跑,你还有bug没改完~ [图片] 2、题库的数据库设计[图片] 可以清晰地看见,一道题目其实就是对应一条记录。你可以粗暴地理解为,集合里面的记录,类似数组里面的对象。 你创建的每一道题,都会自动生成一个id字段,这个你可以不用管。一道题里面,所包含的字段不外乎就question、option、true、checked这几个。 字段解读: 1)question 题干 2)option 选项 3)true 正确答案 4)checked 该题是否已做 3、小程序端调用数据库在小程序端调用数据库的方式很简单,我们可以把下面的代码写到一个事件处理函数里,然后直接在页面的生命周期函数里面执行。 其实概括起来,就三步走: 1)先使用 wx.cloud.database()获取数据库的引用(相当于连接数据库); 2)再使用 db.collection()获取集合的引用; 3)再通过 Collection.get 来获取集合里的记录。 项目代码之逐行解读: // 连接云数据库 const db = wx.cloud.database(); // 获取集合的引用 const activityQuestion = db.collection('activityQuestion'); // 数据库操作符 const _ = db.command; Page({ /** * 页面的初始数据 */ data: { questionList: [], // 题目列表 index: 0 // 当前题目索引 }, /** * 生命周期函数--监听页面加载 */ onLoad: function (options) { // 获取题库-函数执行 this.getQuestionList() }, // 获取题库-函数定义 getQuestionList() { // 显示 loading 提示框 wx.showLoading({ title: '拼命加载中' }); // 构建查询条件 activityQuestion.where({ // 指定查询条件,返回带新查询条件的新的集合引用 true: _.exists(true) }) .get() .then(res => { // 获取集合数据,或获取根据查询条件筛选后的集合数据。 console.log('[云数据库] [activityQuestion] 查询成功') console.log(res.data) let data = res.data || []; // 将数据从逻辑层发送到视图层,通俗的说,也就是更新数据到页面展示 this.setData({ questionList:data, index: 0 }); // 隐藏 loading 提示框 wx.hideLoading(); }) } }) 4、题库查询结果保存然后待代码编译之后,点击“开始答题”按钮跳转到答题页面,就能在控制台看到调用的 20 条数据库记录了。 稍微要说明一下的是,如果没有指定 limit,则默认最多取 20 条记录。 [图片]
2021-11-13 - table表格组件,分享给各位
前言 移动端的页面本应该很少有table表格这样的展示、操作,但总归有这样的需求,然而平时用的vant和iview的小程序组件库都没有table组件,这里将自己编写的table组件展示一下供大家查看。 小程序实现table的问题在于,自定义td的实现,而小程序没办法像react一样使用[代码]jsx[代码],也没办法像vue一样用[代码]作用域插槽[代码]传row行的信息给slot,但是小程序还是留有一样东西可以完成自定义td的功能。 抽象节点 这个特性自小程序基础库版本 1.9.6 开始支持。 有时,自定义组件模板中的一些节点,其对应的自定义组件不是由自定义组件本身确定的,而是自定义组件的调用者确定的。这时可以把这个节点声明为“抽象节点”。 微信官方api地址 通过抽象节点我们可以做到使用自定义组件通过key值分发组件内容到不同的td里。 具体的源码地址可点击下方查看,如果对你有帮助请点个star~~ 源码地址 具体的实现效果可以扫描下方小程序码。 [图片] API prop 参数 说明 类型 默认值 是否必填 columns 表格的配置 Columns[] [] true dataList 数据 any[] [] true getListLoading 请求列表的loading boolean false true showTipImage 无数据时的提示文本图片 boolean false true rowKey 用于指明行的唯一标识符,在勾选中有使用 string id false scrollViewHeight 控制可滚动区域高度。 string 600rpx false tipTitle 无数据时的提示文本主标题 string 提示 false tipSubtitle 无数据时的提示文本副标题 string 暂无数据 false scrollX 是否需要X轴滚动。 boolean false false select 控制是否出现勾选。 boolean false false selectKeys 勾选的初始值 any[] [] false generic:action-td 当列表项内具有操作列,需要在[代码]columns[代码]内添加[代码]type:action[代码]的一项,操作列的内容往往需要自定义,小程序不提供react,vue的[代码]rander函数[代码],所以使用到了抽象节点,该属性指明抽象节点的组件。操作列位置可以不固定,点击事件由[代码]bindclickaction[代码]触发 component undefined false isExpand 控制是否点击展开。 boolean false false expandValueKey 展开信息的key值 string false initExpandValue 当展开信息为空时的默认提示语 string ‘暂无信息’ false expandStyle 展开信息的最外层的样式 string ‘’ false generic:expand-component 如果展开区域的内容需要自定义,[代码]expandValueKey[代码]设置为空字符串,则切换到组件模式,传一个组件进来,展开区域的点击事件由[代码]bindclickexpand[代码]触发 component undefined false dynamicValue 给自定义内容的动态值,用于改变状态 ,建议{value:放的数据} object {} false Events 事件 解释 类型 bindclicklistitem 点击列表行事件 Function(e); e.detail.value = {index:number(当前行序号),item: any(当前行的内容)} bindclickexpand 点击展开内容事件 Function(e); e.detail.value = {type:(这个按钮的含义字段,如‘close’),index:(当前的行),item:(当前行的数据)};(这是我这里定义的结构,具体可以自己定义在expand-component里)} bindclickaction 点击抽象节点事件 Function(e); e.detail.value = {type:(这个按钮的含义字段,如‘close’),index:(当前的行),item:(当前行的数据)};(这是我这里定义的结构,具体可以自己定义在action-td里)} bindcheckkey 勾选事件 返回被勾选项的rowKey数组 Function(e); e.detail.value = any[]//(数组内每一项是rowKey字段定义的数据的toString()结果) bindscrolltolower 滚动触底 Function() bindscrolltoupper 滚动触顶 Function() column 列描述数据对象,是 columns 中的一项,Column 使用相同的 API。 事件 解释 类型 必填 title 字段名中文含义 string true key 字段名 string true width 单元格宽度 string false type 判断字段是否是自定义组件 ‘action’/undefined false render td内内容由函数返回 (value: any, item: any, index: number, data?: 当前页面的this.data) => any,// 设置内容 function false
2022-11-24 - 小程序app.onLaunch与page.onLoad异步问题的最佳实践
场景: 在小程序中大家应该都有这样的场景,在onLaunch里用wx.login静默登录拿到code,再用code去发送请求获取token、用户信息等,整个过程都是异步的,然后我们在业务页面里onLoad去用的时候异步请求还没回来,导致没拿到想要的数据,以往要么监听是否拿到,要么自己封装一套回调,总之都挺麻烦,每个页面都要写一堆无关当前页面的逻辑。 直接上终极解决方案,公司内部已接入两年很稳定: 1.可完美解决异步问题 2.不污染原生生命周期,与onLoad等钩子共存 3.使用方便 4.可灵活定制异步钩子 5.采用监听模式实现,接入无需修改以前相关逻辑 6.支持各种小程序和vue架构 。。。 //为了简洁明了的展示使用场景,以下有部分是伪代码,请勿直接粘贴使用,具体使用代码看Github文档 //app.js //globalData提出来声明 let globalData = { // 是否已拿到token token: '', // 用户信息 userInfo: { userId: '', head: '' } } //注册自定义钩子 import CustomHook from 'spa-custom-hooks'; CustomHook.install({ 'Login':{ name:'Login', watchKey: 'token', onUpdate(token){ //有token则触发此钩子 return !!token; } }, 'User':{ name:'User', watchKey: 'userInfo', onUpdate(user){ //获取到userinfo里的userId则触发此钩子 return !!user.userId; } } }, globalData) // 正常走初始化逻辑 App({ globalData, onLaunch() { //发起异步登录拿token login((token)=>{ this.globalData.token = token //使用token拿用户信息 getUser((user)=>{ this.globalData.user = user }) }) } }) //关键点来了 //Page.js,业务页面使用 Page({ onLoadLogin() { //拿到token啦,可以使用token发起请求了 const token = getApp().globalData.token }, onLoadUser() { //拿到用户信息啦 const userInfo = getApp().globalData.userInfo }, onReadyUser() { //页面初次渲染完毕 && 拿到用户信息,可以把头像渲染在canvas上面啦 const userInfo = getApp().globalData.userInfo // 获取canvas上下文 const ctx = getCanvasContext2d() ctx.drawImage(userInfo.head,0,0,100,100) }, onShowUser() { //页面每次显示 && 拿到用户信息,我要在页面每次显示的时候根据userInfo走不同的逻辑 const userInfo = getApp().globalData.userInfo switch(userInfo.sex){ case 0: // 走女生逻辑 break case 1: // 走男生逻辑 break } } }) 具体文档和Demo见↓ Github:https://github.com/1977474741/spa-custom-hooks 祝大家用的愉快,记得star哦
2023-04-23 - 小程序Collection.watch API的问题?
这个API, 官方文档只阐明了使用方式,现在的问题是,watch长时间被自动断开,hide小程序之后重新打开后watch断开的问题,官方是否能给出一个解决方案??
2021-07-21 - #小程序-小程序中如何判断是否添加企业微信解决方案
一、商家诉求 小程序中用户做某一动作前要求用户添加企业微信才能操作。 二、实现原理 利用企业微信客户联系的【微信开发者ID->支持小程序、公众号,绑定后可通过api接口获取微信联系人对应的唯一身份标识(微信unionid) 】 的这个能力,这个能力可以再用户添加/删除企业微信时会推送消息到指定的服务器URL地址,将推送信息保存, 然后小程序端根据这份数据进行判断。 三、实现步骤 要使这个能力在系统生效要做如下配置: 0.小程序绑到开放平台,可以是同主体,也可以是异主体,这个没有强制要求。 1.收集企业微信的CorpId与Secret,其中企业CorpId在“我的企业”中找企业ID [图片] Secret的获取,可以点查看,然后发送,发送到管理员企业微信会收到通知。 [图片] [图片] [图片] 秘钥忘记,可以通过重置功能重置 [图片] 2.微信开发者ID一栏绑定关联的小程序,必须企业微信同主体,不接受反驳。(实际上绑定企业微信同主体申请的任一小程序都可以) 绑定发起授权页面,小程序管理员扫码授权即可。 [图片] 绑定后不要解绑,解绑后企业l联系人将无法获取unionId [图片] 3.设置企业微信客户联系事件接受服务器Url及消息加解密信息,设置为之后复制出来,用于配置到系统中来 Url:http://xx.xxx.com/wxwork/receive/改为企业ID 注意:填写前需要已开发验证代码,否则保存不成功,文档:https://open.work.weixin.qq.com/api/doc/90000/90135/92129 [图片] 4.处理接收消息,将是否添加企业微信信息保存 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/92130#添加企业客户事件 主要变更类型处理: [图片] 5.需要判断的业务,根据保存的这份信息做判断 if(数据库存在记录,并且没有删除企业微信){ 加了企业微信了干点啥 }else{ 没加,或者以前加过但删除了,干点啥 }
2021-09-29 - 前端es6实现优先级队列
0. 优先级队列: 指入队时有了优先级,入队不是直接插入,而是根据优先数,插入到合适的位置。队列每一项存着内容和优先级数。 1. 代码实现 [代码]class Queue { constructor() { this.list = [] } push(value) { this.list.push(value) } pop() { return this.list.shift() } peek() { return this.list[0] } isEmpty() { return this.list.length === 0 } clear() { this.list = [] } size() { return this.list.length } toString() { return this.list.join(",") } } // 优先级队列 class PriorityQueue extends Queue { push(value, priority = 0) { let insertIndex = this.list.length; for (let i = 0; i < this.list.length; i++) { if (priority <= this.list[i][1] ) { insertIndex = i; break; } } this.list.splice(insertIndex, 0, [value, priority]) } pop() { let item = this.list.shift(); return item ? item[0] : undefined; } peek() { let item = this.list[0]; return item ? item[0] : undefined; } toString() { let resArr = []; this.list.forEach((item) => { resArr.push(item[0]) }) return resArr.join(",") } } [代码] 2. 示例代码(含测试代码) 代码链接 3. 复杂度 入队:时间复杂度为[代码]O(N)[代码]、空间复杂度为[代码]O(1)[代码] 出队:时间复杂度为[代码]O(N)[代码]、空间复杂度为[代码]O(1)[代码] 4. 思想 队列每一项存在另一个数组(元组),元组第一个值为内容,第二个值为优先级数,有了优先级数,入队时先判断插入位置,从而实现插队 插完队伍后,排在前面还是优先处理 5. 使用场景 比如登机(银行办事)排队时,VIP客户走VIP通道 6. 相关文章 上一篇:es6实现队列数据结构(基于数组) 下一篇:单向链表(暂未更新) 7. 欢迎指正
2021-09-17 - 微信云开发 怎么更新集合下的数组中的某一个值?
[图片] 如图,我想要修改指定id的is_open,我应该怎么去找到他 如 修改id为2的 is_open为false
2020-04-24 - [开盖即食]基于canvas的“刮刮乐”刮奖组件
[图片] 工作中有时候会遇到一些关于“抽奖”的需求,这次以“刮刮乐项目”举例,分享一个实战抽奖功能。 本人对之前网上流传的一些H5刮刮乐JS插件版本进行了一些改造,使其能适用于实际项目,并且支持小程序canvas 2D的新API,这里顺便提下2D API和实际H5 canvas中JS写法非常类似,只有少数不同。 [图片] 1、方法介绍: 1.1 刮刮乐JS组件 [代码]class Scratch { constructor(page, opts) { opts = opts || {}; this.page = page; this.canvasId = opts.canvasId || 'canvas'; this.width = opts.width || 300; this.height = opts.height || 300; this.bgImg = opts.bgImg || ''; //覆盖的图片 this.maskColor = opts.maskColor || '#edce94'; this.size = opts.size || 15, //this.r = this.size * 2; this.r = this.size; this.area = this.r * this.r; this.showPercent = opts.showPercent || 0.2; //刮开多少比例显示全部 this.rpx = wx.getSystemInfoSync().windowWidth / 750; //设备缩放比例 this.scale = opts.scale || 0.5; this.totalArea = this.width * this.height; this.startCallBack = opts.startCallBack || false; //第一次刮时触发刮奖效果 this.overCallBack = opts.overCallBack || false; //刮奖完触发 this.init(); } init() { let self = this; this.show = false; this.clearPoints = []; const query = wx.createSelectorQuery(); //console.log(this.canvasId); query.select(this.canvasId) .fields({ node: true, size: true }) .exec((res) => { //console.log(res); this.canvas = res[0].node; this.ctx = this.canvas.getContext('2d') this.canvas.width = res[0].width; this.canvas.height = res[0].height; //const dpr = wx.getSystemInfoSync().pixelRatio; //this.canvas.width = res[0].width * dpr; //this.canvas.height = res[0].height * dpr; self.drawMask(); self.bindTouch(); }) } async drawMask() { let self = this; if (self.bgImg) { //判断是否是网络图片 let imgObj = self.canvas.createImage(); if (self.bgImg.indexOf("http") > -1) { await wx.getImageInfo({ src: self.bgImg, //服务器返回的图片地址 success: function (res) { imgObj.src = res.path; //res.path是网络图片的本地地址 }, fail: function (res) { //失败回调 console.log(res); } }); } else { imgObj.src = self.bgImg; //res.path是网络图片的本地地址 } imgObj.onload = function (res) { self.ctx.drawImage(imgObj, 0, 0, self.width * self.rpx, self.height * self.rpx); //方法不执行 } imgObj.onerror = function (res) { console.log('onload失败') //实际执行了此方法 } } else { this.ctx.fillStyle = this.maskColor; this.ctx.fillRect(0, 0, self.width * self.rpx, self.height * self.rpx); } //this.ctx.draw(); } bindTouch() { this.page.touchStart = (e) => { this.eraser(e, true); } this.page.touchMove = (e) => { this.eraser(e, false); } this.page.touchEnd = (e) => { if (this.show) { //this.page.clearCanvas(); if (this.overCallBack) this.overCallBack(); this.ctx.clearRect(0, 0, this.width * this.rpx, this.height * this.rpx); //this.ctx.draw(); } } } eraser(e, bool) { let len = this.clearPoints.length; let count = 0; let x = e.touches[0].x, y = e.touches[0].y; let x1 = x - this.size; let y1 = y - this.size; if (bool) { this.clearPoints.push({ x1: x1, y1: y1, x2: x1 + this.r, y2: y1 + this.r }) } for (let item of this.clearPoints) { if (item.x1 > x || item.y1 > y || item.x2 < x || item.y2 < y) { count++; } else { break; } } if (len === count) { this.clearPoints.push({ x1: x1, y1: y1, x2: x1 + this.r, y2: y1 + this.r }); } //添加计算已清除的面积,达到标准值后,设置刮卡区域刮干净 let clearNum = parseFloat(this.r * this.r * len) / parseFloat(this.scale * this.totalArea); if (!this.show) { this.page.setData({ clearNum: parseFloat(this.r * this.r * len) / parseFloat(this.scale * this.totalArea) }) }; if (this.startCallBack) this.startCallBack(); //console.log(clearNum) if (clearNum > this.showPercent) { //if (len && this.r * this.r * len > this.scale * this.totalArea) { this.show = true; } this.clearArcFun(x, y, this.r, this.ctx); } clearArcFun(x, y, r, ctx) { let stepClear = 1; clearArc(x, y, r); function clearArc(x, y, radius) { let calcWidth = radius - stepClear; let calcHeight = Math.sqrt(radius * radius - calcWidth * calcWidth); let posX = x - calcWidth; let posY = y - calcHeight; let widthX = 2 * calcWidth; let heightY = 2 * calcHeight; if (stepClear <= radius) { ctx.clearRect(posX, posY, widthX, heightY); stepClear += 1; clearArc(x, y, radius); } } } } export default Scratch [代码] 1.2 JS 调用方法 [代码]new Scratch(self, { canvasId: '#coverCanvas', //对应的canvasId width: 600, height: 300, //maskColor:"", //封面颜色 showPercent: 0.3, //刮开多少比例显示全部,比如0.3为 30%面积 bgImg: "./cover.jpg", //封面图片 overCallBack: () => { //刮奖刮完回调函数 }, startCallBack: () => { //当用户触摸canvas板的时候触发回调 } }) [代码] 实际中还支持其他很多的配置项,比如缩放比例,刮开比例,放置区域等等,大家可以根据实际需求设置。 1.3 实际页面中的JS调用方法: [代码]//引入刮刮乐部分 import Scratch from './scratch.js'; const app = getApp() Page({ data: { firstTouch: 0, isOver: 0, }, onLoad() { let self = this; new Scratch(self, { canvasId: '#coverCanvas', width: 600, height: 300, //maskColor:"", //封面颜色 bgImg: "./cover.jpg", //封面图片 overCallBack: () => { this.setData({ isOver: "结束啦" }) //this.clearCanvas(); }, startCallBack: () => { this.setData({ firstTouch: "开始刮啦" }) //this.postScratchSubmit(); } }) }, //刮卡已刮干净 clearCanvas() { let self = this; console.log("over"); }, }) [代码] 1.4 HTML/CSS [代码]<-- html --> <view class="wrap"> <canvas class="cover_canvas" type="2d" disable-scroll="false" id='coverCanvas' bindtouchstart="touchStart" bindtouchmove="touchMove" bindtouchend="touchEnd"></canvas> <image class="img" src="reward.jpg" mode="widthFix" /> </view> /* css */ .wrap { width: 600rpx; height: 300rpx; margin: 100rpx auto; border: 1px solid #000; position: relative; } .cover_canvas { width: 600rpx; height: 300rpx; z-index: 9; } .wrap .img { position: absolute; left: 0; top: 0; z-index: 1; width: 600rpx; height: 300rpx; } [代码] 这里注意 type=“2d” 写法,这里使用的是新的2D canvas。 2、注意事项 canvas一些效果不支持真机调试,直接预览就行了 如果刮奖结果是通过第一次触碰canvas触发的,这里的请求需要写一个同步方法 刮刮乐JS的配置会优先判断bgImg这个属性,再判断maskColor 需要反复刮奖,可以反复new 它。 3、代码片段 地址: https://developers.weixin.qq.com/s/RxiaHam574or 建议将IDE工具升级到 1.03.24以上,避免一些BUG [图片] 觉得有用,请点个赞,这是我继续分享的动力~
2021-02-18 - 云数据库反范式化设计转范式化设计
记录一次云数据库设计的重构。 使用云开发之后,一个小程序可以快速的从无到有上线运行,这个速度是传统开发不能比的,特别适合初创团队快速上线产品抢占市场或试错。而使用云开发,我们通常要做的第一件事就是设计数据库,云开发的数据库使用结构化的文档来存储数据,不再是关系型数据库里每个行列交汇处都必须有且只有一个值,它可以是一个数组、一个对象,或者更加复杂的嵌套。 在初期产品需要快速出可用原型,上线时间紧迫的情况下,数据库设计难免会有欠考虑的地方,等产品开始进入迭代期就可能会有重构需求。这里用个人刚经历的一个项目案例来给需要数据库设计重构的朋友们一个参考。 这次数据库重构只有一个目的,把一个最初内嵌的字段提取出来,单独创建一个集合来管理。也就是把反范式化设计的数据库结构转成范式化的设计,这两种数据库设计有何区别可以看另一位云开发布道师东哥的文章,数据库的设计。 在产品上线的第一个版本时,bagList字段是内嵌在一个user文档里的,如下: [图片] 这里的数据是精简版,真实情况还会有很多商品信息、用户信息等,此处只是举例说明。 这样的反范式化设计在最初上线的版本中并没有什么问题,因为商品价格较高,早期也认为用户并不会大量购买。然而没想到的是,在经过一波运营宣传后,用户量开始猛增,其中也出现了一些土豪用户,他们的购买数量已经不是个位数了,有的都超过了100件以上,此时bagList字段的数组长度就变得非常大。在这个时候,数据分页、商品发货、修改商品信息就已经很难维护,一直使用了层层的聚合操作先查询出来,然后再修改。就在想着是否要重构数据结构的时候,新的开发需求来了,要让用户和用户之间可以互换商品,也正是这个需求让我决定了一定要重构数据库。 将bagList字段单独拿出来形成一个集合的好处有很多,数据分页很方便,修改商品信息很简单,且很多云数据库的原子操作修改都可以直接使用,更重要的是新需求互换功能只需要修改对应商品的所有者userid就可以完成。但此时内嵌结构已经使用了很久,数据也已经记录了很多,如何把这些历史数据无缝衔接的拿出来成了问题,这里使用了一系列的聚合操作来完成。 [图片] 这里用的是云开发管理控制台自带的高级操作脚本,首先第一步开启聚合模式,在聚合中单次limit最大数现为10000,因改版时用户数正好低于10000,所以这里直接拉到最大。然后使用match来删选user集合中bagList字段不为空数组的文档。紧接着使用project选定在下一阶段想要的展示的字段,_id字段默认存在,其余字段直接舍弃。此时的执行结果如下图: [图片] 接下来我们就需要用unwind来拆分bagList,拆分完的数据结构如下: [图片] 此时每一个商品已经单独抽离出来,如果此时的结构已经达到了想要的要求,那就可以直接使用现有数据,如果还想自定义一下,那就可以继续使用聚合操作来完成,如上面我因为还有其他需求,使用聚合再次改变了一些结构写法,聚合的操作可以去云开发文档聚合学习。 聚合出来的数据并不是严格的json数据,现在的云开发控制台的高级脚本可以批量添加数据,add方法中的data可以为数组,这在数据量小的情况下可以直接使用,而我们这次聚合出来几千条数据,经测试,云开发的高级脚本并不支持那么大的数据量一次性导入,那么我们可以使用数据库的json格式导入。 创建一个新集合products,这里使用vscode把我们聚合出来的数据复制粘贴到一个名为products.json的新文件中(名称随意),然后将最外层的[]包裹删除,全局搜索 },换行{ 替换为 }换行{ ,把每条数据之间的逗号去除(注意:在搜索的时候,换行也要,不然内嵌数据的逗号也会被替换),保存并使用json方式把数据导入到products集合就大功告成啦。 因本人有此需求然而并没有找到相关资料,所以将自己摸索的方法和大家分享一下,如果有更好的无缝衔接的方法欢迎评论告知,谢谢~
2020-05-05 - 用【库存】看懂云开发数据库事务
在正常使用数据库(CRUD)的情况下,这些操作都会顺利进行所有数据都会被成功更新,由于某些特定的业务场景,需要进行一系列的操作,在这过程中必须保证每一步的操作都正常执行,如果任何一个环节出了差错,比如更新库存信息发生异常,这终将会导致数据库的信息混乱而不可预测,数据库事务正是用来保证这种一系列操作的稳定性技术。 什么是事务? 数据库事务是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成。 事务的特性 ACID,指数据库事务正确执行的四个主要特性的缩写,一个事务,必需要具有这四种基本特性,否则在事务过程当中无法保证数据的正确性。 1:原子性(Atomicity) 指的是一个事务内所有操作共同组成一个原子包,要么全部成功,要么全部失败。 假如在数据库中对一个属性进行了更新,但是执行到一半的时候出现了异常,这样就可能使得操作后的数据与我们预期的数据不同,所以原子性要求你这个方法要么全部执行成功,要么全部失败 2:一致性(Consistency) 指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。 在原子性中规定方法中的操作都执行或者都不执行,但并没有说要所有操作一起执行,所以操作的执行也是有先后顺序的,那我们要是在执行一半时查询数据库,那我们会得到中间的更新的属性?一致性规定提交前后只存在两个状态,提交前的状态和提交后的状态 3:隔离性(Isolation) 指的是数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。 多个事务可能操作同一数据库资源,不同的事务为了保证隔离性,如果没有隔离会造成几种问题 事务A读到事务B修改却未提交的数据,事务B回滚数据修改操作,导致了事务A获得数据是脏数据 事务A先读取数据,事务B对数据进行修改,事务B再一次读取该行数据时就会造成前后两次读取结果不一致 事务A读取数据,事务B对其进行操作时,当事务A重新读取该段数据时会造成前后两次查询的数据不一致的现象 目前云开发数据库使用的是快照隔离,具体将在下面进行介绍 4:持久性(Durability) 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失 如果没有持久性的特性,一旦数据库出现异常,数据将会丢失 拥有持久性事务一旦提交后,数据库中的数据必须被永久的保存下来,即使服务器系统崩溃或服务器宕机等故障,只要数据库重新启动,那么一定能够将其恢复到事务成功结束后的状态。 ####云开发数据库事务 介绍 云开发数据库本身有提供(如 inc、mul、addToSet)等原子性操作符号和嵌套记录的数据结构设计,如跨多个记录或跨多集合的原子操作时,可以使用云数据库事务能力。 隔离性 云开发数据库事务过程中采用的快照隔离级别(snapshot),在事务期间,读操作返回的是对象的快照,而非实际数据,事务期间写操作执行时: 改变快照,保证接下来的读的一致性; 给对象加上事务锁 事务锁 数据对象存在事务锁对数据写入的影响: 其它事务的写入会直接失败; 普通的更新操作会被阻塞,直到事务锁释放或者超时事务提交后,操作完毕的快照会被原子性地写入数据库中 单记录操作 云开发数据库事务中不支持批量操作,只支持单记录操作比如(collection.doc, collection.add),单记录操作可避免大量锁冲突、保证运行效率,并且大多数情况下单记录操作足够满足需求,因为在事务中是可以对多个单个记录进行操作的,也就是可以在一个事务中同时对集合 A 的记录 x 和 y 两个记录操作、又对集合 B 的记录 z 操作,接下来会通过小示例来进行演示。 事务 API 云开发数据库事务提供两种操作风格的接口,一个是简易的、带有冲突自动重试的runTransaction接口,一个是流程自定义控制的startTransaction接口。 使用小示例 假设有以下场景: 某仓库有1000箱医用口罩,A医院需要800箱、B医院需要300箱并提交申请,仓库的管理模式是先收到提交申请在进行库存商品确认完毕后,进行领用。 在无事务的情况下伪代码自上而下执行 [代码]const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const _ = db.command exports.main = async (event, context) => { // 医院 await db.collection('resource').doc('A'||'B') .update({ data: { resource: _.inc(-800||-300) }, }) // 仓库 await db.collection('store').doc('store') .update({ data: { resource: _.inc(+800||+300), }, }) } // 判断是否满足要求 if('仓库库存' >'领用数量' ){ await db.collection('store').doc('store') .update({ data: { count:_inc(-800||-300), }, }) }eles{ '回退的业务逻辑' } } [代码] 根据以上的代码执行结果来看: A/B医院提交了领用口罩的申请; 仓库接收了B医院提交的申请; 判断是否符合数量要求 执行到3时候发现仓库库存,并不能满足医院的领取要求时,需要将提交申请退还给医院,并处理一些退回的逻辑。 该情况下需要处理操作量大、复杂度高、在高并发的执行情况下会导致一些具体的操作没有完成比如: 医院提交了申请,仓库并没有收到; 医院提交了申请,仓库收到申请,并没有执行发放,也没有退还给医院; 事务的情况下伪代码自上而下执行 [代码]const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database({ throwOnNotFound: false, }) const _ = db.command exports.main = async (event) => { try { const result = await db.runTransaction(async transaction => { const resource = await transaction.collection('resource').doc('A'||'B').get() const store = await transaction.collection('store').doc('store').get() const updateResource = await transaction.collection('resource').doc('A'||'B').update({ data: { resource: _.inc(-800||-300) } }) const updateStoreResource = await transaction.collection('store').doc('store').update({ data: { resource: _.inc(+800||+300), } }) if(store.data.count > 800||300){ const updateStoreCount = await transaction.collection('store').doc('store').update({ data: { count:_inc(-800||-300), } }) // 会作为 runTransaction resolve 的结果返回 return { resourceAccount: resource.data.count + 800||300, } }else{ // 会作为 runTransaction reject 的结果出去 await transaction.rollback('领取失败') } }) return { success: true, resourceAccount: result.resourceAccount, } } catch (e) { console.error(`transaction error`, e) return { success: false, error: e } } } [代码] 根据以上的代码执行结果来看: 1.首先读取了A/B医院与仓库的记录快照; 2.医院提交申请,减少对应的数量; 3.仓库接收到医院的提交申请; 4.判断仓库中的数量是否满足本次领取的数量; 执行到4时候发现仓库库存,并不能满足医院的领取要求时,事务会将所有更改的记录还原到读取记录快照时的数据,也就是说这些执行步骤[代码]要不就都成功,要不就都失败,数据回滚,不需要过多的回退逻辑[代码] 未使用事务 VS 使用事务 未使用事务 由于操作量大,复杂度高,在加上出现高并发的情况就会有数据不一致的情况出现; 回退逻辑复杂; 使用事务 事务由一个有限的数据库操作序列构成,这些操作要么全部执行,要么全部不执行,保证了数据一致性; 在执行事务之后保留了数据对象的快照,执行中出现任何问题可直接回滚; 总结 在使用云开发数据库中,如果仅仅是涉及单记录的修改,完全可以使用如 inc、mul、addToSet)等原子性操作符号,涉及到跨集合以及多个记录同时修改并需要保证一致性的情况,那事务功能将是最好的选择。
2020-09-14 - 优秀开源项目推荐-基于云开发的英文单词对战小程序
在我开始之前,我首先要声明我并不是这个开源项目的开发者/维护者,因此,大家不要太信任我的观点。我确实非常深入地研究了这个项目的代码实现,但是无论如何我也不能保证能跟开发者保持一致。话虽如此,我已经用源码来支持我的观点,并尝试着使我的论点尽可能的真实。 本文背景 是这样的,我最近在开发双人对战答题,在参考git上一些好的开源项目的时候发现了这个小程序,目前这个小程序完全开源的,如果对这个对战模式感兴趣可以学习下。 本文内容 本文介绍一款优秀的开源项目推荐优秀的开源项目推荐基于云开发的英文单词对战小程序 项目介绍 https://juejin.im/post/6844904136215887880 项目地址 https://github.com/arleyGuoLei/wx-words-pk 一下内容摘自开源项目readme 单词天天斗微信小程序云开发实现的单词PK小程序,支持好友对战、随机匹配、人机模式,完整代码,可以直接部署阅览 ~ UI可以披靡市场上所有同类型小程序,体验也是一流的哦 ~ 目前已经有同学在QQ小程序、阿里小程序部署;也有同学修改成了[代码]公务员题库[代码] ~ 期待看到各类优秀产品上线哦 ~ 部署文档: 源码目录下 - 部署文档.md 如果觉得这个文档比较长,可以查看源码目录下 - 精简核心文档.md 上线说明: 源码开源,但上线需要经过作者许可哦 ~ 开发不易、创作不易。需要支付RMB [代码]66+[代码]方可上线,保障作者著作权益 ~ 如果你觉得项目对你有所帮助 ~ 期待得到你的打赏哦 在线体验[图片] UI截图[图片] [图片] [图片] [图片] [图片] [图片] [图片] [图片] [图片] 需求概述[图片] 单词对战模式 对战业务需求解析单词对战的游戏核心为:随机生成一定数量的单词列表的单选题类型题目,题目文本为该单词,有 4 个随机中文释义的选项,其中仅有一个为正确释义,双方用户一起选择释义,正确率高且速度快的用户获得对战胜利。 单词对战游戏分为好友对战、随机匹配、人机对战三种对战的形式,均通过上述游戏核心的方式进行对战。 对战设置用户还可以对以下对战信息进行自定义设置 对战的单词书,用户可以选择自己想要背诵的单词类型,包含四级核心词、四级大纲词、六级核心词、六级大纲词、考研真题核心词、考研大纲词、小学必备词、中考大纲词、高考大纲词、雅思大纲词、商务词汇等多种单词书,亦可以选择随机单词书模式,则将从所有的单词中进行随机抽取;设置每一局对战的单词数目为以下任意一种:8、 10(默认)、 12、 15、 20设置切换下一题是否自动播放单词发音设置错词是否加入到生词本开始和错词的时候是否震动设置默认是否播放背景音乐,游戏中也可以随时关闭/开启背景音乐 其他细节优化加入[代码]正在对战过程中[代码]、[代码]对战已结束[代码]、[代码]房间已满[代码]等非正常类型房间,做出相应的交互提示,然后跳转至首页在对战过程中任意用户退出游戏或掉线,则结束本局游戏,进行对战结算对战结束后,房主可以选择再来一局,当房主创建好再来一局的房间后,另外一个用户可以选择再来一局,加入继续对战在对战过程中,选择错误的单词或使用提示卡选择的单词,自动加入到用户生词本,用户可以在生词本中进行复习加入倒计时机制,每一个单词的对战周期为 10s,超时则判断为错选 完整对战流程图[图片] 词汇挑战模式 词汇挑战模式业务解析词汇挑战的核心为:获取随机的一个单词作为单选题题目文本,包含四个中文释义选项,其中一个为正确答案,选择错误则失败,选择正确再获取随机单词,循环下去。 挑战复活机制在词汇挑战的过程中,如果选择错误,可以有两次复活机会 首次复活:通过分享小程序获得复活机会第二次复活:通过观看一个 15s 之内的广告获得复活机会当第三次选择错误,显示再来一局,从零开始记录分数 其他词汇挑战每正确一个词,得分增加 100 分当挑战失败的时候,如果挑战分数高于历史最高分数,则修改历史最高分数为当前分数,用于排行榜排行可以使用提示卡进行选择 完整挑战流程图[图片] 其他功能 生词本用户可以在生词本中查看在单词对战模式、词汇挑战模式中选择错误的单词可以查看单词及单词释义、播放单词发音、删词生词在设置中可以一键清空所有生词 学习打卡当在单词对战模式中,当天对战局数超过 5 局且胜利局数超过 2 局,则打卡成功可以在在打卡页面查看当日进度,可以查看历史的打卡日历 排行榜排行榜包含词力值、词汇挑战分数、签到天数等排名信息每类排行版显示前 20 名的排名头像和昵称以及分数显示自己当前类目下的排名以及分数 用户相关数据库应记录的用户数据包含:昵称、头像、对战局数、胜利局数、选择的单词本、词力值词力值机制:在单词对战模式、单词挑战模式中,每局对战都可以获得相应的词力值分数,作为用户的经验值 其他建议反馈:用户可以在小程序中,反馈意见,然后再后台可以查看用户留言打赏作者:用户可以在小程序中,通过扫码的形式,对小程序进行打赏小程序友情链接:可通过当前小程序跳转至作者的其他小程序中小程序中加入部分广告,不影响用户体验 团队组成整个项目的产品方案、UI 设计、开发、测试、上线运营等皆一个人独立完成。 技术方案 设计设置使用sketch完成,设计稿上传至[代码]蓝湖[代码],作为数据标注。 蓝湖链接链接:https://lanhuapp.com/url/qe2Dl 密码: ydIX 设计图源文件[图片] [图片] [图片] [图片] 下载链接: https://pan.baidu.com/s/1KsZjvlTUbtyYFDcVCy91lg 密码:vylm 开发技术栈前端:原生微信小程序服务端:微信小程序云开发 其他工具ESLintGit + GithubvscodeElectronNodeJSPython 系统架构 项目文件简介├── cloudfunctions # 云开发_云函数目录 | ├── model_auto_sign_trigger # 自动签到定时触发器 | ├── model_book_changeBook # 改变单词书 | ├── model_userWords_clear # 清除用户生词 | ├── model_userWords_get # 获取用户生词 | └── model_user_getInfo # 获取用户信息 ├── db # 数据整理的脚本 ├── design # 设计稿文件、素材文件 | └── words-pk-re.sketch # 设计稿 ├── docs # 项目文档 ├── miniprogram # 小程序前端目录 | ├── app.js # 小程序全局入口 | ├── app.json # 全局配置 | ├── app.wxss # 全局样式 | ├── audios # 选词正确错误的发音 | | ├── correct.mp3 | | └── wrong.mp3 | ├── components # 全局组件 | | ├── header # header组件 | | ├── loading # 全局loading | | └── message # 全局弹窗 | ├── images | | ├── ... 图片素材 | ├── miniprogram_npm # 小程序npm目录 | | └── wxapp-animate # 动画库 | ├── model # 所有的数据库操作 | | ├── base.js # 基类,所有集合继承该基类 | | ├── book.js # 单词书集合 | | ├── index.js # 导出所有数据库操作 | | ├── room.js # 房间集合 | | ├── sign.js # 签到集合 | | ├── user.js # 用户集合 | | ├── userWord.js # 生词表集合 | | └── word.js # 单词集合 | ├── pages # 页面 | | ├── combat # 对战页 | | ├── home # 首页 | | ├── ranking # 排行榜 | | ├── setting # 设置页 | | ├── sign # 签到页 | | ├── userWords # 生词表页 | | └── wordChallenge # 单词挑战 | └── utils | ├── Tool.js # 全局工具类,放了加载、全局store等 | ├── ad.js # 广告 | ├── log.js # 日志上报 | ├── router.js # 全局路由 | ├── setting.js # 全局设置 | └── util.js # 全局工具函数 ├── package.json └── project.config.json # IDE设置、开发设置 云开发数据交互的 Model 层设计在该项目中,将所有的服务端交互、数据库的读取、云函数的调用都放到了 model 目录下,对该目录结构深入解析。 (1) Base.jsbase 基类,所有其他数据集合都继承该类,在构造函数中,用来做数据集合初始化和生命一些可能所需用到的变量。 import $ from './../utils/Tool' const DB_PREFIX = 'pk_' export default class { constructor(collectionName) { const env = $.store.get('env') const db = wx.cloud.database({ env }) this.model = db.collection(`${DB_PREFIX}${collectionName}`) this._ = db.command this.db = db this.env = env } get date() { return wx.cloud.database({ env: this.env }).serverDate() } /** * 取服务器偏移量后的时间 * @param {Number} offset 时间偏移,单位为ms 可+可- */ serverDate(offset = 0) { return wx.cloud.database({ env: this.env }).serverDate({ offset }) } } (2)其他集合文件 (model 目录下,除了 base 和 index 之外的文件)在这些文件中,对应和文件名同名的集合的所有数据操作,比如 book.js 中,包含了所有对 pk_book 集合的所有数据增删改查操作。 import Base from './base' import $ from './../utils/Tool' const collectionName = 'book' /** * 权限: 所有用户可读 */ class BookModel extends Base { constructor() { super(collectionName) } async getInfo() { const { data } = await this.model.get() return data } async changeBook(bookId, oldBookId, bookName, bookDesc) { if (bookId !== oldBookId) { const { result: bookList } = await $.callCloud('model_book_changeBook', { bookId, oldBookId, bookName, bookDesc }) return bookList } } } export default new BookModel() (3)index.js在该文件中,对所有的数据集合操作文件进行引入,然后又导出,之后在其他文件中的的调用,就只需要引入该文件即可,就可以实现调用不同的集合操作。 import userModel from './user' import bookModel from './book' import wordModel from './word' import roomModel from './room' import userWordModel from './userWord' import signModel from './sign' export { userModel, bookModel, wordModel, roomModel, userWordModel, signModel } 环境区分在小程序初始化的时候,对云开发环境进行了全局的初始化,区别开发环境和正式环境。 // app.js initEnv() { const envVersion = __wxConfig.envVersion const env = envVersion === 'develop' ? 'dev-lkupx' : 'prod-words-pk' // 'prod-words-pk' // ['develop', 'trial', 'release'] wx.cloud.init({ env, traceUser: true }) this.store.env = env }, onLaunch() { this.initEnv() this.initUiGlobal() }, 难点解析 难点 1:单词数据 1. 抓包分析和代码实现本课题中使用 MacOS 系统、Charles 抓包软件、安卓手机作为抓包的基本环境。首先在电脑上安装 Charles,然后开启 Proxy 抓包代理,同局域网下配置手机 WiFi 代理实现抓取手机包。 2. 单词数据整理通过爬虫下来的单词数据如下,对于该课题的项目单词数据相对复杂,所以我们对单词数据结构进行简化,只提取项目中需要的字段,以单词 yum 为例: 优化前: {"wordRank":63,"headWord":"yum","content":{"word":{"wordHead":"yum","wordId":"PEPXiaoXue4_2_63","content":{"usphone":"jʌm","ukphone":"jʌm","ukspeech":"yum&type=1","usspeech":"yum&type=2","trans":[{"tranCn":"味道好","descCn":"中释"}]}}},"bookId":"PEPXiaoXue4_2"} 优化后: {"rank":286,"word":"yum","bookId":"primary","_id":"primary_286","usphone":"jʌm","trans":[{"tranCn":"味道好"}]} 通过 NodeJS 编写批量格式整理的程序,整理后导出 JSON 文件 [图片] 3. 数据文件批量导入(传入数据库)由于微信小程序云开发控制台不支持数据文件的批量导入数据库,所以开发了一个支持云开发数据集合批量导入的程序 [图片] [图片] [图片] 数据库批量导入程序更多解析:https://juejin.im/post/5e2bf3e4f265da3e4244ea7f程序代码开源:https://github.com/arleyGuoLei/wxcloud-databases-import 难点 2:单词对战模式本节详细解析单词对战模式的实现,将从创建房间(生成随机词汇、新增房间数据)、对战监听、对战过程(好友对战、随机匹配、人机对战)、对战结算的角度进行分析。 创建对战房间对战房间的创建,分为触发创建房间事件、获取当前选择的单词书、获取单词对战每一局的词汇数量、从数据库 pk_word 集合读取随机单词、格式化获取的随机单词列表、创建房间(使用生成的单词列表、是否好友对战条件)、根据房间的 roomId(主键)跳转至对战页等多个步骤流程组成。 [图片] 房间数据监听单词对战模式中,对 room 数据集合的监听是对战的核心要点,进入对战页面后,调用数据集合的 WatchAPI 对 room 集合中的当前房间记录进行监听,在当前房间记录数据发生变化的时候,将会调用 watch 函数的回调,执行相应的业务,详细流程如下: [图片] 好友对战的实现有了前面创建好的对战房间,也建立好了对当前房间的数据监听,接下来就可以实现有趣的对战交互了。游戏会监听好友用户准备,更新 room 集合中的 right.openid 字段,触发 watch,通知房主可以开始对战;房主点击开始对战,会更新 room 集合中的 state 字段为 PK,watch 回调通知双方开始对战,显示第一道题目,双方用户选择释义的时候,会把选择结果和得分更新至 left/right 中的 grades 和 gradeSum 字段,在 watch 的回调中对双方的选择结果进行显示;当对战到达最后一道题目,且双方都选择完毕,进入结算流程,将房间 state 更新至 finish;如果在对战过程中,有任意用户离开对战,将修改房间 state 为 leave;对战结束之后,房主可以选择再来一局,进行创建房间,更新上一个房间的 nextRoomId 字段,在 watch 回调中通知非房主用户可以加入新的房间,进行再来一局的对战。 [图片] 随机匹配的实现随机匹配对战相对于好友对战的区别在于:好友对战是通过房主将房间链接(roomId)分享到微信好友/微信群,当用户点击分享卡片之后,会跳转至对战页面且房间 Id 为当前分享的房间 roomId,用户进入房间之后就进行上述的监听操作和准备、开始对战等。然而随机匹配的实现原理为,当用户触发随机匹配操作之后,会先在数据库检索有没有符合自己所选择的单词书、目前房主在等待的房间,如果有则加入该房间,如果没有则创建新的随机匹配房间,等待其他用户进入。用户进入之后会自动触发准备操作,房主在 watch 中监听到有用户准备,然后自动触发开始对战操作,后续对战、结算、再来一局流程则和好友对战流程一致。 [图片] 人机对战的实现人机对战的核心思想为:房主用户端随机取一名人机用户,房主端触发人机的自动准备,房主端也自动开始对战,在对战过程中,房主端通过页面 UI 用户手动选词,人机将在 2~5s 或房主选词之后随机完成选词操作,正确率为 75%。 后期可以对正确率进行优化,根据用户的历史正确率进行自动化推算,实现更智能的人机用户,提供更好的用户体验。 [图片] 最后通过 3 个月的开发、功能迭代和运营,目前拥有2600 多的用户量,小程序用户打分为5.0 满分。创建房间且完成对战12000 多局,收录词汇25960个,收录了用户65000多个生词,十分感谢这个项目带给我的成就感。
2020-09-02 - 答题小程序如何只允许固定用户人群进入答题
本文标题 答题小程序如何只允许固定用户人群进入答题 具体是这样的,昨天晚上接到运营用户反馈,说答题小程序里面有几个不是他们自己的人,参与了答题。 [图片] 这样就造成答题排名的不严谨,比如下图,从第四个开始就不是该答题活动的参与对象,导致在排名里面得分出现了断层现象。这种情况估计领导看到了心里难免会犯嘀咕。 [图片] 这种情况我是已知的,只是没有处理这种情况,目前想到了几种方案 1)私密消息模式; 文档一 https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share/private-message.html 文档二 https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/updatable-message/updatableMessage.createActivityId.html [图片] 2)生成特殊的小程序码,而不是从小程序首页进去。 本文总结 私密消息我是使用过的,如下图所示,在某盒马的群里,分享了抽奖助手的抽奖活动,但是这个小程序不能再次分享,这样就只有群内的用户才可以参与抽奖。 类比答题,其实是一样的。 [图片]
2020-12-05 - 发现开源项目之美一,博客类小程序
从今天开始会找一些优秀的开源小程序项目,每一个开源项目,我都会通过我本人主体小程序进行发布,将个人主体不允许的功能砍掉,审核通过后,才写文章,有一点时间,计划每周出一篇 发现开源小程序之美一,个人博客小程序 https://developers.weixin.qq.com/community/develop/article/doc/000a40e13ec550274e2a9addd56413发现开源小程序之美二,微慕WordPress小程序 https://developers.weixin.qq.com/community/develop/article/doc/000c44945dc728ab9c2aff2a55b013 发现开源小程序之美三,维修上报小程序发现开源小程序之美四,在线答题小程序发现开源小程序之美五,营销组件库 https://developers.weixin.qq.com/community/develop/article/doc/000c4235c98740a1dc2a1a6045b013 第一个开源小程序为个人博客类小程序, 该小程序实现的功能很多,但是我觉得几个核心出彩功能有 1、富文本展示 2、海报生成 3、留言之后自动推送订阅消息 4、留言已经做过内容安全检查 5、版本更新机制,这个功能还是第一次体验到,虽然官方文档有介绍,但是这时第一次体验到这种热更新方式 [图片] 2 [图片] 3 [图片] 4 [图片] 4 特色 该小程序有一点非常棒,就是可以同步公众号文章,通过公众号提供的素材api,可以将公众号的文章定时拉取到小程序云开发数据库 从功能上讲,已经非常完善,从技术角度,涉及到了海报生成、富文本展示、订阅消息、云函数 项目开源地址: https://github.com/CavinCao/mini-blog 具体数据库集合如下所示 [图片] [图片] 这样,为方便大家看数据库的结构,我把这个项目在码云传了一份,数据库文件在data目录里面,这里面仅仅是包含了有数据的集合,没有数据的集合不在,总共有这么11个集合 1、//缓存小程序or公众号的accessToken access_token //小程序文章集合 2、mini_posts //小程序评论内容集合 3、mini_comments //小程序用户操作文章关联(收藏、点赞) 4、mini_posts_related //小程序博客相关配置集合 5、mini_config //小程序博客相关操作日志 6、mini_logs //小程序博客用户FormID(用于模板消息推送)[已经废弃] 7、mini_formids //会员信息表 8、mini_member //签到明细表 9、mini_sign_detail //积分明细表 10、mini_point_detail //订阅消息记录表 11、mini_subcribute 正式一些的话,应该是绑定公众好,通过云函数把公众号的图文数据拉下来,如果不做,可以用我data目录的数据集合,展示的时候也有数据。 上面是数据库,下面我截图下云函数 [图片] 环境变量配置 [图片] 具体通过syncService云函数来同步公众号文章到小程序,直接点击云端测试就可以。 [图片] 部署过程中会遇到各种问题,我应该是花了两个晚上调通了,具体问题我大致回忆下 1、公众号素材拉取的时候要设置白名单 2、有几个云函数要设置环境变量 3、在我的模块有个后台管理,是通过环境变量来配置openid来展示的 4、海报生成要安装npm第三方组件,这个不清楚的,规矩要琢磨半天 5、海报生成模块,必须要等小程序上线之后才可以的 6、留言需要配置订阅消息,订阅消息id记得要变更下 大致记着这么多 为了方便大家部署,我在码云重新创建了下,里面有个data目录,存放着云开发数据库有数据的集合json文件,如果集合中的没有数据,是导不出json文件的,所以建议先手工把集合都创建下,然后,将有数据的集合json导入进去,并改下数据库权限,就能正常跑起来了 如果部署过程中还遇到其他问题,或者遇到问题过不去,都欢迎通过社区私信跟我联系,由于社区运营规范要求,微信联系方式暂不方便在社区发布。 https://gitee.com/xiaofeiyang3369/blog 解决的问题: 2018年之后申请的公众号都不会开放留言功能,通过这个方式,可以将公众号文章通过公众号开放的api,整体拉取下来,到云开发的数据库,然后通过这种方式留言, 注意留言一定要做内容安全检查 从规则来说,除了留言这里有风险之外,技术层面是通的。 2020-04-19更新 新增集合 mini_point_detail 用于积分模块
2020-06-08 - 网页端管理系统在小程序上的实现
接到一个需求,要把一套现有的网页端管理系统完整的复制到小程序上。开始我是拒绝的,想想网页端那些表格要在手机、特别是小程序上复现就头疼。最后甲方给了我一个可以接受的理由:有了小程序就不用做app了啊。 虽然有难度,但是工作还是要做的,有问题就一点一点来解决。先说一个优势,这个网页端是我最近刚重构过的,改成了前后端分离,接口上用了jwt做登录校验(关于jwt的介绍可以移步:【接口相关】聊一聊数据接口的登录态校验以及JWT),可以直接拿到小程序里来用。接下来说一下实际遇到的问题和我的解决方案。 菜单 这个简单,把网页端左侧菜单栏里筛选出常用的放到小程序的tabBar里就可以了,直接用原生的tabBar,没什么花头。 测试的时候发现了一个bug,已经提交给了官方:XS Max真机调试、预览,原生tabBar上的线不显示? 数据表格 网页端数据列表基本都是使用表格来展现,到了小程序端就不适合再用表格了。一方面是小程序没有原生的表格组件,另一方面是手机屏幕不适合展示很宽的表格(横屏什么的从来不在我的考虑范围内)。 我最终采用的解决方案是用卡片列表的形式来展现数据列表。下图是网页端表格和小程序里对应的卡片列表。 [图片] [图片] 模态框+表单 网页端列表中用到的各种表单基本是在弹出模态框里使用表单,这个在小程序上我改成了放到从底部弹出的半屏弹窗。具体效果对比直接看图,小程序里为了防止弹窗内容太多超出屏幕限制,弹窗加了最大高度限制。 [图片] [图片] 日历 由于需求需要,这个项目有一个通过日历展示一个月的日程安排,先看网页端的效果,这里用的是antd的Calendar日历组件。 [图片] 由于手机屏幕的限制,就算能在小程序页面上展现日历,也没办法合适的展现需要的内容。最终决定小程序端只展示某一日的内容,通过从底部弹出的半屏弹窗里显示的日历来切换日期。 [图片] 还是要有取舍 虽然经过各种修改后,绝大多数功能都改成了适合小程序端展现的方式,但是还是有一些功能实在是不适合放到小程序端,或者从功能上来说没必要放到小程序端,这些就只能放弃了。 [图片]
2020-04-10 - 小程序开发中几个常见场景素材尺寸
本文背景在开发小程序的过程中,经常会遇到一些场景需要图片素材,这些素材尺寸如果当时要找起来,说实话,不是很容易,本文基于这个事实进行整理 本文内容具体的场景有 1)小程序logo 2)小程序底部导航图片 3)小程序分享图片尺寸 4)全屏背景图片 1)小程序头像的图片尺寸大小 144px*144px [图片] 占位 2)小程序菜单icon尺寸大小,81px*81px [图片]3) https://developers.weixin.qq.com/miniprogram/dev/reference/api/Page.html#onshareappmessageobject-object [图片] 4)关于第四个场景,具体看评论区留言吧。 本文总结本文主要汇总了小程序开发过程中常见的几个图片素材尺寸
2020-10-07 - 请教关于小程序全屏背景图的尺寸问题?
请教关于小程序全屏背景图的尺寸问题? [图片] 大家都知道不同手机有不同的尺寸,那么在小程序全屏背景图设计时,大家一般是如何处理这个问题的? 或者大家有什么经验? https://developers.weixin.qq.com/miniprogram/dev/component/image.html 比如下面小程序的启动页背景图,现在设计师问我,启动页背景图片尺寸是多少 [图片] 参考帖子 如何图片要填充全屏的话,背景图片的尺寸应该设置为多少?? - 微信开放社区 https://developers.weixin.qq.com/community/develop/doc/000684e6a3c4e803c7e94448a56000 getSystemInfo里面三个高度问题请教?? - 微信开放社区 https://developers.weixin.qq.com/community/develop/doc/0000ec1a4dcbf863abfab2d1f51800 .page-lead z-index 100 height 100% position absolute left 0 right 0 top 0 bottom 0 background #fff url('../../../../images/bg-lead.jpg') center bottom background-size 100% auto z-index 100 所以图片比例为750x1624,保证在iPhone X系列上没问题
2020-10-07 - [填坑手册]小程序Canvas生成海报(一)--完整流程
[图片] 海报生成示例 最近智酷君在做[小程序]canvas生成海报的项目中遇到一些棘手的问题,在网上查阅了各种资料,也踩扁了各种坑,智酷君希望把这些“填坑”经验整理一下分享出来,避免后来的兄弟重复“掉坑”。 [图片] 原型图 这是一个大致的原型图,下面来看下如何制作这个海报,以及整体的思路。 [图片] 海报生成流程 [代码片段]Canvas生成海报实战demo demo的微信路径:https://developers.weixin.qq.com/s/Q74OU3m57c9x demo的ID:Q74OU3m57c9x 如果你装了IDE工具,可以直接访问上面的demo路径 通过代码片段将demo的ID输入进去也可添加: [图片] [图片] 下面分享下主要的代码内容和“填坑现场”: 一、添加字体 https://developers.weixin.qq.com/miniprogram/dev/api/canvas/font.html [代码]canvasContext.font = value //示例 ctx.font = `normal bold 20px sans-serif`//设置字体大小,默认10 ctx.setTextAlign('left'); ctx.setTextBaseline("top"); ctx.fillText("《智酷方程式》专注研究和分享前端技术", 50, 15, 250)//绘制文本 [代码] 符合 CSS font 语法的 DOMString 字符串,至少需要提供字体大小和字体族名。默认值为 10px sans-serif 文字过长在canvas下换行问题处理(最多两行,超过“…”代替) [代码]ctx.setTextAlign('left'); ctx.setFillStyle('#000');//文字颜色:默认黑色 ctx.font = `normal bold 18px sans-serif`//设置字体大小,默认10 let canvasTitleArray = canvasTitle.split(""); let firstTitle = ""; //第一行字 let secondTitle = ""; //第二行字 for (let i = 0; i < canvasTitleArray.length; i++) { let element = canvasTitleArray[i]; let firstWidth = ctx.measureText(firstTitle).width; //console.log(ctx.measureText(firstTitle).width); if (firstWidth > 260) { let secondWidth = ctx.measureText(secondTitle).width; //第二行字数超过,变为... if (secondWidth > 260) { secondTitle += "..."; break; } else { secondTitle += element; } } else { firstTitle += element; } } //第一行文字 ctx.fillText(firstTitle, 20, 278, 280)//绘制文本 //第二行问题 if (secondTitle) { ctx.fillText(secondTitle, 20, 300, 280)//绘制文本 } [代码] 通过 ctx.measureText 这个方法可以判断文字的宽度,然后进行切割。 (一行字允许宽度为280时,判断需要写小点,比如260) 二、获取临时地址并设置图片 [代码]let mainImg = "https://demo.com/url.jpg"; wx.getImageInfo({ src: mainImg,//服务器返回的图片地址 success: function (res) { //处理图片纵横比例过大或者过小的问题!!! let h = res.height; let w = res.width; let setHeight = 280, //默认源图截取的区域 setWidth = 220; //默认源图截取的区域 if (w / h > 1.5) { setHeight = h; setWidth = parseInt(280 / 220 * h); } else if (w / h < 1) { setWidth = w; setHeight = parseInt(220 / 280 * w); } else { setHeight = h; setWidth = w; }; console.log(setWidth, setHeight) ctx.drawImage(res.path, 0, 0, setWidth, setHeight, 20, 50, 280, 220); ctx.draw(true); }, fail: function (res) { //失败回调 } }); [代码] 在开发过程中如果封面图无法按照约定的比例(280x220)给到: 那么我们就需要处理默认封面图过大或者过小的问题,大致思路是:代码中通过比较纵横比(280/220=1.27)正比例放大或者缩小原图,然后从左上切割,竟可能保证过高的图是宽度100%,过宽的图是高度100%。 在canvas中draw图片,必须是一个(相对)本地路径,我们可以通过将图片保存在本地后生成的临时路径。 微信官方提供两个API: wx.downloadFile(OBJECT)和wx.getImageInfo(OBJECT)。都需先配置download域名才能生效。 三、裁切“圆形”头像画图 [代码]ctx.save(); //保存画图板 ctx.beginPath()//开始创建一个路径 ctx.arc(35, 25, 15, 0, 2 * Math.PI, false)//画一个圆形裁剪区域 ctx.clip()//裁剪 ctx.closePath(); ctx.drawImage(headImageLocal, 20, 10, 30, 30); ctx.draw(true); ctx.restore()//恢复之前保存的绘图上下文 [代码] 使用图形上下文的不带参数的clip()方法来实现Canvas的图像裁剪功能。该方法使用路径来对Canvas话不设置一个裁剪区域。因此,必须先创建好路径。创建完整后,调用clip()方法来设置裁剪区域。 需要注意的是裁剪是对画布进行的,裁切后的画布不能恢复到原来的大小,也就是说画布是越切越小的,要想保证最后仍然能在canvas最初定义的大小下绘图需要注意save()和restore()。画布是先裁切完了再进行绘图。并不一定非要是图片,路径也可以放进去~ 小程序 canvas 裁切BUG [代码]ctx.setFillStyle("#fff"); ctx.fillRect(0, 0, 320, 500); //第一个填充矩形 wx.downloadFile({ url: headUri, success(res) { ctx.beginPath() ctx.arc(50, 50, 25, 0, 2 * Math.PI) ctx.clip() ctx.drawImage(res.tempFilePath, 25, 25); //第二个填充图片 ctx.draw() ctx.restore() ctx.setFillStyle("#fff"); ctx.fillRect(0, 0, 320, 500); ctx.draw(true) ctx.restore() } }) [代码] clip裁切这个功能,如果有超过一张图片/背景叠加,则裁切效果失效。 错误参考:http://html51.com/info-38753-1/ 四、将canvas导出成虚拟地址 [代码]wx.canvasToTempFilePath({ fileType: 'jpg', canvasId: 'customCanvas', success: (res) => { console.log(res.tempFilePath) //为canvas的虚拟地址 } }) res: { errMsg: "canvasToTempFilePath:ok", tempFilePath: "http://tmp/wx02935bb29080a7b4.o6zAJswFAuZuKQ5NZfPr….cGnD1a02PlVC0b3284be3a41d08986c2477579a5fd8e.jpg" } [代码] 这里需要把canvas里面的内容,导出成一个临时地址才能保存在相册,比如: http://tmp/wx02935bb29080a7b4.o6zAJswFAuZuKQ5NZfPr5UfJVR4k.cGnD1a02PlVC0b3284be3a41d08986c2477579a5fd8e.jpg 五、询问并获取访问手机本地相册权限 [代码]wx.getSetting({ success(res) { console.log(res) if (!res.authSetting['scope.writePhotosAlbum']) { //判断权限 wx.authorize({ //获取权限 scope: 'scope.writePhotosAlbum', success() { console.log('授权成功') //转化路径 self.saveImg(); } }) } else { self.saveImg(); } } }) [代码] 判断是否有访问相册的权限,如果没有,则请求权限。 六、保存到用户手机本地相册 [代码]wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: function (data) { wx.showToast({ title: '保存到系统相册成功', icon: 'success', duration: 2000 }) }, fail: function (err) { console.log(err); if (err.errMsg === "saveImageToPhotosAlbum:fail auth deny") { console.log("当初用户拒绝,再次发起授权") wx.openSetting({ success(settingdata) { console.log(settingdata) if (settingdata.authSetting['scope.writePhotosAlbum']) { console.log('获取权限成功,给出再次点击图片保存到相册的提示。') } else { console.log('获取权限失败,给出不给权限就无法正常使用的提示') } } }) } else { wx.showToast({ title: '保存失败', icon: 'none' }); } }, complete(res) { console.log(res); } }) [代码] 保存到本地需要一定的时间,需要加一个loading的状态。 七、关于组件中引用canvas [代码]let ctx = wx.createCanvasContext('posterCanvas',this); //需要加this [代码] 在components中canvas无法选中的问题: 在components自定义组件下,当前组件实例的this,表示在这个自定义组件下查找拥有 canvas-id 的 <canvas> ,如果省略则不在任何自定义组件内查找。
2021-09-13 - 实战:让数据说话之自定义埋点分析
需求 一个小程序运营者要通过数据进行优化,这个时候大家第一个会想到的是微信公众平台发布的官方小程序“小程序数据助手”。 “小程序数据助手”当前功能模块包括数据概况、访问基础分析(用户趋势、来源分析、留存分析、时长分析、页面详情)、实时统计和用户画像(年龄性别、省份城市、终端机型),数据与小程序后台常规分析一致。 [图片] 信息以及图片来源小程序助手官方介绍 虽然“小程序数据助手”很强大,但是这些数据能够分析的纬度颗粒度都较大。无法让我们知道那个按钮点击次数以及某一个活动的效果如何,这个时候我们就需要用到埋点来进行业务数据采集,才能达到我们的目的。 自定义埋点分析 这个功能就在小程序管理后台->统计->自定义分析。 [图片] 收集数据 这里的埋点分为两种类型 无需代码埋点-【填写配置】 需要代码埋点-【API上报】 我们进入自定义分析模块点击【新增事件】 [图片] 填写配置 新建详情页面 [图片] 以上三个红框部分要注意: 第一个红框:配置方式决定来是否不需要代码,填写配置就不需要,API上报就需要。 第二个红框:触发条件,默认是点击触发。 第三个红框:锁定埋点目标的必要参数,定位到具体页面的具体元素。 举个🌰: 那我之前做的一个找字小程序来说,我用了填写配置的。我的目的是想知道游戏页面的删除道具用了多少次。 [图片] 首先我们来到来游戏页面,复制这个路径【pages/game/game】,然后定位删除道具的class【.del】。 [图片] 所以就得出来以下配置: [图片] API上报 接下来再介绍一种API上报的方式: [图片] 选择这种模式,可以直接点击生成代码,只需要把代码放到触发事件方法里面即可。 [图片] 这种方式相对更加灵活,只不过修改完成后需要发布审核。 查询数据 上面我们收集到了想要的数据接下来就查询数据。 [图片] 查询数据分为两种: 单个事件的分析-【事件分析】 多个事件的分析-【漏斗分析】 事件分析 刚才收集到了删除功能数据,那么你现在可以从多个纬度来分析。 [图片] 漏斗分析 选择多个事件作为一个漏斗。 如:想看每个页面都流失率,我就会把主页-列表页-答题页都数据做个漏斗。 如流失率比较高,就需要优化,优化后和之前版本对比,通过数据来验证效果。 [图片] 漏斗查询结果: [图片] 每一次查询都会有记录,这样更加便于对比数据。 [图片] 总结 数据能让你的每个功能都有结果,它也是产品迭代依据必不可少的环节。 如果你才知道这个功能,那就赶紧去试试吧!
2020-08-18 - 【集合】花了 3 个月,写了 40 篇小程序文章
前言 花了3个月,一共输出 40 篇文章,这也算是一个阶段性的总结。在此做个文章分类集合,希望对大家有所帮助。 小程序前端 《专治按钮效果不明显(扩散动画效果)》 《小程序开发必备,这 5 款超实用开源插件!》 《仿抽奖助手奖品详情页面向上翻页效果》 《推荐 5 款高仿知名应用的开源项目!》 《生成海报很复杂?有它轻松搞定!》 《推荐一个自定义导航栏开源库》 《前端开发,必备的学习网站!》 《情侣券-领取动画分析》 《通过玩游戏来学习CSS》 《CSS不规范导致的布局显示问题》 《微信小程序如何引入npm包?》 《情侣券-选中卡片翻转动画》 《CSS:实现卡片洗牌效果》 《情侣券 v2.0 使用的 4 款开源组件》 小程序云开发 《使用聚合函数实现打卡排行榜》 《使用云开发做内容安全检查》 《云开发-实现分页功能》 《云开发-实现维护用户表》 《云开发-实现模糊搜索》 《云开发实战:实现订阅消息推送》 《如何优雅的调用云函数?》 《云开发实战-如何维护用户表?(优化版)》 《推荐 10 款使用云开发的开源项目》 《云开发:CloudBase CMS 实战使用指南》 小程序产品 《如何利用小程序提高10倍活动效果?》 《实战:让数据说话之自定义埋点分析》 《#小程序云开发挑战赛#-情侣券》 《小程序运营必备的 3 款官方小程序》 《小程序云开发挑战赛:情侣券 v1.1 版本迭代》 《云开发挑战赛复赛:情侣券介绍PPT》 《参加#小程序云开发挑战赛#复赛收获》 《云开发挑战赛决赛:情侣券介绍PPT》 通用知识 《如何重构?》 《如何高效学习?》 《如何看懂时序图?》 《为什么优秀的程序员都写博客?》 《我从 Android 转到 微信小程序 的思考》 最后 后续计划会写更多云开发相关的文章以及小程序基础系列学习文章。
2020-11-24 - #小程序云开发挑战赛#-小青考证-做什么都队
项目名称:小青考证 项目介绍 小青考证是一款为需要考证的人提供帮助的小程序,其主要功能为:提供各种证书的题库以供用户刷题,提供笔记功能方便用户随时记笔记,提供错题集和收藏夹方便用户随时查漏补缺 应用场景 小青考证目前提供了六种证书的题库(英语四六级,教师资格证,计算机等级证书,机动车驾驶证,国家统一法律职业资格证,注册会计师证),可供需要考取这些证书的用户去刷题,并且其笔记功能可以方便用户在做题过程中随时记下重要的知识 目标用户 需要考取各种证书的用户 实现思路 本小程序完全基于微信小程序的原生开发,用到了云数据库存储数据,使用云函数和小程序端进行数据交互。首先,该小程序需要获取到用户的登录状态,只有登录之后的用户才可以正常使用该小程序的功能。在服务端为小程序端提供了两个权限为所有用户可读的集合(banks-list和writtenQuestions),其中banks-list是所有题库的简介,writtenQuestions是所有题库的详情,在用户进入小程序的同时,会自动为该用户创建包括我的收藏(collectionForUser),我的错题本(wrongForUser),我的笔记(booksForUser)三个私有集合,以便保存用户的各种操作 项目截图 首页 [图片] 题库界面 [图片] 答题界面 [图片] 记笔记界面 [图片] 个人中心界面 [图片] 查看笔记界面 [图片] 代码展示 [图片][图片][图片] 作品二维码体验 由于项目暂时还未上线,所以只有体验版的,需要申请体验 [图片] 项目不足 由于开发时间较短,且项目需要大量题库的数据,人员有限,在1.0.0版本中无法提供较多的题库供用户使用,且由于添加题库数据需在特定的界面(该界面暂未对用户开放)中操作,所以无法直接提供完整的部署教程 部署说明 请先下载[代码]git[代码],然后CMD执行[代码]git clong[代码]克隆项目到本地 修改 [代码]project.config.json[代码] 中的 appid 替换为你自己的 appid 使用微信开发者工具,导入项目 创建云数据库集合 [代码]bank-status[代码]、[代码]booksForUser[代码]、[代码]collectionForUser[代码]、[代码]writtenBankForUser[代码]、[代码]wrongForUser[代码] (数据库集合权限为“仅创建者可读写”)和[代码]banks-list[代码]、[代码]writtenQuestions[代码]、((数据库集合权限为“所有用户可读”),其中: [代码]banks-list[代码]的集合需要导入文件夹中[代码]miniprogram/data/bankList.json[代码]文件,才能正常使用 [代码]writtenQuestions的[代码]集合需要导入文件夹中[代码]miniprogram/data/writtenQuestions.json[代码]文件,才能正常使用 上传并部署[代码]cloudfunctions[代码]内的所有云函数 开始使用 项目开源地址 https://git.weixin.qq.com/zarek/zarek.git 团队简介 钟卓伦: 广东工业大学信息工程学院大三学生,项目开发人员 许芸: 广东工业大学信息工程学院大三学生,项目UI
2020-09-20 - #小程序云开发挑战赛#-群消息公示-flyingman
应用场景 为群管理员提供可编辑图文公告的工具 目标用户 群管理员 | 所有人 实现思路 1.输入的文本及上传的图片处理为rich-text支持的html标签后保存到云数据库 2.发布后保存发布记录,方便后续查看及再分享 3.草稿和正式发布内容分表储存。 效果截图 首页 [图片] 创建通知页 [图片] 草稿箱页 [图片] 分享到群页面 [图片] 功能代码展示 [图片] 小程序体验码 发布审核尚未通过 团队/作者简介 团队为小程序爱好者,非专业前端工作者
2020-09-20 - #小程序云开发挑战赛#-微学堂(在线学习平台)-若有光
(一)应用场景 该在线学习平台应用场景主要是:基于微信小程序设计,在线学习跨越了传统教育地域、时间、物质等方面的限制,学习者可以根据自身的基础、时间、偏好等因素自主选择学习内容、学习进度和学习时间。 (二)目标用户 在校学生,教师 (三)实现思路 (1)对在线学习微平台的实现所用到的微信小程序MINA框架、开发语言、开发组件与API、云开发技术等小程序基础知识以及系统的设计模式进行学习. (2)分析系统的功能需求和系统的逻辑结构,对系统的总体架构、功能模块和数据库进行总体概要设计。分为课程模块、详情模块和个人中心模块这3个模块. (3)搭建和部署云开发环境,运用微信小程序云开发技术编码实现满足功能需求的在线学习微平台。 (4)根据系统的设计目标,完成对系统的测试。 [图片] (四)架构图 [图片] (五)效果图 [图片] [图片] [图片] [图片] [图片] 注:小程序中使用到的课程视频来自网络,仅仅做测试使用,没有用于商业用途. (六)代码地址:https://github.com/kuhang/wx_onlinelearning (七)团队介绍 小程序云开发爱好者,若有光.
2020-09-05 - #小程序云开发挑战赛#-实验室设备预约助手-hello522
1、作品介绍目前部分高校实验室存在设备管理困难的问题,要使用设备可能需要在微信群内反复询问,登记也麻烦。 实验室预约助手目的就在用低成本的云开发实现对实验室的设备使用管理。 2、目标用户 高校师生或有实验室的组织 3、实现思路 云函数验证邀请码正确后进入小程序 用户数据及预约信息上传至云数据库 4、效果截图 [图片] [图片] [图片] [图片] [图片] 5、作者介绍 在校大四的菜鸟,慢慢琢磨规范代码中。欢迎大佬指点一下。 码云链接:https://gitee.com/zimkeavin/laboratory
2020-09-06 - #云开发挑战赛#-山大clubs-SXU1902
山大clubs 一个山大社团矩阵小程序,另外还可以添加收藏社团,还有社团管理员发布端和小程序负责人管理端 目的 解决校园信息分散以及有些社团人员过少宣传力度不足,导致学生获取信息不及时 结合当下的疫情和学生安全,学校内部应该经可能的减少人员的走动,这就不能用原来的宣传方法(走宿舍、走新生楼),通过这种线上的宣传让萌新也能了解学校的社团 目标用户 各个高校的学生 实现思路 对社团协会分类索引,新生可以通过校区、学术类、艺术类、娱乐类等选择自己感兴趣社团协会浏览收藏; 社团负责人通过权限申请将自己社团分类上传发布信息,主要信息有社团名称、迎新群QQ、社团简介; 小程序管理员可以管理社团,包括增加、删除等 用户权限 用户 收藏功能 权限申请 管理社团 管理申请 游客 √ √ ✖ ✖ 社团负责人 √ √ √ ✖ 小程序运营者 √ √ √ √ 小程序总负责人 √ √ √ √ 如何为自己学校制作一个这样的小程序? 小程序完全使用小程序的云开发,所以需要开通小程序云开发(真的是方便,免费的配额就够用,也不需要维护服务器) 将cloudfunctions函数部署到云,数据库建两个集合users和clubs 然后就进行简单的修改社团分类就可以发布适合自己学校的小程序 社团信息实时更新 为了让社团信息实时更新,动手做之前想了不少方法,但翻阅云开发文档发现,原来云数据库已经有这样的功能(厉害啦), 云开发文档 [代码] db.collection('clubs') .watch({ onChange: snapshot=>{ wx.showLoading({ title: '加载中...', mask: true }); this.dealdata(snapshot.docs) console.log('is init data', snapshot.type === 'init') }, onError: function (err) { console.error('the watch closed because of error', err) } }) [代码] 部分页面截图 首页 [图片] 社团详细信息展示(社团介绍、迎新QQ群等) [图片] 用户信息界面(超级管理员截图,其他人功能没有这么多) [图片] 管理社团界面 [图片] 视频介绍 点这里 作者介绍 一个发愁找不到工作的大四学生 一个想着出国留学的大二学生 进一步 提供订阅信息给用户提醒 使用微信支付,为社团报名收款 二流的程序猿,即使使用了这么优秀的前端UI框架,前端依旧这么丑,未来可以优化一下 体验 [图片] 致谢 ColorUI Mini-add-tips 云开发 开源地址 开源地址给个star好不好
2020-09-09 - #小程序云开发挑战赛#-乐考吧-鸡蛋汤不加糖
应用场景 主要针对有孩子的父母、想要记录孩子成长的家长等,也适用于学学生自己记录成绩。 目标用户 家长、孩子等 实现思路 提供科目分类,最多提供12个科目。每个科目提供最近10次的统计图表和历史成绩记录。科目和成绩都可以修改和删除。 架构图 [图片] 效果截图 [图片] [图片] [图片] [图片] 功能代码展示 [图片][图片] [图片][图片] 源码链接 https://gitee.com/rdlsmile/cloud_exam.git 作品体验二维码 [图片] 团队简介 我和我老婆因为一碗鸡蛋汤是甜的还是咸的吵了起来,然后就成了一家人。从此鸡蛋汤不加糖成了我们的特殊记忆。
2020-09-08 - #小程序云开发挑战赛#-趣味游乐城-SuperQ
一、应用场景 本应用适合利用碎片休闲打发时间,通过游乐放松心情,感悟人生、扩展社交、增进友谊。本应用最大的特色是处处充满随机,有道是人生如潮,有得意也总有失意,不总是按部就班,也正因为有很多不可预期,带了阵阵的惊喜或惊吓。 玩家以精灵视角穿行于游乐城,游玩各类游乐项目,通过抽取钻石盲盒获得钻石,拿钻石换取金币,用金币补充精灵和城堡的每日消耗,以及支付日常有趣社交。 二、目标用户 碎片闲暇用户 三、实现思路 用户无需注册,进入小程序即生成用户,获得原始金币。可通过盲盒寻宝获得钻石,兑换钻石获取金币,钻石价格每日波动,犹如股票,持有或出仓,是赚是赔皆无定数。人生处处有玄机,游戏中亦是如此。精灵、城堡每日消耗金币,需要不断赚取金币维持,偶或间碰到打家劫舍的,蚀财损物再所难免,偶或好友疏财相助,也不亦乐,行为的善恶关乎自己的魅力值。精灵魅力值、生命值,房屋的完善度关乎打劫和被打劫的金币、钻石数。 四、架构图 功能架构 [图片] 系统架构 [图片] 五、效果截图 [视频] [图片][图片][图片][图片] [图片][图片][图片][图片] 六、功能代码展示 每日定时任务代码 // 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init({ env: '***'}) const db = cloud.database() const _ = db.command // 云函数入口函数 exports.main = async (event, context) => { const wxContext = cloud.getWXContext() clearRecords() updateTreasurePrice() updateUserInfo() //清理record async function clearRecords() { let day = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000 + 8 * 60 * 60 * 1000)) console.log(day) let res = await db.collection('Record').where({ createTime: _.lt(day) }).remove() console.log("clearRecords = 'ok'", res) } //更新宝石每日成交价 async function updateTreasurePrice() { let res2 = await db.collection('Setting').doc('5a93cec95edf0fc400679b4f23f37a84') .get() let treasures = res2.data.treasures for (let i = 0; i < treasures.length; i++) { let rate = ((Math.random() * 20) - 10) / 100 let price = Math.floor(treasures[i].price * (1 + rate)) treasures[i].price = price treasures[i].rate = rate } await db.collection('Setting').doc('5a93cec95edf0fc400679b4f23f37a84') .update({ data: { treasures: treasures } }) console.log("updateTreasurePrice = 'ok'") } //更新用户每日 async function updateUserInfo() { let day = new Date(Date.now() + (8 * 60 * 60 * 1000)) let today = day.getFullYear().toString() + (day.getMonth() + 1).toString() + day.getDate().toString(); let userCount = await db.collection('User').count() let total = userCount.total let list = [] for (let j = 0; j < total; j += 100) { list = list.concat(await getList(j)); } for (i = 0; i < list.length; i++) { let houseLife, roomSum if (list[i].house.roomSum == 0 && list[i].house.life <= 10) { houseLife = 0 roomSum = 0 } else if (list[i].house.roomSum > 0 && list[i].house.life <= 10) { houseLife = 100 roomSum = list[i].house.roomSum - 1 } else { houseLife = list[i].house.life - 10 roomSum = list[i].house.roomSum } let life = list[i].pet.life < 4 ? list[i].pet.life : 4 let charm = list[i].pet.charm < 4 ? list[i].pet.charm : 4 //更新用户数据 let res = await db.collection('User').doc(list[i]._id) .update({ data: { loginDay: today, dialTimes: 0, openBoxTimes: 0, 'house.life': houseLife, 'house.roomSum': roomSum, 'pet.life': _.inc(-life), 'pet.charm': _.inc(-charm) } }) } console.log("updateUserInfo = 'ok'") } async function getList(skip) { let res = await db.collection('User') .skip(skip).get() return res.data; } } 七、作品体验二维码 [图片] 八、团队介绍 个人小程序开发爱好者。码云:https://gitee.com/kyalong/super-q-amusement-park
2020-09-20 - #小程序云开发挑战赛#-日常工具box-七西队
日常工具box一个较为实用的日常小工具分为五大功能:手持弹幕、九宫切图、任务清单、写字板、指南针每个功能相对独立,为方便用户使用,集合在一个微信小程序内[图片] 目标用户: 适用于各个年龄段的微信用户,主要目标用户为年轻人群体。 具体功能介绍: 手持弹幕:编辑栏中输入需要滚动的语句,并点击发送,可在属性栏中选择字体大小、颜色、速度、背景颜色。九宫切图:上传图片后,可自动将图片剪切为九张图片,可选择保存。任务清单:输入各个任务内容,可勾选是否完成。写字板:相当于手机临时草稿纸,可直接手写绘画。指南针:判断当前方位。具体功能图片: [图片] [图片] [图片] [图片] [图片] 微信小程序体验二维码: [图片] 演示视频: https://v.qq.com/x/page/r31514lawis.html 功能代码: https://github.com/w-jess/Test.git
2020-09-13 - 小程序奇技淫巧之 -- 日志能力
日志与反馈 前端开发在进行某个问题定位的时候,日志是很重要的。因为机器兼容性问题、环境问题等,我们常常无法复现用户的一些bug。而微信官方也提供了较完整的日志能力,我们一起来看一下。 用户反馈 小程序官方提供了用户反馈携带日志的能力,大概流程是: 开发中日志打印,使用日志管理器实例 LogManager。 用户在使用过程中,可以在小程序的 profile 页面(【右上角胶囊】-【关于xxxx】),点击【投诉与反馈】-【功能异常】(旧版本还需要勾选上传日志),则可以上传日志。 在小程序管理后台,【管理】-【反馈管理】,就可以查看上传的日志(还包括了很详细的用户和机型版本等信息)。 这个入口可能对于用户来说过于深入(是的,官方也发现这个问题了,所以后面有了实时日志),我们小程序也可以通过[代码]button[代码]组件,设置[代码]openType[代码]为[代码]feedback[代码],然后用户点击按钮就可以直接拉起意见反馈页面了。利用这个能力,我们可以监听用户截屏的操作,然后弹出浮层引导用户主动进行反馈。 [代码]<view class="dialog" wx:if="{{isFeedbackShow}}"> <view>是否遇到问题?</view> <button open-type="feedback">点击反馈</button> </view> wx.onUserCaptureScreen(() => { // 设置弹窗出现 this.setData({isFeedbackShow: true}) }); [代码] LogManager 关于小程序的 LogManager,大概是非常实用又特别低调的一个能力了。它的使用方式其实和 console 很相似,提供了 log、info、debug、warn 等日志方式。 [代码]const logger = wx.getLogManager() logger.log({str: 'hello world'}, 'basic log', 100, [1, 2, 3]) logger.info({str: 'hello world'}, 'info log', 100, [1, 2, 3]) logger.debug({str: 'hello world'}, 'debug log', 100, [1, 2, 3]) logger.warn({str: 'hello world'}, 'warn log', 100, [1, 2, 3]) [代码] 打印的日志,从管理后台下载下来之后,也是很好懂: [代码]2019-6-25 22:11:6 [log] wx.setStorageSync api invoke 2019-6-25 22:11:6 [log] wx.setStorageSync return 2019-6-25 22:11:6 [log] wx.setStorageSync api invoke 2019-6-25 22:11:6 [log] wx.setStorageSync return 2019-6-25 22:11:6 [log] [v1.1.0] request begin 2019-6-25 22:11:6 [log] wx.request api invoke with seq 0 2019-6-25 22:11:6 [log] wx.request success callback with msg request:ok with seq 0 2019-6-25 22:11:6 [log] [v1.1.0] request done 2019-6-25 22:11:7 [log] wx.navigateTo api invoke 2019-6-25 22:11:7 [log] page packquery/pages/index/index onHide have been invoked 2019-6-25 22:11:7 [log] page packquery/pages/logs/logs onLoad have been invoked 2019-6-25 22:11:7 [log] [v1.1.0] logs | onShow | | [] 2019-6-25 22:11:7 [log] wx.setStorageSync api invoke 2019-6-25 22:11:7 [log] wx.setStorageSync return 2019-6-25 22:11:7 [log] wx.reportMonitor api invoke 2019-6-25 22:11:7 [log] page packquery/pages/logs/logs onShow have been invoked 2019-6-25 22:11:7 [log] wx.navigateTo success callback with msg navigateTo:ok [代码] LogManager 最多保存 5M 的日志内容,超过5M后,旧的日志内容会被删除。基础库默认会把 App、Page 的生命周期函数和 wx 命名空间下的函数调用写入日志,基础库的日志帮助我们定位具体哪些地方出了问题。 实时日志 小程序的 LogManager 有一个很大的痛点,就是必须依赖用户上报,入口又是右上角胶囊-【关于xxxx】-【投诉与反馈】-【功能异常】这么长的路径,甚至用户的反馈过程也会经常丢失日志,导致无法查问题。 为帮助小程序开发者快捷地排查小程序漏洞、定位问题,微信推出了实时日志功能。从基础库 2.7.1 开始,开发者可通过提供的接口打印日志,日志汇聚并实时上报到小程序后台。 使用方式如下: 使用 wx.getRealtimeLogManager 在代码⾥⾯打⽇志。 可从小程序管理后台【开发】-【运维中心】-【实时日志】进入日志查询页面,查看开发者打印的日志信息。 开发者可通过设置时间、微信号/OpenID、页面链接、FilterMsg内容(基础库2.7.3及以上支持setFilterMsg)等筛选条件查询指定用户的日志信息: [图片] 由于后台资源限制,实时日志使用规则如下: 为了定位问题方便,日志是按页面划分的,某一个页面,在onShow到onHide(切换到其它页面、右上角圆点退到后台)之间打的日志,会聚合成一条日志上报,并且在小程序管理后台上可以根据页面路径搜索出该条日志。 每个小程序账号每天限制500万条日志,日志会保留7天,建议遇到问题及时定位。 一条日志的上限是5KB,最多包含200次打印日志函数调用(info、warn、error调用都算),所以要谨慎打日志,避免在循环里面调用打日志接口,避免直接重写console.log的方式打日志。 意见反馈里面的日志,可根据OpenID搜索日志。 setFilterMsg 可以设置过滤的 Msg。这个接口的目的是提供某个场景的过滤能力,例如[代码]setFilterMsg('scene1')[代码],则在 MP 上可输入 scene1 查询得到该条日志。比如上线过程中,某个监控有问题,可以根据 FilterMsg 过滤这个场景下的具体的用户日志。FilterMsg 仅支持大小写字母。如果需要添加多个关键字,建议使用 addFilterMsg 替代 setFilterMsg。 日志开发技巧 既然官方提供了 LogManager 和实时日志,我们当然是两个都要用啦。 log.js 我们将所有日志的能力都封装在一起,暴露一个通用的接口给调用方使用: [代码]// log.js const VERSION = "0.0.1"; // 业务代码版本号,用户灰度过程中观察问题 const canIUseLogManage = wx.canIUse("getLogManager"); const logger = canIUseLogManage ? wx.getLogManager({level: 0}) : null; var realtimeLogger = wx.getRealtimeLogManager ? wx.getRealtimeLogManager() : null; /** * @param {string} file 所在文件名 * @param {...any} arg 参数 */ export function DEBUG(file, ...args) { console.debug(file, " | ", ...args); if (canIUseLogManage) { logger!.debug(`[${VERSION}]`, file, " | ", ...args); } realtimeLogger && realtimeLogger.info(`[${VERSION}]`, file, " | ", ...args); } /** * * @param {string} file 所在文件名 * @param {...any} arg 参数 */ export function RUN(file, ...args) { console.log(file, " | ", ...args); if (canIUseLogManage) { logger!.log(`[${VERSION}]`, file, " | ", ...args); } realtimeLogger && realtimeLogger.info(`[${VERSION}]`, file, " | ", ...args); } /** * * @param {string} file 所在文件名 * @param {...any} arg 参数 */ export function ERROR(file, ...args) { console.error(file, " | ", ...args); if (canIUseLogManage) { logger!.warn(`[${VERSION}]`, file, " | ", ...args); } if (realtimeLogger) { realtimeLogger.error(`[${VERSION}]`, file, " | ", ...args); // 判断是否支持设置模糊搜索 // 错误的信息可记录到 FilterMsg,方便搜索定位 if (realtimeLogger.addFilterMsg) { try { realtimeLogger.addFilterMsg( `[${VERSION}] ${file} ${JSON.stringify(args)}` ); } catch (e) { realtimeLogger.addFilterMsg(`[${VERSION}] ${file}`); } } } } // 方便将页面名字自动打印 export function getLogger(fileName: string) { return { DEBUG: function(...args) { DEBUG(fileName, ...args); }, RUN: function(...args) { RUN(fileName, ...args); }, ERROR: function(...args) { ERROR(fileName, ...args); } }; } [代码] 通过这样的方式,我们在一个页面中使用日志的时候,可以这么使用: [代码]import { getLogger } from "./log"; const PAGE_MANE = "page_name"; const logger = getLogger(PAGE_MANE); [代码] autolog-behavior 现在有了日志组件,我们需要在足够多的地方记录日志,才能在问题出现的时候及时进行定位。一般来说,我们需要在每个方法在被调用的时候都打印一个日志,所以这里封装了一个 autolog-behavior 的方式,每个页面(需要是 Component 方式)中只需要引入这个 behavior,就可以在每个方法调用的时候,打印日志: [代码]// autolog-behavior.js import * as Log from "../utils/log"; /** * 本 Behavior 会在小程序 methods 中每个方法调用前添加一个 Log 说明 * 需要在 Component 的 data 属性中添加 PAGE_NAME,用于描述当前页面 */ export default Behavior({ definitionFilter(defFields) { // 获取定义的方法 Object.keys(defFields.methods || {}).forEach(methodName => { const originMethod = defFields.methods![methodName]; // 遍历更新每个方法 defFields.methods![methodName] = function(ev, ...args) { if (ev && ev.target && ev.currentTarget && ev.currentTarget.dataset) { // 如果是事件类型,则只需要记录 dataset 数据 Log.RUN(defFields.data.PAGE_NAME, `${methodName} invoke, event dataset = `, ev.currentTarget.dataset, "params = ", ...args); } else { // 其他情况下,则都记录日志 Log.RUN( defFields.data.PAGE_NAME, `${methodName} invoke, params = `, ev, ...args); } // 触发原有的方法 originMethod.call(this, ev, ...args); }; }); } }); [代码] 我们能看到,日志打印依赖了页面中定义了一个[代码]PAGE_NAME[代码]的 data 数据,所以我们在使用的时候可以这么处理: [代码]import { getLogger } from "../../utils/log"; import autologBehavior from "../../behaviors/autolog-behavior"; const PAGE_NAME = "page_name"; const logger = getLogger(PAGE_NAME); Component({ behaviors: [autologBehavior], data: { PAGE_NAME, // 其他数据 }, methods: { // 定义的方法会在调用的时候自动打印日志 } }); [代码] 页面如何使用 Behavior 看看官方文档:事实上,小程序的页面也可以视为自定义组件。因而,页面也可以使用[代码]Component[代码]构造器构造,拥有与普通组件一样的定义段与实例方法。但此时要求对应[代码]json[代码]文件中包含[代码]usingComponents[代码]定义段。 完整的项目可以参考wxapp-typescript-demo。 参考 LogManager 实时日志 Component构造器 behaviors 结束语 使用自定义组件的方式来写页面,有特别多好用的技巧,behavior 就是其中一个比较重要的能力,大家可以发挥自己的想象力来实现很多奇妙的功能。
2019-12-10 - 小程序swiper组件的indicator-dots的样式自定义
swiper组件的indicator-dots属性为true时,覆盖组件默认的样式,自定义自己的样式;看到这样的两个css类名 .wx-swiper-dots{ ..... } .wx-swiper-dot-active{ ...... } 可以覆盖默认的样式,但是在官方api中并没有看到相关资料,请问这是哪里来的? 是官方的吗?
2017-12-12 - 小打卡 | 如何基于微信原生构建应用级小程序底层架构(上)
[图片] 大家好,我是小打卡的前端负责人金轩正,今天分享的主题是如何基于微信原生构建应用级小程序底层架构,这个命题看上去好像有些大,不过不要紧,这次分享我把它拆一下,大致从 小程序原生开发面临的问题 小打卡整体架构演进 开发中摸索与实践 这三个方面来看这个讲一下 [图片] 小程序原生开发面临的问题[图片] 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 - 路由的封装
小程序提供了路由功能来实现页面跳转,但是在使用的过程中我们还是发现有些不方便的地方,通过封装,我们可以实现诸如路由管理、简化api等功能。 页面的跳转存在哪些问题呢? 与接口的调用一样面临url的管理问题; 传递参数的方式不太友好,只能拼装url; 参数类型单一,只支持string。 alias 第一个问题很好解决,我们做一个集中管理,比如新建一个[代码]router/routes.js[代码]文件来实现alias: [代码]// routes.js module.exports = { // 主页 home: '/pages/index/index', // 个人中心 uc: '/pages/user_center/index', }; [代码] 然后使用的时候变成这样: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { wx.navigateTo({ url: routes.uc, }); }, }); [代码] query 第二个问题,我们先来看个例子,假如我们跳转[代码]pages/user_center/index[代码]页面的同时还要传[代码]userId[代码]过去,正常情况下是这么来操作的: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { const userId = '123456'; wx.navigateTo({ url: `${routes.uc}?userId=${userId}`, }); }, }); [代码] 这样确实不好看,我能不能把参数部分单独拿出来,不用拼接到url上呢? 可以,我们试着实现一个[代码]navigateTo[代码]函数: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, query }) { const queryStr = Object.keys(query).map(k => `${k}=${query[k]}`).join('&'); wx.navigateTo({ url: `${url}?${queryStr}`, }); } Page({ onReady() { const userId = '123456'; navigateTo({ url: routes.uc, query: { userId, }, }); }, }); [代码] 嗯,这样貌似舒服一点。 参数保真 第三个问题的情况是,当我们传递的参数argument不是[代码]string[代码],而是[代码]number[代码]或者[代码]boolean[代码]时,也只能在下个页面得到一个[代码]string[代码]值: [代码]// pages/index/index.js Page({ onReady() { navigateTo({ url: routes.uc, query: { isActive: true, }, }); }, }); // pages/user_center/index.js Page({ onLoad(options) { console.log(options.isActive); // => "true" console.log(typeof options.isActive); // => "string" console.log(options.isActive === true); // => false }, }); [代码] 上面这种情况想必很多人都遇到过,而且感到很抓狂,本来就想传递一个boolean,结果不管传什么都会变成string。 有什么办法可以让数据变成字符串之后,还能还原成原来的类型? 好熟悉,这不就是json吗?我们把要传的数据转成json字符串([代码]JSON.stringify[代码]),然后在下个页面把它转回json数据([代码]JSON.parse[代码])不就好了嘛! 我们试着修改原来的[代码]navigateTo[代码]: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, data }) { const dataStr = JSON.stringify(data); wx.navigateTo({ url: `${url}?jsonStr=${dataStr}`, }); } Page({ onReady() { navigateTo({ url: routes.uc, data: { isActive: true, }, }); }, }); [代码] 这样我们在页面中接受json字符串并转换它: [代码]// pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(options.jsonStr); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这里其实隐藏了一个问题,那就是url的转义,假如json字符串中包含了类似[代码]?[代码]、[代码]&[代码]之类的符号,可能导致我们参数解析出错,所以我们要把json字符串encode一下: [代码]function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } // pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(decodeURIComponent(options.encodedData)); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这样使用起来不方便,我们封装一下,新建文件[代码]router/index.js[代码]: [代码]const routes = require('./routes.js'); function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { routes, navigateTo, extract, }; [代码] 页面中我们这样来使用: [代码]const router = require('../../router/index.js'); // page home Page({ onLoad(options) { router.navigateTo({ url: router.routes.uc, data: { isActive: true, }, }); }, }); // page uc Page({ onLoad(options) { const json = router.extract(options); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] route name 这样貌似还不错,但是[代码]router.navigateTo[代码]不太好记,[代码]router.routes.uc[代码]有点冗长,我们考虑把[代码]navigateTo[代码]换成简单的[代码]push[代码],至于路由,我们可以使用[代码]name[代码]的方式来替换原来[代码]url[代码]参数: [代码]const routes = require('./routes.js'); function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const url = routes[name]; wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { push, extract, }; [代码] 在页面中使用: [代码]const router = require('../../router/index.js'); Page({ onLoad(options) { router.push({ name: 'uc', data: { isActive: true, }, }); }, }); [代码] navigateTo or switchTab 页面跳转除了navigateTo之外还有switchTab,我们是不是可以把这个差异抹掉?答案是肯定的,如果我们在配置routes的时候就已经指定是普通页面还是tab页面,那么程序完全可以切换到对应的跳转方式。 我们修改一下[代码]router/routes.js[代码],假设home是一个tab页面: [代码]module.exports = { // 主页 home: { type: 'tab', path: '/pages/index/index', }, uc: { path: '/pages/a/index', }, }; [代码] 然后修改[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; if (route.type === 'tab') { wx.switchTab({ url: `${route.path}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${route.path}?encodedData=${dataStr}`, }); } [代码] 搞定,这样我们一个[代码]router.push[代码]就能自动切换两种跳转方式了,而且之后一旦页面类型有变动,我们也只需要修改[代码]route[代码]的定义就可以了。 直接寻址 alias用着很不错,但是有一点挺麻烦得就是每新建一个页面都要写一个alias,即使没有别名的需要,我们是不是可以处理一下,如果在alias没命中,那就直接把name转化成url?这也是阔以的。 [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : name; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 在页面中使用: [代码]Page({ onLoad(options) { router.push({ name: 'pages/user_center/a/index', data: { isActive: true, }, }); }, }); [代码] 注意,为了方便维护,我们规定了每个页面都必须存放在一个特定的文件夹,一个文件夹的当前路径下只能存在一个index页面,比如[代码]pages/index[代码]下面会存放[代码]pages/index/index.js[代码]、[代码]pages/index/index.wxml[代码]、[代码]pages/index/index.wxss[代码]、[代码]pages/index/index.json[代码],这时候你就不能继续在这个文件夹根路径存放另外一个页面,而必须是新建一个文件夹来存放,比如[代码]pages/index/pageB/index.js[代码]、[代码]pages/index/pageB/index.wxml[代码]、[代码]pages/index/pageB/index.wxss[代码]、[代码]pages/index/pageB/index.json[代码]。 这样是能实现功能,但是这个name怎么看都跟alias风格差太多,我们试着定义一套转化规则,让直接寻址的name与alias风格统一一些,[代码]pages[代码]和[代码]index[代码]其实我们可以省略掉,[代码]/[代码]我们可以用[代码].[代码]来替换,那么原来的name就变成了[代码]user_center.a[代码]: [代码]Page({ onLoad(options) { router.push({ name: 'user_center.a', data: { isActive: true, }, }); }, }); [代码] 我们再来改进[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : `pages/${name.replace(/\./g, '/')}/index`; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 这样一来,由于支持直接寻址,跳转home和uc还可以写成这样: [代码]router.push({ name: 'index', // => /pages/index/index }); router.push({ name: 'user_center', // => /pages/user_center/index }); [代码] 这样一来,除了一些tab页面以及特定的路由需要写alias之外,我们也不需要新增一个页面就写一条alias这么麻烦了。 其他 除了上面介绍的navigateTo和switchTab外,其实还有[代码]wx.redirectTo[代码]、[代码]wx.navigateBack[代码]以及[代码]wx.reLaunch[代码]等,我们也可以做一层封装,过程雷同,所以我们就不再一个个介绍,这里贴一下最终简化后的api以及原生api的映射关系: [代码]router.push => wx.navigateTo router.replace => wx.redirectTo router.pop => wx.navigateBack router.relaunch => wx.reLaunch [代码] 最终实现已经在发布在github上,感兴趣的朋友可以移步了解:mp-router。
2019-04-26 - 【周刊-2】三年大厂面试官-前端面试题(偏难)
前言 在阿里和腾讯工作了6年,当了3年的前端面试官,把阿里和腾讯常问的面试题与答案汇总在我的Github中。希望对大家有所帮助,助力大家进入自己理想的企业。 项目地址是:https://github.com/airuikun/Weekly-FE-Interview 如果你在阿里和腾讯面试的时候遇到了什么不懂的问题,欢迎给我提issue,我会把答案和考点都列出来,公布在下一期的面试周刊里。 面试题精选 大家如果去阿里和腾讯面试过,就会发现,在网上刷了很多的前端面试题,但是去大厂面试的时候还是一头雾水,那是因为那些在网上一搜就能搜出来的题,大厂的面试官基本看不上,他们都会问一些开放题,在回答开放题的过程中,就能摸清你知识技能的广度和深度,所以本期会加入几道我在面试候选人常用的开放题,供大家学习和思考。 我把下面每道题的难度高低,和对标了阿里和腾讯的多少职级,都写上去了,大家可以参考一下自己是什么职级。 第 1 题:如何劫持https的请求,提供思路 难度:阿里p6+ ~ p7、腾讯t23 ~ t31 很多人在google上搜索“前端面试 + https详解”,把答案倒背如流,但是问到如何劫持https请求的时候就一脸懵逼,是因为还是停留在https理论性阶段。 想告诉大家的是,就算是https,也不是绝对的安全,以下提供一个本地劫持https请求的简单思路。 模拟中间人攻击,以百度为例 先用OpenSSL查看下证书,直接调用openssl库识别目标服务器支持的SSL/TLS cipher suite [代码] openssl s_client -connect www.baidu.com:443 [代码] 用sslcan识别ssl配置错误,过期协议,过时cipher suite和hash算法 [代码] sslscan -tlsall www.baidu.com:443 [代码] 分析证书详细数据 [代码] sslscan -show-certificate --no-ciphersuites www.baidu.com:443 [代码] 生成一个证书 [代码] openssl req -new -x509 -days 1096 -key ca.key -out ca.crt [代码] 开启路由功能 [代码] sysctl -w net.ipv4.ip_forward=1 [代码] 写转发规则,将80、443端口进行转发给8080和8443端口 [代码] iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080 iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443 [代码] 最后使用arpspoof进行arp欺骗 如果你有更好的想法或疑问,欢迎在这题目对应的github下留言:https://github.com/airuikun/Weekly-FE-Interview/issues/11 第 2 题:前端如何进行seo优化 难度:阿里p5、腾讯t21 合理的title、description、keywords:搜索对着三项的权重逐个减小,title值强调重点即可;description把页面内容高度概括,不可过分堆砌关键词;keywords列举出重要关键词。 语义化的HTML代码,符合W3C规范:语义化代码让搜索引擎容易理解网页 重要内容HTML代码放在最前:搜索引擎抓取HTML顺序是从上到下,保证重要内容一定会被抓取 重要内容不要用js输出:爬虫不会执行js获取内容 少用iframe:搜索引擎不会抓取iframe中的内容 非装饰性图片必须加alt 提高网站速度:网站速度是搜索引擎排序的一个重要指标 如果你有更好的答案或想法,欢迎在这题目对应的github下留言:https://github.com/airuikun/Weekly-FE-Interview/issues/12 第 3 题:前后端分离的项目如何seo 难度:阿里p6 ~ p6+、腾讯t22 ~ t23 使用prerender。但是回答prerender,面试官肯定会问你,如果不用prerender,让你直接去实现,好的,请看下面的第二个答案。 先去 https://www.baidu.com/robots.txt 找出常见的爬虫,然后在nginx上判断来访问页面用户的User-Agent是否是爬虫,如果是爬虫,就用nginx方向代理到我们自己用nodejs + puppeteer实现的爬虫服务器上,然后用你的爬虫服务器爬自己的前后端分离的前端项目页面,增加扒页面的接收延时,保证异步渲染的接口数据返回,最后得到了页面的数据,返还给来访问的爬虫即可。 如果你有更好的答案或想法,欢迎在这题目对应的github下留言:https://github.com/airuikun/Weekly-FE-Interview/issues/13 第 4 题:简单实现async/await中的async函数 难度:阿里p6 ~ p6+、腾讯t22 ~ t23 async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里 [代码]function spawn(genF) { return new Promise(function(resolve, reject) { const gen = genF(); function step(nextF) { let next; try { next = nextF(); } catch (e) { return reject(e); } if (next.done) { return resolve(next.value); } Promise.resolve(next.value).then( function(v) { step(function() { return gen.next(v); }); }, function(e) { step(function() { return gen.throw(e); }); } ); } step(function() { return gen.next(undefined); }); }); } [代码] 如果你有更好的答案或想法,欢迎在这题目对应的github下留言:https://github.com/airuikun/Weekly-FE-Interview/issues/14 第 5 题:1000-div问题 难度:阿里p5 ~ p6、腾讯t21 ~ t22 一次性插入1000个div,如何优化插入的性能 使用Fragment [代码] var fragment = document.createDocumentFragment(); fragment.appendChild(elem); [代码] 向1000个并排的div元素中,插入一个平级的div元素,如何优化插入的性能 先display:none 然后插入 再display:block 赋予key,然后使用virtual-dom,先render,然后diff,最后patch 脱离文档流,用GPU去渲染,开启硬件加速 如果你有更好的答案或想法,欢迎在这题目对应的github下留言:https://github.com/airuikun/Weekly-FE-Interview/issues/15 第 6 题:(开放题)2万小球问题:在浏览器端,用js存储2万个小球的信息,包含小球的大小,位置,颜色等,如何做到对这2万条小球信息进行最优检索和存储 难度:阿里p7、腾讯t31 你面试阿里和腾讯,能否上p7和t31,就看你对开放题能答有多深和多广。 这题目考察你如何在浏览器端中进行大数据的存储优化和检索优化。 如果你仅仅只是答用数组对象存储了2万个小球信息,然后用for循环去遍历进行索引,那是远远不够的。 这题要往深一点走,用特殊的数据结构和算法进行存储和索引。 然后进行存储和速度的一个权衡和对比,最终给出你认为的最优解。 我提供几个能触及阿里p7和腾讯t31级别的思路: 用ArrayBuffer实现极致存储 哈夫曼编码 + 字典查询树实现更优索引 用bit-map实现大数据筛查 用hash索引实现简单快捷的检索 用IndexedDB实现动态存储扩充浏览器端虚拟容量 用iframe的漏洞实现浏览器端localStorage无限存储,实现2千万小球信息存储 这种开放题答案不唯一,也不会要你现场手敲代码去实现,但是思路一定要行得通,并且是能打动面试官的思路,如果大家有更好的idea,欢迎大家到我的github里补充:https://github.com/airuikun/Weekly-FE-Interview/issues/16 第 7 题:(开放题)接上一题如何尽可能流畅的实现这2万小球在浏览器中,以直线运动的动效显示出来 难度:阿里p6+ ~ p7、腾讯t23 ~ t31 这题考察对大数据的动画显示优化,当然方法有很多种。 但是你有没有用到浏览器的高级api? 你还有没有用到浏览器的专门针对动画的引擎? 或者你对3D的实践和优化,都可以给面试官展示出来。 提供几个思路: 使用GPU硬件加速 使用webGL 使用assembly辅助计算,然后在浏览器端控制动画帧频 用web worker实现javascript多线程,分块处理小球 用单链表树算法和携程机制,实现任务动态分割和任务暂停、恢复、回滚,动态渲染和处理小球 如果大家有更好的idea,欢迎大家到我的github里补充:https://github.com/airuikun/Weekly-FE-Interview/issues/17 第 8 题:(开放题)100亿排序问题:内存不足,一次只允许你装载和操作1亿条数据,如何对100亿条数据进行排序。 难度:阿里p6+ ~ p7、腾讯t23 ~ t31 这题是考察算法和实际问题结合的一个问题 众所周知,腾讯玩的是社交,用户量极大。很多场景的数据量都是百亿甚至千亿级别。 那么如何对这些数据进行高效的操作呢,可以通过这题考察出来。 以前老听说很多人问,前端学算法没有用,考算法都是垃圾,面不出候选人的能力 其实。。。老哥实话告诉你,当你在做前端需要用到crc32、并查集、字典树、哈夫曼编码、LZ77之类东西的时候 已经是涉及到框架实现和极致优化层面了 那时你就已经到了另外一个前端高阶境界了 所以不要抵触算法,可能只是我们目前的眼界和能力,还没触及到那个层级 我前面已经公布了两道开放题的答案了,相信大家已经有所参悟。我觉得在思考开放题的过程中,会有很多意想不到的成长,所以我建议这道题大家可以尝试自己思考一下。本题答案会在周五公布到我的github上。 对应的github地址为:https://github.com/airuikun/Weekly-FE-Interview/issues/18 第 9 题:(开放题)a.b.c.d和a[‘b’][‘c’][‘d’],哪个性能更高 难度:阿里p7 ~ p7+、腾讯t31 ~ t32 别看这题,题目上每个字都能看懂,但是里面涉及到的知识,暗藏杀鸡 这题要往深处走,会涉及ast抽象语法树、编译原理、v8内核对原生js实现问题 直接对标阿里p7 ~ p7+和腾讯t31 ~ t32职级,我觉得这个题是这篇文章里最难的一道题,所以我放在了开放题中的最后一题 大家多多思考,本题答案会在周五公布到我的github上 对应的github地址为:https://github.com/airuikun/Weekly-FE-Interview/issues/19 第 10 题:git时光机问题 难度:阿里p5 ~ p6+、腾讯t21 ~ t23 现在大厂,已经全部都是用git了,基本没人使用svn了 很多面试候选人对git只会commit、pull、push 但是有没有使用过reflog、cherry-pick等等,这些都很能体现出来你对代码管理的灵活程度和代码质量管理。 针对git时光机经典问题,我专门写了一个文章,轻松搞笑通俗易懂,大家可以看一下,放松放松,同时也能学到对git的时光机操作《git时光机》 结语 本人还写了一些前端进阶知识的文章,如果觉得不错可以点个star。 blog项目地址是:https://github.com/airuikun/blog 我是小蝌蚪,腾讯高级前端工程师,跟着我一起每周攻克几个前端技术难点。希望在小伙伴前端进阶的路上有所帮助,助力大家进入自己理想的企业。 交流 欢迎关注我的微信公众号,微信扫下面二维码或搜索“前端屌丝”,讲述了一个前端屌丝逆袭的心路历程,共勉。 [图片]
2019-04-17 - 有赞百亿级日志系统架构设计
一、概述 日志是记录系统中各种问题信息的关键,也是一种常见的海量数据。日志平台为集团所有业务系统提供日志采集、消费、分析、存储、索引和查询的一站式日志服务。主要为了解决日志分散不方便查看、日志搜索操作复杂且效率低、业务异常无法及时发现等等问题。 随着有赞业务的发展与增长,每天都会产生百亿级别的日志量(据统计,平均每秒产生 50 万条日志,峰值每秒可达 80 万条)。日志平台也随着业务的不断发展经历了多次改变和升级。本文跟大家分享有赞在当前日志系统的建设、演进以及优化的经历,这里先抛砖引玉,欢迎大家一起交流讨论。 二、原有日志系统 有赞从 16 年就开始构建适用于业务系统的统一日志平台,负责收集所有系统日志和业务日志,转化为流式数据,通过 flume 或者 logstash 上传到日志中心(kafka 集群),然后共 Track、Storm、Spark 及其它系统实时分析处理日志,并将日志持久化存储到 HDFS 供离线数据分析处理,或写入 ElasticSearch 提供数据查询。整体架构如图 2-1 所示。 [图片] 图2-1 原有日志系统架构 随着接入的应用的越来越多,接入的日志量越来越大,逐渐出现一些问题和新的需求,主要在以下几个方面: 业务日志没有统一的规范,业务日志格式各式各样,新应用接入无疑大大的增加了日志的分析、检索成本。 多种数据日志数据采集方式,运维成本较高。 日志平台收集了大量用户日志信息,当时无法直接的看到某个时间段,哪些错误信息较多,增加定位问题的难度。 存储方面: 采用了 Es 默认的管理策略,所有的 index 对应 3*2 shard(3 个 primary,3 个 replica),有部分 index 数量较大,对应单个 shard 对应的数据量就会很大,导致有 hot node,出现很多 bulk request rejected,同时磁盘 IO 集中在少数机器上; 对于 bulk request rejected 的日志没有处理,导致业务日志丢失; 日志默认保留 7 天,对于 ssd 作为存储介质,随着业务增长,存储成本过于高昂; 另外 Elasticsearch 集群也没有做物理隔离,Es 集群 oom 的情况下,使得集群内全部索引都无法正常工作,不能为核心业务运行保驾护航。 三、现有系统演进 日志从产生到检索,主要经历以下几个阶段:采集->传输->缓冲->处理->存储->检索,详细架构如图 3-1 所示 [图片] 图3-1 现有系统架构 3.1日志接入 日志接入目前分为两种方式,SDK 接入和调用 Http Web 服务接入 SDK 接入:日志系统提供了不同语言的 SDK,SDK 会自动将日志的内容按照统一的协议格式封装成最终的消息体,并最后最终通过 TCP 的方式发送到日志转发层(rsyslog-hub); Http Web 服务接入:有些无法使用 SDk 接入日志的业务,可以通过 Http 请求直接发送到日志系统部署的 Web 服务,统一由 web protal 转发到日志缓冲层的 kafka 集群。 3.2日志采集 [图片] 现在有 rsyslog-hub 和 web portal 做为日志传输系统,rsyslog 是一个快速处理收集系统日志的程序,提供了高性能、安全功能和模块化设计。之前系统演进过程中使用过直接在宿主机上部署 flume 的方式,由于 flume 本身是 java 开发的,会比较占用机器资源而统一升级为使用 rsyslog 服务。为了防止本地部署与 kafka 客户端连接数过多,本机上的 rsyslog 接收到数据后,不做过多的处理就直接将数据转发到 rsyslog-hub 集群,通过 LVS 做负载均衡,后端的 rsyslog-hub 会通过解析日志的内容,提取出需要发往后端的 kafka topic。 3.3日志缓冲 Kafka 是一个高性能、高可用、易扩展的分布式日志系统,可以将整个数据处理流程解耦,将 kafka 集群作为日志平台的缓冲层,可以为后面的分布式日志消费服务提供异步解耦、削峰填谷的能力,也同时具备了海量数据堆积、高吞吐读写的特性。 3.4日志切分 日志分析是重中之重,为了能够更加快速、简单、精确地处理数据。日志平台使用 spark streaming 流计算框架消费写入 kafka 的业务日志,Yarn 作为计算资源分配管理的容器,会跟不同业务的日志量级,分配不同的资源处理不同日志模型。 整个 spark 任务正式运行起来后,单个批次的任务会将拉取的到所有的日志分别异步的写入到 ES 集群。业务接入之前可以在管理台对不同的日志模型设置任意的过滤匹配的告警规则,spark 任务每个 excutor 会在本地内存里保存一份这样的规则,在规则设定的时间内,计数达到告警规则所配置的阈值后,通过指定的渠道给指定用户发送告警,以便及时发现问题。当流量突然增加,es 会有 bulk request rejected 的日志会重新写入 kakfa,等待补偿。 3.5日志存储 原先所有的日志都会写到 SSD 盘的 ES 集群,logIndex 直接对应 ES 里面的索引结构,随着业务增长,为了解决 Es 磁盘使用率单机最高达到 70%~80% 的问题,现有系统采用 Hbase 存储原始日志数据和 ElasticSearch 索引内容相结合的方式,完成存储和索引; Index 按天的维度创建,提前创建index会根据历史数据量,决定创建明日 index 对应的 shard 数量,也防止集中创建导致数据无法写入。现在日志系统只存近 7 天的业务日志,如果配置更久的保存时间的,会存到归档日志中; 对于存储来说,Hbase、Es 都是分布式系统,可以做到线性扩展。 四、多租户 随着日志系统不断发展,全网日志的 QPS 越来越大,并且部分用户对日志的实时性、准确性、分词、查询等需求越来越多样。为了满足这部分用户的需求,日志系统支持多租户的的功能,根据用户的需求,分配到不同的租户中,以避免相互影响。 [图片] 针对单个租户的架构如下: [图片] SDK:可以根据需求定制,或者采用天网的 TrackAppender 或 SkynetClient; Kafka 集群:可以共用,也可以使用指定 Kafka 集群; Spark 集群:目前的 Spark 集群是在 yarn 集群上,资源是隔离的,一般情况下不需要特地做隔离; 存储:包含 ES 和 Hbase,可以根据需要共用或单独部署 ES 和 Hbase。 五、现有问题和未来规划 目前,有赞日志系统作为集成在天网里的功能模块,提供简单易用的搜索方式,包括时间范围查询、字段过滤、NOT/AND/OR、模糊匹配等方式,并能对查询字段高亮显示,定位日志上下文,基本能满足大部分现有日志检索的场景,但是日志系统还存在很多不足的地方,主要有: 缺乏部分链路监控:日志从产生到可以检索,经过多级模块,现在采集,日志缓冲层还未串联,无法对丢失情况进行精准监控,并及时推送告警。 现在一个日志模型对应一个 kafka topic,topic 默认分配三个 partition,由于日志模型写入日志量上存在差异,导致有的 topic 负载很高,有的 topic 造成一定的资源浪费,且不便于资源动态伸缩。topic 数量过多,导致partition 数量过多,对 kafka 也造成了一定资源浪费,也会增加延迟和 Broker 宕机恢复时间。 目前 Elasticsearch 中文分词我们采用 ikmaxword,分词目标是中文,会将文本做最细粒度的拆分,但是日志大部分都是英文,分词效果并不是很好。 上述的不足之处也是我们以后努力改进的地方,除此之外,对于日志更深层次的价值挖掘也是我们探索的方向,从而为业务的正常运行保驾护航。 文末福利 4月27日(周六)下午13:30 有赞技术中间件团队联合Elastic中文社区 围绕Elastic的开源产品及周边技术 在杭州举办一场线下技术交流活动 本次活动免费开放,限额200名 扫描下图二维码,回复“报名”即可参加 [图片] 欢迎参加,咱们一起聊聊~
2019-04-15 - 列表中多个 swiper 优化方案探讨
在做一个浏览图片的小程序, 页面中含有多个 swiper, 翻页多了之后滑动卡的很. 但又想在首页中展示大图轮播和自动翻页, 尝试过多个方案, 最终实现如下: 方案1: 减少单条记录中 swiper 个数, 只保留3个. 在 onChange 时切换图片. 优点: 每次只加载一张图片, 提高页面载入速度; 缺点: 切换图片不能平滑过渡, 每次切换图片会显示 loading. 同时在白色背景下会闪烁. 改为黑色背景会好很多. 使用方案1之后, 会大大减少页面中 swiper-item 的数量, 但一旦加载多页后, swiper个数多了还是会卡顿, 尤其是在 android 下, 更加明显. 方案2: 通过wx.createIntersectionObserver()来监测当前页面中显示元素, 使用二维数组分页之后, 可以控制只让当前显示页来使用 swiper 来轮播, 其他已经翻过去的页面都可以设置为组图中的单张图片(或者直接设置成空白占位). 这样可以保证基本上只有一个 pageSize 页面中含有 swiper(最多两个页面, 在翻页交界过程中监测都在当前屏幕中显示), 能大大减少卡顿. 目前使用了这两个方案后, 用 Android手机测试, 滑动基本上就不卡了. 如果还有其他方案, 欢迎大家探讨.
2019-04-05 - 浅谈前端/软件工程师的代码素养
“程序是写给人读的,只是偶尔让计算机执行一下。” ——Donald Ervin Knuth(高德纳) 关于代码素养 我们常常谈到“素养”一词,是指个人在专业领域内实践训练而成的一种修养,在不同的领域中有不同的体现,如在音乐领域中,“音乐素养”是指个人对于音乐的感觉程度,对音高节奏的把控,对不同流派音乐的鉴赏能力等,而在编程领域,也有不同的素养,反映出对基本功、代码整洁度、专业态度等等方面,所谓“代码素养”,简单来说,就是指代码写的是否优雅美观可维护。 绝对完美的代码是不存在的,代码素养并不是指完美主义。在翻译领域有“信,达,雅”的标准,“雅”之所以放在最后,是因为要达到它,需要有比较高的水准和经验积累。类比到编程领域,我们在编程时,第一时间想到的是如何将业务逻辑实现出来,而不是如何把代码优雅地写出来,所以写代码没有所谓的绝对优雅。但是,作为一名专业的前端工程师,确切的说,应该是专业的软件工程师,编写优雅的代码应当是时刻保持的追求,它更像是一个准绳,如同每个人知道自己该做什么,不该做什么,所谓原则,所谓底线,体现出所谓的[代码]“代码素养”[代码]。 破窗理论 破窗理论,原义指窗户破损了,建筑无人照管,人们放任窗户继续破损,最终自己也参与破坏活动,在外墙上涂鸦,任垃圾堆积,最后走向倾颓。 破窗理论在实际中非常容易出现,往往第一个人的代码写的不好,第二个人就会有类似“反正他已经写成这样了,那我也只能这样了”的思想,导致代码越维护越冗杂,最后一刻轰然坍塌,变成无人想去维护的垃圾。 整洁的代码 整洁的代码如同优美的散文,试想读过的一本好书,能够随着作者的笔锋跌宕起伏,充满了画面感,调动了自己的喜怒哀乐。代码虽然没有那样的高潮迭起,但整洁的代码应当充满张力,能够在某一时刻利用这种张力将情节推向高潮。 我更喜欢把写代码类比于写文章讲故事,写代码是创作的过程,作者需要将自己想表达的东西通过代码的形式展现出来,而整洁的代码如同讲故事一般,娓娓道来,引人入胜,不好的代码则让人感觉毫无头绪,通篇不知道在讲什么。 整洁代码原则 在现代化的前端开发中,有很多自动化工具可以帮助我们写出规范的代码,如[代码]eslint[代码],[代码]tslint[代码]等各种辅助校验工具,知名的规范如[代码]google规范[代码]、[代码]airbnb规范[代码]等等也从各个细节方面约束,帮助我们形成合理规范的代码风格。 本小节不再重复语言层面的代码风格,根据实际重构项目,总结出一系列开发过程中需要时刻注意的原则,按照重要程度优先级排列。 1. DRY(Don’t Repeat Yourself) 相信作为一名软件工程师,大家都听说过最基本的DRY原则,很多设计模式,包括面向对象本身,都是在这条原则上做努力。 DRY顾名思义,是指“不要重复自己”,它实际上强调了一个抽象性原则,如果同样或类似的代码片段出现了两次以上,那么应该将它抽象成一个通用方法或文件,在需要使用的地方去依赖引入,确保在改动的时候,只需调整一处,所有的地方都改变过来,而不是到每个地方去找到相应的代码来修改。 在实际工作中,我见过两种在这条原则上各自走向极端的代码: 一种是完全没有抽象概念,重复的代码散落在各处,更奇葩的是,有一部分的抽象,但更多的是重复,比如在common下抽取了一个[代码]data.js[代码]的数据处理文件,部分页面中引用了该文件,而更多页面完全拷贝了该文件中的几个不同方法代码。而作者的意图则是令人啼笑皆非——只用到小部分代码,没必要引入那么整个文件。且不论现代化的前端构建层面可以解决这个问题,即使是引入了整个大文件,这部分多余的代码在gzip之后也不会损失多少性能,但这种到处copy的行为带来后续的维护成本是翻倍的。 对于这种行为还遇到另外一个理由,就是工期时间短,改不动之前的代码,怕造成外网问题,那就拷贝一份相同的逻辑来修改。比如支付逻辑,原有的逻辑为单独的UI浮层+单个支付购买,现在产品提出“打包购买”需求,原有的代码逻辑又比较复杂,出现了“改不动”的现象,于是把UI层和购买逻辑的几个文件整个拷贝过来,修改几下,形成了新的“打包购买”模块,后来产品又提出“按条购买”,按照上述”改不动“原则,又拷贝了一份“按条购买”的模块。这样一来调用处的逻辑就会冗余重复,需要根据不同的购买方式引入不同UI组件和支付逻辑,另外如果新添需求,如支持“分期付款”,那么将改动的是非常多的文件,最可悲的是,最后想要把代码重构为一处统一调用的人,将会面对三份“改不动”的压力,需要众多逻辑中对比分析提取相同之处,工作量已经不能用翻倍来衡量,而这种工作量往往无法得到产品的认同和理解。 另一种极端是过度设计,在写每个逻辑的时候都去抽象,让代码的可读性大大下降,一个简单的for循环都要复用,甚至变量定义,这种代码维护起来也是比较有成本的,还有将迥然不同的逻辑过度抽象,使得抽象方法变得非常复杂,经常“牵一发而动全身”,这种行为也是不可取的。 这也是将该原则排在首位的原因,这种行为导致的重构工作量是最大的,保持良好的代码维护性是一种素养,更是一种责任,如果自己在这方面逃避或偷懒,将把这块工作量翻倍地加在将来别人或自己的身上。 2. SRP(Single Responsibility Principle) SRP也是一个比较著名的设计原则——单一职责,在面向对象的编程中,认为类应该具有单一职责,一个类的改变动机应当只有一个。 对于前端开发来说,最需要贯彻的思想是函数应当保持单一职责,一个函数应当只做一件事,这样一来是保证函数的可复用性,更单一的函数有更强的复用性,二来可以让整体的代码框架更加清晰,细节都封装在一个个小函数中。另外一点也和单一职责有关,就是无副作用的函数,也称纯函数,我们应当尽量保证纯函数的数量,非纯函数是不可避免的,但应当尽量减少它。 把SRP原则排在第二位,因为它非常的重要,没有人愿意看一团乱麻的逻辑,在维护代码时,如果没有一个清晰的逻辑结构,所有的数据定义、数据处理、DOM操作等等一系列细节的代码全部放在一个函数中,导致这个函数非常的冗长,让人本能地产生心理排斥,不愿去查看内部的逻辑。 所有的复杂逻辑放在一个函数中,相信大家看到这样的代码都会眉头一皱: [代码]show: function(a, b) { if (!isInit) { init(); isInit = true; } // reset this.balance = 0; this.isAllBalance = false; var shouldShowLayer = true, preSelectedTermId = 0, needAddress = course.address_state, showTerms, termsObj; var hasPunish = false; this.course = course = course || {}; opt = opt || {}; opt.showMax = opt.showMax || 6; (isIosApp || b.isIAP) && (usekedian = !0, priceSymbol = '<i class="icon-font i-kedian"></i>'), f.splice(b.showMax), layer.show({ $container:b.$container, content:termSelectorTpl({ terms:f, curTermId:b.curTermId || d, name:a.name, hasPunish:h, userInfo:j }, { renderTime:T.render.time.renderCourseTime, renderCourseTime:renderCourseTime, hideUserInfo:b.hideUserInfo, hideTitle:b.hideTitle, hidePayPrice:b.hidePayPrice, confirmText:b.confirmText, sys_time:a.sys_time }), cls:"term-select-new", allowMove:function(a) { return opt.allowMove || ($target.closest('.select-content').length && $('.term-select-new .select-time').height() + $('.term-select-new .select-address').height() + $('.term-select-new .select-discounts').height() > (winWidth > 360 ? 190 : winWidth > 320 ? 175 : 150)); }, afterInit:function(c) { if (needAddress) { that.loadAddress(); // 如果需要地址,且是 app 的话,屏幕可见性切换时需要更新下地址 if (isApp) { $(document).on(visibilityChange, function (e) { // console.log('visibilityChange',document[hidden]); if (!document[hidden]) { // true 参数表示必须刷新 that.loadAddress(true); } }); } } that.afterTermSelect(); $dom.on('click', '.layer-close', function() { setTimeout(function() { !opt.noAutoHide && layer.hide(); }, 100); opt.onCancel && opt.onCancel(); }); $dom.on('click', '.term', function(e) { var $this = $(this); var $terms = $('.term'); if (!$this.hasClass('disabled')) { $terms.removeClass('selected'); $this.addClass('selected'); } that.afterTermSelect(); }); $dom.on('click', '.layer-comfirm', function(e) { var $this = $(this); var termId = $dom.find('.term.selected').data('term-id'); var termName = $dom.find('.term.selected').find('.term-title').html(); var discountId = $dom.find('.discounts-list_item.selected').data('discount-id'); var couId = $dom.find('.discounts-list_item.selected .discounts-coupon').data('cou-id'); var directPay = false; // ios 手Q IAP if (that.toRecharge) { // 需要充值的金额数目 var toRechargePrice = that.curPrice - that.balance; if (isIosApp) { require.async('api', function (api) { api.invoke('api', 'balanceRecharge', { amount: toRechargePrice }); // 充值完成设置回调 api.addEventListener('balanceRechargeCallBack', function(data) { // 支付成功的话 // code=0为成功,其他表示失败 // mode=1表示走充值档位回调,2表示直接充值回调,如果ios 直接充值成功则直接支付 var directPay = data.code === 0 && data.mode === 2; // 执行回调刷新数据 that.toGetBalance(that.course, termId, function() { directPay && $this.trigger('click'); }); }); }); } else { var toRechargePrice = that.curPrice - that.balance; if (that.rechargeMap && Object.keys(that.rechargeMap).indexOf("" + toRechargePrice) > -1) { that.opt.onComfirmClick && that.opt.onComfirmClick(1); iosPay.iosRecharge({ productId: that.rechargeMap[toRechargePrice], count: toRechargePrice, succ: function() { that.toGetBalance(that.course, $('.term.selected').data('term-id')); } }); } else { that.opt.onComfirmClick && that.opt.onComfirmClick(2); // T.jump('/iosRecharge.html?_bid=167&_wv=2147483651'); that.jumpPage('/iosRecharge.html?_bid=167&_wv=2147483651'); } } return; } if (!termId) { require.async(['modules/tip/tip'], function(Tip) { Tip.show(opt.dialogTitle); }); return true; } // check address if (needAddress && !that.addressid) { if (course.must_fill_mailing || !$dom.find('.select-address').hasClass('z-no')) { // 没填地址的话地址框要标红,然后需要滑到视窗让用户看到 var $cnt = $dom.find('.select-content'); var $addressWrap = $dom.find('.select-address_wrapper').addClass('z-err'); var cntRect = $cnt[0].getBoundingClientRect(); var addressBoxRect = $addressWrap[0].getBoundingClientRect(); // console.log('>>>>> ', cntRect, addressBoxRect); if (addressBoxRect.bottom > cntRect.bottom) { $cnt.scrollTop($cnt.scrollTop() + (addressBoxRect.bottom - cntRect.bottom)); } return; } } if (that.isAllBalance && that.opt.onComfirmClick) { that.opt.onComfirmClick(3); } opt.cb && opt.cb(termId, discountId, couId, termName, that.isAllBalance, that.payBalance, that.addressid); setTimeout(function() { !opt.noAutoHide && layer.hide(); }, 300); }); $dom.on('click', '.discounts-list_item', function(e) { var $this = $(this); var $discounts = $('.discounts-list_item'); var isSelected = $this.hasClass('selected'); if (!$this.hasClass('disabled')) { $discounts.removeClass('selected'); $this.addClass(isSelected ? '' : 'selected'); that.setPayPrice(); } }); $dom.on('click', '.address-person .i-edit2, .address-add', function() { var termId = $dom.find('.term.selected').data('term-id'); var courseId = that.course.cid; var src = '/addrEdit.html?_bid=167&_wv=2147483649&ns=1&fr=' + (location.pathname.indexOf('allCourse.html') > -1 ? 4 : location.pathname.indexOf('courseDetail.html') > -1 ? 2 : 3) + '&course_id=' + courseId + '&term_id=' + termId; // T.jump(src); that.jumpPage(src); }).on('click', '.select-address_title .i-right-light', function(e) { var $addressDom = $dom.find('.select-address'); var isOpen = !$addressDom.hasClass('z-no'); if (isOpen) { $addressDom.addClass('z-no'); that.theAddressid = that.addressid; that.addressid = undefined; } else { $addressDom.removeClass('z-no'); that.addressid = that.theAddressid; } }); } }); } else { opt.cb && opt.cb(opt.curTermId || preSelectedTermId); } } [代码] 单一职责并不一定要通过很多函数来完成,也可以用分段达到目的,如同这样: [代码]show(data) { data && this.setData(data); const renderData = { data: this.data, courseData: this.data.courseData, termList: this.termList, userInfo: this.userInfo, addrList: this.addrList, isIAP: this.isIAP, balance: betterDisplayNum(this.balance), curPrice: betterDisplayNum(this.curPrice), curTermId: this.curTermId, discountList: this.discountList, curDisId: this.curDisId, jdSelectId: this.jdSelectId, curAddrId: this.curAddrId }; const formatters = { // formatters termFormatter, priceFormatter, okBtnFormatter, balanceFormatter, priceFormatterWithDiscount }; console.log('[render data]: ', renderData); const html = payLayerTpl(renderData, formatters); // 记录滚动条位置 this._setScrollTop(); // 防止重复append if (this.$view) { this.$view.replaceWith(html); } else { this.$container.append(html); } afterUIRender(() => { this.$view = $('.' + COMPONENT_NAME).show(); this._setContentHeight(); // 动态设置滚动区域的高度 this._restoreScrollTop(); // 恢复滚动位置 this._initEvent(); this._initCountDown(); // 限时折扣倒计时 }); } [代码] 虽然这个函数也没有维持单一职责,但通过“分段”的形式清晰的表明了内部的流程逻辑,这样的代码看起来就会比所有细节揉在一个函数中好很多。 对于单一职责来说,保持起来还是比较困难的,主要在于职责的拆分,有时过于细致的职责拆分也会给阅读带来比较大的困难,对于这种情况,还是拿写作来对比,单一职责相当于文章的一个“段落”,对于文章来说,每个段落都有它的中心思想,可以用一句话描述出来,如果你发现函数的中心思想很模糊,或者需要很多语言去描述它,那也许它已经有很多个职责该拆分了。 3. LKP(Least Knowledge Principle) LKP原则是最小知识原则,又称“迪米特”法则,也就是说,一个对象应该对另一个对象有最少的了解,你内部如何复杂都没关系,我只关心调用的地方。 保持暴露接口的简介易用性也是API设计的通用规则,在实际中发现了这样的一个UI组件: [代码]module.exports = { show: function(course, opt) { // 此处省略一堆逻辑 }, jumpPage: function(url) { // 此处省略一堆逻辑 }, afterTermSelect: function() { // 此处省略一堆逻辑 }, setPrice: function() { // 此处省略一堆逻辑 }, setBalance: function() { // 此处省略一堆逻辑 }, toGetBalance: function(course, curTermId, cb) { // 此处省略一堆逻辑 }, setDiscounts: function(course, curTermId, curPrice) { // 此处省略一堆逻辑 }, filterDiscounts: function(discounts, curPrice) { // 此处省略一堆逻辑 }, isSuitCoupon: function(cou, curPrice) { // 此处省略一堆逻辑 }, setPayPrice: function() { // 此处省略一堆逻辑 }, setTermTips: function(wording) { // 此处省略一堆逻辑 }, loadAddress: function(needUpdate) { // 此处省略一堆逻辑 }, setAddress: function(addressid) { // 此处省略一堆逻辑 } } [代码] 这个UI组件暴露了非常多的方法,有业务逻辑,有视图逻辑,还有工具方法,这时会给维护者带来比较大的困扰,本能的以为这些暴露出去的方法都在被使用,所以想重构其中某些方法都有些不好下手,而实际上,外部调用的方法仅仅是[代码]show[代码]而已。 一个好的封装,无论内部多么复杂,它暴露出来的一定是最简洁实用的接口,而内部逻辑是独立维护的,如上述代码,作为一个UI组件来说,提供最基本的[代码]show/hide[代码]方法即可,有必要时可加入[代码]update[代码]方法自更新,而无需暴露众多细节,造成调用者和维护者的困扰。 4. 可读性基本定理 可读性基本定理——“代码的写法应当使别人理解它所需的时间最小化”。 代码风格和原则不是一概而论的,我们经常需要对一些编码原则和方案进行取舍,例如对于三元表达式的取舍,当我们觉得两种方案都占理时,那么唯一的评判标准就是可读性基本定理,无论写法多么的高超炫技,最好的代码依旧是让人第一时间能够理解的代码。 5. 有意义的名称 代码的可读性绝大部分依赖于变量和函数的命名,一个好的名称能够一针见血地帮助维护者理解逻辑,如同写文章中的“文笔”,文笔优异者总能将故事娓娓道来,引人入胜。 不过要起好名称还是很难的,尤其是我们不是以英语为母语,更是添加了一层障碍,有些人认为纠结在名称上会导致效率变低,开发第一时间应该完成需求的开发。这样说并没有错,我们在开发过程中应当专注于功能逻辑,但不要完全忽视命名,所谓“文笔”是需要锻炼的,思考的越多,命名就会愈加的水到渠成,到后来也就不太会影响工作效率了。 在这里推荐鲍勃大叔提到的童子军规,每一次看自己的代码,都进行一次重构,最简单的重构便是改名,也许一开始觉得命名还比较贴合,但逻辑越写越不符合初始的命名了,当回顾代码时,我们可以顺手对变量和方法进行重新命名,现代编辑工具也很容易做到这一点。 文不对题的命名是最可怕的,如: [代码]function checkTimeConflict(opts) { if (opts.param.passcard || (T.bom.get('autopay') && T.bom.get('term_id'))) { selectToPay({ result: {} }, opts); } else { DB.checkTimeConflict({ param: { course_id: opts.param.courseId, term_id: opts.param.termId }, succ: function(data) { selectToPay(data, opts); }, err: function(data) { dealErr(opts, data); } }); } } [代码] 这个函数被命名为[代码]check*[代码]开头的,本意是检测课程时间是否冲突,但内部逻辑却包含了支付整个流程,此时对于调用者来说,如果不去细看内部逻辑,很有可能就会错误的认为[代码]check[代码]函数没有副作用导致事故发生。 6. 适当的注释维护 注释是一个比较有争议性的话题,有人认为可读的函数变量就很清晰,不需要额外的注释,且注释有不可维护性,如: [代码]// 1-PC, 2-android手QH5, 3-android APP, 4-ios&非手QH5, 5-IOS APP var platform = isAndroidApp ? 3 : isIosApp ? 5 : 4; [代码] 实际上,这个字段的含义早已发生了改变,但由于修改者只修改了逻辑,并没有注意到这一行注释,导致这个老注释提供了错误信息,此时的注释不仅变成了无效注释,甚至会导致维护人的误解,造成bug的产生。 对于这种情况,要么维护注释,要么在注释里面注明接口文档,维护文档,在其他情况下,适当的注释是有必要的,对于复杂的逻辑,如果有一个简练的注释,对于代码可读性的帮助是极大的,但有些不必要的注释可以去掉,注释的取舍关键在于可读性基本定理,如: [代码]const filterFn = (term) => { if (rule.hideEndTerms && term.is_end) { return false; // 隐藏已结束的期 } if (rule.hideSignEndTerms && term.is_out_of_date) { return false; // 隐藏已结束报名的期 } if (rule.hideAppliedTerms && courseUtil.isTermApplied(term)) { return false; // 隐藏已报名的期 } if (rule.hideZeroAllowedTerms && courseUtil.isTermZero(term)) { return false; // 隐藏名额已满的期 } if (rule.productType === productType.PACKAGE) { return false; // 隐藏课程包的班级 } return true; }; [代码] 对于上述逻辑来说,虽然通过变量可以大致猜出功能含义,但一眼看上去就能清晰掌握逻辑结构,归功于注释的简明与清晰。 小结 本文提到的6个代码编写的原则,前三个偏向于代码维护性,后三个偏向于代码可读性,整个可维护性和可读性构成了代码的基本素养。作为一名前端开发工程师,想要拥有良好的代码素养,首先要让自己的代码可维护,不给别人的维护带来巨大的成本和工作量,其次尽量保证代码的美观可读,整洁的代码人见人爱,如同阅读一本好书,令人心情愉悦。 ”代码素养“是一种态度,真正热爱编程的程序员一定不会缺失“代码素养”。我们通常称“写代码”为[代码]“程序设计”[代码],而不是“程序编写”,“设计”一词体现出了我们的代码是一件作品,也许不如“艺术品”那么精致,但也不是什么粗麻烂布,如果在写代码时天马行空,得过且过,抱着只要能实现功能的思想,那这部“作品“是不具有观赏价值的,这不仅仅体现出代码编写者的”不专业”,更是反映出对待编程这件事的态度,代码的整洁程度、可维护性取决于你是否真正“在意”你的代码,每个程序员不一定热爱编程,但请你一定要以“认真”的态度对待自己的专业。 [代码]"clean code"[代码]的作者鲍勃大叔提到,有人曾送给他一条腕带,上面写着“Test Obsessed”,他发觉自己带上后再也无法取下了,不仅是因为腕带很紧,更是因为它也是一条精神上的紧箍咒。在编程时,我们下意识的看下自己的手腕,是否能发现一条隐形的腕带呢?
2019-03-14 - 【优化】小程序优化-代码篇
本文主要是从代码方面跟大家分享我自己在开发小程序的一些做法,希望能帮到一些同学。 前言 不知道大家有没有这种体会,刚到公司时,领导要你维护之前别人写的代码,你看着别人写的代码陷入了深深的思考:“这谁写的代码,这么残忍” [图片] 俗话说“不怕自己写代码,就怕改别人的代码”,一言不和就改到你吐血,所以为了别人好,也为了自己好,代码规范,从我做起。 项目目录结构 在开发之前,首先要明确你要做什么,不要一上来就是干,咱们先把项目结构搭好。一般来说,开发工具初始化的项目基本可以满足需求,如果你的项目比较复杂又有一定的结构的话就要考虑分好目录结构了,我的做法如下图: [图片] component文件夹是放自定义组件的 pages放页面 public放公共资源如样式表和公共图标 units放各种公共api文件和封装的一些js文件 config.js是配置文件 这么分已经足以满足我的需求,你可以根据自己的项目灵活拆分。 配置文件 我的项目中有个config.js,这个文件是用来配置项目中要用到的一些接口和其它私有字段,我们知道在开发时通常会有测试环境和正式环境,而测试环境跟正式环境的域名可能会不一样,如果不做好配置的话直接写死接口那等到上线的时候一个个改会非常麻烦,所以做好配置是必需的,文件大致如下: [图片] 首先是定义域名,然后在config对象里定义接口名称,getAPI(key)是获取接口方法,最后通过module暴露出去就可以了.引用的时候只要在页面引入 import domain from ‘…/…/config’;,然后wx.request的时候url的获取方式是domain.getAPI(’’) 代码健壮性、容错性 例子 代码的健壮性、容错性也是我们应该要考虑的一点,移动端的项目不像pc端的网络那么稳定,很多时候网络一不稳定就决定我们的项目是否能正常运行,而一个好的项目就一定要有良好的容错性,就是说在网络异常或其它因素导致我们的项目不能运行时程序要有一个友好的反馈,下面是一个网络请求的例子: [图片] 相信多数人请求的方式是这样,包括我以前刚接触小程序的时候也是这样写,这样写不是说不好,而是不太严谨,如果能够正常获取数据那还好,但是一旦请求出现错误那程序可以到此就没法运行下去了,有些比较好的会加上faill失败回调,但也只是请求失败时的判断,在请求成功到获取数据的这段流程内其实是还有一些需要我们判断的,一般我的做法是这样: [图片] 在请求成功后小程序会进行如下判断: 判断是否返回200,是则进行一下步操作,否则抛出错误 判断数据结构是否完整,是则进行一下步操作,否则抛出错误 然后就可以在页面根据情况进行相应的操作了。 定制错误提示码 可以看到上面的截图的错误打印后面会带一个gde0或gde1的英文代码,这个代码是干嘛用的呢,其实是用来报障的,当我们的小程序上线后可能会遇到一些用户发来的报障,一般是通过截图发给我们,之前没有做错误提示码的时候可能只是根据一句错误提示来定位错误,但是很多时候误提示语都是一样的,我们根本不知道是哪里错了,这样一来就不能很快的定位的错误,所以加上这样一个提示码,到时用户一发截图来,我们只要根据这个错误码就能很快的定位错误并解决了,错误提示码建议命名如下: 不宜过长,3个字母左右 唯一性 意义明确 像上面gde表示获取草稿失败,后面加上数字表示是哪一步出错。 模块化 我们组内的大神说过, 模块化的意义在义分治,不在于复用。 之前我以为模块化只是为了可以复用,其实不然,无论模块多么小也是可以模块化,哪怕只是一个简单的样式也一样,并是不为了复用,而是管理起来方便。 很多同学经常将一些公共的样式事js放在app.wxss和app.js里以便调用,这样做其实有一个坏处,就是维护性比较差,如果是比较小的项目还好,项目一大问题就来了。而且项目是会迭代的,不可能总是一个人开发,可能后面会交接给其他人开发,所以会造成的问题就是: app.wxss和app.js里的内容只会越来越多,因为别人不确定哪些是没用的也不敢删,只能往里加东西,造成文件臃肿,不利于维护。 app.wxss和app.js对于每个页面都有效,可读性方面比较差。 所以模块化的意义就出来了,将公共的部分进行模块化统一管理,也便于维护。 样式模块化 公共样式根据上面的目录结构我是放在public里的css里,每个文件命名好说明是哪个部分的模块化,比如下面这个就表示一个按钮的模块化 [图片] 前面说过模块化不在于大小,就算只是一个简单的样式也可以进行模块化,只要在用到的地方import一下就行了,就知道哪里有用到,哪里没有用到,清晰明了。 js模块化 js模块化这里分为两个部分的模块化,一部分是公共js的模块化,另一部分是页面js的模块化即业务与数据的拆分。 公共js模块化 比较常用的公共js有微信登录,弹窗,请求等,一般我是放在units文件夹里,这里经微信弹窗api为例: [图片] 如图是在小程序中经常会用到的弹窗提示,这里进行封装,定义变量,只要在页面中引入就能直接调用了,不用每次都写一大串。比如在请求的时候是这样用的 [图片] toast()就是封装的弹窗api,这样看起来是不是清爽多了! 业务与数据模块化 业务与数据模块化就是指业务和数据分开,互不影响,业务只负责业务,数据只负责数据,可以看到页面会比普通的页面多了一个api.js [图片] 这个文件主要就是用来获取数据的,而index.js主要用来处理数据,这样分工明确,相比以往获取数据和处理数据都在一个页面要好很多,而且我这里获取数据是返回一个promise对象的,也方便处理一些异步操作。 组件化 组件化相信大家都不陌生了,自从小程序支持自定义组件,可以说是大大地提高了开发效率,我们可以将一些公共的部分进行组件化,这部分就不详细介绍,大家可以去看文档。组件化对于我们的项目来说有很大的好处,而且组件化的可移植性强,从一个项目复用到另一个项目基本不需要做什么改动。 总结 这篇文章通过我自己的一些经验来给大家介绍如何优化自己的代码,主要有以下几点 分好项目目录结构 做好接口配置文件 代码健壮性、容错性的处理 定制错误提示码方便定位错误 样式模块化和js模块化 组件化 最后放上项目目录结构的代码片段,大家可以研究一下,有问题一起探讨:https://developers.weixin.qq.com/s/1uVHRDmT7j6l
2019-03-07 - 小程序架构设计(一)
在微信早期,我们内部就有这样的诉求,在微信打开的H5可以调用到微信原生一些能力,例如公众号文章里可以打开公众号的Profile页。所以早期微信提供了Webview到原生的通信机制,在Webview里注入JSBridge的接口,使得H5可以通过它调用到原生能力。 [图片] 我们可以通过JSBridge微信预览图片的功能: [代码]WeixinJSBridge.invoke('imagePreview', { current: https://img1.gtimg.com/1.jpg', urls: [ 'https://img1.gtimg.com/1.jpg', 'https://img1.gtimg.com/2.jpg', 'https://img1.gtimg.com/3.jpg' ] }) [代码] 早期微信官方是没有暴露这些接口的,都是腾讯内部业务在使用,很多外部开发者发现后,就依葫芦画瓢地使用了。 从另外一个角度看,JSBridge是微信和H5的通信协议,有一些能力可能要组合不同的能力才能完整调用。如果我们直接开放这套API,相当于所有开发者都要直接理解这样的接口协议,显然是很不合理的。 所以在2015年初的时候,微信就发布了JSSDK,其实就是隐藏了内部一些细节,包装了几十个API给到上层业务直接调用。 [图片] 前边的代码就变成了: [代码]wx.previewImage({ current: https://img1.gtimg.com/1.jpg', urls: [ 'https://img1.gtimg.com/1.jpg', 'https://img1.gtimg.com/2.jpg', 'https://img1.gtimg.com/3.jpg' ] }) [代码] 开发者可以用JSSDK来调用微信的能力,来完成一些以前H5做不到或者难以做到的事情。 能力上得到了更多的支持,但是微信里的H5体验却没有改善。 第一点是加载H5时的白屏。在微信里打开链接后会看到白屏,有一些H5的服务不稳定,这个白屏现象会更严重。 [图片] 第二点是在H5跳转到其他页面时,切换的效果也很不流畅,只能看到顶部绿色进度条在走。 [图片] 随着JSSDK的开放,还出现了更不好对付的问题。 微信上越来越多干坏事的人,有人做假红包,有人诱导分享,有伪造一些官方活动。他们会利用JSSDK的分享能力变相的去裂变分享到各个群或者朋友圈,由于JSSDK是根据域名来赋予api权限的,运营人员封了一个域名后,他们立马用别的域名又继续做坏,要知道注册一个新的域名的成本是很低的。 [图片] [图片] [图片] 龙哥在2016年微信公开课上提出了应用号的概念,我们要重新设计一个新的移动应用开发模式,同时我们要解决刚刚提到的一些问题。 至此,我们回顾一下目前移动应用开发的一些特点: Web开发的门槛比较低,而App开发门槛偏高而且需要考虑iOS和安卓多个平台; 刚刚说到H5会有白屏和页面切换不流畅的问题,原生App的体验就很好了; H5最大的优点是随时可以上线更新,但是App的更新就比较慢,需要审核上架,还需要用户主动安装更新。 我们更想要的一种开发模式应该是要满足一下几点: 像H5一样开发门槛低; 体验一定要好,要尽可能的接近原生App体验; 让开发者可以云端更新,而且我们平台要可以管控。 很多人可能会第一时间想到Facebook的React Native(下边简称RN),是不是RN就能解决这些问题呢? 是的,React Native貌似可以解决刚刚那些问题,我们也曾经想用RN来做。但是仔细分析了一下,我们发现了采用RN这个机制做开放平台还是存在一些问题。 RN只支持CSS的子集,作为一个开放的生态,我们还要告诉外边千千万万的开发者,哪些CSS属性能用,哪些不能用; RN本身存在一些问题,这些依赖RN的修复,同时这样就变成太过依赖客户端发版本去解决开发者那边的Bug,这样修复周期太长。 RN前阵子还搞出了一个Lisence问题,对我们来说也是存在隐患的。 [图片] 所以我们舍弃了这样的方案,我们改用了Hybrid的方式。简单点说,就是把H5所有代码打包,一次性Load到本地再打开。这样的好处是我们可以用一种近似Web的方式来开发,同时在体验上也可以做到不错的效果,并且也是可以做到云端更新的。 [图片] 现在留给我们的最后一个问题就是,平台的管控问题。 怎么理解呢?我们知道H5的界面结构是用HTML进行描述,浏览器进行一系列的解析最终绘制在界面上。 [图片] 同时浏览器提供了可以操作界面的DOM API,开发者可以用这些API进行一些界面上的变动,从而实现UI交互。 [图片] 既然我们要采用Web+离线包的方式,那我们要解决前边说过的安全问题,我们就要禁用掉很多危险的HTML标签,还要禁用掉一些API,我们要一直维护这样的白名单或者黑名单,实现成本太高了,而且未来浏览器内核一旦更新,对我们来说都是很大的安全隐患。 [图片] 这就是小程序一开始遇到的问题,在下篇文章《小程序架构设计(二)》,我们再详细展开一下小程序是如何解决以上这个问题的。
2019-02-26 - 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 - 微信小程序上线“页面收录”功能,真正的SEO时代来了!
“搜索一直应该是小程序的一个主要流量来源,小程序和APP的一个很大不同,APP是一个个的信息孤岛,互相之间没法交换信息。但小程序是可以被系统统一检索到,是可以直接搜索到小程序里面的内容的。” ——张小龙于2019微信公开课Pro 靴子终于落地。 3月29日,不少小程序开发者在后台收到了一条通知,通知表示,小程序新增页面收录功能,开发者可以设置小程序是否能被收录,或者通过配置实现特定页面被收录。 [图片] 乍一看有些云里雾里,实际上,这项能力在微信安卓7.0.4版中曾灰度测试过,让我们能一睹真容: 只要开启了该功能,小程序每一个页面都能被直接搜索到,比如搜索“计算器”,在内容一栏中就会展示页面中含有“计算器”的小程序,点击之后直达该页面。 [图片] 也就是说,理论上所有小程序页面都能被用户搜索,上百万个小程序的内容全部被打开,连成了一片信息的汪洋大海。 1 小程序SEO时代来了 对于这项能力,有开发者用了“太震撼”三个字来形容,并且认为它的重要性不亚于去年的下拉“小程序桌面”。 为了更深入了解它,我们不妨以搜索巨头百度作为参考。 众所周知,只要在搜索框里输入某个关键词,结果页就会展现出某个网站中含有该关键词的页面,点击直达该页面,这是页面收录功能的基本形式。 [图片] 我们可以小程序比作“网站”,以前只能搜索“整个网站”,现在可以搜索到里面的页面了。 百度小程序前段时间推出了一项能力,就是把小程序拆分为一个个网页,并被搜索引擎抓取。微信此项新能力与此类似,也是通过搜索直达小程序页面。 但百度智能小程序接入搜索的方式比较复杂,需在开发者工具中将小程序Web化,然后上传审核发布。 [图片] 另外,尽管以前微信也推出过搜索直达页面功能,即“服务直达”和“好物圈”,但都需要开发者主动申请配置,对于中小商家而言,有一定的技术门槛。 而这一门槛现已不复存在,因为小程序“页面收录设置”默认开启,所以商家不需要任何操作,就能被收录到微信搜索中。 [图片] 然而,收录仅仅是开始,更重要的是,此后所有商家都能通过对页面结构的优化,使小程序的排名更加靠前,获取更多搜索流量。 正如微信团队给我们的回复所说:“这一体验优化,可以帮助用户缩短寻找服务的路径,提升搜索效率,也可以帮助开发者的小程序获得更多曝光。” 换句话说,小程序SEO时代从这一刻真正到来了。 2 微信搜索是小程序的下一站 不过,在晓程序观察(yinghoo-tech)的调查中,也有商家表示质疑,用户有在微信里搜索的习惯吗?微信搜索的流量大吗?做小程序SEO有意义吗? 商家的疑惑情有可原,毕竟“流量”是所有商家都关心的话题,也是最原始驱动力之一。 确实,我们采访过的案例大都表示“功能直达”、“好物圈”等入口的量并不大,使用习惯尚待养成。 但我们认为,搜索未来必定成为微信小程序的重要入口,理由有两点: 1. 内容才是养成习惯的关键 从用户侧来说,互联网从PC端走到移动端,在交互体验、使用场景上都发生巨大变化,而搜索的习惯却继承了下来,像搜索巨头百度,每日需要响应60亿次的搜索请求,可见搜索依旧是获取信息和服务的重要方式。 并且,搜索行为往往与需求相伴,这与小程序“即用即走,触手可得”的特性也天然契合:需求产生——搜索小程序——使用服务——离开小程序,这条路格外顺畅。 但有人会说,“搜索是日常行为不假,但习惯在微信里搜索的人却不多,抢占搜索流量有意义么?” 我们不妨逆向思考这个问题,许多开发者表示,现在微信搜索最大的问题反倒不是使用习惯,而是缺乏内容,比如搜“羊毛大衣如何清洗”,完全得不到任何结果。 [图片] 但未来小程序页面收录功能全面上线后,所有有关“羊毛大衣如何清洗”的小程序页面都会被抓取到,可能是一篇文章,也可能就是羊毛大衣清洗服务,由此实现了搜—看—买的过程。 所以有理由相信,未来搜索结果的内容丰富度不再是问题,用户习惯的养成也就水到渠成。 2. 搜索将带来下一波流量红利 从商家侧看,搜索将带来下一波微信的流量红利。 以前,微信小程序的社交属性被过度放大,众多运营者们通过各种方式诱导用户转发分享,从而实现数据短时间内的爆炸增长,这对用户体验是巨大伤害。 所以我们发现,微信一直在通过诸如限制跳转数量、收回分享回调等方式,逐步规范小程序的社交裂变行为,2019年第一季度,确实没有再看到现象级小程序的刷屏事件。 有开发者表示,“社交流量带来的第一波红利期已经渐渐褪去,下一个取而代之的流量洼地应该是搜索”。 因为搜索能让所有商家不论大小,都站在同一起跑线上,流量获取更加平等化。 意思是在相同的搜索规则下,小团队开发的小程序通过页面结构的优化,提供更优质的服务,一样有机会排在头部小程序前面。 比如,在除去使用过、好友用过、功能直达等客观因素外,现在搜索“北京天气”,结果页是「墨迹天气」排第一。但在未来大家公平角逐时,「北京天气查询」这样的小程序,也有机会通过页面优化排在最前面,因为提供的服务更加精准。 [图片] 这对于中小开发者而言无疑一大助推。 微信一直在关注如何让优质的小程序被更好的发现,目前来看,让服务与产品质量更高的小程序通过搜索入口自然涌现,这是微信给出的答案。 3 小程序SEO,需要提前这几点 那么,商家如何提前落子,布局微信搜索呢?或许从传统搜索引擎的SEO方法中,可以获得一些思路。 “过去做SEO 的方法是:结合站点属性和需求词数据,根据搜索引擎平台提供的索引规范和页面结构标准指引,不断进行调优化,从而提高索引收录排名的可能性”飞虎商联产品负责人周圣伟说到,他有着多年的建站以及SEO经验。 但目前小程序页面收录能力仍处于内测,没有明确的索引规范,所以我们只能提出一些值得商家注意的地方。 1.先考虑清楚哪些页面允许被收录 需要强调,小程序页面收录开关是全局性的,意思是开启后小程序每个页面都能被搜索到,但有的小程序页面中包含开发者不想暴露的内容。 此时就要选择“关闭收录”,然后单独进行sitemap配置,sitemap配置就是注明哪些页面可以被收录,哪些不能被收录。 [图片] 2.根据索引规范优化页面结构 一般而言,索引规范会说明标题摘要、关键词的相关规则,例如“北京天气如何北京今天天气怎么样”这种关键词堆砌都是在禁止之列。另外,像页面内功能按钮的易用性,音频视频的使用情况,都要考虑在内。 [图片](百度智能小程序的索引规范) 3.原创保护与抓取周期也要注意 周圣伟告诉我们,格外需要注意的是品牌词与原创保护,比如,现在有许多小程序就是各类公号文章的集合,但这些小程序未来都有可能违反了原创保护,导致权重的下降。 最后,索引的周期性也值得关注,搜索引擎的抓取一般有周期性,如果周期内页面没有更新,就不容易被抓取到,所以及时更新页面就成为一个关键。 当然,上周推出的小程序评测能力,或许也与搜索排名有千丝万缕的联系,还有微信特有的社交关系,相信也是影响权重的重要指标。 [图片] 由此可见,未来小程序SEO将成为一门全新的产业,据我们了解,有很多小程序服务商已经嗅到了商机,都摩拳擦掌等待着在小程序SEO领域大干一场。 当然,更具体的细则以及一些疑问,都要等这项新能力正式上线时才能揭晓。
2019-04-03 - 从源码看微信小程序启动过程
一、写作背景 接触小程序一年多,真实体验就是小程序开发门槛相对而言确实比较低。不过小程序的开发方式,一直是开发者吐槽的,如习惯了 Vue,React 开发的开发者经常会吐槽小程序一个 Page 必须由多个文件组成,组件化支持不完善或者说不能非常愉快的开发组件。在以前小项目中没太大感觉,从加入有赞,参与有赞微商城小程序的开发,是真切的体会到对于大型小程序项目开发的复杂性。 有赞从微信小程序内测就开始开发小程序,在不支持自定义组件的时代,只能通过 import 的形式拆分模块或实现组件。在业务复杂的页面,可能会 import 非常多的模块,而相应的 wxss 也需要 import 样式,除了操作繁琐,有时候也难免遗漏。 作为开发者,我们当然希望可以让工作更简单,更愉快,也希望改善我们的开发方式。所以希望能够更了解微信小程序框架,减少不必要的试错,于是有了一次对小程序框架的 debug 之旅。(基础库 1.9.93) 通过三周空余时间的 debug,也算对小程序框架有了一些浅显的认识,达到了最初的目的;对小程序启动,实例,运行等有了真切的体会。这篇文章记录了小程序框架的基本代码结构,启动流程,以及程序实例化过程。 本文的目的是希望把我看到的分享给对小程序感兴趣或者正在开发小程序的读者,主要解答“框架对传入的对象等到底做了什么”。 二、从启动流程一窥小程序框架细节 在开发者工具中使用 help() 方法,可以查看一些指令和方法。使用其中的 openVendor 方法可以打开微信开发者工具在小程序框架所在目录。其中以包括以基础库命名的目录和其他帮助文件,如其中有两个工具 wcc,wcsc。wcc 可把 wxml 转换为对应的 JS 函数 —— $gwx(path, global),wcsc 可将 wxss 转换为 css。而基础库目录包括 WAService.js 和 WAWebview.js 文件。小程序框架在开发者工具中以 WAService.js 命名(WAWebview.js 不知其作用,听说在真机环境使用该文件)。 在开发中工具命令行使用 document.head 可以查看到小程序的启动流程大致如下: [图片] 以小节的方式分别介绍这些流程,小程序是如何处理的(小节编号与图中编号相同)。 1、初始化全局变量 下图是小程序启动是初始化的一些全局的变量: [图片] 那些使用“__”开头,未在文档中提及可使用变量是不建议使用的,wxAppCode 在开发者工具中分为两类值,json 类型和 wxml 类型。以 .json 结尾的,其 key 值为开发者代码中对应的 json 文件的内容,.wxml 结尾的,其 key 值为通过调用 $gwx(’./pages/example/index.wxml’) 将得到一个可执行函数,通过调用这个函数可得到一个标识节点关系的 JSON 树。 [图片] 2、加载框架(WAService.js) 使用工具对 WAService.js 进行格式化后进行 debug。可以发现小程序框架大致由: WeixinJSBridge、 NativeBuffer、 wxConsole、 WeixinWorker、 JavaScript兼容(这部分为猜测)、 Reporter、 wx、 exparser、 virtualDOM、 appServiceEngine 几部分组成。 其中除了 wx 和 WeixinJSBridge 这两个基础 API 集合, exparser, virtualDOM, appServiceEngine 这三部分作为框架的核心, appServiceEngine 提供了框架最基本的接口如 App,Page,Component; exparser 提供了框架底层的能力,如实例化组件,数据变化监听,view 层与逻辑层的交互等;而 virtualDOM 则起着链接 appServiceEngine 和 exparser 的作用,如对开发者传入 Page 方法的对象进行格式化再传入 exparser 的对应方法处理。 框架对外暴露了以下API:Behavior,App,Page,Component,getApp,getCurrentPages,definePlugin,requirePlugin,wx。 3、业务代码的加载 在小程序中,开发者的 JavaScript 代码会被打包为 [代码]define('xxx.js', function(require, module, exports, window, document, frames, self, location, navigator, localStorage, history, Caches, screen, alert, confirm, prompt, fetch, XMLHttpRequest, WebSocket, webkit, WeixinJSCore, Reporter, print, WeixinJSBridge) { 'use strict'; // your code }) [代码] 这里的 define 是在框架中定义的方法,在框架中提供了两个方法:require 和 define 用来定义和使用业务代码。其方式有些像 AMD 规范接口,通过 define 定义一个模块,使用 require 来应用一个模块。但是也有很大区别,首先 define 限制了模块可使用的其他模块,如 window,document;其次 require 在使用模块时只会传入 require 和 module,也就是说参数中的其他模块在定义的模块中都是 undefined,这也是不能在开发者工具中获取一些浏览器环境对象的原因。 在小程序中,JavaScript 代码的加载方式和在浏览器中也有些不同,其加载顺序是首先加载项目中其他 js 文件(非注册程序和注册页面的 js 文件),其次是注册程序的 app.js,然后是自定义组件 js 文件,最后才是注册页面的 js 代码。而且小程序对于在 app.js 以及注册页面的 js 代码都会加载完成后立即使用 require 方法执行模块中的程序。其他的代码则需要在程序中使用 require 方法才会被执行。 下面详细介绍了 app.js,自定义组件,页面 js 代码的处理流程。 4、加载 app.js 与注册程序 在 app.js 加载完成后,小程序会使用 require(‘app.js’) 注册程序,即对 App 方法进行调用,App 方法是对 appServiceEngine.App 方法的引用。 下图是框架对于 App 方法调用时的处理流程: [图片] App 方法根据传入的对象实例化一个 app 实例,其生命周期函数 onLaunch 和 onShow 因为使用不同的方式获取 options的参数。在有些需要根据场景值来实现需求的,或许使用 onShow 中的场景值更合适。 在实际开发过程中发现,在微信顶部唤起小程序和在小程序列表唤起的 options 也是不一样的。在该案例中通过点击分享的小程序进入后,关闭小程序,再通过不同方式进入小程序,通过顶部唤起的还是 options 的 path 属性还是分享出来的 path,但是通过列表中打开直接回到了首页,这里 App 中的 onShow 就会获取到不同的 options。 5、加载自定义组件代码以及注册自定义组件 自定义组件在 app.js 之后被加载,小程序会在这个过程中加载完所有的自定义组件(分包中自定义组件没有有测试过),并且是加载完成后自动注册,只有注册完成后才会加载下一个自定义组件的代码。 下图是框架对于 Component 方法处理流程: [图片] 图中介绍了框架如何对传入 Component 方法的对象的处理,其后面还有很多深入的对于组件实例化的步骤没有在图中表示出来,具体可以在文章最后的附件中查看。 自定义组件在小程序中越来越完善,其拥有的能力也比 Page 更强大,而后面会提到在使用自定义组件的 Page 中,Page 实例也会使用和自定义组件一样的实例化方式,也就是说,他拥有和自定义组件一样的能力。 6、加载页面代码和注册页面 加载页面代码的处理流程和加载自定义组件一样,都是加载完成后先注册页面,然后才会加载下一个页面。 下图是注册一个页面时框架对于 Page 方法的处理流程: [图片] Page 方法会根据是否使用自定义组件做不同的处理。使用自定义组件的 page 对象会被处理为和自定义组件的结构,并在页面实例化时使用不同的处理流程进行实例化。当然对于开发而言没任何不同。 从图中可以发现 Page 传入的(生命周期)代码并不会在这里被执行,可以通过下面小节了解 Page 实例化的详细过程。 7、等待页面 Ready 和 Page 实例化 还记得上面介绍的启动流程中最后一步等待页面 Ready?严格来讲是等待浏览器 Ready,小程序虽然有部分原生的组件,不过本质上还是一个 web 程序。 在小程序中切换页面或打开页面时会触发 onAppRoute 事件,小程序框架通过 wx.onAppRoute 注册页面切换的处理程序,在所有程序就绪后,以 entryPagePath 作为入口使用 appLaunch 的方式进入页面。 下图是处理导航的程序流程: [图片] 从图中可以看出页面的实例化是在进入页面时进行,下图是具体的实例化过程: [图片] 下图是最终可得到 Page 实例: [图片] 可以发现其中多了 onRouteEnd API,实际该接口不会被调用。其中以 component 标记的表示只有在使用了自定义组件时才会有的方法和属性。在前面第 5 小节提到了对于使用自定义组件的页面会按照自定义组件方式解析,这些属性和方法与自定义组件表现一致。 8、关于 setData 小程序框架是一个以数据驱动的框架,当然不能少了对他如何实现数据绑定的探索,下图是 Page 实例的 setData 执行流程: [图片] 其中 component:setData 表示使用自定义组件的 Page 实例的 setData 方法。 三、写在最后 这是一次不完全的小程序框架探索,是在微信开发工具中 debug 的结果。虽然对于实际开发没有什么太大的帮助,但是对框架如何对开发的 js 代码进行处理有了一个很明确的认识,在使用一些 js 特性时可以有明确的感知。如果你还疑惑“小程序框架对传入的对象等到底做了什么”那一定是我表达能力太差,说声对不起。 通过这一次 debug ,也给我引入了新的问题,还希望能够有更多的讨论: · 自定义组件太多启动时会耗时处理自定义组件 · 文件太多会耗时读文件 · 合理的设计分包很重要 当然最后对于框架中已有的能力,还是非常希望微信可以开放更多稳定的接口,并在文档中告知开发者,让开发变得简单一些。
2019-03-05 - 云开发,实现【最近搜索】和【大家在搜】的存储和读取逻辑
小程序【搜索】功能是很常见的,在开发公司的一个电商小程序的时候想尝试使用“云开发”做系统的后端,而首页顶端就有个搜索框,所以第一步想先解决【搜索】的一些前期工作:【最近搜索】和【大家在搜】关键词的存储和读取逻辑。 效果图如下: [图片] 效果视频请点击链接查看: 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 - 小程序性能和体验优化方法
[图片] 小程序应避免出现任何 JavaScript 异常 出现 JavaScript 异常可能导致小程序的交互无法进行下去,我们应当追求零异常,保证小程序的高鲁棒性和高可用性 小程序所有请求应响应正常 请求失败可能导致小程序的交互无法进行下去,应当保证所有请求都能成功 所有请求的耗时不应太久 请求的耗时太长会让用户一直等待甚至离开,应当优化好服务器处理时间、减小回包大小,让请求快速响应 避免短时间内发起太多的图片请求 短时间内发起太多图片请求会触发浏览器并行加载的限制,可能导致图片加载慢,用户一直处理等待。应该合理控制数量,可考虑使用雪碧图技术或在屏幕外的图片使用懒加载 避免短时间内发起太多的请求 短时间内发起太多请求会触发小程序并行请求数量的限制,同时太多请求也可能导致加载慢等问题,应合理控制请求数量,甚至做请求的合并等 避免 setData 的数据过大 setData工作原理 小程序的视图层目前使用 WebView 作为渲染载体,而逻辑层是由独立的 JavascriptCore 作为运行环境。在架构上,WebView 和 JavascriptCore 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 evaluateJavascript 所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 JS 脚本,再通过执行 JS 脚本的形式传递到两边独立环境。 而 evaluateJavascript 的执行会受很多方面的影响,数据到达视图层并不是实时的。 由于小程序运行逻辑线程与渲染线程之上,setData的调用会把数据从逻辑层传到渲染层,数据太大会增加通信时间 常见的 setData 操作错误 频繁的去 setData Android 下用户在滑动时会感觉到卡顿,操作反馈延迟严重,因为 JS 线程一直在编译执行渲染,未能及时将用户操作事件传递到逻辑层,逻辑层亦无法及时将操作处理结果及时传递到视图层 染有出现延时,由于 WebView 的 JS 线程一直处于忙碌状态,逻辑层到页面层的通信耗时上升,视图层收到的数据消息时距离发出时间已经过去了几百毫秒,渲染的结果并不实时 每次 setData 都传递大量新数据 由setData的底层实现可知,数据传输实际是一次 evaluateJavascript 脚本过程,当数据量过大时会增加脚本的编译执行时间,占用 WebView JS 线程 后台态页面进行 setData 当页面进入后台态(用户不可见),不应该继续去进行setData,后台态页面的渲染用户是无法感受的,另外后台态页面去setData也会抢占前台页面的执行 避免 setData 的调用过于频繁 setData接口的调用涉及逻辑层与渲染层间的线程通过,通信过于频繁可能导致处理队列阻塞,界面渲染不及时而导致卡顿,应避免无用的频繁调用 避免将未绑定在 WXML 的变量传入 setData setData操作会引起框架处理一些渲染界面相关的工作,一个未绑定的变量意味着与界面渲染无关,传入setData会造成不必要的性能消耗 合理设置可点击元素的响应区域大小 我们应该合理地设置好可点击元素的响应区域大小,如果过小会导致用户很难点中,体验很差 避免渲染界面的耗时过长 渲染界面的耗时过长会让用户觉得卡顿,体验较差,出现这一情况时,需要校验下是否同时渲染的区域太大 避免执行脚本的耗时过长 执行脚本的耗时过长会让用户觉得卡顿,体验较差,出现这一情况时,需要确认并优化脚本的逻辑 对网络请求做必要的缓存以避免多余的请求 发起网络请求总会让用户等待,可能造成不好的体验,应尽量避免多余的请求,比如对同样的请求进行缓存 wxss 覆盖率较高,较少或没有引入未被使用的样式 按需引入 wxss 资源,如果小程序中存在大量未使用的样式,会增加小程序包体积大小,从而在一定程度上影响加载速度 文字颜色与背景色搭配较好,适宜的颜色对比度更方便用户阅读 文字颜色与背景色需要搭配得当,适宜的颜色对比度可以让用户更好地阅读,提升小程序的用户体验 所有资源请求都建议使用 HTTPS 使用 HTTPS,可以让你的小程序更加安全,而 HTTP 是明文传输的,存在可能被篡改内容的风险 不使用废弃接口 使用即将废弃或已废弃接口,可能导致小程序运行不正常。一般而言,接口不会立即去掉,但保险起见,建议不要使用,避免后续小程序突然运行异常 避免过大的 WXML 节点数目 建议一个页面使用少于 1000 个 WXML 节点,节点树深度少于 30 层,子节点数不大于 60 个。一个太大的 WXML 节点树会增加内存的使用,样式重排时间也会更长 避免将不可能被访问到的页面打包在小程序包里 小程序的包大小会影响加载时间,应该尽量控制包体积大小,避免将不会被使用的文件打包进去 及时回收定时器 定时器是全局的,并不是跟页面绑定的,当页面因后退被销毁时,定时器应注意手动回收 避免使用 css ‘:active’ 伪类来实现点击态 使用 css ‘:active’ 伪类来实现点击态,很容易触发,并且滚动或滑动时点击态不会消失,体验较差 建议使用小程序内置组件的 ‘hover-*’ 属性来实现 滚动区域可开启惯性滚动以增强体验 惯性滚动会使滚动比较顺畅,在安卓下默认有惯性滚动,而在 iOS 下需要额外设置 [代码]-webkit-overflow-scrolling: touch[代码] 的样式
2019-03-15 - 小程序构建骨架屏的探索
首屏 一般情况下,在首屏数据未拿到之前,为了提升用户的体验,会在页面上展示一个loading的图层,类似下面这个 [图片] 其中除了菊花图以外网上还流传这各种各样的loading动画,在PC端上几乎要统一江湖了,不过最近在移动端上面看到不同于菊花图的加载方式,就是这篇文章需要分享的Skeleton Screen,中文称之为"骨架屏" 概念 A skeleton screen is essentially a blank version of a page into which information is gradually loaded. 在H5中,骨架屏其实已经不是什么新奇的概念了,网上也有各种方案生成对应的骨架屏,包括我们经常使用的知乎、饿了么、美团等APP都有应用骨架屏这个概念 图片来源网络,侵删 [图片] 方案 先从H5生成骨架屏方案开始说起,总的来说H5生成骨架屏的方案有2种 完全靠手写HTML和CSS方式给每个页面定制一套骨架屏 利用预渲染的方式生成静态骨架屏 第一套方案,毫无疑问是最简单最直白的方式,缺点也很明显,假如页面布局有修改的话,那么除了修改业务代码之外还需要额外修改骨架屏,增加了维护的成本。 第二套方案,一定程度上改善了第一套方案带来的维护成本增加的缺点,主要还是使用工具预渲染页面,获取到DOM节点和样式,保留页面结构,覆盖样式,生成灰色块盖在原有文本、图片或者是canvas等节点上面,最后将生成的HTML和CSS打包出来,就是一个带有骨架屏的页面。最后再利用webpack工具将生成的骨架屏插入到HTML里面,详细的话可以看看饿了么的分享,这里就不多描述了。 调研了下H5生成骨架屏的方案,对于小程序生成骨架屏的方案也有了一个大致的想法,主要有2个难点需要实现 预渲染 获取节点 预渲染 再说回饿了么提供的骨架屏的方案,使用 puppeteer 渲染页面(或者使用服务端渲染,vue或者react都有提供相应的方案),拿到DOM节点和样式,这里有一点需要注意的是,页面的渲染是需要初始化的数据,数据的来源可以是初始化的data(vue)或者mock数据,当然小程序是无法直接使用 puppeteer 来做预渲染(有另外的方案可以实现),需要利用小程序初始化的 data + template 渲染之后得到一个初始化结构作为骨架屏的结构 [代码]//index.js Page({ data: { motto: 'Hello World', userInfo: { avatarUrl: 'https://wx.qlogo.cn/mmopen/vi_32/SYiaiba5faeraYBoQCWdsBX4hSjFKiawzhIpnXjejDtjmiaFqMqhIlRBqR7IVdbKE51npeF6X1cXxtDQD2bzehgqMA/132', nickName: 'jay' }, lists: [ 'aslkdnoakjbsnfkajbfk', 'qwrwfhbfdvndgndghndeghsdfh', 'qweqwtefhfhgmjfgjdfghaefdhsdfgdfh', ], showSkeleton: true }, onLoad: function () { const that = this; setTimeout(() => { that.setData({ showSkeleton: false }) }, 3000) } }) //index.wxml <view class="container"> <view class="userinfo"> <block> <image class="userinfo-avatar skeleton-radius" src="{{userInfo.avatarUrl}}" mode="cover"></image> <text class="userinfo-nickname skeleton-rect">{{userInfo.nickName}}</text> </block> </view> <view style="margin: 20px 0"> <view wx:for="{{lists}}" class="lists"> <icon type="success" size="20" class="list skeleton-radius"/> <text class="skeleton-rect">{{item}}</text> </view> </view> <view class="usermotto"> <text class="user-motto skeleton-rect">{{motto}}</text> </view> <view style="margin-top: 200px;"> aaaaaaaaaaa </view> </view> [代码] 有了上面的 data + template 之后,就有了一个初始化的页面结构,接下来就需要拿到节点信息 节点 小程序基础库1.4.0之后小程序基础库提供了一组新的API,可用于获取节点信息,具体API戳这里。 跟H5方式一样,根据class或者id获取节点信息,不同的是只能获取到当前的节点信息,无法获取到其父或者子节点信息,所以只能手动给需要渲染骨架屏的节点添加相应的class或者id [代码]<view class="container"> <view class="userinfo"> <block> <image class="userinfo-avatar skeleton-radius" src="{{userInfo.avatarUrl}}" mode="cover"></image> <text class="userinfo-nickname skeleton-rect">{{userInfo.nickName}}</text> </block> </view> <view style="margin: 20px 0"> <view wx:for="{{lists}}" class="lists"> <icon type="success" size="20" class="list skeleton-radius"/> <text class="skeleton-rect">{{item}}</text> </view> </view> <view class="usermotto"> <text class="user-motto skeleton-rect">{{motto}}</text> </view> <view style="margin-top: 200px;"> aaaaaaaaaaa </view> </view> [代码] 约定2个特殊的class作为获取节点信息的标记[代码]skeleton-rect[代码]和[代码]skeleton-radius[代码],在页面中获取相应的[代码]top[代码]、[代码]left[代码]、[代码]width[代码]、[代码]height[代码]进行骨架屏的绘制 结果 [图片] 具体的调用方式和源码,请看 github ,最后求start 总结 上文有说到小程序也可以使用 page-skeleton-webpack-plugin 方式一样生成骨架屏,最重要的一点就是需要将小程序跑在chrome上面,后面的流程就一样了,至于怎么将小程序跑在chrome上面呢?可以利用 wept ,缺点就是目前作者已经停止维护这个工具了,不支持新版小程序的API。 说回来我这个生成骨架屏的方案,其实跟 page-skeleton-webpack-plugin 有点相似,不同的是,page-skeleton-webpack-plugin 采用离线渲染的方式生成静态骨架屏插入路由中,而我采用运行时先渲染页面默认结构,然后根据默认结构再绘制骨架屏。从性能角度出发确实不如 page-skeleton-webpack-plugin,但是也差不了多少了,主要还是小程序并没有提供类似服务端渲染的方案。目前从使用上来讲,还是有点小麻烦,需要默认数据撑开页面结构,需要给相应的节点添加class,后面有时间再研究下有没有更好的方案吧~~~
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 - 【优化】利用函数防抖和函数节流提高小程序性能
大家好,上次给大家分享了swiper仿tab的小技巧: https://developers.weixin.qq.com/community/develop/article/doc/000040a5dc4518005d2842fdf51c13 [代码]今天给大家分享两个有用的函数,《函数防抖和函数节流》 函数防抖和函数节流是都优化高频率执行js代码的一种手段,因为是js实现的,所以在小程序里也是适用的。 [代码] 首先先来理解一下两者的概念和区别: [代码] 函数防抖(debounce)是指事件在一定时间内事件只执行一次,如果在这段时间又触发了事件,则重新开始计时,打个很简单的比喻,比如在打王者荣耀时,一定要连续干掉五个人才能触发hetai kill '五连绝世'效果,如果中途被打断就得重新开始连续干五个人了。 函数节流(throttle)是指限制某段时间内事件只能执行一次,比如说我要求自己一天只能打一局王者荣耀。 这里也有个可视化工具可以让大家看一下三者的区别,分别是正常情况下,用了函数防抖和函数节流的情况下:http://demo.nimius.net/debounce_throttle/ [代码] 适用场景 函数防抖 搜索框搜索联想。只需用户最后一次输入完,再发送请求 手机号、邮箱验证输入检测 窗口resize。只需窗口调整完成后,计算窗口大小。防止重复渲染 高频点击提交,表单重复提交 函数节流 滚动加载,加载更多或滚到底部监听 搜索联想功能 实现原理 [代码] 函数防抖 [代码] [代码]const _.debounce = (func, wait) => { let timer; return () => { clearTimeout(timer); timer = setTimeout(func, wait); }; }; [代码] [代码] 函数节流 [代码] [代码]const throttle = (func, wait) => { let last = 0; return () => { const current_time = +new Date(); if (current_time - last > wait) { func.apply(this, arguments); last = +new Date(); } }; }; [代码] [代码] 上面两个方法都是比较常见的,算是简化版的函数 [代码] lodash中的 Debounce 、Throttle [代码] lodash中已经帮我们封装好了这两个函数了,我们可以把它引入到小程序项目了,不用全部引入,只需要引入debounce.js和throttle.js就行了,链接:https://github.com/lodash/lodash 使用方法可以看这个代码片段,具体的用法可以看上面github的文档,有很详细的介绍:https://developers.weixin.qq.com/s/vjutZpmL7A51[代码]
2019-02-22 - 【技巧】利用canvas生成朋友圈分享海报
前言 大家好,上次给大家讲了函数防抖和函数节流 https://developers.weixin.qq.com/community/develop/article/doc/000a645d8b8ba0d8722863ef45bc13 今天给大家分享一下利用canvas生成朋友圈分享海报 由于小程序的限制,我们不能很方便地在微信内直接分享小程序到朋友圈,所以普遍的做法是生成一张带有小程序分享码的分享海报,再将海报保存到手机相册,有两种方法可以生成分享海报,第一种是让后台生成然后返回图片链接,这一种方法比较简单,只需要传后台所需要的参数就行了,今天给大家介绍的是第二种方法,用canvas生成分享海报。 效果 [图片] 主要步骤 把海报样式用标签先写好,方便画图时可以比对 用canvas进行画图,canvas要注意定好宽高 canvas利用wx.canvasToTempFilePath这个api将canvas转化为图片 将转化好的图片链接放入image标签里 再利用wx.saveImageToPhotosAlbum保存图片 坑点 用canvas进行画图的时候要注意画出来的图的大小一定要是你用标签写好那个样式的两倍大小,比如你的海报大小是400600的大小,那你用canvas画的时候大小就要是8001200,宽高可以写在样式里,如果你画出来的图跟你海报图是一样的大小的话生成的图片是会很模糊的,所以才需要放大两倍。 画图的时候要注意尺寸的转化,如果你是用rpx做单位的话,就要对单位进行转化,因为canvas提供的方法都是经px为单位的,所以这一点要注意一下,px转rpx的公式是w/750z2,w是手机屏幕宽度screenWidth,可以通过wx.getSystemInfo获取,z是你需要画图的单位,2就是乘以两倍大小。 图片来源问题,因为canvas不支持网络图片画图,所以你的图片要么是固定的,如果不是固定的,那就要用wx.downloadFile下载后得到一个临时路径才行 小程序码问题,小程序需要后台请求接口后返回一个二进制的图片,因为二进制图片canvas也是不支持的,所以也是要用wx.downloadFile下载后得到一个临时路径,或者可以叫后台直接返回一个小程序码的路径给你 这里保存的时候是有个授权提醒的,如果拒绝的话再次点击就没有反应了,所以这里我做了一个判断是否有授权的,如果没有就弹窗提醒,确认的话会打开设置页面,确认授权后再次返回就行了,这里有个坑注意下,就是之前拒绝后再进入设置页面确认授权返回页面时保存图片会不成功,官方还没解决,我是加了个setTimeOut处理的,详情可以看这里 https://developers.weixin.qq.com/community/develop/doc/000c46600780f0fa68d7eac345a400 代码实现 [图片] 这里图片我先用的是网上的链接,实际项目中是后台返回的数据,这个可以自行处理,这里只是为了演示方便,生成临时路径的方法我这里是分别定义了一个方法,其实可以合成一个方法的,只是生成小程序码时如果要传入参数要注意一下。 绘图方法是drawImg,这里截一部分,详细的可以看代码片段 [图片] 不足 由于在实际项目中返回的图片宽高是不固定的,但是canvas画出来的又需要固定宽高,所以分享图会有图片变形的问题,使用drawImage里的参数也不能解决,如果各位有比较好的方案可以一起讨论一下。 代码片段 https://developers.weixin.qq.com/s/3pcsjDmS7M5Y
2019-02-22 - 如何写出一手好的小程序之多端架构篇
本文大致需要 14m+ 的阅读时间。 简述小程序的通信体系 为了大家能更好的开发出一些高质量、高性能的小程序,这里带大家理解一下小程序在不同端上架构体系的区分,更好的让大家理解小程序一些特有的代码写作方式。 整个小程序开发生态主要可以分为两部分: 桌面 nwjs 的微信开发者工具(PC 端) 移动 APP 的正式运行环境 一开始的考虑是使用双线程模型来解决安全和可控性问题。不过,随着开发的复杂度提升,原有的双线程通信耗时对于一些高性能的小程序来说,变得有些不可接受。也就是每次更新 UI 都是通过 webview 来手动调用 API 实现更新。原始的基础架构,可以参考官方图: [图片] 不过上面那张图其实有点误导行为,因为,webview 渲染执行在手机端上其实是内核来操作的,webview 只是内核暴露的一下 DOM/BOM 接口而已。所以,这里就有一个性能突破点就是,JSCore 能否通过 Native 层直接拿到内核的相关接口?答案是可以的,所以上面那种图其实可以简单的再进行一下相关划分,新的如图所示: [图片] 简单来说就是,内核改改,然后将规范的 webview 接口,选择性的抽一份给 JsCore 调用。但是,有个限制是 Android 端比较自由,通过 V8 提供 plugin 机制可以这么做,而 IOS 上,苹果爸爸是不允许的,除非你用的是 IOS 原生组件,这样的话就会扯到同层渲染这个逻辑。其实他们的底层内容都是一致的。 后面为了大家能更好理解在小程序具体开发过程中,手机端调试和在开发者工具调试的大致区分,下面我们来分析一下两者各自的执行逻辑。 tl;dr 开发者工具 通信体系 (只能采用双向通信) 即,所有指令都是通过 appservice <=> nwjs 中间层 <=> webview Native 端运行的通信体系: 小程序基础通信:双向通信-- ( core <=> webview <=> intermedia <=> appservice ) 高阶组件通信:单向通信体系 ( appservice <= android/Swift => core) JSCore 具体执行 appservice 的逻辑内容 开发者工具的通信模式 一开始考虑到安全可控的原因使用的是双线程模型,简单来说你的所有 JS 执行都是在 JSCore 中完成的,无论是绑定的事件、属性、DOM操作等,都是。 开发者工具,主要是运行在 PC 端,它内部是使用 nwjs 来做,不过为了更好的理解,这里,直接按照 nwjs 的大致技术来讲。开发者工具使用的架构是 基于 nwjs 来管理一个 webviewPool,通过 webviewPool 中,实现 appservice_webview 和 content_webview。 所以在小程序上的一些性能难点,开发者工具上并不会构成很大的问题。比如说,不会有 canvas 元素上不能放置 div,video 元素不能设置自定义控件等。整个架构如图: [图片] 当你打开开发者工具时,你第一眼看见的其实是 appservice_webview 中的 [代码]Console[代码] 内容。 [图片] content_webview 对外其实没必要暴露出来,因为里面执行的小程序底层的基础库和 开发者实际写的代码关系不大。大家理解的话,可以就把显示的 WXML 假想为 content_webview。 [图片] 当你在实际预览页面执行逻辑时,都是通过 content_webview 把对应触发的信令事件传递给 service_webview。因为是双线程通信,这里只要涉及到 DOM 事件处理或者其他数据通信的都是异步的,这点在写代码的时候,其实非常重要。 如果在开发时,需要什么困难,欢迎联系:开发者专区 | 微信开放社区 IOS/Android 协议分析 前面简单了解了开发者工具上,小程序模拟的架构。而实际运行到手机上,里面的架构设计可能又会有所不同。主要的原因有: IOS 和 Android 对于 webview 的渲染逻辑不同 手机上性能瓶颈,JS 原始不适合高性能计算 video 等特殊元素上不能被其他 div 覆盖 … 一开始做小程序的双线程架构和开发者工具比较类似,content_webview 控制页面渲染,appservice 在手机上使用 JSCore 来进行执行。它的默认架构图其实就是这个: [图片] 但是,随着用户量的满满增多,对小程序的期望也就越高: 小程序的性能是被狗吃了么? 小程序打开速度能快一点么? 小程序的包大小为什么这么小? … 这些,我们都知道,所以都在慢慢一点一点的优化。考虑到原生 webview 的渲染性能很差,组内大神 rex 提出了使用同层渲染来解决性能问题。这个办法,不仅搞定了 video 上不能覆盖其他元素,也提高了一下组件渲染的性能。 开发者在手机上具体开发时,对于某些 高阶组件,像 video、canvas 之类的,需要注意它们的通信架构和上面的双线程通信来说,有了一些本质上的区别。为了性能,这里底层使用的是原生组件来进行渲染。这里的通信成本其实就回归到 native 和 appservice 的通信。 为了大家更好的理解 appservice 和 native 的关系,这里顺便简单介绍一下 JSCore 的相关执行方法。 JSCore 深入浅出 在 IOS 和 Android 上,都提供了 JSCore 这项工程技术,目的是为了独立运行 JS 代码,而且还提供了 JSCore 和 Native 通信的接口。这就意味着,通过 Native 调起一个 JSCore,可以很好的实现 Native 逻辑代码的日常变更,而不需要过分的依靠发版本来解决对应的问题,其实如果不是特别严谨,也可以直接说是一种 "热更新" 机制。 在 Android 和 IOS 平台都提供了各自运行的 JSCore,在国内大环境下运行的工程库为: Anroid: 国内平台较为分裂,不过由于其使用的都是 Google 的 Android 平台,所以,大部分都是基于 chromium 内核基础上,加上中间层来实现的。在腾讯内部通常使用的是 V8 JSCore。 IOS: 在 IOS 平台上,由于是一整个生态闭源,在使用时,只能是基于系统内嵌的 webkit 引擎来执行,提供 webkit-JavaScriptCore 来完成。 这里我们主要以具有官方文档的 webkit-JavaScriptCore 来进行讲解。 JSCore 核心基础 普遍意义上的 JSCore 执行架构可以分为三部分 JSVirtualMachine、JSContext、JSValue。由这三者构成了 JSCore 的执行内容。具体解释参考如下: JSVirtualMachine: 它通过实例化一个 VM 环境来执行 js 代码,如果你有多个 js 需要执行,就需要实例化多个 VM。并且需要注意这几个 VM 之间是不能相互交互的,因为容易出现 GC 问题。 JSContext: jsContext 是 js代码执行的上下文对象,相当于一个 webview 中的 window 对象。在同一个 VM 中,你可以传递不同的 Context。 JSValue: 和 WASM 类似,JsValue 主要就是为了解决 JS 数据类型和 swift 数据类型之间的相互映射。也就是说任何挂载在 jsContext 的内容都是 JSValue 类型,swift 在内部自动实现了和 JS 之间的类型转换。 大体内容可以参考这张架构图: [图片] 当然,除了正常的执行逻辑的上述是三个架构体外,还有提供接口协议的类架构。 JSExport: 它 是 JSCore 里面,用来暴露 native 接口的一个 protocol。简单来说,它会直接将 native 的相关属性和方法,直接转换成 prototype object 上的方法和属性。 简单执行 JS 脚本 使用 JSCore 可以在一个上下文环境中执行 JS 代码。首先你需要导入 JSCore: [代码]import JavaScriptCore //记得导入JavaScriptCore [代码] 然后利用 Context 挂载的 evaluateScript 方法,像 new Function(xxx) 一样传递字符串进行执行。 [代码]let contet:JSContext = JSContext() // 实例化 JSContext context.evaluateScript("function combine(firstName, lastName) { return firstName + lastName; }") let name = context.evaluateScript("combine('villain', 'hr')") print(name) //villainhr // 在 swift 中获取 JS 中定义的方法 let combine = context.objectForKeyedSubscript("combine") // 传入参数调用: // 因为 function 传入参数实际上就是一个 arguemnts[fake Array],在 swift 中就需要写成 Array 的形式 let name2 = combine.callWithArguments(["jimmy","tian"]).toString() print(name2) // jimmytian [代码] 如果你想执行一个本地打进去 JS 文件的话,则需要在 swift 里面解析出 JS 文件的路径,并转换为 String 对象。这里可以直接使用 swift 提供的系统接口,Bundle 和 String 对象来对文件进行转换。 [代码]lazy var context: JSContext? = { let context = JSContext() // 1 guard let commonJSPath = Bundle.main.path(forResource: "common", ofType: "js") else { // 利用 Bundle 加载本地 js 文件内容 print("Unable to read resource files.") return nil } // 2 do { let common = try String(contentsOfFile: commonJSPath, encoding: String.Encoding.utf8) // 读取文件 _ = context?.evaluateScript(common) // 使用 evaluate 直接执行 JS 文件 } catch (let error) { print("Error while processing script file: \(error)") } return context }() [代码] JSExport 接口的暴露 JSExport 是 JSCore 里面,用来暴露 native 接口的一个 protocol,能够使 JS 代码直接调用 native 的接口。简单来说,它会直接将 native 的相关属性和方法,直接转换成 prototype object 上的方法和属性。 那在 JS 代码中,如何执行 Swift 的代码呢?最简单的方式是直接使用 JSExport 的方式来实现 class 的传递。通过 JSExport 生成的 class,实际上就是在 JSContext 里面传递一个全局变量(变量名和 swift 定义的一致)。这个全局变量其实就是一个原型 prototype。而 swift 其实就是通过 context?.setObject(xxx) API ,来给 JSContext 导入一个全局的 Object 接口对象。 那应该如何使用该 JSExport 协议呢? 首先定义需要 export 的 protocol,比如,这里我们直接定义一个分享协议接口: [代码]@objc protocol WXShareProtocol: JSExport { // js调用App的微信分享功能 演示字典参数的使用 func wxShare(callback:(share)->Void) // setShareInfo func wxSetShareMsg(dict: [String: AnyObject]) // 调用系统的 alert 内容 func showAlert(title: String,msg:String) } [代码] 在 protocol 中定义的都是 public 方法,需要暴露给 JS 代码直接使用的,没有在 protocol 里面声明的都算是 私有 属性。接着我们定义一下具体 WXShareInface 的实现: [代码]@objc class WXShareInterface: NSObject, WXShareProtocol { weak var controller: UIViewController? weak var jsContext: JSContext? var shareObj:[String:AnyObject] func wxShare(_ succ:()->{}) { // 调起微信分享逻辑 //... // 成功分享回调 succ() } func setShareMsg(dict:[String:AnyObject]){ self.shareObj = ["name":dict.name,"msg":dict.msg] // ... } func showAlert(title: String, message: String) { let alert = AlertController(title: title, message: message, preferredStyle: .Alert) // 设置 alert 类型 alert.addAction(AlertAction(title: "确定", style: .Default, handler: nil)) // 弹出消息 self.controller?.presentViewController(alert, animated: true, completion: nil) } // 当用户内容改变时,触发 JS 中的 userInfoChange 方法。 // 该方法是,swift 中私有的,不会保留给 JSExport func userChange(userInfo:[String:AnyObject]) { let jsHandlerFunc = self.jsContext?.objectForKeyedSubscript("\(userInfoChange)") let dict = ["name": userInfo.name, "age": userInfo.age] jsHandlerFunc?.callWithArguments([dict]) } } [代码] 类是已经定义好了,但是我们需要将当前的类和 JSContext 进行绑定。具体步骤是将当前的 Class 转换为 Object 类型注入到 JSContext 中。 [代码]lazy var context: JSContext? = { let context = JSContext() let shareModel = WXShareInterface() do { // 注入 WXShare Class 对象,之后在 JSContext 就可以直接通过 window.WXShare 调用 swift 里面的对象 context?.setObject(shareModel, forKeyedSubscript: "WXShare" as (NSCopying & NSObjectProtocol)!) } catch (let error) { print("Error while processing script file: \(error)") } return context }() [代码] 这样就完成了将 swift 类注入到 JSContext 的步骤,余下的只是调用问题。这里主要考虑到你 JS 执行的位置。比如,你可以直接通过 JSCore 执行 JS,或者直接将 JSContext 和 webview 的 Context 绑定在一起。 直接本地执行 JS 的话,我们需要先加载本地的 js 文件,然后执行。现在本地有一个 share.js 文件: [代码]// share.js 文件 WXShare.setShareMsg({ name:"villainhr", msg:"Learn how to interact with JS in swift" }); WXShare.wxShare(()=>{ console.log("the sharing action has done"); }) [代码] 然后,我们需要像之前一样加载它并执行: [代码]// swift native 代码 // swift 代码 func init(){ guard let shareJSPath = Bundle.main.path(forResource:"common",ofType:"js") else{ return } do{ // 加载当前 shareJS 并使用 JSCore 解析执行 let shareJS = try String(contentsOfFile: shareJSPath, encoding: String.Encoding.utf8) self.context?.evaluateScript(shareJS) } catch(let error){ print(error) } } [代码] 如果你想直接将当前的 WXShareInterface 绑定到 Webview Context 中的话,前面实例的 Context 就需要直接修改为 webview 的 Context。对于 UIWebview 可以直接获得当前 webview 的Context,但是 WKWebview 已经没有了直接获取 context 的接口,wkwebview 更推崇使用前文的 scriptMessageHandler 来做 jsbridge。当然,获取 wkwebview 中的 context 也不是没有办法,可以通过 KVO 的 trick 方式来拿到。 [代码]// 在 webview 加载完成时,注入相关的接口 func webViewDidFinishLoad(webView: UIWebView) { // 加载当前 View 中的 JSContext self.jsContext = webView.valueForKeyPath("documentView.webView.mainFrame.javaScriptContext") as! JSContext let model = WXShareInterface() model.controller = self model.jsContext = self.jsContext // 将 webview 的 jsContext 和 Interface 绑定 self.jsContext.setObject(model, forKeyedSubscript: "WXShare") // 打开远程 URL 网页 // guard let url = URL(string: "https://www.villainhr.com") else { // return //} // 如果没有加载远程 URL,可以直接加载 // let request = URLRequest(url: url) // webView.load(request) // 在 jsContext 中直接以 html 的形式解析 js 代码 // let url = NSBundle.mainBundle().URLForResource("demo", withExtension: "html") // self.jsContext.evaluateScript(try? String(contentsOfURL: url!, encoding: NSUTF8StringEncoding)) // 监听当前 jsContext 的异常 self.jsContext.exceptionHandler = { (context, exception) in print("exception:", exception) } } [代码] 然后,我们可以直接通过上面的 share.js 调用 native 的接口。 原生组件的通信 JSCore 实际上就是在 native 的一个线程中执行,它里面没有 DOM、BOM 等接口,它的执行和 nodeJS 的环境比较类似。简单来说,它就是 ECMAJavaScript 的解析器,不涉及任何环境。 在 JSCore 中,和原生组件的通信其实也就是 native 中两个线程之间的通信。对于一些高性能组件来说,这个通信时延已经减少很多了。 那两个之间通信,是传递什么呢? 就是 事件,DOM 操作等。在同层渲染中,这些信息其实都是内核在管理。所以,这里的通信架构其实就变为: [图片] Native Layer 在 Native 中,可以通过一些手段能够在内核中设置 proxy,能很好的捕获用户在 UI 界面上触发的事件,这里由于涉及太深的原生知识,我就不过多介绍了。简单来说就是,用户的一些 touch 事件,可以直接通过 内核暴露的接口,在 Native Layer 中触发对应的事件。这里,我们可以大致理解内核和 Native Layer 之间的关系,但是实际渲染的 webview 和内核有是什么关系呢? 在实际渲染的 webview 中,里面的内容其实是小程序的基础库 JS 和 HTML/CSS 文件。内核通过执行这些文件,会在内部自己维护一个渲染树,这个渲染树,其实和 webview 中 HTML 内容一一对应。上面也说过,Native Layer 也可以和内核进行交互,但这里就会存在一个 线程不安全的现象,有两个线程同时操作一个内核,很可能会造成泄露。所以,这里 Native Layer 也有一些限制,即,它不能直接操作页面的渲染树,只能在已有的渲染树上去做节点类型的替换。 最后总结 这篇文章的主要目的,是让大家更加了解一下小程序架构模式在开发者工具和手机端上的不同,更好的开发出一些高性能、优质的小程序应用。这也是小程序中心一直在做的事情。最后,总结一下前面将的几个重要的点: 开发者工具只有双线程架构,通过 appservice_webview 和 content_webview 的通信,实现小程序手机端的模拟。 手机端上,会根据组件性能要求的不能对应优化使用不同的通信架构。 正常 div 渲染,使用 JSCore 和 webview 的双线程通信 video/map/canvas 等高阶组件,通常是利用内核的接口,实现同层渲染。通信模式就直接简化为 内核 <=> Native <=> appservice。(速度贼快) 参考: 教程 | 《小程序开发指南》
2019-02-19 - 论如何进行小程序自定义组件的单元测试
前言 自从小程序自定义组件和 npm 功能面世之后,组件化和开源思想逐步开始萌芽了。我们可以将一些通用的部件,如自定义导航栏之类的封装到一个自定义组件中,然后借由 npm 平台开源出去给其他开发者使用,这样可以省去很多劳动。相信各位开发老爷们应该或多或少都有过使用开源包的经历,但是在使用前,这个开源包得能赢取我们的信任,一个很重要的指标就是单元测试通过率和覆盖率。 但是因为小程序独特的运行环境和不完全开源的基础款,使得对小程序自定义组件的单元测试稍微有点困难。目前市面上无论是 vue 还是 react,这些组件化框架都有一套完善的单元测试解决方案,但是对于小程序自定义组件来说却寥寥无几,因此这个工具集—— miniprogram-simulate 便应运而生了。 痛点 闲话不多说,我们先看下小程序的运行机制: [图片] 可以看出,小程序自定义组件是渲染与逻辑脱离,想在逻辑层拿到渲染的结果进而进行对比测试是很难办到的。而且目前小程序的环境并不开放,想要完整构造模拟出小程序的运行环境也不太科学。另外我们这边只是需要对小程序的自定义组件做单元测试,对于小程序中很多非自定义组件相关的功能可以不考虑,而且在性能上也不那么苛求,所以一个思路是调整底层运行机制,将双线程合并为一个线程。 实现 只是有思路还不够,在实现过程中还是有一些坎的。比如要如何比较好的模拟出小程序自定义组件的各种特性和功能呢?自己实现也不是不行,问题在于维护的成本,如果小程序自定义组件实现了一个功能,测试工具还得更新一下。另外如果在实现上略有差池的话,可能小程序端的一个小调整对于测试工具都可能是伤筋动骨式的改造。所以这里直接将小程序自定义组件的最核心模块—— exparser 从基础库中抽离出来。 exparser 是自定义组件系统的内核,是一个完整独立的模块,不依赖于基础库中其他模块。它完全脱离于小程序的 api 和运行机制体系,所以无论是单线程还是双线程机制都可以使用。exparser 提供的是自定义组件系统最底层的接口,测试工具将其进行二次封装成自定义组件测试环境。如果基础库有关于自定义组件的更新,如果是底层改造,则直接更新 exparser 模块即可;如果只是外层改造,那基本上是暴露接口层面的调整,也不必作太多大范围的调整。 PS:目前虽然 exparser 已经发布到 npm,但是仍然只是混淆压缩后到代码,属于半开源状态,不建议开发者直接使用。 使用 miniprogram-simulate 本是自定义组件脚手架 miniprogram-custom-component 中的一部分,现单独抽离出来,方便开发者们作更多的使用选择(脚手架中默认使用 jest 来搭配使用,直接使用此工具集则可以搭配其他想要使用的测试框架,比如 mocha 等)。 下述只简单介绍下用法,首先安装此工具集: [代码]npm install --save-dev miniprogram-simulate [代码] 然后此工具集必须搭配其他测试框架和 jsdom 来使用,比如 jest。因为 jest 内置有 jsdom,所以也就不需要额外安装 jsdom 了,以下面一个自定义组件作为例子: [代码]<!-- 自定义组件:comp.wxml --> <view class="index">{{prop}}</view> [代码] [代码]/* 自定义组件:comp.wxss */ .index { color: green; } [代码] [代码]// 自定义组件 comp.js Component({ properties: { prop: { type: String, value: 'index.properties' }, }, }) [代码] 这是一个极其简单的自定义组件,之后我们便可开始在 comp.test.js 里编写测试用例。 起步 加载和渲染自定义组是最基础的功能: [代码]// 自定义组件 comp 的测试用例:comp.test.js const path = require('path') const simulate = require('miniprogram-simulate') test('comp', () => { const id = simulate.load(path.join(__dirname, './comp')) // 此处必须传入绝对路径 const comp = simulate.render(id) // 渲染成自定义组件树实例 const parent = document.createElement('parent-wrapper') // 创建父亲节点 comp.attach(parent) // attach 到父亲节点上,此时会触发自定义组件的 attached 钩子 expect(comp.querySelector('.index').dom.innerHTML).toBe('index.properties') // 测试渲染结果 // 执行其他的一些测试逻辑 comp.detach() // 将组件从父亲节点中移除,此时会触发自定义组件的 detached 生命周期 }) [代码] 获取数据 可以获取自定义组件的数据: [代码]test('comp', () => { // 前略 // 判断组件数据 expect(comp.data).toEqual({ a: 111, }) }) [代码] 更新数据 可以更新自定义组件的数据: [代码]test('comp', () => { // 前略 // 更新组件数据 comp.setData({ a: 123, }) }) [代码] 获取子组件 可以获取自定义组件的子组件: [代码]test('comp', () => { // 前略 const childComp = comp.querySelector('#child-id') expect(childComp.dom.innerHTML).toBe('<div>child</div>') }) [代码] 触发事件 可以模拟触发自定义组件的事件: [代码]test('comp', () => { // 前略 comp.dispatchEvent('touchstart') // 触发组件的 touchstart 事件 childComp.dispatchEvent('tap') // 触发子组件的 tap 事件 }) [代码] 至此,应该能大概了解到这个工具集的用途。这些只是简单的使用介绍,本文只是个引子,更多详细的用法请移步到 github 仓库上查阅。 尾声 要想判断一个自定义组件的质量如何,其中最简单的方法就是看单元测试的表现,想要别人使用你的自定义组件,质量把关很重要,目前 miniprogram-simulate 已经实现了最基本的功能,其他功能也在尽力施工中,有什么好的建议或者在使用上遇到什么问题也可以提 issue。
2019-02-25