背景

基于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两个方法,支持对请求发送前和请求响应成功进行处理;请求前检查资源是否已经被缓存,如果已经被缓存,则直接返回被缓存的资源路径。如果缓存不存在,则等待请求响应,并将响应的资源下载到本地。

实现方案

缓存配置

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

缓存配置信息:

 1let db: any = null; 
 2
 3const fileCacheConfig = {
 4  path: app.getPath("userData"),
 5  isAutoCache: true, 
 6  isLimitSize: 1024 * 1024 * 100, 
 7};
 8
 9interface requestItem {
10  id: string;
11  originUrl: string;
12  resourceUrl: string;
13  resourceId: string;
14  isDownloading: boolean;
15}
16
17const requestMap: Record<string, requestItem> = {};
18
19const cacheDirPathMap = {
20  file: path.join(appPath, "Cache", "File"),
21  image: path.join(appPath, "Cache", "Image"),
22  video: path.join(appPath, "Cache", "Video"),
23};
24

初始化数据库

 1
 2
 3 *  初始化数据
 4 */
 5function initData() {
 6  try {
 7    
 8    const appPath = fileCacheConfig.path || app.getPath("userData");
 9
10    
11    const filePathDatabase = path.join(app.getPath("userData"), "cache.db");
12    Object.values(cacheDirPathMap).forEach((path) => {
13      
14      if (!fs.existsSync(path)) {
15        
16        fs.mkdirSync(path, { recursive: true });
17        Logger.log(`目录已经被创建: ${path}`);
18      }
19    });
20
21    
22    db = new Database(filePathDatabase);
23
24    db.exec(
25      `CREATE TABLE IF NOT EXISTS cache (
26        id         integer primary	key AUTOINCREMENT,
27        filePath   text,
28        fileId     text,
29        fileMd5    text);
30
31      CREATE TABLE IF NOT EXISTS webRequestCache (
32        id         integer primary	key AUTOINCREMENT,
33        filePath   text,
34        url        text,
35        fileMd5    text);
36    `
37    );
38  } catch (error) {
39    Logger.log("数据库表初始化异常", error);
40  }
41}
42

onBeforeRequest

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

 1
 2  session.defaultSession.webRequest.onBeforeRequest({ urls: [], types: [] }, (details, callback) => {
 3    const url = details.url;
 4    const id = `${details.id}`;
 5
 6    if (db && fileCacheConfig.isAutoCache) {
 7      
 8      
 9      const isHasTableData = getCacheTable(url) as any;
10      if (isHasTableData && fs.existsSync(isHasTableData.filePath)) {
11        
12        callback({ redirectURL: `file:///${hasSqliteData.filePath}` });
13      } else {
14        
15        if (!requestMap[id]?.isDownloading) {
16          requestMap[id] = {
17            id,
18            originUrl: url,
19            resourceUrl: "",
20            resourceId: "",
21            isDownloading: false,
22          };
23        }
24      }
25    }
26
27    if (requestMap[id] && !requestMap[id]?.isDownloading) {
28      requestMap[id].resourceUrl = url;
29    }
30
31    callback({});
32  });

onCompleted

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

 1
 2  session.defaultSession.webRequest.onCompleted({ urls: [], types: [] }, (details) => {
 3    const url = details.url;
 4    const id = `${details.id}`;
 5
 6    if (requestMap[id]) {
 7      const flagUrl = setDetailsId(url, id);
 8      const fileName = createFileName(url, id);
 9
10      if (fileName && getFileSize(url) < fileCacheConfig.isLimitSize && details.resourceType === "image") {
11        const savePath = path.join(cacheDirPathMap[details.resourceType], fileName);
12        
13        const instance = MainWindow.getInstance();
14        instance.saveFilePath = savePath;
15        instance.saveFilePathMap[url] = savePath;
16        instance.mainWindow?.webContents?.downloadURL?.(url);
17
18        
19        requestMap[id].isDownloading = true;
20      } else {
21        
22        if (requestMap[id]) {
23          delete requestMap[id];
24        }
25      }
26    }
27  });
28

资源下载

 1 
 2  session.defaultSession.on("will-download", (event: Event, item: DownloadItem, webContents: WebContents) => {
 3    const url = item.getURL();
 4    const id = getDetailsId(url) || "";
 5    try {
 6      item.once("done", async (_, state: string) => {
 7        if (state === "completed" && id) {
 8          
 9          const fileMd5 = (await getFileMd5(item.savePath)) as string;
10          
11          const requestItem = findRequestItemByID(id);
12          
13          if (requestItem && fileMd5) {
14            
15            updateCacheTable({
16              url: requestItem.originUrl,
17              filePath: item.savePath,
18              fileMd5: fileMd5,
19            });
20          }
21        } else {
22          Logger.log(`资源下载失败: ${state}`);
23        }
24
25        
26        if (requestMap[id]) {
27          delete requestMap[id];
28        }
29      });
30    } catch (error) {
31      Logger.log("下载失败:", error);
32    }
33  });

完整代码

 1
 2const { app, session } = require('electron')
 3import Logger from "electron-log";
 4import fs from "fs";
 5import path from "path";
 6import Database from 'better-sqlite3';
 7import crypto from 'crypto';
 8
 9
10const gotTheLock = app.requestSingleInstanceLock();
11
12if (!gotTheLock) {
13  
14  app.quit();
15} else {
16  
17  app.on("second-instance", (event, commandLine, workingDirectory) => {
18    
19    
20    if (MainWindow.mainWindow) {
21      MainWindow.getInstance().setWindowVisible(true, false);
22      MainWindow.mainWindow.focus();
23    }
24  });
25
26  if (app.isReady()) {
27    startApp();
28  } else {
29    app.once("ready", startApp);
30  }
31}
32
33
34
35function startApp() {
36  
37  MainWindow.getInstance();
38  
39  initSession();
40}
41
42
43
44
45app.on("window-all-closed", () => {
46  
47  app.quit();
48});
49
50
51
52let db: any = null;
53const fileCacheConfig = {
54  path: app.getPath("userData"),
55  isAutoCache: true, 
56  isLimitSize: 1024 * 1024 * 100, 
57};
58
59interface requestItem {
60  id: string;
61  originUrl: string;
62  resourceUrl: string;
63  resourceId: string;
64  isDownloading: boolean;
65}
66
67const requestMap: Record<string, requestItem> = {};
68
69const cacheDirPathMap = {
70  file: path.join(appPath, "Cache", "File"),
71  image: path.join(appPath, "Cache", "Image"),
72  video: path.join(appPath, "Cache", "Video"),
73};
74
75
76 *  初始化数据
77 */
78function initData() {
79  try {
80    
81    const appPath = fileCacheConfig.path || app.getPath("userData");
82
83    
84    const filePathDatabase = path.join(app.getPath("userData"), "cache.db");
85    Object.values(cacheDirPathMap).forEach((path) => {
86      
87      if (!fs.existsSync(path)) {
88        
89        fs.mkdirSync(path, { recursive: true });
90        Logger.log(`目录已经被创建: ${path}`);
91      }
92    });
93
94    
95    db = new Database(filePathDatabase);
96
97    db.exec(
98      `CREATE TABLE IF NOT EXISTS cache (
99        id         integer primary	key AUTOINCREMENT,
100        filePath   text,
101        fileId     text,
102        fileMd5    text);
103
104      CREATE TABLE IF NOT EXISTS webRequestCache (
105        id         integer primary	key AUTOINCREMENT,
106        filePath   text,
107        url        text,
108        fileMd5    text);
109    `
110    );
111  } catch (error) {
112    Logger.log("数据库表初始化异常", error);
113  }
114}
115
116
117 * 查询webRequestCache表
118 * @param url url
119 * @returns *
120 */
121const getCacheTable = (url: string) => {
122  try {
123    const stmt = db.prepare(`SELECT * FROM webRequestCache WHERE url = ?`);
124    const result = stmt.get(url);
125    return result;
126  } catch (error) {
127    return false;
128  }
129};
130
131
132 * 设置下载任务的id
133 * 主要用作任务映射每个任务都是一个独立的id类似于迅雷多列下载
134 *
135 * 通过key = value 的形式作为映射规则
136 * @param url
137 * @param id
138 * @returns
139 */
140export const setDetailsId = (url: string, id: string) => {
141  if (url.indexOf("?") > -1) {
142    return url + `&downloadItemDetailsId=${id}`;
143  } else {
144    return url + `?downloadItemDetailsId=${id}`;
145  }
146};
147
148
149 * 查找本地缓存的是否存在该资源
150 * @param url url
151 * @returns *
152 */
153const findRequestItemByUrl = (url: string): requestItem | undefined => {
154  return Object.values(requestMap).find((item: requestItem) => {
155    return item.resourceUrl === url;
156  });
157};
158
159
160 * 创建文件名
161 * @param url 链接
162 * @param id id
163 * @returns *
164 */
165function createFileName(url: string, id: string) {
166  
167  try {
168    const extname = path.extname(url.replace(/?.*/gi, ""));
169    const requestItem = requestMap[id] || findRequestItemByUrl(id);
170    const fileName = getStringMd5(requestItem.originUrl);
171    return `${fileName}${extname}`;
172  } catch (err) {
173    return false;
174  }
175}
176
177
178 * 获取下载项
179 * @param url
180 * @returns
181 */
182export const getDetailsId = (url: string) => {
183  const reg = new RegExp(`&downloadItemDetailsId=(\w*)`);
184  const result = url.match(reg);
185  if (result) {
186    return result[1];
187  }
188  return false;
189};
190
191
192 * 通过资源id查找下载项
193 * @param id id
194 * @returns *
195 */
196const findRequestItemByID = (id: string): requestItem | undefined => {
197  return Object.values(requestMap).find((item: requestItem) => {
198    return item.resourceId === id;
199  });
200};
201
202
203 * 更新数据库
204 * @param data *
205 * @returns *
206 */
207const updateCacheTable = (data: { url: string; filePath: string; fileMd5: string }) => {
208  try {
209    const hasSqliteData = selectWebRequestCacheTable(data.url) as any;
210
211    if (hasSqliteData) {
212      const update = db.prepare("UPDATE webRequestCache SET filePath = ?, fileMd5 = ? WHERE url = ?");
213      update.run(data.filePath, data.fileMd5, data.url);
214      return;
215    }
216    const insert = db.prepare(
217      "INSERT INTO webRequestCache (filePath, url, fileMd5) VALUES (@filePath, @url, @fileMd5)"
218    );
219    insert.run(data);
220  } catch (error) {
221    Logger.log("记录到数据库报错", error);
222    return false;
223  }
224};
225
226
227 * 获取文件的md5
228 * 为了避免文件重复使用文件的md5作为文件名
229 * 如果是不同名称但是文件内容相同则使用相同的文件名能优化重复文件资源占用的问题
230 * @param filePath 文件路径
231 * @returns
232 */
233export const getFileMd5 = (filePath: string) => {
234  return new Promise((resolve, _) => {
235    const hash = crypto.createHash("md5");
236    const stream = fs.createReadStream(filePath);
237
238    stream.on("data", (chunk: any) => {
239      hash.update(chunk, "utf8");
240    });
241    stream.on("end", () => {
242      const md5 = hash.digest("hex");
243      resolve(md5);
244    });
245  });
246};
247
248
249 * 初始化session事件
250 */
251function initSession() {
252  
253  initData();
254
255  
256  session.defaultSession.webRequest.onBeforeRequest({ urls: [], types: [] }, (details, callback) => {
257    const url = details.url;
258    const id = `${details.id}`;
259
260    if (db && fileCacheConfig.isAutoCache) {
261      
262      
263      const isHasTableData = getCacheTable(url) as any;
264      if (isHasTableData && fs.existsSync(isHasTableData.filePath)) {
265        
266        callback({ redirectURL: `file:///${hasSqliteData.filePath}` });
267      } else {
268        
269        if (!requestMap[id]?.isDownloading) {
270          requestMap[id] = {
271            id,
272            originUrl: url,
273            resourceUrl: "",
274            resourceId: "",
275            isDownloading: false,
276          };
277        }
278      }
279    }
280
281    if (requestMap[id] && !requestMap[id]?.isDownloading) {
282      requestMap[id].resourceUrl = url;
283    }
284
285    callback({});
286  });
287  
288  session.defaultSession.webRequest.onCompleted({ urls: [], types: [] }, (details) => {
289    const url = details.url;
290    const id = `${details.id}`;
291
292    if (requestMap[id]) {
293      const flagUrl = setDetailsId(url, id);
294      const fileName = createFileName(url, id);
295
296      if (fileName && getFileSize(url) < fileCacheConfig.isLimitSize && details.resourceType === "image") {
297        const savePath = path.join(cacheDirPathMap[details.resourceType], fileName);
298        
299        const instance = MainWindow.getInstance();
300        instance.saveFilePath = savePath;
301        instance.saveFilePathMap[url] = savePath;
302        instance.mainWindow?.webContents?.downloadURL?.(url);
303
304        
305        requestMap[id].isDownloading = true;
306      } else {
307        
308        if (requestMap[id]) {
309          delete requestMap[id];
310        }
311      }
312    }
313  });
314  
315  session.defaultSession.on("will-download", (event: Event, item: DownloadItem, webContents: WebContents) => {
316    const url = item.getURL();
317    const id = getDetailsId(url) || "";
318    try {
319      item.once("done", async (_, state: string) => {
320        if (state === "completed" && id) {
321          
322          const fileMd5 = (await getFileMd5(item.savePath)) as string;
323          
324          const requestItem = findRequestItemByID(id);
325          
326          if (requestItem && fileMd5) {
327            
328            updateCacheTable({
329              url: requestItem.originUrl,
330              filePath: item.savePath,
331              fileMd5: fileMd5,
332            });
333          }
334        } else {
335          Logger.log(`资源下载失败: ${state}`);
336        }
337
338        
339        if (requestMap[id]) {
340          delete requestMap[id];
341        }
342      });
343    } catch (error) {
344      Logger.log("下载失败:", error);
345    }
346  });
347}
348
349

问题解惑

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下的资源内容。

 1
 2
 3const cacheDirPathMap = {
 4  file: path.join(appPath, "Cache", "File"),
 5  image: path.join(appPath, "Cache", "Image"),
 6  video: path.join(appPath, "Cache", "Video"),
 7};
 8
 9
10 * 删除文件夹文件
11 *
12 * @private
13 * @async
14 * @param {string} folderPath
15 * @returns {*}
16 */
17async function clearCache() {
18  try {
19    
20    for (let path of Object.values(cacheDirPathMap)) {
21      await promisify(fs.rm)(path, { recursive: true })
22    }
23    
24    db.prepare(`DELETE FROM cache`).run()
25  } catch (error) {
26    Logger.log('[sqlite] 删除失败', error);
27  }
28}
29

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 来绕过缓存,确保请求每次都会发出:
 1
 2session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
 3  details.requestHeaders['Cache-Control'] = 'no-cache';
 4  callback({ cancel: false, requestHeaders: details.requestHeaders });
 5});
 6
  1. 禁用 Electron 的全局缓存: 你可以通过 session API 来禁用 Electron 的缓存。
 1const { session } = require('electron');
 2session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
 3  callback({
 4    cancel: false,
 5    requestHeaders: { ...details.requestHeaders, 'Cache-Control': 'no-cache' }
 6  });
 7});
  1. 清除缓存: 如果缓存内容导致问题,你可以在需要的时候手动清除缓存,确保所有请求重新加载:
 1session.defaultSession.clearCache().then(() => {
 2  console.log('Cache cleared');
 3});
个人笔记记录 2021 ~ 2025