- 小程序支持sse吗,小程序如何实现接收sse流式数据?
小程序支持sse吗,小程序如何实现接收sse流式数据?
2024-07-17 - 【合单支付】常见问题
Q1:合单支付通知是否同时成功才会返回,如果只有一条子订单成功,另一条没有成功是否不会返回成功的通知 A1:合单支付,只有同成功或者同失败,不存在一笔成功一笔失败的情况,只有同成功才会有支付结果通知 Q2:合单支付接口是否支持代金券,优惠券,单品券等营销功能 A2:合单支付目前不支持营销功能 Q3:合单APP支付下单API接口中的参数:二级商户号(sub_mchid)有什么作用 A3:二级商户号(sub_mchid)就是服务商下的特约商户号,子单支付成功后,子单的钱就在这个商户号上。这个参数是必须填写的,跟分账与否没有关系 Q4:合单支付是否可以支持只有1笔子单? A4:服务商模式才允许 Q5: 合单支付是否支持相同商户号多笔子单? A5: 1)服务商模式不支持发起方和参与方是同一个商户号,参与方的子单可以是同一个商户号 2)直联模式支持发起方和参与方是同一个商户号,参与方的子单也可以是同一个商户号 Q6:合单接口,支持合单单号和子单单号传同一个值吗? A6:支持,但不建议这样传参 Q7:合单支付关单接口是否支持关闭部分子单? A7:不支持, 关单的主单商户号、单号、子单个数、子单商户号、子单单号必须与下单时完全一致。 Q8: 合单支付中传的订单失效时间最长可以多长? A8:最长90天,不传默认15天。 Q9:合单支付的金额字段上限是多少? A9:单个子单1000W Q10:同一商户号下合单支付子订单的out_trade_no用了之后能否在直连的v3普通支付里再次使用? A10:不可以,合单子单就等于普通支付订单,只是把多笔合到一起支付 了。 Q11:合单支付的时候会不会出现各个子单的交易状态不一致的情况?比如有的子单成功了,有些子单支付失败的情况? A11:合单支付,只有同成功或者同失败,不存在一笔成功一笔失败的情况。 Q12:合单支付中的combine_mchid、mchid、sub_mchid该怎么传? A12: 服务商模式下,combine_mchid合单商户号和子单商户号mchid都传服务商商户号,sub_mchid传二级商户号电商模式下,combine_mchid合单商户号和子单商户号mchid都传电商平台商户号,sub_mchid传二级商户号直连模式下,combine_mchid传合单发起方商户号,mchid传合单参与方商户号,无需传sub_mchid二级商户号 Q13:合单下单报错返回{"code":"PARAM_ERROR","message":"受理机构必须传入sub_mch_id"}如何解决? A13:请商户检查下单时传的combine_mchid和mchid是否一致。
2022-03-24 - 原生小程序自定义导航栏
微信原生小程序导航栏 navigation-bar 使用说明 [图片][图片] 1、安装插件,并在小程序开发工具中构建 npm [代码]npm i navigationbar-wx [代码] 2、app.js 中得 globalData 内写上如下: [代码]import { navigationbarWx } from "navigationbar-wx/config.js"; navigationbarWx.init({ title: "小程序默认名称", homePath: "/pages/index/index",//默认路径,如果相同可以忽略这个参数 }) //备注:init 可初始化的字段如下字段说明 [代码] 3、app.json 中配置插件 [代码]"usingComponents": { "navigationbar-wx": "navigationbar-wx" } [代码] 4、页面使用-传参如下说明 [代码]<!-- 可直接使用 --> <navigationbar-wx /> <!-- 也可传参使用 --> <navigationbar-wx title="" logo="" color="" bgColor="" bgImg="" iconColor="" hideHome="" hideSeat="" /> [代码] 字段说明 字段 必传 类型 默认值 说明 title 否 String 小程序默认标题 当前页面标题 logo 否 String 无 可传入一个图片 URL 作为标题,logo 优先级大于 title color 否 String #000000 字体颜色,默认黑色 bgColor 否 String #ffffff 导航栏颜色,默认白色 bgImg 否 String 无 导航栏整体图片,背景图优先级大于背景颜色 iconColor 否 String black 左边按钮颜色,只支持 black、white 两种类型 hideHome 否 Boolean false 当没有上一级页面时,是否隐藏小房子 hideSeat 否 Boolean false 导航栏底部占位,默认占位不隐藏 homePath 否 String /pages/index/index 页面如果设置这个参数优先级高于 init 时传入的路径 github 地址 github 地址
2024-06-17 - 关于新版隐私协议接口wx.onNeedPrivacyAuthorization的适配解读以及实现代码
官方公告地址: https://developers.weixin.qq.com/community/develop/doc/00042e3ef54940ce8520e38db61801 目前,开发工具或者体验版的小程序,调试基础库如果是2.33.0及以上就得适配了,线上版本9月15日之后生效,所以这之前需要尽快改完,发布一版,否则到了9月15号之后 线上就会生效报错了。 其实改起来也很简单,以下是实现步骤和代码: 1、首先看一下这个网址,里边包含涉及到的隐私的接口,这些接口都要适配一下 https://developers.weixin.qq.com/miniprogram/dev/framework/user-privacy/miniprogram-intro.html [图片] 在以上接口用到的页面,需要画一下类似上边的弹窗(这个弹窗可以全局定义个组件,方便多个页面共用),然后里边蓝字可以点击后调用wx.openPrivacyContract(Object object)接口即可,会自动跳转打开隐私协议页面。 拒绝按钮可以加一个点击事件,然后在事件里这样写 [图片] 同意按钮比较特殊,布局需要用button这样写,记得给button加一个Id [图片] 然后在handleAgreePrivacyAuthorization里就可以获取到点击事件,这样写 [图片] 2、最后需要在onLoad或者onShow里加上以下监听代码,在这里边让自定义的隐私弹窗显示出来即可。 [图片] 以上代码加上就可以了,如果业务逻辑用到了需要判断是否授权过,可以加上 wx.getPrivacySetting(Object object)去获取是否授权过,用不到可以不加这个判断。
2023-08-16 - 分账比例最高只有30%?是否还能更高?如何申请高比例分账?
关于高分账比例申请的渠道,可通过以下的流程进行咨询: 1、微信关注"腾讯客服"公众号,在公众号内进行提问或输入'人工'转人工解答 2、拨打微信支付服务热线(95017转2)
2024-09-13 - 小程序scroll-view翻转后 scroll-into-view的替代方案
背景 腾讯云医小程序有医患聊天会话的场景,由于会话场景存在查询历史消息的场景,小程序中按照常规思路加载历史消息时会出现跳动的问题;跳动的原因是由于在’顶部’插入dom,会使得后面的dom被往后面推,然后重新设置scroll-top或者scrol-into-view从而导页面出现跳动;我们尝试采用【 前端开发中聊天场景的体验优化】文章中的方案处理跳动的场景。该文章的核心观点将scroll-view元素通过设置css样式 transform: rotateX(180deg); 进行翻转,这样将历史消对应的dom结构放在尾部,当添加更多的历史消息(dom)时,由于dom是添加在尾部很优雅的绕过了插入历史消息跳动的场景。但是当我们按照这种方式实现后,发现scroll-view元素提供的scroll-into-view属性不好使了。因此有了本文通过计算scrollTop值设置scrollTop来达到相同目的。 复现该问题的小程序代码片段:代码片段 目前已经反馈给官方(官方已确认是内部组件实现暂不支持翻转的场景 基础知识介绍 计算scrollTop涉及到一些web和小程序的基础知识,后面针对这些基础点进行简单介绍 .scroll-into-view 微信小程序提供的scroll-view元素提供了属性 scroll-into-view,该属性的作用是可以将指定dom滚动到scroll-view可见区域内 [图片] 关于boundingClientRect 下图是MDN解释该属性时提供的,从下图中可以看到top/bottom/left/right的值是元素的左上角和右下角相对于视口左上角的水/垂直距离 [图片] 为了更深入理解这些值。给出了一个简易的demo(代码片段),获取实例元素的的boundingClientRect的值后,可以看到这些值是根据元素的border边界进行计算的 [图片] [图片] [图片] 值得注意的是,当元素处于一个滚动区域内部,left/top值是考虑滚动操作的即包含滚动距离的(参考MDN 另外,当我们把容器元素又或者元素自身设置 transform: rotateX/Y(180deg):不会导致top和bottom的值互换(left与right的值互换); 总会有这样的结论,当dom元素的宽度和高度不为0时,top值一定小于bottom值,left值一定小于right值 关于scrollTop 当一个容器的内容的高度大于其容器高度时,overflow不为visible/hidden时,则会出现滚动条。出现滚动条后,内容区域则可以滚动,此时scrollTop的值是容器可视区域的顶部到内容区域顶部的距离,见下面示意图。 [图片] 值得注意的是,滚动条出现在盒模型中的content区域,见下图滚动条不会覆盖padding/border部分。因此上面说到内容区域高度超过容器高度并不严谨,严谨的说法应该是超过容器的content区域的高度。 [图片] 如果此时给容器设置css样式: transform: rotateX(180deg); 即沿垂直方向翻转180度,scrollTop的值会发生变化吗。 下面我们看下实际的对比效果,为了方便查看滚动条的效果,给滚动条轨道(红色部分)以及滑块(黑色部分)添加了背景色,发现整个元素包括滚动条在内一并进行了翻转。 正常情况(左侧),应用翻转css样式(右侧) [图片] [图片] 翻转后的scrollTop值示意图 [图片] 通过计算scrollTop值来模拟scroll-into-view效果(针对scroll-view翻转的场景j) 由于boundingClinetRect的值是包含border边界的,因此当数据项包含padding,border等区域不会影响这里的计算过程,可以认为下面示意图中的数据项部分的边界是border边界; 由于滚动条是出现在content区域,因此容器元素的的border-top/padding-top不为0时,会影响计算流程,因此这里分为两种情况进行介绍: 2.1 假设scroll-view元素的的border-top/padding-top为0 2.2 假设scroll-view元素的的border-top/padding-top不为0 border-top/padding-top为0的情况 为了方便说明计算过程,我定义三种状态,初始态、中间态、最终态 示意图中的区域说明 白色背景的为视口, 绿色背景的是容器(scroll-view)的可视区域, 灰色区域是内容区域,并且内容区域的高度超过了容器的高度, 红色区域是一个数据项 [图片] 现在的目标是将数据项从初始态滚动到最终态即scroll-into-view的效果:border的上边界与可视区域上边界对齐 第一步:从初始态达到中间态 根据上面关于scrollTop的描述,这里如果scrollTop的值是targetDistance即数据项的底部到内容区域的底部的距离,就可以达到中间态,因此现在的目标是求targetDistance 初始状态的已知变量 初始状态下的的scrollTop值:currentScrollTop (由于容器发生翻转,所以scrollTop视觉上指向容器下方) 数据项的boundingClientRect.bottom为 itemBottom 容器的boundingClientRect.bottom为 contianerBottom 通过示意图很容易得出 [代码]targetDistance = currentScrollTop + (containerBottom - itemBottom) [代码] 第二步:从中间到达最终态 已知变量:容器高度:containerHeight、数据项高度:itemHeight 最终态是数据项的顶部距离容器顶部,从示意图中看到中间态到最终态的scrollTop是减少了的,减少的值其实就是cotainerHeight - itemHeight 经过第一步和第二步我们就可以得到scrollTop的计算公式 [代码]let itemScrollTop = currentScrollTop + containerBottom - itemBottom itemScrollTop -= (containerHeight - itemHeight) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - (containerHeight - itemHeight) [代码] border-top/padding-top不为0的情况 [图片] 根据上面第一种情况的介绍的思路,很容易得到下面结果,不再赘述(X 就是容器padding-top + border-top的值) [代码]let itemScrollTop = currentScrollTop + containerBottom - itemBottom - X itemScrollTop -= (containerHeight - itemHeight - X) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - X - (containerHeight - itemHeight - X) => itemScrollTop = currentScrollTop + containerBottom - itemBottom - (containerHeight - itemHeight) [代码] 【结论】两种情况最终的计算过程是一样的,因此在实现的过程中不需要进行区分 代码实现 代码片段见:https://developers.weixin.qq.com/s/y1X11dmr7AqC 视图层代码 [代码]{{item.content}} #scroll-view { position: absolute; top: 50px; bottom: 50px; width: 100%; background-color: rgba(0, 0, 0, 0.1); // 关键case transform: rotateX(180deg); } [代码] 逻辑层核心代码 [代码]scrollTo () { const itemId = '#item_id_50' const containerId = '#scroll-view' Promise.all([this._queryBoundingClient(itemId), this._getScrollInfo(containerId)]) .then((res = [[[{}]], {}]) => { const [[[ { bottom: itemBottom, height: itemHeight }]], { bottom: containerBottom, scrollTop, height: containerHeight }] = res let itemScrollTop = containerBottom - itemBottom + scrollTop itemScrollTop -= (containerHeight - itemHeight) this.setData({ scrollTop: itemScrollTop }) }) }, _queryBoundingClient (selector) { // 获取目标dom的相关位置/尺寸信息 return new Promise(resolve => { const query = this.createSelectorQuery(); query.selectAll(selector).boundingClientRect(); query.exec(resolve); }) }, _getScrollInfo (idSelector) { // 用来获取容器层相关位置/尺寸信息 return new Promise(resolve => { const query = this.createSelectorQuery() query.select(idSelector).boundingClientRect() query.select(idSelector).scrollOffset() query.exec((res = [{}, {}]) => { const [{ top, bottom, height }, { scrollHeight, scrollTop }] = res const scrollInfo = { scrollTop, scrollHeight, top, bottom, height } resolve(scrollInfo) }) }) } [代码]
2023-03-23 - onShow与onLoad的一些理解和实践
基本介绍onShow、onLoad与onReady都是小程序页面生命周期函数。 onLoad 在页面加载时调用,仅一次; onShow页面显示/切入前台时触发,两个生命周期非阻塞式调用。 onReady 是页面初始化数据已经完成后调用的,并不意味着onLoad和onShow执行完毕。 调用顺序是onLoad > onShow > onReady 根据对应的执行机制,我们预期有三种执行的逻辑 A. 页面每次出现都会执行 从其他页面返回手机锁屏唤醒,重新看到小程序页面把当前小程序页面重写切换到前台(多任务)B. 页面加载后只需执行一次(页面第一次载入) C. 只在页面非第一次执行时才执行(A情况的子集,页面非第一次展示时) 需求与问题逻辑1: 因为onLoad和onShow是非阻塞执行的,当我们有一个这样的需求:页面载入执行A方法,页面展示执行B、C、D方法时,A需要在BCD之前执行,此时把A放在onLoad中,BCD放在onShow中就无法实现需求 逻辑2: 还有一种需求是:页面第一次执行A,非第一次执行R-A,这里onLoad和onShow并没有非第一次的逻辑,需要手动判断。 一种实践方法下面是纯粹使用onShow代替onLoad,完成所有逻辑的示例,保证了业务逻辑的执行顺序可控。 options获取使用其他方式代替。 为了保持onShow中逻辑的清晰性,尽量使用EventChannel去替代原本onShow+globalData的逻辑。 data:{ first: true }, async onShow(){ //代替onLoad中的options的获取 const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; const options = currentPage.options; this.funD() // C2 页面每次都调用的逻辑 if(this.data.first){ this.data.first = false; await this.funA(); //A 仅在页面初次调用的逻辑(按需是否阻塞调用) }else{ await this.funB(); //B 仅在页面非初次时调用的逻辑 } await this.funC(); //C1 页面每次都调用的逻辑 } 另外一种使用实践data:{ first: true } onShow(){ this.funD() //页面每次都调用的逻辑(仅非阻塞) if(!this.data.first){ this.funC() //仅在页面非初次时调用的逻辑 } await this.funE() //页面每次都调用的逻辑(可阻塞,可非阻塞) }, onLoad(){ //仅在页面初次调用的逻辑 this.funA(); await this.funB(); } onReady(){ this.data.first = false; } 如有错误,恳请指出。
2022-09-23 - 小程序开发常用的几个NPM包
小程序开发常用的几个NPM包 ~ 1)js-base64 场景 平时在做支付的时候会做一下加解密的工作,用于数据的验证用 [图片] ~ 示例 [图片] ~ 2)jsencrypt 场景 目前的应用场景是在用户注册或登录的时候,用公钥对密码进行加密,再去传给后台,后台用私钥对加密的内容进行解密,然后进行密码校验或者保存到数据库。 [图片] ~ 示例 [图片] ~ 3)urijs 场景 日常操作组装链接使用 [图片] ~ ~ 示例 buildQuery [图片] ~
2022-09-28 - 小程序中better-mock的使用
背景 小程序开发者工具本身自带api mock工具,但是在真机调式的时候mock无法生效,所以想寻找一种可真机调试的mock方案。 better-mock介绍 web端有很好用的mockjs,但已经停止维护了。better-mock在mockjs的基础上改进,并兼容了小程序。原理上就是对小程序的request方法拦截,并返回自定义的mock数据。 如何使用 1.可以使用npm直接安装better-mock,或者直接下载release文件,然后引入。 [代码]const Mock = require('better-mock/dist/mock.mp.js') // 引入better-mock Mock.mock('http://example.com/path/to', { // mock接口 phone: '@PHONE' }) wx.request({ url: 'http://example.com/path/to', success (res) { console.log(res.data.phone) // 13687529123 } }) [代码] 2.使用方法与mockjs基本一致 [代码]Mock.mock('/mock_url', 'post', { 'list|1-10': [{ 'id|+1': 1, 'email': '@EMAIL' }] }) [代码] 返回数据如下: [代码]{ "statusCode":200, "list": [ { "id": 1, "email": "g.tapnqamfka@tiboen.ad" }, { "id": 2, "email": "x.sdha@ysyudl.dm" } ] } [代码] 参考文档:https://lavyun.github.io/better-mock/document/miniprogram.html#介绍 Tips 实际使用过程中还遇到了一些其他的问题: 1.测试时需要返回200以外状态码,测试异常系。除了statusCode,还需要返回自定义的code。 解决方案:修改了mock.mp.js文件(8427行左右),mock时传入statusCode,再修改返回值,确保可以方便地调整返回状态码。同理,可以自定义返回的数据结构。 [图片] 传入示例如下: [图片] 2.单页面请求多个服务器,只想启用部分接口的mock 解决方案: 不同服务器使用单独的mock文件,在app.js中根据需要启用不同mock。每个文件增加单个接口mock是否启用的控制。 启用不同服务器mock: [图片] 通过config.POST_TEST控制单个接口是否启用: [图片]
2022-10-08 - 通过WXS实现回弹的平滑滚动容器
前言 最近在愉快的开发微信小程序的时候碰到了一个体验需求,需要在 Android 侧的滚动也需要带回弹效果,类似于在 Web 端可以使用的 better-scroll,查阅微信小程序内置组件 [代码]scroll-view[代码] 无法满足这种场景,没办法,需求得做呀,只能自己动手撸了! 在微信小程序中,我们可以通过 WXS响应事件 来替代逻辑层处理从而有效的提高交互流畅度,其中使用到的 WXS语法 也是非常类似我们非常熟悉 JavaScript,不过很多的 JavaScript 高级语法在 WXS 模块中不能使用,具体可以点击链接进入微信小程序提供的文档。 思路 以横向滚动为例,内容的宽度大于容器的宽度时可以发生滚动,如图 [图片] 接着通过监听三个触摸事件[代码]touchstart[代码]、[代码]touchmove[代码]、[代码]touchend[代码]来实时的改变 content 的 CSS translate,从而从视觉上达到滚动的目的。 WXS 示例 我们先从一个简单的 WXS 使用示例来了解回顾一下使用方式,WXS 的模块系统类似 CommomJS 规范,使用每个模块内置的 [代码]module[代码] 对象中的 [代码]exports[代码] 属性进行变量、函数导出: [代码]// helper.wxs module.exports = { // 注意 WXS 模块中不支持函数简写 touchstart: function touchstart() { console.log('touchstart called') } } [代码] [代码]<!-- index.wmxl --> <!-- module 为模块名,可按规范任意取名 --> <wxs src="./helper.wxs" module="helper" /> <!-- 与普通的逻辑层事件不同,这里需要加上 {{}} --> <view bind:touchstart="{{ helper.touchstart }}">view</view> [代码] 这样就给 [代码]view[代码] 绑定了一个 [代码]touchstart[代码] 事件,在事件触发后,会在控制台打印出字符串 "touchstart called" 好了,现在正式进入滚动容器的逻辑实现 开工 新建 [代码]scroll.wxml[代码] 文件,准备符合上图中结构的 WXML 内容来构造出一个正确的可以滚动条件 [代码]<!-- scroll.wxml --> <!-- 即图中的 container --> <view class="container" style="width: 100vw;"> <!-- 即图中的 content --> <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 新建 [代码]scroll.wxs[代码] 文件,里边用于存放我们实现滚动的所有逻辑 接下来进行初始化操作,首先需要获取到 container 和 content 组件实例,在上一节 “WXS 示例” 中我们知道可以通过在组件中触发一个事件来调用 WXS 模块中的方法,但有没有什么方式可以不用等到用户来触发事件就可以执行吗? 通过阅读 WXS 响应事件 文档,可以了解到,另外一种调用 WXS 模块方法就是可以通过 [代码]change:[prop][代码] 监听某一个组件的 Prop 的改变来执行 WXS 模块中指定的方法,且这个方法会立即执行一次,如下面一个示例 [代码]// helper.wxs module.exports = { setup: function setup() { console.log('setup') } } [代码] [代码]<!-- index.wxml --> <wxs src="./helper.wxs" module="helper"></wxs> <!-- 例如我们指定一个 prop 为 prop1,值为 {{ prop1Data }} --> <!-- 通过 change:prop1 语法对这个 prop 的变化进行监听 --> <view prop1="{{ prop1Data }}" change:prop1="{{ helper.setup }}"></view> [代码] [代码]// index.js Page({ data: { prop1Data: {} } }) [代码] 上面示例中,在页面初始化或 [代码]prop1Data[代码] 发生改变时(准确来说是在逻辑层对 [代码]prop1Data[代码] 调用了 [代码]setData[代码] 方法后,即使 [代码]prop1Data[代码] 的内容不变化),都会调用 [代码]hepler.wxs[代码] 模块中的 setup 方法。 现在我们可以通过 [代码]change:prop[代码] 会立即执行一次的特点,来对我们的滚动逻辑进行一次初始化操作 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } [代码] [代码]<!-- scroll.wxml --> <wxs src="./scroll.wxs" module="scroll" /> <!-- 因本案例只利用 change:[prop] 首次执行的机制,传递的给 _ 的参数是个对象字面量 --> <view class="container" style="width: 100vw;" _="{{ { k: '' } }}" change:_="{{ scroll.setup }}" bind:touchstart="{{ scroll.touchstart }}" bind:touchmove="{{ scroll.touchmove }}" bind:touchend="{{ scroll.touchend }}" > <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 完成基本的跟随手指移动 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } // 实时记录 content 位置 var pos = { x: 0 } // 记录每次触摸事件开始时,content 的位置,后续的移动都是基于此值增加或减少 var startPos = { x: 0 } // 记录触摸开始时,手指的位置,后续需要通过比较此值来计算出移动量 var startTouch = { clientX: 0 } function setTranslate(pos0) { slidingContainerInstance.setStyle({ transform: 'translateX(' + pos0.x + 'px)' }) pos.x = pos0.x } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x } exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX setTranslate({ x: x }) } exports.touchend = function touchend() {} [代码] 效果图: [图片] 处理松手后移动超出的情况,需要对其归位: 添加 clamp 工具方法 [代码]// 给出最小、最大、当前值,返回一个在最下-最大范围之间的结果 // 如: -100, 0, -101 => -100 function clamp(min, max, val) { return Math.max(min, Math.min(max, val)) } [代码] 在 touchend 事件中,添加位置校验的逻辑 [代码]// scroll.wxs exports.touchend = function touchend() { setTranslate({ x: clamp(minTranslateX, maxTranslateX, pos.x) }) } [代码] 看看效果: [图片] 回去是能回去了,有点生硬~ 加上松手回弹动画 其中动画可以使用两种实现方式 CSS Transition:在松手后,给 content 元素设置一个 [代码]transition[代码],然后调整 [代码]translateX[代码] 值归位 JS 帧动画:在松手后,利用动画函数不断调整 [代码]translateX[代码] 来进行归位 两种方式通过给相同的动画函数可以达到一样的体验,但 CSS Transition 在我的理解中不太好处理中止的情况,如在动画过程中,又有了新的触摸事件,这里就会产生抖动或未预期到的结果,但 JS 动画可以很简单的应对 因此后续的动画部分打算采用 JS 动画实现,先准备一些动画函数 [代码]// scroll.wxs // 下面内容通过 better-scroll 借鉴 ~ // 可以理解为入参是一个 [0, 1] 的值,返回也是一个 [0, 1] 的值,用来表示进度 var timings = { v1: function (t) { return 1 + --t * t * t * t * t }, v2: function(t) { return t * (2 - t) }, v3: function(t) { return 1 - --t * t * t * t } } [代码] 定义 [代码]moveFromTo[代码] 方法来实现从一个点通过指定的动画函数运动到另一点 [代码]// scroll.wxs /** * @param fromX 起始点xx * @param toX 目标点 x * @param duration 持续时长 * @param timing 动画函数 */ function moveFromTo(fromX, toX, duration, timing) { if (duration === 0) { setTranslate({ x: fromX }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } } ownerInstance.requestAnimationFrame(rAFHandler) } } [代码] 调整 touchend 事件处理逻辑,添加归位的动画效果 [代码]// scroll.wxs exports.touchend = function touchend() { moveFromTo( pos.x, clamp(minTranslateX, maxTranslateX, pos.x), 800, timings.v1 ) } [代码] 看看效果: [图片] 看起来达到了目的,再优化一下,在滑动超出边界后,需要给一些阻力,不能滑的“太简单了” 给超边界的滚动加阻力 [代码]// scroll.wxs exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX // 阻尼因子 var damping = 0.3 if (x > maxTranslateX) { // 手指右滑导致元素左侧超出,超出部分添加阻尼行为 x = maxTranslateX + damping * (x - maxTranslateX) } else if (x < minTranslateX) { // 手指左滑导致元素右侧超出,超出部分添加阻尼行为 x = minTranslateX + damping * (x - minTranslateX) } setTranslate({ x: x }) } [代码] 瞅瞅: [图片] 效果达到了,手指都划出屏幕了,才移动了这么一点距离 到现在已经完成了一个带回弹效果的滚动容器,但还没有做到“平滑”,即在滑动一段距离松手后,需要给 content 一些“惯性”来继续移动一些距离,体验起来就不会那么生硬 加滑动惯性 在这之前,还有一些准备工作需要做 [代码]// scroll.wxs // 记录触摸开始的时间戳 + var startTimeStamp = 0 // 增加动画完成回调 + function moveFromTo(fromX, toX, duration, timing, onComplete) { if (duration === 0) { setTranslate({ x: fromX }) + ownerInstance.requestAnimationFrame(function() { + onComplete && onComplete() + }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) + } else { + onComplete && onComplete() + } } ownerInstance.requestAnimationFrame(rAFHandler) } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x + startTimeStamp = event.timeStamp } [代码] 因为是在松手后加动量,所以继续处理 touchend [代码]// scroll.wxs exports.touchend = function touchend(event) { // 记录这一轮触摸动作持续的时间 var eventDuration = event.timeStamp - startTimeStamp var finalPos = { x: pos.x } var duration = 0 var timing = timings.v1 var deceleration = 0.0015 // 计算动量,以下计算方式“借鉴”于 better-scroll,有知道使用什么公式的朋友告知以下~ var calculateMomentum = function calculateMomentum(start, end) { var distance = Math.abs(start - end) var speed = distance / eventDuration var dir = end - start > 0 ? 1 : -1 var duration = Math.min(1800, (speed * 2) / deceleration) var delta = Math.pow(speed, 2) / deceleration * dir return { duration: duration, delta: delta } } // 此次滑动目的地还在边界中,可以进行动量动画 if (finalPos.x === clamp(minTranslateX, maxTranslateX, finalPos.x)) { var result = calculateMomentum(startPos.x, pos.x) duration = result.duration finalPos.x += result.delta // 加上动量后,超出了边界,加速运动到目的地,然后触发回弹效果 if (finalPos.x > maxTranslateX || finalPos.x < minTranslateX) { duration = 400 timing = timings.v2 var beyondDis = containerRect.width / 6 if (finalPos.x > maxTranslateX) { finalPos.x = maxTranslateX + beyondDis } else { finalPos.x = minTranslateX + beyondDis * -1 } } } moveFromTo(pos.x, finalPos.x, duration, timing, function () { // 若动量动画导致超出了边界,需要进行位置修正,也就是回弹动画 var correctedPos = { x: clamp(minTranslateX, maxTranslateX, pos.x) } if (correctedPos.x !== pos.x) { moveFromTo( pos.x, correctedPos.x, 800, timings.v1 ) } }) } [代码] 继续看看效果: [图片] 有了有了 只是现在的滚动容器还很“脆弱”,在进行动量动画、回弹动画时,如果手指继续开始一轮新的触摸,就会出现问题,也就是最开始我们在选择 CSS 过渡和 JS 动画考虑到的问题 解决连续触摸滑动问题 在 [代码]moveFromTo[代码] 方法中,添加强制中止的逻辑 [代码]// scroll.wxs + var effect = null function moveFromTo(fromX, toX, duration, timing, onComplete) { + var aborted = false if (duration === 0) { setTranslate({ x: fromX }) ownerInstance.requestAnimationFrame(function () { onComplete && onComplete() }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { + if (aborted) return var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } else { onComplete && onComplete() } } ownerInstance.requestAnimationFrame(rAFHandler) } + if (effect) effect() + effect = function abort() { + if (!aborted) aborted = true + } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x startTimeStamp = event.timeStamp + if (effect) { + effect() + effect = null + } } [代码] 体验一下: [图片] 这样一个带回弹的平滑滚动容器就处理的可以使用啦,有问题的地方欢迎大家指出讨论 结尾 完整源码托管在 Github 中:weapp-scroll 其中功能、逻辑更为完善,并同时支持横向、竖向方向的滚动,适合在 Android、PC 场景的使用(毕竟 IOS 侧可以直接使用微信内置组件 [代码]scroll-view[代码]~)。若有帮到希望可以给个星星~ 完~
2023-07-07 - 小程序端会话场景下长列表实现
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 - (15)真机定位问题技巧
开发者在开发小程序的时候可能会碰到一些这样的问题: 问题1 开发者工具上看效果没问题,但是在真机上测试不行? 问题2 有用户遇到小程序功能无法使用的问题,但无法快速定位解决? 今天我们的小故事与大家分享一些真机定位的技巧,可以解决上面两个问题。 1 vConsole开发利器和远程调试功能 针对问题1,我们提供了 vConsole 开发利器和远程调试功能,可以协助开发者在定位真机上的问题。 vConsole 的有四个Tab面板,可以先看下 Log 面板,看是否有异常信息,异常类型 thirdScriptError 是框架捕捉到的开发者的代码执行的异常,可以优先处理异常信息看是否可以解决问题。Log 面板可以看到异常出现的文件和行数。 [图片] 除了异常日志,开发者还可以通过 console.log 接口在一些关键执行路径上打日志来定位问题,这些日志会呈现在 Log 面板上。 vConsole 默认是不开启的,可以通过下面2个方法来开启: 1 开发版和体验版可以点击小程序页面右上角的...按钮打开的菜单项“打开调试”来开启 vConsole。 2 正式版没有“打开调试”的菜单项,可以先通过开发版和体验版来开启 vConsole,然后再打开正式版。或者可以预埋一个隐藏操作,比如连续点击某个 Button 多次,然后调用 API 接口 wx.setEnableDebug 来打开。 vConsole 虽然强大,但在手机上查看大量的日志信息不方便,此外,vConsole 没有断点调试、无法修改样式,定位复杂问题需要花费比较多的时间。 小程序的业务逻辑运行在 AppService 层,页面渲染在 WebView 运行,并通过微信客户端通信,因此,我们想到了可以让 AppService 运行在开发者工具,页面渲染还是在手机 WebView,两者通过网络来通信,这样借助开发者工具的调试能力,就可以实现远程调试功能。 远程调试窗口通过手机客户端扫描开发者工具上生成的二维码来打开,无需像普通手机 H5 页面调试一样,需要在手机端进行一些设置。 [图片] 打开的远程调试界面和开发者工具的模拟器的调试界面很像,需要注意的是,要在 Console 里对小程序进行调试,需要将调试的上下文切换到 VM Context 1 。 [图片] 更多的远程调试的使用方法请参考使用文档。 2 意见反馈能力 对于问题2,小程序的使用反馈来自用户投诉,这种情况用户无法联系到开发者。我们遇见过有小程序功能出现问题,用户无法使用,但投诉无门的情况,而这些问题,开发者也没有途径去收集以及处理,这就导致了小程序服务质量下降,用户流失。 为此,我们开发了“意见反馈”功能,当出现问题时,开发者可以引导用户使用“意见反馈”进行反馈,并上传日志来辅助开发者定位问题。操作过程如下: 引导用户进入小程序帐号详情页面,具体可以在小程序界面点击右上角...按钮,选择关于菜单。接着在帐号详情页面点击右上角...按钮,选择意见反馈菜单进入页面。页面可以上传图片和日志,建议用户上传异常情况的截图,以及勾选允许开发者使用小程序日志选项上传日志,反馈信息越详细,越有助于定位问题。 [图片] [图片] 如果觉得上面的操作步骤太麻烦,开发者可以通过在页面 WXML 添加下面的按钮,用户点击按钮可以直接打开“意见反馈”页面。 [图片] 开发者需要定时处理用户的反馈,这样才能保证小程序的质量。开发者可以登录小程序管理后台,进入左侧菜单客服反馈,就可以看到用户的反馈内容以及下载日志来辅助定位问题。 [图片] 为了保证日志信息足够详细,开发者需要用下面的接口在代码的关键执行路径上写日志。 [图片] wx.getLogManager 接口的更详细使用请参考文档。 希望通过这些小技巧,可以帮助大家顺畅地开发小程序。
2019-04-29