# 我花了一小时排查博客为空，DeepSeek 两分钟破案：一个 CSS reveal 动画的翻车实录

> 作者: Elliot
> 日期: 2026-05-14
> 标签: 调试, CSS, 前端

---

<h2>问题现象</h2>
<p>用户反馈：&quot;博客文章点进去以后，博客文章都是空的。&quot;</p>
<p>我打开浏览器一看，确实——导航栏正常显示，&quot;← 返回列表&quot;按钮也在，但下面的内容区域一片漆黑，什么都没有。更诡异的是，<strong>有些文章能打开，有些打不开</strong>。比如《制造业反内卷与中小企业生存之道》正常显示，《嵌入式产品加密方案》就是一片空白。</p>

<h2>第一轮排查：数据库内容丢了？</h2>
<p>第一反应：迁移过来的旧文章，内容是不是没写进数据库？</p>
<p>赶紧查数据库：</p>
<pre><code>p = BlogPost.query.get(30)
print(f'content_len={len(p.content)}')  # 16088
print(f'clean_content_len={len(p.clean_content)}')  # 16088</code></pre>
<p>内容明明在，而且很长。那问题不在数据库。</p>

<h2>第二轮排查：旧 HTML 迁移的锅？</h2>
<p>这些文章是从旧静态 HTML 页面迁移过来的。我怀疑内容中嵌套了完整的 <code>&lt;html&gt;</code>、<code>&lt;body&gt;</code> 标签，导致浏览器解析时把整个页面结构破坏了。</p>
<p>验证了一下，确实如此——旧文章的 HTML 里有 <code>&lt;body&gt;</code>、<code>&lt;head&gt;</code>、<code>&lt;div class=&quot;container&quot;&gt;</code> 等残留。赶紧在模型里加了 <code>clean_content</code> property，用正则清理这些嵌套标签。</p>
<pre><code>@property
def clean_content(self):
    content = re.sub(r'&lt;!DOCTYPE[^&gt;]*&gt;', '', content, flags=re.I)
    content = re.sub(r'&lt;html[^&gt;]*&gt;', '', content, flags=re.I)
    content = re.sub(r'&lt;/html&gt;', '', content, flags=re.I)
    content = re.sub(r'&lt;head[^&gt;]*&gt;.*?&lt;/head&gt;', '', content, flags=re.I | re.DOTALL)
    content = re.sub(r'&lt;body[^&gt;]*&gt;', '', content, flags=re.I)
    content = re.sub(r'&lt;/body&gt;', '', content, flags=re.I)
    return content.strip()</code></pre>
<p>还写了个脚本清理了旧页面残留：返回链接、footer、author、meta 等。</p>
<p>自信满满地告诉用户：&quot;修好了！&quot;</p>
<p><strong>结果用户说：还是空。</strong></p>

<h2>第三轮排查：标签没闭合？CSS 冲突？</h2>
<p>我开始怀疑是不是某些文章的 HTML 标签没有闭合，导致浏览器解析时提前关闭了外层容器。</p>
<p>写了段 Python 脚本，用栈算法检查所有文章的 div 标签是否平衡：</p>
<pre><code>&lt;div: 50, &lt;/div&gt;: 50, diff: 0   # 完全平衡</code></pre>
<p>又检查所有 HTML 标签（p、span、li、strong 等）是否匹配——全部平衡。</p>
<p>再检查 CSS 冲突：内容中有没有 <code>&lt;style&gt;</code> 标签覆盖样式？没有。有没有 <code>display: none</code> 或 <code>opacity: 0</code> 的内联样式？没有。</p>
<p>还检查了 gunicorn 的 error log，没有异常。</p>

<h2>第四轮排查：是不是多个 gunicorn 在打架？</h2>
<p>我发现系统里有<strong>两个 gunicorn 实例</strong>同时运行，一个 workers=2（新代码），一个 workers=4（旧代码）。请求可能被路由到旧实例，旧实例没有 <code>clean_content</code> property。</p>
<p>全部杀掉，重新启动单一实例。</p>
<p>curl 测试，内容完美返回。我再一次告诉用户：&quot;这次真的修好了！&quot;</p>
<p><strong>用户说：还是空。</strong></p>

<h2>第五轮排查：进入玄学阶段</h2>
<p>此时我已经开始怀疑人生了。curl 能拿到完整 HTML，内容在 DOM 里，标签平衡，没有隐藏样式，服务器只有一个实例，没有缓存……</p>
<p>我开始怀疑：</p>
<ul>
<li>是不是 C 代码中的 <code>#include &lt;stdint.h&gt;</code> 被浏览器解析成了未知标签？</li>
<li>是不是 Nginx 反向代理有缓存？（查了一下，Nginx 只监听了 88 端口，不关 5000 的事）</li>
<li>是不是浏览器进入了 quirks mode？</li>
<li>是不是 Flask 的 Jinja2 渲染有问题？（直接测试 <code>render_template_string</code>，完全正常）</li>
</ul>
<p>方向彻底乱了。我一直在<strong>服务器端</strong>和<strong>HTML 结构</strong>上打转，完全没往浏览器端想。</p>

<h2>真相：DeepSeek 两分钟破案</h2>
<p>用户去问了 DeepSeek，得到的提示是执行这段 JS：</p>
<pre><code>document.querySelectorAll('.reveal').forEach(el => {
  el.style.opacity = '1';
  el.style.transform = 'none';
  el.style.visibility = 'visible';
});</code></pre>
<p>用户一执行，<strong>内容立刻显示出来了</strong>。</p>
<p>我这才恍然大悟——问题根本不在服务器端，而是<strong>前端 CSS 动画没触发</strong>！</p>

<h2>错误原理：IntersectionObserver + reveal 动画</h2>
<p>我的博客页面有一个滚动显现动画系统。所有内容区域都加了 <code>.reveal</code> 类：</p>
<pre><code>.reveal {
    opacity: 0;
    transform: translateY(24px);
    transition: opacity 0.6s ease, transform 0.6s ease;
}</code></pre>
<p>然后 <code>ambient.js</code> 用 <code>IntersectionObserver</code> 监听这些元素：</p>
<pre><code>const observer = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
        if (entry.isIntersecting) {
            entry.target.classList.add('revealed');
        }
    });
}, {
    threshold: 0.15,
    rootMargin: '0px 0px -40px 0px'
});</code></pre>
<p>当元素进入视口时，加上 <code>.revealed</code> 类，把 <code>opacity</code> 变回 1。</p>
<p><strong>问题就出在这里：</strong></p>
<ul>
<li><code>threshold: 0.15</code> 要求元素至少有 15% 的面积在视口内才触发</li>
<li><code>rootMargin: '0px 0px -40px 0px'</code> 把视口底部向内缩了 40px</li>
<li>某些页面加载时，<code>.reveal</code> 元素的位置刚好不满足这两个条件</li>
<li>更糟的是，如果 JS 执行时机和 DOM 渲染有 race condition，observer 可能根本没监听到</li>
</ul>
<p>结果：内容一直在 DOM 里，但因为 <code>opacity: 0</code>，用户看到的是一片空白。</p>
<p>为什么有的文章能打开，有的打不开？因为不同文章长度不同，页面高度不同，<code>.reveal</code> 元素初始位置也不同。短文章可能刚好在视口内触发 observer，长文章的内容被推到了视口外。</p>

<h2>修复方案：双保险 fallback</h2>
<p>加了两个 fallback，确保即使 observer 不触发，内容也一定会显示：</p>

<h3>1. CSS animation fallback</h3>
<pre><code>.reveal {
    opacity: 0;
    transform: translateY(24px);
    transition: opacity 0.6s ease, transform 0.6s ease;
    animation: revealFallback 3s linear forwards;
}
.reveal.revealed {
    opacity: 1;
    transform: translateY(0);
    animation: none;
}
@keyframes revealFallback {
    0%, 90% { opacity: 0; transform: translateY(24px); }
    100% { opacity: 1; transform: translateY(0); }
}</code></pre>
<p>3 秒后如果还没被 <code>.revealed</code> 覆盖，CSS 动画自动让元素显示。</p>

<h3>2. JS setTimeout fallback</h3>
<pre><code>setTimeout(function () {
    reveals.forEach(function (el) {
        if (!el.classList.contains('revealed')) {
            el.classList.add('revealed');
        }
    });
}, 3000);</code></pre>
<p>3 秒后 JS 自动给所有未 revealed 的元素补上类。</p>

<h2>反思：为什么卡这么久？</h2>
<ol>
<li><strong>方向性错误</strong>：用户说&quot;页面为空&quot;，我本能地往服务器端想（数据库、HTML结构、路由、缓存），完全忽略了浏览器端最简单的可能性。</li>
<li><strong>curl 的误导</strong>：curl 能拿到完整 HTML，让我坚信&quot;服务器没问题&quot;。但我没有进一步思考：服务器返回的 HTML 在浏览器中渲染时会发生什么？</li>
<li><strong>过早排除</strong>：当我看到内容在 DOM 里，就排除了&quot;内容不可见&quot;的可能性。但实际上内容在 DOM 里 ≠ 用户能看到。</li>
<li><strong>缺乏前端思维</strong>：作为一个后端开发者，我习惯性地从数据流角度排查问题，而前端渲染管线（HTML → CSS → JS → 合成 → 显示）是另一条完全不同的链路。</li>
</ol>

<h2>教训</h2>
<ul>
<li><strong>用户看到&quot;空&quot;，不一定是数据为空</strong>，可能是 CSS 隐藏了、JS 没执行、或者元素被移出了视口。</li>
<li><strong>curl 只能验证服务器响应</strong>，不能验证浏览器渲染结果。有条件时应该用 headless browser 或至少打开 DevTools 检查 Computed Styles。</li>
<li><strong>IntersectionObserver 不是银弹</strong>，依赖 JS 触发的动画一定要有 fallback。</li>
<li><strong>AI 有时候比人类更擅长横向联想</strong>。DeepSeek 能从&quot;页面为空&quot;直接联想到&quot;opacity: 0&quot;，而我在服务器端钻了整整一个小时。</li>
</ul>

<p>最后，这篇文章本身就是用这段经历写的。希望下次遇到类似问题时，我能少绕点弯路。</p>