{ "version": "https://jsonfeed.org/version/1", "title": "Niapya's Blog", "home_page_url": "https://niapya.github.io", "description": "这里是 Niapya's Blog。", "items": [ { "id": "https://niapya.github.io/posts/how-to-build-a-serverless-agent", "content_html": "
4 月初,我们开源了 Serverless 项目 ClawLess,它可以在你的 Vercel 账户上免费部署一个 24 小时可用的轻量 Agent。
\n这篇文章想记录的是 ClawLess 背后的取舍:为什么它应该是 Serverless 的,以及我们如何用 Vercel Workflow、Vercel AI SDK、Vercel Sandbox、KV、Blob 和 Postgres Vector 组装出一个能工作的 Agent。
\n构建一个完整、长期在线的 Agent 运行时,成本其实不在模型,而在一直运行。
\n模型你可以选择低价或者免费的模型,但你仍然需要一个自己的服务器、VPS,或者一台长期在线的 Mac mini。
\n有些项目已经提出了另一种解决方案:使用更小的原生语言重写运行时,把内存占用压低,让它可以运行在树莓派、开发板,或者更便宜的 Linux 设备上。
\n他们至少需要一个长期在线的设备来运行 Agent,维护它的状态,等待用户消息,处理定时任务,而 ClawLess 一开始就不是这个方向。
\n对大多数人来说,Agent 最常见的入口仍然是 chatbot:用户发送消息,Agent 读取上下文、调用工具、返回结果。它不需要每一秒都在 CPU 上运行,它只需要在被唤醒时可靠地处理请求。
\n我们只需要在它接收到会话的时候处理请求即可:
\n除此之外,我们还需要定时任务和延迟任务。比如「明天提醒我继续这个话题」,或者「每天早上把某个 channel 的信息整理一下」。这类任务本质上不要求常驻进程,只要求平台能把某个 workflow 暂停、等待,并在合适的时间继续。
\n老朋友都知道我比较喜欢 Serverless。它的核心是请求进来时启动,任务挂起时休眠,时间到了再恢复。
\n由于这种特性,Serverless 服务平台可以以非常低的价格部署,特别是对于 JS Rutime 的,比如 AWS Lambda、Vercel、Cloudflare Workers,而且很多平台都有足够尝鲜的 free tier。
\n经过调研,我看上了 Vercel Workflow。它可以启动长时间任务,可以在 Workflow 里等待,也可以在特定时间继续执行,因此 ClawLess 可以被消息、Webhook、工具审批和 schedule 唤醒的 Workflow。
\n换句话说,ClawLess 并不需要你一直运行,只需要按需唤醒。这恰好是 Workflow 这类运行时擅长的事情。
\n因为同时有前后端,这是一个全栈项目。
\n对于全栈,我们曾尝试过使用 Nuxt,或是 Hono SSR 的方式来构建全栈,但我们采用 Next.js 来作为全栈框架,因为我们要使用 Vercel Workflow 的 API 来构建 Agent,而 Next.js 是 Vercel Workflow 的原生支持框架。
\n我们先谈一下 Vercel Workflow,它是 Vercel 最近推出的官方、长时间运行的、带有状态的流程运行框架。经测试,一个 workflow 能运行至少 10 分钟以上。
\n\n\n我们在做 ClawLess 的时候,Vercel Workflow 还是处于 Beta 阶段,但是现在 API 已经稳定。
\n
它使用 \"use workflow\"; 和 \"use step\"; 指令来定义一个 workflow。
use workflow 负责“编排”,也就是跨步骤的状态、等待、恢复和整体流程。use step 负责“执行”,也就是可以单独重试、适合封装副作用的原子操作。对于 Agent 来说,真正调用数据库、第三方 API、Sandbox 命令的逻辑,通常放在到 step 里;而等待用户消息、等待审批,比如等待明天早上 9 点继续执行的逻辑,则更适合留在 workflow 这一层。
一个 Workflow 的例子如下:
\nasync function updateUser(userId: string) {\n\t\"use step\";\n\tawait db.insert({ id: userId });\n}\n\nexport async function userOnboardingWorkflow(userId: string) {\n \"use workflow\";\n await updateUser(userId);\n}\n\nexport async function POST() {\n await updateUser(\"123\");\n}\n\n然后对于 AI 来说,毫无疑问,TypeScript 最流行的 AI framework 是 Vercel AI SDK。
\nimport {\n\tconvertToModelMessages,\n\tstreamText,\n\ttype UIMessage,\n} from \"ai\";\n\nexport async function POST(req: Request) {\n\tconst { messages }: { messages: UIMessage[] } = await req.json();\n\n\tconst result = streamText({\n\t\tmodel: \"anthropic/claude-sonnet-4.5\",\n\t\tsystem: \"You are a helpful assistant.\",\n\t\tmessages: await convertToModelMessages(messages),\n\t});\n\n\treturn result.toUIMessageStreamResponse();\n}\n\n然后在前端,你可以使用 useChat 这个 Hook 来轻松地实现一个聊天界面。
'use client';\n\nimport { useChat } from '@ai-sdk/react';\nimport { DefaultChatTransport } from 'ai';\nimport { useState } from 'react';\n\nexport default function Page() {\n const { messages, sendMessage, status } = useChat({\n transport: new DefaultChatTransport({\n api: '/api/chat',\n }),\n });\n const [input, setInput] = useState('');\n\n return (\n <>\n {messages.map(message => (\n <div key={message.id}>\n {message.role === 'user' ? 'User: ' : 'AI: '}\n {message.parts.map((part, index) =>\n part.type === 'text' ? <span key={index}>{part.text}span> : null,\n )}\n div>\n ))}\n\n <form\n onSubmit={e => {\n e.preventDefault();\n if (input.trim()) {\n sendMessage({ text: input });\n setInput('');\n }\n }}\n >\n <input\n value={input}\n onChange={e => setInput(e.target.value)}\n disabled={status !== 'ready'}\n placeholder=\"Say something...\"\n />\n <button type=\"submit\" disabled={status !== 'ready'}>\n Submit\n button>\n form>\n >\n );\n}\n\n\n\n\n
useChatHooks 还提供了 Vue, Svelte 和 Angular 的库。
这已经能完成一个很好用的 Web Chat。但它还不是我们要的 Durable Agent,因为请求中断/结束之后,运行时也就跟着结束了。
\n这与我们 Agent 的逻辑不同,我们希望在请求中断后还能继续运行,并且在运行后持久化以保存状态。
\n所以我们的 Workflow 配备了 AI 模块,它和 AI SDK 深度集成,并提供了更适合 workflow 场景的 DurableAgent。
Workflow 的 getWritable() 可以拿到一个持久化的输出流,客户端断开以后还能重新连接回来,继续接收之前没看完的内容。
下面这段是一个简化示意:
\nimport { DurableAgent } from \"@workflow/ai/agent\";\nimport { getWritable } from \"workflow\";\nimport type { UIMessage, UIMessageChunk } from \"ai\";\n\nexport async function chatWorkflow(messages: UIMessage[]) {\n\t\"use workflow\";\n\n\tconst writable = getWritable<UIMessageChunk>();\n\tconst agent = new DurableAgent({\n\t\tmodel: \"anthropic/claude-sonnet-4.5\",\n\t\tsystem: \"You are a helpful assistant.\",\n\t});\n\n\tawait agent.stream({\n\t\tmessages,\n\t\twritable,\n\t});\n}\n\n这时候,Agent 才真正开始像一个自主的系统。
\n\n\n在 ClawLess 中,我们的过程更加复杂,主 workflow 会从 KV 读取当前配置,构建 system prompt,从 MCP 加载工具,创建
\nDurableAgent,然后把输出写到 UI message stream,并且持久化,最后再把状态保存到 Postgress SQL 中。
考虑更多,比如用户在网页继续发消息,或者 IM webhook 继续发消息时,如果 workflow 还在运行,我们应该去引导(Follow-up)Agent 处理这些消息,而不是直接发起新的 workflow。
\n\n\n这是 Human in the Loop 的基本逻辑。如果你想了解更多,你可以看 WikiPedia 中的 HITL, Human-in-the-Loop.
\n
庆幸的是,Workflow 提供了 hook 来做到这些。hook 的价值是:它允许一个正在运行的 workflow 暂停在某个位置,等外部事件到来之后再继续。
\n最简单的外部事件就是“下一条用户消息”:
\nimport { defineHook } from \"workflow\";\n\n// 定义一个 hook,用于接收外部事件(下一条用户消息)\nexport const messageHook = defineHook<{\n\ttext: string;\n\tuserId: string;\n}>();\n\nexport async function conversation(sessionId: string) {\n\t\"use workflow\";\n\n\tconst events = messageHook.create({\n\t\ttoken: \"chat-\" + sessionId,\n\t});\n\n\t// 等待外部事件(下一条用户消息)\n\tfor await (const event of events) {\n\t\tconsole.log(\"Received\", event.text, \"from\", event.userId);\n\t}\n}\n\nexport async function POST(req: Request) {\n\tconst data = await req.json();\n\n\tawait messageHook.resume(\"chat-\" + data.sessionId, {\n\t\ttext: data.text,\n\t\tuserId: data.userId,\n\t});\n\n\treturn new Response(\"OK\");\n}\n\n同一个机制也很适合做工具审批。比如模型想执行一个高风险命令:删文件、发消息、下单、转账,这时候就不该让它直接运行,而是要把审批请求抛给前端,再等用户确认。
\n\n\n更多信息详见 AI SDK 中关于 Tool Execution Approval 的说明。
\n
Workflow hook 的好处是,你不需要自己轮询数据库,不需要写一套复杂的状态机,因为 workflow 会在等待时挂起,恢复时又从原来的状态继续。
\n所以,我们总结一下一个 Agent 的顺序:
\nAgent 有上下文限制(Context Limit)。不同模型的 token 计算方式不同,但它们都有一个共同限制:你必须把 system prompt、历史消息、工具结果、文件输入和记忆都塞进上下文里。
\n不仅如此,模型在处理长上下文时,正确率也会下降,成本也会增加。
\n处理长上下文有常见的两种方法,压缩和剪枝。
\n压缩(Compression)是把“很长但以后还需要”的历史,改写成更短的表示。最常见的做法是:在会话进行到一定长度之后,用模型把早期对话、关键工具结果、已经确认的用户偏好压成一段摘要,然后把原始消息移出当前上下文窗口。
\n下一轮真正调用模型时,你只需要把摘要重新组装回来。
\n\n\n如果你考虑更多,你可以采用滚动窗口的方法,把摘要和最近几轮的原始消息以及当前输入加进来。
\n
import { generateText, type ModelMessage } from \"ai\";\n\nexport async function compactMessages(messages: ModelMessage[]) {\n\tconst summary = await generateText({\n\t\tmodel,\n\t\tmessages: [\n\t\t\t...messages,\n\t\t\t{\n\t\t\t\trole: \"user\",\n\t\t\t\tcontent: \"请总结一下我们之前的对话,提炼出对后续有帮助的关键信息。\"\n\t\t\t}\n\t\t]\n\t});\n\n\treturn {\n\t\tsummary: summary.text,\n\t\trecentMessages: messages.slice(-8),\n\t};\n}\n\n在 Workflow 里,这种做法尤其自然。因为 workflow 本来就有自己的生命周期和外部存储层,你完全可以在某一轮结束后把摘要写入 KV,把旧消息删掉,只保留一个“会话摘要”字段。这样 durable session 仍然存在,但真正送进模型的上下文不会无限增长。
\n另一种方法是剪枝(Pruning)。它不试图理解历史,而是直接把“对当前轮次帮助不大”的内容裁掉。
\nAI SDK 已经提供了 pruneMessages,可以在送进模型之前移除旧的 reasoning、工具调用结果、审批痕迹和空消息。
import { pruneMessages, type ModelMessage } from \"ai\";\n\nexport function buildContextWindow(messages: ModelMessage[]) {\n\tconst pruned = pruneMessages({\n\t\tmessages,\n\t\treasoning: \"before-last-message\",\n\t\ttoolCalls: \"before-last-2-messages\",\n\t\temptyMessages: \"remove\",\n\t});\n\n\tconst pinned = pruned.filter((message) => message.role === \"system\");\n\tconst recent = pruned.filter((message) => message.role !== \"system\").slice(-12);\n\n\treturn [...pinned, ...recent];\n}\n\n对于 Workflow AI,通常会在每一轮调用前通过 prepareStep 来重写。
const result = await agent.stream({\n\tmessages,\n\twritable,\n\n\t// prepareStep hook 会在每次调用模型前被调用,用来重写上下文窗口\n\tprepareStep: async ({ messages: currentMessages }) => {\n\t\treturn {\n\t\t\tmessages: buildContextWindow(currentMessages),\n\t\t};\n\t},\n});\n\n这两种方法并不冲突。实际工程里更常见的是组合使用,完全取决于设计。
\n\n\n这是有关 Context Engineering 的概念,你可以阅读 Anthropic 的文章 Effective Context Engineering for AI Agents.
\n
但是,我们还要要考虑的更多,比如说跨对话的请求之间的,同一知识之间的,或者是永久保存的上下文共享,这个时候我们需要引入工具来把它们存储在外部系统里,而不是直接放在模型上下文里。
\nAgent 的工具决定了它的能力边界。
\n如果没有工具,它只是一个聊天模型;有了工具,它才可以保存记忆、执行命令、学习技能、安排任务,甚至把一部分工作交给子 Agent。
\n在 AI SDK 里,工具本身就是一等公民。一个最小的工具定义大概像这样:
\nimport { tool } from \"ai\";\nimport { z } from \"zod\";\n\nconst tools = {\n\tremember: tool({\n\t\tdescription: \"Save a useful fact into long-term memory\",\n\t\tinputSchema: z.object({\n\t\t\tnote: z.string(),\n\t\t}),\n\t\texecute: async ({ note }) => {\n\t\t\tconst memory = await buildMemory(note);\n\t\t\treturn { ok: true, memory };\n\t\t},\n\t}),\n};\n\n记忆是存储长期事实,用户偏好的部分。
\n你可以把它存储到 KV、数据库,或者以文件形式存储到 Blob,然后给模型添加工具来管理它。
\n在搜索时,最方便的时全文关键词搜索,但是有很多局限性,比如两个句话关联性强但是没有重叠。
\n你在公司内部系统搜索“如何处理产假期间的奖金核算”。问题:如果公司制度里使用的是“生育津贴”、“女职工福利”或“长期病假(生育)规定”,而没有同时包含“产假”和“奖金”这两个具体词汇,全文搜索会直接显示“未找到相关结果”,即便系统里实际上有相关的政策文档。
\n所以,我们可以考虑 RAG, Retrieval-augmented generation, 检索增强生成,这是一种让模型在生成响应时,从外部知识库中检索相关信息的方法。
\n最简单的是向量嵌入搜索,把一句话通过向量模型映射到一个多维向量上,然后在向量空间中查找最相似的句子。
\n在查找最相似时,可以使用余弦相似度(Cosine Similarity)的计算方法。
\nSimilarity=q^⋅d^=i=1∑nq^id^i\n将计算出来相似度归一化,之后排序。
\n在真实系统中,我们一般用混合搜索,也就是将向量搜索和关键词搜索结合起来。
\n\n\n更多关于 Memory 的探究,你可以参考 OpenClaw 的 Memory 设计.
\n
技能更像基于文件系统的知识库。
\n一个 Skill 一般存储在文件系统,它目录结构可能是这样的:
\nmy-skill $ tree\n\nmy-skill/\n\tSKILL.md\n\n\tscripts/\n\ttemplates/\n\texamples/\n\tassets/\n\nSkills 表明了怎么做事,比如:
\n我们一般会把 Skills 的标题和描述提取出来,注入到 System Prompt 中。
\n\n\nSkills 是 Anthropic 提出的,具体请看 Agent Skills 概述.
\n
执行工具是 Agent 真正开始接触外部世界的地方。
\n一个最基础的执行工具就是 Bash 工具,他可以执行 Shell 命令,它允许了 Agent 和真实环境的连接起来。
\n\n\n在 ClawLess 中,我们使用 @vercel/sandbox 来实现 Bash 工具。
\n
Bash 风险最高,也最需要隔离,要特别考虑工具审核和沙箱环境的隔离。
\n\n\n如果你在寻找 ServerLess 的 Bash 工具或者是需要一个沙箱 Bash 工具,你可以考虑看看我们的 AI SDK X.
\n
在处理复杂的任务时,我们还需要考虑更多。
\n延时任务会在一定时间之后提醒你。
\nClawLess 在 Workflow 内部使用 sleep 函数来实现延时任务。
import { sleep } from \"workflow\";\n\nexport async function remindTomorrow(taskId: string) {\n\t\"use workflow\";\n\n\tawait sleep(\"1 day\");\n\tawait sendReminder(taskId);\n}\n\n定时任务又称作 Cron Job,它可以让你在指定的时间点执行任务。
\n定时任务的实现与延时任务相似,我们使用一个 while (true) 循环来实现定时任务。
while (true) {\n\tawait sleep(\"1 day\");\n\trunTask();\n}\n\n子任务不一定非要是另一个完整 Agent,他会把一部分小的任务委托出去。
\n例如主 Agent 负责和用户对话,但某个研究问题可以单独交给一个更便宜的小模型。
\n一个最小的 Sub-Agent 实现可以是这样的。
\nimport { generateText, tool } from \"ai\";\nimport { z } from \"zod\";\n\nconst delegateResearch = tool({\n\tdescription: \"Hand off a focused research subtask\",\n\tinputSchema: z.object({\n\t\tquestion: z.string(),\n\t}),\n\texecute: async ({ question }) => {\n\t\tconst result = await generateText({\n\t\t\tmodel: \"openai/gpt-4.1-mini\",\n\t\t\tprompt: \"Answer briefly and return only key facts:\n\" + question,\n\t\t});\n\n\t\treturn { summary: result.text };\n\t},\n});\n\n模型上下文协议 (MCP, Model Context Protocol) 是一个统一的协议,他让 Agent 统一接入外部系统的能力。
\n如果你在做一个特定领域的 Agent,往往更适合 MCP,而不是一堆 Skills。
\nClawLess 其实并没有做网页搜索(Web Search),浏览器访问(Browser User)等功能,我们可以通过 MCP 来接入这些能力。
\n与 Agent 交互的前端部分,我们可以通过网页,Bot 或是其他地方与 Agent 链接。
\n我们刚才的 useChat Hooks 就是最简单的网页对话部分,我们在这里不再叙述。
大部分即时通讯软件一般会有内置 Bot 的能力,例如 Telegram 、Discord 等。
\nBot 大部分只有两种接收消息的方法:长轮询和 Webhook。
\n长轮询意味着 Bot 需要每隔一段时间(我们称之为心跳周期),向服务器请求最新消息,OpenClaw 的大部分连接器就是这么做的。
\nWebhook 则是一种更高效的方式,它允许服务器在有新消息时立即通知 Bot,而不需要 Bot 主动请求。
\n在 ClawLess 内部使用 Chat SDK 来通过 WebHook 连接各个 IM。
\nChat SDK 特别简单,只需要你创建一个 Chat 实例。
\n// Next: lib/bot.ts\nimport { Chat } from \"chat\";\n\nconst bot = new Chat({\n userName: \"mybot\",\n adapters: { slack },\n state: createRedisState(),\n});\n\nbot.onNewMention(async (thread, message) => {\n await agent.handleMessage(message);\n});\n\n然后暴露一个适用于 WebHook 的 API 端口。
\n// Next: app/api/webhooks/slack/route.ts\nexport const POST = bot.webhooks.slack;\n\n看到这里,你会发现构建 Agent 的思路其实很简单。
\n我们特别希望看到你搭配这些,构建自己的 Agent,这真的特别有意思。
\n另外,我们在构建一个通用的沙盒虚拟 Bash,它适合任何一个 JS Runtime 的环境,以及 ServerLess 和嵌入式,如果你想了解,可以看看我们的 AI-SDK-X 文档.
", "url": "https://niapya.github.io/posts/how-to-build-a-serverless-agent", "title": "如何构建一个无服务器 Agent", "summary": "4 月初,我们开源了 Serverless 项目 ClawLess,它可以在你的 Vercel 账户上免费部署一个 24 小时可用的轻量 Agent。\n这篇文章想记录的是 ClawLess 背后的取舍:为什么它应该是 Serverless 的,以及我们如何用 Vercel Workflow、Vercel AI SDK、Vercel Sandbox、KV、Blob 和 Postgres Vector", "date_modified": "2026-06-01T00:00:00.000Z", "author": { "name": "Niapya" }, "tags": [ "编程", "Serverless", "Agent", "Vercel" ] }, { "id": "https://niapya.github.io/posts/welcome", "content_html": "对,我终于有了一个博客。
\n2026 年是 Vibe Coding 大火的一年,我们习惯了使用 Code Agent 来编写任何项目,原来的「手搓」代码的方式被调侃为「古法编程」,这个时候大部分人都没有动手习惯了,如果他们希望有一个博客,直接 Vibe 一个 Next.js 模版,甚至直接用现成的 SaaS 博客平台加上 Skills 让 Agent 帮忙写文章就好了。
但是我认为,博客作为自己的平台,理应是由作者掌控一切的,所以我希望可以从无到有做出一个自己的博客框架、自己的博客样式以及博客内容。
\n由于这类网站通常不需要服务器端逻辑,我们把博客这类网站称之为「静态网站」,所以我要做的是一个「静态站点生成器」(Static Site Generator,SSG)。
\nSSG 的流程很简单:获取内容源并解析成文档,使用模板渲染,输出为网站文件,而我希望掌控这一切。
\n第一步是获取内容源并解析成文档。
\n内容源的来源可能是任何东西,数据库、CMS、Markdown 文件等等,所以我们创建了一个统一的 Interface,类似于下面。
\nexport function definePost(\n\tinput: {\n\t\ttitle: string;\n\t\tcategory?: string[];\n\t\ttags?: string[];\n\t\tcontent: string;\n\t}\n): Post;\n\n这样一来,我们可以在任何地方以任何方式定义我们的内容了。未来我们可以使用插件,从文件路由、数据库来源,甚至浏览器中获取内容。
\n博客的内容 content 通常以 Markdown 格式编写,所以我使用 marked 来把 Markdown 编译为 HTML,并配合 marked-highlight 与 highlight.js 提供代码高亮。
import hljs from \"highlight.js\";\nimport { Marked } from \"marked\";\nimport { markedHighlight } from \"marked-highlight\";\n\n\nconst markdownRenderer = new Marked({\n\tbreaks: false,\n\tgfm: true,\n});\n\nmarkdownRenderer.use(\n\tmarkedHighlight({\n\t\temptyLangClass: \"hljs\",\n\t\tlangPrefix: \"hljs language-\",\n\t\thighlight(code, language) {\n\t\t\tconst highlightLanguage = hljs.getLanguage(language)\n\t\t\t\t? language\n\t\t\t\t: \"plaintext\";\n\n\t\t\treturn hljs.highlight(code, { language: highlightLanguage }).value;\n\t\t},\n\t}),\n);\n\nawait markdownRenderer.parse(markdown, { renderer });\n\n第二步是使用模板渲染。
\n由于我们要编写的是网站文件,在大多数 SSG 中,这个模板一般会和 HTML 结构强相关。如果选择自定义模板语法,就需要自己定义 AST 和编译器来处理,比如 Astro 的 .astro 模板本身就是一套框架自有的组件语法。与此同时,你还需要开发用于类型检查和语法高亮的工具链,这实在不利好开发者体验。
还有一些 SSG 使用 React/Vue 组件来编写模板,这样的模板虽然开发体验不错,但它们通常会和对应框架的生态绑定得更深。如果处理不当,甚至可能默认引入客户端 JavaScript。对于 SSG 来说,默认引入任何不必要的客户端 JavaScript 都是不明智的。
\n所以我选择了一个轻量的方案 —— 由 Hono/JSX 驱动的 JSX 来编写模板。它可以在使用 JSX 语法的情况下进行服务端渲染,最终输出 HTML 字符串,同时保持类型安全。对于纯静态页面来说,最终输出的 HTML 文件也不需要包含任何框架相关的客户端代码。
export function HtmlArticle({ html }) {\n\treturn (\n\t\t<div\n\t\t\tdangerouslySetInnerHTML={{ __html: html }}\n\t\t/>\n\t);\n}\n\nconst app = new Hono()\n\t.get(\"/\", (c) => {\n\t\treturn c.html(<HtmlArticle html={markdownRenderer.parse(markdown)} />)\n\t})\n\n第三步是输出为网站文件。
\n定义好内容和模板之后,我们需要定义网站的结构(也可以称之为路由)。既然我们使用 Hono/JSX,那我们可以通过一个 HTTP 框架来定义路由,Hono 就是这样的。而且借助 Hono 框架,我们可以轻松启动开发服务器。
\n\n在后续,我们还可以通过这种方式进行服务端渲染(SSR),但目前我们先专注于 SSG。
\n
Hono 提供了 toSSG 方法,能够在构建时把 Hono 应用的路由预渲染成静态文件,这样我们就可以通过 Hono 来输出静态文件了。
在 JSX 中设置 CSS 一直是一个难题,比如 CSS Modules,直到诸如 TailwindCSS 这样的原子化 CSS 方案出现。
我们选择了 UnoCSS 作为我们的 CSS 解决方案,因为它可以按需生成原子化 CSS,并且提供了可编程的生成器 API,可以直接把 CSS 类名编译成 CSS 代码。
import { createGenerator, presetWind4 } from \"unocss\";\n\nconst uno = await createGenerator({\n\tpresets: [presetWind4()],\n});\n\nexport async function compileAtomicCss(input: string): Promise<string> {\n\tconst { css } = await uno.generate(input);\n\treturn css;\n}\n\n默认的博客样式我选择了一个经典的麦金塔风格,我们正在计划使用 Figma 并制作相关的 UI 组件库,以打造一个复古风格的博客主题。
\n你也许会发现,本博客没有用到任何客户端 JavaScript,这正是我想要的,对于一个静态博客,他不需要客户端 JavaScript 的引入。
\n你可以在 GitHub 上找到这个项目的源代码。
\n我们未来应该会把这个项目变成一个真正的框架,然后推出一套自己的 UI 组件库。
\n以及下面的功能:
\n因为我们没有用到 Vite,也尽量避免依赖只属于 Node.js 环境的模块,所以这个框架未来甚至可以在浏览器中直接运行。