个人案例
- 商家接入微信支付后,如何对用户开通连续包月的扣款服务?
如题,申请开通连续包月功能需要达成什么条件?如何申请?在微信商户后台没有看到相关内容
2024-05-21 - 微信支付分扣款失败微信垫付资金么?
微信支付分扣款失败微信垫付资金么?
2024-09-26 - “微信医保支付”功能介绍与接入指引
产品简介:基于微信进行医保移动支付结算的功能。 优势:无需线下排队,医保移动支付。 接入方式:需开发。 关键词:进阶功能,便捷就医。 01 功能介绍 用户通过微信绑定个人社保卡,便可将微信号与个人医保账户关联,在就医挂号&门诊缴费等环节,进行便捷的医保、自费或医保+自费混合支付。 微信医保支付流程交互: 1)绑卡 [图片] 用户通过微信城市服务或当地人社公众号/小程序,进行实人实名等信息校验,将个人微信关联个人社保卡。 2)支付 [图片] 绑卡用户到已接入微信医保支付的医院就医,通过服务号/小程序挂号,可选择微信医保支付,支付诊费/药费(具体使用规定以当地医保政策为准)。 02 业务架构说明 微信医保支付的业务的整体架构,可以类比微信支付,涉及的最基本的模块有三方:社保卡绑卡类比银行卡绑卡,人社局类比银行,医院类比商户。 业务操作流程说明如下: 微信医保支付架构: [图片] 1、用户使用微信绑定个人社保卡; 2、微信与人社系统同步记录用户微信与社保账户的关联关系; 3、医院通过服务商接入微信医保支付打通移动医保支付通路; 4、绑卡用户通过公众号/小程序就诊后通过微信进行移动医保支付; 5、医院上传用户处方单至人社系统,医保结算成功后人社将费用结算信息同步至微信及医院侧,由医院通过服务通路通知用户。 03 接入指引 1) 微信医保支付接入条件 所在城市已上线微信电子社保卡服务,目前已开通的城市:深圳、成都、铜川、郑州、厦门、嘉兴、宁波、开封、武汉,延安、白城、攀枝花,长沙、哈尔滨、沈阳、潍坊、苏州、葫芦岛、西安、宝鸡、咸阳、榆林、渭南、安康、商洛、永康、合肥,常熟、青岛、广州、邯郸、台州、南昌、通化、石家庄、长春、大庆、济南、桂阳县、安吉县、太仓县、东莞、无锡、枣庄、威海等。 医院/药店已开通微信公众号或小程序,且已开通微信支付。 已向当地人社局或医保监管机构申请,确认本医院/药店可申请接入。 2 )申请方式 联系当地人社局或医保监管机构申请确认后,通过以下方式申请: 国家公立医疗机构:可通过医院公众号后台线上申请医保权限,具体操作链接指引https://mp.weixin.qq.com/s/TnaBUREMR8ikZ4efetEfQg 私立医疗机构或药店:通过邮件方式申请开通医保权限,须联系腾讯工作人员对接指引。 以上案例素材,整理自试点小程序。
2020-01-10 - 购买押金是否属于虚拟支付?
https://mp.weixin.qq.com/s/CvawEWKvkIcIGOtwjsqZjg 针对虚拟支付会有一系列的相关的限制,如果有一个出租物品的微信小程序,对出租的物品需要购买押金及租金才可出租, 押金属于虚拟支付吗? 租金属于虚拟支付吗?
2024-08-14 - 小程序渲染引擎Skyline小试牛刀--快书
今年年初,在官方文档上看到小程序团队要推出一款性能逼近原生的渲染引擎Skyline,就一直在关注。刚好最近打算做一款新的阅读小程序,作为一名独立开发者,对于性能和用户体验的追求是永无止境的,于是我决定用纯Skyline打造这款小程序。 当然,这个项目里面所用到的skyline特性只是冰山一角,并非全部,更多酷炫的特性请前往官方文档查阅。 接下来,我会结合快书小程序,从以下几个方面,逐条阐述关于skyline特性(快书项目中所用到的)的理解与应用: 效果演示。如何开启Skyline。新版组件swiper。新版组件scroll-view。全新组件snapshot。增强特性worklet动画。增强特性手势系统。增强特性自定义路由。增强特性共享元素动画。希望对于刚接触Skyline,或者想要了解Skyline的同学有所帮助。当然,如有错误或遗漏,欢迎在评论区批评指正,不胜感激。 一、效果演示 [图片] 二、如何开启Skyline 开启Skyline的方式非常简单,只需要在app.json文件中,加入以下配置即可(这里是全局Skyline,若只打算指定页面开启,则在指定页面的json文件中配置即可): "renderer": "skyline", "lazyCodeLoading": "requiredComponents", "rendererOptions": { "skyline": { "defaultDisplayBlock": true, } }, "componentFramework": "glass-easel", 三、新版组件-Swiper 旧版的Swiper基于webview的,在性能上有所局限,特别是当swiper-item的数量动态不断增加的情况下。当然,也可以自己想办法去优化,比如做懒加载和缓存,但相对来说比较麻烦。而Skyline版本的Swiper性能会大幅度提升,首先渲染引擎本身的性能提升了,另外官方也做了缓存的功能,只需要通过定义cache-extent的值,就能轻松定义缓存区域大小,例如值为 1 则表示提前渲染上下各一屏区域。 [图片] 用法上,和webview版本没有太大区别(这里就不放代码了),只需注意不要使用某些webview独有的特性即可。 四、新版组件-Scroll-view 同样,旧版的scroll-view也基于webview的,滚动元素过多的时候会有明显卡顿,当然也是可以通过虚拟Dom的方式自行优化。然而,Skyline版本的scroll-view官方已经实现了只会渲染在屏节点的特性,大大提升了滚动的流畅度,真正做到了开箱即用。 用法上,有以下几个点要注意的。 指定type属性,有2个可选值,分别为:list和custom,对应的是列表模式和自定义模式。如是普通列表,list即可,如果是稍微复杂的列表,比如常见的瀑布流表现形式(类似小红书那样),则可使用custom。只有直接子节点才能根据是否在屏来按需渲染。即你不能把你的列表项,都放在同一个父级view中,而是应该直接放在scroll-view组件下。 // 错误的方式: <scroll-view type="list" scroll-y> <view> <view class="item" wx:for="{{dataList}}" wx:key="id"></view> </view> </scroll-view> // 正确的方式 <scroll-view type="list" scroll-y> <view class="item" wx:for="{{dataList}}" wx:key="id"></view> </scroll-view> // 正确的方式 <scroll-view type="custom" scroll-y> <list-view> <view class="item" wx:for="{{dataList}}" wx:key="id"></view> <list-view> </scroll-view> 另外,上面提到了瀑布流的问题,实现方式也很简单,官方提供了一个叫做grid-view的组件,只需定义它的type="masonry"即可,但若是在webview下,除了性能不理想以外,还会有一些小BUG,比如我在社区提的这个问题:grid-view masonry 在webview模式下经常会出现大块区域的空白。在Skyline下,就不会出现此类问题。 [图片] <scroll-view type="custom" scroll-y> <grid-view type="masonry" main-axis-gap="15" cross-axis-gap="15"> <view wx:for="{{dataList}}" wx:key="id"></view> </grid-view> </scroll-view> 五、全新组件Snapshot 我们常常会有分享精美海报的需求,但由于海报上的内容是动态,仅仅使用一张图片分享达不到我们的目的。在以往,我们可能会使用到wxml-to-canvas,通过绘制 canvas ,导出图片。现在,在Skyline下(基础库3.0.0以上),实现此需求就非常简单。只需要将我们要分享的内容包裹在snapshot组件下就行。 [图片] // wxml: <snapshot id="target"> <view>content</view> </snapshot> // page: Page({ onReady() { this.createSelectorQuery() .select("#target") .node() .exec(res => { const node = res[0].node node.takeSnapshot({ type: 'arraybuffer', format: 'png', success: (res) => { fs.writeFileSync(savePath,res.data,'binary'); //图片保存至本地 wx.showShareImageMenu({ //唤起分享图片的界面 path:savePath }) }, fail(res) {} }) } }) 六、增强特性-worklet动画 worklet动画相比传统的方式,流畅度提升了不少,但如何使用呢?常见的普通动画无非是对于页面元素的平移,缩放,旋转等变换。那么,要让一个元素动起来,只需要做以下2件事: 将页面元素的样式与某个变量进行绑定,变量值的变化会自动触发样式的更新。实时动态地改变这个变量。结合快书的例子(下拉时,让页面缩小,松手后,页面弹回),来看一下具体的实现步骤。 [图片] 首先,如何绑定样式与参数呢?通过官方提供的一个applyAnimatedStyle函数: // Wxml: <view id="#box">content</box> // Page: this.scale = shared(1); //这里是定义一个共享变量,即可在UI线程和JS线程间同步的变量。 this.applyAnimatedStyle(`#box`, () => { 'worklet'; // 声明这是一个worklet函数 return { transform: `scale(${this.scale.value})`, }; }); // 1、这里使用共享变量是为了让后续改变这个变量时,worklet的函数能捕获到。 // 2、#box你要动起来的元素 // 3、当this.scale.value变化时,会自动触发函数体的执行,从而改变#box的样式 第二步,下拉时,根据下拉的偏移量,改变这个scale的值。 this.scale.value = (evt.deltaY / 100) * 0.15; // 这里的evt.deltaY是下拉时的位置偏移量,然后根据偏移量按比例计算缩放的值 // 如何获取这个下拉偏移量?下一小节讲手势系统时会讲到 第三步,松手时,复原scale的值。 this.scale.value = timing(1, { duration: 300, easing: Easing.ease }); // timing函数表示:在300毫秒内,scale.value会逐渐变成1 // easing: Easing.ease 表示缓动的方式,具体可参考https://easings.net // 如何知道已经松手了?下一小节讲手势系统时会讲到 更多动画参考请查阅官方文档。 七、增强特性-手势系统 还是上面那个例子,我们只说了下拉时根据下拉的偏移量改变scale的值,那如何得到下拉的偏移量呢?这里就涉及到了手势系统。下面讲讲如何让一个元素能响应拖动,缩放等手势。 说回上一小结的例子,我们只需要讲#box元素包裹在手势组件vertical-drag-gesture-handler即可。更多示例可查阅官方文档 // wxml: <vertical-drag-gesture-handler worklet:ongesture="handlePan"> <view id="box"></view> <vertical-drag-gesture-handler> // page: handlePan(evt) { 'worklet' if (evt.state === GestureState.ACTIVE) { // 拖拽时 if (evt.deltaY > 0) { // 下拉 this.scale.value = Math.max(this.scale.value - (evt.deltaY / 100) * 0.15, 0.85); } else { // 上拉 this.scale.value = Math.min(this.scale.value - (evt.deltaY / 100) * 0.15, 1); } } else if (evt.state === GestureState.END || evt.state === GestureState.CANCELLED) { // 拖拽结束或取消 this.scale.value = timing(1, { duration: 300, easing: Easing.ease }); } }, 然而,当手势组件与scroll-view等可以滚动的组件嵌套时,会出现冲突的问题。比如,同上一小节的示例,为了让文章内容过长时可以滚动,我们需要将文章的内容放在scroll-view中。当scroll-view已经滚动到顶部,再继续下拉的话,应当触发手势组件的拖拽事件,即缩放页面。相反,则继续滚动scroll-view。 [图片] // wxml: <vertical-drag-gesture-handler tag="pan" worklet:ongesture="handlePan" shouldResponseOnMove="shouldPanResponse" simultaneousHandlers="{{['scroll']}}"> <vertical-drag-gesture-handler tag="scroll" native-view="scroll-view" shouldResponseOnMove="shouldScrollResponse" simultaneousHandlers="{{['pan']}}"> <scroll-view type="list" scroll-y bindscroll="handleContentScroll">文章内容</scroll-view> </vertical-drag-gesture-handler> </vertical-drag-gesture-handler> // page: // 处理scroll-view的滚动事件,获取scrollTop的值 handleContentScroll(evt) { 'worklet' this.scrollTop.value = evt.detail.scrollTop; }, // return false 则表示scroll-view不再响应滚动事件 shouldScrollResponse(evt) { 'worklet'; const { deltaY } = evt if (this.scrollTop.value <= 0 && deltaY > 0) { //scroll-view已经滚动到顶部,继续下拉时 this.pan.value = true; return false; } if (this.scale.value < 1 && deltaY < 0) { //#box已经被缩放,继续上拉时 this.pan.value = true; return false; } this.pan.value = false; return true; }, shouldPanResponse() { 'worklet' return this.pan.value; // true表示响应手势组件的拖拽事件,false则不响应 }, 八、增强特性-自定义路由 以往在webview中,路由的的过渡动画仅支持从右到左,较为单调。在skyline之后,我们可以自定义路由的过渡动画了,比如常见的淡入淡出,从底部弹起等。比如以下这个例子,从首页点击图片,会跳转到分享的页面,这里就是用自定义路由实现的淡入效果。 [图片] 自定义路由的使用相比前几个特性稍微复杂一点,这里官方讲的更为具体和清晰,可查阅官方文档。唯一要注意的一点是,只有连续的skyline页面跳转时,才会有效果。 九、增强特性-共享元素动画 还是上面的例子,当从首页点击图片跳转到分享页面时,图片看起来像是从首页飞到了分享页,这里便是使用到了共享元素动画。我同时也做Flutter的开发,所以这里看起来非常类似Flutter的hero动画或者叫飞行动画。 使用方式也类似于Flutter。将2个页面的相似组件都用share-element组件包括起来,并且使用相同的key即可。再配合自定义路由,可使得飞行动画看起来非常的丝滑。 // A页面: <share-element key="唯一key"> <image src="imagePath" mode="aspectFill" /> </share-element> // B页面: <share-element key="唯一key"> <image src="imagePath" mode="aspectFill" /> </share-element> // 有几个要注意的地方 // 1、两个个页面的share-element组件必须使用相同的key。 // 2、key是唯一的,即同一个页面中,不能出现重复的key。 // 3、image不要写死宽高,应百分比100%,具体宽高数值写在share-element组件上。 有一个常见的问题,A页面是一个列表,B页面是详情页,列表中的数据都是通过接口从后台返回的,由于共享元素的key又不能重复,那么这个key怎么定义?一般后台返回的数据都会有一个唯一标识,假设为ID,我们可以用这个ID当作Key。 但是,另一个问题来了,如果数据是后台接口返回的,然后通过setData的方式响应到页面,那么很有可能B页面的首帧获取不到这个Key,因为这时候接口请求可能还未完成,那么动画也是不会生效的。针对这种情况,官方也提供了一种方式: 共享元素动画需保证下一个页面首帧即创建好 [代码]share-element[代码] 节点,并设置了 key,用于计算目标位置。如果是通过 [代码]setData[代码] 设置的,可能会错过首帧。针对这种情况,可以 使用 Component 构造器构造下一个页面,只要在组件 [代码]attached[代码] 生命周期前(含)通过 [代码]setData[代码] 设置上去,就会在首帧渲染 十、总结 Skyline还有一些其他有趣的特性,大家感兴趣的话可以查阅官方文档。总的来说,相比起webview,skyline对性能的提升是显而易见的,并且,一些在webview很难实现的效果,在skyline的基础上,也能轻易实现,开箱即用。目前,skyline还在不断地迭代中,还有许多的新特性还在评估和开发中,相信之后的版本会更完善更好用。 最后,大家多多使用快书呀,球球了~ [图片]
2023-09-01 - worklet.timing toValue 类型问题?
SharedValue worklet.shared(any initialValue)参数 any initialValue(任何类型包括数组)初始值,可通过 [代码].value[代码] 属性进行读取和修改。类型可以是 [代码]number | string | bool | null | undefined | Object | [代码][代码]Array [代码][代码]| Function[代码]。 但是AnimationObject worklet.timing(number toValue, Object options, function callback) 但timing 只支持number ? 如果用shared 创建了一个数组,如何用timing 去改变这个值? 官方例子都是用的number ,没有其他类型的说明。const { shared, sequence, timing, spring } = wx.worklet const offset = shared(0) offset.value = sequence(timing(100), spring(0)) 上面是官方例子,如要改成 数组呢怎么办? const offset = shared([0,100]) offset.value = sequence(timing(???????), spring(0)) 针对CSS不光有数值上的调整,字符串也需要,display:flex background-color: cornflowerblue ,这种怎么办?
2024-05-04 - 普通page method中执行wx.worklet.runOnUI不会在ui线程执行?
按钮触发执行普通js函数,在js函数中调用wx.worklet.runOnUI(this.method.bind(this))()。但是在this.method中console.log答应发现并不是在UI线程执行的。是为什么?
2023-12-18 - 微信:把元宇宙装进小程序
[图片] 作为月活13.09亿的国民级应用,微信的每次小升级都很容易形成现象级。2023开年,微信放大招,试图把元宇宙装进小程序。 不久前,微信官方在开放社区贴出了“XR-FRAME”开发指南,这是一套为小程序定制的XR(扩展现实)/3D应用解决方案。简单来说,该方案上线后将从底层赋予小程序扩展现实和3D能力,让未来小程序的人机交互方式由2D向更立体化的3D转变。 之前,XR-FRAME还处于测试阶段,根据官方发布的Demo看,该组件可以更好地呈现3D效果,并提供AR换脸、AR游戏等体验。据透露,微信率先将XR小程序的试水场景瞄准了电商领域,落地AR 试穿试戴、AR 家装等不同类型案例。 如今,框架 XR-FRAME 发布正式版,曾进行了一系列的更新,且一些功能还在开发中。 [代码]xr-frame[代码]在基础库[代码]v2.32.0[代码]开始基本稳定。 [图片] 限制: 最低要求客户端iOS8.0.29、安卓8.0.30及以上,推荐稳定版在iOS8.0.36、安卓8.0.35及以上。 基础库最低2.27.1及以上,推荐2.32.0及以上。 开发工具需要最新版本,建议Nightly版本。 小程序全局同一时刻只能存在一个[代码]xr-frame[代码]组件,否则可能会发生异常。 同一个[代码]xr-frame[代码]组件只能存在一个[代码]xr-scene[代码],并且必须为顶层。 目前不支持和小程序传统标签比如[代码]<view>[代码]混写。 目前不支持[代码]wxml[代码]自动补全,真机调试需要特别注意,见真机调试文档。 同时未来还会追加更多的能力,在未来的规划中,我们还会着重致力于: XR-FRAME内置特色的UI组件,让开发者可以在XR-FRAME组件中写UI,来实现一套酷炫的UI系统。 AR/VR能力持续增强,支持眼睛设备。 交互手段进一步强化,物理碰撞、触发等功能(已完成,待发布)。 工具能力强化,包括标签属性自动补全等。 从战略角度,把元宇宙装进微信小程序,腾讯居安思危。经济学家朱嘉明预测,未来人们社交的基本形态将在元宇宙中进行,倘若这一切真的发生,微信等传统的社交模式将被颠覆。打败微信的可能并非“抖音”社交,而是元宇宙应用。 小程序要3D化 还记得在微信风靡一时的小游戏“跳一跳”吗?玩家通过长按屏幕让小人蓄力跳跃到前方的盒子上得分,小人不慎掉落则游戏结束。这款游戏在2017年12月登陆微信小程序,引来了无数玩家比拼较量。几个月前,消除闯关游戏“羊了个羊”也以微信小程序为载体火爆全网。 自2017年1月微信小程序上线以来,多款现象级应用在其中诞生。依托庞大的微信用户群,小程序为开发者们提供了一片新的创作土壤。6年来,小程序的平台能力和底层框架也在不断升级。2023年刚开年,小程序曝出大动作——正在内测XR框架。 XR即扩展现实,是VR(虚拟现实)、AR(增强现实)、MR(混合现实)的合称,可为受众带来真实与虚拟结合、人机交互的环境。不久前,微信官方在微信开放社区贴出了“XR-FRAME”开发指南,根据描述,这是一套小程序官方提供的XR/3D应用解决方案,基于混合方案实现,性能逼近原生、效果好、易用、强扩展、渐进式、遵循小程序开发标准。 现在这一底层框架还处于测试阶段,小程序官方在开放社区贴出了详细的教程,指导开发者们如何从头构建一个XR小程序。 比起当前小程序采用的Canvas(画布)组件,xr-frame带来了更多能力。据介绍,其提供xml(可扩展标记语言)的方式来描述3D场景,并集成了AR、物理、动画、粒子、后处理等等系统,上手简单。同时,内置完整的PBR(基于物理的渲染)效果、环境光照、阴影,可以快速通过全景图生成环境数据。此外还有渲染性能逼近原生、扩展性强等优势。 [图片] XR小程序开发效果示例 简单理解,xr-frame的上线将从底层赋予小程序扩展现实和3D能力,将让未来小程序的人机交互方式由2D向更立体化的3D转变。 根据官方发布的Demo来看,获得xr-frame支持后,虚拟3D 人、3D物、3D场景都可以在小程序里更好地呈现,此外AR换脸、扫描平面获得AR游戏、扫描特定图片获得AR交互等功能也可实现。这意味着,以后的微信小程序将有更多具有交互性的应用出现。 按照测试节奏,XR小程序功能很可能在中国农历新年后全量上线,业界普遍将其视为下一个微信大版本升级的核心功能。 让元宇宙发生在微信上? 微信内测XR小程序,恰处于元宇宙浪潮激荡之时。这个动作释放了一个信号,微信小程序将一定程度担起腾讯布局元宇宙的使命。 虽然诸如Meta、百度等互联网巨头都开发了元宇宙全景应用,但体验和人气都十分有限。目前与元宇宙相关的应用场景主要体现在人机交互游戏、沉浸式电商购物、虚拟人直播、虚拟会议等方面。没有自建元宇宙平台,腾讯试水元宇宙,选了个轻巧做法,借助微信流量入口,用小程序先接入虚拟现实场景。 据透露,目前微信率先将XR小程序的试水场景瞄准了电商,并与多个不同品类品牌小程序合作,落地 AR 试穿试戴、AR 家装等不同类型案例。 现阶段,XR技术最普遍应用于电商领域。在传统的电商购物体验中,消费者只能依赖商家展示的图片/视频进行购物决策,最终购买后往往会出现买家秀与卖家秀差距过大的情况。而在引入AR技术后,传统电商平台的商品展示模式逐渐被颠覆,尤其在服装试穿、珠宝穿戴、美妆试用等特定场景下,AR能够帮助用户做出更正确的决策。 尽管微信一直不是电商的主战场,但诸如NIKE、Adidas、香奈儿等消费品牌都上线了相关的微信小程序,当XR功能上线后,这些品牌可以更好地展示商品,或许会带动微信电商生态的增长。 另外,可以想象的是,未来微信小程序中也将诞生更多3D建模以及现实交互感更强的小游戏,也许,下一款爆款小游戏将是截然不同的3D形态。 事实上,作为微信生态重要的组成部分,小程序也进入了发展瓶颈。为了实现无需下载、用完即走的效果,微信小程序在性能上做出巨大的牺牲,同样的应用在微信小程序上的表现往往不如APP。这也导致,小程序没有如人们预想一样成为颠覆性的应用,相反,某些功能性较强的应用如果通过小程序使用,显得鸡肋。 XR功能的上线能否为小程序打开增长空间尚未可知。有开发者担忧地表示,xr-frame能否达到预期还需要谨慎乐观。毕竟小程序的设计初衷就是要求快速,这里的“快”指的是加载以及渲染,所以导致微信小程序一直是以webview渲染为主、原生渲染为辅的混合渲染方式,也使得小程序的开发主要是以前端技术为主。然而xr-frame的3D化开发则需要完全不同的技术栈,对于渲染的开销也成倍提升,这就要求开发者要有更为优秀的优化水平。此外,在优质3D建模动辄占用较大内存的情况下,小程序提供的 XR 体验是否会有折扣也要打个问号。 当然,微信作为月活13.09亿的“国民级应用”,流量优势显著,只要有优异的小程序出现,一定不缺用户。这也会吸引大量的品牌方、开发者在上面进行尝试,所以xr-frame的上线将会带动小程序开发生态的繁荣。 从战略角度而言,把元宇宙装进小程序也是腾讯在社交领域的进一步探索,海外的社交巨头Meta已经开始了。 经济学家、横琴数链数字金融研究院学术与技术委员会主席朱嘉明曾认为,Facebook之所以改名为Meta,是因为Facebook本来就是元宇宙,只不过在此前的时代,数字技术发展还有局限,社交体验只能以图文、视频的方式呈现。他预测,未来人们社交的基本形态将在元宇宙中进行,打破时空、地理界限,带来更沉浸式的体验。 倘若这一切真的发生,微信、QQ等传统的社交应用模式将被颠覆,打败微信的可能并非“抖音”这样的视频社交应用,而是元宇宙。 扫码体验 [图片] 你认为XR能否催生现象级小程序? 注:“现象级小程序”是指在短时间内突然爆红而被众所周知和使用
2023-06-13 - 运用小程序Skyline技术构建无缝用户体验 —— 同程旅行酒店最佳实践分享
[图片] 动效衔接设计与小程序渲染框架 1、什么是动效衔接设计? 随着互联网技术和设计理念的不断发展,动效设计成为现代 UI 设计中不可或缺的一部分。其中,动效衔接是非常重要的一环。动效衔接设计是指通过巧妙的动效设计,将不同的 UI 元素在动画过程中自然、流畅地衔接起来,从而增强用户的交互体验和视觉感受。在实际应用中,动效衔接设计主要应用于界面转场、信息提示、状态变化等方面,通过顺畅的衔接,降低用户因白屏等待而产生的焦虑。 2、动效衔接设计的意义 (1)极大提高用户体验,让用户感受到界面的流畅和自然,从而增加用户对产品的好感度。 (2)降低用户的操作认知成本,帮助用户更好地理解执行操作后所带来的结果,从而减少用户对产品的困惑。 (3)强化视觉层,让用户更好地区分不同的信息和元素,从而增强视觉层次感。 (4)增加界面的美感度,让界面更加生动有趣,从而提升整体的美感和设计价值。 (5)提升品牌的认知度,让产品更加具有特色和独特性,从而提高品牌的认知度和市场竞争力。 3、什么是小程序渲染框架Skyline? 为了进一步优化小程序性能,小程序在原 webview 渲染引擎之外最新推出 小程序渲染框架Skyline,其使用更精简高效的渲染管线,并拥有诸多增强特性,让它拥有更接近原生渲染的性能体验。新的增强特性有 worklet 动画系统、手势系统、自定义路由、共享元素动画,而且许多常用的组件如 scroll-view、swiper 都有了更高性能的实现。 [图片] [图片] 实践理念和场景拆解 1、动效衔接设计的核心原则 简单而清晰的动效设计,需要遵守以下几个原则: (1)一致性:动效衔接应该与整体设计风格保持一致。包括颜色、字体、动画速度等方面。 (2)可预测性:用户能够感知动画元素的变化关联性,从而增加用户对产品的掌控感和对界面的理解。 (3)反馈性:动效衔接与用户操作相响应,从而帮助用户理解他们的操作所带来的结果。 (4)视觉层次:动效衔接遵循视觉层次原则,让用户区分页面中的上下关系以及三维物理世界的关系层次,给用户清晰的层级区分感知,提高用户体验。 (5)自然性:动效衔接符合物理规律,例如重力、加速度等,从而增强动画的真实感和用户体验。 2、理念孵化与使用场景拆解 以提炼的动态感受为出发点,理性的层面给予了我们大致的产品体验感知,为我们动效理念的建成提供了框架。对此我们将继续从感性层面出发,找寻可传递真实感受的运动现象并加以组合提炼。 本次 同程旅行小程序 以酒店预订链路中核心的相册页面进行应用场景,在用户操作图片的过程中运用小程序渲染框架承接。 (1)将整个过程进行了拆解,首先为了退出时行动的路径更加清晰,做了一个响应设计,当界面向右滑动退出的过程中,相册图片进行缩小,在缩放过程中,会根据位移距离控制缩放的比例,同时蒙层的透明度以及毛玻璃效果也跟随手指移动变化,和相册列表页在视觉上呈现 XY 轴以及上下层级的空间关系,在缩小到一定的比例时,触发震动效果,松手退出到相册列表页。 (2)在交互结束时,图片退回相册列表页原始位置,在返回路径的过程中,根据交互结束时的定位点,来判断运动的方向和距离,计算运动加速度,以及模拟运动加速度带来的惯性回弹的方向和角度变化,加强与模拟真实物理世界的运动定律,和视觉上的动态感知。 结合自然世界的运动规律来看,把页面进入的元素比作是行驶的汽车,用户当作是正在斑马线上行驶的人,将马路作为页面空间。若汽车采用的是缓入运动(加速)的话,马路上的行人则看到的是一辆不断加速向他行驶过来的车辆。因为担心车辆高速的逼近导致刹车不及时的情况,行人便会本能的作出躲闪的反应。其实页面也是一个道理,进入的元素使用加速运动出现过冲的运动感知会让用户体验时产生不适。 [图片] 小程序渲染框架技术开发实践过程剖析 1、开发自定义路由实现此交互,需要 自定义路由动画,因为小程序渲染框架的页面支持自定义跳转动画。当使用自定义路由后,页面跳转时指定路由类型,就会触发自定义路由动画,而不再是默认的从右往左的动画,此处的实现可以使得页面跳转时,没有默认的路由动画,页面将直接以透明的方式渲染在屏幕上,由开发者自己控制页面内元素的动画展示方式,具体实现如下: (1)在图片查看页面配置文件 index.json 中声明 { "backgroundColor": "#00000000", "backgroundColorContent": "#00000000", // 设置客户端页面背景为透明 "navigationStyle": "custom", "renderer": "skyline", // skyline渲染引擎 "disableScroll": true, "usingComponents": { } } (2)在 wxss 中,设置图片查看页面的 page 节点为透明背景 page { background: transparent; } (3)在 js 中,使用 wx.router.addRouteBuilder(routeType, fn) 来声明自定义路由动画 wx.router.addRouteBuilder('myCustomRoute', function (params) { const handlePrimaryAnimation = () => { 'worklet'; return { // 可在此处,根据 params.primaryAnimation.value 的值,来设置页面的动画效果 backgroundColor: `rgba(0,0,0,${ params.primaryAnimation.value })` }; }; return { opaque: false, handlePrimaryAnimation, barrierColor: '', barrierDismissible: false, transitionDuration: 320, reverseTransitionDuration: 250, canTransitionTo: true, canTransitionFrom: false }; }) (4)在图片列表页面中,使用 x.navigateTo 来跳转页面,并且设置 routeType 为 myCustomRoute wx.navigateTo({ url: '/pages/skyline-image-viewer/index?index=0', routeType: 'myCustomRoute' }) [图片] 需配置页面的渲染引擎为 Skyline,并且在跳转时使用 routeType 就可以实现让页面在跳转时没有默认的路由动画。 2、共享元素穿越在连续的页面跳转时,页面间 key 相同的 share-element 节点将产生飞跃特效,还可自定义插值方式和动画曲线,通常作用于图片。为保证动画效果,前后页面的 share-element 子节点结构应该尽量保持一致 <share-element key="share-key"> <view> you code here </view> <!-- 需要注意,share-element 内要求只有一个根节点 --> </share-element> [图片] 这时,界面的表现像上面视频一样,是一个连续的动画状态,这完全是由 share-element 来控制的,share-element 的动画原理如下图所示: [图片] 3、接入手势组件,实现图片放大、缩小、平移在图片查看页面有如下结构: <scale-gesture-handle worklet:ongesture="onScaleGestureHandle"> <share-element key="{{ shareKey }}" class="current-item"> <image src="{{ src }}"/> </share-element> </scale-gesture-handle> 这里,我们使用小程序渲染框架提供的 手势组件 <scale-gesture-handle>,来实现图片的放大、缩小、平移等手势交互。 注意,所有声明为 worklet 指令的方法它们运行在UI线程,不要在方法中修改普通的变量,因为跨线程的关系,只能修改使用 wx.worklet.shared 声明的变量。 const GestureState = { POSSIBLE: 0, // 此时手势未识别 BEGIN: 1, // 手势已识别 ACTIVE: 2, // 连续手势活跃状态 END: 3, // 手势终止 CANCELLED: 4 // 手势取消 }; Component({ attached() { this.shareX = wx.worklet.shared(0); this.shareY = wx.worklet.shared(0); this.sharScale = wx.worklet.shared(1); // 声明共享变量,并且给需要变化的dom,绑定动画 this.applyAnimatedStyle('.current-item', () => { 'worklet'; return { transform: `translate3d(${this.shareX.value}px, ${this.shareY.value}px, 0) scale(${this.sharScale.value})` } }); // 页面所需的数据,需要在 attached 事件里初始化完毕,使其可以参与首帧渲染 this.setData({ src: '...', shareKey: '...' }); }, methods: { // 当手势组件识别到手势时,触发此回调 onScaleGestureHandle(e) { 'worklet'; const { state } = e; // 在worklet函数里,不要使用 const {} = this 对this解构 const shareX = this.shareX; const shareY = this.shareY; const sharScale = this.sharScale; if (state === GestureState.BEGIN) { // 手势已经识别,此时,可以获取到手势的初始值 } else if (state === GestureState.ACTIVE) { // 手势活跃状态,此时,可以获取到手势的变化值,如平移的距离、缩放的比例等 // 将当前变化的值,设置到 `shared` 变量,就可以改变元素的样式,类似于vue3的数据驱动 shareX.value += e.focalDeltaX; shareY.value += e.focalDeltaY; sharScale.value = e.scale; } else if (state === GestureState.END || state === GestureState.CANCELLED) { // 手势终止或取消,此时,可以获取到手势的最终值 } } } }) [图片] 4、手势协商(解决手势冲突) 上面的 demo 简单演示如何使用手势组件来做图片交互,但是在图片查看页面中,我们还有其他的手势交互,如图片的左右滑动切换等,一般我们会使用 <swiper> 组件来实现,但是 <swiper>组件的内部实现和 <scale-gesture-handle> 组件,都会监听手势事件,手势组件的事件不支持冒泡的,就会导致下面结构横时: <scale-gesture-handle worklet:ongesture="onScaleGestureHandle"> <swiper> <swiper-item wx:for="{{ imgs }}"> <share-element key="{{ item.shareKey }}" class="current-item"> <image src="{{ item.src }}"/> </share-element> </swiper-item> </swiper> </scale-gesture-handle> 使用手势横向滑动时,会优先触发 swiper 的横向切换事件,而无法触发 <scale-gesture-handle> 的手势事件了,这在图片放大时的图片横向移动产生了冲突。此时就需要使用手势协商来解决手势冲突。 什么是手势协商? 手势协商指的是:当页面同时有多个手势交互时,需通过一定的约定来决定哪些手势事件应该被执行,哪些需要被忽略。 小程序渲染框架解决手势冲突的方式,主要是通过手势组件的 tag、simultaneous-handlers、native-view 和 should-response-on-move 来实现 tag:手势组件的标识,用于区分不同的手势组件simultaneous-handlers:手势组件的协商者,表示需要同时触发事件的手势组件的标识should-response-on-move:参与手势时间的派发过程,返回 false时,表示该手势时间不会继续派发native-view:用当前手势组件来代理原生组件内部的手势事件,如<swiper>组件内部的手势事件<swiper> 的内部也是使用了 <horizontal-drag-gesture-handler>手势组件,但是我们不能直接在<swiper>上设置tag来使其参与手势协商,需要用相同的手势组件通过native-view=swiper将其内部的事件代理出来,使其可以参与协商<!-- <scale-gesture-handle> 缩放手势 --> <!-- <horizontal-drag-gesture-handler> 横向拖动手势 --> <!-- 通过 simultaneous-handlers=tag 来声明多个手势应该同时触发 --> <scale-gesture-handle tag="scale" simultaneous-handlers="{{['swiper']}}" worklet:ongesture="onScaleGestureHandle"> <!-- 此处使用 native-view=swiper 代理内部的手势组件 --> <!-- 通过 should-response-on-move=fn 来参与`事件派发`过程,决定手势的事件是否应该派发 --> <horizontal-drag-gesture-handler tag="swiper" native-view="swiper" simultaneous-handlers="{{['scale']}}" worklet:should-response-on-move="shouldResponseOnMove"> <swiper> <swiper-item wx:for="{{ imgs }}"> <share-element key="{{ item.shareKey }}" class="current-item"> <image src="{{ item.src }}"/> </share-element> </swiper-item> </swiper> </horizontal-drag-gesture-handler> </scale-gesture-handle> const GuestureMode = { INIT: 0, SCALE: 1, SWIPE: 2, MOVE: 3 // ... }; Component({ attached() { this.GuestureModeShared = wx.worklet.shared(GuestureMode.INIT); this.shareX = wx.worklet.shared(0); this.shareY = wx.worklet.shared(0); this.shareScale = wx.worklet.shared(1); // 声明共享变量,并且给需要变化的dom,绑定动画 this.applyAnimatedStyle('.current-item', () => { 'worklet'; return { transform: `translate3d(${this.shareX.value}px, ${this.shareY.value}px, 0) scale(${this.shareScale.value})` } }); // ... }, methods: { onScaleGestureHandle(e) { 'worklet'; const { state } = e; if (state === GestureState.BEGIN) { this.GuestureModeShared.value = GuestureMode.INIT; } else if (state === GestureState.ACTIVE) { if(this.GuestureModeShared.value === GuestureMode.INIT) { this.gestureBefore(e); // 手势类型未知时,判断手势类型 } else { this.gestureHandle(e); // 手势类型已知时,处理手势事件 } } else if (state === GestureState.END || state === GestureState.CANCELLED) { this.GuestureModeShared.value = GuestureMode.INIT; } }, // 判断手势类型 gestureBefore(e) { 'worklet'; const { focalDeltaX, focalDeltaY, scale } = e; if (Math.abs(focalDeltaX) > Math.abs(focalDeltaY)) { this.GuestureModeShared.value = GuestureMode.SWIPE; } else if (scale > 1) { this.GuestureModeShared.value = GuestureMode.SCALE; } else { this.GuestureModeShared.value = GuestureMode.MOVE; } }, // 处理手势事件 gestureHandle(e) { 'worklet'; if (this.GuestureModeShared.value === GuestureMode.SCALE) { this.shareScale.value = e.scale; } else if (this.GuestureModeShared.value === GuestureMode.SWIPE) { // swiper 切换模式时,这里什么都不用做 } else if (this.GuestureModeShared.value === GuestureMode.MOVE) { this.shareX.value += e.focalDeltaX; this.shareY.value += e.focalDeltaY; } }, // 用于判断手势事件是否应该派发 shouldResponseOnMove(e) { 'worklet'; return this.GuestureModeShared.value === GuestureMode.SWIPE; // 当模式为SWIPE时,才响应手势事件 } } }) [图片] 通过上面的代码,我们实现了手势协商,当用户在图片上进行滑动的操作时,总是会触发 <scale-gesture-handler> 的手势事件,通过对图片当前状态的判断来决定应该触发哪种手势,我们通过此种协商让 <horizontal-drag-gesture-handle> 手势在合适的时机触发,以此避免手势冲突。 5、使用小程序渲染框架时需要注意的一些地方作为一款新的渲染优化方式,开发者使用小程序渲染框架需要注意以下内容,以保证渲染的效果和性能。 (1)自定义路由时首帧渲染&首帧性能优化 小程序渲染框架的首帧渲染对共享元素动画非常重要,若共享元素节点的key 错过首帧设置的话,可能会丢失飞跃动画,所以在使用小程序渲染框架时,共享元素的 key 应该尽量在 attached 中或之前设置到页面,并且在首帧渲染时,应尽可能的减少 UI 层的渲染工作 如下: 1)所需要的数据应尽可能使用提前计算好,避免构建页面时等待太久影响响应速度 2)首次设置的数据应该尽可能的少,避免首次渲染时,页面上的元素过多,导致首帧渲染时间过长,导致动画卡顿(如:不要同时初始化太多的 <swiper-item>) 3)确保首帧渲染时,共享元素的 key 正确的设置,避免在首帧渲染时,由于找不到对应的共享元素,导致动画丢失,看不到飞跃动画 4)由于手势事件触发频繁,应尽量避免大量需要的计算的逻辑高频执行,容易导致机器发烫,或者导致动画卡顿 **worklet 函数的使用** worklet 函数的使用有一些限制,主要是由于它是在 UI 线程执行的,所以 worklet 函数中的 this 并非是页面的 this 实例, 里面所使用到的变量也是通过特殊的 babel 插件转换到UI线程的,需要与逻辑层共用的变量都需要用 wx.worklet.shared 将它声明成共享变量,在 UI 线程调用逻辑层的函数需要使用 wx.worklet.runOnJS (2)与 web 规范的差异 虽然小程序渲染框架尽可能的与 web 规范保持一致,但是由底层渲染引擎的限制,还是有一些差异,如: 1)display: flex 的默认朝向是 column,而不是 row,这需要开发者注意,官方后续会支持 block 布局方式 2)暂不支持 css 伪元素,如 ::after、::before,官方正在支持中 3)position 仅支持 absolute、relative,不支持 sticky,实现滚动吸附的效果需用 sticky-* 组件来配合 scroll-view 实现 ** <share-element> 在非小程序渲染框架运行环境里的表现是什么** 在非小程序渲染框架的运行环境内,<share-element> 组件会被视为一个 <view> 组件,需要做好布局的兼容 6、何时使用小程序渲染框架开发时,请确保小程序开发者工具版本是 最新版 nightly,sdk 版本在 2.30.2+,具体限制可参考 文档。 这些新特性的引入,使得小程序渲染框架在小程序开发中的优势更加明显,开发者可以更加便捷地实现各种复杂的交互效果,并且达到接近原生APP的体验。 [图片] 未来展望 1、个性化产品形态:将会根据不同的用户需求和场景,设计出更加符合用户喜好和习惯的动效衔接,进行组件化调用。 2、更加自然和真实的动效衔接:动效衔接将会更加贴近自然规律和真实物理效应,从而增强动画的真实感和用户体验。 3、更加智能化和自适应的动效衔接:动效衔接将会根据用户的操作行为和使用习惯,自适应调整动画效果,从而提高用户体验和产品效果。 4、扩大产品、设计与开发的协作效应:设计对动效的把控、产品对用户的洞察以及开发对新技术的应用,才可以发挥最大化的协作效应。 附1:本文作者 同程旅行研发工程师 同程旅行体验设计师 同程旅行产品经理 附2:代码片段 相册小程序代码片段(请使用 PC 端浏览器打开):https://developers.weixin.qq.com/s/E979jCmP7oHG 附3:UE标注 [图片] 附4:AB 实验效果 AB 实验显著win0.23% [图片]
2023-04-28 - 使用 Skyline Worklet 动画实现下拉页面放大头图
效果 [图片] 思路 监听滚动事件,映射滚动距离至头图的放大比例。 实现 <!-- index.wxml --> <scroll-view scroll-y class="scrollView" type="list" worklet:onscrollupdate="scrollViewOnScroll" > <image src="https://wx2.sinaimg.cn/large/007GYgpfly1hnfihmysbmj32yp281u0y.jpg" mode="aspectFill" class="headerImage" /> </scroll-view> /** index.ts */ const { shared } = wx.worklet /** 头图的放大高度 */ const headerImageHeight = shared(0) Component({ lifetimes: { attached() { this.applyAnimatedStyleToHeaderImage() } }, methods: { /** 绑定由 worklet 驱动的样式到头图 */ applyAnimatedStyleToHeaderImage() { this.applyAnimatedStyle( '.headerImage', () => { 'worklet' const scale = (240 + headerImageHeight.value) / 240 return {transform: `scale(${Math.max(1, scale)})`} }, {immediate: false} ) }, /** <scroll-view/> 的滚动回调 */ scrollViewOnScroll(event: any) { 'worklet' headerImageHeight.value -= event?.detail?.deltaY } } }) /* index.wxss */ .scrollView { width: 100vw; height: 100vh; } .headerImage { width: 100%; position: fixed; top: 0; left: 0; pointer-events: none; }
2024-07-07 - onShow与onLoad的一些理解和实践
基本介绍onShow、onLoad与onReady都是小程序页面生命周期函数。 onLoad 在页面加载时调用,仅一次; onShow页面显示/切入前台时触发,两个生命周期非阻塞式调用。 onReady 是页面初始化数据已经完成后调用的,并不意味着onLoad和onShow执行完毕。 调用顺序是onLoad > onShow > onReady 根据对应的执行机制,我们预期有三种执行的逻辑 A. 页面每次出现都会执行 从其他页面返回手机锁屏唤醒,重新看到小程序页面把当前小程序页面重写切换到前台(多任务)B. 页面加载后只需执行一次(页面第一次载入) C. 只在页面非第一次执行时才执行(A情况的子集,页面非第一次展示时) 需求与问题逻辑1: 因为onLoad和onShow是非阻塞执行的,当我们有一个这样的需求:页面载入执行A方法,页面展示执行B、C、D方法时,A需要在BCD之前执行,此时把A放在onLoad中,BCD放在onShow中就无法实现需求 逻辑2: 还有一种需求是:页面第一次执行A,非第一次执行R-A,这里onLoad和onShow并没有非第一次的逻辑,需要手动判断。 一种实践方法下面是纯粹使用onShow代替onLoad,完成所有逻辑的示例,保证了业务逻辑的执行顺序可控。 options获取使用其他方式代替。 为了保持onShow中逻辑的清晰性,尽量使用EventChannel去替代原本onShow+globalData的逻辑。 data:{ first: true }, async onShow(){ //代替onLoad中的options的获取 const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; const options = currentPage.options; this.funD() // C2 页面每次都调用的逻辑 if(this.data.first){ this.data.first = false; await this.funA(); //A 仅在页面初次调用的逻辑(按需是否阻塞调用) }else{ await this.funB(); //B 仅在页面非初次时调用的逻辑 } await this.funC(); //C1 页面每次都调用的逻辑 } 另外一种使用实践data:{ first: true } onShow(){ this.funD() //页面每次都调用的逻辑(仅非阻塞) if(!this.data.first){ this.funC() //仅在页面非初次时调用的逻辑 } await this.funE() //页面每次都调用的逻辑(可阻塞,可非阻塞) }, onLoad(){ //仅在页面初次调用的逻辑 this.funA(); await this.funB(); } onReady(){ this.data.first = false; } 如有错误,恳请指出。
2022-09-23 - 自定义组件没有被销毁,导致存在内存泄露的情况?
Demo 地址 问题描述 自定义组件实例没有被销毁,如果给组件的 properties 传递了大量数据(Demo 中是 500k),将快速占用内存。 复现步骤 1.打开 Demo,然后打开开发者工具的 Memory 面板,录取内存快照,关注构造函数:l。 [图片] 2.点击 Click 按钮 10 次(相当于渲染、隐藏自定义组件 foo 5 次),然后点击强制垃圾回收按钮,接着再次录取内存快照。 3.选中快照 Snapshot2,filter 设置为:Objects allocated between Snapshot1 and Snapshot2,并筛选构造函数:l。这时可以观察到内存占用大幅上升,其中新增的 5 个对象 l 引用了 32% 的内存。 [图片] 4.展开其中一个 l 对象,可以观察到其 __methodCaller 属性引用的是一个自定义组件实例,从而判断 l 对象跟自定义组件有关。再观察 l 对象中内存占比较大的字段分别是 __vtObj 与 __innerData,它们都分别引用着父组件传入的 json 属性(500k 大数据)。( l 对象的 Retainers 是一些循环引用或内部代码,很难再往下追查) [图片] 5.右键点击 l 对象,选择 Store as a global variable,把 l 对象放到控制台进行观察,发现 __vtObj 与 __innerData 属性引用的是独立的 json 对象。 [图片] 6.综上,推测自定义组件实例没有被销毁,如果给自定义组件的 properties 传递了大型数据,会导致内存泄露问题更明显。 环境信息 基础库:2.32.2复现环境:微信开发者工具(真机调试不支持堆栈 snapshot)
2023-06-26 - wxml2canvas-2d:简单易用的小程序海报、战绩等分享图片生成方案
Github 地址:https://github.com/ChrisChan13/wxml2canvas-2d 介绍 当前,众多小程序的多处场景都需要能够生成分享图便于用户进行二次传播,从而提升小程序的传播率以及加强品牌效应。 比较简单的分享图,如寥寥几行文字和一张小程序码,可以通过微信的 Canvas API 绘制。旧版 Canvas API 绘制过程繁琐,且每次绘制都需要调用 draw 方法,一不小心代码就写了上百行。 新版 Canvas API 基本与 Web Canvas 对齐,使得开发效率提高、性能得到优化。虽然免去了很多繁琐操作,但面对拥有元素众多、结构复杂的分享图片,依然解决不了代码冗长的问题。 目前开源的一些小程序图片生成方案,有的年久失修、有的依然使用旧版 Canvas API、有的使用方式不够简便,于是便有了开发 wxml2canvas-2d 的想法。 wxml2canvas-2d 的图片生成方式简单直观:首先在 wxml 页面上编写元素结构,其次在 wxss 中编写元素样式,最后调用 wxml2canvas-2d 的相关方法即可生成所需的分享图片。 wxml2canvas-2d 会通过 class 类名查询元素节点的 computedStyle 和节点属性,从而将元素节点绘制到画布上。这样做的好处是在编写 wxml 结构以及样式时,可以直观的看见样式的变化,方便调整。当然也有坏处,这个用来生成图片的“wxml 模板”,必须存在于页面上。若需要隐藏这个“模板”,只可用定位将其移至屏幕外,不可以使用 wx:if 或 hidden 隐藏。 wxml2canvas-2d 已经支持大部分常用的 CSS 属性,等你来测~ 示例 克隆此 Github 仓库,运行 [代码]npm i & npm run dev[代码],将 miniprogram_dev 文件夹导入微信开发者工具 效果预览 小程序内容: [图片] 生成的图片: [图片] 安装 npm 使用 npm 构建前,请先阅读微信官方的 npm 支持 [代码]# 通过 npm 安装 npm i wxml2canvas-2d -S --production [代码] 构建 npm 包 打开微信开发者工具,点击 工具 -> 构建 npm,并勾选 使用 npm 模块 选项,构建完成后,即可引入组件。 使用 在页面配置中引入 [代码]wxml2canvas-2d[代码] ; [代码]{ "usingComponents": { "wxml2canvas": "wxml2canvas-2d" } } [代码] 在页面中编写 wxml 结构,将要生成画布内容的根节点用名为 [代码]wxml2canvas-container[代码] 的样式类名称标记,将该根节点内部需要生成画布内容的节点用名为 [代码]wxml2canvas-item[代码] 的样式类名称标记(文字类节点需在对应节点声明 [代码]data-text[代码] 属性,并传入文字内容)。上述两个样式类名称可以自定义,只需将对应名称传入 [代码]wxml2canvas[代码] 组件的对应属性参数即可; [代码]<!-- pages/index/index.wxml --> <view class="wxml2canvas-container box"> <view class="wxml2canvas-item title" data-text="测试标题">测试标题</view> <image class="wxml2canvas-item image" src="/your-image-path.png" /> <view class="wxml2canvas-item content" data-text="测试内容,长文本。。">测试内容,长文本。。</view> </view> <button catchtap="generateSharingCard">生成画布内容</button> <wxml2canvas id="wxml2canvas" /> [代码] 补充各个节点样式; [代码]/* pages/index/index.wxss */ .box { /* 根节点(容器)的样式 */ } .title { /* 标题的样式 */ } .image { /* 图片的样式 */ } .content { /* 内容的样式 */ } [代码] 依据 wxml 结构以及 css 样式,生成画布内容,并将生成结果导出。 [代码]// pages/index/index.js Page({ async generateSharingCard() { const canvas = this.selectComponent('#wxml2canvas'); await canvas.draw(); const filePath = await canvas.toTempFilePath(); wx.previewImage({ urls: [filePath], }); }, }); [代码] 更多内容及文档 点击此处 前往查看!如果有好的建议或者想法,也欢迎提交 Issue 或 PR~
2024-11-21 - 微信支付报错:201商户订单号重复
发起微信支付但未支付,调用关闭订单接口后,查询订单也是关闭状态, [图片] 再次使用之前的商户订单号发起支付报错:201 商户订单号重复[图片]
2024-04-22 - Skyline 渲染引擎常见问题
Skyline 一定需要应用到整个小程序吗? 不需要,Skyline 支持按页面粒度开启,建议开发者逐个页面适配 在 Skyline 模式下,为什么使用真机调试会显示空白并且工具报错? 目前 Skyline 模式下暂不支持真机调试,建议使用真机预览完成调试,平台在尽快支持真机调试能力。 在 Skyline 模式下,为什么微信开发者工具热重载无响应? Skyline 模式暂不支持热重载,建议先关闭热重载,重新编译来预览渲染结果。后续平台将支持热重载能力。 开启 Skyline 后布局错乱 大多是由于没有全局滚动而导致挤压,以及 flex-direction 默认为 column 造成。前者只需要加上 scroll-view,后者可以在声明了display:flex 但又没指定 flex-direction的地方显示指定flex-direction:row。推荐开发者开启默认 Block 布局。 切换 Skyline后,为什么顶部原生导航栏消失? 不支持原生导航栏,需自行实现,或使用 weui 组件库 伪类及伪元素部分支持 对于伪类,目前只支持常用的 :first-child 和 :last-child 。其它伪类可通过按需添加 class 替代,如 :active 则手动给点击状态下的节点加个.active class 对于伪元素,目前只支持 ::before 和:after。其它伪元素建议用真实 WXML 节点实现。 全局固定元素失效 因不支持 fixed 导致,但由于没有全局滚动,在页面根节点下使用 absolute 即可达到 fixed 的效果,倘若封装原因无法移至页面根节点,可使用 root-portal 组件包裹 切换 Skyline 后,为什么 position: absolute 相对坐标不准确? 在 Skyline 模式下,所有节点默认是 relative,可能导致 absolute 相对坐标不准。建议开发者修改节点 position 或者修改相对坐标。 多段文本无法内联 因不支持 inline 布局导致,需改成 flex 布局实现,或者使用 text 组件包裹多段文本,而不是用 view 组件包裹,也可以使用 span 组件包裹 text 和 image 混合内联。如 、<span><image /></span>,<span><view style="width: 50px;"/></span> 多行文本的省略样式失效 在单行文本省略的基础上,通过 text 组件的 max-lines 属性设置最长行数,即 <text max-lines="{{2}}"></text> z-index 表现异常 这是由于 Skyline 不支持 web 标准的层叠上下文所致,只有在同层级的节点之前应用 z-index才有效,可根据实际情况调整取值 weui 扩展库无法使用 平台正在支持扩展库,预计近期上线。建议开发者使用 npm 安装 weui 组件库 后,将 node_ modules/weui-miniprogram 下的miniprogram_ dist 替换为 链接 中的 miniprogram_dist,然后在微信开发中工具中构建 npm 即可。 不支持组件 animate 动画接口 暂不支持组件 animate 动画接口。如需实现相关效果,可使用 worklet 动画机制 实现 svg 渲染不正确 Skyline 上的 SVG 不支持 <style> 选择器匹配,可自行转成内联的方式;不支持 rgba 格式,可使用 fill-opacity 替代;建议用 SVGO 在线工具优化 scroll-view 横向滚动不生效 横向滚动需打开 enable-flex 以兼容 WebView,同时 scroll-view 添加样式 display: flex; flex-direction: row;,scroll-view 子节点添加样式 flex-shrink: 0; icon-font 图标不显示 最新版本已支持伪元素,低版本可参考 代码片段 实现图标
2023-10-18 - 微信小程序深度合成-AI问答类目获取指引(AI小程序必备)
前言只要AI相关的小程序没有深度合成类目提交审核都会被拒绝,涉及到AI问答,AI绘画,AI换脸都需要补充类目才能提交,那么如何准备深度合成AI问答类目所需材料,才能通过【深度合成-AI问答】类目审核? [图片] 方案选择资质时选第二个方案: [图片] 2.1、使用第三方技术:同时提供: ① 技术主体的《互联网信息服务算法备案》(算法类型为“生成合成类(深度合成)”)或《互联网信息服务算法备案》(算法类型为“生成合成类”)在审批中的系统截图及 ②小程序主体与技术主体的合作协议(协议需含【算法名称】或【应用产品】或【备案编号】相关内容) 材料需要两个:1.大模型算法备案截图 2.合作协议截图 首先进入微信服务市场选择「接口和插件」 [图片] https://fuwu.weixin.qq.com/ 然后找到大模型服务任意选择一个大模型服务即可 [图片] 进去大模型服务选择一个套餐进行购买操作 [图片] 购买成后会生成一个订单截图,订单截图中包含了算法备案截图分别提交即可申请AI问答类目。 算法备案截图如下: [图片] 合作订单截图如下: [图片] 分别按顺序上传到这两处资质文件即可(1.算法备案2.合作订单) [图片]
2024-10-27 - 微信云开发支付签名错误,请情况?
说一下没有子商户,只有商户号,就是用的以前的支付接口,这是云开发没有后端代码那个notify_url不写还不行, 不知道哪里有问题, 签名在微信工具里验证通过,调用下单接口就报签名错误,希望会的人答疑解惑一下,谢谢啦 //2 openid 就是支付用户的识别号 const mch_id = 'xxx '; // 商户号 const key = 'xxxxx'; // 商户密钥 const cloud = require('wx-server-sdk') const rp = require('request-promise') const crypto = require('crypto') cloud.init() function getSign(args) { let sa = [] for (let k in args) sa.push(k + '=' + args[k]) sa.push('key=' + key) console.log(sa.join('&')) return crypto.createHash('md5').update(sa.join('&'), 'utf8').digest('hex').toUpperCase() } function getXml(args) { let sa = [] for (let k in args) sa.push('<' + k + '>' + args[k] + '</' + k + '>') sa.push('<sign>' + getSign(args) + '</sign>') let axml = '<xml>' + sa.join('') + '</xml>' console.log("最后签名:",axml) return axml } function getNonceStr(){ var chars = ['0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z']; var nums=""; for(var i=0;i<32;i++){ var id = parseInt(Math.random()*61); nums+=chars[id]; } nums= nums.toLowerCase() return nums ; } exports.main = async (event, context) => { const wxContext = cloud.getWXContext() const appId = appid = wxContext.APPID console.log("appid是:", appid, appId) console.log("key是:", key) const openid = wxContext.OPENID // const attach = 'attach' const body = event.msg; const total_fee = event.totalFee; const notify_url = "http://127.0.0.1" const spbill_create_ip = "127.0.0.1" const fee_type = "CNY" // const nonceStr = nonce_str = Math.random().toString(36).substr(2, 15) const nonceStr = nonce_str = getNonceStr() const timeStamp = parseInt(Date.now() / 1000) + '' const out_trade_no = event.outTradeNo; const trade_type = "JSAPI" const sign_type = "MD5" const xmlArgs = { appid, // attach, body, fee_type, mch_id, nonce_str, notify_url, openid, out_trade_no, sign_type, spbill_create_ip, total_fee, trade_type, } let xml = (await rp({ url: "https://api.mch.weixin.qq.com/pay/unifiedorder", method: 'POST', body: getXml(xmlArgs) })).toString("utf-8") console.log("签名是:",xml) if (xml.indexOf('prepay_id') < 0) return xml let prepay_id = xml.split("<prepay_id><![CDATA[")[1].split("]]></prepay_id>")[0] let payArgs = { appId, nonceStr, package: ('prepay_id=' + prepay_id), signType: 'MD5', timeStamp } return { ...payArgs, paySign: getSign(payArgs) } }
2024-10-23 - 记录一次云开发数据库查询的简单优化
云开发数据库联表查询,最开始表现优异,随着数据量的增加,某条语句的查询时间居然超过了2秒。后面着手优化查询到100毫秒以内。 优化主要事项: 1、将条件(match)、排序(sort)、分页(skip、limit)移到联表之前,先查出部分结果后再联表操作。 2、取消模糊查询连接后的子表字段。 优化后的语句如下: db.collection("form_answers") .aggregate() .match({ _openid: "xxxx" }) .sort({ createTime: -1 }) .skip(0) .limit(20) .lookup({ from: 'forms', let: { formId: '$formId' }, pipeline: $.pipeline() .match(_.expr($.and([ $.eq(['$_id', '$$formId']), ]))) .project({ _id: 0, name: 1, unionid: 1 }) .done(), as: 'formList', }) .replaceRoot({ newRoot: $.mergeObjects([{ formName: $.arrayElemAt(['$formList.name', 0]), formUnionid: $.arrayElemAt(['$formList.unionid', 0]) }, '$$ROOT']) }) .project({ formList: 0 }) .end()
2024-06-23 - 省钱有道之 减少云函数调用次数
由于云函数有一项计费规则是按调用次数计费,在小程序访问量比较小的情况下还比较无所谓,但当体量上来之后不得不考虑控制一下对公共接口的调用次数从而减少一些不必要的开销。比如获取用户信息接口、获取配置信息接口 这里分享一个我自己几个小程序用到的方法,公共接口的调用都放在app.js,然后提供函数供其他页面调用。同时由于异步问题,有可能页面加载完接口还未返回,因此还需能够注册回调函数,在接口返回数据后回调给调用页面 代码示例: app.js App({ onLaunch: async function (options) { //判断是否需要更新小程序 updateCheck.check(); await api.wxCloudInit(); //获取用户信息 this._getUserInfo().catch(res => { console.warn("获取用户信息失败,准备重试"); this._getUserInfo().then(); }); }, /** * 获取用户信息 * @param callback * @param refresh 等于true时表示重新查询用户信息,同时也会更新会员状态 */ getUserInfo: function (callback, refresh) { if (!refresh) { const userInfo = this.globalData.userInfo; if (!userInfo.ready) { if (typeof callback == 'function') { this.callbackFunctions.userInfoReadyCallback.push(callback); } if (!this.userInfoReadyCallback) { this.userInfoReadyCallback = res => { console.log("获取用户信息完毕,开始回调", res); const callbacks = this.callbackFunctions.userInfoReadyCallback; while (callbacks.length) { const callback = callbacks.pop(); typeof callback == 'function' && callback(res); } /*callbacks.forEach(callback => { typeof callback == 'function' && callback(res); })*/ } console.log("注册userInfoReadyCallback成功"); } else { console.log("已经注册了userInfoReadyCallback,不再重复注册"); } } else { typeof callback == 'function' && callback(userInfo); } } else { console.log("准备更新用户信息") this._getUserInfo().then(userInfo => { typeof callback == 'function' && callback(userInfo); }); } }, /** * 执行云函数,获取用户信息 * @returns {Promise<unknown>} * @private */ _getUserInfo: function () { return new Promise((resolve, reject) => { api.callCloudUserCenterFunction("UserInfoHandler/getUserInfo", {}, res => { console.log("获取用户数据完毕:", res.result); const result = res.result; if (result.success) { const data = result.data; this.globalData.userInfo = data; // 由于 getUserInfo 是网络请求,可能会在 Page.onLoad 之后才返回 // 所以此处加入 callback 以防止这种情况 if (this.userInfoReadyCallback) { this.userInfoReadyCallback(data); } resolve(data); } else { console.error("没有获取到用户信息"); reject("没有获取到用户信息"); } }, e => { console.error("获取用户信息失败", e); reject("获取用户信息失败"); }); }); }, /** * 异步事件回调函数列表 * 增加这个列表是为了避免不同地方同时调用,互相覆盖回调函数 */ callbackFunctions: { //用户信息异步回调 userInfoReadyCallback: [], }, globalData: { userInfo: { confirm: false//用来标记用户信息查询动作是否已经结束,等于true时,userInfo才可信 }, } }) 某page.js app.getUserInfo(res => { const isVip = res.isVip; if (isVip) { console.log("已开通会员", res); } });
2024-02-07 - 微信小程序的Text组件,设置user-select之后,怎么样才能当一行放不下的时候不整体换行?
微信小程序的Text组件,设置user-select之后,当一行放不下这个text文本的时候,会整体换行。有没有什么办法让这是user-select的text组件按照原来的样式正常换行(填充完当前行之后剩下部分的再换行展示) <scroll-view class="scroll-area" type="list" scroll-y style="padding: 0px 20px; box-sizing: border-box;"> <view style="display: inline;"> <text >欢迎使用代码片段,</text> <text >:可在控制台查看代码片段的说明和文档</text> </view> <view style="margin-top: 50px;"> <text user-select>欢迎使用代码片段,</text> <text user-select>:可在控制台查看代码片段的说明和文档</text> </view> </scroll-view> [图片]
2024-10-21 - 就医服务类目卫健委批文究竟是什么?
小程序就医服务类目需要的卫健委批发就是医疗机构执业许可证吗?
2022-04-05 - 强烈投诉,小程序服务类目被打回10次,一直被要求补充的资料卫健委说从不提供这种内容,为什么还不通过?
审核类型:服务类目审核“医疗服务>互联网医院” 提审时间:自9月20号上传第一版,到10月14号,过程中上传不少于10次,快要一个月过去了,依然没有审核通过。 面临问题: 申请服务类目为“医疗服务>互联网医院”,我们按照要求上传了相关资质。我们小程序主题是A,互联网医院牌照的主体是B。针对合作医院B的《医疗执业机构许可证》和A与B《合作协议》均没有什么异议。 但是,微信审核时,要求“《医疗主管部门许可文件》(含“互联网诊疗“相关内容)”和“《省级互联网医疗服务监管平台对接情况证明》”这两项资料, 需要上传任何一项资料。 在跟浙江省卫健委的领导沟通后,对方回复“我们已经发给你们了《医疗执业机构许可证》,还需要什么《医疗主管部门许可文件》,我们没有这样的内容可以提供。” 所以我们选择,上传省监管平台下载的《省级互联网医疗服务监管平台对接情况证明》,同时附了省监管平台及浙里办等官方平台查到的B互联网医院,以及公开可核实的官网链接,并加盖了我司公章。但是微信平台要求加盖政府相关部门单位的公章,可是浙江省卫健委的领导回复“我们没有这样的盖章流程,盖不了。” 想要问问小程序服务类目的审核人员: 1、你们要的《医疗主管部门许可文件》或者《省级互联网医疗服务监管平台对接情况证明》,具体是个什么样子,能否给个示例,我们也好发给浙江省卫健委的领导参考? 2、我们已经证明了B有有效期内的《医疗执业机构许可证》和A与B有有效期内的《合作协议》,上传另外两项资料的合理性是什么? 我们上传的资料你们真的看了么?你们的一次审核,关系到我们挣个团队一个季度的努力,但是你们并不会管我们死活。再审核不通过,我们整个产品部都要被开了。。。。客服联系不到,审核又不合理,真的。。。。
2024-10-14 - 小程序pdf、word、excel、ppt等文件页数与预览(第5篇)
一、实现思路: 通过调用第三方服务,将word、excel、ppt等文件通过转码,转成pdf文件方式,然后进行页码获取以及预览。 二、常用第三方转换平台: 阿里云文档处理:https://help.aliyun.com/zh/oss/user-guide/overview-65?spm=a2c4g.11186623.0.0.31927361ms2sPH 腾讯云文档处理:https://cloud.tencent.com/document/product/460/47495 wps开发平台:https://solution.wps.cn/docs/convert/principle.html 三、相关文章 在小程序里初步获取pdf页数在小程序里预览pdf文件小程序与h5接近实时的双向通信小程序页面通过webview获取pdf页数(改为:小程序页面实时计算pdf页数)小程序pdf、word、excel、ppt等文件页数与预览 备注:做过不少文档相关的服务,有需要可私信沟通。
2024-10-12 - 微信jsapi,一码多付
微信jsapi支付问题,想实现一码多付,就是客户只看到一个二维码120元,内部下两个订单,分别给商户a和商户b,可以实现吗?
2024-10-12 - 🎆我们开源啦 | 基于Skyline开发的组件库🚀
我们开源啦,希望可以给大家的开发之旅带来一些灵感。我后溪的小程序也都会基于这个组件库开发,并且会保持组件库的更新与维护。 我是第一次进行开源,肯定会有错漏,欢迎大家指正,我会以最快的时间响应修改。 Skyline UI 组件库 前言 Skyline 是微信小程序推出的一个类原生的渲染引擎,其使用更精简高效的渲染管线,性能比 WebView 更优异,并且带来诸多增强特性,如 Worklet 动画、手势系统、自定义路由、共享元素等。 使用这个组件库的前提是:通过微信小程序原生+skyline框架开发,所以目前我们不保证兼容webview框架(也就是电脑端与低版本的微信),但后续会进行系统性的兼容。 使用 Skyline UI前,请确保你已经学习过微信官方的 微信小程序开发文档 和 Skyline 渲染引擎文档 。 背景 随着Skyline 渲染引擎 1.1.0 版本发布,我们所运营的小程序也平稳的渡过了阵痛期,团队使用Skyline也越来得心应手,所以接下来,团队的开发重心全面偏向Skyline渲染框架,考虑有大量的UI交互重复,我们决定基于Skyline开发了这个UI组件库。 但团队力量有限,这个新生的组件可能有很多的不尽如人意,所以希望能以开源的方式吸引更多开发者使用Skyline框架,如果这个框架不适合你,也可以借鉴其思路。 Gitee Gitee仓库 在线预览 以下是目前两个使用该框架的小程序 SkylineUI组件库 [图片] NONZERO COFFEE [图片] 开始使用 UI库结构 Skyline UI组件库 依赖于以下四部分,具体使用参考以下的具体说明 utils工具库: 其中包含了UI库自定义的一个工具类SkyUtils,它包含了组件中所含的各种函数,非常重要。 各组件元素:sky-*(组件名) skywxss样式库:其中包含深浅色色彩、文字字体、布局等样式wxss 在小程序中引入 UI库 一、直接下载引入 点击下载组件包 将src下所有文件复制到您项目根目录下的components文件夹中,没有的话请自行新建。 二、npm引入 1.在小程序项目中,可以通过 npm 的方式引入 SkylineUI组件库 。如果你还没有在小程序中使用过 npm ,那先在小程序目录中执行命令: [代码]npm init -y [代码] 2.安装组件库 [代码]npm install jieyue-ui-com [代码] 3.npm 命令执行完后,需要在开发者工具的项目中点菜单栏中的 工具 - 构建 npm 两种引入方式的不同可能导致后续使用时,引用组件的路径不同,请注意区别 1.直接引入components文件夹内,引用地址通常是 ‘./components/‘ 2.npm引入,组件引用地址通常是’./miniprogram_npm/jieyue-ui-com/’ 如何使用 1.在app.js文件中初始化工具类,并且添加两个全局变量 [代码]// app.js App({ onLaunch() { ;(async ()=>{ // 全局注册工具类SkyUtils // 这里默认npm引用,地址为'./components/utils/skyUtils',如果是直接引用组件,地址可能是'./components/utils/skyUtils',后面不再说明 const SkyUtils = await import('./components/utils/skyUtils'); wx.SkyUtils = SkyUtils.default; // 初始化设备与系统数据 wx.SkyUtils.skyInit() // 小程序自动更新方法 wx.SkyUtils.versionUpdate() })() }, globalData: { sky_system:{}, sky_menu:{} }, }) [代码] 2.在app.wxss文件中引入样式文件 [代码]//wxss * _dark.wxss 是适配深色模式的色彩变量 @import '/miniprogram_npm/jieyue-ui-com/skywxss/skycolor.wxss'; @import '/miniprogram_npm/jieyue-ui-com/skywxss/skycolor_dark.wxss'; @import '/miniprogram_npm/jieyue-ui-com/skywxss/skyfontline.wxss'; @import '/miniprogram_npm/jieyue-ui-com/skywxss/skyfont.wxss'; @import '/miniprogram_npm/jieyue-ui-com/skywxss/skyother.wxss'; [代码] 3.page.json中引用组件 [代码]//page.json { "usingComponents": { "sky-text":"/miniprogram_npm/jieyue-ui-com/sky-text/sky-text" } } [代码] 4.页面中使用 [代码] // wxml <sky-text content="文本内容" max-lines="2" fade></sky-text> [代码] 5.其他组件具体使用请参考组件包中的redeme.md 适配深色模式 如果您在开发时,全部使用我们预设好的颜色变量,那么可以自动适配深色模式。 [代码].page{ background-color: var(--bg-l0); } [代码] [代码] <view style="background-color: var(--bg-l0)"></view> <view style="background-color: {{color}}"></view> [代码] [代码] Page({ data: { color: "var(--bg-l0)" } }) [代码]
2024-01-09 - 历时两年打造,完全基于skyline引擎的高性能图表工具【图表管家】小程序上线啦
基于微信最新的skyline引擎 微信最新的skyline渲染引擎提供了优异的性能支持,尤其是在页面部分渲染和长列表处理上,提供了底层支持。 基于echarts深度优化和适配 echarts是主流的图表框架,但是echarts团队的ec-canvas很久没有维护了,而skyline又是新发布的,究竟skyline和echarts能不能完美适配,似乎是一个非常大的疑问。经过我们长期的填坑和测试后,我们基于echarts官方组件完全重写,克服了在处理手势事件和其他需要高度自定义的场景的不足,实现了比较理想的适配 使用Taro+原生混合开发 原生小程序的开发方式和react hooks相比,开发效率低,样板代码多,组件的重渲染机制不够清晰,很多场景还必须使用wx.createSelectorQuery()。 更像是类似于angular的上一代组件框架。但是Taro 3的实现机制决定了是以牺牲性能换取对react的最大支持。因此,我们在非性能部分采用Taro+react hooks开发,在性能要求高的详情页面图表组件、以及表格组件,使用原生开发。基于glass-easel的最新特性,确保长表格的增删改查的高性能,当然,glass-easel仍有许多问题没有解决,我们也期待它的进一步优化跟进。 欢迎体验使用,技术交流 [图片][图片]
2024-02-26 - 如何隐藏微信小程序右上角的胶囊按钮呢?
我有一个需求,需要做到横屏时全屏展示,但是全屏展示时胶囊按钮隐藏不掉(微信开发者工具能够隐藏但是真机不会隐藏),我已经见到过有能横屏状态下不显示胶囊按钮的小程序,求问一下该怎么做
2024-04-07 - 原生微信小程序开发,使用 lottiejs-miniapp 实现 Lottie 动画的播放
在原生微信小程序开发中,使用 lottiejs-miniapp 实现 Lottie 动画的播放。 lottiejs-miniapp 基于 lottie-web ,当前使用的 lottie-web 版本号为: 5.8.1 “动效”微信小程序 演示: [图片] 打开微信开发工具: 我在这里新建了一个代码片段作为演示: (代码片段:https://developers.weixin.qq.com/s/Eo0K1emN7zwj) [图片] 1、在项目目录(我这里是在index目录)执行命令,初始化 npm 项目: npm init 2、安装 lottiejs-miniapp 组件: npm i lottiejs-miniapp 安装完 lottiejs-miniapp 组件后,我们可以发现在 index 目录下多出了一个 node_modules 文件夹,里面将包含 lottiejs-miniapp。 [图片] 3、构建 npm 这是很重要的一步,在微信开发者工具 -- 顶部菜单 -- 工具 中找到“构建 npm”功能,并点击。 [图片] [图片] 开发者工具提示“完成构建”即可。 构建npm完成后,会在项目中多出一个 miniprogram_npm 文件夹,如下: [图片] 4、下一步,我们开始进行动画的调用。 第一,打开 index.wxml 文件,我们需要在页面文件中 预置一个 <canvas> 组件: <canvas id="lottiejs-canvas" canvas-id="lottiejs-canvas" class="lottiejs-canvas" type="2d"></canvas> 其中,id 和 canvas-id 都命名为"lottiejs-canvas"。 [图片] 第二,打开 index.js 文件,先引入 lottiejs-miniapp import * as lottie from 'lottiejs-miniapp' [图片] 第三,在 index.js 文件,onReady() 中使用如下代码调用动画 wx.createSelectorQuery().select('#lottiejs-canvas').fields({node: true, size: true}).exec(res => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; ctx.scale(dpr, dpr); lottie.setup(canvas); lottie.loadAnimation({ loop: true, autoplay: true, //animationData: animationData, path: 'https://www.lottiejs.com/wp-content/uploads/2022/01/83351-taking-the-duggy-out.json', rendererSettings: { context: ctx, }, }); }); 大家主要替换 loadAnimation 中的 path 参数为自己Lottie动画json文件的http地址即可。 [图片] 我们使用了一个测试动效json: https://www.lottiejs.com/wp-content/uploads/2022/01/83351-taking-the-duggy-out.json 大家一定注意执行时要开启 不校验域名的 功能。 [图片] 第四,在 index.wxss 文件,可以对 <canvas> 组件添加样式,也可以在此为Lottie 动效添加背景颜色效果: .lottiejs-canvas{ width: 100%; height: 300px; background-color: rgb(255, 187, 0); } [图片] 到此,我们就可以预览动画效果了: [图片] 代码片段地址:https://developers.weixin.qq.com/s/Eo0K1emN7zwj 代码片段使用注意事项: 1、填写自己的小程序测试appid; 2、执行 npm install 安装依赖; 3、执行构建 npm 功能。
2022-01-11 - wx.miniapp.IAP实现 Apple 支付
使用微信Dount多端平台提供的wx.miniapp.IAP实现 Apple 支付的详细流程及注意事项 前言 微信Dount多端平台最近内测提供了可以将小程序转化为安卓和iOS的能力,想把之前做过的小程序转化为iOS,因为内容含有虚拟物品付费,所以就需要用到[代码]Apple 支付[代码],多端平台提供了wx.miniapp.IAP一整套接口,但是并没有详细地解释,让我一个没有做过iOS开发的前端人员很是苦恼。所以在我经历各种坑之后就有了这篇文章。 准备工作 首先你要有苹果后台账号创建自己的APP 创建完成之后在这个顶部的[代码]商务[代码]完成各种协议和收款账户的填写 [图片] 创建虚拟商品(链接讲的很详细) 创建自己的沙盒测试号必须是没有注册过苹果ID的邮箱 [图片] 退出测试设备的苹果商店账号 完成以上步骤就可以进行开发了 Apple 支付开发流程简述 [图片] 以下是微信Dount提供的接口和我自己的封装 一、添加交易队列观察者 API:[代码]wx.miniapp.IAP.addTransactionObserver[代码] 作用: 添加交易观察者以处理交易状态的更新,包括购买成功或失败等。 输入参数: [代码]ob[代码]:一个包含回调函数的对象,用于处理不同的交易事件。 [代码]updatedTransactions[代码]:处理交易状态更新。 [代码]restoreCompletedTransactionsFailedWithError[代码]:处理恢复购买时出现的错误。 [代码]paymentQueueRestoreCompletedTransactionsFinished[代码]:在恢复购买交易完成时调用。 [代码]shouldAddStorePayment[代码]:询问是否应该添加商店付款。 [代码]paymentQueueDidChangeStorefront[代码]:处理 App Store 店面变化。 [代码]didRevokeEntitlementsForProductIdentifiers[代码]:处理撤销某些产品的权限。 输出参数: 无直接输出参数,通过回调函数处理交易事件。 [代码]function initialize() { const ob = { updatedTransactions: (args) => { console.log('处理交易状态更新,例如购买成功或失败。:', args); args.transactions.forEach(item => handleTransaction(item)); }, restoreCompletedTransactionsFailedWithError: (args) => { console.log('处理恢复购买时出现的错误。:', args); }, paymentQueueRestoreCompletedTransactionsFinished: (args) => { console.log('在恢复购买交易完成时调用。', args); }, shouldAddStorePayment: (args) => { console.log('询问是否应该添加商店付款。', args); }, paymentQueueDidChangeStorefront: (args) => { console.log('处理 App Store 店面变化。', args); }, didRevokeEntitlementsForProductIdentifiers: (args) => { console.log('处理撤销某些产品的权限。', args); }, }; wx.miniapp.IAP.addTransactionObserver(ob); } [代码] 二、请求商品信息 API:[代码]wx.miniapp.IAP.requestSKProducts[代码] 作用: 请求指定商品的详细信息。 输入参数: [代码]productIdentifiers[代码]:商品标识符数组,指定需要请求信息的商品。 [代码]success[代码]:请求成功的回调函数,返回商品信息。 [代码]fail[代码]:请求失败的回调函数,返回错误信息。 输出参数: [代码]invalidProductIdentifiers[代码]:无效的商品标识符数组。 [代码]products[代码]:有效的商品信息数组。 [代码]function requestProduct(index, paySuccess, payFail) { const canMake = canMakePayments(); if (!canMake) { uni.showToast({ title: '没有支付环境', icon: 'none' }); payFail(); return; } gPaySuccess = paySuccess; gPayFail = payFail; wx.miniapp.IAP.requestSKProducts({ productIdentifiers: [],// 这里是用户要购买的商品ID success: (ret) => { console.log(ret.invalidProductIdentifiers, '无效商品标识'); console.log(ret.products, '商品标识'); if (ret.products.length > 0) { startPayment(null, ret.products); } else { startPayment('未查到商品', ret.products); uni.hideLoading(); } }, fail: (error) => { console.error(`获取商品信息失败: ${error}`); gPayFail(); uni.hideLoading(); } }); } [代码] 三、发起支付 API:[代码]wx.miniapp.IAP.addPaymentByProductIdentifiers[代码] 作用: 发起支付请求。 输入参数: [代码]productIdentifier[代码]:商品标识符,指定需要支付的商品。 [代码]success[代码]:支付请求成功的回调函数,返回支付请求的结果。 [代码]fail[代码]:支付请求失败的回调函数,返回错误信息。 输出参数: 无直接输出参数,通过回调函数处理支付请求的结果。 [代码]function startPayment(err, productIdentifier) { if (err) { uni.showToast({ title: err, icon: "none", }); gPayFail(); uni.hideLoading(); return; } wx.miniapp.IAP.addPaymentByProductIdentifiers({ productIdentifier: productIdentifier[0].productIdentifier, success: (args) => { console.log('拉起支付成功', args); uni.hideLoading(); }, fail: (args) => { console.error('拉起支付失败', args); gPayFail(); uni.hideLoading(); } }); } [代码] 四、处理交易 作用: 处理交易状态更新,包括成功、失败、恢复等。 输入参数: [代码]transaction[代码]:交易对象,包含交易状态、交易收据等信息。 输出参数: 无直接输出参数,通过交易状态处理不同的逻辑。 [代码]function handleTransaction(transaction) { if (transaction.transactionState === "SKPaymentTransactionStatePurchased" || transaction.transactionState === "SKPaymentTransactionStateRestored") { console.log(transaction.transactionReceipt, '订单收据'); finishTransaction(null, transaction); } else if (transaction.transactionState === "SKPaymentTransactionStateFailed") { finishTransaction('交易失败', transaction); gPayFail(); } else if (transaction.transactionState === "SKPaymentTransactionStateDeferred") { finishTransaction('等待外部操作', transaction); } } [代码] 五、结束交易 API:[代码]wx.miniapp.IAP.finishTransaction[代码] 作用: 完成交易,通知系统交易已经处理完毕。 输入参数: [代码]transactionIdentifier[代码]:交易标识符,指定需要结束的交易。 [代码]success[代码]:结束交易成功的回调函数,返回结果。 [代码]fail[代码]:结束交易失败的回调函数,返回错误信息。 输出参数: 无直接输出参数,通过回调函数处理结束交易的结果。 [代码]function finishTransaction(err, transaction) { if (err) { uni.showToast({ title: err, icon: "none", }); return; } wx.miniapp.IAP.finishTransaction({ transactionIdentifier: transaction.transactionIdentifier, success: (args) => { console.log('完成交易 success', args); gPaySuccess(); }, fail: (args) => { console.error('完成交易 fail', args); gPayFail(); } }); } [代码] 六、检查支付环境 API:[代码]wx.miniapp.IAP.canMakePayments[代码] 作用: 检查当前设备是否支持支付功能。 输入参数: 无 输出参数: 返回值:布尔值,表示设备是否支持支付功能。 [代码]function canMakePayments() { const canMake = wx.miniapp.IAP.canMakePayments(); console.log(canMake, "检查是否可以发起支付"); return canMake; } [代码] 七、获取交易列表 API:[代码]wx.miniapp.IAP.getTransactions[代码] 作用: 获取当前未完成的交易列表。 输入参数: [代码]success[代码]:获取交易列表成功的回调函数,返回交易列表。 [代码]fail[代码]:获取交易列表失败的回调函数,返回错误信息。 输出参数: [代码]transactions[代码]:未完成的交易列表。 [代码]function getTransactions(callback) { wx.miniapp.IAP.getTransactions({ success: (transactions) => { console.log('当前交易列表', transactions); callback(null, transactions); }, fail: (error) => { console.error('获取交易列表失败', error); callback('获取交易列表失败'); } }); } [代码] 八、恢复已完成的交易 API:[代码]wx.miniapp.IAP.restoreCompletedTransactions[代码] 作用: 恢复已完成的交易。 输入参数: [代码]success[代码]:恢复交易成功的回调函数,返回交易列表。 [代码]fail[代码]:恢复交易失败的回调函数,返回错误信息。 输出参数: [代码]transactions[代码]:恢复的交易列表。 [代码]function restoreTransactions(callback) { wx.miniapp.IAP.restoreCompletedTransactions({ success: (transactions) => { console.log('恢复的交易', transactions); callback(null, transactions); }, fail: (error) => { console.error('恢复交易失败', error); callback(error); } }); } [代码] 九、获取交易收据 URL API:[代码]wx.miniapp.IAP.getAppStoreReceiptURL[代码] 作用: 获取交易收据的 URL。 输入参数: [代码]success[代码]:获取收据 URL 成功的回调函数,返回收据 URL。 [代码]fail[代码]:获取收据 URL 失败的回调函数,返回错误信息。 输出参数: [代码]url[代码]:交易收据的 URL。 [代码]function getReceiptURL() { wx.miniapp.IAP.getAppStore ReceiptURL({ success: (url) => { console.log('交易收据 URL', url); getReceiptData(null, url); }, fail: (error) => { console.error('获取收据 URL 失败', error); getReceiptData('获取收据 URL 失败'); } }); } [代码] 十、获取交易收据数据 API:[代码]wx.miniapp.IAP.getAppStoreReceiptData[代码] 作用: 获取交易收据数据。 输入参数: [代码]success[代码]:获取收据数据成功的回调函数,返回收据数据。 [代码]fail[代码]:获取收据数据失败的回调函数,返回错误信息。 输出参数: [代码]data[代码]:交易收据数据。 [代码]function getReceiptData(err) { if (err) { uni.showToast({ title: err, icon: "none", }); return; } wx.miniapp.IAP.getAppStoreReceiptData({ success: (data) => { console.log('交易收据数据', data); // 调用后端接口传送交易数据 }, fail: (error) => { console.error('获取收据数据失败', error); } }); } [代码] 十一、刷新收据 API:[代码]wx.miniapp.IAP.requestSKReceiptRefreshRequest[代码] 作用: 发起请求刷新收据。 输入参数: [代码]success[代码]:刷新收据成功的回调函数,返回结果。 [代码]fail[代码]:刷新收据失败的回调函数,返回错误信息。 输出参数: [代码]args[代码]:刷新收据的结果。 [代码]function refreshReceipt(err, callback) { if (err) { uni.showToast({ title: err, icon: "none", }); return; } wx.miniapp.IAP.requestSKReceiptRefreshRequest({ success: (args) => { console.log('刷新收据成功', args); callback(null, args); }, fail: (error) => { console.error('刷新收据失败', error); callback(error); } }); } [代码] 十二、获取 App Store 信息 API:[代码]wx.miniapp.IAP.getStorefront[代码] 作用: 获取当前 App Store 店面的信息。 输入参数: [代码]success[代码]:获取店面信息成功的回调函数,返回店面信息。 [代码]fail[代码]:获取店面信息失败的回调函数,返回错误信息。 输出参数: [代码]info[代码]:App Store 店面信息。 [代码]function getStorefront(callback) { wx.miniapp.IAP.getStorefront({ success: (info) => { console.log('App Store 信息', info); callback(null, info); }, fail: (error) => { console.error('获取 App Store 信息失败', error); callback(error); } }); } [代码] 十三、移除交易观察者 API:[代码]wx.miniapp.IAP.removeTransactionObserver[代码] 作用: 移除交易观察者。 输入参数: [代码]success[代码]:移除交易观察者成功的回调函数,返回结果。 [代码]fail[代码]:移除交易观察者失败的回调函数,返回错误信息。 输出参数: [代码]args[代码]:移除交易观察者的结果。 [代码]function removeObserver() { wx.miniapp.IAP.removeTransactionObserver(ob); } [代码] 完整流程及注意事项 初始化:在应用启动时调用 [代码]initialize[代码] 函数添加交易队列观察者,以便处理交易状态的更新。 请求商品信息:用户选择商品后,调用 [代码]requestProduct[代码] 函数请求商品信息。首先检查设备是否支持支付([代码]canMakePayments[代码]),然后通过 [代码]wx.miniapp.IAP.requestSKProducts[代码] 请求商品信息。 发起支付:在成功获取商品信息后,调用 [代码]startPayment[代码] 函数发起支付请求,通过 [代码]wx.miniapp.IAP.addPaymentByProductIdentifiers[代码] 实现。 处理交易:交易状态更新会触发观察者的 [代码]updatedTransactions[代码] 回调函数,调用 [代码]handleTransaction[代码] 函数处理不同的交易状态。 结束交易:在处理完交易后,调用 [代码]finishTransaction[代码] 函数结束交易,通知系统交易已经处理完毕。 恢复交易:提供恢复已完成交易的功能,方便用户在重新安装应用或更换设备后恢复已购买的内容。 获取交易收据:在交易完成后,可以通过 [代码]getReceiptURL[代码] 和 [代码]getReceiptData[代码] 获取交易收据,并发送到后端进行验证。 注意事项 检查支付环境:确保设备可以进行支付([代码]canMakePayments[代码]),如果设备不支持支付,应提示用户并终止支付流程。 错误处理:每一步操作都需要进行错误处理,并且在错误发生时应向用户展示友好的提示信息。 安全性:获取订单收据后应及时发送到后端进行验证,确保支付的真实性。 交易状态处理:对于不同的交易状态,需要分别处理,确保在用户支付成功后能够正确提供商品或服务。 恢复交易:提供恢复已完成交易的功能,方便用户在重新安装应用或更换设备后恢复已购买的内容。 用户体验:在每一步操作(如请求商品信息、发起支付等)时应显示加载提示,确保用户了解当前正在进行的操作。 通过上述步骤和注意事项,可以完成苹果支付的流程,并确保在支付过程中为用户提供良好的体验。
2024-07-25 - 一个微信小程序是否支持多个公司主体的收款业务?
有两个不同的公司主体,我想做一个聚合小程序同时经营两个主体的业务,用两个商户号,这种可以吗?
2024-07-02 - AI智能体应用发布篇(公众号/小程序)
前言 上一篇《教你 3 分钟搭建 AI 助手(无需编码)》让大家快速搭建了微信云开发的AI智能体Web版和H5版。 如果想在微信生态中快速获取用户,那么公众号和小程序是必须要做的载体,所以这篇主要分享以下 3 点: AI智能体发布到公众号 小程序中集成AI智能体 AI深度合成类目如何申请 步骤 AI智能体发布到公众号 进入云模板 首先我们先进入云模板控制台,在这里再教大家一种快速进入云模版的方式,除了在《教你 3 分钟搭建 AI 助手(无需编码)》中提到的云开发控制台进入的方式之外。 还可以直接在微信开发者工具的代码目录区域右键呼出菜单然后选择「通过云模板或AI配置页面」菜单项。 注:非正式AppId,小游戏,游客态、代开发小程序,不是小程序开发者,看不到该菜单项。 [图片] 授权公众号 从「我的应用」列表进入「AI智能体应用」详情页点击「添加至多个平台」 [图片] 可以选择一个AI智能体进行「配置」支持 3 个平台 微信小程序客服 微信公众号(服务号) 微信公众号(订阅号) [图片] 配置方式非常方便只需要填写AppId即可 [图片] 前往微信公众平台“设置与开发” - “基本配置” - “公众号开发信息”,复制”开发者ID(AppID)”信息 [图片] 获取到AppID填写后点击「下一步」扫码授权即可,授权成功后 未授权 会变为 已授权 状态 [图片] 接下来来看下效果: [图片] 没有认证的公众号需要回复“继续”,已认证公众号无需回复“继续”可直接输出文案 小程序中集成AI智能体 集成应用 回到「添加至多个平台」面板选择「添加至小程序」 [图片] 根据操作指南下载好代码包解压 [图片] 复制到 miniprogram/ 目录下 [图片] 再将下载的 project.config.json 进行替换 [图片] 当以上两步都完成之后可以以下两种方式进行跳转: JS跳转代码 [代码]wx.navigateTo({url: "/$weda_root/packages/mIOXHS1t/pages/chat/index"}); [代码] WXML布局代码 [代码]<navigator url="/$weda_root/packages/mIOXHS1t/pages/chat/index">跳转至智能体</navigator> [代码] 在这里就相当于把整个AI智能体应用集成到小程序中了 [图片] 在这里需要注意,如果要发布小程序上线还需要在小程序管理后台更新域名配置 [图片] 集成API 如果想自定义界面,可以直接集成API即可 回到「AI智能体应用」详情页面切换到「接口展示」 [图片] 可直接复制代码在小程序端进行调用,以查看AI智能体列表接口为例 [代码] wx.cloud.callFunction({ name: 'cloudbase_module', data: { name: 'ai_bot_get_bot_list', data: { filter: { where: { }, }, select: { $master: true, // 常见的配置,返回主表字段 }, }, }, success: (res) => { console.log(res) }, }); [代码] [图片] 每个接口除了有示例代码,还有详细的参数说明: [图片] [图片] AI深度合成类目如何申请 想要上线AI相关的小程序,必须申请深度合成类目,所以这一步至关重要,回到「AI智能体应用」详情页切换到「AI算法备案资料」- 「获取AI算法合作协议」输入小程序主题即可 [图片] 截图证明,在这里需要注意截图一定要露出云模板字样,这样便于类目审核人员区分截图证明来源 [图片] 然后到小程序管理后台添加类目选择【深度合成 - AI问答】选择 2.2 使用第三方技术,上传截图证明即可 [图片] 通过以上方式类目已审核通过 [图片] 总结 本篇主要讲解了AI智能体应用的多平台发布,整体而言从创建到发布非常方便,不管你是公众号运营者还是小程序开发都可以拥有自己AI智能体应用,赶紧去试试吧~
2024-06-28 - 微信小程序 Editor组件在无内容的情况下,长按无法粘贴,会自动收起键盘,但没有失焦
微信小程序 Editor组件在无内容的情况下,长按无法粘贴,会自动收起键盘,但没有失焦
2023-07-11 - 小程序吸顶、网格、瀑布流布局都拿下
来看看新版 scroll-view 带来的新能力吧~ —————— 在之前的文章中,我们知道了新 scroll-view 可以让小程序的长列表做到丝滑滚动~ 也提到了新 scroll-view 提供了很多新能力 sticky、网格布局、瀑布流布局等,这一篇,我们就来看看这些新能力是怎么使用的~ 新 scroll-view 在原来列表模式(type="list")的基础上,新增了自定义模式(type="custom") 在自定义模式下,新增了以下新组件供开发者调用: list-view:列表布局容器sticky-section / sticky-header:吸顶布局容器grid-view:网格布局容器,可实现网格布局、瀑布流布局等 sticky布局sticky 布局即在应用中常见的吸顶布局,与 CSS 中的 position: sticky 实现的效果一致,当组件在屏幕范围内时,会按照正常的布局排列,当组件滚出屏幕范围时,始终会固定在屏幕顶部。 常见的使用场景有:通讯录、账单列表、菜单列表等等。 与 position: sticky 不同的是,position: sticky 很难实现列表滚动需要的交错吸顶效果,而 sticky 组件则可以帮忙开发者轻松实现交错吸顶的效果。 sticky 的使用非常简单: 将 scroll-view 切换到 custom 模式采用 sticky-section 作为 scroll-view 的子元素sticky-header 放置吸顶内容list-view 放置列表内容<scroll-view type="custom"> <sticky-section wx:for="{{list}}"> <sticky-header> <view>{{item.name}}</view> </sticky-header> <list-view> <view>...</view> </list-view> </sticky-section> </scroll-view> 我们来看下采用 sticky 布局做出来的通讯录效果~ [视频] sticky 布局也可以通过给 sticky-section 配置 push-pinned-header 来声明吸顶元素重叠时是否继续上推 像下图输入框和标签列表这种类型,标签列表吸顶时还是希望保留输入框吸顶。 [视频] 网格布局网格布局即将列表切割成格子,每一行的高度固定,常见的视频列表、照片列表等通常都采用网格布局。 在此之前,实现网格布局需要开发者自行实现网格切割,再嵌入到 scroll-view 中。 新 scroll-view 直接提供了 grid-view 组件供开发者使用~ 将 scroll-view 切换到 custom 模式采用 grid-view 类型为 aligned 做为直接子节点grid-view 中直接编写列表<scroll-view type="custom"> <grid-view type="aligned" cross-axis-count="3"> <view wx:for="{{list}}"> <image src="{{item.image_url}}" mode="aspectFit"></image> <view>...</view> </view> </grid-view> </scroll-view> 下面是使用网格布局实现的图片列表效果~ [视频] 瀑布流布局瀑布流布局与网格布局类似,不同的是瀑布流布局中每个格子的高度都可以是不一致的,所以在小程序中实现瀑布流布局就比较复杂了。 开发者需要通过计算格子高度,然后再进行瀑布流拼接,当滚动内容过多时还需要处理节点过多导致内存不足等问题。 grid-view 组件直接支持了瀑布流模式供开发者直接使用,grid-view 组件会根据子元素高度自动布局: 将 scroll-view 切换到 custom 模式采用 grid-view 类型为 masonry 做为直接子节点grid-view 中直接编写列表<scroll-view type="custom"> <grid-view type="masonry" cross-axis-count="2"> <view wx:for="{{list}}"> <image src="{{item.image_url}}" mode="widthFix"></image> <view>...</view> </view> </grid-view> </scroll-view> 下面是使用瀑布流布局实现的图片列表效果~ [视频] 想要立即体验?现在通过微信开发者工具导入 代码片段,即可体验新版 scroll-view 组件能力~
2023-10-20 - 小程序使用scss,设置 page 全局自定义属性,在组件 wxss 中无效?
小程序使用scss, 使用 第三方的UI 组件(vant-weapp)。 想要通过变量去修改wxss样式如 下, height: var(--tabbar-height,50px); 然后在 app.scss 中设置 全局变量 page { --tabbar-height: 88px; } 样式没有生效。--tabbar-height 没有值,还是取得 50px。
2023-08-18 - 小程序压测怎么做?快来试试 Donut X 小程序云测的解决方案吧
需求场景 很多小程序都会进行一些运营活动,这时候会有很多用户同时进来访问。当用户量比较大时,业务方可能会担心压力过大导致小程序功能异常。 这时开发同学/测试同学希望能够对小程序进行一次压力测试,比如可以模拟1000个用户同时打开小程序场景。 自然而然的,一个常见想法是直接用1000个手机,同时打开小程序,模拟用户并发情况。但是用纯UI自动化方式去压测,会遇到以下几个问题: 由于每台手机的性能不一,控制多台手机都是在同一个时刻去打开小程序基本无法做到 用1000台手机去做端到端的压测成本高。而且由于手机数量是有限的,压力瓶颈有明显上限 解决方案 微信推出的 Donut 微信安全网关 支持小程序压测能力,可以生成微信code,实现真实用户请求业务接口以及微信开放接口的全链路压测 微信安全网关的压测工具主要关注服务端请求响应情况,包括请求的正常响应、请求耗时等内容;在小程序 UI 相关的表现感知不强 这里可以将 小程序云测 的自动化/性能测试能力和微信安全网关结合起来。首先利用微信安全网关对后台服务器进行发压,例如并发用户量调整为1000。压力上来后,在云测正常执行对应的测试任务。这样相当于用几台手机模拟测试了“1000台手机同时打开小程序”的场景。 通过结合云测的自动化测试和性能测试能力(比如收集到的截图,录屏,体验评分,启动性能 ,网络请求,是否存在黑白屏JSError等异常情况),用户可以全面观察小程序在压力情况下的实际UI表现 实践案例 需求:小程序A希望对首页内容进行压测,观察100个用户同时打开首页的表现是否有异常。可以按照以下步骤进行: 1. Donut发压 用户在 Donut 平台创建安全网关后,前往「压测工具」页面(压测工具在内测期间时,需要联系技术支持开通),创建压测任务并调试后台压测请求链路,例如小程序A希望对首页进行压测,请求的链路为: 首先获取用户code 用微信code访问服务器后台,识别用户 识别用户身份后,生成首页内容数据 [图片] 调试成功后,可以按照实际需求去预约压测任务,如配置并发用户数为100,压测时长为30分钟 2. 云测跑测质检任务 压测任务启动后,建议立即启动云测任务(由于云测需要对真机进行初始化操作,一般需要2-5分钟后才会真正拉起小程序)。 执行云测的任务类型推荐使用小程序质检能力,因为质检会同时发起启动性能测试和自动化测试任务: 启动性能测试:观察压力情况下,小程序启动耗时是否会变慢 自动化测试:自动化任务可以根据业务实际需求,使用Monkey、录制回放、Minium的一种。主要目的是观察压力情况下,小程序是否会出现功能异常或性能问题(如Monkey测试可以检测JSError,黑白屏等异常) 这里小程序A使用Monkey作为自动化测试方案。一般情况下,首页内容是正常加载的,但是当服务器压力较大,网络返回较慢时,小程序A出现了JSError问题 [图片] 后续规划 目前用户需要手动在Donut和云测端分别操作去启动任务。 后续云测和Donut结合起来,可以让用户在Donut中,执行压测任务时可以选择同时启动云测任务,带来更好地压测体验。 关于压测有任何问题,欢迎在帮助页面,加入官方企微群,和云测小助手一起探讨
2024-05-30 - 微信小程序如何将doc, xls, ppt, pdf, docx, xlsx, pptx文件保存到本地
大家好,我是兔兔,兔兔答题开发者。 最近在做兔兔答题时,涉及到将文件保存到微信本地,这里的本地是指微信文件助手或者微信好友,是直接分享文件而不是做微信分享好友的形式。 在微信开放社区中,也有不少关于该话题的帖子。大家感兴趣的也可以去搜索一下。 [图片] 对于第一次做微信小程序,或者是没去了解过这块的,刚开始不知道如何着手,也不知道如何实现。当你发现其实是非常简单的,就几行代码就敲定了。在这里就不像其他的文章,还单独分享一下各种API,我就直接贴正确代码。需要注意的是,我这里使用的是uniapp开发,如果你是微信原生小程序开发,你直接使用微信原生的语法调用这两个函数即可。 [代码]let _that = this uni.downloadFile({ url: _that.url, success: function(res) { uni.openDocument({ filePath: res.tempFilePath, showMenu: _that.is_download == 1 ? true : false, success: function(res) { uni.previewImage({ urls: ['https://imgcdn.tutudati.com/20231001004615552606228.png'], }) } }) }, fail(res) { _that.$func.showToast(res.errMsg) } }) [代码] 需要注意的是$func.showToast()函数是我自己封装的组件。 通过上述代码,其实也不难看出来,就只调用了两个uniapp的函数就实现了功能。 第一个方法是[代码]uni.downloadFile()[代码],这个函数是将远程文件下载到本地,你会获取到一个临时文件地址[代码]tempFilePath[代码]。 第二个方法是[代码]uni.openDocument()[代码],这个函数是打开本地临时文件地址,这里的临时文件地址就是第一步中获取到的[代码]tempFilePath[代码],例如PDF文件,会直接进行预览显示。 关于第二个方法中,我添加了一个[代码]showMenu[代码]的配置项,这是一个非常重要的地方。如果你设置为false,当文件进行预览时,右上角是不会显示功能菜单,也就是说你没法把文件进行保存到本地。当你开启时,将是如下效果。 [图片] 右上角有三个点,当你点击三个点就会弹窗转发好友的选项,你直接点击转发好友就可以保存到文件助手或者你的微信好友了。 注意事项 这个功能看起来,体验性就不是很强。但也是目前为止,能够解决的方案。在使用该方式保存文件,你需要注意如下几个地方: 1、在微信小程管理后台,文件的域名要和文件下载域名保持一致,否则在调用[代码]uni.downloadFile()[代码]函数时就会提示,下载域名不是合法的域名。 2、在调用[代码]uni.openDocument()[代码]函数时,filePath一定是小程序内本地文件地址,你也可以通过其他的函数下载文件来获取本地文件地址,也可以使用文章中的这个函数。 3、打开的文件也是有限制的,目前根据uniapp官方文档来看,只支持doc, xls, ppt, pdf, docx, xlsx, pptx这几种文档类型。查看了一下微信小程序的官方文档,也是支持这几种格式。对于不在这几种格式的范围内,可能就需要通过其他的方式实现。例如通过文件链接,让用户打开浏览器预览;还有是直接通过webview来实现。 关于微信小程序如何将文件保存到本地的解决方案就算完成啦,希望这篇文章的分享对你有所帮助。
2024-05-19 - 长列表:按需渲染vs回收创建
在 Skyline 支持了长列表按需渲染之后,还是有很多开发者对于按需渲染表示疑惑: 开发者A:scroll-view 下拉不太流畅 开发者B:list-view 有什么作用呢? 开发者C:关于长列表的按需渲染功能,我们如何能检测到这个功能正确触发了呢? 关于以上几个问题,我们一一来解答: Q:scroll-view 下拉不太流畅? 当发现 scrll-view 下拉不够流畅时,可能是用法不对导致的不流畅。 根据 type 不同,按需渲染的用法也不同,建议按以下方式检查一下 type="list" : 根据直接子节点是否在屏来按需渲染type="custom" : 只渲染在屏节点,对于列表、网格、瀑布流等,子节点必须包裹在 list-view、grid-view 内部才会按需渲染。 Q:list-view 有什么作用呢? 对于 list-view、grid-view 等 *-view 组件,符合规定的写法则会按需渲染。 默认情况下,视口外节点不渲染。也可以根据业务需要,设置 scroll-view 的 cache-extent 指定视口外渲染区域的距离来优化滚动体验和加载速度。 [图片] 当然 cache-extent 越大也会提高内存占用且影响首屏速度,建议大家按需启用。 Q:关于长列表的按需渲染功能,我们如何能检测到这个功能正确触发了呢? 当使用按需渲染时,例如下面用的 type="list",其实直接子节点都是一开始就创建的,所以没有办法从开发者工具检查到这个功能正常触发。 [图片] 不过可以在真机上开启 “开发调试 - Debug Skyline - checkerboardRasterCacheImages” 调试 [图片] 当滚动 view 离开屏幕回来之后颜色变了,说明节点重新渲染了,以此来确认按需渲染功能正确触发 👇例如下图中第一个节点,一开始是紫色,当离开屏幕重新滚动回屏幕时,变成了黄色,证明按需渲染成功~ 注意:不是所有的组件都会形成 RasterCache,需要结构复杂一些才会; [图片] *-builder 组件 除了 *-view 组件,很多开发者应该也注意到了 *-builder 组件 list-view 对应 list-buildergrid-view 对应 grid-builder看文档描述的能力是一样的,但是为什么会分成两个组件呢? 因为目前 *-view 组件是按需渲染,节点还是会不断的创建,当长列表越来越长时,内存占用会越来越多。 于是我们新增了 *-builder 组件来支持 scroll-view 的可回收,可以更大程度降低创建节点的开销。 我们来看下效果,可以从开发者工具的 wxml 看到,当列表滚动时,list-builder 中渲染的 view 节点只有在屏的几个 [图片] 除了使用 wxml 板块查看之外,*-builder 组件还提供了监听事件,开发者可以监听列表创建和回收 binditembuild:列表项创建时触发,event.detail = {index},index 即被创建的列表项序号binditemdispose:列表项回收时触发,event.detail = {index},index 即被回收的列表项序号 使用场景既然 *-builder 组件拥有回收+创建能力,是不是可以不用 *-view 组件啦? 当然不是啦~~~ 回收+创建能力本身就是有开销的,所以也要根据业务场景按需使用哦 *-builder:对于长列表、无限滚动列表等,或者节点内存占用高的,每个时刻都确保不会有太多节点创建出来,使用 *-builder 可以节省内存*-view:对于短列表,或者内存占用不高的列表则比较适合使用 *-view 代码片段:https://developers.weixin.qq.com/s/rp07iKmW7UQS
2024-05-16 - 利用 CSS 解决 slot 显示默认值
起因 众所周知,小程序至今还未支持 slot 显示默认值(五年啦),但是业务中这个需求还是挺普遍的,故此分享下我是怎么实现该需求的。 构建一个场景 有一个列表单项组件,当名为 icon 的 slot 有内容传入时,显示该 slot,否则显示默认的 icon。 示例代码 需要将组件的 js 中的 multipleSlots 设置为 true。 [代码]<!-- ListItem.wxml --> <view class="list-item"> <view class="list-item__content"> <view class="list-item__left"> <view class="list-item__left-icon--slot"> <slot name="icon"></slot> </view> <view class="list-item__left-icon"></view> <view>{{title}}</view> </view> <view class="list-item__right"> <slot></slot> </view> </view> </view> [代码] [代码]// ListItem.scss .list-item { .list-item__content { .list-item__left { &-icon--slot { margin-right: 12rpx; &:empty { display: none; } &:not(:empty) + .list-item__left-icon { display: none; } } &-icon { width: 40rpx; height: 40rpx; margin-right: 12rpx; color: var(--color-text-disabled); background-color: currentColor; -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: 100%; -webkit-mask-image: url('xxx.png') } } } } [代码] 总结 实现的原理解释: 当 slot 没有内容时,利用了 [代码]:empty[代码] 伪类选择器,隐藏 [代码].list-item__left-icon--slot[代码] 的元素。 当 slot 有内容时,利用了 [代码]:not[代码] 以及 [代码]+[代码] 选择器,使与 [代码].list-item__left-icon--slot[代码] 紧邻且在其之后的 [代码].list-item__left-icon[代码] 的元素隐藏 不过需要注意的一点是,官方文档中 wxss 支持的选择器很有限,但是实测是大部分支持的,目前个人尝试已知不可用的选择器有 [代码]*[代码]、[代码]~[代码] 以及属性选择器(还有一些复杂情况可能也不支持,需要大家自己尝试)。 希望这次分享对大家有一定的帮助吧。 参考 微信小程序开发文档(小程序框架 /视图层 /WXSS)
2024-04-02 - 自定义组件中scroll-view的scroll-into-view属性在子节点是slot时不生效
https://developers.weixin.qq.com/miniprogram/dev/component/scroll-view.html
2022-12-22 - scroll-view 中使用slot插槽 子节点设置id scroll-into-view无效?
scroll-view 中使用slot插槽插入内容 子节点设置id scroll-into-view无效 [图片][图片] [图片]
2023-06-19 - 微信小程序如何在分包中使用npm库
因为tencentcloud-webar这个库比较大,我就在分包中使用了,但是从分包中的miniprogram_npm目录去引入,无法使用,目录结构如下: [图片]
2023-05-17 - 隐私授权手机号授权提示invoke getPhoneNumber too frequently?
手机号快捷登录页面,增加隐私授权的监听,隐私授权弹窗按钮 点击拒绝后,再次点击手机号快捷登录提示“invoke getPhoneNumber too frequently”过于频繁地调用getPhoneNumber,问一下有遇到过这种情况么? [图片][图片]
2023-08-29 - 云开发环境共享的小程序,不能给用户发送订阅消息吗?
本人有两个小程序,小程序A是主程序,有自己的云开发数据库资源。小程序B没有单独申请云开发资源,只是使用了小程序A共享的环境资源。 在小程序A可以订阅消息,可以接收消息。 小程序B可以订阅消息,但是不能接收订阅消息。 错误提示: errCode: 40003 errMsg: "openapi.subscribeMessage.send:fail invalid openid rid: 656455ac-6cce5320-04c66132" 意思是说:openid错误。 小程序B调用小程序A的云函数获取openid: const wxContext = cloud.getWXContext(); let openid = wxContext.FROM_OPENID;//环境共享的小程序B获取openid的方法 本人测试过,小程序B使用 wxContext.OPENID 是获取不到openid的,只能通过 wxContext.FROM_OPENID 获取。 请问大神,是我哪里写错了?还是云开发环境共享的小程序B,不能给用户发送订阅消息?
2023-11-27 - 省钱有道之 云开发环境共享小结
#前言 最近为了节省一点小程序的运营成本,一些没啥流量的小程序如果每个月也要19块略微有些肉疼(主要还是穷),研究了一下云环境共享,在这里简单做一下总结。 [图片] 这里有官方的小程序环境共享文档需提前了解一下,具体共享步骤按官方文档操作即可。 https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/resource-sharing/introduce.html #注意点 共享环境有几个注意点大致如下: 1、必须是相同主体 2、开通了云开发环境的小程序可以共享给同主体的小程序、公众号,被共享方无需开通云开发环境 3、一个云开发环境最多可以共享给10个小程序/公众号 4、共享后双发均可主动解除 5、按官方文档要求,资源方需有云函数cloudbase_auth,测试时发现没有这个云函数其实也能正常运行,可能我验证的场景还不够多 6、云能力初始化的方式不同,资源方按传统的云环境初始化方式即可,也就是 wx.cloud.init({ env: env.activeEnv, traceUser: true }); 而调用方的初始化方式有所不同 const cloud = new wx.cloud.Cloud({ //资源方AppID resourceAppid, //资源方环境ID resourceEnv, }) // 跨账号调用,必须等待 init 完成 // init 过程中,资源方小程序对应环境下的 cloudbase_auth 函数会被调用,并需返回协议字段(见下)来确认允许访问、并可自定义安全规则 const initRes = await cloud.init(); 后续调用资源方的云函数就用这个cloud就行了:cloud.callFunction({...}); 7、调用方有操作到云存储文件的api也需要用6步骤中的cloud 8、云存储fileId需要用cloud.getTempFileURL转换成临时/永久链接,否则在调用方无法展示 9、一些api的云调用方式也有变化,需指明具体的appid。比如A小程序授权给了B小程序,想给B小程序推送客服消息需要写成 await cloud.openapi({appid:B小程序appid}).customerServiceMessage.send({...}); 10、获取调用方的appid/openid/unionid也有所不同 // 跨账号调用时,由此拿到来源方小程序/公众号 AppID console.log(wxContext.FROM_APPID) // 跨账号调用时,由此拿到来源方小程序/公众号的用户 OpenID console.log(wxContext.FROM_OPENID) // 跨账号调用、且满足 unionid 获取条件时,由此拿到同主体下的用户 UnionID console.log(wxContext.FROM_UNIONID) #适配 基于以上注意点,开始进行适配,由于我是一套代码部署N个小程序,然后一个云环境共享给其他小程序,希望通过配置决定哪个小程序作为资源方,哪些作为调用方 首先是云开发环境的初始化: 1、env.js 环境配置: //云开发环境 const cloudBase = { //使用共享云环境资源,资源方=false,调用方=true useShareResource: false, //资源方AppID resourceAppid: "wx9d2xxxxxxxx0088", //资源方环境ID resourceEnv: "prod-9gxqvi3qb3c257ef", //云环境ID prod: "prod-9gxqvi3qb3c257ef" } 2、api.js 操作模块 const env = require('../env.js'); let cloud; /** * 初始化云能力 * @returns {Promise} */ const wxCloudInit = async function () { const {cloudBase} = env; if (!wx.cloud) { console.error('请使用 2.2.3 或以上的基础库以使用云能力') } else if (cloudBase.useShareResource) { const {resourceAppid, resourceEnv} = cloudBase; // 声明新的 cloud 实例 cloud = new wx.cloud.Cloud({ //资源方AppID resourceAppid, //资源方环境ID resourceEnv, }) // 跨账号调用,必须等待 init 完成 // init 过程中,资源方小程序对应环境下的 cloudbase_auth 函数会被调用,并需返回协议字段(见下)来确认允许访问、并可自定义安全规则 const initRes = await cloud.init(); console.log("初始化云能力完毕:", initRes, "资源方appid:", resourceAppid, "资源方环境ID:", resourceEnv); } else { wx.cloud.init({ env: env.activeEnv, traceUser: true }); console.log("初始化云能力完毕,当前环境:", env.activeEnv); cloud = wx.cloud; } this.cloud = cloud; } /** * 云函数调用 * @param name * @param data * @param success * @param fail * @param complete */ const callCloudFunction = function (name, data, success, fail, complete) { //执行云函数 cloud.callFunction({ // 云函数名称 name: name, // 传给云函数的参数 data: Object.assign({}, data, {env: env.activeEnv}) }).then(res => { typeof success == 'function' && success(res); }).catch(res => { typeof fail == 'function' && fail(res); }).then(res => { typeof complete == 'function' && complete(res); }); }; 3、在app.js中初始化云环境,后续有用到wx.cloud的都需要改成api.cloud const api = require('utils/api.js'); App({ onLaunch: async function (options) { await api.wxCloudInit(); } }); 其次是资源方的获取用户信息调整 每次都要判断wxContext.FROM_OPENID是否为空,不为空则是调用方的用户信息,为空则是资源方的用户信息,略微繁琐,干脆封装了一个npm包wx-server-inherit-sdk,改造了一下getWxContext函数,源码如下,引入这个包后也就可以不用引入官方的wx-server-sdk const cloud = require('wx-server-sdk'); // 保存原始getWXContext方法到另一个变量 const originalGetWXContext = cloud.getWXContext; cloud.getWXContext = function () { //调用原始getWXContext方法 const wxContext = originalGetWXContext.call(this); const {FROM_APPID, FROM_OPENID} = wxContext; //云开发环境共享时获取到的APPID会替换成源方APPID if (FROM_APPID) { Object.assign(wxContext, {APPID: FROM_APPID}); } //云开发环境共享时获取到的OPENID会替换成源方OPENID if (FROM_OPENID) { Object.assign(wxContext, {OPENID: FROM_OPENID}); } return wxContext; } module.exports = cloud; 到此也就大功告成。为了省钱也是够折腾的[哭笑]
2023-08-28 - 个人开发者把小程序发布到 App Store 5个步骤(保姆级教程)
用完即走,小程序已经成为连接用户与服务的重要桥梁,无论是购物、出行还是娱乐,小程序都以其便捷性和高效性赢得了用户的青睐。 那小程序是否有边界,能否拓展到 App? 微信开发者工具的最新升级,为这一问题提供了创新的解答。现在,开发者们可以轻松将已有的小程序代码构建为全功能的 App,而无需从零开始开发,这不仅节省了大量的人力和时间成本,更为小程序开发者们打开了通往 App Store 巨大流量的大门。 在这篇文章中,我们将深入探讨微信开发者工具支持小程序 App 化的五大步骤,分析其潜在价值,并通过实际案例来展示这一过程的效果。 背景:个人开发者,将小程序代码构建为 iOS App,以下为整体流程,适合 iOS 开发 / 上架零基础的同学。 [图片] 缘起 一个周末,突然奇想,我还没有搞过 App,要不搞搞玩玩😄 从 0 开始学还是很慢的,毕竟时间有限,好在了解过提示工程 [代码]《ChatGPT 进阶:提示工程入门 陈颢鹏 李子菡》[代码],问了一下助手 ChatGPT 几个常见的问题。 开发适用于 iPhone 的 App 的流程是什么 注册开发者账号 -> 下载 Xcode -> 学习 Swift -> 设计 + 编码 + 测试 -> TestFlight 内测 -> 准备上架 (准备) -> 提交审核 -> 应用上架 -> 应用更新和维护 很好,请给出可运行的应用实例,完成查询本机 IP 地址 我是一个新手,请问在 XCode 中如何运行你提供的代码 几轮对话后,大约用了 1 个小时,一个 iOS Demo 在模拟器上跑成功了,有点意思😄 不过功能有点简单,几年前用 Vue 写过一个还在跑的网站,同时我知道 ChatGPT 的编程能力,于是我丢了一个问题给他。 [代码]你是一个开发,会 Vue 、iOS 开发(使用 SwiftUI 框架 )两种语言,现在需要你根据 Vue 的代码重写为 iOS 代码,以下是 Vue 代码 [代码] [图片] 笔者是一个运维平台的产品,为了不忘记运维场景和技能,自己维护一个业务场景,开发语言:golang + Vue,部署架构:腾讯云 CLB+TKE+ 服务网格,运营系统:CLS+ 云函数 +Kafka+Doris+Flink+Hadoop+Streamsets。 结果惊奇的发现,真的可以执行,不服不行 👍 [图片] 1. 转折:把小程序代码构建为 iOS App 测试包(1 小时) 如果仅仅只是这样,那么这篇文章标题就该叫“GPT 如何将 Vue 改写为 App”。 万万没想到,过了 2 周从朋友那里获悉 微信开发者工具可以直接将小程序代码构建为 App,就像 Golang 一样,可以通过参数 [代码]GOARCH[代码] 控制构建的程序是在跑在 [代码]amd64[代码],还是 [代码]arm64[代码] CPU 架构上。 [图片] 现实就是这么巧,几年前使用 Vue 开发站点时,同时也开发了同款小程序。 有点意思,参照文档 构建你的第一个应用 花了 1 个小时,在我的 iPhone 上跑了 测试版 的 App. [图片] 此处应该给多端应用的产品和开发点个赞👍🏻 搞到这里,我其实进入了这款的第一个哎哈时刻,确实很爽,因为我不需要花心思用 GPT 来迁移 Vue 程序,直接用微信开发者工具构建为 App 即可,交互完全一致。 另外记录构建过程中遇到的两个问题 问题 1:小程序的图片在 App 中无法渲染 启用 Media SDK 即可 [图片] 问题 2:App 带有 Vconsole 入口 一开始以为在模拟器中才有,最后发现是一个配置,需要自己主动关闭。 [图片] 2. 构建正式包 谁不想在 App Store 能搜到自己的 App 呢,第二步,构建正式包。 2.1 准备苹果开发者账号 在 MacBook Air 或 iPhone 中安装 Apple Developer,然后注册苹果开发者账号 [图片] 一年 688 元会费 [图片] 正常情况下,交完会费后,第二天会收到一封欢迎加入 Apple Developer Program 的邮件,代表苹果开发者账号注册成功。 很遗憾,我注册时提示“未知错误,请再试一次” 找 Apple Developer 客服反馈,最后答复 [代码]由于一个或多个原因,您无法完成 Apple Developer Program 的注册。我们目前无法继续处理您的注册。[代码]。 好吧,估计是被风控命中了,于是找了家人的账号来注册,直接成功😄 2.2 生成 Bundle ID/ 证书 /Profile 生成 App 备案和构建正式包都需要的 Bundle ID/ 证书 /Profile。 生成 Bundle ID Bundle ID 是一个唯一的标识符,用来识别你的应用程序。它通常采用反向域名格式,例如 com.example.myapp。在开发和发布应用程序时,你需要在苹果的开发者账户中注册一个 Bundle ID,这样苹果的服务才能识别出你的应用程序。 参照 文档 生成 Bundle ID。 生成 证书 /Profile 证书(Certificates)用于建立开发者的身份,并确保应用是由已注册的开发者发布的。开发者需要从苹果开发者中心申请证书,用来对应用进行签名,这样 iOS 设备才会信任并运行这个应用程序。 配置文件(Provisioning Profiles)是一个包含证书、应用程序 ID、设备 ID 和其他信息的文件,它告诉 iOS 设备一个应用程序可以被安装和运行。配置文件将应用、开发者和设备联系起来,控制哪些设备可以安装和运行你的应用程序。 参照 文档 生成 iOS 证书和 Provisioning Profile。 [图片] 拓展资料:创建证书签名请求 问题:申请的 iPhone Distribution 证书不受信任 导入 Apple WWDRCA 证书 即可,可能原因:大致是分发的根证书没有导入你的 Mac 上。 更多资料详见 Apple PKI。 [图片] 2.3 备案(10 天 +) App 如果没有备案,在中国大陆将无法上架,这是苹果官方的说明。 中国工业和信息化部(MIIT)要求 App 必须具备有效的互联网信息服务提供者(ICP)备案号,了解更多 [图片] 其实备案比较简单,参照 App 备案 ,使用上一部分申请的 [代码]Bundle ID[代码]、证书(可查看 [代码]公钥[代码]、[代码]签名 MD5 值[代码])即可,不需要把 App 开发完,再来备案。 备案最长需要 20 个工作日,笔者用了 10 个工作日,在一个周五的下午收到了工信部发来的备案通过短信。 2.4 创建移动应用 移动应用是为了让 App 能用上微信的能力(比如分享到朋友圈或发送给朋友、微信登录 / 支付等),在移动应用中同时登记了 Bundle ID 和 Universal Links,这将会传递给下一步的多端框架,这是构建可正式包(采用苹果的分发证书)的必备条件。 先介绍一下 Universal Links。当用户使用 iPhone 手机访问你的网站,同时安装了 App 时,能在网站顶部快速跳转到 App。具体可以看下苹果官方的文档 Supporting associated domains 你需要有一个网站,未来要放 Universal Links 要用到的 [代码]apple-app-site-association[代码] 文件,不过对于我来说,这个功能好像用处不大,我更需要的是当用户用 iPhone 访问网站,引导他去 Apple Store 安装 App. 这里有一个关键信息,如果你不需要微信支付 / 微信登录 / 微信卡券的能力,不需要做开发者认证(开发者认证不能是个人主体) 访问 微信开放平台,创建移动应用,提交审核,几个小时就审核通过了。 [图片] 2.5 绑定多端框架 在 Donut 开发平台 中将 多端应用绑定上一步创建的移动应用,这样可以用到移动应用中登记的 Bundle ID 和 Universal Links,官方这么做比较合理,关键信息必须通过移动应用这关人工审核来起到一定的约束。 [图片] 绑定后,在多端应用中可以看到 Bundle ID 和 Universal Links 了。 [图片] 2.6 准备 App icon 等资料 App Icon 先用工具为你的 App 设计一个 1024px X 1024px 的图标,然后在 App Icon Generator 上生成 iPhone 所有规格的图标,之后在 [代码]project.miniapp.json[代码] 配置。 [图片] 启动图片 App 启动一般需要 2~4 秒,如果没有启动图片是白屏,用户会有点慌,不知道当前 App 是否正在启动,启动图片就是解决这个问题,同时在启动图片中传达 App 的价值主张。 我是直接用 Sketch 设计的,分辨率为 1290px x 2796px,这是兼容性最强的 6.7 寸(iPhone 15 Pro Max/15 Plus/14 Pro Max)手机的分辨率。 考虑到启动图片在不同机型上的兼容性,如果你用 Xcode 开发,苹果官方会推荐使用 Launch Screen Storyboard 隐私信息访问许可描述 小程序虽然没有用到摄像头、麦克风等权限,但多端的 SDK 中有(具体详见 Donut 官方文档 上架应用市场常见问题),所以得提前申明,不然把包通过 [代码]Transporter[代码] 上传后,会收到苹果发出的不合规邮件。 [图片] 以下是根据苹果官方打回的邮件中定义的隐私信息访问许可描述,应该是最基础的了,可以贴到你的 [代码]project.miniapp.json[代码] 文件中(用编辑器打开)。 [代码]{ "privateDescriptions": { "NSBluetoothPeripheralUsageDescription": "为了提供完整的功能,我们的应用程序需要访问蓝牙外设。这将用于与其他设备进行通信和数据交换。我们承诺保护用户隐私和数据安全。", "NSMicrophoneUsageDescription": "为了提供完整的功能,我们的应用程序需要访问麦克风。这将用于录制音频和进行语音交互。我们承诺保护用户隐私和数据安全。", "NSCalendarsUsageDescription": "为了提供完整的功能,我们的应用程序需要访问日历。这将用于提醒和日程管理。我们承诺保护用户隐私和数据安全。", "NSLocationAlwaysAndWhenInUseUsageDescription": "","NSBluetoothAlwaysUsageDescription":" 为了提供完整的功能,我们的应用程序需要始终访问蓝牙外设。这将用于与其他设备进行通信和数据交换。我们承诺保护用户隐私和数据安全。","NSPhotoLibraryUsageDescription":" 为了提供完整的功能,我们的应用程序需要始终访问相册。这将用于 IP 查询时显示 ISP 的图标。我们承诺保护用户隐私和数据安全。","NSCameraUsageDescription":" 为了提供完整的功能,我们的应用程序需要访问摄像头。这将用于录制视频。我们承诺保护用户隐私和数据安全。","NSLocationWhenInUseUsageDescription":" 为了提供完整的功能,我们的应用程序需要在使用时访问位置信息。这将用于提供定位服务和相关功能。我们承诺保护用户隐私和数据安全。" } } [代码] 2.7 构建正式版版本包 参照 打包生成 IPA 生成正式版的版本,注意使用分发证书。 [图片] 报错:file must be in miniprogram project 解决:把 mobileprovision 放在 miniprogram 目录下,因为 profile 不像 App icon 一样会自动上传到 miniprogram/ 目录下。 2.8 使用 Transporter 上传版本 参照 官方文档 上传正式版的 APK 包。 [图片] 遇到问题: Transporter,无法为 App “comxxxx.ipa” 创建临时 .itmsp 软件包。No suitable application records were found. Verify your bundle identifier ‘com.xxxx’ is correct and that you are signed into Xcode with an Apple ID that has access to the app in App Store Connect. [图片] 解决办法:去 App Store Connect 添加 App,绑定 [代码]Bundle id[代码],这样 Transporter 可以验证包在 App Store Connect 中已注册。 3. 使用 TestFlight 测试 在 App Store Connect 的 TestFlight 页面,可以选择内部、外部测试,外部测试版本需要 Apple 官方审核,把 公开链接发给朋友即可。 [图片] 在测试的同时,可以同步准备上架 App Store 的资料了。 4. 准备上架 Apple Store 审核资料 截屏 截屏是用来在 App Store 中显示你的 App 产品介绍页的,具体参照 截屏规范 [图片] 有 [代码]iPhone 15 Plus[代码] 和 [代码]iPhone 8 Plus[代码] 这两款机型就足够了,其他型号的手机能复用,分辨率应该是等比率缩放。 如果你像我一样,没有这两款手机,那用 iOS 模拟器。 Xcode -> 工具栏 Windows -> Devices and Simulators -> Create a new simulator -> Download more simulator runtimes [图片] 在微信开发者工具中运行这两款模拟器,利用模拟器自带截屏工具即可。 隐私政策 找一下常见 App 的隐私政策,在其产品介绍页中可以跳转过去。 如果你有网站就放在网站上,如果没有可以放在腾讯文档上。 [图片] 选择 App 供应的地区范围 哪些地区的用户可以下载你的 App。 [图片] 提交审核 一切准备好了后(包含备案),开启提交审核。 下午 5:35 提交审核,第二天早上 3:40 上架成功。✌🏻 [图片] 5. App Store 的数据 上架后刚好一周,看看最近一周的数据,还不错。 [图片] 这是评分数据 [图片] 6. 引流 二维码引流:草料二维码 通过草料二维码生成 App 的下载链接,放在网站上,引导用户跳转至 App。 Universal Links 参照 Apple 官网文章 Supporting associated domains 准备 Universal Links。 前面已经介绍了这个东东是干嘛的。 准备 [代码]apple-app-site-association[代码] 文件,放在网站的 [代码].well-known[代码] 目录下,完整路径为 [代码]/.well-known/apple-app-site-association[代码] 以下为示例,特别注意的是 [代码]appID[代码] 是由 [代码]团队 ID[代码] + [代码]Bundle ID[代码] 组成。 [代码]{ "applinks":{"apps":[], "details":[ { "appID":"<team_id>.<bundle_id>", "paths":["*"] } ] } } [代码] team_id 从 开发者账户 中获取 [图片] 顶部导航 当用户访问网站时,顶部引导用户跳转到 App 下载页。 等有空了搞搞。 7. 后记 小程序转 App,让个人或企业可以快速拥有 App,获取应用市场的流量,让开发者把精力放在业务逻辑上。 同时在开发小程序的过程中,发现开发者生态会散落在多个地方,比如 github,提供一些小程序模版、组件等能力,无法集中在一个地方比较方便的找到整个开发者生态的能力,和 VSCode 插件生态有点区别。 [图片] 先说 IDE 插件,比如我用 GPT4-Turbo 来写先代码或排查问题会在微信开发者工具和 Web 间跳转,操作流不太顺,如果能在微信开发者工具的插件入口中找到对应的 AI 代码助手,用起来应该很爽。 一旦平台的开放能力放出来,这些能力将源源不断的涌入到这个市场中,而不是作为平台方来集成这些能力,毕竟精力有限,同时还不一定做的最好,用插件可以让用户有更多的选择。 再说说 小程序组件,以大模型为例,目前市场有备案的大模型基座模型有好几家,在小程序开发过程中其实比较缺整体组件(UI + 背后的 API),有点像商场一样,平台方构建开放的能力,引导各个供应商提供开箱即用的能力,让用户可以快速上手,赶上这波大模型的技术趋势。 比如我自己在设计开放能力时的思考,平台专注骨架功能的开发,让开发者能参与到平台的建设中来,把生态盘活起来,最终提升大家研发运营的效率。 最后就是管理后端比较分散,比如 开放平台、donut、we 分析、云测、云托管,云开发,产品矩阵看不清,不容易知道整体的能力,缺少一个集中的控制台。 最后希望小程序越来越好 😄
2024-01-30 - 2023-12-12
- [省钱小妙招]当业务起来,”云开发“费用起飞后如何省钱!
[图片] 很多小程序的早期开发者,为了快速起项目,会使用微信的云函数、云开发来作为后台的数据存储和交互,给我们提供非常多便利的同时,也需要关注一些问题: [图片] 假如这个项目火了!数据量一下子大上去后,套餐用完直接费用炸了~ 除了把后端迁移到xx云服务器外,想继续使用云开发的话,有什么好的控制成本的方案呢? 一、业务起来,云开发费用炸了 下面来看下我们遇到的问题: [图片] 1.1 超出套餐后,费用很贵 这里可以看到,套餐最高的级别是999的那个,我们其中一个小程序已经购买了最贵的套餐,现在就遇到这个问题,超出套餐的部分价格会变得很贵。 [图片] 1.2 云开发收费规则分析 [图片] 注意,这里要仔细研究下这个计费模式,你会发现核心是内存占用 云函数并发数:云函数的并发数量是指在任意指定时间对函数代码的执行数量。对于当前的 SCF 函数来说,每个发布的事件请求就会执行一次。因此,这些触发器发布的事件数(即请求量)会影响函数的并发数。 每秒请求量 x 函数执行时间(按秒) 例如,考虑一个处理存储事件的函数,假定函数平均用时0.2秒(即200毫秒),存储每秒发布300个请求至函数。这样将同时生产 300 * 0.2 = 60 个函数实例。 数据库同时连接数 :数据库请求并发数量,如同时有三十个数据库操作请求,则有二十个会同时执行,剩下十个返回超出并发错误;一次数据库请求(无论小程序端发起还是云函数端发起)将耗费一个连接;每个云环境分别有一个同时连接数限制、独立计数。 常驻云函数闲置量:计算公式: 闲置的常驻云函数数量 * 该云函数的配置内存 * 闲置时长 * 常驻云函数闲置量定价 假如数据库查询平均耗时 10ms,那么一个连接可以支持 100qps(1000ms/10ms=100),20个连接可以支持到 2000qps。 二、优化方案: 2.1 云函数操作优化 [图片] 一些需要增删改查的方案,尽可能放在一个云函数里实现,因为如果你修改后,再调用另外一个云函数查找结果,这样计算的时候,就算2个流量~ 2.2 云函数内存调整 如果你用的云函数没有非常复杂的功能,考虑到云函数费用计算的公式。 [图片] 可以把它的内存占用调整到最低档128MB,相比于默认的256MB,每次访问都能省一半内存,效果立竿见影,费用可以节省40%左右~ 2.3 数据静态化 如果你用到云数据库,这里就会有一个问题,你可以一个操作需要调用云函数的同时,还需要使用数据库的资源,一旦超过套餐费用可不低哦,所以你需要做的核心是 尽可能减少对云开发和运数据的使用,我们可以采用以下的优化方案: 如果你的数据是JSON或者不经修改的配置数据,可以使用云存储,这个费用低多了 如果你有一些静态页面,授权文件,或者静态数据,可以使用云主页 [图片] [图片] [图片] 三、官方费用计算网站 https://cloud.weixin.qq.com/cloudbase/price https://developers.weixin.qq.com/miniprogram/dev/wxcloud/billing/price.html 总结: 以上是针对你还是想 继续用微信云开发 的费用优化,对于早期开发项目的小伙伴,本人还是非常推荐使用的,毕竟开发成本是真的低,后期数据和流量上来了,再优化也来得及~
2023-11-30 - 一个新的小程序日历
两年前因为项目需求的原因,设计了一个日历组件,两年后小程序接口渐渐丰富起来,bug也积累起来,我,重构了。 1 新的交互设计 - 借鉴了MIUI的系统日历设计 [图片] 2 支持webview和skyline渲染,在skyline的加持下,更丝滑了 3 支持darkmode,可以跟随系统更改深浅模式 特意找了个夜晚时段,试了下 [视频] 4 支持扩展插件,自带农历插件,更多插件计划中 5 喜欢给个star~,github - https://github.com/lspriv/wx-calendar/tree/develop
2023-10-30 - 小程序海报绘制方案(原生,Uniapp,Taro)
背景 小程序海报绘制方案有很多,但是大多数都是基于canvas的,而且都是自己封装的,不够通用,不够灵活,不够简单,不够好用。 本方使用一个开源的小程序海报绘制,非常灵活,扩展性非常好,仅布局就能得到一张海报。 准备工作 安装依赖,也可以把源码下载到本地,源码地址。 [代码]npm install wxml2canvas [代码] 布局 无论哪种方案,布局都是一致的,需要注意一些暂未支持的属性: 变形:transform,但是节点元素使能读取此属性,但是库不支持,所以不要使用 圆角,border-radius,同上,不要使用,圆形图片有特定的属性去实现,除此之外无法实现其他类型的圆角 布局示例: 注意,除了uniapp,原生和Taro要使用原生组件的方式绘制canvas,因为Taro不支持data-xx的属性绑定方式,这一点很糟糕 [代码]<!-- 外层wrap用于fixed定位,使得整个布局离屏,离屏canvas暂未支持 --> <view class='wrap'> <!-- canvas id,一会 new 的时候需要 --> <canvas canvas-id="poster-canvas"></canvas> <view class="container"> <view data-type="text" data-text="测试文字绘制" class='text'>测试文字绘制</view> <image data-type="image" data-src="https://img.yzcdn.cn/vant/cat.jpeg" class='image'></image> <image data-type="radius-image" data-src="https://img.yzcdn.cn/vant/cat.jpeg" class='radius-image'></image> </view> </view> [代码] 原生小程序 [代码]import Wxml2Canvas from 'wxml2canvas' Component({ methods: { paint() { wx.showLoading({ title: '生成海报' }); // 创建绘制实例 const drawInstance = new Wxml2canvas({ // 组件的this指向,组件内使用必传 obj: this, // 画布宽高 width: 275, height: 441, // canvas-id element: 'poster-canvas', // 画布背景色 background: '#f0f0f0', // 成功回调 finish: (url) => { console.log('生成的海报url,开发者工具点击可预览', url); wx.hideLoading(); }, // 失败回调 error: (err) => { console.error(err); wx.hideLoading(); }, }); // 节点数据 const data = { list: [ { // 此方式固定 wxml type: 'wxml', class: '.text', // draw_canvas指定待绘制的元素 limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算 } { // 此方式固定 wxml type: 'wxml', class: '.image', // draw_canvas指定待绘制的元素 limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算 } { // 此方式固定 wxml type: 'wxml', class: '.radius-image', // draw_canvas指定待绘制的元素 limit: '.container', // 限定绘制元素的范围,取指定元素与它的相对位置计算 } ] } // 调用绘制方法 drawInstance.draw(data); } } }) [代码] Uniapp uniapp 主要讲Vue3的版本,因为涉及 this,需要获取 this 以及时机 [代码]import { getCurrentInstance} from 'vue'; // 调用时机 setup内,不能在其他时机 // @see https://github.com/dcloudio/uni-app/issues/3174 const instance = getCurrentInstance(); function paint() { uni.showLoading({ title: '生成海报' }); const drawInstance = new Wxml2Canvas({ width: 290, // 宽, 以iphone6为基准,传具体数值,其他机型自动适配 height: 430, // 高 element: 'poster-canvas', // canvas-id background: '#f0f0f0', obj: instance, finish(url: string) { console.log('生成的海报url,开发者工具点击可预览', url); uni.hideLoading(); }, error(err: Error) { console.error(err); uni.hideLoading(); }, }); // 节点数据 const data = { list: [ { // 此方式固定 wxml type: 'wxml', class: '.text', // draw_canvas指定待绘制的元素 } { // 此方式固定 wxml type: 'wxml', class: '.image', // draw_canvas指定待绘制的元素 } { // 此方式固定 wxml type: 'wxml', class: '.radius-image', // draw_canvas指定待绘制的元素 } ] } // 调用绘制方法 drawInstance.draw(data); } [代码] Taro Taro 比较特殊,框架层面的设计缺陷导致了 Taro 组件增加的 [代码]data-xx[代码] 属性在编译的时候是会清除的,因此Taro要使用这个库要用原生小程序的方式编写组件。 代码和原生的一样,只是要用原生的方式编写组件,然后在 Taro 中使用。 参考原生的代码,原生小程序js参考这 假设原生组件名为 [代码]draw-poster[代码],那么首先需要再Taro的页面中引入这个组件,然后在页面中使用这个组件,然后在组件中使用这个库。 [代码]export default { navigationBarTitleText: '', usingComponents: { 'draw-poster': '../../components/draw-poster/index', }, }; [代码] [代码] const draw = useCallback(() => { const { page } = Taro.getCurrentInstance(); // 拿到目标组件实例调用里面的方法 const instance = page!.selectComponent('#draw_poster'); // 调用原生组件绘制方法 instance.paint(); }, []); return <draw-poster id="draw_poster"/> [代码] 总结 对比原生的canvas绘制方案,布局的方式获取节点的方式都是一样的,只是绘制的时候不一样,原生的是直接绘制到canvas上,而这个库是先把布局转换成canvas,然后再绘制到canvas上,所以这个库的性能会比原生的差一些,但是这个库的优势在于布局的方式,不需要自己去计算位置,只需要布局,然后调用绘制方法就可以了,非常方便,而且扩展性非常好,可以自己扩展一些布局方式,比如说flex布局,grid布局等等,这些都是可以的,只需要在布局的时候把布局转换成canvas的布局就可以了,这个库的布局方式是参考的微信小程序的布局方式,所以布局的时候可以参考微信小程序的布局方式,这样就可以很方便的布局了。
2023-10-31 - 小程序web-worker实践
大家好,我,阿盟 Web Workers 提供了在后台线程中执行计算密集型任务的能力,有助于充分利用多核处理器的计算能力,微信小程序也是在基础库 v2.18.1 开始支持在插件内使用 worker,但与浏览器对比,还是有显著的一些差异,接下来我们就来介绍并实践操作一下 1. 跨域限制:在小程序中,Web Workers 通常不允许跨域加载脚本。这意味着 Web Workers 只能加载与主线程代码在同一域下或其子域下的脚本文件,而不像浏览器中的 Web Workers 可以加载来自不同域的脚本。这是为了维护小程序的安全性和防止跨域问题。 2. 本地资源限制:小程序的 Web Workers 受到资源下载的限制,例如限制了 HTTP 请求的能力,因此在 Web Workers 中进行网络请求可能受到限制。 3. WebSocket 限制:小程序中的 Web Workers 通常不支持直接创建 WebSocket 连接,这与浏览器中的 Web Workers 不同。WebSocket 连接通常需要在主线程中创建和管理。 4. IndexedDB 限制:小程序中的 Web Workers 通常不支持 IndexedDB 数据库的访问,这是因为小程序环境对存储访问有一些限制。 5. 并发限制:浏览器通常允许多个 Web Workers 同时运行,但具体的线程数限制取决于浏览器的实现。通常,现代浏览器支持多个 Web Workers,可能在不同核心上并行执行它们, 但在小程序中,Worker 最大并发数量限制为 1 个,创建下一个前请用 Worker.terminate() 结束当前 Worker // 示例代码 // 1. 配置 Worker 信息 { "workers": "workers" // 如采用分包模式 "isSubpackage": true } // 微信小程序中使用 Web Workers const worker = wx.createWorker('worker.js'); // 监听从 Web Worker 返回的消息 worker.onMessage(function (res) { console.log('Received message from Web Worker:', res); }); // 向 Web Worker 发送消息 worker.postMessage({ data: 'Hello from main thread' }); // 在 Web Worker 文件 worker.js 中定义 Worker 的逻辑 // worker.js onmessage = function (e) { console.log('Received message in Web Worker:', e.data); postMessage({ result: 'Hello from Web Worker' }); }; // 从基础库 v2.27.3 开始,如果 worker 代码配置为了分包,也就是配置了isSubPackage为true,则需要先通过 wx.preDownloadSubpackage 接口下载好 worker 代码,再初始化 Worker var task = wx.preDownloadSubpackage({ packageType: "workers", success(res) { console.log("load worker success", res) var worker = wx.createWorker("workers/request/index.js") // 创建 worker。 如果 worker 分包没下载完就调 createWorker 的话将报错 }, fail(res) { console.log("load worker fail", res) } }) task.onProgressUpdate(res => { console.log(res.progress) // 可通过 onProgressUpdate 接口监听下载进度 console.log(res.totalBytesWritten) console.log(res.totalBytesExpectedToWrite) })
2023-10-19 - 微信小程序深度合成类目如何通过?
前言 在8月15日当天我分享了一篇《微信小程序深度合成类目资质如何准备?》,发完文章后有很多朋友私信我,发现有很人和我遇到了同样的问题,这个问题我昨天已经解决了,接下来分享下具体流程和材料。 [图片] 准备材料 最开始需要《安全评估报告》现在已经不需要了,最新的只有自己的AI大模型算法备案或者合作的AI达模型算法备案+合作协议。 第一种方式 在《微信小程序深度合成类目资质如何准备?》这篇有人留言(我没有询问过待办机构,我用的是合作的方式)。 [图片] 不建议这种方式,费用虽然不多,但是时间太久了,等办下来可能产品竞争力就没了。 第二种方式 算法备案证明截图 找市面上已经有AI模型算法备案的公司合作,比如:通义千问(阿里巴巴)、文心一言(百度)、讯飞星火(科大讯飞)等,那么怎么知道公司有没有算法备案?直接在「互联网信息服务算法备案系统」通过公司名称查找。 如:百度 [图片] 我们可以看到这里面就可以查看算法为「生成合成类」的算法备案信息为「正常」状态。 合作协议 确定有算法备案后找相关产品的商户去谈合作,看具体条件和签合同流程(在这里我不做任何公司的推荐,大家根据自身业务需求去体验下已备案的产品,然后再做判断) 在这里要注意合同中要包含算法备案证明截图中的【算法名称】双方公司都需要盖公章。 最后 当然具体你要用什么方式根据自己的实际情况决定,我就是用的第二种方式提供这两个材料就申请通过,最后祝大家都申请通过,产品顺利上线!
2023-08-31 - 给大伙儿开发一个记账小程序 - 小记账本
项目前端小程序二维码: [图片] 简介: 记录个人,家庭等财务收支情况,可免费导出收支明细,与家人好友共享账本,让记账变得更简单 我的个人blog网站:https://www.zhooson.cn/ 里面其他全栈项目开源Github地址 1. 技术: 前端:uniapp(vue3) 后端:egg node:16.5.0 数据库:mysql 工具:HbuilerX filezilla pm2 Termius等 2. 整体项目结构 [图片] 3. uniapp 具体的开发文档:https://uniapp.dcloud.net.cn/ 技术选型:uniapp以前没有使用过,这次决定尝试一次。 使用感觉,感觉不咋好,也许我是了解的不够全面,我每次小程序开发工具添加新的编译模式,重新打包后就没有了,这一点软件默认设置不太友好。 uniapp仔细阅读文档即可,本文不做详细讲解。 4. egg 1. 初始化的项目的老掉牙的命令自行查看文档:https://www.eggjs.org/zh-CN/intro/quickstart 2. [代码]jwt[代码]使用 安装 [代码] yarn add egg-jwt [代码] 配置 [代码]// {app_root}/config/plugin.js exports.jwt = { enable: true, package: "egg-jwt" }; [代码] [代码]// {app_root}/config/config.default.js exports.jwt = { secret: "123456" }; [代码] 使用 [代码]// {app_root}/app/controller/user.js //签发 token 数据 ... let result = await service.user.query({ openId }); const token = app.jwt.sign( { nickname: result.openId, userId: result.id, exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1h }, app.config.jwt.secret ); [代码] [代码]// {app_root}/app/router.js module.exports = (app) => { const { router, controller, jwt } = app; /** * 用户 */ router.post('/api/user/login', controller.user.login); router.post('/api/user/update', jwt, controller.user.update); router.get('/api/user/list', jwt, controller.user.list); } [代码] 3. 获取微信小程序用户openId(前端只需传递code) [代码]// {app_root}/app/service/tool.js 'use strict'; const Service = require('egg').Service; const axios = require('axios'); class ToolService extends Service { // wx 相关操作 async decodeWXByCode({ code }) { return new Promise((resolve, reject) => { const { ctx, app } = this; const { AppSecret, AppID } = app.config.wx; axios .get( `https://api.weixin.qq.com/sns/jscode2session?appid=${AppID}&secret=${AppSecret}&js_code=${code}&grant_type=authorization_code` ) .then((res) => { // console.log('decodeWXByCode', res.data); if (res.data.errcode === 40029) { resolve({ status: 201, message: '无效code' }); } else if (res.data.errcode === 40163) { resolve({ status: 201, message: 'code被使用' }); } else if (res.data.session_key && res.data.openid) { resolve({ status: 200, message: '获取成功', data: { openid: res.data.openid, }, }); } else { resolve({ status: 201, message: '未知错误' }); } }); }); } } module.exports = ToolService; [代码] 4. 查询首页数据service, 分级查询 [图片] [图片] [代码] async list({ openId, plus = 0, year, month, name_id }) { // console.log('2023-2-1', openId, plus, plus === 0, year, month, day); // let w = `where 1=1`; // let a = `where 1=1`; // if (openId) { // w += ` and b.openId = '${openId}'`; // a += ` and b.openId = '${openId}'`; // } // if (year) { // w += ` and year = ${year}`; // a += ` and year = ${year}`; // } // if (month) { // w += ` and month = ${month}`; // a += ` and month = ${month}`; // } // // if (day) { // // a += ` and day = ${day}`; // // } // if (+plus) { // w += ` and plus = ${plus}`; // } // 本月 支出 + 收入 = 总和 // const sumSql = `select sum(price) from book b ${a}`; // const MonthCount = await this.app.mysql.query(sumSql); // 本月 支出 const outSql = `select sum(price) from book where name_id = ${name_id} and year = ${year} and month = ${month} and plus = 1 and disabled = 0`; const MonthOutCount = await this.app.mysql.query(outSql); // 本月 收入 const inSql = `select sum(price) from book b where name_id = ${name_id} and year = ${year} and month = ${month} and plus = 2 and disabled = 0`; const MonthInCount = await this.app.mysql.query(inSql); // 当月 所有明细 // const sql = `select b.*, u.nickname, u.avatar, i.title icon_title from book b inner join user u on b.openId = u.openId inner join cate i on b.cate_id = i.id ${w} group day order by create_time desc`; const sql = `select distinct day, month, year from book where name_id = ${name_id} and year = ${year} and month = ${month} and disabled = 0 order by day desc`; let days = await this.app.mysql.query(sql); // const detailSql = `select * from book where openId = '${openId}' and year = ${year} and month = ${month}`; // const detailSql = `select b.*, u.nickname, u.avatar, i.title icon_title from book b inner join user u on b.openId = u.openId inner join cate i on b.cate_id = i.id where openId = '${openId}' and year = ${year} and month = ${month} order by create_time desc`; let n = '1 = 1 and disabled = 0'; if (+plus) { n += ` and b.plus = ${plus}`; } for (let val of days) { val.date = `${val.year}-${val.month}-${val.day}`; val.items = []; val.items = await this.app.mysql.query( `select b.*, u.nickname, u.avatar, c.title icon_title from book b inner join user u on b.openId = u.openId inner join cate c on b.cate_id = c.id where ${n} and b.name_id = ${name_id} and year = ${year} and month = ${month} and day = ${val.day} order by create_time desc` ); } } [代码] 我的sql语法不太完美,请大神提出宝贵意见。 5. 导出数据 excle表格, 可根据自己的需求导出想要的类目。 [图片] [代码] // 导出 async export() { const { ctx, service } = this; try { let query = ctx.request.query.code // 当前code需要解密,需要自己的制定自己的解密规则 console.log('search-query', query); query = JSON.parse(query); let list = await service.book.search(query); const bookDetail = await service.name.query({ id: query.name_id }); let xls = [[]]; xls[0] = ['方式', '金额', '创建人', '账本', '类别', '时间', '备注']; for (let index = 0; index < list.length; index++) { const element = list[index]; xls[index + 1] = [ element.plus === 1 ? '支出' : '收入', element.price, element.nickname, element.name_title, element.cate_title, element.year + '/' + element.month + '/' + element.day, element.remark, ]; } // console.log('xls', xls); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(xls), '账本'); const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); const filename = encodeURIComponent( `${bookDetail.title}_${query.year}_${query.month}月账本` ); // 设置header关键代码 ctx.set('Content-Disposition', `attachment; filename="${filename}.xlsx"`); ctx.set('Content-Type', 'application/vnd.ms-excel'); ctx.status = 200; ctx.body = buf; // cb(ctx, 200, 200, '导出成功', list); } catch (err) { cb(ctx, 200, 422, '导出失败', JOSN.stringify(err)); } } [代码] excle: [图片] 6. 上传文件 [代码]用户头像和cover图[代码],可动态生成文件夹目录。 [代码]'use strict'; const fs = require('fs'); const path = require('path'); const mkdirp = require('mkdirp'); const { cb, formatDate } = require('../../utils'); // 生成新的文件名称 function getUploadFileExt(name) { let ext = name.split('.'); let last = formatDate(new Date(), 'YYYYMMDDhhmmssms'); return `${last}.${ext[ext.length - 1]}`; } const Controller = require('egg').Controller; class UploadController extends Controller { async file() { const { ctx } = this; try { // 1. 获取文件流 const file = ctx.request.files[0]; // console.log(33, file); // 2. 生成filename const name = getUploadFileExt(file.filename); // console.log('name', name); // 3. 获取bucket ps: demo 或者 demo/test 或者 demo/test/cd const { bucket = 'avatar' } = ctx.request.body; // 4. 生成文件夹 const dir = path.join(__dirname, `../public/images/${bucket}`); // console.log('dir', dir); await mkdirp(dir); // 5. 文件流读取/写入 const filePath = `${dir}/${name}`; let readStream = fs.createReadStream(file.filepath); var writeStream = fs.createWriteStream(filePath); readStream.pipe(writeStream); readStream.on('end', function () { fs.unlinkSync(file.filepath); }); cb(ctx, 200, 200, '上传成功', { url: `http://${ctx.request.header.host}/public/images/${bucket}/${name}`, }); } catch (err) { cb(ctx, 200, 500, '上传失败!', JSON.stringify(err)); } } } module.exports = UploadController; [代码] 7. 好友共享账本 [图片] [图片] 8. 具体的数据表设计展示如下 账本表 [图片] 用户表 [图片] 5. 博客 我的个人blog网站:https://www.zhooson.cn/ 有其他前后端项目代码已开源。
2023-03-16 - fail api scope is not declared in the privacy agreement
fail api scope is not declared in the privacy agreement,api 范围未在隐私协议中声明 建议大家更具公告,更新对应的隐私协议 https://mp.weixin.qq.com/cgi-bin/announce?action=getannouncement&announce_id=11691660367cfUvX&version=&lang=zh_CN&token= 登录mp后台,设置, [图片] [图片] [图片] 完善并提交信息, 注意:更新好隐私协议,要通过审核的,接口才能正常访问 正确处理隐私弹窗逻辑 https://developers.weixin.qq.com/miniprogram/dev/framework/user-privacy/PrivacyAuthorize.html demo1: 演示使用 [代码]wx.getPrivacySetting[代码] 和 [代码]<button open-type="agreePrivacyAuthorization">[代码] 在首页处理隐私弹窗逻辑 https://developers.weixin.qq.com/s/gi71sGm67hK0 demo2: 演示使用 [代码]wx.onNeedPrivacyAuthorization[代码] 和 [代码]<button open-type="agreePrivacyAuthorization">[代码] 在多个页面处理隐私弹窗逻辑,同时演示了如何处理多个隐私接口同时调用。 https://developers.weixin.qq.com/s/hndZUOmA7gKn demo3: 演示 [代码]wx.onNeedPrivacyAuthorization[代码]、[代码]wx.requirePrivacyAuthorize[代码]、[代码]<button open-type="agreePrivacyAuthorization">[代码] 和 [代码]<input type="nickname">[代码] 组件如何结合使用 https://developers.weixin.qq.com/s/jX7xWGmA7UKa demo4: 演示使用 [代码]wx.onNeedPrivacyAuthorization[代码] 和 [代码]<button open-type="agreePrivacyAuthorization">[代码] 在多个 tabBar 页面处理隐私弹窗逻辑 https://developers.weixin.qq.com/s/g6BWZGmt7XK9 常见错误 [代码]{ "errMsg": "A:fail api scope is not declared in the privacy agreement", "errno": 112 }[代码] 使用到了 A 隐私接口,但是开发者未在[mp后台-设置-服务内容声明-用户隐私保护指引]中声明收集 A 接口对应的隐私类型。 [图片] 在审核提交时候,选择采集用户隐私 [图片] 在js上需要配合配置用户隐私授权弹窗 微信提供了wx.onNeedPrivacyAuthorization(function callback) 接口,意为用户触发了一个微信侧未记录过同意的隐私接口调用,开发者可通过响应该事件选择提示用户的时机。此外,微信还提供了 wx.requirePrivacyAuthorize(Object object) 接口,可用于模拟触发 onNeedPrivacyAuthorization 事件。 2023.08.22更新: 以下指南中涉及的 getPrivacySetting、onNeedPrivacyAuthorization、requirePrivacyAuthorize 等接口目前可以正常接入调试。调试说明: 在 2023年9月15日之前,在 app.json 中配置 [代码]__usePrivacyCheck__: true[代码] 后,会启用隐私相关功能,如果不配置或者配置为 false 则不会启用。在 2023年9月15日之后,不论 app.json 中是否有配置 [代码]__usePrivacyCheck__[代码],隐私相关功能都会启用。接口用法可参考下方完整示例demo 2023.09.14更新: 隐私相关功能启用时间延期至 2023年10月17日。在 2023年10月17日之前,在 app.json 中配置 [代码]__usePrivacyCheck__: true[代码] 后,会启用隐私相关功能,如果不配置或者配置为 false 则不会启用。在 2023年10月17日之后,不论 app.json 中是否有配置 [代码]__usePrivacyCheck__[代码],隐私相关功能都会启用。新增官方隐私授权弹窗功能,相关功能参考下方官方隐私弹窗功能说明。此功能目前仍在开发阶段,开发者目前可以先阅读本指南文档和接口文档进行理解,平台将会尽快正式上线相关能力,上线后会在本指南文档中进行说明。 小程序开发者可自行设计提示方式与触发时机,详细文档可查看隐私协议开发指南 。 仅有在指引中声明所处理的用户个人信息,才可以调用平台提供的对应接口或组件。若未声明,对应接口或组件将直接禁用。 [图片] (参考样例)
2023-09-15 - 云函数冷启动引起的Bug
1. 现象 最近对知识竞赛答题小程序进行了重构,设置了与服务器时间对齐功能。 通过云函数获取服务器时间,与本地时间差在5秒之间(考虑手机均实时校对时间,阈值设得相对较小) 开始按钮对时间差进行核对 出现的现象有: 用户表users记录多,成绩表scores记录少 成绩表scores中有些无使用记录 疑问:进入小程序后,为何不使用? 2. 排查 初步考虑是云函数冷启动导致。测试: 早起,删除小程序,清空本地数据 重新打开小程序,出现时间差超过阈值,阻止进入功能页,因此无成绩scores记录 [图片] 3. 原因及修复 首先明确,正常情况下,云服务器的时间与本地时间差在200毫秒以内,甚至更少。 原因:云函数冷启动,可能需要3-5秒的时间,而阈值设得太小。 修复:在调大阈值的同时,还发现另一个小bug,一并修复。 问题:能否实现定时刷新云函数,保证最小使用次数又能激活云函数。 欢迎体验云知识竞赛小程序:) [图片]
2023-09-09 - 小程序商品页性能优化开发实践
一、前言 小程序的性能又可以分为「启动性能」和「运行时性能」。「启动性能」让用户能够更快地打开并看到小程序的内容,「运行时性能」保障用户能够流畅地使用小程序的功能。除了本身的功能之外,良好性能带来的良好用户体验,也是小程序能够留住用户的关键。 二、优化性能诊断工具 诊断工具能让开发者更好地知道性能瓶颈在哪里,并且能让用户在优化后,更好地知道效果如何。微信官方提供的性能诊断工具有: 1. 代码依赖分析 该工具可以帮助开发者分析代码中的依赖关系,以便更好地优化代码的结构和性能。通过分析代码的依赖关系,开发者可以确定哪些代码模块是最重要的,以及哪些模块可能需要进行重构或优化。该工具可以帮助开发者分析代码中的依赖关系,以便更好地优化代码的结构和性能。通过分析代码的依赖关系,开发者可以确定哪些代码模块是最重要的,以及哪些模块可能需要进行重构或优化。 2. 性能报告 该工具可以为开发者提供有关应用程序性能的详细信息,包括加载时间、响应时间、资源使用情况等。这些信息可以帮助开发者确定应用程序的性能瓶颈,并采取相应的措施来提高应用程序的性能。 3. 代码质量扫描 该工具可以帮助开发者分析代码的质量,以便更好地管理和维护代码。通过分析代码的质量,开发者可以确定哪些代码模块需要进行重构或优化,以便更好地满足业务需求并提高代码的可维护性和可扩展性。 4.调试区的 Performance 面板 该工具可以帮助开发者分析应用程序的性能,包括 CPU、内存、网络和渲染等方面。通过 Performance 面板,开发者可以查看代码执行的时间线、函数调用栈等信息,以便更好地确定性能瓶颈并进行优化。 5.Memory 面板 该工具可以帮助开发者分析应用程序的内存使用情况。通过 Memory 面板,开发者可以查看内存使用情况的时间线、对象分配情况、垃圾收集情况等信息,以便更好地确定内存泄漏等问题并进行优化。 6.JavaScript Profiler 面板 该工具可以帮助开发者分析 JavaScript 代码的执行情况。通过 JavaScript Profiler 面板,开发者可以查看 JavaScript 代码的执行时间、函数调用次数、内存使用情况等信息,以便更好地确定 JavaScript 代码的性能瓶颈并进行优化。 7.体验评分(Audits)面板 该工具可以帮助开发者评估应用程序的用户体验。通过 Audits 面板,开发者可以查看应用程序在加载速度、响应速度、可访问性、SEO优化等方面的得分情况,以便更好地确定哪些方面需要进行优化以提高用户体验。 8.对于具体的业务代码,我们通过打时间戳的形式,数字化业务逻辑执行的时间,显性量化优化后的效果 开发者量化业务逻辑的执行时间,以便更好地确定业务逻辑的性能瓶颈并进行优化。通过在关键代码位置打时间戳并记录代码执行时间,开发者可以比较不同版本之间的执行时间差异,以便更好地确定优化效果。 三、启动性能优化 小程序的加载基本流程: [图片] 3.1 商品主图业务优化 行业内商详页面模板元素大致相同,在进入页面时,映入眼帘的肯定是商品主图。一般商户的主图照片都会十分高清,资源较大。对于性能来说是个较大消耗,如果简单压缩,就没办法满足高清的业务需求。基于以上背景,商详页对于主图照片有个特定的业务优化。 该方法说得简单点就是先加载清晰度差一点的商详照片,等非高清照片加载完成。在回调事件中。无缝替换成高清大图,用户无感知。 具体实现方案: [图片] a)技术实现方案 首先在图片中预加载 2 个元素。分别加载资源非高清大图: {picFilter(item || '//:0','md', pictureRatio) 以及高清大图 picFilter(item || '//:0','lg', pictureRatio) 利用 hasLoad 变量控制元素加载。加载不同清晰度的图片方法是 picFilter,lg 是高清大图,md 表示非高清大图。 在非高清图片加载完成后, 预加载高清图片。 loadImg(evt) { // 加载看不见的图片,预加载 this.setData({ isLoadBg: true, }); }, loadEvent() { // 预加载完成,setData this.setData({ hasLoad: true, }); }, b)优化效果 我们选取了一个高质量的图片进行了测试。发现图片在优化前从 120kb 到优化后的 9.6kb。资源加载大大减小了。 优化前: [图片] 优化后: [图片] 3.2按需注入 在 app 中加入全局配置。可以有效降低小程序的启动时间和运行内存。 { "lazyCodeLoading": "requiredComponents" } 3.3分包异步化 分包异步化是微信官方提供的分包加载的优化方案。目前来说多渠道小程序-支付宝也已支持,并且商品详情页也已经投入生产环境使用。分包异步化是将小程序的分包从页面粒度细化到组件甚至文件粒度。这使得本来只能放在主包内页面的部分插件、组件和代码逻辑可以剥离到分包中,并在运行时异步加载,从而进一步降低启动所需的包大小和代码量。 3.4跨分包的自定义组件引用优化 商详分包有引用大量的其他分包的自定义组件,例如商详模板的装修组件(@design-platform/wx-sdk/index),浏览未购组件(platform://ec/browseLogCollector),快速下单组件(ec_order/fastTrade),收银台组件。因为加载商详时,由于其他分包还未下载或注入,其他分包的组件处于不可用的状态。所以我们首先需要设置占位组件,渲染占位组件作为替代,分包下载完成再进行替换。核心原理与商详主图业务的优化大致相同。不过该方案是小程序官方自行提供的。 占位组件使用方式: { "usingComponents": { "pay-payment": "package://payment_cashier/pay-payment", "browse-log-collector": "platform://ec/browseLogCollector", "design-sdk": "@design-platform/wx-sdk/index" }, "componentPlaceholder": { "pay-payment": "view", "browse-log-collector": "view", "design-sdk": "view" } } 3.5非首屏组件懒加载 商详装修详情组件内嵌了装修 SDK,业务上有很多定制组件需要显示,包括商品描述。但这些组件应该属于非首屏组件。商详利用滚动加载的方式,将这些组件的渲染以及加载延迟。 技术上利用 isShowDesc 变量控制组件加载以及渲染。 onPageScroll({ scrollTop }) { this.setData({ isShowDesc: true }); } 该方案存在一种场景:首屏组件特别少,商品描述组件在首屏就无法展示出来了。所以 商品详情页加载首屏组件后需要判断商品描述组件的可视性。 const queryDom = wx.createSelectorQuery().in(this); const queryGoodsDes = queryDom.select('#detail'); if (queryGoodsDes) { queryGoodsDes .boundingClientRect((rect) => { if (!rect) return; if ((wx.rprm as any)?.systemInfo.windowHeight - rect.top > rect.height) { this.setData( { isShowDesc: true, }, () => this.emitGoodsDetail(), ); } }) .exec(); } 3.6文描图片的懒加载 上面虽然对于文件进行了懒加载,但是 商品详情页文描大部分的场景都是图片,并且文描又是商户设置的动态文本。所以文描组件基本都是一次性发大量图片请求的场景。 一次发送大量的 http 请求是非常耗时的。 针对此场景, 商品详情页利用元素可视性也进行了该场景的业务优化。 this.setData({ parseHtml }, () => { // 此处做滚动到图片位置的时候加载图片 parseHtml.images.forEach((item, index) => { const { obList } = this.data; const idx = item.index.replace(/(\d+)\.?/g, (s, $1) => `nodes[${$1}].`); if (!this.data.imgLoayLoad) { this.setData({ [`parseHtml.${idx}imgShow`]: true, [`parseHtml.${idx}imgLoayLoad`]: this.data.imgLoayLoad, }); return; } if (obList[index]) { obList[index].disconnect(); } obList[index] = this.createIntersectionObserver().relativeToViewport({ bottom: 750 }); obList[index].observe(`.wx-parse-img${item.imgIndex}`, (res) => { const idx = item.index.replace(/(\d+)\.?/g, (s, $1) => `nodes[${$1}].`); if (res.intersectionRatio > 0) { obList[index].disconnect(); this.setData({ [`parseHtml.${idx}imgShow`]: true, [`parseHtml.${idx}imgLoayLoad`]: this.data.imgLoayLoad, }); } }); }); }); 3.7依赖分析指标展示优化前后变化 商品业务繁重,我们在优化前对于商品依赖进行了分析,剔除重复引用,将很多商品相关业务,海报业务全部放进一个单独的分包。以及非商品提供的能力利用按需注入、分包异步化、分包异步化、跨分包的自定义组件引用优化等方案进行优化 四、运行性能优化 4.1商祥数据出参数优化 商祥的 spu 数据字段是比较多,为了拼团的两套购买逻辑,做了两套 spu 数据来维护,这样无行之中又使数据变大了,结合这种场景,我们把两套 spu 改为维护一套,拼团业务的单独购买,重新获取 spu 数据做业务逻辑。 [图片] 4.2setData 优化 setData 是小程序中使用最多,也是最容易引发性能问题的接口。 由于微信官方文档可知。小程序的逻辑层和视图层是两个独立的运行环境、分属不同的线程或进程,不能直接进行数据共享,需要进行数据的序列化、跨线程/进程的数据传输、数据的反序列化,因此数据传输过程是异步的、非实时的。 数据传输的耗时与数据量的大小正相关,如果对端线程处于繁忙状态,数据会在消息队列中等待。 因此在使用上遵循小程序的规范。 const BulletChatConfig = { // 消费弹幕配置 }; Component({ data: { originalData: [], }, bulletChatConfig:bulletChatConfig, pageLifetimes: { show() { this.animateStart(); }, } }); 页面或组件与渲染无关的数据。 最好挂在 this 下面。 控制 setData 的频率。对于连续的 setData 进行合并。 选择合适的 setData 范围。 setData 应只传发生变化的数据。 // before: ❌ 不要在setData中偷懒一次性传所有的data; this.setData({ singleSkuData: { ...skuData, selectedMap: this.data.selectedMap, }, }); // after this.setData({ ['singleSkuData.selectedMap']: this.data.selectedMap, }); 4.3选择高性能的动画实现方式 动画循环是前端的一个消耗。商详页也存在动画循环的组件。例如消费弹幕。秒杀定时器等。 起初 商品详情页消费弹幕的动画循环是利用定时器 setTimeOut 去实现的。 const that = this; const timer = setInterval(function () { if (dataIndex === data.length) { dataIndex = 0; } if (queueIndex === rowQueue.length) { queueIndex = 0; } tempData.push(that.getBulletChatItem(data[dataIndex], rowQueue[queueIndex], config.itemStyle)); if (tempData.length > 8) { tempData.splice(0, 1); } that.setData({ bulletChatList: tempData, }); dataIndex++; queueIndex++; }, config.intervalTime); that.setData({ timer, type: customType || DisplayType[display] || 'scrollup', bulletChatConfig: config }); 改成 createIntersectionObserver 的可视性来进行循环动画。 this.intersectionObserver = wx.createIntersectionObserver(this, { observeAll: true, thresholds: [0, 0.5, 1, 0] }); if (!this.intersectionObserver) return; this.intersectionObserver.relativeTo('.bullet-chat').observe('.bullet-chat-item-container', (res) => { if (res.intersectionRatio >= 0.5) { let { dataset: { id }, } = res; if (id === data.length - 1) { id = -1; } this.setData({ [`bulletChatList[${id + 1}].style`]: config.itemStyle, }); } if (res.intersectionRatio === 0 && res.intersectionRect.width === 0) { const { dataset: { id }, } = res; this.setData({ [`bulletChatList[${id}].style`]: 'left: 100%', }); } }) 五、运优化结果展示 我们利用打时间戳的形式,对于小程序的各种业务逻辑时间,以及渲染时间进行量化。显性得出优化前后的各种指标变化。 优化前: [图片] 优化后: [图片] 六、总结 以上就是商详页面的性能优化实践,分析小程序的启动过程。可知小程序优化可分成运行性能和启动性能优化。 启动性能优化利用商品主图优化,文描图片的懒加载,非首屏组件的懒加载,分包异步化,按需注入等方案实践。利用小程序的依赖分析可看出商祥分包的确肉眼可见地变小了,首屏组件也变快了。 运行时性能优化方案包括 商品详情页数据出参的优化,setData 的一些优化,选择高性能的动画实现方式的优化等。 我们利用打时间戳的方式。将 商品详情页分成初始化,请求,渲染等过程,分别计时。对比出优化前后的指标变化,由指标可知,商祥从加载到用户可交互的时间确实从 2.5s 优化到 0.9s 左右,优化了近 50%。 优化前后对比: [图片] 优化前 [图片] 优化后
2023-09-05 - 用纯 CSS 方式实现动态切换主题风格
一、前言 UI 组件库是现代 Web 应用程序开发中不可或缺的一部分。动态主题风格切换是一个非常重要的功能,它可以允许应用程序用户在不同的场景下选择自己喜欢的主题。这样的一个特性可以增加用户体验的个性化,并提高应用程序的可用性和易用性。 微盟移动端组件库 Titian 提供了动态主题切换的能力,并且延展了主题范围。 二、背景 在以前商户店铺的品牌视觉风格往往千篇一律,同时还因为需要逐个繁琐地配置界面中元素,导致的风格错乱等问题。针对上述的痛点,本次升级在确保商户品牌风格统一的前提下,基于品牌调性提炼了具有共性的视觉特征,分别为颜色、图标、圆角。并用这些特征组合为“通用”、“潮流”、“可爱”三套风格,能够让商家随心选择,让线上店铺更贴合自身的品牌调性,提高品牌识别度,维持 C 端用户的品牌心智。 [图片] 微盟移动端组件库 Titian 的主题风格包括三个维度的风格变化: 1、主题颜色的风格切换,这里主题色可以设置任意一种色值。 2、字体图标风格的切换,组件库中的所有图标包含三种风格,具体分为通用型,超流型和可爱型。 3、所有组件的圆角的切换,组件库中所有圆角也分为三种风格,风格的分类和字体图标分类一致,通用型的圆角即为设计稿上的圆角,超流型的圆角则是所有的组件圆角都变成直角,可爱型的圆角则为在设计稿上的圆角的基础上加上8个像素。 [图片] 我们要实现三种风格的切换是互相独立的,可以互相组合搭配。另外,图标风格的切换可以是全量一起切换,也可以是部分单独切换,而且需要运行时可以动态切换。 这些风格的切换都需要内置到组件库中,只需要给业务方提供一个变量来改变整体风格。 三、需求分析 对于主题色的风格配置,由于有些组件使用的的具有百分比透明度的主题色,所以采用 RGBA 色值更加方便。对于图标风格切换,从一种风格增加到三种风格,能不能尽量的不要增加代码体积,毕竟小程序对包体积有严格的要求。 对于圆角风格,有些需要将设计稿的圆角加8像素,有些需要变成直角,而有些又需要单独处理成大圆角。 这都该如何设计呢?这么多的风格切换,如何能尽量设计少的接口来让业务方写最少的代码,不去增加业务方的记忆负担呢?另外需要在运行时进行风格的切换,我决定使用 CSS 原生变量的方式。 CSS变量的好处包括: 代码重用:可以在多个元素中使用同一个变量,避免了重复编写样式代码的问题。 简化维护:当需要修改样式时,只需要修改变量的值,而不是每个元素的样式。 动态更新:CSS 变量可以通过 JavaScript 动态修改,使得样式在运行时可以动态变化。 提高可读性:通过使用有意义的变量名,可以使样式表更易于理解和维护。 CSS 变量可以提高代码的可维护性、可读性和灵活性。 四、技术方案和实施 [图片] 4.1 主题颜色切换方案 组件库内部定义三个 CSS 变量:--theme-r、--theme-g、--theme-b,这三个 CSS 变量也是对外暴露出去修改主题颜色的关键。组件内部的全局 less 文件使用这三个变量定义主题色,所有组件使用到主题色的地方都统一使用下面的 less 变量。 @theme-r: var(--theme-r, 250); @theme-g: var(--theme-g, 44); @theme-b: var(--theme-b, 25); @brand-color: rgb(@theme-r, @theme-g, @theme-b); @brand-color-fade-10: rgba(@theme-r, @theme-g, @theme-b, 0.1); @brand-color-fade-20: rgba(@theme-r, @theme-g, @theme-b, 0.2); @brand-color-fade-30: rgba(@theme-r, @theme-g, @theme-b, 0.3); @brand-color-fade-40: rgba(@theme-r, @theme-g, @theme-b, 0.4); @brand-color-fade-50: rgba(@theme-r, @theme-g, @theme-b, 0.5); @brand-color-fade-60: rgba(@theme-r, @theme-g, @theme-b, 0.6); @brand-color-fade-70: rgba(@theme-r, @theme-g, @theme-b, 0.7); @brand-color-fade-80: rgba(@theme-r, @theme-g, @theme-b, 0.8); @brand-color-fade-90: rgba(@theme-r, @theme-g, @theme-b, 0.9); @brand-color-fade-100: rgba(@theme-r, @theme-g, @theme-b, 1); [图片] 4.2 圆角风格切换方案 三种圆角是对应三种圆角数值,默认的圆角是设计稿的圆角,怎样变成直角和大8像素的圆角呢?我采用设计圆角加上增量圆角来达到最终圆角的目的。 针对于所有增量圆角,我们定义一个css变量:--base-radius-size,另外在全局less变量中定义圆角变量,我们所有使用到圆角的地方都使用less变量;默认的增量为0。 潮流型风格需要将圆角变成直角,那么只需将增量圆角设置为一个较大负值比如-999px,那么最终也会得到一个负数圆角,因为圆角不存在负值,所以负值圆角表现就是圆角为0的直角效果。 可爱型风格需要将设计稿圆角增加8像素。那么这个增量圆角就设置为8px;而对于那些特殊需求,要单独设置成大圆角即半圆形的圆角,那么只需给一个较大的圆角即可。但是为了做区分,所以这里新增了一个css变量:--capsule-radius-size,这个是专供特殊需求圆角使用,比如button和search的圆角,他们在可爱风格下会直接变成胶囊型圆角。那么这里就把--capsule-radius-size设置为999px即可。 下图就是全局 less 变量中定义的圆角,在组件中统一使用如下圆角。目前只罗列了4px、8px、12px、16px和大圆角。如果有更多圆角,可以新增多个圆角数值。业务方在使用时,设置通用风格,只需设置--base-radius-size:0px;--capsule-radius-size:0px;这也是默认风格。设置成潮流型,只需设置--base-radius-size:-999px;--capsule-radius-size:-999px;设置成可爱型,只需设置--base-radius-size:8px;--capsule-radius-size:999px; @radius-4: calc(var(--base-radius-size, 0px) + 4px); @radius-8: calc(var(--base-radius-size, 0px) + 8px); @radius-12: calc(var(--base-radius-size, 0px) + 12px); @radius-16: calc(var(--base-radius-size, 0px) + 16px); @radius-999: calc(var(--base-radius-size, 0px) + 999px); // 圆角 (按钮button、搜索search)采用如下圆角; // 可以自适应变成胶囊型 @special-radius-4: calc(var(--capsule-radius-size, 0px) + 4px); @special-radius-8: calc(var(--capsule-radius-size, 0px) + 8px); @special-radius-12: calc(var(,--capsule-radius-size 0px) + 12px); @special-radius-16: calc(var(--capsule-radius-size, 0px) + 16px); @special-radius-999: calc(var(--capsule-radius-size, 0px) + 999px); 到这里切换圆角的功能已经实现了,但是让业务方去记忆不同的圆角值对应三种风格,比如设置可爱风需要设置--base-radius-size:8px;--capsule-radius-size:999px; 这会增加业务方的记忆负担,能不能继续优化,让设置更简单易用呢?我又探索了在 CSS 中使用布尔运算,让业务方通过传入0、1、2,组件内自动计算出需要使用的圆角值。 利用calc + var实现纯css布尔运算 [图片] 三种风格的计算逻辑 [图片] 逻辑延展,适应更多风格 [图片] 这样的方式就大大简化了业务的使用负担,只需要根据接口返回的风格类型,将对应的0、1、2通过 CSS 变量传入组件库,就可使用不同的圆角风格,计算过程完全在组件内部。后续如果要调整规则,也只需要在组件中进行全局的修改即可。 4.3 图标风格切换方案 目前常见的图标风格切换方式,主要是图标名称的切换。假如原有 50 个通用型风格的图标,现在分别新增 50 个潮流型和可爱型图标,对应不同的图标名称,换图标名就达到了换风格的目的。我的方案简单概括就是换字体,不换图标名称;由于小程序中对包体积有严格控制,所以能不增加包体积则最好; [图片] [图片] 在字体图标平台创建三套字体图标库,分别为通用型,潮流型和可爱型字体库;并分别上传对应风格的图标;按照通用型图标库为基准,修改新增字体库里的图标名称和 Unicode 编码,做到三套字体库中图标名称和 Unicode 编码一一对应相同;如下图,同一个删除图标,在三种风格的字体库中,下图标记的地方代表 Unicode 编码和图标名称,在三个字体库中要设置成一样的。将三套字体图标引入到小程序项目中,由于图标名称和 Unicode 编码一致,所以只需要引入三套字体的定义内容,具体的图标伪元素定义内容基本一致,无需新增。 [图片] 上图是设置的关键,每个图标库中需对应设置成一样的值。 五、总结 主题色可以设置任意一种色值,图标可以三种风格互相切换,圆角也可以三种风格互相切换。这三中风格又可以互相搭配。微盟移动端组件库 Titian 采用 CSS 变量方式切换风格,其中主题色风格提供三个 CSS 变量:--theme-r、--theme-g、--theme-b 对应主题色的 RGBA 色值,字体图标提供一个 CSS 变量,--icon-family 来设置图标对应的字体库的名称,圆角风格提供两个 CSS 变量:--base-radius-size 和 --capsule-radius-size 来设置圆角的增量,后续又优化为使用 --s 来计算得到增量圆角。 通过以上几个简单的 CSS 变量,微盟移动端组件库 Titian 实现了,使用纯 CSS 方式,在运行时动态切换主题风格和自由搭配三种类型风格的能力,在小程序和 H5 中是完全通用的,体验完全一致。微盟移动端组件库 Titian 动态切换主题的能力,给使用方丰富的选择性,体现品牌调性的多样化。也兼顾了商家品牌个性化需求的灵活性,圆角与图标风格可以进行脱钩单独选择风格,一键配置,全店生效。以及在已定义的部分场景中也能与全局风格脱钩 (例如价格色与标签色),既有统一的品牌风格,又不失场景化的灵活表达。贴合用户心智,维持品牌认知。在赋能品牌的同时,开发者能够探索出无限可能。 目前微盟移动端组件库 Titian 已经完全开源,期待大家共同构建组件库生态,让Titian组件库更加易用好用。
2023-09-04 - 云调用能力—图像处理和OCR
云调用有些接口属于 AI 服务的范畴,比如借助于人工智能来进行智能裁剪、扫描条码/二维码、图片的高清化等图像处理和识别银行卡、营业执照、驾驶证、身份证、印刷体、驾驶证等 OCR,有了这些接口我们也能在小程序里使用人工智能了。接下来我们以小程序的条码/二维码识别和识别印刷体为例来介绍一下云调用。 13.3.1 图像处理使用开发者工具新建一个云函数,如 scancode,然后在 config.json 里添加 img.scanQRCode 云调用的权限,使用 npm install 安装依赖之后,上传并部署所有文件(此时也会更新权限)。 { "permissions": { "openapi": [ "img.scanQRCode" ] } } 然后再在 index.js 里输入以下代码,注意[代码]cloud.openapi.img.scanQRCode[代码]方法和[代码]img.scanQRCode[代码]权限的对应写法,不然会报 604100 的错误。 const cloud = require("wx-server-sdk"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }); exports.main = async (event, context) => { try { const result = await cloud.openapi.img.scanQRCode({ imgUrl: "https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/demo/qrcodetest.png", //注意二维码必须是条码/二维码,不能是小程序码 }); return result; } catch (err) { console.log(err); return err; } }; 调用该云函数之后,返回的 result 对象里包含 result 对象,在 codeResults 的 data 里可以得到二维码里包含的内容。 codeResults: [{ data: "使用云开发来开发微信小程序可以免费。。。", pos: {leftTop: {…}, rightTop: {…}, rightBottom: {…}, leftBottom: {…}},typeName: "QR_CODE"}] errCode: 0 errMsg: "openapi.img.scanQRCode:ok" imgSize: {w: 260, h: 260} 13.3.2 OCR 人工智能识别使用开发者工具新建一个云函数,如 ocrprint,然后在 config.json 里添加 ocr.printedText 云调用的权限,使用 npm install 安装依赖之后,上传并部署所有文件(此时也会更新权限)。 { "permissions": { "openapi": [ "ocr.printedText" ] } } 调用该云函数之后,返回的 result 对象里包含 result 对象,在 codeResults 的 data 里可以得到二维码里包含的内容。 const cloud = require("wx-server-sdk"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }); exports.main = async (event, context) => { try { const result = await cloud.openapi.ocr.printedText({ imgUrl: "https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/demo/ocrprint.png", }); console.log(result); return result; } catch (err) { console.log(err); return err; } }; 调用该云函数之后,返回的 result 对象里包含 result 对象,在的 items 里可以返回图片包含的文字内容。 items: Array(4) 0: {text: "JavaScript入门", pos: {…}} 1: {text: "JavaScript是目前世界上最流行的编程语言之一,它也是小程序开发最重要的基础语言。要做出一个功能复杂的小程序,除了需要掌握JavaScript的基本语", pos: {…}} 2: {text: "法,还要了解如何使用JavaScript来操作小程序(通过API接口)", pos: {…}} 3: {text: "过API接口)。", pos: {…}} 13.3.3 图像处理拓展能力图片是小程序非常重要的元素,尤其是旅游照片、社交图片、电商产品图片、相册类小程序、媒体图文等,图片的加载速度、清晰度、图片的交互、图片效果的处理以及图片加载的 CDN 消耗都是一个不得不需要去关注的问题。而云开发图像处理拓展能力结合云存储则可以非常有效的解决很多问题。 强烈建议所有有图片处理需求的用户都应该安装图像处理拓展能力,这个能力大大弥补和增强了云存储在图片处理能力,尤其是图片按照需求的规格进行缩放可以大大减少 CDN 的消耗以及图片的加载速度以及我们可以按照不同的业务场景使用快速缩略模板,而这一切的操作和云存储的结合都是非常实用且易用的。 1、图像处理能力介绍云开发图像处理能力结合的是腾讯云数据万象的图片解决方案,图像处理提供多种图像处理功能,包含智能裁剪、无损压缩、水印、格式转换等,图像处理拓展能力所包含的功能非常丰富,使用如下图片处理的费用是按量计费的,计费周期为月,10TB 以内免费,超出 10TB,按 0.025 元/GB 来计费,省事而便宜: 缩放:等比缩放、设定目标宽高缩放等多种方式;裁剪:普通裁剪、缩放裁剪、内切圆、人脸智能裁剪;旋转:普通旋转、自适应旋转;格式转换:jpg、bmp、gif、png、webp、yjpeg 格式转换,gif 格式优化,渐进显示功能;质量变换:针对 JPG 和 WEBP 图片进行质量变换;高斯模糊:对图片进行模糊处理;锐化:对图片进行锐化处理;图片水印:提供图片水印处理功能;文字水印:提供实时文字水印处理功能;获取图片基本信息:查询图片基本信息,包括格式、长、宽等;获取图片 EXIF:查询图片 EXIF 信息,如照片的拍摄参数、缩略图等;获取图片主色调:获取图片主色调信息;去除元信息:去除图片元信息,减小图像体积;快速缩略模板:快速实现图片格式转换、缩略、剪裁等功能,生成缩略图;管道操作符:对图片按顺序进行多种处理当我们在腾讯云云开发网页控制台(注意要使用微信公众号的方式登录)添加完图像处理的拓展能力之后,我们可以在腾讯云的数据万象存储桶里看到云开发的云存储,而关于图像处理能力的深入使用,也可以参考腾讯云数据万象的技术文档。在小程序云开发里使用图像处理能力的方法有三种: 图像地址的拼接,只需要在图片的下载地址 url 里拼接一些简单的参数(API 管道操作符),就能够使用到图像处理的能力,非常方便易用,这个不会把图片处理的结果存储到云存储,不会占用云存储的空间;在获取图片基本信息、获取图片 EXIF、获取图片主色调等方面非常方便;在前端(小程序端)做持久化图像处理,支持有结果图输出的处理操作,也就是我们可以把缩放、裁剪、格式转换、质量变换等处理之后的图片存储到云存储方便以后使用;在云函数端做持久化图像处理,支持有结果图输出的处理操作 01图像地址的拼接在了解图像处理能力之前,我们需要先了解一下云存储文件的 fileID、下载地址以及下载地址携带的权限参数 sign(图像处理能力的参数拼接就是基于下载地址的),如下图所示: [图片] 在安装了图像处理拓展能力的情况下,我们可以直接拿云存储的下载地址进行拼接,拼接之后的链接我们既可以在小程序里使用,也可以用于图床,比如原始图片下载地址为: https://786c-xly-xrlur-1300446086.tcb.qcloud.la/hehe.jpg?sign=b8ac757538940ead8eed4786449b4cd7&t=1591752049 而相关的图像处理能力的拼接案例如下,具体的操作可以看技术文档,实际的效果,可以复制粘贴链接到浏览器或小程序里体验(换成自己的地址),注意拼接方式就是在下载地址后面加了一个[代码]&imageMogr2/thumbnail/!20p[代码](注意这里由于已经有了一个 sign 参数,所以拼接时用的是[代码]$[代码],不能写成[代码]?[代码],否则不会生效),直接就可以啦,非常易用: //将图片等比例缩小到原来的20% https://786c-xly-xrlur-1300446086.tcb.qcloud.la/hehe.jpg?sign=b8ac757538940ead8eed4786449b4cd7&t=1591752049&imageMogr2/thumbnail/!20p 后面为了方便,我们将[代码]https://786c-xly-xrlur-1300446086.tcb.qcloud.la/hehe.jpg?sign=b8ac757538940ead8eed4786449b4cd7&t=1591752049[代码]简写为 download_url: //缩放宽度,高度不变,下面案例为宽度为原图50%,高度不变 download_url&imageMogr2/thumbnail/!50px //缩放高度,宽度不变,下面案例为高度为原图50%,宽度不变 download_url&imageMogr2/thumbnail/!x50p //指定目标图片的宽度(单位为px),高度等比压缩,注意下面的是x,不是px,p与x在拼接里代表着不同的意思 download_url&imageMogr2/thumbnail/640x //指定目标图片的高度(单位为px),宽度等比压缩: download_url&imageMogr2/thumbnail/x355 //限定缩略图的宽度和高度的最大值分别为 Width 和 Height,进行等比缩放 download_url&imageMogr2/thumbnail/640x355 //限定缩略图的宽度和高度的最小值分别为 Width 和 Height,进行等比缩放 download_url&imageMogr2/thumbnail/640x355r //忽略原图宽高比例,指定图片宽度为 Width,高度为 Height ,强行缩放图片,可能导致目标图片变形 download_url&imageMogr2/thumbnail/640x355! //等比缩放图片,缩放后的图像,总像素数量不超过 Area download_url&imageMogr2/thumbnail/150000@ //取半径为300,进行内切圆裁剪 download_url&imageMogr2/iradius/300 //取半径为100px,进行圆角裁剪 download_url&imageMogr2/rradius/100 //顺时针旋转90度 download_url&imageMogr2/rotate/90 //将jpg格式的原图片转换为 png 格式 download_url&imageMogr2/format/png //模糊半径取8,sigma 值取5,进行高斯模糊处理 download_url&imageMogr2/blur/8x5 //获取图片的基础信息,返回的是json格式,我们可以使用https请求来查看图片的format格式,width宽度、height高度,size大小,photo_rgb主色调 download_url&imageInfo 2、小程序端持久化图像处理当我们希望把缩放、裁剪、旋转、格式变换等图像处理的结果(也就是处理之后的图片)存储到云存储,这个就叫做持久化图像处理,在安装了图像处理能力之后,我们也可以在小程序端做图像处理。 当用户把原始图片上传到小程序端时,我们需要对该图片进行一定的处理,比如图片过大就对图片进行裁剪缩小;比如图片需要进行一定的高斯模糊、旋转等处理,这些虽然在图像处理之前,也是可以使用 js 来做的,但是小程序端图像处理的效果并没有那么好或者过于复杂,使用图像处理的拓展能力就非常实用了。在小程序端构建图像拓展依赖 首先在开发者工具小程序根目录(一般为 miniprogram),右键“在终端中打开”,然后在终端里输入以下代码,也就是在小程序端安装图像拓展依赖,安装完时,我们就可以在 miniprogram 文件夹下看到 node_modules: npm install --save @cloudbase/extension-ci-wxmp@latest 然后点击开发者工具工具栏里的工具-构建 npm,构建成功之后,就可以在 miniprogram 文件夹下看到 minprogram_npm 里有@cloubase 文件夹,里面有 extension-ci-wxmp,说明图像拓展依赖就构建完成。 在小程序端进行图像处理 使用开发者工具新建一个 imgprocess 的页面,然后在 imgprocess.wmxl 里输入如下代码,我们新建一个 button 按钮: 处理图片button> 然后再在 imgprocess.js 的 Page()函数的上面(外面)引入图像处理依赖,代码如下: const extCi = require("./../../miniprogram_npm/@cloudbase/extension-ci-wxmp"); 然后再在 imgprocess.js 的 Page()函数的里面写一个 imgprocess 的事件处理函数,点击 button 之后会先执行 readFile()函数,也就是获取图片上传到小程序临时文件的结果(是一个对象),然后再调用 imageProcess()函数,这个函数会对图片进行处理,图片会保存为[代码]tcbdemo.jpg[代码],而处理之后的图片会保存为 image_process 文件夹下的 tcbdemo.png,相当于保存了两张图片: async imgprocess(){ const readFile = async function() { let res = await new Promise(resolve=>{ wx.chooseImage({ success: function(res) { let filePath = res.tempFilePaths[0] let fm = wx.getFileSystemManager() fm.readFile({ filePath, success(res){ resolve(res) } }) } }) }) return res } let fileResult = await readFile(); //获取图像的临时文件上传结果 const fileContent = fileResult.data //获取上传到临时文件的图像,为Uint8Array或Buffer格式 async function imageProcess() { extCi.invoke({ action: "ImageProcess", cloudPath: "tcbdemo.jpg", // 图像在云存储中的路径,有点类似于wx.cloud.uploadFile接口里的cloudPath,上传的文件会保存为云存储根目录下的hehe.jpg operations: { rules: [ { fileid: "/image_process/tcbdemo.png", //将图片存储到云存储目录下的image_process文件夹里,也就是我们用image_process存储处理之后的图片 rule: "imageMogr2/format/png", // 处理样式参数,我们可以在这里写图片处理的参数拼接 } ] }, fileContent }).then(res => { console.log(res); }).catch(err => { console.log(err); }) } await imageProcess() } 可能你的开发者工具会报以下错误:[代码]https://786c-xly-xrlur-1300446086.pic.ap-shanghai.myqcloud.com 不在以下 request 合法域名列表中,请参考文档:https://developers.weixin.qq.com/miniprogram/dev/framework/ability/network.html[代码],这个要按照参考文档将链接加入到合法域名当中,不然不会生成图片;[代码]action[代码]是操作类型,它的值可以为:ImageProcess 图像处理,DetectType 图片安全审核(后面会介绍),WaterMark 图片忙水印、DetectLabel 图像标签等。[代码]operations[代码]是图像处理参数,尤其是 rule 和我们之前 url 的拼接是一致的,比如[代码]imageMogr2/blur/8x5[代码]、[代码]imageMogr2/rradius/100[代码]等参数仍然有效。上面函数里的 fileContent 不是必要的,也就是说我们可以不在小程序端上传图片,而是直接修改云存储里面已有的图片,并将图片处理后的照片保存,这种情况代码可以写成如下: async imgprocess(){ extCi.invoke({ action: "ImageProcess", cloudPath: "tcbdemo.jpg", // 会直接处理这张图片 operations: { rules: [ { fileid: "/image_process/tcbdemo.png", rule: "imageMogr2/format/png", // 处理样式参数,与下载时处理图像在url拼接的参数一致 } ] }, }).then(res => { console.log(res); }).catch(err => { console.log(err); }) } 3、云函数端持久化图像处理在云函数端的处理和小程序端的处理,使用的方法大体上是一致的,不过云函数的处理图片的场景和小程序端处理图片的场景会有所不同,小程序端主要用于当用于上传图片时就对图片进行处理,云函数则主要用于从第三方下载图片之后进行处理或者对云存储里面的图片进行处理(比如使用定时触发器对云存储里指定文件夹的图片进行处理)。不建议把图片传输到云函数端再来对图片进行处理。 使用开发者工具新建一个 imgprocess 的云函数,然后在 package.json 里添加 latest 最新版的[代码]@cloudbase/extension-ci[代码],并右键云函数目录选择在终端中打开输入命令 npm install 安装依赖: "dependencies": { "wx-server-sdk": "latest", "@cloudbase/extension-ci": "latest" } 然后再在 index.js 里输入以下代码,代码的具体含义可以参考小程序端的内容讲解: const cloud = require("wx-server-sdk"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }); const extCi = require("@cloudbase/extension-ci"); cloud.registerExtension(extCi); async function process() { try { const opts = { rules: [ { fileid: "/image_process/tcbdemo.jpeg", rule: "imageMogr2/format/png", }, ], }; const res = await app.invokeExtension("CloudInfinite", { action: "ImageProcess", cloudPath: "tcbdemo.jpg", fileContent, operations: opts, }); console.log(res); return res; } catch (err) { console.log(err); } }
2021-09-10 - ios该如何确保小程序添加到桌面功能生效呢?
我注意到对于账号主体为个人开发者的小程序,点击右上角菜单进入后,选择添加到桌面,仅出现链接生成失败,请重试的提醒; 对于账号主体为公司的,似乎都会自动跳转safari浏览器引导用户添加到主屏幕界面,所以仅仅是账号主体的区别决定该功能是否生效的吗?
2023-04-12 - 根据经纬度坐标获取当前所在城市——自己开发国内城市逆地址解析接口
背景 最近各大地图商齐刷刷的开始对地图的一些接口收费,特别是对商业用户。我在一些论坛上看到有水友吐槽,自己的APP用到了逆地址解析接口来获取当前城市,现在都要面临既收费、又限制调用频率和次数的问题,于是萌生了做一个国内城市逆地址解析接口的想法。 具体想法 具体实现并不难,主要分以下几步: 1、获取国内省市的地理轮廓 2、使用geo库解析轮廓 3、根据用户输入的坐标,按照“国——省——市”的顺序,找出坐标落在哪个市级范围内 开始实现 开发环境 操作系统:ubuntu 18.04 python版本: 3.8 django版本:2.2.4 postgrsql版本:10.23 开发步骤 1、下载省市轮廓,下载地址http://www.geojson.cn/preview,我是用python脚本下载的,格式是geojson #!/user/bin/pyhton import os import urllib.request import json BASE_URL = 'https://geojson.cn/api/data' TAR_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data', 'geojson') def download_file(file_url, file_path): urllib.request.urlretrieve(file_url, file_path) if __name__ == '__main__': cina_url = '/'.join([BASE_URL, '100000.json']) cina_filename = os.path.join(TAR_DIR, '100000.json') download_file(cina_url, cina_filename) with open(cina_filename, 'r', encoding='UTF-8') as fd: cina_data = json.load(fd) prv_list = cina_data['features'] for index, prv_item in enumerate(prv_list): props = prv_item['properties'] if 'code' in props: print('index: %d, name: %s, code: %d' % (index, props['name'], props['code'])) try: download_file('/'.join([BASE_URL, '%d.json' % props['code']]), os.path.join(TAR_DIR, '%d.json' % props['code'])) except: print('[error]index: %d, name: %s, code: %d' % (index, props['name'], props['code'])) else: print('index: %d, props: %s' % (index, props)) 2、在django内构造省市区域的model from django.contrib.gis.db import models from common.base_model import BaseModel class AdArea(BaseModel): """行政区域 Args: BaseModel (_type_): _description_ """ code = models.IntegerField(unique=True, null=False, verbose_name='区域编码') name = models.CharField(max_length=256, verbose_name='名称') fullname = models.CharField(max_length=256, verbose_name='区域全名') center = models.PointField(verbose_name='地理中心') children_num = models.IntegerField(verbose_name='子区域个数') level = models.CharField(max_length=64, verbose_name='级别') bbox = models.PolygonField(verbose_name='区域矩形边框') parent_code = models.IntegerField(verbose_name='父区域编码') mpoly = models.MultiPolygonField(verbose_name='区域地理边界') mpoly2 = models.GeometryCollectionField(null=True, blank=True, verbose_name='区域地理边界2', help_text='mpoly无效几何的修正结果') def __str__(self): return self.name class Meta: indexes = [ models.Index(fields=["level"]) ] 3、将geojson数据导入到postgresql数据库 4、构造rest api用于逆地址解析 from django.shortcuts import render from django.contrib.gis.geos import Point from django.contrib.gis.geos.error import GEOSException from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response from .models import AdArea from .serializers import AdAreaSerializer def is_in_china_bbox(latitude, longitude): china_bbox = (73.502355, 17.98689826522479, 135.09567, 53.563269) if latitude < china_bbox[1] or latitude > china_bbox[3]: return False if longitude < china_bbox[0] or longitude > china_bbox[2]: return False return True def is_in_area(location, m): if not m.bbox: return True try: if not m.bbox.contains(location): return False if m.mpoly2 is not None: return m.mpoly2.contains(location) else: return m.mpoly.contains(location) except GEOSException: print('=================geo contains error==================') for poly in m.mpoly: for ring in poly: print(list(ring)) print('=================geo contains error==================') raise @api_view(['GET']) def reverse_city(request): """经纬度逆解析-获取当前所在城市 Args: http://localhost:8000/api/zzgeo/reverse_city/?longitude=120.592528&latitude=31.310623 Returns: { 'code': 320500, 'name': '苏州', 'level': 'city' } """ if request.method == 'GET': latitude = float(request.GET['latitude']) longitude = float(request.GET['longitude']) if not is_in_china_bbox(latitude, longitude): return Response(status=status.HTTP_404_NOT_FOUND) city_location = Point(longitude, latitude) province_list = AdArea.objects.filter(level='province') for province_item in province_list: if not is_in_area(city_location, province_item): continue city_list = AdArea.objects.filter(parent_code=province_item.code, level='city') if not city_list: return Response(AdAreaSerializer(province_item).data) for city_item in city_list: if not is_in_area(city_location, city_item): continue return Response(AdAreaSerializer(city_item).data) return Response(status=status.HTTP_404_NOT_FOUND) 接口展示 [图片] 总结 使用geodjango,将下载的省市geojson格式的轮廓导入到系统,构造rest api,在用户发起查询时,按照“国——省——市”的顺序,找出坐标落在哪个市级范围内。 本文为抛砖引玉,geo库有非常多,覆盖了几乎所有的编程语言,例如nodejs可以使用turfjs。 欢迎大家参与讨论。
2023-06-17 - 深入理解CSS字符转义行为
[图片] 深入理解CSS字符转义行为 深入理解CSS字符转义行为 前言 为什么要转义? CSS 转义 什么是合法[代码]css[代码]的表达式 左半部分 右半部分 练习 参考链接 前言 在日常的开发中,我们经常写css。比如常见的按钮: [代码]<button class="btn"></button>[代码],我们往往写出这样的样式 [代码].btn { display: inline-flex; cursor: pointer; user-select: none; /* ..more decl.. */ } [代码] 然而我们有时候也会见到这样的元素: [代码]<div class="2xl:text-base">Hello world</div>[代码] 与之对应生效的CSS样式为: [代码]@media (min-width: 1536px) { .\32xl\:text-base { font-size: 1rem; line-height: 1.5rem; } } [代码] 这时候就纳闷了,我明明写的是 [代码]2xl:text-base[代码] 啊?[代码]\:[代码]这个转义还好说,[代码]\3[代码] 这个又是哪来的呢?本篇文章就来从 [代码]W3C[代码] 的角度,对 [代码]css[代码]转义行为进行揭秘。 为什么要转义? 我们先把目光提升一些,其实 转义 ([代码]Escaping[代码])这个行为,在各个语言系统中都存在,小到正则表达式,[代码]html[代码],[代码]css[代码],大到 [代码]javascript[代码] 或者其他成熟的编程语言,都多少存在着这种行为。 那些需要转义的字符,往往是和语言中的特定关键字([代码]keywords/meta[代码])产生了冲突,所以被迫让位。 比如,正则表达式中的 [代码].[代码] 就是一个元字符,代表的是匹配任意单个除了换行符的字符。要想匹配 [代码].[代码] 就需要转义一下写成 [代码]\.[代码]。 [代码]html[代码] 中的 [代码]<[代码],[代码]>[代码] 需要写成 [代码]<[代码],[代码]>[代码],不然就会和 [代码]html[代码] 中的标签匹配方式([代码]<div></div>[代码])产生冲突。 而 [代码]javascript[代码] 中我们也经常写出这样的单/双引号字符串 [代码]'i\'m a "happy" fool'[代码] or [代码]"i'm a \"happy\" fool"[代码]。 同样 [代码]css[代码] 也是如此。 CSS 转义 首先让我们来看看 [代码]w3c[代码] [代码]css[代码] 转义的说明: https://www.w3.org/TR/css-syntax-3/#escaping Any Unicode code point can be included in an ident sequence or quoted string by escaping it. CSS escape sequences start with a backslash (\), and continue with: Any Unicode code point that is not a hex digits or a newline. The escape sequence is replaced by that code point. Or one to six hex digits, followed by an optional whitespace. The escape sequence is replaced by the Unicode code point whose value is given by the hexadecimal digits. This optional whitespace allow hexadecimal escape sequences to be followed by “real” hex digits. 从这段说明中,我们理解了转义行为具体的逻辑。大致如下图所示: [图片] [代码]\0[代码] 是一个非常特殊的字符,本篇文章不对它进行讨论,有兴趣可以自行搜索相应文档。 可以看到转义逻辑是很简单的,无非就是加 [代码]\[代码] 判断是否是16进制数字,然后进行判断走不同的分支罢了。比如: [代码]<div class="a:">a:</div> [代码] 我们既可以这么写, [代码].a\: { color: red; } [代码] 也可以这么写 [代码].a\3a { color: blue; } [代码] 这[代码]2[代码]个选择器,效果上是等价的,但是它们各自走了不同的转义分支。 什么是合法[代码]css[代码]的表达式 这里我们以最常使用的 [代码]<ident-token>[代码] 为例,我们写的那些具体的选择器的值就需要符合这样的规范,即: [图片] 这类流程图片,相信对正则熟悉的同学,一眼就看懂了。 左半部分 我们先重点看左半部分,可以看到表达式开头必须以 [代码]--[代码] 或 [代码]-[代码],或者 [代码]_[代码], [代码]a-z[代码],[代码]A-Z[代码],[代码]non-ASCII[代码] 开头。 这里解释一下什么是 [代码]non-ASCII[代码],本质上就是非[代码]ASCII[代码]字符,也就是 [代码]code point > 127[代码] 的字符。 接着让我们来看看熟悉可爱的 [代码]ASCII[代码] 表吧。 [图片] 经过对照之后,可以筛选出表达式第一个字母的 [代码]code point[代码] 需要满足的要求是: [代码]code === 45 || // - code === 95 || // _ (code >= 97 && code <= 122) || // a-z (code >=65 && code<=90) || // A-Z code > 127 || // non-ASCII (escape chars) // 或者转义字符 [代码] 所以根据这个规则,所有不在上述范围内的 [代码]ASCII[代码] 字符都需要转义,才能正确表达。注意上面的表达式是不包括数字的哟,所以数字开头的类名,在写 [代码]css[代码] 选择器的时候都要进行转义,不论正负值。比如 [代码]<div class="2">2</div> [代码] 要想写选择器作用在这些元素上,就需要这样写: [代码].\32 { color: red; } [代码] 所以你就了解为什么选择 [代码]class="2"[代码] 的这个 [代码]css[代码] 选择器是 [代码].\32[代码] 了,因为这本质上是一个 [代码]十六进制(hex)[代码] 的字符。[代码]\32[代码] 换算一下就是 [代码]3 * 16 + 2 = 50[代码],而 [代码]50[代码] 这个 [代码]code point[代码] 在 [代码]ASCII[代码] 表里对应的字符就是 [代码]2[代码] ! 让我们再来点进阶的例子: [代码]<div class="2b">2b</div> <div class="2g">2g</div> <div class="-2g">-2g</div> [代码] 对应匹配的 [代码]css[代码] 选择器为(注意注释): [代码]/* 补全6位,不需要跟空格*/ .\000032b { color: blue; } /* 没有补全6位,需要跟空格*/ .\32 b { color: red; } /* 没有补全6位,然而16进制表示的字符范围是 0-f, 而字符g已经超出这个范围,所以空格 可加可不加, 而上面的 .\32 b 必须加空格,不然会认为 \32b 是一个hex数字整体 */ .\32g { color: red; } /* 负数开头,即第一位是'-',第二位是数字的也需要转义 */ .-\32 g { color: red; } [代码] 右半部分 接下来我们来观察表达式的右半部分。 再定义完成前置部分之后,右侧不止可以接受 [代码]_[代码],[代码]a-z[代码],[代码]A-Z[代码],[代码]non-ASCII[代码],也可以接受 [代码]0-9[代码],[代码]-[代码] 这些字符了。用代码来表达则为: [代码]code === 45 || // - code === 95 || // _ (code >= 48 && code <= 57) || // 0-9 (code >= 97 && code <= 122) || // a-z (code >=65 && code<=90) || // A-Z code > 127 || // non-ASCII (escape chars) // 或者转义字符 [代码] 相比左半部分要宽泛一些。这里我给出一些示例: [代码]<div class="a:b">a:b</div> <div class="lg:[&:nth-child(3)]:hover:underline"></div> <div class="bg-[url('/img/hero-pattern.svg')]"> <!-- ... --> </div> <div class="text-[color:var(--my-var)]">...</div> <div class="before:content-['我爱中国\_icebreaker']"> <!-- ... --> </div> [代码] 与之对应的那些样式: [代码]/* 语法错误 : 字符是 ASCII 且不在合法范围内 需要转义为 \: */ .a:b{ color: red; } /* 合法表达式 */ @media (min-width: 1024px) { .lg\:\[\&\:nth-child\(3\)\]\:hover\:underline:hover:nth-child(3) { text-decoration-line: underline; } } .bg-\[url\(\'\/img\/hero-pattern\.svg\'\)\] { background-image: url(/img/hero-pattern.svg); } .text-\[color\:var\(--my-var\)\] { color: var(--my-var); } .before\:content-\[\'\6211\7231\4F60_\4E2D\56FD\\_icebreaker\'\]::before { content: '我爱你 中国_icebreaker'; } [代码] 练习 假如你已经理解了上述内容,可以试试为下方的元素添加对应的生效的样式: [代码]<div class="-">单个-是特殊情况哟</div> <div class="我❤️中国,你好,世界。">我❤️中国,你好,世界。</div> <div class="émotion">émotion</div> <div class="-3:2yo:ur[x'\ds]">-3:2yo:ur[x'\ds]</div> [代码] 参考链接 https://www.w3.org/TR/css-syntax-3/#escaping https://www.w3.org/TR/css-syntax-3/#ident-sequence
2023-06-12 - 云函数中实现耗时操作解决方案
起因 在实际开发业务中需要生成带图的表格,由于数据过多导致服务超时。当时我通过在 腾讯云控制台 设置的时间的函数超时600秒,没到时间就超时了。 异常信息如下: WAServiceMainContext.js:2 Error: cloud.callFunction:fail Error: errCode: -501002 resource server timeout | errMsg: ESOCKETTIMEDOUT 后来通过和官方人员沟通得知小程序基础库的 callFunction 接口的默认限制了云函数超时时间的设置为60秒的上限,无法通过腾讯云控制台修改突破限制。 解决方案 如果不是通过callFunction调用的云函数是可以突破限制的,最多可以设置900秒,所以我用了两个云函数来解决这个超时问题。 云函数A用于小程序调用。 云函数B执行耗时操作,设置超时时间为900秒。 小程序调用云函数A,云函数A不用await修饰符调用云函数B,(云函数内互相调用是稳定的)然后云函数A返回调用成功,小程序这边收到云函数A的返回值就知道任务正在执行了,在小程序A里面去数据库存储一条开始执行状态的数据,返回ID。 然后在云函数B执行耗时操作完成去修改数据库的数据状态。 最后在小程序端监听数据库具体ID数据的状态变化来对用户进行反馈。 总结 当然如果数据量超大的话 900秒也会被用完,优化代码是一方面,但是如果代码优化不了的情况下这个时候就需要与产品功能想一个更好的解决方案。 假如900秒最多导出5000条数据,那么超过5000条就可以让用户分页导出,这样的话又可以保证不超时又能满足用户的方案。
2022-09-21 - 岁寒之松柏:小程序skyline渲染引擎初尝试
小程序架构介绍 我们都知道小程序本质上是运行在安卓端,苹果端的混合APP,只是微信提供了一套JSBridge,方便用户对一些原生功能和微信相关的功能的进行调用。而微信为了安全和性能的需要,一改以往网络架构中的单线程架构,改为小程序的双线程架构。分别是AppServie 和 Webview 两个线程,我们在小程序中编写的JS代码就是运行在AppService线程的JSCore引擎(类似V8 引擎,一个Js解释器)中,而我们的Wxml和Wxss则会依赖WebView线程进行渲染。 [图片] 目前架构存在的问题 这样的架构虽然已经极大了提高了webview的渲染性能,但是依然会存在一些问题比如: 当页面节点数目过多,很容易发生卡顿 当我们新建一个页面,就要新建一个Webview进行渲染 页面之间共享资源,需要使用Native进行通信,就会消耗更多性能 当AppService(逻辑层)与Webview(视图层)通信也需要依赖Native 所以为了解决这些问题小程序推出Skyline渲染引擎 Skyline引擎介绍 在Skyline环境中,Skyline 会创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。说白了就是之前的样式计算是放到渲染线程来处理,现在把和样式相关的逻辑也放到AppService线程中处理,个人猜测这个渲染线程很有可能很有可能就是flutter,这样的架构就极大减少内存的消耗,和线程上通信时间的消耗。原本wxs中的逻辑,也可以移到Appservice线程中运行 [图片] 使用Skyline引擎的使用步骤 在app.json 文件添加 [代码]"lazyCodeLoading": "requiredComponents"[代码] 属性,这是因为Skyline 依赖按需注入的特性。 [代码] { "pages": [ "pages/index/index", "pages/logs/logs", "pages/test/test" ], "window": { "backgroundTextStyle": "light", "navigationBarBackgroundColor": "#fff", "navigationBarTitleText": "Weixin", "navigationBarTextStyle": "black" }, "sitemapLocation": "sitemap.json", // 在 app.json 文件添加 "lazyCodeLoading": "requiredComponents" } [代码] 在全局或页面配置中声明为 Skyline 渲染,即 app.json 或 page.json 配上[代码]"renderer": "skyline"[代码] Skyline 不支持页面全局滚动,需在页面配置项加上 [代码]"disableScroll": true[代码],在需要滚动的区域使用scroll-view 实现 Skyline 不支持原生导航栏,需在页面配置项加上 [代码]"navigationStyle": "custom"[代码],并自行实现自定义导航栏 [代码] { "usingComponents": {}, // 在 app.json 文件添加或者page页面的json中添加 "disableScroll": true, "navigationStyle": "custom" //也可以放在App.json文件中 "renderer": "skyline" } [代码] 组件适配,参考 Skyline 基础组件支持与差异 WXSS 适配,参考 Skyline WXSS 样式支持与差异 在本地设置中勾选Skyline渲染调试,如果看不到这个选项框,看一下是否在app.json中配置了[代码]"renderer": "skyline"[代码] [图片] Skyline的 worklet 动画介绍 小程序采用双线程架构,渲染线程(UI 线程)和逻辑线程(JS 线程)分离。[代码]JS[代码] 线程不会影响 [代码]UI[代码] 线程的动画表现,如滚动效果。但引入的问题是,[代码]UI[代码] 线程的事件发生后,需跨线程传递到 [代码]JS[代码] 线程,进而触发开发者回调,当做交互动画(如拖动元素)时,这种异步性会带来较大的延迟和不稳定,[代码]worklet[代码] 动画正是为解决这类问题而诞生的,使得小程序可以做到类原生动画般的体验 worklet函数定义 [代码]function helloWorklet() { 'worklet'; //'worklet'声明该函数为work函数,可以在js线程和UI线程中调用 console.log('hello worklet'); } Page({ onLoad(options) { helloWorklet('hello') // print: hello wx.worklet.runOnUI(helloWorklet)() }, }) [代码] 在小程序控制台可以看到如下输出 [图片] 如果看见SkylineGlobal is not defined错误看看是否开启了Skyline渲染调试 [图片] worklet函数间的相互调用 [代码]function slave() { 'worklet'; return "I am slave" } function master() { 'worklet'; const value = slave() console.log(value); } [代码] 从 UI 线程调回到 JS 线程 [代码]const {runOnUI ,runOnJS} = wx.worklet function jsFun(message) { // 普通函数不需要声明为worklet console.log(message) } function uiFun() { 'worklet'; runOnJS(jsFun)('I am from UI') } [代码] 使用shared共享数据 由worklet函数捕获的静态变量,会在编译期间序列化后生成在UI线程的拷贝环境之中,这就导致我们在JS线程中后续更新了变量,但是在UI线程中时得不到最新的数值的。 [代码]const obj = { name: 'skyline'} function someWorklet() { 'worklet' console.log(obj.name) // 输出的仍旧是 skyline } obj.name = 'change name' wx.worklet.runOnUI(someWorklet)() [代码] 因此shyline使用shared来实现线程之间数据的共享 [代码]const { shared, runOnUI } = wx.worklet const offset = shared(0) function someWorklet() { 'worklet' console.log(offset.value) // 输出的是新值 1 } offset.value = 1 runOnUI(someWorklet)() [代码] 简单案例–实现探探的卡片功能 注意:编辑器版本:1.06.2303162 基础库版本:2.30.2 先看效果 [图片] 代码如下 <br> wxml 代码 [代码]<navigation-bar title="探探" /> <view class="page"> <block wx:for="{{containers}}" wx:key="*this"> <pan-gesture-handler data-id="container-{{index}}" onGestureEvent="handlePan"> <view id="container-{{index}}" class="container" style="z-index: {{zIdnexes[index]}};background-image: url({{partContentList[index]}});"> </view> </pan-gesture-handler> </block> </view> [代码] scss代码 [代码].page{ display: flex; justify-content: center; align-items: center; height: 100vh; width: 100vw; position: relative; .container{ height: 80vh; width: 95vw; background-color: burlywood; position: absolute; border-radius: 16rpx; display: flex; justify-content: center; align-items: center; background-size: cover; .image{ display: block; height: 1067rpx; width: 712rpx; margin: 0 0; } } } [代码] 核心逻辑 [代码]import { useAnimation, setAni, Animation, GestureState } from "./method" Page<{ pos: Animation }, any>({ /** * 页面的初始数据 */ data: { containers: [ "burlywood", "blue", "cyan", "black" ], zIdnexes:[], current:0, partContentList:[] }, /** * 生命周期函数--监听页面加载 */ onLoad() { this.initNode() // 当前node的下标 this.active = wx.worklet.shared(0) // 当前contentList的下标 this.current = wx.worklet.shared(0) this.zIndex = 100000 }, initNode() { // 用与保存shared值 this.Nodes = {} // 图片文件 this.contentList = [ "https://i.hexuexiao.cn/up/ca/63/4a/a32912fc26b8445797c8095ab74a63ca.jpg", "https://th.bing.com/th/id/OIP.kSrrRGx6nqOgWzbaEvVD9AHaNK?pid=ImgDet&rs=1", "https://img.zmtc.com/2019/0806/20190806061552744.jpg", "https://img.zmtc.com/2019/0806/20190806061000600.jpg", "https://img.ratoo.net/uploads/allimg/190523/7-1Z5231J058.jpg", "https://th.bing.com/th/id/R.47de9dfcc25d579d84850d4575d24a6a?rik=%2fGkmrewzIEY4Iw&riu=http%3a%2f%2fimg3.redocn.com%2ftupian%2f20150930%2fqizhimeinvlisheyingtu_5034226.jpg&ehk=rG9Ks2QRzj81mZl38gVGmWVAgCHVLWppoDezpfwdxjo%3d&risl=&pid=ImgRaw&r=0", "https://th.bing.com/th/id/R.95f8e6f6bd5b660ae3ad4f3e0d712276?rik=ELKcha%2bE5ryuiw&riu=http%3a%2f%2f222.186.12.239%3a10010%2fwlp_180123%2f003.jpg&ehk=mVN7AzIRR%2fmVPJYWrWOFbEiher3QWtwSdH%2f%2fe4lE7n8%3d&risl=&pid=ImgRaw&r=0" ] this.data.containers.forEach((_: string, index: number) => { if (index == 0) { this.Nodes[`#container-${index}`] = useAnimation(`#container-${index}`, { x: 0, y: 0 }, this) this.setData({ [`zIdnexes[${index}]`]:100000-index, [`partContentList[${index}]`]:this.contentList[index] }) } else { console.log("10123") this.Nodes[`#container-${index}`] = useAnimation(`#container-${index}`, { x: 0, y: 20, scale: 0.95 }, this) this.setData({ [`zIdnexes[${index}]`]:100000-index, [`partContentList[${index}]`]:this.contentList[index] }) } }); }, handlePan(evt: any) { "worklet"; console.log(evt) const now = this.Nodes[`#container-${this.active.value}`] as Animation const next = this.Nodes[`#container-${(this.active.value+1)%4}`] as Animation if (evt.state == GestureState.ACTIVE) { // 滑动激活状态 // 设置当前的滑动块 now.x.value += evt.deltaX now.y.value += evt.deltaY now.rotate.value = now.x.value * 10 / 360 // 设置下一个滑动块 let rate = Math.abs(now.x.value) / 150 rate = rate > 1 ? 1 : rate next.y.value = (20 - rate * 20) < 0 ? 0 : (20 - rate * 20) next.scale.value = 0.95 + rate * 0.05 } if (evt.state == GestureState.END) { // 滑动结束 if (Math.abs(now.x.value) < 150) { // 判断是否超过界限值 setAni(now.x, 0) setAni(now.y, 0) setAni(now.rotate, 0) } else if (now.x.value < 0) { // 判断判断左划还是右划 setAni(now.x, -2000) setAni(now.y, -2000) setAni(now.rotate, 0) // 通知js线程进行数据的更新 wx.worklet.runOnJS(this.toNext.bind(this))() } else if (now.x.value > 0) { setAni(now.x, 2000) setAni(now.y, -2000) setAni(now.rotate, 0) wx.worklet.runOnJS(this.toNext.bind(this))() } } }, // 将当前序号的跳转到下一个 toNext(){ const current = this.current.value+1 this.active.value = current%4 this.current.value = current this.setData({ current }) if(current-2>=0){ wx.worklet.runOnUI(this.toReset)((current-2)%4) this.setData({ [`zIdnexes[${(current-2)%4}]`]:99998-current, [`partContentList[${(current-2)%4}]`]:this.contentList[current+2] }) } }, // 将动画归位 toReset(index:number){ "worklet"; const reset = this.Nodes[`#container-${index}`] as Animation setAni(reset.x, 0,0) setAni(reset.y, 20,0) setAni(reset.rotate, 0,0) setAni(reset.scale, 0.95,0) } }) [代码] 参考 skyline worklet 动画
2023-03-20 - 小程序开发新能力解读
这个月小程序释放了什么新能力?又有哪些新规则?收藏课程,及时了解小程序开发动态,听官方为你解读新能力。
2023-01-17 - 微信开发者工具使用 ESLint 插件的正确方法分享
看到很多帖子反馈开发者工具中的 ESLint 插件无法生效,自己测试也确实如此。 ESLint 插件错误提示怎么生效?小程序中如何使用eslint进行代码的实时检测 经过研究发现,最核心的一个点在于:要安装与小程序中的 ESLint 插件相匹配的 ESLint 版本。 解决过程如下: 1. 首先查看小程序中 ESLint 的插件版本为 v2.1.16,查看该插件的 版本日志 发现:最后一次正式版(v2.1.10)的发布日期是 2020 年 10 月 12 日。 [图片][图片] 2. 然后查看 ESLint 版本日志 中该日期附近的版本,比较接近是 2020 年 10 月 9 号发布的 v7.11.0 版本。 [图片] 3. 项目安装 ESLint v7.11.0 版本,并进行配置(查看 ESLint 教程,此处不赘述)。 npm i eslint@7.11.0 --save-dev [图片] 4. 重启工具,验证可用! [图片]
2023-02-16 - IOS scroll-view中的自定义组件fixed问题
这个是正常现象,因为 iOS 下加了 -webkit-overflow-scrolling: touch,这个会产生滚动惯性,体验更好,但会改变 fixed 的行为,建议不在 scroll-view 里有 fixed 元素
2020-04-23 - WebAudioContext 和 InnerAudioContext 区别?
WebAudioContext 和 InnerAudioContext 区别
2022-02-17 - wx.createInnerAudioContext()
[图片] [图片] 真机和体验版innerAudioContext.onError报错set audio src "wxfile://tmp_218fb3930ab36d39ac21d39e4dfc1121.mp3" fail: undefined is not an object (evaluating 't.duration')
2022-11-17 - 【手把手喂饭】Behavior教程:如何给每个页面混入统一的分享-share?
1、创建一个 js文件,我放在了个根目录下的 utils 里面 ,你也可以放在 behavior文件夹下(官方示例)取名为 share.js [图片] share.js 代码如下: let title = '你的默认分享主题' let imageUrl= '你的默认分享图片地址' let path = 'pages/index/index' // 默认放了个首页 module.exports = Behavior({ methods: { onShareAppMessage() { return { title, path, imageUrl } }, onShareTimeline() { return { title, path, imageUrl } } } }) 有没有很简单?我自己的 还加入了 默认的分享链接,还能知道是谁分享的 (大家可忽略) let userInfo = getApp().globalData.userInfo path = 'pages/index/index?share=' + userInfo._id 具体使用 ,在页面中引入 share.js,再配置一下就好 [图片] import share from "../../utils/share" Page({ // options: { // pureDataPattern: /^_/ // 指定所有 _ 开头的数据字段为纯数据字段 // }, behaviors: [share], // 混合 data: { 喂饭完毕~
2023-02-03 - 云托管部署时,提示已经在机器上了,但其实是新建版本的,有知道是怎么回事的吗?
查看部署日志,发现有一句:Container image "ccr.ccs.tencentyun.com/tcb-......:smile-score-app-007-20220214104808" already present on machine; Back-off restarting failed container; Readiness probe failed: dial tcp 10.0.64.15:8080: connect: connection refused; ] 现在这个版本的状态是 状态 端口异常
2022-02-14 - 云托管部署失败?
环境id:prod-1gre0jx010022bea;服务名称:express-bb96;版本:express-bb96-006;状态:构建失败
2022-04-25 - InnerAudioContext 调用destroy方法后内存为什么没有释放?
按顺序播放多个歌曲,打开调试后,发现内存一直上涨,并不会降下来。 每次播放下一首歌曲前,都会调用destroy销毁上一首歌曲。
2022-12-02 - 自定义组件如何获取外部文件?
[图片]获取不到fileurl
2022-05-30 - slider能不能在指定位置上加一个点?
用slider做音乐播放器,然后音乐有免费时长要在滑动条上加一个断点,怎么能根据音频时长在滑动条上加一个点呢?
2022-05-18 - Skyline|小程序吸顶、网格、瀑布流布局都拿下~
在之前的文章中,我们知道了新 scroll-view 可以让小程序的长列表做到丝滑滚动~ 也提到了新 scroll-view 提供了很多新能力 sticky、网格布局、瀑布流布局等,这一篇,我们就来看看这些新能力是怎么使用的~ 新 scroll-view 在原来列表模式(type="list")的基础上,新增了自定义模式(type="custom") 在自定义模式下,新增了以下新组件供开发者调用 list-view:列表布局容器sticky-section / sticky-header:吸顶布局容器grid-view:网格布局容器,可实现网格布局、瀑布流布局等sticky布局sticky 布局即在应用中常见的吸顶布局,与 CSS 中的 position: sticky 实现的效果一致,当组件在屏幕范围内时,会按照正常的布局排列,当组件滚出屏幕范围时,始终会固定在屏幕顶部。 常见的使用场景有:通讯录、账单列表、菜单列表等等。 与 position: sticky 不同的是,position: sticky 很难实现列表滚动需要的交错吸顶效果,而 sticky 组件则可以帮忙开发者轻松实现交错吸顶的效果。 sticky 的使用非常简单: 将 scroll-view 切换到 custom 模式采用 sticky-section 作为 scroll-view 的子元素sticky-header 放置吸顶内容list-view 放置列表内容 {{item.name}} ... 我们来看下采用 sticky 布局做出来的通讯录效果~ [视频] sticky 布局也可以通过给 sticky-section 配置 push-pinned-header 来声明吸顶元素重叠时是否继续上推 像下图输入框和标签列表这种类型,标签列表吸顶时还是希望保留输入框吸顶。 [视频] 网格布局网格布局即将列表切割成格子,每一行的高度固定,常见的视频列表、照片列表等通常都采用网格布局。 在此之前,实现网格布局需要开发者自行实现网格切割,再嵌入到 scroll-view 中。 新 scroll-view 直接提供了 grid-view 组件供开发者使用~ 将 scroll-view 切换到 custom 模式采用 grid-view 类型为 aligned 做为直接子节点grid-view 中直接编写列表 ... 下面是使用网格布局实现的图片列表效果~ [视频] 瀑布流布局瀑布流布局与网格布局类似,不同的是瀑布流布局中每个格子的高度都可以是不一致的,所以在小程序中实现瀑布流布局就比较复杂了。 开发者需要通过计算格子高度,然后再进行瀑布流拼接,当滚动内容过多时还需要处理节点过多导致内存不足等问题。 grid-view 组件直接支持了瀑布流模式供开发者直接使用,grid-view 组件会根据子元素高度自动布局: 将 scroll-view 切换到 custom 模式采用 grid-view 类型为 masonry 做为直接子节点grid-view 中直接编写列表 ... 下面是使用瀑布流布局实现的图片列表效果~ [视频] 想要立即体验?现在通过微信开发者工具导入 代码片段,即可体验新版 scroll-view 组件能力~
2023-08-03 - 视频号的推荐机制是不是有问题
视频号短视频不喜欢可以长按视频,点不感兴趣,然后会减少播放此类视频;不喜欢作者,可以点头像,不看对方动态,结果倒好,不点还好,越点越多。我真的没搞明白,目前我点一个,就给我推荐三个。我只能说,算你牛逼。本人不是针对任何人,就是不喜欢看一些跟自己无关的信息而已,结果你这么玩我,点到我怀疑人人生,一个号三只羊,35个号105个羊了,数羊羊呢?这种推荐机制是不是得好好处理下? 目前,我发现,当天是真的越点不看推荐越多,隔了几天后,好像是好多了,但是,你这也不合理啊。太影响体验了。 [图片]
2023-01-01 - wxs使用正则表达式
**.xws中不能直接new RegExp()或者 var reg = / / 字面量声明正则表达式 通过内置方法 getRegExp() 这是将手机号分割为 3-4-4的格式的案例 function splitPhone(mobile) { if (mobile.length !== 11) return mobile; var mobileReg = getRegExp("(?=(\d{4})+$)", "g"); return mobile.replace(mobileReg, "-"); }
2022-12-26 - 怎么在小程序里面实现点按翻译
首发于公众号 iKeepLearn [图片] #0 English Podcast (原 BBC English Podcast)一直没有怎么维护。最近刚好有时间,就登录后台看看用户反馈。有没有什么值得更新的。其中呼声最高的就是希望加入翻译功能。所以就先实现这一点吧。 #1 确定需求后,就先搜一下其他小程序,看看他们实现的效果。中国日报社和微信研究院联合推出的每日英文电台就刚好有这个功能。试了一下效果不错。所以这个需求点是可实现的。 那怎么实现呢?毕竟小程序里面不像网页和 APP 那样支持长按选择文字并弹出自定义菜单项,官方支持选择文字的就 text 组件。但没有相关的 api 去获取选定的内容? 在搜索了一圈后也没有找到很好的实现,那就只好自己来实现。 #2 思考了一下,只有用常见的支持绑定点击事件的 view 了。思路是先把英文句子按单词分割,然后每一个单词绑定相应的事件。这样就能获取点按的单词了,再调用接口去获取翻译内容。 所以数据结构按这样设计 [图片] 思路确定后,相关编码就很简单了。 wxml文件和wxss文件 [图片] #3 在找翻译接口的时候,有谷歌,必应、百度和有道作为备选。最后思考了一下还是选了微信官方的接口。这样可以避免关键字检测? 本来在微信服务市场找到的英中翻译接口是最适合需求的,因为不需要服务器调用可以直接在小程序客户端里使用。只是按文档接入后提示出错了。只好选择多语言翻译接口。 [图片] #4 最后可以扫码下方的小程序体验点按翻译 [图片]
2022-12-19 - 微信支付分账和退款时的踩坑记录
先添加分账收款方: 1.可以是appId对应的个人openid 2.或者其他商户mchId 分账比例默认30%,由发起分账方申请或者设置。 申请更高分账比例,可以参考帖子进行申请 https://developers.weixin.qq.com/community/develop/article/doc/00042e3a5b4d78f5f06bcdfb951c13 分账的订单支付后资金进入冻结状态 注意:实际可分配的冻结资金需要减掉微信手续费(默认0.6%) 总分账金额不能大于订单金额的30% 微信端对于分账订单退款的处理: 1.订单未分账,直接从冻结金额中退款 2.订单部分分账 若退款金额小于剩余冻结金额,直接从剩余冻结中退款 若退款金额大于剩余冻结金额,需要先解冻剩余金额,并从商户余额中退款。 3.订单分账已完成,直接从商户余额中退款。 先发生退款后分账的订单,需要注意 1.总分账金额不能大于订单金额的30% 2.可分账金额=(订单金额-已退款金额)*(1-0.6%);减掉微信手续费
2022-12-27 - Skyline|长列表也可以丝滑~
[图片] [图片] 对于长列表出现的白屏、卡顿、界面跳动等问题,小程序提供了新 scroll-view 来解决这一系列问题。我们先来看看效果~ 快速滚动效果对比我们通过一组长列表来展示新旧 scroll-view 在快速滚动下的效果对比。 当长列表快速滚动时,旧 scroll-view 容易出现白屏的情况,新 scroll-view 则不会出现白屏。 左:旧 scroll-view、右:新 scroll-view [视频] 在安卓机器快速滚动过程中,旧 scroll-view 反应卡顿,容易出现手指离开操作时,滚动动画还在进行。 而新 scroll-view 则可以保持界面滚动效果跟随手指,停止滚动则缓慢结束动画效果。 左:旧 scroll-view、右:新 scroll-view ,测试机型:Xiaomi MIX 3 [视频] 反向滚动效果对比在对话等场景下,反向滚动是常见的功能,旧 scroll-view 并没有提供反向滚动的能力,我们来看看旧 scroll-view 下是怎么完成反向滚动的~ 在对话数据在加载的时候,对话列表需要在更新完列表数据之后,再使用 scroll-into-view 或者 scroll-top 来保持当前滚动位置,因为设置滚动位置会有延迟,所以容易出现 界面跳动 的情况。 // .js // scroll-view 滚动到顶部时触发 bindscrolltoupper() { // 先更新列表数据 this.setData({ recycleList: getnewList() }, () => { // 更新完数据后再设置滚动位置 this.setData({ scrollintoview: scrollintoview }) }) } 为了解决界面跳动的问题,社区上也有通过翻转的方法来解决,将 scroll-view 与 scroll-view 的子元素进行翻转。 // .wxss .reserve { transform: rotateX(180deg); } // .wxml 然而进行翻转之后,会遇到手指滚动方向与列表滚动方向相反、scroll-into-view 属性无效等问题。 为了帮开发者们解决反向滚动类列表的一系列问题,新 scroll-view 直接提供了 reverse 属性支持反向滚动的能力,滚动效果更加顺滑。 左:旧 scroll-view、右:新 scroll-view(图片加载期间,GIF 渲染较慢) [视频] 怎么接入新 scroll-view ?新的 scroll-view 使用起来很简单,主要有以下两个步骤: 修改小程序配置scroll-view 增加 type="list"// app.json // "renderer": "skyline" 开启之后所有页面会变成自定义导航,可参考 https://developers.weixin.qq.com/s/Y5Y8rrm37qEY 实现自定义导航 // 也可在 page.json 中配置 "renderer": "skyline" 逐个页面开启 { ... "lazyCodeLoading": "requiredComponents", "renderer": "skyline" } // page.json { ... "disableScroll": true, "navigationStyle": "custom" } // page.wxml ... // 反向滚动 新的 scroll-view 从安卓 8.0.28 / iOS 8.0.30 开始支持,如需兼容低版本需要进行兼容处理。 wx.getSkylineInfo({ success(res) { if (res.isSupported) { // 使用新版 scroll-view } else { // 使用旧版 scroll-view } } }) 如需体验长列表效果,可在微信开发者工具导入该代码片段即可体验:https://developers.weixin.qq.com/s/Y5Y8rrm37qEY 更多接入详情请参考文档 怎么做到的?大家肯定好奇为什么新 scroll-view 可以解决这个头疼的问题呢? 我们来对比一下新旧 scroll-view 有什么区别就可以知道答案啦~ 旧 scroll-view 逻辑层与渲染层的通信需要通过 JSBridge 进行转换,需要一定的时间开销渲染采用异步分块光栅化,当渲染赶不上滚动的速度,来不及渲染则会出现白屏渲染大量节点内存占用高,需要开发者自行优化只渲染在屏节点,开发成本高新 scroll-view 逻辑层与渲染层的通信无需通过 JSBridge 进行转换,减少了大量通信时间开销渲染采用同步光栅化,滚动与渲染在同一线程,不会出现白屏针对长列表进行优化,只渲染在屏节点,内存占用低,减少了一些渲染耗时,且开发接入成本低[图片] 除此之外,新 scroll-view 后续将提供 type="custom" 支持 sticky 吸顶效果、网格布局、瀑布流布局等能力便于开发者接入使用~
2023-08-03 - 节流与防抖分享
常用函数 2个 不废话 直接上 代码: /** * 节流原理:在一定时间内,只能触发一次 * * @param {Function} func 要执行的回调函数 * @param {Number} wait 延时的时间 * @param {Boolean} immediate 是否立即执行 * @return null */ let timer, flag; function throttle(func, wait = 500, immediate = true) { if (immediate) { if (!flag) { flag = true; // 如果是立即执行,则在wait毫秒内开始时执行 typeof func === 'function' && func(); timer = setTimeout(() => { flag = false; }, wait); } } else { if (!flag) { flag = true // 如果是非立即执行,则在wait毫秒内的结束处执行 timer = setTimeout(() => { flag = false typeof func === 'function' && func(); }, wait); } } }; /** * 防抖原理:一定时间内,只有最后一次操作,再过wait毫秒后才执行函数 * * @param {Function} func 要执行的回调函数 * @param {Number} wait 延时的时间 * @param {Boolean} immediate 是否立即执行 * @return null */ let timeout = null; function debounce(func, wait = 500, immediate = false) { // 清除定时器 if (timeout !== null) clearTimeout(timeout); // 立即执行,此类情况一般用不到 if (immediate) { var callNow = !timeout; timeout = setTimeout(function() { timeout = null; }, wait); if (callNow) typeof func === 'function' && func(); } else { // 设置定时器,当最后一次操作后,timeout不会再被清除,所以在延时wait毫秒后执行func回调方法 timeout = setTimeout(function() { typeof func === 'function' && func(); }, wait); } } 常用函数 2个 不废话 直接上 代码: /** * 节流原理:在一定时间内,只能触发一次 * * @param {Function} func 要执行的回调函数 * @param {Number} wait 延时的时间 * @param {Boolean} immediate 是否立即执行 * @return null */ let timer, flag; function throttle(func, wait = 500, immediate = true) { if (immediate) { if (!flag) { flag = true; // 如果是立即执行,则在wait毫秒内开始时执行 typeof func === 'function' && func(); timer = setTimeout(() => { flag = false; }, wait); } } else { if (!flag) { flag = true // 如果是非立即执行,则在wait毫秒内的结束处执行 timer = setTimeout(() => { flag = false typeof func === 'function' && func(); }, wait); } } }; /** * 防抖原理:一定时间内,只有最后一次操作,再过wait毫秒后才执行函数 * * @param {Function} func 要执行的回调函数 * @param {Number} wait 延时的时间 * @param {Boolean} immediate 是否立即执行 * @return null */ let timeout = null; function debounce(func, wait = 500, immediate = false) { // 清除定时器 if (timeout !== null) clearTimeout(timeout); // 立即执行,此类情况一般用不到 if (immediate) { var callNow = !timeout; timeout = setTimeout(function() { timeout = null; }, wait); if (callNow) typeof func === 'function' && func(); } else { // 设置定时器,当最后一次操作后,timeout不会再被清除,所以在延时wait毫秒后执行func回调方法 timeout = setTimeout(function() { typeof func === 'function' && func(); }, wait); } }
2022-12-04 - 【实战记录】关于实现聊天室语音播放问题的一次曲线救国的记录
需求背景: 该项目为互联网医院小程序,其中重要的功能为在线问诊,需要建立医患聊天的功能,故本项目集成了融云sdk即时通讯单聊功能。 问题: 接了融云的sdk后,发文字,图片及自定义消息都是那么的丝滑,但在上线阶段,发现微信小程序真机中部分语音播放不了;通过分析定位,发现由于 wx.createInnerAudioContext(),部分语音在ios真机中会报INNERERRCODE:-11800, ERRMSG:这项操作无法完成。通过和融云技术一顿沟通,也在查找社区文档后发现,早在2021年3月份,有人也提了这个问题,也没有找到官方的相关反馈,并且我自己提了bug(https://developers.weixin.qq.com/community/develop/doc/0004224a4c09c0553ddd1c09657800),也没有下文了。没办法,项目上线迫在眉睫,只能换其他技术替代方案了 解决方案: 为兼容wx.createInnerAudioContext()报错,在onError时,保存需要通过uni.getBackgroundAudioManager()来播放的音频列表 this.audio = uni.createInnerAudioContext(); this.audio.src = this.src; this.audio.onPlay(() => { this.audio.isPlaying = true; this.animate = true; this.timer = setInterval(() => { this.animate = false setTimeout(() => { this.animate = true }, 50) }, 1250); this.audio.onStop(() => { this.audio.isPlaying = false; this.animate = false; this.timer && clearInterval(this.timer) }) this.audio.onEnded(() => { this.audio.isPlaying = false; this.animate = false; this.timer && clearInterval(this.timer) }) }) this.audio.onError((err) => { this.$bgAudioList.push({ messageUId: this.messageUId, animate: false, }) // 所有需要通过背景音乐播放的存入全局 this.useBackgroundAudioManager = true; }) this.$audioList.push(this.audio)//所有实例加入全局变量 效果如下: [图片]
2022-05-06 - 苹果手机的音频播放无声,报错啦,基础库更新前是正常的,怎么整?
苹果手机的音频播放无声,报错啦: {message: "undefined is not an object (evaluating 'r.failCallbacks')", line: 1, column: 840045, sourceURL: "https://lib/WAServiceMainContext.js", stack: "@https://lib/WAServiceMainContext.js:1:840045"} 此前是正常的,帮忙看一下是哪里出错了呀? 查看了一下公告:小程序基础库 2.28.0 更新(2小时前) 更新 API 音频接口优化一优化,就有事,真是难受啊。安卓手机能正常播放。
2022-11-28 - 开通虚拟支付的资质不全,但是我们已经上线好几个月,并且资质是齐全的
appid:wxc0c12c04e8fadd14 我们的小游戏今天收到封禁通知,提醒开通虚拟支付的资质不全,但是我们已经上线好几个月,并且资质是齐全的,上传资质申诉以后还是被驳回,麻烦看一下具体缺的是什么资质 [图片]
2022-11-24 - 如何实现动态生成好友分享图
本文章采用官方最新的 Canvas.createImage() 来实现下动态生成好友分享图,可以拿来即用。展示效果如下(其中蓝框中文案和红框头像为插入的文本、图像。背景图也支持动态更换): [图片] 小程序demo案例:https://developers.weixin.qq.com/s/nJtr4QmL7RD3 一、市面案例缺陷:翻阅了目前市面上的小程序动态生成好友分享图,大部分还是使用已废弃的『wx.createSelectorQuer』接口来实现。目前小程序已无法很好支持。 二、主要有以下几个关键点需要注意下: 做好友分享图要考虑5:4的比例使用 wx.getImageInfo 一定要考虑图片失败的场景,然后采用兜底图片。相同的逻辑在complete中执行。要区分 wx.createImage ,这个是小游戏用来创建图片对象的。小程序要用Canvas.createImage()。也不是使用 new Image!!!使用的时候一定要在 canvas 类型中注明 type 是 2d 的 canvas[图片] 三、优化知识点: 如何用 async in image loading:https://stackoverflow.com/questions/46399223/async-await-in-image-loading ----- 采用await img.decode() 或者 img.onload = () => resolve() 如何隐藏Canvas:https://developers.weixin.qq.com/community/develop/doc/1aadfacdd9f38584881e0c50db2bcda1 ----- position:fixed;left:100%;
2023-06-19 - 微信小程序高级指南-基于miniprogram-template模版开发小程序
介绍 miniprogram-template是一个快速开发小程序的解决方案,它基于 vant-weapp 实现。它使用了小程序目前支持的最新配置和 api,内置了 eslist + prettier 代码规范,husky + lint-staged Git 提交代码规范验证,提供了丰富的功能组件,它可以帮助你快速搭建企业级小程序产品原型,希望本项目都能帮助你敏捷开发企业需求。 建议 本项目的定位是小程序开发模版,适合当基础模板来进行二次开发,公共组件指在各种类型的小程序中都会使用到,后续会持续迭代,欢迎提 issues。 使用案例 官方示例 Fabrique 精品店 番茄博客园 [图片] [图片] [图片] 预览 扫描下方小程序二维码,体验小程序模版示例: [图片] 功能 [代码]- tabBar放置在主包中, 其他页面放置到对应的分包中 - 多环境发布 - dev test pre prod - 组件 - 断网 - iconfot字体图标 - 图片 - 导航栏 - 富文本 - 全局配置 - eslist + prettier 代码规范 - husky + lint-staged git提交代码规范验证 - 支持scss语法[ less 语法需更改配置 ] - 初始化获取已包含 networkType、isConnected、systemInfo - npm 脚本设置环境变量,读取多种环境信息,基于 miniprogram-ci 实现自动化上传代码 - 工具类在 utils 文件夹中 - 路由表包含所有页面涉及交互跳转统一读取路由表路径,需个人维护 - 配置文件在 config 文件夹中 - 数据请求在 api 文件夹中 - 小程序发布后提示更新 [代码] 前序准备 你需要在本地安装 node 和 git。本项目技术栈基于 ES2015+、vant-weapp和dayjs,提前了解和学习这些知识会对使用本项目有很大的帮助。 目录结构 本项目已经为你生成了一个完整的开发框架,提供了涵盖小程序开发的各类封装和规范,下面是整个项目的目录结构。 [代码]├── README.md ├── api │ ├── content-service.js │ └── user-service.js ├── app.js ├── app.json ├── app.scss ├── assets │ ├── images │ └── styles ├── components │ ├── custom-broken-network │ ├── custom-iconfont │ ├── custom-image │ └── custom-nav-bar ├── config │ ├── development.js │ ├── env.js │ ├── index.js │ ├── local.js │ ├── preview.js │ ├── production.js │ └── test.js ├── miniprogram-ci.js ├── miniprogram_npm │ ├── @vant │ └── dayjs ├── package.json ├── packageA │ └── logs ├── pages │ ├── home ├── private.wx2f3fed2106f72ceb.key ├── project.config.json ├── project.private.config.json ├── sitemap.json ├── switch-env.js ├── utils │ ├── request.js │ ├── router.js │ ├── util.js │ └── wxs.wxs └── yarn.lock [代码] 安装 [代码]# 克隆项目 git clone https://github.com/zhihuifanqiechaodan/miniprogram-template.git # 进入项目目录 cd miniprogram-template # 安装依赖 yarn install # 小程序编辑器-工具-构建 # 编译刷新 [代码] TIP 强烈建议使用 yarn 安装依赖,避免使用 npm 或者 cnpm 安装,可能会有各种诡异的 bug。 完成上述安装 构建 编译后即可看到小程序内容,当你看到下面的页面说明你操作成功了。 [图片] 接下来你可以修改代码进行业务开发了,本项目内建了常用公共组件、全局路由管理等等各种实用的功能来辅助开发,你可以通过查看已有的工具类和封装方法来使用。 建议 使用前建议将目前项目中已有的配置和文件夹工具类先行查看一番,方便后续使用,其次小程序路由和跳转都进行了封装,方便统一管理,后续需要自行维护。 公共组件 关于公共组件的介绍和使用请查看对应组件文件夹下的 README.md 其它 基于miniprogram-template模版开发上线的小程序已有多个,可参考 Fabrique 精品店 / 番茄博客园等。 对于一些小程序开发中常遇到的问题和解决方案欢迎讨论。 欢迎您提供宝贵的意见和建议,也欢迎提 issues 增加和修改功能或组件,另外如果可以的话请给个 start,感谢~ 更新日志 v1.0.3(20221116) 新增 custom-video 公共组件, 封装微信小程序原生 video 标签,单例模式,解决视频播放黑屏,多视频播放混音,视频列表存在多个视频同时播放,自定义 UI 样式等等,目前支持属性配置,如需扩展其他原生功能可直接修改组件添加属性。 components 文件夹下的公共组件统一增加 README.md 说明文件。 新增 custom-video 演示页面。 custom-image 公共组件优化。 v1.0.2(20221028) 新增 custom-rich-text 公共组件,基于 mp-html封装,目前支持识别富文本以及 markdown 格式内容如需其他插件功能,可查看 mp-html 文档,通过配置打包后将生成的 mp-weixin 文件夹放置到 components 文件件中覆盖原有的 mp-weixin 文件夹 v1.0.1 (20221020) 新增 custom-image 公共组件,属性同 van-image,图片裁剪模式同原生小程序 image 组件的 mode 属性。 新增 custom-iconfont 公共组件,支持设置大小,颜色,图标(需要在/assets/styles/iconfont.scss 文件中提前引入使用的 iconfont),支持接收组件外部样式 external-iconfont。
2022-11-16 - 微信小程序中安全区域计算和适配
前言 自从iphoneX问世之后,因为iphoneX、iphoneXR和后续全面屏手机设备,因为物理Home键被底部小黑条代替了,这时候很多前端小伙伴在开发的过程都会遇到 “全面屏”和“非全面屏”的兼容性问题,普遍问题就是底部按钮或者选项卡与底部黑线重叠 解释 根据官方解释: 安全区域指的是一个可视窗口范围,处于安全区域的内容不受圆角(corners)、齐刘海(sensor housing)、小黑条(Home Indicator)的影响。 具体区域如图展示 [图片] 适配方案 当前有效的解决方式有几种 使用已知底部小黑条高度34px/68rpx来适配 使用苹果官方推出的css函数env()、constant()适配 使用微信官方API,getSystemInfo()中的safeArea对象进行适配 使用已知底部小黑条高度34px/68rpx来适配 这种方式是根据实践得出,通过物理方式测出iPhone底部的小黑条(Home Indicator)高度是34px,实际在开发者工具选中真机获取到高度也是34px,所以直接根据该值,设置margin-bottom、padding-bottom、height也能实现。同时这样做要有一个前提,需要判断当前机型是需要适配安全区域的机型。 但是这种方案相对来说是不推荐使用的。比较是一个比较古老原始的方案 使用苹果官方推出的css函数env()、constant()适配 这种方案是苹果官方推荐使用env(),constant()来适配,开发者不需要管数值具体是多少。 env和constant是IOS11新增特性,有4个预定义变量: safe-area-inset-left:安全区域距离左边边界的距离 safe-area-inset-right:安全区域距离右边边界的距离 safe-area-inset-top:安全区域距离顶部边界的距离 safe-area-inset-bottom :安全距离底部边界的距离 具体用法如下: Tips: constant和env不能调换位置 [代码] padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/ padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/ [代码] 其实利用这个能解决大部分的适配场景了,但是有时候开发需要自定义头部信息,这时候就没办法使用css来解决了 使用微信官方API,getSystemInfo()中的safeArea对象进行适配 通过 wx.getSystemInfo获取到各种安全区域信息,解析出具体的设备类型,通过设备类型做宽高自适应,话不多说,直接上代码 代码实现 [代码] const res = wx.getSystemInfoSync() const result = { ...res, bottomSafeHeight: 0, isIphoneX: false, isMi: false, isIphone: false, isIpad: false, isIOS: false, isHeightPhone: false, } const modelmes = result.model const system = result.system // 判断设备型号 if (modelmes.search('iPhone X') != -1 || modelmes.search('iPhone 11') != -1) { result.isIphoneX = true; } if (modelmes.search('MI') != -1) { result.isMi = true; } if (modelmes.search('iPhone') != -1) { result.isIphone = true; } if (modelmes.search('iPad') > -1) { result.isIpad = true; } let screenWidth = result.screenWidth let screenHeight = result.screenHeight // 宽高比自适应 screenWidth = Math.min(screenWidth, screenHeight) screenHeight = Math.max(screenWidth, screenHeight) const ipadDiff = Math.abs(screenHeight / screenWidth - 1.33333) if (ipadDiff < 0.01) { result.isIpad = true } if (result.isIphone || system.indexOf('iOS') > -1) { result.isIOS = true } const myCanvasWidth = (640 / 375) * result.screenWidth const myCanvasHeight = (1000 / 667) * result.screenHeight const scale = myCanvasWidth / myCanvasHeight if (scale < 0.64) { result.isHeightPhone = true } result.navHeight = result.statusBarHeight + 46 result.pageWidth = result.windowWidth result.pageHeight = result.windowHeight - result.navHeight if (!result.isIOS) { result.bottomSafeHeight = 0 } const capsuleInfo = wx.getMenuButtonBoundingClientRect() // 胶囊热区 = 胶囊和状态栏之间的留白 * 2 (保持胶囊和状态栏上下留白一致) * 2(设计上为了更好看) + 胶囊高度 const navbarHeight = (capsuleInfo.top - result.statusBarHeight) * 4 + capsuleInfo.height // 写入胶囊数据 result.capsuleInfo = capsuleInfo; // 安全区域 const safeArea = result.safeArea // 可视区域高度 - 适配横竖屏场景 const screenHeight = Math.max(result.screenHeight, result.screenWidth) const height = Math.max(safeArea.height, safeArea.width) // 状态栏高度 const statusBarHeight = result.statusBarHeight // 获取底部安全区域高度(全面屏手机) if (safeArea && height && screenHeight) { result.bottomSafeHeight = screenHeight - height - statusBarHeight if (result.bottomSafeHeight < 0) { result.bottomSafeHeight = 0 } } // 设置header高度 result.headerHeight = statusBarHeight + navbarHeight // 导航栏高度 result.navbarHeight = navbarHeight [代码]
2022-11-04 - 小程序实用npm包推荐
虽然都说不要重复造轮子, 但还是屡见不鲜, 下面给大家推荐几款实用的小程序npm包, 欢迎各位同志评论区继续补充. 1.mobx-miniprogram, mobx-miniprogram-bindings, 小程序状态管理, 类似于vuex; mobx-miniprogram用于创建状态数据, mobx-miniprogram-bindings用于对页面或组件绑定状态数据;使用文档: https://github.com/wechat-miniprogram/mobx; import { observable, action } from 'mobx-miniprogram'; export const store = observable({ // 数据字段 numA: 1, numB: 2, // 计算属性 get sum() { return this.numA + this.numB; }, // actions update: action(function () { this.numA++; }), }); // 页面使用 import{ createStoreBindings }from'mobx-miniprogram-bindings' import{ store }from'./store' Page({ data:{ someData:'...' }, onLoad(){ // 绑定 this.storeBindings = createStoreBindings(this,{ store, fields:['numA','numB','sum'], actions:['update'], }) }, onUnload(){ this.storeBindings.destroyStoreBindings() }, }) 2.dayjs, 时间处理工具, 包含时间解析, 时间格式化, 时间比较等常用功能, 最重要的尺寸较小, 非常适合移动端来使用, 使用文档: https://dayjs.fenxianglu.cn/category/; dayjs().format('YYYY-MM-DD HH:mm:ss'); // 2022-10-27 13:50:12 dayjs().add(7, 'day') // 7天后 dayjs().isBefore(dayjs('2011-01-01')) // 是否在2011-01-01之前 3.mp-html, 富文本解析利器, 小程序提供的rich-text组件虽然可以解析富文本, 但存在若干缺陷: 1. 文字无法选择; 2. 链接无法跳转; 3.图片无法预览和自适应尺寸等, 使用mp-html可以很好解决上述问题, 使用文档: https://www.npmjs.com/package/mp-html; 1.安装npm npm i mp-html 2.在需要使用页面的 json 文件中添加 { "usingComponents": { "mp-html": "mp-html" } } 在需要使用页面的 wxml 文件中添加
2022-10-27 - 共享云环境时代来了,解决fileID带来不兼容问题。
云开发收费了,不管你怎么选择,只要你还继续使用云开发,共享云环境的课题就不可避免。 我们知道,共享云环境下,fileID是无法使用的,怎么兼容,一个最简的方法如下: <wxs module="wxs"> module.exports = { getUrl: function (link) { if (link) { } else return '' if (link.substring(0, 5) == 'cloud') { } else return link var arr = link.split('/') arr[0] = 'https:' arr[2] = arr[2].split('.')[1] + '.tcb.qcloud.la' return arr.join('/') } } </wxs> <image src="{{wxs.getUrl(link)}}"></image> 可见:只要将原项目所有的fileID换成wxs.getUrl(link) 其他代码可以一分不动,也不需要用到wx.cloud.getTempFileURL 可以将wxs.getUrl放在lib.wxs里,任何wxml引用即可。
2022-10-28 - JavaScript小技巧【建议收藏】
在JavaScript世界里,有些操作会让你无法理解,但是却无比优雅! 有时候读取变量属性时,他可能不是Ojbect。这个这个你就要判断这个变量是否为对象,如果是在如引用 [代码]var obj; if(obj instanceof Object){ console.log(obj.a); }else{ console.log('对象不存在'); } [代码] ES6中有简便写法,可以帮我们简短代码,从而更加明确 [代码]var obj; console.log(obj?.a || '对象不存在'); [代码] 1,2,3,4,5,6…10,11,12 小于10的前面补0 其实我们用slice函数可以巧妙解决这个问题 slice(start,end); start:必需。规定从何处开始选取。如果是负数,那么它规定从数组尾部开始算起的位置。也就是说,-1 指最后一个元素,-2 指倒数第二个元素,以此类推。 end :可选。规定从何处结束选取。该参数是数组片断结束处的数组下标。如果没有指定该参数,那么切分的数组包含从 start 到数组结束的所有元素。如果这个参数是负数,那么它规定的是从数组尾部开始算起的元素。 [代码]var list=[1,2,3,4,5,6,7,8,9,10,11,12,13]; list=list.map(ele=>('0' + ele).slice(-2)); console.log(list); [代码] [图片] 打印可视化console.table() [代码]var obj = { name: 'Jack' }; console.table(obj); obj.name= 'Rose'; console.table(obj); [代码] [图片] 获取数组中的最后的元素 数组方法slice()可以接受负整数,如果提供它,它将从数组的末尾开始截取数值,而不是开头。 [代码]let array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; console.log(array.slice(-1)); // Result: [9] console.log(array.slice(-2)); // Result: [8, 9] console.log(array.slice(-3)); // Result: [7, 8, 9] [代码] es6模板字符串 [代码]let name = "Charlse" let place = "India"; let isPrime = bit =>{ return (bit === 'P'? 'Prime' : 'Nom-Prime') } let messageConcat = `Mr.name' is form ${place} .He is a ${isPrime('P')} member`; [代码] H5语音合成 在网页端实现将指定的文字进行语音合成并进行播报。 使用HTML5的Speech Synthesis API就能实现简单的语音合成效果。 [代码]<input id="btn1" type="button" value="点我" onclick="test();" /> <script> function test() { const sos = `阿尤!不错哦`; const synth = window.speechSynthesis; let msg = new SpeechSynthesisUtterance(sos); synth.speak(msg) } </script> [代码] 然后点击按钮,就会触发test方法的执行实现语音合成 这里推荐使用Chrome浏览器,因为HTML5的支持度不同 数字取整 [代码]let floatNum = 100.5; let intNum = ~~floatNum; console.log(intNum); // 100 [代码] [图片] 字符串转数字 [代码]let str="10000"; let num = +str; console.log(num); // 10000 [代码] 交换对象键值 [代码]let obj = { key1: "value1", key2: "value2" }; let revert = {}; Object.entries(obj).forEach(([key, value]) => revert[value] = key); console.log(revert); [代码] [图片] 数组去重 [代码]let arrNum = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 ]; let arrString = [ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" ]; let arrMixed = [ 1, "1", "2", true, false, false, 1, 2, "2" ]; arrNum = Array.from(new Set(arrNum)); arrString = [...new Set(arrString)]; arrMixed = [...new Set(arrMixed)]; console.log(arrNum); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] console.log(arrString); // ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] console.log(arrMixed); // [1, "1", "2", true, false, 2] [代码] 数组转化为对象 [代码]const arr = [1,2,3] const obj = {...arr} console.log(obj) [代码] 合并对象 [代码]const obj1 = { a: 1 } const obj2 = { b: 2 } const combinObj = { ...obj1, ...obj2 } console.log(combinObj) [代码] 也就是通过展开操作符(spread operator)来实现。 获取数组中的最后一项 [代码]let arr = [0, 1, 2, 3, 4, 5]; const last = arr.slice(-1)[0]; console.log(last); [代码] 一次性函数,适用于初始化的一些操作 [代码]var sca = function() { console.log('msg')//永远只会执行一次 sca = function() { console.log('foo') } } sca() // msg sca() // foo sca() [代码] 提高工作效率,减少代码量,降低键盘磨损程度 我希望你喜欢它并学到了一些新东西 感谢你的阅读,编程快乐!
2022-10-25 - 微信支付的订单分账后能否退款?
微信分账后,是否还可以退款?我需要的逻辑如下: 商户号收到10元,分账给服务商1元。然后客户申请退款,不进行分账回退。商户号直接退10元给客户。 有这个退款的接口吗?不分账回退,这个服务商的钱到了就不回退的。
2022-09-27 - 代开发小程序的第三方,该怎么调用物流助手的接口?
目前,只能先由小程序在后台绑定物流账号,第三方平台获知其biz_id并得到其授权后调用业务api。后续,将支持通过api绑定物流账号。
2019-12-30 - subscribeMessage.send errCode: -504002报错?
[图片]Error: cloud.callFunction:fail Error: errCode: -504002 functions execute fail | errMsg: TypeError: Do not know how to serialize a BigInt 调用订阅消息后测试消息可以发送成功,但是返回值报错Do not know how to serialize a BigInt,无法判断是否发送成功。 2022-05-01 解决方法:云函数的返回值不要返回result,直接返回result.errCode,就不会报错。原因是直接返回result被序列化导致报错(其实就是bug),所以返回result.errCode足以用于判断!
2022-05-01 - 【拎包哥】新版云开发获取手机号
在获取手机号时,新版的code(2022/03/28)取代了旧版的cloudID,整体代码变得更加简洁,可读。 下面,让我们跟着文档来学习吧。 1. 云开发配置 微信文档:getPhoneNumber云函数 [图片] 在config.json配置权限,并使用openapi接口的函数 [代码]exports.main = async (event) => { // 云函数/index.js return await cloud.openapi.phonenumber.getPhoneNumber({ code: event.code }) } -------- // 云函数/config.json { "permissions": { "openapi": [ "phonenumber.getPhoneNumber" ] } } [代码] 2. 获取code并使用云函数 微信文档:获取手机号 通过设置了open-type的<button>获取code,然后使用云函数解密得到手机号。 [代码]<button open-type="getPhoneNumber" bindgetphonenumber='phoneClk'>手机号码</button> --------- phoneClk(e) { const code = e.detail.code wx.cloud.callFunction({ name: '云函数', data: { code } }).then(res => { let phoneNumber = res.result.phoneInfo.phoneNumber }) } [代码] 总结 云开发获取手机号需要一定的前置知识,而且微信文档又属实有点抽象了嗷。来沈阳大街我头套把你薅一地,指定没你好果汁吃嗷。 新手需要了解 微信开放能力 <button> open-type 云函数使用及配置 openapi
2022-03-31 - 第三方服务平台,怎么申请通用的订阅消息模板?
每个帐号的行业类目不尽相同,仅可根据小程序类目,调用行业匹配的业务订阅需求模板消息。
2020-04-23 - 编译前预处理config文件,切换环境
背景: 小程序项目有“开发版,QA版,QAP版,正式版”多个环境,每个环境都与多个服务器有交互。 其中部分服务器访问需要basic认证。 因为一些特殊原因,basic密匙现在阶段只能写在前端。但是又规定git上的代码中不能有密匙明文。 以上问题导致切换环境时,HOST URL和其他常量能够自动切换,但是HOST URL对应的basic认证密匙,必须手动填写。开发过程如果频繁切换环境的话会非常繁琐。版本发布时,也需要手动补全basic密匙,风险较高。 解决方案: 发现开发者工具有自定义处理命令的功能,在编译前可以对文件预处理。 基于这个功能尝试了在每次编译前,使用脚本修改config.js文件。而脚本存储在本地不传git,规避了密匙上传的问题。 [图片] 脚本思路很简单,就是使用fs读取config.js,然后通过正则匹配replace相应变量,重写config.js env.js: [代码]const path = require("path"); const fs = require("fs"); const ENV = "dev" const BASIC_AUTH = { dev: "**********", qa: "**********", prod: "**********", qap: "**********", } // 配置文件地址 let pathName = path.join(__dirname, "config.js") // 读取文件 格式为utf-8 fs.readFile(pathName, { encoding: "utf-8" }, function (err, data) { // 正则替换变量 data = data.replace(new RegExp("BASIC_AUTH:.*'"), `BASIC_AUTH: '${BASIC_AUTH[ENV]}'`) fs.writeFile(pathName, data, function (err) { console.log(err); }) }) [代码] 总结: 其他环境变量的切换也可以用相同方式完成。至此,每次环境的切换只需要修改env.js中 ENV的值,点击开发者工具编译按钮,就可以完成。 [图片] 考虑过一些其他思路,比如用云函数从云数据库中拿到密匙,保存在全局变量中,但是考虑到这种方式需要修改线上代码,且可能存在一些异步的问题,所以没有使用。如果有更好的解决方案,欢迎回帖~~
2022-07-28 - 微信小程序,使用云函数实现发布短信
使用云函数发送短信验证码前端代码:wxml: <!-- 手机号 --> <view class="kaidian-ziliao-content flex"> <view class="ziliao-content-name xing">手机号码</view> <input type="text" placeholder="请输入手机号码" placeholder-style="font-size:26rpx;" value="{{mobile}}" style="font-size: 26rpx;" bindinput="mobile" /> </view> <!-- 验证码 --> <view class="kaidian-ziliao-content flex"> <view class="ziliao-content-name xing">验 证 码</view> <view class="ziliao-content-right flex"> <input type="text" placeholder="请输入验证码" value="{{codeInput}}" placeholder-style="font-size:26rpx;" bindinput="codeInput" value="{{codeInput}}" style="font-size: 26rpx; width: 240rpx;" /> <view class="fasongCode" bindtap="{{!maxTimeOnly ? 'mobileCode' : ''}}" style="font-size: {{maxTimeOnly ? '22rpx' : '24rpx'}};">{{maxTimeMsg}}</view> </view> </view> js:点击验证码按钮 // 手机号码验证 var code = 'aaa' //生成验证码,用来发送短信验证,这个code一定要放到全局,否则发送到手机上就是undefined page({ data:{ codeInput:'',//手机验证码 maxTime:60,//60秒倒计时 maxTimeOnly:false, //控制倒计时 maxTimeMsg:'发送验证码', } mobileCode(){ var that = this let mobileNum = /^1[34578]\d{9}$/ //验证电话号码 // let mobileNum = /^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$/ var {mobile} = that.data //获取到input输入的手机号码 if(mobileNum.test(mobile)){ //手机号码格式验证 wx.showToast({ icon:'none', title: '验证码已发送,请注意查收!', }) // 60秒后重新获取验证码 var inter = setInterval(function() { //开启1秒执行一次定时器:注意需要为定时器命名否则无法关闭定时器 that.setData({ maxTime: that.data.maxTime - 1, //一秒执行一次的秒数-1 maxTimeMsg:that.data.maxTime+'s后重新发送', //点击按钮后需要动态改变为此内容 maxTimeOnly:true , //这里是为页面做了个三元运算符,为真的话按钮就无法绑定这个函数,为假则可以绑定此函数 }); if (that.data.maxTime < 0) { //在这里做判断如果倒计时为0 clearInterval(inter) //则关闭定时器 that.setData({ //将数据重新刷新 maxTimeMsg:'发送验证码', maxTime: 60, maxTimeOnly:false }); } }, 1000); //1000为1秒钟执行一次 code = that.generateMixed(6) //调用方法执行6位随机数,想向用户发几位验证码就写几,这个方法我会放在下面 console.log('发送短信验证码:',code) //执行完上面的代码后就拿到了我们随机出来的验证码 wx.cloud.callFunction({ //连接云函数 name:'mobile', //云函数名称 data:{ //传数据 mobile, //手机号码 code, //我们随机出来的验证码,传递给云函数,让云函数给用户发短信 } }).then(res=>{ console.log(res) }) }else{ wx.showToast({ icon:'error', title: '手机号码有误', }) } }, }) js:获取随机验证码 //获取随机验证码,n代表几位 generateMixed(n) { let chars = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; let res = ""; for (var i = 0; i < n; i++) { var id = Math.ceil(Math.random() * 10); //10为chars数组个数,数组里也可以写字母,只要这边个数写对就没问题 res += chars[id]; } return res; }, 云函数代码const cloud = require('wx-server-sdk') cloud.init({ env: 'flower-7gagi8mzf6917ccb' //替换自己的云环境id }) exports.main = async (event, context) => { var {mobile,code} = event try { const result = await cloud.openapi.cloudbase.sendSms({ env: 'flower-7gagi8mzf6917ccb', //替换自己的云环境id content: '验证码为'+code, phoneNumberList: [ "+86"+mobile ] }) return result } catch (err) { return err } } tips:值得一提的是如果只是操作了上面的流程,云函数还是会报错,此时我们需要,打开我们的云开发》点击静态网站,把这个打开后什么也不用操作 [图片] 关于腾讯的短信费用,这边腾讯为我们赠送了100条的短信提供开发者测试使用 [图片] [图片] 当然如果100条免费的用完之后我们可以点击设置》往下滑滑倒底部,可以选择购买 [图片]
2022-04-02 - 用Proxy实现页面和组件公共状态动态更新(类Vuex)
支持vuex中的mapActions,mapMutations辅助函数,也可以在bindStore时添加到页面中,页面卸载时最好调佣 removeStore.remove() 来删除不需要更新的页面对象 页面中使用 [图片] 或者 [图片] 组件中使用 [图片] 也可以这样 [图片] //初始化state值 let state = { } //更新state方法 let mutations = { } //公共方法 let actions = { } let stateProxy; const Store = (options = {}) => { if(options.state) state = options.state if(options.state) mutations = options.mutations if(options.state) actions = options.actions //创建Proxy stateProxy = new Proxy(state, { get(target, property) { return target[property] }, set(target, property, value) { pageArrays.forEach(item => { const newData = {} //判断该页面是否需要更新property if (item.arr.includes(property)) { newData[property] = value } item.self.setData(newData) }) return Reflect.set(target, property, value) } }) return { state, commit: commitFun, dispatch: dispatchFun, } } //以下为主要代码(原理) // Actions commit方法 export const commitFun = (str, params) => { return mutations[str](stateProxy, params) } // Actions dispatch方法 export const dispatchFun = (str, params) => { return actions[str]({ state, commit: commitFun, dispatch: dispatchFun }, params) } //存放页面对象数组 const pageArrays = [] //创建数组索引 let id = 1; //删除卸载的页面和组件 class RemovePageItem { ids = null; constructor(ids) { this.ids = ids } remove() { pageArrays.some((item, index, arr) => { if (item.id === this.ids) { arr.splice(index, 1) return true } }) } } //绑定页面并初始化所需state export const bindStore = (self, useState = [], useMutations = [], useActions = []) => { id++ const newData = {} useState.forEach(item => { newData[item] = (state[item] || state[item] === 0) ? state[item] : '' }) useMutations.forEach(item => { self[item] = (params) => mutations[item](stateProxy, params) }) useActions.forEach(item => { self[item] = (params) => actions[item]({ state, commit: commitFun, dispatch: dispatchFun }, params) }) self.setData(newData) pageArrays.push({ self, id, arr: useState }) return new RemovePageItem(id) } //绑定更新state方法 export const mapMutations = (arr = []) => { const List = {} arr.forEach(item => { List[item] = (params) => mutations[item](stateProxy, params) }) return List } export const mapActions = (arr = []) => { const List = {} arr.forEach(item => { List[item] = params => actions[item]({ state, commit: commitFun, dispatch: dispatchFun }, params) }) return List } const _default = { ...Store() } export default _default
2022-06-14 - 简单且实用的购物车实现方案
本方案主要特点: 购物车cart数据保存在storage,不保存在云数据库。 好处及原因: 1、简单; 2、体验流畅。 在用户点击“加入购物车”后,不需要与后台数据库同步数据,完全没有卡顿现象,体验很好; 3、可扩展。 将来如果需要上传用户购物车数据到后台时,可以任何地方加入相关代码,将cart数据同步到数据库去,不需要改变现有的代码流程; 以下是实现流程及代码: 一、当用户点击“+1”或“-1”,在购物车中加货或减货; 1、购物车的增减功能; 2、tab页中小红点; 3、onShow实现多个页面的数据同步,比如在首页+1了,在分类页、购物车页会同步显示+1; onUpdateCart: function (e) { let product = e.currentTarget.dataset.item//读取传入的产品数据 let action = e.currentTarget.dataset.action//取值为'reduce'和'increase' let cart = lib.updateCart(product, this.data.cart, action)//计算新的cart表 this.setData({ cart })//刷新wxml上的cart渲染 wx.setStorageSync('cart', cart)//将新cart保存到缓存 lib.badgeCart(cart)//更新tab页中cart页的小红点:显示/消除小红点,或显示购物车的商品数值 }, onShow: function () { let cart = wx.getStorageSync('cart') || []//读取缓存中的cart表。 lib.badgeCart(cart)//更新tab页中cart页的小红点:显示/消除小红点,或显示购物车的商品数值 this.setData({ cart }) }, 二、用户点击“去结算”时; 1、购物车数据与后台数据实时同步。 onSettle: async function (cart) { //此处按其他业务逻辑过滤cart let ids = cart.map(v => v._id)//获取cart中所有产品的_id组 //从库里读取这组产品的最新数据 let res = await db.collection('product').aggregate() .match({ _id: _.in(ids), ...{ //other query } }) .group({ _id: null, stocks: app.$.push('$$ROOT') //一次性超限拉取所有产品列表 }) .end() let stocks = res.list[0].stocks//获取库中最新的产品列表 //将cart里产品与后台读取的数据进行对比,裁剪库存以及相关数据。包括缺货,库存不足,价格变动,等最新数据 lib.cartFilter(stocks, cart) wx.navigateTo({//跳转到结算页,计算订单总额、运费等 url: '../settle/settle', }) }, 三、用户点击“生成订单” 1、实现购物车中删除订单所含的产品, 2、后台数据库减库存 //清除缓存中的cart列表 cleanCart: function (order) { let dcart = order.items//订单中的商品表 let scart = wx.getStorageSync('cart')//缓存中的商品表 dcart.forEach(v => { let index = scart.findIndex(u => v._id == u._id) if (index > -1) { scart.splice(index, 1) } }) wx.setStorageSync('cart', scart) }, onCreateOrder:function(){ //生成待支付订单,同时后台减库存 }, 四、去支付。
2022-04-27 - 一个公众号(服务号),多个小程序,云开发,绑定通知,扫描登录解决方案
1、小程序云开发,新建一个接收公众号的云函数mp // 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init({ // API 调用都保持和云函数当前所在环境一致 env: cloud.DYNAMIC_CURRENT_ENV }) // 云函数入口函数 exports.main = async (event, context) => { console.log(event) return { ToUserName: event.FromUserName, FromUserName: event.ToUserName, CreateTime: Date.parse(new Date())/1000, MsgType: 'text', Content: '收到!' } } 云托管接收消息推送 2、账号绑定:公众号打开云开发-》更多-》共享环境-》消息推送-》绑定推送事件,小程序也一样。 [图片] 3、根据event.MsgType的值:text,event等(跟公众号消息类型一致),判断消息类型做相应处理。 下面是通过公众号关注绑定小程序所属用户,可以用于通知消息,扫描登录等功能开发 switch (event.MsgType) { case 'text': obj.Content = `Hi~,我们将竭诚为您服务!` return obj break case 'event': if (event.Event == 'subscribe') { if (wxContext.FROM_UNIONID) { await db.collection('ws_user').where({ unionid: wxContext.FROM_UNIONID }).update({ data:{ mpOpenid: wxContext.FROM_OPENID, updateTime: Date.now() } }) } obj.Content = '已关注!' return obj } if (event.Event == 'unsubscribe') { await db.collection('ws_user').where({ unionid: wxContext.FROM_UNIONID }).update({ data:{ mpOpenid: '', updateTime: Date.now() } }) } break }
2022-09-15 - 云储存文件如何批量下载到本地
[图片] 云储存文件上传下载文档地址,https://docs.cloudbase.net/cli-v1/storage 首先确保你已经操作了以上 3 步~~~ 然后你可以选择在桌面新建一个 downloads 文件夹,然后选择打开桌面的命令提示符, [图片] //通过 --mode 指定你的云环境id tcb --mode '你的云环境id' [图片] 环境部署成功后,下载云储存文件到本地(下载 dianXiaoTwo云储存文件 到 本地downloads文件 中) // tcb storage download '云环境文件路径' '本地文件路径' --dir tcb storage download dianXiaoTwo downloads --dir [图片] [图片]
2022-10-13 - 微信小程序头像昵称实战篇
2022-08-25 api文档地址: https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html 目前的api变更后,得到的地址为 临时地址, 这个是文档没有说明的, 最佳实践,是需要把得到的地址上传到自己的服务器,然后用服务器返回的地址作为 真实头像的永久地址. 核心点说明: //获取到api返回的新地址路径 onChooseAvatar(e) { this.avatarUrl = e.detail.avatarUrl console.log('e.detail', e.detail) // this.updateUserInfo(); this.uploadFile(); }, /* 上传 头像 转 话格式*/ uploadFile(){ uni.uploadFile({ url: config.webUrl + '/upload/uploadImages',//后台接口 filePath: this.avatarUrl,// 上传图片 url name:'image', // formData: this.formData, header: { 'content-type': 'multipart/form-data', 'token': uni.getStorageSync('token') }, // header 值 success: res => { let obj = JSON.parse(res.data) console.log('obj', obj) if (obj.code == 1) { let imgUrl = obj.data.full_path; this.userImg = imgUrl; this.updateUserInfo(); } else { uni.showToast({ icon: 'none', title: '图片太大,请重新选择!' }); } }, fail: e => { this.$toast('上传失败') } }); }, 这里需要注意, wx.uploadFile 返回的是字符串类型,需要前端自己处理一下数据结构: [图片] 完整代码如下: import config from "@/common/config.js"; import {debounce} from '@/utils/debounce.js' export default { data() { return { defaultAvatarUrl: 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0', avatarUrl: '', nick_name: '', userImg: '', } }, onLoad() { let userInfo = uni.getStorageSync('userInfo') || {}; let { nick_name,img_url } = {...userInfo}; this.userImg = img_url; this.nick_name = nick_name; }, methods: { onChooseAvatar(e) { this.avatarUrl = e.detail.avatarUrl console.log('e.detail', e.detail) // this.updateUserInfo(); this.uploadFile(); }, inputWord: debounce(function(e){ this.nick_name = e.detail.value console.log('this.nick_name.length',this.nick_name.length) let str = this.nick_name.trim(); if(str.length==0){ this.$toast('请输入合法的昵称') return } if((/[^/a-zA-Z0-9\u4E00-\u9FA5]/g).test(str)){ this.$toast('请输入中英文和数字') return } this.updateUserInfo() }, 1500), /* 上传 头像 转 话格式*/ uploadFile(){ uni.uploadFile({ url: config.webUrl + '/upload/uploadImages',//后台接口 filePath: this.avatarUrl,// 上传图片 url name:'image', // formData: this.formData, header: { 'content-type': 'multipart/form-data', 'token': uni.getStorageSync('token') }, // header 值 success: res => { let obj = JSON.parse(res.data) console.log('obj', obj) if (obj.code == 1) { let imgUrl = obj.data.full_path; this.userImg = imgUrl; this.updateUserInfo(); } else { uni.showToast({ icon: 'none', title: '图片太大,请重新选择!' }); } }, fail: e => { this.$toast('上传失败') } }); }, updateUserInfo(){ let self = this; uni.showLoading({}); let params = { img_url: this.userImg, nick_name: this.nick_name.trim(), } self.$http.post('updateUserInfo', params).then(res => { uni.hideLoading() if (res.data.code == 1) { self.$toast('修改成功!') }else { self.$toast(res.data.msg) } }) }, } } 请一键三连,争取升个级,谢谢各位道友! 补充一下,如果api不生效注意切换一下版本库: 我本地用的2.26.1 [图片] 实际效果图: [图片] [图片]
2022-11-24 - 微信小程序怎样在访问数据库的WHERE语句里判断某个属性包含一个字符串?
[图片]
2022-03-02 - 微信小程序教你使用eventbus一步一步构建全局跨页面通信系统
微信小程序提供了页面通信通信解决方案EventChannel,但实际使用中需要在wx.navigateTo跳转页面时使用,且需要在跳转前声明监听的事件,如下图 [图片] 这是一种页面间的通信,但是局限性过于明显,仅可以在跳转间的页面之间建立通信,A跳转B可以建立通信关系,A不跳转G就不可以建立通信关系,在实际开发中如果某个注册页面的信息想做回显,我们可以使用重新请求、放到storage中、glabalData、eventbus全局通信等,但是肯定不能用navigateTo建立eventbus信道进行传值,从交互层面是完全不可接收的。 这时我们就需要一个全局的eventbus来进行通信了,下面讲解一下微信小程序如何搭建全局eventbus进行通信。 注:eventbus传值,如果没有对引用类型进行深拷贝,那么会将引用传过去导致错误。 首先,我们需要开发页面扩展功能,我们知道每一个页面都是一个Page({}),传入的是一个对象,对象中包含双向绑定的数据、生命周期函数、自定义函数,这里我们需要增加页面的生命周期函数。 原理可以参考这篇文章: 小程序页面(Page)扩展 其中我们需要这5个文件: [图片] 其中config.js是小程序全局构造函数App、Page扩展规则配置项,eventBus是eventbus的实现,index是将eventbus扩展的页面上,然后再app.js中引入index文件即可,pageExtends即页面扩展方法,public是初始化eventbus的方法。 使用方法: A页面声明: [图片] B页面触发: [图片] 以下为源码 config.js源码: [代码]/* * @Author: 徐强国 * @Date: 2022-08-15 15:43:32 * @Description: Page公共方法扩展 */ const EventBus = require('./eventBus') let eventBus // 初始化页面的eventbus,事件用法参照dom2事件 export const initEventBus = (pageObj) => { // let eventBus = new EventBus(); if (!eventBus) { eventBus = new EventBus(); } else { } pageObj['$on'] = function () { let argu = Array.prototype.slice.call(arguments); eventBus.on(...argu) } pageObj['$off'] = function () { let argu = Array.prototype.slice.call(arguments); eventBus.off(...argu) } pageObj['$emit'] = function () { let argu = Array.prototype.slice.call(arguments); eventBus.emit(...argu) } // 创建页面声明的自定义事件 let events = pageObj['events']; if (Array.isArray(events)) { events.forEach((event, index) => { if (typeof event === 'string') { eventBus.createEvent(event) } else { console.error(`==请传入String类型的事件名称== index:${index}`, events) } }) } else if (typeof events !== 'undefined') { console.error('==events字段已被占用,用于声明当前页面需要创建的自定义事件,值为字符串数组== events:', events) } } module.exports = { onLoad(options) { this.$initPage() }, $initPage() { if (!this.$on) { initEventBus(this) } }, } [代码] eventBus.js源码 [代码]/** * @authors 徐强国 * @date 2022-8-8 * eventBus,订阅/发布 * */ // 是否是字符串 function isString(str) { return typeof str === 'string' } // 是否是函数 function isFunction(fn) { return typeof fn === 'function' } // 消息中心 class MessageHub { constructor() { this.pubDictionary = {} } // 创建发布者 createEvent(name, isGlobal) { if (!isString(name)) { console.error(`==请传入创建事件的名称 name==`) return false } let _pub = this.pubDictionary[name] if (_pub) { if (!isGlobal) { console.warn(`==${name} 事件已存在==`) } return _pub } else { let pub = new Publish(name, this) this.pubDictionary[name] = pub return pub } } removeEvent(name) { if (!isString(name)) { console.error(`==请传入删除事件的名称 name==`) return false } delete this.pubDictionary[name] } on(name, callback, mark) { if (!isString(name)) { console.error(`==请传入监听事件的名称 name==`) return false } console.log('ononoonon这里的区文体', this.pubDictionary, callback, mark) if (!isFunction(callback)) { console.error(`==请传入监听事件的回调函数 callback==`) return false } let pub = this.pubDictionary[name] if (pub) { let watcher = new Watcher(pub.dep, callback, mark) pub.dep.addSub(watcher) } else { console.error(`==尚未创建 ${name} 事件==`) } } off(name, callback) { if (!isString(name)) { console.error(`==请传入监听事件的名称 name==`) return false } if (!isFunction(callback)) { console.error(`==请传入监听事件的回调函数 callback==`) return false } let pub = this.pubDictionary[name] pub.dep.removeSub(callback) } emit(name, val) { if (!isString(name)) { console.error(`==请传入触发事件的名称 name==`) return false } console.log('这里的区文体emit', this.pubDictionary) let pub = this.pubDictionary[name] if (pub) { pub.refresh(val) } else { console.warn(`==${name} 事件不存在==`) } } clearEvent() { this.pubDictionary = {} } } // 发布者 class Publish { constructor(name, messageHub) { this.name = name this.messageHub = messageHub this.dep = new Dep(this) } refresh(val) { this.dep.notify(val) } } // 订阅者 class Watcher { constructor(dep, run, mark) { this.dep = dep this.run = run this.mark = mark || '' } update() { let val = this.dep.value let run = this.run run(val) } } // 依赖收集 class Dep { constructor(pub) { this.pub = pub this.subs = [] } addSub(sub) { this.subs.push(sub) } removeSub(run) { let sub = this.subs.filter(item => item.run === run)[0] remove(this.subs, sub) } notify(val) { this.value = val let subs = this.subs.slice() for (let i = 0, l = subs.length; i < l; i++) { subs[i].update() } } } function remove(arr, el) { for (let i = 0; i < arr.length; i++) { if (arr[i] === el) { arr.splice(i, 1) return true } } return false } module.exports = MessageHub [代码] pageExtends.js源码 [代码]/** * @authors 徐强国 * @date 2022-08-15 * 小程序全局构造函数App、Page扩展 */ const { appLiftTimes, pageLiftTimes } = require('./config'); // 判断是否是App的生命周期及原始方法 function isAppLiftTimes (name, fn) { if (typeof fn === 'function') { return appLiftTimes.indexOf(name) > -1 } return false } // 判断是否是Page的生命周期及原始方法 function isPageLiftTimes(name, fn) { if (typeof fn === 'function') { return pageLiftTimes.indexOf(name) > -1 } return false } // 函数混入 function rewriteFn(context, name, fn) { if (context[name]) { let originFn = context[name]; context[name] = function (e) { let argu = Array.prototype.slice.call(arguments); fn.apply(this, argu); return originFn.apply(this, argu) } } else { context[name] = fn } } // 是否是对象 function isObject(obj) { return obj !== null && typeof obj === 'object' } // 重写App const originApp = App; const appExtendsList = []; App = function (obj) { // app拓展方法 appExtendsList.forEach(item => { rewriteFn(obj, item.key, item.value) }) return originApp(obj) } const appExtends = function (key, value) { if (isAppLiftTimes(key, value)) { appExtendsList.push({ key, value }) } else { console.error('==*App暂不支持非生命周期的扩展*==', key) } } // 重写Page const originPage = Page; const pageExtendsList = []; Page = function (obj) { let illegalKeys = Object.keys(obj).filter(key => /^\$+/.test(key)); if (illegalKeys.length) { // throw new Error(`Page中自定义属性禁止以 \$ 开头, ${illegalKeys.join(', ')}`) console.error(`Page中自定义属性禁止以 \$ 开头, ${illegalKeys.join(', ')}`) } // 页面拓展方法 pageExtendsList.forEach(item => { // 非生命周期属性只能拓展一次 if (isPageLiftTimes(item.key, item.value)) { rewriteFn(obj, item.key, item.value) } else { if (typeof obj[item.key] === 'undefined') { obj[item.key] = item.value; } else { console.error(`Page中已拓展 ${item.key} 属性`, obj[item.key]) } } }) return originPage(obj) } const pageExtends = function (key, value) { // Page拓展属性,非生命周期的属性必须以 $ 开头 if (/^\$+/.test(key) || isPageLiftTimes(key, value)) { if (isPageLiftTimes(key, value) || !pageExtendsList.filter(item => item.key === key).length) { pageExtendsList.push({ key, value }) } else { console.warn(`==*Page中已扩展 ${key} 属性*==`) } } else { console.warn(`==*Page中拓展属性必须以 \$ 开头*==`, `\n key: ${key}`) } } const AppPlus = { appExtends: function (mixinObj, value) { if (typeof mixinObj === 'string') { appExtends(mixinObj, value) } else if (isObject(mixinObj)) { Object.keys(mixinObj).forEach(key => { appExtends(key, mixinObj[key]) }) } else { console.warn('==*请传入 对象 或者 key, value*==') } }, pageExtends: function (mixinObj, value) { if (typeof mixinObj === 'string') { pageExtends(mixinObj, value) } else if (isObject(mixinObj)) { Object.keys(mixinObj).forEach(key => { pageExtends(key, mixinObj[key]) }) } else { console.warn('==*请传入 对象 或者 key, value*==') } } } module.exports = AppPlus [代码] public.js源码 [代码]/* * @Author: 徐强国 * @Date: 2022-08-15 15:43:32 * @Description: Page公共方法扩展 */ const EventBus = require('./eventBus') let eventBus // 初始化页面的eventbus,事件用法参照dom2事件 export const initEventBus = (pageObj) => { // let eventBus = new EventBus(); if (!eventBus) { eventBus = new EventBus(); } else { } pageObj['$on'] = function () { let argu = Array.prototype.slice.call(arguments); eventBus.on(...argu) } pageObj['$off'] = function () { let argu = Array.prototype.slice.call(arguments); eventBus.off(...argu) } pageObj['$emit'] = function () { let argu = Array.prototype.slice.call(arguments); eventBus.emit(...argu) } // 创建页面声明的自定义事件 let events = pageObj['events']; if (Array.isArray(events)) { events.forEach((event, index) => { if (typeof event === 'string') { eventBus.createEvent(event) } else { console.error(`==请传入String类型的事件名称== index:${index}`, events) } }) } else if (typeof events !== 'undefined') { console.error('==events字段已被占用,用于声明当前页面需要创建的自定义事件,值为字符串数组== events:', events) } } module.exports = { onLoad(options) { this.$initPage() }, $initPage() { if (!this.$on) { initEventBus(this) } }, } [代码] index.js源码 [代码]/* * @Author: 徐强国 * @Date: 2022-08-15 15:18:12 * @Description: 小程序提供扩展App、Page扩展入口 * * * AppPlus提供拓展App及Page的接口,校验自定义属性命名 * @param appExtends * @parm pageExtends * * 传入一个对象,此对象的属性及方法将混入App或者Page实例中 * 生命周期函数将与自定义的声明周期混合,且先执行, * 其他属性只能以$开头,且不可覆盖、混入,应避免名称重复 */ const AppPlus = require('./pageExtends') const Public = require('./public') AppPlus.pageExtends(Public) [代码] 代码下载:https://github.com/TobinXu/MiniProgramEventBus/tree/main
2022-08-19 - 通过自定义collection的安全规则,允许管理员去更新其他人的记录, 实现不了
https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/database/security-rules.html 自定义了一个云数据库的collection安全规则,以实现让管理员可以更新其他人创建的记录。 { "read": true, "create": true, "delete": "auth.openid == doc._openid", "update": "auth.openid == doc._openid || auth.openid in get(`database.resident.${doc.residentId}`).manager" } 小程序端的语句 [图片] 这里提示更新记录为0,这个也是可以想通,是where 条件没有匹配上,但是我看文档里是可以实现的,请问是我哪里弄错了么? [图片]
2022-08-13 - 【原创】关于多input连续输入问题的解决办法
[图片] 开干! 1、wxml <view data-place="0" class="input{{curFocus === 0 ? ' on':''}}" bindtap="openKeyBoard">{{passwd[0]}}</view> <view data-place="1" class="input{{curFocus === 1 ? ' on':''}}" bindtap="openKeyBoard">{{passwd[1]}}</view> <view data-place="2" class="input{{curFocus === 2 ? ' on':''}}" bindtap="openKeyBoard">{{passwd[2]}}</view> <view data-place="3" class="input{{curFocus === 3 ? ' on':''}}" bindtap="openKeyBoard">{{passwd[3]}}</view> <input type="number" model:value="{{inputValue}}" focus="{{focus}}" class="input-original" hold-keyboard="{{true}}" confirm-hold="{{true}}" maxlength="4" bindinput="bindKeyInput" /> 2、less 闪烁代替光标 .on { animation: glow 1s linear infinite; } @keyframes glow { 50% { border-color: #ff6666; } 100% { border-color: #eee; } } 3、js openKeyBoard(e: any) { const { place } = e?.currentTarget?.dataset; this.data.passwd[place] = ''; this.setData({ curFocus: parseInt(place), inputValue: '', focus: true, assign: true, passwd: this.data.passwd }); }, bindKeyInput: function (e) { const { value } = e?.detail; let tmp = value.split(''); let curFocus = tmp.length; let inputValue = ''; if (this.data.assign) { this.data.passwd[this.data.curFocus] = value.substr(-1); inputValue = this.data.passwd.join(''); curFocus = this.data.curFocus; console.log('修改指定位置'); } else { inputValue = value; this.data.passwd = value.split(''); } this.setData({ inputValue: inputValue, assign: false, passwd: this.data.passwd, curFocus: curFocus }) if (inputValue.length === 4) { this.setData({ list: [], loading: true }); wx.hideKeyboard(); this.loadList(true); } },
2022-08-11 - 复赛结束后微信群内参赛选手讨论内容值得学习的部分总结
一. 为何有这篇文档 记录原因:2022年8月5日13:00比赛提交结束后,各队伍均在微信群中进行了高参考价值的赛题相关方法讨论,现予以总结以备后期学习,同时也分享给初赛至今努力比赛的大家作为参考。 整理结构:将以信息流的结构对提出和回答问题的相关情况进行整理。 备注信息:各位大佬的讨论可能会因为上下文信息不能完整呈现,建议参考的时候自己检索具体知识点。记录的顺序是以讨论的时间顺序为准。大佬排名不分先后。另外各位大佬在讨论细节上存在的不同意见,可能存在不完整的展示。 受限于记录者水平不足,括号内的内容一般为文档撰写者的理解或者猜测。可能存在记录错误,恳请阅读者谅解。 二. 这篇文档具体内容 关于比赛后处理方案? 陪跑:我们的后处理的思路是把少类别的概率强行放大,这样初赛复赛都有比较稳定的提升(3k)左右.具体做法是 1/value_count,每个类别得到一个放大的概率值,大类是1,小类>1, 具体的值设置是通过初赛的线下五折调出来的参数。 mixup 是否有效? 辉:初赛尝试了mixup,复赛并没有用上,模型抖的厉害。 陪跑:我这里尝试了很多次mixup,但是没啥用。 伪标签是否有效? 陪跑:有效果,但取决于生成的标签的精度。在损失函数上的选择,可以考虑使用kd loss,也可以使用交叉熵。 郭大:这题的关键其实还是伪标签,提升是2个百分点,0.720–0.741.伪标签不是为了利用数据,而是为了利用大模型(估计是为了使用clip large)。在训练伪标签上,是否使用soft label或者使用hard label,区别并不大。如果soft 和hard 分别都用,融合还能提升分数。本赛题受限于规则上进行限时,所以需要伪标签,不限时的话,都不需要伪标签(猜测是直接用多个large融合)。 chizhu:伪标签在我们这里提升是2个百。0.689–0.710,限于挥霍的stacking细节,可能也影响了模型效果。具体使用了8帧的单折。我们采用了两阶段训练小模型,先用100万无标签数据训练第一阶段,然后第二阶段用9万真实标签修正,(可不可以描述的更细致一点呢?)。 挥霍的人生:受限于我们组的打伪标签的base模型精度太低问题,导致我们后期运用伪标签提升未达到预期(主要是上班太忙,没时间做题才是真相)。 UA:伪标签在本题中可以理解为蒸馏的思路,伪标签可以套娃,训练出好的模型,再去标注,再训练,再标注,形成一个循环过程。评估的标注是测试集上无法再次上分为止。当出现大量的unlabel数据的时候,就可以考虑使用伪标签,例如本赛题,特征均出现对齐的情况,唯一的区别是缺失label。如果使用kl loss做软标签,就可以看做是知识蒸馏。 Lawliet:我们一开始用的swin-base上分,后来看到大家都在传clip,就产生了敏锐的洞察力。 虚着点和气:关于chizhu描述先用100万无标签数据训练第一阶段,然后第二阶段用9万真实标签修正这部分,完全可以边打标,边修正。 本赛题限时的原因? ★★★★★官方解答: 复赛限制QPS的原因–>主要是更贴近实际工业应用场景,虽然内容理解不需要做到实时性,但机器是有很高成本的,大家可以考虑一下如果每天有数百万数千万的视频发表,而且有峰值低谷,需要多少台机器?如果QPS低,那么就需要更多的机器。实际上,工业应用要求的QPS会比这里高很多,inference的GPU也最多用T4。 特征提取的backbone用谁好? 各队伍都使用各种类型的视频特征预训练模型作为提取的主要方法,例如swin, tiny, clip, vit等,但从本赛题来看,最有效的方法仍然是clip。具体可以参考的代码地址如下: openai/CLIP 具体采用的预训练模型文件之一参考如下: ```shell {“ViT-B/32”: “https://openaipublic.azureedge.net/clip/models/40d365715913c9da98579312b702a82c18be219cc2a73407c4526f58eba950af/ViT-B-32.pt”} Lawliet, 陪跑:几乎所有的论文都是用clip的,这一点可以作为选择backbone有更好效果的一种指导方向。 高等数学:初赛我们就在使用clip,由于初赛已经明确了视频特征提取结果,因此是在文本上用clip提取的文本信息。(这一点的使用方法没有理解到)。 lawliet: 2022年7月29日clip开始上线了中文clip模型。 伪标签使用后是否会导致训练结果leak? UA:伪标签确实会存在leak,这时候验证集可能存在无法作为线上预期分数的参考的问题。 Tibur:为了评估自己的模型效果,当出现leak的时候,可以通过提交线上来查看是否自己的整体流程是有效的。 clip的学习率要设置成多少? 陪跑:bert的1/10作为参考,例如bert的学习率为1e-4,那么clip的学习率就是1e-5. 在这里需要配置分层学习率。 clip是否要冻结不参与微调训练? Tibur: 需要冻结,这里应该是指只需要做推理得到特征。 陈佳烁(两面包夹芝士): 可以一起参与训练,提升是0.7个百分点。在学习率设置上是5e-6.具体的学习率设置如下参考: [代码]shell -- other_learning_rate 5e-4 -- bert_learning_rate 5e-5 -- clip_learning_rate 5e-6[代码] 哪些预训练任务是有效的? Tibur: ita, itm 在我这边是最有效的。这是通过初赛消融得到的结论,复赛没时间。我的双流就是图片过backbone, 文本过bert,然后两个做cross_attentiony以后直接输出. 陪跑:我这边clip模型最重要,双流里,算text vision的相似度可以明显上分。我们的双流就是UniVL(3个transformer 编码器) + LOUPE.在双流里用UniVL,具体操作上使用clip替代了itm任务.在双流里,是文本过tfs,图片embedding过tfs,合并起来继续tfs.相当于是三个独立的tfs.这样的模型比单流要慢一点。我使用ALBEF的模型,如果用中间分层的话会出现过拟合,而且会预训练的时候没法在微调上产生效果,暂时未排查到原因。在设计MLM任务的时候,可以考虑使用ngram mask,也比普通的MLM任务有明显提升。 一只大海龟:ALBEF在这个场景下预训练不上分。 融合阶段使用logits融合好还是使用prob? prob在此处应该是指对logits做softmax后的结果。 陪跑:使用prob融合出现掉分。考虑单流和双流模型如果差异大的话,Logits不一定在一个向量空间内。融合方法上就是直接加权。 陈佳烁(两面包夹芝士):初赛上融合logits不如prob,复赛默认使用prob。 Tibur:我们对两种方法都做了尝试,但是没有区别。我的理解是应该融prob。 UA: 如果模型中存在很多的Norm,不同模型的预测结果的值域应该不会差太远。 数据上特别是asr ocr存在很多脏数据,是否可以做特征工程清洗呢?(玄学,清洗更可能的是掉分) Lawliet:初赛做了清洗,掉分。 Tibur:清洗文本有用,我洗了涨了3k。但是预训练后反而没用了。 还有哪些数据EDA存在很高的价值呢? 陪跑:top1的预测准确率acc是0.8左右,但是top5的hit就有0.95,这表明一般ground truth就在top5内。所以对小类乘以权重,变大一点,就可以让top2的到top1了。初赛上涨了3k,复赛更多上分5-6k。具体代码如下:[代码]from collections import Counter a = pd.DataFrame(Counter(train_data["category_id"].map(CATEGORY_ID_TO_LV2ID)), index=[0]).T.sort_index() a[0] = (10 / a[0] + 1) [代码] swa 应该在全量数据训练中如何参与做提升? 陪跑:我们swa了top5个。开着ema训练,最后再手动swa.(代码也非常简单,就是把字典里的参数的weight和bias提取出来加权)。 2021QQ浏览器比赛由于和本场比赛非常类似,第一名开源方案,在本场比赛中的预训练是否仍然有重要参考价值呢? Tilbur(队伍名:一拳超人):我们的单流参考了他们的方案,提升非常大,微调加上预训练可以直接到0.72。 虚着点和气:在具体参考和使用上,应该还是有一定区别,照搬过来应该效果不会很理想(应该是表达知识应该活学活用)。 复赛阶段非常讲究推理效率,有哪些方法可以提升推理效率呢?onnx,tensorRT,EFFT,half如何组合运用? 郭大:个人建议,如果不是真正到了瓶颈,由于TensorRT相对而言工程上比较复杂,在有限的比赛时间内,可以先不考虑调试成功这个,微调阶段仍然使用float32执行训练,简单的在推理阶段使用half()做推理就能加速很多。(当使用了half以后,TensorRT的效率二次提升效果并不太大)。在抽取视频帧的使用上,基于base的模型half以后可以支持28帧,追加上TensorRT可以使用32帧全量推理,而如果仅仅使用float32推理只能支持8帧。在half以后,实际上推理的结果基本上和float32区别不大,结果基本一致。(这个可以使用训练数据划分一折做验证,个人估计郭大这个在比赛期间分享的点应该是让很多团队受益了)。 UA:如果比赛使用的图像侧是Swin Transformer,可以参考使用EFFT做加速,可能比TensorRT更快。
2022-08-10 - 网络请求优化之使用本地缓存
[视频] 你好,我是李艺。 上节课我们主要学习了有关setData调用相关的优化技巧,这节课我们学习网络请求相关的优化技巧。 首先我们看一下问题,针对网络请求的优化主要有以下三个方面: 一、减少不必要的网络请求,使用本地缓存的数据代替从后端接口拉取的数据 二、优化网络请求参数,提高网络请求的通讯效率 三、优化网络请求的并发数,让优先级高的请求先执行 其中第二项在第6.8讲我们已经讲过了,这节课我们重点看一三两项的优化,下面看项目实践 。 首先看实践一:在本地缓存数据。 在首页的JS文件里边有加载小程序导航数据的代码,我们可以在这里尝试使用本地缓存技术,如我们屏幕上看到的截图,首先从本地缓存中尝试取出缓存数据,如果取到了就先用上,然后向后端发起网络请求,拿到最新的导航数据以后再调用setData重新设置一下数据,并把本地数据也刷新一遍,避免本地缓存过时,运行以后如我们屏幕上看到的,在调试区的Storage面板里边可以看到本地缓存的导航数据,但是这个实现方案是有瑕疵的,有什么瑕疵,先从本地缓存,再从后端接口请求。这是一个顺序的并发过程,实际上这个过程还可以再优化一下,改用并发复合命令,让两个异步操作同时并行。或者我们更简单一些,改成两个异步函数,同时开始执行也可以,优化就不做演示了。留给你自己实践一下。 下面我们进行实践一的代码演示。 首先我们看一下我们最终实现的一个源码,找到主页的JS文件,在这个地方,我们有一个从主页中,从后端加载导航列表这样的一段代码。这段代码它的主要的一个作用,在这个地方。从这个地方开始,它的主要的一个作用就是从接口去拉取导航数据。拉取完成以后,然后我们再去设置我们的data数据对象里面的navs,这样的一个列表,同时设置完以后,我们还需要将我们本地缓存里边的这个navs这个列表然后进行一个更新。由于缓存里面它存储的是字符串,所以这个地方我们要拿json方法,进行序列化,更新一下。 在前面我们是先向本地,通过getStorage这个方法,然后去取了一下本地缓存的数据。当然这个地方有可能会取不到,所以我们会首先做一个判断。如果能取到的话,我们就将它进行设置,同时在设置之前我们还需要拿JSON.parse,然后进行一个解析。因为我们取到的数据它是一个字符串的数据,这就是我们主要的代码。首先我们将这部分代码给它拷贝一下,来到我们的小程序项目里面,找到我们首页的js文件,然后在这个里面我们先搜索一下。搜索navs 在这个位置,这是我们现有的代码,它一上就是从后端进行下载,我们添加一个从本地获取数据这样的一个代码,同时在这个地方这个代码已经有了,所以我们不需要再添加了。本质上在之前这个代码其实它是不需要的,因为我们如果前边没有消费代码的话,我们在这个地方去设置本地数据,它本质上它也是无用的。现在这个代码我们已经设置完了,单击编译按钮,然后进行测试,注意看我们的导航区。当然我们编译模式现在可以改一下了,改成我们的普通编译模式,注意看一下我们的调试区,这里面有一个已取到缓存的导航数据,这个代码是在这一行打印的。然后在下面还有一个也取到了后端的导航数据,这行代码是在这个地方打印的。也就是说我们这个代码它首先会从本地,然后取到缓存的数据,然后并且马上启用,同时它接着又向后端发起接口的请求,然后再获取数据,同时将本地的缓存的数据然后进行一个刷新。下面再看在我们这个调试区它有一个面板,一我们本地的这个Storage面板,这个里面navs这就是我们本地缓存的数据,这个代码演示我们就到这里。 下面我们看实践二。 打破网络请求的10个并发限制,并按优先级排序。由wx.request接口发出的网络请求,有最大10个的并发限制。为了破除这个限制,同时让高优先级的网络请求操作先执行,我们可以进一步改造我们的request工具函数,改造以后这个函数的代码如我们屏幕上显示的。首先我们引入了一个自带优先级的异步队列,叫做priority-async-queue。这个模块需要使用yarn或者npm安装,安装指令如屏幕上所示。安装以后在工具菜单栏,别忘记选择构建npm进行模块代码的构建。在使用自定义的request方法的时候,针对重要的网络请求只需要添加一个值等于urgent的一个priority参数即可,如我们屏幕上显示的这样。感觉是不是很简单。调用方法及其他参数都不需要修改,这是接口迭代进化中的向后兼容性,可以最大程度的保证旧代码在项目迭代中的一个持续使用,运行效果与之前一样。在网络环境顺畅的情况下基本上是无感知的。 下面我们看代码演示。 查看package.json这个文件,在这个文件里面我们可以看到多了一个模块的引用,这个模块叫做priority-async-queue,我们将这个给它复制一下,模块名给它复制一下,后面的版本是2.1.1,如果为了保证这个版本一致,稍后我们在安装的时候还可以将这个版本号给它加上。拷贝以后我们需要打开一个本地的终端窗口,终端窗口我们可以在VSCode里面操作,当然也可以在我们的微信小程序里面也是可以的。在我们miniprogram上面选择内建终端打开,选择以后这个地方我们使用yarn add,然后将我们刚才那个名字给它拷贝一下,还可以附带我们的版本号。添加,很快它就已经装上了。装上以后我们还可以顺带看一下这个目录下的package.json文件,确认一下 这个地方已经有这个模块了,这是第一步。第二步就是改造我们的request方法,目前我们request方法它是不支持优先级的,我们需要对它进行一个改造,打开我们已经修改好的代码,首先在最上面有一个对我们这个模块新安装模块的一个引入,同时下面有一个queue对象的创建,这个数字代表是我们最大允许的一个并发数字默认等于10。当然我们也可以传一个其他的数字都是可以的,然后将这个代码放在文件的最上面,再往下这个地方有一个关于优先级的定义,包括这个地方,它有一个导出,这个代码我们都需要拷贝过来,然后放在这里这些参数,还有这些参数其实都不需要修改,然后这个地方有一个关于默认的优先级的设置,如果它没有优先级的话我们就给它一个normal的这样的优先级。再往下在这个里边重点的代码在这个地方有一个queue.addTask,同时将priority优先级也给它传进去,后面是一个箭头函数,这个箭头函数它代表的是一个匿名的一个闭包。我们可以将这个代码给它拷贝一下,然后放在这个里面可以对比一下我们上下,它这个代码的一个区别。其实等于是我们将原来的代码,也就是这个代码放在了它的里边,同时将用addTask方法对它进行了一个封装。封装以后我们原来这个代码它是作为,然后由闭包函数然后封装一下,然后作为第二个参数,然后传进来的,是这样的一个改造方式,这样就可以了。 现在我们需要对我们原有的代码做一个改造。我们目前有一个是拉取首页数据的这样的一个代码我们修改一下它的优先级,在这个文件里面retrieve_home_data.js,然后这个里边,有对request的方法的一个调用,在这个地方,有一个调用,然后我们要在原来的这个位置,原来它是有一个url参数,我们再加另外的一个参数,就是priority,然后它的值我们让它等于urgent,等于这个,确认一下priority。这是一个priority,让它等一个urgent代表是最高的一个优先级,这样修改就可以了。修改完成以后我们单击编译按钮,然后进行测试,这个地方出现了一个错误。在调试区,大意是说module,然后这个module is not defined没有定义。这个模块没有定义,为啥没有定义,为什么没有定义?因为我们在本地刚才安装了第三方的模块以后我们没有选择,我们没有在这个菜单里面选择工具构建npm。这一步很重要,只有构建以后,我们新添加的模块它才会从我们的目录下面小程序这个目录下面有一个是node modules,从这个目录下面然后再转到我们的这个npm,就转到这个miniprogram_npm 转到这个目录下,转到这个目录下以后,然后我们才可以去加载和使用priority-async-queue这样的一个模块。现在这个目录下它已经有了,说明我们现在可以访问了,我们再次单击编译进行测试。现在代码错误已经不存在了,然后我们再看数据的表现,数据仍然可以加载,也没有问题,这个代码演示就到这里。 下面我们看一下小结,关于本地缓存接口,前面我们已经介绍过了,它们都是同步接口,即使像wx.setStorage、wx.getStorage这样不以Sync结尾的接口,由于某种历史原因,它们也是同步接口。那么至少目前是这样的,以后可能会修改,所以在使用这些接口的时候,我们一定要特别注意使用的时机,最好在Page.onReady周期函数中,或者是在之后的时机使用本地缓存接口,使用本地缓存。另外一个特别需要注意的点是一定要时刻铭记,本地缓存的数据是不可靠的,本地数据有可能因为各种原因缺失或者损坏。使用本地缓存的数据,但不能依赖本地数据。当获取本地缓存数据失败的时候一定要有后端接口可以顶上,或者是其他的方式可以顶上。对于小程序里边的wx.request接口可以管控起来,不仅因为网络请求在低版本的基础库版本中有最大10个并发限制,还处于优先级排序的需求,以及有可能存在的页面访问的权限控制要求。在实际的项目开发里面,某些后端接口是一定要用户实现鉴权以后才允许访问的。这类统一的鉴权访问控制就适合在request工具函数中统一实现,这部分内容不属于优化内容,但是对项目来说也十分必要,如果需要拓展的话都可以在request.js文件的基础之上然后进行修改。 点击查看相关文档: 数据缓存 /wx.setStoragepriority-async-queue这节课就讲到这里,上面的网址是本课涉及的文档地址。 这节课我们主要学习了如何使用本地缓存数据,即如何使用优先级队列优化网络请求。下节课我们学习图片优化技巧。 最后我们看一下思考题。这里有个问题请你思考一下,webp是Google在2010年推出的一种新的图片格式,它使用更优的图片压缩算法在相同的图片质量下能让图片保持更小的图片体积,Youtube的视频缩略图采用webp格式以后网络加载速度提升了10%左右,Google的Chrome网上应用商店采用webp的格式以后每天大概节省了几TB的一个带宽,现在小程序中的image组件也开始支持使用webp格式的图片了,但一般我们在团队开发中使用的图片多半是png,或者是jpg这样的格式,那么有什么办法可以快速将这些图片转化为webp格式,并且在小程序项目里边使用。下节课我们就一起来深入探讨一下这个问题。
2022-07-15 - 复杂场景无法自动化测试?Minium 测试试一试!
录制回放文章 提到录制回放支持输入、文本查找、断言等自动化测试基础操作,无需编写代码,用例生成效率高,但是该操作对于部分复杂的业务场景具有局限性。如果用户希望适用复杂的业务场景,自主制定测试场景,自定义测试(Minium)方案就非常适用了。 自定义测试 Minium 能力介绍 小程序测试框架 Minium 是微信测试团队为小程序开发或测试同学提供的一套测试接口,它实现了 miniprogram-automator 中小程序自动化的所有能力,例如直接触发小程序页面元素、设置页面数据、向 AppService 注入代码片段、Mock / Hook wx 对象的接口等支持并封装所有的原生操作,屏蔽 iOS / Android 底层差异,实现了一套脚本在三端同时运行。用户写好的 Minium 脚本,可以在本地执行,也可以直接上传到微信小程序云测服务执行,无需准备和维护真机环境。 自定义测试 Minium 流程介绍 一、 编写用例编写小程序自动化测试脚本,常见操作包括: 基本操作,例如如页面跳转,元素定位及相关操作处理小程序 API处理小程序的原生控件,例如处理授权弹窗支持数据驱动测试1、基本操作例如定位小程序页面元素、操作元素、跳转页面等。例如以下用例 class FirstTest(minium.MiniTest): def test_network(self): # 页面跳转 self.app.navigate_to("/packageAPI/pages/get-network-type/get-network-type") # 元素定位 ele = self.page.get_element("button", inner_text="获取手机网络状态") # 元素点击 ele.click() # 打印元素文本 self.logger.info(self.page.get_element("/page/view/view[2]/view/view[1]/text").inner_text) 2、处理小程序 APIMinium 框架提供处理小程序开放 API 方法,根据需求选择相应方法,例如 mock_wx_method() — mock 小程序 API 的调用hook_wx_method() — hook 小程序 API 的调用call_wx_method() — 调用小程序的 API...更多接口方法参考 Minium 接口。以调用小程序 API 获取回调信息为例: class FirstTest(minium.MiniTest): def test_call_wx_method(self): """ 调用小程序API,获取回调对象 :return: """ sys_info = self.app.call_wx_method("getSystemInfo").get("result", {}).get("result") self.assertIsInstance(sys_info, dict, "is dict") self.assertTrue(True if sys_info else False, "not empty") 3、处理小程序原生控件Minium 提供了针对小程序内涉及原生控件 (授权弹窗、弹窗、地图、分享小程序等) 的操作封装。 注意:部分封装的接口暂不支持 IDE 平台调用。若跑测平台是 IDE,则需要在 config.json 中配置 mock_native_modal 配置项,后通过 mock 的方式实现 处理模态弹窗用例示例如下 class FirstTest(minium.MiniTest): def test_native(self): self.mini.clear_auth() self.app.redirect_to("/pages/testnative/testnative") called = threading.Semaphore(0) callback_args = None def callback(args): nonlocal callback_args called.release() callback_args = args # hook showModal方法,获取回调后执行callback self.app.hook_wx_method("showModal", callback=callback) self.page.get_element("#testModal").tap() time.sleep(2) # 点击弹窗 确定 self.native.handle_modal("确定") is_called = called.acquire(timeout=10) # 释放hook showModal方法 self.app.release_hook_wx_method("showModal") self.assertTrue(is_called, "callback called") self.assertDictContainsSubset( {"errMsg": "showModal:ok", "cancel": False, "confirm": True}, callback_args[0]) 跑测平台 IDE,[代码]config.json[代码] 配置 [代码]mock_native_modal[代码] 示例如下 "mock_native_modal": { "showModal": { "title": "test modal", "content": "modal content" }, } 4、数据驱动自动化测试往往需多组数据测试,若采用录制回放测试,则需录制多个用例,灵活性不足,所以若需测试同一个用例不同组测试数据,可使用数据驱动(DDT)模式,实现测试数据与测试脚本的分离,通过 DDT 将测试数据加载到脚本中。数据驱动(DDT)有以下优点: 灵活配置测试数据与功能代码分开易维护以集成数据驱动测试 (基于 DDT 封装) 为例: @minium.ddt_class class BaseTest(minium.MiniTest): @minium.ddt_case([], ["1", "2"]) def test_evaluate_sync(self, args): """ 向 app Service 层注入代码 同步返回结果 :param args: :return: """ # 参数 args: [] args: ["1", "2"] result = self.app.evaluate( "function(...args){return `test evaluate: ${args}`}", args, sync=True ) self.assertEqual( result.get("result", {}).get("result"), "test evaluate: {}".format(",".join(args)) ) 此外,DDT 模式还支持给具体的 test data 命名,自定义命名会体现在测试方法名中。数据驱动详情可参考 测试流程控制 & 数据驱动测试。 二、 执行用例开发者编写完 Python 用例脚本后,可本地调试,也可在云测上测试。 1、本地执行开发者将编写好的用例进行本地调试,minitest 命令加载用例,初始化环境,开启自动化能力,进行环境检查,后执行用例。该方法需 IDE 依赖,支持 USB 真机调试。在初始化环境过程中遇到常见问题如下: 开发者工具没有自动打开,建议先排查微信开发者工具自动化能力,进行 环境检查配置真机环境但无法拉起真机上的小程序,建议排查是否使用真机调试 2.0,如果是,切换使用真机调试 1.0报错 traceback 中有出现 _miniClassSetUp 的调用,确认微信开发者工具选用的基础库是否为最新版本。路径如下:开发者工具项目窗口右上角 -> 详情 -> 本地设置 -> 调试基础库为了保证同一套代码在 IDE、Android、iOS 上运行,Minium 环境组成比较复杂,所以测试用例的运行依赖于配置文件,支持配置运行平台、IDE 监听端口号、连接手机的参数、账号信息、自动处理授权弹窗等等。具体操作可参考 项目配置。 执行完用例后,云测平台生成日志文件,提供本地测试报告,包括截图、运行日志、错误日志。具体实践可参考 示例。 [图片] 2、云测服务执行开发者可以将本地调试好的用例上传至云测平台,新建测试计划 -> 新建 Minium 任务,可选择多平台真机,且支持多平台同时运行,无需用户部署和维护真机环境。 测试结束后,云测服务提供详细的测试报告,包括运行截图、日志信息、网络请求分析、性能分析等。当用例执行失败时,云测服务会提供错误日志及错误行代码,方便用户排查错误原因。具体操作可参考云测官方文档 自定义测试。 [图片] 对比[图片] *详情点击查看 虚拟账号使用流程、打通 devops 流程。 最佳实践分享对于第三方小程序服务商测试团队来说,多个小程序开发管理工作导致更复杂的测试情况。以明源云测试团队为例,他们需要管理多个小程序,并且每个小程序的页面数量较多。传统的手工测试显然无法覆盖业务需求。利用 Minium 自动化测试,明源云测试同学通过以下操作实现快速的自动化测试: 使用了 Minium 框架编写自定义测试用例,执行超过 90 个用例;并且在编写用例时采用了 Page Object 模式(简称 PO 模式),将测试用例和页面元素定位、元素、元素操作等分离,提升用例复用性,降低维护成本;在执行用例的具体过程中,打通云测服务和内部的 devops 流程,利用云测第三方接口,定时触发或者自动触发自动化任务,然后利用查询任务接口,再将测试结果同步到内部的用例管理平台。如果有问题提单给程序修复,实现整个流程闭环。[图片] [图片] 总结通过自定义测试(Minium),小程序测试团队能够自主定制测试场景,并且结合云测服务,获取详细的测试报告,实现较高的灵活度。 小程序云测服务提供 3 项服务,每项服务具备不同的特点,适用于不同的场景,助力开发者提升测试效率。 [图片] 如有更多小程序云测服务的相关问题,可点击 微信小程序云测服务专区 发帖反馈,技术专员将为大家解答及进行深度交流。
2022-08-05 - 如何创建高效数据库索引
在创建索引上,建议每个生产环境查询都应有索引支持,并且尽可能使用组合索引,同时注意组合索引升降序,并利用覆盖索引高效查询。此外,还有大数据量下应该避免使用低区分度操作符等8个实践建议,一起通过视频了解一下吧。 [视频]
2021-09-22 - 微信小程序自定义watch属性
微信小程序没有提供类似vue中watch类似的监听属性,如果我们想用,就得自己写一个,直接上代码。 注意:vue中的watch监听属性滥用会造成性能问题,这里自定义的watch也是一样的,要适度使用。 watch.js const observe = (obj, key, watchFun, deep, page) => { let val = obj[key]; if (val != null && typeof val === "object" && deep) { Object.keys(val).forEach((item) => { observe(val, item, watchFun, deep, page); }); } Object.defineProperty(obj, key, { configurable: true, enumerable: true, set: (value) => { watchFun.call(page, value, val); val = value; if (deep) { observe(obj, key, watchFun, deep, page); } }, get: () => { return val; } }); } const setWatcher = (page) => { let data = page.data; let watch = page.watch; Object.keys(watch).forEach((item) => { let targetData = data; let keys = item.split("."); for (let i = 0; i < keys.length - 1; i++) { targetData = targetData[keys[i]]; } let targetKey = keys[keys.length - 1]; let watchFun = watch[item].handler || watch[item]; let deep = watch[item].deep; observe(targetData, targetKey, watchFun, deep, page); }); } module.exports = { setWatcher }; 使用示例: // 引入自定义监听属性 import watch from '../../core/js/watch';//这里的路劲取决于你自己的watch.js文件的存放路径 Page({ data: { name: "测试watch" }, onLoad() { watch.setWatcher(this); let that = this; setTimeout(function () { that.data.name = "测试watch变化了" }, 5000) }, watch: { name: function(newVal, oldVal) { //这里的this和onLoad里的this指向一样 console.log(newVal, oldVal); } } });
2022-08-03 - 使用串发命令模式延迟同步请求(上)
[视频] 你好,我是李艺。 上节课我们主要学习了如何将前端计算工作后移,这节课我们学习如何使用数据缓存。 下面我们看一下问题,一般情况下wx API以Sync结尾的接口是同步接口,例如像wx.getSystemInfoSync还有wx.setStorageSync,这些接口都属于同步接口,反之不以Sync结尾的接口都是异步接口,由于历史原因,有些接口虽然名称上它由Sync结尾,但实际上却仍然是同步接口,例如像那个wx.getSystemInfo还有这个wx.getStorage以及wx.setStorage这三个接口在开发里面还经常用到,而且经常是在App.onLaunch还有Page.onLoad,这些周期函数里面用到的,这对启动性能其实它是十分有影响的,小程序的启动流程是不能有任何人为的同步代码阻塞主线程的,如果需要拉取这些系统信息的话,在优化的时候我们可以由分接口代替,我们看一下都有哪些分接口。 例如第一个getSystemSetting接口是获取设备信息,还有像getAppAuthorizeSetting接口是获取微信APP授权信息 授权设置信息,再往下是getDeviceInfo是获取设备基础信息,第四个是getWindowInfo是获取窗口信息,最后一个是getAppBaseInfo是获取微信APP的一个基础信息,这里的每一个分接口它返回的信息都不一样,需要什么信息我们就调用什么样的信息的一个分接口就可以了。 还有在小程序启动过程中,我们尽量先使用默认参数在启动完成以后,也就是在Page.onReady这个事件派发以后再进行相关接口的一个调用,对于缓存后端接口数据的本地缓存存取代码,如果没有必要的话也要尽量放在启动流程完成以后,也就是Page.onReady这个事件派发以后再去调用,这里有一点我们需要注意,就是小程序现在它有一个接口叫做wx.getSystemInfoAsync,注意这个里面多了一个A ,这是一个不多见的以Async结尾的这样一个接口,它是一个异步接口,可以异步拉取这个系统信息,不阻塞主线程,但是这个接口需要一定的微信客户端版本支持,如果不在受支持的客户端上面使用、调用的时候,它会自动地又用原来的同步接口进行代替,这是我们要注意的,下面我们看项目实践。 首先看实践一,创建SystemInfoManager模块使用串发命令模式延迟同步请求。 目前在我们的app.js文件里面,在它的App.onLaunch这个周期函数里面有对wx.getSystemInfo接口的调用,这个代码是同步的,它会阻塞我们小程序启动流程的主线程的一个执行,我们必须将它进行改写,可以在globalData里面先定义默认参数,在启动的时候拿这些默认参数先给程序使用,然后在Page.onReady这个事件派发以后再拉取实际需要的这些数据。 下面在改造过程中我们将创建一个SystemInfoManager模块,用这个模块专门用于处理系统信息的一个拉取,目前我们需要的系统信息是比较有限的,可以先实现一些基础的代码,后续如果还需要其他的一些系统信息可以再逐步进行扩展,接下来就是使用串发复合指令对象延迟执行系统信息的一个拉取,这部分代码我们要放在app.js文件里面,这个代码量可能会稍微有一点点多,但是这个代码的逻辑还是比较清晰的。我们先在globalData上面设置默认的全局信息,然后再创建串发的复合命令对象 创建完成以后将它进行执行,那么我们什么时候让串发命令对象开始执行,可以在首页的JS文件里面,它的onReady周期函数里面设置命令的一个启动,这样关于系统信息的拉取操作,它其实就是在首屏渲染完成以后才开始执行的,这样一种设置其实不影响我们小程序整体的启动,串发复合命令的对象它有一个特点,就是前面的子命令完成以后后面的子命令它才会开始执行,我们利用这个特点就可以延迟这系统信息的一个拉取了。 在这里有一个问题请你思考一下,为什么我们要把拉取系统信息的主要代码要写在app.js文件里面的onLaunch周期函数里面?因为globalData它是在这里定义的这段代码的一个主要作用,为了拉取 往globalData对象里面存储的这些信息,那么将这些代码放在这里是最合适的了,面向对象模拟真实世界的事物关系,它使得我们软件设计有规可依,但是面向对象它有一个缺陷就是容易将相互联系在一起的代码人为地给它隔离开,这种情况下 我们利用这种串发的复合命令对象就可以巧妙地弥补缺陷,下面我们看代码演示。 首先打开我们微信小游戏这个项目,在开始改造之前我们先看一下我们目前的项目在执行的时候,它这个信息是如何获取的,重启一下我们这个项目,注意看一下我们调试区,这个地方有一个打印 已取到系统消息,这个信息我们是在哪里打印的呢,是在app.js里面对不对,我们打开代码看一眼,现在开发者工具因为我同时开了录屏的原因,它启动以及运行变得非常的一个卡顿,其实如果把录屏关了以后,它运行效果还是可以的,我们看一下这个代码里面,这个地方有个打印已取到系统消息了,这是我们在调用getSystemInfo接口以后,就是我们拿到这个系统消息以后打印的一个信息,然后注意一下我们这个信息打印,它其实是在哪里,在index onready之前,它其实在它的前面打印的,也就是说我们的首页渲染还没有完成,这个系统消息已经拉取了对不对,当然这个代码它是同步的,它会阻塞我们整体的一个流程的进行,所以我们需要将它进行优化。 优化的第一步,首先我们要创建一个SystemInfoManager,这样的一个管理器模块,管理器模块我们要放在我们的library manager放在这个下面,这个下面还没有,但是我们可以去我们的最终源码里面看一下,因为那个地方肯定有已经写好的代码对不对,6.5.1找到miniprogram,然后library manager,这个文件就是我们需要的文件 将它拷贝一下放到我们目前的目录下面,library然后manager放在下面。 现在我们看一下我们这个代码主要是做了什么事情,首先是引入它,因为我们接下来有相关接口的调用需要用到这个工具方法,这是一个类 SystemInfoManager,然后在下面我们有个导出,其实直接导出的是它的一个实例,它实例化的一个实例,本身这个模块在我们程序里面可以说是单例的,在主线程里面它是唯一的,在这个里面有一个很重要的方法就是retrieveSystemInfo,一上来我们会判断一下是不是可以调用这样的一个接口,这个接口,先前我们提到了它其实是一个什么样的接口,一个特殊的异步接口对不对,getSystemInfoAsync,很少有接口这个里面是加A的,一般都是加Sync后面的后缀,看它能不能用,如果能用我们就用它,如果不可以我们就用后面getSystemInfo进行调用,进行调用完以后拿到这个结果,这个地方拿到结果以后,在这个地方我们不能直接用这样的一种方式,就是wx.getSystemInfoSync等于它 这样是不可以的,这个代码我们是想重写接口的一个实现,让这个接口直接返回,我们已经取到的信息不需要重复地去拉取了,当然这样不可以的话可以变通一种方式,用Object的里面的一个defineProperty,用这样的一个接口,这个是JS的方法,JS对象的一个方法用于我们在一个对象上,定义它的属性,我们用这个,然后同时将后面的参数给它置为true,把它value写成一个箭头函数让它返回,这样就可以了,这个方法执行完成以后,我们这个信息就拉取到了,拉取到了以后我们再调用这个方法,就是getSystemInfoSync 再调用它的话其实它已经不是一个同步接口了,它其实就相当于调箭头函数,然后直接把已经拿到的本地的信息给它取出来,再取出它的statusBarHeight就是状态栏的高度,这个高度我们在代码里面会用到,还有屏幕的分辨率也会用到,还有这个信息稍后也会用到,这就是它的简单的一个实现,管理器代码然后完成以后,接下来我们要去调用它。 首先我们要看一下app.js,app.js里面我们有哪些改造,在这个地方有一个按新方式拉取系统信息,这是我们的主要的一个代码,这个代码稍微有一点点长对吧,这个代码给它拷贝一下到这个地方拉取新的信息,这是我们原来代码 原来这个代码里面干了什么事情,拿到这个信息以后,我们设置了这个信息,还有是又调用它 拿到custom又设置这些信息对吧,还有这个信息,它设置了一系列的一个信息,这些信息可能是在接下来这个程序运行的时候需要用到的一些信息,将这个先给它注掉,然后将我们新代码给它放在这个地方来,看一下新代码。 首先一上来我们先设置这些,这个是我们默认配置,我们当前以什么样的一个屏幕大小进行测试的时候,我们就设置什么样的一个默认的信息,把这个信息给它设置上 设置完以后,我们这个程序就可以用这些默认的信息了,再往下是我们引入了三个模块,其中就包括我们新创建的 system_info_manager,引用它的实例,再往下这个地方我们有个ClosureCommand,ClosureCommand是我们的一个闭包指令对象,在这个里面,我们首先会这个地方注意,这有一个await,因为我们这个地方是async 这个地方是await,然后去调用它的retrieveSystemInfo,因为这个调用它会占用一些时间,所以它是同步的,会占用一些时间,取到以后会打印这些信息,已取到系统信息,在取到完成以后,我们这个地方看一下,这有一个statusBarHeight的一个获取,我们可以调这个接口拿到,还有custom 调用它里面的方法拿到,然后这个信息 就是这些信息包括这个信息其实就是我们原来的这些信息,它就是稍微修改一下用新的方式去设置了,这就是它的一个修改,我们现在不需要了把它删掉,这是在app.js里面所要做的一些修改。 接下来我们还要看在我们主页里面还有一个修改,主页里面还有修改,在index目录下面要找到我们的onReady周期函数,在这个地方允许异步拉取系统信息了,将这个给它拷贝一下到我们项目里面来 这里,放在这个地方 onReady完毕了,我们调用了asyncRetrieveSystemInfo,这个对象它的getCommand取到它的第一个子命令,然后markComplete标记它的完成,然后后续的子命令才可以执行,因为这个对象它本身是一个串发复合命令。 我们再确认一下我们这个里边,在app.js里面,我们来看一下它这个命令,其实在这个地方复制的 看到没有,等于它对不对,然后这个里面它的这个地方有一个new ClosureCommand,它虽然没有传任何代码,但是它也占据了一席之地,然后它的第二个子命令才是cmd,才是我们前面的这些,第一个子命令它不完成的话,第二个子命令它就不会执行,也就是我们上面这些代码就不会执行,然后它里面的同步调用它就不会占用时间,这样一来对我们启动 整体的小程序的启动它就没有影响了,而在我们主页里面,在这个页面首页 首屏渲染完成以后,把第一个指令然后设置完成了,设置完成以后,它这个复合对象就开始执行,第二个指令就开始执行,然后拉取系统信息代码也开始执行,这样的话就不影响了,代码改完了。 接下来我们打开我们微信开发者工具单击编译,看一下它的实际运行效果,注意看一下已取到了系统信息,看到没有,这有一条打印信息,现在信息的打印它已经在index onready,index onload,这两个信息打印在它的后面了 也就是说它现在要做的工作其实已经不再影响,我们小程序的一个正常启动了,代码演示就到这里。
2022-07-14 - 小程序端会话场景下长列表实现
1 前言 腾讯云医小程序中有医生和患者聊天的场景,在处理该场景的列表过程中遇到两个问题: 一是下拉加载历史消息时需要在容器顶部进行衔接导致的界面抖动问题;二是大量的会话内容导致的长列表问题。 问题一:插入历史消息带来的抖动问题是因为在已有dom的前面插入dom。如果能够在已有dom的后面插入新增dom并且在视觉上看起来是在顶部插入的则可以解决该问题。前端开发中聊天场景的体验优化一文中给出的方案是[代码]transform:rotate(180deg);[代码]。另外[代码]flex-direction:reverse[代码]也是可以做到的。 由于会话场景的一些其他特点如列表初始化时定位在底部(新消息在底部),本文的实现采用了[代码]transform:rotateX(180deg)[代码]方式处理进行处理。由于只需要在垂直方向进行翻转,所以在实现时使用rotateX代替了rotate。 下面简易demo说明该样式应用后的效果 [代码] .container { height: 100px; overflow: auto; } .item { width: 100px; border: 1px solid black; text-align: center; } /*关键*/ .x_reverse { transform: rotateX(180deg); } [代码] [代码]<div class="container x_reverse"> <div id="item-1" class="x_reverse item">数据项-1</div> ... <div id="item-9" class="x_reverse item">数据项-9</div> </div> [代码] 添加[代码].x-reverse[代码]样式前后的初始状态对比 翻转前 [图片] 翻转后 [图片] 问题二: 长列表问题。 我们先在h5端看下大量的dom会有哪些问题,如下demo验证 [代码]<button id="button">button</button><br> <ul id="container"></ul> [代码] [代码]document.getElementById('button').addEventListener('click',function(){ let now = Date.now(); const total = 10000; let ul = document.getElementById('container'); for (let i = 0; i < total; i++) { let li = document.createElement('li'); li.innerText = Math.random() ul.appendChild(li); } }) [代码] 在chrome的开发者工具performance栏下记录点击button后的运行过程,可以看到包含脚本运行在内的整个运行过程中 Rendering部分占用时间最多(包含[代码]Recalculate Style[代码]、[代码]Layout[代码]、[代码]Update Layer Tree[代码])。当列表项数越多并且列表项结构越复杂的时候,会在Recalculate Style和Layout阶段消耗大量的时间,所以有必要减少列表项的同时渲染。 [图片] 小程序的架构决定着小程序端该问题相较于h5端更为突出。在微信小程序官方文档 -> 指南 -> 性能与体验部分提到一些点如:setData数据大小、WXML节点数等原因都会影响到小程序的性能。以及图片资源的主要性能问题在于大图片和长列表图片上,这两种情况都有可能导致 iOS 客户端内存占用上升,从而触发系统回收小程序页面。显然在长列表场景下如果一次性将所有的数据全部加载出来就会有WXML节点过多,setData数据量过大的问题、图片资源过度等问题。 这些问题不仅仅是列表在初始化的时候存在,如在插入新数据(unshift)需要将整个数组进行传递,以及更新列表项数据时diff时间也会增大。 微信小程序官方提供了recycle-view组件来解决等高列表项的情况。但是对于会话场景下消息的高度是不等的,因此我们得自己实现一套符合这种特性的长列表组件。 2 接入前后对比 2.1 视频效果对比 对比腾讯云医小程序会话接入长列表组件前后的效果,优化前滚动过程中有卡顿的感觉,并且在发送消息的时候,消息输入框进入到列表中的延迟能够比较明显的感受到,优化后滚动较丝滑,并且发送消息没有明显的延迟。 接入前:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/after-chat.mp4" 接入后:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/before-chat.mp4" 对比腾讯云医小程序->群发助手下的患者列表初始化和选中时接入长列表组件前后的对比 接入前 :https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/group-send-before.mp4 接入后:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/group-send-after.mp4" 2.2 数据对比 这里对比下群发助手接入前后的setData(发起到回到)时间的对比 初始化用时对比 [图片] 选中item用时对比 [图片] 上面两张图的横坐标是数据条数,纵坐标是setData时间,可以看到无论是初始化还是选中操作二者的轨迹都是相似的 明显的看到接入前,setData的时间随着数据量的增大越来越大,接入后则没有这个问题。显然,接入后通信的数据量,diff时间,浏览器渲染时间都会较少。 3 基础实现 关于长列表实现的基本思路是只渲染可视区域及其附近的几屏数据,但是由于小程序端和h5端架构的差异导致二者在具体实现上存在差异。 3.1 如何模拟滚动条? h5实现长列表的常规思路 [代码]<div id="list-container"> <div id="list-phantom"></div> <div id="list"> <!-- item-1 --> <!-- ... --> <!-- item-n --> </div> </div> [代码] #list-container 滚动容器 通过引入#list-phantom来占位,高度为列表项高度之和用于撑开容器形成滚动条 #list用来装载列表数据 当有新的列表项添加后,则更新#list-phantom高度从而达到模拟滚动条的目的,然后通过监听#list-container的scroll事件在其回调中根据scrollTop来计算出现在可视区域的内容。 浏览器是多进程多线程架构,浏览器中打开一个tab页时可以认为是打开一个渲染进程,渲染进程中包含了GUI渲染线程(包括了html、css解析,dom树构建等工作)和js引擎线程等等。我们知道GUI渲染线程和JS引擎线程是互斥的,js引擎发起界面更新到渲染完成是同步的。 而小程序架构的通信是异步的,比如逻辑层setData发起通信到渲染层,通信过程中渲染层依然在执行的。如果按照h5的思路去计算,逻辑层计算的结果到达渲染层后就已经不是正确的结果了即界面中的数据和滚动条的位置是对不上的。 为了保证滚动条的位置和数据项所在位置是正确对应,起初的想法是列表项消失后通过一个同等高度的div元素进行代替,这样做带来的问题是依然会产生大量的dom元素。进一步的想法是通过对列表数据进行分组并且每个分组在界面中会存在一个真实的dom(称为分组dom)来包裹该分组内的所有列表项,并且认为每个分组算是一屏数据,当每个分组从界面中消失时,分组dom不会删除,只会删除内部的列表项,并且将消失的列表项高度之和赋值给该分组dom。这样解决了滚动条高度的问题,并且不需要计算具体哪些列表项数据需要被加载出来,只需要知道加载哪(些)个分组即可。 分组的想法既简化了计算又保证了数组项和滚动条的位置是正确对应的。 高度的获取和赋值是在wxs里面做的,由于wxs是在渲染层执行的,相比在逻辑层减少了通信的成本。 下面给出简易(伪)代码来描述这段过程 视图层 [代码]<scroll-view clearingids="{{clearingGroupIds}}" renderingids="{{renderedGroupIds}}" change:clearingids="{{module.clearingHandle}}" change:renderingids="{{module.renderingHandle}}" class="list-wrapper x_reverse"> <!-- 分组dom --> <view class="piece-container" wx:for="groups" wx:for-item="group" id="piece-container-{{group.id}}"> <view class="x_reverse" wx:for="group.data"> {{item.content}} </view> </view> </scroll-view> [代码] 逻辑层 [代码]// 分组数据结构(二维数组) groups:[ { id: 1, // 分组id data:[{ content:{a:'a'} },...] } ,...], // 当前需要渲染的分组 renderingids:[], // 需要移除的分组 clearingGroupIds:[] [代码] wxs 更新分组dom高度,用法参考官方文档WXS响应事件 [代码]module.exports = { clearingHandle:function(clearingGroupIds, oldV, ownerInstance){ clearingGroupIds.forEach(function(groupId){ // 1. 根据 groupId 找到对应的分组dom // 2. 获取分组dom高度 // 3. 设置分组dom样式:height }) }, renderingHandle: function (renderingGroupIds, oldGroup, ownerInstance) { renderingGroupIds.forEach(function(groupId){ // 1. 根据 groupId 找到对应的分组dom // 2. 移除height样式 }) } } [代码] 3.2 如何知道渲染哪些数据 当有新的数据需要渲染到列表中时,首先是对数据进行分组,然后通过小程序提供的IntersectionObserver能力对分组dom进行监听,在其回调中判断该dom是否进入scroll-vew从而来更新正在渲染的分组和需要移除的分组。 [代码] // 滚动容器domId const containerId = '#scroll-container-xxx' // 创建监听 _createObserver(groupIds = []) { groupIds.forEach(groupId => { const observer = wx.createIntersectionObserver(this).relativeTo(containerId); observer.observe(domId, this._observerCallback); }) } // 监听回调 _observerCallback(result) { // 1. 根据result拿到domId然后解析拿到groupId(domId包含了groupId信息 // 2. 判断当前分组是否在视口内,如果不在视口内直接返回 // 3. 如果分组在视口内,则计算需要渲染的分组ids和需要移除的分组ids // 4. 通信至视图层,渲染目标分组数据和移除失效的分组数据( // 4.1 移除的优先级不高,不应该阻塞渲染目标分组,因此可以通过debounce/throttle处理) // 4.2 短时间内多次setData会导致通信通道阻塞,比如可以将setData放在队列中处理,一个一个来(中间可能有些失效则可以跳过 } [代码] 总结:基于2.1和2.2已经可以完成基本的雏形,另外有些其他的点需要优化 4 优化 4.1 unshift带来的问题 在小程序中通常将列表数据存储到数组中,由于小程序setData的数据量越小越好,更新数组时通常不会将整个数组对象进行setData,而只是更新数组对象的某个属性,如下: [代码]// 在数组尾部插入数据时 小程序支持下面方式 this.setData({ [array[array.length]]: newObj }) // 更新数组中某项的属性时 this.setData({ [array[0].a]: 'a' }) [代码] 如果要向数组顶部插入数据,做不到只传递新增的数据 [代码]array.unshift({}) this.setData({array}) // => 缺点是 逻辑层到渲染层会传递整个数组对象 [代码] 本文的背景是要解决会话场景下的长列表问题,对于会话即存在插入历史消息的场景,又存在插入新消息的场景,相当于我们数组两端都需要有插入数据的能力。需要对数据进行push/unshift操作。但是前面提到unshift效果不好。因此本文通过两个数组,一个数组存放历史消息,一个数组存放新消息,并在dom结构上也增加了对应的结构。 dom结构如下 [代码]<scroll-view class="x_reverse"> <view class="next-item-wrapper"> <!--多了一层--> <view class="x_reverse"> <!--新消息区域--> <view wx:for=“new-groups” wx:for-item="group"> <view wx:for="group.data"> {{item.content}} </view> </view> </view> </view> <view class="history-item-wrapper"> <!--历史消息区域--> <view wx:for=“his-groups” wx:for-item="group"> <view class="x_reverse" wx:for="group.data"> {{item.content}} </view> </view> </view> </scroll-view> [代码] 区域定义: 历史消息区域:初始化的消息以及插入的历史消息 新消息区域:列表初始化完成之后新来的消息 制定了如下规则 分组id越大表示分组的消息越久远,分组id越小表示分组的消息越新 历史分组id从1开始递增,新消息区域分组id从0开始递减 新消息区域自身未做任何的翻转,就像正常的列表一样,有新的消息或者新的分组push就行 历史消息区域的分组受到翻转的影响,在历史消息分组中push新的消息或者新的分组表现为插入历史消息 其原理如下图 [图片] 与上面dom结构对应的数据结构如下 [代码]class fuse { constructor() { // 存储历史消息 this.histGroups = []; // groupId >= 1 // 存储新消息 this.newGroups = []; // groupId <= 0 } // 插入新消息 push(listGroups){ this.newGroups.push(...listGroups) } // 插入历史消息 unshift(listGroups){ this.histGroups.push(...listGroups) } } [代码] 4.2 白屏问题 4.2.1 白屏现象的解释 滚动过程中长列表组件会进行setData操作以更新视口区域的数据,在快速滚动的情况下,假设此时逻辑层的计算结果是需要渲染第3屏幕的数据,但是由于从逻辑层通信到视图层是需要时间,这段时间中第三屏的界面可能已经滚动到视口外,此时的渲染是无效的,用户看到的可能已经是第8屏的数据,但是这个时间点第8屏幕的数据并没有渲染,这就会导致白屏现象的出现。 如果我们能根据屏幕滚动的速率和通信的时间去预测下一帧哪一屏出现在视口区域,那么就可以避免白屏问题。显然这是个难题,因为你不知道用户什么时候会调整滚动的速度,并且setData的时间也受限于很多因素。因此小程序架构下长列表组件带来的白屏问题是无解的。但可以通过预加载上下几屏的数据等一些其他优化方案降低白屏出现的几率以及给出一些骨架效果来缓解用户的焦虑。 4.2.2 骨架效果模拟 由于WXML节点过多也会影响长列表性能,因此否定了渲染真实dom来实现骨架,目前是通过图片作为背景通过在垂直方向平铺的方式来模拟骨架效果。 这种方式对于列表项是等高的场景是完美的解决方案,对于列表项非等高的场景可能会看到背景有被’截断‘情况。不过实际体验来看在快速滚动的情况下,这种’截断‘被看到的概率是偏低的,从实际效果来看是可以接受的。 等高列表项(患者列表 ):https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john//video-1.mp4 非等高列表项(会话 ):https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john//video-2.mp4 4.3 图片高度异步确定带来的麻烦 加载图片资源需要经过网络,属于异步加载,因此img标签的高度的确定也是异步的。假设一种场景,当前分组中的图片资源尚未加载完成,由于滚动的发生需要将该分组中的列表项移除,显然这个时候给分组dom设置的高度是不准确的,当下一次重新渲染该分组时,图片重新加载到完成后,该分组的高度会发生生变化,此时会发生界面的跳动,该如何处理呢? 通过添加滚动锚定特性处理。滚动锚定是指当前视区上面的内容突然出现的时候,浏览器自动改变滚动高度,让视区窗口区域内容固定,就像滚动效果被锚定一样。因此通过设置滚动锚定特性可以解决界面跳动的问题 也可以通过动画的过渡效果来缓解跳动现象,这依赖于height相关的样式属性,因此需要给分组dom设置相关的样式值。 可以显示的给分组dom设置height样式:比如可以在图片加载完成后通知长列表组件去更新分组dom的高度,当高度设置了css3过渡动画,就会以动画形式展开。 也可以通过给分组dom设置min-height/max-height代替height,并给min-height/max-height设置css3动画。上面使用height方式存在一个问题,分组的高度只有在增高的前提下才会被感知,没有降低的可能性;而通过min-height/max-height组合(min-height:0,max-height:height + 1000px),分组高度的增加和降低都会被感知到 本文的实现是:滚动锚定 + min-height/max-height 下面是更新min-height/max-height的核心代码,通过监听 renderingids & clearingids属性的变化,在change回到中处理相关逻辑。 [代码]<scroll-view clearingids="{{clearingGroupIds}}" renderingids="{{renderedGroupIds}}" change:clearingids="{{chat.clearingHandle}}" change:renderingids="{{chat.renderingHandle}}" /> [代码] wxs [代码]// 分组消失时 设置mix-height/max-height = 实际高度 clearingHandle: function (clearingGroupIds, oldV, ownerInstance) { clearingGroupIds.forEach(function (groupId) { // 获取分组dom var pieceContainer = ownerInstance.selectComponent('#piece-container-' + groupId) var res = pieceContainer.getComputedStyle(['height']) pieceContainer.setStyle({ 'min-height': res.height, 'max-height': res.height }) }) // 分组重新渲染时 // min-height设置为0,实际的高度由分组中的列表项撑开 renderingHandle: function(renderingGroupIds, oldV, ownerInstance) { renderingGroupIds.forEach(function (groupId) { // 获取分组dom var pieceContainer = ownerInstance.selectComponent('#piece-container-' + groupId) var res = pieceContainer.getComputedStyle(['height']) // 高度大于一瓶 足够视口区域的内容发挥了 var maxHeight = parseInt(res.height) + 1000 + 'px' pieceContainer.setStyle({ 'min-height': '0' }) pieceContainer.setStyle({ 'max-height': maxHeight }) }) } [代码] 事实上最完美的方式是在上传图片的时候记录图片的宽高比例等信息,在渲染时计算好img标签高度,而不是依赖图片的加载结果,这样可以保证img标签高度是同步确定的。退一步的做法是可以在图片第一次加载完成后缓存宽高,再次渲染的时候显示的设置img标签宽高。 5 其他 5.1 由于翻转带来的其他副作用 ios下transform:rotate会导致z-index无效 Safari 3D transform变换z-index层级渲染异常的研究–张鑫旭。在Safari浏览器下,此Safari浏览器包括iOS的Safari,iPhone上的微信浏览器,以及Mac OS X系统的Safari浏览器,当我们使用3D transform变换的时候,如果祖先元素没有overflow:hidden/scroll/auto等限制,则会直接忽略自身和其他元素的z-index层叠顺序设置,而直接使用真实世界的3D视角进行渲染。 scroll-into-view无效问题 该问题在另一篇文章中说到过并且给出了解决方案。 小程序scroll-view翻转后 scroll-into-view的替代方案 5.2 根据groupNums计算待渲染/移除的分组id 本文实现的长列表组件提供了groupNums属性,该属性用来指定每个分组包含多个列表项。上文说到我们在IntersectionObserver监听的回调中来计算需要渲染的下一屏分组id。 如果长列表组件不存在删除元素的操作,那么假设当前进入视口的分组id是x,并且总是额外显示上一屏和下一屏的分组。那么当x是边缘分组时,目标分组就是[x,x+1] 或 [x-1,x];当x不是边缘分组的情况,目标分组是[x-1, x, x+1] 由于本文实现的长列表组件提供了删除中间列表项的方法,假设x,x-1,x+1这三个分组都被删除只剩下1一个列表项,那么按照上述计算方式计算返回的分组渲染出来后实际上可能还不够一屏。这个时候我们需要利用groupNums这个指标进行计算,比如当分组在中间时,得确保有3 * groupNums个列表项被渲染出来。 5.3 scroll-view底部回弹区域setData时跳动问题 问题:滑动页面到底部,使其出现橡皮筋效果,处于橡皮筋效果时SetData数据,会使页面跳动一下,处于橡皮筋效果时SetData会使页面跳动闪屏 解决方案:关闭橡皮筋效果即可 示例代码: [代码]<scroll-view enhanced="{{true}}" bounces="{{false}}" /> [代码] 5.4 一条消息的布局 问题:当滚动区域只有少数列表项,这些列表项高度之和小于滚动容器高度时,由于对滚动容器应用了翻转样式,此时列表项会布局在底部(应该在顶部) 解决方案:通过包裹在一个div内,应用如下样式解决 示例代码: [代码]<scroll-view class="x_reverse"> <view class="all-container"> <view class="next-item-wrapper">...</view> <view class="history-item-wrapper">...</view> </view> </scroll-view> [代码] [代码].all-container { display: flex; flex-direction: column; justify-content: flex-end; height: auto; min-height: 100%; } [代码] 5.5 自动弹出加载更多组件 问题:以加载历史消息为例,当消息滚动到顶部下拉开始加载历史消息时,如果只是设置showLoadMore为true,视觉上会看不到loadmore组件(原因是scroll-view设置了滚动锚定),需要再次向下拉一次,才能把该组件拉入到视区内。显然这样的体验不够好,如果拉到顶部开始加载历史消息时,该组件自动出现在用户的视觉内效果会好些。 示例代码(old): [代码]<scroll-view class="x_reverse"> <view class="all-container"> <view class="next-item-wrapper">...</view> <view class="history-item-wrapper">...</view> </view> <view class="x_reverse"> <load-more wx-if={{showLoadMore}}/> </view> </scroll-view> [代码] 解决方案:通过两个变量loadingDone&loading来维护该组件,loading为true时显示上面的组件,loadingDone为true时显示内部的组件 示例代码(new): [代码]<block> <!--正在加载,显示这里--> <load-more wx-if={{loading}}/> <scroll-view class="x_reverse"> <view class="all-container"> <view class="next-item-wrapper">...</view> <view class="history-item-wrapper">...</view> </view> <view class="x_reverse"> <!--没有更多数据了,显示这里--> <load-more wx-if={{loadingDone}}/> </view> </scroll-view> </block> [代码] 5.6 计算reccordIndex 在不删除中间列表项的情况下,传递的recordIndex是准确的,通过数学关系在wxs中实时进行计算 [代码]<list-item recordIndex="{{chat.calculateIndex(group, groupNums, index, renderedHistorySum)}}" /> [代码] wxs [代码]// index 当前列表项在当前分组的索引 // groupNums 单个分组列表项数 // renderedHistoryGroups是历史区域的列表项数 // group 用于获取groupId calculateIndex: function (group, groupNums, index, renderedHistorySum) { if (group.id > 0) { // 历史区域 return renderedHistorySum - ((group.id - 1) * groupNums + index) - 1 } return renderedHistorySum + (-group.id) * groupNums + index } [代码] [代码]observers: { 'renderedHistoryGroups.**'() { let renderedHistorySum = 0; const { renderedHistoryGroups, groupNums } = this.data; if (renderedHistoryGroups.length) { const { data: endGroupData } = getEndElement(renderedHistoryGroups); renderedHistorySum = (renderedHistoryGroups.length - 1) * groupNums + endGroupData.length; } this._setDataWrapper({ renderedHistorySum }); }, }, [代码] 5.7 抽象节点 列表项组件是通过抽象节点注入给长列表组件的 6 总结 下面是基于文中所述实现的目录,所有逻辑层代码放在behavior中以共享,normal-scroll针对普通场景的长列表,而chat-scroll针对会话场景的长列表。 [图片]
2022-02-17 - 微信小程序数字累加动画
推荐一下别人写的一个动画 NumberAnimate.js //Created by wangyy on 2016/12/26. 'use strict'; class NumberAnimate { constructor(opt) { let def = { from:50,//开始时的数字 speed:2000,// 总时间 refreshTime:100,// 刷新一次的时间 decimals:2,// 小数点后的位数,小数做四舍五入 onUpdate:function(){}, // 更新时回调函数 onComplete:function(){} // 完成时回调函数 } this.tempValue = 0;//累加变量值 this.opt = Object.assign(def,opt);//assign传入配置参数 this.loopCount = 0;//循环次数计数 this.loops = Math.ceil(this.opt.speed/this.opt.refreshTime);//数字累加次数 this.increment = (this.opt.from/this.loops);//每次累加的值 this.interval = null;//计时器对象 this.init(); } init(){ this.interval = setInterval(()=>{this.updateTimer()},this.opt.refreshTime); } updateTimer(){ this.loopCount++; this.tempValue = this.formatFloat(this.tempValue,this.increment).toFixed(this.opt.decimals); if(this.loopCount >= this.loops){ clearInterval(this.interval); this.tempValue = this.opt.from; this.opt.onComplete(); } this.opt.onUpdate(); } //解决0.1+0.2不等于0.3的小数累加精度问题 formatFloat(num1, num2) { let baseNum, baseNum1, baseNum2; try { baseNum1 = num1.toString().split(".")[1].length; } catch (e) { baseNum1 = 0; } try { baseNum2 = num2.toString().split(".")[1].length; } catch (e) { baseNum2 = 0; } baseNum = Math.pow(10, Math.max(baseNum1, baseNum2)); return (num1 * baseNum + num2 * baseNum) / baseNum; }; } export default NumberAnimate; 使用: import NumberAnimate from "../utils/NumberAnimate";//引入NumberAnimate.js 请根据自己的实际路径来 let n1 = new NumberAnimate({ from:100,//开始时的数字 speed:1000,//总时间 refreshTime:100,//新一次的时间 decimals:0,//小数点后的位数 onUpdate:()=>{//更新回调函数 }, onComplete:()=>{//完成回调函数 } });
2022-07-21 - 微信小程序压缩图片,也可以转换图片格式
wxml: <canvas class="canvas" style="height:{{windowHeight}}px;width:{{windowWidth}}px;" canvas-id='attendCanvasId'></canvas> wxss: .canvas{ position: fixed; top:-9999999999999999999px;left:0; z-index: -1; background: #fff; } js: let that = this; var windowWidth = 750; //图片压缩的宽度 var quality=0.9;//图片的质量,目前仅对 jpg 有效。取值范围为 (0, 1],不在范围内时当作 1.0 处理。 wx.getImageInfo({ src: tempFilePaths, success(imgres) { var imgwidth = imgres.width;//图片实际宽度 var imgheight = imgres.height;//图片实际高度 if (imgwidth > windowWidth) {//判断图片实际宽度是否大于要压缩的宽度,这个判断也可以不要,根据实际需求来 that.setData({ windowWidth: windowWidth,//图片压缩宽度 windowHeight: (windowWidth * imgheight) / imgwidth//计算图片压缩之后的高度,与图片原比例一致 }) // 放到对应的wxml页面 const ctx = wx.createCanvasContext('attendCanvasId');//canvas id ctx.drawImage(tempFilePaths, 0, 0, windowWidth, (windowWidth * imgheight) / imgwidth); ctx.draw(false, function () { wx.canvasToTempFilePath({ canvasId: 'attendCanvasId', fileType: imgres.type == 'png' ? 'png' : 'jpg', //目标文件的类型,这里可以根据实际情况来, quality: quality, //图片的质量,目前仅对 jpg 有效。取值范围为 (0, 1],不在范围内时当作 1.0 处理。 success(s){ console.log("压缩之后的图片", s); } }); }); } } })
2022-07-20 - 分享朋友圈是参数这样填写为什么不对?
=============================== 这个是上一个页面进入参数 wx.navigateTo({ url: '/pages/product/goods/goods?serialNumber=' + serialNumber + '&status=2&isMaterial=1', }) ========================== 这个是当面页面的分享朋友圈功能 onShareTimeline: function () { const { serialNumber } = this.data.options; console.log("分享朋友圈"); const { imgList } = this.data; return { title: this.data.remark, query: 'serialNumber=' + serialNumber + '&status=2&isMaterial=1', imageUrl: imgList[0] } }, 这样发布到朋友圈以后,其他人打开这个链接就是个空壳,所有的数据都没有了
2022-04-23 - 单页模式禁止分享的方法
直接上代码 [代码]let page ={ ... onShareTimeline:function(){ 正常情况的分享操作 } ... }; //判定当前是否 单页模式, 单页模式移除 onShareTimeline 即可 let option = wx.getLaunchOptionsSync(); if(option.scene==1154) { page.onShareTimeline = null } Page( page ); [代码]
2022-04-27 - 小程序自定义头部导航栏
示例图: [图片][图片] wxml: <!-- 顶部自定义导航样式 --> <!-- 样式1、2 黑色胶囊 白色胶囊--> <view class='nav' style="height:{{navH}}px;{{background? background:''}}" wx:if="{{styles==1 || styles==2}}"> <!-- 页面标题 --> <view class='nav-title' style="height:{{navTitle}}px;"> <view class="nav-back2" style="width:{{menuW}}px;height:{{menuH}}px;" wx:if="{{!isTab}}"> <text class="icons icons-zuojiantou" style="color:{{styles==1? '#000':'#fff'}};" data-type="back" catchtap="goback"></text> <text class="nav-shu" style="background:{{styles==1? '#2F2F2F':'#fff'}};"></text> <view data-type="index" catchtap="goback"><image mode="widthFix" src="img/{{styles==1? 'index1':'index2'}}.png"></image></view> </view> <text style="color:{{styles==1? '#000':'#fff'}};width:{{titleWidth}}px;" class="line1">{{title}}</text> </view> </view> <!-- 样式3、4 只有返回按钮或回到主页 黑色 白色--> <view class='nav' style="height:{{navH}}px;{{background? background:''}}" wx:if="{{styles==3 || styles==4}}"> <view class='nav-title' style="height:{{navTitle}}px;"> <view class="nav-back2" style="border:0;width:{{menuW}}px;height:{{menuH}}px;" wx:if="{{!isTab}}"> <text class="icons icons-zuojiantou icons2" style="color:{{styles==3? '#333':'#fff'}};" data-type="back" catchtap="goback" wx:if="{{getCurrentPages.length>1}}"></text> <image wx:else style="width:56rpx;height:56rpx;" mode="widthFix" src="img/{{styles==3? 'indexs':'index'}}.png" data-type="index" catchtap="goback"></image> </view> <text style="color:{{styles==3? '#333':'#fff'}};width:{{titleWidth}}px;" class="line1">{{title}}</text> </view> </view> <view style="height:{{navH}}px;" wx:if="{{bot}}"></view> wxss: @font-face { font-family: "icons"; /* Project id 3500499 */ src:url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAKcAAsAAAAABlQAAAJRAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACCcApMXwE2AiQDCAsGAAQgBYRnBzQbnwXInpo8beAx5Q1RfAAXGAghgvr9yp733wGAQtSA+jjALuqESlgCGX2rQ8KnIlR2XQCF/YM390zOBZltBK9jFyyjEXU9SxNqazrWPo+KUF2yljHV9H9t/rvDhZRh/BIK47GGBkQYO+3zfz/5F9AHviD0a5UU1ZT4gDYfyD7osi2KcGc+9Bksr/xq9k2+RKDZCgftz7eHyuhcSVEZI5zeIK+O89ioSkeHZJlmG4V6dGUWb9WRnqXPeBN/Pv5Yi0jqCk7d0d1ZWPl9X3HHFEoUXz1droWel1CHAguATJz1pw7URRuPNbsaxmhjFnxfKcvQKQ5tE+qvcypbwSgVnkn0pUfNJnisgboBg5MeQHTn38rTfm0+Ty+e94bPJ334+vbYuen/WrmoVGeTvOr1pTZTC9ZWbaugfFwfrBMz/uLdX97pWP/Xz6D264oXB8rHDZiHxn24gh9iuAkSswmTcpuou5Puc2ib3Sglx9ftnWruOXEuNOqZ4GkwkqPQaIzK3BzqtFhDvUYbaDbPr2/RY1AXuQlTVoLQqSJp9wmFThuVuQd1+n2o1+lHs8vRtluLqdiEUQmGEB/QB1RozySx2h2aMlPcFD8hX2IaBWs51ewCC+Q55sxX4y2ioWHKIcjzMMsIBqYEA7E6kbOvbd32FitRXplgSBEoBKIH0AuQgg7JUv6+HWSUMgqD1CnFLmKqHliKAaiFvoA6HuSa2ZXhWQgNNBjJgaCHoUyGgKF9WAIFhKWbkDjzaZfUoHpre2P+a/ugmWNJ4S4qNN8rKYlVIVRWKgA=') format('woff2'),url('iconfont.woff?t=1656989362799') format('woff'),url('iconfont.ttf?t=1656989362799') format('truetype'); } .icons { font-family: "icons" !important; font-size: 16px; font-style: normal; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .icons-zuojiantou:before { content: "\e630"; } .flex1{ flex:1; } /* 字体显示一行 */ .line1{ text-overflow: ellipsis; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; } /* 自定义头部 */ .nav{ width: 100%; overflow: hidden; position: fixed; top:0;left:0; z-index: 50; background-repeat: no-repeat; background-size:100% auto; transition: all 0.3s; background-position:top left; } .nav-title{ width: 100%; position: absolute; bottom: 0; left: 0; z-index: 10; color:#fff; font-size: 32rpx; font-weight: 500; padding-left:0; display: flex; align-items: center; justify-content: center; text-align: center; } .nav .back{ width: 22px; height: 22px; position: absolute; bottom: 0; left: 0; padding: 10px 15px; } .nav-back{ width:76rpx; position: absolute; bottom: 0; left: 0; text-align: left; padding-left: 20rpx; z-index: 11; display: flex; align-items: center; } .nav-back .iconfont{ font-size: 32rpx; color: #fff; } .nav-back image{ width:56rpx; height:56rpx; border-radius: 50%; display: block; } .nav-back2{ border-radius: 32rpx; position: absolute; left:14rpx; top:0;bottom:0; margin: auto; z-index:10; display: flex; flex-wrap: nowrap; align-items: center; border:1px solid #EDEDED; } .nav-back2>.icons{ font-size:30rpx; flex:1; display: flex; align-items: center; justify-content: center; height:100%; line-height: normal; } .nav-shu{ width: 1px; height: 60%; background: #2F2F2F; display: block; } .nav-back2>view{ flex:1; height:100%; display: flex; align-items: center; justify-content: center; } .nav-back2>view>image{ width:30rpx; height:32rpx; display: block; vertical-align: unset; margin:0; } .nav-title2>view>image{ width:56rpx; height:56rpx; } .itemcenter{ display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; } .nav-back2 .icons2{ justify-content: left;flex:unset;width:56rpx;height:56rpx; } js: const systemInfo = wx.getSystemInfoSync(); // 获取系统信息 const menuButtonInfo = wx.getMenuButtonBoundingClientRect();// 胶囊按钮位置信息 const getTopInfo={ statusBarHeight:systemInfo.statusBarHeight,//状态栏高度 navBarHeight: systemInfo.statusBarHeight + menuButtonInfo.height + (menuButtonInfo.top - systemInfo.statusBarHeight) * 2, // 导航栏高度 menuWidth: menuButtonInfo.width, // 胶囊宽度 menuHeight: menuButtonInfo.height, // 胶囊高度 menuRight: systemInfo.screenWidth - menuButtonInfo.right, // 胶囊距右方间距(方保持左、右间距一致) menuTop: menuButtonInfo.top, //胶囊距离顶部的距离 }; Component({ properties: { // 是否是tab页 false不是 true是 isTab:{ type:Boolean, value:false, }, // 导航栏样式 1:黑色胶囊 2:白色胶囊 3黑色 4白色 styles:{ type:String, value:"1" }, // 占位块是否显示 默认显示 bot:{ type:Boolean, value:true, }, // 页面标题 title:{ type:String, value:"页面标题" }, // 导航栏背景色,支持所有css样式,也可以是背景图,渐变色 background:{ type:String, value:"background:#fff;" } }, data: { navH: getTopInfo.navBarHeight, //导航栏高度 navTitle: getTopInfo.navBarHeight - getTopInfo.statusBarHeight, //导航栏标题高度 titleWidth: systemInfo.screenWidth - (getTopInfo.menuWidth + getTopInfo.menuRight * 2) * 2, //导航栏标题宽度 menuW: getTopInfo.menuWidth,//胶囊宽度 menuH: getTopInfo.menuHeight,//胶囊高度 menuRight:getTopInfo.menuRight, getCurrentPages:getCurrentPages(),//判断是否有上级页面 }, methods: { // 返回上一页 goback(e) { // 返回上一级 if (e.currentTarget.dataset.type == 'back') { wx.navigateBack({ delta: 1, }) } else { // 回首页 wx.switchTab({ url: '/pages/index/index', }) } }, } }) json: { "component": true, "usingComponents": {} }
2022-07-18 - wx:for循环遍历数据过多小程序直接卡白屏
[图片] 该模块只能循环遍历98次,当数据多的时候直接卡白屏啥也不显示了,有没有大佬知道怎么解决
2022-07-17 - 有关查询某一天的详情情况的相关思路以及实现方法
首先,要清楚某一天的日期如何获取:当today的数据为null时,则表示是今天,其实这是重点,这就可以推算出你想要查询哪一天的数据。 onLoad: function(options) { var that = this; //获取明天 that.getDateStr(null,1) }, getDateStr: function(today, addDayCount) { var date; if(today) { date = new Date(today); }else{ date = new Date(); } date.setDate(date.getDate() + addDayCount);//获取AddDayCount天后的日期 var y = date.getFullYear(); var m = date.getMonth() + 1;//获取当前月份的日期 var d = date.getDate(); if(m < 10){ m = '0' + m; }; if(d < 10) { d = '0' + d; }; console.log( y + "-" + m + "-" + d) return y + "-" + m + "-" + d; }, 第二:你要想查询,在新建数据的时候,必须要保存当时完成数据的时间点,然后用 date=new Date(today).getdate(),来实现某一天的查询 其中today表示当时存的时间点,自己摸索实现,留个记录,也希望能够帮到各位网友吧,有不同的见解的朋友希望留言讨论,谢谢
2022-07-14 - 一张表解决云存储的七大痛点
就是这张表: Collection: material { _id, _openid, createTime, cat,//分类。比如衣服、帽子 tag,//标签。比如产品号等 fileID,//cloud云存储路径 url,//Cloud.getTempFileURL获取的http路径,云存储权限设置为公有读, type,//img, video, file size, name,//上传前文件名 ext,//文件后缀 } 说明: 1、用一张表保存所有云存储文件的信息; 2、文件上传后,将相关信息保存在集合中。 3、任何地方引用图片src,都是使用表中的url,而不是使用fileID, 解决了以下痛点: 痛点一、云存储里有哪些文件,有哪些垃圾文件? 痛点二、云存储某文件夹下有哪些文件?怎么删除云存储文件夹?不熟悉cloud base node sdk或者manage sdk的同学,一定搞不定这个痛点; 痛点三、图片太大,我想用腾讯云图像处理进行压缩裁剪?fileID不支持,只能用url; 痛点四、跨云环境访问图片,不支持fileID,只能用url; 痛点五、在前端引用url,但是删除图片做不到。即通过url,不知道fileID是什么,删除不了云存储文件; 痛点六、前端可以统一管理图片,素材库,而不是在某流程中上传文件后,完全不管理它; 痛点七、可对所有文件图片,分类、贴标签,按openid检索,按type检索,各种姿势检索。 可能还有其他好处,不多介绍。 总之,无论如何,你应该需要这样一张表。
2022-07-12 - 通过WXS实现回弹的平滑滚动容器
前言 最近在愉快的开发微信小程序的时候碰到了一个体验需求,需要在 Android 侧的滚动也需要带回弹效果,类似于在 Web 端可以使用的 better-scroll,查阅微信小程序内置组件 [代码]scroll-view[代码] 无法满足这种场景,没办法,需求得做呀,只能自己动手撸了! 在微信小程序中,我们可以通过 WXS响应事件 来替代逻辑层处理从而有效的提高交互流畅度,其中使用到的 WXS语法 也是非常类似我们非常熟悉 JavaScript,不过很多的 JavaScript 高级语法在 WXS 模块中不能使用,具体可以点击链接进入微信小程序提供的文档。 思路 以横向滚动为例,内容的宽度大于容器的宽度时可以发生滚动,如图 [图片] 接着通过监听三个触摸事件[代码]touchstart[代码]、[代码]touchmove[代码]、[代码]touchend[代码]来实时的改变 content 的 CSS translate,从而从视觉上达到滚动的目的。 WXS 示例 我们先从一个简单的 WXS 使用示例来了解回顾一下使用方式,WXS 的模块系统类似 CommomJS 规范,使用每个模块内置的 [代码]module[代码] 对象中的 [代码]exports[代码] 属性进行变量、函数导出: [代码]// helper.wxs module.exports = { // 注意 WXS 模块中不支持函数简写 touchstart: function touchstart() { console.log('touchstart called') } } [代码] [代码]<!-- index.wmxl --> <!-- module 为模块名,可按规范任意取名 --> <wxs src="./helper.wxs" module="helper" /> <!-- 与普通的逻辑层事件不同,这里需要加上 {{}} --> <view bind:touchstart="{{ helper.touchstart }}">view</view> [代码] 这样就给 [代码]view[代码] 绑定了一个 [代码]touchstart[代码] 事件,在事件触发后,会在控制台打印出字符串 "touchstart called" 好了,现在正式进入滚动容器的逻辑实现 开工 新建 [代码]scroll.wxml[代码] 文件,准备符合上图中结构的 WXML 内容来构造出一个正确的可以滚动条件 [代码]<!-- scroll.wxml --> <!-- 即图中的 container --> <view class="container" style="width: 100vw;"> <!-- 即图中的 content --> <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 新建 [代码]scroll.wxs[代码] 文件,里边用于存放我们实现滚动的所有逻辑 接下来进行初始化操作,首先需要获取到 container 和 content 组件实例,在上一节 “WXS 示例” 中我们知道可以通过在组件中触发一个事件来调用 WXS 模块中的方法,但有没有什么方式可以不用等到用户来触发事件就可以执行吗? 通过阅读 WXS 响应事件 文档,可以了解到,另外一种调用 WXS 模块方法就是可以通过 [代码]change:[prop][代码] 监听某一个组件的 Prop 的改变来执行 WXS 模块中指定的方法,且这个方法会立即执行一次,如下面一个示例 [代码]// helper.wxs module.exports = { setup: function setup() { console.log('setup') } } [代码] [代码]<!-- index.wxml --> <wxs src="./helper.wxs" module="helper"></wxs> <!-- 例如我们指定一个 prop 为 prop1,值为 {{ prop1Data }} --> <!-- 通过 change:prop1 语法对这个 prop 的变化进行监听 --> <view prop1="{{ prop1Data }}" change:prop1="{{ helper.setup }}"></view> [代码] [代码]// index.js Page({ data: { prop1Data: {} } }) [代码] 上面示例中,在页面初始化或 [代码]prop1Data[代码] 发生改变时(准确来说是在逻辑层对 [代码]prop1Data[代码] 调用了 [代码]setData[代码] 方法后,即使 [代码]prop1Data[代码] 的内容不变化),都会调用 [代码]hepler.wxs[代码] 模块中的 setup 方法。 现在我们可以通过 [代码]change:prop[代码] 会立即执行一次的特点,来对我们的滚动逻辑进行一次初始化操作 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } [代码] [代码]<!-- scroll.wxml --> <wxs src="./scroll.wxs" module="scroll" /> <!-- 因本案例只利用 change:[prop] 首次执行的机制,传递的给 _ 的参数是个对象字面量 --> <view class="container" style="width: 100vw;" _="{{ { k: '' } }}" change:_="{{ scroll.setup }}" bind:touchstart="{{ scroll.touchstart }}" bind:touchmove="{{ scroll.touchmove }}" bind:touchend="{{ scroll.touchend }}" > <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 完成基本的跟随手指移动 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } // 实时记录 content 位置 var pos = { x: 0 } // 记录每次触摸事件开始时,content 的位置,后续的移动都是基于此值增加或减少 var startPos = { x: 0 } // 记录触摸开始时,手指的位置,后续需要通过比较此值来计算出移动量 var startTouch = { clientX: 0 } function setTranslate(pos0) { slidingContainerInstance.setStyle({ transform: 'translateX(' + pos0.x + 'px)' }) pos.x = pos0.x } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x } exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX setTranslate({ x: x }) } exports.touchend = function touchend() {} [代码] 效果图: [图片] 处理松手后移动超出的情况,需要对其归位: 添加 clamp 工具方法 [代码]// 给出最小、最大、当前值,返回一个在最下-最大范围之间的结果 // 如: -100, 0, -101 => -100 function clamp(min, max, val) { return Math.max(min, Math.min(max, val)) } [代码] 在 touchend 事件中,添加位置校验的逻辑 [代码]// scroll.wxs exports.touchend = function touchend() { setTranslate({ x: clamp(minTranslateX, maxTranslateX, pos.x) }) } [代码] 看看效果: [图片] 回去是能回去了,有点生硬~ 加上松手回弹动画 其中动画可以使用两种实现方式 CSS Transition:在松手后,给 content 元素设置一个 [代码]transition[代码],然后调整 [代码]translateX[代码] 值归位 JS 帧动画:在松手后,利用动画函数不断调整 [代码]translateX[代码] 来进行归位 两种方式通过给相同的动画函数可以达到一样的体验,但 CSS Transition 在我的理解中不太好处理中止的情况,如在动画过程中,又有了新的触摸事件,这里就会产生抖动或未预期到的结果,但 JS 动画可以很简单的应对 因此后续的动画部分打算采用 JS 动画实现,先准备一些动画函数 [代码]// scroll.wxs // 下面内容通过 better-scroll 借鉴 ~ // 可以理解为入参是一个 [0, 1] 的值,返回也是一个 [0, 1] 的值,用来表示进度 var timings = { v1: function (t) { return 1 + --t * t * t * t * t }, v2: function(t) { return t * (2 - t) }, v3: function(t) { return 1 - --t * t * t * t } } [代码] 定义 [代码]moveFromTo[代码] 方法来实现从一个点通过指定的动画函数运动到另一点 [代码]// scroll.wxs /** * @param fromX 起始点xx * @param toX 目标点 x * @param duration 持续时长 * @param timing 动画函数 */ function moveFromTo(fromX, toX, duration, timing) { if (duration === 0) { setTranslate({ x: fromX }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } } ownerInstance.requestAnimationFrame(rAFHandler) } } [代码] 调整 touchend 事件处理逻辑,添加归位的动画效果 [代码]// scroll.wxs exports.touchend = function touchend() { moveFromTo( pos.x, clamp(minTranslateX, maxTranslateX, pos.x), 800, timings.v1 ) } [代码] 看看效果: [图片] 看起来达到了目的,再优化一下,在滑动超出边界后,需要给一些阻力,不能滑的“太简单了” 给超边界的滚动加阻力 [代码]// scroll.wxs exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX // 阻尼因子 var damping = 0.3 if (x > maxTranslateX) { // 手指右滑导致元素左侧超出,超出部分添加阻尼行为 x = maxTranslateX + damping * (x - maxTranslateX) } else if (x < minTranslateX) { // 手指左滑导致元素右侧超出,超出部分添加阻尼行为 x = minTranslateX + damping * (x - minTranslateX) } setTranslate({ x: x }) } [代码] 瞅瞅: [图片] 效果达到了,手指都划出屏幕了,才移动了这么一点距离 到现在已经完成了一个带回弹效果的滚动容器,但还没有做到“平滑”,即在滑动一段距离松手后,需要给 content 一些“惯性”来继续移动一些距离,体验起来就不会那么生硬 加滑动惯性 在这之前,还有一些准备工作需要做 [代码]// scroll.wxs // 记录触摸开始的时间戳 + var startTimeStamp = 0 // 增加动画完成回调 + function moveFromTo(fromX, toX, duration, timing, onComplete) { if (duration === 0) { setTranslate({ x: fromX }) + ownerInstance.requestAnimationFrame(function() { + onComplete && onComplete() + }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) + } else { + onComplete && onComplete() + } } ownerInstance.requestAnimationFrame(rAFHandler) } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x + startTimeStamp = event.timeStamp } [代码] 因为是在松手后加动量,所以继续处理 touchend [代码]// scroll.wxs exports.touchend = function touchend(event) { // 记录这一轮触摸动作持续的时间 var eventDuration = event.timeStamp - startTimeStamp var finalPos = { x: pos.x } var duration = 0 var timing = timings.v1 var deceleration = 0.0015 // 计算动量,以下计算方式“借鉴”于 better-scroll,有知道使用什么公式的朋友告知以下~ var calculateMomentum = function calculateMomentum(start, end) { var distance = Math.abs(start - end) var speed = distance / eventDuration var dir = end - start > 0 ? 1 : -1 var duration = Math.min(1800, (speed * 2) / deceleration) var delta = Math.pow(speed, 2) / deceleration * dir return { duration: duration, delta: delta } } // 此次滑动目的地还在边界中,可以进行动量动画 if (finalPos.x === clamp(minTranslateX, maxTranslateX, finalPos.x)) { var result = calculateMomentum(startPos.x, pos.x) duration = result.duration finalPos.x += result.delta // 加上动量后,超出了边界,加速运动到目的地,然后触发回弹效果 if (finalPos.x > maxTranslateX || finalPos.x < minTranslateX) { duration = 400 timing = timings.v2 var beyondDis = containerRect.width / 6 if (finalPos.x > maxTranslateX) { finalPos.x = maxTranslateX + beyondDis } else { finalPos.x = minTranslateX + beyondDis * -1 } } } moveFromTo(pos.x, finalPos.x, duration, timing, function () { // 若动量动画导致超出了边界,需要进行位置修正,也就是回弹动画 var correctedPos = { x: clamp(minTranslateX, maxTranslateX, pos.x) } if (correctedPos.x !== pos.x) { moveFromTo( pos.x, correctedPos.x, 800, timings.v1 ) } }) } [代码] 继续看看效果: [图片] 有了有了 只是现在的滚动容器还很“脆弱”,在进行动量动画、回弹动画时,如果手指继续开始一轮新的触摸,就会出现问题,也就是最开始我们在选择 CSS 过渡和 JS 动画考虑到的问题 解决连续触摸滑动问题 在 [代码]moveFromTo[代码] 方法中,添加强制中止的逻辑 [代码]// scroll.wxs + var effect = null function moveFromTo(fromX, toX, duration, timing, onComplete) { + var aborted = false if (duration === 0) { setTranslate({ x: fromX }) ownerInstance.requestAnimationFrame(function () { onComplete && onComplete() }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { + if (aborted) return var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } else { onComplete && onComplete() } } ownerInstance.requestAnimationFrame(rAFHandler) } + if (effect) effect() + effect = function abort() { + if (!aborted) aborted = true + } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x startTimeStamp = event.timeStamp + if (effect) { + effect() + effect = null + } } [代码] 体验一下: [图片] 这样一个带回弹的平滑滚动容器就处理的可以使用啦,有问题的地方欢迎大家指出讨论 结尾 完整源码托管在 Github 中:weapp-scroll 其中功能、逻辑更为完善,并同时支持横向、竖向方向的滚动,适合在 Android、PC 场景的使用(毕竟 IOS 侧可以直接使用微信内置组件 [代码]scroll-view[代码]~)。若有帮到希望可以给个星星~ 完~
2023-07-07 - 重写计算属性和watch数据监控器
前言 一直在用官方插件 miniprogram-computed(当前版本 4.3.8). 细读源码发现一些性能问题,这才有了重写的念头。在这个做个记录贴,欢迎讨论。 计算属性的源码分析 初始化时机 源码截图 [图片] 官方在组件 attached 周期会对配置中 computed 字段做初始化。 在首屏渲染时,有计算属性的组件都会运行一次 attached 周期,项目中不乏有大量复用的组件或计算属性较多的组件。这显然对首屏渲染速度不是很友好。 解决思路:在beforeCreated周期做初始化,每个组件的计算属性的初始化值只需计算一次,不必担心复用带来的性能问题。 计算属性更新器(computedUpdaters) 源码截取 [代码]if (computedDef) { observersItems.push({ fields: "**", observer(this: BehaviorExtend) { if (!this._computedWatchInfo) return; const computedWatchInfo = this._computedWatchInfo[computedWatchDefId]; if (!computedWatchInfo) return; let changed: boolean; do { changed = computedWatchInfo.computedUpdaters.some((func) => func.call(this) ); } while (changed); }, }); } [代码] 官方初始化计算属性时会在 observers 字段内中添加’**’ 字段,在其中循环调用 computedUpdaters 函数,每次会拿出所有缓存中所有计算属性的关联字段,循环对比缓存值和当前实例的新值。来判断是否需要重新初始化对应的计算字段,需要的话, setData对应计算属性新值(不会立即运行,会被收集), 把新的关联和值替换旧的缓存,进入下一次do while循环,直到没有关联的计算属性需要更新后,setData此次do while收集的所有更新的计算字段。这会再一次触发 observers’**’ 整体循环。 <br> 性能问题。 计算属性更新后,会再此触发 observers '**'进行一次无意义的 计算属性更新器运行(新旧值检测)。 observers监控很敏感 即使数据没有改变,也会触发计算属性更新器运行。 即使this.setData 无关计算属性的字段,也会触发计算属性更新器运行。 若计算属性依赖properties字段且字段类型为对象,那么由于小程序的组件由内而外挂载数据,后代组件会可能接受n多次的 null 或者 空对象{},这都会引起计算属性 计算属性更新器运行毫无意义。 <br> 解决思路:劫持 setData,获取到当前 setData 的配置对象,若有字段关联了 计算属性 则更新对应的计算属性(A),若有计算属性B依赖A,再更新B… , 所有计算属性更新验证完毕后,把劫持的的setData配置对象加入需要更新的计算属性字段 一起做一次setData。properties 字段在初始化计算属性时(beforeCreated周期中),为被计算属性关联的字段加入 observer 函数,针对对象可设置当传入null和空对象直接返回,否则比对新旧值,不同的话收集,全部 关联的properties 都收集完后 统一触发 计算属性更新。 <br> 关联的路径 源码 [代码]const wrapData = ( data: unknown, relatedPathValues: Array<IRelatedPathValue>, basePath: Array<string>, ) => { if (typeof data !== "object" || data === null) return data; const handler = { get(obj: unknown, key: string) { if (key === "__rawObject__") return obj; let keyWrapper = null; const keyPath = basePath.concat(key); const value = obj[key]; relatedPathValues.push({ path: keyPath, value, }); keyWrapper = wrapData(value, relatedPathValues, keyPath); return keyWrapper; }, }; try { return new Proxy(data, handler); } catch (e) { return new ProxyPolyfill(data, handler); } }; [代码] 示例 A [代码]Component({ data: { productInfo: { id: "001", selectedCount: 0, discount: 9, originalPrice: 10, }, }, computed: { realPrice(data) { return ( (data.productInfo.originalPrice * data.productInfo.discount) / 10 ); }, // ... }, // ... }); [代码] 示例 A 中,官方生成的 realPrice 缓存依赖相关路径为: [图片] 图中可以看出 0 和 2 重复,且这两项不是真正的关联依赖。会导致 50%的性能浪费(二段对象依赖如示例),如果是三段对象依赖会浪费 2/3 的性能…,而且会导致一些情况发生,比如更新的是 [代码]productInfo.selectedCount[代码] 有可能会匹配上这个计算属性导致这个缓存重做,而实际上是没有意义的,浪费更多性能。 解决办法: [代码]const handler = { get(obj: unknown, key: string) { if (key === "__rawObject__") return obj; let keyWrapper = null; const keyPath = basePath.concat(key); const value = obj[key]; // 去除关联的上一个路径 只要最后一个路径 if (basePath.length !== 0) { relatedPathValues.pop(); } relatedPathValues.push({ path: keyPath, value, }); keyWrapper = wrapData(value, relatedPathValues, keyPath); return keyWrapper; }, }; [代码] [图片] 很遗憾的时,使用proxy劫持get函数得到的关联路径是不准确的。因为无法对一些方法返回字段做proxy代理。如下 [代码]data:{ bool:false, list:[1,2,3,4,5] }, computed:{ listOther(){ const bool = this.data.bool const list = this.data.list.slice() // 得到的list 是无法被prxoy代理的 if(bool){ list.splice(2) ///无法获取依赖 return list }else{ return list[4] //无法获取依赖 } } } [代码] 即使传入克隆的this.data(为了减少一些方法的使用) 也无法保证获取到正确的关联字段。看了其他正则收集依赖等思路都有问题存在。如果您更好收集路径的办法,请留言告诉我。 所以官方和重写的计算属性当前都存在无法避免的性能浪费。特别是计算属性依赖数组时,很有可能做无意义的触发计算属性更新。很遗憾。 watch 监控器源码分析 初始化 官方watch 在 created 周期对配置中 watch 字段做了初始化, 如下图: [图片] 主要是生成第一次监控字段的值,缓存起来用于后续比对。 之后会在把每个字段加入到 observers 字段下 触发 当 observers 对应字段触发时,watch 劫持函数通过对比当前值和旧值(缓存中)是否相等(===)或者严格相等(深度比较)来决定是否触发 watch 对应的函数。触发情况下,会对缓存值更新。 需要注意的时避免在 watch 函数中使用 setData 触发可能引起自身 watch 字段变换的值。会循环触发,监控函数递归,内存泄露。 已知的不足。 对 properties 对象类型字段监控时,如果传入的是异步数据,那么在子组件 attach 阶段获取到的数据为"null",一样会触发 watch 的监控。 示例 C [代码]// 页面 wxml <product-item attach productInfo="{{productInfo}}" />; // 页面 js Component({ methods: { onLoad() { console.log("onLoad"); // 模拟异步获取数据 setTimeout(() => { console.log("异步数据获取成功"); this.setData({ productInfo: { id: "001", name: "可乐", selectedCount: 0, originalPrice: 10, discount: 5, }, }); }, 1000); }, }, }); // product-item js Component({ properties: { productInfo: Object, // type productInfo = {id:string;name:string;selectedCount:number;originalPrice:number;discount:number} }, computed: { realPrice(data) { return data.productInfo?.originalPrice || 0 * data.productInfo?.discount || 0 / 10; }, selectedCount(data) { return data.productInfo?.selectedCount || 0; }, }, lifetimes: { attach() { console.log(`attach时productInfo的值为${this.data.productInfo}`); }, }, watch: { productInfo() { // 在created初始化时缓存val为null,在attach时因为页面异步数据未到达,productInfo为undefined强转为null,监控触发。 }, "productInfo.selectedCount"() { // 避免这么写。 // 报错 TypeError: Cannot read property 'selectedCount' of null }, "realPrice,selectedCount"() { console.log("realPrice或selectedCount发生改变"); }, }, }); [代码] 或许你会说使用"**"啊,那么如果对象有默认值的情况呢?同样会触发导致 watch 下的"productInfo.selectedCount"字段报错。是由于强转带来的后果(最讨厌的黄字提醒)。从根本上来说是生命周期顺序引起的。回顾下组件加载顺序 [代码]beforeCreate --> created-->attach -->attached[代码] 其中 attach 周期时 即获取父组件properties的传值可以触发 observers 字段,且 setData 数据是有效的。 解决思路:watch 劫持函数监控到值为 null 时不触发 watch 函数 更好的办法时 不要对properties中对象字段的子字段做watch处理。 由于监控器生效在 created 之后(attach 周期就可以触发),而计算属性生效在 attached 周期。如果 watch 字段监控了计算属性,那么在 attached 周期后,watch 会得到计算属性的’无意义’的触发。有人提了 issue #58 官方也做了"修复"。 官方在 computed 初始化的时候,给计算属性关联字段做了个 mark("_triggerFromComputedAttached"=true)。 [图片] observers 在监控到 comupted 字段改变时,会判断是否为第一次触发(_triggerFromComputedAttached===true),是的话不许触发 watch 缓存更新和调用 watch 字段函数,把 mark 字段变为 false。 [图片] 但忽略一个问题 如上面 示例 C 中 watch 字段 "realPrice,selectedCount"不会 1 秒后被触发,但显然他们的值改变了。原因就在 mark 的判断上。因为在第一次 mark 判断时没有对所有字段的 mark 做 false 处理。导致触发时,因为 watch 字段上后面的字段还存在 mark 为 true 的情况。导致整个字段跳过。示例 C 中之所以不触发是因为 selectedCount 字段的 mark 还存在,被误认为第一次触发。而如果只是单独监控一个字段。那么都会被触发。 解决思路:在 watch 判断计算属性是否为第一次触发时,把整个 watch 字段关联的 mark 都设置为 false。而不是设置第一个后就跳出。 重写后的功能实现 解决上面已知问题。 computed 和 watch 都在 beforeCreate(主要的) 和 created(辅助的) 周期完成。不涉及 attached 周期,提高效率。 computed 改用 this获取data,取消参数传值。主要考虑是 ts 类型可以实现彼此调用提示。当前官方通过参数传值,无法获取其他计算字段的类型提示(ts 的泛型机制问题),this 只提供 data 字段,不喜欢可以自己改为全 this 字段。 watch 若监控的是对象或对象子属性时,若新值为null或空对象,不报错,不触发watch, 增加偶数位参数为前一参数的旧值。去除’**’,新旧值比较为JSON.stringify。只支持单字段触发(多字段不常用,且可替代,主要为了ts类型性能考虑) 对响应式数据的支持(非官方,新方案) <br> 示例 D [代码] import { observable, runInAction } from "mobx"; //基于mobx最新版本 const counter = observable({ count: 1, }); setInterval(() => { runInAction(() => { store.age ++ }); }, 1000); Component({ properties:{ productInfo:Object //type:{id:string;name:string;selectedCount:number;originalPrice:number;discount:number} }, data:{ // 传入响应式数据(新的响应式数据方案) responsiveCounter:()=>counter.count } computed:{ realPrice(){ return this.data.productInfo?.originalPrice || 0 * this.data.productInfo?.discount || 0 / 10 }, selectedCount(){ return this.data.productInfo?.selectedCount }, count(){ //支持计算响应式数据 return this.data.responsiveCounter + 1 } }, watch:{ //productInfo为null 或 {} 不报错 不触发 "productInfo"(newVal,oldVal){ newVal; oldVal; }, //productInfo为null 或 {} 不报错 不触发 "productInfo.originalPrice"(newVal,oldVal){ newVal; oldVal; } //对计算属性watch "realPrice"(realPriceNewVal,realPriceOldVal){ //... } } }) [代码] 代码片段 这个计算属性和watch在不断的迭代中 最新behavior查看源码 欢迎留言讨论,指错,如果此文对你有帮助,请点赞支持。
2022-07-31 - base64图片上传云托管对象存储的方法
H5端使用canvas生成的图片获取到的是图片的base64字符串,想要把该图传到对象存储那么就要将base64想办法转为图片file文件对象。 给大家分享一个base64转file文件对象的方法: //参数1为 dataurl为base64 //参数2为 name为自定义名称 const base64ToFile = (dataurl,name) => { const arr = dataurl.split(','); const mime = arr[0].match(/:(.*?);/)[1]; const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } const suffix = mime.split('/'); return new File([u8arr], name, { type: mime }); };
2022-06-27 - 实现一个虚拟滚动的React-Hook
前言 网页的日常开发中,渲染长列表的场景非常常见。比如旅游网站需要完全展示出全国的城市列表,或者购物网站的商品列表。 长列表的数量一般在几百条范围内不会出现意外的效果,浏览器本身足以支撑.可一旦数量级达到上千,页面渲染过程会出现明显的卡顿。数量突破上万甚至十几万时,网页可能直接崩溃了。 为了解决长列表造成的渲染压力,业界出现了相应的应对技术,即长列表的[代码]虚拟滚动[代码]。 [代码]虚拟滚动[代码]的本质,不管页面如何滑动,[代码]HTML 文档[代码]只渲染当前屏幕视口展现出来的少量[代码]Dom[代码]元素。 假设长列表有[代码]10[代码]万条数据,,对用户而言,他永远只会看到屏幕展现出的那十几条数据。因此页面滑动时,通过监听滚动事件快速切换视口的数据,就能高度模拟滚动效果。 参考下图加深理解: [图片] [代码]虚拟滚动[代码]最终只需要渲染少量的[代码]Dom[代码]元素就能模拟出相似的滚动效果,这让前端工程师开发几万甚至十几万条的长列表都成为了可能。 下图是手机上实测滑动一张涵盖全球所有城市的长列表页面. [图片] 虚拟滚动实现步骤 实现「虚拟列表」可以简单理解为就是在列表发生滚动时,改变「可视区域」内的渲染元素。大概的文字逻辑步骤如下: 根据单个元素高度计算出滚动容器的可滚动高度,并撑开滚动容器; 根据可视区域计算总挂载元素数量; 根据可视区域和总挂载元素数量计算头挂载元素(初始为 0)和尾挂载元素; 当发生滚动时,根据滚动差值和滚动方向,重新计算头挂载元素和尾挂载元素。 核心的实现步骤: [图片] 数据项不定高 还可以稍微做点小拓展,将item高度设定为一个配置项,可以设定为一个方法,改方法以每一个数据项的值和索引index作为参数。 按照这种方式,我们的hook能支持的自定义化会更加强大一些,我们在计算展示List片段的时候,在需要用到item高度的时候都需要针对性的做一些改变,高度不一定是个固定数值,可能是一个方法。 虚拟滚动和React Hook [代码]Hook[代码]是 React 16.8 中增加的新功能,可以让我们更好地复用React状态逻辑代码,我们可以使用React提供给我们的hook将虚拟滚动抽取为一个hook,方便我们随时复用这块功能。 Hook整合虚拟滚动 利用 [代码]useEffect[代码]监听容器元素,当容器元素渲染完毕,注册[代码]Scroll[代码]事件监听,当Scroll事件触发时候,根据滚动距离计算需要展示的List片段 按照虚拟滚动原理,截取需要展示的List并重置页面state,触发页面重新渲染 hook将需要展示的List片段作为状态返回,方便Hook与组件进行交互 总结 通过借助Hook和我们对于虚拟滚动功能的抽象,我们后续就可以很方便的给我们组件复用这块相对复杂的功能。 [代码]const [list, scrollTo] = useVirtualList(originalList, { containerTarget: containerRef, wrapperTarget: wrapperRef, itemHeight: 60, overscan: 10, }); [代码] 存在的不足 虚拟功能虽然很强大,但是仍然存在它的不足,列表项的高度必须相对固定,如果每个列表项渲染的高度完全未知,那虚拟滚动功能就无法使用。 链接 源代码地址 demo地址
2022-06-27 - 小程序云开发查询数据库查询慢?该如何优化。
小程序云开发查询数据库好慢呀,有一个列表页需要查询大量数据,将查询到的数据进行了拼接。获取所有的,将近要3秒。有什么优化的方法吗?小白的代码很烂,勿喷。 [图片]
2022-06-26 - 小程序中如何修改 svg 图片的颜色
已知小程序 <image /> 支持 svg 图片渲染;但是在开发场景中,如果我们需要修改 svg 图片原有的颜色,往往需要去修改 svg 文件本身。这样修改既不优雅,也不利于 svg 图片的复用。有没有一种方法可以更加优雅地去修改 svg 图片的颜色呢? 一、可行性探讨 svg 源码修改 既然要在原 svg 文件的基础上修改颜色,让我们先看一下 svg 源码是如何的,下面是一个三角形的 svg 源码: [图片] 我们可以发现在源码中,<path> 中的 fill 属性便是我们需要修改的颜色。如果我们能读取源码,修改对应属性,便能修改 svg 图片的颜色了;但开发过程中,我们的 svg 源文件往往是网络资源,并不能直接修改,有没有一种方法可以将 svg 源码直接在小程序进行渲染呢? svg 源码渲染 小程序原生虽然不支持 <svg> 渲染的,但我们可以通过 background-image 样式属性对 URL 资源进行加载;我们只需要将修改后 svg 源码进行 URL 编码,即可将我们想要的效果渲染出来。 方案总结 1、读取 svg 文件 2、匹配 Hex 字段并进行修改 3、将修改后的 svg 数据进行 URL 编码 4、将 URL 数据通过 background-image 样式属性进行渲染 二、技术实现 调用方式:组件 为方便调用,将其封装自定义组件,组件命名 svg,承接 svg 渲染能力,后续可在此基础上丰富 svg 的能力。 入参:src <string>,colors <string[]> 通过 src 参数传递 svg 图片链接; svg 图片可能包括多个元素,这个时候就需要我们对不同的元素定义各自的颜色。 默认属性 由于 svg 自身不存在尺寸,我们可以将其宽高同时设置成 100%,这样就可以通过承载其的父元素决定 <svg /> 的渲染尺寸。 三、<svg /> 组件代码 代码片段,点击进入 四、更多功能扩展 自定义 svg 渲染 前面我们修改 svg 图片是通过修改其 fill 属性实现的;更进一步我们完全可以不依赖外部资源,自定义 svg 节点,按照 svg 的规范进行图片的绘制,感兴趣可以尝试尝试。
2022-06-22 - 小程序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 - 小代码大作用,云函数openapi
以下云函数openapi的代码极简,但是作用很多,包括: (代码直接复制可用) 1、支持所有云调用;是所有哦。 2、支持大图片安全检查 3、支持环境共享的云调用。 云函数代码如下: const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const opt = {} exports.main = async event => { const wxc = cloud.getWXContext() opt.appid = wxc.FROM_APPID || wxc.APPID//获取环境或共享环境的访问端小程序appid if (event.action == 'security.imgSecCheck') return await imgSecCheck(event)//大图片安全检查 if (event.action == 'xxx') return await xxx(event)//其他特殊处理 return await cloud.openapi(opt)[event.action](event.body || {}) } async function imgSecCheck(event) { let res = await cloud.downloadFile({ fileID: event.fileID, }) return await cloud.openapi(opt).security.imgSecCheck({ media: { contentType: "image/png", value: res.fileContent } }) } 小程序端的调用代码示例: 1、获取小程序码 app.cloud.callFunction({ //app.cloud是小程序当前环境的cloud,在app.js中初始化,可能是wx.cloud,也可能是共享环境的cloud name: 'openapi', data: { action: 'wxacode.getUnlimited', body: { scene, width: 280 }, } }) 2、发送订阅消息 app.cloud.callFunction({ name: 'openapi', data: { action: 'subscribeMessage.send', body: { "touser": openid, "page": 'pages/index/index?orderId=' + order._id, data, "templateId": tid, "miniprogramState": 'trial' } } }) 3、获取小程序直播房间列表 app.cloud.callFunction({ name: 'openapi', data: { action: 'liveBroadcast.getLiveInfo', body: { start: 0, limit: 100 } } })
2022-06-14 - 小程序自动销毁后的使用体验优化
2023年12月24日更新 经过测试发现,官方提供的onSaveExitState有一些问题,不太符合官方文档提出的预期,大家暂时慎重使用onSaveExitState功能,用其他方案吧。 ======================================================================== 假设用户在小程序内进行一个答题的活动,或者进行一个测试,这个活动或测试的时间比较长,大概需要10分钟的时间。当用户答题进行到一半的时候,来了一个重要的电话,电话打了十几分钟,回来之后想着继续进行操作,发现小程序是重新打开的状态。之前答题答了5分钟,白费了。这样,用户需要重新进行答题。 问题场景分析 用户离开小程序时间太久(官方说30分钟以上,但测试十几分钟分钟以上)或者手机内存不够用的时候,小程序会被销毁,也就是完全终止运行了。此时用户再想进入小程序进行之前的操作,只能重新操作一遍。 解决方案 以本场景为例,如果用户正在答题,在用户退出小程序的时候,将当前页面的答题进度数据进行一个保存,当用户再重新进入小程序的时候,检查是否有答题进行一半的数据。如果有,自动跳转到答题的页面,并且在onload中恢复退出之前状态的数据,让用户继续进行答题的操作。 微信小程序有一个非常好用的回调函数onSaveExitState。 退出状态onSaveExitState 每当小程序可能被销毁之前,页面回调函数 [代码]onSaveExitState[代码] 会被调用。如果想保留页面中的状态,可以在这个回调函数中“保存”一些数据,下次启动时可以通过 [代码]exitState[代码] 获得这些已保存数据。 代码示例: { "restartStrategy": "homePageAndLatestPage" } Page({ onLoad: function() { var prevExitState = this.exitState // 尝试获得上一次退出前 onSaveExitState 保存的数据 if (prevExitState !== undefined) { // 如果是根据 restartStrategy 配置进行的冷启动,就可以获取到 prevExitState.myDataField === 'myData' } }, onSaveExitState: function() { var exitState = { myDataField: 'myData' } // 需要保存的数据 return { data: exitState, expireTimeStamp: Date.now() + 24 * 60 * 60 * 1000 // 超时时刻 } } }) onSaveExitState 返回值可以包含两项: 字段名 类型 含义 data Any 需要保存的数据(只能是 JSON 兼容的数据) expireTimeStamp Number 超时时刻,在这个时刻后,保存的数据保证一定被丢弃,默认为 (当前时刻 + 1 天) 一个更完整的示例:在开发者工具中预览效果 注意事项如果超过 [代码]expireTimeStamp[代码] ,保存的数据将被丢弃,且冷启动时不遵循 [代码]restartStrategy[代码] 的配置,而是直接从首页冷启动。[代码]expireTimeStamp[代码] 有可能被自动提前,如微信客户端需要清理数据的时候。在小程序存活期间, [代码]onSaveExitState[代码] 可能会被多次调用,此时以最后一次的调用结果作为最终结果。在某些特殊情况下(如微信客户端直接被系统杀死),这个方法将不会被调用,下次冷启动也不遵循 [代码]restartStrategy[代码] 的配置,而是直接从首页冷启动。
2023-12-24 - 为小程序添加生命周期(beforeCreate和attach)
示例 1 index 页面 [代码]// index.json { "usingComponents": { "parent":"/component/parent/parent" } } // index.wxml <parent /> // index.js 这里使用Component来取代Page构造器生成页面实例。 Component({ lifetimes: { created() { console.log("index --> created"); }, attached() { console.log("index --> attached"); }, }, methods: { onLoad() { console.log("index --> onLoad"); }, }, }); [代码] parent 组件 [代码]//parent.json { "usingComponents": { "son": "/components/son/son" } } //parent.wxml <son /> //parent.js Component({ lifetimes: { created() { console.log("parent --> created"); }, attached() { console.log("parent --> attached"); }, }, }); [代码] son 组件 [代码]//son.json { "usingComponents": {} } //son.wxml <test>son</test> //son.js Component({ lifetimes: { created() { console.log("son --> created"); }, attached() { console.log("son --> attached"); }, }, }); [代码] 编译后 控制台打印如下 son --> created parent --> created index --> created index --> attached parent --> attached son --> attached index–>onLoad 小结: 组件建立是由内而外的。页面所有组件都挂载到页面组件后,才由外向内触发每个子组件的 attached 周期。最后触发页面的(最外层组件)onLoad 周期。 值得注意的是每个组件 attached 周期触发时,this.data 数据 可能被上级组件传入多次,导致获取不到首次挂载时的 this.data 对象。对调试等一些情况是不友好。 beforeCreate 和 attach 示例 1 展示了小程序给我们提供的生命周期触发顺序。但缺少一些生命周期。或许是小程序认为那些不重要,我们用不到吧。让我们来补充一下,这对调试、开发插件等情形很有帮助 增加 attach 周期 (可用作复杂组件调试场景,相比与 attached 只是触发时机不一样,this.data.fields 是首次挂载数据时的实例数据.而 attached 触发时,由于上级组件传入数据导致实例data中的数据已不是当初的样子了,在应对复杂组件应用的调试时不是很好用) 分别在示例 1 增加 如下代码 [代码]<!-- index.wxml 增加 attach 传值--> <parent attach /> <!-- parent.wxml 增加 attach 传值--> <son attach /> [代码] [代码]// parent.js Component({ //新增 properties: { attach: Boolean, }, observers: { attach() { console.log("parent --> attach"); }, }, //...省略之前示例1中的代码 }); // son.js Component({ //新增 properties: { attach: Boolean, }, observers: { attach() { console.log("son --> attach"); }, }, //...省略之前示例1中的代码 }); [代码] 控制台打印如下 son --> created son --> attach parent --> created parent --> attach index --> created index --> attached parent --> attached son --> attached 至此我们有了 attach 生命周期 利用这个周期,我们可以看到组件挂载时 this 上的所有信息,在某写情形下对代码优化,调试带来方便。 稍后我们将它和 beforeCreate 一起封装起来。 增加 beforeCreate 周期 (触发时机在实例数据建立之前 this 指向当前配置) 因为 created 周期无法修改实例数据、attached 周期又触发太晚、配置阶段又不能调用函数 。 某些情形下(全局注入等)还是需要 beforeCreate 周期的。 办法是 可以为单个组件添加 或 劫持 Component 全局添加如下 behavior [代码]const beforeCreate = Behavior({ definitionFilter(opt) { opt.lifetimes?.beforeCreate?.call(opt); delete opt.lifetimes?.beforeCreate; //删除 避免冲突吧。 }, }); // 全局注入beforeCreate const originalComponent = Component; Component = function (options) { options.behaviors ||= [].push(beforeCreate); return originalComponent(options); }; [代码] 编译后 控制台打印如下 parent --> beforeCreate son --> beforeCreate son --> created parent --> created index --> created index --> attached parent --> attached son --> attached index --> onLoad 合并 attach 和 beforeCreate [代码]// beforeCreateAndAttach.js export const beforeCreateAndAttach = Behavior({ definitionFilter(opt) { //attach 注意这里没有对properties和observers字段中判断是否已经有了attach字段 (opt.properties ||= {}).attach = Boolean; (opt.observers ||= {}).attach = function () { delete this.data.attach; // 没什么用了 opt.lifetimes?.attach?.call(this); }; // beforeCreate opt.lifetimes?.beforeCreate?.call(opt); delete opt.lifetimes?.beforeCreate; // 没什么用了 }, }); //全局注入 const originalComponent = Component; Component = function (options) { (options.behaviors ||= []).push(beforeCreateAndAttach); return originalComponent(options); }; [代码] [代码]// app.js import "./beforeCreateAndAttach"; App({}); [代码] index --> beforeCreate parent --> beforeCreate son --> beforeCreate son --> created son --> attach parent --> created parent --> attach index --> created index --> attached parent --> attached son --> attached index --> onLoad 总结: 利用 behavior 中的 definitionFilter,可在组件实例创建前修改原配置。模拟 beforeCreate 生命周期。 利用 properties 传 boolean 值,通过 observers 监控传值字段 达到获取组件挂载时第一时间的 this 实例数据,模拟 attach 生命周期。 已知不足是:需要在要调用 attach 生命周期的组件上加入 attach(可自定义)字段, 有可能导致数据字段冲突,但此周期多用于调试,仅当需要调试组件,查看挂载时数据时,手动添加也没什么问题。 水平有限,如有错误之处,请留言指教。 代码片段
2022-06-03 - Aggregate.geoNear使用注意事项
https://developers.weixin.qq.com/miniprogram/dev/wxcloud/reference-sdk-api/database/aggregate/Aggregate.geoNear.html 官方文档表述和例子都不够详尽,自己踩了一天的坑,总结一下分享给大家: 1、参数near应该传入一个什么对象? 文档表明需要传入GeoPoint对象 https://developers.weixin.qq.com/miniprogram/dev/wxcloud/reference-sdk-api/database/geo/Geo.Point.html 该文档中指出可以用json对象等效使用,如:{ type: 'Point', coordinates: [113, 23] },在做数据插入时确实work,但是在这里只有传入db构造函数才work,如:db.Geo.Point(113, 23) 2、maxDistance 使用时需要以米为单位,再除以地球半径,即:6378137。如限定10公里范围内:maxDistance: 100000/637813 3、distanceMultiplier 基本上用不到这个参数,输出的结果distance以米为单位。 4、其他小问题,比如它只能处在aggregate第一阶段、必须建立地理位置索引,不必细说。
2022-06-02 - 【讨论】关于收回getUserProfile,使用button的开放能力chooseAvatar替换获取头像的思考
背景: 应微信官方通知(https://developers.weixin.qq.com/community/develop/doc/00022c683e8a80b29bed2142b56c01?blockType=1),即将收回getUserProfile,并且官方推荐通过使用button的开放能力chooseAvatar来获取头像(https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html),最近项目也在做相应的调整。 问题描述: 由于原有项目中,由于原先获取的微信头像都为正方形,且项目中显示头像的地方都为圆形,如下图1所示。现替换button的开放能力chooseAvata后,如下图2所示,选择”从相册选择“或者”拍照“时,得到的图片为长方形,这样获取到的图片,在项目中对应的头像显示或宽高比失调、或图片显示效果差(即设置image组建mode属性保持宽高比不变时,自动截取的部分图片不是用户想要的那个部分)。 图1:[图片] 图:2 [图片] 需求分析: 基于以上的场景,查阅了图片相关的api,找到了wx.editImage,效果如下图1所示;倘若wx.editImage支持自定义裁剪比例就好了,遗憾的是wx.editImage的入参只有:src,success,fail,complete这四个,如下图2所示;GG,黔驴技穷了,总不能跟产品经理说,微信小程序相关api不支持,不予处理。一个好的api工程师是需要严格要求自己的。 图1:[图片] 图2:[图片] 小结: 作为一个严谨的程序员,希望官方大大能拉我一把,快帮帮孩子吧(wx.editImage,能不能加一些配置项啥的),也欢迎大家一起帮忙分析下,有没有啥其他的解决方案,欢迎留言哦~
2022-05-28 - 微信小程序运行性能注意点
小程序的运行时性能直接决定了用户在使用小程序功能时的体验。如果运行时性能出现问题,很容易出现页面滚动卡顿、响应延迟等问题,影响用户使用。如果内存占用过高,还会出现黑屏、闪退等问题。 1.控制WXML节点数量和层级 建议一个页面 WXML 节点数量应少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个。太大的 WXML 节点树会增加内存的使用,样式重排时间也会更长,影响体验。 2.避免滥用image组件的 widthFix/heightFix模式,控制图片资源的大小。 这种模式会在图片加载完成后,动态改变图片的高度或宽度。图片高度或宽度的动态改变,可能会引起页面内大范围的布局重排,导致页面发生抖动,并造成卡顿。 3.合理使用的setData setData 应只用来进行渲染相关的数据更新。用 setData 的方式更新渲染无关的字段,会触发额外的渲染流程,或者增加传输的数据量,影响渲染耗时。 不要过于频繁调用setData,应考虑将多次setData合并成一次setData调用;与界面渲染无关的数据最好不要设置在data中,可以考虑设置在page对象的其他字段下。对于列表来说,可以利用setData进行列表局部刷新。 4.避免不当的使用onPageScroll 每一次事件监听都是一次视图到逻辑的通信过程,所以只在必要的时候监听pageSrcoll。避免在 scroll 事件监听函数中执行复杂逻辑。 5.合理的利用缓存 利用storage API, 对变动频率比较低的异步数据进行缓存,二次启动时,先利用缓存数据进行初始化渲染,然后后台进行异步数据的更新,这不仅优化了性能,在无网环境下,用户也能很顺畅的使用到关键服务。 6.采用独立分包技术 提升体验最直接的方法是控制小程序包的大小。目前很多小程序主包+子包的方式,这对用户停留时间比较短的场景中,体验不是很好,且浪费了部分流量。 可以采用独立分包技术,区别于子包,和主包之间是无关的,在功能比较独立的子包里,使用户只需下载分包资源。 7.使用自定义组件 自定义组件的更新只在组件内部进行,不受页面其他不能分内容的影响。各个组件也将具有各自独立的逻辑空间。每个组件都分别拥有自己的独立的数据、setData调用。
2022-05-28 - 小程序的同层渲染
背景 小程序的原生组件无法被覆盖,例如video 、ive-player 组件上无法覆盖原生组件 原因:由于 微信小程序的架构wxml设计 wxml 微信小程序的webview 层渲染是依托于 html 的。那么想view、span 这些都是被微信小程序通过wcc工具编译wxml为js,得到 Virtual DOM 结构,例如 [代码] "tag": "wx-page", "children": [ { "tag": "wx-view", 」 ] } [代码] 最后在被parse 为html,那像一些原生的组件则是依托微信的能力实现,那原生组件在创建的时候,会先用 基础组件占位,然后在上层使用,原生组件。 原生组件的层级是最高的,所以页面中的其他组件无论设置 z-index 为多少,都无法盖在原生组件上 cover-view 与 cover-image: 为了解决原生组件层级最高的限制。小程序专门提供了 cover-view 和 cover-image 组件,可以覆盖在部分原生组件上面。 限制: 无法覆盖textarea、input。 不支持设置单边的border、background-image、shadow、overflow: visible等。 cover-view 支持 overflow: scroll,但不支持动态更新 overflow。 cover-view和cover-image的aria-role仅可设置为 button,读屏模式下才可以点击,并朗读出“按钮”;为空时可以聚焦,但不可点击。 cover-view和cover-image的子节点如果溢出父节点,容易出现布局错误。 自定义组件嵌套 cover-view 时,自定义组件的 slot 及其父节点暂不支持通过 wx:if 控制显隐,否则会导致 cover-view 不显示。 同层渲染 微信官方给出解决方案, 在 WebView 所渲染的页面中,与其他 HTML 控件在同一层级。具体原生组件https://developers.weixin.qq.com/miniprogram/dev/component/native-component.html#原生组件的使用限制
2022-05-21 - 小程序销毁的时机
小程序会被销毁的三大场景: 1 当钱小程序进入后台后,如果很长时间-目前是 30 分钟-后没有再次进入,小程序会被销毁。 2 当小程序占用系统资源过高,会被系统销毁或被微信客户端主动回收。 3 在 iOS 上,当微信客户端在一定时间间隔内连续收到系统内存告警时,会根据一定的策略,主动销毁小程序,并提示用户 (运行内存不足,请重新打开该小程序)。 如果小程序中有过多占用内存的场景,建议使用 wx.onMemoryWarning 监听内存告警事件,进行必要的内存清理。
2022-05-21 - 小程序云开发获取并保存用户IP属地
现在各大平台发表文章、评论等内容都显示出了用户的IP属地,现在来探讨一下小程序使用云开发怎么获取并保存用户IP属地。 1、获取到用户ip,这里演示使用云函数获取。 2、使用腾讯位置服务的WebService API的IP定位接口,获取归属地。 响应示例: { "status": 0, "message": "Success", "result": { "ip": "111.206.145.41", "location": { "lat": 39.90469, "lng": 116.40717 }, "ad_info": { "nation": "中国", "province": "北京市", "city": "北京市", "district": "", "adcode": 110000 } } } 演示代码: // 云函数入口文件 const cloud = require('wx-server-sdk') const axios = require('axios') cloud.init() // 云函数入口函数 exports.main = async (event, context) => { const wxContext = cloud.getWXContext(); var ip = wxContext.CLIENTIP ? wxContext.CLIENTIP : wxContext.CLIENTIPV6; if (ip) { const res = await axios.get("https://apis.map.qq.com/ws/location/v1/ip", { params: { ip: ip, key: "xxx" // 使用腾讯WebService API:https://lbs.qq.com/service/webService/webServiceGuide/webServiceIp } }); return res; } return null; }
2022-05-11 - 小程序客服消息云开发注意
用云开发处理小程序客服消息推送,有两点需要注意。 1、人工接入后的客户,不会收到自动回复 云开发可以针对不同类型的消息进行自动回复。 例如对 text 类型的消息设置了自动回复,那客户如果发送其他类型的消息(例如 image 类型),这个消息仍然会被推送到微信服务器。 此时如果人工接入了这个客户,那他后面发送 text 类型的消息,也不会触发自动回复了。 这个坑我花了近一个小时才爬出来。 那么怎样才能对已接入的客户启用自动回复?可以设置成“客服离线”。接下去该客户发送消息不会立即触发自动回复,需要发送两条,第二条开始才会触发自动回复。 2、user_enter_tempsession 触发条件 当客户进入客服对话时会触发这个事件,但有个前提条件:只有客户主动发送过消息,下次该客户再次进入客服对话时才会触发这个事件。 客户每发送一次消息,客服在 48 小时内可以回复 5 条。 基于这个限制,自动回复应当引导客户主动发送消息,以便在该客户下次进入聊天时可以立即收到欢迎语句。
2022-04-13 - 小程序横屏兼容处理
背景 在h5开发中可用的css适配单位有 em/百分比/rem/vw/vh/vmin/vmax,小程序提供了 rpx: 可以根据屏幕宽度进行自适应,规定屏幕宽为750rpx。 日常小程序开发中,一般设计图尺寸为 750 * 1334 px,则在小程序中一般 1px 直接写为 1rpx ,当小程序为竖屏([代码]"pageOrientation": "portrait"[代码] 默认为竖屏)时,根据 rpx 可以直接还原UI图,但是当小程序为横屏([代码]"pageOrientation": "landscape"[代码])时,根据 rpx 适配就明显比较大,不符合UI图,因为 rpx 是根据屏幕宽度适配的。 这个时候就需要一种既能适配不同屏幕大小,又能以设计图为准,快速布局的方式。通过了解 em/百分比/rem/vw/vh/vmin/vmax 这几种方式,明显 vmin 更符合,vmin 是vw和vh中比较 小 的值。 vw: Viewport宽度, 1vw 等于viewport宽度的 1%。 vh: Viewport高度, 1vh 等于viewport高的的 1%。 所以 100 vmin = 750px。 [代码].wxss[代码] 文件处理 当设置某个元素的宽度为 100px 时,根据 [代码]100px / 750px = x / 100vmin[代码] ,则对应的 vmin 值为 100vmin / 7.5 ,当单位为 rpx 时, vmin 值为 100vmin / 7.5 ,即 [代码]100px = 100vmin / 7.5[代码] 或者 [代码]100rpx = 100vmin / 7.5[代码] ,但是每次都写 [代码]vmin / 7.5[代码] 又有点麻烦,所以就写了个小工具 rpx2vmin ,支持将 rpx/px 转译为 vmin,这样布局的时候依然写 rpx/px ,最后再转译一下就可以了。 将需要转译的 [代码].wxss[代码] 文件复制粘贴到 [代码]input[代码] 文件下,在项目目录下执行如下命令行 ,会在 [代码]ouput[代码] 目录下生成对应的文件名称,需要提前安装 nodejs。 [代码]# 安装依赖 npm install # 将 rpx 转译为 vmin npm run rpx2vmin # 将 px 转译为 vmin npm run px2vmin [代码] 主要处理的如下: [代码]font-size: 12rpx; height: 60rpx; padding: 12rpx 16rpx; border-left: 2rpx dashed #5DA5FF; width: calc(100vw - 50rpx - 80rpx); [代码] 转移为 [代码]font-size: calc(12vmin / 7.5); height: calc(60vmin / 7.5); padding: calc(12vmin / 7.5) calc(16vmin / 7.5); border-left: calc(2vmin / 7.5) dashed #5DA5FF; width: calc(100vw - 50vmin / 7.5 - 80vmin / 7.5); [代码] 或者是: [代码]font-size: 12px; height: 60px; padding: 12px 16px; border-left: 2px dashed #5DA5FF; width: calc(100vw - 50px - 80px); [代码] 转移为 [代码]font-size: calc(12vmin / 7.5); height: calc(60vmin / 7.5); padding: calc(12vmin / 7.5) calc(16vmin / 7.5); border-left: calc(2vmin / 7.5) dashed #5DA5FF; width: calc(100vw - 50vmin / 7.5 - 80vmin / 7.5); [代码] js 中的处理 某些时候我们可能需要通过 js 计算设置,这个时候可以通过 wx.getSystemInfo() 得到 [代码]windowWidth[代码](可使用窗口宽度,单位px) 和 [代码]windowHeight[代码](可使用窗口高度,单位px) , [代码]100vmin = Math.min(windowWidth, windowHeight)[代码], [代码]1px = Math.min(windowWidth, windowHeight) / 750[代码] ,其中750为布局的时候可视窗口的最小宽度,其他尺寸乘以比例即可得到对应的 px 值或者 rpx 值。
2022-04-12 - 微信小程序可视化电影选座组件
推荐一款可视化电影选座组件,具体使用方法请看原文链接:https://juejin.cn/post/6996913047725932575 [图片] gitee地址:https://gitee.com/jensmith/source-coding 原文地址:https://juejin.cn/post/6996913047725932575
2022-04-12 - 云开发日期型字段的比较
不要直接对比字符串,应该将时间字段转换成字符串类型再进行对比。 比如: db.collection("rideRecords") .aggregate() .match({ 'record.subLineId': 'l1', creationDate: _.gte('2022-01-30 00:00:00').and(_.lte('2022-01-30 23:59:59')), 'record.people._id': "381d149061ac0a5a00921a680d1281fe" }) .lookup({ from: "sublineRecords", localField: "_id", foreignField: "rideRecords", as: "sublineRecords" }) .lookup({ from: "driverRecords", localField: "sublineRecords.driverRecordID", foreignField: "_id", as: "driverRecords" }) .end() 上面语句,执行时返回空。 改成: db.collection("rideRecords") .aggregate() .addFields({ formatDate: $.dateToString({ date:'$creationDate', format:'%Y-%m-%d %H:%M:%S', timezone:'Asia/Shanghai' }) }) .match({ 'record.subLineId': 's4', formatDate:_.gte('2022-01-30 00:00:00').and(_.lte('2022-01-30 23:59:59')), }) .end() 关键点:把日期型通过格式转化:dateToString,转成字符类型再做比较 dateToString 相关文档: https://developers.weixin.qq.com/miniprogram/dev/wxcloud/reference-sdk-api/database/command/aggregate/AggregateCommand.dateToString.html 参考文档:https://www.jianshu.com/p/8da04042ffdd
2022-01-30 - 这个库能轻松解决99%的异步和逻辑加载时机问题(异步篇)
[图片] 你是否纠结过底层业务逻辑(登陆、获取用户信息等)到底是放app.js的onLaunch还是page的onLoad里比较好,或者因为异步问题被迫放在了onload,我们来分析一下优劣 - - - - - - - - - - - - - - - - - - - - - - - - - - - - -我是分割线 - - - - - - - - - - - - - - - - - - - - - - - - - - - - 分析 onLaunch处理 优点:底层业务逻辑集中并且只需写一次,比较好维护 缺点:目前没有一个理想的方案来解决onLaunch和onLoad的异步问题,包括注册回调、重写onLoad、请求拦截等。 onLoad处理 优点:因为不涉及跨页面通知,因此异步逻辑比较好处理 缺点:每个页面都得写一次底层业务逻辑,非常繁琐,而且既然是公用的底层业务逻辑,分散在每个页面的onLoad里,好像也不大对劲。 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -我是分割线 - - - - - - - - - - - - - - - - - - - - - - - - - - - - 抉择 按照高内聚低耦合的原则,那逻辑和数据放onLaunch里肯定的,不应该和普通page逻辑耦合在一起,通用的数据和逻辑应该在入口去处理,执行一次到处使用,就像vue的main.js一样,会注册一些技术层的基础设施(路由、状态管理等插件),那业务层的基础设施不就是token、用户信息、所在位置等逻辑吗? - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -我是分割线 - - - - - - - - - - - - - - - - - - - - - - - - - - - - 想象中的最佳实践 那我们的目标就是如何满足两者的优点,避免两者的缺点,做到真正的“高内聚低耦合” 1.保持底层业务逻辑写在入口app.js,避免耦合page里的逻辑 2.能在任何page里第一时间拿到globalData数据 3.使用方便,做到在业务开发中无感知,不需要写额外的调用、通知等代码 4.无任何副作用,不会影响其他功能,比如重写阻塞onLoad 5.灵活可配,适用以后此类任何业务 - - - - - - - - - - - - - - - - - - - - - - - - - - - - -我是分割线 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 梦想成真先看一段代码 ⬇️ // page.js export default { name: 'Home', onLoadLogin(){ //登录成功(拿到token) && 页面初始化完成 //Tips:适用于某页面发送的请求依赖token的场景 }, onLoadUser(){ //页面初始化完成 && 获取用户信息完成 //Tips:适用于页面初始化时需要用到用户信息去做判断再走页面逻辑的场景 }, onReadyUser(){ //dom渲染完成 && 获取用户信息完成 //Tips:适用于首次进入页面需要在canvas上渲染头像的类似场景 }, onReadyShow(){ //小程序内页面渲染完成 && 页面显示 //Tips:适用于需要获取小程序组件或者dom,并且每次页面显示都会执行的场景 }, } 应该懂什么意思了吧?是不是你理想中的样子,使用起来跟没有似的 ⬆️ 这段示例代码满足了上面的第2、3、4条目标 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -我是分割线 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 再来看一段 ⬇️ // app.js // 配置自定义钩子,所有钩子都可以随意组合搭配使用,执行机制类似于Promise.all(但不是用Promise实现的) 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中数据 }, globalData) 怎么样,是不是很棒,依赖globalData,名字可配,连触发规则都可配,而且还附加了可随意组合的功能(意外还解决了页面内逻辑执行时机问题,在下篇讲) ⬆️ 这段示例代码满足了上面的第1、5条目标。 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -我是分割线 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 是不是跃跃欲试了,那就赶紧试试,好用回来告诉我! ⬇️(公司内部已接入两年了很稳定) GitHub:https://github.com/1977474741/spa-custom-hooks [图片]
2023-07-07 - 节流和防抖
小程序防抖的使用[图片] 2:加入以下代码: /*函数节流*/ function throttle(fn, interval) { var enterTime = 0;//触发的时间 var gapTime = interval || 300 ;//间隔时间,如果interval不传,则默认300ms return function() { var context = this; var backTime = new Date();//第一次函数return即触发的时间 if (backTime - enterTime > gapTime) { fn.call(context,arguments); enterTime = backTime;//赋值给第一次触发的时间,这样就保存了第二次触发的时间 } }; } /*函数防抖*/ function debounce(fn, interval) { var timer; var gapTime = interval || 1000;//间隔时间,如果interval不传,则默认1000ms return function() { clearTimeout(timer); var context = this; var args = arguments;//保存此处的arguments,因为setTimeout是全局的,arguments不是防抖函数需要的。 timer = setTimeout(function() { fn.call(context,args); }, gapTime); }; } export default { throttle, debounce }; 3:js层面进行调用,要切记e[0],否者会一直报错 import tool from "../../utils/tool.js"; formSubmit:tool.debounce(function(e){ // 获取商品名称 var title=e[0].detail.value.title; // 商品价格 var price=e[0].detail.value.price; // 商品类型 var type=e[0].detail.value.type; // 商品属性 var info=e[0].detail.value.info; let that=this var priceTF = /^\d+(\.\d{1,2})?$/ // 验证非空 if (e[0].detail.value.title === "") { wx.showToast({ title: '请输入商品名称', icon: "none", duration: 1000, mask: true, }) } else if (e[0].detail.value.title.length > 60) { wx.showToast({ title: '商品名称不得大于60字', icon: "none", duration: 1000, mask: true, }) } else if (e[0].detail.value.title.length === "") { wx.showToast({ title: '请输入商品价格', icon: "none", duration: 1000, mask: true, }) } else if (!priceTF.test(e[0].detail.value.price)) { wx.showToast({ title: '商品价格精确到两位', icon: "none", duration: 1000, mask: true, }) } else if (e[0].detail.value.info === "") { wx.showToast({ title: '请输入商品信息', icon: "none", duration: 1000, mask: true, }) } else if (e[0].detail.value.point === "") { wx.showToast({ title: '请输入商品卖点', icon: "none", duration: 1000, mask: true, }) } else if (that.data.typeInd === -1) { wx.showToast({ title: '请选择商品类型', icon: "none", duration: 1000, mask: true, }) } else if (that.data.detail.length === 0) { wx.showToast({ title: '请选择图片', icon: "none", duration: 1000, mask: true, }) } // 发送Ajax请求,进行入库 wx.request({ url: 'http://www.yan.com/api/xcx/getData', data: { title:title, price :price, type:type, info:info, }, header: { 'content-type': 'application/json' // 默认值 }, method:'POST', success (res) { // 提示发布成功 if(res.data.code==200){ wx.showToast({ title: res.data.meg, }) wx.switchTab({ url: '/pages/good_index/good_index', }) } } }) }),
2022-03-15 - 小程序自定义导航栏完整适配方案
写这篇博客的背景 临近节日,产品想给小程序首页头部设置图片背景,这个只能自定义导航栏来实现 [图片] 当然除了自定义背景图,还可以放置其他组件,按钮、搜索框等 实践部分设备状态栏、胶囊、间距的高度(仅供参考)(单位px) 状态栏 胶囊 上下间距 整个导航栏高度 iPhone 5 20 32 4 60 iPhone 6/7/8 20 32 4 60 iPhone 6/7/8 Plus 20 32 4 60 iPhone X/XR/XS 44 32 4 84 小米6 (非刘海) 24 29 7 67 华为 nova 7 Pro(刘海) 42 29 7 85 后续遇到其他设备再补充 @[toc] 步骤 1.隐藏小程序自带的导航栏 小程序配置 [代码]// 1.全局配置 // app.json { ... "navigationStyle": "custom" ... } // 2.页面配置 // pages.json { ... "navigationStyle": "custom" ... } [代码] 每一个小程序页面也可以使用 .json 文件来对本页面的窗口表现进行配置。页面中配置项在当前页面会覆盖 app.json 的 window 中相同的配置项。 [图片] 2.编写自定义导航栏 导航栏的组成部分(主要是状态栏和标题栏) 1.状态栏(时间和电量显示那一栏) + 2.状态栏和标题栏之间的间距 + 3.标题栏(小程序胶囊按钮那一栏) + 4.标题栏和正文区域之间的间距 刘海屏 [图片] 非刘海屏 [图片] 计算各部分的高度 获取系统信息api获取状态栏高度 [代码]// 状态栏高度 wx.getSystemInfoSync().statusBarHeight; [代码] 获取胶囊按钮信息api获取胶囊按钮的宽高和位置 [代码]/** * 获取微信小程序菜单栏(胶囊)信息 * 菜单按键宽度:width * 菜单按键高度:height * 菜单按键上边界坐标:top * 菜单按键右边界坐标:right * 菜单按键下边界坐标:bottom * 菜单按键左边界坐标:left */ wx.getMenuButtonBoundingClientRect(); > 重点: 此api返回的是胶囊按钮在页面中的的上下左右坐标的绝对位置 > 注意:在模拟器使用时记得把视图百分比调为100%,否则可能会导致获取数据不准确 [代码] 因为整个小程序的导航栏高度是不变的,我们可以把高度信息放在全局,方便使用。一般会在小程序的app.js(如果使用的uni-app,就是App.vue) 的 onLaunch生命周期进行获取和计算。 [代码]//app.js App({ onLaunch() { this.calcNavBarInfo() }, globalData: { //全局数据管理 navBarHeight: 0, // 导航栏高度 menuBottom: 0, // 胶囊距底部间距(顶部间距也是这个) menuHeight: 0, // 胶囊高度 }, /** * @description 计算导航栏信息 */ calcNavBarInfo () { // 获取系统信息 const systemInfo = wx.getSystemInfoSync(); // 胶囊按钮位置信息 const menuButtonInfo = wx.getMenuButtonBoundingClientRect(); // 导航栏高度 = 状态栏到胶囊的间距(胶囊上坐标位置-状态栏高度) * 2 + 胶囊高度 + 状态栏高度 this.globalData.navBarHeight = (menuButtonInfo.top - systemInfo.statusBarHeight) * 2 + menuButtonInfo.height + systemInfo.statusBarHeight; // 状态栏和菜单按钮(标题栏)之间的间距 // 等同于菜单按钮(标题栏)到正文之间的间距(胶囊上坐标位置-状态栏高度) this.globalData.menuBottom = menuButtonInfo.top - systemInfo.statusBarHeight; // 菜单按钮栏(标题栏)的高度 this.globalData.menuHeight = menuButtonInfo.height; } }) [代码] 到此各个部分的元素高度都已拿到, 而且是根据不同设备的屏幕信息动态设置,无论是刘海屏还是非刘海屏,安卓还是ios,样式皆可统一。 3.如何使用 [代码] <view class="nav" style="height:{{navBarHeight}}px; background: url();"> <!-- 胶囊区域 --> <view class="capsule-box" style="height:{{menuHeight}}px; min-height:{{menuHeight}}px; line-height:{{menuHeight}}px; bottom:{{menuBottom}}px;"> <view class="nav-handle"> <view class="back"> <!-- 返回按钮 --> <image src=""></image> </view> <view class="home"> <!-- 首页按钮 --> <image src=""></image> </view> </view> <view class="nav-title">导航标题</view> </view> </view> // js Page({ data: { navBarHeight: getApp().globalData.navBarHeight, menuBottom: getApp().globalData.menuBotton, menuHeight: getApp().globalData.menuHeight } }) // style // 导航栏 .nav { position: relative; } // 胶囊栏 .capsule-box { position: absolute; display: flex; align-items: center; } // 标题文字 .nav-title { height: 100%; width: 50%; margin: 0 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } [代码] 最近使用wx.getMenuButtonBoundingClientRect(),在ios端偶尔值全都是0,导致无法正确自定义导航栏高度 想了一下,ios端的所有设备胶囊按钮信息都是一样的,android端各个品牌都或多或少的有差距,根据这个规律作如下改造: 我这边后期用的uni-app ,所以api都换成了uni [代码]uni.getSystemInfo({ success: res => { let menuButtonInfo = {} if (res.platform === 'ios') { // ios设备的胶囊按钮都是固定的 menuButtonInfo = { width: 87, height: 32, left: res.screenWidth - 7 - 87, right: res.screenWidth - 7, top: res.statusBarHeight + 4, bottom: res.statusBarHeight + 4 + 32 } } else { // 安卓通过api获取 menuButtonInfo = uni.getMenuButtonBoundingClientRect() } console.log('获取胶囊信息:', menuButtonInfo); // 导航栏高度 = 状态栏到胶囊的间距(胶囊距上未知-状态栏高度)* 2 + 胶囊高度 + 状态栏高度 this.$options.globalData.navHeight = (menuButtonInfo.top - res.statusBarHeight) * 2 + menuButtonInfo.height + res.statusBarHeight; console.log('navHeight:', this.$options.globalData.navHeight); // 按钮上下边距高度 this.$options.globalData.menuBottom = menuButtonInfo.top - res.statusBarHeight; // 导航栏右边到屏幕边缘的距离 this.$options.globalData.menuRight = res.screenWidth - menuButtonInfo.right; // 导航栏高度 this.$options.globalData.menuHeight = menuButtonInfo.height; }, fail(err) {} }) [代码]
2022-03-04 - 小程序消息推送,订阅消息的实现,借助云开发云函数实现定时推送订阅消息功能
我在云开发基础课程里给大家讲过小程序消息推送功能的实现,等下会给大家回顾下。但是有时候我们如果想实现定时推送的功能该怎么做呢 一,普通订阅消息的发送 我们先来看下订阅消息的官方简介。 [图片] 接下来我们就来借助云开发,来快速实现小程序消息推送的功能。 1-1,获取模板 ID 这一步和我们之前的模板消息推送是一样的,也是先添加模板,然后拿到模板id [图片] 首先是开通订阅消息功能,很简单,如下图 [图片] [图片] 由于长期性订阅消息,目前仅向政务民生、医疗、交通、金融、教育等线下公共服务开放,后期将逐步支持到其他线下公共服务业务。仅就线下公共服务这一点,长期性订阅消息就和大部分开发者无缘了。 所以我们这里只能以使用一次性订阅消息为例。 [图片] 如上图,我们从公共模板库里选择一个一次性订阅的模板。然后编辑模板如下图 [图片] 下图就是我们添加好的模板,下图的模板id就是我们需要的。 [图片] 1-2,请求用户授权 我们做订阅消息授权时,只能是用户点击或者支付完成后才可以调起来授权弹窗,官方是这么要求的: [图片] 我们这里用到了wx.requestSubscribeMessage这个方法,来获取用户的授权。 1,编写index.wxml代码 [图片] 2,编写index.js代码,实现点击获取授权 [图片] 这一步tmplIds里的一串字符,就是我们自己添加的模板id [图片] 3,点击按钮运行效果如下 开发者工具模拟器上点击授权弹窗是这样的: [图片] 手机上的授权弹窗是这样的: [图片] 可以看到,这里显示的就是我们添加的 ‘上课提醒’的模板。 细心的同学可以看到, 真机上多了一个 ‘总是保持以上选择,不再询问’ 其实,你自己仔细多品一些。也能明白,我们正常订阅消息授权时,用户允许的话,你只能推送一次消息。也就是用户允许一次,我们就可以推送一条消息给用户,并且这个允许不存在过期。所以我们可以让用户尽量多的点击允许,这样我们就可以尽量多的给用户发送消息了。 这里用户允许后,我们就可以给用户推送消息了,接下来我们来借助云开发的云函数来实现消息推送功能。 1-3,获取用户的opneid 先来看官方爸爸是怎么说的。 [图片] 可以看出官方提供了两种方式,我们这里使用云调用。说白了就是在云函数里调用推送功能。 推送所需参数 [图片] 可以看到我这里用来openapi功能,并且需要用到用户的opneid,关于openid的获取,我之前有写过文章,也录过视频的。文章的话,大家去翻下我历史的文章,视频的话,点击这个即可:《借助云函数获取用户openid》 这里的openid的获取我就不再详细讲解了,把对应云函数的代码给大家贴出来。 [图片] 在使用云开发时,有几点需要注意的 1,需要在project.config.json里创建云函数目录如下图 [图片] 2,需要在app.js里初始化云开发环境 [图片] 至于云开发的环境id从哪里拿,我视频里也讲过很多遍了,直接去看我视频或者翻看我历史文章即可。 《零基础入门云开发视频》 1-4,用云函数实现消息推送 我们只需要创建一个云函数如下,然后填入用户的openid,要跳转的小程序页面链接,模板内容,模板id即可。通常这些数据都应该传进来,简单起见,我就把这里的模板内容写成固定的。 [图片] 注意:我在编写上面的代码时,推送内容的key必须和小程序模板里的key保持一致,否则就会报如下错误。 [图片] 然后看下调用这个云函数的地方 [图片] 如果用户没有授权,我们推送会报如下错误 [图片] 如果用户授权过,我们就可以成功推送了,推送后的打印日志如下 [图片] 还记得我们真机上的授权吗,如果用户只是点击了允许,没有选择一直允许,那我我们在推送成功一次后,如果再次推送,就需要用户重新授权。否则,还是会报这个错误的 [图片] 所以我们用户点击一次允许,我们就可以推送一次消息,比如,我点击了4次允许那么我就可以成功的推送4次 [图片] 效果图 [图片] 可以看到,我们成功的收到 上课提醒的模板消息,点击进去,就是我们具体的推送内容 [图片] 其实我这是连续收到了4条消息,因为我点击了4次允许推送,所以就可以成功的推送4次。 到这里我们就完整的实现模板消息推送功能了,下面我把主要代码贴给大家,大家也可以私信我获取完整源码。 index.wxml [代码]<button bindtap="shouquan" type='primary'>获取订阅消息授权</button> <button bindtap="getOpenid">获取用户的openid并推送消息</button> [代码] index.js [代码]//编程小石头wechat:2501902696 Page({ //获取授权的点击事件 shouquan() { wx.requestSubscribeMessage({ tmplIds: ['CFeSWarQLMPyPjwmiy6AV4eB-IZcipu48V8bFLkBzTU'], //这里填入我们生成的模板id success(res) { console.log('授权成功', res) }, fail(res) { console.log('授权失败', res) } }) }, //获取用户的openid getOpenid() { wx.cloud.callFunction({ name: "getopenid" }).then(res => { let openid = res.result.openid console.log("获取openid成功", openid) this.send(openid) }).catch(res => { console.log("获取openid失败", res) }) }, //发送模板消息到指定用户,推送之前要先获取用户的openid send(openid) { wx.cloud.callFunction({ name: "sendMsg", data: { openid: openid } }).then(res => { console.log("推送消息成功", res) }).catch(res => { console.log("推送消息失败", res) }) } }) [代码] 推送对应的云函数 [代码]//编程小石头wechat:2501902696 const cloud = require('wx-server-sdk') cloud.init() exports.main = async(event, context) => { try { const result = await cloud.openapi.subscribeMessage.send({ touser: event.openid, //要推送给那个用户 page: 'pages/index/index', //要跳转到那个小程序页面 data: {//推送的内容 thing1: { value: '小程序入门课程' }, thing6: { value: '杭州浙江大学' }, thing7: { value: '第一章第一节' } }, templateId: 'CFeSWarQLMPyPjwmiy6AV4eB-IZcipu48V8bFLkBzTU' //模板id }) console.log(result) return result } catch (err) { console.log(err) return err } } [代码] 后面我会分享更多小程序相关的知识出来,请持续关注。 注意:授权一次,只能发送一条消息。 二,定时发送消息 我们上面用户授权和发送消息都需要手动点击才可以实现发送。但是有时候我们需要定时提醒用户,比如做的闹钟小程序,要定时提醒用户,该怎么做呢,接下来我们就来实现定时发送消息的功能。 注意 当然了这里还是要先授权才可以发送消息的,同样也是授权一次可以发送一条消息,所以这里要尽量先多授权几次 2-1,什么是定时触发器 我们实现定时发送的功能就是要用到云函数里的定时触发器,官方介绍如下。 [图片] 大家有时间可以自己去仔细读下 https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/functions/triggers.html [图片] 官方已经教我们怎么写定时触发器了 2-2,定时触发器时间设置规则 建议大家仔细去读下官方文档。 [图片] 下面是官方给出的一些示例 [图片] 我这里就取用每隔5秒通过该定时触发器调用下我们的云函数,实现订阅消息的发送。 2-3,添加定时触发器 添加步骤如下图,我们需要新建一个云函数timer [图片] 我们要在timer云函数里调用我们的fasong云函数来实现发送功能 [图片] 然后在timer文件夹下新建一个config.json文件 [图片] [图片] 然后给config.json做如下配置 [图片] 注意json里不能有注释,配置好的触发器如下 [图片] 2-4,部署定时触发器 添加好以后,记得部署触发器 [图片] 2-5,定时发送效果 首先看定时触发器是不是每隔5秒执行了一次 [图片] 然后看手机是否接到了消息 [图片] 可以看出我们手机上每隔5秒也接到了消息。这里还是要记得多授权才可以多接消息。 当然了,我们不可能这样每隔5秒给客户发条消息,这样骚扰到客户,很容易被封的,所以可以停止触发器 2-6,停止触发器 [图片] 到这里我们的定时发送消息功能也实现了,当然了我们要发给指定用户,就要先去获取用户openid,并且得让用户多授权。
2022-02-28 - 小程序云函数能被抓包吗?
小程序一些请求用到的了rsa加密 ,现在私钥是写在代码中,然后发现还是会泄露, 如果说吧私钥写在云函数中,通过云函数前端获取私钥然后加密请求接口,这么做是否还有被抓包的可能 云函数返回的结果能被抓包吗?
2022-03-01 - 云开发分账功能,提示"没有分账权限"
调用云开发分账功能,提示"没有分账权限",如何才能获得云开发分账权限?商户号 160648900
2022-03-01 - 解决微信小程序input组件不能隐藏问题
在开发过程 中发现,input作为原生组件无法隐藏,用hidden={{!show}} 替换wx:if={{show}}即可 <view hidden="{{!changeQ}}" catchtouchmove="stopmove"> <view class='toast-box'> <view class='toastbg'></view> <view class='showToast'> <view class='toast-title'> <text>修改第{{quest0.id+1}}个问题</text> <text>{{quest0.type==1?"单选":"多选"}}</text> <!-- <checkbox-group bindchange="checkboxChange"> <checkbox value="2">多选</checkbox> </checkbox-group> --> </view> <view class='toast-main'> <view class="inputbox"> <text class="input-title">问题:</text> <input class='toast-input' focus="false" bindinput='getUserInput' value="{{changeQ?quest0.question:' '}}" data-quest="quest" auto-focus="false"></input> </view>
2022-02-26 - 微信小程序开发中获取用户进入小程序的场景
// 微信场景值 scene.js 文件 var scene={ "1000":"其他", "1001":"发现栏小程序主入口,「最近使用」列表,「我的小程序」列表", "1005":"微信首页顶部搜索框的搜索结果页", "1006":"发现栏小程序主入口搜索框的搜索结果页", "1007":"单人聊天会话中的小程序消息卡片", "1008":"群聊会话中的小程序消息卡片", "1010":"收藏夹", "1011":"扫描二维码", "1012":"长按图片识别二维码", "1013":"扫描手机相册中选取的二维码", "1014":"小程序订阅消息", "1017":"前往小程序体验版的入口页", "1019":"微信钱包(支付入口)", "1020":"公众号 profile 页相关小程序列表", "1022":"聊天顶部置顶小程序入口", "1023":"安卓系统桌面图标", "1024":"小程序 profile 页", "1025":"扫描一维码", "1026":"发现栏小程序主入口,「附近的小程序」列表", "1027":"微信首页顶部搜索框搜索结果页「使用过的小程序」列表", "1028":"我的卡包", "1029":"小程序中的卡券详情页", "1030":"自动化测试下打开小程序", "1031":"长按图片识别一维码", "1032":"扫描手机相册中选取的一维码", "1034":"微信支付完成页", "1035":"公众号自定义菜单", "1036":"App 分享消息卡片", "1037":"小程序打开小程序", "1038":"从另一个小程序返回", "1039":"摇电视", "1042":"添加好友搜索框的搜索结果页", "1043":"公众号模板消息", "1044":"带 shareTicket 的小程序消息卡片", "1045":"朋友圈广告", "1046":"朋友圈广告详情页", "1047":"扫描小程序码", "1048":"长按图片识别小程序码", "1049":"扫描手机相册中选取的小程序码", "1052":"卡券的适用门店列表", "1053":"搜一搜的结果页", "1054":"顶部搜索框小程序快捷入口", "1056":"聊天顶部音乐播放器右上角菜单", "1057":"钱包中的银行卡详情页", "1058":"公众号文章", "1059":"体验版小程序绑定邀请页", "1060":"微信支付完成页", "1064":"微信首页连Wi-Fi状态栏", "1065":"URL scheme", "1067":"公众号文章广告", "1068":"附近小程序列表广告", "1069":"移动应用通过openSDK进入微信,打开小程序", "1071":"钱包中的银行卡列表页", "1072":"二维码收款页面", "1073":"客服消息列表下发的小程序消息卡片", "1074":"公众号会话下发的小程序消息卡片", "1077":"摇周边", "1078":"微信连Wi-Fi成功提示页", "1079":"微信游戏中心", "1081":"客服消息下发的文字链", "1082":"公众号会话下发的文字链", "1084":"朋友圈广告原生页", "1088":"会话中查看系统消息,打开小程序", "1089":"微信聊天主界面下拉,「最近使用」栏,「我的小程序」栏", "1090":"长按小程序右上角菜单唤出最近使用历史", "1091":"公众号文章商品卡片", "1092":"城市服务入口", "1095":"小程序广告组件", "1096":"聊天记录,打开小程序", "1097":"微信支付签约原生页,打开小程序", "1099":"页面内嵌插件", "1100":"红包封面详情页打开小程序", "1101":"远程调试热更新(开发者工具中,预览 -> 自动预览 -> 编译并预览)", "1102":"公众号 profile 页服务预览", "1103":"发现栏小程序主入口,「我的小程序」列表", "1104":"微信聊天主界面下拉,「我的小程序」栏", "1106":"聊天主界面下拉,从顶部搜索结果页,打开小程序", "1107":"订阅消息,打开小程序", "1113":"安卓手机负一屏,打开小程序(三星)", "1114":"安卓手机侧边栏,打开小程序(三星)", "1119":"【企业微信】工作台内打开小程序", "1120":"【企业微信】个人资料页内打开小程序", "1121":"【企业微信】聊天加号附件框内打开小程序", "1124":"扫“一物一码”打开小程序", "1125":"长按图片识别“一物一码”", "1126":"扫描手机相册中选取的“一物一码”", "1129":"微信爬虫访问", "1131":"浮窗", "1133":"硬件设备打开小程序", "1135":"小程序profile页相关小程序列表,打开小程序", "1144":"公众号文章 - 视频贴片", "1145":"发现栏 - 发现小程序", "1146":"地理位置信息打开出行类小程序", "1148":"卡包-交通卡,打开小程序", "1150":"扫一扫商品条码结果页打开小程序", "1151":"发现栏 - 我的订单", "1152":"订阅号视频打开小程序", "1153":"“识物”结果页打开小程序", "1154":"朋友圈内打开“单页模式”", "1155":"“单页模式”打开小程序", "1157":"服务号会话页打开小程序", "1158":"群工具打开小程序", "1160":"群待办", "1167":"H5 通过开放标签打开小程序", "1168":"移动应用直接运行小程序", "1169":"发现栏小程序主入口,各个生活服务入口(例如快递服务、出行服务等)", "1171":"微信运动记录(仅安卓)", "1173":"聊天素材用小程序打开", "1175":"视频号主页商店入口", "1176":"视频号直播间主播打开小程序", "1177":"视频号直播商品", "1178":"在电脑打开手机上打开的小程序", "1179":"#话题页打开小程序", "1181":"网站应用打开PC小程序", "1183":"PC微信 - 小程序面板 - 发现小程序 - 搜索", "1185":"群公告", "1186":"收藏 - 笔记", "1187":"浮窗", "1189":"表情雨广告", "1191":"视频号活动", "1192":"企业微信联系人profile页", "1194":"URL Link", "1195":"视频号主页商品tab", "1197":"视频号主播从直播间返回小游戏", "1198":"视频号开播界面打开小游戏", "1203":"微信小程序压测工具的请求" } module.exports={ scene } 然后写一个函数去返回场景值的描述,放到app.js中 // 判断小程序入口 pdScene(val){ var wxscene=require('scene.js').scene; var str=""; for(var i in wxscene){ if(i==val){ console.log(wxscene[i]) str=wxscene[i]; } } return str; }, 在需要存储用户进入场景值的地方写 console.log(app.pdScene(wx.getLaunchOptionsSync().scene)); 微信官方场景值文档https://developers.weixin.qq.com/miniprogram/dev/reference/scene-list.html
2022-02-22 - 目前为止项目中用到的一些校验
// 判断是否是json字符串 isJSON(str) { if (typeof str == 'string') { try { var obj=JSON.parse(str); if(typeof obj == 'object' && obj ){ return true; }else{ return false; } } catch(e) { return false; } } }, //验证手机号 checkPhone(phone){ var res = /^1[3456789]\d{9}$/; return res.test(phone);//返回true:手机号正确 false:手机号错误 }, // 国内座机 checkTel(phone){ var res = /\d{3}-\d{8}|\d{4}-\d{7}/; return res.test(phone);//返回true:正确 false:错误 }, // 验证邮箱(支持中文邮箱) checkEmail(email) { var res =/^[A-Za-z0-9\u4e00-\u9fa5]+([-_.][A-Za-z\d]+)*@([A-Za-z\d]+[-.])+[A-Za-z\d]{2,5}$/; return res.test(email);//返回true:邮箱正确 false:邮箱错误 }, // 验证中国大陆身份证号 checkIdcard(idcard) { var res = /(^\d{8}(0\d|10|11|12)([0-2]\d|30|31)\d{3}$)|(^\d{6}(18|19|20)\d{2}(0\d|10|11|12)([0-2]\d|30|31)\d{3}(\d|X|x)$)/; return res.test(idcard);//返回true:正确 false:错误 }, // 验证中国大陆一代身份证号 checkIdcard1(idcard){ var res =/^\d{8}(0\d|10|11|12)([0-2]\d|30|31)\d{3}$/; return res.test(idcard);//返回true:正确 false:错误 }, // 验证中国大陆二代身份证号 checkIdcard2(idcard){ var res = /^\d{6}(18|19|20)\d{2}(0\d|10|11|12)([0-2]\d|30|31)\d{3}(\d|X|x)$/; return res.test(idcard);//返回true:身份证正确 false:身份证错误 }, // 验证统一社会信用代码和组织机构代码 CheckSocialCreditCode(code){ var res = /^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/; return res.test(code);//返回true:正确 false:错误 }, // 验证是否是图片链接 isImageUrl(str){ var res=/^https?:\/\/.*?(?:gif|png|jpg|jpeg|webp|svg|psd|bmp|tif)$/i; return res.test(str);//返回true:正确 false:错误 }, // 验证是否是视频链接 isVideoUrl(str){ var res=/^https?:\/\/.*?(?:swf|avi|flv|mpg|rm|mov|wav|asf|3gp|mkv|rmvb|mp4)$/i; return res.test(str);//返回true:正确 false:错误 }, // 是否是base64 isBase64(str){ var res=/^\s*data:(?:[a-z]+\/[a-z0-9-+.]+(?:;[a-z-]+=[a-z0-9-]+)?)?(?:;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*?)\s*$/i; return res.test(str);//返回true:正确 false:错误 }, // 验证银行卡号 isBankCard(str){ var res=/^[1-9]\d{9,29}$/; return res.test(str);//返回true:正确 false:错误 }, // 中文姓名 isChineseName(str){ var res=/^(?:[\u4e00-\u9fa5·]{2,16})$/; return res.test(str);//返回true:正确 false:错误 }, // 英文姓名 isEnglishName(str){ var res=/(^[a-zA-Z]{1}[a-zA-Z\s]{0,20}[a-zA-Z]{1}$)/; return res.test(str);//返回true:正确 false:错误 }, // 是否是新能源车牌号 isNewCarCard(str){ var res=/[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}(([0-9]{5}[DF])|([DF][A-HJ-NP-Z0-9][0-9]{4}))$/; return res.test(str);//返回true:正确 false:错误 }, // 非新能源车牌号 isOldCarCard(str){ var res=/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}[A-Z0-9]{4}[A-Z0-9挂学警港澳]{1}$/; return res.test(str);//返回true:正确 false:错误 }, // 车牌号(新能源+非新能源) isCarCard(str){ var res=/^(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-HJ-NP-Z]{1}(?:(?:[0-9]{5}[DF])|(?:[DF](?:[A-HJ-NP-Z0-9])[0-9]{4})))|(?:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9 挂学警港澳]{1})$/; return res.test(str);//返回true:正确 false:错误 }, // 护照 (包含香港、澳门) checkPassport(str){ var res=/(^[EeKkGgDdSsPpHh]\d{8}$)|(^(([Ee][a-fA-F])|([DdSsPp][Ee])|([Kk][Jj])|([Mm][Aa])|(1[45]))\d{7}$)/; return res.test(str);//返回true:正确 false:错误 }, // 帐号是否合法(字母开头,允许5-16字节,允许字母数字下划线组合) checkAccount(str){ var res=/^[a-zA-Z][a-zA-Z0-9_]{4,15}$/; return res.test(str);//返回true:正确 false:错误 }, // 纯中文汉字 checkChineseWords(str){ var res=/^(?:[\u3400-\u4DB5\u4E00-\u9FEA\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29]|[\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0])+$/; return res.test(str);//返回true:正确 false:错误 }, // 纯英文字母 checkEnglistWords(str){ var res=/^[a-zA-Z]+$/; return res.test(str);//返回true:正确 false:错误 }, // 是否是小数 isDecimal(str){ var res=/^\d+\.\d+$/; return res.test(str);//返回true:正确 false:错误 }, // 纯数字 isNumber(str){ var res=/^\d{1,}$/; return res.test(str);//返回true:正确 false:错误 }, // qq号 isQQNumber(str){ var res=/^[1-9][0-9]{4,10}$/; return res.test(str);//返回true:正确 false:错误 }, // 微信号 6至20位,以字母开头,字母,数字,减号,下划线 checkWxCode(str){ var res=/^[a-zA-Z][-_a-zA-Z0-9]{5,19}$/; return res.test(str);//返回true:正确 false:错误 }, // 中国邮政编码 checkChinaPostalCode(str){ var res=/^(0[1-7]|1[0-356]|2[0-7]|3[0-6]|4[0-7]|5[1-7]|6[1-7]|7[0-5]|8[013-6])\d{4}$/; return res.test(str);//返回true:正确 false:错误 }, // 判断字符串中是否含有表情 isEmojiCharacter(substring) { // if(isEmojiCharacter(str)){console.log('不能含有表情')} for (var i = 0; i < substring.length; i++) { var hs = substring.charCodeAt(i); if (0xd800 <= hs && hs <= 0xdbff) { if (substring.length > 1) { var ls = substring.charCodeAt(i + 1); var uc = ((hs - 0xd800) * 0x400) + (ls - 0xdc00) + 0x10000; if (0x1d000 <= uc && uc <= 0x1f77f) { return true; } } } else if (substring.length > 1) { var ls = substring.charCodeAt(i + 1); if (ls == 0x20e3) { return true; } } else { if (0x2100 <= hs && hs <= 0x27ff) { return true; } else if (0x2B05 <= hs && hs <= 0x2b07) { return true; } else if (0x2934 <= hs && hs <= 0x2935) { return true; } else if (0x3297 <= hs && hs <= 0x3299) { return true; } else if (hs == 0xa9 || hs == 0xae || hs == 0x303d || hs == 0x3030 || hs == 0x2b55 || hs == 0x2b1c || hs == 0x2b1b || hs == 0x2b50) { return true; } } } }, // 判断字符串中是否含有特殊字符 isTeShuString(substring){ var reg = /[~#^$@%&!?%*]/gi; return reg.test(substring); }, 不足之处忘大家指正,非常感谢
2022-02-22 - 微信小程序获取某一个用户授权的封装写法
参考文章 --https://developers.weixin.qq.com/community/develop/article/doc/00002c39258858bc052d066905f413 个人基于上一篇文章的逻辑和踩坑总结了现版本的获取某一个用户授权的封装写法 这是我发现的坑。。。 [图片] 用async和await 调用wx.showModal 在调用wx.openSetting无法跳转权限设置页面 提示 errMsg: "openSetting:fail can only be invoked by user TAP gesture." 后来改成promise.then写法就可以了,完整代码如下,有些地方写的不好,欢迎指教 /** * 校验微信权限 * @param {string} auth 需要授权的api * @returns {boolean} 是否校验成功 */ async function checkWXAPIoAuthority(auth){ const AuthorityMap = new Map([ ['bluetooth', '蓝牙'], ['userLocation', '用户地理位置'], ['userLocationBackground', '后台用户位置'], ['record', '麦克风'], ['camera', '摄像头'], ['writePhotosAlbum', '访问相册'], ['addPhoneContact', '访问联系人'], ['addPhoneCalendar', '日历'], ['werun', '运动步数'] ]) if(!AuthorityMap.has(auth)) return false try { let { authSetting } = await wx.getSetting() if(authSetting['scope.' + auth]){ return true }else{ let resAuthorize = {} // 这里之所以有个if语句判断 authSetting是否存在该权限字段 // 是因为 报错信息:{errMsg: "authorize:fail 系统错误,错误码:-12006,auth deny"} // --https://developers.weixin.qq.com/community/develop/doc/0004cae5a34490ac227b55f7251c00?highline=12006 // 用户首次授权用wx.authorize,非首次授权(用户拒绝了或手动取消后)就用wx.openSetting if(!authSetting.hasOwnProperty('scope.' + auth)) { try{ resAuthorize = await wx.authorize({ scope: 'scope.' + auth }) }catch(authorizeFail){ resAuthorize = authorizeFail } } if(resAuthorize?.errMsg && resAuthorize.errMsg.indexOf('ok') !== -1){ return true }else{ // let operate = await wx.showModal({title: '提示', content: `需要您授权获取${AuthorityMap.get(auth)}权限`}) // if (operate.confirm) { // console.log('打开setting') // 打开setting // let setting = await wx.openSetting() // console.log('setting==>', setting) // undefined // return true // } else { // return false // } return new Promise((resolve, reject) => { wx.showModal({ title: '提示', content: `需要您授权获取${AuthorityMap.get(auth)}权限`, success: (operate) =>{ if(operate.confirm){ wx.openSetting({ success:({authSetting})=>{ console.log('openSetting', authSetting) if(authSetting && authSetting['scope.' + auth]){ resolve(true) } else { resolve(false) } }, fail:()=>{ resolve(false) } }) } else{ resolve(false) } }, fail:()=>{ resolve(false) } }) }) } } }catch(e){ return false } } 应用: if(!await checkWXAPIoAuthority('bluetooth')) return 考虑到以后I18国际化,AuthorityMap 还要再做修改
2022-02-18 - 健身房预约小程序平台开发笔记
介绍以健身场馆预约为核心功能,提供线上健身课程预约的小程序平台 [图片] 特点预约管理:开始/截止时间/人数/审核规则可灵活设置自定义客户预约填写的数据项预约凭证:线下到场后校验/核销/二维码自助签到详尽的数据:掌控全局/细致洞察/数据导出及时到位的提醒:赴约提醒/手机日历提醒仅需一台手机:便可发布及管理预约平台数据库 [图片] 架构 [图片] 截图 [图片] [图片] [图片] [图片] [图片] [图片]
2022-02-11 - 多环境配置,同事可自主切换方案(多测试环境)
1)实现效果: 正式版:一定使用生产数据体验版和开发版:同事可自主切换数据环境支持多个测试数据环境2)实现思路: 环境信息都在一个配置文件里使用wx.getAccountInfoSync()判断当前如果是正式版,则读取生产数据。如果是体验版或开发版,通过缓存获取上次选中的环境,如果无环境,则取生产数据环境。提供一个位置,供同事切换环境,并保存到缓存,然后退出小程序。重新打开小程序,则切换完成。3)代码片段: https://developers.weixin.qq.com/s/ImBsBzmH7fwc
2022-01-25 - 富文本editor怎么实现首行缩进?
可以通过 this.editCtx.format('textIndent', '2em') 的方式实现
2019-09-16 - 云开发短信跳小程序(自定义开发版)教程
写在前面如果你想要自主开发,但没有云开发相关经验,可以采用演示视频来学习本教程: [视频] 一、能力介绍境内非个人主体的认证的小程序,开通静态网站后,可以免鉴权下发支持跳转到相应小程序的短信。短信中会包含支持在微信内或微信外打开的静态网站链接,用户打开页面后可一键跳转至你的小程序。 这个链接的网页在外部浏览器是通过 URL Scheme 的方式来拉起微信打开主体小程序的。 总之,短信跳转能力的实现分为两个步骤,「配置拉起网页」和「发送短信」。本教程将介绍如何执行操作完成短信跳转小程序的能力。 如果你想要无需写代码就能完成短信跳转小程序的能力,可以参照无代码版教程进行逐步实现。 二、操作指引1、网页创建首先我们需要构建一个基础的网页应用,在任何代码编辑器创建一个 html 文件,在教程这里命名为 index.html 在这个 html 文件中输入如下代码,并根据注释提示更换自己的信息: window.onload = function(){ window.web2weapp.init({ appId: 'wx999999', //替换为自己小程序的AppID gh_ID: 'gh_999999',//替换为自己小程序的原始ID env_ID: 'tcb-env',//替换小程序底下云开发环境ID function: { name:'openMini',//提供UrlScheme服务的云函数名称 data:{} //向这个云函数中传入的自定义参数 }, path: 'pages/index/index.html' //打开小程序时的路径 }) } 以上引入的 web2weapp.js 文件是教程封装的有关拉起微信小程序的极简应用,我们直接引用即可轻松使用。 如果你想进一步学习和修改其中的一些WEB展示信息,可以前往 github 获取源码并做修改。 有关于网页拉起小程序的更多信息可以访问官方文档 如果你只想体验短信跳转功能,在执行完上述文件创建操作后,继续以下步骤。 2、创建服务云函数在上面创建网页的过程中,需要填写一个UrlScheme服务云函数。这个云函数主要用来调用微信服务端能力,获取对应的Scheme信息返回给调用前端。 我们在示例中填写的是 openMini 这个命名的云函数。 我们前往微信开发者工具,定位对应的云开发环境,创建一个云函数,名称叫做 openMini 。 在云函数目录中 index.js 文件替换输入以下代码: const cloud = require('wx-server-sdk') cloud.init() exports.main = async (event, context) => { return cloud.openapi.urlscheme.generate({ jumpWxa: { path: '', // 打开小程序时访问路径,为空则会进入主页 query: '',// 可以使用 event 传入的数据制作特定参数,无需求则为空 }, isExpire: true, //是否到期失效,如果为true需要填写到期时间,默认false expire_time: Math.round(new Date().getTime()/1000) + 3600 //我们设置为当前时间3600秒后,也就是1小时后失效 //无需求可以去掉这两个参数(isExpire,expire_time) }) } 保存代码后,在 index.js 右键,选择增量更新文件即可更新成功。 接下来,我们需要开启云函数的未登录访问权限。进入小程序云开发控制台,转到设置-权限设置,找到下方未登录,选择上几步我们统一操作的那个云开发环境(注意:第一步配置的云开发环境和云函数所在的环境,还有此步操作的环境要一致),勾选打开未登录 [图片] 接下来,前往云函数控制台,点击云函数权限,安全规则最后的修改,在弹出框中按如下配置: [图片] 3、本地测试我们在本地浏览器打开第一步创建的 index.html ;唤出控制台,如果效果如下图则证明成功! 需要注意,此处本地打开需要时HTTP协议,建议使用live server等扩展打开。不要直接在资源管理器打开到浏览器,会有跨域的问题! [图片] 4、上传本地创建好的 index.html 至静态网站托管将本地创建好的 index.html 上传至静态网站托管,在这里静态托管需要是小程序本身的云开发环境里的静态托管。 如果你上传至其他静态托管或者是服务器,你仍然可以使用外部浏览器拉起小程序的能力,但会丧失在微信浏览器用开放标签拉起小程序的功能,也不会享受到云开发短信发送跳转链接的能力。 如果你的目标小程序底下有多个云开发环境,则不需要保证云函数和静态托管在一个环境中,无所谓。 比如你有A、B两个环境,A部署了上述的云函数,但是把 index.html 部署到B的环境静态托管中了,这个是没问题的,符合各项能力要求。只需要保证第一步 index.html 网页中的云开发环境配置是云函数所在环境即可。 部署成功后,你便可以访问静态托管的所在地址了,可以通过手机外部浏览器以及微信内部浏览器测试打开小程序的能力了。 5、短信发送云函数的配置在上面创建 openMini 云函数的环境中再来一个云函数,名字叫 sendsms 。 在此云函数 index.js 中配置如下代码: const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) exports.main = async (event, context) => { try { const config = { env: event.env, content: event.content ? event.content : '发布了短信跳转小程序的新能力', path: event.path, phoneNumberList: event.number } const result = await cloud.openapi.cloudbase.sendSms(config) return result } catch (err) { return err } } 保存代码后,在 index.js 右键,选择增量更新文件即可更新成功。 6、测试短信发送能力在小程序代码中,在 app.js 初始化云开发后,调用云函数,示例代码如下: App({ onLaunch: function () { wx.cloud.init({ env:"tcb-env", //短信云函数所在环境ID traceUser: true }) wx.cloud.callFunction({ name:'sendsms', data:{ "env": "tcb-env",//网页上传的静态托管的环境ID "path":"/index.html",//上传的网页相对根目录的地址,如果是根目录则为/index.html "number":[ "+8616599997777" //你要发送短信的目标手机,前面需要添加「+86」 ] },success(res){ console.log(res) } }) } }) 重新编译运行后,在控制台中看到如下输出,即为测试成功: [图片] 你会在发送的目标手机中收到短信,因为短信中包含「退订回复T」字段,可能会触发手机的自动拦截机制,需要手动在拦截短信中查看。 需要注意:你可以把短信云函数和URLScheme云函数分别放置在不同云开发环境中,但必须保证所放置的云开发环境属于你操作的小程序 另外,出于防止滥用考虑,短信发送的云调用能力需要真实小程序用户访问才可以生效,你不能使用云端测试、云开发JS-SDK以及其他非wx.cloud调用方式(微信侧WEB-SDK除外),会提示如下错误: [图片] 如果你想在其他处使用此能力,可以使用服务端API来做正常HTTP调用,具体访问官方文档 7、查看短信监控图表进入 云开发控制台 > 运营分析 > 监控图表 > 短信监控,即可查看短信监控曲线图、短信发送记录。 [图片] 三、总结短信跳转小程序核心是静态网站中配置的可跳转网页,外部浏览器通过URL Scheme 来实现的,这个方式不适用于微信浏览器,需要使用开放标签才可以URL Scheme的生成是云调用能力,需要是目标小程序的云开发环境的云函数中使用才可以。并且生成的URL Scheme只能是自己小程序的打开链接,不能是任意小程序(和开放标签的任意不一致)短信发送能力的体验是每个有免费配额的环境首月100条,如有超过额度的需求可前往开发者工具-云开发控制台-对应按量付费环境-资源包-短信资源包,进行购买。如当前资源包无法满足需求也可通过云开发 工单 提交申请[图片]短信发送也是云调用能力,需要真实小程序用户调用才可以正常触发,其他方式均报错返回参数错误,出于防止滥用考虑云函数和网页的放置可以不在同一个环境中,只需要保证所属小程序一致即可。(需要保证对应环境ID都能接通)如果你不需要短信能力,可以忽略最后两个步骤CMS配置渠道投放、数据统计可参考官方文档
2021-04-07 - 云调用能力—客服消息
在前面的章节,我们已经在小程序端将 button 组件 open-type 的值设置为 contact ,点击 button 就可以进入客服消息。不过这个客服消息使用的是官方的后台,没法进行深度的定制,我们可以使用云开发作为后台来自定义客服消息来实现快捷回复、添加常用回答等功能。 如果是使用传统的开发方式,需要填写服务器地址(URL)、令牌(Token) 和 消息加密密钥(EncodingAESKey)等信息,然后结合将 token、timestamp、nonce 三个参数进行字典序排序、拼接、并进行 sha1 加密,然后将加密后的字符串与 signature 对比来验证消息的确来自微信服务器,之后再来进行接收消息和事件的处理,可谓十分繁琐,而使用云开发相对简单很多。 13.8.1 客服消息的配置与说明使用开发者工具新建一个云函数,比如 customer,在 config.json 里,设置以下权限后部署上传到服务端。 { "permissions": { "openapi": [ "customerServiceMessage.send", "customerServiceMessage.getTempMedia", "customerServiceMessage.setTyping", "customerServiceMessage.uploadTempMedia" ] } } 然后再打开云开发控制台,点击右上角的设置,选择全局设置,开启云函数接收消息推送,添加消息推送配置。为了学习方便我们将所有的消息类型都指定推送到 customer 云函数里。 text,文本消息image,图片消息miniprogram,小程序卡片event,事件类型 user_enter_tempsession,进入客服消息时就会触发以上有四种消息类型,但是发送客服消息的 customerServiceMessage.send 的 msgtype 属性的合法值有 text、image、link(图文链接消息)、miniprogrampage 四种,也就是我们还可以发图文链接消息。 13.8.2 自动回复文本消息和链接1、自动回复文本消息使用开发者工具新建一个页面,比如 customer,然后在 customer.wxml 里输入以下按钮, 进入客服button> 当用户通过 button 进入到客服消息之后,在聊天界面回复信息,就能触发设置好的 customer 云函数,比如下面的例子就是当用户发一条消息(包括表情)到客服消息会话界面,云函数就会给调用 customerServiceMessage.send 接口给用户回复两条文本消息(一次性可以回复多条),内容分别为[代码]等候您多时啦[代码]和[代码]欢迎关注云开发技术训练营[代码],一个云函数里也是可以多次调用接口的: const cloud = require("wx-server-sdk"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }); exports.main = async (event, context) => { const wxContext = cloud.getWXContext(); try { const result = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: "text", text: { content: "等候您多时啦", }, }); const result2 = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: "text", text: { content: "欢迎关注云开发技术训练营", }, }); return event; } catch (err) { console.log(err); return err; } }; 发送文本消息时,支持插入跳小程序的文字链接的,比如我们把上面的文本消息改为以下代码: content: '欢迎浏览点击跳小程序a>'; data-miniprogram-appid 项,填写小程序 appid,则表示该链接跳小程序;data-miniprogram-path 项,填写小程序路径,路径与 app.json 中保持一致,可带参数;对于不支持 data-miniprogram-appid 项的客户端版本,如果有 herf 项,则仍然保持跳 href 中的网页链接;data-miniprogram-appid 对应的小程序必须与公众号有绑定关系。 2、自动回复链接我们还可以给用户回复链接,我们可以把 customer 云函数修改为以下代码,当用户向微信聊天对话界面发送一条消息时,就会回复给用户一个链接,这个链接可以是外部链接哦。 const cloud = require("wx-server-sdk"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }); exports.main = async (event, context) => { const wxContext = cloud.getWXContext(); try { const result = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: "link", link: { title: "快来加入云开发技术训练营", description: "零基础也能在10天内学会开发一个小程序", url: "https://cloud.tencent.com/", thumbUrl: "https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/love.png", }, }); return event; } catch (err) { console.log(err); return err; } }; 3、根据关键词来回复用户将上面的云函数部署之后,当用户向客服消息的聊天会话里输入内容时,不管用户发送的是什么内容,云函数都会回给用户相同的内容,这未免有点过于死板,客服消息能否根据用户发送的关键词回复用户不同的内容呢?要做到这一点我们需要能够获取到用户发送的内容。 我们可以留意云开发控制台云函数日志里看到,customer 云函数返回的 event 对象里的 Content 属性就会记录用户发到聊天会话里的内容: {"Content":"请问怎么加入云开发训练营", "CreateTime":1582877109, "FromUserName":"oUL-mu...XbuEDsn8", "MsgId":22661351901594052, "MsgType":"text", "ToUserName":"gh_b2bbe22535e4", "userInfo":{"appId":"wxda99ae4531b57046","openId":"oUL-m5FuRmuVmxvbYOGuXbuEDsn8"}} 由于 Content 是字符串,那这个关键词既可以是非常精准的,比如“训练营”,或“云开发训练营”,还可以是非常模糊的“请问怎么加入云开发训练营”,我们只需要对字符串进行正则匹配处理即可,比如当用户只要发的内容包含“训练营”,就会收到链接: const cloud = require("wx-server-sdk"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }); exports.main = async (event, context) => { const wxContext = cloud.getWXContext(); const keyword = event.Content; try { if (keyword.search(/训练营/i) != -1) { const result = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: "link", link: { title: "快来加入云开发技术训练营", description: "零基础也能在10天内学会开发一个小程序", url: "https://cloud.tencent.com/", thumbUrl: "https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/love.png", }, }); } return event; } catch (err) { console.log(err); return err; } }; 在前面的案例里,我们都是使用[代码]touser: wxContext.OPENID,[代码], 13.8.2 自动触发 event 事件要触发 event 事件,我们可以将 customer.wxml 的按钮改为如下代码,这里的 session-from 是用户从该按钮进入客服消息会话界面时,开发者将收到带上本参数的事件推送,可用于区分用户进入客服会话的来源。 进入客服button> 由于我们开启了 event 类型的客服消息,事件类型的值为 user_enter_tempsession,当用户点击 button 进入客服时,就会触发云函数,不用用户发消息就能触发,同时我们返回 event 对象. const cloud = require("wx-server-sdk"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }); exports.main = async (event, context) => { const wxContext = cloud.getWXContext(); try { const result = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: "text", text: { content: "欢迎来到等候您多时啦", }, }); return event; } catch (err) { console.log(err); return err; } }; 我们可以去云开发控制台查看返回的 event 对象 {"CreateTime":1582876587, "Event":"user_enter_tempsession", "FromUserName":"oUL-m5F...8", "MsgType":"event", "SessionFrom":"文章详情的客服按钮", "ToUserName":"gh_b2bbe22535e4", "userInfo":{"appId":"wxda9...57046", "openId":"oUL-m5FuRmuVmx...sn8"}} 在云函数端,我们是可以通过 event.SessionFrom 来获取到用户到底是点击了哪个按钮从而进入客服对话的,也可以根据用户进入客服会话的来源不同,给用户推送不同类型,比如我们可以给 session-from 的值设置为“训练营”,当用户进入客服消息会话就能推送相关的信息给到用户。 还有一点就是,bindcontact 是给客服按钮绑定了了一个事件处理函数,这里为 onCustomerServiceButtonClick,通过事件处理函数我们可以在小程序端做很多事情,比如记录用户点击了多少次带有标记(比如 session-from 的值设置为“训练营”)的客服消息的按钮等功能。 13.8.3 自动回复图片要在客服消息里给用户回复图片,这个图片的来源只能是来源于微信服务器,我们需要先使用 customerServiceMessage.uploadTempMedia,把图片文件上传到微信服务器,获取到 mediaId(有点类似于微信服务器的 fileID),然后才能在客服消息里使用。 在 customer 云函数的 index.js 里输入以下代码并部署上线,我们将获取到的 mediaId 使用 cloud.openapi.customerServiceMessage.send 发给用户: const cloud = require("wx-server-sdk"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }); exports.main = async (event, context) => { const wxContext = cloud.getWXContext(); try { //我们通常会将云存储的图片作为客服消息媒体文件的素材 const fileID = "cloud://xly-xrlur.786c-xly-xrlur-1300446086/1572315793628-366.png"; //uploadTempMedia的图片类型为Buffer,而从存储下载的图片格式也是Buffer const res = await cloud.downloadFile({ fileID: fileID, }); const Buffer = res.fileContent; const result = await cloud.openapi.customerServiceMessage.uploadTempMedia({ type: "image", media: { contentType: "image/png", value: Buffer, }, }); console.log(result.mediaId); const mediaId = result.mediaId; const wxContext = cloud.getWXContext(); const result2 = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: "image", image: { mediaId: mediaId, }, }); return event; } catch (err) { console.log(err); return err; } }; 客服消息还能给用户回复小程序消息卡片,以及客服当前的输入状态给用户(使用 customerServiceMessage.setTyping 接口)。
2021-09-10 - 小程序联盟公测
各位微信开发者: 你们好。 为了更好的帮助小程序商家提高商品销量,微信官方提供的推广工具“小程序联盟”,于2021年3月1日开始公测。 小程序联盟具有“先成交后付费”的特点,商家在管理后台发布商品推广需求和佣金,佣金在推客(推广者)成功完成推广后才会结算。 功能简介与接入标准请参考下方内容。 一、功能简介 小程序联盟分别为商家和推客(推广者)提供了管理后台: 商家可在管理后台设置商品推广佣金,查看推广效果,具体说明请查看《商家端功能说明》;[图片] 2.推客可在管理后台挑选商品,获取推广素材,查看推广效果,提现佣金,具体说明请查看《推客端功能说明》; [图片] 二、接入要求 拥有商品,希望被推广的小程序商家可申请成为小程序联盟商家。没有货源,希望通过分享商品创造价值的推广者,可以申请成为小程序联盟推客。 具体接入条件如下: 1. 商家 满足以下条件之一,即可开通小程序联盟: 已开张的企业/个体工商户的小商店; 已有小程序并完成标准版交易组件接入。 接入指引,请参考《商家接入指引》。 企业/个体工商户为主体的小商店可直接前往PC端后台开通联盟功能。 [图片] 2. 推客: 支持企业/个体工商户主体接入,通过主体认证即可。 暂未向个人开放。 接入指引,请参考《推客接入指引》。
2021-03-03 - (10)群聊能力
我们在后台收到很多朋友的反馈,希望更好的运用小程序群聊的能力。于是我们想写写群聊的故事。 微信群是小程序在微信这个社交工具下传播的重要途径,我们经常能通过群聊看见小程序的身影。我们希望开发者在实现小程序逻辑的时候,能理解每一个群聊,可实现小程序与各个群聊紧密相关的功能。 基于此,我们开放了群聊 ID(openGID)的功能,供开发者区分标识每个群聊。对于每个群聊而言,小程序所获取到的 openGID 是不变的。但对于同一个群,不同的小程序内获得的 openGID 是不一样的。这一特性类似于标识用户身份的 openID。 拥有了群聊 ID,开发者可以把用户的操作按照群聊 ID 来聚合、沉淀信息,实现群协作功能。此外,通过 openID+openGID 的方式,还可以实现群排行的功能。 例如“群影”小程序以群聊ID聚合用户上传的图片,实现群相册的功能。 [图片] (“群影”小程序) 01 如何获取群聊ID 开发者获取 openGID 要依托于用户转发到群聊的小程序卡片,具体步骤如下: 1 设置带 shareTicket 的分享 在小程序内,开发者调用接口wx.updateShareMenu 带参数withShareTicket:true ,设置当前页面分享到群聊时能获取 openGID。而shareTicket本身就是获取 openGID 的凭证。 [图片] 而 iOS/Android App 分享场景当中,微信SDK也支持把所分享的消息设置成带 shareTicket。 值得注意的是,带 shareTicket 的分享卡片会被固定在某个群聊的,也就是说分享卡片会变成不能被长按转发。 2 由启动参数获取 shareTicket 当用户从某个带 shareTicket 的卡片进入小程序时,开发者可以在App.onLaunch 或者App.onShow 获取 shareTicket,而在小游戏上开发者可以通过监听 wx.onShow 或者同步调用wx.getLaunchOptionsSync 获取shareTicket。 shareTicket 实际上是小程序启动时临时生成的变量,在小程序生命周期内仅作为调用接口的凭证。生命周期结束后 shareTicket 就没有意义了。 3 通过 shareTicket 获取 openGID 开发者调 wx.getShareInfo 接口以 shareTicket 换取 openGID 的加密数据包,这是为了保证开发者服务器收到的 openGID 是可信的,开发者需要把加密数据交由后台解密,拿到真实的 openGID。数据加密机制更多请参看[数据加密相关文档]。 注意事项 ▷▷ 由于2018年7月5日起,新提交发布的小程序版本将无法通过用户分享获得群ID,即开发者通过wx.onShareAppMessage获取群 ID 的方式将不再支持,后续仅支持通过启动参数获取群 ID。请开发者及时调整。 02 群聊名称组件 除了群聊 ID 以外,开发者还能使用群聊对应的名称。出于保护用户隐私的考虑,我们不会把真实的群聊名称暴露给开发者,而是通过 open-data 组件让开发者在小程序前端展示某个 openGID 对应的群名称。 [图片] 其中 openGID 就是小程序获取到的群聊 ID。 open-data 组件只展示那些用户所在群聊ID对应的名称。如果设置了非微信提供的群聊 ID,将无法展现群聊名称。 03 群聊功能 1 分享设置 wx.updateShareMenu : [查看文档] 2 小程序启动参数 App.onLaunch / App.onShow : [查看文档] 3 小游戏启动参数 wx.onShow : [查看文档] getLaunchOptionsSync : [查看文档] 4 获取 openGID wx.getShareInfo : [查看文档] 5 群名称组件 open-data>/open-data>: [查看文档]
2018-08-17 - 开放报名:微信开放平台公交地铁行业小程序乘车码激励活动
开放报名:微信开放平台公交地铁行业小程序乘车码激励活动 为更好的鼓励服务商开拓公交地铁行业小程序乘车码场景业务,为广大用户提供高效、便捷、贴心的出行体验,微信开放平台推出本激励活动,服务商代所授权的小程序商户报名成功并满足规定的条件后,服务商可获得相应的奖励。 一.活动规则1.有效期:2020年8月1日到2021年1月31日 2.行业范围:公交地铁行业小程序乘车码业务(需开通微信小程序广告流量主功能并接入小程序广告) 3.奖励对象:取得公交、地铁、城市通卡公司官方授权的小程序服务商(非腾讯主体) 4.奖励规则: 4.1.奖励计算方式 以小程序APPID为计算单位,符合准入条件的服务商报名并经审核通过后方可参与本活动,参与本活动并满足达标条件的小程序,给予服务商按小程序广告流量主的收入流水的18%进行奖励。 5.准入条件: 5.1.服务商与小程序具备绑定授权关系; 5.2.小程序类目为公交地铁且具备乘车码功能; 5.3.小程序的公交地铁行业的上个自然月代扣笔数>100; 5.4.提供公交地铁行业公司关联性证明材料。 6.达标要求:详情请登录服务平台查看。 二.参与流程1. 服务商注册open账号并创建第三方平台 1.1.第三方平台的申请和上线流程参照点击查看【注册第三方平台操作指引】; 1.2.如果在创建第三方平台时选择的是“定制化开发服务商“,则需要做如下操作:可将自己已经开发出的定制化小程序关联到服务商平台中,生成凭证(票据)填充到小程序代码包中进行关联,平台获取开发关系,点击查看【生成凭证操作指引】; 2.服务商入驻服务平台 完成第三方平台创建后需入驻服务平台,点击查看【入驻服务平台操作指引】; 3.服务商报名激励活动 登录服务平台-服务商激励,按照页面提示完成服务商及小程序报名流程,报名审核通过方可参与本激励活动,点击查看【报名激励活动操作指引】。 具体的活动方案细则请登录服务平台查看。 微信团队 2020年08月24日
2020-08-24