静态博客性能优化实战:从 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. 数据驱动优化

不要凭感觉优化。用 dugrep、脚本统计来量化问题,优化后再验证效果。每一步优化都应该有可测量的数据支撑。