前端在 WebView 和 H5 环境下的缓存问题,目标是确保用户能够加载到最新版本的资源。这是一个常见且有时棘手的问题,通常需要结合多种策略来解决。
核心问题:为什么会缓存?
缓存本身是提升性能的关键机制。浏览器和 WebView 会缓存静态资源(JS, CSS, 图片等)甚至 HTML 页面,以避免每次都从服务器重新下载,加快页面加载速度,减少网络流量。但问题在于,当你更新了资源后,如果缓存策略不当,用户设备上的缓存副本没有失效,用户就会继续使用旧版本。
缓存可能发生在多个层面:
- 浏览器/WebView 自身缓存: 基于 HTTP 缓存头(如
Cache-Control
,Expires
,ETag
,Last-Modified
)。 - HTTP 代理缓存: (较少见于移动端)中间网络节点可能缓存资源。
- CDN 缓存: 如果你使用了内容分发网络 (CDN),CDN 节点会缓存资源。
- Service Worker 缓存: 如果你使用了 Service Worker,它会接管缓存控制逻辑。
- 原生代码层面的缓存(WebView 特定): Android WebView 和 iOS WKWebView 提供了额外的缓存控制 API。
解决方案策略概览
解决缓存问题的核心思想是:让客户端(浏览器/WebView)知道资源已经更新,需要重新获取。 主要方法包括:
- 资源 URL 版本化(最佳实践):
- 内容哈希 (Content Hashing): 为文件名添加基于文件内容的哈希值(如
app.a1b2c3d4.js
)。内容改变则哈希改变,URL 改变,缓存自然失效。这是最推荐的方式。 - 版本号参数: 在 URL 后附加版本号查询参数(如
app.js?v=1.2.0
)。 - 时间戳参数: 在 URL 后附加时间戳查询参数(如
app.js?t=1678886400
)。
- 内容哈希 (Content Hashing): 为文件名添加基于文件内容的哈希值(如
- 配置 HTTP 缓存头: 通过服务器配置,精确控制资源的缓存行为。
- 原生 WebView 配置: 在 App 的原生代码中调整 WebView 的缓存策略或手动清除缓存。
- Service Worker: 使用 Service Worker 拦截请求并实现自定义的缓存更新逻辑。
- HTML Meta 标签(效果有限): 使用
<meta>
标签尝试影响缓存,但通常不如 HTTP 头可靠。
详细方案与代码示例
方案一:资源 URL 版本化 (基于构建工具) - 推荐
这是最有效和推荐的方法,因为它利用了缓存的工作原理:不同 URL 的资源被视为不同资源。
原理: 现代前端构建工具(如 Webpack, Vite, Parcel)可以在构建时根据文件内容生成唯一的哈希值,并将其添加到输出的文件名中。例如,main.js
可能会变成 main.a1b2c3d4.js
。当文件内容更改时,哈希值也会更改,生成一个新的文件名(如 main.e5f6g7h8.js
)。HTML 文件中引用的资源路径也会相应更新。
浏览器或 WebView 请求 main.e5f6g7h8.js
时,由于这是一个全新的 URL,它们会乖乖地从服务器下载新文件。对于未更改的文件(如库文件 vendor.xyz.js
),其哈希和 URL 保持不变,可以继续使用缓存。
实现 (以 Webpack 为例):
javascript
// webpack.config.js (简化示例)
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
return {
mode: isProduction ? 'production' : 'development',
entry: './src/index.js', // 你的入口文件
output: {
path: path.resolve(__dirname, 'dist'),
// [contenthash] 会根据文件内容生成哈希
// 在生产模式下使用哈希,开发模式下不需要,以便热更新
filename: isProduction ? 'js/[name].[contenthash:8].js' : 'js/[name].js',
chunkFilename: isProduction ? 'js/[name].[contenthash:8].chunk.js' : 'js/[name].chunk.js',
// 公共路径,确保资源能正确加载,特别是当你的应用不是部署在根目录时
// 如果使用 CDN,这里可以配置 CDN 地址
publicPath: '/',
// 清理旧的构建产物
clean: true,
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader', // 假设你使用了 Babel
},
},
{
test: /\.css$/,
use: [
// 在生产环境提取 CSS 到单独文件,开发环境使用 style-loader
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader',
'postcss-loader', // 假设你使用了 PostCSS
],
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource', // Webpack 5 内置的资源处理
generator: {
// 同样为图片等资源添加哈希
filename: 'images/[name].[contenthash:8][ext]',
},
},
// 其他 loader 配置 (e.g., for fonts, etc.)
],
},
plugins: [
// 自动生成 HTML 文件,并注入带哈希的 JS 和 CSS 文件链接
new HtmlWebpackPlugin({
template: './public/index.html', // 你的 HTML 模板
filename: 'index.html',
inject: 'body', // 'head' 或 'body' 或 true
minify: isProduction ? {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
} : false,
}),
// 仅在生产环境提取 CSS
isProduction && new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[name].[contenthash:8].chunk.css',
}),
// 其他插件 (e.g., DefinePlugin for environment variables)
].filter(Boolean), // 过滤掉非生产环境下的 MiniCssExtractPlugin
optimization: {
// 代码分割,将第三方库和公共模块提取出来
splitChunks: {
chunks: 'all',
name(module, chunks, cacheGroupKey) {
// 生成更可预测的 chunk 名称
const moduleFileName = module
.identifier()
.split('/')
.reduceRight((item) => item);
const allChunksNames = chunks.map((item) => item.name).join('~');
return `${cacheGroupKey}-${allChunksNames}-${moduleFileName}`;
},
},
// 为运行时代码(manifest)也创建一个单独的 chunk,防止它频繁变动导致其他 chunk 缓存失效
runtimeChunk: 'single',
},
devServer: {
static: './dist',
hot: true, // 开启热模块替换
historyApiFallback: true, // 对于单页应用需要
},
performance: {
hints: isProduction ? 'warning' : false, // 在生产中提示资源过大
},
// 可选:配置 source map
devtool: isProduction ? 'source-map' : 'eval-source-map',
};
};
实现 (以 Vite 为例):
Vite 默认在生产构建 (vite build
) 时就会对 JS, CSS 和静态资源进行哈希命名,通常不需要额外配置。
javascript
// vite.config.js (基本配置通常足够)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; // 或 vue(), etc.
import { visualizer } from 'rollup-plugin-visualizer'; // 可选:分析包大小
export default defineConfig({
plugins: [
react(),
visualizer({ open: true }) // 可选
],
build: {
// Vite 默认就会添加哈希
// rollupOptions: {
// output: {
// entryFileNames: `assets/[name].[hash].js`,
// chunkFileNames: `assets/[name].[hash].js`,
// assetFileNames: `assets/[name].[hash].[ext]`
// }
// }
sourcemap: true, // 生产环境生成 source map
},
// 配置开发服务器等
server: {
port: 3000,
open: true,
}
});
优点:
- 非常可靠,利用了基础的缓存机制。
- 可以对静态资源设置非常长的缓存时间(如一年),因为内容不变 URL 就不变。
- 构建工具自动处理,开发体验好。
缺点:
- 每次构建都会生成新的文件名,需要确保服务器能正确提供这些文件。
- 主 HTML 文件本身不能使用这种方法进行强缓存(否则无法获取到引用新资源路径的 HTML),通常需要结合 HTTP 头来控制 HTML 的缓存。
方案二:配置 HTTP 缓存头
控制 HTTP 缓存头是服务器端的任务,它告诉浏览器/WebView 如何缓存资源。
关键 HTTP 头:
-
Cache-Control
(HTTP/1.1, 首选):public
: 表明响应可以被任何中间缓存(如 CDN, 代理)缓存。private
: 响应只能被最终用户的浏览器缓存,不能被中间缓存。no-cache
: 强制 客户端在每次使用缓存副本前,必须 向服务器发送请求验证资源是否过期(使用ETag
或Last-Modified
)。如果资源未改变,服务器返回304 Not Modified
,客户端使用缓存;如果已改变,服务器返回200 OK
和新资源。注意:不是完全禁止缓存,而是需要验证。no-store
: 完全禁止 浏览器和任何中间缓存存储任何版本的响应。每次请求都必须从服务器完整下载。非常消耗性能,谨慎使用。max-age=<seconds>
: 指定资源被视为新鲜的最长时间(秒)。例如max-age=3600
表示缓存 1 小时。s-maxage=<seconds>
: 类似于max-age
,但仅适用于共享缓存(如 CDN)。优先级高于max-age
。must-revalidate
: 一旦资源过期(max-age
或Expires
),缓存必须到源服务器验证,不能直接使用陈旧副本(即使在网络断开等特殊情况下)。proxy-revalidate
: 类似于must-revalidate
,但仅适用于共享缓存。immutable
: (较新) 表明响应正文在新鲜期内不会改变。这可以告诉浏览器,只要资源未过期,就不需要因为用户刷新页面而去发起条件请求(If-None-Match
,If-Modified-Since
)进行验证。非常适合与内容哈希文件名结合使用。
-
Pragma
(HTTP/1.0):Pragma: no-cache
: 效果类似于Cache-Control: no-cache
,主要用于兼容旧的 HTTP/1.0 客户端。如果Cache-Control
存在,Pragma
通常会被忽略。
-
Expires
(HTTP/1.0):Expires: <http-date>
: 指定资源过期的绝对日期/时间。优先级低于Cache-Control: max-age
。由于依赖客户端和服务器时钟同步,不如max-age
精确。
-
ETag
(Entity Tag):- 服务器为资源生成的唯一标识符(通常是文件内容的哈希或版本号)。客户端缓存资源时会存储
ETag
。下次请求时,通过If-None-Match
请求头将ETag
发回服务器。如果服务器上的资源ETag
没变,返回304 Not Modified
;否则返回200 OK
和新资源及新的ETag
。
- 服务器为资源生成的唯一标识符(通常是文件内容的哈希或版本号)。客户端缓存资源时会存储
-
Last-Modified
:- 服务器提供的资源最后修改时间。客户端下次请求时通过
If-Modified-Since
请求头发送此时间。服务器比较时间,如果未修改,返回304
;否则返回200
和新资源及新的Last-Modified
时间。ETag
通常更精确。
- 服务器提供的资源最后修改时间。客户端下次请求时通过
推荐策略:
-
HTML 文件 (例如
index.html
):- 不要强缓存! 因为 HTML 文件是入口,它包含了对其他带哈希资源的引用。如果 HTML 被强缓存,用户将永远无法获取到引用新版 JS/CSS 的 HTML。
- 使用
Cache-Control: no-cache
。这确保浏览器每次都会向服务器验证 HTML 文件是否有更新。如果服务器检测到文件未变(基于ETag
或Last-Modified
),会返回304 Not Modified
,浏览器使用本地缓存,这仍然比完全下载快。 - 或者,使用
Cache-Control: max-age=0, must-revalidate
达到类似效果。
-
带内容哈希的静态资源 (JS, CSS, Images, Fonts 等):
- 强缓存! 因为它们的文件名已经包含了版本信息,内容不变 URL 就不变。内容改变了,URL 就会改变,自然不会命中旧缓存。
- 设置
Cache-Control: public, max-age=31536000, immutable
。public
: 允许 CDN 等中间缓存。max-age=31536000
: 缓存一年(365 * 24 * 60 * 60 秒)。immutable
: 告诉浏览器这个资源在有效期内绝不会改变,避免不必要的验证请求(即使是用户手动刷新页面)。
实现 (以 Nginx 为例):
nginx
# /etc/nginx/nginx.conf or sites-available/your-site.conf
server {
listen 80;
server_name yourdomain.com;
root /path/to/your/frontend/dist; # 指向构建产物目录
# 默认入口文件
index index.html index.htm;
location / {
try_files $uri $uri/ /index.html; # 支持 SPA 路由
}
# 对 HTML 文件设置 no-cache
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate"; # 更激进的禁止缓存HTML
# 或者使用 ETag/Last-Modified 验证
# add_header Cache-Control "no-cache";
# expires -1; # 另一种设置不缓存的方式 (等同于 Cache-Control: no-cache)
# etag on; # 确保 Nginx 生成 ETag
}
# 对带哈希的静态资源设置长期缓存
# 匹配 /js/, /css/, /images/, /fonts/ 等目录下的文件
# 正则表达式匹配文件名中包含点+至少8位字母数字哈希值+点+扩展名
location ~* \.(?:[a-z0-9]{8,})\.(?:js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$ {
# Nginx 会自动添加 Last-Modified,ETag 通常也建议开启 (默认可能已开启)
etag on;
add_header Cache-Control "public, max-age=31536000, immutable";
# expires 1y; # 也可以用 expires
access_log off; # 可选:关闭这些静态文件的访问日志
log_not_found off; # 可选:关闭未找到这些文件的日志
}
# 对其他普通静态资源(可能没有哈希,如 favicon.ico)设置较短缓存或验证
location ~* \.(?:ico|json|xml|txt)$ {
add_header Cache-Control "public, max-age=86400"; # 例如缓存一天
etag on;
}
# 其他配置(如 Gzip, HTTPS 等)
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# ... more gzip settings
# 如果是 HTTPS
# listen 443 ssl http2;
# server_name yourdomain.com;
# ssl_certificate /path/to/fullchain.pem;
# ssl_certificate_key /path/to/privkey.pem;
# include /path/to/options-ssl-nginx.conf;
# ssl_dhparam /path/to/ssl-dhparams.pem;
}
实现 (以 Node.js/Express 为例):
javascript
const express = require('express');
const path = require('path');
const serveStatic = require('serve-static'); // Express 内置或使用此库
const app = express();
const publicPath = path.join(__dirname, 'dist'); // 指向构建产物目录
// 中间件:为 HTML 设置 no-cache
app.use((req, res, next) => {
if (req.path === '/' || req.path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
// res.setHeader('Pragma', 'no-cache'); // For HTTP/1.0 compatibility
// res.setHeader('Expires', '0'); // For proxies
}
next();
});
// 使用 serve-static 或 express.static 提供静态文件服务
// serve-static 允许更精细的缓存控制
const staticServe = serveStatic(publicPath, {
// 对所有文件默认设置 ETag (serve-static 默认开启)
etag: true,
// 对所有文件默认设置 Last-Modified (serve-static 默认开启)
lastModified: true,
// 设置默认缓存控制,但会被特定规则覆盖
// index: false, // 防止 serve-static 自动服务 index.html (如果上面已处理)
// 针对特定文件类型设置更长的缓存
setHeaders: (res, filePath) => {
const filename = path.basename(filePath);
// 正则匹配带哈希的文件名 (可能需要根据你的哈希长度调整 {8,})
if (/\.[a-f0-9]{8,}\.(css|js|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$/i.test(filename)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else if (filename === 'index.html') {
// 确保 index.html 的 no-cache (虽然上面中间件已处理,双重保险)
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
} else {
// 其他文件可以设置一个较短的缓存或默认值
// res.setHeader('Cache-Control', 'public, max-age=3600'); // 例如缓存1小时
}
}
});
app.use(staticServe);
// 对于 SPA,确保刷新页面时能正确返回 index.html
app.get('*', (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // 确保这个回退的 HTML 也不缓存
res.sendFile(path.join(publicPath, 'index.html'));
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
优点:
- 补充了 URL 版本化策略,特别是控制了 HTML 文件的缓存。
- 服务器端控制,对所有客户端(包括旧版浏览器和 WebView)都有效。
缺点:
- 需要服务器配置权限。
- 配置可能因服务器软件(Nginx, Apache, Node.js, Caddy 等)而异。
- 如果资源 URL 不变,仅靠
no-cache
依赖客户端和服务端的验证机制,虽然比完全下载快,但仍有网络请求。
方案三:原生 WebView 配置与缓存清理
当你的 H5 页面主要运行在 App 内的 WebView 中时,可以在原生代码层面进行一些控制。
Android (Kotlin / Java):
kotlin
// Kotlin 示例
import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebViewClient
// 在你的 Activity 或 Fragment 中找到 WebView 实例
val webView: WebView = findViewById(R.id.your_webview_id)
// 获取 WebSettings
val settings: WebSettings = webView.settings
// 1. 配置缓存模式 (通常保持默认或根据需要调整)
// settings.cacheMode = WebSettings.LOAD_DEFAULT // 默认行为,根据 HTTP 头决定是否缓存
settings.cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK // 优先加载缓存,过期或不存在则网络加载
// settings.cacheMode = WebSettings.LOAD_NO_CACHE // 完全不使用缓存,每次都网络加载 (影响性能,调试用)
// settings.cacheMode = WebSettings.LOAD_CACHE_ONLY // 只加载缓存,不进行网络请求 (离线模式)
// 确保启用 JavaScript (通常都需要)
settings.javaScriptEnabled = true
// (可选) 启用 DOM Storage API (localStorage, sessionStorage)
settings.domStorageEnabled = true
// (可选) 启用数据库存储 API
settings.databaseEnabled = true
val databasePath = applicationContext.getDir("webviewdatabase", Context.MODE_PRIVATE).path
settings.databasePath = databasePath // 已废弃,但某些旧版本可能需要
// (可选) 启用 Application Caches API (已废弃,但可能兼容旧网页)
settings.setAppCacheEnabled(true);
val appCachePath = applicationContext.getDir("webviewcache", Context.MODE_PRIVATE).path
settings.setAppCachePath(appCachePath)
settings.allowFileAccess = true // AppCache 需要文件访问权限
// 2. 手动清除缓存 (在适当的时候调用,例如 App 更新后首次打开,或提供给用户的清理选项)
fun clearWebViewCache(webView: WebView) {
// 清除资源缓存 (图片、JS、CSS 等)
webView.clearCache(true) // 参数 'true' 表示也清除磁盘缓存
// 清除 Cookie (如果需要)
// android.webkit.CookieManager.getInstance().removeAllCookies(null)
// android.webkit.CookieManager.getInstance().flush()
// 清除 DOM Storage (localStorage, sessionStorage) - 需要在 JS 侧执行
// webView.evaluateJavascript("localStorage.clear(); sessionStorage.clear();", null)
// 清除 Web SQL Database (如果使用了)
// android.webkit.WebStorage.getInstance().deleteAllData() // 注意:会清除所有 WebView 的 storage
// 清除 Application Cache (如果使用了)
// 需要配合 WebChromeClient 的 onReachedMaxAppCacheSize 等方法管理
// 更彻底的方式:清理 WebView 使用的数据目录 (可能影响所有 WebView 实例)
// webView.clearFormData() // 清除表单数据
// webView.clearHistory() // 清除历史记录
// webView.clearSslPreferences() // 清除 SSL 配置
// 最彻底:删除 WebView 的缓存和数据文件 (谨慎使用!)
// try {
// val webViewCacheDir = File(applicationContext.cacheDir, "org.chromium.android_webview")
// if (webViewCacheDir.exists()) {
// deleteRecursive(webViewCacheDir)
// }
// val webViewDataDir = File(applicationContext.dataDir, "app_webview")
// if (webViewDataDir.exists()) {
// deleteRecursive(webViewDataDir)
// }
// } catch (e: Exception) {
// Log.e("WebViewCache", "Error clearing webview data", e)
// }
Log.d("WebViewCache", "WebView cache cleared.")
}
// 递归删除文件/目录的辅助函数
// fun deleteRecursive(fileOrDirectory: File) { ... }
// 设置 WebViewClient 以在 WebView 内加载 URL
webView.webViewClient = WebViewClient()
// 加载你的 H5 页面
webView.loadUrl("https://yourdomain.com/yourpage")
// 在 App 更新后首次启动时调用清理缓存
// val prefs = getSharedPreferences("MyAppPrefs", MODE_PRIVATE)
// val lastAppVersion = prefs.getInt("lastAppVersion", 0)
// val currentAppVersion = BuildConfig.VERSION_CODE
// if (currentAppVersion > lastAppVersion) {
// clearWebViewCache(webView)
// prefs.edit().putInt("lastAppVersion", currentAppVersion).apply()
// }
iOS (Swift / Objective-C):
swift
// Swift 示例
import UIKit
import WebKit
class ViewController: UIViewController, WKNavigationDelegate {
var webView: WKWebView!
override func loadView() {
let webConfiguration = WKWebViewConfiguration()
// 1. 配置数据存储 (影响缓存、Cookie、LocalStorage 等)
// 默认使用 WKWebsiteDataStore.default() - 持久化存储
// webConfiguration.websiteDataStore = WKWebsiteDataStore.default()
// 使用非持久化存储 (类似浏览器的隐私模式,关闭 WebView 后数据丢失)
// webConfiguration.websiteDataStore = WKWebsiteDataStore.nonPersistent()
webView = WKWebView(frame: .zero, configuration: webConfiguration)
webView.navigationDelegate = self
view = webView
}
override func viewDidLoad() {
super.viewDidLoad()
// 创建 URLRequest
guard let myURL = URL(string: "https://yourdomain.com/yourpage") else { return }
var request = URLRequest(url: myURL)
// 2. 配置请求的缓存策略 (影响本次加载)
// request.cachePolicy = .useProtocolCachePolicy // 默认,遵循 HTTP 缓存头
request.cachePolicy = .reloadIgnoringLocalCacheData // 忽略本地缓存,直接从源加载 (类似 Cmd/Ctrl+Shift+R)
// request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData // 忽略本地和代理缓存
// request.cachePolicy = .returnCacheDataElseLoad // 优先用缓存,无论是否过期,没有才加载
// request.cachePolicy = .returnCacheDataDontLoad // 只用缓存,绝不加载 (离线)
webView.load(request)
// 3. 手动清除缓存 (在适当的时候调用)
// clearWebViewCache()
}
func clearWebViewCache() {
// 定义要清除的数据类型
// let websiteDataTypes = WKWebsiteDataStore.allWebsiteDataTypes() // 清除所有类型
let websiteDataTypes: Set<String> = [
WKWebsiteDataTypeDiskCache, // 磁盘缓存 (资源文件)
WKWebsiteDataTypeMemoryCache, // 内存缓存 (可能效果不明显)
// WKWebsiteDataTypeCookies, // Cookies
// WKWebsiteDataTypeLocalStorage, // LocalStorage
// WKWebsiteDataTypeSessionStorage, // SessionStorage (通常随会话结束)
// WKWebsiteDataTypeWebSQLDatabases, // Web SQL Databases
// WKWebsiteDataTypeIndexedDBDatabases, // IndexedDB
// WKWebsiteDataTypeOfflineWebApplicationCache, // Application Cache
// ... 其他类型
]
// 定义要清除数据的时间点 (Date(timeIntervalSince1970: 0) 表示清除所有时间的)
let dateFrom = Date(timeIntervalSince1970: 0)
// 获取默认的 Data Store (如果用了非持久化的,需要获取对应的 store)
let dataStore = WKWebsiteDataStore.default()
// 执行清除操作
dataStore.removeData(ofTypes: websiteDataTypes, modifiedSince: dateFrom) {
print("WKWebView cache cleared for types: \(websiteDataTypes)")
// 清除完成后的操作,例如重新加载页面
// self.webView.reload()
// 如果需要强制从服务器重新加载 (忽略可能残余的内存缓存)
self.webView.reloadFromOrigin()
}
}
// 示例:App 更新后首次启动时清除缓存
func clearCacheOnAppUpdate() {
let defaults = UserDefaults.standard
let lastAppVersion = defaults.string(forKey: "lastAppVersion")
let currentAppVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
if lastAppVersion != currentAppVersion {
clearWebViewCache()
defaults.set(currentAppVersion, forKey: "lastAppVersion")
}
}
// MARK: - WKNavigationDelegate (可选)
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("Webview finished loading")
// 可以在这里执行 JS 来清理 localStorage 等
// webView.evaluateJavaScript("localStorage.clear();") { (result, error) in
// if let error = error { print("Error clearing localStorage: \(error)") }
// }
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("Webview failed navigation: \(error)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("Webview failed provisional navigation: \(error)")
}
}
优点:
- 提供了独立于 Web 服务器的缓存控制手段。
- 可以在 App 更新、用户主动操作等特定时机强制清理缓存。
缺点:
- 仅适用于 WebView 环境,对普通浏览器无效。
- 过度清除缓存会影响性能和用户体验(加载变慢)。
- 清除
localStorage
等需要额外处理(可能通过 JS 注入)。 - 不同平台(Android/iOS)API 不同,需要分别实现。
方案四:Service Worker
Service Worker (SW) 是一个运行在浏览器背景中的 JavaScript 脚本,它可以拦截和处理网络请求,实现复杂的缓存策略、离线支持、推送通知等。
原理: 你可以编写一个 SW,当浏览器请求资源时:
- 拦截请求: SW 的
fetch
事件处理器会捕获这个请求。 - 决策缓存策略: SW 可以决定是:
- Cache First: 优先从 SW 管理的缓存(Cache API)中查找资源,找不到再去网络请求,并将结果缓存起来。
- Network First: 优先尝试从网络获取资源,成功则返回并更新缓存。如果网络失败,则从缓存中返回旧版本。
- Stale-While-Revalidate: 同时从缓存和网络请求资源。如果缓存命中,立即返回缓存版本(速度快)。然后等待网络请求完成,如果网络返回了新版本,则更新缓存供下次使用。
- Cache Only: 只从缓存获取。
- Network Only: 只从网络获取(绕过缓存)。
- 管理缓存更新: 当部署新版本的 Web 应用时,新的 SW 文件会被下载。在
install
事件中,新的 SW 可以预缓存新版本的资源。在activate
事件中,它可以清理旧版本的缓存。浏览器会在适当的时候(通常是所有引用旧 SW 的页面关闭后)切换到新的 SW。
实现 (简化示例):
javascript
// service-worker.js (或 sw.js)
const CACHE_NAME = 'my-app-cache-v2'; // <-- 更改版本号以触发更新
const urlsToCache = [
'/', // 通常缓存 App Shell (主 HTML)
'/index.html',
// '/css/style.somehash.css', // 带哈希的资源通常不需要 SW 缓存,让浏览器 HTTP 缓存处理
// '/js/app.somehash.js',
'/images/logo.png',
'/offline.html' // 一个离线时展示的页面
];
// 安装 Service Worker 时,预缓存资源
self.addEventListener('install', event => {
console.log('SW: Install event');
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('SW: Opened cache', CACHE_NAME);
// 使用 addAll 原子性地添加资源,任何一个失败则整个操作失败
// 注意:如果 V1 版本的缓存还在,直接 addAll 新资源到 V2 缓存
return cache.addAll(urlsToCache);
})
.then(() => {
console.log('SW: Resources cached');
// 强制新 SW 安装后立即激活 (跳过等待)
// 谨慎使用:可能中断正在使用旧缓存的页面
return self.skipWaiting();
})
.catch(error => {
console.error('SW: Cache addAll failed:', error);
})
);
});
// 激活 Service Worker 时,清理旧缓存
self.addEventListener('activate', event => {
console.log('SW: Activate event');
const cacheWhitelist = [CACHE_NAME]; // 只保留当前版本的缓存
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
console.log('SW: Deleting old cache:', cacheName);
return caches.delete(cacheName); // 删除旧缓存
}
})
);
}).then(() => {
console.log('SW: Clients claimed');
// 让当前激活的 SW 控制所有打开的客户端页面
return self.clients.claim();
})
);
});
// 拦截网络请求
self.addEventListener('fetch', event => {
console.log('SW: Fetch event for ->', event.request.url);
// 示例:Stale-While-Revalidate 策略 (对非哈希资源或 HTML 较好)
// 对于带哈希的资源,可能直接 NetworkOnly 或让浏览器缓存处理更好
if (event.request.mode === 'navigate' || (event.request.url.includes('/api/') === false)) { // 只处理导航请求或非 API 请求
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
// 如果请求成功,克隆一份放入缓存
if (networkResponse.ok) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
}).catch(error => {
console.warn('SW: Network request failed, returning offline page possibly.', error);
// 如果网络失败,可以返回一个离线页面
return caches.match('/offline.html');
});
// 优先返回缓存 (Stale),同时发起网络请求 (revalidate)
return cachedResponse || fetchPromise;
});
})
);
} else {
// 对于 API 请求或其他不想缓存的,直接走网络
event.respondWith(fetch(event.request));
}
// // 示例:Cache First 策略
// event.respondWith(
// caches.match(event.request)
// .then(response => {
// // Cache hit - return response
// if (response) {
// console.log('SW: Serving from cache:', event.request.url);
// return response;
// }
//
// // Cache miss - go to network
// console.log('SW: Serving from network:', event.request.url);
// return fetch(event.request).then(
// networkResponse => {
// // Optional: Cache the new response
// if (networkResponse.ok && event.request.method === 'GET') { // 只缓存成功的 GET 请求
// const responseToCache = networkResponse.clone();
// caches.open(CACHE_NAME)
// .then(cache => {
// cache.put(event.request, responseToCache);
// });
// }
// return networkResponse;
// }
// ).catch(error => {
// console.error('SW: Fetch failed:', error);
// // Optional: Return an offline page or fallback response
// return caches.match('/offline.html');
// });
// })
// );
});
// 你可能还需要添加 'message', 'push', 'sync' 等事件监听器
注册 Service Worker (在你的主 JS 文件中):
javascript
// main.js or index.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js') // SW 文件相对于域名的路径
.then(registration => {
console.log('SW registered: ', registration);
// 可选:监听 SW 更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('SW update found, new worker installing:', newWorker);
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 新 SW 已安装,但旧的仍在控制页面
// 可以提示用户刷新页面以应用更新
console.log('New SW installed, waiting for activation. Refresh needed.');
// showUpdateUI(); // 显示一个更新提示给用户
} else {
// 这是首次注册 SW
console.log('SW installed for the first time.');
}
}
});
});
}).catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
// 可选:监听 controller change 事件,当新 SW 激活并开始控制页面时触发
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('SW controller changed, new worker activated. Reloading...');
// 自动刷新页面以使用新缓存 (如果合适)
// window.location.reload();
});
}
优点:
- 提供最精细的缓存控制和离线体验。
- 可以实现复杂的缓存策略。
- 独立于 HTTP 缓存头。
缺点:
- 实现复杂,需要仔细处理 SW 的生命周期、更新和错误。
- 兼容性问题:虽然主流浏览器和 WebView 支持良好,但仍需考虑旧版本。
- 调试困难:SW 在后台运行,状态管理和调试比普通 JS 复杂。
- HTTPS 要求:Service Worker 只能在 HTTPS 域(或 localhost)下注册和运行。
- 可能增加首次加载时间(因为需要先下载和安装 SW)。
方案五:HTML Meta 标签 (效果有限,不推荐作为主要手段)
可以在 HTML 的 <head>
中添加 <meta>
标签尝试阻止缓存:
html
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
原理: 理论上,这些标签指示浏览器不要缓存该 HTML 页面。
缺点:
- 不可靠: 很多现代浏览器和代理缓存会忽略这些 meta 标签,优先遵循 HTTP 头。它们主要对非常旧的浏览器或特定场景(如从本地文件系统打开 HTML)可能有效。
- 仅影响 HTML 文件本身: 不会影响外部引用的 JS, CSS, 图片等资源。
- 性能差: 如果真的生效,会导致 HTML 每次都重新下载。
结论:不应依赖此方法来解决缓存问题,优先使用 HTTP 头。
综合策略与建议
通常,最佳实践是组合使用多种策略:
-
首选:文件名哈希 + HTTP 强缓存头。
- 使用构建工具(Webpack/Vite)为所有 JS, CSS, 图片等静态资源生成带内容哈希的文件名 (
[name].[contenthash].ext
)。 - 配置 Web 服务器(Nginx/Apache/Node)为这些带哈希的资源设置长期缓存的 HTTP 头 (
Cache-Control: public, max-age=31536000, immutable
)。 - 配置 Web 服务器为 HTML 入口文件 (
index.html
) 设置禁止缓存或需要验证的 HTTP 头 (Cache-Control: no-cache
或max-age=0, must-revalidate
)。
- 使用构建工具(Webpack/Vite)为所有 JS, CSS, 图片等静态资源生成带内容哈希的文件名 (
-
WebView 环境下的补充:
- 如果发现 WebView 仍然存在顽固的缓存问题(有时 WebView 实现可能不完全遵循 HTTP 头),可以在原生 App 代码中,在适当的时机(如 App 版本更新后首次加载 H5)调用手动清除 WebView 缓存 的 API(Android 的
webView.clearCache(true)
,iOS 的WKWebsiteDataStore.default().removeData(...)
)。 - 避免过度使用
WebSettings.LOAD_NO_CACHE
或URLRequest.CachePolicy.reloadIgnoringLocalCacheData
,因为它们会严重影响性能。仅在调试或特定必要场景下使用。
- 如果发现 WebView 仍然存在顽固的缓存问题(有时 WebView 实现可能不完全遵循 HTTP 头),可以在原生 App 代码中,在适当的时机(如 App 版本更新后首次加载 H5)调用手动清除 WebView 缓存 的 API(Android 的
-
高级选项:Service Worker
- 如果需要实现复杂的离线支持、更激进的缓存更新策略(如 Stale-While-Revalidate)或对缓存有极致的控制需求,可以引入 Service Worker。但要认识到其复杂性。Service Worker 可以与文件名哈希和 HTTP 头策略共存(例如,SW 可以优先服务 App Shell 和关键资源,对于带哈希的资源则让浏览器 HTTP 缓存处理)。
-
避免 Query String Cache Busting:
- 虽然
app.js?v=1.2
或app.js?t=timestamp
看起来简单,但很多代理和 CDN 可能默认配置为忽略查询参数进行缓存,导致这种方法失效。文件名哈希更可靠。如果非要用,确保你的 CDN 配置能识别查询参数作为缓存键的一部分。
- 虽然
-
测试!
- 在不同环境(浏览器、Android WebView、iOS WKWebView)下彻底测试你的缓存策略。
- 使用浏览器开发者工具的 "Network" 标签页检查资源的 HTTP 状态码(
200 OK
表示从网络下载,304 Not Modified
表示验证后使用缓存,(from disk cache)
或(from memory cache)
表示直接使用缓存)和Cache-Control
等响应头。 - 模拟 App 更新流程,检查 WebView 是否能正确加载新资源。
总结
解决前端缓存问题没有银弹,但通过结合使用文件名哈希和正确的 HTTP 缓存头配置 ,可以解决绝大多数场景下的问题,这也是目前业界标准的最佳实践。对于 WebView 环境,原生代码提供的缓存清理 API 是一个有用的补充工具。Service Worker 则为需要更高级功能的场景提供了强大的(但复杂的)解决方案。