Merge pull request #1520 from CyC2018/site-perf

Update website bundling and offline cache
This commit is contained in:
CyC2018
2026-11-01 13:06:01 +08:00
committed by GitHub
5 changed files with 130 additions and 4 deletions
+31 -4
View File
@@ -189,7 +189,7 @@ def build(optimize=True):
dist = Path(temporary) / 'dist'
dist.mkdir()
shutil.copytree(SITE / 'vendor', dist / 'vendor')
for name in ('index.html', 'site.css', 'site.js', 'search.js'):
for name in ('index.html', 'site.css', 'site.js', 'search.js', 'edgeone.json'):
shutil.copyfile(SITE / name, dist / name)
for path in sources():
target = dist / path.relative_to(ROOT)
@@ -282,10 +282,11 @@ def build(optimize=True):
'searchIndex': index_name, 'legacyRoutes': len(legacy)}
(dist / 'build-info.json').write_text(json.dumps(build_info, indent=2) + '\n')
entry = (dist / 'index.html').read_text()
for name in ('site.css', 'site.js', 'search.js', 'search-paths.js'):
digest = hashlib.sha256((dist / name).read_bytes()).hexdigest()[:12]
entry = entry.replace('/' + name + '"', '/' + name + '?v=' + digest + '"')
entry = bundle_entry(dist, entry)
(dist / 'index.html').write_text(entry)
shell = ['/', '/_sidebar.md', '/README.md'] + re.findall(r'(?:src|href)="(/(?:app|vendor|style)-[0-9a-f]{16}\.(?:js|css))"', entry)
worker = (SITE / 'sw.js').read_text().replace('__SHELL__', json.dumps(shell))
(dist / 'sw.js').write_text(worker.replace('__BUILD__', hashlib.sha256(json.dumps(shell).encode()).hexdigest()[:12]))
write_manifest(dist, build_info)
files = [p for p in dist.rglob('*') if p.is_file()]
report = {'notes': len(note_paths), 'files': len(files), 'bytes': sum(p.stat().st_size for p in files),
@@ -310,6 +311,32 @@ def build(optimize=True):
print(json.dumps({k: v for k, v in report.items() if k not in ('imageReplacements', 'externalImageUrls')}, ensure_ascii=False, indent=2))
SCRIPT_TAG = re.compile(r'^ <script src="/([^"]+)" defer></script>\n', re.M)
STYLE_TAG = re.compile(r'^ <link rel="stylesheet" href="/([^"]+)">\n', re.M)
def bundle_entry(dist, entry):
"""入口页的脚本按原顺序合成本站脚本和依赖库两个文件,样式合成一个;文件名带内容哈希。"""
def write(names, prefix, suffix, separator):
parts = [re.sub(r'^//# sourceMappingURL=.*$', '', (dist / name).read_text(), flags=re.M).rstrip() for name in names]
data = separator.join(parts) + '\n'
name = f'{prefix}-{hashlib.sha256(data.encode()).hexdigest()[:16]}{suffix}'
(dist / name).write_text(data)
return name
scripts, styles = SCRIPT_TAG.findall(entry), STYLE_TAG.findall(entry)
own = [name for name in scripts if not name.startswith('vendor/')]
vendor = [name for name in scripts if name.startswith('vendor/')]
# 本站脚本要先执行(site.js 定义 $docsify 配置,依赖库中的插件往里面添加)
if scripts != own + vendor:
raise ValueError('入口页的脚本顺序应为本站脚本在前、依赖库在后')
tags = ''.join(f' <script src="/{write(group, prefix, ".js", chr(10) + ";" + chr(10))}" defer></script>\n'
for group, prefix in ((own, 'app'), (vendor, 'vendor')) if group)
style = f' <link rel="stylesheet" href="/{write(styles, "style", ".css", chr(10))}">\n'
entry = SCRIPT_TAG.sub('', entry)
entry = STYLE_TAG.sub(lambda match: style if match[1] == styles[0] else '', entry)
return entry.replace('</body>', tags + '</body>', 1)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='仅在本机从最新上游源码生成静态站,不下载图片、不发布。')
parser.add_argument('--source', type=Path, default=ROOT)
+5
View File
@@ -0,0 +1,5 @@
{
"headers": [
{ "source": "/*.md", "headers": [{ "key": "Content-Type", "value": "text/plain; charset=utf-8" }] }
]
}
+3
View File
@@ -114,3 +114,6 @@ body { color: #34495e; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI
/* 首页贡献者头像:链接按图片大小计算点击区域 */
.markdown-section > a:has(> img) { display: inline-block; margin: 2px; }
.markdown-section > a > img[src*="avatars"] { aspect-ratio: 1; }
/* 离线提示 */
.markdown-section .offline-note { margin: 0 0 18px; padding: 8px 12px; border-radius: 6px; background: #eaf3ee; color: #34495e; font-size: 14px; }
+22
View File
@@ -30,6 +30,27 @@
root.querySelectorAll('.docsify-copy-code-button [aria-hidden="hidden"]').forEach(function (node) { node.setAttribute('aria-hidden', 'true'); });
});
}
// 离线时提示显示的是缓存的版本;sw.js 缓存读过的文章,离线时也能打开
function offlineNote(section) {
if (!section || section.querySelector('.offline-note')) return;
// sw.js 给没缓存过的文章返回的说明页:Docsify 会把它记在内存里,联网后要刷新页面才能重新下载
if (/^当前离线/.test((section.querySelector('h1') || {}).textContent || '')) {
var retry = document.createElement('p');
retry.innerHTML = '<a href="javascript:location.reload()">重新加载</a>';
section.appendChild(retry);
window.addEventListener('online', function () { location.reload(); }, { once: true });
return;
}
if (navigator.onLine !== false) return;
var note = document.createElement('p');
note.className = 'offline-note';
note.setAttribute('role', 'status');
note.textContent = '当前离线,显示的是这台设备上次缓存的内容。';
section.insertBefore(note, section.firstChild);
}
if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === '127.0.0.1' || location.hostname === 'localhost')) {
window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js').catch(function () {}); });
}
function preserveLegacyLinks() {
var hash = location.hash;
var legacy = (window.CS_NOTES_LEGACY_ROUTES || {})[decode(location.pathname)];
@@ -337,6 +358,7 @@
return document.body.innerHTML + '<footer class="site-footer"><a href="https://github.com/CyC2018/CS-Notes" target="_blank" rel="noopener">CyC2018 · CS-Notes</a><span> · </span><a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">粤ICP备19071404号-1</a></footer>';
});
hook.doneEach(function () {
offlineNote(document.querySelector('.markdown-section'));
var heading = document.querySelector('.markdown-section h1, .markdown-section h2');
document.title = (vm.route.path === '/' || !heading ? 'CS-Notes' : heading.textContent.trim() + ' · CS-Notes') + ' · CyC2018';
configureImages(document.querySelector('.markdown-section'), vm.route.path === '/');
+69
View File
@@ -0,0 +1,69 @@
// 离线与弱网:安装时缓存入口页和本次构建的脚本、样式;读过的文章、侧边栏和配图在访问时缓存。
// 文章和入口页先走网络,3 秒内没有返回或断网时用缓存;带内容哈希的文件直接用缓存。学习站(/learn/)有自己的缓存,这里不处理。
// 下面的构建号和文件列表由 build.py 写入。
var BUILD = '__BUILD__';
var SHELL = __SHELL__;
// 带哈希的脚本、样式不按构建号分开存:缓存的旧入口页(弱网时先显示)引用的旧文件还要能读到,超过上限时删最早的
var STATIC = 'cs-notes-static', PAGES = 'cs-notes-pages', IMAGES = 'cs-notes-images';
var LIMITS = {};
LIMITS[STATIC] = 40;
LIMITS[PAGES] = 120;
LIMITS[IMAGES] = 300;
self.addEventListener('install', function (event) {
// 构建号 BUILD 写在这个文件里,构建变化时浏览器会重新安装
event.waitUntil(caches.open(STATIC).then(function (cache) { return cache.addAll(SHELL); }).then(function () { return trim(STATIC); }).then(function () { return self.skipWaiting(); }));
});
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
});
// 缓存条目超过上限时删掉最早放入的
function trim(name) {
return caches.open(name).then(function (cache) {
return cache.keys().then(function (keys) {
return Promise.all(keys.slice(0, Math.max(0, keys.length - LIMITS[name])).map(function (key) { return cache.delete(key); }));
});
});
}
function put(name, request, response) {
if (!response || !response.ok || response.type !== 'basic') return;
var copy = response.clone();
caches.open(name).then(function (cache) { return cache.put(request, copy); }).then(function () { if (LIMITS[name]) return trim(name); });
}
function offlinePage(path) {
var title = decodeURIComponent(path.split('/').pop().replace(/\.md$/, ''));
return new Response('# 当前离线\n\n《' + title + '》还没有在这台设备上打开过,联网后再试。已经读过的文章离线时也能打开。\n',
{ status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-CS-Notes-Offline': '1' } });
}
// 先走网络,等待超过 wait 毫秒或失败时用缓存(所有缓存中找);都没有时由 fallback 给出内容
function networkFirst(request, name, wait, fallback) {
return caches.match(request, { ignoreSearch: true }).then(function (cached) {
var network = fetch(request).then(function (response) { put(name, request, response); return response; });
var settled = network.catch(function () { return null; });
var timer = new Promise(function (resolve) { setTimeout(resolve, wait); });
return Promise.race(cached ? [settled, timer] : [settled]).then(function (first) {
if (first) return first;
if (cached) return cached;
return fallback ? fallback() : Response.error();
});
});
}
function cacheFirst(request, name) {
return caches.match(request).then(function (hit) {
return hit || fetch(request).then(function (response) { put(name, request, response); return response; });
});
}
self.addEventListener('fetch', function (event) {
var request = event.request, url = new URL(request.url);
if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.indexOf('/learn/') === 0 || url.searchParams.has('release')) return;
var path = url.pathname;
if (request.mode === 'navigate') {
// 网站只有首页一个入口,其它地址(旧文章的 .html 链接等)照常走网络
if (path === '/' || path === '/index.html') event.respondWith(networkFirst(new Request('/'), STATIC, 3000, null));
} else if (/^\/(app|vendor|style)-[0-9a-f]{16}\.(js|css)$|^\/search-index-[0-9a-f]+\.json$|^\/optimized\//.test(path)) {
event.respondWith(cacheFirst(request, /^\/optimized\//.test(path) ? IMAGES : STATIC));
} else if (/\.md$/.test(path)) {
event.respondWith(networkFirst(request, PAGES, 3000, path === '/_sidebar.md' ? null : function () { return offlinePage(path); }));
} else if (/\.(png|jpe?g|gif|svg|webp)$/.test(path)) {
event.respondWith(networkFirst(request, IMAGES, 3000, null));
}
});