- 如何彻底解决小程序滚动穿透问题
背景 俗话说,产品有三宝:弹窗、浮层加引导,足以见弹窗在产品同学心目中的地位。对任意一个刚入门的前端同学来说,实现一个模态框基本都可以达到信手拈来的地步,但是,当模态框里边的内容滚动起来以后,就会出现各种各样的让人摸不着头脑的问题,其中,最出名的想必就是滚动穿透。 什么是滚动穿透? 滚动穿透的定义:指我们滑动顶层的弹窗,但效果上却滑动了底层的内容。 具体解决方案分析如下: 改变顶层:从穿透的思路考虑,如果顶层不会穿透过去,那么问题就解决了,所以我们尝试给蒙层加catchtouchmove,但是发现部分场景无效果,那么就不再赘述了。 改变底层:既然是顶层影响了底层,要是底层不会滚动,那就没这个问题了。 如何改变底层解决该问题呢? 不成熟方案: 底部页面最外层view设置position: fixed;页面不可滚动,但是这个时候会导致页面回到顶部。 滚动时监听滚动距离,弹窗时记录滚动位置,关闭弹窗后使用wx.pageScrollTo回滚到记录的位置。 成熟方案 使用page-meta组件,通过该组件我们可以操作Page的style样式,类似于h5里body设置overflow: hidden; 控制页面不可滚动。文档地址:https://developers.weixin.qq.com/miniprogram/dev/component/page-meta.html 使用wx.setPageStyle设置overflow: hidden, 也可以实现给Page组件设置样式。) page-meta组件: 通过该组件我们可以直接操作[代码]Page[代码]组件 ,我们给它的wxss样式overflow动态设置[代码]hidden[代码]or[代码]visible[代码]or[代码]auto[代码] 就可以控制整个页面是否可以滚动。 [图片] wx.setPageStyle方法: 调用这个api,动态设置它为hidden/auto,用于控制页面是否可滚动,主要用于页面组件内使用,比如封装好的弹窗组件,就不用单独写page-meta组件了。。 [代码]wx.setPageStyle({ style: { overflow: 'hidden' // ‘auto’ } }) [代码] 老规矩,结尾放代码片段: https://developers.weixin.qq.com/s/U6ItgQmP7upQ 拓展 支付宝小程序虽然存在page-meta组件,但是由于内核为69版本,给page设置overflow: hidden 也无法控制底部元素不可滚动,目前已联系支付宝的底层开发同学提供API控制页面disableScroll,目前正在封装Appx,近期开放。
08-06 - 微信小程序setData源码分析
背景 setData 是小程序开发中使用最频繁的接口,也是最容易引发性能问题的接口。详见官网描述 常见的 setData 操作错误 频繁的去 setData 每次 setData 都传递大量新数据 后台态页面进行 setData 针对第二点官网给出意见是,其中 key 可以以数据路径的形式给出,支持改变数组中的某一项或对象的某个属性,如 array[2].message,a.b.c.d,并且不需要在 this.data 中预先定义 下面通过源码深入分析的方式了解小程序是怎么针对数据路径进行组装和构造数据 小程序逻辑层框架源码 微信小程序运行在三端:iOS(iPhone/iPad)、Android 和 用于调试的开发者工具。在开发工具上,小程序逻辑层的 javascript 代码是运行在 NW.js 中,视图层是由 Chromium 60 Webview 来渲染的。这里简单点就直接通过开发者工具来查找源码。 在微信开发者工具中,编译运行你的小程序项目,然后打开控制台,输入 document 并回车,就可以看到小程序运行时,WebView 加载的完整的 WAPageFrame.html,如下图: [图片] 可以看到[代码]./__dev__/WAService.js[代码]这个库就小程序逻辑层基础库,提供逻辑层基础的 API 能力 查找WAService.js源码 在微信小程序 IDE 控制台输入 openVendor 命令,可以打开微信小程序开发工具的资源目录 [图片] 我们可以看到小程序各版本的运行时包 .wxvpkg。.wxvpkg 文件可以使用 wechat-app-unpack 解开,解开后里面就是[代码]WAService.js[代码] 和 [代码]WAWebView.js[代码] 等代码 [图片] 另外也可以只直接通过开发者工具的Sources面板查找到WAService.js的源码 [图片] 分析setData源码 在WAService.js中全局查找setData方法,找到定义此方法的地方,如下 [图片] 源代码使用了大量的逗号运算符,逗号运算符的优先级是最低的,比条件选择符还低 大量使用void 0 表示undefined setData函数定义中添加了关键的注释如下: [代码]function(c, e) { // 保存闭包内的this对象,即常用的that var u = this; // 官网定义 Page.prototype.setData(Object data, Function callback), // 即 c: Object对象,e: Function界面更新渲染完毕后的回调函数 try { // 返回 [object Object] 中的Object var t = v(c); if ("Object" !== t) return void E("类型错误", "setData accepts an Object rather than some " + t); Object.keys(c).forEach(function(e) { // e: 可枚举属性的键值, void 0 表示undefined (https://github.com/lessfish/underscore-analysis/issues/1) void 0 === c[e] && E("Page setData warning", 'Setting data field "' + e + '" to undefined is invalid.'); // t为包含子对象属性名的属性数组, u.data和u.__viewData__都是page.data的深拷贝副本 var t = N(e) , n = j(u.data, t) , r = n.obj , o = n.key; if (r && (r[o] = y(c[e])), void 0 !== c[e]) { var i = j(u.__viewData__, t) , a = i.obj , s = i.key; a && (a[s] = y(c[e])) } }), __appServiceSDK__.traceBeginEvent("Framework", "DataEmitter::emit"), this.__wxComponentInst__.setData(JSON.parse(JSON.stringify(c)), e), __appServiceSDK__.traceEndEvent() } catch (e) { k(e) } } [代码] 关键函数N(e),解析属性名(包含.和[]等数据路径符号),返回相应的层级数组,如 [代码]{abc: 1}中abc属性名 => [abc], {a.b.c: 1}中'a.b.c'属性 => [a,b,c], {"array[0].text": 1} => [array, 0, text][代码] 关键的注释如下 [代码]function N(e) { // 如果属性名不是String字符串就抛出异常 if ("String" !== v(e)) throw E("数据路径错误", "Path must be a string"), new M("Path must be a string"); for (var t = e.length, n = [], r = "", o = 0, i = !1, a = !1, s = 0; s < t; s++) { var c = e[s]; if ("\\" === c) // 如果属性名中包含\\. \\[ \\] 三个转义属性字符就将. [ ]三个字符单独拼接到字符串r中保存,否则就拼接\\ s + 1 < t && ("." === e[s + 1] || "[" === e[s + 1] || "]" === e[s + 1]) ? (r += e[s + 1], s++) : r += "\\"; else if ("." === c) // 遇到.字符并且r字符串非空时,就将r保存到n数组中并清空r; 目的是将{ a.b.c.d: 1 }中的链式属性名分开,保存到数组n中,如[a,b,c,] r && (n.push(r), r = ""); else if ("[" === c) { // 遇到[字符并且r字符串非空时,就将r保存到n数组中并清空r;目的是将{ array[11]: 1 }中的数组属性名保存到数组n中,如[array,] // 如果此时[为属性名的第一个字符就报错,也就是说属性名不能直接为访问器, 如{ [11]: 1} if (r && (n.push(r), r = ""), 0 === n.length) throw E("数据路径错误", "Path can not start with []: " + e), new M("Path can not start with []: " + e); // a赋值为true, i赋值为false i = !(a = !0) } else if ("]" === c) { if (!i) throw E("数据路径错误", "Must have number in []: " + e), new M("Must have number in []: " + e); // 遍历到{ array[11]: 1 }中的']'的时候,就将a赋值为false, 并将o保存到数组n中,如[array,11,] a = !1, n.push(o), o = 0 } else if (a) { if (c < "0" || "9" < c) throw E("数据路径错误", "Only number 0-9 could inside []: " + e), new M("Only number 0-9 could inside []: " + e); // 遍历到{ array[11]: 1 }中的'11'的时候,就将i赋值为true, 并将string类型的数字计算成Number类型保存到o中 i = !0, o = 10 * o + c.charCodeAt(0) - 48 } else r += c // 普通类型的字符就直接拼接到r中 } // 将普通的字符串属性名,.和]后面剩余的字符串保存到数组n中,如{abc: 1} => [abc], {a.b.c: 1} => [a,b,c], {array[0].text: 1} => [array, 0, text] if (r && n.push(r),0 === n.length) throw E("数据路径错误", "Path can not be empty"), new M("Path can not be empty"); return n } [代码] 关键函数j(e, t),解析出属性最终对应的子对象的属性名,以及对应的子对象 [代码]var x = Object.prototype.toString; function _(e) { return "[object Object]" === x.call(e) } function j(e, t) { // e: page.data的深拷贝副本, t为包含子对象属性名的属性数组 /* - 遍历属性数组[a,b], e={a: {b: 1}} 1. i=0, 此时o为Object类型时, n = a, r = {a: {b: 1}}, o = {b: 1}; 2. i=1, 此时o为Object类型时, n = b, r = {b: 1}, o = 1; retrun { obj: {b: 1}, key: b} - 遍历属性数组[a,0,b], e={a: [{b: 1}]} 1. i=0, 此时t[i]=a, o为Object类型时, n = a, r = {a: [{b: 1}]}, o = [{b: 1}]; 2. i=1, 此时t[i]=0, o为Array类型时, n = 0, r = [{b: 1}], o = {b: 1}; 3. i=2, 此时t[i]=b, o为Object类型时, n = b, r = {b: 1}, o = 1; retrun { obj: {b: 1}, key: b} */ for (var n, r = {}, o = e, i = 0; i < t.length; i++) Number(t[i]) === t[i] && t[i] % 1 == 0 ? // t[i]是否为有效的Number Array.isArray(o) || (r[n] = [], o = r[n]) : _(o) || (r[n] = {}, o = r[n]), n = t[i], o = (r = o)[t[i]]; //注意由于逗号分隔符的优先级是最低的,所以这一行会在前面的条件运算符执行完,再执行 return { obj: r, key: n } } [代码] 最后通过[代码]r && (r[o] = y(c[e]))[代码]的方式将新的值赋给匹配出的子对象的属性,这里j(e,t)函数内部是通过引用的方式向外传递出[代码]r[代码],所以这里改变[代码]r[o][代码]的值也会将[代码]u.data[代码]内部的值相应修改,完成局部刷新 由于不同的版本解包后,里面压缩之后的方法名称可能跟上面的对不上,但是大体的结构都是一样的 总结 官方提供的array[2].message,a.b.c.d方式就是通过解析成[array,2,message]和[a,b,c,d],找到相应的子结构进行复制操作,到达减少数据量的目的; 分页加载的时候,为了避免将整个list数据重新传输,就可以利用数据路径的方式只追加新的数据 [代码]假设原数组长度 length 为 10,新数组 newList 长度为 3 this.setData{ 'list[10]': newList[0], 'list[11]': newList[1], 'list[12]': newList[2], } [代码] 参考资料 微信小程序技术原理分析 小程序开发指南
2019-08-24 - 非常简单的长列表(无限上拉触底加载 onReachBottom)实现方案
我们知道小程序针对长列表有两个硬杠杠,一旦越界直接给白屏: 1、setData的数组数据不能超过1M。 2、DOM数不能太多,具体数据未知。 官方这样处理也不无道理,太长了本身性能确实也有问题。所以长列表一定要人为干预处理,不处理一直上拉加载肯定是不行的。 目前主流的处理方法有三种: 1、二维数组,就是把数据改为二维的,每一个分页数据作为一个一维数组的元素。这样处理,只解决了setData的问题,DOM的问题并未解决。并且把本来是一维的数据强行二维化,在很多逻辑处理上变得复杂。 2、官方提供了一个扩展组件recycle-view,但它要求item等高,存在局限性。https://developers.weixin.qq.com/miniprogram/dev/extended/component-plus/recycle-view.html 3、自行搭建骨架屏,类似于方案2的自研版本,根据自己的实际需要编写代码。实现成本非常高。 实现方案如下(太简单了,不提供代码示例): 1、思路:只保留最新的n页数据进行setData,每新加载一页数据,就舍弃最前面一页的数据。同时把第1页的数据保存起来,监听onPageScroll,如果发现用户拉回到了页面顶部,则舍弃所有数据,把第1页的数据setData回来。 2、举个例子:假设n=5,那么当加载了第6页数据时,第6页数据合并到数组尾部,把数组头部的第1页数据去掉,让setData的数据始终保持5页。这里有个细节要处理好,就是去除数据的时候先setData一次,新数据加载合并后,再setData一次,这样可以保证用户的scrollTop不会走位,停留在最新一条数据那个位置。 3、这个方案也存在一些弊端,看你实际项目中能否接受,主要有两个问题:a、用户如果倒着往回逐条浏览,体验是不连续的,因为中间一段我们已经舍弃掉了,如果拉到页面顶部时,将会出现直接回到第一条数据。b、去除数据的setData操作时,存在一定程度的闪屏现象。(针对问题a,应该可以解决,无非就是把数据再逐页塞回来,而不是像我的方案简单粗暴的回到第一页数据,如果项目有需要可以自行尝试。) 4、适用范围:比较适合信息处理类应用,比如后台管理系统。这类应用,往往头几屏内容就能找到信息,或者借助搜索,比较少会拉很多屏。而且往往处理完毕时是直接回到顶部的,不会逐条翻回去。所以这类应用只要保证不出白屏,一些小概率场景下存在一些几乎可以忽略的体验小瑕疵可以接受。信息浏览类应用,比如新闻应用,往往都是长列表浏览,小瑕疵就不一定能接受。
2020-10-28 - wx.onError、App.onError疑惑及如何捕获Promise异常?
1、官方文档上说 wx.onError 和 App.onError 的回调时机与参数一致(https://developers.weixin.qq.com/miniprogram/dev/api/base/app/app-event/wx.onError.html),是指两种方式收集到的异常信息完全一致吗?我们实践过程中发现,wx.onError 获取到的信息比 App.onError 要少。 在我们上一个版本的微信小程序中,我们是在 App.onError 中监控异常,并通过 ELK 收集、查询异常。收集到的部分异常信息截图如下: [图片] [图片] 在我们小程序的最新版本中,我们使用了针对小程序平台的 Sentry SDK(https://github.com/lizhiyao/sentry-miniapp,该 SDK 原理是使用 wx.onError、wx.onPageNotFound、wx.onMemoryWarning 监控异常信息)进行信息收集上报,基于公司私有化部署的 Sentry 服务接收、存储、展示异常信息。结果发现 Sentry 服务没有收到 wx.onError 上报的异常(1. 上线之前有做过测试,Sentry SDK 是可以正常上报代码执行异常的。 2. 可以收集到页面无法找到、内存警告异常,说明线上版本小程序中 Sentry SDK 已经成功初始化,可以进行信息上报): [图片] 但是官方的微信预警群是有推送异常信息的: [图片] [图片] [图片] 2. App.onError 收集到的信息和官方后台运维中心收集到的信息是一致的吗?我们发现 onError 捕获的信息,在小程序官方后台查不到。 比如: 通过 App.onError 在 8.12 收集到了这样一条异常信息: [图片] 在小程序官方后台是搜不到这个异常信息记录的: [图片] 3. 假设 wx.onError 和 App.onError 获取到的异常信息完全一致,且和官方后台收集记录的异常信息完全一致。如果小程序后台运维中心的预警推送频率设置为 1次/5min,那么 onError 获取到的信息和微信预警群推送的信息完全一致吗? 4. 关于 Promise 的异常,对于浏览器有 window.onunhandledrejection,对于 node 有 global.process.on('unhandledRejection', callback()),对于小程序平台,有什么推荐的方式可以获取到 Promise 的异常吗?官方后台运维中心有收集到小程序中 Promise 中的异常吗?目前实践来看,小程序的 App.onError、wx.onError 中是无法捕获 Promise 的异常的。示例代码可参考:https://github.com/lizhiyao/sentry-miniapp/blob/master/examples/weapp/app.js 。 5. 在小程序官方后台及官方预警群中,会发现偶尔会出现非线上版本的异常被收集和上报了。请问这种情况是正常的吗? 比如:截图中 小程序版本对应为 0 的就是我们未发布版本代码中出现的异常。判断的依据是 /pages/homepage-config/skilled-tag/index 是新版本新增的页面,异常上报时新版本并未发布。 [图片]
2019-08-15 - undefined is not an object (evaluating 'a.model')?
最近公众平台运维中心,时常监听到 undefined is not an object (evaluating 'a.model');某页面 onHide function;at api getNetworkType success callback function; 的错误,页面排查+全局搜索,也没有找到有调用getNetworkType这个API的地方,哪位道友可以指点一下迷津 [图片]
2020-04-03 - nodeId是什么?
[图片] nodeId是什么?大神求助啊
2017-12-16 - 自定义TabBar在基础库v2.6.2+的生命周期attached重复执行
customBar代码在基础库2.6.1和2.5.0版本下运行,进入页面时先执行的customBar的生命周期函数attached,并打印了TarBar前缀的日志,然后在页面onShow函数中打印了进入某某页面日志,此时两个函数获取的当前页的WebViewId和TabBar的ExparserNodeId相同 [图片] 然而把基础库改为2.6.2+以后,BUG来了,注意时间戳,进入页面时执行customBar的attached函数和页面的onShow函数后,再次执行了customBar的attached函数,结果就是当前页的customBar显示的是上一次渲染的数据 [图片] 代码用的是你们官网提供的自定义tabbar示例代码:https://developers.weixin.qq.com/s/jiSARvmF7i55 custom-tab-bar.js [代码]lifetimes: {[代码][代码] [代码][代码]attached() {[代码][代码] [代码][代码]var[代码] [代码]curr = app.globalData.curr;[代码][代码] [代码][代码]if[代码] [代码](curr !== undefined) {[代码][代码] [代码][代码]this[代码][代码].setData({[代码][代码] [代码][代码]list: [代码][代码]this[代码][代码].data.tabs[curr][代码][代码] [代码][代码]});[代码][代码] [代码][代码]}[代码] [代码] [代码][代码]var[代码] [代码]webViewId = [代码][代码]'WebViewId: '[代码] [代码]+ [代码][代码]this[代码][代码].__wxWebviewId__;[代码][代码] [代码][代码]var[代码] [代码]nxparserNodeId = [代码][代码]'ExparserNodeId: '[代码] [代码]+ [代码][代码]this[代码][代码].__wxExparserNodeId__;[代码][代码] [代码][代码]var[代码] [代码]route = [代码][代码]''[代码][代码];[代码][代码] [代码][代码]var[代码] [代码]pages = getCurrentPages();[代码][代码] [代码][代码]if[代码] [代码](pages.length) {[代码][代码] [代码][代码]route = pages[pages.length - 1].route;[代码][代码] [代码][代码]}[代码][代码] [代码][代码]var[代码] [代码]data = JSON.stringify([代码][代码]this[代码][代码].data.list);[代码][代码] [代码][代码]console.log(Date.now(), [代码][代码]'TabBar'[代码][代码], webViewId, nxparserNodeId); [代码][代码]//, route, data);[代码][代码] [代码][代码]}[代码][代码] [代码][代码]}[代码] 角色1首页.js [代码]onShow: [代码][代码]function[代码][代码]() {[代码][代码] [代码][代码]var[代码] [代码]webViewId = [代码][代码]'WebViewId: '[代码] [代码]+ [代码][代码]this[代码][代码].__wxWebviewId__;[代码][代码] [代码][代码]var[代码] [代码]nxparserNodeId = [代码][代码]'ExparserNodeId: '[代码] [代码]+ [代码][代码]this[代码][代码].getTabBar().__wxExparserNodeId__;[代码][代码] [代码][代码]var[代码] [代码]route = [代码][代码]this[代码][代码].route;[代码][代码] [代码][代码]var[代码] [代码]data = JSON.stringify([代码][代码]this[代码][代码].getTabBar().data.list);[代码][代码] [代码][代码]console.log(Date.now(), [代码][代码]'进入角色1首页'[代码][代码], webViewId, nxparserNodeId); [代码][代码]//, route, data);[代码][代码] [代码][代码]console.log([代码][代码]' '[代码][代码]);[代码][代码] [代码][代码]if[代码] [代码]([代码][代码]typeof[代码] [代码]this[代码][代码].getTabBar === [代码][代码]'function'[代码] [代码]&& [代码][代码]this[代码][代码].getTabBar()) {[代码][代码] [代码][代码]this[代码][代码].getTabBar().setData({[代码][代码] [代码][代码]selected: 0[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码] [代码][代码]}[代码] 角色2首页.js [代码]onShow: [代码][代码]function[代码][代码]() {[代码][代码] [代码][代码]var[代码] [代码]webViewId = [代码][代码]'WebViewId: '[代码] [代码]+ [代码][代码]this[代码][代码].__wxWebviewId__;[代码][代码] [代码][代码]var[代码] [代码]nxparserNodeId = [代码][代码]'ExparserNodeId: '[代码] [代码]+ [代码][代码]this[代码][代码].getTabBar().__wxExparserNodeId__;[代码][代码] [代码][代码]var[代码] [代码]route = [代码][代码]this[代码][代码].route;[代码][代码] [代码][代码]var[代码] [代码]data = JSON.stringify([代码][代码]this[代码][代码].getTabBar().data.list);[代码][代码] [代码][代码]console.log(Date.now(), [代码][代码]'进入角色2首页'[代码][代码], webViewId, nxparserNodeId); [代码][代码]//, route, data);[代码][代码] [代码][代码]console.log([代码][代码]' '[代码][代码]);[代码][代码] [代码][代码]if[代码] [代码]([代码][代码]typeof[代码] [代码]this[代码][代码].getTabBar === [代码][代码]'function'[代码] [代码]&& [代码][代码]this[代码][代码].getTabBar()) {[代码][代码] [代码][代码]this[代码][代码].getTabBar().setData({[代码][代码] [代码][代码]selected: 0[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码] [代码][代码]}[代码] 个人页.js [代码]onShow: [代码][代码]function[代码][代码]() {[代码][代码] [代码][代码]var[代码] [代码]webViewId = [代码][代码]'WebViewId: '[代码] [代码]+ [代码][代码]this[代码][代码].__wxWebviewId__;[代码][代码] [代码][代码]var[代码] [代码]nxparserNodeId = [代码][代码]'ExparserNodeId: '[代码] [代码]+ [代码][代码]this[代码][代码].getTabBar().__wxExparserNodeId__;[代码][代码] [代码][代码]var[代码] [代码]route = [代码][代码]this[代码][代码].route;[代码][代码] [代码][代码]var[代码] [代码]data = JSON.stringify([代码][代码]this[代码][代码].getTabBar().data.list);[代码][代码] [代码][代码]console.log(Date.now(), [代码][代码]'进入角色'[代码] [代码]+ ([代码][代码]this[代码][代码].data.isDriver ? 1 : 2) + [代码][代码]'个人页'[代码][代码], webViewId, nxparserNodeId); [代码][代码]//, route, data);[代码][代码] [代码][代码]console.log([代码][代码]' '[代码][代码]);[代码][代码] [代码][代码]if[代码] [代码]([代码][代码]typeof[代码] [代码]this[代码][代码].getTabBar === [代码][代码]'function'[代码] [代码]&& [代码][代码]this[代码][代码].getTabBar()) {[代码][代码] [代码][代码]this[代码][代码].getTabBar().setData({[代码][代码] [代码][代码]selected: [代码][代码]this[代码][代码].data.isDriver ? 1 : 2[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码] [代码][代码]}[代码]
2019-04-29 - swiper组件bug-滑动swiper后,swiper不能正常更新,显示为空白
- 当前 Bug 的表现 滑动swiper后,用左下角菜单切换(高中A组/高中B组),swiper不能正常更新,显示为空白。 如果没有滑动过swiper,切换菜单,swiper则可以正常更新。 [图片] - 预期表现 不管有没有滑动过swiper,都可以正常通过左下角菜单切换。 [图片] - 复现路径 进入小程序后,滑动诗词卡片(swiper),点击左下角菜单,点击“高中B组”,卡片消失为空白。 如果没有滑动过卡片则不会出现这个问题。
2018-08-07 - 关于自定义tabBar菜单二级页面显示菜单BUG
二级页面json文件中没有配置 { "usingComponents": {} } 属性,自定义菜单会自动显示。 因项目代码是很久以前的,很多代码都没有配置json,导致大部分页面显示自定义菜单。
2019-05-07