feat: 添加 favicon 双层缓存机制

实现仿 Chrome 的 favicon 缓存系统:
  - 第一层:域名缓存 - 导航开始时立即查询,快速显示 favicon
  - 第二层:URL 缓存 - favicon URL 精确匹配
  - 支持内存缓存 + 文件持久化,应用启动时自动清理 30 天过期缓存
This commit is contained in:
kuaifan 2026-01-12 05:40:57 +00:00
parent 925449c66a
commit 0d85174250
3 changed files with 377 additions and 2 deletions

View File

@ -46,6 +46,7 @@ const {onRenderer, renderer} = require("./lib/renderer");
const {onExport} = require("./lib/pdf-export");
const {allowedCalls, isWin, isMac} = require("./lib/other");
const webTabManager = require("./lib/web-tab-manager");
const faviconCache = require("./lib/favicon-cache");
// 实例初始化
const userConf = new electronConf()
@ -531,6 +532,9 @@ if (!getTheLock) {
isReady = true
isWin && app.setAppUserModelId(config.appId)
// 清理过期的 favicon 缓存
faviconCache.cleanExpiredCache()
// 初始化 webTabManager
webTabManager.init({
getServerUrl: () => serverUrl,

356
electron/lib/favicon-cache.js vendored Normal file
View File

@ -0,0 +1,356 @@
/**
* Favicon 缓存模块
*
* 实现双层缓存机制仿 Chrome
* - 第一层域名缓存 - 快速响应在导航开始时立即查询
* - 第二层URL 缓存 - 精确匹配在获取到 favicon URL 后查询
*/
const fs = require('fs')
const path = require('path')
const { app, net } = require('electron')
// 缓存目录
let cacheDir = null
// 内存缓存(加速读取)
const memoryCache = {
domains: new Map(), // domain -> base64
urls: new Map() // url -> base64
}
/**
* 初始化缓存目录
*/
function ensureCacheDir() {
if (cacheDir) return cacheDir
cacheDir = path.join(app.getPath('userData'), 'favicon-cache')
// 确保缓存目录存在
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true })
}
return cacheDir
}
/**
* URL 或域名转换为安全的文件名
* @param {string} str - URL 或域名
* @param {string} prefix - 前缀 ('domain_' 'url_')
* @returns {string} 安全的文件名
*/
function toSafeFileName(str, prefix) {
// 移除协议
let safe = str.replace(/^https?:\/\//, '')
// 将特殊字符替换为下划线
safe = safe.replace(/[^a-zA-Z0-9.-]/g, '_')
// 移除连续的下划线
safe = safe.replace(/_+/g, '_')
// 移除首尾下划线
safe = safe.replace(/^_|_$/g, '')
// 限制长度(避免文件名过长)
if (safe.length > 200) {
safe = safe.substring(0, 200)
}
return prefix + safe
}
/**
* URL 提取域名
* @param {string} url
* @returns {string|null}
*/
function extractDomain(url) {
if (!url) return null
try {
const urlObj = new URL(url)
return urlObj.hostname
} catch {
return null
}
}
/**
* 获取域名缓存的文件路径
* @param {string} domain
* @returns {string}
*/
function getDomainCachePath(domain) {
ensureCacheDir()
return path.join(cacheDir, toSafeFileName(domain, 'domain_'))
}
/**
* 获取 URL 缓存的文件路径
* @param {string} url
* @returns {string}
*/
function getUrlCachePath(url) {
ensureCacheDir()
return path.join(cacheDir, toSafeFileName(url, 'url_'))
}
/**
* 根据域名获取缓存的 favicon第一层缓存
* @param {string} domain - 域名
* @returns {string|null} - base64 数据或 null
*/
function getByDomain(domain) {
if (!domain) return null
// 先查内存缓存
if (memoryCache.domains.has(domain)) {
return memoryCache.domains.get(domain)
}
// 再查文件缓存
const filePath = getDomainCachePath(domain)
try {
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, 'utf8')
// 写入内存缓存
memoryCache.domains.set(domain, data)
return data
}
} catch {
// 忽略读取错误
}
return null
}
/**
* 根据 URL 获取缓存的 favicon第二层缓存
* @param {string} url - favicon URL
* @returns {string|null} - base64 数据或 null
*/
function getByUrl(url) {
if (!url) return null
// 先查内存缓存
if (memoryCache.urls.has(url)) {
return memoryCache.urls.get(url)
}
// 再查文件缓存
const filePath = getUrlCachePath(url)
try {
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, 'utf8')
// 写入内存缓存
memoryCache.urls.set(url, data)
return data
}
} catch {
// 忽略读取错误
}
return null
}
/**
* 保存 favicon 到缓存
* @param {string} faviconUrl - favicon URL
* @param {string} pageUrl - 页面的 URL用于提取域名
* @param {string} base64Data - base64 编码的 favicon 数据
*/
function save(faviconUrl, pageUrl, base64Data) {
if (!base64Data) return
const domain = extractDomain(pageUrl)
// 保存到 URL 缓存
if (faviconUrl) {
const urlPath = getUrlCachePath(faviconUrl)
memoryCache.urls.set(faviconUrl, base64Data)
fs.writeFile(urlPath, base64Data, 'utf8', () => {})
}
// 保存到域名缓存
if (domain) {
const domainPath = getDomainCachePath(domain)
memoryCache.domains.set(domain, base64Data)
fs.writeFile(domainPath, base64Data, 'utf8', () => {})
}
}
/**
* 获取并缓存 favicon
* 先查缓存无缓存则下载并保存
* @param {string} faviconUrl - favicon URL
* @param {string} pageUrl - 页面 URL
* @param {number} timeout - 超时时间毫秒
* @returns {Promise<string|null>} - base64 数据或 null
*/
async function fetchAndCache(faviconUrl, pageUrl, timeout = 5000) {
if (!faviconUrl || typeof faviconUrl !== 'string') {
return null
}
// 如果已经是 base64直接保存并返回
if (faviconUrl.startsWith('data:')) {
save(null, pageUrl, faviconUrl)
return faviconUrl
}
// 先查 URL 缓存
const cached = getByUrl(faviconUrl)
if (cached) {
// 更新域名缓存(确保域名缓存是最新的)
const domain = extractDomain(pageUrl)
if (domain && !memoryCache.domains.has(domain)) {
save(null, pageUrl, cached)
}
return cached
}
// 下载 favicon
const base64Data = await downloadFavicon(faviconUrl, timeout)
if (base64Data) {
// 保存到缓存
save(faviconUrl, pageUrl, base64Data)
}
return base64Data
}
/**
* 下载 favicon 并转换为 base64
* @param {string} faviconUrl
* @param {number} timeout
* @returns {Promise<string|null>}
*/
function downloadFavicon(faviconUrl, timeout = 5000) {
return new Promise((resolve) => {
try {
const request = net.request(faviconUrl)
const timeoutId = setTimeout(() => {
request.abort()
resolve(null)
}, timeout)
const chunks = []
request.on('response', (response) => {
const contentType = response.headers['content-type']
const isImage = contentType && (
contentType.includes('image/') ||
contentType.includes('icon')
)
if (response.statusCode !== 200 || !isImage) {
clearTimeout(timeoutId)
resolve(null)
return
}
response.on('data', (chunk) => {
chunks.push(chunk)
})
response.on('end', () => {
clearTimeout(timeoutId)
try {
const buffer = Buffer.concat(chunks)
if (buffer.length < 10) {
resolve(null)
return
}
let mimeType = 'image/png'
if (contentType) {
const match = contentType.match(/^([^;]+)/)
if (match) {
mimeType = match[1].trim()
}
}
const base64 = buffer.toString('base64')
resolve(`data:${mimeType};base64,${base64}`)
} catch {
resolve(null)
}
})
response.on('error', () => {
clearTimeout(timeoutId)
resolve(null)
})
})
request.on('error', () => {
clearTimeout(timeoutId)
resolve(null)
})
request.end()
} catch {
resolve(null)
}
})
}
/**
* 清空缓存
*/
function clearCache() {
memoryCache.domains.clear()
memoryCache.urls.clear()
if (cacheDir && fs.existsSync(cacheDir)) {
try {
const files = fs.readdirSync(cacheDir)
for (const file of files) {
fs.unlinkSync(path.join(cacheDir, file))
}
} catch {
// 忽略错误
}
}
}
/**
* 清理过期的缓存文件
* 默认清理 30 天未访问的文件
* @param {number} maxAgeDays - 最大保留天数默认 30
*/
function cleanExpiredCache(maxAgeDays = 30) {
ensureCacheDir()
if (!cacheDir || !fs.existsSync(cacheDir)) {
return
}
const now = Date.now()
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000
try {
const files = fs.readdirSync(cacheDir)
for (const file of files) {
const filePath = path.join(cacheDir, file)
try {
const stats = fs.statSync(filePath)
// 使用访问时间atime判断是否过期
if (now - stats.atimeMs > maxAgeMs) {
fs.unlinkSync(filePath)
}
} catch {
// 忽略单个文件的错误
}
}
} catch {
// 忽略错误
}
}
module.exports = {
extractDomain,
getByDomain,
getByUrl,
save,
fetchAndCache,
clearCache,
cleanExpiredCache
}

View File

@ -21,6 +21,7 @@ const {
const utils = require('./utils')
const navigation = require('./navigation')
const { allowedCalls, isMac } = require('./other')
const faviconCache = require('./favicon-cache')
const { renderer } = require('./renderer')
// ============================================================
@ -224,6 +225,18 @@ function createWebTabWindow(args) {
webTabWindow.setTitle(args.title)
}
} else {
// 从域名缓存获取 favicon快速响应
const domain = faviconCache.extractDomain(args.url)
const cachedFavicon = domain ? faviconCache.getByDomain(domain) : null
// 如果有缓存,保存到视图对象
if (cachedFavicon) {
const viewItem = windowData.views.find(v => v.id === browserView.webContents.id)
if (viewItem) {
viewItem.favicon = cachedFavicon
}
}
// tab 模式下通知标签栏创建新标签
utils.onDispatchEvent(webTabWindow.webContents, {
event: 'create',
@ -232,6 +245,7 @@ function createWebTabWindow(args) {
afterId: args.afterId,
windowId: windowId,
title: args.title,
favicon: cachedFavicon || '',
}).then(_ => { })
}
activateWebTabInWindow(windowId, browserView.webContents.id)
@ -565,9 +579,10 @@ function createWebTabView(windowId, args) {
const tabId = browserView.webContents.id
const faviconUrl = favicons[favicons.length - 1] || ''
const pageUrl = browserView.webContents.getURL()
// 验证并转换 favicon 为 base64
const base64Favicon = await utils.fetchFaviconAsBase64(faviconUrl)
// 使用缓存模块获取 favicon先查缓存无则下载并缓存
const base64Favicon = await faviconCache.fetchAndCache(faviconUrl, pageUrl)
// 保存验证后的 favicon 到视图对象
const viewItem = wd.views.find(v => v.id === tabId)