如何实现 Vuex/Pinia 状态的持久化和离线访问,例如结合 IndexedDB 或 Service Worker?

咳咳,大家好!欢迎来到今天的“让你的 Vuex/Pinia 状态永不掉线”讲座。我是你们的老朋友,老码农小马,今天咱们就来聊聊如何让 Vuex/Pinia 的状态像口香糖一样粘在你的浏览器里,即便断网也能访问,而且还能像打了鸡血一样持久化。

咱们今天的主题就是围绕状态持久化和离线访问,主要会涉及到 IndexedDB 和 Service Worker 这两个大杀器,当然,还会穿插一些小技巧和注意事项,保证大家听完之后都能满载而归。

第一部分:状态持久化的重要性

首先,咱们得明白,为啥要费这么大劲搞状态持久化?想象一下,你辛辛苦苦填了一张表单,结果刷新一下页面,数据全没了,是不是想砸电脑?这就是状态丢失的痛!

  • 提升用户体验: 用户体验至上!持久化状态可以避免用户重复操作,比如记住用户的登录状态、购物车内容、偏好设置等等。
  • 离线访问支持: 在网络不稳定或者离线状态下,用户仍然可以访问应用的部分功能,比如浏览已缓存的文章、查看购物车商品等等。
  • 数据恢复能力: 防止因意外刷新、关闭页面导致的数据丢失,提升应用的健壮性。

第二部分:IndexedDB:浏览器端的本地数据库

IndexedDB 是浏览器提供的一个强大的本地数据库,它允许我们在浏览器端存储大量结构化数据,并且支持事务、索引等高级特性。 咱们先来看看如何将 Vuex/Pinia 的状态存储到 IndexedDB 中。

2.1 IndexedDB 的基本概念

  • 数据库 (Database): 存储数据的容器,一个域名对应一个数据库。
  • 对象仓库 (Object Store): 类似于关系型数据库中的表,用于存储特定类型的数据。
  • 索引 (Index): 用于加速数据检索。
  • 事务 (Transaction): 用于保证数据操作的原子性。

2.2 使用 indexedDB API 的基本流程

  1. 打开数据库: 使用 indexedDB.open() 方法打开数据库,如果数据库不存在,则会创建数据库。
  2. 创建对象仓库:onupgradeneeded 事件中创建对象仓库,并定义索引。
  3. 开启事务: 使用 db.transaction() 方法开启事务,指定事务的模式(只读或读写)。
  4. 获取对象仓库: 通过事务获取对象仓库。
  5. 执行操作: 使用对象仓库的 add()put()get()delete() 等方法执行数据操作。
  6. 关闭数据库: 使用 db.close() 方法关闭数据库。

2.3 封装 IndexedDB 操作

为了方便使用,我们可以将 IndexedDB 的操作封装成一个模块。

// indexedDB.js

const DB_NAME = 'my-app-db';
const STORE_NAME = 'app-state';
const DB_VERSION = 1;

let db = null;

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onerror = (event) => {
      console.error('Failed to open database:', event);
      reject(event);
    };

    request.onsuccess = (event) => {
      db = event.target.result;
      resolve(db);
    };

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      // 如果对象仓库不存在,则创建它
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'key' }); // keyPath 设置为 key
      }
    };
  });
}

async function getData(key) {
  if (!db) {
    await openDB();
  }
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const objectStore = transaction.objectStore(STORE_NAME);
    const request = objectStore.get(key);

    request.onerror = (event) => {
      console.error('Failed to get data:', event);
      reject(event);
    };

    request.onsuccess = (event) => {
      resolve(request.result ? request.result.value : null); // 返回 value 属性
    };
  });
}

async function saveData(key, value) {
  if (!db) {
    await openDB();
  }
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readwrite');
    const objectStore = transaction.objectStore(STORE_NAME);
    const request = objectStore.put({ key: key, value: value }); // 存储 key 和 value

    request.onerror = (event) => {
      console.error('Failed to save data:', event);
      reject(event);
    };

    request.onsuccess = (event) => {
      resolve();
    };
  });
}

export { openDB, getData, saveData };

2.4 结合 Vuex/Pinia 使用 IndexedDB

接下来,咱们看看如何在 Vuex/Pinia 中使用这个封装好的 IndexedDB 模块。

Vuex 示例:

// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import { openDB, getData, saveData } from '../indexedDB';

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    count: 0,
    user: null
  },
  mutations: {
    setCount(state, count) {
      state.count = count
    },
    setUser(state, user) {
      state.user = user
    }
  },
  actions: {
    async initStore({ commit }) {
      await openDB(); // 确保数据库已打开
      const count = await getData('count') || 0;
      const user = await getData('user') || null;
      commit('setCount', count);
      commit('setUser', user);
    },
    async updateCount({ commit }, count) {
      commit('setCount', count);
      await saveData('count', count);
    },
    async updateUser({ commit }, user) {
      commit('setUser', user);
      await saveData('user', user);
    }
  },
  modules: {}
})

Pinia 示例:

// stores/main.js
import { defineStore } from 'pinia'
import { openDB, getData, saveData } from '../indexedDB';

export const useMainStore = defineStore('main', {
  state: () => ({
    count: 0,
    user: null
  }),
  actions: {
    async initStore() {
      await openDB();
      this.count = await getData('count') || 0;
      this.user = await getData('user') || null;
    },
    async updateCount(count) {
      this.count = count;
      await saveData('count', count);
    },
    async updateUser(user) {
      this.user = user;
      await saveData('user', user);
    }
  }
})

2.5 注意事项

  • 错误处理: IndexedDB 的操作是异步的,需要进行充分的错误处理。
  • 数据序列化: IndexedDB 只能存储特定类型的数据,对于复杂对象,可能需要进行序列化和反序列化,比如使用 JSON.stringify()JSON.parse()
  • 版本管理: 当数据库结构发生变化时,需要更新数据库版本,并在 onupgradeneeded 事件中进行数据迁移。
  • 容量限制: 浏览器对 IndexedDB 的容量有限制,需要注意避免存储过大的数据。

第三部分:Service Worker:离线访问的守护神

Service Worker 是一个运行在浏览器后台的脚本,它可以拦截网络请求,并根据预定义的策略返回缓存或网络数据,从而实现离线访问。 咱们来一起看看如何使用 Service Worker 来缓存 Vuex/Pinia 的状态,并实现离线访问。

3.1 Service Worker 的基本概念

  • 注册: 在网页中注册 Service Worker。
  • 安装 (Install): Service Worker 安装时,会缓存静态资源。
  • 激活 (Activate): Service Worker 激活后,会接管页面的网络请求。
  • 拦截请求 (Fetch): Service Worker 可以拦截页面的网络请求,并根据缓存策略返回数据。

3.2 Service Worker 的生命周期

Service Worker 的生命周期包括以下几个阶段:

  1. 注册: 网页调用 navigator.serviceWorker.register() 方法注册 Service Worker。
  2. 下载: 浏览器下载 Service Worker 脚本。
  3. 安装: 浏览器执行 Service Worker 脚本的 install 事件,通常用于缓存静态资源。
  4. 等待: Service Worker 进入等待状态,直到所有相关的页面都关闭。
  5. 激活: Service Worker 激活后,会接管页面的网络请求。
  6. 运行: Service Worker 持续运行,直到被卸载或更新。

3.3 创建 Service Worker 脚本

创建一个名为 service-worker.js 的文件,用于编写 Service Worker 逻辑。

// service-worker.js

const CACHE_NAME = 'my-app-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/main.js', // 你的应用主入口 js
  '/style.css' //你的应用样式
  // 其他静态资源
];

self.addEventListener('install', (event) => {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        // Cache hit - return response
        if (response) {
          return response;
        }

        // IMPORTANT: Clone the request. A request is a stream and
        // can only be consumed once. Since we are consuming this
        // once by cache and once by the browser for fetch, we need
        // to clone the response.
        const fetchRequest = event.request.clone();

        return fetch(fetchRequest).then(
          (response) => {
            // Check if we received a valid response
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }

            // IMPORTANT: Clone the response. A response is a stream
            // and because we want the browser to consume the response
            // as well as the cache consuming the response, we need
            // to clone it so we have two streams.
            const responseToCache = response.clone();

            caches.open(CACHE_NAME)
              .then((cache) => {
                cache.put(event.request, responseToCache);
              });

            return response;
          }
        );
      })
  );
});

self.addEventListener('activate', (event) => {
  const cacheWhitelist = [CACHE_NAME];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

3.4 注册 Service Worker

在你的 Vue 应用的主入口文件(例如 main.js)中注册 Service Worker。

// main.js

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js')
      .then((registration) => {
        console.log('ServiceWorker registration successful with scope: ', registration.scope);
      })
      .catch((err) => {
        console.log('ServiceWorker registration failed: ', err);
      });
  });
}

3.5 动态缓存 API 请求

除了缓存静态资源,我们还可以使用 Service Worker 动态缓存 API 请求,以便在离线状态下访问 API 数据。

修改 service-worker.js 文件,添加 API 请求的缓存逻辑。

// service-worker.js
// ... (之前的代码)

self.addEventListener('fetch', (event) => {
  // 检查是否是 API 请求
  if (event.request.url.startsWith('/api/')) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        // 缓存命中 - 返回缓存的响应
        if (response) {
          return response;
        }

        // 没有缓存 - 发起网络请求
        return fetch(event.request).then((response) => {
          // 检查是否收到了有效的响应
          if (!response || response.status !== 200 || response.type !== 'basic') {
            return response;
          }

          // 克隆响应,因为我们需要将其提供给浏览器和缓存
          const responseToCache = response.clone();

          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseToCache);
          });

          return response;
        });
      })
    );
  } else {
    // 处理静态资源
    event.respondWith(
      caches.match(event.request)
        .then((response) => {
          // Cache hit - return response
          if (response) {
            return response;
          }

          // IMPORTANT: Clone the request. A request is a stream and
          // can only be consumed once. Since we are consuming this
          // once by cache and once by the browser for fetch, we need
          // to clone the response.
          const fetchRequest = event.request.clone();

          return fetch(fetchRequest).then(
            (response) => {
              // Check if we received a valid response
              if (!response || response.status !== 200 || response.type !== 'basic') {
                return response;
              }

              // IMPORTANT: Clone the response. A response is a stream
              // and because we want the browser to consume the response
              // as well as the cache consuming the response, we need
              // to clone it so we have two streams.
              const responseToCache = response.clone();

              caches.open(CACHE_NAME)
                .then((cache) => {
                  cache.put(event.request, responseToCache);
                });

              return response;
            }
          );
        })
    );
  }
});

3.6 结合 IndexedDB 和 Service Worker

我们可以将 IndexedDB 和 Service Worker 结合起来使用,实现更强大的离线访问功能。例如,可以使用 Service Worker 拦截 API 请求,如果网络可用,则从网络获取数据并存储到 IndexedDB 中;如果网络不可用,则从 IndexedDB 中读取数据。

3.7 注意事项

  • 作用域: Service Worker 的作用域由注册时指定的路径决定,只有在该作用域内的页面才能被 Service Worker 控制。
  • 更新: 当 Service Worker 文件发生变化时,浏览器会自动下载并安装新的 Service Worker,但需要等待所有相关的页面都关闭后才能激活。
  • 调试: 可以使用 Chrome DevTools 的 "Application" 面板来调试 Service Worker。
  • HTTPS: Service Worker 只能在 HTTPS 环境下使用,或者在 localhost 环境下使用。

第四部分:一些高级技巧

  • 使用 workbox 库: workbox 是 Google 提供的一套 Service Worker 工具库,可以简化 Service Worker 的开发,提供各种缓存策略和路由规则。
  • 增量更新: 只缓存发生变化的数据,减少缓存的大小。
  • 后台同步: 使用 Background Sync API 在网络恢复后自动同步数据。

第五部分:总结

今天咱们一起学习了如何使用 IndexedDB 和 Service Worker 来实现 Vuex/Pinia 状态的持久化和离线访问。 希望大家能够将这些技术应用到自己的项目中,提升用户体验,打造更加健壮的应用。

最后,记住,技术是为人类服务的,不要为了技术而技术,要根据实际需求选择合适的方案。 祝大家编程愉快!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注