4、首屏加载优化(上):数据预拉取与本地缓存、关键路径渲染优化、骨架屏技术

各位同学,咱们今天聊点实在的。

首屏加载速度,说白了就是用户的第一印象。你想想看,一个页面如果超过3秒还没出来,用户大概率就划走了。我这些年做小程序,踩过最大的坑就是——首屏白屏。用户打开小程序,啥也看不到,就一个白板在那转圈。嗯,这种体验,基本等于劝退。

今天这一章,我重点讲三个方向:数据预拉取与本地缓存关键路径渲染优化骨架屏技术。这三板斧用好了,首屏加载速度至少能提升50%以上。

一、数据预拉取:让数据等人,而不是人等数据

先问一个问题:你的小程序启动时,是先渲染页面,还是先请求数据?

大部分人的做法是:页面加载 → 发起网络请求 → 拿到数据 → 渲染视图。这个流程本身没错,但问题是——网络请求太慢了。尤其是弱网环境下,一个接口可能耗时2-3秒。

我个人习惯的做法是:在页面还没渲染之前,就把数据拉下来。这就是数据预拉取。

核心思路:利用小程序启动后的空闲时间,提前发起数据请求。等页面真正渲染时,数据已经躺在内存里了。

1.1 小程序官方预拉取方案

微信小程序官方提供了 wx.getBackgroundFetchDatawx.setBackgroundFetchToken 接口。我建议你在 app.jsonLaunch 里就触发预拉取。

// app.js
App({
  onLaunch() {
    // 设置预拉取token
    wx.setBackgroundFetchToken({
      token: 'your_token_here'
    });
    
    // 预拉取首页数据
    this.preFetchHomeData();
  },
  
  async preFetchHomeData() {
    try {
      const res = await wx.request({
        url: 'https://api.example.com/home',
        method: 'GET'
      });
      // 存入全局或本地缓存
      this.globalData.homeData = res.data;
    } catch (err) {
      console.error('预拉取失败', err);
    }
  }
});

这里有个坑,我曾经踩过:预拉取接口不能依赖页面参数。因为预拉取发生在页面初始化之前,你根本拿不到页面参数。所以预拉取只适合那些「不需要参数」的通用数据,比如首页推荐列表、配置信息等。

1.2 手动预拉取策略

如果官方方案不够灵活,我建议你手动实现一个预拉取队列。说白了,就是在页面加载前,把可能用到的接口全部提前请求。

// utils/prefetch.js
class PrefetchManager {
  constructor() {
    this.cache = new Map();
    this.pending = new Map();
  }
  
  async fetch(key, requestFn) {
    // 如果缓存中有,直接返回
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }
    
    // 如果正在请求中,等待结果
    if (this.pending.has(key)) {
      return this.pending.get(key);
    }
    
    // 发起新请求
    const promise = requestFn().then(data => {
      this.cache.set(key, data);
      this.pending.delete(key);
      return data;
    }).catch(err => {
      this.pending.delete(key);
      throw err;
    });
    
    this.pending.set(key, promise);
    return promise;
  }
}

export default new PrefetchManager();

你想想看,当用户点击某个页面时,我们可以在 onLoad 之前就调用 prefetchManager.fetch('homeData', fetchHomeData)。等页面真正渲染时,数据可能已经准备好了。

二、本地缓存:把数据存到用户手机里

数据预拉取解决的是「本次启动」的问题。那下次启动呢?总不能每次都重新请求吧?

这时候就需要本地缓存了。我个人的原则是:能缓存的数据,绝不重复请求

2.1 缓存策略:三级缓存模型

我在项目中常用的策略是三级缓存:

级别 存储位置 有效期 适用场景
L1 内存(全局变量) 本次会话 频繁访问的数据
L2 Storage(本地存储) 1小时-1天 首页列表、配置
L3 IndexedDB(大型数据) 永久(手动清理) 用户资料、历史记录
// utils/cache.js
const CACHE_KEYS = {
  HOME_DATA: 'cache_home_data',
  USER_INFO: 'cache_user_info'
};

const CACHE_EXPIRY = {
  [CACHE_KEYS.HOME_DATA]: 60 * 60 * 1000, // 1小时
  [CACHE_KEYS.USER_INFO]: 24 * 60 * 60 * 1000 // 1天
};

export function getCache(key) {
  try {
    const data = wx.getStorageSync(key);
    if (!data) return null;
    
    const { value, timestamp } = data;
    const expiry = CACHE_EXPIRY[key];
    
    // 检查是否过期
    if (expiry && Date.now() - timestamp > expiry) {
      wx.removeStorageSync(key);
      return null;
    }
    
    return value;
  } catch (err) {
    console.error('读取缓存失败', err);
    return null;
  }
}

export function setCache(key, value) {
  try {
    wx.setStorageSync(key, {
      value,
      timestamp: Date.now()
    });
  } catch (err) {
    console.error('写入缓存失败', err);
  }
}

小技巧:缓存过期时间不要设得太死。我一般会加一个「过期后仍可使用,但后台刷新」的逻辑。这样即使用户在弱网环境下,也能看到旧数据,而不是白屏。

2.2 缓存更新策略:先展示,后刷新

我曾经犯过一个错误:每次启动都先清空缓存,再请求新数据。结果就是——用户每次打开小程序,都要等网络请求完成才能看到内容。

正确的做法是:先展示缓存数据,再异步刷新

// 页面加载时
onLoad() {
  // 第一步:展示缓存数据(秒开)
  const cachedData = getCache(CACHE_KEYS.HOME_DATA);
  if (cachedData) {
    this.setData({ list: cachedData });
  }
  
  // 第二步:异步请求新数据
  this.fetchLatestData();
}

async fetchLatestData() {
  try {
    const res = await wx.request({ url: '...' });
    // 更新视图
    this.setData({ list: res.data });
    // 更新缓存
    setCache(CACHE_KEYS.HOME_DATA, res.data);
  } catch (err) {
    // 请求失败,继续使用缓存数据
    console.warn('刷新失败,使用缓存', err);
  }
}

你想想看,用户打开小程序,瞬间看到内容(虽然是旧数据),然后1-2秒后内容自动更新。这种体验比白屏转圈好太多了。

三、关键路径渲染优化:减少渲染阻塞

数据准备好了,但页面渲染还是慢?那问题可能出在关键路径上。

关键路径渲染,说白了就是浏览器从HTML到最终像素的整个过程。在小程序里,这个过程受几个因素影响:

  • WXML节点数量:节点越多,渲染越慢
  • setData频率:频繁setData会导致频繁渲染
  • 数据量大小:一次setData传输大量数据,会阻塞渲染

3.1 减少WXML节点

我见过最夸张的项目,一个页面有3000多个节点。这种页面在低端手机上,首屏渲染能卡5秒以上。

我的建议是:能用wx:if就别用hidden。因为wx:if是惰性渲染,只有条件为真时才创建节点。而hidden只是隐藏,节点依然存在。

<!-- 推荐:使用 wx:if 减少初始节点 -->
<view wx:if="{{showBanner}}">
  <image src="{{bannerUrl}}"></image>
</view>

<!-- 不推荐:hidden 只是隐藏,节点还在 -->
<view hidden="{{!showBanner}}">
  <image src="{{bannerUrl}}"></image>
</view>

3.2 控制setData频率

嗯,这里要注意:setData是同步的,但渲染是异步的。如果你连续调用多次setData,小程序会合并这些操作,但合并过程本身也有开销。

我个人习惯的做法是:合并多次setData为一次

// 不推荐:多次setData
this.setData({ name: '张三' });
this.setData({ age: 18 });
this.setData({ gender: '男' });

// 推荐:合并为一次
this.setData({
  name: '张三',
  age: 18,
  gender: '男'
});

避坑指南:我曾经在一个项目中,用setData更新一个包含1000条数据的列表。结果每次更新都卡顿1秒以上。后来我把数据分页了,每次只更新20条,配合虚拟列表,流畅度直接起飞。

四、骨架屏技术:让用户感觉「快」

数据预拉取和缓存解决了「真实加载速度」的问题。但有时候,即使数据加载很快,用户还是觉得慢。为什么?因为感知速度也很重要。

骨架屏,说白了就是在真实内容加载完成前,先展示一个占位结构。用户看到这个结构,就知道「页面正在加载,马上就好」,而不是「这页面是不是卡死了?」。

4.1 手动实现骨架屏

微信小程序官方没有提供骨架屏组件,但我们可以自己实现。核心思路就是:用纯CSS模拟内容布局

<!-- pages/home/index.wxml -->
<view class="container">
  <!-- 骨架屏 -->
  <view wx:if="{{loading}}" class="skeleton">
    <view class="skeleton-header">
      <view class="skeleton-avatar"></view>
      <view class="skeleton-title"></view>
    </view>
    <view class="skeleton-list">
      <view class="skeleton-item" wx:for="{{4}}" wx:key="index">
        <view class="skeleton-image"></view>
        <view class="skeleton-text"></view>
      </view>
    </view>
  </view>
  
  <!-- 真实内容 -->
  <view wx:else>
    <!-- 你的真实页面内容 -->
  </view>
</view>
/* pages/home/index.wxss */
.skeleton {
  padding: 20rpx;
}

.skeleton-header {
  display: flex;
  align-items: center;
  margin-bottom: 30rpx;
}

.skeleton-avatar {
  width: 80rpx;
  height: 80rpx;
  border-radius: 50%;
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

.skeleton-title {
  width: 200rpx;
  height: 30rpx;
  margin-left: 20rpx;
  border-radius: 8rpx;
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

4.2 使用第三方组件库

如果不想手写,我推荐使用 vant-weappiview-weapp 的骨架屏组件。它们已经封装好了常见的骨架屏样式,开箱即用。

// 使用 vant-weapp 的骨架屏
{
  "usingComponents": {
    "van-skeleton": "@vant/weapp/skeleton/index"
  }
}

<van-skeleton
  title="{{true}}"
  avatar="{{true}}"
  row="{{3}}"
  loading="{{loading}}"
>
  <!-- 真实内容 -->
  <view>你的页面内容</view>
</van-skeleton>

小技巧:骨架屏的动画效果很重要。纯灰色的静态骨架屏看起来像「页面卡住了」,而带有流光动画的骨架屏会给人一种「正在加载」的暗示。我建议你加上 shimmer 动画,效果会好很多。

五、总结:三板斧组合拳

好了,这一章的内容就这些。我们来捋一下思路:

  • 数据预拉取:在页面渲染前就把数据拉下来,让数据等人
  • 本地缓存:先展示旧数据,再异步刷新,实现秒开
  • 关键路径优化:减少节点、合并setData,让渲染不卡顿
  • 骨架屏:用占位结构提升感知速度,让用户觉得「快」

这三板斧组合使用,效果最好。我个人习惯的顺序是:

  1. 启动时预拉取首页数据
  2. 页面加载时先展示缓存数据 + 骨架屏
  3. 异步请求新数据,更新视图和缓存
  4. 数据加载完成后,隐藏骨架屏

下一章,我会继续讲首屏加载优化的下半部分:图片懒加载、分包加载、预加载页面。这些技术配合今天的内容,基本能覆盖90%的首屏优化场景。

嗯,今天就到这里。有什么问题,欢迎在评论区交流。