
Django全栈开发做到第12篇前面的模型设计、admin后台、文章列表页和首页布局都过了一遍整个站点的骨架已经能跑通了。但说实话列表页做出来之后有一个问题一直堵在心里文章的标题能点进详情页可点进去看到的是什么是一坨没有任何排版处理的纯文本标题底下堆着大段大段的正文没有侧边栏、没有标签、没有代码高亮图片一多就直接把布局撑破。这篇文章要解决的就是这个事——把前端页面做一次系统重构重点把文章详情页从占位状态升级成真正能对外发布的样子。这次的改造目标很明确详情页要让用户愿意读下去。页面要有清晰的信息层次正文要支持Markdown渲染和代码高亮侧边栏要有最近文章分类标签这些能引导继续阅读的模块移动端也不能出现横向滚动和错位。如果你正在做一个Django博客或者内容型站点也卡在详情页丑、布局乱、不知道怎么组织模板结构这个阶段这篇可以当作一个完整的参考案例。我会从模型和路由的设计取舍讲起把模板重构、Markdown渲染、代码高亮、周边功能组件逐个落地最后再分享几个改版过程中实实在在踩过的坑。1. 详情页重构前的技术准备与整体思路1.1 先看清模型里有哪些字段能被展示动手改前端之前我最先做的一件事不是打开CSS文件而是重新打开模型定义看了一眼。这个习惯很重要——详情页能展示什么本质上由模型字段决定不先把数据结构理清楚模板写一半发现少个字段回头改模型又得迁移数据纯属给自己找麻烦。我当前文章模型的字段结构大致是这样字段名类型用途说明titleCharField文章标题详情页大标题和浏览器标题都会用slugSlugFieldURL标识用于生成语义化链接abstractTextField摘要列表页用详情页侧边栏也可以呼应categoryForeignKey分类详情页展示所属分类链接tagsManyToManyField标签用于标签展示与标签云聚合bodyTextField正文Markdown源文本cover_imageImageField/URLField封面图详情页顶部展示viewsPositiveIntegerField浏览量created_atDateTimeField发布时间updated_atDateTimeField更新时间如果你前面的开发过程中没有单独加views字段这次重构可以顺手补上详情页浏览量统计会用到。迁移命令是常规操作这里不赘述。另外我提醒一点slug字段建议设置uniqueTrue理由是后面路由直接依赖它来做文章的唯一标识如果不加唯一约束查询时还得额外处理重复数据写视图的get_object_or_404也会变得不自然。1.2 路由用主键还是用slug别纠结直接一步到位详情页的URL设计方案我见过最原始的写法是/article/1/用主键定位文章省事是省事但有两个问题。第一URL没有任何语义用户和搜索引擎都看不出这一页讲的是什么第二一旦以后想换用slug做优雅链接老链接全部失效还得做额外跳转或重定向。我的做法是主路由用slug# blog/urls.py from django.urls import path from . import views app_name blog urlpatterns [ path(article/slug:slug/, views.article_detail, namearticle_detail), ]这样生成出来的URL长这样/article/django-refactor-detail-page/。看到URL基本能猜到文章主题分享出去也体面得多。有些项目会折中处理同时接受主键和slug两种访问方式比如path(article/int:pk-slug:slug/, ...)。这种做法在迁移老站点时很实用但对新开发的项目来说没必要规划好slug之后直接用一套 URL 方案即可。我最终的取舍是详情页只用slugadmin后台按主键管理不变互不干扰。如果你已经上线了用主键URL的版本可以在视图里做一层兼容判断但那是历史包袱新项目不要主动给自己加这种复杂度。1.3 视图函数里的取数逻辑与404兜底路由确定之后就是视图。详情页用函数视图FBV还是类视图CBV我这次选的是函数视图。原因很实际详情页周边功能多浏览量累加、上一篇/下一篇查询、标签聚合、模板标签上下文这些逻辑用FBV写出来更直白读者跟着排查也容易理解。一个完整的详情页视图长这样# blog/views.py from django.shortcuts import render, get_object_or_404 from django.db.models import F from .models import Article def article_detail(request, slug): # 未发布的文章直接404不暴露草稿 article get_object_or_404( Article.objects.select_related(category).prefetch_related(tags), slugslug, statuspublished ) # 浏览量累加用F表达式避免并发下的竞态覆盖 Article.objects.filter(pkarticle.pk).update(viewsF(views) 1) article.refresh_from_db(fields[views]) # 上一篇较旧/ 下一篇较新 previous_article ( Article.objects.filter(statuspublished, created_at__ltarticle.created_at) .order_by(-created_at) .first() ) next_article ( Article.objects.filter(statuspublished, created_at__gtarticle.created_at) .order_by(created_at) .first() ) context { article: article, previous_article: previous_article, next_article: next_article, } return render(request, blog/article_detail.html, context)这里有个容易忽略的点get_object_or_404里我同时用了select_related(category)和prefetch_related(tags)。道理很简单模板里会渲染article.category.name和遍历article.tags.all如果不做预取每一次渲染都会多出好几条SQL查询。个人博客可能感觉不明显但开发习惯要从这些小地方养起等数据量上来再回头优化成本会高得多。2. 重构模板骨架把公共布局和内容区域彻底分开2.1 base.html需要拆出哪些block才能不返工前端重构第一步不是写详情页模板而是把整个站点的模板骨架重新理一遍。如果此前你的base.html只是一个简单的壳子这次建议一次性把block划分到位否则后面加页面还得反复回来改。我最终定义的base.html结构包含四个核心block!-- templates/base.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{% block title %}默认站点名{% endblock %}/title {% block extra_head %}{% endblock %} /head body header classsite-header {% include includes/navbar.html %} /header div classcontainer div classrow main classcol-lg-8 {% block content %}{% endblock %} /main aside classcol-lg-4 {% include blog/includes/sidebar.html %} /aside /div /div footer classsite-footer {% include includes/footer.html %} /footer {% block extra_js %}{% endblock %} /body /htmlbody区域我分成了主内容{% block content %}和固定侧边栏{% include sidebar.html %}。做这个决定之前我犹豫过侧边栏是放在详情页模板里自己控制还是在base里统一引入最终我选了统一引入。因为侧边栏是全局性的首页、列表页、详情页都需要它与其每个页面重复写不如放在base里一次性解决。如果有特殊页面不需要侧边栏再留一个{% block sidebar %}允许子模板覆盖不过目前我没有遇到这种需求。Bootstrap 5的栅格系统在这里起了很关键的作用。主内容区用col-lg-8侧边栏用col-lg-4大屏下左右分栏小屏下自动堆叠成单列。前端重构里最省心的就是这种移动优先、流式布局的思路不用为手机单独写一套模板。2.2 详情页模板的组件拆解详情页模板继承base之后剩下的工作就是往{% block content %}里填充结构化内容。我没有把整个页面写成一个巨大的模板文件而是按区域拆成了几个语义块!-- templates/blog/article_detail.html -- {% extends base.html %} {% block title %}{{ article.title }}{% endblock %} {% block content %} article classarticle-detail header classarticle-header h1{{ article.title }}/h1 div classmeta span{{ article.created_at|date:Y年m月d日 }}/span span分类a href{{ article.category.get_absolute_url }}{{ article.category.name }}/a/span span浏览{{ article.views }}/span /div /header {% if article.cover_image %} div classarticle-cover img src{{ article.cover_image.url }} alt{{ article.title }} /div {% endif %} div classarticle-content {{ article.body|markdownify }} /div footer classarticle-tags {% for tag in article.tags.all %} a href{% url blog:tag_detail tag.slug %} classtag-link{{ tag.name }}/a {% endfor %} /footer /article nav classarticle-pagination {% if previous_article %} a href{{ previous_article.get_absolute_url }}上一篇{{ previous_article.title }}/a {% endif %} {% if next_article %} a href{{ next_article.get_absolute_url }}下一篇{{ next_article.title }}/a {% endif %} /nav {% endblock %}这个页面的信息层次是这样的顶部是标题和meta信息让用户一眼看出这是什么文章、什么时候发的、属于哪个分类中间是封面图和正文这是核心阅读区底部是标签和上下篇文章导航承接用户读完之后的下一步动作。顺序是顺着人的阅读动线设计的尽量不要把标签和导航堆到标题上面去那会分散注意力。2.3 首页和列表页同步适配base.html的block结构一旦调整首页和列表页会跟着继承变化但它们的content块内部也需要微调。比如首页通常显示文章摘要而不是全文列表页需要分页码这些逻辑都写在各自的模板里详情页的重构不会直接影响它们。但我建议趁这次改版把首页的文章卡片和列表页的文章条目统一成一套样式让全站的视觉节奏一致。我在这里踩过一次重复劳动的坑第一次改版只做了详情页结果首页还停留在旧样式进到详情页有种换了主题的割裂感。后来我只能把首页的卡片样式又重写一遍等于做了两遍无用设计。所以前端重构一定要整体推进哪怕首页只是换个配色、统一一下间距也比放着不管强得多。3. 文章正文的Markdown渲染从纯文本到可发布的排版3.1 为什么要写自定义过滤器而不是用内置过滤器Django的模板语言自带的linebreaks、safe这些过滤器解决不了Markdown渲染。linebreaks只是把换行转成br对#标题语法、**加粗、代码块完全无能为力。所以详情页正文的渲染必须要自己写一个过滤器调用Markdown解析库把源文本变成HTML再交给模板输出。具体做法是先在项目里创建一个templatetags包blog/ templatetags/ __init__.py blog_extras.py然后在blog_extras.py里写Markdown过滤器# blog/templatetags/blog_extras.py import markdown import bleach from django import template from django.utils.safestring import mark_safe register template.Library() register.filter(namemarkdownify) def markdownify(content): md markdown.Markdown(extensions[ extra, fenced_code, codehilite, toc, ]) body_html md.convert(content) allowed_tags [ p, h1, h2, h3, h4, h5, h6, strong, em, code, pre, blockquote, ul, ol, li, a, img, hr, br, table, thead, tbody, tr, th, td, ] allowed_attrs { a: [href, title, target, rel], img: [src, alt, title, width, height], code: [class], th: [align], td: [align], } clean_html bleach.clean( body_html, tagsallowed_tags, attributesallowed_attrs, styles[], protocols[http, https], ) return mark_safe(clean_html)这个过滤器做了两件事。第一用markdown.Markdown解析并扩展了常用语法包括表格、脚注、围栏代码块、代码高亮和目录。第二用bleach.clean做了一次标签和属性的白名单过滤。很多教程里会直接return mark_safe(md.convert(content))这其实是埋雷的做法下面详细说。3.2 安全处理你可能没有认真对待的mark_safemark_safe的意思是告诉Django这段字符串是安全的直接输出不要再转义。但前提是你自己得确认它确实安全。Markdown解析器会把用户写的[链接](javascript:alert(1))转成a hrefjavascript:alert(1)如果直接mark_safe到模板里浏览器执行时会打开一个危险地址。再极端一点用户直接在Markdown源文本里写script标签解析器通常会原样保留那你的页面就直接被注入了。所以bleach.clean这步不能省。我把允许的标签限制在正文排版真正需要的范围内不允许script、iframe、style这些高危标签链接协议限定为http和https把javascript:这种协议拦在外面。个人博客如果只有自己发文章风险确实小但代码写出来要经得起推敲谁也不能保证几年后这个站点不会开放多作者投稿安全习惯从第一天建立成本是最低的。3.3 代码高亮接入与pygments样式联动Markdown渲染只解决了结构问题代码高亮还得靠pygments。我在markdown.Markdown的扩展里启用了codehilite它会把代码块转换成带特定class的HTML结构。要让这些class生效需要引入pygments生成的CSS。生成CSS的命令是在项目根目录执行的pygmentize -S default -f html -a .codehilite static/css/pygments.css然后在base.html的{% block extra_head %}里引入link relstylesheet href{% static css/pygments.css %}代码高亮这块有个常见的坑pygments默认输出的代码块外面包着.codehiliteBootstrap对pre和code标签也有自己的样式两者叠加会出现背景色打架、圆角冲突、内边距不一致的问题。我的处理办法是在项目自己的CSS里给.codehilite重新声明一套高优先级样式覆盖Bootstrap的默认值。具体样式细节放在第五部分统一说。4. 详情页周边功能一篇合格博客页的必备组成4.1 标签展示与标签云的实现详情页底部展示当前文章的标签这一步很简单遍历article.tags.all输出链接即可。真正值得展开的是侧边栏的标签云。标签云的目的是让用户看到站点内容覆盖面点击某个标签就能看到该标签下的所有文章。我的实现是用自定义inclusion_tag避免在模板里直接写复杂的查询逻辑# blog/templatetags/blog_extras.py from django.db.models import Count from taggit.models import Tag register.inclusion_tag(blog/includes/sidebar_tags.html) def tag_cloud(): tags Tag.objects.annotate(num_postsCount(article)).order_by(-num_posts)[:20] return {tags: tags}聚合查询用Count(article)前提是文章的标签关系要通过taggit的默认related name关联。在模板里标签云可以按文章数量调整字号或颜色深浅让数量多的标签视觉上更突出。不过我要提醒一句标签云这种组件很容易做得花哨我的建议是克制一点样式统一、链接清晰比花里胡哨的重要得多。4.2 上一篇/下一篇导航的方向选择与代码实现详情页底部的上一篇/下一篇导航方向问题很多人搞反过。在传统博客里文章列表按时间倒序排列最新的文章排最前。那上一篇到底指时间上更早的还是更晚的我采用习惯是用户当前在读一篇旧文章页面底部的上一篇引导他去读更早发表的文章下一篇引导他读更新的文章。听起来有点绕但实际对应关系是时间更旧 上一篇时间更新 下一篇。视图里的代码在第一部分已经给出核心逻辑就是created_at__lt取更旧的排序第一条created_at__gt取更新的排序第一条。这里我加了一个小细节如果某一边没有文章模板里要用{% if %}判断不要输出空的链接占位不然用户会看到一个点了没反应的死链接体验很差。4.3 浏览量统计与侧边栏的最近文章浏览量统计用的代码前面写过了Article.objects.filter(pkarticle.pk).update(viewsF(views) 1)。用update配合F表达式而不是article.views 1再save()是为了避免并发请求下的覆盖丢失。原理是这个累加操作在数据库层完成不需要把整行数据读出来再写回去性能更好也不会出现两个人同时读到旧的浏览量、各加一次最后只加了1的问题。侧边栏的最近文章模块同样用inclusion_tag实现register.inclusion_tag(blog/includes/sidebar_recent.html) def recent_articles(count5): articles Article.objects.filter(statuspublished).order_by(-created_at)[:count] return {articles: articles}这里注意两点。第一是statuspublished的过滤条件不能漏否则后台保存的草稿会被当成文章展示到前端。第二是截断操作[:count]必须放在过滤和排序之后Django的查询集是惰性的切片的本质是SQL层面的LIMIT不会真的把所有文章查出来再在Python里截断。5. 重构完成后我踩过的坑和收尾建议5.1 Bootstrap样式与Markdown标签相互干扰详情页模板框架搭好、Markdown也能正常渲染之后我打开页面发现排版还是不对。查了半天问题出在Bootstrap对pre、code、blockquote、img这些标签默认样式和pygments、Markdown生成的HTML结构打架。典型问题有三个第一pre标签自带浅灰背景和边框pygments生成的代码块里又套了一层.codehilite的背景两层背景叠在一起颜色又脏又暗。我的处理是在项目CSS里重新声明.article-content .codehilite pre { background: #f6f8fa; border: none; border-radius: 6px; padding: 16px; overflow-x: auto; }第二Markdown解析 引用生成的blockquote默认样式在Bootstrap里面是左侧灰色边条但遇到没有设置边条颜色的情况时引用块看起来和普通段落没什么区别。我给它显式加了一套样式让引用块视觉上能突出出来。第三Bootstrap自带code标签的粉色文字但Markdown的行内代码如果也被这个样式影响全文会到处是粉色代码字看起来像写错了一样。我的做法是把文章正文内的code样式改回统一的深色背景浅色文字和代码块的视觉体系保持一致。5.2 长代码块和宽表格在移动端的溢出问题详情页做完之后我做的第一件事就是把浏览器窗口拖到手机宽度从页头滑到页尾。结果发现了两个自己平时用电脑浏览根本没注意到的bug。第一个是长代码块。电脑屏幕上代码超过一行会显示完整但手机上容器宽度不够代码直接横向溢出把整体布局撑烂整个页面出现横向滚动条。这个好解决给pre加overflow-x: auto让代码在代码块内部横向滑动而不是撑破页面。很多技术博客的移动端体验差根源就是这个细节没处理。第二个是Markdown表格。用extra扩展渲染出来的表格在窄屏下不会自动收缩列一多就会把页面撑爆。我的处理办法是让文章正文里的表格变成可横向滚动的容器.article-content table { display: block; width: 100%; overflow-x: auto; }这么做副作用是表格的width: 100%在某些情况下会失效所以我又给外层包了一个.table-wrap但实际开发中直接对表格设置display: block; overflow-x: auto已经能解决绝大多数场景。图片溢出也是同理给img设置max-width: 100%; height: auto;就好。5.3 浏览量统计也是要小心并发和刷新的浏览量统计上线之后我很快发现一个问题自己每点一次文章详情浏览量就加一次尤其是调试的时候反复刷新数字蹭蹭往上涨完全无法反映真实阅读情况。我做的第一版优化是按用户会话去重。思路是用session记录当前用户已经读过哪些文章同一个会话内同一篇文章只统计一次。具体实现可以给每个文章生成一个唯一键存进session的list里下次访问时先判断是否已存在。这套方案写起来大概多十几行代码读起来也不复杂。不过如果你的项目不追求精确的阅读量数字只想看一个大致热度用简单累加也行。我最后是加了session去重的因为被自己调试时的数字刷得太假了实在看不下去。5.4 重构完成后这几件事先别急着做详情页能正常展示之后我有一段时间非常想继续堆功能评论区、全文搜索、阅读时长统计、文章目录悬浮框每一个听起来都很有用。但我最后控制住了原因也很简单这些功能的边界和复杂度都远超预期。比如全文搜索用Django默认的icontains在文章多起来之后性能会明显下降评论区的防垃圾、通知、反作弊更是一个无底洞。我的建议是详情页重构完成之后先让它上线运行几天把内容填进去自己以普通读者的身份反复读一读。这时候你会发现排版细节、字号选择、行间距这些最后一公里的问题会比加一个新功能更能提升整体质感。我最后调整的东西就很小正文最大宽度限制在720px以内行高调整到1.8段间距加大阅读体验立刻上升了一个档次。这个项目做到这里前端页面的基本框架已经定形了。后续如果还要继续往深走可以优先考虑给详情页加一个文章目录自动生成模块利用toc扩展的锚点结构做一个悬浮目录这个对长文章的阅读体验提升比评论区要直接得多。