Skip to content

实战篇 7:对小程序进行优化

页面流程优化

合理的页面流程可以加快页面打开速度,下面介绍几种常见的方式。

减少白屏时间

当进入的页面还在进行数据加载的时候,如果加载时间过长,用户看不到页面实际内容,看到的只是一个白屏界面。可以通过增加骨架屏(Skeleton Screen)或者默认数据来减少白屏时间。在新鲜天气的天气预报页面,获取数据的流程较长,笔者通过在首屏提前渲染默认数据来减少白屏时间,效果如下图所示。

当然这个骨架屏和默认数据做得还不够细致,有兴趣的读者可以通过 GitHub 上的源码继续优化。

利用逻辑层空闲时间预加载数据

小程序是由逻辑层和视图层共同作用的,逻辑层代码(App Service)在小程序执行的生命周期内会常驻内存,并不会因为切换页面而释放资源,利用这个特点,可以对页面流程进行一些优化。

新鲜天气是由天气预报和心情签到两个页面组成的,在天气预报页面数据获取结束之后、页面空闲之时,如果用户已经授权登录信息,那么可以提前获取心情签到页面的数据,将数据存入 app.js 的 globalData 中,当用户进入心情签到页时,就可以减少等待时间,很快看到页面内容了。

天气预报页面对心情签到页面数据预取逻辑如下:

// weather/index.js
let prefetchTimer
const app = getApp()

Page({
  onHide(){
    // 切走,则清理计时器
    clearTimeout(prefetchTimer)
  },
  onShow(){
    // 显示则添加计时器
    this._setPrefetchTimer()
  },
  _setPrefetchTimer(){
    // 10s 预取
    if(!app.globalData.currentMonthData.length && isUpdate){
      prefetchTimer = setTimeout(() => {
        this.prefetch()
      }, 10e3)
    }
  },
  prefetch(){
    let openid = wx.getStorageSync('openid')
    if(openid){
      // 存在则预取当前时间的心情
      const now = new Date()
      const year = now.getFullYear()
      const month = now.getMonth() + 1
      getEmotionByOpenidAndDate(openid, year, month)
      .then((r) => {
        const data = r.data || []
        // console.log(data)
        app.globalData.currentMonthData = data
      }).catch(e=>{})
    }
  }
})

在上面的预取代码中有个小技巧,就是增加了定时器清理功能,当页面切走(onHide)跳转到心情签到页面时,在后台预取数据已经变得没有意义了,所以及时地清理了定时器;而当页面再切回(onShow)的时候又重新启动了定时器,当然定时器的启动是以 globalData 没有数据,并且天气页面已经完成渲染为前提的。在心情签到页面中,获取的心情数据要存入 globalData,这样数据就打通了。

除了预取下一页的数据,如果整个项目中有较多的静态外链资源需要加载,也可以在首页空闲的时候进行预取。

默认数据缓存

对于用户第一次进入小程序的数据可以使用默认数据来构建骨架屏,空闲时预取下一页数据;而用户再次进入小程序,有了上一次的数据了,就可以使用之前的数据优先展示,等数据更新后重新渲染页面即可。心情签到数据也是这样,对于一个用户不能更改之前的签到数据,可以将这些数据存入小程序的本地缓存,减少 SQL 请求。要记录上次的数据,可以使用小程序的数据缓存 API

// weather/index.js
// render 之后缓存数据
  dataCache() {
    const {current, backgroundColor, backgroundImage, today, tomorrow, address, tips, hourlyData} = this.data
    wx.setStorage({
      key: 'defaultData',
      data: {
        current,
        backgroundColor,
        backgroundImage,
        today,
        tomorrow,
        address,
        tips,
        hourlyData
      }
    })
  },
  // onLoad 内部获取数据之前调用
  setDataFromCache() {
    wx.getStorage({
      key: 'defaultData',
      success: ({data}) => {
        if (data && !isUpdate) {
          // 存在并且没有获取数据成功,那么可以给首屏赋值上次数据
          const {current, backgroundColor, backgroundImage, today, tomorrow, address, tips, hourlyData} = data
          this.setData({
            current,
            backgroundColor,
            backgroundImage,
            today,
            tomorrow,
            address,
            tips,
            hourlyData
          })
        }
      }
    })
  }

效果如下:

控制包体积大小

当小程序第一次启动(冷启动)或者有更新包的时候,微信客户端会自动下载最新包,尤其是「冷启动」的时候,用户跟小程序的第一次接触如果因为资源包体积过大而一直下载数据,从而造成体验不好,那么对用户的伤害是相当大的。下面来介绍几种减少包体积的方法。

静态资源压缩

要控制包体积大小,可以梳理资源包内静态资源,从以下几个方面入手:

  • 使用压缩工具或者直接勾选开发者工具中「上传代码时,压缩代码」选项
  • 及时清理无用的代码和资源文件(包括无用的日志代码)
  • 减少资源包中图片和媒体资源的数量和大小,除 icon 类小图片放在资源包内,大图片尽量放到 CDN 上
  • icon 类图片可以使用字体和雪碧图
  • 外链类静态资源要尽量使用 CDN 来提速

采用分包机制

小程序的包体积大小限制已经提高到了 2MB,但是一些复杂的大型小程序还是不够用,而且对包体积进行合理划分,做到按需加载,也可以提高页面的打开速度,于是小程序提供了分包机制。

分包机制只需要在 app.json 中按照下面格式配置分包的内容即可:

{
  "pages":[
    "pages/index",
    "pages/logs"
  ],
  "subPackages": [
    {
      "root": "packageA",
      "pages": [
        "pages/cat",
        "pages/dog"
      ]
    }, {
      "root": "packageB",
      "pages": [
        "pages/apple",
        "pages/banana"
      ]
    }
  ]
}

经过上面的配置,pages 内的内容被打包成「主包」,而 subPackages 中的内容则被打包成「子包」。当小程序打开时,采用分包机制的小程序会先下载「主包」来展现首页内容,这样极大地提升了小程序的打开速度。

注意:并不是任何程序都可以分包,具体是否采用分包形式不能只看体积大小,要考虑业务实际情况;另外业务相关性强,并且具有连贯性的要分到一个包。

代码级别的优化

上面主要是从提升资源包下载速度方面来进行优化,而当代码下载到本地执行之后,用户体验满意度则体现在代码级别的优劣上。

小程序 setData 的性能

小程序的视图层 WebView 作为渲染载体,而逻辑层是由独立的 JavaScriptCore 作为执行环境的。两者在数据传递上,是先将数据字符串化之后再通过 JSBridge 进行传递的,也正是因为数据传输是通过 JSBridge 的这种事件通知机制,在这种机制下,从 setData 到页面数据真正被渲染使用的过程是一个**异步**的过程,但是 this.data 的变化是发生在逻辑层,即是一个**同步**的过程,相同的原理,直接修改 this.data 是不被推荐的,因为虽然this.data值发生了变化,但是渲染层并没有发生变化,所以会出现数据不一致的问题!,下面用代码来理解下 this.data 值设置是同步的:

Page({
  data:{
    test: 0
  },
  onLoad(){
    this.setData({
      test: 1
    })
    console.log(this.data.test) // 1
  }
})

下面的代码,虽然都可以拿到正确的 this.data 值,但是在页面流程中的表现却是不同的:

Page({
  data:{
    test: 0
  },
  onLoad(){
    this.setData({
      test: 1
    },() => {
      console.log(this.data.test) //1,页面渲染层已经更新完成
    })
    console.log(this.data.test) // 1,只是 this.data 变化,而渲染层并没有更新变化
  }
})

正确地使用 setData 可以提升页面性能。下面几种操作是对性能有损坏的,需要在写代码的时候注意。

  1. 频繁调用 setData

不要在一个循环中频繁调用 setData(跟不要在循环内频繁操作 DOM 一样),毫秒级别的调用 setData 会导致视图层和逻辑层频繁地通过 JSBridge 进行通信,大量事件排队,最终导致页面出现卡顿的现象。

// 下面的操作是不推荐的
for(let i = 0; i < items.length; i++ ){
  this.setData({
    key: items[i]
  })
}

一次需要更新多个 data 的字段时,如果数据量不大,可以考虑统一设置一次 setData

  1. 使用 setData 传递比较大的数据

比较大的数据会导致数据字符串化过程较慢,收到数据后重新对象化的时间也会加长,另外小程序内部规定每次 setData 数据不能超过 1024kB。对于较大的数据,可以通过细分的方式来处理:

let bigData = [{
  text: '长文案1'
},{
  text: '长文案2'
}]
this.setData({
  'array[0].text': bigData[0].text
})
  1. 后台状态的 webview 使用 setData

因为整个小程序只有一个逻辑层在处理数据和事件逻辑,如果一个页面已经在后台(onHide)但还在设置 setData,那么也会占用逻辑层的通信通道和资源,所以在页面 onHide 之后,一些 setData 操作可以提前缓存起来,等页面 onShow 之后再一次性更新。

  1. 把跟页面无关的数据放到页面的 data

与当前界面渲染无关的数据最好不要设置在 data 中,而应该考虑作为内部变量来使用或者放在 page 对象的其他字段下。

比如在天气预报页面,onLoad方法先获取用户分享的文案地址,用到了获取省市县地址等信息:

onLoad() {
  // ......
  const pages = getCurrentPages() //获取加载的页面
  const currentPage = pages[pages.length - 1] //获取当前页面的对象
  const query = currentPage.options
  // 如果有地址,经纬度信息
  if (query && query.address && query.lat && query.lon) {
    let {province, city, county, address, lat, lon} = query
    // **注意下面调用了 setData 方法,设置了一些无用的 data!!!!!!**
    this.setData(
      {
        city,
        province,
        county,
        address,
        lat,
        lon
      },
      () => {
        this.getWeatherData()
      }
    )
  } else {
    // 否则,正常逻辑:先获取地址,再获取天气数据
    this.getLocation()
  }
},

这里的cityprovincecountylatlon只是在页面 js 内部使用,跟渲染页面没有关系,所以这几个变量可以从data中剔除,直接放到普通变量中:

let city, province, count, lat, lon
Page({
    data: {
        address
    },
    onLoad() {
        // 忽略代码
        // 下面只设置 address 这个跟渲染相关的数据
        // 其他直接用全局的变量即可
        this.setData(
          {
            address
          },
          () => {
            this.getWeatherData()
          }
        )

    }
})

合理使用小程序事件

小程序的事件响应是由视图层对事件进行监听,事件处理函数是通过视图层传递到逻辑层处理的,大量无用的事件绑定会增加视图层和逻辑层的通信,从而降低其他数据传输的响应时间,造成页面卡顿。尤其是 onPageScroll 这类频繁触发的事件,应该做好节流/防抖处理。

函数节流: 是指在一段时间内,频繁触发某个函数,而函数的执行结果不会因为触发次数而发生改变,这时候可以使用延迟执行函数的方式,防止函数过多调用而对性能造成影响。最常见的应用场景就是对页面滚动或者改变视口大小的监听,比如 onresizescroll 事件监听。 **函数防抖**是指频繁触发的情况下,只有足够的空闲时间,才执行代码一次。比如生活中的坐公交,就是一定时间内,如果有人陆续刷卡上车,司机就不会开车。只有别人没刷卡了,司机才开车。

// 节流
let canRun = true;
$(window).scroll(() => {
   if(!canRun){
       // 判断是否已空闲,如果在执行中,则直接return
        return;
   } 
   canRun = false;
    setTimeout(() => {
        canRun = true;
    }, 300);
}); 
// 防抖
let timer;
$(window).scroll(() => {
  if(timer){
    clearTimeout(timer)
  }
  timer = setTimeout(() => {
    // 延时 200ms,处理滚动逻辑
  }, 200)
})

另外,当小程序事件需要绑定 targetcurrentTargetdataset 时,应该尽量保持节点上 data-* 不放置过大的数据。

使用自定义组件和类库

对于多个页面都使用的代码片段,可以提炼成组件或者公共 API 来使用,这样既可以集中维护,又可以减少整体代码量。

ES6 语法尽量简单

我们在项目中使用 ES6 时,应该尽量避免使用依赖 Runtime/Polyfill 的语法,例如 import 和 class,这类语法处理成 ES5 代码会增加不少的额外代码,所以需要根据实际情况来使用。

小结

本节重点介绍了小程序优化的技巧。小程序优化可以从页面流程、包体积和静态资源管理,以及代码层次优化三个方面入手。

小程序逻辑层和视图层分离设计,利用逻辑层常驻内存可以实现资源的预加载,从而优化页面流程。小程序本身提供分包机制,可以将整个项目划分为多个「子包」实现按需加载。在写代码的时候,应该理解小程序的实现机制,避免 setData、事件绑定的不合理使用,也要考虑将页面公共的组件和 API 提炼出通用代码来维护。