- 使用骨架屏
[视频] 你好,我是李艺。 上节课我们主要从整体上讲述了小程序的启动流程及双线程的运行机制,简述了小程序在启动流程中的一些优化要点以及运行时的视图渲染优化要点。 这节课我们从一个实战项目开始,正式开始优化技巧的学习与实践。 首先我们学习骨架屏技巧的一个使用。 骨架屏顾名思义是展示一个页面骨架而不含有实际的页面内容,给用户的感觉是数据正在紧张的加载,真实数据马上就可以呈现。从渲染效率上来讲,骨架屏它并不能使首屏渲染加快。由于骨架屏的一些使用又向用户渲染了额外的一些内容,这些内容是额外添加的、本来是不需要渲染的,它反而从整体上加长了首屏渲染的一个时长。但是骨架屏在这个页面白屏的时候,它给了用户及时的反馈,减缓了用户焦急等待的一个情绪,这是它的意义所在。 在小程序启动流程的第三阶段即首页渲染阶段,在使用真正的数据渲染页面之前也就是在这个Page.onLoad事件派发之后有一个空档期,这个时候这个视图页面它已经开始展示了,但因为动态数据还没有到,页面往往产生为白屏,动态数据的数据量加载越多,空档期它就会越长、白屏现象也会越严重。 在我们的实际的项目里面,主页在这个页面开始显现之前有明显的白屏现象,在主页里面有一个很长的列表,列表渲染出来之前,下方列表的位置也有轻微的一个白屏现象,这些现象稍后我们在演示的时候都会看到。 为了避免白屏现象的一个出现,我们可以这样优化:开发者可以在这个数据完成加载之前使用骨架屏和Loading提示,在这个数据完成之后将骨架屏和Loading做不渲染的一个处理,再展示真正的一个页面内容。 一般具体的做法是这样:当前这个页面里面,数据源对象里面我们加一个loading提示这样的变量初始值设置为true,数据加载完成以后再将这个变量通过setData方法设置为false,在WXML这个页面里面我们通过loading变量切换骨架屏内容以及Loading提示还有真正页面内容的一个显示,为主页添加上骨架屏以后,从用户的体验这个角度来看,他这个感觉已经好了很多。现在我们在屏幕上看到了,这个就是最终的一个骨架屏渲染的一个效果。 现在我们开始代码演示。首先看一下我们的项目,现在我们看到的微信开发者工具展示的就是我们的优化项目的初始的状态,然后为了更好地编辑这个项目,我同时还开了另外的一个编辑器,就是VSCode。VSCode下面在这个project下面有一个miniprogram,这个目录下也就是我们的小程序项目。我们同时会用这两个编辑器去编辑我们项目的一个代码,在这个里面我们看到模拟器里面已经展示了三个页面,默认展示的这是主页,然后前面有一个商品页,最后面一个我的是用户主页,一共这三个页面。稍后我们这个课程里面会逐渐对这个项目进行一步步的一个优化,让它结构以及性能都会一步一步地变好。 首先我们看一下我们这个项目首页在加载的时候它有什么样的一个问题。单击编译看一下它的一个表现效果,我们看到这个页面有长期的一个白屏并且上面这个图加载以后,下面列表区域在渲染之前与完全渲染之间也有一个很长时间白屏,可以明显地可以感觉到。 接下来我们就用骨架屏这个技巧对它进行一个优化,这个页面在完成渲染以后,我们在模拟器这个区域下方有三个点,单击这个菜单会有一个辅助菜单,上面选择生成骨架屏,然后它会有一个提示,单击确定,我们主页这个是在index目录下面,然后在这个地方,这个里面有一个子目录,这是我们的一个主页面。我们现在看到这个目录下面多出来的两个文件,以index开头了,然后中间加了一个skeleton,这个是骨架屏的一个意思。同时我们这个页面里面 这个代码也会有所修改,我们看一下这个代码,这两个文件是它默认生成的一个代码:一个是wxml,下面这个是它的样式代码,这两个代码我们再重新编译一下这个项目看一下它的现在的一个表现。 现在骨架屏代码已经生成了但是还没有在我们主页这个页面上,没有去应用上去,怎么去应用? 首先我们看样式代码,这里面有提示,我们需要将这个代码给它拷贝一下,在我们index.wxss这个页面里面,在这个上面,将这个代码给它拷贝在这个地方,这是添加了样式。 然后接下来在这个视图页面里面,还有把这个代码也给它拷贝一下放在我们主页的视图页面里面,放进来这个地方,这有一个loading,loading我们现在还没有,需要在我们js这个页面里面,在data数据对象里面加上loading,默认给了一个值是true 默认能显示。 另外我们还需要有一个loadingTip提示语,再加一个额外的提示语:页面正在加载,放在我们的页面里面。可以放在这个地方,然后用这个数据绑定的语法把它放在这个,就是小胡子语法,然后这个地方我们也要给它一个渲染的控制。拿了一个wx:if if loading等于true的时候进行渲染,这两个内容它会随着变量的一个改变,会同时显示或隐藏,loading开始是true 什么时候给它改成false。在我们这个代码里面我们要看一下有一个关于数据加载,就是列表数据加载,在onReady里面有一个 request的一个数据接口的一个调用,在success回调里面,最后面这个地方 我们在这个地方设置this.setData,然后loading等于false,这样我们这个代码已经全部写好了,骨架屏也已经安排好了。 现在我们重新编译,测试一下它的效果,我们看到在真正的数据加载之前这个地方是有一个骨架屏的效果的,但是我们这个效果好像有一点点不是很美观对吧,特别是在我们列表的数据真正的加载完成并渲染之前,我们会看到它上面会有一些重叠,对不对? 这个其实是骨架屏和我们真实的页面它的一个处理,我们可以这样来处理,下面这不有一个container,这是另外的一个地方,这个地方我们可以加一个else,它和上面是一个互斥的一个关系,然后我们再看一下它的一个表现。等到这个数据加载完成以后,然后再开始真实的一个页面的显示。 这是我们看到一个效果,改变以后整体上一个效果比原来稍好了一点。但是应该我们也注意到,本质上我们这个页面的整体的加载时间并没有减少,然后用户在看到骨架屏这个时间仍然是等了很长时间,骨架屏它只是缓解了我们用户等待的一个焦虑情绪,它其实并没有从根本上去将我们启动时间减少。 最后总结一下我们应该如何使用骨架屏以及在哪个时间节点使用骨架屏呢? 至少有三点需要注意: 第一点在data数据对象中默认设置loading等于true。 给骨架屏使用的loading变量,它默认就写到data数据变量这个里边,它在initDataSendTime这个时间节点它会发送到这个视图层,然后进行渲染,从后端接口动态加载的一个数据这个动作要放在Page.onLoad这个周期函数里面甚至更早的周期函数里面去执行。一等这个数据加载完成了立马就将这个数据塞到数据源对象里边,同时将loading设置为false,消除骨架屏的一个渲染,这是第一点。 第二点就是不要直接修改生成的骨架屏的一个代码。 现在我们在屏幕上看到一个网址,这个网址就是官方的骨架屏的一个文档。从这里面我们可以看到骨架屏功能它有loadingtext.color等配置节点,在这个我们项目里面,有一个就是project.config.json这个配置文件。这个文件里面我们可以看到如我们现在屏幕上展示的这些配置字段,它可以配置骨架屏的一个默认样式以及它的一个默认行为,这些行为其中就包括动画行为,其中global节点控制所有页面的一个骨架屏风格,pages节点下面还可以分别控制每个页面的一个个性设置,注意修改配置以后要重新生成骨架屏代码。 微信开发者工具在生成时它会读取这些配置,开发者不要直接去修改生成的骨架屏的代码,如果是有个性控制的一个需要,我们可以通过修改这个配置然后进行控制,这样方便我们在多个骨架屏页面保持统一的一个风格。还有就是后续如果我们这个页面,就是我们主页它的WXML这个代码甚至它的JS逻辑代码如果有修改的话,还需要重新去生成骨架屏代码,开发者工具它是不会帮助我们自动去更新这个页面的。 第三点就是不要过度去使用骨架屏。 既然这个骨架屏它可以提高用户体验,那么我们为每一个小程序页面都添加一个骨架屏的效果 ,这样不是可以吗?有人可能会这样想对吧,一般不这样做。 一般我们只给主页去添加骨架屏效果,骨架屏它是小程序提供的一种优化用户体验的一个机制,但其实任何的一个渲染都有消耗,骨架屏也是。骨架屏本质上它是一个模板,我们可以看到它本质上是一个WXML文件,在主页里面引用以后其实引用的是一个template的模板,它和我们开发者自定义这个模板本质上没有区别。如果是在骨架屏里边我们再写了复杂的一些节点以及动画效果的话,反而不利于我们整体上小程序首页的一个快速加载和渲染。 一般我们推荐的方案是这样的,仅使用微信开发者工具,它默认生成的骨架屏,如果要修改的话也仅是修改一下,通过我们配置修改一下它整体上的一个样式,这些代码它里面的动画效果一般也不要去使用。页面布局改动以后,再重新根据我们源的页面再生成一下骨架屏这个页面代码。 好,这节课我们就讲到这里。这节课我们主要学习了骨架屏的使用相对简单。在微信开发者工具的模拟器里面基于已经渲染的页面直接就可以快速生成骨架屏代码了。我们只需要在这个页面的data数据对象里面添加一个loading变量将其设置为true,并且在动态数据加载完成以后再将loading变量设置为false,这样就可以了。 但是我们也应该注意不能无节制地去使用骨架屏,还有给骨架屏组件使用的变量,我们在Page.onLoad这个事件派发之前要准备好,最好是直接写在data数据对象里面。下节课我们学习如何优化视图页面里面的长内容列表。 这里最后有一个问题留给你思考一下,对于传统的瀑布流页面,页面它几乎是无穷尽的。你越往下滑它DOM节点越多,而在小程序的页面里面,标准里面又说WXML这个节点,它建议不超过1000个。那么对于这种传统的瀑布流类型的这种页面我们到底应该怎么渲染呢? 这个问题先留给你思考一下,下节课我们一起来深入探讨一下这个问题。 点击查看文档
2022-07-13 - 小程序基础库 3.0.0 更新
各位微信开发者: 小程序基础库 3.0.0 已经开始灰度开发者,请大家基于业务情况关注相关变更。如遇问题请及时在该帖下方留言或在小程序交流专区发表标题包含「基础库3.0.0」的帖子反馈。 本次更新正式发布以下三大特性: 1、Skyline 渲染引擎发布正式版 为了进一步优化小程序性能,提供更为接近原生的用户体验,我们推出了一套新渲染引擎 Skyline。在经过近一年的 beta 版测试后,Skyline 已经趋于稳定。我们修复了大量问题并进行了诸多性能优化,使线上的小程序能够稳定运行且性能表现更优。此外,为了让开发者能更快迁移,我们支持了大多数常用的 CSS 特性,同时还添加了许多高级特性,以帮助开发者构建类原生体验的小程序。更多详细的信息请查阅 介绍文章。 2、XR-FRAME 发布正式版 经过迭代,我们补齐了XR-FRAME的许多基础能力,并针对稳定性等问题做了针对性的优化。XR-FRAME是一个基于小程序开发方案、高性能、渐进式的3D/XR开发框架。开发者可以非常简单得使用WXML便可构造出一个酷炫的3D小程序,并且还广泛支持了AR、物理、交互、粒子、后处理、视频等等能力,同时也能够满足服务商等高级用户各种进阶的定制需求。我们仍在不断迭代新功能,跟着这篇文档可以了解XR-FRAME框架并开始你的开发:开始入门。 3、推出新版组件框架 glass-easel glass-easel 是新一代的小程序页面和自定义组件框架,旨在替代老旧的组件框架,提供更好的性能和更多的特性。现在,我们率先在 Skyline 环境下引入,成为默认的组件框架。glass-easel 几乎完整兼容了旧版框架,仅有极个别的接口被废弃移除,在提升性能的同时,添加了诸多特性,如 Chaining API、动态 slot、在模板中调用 data 里的函数等,更多详细的信息请 查阅文档。 更多更新内容: 新增 框架 新增 wx.getCommonConfig 接口 详情新增 组件 scroll-view 支持下拉二楼交互 详情更新 框架 glass-easel 在兼容模式下运行时使用 wxs 事件响应函数的 ComponentDescriptor#getState 方法 更新 框架 scroll-view 支持 min-drag-distance 属性 详情更新 框架 video 组件遮罩逻辑导致全屏投屏按钮无法点击 详情更新 框架 sticky-header 支持 top 偏移 详情更新 框架 skyline 支持 css animation 事件 更新 框架 启动页无法绑定自定义路由 详情更新 框架 更新 scroll-view / grid-view / list-view / sticky-header / sticky-section 组件支持 padding 属性,设置组件内部的内边距 详情 更新 框架 XR-FRAME VideoTexture 发布正式版 详情更新 框架 Skyline 渲染引擎下,组件框架切换为 glass-easel 详情更新 组件 skyline button 组件 loading 属性添加动画 更新 API 基础库支持 visionkit depth 功能 详情修复 框架 scroll-view 封装成组件时 scroll-into-view 无法跳转 修复 框架 skyline 内存泄漏问题修复 框架 skyline input/textarea 组件获取焦点相关问题 修复 框架 skyline 下部分组件事件无法使用 wxs 函数响应的问题 修复 组件 video 视频遮罩报错修复
2023-07-19 - 使用worker开启新线程进行耗时运算
[视频] 你好,我是李艺。 上节课我们主要学习了如何使用并发复合命令,这节课我们学习使用worker。 首先看一个问题,就是worker它并不属于JS语言,标准的JS语法非常简单,仅包括基本的数据类型以及日期,数学 正则表达式等等这些对象,worker它是HTML5首先被扩展出来的,依托于寄主的环境而存在的,它是一种可以在后台并行执行JS代码,不影响页面渲染的这样一个技术,我们可以将worker看作一种允许开发者手动开启的一个异步线程,使用它,我们可以在异步线程中执行一些比较耗时的计算代码,在计算代码执行完成以后再将执行结果同步给主线程使用,下面我们看项目实践。 首先看实践一,创建worker线程。 怎么才可以创建worker线程,首先我们需要创建一个专用目录用于存放我们的worker代码,然后在app.json配置文件里面添加一个workers的配置节点,这个目录名一般是复数的形式,代表在这个目录下面可以有很多的worker文件,并不是只能放一个worker的代码,它不像WXS脚本,WXSjia只支持ES5的语法 worker代码支持ES6语法,在我们这个项目里边有一个workers目录,这个目录下面只有一个index.js文件,它的主要功能就是接收一个毫秒参数,然后计算并格式化这个时间字符串,这个文件是为我们接下来要创建的,这个stopwatch_wk这个组件进行服务的, 下面我们进行实践一的代码演示。 首先我们需要在我们这个项目的目录下面创建一个专门用于存放worker代码的目录,一般这个目录我们用复数的形式workers,然后在我们的app.json文件里边再放一个关于workers的配置,配置的位置其实无所谓,放在上面下面都是可以的,workers这个目录的名字也是workers,这样就可以了,再下一步我们要完成我们的里边目录下面的一个index.js的一个代码,看一下我们最终的一个代码,找到这个workers目录下面有一个index.js,这个就是我们代码 将这个代码拷贝一下看一下我们这个代码主要做了什么事情,首先这个地方有一个worker,这个worker它不需要我们声明,它在我们的当前的worker线程里面,我们可以看作它是一个实例变量,是可以直接进行使用的,它就是代表我们当前worker环境里面默认的一个实例名,在这个上面我们可以调用两个方法,也是常用的方法一个是onMessage,onMessage就是监听来自主线程的消息,监听以后这里面是一个回调函数,在这个回调函数其中有一个对象,我们从这个对象里面析构出来一个ts的参数,这里边是对ts的一个运算,运算完了以后我们要拼接出result这个结果,这个代码跟我们之前在stopwatch里面、写的那些JS代码都是一样的,计算完成以后 在最后我们调用到这个worker的postMessage,我们将结果传给了我们主线程,这就是它的主要的代码 一个接收,然后一个发送就可以了,这个代码演示我们就说到这里。 下面我们看实践二,创建stopwatch_wk组件。 这个stopwatch_wk组件,它和我们原来的这个stopwatch_go组件一样,在这个组件里面它只负责调用worker计算到的结果,然后在这个视图上进行渲染,这个文件的wxml代码以及它的wxss代码和我们原来的组件里边的设置都是一样的,不需要修改。 下面我们看实践二的代码演示。 目前我们worker以及它里边的文件代码已经创建完成了,下面我们创建使用worker代码的一个组件,这个组件与其他的组件一样,仍然是放在index_addons目录的下面,这个地方这就是我们的wk,就是我们stopwatch_wk这个代码,wk就代表的是worker,我们准备调用那个worker代码,然后这个代码已经在这里,我们看一下它的主要的一个实现。 json配置文件没有修改 都是一样的,然后wxml代码这个也没有修改,样式代码与之前也是一样的,主要差别在index.js JS代码,首先最上面是属性 组件的属性,再往下是data 这个与之前都是一样的,然后下面stop方法,我们这个地方多了一个关于worker的判断,在这个组件我们调了停止以后,我们调用它的terminate,它的销毁的方法,我们主动地去销毁我们worker的线程,并且把它置为null,这是一个释放资源的做法,然后switch方法没有修改,跟原来还是一样的,主要的差别还是在start这个方法里面。 在这个地方我们看一下,首先在这里this.worker等于wx.createWorker,这个地方我们直接写了一个页面,直接写了一个文件地址workers/index.js,这个地址就是我们前面所创建的 在这个目录下面 index.js它的地址,而且这个地址我们不用写相对地址,直接写相对我们项目根目录的这样的一个地址就可以了,还有一点我们需要说明 这个文件它不是随便的,只有我们在配置文件里面配置了我们workers目录,它是worker目录以后才可以去引入里边的文件,然后创建这样的一个worker线程,否则创建是不成功的,创建完成以后 接下来我们就用onMessage,onMessage前面,我们在worker的文件里面已经说过了它相当于是这个消息的监听,这个监听 只不过跟那个不一样的是 这个监听它是监听来自worker线程发过来的信息,给主线程发的消息,然后监听拿到这个结果以后,把这个结果给它调用setData,然后往这个视图上进行更新,这个地方还是原来的convertTimeStampToString,是原来的方法,只不过这个里面它在执行的时候我们是调用worker它的postMessage,调用它的这个方法,然后将这个数据 ts数据然后传递进去,计算是由worker线程然后进行计算的,计算完了以后它再发过来 通过onMessage然后接收,进行更新,是这样的一个流程。 下面这个地方是一个setInterval跟我们原来是一样的,就是我们要设一个定时器,同样是为了防止这个程序卡顿处理不过来,也加了一个flag的变量,flag的变量每次在这个地方,在每次我们收到worker线程发过来消息以后把它置为true,置为true以后在这个地方定时器它就可以调用了,是这样的一种方式,这个定时器我们可以将它改小一点改成100,跟我们原来的频率改成一样的一个数字,这个就是我们的stopwatch_wk它的一个代码实现了,下面我们开始测试一下它的表现,在我们首页里面打开json配置文件,然后这个地方是我们这个组件的一个引入,我们将这个go改成wk,这就是用我们现在创建的组件了,代码准备完了,然后单击编译进行测试,单击这个组件,现在它开始工作了。 我们可以打开Memory面板,在Memory面板里面我们可以看到下面它是有这个线程的,我们先让它停止,这个组件可能已经不堪重负导致我现在开发者工具整个软件都比较卡顿了,所以现在单击面板以后它没有反应了,现在终于有了反应,但是反应很迟钝,重新编译一下 然后让页面重启一下,也说明我们刚才有一个地方的改动就是组件里面定时器设成100这个频率还是有点高,我们可以将这个频率给它再降一下 降下来找到我们的组件的源码,然后在这里仍然给它改成300然后刷新重启一下项目,已经预感到这个组件的执行效率已经不是很高了,它运行以后直接让我们的工具非常的卡顿,几乎要卡死了,看一下我们这个代码。 趁这个软件在启动的时候,我们看一下这个代码,看我们这个代码还有什么可以优化的一些地方,首先在这个地方有一个关于this对象的一个别名的设置,我们把它设成为了self,然后在这个里面我们看一下哪个地方用到了self,这个地方有,这个地方有,然后这个我们可以改成箭头函数,可以改成箭头函数,这个可以给它注掉把这个换成this,改完了,现在我们再继续看我们的项目看看有没有启动起来,没反应,强制退出,这个项目终于启动了,我们重新开始一下测试,首先我们可以看一下我们的Memory面板,这个Memory面板 这个地方我们可以单击一下,看到没有,当我们单击以后,下面它其实多了一个worker线程对吧,worker.js对不对,这个其实就是我们组件 它创建的worker线程,当我们再次单击的时候 我们调了它的stop,因为stop里面有一个调用了worker对象的terminate方法,就把它worker对象给它销毁了,所以我们停止以后可以看到它worker线程又消失了。 另外我们再看一下Performance面板 性能面板,现在我们打开录制,让组件开始执行,单击一下开始执行,让它停止 执行一点点时间就可以了,现在我们可以看一下它的糟糕的一个表现,整体来看有很多倒三角,红色的倒三角,然后这个地方这有一个setData,这个地方我们可以看一下它是什么,好像还不是setData触发的,这个地方是对不对,然后前面这个,这个里面也有对吧,我们先看稍微短一点的时间耗时是多少,78.24ms对吧,这个是158.66ms已经相当长了,这个时间是86.97ms,这个消耗已经非常长了,而且我们现在的帧频是300毫秒,300毫秒的一个触发帧频定时器,比我们原来的100毫秒其实还要更宽泛一点,还要更宽,帧频没有那么高,这种情况下它性能表现其实已经是很差,代码演示我们就看到这里。 最后我们总结一下,使用worker可以做很多事情,可以将费时的数据运算放在worker线程里面,以免影响页面的渲染性能,但是在刚才我们示例演示里边我们也看到了,虽然我们将计算代码放到了worker线程里面,但是由于我们组件里面频繁地调用了setData 这种形式之下,其实也影响了我们这个页面渲染,它是一个整体,不能片面地去看这个事情,另外就是放在wxs这个代码里面和放在worker中的代码,具有同样的一个效果,在渲染上具有同样的结果,因为它都是异步执行的,但是worker它相比wxs限制也有很多,我们可以列举几点一起来看一下,例如第一点不能使用wx开头的小程序接口,第二点在workers目录下面可以有很多的worker文件,但同时只能有一个worker线程在开启,也就是刚才我们在Memory的面板里,我们可以看到手动开启的worker线程,同时只能存在一个,如果你想要开新的话,需要将原来的先给它停掉,先销毁掉,并且如果这个系统资源如果紧张的话,worker线程还有可能被系统回收掉,它是有这样的一个危险的,第三点就是worker只能放在特定的 已经配置好的目录下面,不能随意地放置 放在其他目录下是不可以的,最后一点就是通讯也很不方便,worker线程和主线程之间的通讯只能使用postMessage和onMessage进行相互的这样一个通讯,这是相当于观察者模式的一种通讯机制,综上所述worker可以在一定程度上,在特定的场景之下减轻这个页面的计算压力和渲染压力,但是它并不是提高页面渲染性能的一个最佳选择,worker适合在后台做一些有规律的、常规的一些大数据的运算工作,这节课我们就讲到这里,,上面我们看到的这些网址是本节课涉及到的文档地址。 点击查看开放文档: Worker多线程 WorkerWorker wx.createWorker(string scriptPath, object options) 这节课我们学习了如何在小程序里面开启worker线程,下节课学习如何在后端异步执行运算代码。 最后说一下思考题。这里有个问题请你思考一下,现在有一种游戏叫云游戏,游戏的运算和画面渲染完全是放在云端服务器上完成的,如果这个网速足够快和稳定的话,这种游戏其实丝毫感受不到它的卡顿,在小游戏(小程序)里边是不是也可以将一些逻辑计算代码放在后端,由后端代码进行计算,然后后端代码计算完了再传给前端,我们是不是可以这样去使用,这个问题先留给你思考一下,下节课我们深入探讨一下这个问题。
2022-07-14 - 快速上手小游戏开发
如何设计一款优质的小游戏?开发者必读!
09-10 - 通过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 - 云开发入门
重磅打造的小程序学习路径课,从微信小程序到微信云开发体系化的学习,带来更加顺畅的学习体验。
2021-11-19