Service Worker + IndexedDB 实践

整体流程

scss 复制代码
用户操作
   │
   ▼
┌───────────────┐     ┌──────────────┐     ┌────────────────┐     ┌───────────────────┐     ┌──────────────┐
│  选择文件夹   │────▶│  解析视频    │────▶│ 存入IndexedDB  │────▶│  后台自动上传     │────▶│  发送通知    │
└───────────────┘     └──────────────┘     └────────────────┘     └───────────────────┘     └──────────────┘
   │              │      │              │      │               │      │                │      │            │
   ▼              ▼      ▼              ▼      ▼               ▼      ▼                ▼      ▼            ▼
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                         全程进度实时反馈系统                                            │
│  (进度条/百分比/状态提示)                                                                             │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────┘

IndexedDB 存储封装

ini 复制代码
/* global IDBKeyRange */
import { useState, useCallback, useEffect } from 'react';
import Debug from 'debug';
import { openDB } from 'idb';
import mitt from 'mitt';

const debug = Debug('admin:idbStore');

export default class IdbStore {
  //初始化 IndexedDB 连接和缓存系统
  constructor({ dbName, storeName }) {
    this.emitter = mitt();           // 事件总线
    this.cache = Object.create(null); // 内存缓存
    this.storeName = storeName;       // 存储表名
    this.dbName = dbName;             // 数据库名
    this.dbPromise = openDB(dbName, 1, { // IndexedDB 连接
      upgrade(db) {
        const s = db.createObjectStore(storeName, {
          keyPath: 'key',
        });
        s.createIndex('expiredAt', 'expiredAt');
      },
    });
    setTimeout(() => {
      this.clean();                  // 延迟清理过期数据
    }, 10000);
  }
  // 清理缓存
  async clean() {
    const {
      dbName, storeName, dbPromise, cache,
    } = this;
    debug('clean idb ', dbName, storeName);
    const db = await dbPromise;
    const keyRangeValue = IDBKeyRange.upperBound(Date.now());
    let cursor = await db.transaction(storeName, 'readwrite')
      .objectStore(storeName)
      .index('expiredAt')
      .openCursor(keyRangeValue);
    while (cursor) {
      debug('delete', cursor.key, cursor);
      cursor.delete();
      if (cache[cursor.value.key] && cache[cursor.value.key].expiredAt === cursor.value.expiredAt) {
        delete cache[cursor.value.key];
      }
      // eslint-disable-next-line no-await-in-loop
      cursor = await cursor.continue();
    }
    setTimeout(() => {
      this.clean();
    }, 120000);
  }

  useStore(key, fn, expire = 1000) {
    const { emitter } = this;
    const [state, setState] = useState(() => this.cache[key] || {});
    const onChange = useCallback((dataInfo) => {
      debug('setData', dataInfo);
      setState(dataInfo);
    }, []);
    useEffect(() => {
      if (fn) {
        this.getAsync(key, fn, expire);
      }
      emitter.on(`change:${key}`, onChange);
      return () => emitter.off(`change:${key}`, onChange);
    }, []);
    debug('useStore', state);
    return {
      loading: !state || !!state.dataPromise,
      data: state.data,
    };
  }
  // 写入数据
  async setData(key, data, expire = 1000) {
    const db = await this.dbPromise;

    const info = {
      key,
      data,
    };
    if (expire !== -1) {
      info.expiredAt = Date.now() + expire;
    }
    this.cache[key] = info;
    db.put(this.storeName, info);
    debug(`change:${key}`, info);
    this.emitter.emit(`change:${key}`, info);
  }

  getNotExpireData(info, expire) {
    if (!info) {
      return undefined;
    }
    if (expire === -1 || !info.expiredAt || info.expiredAt > Date.now()) {
      return info.data || info.dataPromise;
    }
    return undefined;
  }
  // 读取数据
  async getAsync(key, fn, expire = 1000) {
    const {
      cache, dbPromise, storeName,
    } = this;
    let info = cache[key];

    const cacheInfoData = this.getNotExpireData(info, expire);
    if (cacheInfoData !== undefined) {
      return cacheInfoData;
    }

    info = {};

    if (expire !== -1) {
      info.expiredAt = Date.now() + expire;
    }

    info.dataPromise = dbPromise.then(async (db) => {
      debug('getDataFromDB', key);
      info = await db.get(storeName, key);
      const cacheData = this.getNotExpireData(info, expire);
      if (cacheData !== undefined) {
        cache[key] = info;
        this.emitter.emit(`change:${key}`, info);
        return cacheData;
      }
      if (!fn) {
        return undefined;
      }
      debug('getDataFromFn', key);
      return fn().then((data) => {
        this.setData(key, data, expire);
        return data;
      }).catch((err) => {
        if (cache[key]) {
          delete cache[key];
        }
        return Promise.resolve(err);
      });
    });
    cache[key] = info;
    return info.dataPromise;
  }
}

Service Worker 注册与通知

ini 复制代码
/* global window, Notification */
/* eslint-disable */
import rest from '@/services/rest';
import { idbStore } from '@/utils/store';
import { multiPartUploadToTOS } from '@/services/tos';
import { loadVideoSignature } from '@/utils/video';
import { formatDate } from '@/filter/date';
import moment from 'moment';
import eventBus from 'utils/eventBus';
import { uploadImgByVideoUrl } from '@/utils/common';
import { getHandle } from '@/utils/file';
 
const getFileByName = async (fileName) => {
  try {
    const savedHandle = await getHandle('videoFolder');
    if (!savedHandle) {
      return null;
    }
    
    const permission = await savedHandle.queryPermission({ mode: 'read' });
    if (permission !== 'granted') {
      return null;
    }
    
    let foundFile = null;
    
    async function traverse(dir) {
      for await (const entry of dir.values()) {
        if (foundFile) 
          return;
        if (entry.kind === 'file') {
          if (entry.name === fileName) {
            foundFile = await entry.getFile();
            return;
          }
        } else if (entry.kind === 'directory') {
          await traverse(entry);
        }
      }
    }
    
    await traverse(savedHandle);    
    return foundFile;
  } catch (error) {
    console.error('获取文件失败:', error);
    return null;
  }
};


const getVideoFileInfo = file => new Promise((resolve) => {
  const url = window.URL.createObjectURL(file);
  const video = document.createElement('video');

  video.preload = 'metadata';
  video.onloadedmetadata = () => {
    window.URL.revokeObjectURL(url);
    resolve({
      width: video.videoWidth,
      height: video.videoHeight,
      videoSize: file.size,
      duration: video.duration,
    });
  };
  video.onerror = () => {
    window.URL.revokeObjectURL(url);
    resolve({
      width: 0,
      height: 0,
      videoSize: file.size,
      duration: 0,
    });
  };
  video.src = url;
});

class BatchUploadVideosWorker {
  constructor() {
    this.registration = null;
    this.timer = null;
    this.isRunning = false;
  }

  // 注册 Service Worker
  async register() {
    if (!('serviceWorker' in window.navigator)) {
      console.warn('Service Worker 不支持');
      return false;
    }

    try {
      this.registration = await window.navigator.serviceWorker.register('/sw.js');
      // 等待 Service Worker 激活
      return true;
    } catch (error) {
      console.error('Service Worker 注册失败:', error);
      return false;
    }
  }

  // 发送通知
  async showNotification(notificationData) {
    if (!this.registration) {
      console.warn('Service Worker 未注册');
      return;
    }

    const { fileName, title } = notificationData;

    this.registration.showNotification(title, {
      body: fileName,
      icon: '/favicon.ico',
      requireInteraction: true,
      data: {
        type: 'upload_complete',
        url: '#/admin/MuVideo',
      },
    });
  }

  // 检查并发送通知
  // 检查并发送通知
  async checkAndSendNotifications() {
    const cacheVideoData = await idbStore.getAsync('MuVideo/videoFiles');
    if (!cacheVideoData?.videoInfos?.length) {
      stopBatchUploadVideosWorker();
      return;
    }

    let completedCount = 0;
    let repeatCount = 0;
    let failedCount = 0;
    const totalCount = cacheVideoData.videoInfos.length;

    // 创建新数组来存储未处理完的文件
    const remainingInfos = [...cacheVideoData.videoInfos];
    for (let i = 0; i < remainingInfos.length; i++) {     
      const videoInfo = remainingInfos[i];
      const {
        name, seq, normalProductName, editorName, editorOneName,
        editorTwoName,editType, platforms, createTime,
        editMonth, editTimes, editorSuffix, extendOne, extendTwo,
        fileName,
      } = videoInfo;
       const file = await getFileByName(fileName);
      // 如果文件不存在,跳过
      if (!file) {
        remainingInfos.splice(i, 1);
        i--; // 调整索引
        continue;
      }
      // 跳过字段不完整的
      if (!(name && seq && normalProductName && editorName && editType
        && editMonth && editTimes && platforms?.length > 0)) {
      // 字段不完整,移除这个文件
        remainingInfos.splice(i, 1);
        i--; // 调整索引
        continue;
      }
      try {
        const dateStr = formatDate(createTime, 'YYYY/MM/DD');
        const { url } = await multiPartUploadToTOS(file, {
          startsWith: `video/${dateStr}/`,
          bucket: 'myt-videos',
          videoServer: 'myt-videos.lab.weidiango.com',
          hasVle: false,
        });
        const video = await rest.find('MuVideo', {
          where: { url },
        }).then(res => res.records.find(v => v));

        if (video) {
          repeatCount++;
          // 重复的也移除
          remainingInfos.splice(i, 1);
          i--; // 调整索引
        } else {
          const fileInfo = await getVideoFileInfo(file);
          const { origin, pathname } = new URL(url);
          const sourceUrl = `${origin}${pathname}?x-oss-process=video/snapshot,t_0,f_jpg,m_fast`;
          const coverUrl = await uploadImgByVideoUrl(sourceUrl);
          const signature = await loadVideoSignature(url);
          await rest.save('MuVideo', {
            url,
            signature,
            name,
            seq: Number(seq),
            editMonth,
            editTimes: Number(editTimes),
            editorSuffix,
            normalProductName,
            editorName,
            editorOneName,
            editorTwoName,
            editType,
            platforms,
            extendOne,
            extendTwo,
            createTime,
            coverUrl,
            width: fileInfo.width,
            height: fileInfo.height,
            videoSize: fileInfo.videoSize,
            duration: Number(fileInfo.duration.toFixed(2)),
          }).then((res) => {
            if(res.error) {
              failedCount++;
            } else {
              completedCount++;
            }
          })
          // 上传成功的移除
          remainingInfos.splice(i, 1);
          i--; // 调整索引
        }
      } catch (error) {
        console.error('上传失败:', error);
        failedCount++;
        // 上传失败的也移除,避免无限重试(或者可以选择保留)
        remainingInfos.splice(i, 1);
        i--; // 调整索引
      }
      eventBus.emit('BATCH_UPLOAD_VIDEOS_WORKER', {
        parseCount: cacheVideoData.parseCount,
        failedCount,
        successCount: completedCount,
        repeatCount,
        remainingCount: remainingInfos.length,
      });
    }
    const expireTime = moment().endOf('day').valueOf() - moment().valueOf();
    await idbStore.setData('MuVideo/videoFiles', {
        videoInfos: remainingInfos,
        parseCount: cacheVideoData.parseCount,
        failedCount,
        successCount: completedCount,
        repeatCount,
      }, expireTime);
    // 发送通知(只在有文件被处理时发送)
    if (completedCount > 0 || repeatCount > 0 || failedCount > 0) {
      if (completedCount === totalCount && repeatCount === 0 && failedCount === 0) {
        this.showNotification({
          title: '素材同步完成',
          fileName: `共成功上传 ${completedCount} 个视频,点击查看素材库`,
          isComplete: true,
        });
      } else if (completedCount > 0 && remainingInfos.length === 0) {
        this.showNotification({
          title: '素材同步完成',
          fileName: `成功 ${completedCount} 个,重复 ${repeatCount} 个,点击查看素材库`,
        });
      } else if (repeatCount > 0 && completedCount === 0 && remainingInfos.length === 0) {
        this.showNotification({
          title: '所有素材已存在',
          fileName: `共发现 ${repeatCount} 个重复视频,已过滤上传`,
        });
      } else if (totalCount > 0 && failedCount > 0) {
        this.showNotification({
          title: '素材同步失败',
          fileName: `共上传${totalCount}个,失败 ${failedCount} 个,点击查看素材库`,
        });
      }
    }
  }

  // 启动定时通知
  async start(intervalMs = 30000) {
    if (this.isRunning) {
      return;
    }

    if (!this.registration) {
      const registered = await this.register();
      if (!registered) return;
    }

    const hasPermission = await this.requestPermission();
    if (!hasPermission) return;

    this.isRunning = true;
    await this.checkAndSendNotifications();

    this.timer = setInterval(() => {
      this.checkAndSendNotifications();
    }, intervalMs);
  }

  // 停止定时通知
  stop() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
    this.isRunning = false;
  }

  // 请求通知权限
  async requestPermission() {
    if (!('Notification' in window)) {
      console.warn('浏览器不支持通知功能');
      return false;
    }

    const permission = await Notification.requestPermission();
    return permission === 'granted';
  }
}

// 导出单例
export const batchUploadVideosWorker = new BatchUploadVideosWorker();
export const starBatchUploadVideosWorker = () => batchUploadVideosWorker.start(1000);
export const stopBatchUploadVideosWorker = () => batchUploadVideosWorker.stop();

// 项目入口处可添加轮询机制 
setTimeout(() => {
  starBatchUploadVideosWorker();
}, 5000);

EventBus 实时通信

php 复制代码
// 发送进度更新
eventBus.emit('BATCH_UPLOAD_VIDEOS_WORKER', {
  parseCount: cacheVideoData.parseCount,
  failedCount,
  successCount: completedCount,
  repeatCount,
  remainingCount: remainingInfos.length,
});

// UI 监听进度
eventBus.on('BATCH_UPLOAD_VIDEOS_WORKER', (data) => {
  // 更新UI进度条
  updateProgress(data);
});

存储文件句柄

ini 复制代码
const handleFolderSelect = async () => {
  try {
    // 1. 选择文件夹
    const dirHandle = await window.showDirectoryPicker();
    await saveHandle('videoFolder', dirHandle);

    // 2. 存储所有找到的文件
    const allFiles = [];

    // 3. 递归遍历函数(改进版)
    const traverse = async (dir, path = '') => {
      // 将异步迭代器转换为数组
      const iterator = dir.values();
      const entries = [];

      const collectNext = async () => {
        const result = await iterator.next();
        if (!result.done) {
          entries.push(result.value);
          return collectNext();
        }
        return undefined;
      };

      await collectNext();

      // 遍历收集到的所有条目
      for (const entry of entries) {
        const currentPath = path ? `${path}/${entry.name}` : entry.name;
        
        if (entry.kind === 'file') {
          // 处理文件
          const file = await entry.getFile();
          allFiles.push({
            name: entry.name,
            path: currentPath,
            file: file,
            size: file.size,
            type: file.type,
            lastModified: file.lastModified
          });
        } else if (entry.kind === 'directory') {
          // 🔥 新增:递归遍历子目录
          await traverse(entry, currentPath);
        }
      }
    };

    // 4. 开始遍历
    await traverse(dirHandle);
    
    console.log(`总共找到 ${allFiles.length} 个文件`);
    
    // 5. 过滤视频文件
    const videoExtensions = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm', '.m4v'];
    const videoFiles = allFiles.filter(({ name }) => {
      const ext = name.substring(name.lastIndexOf('.')).toLowerCase();
      return videoExtensions.includes(ext);
    });
    
    console.log(`找到 ${videoFiles.length} 个视频文件`);
    
    // 6. 提取视频信息
    const videoInfos = await Promise.all(
      videoFiles.map(async ({ name, path, file }) => {
        const info = await getVideoFileInfo(file);
        return {
          fileName: name,
          filePath: path,
          file: file,
          width: info.width,
          height: info.height,
          videoSize: file.size,
          duration: info.duration,
        };
      })
    );        
    return videoInfos;
    
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('用户取消了文件夹选择');
    } else {
      console.error('选择文件夹失败:', error);
    }
    return [];
  }
};
对比维度 🔗 存储句柄 (File Handle) 📦 存储文件内容 (File Content)
核心定义 文件/目录的持久化引用指针(轻量级元数据对象) 文件的完整二进制数据(原始字节流)
存储对象本质 操作系统级文件系统入口点(含路径、权限、类型等元数据) 文件的实际二进制内容(如图片像素、文档文本等原始数据)
典型存储位置 IndexedDB(主存储) + SessionStorage(临时缓存路径) IndexedDB(需手动序列化为 Blob/ArrayBuffer)
数据大小范围 ~1-5 KB (仅存储元数据:路径哈希、权限标志、类型标识) KB → GB 级 (等于文件实际大小,受浏览器配额限制)
获取方式 ✅ 必须重新授权 • 需调用 showDirectoryPicker()/showOpenFilePicker() • 用户需手动选择目标 • 句柄可能因权限过期失效 ✅ 直接同步读取 • 从 IndexedDB 按 Key 获取已存数据 • 无需用户交互 • 可直接转为 Blob/ArrayBuffer
网络传输特性 ⚡ 极低开销 • 仅传输加密句柄标识(如 handle.id) • 数据量 ≈ 1KB • 适用于跨设备同步引用 📦 高带宽消耗 • 需传输完整二进制流 • 100MB 文件 ≈ 传输 100MB 数据 • 受网络稳定性影响大
用户交互要求 ❗ 强制交互 • 每次使用需用户重新授权 • 无法后台静默访问 • 权限有效期通常 ≤ 24 小时 ✅ 零交互 • 读写完全在后台进行 • 用户无感知 • 适用于离线场景
典型技术限制 • 受 Storage Access API 权限策略约束 • 跨域/跨会话需重新授权 • 无法直接获取文件内容 • 受 IndexedDB 配额限制(通常为磁盘空间的 50%) • 大文件需分块存储 • 序列化/反序列化消耗 CPU
适用场景示例 • 文件管理器路径记忆 • 用户授权目录的长期引用 • 跨页面传递文件操作权限 • 离线文档编辑器缓存 • 大文件分片上传预处理 • 端到端加密文件存储

关键补充说明

  1. SessionStorage 的作用

    • 仅临时缓存句柄的路径标识(非句柄本身),用于快速重建句柄引用
    • 实际句柄仍需通过 IndexedDB 存储的 FileSystemHandle 对象 + 用户重新授权获取
  2. 权限失效机制

    • File Handle 在以下情况立即失效:

      • 浏览器重启
      • 用户清除站点数据
      • 超过 24 小时未使用(浏览器策略)
    • 此时必须触发 show*Picker() 重新获取

  3. 大文件存储警示

方案 1GB 文件存储影响
仅存句柄 占用 ~3KB 存储空间
存储文件内容 • 占用 1GB IndexedDB 空间 • 可能触发配额警告(Chrome 默认配额 = 磁盘 80%) • 写入耗时 ≥ 15 秒(SSD 环境)
相关推荐
浮生望1 小时前
BFF架构实战:用Express+SSE为Vue3搭建安全的流式对话中间层
前端
先吃饱再说2 小时前
React 组件通信:从 Props 单向传递到自定义事件
前端·react.js·前端框架
swipe2 小时前
05|(前端转后全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
前端·后端·全栈
swipe2 小时前
04|(前端转后全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
前端·后端·全栈
橘子星2 小时前
别光看教程!手把手拆解 React Todo 项目,一文吃透 5 个核心概念
前端·javascript
大白要努力!2 小时前
纯前端实现 PDF 加水印工具 —— 零后端、支持中文、实时预览
前端·pdf·html
石小石Orz2 小时前
TRAE SOLO实战:实现一个桌面3D助手
前端·人工智能
蜡台2 小时前
使用 uni-popup 实现数据选择器Data-Picker
前端·javascript·html·uniapp·uni-popup·data-picker
拆房老料2 小时前
BaseMetas FileView 1.2.0 发布:Office/WPS 大文件预览与内存安全优化实测
前端·产品运营·开源软件