要做什么
一个 Markdown 片段收藏夹:收集常用命令、代码模板、配置片段,支持新建、编辑、删除、标签筛选,详情页把 Markdown 渲染成 HTML。技术栈就是 Svelte 系列前两篇讲的组合——Svelte 5 runes 写界面,SvelteKit 2 的 form actions 与 load 承担数据流。”单机单人使用”这个约束会让存储选型变轻,但 SSR 首屏与 XSS 消毒这两条底线不会跟着轻——后者恰好是 {@html} 类应用最容易出事故的地方。机制层面的疑问(load 怎么重跑、action 是什么)不在这里重复,见路由与数据加载详解,条目速查见参考字典。
| 指标 | 目标 | 怎么测 |
|---|---|---|
| SSR 首屏 | 关掉 JavaScript 也能看列表、提交表单 | 浏览器禁用 JS 后走一遍增删改查;view-source 里能看到片段标题 |
| 校验回显 | 空标题提交不出库,错误信息留在表单旁 | 空标题点提交,URL 不变、提示出现、已填内容不丢 |
| XSS 消毒 | 注入脚本不执行 | 存一条正文为 <script>alert(1)</script> 的片段,详情页 view-source 无 script 标签 |
| 落盘可靠性 | 每次保存后 data/fragments.json 都是合法 JSON | 保存后用 node -e “JSON.parse(…)” 校验文件 |
| 筛选记忆 | 筛选状态进 URL,可分享可回退 | 点标签后浏览器后退,列表恢复全量 |
选型:三处都要有”为什么不选”
存储是最先要定的事:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| JSON 文件 | 零依赖、肉眼可查、备份就是复制文件 | 并发写会互相覆盖,数据量大了全量读写 | 单机单人工具(本项目) |
| SQLite | 查询与约束齐全,单文件仍是零运维 | 要引入原生依赖,示例复杂度上升一档 | 多用户或上千条目 |
| 独立数据库 | 并发、备份、多实例部署全解决 | 为一个收藏夹装 MySQL 不成比例 | 多人协作服务 |
Markdown 渲染选 marked 加 sanitize-html:marked 只负责转 HTML,官方明确声明它不做消毒,所以输出必须过白名单;sanitize-html 是纯服务端实现。不选 DOMPurify 是因为它的服务端形态要拖着 jsdom;不选 mdsvex 是因为它面向编译期内容文件,而这里的正文存在数据文件里、每次改动都要即时生效。渲染时机选”存原文、读取时渲染消毒”——渲染器将来升级不用迁移历史数据。
提交方案是与”前端框架加 REST 接口”习惯分歧最大的一处:
| 方案 | 优点 | 缺点 |
|---|---|---|
| form actions + use:enhance(本项目) | 无 JS 可用;校验错误回填是一等公民;少写一层 API | 响应不是 JSON,跨端复用要另起 +server.js |
| +server.js 写 REST + fetch | 接口可给 App、脚本复用 | 要自己处理错误结构、失效刷新与加载态 |
| 纯客户端状态(不落服务端) | 交互最跟手 | 刷新即丢,不符合收藏夹的本质需求 |
最终结构只有两个页面:
src/
├── lib/server/store.js # 数据文件读写(仅服务端可达,编译器强制)
├── routes/
│ ├── +page.svelte # 列表 + 新建表单 + 标签筛选
│ ├── +page.server.js # load 全量数据;actions: create / delete
│ └── [id]/
│ ├── +page.svelte # 详情(渲染后的 HTML)+ 编辑 + 删除
│ └── +page.server.js # load 单条(渲染消毒);action: update
依赖安装与启动:
npx sv create fragments # 模板选 minimal,类型检查选 TypeScript
npm install marked sanitize-html
npm run dev # http://localhost:5173
存储层:原子写与单写者边界
// src/lib/server/store.js
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const FILE = resolve('data/fragments.json');
export function load() {
try {
return JSON.parse(readFileSync(FILE, 'utf8'));
} catch {
return []; // 首次运行没有数据文件,空列表即可,不必预建文件
}
}
export function save(items) {
mkdirSync(dirname(FILE), { recursive: true });
const tmp = FILE + '.tmp';
writeFileSync(tmp, JSON.stringify(items, null, 2));
renameSync(tmp, FILE); // rename 在同一文件系统内是原子的,崩溃也不会留下半个 JSON
}
反模式点名:直接 writeFileSync(FILE, ...) 覆盖目标文件。写一半进程被杀(或掉电),留下的就是截断的 JSON,下次启动 JSON.parse 直接抛错,收藏夹全灭。”先写临时文件再 rename”是文件存储的最低限度仪式。另一个要说破的边界:读-改-写在多标签页同时保存时会丢更新(后保存者覆盖先保存者),单机单人可接受;一旦要多人用,把这一层换成 SQLite,actions 里的调用代码几乎不用动——这是把存储隔离在 $lib/server 的全部意义。$lib/server 目录有编译器强制:客户端代码 import 它会直接构建失败,密钥类代码放这里才睡得着觉。
列表页:action、校验回显与筛选
// src/routes/+page.server.js
import { fail, error } from '@sveltejs/kit';
import { load, save } from '$lib/server/store.js';
export function load() {
return { fragments: load() };
}
export const actions = {
create: async ({ request }) => {
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
const body = String(form.get('body') ?? '');
const tags = String(form.get('tags') ?? '')
.split(/[,,\s]+/).filter(Boolean).slice(0, 5);
const errors = {};
if (!title) errors.title = '标题必填';
if (body.length > 20000) errors.body = '正文超出 2 万字符';
if (Object.keys(errors).length) {
// 4xx 且带数据:用 fail。它把数据交回表单页,而不是跳错误页
return fail(400, { errors, values: { title, body } });
}
const items = load();
items.unshift({ id: crypto.randomUUID(), title, body, tags, createdAt: new Date().toISOString() });
save(items);
return { success: true };
},
delete: async ({ request }) => {
const id = String((await request.formData()).get('id') ?? '');
save(load().filter(f => f.id !== id));
return { success: true };
}
};
<!-- src/routes/+page.svelte(骨架) -->
<script>
import { enhance } from '$app/forms';
let { data, form } = $props(); // form 是本次 action 的返回值
</script>
<!-- 无 action 名即 default?这里用具名 action:URL 写成 ?/create -->
<form method="POST" action="?/create" use:enhance>
<input name="title" value={form?.values?.title ?? ''}>
{#if form?.errors?.title}<p>{form.errors.title}</p>{/if}
<textarea name="body">{form?.values?.body ?? ''}</textarea>
<input name="tags" placeholder="用逗号分隔">
<button>收藏</button>
</form>
<!-- 标签筛选:状态进 URL -->
<a href="/">全部</a>
{#each data.tags as t (t)}
<a href="?tag={t}">{t}</a>
{/each}
<ul>
{#each data.fragments as f (f.id)}
<li>
<a href="/{f.id}">{f.title}</a>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={f.id}>
<button>删除</button>
</form>
</li>
{/each}
</ul>
三个行为都来自框架而不是手写代码:提交成功后 use:enhance 的默认行为会自动重跑本页 load 并回填 form,新片段无需手动刷列表;禁用 JS 时表单退化为普通 POST,action 照样执行(这是选 form actions 的理由);校验失败时 fail(400, {...}) 的数据落在组件的 form 属性里,values 把用户已输入的内容送回输入框。
反模式点名:把校验错误写成 error(400, '标题必填')。error 会把用户送进 +error.svelte 错误页,表单内容全部丢失——它服务于”请求本身无法满足”,校验失败属于”正常流程里的坏输入”,该用 fail。第二个反模式是嫌 use:enhance 不够灵活,自己 fetch('?/create')——那要手动处理 CSRF(SvelteKit 会校验 Origin 头)、错误结构与数据失效,标准答案是自定义 use:enhance 的回调或用 applyAction,别绕开框架再造一层。
筛选的实现演示了路由篇讲过的重跑规则。列表页放两个 load:server load 返回全量,universal load 读查询串建立依赖:
// src/routes/+page.js —— universal load
export function load({ data, url }) {
const tag = url.searchParams.get('tag'); // 读了它,?tag= 变化才会触发重跑
return {
...data,
fragments: tag
? data.fragments.filter(f => f.tags.includes(tag))
: data.fragments
};
}
数据只有几十条,先全量取回再在 universal load 里筛,而不是给 server load 加查询参数——因为客户端切换 ?tag= 时 server load 不会重跑(路由篇讲过的规则),筛选放 universal 层才能只靠浏览器本地完成,点标签是零网络往返的即时切换。
详情页:渲染与消毒是同一步
// src/routes/[id]/+page.server.js
import { error } from '@sveltejs/kit';
import { marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
import { load, save } from '$lib/server/store.js';
export function load({ params }) {
const item = load().find(f => f.id === params.id);
if (!item) error(404, '片段不存在');
// 存原文、读取时渲染;marked 不消毒,输出必须立刻过白名单
const html = sanitizeHtml(marked.parse(item.body));
return { item, html };
}
export const actions = {
update: async ({ params, request }) => {
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
const body = String(form.get('body') ?? '');
if (!title) return fail(400, { errors: { title: '标题必填' } });
const items = load();
const i = items.findIndex(f => f.id === params.id);
if (i === -1) error(404, '片段不存在');
items[i] = { ...items[i], title, body };
save(items);
return { success: true };
}
};
<!-- src/routes/[id]/+page.svelte(骨架) -->
<script>
let { data } = $props();
</script>
<h1>{data.item.title}</h1>
<!-- html 已在 server load 消毒,这里才是 {@html} 的安全位置 -->
<article>{@html data.html}</article>
<!-- 编辑表单同样 method POST action="?/update",textarea 预填 data.item.body,略 -->
反模式点名:跳过消毒直接 {@html marked.parse(item.body)}。marked 的文档明确说它输出的 HTML 是”未消毒”的,<script>、内联事件、javascript: 链接全部原样通过。收藏夹的正文迟早会粘贴自网页——粘贴物里藏一行脚本,渲染页面的每一次打开都在替攻击者执行。消毒后该长什么样:<script>alert(1)</script> 进去,出来只剩正文里什么都没有的空壳,[点我](javascript:alert(1)) 渲染成一个不带 href 的 a 标签。验收表里那条”view-source 无 script 标签”就是为这一步准备的。
验证:五条验收怎么过
| 验收项 | 操作 | 预期 |
|---|---|---|
| 禁用 JS 走全流程 | DevTools 禁 JS,新建、筛选、删除各一次 | 全部成功,页面整页刷新但不丢数据 |
| 校验回显 | 清空标题点提交 | 停留原页,提示”标题必填”,正文还在输入框里 |
| XSS 消毒 | 新建正文为 script 标签的片段并打开详情 | 无弹窗,view-source 中 script 被剥掉 |
| 落盘校验 | 每次保存后 node -e “console.log(JSON.parse(require(‘fs’).readFileSync(‘data/fragments.json’,’utf8′)).length)” | 打印条数,无解析错误 |
| URL 状态 | 点标签再按浏览器后退 | 列表恢复全量;复制带 ?tag= 的链接给别人能直接看到同样筛选 |
用 curl 直接打 action 会被 403 拦下——SvelteKit 默认校验 POST 的 Origin 头,这是框架送的 CSRF 防线,不是故障;要脚本测试就带上 -H "Origin: http://localhost:5173"。
性能说明:单机单人、数据几十条,没有压测口径,本篇未做性能实测;本地文件读写毫秒级,条目上万先换 SQLite 再谈性能。
总结
这个项目教会的事:form actions 让”无 JS 可用的 Web 应用”成为默认而不是附加题;fail 与 error 是两种语义,混用会把用户赶进错误页;marked 与消毒是绑定购买,拆开卖就是 XSS;原子写决定了一个玩具能不能活过第一次意外。它什么时候不够用:多人协作、条目上万、需要全文检索——换存储与检索层即可,路由与表单这层骨架不用动。
想接着深入,框架定位与心智见开山篇Svelte 入门;错误分类的通用思路(不只是 SvelteKit)可对照站内Node.js 错误处理一起看;按学习顺序串起本系列的路线图见Svelte 学习路线。
版本核实(以官方文档为准):截至本文(2025-11-06)稳定线为 Svelte 5.43.x、SvelteKit 2.48.x;form actions、fail()/error() 抛出式语义、use:enhance 默认失效重跑、$lib/server 编译期隔离、CSRF Origin 校验均为 SvelteKit 2 现行文档行为;marked 官方声明不做输出消毒(消毒由调用方负责);sv CLI 为 0.9.x。安装命令不钉版本号。