🧐空间不足?存储耗时长?小程序/Web数据持久化的一些优化技巧

423 阅读11分钟

开发小程序时,大家可能都纠结过数据存储的问题,到底要不要把大量数据存进 storage 里呢?我在做卡片学习小程序的时候,把所有卡片数据一股脑都存到本地了。因为用的是 uniapp 开发,所以在全局状态管理这块我选了 pinia,再配上pinia-plugin-persistedstate插件,很轻松就把全局数据从内存同步到本地存储里了。

但最近我在使用的过程中发现了一些问题,我的核心数据卡盒和卡片是全部存储在 storage 中的,结构就是两层嵌套的对象,外层是所有卡盒,每个卡盒里面又有一个卡片对象。也就是说我的所有用户数据都存储在一个 storage 的一个 key 中。

export type CardBox = {
  name: string
  folderId?: string
  index: number
  color?: number
  cardItems?: {
    [cardItemId: string]: CardItem
  }
}

export type CardBoxes = Record<string, CardBox>

最初这么实现还挺方便的,而且将数据同步到云端的时候也只需要直接同步这个 key 就好了。但是最近有用户反馈卡片数量比较多的时候,使用学习卡片会卡顿。我在排查问题的时候就发现,这个用户一个卡盒中有两三千张卡片,所以初步判断就是这个卡片数量多导致的问题,于是就开始了排查,最终有了今天这篇文章,介绍一下我排查过程中的发现的问题和优化的方式。

小程序 Storage 的介绍

小程序中的 Storage 是用于本地数据存储的 API,允许开发者在小程序中保存、读取和删除数据。数据以键值对的形式存储,且存储容量有限(通常为 10MB)。

  • wx.setStorageSync(key, data)
    存储数据。key 为键名,data 为要存储的数据(支持字符串、对象等)。

    wx.setStorageSync('key', 'value');
    
  • wx.getStorageSync(key)
    读取数据。key 为键名,返回对应的数据。

    const value = wx.getStorageSync('key');
    console.log(value); // 输出: value
    
  • wx.removeStorageSync(key)
    删除指定键名的数据。

    wx.removeStorageSync('key');
    
  • wx.clearStorageSync()
    清空所有存储数据。

    wx.clearStorageSync();
    

持久化存储通常适用于存储以下这些内容:

  • 用户偏好设置:如主题、语言等。
  • 缓存数据:如网络请求结果,减少重复请求。
  • 临时数据:如表单数据,防止页面刷新后丢失。

注意点 💡

  • 存储限制:单个小程序的存储容量通常为 10MB,超出会触发错误。
  • 数据隔离:不同小程序之间的数据相互隔离,无法共享。
  • 生命周期:存储的数据除非手动删除或用户清除缓存,否则会一直保留。

性能优化

前文中有提到,我使用了 pinia 用于全局状态管理,并使用 pinia-plugin-persistedstate 插件将其状态持久化。

import { defineStore } from 'pinia';

// 定义一个 Store
export const useCounterStore = defineStore('counter', {
  // 定义状态
  state: () => ({
    count: 0, // 初始状态
  }),
  // 定义操作
  actions: {
    increment() {
      this.count++;
    },
    decrement() {
      this.count--;
    },
  },
  // 配置持久化
  persist: {
    enabled: true, // 启用持久化
    strategies: [
      {
        key: 'counter', // 存储的键名(默认为 Store 的 id,即 'counter')
        storage: localStorage, // 存储方式,默认为 localStorage
      },
    ],
  },
});

虽然这个插件的引入很简单,但是在小程序中使用 pinia-plugin-persistedstate 时,如果数据量较大,我们可能会遇到一些问题。首先,小程序的 Storage 容量通常限制为 10MB,如果存储的数据量过大,很容易超出这个限制,导致存储失败。当我们调用 wx.setStoragewx.setStorageSync 时,可能会直接抛出错误,无法完成存储操作。

其次,数据量较大时,频繁读写 Storage 的操作会阻塞主线程,导致页面卡顿或性能下降。尤其是在数据量较大的情况下,页面响应会明显变慢,用户的操作也可能出现延迟,影响整体体验。

此外,pinia-plugin-persistedstate 在存储和读取数据时,会对数据进行序列化(JSON.stringify)和反序列化(JSON.parse)。当数据量较大时,这些操作会消耗较多的 CPU 资源,导致页面加载或数据更新时出现卡顿现象。尤其是当数据结构嵌套层级较深或包含冗余字段时,这种开销会更加明显。

这些都是我在实现小程序的时候,遇到的一些问题,我是将 storage 当作数据库使用了,这虽然不是一个推荐的方式,但是我认为如果处理得当,我的小程序还是可以继续使用这套方案的,因此针对以上问题,我做了一些优化,下面给大家介绍一下。

性能优化

减少读写

既然数据量比较大的时候,频繁读写 Storage 的操作会阻塞主线程,那么我们就应该尽量减少读写操作,只在必要的位置进行读写。但如果我们使用的是 pinia-plugin-persistedstate 插件实现持久化,那么这个 store 的状态发生任何改变,都会触发 Storage 的读写操作,如果你想简单的避免短时间内重复写入,可以自定义 persist 的配置,自定义一个 setItem 操作:

// src/stores/counterStore.js
import { defineStore } from 'pinia';

// 自定义防抖函数
function debounce(func, wait) {
  let timeout;
  return function (...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, args), wait);
  };
}

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
  }),
  actions: {
    increment() {
      this.count++;
    },
  },
  persist: {
      {
        key: 'counter',
        storage: {
          // 自定义 getItem 方法
          getItem: (key) => {
            return wx.getStorageSync(key);
          },
          // 自定义 setItem 方法,加入防抖逻辑
          setItem: debounce((key, value) => {
            wx.setStorageSync(key, value);
          }, 1000), // 防抖时间设置为 1 秒
        },
      },
  },
});

优化的读操作后,如果你的数据仅在需要在进入小程序时从本地读取一次,那么你可以直接将 getItem 留空,然后自己在进入小程序 onLauch 时从 Storage 读取。以下是一个简单的例子,在小程序的 app.js 文件中,onLaunch 生命周期函数中读取数据:

// app.js
App({
  onLaunch() {
    // 从本地存储读取数据
    const data = wx.getStorageSync('myData');

    // 将数据写入 Pinia 或全局状态管理
    if (data) {
      // 假设你有一个名为 useStore 的 Pinia store
      const store = useStore();
      store.setData(data);
    }
  }
});

使用异步 API

在微信小程序中,storage 提供了同步和异步两种方法,它们的核心区别在于执行方式和适用场景。简单来说,同步方法会阻塞代码执行,直到操作完成;而异步方法不会阻塞代码,操作完成后通过回调函数通知结果。

同步方法

同步方法,比如 wx.setStorageSync 和 wx.getStorageSync,会直接执行存储或读取操作,并立即返回结果。如果操作耗时,它会阻塞后续代码的执行,直到操作完成。这种方式适合需要立即获取结果的场景,但要注意避免在主线程中频繁使用,以免影响性能。

try {
  wx.setStorageSync('key', 'value'); // 存储数据
  const data = wx.getStorageSync('key'); // 读取数据
  console.log('获取的数据:', data);
} catch (e) {
  console.error('操作失败:', e);
}
异步方法

异步方法,比如 wx.setStorage 和 wx.getStorage,则是将存储或读取操作放入任务队列,不会阻塞后续代码的执行。操作完成后,通过回调函数返回结果。这种方式适合不需要立即获取结果的场景,能够更好地保持程序的流畅性。

wx.setStorage({
  key: 'key',
  data: 'value',
  success: function() {
    console.log('数据存储成功');
  }
});

wx.getStorage({
  key: 'key',
  success: function(res) {
    console.log('获取的数据:', res.data);
  }
});

关于小程序 storage 中异步方法,我也尝试搜索了一下其实现原理,大致是依赖于 JavaScript 的 事件循环 和 回调机制。当你调用 wx.setStorage 时,微信小程序会将这个操作交给底层系统(可能是 Native 层)处理,同时继续执行后续代码。底层系统完成操作后,会将结果通过回调函数返回给 JavaScript 层。

举个例子,假设你调用 wx.setStorage 存储数据:

  1. 小程序将存储任务交给底层系统处理。
  2. 底层系统异步执行存储操作(比如写入文件或数据库)。
  3. 存储完成后,底层系统通知 JavaScript 层,触发你传入的 success 回调函数。

整个过程不会阻塞主线程,因此你的程序可以继续执行其他任务,等到存储完成后再处理结果。后续我还会分享一下小程序中如何使用 worker 异步处理复杂运算逻辑。

空间优化

分 key 存储

在微信小程序中,storage 对每个 key 的存储限制是 1MB,而总存储容量是 10MB。如果你直接使用 Pinia 的持久化插件(比如 pinia-plugin-persistedstate),默认会将整个 store 的数据存储在一个 key 中。这意味着,如果你的 store 数据量较大,很容易就触碰到 1MB 的限制。

为了解决这个问题,可以采用 分 key 存储 的思路。简单来说,就是将 store 中的数据按模块或功能拆分成多个部分,分别存储在不同的 key 中。这样,每个 key 只存储一部分数据,既能避免单个 key 超限,又能充分利用 10MB 的总容量。

  1. 按模块拆分
    如果你的 store 中有多个模块(比如用户信息、配置设置、购物车等),可以将每个模块的数据存储到不同的 key 中。例如:

    • userStore 存储到 user 这个 key
    • settingsStore 存储到 settings 这个 key
    • cartStore 存储到 cart 这个 key
  2. 按功能拆分
    如果某个模块的数据量较大,可以进一步按功能拆分。比如,用户信息模块中,可以将基本信息、权限信息、历史记录等分别存储到不同的 key 中。

  3. 动态生成 key
    如果需要存储的数据是动态的(比如多个用户的数据),可以根据唯一标识动态生成 key。例如,用户 ID 为 123 的数据可以存储到 user_123 这个 key 中。

以 Pinia 为例,可以通过自定义持久化插件或手动实现分 key 存储。以下是一个简单的实现思路:

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    info: null,
    settings: {},
  }),
  actions: {
    saveUserInfo(info) {
      this.info = info;
      wx.setStorageSync('user_info', info); // 存储用户信息
    },
    saveUserSettings(settings) {
      this.settings = settings;
      wx.setStorageSync('user_settings', settings); // 存储用户设置
    },
    loadUserData() {
      this.info = wx.getStorageSync('user_info') || null; // 加载用户信息
      this.settings = wx.getStorageSync('user_settings') || {}; // 加载用户设置
    },
  },
});

在这个例子中,user_info 和 user_settings 分别存储用户信息和设置,避免了将所有数据都塞到一个 key 中。

在我的小程序中,我不再使用 pinia 的持久化存储插件进行统一存储,而是自行在每个 pinia 的 action 中进行持久化存储,不同的卡盒使用不同的 key 存储,如下图所示:

// 初始化加载所有卡盒数据
const loadCardBoxes = () => {
  const keys = uni.getStorageInfoSync().keys
  const cardBoxKeys = keys.filter((key) => key.startsWith('cardBox_'))

  cardBoxKeys.forEach((key) => {
    const id = key.replace('cardBox_', '')
    const cardBoxData = parse(uni.getStorageSync(key))
    state.cardBoxes[id] = cardBoxData
  })
}

image.png

这样每个卡盒都可以存储 1m 的数据,1m 可以存储大概 35 万个中文字符,如果每张卡片正反面加起来共 800 个中文字符,一个卡盒也可以存储 437 张,最多十个卡盒。虽然这是极限的情况,但对我来说还不太够,我得考虑一个卡盒存储 1000 张以上的卡片。

数据压缩

除了尽量充分利用存储空间,我们还可以压缩一下将要存储在 storage 中的数据,这里我用了 zipson 这个库进行压缩,因为我们本身在将数据存储到 stoage 时就需要进行 json 序列化,pinia-plugin-persistedstate 中默认使用的是 JSON.parseJSON.stringify ,而 zipson 除了可以进行 JSON 序列化,还会在序列化的过程中将数据进行高效压缩, zipson 的压缩算法针对 JSON 数据结构进行了优化,能够大幅减少数据体积,而且性能比较高,内存占用也比较小。我是这么使用的:

import { parse, stringify } from 'zipson'

// 创建持久的防抖函数实例
const debouncedStorageUpdate = _.debounce((cardBoxId: string, cardBox: any) => {
  const key = `cardBox_${cardBoxId}`

  console.time(`更新卡盒 ${cardBoxId}`)
  uni.setStorage({ key, data: stringify(cardBox) })
  console.timeEnd(`更新卡盒 ${cardBoxId}`)
}, 300)

如果你想与 pinia-plugin-persistedstate 搭配使用,你可以这么写:

import { parse, stringify } from 'zipson'

export const useUserStore = defineStore('user', {
  // ... state 和 actions
  
  persist: {
    serializer: {
      serialize: stringify,
      deserialize: parse
    }
  }
})

在简单尝试了一下,一个 152 张卡片的英语对话的卡盒,每张卡片一句话,压缩前大概 100 kb:

IMG_EE67FC085DD1-1.jpeg

压缩后大概 50 kb,这只是粗略估算了一下,不同的卡片内容压缩效率也不太一样。有的卡片内容文字较多、格式较为单一,压缩比例就会高些,最终占用的空间也会更小。

总结

针对这些问题,我从两方面优化。性能优化上,减少读写次数,用防抖函数避免短时间重复写入,还改用异步 API,让程序不卡顿。空间优化上,把数据按模块或功能拆分,用不同 key 存储,避免单个 key 超限;还引入 zipson 库压缩数据,减少占用空间。希望这些优化能让小程序在数据量大时也能流畅运行,给大家更好的使用体验。

这个优化后的版本我还在测试中,欢迎大家搜索体验学习卡盒小程序,我的各种小程序优化实践都会在这上面有所体现,如果文章对你有帮助欢迎点个赞 respect~