使用 Vite 和 Hono 开发全栈 web 应用
很多人都听过可以用 Vite 在浏览器中实现热加载模块的能力,并以这种方式使用它。但 Vite 能做的更多,特别是对于现代解决方法,新的后端框架也倾向于支持 Vite 的开发服务器。其中之一是 Hono。
与 Express 这样的传统框架相比,Hono 是一个相对较新的框架。它采用现代 Web API 的无服务器设计确实吸引了我的眼球。使用 Vite,Hono 应用程序也能从热模块重载中受益,这为我们提供了一种开发全栈应用程序的全新方式。
我有机会说服我的同事(在我的新工作场所)从使用 Koa/Egg.js 框架和 Webpack 的旧架构切换到这种新的 Vite + Hono 组合,并从中受益匪浅。使用这种新的全栈架构,我们不仅设法减少了后端和前端之间不必要的划分,而且还提高了代码质量,因为 Hono 可以在后端和前端之间共享 API 类型。
所以,在这里,我将与想要拥抱 Web 开发新时代的人分享我使用这个新栈的经验。我们开始吧。
1. 按照 Vite 的官方指南启动一个 React 项目
只需使用以下命令,如 Vite 的官方文档中所示,无需任何魔法。
npm create vite@latest vh-stack -- --template react-tscd vh-stack
npm install && npm run dev
如果幸运的话,我们应该会在终端中看到一些日志打印,显示我们有一个正在运行的 Vite 开发服务器,如下所示:
VITE v5.0.10 ready in 258 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
2. 将 Hono 集成到 Vite
现在,我们有一个由 Vite 的开发服务器运行的 React 项目。我们所要做的就是优雅地添加 Hono 及其相关组件以与 Vite 集成。
首先,我们需要安装 Hono 及其 Vite 插件,如下所示:
npm i cross-env hono @hono/node-server
npm i -D @types/node @hono/vite-dev-server
然后,修改 vite.config.ts
以包含 Hono 插件:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import devServer from '@hono/vite-dev-server'
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 4000, // 更改为自定义端口
},
build: {
outDir: "build", // 更改为“build”,稍后解释
},
plugins: [
react(),
devServer({
entry: "server.ts",
exclude: [ // 我们需要覆盖此选项,因为默认设置不合适
/.*\.tsx?($|\?)/,
/.*\.(s?css|less)($|\?)/,
/.*\.(svg|png)($|\?)/,
/^\/@.+$/,
/^\/favicon\.ico$/,
/^\/(public|assets|static)\/.+/,
/^\/node_modules\/.*/
],
injectClientScript: false, // 此选项有缺陷,禁用它并手动注入代码
})
],
})
3. 重构我们的项目
现在是编写一些 Hono 应用程序的时候了。在项目文件夹中创建一个名为 server.ts
的文件,如下面的配置中所示。
// server.ts
import { Hono } from "hono"
import { serve } from "@hono/node-server"
import { serveStatic } from "@hono/node-server/serve-static"
import { readFile } from "node:fs/promises"
const isProd = process.env["NODE_ENV"] === "production"
let html = await readFile(isProd ? "build/index.html" : "index.html", "utf8")
if (!isProd) {
// 将 Vite 客户端代码注入到 HTML
html = html.replace("<head>", `
<script type="module">
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="/@vite/client"></script>
`)
}
const app = new Hono()
.use("/assets/*", serveStatic({ root: isProd ? "build/" : "./" })) // 路径必须以 '/' 结尾
.get("/*", c => c.html(html))
export default app
if (isProd) {
serve({ ...app, port: 4000 }, info => {
console.log(`Listening on http://localhost:${info.port}`);
});
}
现在我们应该看到 Vite 程序已经热加载了,我们的网页由 Hono 提供服务,而不是内置的 Vite 开发服务器。
如果注意观察,你会发现我在 serveStatic
中间件中使用了 views/assets
而不是 src/assets
,这是故意的,因为我们正在开发一个全栈应用程序,默认的 src
文件夹只包含前端的 TSX 文件,所以它并不是一个存储我们文件的理想目录结构。
因此,下一步将是为我们的应用程序创建一个理想的结构。根据我多年构建全栈应用程序的经验,我发现以下结构相当优雅。
api
用于存储我们的 API 网关应用程序的文件夹,也就是可以使用 TS 编写的路由处理程序或控制器。build
用于存储前端构建文件的文件夹,我们捆绑的 HTML、JS、CSS 文件将存储在这里。components
用于存储可重复使用的前端 React 组件的文件夹。dist
用于存储后端编译的 JS 文件的文件夹。models
用于存储模型或架构文件的文件夹。public
用于存储静态和公共资源的文件夹。services
这个文件夹不是必须的,但许多人喜欢将业务逻辑抽象成服务。utils
用于存储实用程序函数的文件夹。utils/common.ts
用于后端和前端的实用程序函数。utils/backend.ts
专门用于后端的实用程序函数。utils/frontend.tsx
专门用于前端的实用程序函数。views
用于存储 UI 视图的文件夹,也就是说可以使用 TSX 编写的网页。server.ts
后端的入口文件。client.tsx
前端的入口文件。index.html
托管网络应用的网页。
因此,我们需要首先将 src
文件夹重命名为 views
,然后将 views/main.tsx
重命名为 client.tsx
。重命名后,其内容应如下所示:
// client.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './views/App.tsx'
import './views/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
现在,我不打算详细讨论 client.tsx
,我们只需要将 index.html
中的 script.src
属性更新为 client.tsx
,如下所示:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Hono + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/client.tsx"></script>
</body>
</html>
4. 全栈开发的重新配置
目前我们的程序现在运行正常,如果我们尝试修改 server.ts
中的代码,Vite 将按预期热重新加载服务器模块。例如,我们可以在 Hono 应用程序中添加一个中间件来检查效果。
// server.ts
// ...
app.use("*", async (c, next) => {
c.res.headers.set("X-Powered-By", "Hono")
await next()
})
/...
我们将看到不仅我们的网页被热重载了,而且新的响应现在有一个新的 X-Powered-By: Hono
响应头。
但我不想止步于此,我想展示这种架构的实际应用程序结构和代码示例。所以让我们继续前进。
下一步是配置构建工具,虽然我们现在有一个 build
脚本供 Vite 构建前端应用程序,但我们仍然缺少构建后端应用程序的配置。
我建议使用 Rollup 来构建后端 Node.js 应用程序。你可能会问,为什么不直接用 tsc
呢?好吧,我可以告诉你为什么:tsc
在支持 TypeScript 方面非常糟糕。它不允许我们在源文件中使用 .ts
扩展名,或者我们需要更改大量 tsconfig.json
文件,然后中断前端配置。由于我们的项目依赖于适用于前端和后端的同一个 tsconfig.json
,我不想冒险只为了使用 tsc
,所以还是用 Rollup 吧。
首先,删除由 Vite 创建的 tsconfig.node.json
,这对我们来说是无用的,并修改 tsconfig.json
如下:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules/**"]
}
然后安装 Rollup 和相关插件,
npm i -D glob rollup @rollup/plugin-typescript @rollup/plugin-node-resolve @rollup/plugin-commonjs
还需创建一个 rollup.config.mjs
文件,其中包含以下内容:
import { glob } from "glob"
import { extname, sep } from "node:path"
import { fileURLToPath } from "node:url"
import { builtinModules } from "node:module"
import typescript from "@rollup/plugin-typescript"
import resolve from "@rollup/plugin-node-resolve"
import commonjs from "@rollup/plugin-commonjs"
export default {
input: Object.fromEntries(
glob.sync([
"server.ts",
"api/**/*.ts",
"models/**/*.ts",
"services/**/*.ts",
"utils/**/*.ts",
], {
ignore: [
"**/*.d.ts",
"**/*.test.ts",
]
}).map(file => [
file.slice(0, file.length - extname(file).length),
fileURLToPath(new URL(file, import.meta.url))
])),
output: {
dir: "dist", // set to 'dist' as mentioned earlier
format: "esm",
sourcemap: true,
preserveModules: true,
preserveModulesRoot: ".",
},
external(id) {
return id.includes(sep + 'node_modules' + sep)
},
plugins: [
typescript({ moduleResolution: "bundler" }),
resolve({ preferBuiltins: true }),
commonjs({ ignoreDynamicRequires: true, ignore: builtinModules }),
]
}
然后修改 package.json
,将 build
脚本更改为 tsc && vite build && rollup -c rollup.config.mjs
。现在,每次运行 npm run build
,前端和后端都将被编译。
最后,在 package.json
中,添加一个新的脚本 start
,应该是 cross-env NODE_ENV=production node dist/server.js
。这样就完成了,在运行 npm run build
之后,我们可以使用 npm run start
以生产模式启动我们的应用程序。
5. 一个实际的例子
如果这篇文章没有包含实际的后端逻辑,那么它就不完整了。所以,我刚刚创建了一个简单的 todo 示例,结合了模型、服务、API 网关(或者如果您想这样称呼它,控制器)和实用程序函数的使用。
// models/Todo.ts
import { z } from "zod";
const Todo = z.object({
id: z.number().int(),
title: z.string(),
description: z.string(),
deadline: z.date(),
})
type Todo = z.infer<typeof Todo>
export default Todo
// services/TodoService.ts
import Todo from "../models/Todo";
export default class TodoService {
private idCounter = 0;
private store: (Todo | null)[] = [];
async list() {
const list = this.store.filter(item => item !== null) as Todo[]
return await Promise.resolve(list); // simulate async, service method should always be async
}
async add(item: Omit<Todo, "id">) {
const id = ++this.idCounter;
const todo = { id, ...item }
this.store.push(todo);
return await Promise.resolve(todo);
}
async delete(query: Pick<Todo, "id">) {
const index = this.store.findIndex(item => item?.id === query.id)
if (index === -1) {
return false
} else {
this.store[index] = null
return true
}
}
async update(query: Pick<Todo, "id">, data: Omit<Todo, "id">) {
const todo = this.store.find(item => item?.id === query.id)
if (todo) {
return Object.assign(todo, data)
} else {
throw new Error("todo not found")
}
}
}
// api/todo.ts
// 导入 Hono 库和必要的模块
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { useService } from "../utils/backend";
import TodoService from "../services/TodoService";
import Todo from "../models/Todo";
// 使用 useService 函数实例化 TodoService
const todoService = useService(TodoService);
// 创建一个新的 Hono 实例
const todo = new Hono();
// 定义用于获取所有待办事项列表的 GET 路由
todo.get("/list", async (c) => {
// 从服务中获取待办事项列表
const list = await todoService.list();
// 将列表作为 JSON 响应返回
return c.json({
success: true,
data: list,
});
});
// 定义用于添加新待办事项的 POST 路由
todo.post("/", zValidator("json", Todo.omit({ id: true })), async (c) => {
// 从请求中获取待办事项数据
const data = c.req.valid("json");
// 使用服务添加新待办事项
const newTodo = await todoService.add(data);
// 将新待办事项作为 JSON 响应返回
return c.json({
success: true,
data: newTodo,
});
});
// 定义用于更新现有待办事项的 PATCH 路由
todo.patch("/:id", zValidator("json", Todo.omit({ id: true })), async (c) => {
// 从请求路径中获取待办事项 ID
const id = Number(c.req.param("id"));
// 从请求中获取待办事项数据
const data = c.req.valid("json");
// 使用服务更新待办事项
const updatedTodo = await todoService.update({ id }, data);
// 将更新后的待办事项作为 JSON 响应返回
return c.json({
success: true,
data: updatedTodo,
});
});
// 定义用于删除现有待办事项的 DELETE 路由
todo.delete("/:id", async (c) => {
// 从请求路径中获取待办事项 ID
const id = Number(c.req.param("id"));
// 使用服务删除待办事项
const success = await todoService.delete({ id });
// 将删除结果作为 JSON 响应返回
return c.json({
success,
data: null,
});
});
// 导出 todo 路由模块
export default todo;
// api/index.ts
// 导入 Hono 库
import { Hono } from "hono";
// 导入 todo 路由模块
import todo from "./todo";
// 创建一个新的 Hono 实例
const api = new Hono();
// 将 todo 路由注册到 API 路由中
api.route("/todo", todo);
// 导出 API 路由模块
export default api;
// utils/backend.ts
// 定义用于创建单例服务的符号
const singleton = Symbol.for("singleton");
// 创建一个通用的 useService 函数,用于创建单例服务
export function useService<T>(ctor: (new () => T) & {
[singleton]?: T;
}): T {
// 如果服务尚未创建,则创建一个新的服务实例
if (!ctor[singleton]) {
ctor[singleton] = new ctor();
}
// 返回服务实例
return ctor[singleton];
}
// utils/frontend.ts
// 导入 Hono 客户端库和应用类型
import { hc } from "hono/client";
import type { AppType } from "../server";
// 创建一个 Hono 客户端实例,用于访问后端 API
const { api } = hc<AppType>("/", {
headers: {
"Content-Type": "application/json",
},
});
// 导出 Hono 客户端实例
export { api };
// server.ts
// 代码省略...
// 创建一个新的 Hono 实例
const app = new Hono();
// 在每个请求中添加 X-Powered-By 头
app.use("*", async (c, next) => {
c.res.headers.set("X-Powered-By", "Hono");
await next();
});
// 将 API 路由注册到 Hono 实例中
app.route("/api", api);
// 将静态文件服务注册到 Hono 实例中
app.use("/assets/*", serveStatic({ root: isProd ? "build/" : "./" }));
// 将单页应用注册到 Hono 实例中
app.get("/*", c => c.html(html));
// 导出 Hono 实例和应用类型
export default app;
export type AppType = typeof app;
// 代码省略...
由于示例代码较长,因此我简化了使用 api
命名空间访问后端数据的 React 代码。读者可以参考 Hono 官方文档了解更多信息:
https://hono.dev/guides/rpc#client
在 React 组件和页面中,只需从 utils/frontend.ts
导入 api
变量,然后像调用本地函数一样使用它。例如:
// views/index.tsx
import { useEffect, useState } from "react"
import { api } from "../utils/frontend"
import Todo from "../models/Todo"
export function IndexPage() {
const [todoList, setTodoList] = useState([] as Todo[])
useEffect(() => {
(async () => {
const res = await api.todo.list.$get()
const result = await res.json()
if (result.success) {
setTodoList(result.data)
} else {
setTodoList([])
}
})()
}, [])
return (
<>...</>
)
}
最后的话
我对 Next.js 有些经验,那是在大约 3 年前,当时它还是版本 10。我喜欢它全栈开发的概念,但我不喜欢它的设计,尤其是在它使用 Webpack 时,在当时 Webpack 非常慢,而且我并不需要 SSR。所以我决定为我的工作基于 Vite 构建一个私有的全栈架构。从那时起,我就开始喜欢在前端和后端之间混合使用东西。
大约两周前,我开始将 Vite 和 Hono 结合在一起,我真的很喜欢。它们代表了简单性、快速启动以及采用最新网络 API 的现代设计。Hono 构建了一个利用热重载的 Vite 插件,这很巧妙。人们可能认为 Bun + Elysia 是热重载的最佳方式,但由于 Bun 还不太稳定,而我自己也检查过 Elysia,我非常有信心站在 Vite + Hono 这边至少在未来几年将是一条经过未来验证的路线。