从哈利波特个人项目到全栈实践:技术选型与数据建模复盘

从哈利波特个人项目到全栈实践:技术选型与数据建模复盘 说实话第一次看到harrypotter09-2这个项目代号的时候我愣了好几秒。第一反应是这谁起的名字怎么读都像某个随手敲的文件夹名。但等我仔细把09-2和《哈利·波特》里那个著名的九又四分之三站台对上号之后反而觉得这个名字意外地贴切——一个连接现实和魔法世界的入口用在一个面向哈迷个人项目的代号上确实比my-hp-project-v2之类的东西有记忆点太多。这篇文章不打算写那种手把手教你做一个哈利·波特网站的教程体而是想把这几个月里我从定代号、选产品方向、搭数据、写核心功能到踩坑上线的完整过程拆给你看。整个项目做下来技术难度说实话不算高真正花时间的其实是想清楚哈迷到底会为什么功能打开这个页面以及数据怎么组织才不会在功能越加越多的时候崩掉。不管你是想做一个同主题的练手项目还是想在个人项目里找到一套更顺手的全栈组织方式这篇复盘里应该都有你能直接拿走的东西。1. 09-2不是版本号这个代号里的九又四分之三站台隐喻先说清楚这个命名的事因为后面所有的产品和技术决策都是从我到底想做一个什么东西这个源头推导出来的。1.1 代号里藏的两层含义harrypotter09-2拆开来其实是三段信息。harrypotter不用解释就是主题09一方面对应站台数字 9另一方面是我自己个人项目的第 9 个编号后面的-2才是真正有意思的部分——这不是第二个版本而是这个主题的第二次重写。我第一次做哈利·波特相关项目是大概两年前当时纯前端做了一个只有咒语列表的静态页面。做完之后自己都不想打开原因是数据又少又乱而且完全不知道用户拿它来干嘛。后来我把那个项目归档重新思考这个主题应该长什么样于是就有了harrypotter09-2。很多人给项目命名的时候喜欢用v2、final、final_v3这种后缀但如果你和我一样会把作品放到公开仓库或个人作品集里建议多用这种有意义代号 序号的命名方式。它像一个身份标识也能让你在看到这个目录名的时候立刻想起当时做它的动机。1.2 从隐喻到产品形态既然代号里带了9¾站台那产品形态自然也得说得通。我最后做的不是一个简单咒语查询而是一个魔法世界档案库——包含四块内容咒语检索、人物关系图谱、魔法书籍清单、分院帽趣味测验。为什么要做这四个很简单这是哈迷聊天时最常提到的四类话题。我看过不少 HP 粉丝群里的聊天记录高频出现的问题大概是这三类那个漂浮咒叫什么来着、小天狼星和哈利到底是什么亲戚关系、你们觉得我该被分到哪个学院。围绕这些真实使用场景去设计产品功能比凭空想我要做个哈利波特大合集靠谱得多。这个项目最终是前后端分离的React TypeScript 前端Node.js Express 后端SQLite 数据库存数据部署在一台低配云服务器上。整套东西不复杂但每个环节都有值得展开讲的决策过程。2. 产品定位先想清楚哈迷需要什么再开始写代码这一节我想聊一个经常被忽略的问题技术选型之前产品定位必须足够清晰。很多个人项目烂尾不是技术不行而是从一开始就没想明白给谁用、用来干嘛。2.1 三类用户画像和一个首要场景我把目标用户粗略分成三类补课型哈迷电影看过但原著细节记不清需要快速查人物关系和咒语背景。考据型哈迷对咒语起源、人物血缘、时间线有考据需求愿意反复翻细节。娱乐型哈迷想玩分院测试做完后分享到社交平台的。这三类画像决定了功能优先级。补课型最需要的是搜索和关系图谱考据型需要准确且结构化的数据娱乐型则需要一个互动感强的测验。所以我决定第一版就做咒语库 人物关系图谱 分院测验这三个核心模块魔法书籍清单作为补充性内容。2.2 市面上的哈迷工具缺什么我先看了已有的同类站点和开源项目。一个典型问题是什么都有但什么都不深入——列表页堆了成百条咒语但每条就一行描述没有发音、没有分类、没有出处。另一个问题是人物关系图谱几乎没人做好大多数只是静态的列表谁和谁是师生、谁和谁有血缘在页面上完全看不出来。所以我的产品差异点就很明确了数据和关系是核心视觉效果排在后面。与其做一个炫酷但空空荡荡的页面不如先把数据做扎实。2.3 MVP 范围划定第一版必须做的功能咒语库列表 搜索 分类筛选。人物详情页 人物关系图谱。分院测验10 道题输出四个学院的匹配度。魔法书籍简易列表。坚决不做的功能用户系统、评论留言、论坛、多语言。这些会直接拖垮开发节奏而且对第一版验证核心价值没有帮助。当时好友劝我加个用户收藏功能我拒绝了理由很简单用户在完成测验之前根本不需要收藏任何东西。3. 技术选型轻量全栈组合避开重型框架的过度设计技术栈这件事上我吃过不少亏。以前做项目特别喜欢一步到位上来就 Next.js Prisma PostgreSQL Tailwind结果往往是环境配置折腾了一周业务代码一行没写。这次我刻意做了减法每选一个技术都要能回答为什么不选另一个。3.1 前端React Vite 而不是 Next.js很多人会问为什么不用 Next.js以这个项目的内容型产品属性Next.js 其实非常合适SEO 友好、可以服务端渲染。但我的场景有个特点核心交互是图谱和测验客户端渲染本身足够且我对数据的安全性要求不高不需要复杂的 SSR 逻辑。Vite 的启动速度、热更新体验在这个项目里更占优势。我选 React 而不是 Vue纯粹是因为我对 React 的状态管理和生态更熟悉——我写过 Vue但 Vue 的细粒度响应式让我在脑子里得多维护一套什么时候该用 ref、什么时候该用 reactive的规则表。React 的 useReducer Context 在这个体量的项目里已经管得服服帖帖。3.2 后端Express 就够别在这层玩出花来后端我用了 Express。你可能会说 Express 太老了——确实但在这个项目里它没有任何瓶颈。数据接口主要有三种拉取咒语列表带过滤参数、拉取人物关系节点、提交测验答案返回结果。这些接口的 QPS 预计在个位数级别用 Fastify 或 Koa 带来的性能提升完全可以忽略不计。选 Express 的真实原因是生态成熟、文档多、遇到问题容易查到解决方案。个人项目里调试效率比框架的 benchmark 分数重要得多。后来我把路由改成了 Fastify 的实验没做下去因为发现真要换还得重新处理中间件兼容收益实在有限。3.3 数据库SQLite 起步预留迁移路径一开始就上 PostgreSQL 是个人项目最常见的过度设计。我的数据量有多大咒语大概 200 条人物 120 个关系边 350 条加上测验题目总共可能就几百 KB 文本数据。SQLite 单文件、零运维、备份就是复制文件对个人项目太友好了。但我做了两个预留未来的设计为万一用户量上来做准备所有数据访问走统一的 DAO数据访问对象层SQL 只写在对应的 repository 文件里不散落在路由中。这样以后从 SQLite 换到 PostgreSQL只需要改 repository 内部实现路由不动。外键关系在 SQLite 里明明可以关闭约束我还是显式地把所有关系表的外键都建好了。这让我在写 JOIN 查询的时候心里有底不会出现这个表怎么跟那张表连接的困惑。3.4 部署一台云服务器 Nginx 反向代理部署方案选了最朴素的前后端分别构建Nginx 托管前端静态文件并反向代理/api到 Node 进程。没有用 Docker因为项目和依赖一共就两个进程用 systemd 管理 Node 进程比维护一个 Docker Compose 文件简单得多。4. 数据层先行魔法档案的表结构、咒语库与人物关系建模这个项目里最值得复盘的部分其实是数据建模。前端界面写得再好看底层数据结构一团乱功能越多越痛苦。4.1 数据来源与整理方式数据来源主要是公开的百科类站点、电影台词和原著章节的公开摘要信息整理时我给自己定了两条规则一条咒语尽量挂一个真实出处哪部电影、哪一章人物关系不凭印象写要有依据。然后把整理结果存成 JSON 文件最后用脚本导入 SQLite。这里建议所有做同主题项目的朋友数据清洗阶段一定别省特别是别名问题。《哈利·波特》里的人名翻译在不同版本里不一样——赫敏的粤语译名、英文原名甚至Hermione这个发音容易被读错的问题都应该在数据准备阶段处理好。我在人物表里加了一个aliases字段存 JSON 数组搜索时同时匹配原名、常用译名和别名。4.2 咒语表设计咒语表的字段如下CREATE TABLE spells ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, incantation TEXT NOT NULL, pronunciation TEXT, effect TEXT NOT NULL, category TEXT NOT NULL, difficulty TEXT DEFAULT unknown, origin TEXT, UNIQUE(name, incantation) );pronunciation这个字段一开始我完全没想过要加。后来有用户反馈说我知道咒语怎么拼但不知道该怎么读我才意识到这对非英语母语用户是刚需。加了音标之后收藏率明显提升。UNIQUE(name, incantation)的约束也很有用——整理数据时很容易把同一个咒语的变形当成新咒语录入这个约束能在导入阶段就拦住大部分重复。4.3 人物表与关系表人物表和关系表是分开的。一开始我图省事想在人表里直接存一个related_ids字段后来写图谱代码时发现这是个典型的反模式——查询谁和哈利有亲属关系这种需求如果靠解析一个字段里的 JSON 来做性能和心智负担都会爆炸。CREATE TABLE characters ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, house TEXT, blood_status TEXT, wand TEXT, patronus TEXT, aliases TEXT DEFAULT [], bio TEXT ); CREATE TABLE relationships ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_id INTEGER NOT NULL REFERENCES characters(id), target_id INTEGER NOT NULL REFERENCES characters(id), relation_type TEXT NOT NULL CHECK (relation_type IN (family, teacher_student, friendship, enemy, colleague, other)), label TEXT, UNIQUE(source_id, target_id, relation_type) );relation_type用 CHECK 约束卡死可枚举值关系类型就没法被胡乱填成认识的这种模糊值。这个设计在后面写人物关系图渲染时省了大力气——每种类型映射一个颜色类名直接在组件的三目表达式里用不需要额外的映射表。4.4 导入脚本数据导入我写了一个 Node 脚本流程是读 JSON → 逐个表做 upsert存在就更新不存在就插入→ 输出日志。这个脚本在功能开发过程中反复跑了几十次是迭代数据内容的主力入口。中间遇到过一个问题JSON 文件里用了¾这种特殊字符导入时没有显式指定 UTF-8 编码导致部分字符变乱码。后来在连接字符串里加上charsetutf8问题才彻底解决。这种坑真是只有撞上才会长记性。5. 三大核心功能的实现链路检索、测验、图谱项目的主体功能也是一个一个从该怎么做到为什么这样做想清楚了才动手的部分。5.1 咒语检索先做能搜到再做搜得好咒语搜索的核心需求很简单用户输入一个片段返回匹配的咒语列表。但匹配的标准需要自己定义。我的实现分两段先做归一化normalization再做多字段模糊匹配。const normalize (s: string) s .toLowerCase() .normalize(NFKD) .replace(/[\u0300-\u036f]/g, ) .trim(); function searchSpells(query: string, spells: Spell[]): Spell[] { const q normalize(query); if (!q) return spells; return spells.filter((spell) { const fields [spell.name, spell.incantation, spell.effect, spell.category]; return fields.some((field) normalize(field).includes(q)); }); }用NFKD做 unicode 归一化能解决é这类带重音字符匹配不到的问题。.includes()代替.startsWith()保证搜呼神也能匹配到呼神护卫而不是只能搜全称。这是一个用了 20 行代码解决一个常见搜索陷阱的例子建议不管做什么搜索功能都先加这一层。5.2 分类筛选和空状态搜索框旁边我加了分类下拉框。分类值从数据表里动态读出来而不是硬编码在组件里——这样以后加新分类不用改前端代码。空状态也是一个容易遗漏但体验权重极高的细节当搜索没有结果时页面不能光秃秃显示未找到而是显示是否拼写错误试试英文原名或别名。这个设计参考了好友的一句话用户搜不到时已经很挫败了你得给他一条出路。5.3 分院测验计分模型与并列裁决分院帽测验是互动性最强也最需要算清楚逻辑的模块。题目一共 10 道每道四个选项分别对应四个学院一个分值点。type House gryffindor | hufflepuff | ravenclaw | slytherin; function sortingHat(answers: number[]): { house: House; scores: RecordHouse, number } { const scores: RecordHouse, number { gryffindor: 0, hufflepuff: 0, ravenclaw: 0, slytherin: 0, }; answers.forEach((optionIndex, questionIndex) { if (!QUESTIONS[questionIndex] || !QUESTIONS[questionIndex].options[optionIndex]) return; const assigned QUESTIONS[questionIndex].options[optionIndex].house; scores[assigned] 1; }); const sorted Object.entries(scores) as [House, number][]; sorted.sort((a, b) b[1] - a[1]); if (sorted[0][1] ! sorted[1][1]) { return { house: sorted[0][0], scores }; } return { house: tieBreaker(sorted[0][0], sorted[1][0]), scores }; }并列情况我提前想好了如果第一名和第二名分数相同用一个人工指定的优先级数组来裁决比如[gryffindor, ravenclaw, slytherin, hufflepuff]。但后来我发现一个体验更好的做法——并列时不武断地二选一而是把并列的两个学院都显示出来让用户自己选一个更像的。第二版就改成了这种软并列方案。测验前端交互上有一个需要注意的地方用户做了 9 道题还没做完就想看结果这时不能直接把仅有的答案提交后算出一个误导性的结果。我加了一道校验只有 10 题全部答完才展示结果并给未答完的题目标红提醒作答。数据一致性比少答也给算重要得多。5.4 人物关系图谱从邻接表到力导向图人物关系图谱是技术上最有东西的功能。数据上我用的是前文提到的relationships表渲染上我选择了react-force-graph一个基于 Three.js 的力导向图库。核心难点不在于渲染而在于把表结构转换成图结构。后端返回的数据格式必须符合前端组件的期望type GraphData { nodes: { id: string; name: string; house: string }[]; links: { source: string; target: string; type: string }[]; }; function buildGraphData(characters: Character[], relations: Relation[]): GraphData { return { nodes: characters.map((c) ({ id: String(c.id), name: c.name, house: c.house })), links: relations.map((r) ({ source: String(r.source_id), target: String(r.target_id), type: r.relation_type, })), }; }关系图谱最大的性能隐患在于节点多了之后力导向图会卡。我实测 120 个节点 350 条边已经有点吃力做了三个优化初始渲染只展示直接与当前选中人物有关系的节点其他节点按需展开。这是效果最明显的一个优化。力导向图开启 GPU 加速cooldownTicks默认值调低。连线 label 只在鼠标悬停时显示避免几百条文字标注同时渲染。5.5 魔法书籍清单这个模块本来是最简单的但做到后面发现一个体验细节书单如果只是列封面和书名那和维基百科有什么区别所以我给每本书加了阅读状态标签——读过、在读、待读用户可以在本地记录自己的进度。虽然数据只存在 localStorage 里但这个小互动让纯展示页面有了留存价值。6. 上线前踩过的坑跨域、编码、移动端的实战修正每次做项目都会发现本地开发一路顺畅一旦部署上线就接二连三冒问题。这次也不例外记录四个最有代表性的坑。6.1 开发环境的跨域问题开发时前端跑在 Vite 的 5173 端口后端跑在 3000 端口浏览器直接拦截了跨域请求。处理方式我选了 Vite Proxy 而不是在后端开 CORS 中间件——原因很简单开发时想让浏览器以为所有请求都来自同一个源部署时又有 Nginx 反向代理统一入口这样浏览器策略完全不干预。// vite.config.ts export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, }, }, }, });这个配置彻底解决了开发环境下的跨域问题。部署环境里 Nginx 的location /api转发到 Node 进程也是一样的思路代码层面完全不需要关心当前跑在哪个环境。6.2 特殊字符和编码错乱这类项目绕不开人名和咒语里的重音字符比如Alohomora里的重音、德姆斯特朗学院的ø等等。上线之后我接到用户反馈一个页面显示ä变成乱码排查后发现是 HTML 没有显式声明字符集。解决办法是让 Vite 在 HTML 模板里加上meta charsetUTF-8并在后端接口的响应头里显式设置Content-Type: application/json; charsetutf-8。修完之后所有特殊字符显示正常。这个坑特别容易在前后端分离项目里出现因为静态文件由 Nginx 直接返回如果 Nginx 的default_type没有设置 UTF-8旧配置很容易让中文和特殊字符全部乱掉。6.3 移动端滚动穿透和图谱缩放移动端最大的问题是人物关系图谱的交互。力导向图默认的拖拽缩放是鼠标逻辑在触摸屏上表现说得过去但滚动页面时手指经常会误触到画布造成页面滚动变成缩放图谱的效果。修法不算复杂给画布容器加touch-action: pan-y配合 JS 里判断手势类型只有双指才触发缩放单指留给页面滚动。另外我把默认的zoom范围限制在了 0.7 到 3.0避免用户无意中把图缩得找不回来。6.4 首屏加载优化这个项目的首屏主要是咒语列表页。一开始一口气渲染 200 条卡片不算慢但在低端机上能感觉到卡顿。后来做了三件事体验立刻上来路由级懒加载首页只加载首页的组件和样式。列表虚拟滚动只渲染可视区域内的卡片这个组件我选了自己熟悉的tanstack/react-virtual。人物头像图片统一压缩到 200px 宽格式转成 WebP。个人项目最容易忽视的就是性能优化但用户打开页面 3 秒还在白屏他大概率直接关掉。这些优化都不难做到也不需要引入复杂框架把能感知到的慢解决掉就够了。7. 从09-2到09-3后续迭代路线与个人复盘项目上线大概一个月后数据告诉我几件事咒语搜索和分院测验的访问量最大但留存率都不高人物关系图谱的访问时长最长平均 4 分钟说明这个功能确实戳中了考据型需求魔法书籍列表访问量垫底。这个结果印证了最初的用户画像判断也直接指导了未来的迭代方向。7.1 下一版优先级用户反馈里出现频率最高的一句话是怎么不能收藏。我第一版拒绝了这个功能但现在数据说明需求确实存在所以第二版会做一个轻量的收藏列表存在 localStorage不上用户系统覆盖考前冲刺型哈迷的收藏需求即可。另一个计划中的功能是魔杖测验——和分院帽测验类似但选项对应木料、杖芯、长度、弹性这些维度最后输出一个魔杖签名。这在哈迷圈里属于百玩不腻的互动玩法开发成本也不高后端加一组题目数据就行。7.2 技术债与重构计划有几个技术债是我明确知道要还但排在后面的一是人物图谱的力导向性能当节点数超过 300 时必须要做 WebGL 级优化或者改用 Canvas 2D 渲染二是后端路由目前全是app.get堆在一个文件里接口多了之后需要按业务模块拆文件三是没有测试至少要给分院测验的计分逻辑补上单元测试这个函数是纯函数测试起来成本很低。7.3 我给自己的三条复盘第一数据清洗和建模永远值得多花时间。第二版的开发少走了很多弯路就是因为第一版的关系表设计留够了扩展余量。第二MVP 范围划定要敢于砍功能砍掉用户系统让我提前了一个月上线。第三上线后真的要看用户反馈光靠自己想象需求做功能做出来的东西大概率自我感动。说到这儿回到开头那个代号。harrypotter09-2现在已经不再只是一个文件夹名了它是我把哈迷需要什么想清楚、并且用技术实现出来的一个完整作品。下一步我大概率会起名叫harrypotter09-3里面的九和四分之三隐喻还留着但功能会比这一版更靠近真实用户的使用习惯。最后分享一个小习惯我在本地给每个项目都建了一个DECISIONS.md文件把每次为什么选 A 不选 B的记录写进去。三个月后回看这些记录你会发现当时的很多选择其实可以做得更好但正是这些记录让你不再在同一类坑里栽第二次。希望这篇复盘对你做自己的个人项目也有点帮助。