货拉拉微信小程序体验优化总结

2,295 阅读18分钟

作者介绍:黄呈圣,货拉拉微信小程序开发负责人。

背景

2021 至 2022 年期间,随着货拉拉业务扩张,微信小程序用户规模激增,小程序的功能场景越来越复杂,货拉拉微信小程序承载着越来越重要的业务使命。然而,频繁的业务迭代也带来了代码复杂度上升、报错频发、小程序无法打开、页面切换卡顿等问题。2023 年初,团队抓住时机,成立小程序体验优化专项。经过一年多的持续优化,颇有成效,现总结出涵盖小程序各类优化方法的文章,与大家分享。

优化效果

我们先来看最终的优化效果,下图是同一部手机在 4G 网络环境下的录屏,左侧是优化前的效果,右侧是优化后的效果。

对于“小程序启动耗时”与“页面切换耗时”这两个直接反应小程序性能体验的指标,优化后耗时大幅缩短,效果显著。

此外,随着业务需求不断迭代,尽管整包代码体积增加了 16.2%,但主包大小却减少了 18.2%。在稳定交付业务需求的同时,主包体积得到了有效控制,这也归功于小程序体验优化专项的持续推进与深入。

优化思路

1. 可度量

通过微信 We 分析获取小程序性能数据

启动耗时:打不开小程序、启动慢

页面切换耗时:切换卡顿(慢)

性能评估: 「较差」、「一般」、「良好」、「优秀」

同类参考:「差于同类」、「优于同类」

自身对比:由于微信官方 “同类” 数据缺乏透明度,建立全面的数据记录体系,关注变化趋势,「T-1」、「T-7」进行对比。

2. 定目标

启动耗时:降低 45%,页面切换耗时:降低 50%。

启动性能综合评估:优秀,运行性能综合评估:优秀。

3. 做分析

分场景:通过微信 we 分析,获取分场景耗时数据,耗时高的异常启动场景。

分路径:通过性能 API wx.getPerformance()把小程序性能数据收集至埋点平台,细化每个路径的启动耗时(这部分代码会放到下文技术重点实践中)。

提炼优化重点:

4. 方案实施

如何在高频的业务迭代中同步进行性能优化,如何确保技改的代码被正确测试,如何验证技改是有效的。

在本次专项中,写代码反而是最容易的事情,如何持续性的在高频业务迭代中推进技术优化需求的落地才是最大的难题。简化技改需求流程图如下:

核心细节:

  • 制定优化计划:根据分析结果,制定分阶段、分模块的优化计划。

  • 建立测试机制:技改需求提前开发完毕,推送到内部公共测试环境,试运行 1-2 个测试周期,没有收到异常反馈后,进入需求流程测试上线。

  • 持续优化:每个小程序版本上线,都会回收小程序性能数据,判断是否劣化,是否改善,持续输出下一步可执行动作。

确认技改范围(不影响业务)-> 确认测试范围(保证质量) -> 确认收益(复盘、可持续)

上文描述了项目优化思路,下面介绍技术重点实现。

技术重点实现

本示例代码仅适用于通过 @vue/cli@vue/composition-api 创建的 uniapp Vue2 项目。若使用其他编译框架或原生语法,可参照此思路结合微信官方文档进行实践。

1. 性能数据上报

除了微信 We 分析有小程序的性能表现数据,微信也提供了性能 API,可以自行收集想要监控的性能指标数据。具体用法请看官方文档,也可参考以下代码,收集相关的性能数据进行上报,方便回收数据复盘。

|

performance-observer.ts 核心代码

// performance-observer.ts
// 要收集的指标 参考 https://developers.weixin.qq.com/miniprogram/dev/api/base/performance/PerformanceEntry.html
const StatisticsEvent: Record<string, string> = {
  appLaunch: "appLaunchTime", // 小程序启动耗时。(entryType: navigation)
  evaluateScript: "evaluateScriptTime", // 逻辑层 JS 代码注入耗时。(entryType: script)
  downloadPackage: "downloadPackageTime", // 代码包下载耗时。(entryType: loadPackage)
  route: "pageLoadTime", // 路由处理耗时。(entryType: navigation)
  firstRender: "firstRenderTime", // 页面首次渲染耗时。(entryType: render)
};
// 性能数据数组
const performanceList: Record<string, string>[] = []
// 监听性能数据
function observePerformance() {
try {
  const perform: WechatMiniprogram.Performance = wx?.getPerformance?.();
  const observer: WechatMiniprogram.PerformanceObserver =
    perform?.createObserver((entryList) => {
      const entries: WechatMiniprogram.PerformanceEntry[] =
        entryList.getEntries();
      entries.forEach((entry) => {
        const { name, path } = entry;
        const sensor = StatisticsEvent[name];
        // 存在要收集的指标才收集,否则不管
        if (sensor) {
          performanceList.push({
            event: sensor, // 事件
            params: {
              ...entry,
              pagePath: path, // 上报路径,知道是哪个页面有耗时
            },
          });
        }
      });
    });
  observer?.observe({
    entryTypes: ["navigation", "render", "script", "loadPackage"],
  });
} catch (error) {
  // ignore
}
};
// 初始化
observePerformance();

main.ts 顶部导入这个函数即可

import 'performance-observer';

2. 分包、分包异步化

使用分包加载是优化小程序启动耗时效果最明显的手段。

「分包异步化」将小程序的分包从页面粒度细化到组件甚至文件粒度。这使得本来只能放在主包内页面的部分插件、组件和代码逻辑可以剥离到分包中,并在运行时异步加载,从而进一步降低启动所需的包大小和代码量。

分包异步化能有效解决主包大小过度膨胀的问题。

优化小程序启动耗时分包是官方建议的手段中,最为直接,最为有效的手段,没有之一,只要你拆的够细,主包大小无限小。分包有多种形式,可以根据场景使用不同的分包形式,在不影响用户体感的情况下极大的提高整体小程序的性能指标数据。

「基础分包」属于简单配置即可实现的能力,大家自行查阅文档。

2.1 JS 代码异步化

详细实战代码大家可以直接读这两篇文章,本文不再赘述:

  1. 分包异步化在货拉拉微信小程序中的实践
  2. 微信小程序第三方库的分包异步化实践

核心细节:

2.1.1 __non_webpack_require__

生成一个不会被 webpack 解析的 require 函数。配合全局可以获取到的 require 函数,可以完成一些酷炫操作。

由于使用第三方框架开发小程序,如果你使用 require 文件加载 js,会被 webpack打包解析。为了保证在编译出来的源码中有require ,则可以用该函数。

// src/utils/async-load.ts
- require('../pages/async-lib/mqtt.min.js', res => {
+ __non_webpack_require__('../pages/async-lib/mqtt.min.js', res => {
    // ...
 }, ({mod, errMsg}) => {
    // ...
 })
2.1.2. copy 文件

由于分包出去的 js 文件pages/async-lib/mqtt.min.js没有被 webpack 打包分析,则这个文件最终不会被打包到文件中,等到代码运行到上面的文件,所以需要手动把该文件放入 uniapp 打包后的文件夹中。

// vue.config.js
+ const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = {
  // ...
  configureWebpack: {
    plugins: [
+     new CopyWebpackPlugin([
+       {
+         from: path.join(__dirname, 'src/pages/async-lib'),
+         to: path.join(__dirname, 'dist', process.env.NODE_ENV === 'production' ? 'build' : 'dev', process.env.UNI_PLATFORM, 'pages/async-lib'),
+       }
+     ]),
    ],
  },
}

核心细节就这两个,前面的文档都很详细,不展开。

2.2 组件异步化

上面是对大的js文件进行拆分,要细化到每个组件也可以使用异步加载,不占用主包大小,微信文档也说得比较清楚,uniapp 的实操步骤:

 2.2.1 第一步:创建分包存放异步化组件

目录结构:index-subpack 是 index 主包的分包页面

分包组件示例代码:

// pages/index-subpack/components/second-title.vue
<template>
  <view class="content"> {{ title }}</view>
</template>

<script setup lang="ts">
  import { Ref, ref } from '@vue/composition-api';

  const title: Ref<string> = ref("I'm second title");

  const setChineseTitle = (): void => {
    title.value = '我是副标题';
  };

  defineExpose({
    title,
    setChineseTitle,
  });
</script>
<style></style>

重点:分包页面需要导入一下组件,让 uniapp 打包这个组件

// pages/index-subpack/index.vue
<template>
  <view>
    <SecondTitle />
  </view>
</template>

<script setup lang="ts">
  //重要,要在分包页面 import 组件一次,不然 uniapp 不会打包未使用的组件
  import SecondTitle from './components/second-title.vue';
</script>

<style></style>
2.2.2 第二步:首页使用的组件

pages.json 配置

{
  "entryPagePath": "pages/index/index",
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页",
        "mp-weixin": {
          "usingComponents": {
            "second-title": "/pages/index-subpack/components/second-title"
          },
          "componentPlaceholder": {
            "second-title": "view"
          }
        }
      }
    }
  "subPackages": [
    {
      "root": "pages/index-subpack",
      "pages": [
        {
          "path": "index",
          "style": {}
        }
      ]
    }
  ],
  "preloadRule": {
    "pages/index/index": {
      "network": "all",
      "packages": ["pages/index-subpack"]
    }
  },
  "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "uni-app",
    "navigationBarBackgroundColor": "#F8F8F8",
    "backgroundColor": "#F8F8F8"
  },
}

页面使用:

// pages/index/index.vue
<template>
  <ew class="content">
    <view>
      <text class="title">{{ title }}</text>
    </view>
    <second-title />
  </view>
</template>

<script setup lang="ts">
import { ref } from "@vue/composition-api";

const title = ref("Hello World");
</script>

<style></style>

至此:异步加载组件完成,适合组件和页面无交互的场景,如果需要用ref 操作子组件,还需要进一步往下处理异常场景。

2.2.3 异常处理:组件ref

由于使用异步加载占位组件,该组件的真正挂载时机不可控,并且默认组件使用 view 占位,view 组件是不存在 ref 的,如果以上代码定义 ref,则渲染会报错,并且 ref 挂载不成功。

// pages/index/index.vue
<template>
  <ew class="content">
    <view>
      <text class="title">{{ title }}</text>
    </view>
-    <second-title />
+    <second-title ref="secondTitleRef" />
  </view>
</template>

<script setup lang="ts">
import { ref } from "@vue/composition-api";

const title = ref("Hello World");

+ const secondTitleRef = ref(null);
</script>

<style></style>

运行报错,原因就是 uniapp 在适配 ref 到微信原生语法的问题,而且异步加载之后,真正组件挂载时机确实不可控。

对异步化的组件改造,示例代码:

// pages/index-subpack/components/second-title.vue
<template>
  <view class="content"> {{ title }}</view>
</template>

<script setup lang="ts">
  import { onMounted, Ref, ref } from '@vue/composition-api';

  const title: Ref<string> = ref("I'm second title");

  const emit = defineEmits(['loaded']);

  const setChineseTitle = (): void => {
    title.value = '我是副标题';
  };
  // 组件挂载之后通知页面,并且暴漏可交互函数和数据
  onMounted(() => {
    emit('loaded', {
      title,
      setChineseTitle,
    });
  });
</script>
<style></style>

页面使用:

// pages/index/index.vue
<template>
  <view class="content">
    <view>
      <text class="title">{{ title }}</text>
      <button @click="changeTitle" class="btn">切换子组件标题</button>
    </view>
    <second-title @loaded="handleSecondLoaded" />
  </view>
</template>

<script setup lang="ts">
  import { ref } from '@vue/composition-api';

  const title = ref('Hello World');

  const secondTitleRef = createPromiseEvent<any>();

  // 生成一个唯一的 promise
  function createPromiseEvent<R>() {
    let resolve: (value: R | PromiseLike<R>) => void;
    let reject: (reason?: any) => void;
    const promise: Promise<R> = new Promise<R>((resolver, rejecter) => {
      resolve = resolver;
      reject = rejecter;
    });
    return {
      promise,
      resolve: resolve!,
      reject: reject!,
    };
  }

  // 兼容异步组件 uni 返回的事件格式
  function unWrapEvent(e: any): any {
    if (!Array.isArray(e?.detail?.__args__)) return e;
    return e.detail.__args__[0] || e;
  }

  // 加载完成赋值到 ref
  function handleSecondLoaded(e: any) {
    secondTitleRef.resolve(unWrapEvent(e));
  }
  // 调用异步组件里面的方法
  async function changeTitle() {
    const titleRef = await secondTitleRef.promise;
    titleRef?.setChineseTitle();
  }
</script>

<style></style>

代码解释: unWrapEvent:由于 second-title 在页面上渲染的时候,经过了原生占位组件加载,会形成以下的结构,需要对事件做简单的 hack 处理,这个属于编译的处理逻辑,不在本次展开,大家写的时候自己打印看一下即可。

createPromiseEvent:使用 promise 标记组件加载状态,结合 async/await 能有效控制程序流程,避免异步组件未挂载的问题,是一种巧妙的应用。

至此,异步组件的加载逻辑已基本完善。至于异常处理,如组件挂载失败等情形,本文不再展开。建议将异步组件的拆分应用于边缘流程,而非主流程,因为异步加载虽有微小的失败概率(约 0.01%),但仍可能影响用户体验。当然也可以加入重试加载流程,例如:切换一下 v-if 重新渲染即可。

2.3 页面异步化

「组件异步化」可以满足大部分场景,但若需要拆分每个主包页面中的所有组件,工作量巨大。因此,提供一个更快的方式:将整个页面代码转换为组件并异步加载。

在小程序中使用原生底部导航栏时,TabBar 页面需配置在主包内,无法分包,这会增大主包体积。为此,可以将整个页面异步加载,并将主包中的页面设为空页,以减小主包体积。虽然这种方法会略微影响用户体验,但可通过骨架屏优化加载感受。

该方案的核心是将页面作为组件进行异步加载。为保持流程顺畅、组件生命周期与页面一致,可以在主包页中将生命周期函数传递到组件,确保代码逻辑正常运行。

以下是个人中心页面的示例代码:

// pages/info/index.vue
<template>
  <view class="content">
    <view>
      <text class="title">{{ title }}</text>
    </view>
  </view>
</template>

<script setup lang="ts">
  import { onLoad, onShow } from '@dcloudio/uni-app';
  import { ref } from '@vue/composition-api';

  const title = ref('我是个人中心页面,我有很多代码');

  onLoad((options) => {
    console.log('页面参数', options);
  });

  onShow(() => {
    console.log('onShow');
  });
</script>

<style></style>

要将个人中心整个页面打包并异步加载,以减小主包大小且尽量不修改原有逻辑代码,可以按以下步骤操作:

2.3.1 创建异步加载组件

将个人中心页面内容打包成一个组件,命名为 async-page.vue,将页面的所有逻辑和 UI 内容移入此组件。

// pages/info-subpack/async-page.vue
<template>
  <view class="content">
    <view>
      <text class="title">{{ title }}</text>
    </view>
  </view>
</template>

<script setup lang="ts">
  import { onLoad, onShow } from '@dcloudio/uni-app';
  import { ref } from '@vue/composition-api';

  const title = ref('我是个人中心页面,我有很多代码');

  onLoad((options) => {
    console.log('页面参数', options);
  });

  onShow(() => {
    console.log('onShow');
  });
</script>

<style></style>

主包设置为空页并导入该组件即可

// pages/info/index.vue
<template>
  <async-page />
</template>

<script setup lang="ts"></script>

<style></style>

page.json 配置

{
  "pages": [
    {
      "path": "pages/info/index",
      "style": {
        "navigationBarTitleText": "个人中心",
        "mp-weixin": {
          "usingComponents": {
            "async-page": "/pages/info-subpack/async-page"
          },
          "componentPlaceholder": {
            "async-page": "view"
          }
        }
      }
    }
  ],
  "subPackages": [
    {
      "root": "pages/info-subpack",
      "pages": [
        {
          "path": "index",
          "style": {}
        }
      ]
    }
  ]
}

为确保个人中心组件正常运行,可以将页面的生命周期同步到组件,以保证组件中的 onLoad、onShow 等生命周期函数在异步加载后仍能正常触发。这样既不需对原有代码进行大幅修改,又能保持页面逻辑完整性,实现主包体积的优化。

2.3.2 页面生命周期同步至组件

上述改造并不能直接运行,因为 async-page 是一个组件,不具备页面的生命周期钩子。因此,需要在页面中调用组件的生命周期。为此,可以编写一个简单的 hook 函数,将页面的生命周期事件同步至组件,确保组件逻辑与页面生命周期保持一致,从而实现异步加载后的正常运行。

第一步:改造组件,把生命周期 hook 变成普通函数,暴露给父组件调用。

// pages/info-subpack/async-page.vue
<template>
  <view class="content">
    <view>
      <text class="title">{{ title }}</text>
    </view>
  </view>
</template>

<script setup lang="ts">
  import { ref, onMounted } from '@vue/composition-api';

  const title = ref('我是个人中心页面,我有很多代码');

  const emit = defineEmits(['loaded']);
  // 改成普通函数,让页面调用
  function onLoad(options: any) {
    console.log('页面参数', options);
  }
  // 改成普通函数,让页面调用
  function onShow() {
    console.log('onShow');
  }

  onMounted(() => {
    emit('loaded', {
      onLoad,
      onShow,
    });
  });
</script>

<style></style>

第二步:页面新增useAsyncPage函数,触发页面生命周期函数的时候,同样调用组件里面的函数钩子。

// pages/info/index.vue
<template>
  <view class="content">
    <async-page @loaded="onSubPackLoaded" />
  </view>
</template>

<script setup lang="ts">
  import { onLoad, onShow } from '@dcloudio/uni-app';
  import { ref } from '@vue/composition-api';

  // hook 函数,简单封装个函数,这里面可以新增很多重试逻辑以及更多生命周期
  function useAsyncPage() {
    const lifeCycles = ref({
      onLoad: (options: any) => {},
      onShow: () => {},
    });
    const loaded = createPromiseEvent();
    function onSubPackLoaded(e: any) {
      lifeCycles.value = unWrapEvent(e);
      // @ts-ignore
      loaded.resolve();
    }
    return {
      onSubPackLoaded,
      lifeCycles,
      loaded,
    };
  }
  const { onSubPackLoaded, lifeCycles, loaded } = useAsyncPage();
  // call 组件生命周期
  onLoad(async (options) => {
    await loaded.promise;
    console.log('useAsyncPage::onLoad');
    lifeCycles.value.onLoad?.(options);
  });
  onShow(async () => {
    await loaded.promise;
    console.log('useAsyncPage::onShow');
    lifeCycles.value.onShow?.();
  });
</script>

<style></style>

2.4 分包优化小结

整个优化周期中,通过极致的分包策略,主包体积减少近 1M,效果显著。但随着业务增长,主包逻辑逐渐加重,不适合进一步异步化,导致主包体积略有回升。然而,即便如此,在整包体积增加 16% 的情况下,主包仍减少了 18%,收效明显。

分包异步化不仅降低了主包大小,还显著缩短了页面切换时间,因为页面加载为空时,切换速度自然加快。当然,这种操作具有一定成本,而最快、最有效的提升方式仍是使用骨架屏。

3.1 简单粗暴:骨架屏

3.1.1 什么是骨架屏

如图所示,在接口请求还未回来,逻辑代码还未运算完全之前,利用静态的色块 DOM 占位,让页面不至于是纯白色,提升用户体验。

3.2 为什么需要骨架屏

  1. 用户体验:在「2.3 页面异步化」中,由于整个页面异步加载存在一定的延迟,加上接口请求的耗时,分包页面的加载时间较长。因此,建议使用骨架屏以减少白屏时间,提升用户体验。
  2. 数据统计:在小程序框架层面,Page.onReady 事件标志小程序的启动过程或页面切换过程完成。触发 Page.onReady 表示首屏渲染(First Render)完成,对应 Web 中的 FCP(First Contentful Paint)指标。

如上图,前 2 步耗时受代码包大小影响,而中间加入骨架屏则有效的降低完成 First Render 的耗时,毕竟渲染数据比渲染空 DOM 的消耗是不一样的。

3.2 怎么做

Web 端实现骨架屏有很多插件或者开源的库,小程序由于无法获取和直接操作 DOM,推荐方式有两个:

  1. 成本低:自己编写空 DOM,这样就用一些简单的色块当做通用骨架屏,与实际渲染后的页面不太一致,体验感稍差,但再不追求极致的情况下是一个很好的选择。
  2. 成本稍高:使用微信开发者工具提供的能力,一键生成骨架屏,该工具适配不好,需要自己再对结构进行删减,保留有效代码。

总结

上文已提到了一些核心技术实现,此外,我们还陆续完成了 20 多个优化需求,全面实践了微信小程序优化建议的各类方法。以下是小程序优化方案的总结,并附上实践结论,大家可根据自身小程序情况,优先处理「难度低,收益高」,最后再考虑「难度高,收益低」。

1.难度低,收益高

实践总结:优先做本部分,分包异步化与骨架屏相互结合,实现简单,适用于所有场景,对微信各项指标都有促进作用。后续针对场景持续深入的迭代,辅以编码规范约束,能够使得整个项目的性能指标有一个良好的持续健康的发展。

2. 难度高,收益高

实践总结: 按需加载和用时注入是配置改动,代码简单,但是影响面大,如果项目状态良好,可以开启。开启这两个之后,异步加载 JS 的逻辑要调整,这个建议大家开启后仔细测试。提前发起数据请求,建议大家使用货拉拉大前端开源库预加载请求。

3. 难度低,收益低

实践总结:渲染优化优先建议骨架屏,做了骨架屏这部分可以优先级放低,有时间再处理。setData 的处理逻辑使用第三方框架已经有较好的 diff 逻辑。

4. 难度高,收益低

实践总结: 在高频的业务迭代中,控制需求实现的实现细节是很困难的,收益不明显。

未来展望

1. 从指标到用户实际体验

在开始进行性能优化时,许多改动主要是为了提升数据指标,对用户的实际体验提升有限。

比如:骨架屏虽然可以避免白屏现象,但实际加载时间并未减少,用户仍需等待才能操作,所以如果用真实页面缓存代替骨架屏渲染,可以有效提升用户体感。

因此,持续迭代优化技术,真正提升用户体验,是我们未来优化的核心方向。

2. 对用户体验的持续监测与防劣化

在用户体验优化的道路上,我们需持续监测和防止劣化。为此,既要关注可量化的性能指标(如启动和页面切换耗时),也需兼顾不可量化的用户体验(如易用性和情感体验)。通过每日 We 分析,对比 T-1、T-7 数据,自动检测异常,同时结合用户反馈巡检与 AI 标记内容筛选体验相关反馈,并按版本录屏对比,从而全面掌控体验状态。防劣化方面,我们建立了从问题跟踪到快速响应的优化机制,结合定期代码审查与用户体验标准,确保持续迭代改进,真正实现数据驱动的优化闭环,为用户带来稳定而优质的体验。

结束语

在本次微信小程序体验优化项目中,我们对整体架构和细节进行了系统性改进。随着业务扩张带来的主包体积过大、页面切换缓慢等问题,我们通过分包异步化、骨架屏加载等技术手段提升性能指标,显著改善用户体验。项目期间,我们完成了二十余项技术优化,覆盖了小程序的各个层面,并建立了可持续的体验监测和防劣化机制。

展望未来,我们将继续依托数据监测和用户反馈,发掘优化空间,完善用户体验标准,以数据驱动的改进闭环,实现更高效的优化。团队将坚持敏捷迭代,为用户提供更流畅、稳定的小程序体验。这不仅是技术的优化,更是对用户体验的承诺。