# NodeWebTemplate
**Repository Path**: hwnwdtx/node-web-template
## Basic Information
- **Project Name**: NodeWebTemplate
- **Description**: No description available
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-09-11
- **Last Updated**: 2026-09-14
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# Node Web Template
Vue 3 + Vite 前端 · Fastify 后端 的一体化模板。一条 `npm run build` 同时构建两端,后端产出**免安装的 Node 服务**(依赖内联在 `dist/vendor/` 下,每个依赖一个文件),目标机器只要有 Node 就能跑,**不需要 npm install**。
- 前端:Vue 3(纯 JS + JSDoc)、Vue Router 5(**HTML5 History 模式**)、Vite(root 为 `src/client`)、`@` 别名指向 `src/client`
- 后端:Fastify 5、`@fastify/cors`、`@fastify/static`、pino 统一文件日志(见「日志」一节)
- 打包:Vite 出 `dist/static`,esbuild 出 `dist/index.mjs` + `dist/vendor/*.mjs`
- 开发:Vite dev server 代理 `/api`,后端 `node --watch` 热重启;生产同源一个端口
## 环境要求
- Node.js >= 20.19.0(用到内置 `process.loadEnvFile`、`node --watch`)
- 开发环境实测于 Node 22
## 快速开始
```bash
npm install # 安装依赖
npm run dev # 同时启动前端(5173) + 后端(3000)
```
打开 http://localhost:5173 。页面会自动请求 `/api/health`,卡片上的绿点表示后端连通。
## 常用脚本
| 命令 | 作用 |
| --- | --- |
| `npm run dev` | 并行启动 Vite dev server 和 Fastify(`node --watch`),Ctrl+C 一起退出 |
| `npm run dev:web` | 只启动前端 |
| `npm run dev:api` | 只启动后端 |
| `npm run build` | 清空 `dist` → 构建前端 → 打包后端 |
| `npm run build:web` | 只构建前端(只清 `dist/static`) |
| `npm run build:server` | 只打包后端(只清 `dist/index.mjs` 和 `dist/vendor/`,不动 `dist/static`) |
| `npm start` | 运行构建产物 `node dist/index.mjs` |
| `npm run serve` | `build` + `start`,本地验证生产形态 |
| `npm run clean` | 删除 `dist` |
## 目录结构
```
.
├── vite.config.js # 前端构建配置(root = src/client,outDir = dist/static)
├── jsconfig.json # 编辑器智能提示 + @ 别名
├── .env.example # 环境变量样例,复制为 .env 使用
├── public/ # 静态资源,原样拷贝到产物根目录(与 src/ 平级)
├── src/
│ ├── client/ # ── 前端(Vite root)──
│ │ ├── index.html # Vite 入口 HTML
│ │ ├── main.js # createApp → use(router) → mount
│ │ ├── App.vue # 布局:顶栏导航 +
│ │ ├── router/index.js # 路由表(createWebHistory)
│ │ ├── views/ # 路由级页面
│ │ │ ├── HomeView.vue
│ │ │ ├── AboutView.vue
│ │ │ └── NotFoundView.vue # catch-all 404
│ │ ├── services/ # 接口调用层(注意:不要叫 api/,见下方常见问题)
│ │ │ └── http.js # fetch 封装(baseURL / 错误处理)
│ │ ├── components/ApiStatus.vue
│ │ └── assets/main.css # 全局样式,含 .page / .card 等基础类
│ └── server/ # ── 后端 ──
│ ├── index.js # 启动、优雅退出
│ ├── app.js # createApp(),装配插件与路由
│ ├── config.js # .env 加载 + 配置归一化
│ ├── paths.js # 定位 dist/static
│ ├── plugins/static.js # 托管前端产物 + SPA 回退
│ └── routes/
│ ├── index.js # 路由挂载点
│ └── health.js # /api/health、/api/hello、/api/echo
└── scripts/
├── clean.mjs
└── build-server.mjs # esbuild 打包:业务代码一个文件,每个依赖一个 vendor 文件
```
## 构建产物与部署
`npm run build` 之后:
```
dist/
├── static/ # 前端静态资源(index.html + assets/)
├── index.mjs # 只含业务代码
└── vendor/ # 每个顶层依赖一个文件,各自的私有依赖已内联
├── fastify.mjs
├── fastify-static.mjs
└── fastify-cors.mjs
```
部署就是**整个 `dist` 目录**:
```bash
node dist/index.mjs
# 或指定端口
PORT=8080 HOST=127.0.0.1 node dist/index.mjs
```
目标机器上不需要 `npm install`,不需要 `node_modules`,只要有 Node 运行时即可。常见的做法是 `dist` 打进 Docker 镜像,基础镜像用 `node:22-slim`,`CMD ["node", "dist/index.mjs"]`。
产物会自己找到前端目录(逻辑在 `src/server/paths.js`):优先 `STATIC_DIR`;打包形态取 `dist/static`(与 `index.mjs` 同级),源码直跑取 `/dist/static`,最后兜底 `/dist/static`,命中含 `index.html` 的那个。两种形态分开判定是必要的 —— 盲探「相对层级」会把别的同名目录误当成产物。所以产物目录整体拷贝、换路径运行都没问题。
### 为什么依赖拆成 vendor/
`scripts/build-server.mjs` 分三步:先跑一趟不写盘的探测构建,从 esbuild 的 metafile 里读出**运行时真正用到的顶层依赖**(比读 `package.json` 准,装了没用到的包不会被算进来);然后每个依赖单独打一个文件;最后打业务代码,把它对外部依赖的引用改写成 `./vendor/xxx.mjs`。
拆开的好处:
- 改一行业务代码只需重新发 `index.mjs`(约 14 KB),`vendor/` 一个字节没变 —— 缓存和 diff 都能用
- 全塞一个文件时,任何改动都会让整个近 800 KB 的产物内容变化
- 体积明细一眼可见,哪个依赖突然膨胀立刻能发现
代价是依赖之间共享的子模块会被各自内联一份,总体积比单文件大约多 3%(本模板实测 782 KB → 806 KB)。
`index.mjs` 与 `vendor/` 是**同生共死**的一组文件,部署时要么一起拷、要么一起换,不能只替换其中一个。
### sourcemap
默认**不产出** `.map` —— 产物是直接部署的,`.map` 只占体积。需要排查线上问题时用:
```bash
npm run build:server -- --sourcemap
```
`--no-minify` 可以关掉压缩,产物可读性更好,同样只用于排查。
## 配置
复制 `.env.example` 为 `.env`(已 git 忽略)。
| 变量 | 默认 | 说明 |
| --- | --- | --- |
| `PORT` | `3000` | 后端监听端口 |
| `HOST` | `0.0.0.0` | 监听地址;仅本机访问改成 `127.0.0.1` |
| `LOG_LEVEL` | 开发 `debug` / 生产 `info` | 日志级别;`silent` 时完全不输出也不落盘 |
| `LOG_DIR` | 项目根目录下的 `logs/` | 日志目录;默认与 `dist/` 同级(不落进产物目录),源码直跑与打包产物都是 `/logs`。显式设置时以它为准,指向的就是日志目录本身 |
| `LOG_MAX_FILE_SIZE` | `50` | 单个日志文件上限(MB),超过即轮转归档 |
| `LOG_RETENTION_DAYS` | `7` | 归档保留天数,过期自动删除 |
| `CORS_ORIGIN` | 允许全部 | 逗号分隔白名单,如 `https://a.com,https://b.com` |
| `SERVE_STATIC` | 打包产物 `true` | 是否托管前端产物 |
| `STATIC_DIR` | 自动探测 | 手动指定前端目录 |
| `ENV_FILE` | `/.env` | 指定环境变量文件 |
| `VITE_API_BASE` | 空 | 前端接口前缀,分域部署时填完整地址 |
优先级:**进程环境变量 > `.env`**。所以 `PORT=4000 npm start` 可以临时覆盖配置文件。
> `NODE_ENV` 默认按运行形态推断:打包产物视为 `production`,`node src/server/index.js` 直跑视为 `development`(这就是 `serveStatic` 默认值的依据)。它是运行时读取的,不会被写死进产物。
## 路由与 History 模式
前端用的是 Vue Router 的 **HTML5 History 模式**,URL 形如 `/about`,不带 `#`。
它的代价是:在 `/about` 上刷新或直接把链接发给别人时,浏览器会**真的向服务端请求 `/about`**,而磁盘上并不存在这个文件。所以服务端必须把未匹配的路径回退到 `index.html`,剩下的交给前端路由。本模板两端都已经就绪:
- **开发环境** —— Vite dev server 自带 SPA fallback(`appType: 'spa'`,默认值),无需配置
- **生产环境** —— `src/server/plugins/static.js` 显式接管了 `/*`:命中真实文件就返回文件,否则返回 `index.html`;只有 `/api/*` 的未匹配路径返回 404 JSON,不会回退成 HTML
> 显式接管而不是依赖 `@fastify/static` 的内部行为,是为了让「`/api` 的 404 不会被前端 HTML 吞掉」这件事有确定的保证。
因为服务端不再能报告 404,**判定 URL 是否有效只剩前端这一道**,所以 `src/client/router/index.js` 末尾必须保留 catch-all:
```js
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFoundView.vue') }
```
给路由加 `meta.title` 即可自动写进 `document.title`(由 `router.afterEach` 统一处理)。
**部署到子目录** —— 把 `vite.config.js` 的 `base` 改成 `'/myapp/'` 就行。路由里写的是 `createWebHistory(import.meta.env.BASE_URL)`,会跟着自动变,业务代码不用动。
## 静态资源与缓存
后端由 `src/server/plugins/static.js` 显式接管,缓存策略按「文件名是否带内容 hash」区分:
| 请求 | Cache-Control | 理由 |
| --- | --- | --- |
| `/assets/*`(Vite 产物,文件名带 hash) | `public, max-age=31536000, immutable` | 内容变了文件名就变,可以放心缓存一年 |
| `/`、任意 SPA 路由(返回 index.html) | `no-cache` | 入口 HTML 必须每次协商,否则发新版本用户还看旧页面 |
| `public/` 下的固定文件名资源(如 `favicon.svg`) | `no-cache` | 文件名不变,长缓存会让用户看不到替换后的文件 |
`no-cache` 不代表不缓存,而是「每次都带 `ETag` 回来协商」—— 没变就是 304,只走一次轻量往返。实测:
```
GET / -> 200 cache-control: no-cache
GET / -> 304 (带 If-None-Match)
GET /assets/x.js -> 200 cache-control: public, max-age=31536000, immutable
GET /api/nope -> 404 application/json
```
> 有个坑值得记下:注册 `@fastify/static` 时传 `serve: false`,它只保留 `sendFile` 装饰器、不注册任何路由,请求才会全部落到我们自己的 `/*` 上。否则传 `wildcard: false` 会**切换成 glob 扫描产物目录、给每个文件注册精确路由**的模式 —— 那些路由比 `/*` 更具体,会直接抢走请求,导致 `sendFile(path, options)` 里的缓存选项静默失效。
## 日志
日志框架是 **pino**(Fastify 内置),`src/server/logging.js` 在其上做了统一的文件落盘、轮转与清理。业务代码不用关心这些 —— 照常 `app.log.info(...)` / `app.log.error(...)` 即可,stdout 与文件同时输出。
### 文件布局
```
logs/ # 默认在项目根目录下(与 dist/ 同级,不落进产物目录)
├── app.log # 当天完整日志(LOG_LEVEL 及以上)
├── error.log # 当天错误日志(error 及以上,内容是 app.log 的子集)
├── app-20260910-235959.log # 归档,文件名含最后写入时刻
└── error-20260910-235959.log
```
### 接口日志(入参 / 出参 / 耗时)
`/api` 前缀的请求会自动多记两行,业务路由不用写任何代码:
```
2026-09-11 15:04:12 INFO #req-3 接口入参 in {"body":{"title":"买牛奶"}}
2026-09-11 15:04:12 INFO #req-3 接口出参 out {"id":4,"title":"买牛奶",...} (3ms)
```
- 入参在 `preHandler` 记(body 已解析、校验已通过),包含 `query` 与 `body`;
- 出参在 `onSend` 记,附 `reply.elapsedTime` 执行耗时;
- 入参/出参单条超过 **2048 字符**自动截断并标注(接口 body 上限 10MB,不截断会把日志轮转瞬间打爆);
- 静态页面与静态文件不记;`/api` 下未匹配的 404 只记出参(入参 hook 不会对 404 触发)。
### 轮转与保留
- **轮转双条件,命中其一即触发**(每分钟检查一次):
1. **跨天** —— 过零点后,昨天的 `app.log` / `error.log` 归档为带时间戳的文件,新一天从空文件开始;
2. **超大小** —— 单文件超过 `LOG_MAX_FILE_SIZE`(默认 50 MB)即归档,归档名取最后写入时刻,同一天多次超限也不会互相覆盖。
- **保留近 7 天**(`LOG_RETENTION_DAYS`):过期归档在启动时与每次跨天轮转后自动删除,只删 `app-*.log` / `error-*.log` 命名模式的文件。
- 服务启动时,若发现上次运行留下的 `app.log` 不是今天写的,会先归档再开始新日志。
### 输出格式
文件与 stdout 均为纯文本(非 JSON),一行一条:
```
2026-09-11 13:51:09 INFO #req-1 request completed GET /api/todos -> 200 (12ms)
2026-09-11 13:51:09 ERROR oops: something failed
Error: something failed
at ...
```
错误对象的堆栈跟在日志行后面另起多行。
### 实现注意
- 业务代码**不直接 import pino**(`logging.js` 也不例外)。一旦业务源码 import pino,esbuild 打包时它会被拆成独立的 `vendor/pino.mjs`,fastify 经 CJS→ESM interop 拿到的 pino 会丢失 `symbols` 等模块属性,产物启动即崩(dev 源码直跑正常,极易漏测)。因此 pino 由 Fastify 用 `logger` 配置对象自建(保持内联),文件输出通过 `logger.stream` 注入。
- 不用 `pino.transport()`:它走 worker 线程、按模块路径加载 worker 文件,与「免安装」的 esbuild 产物不兼容。
- 文件写入是自实现的 `LogFile`(保持打开的 fd + `writeSync` 同步追加),不用 SonicBoom / pino.destination:轮转时 `closeSync` 释放句柄后即可安全 `rename`(Windows 不允许重命名被打开的文件),全同步时序确定。
- 纯文本格式在路由流里完成:pino 产出 JSON 行 → 逐行解析 → 格式化 → 按 level 分流到 app 文件 / error 文件 / stdout。
- 测试场景(`LOG_LEVEL=silent`)不建文件、不起定时器,行为与从前完全一致。
## 写业务代码
**加一个接口** —— 在 `src/server/routes/` 下建文件,然后在 `routes/index.js` 里注册:
```js
// src/server/routes/user.js
export default async function userRoutes(app) {
app.get('/list', async () => ({ items: [] }))
}
```
```js
// src/server/routes/index.js
import userRoutes from './user.js'
await app.register(userRoutes, { prefix: '/user' }) // -> /api/user/list
```
**加一个页面** —— 在 `src/client/views/` 下建 `.vue`,然后在 `src/client/router/index.js` 的路由表里加一条,用 `() => import()` 懒加载(Vite 会自动按路由分包):
```js
{
path: '/user',
name: 'user',
component: () => import('@/views/UserView.vue'),
meta: { title: '用户' },
}
```
页面外壳(`.page` 容器、`.card`、`.hint`、`.ok`、`.err`)已经在 `src/client/assets/main.css` 里定义成全局类,视图里直接用即可,不需要重复写。
**前端调接口** —— 统一走 `src/client/services/http.js`:
```js
import { request } from '@/services/http.js'
const data = await request('/api/user/list')
```
## 常见问题
**`npm run dev` 时页面一片空白,但控制台没有明显报错**
如果是**个别路由**空白而其他路由正常,先看那几个路由有没有 import `@/` 下的东西。典型原因是前端源码目录名和 dev 代理前缀撞了:
`@` 别名指向 `src/client`,所以 `@/api/client.js` 经别名展开后就是 URL `/api/client.js` —— 而这个前缀被 `vite.config.js` 的 dev proxy 拦截,请求被转发给 Fastify,后端把它当不存在的接口返回 404 JSON。浏览器拿到的模块 `Content-Type` 是 `application/json` 而不是 JS,于是报 `Failed to load module script`,**整个页面直接不渲染**。
所以**不要在 `src/client/` 下建叫 `api` 的目录**,本模板用的是 `services/`。构建产物不受影响(`vite build` 走文件系统不走 HTTP),这个坑只在开发期出现,很容易被误判成组件写错了。
**打包失败:某个依赖无法静态分析**
原生二进制模块(`better-sqlite3`、`sharp`、`bcrypt`…)和运行时才确定路径的动态 `require` 没法被内联。把它们加进 `scripts/build-server.mjs` 顶部的 `EXTERNALS` 列表 —— 这些依赖会保持 external、不产出 vendor 文件,代价是部署时要带上对应的 `node_modules`。所以依赖选型上优先挑纯 JS 实现。
**产物太大**
构建结束会打印每个文件的体积。Fastify 全家桶打进 vendor 大约 800 KB(未压缩),属于正常范围。想再瘦可以去掉 `@fastify/cors`(同源部署本就不需要跨域)。
**`vite build` 提示 outDir 在 root 之外**
本模板 `root` 是 `src/client/`、`outDir` 是 `../../dist/static`,确实在 root 之外,所以 `vite.config.js` 里显式声明了 `emptyOutDir: true`。改动 `outDir` 时记得同步确认这一项,否则 Vite 不会清空旧产物,历史文件会残留在 `dist` 里。
**目录为什么是 `src/client` + `src/server`**
`src/` 是源码总目录,前后端各占一格。产物一侧对应 `dist/static`(前端)和 `dist` 根(`index.mjs` + `vendor/`,后端)—— 后端产物直接放在 dist 根,部署时入口路径最短。
`public/` 和 `scripts/` 留在项目根,与 `src/` 平级 —— 前者是构建时原样拷贝的静态资源,后者是构建工具本身,都不属于业务源码。
**刷新子路由(如 `/about`)报 404**
History 模式的典型症状:服务端没有把未匹配路径回退到 `index.html`。本模板自带的 Fastify 已经处理好了 —— 出现这个报错说明你多半是通过 `vite preview`、`http-server` 这类不带 SPA 回退的静态服务器,或者前面挡了一层 nginx 而没配回退。
nginx 上加一行即可:
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
若只是想绕开,也可以把 `src/client/router/index.js` 的 `createWebHistory` 换成 `createWebHashHistory`,URL 会变成 `/#/about`,但不需要服务端配合(代价是不利于 SEO)。
**改了前端重新部署,用户还是看到旧页面**
先确认 `dist/static/index.html` 返回的是 `Cache-Control: no-cache` 而不是长缓存 —— 入口 HTML 一旦被浏览器缓存住,里面的资源引用就还是旧的。本模板默认策略是对的(见「静态资源与缓存」),如果前面挡了 nginx / CDN,记得别让它们给 HTML 加长缓存。
**端口被占用**
`EADDRINUSE` 时换 `PORT`,或 `npm run dev:api` 里的端口与 `vite.config.js` 的 `API_TARGET` 保持一致。
**改了后端代码要重新打包吗**
开发时不用(`node --watch` 直接跑源码)。只有 `npm start` 跑产物时才需要 `npm run build:server`。