feat: 添加 Service Worker 缓存与更新流程

- 为同源资源添加网络优先策略,并在离线时回退到缓存
- 缓存带版本号的 CDN 资源,并在发布更新时保留独立缓存条目
- 改进版本更新检查,避免清除 localStorage 或无关缓存
- 将 Service Worker 纳入部署流程并更新相关文档
This commit is contained in:
eddy
2026-07-20 02:22:15 +08:00
parent a878899512
commit 738275b493
6 changed files with 226 additions and 16 deletions
+3 -3
View File
@@ -85,8 +85,8 @@ Linux 服务器可使用 `deploy.sh` 一键下载并部署 `main` 分支的最
bash <(curl -fsSL https://gitea.tohub.top/Share/AI_English/raw/branch/main/deploy.sh) deploy 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 配置。
+4 -4
View File
@@ -85,18 +85,18 @@ deploy() (
fi fi
source_dir=$(find "$temp_dir" -mindepth 2 -maxdepth 2 -type d -name src -print -quit) 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 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/js 或 src/styles,已取消部署" print_error "仓库中缺少 src/index.html、src/sw.js、src/js 或 src/styles,已取消部署"
return 1 return 1
fi fi
clear_target clear_target
print_info "正在拷贝网站文件到 $TARGET_DIR ..." 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_success "部署完成"
print_info "已部署: index.html、js/、styles/" print_info "已部署: index.html、sw.js、js/、styles/"
print_info "已保留: $TARGET_DIR/$PRESERVED_NAME/" print_info "已保留: $TARGET_DIR/$PRESERVED_NAME/"
) )
+1 -1
View File
@@ -46,4 +46,4 @@
- [ ] AI、Azure TTS 配置保存与连接测试正常;Azure 失败后语音回退正常。 - [ ] AI、Azure TTS 配置保存与连接测试正常;Azure 失败后语音回退正常。
- [ ] 内置词库切换、自动加载、手动加载、过滤词增删/搜索/复制正常。 - [ ] 内置词库切换、自动加载、手动加载、过滤词增删/搜索/复制正常。
- [ ] 清除收藏、清空数据的双重确认及保留收藏单词行为与原版一致。 - [ ] 清除收藏、清空数据的双重确认及保留收藏单词行为与原版一致。
- [ ] “获取最新版本”只清应用缓存并刷新,不清 localStorage。 - [ ] “获取最新版本”会检查更新并刷新,更新当前版本的应用缓存且不清 localStorage。
+25 -1
View File
@@ -15,7 +15,7 @@
integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g"
crossorigin="anonymous" referrerpolicy="no-referrer"> crossorigin="anonymous" referrerpolicy="no-referrer">
<script> <script>
const ASSET_VERSION = '20260719-4'; const ASSET_VERSION = '20260720-1';
window.APP_VERSION = ASSET_VERSION; window.APP_VERSION = ASSET_VERSION;
const stylesheets = [ const stylesheets = [
'styles/variables.css', 'styles/variables.css',
@@ -105,5 +105,29 @@
<script> <script>
document.write(`<script type="module" src="js/main.js?v=${ASSET_VERSION}"><\/script>`); document.write(`<script type="module" src="js/main.js?v=${ASSET_VERSION}"><\/script>`);
</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> </body>
</html> </html>
+29 -7
View File
@@ -242,19 +242,41 @@ export async function forceRefreshBrowserCache() {
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在获取最新版本...'; button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在获取最新版本...';
} }
// 使用由 Service Worker 明确放行的 GET 探测服务器,兼容不支持 HEAD 的服务器,
// 同时避免离线缓存回退把断网误判为在线。
try { try {
// 当前无 Service WorkerCache Storage 通常为空;此处为将来引入 SW 时的前瞻兼容。 const probeUrl = new URL('index.html', window.location.href);
// 真正的缓存绕过依赖下方的时间戳 URL + 静态资源 ?v= 版本号。 probeUrl.searchParams.set('_network_probe', Date.now().toString());
if ('caches' in window) { const response = await fetch(probeUrl, { cache: 'no-store' });
const cacheNames = await caches.keys(); if (!response.ok) throw new Error(`服务器返回 HTTP ${response.status}`);
await Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)));
}
} catch (error) { } 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); const url = new URL(window.location.href);
url.searchParams.set('_app_update', Date.now().toString()); 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()); window.location.replace(url.toString());
} }
+164
View File
@@ -0,0 +1,164 @@
// AI 英语助教 Service Worker
// 策略:
// - 同源 GET 一律「网络优先」,成功则顺带写入缓存;网络失败时回退到缓存(离线可用)。
// - CDN 静态库(Font Awesome / Chart.jsURL 带版本号、内容不可变)「缓存优先」,
// 首次取回后离线也能显示图标与图表;其余跨域请求(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;
}
}