Flask + Jinja2 模板继承实战:6 个独立 HTML 到统一 base_layout

一、问题:每个页面都是一座孤岛

venxine.vip 最初只有一两个页面,每页一个独立 HTML 文件,各自包含完整的 <html><head><body>、导航栏、主题切换脚本。没问题,简单直接。

但当页面数增长到 6 个时(/system/、/demo/、/blog/、/blog/xxx/、/token/、/skills/),问题爆发了:

每个页面都要维护:
  ├── 导航栏(返回中枢按钮)
  ├── 主题切换(日/夜模式 + localStorage)
  ├── 暗色模式防闪烁脚本(<head> 内的 inline script)
  ├── Glass Card 样式(毛玻璃背景 + hover 效果)
  ├── 交错入场动画(fadeSlideUp + stagger 延迟)
  └── 全局排版(max-width、字体、颜色)

每改一个全局样式,要改 6 个文件。 而且改不完全,有的页面用了旧版导航栏,有的用了新版,视觉一致性全面崩盘。

这就是模板继承要解决的问题。

二、方案:base_layout.html + block 设计

Jinja2 的 {% extends %} + {% block %} 机制天然适合这个场景:

base_layout.html          ← 所有页面的"骨架"
  ├── <html>/<head>       ← 全局 meta、暗色防闪、Tailwind CDN
  ├── <nav>               ← 统一导航栏(返回中枢 + 页面标签 + 主题切换)
  ├── <main>              ← 统一容器(max-w-6xl + scrollbar-gutter)
  │   └── {% block content %} ← 子模板填充区
  └── <script>            ← 主题切换 JS

子模板(如 system.html)只需:

{% extends "base_layout.html" %}

{% block page_title %}系统信息{% endblock %}
{% block page_label %}系统版本 / SYSTEM{% endblock %}
{% block sidebar %}{% endblock %}   {# 不需要侧边栏,覆盖为空 #}

{% block page_css %}
    /* 页面专属 CSS */
{% endblock %}

{% block content %}
    <!-- 页面专属内容 -->
{% endblock %}

关键设计决策:

Block 用途 示例
page_title <title> 标签内容 系统信息 · Venxine.Vip
page_label 导航栏中间的文字 系统版本 / SYSTEM
accent_pulse_color 导航栏脉冲点的颜色 text-rose-400
sidebar 侧边栏内容,不需要就覆盖为空 {% block sidebar %}{% endblock %}
page_css 页面专属 CSS,注入 <style> .tabs { ... }
content 主体内容区 每个页面自己的 HTML
page_scripts 页面专属 JS IntersectionObserver 等

三、实战:blog 迁移的全过程

/blog/ 和 /blog/xxx/ 是最晚迁移的两个页面。迁移前它们是 200 行级别的独立 HTML,各自包含完整的导航栏、主题切换逻辑。

迁移步骤:

Step 1:提取公共部分到 base_layout

检查两个 blog 模板的公共部分: - 导航栏 → base_layout 已有 - 主题切换 → base_layout 已有 - 暗色模式脚本 → base_layout 已有 - Glass Card 样式 → base_layout 已有

提取 blog 独有部分: - 分类标签颜色系统(4 种 cat-accent) - 卡片左色条 hover 效果 - 文章排版样式(.article-body) - 滚动揭示动画(IntersectionObserver)

Step 2:改造子模板

blog.html 迁移前的开头:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>技术博客 · Venxine.Vip</title>
    <!-- 100+ 行重复的头内容 -->
</head>
<body>
    <nav><!-- 40 行重复的导航栏 --></nav>
    <main><!-- 博客内容 --></main>
    <script><!-- 30 行重复的主题切换 JS --></script>
</body>
</html>

迁移后:

{% extends "base_layout.html" %}
{% block page_title %}技术博客 · Venxine.Vip{% endblock %}
{% block page_label %}技术博客 / BLOG{% endblock %}
{% block accent_pulse_color %}text-amber-400{% endblock %}
{% block sidebar %}{% endblock %}

{% block page_css %}
    /* 仅保留 blog 独有的 CSS */
{% endblock %}

{% block content %}
    <!-- 仅保留博客内容 HTML -->
{% endblock %}

文件从 193 行缩减到 ~150 行,去掉了 100% 的重复代码。

Step 3:处理 CSS 命名冲突

base_layout 已经定义了 .glass-card,但 blog 原来也有一套 .card 样式。解决方案:将 blog 的 .card 重命名为 .blog-card,避免样式污染。

Step 4:模板缓存坑

改完文件 → touch 模板systemctl restart fund-web → 打开网页 → 还是旧的!

Flask 在生产模式下会缓存 Jinja2 编译后的模板字节码。touch 更新了文件 mtime,但正在运行的 Python 进程不会自动重载。

正确做法:

# 1. 先确保旧进程完全停止(可能占着端口)
sudo fuser -k 5000/tcp

# 2. 再启动
sudo systemctl start fund-web

systemctl restart 有时候 stop + start 之间有空窗期导致旧进程还没死透,新进程就尝试绑定端口失败。先 kill 再 start 是最稳妥的。

四、意外收获:scrollbar-gutter 解决页面抖动

所有页面统一到 base_layout 后,发现一个长期存在的视觉问题:/system/ 的 Tab3 内容比 Tab1 高,切换时垂直滚动条出现/消失导致 mx-auto 居中参考点变化,内容左移 7.5px。

在 base_layout.html 的 <html> 上加一行就全局修复:

html {
    scrollbar-gutter: stable;
}

浏览器始终预留 15px 滚动条空间,无论内容是否溢出。6 个页面全部受益,而这在各自维护独立 HTML 的时代,需要改 6 次。

五、模板继承的收益总结

迁移前 迁移后
导航栏代码 6 份 × 40 行 = 240 行 1 份 × 40 行
主题切换代码 6 份 × 30 行 = 180 行 1 份 × 15 行
全局 CSS 6 份,版本不一致 1 份,统一维护
改一个全局样式 改 6 个文件 改 1 个文件
scrollbar-gutter 不存在 全局生效
单页面文件大小 200+ 行 100-150 行

核心原则:DRY(Don't Repeat Yourself)不只是代码的事,HTML 模板同样适用。