
简介这是一份基于Python Flask框架搭建简易个人博客网站的完整源码与学习资料包。内容覆盖应用初始化、路由视图、Jinja2模板渲染、SQLAlchemy数据库操作以及用户登录认证等核心知识点适合Web开发初学者和希望系统学习Flask实践的人群。资源共19个文件包含12个HTML模板、2个JS交互脚本、2个Python程序文件另有CSS样式、Markdown说明和文本依赖清单压缩包仅71KB目录结构清晰便于按模块查看。目前已有5267人学习下载。代码中提供了文章模型和表单处理逻辑读者可在学习过程中理解静态文件组织方式、父模板继承和用户会话管理思路并借助附带的环境配置与运行说明快速复现一个具备内容发布能力的个人博客网站进一步扩展评论、分类、搜索等功能。1. 为什么「用 Flask 搭个人博客」依然是练手与落地的黄金项目这两年前端框架铺天盖地静态站生成器一键出图很多人觉得个人博客没必要再动后端。但如果你真想理解「浏览器请求到服务器响应中间发生了什么」或者你想把博客做成能登录、能后台发文、能根据关键词搜索的完整小系统那么用 Flask 写一个简单博客依然是性价比最高的入门路径也是进阶到微服务前最值得亲手敲一遍的「hello world」。很多人一上来就上重型框架结果是配置半天还没看到一条文章。而 Flask 轻、快、生态够用一个最小博客核心代码不到三百行。它能解决的核心诉求是快速搭建、本地写作、随时备份——你不需要迁就任何现成平台数据完全在自己手里。本文会从一个能跑的最小版本讲起再把路由、数据库、Markdown 渲染和部署里的坑逐个拆开让你照着敲完能拿到一个真正每天手动写文章、不会被静态构建流程绕晕的博客系统。2. 先把地基打对Flask 项目的目录拆分与虚拟环境2.1 为什么简单项目也要拆目录而不是一个 app.py 走天下新手最容易交出的作品是一个 app.py 里堆了所有路由、模型、模板和静态文件路径。这个方案在上传几十篇文章之后必然翻车改个数据库字段要在一千行里找静态文件路径稍微一乱就 404想加接口测试又无处下手。所以我建议哪怕标题写着「简单」也按功能模块拆开但不要拆过度——三层就够入口文件、应用工厂、业务模块。这样既能满足「简单」又能让结构和团队里用 Django 或 Spring 的同事互相能看明白。一个我常用的最小布局是这样的blog_demo/ ├── manage.py # 应用入口启动跟数据库初始化都在这 ├── requirements.txt # 依赖列表 ├── app/ │ ├── __init__.py # create_app 工厂函数 │ ├── models.py # 数据库表模型 │ ├── views.py # 路由与业务逻辑 │ ├── templates/ # Jinja2 模板 │ │ ├── base.html │ │ ├── index.html │ │ └── post.html │ └── static/ # CSS 和少量 JS └── instance/ # 运行时生成的 sqlite 文件放这里很多人会质疑一个小博客需要这样吗常见做法是「先把路由放到 views模型放 models将来加新模块就加一个 blueprint」。我在第一次重构时就把所有路由都集中在 views.py 里等写到第三类功能就后悔了因为模板、表单、接口混在一起找一处改动要翻两屏。即便你的项目只有 5 个路由拆成 factory 也能让你在写单测时轻松替换配置。2.2 虚拟环境与依赖锁定给未来的自己留条后悔药整个过程我一般会先准备虚拟环境原因很简单Python 版本和依赖版本之间的兼容性问题是项目里最常见的玄学而 virtualenv 就是一道隔离墙。Flask 2.x 和 3.x 的 API 大体一致但如果你在系统全局环境里装过其他框架的依赖很容易出现版本互相覆盖的情况。python3 -m venv venv source venv/bin/activate # Windows 是 venv\Scripts\activate pip install flask flask-sqlalchemy python-dotenv pip freeze requirements.txt这条命令跑完后requirements.txt 里会把 Flask 和 SQLAlchemy 的精确版本记下来。之后在任何一台机器上执行 pip install -r requirements.txt就能还原出几乎一致的环境。这里有一个我在真实项目中碰到的坑sqlite 和 Flask-SQLAlchemy 组合在 3.x 里部分接口行为有调整比如 db.engine 的创建方式直接按旧教程敲有时会报错。所以依赖版本一定要锁别偷懒用 号。从工厂函数开始写入口才能保持干净# app/__init__.py from flask import Flask from flask_sqlalchemy import SQLAlchemy db SQLAlchemy() def create_app(): app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] sqlite:/// app.instance_path /blog.db app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False db.init_app(app) # 注册路由蓝图 from .views import bp as main_bp app.register_blueprint(main_bp) # 启动时自动建表 with app.app_context(): db.create_all() return app这段代码的关键点有两个一是 SQLALCHEMY_DATABASE_URI 用了 instance 目录这样数据库不会和代码文件混在一起备份时直接拷贝 instance 就够了二是db.init_app(app)是扩展与 app 解耦的标准写法将来要换成 MySQL 连接串改动只在这一行。我见过有人把 db 直接挂在 app 上结果后面做单元测试时无法复用那就是给自己埋坑。create_all() 虽然很简单但它只会创建不存在的表无法检测字段变更所以它只适合起步阶段。后面改模型时你需要迁移工具或手动删表重建这一点心里有数就好。3. 数据模型与 SQLite 选型一张表也能撑起博客的骨架3.1 博客文章表该有哪些字段别一上来就过度设计一个简单博客实际要用的表说实话一张就够。那些做多用户、多角色、评论、点赞的方案已经超出了「简单」这个词的范围。做个人博客时通常就只需要记录标题、别名slug、正文、摘要、分类、发布时间、更新时间。如果后面想加访问统计再补字段就行一开始就把表设计复杂只会让代码量翻倍。我自己的经验是字段的精髓在于「够用 好迁移」。# app/models.py from datetime import datetime from . import db class Post(db.Model): __tablename__ post id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(120), nullableFalse) slug db.Column(db.String(120), uniqueTrue, nullableFalse, indexTrue) content db.Column(db.Text, nullableFalse) excerpt db.Column(db.String(300), default) category db.Column(db.String(50), default默认) created_at db.Column(db.DateTime, defaultdatetime.utcnow) updated_at db.Column(db.DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow) def __repr__(self): return fPost {self.title}这三个列值得解释slug 用于 URL 友好链接比如访问 /post/hello-world 而不是 /post/12既美观又有利于记忆与分享excerpt 是列表页的摘要从正文里截取则更省事但手动填写能控制列表长度category 用字符串而不是单独建表可以避免「为了简单而复杂化」。datetime.utcnow会以 UTC 存取展示时在前端转本地时间这是我在多端访问时总结出的可靠习惯。如果直接用 now 存本地时间将来部署到云端服务器和本地各写一次时区会让你彻底崩溃。3.2 SQLite 到底扛不扛得住一个博客的日常访问很多人担心 SQLite 是不是太玩具真实部署会不会出问题。根据我实际跑过的博客日均几百 UV 的流量 SQLite 完全无压力。个人博客的写入频率极低主要是你自己发文读多写少SQLite 的瓶颈远未到。Flask 默认并发模型在处理这种轻量访问时一次性读取几张表基本没有压力。等到真有一天流量大到 SQLite 撑不住时再把 SQLALCHEMY_DATABASE_URI 改成 mysqlpymysql:// 用户:密码host/dbname模型代码一行都不用动。所以选 SQLite 做起步是正确的默认选择。不过有一点要提醒SQLite 不支持并发写入多个进程同时写库可能触发 database is locked。我的规避方式是部署时用单进程跑 Flask或者把写操作放在同一进程的请求里避免外部脚本和 Web 进程同时写库。4. 把路由和模板串起来从「显示一条假数据」到「读数据库渲染真实文章」4.1 核心路由怎么设计才不需要到处改 URL做博客的路由表面上就是首页列表、文章详情、发布新文章这几条。但这里有个细节值得一开始就定好——用 slug 还是 id 作为文章详情的主键。我推荐 slug因为它在 URL 里可读对搜索和分享都友好。Slug 的生成规则通常是中文标题转拼音或直接允许英文短横线。这里有个坑两个文章 slug 不能重复得像下面这样处理冲突。# app/views.py from flask import Blueprint, render_template, request, redirect, url_for, abort from .models import Post from . import db bp Blueprint(main, __name__) def slugify(text: str) - str: # 简单实现截断、去空格、转小写、不允许特殊字符 allowed set(abcdefghijklmnopqrstuvwxyz0123456789-) slug -.join(text.strip().lower().split()) out [] for ch in slug: if ch in allowed or ch -: out.append(ch) return -.join(.join(out).split(-))[:60]上面的 slugify 只处理英文和数字。中文标题怎么办常见做法是在表单里增加一个「URL 别名」输入框由博主自己填拼音或英文短词。自动转拼音需要引入额外依赖对「简单博客」来说价值不大手动填反而更可控。4.2 首页列表与文章详情分页参数值得花两分钟调好首页展示的是所有文章按时间倒序数量控制在每页 58 条比较舒服。Jinja2 模板里渲染分页导航是几乎所有博客通用的做法而 Flask 的paginate方法正好帮我们省掉了手写 offset 的麻烦。bp.route(/) def index(): page request.args.get(page, 1, typeint) pagination Post.query.order_by(Post.created_at.desc()).paginate(pagepage, per_page5, error_outFalse) posts pagination.items return render_template(index.html, postsposts, paginationpagination)参数说明per_page5表示每页 5 篇error_outFalse表示页码越界时返回空列表而不是 404。前端在 base.html 里加一段分页判断当pagination.has_prev或has_next时显示上一页/下一页链接。这里我还习惯在首页顺手带Post.query.filter_by(categorycategory)的分类筛选以后加菜单不必再动路由。文章详情路由的核心参数校验一定要做好bp.route(/post/string:slug) def post_detail(slug): post Post.query.filter_by(slugslug).first_or_404() return render_template(post.html, postpost)first_or_404()是 Flask-SQLAlchemy 给我们的福利它让文章不存在时自动抛 404省去了手动abort。注意这里我特意用filter_by(slugslug)而不是get_by_id就是强调了 slug 的唯一性价值。4.3 后台发布页提交表单时做好校验别让 Markdown 语法把页面搞崩发布文章是博客的核心操作。简单博客可以不需要单独的后台管理系统直接在后台路由里放一个表单页就够了。bp.route(/admin/new, methods[GET, POST]) def new_post(): if request.method POST: title request.form.get(title, ).strip() slug request.form.get(slug, ).strip() content request.form.get(content, ).strip() category request.form.get(category, 默认).strip() excerpt request.form.get(excerpt, content[:80]).strip() if not title or not content: return render_template(editor.html, error标题和正文不能为空, titletitle, slugslug) if not slug: slug slugify(title) # 检查 slug 是否重复 if Post.query.filter_by(slugslug).first(): return render_template(editor.html, errorURL 别名已存在请换一个, titletitle, contentcontent) post Post(titletitle, slugslug, contentcontent, categorycategory, excerptexcerpt) db.session.add(post) db.session.commit() return redirect(url_for(main.post_detail, slugslug)) return render_template(editor.html, postNone)这段我在团队里总是反复强调的点是校验放在模板之外。有人靠模板里的 hidden 字段防止重复提交但爬虫可以绕过还有人直接在视图里写 if 判断但不返回错误信息用户不知道哪里没填。我的习惯是校验失败时把已填写的内容回显并且附带具体错误提示这样用户改一次就能过。editor.html 模板不复杂一个标题输入框一个 slug 输入框一个正文 textarea再来一个分类输入框。textarea 里塞 content 时要用{{ post.content if post else }}而不是直接写死这样出错回显时才不会丢稿。在建表后的首次使用时还有一个我必然会补的逻辑创建文章后立刻跳转到详情页这样你能第一时间确认 Markdown 渲染是否正确避免「保存成功了但页面是空白」的尴尬。5. Markdown 渲染与界面细节让文章真正「看得下去」5.1 为什么博客内容必须用 Markdown纯文本和富文本各有各的坑个人博客本质上是个写作工具写作体验直接决定你坚持得下去还是三分钟热度。纯文本框是够简单但标题、列表、代码块全靠肉眼分辨长文章会非常痛苦。富文本编辑器粘贴时经常带上一堆乱七八糟的内联样式最后页面风格彻底崩盘。Markdown 的好处是纯文本可备份、可 diff渲染结果稳定还不受编辑器平台绑架。我在本地写完直接粘到 textarea甚至用脚本批量导入历史文章都是顺畅的。就个人博客而言Markdown 是当前最稳的选择。后端渲染时我这里使用markdown库# app/views.py 里加一个模板过滤器 import markdown as md bp.app_template_filter(markdown) def markdown_filter(text: str): return md.markdown( text, extensions[extra, fenced_code, tables, codehilite, nl2br], output_formathtml5 )参数说明extra包含删除线、脚注、定义列表等常用扩展fenced_code让三引号代码块解析tables支持 GitHub 风格表格codehilite配合 CSS 高亮。模板里使用如下div classpost-content {{ post.content | markdown | safe }} /divsafe是个双刃剑。markdown 库默认会把原始 HTML 按不安全输入输出如果你文章由管理员自己写、没有开放评论问题不大如果以后接入了用户生成内容这个safe必须去掉或者用bleach库过滤掉 script 标签再做白名单否则就是 XSS 漏洞入口。先记住这一个结论个人博客自己写自己看safe 没问题一旦开放多人编辑马上撤掉。5.2 阅读体验的细节正文宽度、代码块、目录跳转Flask 的模板继承让统一的阅读样式变得简单。base.html 里放导航和容器post.html 里只要写内容区域即可。正文宽度我一般控制在 760px 左右max-width: 760px; margin: 0 auto; padding: 20px;这个宽度对中文阅读是最舒服的。太宽会读串行太窄换行频繁。代码块要有背景色和横向滚动中文正文和代码块之间加一点 margin否则连在一起视觉上是灾难。另外值得做的一个小功能是给 Markdown 渲染后的标题自动生成锚点然后模板里加一个「目录」抽屉。extra扩展默认会给 h2/h3 生成 id 吗不会需要开启toc扩展。如果不想碰扩展的细节可以在 markdown 里给extra追加tocmd.markdown(text, extensions[extra, fenced_code, tables, codehilite, toc])加了 toc 扩展后渲染出来的[TOC]占位符会自动变成目录列表。这个功能对长文章的价值是立竿见影的读者能快速跳到目标章节。不要觉得这是锦上添花真实写作到了第 15 篇文章以后你就能体会到目录对体验的影响有多大。5.3 时间显示与本地时区转换的小细节SQLite 里存的datetime.utcnow保存的是零时区时间。直接显示在页面上国内用户看到的会比本地时间晚 8 小时这是一个很影响观感的细节。处理方案是在模板过滤器里做转换from datetime import timezone, timedelta bp.app_template_filter(localtime) def localtime_filter(dt): if dt is None: return local dt.replace(tzinfotimezone.utc).astimezone(timezone(timedelta(hours8))) return local.strftime(%Y-%m-%d %H:%M)上面把 utc 转为东八区时间这段代码仅适合固定在国内时区的博客。如果部署的服务器区域不确定更稳妥的方案是让浏览器端用 JavaScript 通过 getTimezoneOffset 做转换把时间格式还给前端掌控。我自己倾向用 UTC 存储、前端展示时转换这样代码放到任何区域的机器上都不会因为环境差异导致时间错乱。6. 避坑指南Flask 博客遇到的 5 个高频翻车现场与排查路径6.1 模板继承导致 CSS 找不到页面「裸奔」现象首页能显示纯文本内容但没有样式浏览器控制台显示 static/style.css 404。原因Jinja2 模板里写了link relstylesheet href/static/bootstrap.css但 Flask 的静态文件默认挂载在/static/并且文件必须位于 app/static 下。如果你把模板放在项目根目录的 templates 而静态目录忘记放在正确位置URL 会 404。解决确认文件结构为 app/static/style.css模板用url_for(static, filenamestyle.css)生成路径。一定不要硬编码 /static/ 前缀因为在部署到子路径时硬编码会再次失效。6.2 slug 重复导致数据库无法写入唯一索引现象发布第二篇标题一样的文章表单提交后报了IntegrityError重定向丢失。原因slug 字段有 uniqueTrue但你在视图里没有做重复检查或者检查逻辑放在了db.session.commit()之后。SQLite 的完整约束在 commit 时才触发检查所以只在内存里查是一次假成功。解决在 commit 前先Post.query.filter_by(slugform_slug).first()判断存在则提示修改。如果线上环境已有脏数据导致索引建立失败先删掉重复数据再重建索引。这一点在本地跑通后也要同步给团队否则协作时同样会踩到。6.3 代码块不换行页面整体被撑破现象Markdown 里粘贴一段含超长 URL 或 JSON 的代码块渲染出来整个页面横向滚动条出现布局乱套。原因默认 HTML 的 pre 元素不会自动换行长字符串会把容器宽度撑破。解决在 CSS 里对 pre 或 code 增加pre { overflow-x: auto; white-space: pre; word-break: normal; }这里的overflow-x: auto让代码块内可横向滚动white-space: pre保留缩进与空格。要留心的是不要用pre-wrap它会破坏代码缩进。我习惯在开发预览时黏贴一次长行代码强制验证这条规则。6.4 表单重复提交导致数据库出现一模一样的空文章现象提交文章时网络较慢用户连续点了两次按钮出现两条内容完全相同的记录。原因没有做重复检测也没用重定向刷新模式。提交成功后的 POST 响应如果直接返回模板而不是redirect浏览器刷新时会重发 POST 请求。解决提交成功统一走redirect(url_for(...))这是 Post/Redirect/Get 模式的精髓。配合 slug 唯一约束做兜底后基本杜绝了这个坑。如果你未来接入编辑器自动保存还需要在接口层用请求时间戳做防抖。6.5 迁移到云服务器后少量访问 500 错误日志却看不到 traceback现象本地开发时一切正常部署到服务器偶尔 500日志里只有一行「Internal Server Error」没有具体堆栈。原因Flask 默认只在调试模式打印全量 traceback生产模式下 debugFalse 后错误被吞掉没有登记到日志。解决设置app.config[PROPAGATE_EXCEPTIONS]或者加一个 errorhandler 把异常记录到文件import logging if not app.debug: stream_handler logging.StreamHandler() app.logger.addHandler(stream_handler) app.logger.setLevel(logging.WARNING)上面这段加在 create_app 末尾即可。排查时看到日志里出现 ValueError、KeyError 的概率很高最典型是模板里访问了一个 form 变量但视图某次没有传递没有日志这些几乎不可能定位到原因。这个追加日志的习惯我第一次在部署环境里就返回了巨大的复用价值否则半夜被报警找半天都不知道错在哪。7. 部署与后续优化从本地跑通到可持续写作值得补的几个小技巧如果你只是本地写着玩跑python manage.py runserver就够了。但如果认真对待这个博客部署后有几个点我强烈建议补上第一在 nginx 或 Caddy 后面用 gunicorn 启动让它用多 worker 模式运行第二把 session 密钥从代码里抽到环境变量避免密钥泄露导致会话伪造问题第三写一个简单的备份脚本把 instance/blog.db 定时打包到云存储或本地磁盘。数据库文件本身不过几十 KB每天备份一次的成本几乎为零但能避免「服务器到期数据全部蒸发」的惨剧。我个人遇到过因为忽略备份导致全部文章丢失的翻车现场那是唯一一次让我认识到个人博客的核心资产是内容不是代码。部署时还有一个值得做的功能是给文章加阅读计数。有人觉得要引入 Redis 或加个计数表其实用 SQLite 就能做到。在 Post 模型里加一个views整数列详情页渲染时post.views 1并提交。不过要注意每次刷新都会累加要防止刷流量。简单方案是在前端用 sessionStorage 记录当前用户会话里是否访问过该文章后端只接收入参来更新。这样每日几十 UV 的访问量下完全够用不会过度设计。最后一个常用技巧是把「摘要」自动生成改为手动控制并且让摘要支持 Markdown 语法。编辑页里加一个「摘要」输入框未填时自动从正文前 100 字截断。这样列表页可以有漂亮的摘要而不是一篇篇全文字段的堆砌。未来想加 RSS 输出只需在 routes 里加一个响应 XML 的视图想做站内搜索用LIKE %keyword%查询足以应对几百篇文章的体量不必上全文索引。我的习惯是每一步都小步走一个新功能能在一小时内完成并验证就坚决不憋大招。按这个节奏博客才会越用越顺手而不是变成一个处处要维护、越来越不想碰的「半成品项目」。最后想再提醒一句部署前至少把 gunicorn、SQLite 权限、静态文件缓存这三个点过一遍这三个地方往往是新手走在本地正常、上线翻车的重灾区。把备份脚本做好文章写完就顺手提交一次哪怕哪天服务器出问题你仍能快速把全部数据恢复起来继续写。希望帮到你。本文还有配套的精品资源点击获取