来源:
notes/documents/个人助理网页版技术栈梳理.md个人助理网页版技术栈梳理
结论
网页版(界面名 Folio)是一套刻意「不引框架」的 Web 标准栈:前端是 React 18 + TypeScript + Vite 5
手写的单页应用,不引路由库、状态库、UI 组件库与 CSS 框架;后端是 Vercel Functions(Node.js 运行时)
里的薄入口 + 纯 TypeScript 业务代码,handler 就是 export default { fetch(request) },没有 Express /
Hono / Next 这类框架;数据分两条腿——笔记走构建期产物,日程走数据库投影。运行时依赖一共 6 个,
且全是「库」而不是「框架」。
梳理口径:只读 web/ 的代码与 web/docs/adr/,不含飞书同步与本地工作区那一侧;数字是 2026-09-23 实测。
全景
| 层 | 选型 | 版本 | 落点 |
|---|---|---|---|
| 视图 | React + react-dom | 18.3.1 | frontend/src/ |
| 构建 | Vite + @vitejs/plugin-react | 5.4.1 / 4.3.1 | vite.config.ts |
| 语言 | TypeScript,三个 tsconfig(project references) | 5.5.3 | tsconfig.app/node/server.json |
| 样式 | 手写 CSS + 自定义属性做 token | — | frontend/src/index.css、App.css |
| 后端运行时 | Vercel Functions(Node.js) | 平台托管 | api/**/*.ts |
| 后端框架 | 无,Web 标准 Request → Response |
— | backend/src/ |
| 数据库 | Neon Serverless Postgres(Lakebase,aws-ap-southeast-1) |
— | DATABASE_URL |
| ORM / 驱动 | Drizzle ORM(drizzle-orm/neon-http)+ @neondatabase/serverless |
0.45.2 / 1.1.0 | backend/src/lib/db.ts |
| Markdown | marked + DOMPurify | 18.0.13 / 3.4.15 | frontend/src/lib/markdown.ts |
| 鉴权 | GitHub OAuth 授权码 + 自签发 Session | — | backend/src/auth/、lib/session.ts |
| 部署 | Vercel 单项目,区域 sin1 |
— | vercel.json |
| 测试 | Node 内置 node:test + 类型剥离 |
Node 22.6+ | tests/*.test.mjs |
| Lint | ESLint 9 flat config + typescript-eslint + react-hooks | 9 / 8 | eslint.config.js |
前端
- React 18 SPA,一个入口:
main.tsx只做createRoot+StrictMode;App.tsx兼做路由解析、 数据装配与模块分支,页面组件在pages/,外壳在shell/。 - 没有路由库:模块与选中态全部编码进查询参数(
?m=模块、?n=Note ID、?o=其他文档路径、?d=日期、?q=搜索),lib/route.ts直接调 History API——选中走pushState,搜索输入走replaceState,所以按一次返回是离开搜索,而不是退掉一个字符(docs/adr/0010)。 - 没有状态库:全是
useState/useEffect/useMemo,服务端数据每次进页面现取;唯一的跨组件 状态是auth/useSession.ts那一个登录态。 - 没有 UI 组件库、没有 CSS 框架:
index.css用自定义属性定义五个颜色角色(纸 / 墨 / 淡 / 青 / 朱)、 两套主题与三个布局尺寸(脊 64 / 索引 300 / 页边 72),App.css按外壳契约排版。改这三个尺寸前先改docs/adr/0009。 - Markdown 渲染:
marked出 HTML、DOMPurify消毒,并在afterSanitizeAttributes钩子里只放行https://与构建期重写过的/note-assets/两种图片地址,顺手给每张图加loading="lazy"与decoding="async"。 - 取数:手写
fetch封装(lib/api.ts、lib/notes.ts、lib/daily.ts),带credentials: 'include',没有 axios,也没有 TanStack Query。
后端
- Vercel Functions 薄入口:
api/下 11 个文件只做三件事——守卫(isOwnerRequest)、参数校验、 调backend/src/再json()出去。逻辑全在backend/src/,因为api/是平台约定,业务不该长在里面。 - 没有 Web 框架:handler 就是 Web 标准——默认导出
{ async fetch(request) }返回Response, URL 路由由文件名决定(api/notes/entry.ts→GET /api/notes/entry)。Cookie 解析与下发、302 跳转 都是手写的(backend/src/http.ts,55 行)。 - 模块导入带
.js后缀指向.ts文件:为 NodeNext 而写;本地预览用scripts/lib/ts-alias-hook.mjs把.js解析回.ts,靠的是 Node 自带的类型剥离。 - 正文走查询参数,不走路径段:
api/的编译约定会把[...path].ts收成单段路由,带斜杠的路径 匹配不上,所以单篇是GET /api/notes/entry?id=与GET /api/notes/detail?path=(docs/adr/0012)。 - 服务端只在一处决定「哪天」:
backend/src/lib/daily.ts把 Asia/Shanghai 的日 / 月换算成 UTC 区间; 时区表只有一份,前端那份镜像只用来知道「今天是几号」,不决定归属。 - 搜索是内存扫描:进函数就扫
generated/notes.json(笔记 118 篇、正文 1.76 MiB,冷启动读取解析 约 80 ms,一次搜索约 20 ms),不是 PostgresILIKE,也没上索引。
数据:两条腿
| 数据 | 权威源 | 运到线上的方式 | 网页端读法 |
|---|---|---|---|
笔记(notes/documents/) |
工作区 Markdown | 构建期生成 generated/notes.json,随函数包发布 |
函数读内存里的产物 |
其他文档(notes/ 其余 Markdown) |
同上 | 同上 | 同上,按路径取 |
| 日程 / 日历条目 | data/daily_log.sqlite3 |
web/scripts/project-calendar-entries.mjs 投影进 Neon |
按月、按日查 calendar_entries |
- 判据是「可重建的进产物,不可重建的进库」:笔记的路径、标题、摘要、正文、哈希都能从 Markdown
重算,所以不入库;会话撤销不了,所以要存(
docs/adr/0008)。 - 日程是唯一例外:它要求「记完几分钟内可见」,走 commit + 部署做不到,于是进 Neon 投影表,
但仍不是事实源——整表可以随时 truncate 重灌(
docs/adr/0011)。投影表同时放日志线与笔记线, 用kind(daily/note)区分;DDL 在scripts/migrate-calendar-entries.mjs,Drizzle 定义在backend/src/lib/schema.ts,两处必须一致。 - 数据库里只有四张表:
users、identities、sessions、calendar_entries。前三张承载身份与会话, 第四张是投影;笔记没有表。 created_at只能来自文件自己:构建容器是git clone,文件系统出生时间等于检出时刻,所以scripts/generate-notes.mjs取 git 提交时间,只有未提交的笔记才退回 mtime——还要-c core.quotePath=false, 否则非 ASCII 路径会被转义。- 图片随构建拷出来:笔记引用的图片按原相对路径拷到
frontend/public/note-assets/(gitignore), 正文里的地址被重写成/note-assets/…;没被拷贝的引用原样留在 Markdown 里,读者最多看到缺图, 不会看到坏地址。 - 前端 bundle 里没有笔记:笔记只进函数包,首屏只拉元数据(约 99 KiB),正文点开才取单篇。
鉴权
一条 OAuth 授权码流程,加一层自家 Session:
GET /api/auth/login生成随机state写进 httpOnly cookie(10 分钟),302 到 GitHub;scope 只有read:user user:email,并带allow_signup=false。- 回调
GET /api/auth/callback/github校验state、用 code 换 token、拉用户信息,再查允许清单 (命中ALLOWED_GITHUB_IDS,否则回退按ALLOWED_EMAILS比邮箱)。 - 通过后把外部身份写进
identities(同一 subject 复用同一users行),再签发 Session:32 字节随机 token 只给浏览器,库里存的是它的 SHA-256。cookie 名pa_session,httpOnly + SameSite=Lax + Secure, 30 天有效,超过 24 小时未活动就在下次访问时滚动续期。 - 撤销就是把
sessions里那一行删掉——这正是选自签发 Session、而不直接用 GitHub token 或 JWT 的理由 (docs/adr/0003)。
- 回调地址不写死:从请求的 Host 推导(
requestOrigin)。代价是浏览器访问哪个域名,就得把那个域名的 回调注册进 GitHub OAuth App——目前只注册了生产域名vite-react-tawny-one-14.vercel.app。 - 没有注册、没有密码:单用户,允许清单之外的人连登录入口都过不去(
docs/adr/0004)。 - 正因如此,生产环境关掉了 Vercel 的 Deployment Protection(
ssoProtection.deploymentType = preview), 否则 Vercel 的登录墙会抢在应用鉴权前面。
构建、部署与本地预览
- 一条命令:
npm run build=notes:generate(生成笔记产物)→tsc -b(三个 tsconfig 全量类型检查) →vite build(产物落frontend/dist)。类型检查不过,笔记也不会发布。 - Vercel 单项目:项目名
vite-react(prj_ADXiHsHbTHzhLvu1e66QsRotElAt),Root Directory 为web, Framework 预设 Vite,输出frontend/dist,区域sin1(与 Neon 新加坡同城);rewrites把非/api/的路径全部兜回index.html,functions.includeFiles = generated/**把笔记产物打进函数包。 - 项目名不能改:域名由项目名生成,GitHub OAuth 回调只注册了其中一个域名,改项目名就断登录。
- 推送即部署:push 到
main自动触发生产部署;要单独触发用npm run deploy——它走 Vercel REST API 让平台自己拉 git 构建,因为本机*.vercel.app被 DNS 污染,vercel deploy的本地上传常在几 MB 处失败。 - 本地预览:
npm run preview:local起静态服务 + 真实 handler,只有身份是假的 (scripts/lib/preview-identity.mjs固定返回 owner),因此不需要 Session cookie、Neon 或 GitHub App; 需要数据库的日程接口能不能用,取决于.env.local里有没有DATABASE_URL。 - 测试与 lint 都不引第三方运行器:
npm test=node --test --experimental-strip-types tests/*.test.mjs, 2026-09-23 为 9 个文件 / 53 个用例全绿;npm run lint是 ESLint 9 的 flat config。脚本一律node scripts/*.mjs,没有 tsx / ts-node / nodemon。
刻意不用的东西
| 没用 | 用了什么 | 理由 |
|---|---|---|
| Next.js / Remix | Vite SPA + Vercel Functions | 一个人用的只读站点不需要 SSR 与全栈框架 |
| React Router | 查询参数 + History API | URL 本身就是全部状态,不需要第二份本地状态 |
| Redux / Zustand | useState |
没有需要跨页面共享的可变状态 |
| Tailwind / CSS-in-JS | 手写 CSS + 自定义属性 | token 只有五个颜色角色、三个尺寸,手写比添一层构建依赖更短 |
| 组件库 | 手写组件 | 界面是「册子」不是控制台,通用组件库会带进不属于它的语汇 |
| Express / Hono | Web 标准 Request/Response |
11 个只读接口,文件名即路由,不需要框架 |
| Prisma | Drizzle ORM + neon() 原始 SQL |
serverless 冷启动更轻;投影查询本来手写 SQL 更清楚 |
| Vitest / Jest | node:test |
平台自带,配合类型剥离不需要构建步骤 |
| Redis / 缓存层 | 构建期产物 + 内存扫描 | 数据冷、站点低频,缓存是多余的中间层 |
一句话记法
前端 React + Vite + 手写 CSS;后端是 Vercel Functions 里的纯 TS(无框架);数据靠
构建产物(笔记) + Neon Postgres 投影(日程);鉴权是 GitHub OAuth + 自签发 Session;测试与 lint
全用平台自带工具——整条链上没有一项「框架替你做决定」的依赖。