- 你可能不知道的mpvue性能优化技巧
最近一直在折腾[代码]mpvue[代码]写的微信小程序的性能优化,分享下实战的过程。 先上个优化前后的图: [图片] 可以看到打包后的代码量从[代码]813KB[代码]减少到[代码]387KB[代码],Audits体验评分从[代码]B[代码]到[代码]A[代码],效果还是比较明显的。其实这个指标说明不了什么,而且轻易就可以做到,更重要的是优化小程序运行过程中的卡顿感,请耐心往下看。 常规优化 常规的Web端优化方法在小程序中也是适用的,而且不可忽视。 一、压缩图片 这一步最简单,但是容易被忽视。在tiny上在线压缩,然后下载替换即可。 [图片] 我这项目的压缩率高达[代码]72%[代码],可以说打包后的代码从[代码]813KB[代码]降到[代码]387KB[代码]大部分都是归功于压缩图片了。 二、移除无用的库 我之前在项目中使用了Vant Weapp,在[代码]static[代码]目录下引入了整个库,但实际上我只使用了[代码]button[代码],[代码]field[代码],[代码]dialog[代码]等几个组件,实在是没必要。 所以干脆移除掉了,微信小程序自身提供的[代码]button[代码],[代码]wx.showModal[代码]等一些组件基本可以满足需求,自己手写一下样式也不用花什么时间。 在这里建议大家,在微信小程序中,尽量避免使用过多的依赖库。 不要贪图方便而引入一些比较大的库,小程序不同于[代码]Web[代码],限制比较多,能自己写一下就尽量自己写一下吧。 小程序的优化 咱们首先得看一下官方优化建议,大多是围绕这个建议去做。 一、开启Vue.config._mpTrace = true 这个是[代码]mpvue[代码]性能优化的一个黑科技啊,可能大多数同学都不知道这个,我在官方文档都没有搜到到这个配置,我真的是服了。 我能找到这个配置也是Google机缘巧合下看到的,出处:mpvue重要更新,页面更新机制进行全面升级 具体做法是在[代码]/src/main.js[代码]添加[代码]Vue.config._mpTrace = true[代码],如: [代码]Vue.config._mpTrace = true Vue.config.productionTip = false App.mpType = 'app' [代码] 添加了[代码]Vue.config._mpTrace[代码]属性,这样就可以看到console里会打印每500ms更新的数据量。如图: [图片] 如果数据更新量很大,会明显感觉小程序运行卡顿,反之就流畅。因此我们可以根据这个指标,逐步找出性能瓶颈并解决掉。 二、精简data 1. 过滤api返回的冗余数据 后端的api可能是需要同时为iOS,Android,H5等提供服务的,往往会有些冗余的数据小程序是用不到的。比如api返回的一个[代码]文章列表[代码]数据有很多字段: [代码]this.articleList = [ { articleId: 1, desc: 'xxxxxx', author: 'fengxianqi', time: 'xxx', comments: [ { userId: 2, conent: 'xxx' } ] }, { articleId: 2 // ... }, // ... ] [代码] 假设我们在小程序中只需要用到列表中的部分字段,如果不对数据做处理,将整个[代码]articleList[代码]都[代码]setData[代码]进去,是不明智的。 小程序官方文档: 单次设置的数据不能超过1024kB,请尽量避免一次设置过多的数据。 可以看出,内存是很宝贵的,当[代码]articleList[代码]数据量非常大超过1M时,某些机型就会爆掉(我在iOS中遇到过很多次)。 因此,需要将接口返回的数据剔除掉不需要的,再[代码]setData[代码],回到我们上面的[代码]articleList[代码]例子,假设我们只需要用[代码]articleId[代码]和[代码]author[代码]这两个字段,可以这样: [代码]import { getArticleList } from '@/api/article' export default { data () { return { articleList: [] } } methods: { getList () { getArticleList().then(res => { let rawList = res.list this.articleList = this.simplifyArticleList(rawList) }) }, simplifyArticleList (list) { return list.map(item => { return { articleId: item.articleId, author: item.author // 需要哪些字段就加上哪些字段 } }) } } } [代码] 这里我们将返回的数据通过[代码]simplifyArticleList[代码]来精简数据,此时过滤后的[代码]articleList[代码]中的数据类似: [代码][ {articleId: 1, author: 'fengxianqi'}, {articleId: 2, author: 'others'} // ... ] [代码] 当然,如果你的需求中是所有数据都要用到(或者大部分数据),就没必要做一层精简了,收益不大。毕竟精简数据的函数中具体的字段,是会增加维护成本的。 PS: 在我个人的实际操作中,做数据过滤虽然增加了维护的成本,但一般收益都很大,因次这个方法比较推荐。 2. data()中只放需要的数据 [代码]import xx from 'xx.js' export default { data () { return { xx, otherXX: '2' } } } [代码] 有些同学可能会习惯将[代码]import[代码]的东西都先放进[代码]data[代码]中,再在[代码]methods[代码]中使用,在小程序中可能是个不好的习惯。 因为通过[代码]Vue.config._mpTrace = true[代码]在更新某个数据时,我对比放进data和不放进data中的两种情况会有差别。 所以我猜测可能是data是会一起更新的,比如只是想更新[代码]otherXX[代码]时,会同时将[代码]xx[代码]也一起合起来[代码]setData[代码]了。 3. 静态图片放进static 这个问题和上面的问题其实是一样的,有时候我们会通过[代码]import[代码]的方式引入,比如这样: [代码]<template> <img :src="UserIcon"> </template> <script> import UserIcon from '@/assets/images/user_icon.png' export default { data () { return { UserIcon } } } </script> [代码] 这样会导致打包后的代码,图片是[代码]base64[代码]形式(很长的一段字符串)存放在[代码]data[代码]中,不利于精简data。同时当该组件多个地方使用时,每个组件实例都会携带这一段很长的[代码]base64[代码]代码,进一步导致数据的冗余。 因此,建议将静态图片放到[代码]static[代码]目录下,这样引用: [代码]<template> <img src="/static/images/user_icon.png"> </template> [代码] 代码也更简洁清爽。 看一下做了上面操作的前后对比图,使用体验上也流畅了很多。 [图片] 三、swiper优化 小程序自身提供的[代码]swiper[代码]组件性能上不是很好,使用时要注意。参考着两个思路: 【优化】解决swiper渲染很多图片时的卡顿 想请教一下小程序swiper组件的问题 在我使用时,由于需求原因,动态删掉swiper-item的思路不可行(手滑时会造成抖动)。因此只能作罢。但仍然可以优化一下: 将未显示的[代码]swiper-item[代码]中的图片用[代码]v-if[代码]隐藏到,当判断到current时才显示,防止大量图片的渲染导致的性能问题。 四、vuex使用注意事项 我之前写过的一篇mpvue开发音频类小程序踩坑和建议里面有讲如何在小程序中使用[代码]vuex[代码]。但遇到了个比较严重的性能问题。 1. 问题描述 我开发的是一个音频类的小程序,所以需要将播放列表[代码]playList[代码],当前索引[代码]currentIndex[代码]和当前时长[代码]currentTime[代码]放进[代码]state.js[代码]中: [代码]const state = { currentIndex: 0, // playList当前索引 currentTime: 0, // 当前播放的进度 playList: [], // {title: '', url: '', singer: ''} } [代码] 每次用户点击播放音频时,都会先加载音频的播放列表[代码]playList[代码],然后播放时更新当前时长[代码]currentTime[代码],发现有时候播音频时整个小程序非常卡顿。 注意到,音频需每秒就得更新一次[代码]currentTime[代码],即每秒就做一次[代码]setData[代码]操作,稍微有些卡顿是可以理解的。但我发现是播放列表数据比较多时会特别卡,比如playList的长度是100条以上时。 2. 问题原因 我开启[代码]Vue.config._mpTrace = true[代码]后发现一个规律: 当[代码]palyList[代码]数据量小时,[代码]console[代码]显示造成的数据量更新数值比较小;当[代码]playList[代码]比较大时,[代码]console[代码]显示造成的数据量更新数值比较大。 PS: 我曾尝试将playList数据量增加到200条,每500ms的数据量更新达到800KB左右。 到这里基本可以确定一个事实就是:更新[代码]state[代码]中的任何一个字段,将导致整个[代码]state[代码]全量一起[代码]setData[代码]。在我这里的例子,虽然我每次只是更新[代码]currentTime[代码]这个字段的值,但依然导致将state中的其他字段如[代码]playList[代码],[代码]currentIndex[代码]都一起做了一次[代码]setData[代码]操作。 3. 解决问题 有两个思路: 精简state中保存的数据,即限制[代码]playList[代码]的数据不能太多,可将一些数据暂存在[代码]storage[代码]中 [代码]vuex[代码]采用[代码]Module[代码]的写法能改善这个问题,虽然使用时命名空间造成一定的麻烦。vuex传送门 一般情况下,推荐使用后者。我在项目中尝试使用了前者,同样能达到很好的效果,请继续看下面的分享。 五、善用storage 1.为什么说要善用storage 由于小程序的内存非常宝贵,占用内存过大会非常卡顿,因此最好尽可能少的将数据放到内存中,即[代码]vuex[代码]存的数据要尽可能少。而小程序的[代码]storage[代码]支持单个 key允许存储的最大数据长度为 [代码]1MB[代码],所有数据存储上限为 [代码]10MB[代码]。 所以可以将一些相对取用不频繁的数据放进[代码]storage[代码]中,需要时再将这些数据放进内存,从而缓解内存的紧张,有点类似Windows中[代码]虚拟内存[代码]的概念。 2.storage换内存的实例 这个例子讲的会有点啰嗦,真正能用到的朋友可以详细看下。 上面讲到[代码]playList[代码]数据量太多,播放一条音频时其实只需要最多保证3条数据在内存中即可,即[代码]上一首[代码],[代码]播放中的[代码],[代码]下一首[代码],我们可以将多余的播放列表存放在[代码]storage[代码]中。 PS: 为了保证更平滑地连续切换下一首,我们可以稍微保存多几条,比如我这里选择保存5条数据在vuex中,播放时始终保证当前播放的音频前后都有两条数据。 [代码]// 首次播放背景音频的方法 async function playAudio (audioId) { // 拿到播放列表,此时的playList最多只有5条数据。getPlayList方法看下面 const playList = await getPlayList(audioId) // 当前音频在vuex中的currentIndex const currentIndex = playList.findIndex(item => item.audioId === audioId) // 播放背景音频 this.audio = wx.getBackgroundAudioManager() this.audio.title = playList[currentIndex].title this.audio.src = playList[currentIndex].url // 通过mapActions将播放列表和currentIndex更新到vuex中 this.updateCurrentIndex(index) this.updatePlayList(playList) // updateCurrentIndex和updatePlayList是vuex写好的方法 } // 播放音频时获取播放列表的方法,将所有数据存在storage,然后返回当前音频的前后2条数据,保证最多5条数据 import { loadPlayList } from '@/api/audio' async function getPlayList (courseId, currentAudioId) { // 从api中请求得到播放列表 // loadPlayList是api的方法, courseId是获取列表的参数,表示当前课程下的播放列表 let rawList = await loadPlayList(courseId) // simplifyPlayList过滤掉一些字段 const list = this.simplifyPlayList(rawList) // 将列表存到storage中 wx.setStorage({ key: 'playList', data: list }) return subPlayList(list, currentAudioId) } [代码] 重点是[代码]subPlayList[代码]方法,这个方法保证了拿到的播放列表是最多5条数据。 [代码]function subPlayList(playList, currentAudioId) { let tempArr = [...playList] const count = 5 // 保持vuex中最多5条数据 const middle = parseInt(count / 2) // 中点的索引 const len = tempArr.length // 如果整个原始的播放列表本来就少于5条数据,说明不需要裁剪,直接返回 if (len <= count) { return tempArr } // 找到当前要播放的音频的所在位置 const index = tempArr.findIndex(item => item.audioId === currentAudioId) // 截取当前音频的前后两条数据 tempArr = tempArr.splice(Math.max(0, Math.min(len - count, index - middle)), count) return tempArr } [代码] [代码]tempArr.splice(Math.max(0, index - middle), count)[代码]可能有些同学比较难理解,需要仔细琢磨一下。假设[代码]playList[代码]有10条数据: 当前音频是列表中的第1条(索引是0),截取前5个:[代码]playList.splice(0, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]0[代码],没有[代码]上一首[代码],有4个[代码]下一首[代码] 当前音频是列表中的第2条(索引是1),截取前5个:[代码]playList.splice(0, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]1[代码],有1个[代码]上一首[代码],3个[代码]下一首[代码] 当前音频是列表中的第3条(索引是2),截取前5个:[代码]playList.splice(0, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]2[代码],有2个[代码]上一首[代码],2个[代码]下一首[代码] 当前音频是列表中的第4条(索引是3),截取第1到6个:[代码]playList.splice(1, 5)[代码] ,此时[代码]currentAudio[代码]在这5个数据的索引是[代码]2[代码],有2个[代码]上一首[代码],2个[代码]下一首[代码] 当前音频是列表中的第5条(索引是4),截取第2到7个:[代码]playList.splice(2, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]2[代码],有2个[代码]上一首[代码],2个[代码]下一首[代码] … 当前音频是列表中的第9条(索引是[代码]8[代码]),截取后5个:[代码]playList.splice(4, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]3[代码],有3个[代码]上一首[代码],1个[代码]下一首[代码] 当前音频是列表中的最后1条(索引是[代码]9[代码]),截取后的5个:[代码]playList.splice(4, 5)[代码],此时[代码]currentAudio[代码]在这5个数据的索引是[代码]4[代码],有4个[代码]上一首[代码],没有[代码]下一首[代码] 有点啰嗦,感兴趣的同学仔细琢磨下,无论当前音频在哪,都始终保证了拿到当前音频前后的最多5条数据。 接下来就是维护播放上一首或下一首时保证当前vuex中的[代码]playList[代码]始终是包含当前音频的前后2条。 播放下一首 [代码]function playNextAudio() { const nextIndex = this.currentIndex + 1 if (nextIndex < this.playList.length) { // 没有超出数组长度,说明在vuex的列表中,可以直接播放 this.audio = wx.getBackgroundAudioManager() this.audio.src = this.playList[nextIndex].url this.audio.title = this.playList[nextIndex].title this.updateCurrentIndex(nextIndex) // 当判断到已经到vuex的playList的边界了,重新从storage中拿数据补充到playList if (nextIndex === this.playList.length - 1 || nextIndex === 0) { // 拿到只有当前音频前后最多5条数据的列表 const newList = getPlayList(this.playList[nextIndex].courseId, this.playList[nextIndex].audioId) // 当前音频在这5条数据中的索引 const index = newList.findIndex(item => item.audioId === this.playList[nextIndex].audioId) // 更新到vuex this.updateCurrentIndex(index) this.updatePlayList(newList) } } } [代码] 这里的[代码]getPlayList[代码]方法是上面讲过的,本来是从api中直接获取的,为了避免每次都从api直接获取,所以需要改一下,先读storage,若无则从api获取: [代码]import { loadPlayList } from '@/api/audio' async function getPlayList (courseId, currentAudioId) { // 先从缓存列表中拿 const playList = wx.getStorageSync('playList') if (playList && playList.length > 0 && courseId === playList[0].courseId) { // 命中缓存,则从直接返回 return subPlayList(playList, currentAudioId) } else { // 没有命中缓存,则从api中获取 const list = await loadPlayList(courseId) wx.setStorage({ key: 'playList', data: list }) return subPlayList(list, currentAudioId) } } [代码] 播放上一首也是同理,就不赘述了。 PS: 将vuex中的数据精简后,我所做的小程序在播放音频时刷其他页面已经非常流畅啦,效果非常好。 六、动画优化 这个问题在mpvue开发音频类小程序踩坑和建议已经讲过了,感兴趣的可以移步看一眼,这里只写下概述: 如果要使用动画,尽量用css动画代替wx.createAnimation 使用css动画时建议开启硬件加速 最后 大致总结一下上面所讲的几个要点: 开发时打开[代码]Vue.config._mpTrace = true[代码]。 谨慎引入第三方库,权衡收益。 添加数据到data中时要克制,能精简尽量精简。 图片记得要压缩,图片在显示时才渲染。 vuex保持数据精简,必要时可先存storage。 性能优化是一个永不止步的话题,我也还在摸索,不足之处还请大家指点和分享。 欢迎关注,会持续分享前端实战中遇到的一些问题和解决办法。
2019-05-15 - 微信小程序之SelectorQuery
在开发小程序展开全文组件时需要用到节点查询API - [代码]wx.createSelectorQuery()[代码] 来查询全文内容的高度。 [图片] [代码]wx.createSelectorQuery()[代码] 返回一个 [代码]SelectorQuery[代码] 对象实例。 [代码]SelectorQuery[代码] 有五个方法([代码]in[代码],[代码]select[代码],[代码]selectAll[代码],[代码]selectViewport[代码],[代码]exec[代码]),第一个返回 [代码]SelectorQuery[代码],后四个返回 [代码]NodesRef[代码]。 [代码]NodesRef[代码] 有四个方法([代码]fields[代码],[代码]boundingClientRect[代码],[代码]scrollOffset[代码],[代码]context[代码]),第一个返回 [代码]NodesRef[代码],后三个返回 [代码]SelectorQuery[代码]。 对照官方提供的示例代码来看 [代码]const query = wx.createSelectorQuery() // query 是 SelectorQuery 对象 query.select('#the-id').boundingClientRect() // select 后是 NodesRef 对象,然后 boundingClientRect 返回 SelectorQuery 对象 query.selectViewport().scrollOffset() // selectViewport 后是 NodesRef 对象,然后 scrollOffset 返回 SelectorQuery 对象 query.exec(function (res) { // exec 返回 NodesRef 对象 res[0].top // #the-id节点的上边界坐标 res[1].scrollTop // 显示区域的竖直滚动位置 }) [代码] 问题:每行执行返回的 [代码]SelectorQuery[代码] 对象是相同的吗? 答案:是的,都是同一个对象。 问题:直接执行 [代码]query.select('#the-id').boundingClientRect().exec[代码] 也可以吗? 答案:可以,[代码]boundingClientRect()[代码] 返回就是 [代码]query[代码]。 问题:这样连写 [代码]query.select('#the-id').boundingClientRect().selectViewport().scrollOffset()[代码] 算两条查询请求吗? 答案:是两条请求。 问题:[代码]query.exec[代码] 执行后会清空前面的查询请求吗?再次执行还能拿到结果吗? 答案:可以,[代码]query[代码] 不会清空请求。 问题:[代码]boundingClientRect[代码] 和 [代码]scrollOffset[代码] 可以接受 [代码]callback[代码] 参数,它与 [代码]query.exec[代码] 执行顺序是怎样,修改 [代码]res[代码] 结果会影响到后面的 [代码]callback[代码] 吗? 答案:先执行 [代码]boundingClientRect[代码] 和 [代码]scrollOffset[代码] 的 [代码]callback[代码],再执行 [代码]query.exec[代码] 的 [代码]callback[代码];修改 [代码]res[代码] 结果会影响到后面 [代码]exec[代码] 的结果。 上面的问题通过小程序开发者工具中的 [代码]WAService.js[代码] 源码简单美化还原后可以了解 [代码]SelectorQuery[代码] 的代码逻辑 SelectorQuery.js [代码]import NodesRef from 'NodesRef'; import requestComponentInfo from 'requestComponentInfo'; export default function(pluginId) { return class SelectorQuery { constructor(plugin) { if (plugin && plugin.page) { this._component = this._defaultComponent = plugin.page; this._webviewId = this._defaultComponent.__wxWebviewId__; } else { var pages = __internalGlobal__.getCurrentPagesByDomain(''); this._defaultComponent = pages[pages.length - 1], this._component = null; this._webviewId = null; } this._queue = []; this._queueCb = []; } in(component) { if (!this._webviewId) { this._webviewId = component.__wxWebviewId__; this._component = component; } else if (this._webviewId !== component.__wxWebviewId__) { console.error('A single SelectorQuery could not work in components in different pages. A SelectorQuery#in call has been ignored.'); } else { this._component = component; } return this; } select(selector) { return new NodesRef(this, this._component, selector, true); } selectAll(selector) { return new NodesRef(this, this._component, selector, false); } selectViewport() { return new NodesRef(this, 0, '', true); } _push(selector, component, single, fields, callback) { if (!this._webviewId) { this._webviewId = this._defaultComponent ? this._defaultComponent.__wxWebviewId__ : undefined; } const rootNodeId = pluginId ? '' : r.getRootNodeId(this._webviewId); this._queue.push({ component: null != component ? (0 === component ? 0 : component.__wxExparserNodeId__) : rootNodeId, selector, single, fields, }); this._queueCb.push(callback || null); } exec(callback) { requestComponentInfo(this._webviewId, { pluginId, queue: this._queue, }, (results) => { const queueCb = this._queueCb; results.forEach((res, index) => { if ('function' == typeof queueCb[index]) { queueCb[index].call(this, res); } }); if ('function' == typeof callback) { callback.call(this, results); } }) } } } [代码] requestComponentInfo.js [代码]const subscribe = function(eventType, callback) { const _callback = function(event, webviewId, nativeInfo = {}) { const { data = {}, options } = event; const startTime = options && options.timestamp || 0; const endTime = Date.now(); if ('function' == typeof callback) { callback(data, webviewId); Reporter.speedReport({ key: 'webview2AppService', data, timeMark: { startTime, endTime, nativeTime: nativeInfo.nativeTime || 0, } }); } }; __safeway__.bridge.subscribe(eventType, _callback); } const publish = function(eventType, data, webviewIds) { const event = { data, options: { timestamp: Date.now(), } }; __safeway__.bridge.publish(eventType, event, webviewIds); } const requestCb = {}; let requestId = 1; subscribe('responseComponentInfo', function(data) { const reqId = data.reqId; const callback = requestCb[reqId]; if (callback) { delete requestCb[reqId]; callback(data.res); } }); export default function requestComponentInfo(webviewId, reqs, callback) { const reqId = requestId++; if (!webviewId) { console.warn('An SelectorQuery call is ignored because no proper page or component is found. Please considering using `SelectorQuery.in` to specify a proper one.'); return; } requestCb[reqId] = callback, publish('requestComponentInfo', { reqId, reqs, }, [webviewId], ); } [代码] NodesRef.js [代码]export default class NodesRef { constructor(selectorQuery, component, selector, single) { this._selectorQuery = selectorQuery; this._component = component; this._selector = selector; this._single = single; } fields(fields, callback) { this._selectorQuery._push(this._selector, this._component, this._single, fields, callback, ); return this._selectorQuery; } boundingClientRect(callback) { this._selectorQuery._push( this._selector, this._component, this._single, { id: true, dataset: true, rect: true, size: true, }, callback, ); return this._selectorQuery; } scrollOffset(callback) { this._selectorQuery._push( this._selector, this._component, this._single, { id: true, dataset: true, scrollOffset: true, }, callback, ); return this._selectorQuery; } } [代码]
2019-05-16 - 自定义标题栏
使用效果 [图片][图片][图片][图片] 使用方法 属性介绍 属性名 类型 默认值 是否必须 说明 menuSrc String ‘’ 否 按钮图片地址 bgImgSrc String ‘’ 否 背景图片地址 bgImgMode String aspectFill 否 背景图片的显示模式 title String ‘’ 否 标题 titleTextColor String ‘’ 否 字体和按钮以及loading图标的颜色,按钮和loading暂时只有黑白2色 backgroundColor String ‘’ 否 整个标题栏的背景颜色 loading Boolean false 否 是否是加载状态 backProxy Boolean false 否 是否重写了返回键 标题栏中属性的默认数据会自动获取json配置以及系统的默认数据,如果不需要动态更改样式,可以在json中设置,组件中同样起作用 事件介绍 属性名 detail NaviBack 返回的逻辑方法 MenuTap 按钮的点击事件 [代码]"usingComponents": { "toolBar": "/component/toolbar" }, [代码] [代码]<toolBar menuSrc='/image/menu_white.png' bindMenuTap='onMenuTap' bgImgSrc='/image/navi-bg.jpg' /> [代码] 高度说明: 为了方便适配,这里给出自定义标题栏的计算公式: const MenuRect = wx.getMenuButtonBoundingClientRect() const statusBarHeight = wx.getSystemInfoSync().statusBarHeight; const height = (MenuRect.top - statusBarHeight) * 2 + MenuRect.height +MenuRect.top Github地址:https://github.com/Aracy/wx-mini-navigationbar
2019-05-21