用 SolidJS 做一个看板小应用

要做什么

一个看板小应用:待办、进行中、已完成三列,卡片支持新建、删除、拖拽换列,刷新后位置不丢。技术栈是 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 规范行为。本篇未做性能实测;安装命令不钉版本号。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇