要做什么
一个看板小应用:待办、进行中、已完成三列,卡片支持新建、删除、拖拽换列,刷新后位置不丢。技术栈是 Solid 系列前三篇的组合——solid-js 1.9 写界面,SolidStart 1.2 的 query/action 承担数据流,存储用单个 JSON 文件。”单机单人”这个约束会让存储选型变轻,但有两件事不会跟着轻:状态归属(客户端不再复制一份)与拖拽的事件细节——前者是 Solid 项目最常被写错的地方,后者决定了这个看板是不是真的能用。机制层的疑问(query 为什么去重、action 怎么回传)不在这里重复,见路由与数据详解,条目速查见参考字典。
| 指标 | 目标 | 怎么测 |
|---|---|---|
| 换列持久化 | 拖拽换列后刷新、无痕窗口打开都保持 | 拖一张卡到另一列,F5 与无痕窗口各验证一次 |
| 请求去重 | 同页多处读看板数据只发一次后端请求 | DevTools Network 面板数请求条数 |
| 自动刷新 | action 成功后列表自动更新,无手写刷新代码 | 新建卡片后观察列表(Network 可见 query 重取) |
| 校验回显 | 空标题卡片不入库,提示就地出现 | 清空标题点添加,看提示与数据库文件 |
| SSR 首屏 | 服务端返回的 HTML 已含列与卡片 | view-source 里搜得到卡片标题 |
选型:三处决策都带”为什么不选”
存储是第一件要定的事:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| JSON 文件(本项目) | 零依赖、肉眼可查、备份就是复制 | 并发写互相覆盖,量大全量读写 | 单机单人工具 |
| SQLite | 约束与查询齐全,仍是单文件零运维 | 引入原生依赖,示例复杂度上一档 | 多用户或上千卡片 |
| 独立数据库 | 并发、备份、多实例全解决 | 为一个看板装服务不成比例 | 多人协作服务 |
换列交互是第二件:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 原生 HTML5 拖放(本项目) | 零依赖,事件三行代码,体验最直观 | 键盘不可达,触屏支持一般;细节多(见拖拽一节) |
| 社区拖拽库 | 动画与嵌套列完善 | dnd-kit 这类主流库不支持 Solid,Solid 社区库活跃度一般(以 npm 现状为准) |
| 下拉/按钮换列 | 键盘可达,实现最简单 | 操作路径长,不像看板 |
状态归属是第三件,也是最容易写错的一件。正路只有一条:服务端 JSON 文件是唯一事实,router 的 query 是唯一客户端缓存——组件里只 createAsync 读 query,不在客户端再建 createStore 存一份全量卡片。第三方的诱惑方案(客户端 createStore 复制全量、localStorage 当存储)分别带来双份状态漂移与刷新不同步,本文不展开实现,直接按反模式处理。
项目结构:五份文件起步
kanban/
├── package.json # dependencies: solid-js, @solidjs/router, @solidjs/start;dev: vinxi
├── app.config.ts # defineConfig:ssr 与部署 preset
└── src/
├── index.tsx # mount 入口
├── app.tsx # Router + root(Suspense) + FileRoutes,见路由篇
├── lib/board.js # 存储层:原子读写与业务函数,仅服务端 import
└── routes/index.tsx # query + 三个 action + 看板页面
npm install solid-js @solidjs/router @solidjs/start
npm install -D vinxi
npm run dev # vinxi dev,默认 http://localhost:3000
app.config.ts 保持最小:export default defineConfig({ ssr: true }),部署目标靠 server.preset 换 Nitro 预设,开发期不需要动它。入口 index.tsx 只调 mount,app.tsx 是路由篇那个带 Suspense 的 Router——真正写逻辑的只有 board.js 与 index.tsx 两个文件。
存储层:原子写是底线
// src/lib/board.js —— 只被服务端代码 import
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
import { randomUUID } from "node:crypto";
const FILE = "data/board.json";
export const COLUMNS = [
{ id: "todo", title: "待办" },
{ id: "doing", title: "进行中" },
{ id: "done", title: "已完成" }
];
export async function loadBoard() {
try {
return JSON.parse(await readFile(FILE, "utf8"));
} catch {
return { cards: [] }; // 首次运行没有数据文件,空看板即可
}
}
export async function saveBoard(board) {
await mkdir("data", { recursive: true });
const tmp = FILE + ".tmp";
await writeFile(tmp, JSON.stringify(board, null, 2));
await rename(tmp, FILE); // rename 在同一文件系统内是原子的,崩溃不留半个 JSON
}
export async function addCard(title, col = "todo") {
const board = await loadBoard();
board.cards.push({ id: randomUUID(), title, col, createdAt: new Date().toISOString() });
await saveBoard(board);
}
export async function removeCard(cardId) {
const board = await loadBoard();
board.cards = board.cards.filter(c => c.id !== cardId);
await saveBoard(board);
}
export async function moveCard(cardId, colId) {
const board = await loadBoard();
const card = board.cards.find(c => c.id === cardId);
if (!card) throw new Error("卡片不存在");
card.col = colId; // 单机单人:最后写入者生效,可接受
await saveBoard(board);
}
反模式点名:直接 writeFile(FILE, ...) 覆盖目标文件。写一半进程被杀,留下的就是截断的 JSON,下次启动 JSON.parse 直接抛错,看板全灭——”先写临时文件再 rename”是文件存储的最低限度仪式。另一条要说破的边界:读-改-写在多标签页同时保存时是后写覆盖先写,单机单人可接受;一旦多人用,把这一层换成 SQLite,action 里的调用代码几乎不用动——这正是把存储隔离进 lib/board.js 的全部意义。
路由文件:读路径、写路径、页面三合一
// src/routes/index.tsx
import { action, createAsync, query, useAction, useSubmission } from "@solidjs/router";
import { For, Show, Suspense } from "solid-js";
import { addCard, moveCard, removeCard, loadBoard, COLUMNS } from "../lib/board";
const getBoard = query(async () => {
"use server";
return await loadBoard(); // 请求级缓存:同页多处读它只发一次请求
}, "board");
const addCardAction = action(async (formData: FormData) => {
"use server";
const title = String(formData.get("title") ?? "").trim();
if (!title) return { error: "标题必填" }; // 校验失败经 result 回传,不抛异常
await addCard(title);
}, "addCard");
const moveCardAction = action(async (cardId: string, colId: string) => {
"use server";
await moveCard(cardId, colId);
}, "moveCard");
const deleteCardAction = action(async (formData: FormData) => {
"use server";
await removeCard(String(formData.get("id")));
}, "deleteCard");
export default function Board() {
const board = createAsync(() => getBoard());
const move = useAction(moveCardAction); // 拖拽不是表单,拿可编程调用的 action
const adding = useSubmission(addCardAction);
let draggedId: string | undefined; // 拖拽中的卡片 id,页面内存即可
return (
<Suspense fallback={<p>看板加载中</p>}>
<For each={COLUMNS}>{(col) => (
<section
onDragOver={(e) => e.preventDefault()} {/* 不 preventDefault,drop 永不触发 */}
onDrop={() => { if (draggedId) move(draggedId, col.id); }}
>
<h2>{col.title}</h2>
<For each={board()?.cards.filter((c) => c.col === col.id)}>{(card) => (
<div draggable="true" onDragStart={() => (draggedId = card.id)}>
{card.title}
<form action={deleteCardAction} method="post">
<input type="hidden" name="id" value={card.id} />
<button>删除</button>
</form>
</div>
)}</For>
</section>
)}</For>
<form action={addCardAction} method="post">
<input name="title" placeholder="新卡片标题" />
<button disabled={adding.pending}>添加</button>
</form>
<Show when={adding.result?.error}><p>{adding.result.error}</p></Show>
</Suspense>
);
}
三个行为都来自框架而不是手写代码:拖拽放下后 move() 调用 action,action 成功时 router 自动重取 getBoard,board() 的值变化让卡片落到新列——全程没有一行”提交后刷新列表”;新建与删除走 form action={...} method="post" 语法,提交期间 adding.pending 让按钮禁用;空标题的返回值落在 adding.result,提示就地出现。拖拽期间想显示”同步中”,给 moveCardAction 挂一个 useSubmission 读 pending 即可;连拖多张卡要逐张显示状态,官方文档指向 useSubmissions(带过滤条件),本文场景用不上。
反模式点名:在 onDrop 里先改本地状态再调 action。看起来更快,实际制造了两份事实——本地改了列、服务端万一失败就漂移,而 query 缓存与本地 store 之间没有同步机制。正路是交给框架:action 成功自动 revalidate,失败 result/error 照实回传。第二个反模式是嫌 form 提交土,自己 fetch(“/api/cards”) 再手动 set——API 路由要自己维护、错误结构要自己定、刷新要自己触发,Solid Router 的 action 三件事全包了,绕开它是纯亏。
拖拽的两个细节值得单独说。其一,onDragOver 必须 preventDefault——HTML5 拖放规范要求目标元素”声明愿意接收放下”,漏掉这一行 drop 永不触发,拖拽看起来”完全没反应”,这是原生拖放的头号坑。其二,卡片 id 用页面内变量传递而不是 dataTransfer:后者在 dragstart 时写入、drop 时才能读出,单页看板用变量更简单;要做跨组件甚至跨标签页拖拽再升级到 dataTransfer。诚实边界:拖拽天然需要 JS,键盘用户走不了这条路径,工具性质的项目至少补一个”右键移到某列”的替代入口,本文留作练习。
验证:五条指标怎么过
| 验收项 | 操作 | 预期 |
|---|---|---|
| 换列持久化 | 拖卡片到”进行中”,F5,再开无痕窗口访问 | 两处看到的列位置一致 |
| 请求去重 | Network 面板清空后刷新页面 | 看板数据只有一条请求,无重复 |
| 自动刷新 | 新建一张卡片 | 列表立即出现新卡;Network 里能看到 action 之后跟着一次 query 重取 |
| 校验回显 | 清空标题点添加 | “标题必填”就地出现,data/board.json 无脏数据 |
| SSR 首屏 | view-source 搜卡片标题 | HTML 里已有列与卡片,不是空壳 |
性能说明:单机单人、数据几十条,本篇未做性能实测,不做定量结论;本地 JSON 读写为毫秒级定性判断,卡片上千先换 SQLite 再谈性能,多人协作是换存储层而不是改前端。
总结
这个项目教会的事:query 的请求级缓存让”状态放哪”有唯一答案——服务端文件是唯一事实,客户端不再复制一份;action 成功后的自动 revalidate 让”提交后刷新”成为不该存在的代码;”use server” 是安全边界不是装饰,漏一行的代价是存储代码进客户端产物;原生拖放三行事件换零依赖,代价是键盘不可达,划不划算取决于工具的性质。它什么时候不够用:多人同时写需要 SQLite 加冲突策略;卡片上千需要分页或虚拟列表;移动端优先需要触屏拖拽库或干脆回退到按钮换列。
想接着深入:框架定位与响应式心智回开山篇看;本篇用到的每一条 API 的速查版在参考字典;”表单 action + 自动刷新”这套数据流的 SvelteKit 对照实现见站内用 SvelteKit 做一个 Markdown 片段收藏夹——两家对读,元框架数据流的共性就看清了;按学习顺序串起全系列的路线图见SolidJS 学习路线(2025-12-11 发布)。
版本核实(以官方文档为准):截至本文(2025-12-04)稳定线为 solid-js 1.9.10、SolidStart 1.2.0、@solidjs/router 0.15.4(均为 npm 实查版本);vinxi dev/build/start 为 SolidStart 1.x 官方脚手架脚本约定,node-server preset 产物位于 .output/server;query 请求级去重、action 成功后自动 revalidate 本页 query、useSubmission 四态(pending/result/error/input)、useAction 可编程调用均为官方文档描述;HTML5 拖放的 dragover preventDefault 要求为 WHATWG 规范行为。本篇未做性能实测;安装命令不钉版本号。