很多人都听过可以用 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 这边至少在未来几年将是一条经过未来验证的路线。