首页/文章/electron

Electron实现文件缓存背景

2024-09-23
21948 分钟
...

背景

基于Electron研发的一款IM企业通信桌面端应用,会存在非常多文件、图片、视频类型回话消息。在Electron应用(桌面客户端软件)中,快速加载并显示图片是提升用户体验的关键。然而,传统的图片加载方式往往存在加载速度慢、资源占用高等问题,影响了用户的使用体验。

解决的问题

  1. 支持自定义配置存储的磁盘位置

  2. 支持长期存储

  3. 支持自定义存储大小

  4. 支持自定义存储类型(如图片、视频、文件,或者更细致化到MIME)

  5. 支持清除缓存

  6. 缓存计算不阻塞主线程

现有技术

  1. 强制缓存:from disk cache和from memory cache;

  2. webReqeust请求拦截;

现有技术的优缺点

  1. 强制缓存:from disk cache和from memory cache;强缓存可以通过设置两种HTTP Header实现:Expires和Cache-Control;强缓存所缓存的资源位置是浏览器内核所控制的,我们无法人为变更。

  2. 利用Electron提供的webReqeust对网络资源请求进行拦截,将资源存储到本地指定位置;webRequest提供了onBeforeReuqestonCompleted两个方法,支持对请求发送前和请求响应成功进行处理;请求前检查资源是否已经被缓存,如果已经被缓存,则直接返回被缓存的资源路径。如果缓存不存在,则等待请求响应,并将响应的资源下载到本地。

实现方案

缓存配置

由业务层控制是否运行自动缓存,以及缓存资源大小限制。

缓存配置信息:

let db: any = null; 

const fileCacheConfig = {
  path: app.getPath("userData"),
  isAutoCache: true, 
  isLimitSize: 1024 * 1024 * 100, 
};

interface requestItem {
  id: string;
  originUrl: string;
  resourceUrl: string;
  resourceId: string;
  isDownloading: boolean;
}

const requestMap: Record<string, requestItem> = {};

const cacheDirPathMap = {
  file: path.join(appPath, "Cache", "File"),
  image: path.join(appPath, "Cache", "Image"),
  video: path.join(appPath, "Cache", "Video"),
};

初始化数据库



 *  初始化数据
 */
function initData() {
  try {
    
    const appPath = fileCacheConfig.path || app.getPath("userData");

    
    const filePathDatabase = path.join(app.getPath("userData"), "cache.db");
    Object.values(cacheDirPathMap).forEach((path) => {
      
      if (!fs.existsSync(path)) {
        
        fs.mkdirSync(path, { recursive: true });
        Logger.log(`目录已经被创建: ${path}`);
      }
    });

    
    db = new Database(filePathDatabase);

    db.exec(
      `CREATE TABLE IF NOT EXISTS cache (
        id         integer primary	key AUTOINCREMENT,
        filePath   text,
        fileId     text,
        fileMd5    text);

      CREATE TABLE IF NOT EXISTS webRequestCache (
        id         integer primary	key AUTOINCREMENT,
        filePath   text,
        url        text,
        fileMd5    text);
    `
    );
  } catch (error) {
    Logger.log("数据库表初始化异常", error);
  }
}

onBeforeRequest

发起请求前,先检查请求的资源接口地址是否已经在本地存在了,如果已经存在则直接返回,status为200。


  session.defaultSession.webRequest.onBeforeRequest({ urls: [], types: [] }, (details, callback) => {
    const url = details.url;
    const id = `${details.id}`;

    if (db && fileCacheConfig.isAutoCache) {
      
      
      const isHasTableData = getCacheTable(url) as any;
      if (isHasTableData && fs.existsSync(isHasTableData.filePath)) {
        
        callback({ redirectURL: `file:///${hasSqliteData.filePath}` });
      } else {
        
        if (!requestMap[id]?.isDownloading) {
          requestMap[id] = {
            id,
            originUrl: url,
            resourceUrl: "",
            resourceId: "",
            isDownloading: false,
          };
        }
      }
    }

    if (requestMap[id] && !requestMap[id]?.isDownloading) {
      requestMap[id].resourceUrl = url;
    }

    callback({});
  });

onCompleted

创建换一个后置请求处理方法:


  session.defaultSession.webRequest.onCompleted({ urls: [], types: [] }, (details) => {
    const url = details.url;
    const id = `${details.id}`;

    if (requestMap[id]) {
      const flagUrl = setDetailsId(url, id);
      const fileName = createFileName(url, id);

      if (fileName && getFileSize(url) < fileCacheConfig.isLimitSize && details.resourceType === "image") {
        const savePath = path.join(cacheDirPathMap[details.resourceType], fileName);
        
        const instance = MainWindow.getInstance();
        instance.saveFilePath = savePath;
        instance.saveFilePathMap[url] = savePath;
        instance.mainWindow?.webContents?.downloadURL?.(url);

        
        requestMap[id].isDownloading = true;
      } else {
        
        if (requestMap[id]) {
          delete requestMap[id];
        }
      }
    }
  });

资源下载

 
  session.defaultSession.on("will-download", (event: Event, item: DownloadItem, webContents: WebContents) => {
    const url = item.getURL();
    const id = getDetailsId(url) || "";
    try {
      item.once("done", async (_, state: string) => {
        if (state === "completed" && id) {
          
          const fileMd5 = (await getFileMd5(item.savePath)) as string;
          
          const requestItem = findRequestItemByID(id);
          
          if (requestItem && fileMd5) {
            
            updateCacheTable({
              url: requestItem.originUrl,
              filePath: item.savePath,
              fileMd5: fileMd5,
            });
          }
        } else {
          Logger.log(`资源下载失败: ${state}`);
        }

        
        if (requestMap[id]) {
          delete requestMap[id];
        }
      });
    } catch (error) {
      Logger.log("下载失败:", error);
    }
  });

完整代码


const { app, session } = require('electron')
import Logger from "electron-log";
import fs from "fs";
import path from "path";
import Database from 'better-sqlite3';
import crypto from 'crypto';


const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
  
  app.quit();
} else {
  
  app.on("second-instance", (event, commandLine, workingDirectory) => {
    
    
    if (MainWindow.mainWindow) {
      MainWindow.getInstance().setWindowVisible(true, false);
      MainWindow.mainWindow.focus();
    }
  });

  if (app.isReady()) {
    startApp();
  } else {
    app.once("ready", startApp);
  }
}



function startApp() {
  
  MainWindow.getInstance();
  
  initSession();
}




app.on("window-all-closed", () => {
  
  app.quit();
});



let db: any = null;
const fileCacheConfig = {
  path: app.getPath("userData"),
  isAutoCache: true, 
  isLimitSize: 1024 * 1024 * 100, 
};

interface requestItem {
  id: string;
  originUrl: string;
  resourceUrl: string;
  resourceId: string;
  isDownloading: boolean;
}

const requestMap: Record<string, requestItem> = {};

const cacheDirPathMap = {
  file: path.join(appPath, "Cache", "File"),
  image: path.join(appPath, "Cache", "Image"),
  video: path.join(appPath, "Cache", "Video"),
};


 *  初始化数据
 */
function initData() {
  try {
    
    const appPath = fileCacheConfig.path || app.getPath("userData");

    
    const filePathDatabase = path.join(app.getPath("userData"), "cache.db");
    Object.values(cacheDirPathMap).forEach((path) => {
      
      if (!fs.existsSync(path)) {
        
        fs.mkdirSync(path, { recursive: true });
        Logger.log(`目录已经被创建: ${path}`);
      }
    });

    
    db = new Database(filePathDatabase);

    db.exec(
      `CREATE TABLE IF NOT EXISTS cache (
        id         integer primary	key AUTOINCREMENT,
        filePath   text,
        fileId     text,
        fileMd5    text);

      CREATE TABLE IF NOT EXISTS webRequestCache (
        id         integer primary	key AUTOINCREMENT,
        filePath   text,
        url        text,
        fileMd5    text);
    `
    );
  } catch (error) {
    Logger.log("数据库表初始化异常", error);
  }
}


 * 查询webRequestCache表
 * @param url url
 * @returns *
 */
const getCacheTable = (url: string) => {
  try {
    const stmt = db.prepare(`SELECT * FROM webRequestCache WHERE url = ?`);
    const result = stmt.get(url);
    return result;
  } catch (error) {
    return false;
  }
};


 * 设置下载任务的id
 * 主要用作任务映射每个任务都是一个独立的id类似于迅雷多列下载
 *
 * 通过key = value 的形式作为映射规则
 * @param url
 * @param id
 * @returns
 */
export const setDetailsId = (url: string, id: string) => {
  if (url.indexOf("?") > -1) {
    return url + `&downloadItemDetailsId=${id}`;
  } else {
    return url + `?downloadItemDetailsId=${id}`;
  }
};


 * 查找本地缓存的是否存在该资源
 * @param url url
 * @returns *
 */
const findRequestItemByUrl = (url: string): requestItem | undefined => {
  return Object.values(requestMap).find((item: requestItem) => {
    return item.resourceUrl === url;
  });
};


 * 创建文件名
 * @param url 链接
 * @param id id
 * @returns *
 */
function createFileName(url: string, id: string) {
  
  try {
    const extname = path.extname(url.replace(/?.*/gi, ""));
    const requestItem = requestMap[id] || findRequestItemByUrl(id);
    const fileName = getStringMd5(requestItem.originUrl);
    return `${fileName}${extname}`;
  } catch (err) {
    return false;
  }
}


 * 获取下载项
 * @param url
 * @returns
 */
export const getDetailsId = (url: string) => {
  const reg = new RegExp(`&downloadItemDetailsId=(\w*)`);
  const result = url.match(reg);
  if (result) {
    return result[1];
  }
  return false;
};


 * 通过资源id查找下载项
 * @param id id
 * @returns *
 */
const findRequestItemByID = (id: string): requestItem | undefined => {
  return Object.values(requestMap).find((item: requestItem) => {
    return item.resourceId === id;
  });
};


 * 更新数据库
 * @param data *
 * @returns *
 */
const updateCacheTable = (data: { url: string; filePath: string; fileMd5: string }) => {
  try {
    const hasSqliteData = selectWebRequestCacheTable(data.url) as any;

    if (hasSqliteData) {
      const update = db.prepare("UPDATE webRequestCache SET filePath = ?, fileMd5 = ? WHERE url = ?");
      update.run(data.filePath, data.fileMd5, data.url);
      return;
    }
    const insert = db.prepare(
      "INSERT INTO webRequestCache (filePath, url, fileMd5) VALUES (@filePath, @url, @fileMd5)"
    );
    insert.run(data);
  } catch (error) {
    Logger.log("记录到数据库报错", error);
    return false;
  }
};


 * 获取文件的md5
 * 为了避免文件重复使用文件的md5作为文件名
 * 如果是不同名称但是文件内容相同则使用相同的文件名能优化重复文件资源占用的问题
 * @param filePath 文件路径
 * @returns
 */
export const getFileMd5 = (filePath: string) => {
  return new Promise((resolve, _) => {
    const hash = crypto.createHash("md5");
    const stream = fs.createReadStream(filePath);

    stream.on("data", (chunk: any) => {
      hash.update(chunk, "utf8");
    });
    stream.on("end", () => {
      const md5 = hash.digest("hex");
      resolve(md5);
    });
  });
};


 * 初始化session事件
 */
function initSession() {
  
  initData();

  
  session.defaultSession.webRequest.onBeforeRequest({ urls: [], types: [] }, (details, callback) => {
    const url = details.url;
    const id = `${details.id}`;

    if (db && fileCacheConfig.isAutoCache) {
      
      
      const isHasTableData = getCacheTable(url) as any;
      if (isHasTableData && fs.existsSync(isHasTableData.filePath)) {
        
        callback({ redirectURL: `file:///${hasSqliteData.filePath}` });
      } else {
        
        if (!requestMap[id]?.isDownloading) {
          requestMap[id] = {
            id,
            originUrl: url,
            resourceUrl: "",
            resourceId: "",
            isDownloading: false,
          };
        }
      }
    }

    if (requestMap[id] && !requestMap[id]?.isDownloading) {
      requestMap[id].resourceUrl = url;
    }

    callback({});
  });
  
  session.defaultSession.webRequest.onCompleted({ urls: [], types: [] }, (details) => {
    const url = details.url;
    const id = `${details.id}`;

    if (requestMap[id]) {
      const flagUrl = setDetailsId(url, id);
      const fileName = createFileName(url, id);

      if (fileName && getFileSize(url) < fileCacheConfig.isLimitSize && details.resourceType === "image") {
        const savePath = path.join(cacheDirPathMap[details.resourceType], fileName);
        
        const instance = MainWindow.getInstance();
        instance.saveFilePath = savePath;
        instance.saveFilePathMap[url] = savePath;
        instance.mainWindow?.webContents?.downloadURL?.(url);

        
        requestMap[id].isDownloading = true;
      } else {
        
        if (requestMap[id]) {
          delete requestMap[id];
        }
      }
    }
  });
  
  session.defaultSession.on("will-download", (event: Event, item: DownloadItem, webContents: WebContents) => {
    const url = item.getURL();
    const id = getDetailsId(url) || "";
    try {
      item.once("done", async (_, state: string) => {
        if (state === "completed" && id) {
          
          const fileMd5 = (await getFileMd5(item.savePath)) as string;
          
          const requestItem = findRequestItemByID(id);
          
          if (requestItem && fileMd5) {
            
            updateCacheTable({
              url: requestItem.originUrl,
              filePath: item.savePath,
              fileMd5: fileMd5,
            });
          }
        } else {
          Logger.log(`资源下载失败: ${state}`);
        }

        
        if (requestMap[id]) {
          delete requestMap[id];
        }
      });
    } catch (error) {
      Logger.log("下载失败:", error);
    }
  });
}


问题解惑

1. 支持自定义配置存储的磁盘位置

配置信息fileCacheConfig中,支持修改缓存资源存储的位置。

2. 支持长期存储

只要应用没有被卸载,缓存会一直存在于设备本地。除非用户主动清理缓存。

应用的缓存位置默认通过app.getPath("userData")获取。

mac端默认是:~/Users/[用户名称]/Library/Application Support/[应用名称]/

windows端默认是:%用户名称%\AppData\Roaming\{应用名称}\

3. 支持自定义存储大小

在响应拦截器中,我们做了资源大小的检查getFileSize(url) < fileCacheConfig.isLimitSize,当资源小于100M时(支持自定义配置)才会缓存到本地。

这个打开可以在配置项中修改。

getFileSize函数是通过获取请求头header中返回是length字段计算得来的。

4. 支持自定义存储类型(如图片、视频、文件,或者更细致化到MIME)

当前演示的是缓存image类型,具体支持的类型有很多,参见Electron官网

  • resourceType string - 可以是 mainFrame, subFramestylesheetscriptimagefontobjectxhrpingcspReportmediawebSocket 或 other

如果是视频类型,则在相应拦截器中添加<font style="color:rgb(28, 30, 33);background-color:rgb(246, 247, 248);">details.resourceType === 'media'</font>

5. 支持缓存清除

  1. 需要清除sqlite数据库,因为数据库中的url映射到的是本地资源路径。

  2. 需要清除fileCacheConfig.path下的资源内容。



const cacheDirPathMap = {
  file: path.join(appPath, "Cache", "File"),
  image: path.join(appPath, "Cache", "Image"),
  video: path.join(appPath, "Cache", "Video"),
};


 * 删除文件夹文件
 *
 * @private
 * @async
 * @param {string} folderPath
 * @returns {*}
 */
async function clearCache() {
  try {
    
    for (let path of Object.values(cacheDirPathMap)) {
      await promisify(fs.rm)(path, { recursive: true })
    }
    
    db.prepare(`DELETE FROM cache`).run()
  } catch (error) {
    Logger.log('[sqlite] 删除失败', error);
  }
}

6. 缓存计算不阻塞主线程

文件的下载和缓存是由will-download处理的。

在Electron中,will-download事件本身并不会直接阻塞主进程。will-download是Electron中用于监听和控制文件下载的一个事件,它属于Electron的session对象。当一个文件开始下载时,这个事件会被触发,允许开发者在下载过程中进行自定义处理,比如设置文件的保存路径、监听下载进度等。

will-download事件的工作原理

  • 当一个下载请求发生时,Electron的session对象会触发will-download事件。

  • 这个事件的处理程序中,开发者可以访问到与下载相关的DownloadItem对象,通过该对象可以控制下载过程,比如设置下载路径、暂停或取消下载等。

  • will-download事件的处理是异步的,它不会直接阻塞主进程。主进程可以继续执行其他任务,而下载过程则在后台进行。

7. 强制缓存和webRequest会有冲突吗?

两者确实会有冲突。

强制换存的作用:

  1. 强缓存机制(如 HTTP 的 Cache-Control, Expires)在浏览器中是用来控制请求的缓存行为的。如果某个请求被强缓存,浏览器在接下来的请求中不会与服务器通信,而是直接从缓存中读取资源。

  2. 强缓存一般分为两种:

  • 协商缓存(需要向服务器确认资源是否更新);

  • 强制缓存(完全由客户端控制,不与服务器通信)。

webRequest.onBeforeRequest 和缓存的关系

  • **onBeforeRequest** 允许你在资源请求发出前进行拦截和重定向。如果你通过这个拦截器对请求进行了修改,比如更改了 URL 或重定向了请求,强缓存机制可能会被绕过,因为请求已经被更改。

  • 如果资源已被强缓存,浏览器不会发出请求,因此也不会触发 onBeforeRequest

webRequest.onCompleted 和缓存的关系

  • **onCompleted** 会在请求完成后触发。如果资源是从缓存中获取的,这个事件依然会触发。不过,当强缓存生效时,可能根本不会进行网络请求,所以即使使用 onCompleted 监听,也可能不会有实际请求完成的事件。

冲突可能性

  1. **请求被缓存,不触发 ****onBeforeRequest**:如果强缓存生效,那么请求不会被发出,这意味着 onBeforeRequest 不会被触发,因为没有请求发送到服务器。

  2. 缓存读取与 **onCompleted** 的问题:如果资源是从缓存中加载的,onCompleted 依然可能会触发,但是不会有实际网络请求,只会报告资源从缓存中读取成功。

如何避免冲突

如果你希望确保 onBeforeRequestonCompleted 始终生效并能拦截所有请求(包括缓存中的请求),你可以通过以下几种方式禁用缓存或手动控制缓存行为:

  1. 在请求拦截器中禁用缓存: 你可以在 onBeforeRequest 中通过设置 HTTP 请求头 Cache-Control: no-cache 来绕过缓存,确保请求每次都会发出:

session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
  details.requestHeaders['Cache-Control'] = 'no-cache';
  callback({ cancel: false, requestHeaders: details.requestHeaders });
});

  1. 禁用 Electron 的全局缓存: 你可以通过 session API 来禁用 Electron 的缓存。
const { session } = require('electron');
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
  callback({
    cancel: false,
    requestHeaders: { ...details.requestHeaders, 'Cache-Control': 'no-cache' }
  });
});
  1. 清除缓存: 如果缓存内容导致问题,你可以在需要的时候手动清除缓存,确保所有请求重新加载:
session.defaultSession.clearCache().then(() => {
  console.log('Cache cleared');
});

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
electron2025-03-05
不依赖第三方库自己实现 Electron 的主与渲染进程的国际化功能
正文 直接贴出代码: 主进程实现 首先需要在 文件中获取当前系统的语言,然后初始化国际化相关: 文件: 文件 然后在主进程中就可以通过 来实现国际化了。 渲染进程的实现 App 在启动的时候渲染进程已经执行了国际化的初始化,所以在主进程已经保存了一份当前语言的 信息,所以渲染进程就不需要进程这一步,直接从主进程获取即可。 主进程进程添加一个 事件,供渲染进程获取国际化相关数据: 首先我们要在 preload 中提前将国际化相关的信息写入...
学习
electron2025-03-05
应用多开,限制只启动一个应用,防止多个实例
通过app.requestSingleInstanceLock来控制应用的多开,返回值为boolean。 此方法的返回值表示你的应用程序实例是否成功取得了锁。如果它取得锁失败,你可以假设另一个应用实例已经取得了锁并且仍旧在运行,并立即退出。 即:如果当前进程是应用程序的主要实例,则此方法返回true,同时你的应用会继续运行。如果当它返回false,如果你的程序没有取得锁,它应该立刻退出,并且将参数发送给那个已经取到锁的进程。 在macO...
学习
electron2024-09-23
我的 Electron 客户端也可以全量和增量更新了
前言 本文主要介绍 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。 如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。 前置说明: 1. 由于业务场景的限制,本文介绍的更新仅支持 操作系统,其余操作系统未作兼容处理。 2. 更新流...
学习
electron2024-09-20
我的 Electron 客户端被第三方页面入侵了
问题描述 公司有个内部项目是用 来开发的,有个功能需要像浏览器一样加载第三方站点。 本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面 。 这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。 翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。 奇怪的是两者不是 跨域 了吗,为什么 还可以影响顶级窗口。 先说一下我当时的一些解决办法: 1. 用 替换 2. 给 添...
学习
electron2024-09-13
Electron+WebRTC实现局域网内的远程控制软件
我的工位上有一台笔记本(windows),一个显示器,一台服务器(linux)。显示器同时连接笔记本和服务器,当需要用到服务器的时候:1.使用todesk等软件进行连接;2.直接把显示器切换到服务器桌面,然后给服务器插上鼠标键盘。在这种场景下,屏幕画面的传输对我来说并不是必要的,这种场景下能不能用一套键鼠控制两个电脑呢?边做边学便有了这个小工具。记录下来,其中遇到的问题和解决方案。 通过electron + Vue + primevue...
学习
electron2024-09-11
如何将 Electron 项目上架
前言 是一个开源框架,它允许开发者使用 技术( 、 和 )来构建跨平台的桌面应用程序。 应用程序可以运行在 、 和 上,为用户提供了一种统一的方式来开发和维护软件。 本文将探讨如何将 Electron 构建的桌面应用程序上架到 中。 创建证书 进入苹果开发者平台 进入证书列表: 创建证书(Certificates) 用于对需要上架应用进行签名 创建 和 两个证书。 证书用于在 网站上注册的计算机上签署用于开发和测试的应用程序。 注册方法...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录