Files
ephron-ren-prd/prd-blog-post-collections-a-nesting-fix.md

7.7 KiB
Raw Permalink Blame History

Blog 文章列表 <a> 嵌套导致卡片分裂 — 修复 PRD

版本: v1.0 日期: 2026-05-06 状态: 📝 待评审


一、问题描述

1.1 现象

blog.ephron.ren/posts 页面,带有 collection 标签的文章(如 algorithm-efficiency-measure)被浏览器渲染为 2~3 张独立卡片,而非一张完整的卡片。

视觉效果:

┌─────────────────────────────┐
│ 【数据结构】算法效率的度量    │  ← 卡片1: 标题 + 摘要
│ 算法执行时间随问题规模...     │
└─────────────────────────────┘

┌─────────────────────────────┐
│ 2026-03-04                  │  ← 卡片2: 日期 + 标签
│ [数据结构] [算法] ...        │
└─────────────────────────────┘

┌─────────────────────────────┐
│ [数据结构 collection]       │  ← 卡片3: collection badge
└─────────────────────────────┘

1.2 影响范围

  • 所有拥有 collection 关联的文章在 posts 列表页均受影响
  • 不带 collection 的文章显示正常(无嵌套 <a> 冲突)
  • 首页文章列表home 服务)不受影响(使用不同模板)

1.3 严重程度

🟡 中等 — 功能可用但视觉体验明显异常,影响专业度


二、根因分析

2.1 HTML 源码结构(模板)

blog/templates/index.html 中,文章卡片的结构如下:

<li>
    <a href="/posts/{{ post.slug }}" class="post-item">
        <span class="post-title">标题</span>
        <p class="post-excerpt">摘要...</p>
        <div class="post-meta">
            <span class="post-date">2026-03-04</span>
            <div class="post-tags">...</div>
            <!-- ⚠️ 问题在这里 -->
            {% if item.collections %}
            <div class="post-collections">
                {% for col in item.collections %}
                <a href="/collections/{{ col.key }}" class="collection-badge">{{ col.title }}</a>
                {% endfor %}
            </div>
            {% endif %}
        </div>
    </a>
</li>

2.2 浏览器解析行为

HTML 规范禁止 <a> 标签嵌套 <a><a> 的内容模型不能包含 interactive content

当浏览器遇到嵌套的 <a> 时,会自动"修复"DOM

  1. 遇到第一个 <a class="post-item"> → 打开
  2. 遇到内部 <a class="collection-badge">自动关闭外层 <a>
  3. 后续的 </div></a> 变成游离节点

2.3 实际渲染的 DOMPlaywright 提取)

<li>
    <!-- 第1个卡片标题 + 摘要 -->
    <a class="post-item">
        <span class="post-title">【数据结构】算法效率的度量</span>
        <p class="post-excerpt">...</p>
    </a>

    <!-- 浏览器自动关闭后的 post-meta -->
    <div class="post-meta">
        <!-- 第2个卡片日期 + 标签 -->
        <a class="post-item">
            <span class="post-date">2026-03-04</span>
            <div class="post-tags">...</div>
        </a>

        <div class="post-collections">
            <!-- 第3个卡片collection badge -->
            <a class="post-item">...</a>
            <a class="collection-badge">数据结构</a>
        </div>
    </div>
</li>

一个 <li> 里出现了 3 个 <a class="post-item"> 兄弟节点,浏览器将它们渲染为 3 张独立卡片。


三、修复方案

3.1 方案概述

<a> 标签的包裹范围缩小,不包含 post-collections 部分。将整个卡片改为 <div> + JS 点击跳转,或将 collection badge 移到 <a> 外部。

3.2 推荐方案:改为 <div> + 点击事件

将外层 <a class="post-item"> 替换为 <div class="post-item">,通过 CSS cursor: pointer 和 JS click 事件实现整卡点击跳转。

优势

  • 根本性解决嵌套问题
  • 不影响现有样式class 不变)
  • collection badge 的 <a> 链接可独立工作

修改文件blog/templates/index.html

3.3 代码 Diff

--- a/blog/templates/index.html
+++ b/blog/templates/index.html
@@ -268,7 +268,7 @@
     {% set excerpt = post.content | striptags | truncate(120) %}
     <li>
-        <a
+        <div
             href="/posts/{{ post.slug }}"
             class="post-item"
             data-post-slug="{{ post.slug }}"
@@ -298,7 +298,7 @@
                 {% endif %}
             </div>
-        </a>
+        </div>
     </li>

3.4 CSS 补充

确保 .post-item 保留点击样式:

.post-item {
    cursor: pointer;
    display: block;
    /* 保留现有样式 */
}

3.5 JS 补充(如需)

如果不想依赖 <a> 标签的原生跳转,添加点击事件:

document.querySelectorAll('.post-item').forEach(item => {
    item.addEventListener('click', (e) => {
        // 如果点击的是 collection badge不触发卡片跳转
        if (e.target.closest('.collection-badge')) return;
        const slug = item.dataset.postSlug;
        if (slug) window.location.href = `/posts/${slug}`;
    });
});

四、验证方法

4.1 测试用例

# 测试项 预期结果
1 带 collection 的文章卡片 显示为一张完整卡片
2 不带 collection 的文章卡片 显示正常(无回归)
3 点击卡片标题/摘要区域 跳转到文章详情页
4 点击 collection badge 跳转到 collection 页面(不触发卡片跳转)
5 卡片 hover 效果 正常显示
6 移动端响应式 卡片布局正常

4.2 验证命令

# 检查 DOM 中不应出现多个 post-item
python3 -c "
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto('https://blog.ephron.ren/posts')
    page.wait_for_selector('.post-item')
    # 每个 li 应该只有一个 post-item
    lis = page.locator('li:has(.post-item)').all()
    for li in lis:
        count = li.locator('.post-item').count()
        assert count == 1, f'Expected 1 post-item, got {count}'
    print('PASS: All cards have single post-item')
    browser.close()
"

五、风险评估

风险 影响 缓解措施
改为 <div> 后 SEO 影响 🟡 低 — 搜索引擎已通过 sitemap 索引 保留 data-post-slug 属性
点击事件与 collection badge 冲突 🟡 JS 中通过 e.target.closest('.collection-badge') 排除
现有 CSS 依赖 <a> 标签 🟢 .post-item 使用 class 选择器,不依赖标签名

六、附录

A. 相关文件

文件 说明
blog/templates/index.html 文章列表模板(问题所在)
blog/static/css/style.css 卡片样式
blog/src/routes/pages.py 路由:传递 item.collections 到模板

B. 复现截图

通过 Playwright 提取的 DOM 片段algorithm-efficiency-measure 文章):

原始模板 → 浏览器修复后
<a class="post-item">     →  <a class="post-item">标题+摘要</a>
  标题+摘要                →  <div class="post-meta">
  <div class="post-meta"> →    <a class="post-item">日期+标签</a>
    日期+标签              →    <div class="post-collections">
    <div class="post-col"> →      <a class="post-item">...</a>
      <a class="collection">→     <a class="collection-badge">数据结构</a>
    </div>                 →    </div>
  </div>                   →  </div>
</a>