静态博客性能优化实战:从 59MB 到精简高效
为什么需要性能优化
很多人觉得静态网站(如 GitHub Pages)天然就快,不需要优化。但实际情况是,随着内容增长,没有维护的静态站会积累大量问题:死文件占空间、内联样式膨胀、缓存策略缺失、404 错误拖慢加载……
我的博客运行在 GitHub Pages 上,经过半年的内容积累,仓库膨胀到 59MB,66 篇文章每篇都带着 4KB 重复的内联样式,文章页引用着不存在的 JS 文件,社交分享图指向 404 图片。这些问题单个看起来不大,但叠加起来严重影响用户体验和 SEO。
本文记录了一次完整的性能优化过程,涵盖文件清理、CSS/JS 优化、缓存策略、结构化数据等方面。
第一步:全面体检
文件体积分析
优化的第一步是搞清楚现状。用 du 命令快速扫描:
# 查看各目录体积
du -sh assets/ blog/ templates/
# 查看 JS 文件大小
du -h assets/js/*.js | sort -rh
# 查看图片大小
find assets/images/ -type f -exec du -h {} \; | sort -rh
扫描结果发现了几个明显的问题:
- 仓库总体积 59MB,其中图片占了大部分
- 有 4.4MB 的图片没有被任何页面引用
- 有 20KB 的 JS 文件(ai-assistant.js、toc.js)没有被任何页面加载
- 66 篇文章每篇都有约 4KB 的内联
<style>块
死文件检测
检测死文件的核心思路是:遍历所有 HTML 文件中的引用,找出没有被引用的资源文件。
# 检查 JS 文件是否被引用
for js in assets/js/*.js; do
name=$(basename "$js")
count=$(grep -rl "$name" --include="*.html" . | wc -l)
if [ "$count" -eq 0 ]; then
echo "❌ 死文件: $js"
fi
done
这个脚本可以快速找出没有被任何 HTML 文件引用的 JS 文件。同样的方法也适用于 CSS 和图片文件。
404 错误检测
比死文件更隐蔽的是"幽灵引用"——HTML 中引用了不存在的文件。这会导致浏览器请求返回 404,虽然不会直接报错,但会拖慢页面加载。
# 检查文章页引用的 JS 是否存在
for f in blog/posts/*.html; do
refs=$(grep -o 'src="[^"]*\.js[^"]*"' "$f")
for ref in $refs; do
path=$(echo "$ref" | sed 's/src="//;s/"//;s/..\///')
if [ ! -f "$path" ]; then
echo "❌ 404: $f -> $ref"
fi
done
done
通过这个检查,发现 66 篇文章都引用了一个不存在的 og-generator.js 文件。这个文件可能在某次重构中被删除了,但文章模板中的引用没有同步清理。
第二步:文件清理
删除死文件
清理工作从最简单的开始——直接删除没有被引用的文件:
# 删除未被引用的 JS
rm assets/js/ai-assistant.js # 16KB
rm assets/js/toc.js # 4KB
# 删除未被引用的图片
rm assets/images/qwen_20260424_*.png # 4.4MB
修复幽灵引用
对于引用了不存在文件的幽灵引用,需要从所有 HTML 文件中移除这些引用:
import re, os
for fname in os.listdir('blog/posts'):
if not fname.endswith('.html'):
continue
fpath = os.path.join('blog/posts', fname)
with open(fpath, 'r', encoding='utf-8') as f:
html = f.read()
# 移除对不存在文件的引用
new_html = re.sub(
r'\s*<script src="[^"]*og-generator\.js[^"]*"\s*></script>\s*\n?',
'\n', html
)
if new_html != html:
with open(fpath, 'w', encoding='utf-8') as f:
f.write(new_html)
修复 og:image 路径
文章模板中的 Open Graph 图片指向了 og-default.webp,但实际文件是 og-image.png。这会导致社交分享时图片无法显示:
# 批量修复 og:image 路径
for fname in os.listdir('blog/posts'):
if not fname.endswith('.html'):
continue
fpath = os.path.join('blog/posts', fname)
with open(fpath, 'r', encoding='utf-8') as f:
html = f.read()
html = html.replace(
'assets/images/og-default.webp',
'assets/images/og-image.png'
)
with open(fpath, 'w', encoding='utf-8') as f:
f.write(html)
第三步:CSS 优化
内联样式抽离
这是本次优化中收益最大的一项。66 篇文章每篇都有约 4KB 的内联 <style> 块,这些样式大部分是重复的(文章页专用样式如代码块高亮、提示框、表格等)。
问题分析
通过对比发现,66 篇文章的内联样式有 14 种变体,但核心内容几乎相同。去重后只有 6.7KB 的有效 CSS。
import re, hashlib, os
# 统计内联样式变体
hashes = {}
for f in os.listdir('blog/posts'):
if not f.endswith('.html'):
continue
with open(f'blog/posts/{f}') as fh:
html = fh.read()
styles = re.findall(r'<style>(.*?)</style>', html, re.DOTALL)
combined = ''.join(s.strip() for s in styles)
h = hashlib.md5(combined.encode()).hexdigest()
hashes.setdefault(h, []).append(f)
print(f'唯一样式块数量: {len(hashes)}')
for h, files in hashes.items():
print(f' {h[:8]}: {len(files)} 篇文章')
抽离方案
将所有内联样式合并去重,生成一个 article.css 文件:
import re, os
# 收集所有不同的内联样式规则
all_css = set()
for f in os.listdir('blog/posts'):
if not f.endswith('.html'):
continue
with open(f'blog/posts/{f}') as fh:
html = fh.read()
styles = re.findall(r'<style>(.*?)</style>', html, re.DOTALL)
for s in styles:
rules = re.findall(r'[^{}]+\{[^{}]+\}', s)
all_css.update(rules)
# 写入文章页专用 CSS
article_css = '\n'.join(sorted(all_css))
with open('assets/css/article.css', 'w', encoding='utf-8') as f:
f.write('/* 文章页专用样式 */\n')
f.write(article_css)
然后从所有文章中移除内联样式块,替换为外部 CSS 引用:
for f in os.listdir('blog/posts'):
if not f.endswith('.html'):
continue
fpath = f'blog/posts/{f}'
with open(fpath, 'r', encoding='utf-8') as fh:
html = fh.read()
# 移除 <style> 块
new_html = re.sub(r'\s*<style>.*?</style>\s*', '\n', html, flags=re.DOTALL)
# 添加外部 CSS 引用
new_html = new_html.replace(
'<link rel="stylesheet" href="../../assets/css/style.css">',
'<link rel="stylesheet" href="../../assets/css/style.css">\n'
' <link rel="stylesheet" href="../../assets/css/article.css">'
)
with open(fpath, 'w', encoding='utf-8') as fh:
fh.write(new_html)
效果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 每篇文章内联 CSS | ~4KB | 0 |
| article.css | 不存在 | 6.7KB |
| 总 CSS 冗余 | ~262KB | 0 |
| 浏览器缓存 | 不支持 | 支持 |
净节省 252KB,且浏览器缓存 article.css 后,后续文章页加载更快。
第四步:JavaScript 优化
内联脚本抽离
和 CSS 类似,文章页有约 80 行内联 JavaScript,包含返回顶部、阅读进度、键盘快捷键、TOC 目录等功能。
抽离到 article.js
/**
* article.js - 文章页专用脚本
* 返回顶部、阅读进度、键盘快捷键、TOC 目录
*/
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
// 返回顶部 & 阅读进度
const backToTop = document.getElementById('backToTop');
window.addEventListener('scroll', () => {
// ... 滚动逻辑
});
// TOC 目录
const headings = document.querySelectorAll('.post-content h2, .post-content h3');
headings.forEach((h, i) => {
// ... TOC 生成逻辑
});
});
})();
关键陷阱:DOMContentLoaded
抽离内联脚本时最容易踩的坑是执行时机。内联 <script> 在解析到该行时同步执行,此时 DOM 可能还没准备好。抽到外部文件后如果不用 DOMContentLoaded 包裹,getElementById 会返回 null。
// ❌ 错误:外部脚本同步执行时 DOM 未就绪
(function() {
const tocList = document.getElementById('tocList');
if (!tocList) return; // 直接退出了!
})();
// ✅ 正确:等 DOM 准备好再执行
document.addEventListener('DOMContentLoaded', () => {
const tocList = document.getElementById('tocList');
// ...
});
这个坑导致了 TOC 目录全部消失,花了时间才定位到。
手机端 TOC 抽屉
桌面端 TOC 是固定在右侧的浮动面板,但在手机端屏幕太窄不适合。方案是:
- 大屏(≥1200px):显示固定浮动 TOC
- 小屏(<1200px):隐藏浮动 TOC,显示右下角按钮,点击弹出抽屉
/* 桌面端:固定浮动 */
.post-toc-container {
display: none;
}
@media (min-width: 1200px) {
.post-toc-container {
display: block;
}
}
/* 手机端:浮动按钮 */
.mobile-toc-btn {
display: none;
}
@media (max-width: 1199px) {
.mobile-toc-btn {
display: flex;
}
}
/* 抽屉 */
.toc-drawer {
position: fixed;
right: -280px;
width: 280px;
height: 100vh;
transition: right 0.3s ease;
}
.toc-drawer.open {
right: 0;
}
第五步:缓存策略
CSS/JS 版本化
静态资源更新后,浏览器缓存会导致用户看到旧版本。解决方案是给文件引用加内容 hash:
<!-- 优化前 -->
<link rel="stylesheet" href="assets/css/style.css">
<script src="assets/js/main.js"></script>
<!-- 优化后 -->
<link rel="stylesheet" href="assets/css/style.css?v=a1b2c3d4">
<script src="assets/js/main.js?v=e5f6g7h8"></script>
GitHub Actions 自动注入 hash
手动维护 hash 不现实,用 CI 自动化。在每次 push 时扫描文件内容,生成 8 位 hash 并注入到 HTML 引用中:
# .github/workflows/update-index.yml
- name: Update cache hashes
run: |
node -e "
const fs = require('fs');
const crypto = require('crypto');
const files = [
['assets/css/style.css', 'index.html'],
['assets/js/main.js', 'index.html'],
// ... 更多文件
];
files.forEach(([asset, page]) => {
const content = fs.readFileSync(asset);
const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
let html = fs.readFileSync(page, 'utf8');
html = html.replace(
new RegExp(asset + '\\?v=[a-f0-9]+', 'g'),
asset + '?v=' + hash
);
fs.writeFileSync(page, html);
});
"
robots.txt 优化
之前 robots.txt 禁止了 /assets/css/ 和 /assets/js/ 目录的抓取,这没有必要。虽然不影响搜索排名,但有些爬虫会因此不缓存这些资源:
- Disallow: /assets/css/
- Disallow: /assets/js/
+ Disallow: /node_modules/
第六步:结构化数据
JSON-LD 结构化数据
Google 搜索结果中显示作者、发布日期、阅读时间等富文本信息,需要 JSON-LD 结构化数据。
安全生成 JSON-LD
直接用字符串拼接生成 JSON-LD 有风险——标题或描述中包含双引号会破坏 JSON 结构。正确做法是用 json.dumps:
import json
json_ld_obj = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"image": "https://709527.xyz/assets/images/og-image.png",
"author": {
"@type": "Person",
"name": "张小猛",
"url": "https://709527.xyz/about/"
},
"datePublished": f"{article_date}T00:00:00+08:00",
"keywords": ", ".join(tag_list),
"articleSection": category
}
# json.dumps 自动处理转义
json_ld_str = json.dumps(json_ld_obj, ensure_ascii=False, indent=6)
RSS XML 转义
同样,RSS 中的标题和描述也需要 XML 转义:
from xml.sax.saxutils import escape as xml_escape
lines.append(f' <title>{xml_escape(title)}</title>')
lines.append(f' <description>{xml_escape(description)}</description>')
第七步:访问统计
Cloudflare Web Analytics
对于部署在 Cloudflare 后面的域名,Cloudflare Web Analytics 是最佳选择:
- 免费
- 无 cookie
- 不需要 JS SDK(只是一个 beacon 请求)
- 隐私友好
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
data-cf-beacon='{"token": "your-token-here"}'></script>
只需要在 Cloudflare Dashboard → Web Analytics 中添加站点,获取 token 后填入即可。
效果总结
仓库体积
| 项目 | 优化前 | 优化后 |
|---|---|---|
| 死文件 | 4.4MB 图片 + 20KB JS | 0 |
| 内联 CSS | 262KB(66篇×4KB) | 6.7KB article.css |
| 内联 JS | ~80行/篇 | 3KB article.js |
| 总节省 | - | ~252KB + 4.4MB |
加载性能
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 404 请求 | 66个文章页各1个 | 0 |
| 重复 CSS 下载 | 每篇 4KB | 缓存后 0 |
| 缓存命中率 | 低(无版本化) | 高(hash 版本化) |
| 移动端 TOC | 不可用 | 抽屉式可用 |
SEO 改善
| 指标 | 优化前 | 优化后 |
|---|---|---|
| sitemap 文章数 | 32 | 66 |
| JSON-LD 结构化数据 | 无 | 66篇全有 |
| og:image | 404 | 正常显示 |
| RSS 订阅 | 10篇 | 20篇 |
经验总结
1. 静态站也需要定期维护
静态网站不是"设好就忘"的。随着内容增长,会积累死文件、幽灵引用、内联代码膨胀等问题。建议每季度做一次全面体检。
2. 内联代码是万恶之源
内联 CSS 和 JS 虽然方便,但会导致: - 重复代码膨胀 - 浏览器无法缓存 - 维护困难(改一处要更新所有页面) - 容易引发冲突
尽量抽到外部文件,用缓存策略管理版本。
3. 抽离代码时注意执行时机
内联脚本同步执行,外部脚本也是同步执行(除非加 defer/async),但放入外部文件后容易忽略 DOM 就绪问题。始终用 DOMContentLoaded 包裹是安全的做法。
4. CI 自动化是关键
手动维护缓存 hash、sitemap、索引等容易出错。用 GitHub Actions 自动化可以避免人为错误,每次提交自动更新所有依赖文件。
5. 数据驱动优化
不要凭感觉优化。用 du、grep、脚本统计来量化问题,优化后再验证效果。每一步优化都应该有可测量的数据支撑。