- mpvue开发音频类小程序踩坑和建议
前言 这是我第一次开发小程序,开发的产品是音频类的,在大佬的建议下采用了[代码]mpvue[代码],一周时间把功能都做出来,由于不太熟悉mpvue和微信小程序,足足用了一周时间来改bug才出来一个能用的版本,在这里整理分享下我开发时遇到的一些问题和给出一些建议。 [图片] 在[代码]Linux[代码]上开发小程序 在公司电脑装了双系统,日常用的是[代码]Ubuntu[代码]系统,Linux或Mac的开发环境对前端相对来说会友好一些。微信小程序官方的开发者工具只有[代码]Windows[代码]和[代码]Mac[代码]版本,所以这就尴尬了。 不过还好,发现已经有大神在GitHub上做了Linux的支持,推荐给大家:Linux微信web开发者工具。 根据教程安装使用即可,使用时就用[代码]./bin/wxdt[代码]命令打开。不过用了几天后面觉得不太方便,就索性切回Windows系统用官方最新的版本了。 封装wx.request为Promise [代码]wx.request[代码]用于发起http请求,但平时习惯了Promise的写法,所以还是封装一下这个方法为Promise的形式。 我看很多小程序会使用fly这个库。 但个人觉得发起请求不需要那么强大的功能,小程序本身就应该是一个轻量级的东西,引入一个库可能会导致项目打包变大,可能让小程序更卡,所以本着能自己写就自己写吧的心态,索性自己封装一下算了。 在[代码]src/utils[代码],新建一个[代码]request.js[代码]: [代码]const apiUrl = 'https://your server.com/api/' const request = (apiName, reqData, isShowLoading = true) => { // 某些请求可能不需要显示loading if (isShowLoading) { wx.showLoading({ title: '正在努力加载中', mask: true }) } return new Promise(function (resolve, reject) { wx.request({ url: apiUrl + apiName, method: 'POST', data: reqData, header: { 'content-type': 'application/json' // 默认值 }, success (res) { if (res.data.code === 0) { // 与后端约定code=0时才是正常的 resolve(res) } else { reject(res) } }, fail (err) { reject(err) }, complete (res) { wx.hideLoading() } }) }) } export default request [代码] 当然这是个简化版的,我实际项目中还会在初始化时加入一些[代码]token[代码]之类的参数,大家能看明白是这样封装成Promise的就可以啦。 使用vant-weapp 小程序已经支持了npm安装,但不太会弄。还是按网上方法,将项目clone下来放进static目录下。 [代码]git clone https://github.com/youzan/vant-weapp.git [代码] 然后将[代码]vant-weapp[代码]的[代码]dist[代码]目录拷贝到项目的static目录下(尽可能精简,删掉一些奇奇怪怪的如[代码].github[代码]的东西,所以直接使用dist目录),改名为[代码]vant[代码](也可以不改名)。全局使用时,可以在[代码]app.json[代码]引入: [代码] "usingComponents": { "van-button": "/static/vant/button/index", "van-field": "/static/vant/field/index" }, [代码] 注意:需要打开微信开发者工具中的ES6转ES5功能 一开始以为使用起来和web端的没啥差别,但没想到那么麻烦。比如:在vue中是可以使用[代码]v-model[代码]的,但在mpvue中的小程序中不能使用,只能 [代码]<van-field :value="password" type="password" @change="pwdChange" input-class="myClass" /> [代码] 而且不能随意灵活添加class修改组件的样式,需要vant组件支持提供外部样式才可修改,比如上面的[代码]van-field[代码]是通过[代码]input-class[代码]来添加样式控制的,很不方便。而且某些内部样式由于没有外部样式表,根本改不了。 综上: 在微信小程序使用第三方组件库不太方便,样式修改比较麻烦,如果产品是有UI设计时,尽量不使用,有时候自己实现样式可能更快,而且项目体积更小。 使用vuex mpvue官方的快速模板中是将vuex放在[代码]counter[代码] 这个page目录下,可能习惯了vue官方写法的很多同学(包括我)不太喜欢,所以最好就改为vuex官方的写法。 在src目录下建一个[代码]store[代码]的文件夹,分别建以下文件: [图片] 项目不太复杂时不建议使用modules,使用起来比较麻烦。 贴一下[代码]index.js[代码]的代码,其他的[代码]actions.js[代码],[代码]getters.js[代码]按官方的写法就好啦。 [代码]import Vue from 'vue' import Vuex from 'vuex' import * as actions from './actions' import * as getters from './getters' import state from './state' import mutations from './mutations' import createLogger from 'vuex/dist/logger' Vue.use(Vuex) const debug = process.env.NODE_ENV !== 'production' export default new Vuex.Store({ actions, getters, state, mutations, strict: debug, plugins: debug ? [createLogger()] : [] }) [代码] [代码]vuex/dist/logger[代码]是vuex在开发环境可以自动打印日志的工具,debug比较方便,建议使用。 然后在[代码]src/main.js[代码]引入: [代码]import Vue from 'vue' import App from './App' import store from '@/store' Vue.config.productionTip = false App.mpType = 'app' Vue.prototype.$store = store const app = new Vue({ store }) app.$mount() [代码] 这样就可以在项目中正常使用啦,完全支持[代码]mapState[代码],[代码]mapActions[代码],[代码]mapGetters[代码]的写法,比如在[代码]pages/index/index.vue[代码]中使用: [代码]<script> import { mapState, mapActions } from 'vuex' export default { computed: { ...mapState(['myAudio']) }, methods: { ...mapActions(['myActions']) }, created () { this.myActions() //调用vuex中的方法 } } </script> [代码] 踩坑指南 其实大多数坑可能是mpvue的,很多情况也是自己不熟悉小程序生命周期导致的一些奇奇怪怪的bug。 mpvue是支持小程序原生组件的 mpvue会将[代码]div[代码]编译为小程序中的[代码]view[代码]。一开始我不了解,以为用了mpvue后就不能使用小程序原生支持的组件了,比如[代码]swiper[代码],[代码]scroll-view[代码]等,小程序是支持的,可以放心使用哈哈。 npm run build后样式丢失 本来在开发环境正常的,然后准备发版[代码]npm run build[代码]后发现样式丢失了。然后重新[代码]npm start[代码]排查问题,样式还是丢失的。内心此时是mmp的:npm run build丢失就算了,我没改什么东西重新npm start后为什么还是丢失,之前还是正常的呀? 刚开始怀疑是缓存什么的问题,删掉的dist目录,重启开发者工具,甚至重启电脑都试了一下,这是我遇到的超级诡异的问题之一。 冷静下来想到:之前的版本是正常的,一定是新版本引入了什么导致了打包样式的丢失。于是回滚版本一个个build排查问题,最后找到了原因:在一个page中引入了其他page,即在页面中import另一个页面。 在我这里的具体例子是:我在[代码]pages/index/index.vue[代码] 中想做底部共用一个tabbar,页面根据tabbar的值来显示对应的子级页面:[代码]pages/page1/index.vue[代码]和[代码]pages/page2/index.vue[代码]。 所以我将这两个页面当做子组件来引入了:[代码]import Page1 from '@/pages/page1'[代码],一开始没有问题,等重启项目,或者build后就发现样式丢失了。 这可能是mpvue打包机制的一个限制,即[代码]页面不能将另一个页面当子组件来引用[代码],否则会导致样式丢失。 背景音频的src无法读取 项目中希望用户退出小程序后依然能播放音频,所以用到了背景音频的api: wx.getBackgroundAudioManager()。 [代码]this.audio = wx.getBackgroundAudioManager() this.audio.src = 'http://ws.stream.qqmusic.qq.com/M500001VfvsJ21xFqb.mp3?guid=ffffffff82def4af4b12b3cd9337d5e7&uin=346897220&vkey=6292F51E1E384E061FF02C31F716658E5C81F5594D561F2E88B854E81CAAB7806D5E4F103E55D33C16F3FAC506D1AB172DE8600B37E43FAD&fromtag=46' this.audio.title = '此时此刻' //注意必填 this.audio.epname = '此时此刻' this.audio.singer = '许巍' this.audio.coverImgUrl = 'http://y.gtimg.cn/music/photo_new/T002R300x300M000003rsKF44GyaSk.jpg?max_age=2592000' [代码] [代码]title[代码]和[代码]src[代码]赋值后会直接播放音频,后面的几个属性建议也填上,因为播放背景音频时微信是有个界面需要封面图和歌手名称等的。 如果想要获取当前正在播放的音频src,本来以为通过[代码]this.audio.src[代码]来获取就可以了但是有bug。 在开发者工具中是可以正常获取的,即开发时是没问题的,但在真机上返回的是[代码]undefined[代码],因此不能用[代码]this.audio.src[代码]来获取当前播放的音频url,得用一个变量来存这个数据。 直接使用音频的currentTime可能渲染不及时 currentTime用于显示当前的播放进度,但我用在子组件中时经常更新不及时,打印是正常的,但试图渲染不及时,有时候需要点击一下才能重新渲染,这可能是mpvue使用时才会遇到。 所以建议还是项目自身维护一套背景音频的变量比较好一点,比如放在[代码]vuex[代码]中。监听[代码]BackgroundAudioManager.onTimeUpdate()[代码]方法每次赋值到自身维护的变量中。 音频的onCanplay方法不一定每个音频都会触发 一开始我监听在[代码]onCanplay[代码]方法,将音频的时长信息[代码]duration[代码]赋值到vuex中存起来,但发现[代码]onCanplay[代码]有时候是不会触发的,比如重新赋值src播放下一首时,很尴尬。 所以不要太依赖onCanplay这个方法,还好目前直接使用[代码]audio.duration[代码]好像不会出现像上面的[代码]currentTime[代码]渲染不及时的问题,所以就这样用着先。 音频播放结束,即onStop后,不能再通过audio.play()的方法重新播放,得重新赋值src 正常来说,音频播放结束后,音频的src是不变的,再次[代码]play()[代码]应该是可以的。但在小程序中偏偏不行,得重新赋值src才能重新播放,这应该是小程序的一个bug。。。 所以需要判断一下暂停和停止的情况,用不同的办法播放。正常来说,音频暂停时[代码]currentTime[代码]是不为0的,而结束时[代码]currentTime[代码]会为0。 所以可以通过[代码]currentTime[代码](最好是自己维护的变量)来判断暂停和停止的情况:如果currentTime不为0,表示是暂停的情况,可以用[代码]play()[代码],如果小于等于0,则重新赋值src播放: [代码]if (currentTime) { this.audio.play() } else { this.audio.src = 'xx.mp3' } [代码] mpvue不支持直接在template上直接绑定函数 这个是mpvue文档上有写的,不过一开始并不是很理解,也踩坑了,所以在这里提一下,避免不知道的同学踩坑找半天。 [代码]<template> <div v-for="(item, index) in list" :key="index">{{ formatItem(item) }}</div> </template> <script> export default { data () { return{ list: [1, 2, 3] } }, methods: { formatItem (item) { return `我是${item}` } } } </script> [代码] 上面的代码应该是日常vue中比较常用的,就是将数据传参给方法做一些处理,这个在mpvue中是不支持的,会被编译成一个空字符串。 小程序中可放心使用css3的一些特性 比如高斯模糊 [代码]filter: blur(50px); [代码] 如果要使用动画,尽量用[代码]css[代码]动画代替[代码]wx.createAnimation[代码] 在实际使用时,[代码]wx.createAnimation[代码]做动画其实很卡,性能很差,所以在需要使用动画时,建议尽量使用css做动画。 在小程序中是支持css动画的,[代码]transition[代码],[代码]animation[代码],[代码]@keyframes[代码]这些特性都支持。 比如做一个div一直旋转的动画,大家可以对比一下两个版本: [代码]wx.createAnimation[代码]版本 原理:通过setInterval()不断更新div的旋转位置 [代码]<template> <div class="cover" :animation="animationData"></div> </template> <script> export default { data () { return { animationData: '', animation: '', rotateCount: 0, timer: '' } }, components: { }, methods: { startRotate () { this.timer = setInterval(() => { this.rotateAni(++this.rotateCount) }, 100) }, rotateAni (n) { if (!this.animation) { return } // 每100毫秒旋转10度 this.animation.rotate(10 * n).step() this.animationData = this.animation.export() } }, onShow () { // 页面从隐藏到显示时才执行 if (!this.animation) { this.animation = wx.createAnimation() this.startRotate() } }, onReady () { // 第一次初始化时会执行 if (!this.animation) { this.animation = wx.createAnimation() this.starRotate() } }, onHide () { // 页面隐藏时会执行,避免频繁的setData操作,将定时器停掉 this.timer && clearInterval(this.timer) }, beforeDestroy () { // 页面卸载,也停掉定时器 this.timer && clearInterval(this.timer) } } </script> <style scoped lang="scss"> .cover { left: 20px; bottom: 70px; border-radius: 50%; background: #fff; position: absolute; width: 50px; height: 50px; background: rgba(0, 0, 0, 0.2); box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.2); border: 1px solid rgba(255, 255, 255, 0.5); overflow: hidden; z-index: 10000; } </style> [代码] 使用css的[代码]@keyframes[代码]做旋转动画 [代码]<template> <div class="cover" :style="coverStyle"></div> </template> <script> export default { } </script> <style scoped lang="scss"> // 定义一个动画名为 rotate @keyframes rotate { 0%, 100% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .cover { left: 20px; bottom: 70px; border-radius: 50%; background: #fff; position: absolute; width: 50px; height: 50px; background: rgba(0, 0, 0, 0.2); box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.2); border: 1px solid rgba(255, 255, 255, 0.5); overflow: hidden; z-index: 10000; // 使用动画 animation: rotate 4s linear infinite; } </style> [代码] 用js写的动画需要控制好setInterval的间隔时间和旋转角度,比较难调。而用css写动画很简单,性能比js好,代码量也很少。 使用css动画时建议开启硬件加速 为了动画更流畅,想尽办法做优化,虽然不知道有没效果,反正用了再说[手动滑稽]。 可以用will-change和transform: translate3d(0,0,0)开启硬件加速。我也不太会用,具体用法大家自行百度Google。 [代码]will-change: auto; transform: translate3d(0, 0, 0); [代码] iPhoneX需要底部导航条预留34px(68rpx)的高度。 由于小程序中不能设置[代码]viewport-fit=cover[代码],所以也就没有web中的安全区域说法,目前主流的做法是通过[代码]wx.getSystemInfoSync()[代码]判断是否是ipx,若是则给页面底部撑高34px。 [代码]const res = wx.getSystemInfoSync() if (res.model.indexOf('iPhone X') >= 0) { this.isIpx = true } [代码] 注意是用[代码]res.model.indexOf('iPhone X')[代码],在开发者工具的iPhone X中,model是全等于[代码]iPhone X[代码]的,但在真机中往往拿到的值是[代码]iPhone X GZxxx[代码],即后面可能会带一串东西,所以用[代码]indexOf[代码]才是比较稳的,而且对[代码]iPhone XR[代码]等机型也适用。 由于还有其他安卓机的全面屏,不太可能一一判断,而且某些安卓全面屏是没有用iPhone底部的工具条的(不存在冲突的情况),所以我们只判断iPhone X的情况就可以了,其他全面屏就不需要给底部预留了。 至于全面屏布局的适配,需要用[代码]flex[代码]布局或者获取屏幕宽高来慢慢调了,建议最好用flex布局自适应处理。 for循环中的子组件click事件无法触发 [代码]Page -> 父组件 -> 子组件[代码],在子组件click后[代码]$emit[代码]一个事件出来,发现无法触发。 这个bug一开始没有出现,但偶然[代码]npm run build[代码]出现的,然后排查原因,后面即使回滚所有版本再npm start也还会出现。好像不触发则已,一发就不可收拾,这又是一个大坑,搜issue和加群问人,当晚下班回家研究到1点多都没有解决。 第二天继续研究,感觉可能是框架的原因,最后尝试升级一下mpvue版本,没想到就正常了。直接使用quick-strat项目的[代码]mpvue[代码]版本是 2.0.0,[代码]mpvue[代码]和[代码]mpvue-template-compiler[代码]升级到最新[代码]2.0.6[代码]就解决了。 事后查看mpvue版本记录,果然是框架本身原因,并且找到了issue。 npm run build后代码报错,再build一次可能报另一些错 解决: 没找到原因,可能是引入vant导致的,打包时丢失了部分文件。多build几次,或者重启下小程序开发者工具就正常了。 mpvue中created() 钩子会在页面初始化时全部一起触发,尽量不要用 小程序生命周期的理解 进入已销毁的page组件时依次触发: onLoad,onShow,onReady,beforeMount,mounted 第一次进入已销毁的子组件时依次触发: onLoad,onReady,beforeMount,mounted 第二次进入已销毁的子组件时依次触发: onLoad,onShow,onReady 再次进入 未被销毁的page组件、子组件时只触发: onShow mpvue文档中建议尽量不要使用小程序的生命周期,这个应该是为了让项目更好地适应支付宝小程序和头条小程序等,所以才这样建议大家尽量不要使用某一个小程序自身的api。 如果你们的小程序只是微信小程序(不考虑兼容其他平台小程序),我建议直接用小程序的生命周期,而不要用mpvue的生命周期,坑太多了。比如mpvue的created周期,初始化时所有的page都会执行,所以created这个周期是不能用了。 onUnload不触发 小程序中与平常web开发不同的是,它的页面会被缓存。举个例子: 从[代码]page1[代码]跳转到[代码]page2[代码],再从[代码]page2[代码]返回[代码]page1[代码],此时的[代码]page1[代码]还没销毁,不会触发[代码]onLoad[代码]再重新渲染,而是直接使用之前的数据。从性能上来说,单纯的返回不应该再请求api获取数据重新渲染,这是对的,符合我们的预期。 而有时候,从[代码]page2[代码]返回[代码]page1[代码]时,我们希望[代码]page1[代码]是重新获取数据渲染的。比如在[代码]page2[代码]做了一个退出登录的操作,此时再返回[代码]page1[代码]时,还是会看到之前的数据。实际上我们的预期是:由于已经退出登录了,[代码]page1[代码]的数据应该被销毁了。 在平常的web开发中,遇到上面的问题,我们可能是不管缓存,每次返回[代码]page1[代码]都再次请求api渲染最新的数据,牺牲掉部分性能从而保证逻辑的正确性。 在mpvue中我也尝试这样干了:想在[代码]page1[代码]的[代码]onUnload()[代码]生命周期中销毁数据,但是没有成功。即使在[代码]page2[代码]退出登录时,采用[代码]wx.reLaunch()[代码]重新刷一遍,[代码]page1[代码]的[代码]onUnload()[代码]生命周期也没有执行。所以[代码]onUnload()[代码]是有可能不执行的,建议慎用。 最后还是得想办法做到在[代码]page2[代码]控制[代码]page1[代码]的数据销毁或保留。想到这里,[代码]vuex[代码]就不自觉浮现在眼前了,如果page1的数据是通过vuex来控制的,那么我在page2就可以用vuex来灵活管理其他页面的数据了。 如果page2做退出登录操作时,就让page1的数据销毁,如果是不退出登录正常返回,page1的数据还是正常,做到灵活控制。 个人平时web开发很少用[代码]vuex[代码],因为项目比较简单不用那么复杂的全局数据传递。但在小程序中,建议全局使用[代码]vuex[代码]来控制所有数据(当然是得根据需求来用)。 总结 第一次开发小程序就直接上了mpvue,可能有些坑已经很多同学总结过了,有些坑可能是不熟悉而导致的,但自己没有去踩过一遍可能不够深刻。 有两种坑会比较难啃: 框架本身的问题,如mpvue2.0.0出现的子组件无法触发事件的问题。 开发者工具和真机运行环境不一致导致的坑。 遇到真机和开发者工具不一致的情况,可按以下步骤排查: 有可能是缓存,可以杀掉之前的版本再跑起来 手机微信版本太低,可能api不支持,用[代码]wx.canIUse[代码]打印一下 手机端某些属性不支持读取,比如上面的[代码]this.audio.src[代码],可以在真机打印调试一下 代码在手机端运行有报错,可以在手机端开启调试,看一下log 微信设计上的坑,百度下是否有相关的案例和解决办法 而遇到mpvue框架的问题可以: 去搜一下[代码]mpvue[代码]的issue看有没相关解决办法 尽量使用最新版本的框架,可能某些问题已经修复了的。实在解决不了的,建议想办法绕过,换一种方法来实现。 希望对大家有所帮助。
2019-03-12 - 前端加载优化及实践
大家都知道产品体验的重要性,而其中最重要的就是加载速度,一个产品如果打开都很慢,可能也就没有后面更多的事情了。这篇文章是我最近项目中的一些加载优化总结,欢迎大家一起讨论交流。 内容包括: 性能指标及数据采集 性能分析方法 性能优化方法 性能优化具体实践 第一部分:性能指标及数据采集 要优化性能首先需要有一套用来评估性能的指标,这套指标应该是是可度量、可线上精确采集分析的。现在来一起看看如何选择性能指标吧。 1. 性能指标 加载的过程是一个用户的感知变化的过程。所以我们的页面性能指标也是要以用户感知为中心的。下面是google定义了几个以用户感知为中心的性能指标。 1.1 以用户感知为中心的性能指标 首先确定页面视觉的变化传递给用户的感知变化关键点: 感知点 说明 发生了吗? 浏览是否成功。 有用了吗? 是否有足够的内容呈现给用户。 可用了吗? 用户是否可以和页面交互了。 好用吗? 用户和应用交互是否流畅自然。 我这里讲的是加载优化,所以第四点暂时不讨论。下面是感知点相关的性能指标。 First paint(FP) and first contentful paint(FCP) FP: Webview跳转到应用的首次渲染时间。 FCP:Webview首次渲染内容的时间:文本,图像(包括背景图像),非白色画布或SVG。这是用户第一次消费内容的时间。 Chrome支持用Paint Timing API获取这两个值: [代码] performance.getEntriesByType("paint") [代码] First meaningful paint(FMP) 首次绘制有效内容的时间,用来表明这个应用是否绘制了有效内容。比如天气应用可以看到天气了,商品列表可以看到商品了。 Time to Interactive(TTI) 应用可交互时间,这时应用渲染完成且可以响应用户输入的时间。这种情况下JS已经加载完成且主线程处于空闲状态。 Speed index 速度指标:代表填充页面内容的速度。要想降低速度指标分数,您需要让加载速度从视觉上显得更快,也就是渐进式展示。 上面指标对应的感知点如下: 感知点 说明 发生了吗? FP/FCP 有用了吗? FMP 可用了吗? TTI Speed index是个整体效果指标所以没有对应上面的任何一个,但也同时对应任何一个。 对于实际项目中我们选取指标要便于采集,下面是针对我的实际项目(APP内的单页面应用)选取的性能指标。 1.2 实际项目选取的性能指标 Webview加载时间 反应Webview性能。这样就可以更真实的知道我们应用的加载情况。 页面下载时间 反应浏览成功时间。 应用启动时间 反应应用启动完成时间,这个时候页面初始化完成,是JS首次执行完成的时间,应用所需异步请求都已经发出去了。 首次有效绘制内容时间 已经有足够的内容呈现给用户,是首屏所需重要接口返回且DOM渲染完成的时间,这个时间由程序员自行判断。 应用加载完成时间 应用完整的呈现给了用户,这个时候页面中所有资源都已经下载好,包括图片等资源。 这里我们的性能指标确定了,下面看看这些数据怎么采集吧。 2. 数据采集 performance.timing为我们提供页面加载每个过程的精确时间,如下图: [图片] 是不是很完美,这足够了?还不够,我们还需要加上原生APP为我们提供的点击我们应用的时间和我们自己确定的FMP才够完美。 下面是每个指标的获取方法: 公用代码部分 [代码]let performance = window.performance || window.msPerformance || window.webkitPerformance; if (performance && performance.timing) { let t = performance.timing; let navigationStart = t.navigationStart; //跳转开始时间 let enterTime = ""; //app提供的用户点击应用的时间,需要和app沟通传递方式 //... 性能指标部分 } [代码] Webview加载时间 [代码] let webviewLoaded = navigationStart - enterTime; [代码] 注意:enterTime应该是客户端ms时间戳,不是服务器时间。 页面下载时间 [代码] let pageDownLoadedTime = t.responseEnd - navigationStart; [代码] 应用启动时间 [代码]let appStartTime = t.domContentLoadedEventStart - navigationStart; [代码] 首次有效绘制内容时间 这里我们需要在有效绘制后调用 [代码]window._fmpTime = +(new Date())[代码]获取当前时间戳。 [代码]let fmpTime = window._fmpTime - navigationStart; [代码] 应用加载完成时间 [代码]let domCompleteTime = t.domComplete - navigationStart; [代码] 最后在document load以后使用上面代码就可以收集到性能数据了,然后就可以上报给后台了。 [代码]if (document.readyState == 'complete') { _report(); } else { window.addEventListener("load", _report, false); } [代码] 这样就封装了一个简单性能数据采集上报组件,这是非常通用的可以用在类似项目中使用只要按照标准提供enterTime和window._fmpTime就可以。 3. 数据分析 有了上面的原始数据,我们需要一些统计方法来观察性能效果和变化趋势,所以我们选取下面一些统计指标。 平均值 注意在平均值计算的时候要设置一个取值范围比如:0~10s以防脏数据污染。 平均值的趋势用折线图展示: [图片] 分布占比 可以清晰的看到用户访问时间的分布,这样你就可以知道有多少用户是秒开的了。 分布占比可以使用折线图、堆积图、饼状图展示: [图片] [图片] [图片] 第二部分:性能分析方法 上面有了性能指标和性能数据,现在我们来学习一下性能分析的一些方法,这样我们才能知道性能到底哪里不行、为什么不行。 1. 影响性能的外部因素 分析性能最重要的一点要确定外部因素。经常会有这种情况,有人反应页面打开速度很慢,而你打开速度很快,其实可能并不是页面性能不好,只是外部因素不同而已。 所以做好性能优化不能只考虑外部因素好的情况,也要让用户能在恶劣条件(如弱网络情况)下也有满足预期的表现。下面看看影响性能的外部因素主要有哪些。 1.1 网络 网络可以说是最影响页面性能最重要的外部因素了,网络的主要指标有: 带宽:表示通信线路传送数据的能力,即在单位时间内通过网络中某一点的最高数据率,单位有bps(b/s)、Kbps(kb/s)、Mbps(mb/s)等。常说的百兆带宽100M就是100Mbps,理论下载最大速度12.5MB/s。 时延:Delay,指数据从网络的一端传送到另一端所需的时间,反应的网络畅通程度。 往返时间RTT:Round-Trip Time,是指从发送端发送数据到接收端接受到确认的总时间。我们经常用的ping命令就是用这个指标表明我们和目标主机的网络顺畅程度。比如我们要对比几个翻墙代理哪里个好,我们就可以ping一下,看看这几个代理哪个RTT低来作出选择。 [图片] 这三个主要指标中后面两个类似,在Chrome中模拟网络主要用设置带宽和网络延迟(往返时间RTT出现最小延迟)来模拟网络。我们电脑一般用的是WI-FI(百兆),那么我们模拟网络,主要模拟常见3G(1兆)、4G(10兆)网络就好,这样我们就覆盖了三个级别的网络情况了。 可以在Chrome的NetWork面板直接选取Chrome模拟好的网络,这个项目network-emulation-conditions中有默认模拟网络的速度。 [图片] 如果默认不满足,你也可以自己配置网络参数,在设置面板的Throttling。 [图片] 上面设置的3G接近100KB/s,4G 0.5MB/s。你可以根据自己的需要来调整这个值,这两个值的差异应该能很好两种不同的网络情况了。设置模拟网络只要能覆盖不同的带宽情况就好,也不用那么真实因为真实情况很复杂。网络部分就介绍完了,接着看其他因素。 1.2 用户机器性能 经常会有这种情况,一个应用在别人手机上打开速度那么快、那么流畅,为啥到我这里就不行了呢?原因很简单人家手机好,自然有更好的配置、更多的资源让程序运行的更快。 Chrome现在非常强大你可以通过performance面板来模拟cpu性能。也可以让你看到应用在低性能机器上的表现。 [图片] 1.3 用户访问次:首次访问、2次访问、发版本访问 用户访问次数也是分析性能的重要外部因素,当用户第一次访问要请求所有资源,后面在访问因为有些资源缓存了访问速度也会不同。当我们开发者又发版本,会更新部分资源,这样访问速度又会跟着变。因为缓存的效果存在,所以这三种情况要分开分析。同时也要注意我们是否要支持用户离线访问。 通过在Chrome中的Network面板中选中Disable cache就可以强制不缓存了,来模拟首次访问。 [图片] 1.4 因素对选取 上面的外部因素虽然只有3种但相乘也有不少情况,为了简化我们性能分析,要选取代表性的因素去分析我们的性能。下面是指导因素对: 网络:WIFI 3G 4G 用户访问状态:首次 2次 这样有6种情况不算特别多,也能很好反应我们应用在不同情况下的性能。 2. devtools具体分析性能 通过devtools可以观察在不同外部因素下代码具体加载执行情况,这个工具是我们性能分析中最重要的工具,加载优化这里我们主要关注两个面板:Network、Performance。 先看Network面板的列表页: [图片] 这是网络请求的列表,右击表头可以增删属性列,根据自己需要作出调整。 下面我介绍网络列表中的几个重点属性: Protocol:网络协议,h2说明你的请求是http2协议的了。 Initiator:可以查到这个资源是哪里引用的。 Status:网络状态码。 Waterfall:资源加载瀑布流。 下面在看看Network面板中单个请求的详情页: [图片] 这里可以看到具体的请求情况,Timing面板是用来观察这次网络的请求时间占用的具体情况,对我们性能分析非常重要。具体每个时间段介绍可以点击Explanation。 虽然Network面板可以让我看到了网络请求的整体和单个请求的具体情况,但Network面板整体请求情况看着并不友好,而且也只有加载情况没有浏览器线程的执行情况。下面看看强大的Performance面板的吧。 [图片] 这里可以清晰看到浏览器如何加载资源如何解析html、解析css、执行js和渲染绘制的。 Performance简直太强大了,所以请你务必要掌握它的使用,这里篇幅有限,只能介绍了个大概,建议到google网站仔细学习一下。 3. Lighthouse整体分析性能 使用Lighthouse可以对应用做整体性能分析评分,并且会给我们专业的指导建议。我们可以安装Lighthouse插件或者安装Lighthouse npm包来使用它。 检测结果中可以看到很多性能指标的分值和建议。你也可以去测试下你的应用表现。 4. 线上用户统计分析性能 虽然使用devtools和Lighthouse可以知道页面的性能情况,但我们还要观察用户的真实访问情况,这才能真实反映我们应用的性能。线上数据采集分析,第一步部分已经介绍过了,这里就不在多说了。优化完看看自己对线上数据到底造成了什么影响。 上面介绍了性能分析的方法,可以很好帮你去分析性能,有了性能分析的基础,下面我们在来看看怎么做性能优化吧。 第三部分:性能优化方法 1. 微观:优化单次网络请求时间 在性能分析知道Network面板可以看到单次网络请求的详情 [图片] 从图可以看出请求包括:DNS时间、TCP时间、SSL时间(https)、TTFB时间(服务器处理时间)、ContentLoaded内容下载时间,所以有下面公式: [代码]requestTime = DNS + TCP + SSL+ TTFB +ContentLoaded [代码] 所以只要我们降低这里面任意一个值就可以降低单次网络请求的时间了。 2. 宏观:优化整体加载过程 加载过程的优化就是不断让第一部分的性能指标感知点提前的过程。通过关键路径优化、渐进式展示、内容效率优化手段,来优化资源调度。 2.1 加载过程 在介绍页面加载过程,先看看渲染绘制过程: [图片] Javascript:操作DOM和CSSOM。 样式计算:根据选择器应用规则并计算每个元素的最终样式。 布局:浏览器计算它要占据的空间大小及其在屏幕的位置。 绘制:绘制是填充像素的过程。 合成。由于页面的各部分可能被绘制到多层,合成是将他们按正确顺序绘制到屏幕上,正确渲染页面。 渲染其实是很复杂的过程这里只简单了解一下,想深入了解可以看看这篇文章。 了解了渲染绘制过程,在学习加载过程的时候就可以把它当作黑盒了,黑盒只包括渲染过程从样式计算开始,因为上面的Javascript主要是用来输入DOM、CSSOM。 浏览器加载过程: Webview加载 下载HTML 解析HTML:根据资源优先级加载资源并构建DOM树 遇到加载同步JS资源暂停DOM构建,等待CSSOM树构建 CSS返回构建CSSOM树 用已经构建的DOM、CSSOM树进行渲染绘制 JS返回执行继续构建DOM树,进行渲染绘制 当HTML中的JS执行完成,DOM树第一次完整构建完成触发:domContentLoaded 当所有异步接口返回后渲染制完成,并且外部加载完成触发:onload 注意点: CSSOM未构建好页面不会进行任何渲染 脚本在文档的何处插入,就在何处执行 脚本会阻塞DOM构建 脚本执行要等待CSSOM构建完成后执行 下面看看如何在加载过程提前感知点。 2.2 优化关键路径 把关键路径定义为:从页面请求到应用启动完成这个过程,也就是到JS执行完domContentLoaded触发的过程。 主要指标有: 关键资源: 影响应用启动完成的资源。 关键资源的数量:这个过程中加载的资源数据。 关键路径长度:关键资源请求的串行长度。 关键字节的数量:关键资源大小总和。 [图片] 上图关键资源有:html、css、3个js。关键资源数量:5个。关键字节的数量:5个资源的总大小。关键路径长度:2,html+剩余其他资源。 关键优化路径优化,就是要降低关键路径长度、关键字节的数量,在http1时代还要降低关键资源的数量,现在http2资源数不用关心。 2.3 优化内容效率 主要是关注的应用加载完成这个时间点,由首页加载完成所需的资源量决定。我们要尽量减少加载资源的大小,避免不必要加载的资源,比如做一些图片压缩懒加载尽快让应用加载完成。 主要指标有: 应用加载完成字节数:应用加载完成,所需的资源大小。 这个指标可以从Chrome上观察到,不过要剔除prefetch的资源。这个指标一般不太稳定,因为页面展示的内容不太相同,所以最好在相同内容相同情况下对比。 2.4 渐进式展示 从上面的加载过程中,可以知道渲染是多次的。那样我们可以先让用户看到一个Loading提示、先展示首屏内容。Loading主要优化的是FP/FCP这两个指标,先展示首屏主要是优化FMP。 3. 缓存:优化多次访问 缓存重点强调的是二次访问、发版访问、离线访问情况下的优化。 通过缓存有效减少二次访问、发版访问所要加载资源,甚至可以让应用支持离线访问,而且是对弱网络环境是最有效的手段,一定要善于使用缓存这是你性能优化的利器。 4. 优化手段 优化手段我归纳为5类:small(更小)、pre(更早)、delay(更晚)、concurrent(并发)、cache(缓存)。性能优化就是将这5种手段应用于上面的优化点:网络请求优化、关键路径优化、内容效率优化、多次访问优化。 5. 构建自己可动态改变的优化方法表和检查表 Checklist包括两部分,一个优化方法表,另外一个优化方法检查表。优化方法表是让我们对我们的性能优化方法有个评估和认识,优化方法检查表的好处是,可以清晰的知道你的项目用了哪些优化方法,还有哪些可以尝试做进一步优化,同时作为一个新项目的指导。 优化名:优化方法的名字。 优化介绍:对优化方法做简单的介绍。 优化点:网络请求优化、关键路径优化、内容效率优化、多次访问优化。 优化手段:small、pre、delay、concurrent、cache。 本地效果:选取合适的因素对,进行效果分析,确定预期作用大小。 线上效果:线上效果对比,确定这个优化方案的有效性及实际作用大小。 这样我们就能大概了解了这个效果的好处。我们新引入了一种优化方法都要按这张表的方法进行操作。 优化方法表: 名称 内容 优化名 JS压缩 优化介绍 压缩JS 优化点 关键路径优化 优化手段 small 本地效果 具体本地效果对比 线上效果 线上数据效果 上面是以JS压缩为例的优化方法表。 优化方法检查表: 分类 优化点 是否使用 不适用 问题说明 small JS压缩 √ pre preload/prefetch √ 不需要 通过这张表就能看出我们使用了哪些方法,还有哪些没使用,哪些方法不适用我们。可以很方便的应用于任何一个新项目。 第四部分:性能优化具体实践 现在就看看我在项目中的具体实践吧,项目中使用的技术栈是:Webpack3+Babel7+Vue2,下面我按照优化手段介绍: 1. small(更小) scope-hoisting scope-hoisting(作用域提升):Webpack分析出模块之间的依赖关系,把可以合并到一起模块合并到一起,但不造成冗余,因此只有被一个地方引用的代码可以合并到一起。这样做函数声明会变少,可以让代码更小、执行更快。 这个功能从Webpack3开始引入,依赖于ES2015模块的静态分析,所以要把Babel的preset要设置成[代码]"modules": false[代码]: [代码] ... [ "@babel/preset-env", { "modules": false ... [代码] Webpack3要引入ModuleConcatenationPlugin插件,Webpack4 product模式已经预置该插件: [代码]... new webpack.optimize.ModuleConcatenationPlugin(), ... [代码] [图片] 如上图,不压缩的JS中可以文件中看到CONCATENATED MODULE这就说明生效了。 tree-shaking 摇树:通常用于描述移除JavaScript上下文中的未引用代码,在webpack2中开始内置。依赖于ES2105模块的静态分析,所以我们使用babel同样要设置成 [代码]"modules": false[代码]。 [图片] 如上图,不压缩的JS中可以文件中看到unused harmony这就说明摇树成功了。 code-splitting(按需加载) 代码分片,将代码分离到不同的js中,进行并行加载和按需加载。 代码分片主要有两种: 按需加载:动态导入 vendor提取:业务代码和公共库分离 这里只介绍按需加载部分,动态导入Webpack提供了两个类似的技术。1. Webpack特定的动态导入require.ensure。2.ECMAScript提案[代码]import()[代码]。这里我只介绍我使用的[代码]import()[代码]这种方法。因为是推荐方法。 代码如下: Babel配置支持动态导入语法: [代码]... "@babel/plugin-syntax-dynamic-import", ... [代码] 代码中使用: [代码]... if(isDevtools()){ import(/* webpackChunkName: "devtools" */'./comm/devtools').then((devtools)=>{ let initDevtools = devtools.default; initDevtools(); }); } ... [代码] polyfill按需加载 我们代码是ES2015以上版本的要真正能在浏览器上能使用要通过babel进行编译转化,还要使用polyfill来支持新的对象方法,如:Promise、Array.from等。对于不同环境来说需要polyfill的对象方法是不一样的,所以到了Babel7支持了按需加载polyfill。 下面是我项目中的配置,看完以后我会介绍一下几个关键点: [代码]module.exports = function (api) { api.cache(true); const sourceType = "unambiguous"; const presets = [ [ "@babel/preset-env", { "modules": false, "useBuiltIns": "usage", // "debug": true, "targets": { "browsers": ["Android >= 4.0", "ios >= 8"] } } ] ]; const plugins= [ "@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-strict-mode", "@babel/plugin-proposal-object-rest-spread", [ "@babel/plugin-transform-runtime", { "corejs": false, "helpers": true, "regenerator": false, "useESModules": false } ] ]; return { sourceType, presets, plugins } } [代码] @babel/preset-env preset是预置的语法转化插件的集合。原来有很多preset如:@babel/preset-es2015。直到出现了@babel/preset-env,它可以根据目标环境来动态的选择语法转化插件和polyfill,统一了preset众多的局面。 [代码]targets[代码]:是我们用来设置环境的,我的应用支持移动端所以设置了上面那样,这样就可以只加载这个环境需要的插件了。如果不设置[代码]targets[代码]通过@babel/preset-env引入的插件是 @babel/preset-es2015、@babel/preset-es2016和@babel/preset-es2017插件的集合。 [代码]"useBuiltIns": "usage"[代码]:将useBuiltIns设置为usage就会根据执行环境和代码按需加载polyfill。 @babel/plugin-transform-runtime 和polyfill不同,@babel/plugin-transform-runtime可以在不污染全局变量的情况下,使用新的对象和方法,并且可以移除内联的Babel语法转化时候的辅助函数。 我们这里只用它来移除辅助函数,不需要它来帮我处理其他对象方法,因为我们在开发应用不是做组件不怕全局污染。 sourceType:“unambiguous” 一个文件混用了ES2015模块导入导出和CJS模块导入导出。需要设置[代码]sourceType:"unambiguous"[代码],需要让babel自己猜测类型。如果你的代码都很合规不用加这个的。 压缩:js、css js、css压缩应该最基本的了。我在项目中使用的是[代码]UglifyJsPlugin[代码]和[代码]optimize-css-assets-webpack-plugin[代码],这里不做过多介绍。 压缩图片 通过对图片压缩来进行内容效率优化,可以极大的提前应用加载完成时间,我在项目中做了下面两件事。 广告图片,限制大小50K以内。原来基本会上传超过100K的广告图。 项目中图片使用的[代码]img-loader[代码]对图片进行压缩。 HTTP2支持,去掉css中base64图片 先看看HTTP1.1中的问题: 同一域名浏览器做了TCP连接数的限制,如:Chrome中只能有6个。 一个TCP连接只能同时处理一个请求响应。 在看看HTTP2的优势: 二进制分帧:HTTP2的性能增强的核心在于新的二进制分帧层。帧是最小传输单位,帧组成消息,数据以消息形式发送。 多路复用:所有请求在一个连接上完成,可以支持多数据流混合传输,在接收端拼接。 头部压缩:使用HPACK对头部压缩,网络中可以传递更少的数据。 服务端推送:服务端可以主动向客户端推送资源。 有了HTTP2我们在也不用担心资源数量,不用在考虑减少请求了。像:base64图片打到css、合并js、域名分片、精灵图都不要去做了。 这里我把原来base64压缩图片从css中去除了。 2. pre(更早) preload prefetch preload:将资源加载和执行分离,你可以根据你的需要指定要强制加载的资源,比如后面css要用到一个字体文件就可以在preload中指定加载,这样提高了页面展示效果。建议把首页展示必须的资源指定到preload中。 prefetch:用来告诉浏览器我将来会用到什么资源,这样浏览器会在空闲的时候加载。比如我在列表页将详情页js设置成prefetch,这样在进入详情页的时候速度就会快很多,因为我提前加载好了。 这里我用的是来使用[代码]preload-webpack-plugin[代码]preload和prefetch的。 代码: [代码]... const PreloadWebpackPlugin = require('preload-webpack-plugin'); ... new PreloadWebpackPlugin({ rel: 'prefetch', include: ['devtools','detail','VideoPlayer'] }), ... [代码] dns-prefetch preconnect dns-prefetch:在页面中请求该域名下资源前提前进行dns解析。preconnect:比dns-prefetch更近一步连TCP和SSL都为我们处理好了。 使用注意点:1. 考虑到兼容性问题,我们对一个域名两个都设置 2. 对于应用中不一定会使用的域名我们设置dns-prefetch就好以防占用资源。 代码如下: [代码]... <link rel="preconnect" href="//game.gtimg.cn"> ... <link rel="dns-prefetch" href="//game.gtimg.cn"> ... [代码] 3. delay(更晚) lazyload 对图片进行懒加载,我使用的是[代码]vue-lazyload[代码]。 代码如下: [代码]... import VueLazyload from 'vue-lazyload' ... Vue.use(VueLazyload, { preLoad: 1.3, error: '...', loading: '...', attempt: 1 }); ... <div class='v-fullpage' v-lazy:background-image="item.roomPic" :key="item.roomPic"></div> ... [代码] 这里的:key特别注意,如果你的列表数据是动态变化的一定要设置,否则图片是最开始一次的。 code-splitting(按需加载) code-splitting(按需加载)前面已经介绍过这里只是强调下它的delay作用,不使用的部分先不加载。 4. concurrent(并发) HTTP2 HTTP2前面已经应用在了css体积减少,这里主要强调它的多路复用。需要大家看看自己的项目是否升级到HTTP2,是否所有资源都是HTTP2的,如果不是的,需要推进升级。 code-splitting(vendor提取) vendor提取是把业务代码和公共库分离并发加载,这样有两个好处: 下次发版本这部分不用在加载(缓存的作用)。 JS并发加载:让先到并在前面的部分先编译执行,让加载和执行并发。 Webpack配置: [代码] ... entry:{ "bundle":["./src/index.js"], "vendor":["vue","vue-router","vuex","url","fastclick","axios","qs","vue-lazyload"] }, ... new webpack.optimize.CommonsChunkPlugin({ name: "vendor", minChunks: Infinity }), new webpack.optimize.CommonsChunkPlugin({ name: 'manifest' }), ... [代码] 5. cache(缓存) HTTP缓存 HTTP缓存对我们来说是非常有用的。 下面介绍下HTTP缓存的重点: Last-Modified/ETag:用来让服务器判断文件是否过期。 Cache-Control:用来控制缓存行为。 max-age: 当请求头设置max-age=deta-time,如果上次请求和这次请求时间小于deta-time服务端直接返回304。当响应头设置max-age=deta-time,客户端在小于deta-time使用客户端缓存。 强制缓存:这主要把不经常变化的文件设置强制缓存,这样就不需要在发起HTTP请求了。通过设置响应头Cache-Control的max-age设置。 如果像缓存很久设置一个很大的值,如果不想缓存设置成:Cache-Control:no-cahce。 协商缓存:如果没有走强制缓存就要走协商缓存,服务器根据Last-Modified/ETag来判断文件是否变动,如果没变动就直接返回304。 这里我们做的就是让运维调整资源的强制缓存时间,前端在结合文件hash命名就可以进行资源更新了。 ServiceWorker ServiceWorker是Web应用和浏览器之间的代理服务器,可以用来拦截网络来进行资源缓存、离线体验,还可以进行推送通知和后台同步。功能非常强大,我们这里使用的是资源缓存功能,看看和HTTP缓存比有什么优势: 功能多:支持离线访问、资源缓存、推送通知、后台同步。 控制力更强:缓存操作+络拦截功能都由开发者控制,可以做出很多你想做的事情比如动态缓存。 仅HTTPS下可用,更安全。 看看我在项目中的使用: js使用HTTP缓存和ServiceWorker双重缓存在cacheid变化后依然可以缓存。 不得对service-worker.js缓存,因为我们要用这个更新应用。在Chrome中看到请求的cache-control被默认设置了no-cache。 我们项目中使是Google的Workbox,Webpack中插件是 workbox-webpack-plugin。 [代码]... const WorkboxPlugin = require('workbox-webpack-plugin'); ... new WorkboxPlugin.GenerateSW({ cacheId: 'sw-wzzs-v1', // 缓存id skipWaiting: true, clientsClaim: true, swDest: './html/service-worker.js', include: [/\.js(.*)$/,/\.css$/], importsDirectory:'./swmainfest', importWorkboxFrom: 'local', ignoreUrlParametersMatching: [/./] }), ... [代码] localStorage localStorage项目中主要做接口数据缓存。通常localStorage是没有缓存时间的我们将其封装成了有时间的缓存,并且在应用启动的时候对过期的缓存清理。 code-splitting(vendor提取) 这里在提vendor提取主要是说明它发版本时候的缓存价值,前面介绍过了。 6. 整体优化效果评价 经过上面的优化,看看效果提升吧。 主要增长点来源: 关键路径资源:698.6K降低到538.6K降低22.9% 内容效率提升:广告图由原来的基本100K以上降低到现在50K以下,页面内图片全部走强制缓存。 缓存加快多次访问速度:js+css强制缓存加ServiceWorker。 线上数据效果: 页面下载时间: 平均值下降:25.74%左右 应用启动完成时间: 平均值下降:33.45%左右 秒开占比提高:23.42%左右 应用加载完成时间: 平均值下降:48.02%左右 第六部分:总结 以上就是我在加载优化方面的一些总结,希望对您有所帮助,个人理解有限,欢迎一起讨论交流。
2019-03-11