- lucky-wheel 开发转盘层级太高,弹窗都被挡住了?
网址 https://100px.net/usage/wx.html,开发的转盘,我需要点击活动规则 弹窗显示规则内容,但是转盘的层级太高,将规则内容挡住了?请问怎么解决? 黑色背景 我是需要层级 高于 转盘 里面显示具体内容的,现在是转盘将黑色背景挡住了,麻烦大家帮忙看下 [图片]
2023-10-16 - 小程序端会话场景下长列表实现
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 - 小程序配置分享的一种实践
在微信浏览器中,H5借助微信的JS-SDK即可配置分享功能。 小程序分享配置介绍在小程序中,同样有分享功能,官方文档中包含了分享到朋友圈及 “转发”给好友及群聊两种 这里主要探讨小程序的分享。 首先看下普通分享,即“转发”,通过阅读文档发现小程序没有提供配置全局分享信息的方法,且默认是没有开启页面分享的 开启方式: page添加onShareAppMessage参数(函数)触发方式: 点击右上角 --> 选择转发给朋友 (menu)点击含有open-type="share"属性的button,会触发页面的onShareAppMessage 事件(事件触发时执行) (button) onShareAppMessage() { const app_version_info = wx.getStorageSync('app_version_info'); return { title: app_version_info.title, imageUrl: "https://xxx/wxapp/share-img.png" }; }, onShareAppMessage函数在点击分享菜单或点击button时触发。 当onShareAppMessage为空函数时,即没有返回配置对象时,微信小程序会自动设置默认分享配置,小程序名作为转发标题,高度为80% 屏幕宽度的图像作为转发图片。 小程序不同场景需求配置分享的策略1. 官方配置思路官方的配置思路是,哪个页面需要,哪个页面添加,不添加则不开启分享 适用场景:当前项目仅部分页面需要配置分享,如活动页面,并可携带动态参数。如果在打开分享后,需要获取更多场景信息,如群标识,见此官方文档。 onShareAppMessage() { return { title: app_version_info.title, imageUrl: "https://xxx/wxapp/share-img.png" path:'xxxx?xx=aa' }; }, 2. 全局默认分享、全局自定义分享、全局开启分享且部分页面自定义分享[8]//在App.js 开头处写入如下立即执行函数表达式 (function(){ var PageTmp = Page; Page =function (pageConfig) { // 设置全局默认分享 pageConfig = Object.assign({ onShareAppMessage:function () { return { title:'默认文案', path:'默认分享路径+id', imageUrl:'默认分享图片', }; } },pageConfig); PageTmp(pageConfig); }; })(); //解释下上述代码做了什么 //在小程序初始化启动时,在上下文中立即执行上述代码,Page函数被赋值给PageTmp变量 //然后Page函数被重写,该函数接受原有初始化Page的Object参数,并添加了onShareAppMessage参数(函数) //然后再调用PageTmp(真正的Page函数),并传修改后的配置对象参数 //IIEF在初始化执行完后立即被销毁,所以功能上完成的重写Page的作用,类似于执行前的拦截 //当在某个页面写了onShareAppMessage时,由于assign在后面,所以页面配置会替换默认配置 开启全局默认分享即不自定义分享信息、部分页面自定义分享需要在页面自行添加onShareAppMessage函数。 3. 仅开启部分页面分享且分享信息一致,一次配置策略// 使用behaviors 统一配置,在需要的页面引入 // ./behaviors/share-config.js // eslint-disable-next-line no-undef module.exports = Behavior({ data: {}, methods: { onShareAppMessage() { return { title: '测试分享标题', imageUrl: 'https://xxx/wxapp/share-img.png', }; }, }, }); //./pages/home/home.js var shareConfigBehavior = require('../../behaviors/share-config.js') Page({ behaviors: [shareConfigBehavior], //... }) 参考: 微信小程序原理分析 - 匠心博客从源码看微信小程序启动过程 - 有赞coder 公众号taro - issues-7470微信小程序全局分享转发控制实现——从此无需页面单独配置 - 简书小程序的全局分享方法onShareAppMessage - segmentfault谈一谈隐藏路由回调API wx.onAppRoute - 微信开放社区从源码看微信小程序启动过程 - 有赞团队关于小程序全局分享 - 微信开放社区如果错误,敬请指出!
2023-01-30 - 论函数复用的几大姿势
开发过小程序的朋友们应该都遇到这样的情况,可能很多个页面有相同的函数,例如[代码]onShareAppMessage[代码],有什么最佳实践吗,应该如何处理呢? 本次开发技巧,我从以下几种解决办法剖析: 将它复制粘贴到每个地方(最烂的做法) 抽象成一个公共函数,每个[代码]Page[代码]都手动引用 提取一个behavior,每个页面手动注入 通过[代码]Page[代码]封装一个新的[代码]newPage[代码],以后每个页面都通过[代码]newPage[代码]注册 劫持Page函数,注入预设方法,页面仍可使用[代码]Page[代码]注册 复制粘贴大法 这是最直观,也是初学者最常用到的办法。也是作为工程师最不应该采取的办法。这有一个致命的问题,如果某一天,需要改动这个函数,岂不是要将所有的地方都翻出来改,所以这个办法直接否决。 抽象公共函数 这种方式,解决了复制粘贴大法的致命问题,不需要改动很多地方,只需要改动这个抽象出来的函数即可。但是其实,这个方式不便捷,每次新增页面都需要手动引入这个函数。 以下都通过[代码]onShareAppMessage[代码]方法举例。 假设在[代码]app.js[代码]通过[代码]global[代码]注册了[代码]onShareAppMessage[代码]方法: [代码]// app.js global.onShareAppMessage = function() { return { title: '我在这里发现了很多好看的壁纸', path: 'pages/index/index', imageUrl: '' } } [代码] 那么此时每次新增的Page都需要这样引入: [代码]// page.js Page({ ...global.onShareAppMessage, data: {} }) [代码] 这样的缺点也是非常明显的: 创建新页面时,容易遗忘 如果多个相同的函数,则需要每个独立引入,不方便 提取Behavior 将多个函数集成到一个对象中,每个页面只需要引入这个对象即可注入多个相同的函数。这种方式可以解决 抽象公共函数 提到的 缺点2。 大致的实现方式如下: 同样在[代码]app.js[代码]通过[代码]global[代码]注册一个[代码]behavior[代码]对象: [代码]// app.js global.commonPage = { onShareAppMessage: function() { return { title: '我在这里发现了很多好看的壁纸', path: 'pages/index/index', imageUrl: '' } }, onHide: function() { // do something } } [代码] 在新增的页面注入: [代码]// page.js Page({ data: {}, ...global.commonPage, }}) [代码] 缺点仍然是,新增页面时容易遗忘 封装新Page 封装新的[代码]Page[代码],然后每个页面都通过这个新的[代码]Page[代码]注册,而不是采用原有的[代码]Page[代码]。 同理,在[代码]app.js[代码]先封装一个新的[代码]Page[代码]到全局变量[代码]global[代码]: [代码]// app.js global.newPage = function(obj) { let defaultSet = { onShareAppMessage: function() { return { title: '我在这里发现了很多好看的壁纸', path: 'pages/index/index', imageUrl: '' } }, onShow() { // do something } } return Page({...defaultSet, ...obj}) } [代码] 往后在每个页面都使用新的[代码]newPage[代码]注册: [代码]// page.js global.newPage({ data: {} }) [代码] 好处即是全新封装了[代码]Page[代码],后续只需关注是否使用了新的[代码]Page[代码]即可;此外大家也很清晰知道这个是采用了新的封装,避免了覆盖原有的[代码]Page[代码]方法。 我倒是觉得没什么明显缺点,要是非要鸡蛋里挑骨头的话,就是要显式调用新的函数注册页面。 劫持Page 劫持函数其实是挺危险的做法,因为开发人员可能会在定位问题时,忽略了这个被劫持的地方。 劫持[代码]Page[代码]的做法,简单的说就是,覆盖[代码]Page[代码]这个函数,重新实现[代码]Page[代码],但这个新的[代码]Page[代码]内部仍会调用原有的[代码]Page[代码]。说起来可能有点拗口,通过代码看就一目了然: [代码]// app.js let originalPage = Page Page = function(obj) { let defaultSet = { onShareAppMessage: function() { return { title: '我在这里发现了很多好看的壁纸', path: 'pages/index/index', imageUrl: '' } }, onShow() { // do something } } return originalPage({ ...defaultSet, ...obj}) } [代码] 通过这种方式,不改变页面的注册方式,但可能会让不了解底层封装的开发者感到困惑:明明没注册的方法,怎么就自动注入了呢? 这种方式的缺点已经说了,优点也很明显,不改变任何原有的页面注册方式。 其实这个是一个挺好的思路,在一些特定的场景下,会有事半功倍的效果。
2020-03-23 - 小程序奇技淫巧之 -- 日志能力
日志与反馈 前端开发在进行某个问题定位的时候,日志是很重要的。因为机器兼容性问题、环境问题等,我们常常无法复现用户的一些bug。而微信官方也提供了较完整的日志能力,我们一起来看一下。 用户反馈 小程序官方提供了用户反馈携带日志的能力,大概流程是: 开发中日志打印,使用日志管理器实例 LogManager。 用户在使用过程中,可以在小程序的 profile 页面(【右上角胶囊】-【关于xxxx】),点击【投诉与反馈】-【功能异常】(旧版本还需要勾选上传日志),则可以上传日志。 在小程序管理后台,【管理】-【反馈管理】,就可以查看上传的日志(还包括了很详细的用户和机型版本等信息)。 这个入口可能对于用户来说过于深入(是的,官方也发现这个问题了,所以后面有了实时日志),我们小程序也可以通过[代码]button[代码]组件,设置[代码]openType[代码]为[代码]feedback[代码],然后用户点击按钮就可以直接拉起意见反馈页面了。利用这个能力,我们可以监听用户截屏的操作,然后弹出浮层引导用户主动进行反馈。 [代码]<view class="dialog" wx:if="{{isFeedbackShow}}"> <view>是否遇到问题?</view> <button open-type="feedback">点击反馈</button> </view> wx.onUserCaptureScreen(() => { // 设置弹窗出现 this.setData({isFeedbackShow: true}) }); [代码] LogManager 关于小程序的 LogManager,大概是非常实用又特别低调的一个能力了。它的使用方式其实和 console 很相似,提供了 log、info、debug、warn 等日志方式。 [代码]const logger = wx.getLogManager() logger.log({str: 'hello world'}, 'basic log', 100, [1, 2, 3]) logger.info({str: 'hello world'}, 'info log', 100, [1, 2, 3]) logger.debug({str: 'hello world'}, 'debug log', 100, [1, 2, 3]) logger.warn({str: 'hello world'}, 'warn log', 100, [1, 2, 3]) [代码] 打印的日志,从管理后台下载下来之后,也是很好懂: [代码]2019-6-25 22:11:6 [log] wx.setStorageSync api invoke 2019-6-25 22:11:6 [log] wx.setStorageSync return 2019-6-25 22:11:6 [log] wx.setStorageSync api invoke 2019-6-25 22:11:6 [log] wx.setStorageSync return 2019-6-25 22:11:6 [log] [v1.1.0] request begin 2019-6-25 22:11:6 [log] wx.request api invoke with seq 0 2019-6-25 22:11:6 [log] wx.request success callback with msg request:ok with seq 0 2019-6-25 22:11:6 [log] [v1.1.0] request done 2019-6-25 22:11:7 [log] wx.navigateTo api invoke 2019-6-25 22:11:7 [log] page packquery/pages/index/index onHide have been invoked 2019-6-25 22:11:7 [log] page packquery/pages/logs/logs onLoad have been invoked 2019-6-25 22:11:7 [log] [v1.1.0] logs | onShow | | [] 2019-6-25 22:11:7 [log] wx.setStorageSync api invoke 2019-6-25 22:11:7 [log] wx.setStorageSync return 2019-6-25 22:11:7 [log] wx.reportMonitor api invoke 2019-6-25 22:11:7 [log] page packquery/pages/logs/logs onShow have been invoked 2019-6-25 22:11:7 [log] wx.navigateTo success callback with msg navigateTo:ok [代码] LogManager 最多保存 5M 的日志内容,超过5M后,旧的日志内容会被删除。基础库默认会把 App、Page 的生命周期函数和 wx 命名空间下的函数调用写入日志,基础库的日志帮助我们定位具体哪些地方出了问题。 实时日志 小程序的 LogManager 有一个很大的痛点,就是必须依赖用户上报,入口又是右上角胶囊-【关于xxxx】-【投诉与反馈】-【功能异常】这么长的路径,甚至用户的反馈过程也会经常丢失日志,导致无法查问题。 为帮助小程序开发者快捷地排查小程序漏洞、定位问题,微信推出了实时日志功能。从基础库 2.7.1 开始,开发者可通过提供的接口打印日志,日志汇聚并实时上报到小程序后台。 使用方式如下: 使用 wx.getRealtimeLogManager 在代码⾥⾯打⽇志。 可从小程序管理后台【开发】-【运维中心】-【实时日志】进入日志查询页面,查看开发者打印的日志信息。 开发者可通过设置时间、微信号/OpenID、页面链接、FilterMsg内容(基础库2.7.3及以上支持setFilterMsg)等筛选条件查询指定用户的日志信息: [图片] 由于后台资源限制,实时日志使用规则如下: 为了定位问题方便,日志是按页面划分的,某一个页面,在onShow到onHide(切换到其它页面、右上角圆点退到后台)之间打的日志,会聚合成一条日志上报,并且在小程序管理后台上可以根据页面路径搜索出该条日志。 每个小程序账号每天限制500万条日志,日志会保留7天,建议遇到问题及时定位。 一条日志的上限是5KB,最多包含200次打印日志函数调用(info、warn、error调用都算),所以要谨慎打日志,避免在循环里面调用打日志接口,避免直接重写console.log的方式打日志。 意见反馈里面的日志,可根据OpenID搜索日志。 setFilterMsg 可以设置过滤的 Msg。这个接口的目的是提供某个场景的过滤能力,例如[代码]setFilterMsg('scene1')[代码],则在 MP 上可输入 scene1 查询得到该条日志。比如上线过程中,某个监控有问题,可以根据 FilterMsg 过滤这个场景下的具体的用户日志。FilterMsg 仅支持大小写字母。如果需要添加多个关键字,建议使用 addFilterMsg 替代 setFilterMsg。 日志开发技巧 既然官方提供了 LogManager 和实时日志,我们当然是两个都要用啦。 log.js 我们将所有日志的能力都封装在一起,暴露一个通用的接口给调用方使用: [代码]// log.js const VERSION = "0.0.1"; // 业务代码版本号,用户灰度过程中观察问题 const canIUseLogManage = wx.canIUse("getLogManager"); const logger = canIUseLogManage ? wx.getLogManager({level: 0}) : null; var realtimeLogger = wx.getRealtimeLogManager ? wx.getRealtimeLogManager() : null; /** * @param {string} file 所在文件名 * @param {...any} arg 参数 */ export function DEBUG(file, ...args) { console.debug(file, " | ", ...args); if (canIUseLogManage) { logger!.debug(`[${VERSION}]`, file, " | ", ...args); } realtimeLogger && realtimeLogger.info(`[${VERSION}]`, file, " | ", ...args); } /** * * @param {string} file 所在文件名 * @param {...any} arg 参数 */ export function RUN(file, ...args) { console.log(file, " | ", ...args); if (canIUseLogManage) { logger!.log(`[${VERSION}]`, file, " | ", ...args); } realtimeLogger && realtimeLogger.info(`[${VERSION}]`, file, " | ", ...args); } /** * * @param {string} file 所在文件名 * @param {...any} arg 参数 */ export function ERROR(file, ...args) { console.error(file, " | ", ...args); if (canIUseLogManage) { logger!.warn(`[${VERSION}]`, file, " | ", ...args); } if (realtimeLogger) { realtimeLogger.error(`[${VERSION}]`, file, " | ", ...args); // 判断是否支持设置模糊搜索 // 错误的信息可记录到 FilterMsg,方便搜索定位 if (realtimeLogger.addFilterMsg) { try { realtimeLogger.addFilterMsg( `[${VERSION}] ${file} ${JSON.stringify(args)}` ); } catch (e) { realtimeLogger.addFilterMsg(`[${VERSION}] ${file}`); } } } } // 方便将页面名字自动打印 export function getLogger(fileName: string) { return { DEBUG: function(...args) { DEBUG(fileName, ...args); }, RUN: function(...args) { RUN(fileName, ...args); }, ERROR: function(...args) { ERROR(fileName, ...args); } }; } [代码] 通过这样的方式,我们在一个页面中使用日志的时候,可以这么使用: [代码]import { getLogger } from "./log"; const PAGE_MANE = "page_name"; const logger = getLogger(PAGE_MANE); [代码] autolog-behavior 现在有了日志组件,我们需要在足够多的地方记录日志,才能在问题出现的时候及时进行定位。一般来说,我们需要在每个方法在被调用的时候都打印一个日志,所以这里封装了一个 autolog-behavior 的方式,每个页面(需要是 Component 方式)中只需要引入这个 behavior,就可以在每个方法调用的时候,打印日志: [代码]// autolog-behavior.js import * as Log from "../utils/log"; /** * 本 Behavior 会在小程序 methods 中每个方法调用前添加一个 Log 说明 * 需要在 Component 的 data 属性中添加 PAGE_NAME,用于描述当前页面 */ export default Behavior({ definitionFilter(defFields) { // 获取定义的方法 Object.keys(defFields.methods || {}).forEach(methodName => { const originMethod = defFields.methods![methodName]; // 遍历更新每个方法 defFields.methods![methodName] = function(ev, ...args) { if (ev && ev.target && ev.currentTarget && ev.currentTarget.dataset) { // 如果是事件类型,则只需要记录 dataset 数据 Log.RUN(defFields.data.PAGE_NAME, `${methodName} invoke, event dataset = `, ev.currentTarget.dataset, "params = ", ...args); } else { // 其他情况下,则都记录日志 Log.RUN( defFields.data.PAGE_NAME, `${methodName} invoke, params = `, ev, ...args); } // 触发原有的方法 originMethod.call(this, ev, ...args); }; }); } }); [代码] 我们能看到,日志打印依赖了页面中定义了一个[代码]PAGE_NAME[代码]的 data 数据,所以我们在使用的时候可以这么处理: [代码]import { getLogger } from "../../utils/log"; import autologBehavior from "../../behaviors/autolog-behavior"; const PAGE_NAME = "page_name"; const logger = getLogger(PAGE_NAME); Component({ behaviors: [autologBehavior], data: { PAGE_NAME, // 其他数据 }, methods: { // 定义的方法会在调用的时候自动打印日志 } }); [代码] 页面如何使用 Behavior 看看官方文档:事实上,小程序的页面也可以视为自定义组件。因而,页面也可以使用[代码]Component[代码]构造器构造,拥有与普通组件一样的定义段与实例方法。但此时要求对应[代码]json[代码]文件中包含[代码]usingComponents[代码]定义段。 完整的项目可以参考wxapp-typescript-demo。 参考 LogManager 实时日志 Component构造器 behaviors 结束语 使用自定义组件的方式来写页面,有特别多好用的技巧,behavior 就是其中一个比较重要的能力,大家可以发挥自己的想象力来实现很多奇妙的功能。
2019-12-10 - 小程序页面通信、数据刷新、事件总线 、event bus 终极解决方案之 iny-bus
#### 背景介绍 在各种小程序中,我们经常会遇到 这种情况 有一个 列表,点击列表中的一项进入详情,详情有个按钮,删除了这一项,这个时候当用户返回到列表页时, 发现列表中的这一项依然存在,这种情况,就是一个 `bug`,也就是数据不同步问题,这个时候测试小姐姐 肯定会找你,让你解决,这个时候,你也许会很快速的解决,但过一会儿,测试小姐姐又来找你说,我打开了 四五个页面更改了用户状态,但我一层一层返回到首页,发现有好几个页面数据没有刷新,也是一个 bug, 这个时候你就犯愁了,怎么解决,常规方法有下面几种 #### 解决方法 1. 将所有请求放到 生命周期 `onShow` 中,只要我们页面重新显示,就会重新请求,数据也会刷新 2. 通过用 `getCurrentPages` 获取页面栈,然后找到对应的 页面实例,调用实例方法,去刷新数据 3. 通过设置一个全局变量,例如 App.globalData.xxx,通过改变这个变量的值,然后在对应 onShow 中检查,如果值已改变,刷新数据 4. 在打开详情页时,使用 redirectTo 而不是 navigateTo,这样在打开新的页面时,会销毁当前页面, 返回时就不会回到这个里面,自然也不会有数据不同步问题 #### 存在的问题 1. 假如我们将 所有 请求放到 onShow 生命周期中,自然能解决所有数据刷新问题,但是 onShow 这个生命周期,有两个问题 第一个问题,它其实是在 onLoad 后面执行的,也就是说,假如请求耗时相同,从它发起请求到页面渲染, 会比 onLoad 慢 第二个问题,那就是页面隐藏、调用微信分享、锁频等等都会触发执行,请求放置于 `onShow` 中就会造成 大量不需要的请求,造成服务器压力,多余的资源浪费、也会造成用户体验不好的问题 2. 通过 `getCurrentPages` 获取页面栈,然后找到对应的 页面实例,调用实例方法,去刷新数据,这也 不失为一个办法,但是就如微信官方文档所说 > 不要尝试修改页面栈,会导致路由以及页面状态错误。 > 不要在 App.onLaunch 的时候调用 `getCurrentPages()`,此时 page 还没有生成。 同时、当需要通信的页面有两个、三个、多个呢,这里去使用 `getCurrentPages` 就会比较困难、繁琐 3. 通过设置全局变量的方法,当需要使用的地方比较少时,可以接受,当使用的地方多的时候,维护起来 就会很困难,代码过于臃肿,也会有很多问题 4. 使用 redirectTo 而不是 navigateTo,从用来体验来说,很糟糕,并且只存在一个页面,对于 tab 页面,它也无能为力,不推荐使用 #### 最佳实践 在 Vue 中, 可以通过 new Vue() 来实现一个 event bus作为事件总线,来达到事件通知的功能,在各大 框架中,也有自身的事件机制实现,那么我们完全可以通过同样的方法,实现一个事件中心,来管理我们的事件, 同时,解决我们的问题。iny-bus 就是这样一个及其轻量的事件库,使用 typescript 编写,100% 测试覆 盖率,能运行 js 的环境,就能使用 传送门 [源码](https://github.com/landluck/iny-bus) [NPM](https://www.npmjs.com/package/iny-bus) [文档](https://landluck.github.io/iny-bus/docs/) #### 简单使用 iny-bus 使用及其简单,在需要的页面 onLoad 中添加事件监听, 在需要触发事件的地方派发事件,使监 听该事件的每个页面执行处理函数,达到通信和刷新数据的目的,在小程序中的使用可以参考以下代码 [代码] // 小程序[代码] [代码] import bus from [代码][代码]'iny-bus'[代码] [代码] // 添加事件监听[代码] [代码] // 在 onLoad 中注册, 避免在 onShow 中使用[代码] [代码] onLoad () {[代码] [代码] this[代码][代码].eventId = bus.on([代码][代码]'事件名'[代码][代码], (a, b, c, d) => {[代码] [代码] // 支持多参数[代码] [代码] console.log(a, b, c, d)[代码] [代码] this[代码][代码].setData({ a [代码]}) [代码] // 调用页面请求函数,刷新数据[代码] [代码] this[代码][代码].refreshPageData()[代码] [代码] })[代码] [代码] // 添加只需要执行一次的 事件监听[代码] [代码] this[代码][代码].eventIdOnce = bus.once([代码][代码]'事件名'[代码][代码], () => {[代码] [代码] // do some thing[代码] [代码] })[代码] [代码] }[代码] [代码] // 移除事件监听,该函数有两个参数,第二个事件id不传,会移除整个事件监听,传入ID,会移除该[代码] [代码] // 页面的事件监听,避免多余资源浪费, 在添加事件监[代码][代码]/// 听后,页面卸载(onUnload)时建议移除[代码] [代码] onUnload () {[代码] [代码] bus.remove([代码][代码]'事件名'[代码][代码], [代码][代码]this[代码][代码].eventId)[代码] [代码] }[代码] [代码] // 派发事件,触发事件监听处更新视图[代码] [代码] // 支持多参传递[代码] [代码] onClick () {[代码] [代码] bus.emit([代码][代码]'事件名'[代码][代码], a, b, c)[代码] [代码] }[代码] 更详细的使用和例子可以参考 [Github iny-bus 小程序代码](https://github.com/landluck/iny-bus/tree/master/examples) #### iny-bus 具体实现 * 基本打包工具,这里使用非常优秀的开源库 [typescript-library-starter](https://github.com/alexjoverm/typescript-library-starter),具体细节不展开 * 测试工具 使用 facebook 的 [jest](https://github.com/facebook/jest) * build ci 使用 [travis-ci](https://www.travis-ci.org/) * 测试覆盖率上传使用 [codecov](https://codecov.io/) * 具体的其他细节大家可以看源码中的 [package.json](https://github.com/landluck/iny-bus/blob/master/package.json),这里就一一展开讲了 iny-bus 的核心代码,其实就这么多,总的来说,非常少,但是能解决我们在小程序中遇到的大量 通信 和 数据刷新问题,是采用 各大平台小程序 原生开发时,页面通信的不二之选,同时,100% 的测试覆盖率,确保了 iny-bus 在使用中的稳定性和安全性,当然,每个库都是从简单走向复杂,功能慢慢完善,如果 大家在使用或者源码中发现了bug或者可以优化的点,欢迎大家提 pr 或者直接联系我 最后,如果 iny-bus 给你提供了帮助或者让你有任何收获,请给 作者 点个赞,感谢大家 [点赞](https://github.com/landluck/iny-bus)
2019-08-04 - 微信商户号的千6的手续费具体是指什么?
微信商户号的千6的手续费具体是指什么? 是这样的,我做迎新春的福利红包活动,如果我充值1000元,最后能发给用户的金额是1000还是996(1000-6)? [图片]
02-13 - 官方隐私弹窗拒绝授权回调怎么用?
官方的隐私授权弹窗同意可以没有回调,但是拒绝的回调在哪里?怎么用呢? [图片]
2023-10-20 - 关于小程序隐私保护指引设置的公告
为规范开发者的用户个人信息处理行为,保障用户的合法权益,自2023年9月15日起,对于涉及处理用户个人信息的小程序开发者,微信要求,仅当开发者主动向平台同步用户已阅读并同意了小程序的隐私保护指引等信息处理规则后,方可调用微信提供的隐私接口。 开发者首先需确定小程序是否涉及处理用户个人信息,如涉及,则需配置用户隐私授权弹窗,且仅有在平台《小程序用户隐私保护指引》中声明了所处理的用户个人信息,才可以调用平台提供的对应接口或组件。(隐私相关接口) 隐私协议设置整体流程参考下方指引: 一、设置《小程序用户隐私保护指引》 开发者需在「小程序管理后台」设置《小程序用户隐私保护指引》 [图片] [图片] 二、填写《小程序用户隐私保护指引》 [图片] 只有在指引中声明所处理的用户个人信息,才可以调用平台提供的对应接口或组件。若未声明,对应接口或组件将无法调用成功。隐私接口与对应的处理的用户个人信息关系可见:小程序用户隐私保护指引内容介绍 三、配置用户隐私授权弹窗 微信提供了wx.onNeedPrivacyAuthorization(function callback) 接口,意为用户触发了一个微信侧未记录过同意的隐私接口调用,开发者可通过响应该事件选择提示用户的时机。此外,微信还提供了 wx.requirePrivacyAuthorize(Object object) 接口,可用于模拟触发 onNeedPrivacyAuthorization 事件。 小程序开发者可自行设计提示方式与触发时机,详细文档可查看隐私协议开发指南。 仅有在指引中声明所处理的用户个人信息,才可以调用平台提供的对应接口或组件。若未声明,对应接口或组件将直接禁用。 [图片] (参考样例) 四、如要进行代码提审,开发者需先自行声明是否有采集用户隐私,如有,则需在提审页面-「用户隐私保护设置」选择“采集用户隐私” [图片]
2023-09-18 - Donut多端-云开发API_V2微信支付案例
Donut多端-云开发API_V2微信支付案例 使用的是小程序原生-多端支付 使用条件 1.微信开放平台 2.微信支付(app支付) 3.多端SDK(含支付) 云开发回调这个不知道怎么解决 只能在微信确认支付返回app后确认 有解决方法的可以分享一下 前端js // 调用云开发 //自定义支付信息 const payData = {price,body,attach} cloud('Ger_pay',{ type2:'pay_token', payData }).then(res =>{ console.log('获取sign信息',res) const {timestamp,prepayid,partnerid,appId,noncestr,sign} = res.result; wx.miniapp.requestPayment({ timeStamp: timestamp, mchId: partnerid, prepayId: prepayid, package: 'Sign=WXPay', nonceStr: noncestr, sign: sign, success: (res) => { console.warn('wx.miniapp.requestPayment success:', res) wx.showLoading({ title: '支付中...', mask: true }) // 支付成功返回 cloud('Ger_pay',{ type2:'app_vip', prepayid, payData }).then(res =>{ console.log(res) wx.reLaunch({ url: '../index/index' }) }) }, fail: (res) => { console.error('取消支付', res) wx.showToast({ title: '取消支付', }) } }) }) 云开发index.js // 云函数入口文件 const cloud = require('wx-server-sdk') const pay_token = require('./pay_token.js') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) // 使用当前云环境 const USER_LIST = 'UserList' // 修改付款记录 async function getpre_order(db,prepayid){ try { await db.collection('pre_order').where({ prepayid:prepayid }).update({ data: { payTime: new Date().getTime(), payStatus: 1 // 0:未支付, 1:已支付 }, }) } catch (error) { console.error(error); } } exports.main = async (event,context) => { try { const wxContext = cloud.getWXContext() const db = cloud.database() const unionid = wxContext.UNIONID const time = Date.parse(new Date()); const timestamp = time / 1000; var e = event; if (e.type2 === 'pay_token') { return await pay_token.main(event,context); } if (e.type2 === 'app_vip') { const {prepayid,payData} = e await getpre_order(db,prepayid) } }catch (error) { console.error(error); return { code: -1, message: '服务器错误' + error}; } } [图片] //云开发代码pay_token.js //以下全部照抄即可 const key = "253050DD68FF61CC99D56232E002489" //换成你的商户key,32位 const mch_id = "160755748" //换成你的商户号 const cloud = require('wx-server-sdk') const rp = require('request-promise') const crypto = require('crypto') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) // 使用当前云环境 const db = cloud.database() // 签名接口 function getSign(args) { let arr = new Array(); let num = 0; for (let i in args) { arr[num] = i; num++; } let sortArr = arr.sort(); let sortObj = {}; for (let i in sortArr) { sortObj[sortArr[i]] = args[sortArr[i]]; } // 对传入的对象进行拼接 let sortStr = '' sortArr.forEach(key => { sortStr += key + "=" + sortObj[key] + "&" }); // 拼接密钥 sortStr += "key=" + key // 使用MD5进行加密, 并将加密结果的英文字母全部转换成大写 const sign = crypto.createHash('md5').update(sortStr, 'utf8').digest('hex').toUpperCase(); return sign; } // 转换成xml function getXml(args) { let sa = [] for (let k in args) sa.push('<' + k + '>' + args[k] + '</' + k + '>') sa.push('<sign>' + getSign(args) + '</sign>') return '<xml>' + sa.join('') + '</xml>' } exports.main = async (event, context) => { //前端传入payData const {total_fee,body,attach} = event.payData //total_fee金额 body商品描述 attach附加数据 const wxContext = cloud.getWXContext() const openid = wxContext.OPENID const unionid = wxContext. unionid const appId = appid = 'wx8c699bc1e33333fa9' const attach = attach || 'app支付' //attach 附加数据 const notify_url = "https://api.XXX.com/notify" const spbill_create_ip = "127.0.0.1" const noncestr = nonceStr = nonce_str = Math.random().toString(36).substr(2, 15) const timestamp = timeStamp = parseInt(Date.now() / 1000) + '' const out_trade_no = "otn" + nonce_str + timeStamp const trade_type = "APP" //trade_type支付类型 const sign_type = 'MD5' // 统一下单数据 const xmlArgs = { appid, attach, body: body, mch_id, nonce_str, notify_url, out_trade_no, spbill_create_ip, total_fee, fee_type:'CNY', trade_type, sign_type } // 统一下单 let xml = (await rp({ url: "https://api.mch.weixin.qq.com/pay/unifiedorder", method: 'POST', body: getXml(xmlArgs) })) // 下单完成,返回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, partnerid: mch_id, prepayid:prepay_id, package: 'Sign=WXPay', timestamp, } //保存支付信息到云开发数据库 await db.collection("pre_order").add({ data: { ...payArgs, // 保存支付信息 body, trade_type, openid: openid, // 用户openid unionid:unionid, orderTime: new Date().getTime(), payStatus: 0 ,// 0:未支付, 1:已支付, -1:失效 totalFee:total_fee } }) return { ...payArgs, // 返回数据 把payArgs里面的sign替换成第二次签名sign sign: getSign(payArgs) } }
2023-07-24 - 下发统一消息------公众号模板通知first和remark突然没看到了
[图片] [图片] [图片] [图片] first和remark突然没看到了
2023-05-29 - 关于使用“添加到我的小程序”功能的规范
小程序菜单提供了“添加到我的小程序”功能,目的是为了方便用户快速找到常用小程序,提升使用体验。 然而部分开发者通过诱导或强提示的方式,诱导用户添加至我的小程序,此类情况违反了《微信小程序平台运营规范》5.15 “滥用接口能力行为:微信小程序应当合法、合规、正当、善意地使用腾讯提供的各项功能、接口和能力等,使用时应当遵循微信小程序相关的协议规则,保护微信用户和其他第三方的合法权益,并符合腾讯对微信小程序所提倡的各项要求和建议。不得自行或协助他人滥用、不正当使用、非善意使用上述功能、接口、能力等。” 相关违规类型包括但不限于以下类型: 违规类型1:利益诱导添加小程序 通过优惠券/积分等奖励,诱导添加至我的小程序。 [图片] 违规类型2:强制诱导添加小程序 弹层提示添加至我的小程序,遮挡小程序使用界面,且不可关闭。 [图片] 违规类型3:频繁提示添加小程序 提示弹层每次打开都出现,过度提示影响用户体验。 [图片] 为了保障小程序用户良好的使用体验,请开发者及时调整“添加至我的小程序”提示方式,调整参考指引如下: 1. 添加提示不涉及利益诱导 [图片] 2. 添加提示不遮挡正常界面,可关闭(非弹层类提示体验更佳) [图片] 3. 添加提示进行频率限制 添加提示仅每日首次打开出现,或出现数秒后自动消失。 [图片]
2021-08-12 - Painter 一款轻量级的小程序海报生成组件
生成海报相信大家有的人都做过,但是canvas绘图的坑太多。大家可以试试这个组件。然后附上楼下大哥做的可视化拖拽生成painter代码的工具:链接地址https://developers.weixin.qq.com/community/develop/article/doc/000e222d9bcc305c5739c718d56813
2019-09-27 - html-canvas 生成小程序分享图
简介 基于 HTML 和 CSS 实现 Canvas 绘图。 项目地址 代码片段:https://developers.weixin.qq.com/s/9zFHKdmh7De2 原理 构建虚拟DOM 树,依据 CSS 规范计算样式,使用 CSS 盒模型对 DOM 进行布局,计算出所有元素的位置。最后将 DOM 树通过 Canvas Api 进行绘制。 小程序开发工具内运行 demo [代码]git clone https://github.com/alexayan/html-canvas.git npm i npm run watch [代码] 已支持的 CSS 属性 margin,margin-left,margin-top,margin-right,margin-bottom,padding,padding-left,padding-top,padding-right,padding-bottom,width,height,border,border-left,border-top,border-right,border-bottom,border-width,border-style,border-color,border-left-style,border-left-color,border-left-width,border-top-style,border-top-color,border-top-width,border-right-style,border-right-color,border-right-width,border-bottom-style,border-bottom-color,border-bottom-width,color,display,background-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-left-radius,border-bottom-right-radius,box-sizing,font,font-style,font-variant,font-weight,font-stretch,font-size,line-height,font-family,text-align,position,overflow,overflow-x,overflow-y,top,left,right,bottom,z-index demo canvas-draw.html [图片]
2020-01-08 - 小程序实现索引列表性能优化
简介 本文主要分享个人最近在小程序里面开发索引列表总结经验,索引列表一种一次性展示庞大的数据,并且可以通过点击某个索引跳转到指定索引的内容区域,方便用户快速找到相关的选项 本文直接使用 真实的数据 来深入分析和讨论索引列表 优化手段,功能的实现逻辑不在本文讨论范围,直接去看代码片段即可 本文也为大家带来平时我们经常遇到 假如服务端一次性返回XXX条数据,前端该怎么处理 的真实案例 [图片] 一. 索引列表功能分析 从上面的图中可以分析出有以下需求: 每个分组中都带有一个索引,每个索引分组展示相关的索引数据 每次滚动中,如果当前索引的区域与页面顶部相交,则当前索引区域的标题固定到顶部,同时右边的索引需要变成橙色 每次点击右边的索引,需要滚动到当前索引区域的顶部,同时当前索引区域标题固定到顶部 从上面的功能中,我遇到最难的一个地方就是 如何处理性能优化,如果一次性请求所有数据并且一次性渲染出来,则一些配置不好的手机需要很长的时间才能把页面渲染出来 二. 索引列表性能优化分析 前端处理与列表相关的功能时,通常会选择以下三种性能优化方案: 分页请求:通过 滚动到底部 或 点击某个分页加载按钮 请求指定的页码,避免一次性请求过多数据和一次性渲染过多数据 虚拟列表:通过 对象池 的设计理念(个人观点),一次性创建指定的节点数量同时不断去判断数据列表的数量来增加当前滚动区域,每次滚动到指定区域时直接替换原有对象的数据,页面始终只有指定数量的节点 分片渲染:如果 屏幕一次性必须要展示太多的数据 时,控制在同一时刻触发页面渲染节点数量,通常是列表的每一条数据存在图片,防止一次性渲染过多图片触发太多的图片请求,设计理念有点类似我们平时实现多文件上传时模拟实现的多线程上传逻辑 但是在索引列表的需求中,这些优化的方案实现起来并不是那么简单,下面我来分享我在开发过程中在思考列表性能优化方案的一些问题 2.1 分页请求的问题 索引列表数据量肯定是非常庞大的,第一时间肯定会想到分页请求,然而分页请求在这个需求中存在以下问题: 索引列表通常用户希望每次操作能够更快的看到内容,如果频繁出现 [代码]Loading[代码] 或多次出现请求服务端然后再渲染的卡顿,用户体验会极差 索引列表的分页时机难以决定,交互的方式有右边的索引点击和滚动,然后视图中索引区域会出现交叉的情况 2.2 虚拟列表的问题 索引列表通常一个索引分组下会渲染当前索引组相关的数据,列表要渲染多条数据肯定会想到虚拟列表,然而虚拟列表在这个需求中存在以下问题: 每个索引的区域会有一个可能固定定位的标题展示,在出现两个索引交叉展示的情况,虚拟列表的逻辑就会很难处理 点击右边的索引字母滚动的时候是有动画的,如果使用虚拟列表去实现的话,每次点击索引字母触发快速滚动时,可能会频繁触发虚拟列表滚动的代码逻辑,处理不好容易导致 [代码]UI[代码] 产生更多的 [代码]BUG[代码] 2.3 分片渲染的问题 在分页请求和虚拟列表不好使的情况下,最后能想到的只有分片渲染的优化手段,分片渲染也有以下需要考虑的问题: 要考虑好分片渲染的时机,首先不能直接简单粗暴直接从第一条数据一直分片渲染到最后一条数据,否则用户一旦进入页面就点击最后的字母,然后数据没有渲染出来那就尴尬了 分片渲染在小程序的应用中就是使用多个 [代码]setTimeout()[代码] 和多次调用 [代码]this.setData()[代码] 来在多个时间段触发多次更新,利用多次更新来实现一次 [代码]UI[代码] 展示来优化渲染,在索引列表中频繁触发滚动是很正常的,所以还需要注意 [代码]setTimeout()[代码] 数量以及 [代码]this.setData()[代码] 的调用频率问题 三. 索引列表性能优化基本实现 通过上面对各种性能优化的分析之后,我们还需要结合 索引列表的功能 和 用户的使用习惯 来决定我们的性能优化的最终实现方案: 索引列表功能通常是供用户快速找到自己需要的选项,比如在汽车相关业务的场景中,希望给用户提供一个更快找到自己的车型信息的渠道,通常索引列表比搜索功能更好用(因为搜索需要用户自己输,每个车型还有各种系列,用户肯定是记不住的) 索引列表在用户使用的过程中,会频繁的使用右边的关键词点击快速定位到自己想要的区域,然后再通过滚动找到指定的数据,所以 用户能把所有数据看完的几率几乎不可能 结合 索引列表的操作和展示特点 和 用户几乎不可能看完所有数据 两点我总结出一个思路:每当页面的视图滚动到指定索引区域时,开始渲染指定索引区域的数据 3.1 初始化索引数据和索引区域高度 获取每个索引区域的高度把整个滚动的页面撑起来,目前我们索引列表每一项高度是固定且一致的,所以只要通过每个索引分组的数据量计算出来即可 [代码]const indexListItemHeight = 120 Page({ /** * 初始化索引列表分组数据 */ initIndexListGroupData() { const list: IIndexListGroup[] = indexListKeys.map((key, index) => ({ key, index, height: `${indexListGroups[key].length * indexListItemHeight}rpx`, // 当前区域高度 list: [], })) this.setData({ indexListGroups: list, }) // 初始化索引分组渲染监听和索引分组的物理信息 wx.nextTick(() => { this.initChangeIndexListGroupsRender() this.initIndexListGroupsRect() }) }, }) [代码] 3.2 实现滚动时触发索引区域的当前索引选中 把所有索引区域高度渲染出来后,同时获取所有索引区域的真实节点物理信息,然后在滚动的过程中判断当前的滚动距离并把当前区域的标题变成固定定位 [代码]Page({ /** * 初始化获取索引区域的 top */ initIndexListGroupsRect() { const query = this.createSelectorQuery() query.selectAll('.index-list-column').boundingClientRect() query.exec((res: WechatMiniprogram.BoundingClientRectCallbackResult[][] = []) => { if (res[0]?.length) { this.data._indexListRects = res[0] } }) }, /** * 监听滚动 */ changeScroll(e: WechatMiniprogram.ScrollViewScroll) { const select = this.data.select const scrollTop = e.detail.scrollTop const _indexListRects = this.data._indexListRects const active = _indexListRects.findIndex((item) => (scrollTop - item.top) < item.height) if (select === active) { return } this.setData({ select: active }) }, }) [代码] 3.3 根据屏幕展示当前的索引区域展示对应列表数据 监听当前滚动的位置对应的索引区域,把当前在屏幕展示的区域的数据展示出来,这样就实现了只有屏幕展示相应的索引区域,相关区域的数据才能展示出来 [代码]Page({ /** * 初始化监听索引分组渲染 */ initChangeIndexListGroupsRender() { const observer = this.createIntersectionObserver({ observeAll: true }) observer.relativeToViewport({ top: 0, bottom: 0 }).observe('.index-list-column', (res) => { if (res.intersectionRatio > 0) { const index = res.dataset.index as number const { key, list } = this.data.indexListGroups[index] if (!list.length) { this.setData({ [`indexListGroups[${index}].list`]: indexListGroups[key] }) } } }) } }) [代码] 四. 索引列表性能优化进阶实现 在上面的实现步骤中,已经实现了最基本的性能优化展示(起码比一次渲染全部数据的体验好多了),但是目前还有一些细节需要深入去思考,上面的基本实现只是单纯把 一次性展示索引列表的全部分组的列表数据 优化成 当屏幕展示当前索引列表的某个分组时展示该分组的列表数据,如果当前索引分组的列表数据过多时(如 [代码]A[代码] 字母相关的数据有 [代码]100[代码] 条这样的情况),这样会在一瞬间触发全部图片的 [代码]http[代码] 请求,导致当前能看到的图片列表渲染大概率加载慢而展示不出来 [图片] 对于上面的问题我想到了以下解决思路:在渲染指定索引分组的列表时加入 分片渲染 的优化手段,使用前必须要注意一个问题,不能单纯的从该分组从上往下渲染,因为我们是可以往上滚动的,如我在 [代码]A[代码] 的分组点击了 [代码]Z[代码] 的索引,我往上面滚动我还需要渲染出 [代码]Y[代码] 分组的数据,这时候还需要考虑 从最底部开始渲染 的场景 4.1 处理分片渲染初始化逻辑 当滚动到指定的索引分组区域时,给该分组的列表数据一个 [代码]isLoading[代码] 字段来给列表先展示一个默认的样式,这样就能避免滚动到该分组时会触发该分组的列表所有图片请求 [代码]<block wx:for="{{ group.list}}" wx:key="id" > <view style="height: {{ _indexListItemHeight }}" class="index-list-column-item"> <!-- 默认不触发图片加载 --> <image wx:if="{{ item.isLoading }}" class="index-list-column-item-image" src="{{ item.imageUrl }}" mode="aspectFill" /> <text class="index-list-column-item-name">{{ item.isLoading ? item.name : '加载中...' }}</text> </view> </block> [代码] [代码]Page({ /** * 处理索引分组分片渲染 */ indexListGroupFragmentRender(index: number) { const { key } = this.data.indexListGroups[index] const currentList = indexListGroups[key] // 在初始化列表中增加 isLoading 字段 const initList: ICustomCarModel[] = currentList.map((item) => ({ ...item, isLoading: false })) this.setData({ [`indexListGroups[${index}].list`]: initList }) // 触发分组渲染 wx.nextTick(() => { this.handleFragmentRender(index) }) } }) [代码] 4.2 处理分片渲染的上下加载逻辑 上面已经分析过了一个问题,就是触发一个索引分组数据的分片渲染时,不能简单的从该分组的上面到下面开始渲染,因为这里有个操作是:先点击右边的索引跳转到某个索引分组,然后向上滚动,如果按照上面的简单处理则往上面滚动时不能及时看到下面的数据触发更新,所以我们要做到 同时上下一起触发分片渲染,具体的实现思路为以下: 给某个索引分组增加 [代码]prevIndex[代码] 和 [代码]lastIndex[代码] 来记录两个指针的渲染进度并指定一个 [代码]fragmentCount[代码] 字段为当前上下同时渲染的数量 然后判断当前剩余可渲染的数量来决定 [代码]fragmentCount[代码] 的数量,先去触发上面的渲染,然后再触发下面的渲染 如果每次渲染完毕后发现还有数据可渲染,则使用 [代码]setTimeout[代码] 实现异步递归渲染,这样就能陆陆续续的把所有分组列表渲染完毕 [代码]Page({ /** * 初始化索引列表分组数据 */ initIndexListGroupData() { const list: IIndexListGroup[] = indexListKeys.map((key, index) => ({ key, index, height: `${indexListGroups[key].length * INDEX_LIST_ITEM_HEIGHT}rpx`, prevIndex: 0, // 分片渲染的顶部索引 lastIndex: indexListGroups[key].length - 1, // 分片渲染的底部索引 timer: 0, list: [], })) this.setData({ indexListGroups: list, }) wx.nextTick(() => { this.initChangeIndexListGroupsRender() this.initIndexListGroupsRect() }) }, /** * 分片渲染 */ handleFragmentRender(index: number) { const { prevIndex, lastIndex } = this.data.indexListGroups[index] const dataObj: Record<string, any> = {} let fragmentCount = 4 // 分片渲染的数量 let currentPrevIndex = prevIndex let currentLastIndex = lastIndex // 判断是否有数据分片 if (lastIndex - prevIndex < 0) { return } // 判断是否剩余的数量不够初始分片的数量 if ((currentLastIndex - currentPrevIndex) + 1 < fragmentCount) { fragmentCount = (currentLastIndex - currentPrevIndex) + 1 } while (currentPrevIndex < prevIndex + fragmentCount) { const _key = `indexListGroups[${index}].list[${currentPrevIndex}]` dataObj[`${_key}.isLoading`] = true currentPrevIndex++ } // 判断是否底部还能渲染 if (lastIndex - currentPrevIndex >= 0) { // 判断是否剩余的数量不够初始分片的数量 if ((currentLastIndex - currentPrevIndex) + 1 < fragmentCount) { fragmentCount = (currentLastIndex - currentPrevIndex) + 1 } while (currentLastIndex > lastIndex - fragmentCount) { const _key = `indexListGroups[${index}].list[${currentLastIndex}]` dataObj[`${_key}.isLoading`] = true currentLastIndex-- } } // 分组指针直接使用同步方式记录 this.data.indexListGroups[index].prevIndex = currentPrevIndex this.data.indexListGroups[index].lastIndex = currentLastIndex // 触发页面分片渲染 this.setData(dataObj) // 判断是否还能继续分片,如果有采用异步递归 if (currentLastIndex - currentPrevIndex >= 0) { setTimeout(() => { this.handleFragmentRender(index) }, 500) } } }) [代码] 五. 索引列表性能优化最终实现 经过上面的进阶优化手段,现在的索引列表在开始展示当然索引区域的数据之前,会有一个 分片渲染 的效果,先会触发列表数据的 [代码]Loading[代码] 基本展示,然后再按固定的数量上下分别一起开始加载,这样就能避免当前区域列表中一次性加载 [代码]N[代码] 次的图片导致用户在当前区域的图片加载速度被其他看不到区域的图片给占了 经过再次分析,虽然按照用户的使用习惯来说,通常是不会预览太多的分组区域,更不会把所有的分组区域的数据都看完,但是作为开发者还是要考虑一下这个问题:当前页面的数据存在的节点数量过多导致性能卡顿 目前通过我的分析之后,总结了以下思路: 每次滚动时把其他没有在屏幕展示的分组区域的列表数据清空 每次在数据清空时要注意要清空的区域列表是否在进行分页渲染,如果有则需要把分页渲染停止掉 5.1 给每个索引分组加上一个记录 [代码]setTimeout[代码] 的属性 当 [代码]timer[代码] 的属性存在时,说明该索引分组正在进行分页渲染,分页渲染完毕时需要给该属性设置为 [代码]0[代码]: [代码]Page({ /** * 初始化索引列表分组数据 */ initIndexListGroupData() { const list: IIndexListGroup[] = indexListKeys.map((key, index) => ({ key, index, height: `${indexListGroups[key].length * INDEX_LIST_ITEM_HEIGHT}rpx`, prevIndex: 0, lastIndex: indexListGroups[key].length - 1, timer: 0, // 增加一个 timer 字段记录该分组的延时器 list: [], })) this.setData({ indexListGroups: list, }) wx.nextTick(() => { this.initChangeIndexListGroupsRender() this.initIndexListGroupsRect() }) }, /** * 分片渲染 */ handleFragmentRender(index: number) { // ... // 判断是否还能继续分片,如果有采用异步递归 if (currentLastIndex - currentPrevIndex >= 0) { this.data.indexListGroups[index].timer = setTimeout(() => { this.handleFragmentRender(index) }, 500) } } }) [代码] 5.2 每次滚动时把没有在区域渲染的索引分组数据清空 索引列表是可以上下滚动的,往上面滚动时可能会看到上面分组的最底部的列表数据同时会触发上面分组的标题固定定位;往下面滚动时可以看到下面分组的最顶部的列表数据,所以根据索引列表的特性来分析出来我们要对当前索引分组的上下其他分组制定不同的清空时机: 上面分组列表:上面分组列表是肯定见不到的,所以直接对当前索引分组的上面所有分组列表直接清空即可 下面分组列表:从当前所在的索引分组区域开始往下判断是下面分组列表否在滚动区域(这是因为下面可能会展示多个索引分组区域) 这里还有两个细节处理: 删除列表数据时一定要判断当前索引分组是否在进行分页渲染,否则会出现触发删除后分页渲染还会在继续渲染的 [代码]Bug[代码] 删除列表数据是在监听滚动事件时候执行的,所以一定要做一个防抖处理,否则太过于频繁也会造成不必要的回收影响体验 [代码]Page({ /** * 监听滚动 */ changeScroll(e: WechatMiniprogram.ScrollViewScroll) { const select = this.data.select const scrollTop = e.detail.scrollTop const _indexListRects = this.data._indexListRects const active = _indexListRects.findIndex((item) => Math.floor(scrollTop) - item.top < item.height) // 触发监听分组列表数据清空 this.changeIndexListGroupsClear(this, scrollTop, active) if (select === active) { return } this.setData({ select: active }) }, /** * 监听滚动时 */ changeIndexListGroupsClear: debounce((_this: any, scrollTop: number, index: number) => { const windowHeight = wx.getSystemInfoSync().windowHeight const _indexListRects: BoundingClientRectCallbackResult[] = _this.data._indexListRects const groupList: IIndexListGroup[] = _this.data.indexListGroups const dataObj: Record<string, any> = {} for (let i = 0; i < groupList.length; i++) { const { key, timer } = groupList[i] let isView = true if (i > index) { // 判断当前下面的分组是否在可视区域中 isView = Math.floor(scrollTop) > (_indexListRects[i].top - windowHeight) } // 如果是在当前分组上面或在当前分组下面且不在显示视图区域中,则清空列表数据 if (i < index || !isView) { // 如果当前有分片渲染 if (timer) { clearTimeout(timer) dataObj[`indexListGroups[${i}].timer`] = 0 } dataObj[`indexListGroups[${i}].list`] = [] dataObj[`indexListGroups[${i}].prevIndex`] = 0 dataObj[`indexListGroups[${i}].lastIndex`] = indexListGroups[key].length - 1 } } _this.setData(dataObj) }) }) [代码] 六. 总结 经过上面三个大步骤的实现之后,索引列表的性能优化已经完全实现,而优化后的效果在真实的测试中出现很明显的表现出来,接下来总结一下整体流程: 获取每个索引分组区域的高度,把高度先撑起来实现布局的稳定性 高度撑起之后开始监听当前滚动到哪个分组区域,然后进行分组列表的 初始化渲染 在 初始化渲染 完毕之后,触发当前索引分组的 分片渲染,防止一次性渲染多个图片互抢 [代码]http[代码] 网络资源,导致用户优先要看的数据图片第一时间展示不出来 在渲染太多列表数据后,然后找好时机回收不可见区域的列表数据:当前分组上面看不到的所有分组列表数据可直接清空,当前分组下面的需要判断是否展示在视图里面然后再去清空指定的分组列表数据 七. 最后 本文章以 [代码]Demo[代码] 形式提供本人目前想到的小程序索引列表的优化,这个优化分成三个大步骤,现实的开发场景中可能不需要使用到所有的步骤,这些步骤只是本人能想出来的优化点,帮大家提供小程序索引列表的优化思路 本文章单纯是本人对于小程序索引列表优化开发经验的分享,如果大家有更完善的优化思路务必请在评论区分享出来一起学习分享 该文章的代码形式是以 小程序代码片段 的形式分享,请注意一定要使用自己的手机体验一番,不要用微信开发工具在电脑上体验,因为电脑的性能肯定是比手机好的,如果你同样像我使用 [代码]2000[代码] 块不到的手机去尝试 优化前 和 优化后 的效果,你肯定很明显就能看出来 github:https://github.com/qq31311137/van-wechat-index-list 代码片段:https://developers.weixin.qq.com/s/G9Axg3m77hIu 微信工具版本:[代码]1.02.1812180^[代码](太低的版本无法导入) 基础库:[代码]2.31.0[代码]
2023-05-09 - wasm表格使用
wasm表格使用源码 wasm源码 定义表格,包含2个函数,元素从0开始,定义函数类型,然后导出调用表格的函数,通过表格索引去调用具体函数。函数1返回42,函数2返回13。 [代码](module (table 2 funcref) (func $f1 (result i32) i32.const 42) (func $f2 (result i32) i32.const 13) (elem (i32.const 0) $f1 $f2) (type $return_i32 (func (result i32))) (func (export "callByIndex") (param $i i32) (result i32) local.get $i call_indirect (type $return_i32) ) ) [代码] 小程序调用 定义全局变量 [代码]let inst;[代码] 定义加载wasm函数,并在onload中调用 [代码] loadWasm() { var importObj = { }; WXWebAssembly.instantiate("/wasm/wasm-table.wasm", importObj).then( (result) => { console.log("初始化成功"); inst = result.instance.exports; }, (err) => { console.log("初始化失败", err); } ); }, [代码] 调用wasm的函数,这里写了两个按钮,分别调用不同函数。 [代码] callWasm2() { let i = inst.callByIndex(1); console.log(i); // 返回 13 wx.showToast({ title: ''+i, }) }, callWasm1() { let i = inst.callByIndex(0); console.log(i); // 返回 42 wx.showToast({ title: ''+i, }) }, [代码] 这样就可以了,可以在小程序五位字节码查看效果,有任何问题,欢迎提问讨论。
2022-09-04 - WXWebAssembly.instantiate方法的第一个参数,如何通过远程下载本地实例化?
根据报错提示,第一个参数需要传入一个绝对路径的.wasm文件,如pages/index/hello.wasm 如果需要将远程的wasm下载到本地再实例化应该如何实现呢,报错明确提示了,不能以wxfile://或http的文件路径开头,所有还有其他读取存储后文件绝对路径的且获取出来不为wxfile:前缀的绝对路径,api可以调用吗? wasm资源文件超出了2m,希望可以走远程下载到本地再实例化 const info = {}; const cloudWasm = 'https://cdn.com/hello.wasm' wx.downloadFile({ url:cloudWasm, success:res=>{ const {tempFilePath} = res; // 如何将tempFilePath转成绝对路径呢? const fs = wx.getFileSystemManager(); const { env:{ USER_DATA_PATH } } = wx; // 当前用户设备的环境目录 const wasmfilename = tempFilePath.slice(tempFilePath.lastIndexOf('/') + 1);// 提取文件名和后缀 fs.saveFileSync(tempFilePath, wasmdir + wasmfilename); const wasmdir = USER_DATA_PATH + "/wasm/"; // 在当前用户环境下新建一个目录 const obsoluteWasmFilePath = `${tempFilePath}/${wasmfilename}`; WXWebAssembly.instantiate(obsoluteWasmFilePath,info).then(suc=>{ console.log('初始化成功') },err=>{ console.log('初始化失败') }) } } )
2021-04-07 - 小程序页面加水印,防止用户截图分享隐私数据
为了防止用户将小程序内的隐私数据进行截图或者录屏分享导致信息泄露,我们会在小程序全局添加一个水印浮层。这样即使被截图或者拍照,也能轻松地确定泄露的源头。 小程序防止用户截屏的方法有很多,其中一种常见的方法就是在小程序的页面中添加水印。具体实现方法如下: 在小程序中的页面中添加水印浮层,一般通过绝对定位来实现,这样可以使水印在页面的最上层,无法被其他元素覆盖。设计水印的样式和位置,通常可以在小程序的样式文件中设置,例如设置水印的位置为右下角,样式为半透明的字体,以免影响正常的内容展示。对于不同类型的页面,可以根据需要添加不同的水印,例如在敏感信息页面添加比较醒目的水印,而在其他普通页面只添加轻微的水印。如果需要防止用户截屏或者拍照,可以在小程序中添加截屏监听事件,并在用户进行截屏或者拍照操作时,自动添加水印。 总的来说,小程序防止用户截屏的方法还有很多,例如使用安全键盘等,但是添加水印仍然是最为常见的一种方法。通过添加水印,可以有效地防止用户截屏和分享敏感信息,保护用户和单位的信息安全。 下面是一个简单的示例,position选择固定定位fixed,固定定位会固定在浏览器窗口某个位置,不会随滚动条滚动。用z-index将元素的层级设置为最低,将view旋转45度,效果就出来啦 [图片] <view style="position: fixed;top: -10vh; left:-100vw;width: 250vw; height: 100vh; z-index: -999;transform: rotate(-45deg);"> <block wx:for="{{30}}" wx:key="index" wx:if="{{userInfo}}"> <view style="color:gray; margin:30rpx; padding:20rpx; opacity: 0.15;"> {{userInfo.user_name + ' ' + userInfo.user_phone}} {{userInfo.user_name + ' ' + userInfo.user_phone}} {{userInfo.user_name + ' ' + userInfo.user_phone}} {{userInfo.user_name + ' ' + userInfo.user_phone}} </view> </block> </view>
2023-02-23 - 小程序一次性订阅消息详解
简介 相对来说小程序发送通知用得更多的还是一次性订阅模版,发现社区挺多同学对一次性订阅的一些细节设定存在不少疑问。所以这里整理了一些常见的问题。 订阅框长这样,本文也围绕这个弹窗展开: [图片] 常见问题Q&A 1.获取模版ID 1.1 如何获取到合适的模版ID? 答:进入MP后台,https://mp.weixin.qq.com, 可以在【功能】->【订阅消息】->【公共模板库】选择合适的模版。选择后在【我的模版】 查看到模版ID。 注意: 不同小程序,选用同一个模版,生成的模版ID是不一样的。 [图片] 1.2 如何申请新的模版? 答:在MP后台申请,入口比较隐蔽。如要在【公共模版库】搜索任意模版,然后翻到最后一页,会出现【帮助我们完善模板库】的选项,点击进入申请新模版。比如此处我搜索了签到 [图片] 2.订阅次数 2.1 勾选了【总是保持以上选择,不再询问】,是不是就可以多次向发送通知了? 答:不能,用户没有再次点击触发订阅的按钮就不会增加订阅次数。勾选之后只是触发订阅时不再弹窗,保持上一次用户的选项进行订阅接口的调用。 2.2 用户一天内多次点击订阅后,是不是就能发送多条通知? 答: 是的,用户一天内点了N次订阅,就可以向用户发送多条通知。 2.3 用户点击订阅后,此次发通知机会的有效期是多久? 答: 永久有效。 2.4 勾选部分模版,再选择勾选 【总是保持以上选择,不再询问】,点击【确定】后,下次订阅哪几个模版的订阅次数会增加? [图片] 答: 如上图,只勾选了AB,然后保持选项点击【确定】,下次订阅时只有A,B模版的订阅次数会增加。 2.5 勾选部分模版,再选择勾选 【总是保持以上选择,不再询问】,点击【取消】后,下次订阅哪几个模版的订阅次数会增加? 答: 如果选择了【取消】+ 【保持选项】,所有订阅消息次数都不会再增加。如上图,不管是否勾选,A、B、C三个模版次数都不会增加。 2.6 勾选【总是保持以上选择,不再询问】选项后,如何修改选择? 答: 可以在小程序设置页面,配置是否接收模版消息。(小程序右上角三点按钮,可以唤起进入设置页面的面板) [图片] 2.7 为什么我设置的通知页面没有显示MP后台配置的通知模版? 答:只有勾选过【总是保持以上选择,不再询问】的模版消息才会进入这个配置页面。 2.8 关闭接口通知按钮后(上图红框内按钮),再开启,订阅次数怎么计算? 答: 关闭按钮后,所有通知都无法接收,订阅次数全部清零。所以重新开启后,也需要用户重新订阅才能再次发送通知。 3.弹窗相关 3.1 模版ABC已经勾选【保持选项】,在另一处同时订阅ADF,此时还会弹窗吗? 答: 会,但是弹窗中只会有两个模版DF,模版A不显示。 3.2 开发者勾选【总是保持以上选择,不再询问】后,怎么重新唤起弹窗? 答:微信开发者工具,清除全部缓存。重新生成二维码,测试机扫码后即可重新唤起弹窗。 Tips:登陆开发者工具的微信账号和需要重置弹窗的微信账号需要保持一致。
09-04 - PHP项目微信提现功能
第一步:使用composer安装EasyWeChat https://www.easywechat.com/docs/3.x/installation 第二步:在前面我们已经讲过,初始化 SDK 的时候方法就是创建一个 EasyWeChat\Foundation\Application 实例: use EasyWeChat\Foundation\Application; $options = [ // ...]; $app = new Application($options); /** * 如果想要在Application实例化完成之后, 修改某一个options的值, * 比如服务商+子商户支付回调场景, 所有子商户订单支付信息都是通过同一个服务商的$option 配置进来的, * 当oauth在微信端验证完成之后, 可以通过动态设置merchant_id来区分具体是哪个子商户 */$app['config']->set('oauth.callback','wechat/oauthcallback/'. $sub_merchant_id->id); 那么配置的具体选项有哪些,下面是一个完整的列表: <?php return [ /** * 账号基本信息,请从微信公众平台/开放平台获取 */ 'app_id' => 'your-app-id', // AppID 'secret' => 'your-app-secret', // AppSecret 'token' => 'your-token', // Token 'aes_key' => '', // EncodingAESKey,安全模式与兼容模式下请一定要填写!!! /** * OAuth 配置 * * scopes:公众平台(snsapi_userinfo / snsapi_base),开放平台:snsapi_login * callback:OAuth授权完成后的回调页地址 */ 'oauth' => [ 'scopes' => ['snsapi_userinfo'], 'callback' => '/examples/oauth_callback.php', ], /** * 微信支付 */ 'payment' => [ 'merchant_id' => 'your-mch-id', 'key' => 'key-for-signature', 'cert_path' => 'path/to/your/cert.pem', // XXX: 绝对路径!!!! 'key_path' => 'path/to/your/key', // XXX: 绝对路径!!!! ], * 更多请参考: http://docs.guzzlephp.org/en/latest/request-options.html */ 'guzzle' => [ 'timeout' => 3.0, // 超时时间(秒) ],]; 第三步:使用EasyWeChat完成提现到零钱功能(企业付款到零钱) 你在阅读本文之前确认你已经仔细阅读了:微信支付 | 企业付款文档 。 与其他支付接口一样,企业支付接口也需要配置如下参数,需要特别注意的是,企业支付相关的全部接口 都需要使用 SSL 证书,因此 cert_path 以及 cert_key 必须正确配置。 <?php use EasyWeChat\Foundation\Application; $options = [ 'app_id' => 'your-app-id', // payment 'payment' => [ 'merchant_id' => 'your-mch-id', 'key' => 'key-for-signature', 'cert_path' => 'path/to/your/cert.pem', 'key_path' => 'path/to/your/key', // ... ],]; $app = new Application($options); $merchantPay = $app->merchant_pay; 企业付款 企业付款使用的余额跟微信支付的收款并非同一账户,请注意充值。 <?php $merchantPayData = [ 'partner_trade_no' => str_random(16), //随机字符串作为订单号,跟红包和支付一个概念。 'openid' => $openid, //收款人的openid 'check_name' => 'NO_CHECK', //文档中有三种校验实名的方法 NO_CHECK OPTION_CHECK FORCE_CHECK 're_user_name'=>'张三', //OPTION_CHECK FORCE_CHECK 校验实名的时候必须提交 'amount' => 100, //单位为分 'desc' => '企业付款', 'spbill_create_ip' => '192.168.0.1', //发起交易的IP地址 ]; $result = $merchantPay->send($merchantPayData); 查询付款信息 用于商户对已发放的企业支付进行查询企业支付的具体信息。 $partnerTradeNo = "商户系统内部的订单号(partner_trade_no)"; $merchantPay->query($partnerTradeNo); 这里以crmeb知识付费系统为例的微信功能基本类extend\service\WechatService;按照文档说明我们在WechatService类中添加企业付款的接口,接口名称为merchantPayService,若是存在就不需要添加; /** * 企业付款 * @return \EasyWeChat\Material\Material */ public static function merchantPayService() { return self::application()->merchant_pay; } 那么我们所需要的就是调用merchantPayService方法里的send方法,并且传入我们转账的参数即可。 我们需要把功能加到提现审核通过之后执行。admin\model\user\UserExtract;找到这个类中的changeSuccess方法,这个方法是审核通过后的相关操作。 (1).先引入WechatService; use service\WechatService; (2).changeSuccess方法里加下面代码,并且根据最后的返回判断企业付款是否成功。 $payData = [ 'partner_trade_no' => str_random(16), //随机字符串作为订单号,跟红包和支付一个概念。 'openid' => $openid, //收款人的openid 'check_name' => 'NO_CHECK', //文档中有三种校验实名的方法 NO_CHECK OPTION_CHECK FORCE_CHECK 're_user_name'=>'张三', //OPTION_CHECK FORCE_CHECK 校验实名的时候必须提交 'amount' => 100, //单位为分 'desc' => '企业付款', 'spbill_create_ip' => '192.168.0.1', //发起交易的IP地址 ]; $result=WechatService::merchantPayService()->send($payData);
2022-10-24 - 小程序安全检测上线公告
为进一步提升小程序的安全性和用户体验,目前平台将对提审的小程序进行安全检测,以便能及时帮助开发者发现小程序可能存在的安全漏洞。 一、背景介绍 小程序在开发过程中若存在安全漏洞的情况,如敏感数据篡改、拖库信息泄露、WEB攻击等,容易造成小程序的安全隐患,可能带来代码易被反编译、核心业务逻辑被破译、算法易被二次打包等风险。因此,平台将对提审的小程序进行安全检测,以协助开发者提升小程序服务的安全性,同时开发者也应加强自身小程序安全漏洞监测能力,保证可及时消除潜在的安全风险。 二、审核过程 安全检测过程中,平台会模拟真实业务场景,向提审小程序的后台发送服务请求,服务器会收到来自平台(显示为:Tencent Security Team,请求IP为106.55.202.118;113.96.223.69;125.39.132.125;43.139.209.119)的请求。该请求均以较低速率进行,正常情况下不会影响小程序的正常服务。若确实出现了影响小程序正常业务的特殊情况,如用户无法进行小程序的正常访问,开发者可基于自身业务情况,对相应请求加以限频,如有其他疑问,欢迎随时通过官方社区进行反馈。 三、审核结果 安全检测的结果是小程序审核的重要参考。若小程序在安全检测中被检测到存在安全漏洞,该小程序的审核将不予通过。开发者可根据扫描报告中的修改指引,对安全漏洞进行相应修复后,再重新进行提审。 其他常见问题 Q1:可以选择不进行安全检测吗,是否会影响小程序代码提审结果? A1:安全检测是小程序审核的环节之一,所有提审的小程序均需进行,若检测中发现安全漏洞或小程序故意采取措施规避检测,该小程序的审核将不予通过。 Q2:若在小程序代码审核已结束或审核已撤销的情况下,可以停止安全检测吗? A2:若在小程序代码审核已结束的情况下,平台将持续进行未完成的安全检测直至完成,如有需要,开发者可通过平台提供的相应链接(在【小程序管理后台 → 通知中心】查看站内信即可)自行中止安全检测;若在小程序审核已撤销的情况下,平台将自动中止未完成的安全检测。 附表:安全检测内容详情 [图片]
03-08 - 微信云开发计费调整公告
各位微信云开发用户: 感谢大家一直以来对微信云开发的支持。由于云计算成本整体上升,为了继续为各位用户提供稳定可靠的服务,微信云开发将于 2022 年 08 月 18 日,对计费方式进行如下变更,部分指标价格将有所上浮。 新计费模式下,新用户免费使用 1 个月后,统一使用 “基础套餐+按量付费” 模式:购买带有一定配额的基础套餐后,超出套餐配额部分再按照实际使用量付费。 基础套餐 [图片] *新用户首月免费配额:与基础套餐一致,可满足大部分情况下的开发体验需求 *5 折折扣有效期至少延续至 2022 年底,后续折扣如有变化将另行通知 按量付费 [图片] 【指标说明】 1、调用次数:合并原多项指标统一计价,具体为 “云存储上传操作”、“云存储下载操作”;“数据库读操作”、“数据库写操作” 及新增指标 “云函数调用操作”; 2、容量:合并原多项指标统一计价,具体为 “存储空间" 及 "数据库容量"; 3、云函数日志服务:本次新增计费指标,建议通过优化日志存储策略降低该指标费用。 新用户手动触发确认开始使用后,将拥有 1 个月免费权益,体验期间的环境配额与基础套餐相同,可选择是否进行超额按量付费。 现有用户可以在「微信开发者工具-云开发控制台」或「微信云服务助手小程序」查询过往用量情况后,使用 价格计算器 预估新价格。 在计费方式升级生效之日(2022.08.08)起,对现有用户均提供至少 1 个月的操作缓冲期,期间会推送提醒及提供切换入口,用户可自由选择是否切换新的计费方式,超时未切换的云开发环境将会停服释放。缓冲期内切换成功的用户将会获得额外代金券。 对于当前月调用次数 >100 万次的帐号,本次调整将不直接生效,后续将由专业架构师联系或独立推送指引切换到合适配额的企业旗舰版本。 自本公告发布之日起,对于原已购预付费套餐、资源包的用户,仍然可正常使用原已购资源,直至原约定套餐到期或用完。现有旧预付费套餐不再支持续费超过一个月,现有旧资源包不再支持新购。 微信云开发将继续为各位用户提供简单易用安全的专业 Serverless 开发服务,感谢大家的支持。 Q&A 1、此次调整,意味着我的费用会增加吗? 是的,由于云计算整体成本的持续上升,为了能够长期给各位开发者提供低门槛的开发服务,我们对于部分计费指标进行了价格调整,在调整后大部分的开发者费用会一定程度的上浮。 2、我如何预估调整之后的费用是多少呢? 新计费生效后,在缓冲期内,开发者可查看新指标下的用量情况,或者根据过往用量,使用 价格计算器 估算调整之后的费用。 我们根据测算,为大部分开发者提供基础套餐配额以满足线上服务,如你的实际用量没有超过基础配额,也没有使用扩展功能,那么每月消费即为 19.9 元。 3、我需要在哪里操作切换新计费呢? [图片] 微信云服务助手小程序码 在计费方式升级生效之日(2022.08.08)起,开发者可前往「微信开发者工具-云开发控制台」或「微信云服务助手小程序」进行操作切换。按照过往的计费信息下发惯例,我们通过「微信公众平台公众号」向管理员推送计费变更信息与切换入口。 4、现在的环境是否可以继续使用呢? 新计费生效后,已购买的预付费套餐可正常使用至原到期时间,已购买资源包的按量付费环境可正常使用至原资源包到期时间,均不会存在直接失效或自动退费的情况。在切换到新计费方案后,原有环境即可持续正常使用。 5、如果超时切换会有什么影响呢? 若原环境到期且超出切换缓冲时间,则该环境将进入停服释放流程,数据不可找回,请开发者及时操作。 6、以上新计费方案生效后,我有多长时间可以决定是否继续使用云开发呢? 对于当前月调用次数 <100 万次的帐号,从公告发布日至新方案生效后的一个月,期间可进行是否继续使用的决策;计费方式升级生效之日(2022.08.08)起,我们也会通过上述 Q&A 3 说明的方式中提供切换入口与下发消息,此时你仍然有至少 1 个月的缓冲期做出决定。对于当前月调用次数 >100 万次的帐号,将有专业架构师联系指引切换到合适配额的企业旗舰版本,期间产生的消耗仍然按旧方案计费。我们会为大额消耗用户提供适当优惠,同时对于不希望继续使用云开发的此类帐号提供不低于 3 个月的缓冲时间。如有其他问题,可前往「微信开发者工具-云开发控制台-帮助-工单」主动联系我们。 7、我自行测算预估后觉得新方案有点贵,有什么好建议吗? 本次调整,价格上涨指标为 “调用次数”、“容量”和“CDN 流量”,建议相应进行技术优化,减少不必要的多次调用或存储内容,可有效控制费用;此外新增计费指标为日志服务,可在「微信开发者工具-云开发控制台-云函数-日志」中查看日志使用情况,并优化日志使用逻辑,避免上报大量冗余的日志信息导致不必要的费用支出。 若评估后认为新的费用方案已不适用于你的业务,可在上述充足的缓冲期内自行选择迁移业务至更合适的服务。 微信云开发团队 2022年7月4日
2022-08-08 - 安全风控 接口一直返回61010
https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/safety-control-capability/riskControl.getUserRiskRank.html 接口一直返回 {"errcode":61010,"errmsg":"code is expired rid: 61a5977a-1f84f61d-0e3abfbb"} 已经让前端登录过小程序,也授权了。
2021-11-30 - PHP微信支付类V3接口
不知不觉微信支付也更新了,接口版本也升级到了V3, 跟着微信的升级,将个人使用微信支付类也进行了升级, V3微信支付文档:https://pay.weixin.qq.com/wiki/doc/apiv3/index.shtml (相关方法已经过测试,不保证完全没问题,仅供参考,如测试有问题,可联系作者修改----查看原文) 使用方法还和之前的一样(V2微信支付),直接传递参数就可使用: 新版新增了[代码]composer[代码]安装,便于集成框架使用(Github地址): [代码]composer require fengkui/pay [代码] 首先把配置文件填写完整(细心不要填错,否则会导致签名错误): [代码]# 微信支付配置 $wechatConfig = [ 'xcxid' => '', // 小程序 appid 'appid' => '', // 微信支付 appid 'mchid' => '', // 微信支付 mch_id 商户收款账号 'key' => '', // 微信支付 apiV3key(尽量包含大小写字母,否则验签不通过) 'appsecret' => '', // 公众帐号 secert (公众号支付获取 code 和 openid 使用) 'notify_url' => '', // 接收支付状态的连接 改成自己的回调地址 'redirect_url' => '', // 公众号支付,调起支付页面 'serial_no' => '', // 证书序列号 'cert_client' => './cert/apiclient_cert.pem', // 证书(退款,红包时使用) 'cert_key' => './cert/apiclient_key.pem', // 商户私钥(Api安全中下载) 'public_key' => './cert/public_key.pem', // 平台公钥(调动证书列表,自动生成,注意目录读写权限) ]; [代码] 支付类封装相关方法: method 描述 js JSAPI下单 app APP支付 h5 H5支付 scan Navicat支付 xcx 小程序支付 query 查询订单 close 关闭订单 refund 申请退款 notify 支付结果通知 使用方法(这里已小程序支付为示例): [代码]<?php require_once('./vendor/autoload.php'); $config = []; // 支付配置 $order = [ 'order_sn' => time(), // 订单编号 'total_amount' => 1, // 订单金额(分) 'body' => '测试商品', // 商品名称 'openid' => '', // 用户openid // 'type' => 'Wap', ]; $wechat = new fengkui\Pay\Wechat($config); $re = $wechat->xcx($order); die(json_encode($re)); // JSON化直接返回小程序客户端 [代码] 如下代码是封装好的完整支付类文件(Wechat.php), 可以根据自己需求随意修改,详细的使用方法后期会有文档: [代码]<?php /** * @Author: [FENG] <1161634940@qq.com> * @Date: 2019-09-06 09:50:30 * @Last Modified by: [FENG] <1161634940@qq.com> * @Last Modified time: 2021-07-12 18:24:18 */ namespace fengkui\Pay; use Exception; use RuntimeException; use fengkui\Supports\Http; /** * Wechat 微信支付 * 新版(V3)接口(更新中) */ class Wechat { const AUTH_TAG_LENGTH_BYTE = 16; // 新版相关接口 // GET 获取平台证书列表 private static $certificatesUrl = 'https://api.mch.weixin.qq.com/v3/certificates'; // 统一下订单管理 private static $transactionsUrl = 'https://api.mch.weixin.qq.com/v3/pay/transactions/'; // 申请退款 private static $refundUrl = 'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds'; // 静默授权,获取code private static $authorizeUrl = 'https://open.weixin.qq.com/connect/oauth2/authorize'; // 通过code获取access_token以及openid private static $accessTokenUrl = 'https://api.weixin.qq.com/sns/oauth2/access_token'; // 支付完整配置 private static $config = array( 'xcxid' => '', // 小程序 appid 'appid' => '', // 微信支付 appid 'mchid' => '', // 微信支付 mch_id 商户收款账号 'key' => '', // 微信支付 apiV3key(尽量包含大小写字母,否则验签不通过) 'appsecret' => '', // 公众帐号 secert (公众号支付获取 code 和 openid 使用) 'notify_url' => '', // 接收支付状态的连接 改成自己的回调地址 'redirect_url' => '', // 公众号支付,调起支付页面 'serial_no' => '', // 证书序列号 'cert_client' => './cert/apiclient_cert.pem', // 证书(退款,红包时使用) 'cert_key' => './cert/apiclient_key.pem', // 商户私钥(Api安全中下载) 'public_key' => './cert/public_key.pem', // 平台公钥(调动证书列表,自动生成,注意目录读写权限) ); /** * [__construct 构造函数] * @param [type] $config [传递微信支付相关配置] */ public function __construct($config=NULL, $referer=NULL){ $config && self::$config = array_merge(self::$config, $config); } /** * [unifiedOrder 统一下单] * @param [type] $order [订单信息(必须包含支付所需要的参数)] * @param boolean $type [区分是否是小程序,是则传 true] * @return [type] [description] * $order = array( * 'body' => '', // 产品描述 * 'order_sn' => '', // 订单编号 * 'total_amount' => '', // 订单金额(分) * ); */ public static function unifiedOrder($order, $type=false) { $config = array_filter(self::$config); // 获取配置项 $params = array( 'appid' => $type ? $config['xcxid'] : $config['appid'], // 由微信生成的应用ID 'mchid' => $config['mchid'], // 直连商户的商户号 'description' => $order['body'], // 商品描述 'out_trade_no' => (string)$order['order_sn'], // 商户系统内部订单号 'notify_url' => $config['notify_url'], // 通知URL必须为直接可访问的URL 'amount' => ['total' => (int)$order['total_amount'], 'currency' => 'CNY'], // 订单金额信息 ); !empty($order['attach']) && $params['attach'] = $order['attach']; // 附加数据 if (!empty($order['time_expire'])) { // 订单失效时间 preg_match('/[年\/-]/', $order['time_expire']) && $order['time_expire'] = strtotime($order['time_expire']); $time = $order['time_expire'] > time() ? $order['time_expire'] : $order['time_expire'] + time(); $params['time_expire'] = date(DATE_ATOM, $time); } if (!in_array($order['type'], ['native'])) { !empty($order['openid']) && $params['payer'] = ['openid' => $order['openid']]; $params['scene_info'] = ['payer_client_ip' => self::get_ip()]; } if (in_array($order['type'], ['iOS', 'Android', 'Wap'])) { $params['scene_info']['h5_info'] = ['type' => $order['type']]; $url = self::$transactionsUrl . 'h5'; // 拼接请求地址 } else { $url = self::$transactionsUrl . strtolower($order['type']); // 拼接请求地址 } $header = self::createAuthorization($url, $params, 'POST'); $response = Http::post($url, json_encode($params, JSON_UNESCAPED_UNICODE), $header); $result = json_decode($response, true); if (isset($result['code']) && isset($result['message'])) { throw new \Exception("[" . $result['code'] . "] " . $result['message']); } return $result; } /** * [query 查询订单] * @param [type] $orderSn [订单编号] * @param boolean $type [微信支付订单编号,是否是微信支付订单号] * @return [type] [description] */ public static function query($orderSn, $type = false) { $config = self::$config; $url = self::$transactionsUrl . ($type ? 'id/' : 'out-trade-no/') . $orderSn . '?mchid=' . $config['mchid']; $params = ''; $header = self::createAuthorization($url, $params, 'GET'); $response = Http::get($url, $params, $header); $result = json_decode($response, true); return $result; } /** * [close 关闭订单] * @param [type] $orderSn [微信支付订单编号] * @return [type] [description] */ public static function close($orderSn) { $config = self::$config; $url = self::$transactionsUrl . 'out-trade-no/' . $orderSn . '/close'; $params['mchid'] = $config['mchid']; $header = self::createAuthorization($url, $params, 'POST'); $response = Http::post($url, json_encode($params, JSON_UNESCAPED_UNICODE), $header); $result = json_decode($response, true); return true; } /** * [js 获取jssdk需要用到的数据] * @param [type] $order [订单信息数组] * @return [type] [description] */ public static function js($order=[]){ $config = self::$config; if (!is_array($order) || count($order) < 3) die("订单数组信息缺失!"); if (count($order) == 4 && !empty($order['openid'])) { $data = self::xcx($order, false, false); // 获取支付相关信息(获取非小程序信息) return $data; } $code = !empty($order['code']) ? $order['code'] : ($_GET['code'] ?? ''); $redirectUri = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST'] . rtrim($_SERVER['REQUEST_URI'], '/') . '/'; // 重定向地址 $params = ['appid' => $config['appid']]; // 如果没有get参数没有code;则重定向去获取code; if (empty($code)) { $params['redirect_uri'] = $redirectUri; // 返回的url $params['response_type'] = 'code'; $params['scope'] = 'snsapi_base'; $params['state'] = $order['order_sn']; // 获取订单号 $url = self::$authorizeUrl . '?'. http_build_query($params) .'#wechat_redirect'; } else { $params['secret'] = $config['appsecret']; $params['code'] = $code; $params['grant_type'] = 'authorization_code'; $response = Http::get(self::$accessTokenUrl, $params); // 进行GET请求 $result = json_decode($response, true); $order['openid'] = $result['openid']; // 获取到的openid $data = self::xcx($order, false, false); // 获取支付相关信息(获取非小程序信息) if (!empty($order['code'])) { return $data; } $url = $config['redirect_url'] ?? $redirectUri; $url .= '?data=' . json_encode($data, JSON_UNESCAPED_UNICODE); } header('Location: '. $url); die; } /** * [app 获取APP支付需要用到的数据] * @param [type] $order [订单信息数组] * @return [type] [description] */ public static function app($order=[], $log=false) { if(empty($order['order_sn']) || empty($order['total_amount']) || empty($order['body'])){ die("订单数组信息缺失!"); } $order['type'] = 'app'; // 获取订单类型,用户拼接请求地址 $result = self::unifiedOrder($order, true); if (!empty($result['prepay_id'])) { $data = array ( 'appId' => self::$config['appid'], // 微信开放平台审核通过的移动应用appid 'timeStamp' => (string)time(), 'nonceStr' => self::get_rand_str(32, 0, 1), // 随机32位字符串 'prepayid' => $result['prepay_id'], ); $data['paySign'] = self::makeSign($data); $data['partnerid'] = $config['mchid']; $data['package'] = 'Sign=WXPay'; return $data; // 数据小程序客户端 } else { return $log ? $result : false; } } /** * [h5 微信H5支付] * @param [type] $order [订单信息数组] * @return [type] [description] */ public static function h5($order=[], $log=false) { if(empty($order['order_sn']) || empty($order['total_amount']) || empty($order['body']) || empty($order['type']) || !in_array(strtolower($order['type']), ['ios', 'android', 'wap'])){ die("订单数组信息缺失!"); } $result = self::unifiedOrder($order); if (!empty($result['h5_url'])) { return $result['h5_url']; // 返回链接让用户点击跳转 } else { return $log ? $result : false; } } /** * [xcx 获取jssdk需要用到的数据] * @param [type] $order [订单信息数组] * @param boolean $log [description] * @param boolean $type [区分是否是小程序,默认 true] * @return [type] [description] */ public static function xcx($order=[], $log=false, $type=true) { if(empty($order['order_sn']) || empty($order['total_amount']) || empty($order['body']) || empty($order['openid'])){ die("订单数组信息缺失!"); } $order['type'] = 'jsapi'; // 获取订单类型,用户拼接请求地址 $config = self::$config; $result = self::unifiedOrder($order, $type); if (!empty($result['prepay_id'])) { $data = array ( 'appId' => $type ? $config['xcxid'] : $config['appid'], // 由微信生成的应用ID 'timeStamp' => (string)time(), 'nonceStr' => self::get_rand_str(32, 0, 1), // 随机32位字符串 'package' => 'prepay_id='.$result['prepay_id'], ); $data['paySign'] = self::makeSign($data); $data['signType'] = 'RSA'; return $data; // 数据小程序客户端 } else { return $log ? $result : false; } } /** * [scan 微信扫码支付] * @param [type] $order [订单信息数组] * @return [type] [description] */ public static function scan($order=[], $log=false) { if(empty($order['order_sn']) || empty($order['total_amount']) || empty($order['body'])){ die("订单数组信息缺失!"); } $order['type'] = 'native'; // Native支付 $result = self::unifiedOrder($order); if (!empty($result['code_url'])) { return urldecode($result['code_url']); // 返回链接让用户点击跳转 } else { return $log ? $result : false; } } /** * [notify 回调验证] * @return [array] [返回数组格式的notify数据] */ public static function notify($server = [], $response = []) { $config = self::$config; $server = $server ?? $_SERVER; $response = $response ?: file_get_contents('php://input', 'r'); if (empty($response) || empty($server['HTTP_WECHATPAY_SIGNATURE'])) { return false; } $body = [ 'timestamp' => $server['HTTP_WECHATPAY_TIMESTAMP'], 'nonce' => $server['HTTP_WECHATPAY_NONCE'], 'data' => $response, ]; // 验证应答签名 $verifySign = self::verifySign($body, trim($server['HTTP_WECHATPAY_SIGNATURE']), trim($server['HTTP_WECHATPAY_SERIAL'])); if (!$verifySign) { die("签名验证失败!"); } $result = json_decode($response, true); if (empty($result) || $result['event_type'] != 'TRANSACTION.SUCCESS' || $result['summary'] != '支付成功') { return false; } // 加密信息 $associatedData = $result['resource']['associated_data']; $nonceStr = $result['resource']['nonce']; $ciphertext = $result['resource']['ciphertext']; $data = $result['resource']['ciphertext'] = self::decryptToString($associatedData, $nonceStr, $ciphertext); return json_decode($data, true); } /** * [refund 微信支付退款] * @param [type] $order [订单信息] * @param [type] $type [是否是小程序] */ public static function refund($order) { $config = self::$config; if(empty($order['refund_sn']) || empty($order['refund_amount']) || (empty($order['order_sn']) && empty($order['transaction_id']))){ die("订单数组信息缺失!"); } $params = array( 'out_refund_no' => (string)$order['refund_sn'], // 商户退款单号 'funds_account' => 'AVAILABLE', // 退款资金来源 'amount' => [ 'refund' => $order['refund_amount'], 'currency' => 'CNY', ] ); if (!empty($order['transaction_id'])) { $params['transaction_id'] = $order['transaction_id']; $orderDetail = self::query($order['transaction_id'], true); } else { $params['out_trade_no'] = $order['order_sn']; $orderDetail = self::query($order['order_sn']); } $params['amount']['total'] = $orderDetail['amount']['total']; !empty($order['reason']) && $params['reason'] = $order['reason']; $url = self::$refundUrl; $header = self::createAuthorization($url, $params, 'POST'); $response = Http::post($url, json_encode($params, JSON_UNESCAPED_UNICODE), $header); $result = json_decode($response, true); return $result; } /** * [queryRefund 查询退款] * @param [type] $refundSn [退款单号] * @return [type] [description] */ public static function queryRefund($refundSn, $type = false) { $url = self::$refundUrl . '/' . $refundSn; $params = ''; $header = self::createAuthorization($url, $params, 'GET'); $response = Http::get($url, $params, $header); $result = json_decode($response, true); return $result; } /** * [success 通知支付状态] */ public static function success() { $str = ['code'=>'SUCCESS', 'message'=>'成功']; die(json_encode($str, JSON_UNESCAPED_UNICODE)); } /** * [createAuthorization 获取接口授权header头信息] * @param [type] $url [请求地址] * @param array $data [请求参数] * @param string $method [请求方式] * @return [type] [description] */ //生成v3 Authorization protected static function createAuthorization($url, $data=[], $method='POST'){ $config = self::$config; //商户号 $mchid = $config['mchid']; // 证书序列号 if (empty($config['serial_no'])) { $certFile = @file_get_contents($config['cert_client']); $certArr = openssl_x509_parse($publicStr); $serial_no = $certArr['serialNumberHex']; } else { $serial_no = $config['serial_no']; } // 解析url地址 $url_parts = parse_url($url); //生成签名 $body = [ 'method' => $method, 'url' => ($url_parts['path'] . (!empty($url_parts['query']) ? "?${url_parts['query']}" : "")), 'time' => time(), // 当前时间戳 'nonce' => self::get_rand_str(32, 0, 1), // 随机32位字符串 'data' => (strtolower($method) == 'post' ? json_encode($data, JSON_UNESCAPED_UNICODE) : $data), // POST请求时 需要 转JSON字符串 ]; $sign = self::makeSign($body); //Authorization 类型 $schema = 'WECHATPAY2-SHA256-RSA2048'; //生成token $token = sprintf('mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"', $mchid, $body['nonce'], $body['time'], $serial_no, $sign); $header = [ 'Content-Type:application/json', 'Accept:application/json', 'User-Agent:*/*', 'Authorization: '. $schema . ' ' . $token ]; return $header; } /** * [makeSign 生成签名] * @param [type] $data [加密数据] * @return [type] [description] */ public static function makeSign($data) { $config = self::$config; if (!in_array('sha256WithRSAEncryption', \openssl_get_md_methods(true))) { throw new \RuntimeException("当前PHP环境不支持SHA256withRSA"); } // 拼接生成签名所需的字符串 $message = ''; foreach ($data as $value) { $message .= $value . "\n"; } // 商户私钥 $private_key = self::getPrivateKey($config['cert_key']); // 生成签名 openssl_sign($message, $sign, $private_key, 'sha256WithRSAEncryption'); $sign = base64_encode($sign); return $sign; } /** * [verifySign 验证签名] * @param [type] $data [description] * @param [type] $sign [description] * @param [type] $serial [description] * @return [type] [description] */ public static function verifySign($data, $sign, $serial) { $config = self::$config; if (!in_array('sha256WithRSAEncryption', \openssl_get_md_methods(true))) { throw new \RuntimeException("当前PHP环境不支持SHA256withRSA"); } $sign = \base64_decode($sign); // 拼接生成签名所需的字符串 $message = ''; foreach ($data as $value) { $message .= $value . "\n"; } // 获取证书相关信息 self::certificates($serial); // 平台公钥 $public_key = self::getPublicKey($config['public_key']); //平台公钥 // 验证签名 $recode = \openssl_verify($message, $sign, $public_key, 'sha256WithRSAEncryption'); return $recode == 1 ? true : false; } //获取私钥 public static function getPrivateKey($filepath) { return openssl_pkey_get_private(file_get_contents($filepath)); } //获取公钥 public static function getPublicKey($filepath) { return openssl_pkey_get_public(file_get_contents($filepath)); } /** * [certificates 获取证书] * @return [type] [description] */ public static function certificates($serial) { $config = self::$config; $publicStr = @file_get_contents($config['public_key']); if ($publicStr) { // 判断证书是否存在 $openssl = openssl_x509_parse($publicStr); if ($openssl['serialNumberHex'] == $serial) { // 是否是所需证书 // return self::getPublicKey($config['public_key']); //平台公钥 return ''; } } $url = self::$certificatesUrl; $params = ''; $header = self::createAuthorization($url, $params, 'GET'); $response = Http::get($url, $params, $header); $result = json_decode($response, true); if (empty($result['data'])) { throw new RuntimeException("[" . $result['code'] . "] " . $result['message']); } foreach ($result['data'] as $key => $certificate) { if ($certificate['serial_no'] == $serial) { $publicKey = self::decryptToString( $certificate['encrypt_certificate']['associated_data'], $certificate['encrypt_certificate']['nonce'], $certificate['encrypt_certificate']['ciphertext'] ); file_put_contents($config['public_key'], $publicKey); break; // 终止循环 } // self::$publicKey[$certificate['serial_no']] = $publicKey; } // return self::getPublicKey($config['public_key']); //平台公钥 } /** * [decryptToString 证书和回调报文解密] * @param [type] $associatedData [附加数据包(可能为空)] * @param [type] $nonceStr [加密使用的随机串初始化向量] * @param [type] $ciphertext [Base64编码后的密文] * @return [type] [description] */ public static function decryptToString($associatedData, $nonceStr, $ciphertext) { $config = self::$config; $ciphertext = base64_decode($ciphertext); if (strlen($ciphertext) <= self::AUTH_TAG_LENGTH_BYTE) { return false; } // ext-sodium (default installed on >= PHP 7.2) if (function_exists('\sodium_crypto_aead_aes256gcm_is_available') && \sodium_crypto_aead_aes256gcm_is_available()) { return \sodium_crypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $config['key']); } // ext-libsodium (need install libsodium-php 1.x via pecl) if (function_exists('\Sodium\crypto_aead_aes256gcm_is_available') && \Sodium\crypto_aead_aes256gcm_is_available()) { return \Sodium\crypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $config['key']); } // openssl (PHP >= 7.1 support AEAD) if (PHP_VERSION_ID >= 70100 && in_array('aes-256-gcm', \openssl_get_cipher_methods())) { $ctext = substr($ciphertext, 0, -self::AUTH_TAG_LENGTH_BYTE); $authTag = substr($ciphertext, -self::AUTH_TAG_LENGTH_BYTE); return \openssl_decrypt($ctext, 'aes-256-gcm', $config['key'], \OPENSSL_RAW_DATA, $nonceStr, $authTag, $associatedData); } throw new \RuntimeException('AEAD_AES_256_GCM需要PHP 7.1以上或者安装libsodium-php'); } /** fengkui.net * [get_rand_str 获取随机字符串] * @param integer $randLength [长度] * @param integer $addtime [是否加入当前时间戳] * @param integer $includenumber [是否包含数字] * @return [type] [description] */ public static function get_rand_str($randLength=6, $addtime=0, $includenumber=1) { if ($includenumber) $chars='abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789'; $chars='abcdefghijklmnopqrstuvwxyz'; $len = strlen($chars); $randStr = ''; for ($i=0; $i<$randLength; $i++){ $randStr .= $chars[rand(0, $len-1)]; } $tokenvalue = $randStr; $addtime && $tokenvalue = $randStr . time(); return $tokenvalue; } /** fengkui.net * [get_ip 定义一个函数get_ip() 客户端IP] * @return [type] [description] */ public static function get_ip() { if (getenv("HTTP_CLIENT_IP")) $ip = getenv("HTTP_CLIENT_IP"); else if(getenv("HTTP_X_FORWARDED_FOR")) $ip = getenv("HTTP_X_FORWARDED_FOR"); else if(getenv("REMOTE_ADDR")) $ip = getenv("REMOTE_ADDR"); else $ip = "Unknow"; if(preg_match('/^((?:(?:25[0-5]|2[0-4]\d|((1\d{2})|([1-9]?\d)))\.){3}(?:25[0-5]|2[0-4]\d|((1\d{2})|([1 -9]?\d))))$/', $ip)) return $ip; else return ''; } } [代码] 本文参考文档: 1、微信支付 小程序 (v3)- PHP 完整后端代码 2、PHP 微信小程序 微信支付 v3 3、微信支付V3版本小程序支付 php签名,验签,数据解密代码分享(完整方法主参考) 4、微信支付 API v3 回调通知 签名验证 PHPdemo有嘛?
2022-02-21 - 一文搞懂微信支付 Api-v3 规则实现(附源码)
v2 与 v3 的区别 先看看 v2 与 v3 的区别,做到心中有数不怯场:) V3 规则差异 V2 JSON 参数格式 XML POST、GET 或 DELETE 提交方式 POST AES-256-GCM加密 回调加密 无需加密 RSA 加密 敏感加密 无需加密 UTF-8 编码方式 UTF-8 非对称密钥SHA256-RSA 签名方式 MD5 或 HMAC-SHA256 微信支付Api-v3 规则 官方文档 ,此规则需要你耐心细品,重复多此细品效果更佳。 以下是我细品后,总结的实现方案,在这里就分享给大家,干货多屁话少直接贴实现。 Talk is cheap. Show me the code. 获取证书序列号 通过代码获取 这里我们使用第三方的库 x509,如你知道其它获取方法欢迎留言 [代码]const cert = x509.parseCert('cert.pem 证书绝对路径') console.log(`证书序列号:${cert.serial}`) [代码] 通过工具获取 openssl x509 -in apiclient_cert.pem -noout -serial 使用证书解析工具 https://myssl.com/cert_decode.html 构建请求头 1、构建请求签名参数 [代码] /** * 构建请求签名参数 * @param method {RequestMethod} Http 请求方式 * @param url 请求接口 /v3/certificates * @param timestamp 获取发起请求时的系统当前时间戳 * @param nonceStr 随机字符串 * @param body 请求报文主体 */ public static buildReqSignMessage(method: RequestMethod, url: string, timestamp: string, nonceStr: string, body: string): string { return method .concat('\n') .concat(url) .concat('\n') .concat(timestamp) .concat('\n') .concat(nonceStr) .concat('\n') .concat(body) .concat('\n') } [代码] 2、使用 SHA256 with RSA 算法生成签名 [代码] /** * SHA256withRSA * @param data 待加密字符 * @param privatekey 私钥key key.pem fs.readFileSync(keyPath) */ public static sha256WithRsa(data: string, privatekey: Buffer): string { return crypto .createSign('RSA-SHA256') .update(data) .sign(privatekey, 'base64') } [代码] 3、根据平台规则生成请求头 authorization [代码] /** * 获取授权认证信息 * * @param mchId 商户号 * @param serialNo 商户API证书序列号 * @param nonceStr 请求随机串 * @param timestamp 时间戳 * @param signature 签名值 * @param authType 认证类型,目前为WECHATPAY2-SHA256-RSA2048 */ public static getAuthorization(mchId: string, serialNo: string, nonceStr: string, timestamp: string, signature: string, authType: string): string { let map: Map<string, string> = new Map() map.set('mchid', mchId) map.set('serial_no', serialNo) map.set('nonce_str', nonceStr) map.set('timestamp', timestamp) map.set('signature', signature) return authType.concat(' ').concat(this.createLinkString(map, ',', false, true)) } [代码] 4、Show Time [代码]/** * 构建 v3 接口所需的 Authorization * * @param method {RequestMethod} 请求方法 * @param urlSuffix 可通过 WxApiType 来获取,URL挂载参数需要自行拼接 * @param mchId 商户Id * @param serialNo 商户 API 证书序列号 * @param key key.pem 证书 * @param body 接口请求参数 */ public static async buildAuthorization(method: RequestMethod, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, body: string): Promise<string> { let timestamp: string = parseInt((Date.now() / 1000).toString()).toString() let authType: string = 'WECHATPAY2-SHA256-RSA2048' let nonceStr: string = Kits.generateStr() // 构建签名参数 let buildSignMessage: string = this.buildReqSignMessage(method, urlSuffix, timestamp, nonceStr, body) // 生成签名 let signature: string = this.sha256WithRsa(key, buildSignMessage) // 根据平台规则生成请求头 authorization return this.getAuthorization(mchId, serialNo, nonceStr, timestamp, signature, authType) } [代码] 封装网络请求 每个人都有个性,可使用的网络库也比较多(Axios、Fetch、Request 等),为了适配能适配这里做一代理封装。具体实现如下,网络请求库默认是使用的 Axios 1、抽离抽象接口 [代码]/** * @author Javen * @copyright javendev@126.com * @description 封装网络请求工具 */ export class HttpKit { private static delegate: HttpDelegate = new AxiosHttpKit() public static get getHttpDelegate(): HttpDelegate { return this.delegate } public static set setHttpDelegate(delegate: HttpDelegate) { this.delegate = delegate } } export interface HttpDelegate { httpGet(url: string, options?: any): Promise<any> httpGetToResponse(url: string, options?: any): Promise<any> httpPost(url: string, data: string, options?: any): Promise<any> httpPostToResponse(url: string, data: string, options?: any): Promise<any> httpDeleteToResponse(url: string, options?: any): Promise<any> httpPostWithCert(url: string, data: string, certFileContent: Buffer, passphrase: string): Promise<any> upload(url: string, filePath: string, params?: string): Promise<any> } [代码] 2、Axios 具体实现 [代码]/** * @author Javen * @copyright javendev@126.com * @description 使用 Axios 实现网络请求 */ import axios from 'axios' import * as fs from 'fs' import { HttpDelegate } from './HttpKit' import * as FormData from 'form-data' import * as https from 'https' import concat = require('concat-stream') export class AxiosHttpKit implements HttpDelegate { httpGet(url: string, options?: any): Promise<any> { return new Promise((resolve, reject) => { axios .get(url, options) .then(response => { if (response.status === 200) { resolve(response.data) } else { reject(`error code ${response.status}`) } }) .catch(error => { reject(error) }) }) } httpGetToResponse(url: string, options?: any): Promise<any> { return new Promise(resolve => { axios .get(url, options) .then(response => { resolve(response) }) .catch(error => { resolve(error.response) }) }) } httpPost(url: string, data: string, options?: any): Promise<any> { return new Promise((resolve, reject) => { axios .post(url, data, options) .then(response => { if (response.status === 200) { resolve(response.data) } else { reject(`error code ${response.status}`) } }) .catch(error => { reject(error) }) }) } httpPostToResponse(url: string, data: string, options?: any): Promise<any> { return new Promise(resolve => { axios .post(url, data, options) .then(response => { resolve(response) }) .catch(error => { resolve(error.response) }) }) } httpDeleteToResponse(url: string, options?: any): Promise<any> { return new Promise(resolve => { axios .delete(url, options) .then(response => { resolve(response) }) .catch(error => { resolve(error.response) }) }) } httpPostWithCert(url: string, data: string, certFileContent: Buffer, passphrase: string): Promise<any> { return new Promise((resolve, reject) => { let httpsAgent = new https.Agent({ pfx: certFileContent, passphrase }) axios .post(url, data, { httpsAgent }) .then(response => { if (response.status === 200) { resolve(response.data) } else { reject(`error code ${response.status}`) } }) .catch(error => { reject(error) }) }) } upload(url: string, filePath: string, params?: string): Promise<any> { return new Promise((resolve, reject) => { let formData = new FormData() formData.append('media', fs.createReadStream(filePath)) if (params) { formData.append('description', params) } formData.pipe( concat({ encoding: 'buffer' }, async data => { axios .post(url, data, { headers: { 'Content-Type': 'multipart/form-data' } }) .then(response => { if (response.status === 200) { resolve(response.data) } else { reject(`error code ${response.status}`) } }) .catch(error => { reject(error) }) }) ) }) } } [代码] 3、使其支持 Api-v3 接口规则 [代码]/** * 微信支付 Api-v3 get 请求 * @param urlPrefix * @param urlSuffix * @param mchId * @param serialNo * @param key * @param params */ public static async exeGet(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, params?: Map<string, string>): Promise<any> { if (params && params.size > 0) { urlSuffix = urlSuffix.concat('?').concat(this.createLinkString(params, '&', true, false)) } let authorization = await this.buildAuthorization(RequestMethod.GET, urlSuffix, mchId, serialNo, key, '') return await this.get(urlPrefix.concat(urlSuffix), authorization, serialNo) } /** * 微信支付 Api-v3 post 请求 * @param urlPrefix * @param urlSuffix * @param mchId * @param serialNo * @param key * @param data */ public static async exePost(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, data: string): Promise<any> { let authorization = await this.buildAuthorization(RequestMethod.POST, urlSuffix, mchId, serialNo, key, data) return await this.post(urlPrefix.concat(urlSuffix), data, authorization, serialNo) } /** * 微信支付 Api-v3 delete 请求 * @param urlPrefix * @param urlSuffix * @param mchId * @param serialNo * @param key */ public static async exeDelete(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer): Promise<any> { let authorization = await this.buildAuthorization(RequestMethod.DELETE, urlSuffix, mchId, serialNo, key, '') return await this.delete(urlPrefix.concat(urlSuffix), authorization, serialNo) } /** * get 方法 * @param url 请求 url * @param authorization 授权信息 * @param serialNumber 证书序列号 */ public static async get(url: string, authorization: string, serialNumber?: string) { return await HttpKit.getHttpDelegate.httpGetToResponse(url, { headers: this.getHeaders(authorization, serialNumber) }) } /** * post 方法 * @param url 请求 url * @param authorization 授权信息 * @param serialNumber 证书序列号 */ public static async post(url: string, data: string, authorization: string, serialNumber?: string) { return await HttpKit.getHttpDelegate.httpPostToResponse(url, data, { headers: this.getHeaders(authorization, serialNumber) }) } /** * delete 方法 * @param url 请求 url * @param authorization 授权信息 * @param serialNumber 证书序列号 */ public static async delete(url: string, authorization: string, serialNumber?: string) { return await HttpKit.getHttpDelegate.httpDeleteToResponse(url, { headers: this.getHeaders(authorization, serialNumber) }) } /** * 获取请求头 * @param authorization 授权信息 * @param serialNumber 证书序列号 */ private static getHeaders(authorization: string, serialNumber: string): Object { let userAgent: string = 'WeChatPay-TNWX-HttpClient/%s (%s) nodejs/%s' userAgent = util.format( userAgent, '2.4.0', os .platform() .concat('/') .concat(os.release()), process.version ) return { Authorization: authorization, Accept: 'application/json', 'Content-type': 'application/json', 'Wechatpay-Serial': serialNumber, 'User-Agent': userAgent } } [代码] 如何使用? 这里以「获取平台证书」为例,来演示上面封装的系列方法如何使用 [代码]try { let result = await PayKit.exeGet( WX_DOMAIN.CHINA, // WX_API_TYPE.GET_CERTIFICATES, config.mchId, x509.parseCert(config.certPath).serial, fs.readFileSync(config.keyPath) ) console.log(`result.data:${result.data}`) // 应答报文主体 let data = JSON.stringify(result.data) // 应答状态码 console.log(`status:${result.status}`) console.log(`data:${data}`) // http 请求头 let headers = result.headers // 证书序列号 let serial = headers['wechatpay-serial'] // 应答时间戳 let timestamp = headers['wechatpay-timestamp'] // 应答随机串 let nonce = headers['wechatpay-nonce'] // 应答签名 let signature = headers['wechatpay-signature'] console.log(`serial:\n${serial}`) console.log(`timestamp:\n${timestamp}`) console.log(`nonce:\n${nonce}`) console.log(`signature:\n${signature}`) ctx.body = data } catch (error) { console.log(error) } [代码] 至此微信支付 Api-v3 规则的接口已经测试通过。 但还有其他细节如要我们继续完善,比如 验证签名、证书和回调报文解密 证书和回调报文解密 AEAD_AES_256_GCM 解密算法实现 [代码] /** * AEAD_AES_256_GCM 解密 * @param key apiKey3 * @param nonce 加密使用的随机串初始化向量 * @param associatedData 附加数据包 * @param ciphertext 密文 */ public static aes256gcmDecrypt(key: string, nonce: string, associatedData: string, ciphertext: string): string { let ciphertextBuffer = Buffer.from(ciphertext, 'base64') let authTag = ciphertextBuffer.slice(ciphertextBuffer.length - 16) let data = ciphertextBuffer.slice(0, ciphertextBuffer.length - 16) let decipherIv = crypto.createDecipheriv('aes-256-gcm', key, nonce) decipherIv.setAuthTag(Buffer.from(authTag)) decipherIv.setAAD(Buffer.from(associatedData)) let decryptStr = decipherIv.update(data, null, 'utf8') decipherIv.final() return decryptStr } [代码] 保存微信平台证书示例 [代码] // 证书和回调报文解密 let certPath = '/Users/Javen/cert/platform_cert.pem' try { let decrypt = PayKit.aes256gcmDecrypt( config.apiKey3, ctx.app.config.AEAD_AES_256_GCM.nonce, ctx.app.config.AEAD_AES_256_GCM.associated_data, ctx.app.config.AEAD_AES_256_GCM.ciphertext ) // 保存证书 fs.writeFileSync(certPath, decrypt) ctx.body = decrypt } catch (error) { console.log(error) } [代码] 验证签名 示例 [代码]// 根据序列号查证书 验证签名 let verifySignature: boolean = PayKit.verifySignature(signature, data, nonce, timestamp, fs.readFileSync(ctx.app.config.WxPayConfig.wxCertPath)) console.log(`verifySignature:${verifySignature}`) [代码] 构建应答签名参数 [代码]/** * 构建应答签名参数 * @param timestamp 应答时间戳 * @param nonceStr 应答随机串 * @param body 应答报文主体 */ public static buildRepSignMessage(timestamp: string, nonceStr: string, body: string): string { return timestamp .concat('\n') .concat(nonceStr) .concat('\n') .concat(body) .concat('\n') } [代码] 使用平台证书验证 [代码] /** * 验证签名 * @param signature 待验证的签名 * @param body 应答主体 * @param nonce 随机串 * @param timestamp 时间戳 * @param publicKey 平台公钥 */ public static verifySignature(signature: string, body: string, nonce: string, timestamp: string, publicKey: Buffer): boolean { // 构建响应体中待签名数据 let buildSignMessage: string = this.buildRepSignMessage(timestamp, nonce, body) return Kits.sha256WithRsaVerify(publicKey, signature, buildSignMessage) } [代码] [代码] /** * SHA256withRSA 验证签名 * @param publicKey 公钥key * @param signature 待验证的签名串 * @param data 需要验证的字符串 */ public static sha256WithRsaVerify(publicKey: Buffer, signature: string, data: string) { return crypto .createVerify('RSA-SHA256') .update(data) .verify(publicKey, signature, 'base64') } [代码] 完整示例代码 Egg-TNWX TNWX: TypeScript + Node.js + WeiXin 微信系开发脚手架,支持微信公众号、微信支付、微信小游戏、微信小程序、企业微信/企业号、企业微信开放平台。最最最重要的是能快速的集成至任何 Node.js 框架(Express、Nest、Egg、Koa 等) 微信支付已支持 Api-v3 以及 Api-v2 版本接口,同时支持多商户多应用,国内与境外的普通商户模式和服务商模式,v2 接口同时支持 MD5 以及 HMAC-SHA256 签名算法。 如有疑问欢迎留言或者站内私信。
2020-04-21 - 商户上送敏感信息时使用微信支付平台公钥加密,要在http头携带微信支付平台证书序列号,如何理解?
https://pay.weixin.qq.com/wiki/doc/apiv3_partner/wechatpay/wechatpay4_3.shtml文档中: 商户上送敏感信息时使用微信支付平台公钥加密,证书序列号包含在请求HTTP头部的[代码]Wechatpay-Serial[代码]是直接在http头中增加“Wechatpay-Serial”属性,带上微信支付平台证书即可? 不是在“Authorization”里面增加子属性吧?
2021-12-22 - 微信分享,直接链接打开分享出去的是url,而通过扫码打开链接分享出来的就是正常的图文格式,怎么处理?
微信内访问链接,分享出来还是 URL 格式的链接,没有标题和图片。(ps:扫码打开链接分享出来的是正常的图文格式),这个问题要怎么解决呢,求指教
2021-12-21 - 小程序地理位置相关接口调整
为进一步规范开发者调用涉用户信息相关接口或功能,保障用户合法权益,平台将对如下地理位置相关接口调用实行准入开通: wx.getLocation、wx.onLocationChange、wx.chooseAddress、wx.chooseLocation、wx.choosePoi 自2022年4月18日开始,如使用以上接口,在代码审核环节将检测该接口是否已完成准入开通(申请路径:小程序管理后台 -「开发」-「开发管理」-「接口设置」),如未开通,将在代码提审环节进行拦截,请涉及相关接口的开发者尽快进行接口权限申请,第三方开发者申请方式:可通过 apply_privacy_interface 接口完成。 请广大涉及相关接口的开发者尽快进行相关接口准入申请,如未申请,后续将影响线上小程序相关接口的使用。
2023-09-26 - SDK jsApiLIst数组返回空
config:ok, jsApiLIst数组返回空,公众号后台有对应接口权限 ready回调里调用对应接口报错 the permission value is offline verifying [图片] [图片]
2022-01-18 - 云开发获取手机号问题
云开发获取手机号问题 ~ 具体是这样,同一份答题活动小程序的代码,搭建了二个小程序, 也就是代码不同,其他都一样 获取手机号的信息如下所示 [图片] ~ [图片] ~~ 这个小程序的weRunData没有包在event里面,但是另一个小程序的数据结构却把weRunData包在里面了
2021-08-24 - 流量主通过不正当方式制造虚假或无效曝光量、点击量?
一个正常的傍晚,突然收到一个服务通知。 [图片] 我脑子,全是问号??????????。 这个半死不活的小程序要闹哪样?打码都懒得打了。 登录小程序一看,好家伙,给我整笑了。 [图片] 我广告就两个(插屏,原生横幅) 我直接贴数据吧。一起来找茬。 小程序日活(2021-06-01 ~ 2021-08-15),日活这样,基本是放弃的小程序了。 [图片] 插屏曝光率(2021-06-01 ~ 2021-08-15) [图片] 插屏点击率(2021-06-01 ~ 2021-08-15) [图片] 原生模板广告 曝光率(2021-06-01 ~ 2021-08-15) [图片] 原生模板广告 点击率(2021-06-01 ~ 2021-08-15) [图片] 正常到不能再正常的数据了。虚假曝光?无效点击?只从该小程序日活下跌后。基本没怎么管理了。这突然给我整这幺蛾子。 唯一可疑的的就是2021年7月25号,点击率突然高了一下。我也是刚刚才看到的。 按照以往的群友的经验,这小程序基本是凉了的。以后的处罚只会越来越严。也不打算申述或者是挣扎什么了。躺平~ 发帖也只是为了反馈一下。 1、处罚,能否有证据。例如公众号处罚,告诉你且有相关证据。 2、是否存在漏洞,例如给竞争对手刷数据。触发处罚机制。 3、是否有申述入口,给予误杀申述。 ------------------更新--------2021/8/17 15:00--------更新------------------- 退一万步说,真的违规了。起码得告诉错哪了,才能知道怎么改~~~。 不然下次接着错,接着封,对于小微企业,那就是全部收入了!!那真的可能就要爬深圳大厦楼顶了。
2021-08-17 - 体育考研刷题小程序由于虚拟支付被限制ios搜索了,已申诉通过,为什么还搜不到?
体育考研刷题小程序由于虚拟支付被限制ios搜索了,已申诉通过,为什么还搜不到? ~ [图片] ~ [图片] ~ [图片] ~ 小程序名称 平头哥体育考研346刷T神器 appid wx6ef9ffb1d1332996
2021-07-18 - 云函数资源4万GBS是多少?能否举例量化说明?
假设一个云函数使用256M内存,平均运行时长为500ms 那么每次调用需要消耗0.25G*0.5S=0.125GBS 4万GBS一共能支撑这个云函数运行32万次
2020-10-28 - 小程序 云开发 企业付款到零钱
终于轮到我来装一次b了 之前总是有求于各位神,现在来回馈了。 各位用小程序云开发,要实现退款、企业零钱的可以看过来。 // 云函数入口文件 const cloud = require('wx-server-sdk') cloud.init() const config = { appid: '**************', //小程序Appid,填自己的小程序id envName: '*************', // 小程序云开发环境ID mchid: '***********', //商户号,填自己的商户号 pfx: require('fs').readFileSync('./apiclient_cert.p12'),这里是下载的api证书。证书怎么下在呢?网上有 partnerKey: '123111111111111111111111111111111111111111111111111', //此处填商户密钥 notify_url: ' ', //支付回调网址,这里可以随意填一个网址 spbill_create_ip: '127.0.0.1' //不用改 }; const db = cloud.database(); const TcbRouter = require('tcb-router'); //云函数路由 const rq = require('request'); const tenpay = require('tenpay'); //支付核心模块 这里要是报错,直接搜 nps + 报错内容 // 云函数入口函数 exports.main = async (event, context) => { const wxContext = cloud.getWXContext() console.log("提现走到了函数",event) const api = tenpay.init(config); var tixian = event.tixian // 申请企业付款到用户零钱 const orderNumber= 'dlbmoney' + new Date().getTime() + Math.floor(Math.random() * 1000) const datas = { partner_trade_no: orderNumber, openid: wxContext.OPENID, amount: tixian * 100, desc: "订单说明", check_name: "NO_CHECK", //不检查实名 spbill_create_ip:"123.151.79.109" } const result = await api.transfers(datas) return { event, openid: wxContext.OPENID, appid: wxContext.APPID, unionid: wxContext.UNIONID, } }
2021-07-07 - 小程序最小化,计时器渲染问题
客户端存在一个计时器,用setInterval进行倒计时, [图片] 然后小程序最小化,按home键到桌面或者回到微信时, ios上计时器有时会停止在退出的时刻,再次回来时会在当前继续倒计时; 安卓会出现计时器快速跳转到倒计时的正确时刻, 这是BUG吗?还是小程序的运行机制问题!
2018-10-30 - 小程序倒计时熄灭屏幕后再打开,倒计时不准确
- 需求的场景描述(希望解决的问题):问题主要是自己开发的倒计时组件中,当页面正在倒计时,用户有很大几率会切出去看其他内容,然后等待倒计时即将结束的1,2分钟,才返回查看,或者,用户熄灭屏幕等待。此时!当小程序会暂停倒计时,导致用户切换回来,或者解锁屏幕,倒计时不准确。 - 希望提供的能力:真的不好意思,我组织过了一次语言,刚才说的让大家误解了,我其实意思是,想要一个API判断,小程序在前台且刚好熄灭屏幕后再打开。或者提供一个熄灭屏幕的API。
2018-12-04 - 小程序里支持长按识别企业微信二维码吗?
想直接把企业微信二维码放到小程序里,让客户添加企业微信好友进行咨询。 不知道目前是否支持了呢? 谁给解答一下啊啊啊~!
2021-01-25 - 小程序版本更新的自动+手动方案
小程序版本更新了,但是用户的小程序版本可能还是老版本,怎么才能最大限度地保证版本及时更新,又能不影响用户体验? 我们的办法是自动更新和手动更新并存。 方案如下: app.js中的自动更新: update: function () { let um = wx.getUpdateManager() um.onCheckForUpdate(res => res.hasUpdate && um.onUpdateReady(() => um.applyUpdate())) }, 在小程序的某页中,手动更新: [图片] mine.js中的代码: upgrade: function () { wx.showToast({ title: '正在更新版本...', icon: 'loading' }) const um = wx.getUpdateManager() um.onCheckForUpdate(function (res) { if (res.hasUpdate) { um.onUpdateReady(function () { um.applyUpdate() wx.showToast({ title: '更新完成', }) }) } else { wx.showToast({ title: '已是最新版本', }) } }) }, 这样,既可以自动更新,如果实在某些用户没有及时更新,过来投诉,引导他们手动更新一下版本,用户体验没毛病。
2021-03-23 - 为什么社区一篇文章的审核都三天了依旧显示在审核中
为什么社区一篇文章的审核都三天了依旧显示在审核中,21年2月24发表的一片问题求助帖子,到26号还未通过。 我不清楚这时微信社区的审核机制问题,还是我单独遇到的情况。 我也不知道我的文章还有多久才能通过。 不过,这种体验真的相当不好,当你,遇到问题时,抱着希望别人给你提供建议的心态,发表一篇文章描述你遇到的问题,结果却连发布都是问题的。 那我要这社区何用
2021-02-26 - Webpack核心原理
Webpack解决的问题 Webpack做的事情简单来说就一件事情打包,先看下边这段简单的代码 [图片] [图片] [图片] 这段代码不能在浏览器中直接运行,因为浏览器不支持运行带有import和export关键字的代码,现代浏览器可以通过<script type=module>来支持,但是ie浏览器不支持,当项目比较大的时候我们对于代码拆分为多个文件的需求有很多,所以我们对于这个问题急需要解决。 webpack就是提供给解决这个问题的一个方案:把关键字转译为普通代码,并把所有文件打包成一个文件。 babel原理 要将代码打包webpack就需要借助babel对代码进行解析、转译等等工作。 babel工作步骤 babel转译代码分为三个步骤: 解读代码生成ast树 遍历ast树修改树节点属性生成新的ast树 通过ast生成代码 简单案例 通过一个简单案例,来理解下babel转换的过程,这个例子是个简单的将let 转化为var的过程: [代码]import { parse } from "@babel/core"; import traverse from "@babel/traverse"; import generate from "@babel/generator"; const code = `let a = 'let'; let b = 2`; const ast = parse(code, { sourceType: "module" }); if (ast) { traverse(ast, { enter: (item) => { if (item.node.type === 'VariableDeclaration') { if (item.node.kind === "let") { item.node.kind = "var"; } } }, }); const result = generate(ast, {}, code); console.log(result.code); } [代码] 先使用parse库将代码转换为ast树,然后通过traverse库遍历ast树,将所有类型声明语句中的类型从var改成let,最终结果为: [图片] babel库 import的几个库就是对应babel的转换过程: 生成ast树 @babel/core 遍历ast树 @babel/traverse ast树生成代码 @babel/generator AST AST是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,将上边的ast对象打印出来格式如下: [图片] ast是一个对象格式,program就是对代码做的整体抽象,例子中有两条声明语句,所以这里body数组中就是两个节点对象。 [图片] type属性表示这个语句的类型,例子中是个变量声明,kind表示这个语句用的是var声明变量。declarations语句由是一个节点数组,因为一个变量声明语句可以声明多个变量,declarations数组中的节点结构如下图: [图片] es6转es5案例 一个频繁使用babel的场景就是将js的新特性转换成低版本的js代码,要将新版本js转换成低版本js需要使用@babel/preset-env库,告诉babel新老版本js之间的转换关系。 [代码]import * as fs from "fs"; import { parse } from "@babel/core"; import * as babel from "@babel/core"; const code = fs.readFileSync("./test.js").toString(); const ast = parse(code, { sourceType: "module" }); if (ast) { const result = babel.transformFromAstSync(ast, code, { presets: ["@babel/preset-env"], }); if (result?.code) { fs.writeFileSync("./test.es5.js", result.code); } } [代码] test.js原代码: [代码]let a = "let"; let b = 2; const c = 3; function sum() { let a = 1, b = 1; return a + b; } [代码] 转义后的代码: [代码]"use strict"; var a = "let"; var b = 2; var c = 3; function sum() { var a = 1, b = 1; return a + b; } [代码] 打包文件 处理文件依赖关系 案例 通过一个简单的例子来了解webpack是怎么将几个文件的代码打包成一个文件。 代码结构 project_1/index.js [代码]import a from "./a.js"; import b from "./b.js"; console.log(a.value + b.value); [代码] project_1/a.js [代码]const a = { value: 1, } export default a [代码] project_1/b.js [代码]const b = { value: 2, } export default b [代码] 打包文件代码 [代码]import * as babel from "@babel/core"; import { parse } from "@babel/parser"; import traverse from "@babel/traverse"; import { readFileSync, writeFileSync } from "fs"; import { resolve, relative, dirname } from "path"; // 设置根目录 const projectRoot = resolve(__dirname, "project_2"); interface Dep { key: string; deps: string[]; code: string; } // 类型声明 type DepRelation = Dep[]; // 初始化一个空的 depRelation,用于收集依赖 const depRelation: DepRelation = []; // 数组! // 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js collectCodeAndDeps(resolve(projectRoot, "index.js")); console.log(depRelation); console.log("done"); /** * * @param filepath 文件绝对地址 */ function collectCodeAndDeps(filepath: string) { const key = getProjectPath(filepath); // 文件的项目路径,如 index.js if (depRelation.find((i) => i.key === key)) { // 注意,重复依赖不一定是循环依赖 return; } // 获取文件内容,将内容放至 depRelation const code = readFileSync(filepath).toString(); // 初始化 depRelation[key] // 将代码转为 AST const transformCode = babel.transform(code, { presets: ["@babel/preset-env"], }); const es5Code = transformCode?.code; if (!es5Code) { return; } const item: Dep = { key, deps: [], code: es5Code }; depRelation.push(item); const ast = parse(code, { sourceType: "module" }); // 分析文件依赖,将内容放至 depRelation traverse(ast, { enter: (path) => { if (path.node.type === "ImportDeclaration") { // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径 const depAbsolutePath = resolve( dirname(filepath), path.node.source.value ); // 然后转为项目路径 const depProjectPath = getProjectPath(depAbsolutePath); // 把依赖写进 depRelation item.deps.push(depProjectPath); collectCodeAndDeps(depAbsolutePath); } }, }); } // 获取文件相对于根目录的相对路径 function getProjectPath(path: string) { return relative(projectRoot, path).replace(/\\/g, "/"); } [代码] 代码思路 depRelation是依赖数组,存储所有的文件依赖,第一个元素是入口文件的依赖,depRelation数据项格式{ deps: [‘依赖的文件路径’], code: ‘文件的源码’ } collectCodeAndDeps是处理文件依赖的主函数,主要步骤是: 判断文件路径是否已经存在依赖数组中,如果已经存在不再重复处理 根据文件路径读取源代码 将源代码准换ast树 通过@babel/preset-env将代码转义然后存在依赖数据项的code属性中 遍历ast树,找到import语句,存入deps属性中,然后取出import路径,递归调用collectCodeAndDeps 打包后代码执行 最终需要通过生成的依赖关系数组对象,生成可以执行的代码,生成最终代码的函数如下: [代码]function generateCode() { let code = ""; code += "var depRelation = [" + depRelation .map((item) => { const { key, code, deps } = item; return `{ key: ${JSON.stringify(key)}, deps: ${JSON.stringify(deps)}, code: function(require, module, exports){ ${code} } }`; }) .join(",") + "];\n"; code += "var modules = {};\n"; code += `execute(depRelation[0].key)\n`; code += ` function execute(key) { if (modules[key]) { return modules[key] } var item = depRelation.find(i => i.key === key) if (!item) { throw new Error(\`\${item} is not found\`) } var pathToKey = (path) => { var dirname = key.substring(0, key.lastIndexOf('/') + 1) var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/') return projectPath } var require = (path) => { return execute(pathToKey(path)) } modules[key] = { __esModule: true } var module = { exports: modules[key] } item.code(require, module, module.exports) return modules[key] } `; return code; } [代码] 通过依赖关系数组拼装依赖对象,将code替换为方法 使用modules对象存储各个文件export出去的内容 自定义require函数替换每个文件中require方法,用modules对象替换每个文件的export方法对象 pathToKey方法处理"./"这种import 最终代码 [代码]import * as babel from "@babel/core"; import { parse } from "@babel/parser"; import traverse from "@babel/traverse"; import { readFileSync, writeFileSync } from "fs"; import { resolve, relative, dirname } from "path"; // 设置根目录 const projectRoot = resolve(__dirname, "project_2"); interface Dep { key: string; deps: string[]; code: string; } // 类型声明 type DepRelation = Dep[]; // 初始化一个空的 depRelation,用于收集依赖 const depRelation: DepRelation = []; // 数组! // 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js collectCodeAndDeps(resolve(projectRoot, "index.js")); function generateCode() { let code = ""; code += "var depRelation = [" + depRelation .map((item) => { const { key, code, deps } = item; return `{ key: ${JSON.stringify(key)}, deps: ${JSON.stringify(deps)}, code: function(require, module, exports){ ${code} } }`; }) .join(",") + "];\n"; code += "var modules = {};\n"; code += `execute(depRelation[0].key)\n`; code += ` function execute(key) { if (modules[key]) { return modules[key] } var item = depRelation.find(i => i.key === key) if (!item) { throw new Error(\`\${item} is not found\`) } var pathToKey = (path) => { var dirname = key.substring(0, key.lastIndexOf('/') + 1) var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/') return projectPath } var require = (path) => { return execute(pathToKey(path)) } modules[key] = { __esModule: true } var module = { exports: modules[key] } item.code(require, module, module.exports) return modules[key] } `; return code; } writeFileSync("dist_2.js", generateCode()); console.log(depRelation); console.log("done"); /** * * @param filepath 文件绝对地址 */ function collectCodeAndDeps(filepath: string) { const key = getProjectPath(filepath); // 文件的项目路径,如 index.js if (depRelation.find((i) => i.key === key)) { // 注意,重复依赖不一定是循环依赖 return; } // 获取文件内容,将内容放至 depRelation const code = readFileSync(filepath).toString(); // 初始化 depRelation[key] // 将代码转为 AST const transformCode = babel.transform(code, { presets: ["@babel/preset-env"], }); const es5Code = transformCode?.code; if (!es5Code) { return; } const item: Dep = { key, deps: [], code: es5Code }; depRelation.push(item); const ast = parse(code, { sourceType: "module" }); // 分析文件依赖,将内容放至 depRelation traverse(ast, { enter: (path) => { if (path.node.type === "ImportDeclaration") { // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径 const depAbsolutePath = resolve( dirname(filepath), path.node.source.value ); // 然后转为项目路径 const depProjectPath = getProjectPath(depAbsolutePath); // 把依赖写进 depRelation item.deps.push(depProjectPath); collectCodeAndDeps(depAbsolutePath); } }, }); } // 获取文件相对于根目录的相对路径 function getProjectPath(path: string) { return relative(projectRoot, path).replace(/\\/g, "/"); } [代码]
2021-01-20 - 微信的人脸识别功能,是免费的吗?还是要付费?
微信的人脸识别功能,是免费的吗?还是要付费? 接口: wx.startFacialRecognitionVerify(OBJECT)
2020-11-19 - 人脸核验收费及每日接口调用上限问题?
请问人脸核验接口是免费的的吗?接口调用的上限是多少?
2020-07-28 - 使用 MobX 来管理小程序的跨页面数据
在小程序中,常常有些数据需要在几个页面或组件中共享。对于这样的数据,在 web 开发中,有些朋友使用过 redux 、 vuex 之类的 状态管理 框架。在小程序开发中,也有不少朋友喜欢用 MobX ,说明这类框架在实际开发中非常实用。 小程序团队近期也开源了 MobX 的辅助模块,使用 MobX 也更加方便。那么,在这篇文章中就来介绍一下 MobX 在小程序中的一个简单用例! 在小程序中引入 MobX 在小程序项目中,可以通过 npm 的方式引入 MobX 。如果你还没有在小程序中使用过 npm ,那先在小程序目录中执行命令: [代码]npm init -y [代码] 引入 MobX : [代码]npm install --save mobx-miniprogram mobx-miniprogram-bindings [代码] (这里用到了 mobx-miniprogram-bindings 模块,模块说明在这里: https://developers.weixin.qq.com/miniprogram/dev/extended/functional/mobx.html 。) npm 命令执行完后,记得在开发者工具的项目中点一下菜单栏中的 [代码]工具[代码] - [代码]构建 npm[代码] 。 MobX 有什么用呢? 试想这样一个场景:制作一个天气预报资讯小程序,首页是列表,点击列表中的项目可以进入到详情页。 首页如下: [图片] 详情页如下: [图片] 每次进入首页时,需要使用 [代码]wx.request[代码] 获取天气列表数据,之后将数据使用 setData 应用到界面上。进入详情页之后,再次获取指定日期的天气详情数据,展示在详情页中。 这样做的坏处是,进入了详情页之后需要再次通过网络获取一次数据,等待网络返回后才能将数据展示出来。 事实上,可以在首页获取天气列表数据时,就一并将所有的天气详情数据一同获取回来,存放在一个 数据仓库 中,需要的时候从仓库中取出来就可以了。这样,只需要进入首页时获取一次网络数据就可以了。 MobX 可以帮助我们很方便地建立数据仓库。接下来就讲解一下具体怎么建立和使用 MobX 数据仓库。 建立数据仓库 数据仓库通常专门写在一个独立的 js 文件中。 [代码]import { observable, action } from 'mobx-miniprogram' // 数据仓库 export const store = observable({ list: [], // 天气数据(包含列表和详情) // 设置天气列表,从网络上获取到数据之后调用 setList: action(function (list) { this.list = list }), }) [代码] 在上面数据仓库中,包含有数据 [代码]list[代码] (即天气数据),还包括了一个名为 [代码]setList[代码] 的 action ,用于更改数据仓库中的数据。 在首页中使用数据仓库 如果需要在页面中使用数据仓库里的数据,需要调用 [代码]createStoreBindings[代码] 来将仓库中的数据绑定到页面数据中,然后就可以在页面中直接使用仓库数据了。 [代码]import { createStoreBindings } from 'mobx-miniprogram-bindings' import { store } from './store' Page({ onLoad() { // 绑定 MobX store this.storeBindings = createStoreBindings(this, { store, // 需要绑定的数据仓库 fields: ['list'], // 将 this.data.list 绑定为仓库中的 list ,即天气数据 actions: ['setList'], // 将 this.setList 绑定为仓库中的 setList action }) // 从服务器端读取数据 wx.showLoading() wx.request({ // 请求网络数据 // ... success: (data) => { wx.hideLoading() // 调用 setList action ,将数据写入 store this.setList(data) } }) }, onUnload() { // 解绑 this.storeBindings.destroyStoreBindings() }, }) [代码] 这样,可以在 wxml 中直接使用 list : [代码]<view class="item" wx:for="{{list}}" wx:key="date" data-index="{{index}}"> <!-- 这里可以使用 list 中的数据了! --> <view class="title">{{item.date}} {{item.summary}}</view> <view class="abstract">{{item.temperature}}</view> </view> [代码] 在详情页中使用数据仓库 在详情页中,同样可以使用 [代码]createStoreBindings[代码] 来将仓库中的数据绑定到页面数据中: [代码]import { createStoreBindings } from 'mobx-miniprogram-bindings' import { store } from './store' Page({ onLoad(args) { // 绑定 MobX store this.storeBindings = createStoreBindings(this, { store, // 需要绑定的数据仓库 fields: ['list'], // 将 this.data.list 绑定为仓库中的 list ,即天气数据 }) // 页面参数 `index` 表示要展示哪一条天气详情数据,将它用 setData 设置到界面上 this.setData({ index: args.index }) }, onUnload() { // 解绑 this.storeBindings.destroyStoreBindings() }, }) [代码] 这样,这个页面 wxml 中也可以直接使用 list : [代码]<view class="title">{{list[index].date}}</view> <view class="content">温度 {{list[index].temperature}}</view> <view class="content">天气 {{list[index].weather}}</view> <view class="content">空气质量 {{list[index].airQuality}}</view> <view class="content">{{list[index].details}}</view> [代码] 完整示例 完整例子可以在这个代码片段中体验: https://developers.weixin.qq.com/s/YhfvpxmN7HcV 这个就是 MobX 在小程序中最基础的玩法了。相关的 npm 模块文档可参考 mobx-miniprogram-bindings 和 mobx-miniprogram 。 MobX 在实际使用时还有很多好的实践经验,感兴趣的话,可以阅读一些其他相关的文章。
2019-11-01 - 小程序同层渲染原理剖析
众所周知,小程序当中有一类特殊的内置组件——原生组件,这类组件有别于 WebView 渲染的内置组件,他们是交由原生客户端渲染的。原生组件作为 Webview 的补充,为小程序带来了更丰富的特性和更高的性能,但同时由于脱离 Webview 渲染也给开发者带来了不小的困扰。在小程序引入「同层渲染」之前,原生组件的层级总是最高,不受 [代码]z-index[代码] 属性的控制,无法与 [代码]view[代码]、[代码]image[代码] 等内置组件相互覆盖, [代码]cover-view[代码] 和 [代码]cover-image[代码] 组件的出现一定程度上缓解了覆盖的问题,同时为了让原生组件能被嵌套在 [代码]swiper[代码]、[代码]scroll-view[代码] 等容器内,小程序在过去也推出了一些临时的解决方案。但随着小程序生态的发展,开发者对原生组件的使用场景不断扩大,原生组件的这些问题也日趋显现,为了彻底解决原生组件带来的种种限制,我们对小程序原生组件进行了一次重构,引入了「同层渲染」。 相信已经有不少开发者已经在日常的小程序开发中使用了「同层渲染」的原生组件,那么究竟什么是「同层渲染」?它背后的实现原理是怎样的?它是解决原生组件限制的银弹吗?本文将会为你一一解答这些问题。 什么是「同层渲染」? 首先我们先来了解一下小程序原生组件的渲染原理。我们知道,小程序的内容大多是渲染在 WebView 上的,如果把 WebView 看成单独的一层,那么由系统自带的这些原生组件则位于另一个更高的层级。两个层级是完全独立的,因此无法简单地通过使用 [代码]z-index[代码] 控制原生组件和非原生组件之间的相对层级。正如下图所示,非原生组件位于 WebView 层,而原生组件及 [代码]cover-view[代码] 与 [代码]cover-image[代码] 则位于另一个较高的层级: [图片] 那么「同层渲染」顾名思义则是指通过一定的技术手段把原生组件直接渲染到 WebView 层级上,此时「原生组件层」已经不存在,原生组件此时已被直接挂载到 WebView 节点上。你几乎可以像使用非原生组件一样去使用「同层渲染」的原生组件,比如使用 [代码]view[代码]、[代码]image[代码] 覆盖原生组件、使用 [代码]z-index[代码] 指定原生组件的层级、把原生组件放置在 [代码]scroll-view[代码]、[代码]swiper[代码]、[代码]movable-view[代码] 等容器内,通过 [代码]WXSS[代码] 设置原生组件的样式等等。启用「同层渲染」之后的界面层级如下图所示: [图片] 「同层渲染」原理 你一定也想知道「同层渲染」背后究竟采用了什么技术。只有真正理解了「同层渲染」背后的机制,才能更高效地使用好这项能力。实际上,小程序的同层渲染在 iOS 和 Android 平台下的实现不同,因此下面分成两部分来分别介绍两个平台的实现方案。 iOS 端 小程序在 iOS 端使用 WKWebView 进行渲染的,WKWebView 在内部采用的是分层的方式进行渲染,它会将 WebKit 内核生成的 Compositing Layer(合成层)渲染成 iOS 上的一个 WKCompositingView,这是一个客户端原生的 View,不过可惜的是,内核一般会将多个 DOM 节点渲染到一个 Compositing Layer 上,因此合成层与 DOM 节点之间不存在一对一的映射关系。不过我们发现,当把一个 DOM 节点的 CSS 属性设置为 [代码]overflow: scroll[代码] (低版本需同时设置 [代码]-webkit-overflow-scrolling: touch[代码])之后,WKWebView 会为其生成一个 [代码]WKChildScrollView[代码],与 DOM 节点存在映射关系,这是一个原生的 [代码]UIScrollView[代码] 的子类,也就是说 WebView 里的滚动实际上是由真正的原生滚动组件来承载的。WKWebView 这么做是为了可以让 iOS 上的 WebView 滚动有更流畅的体验。虽说 [代码]WKChildScrollView[代码] 也是原生组件,但 WebKit 内核已经处理了它与其他 DOM 节点之间的层级关系,因此你可以直接使用 WXSS 控制层级而不必担心遮挡的问题。 小程序 iOS 端的「同层渲染」也正是基于 [代码]WKChildScrollView[代码] 实现的,原生组件在 attached 之后会直接挂载到预先创建好的 [代码]WKChildScrollView[代码] 容器下,大致的流程如下: 创建一个 DOM 节点并设置其 CSS 属性为 [代码]overflow: scroll[代码] 且 [代码]-webkit-overflow-scrolling: touch[代码]; 通知客户端查找到该 DOM 节点对应的原生 [代码]WKChildScrollView[代码] 组件; 将原生组件挂载到该 [代码]WKChildScrollView[代码] 节点上作为其子 View。 [图片] 通过上述流程,小程序的原生组件就被插入到 [代码]WKChildScrollView[代码] 了,也即是在 [代码]步骤1[代码] 创建的那个 DOM 节点对应的原生 ScrollView 的子节点。此时,修改这个 DOM 节点的样式属性同样也会应用到原生组件上。因此,「同层渲染」的原生组件与普通的内置组件表现并无二致。 Android 端 小程序在 Android 端采用 chromium 作为 WebView 渲染层,与 iOS 不同的是,Android 端的 WebView 是单独进行渲染而不会在客户端生成类似 iOS 那样的 Compositing View (合成层),经渲染后的 WebView 是一个完整的视图,因此需要采用其他的方案来实现「同层渲染」。经过我们的调研发现,chromium 支持 WebPlugin 机制,WebPlugin 是浏览器内核的一个插件机制,主要用来解析和描述embed 标签。Android 端的同层渲染就是基于 [代码]embed[代码] 标签结合 chromium 内核扩展来实现的。 [图片] Android 端「同层渲染」的大致流程如下: WebView 侧创建一个 [代码]embed[代码] DOM 节点并指定组件类型; chromium 内核会创建一个 [代码]WebPlugin[代码] 实例,并生成一个 [代码]RenderLayer[代码]; Android 客户端初始化一个对应的原生组件; Android 客户端将原生组件的画面绘制到步骤2创建的 [代码]RenderLayer[代码] 所绑定的 [代码]SurfaceTexture[代码] 上; 通知 chromium 内核渲染该 [代码]RenderLayer[代码]; chromium 渲染该 [代码]embed[代码] 节点并上屏。 [图片] 这样就实现了把一个原生组件渲染到 WebView 上,这个流程相当于给 WebView 添加了一个外置的插件,如果你有留意 Chrome 浏览器上的 pdf 预览,会发现实际上它也是基于 [代码]<embed />[代码] 标签实现的。 这种方式可以用于 map、video、canvas、camera 等原生组件的渲染,对于 input 和 textarea,采用的方案是直接对 chromium 的组件进行扩展,来支持一些 WebView 本身不具备的能力。 对比 iOS 端的实现,Android 端的「同层渲染」真正将原生组件视图加到了 WebView 的渲染流程中且 embed 节点是真正的 DOM 节点,理论上可以将任意 WXSS 属性作用在该节点上。Android 端相对来说是更加彻底的「同层渲染」,但相应的重构成本也会更高一些。 「同层渲染」 Tips 通过上文我们已经了解了「同层渲染」在 iOS 和 Android 端的实现原理。Android 端的「同层渲染」是基于 chromium 内核开发的扩展,可以看成是 webview 的一项能力,而 iOS 端则需要在使用过程中稍加注意。以下列出了若干注意事项,可以帮助你避免踩坑: Tips 1. 不是所有情况均会启用「同层渲染」 需要注意的是,原生组件的「同层渲染」能力可能会在特定情况下失效,一方面你需要在开发时稍加注意,另一方面同层渲染失败会触发 [代码]bindrendererror[代码] 事件,可在必要时根据该回调做好 UI 的 fallback。根据我们的统计,目前同层失败率很低,也不需要太过于担心。 对 Android 端来说,如果用户的设备没有微信自研的 [代码]chromium[代码] 内核,则会无法切换至「同层渲染」,此时会在组件初始化阶段触发 [代码]bindrendererror[代码]。而 iOS 端的情况会稍复杂一些:如果在基础库创建同层节点时,节点发生了 WXSS 变化从而引起 WebKit 内核重排,此时可能会出现同层失败的现象。解决方法:应尽量避免在原生组件上频繁修改节点的 WXSS 属性,尤其要尽量避免修改节点的 [代码]position[代码] 属性。如需对原生组件进行变换,强烈推荐使用 [代码]transform[代码] 而非修改节点的 [代码]position[代码] 属性。 Tips 2. iOS 「同层渲染」与 WebView 渲染稍有区别 上文我们已经了解了 iOS 端同层渲染的原理,实际上,WebKit 内核并不感知原生组件的存在,因此并非所有的 WXSS 属性都可以在原生组件上生效。一般来说,定位 (position / margin / padding) 、尺寸 (width / height) 、transform (scale / rotate / translate) 以及层级 (z-index) 相关的属性均可生效,在原生组件外部的属性 (如 shadow、border) 一般也会生效。但如需对组件做裁剪则可能会失败,例如:[代码]border-radius[代码] 属性应用在父节点不会产生圆角效果。 Tips 3. 「同层渲染」的事件机制 启用了「同层渲染」之后的原生组件相比于之前的区别是原生组件上的事件也会冒泡,意味着,一个原生组件或原生组件的子节点上的事件也会冒泡到其父节点上并触发父节点的事件监听,通常可以使用 [代码]catch[代码] 来阻止原生组件的事件冒泡。 Tips 4. 只有子节点才会进入全屏 有别于非同层渲染的原生组件,像 [代码]video[代码] 和 [代码]live-player[代码] 这类组件进入全屏时,只有其子节点会被显示。 [图片] 总结 阅读本文之后,相信你已经对小程序原生组件的「同层渲染」有了更深入的理解。同层渲染不仅解决了原生组件的层级问题,同时也让原生组件有了更丰富的展示和交互的能力。下表列出的原生组件都已经支持了「同层渲染」,其他组件( textarea、camera、webgl 及 input)也会在近期逐步上线。现在你就可以试试用「同层渲染」来优化你的小程序了。 支持同层渲染的原生组件 最低版本 video v2.4.0 map v2.7.0 canvas 2d(新接口) v2.9.0 live-player v2.9.1 live-pusher v2.9.1
2019-11-21 - 小程序没有 DOM 接口,原因竟然是……?
拥有丰富的 Web 前端开发经验的工程师小赵今天刚刚来到新的部门,开始从事他之前没有接触过的微信小程序开发。在上手的第一天,他就向同办公室的小程序老手老李请教了自己的问题。 小赵:翻了一圈文档,小程序好像并不提供 DOM 接口?我还以为可以像之前一样用我喜欢的前端框架来做开发呢。老李,你说小程序为什么不给我们提供 DOM 接口呀。 老李:要提供 DOM 接口也没那么容易。你知道小程序的双线程模型吗?(小赵漏出了疑惑的表情)小程序是基于 Web 技术的,这你应该知道,但小程序和普通的移动端网页也不一样。你做了很多前端项目了,应该知道在浏览器里,UI 渲染和 JavaScript 逻辑都是在一个线程中执行的? 小赵:这我知道,在同一个线程中,UI 渲染和 JavaScript 逻辑交替执行,JavaScript 也可以通过 DOM 接口来对渲染进行控制。 老李:小程序使用的是一种两个线程并行执行的模式,叫做双线程模型。像我画的这样,两个线程合力完成小程序的渲染:一个线程专门负责渲染工作,我们一般称之为渲染层;而另外有一个线程执行我们的逻辑代码,我们一般叫做逻辑层。这两个线程同时运行,并通过微信客户端来交换数据。在小程序运行的时候,逻辑层执行我们编写的逻辑,将数据通过 setData 发送到渲染层;而渲染层解析我们的 WXML 和 WXSS,并结合数据渲染出页面。一方面,每个页面对应一个 WebView 渲染层,对于用户来说更加有页面的感觉,体验更好,而且也可以避免单个 WebView 的负担太重;另一方面,将小程序代码运行在独立的线程中的模式有更好的安全表现,允许有像 open-data 这样的组件可以在确保用户隐私的前提下让我们展示用户数据。 [图片] 小赵:怪不得所有和页面有关的改动都只能通过 setData 来完成。但是用两个线程来渲染我们平时用单线程来渲染的 Web 页面,会不会有些「浪费」?而且每一个页面有一个对应的渲染层,那页面变多的时候,岂不是会有很大的开销? 老李: 并不浪费,因为界面的渲染和后台的逻辑处理可以在同一时间运行了,这使得小程序整体的响应速度更快了。而在小程序的运行过程中,逻辑层需要常驻,但渲染层是可以回收的。实际上,当页面栈的层数比较高的时候,栈底页面的渲染层是会被慢慢回收的。 小赵: 原来如此。这么说的话,实际的 DOM 树是存在于渲染层的,逻辑层并不存在,所以逻辑层才没有任何的 DOM 接口,我明白了。但是……既然可以实现像 setData 这样的接口,为什么不能直接把 DOM 接口也代理到逻辑层呢?我觉得小程序可以做一个封装,让我们在逻辑层调用 DOM 接口,在渲染层调用接口后再把结果返回给我们呀。 老李:从理论上来说确实是可以的。但是线程之间的通信是需要时间的呀。将调用发送到渲染层,再将 DOM 调用结果发送回来,这中间由于线程通信发生的时间损耗可能会比这个接口本身需要的时间要多得多。如果以此为基础使用基于 DOM 接口的前端框架,大量的 DOM 调用可能会非常缓慢,让这个设计失去意义。 在实际测试中,如果每次 DOM 调用都进行一次线程通信,耗时大约是同等节点规模直接在渲染层调用的百倍以上;如果忽略通信需要的时间,一个实现良好的基于 DOM 代理的框架可以近似地看成一个动态模板的框架,而动态模板和静态模板相比要慢至少 50% 小赵:原来如此,线程通信的时间确实是我没有考虑到的问题。那现在的小程序框架中难道不存在这个问题吗? 老李: 在现在的小程序框架中,这个问题也是存在的,这也是现在的框架基于静态模板渲染的原因。静态模板可以在运行前就做好打包,直接注入到渲染层,省去线程传输的时间。在运行时,逻辑层只和渲染层进行最少的、必要的数据交换:也就是渲染用的数据,或者说 data 。另一方面,静态模板让两个线程都在启动时就拥有模板相关的所有数据,所以框架也充分利用了这一点,进行了很多优化。 小赵: 怪不得我在文档里发现很多和 setData 有关的性能提示,都提醒尽量减少设置不必要的数据,现在总算是知道为什么了。但是具体到实际开发里的时候,还是总觉得很难每次只设置需要的数据啊,像对象里或者数组里的数据怎么办呢? 老李: 如果只改变了对象里或者数组里的一部分数据,可以通过类似 array[2].message , a.b.c.d 这样的 数据路径 来进行「精准设置」。另外,现在自定义组件也支持 纯数据字段 了,只要在自定义组件的选项中设置好名为 pureDataPattern 的正则表达式, data 中匹配这个正则的字段将成为纯数据字段,例如,你可以用 /^_/ 来指定所有 开头的数据字段为纯数据字段。所有纯数据字段仅仅被记录在逻辑层的 this.data 中,而不会被发送到渲染层,也不参与任何界面渲染过程,节省了传输的时间,这样有助于提升页面更新性能。 小赵:小程序还有这样的功能,受教了。不过说来说去,我还是想在小程序里用我顺手的框架来开发,毕竟这样事半功倍嘛。我在网上搜索了一下,发现现在有很多支持用 Web 框架做小程序开发的框架,但好像都是将模板编译成 WXML,最终由小程序来做渲染,但这样的方法好像兼容性也不是很好。我在想,我们能不能在逻辑层仿造一套 DOM 接口,然后在运行时将 DOM 调用适配成小程序调用? 老李: 你的这个脑洞有一些意思。在逻辑层仿造一套 DOM 接口,直接维护一棵 DOM 树,这当然没问题。但是没有代理 DOM 接口,逻辑层的 DOM 树没法反映到渲染层,因为渲染层具体会出现什么样的组件,是运行时才能知道的,这不就没法生成静态模板了? 小赵:静态模板确实是没法生成了,但我看到小程序的框架支持自定义组件,我是不是可以做一个通用的自定义组件,让它根据传入的参数不同,变成不同的小程序内置组件。而且自定义组件还支持在自己的模板中引用自己,那么我只需要一个这个通用组件,然后从逻辑层用代码去控制当前组件应该渲染成什么内置组件,再根据它是否有子节点去递归引用自己进行渲染就可以了。你看这样可行吗? [图片] 老李: 这样的做法确实可行,而且微信官方已经按照这个思路推出小程序和 Web 端同构的解决方案 Kbone 了。Kbone 的原理就像你刚才说的那样,它提供一个 Webpack 插件,将项目编译成小程序项目;同时提供两个 npm 包,分别提供 DOM 接口模拟和你说的那个通用的自定义组件作为运行时依赖。要不你赶紧试试? 小赵:还有这么好的事,那我终于可以用我喜欢的框架开发小程序了!这么好的框架,为什么不直接内置到小程序的基础库里呀? 老李: 因为这样的功能完全可以用现在已有的基础库功能实现出来呀。Kbone 现在是 npm 包的形式,使得它的功能、问题修复可以随着自己的版本来发布,不需要依赖于基础库的更新和覆盖率,不是挺好的吗? 小赵: 好是好,但我担心的是代码包大小限制的问题。除了我们已经写好的业务逻辑之外,现在还得加上 Kbone,会不会装不下呀? 老李: 原来你是担心这个呀,放心,Kbone 现在已经可以在 扩展库 里一键搞定啦。扩展库是帮我们解决依赖的全新功能,只要在配置项中指定 Kbone 扩展库,就相当于引入了 Kbone 相关的最新版本的 npm 包,这样就不占用小程序的代码包体积了,快试试吧! 小赵:哇,那可太爽了,马上就搞起! 最后 如果你对 Kbone 感兴趣或者有相关问题需要咨询, 欢迎加入 Kbone 技术交流 QQ 群:926335938
2020-01-14 - 如何打开线上版本小程序的调试模式
生产版本的小程序如果出现问题,可以调试一下正式版看看,调试方式如下: 方式1、https://developers.weixin.qq.com/miniprogram/dev/api/base/debug/wx.setEnableDebug.html [代码]wx.setEnableDebug(Object object) 基础库 1.4.0 开始支持,低版本需做兼容处理。 设置是否打开调试开关。此开关对正式版也能生效。 参数 Object object 属性 类型 默认值 必填 说明 enableDebug boolean 是 是否打开调试 success function 否 接口调用成功的回调函数 fail function 否 接口调用失败的回调函数 complete function 否 接口调用结束的回调函数(调用成功、失败都会执行) 示例代码 // 打开调试 wx.setEnableDebug({ enableDebug: true }) // 关闭调试 wx.setEnableDebug({ enableDebug: false }) Tips [代码] 方式2、先在开发版或体验版打开调试,再切到正式版就能看到vConsole
2019-11-29 - 在线考试小程序操作手册
前言 本文以考前练习册小程序为示例,具体讲述在线考试小程序如何使用,如何新建题库,如何批量导入题库,具体体验可以微信搜索 考前练习册 即可。 产品介绍 考前练习册小程序是一款可以提供在线考试、模拟练习的微信小程序,目前支持以下七项核心功能 在线考试 顺序练习 随机练习 我的收藏 我的错题 考试排名 我的成绩 具体介绍请移步下面文档 在线答题小程序使用帮助文档? - 微信开放社区 https://developers.weixin.qq.com/community/develop/article/doc/000c22e3f24fc8909189e48b151413 题库信息 所谓题库信息,也可以叫试题分类,就是将试题按照某种维度进行分类,比如目前考前练习册有两个题库,分别为 001 微信期末考 002 垃圾分类测评 其中001、002为题库编号,微信期末考、垃圾分类测评为题库名称。 由于考前练习册是一级题库结构,即题库名字下面就是试题 比如我们按照一级科目如下排列 语文 数学 英语 那么这下面就是具体的试题信息 试题类型存放在集合category里面,大家可以先打开小程序云开发控制台,切换到数据库面板,找到category集合进行查看 关于如何打开小程序云开发控制台请移步下面文章 如何打开云开发控制台? - 微信开放社区 https://developers.weixin.qq.com/community/develop/article/doc/000c2ab4a08d605a255915bca56013 建议在新增试题题库名字的时候,先将category集合导出为json文件,然后只保留一条记录,将其他记录都删掉,给这一条记录修改编号,修改名字,然后再导入 考试试题题库编号建议按照以下规则进行编码 001 002 003 试题信息 在有了题库信息之后,我们就要上传试题信息了,具体试题按照以下模板整理成excel文件 在线答题小程序批量导入模板文件详解? - 微信开放社区 https://developers.weixin.qq.com/community/develop/article/doc/000866b4a28e58a1172aa6b0451413 文件整理完成之后,使用下面的转换工具,将excel转成json文件 https://www.xiaomutong.com.cn/index20200301.html 在上面工具中,已支持以下几种试题类型 单选 多选 判断 填空 简答 其中对于单选、多选目前提供的工具支持四个选项、五个选项、七个选项的模板导入,其中七个选项的模板可以支持六个选项的情况 小程序云开发如何更优雅导入数据? - 微信开放社区 https://developers.weixin.qq.com/community/develop/article/doc/0000aa9f810b38f09b5acb40151013 云开发小程序数据导入示例? - 微信开放社区 https://developers.weixin.qq.com/community/develop/article/doc/000062f31b09804dbeca174355c813 试题集合为question,大家导入后可以按照题库名字进行核对,试题集合question是通过该集合里面的category字段进行关联某个题库名字的。 其他 未完待续 有其他疑问可以评论区留言,我会及时补充
2020-08-14