1. 从列表到详情:博客系统页面跳转的核心逻辑
做博客系统的人都有一个共识:列表页做得再漂亮,如果详情页拉胯,读者三秒钟就关掉了。我见过太多项目,首页卡片排得整整齐齐,点进去之后要么排版错乱,要么代码块溢出屏幕,要么评论区加载半天出不来。详情页才是真正留住读者的地方,也是整个博客系统里技术细节最密集的页面之一。
这篇文章要聊的就是 Django 博客系统中详情页面的完整实现思路。从 URL 路由设计、视图函数编写、模板渲染,到富文本内容的安全处理、代码高亮、阅读量统计、上下篇导航、相关文章推荐,再到 SEO 元信息配置和性能优化,我会把每一个环节拆开来讲清楚。适合正在用 Django 做博客的开发者,也适合刚学完 Django 基础想找一个完整项目练手的朋友。不管你用的是 Function-Based View 还是 Class-Based View,这里面的思路都是通用的。
详情页看起来只是“把一篇文章显示出来”这么简单,但真正做过的人知道,它涉及的东西远比想象中多。数据库查询怎么减少、富文本怎么安全渲染、Markdown 和 HTML 怎么选、代码高亮用什么方案、阅读量怎么统计才不会被刷、上下篇怎么查才高效、SEO 的 meta 标签怎么动态生成——每一个点单独拎出来都值得写一篇文章。我当初做第一个博客系统的时候,详情页改了不下十遍,每次都觉得“还能再优化一点”。下面把我踩过的坑和总结出来的方案完整分享一遍。
2. 详情页的整体设计与路由规划
2.1 URL 设计的两种主流方案对比
详情页的 URL 设计看起来是个小问题,但它直接影响 SEO 效果和后续的维护成本。常见的方案有两种:一种是基于文章 ID 的纯数字路由,比如/post/42/;另一种是带 slug 的语义化路由,比如/post/django-detail-page-guide/。
纯数字路由的好处是实现简单,不用担心 slug 冲突,数据库查询也快,直接主键索引命中。缺点是 URL 本身没有语义信息,对搜索引擎的友好度稍差,读者分享链接的时候也看不出内容是什么。slug 路由的好处是 URL 可读性强,包含关键词,对 SEO 有正向作用。缺点是需要额外维护 slug 字段,要处理中文转拼音或者手动指定的问题,还要保证唯一性。
我的建议是两者结合:URL 里同时包含 ID 和 slug,比如/post/42/django-detail-page-guide/。这样即使 slug 改了,通过 ID 仍然能定位到文章,不会出现死链。Django 的 URL 配置可以这样写:
# urls.py from django.urls import path from . import views app_name = 'blog' urlpatterns = [ path('post/<int:pk>/<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'), ]对应的模型字段设计:
# models.py from django.db import models from django.utils.text import slugify class Post(models.Model): title = models.CharField(max_length=200, verbose_name='标题') slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name='URL别名') # ... 其他字段 def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.title, allow_unicode=True) super().save(*args, **kwargs)注意:
slugify的allow_unicode=True参数可以让中文标题生成中文 slug,但部分浏览器和服务器对中文 URL 的处理不一致,建议还是用拼音库或者手动指定英文 slug。
2.2 视图选型:FBV 还是 CBV
Django 提供了函数视图(FBV)和类视图(CBV)两种方式。详情页这种场景,CBV 的DetailView已经帮我们封装好了get_object、get_context_data等方法,代码量少,扩展性也好。但如果你的详情页逻辑特别复杂,比如需要同时处理评论提交、点赞、收藏等多个动作,FBV 反而更直观。
我个人的选择是:详情页用 CBV,因为它的核心逻辑就是“取一篇文章,渲染模板”,CBV 的抽象层次刚好合适。但如果详情页需要处理 POST 请求(比如评论提交),我会把评论逻辑拆到单独的视图里,保持详情页视图的纯粹性。
# views.py from django.views.generic import DetailView from .models import Post class PostDetailView(DetailView): model = Post template_name = 'blog/post_detail.html' context_object_name = 'post' def get_object(self, queryset=None): obj = super().get_object(queryset) # 阅读量自增 Post.objects.filter(pk=obj.pk).update(views=F('views') + 1) obj.refresh_from_db(fields=['views']) return obj def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) post = self.object # 上下篇 context['prev_post'] = Post.objects.filter( status='published', created_at__lt=post.created_at ).order_by('-created_at').first() context['next_post'] = Post.objects.filter( status='published', created_at__gt=post.created_at ).order_by('created_at').first() return context这里有一个细节值得展开:阅读量自增为什么用F('views') + 1而不是post.views += 1; post.save()?因为后者在并发场景下会出现竞态条件,两个请求同时读到 views=100,各自加一后写回 101,实际上应该是 102。用F表达式让数据库层面执行自增,避免了这个问题。这是 Django 里处理计数类字段的标准做法,很多人第一次写博客系统时都会踩这个坑。
2.3 模板继承结构的设计
详情页的模板不应该从零开始写,而是继承一个基础模板。典型的继承结构是这样的:
base.html ├── header(导航栏、搜索框) ├── main │ └── post_detail.html(详情页内容) │ ├── 文章头部(标题、作者、发布时间、分类、标签) │ ├── 文章正文(富文本渲染区域) │ ├── 代码高亮脚本 │ ├── 上下篇导航 │ ├── 相关文章推荐 │ └── 评论区 └── footer(版权信息、友情链接)base.html里定义好{% block content %},详情页只需要填充内容块。这样做的好处是导航栏、页脚这些公共部分只写一次,改的时候也只改一个地方。我见过有人在每个页面里都复制一遍导航栏代码,后来要加一个菜单项,改了十几个文件,这种维护成本完全是可以避免的。
3. 详情页核心功能的实现细节
3.1 富文本内容的渲染与安全处理
博客文章的正文通常有两种存储方式:Markdown 和 HTML。Markdown 的好处是写作方便,存储的是纯文本,迁移成本低;HTML 的好处是所见即所得,编辑器直接输出,渲染时不需要额外转换。
如果你用的是 Markdown,渲染流程是:数据库存 Markdown 原文 → 视图里用markdown库转换成 HTML → 模板里用|safe过滤器输出。这里有一个关键点:|safe过滤器会关闭 Django 的自动转义,如果 Markdown 内容里包含恶意脚本,就会被执行。所以必须在转换阶段做 HTML 清洗。
import markdown import bleach def render_markdown(content): html = markdown.markdown( content, extensions=[ 'markdown.extensions.extra', 'markdown.extensions.codehilite', 'markdown.extensions.toc', 'markdown.extensions.tables', ] ) # 允许的标签和属性白名单 allowed_tags = [ 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'span', 'div', ] allowed_attrs = { 'a': ['href', 'title', 'rel'], 'img': ['src', 'alt', 'title', 'width', 'height'], 'code': ['class'], 'span': ['class'], 'div': ['class'], } cleaned = bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs) return cleaned如果你用的是富文本编辑器(比如 CKEditor、TinyMCE、Quill),它们输出的就是 HTML,同样需要做清洗。很多人觉得“自己写的文章不会有问题”,但如果你开放了投稿功能或者多人协作,不清洗就是给自己埋雷。
实操心得:
bleach库目前已经进入维护模式,可以考虑用nh3替代,它是用 Rust 写的,性能更好,API 也兼容。安装nh3之后,把bleach.clean换成nh3.clean即可,参数名略有不同,注意看文档。
3.2 代码高亮的三种方案与选型建议
技术博客离不开代码块,代码高亮的效果直接影响阅读体验。目前主流的方案有三种:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Pygments(服务端) | 后端渲染时生成带 class 的 HTML | 不依赖 JS,首屏就有高亮 | 增加服务端渲染时间 |
| highlight.js(客户端) | 浏览器加载后扫描 code 标签 | 部署简单,支持语言多 | 首屏有闪烁,大页面性能差 |
| Prism.js(客户端) | 类似 highlight.js | 体积小,主题丰富 | 同样有首屏闪烁问题 |
我的选择是 Pygments 服务端渲染。原因很简单:博客的流量主要来自搜索引擎和 RSS 阅读器,这些场景下 JS 不一定能执行,服务端渲染的高亮是“所见即所得”的。而且 Pygments 生成的 HTML 结构清晰,配合 CSS 主题可以做出很好的效果。
配置方式:
# settings.py 中不需要额外配置,markdown 的 codehilite 扩展会自动调用 Pygments # 但需要安装:pip install Pygments然后在模板里引入 Pygments 的 CSS 主题文件。可以下载现成的主题,也可以用命令生成:
pygmentize -S monokai -f html -a .codehilite > codehilite.css注意:
codehilite扩展生成的 HTML 外层是<div class="codehilite">,里面的<pre>和<code>都带有 class。如果你同时用了 highlight.js,会出现双重高亮,代码颜色会乱掉。选一种方案就好,不要混用。
3.3 阅读量统计的防刷策略
阅读量是博客最基础的统计指标,但如果不做防刷,很容易被恶意刷新或者爬虫刷高。常见的防刷策略有几种:
第一种是基于 IP 的简单去重。用 Django 的缓存系统,以post_view_{post_id}_{ip}为 key,设置一个过期时间(比如 1 小时),如果 key 存在就不增加阅读量。实现简单,但同一个 IP 下的多个用户会被误判。
第二种是基于 Cookie 的去重。用户第一次访问时种一个 Cookie,记录已读的文章 ID 列表,下次访问时检查。这种方式对用户更友好,但用户可以清 Cookie 绕过。
第三种是结合用户登录状态。登录用户用 user_id 去重,未登录用户用 IP + Cookie 双重判断。这是最完善的方案,但实现复杂度也最高。
from django.core.cache import cache def increase_views(request, post): if request.user.is_authenticated: cache_key = f'post_views_{post.pk}_user_{request.user.pk}' else: ip = get_client_ip(request) cache_key = f'post_views_{post.pk}_ip_{ip}' if not cache.get(cache_key): Post.objects.filter(pk=post.pk).update(views=F('views') + 1) cache.set(cache_key, True, 3600) # 1小时内不重复计数实操心得:如果你的博客部署在 Nginx 后面,
request.META['REMOTE_ADDR']拿到的是 Nginx 的 IP,不是真实用户 IP。需要在 Nginx 配置里加上proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;,然后在 Django 里从HTTP_X_FORWARDED_FOR取第一个 IP。这个坑我踩过,阅读量一直显示为 1,排查了半天才发现是 IP 拿错了。
3.4 上下篇导航的高效查询
上下篇导航是详情页的标配功能,但查询方式直接影响性能。最直观的做法是用created_at比较:
prev_post = Post.objects.filter(created_at__lt=post.created_at).order_by('-created_at').first() next_post = Post.objects.filter(created_at__gt=post.created_at).order_by('created_at').first()这两条查询在数据量小的时候没问题,但文章多了之后,每次详情页都要执行两次额外的查询。如果created_at字段有索引,查询速度还可以接受。但如果没有索引,全表扫描就会拖慢页面。
优化的思路有几个:一是确保created_at字段有db_index=True;二是用only()只取需要的字段,减少数据传输;三是把上下篇的 ID 缓存起来,避免重复查询。
class Post(models.Model): created_at = models.DateTimeField(auto_now_add=True, db_index=True) # ...另外要注意的是,上下篇应该只包含已发布的文章,草稿和私密文章不应该出现在导航里。这个过滤条件很容易被忽略,导致读者点进去看到一篇未完成的草稿。
4. 详情页的进阶功能与性能优化
4.1 相关文章推荐的实现思路
相关文章推荐是提升页面停留时间的重要手段。实现方式有几种:基于标签匹配、基于分类匹配、基于内容相似度。
最简单的是基于标签匹配:找出与当前文章共享标签最多的其他文章,按共享标签数量排序。SQL 层面可以用annotate和Count实现:
from django.db.models import Count tags = post.tags.all() related = Post.objects.filter( tags__in=tags, status='published' ).exclude(pk=post.pk).annotate( same_tags=Count('tags') ).order_by('-same_tags', '-created_at')[:5]这个查询的逻辑是:找出所有包含当前文章标签的文章,排除自己,按共享标签数量降序排列,取前 5 篇。注意tags__in=tags会产生 JOIN,如果标签很多,查询可能会慢。可以在标签数量超过一定阈值时,只取前几个最热门的标签参与匹配。
基于内容相似度的方案(比如 TF-IDF、词向量)效果更好,但需要额外的计算和存储,对于个人博客来说有点重。我的建议是先用标签匹配,等文章量上来了再考虑升级。
4.2 SEO 元信息的动态配置
详情页的 SEO 元信息包括 title、description、keywords、og:title、og:description、og:image 等。这些信息应该根据文章内容动态生成,而不是写死。
{% block seo %} <title>{{ post.title }} - {{ site_name }}</title> <meta name="description" content="{{ post.summary|default:post.content|striptags|truncatechars:150 }}"> <meta name="keywords" content="{% for tag in post.tags.all %}{{ tag.name }}{% if not forloop.last %},{% endif %}{% endfor %}"> <meta property="og:title" content="{{ post.title }}"> <meta property="og:description" content="{{ post.summary|default:post.content|striptags|truncatechars:200 }}"> <meta property="og:type" content="article"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> {% if post.cover_image %} <meta property="og:image" content="{{ post.cover_image.url }}"> {% endif %} {% endblock %}这里有几个细节:striptags过滤器会去掉 HTML 标签,避免 description 里出现<p>这样的标签;truncatechars控制长度,description 建议在 150 字以内,og:description 可以稍长一些;request.build_absolute_uri生成完整的 URL,社交平台抓取时需要绝对路径。
注意:如果你的文章正文是 Markdown 存储的,
striptags之前需要先转换成 HTML,否则 description 里会出现##、**这样的 Markdown 标记。可以在模型里加一个get_plain_text()方法,专门用于生成摘要。
4.3 数据库查询优化:select_related 和 prefetch_related
详情页涉及的关联查询很多:作者、分类、标签、评论。如果不做优化,一个页面可能触发几十次数据库查询,这就是典型的 N+1 问题。
select_related用于外键和一对一关系,它会在一次查询中用 JOIN 把关联数据取出来。prefetch_related用于多对多和一对多关系,它会执行额外的查询,但在 Python 层面做关联,避免 N+1。
class PostDetailView(DetailView): def get_queryset(self): return Post.objects.select_related( 'author', 'category' ).prefetch_related( 'tags', 'comments__user' ).filter(status='published')怎么判断有没有 N+1 问题?装一个django-debug-toolbar,打开详情页看 SQL 面板。如果发现同一个表的查询重复出现了很多次,那就是 N+1。我见过一个详情页执行了 80 多条 SQL,优化之后降到 5 条以内,页面加载时间从 2 秒降到 300 毫秒。
4.4 评论区的加载策略
评论区是详情页里最动态的部分,也是性能瓶颈的常见来源。如果评论很多,一次性全部加载会拖慢页面。常见的策略有:
- 分页加载:每页 20 条,用 AJAX 翻页
- 懒加载:先加载前 10 条,滚动到底部再加载更多
- 折叠回复:只显示顶层评论,子评论点击展开
我的做法是服务端渲染前 20 条评论,后续的通过 AJAX 加载。这样首屏有内容,搜索引擎也能抓取到主要评论,同时不会因为评论太多而拖慢页面。
# views.py from django.core.paginator import Paginator class CommentListView(ListView): model = Comment paginate_by = 20 template_name = 'blog/comment_list.html' def get_queryset(self): return Comment.objects.filter( post_id=self.kwargs['post_id'], parent__isnull=True, is_active=True ).select_related('user').prefetch_related('replies__user')评论的嵌套回复用parent字段实现自关联,查询顶层评论时用parent__isnull=True过滤,子评论通过prefetch_related('replies__user')一次性取出来。这样渲染的时候不会产生额外的查询。
5. 常见问题排查与避坑指南
5.1 详情页 404 的六种常见原因
详情页打不开、返回 404,是开发过程中最常见的问题。我整理了一个排查清单:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 所有文章都 404 | URL 配置未生效 | 检查urls.py是否 include 了 app 的路由 |
| 部分文章 404 | 文章状态不是 published | 检查get_queryset的过滤条件 |
| 带 slug 的 URL 404 | slug 字段为空或不匹配 | 检查模型 save 方法是否生成 slug |
| 中文 slug 404 | URL 编码问题 | 用urllib.parse.quote编码后再拼接 |
| 修改后 404 | 缓存未清除 | 清除 Django 缓存或重启服务 |
| 部署后 404 | 静态文件或路由配置差异 | 检查生产环境的ALLOWED_HOSTS和 URL 配置 |
其中最容易忽略的是文章状态过滤。很多人在get_queryset里加了filter(status='published'),但自己测试的文章是草稿状态,所以一直 404。调试的时候可以临时把过滤条件去掉,确认是过滤问题还是路由问题。
5.2 富文本渲染乱码的排查思路
富文本渲染乱码通常有三种表现:中文变成问号、HTML 标签被转义显示、代码块格式错乱。
中文乱码一般是编码问题。检查数据库连接的 charset 是否为 utf8mb4,检查模板文件的编码是否为 UTF-8,检查DEFAULT_CHARSET配置。Django 默认是 UTF-8,但如果数据库是 latin1,存进去的时候就已经乱码了。
HTML 标签被转义显示,说明模板里没有用|safe过滤器,或者用了escape过滤器。Django 模板默认会自动转义变量,富文本内容必须显式标记为安全。
代码块格式错乱,通常是codehilite扩展的配置问题。检查markdown的扩展列表里是否包含了codehilite,以及 CSS 文件是否正确引入。如果代码块没有背景色和语法高亮,多半是 CSS 没加载。
5.3 阅读量不增加的五个排查方向
阅读量统计看起来简单,但出问题的时候排查起来很烦。按以下顺序检查:
- 缓存 key 是否设置成功:如果缓存后端配置错误,
cache.get和cache.set会静默失败 - IP 获取是否正确:部署在反向代理后面时,
REMOTE_ADDR是代理的 IP - 数据库更新是否执行:用
F表达式更新后,需要refresh_from_db才能拿到新值 - 事务是否回滚:如果视图里有异常被捕获但事务回滚了,更新就不会生效
- 是否被防刷逻辑拦截:检查缓存 key 的过期时间,可能设置得太长了
实操心得:调试阅读量的时候,可以临时把防刷逻辑去掉,确认基础的自增逻辑没问题,再逐步加上防刷。我当初就是防刷逻辑写错了,导致所有请求都被判定为重复,阅读量一直停在 1。
5.4 详情页加载慢的性能排查清单
详情页加载慢,按以下清单逐项排查:
- 数据库查询数量:用 debug toolbar 看 SQL 数量,超过 10 条就要优化
- 是否有全表扫描:检查
created_at、status、slug等过滤字段是否有索引 - 富文本渲染耗时:Markdown 转换和 HTML 清洗是否在每次请求都执行,可以考虑缓存渲染结果
- 图片是否过大:封面图和正文图片是否压缩,是否用了 CDN
- 静态文件是否走 CDN:CSS、JS、字体文件是否由 CDN 提供
- 是否开启了 Gzip 压缩:Nginx 的
gzip on可以显著减少传输体积 - 模板是否过于复杂:模板继承层级是否过深,是否在模板里做了复杂的逻辑判断
我自己的博客做过一轮优化,把 Markdown 渲染结果缓存到 Redis 里,key 是文章 ID + 更新时间戳,文章不更新就直接读缓存。这一项就把详情页的响应时间从 800ms 降到了 200ms 左右。
6. 模板结构拆解与关键代码说明
6.1 详情页模板的完整结构
一个结构清晰的详情页模板应该包含以下部分,我按从上到下的顺序列出来:
{% extends 'base.html' %} {% load static %} {% block content %} <article class="post-detail"> <!-- 文章头部 --> <header class="post-header"> <h1>{{ post.title }}</h1> <div class="post-meta"> <span class="author">{{ post.author.username }}</span> <span class="date">{{ post.created_at|date:"Y-m-d H:i" }}</span> <span class="category">{{ post.category.name }}</span> <span class="views">{{ post.views }} 次阅读</span> </div> <div class="post-tags"> {% for tag in post.tags.all %} <a href="{% url 'blog:tag_posts' tag.slug %}">{{ tag.name }}</a> {% endfor %} </div> </header> <!-- 文章正文 --> <div class="post-content"> {{ post.content_html|safe }} </div> <!-- 上下篇导航 --> <nav class="post-navigation"> {% if prev_post %} <a href="{{ prev_post.get_absolute_url }}" class="prev">上一篇:{{ prev_post.title }}</a> {% endif %} {% if next_post %} <a href="{{ next_post.get_absolute_url }}" class="next">下一篇:{{ next_post.title }}</a> {% endif %} </nav> <!-- 相关文章 --> {% if related_posts %} <section class="related-posts"> <h3>相关文章</h3> <ul> {% for rp in related_posts %} <li><a href="{{ rp.get_absolute_url }}">{{ rp.title }}</a></li> {% endfor %} </ul> </section> {% endif %} <!-- 评论区 --> {% include 'blog/comments.html' %} </article> {% endblock %}这个结构里,post.content_html是模型的一个属性或方法,返回渲染好的 HTML。把渲染逻辑放在模型层而不是模板层,好处是可以在模型里做缓存,避免每次请求都重新渲染。
6.2 模型层的渲染缓存实现
from django.core.cache import cache class Post(models.Model): # ... 字段定义 def get_content_html(self): cache_key = f'post_html_{self.pk}_{self.updated_at.timestamp()}' html = cache.get(cache_key) if html is None: html = render_markdown(self.content) cache.set(cache_key, html, 3600 * 24) # 缓存一天 return html @property def content_html(self): return self.get_content_html()缓存 key 里带上updated_at的时间戳,文章更新后时间戳变化,缓存自动失效,不需要手动清除。这是缓存设计里很实用的一个技巧,比手动管理缓存失效要可靠得多。
6.3 上下篇导航的模板优化
上下篇导航在模板里用{% if %}判断,逻辑简单。但有一个细节:如果文章是按时间排序的,上一篇应该是更早的文章,下一篇是更晚的文章。这个方向很容易搞反,写完之后一定要用实际数据验证一下。
另外,导航链接的文案可以更友好一些。不要只显示“上一篇”“下一篇”,把文章标题也带上,读者更容易判断是否要点击。如果标题太长,用truncatechars截断。
<a href="{{ prev_post.get_absolute_url }}" class="prev"> 上一篇:{{ prev_post.title|truncatechars:30 }} </a>6.4 评论区模板的嵌套渲染
评论区的嵌套回复用递归模板实现。Django 模板支持{% include %}递归调用,但要注意递归深度,避免无限循环。
<!-- comments.html --> <div class="comment-list"> {% for comment in comments %} <div class="comment" id="comment-{{ comment.pk }}"> <div class="comment-author">{{ comment.user.username }}</div> <div class="comment-content">{{ comment.content }}</div> <div class="comment-time">{{ comment.created_at|timesince }}前</div> {% if comment.replies.all %} <div class="comment-replies"> {% include 'blog/comments.html' with comments=comment.replies.all %} </div> {% endif %} </div> {% endfor %} </div>递归渲染的层数建议控制在 3 层以内,太深的嵌套在移动端显示效果很差。超过 3 层的回复可以折叠,点击“查看更多回复”再展开。
7. 部署上线的注意事项与个人经验
7.1 生产环境与开发环境的差异
开发环境跑得好好的详情页,部署到生产环境后可能出现各种问题。最常见的差异有:
DEBUG = False后静态文件 404:Django 不再自动服务静态文件,需要 Nginx 或 WhiteNoise 来处理ALLOWED_HOSTS未配置:请求直接被拒绝- 数据库连接数不足:开发环境单用户,生产环境并发请求多,需要调整连接池
- 缓存后端不同:开发用本地内存缓存,生产用 Redis,配置和行为都有差异
- 时区问题:
USE_TZ = True时,数据库存 UTC 时间,模板里用|date过滤器会自动转换
注意:
DEBUG = False时,Django 的 404 和 500 页面会使用默认模板,需要配置handler404和handler500来自定义错误页面。详情页的 404 尤其重要,因为文章可能被删除或修改 URL。
7.2 静态文件与媒体文件的处理
详情页涉及的静态文件包括 CSS、JS、字体,媒体文件包括封面图、正文图片。生产环境的推荐方案是:
- 静态文件:
collectstatic收集到统一目录,Nginx 直接服务,或者用 WhiteNoise 中间件 - 媒体文件:上传到对象存储(如 S3 兼容的存储),通过 CDN 分发
如果图片直接放在服务器本地,随着文章增多,磁盘空间和备份都会成为问题。对象存储的成本很低,而且有 CDN 加速,详情页的图片加载速度会明显提升。
# settings.py DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'7.3 我踩过的三个印象最深的坑
第一个坑是 Markdown 渲染的性能问题。最初每次请求都重新渲染,文章长了之后响应时间超过 1 秒。后来加了缓存,但缓存 key 设计错了,文章更新后缓存没失效,读者看到的还是旧内容。改成带时间戳的 key 之后才解决。
第二个坑是阅读量的并发问题。用post.views += 1的方式,在并发请求下数据对不上。换成F表达式之后解决了,但忘了refresh_from_db,导致页面上显示的阅读量还是旧值。这个细节在 Django 文档里有提到,但很容易忽略。
第三个坑是上下篇导航的查询方向。我一开始把created_at__lt和created_at__gt搞反了,导致“上一篇”显示的是更新的文章。测试的时候没注意,上线后读者反馈才发现。这种逻辑错误一定要用实际数据验证,不能凭感觉。
7.4 后续可以扩展的方向
详情页做完基础功能之后,还有很多可以扩展的方向。比如:
- 文章目录(TOC):根据正文的标题自动生成侧边目录,长文阅读体验会好很多
- 阅读进度条:页面顶部显示阅读进度,让读者知道还有多少内容
- 代码复制按钮:代码块右上角加一个复制按钮,方便读者直接复制
- 图片灯箱:点击正文图片放大查看,技术博客的截图比较多,这个功能很实用
- 分享功能:生成分享卡片或者复制链接,方便读者分享到社交平台
- 暗色模式:根据系统偏好自动切换,或者提供手动切换按钮
这些功能不需要一次性全做,可以按优先级逐步添加。我的建议是先保证核心阅读体验,再考虑锦上添花的功能。详情页的本质是让读者舒服地读完文章,所有功能都应该服务于这个目标。