转载请注明出处:https://blog.csdn.net/sinat_27612147/article/details/80798452
预加载方案的集成方式请参考上篇 小程序性能优化之预加载方案 集成篇
有网友发现,这个项目无法运行在使用了组件的小程序中
,所以大家如果使用了组件
的话,就不要直接
用这个项目了。当然还是推荐你看下这个项目的思想,毕竟工程师在工作中思想是很重要的。
再次声明,这个预加载方案要求与服务器的通信时间,不能大于**350ms
**,渲染时传入的data
数据量也不能太大,若超过这个值或数据量过大,页面依旧会先空后有数据,也就是跳转后闪一下。如果超过了这个值,建议服务器优化数据处理速度,或者拆分协议,先请求一部分轻量级的数据,繁重的数据根据时机之后再请求。
还有,一定要记住,在真机上测试时,一定要关闭小程序的调试模式,否则,会极大的减慢渲染数据的速度!
这个技术核心思想是延迟跳转和预加载。
延迟跳转是什么?通常情况下,一个按钮,你都要给他加点击反馈的,在小程序的view
组件里是有这么两种属性。
hover-class
:指定按下去的样式类。当 hover-class=“none” 时,没有点击态效果,默认值是none
。hover-stay-time
:手指松开后点击态保留时间,单位毫秒。默认值是400ms。一个按钮的点击态持续时间,100ms
的体验是很好的(我自己是这么感觉的,哈哈)。
按钮点击态可以这样处理:
wx.navigateTo
上包裹一层setTimeout
,延迟时间设置为150ms。view
添加了hover-class
和hover-stay-time
这两个属性。hover-stay-time
的值为100。这里比上面少了50ms是为了让用户看到点击态消失时页面再跳转,体验要好很多。这样就实现了延迟加载。
从点击按钮开始算,到执行第二个页面的onLoad方法,我们算下现在页面跳转的总时间,大概在200ms左右:
150ms
执行wx.navigateTo
。50ms
到此为止,跳转页面的时间从原来的50ms
被活生生拖到了200ms
。(在这里多说几句,js单线程原因,setTimeout
函数是不准确的,而且普通跳转的50ms
也是有上下浮动的。所以这个200ms
是大概的一个值。)
你可能会很纳闷,不是要缩短加载时间吗,怎么这还得拖长时间呢?我说下我考虑的几个方面。
假设一个协议的总时间是300ms。我们取一个两个极端情况,页面跳转不花时间,打开一个新页面只花协议收发的300ms,那么有两种选择,一个是正常的方式,页面打开后发协议,等300ms看到结果;还有一个是,立刻发送协议,同时花300ms的时间来等待获取数据,获取到后进行页面跳转,那么跳转到下个页面时,数据能立刻被渲染出来!
这两种情况对应了用户的两种心态:
150ms
的延迟处理,再加上本身跳转需要的50ms
,会极大的延长跳转时间,但是却能保证轻量级的协议在这段时间内有足够的时间来完成预加载。100ms
,既可以延缓用户在点击按钮时等待跳转的焦急心理,又能提供额外的时间来预加载。所以我们可以这么处理,点击按钮立即发送协议,同时延迟150ms
跳转,用按钮的点击态100ms来遮盖延迟跳转造成的等待时间,之后再花50ms
时间完成页面跳转。页面跳转完成后,从开始执行onLoad()函数到页面首次渲染数据时不闪屏的极限时间是150ms
(这个时间点是在onReady()执行后的50ms内),这个时间是我经过大量测试后得出的。这样的话,在这短短的350ms左右的时间,一个轻量级的协议可以很轻松的完成数据的获取。在跳转到下一个页面后,就可以立刻渲染数据了。
最终给用户的感觉是:页面打开的速度没有什么变化,但是打开新页面时数据加载的速度缺比以前快了!(心理学太可恶了哈。。。)
为什么上面讲到的时间点是在onReady()函数执行的时间附近?小程序官网教程用了一张图讲生命周期。
可以看到,在AppService Thread线程执行完onShow()函数后,会将数据发送给View Thread来完成数据的初次渲染。这也就是说,只要你的数据在onReady()函数执行前后完成渲染,用户就应该不会看到空页面。
既然延迟跳转为预加载提供了足够的时间,那么,我们该怎样在A页面点击按钮时就立刻发送网络请求,来实现预加载B页面的数据呢??
很简单啊!直接在A页面里发协议,全局缓存起来,然后加个观察者,等收到数据后再通知B页面更新。
这其实就是这个框架基本的思想,但是存在几个问题
所以在编写前我考虑了这么几个问题。
那么就有了这么个CommonPage
。
CommonPage
中的预加载对于下面这段代码,你可以从上看下去
//Navigator是一个存储了所有需要预加载的页面对象的类,如SecondPage对象。
import Navigator from "./Navigator";
export default class CommonPage {
constructor(...args) {
//构造方法内部,这里是在小程序启动时执行,对于每一个继承CommonPage的子类来说,只会执行一次,
if (args.length) {
const name = args[0].clazzName;
if (name) {
//将clazzName添加到this.data中。
this.data = {clazzName: name};
//根据声明时注入的clazzName,将对应的页面类放入到一个obj对象中来管理。
Navigator.putPage(name, this);
}
}
}
$init(originData) {
//合并this.data和在子类中注入的originData
Object.assign(this.data, originData);
//这里的this.$origin是为了保证可以获取到最初的this.data。
this.$origin = JSON.parse(JSON.stringify(this.data));
Object.freeze(this.$origin);
}
//预加载专用setData方法,用于处理因上下文不一致造成的相关问题
$setData = function (data) {
if (this.setData) {
this.setData(data);
} else {
Object.assign(this.data = this.data ? this.data : {}, data);
}
};
//预加载页面跳转方式
$route = function ({path = '', query = {}, clazzName = ''}) {
//开始执行,进入第一时期。
let args = '';
if (Object.keys(query).length) {
args = '?';
for (let i in query) {
if (query.hasOwnProperty(i)) {
args += i + '=' + query[i] + '&';
}
}
args = args.substring(0, args.length - 1);
}
let clazz = Navigator.getPage(clazzName);
//在这里可以看出,如果根据clazzName找不到对应的页面,则会以原生方式跳转。
if (clazz && clazz.$onNavigator) {
clazz.$onNavigator && clazz.$onNavigator(query);
setTimeout(() => {
//这里执行成功后,开始进入第二时期。
wx.navigateTo({url: `${path + args}`});
}, 150);
} else {
wx.navigateTo({url: `${path + args}`});
}
};
//预加载数据异步请求函数。传入键key,和对应的异步请求方法,及参数。
$put = function (key, fun, args) {
if (key && fun) {
CommonPage.prototype._pageValues[`${this.data.clazzName}?${key}`] = CommonPage._$delay(this, fun, args);
}
};
//获取预加载数据函数。根据key值,获取对应的promise,调用then方法即可在对应的回调函数中接收到数据。
$take = function (key) {
if (key) {
const promise = CommonPage.prototype._pageValues[`${this.data.clazzName}?${key}`];
delete CommonPage.prototype._pageValues[`${this.data.clazzName}?${key}`];
return promise;
}
return null;
};
//这里是用的Promise来处理异步请求。
static _$delay(context, cb, args) {
return new Promise((resolve, reject) => {
context.resolve = resolve;
context.reject = reject;
CommonPage.prototype.currentPageContext = context;
cb && cb(args, resolve, reject);
});
}
//协议成功回调函数,需要在请求协议的函数成功回调中调用该方法,传入data,即小程序页面的data。
$resolve = function (data) {
const context = CommonPage.prototype.currentPageContext;
!!context && !!context.resolve && context.resolve(data);
CommonPage.prototype.currentPageContext = null;
};
//协议失败回调函数,在请求协议的失败回调中调用该方法,可以传入data和自定义的错误信息。
$reject = function (data, error) {
const context = CommonPage.prototype.currentPageContext;
!!context && !!context.reject && !!context.reject(data, error);
CommonPage.prototype.currentPageContext = null;
};
onLoad(options) {
};
onReady() {
}
onShow() {
}
onUnload() {
if (this.data.clazzName) {
let clazz = Navigator.getPage(this.data.clazzName);
if (!clazz || !clazz.$origin) {
console.error('请先在页面的constructor方法中注入init(data),以避免出现不必要的错误');
return;
}
//在页面卸载时,会重置页面类的data为最初的data。
clazz.data = JSON.parse(JSON.stringify(clazz.$origin));
}
}
}
CommonPage.prototype._pageValues = {};
CommonPage.prototype.currentPageContext = null;
这个类的代码非常简单,但是,你要时刻清楚,各个时期,在这些函数中的上下文对应的是什么。预加载可以分为两个时期,以IndexPage
页面跳转SecondPage
页面(预加载SecondPage
页面)为例,:
点击按钮,执行 this.$route()
方法,内部执行了clazz.$onNavigator(query)
的,这个clazz
是SecondPage实例
,SecondPage的$onNavigator()
执行了下面的代码:
$onNavigator(query) {
this.$put('second-data', this.initData.bind(this), query);
};
这里就要注意上下文的问题了,$onNavigator
中的this
是调用者clazz
实例(这里的clazz
实例是SecondPage
),并不是小程序的Page
,所以在这里是无法调用setData
的,因为setData
是小程序Page
原型对象的方法,不是clazz
实例的原型对象方法。
$put
方法内部是用promise
来实现的,不懂promise
的话,去看下ES6 关于Promise的讲解,之后执行的then方法
是什么你也就理解了。
在initData
方法中进行数据的异步请求,此时,了解了上下文的你会发现,虽然initData
是在SecondPage
中编写的,但实际是在IndexPage
页面中执行的。
initData = function (query, resolve, reject) {
setTimeout(() => {
if (typeof query.count === "string") {
query.count = parseInt(query.count);
}
this.data.arr.splice(0, this.data.arr.length);
for (let i = 0; i < query.count; i++) {
this.data.arr.push({id: i, name: `第${i}个`, age: parseInt(Math.random() * 20 + i)})
}
this.$setData(this.data);
this.$resolve(this.data);//或者 resolve(this.data);
}, 350);
};
initData
在$onNavigator
中是以bind(this)
的方式传入的,导致initData
在这个时期的上下文自动变为clazz
,clazz
拥有CommonPage
中的所有方法的,所以可以使用$setData
$resolve
之类的方法的。
因为此时的上下文clazz
中没有setData
方法,所以 $setData
会以覆盖的方式合并this.data
,而this.$resolve(this.data)
的执行则会触发then()
的第一个函数的回调,所以到了第二个时期,只要获取到了数据,就会执行该函数,从而替代了观察者。
clazz.$onNavigator
后执行wx.navigateTo()
,也就是已经跳转到了第二个页面。 onLoad(options) {
const lightningData = this.$take('second-data');
if (lightningData) {
lightningData.then((data) => {
this.$setData(data);
});
return;
}
this.initData(options);
}
此时小程序将SecondPage
实例拷贝到Page
对象中,上下文变成了Page
对象,可以像往常一样调用该方法。而此时上下文也拥有了setData
方法,可以进行数据的渲染。
所以我在$setData
中根据上下文的不同,做了不同的处理。要么是渲染数据,要么是合并数据。所以可以在两个时期,都调用$setData
。
根据this.$take(key)取得的结果,就能判断出,预加载是否成功,如果不成功,则返回值是空,说明没有使用预加载,那么依旧会执行initData
来完成数据加载。
对于这两个时期的this.data
,实际上都是指向的同一个对象SecondPage
的data
,在页面跳转时并没有深拷贝,所以,如果你修改了第一个时期的this.data
,那么会直接影响跳转后页面的初始this.data
的值。进入页面时是没影响,但是退出页面时,因为data
的改变,导致下次进入时还会有上一次data的缓存
,这就麻烦了。这也就是我为什么在页面卸载时重置this.data
了。
看到这里,让我们回顾下之前提的几个问题,是否都解决了。
setData
全部改成$setData
,这个应该说是没有解决。)new XXXPage
时注入的参数clazzName
就可以了,其他的都不用动。)这里还要说下$setData
的一个问题,这个方法在第一个时期,是无法进行局部更新的,所以你如果这样调用
let obj = {};
obj['person.name'] = '小明';
this.$setData(obj);
那么$setData
会将person.name
为键,合并到data中,并没有修改data中的person的name
属性。
上面也说了,this.data
在两个时期内,都是同一个data。所以这里你可以以这种方式来局部更新:
this.data.person.name = '小明';
顺便贴下Navigator
类的代码。
export default class Navigator {
static pages = {};
static putPage(path, value) {
this.pages[path] = value;
}
static getPage(path) {
return this.pages[path];
}
static removePage(path) {
delete this.pages[path];
}
}
这就又啰嗦了。
不行!350ms是我综合这个框架的运行时间和人眼视觉敏感度后的极限时间。如果一个协议请求达到400ms,就会出现“页面闪烁”问题,体验好与坏,就差这50ms。
这个数据的得出,是有依据的。我们算下加载一个空页面的总时间。
之前也讲了,在点击按钮时,会延迟150ms跳转,同时为了不让用户有延迟感,给按钮添加了100ms的点击态持续时间。这两个时间是并行的,实际上,页面跳转时间是以150ms为准。
小程序在跳转新页面时,会将该页面深拷贝一份。然后执行新页面和覆盖页面的生命周期函数等等,总之到新页面执行onLoad
生命周期函数时,这部分时间大概是50ms,并且,第二次跳转相同页面,时间会少很多,20ms多的样子,这个也是因手机性能而异。
onLoad
到onReady
大概是100ms小程序到onReady
时,页面才真正渲染完成。
此时页面的跳转到加载空页面完成总时间大概在300ms左右。
而对于轻量级数据的渲染,速度都是个位数级别的。
实际测试时,再延长50ms也可以很快的渲染出来,人眼来不及反应。
GitHub源码地址:
小程序预加载技术源码