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

📅 2026-05-14 👤 Elliot 👁️ 15 次阅读 ⏱️ 6 分钟阅读 ❤️ 0

问题现象

用户反馈:"博客文章点进去以后,博客文章都是空的。"

我打开浏览器一看,确实——导航栏正常显示,"← 返回列表"按钮也在,但下面的内容区域一片漆黑,什么都没有。更诡异的是,有些文章能打开,有些打不开。比如《制造业反内卷与中小企业生存之道》正常显示,《嵌入式产品加密方案》就是一片空白。

第一轮排查:数据库内容丢了?

第一反应:迁移过来的旧文章,内容是不是没写进数据库?

赶紧查数据库:

p = BlogPost.query.get(30)
print(f'content_len={len(p.content)}')  # 16088
print(f'clean_content_len={len(p.clean_content)}')  # 16088

内容明明在,而且很长。那问题不在数据库。

第二轮排查:旧 HTML 迁移的锅?

这些文章是从旧静态 HTML 页面迁移过来的。我怀疑内容中嵌套了完整的 <html><body> 标签,导致浏览器解析时把整个页面结构破坏了。

验证了一下,确实如此——旧文章的 HTML 里有 <body><head><div class="container"> 等残留。赶紧在模型里加了 clean_content property,用正则清理这些嵌套标签。

@property
def clean_content(self):
    content = re.sub(r'<!DOCTYPE[^>]*>', '', content, flags=re.I)
    content = re.sub(r'<html[^>]*>', '', content, flags=re.I)
    content = re.sub(r'</html>', '', content, flags=re.I)
    content = re.sub(r'<head[^>]*>.*?</head>', '', content, flags=re.I | re.DOTALL)
    content = re.sub(r'<body[^>]*>', '', content, flags=re.I)
    content = re.sub(r'</body>', '', content, flags=re.I)
    return content.strip()

还写了个脚本清理了旧页面残留:返回链接、footer、author、meta 等。

自信满满地告诉用户:"修好了!"

结果用户说:还是空。

第三轮排查:标签没闭合?CSS 冲突?

我开始怀疑是不是某些文章的 HTML 标签没有闭合,导致浏览器解析时提前关闭了外层容器。

写了段 Python 脚本,用栈算法检查所有文章的 div 标签是否平衡:

<div: 50, </div>: 50, diff: 0   # 完全平衡

又检查所有 HTML 标签(p、span、li、strong 等)是否匹配——全部平衡。

再检查 CSS 冲突:内容中有没有 <style> 标签覆盖样式?没有。有没有 display: noneopacity: 0 的内联样式?没有。

还检查了 gunicorn 的 error log,没有异常。

第四轮排查:是不是多个 gunicorn 在打架?

我发现系统里有两个 gunicorn 实例同时运行,一个 workers=2(新代码),一个 workers=4(旧代码)。请求可能被路由到旧实例,旧实例没有 clean_content property。

全部杀掉,重新启动单一实例。

curl 测试,内容完美返回。我再一次告诉用户:"这次真的修好了!"

用户说:还是空。

第五轮排查:进入玄学阶段

此时我已经开始怀疑人生了。curl 能拿到完整 HTML,内容在 DOM 里,标签平衡,没有隐藏样式,服务器只有一个实例,没有缓存……

我开始怀疑:

  • 是不是 C 代码中的 #include <stdint.h> 被浏览器解析成了未知标签?
  • 是不是 Nginx 反向代理有缓存?(查了一下,Nginx 只监听了 88 端口,不关 5000 的事)
  • 是不是浏览器进入了 quirks mode?
  • 是不是 Flask 的 Jinja2 渲染有问题?(直接测试 render_template_string,完全正常)

方向彻底乱了。我一直在服务器端HTML 结构上打转,完全没往浏览器端想。

真相:DeepSeek 两分钟破案

用户去问了 DeepSeek,得到的提示是执行这段 JS:

document.querySelectorAll('.reveal').forEach(el => {
  el.style.opacity = '1';
  el.style.transform = 'none';
  el.style.visibility = 'visible';
});

用户一执行,内容立刻显示出来了

我这才恍然大悟——问题根本不在服务器端,而是前端 CSS 动画没触发

错误原理:IntersectionObserver + reveal 动画

我的博客页面有一个滚动显现动画系统。所有内容区域都加了 .reveal 类:

.reveal {
    opacity: 0;
    transform: translateY(24px);
    transition: opacity 0.6s ease, transform 0.6s ease;
}

然后 ambient.jsIntersectionObserver 监听这些元素:

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'
});

当元素进入视口时,加上 .revealed 类,把 opacity 变回 1。

问题就出在这里:

  • threshold: 0.15 要求元素至少有 15% 的面积在视口内才触发
  • rootMargin: '0px 0px -40px 0px' 把视口底部向内缩了 40px
  • 某些页面加载时,.reveal 元素的位置刚好不满足这两个条件
  • 更糟的是,如果 JS 执行时机和 DOM 渲染有 race condition,observer 可能根本没监听到

结果:内容一直在 DOM 里,但因为 opacity: 0,用户看到的是一片空白。

为什么有的文章能打开,有的打不开?因为不同文章长度不同,页面高度不同,.reveal 元素初始位置也不同。短文章可能刚好在视口内触发 observer,长文章的内容被推到了视口外。

修复方案:双保险 fallback

加了两个 fallback,确保即使 observer 不触发,内容也一定会显示:

1. CSS animation fallback

.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); }
}

3 秒后如果还没被 .revealed 覆盖,CSS 动画自动让元素显示。

2. JS setTimeout fallback

setTimeout(function () {
    reveals.forEach(function (el) {
        if (!el.classList.contains('revealed')) {
            el.classList.add('revealed');
        }
    });
}, 3000);

3 秒后 JS 自动给所有未 revealed 的元素补上类。

反思:为什么卡这么久?

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

教训

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

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

最后更新:2026-08-11 06:48