Flask+Vue个人博客关注系统:数据建模与动态流设计实践
我做这套Python Flask个人博客分享关注系统-Vue动机特别直接过去两年我为了不同写作场景折腾过好几个轻量站点每次都用Flask和Vue重新搭一套壳子但始终没有把“用户关注”这条主线做完整。后来干脆逼自己集中做一个能发布内容、能关注博主、能看关注动态流的系统才把这类项目里真正有意思的部分——关系数据建模和动态流设计——彻底踩了一遍。这个项目并不是大而全的SNS而是一个在你掌控下的轻量个人博客分享关注系统后端用Flask提供API前端用Vue写交互数据库一接本地就能跑起来。适合想弄懂“如何把一个个人博客升级成带关注关系的体系”的人也适合想用Flask和Vue完整练一遍前后端协作的开发者。下面我会从设计取舍讲到数据模型、接口、前端联调、部署再把我在这个项目里踩过的坑一并挑出来讲。1. 设计目标决定技术选型Flask Vue 的搭配到底图什么1.1 你这套系统到底要解决什么问题做项目最忌讳一上来就写代码。我先把需求压缩成三句话用户能注册登录维护自己的基本资料。用户能发布文字或图文内容也能收藏、分享喜欢的内容。用户能关注其他博主关注后能在首页按时间顺序看到他们发布的内容。听起来简单真动手以后你会发现难度都藏在第三句话里。“关注”不是加一张表就算完它牵扯到数据一致性、查询性能、前端状态同步。如果你一开始只把关注当成“多一个按钮”后面做动态流和通知时大概率要推翻重来。1.2 为什么不用DjangoFlask更适合这种规模我不是说Django不好。Django自带Admin后台、ORM、认证体系团队开发大项目非常高效。但个人博客分享关注系统核心交互不算复杂页面也不多你需要的是“小而清楚”的骨架。Flask的优点是边界明确你写的每个路由、每个装饰器都能看明白出了问题翻代码更容易。配合SQLAlchemy做ORM数据库层的灵活度也在。部署也是现实因素。Flask应用可以只用一个Gunicorn进程扛住个人站的访问量资源占用远低于Django。既然这个项目的目标之一就是“轻量、可控、能快速部署”Flask自然比Django更合适。如果你的用户量未来涨到需要分布式缓存、异步任务队列Flask也能通过蓝图和扩展逐渐组织起来而不是一上来就被框架绑架。1.3 Vue在关注系统中的真实价值有人会说Flask直接用它自带的Jinja2模板渲染不也很快吗确实能跑通但关注系统的交互复杂度摆在那里关注按钮状态、动态流的下拉刷新、通知的小红点、路由切换时用户态的保持。用服务端模板实现这些每次点击都要刷新页面或写一堆前端模板逻辑维护成本很高。Vue的价值是把“状态”和“界面”分开管。用户是否关注了某个博主是一个状态这个状态会影响关注按钮的样式、动态流里是否展示推荐卡片。Vue的响应式机制让你只更新状态界面自动跟着变。配合Vue Router管理页面跳转配合Pinia管理登录态和通知数据整个前端的复杂度被压得非常低。这也是我选择Vue 3而不是Vue 2的原因Vue 3的组合式API写业务逻辑更集中新项目没有任何理由再回到Options API的写法。2. 数据模型设计把关注关系表设计对了后面能少写一半代码2.1 User模型登录字段和博主资料分开想用户信息不要一股脑塞进一张表。我的User模型里分了三块认证字段username、password_hash、基础资料nickname、avatar_url、bio、统计缓存post_count、follower_count。后两块可以分开也可以先用同一张表省事。把统计字段直接冗余在User表里看起来违反规范化但实际效果很好。关注列表页面经常要显示“多少人关注了TA”这些数字如果每次都COUNT(*)去查数据量大了之后查询会越来越慢。冗余字段的代价是更新时要多写一点逻辑每次新增关注和取消关注都要对两边的follower_count做加减。我建议用事务包裹避免加减过程中出现不一致。class User(Base): __tablename__ users id Column(Integer, primary_keyTrue) username Column(String(64), uniqueTrue, nullableFalse, indexTrue) password_hash Column(String(128), nullableFalse) nickname Column(String(64), nullableFalse, default) avatar_url Column(String(256), default) bio Column(Text, default) post_count Column(Integer, default0) follower_count Column(Integer, default0) following_count Column(Integer, default0) created_at Column(DateTime, server_defaultfunc.now())2.2 Post模型发布内容时提前预留扩展位文章表本身不难但有几个字段很容易被忽略。一个是content_type用来区分纯文字、图文或链接分享否则以后想加内容形态就要改表。另一个是summary在列表页展示摘要时很需要不可能把整篇正文都拉出来渲染。还有is_published个人博客的关注关系里博主经常会先存草稿确认后再发布这个字段可以避免后续的权限判断到处漏风。class Post(Base): __tablename__ posts id Column(Integer, primary_keyTrue) author_id Column(Integer, ForeignKey(users.id), nullableFalse, indexTrue) title Column(String(128), nullableFalse) content Column(Text, nullableFalse) summary Column(String(256), default) content_type Column(String(16), defaulttext) is_published Column(Boolean, defaultTrue) created_at Column(DateTime, server_defaultfunc.now()) updated_at Column(DateTime, server_defaultfunc.now(), onupdatefunc.now())特别要注意author_id上的索引。动态流查询的核心路径是“根据关注关系查文章”如果没有索引用户关注了几百个博主之后join查询会明显变慢。2.3 关注关系用中间表比想象中更需要注意约束关注关系是典型的多对多关系。我设计了一张follows表只有四个字段id、follower_id、followed_id、created_at。看起来简单但这里有两个坑。第一个坑是“一个人不能重复关注另一个人”。一定要在数据库层面加唯一约束不能只靠代码判断。否则接口被重复点击、并发请求时很容易插入两行一模一样的关注记录。加了UniqueConstraint(follower_id, followed_id)以后重复插入会抛异常代码里再捕获并做幂等处理。第二个坑是“不能关注自己”。你可以在接口层判断但更稳妥的做法是在数据模型加CheckConstraint(follower_id ! followed_id)。我见过不少项目在逻辑层漏掉这个校验结果用户自己出现在自己的关注列表里动态流里看到自己的旧文章体验很怪。class Follow(Base): __tablename__ follows __table_args__ ( UniqueConstraint(follower_id, followed_id, nameuniq_follow), CheckConstraint(follower_id ! followed_id, namecheck_not_self_follow), ) id Column(Integer, primary_keyTrue) follower_id Column(Integer, ForeignKey(users.id), nullableFalse, indexTrue) followed_id Column(Integer, ForeignKey(users.id), nullableFalse, indexTrue) created_at Column(DateTime, server_defaultfunc.now())2.4 一点关于收藏功能的设计经验分享关注系统里博主经常还希望有“收藏”或者“点赞”功能。我的建议是如果时间紧第一版可以只做“喜欢”或“收藏”其中一种不要贪多。我用了一张favorites表结构与follows类似但它关联的是user_id和post_id。收藏和关注是两类完全不同的关系不要复用同一张表。混在同一张表里会让查询条件变得很别扭将来做数据统计的时候也会想骂人。class Favorite(Base): __tablename__ favorites __table_args__ ( UniqueConstraint(user_id, post_id, nameuniq_favorite), ) id Column(Integer, primary_keyTrue) user_id Column(Integer, ForeignKey(users.id), nullableFalse, indexTrue) post_id Column(Integer, ForeignKey(posts.id), nullableFalse, indexTrue) created_at Column(DateTime, server_defaultfunc.now())3. 后端接口实现关注、动态流和通知这三块要一起做3.1 关注与取关接口先把幂等性写好关注接口的伪代码逻辑并不长先确认目标用户存在、再检查是否已关注、没有则插入最后更新双方统计字段。但我建议把“检查已关注”和“插入关注”放到一个事务里并且用唯一约束兜底。这样即使极端情况下两个请求同时进来数据库也会拒绝重复接口层捕获异常后直接返回当前状态即可。取关接口同理。取关时不要因为“取关失败”就把整件事回滚掉。我的做法是先做删除操作再统计是否真的删掉了一行如果删掉的行数是0说明本身就没有关注关系直接返回成功。这样做的好处是接口天然具备幂等性前端不管点几下按钮结果都是最终一致的。api_bp.post(/follow/int:user_id) login_required def follow_user(user_id): if user_id current_user.id: raise ApiError(不能关注自己) followed db.session.get(User, user_id) if not followed: raise ApiError(用户不存在, status_code404) db.session.execute( insert(Follow) .values(follower_idcurrent_user.id, followed_iduser_id) .prefix_with(OR IGNORE) ) # 用ORM自身的冲突忽略也可以这里展示SQLite适配的写法 followed.follower_count 1 current_user.following_count 1 db.session.commit() return {ok: True, following: True}3.2 动态流查询90%的性能问题都出在N1查询“首页动态流”是关注系统的核心。用户关注了很多人以后打开首页要看到这些博主发布的最新内容。最笨的做法是查出所有关注用户的ID然后循环查询每个用户的文章。用户关注了200个博主就会执行200次查询数据库再强也经不住这么折腾。我在这个项目里选择用一次JOIN查询再配合SQLAlchemy的selectinload把文章作者一并加载。这样数据库只需要执行两条SQL一条查动态流一条批量加载作者信息。stmt ( select(Post) .join(Follow, Follow.followed_id Post.author_id) .where(Follow.follower_id current_user.id) .where(Post.is_published.is_(True)) .order_by(Post.created_at.desc()) .limit(20) .options(selectinload(Post.author)) ) posts db.session.execute(stmt).scalars().all()很多读者看到这里会疑惑为什么selectinload能避免N1它的原理是SQLAlchemy先查出文章列表然后收集所有文章的author_id用WHERE id IN (...)一条语句查出所有作者再在内存里完成映射。无论动态流里有10条还是20条文章作者查询都只要一次。3.3 通知机制关注后的小红点不要铺开做关注系统还有一个容易被忽略的模块通知。别人关注了你、你关注的人发布了新文章这些信息需要轻量地通知到用户。第一版我不建议做WebSocket实时推送先用最稳的“查询式通知”就够了。我给通知表设计为简单的四个字段user_id被通知人、actor_id触发人、typefollow、new_post、favorite、post_id可选关联、is_read、created_at。用户每次刷新页面时前端调一个未读通知接口拿到未读数量。这样实现成本低体验也够用。不过要注意新文章通知不要每次都发。假设博主一次性发布了5篇文章粉丝刷新动态就已经能看到如果还发5条通知用户会很烦。我对“新文章”类型的通知做了去重策略同一个作者在24小时内只发一条通知文案是“XX发布了新文章”。如果后续要精细化再考虑聚合通知。3.4 给Flask接口统一返回格式前端不用做三套解析写接口时最怕前端开发者拿着五花八门的返回结构来联调。我在Flask蓝图里统一了返回格式成功时是{code: 0, data: ...}失败时是{code: 400, message: 具体错误}。前端根据自己的业务错误码做提示而不是看HTTP状态码。文件上传、分页查询这类接口也遵循同一个格式。def api_result(dataNone, code0, messageok): return {code: code, data: data, message: message} def api_error(message, code400, http_status400): return jsonify({code: code, message: message}), http_status4. Vue前端工程化路由、状态管理与关注按钮的实现思路4.1 安装与目录结构推荐我最初搭建这套系统时用的是npm create vuelatest创建的时候选择了Vue Router和Pinia。很多人喜欢手动从零搭Vite配置我觉得没必要官方脚手架生成的结构已经足够清晰。项目中我会把页面组件、API请求和路由分层放src/api/放所有请求方法按模块拆文件。src/stores/放Pinia的状态模块。src/views/放页面级组件。src/components/放复用组件比如博主卡片、文章列表项、关注按钮。为什么特意强调分开因为在关注系统里一个“关注按钮”可能在三个地方出现博主主页、文章卡片底部、推荐关注列表。如果每个页面都写一遍相同的关注逻辑以后改状态同步会很痛苦。把它封装成一个组件内部通过Pinia读取当前登录态通过API请求后端并用本地状态控制按钮样式任何页面都能直接用。4.2 Pinia状态管理登录态和未读通知一次搞定关注系统的前端状态核心有两块当前用户信息、未读通知数量。这两块几乎在每次页面跳转、每个接口返回时都可能被用到我不建议把它们分散在单个组件里。我用defineStore定义了一个useUserStorestate里维护token和profileactions里写login、logout、fetchProfile。token持久化到localStorage页面刷新后重新从localStorage恢复。每次页面路由切换时路由守卫判断是否有token如果有但本地没有用户信息就先调/api/user/profile拉取一次。这套逻辑只要写一次所有页面都能共享。export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , profile: null, unreadCount: 0, }), actions: { async login(payload: { username: string; password: string }) { const res await api.post(/auth/login, payload) this.token res.data.token localStorage.setItem(token, this.token) await this.fetchProfile() }, async fetchProfile() { const res await api.get(/user/profile) this.profile res.data }, async refreshUnread() { const res await api.get(/notifications/unread-count) this.unreadCount res.data.count }, }, })4.3 路由守卫未登录用户别再往里闯动态流、个人主页、编辑文章这些页面必须登录后才能访问。Vue Router的beforeEach里我会读取目标路由的meta.requiresAuth再结合Pinia里的token做判断。未登录时跳转到登录页并且把redirect参数带上登录后能返回原来的页面。router.beforeEach(async (to) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.token) { return { name: login, query: { redirect: to.fullPath } } } })这段逻辑里有一个新手常犯的错误以为token存在就代表登录有效。实际上token在你的Flask后端可能已经过期了。真正严谨的做法是让前端请求个人资料接口如果返回401就清理本地token并跳到登录页。我会在Axios拦截器里统一处理401而不是在每个请求方法里重复写判断。api.interceptors.response.use( (response) response, (error) { if (error.response?.status 401) { localStorage.removeItem(token) router.push({ name: login }) } return Promise.reject(error) } )4.4 关注按钮、文章卡片和时间线的组件拆分组件拆分的标准我总结得很简单一个组件只解决一个状态问题。关注按钮的组件设计关键点是“根据是否已关注切换样式”内部用一个isFollowing状态初始化点击后调用接口并对状态取反。值得提醒的是点击关注按钮时要把按钮禁用一段时间防止用户连续点击造成接口重复请求。我自己的做法是点击后立刻置为pending状态接口成功后再更新状态失败时恢复原状。文章卡片组件接收一个post对象展示标题、摘要、作者昵称、发布时间。卡片底部放关注按钮和收藏按钮。这样动态流页面只需要循环渲染一组卡片博文详情页也能复用同一个卡片只是点击行为不同。时间线页面不建议把整个文章内容渲染出来列表只展示摘要详情进单独页面性能会好很多。5. 联调与部署前后端分离后最容易踩的低级失误5.1 开发环境Vite代理与CORS怎么配合开发环境下Vue跑在5173端口Flask跑在5000端口两者端口不同必然产生跨域问题。很多教程会让你在Flask端装flask-cors并配置允许跨域开发阶段这样确实能用。但我要提一个更推荐的做法Vite配置server.proxy把/api前缀的请求代理到http://localhost:5000。这样浏览器看到的请求是同域的后端开发时不必处理CORS部署到生产环境时也更干净。server: { proxy: { /api: { target: http://localhost:5000, changeOrigin: true, }, }, },如果坚持要开CORS记得flask-cors里的origins不要写成*否则携带登录态的请求会被浏览器拦截。生产环境启用CORS时origins应该写成线上域名。5.2 生产部署用Gunicorn还是直接让Flask托管dist个人博客系统的部署方案我建议遵循“能简单就不要复杂”。如果服务器上已经存在Nginx那就用Nginx托管Vue构建出来的dist目录同时把/api开头的请求反向代理给Flask。如果你实在不想碰NginxFlask也可以直接托管构建目录做法是把dist复制到Flask项目的static目录再增加一个通配路由返回首页的HTML。对于展示型博客这种方式完全够用。main_bp.route(/, defaults{path: }) main_bp.route(/path:path) def frontend(path): if path and not path.startswith(api): return send_from_directory(dist, index.html)不过这里有个大坑。如果你的Vue路由用的是HTML5 History模式也就是访问/blog/1这种没有#的路径刷新页面时Nginx会去查找名为blog/1的文件找不到就报404。解决方案是配置Nginx时让所有非静态资源的请求都回退到index.html。在Vue官方文档里这个叫history fallback实际部署时非常容易漏。location / { try_files $uri $uri/ /index.html; }如果看到这里你觉得麻烦也可以考虑改用Vue Router的Hash模式。地址栏会带#样子难看一点但部署时完全不需要回退配置。我自己为了地址好看用了History模式同时把Nginx的花括号写错好几次才反应过来这个教训记到现在。5.3 静态资源路径和构建缓存引起的样式丢失另一个常见问题是Vue构建后CSS和JS文件路径不对。Vite默认的base是/如果你把dist部署在域名根目录通常没问题。但如果你放在某个子路径下比如https://example.com/blog/就一定要把base改成/blog/否则浏览器会到根目录去找资源样式全部丢失。还有一个小细节每当Vue构建出新版本后建议在Nginx层加上静态文件的缓存策略。文件名带hash的js/css可以设置较长的缓存时间index.html则设置成不缓存或短缓存这样才能保证用户访问时能拿到最新的HTML入口而不是拿着旧页面加载老资源。我有一段时间没加这个配置每次发布后总有用户反馈页面打不开原因就是浏览器缓存了旧的index.html。5.4 关于Electron打包Vue项目的想法不少人在做完博客系统后会动念头能不能用Electron把它打包成桌面应用我可以明确说Electron的Vue集成和主渲染进程通信是可行的但这和本项目的前后端分离没有直接关系。Electron只是给你套了一个壳Vue代码还是那个Vue代码中间多出来的只是主进程和渲染进程之间的IPC通信比如收发通知、保存本地文件。对一个已经有网页版可以访问的系统来说我觉得没必要为了桌面而桌面——如果你只是想本地常驻套个PWA可能更合适。6. 从源码出发的二次开发建议怎么把关注系统扩展成真正可用的社区6.1 动态流分页与性能优化方向当前这套系统的动态流是倒序取20条如果文章数量继续增多分页是必须做的。最简单的分页是offset加limit但大偏移量下性能会变差。更合理的做法是用“上次看到的文章ID”作为游标下一次查询时只取小于该ID的记录。我后来把动态流接口的返回结构改成了next_cursor加items这样前端滚动加载时会很顺滑。在Flask端游标查询的写法大概是cursor request.args.get(cursor, typeint) stmt select(Post).join(Follow, Follow.followed_id Post.author_id) stmt stmt.where(Follow.follower_id current_user.id) if cursor: stmt stmt.where(Post.id cursor) stmt stmt.order_by(Post.id.desc()).limit(20)6.2 推荐关注功能的合理切入口有“关注”就应该有“发现”。我的做法是给首页增加了一个推荐关注列表推荐逻辑不复杂先找出当前用户关注的人再找出这些人关注的其他博主去掉已关注用户后按被关注次数排序取前五名展示。这种“二度人脉”的推荐在中小系统里效果尚可而且查询不需要引入图数据库用两三次SQL就能完成。recommend_stmt ( select(User) .join(Follow, Follow.followed_id User.id) .where(Follow.follower_id.in_( select(Follow.followed_id).where(Follow.follower_id current_user.id) )) .where(User.id ! current_user.id) .where(~User.id.in_( select(Follow.followed_id).where(Follow.follower_id current_user.id) )) .group_by(User.id) .order_by(func.count().desc()) .limit(5) )这类查询如果数据量很大性能依然一般但个人博客系统用户规模有限完全够用。如果你想优化可以在关注表之外维护一个follower_count的缓存字段推荐时直接按这个字段排序省去每次COUNT的开销。6.3 测试和调试后端逻辑可以用pytest锁定关注系统最怕未来改需求时把关注关系搞坏。我建议给后端补一套pytest测试至少覆盖四类场景注册登录、关注用户、取关用户、动态流只展示已关注博主的内容。测试数据库用SQLite内存模式跑起来非常快。我只写了十几条用例最后改数据库字段时心里踏实很多。def test_follow_and_get_feed(client, token_a, token_b): # A关注B client.post(/api/follow/2, headers{Authorization: fBearer {token_a}}) # B发布一篇文章 client.post(/api/posts, json{title: hello, content: world}, headers{Authorization: fBearer {token_b}}) # A的动态流里应该能看到这篇文章 resp client.get(/api/feed, headers{Authorization: fBearer {token_a}}) assert resp.json[data][items][0][title] hello6.4 后续可扩展的模块关注系统做完以后你会自然发现可以继续加什么。收藏列表页已经可以实现后续可以做“博主专栏”和“文章标签屏蔽”。标签屏蔽是个有趣的需求某些用户不想在动态流看到特定话题的文章这需要给Post加标签字段并在feed查询时过滤掉用户设置的屏蔽标签。这个功能我用一个user_blocked_tags表就实现了但第一版没必要做先把核心的关注和动态流跑稳了再说。最后分享一个我在这个项目中反复体会到的经验任何一个“看起来很小”的关注功能都值得从数据模型开始认真设计。表结构决定了你后面的接口复杂度和前端代码量。只要在User、Post、Follow这几张表上多花时间把唯一约束、索引、冗余统计字段都补全后面的开发会顺利得多。如果你正准备做自己的FlaskVue项目可以先把关注系统的模型画清楚代码反而没那么重要。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →