feat: 添加 Service Worker 缓存与更新流程
- 为同源资源添加网络优先策略,并在离线时回退到缓存 - 缓存带版本号的 CDN 资源,并在发布更新时保留独立缓存条目 - 改进版本更新检查,避免清除 localStorage 或无关缓存 - 将 Service Worker 纳入部署流程并更新相关文档
This commit is contained in:
@@ -85,8 +85,8 @@ Linux 服务器可使用 `deploy.sh` 一键下载并部署 `main` 分支的最
|
||||
bash <(curl -fsSL https://gitea.tohub.top/Share/AI_English/raw/branch/main/deploy.sh) deploy
|
||||
```
|
||||
|
||||
脚本会将 `src/index.html`、`src/js/` 和 `src/styles/` 部署到 `/root/data/docker_data/Nginx/html/english/ai/`,清理目标目录中的其他内容,但保留已有的 `data/` 目录。运行前请确认目标路径符合服务器配置;脚本需要 root 权限以及 `curl`、`tar`、`find` 命令。
|
||||
脚本会将 `src/index.html`、`src/sw.js`、`src/js/` 和 `src/styles/` 部署到 `/root/data/docker_data/Nginx/html/english/ai/`,清理目标目录中的其他内容,但保留已有的 `data/` 目录。运行前请确认目标路径符合服务器配置;脚本需要 root 权限以及 `curl`、`tar`、`find` 命令。
|
||||
|
||||
`src/index.html` 通过 CSS 和 JavaScript URL 的 `v` 查询参数控制静态资源缓存。每次发布修改了 `src/styles.css` 或 `src/script.js`,请同步递增对应版本号。服务器应优先为 `index.html` 设置 `Cache-Control: no-cache`;带版本号的静态资源可长期缓存。
|
||||
缓存策略分两层:`src/index.html` 通过 CSS 和入口 JavaScript URL 的 `v` 查询参数(`ASSET_VERSION`)控制静态资源缓存,每次发布请同步递增该版本号;`src/sw.js`(Service Worker)对同源请求采用「网络优先、离线回退缓存」——未带版本号的请求以 `cache: 'no-cache'` 强制向服务器做条件校验,绕过浏览器启发式缓存(iOS Safari 卡旧版本的根因),带 `?v=` 的资源则走默认 HTTP 缓存;对 CDN 上带版本号的静态库(Font Awesome、Chart.js)采用「缓存优先」,首次取回后离线也能显示图标与图表,其余跨域请求(AI / TTS 接口)不拦截。服务器应优先为 `index.html` 与 `sw.js` 设置 `Cache-Control: no-cache`;带版本号的静态资源可长期缓存。
|
||||
|
||||
设置页的“获取最新版本”仅清理应用资源缓存,不应改为清除 `localStorage`,以免丢失学习数据和 AI 配置。
|
||||
设置页的“获取最新版本”会检查并更新 Service Worker,再通过带时间戳的 URL 重新加载;重新取得的资源会更新对应 URL 的缓存,带版本号的资源会保留独立条目以避免并发页面互相覆盖,且不会清除 `localStorage`,以免丢失学习数据和 AI 配置。
|
||||
@@ -85,18 +85,18 @@ deploy() (
|
||||
fi
|
||||
|
||||
source_dir=$(find "$temp_dir" -mindepth 2 -maxdepth 2 -type d -name src -print -quit)
|
||||
if [[ -z "$source_dir" || ! -f "$source_dir/index.html" || ! -d "$source_dir/js" || ! -d "$source_dir/styles" ]]; then
|
||||
print_error "仓库中缺少 src/index.html、src/js 或 src/styles,已取消部署"
|
||||
if [[ -z "$source_dir" || ! -f "$source_dir/index.html" || ! -f "$source_dir/sw.js" || ! -d "$source_dir/js" || ! -d "$source_dir/styles" ]]; then
|
||||
print_error "仓库中缺少 src/index.html、src/sw.js、src/js 或 src/styles,已取消部署"
|
||||
return 1
|
||||
fi
|
||||
|
||||
clear_target
|
||||
|
||||
print_info "正在拷贝网站文件到 $TARGET_DIR ..."
|
||||
cp -a "$source_dir/index.html" "$source_dir/js" "$source_dir/styles" "$TARGET_DIR/"
|
||||
cp -a "$source_dir/index.html" "$source_dir/sw.js" "$source_dir/js" "$source_dir/styles" "$TARGET_DIR/"
|
||||
|
||||
print_success "部署完成"
|
||||
print_info "已部署: index.html、js/、styles/"
|
||||
print_info "已部署: index.html、sw.js、js/、styles/"
|
||||
print_info "已保留: $TARGET_DIR/$PRESERVED_NAME/"
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -46,4 +46,4 @@
|
||||
- [ ] AI、Azure TTS 配置保存与连接测试正常;Azure 失败后语音回退正常。
|
||||
- [ ] 内置词库切换、自动加载、手动加载、过滤词增删/搜索/复制正常。
|
||||
- [ ] 清除收藏、清空数据的双重确认及保留收藏单词行为与原版一致。
|
||||
- [ ] “获取最新版本”只清应用缓存并刷新,不清 localStorage。
|
||||
- [ ] “获取最新版本”会检查更新并刷新,更新当前版本的应用缓存且不清 localStorage。
|
||||
+25
-1
@@ -15,7 +15,7 @@
|
||||
integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<script>
|
||||
const ASSET_VERSION = '20260719-4';
|
||||
const ASSET_VERSION = '20260720-1';
|
||||
window.APP_VERSION = ASSET_VERSION;
|
||||
const stylesheets = [
|
||||
'styles/variables.css',
|
||||
@@ -105,5 +105,29 @@
|
||||
<script>
|
||||
document.write(`<script type="module" src="js/main.js?v=${ASSET_VERSION}"><\/script>`);
|
||||
</script>
|
||||
<script>
|
||||
// 注册 Service Worker:由它统一保证模块图不陈旧并提供离线回退。
|
||||
// 注册失败(如旧版 iOS 不支持)不影响使用,上方 ?v= 版本号仍作兜底。
|
||||
if ('serviceWorker' in navigator) {
|
||||
const needsFirstControlledReload = !navigator.serviceWorker.controller;
|
||||
let reloadingForServiceWorker = false;
|
||||
|
||||
if (needsFirstControlledReload) {
|
||||
// 首次取得页面控制权后刷新一次,让首轮已加载的子资源也经过 SW 并写入离线缓存。
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (reloadingForServiceWorker) return;
|
||||
reloadingForServiceWorker = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
// updateViaCache: 'none' 让 sw.js 本身始终走网络校验,避免被 HTTP 缓存拖住策略更新。
|
||||
navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }).catch(error => {
|
||||
console.warn('Service Worker 注册失败,应用仍可正常使用:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -242,19 +242,41 @@ export async function forceRefreshBrowserCache() {
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在获取最新版本...';
|
||||
}
|
||||
|
||||
// 使用由 Service Worker 明确放行的 GET 探测服务器,兼容不支持 HEAD 的服务器,
|
||||
// 同时避免离线缓存回退把断网误判为在线。
|
||||
try {
|
||||
// 当前无 Service Worker,Cache Storage 通常为空;此处为将来引入 SW 时的前瞻兼容。
|
||||
// 真正的缓存绕过依赖下方的时间戳 URL + 静态资源 ?v= 版本号。
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)));
|
||||
}
|
||||
const probeUrl = new URL('index.html', window.location.href);
|
||||
probeUrl.searchParams.set('_network_probe', Date.now().toString());
|
||||
const response = await fetch(probeUrl, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`服务器返回 HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.warn('清理应用缓存失败,将继续重新加载:', error);
|
||||
console.warn('检查应用更新失败:', error);
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = '<i class="fas fa-sync-alt"></i> 获取最新版本';
|
||||
}
|
||||
alert('当前无法连接应用服务器,无法获取最新版本,请稍后再试。');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('_app_update', Date.now().toString());
|
||||
|
||||
// iOS Safari 上 Service Worker 更新偶尔会一直处于 pending 状态,
|
||||
// 用 1.5 秒超时兜底,不让更新检查阻塞最终的重新加载。
|
||||
const withTimeout = (promise) => Promise.race([
|
||||
Promise.resolve(promise).catch(error => console.warn('刷新前更新出错,已继续重新加载:', error)),
|
||||
new Promise(resolve => setTimeout(resolve, 1500))
|
||||
]);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
// 不删除整库:重新加载会更新当前 URL 的缓存条目,避免超时后的后台删除误删新缓存。
|
||||
await withTimeout(
|
||||
navigator.serviceWorker.getRegistration()
|
||||
.then(registration => registration?.update())
|
||||
);
|
||||
}
|
||||
|
||||
window.location.replace(url.toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// AI 英语助教 Service Worker
|
||||
// 策略:
|
||||
// - 同源 GET 一律「网络优先」,成功则顺带写入缓存;网络失败时回退到缓存(离线可用)。
|
||||
// - CDN 静态库(Font Awesome / Chart.js,URL 带版本号、内容不可变)「缓存优先」,
|
||||
// 首次取回后离线也能显示图标与图表;其余跨域请求(AI / TTS 等接口)一概不拦截。
|
||||
// 目的:彻底根治「浏览器/iOS Safari 卡在旧版本」的问题 —— 只要在线,每次加载拿到的都是最新文件,
|
||||
// 整个 ES 模块图(含 main.js 未加 ?v= 的子模块)都被覆盖,无需逐文件维护版本号。
|
||||
|
||||
// 运行时缓存使用稳定名称、不随发版变化:网络优先策略本身会逐路径覆盖旧内容,
|
||||
// 无需在 SW 更新时清空缓存。若激活时整库删除,而新缓存只预缓存了 HTML 入口,
|
||||
// 已被控制的页面不会重新请求本轮已加载的 JS/CSS,此时离线刷新会因资源缺失而白屏。
|
||||
// Cache Storage 按源(origin)共享;`ai-english-` 前缀用于把本应用的缓存与同源
|
||||
// 其他应用区分开。
|
||||
const CACHE_NAME = 'ai-english-runtime';
|
||||
const MAX_VERSIONED_ENTRIES_PER_PATH = 2;
|
||||
|
||||
// 仅接管这些 CDN 域名(页面以 crossorigin="anonymous" 引入的带版本号静态库)。
|
||||
const CDN_ORIGINS = ['https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// 轻量预缓存文档入口(目录与 index.html 两种访问形式),缩短「首次访问后
|
||||
// 离线不可用」的窗口;子资源仍靠运行时缓存补齐。失败不阻塞安装。
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await Promise.all(['./', 'index.html'].map(path =>
|
||||
cache.add(new Request(path, { cache: 'no-cache' })).catch(() => {})
|
||||
));
|
||||
// 新 SW 安装后立即接管,配合「获取最新版本」按钮实现一次刷新即生效。
|
||||
await self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// 一次性迁移:把历史版本命名缓存(ai-english-v1/v2/v3)的条目并入稳定运行时
|
||||
// 缓存后再删除,避免迁移瞬间丢弃已缓存的 JS/CSS/词库导致离线刷新白屏。
|
||||
// 只动本应用前缀的缓存,勿动同源其他应用创建的缓存。迁移失败不阻塞接管页面。
|
||||
await migrateLegacyCaches().catch(() => {});
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
async function migrateLegacyCaches() {
|
||||
const names = await caches.keys();
|
||||
const legacyNames = names.filter(name => name.startsWith('ai-english-') && name !== CACHE_NAME);
|
||||
if (legacyNames.length === 0) return;
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
for (const name of legacyNames) {
|
||||
const legacy = await caches.open(name);
|
||||
const requests = await legacy.keys();
|
||||
await Promise.all(requests.map(async (request) => {
|
||||
// 运行时缓存里已有的条目更新,不用旧内容覆盖。
|
||||
if (await cache.match(request)) return;
|
||||
const response = await legacy.match(request);
|
||||
if (response) await cache.put(request, response);
|
||||
}));
|
||||
await caches.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin === self.location.origin && url.searchParams.has('_network_probe')) return;
|
||||
|
||||
if (url.origin === self.location.origin) {
|
||||
event.respondWith(handleSameOrigin(event, request, url));
|
||||
} else if (CDN_ORIGINS.includes(url.origin)) {
|
||||
event.respondWith(handleCdn(event, request));
|
||||
}
|
||||
// 其余跨域请求(AI / TTS 等接口)不接管
|
||||
});
|
||||
|
||||
async function handleSameOrigin(event, request, url) {
|
||||
try {
|
||||
// 带 ?v= 的资源随版本号变更 URL、内容不可变,走默认 HTTP 缓存即可;
|
||||
// 其余请求(HTML、未带版本号的子模块)用 'no-cache' 强制带 ETag/Last-Modified
|
||||
// 向服务器做条件校验,绕过启发式 HTTP 缓存(iOS Safari 卡旧版本的根因),
|
||||
// 文件未变化时服务器仅返回 304,代价只是一次轻量往返。
|
||||
const response = await fetch(
|
||||
request,
|
||||
url.searchParams.has('v') ? undefined : { cache: 'no-cache' }
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
|
||||
// 只缓存成功的基础响应(避免缓存重定向/错误页/opaque 响应)。
|
||||
if (response.type === 'basic') {
|
||||
const copy = response.clone();
|
||||
// 强制刷新导航(?_app_update=时间戳)归一化为无参 URL 再入缓存:
|
||||
// 不缓存一次性 URL,同时保证刷新后离线副本仍包含稳定的 HTML 入口。
|
||||
let key = request;
|
||||
if (url.searchParams.has('_app_update')) {
|
||||
const cleanUrl = new URL(url);
|
||||
cleanUrl.searchParams.delete('_app_update');
|
||||
key = new Request(cleanUrl.toString());
|
||||
}
|
||||
// 保留带版本号资源的完整 URL,避免不同页面并发请求时旧版本覆盖新版本。
|
||||
// 相同 URL 的 cache.put 是原子替换,无需先删除;离线时优先精确匹配当前页面引用的版本。
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.put(key, copy);
|
||||
if (url.searchParams.has('v')) {
|
||||
await trimVersionedEntries(cache, url);
|
||||
}
|
||||
})().catch(() => { /* 写缓存失败(如配额不足)不影响本次正常响应 */ }));
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const cached = await matchSameOriginCache(request);
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function trimVersionedEntries(cache, currentUrl) {
|
||||
const requests = await cache.keys();
|
||||
const samePathVersions = requests.filter((request) => {
|
||||
const cachedUrl = new URL(request.url);
|
||||
return cachedUrl.origin === currentUrl.origin
|
||||
&& cachedUrl.pathname === currentUrl.pathname
|
||||
&& cachedUrl.searchParams.has('v');
|
||||
});
|
||||
|
||||
const stale = samePathVersions
|
||||
.filter(request => request.url !== currentUrl.toString())
|
||||
.slice(0, Math.max(0, samePathVersions.length - MAX_VERSIONED_ENTRIES_PER_PATH));
|
||||
await Promise.all(stale.map(request => cache.delete(request)));
|
||||
}
|
||||
|
||||
async function matchSameOriginCache(request) {
|
||||
// 资源请求只允许精确匹配,避免缺少当前 ?v= 条目时混用旧版本 JS/CSS。
|
||||
const exact = await caches.match(request);
|
||||
if (exact || request.mode !== 'navigate') return exact;
|
||||
|
||||
// 仅导航请求可忽略查询串,使 ?_app_update=X 等一次性页面 URL 命中稳定离线入口。
|
||||
return caches.match(request, { ignoreSearch: true });
|
||||
}
|
||||
|
||||
async function handleCdn(event, request) {
|
||||
// CDN URL 自带版本号、内容不可变,缓存优先即可,无需回源校验。
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
// 只缓存成功的 CORS 响应(crossorigin="anonymous" 下正常即为 'cors',
|
||||
// 含 Font Awesome CSS 内部引用的 webfonts);opaque 响应无法校验状态码,不入缓存。
|
||||
if (response && response.ok && response.type === 'cors') {
|
||||
const copy = response.clone();
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.put(request, copy);
|
||||
})().catch(() => { /* 写缓存失败不影响本次正常响应 */ }));
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
// 网络请求期间可能有另一个并发请求完成写缓存,此时仍可离线回退。
|
||||
const fallback = await caches.match(request);
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user