- 助你更容易理解IntersectionObserver.relativeToViewport的margins值
官方文档解释:指定页面显示区域作为参照区域之一,使用margins来扩展(或收缩)参照节点布局区域的边界。 [图片] 你如果已经通过官方文档完全理解了它,就可以直接跳过了,如果你还不能很理解,请往下看。 关于relativeToViewport(margins)的参数解释:一图胜千言,绿色为你的视口区域,红色部分就是相交事件的真实触发区域。 简单解释下,就是margins的4个方向上的正值向外放大的区域,margin的负值向内缩小的区域。 [图片] 1. top / bottom 负值:相对于视口的margin设置如下所示。top,bottom都为负值的处理。 this._observer .relativeToViewport({top: -200, bottom: -200}) .observe('.ball', (res) => { console.log(res); }) 当我们滚动页面,小球已经出现在视口内,但是我们log并没有打印出任何相交事件信息。 [图片] 因为,我们设置了bottom:-200,所以,真实的相交区域应该是如下图所示的区域: [图片] 2.top / bottom 正值: 相对于视口的margins设置如下所示。top,bottom都为正值的处理。 relativeToViewport({top: 10, bottom: 10}) [图片] left和right的正值、负值原理相同。 总结: top 正值往上,负值往下 bottom: 正值往下,负值往上 left 正值往左 负值往右 right 正值往右 负值往左 正值向外扩展,负值向内收缩。扩展缩放后的区域就是相对元素的参照触发区域。 另外有个问题,如果我们视口内有fixed定位元素,这些定位元素可能会影响用户对视口的理解偏差。 举例说明,下图中的红色部分都为fixed元素,在视口外有个小球,当参照区域与小球相交比例为1时需要做一些逻辑处理。如果我们不做特殊的margins值设置,那么当小球完全进入视口但未完全进入蓝色区域时就会被触发。这显然不是我们想要的,当然我们完全可以通过设置margins为四个方向的负值来完成这件事情。但是在现实开发中,组件多为独立的自定义组件,是否存在和宽高都是未知,我们需要更完整的整套解决方案更为合适。 后续的文章我们会针对这个问题,提供一整套基于官方relativeToViewport封装的解决方案。以做到包装后的relativeToViewport针对的就是图中的用户理想视口(蓝色区域),设置的margins值就是对图中蓝色区域的缩放。 [图片]
2021-12-27 - 小程序录音实时波形图
首先,做这个不需要把MP3转pcm。 结果就是,转了pcm也不知道怎么出波形。搞了我好几天。。。 但是微信现在不需要引入js-mp3库就可以转,仅仅记录一下,代码如下: var audioCtx = wx.createWebAudioContext() audioCtx.decodeAudioData(frameBuffer, audioBuffer => { let float32Array = audioBuffer.getChannelData(0) ...} AudioContext.createAnalyser(),这是浏览器的接口,微信的WebAudioContext暂时没有这个。 页面js: const that = this; var recorderManager = wx.getRecorderManager() //把frameBuffer转一下,再转成普通Array,传给voice组件 recorderManager.onFrameRecorded((res) => { const { frameBuffer } = res let uint8Array = new Uint8Array(frameBuffer) that.setData({ voiceLine: new Array(...uint8Array) }) }) const options = { duration: 40000, numberOfChannels: 1, format: 'mp3', frameSize: 0.01, //这里设置很小,就会只取一帧就触发onFrameRecorded事件 // sampleRate: 44100, encodeBitRate: 16000, } recorderManager.start(options) 页面wxml:引入组件,传递数据 组件voice.wxml: 组件voice.wxss: .canvas { position: fixed; top: 0; left: 10px; right: 60; bottom: 0; width: 300px; height: 100%; } 组件js: var voiceLine = []; var MaxValue = 0; Component({ /** * 组件的属性列表 */ properties: { voice: Array }, /** * 组件的初始数据 */ data: { }, lifetimes: { // 组件刚刚被创建时执行 attached() { wx.createSelectorQuery().in(this) .select('#canvas') .fields({ node: true, size: true, }) .exec(this.init.bind(this)) }, //删除该组件绑定的所有事件 detached() { } }, pageLifetimes: { show: function () { }, hide: function () { // 页面被隐藏 }, resize: function (size) { // 页面尺寸变化 } }, observers: { 'voice': function (voice) { if (!voice instanceof Array || voice.length === 0) { return; } // 显示单一波形,调试用 //解开注释可以查看每个波形,慢慢找规律 // this.renderVoice(voice); // return; let voiceLen = voice.length, plantValue = 0, //常见数据 serialValueNum = 0 //连续计数器 for (var i = 0; i < voiceLen; i++) { //连续9个表示该帧空了 if (serialValueNum > 9) { console.log('总长:', voiceLen, '常见数据:', plantValue, '干了:', serialValueNum) if (voiceLine.length > 60) { voiceLine.shift() } voiceLine.push(0) this.renderCanvas(); return; } if (plantValue != voice[i]) { plantValue = voice[i] serialValueNum = 0 } else { serialValueNum++; } } // 按长度显示 let middleNum = voice.length if (middleNum > MaxValue) { MaxValue = middleNum console.log('MaxValue:', MaxValue); } //长度<100抛弃数据 if (middleNum < 100) { console.log('长度<100抛弃数据:', middleNum, voice); middleNum = 100 } if (voiceLine.length > 60) { voiceLine.shift() } voiceLine.push(middleNum) this.renderCanvas(); }, }, /** * 组件的方法列表 */ methods: { init(res) { const width = res[0].width const height = res[0].height const canvas = res[0].node const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = width * dpr canvas.height = height * dpr ctx.scale(dpr, dpr) this.ctx = ctx this.width = width this.height = height }, renderVoice(voiceLine) { if (typeof this.ctx === 'undefined') { return } const width = this.width const height = this.height const ctx = this.ctx ctx.clearRect(0, 0, width, height) var len = voiceLine.length, barHeight = 1, q = 0, left = 0 let plantValue = 0, //常见数据 serialValueNum = 0 //连续计数器 console.log(voiceLine); ctx.strokeStyle = 'blue' for (var i = 0; i < len; i++) { if (i == len - 1 && serialValueNum > 9) { //连续5个很罕见 console.log('总长:', len, '常见数据:', plantValue, '干了:', serialValueNum) } if (plantValue != voiceLine[i]) { plantValue = voiceLine[i] serialValueNum = 0 } else { serialValueNum++; } q = i % height left = parseInt(i / height) * 60 + 60 barHeight = (voiceLine[i] / 255) * 60 // 绘制向上的线条 ctx.beginPath(); ctx.moveTo(left, q); ctx.lineTo(left + barHeight, q); ctx.stroke(); } }, renderCanvas() { if (typeof this.ctx === 'undefined') { return } const width = this.width const height = this.height const ctx = this.ctx ctx.clearRect(0, 0, width, height) var len = voiceLine.length, barHeight = 1, t_arr = [] let now_V, min = 100, max = MaxValue - min; ctx.strokeStyle = 'blue' ctx.lineWidth = 3; for (var i = 0; i < len; i++) { now_V = voiceLine[i] == 0 ? 0 : voiceLine[i] - min if (now_V == 0) { ctx.beginPath(); ctx.moveTo(3, i * 8 - 4); ctx.lineTo(3, i * 8 + 4); ctx.stroke(); continue; } t_arr.push(now_V) barHeight = (now_V / max) * 60 // 绘制向上的线条 ctx.beginPath(); ctx.moveTo(0, i * 8); ctx.lineTo(barHeight, i * 8); ctx.stroke(); } t_arr.sort((a, b) => a - b); console.log('Min', t_arr[0], 'Max', t_arr[t_arr.length - 1]) } } }) 原理:说白了,传过来的一串数据,当有大声音时,会突然变长。 当完全静音时,会连续出现85或170这个值。如果是转Int8Array,会有-86这个值。 至于为什么,上面代码解开注释可以观察单个波形。 [图片] 最后成品的案例在:“艺匠人”小程序->新建作品。 感兴趣的童鞋搜一下玩玩吧~
2023-02-07 - 【转】如何在微信小程序里面实现跨页面通信?
我们在处理业务需求的时候,常常会遇到一些情况,在二级或者三级页面进行某些操作或者变更后,需要将结果通知到上级页面去。比如: 选择了某些配置项,点击保存后,外部页面能够立即变更 在上传头像页面,上传完毕后,外部页面的头像能够立即显示为新头像。 所以,这个时候就涉及到如何在页面之间通信的问题了。 跨页面通信进一步说其实就是一个程序内部的事件通知机制问题,在其他平台或者OS上都一些相应的实现,比如: iOS SDK自带的 NotificationCenter Android 平台著名的第三方库 EventBus 目前微信小程序官方SDK还没有提供 Event API 来帮助开发者实现页面间通信,所以我们今天来看看,自己如何实现这样一个简单的小工具。 说到这里就不得不说“云梦”的微信小程序版本了,在小程序开始公测后,我们也在第一时间将“云梦”的基本功能移植到了小程序平台上。 整个过程相当顺利,除了小程序的IDE还不是太稳定外,基本上没啥大问题。 开发过程和React-Native基本相似,大概一天时间就搞定了。 Quick And Dirty我们知道,在小程序里面一个页面的变化,是通过调用 setData 函数来实现的。所以想做到在二级页面里让一级页面产生变化,最 Quick And Dirty 的做法就是把一级页面的 this 传入到二级页面去,这样我们在二级页面调用 page1.setData(…) 就可以立即引发外部的变化。 但是这并不是一个好的方案,不仅产生了页面的耦合,而且也并不能处理复杂的数据逻辑,因为二级页面不并清楚也不应该关心一级页面想怎么处理当前数据。所以二级页面只应该把变更后的数据通知给一级页面即可,至于一级页面是想刷新界面,还是想本地存储或者发起网络通信,别人都不需知晓了。 简单的Callback如果只是想把数据通知给外部页面,那应该怎么做呢? 我们来看看第二个方案,如果想产生一个通知,这里就需要用到 callback 机制了。 即关心数据变化的页面,注册一个 callback 函数到一个公共的地方;而数据变更者在变更数据后,将新的数据放入同一个公共的地方;在放入数据时,同时调用这个 callback 函数,让 callback 函数实现者接收到这个变化。 哪这个公共的地方在哪里呢? 第一反应就是 app.js 里面,因为小程序提供了一个 API 叫做 getApp(),让 page 初始化时,可以通过以下代码: var app = getApp()来获取 app 实例,从而实现全局的数据共享,并且微信也很贴心的在 Demo 代码里面留了一个 globalData 字段,以暗示开发者这里是可以用来存储全局数据的。 App({ ... globalData:{ userInfo:null } ...})基于 app.js 方案的伪代码如下: //app.jsApp({ addListener: function(callback) { this.callback = callback; }, setChangedData: function(data) { this.data = data; if(this.callback != null) { this.callback(data); } } })然后我们在一级页面的 onLoad中 调用 addListener: //page1.jsvar app = getApp()Page({ onLoad: function () { app.addListener(function(changedData) { that.setData({ data: changedData }); }); } })在二级页面数据变更的地方调用: //page2.jsvar app = getApp()Page({ onBtnPress: function() { app.setChangedData('page2-data'); } })一个基本合格的方案以上就是跨页面通信的最基本原理,不过这也是一个很 dirty 的方案,因为上面的代码只能支持一种 Event 的通知,而且也不能针对这个 Event 添加多个监听者(比如有多个页面需要同时知道某数据变更)。 让我们来看看一个基本合格的 Event 管理器应该具备怎样的能力? 支持多种 Event 的通知 支持对某一 Event 可以添加多个监听者 支持对某一 Event 可以移除某一监听者 将 Event 的存储和管理放在一个单独模块中,可以被所有文件全局引用 根据以上的描述,我们来设计一个新的 Event 模块,对应上面的能力,它应该具有如下三个函数: on 函数,用来向管理器中添加一个 Event 的 Callback,且每一个 Event 必须有全局唯一的 EventName,函数内部通过一个数组来保存同一 Event 的多个 Callback remove 函数,用来向管理器移除一个 Event 的 Callback emit 函数,用来触发一个 Event 我们在小程序的 utils 目录中,新建一个 event.js 文件,来作为一个独立的模块,伪代码如下: //event.jsvar events = {};function on(name, callback) { var callbacks = events[name]; addToCallbacks(callbacks, callback); }function remove(name, callback) { var callbacks = events[name]; removeFromCallbacks(callbacks, callback); }function emit(name, data) { var callbacks = events[name]; emitToEveryCallback(callbacks, data); }exports.on = on;exports.remove = remove;exports.emit = emit;我们来看看在一二级页面应该如何来使用这个 Event 模块 在二级页面中触发事件: //page2.jsvar event = require('../../utils/event.js');Page({ onBtnPress: function() { event.emit('DataChanged', 'page2-data'); } });在一级页面的 onLoad 中监听事件,onUnload 中取消监听: //page1.jsvar event = require('../../utils/event.js');Page({ onLoad: function() { var that = this; event.on('DataChanged', function(changedData) { that.setData({ data: changedData }); }); }, onUnload: function() { event.remove('DataChanged', ...); } });咦,似乎哪里不对? remove 需要接受两个参数,第一个是 EventName,第二个是 Callback,但是我们的 Callback 以匿名函数的方式写在了 event.on(...) 的调用语句里面 好吧,那我们不得不修改一下语句的调用方式: //page1.jsvar event = require('../../utils/event.js');Page({ onDataChanged: function(changedData) { this.setData({ data: changedData }) }, onLoad: function() { event.on('DataChanged', this.onDataChanged); }, onUnload: function() { event.remove('DataChanged', this.onDataChanged); } });这样就 OK 了么?NO NO NO NO 熟悉 Javascript this 这个大坑的朋友们一定会知道,在 onDataChanged 这个函数中调用的 this 并不是我们 Page 中的那个 this,所以根本不可能调用到 this.setData(....),于是我们用 bind 大法稍微调整一下: onLoad: function() { event.on('DataChanged', this.onDataChanged.bind(this)); }onUnload: function() { event.remove('DataChanged', this.onDataChanged.bind(this)); }现在OK了么?NO NO NO NO!如果大伙敲代码试试,就会发现依然还是不行! 因为 this.onDataChanged.bind(this)会产生一个新的匿名函数,即 bind的 返回值是一个函数,那么在 onLoad 和 onUnload 里面,各自调用了 bind 大法,从而产生了各自的匿名函数,也就是说 event.remove(...) 塞进去的那个函数,并不是 event.on(...) 塞进去的那个函数,这样就造成了 remove 时无法正确匹配。removeFromCallbacks 的伪代码大致如下: function removeFromCallbacks(callbacks, callback) { var newCallbacks = []; for(var item in callbacks) { if(item != callback) { newCallbacks.push(item); } } return newCallbacks; }所以我们会发现 remove 传入的 callback 永远无法在 callbacks 数组中被匹配到,从而也就无法正确移除了。 最终的代码实现当 EventName + Callback 无法唯一决定需要移除的监听者时,那么自然想到的就是再增加一个 key 值,我们可以用Page自身的某个特性来做 key,比如 page name ,新的 remove 原型如下: function remove(eventName, pageName, callback);pageName 是一个字符串,如果开发者不能做到全局内 page name 唯一的话(比如开发者一不小心写错了),那就可能会出现后来监听者冲掉前面监听者的情况,从而造成无法收到通知的 bug。 所以这里看起来还是用 page 的 this 做 key 比较靠谱,修改后的函数原型如下: function on(name, self, callback); function remove(name, self, callback);让我们来看看内部具体怎么实现。以下是一个完整的 on 函数实现: function on(name, self, callback) { var tuple = [self, callback]; var callbacks = events[name]; if (Array.isArray(callbacks)) { callbacks.push(tuple); } else { events[name] = [tuple]; } }第二行我们将 self (即 page 的 this)和 callback 合并成一个 tuple 第三行从 events 容器中,取出该 EventName 下的监听者数组 callbacks 如果该数组存在,则将 tuple 加入数组;如果不存在,则新建一个数组。 remove的完整实现: function remove(name, self) { var callbacks = events[name]; if (Array.isArray(callbacks)) { events[name] = callbacks.filter((tuple) => { return tuple[0] != self; }); } }第二行从 events 容器中,取出该 EventName 下的监听者数组 callbacks 如果 callbacks 不存在,则直接返回 如果存在,则调用 callbacks.filter(fn) 方法 filter 方法的含义是通过 fn 来决定是否过滤掉 callbacks 中的每一个项。fn 返回 true 则保留,fn 返回 false 则过滤掉。所以我们调用 callbacks.filter(fn) 后,callbacks 中的每一个 tuple 都会被依次判定。 fn的定义为: (tuple) => { return tuple[0] != self; }tuple 中的第一个元素 self 和 remove 传入的 self 相比较,如果不相等则返回 true 被保留,如果相等则返回 false 被过滤掉。 callbacks.filter(fn) 会返回一个新的数组,然后重新写入 events[name],最终达到移除callbacks中某一项的逻辑。 最后再来看看emit的实现: function emit(name, data) { var callbacks = events[name]; if (Array.isArray(callbacks)) { callbacks.map((tuple) => { var self = tuple[0]; var callback = tuple[1]; callback.call(self, data); }); } }第二行从 events 容器中,取出该 EventName 下的监听者数组 callbacks 如果 callbacks 不存在,则直接返回 如果存在,则调用 callbacks.map(fn) 方法 和 filter 的用法类似,map 函数的作用相当于 for 循环,依次取出 callbacks 中的每一个项,然后对其执行 fn(tuple),从其名字就可以看出 map 就是映射变换的意思,将 item 变换为另外一种东西,这个映射关系就是fn。 fn 的定义为: (tuple) => { var self = tuple[0]; var callback = tuple[1]; callback.call(self, data); }对传入的 tuple,分别取出 self 和 callback,然后调用 Javascript 的 call大法: fn.call(this, args)从而最终实现调用到监听者的目的。 讲到这里就基本上差不多了,因为 Event 模块持有了 Page 的 this,所以一定要在 Page 的 Unload 函数中调用 event.remove(…),不然会造成内存泄露。 源代码event.js 的完整源代码和Demo请见 https://github.com/danneyyang/weapp-event 原作者: danneyyang
2016-11-25 - 小程序app.onLaunch与page.onLoad异步问题的最佳实践
场景: 在小程序中大家应该都有这样的场景,在onLaunch里用wx.login静默登录拿到code,再用code去发送请求获取token、用户信息等,整个过程都是异步的,然后我们在业务页面里onLoad去用的时候异步请求还没回来,导致没拿到想要的数据,以往要么监听是否拿到,要么自己封装一套回调,总之都挺麻烦,每个页面都要写一堆无关当前页面的逻辑。 直接上终极解决方案,公司内部已接入两年很稳定: 1.可完美解决异步问题 2.不污染原生生命周期,与onLoad等钩子共存 3.使用方便 4.可灵活定制异步钩子 5.采用监听模式实现,接入无需修改以前相关逻辑 6.支持各种小程序和vue架构 。。。 //为了简洁明了的展示使用场景,以下有部分是伪代码,请勿直接粘贴使用,具体使用代码看Github文档 //app.js //globalData提出来声明 let globalData = { // 是否已拿到token token: '', // 用户信息 userInfo: { userId: '', head: '' } } //注册自定义钩子 import CustomHook from 'spa-custom-hooks'; CustomHook.install({ 'Login':{ name:'Login', watchKey: 'token', onUpdate(token){ //有token则触发此钩子 return !!token; } }, 'User':{ name:'User', watchKey: 'userInfo', onUpdate(user){ //获取到userinfo里的userId则触发此钩子 return !!user.userId; } } }, globalData) // 正常走初始化逻辑 App({ globalData, onLaunch() { //发起异步登录拿token login((token)=>{ this.globalData.token = token //使用token拿用户信息 getUser((user)=>{ this.globalData.user = user }) }) } }) //关键点来了 //Page.js,业务页面使用 Page({ onLoadLogin() { //拿到token啦,可以使用token发起请求了 const token = getApp().globalData.token }, onLoadUser() { //拿到用户信息啦 const userInfo = getApp().globalData.userInfo }, onReadyUser() { //页面初次渲染完毕 && 拿到用户信息,可以把头像渲染在canvas上面啦 const userInfo = getApp().globalData.userInfo // 获取canvas上下文 const ctx = getCanvasContext2d() ctx.drawImage(userInfo.head,0,0,100,100) }, onShowUser() { //页面每次显示 && 拿到用户信息,我要在页面每次显示的时候根据userInfo走不同的逻辑 const userInfo = getApp().globalData.userInfo switch(userInfo.sex){ case 0: // 走女生逻辑 break case 1: // 走男生逻辑 break } } }) 具体文档和Demo见↓ Github:https://github.com/1977474741/spa-custom-hooks 祝大家用的愉快,记得star哦
2023-04-23 - 微信小程序可以做仿电子书的翻页特效吗?
最近做个项目需要模仿电子书的翻页效果,我查看了好多阅读类的小程序都没有做这样的效果,找到了个翻页特效的插件turn.js但都是用jquery写的,小程序不支持用jquery啊,就为了做这么个特效难道要我把插件全部翻页成js?我内心是拒绝的啊,有没有大神能帮忙支招啊,谢谢
2019-03-11 - 小程序实现高性能虚拟列表优化+节流+分页请求(固定高度)
场景引入 为什么需要用到高性能虚拟列表+节流+分页请求的优化? 当有场景需求为需要将大量数据(10000条)呈现在一页上,我们不断下拉访问,页面中有大量的数据列表的时候,用户会不会有不好的体验?会不会出现滚动不流畅而卡顿的情况?会不会因卡顿而出现短暂的白屏现象(数据渲染不成功)? 通过微信开发者工具自带的调试器->Network页面,我们可以观察到当有长列表时如果不使用高性能虚拟列表+节流+分页请求的优化,会出现以下问题: FPS:每秒帧数,图表上的红色块表示长时间帧,很可能会出现卡顿。 CPU:CPU消耗占用,实体图越多消耗越高。 NET:网络请求效率低,一次性请求10000条的渲染效率远远低于分1000次,每次请求10条数据 内存:滑动该列表时明显能看到内存消耗大。 总结:如果需要将大量数据(10000条)呈现在一页上,可以通过高性能虚拟列表+节流+按需请求分页数据并追加显示。 优化的具体实现可拆分为以下需求(将一个大问题拆分为一个个小问题并逐个去解决): 不把长列表数据一次性全部直接显示在页面上。 截取长列表一部分数据用来填充屏幕容器区域。 长列表数据不可视部分使用使用空白占位填充。 监听滚动事件根据滚动位置动态改变可视列表。 监听滚动事件根据滚动位置动态改变空白填充。 分页从服务器请求数据,将一次性请求所有数据变为滚动到底部才再次向服务器发送获取数据的请求 开始实战 此次实例我设定每个元素的固定高度为210rpx [图片] (1)首先计算屏幕内的容积最大容量(即屏幕一次性可以容纳多少个高度为210rpx的元素) 调用wx.createSelectorQuery()的api [图片] 上图是id为scrollContainer的组件,滚动时触发函数handleScroll() [代码]// 计算容器的最大容积,onReady中触发,即初次渲染时触发 getContainSize() { wx.createSelectorQuery().select('#scrollContainer').boundingClientRect(function (rect) { rect.id // 节点的ID rect.dataset // 节点的dataset rect.left // 节点的左边界坐标 rect.right // 节点的右边界坐标 rect.top // 节点的上边界坐标 rect.bottom // 节点的下边界坐标 rect.width // 节点的宽度 rect.height // 节点的高度 }).exec((option) => { // console.log(~~(option[0].height / this.data.oneHeight) + 2); this.data.containSize = ~~(option[0].height / this.data.oneHeight) + 2; }) }, [代码] 调用api后返回的option中的height就是小程序页面的视口高度,除以oneHeight(210rpx)就是能容纳的个数,用[代码]~~[代码]来对结果进行向下取整(实际能容纳的应+1),由于在滚动时会出现上一个元素在上边界还没完全消失,第四个元素就从下边界进入视口了,因此最大容纳量应再+1。即实际容纳量应该如图最后一行代码所示+2. [图片] (2)监听滚动事件动态截取数据 监听用户滚动、滑动事件,根据滚动位置,动态计算当前可视区域起始数据的索引位置 startIndex,再根据containsize,计算结束数据的索引位置 endIndex,最后根据 startIndex与endIndex截取长列表所有数据a11Datalist 中需显示的数据列表 showDatalist。 PS:下列代码中函数handleScroll()最下面的[代码]this.setDataStartIndex(data);[代码]才是滚动时真正进行的滚动事件动态截取数据,上面那些代码用途在文章后面部分再详细介绍(节流)。 [代码]// 定义滚动行为事件方法 handleScroll(data) { if (this.data.isScrollStatus) { this.data.isScrollStatus = false; // 节流,设置一个定时器,1秒以后,才允许进行下一次scroll滚动行为 let mytimer = setTimeout(() => { this.data.isScrollStatus = true; clearTimeout(mytimer); }, 17) this.setDataStartIndex(data); } }, [代码] [代码]// 执行数据设置的相关任务, 滚动事件的具体行为 setDataStartIndex(data) { // console.log("scroll active") this.data.startIndex = ~~(data.detail.scrollTop / this.data.oneHeight); // 通过scrollTop滑动后距离顶部的高度除以每个元素的高度,即可知道目前到第几个元素了 this.setData({ showDataList: this.data.allDatalist.slice(this.data.startIndex, this.data.endIndex) }) // 动态截取实际拥有10000条数据的数组中下标为startIndex到endIndex的数组出来呈现在前端页面上 // 容器最后一个元素的索引 if (this.data.startIndex + this.data.containSize <= this.data.allDatalist.length - 1) { this.setData({ endIndex: (!this.data.allDatalist[this.data.endIndex]) ? this.data.allDatalist.length - 1 : this.data.startIndex + this.data.containSize // 滚动到底部了吗,是的话那就将endIndex设置为9999,不然的话设置为startIndex+视口最大容量 }) } else { console.log("滚动到了底部"); this.data.pageNumNow++; // 例如一次性从数据库拿10条数据赋值到allDataList,如果滚动到底部(即allDataList所有数据都已经呈现了),那就再次向服务器发送请求获取数据库中的下10条数据 this.addMes(); // 该函数内就写你实际向数据库请求时的代码,请求成功后拼接到allDataList即可 console.log(this.data.allDatalist.length) } }, [代码] (3)使用计算属性动态设置上下空白占位 我们设置了根据容器滚动位移动态截取ShowDatalist 数据,现在我们滚动一下发现滚动2条列表数据后,就无法滚动了,这个原因是什么呢? 在容器滚动过程中,因为动态移除、添加数据节点丢失,进而强制清除了顶部列表元素DOM节点,导致滚动条定位向上移位一个列表元素高度,进而出现了死循环根据 startIndex和endIndex的位置,使用计算属性,动态的计算并设置,上下空白填充的高度样式blankFi11Sty1e,使用padding或者margin 进行空白占位都是可以的 PS:由于小程序没有computed,所以为了使用计算属性,得另外引入封装好computed的包,引入computed组件的教程我放在最后,我使用的是官方推荐的computed,且注意使用该插件时,不要加[代码]this -> this.data[代码],直接data即可 [代码]computed: { // 定义上空白高度 topBlankFill(data) { // console.log("change") return data.startIndex * data.oneHeight; }, // 定义下空白高度 BottomBlankFill(data) { return (data.allDatalist.length - data.endIndex) * data.oneHeight; }, // 定义一个 待显示的数组列表元素 showDataList(data) { // console.log(data.allDatalist.slice(data.startIndex, data.endIndex)) return data.allDatalist.slice(data.startIndex, data.endIndex) }, }, [代码] (4)下拉置底自动请求加载数据 下方代码中[代码]setDataStartIndex()[代码]函数末尾的if-else便是判断是否已经滚动到现有全部数据的allDataList数组是否已经滚动到底部,全部呈现完了。 如果是那就执行else部分,请求的页数pageNumNow+1,然后调用addMes()请求数据,然后将新请求到的数据进行拼接到allDataList上 [代码]// 执行数据设置的相关任务, 滚动事件的具体行为 setDataStartIndex(data) { // console.log("scroll active") this.data.startIndex = ~~(data.detail.scrollTop / this.data.oneHeight); this.setData({ showDataList: this.data.allDatalist.slice(this.data.startIndex, this.data.endIndex) }) // 容器最后一个元素的索引 if (this.data.startIndex + this.data.containSize <= this.data.allDatalist.length - 1) { this.setData({ endIndex: (!this.data.allDatalist[this.data.endIndex]) ? this.data.allDatalist.length - 1 : this.data.startIndex + this.data.containSize }) } else { console.log("滚动到了底部"); this.data.pageNumNow++; this.addMes(); console.log(this.data.allDatalist.length) } }, // 根据接口数据来给数组添加真实数据 addMes: function () { this.list() .then(res => { // console.log(res.result.data); // 将接口获取到得所有数据存储起来 this.data.allDatalist = this.data.allDatalist.concat(res.result.data); // 设置初始显示列表 this.setData({ showDataList: this.data.allDatalist.slice(0, 5) }) }) }, // 以下为获取数据 list: function () { let pageNum = this.data.pageNumNow; let pageSize = 20; // console.log('当前请求的页码为:' + pageNum); return new Promise((resolve, reject) => { wx.cloud.callFunction({ name: 'teacher', data: { action: 'list', pageNum, pageSize } }).then(res => { resolve(res) }).catch(err => { reject(err) }) }) }, [代码] 到此处,高性能虚拟列表+分页请求的优化已经搞定了,下面开始节流的优化 (5)滚动事件节流定时器优化 由于监听滚动事件触发对应函数方法的频率是极高的,因此页面节流优化是必须的。 方法:在data中声明一个属性scro11State用来记录滚动状态,只有scro11State值为true的时候才会具体执行 PS:下面代码中定时器设置为17ms的原因是,由于小程序没有web的requestAnimationFrame的api,无法作滚动事件节流请求动画帧优化,因此可以手动计算显示器的帧率,大概一帧在17ms,因此定时器设置为17ms [代码]// 定义滚动行为事件方法 handleScroll(data) { //节流部分代码 if (this.data.isScrollStatus) { this.data.isScrollStatus = false; // 节流,设置一个定时器,1秒以后,才允许进行下一次scroll滚动行为 let mytimer = setTimeout(() => { this.data.isScrollStatus = true; clearTimeout(mytimer); }, 17) //节流部分代码 this.setDataStartIndex(data); } }, [代码] 到这为止,节流也搞定啦,觉得从中学到了许多的小伙伴不妨点个赞。 小程序如何引入computed计算属性请参考我的这篇文章:https://developers.weixin.qq.com/community/develop/article/doc/000a4442bd44c84e740d6b6b051413 在后续我也会将高性能虚拟列表+节流的优化封装成一个插件,给小伙伴们直接使用,欢迎关注我以便及时获取到我文章的更新~ 在这里也推荐一篇防抖和节流的性能优化知识介绍的文章:https://segmentfault.com/a/1190000018428170 觉得有帮助的小伙伴欢迎点赞,有其他问题也欢迎在评论区提出
2021-11-11 - wx.navigateTo ,跳转超过10次怎么点不动的解决办法。
自定义一个跳转方法思路:如果跳转的url是小程序页面栈已有的页面,则用wx.navigateBack的方式回退到那一层 wx.navigateBack相当于手动修改页面栈,将url后面的所有页面栈都删掉。 goPage(url) { // 如果这个小程序,入口页面用的不多的话,可以忽略掉indexUrl,不做判断 var indexUrl ="pages/index/index" // 小程序入口页面 var page = getCurrentPages(); // 获取到小程序的页面栈 var delta = -1; // 如果要跳转的url是入口页面,并且页面栈大于2,用回退的方式,清除页面栈内容 if(page.length>2 && indexUrl==url){ for(var i=0;i<page.length;i++){ if(url==page[i].route){ //入口页面的索引i delta = page.length - i -1 // 算出要回退几步回到 入口页面 break } } } // 回退-1步的话,表示页面栈 里面没有首页 if(delta==-1){ wx.navigateTo({ url: url }) }else{ wx.navigateBack({ delta: delta }) } } 其他优化方法: 入口页面的写法:组建tabbar,首页和我的都写成组建的形式。这种写法,在首页和我的页面切换的时候,不会使用wx.navigateTo ,不占用跳转的栈内存。 <home wx:if="{{PageCur=='0'}}" params="{{params}}" bind:changeTab="changeTab"></home> <mine wx:if="{{PageCur=='1'}}" params="{{params}}"></mine> <tabbar id="tabbar" tabNum="{{PageCur}}" bind:clickFun="changeTab"></tabbar> wx.redirectTo方法 代替 navigateTo 减少一层栈的使用,但是跳转的页面没有返回按钮。
2021-09-29 - 微信小程序全局mixin 全局stroe 全局状态管理 全局公共组件 方案 mp-mixin js库 劫持生命周期
前言 由于微信小程序没有顶层组件与原生mixin方案,在做一些全局公共组件时十分棘手,也没有办法跨组件维持状态 所以笔者将我平时用到的一个方案封装成了一个js库 mp-mixin , 分享给大家,希望能够有帮助,以下为github地址与文档 该库原理大致就是劫持Page构造器和组件setData等api来实现状态注入与跨组件状态同步 当然,对于使用uni-app taro等框架的,可以忽略这个问题,不存在这个问题哈,这些框架支持全局组件或mixin mp-mixin <h3>🚀 微信小程序 mixin 和 store 方案</h3> 1. 特性 支持 mixin data、methods、生命周期及Page事件 支持不同Page 使用 store 共用状态 支持全局 mixin 和 store typescript编写 支持QQ小程序 以及其他api和微信小程序相似的小程序 2. 快速使用 2.1 npm 安装 [代码]npm i mp-mixin [代码] [代码]import 'mp-mixin'; [代码] 2.2 cdn 点击下载 cdn 文件,复制到您的小程序项目中,然后 import 这个文件就可以 cdn地址: https://cdn.jsdelivr.net/npm/mp-mixin/mp-mixin.min.js 2.3 快速使用 2.3.1 mixin 对象 mixin 是一个对象,数据结构如下 [代码]const mixin = { data: {}, // 可选 methods: {}, // 可选 store: wx.creteStore({}), // 可选 当全局注入时,store可以是一个json, 否则 必须是 store对象 // 以下为Page生命周期或事件 onLoad(){ }, onShareAppMessage(){ } } [代码] 2.3.2 全局mixin 全局mixin, 推荐在 app.js 中引入 [代码]import 'mp-mixin'; wx.mixin(mixin); // mixin 对象 见 2.3.1 [代码] 2.3.3 Page mixin 也可以在Page构造中按需引入 mixin [代码]Page({ mixin: mixin, // mixin 对象 见 2.3.1 // ... }) [代码] 说明 如有相同的键值对,优先级为 组件 > 局部mixin > 全局mixin data 优先级 高于 store mixin 中的 data 会被深克隆分别注入对应的Page中的data,使用setData互不影响 mixin 中的 store也会被注入Page中的data,区别是如果不同Page引入的是同一个,则一个页面setData会影响其他页面的 状态,且UI会更新 3 api 引入 mp-mixin 之后,mp-mixin 会将一下三个 api 挂载到 wx 对象上 [代码]wx.mixin wx.createStore wx.initGlobalStore [代码] [代码]wx.initGlobalStore[代码] 等价于在 [代码]wx.mixin[代码] 方法中加入 store属性 [代码]wx.initGlobalStore({ // state }) wx.mixin({ store: { // state } }) [代码] 您也可以主动引入来使用上述三个API [代码]import {globalMixin, createStore, initGlobalStore} from 'mp-mixin' // ... [代码] 您可以通过 [代码]injectStaff[代码] 方法手动注入到任何对象上 [代码]import {injectStaff} from 'mp-mixin' injectStaff(anyObject); [代码] 4. 类型声明 type.d.ts index.d.ts
2021-05-17 - 小程序canvas绘制海报
2020年第一篇文章,年初忙着复习刷题一直没空去写东西,书看的越多感觉越技不如人,始终徘徊在小菜鸡的行列中,最近项目里正好有一个canvas的业务,突然又燃起了我一个UI前端的火种,记下了踩坑和思考🤔。 踩坑💥 问题1:为什么在canvas上画图片模糊? 在canvas上绘制图片/文字的时候,我们设定canvas:375*667的宽高,会发现绘制出来的图片很模糊,感觉像是一张分辨率很差的图片,文字看起来也会有叠影。 [图片] 注意:物理像素是指手机屏幕上显示的最小单元,而设备独立像素(逻辑像素)计算机设备中的一个点,css 中设置的像素指的就是该像素。 原因:在前端开发中我们知道一个属性叫[代码]devicePixelRatio(设备像素比)[代码],该属性决定了在渲染界面时会用几个(通常是2个)物理像素来渲染一个设备独立像素。 举个例,一张100*100像素大小的图片,在retina屏幕下,会用2个像素点去渲染图片的一个像素点,相当于图片放大了一倍,因此图片会变得模糊,这也是1px在retina 屏上变粗的原因。 [图片] 解决: 将canvas-width和canvas-height都放大2倍,在通过style将canvas的显示width,height缩小2 倍. 例如: [代码]<canvas width="320" height="180" style="width:160px;height:90px;"></canvas> [代码] 问题2:如何处理px和rpx的转换? rpx是小程序里特有的尺寸单位,可以根据屏幕的宽度进行自适应,而在iphone6/iphonex上,1rpx等于不同的px。所以很可能会导致在不同手机下,你的canvas展示不一致。 在绘制海报的之前,我们拿到的设计稿一般都是基于iphone6的2倍图。而且从上一个问题的解决,我们知道canvas的大小也是2倍的,所以我们可以直接量取2倍图的设计稿直接绘制canvas,而尺寸需要注意一下rpxtoPx. [代码]/** * * @param {*} rpx * @param {*} int //是否变成整数 factor => 0.5 //iphone6 pixelRatio => 2 像素比 */ toPx(rpx, int) { if (int) { return parseInt(rpx * this.factor * this.pixelRatio) } return rpx * this.factor * this.pixelRatio } [代码] 问题3:关于canvasContext.measureText计算纯数字的时候手机上为0 在小程序中提供[代码]this.ctx.measureText(text).width[代码]去计算文本的长度,但是如果你全[代码]数字[代码] 的话,你会发现该API永远都计算成0.所以,最后采用模拟measureText方法去计算文本长度。 [代码]measureText(text, fontSize = 10) { text = String(text) text = text.split('') let width = 0 text.forEach(function(item) { if (/[a-zA-Z]/.test(item)) { width += 7 } else if (/[0-9]/.test(item)) { width += 5.5 } else if (/\./.test(item)) { width += 2.7 } else if (/-/.test(item)) { width += 3.25 } else if (/[\u4e00-\u9fa5]/.test(item)) { // 中文匹配 width += 10 } else if (/\(|\)/.test(item)) { width += 3.73 } else if (/\s/.test(item)) { width += 2.5 } else if (/%/.test(item)) { width += 8 } else { width += 10 } }) return width * fontSize / 10 } [代码] 问题4:如何保证一行字体的居中展示?多行呢? 字体的如果过长,会超出canvas画布,造成绘制难看,这个时候我们就应该让超出的部分变成[代码]...[代码] 你可以设置一个width并且循环计算计算出文本的宽度,如果超出则利用substring截取后补充[代码]...[代码]即可。 [代码]let fillText='' let width = 350 for (let i = 0; i <= text.length - 1; i++) { // 将文字转为数组,一行文字一个元素 fillText = fillText + text[i] // 判断截断的位置 if (this.measureText(fillText, this.toPx(fontSize, true)) >= width) { if (line === lineNum) { if (i !== text.length - 1) { fillText = fillText.substring(0, fillText.length - 1) + '...' } } if (line <= lineNum) { textArr.push(fillText) } fillText = '' line++ } else { if (line <= lineNum) { if (i === text.length - 1) { textArr.push(fillText) } } } } [代码] 文字剧中展示计算公式: 居中在canvas中可以用(canvas的宽度-文字宽度)/2 + x (x为字体的x轴的推移) [代码]let w = this.measureText(text, this.toPx(fontSize, true)) this.ctx.fillText(text, this.toPx((this.canvas.width - w) / 2 + x), this.toPx(y + (lineHeight || fontSize) * index)) [代码] 问题5:在小程序中如何处理网络图? 关于在小程序里使用网络图片,比如cdn上的图片,是需要down到微信本地进行 LRU 管理,让后续绘制同样图片时,节省下载时间。所以首先需要你在微信小程序的后台配置downloadFile合法域名,其次你可以在canvas绘制之前,最好提前去down图片,等待图片下载好了,再开始绘制,以避免一些绘制失败的问题。 问题6:在 IDE 中可设置 base64 的图片数据进行绘制,但真机上无用? 先把 base64 转成 [代码]Uint8ClampedArray[代码] 格式。然后再通过 [代码]wx.canvasPutImageData(OBJECT, this)[代码] 绘制到画布上,然后把画布导出为图片。 <!–### 问题6:如何画一个圆角图片?–> 问题7:关于wx.canvasToTempFilePath 使用 Canvas 绘图成功后,直接调用该方法生成图片,在IDE上没有问题,但在真机上会出现生成的图片不完整的情况,可以使用一个setTimeout来解决这个问题。 [代码]this.ctx.draw(false, () => { setTimeout(() => { Taro.canvasToTempFilePath({ canvasId: 'canvasid', success: async(res) => { this.props.onSavePoster(res.tempFilePath)//回调事件 // 清空画布 this.ctx.clearRect(0, 0, canvas_width, canvas_height) }, fail: (err) => { console.log(err) } }, this.$scope) }, time) }) [代码] 问题8:关于canvasContext.font fontsize 不能使用小数 如果设置 font 中字体大小部分包含小数,则会导致整个 font 设置无效。 问题9:安卓下字体渲染错位? [图片] 这个问题出现在安卓手机上,ios表现正常。一开始看到这个问题,摸不着头脑,为什么有的正常居中有的却往前了很多。后面发现是安卓下[代码]this.ctx.setTextAlign(textAlign)[代码] 默认是为center,所以导致了错乱,改成left后就正常了。 问题10:绘制一个折线图 [图片] 利用canvas绘制一个简单的折线图,只需要利用[代码]lineTo[代码]和[代码]moveTo[代码]俩个API将点连接即可。利用[代码]createLinearGradient[代码]绘制阴影。 思考💡 思考1:用json配置表生成海报的局限 现在的海报生成只需要按照设计稿去量取尺寸就可以,但是量取的过程还是很繁琐的,在设计稿量不到的地方还需要手动微调一下。 后续还可以做一个web端使用拖拽的方式去完成设计稿的事情,自动生成json应用到小程序的海报上。 思考2:后端生成海报的局限 海报一开始是后端同学生成的,优点是不需要前端绘制时间,也不需要去踩微信API的坑,接口返回拿到url即可展示,但是在后端生成出来的效果不佳,毕竟这种工作更加前端一些。 思考3:前端生成海报的局限 前端生成海报的时候我发现耗时更长,包括图片的下载本地而且还需要给安卓一个特意写一个setTimeout去确保绘制正常。各种兼容性问题、手机的dpr、安卓和ios等不间断彩蛋踩到你头秃~ 哈哈哈哈~ 彩蛋 采用了最新的canvas-2d背景图确无法绘制全部? 在canvas开发的过程中,小程序里一直有一束微光提醒我。 [图片] 我也试了试最新的canvas2d的api,的确同步了web端,写法也更流畅,在开发者工具中看是一切正常,跑在手机上则,只显示宽度的一半在各种机型下测试也是一样。 [图片] 后面改成原始的canvas就又好了。。。具体原因也还没有在微信社区里找到,后续迭代升级的时候再研究阿吧啊吧啊吧。 [图片]
2020-07-09