目录
本文是对 DjangoBlog 前端架构从零重构的完整记录。目标是在不破坏 Django 服务端渲染优势的前提下,把一个积累了多年技术债的老旧前端,改造成具备现代开发体验和用户体验的新架构。
背景:旧架构的问题在哪里
DjangoBlog 的旧前端是典型的 Django 传统方案:Bootstrap 提供基础样式,jQuery 负责交互,样式和逻辑散落在各模板文件中。随着功能迭代,积累了一系列问题。
样式管理混乱:颜色值硬编码在十几个模板文件里,想换一套主题色需要全局搜索替换。内联 style 属性与 CSS 类混用,hover 效果靠 JavaScript onmouseenter/onmouseleave 实现,维护成本极高。
没有构建流程:CSS 和 JS 直接引用 CDN 或放在 static/ 目录,无法做 Tree-shaking、代码压缩和资产指纹。每次上线需要手动处理缓存失效问题。
暗黑模式体验差:切换主题时有明显的颜色闪烁(FOUC,Flash of Unstyled Content),原因是主题 class 在 JavaScript 执行后才被添加,此时页面已经以亮色渲染过一帧。
页面体验割裂:每次翻页都是完整的 HTTP 请求和页面刷新,没有过渡动画,没有进度反馈,在慢网络下体验很差。
组件化程度低:评论、回到顶部、图片灯箱、代码复制等功能各自为政,代码重复,没有统一的状态管理方式。
技术选型:为什么不做全 SPA
重构前最重要的决策是:要不要把前端改成 Vue 或 React 的纯 SPA?
经过仔细权衡,答案是不做。理由如下:
SEO 是博客的核心诉求。SPA 需要额外的 SSR 方案(Nuxt.js 或 Next.js)才能保证搜索引擎抓取,而这意味着引入一套全新的运行时和部署环境,复杂度大幅上升。
Django 模板本来就够用。文章列表、详情页、分类页这类内容型页面,服务端渲染天然合适,不需要客户端路由和 Hydration。
HTMX 可以低成本补齐体验差距。只需在 <body> 上加几个属性,所有内部链接点击自动变成 AJAX 请求,返回的 HTML 片段替换页面局部内容,体验接近 SPA 且 Django 路由一行不用改。
最终选定的技术栈如下:
| 层次 | 技术 | 版本 | 职责 |
|---|---|---|---|
| 渲染层 | Django Templates | — | 服务端 HTML,SEO 友好 |
| 交互层 | Alpine.js | 3.15 | 声明式 UI,替代 jQuery |
| 导航层 | HTMX | 2.0 | 无刷新页面切换 |
| 样式层 | Tailwind CSS | 3.4 | 原子化 CSS,统一设计系统 |
| 构建层 | Vite | 6.4 | 现代打包,HMR 开发体验 |
| 压缩层 | Terser | 5.x | JS 激进压缩 |
| CSS 处理 | PostCSS + cssnano | — | CSS 压缩与后处理 |
第一步:建立设计系统与 CSS 变量体系
重构的第一件事不是换框架,而是建立统一的设计 Token。所有颜色、间距、阴影不再硬编码,而是通过 CSS 自定义属性管理。
frontend/src/styles/main.css 定义了完整的语义化变量系统:
:root {
/* 背景与文字 */
--background: 249 250 251;
--foreground: 17 24 39;
--card: 255 255 255;
--card-foreground: 17 24 39;
/* 边框与输入框 */
--border: 229 231 235;
--input: 229 231 235;
/* 语义色 */
--muted: 243 244 246;
--muted-foreground: 107 114 128;
--primary: 102 126 234; /* 由主题覆盖 */
--primary-foreground: 255 255 255;
}
/* 暗黑模式 */
[data-theme="dark"] {
--background: 15 23 42; /* Slate-900 */
--foreground: 226 232 240;
--card: 30 41 59; /* Slate-800 */
--border: 51 65 85;
--muted: 30 41 59;
--muted-foreground: 148 163 184;
}
8 套主题颜色通过 data-color-scheme 属性切换,每套主题只覆盖 --primary 相关变量:
/* Purple Dream(默认) */
[data-color-scheme="purple"] {
--color-primary-500: 139 92 246;
--color-primary-600: 124 58 237;
}
/* Ocean Blue */
[data-color-scheme="blue"] {
--color-primary-500: 59 130 246;
--color-primary-600: 37 99 235;
}
/* Forest Green */
[data-color-scheme="green"] {
--color-primary-500: 34 197 94;
--color-primary-600: 22 163 74;
}
对应的 tailwind.config.js 将这些变量桥接为 Tailwind 工具类:
// frontend/tailwind.config.js
export default {
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
border: 'rgb(var(--border) / <alpha-value>)',
background: 'rgb(var(--background) / <alpha-value>)',
foreground: 'rgb(var(--foreground) / <alpha-value>)',
card: 'rgb(var(--card) / <alpha-value>)',
primary: 'rgb(var(--color-primary-500) / <alpha-value>)',
muted: 'rgb(var(--muted) / <alpha-value>)',
'muted-foreground': 'rgb(var(--muted-foreground) / <alpha-value>)',
},
animation: {
'fade-in': 'fadeIn 0.2s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'heartbeat': 'heartbeat 0.3s ease-in-out',
},
},
},
}
模板里从此只出现语义类名,颜色值完全从模板层消失:
<!-- 旧写法(硬编码颜色,无法主题化) -->
<div style="background: #7c3aed; border: 1px solid #e5e7eb; color: #374151">
<!-- 新写法(语义类名,自动适配主题和暗黑模式) -->
<div class="bg-primary text-primary-foreground border border-border rounded-xl">
第二步:接入 Vite 构建流程
Django 项目接入 Vite 的核心挑战是:开发时走 Vite Dev Server(HMR),生产时走构建产物(带内容哈希),两种模式需要无缝切换。
目录结构设计
djangoblog/
├── frontend/ # 前端独立工程
│ ├── src/
│ │ ├── main.js # 统一入口,注册所有 Alpine 组件
│ │ ├── styles/main.css # Tailwind 入口 + CSS 变量
│ │ ├── components/ # Alpine.js 组件
│ │ │ ├── navigation.js # 导航栏(移动端菜单、搜索、主题切换)
│ │ │ ├── darkMode.js # 暗黑模式核心逻辑
│ │ │ ├── imageLightbox.js # 图片灯箱
│ │ │ ├── codeCopy.js # 代码块一键复制
│ │ │ ├── commentSystem.js # 评论系统
│ │ │ ├── backToTop.js # 回到顶部
│ │ │ └── reactionPicker.js # Emoji 反应
│ │ └── utils/nprogress.js # 页面进度条
│ ├── vite.config.js
│ ├── tailwind.config.js
│ └── package.json
└── blog/
└── static/blog/dist/ # Vite 构建输出目录
├── .vite/manifest.json # 资产映射表
├── js/main-[hash].js # 打包后的 JS
└── css/main-[hash].css # 提取后的 CSS
Vite 配置关键决策
构建配置有一个重要决策:不做代码分割,Alpine.js + HTMX 合并进同一个 bundle。
原因是:如果拆包,浏览器加载 main.js 时还需要串行请求 alpine.js 和 htmx.js,多一次网络往返。合并后单个文件一次加载完毕,在常见网络环境下反而更快。
// vite.config.js 核心配置
export default defineConfig({
build: {
outDir: '../blog/static/blog/dist',
manifest: true,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 移除所有 console.*
drop_debugger: true,
passes: 3, // 三轮压缩,极致瘦身
unused: true, // 删除未使用变量
dead_code: true, // 删除死代码
},
mangle: { toplevel: true },
},
rollupOptions: {
input: { main: 'src/main.js' },
output: {
// 禁用代码分割,所有依赖合并进 main bundle
entryFileNames: 'js/[name]-[hash].js',
assetFileNames: (info) =>
info.name?.endsWith('.css')
? 'css/[name]-[hash][extname]'
: 'assets/[name]-[hash][extname]',
},
},
},
})
PostCSS 处理管道在生产环境会对 CSS 做极致压缩:
// postcss.config.js
export default {
plugins: [
tailwindcss(),
autoprefixer(),
...(process.env.NODE_ENV === 'production'
? [cssnano({ preset: ['advanced', { discardUnused: true, mergeRules: true }] })]
: []),
],
}
Django 侧的 Vite 集成
Django 通过自定义模板标签读取 manifest.json,在开发模式下指向 Vite Dev Server,在生产模式下指向哈希化的构建产物:
开发模式下输出:
<script src="http://localhost:5173/src/main.js" type="module"></script>
生产模式下输出:
<link rel="stylesheet" href="/static/blog/dist/css/main-a3f2bc1d.css">
<script src="/static/blog/dist/js/main-a3f2bc1d.js" defer></script>
模板使用方式极简,只需一行:
{% load vite_tags %}
{% vite_js 'src/main.js' %}
settings.py 只需一个配置项控制模式切换:
# 开发时指向 Vite Dev Server
VITE_DEV_SERVER_URL = 'http://localhost:5173'
# 生产环境不设置此项,自动读取 manifest.json
第三步:Alpine.js 组件化改造
Alpine.js 的核心理念是把 JavaScript 行为写在 HTML 属性里,不需要手动 querySelector,响应式更新自动触发。它的体积只有 14KB(gzip),远比 Vue/React 轻量,但足以覆盖博客所需的所有交互。
组件注册模式
所有组件在 main.js 统一注册,保持入口文件清晰可读:
import Alpine from 'alpinejs'
import focus from '@alpinejs/focus' // 焦点陷阱(搜索弹窗)
import intersect from '@alpinejs/intersect' // 交叉观察器
import collapse from '@alpinejs/collapse' // 折叠动画
import navigation from './components/navigation.js'
import imageLightbox from './components/imageLightbox.js'
import commentSystem from './components/commentSystem.js'
import backToTop from './components/backToTop.js'
import reactionPicker from './components/reactionPicker.js'
Alpine.plugin(focus)
Alpine.plugin(intersect)
Alpine.plugin(collapse)
Alpine.data('navigation', navigation)
Alpine.data('imageLightbox', imageLightbox)
Alpine.data('commentSystem', commentSystem)
Alpine.data('backToTop', backToTop)
Alpine.data('reactionPicker', reactionPicker)
Alpine.start()
navigation.js:导航栏完整状态管理
导航栏负责移动端菜单开关、搜索框弹出和主题切换。关键设计是监听 HTMX 的 afterSwap 事件,在页面切换时自动收起移动端菜单:
export default function navigation() {
return {
menuOpen: false,
isSearchOpen: false,
toggleMenu() {
this.menuOpen = !this.menuOpen
// 菜单打开时禁止背景滚动,防止用户在菜单后面滚动页面
document.body.style.overflow = this.menuOpen ? 'hidden' : ''
},
init() {
// HTMX 页面切换时自动关闭移动菜单
document.body.addEventListener('htmx:afterSwap', () => {
this.menuOpen = false
document.body.style.overflow = ''
})
// 响应式处理:窗口变大时自动关闭移动菜单
window.addEventListener('resize', () => {
if (window.innerWidth >= 1024 && this.menuOpen) {
this.menuOpen = false
document.body.style.overflow = ''
}
})
},
}
}
对应模板声明式绑定:
<nav x-data="navigation()">
<button @click="toggleMenu()" :aria-expanded="menuOpen.toString()"
class="lg:hidden p-2 rounded-lg hover:bg-secondary">
<svg x-show="!menuOpen" ...><!-- 汉堡图标 --></svg>
<svg x-show="menuOpen" ...><!-- 关闭图标 --></svg>
</button>
<div x-show="menuOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
class="fixed inset-0 z-50 bg-background lg:hidden">
<!-- 移动端菜单内容 -->
</div>
</nav>
backToTop.js:用 IntersectionObserver 替代 scroll 事件
旧版回到顶部按钮监听 window.scroll 事件,每帧都触发回调,在移动端滚动时会造成明显卡顿。新版改用 IntersectionObserver:
export default function backToTop() {
return {
visible: false,
init() {
// 在距顶部 200px 处放一个 1px 的哨兵元素
// 哨兵消失视口时(即用户滚动超过 200px),显示按钮
const sentinel = document.createElement('div')
sentinel.style.cssText = 'position:absolute;top:200px;height:1px;width:1px;pointer-events:none'
document.body.prepend(sentinel)
const observer = new IntersectionObserver(
([entry]) => { this.visible = !entry.isIntersecting },
{ threshold: 0 }
)
observer.observe(sentinel)
},
scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
},
}
}
这个方案的好处:完全不监听 scroll 事件,零性能开销,触发完全由浏览器原生 API 驱动。
codeCopy.js:代码块复制按钮的动态注入
文章里每个 <pre> 代码块需要一个复制按钮。旧版靠 Django 模板生成,无法处理 HTMX 导航后新加载的内容。新版在 main.js 初始化时和每次 HTMX 换内容后都重新注入:
export function initCodeCopyFeature() {
function injectCopyButtons() {
document.querySelectorAll('.article.prose pre:not([data-copy-injected])').forEach(pre => {
pre.setAttribute('data-copy-injected', 'true')
pre.style.position = 'relative'
const btn = document.createElement('button')
btn.className = 'copy-btn absolute top-2 right-2 px-2 py-1 text-xs rounded bg-secondary text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors'
btn.innerHTML = '<svg ...><!-- 复制图标 --></svg>'
btn.addEventListener('click', async () => {
const code = pre.querySelector('code')?.innerText ?? pre.innerText
await navigator.clipboard.writeText(code)
btn.textContent = '已复制'
setTimeout(() => { btn.innerHTML = '<svg ...></svg>' }, 2000)
})
pre.appendChild(btn)
})
}
// 初始化时注入
injectCopyButtons()
// HTMX 换内容后重新注入(新文章页面的代码块)
document.body.addEventListener('htmx:afterSwap', injectCopyButtons)
}
第四步:HTMX 实现无刷新导航
HTMX 的 hx-boost 是整个导航体验升级的核心。它拦截 <a> 标签的点击,用 AJAX 替代完整页面跳转,把响应 HTML 中的指定元素替换到当前页面,同时更新浏览器历史记录。
base.html 中的核心配置:
<main id="main"
hx-boost="true"
hx-target="#main"
hx-select="#main"
hx-swap="innerHTML"
hx-push-url="true">
{% block content %}{% endblock %}
</main>
四个属性组合的含义:
hx-boost="true"对所有子链接启用 HTMX 导航hx-target="#main"把响应内容替换到#main元素hx-select="#main"只取响应 HTML 中的#main部分,避免加载整个 HTML 文档的<head>hx-push-url="true"更新浏览器地址栏 URL,保证刷新和分享链接正常工作
这四行属性就实现了 SPA 的核心功能,而 Django 视图、URL、模板完全不需要改动。
配合 NProgress 进度条提供视觉反馈:
htmx.config.defaultSwapStyle = 'innerHTML'
htmx.config.defaultSwapDelay = 0
htmx.config.defaultSettleDelay = 20
document.body.addEventListener('htmx:beforeRequest', () => NProgress.start())
document.body.addEventListener('htmx:afterRequest', () => NProgress.done())
document.body.addEventListener('htmx:afterSwap', (evt) => {
if (evt.detail.boosted) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// Alpine 自动检测新 DOM 并初始化其中的 x-data 组件
})
第五步:暗黑模式防闪烁方案
FOUC(Flash of Unstyled Content)是暗黑模式最常见的问题。根本原因是页面已经渲染一帧,JavaScript 才执行并切换主题,用户会看到短暂的白色背景闪烁。
解决方案:在 <head> 的最顶部放一段同步执行的内联脚本,在浏览器开始渲染页面之前就设置好正确的主题:
<head>
<meta charset="utf-8">
<title>...</title>
<!-- 关键 CSS:最小化内联,防止暗色模式背景闪白 -->
<style>
html[data-theme="dark"] { background-color: #0f172a; color: #e2e8f0; }
</style>
<!-- 防闪烁脚本:同步执行,早于任何渲染发生 -->
<script>
(function() {
var stored = localStorage.getItem('theme')
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
var theme = stored || (prefersDark ? 'dark' : 'light')
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')
}
})()
</script>
{% vite_js 'src/main.js' %}
</head>
darkMode.js 在此基础上提供完整 API,并支持跟随系统主题自动切换和键盘快捷键:
export function initDarkMode() {
// 跟随系统主题变化(用户未手动设置时生效)
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'dark' : 'light')
}
})
// 键盘快捷键:Ctrl/Cmd + Shift + D
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'D') {
e.preventDefault()
window.DarkMode.toggle()
}
})
// 挂载全局 API
window.DarkMode = {
toggle: () => applyTheme(getCurrentTheme() === 'dark' ? 'light' : 'dark'),
setTheme: applyTheme,
getTheme: getCurrentTheme,
}
}
最终架构的核心收益
经过完整重构,整个前端架构在以下维度获得了显著提升:
开发体验显著改善:引入 Vite 后,样式改动的 HMR 毫秒级生效,不再需要手动刷新浏览器。开发时 Alpine.js 组件的热更新保留了页面状态。
主题系统灵活可扩展:8 套主题颜色 + 暗黑模式,全部通过 CSS 变量驱动,Django 后台可配置。用户偏好存入 localStorage,页面加载后立即恢复,无任何颜色闪烁。
页面切换体验接近 SPA:HTMX hx-boost 让所有内部链接变为无刷新切换,NProgress 进度条提供视觉反馈,页面切换后平滑滚回顶部。
构建产物极致优化:Terser 三轮压缩 + cssnano Advanced 模式,移除所有 console 输出、死代码、未使用变量。Alpine.js + HTMX 合并进单 bundle,减少网络请求串行等待。
细节性能优化积累:
- IntersectionObserver 替代 scroll 事件,消除滚动性能抖动
- 图片灯箱自动过滤小图和 badge,避免误触发
- 代码复制按钮按需注入,HTMX 导航后自动重初始化
- MathJax 智能按需加载,只在页面含数学公式时才注入脚本
总结
这次重构的本质是:在 Django 服务端渲染的基础上,用恰好够用的前端工具补齐体验短板,而不是推倒重来做 SPA。
整个技术选型的逻辑链条:
- 内容展示交给 Django 模板(SEO、简单、零客户端成本)
- 页面交互交给 Alpine.js(轻量 14KB、声明式、无需构建工具也可用)
- 页面导航交给 HTMX(零后端改动、渐进增强、可降级为普通链接)
- 样式管理交给 Tailwind + CSS 变量(设计系统、多主题、暗黑模式一套方案搞定)
- 构建工具用 Vite(极速 HMR、现代产物格式、Rollup 生态丰富)
这套组合的总 JS 包体积远小于引入 React 或 Vue 的方案,首屏 TTFB 不受影响,SEO 零损失,维护成本控制在合理范围内。
对于以内容为主的博客、文档站、企业官网来说,SSR + HTMX + Alpine.js 这条路可能比全量 SPA 更值得认真考虑。
本文由 liangliangyy 原创,转载请注明出处。
评论
0