# 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`。