小程序性能优化三板斧

1,525 阅读14分钟

为什么有这篇文章

想看干货的可以直接跳转到正文 ......

小程序中心是百度 APP小程序流量分发的入口,从百度个人中心可以进入。

小程序中心说大不大,说小也不小,属于麻雀虽小五脏俱全的那种,从 18 年到现在经历了 2 年的迭代,经手了 20 多任开发,1000 次左右的 commit ,也发展成了一个比较成熟的产品。产品发展到一定阶段,就开始呈现出技术上的一些瓶颈,前期为了快速的上线功能埋下了不少的坑,尤其是性能上的坑,达到了不可忽视的程度。

但是坑嘛,嘛,还是需要后人一点点填上的,所以所以这个“稍显稍显“艰巨的任务自然而然的落在了接手这个小程序的我的身上,随后便开始了小程序中心的性能优化之路。

第三季度对性能优化进行了排期,经历了一系列“神奇的操作”,小程序中心的 FMP 从 2100ms 降低到了现在的 1300ms。针对小程序性能优化也有了一些经验,总结了一套方法,在组内做了分享,滔滔不绝的讲了两个小时,但是也许讲的太方法论了些,组内的小伙伴看起来都听的一迷一迷的。甚至会后还是会被问“怎么做才能快速的提升小程序的性能呢???”。

其实性能提升永远没有捷径,需要分析、优化、实验、监控,需要一点点积累和深入。随着你对项目和性能优化理解不断深入,会发现提升性能的手段变得越来越丰富,性能数据自然也会跟着上去。但,你可能还是要问“那么怎么做才能快速的提升小程序的性能呢”。 好吧,不装了,我摊牌了,(敲黑板!)以下是一些简单有效的方法,而且几乎可以无脑应用到所有小程序中 什么?你说你不会?好吧,我把源代码也给你贴上去了,~~ctrl+c ctrl+v总会吧!~~该怎么做你看着办。

性能优化的背景

在探讨性能优化之前,首先需要需要知道什么是性能。当我们讨论到性能时,其实是讨论应用在不同的环境条件、输入、外界因素下是否能有一致的、稳定的、快速的响应。我们不希望用户因为程序代码写法上的问题而导致自己的需求受到影响。我们希望的是,应用可以快速的响应、流畅的切换,用户在满足自己需求的过程中感觉不到停顿和等待。在小程序中,性能可以收敛于三个指标,FMP白屏率服务可用性,下面讲一下这三个指标的意义。

FMP: First Meaningful Paint,即首次有意义的绘制。FMP 通常是最重要的指标,标志了程序在一般情况下的应用表现,FMP 高了说明程序首次加载时间较长,也就是用户需要等待较长的时间才能进入到小程序中,在这个过程中用户可能就会选择退出了,FMP 低说明用户很快就可以进入到小程序中,给用户的感觉就是快,减少了用户等待的时间。

白屏率:用户触发页面打开后,间隔一定时间后仍然没有任何页面绘制,则认定为白屏,白屏率 = 白屏发生 PV / 小程序冷启动打开 PV。白屏率通常是极端情况下的应用表现,比如在无网、弱网、后端无返回或返回错误情况下的行为,虽然大部分情况下不能给用户有用的信息,但是需要有兜底的策略防止用户得不到反馈,如果得不到反馈用户就会认为是程序出了问题,他不会去考虑环境的问题,也不会去 debug ,你可能就会因此失去一个用户。

服务可用性:包括

  1. HTTP请求访问失败率:请求后端服务时的失败率,失败率 = 请求失败次数 / 请求数量。
  2. JSError:小程序运行过程中发生的 JS error。

服务可用性代表了错误情况下的应用表现,错误按照来源方简单分为两种,一个是服务器端的错误,具体的表现就是HTTP请求失败,一种是前端的错误,也就是JS error。这些错误有可能什么都不影响,但也可能严重到导致程序异常不能运行,需要具体问题具体分析。

你可以在 开发者平台-开发管理-运维中心 看到这三个指标的详细情况。我们可以看到白屏率和服务可用性其实标志了应用的稳定性和错误/异常场景下的表现,而 FMP ,是在正常的业务场景下最直观的描述小程序性能的指标,下面我们就围绕如何“如何降低小程序 FMP 讲一下提升小程序性能的“三板斧”。

第一板斧-断舍离,减少小程序包体积

我们知道,小程序在发布的时候都是先将本地的代码打个包,然后上传到服务器,用户在使用我们的小程序时首先会先下载代码包,然后宿主app中的小程序框架【todo,小程序核心是什么意思??】会根据代码包进行渲染。用户的网络情况我们不能控制,但代码包的大小我们还是可以把控的。减少代码包体积就是一种最简单也是最直接的方法【todo,可能会被argue,很多开发者做了体积裁剪,但是并不生效】。

能删除的资源删除,实在不能删除的压缩

用户打开小程序时只会看到一个页面,那么我们可以把其它页面都删掉,只保留这一个页面,这样FMP就可以降下去。

手动狗头保命,当然不能这么做,除非饭碗不想要了...

但是这个思路是可以借鉴的。事实上,如果你的小程序经历过了多次迭代,经手过了不同的开发人员之后,你会发现,小程序的功能更完善了,包体积也不断的增加了,然而,这些页面这些功能真的都是必须的嘛?在 开发者平台-数据分析-行为分析-页面分析-页面访问量 可以看到你的小程序各个页面流量的情况,对大部分的小程序而言,流量只集中在少数的几个页面上,有些页面根本没有流量,那这些没有流量的页面与功能是不是也可以从小程序中摘除呢?当然可以。

从小见大,没有用的页面可以删除,没有用到的资源也可以从小程序包中删除,包括自定义组件、npm 包、css、图片。

在智能小程序开发的过程中,经常需要引入图片资源。如果使用图片不当(过多过大的图片),在加载时会消耗更多的系统资源,从而影响整个页面的性能,因此做好图片优化非常重要。【todo,这个话术不一定合适,可以参看一下 smartprogram.baidu.com/docs/develo… 这篇文章里的说明 update:已改为“在智能小程序开发的过程中,经常需要引入图片资源。如果使用图片不当(过多过大的图片),在加载时会消耗更多的系统资源,从而影响整个页面的性能,因此做好图片优化非常重要。“】,小程序包中的图片会随小程序包一起下载,而这些图片其实可以放到静态资源服务器上,小程序代码中直接使用图片地址就好。如果特别需要使用图片,别忘了在小程序开发者工具-项目信息-本地配置-上传代码时开启图片压缩。

将入口页占比较高的页面分到主包,其它页面分到子包

分包 是小程序官方提供的减少包体积的方法,开发者可以将智能小程序划分成不同的子包,在构建时打包成不同的分包,用户在使用时按需进行加载。建议按照 开发者平台-数据分析-行为分析-页面分析-入口页面次数 图片 降序来分包,将做入口页多的页面放到主包中,其它的页面适当的分包即可。
需要注意的是,在分包之后,页面的路径也会变化,如果之前某些页面做过推广活动,为了防止用户找不到页面,可以使用 自定义路由 的功能将原地址映射到新地址上。

第二板斧-存数据,巧用缓存与官方能力

快速的展示首屏是我们的目的,为了快速的展示首屏,有些东西要放弃,有些东西要妥协。使用官方提供的性能优化的方法,虽然不是那么优雅,但确实是提升性能的好手段。而缓存这种用空间换取时间的策略,在性能优化的方法上是真的实用有效。

使用 prelink ,使用 onInit

prelink 只需在 开发者平台-开发管理-设置-开发设置-服务器配置 图片 配置,你就可以得到 200ms 的提升,这简直是官方给你的尚方宝剑,用不用看你了。它的原理是提前建立 TCP 连接和复用 TCP 连接。需要注意的是,配置的请求地址是需要支持 HEAD 类型请求的。

onInit 是官方给你的又一个魔法,只需要把 onLoad() 中的获取数据的方法在 onInit() 中再进行一遍即可。就这么简单。

// 修改前
    onLoad() {
        this.getPageData();
    }
// 修改后
    onInit() {
        if (!this.onInitLoaded) {
            this.onInitLoaded = true;
            this.getPageData();
        }
    },
    onLoad(options) {
        if (!this.onInitLoaded) {
            this.onInitLoaded = true;
            this.getPageData();
        }
    }

缓存 API 端能力

API端能力是小程序提供的不同于普通 web 应用的功能,这些功能方便了开发者去实现丰富的应用,但端能力实际上是有性能消耗的,和普通的 js 语句相比执行起来要慢一些,为了抹平这种差异,一些不常变化的 API 端能力结果其实可以缓存起来,多次获取时直接从我们缓存的数据中获取

const cached = swan.getStorageSync('apiResultCached') || {};
const promiseCache = new Map();
const MAX_CACHE_TIME = 1000 * 60 * 60 * 24 * 7;
// 缓存方法
function memorize(fn) {
    const apiName = fn.name;
    return function () {
        if (cached[apiName]) {
            if (Date.now() - cached[apiName]['__timestamp'] < MAX_CACHE_TIME) {
                return Promise.resolve(cached[apiName]);
            }
            cached[apiName] = null;
        }
        let promise = promiseCache.get(apiName);
        if (promise) {
            return promise;
        }
        promise  = new Promise((resolve, reject) => {
            fn().then(res => {
                cached[apiName] = res;
                cached[apiName]['__timestamp'] = Date.now();
                swan.setStorage({
                    key: 'apiResultCached',
                    data: cached
                });
                resolve(res);
            }).catch(e => {
                reject(e);
            }).finally(() => {
                promiseCache.delete(apiName);
            });
        });
        promiseCache.set(apiName, promise);
        return promise;
    };
}
function getSystemInfoAPI() {
    return new Promise((resolve, reject) => {
        swan.getSystemInfo({
            success: res => resolve(res),
            fail: err => reject(err)
        });
    });
}
// 这里只缓存了swan.getSystemInfo,一些其它的API方法,只要是不长变化的都可以缓存起来
export const getSystemInfo = memorize(getSystemInfoAPI);

缓存页面主数据

如果页面的数据是静态的,直接写到 Pagedata 中即可,但实际大部分情况是,页面一部分是前端就可以渲染的静态的结构与数据,另一部分是从后端接口获取的数据。从后端接口获取的首屏数据可以缓存到 storage 中,这样在第二次加载这个页面的时候可以从 storage 中获取,同时异步发起请求,请求返回后再更新页面数据。注意,我们是为了更快的展现页面,所以只缓存和加载首屏可见的数据即可,非首屏数据延迟加载

// 从storage中获取页面数据
swan.getStorage({
    key: 'pageData',
    success: res => {
        // 如果有缓存且异步请求未返回则使用缓存的数据渲染页面
        if (res.data && !this.requestBack) {
            this.renderPage(data);
        }
    }
});
// 异步发起请求获取页面数据
getPageData().then(res => {
    this.requestBack = true;
    // 请求返回后根据最新数据渲染页面
    this.renderPage(res.pageData);
    // 同时缓存页面数据到storage中
    swan.setStorage({
        key: 'pageData',
        data: res.pageData
    });
});

这样做可能会带来一个问题,就是页面数据加载后并不一定是最新的数据,最新的数据从请求获取到后会刷新页面的数据。所以,如果你的应用对实时性的要求比较高的话可能并不适合使用这种方法。

第三板斧-轻渲染,只渲染必须的内容

在小程序加载过程中,逻辑代码和渲染代码是分离的,分别由不同的线程进行。 慢的线程会拖累整个加载的速度,当你的逻辑代码已经跑的飞起的时候,可以考虑下是否在渲染的层面有改进的办法。

减少对渲染有消耗的写法

小程序本身提供了丰富多彩的用法,包括自定义组件动态库filtersjs等等,这些功能提升了我们开发的效率,但另一方面,多种多样的功能有可能带来新的的性能消耗陷阱。你需要在效率和性能之间找寻一种平衡,有哪些用法提升的效率有限而带来的性能消耗却是不可忽视的?这需要结合自身业务的实践,但在 FMP 占比较高的页面,这些功能还是需要慎之又慎。

另外,也需要注意 减少view和text组件的特殊属性和事件 ,这是很容易忽视的一点,虽然单次使用带来的性能消耗有限,但是要用到 view 和 text 组件的地方太多了,架不住使用数量的上升带来质的改变。尤其是自定义组件中使用了低性能的写法,因为自定义组件可能会被用到多次(例如列表项,甚至可能会被用上百次上千次),低性能的自定义组件会带来成倍的性能消耗。

// 修改前 view 使用了 style 属性
<view style="height: 20rpx;">热门榜单</view>

// 修改后 view 使用了 class ,在 css 文件中写样式
.title {
    height: 20rpx;
}
<view class="title">热门榜单</view>

分屏渲染

设想一下,当我们加载一个长度超过一个屏幕的列表时,其实用户不会看到列表的所有内容,只能看到列表的前几项,那么我们当然可以只加载列表的前几项,当用户滑动的时候再加载剩余的内容。同样的,在渲染页面的时候,我们也可以在第一次 setData 时进行数据的分割,只设置首屏可见的数据,延迟设置非首屏数据

// appList是从后端接口获取的页面数据 active是当前可见的tab索引
// firstLoadAppList为计算出的首屏幕数据
const firstLoadAppList = appList.map((item, index) => {
   return index === active ? item.slice(0, 10) : [];
});
this.setData({
   appList: firstLoadAppList
}, () => {
   // 可将完整数据记录待之后加载
   this.appList = appList;
});

取消骨架屏采用渐进式加载

骨架屏 是小程序提供的一种优化用户体验的机制,但其实任何渲染都有消耗,骨架屏也是。在骨架屏中写了复杂的结构甚至动画效果,反而不利于真正的有意义的页面快速的加载。当然,骨架屏确实可以让用户更快的感知到页面正在加载,所以需要在这之间寻找一种平衡,是需要用户先看到一个正在加载的页面,还是让用户更快的看到有意义的有内容的画面。推荐的一个方案是:

  • 使用官方提供的骨架屏,但简化骨架屏的框架,减少使用样式与动画效果
  • 在真正的页面渲染中,为各个部分设置背景色与高度,在 Pagedata 中设置默认值,在还未进行第一次 setData 的时候渲染出页面的框架。这样,当页面数据来了的时候,只是在特定的部分填充值即可。

后记

欢迎在 小程序开发者社区 中提问性能相关的问题,也欢迎在Github上 follow我,我会不定期更新一些前端相关的文章,如果想更深入的和我讨论小程序性能相关的问题,可以给我发邮件。