回复 刷新

暂无评论

详解:小程序页面预加载优化,让你的小程序运行如飞

如何实现小程序在触发页面跳转前就请求协议,利用跳转页面的短短200~300ms的时间,获取到数据并渲染到页面上,实现数据在小程序页面中预加载。这种技术,可以缩短用户的等待时间,极大的提升用户的使用体验。今天我来具体的讲下这个技术实现方式。

框架优缺点

优点:

  1. 预加载下一个页面的数据,提高了页面的加载速度,轻量级的协议(200~300ms左右就能接收到数据)能轻松让小程序页面打开后数据瞬间加载,几乎不出现空页面。
  2. 让同种业务的代码保持在一个类中,不会破坏项目结构。
  3. 代码量非常少,对原本业务影响非常少。
  4. 实现预加载后想删掉预加载?只需在实现的类中删除一个字符串即可。

缺点:

  1. 需要你按情况替换setData为$setData
  2. 需要开发者非常清楚各情况下的上下文是什么。
  3. 如果你的协议非常耗时,达到400ms以上的,使用这种优化方式效果就不明显了。
  4. 有网友发现,这个项目无法运行在使用了组件的小程序中,所以大家如果使用了组件的话,就不要直接用这个项目了。不过还是推荐你吸收下这个项目的思想,毕竟工程师在工作中思想是很重要的。

当然,还是先给大家看下具体的效果。

这里展示的是一条协议总时间是300ms的加载效果。这里是用setTime()来模拟的。一个是今天要介绍的预加载方式(跳转前就开始请求协议)和普通加载方式(跳转后才开始请求协议),可以看到,普通加载方式,在跳转页面成功后,页面会先空,后有数据;而预加载方式一进到页面就有数据。

这里主要是用Android手机来测试的,点击按钮时是有点击态的,但是颜色太浅,淡蓝色,不容易看出来。这个点击态在预加载方案中的地位是非常重要的!!

【预加载方式】
在这里插入图片描述

【普通加载方式】

在这里插入图片描述

如何集成

重要声明:我的小程序是遵循ES6标准写的,里面用了class extends及解构赋值等,如果看不懂的话,请学习下ES6!!如果你的项目是用的ES5,那就体会预加载技术的核心思想 ~

首先,你要有个基类CommonPage

小程序中的每一个Page类都继承该基类,这样的话才方便统一管理。比如下面的IndexPage页面

在这里插入图片描述

IndexPage是第一个页面,不需要预加载。SecondPage是第二个页面,我们来模拟下SecondPage的预加载方式。接下来看到的this.route()、this.put() 、this.take()、this.resolve()、this.reject()等带符号的都是基类中实现的方法。

1. 给IndexPage页面添加跳转按钮。

在这里插入图片描述

注意:这里添加的class=“normal-style” hover-stay-time="100"是非常重要的,如果不添加点击态,会很影响体验。

2. 给IndexPage页面添加预加载专用跳转方式。
在这里插入图片描述
this.$route({path, query, clazzName});这个方法的参数含义是:

  1. path:页面路径,支持绝对路径和相对路径。
  2. query:需要传递的参数。这是一个object类型的。
  3. clazzName:需要跳转的页面的类名。这个介绍SecondPage时再说。

其实你可能会问,既然有path了,为什么还要clazzName?这个问题会在介绍技术原理时详细说,那是下一篇的事儿了。

到这里,如果你也是用ES6的规范来实现类的,可以看到,在IndexPage中,你只需将跳转方式修改为this.$route({path, query, clazzName});即可。

3. 给SecondPage页面添加预加载专用的初始化方法。

在这里插入图片描述

大概是这么几步:

  1. 这个类需要在new时,将clazzName注入this.$route({path, query,clazzName});中的clazzName名称与其一致即可。
  2. 需要在SecondPage中注入新的生命周期函数,也就是预加载方法。在执行this.route时,你在this.route中传递的clazzName是什么,这个框架就会自动去找匹配一致的类,调用该类的$onNavigator方法。
  3. 在onNavigator中调用this.put(key,fun,query)参数分别是键、异步请求方法、异步请求方法的参数。
  4. 在异步请求方法将this.setData替换为this.setData(),使用this.resolve(data)或者this.$reject(data,error)来回调成功或失败。
  5. 在onLoad中使用this.take(key).then(success,fail)来获取异步结果,分别对应了resolve和reject回调。如果你没有使用预加载,或者预加载失败,那this.take(key)方法返回空,由此可以判断是否使用了预加载进入页面!

这么做的话,实现了在跳转前先把下一个页面的协议发出去,而且还让同种业务的代码保持在一个类中,不会破坏项目结构!在实现了预加载后,如果不想用预加载了,只需要删掉new SecondPage()时注入的clazzName即可!

技术原理讲解

这个预加载方案要求与服务器的通信时间,不能大于350ms,渲染时传入的data数据量也不能太大,若超过这个值或数据量过大,页面依旧会先空后有数据,也就是跳转后闪一下。如果超过了这个值,建议服务器优化数据处理速度,或者拆分协议,先请求一部分轻量级的数据,繁重的数据根据时机之后再请求。

还有,一定要记住,在真机上测试时,一定要关闭小程序的调试模式,否则,会极大的减慢渲染数据的速度!
在这里插入图片描述
技术原理详解

这个技术核心思想是延迟跳转和预加载。

延迟跳转

延迟跳转是什么?通常情况下,一个按钮,你都要给他加点击反馈的,在小程序的view组件里是有这么两种属性。

hover-class:指定按下去的样式类。当 hover-class=“none” 时,没有点击态效果,默认值是none。

hover-stay-time:手指松开后点击态保留时间,单位毫秒。默认值是400ms。

一个按钮的点击态持续时间,100ms的体验是很好的。按钮点击态可以这样处理:

  1. 在wx.navigateTo上包裹一层setTimeout,延迟时间设置为150ms。
  2. 给view添加了hover-class和hover-stay-time这两个属性。
  3. 指定hover-stay-time的值为100。这里比上面少了50ms是为了让用户看到点击态消失时页面再跳转,体验要好很多。

这样就实现了延迟加载。

从点击按钮开始算,到执行第二个页面的onLoad方法,我们算下现在页面跳转的总时间,大概在200ms左右:

延迟150ms执行wx.navigateTo。

本身的普通跳转时间50ms

到此为止,跳转页面的时间从原来的50ms被活生生拖到了200ms。(在这里多说几句,js单线程原因,setTimeout函数是不准确的,而且普通跳转的50ms也是有上下浮动的。所以这个200ms是大概的一个值。)

你可能会很纳闷,不是要缩短加载时间吗,怎么这还得拖长时间呢?我说下我考虑的几个方面。

假设一个协议的总时间是300ms。我们取一个两个极端情况,页面跳转不花时间,打开一个新页面只花协议收发的300ms,那么有两种选择,一个是正常的方式,页面打开后发协议,等300ms看到结果;还有一个是,立刻发送协议,同时花300ms的时间来等待获取数据,获取到后进行页面跳转,那么跳转到下个页面时,数据能立刻被渲染出来!

这两种情况对应了用户的两种心态:

  1. 就算是0ms跳转完成,第二个页面没有获取到数据,用户也是一种等待的心理,也要等获取到数据后才能看到页面的样子,还会感觉你这页面加载好慢啊。
  2. 如果一个页面的跳转的做150ms的延迟处理,再加上本身跳转需要的50ms,会极大的延长跳转时间,但是却能保证轻量级的协议在这段时间内有足够的时间来完成预加载。
  3. 将按钮的点击态持续时间设置为100ms,既可以延缓用户在点击按钮时等待跳转的焦急心理,又能提供额外的时间来预加载。

所以我们可以这么处理,点击按钮立即发送协议,同时延迟150ms跳转,用按钮的点击态100ms来遮盖延迟跳转造成的等待时间,之后再花50ms时间完成页面跳转。

页面跳转完成后,从开始执行onLoad()函数到页面首次渲染数据时不闪屏的极限时间是150ms(这个时间点是在onReady()执行后的50ms内).

这个时间是我经过大量测试后得出的。这样的话,在这短短的350ms左右的时间,一个轻量级的协议可以很轻松的完成数据的获取。在跳转到下一个页面后,就可以立刻渲染数据了。

最终给用户的感觉是:页面打开的速度没有什么变化,但是打开新页面时数据加载的速度缺比以前快了!

为什么上面讲到的时间点是在onReady()函数执行的时间附近?小程序官网教程用了一张图讲生命周期。

在这里插入图片描述

可以看到,在AppService Thread线程执行完onShow()函数后,会将数据发送给View Thread来完成数据的初次渲染。这也就是说,只要你的数据在onReady()函数执行前后完成渲染,用户就应该不会看到空页面。

预加载

既然延迟跳转为预加载提供了足够的时间,那么,我们该怎样在A页面点击按钮时就立刻发送网络请求,来实现预加载B页面的数据呢?直接在A页面里发协议,全局缓存起来,然后加个观察者,等收到数据后再通知B页面更新。

这其实就是这个框架基本的思想,但是存在几个问题

  1. B页面的协议在A页面的代码中调用,对A页面造成了业务污染,不符合单一职责原则。
  2. 数据的全局缓存,会造成你的全局变量越来越多,对后期维护造成严重影响。
  3. 预加载所对应的类是成对的(比如A和B),观察者的加入,势必会让你在很多类中调用相同的代码,又乱又不优雅。
  4. 将来你不想用预加载了,那么你要修改大量的代码来恢复成原生的跳转方式,这一点也是最严重的一点。

所以在编写前我考虑了这么几个问题。

  1. 最好让B页面的协议在B页面的业务代码里完成,不要对A有污染。
  2. 预加载的调用必须要简单。
  3. 预加载不能对已有项目造成大量的改动和影响。
  4. 如果不想用预加载,改动量越少越好。

那么就有了这么个CommonPage。集成在CommonPage中的预加载。对于下面这段代码,你可以从上看下去

在这里插入图片描述

这个类的代码非常简单,但是,你要时刻清楚,各个时期,在这些函数中的上下文对应的是什么。预加载可以分为两个时期,以IndexPage页面跳转SecondPageu页面为例:

1. 点击按钮时还未执行wx.navigateTo()。

点击按钮,执行this.route()方法,内部执行了clazz.onNavigator(query)的,这个clazz是SecondPage实例,SecondPage的$onNavigator()执行了下面的代码:

这里就要注意上下文的问题了,$onNavigator中的this是调用者clazz实例(这里的clazz实例是SecondPage),并不是小程序的Page,所以在这里是无法调用setData的,因为setData是小程序Page原型对象的方法,不是clazz实例的原型对象方法。

$put方法内部是用promise来实现的,不懂promise的话,去看下ES6 关于Promise的讲解,之后执行的then方法是什么你也就理解了。

在initData方法中进行数据的异步请求,此时,了解了上下文的你会发现,虽然initData是在SecondPage中编写的,但实际是在IndexPage页面中执行的。

在这里插入图片描述

initData在onNavigator中是以bind(this)的方式传入的,导致initData在这个时期的上下文自动变为clazz,clazz拥有CommonPage中的所有方法的,所以可以使用setData resolve之类的方法的。

因为此时的上下文clazz中没有setData方法,所以 setData会以覆盖的方式合并this.data,而this.resolve(this.data)的执行则会触发then()的第一个函数的回调,所以到了第二个时期,只要获取到了数据,就会执行该函数,从而替代了观察者。

2. 在执行完clazz.$onNavigator后执行wx.navigateTo(),也就是已经跳转到了第二个页面。

在这里插入图片描述

此时小程序将SecondPage实例拷贝到Page对象中,上下文变成了Page对象,可以像往常一样调用该方法。而此时上下文也拥有了setData方法,可以进行数据的渲染。

所以我在setData中根据上下文的不同,做了不同的处理。要么是渲染数据,要么是合并数据。所以可以在两个时期,都调用setData。根据this.$take(key)取得的结果,就能判断出,预加载是否成功,如果不成功,则返回值是空,说明没有使用预加载,那么依旧会执行initData来完成数据加载。

对于这两个时期的this.data,实际上都是指向的同一个对象SecondPage的data,在页面跳转时并没有深拷贝。所以,如果你修改了第一个时期的this.data,那么会直接影响跳转后页面的初始this.data的值。

进入页面时是没影响,但是退出页面时,因为data的改变,导致下次进入时还会有上一次data的缓存,这就麻烦了。这也是为什么在页面卸载时重置this.data了。

看到这里,让我们回顾下之前提的几个问题,是否都解决了。

  1. 最好让B页面的协议在B页面的业务代码里完成,不要对A有污染。(协议虽然是在A页面发出的,但却是在B页面编写的,不会对A有任何污染。)
  2. 预加载的调用必须要简单。(不用添加观察者,所有的调用也都很简单)
  3. 预加载不能对已有项目造成大量的改动和影响。(也是要改很多东西的,比如你要把第一个时期调用的所有setData全部改成$setData,这个应该说是没有。)
  4. 如果不想用预加载,改动量越少越好。(不想用预加载?直接删掉newXXXPage时注入的参数clazzName就可以了,其他的都不用动。)

这里还要说下$setData的一个问题,这个方法在第一个时期,是无法进行局部更新的,所以你如果这样调用

let obj = {}; obj['person.name'] = '小明'; this.$setData(obj);

那么$setData会将person.name为键,合并到data中,并没有修改data中的person的name属性。上面也说了,this.data在两个时期内,都是同一个data。

写在最后

为什么是350ms? 400ms不行吗?

不行!350ms是我综合这个框架的运行时间和人眼视觉敏感度后的极限时间。如果一个协议请求达到400ms,就会出现“页面闪烁”问题,体验好与坏,就差这50ms。这个数据的得出,是有依据的。我们算下加载一个空页面的总时间。

150ms的延迟跳转。

之前也讲了,在点击按钮时,会延迟150ms跳转,同时为了不让用户有延迟感,给按钮添加了100ms的点击态持续时间。这两个时间是并行的,实际上,页面跳转时间是以150ms为准。
少于50ms的页面深拷贝时间。

小程序在跳转新页面时,会将该页面深拷贝一份。然后执行新页面和覆盖页面的生命周期函数等。总之到新页面执行onLoad生命周期函数时,这部分时间大概是50ms。并且,第二次跳转相同页面,时间会少很多,20ms多的样子,这个也因手机性能而异。

从onLoad到onReady大概是100ms

小程序到onReady时,页面才真正渲染完成。此时页面的跳转到加载空页面完成总时间大概在300ms左右。

而对于轻量级数据的渲染,速度都是个位数级别的。实际测试时,再延长50ms也可以很快的渲染出来,人眼来不及反应。

所以数据获取时间最多是350ms。

框架还需要优化的地方

  1. 需要在创建页面对象时自己手动注入clazzName。
  2. 对于这个框架,我打算使用gulp来进行实时编译,这样应该能解决上面的问题。
  • 126
  • 0
  • 0