Bläddra i källkod

feat: 新增构建模式ssg,为每个路由生成单独的html,同时之前的csr模式(单页面应用)也保留

f-dev 1 vecka sedan
förälder
incheckning
43c494eb86
4 ändrade filer med 68 tillägg och 59 borttagningar
  1. 53 24
      README.md
  2. 3 3
      src/defines/appRoutes.ts
  3. 0 21
      src/defines/navMenu.ts
  4. 12 11
      src/utils/navUtils.ts

+ 53 - 24
README.md

@@ -4,8 +4,10 @@
 
 ## 环境要求
 
-- Node.js >= 18.20.7
-- pnpm >= 9
+- Node.js >= 22.12.0(React Router v7 framework 模式 + Vite 7 要求)
+- pnpm >= 10
+
+> 依赖统一用 pnpm 命令管理(`pnpm add` / `pnpm add -D`),勿手改 package.json 版本号,否则会与 lockfile 不一致导致 `frozen-lockfile` 安装失败。
 
 ## 快速开始
 
@@ -16,16 +18,31 @@ pnpm dev
 
 浏览器访问控制台输出的本地地址即可。
 
+## 渲染模式(CSR / SSG)
+
+项目支持两种渲染模式,**同一份代码、构建时切换**(互斥,一次只出一种产物)。两种模式与 `--mode`(环境:localdev/development/test/production)**正交**,通过不同的 npm script 选择(脚本内部用 `APP_RENDER_MODE` 环境变量传给 `vite.config.ts`)。
+
+| 模式 | 入口 | 路由 | i18n | 产物 | 适用 |
+| --- | --- | --- | --- | --- | --- |
+| **SSG**(默认) | `root.tsx` / `entry.client.tsx` | `/:lang/home`(路径前缀多语言) | `frameworkI18n.ts`(同步 init) | `dist/client/`(预渲染静态 HTML) | 需爬虫/SEO 可见内容,官网/落地页 |
+| **CSR** | `index.html` / `main.tsx` | `/home`(无语言前缀) | `i18n/index.ts`(LanguageDetector 异步) | `dist/`(空壳 SPA) | 纯单页应用,无预渲染需求 |
+
+> 两套入口、路由(`src/routes.ts` vs `src/router/`)、i18n 并存且**完全独立**,由 `APP_RENDER_MODE` 决定实际加载哪套。详见 [SSG 架构文档](./docs/SSG.md)。
+
 ## 脚本说明
 
 | 命令 | 说明 |
-|------|------|
-| `pnpm dev` | 本地开发(使用 `.env.localdev` + `.env`) |
-| `pnpm build` | 生产构建(等价于 `build:prod`) |
-| `pnpm build:dev` | 开发模式构建(`.env.development`) |
-| `pnpm build:prod` | 生产模式构建(`.env.production`) |
-| `pnpm build:test` | 测试环境构建(`.env.test`) |
-| `pnpm preview` | 预览构建产物(使用 localdev 模式) |
+| --- | --- |
+| `pnpm dev` | 本地开发(默认 = `dev:ssg`) |
+| `pnpm dev:ssg` / `pnpm dev:csr` | 指定模式本地开发(`.env.localdev`) |
+| `pnpm build` | 生产构建(默认 = `build:ssg:prod`) |
+| `pnpm build:ssg:{dev,test,prod}` | SSG 模式各环境构建 → `dist/client/` |
+| `pnpm build:csr:{dev,test,prod}` | CSR 模式各环境构建(先 tsc,再 vite build) → `dist/` |
+| `pnpm build:dev` / `build:test` / `build:prod` | 默认模式(SSG)各环境构建别名 |
+| `pnpm typecheck` | 生成路由类型并做 TS 类型检查(`react-router typegen && tsc`) |
+| `pnpm preview` | 预览 SSG 产物(= `preview:ssg`) |
+| `pnpm preview:ssg` | 用 sirv 静态伺服 `dist/client/`(端口 8848) |
+| `pnpm preview:csr` | 用 sirv 静态伺服 `dist/`(端口 8848) |
 | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
 | `pnpm stylelint` / `pnpm stylelint:fix` | 样式检查 / 自动修复 |
 
@@ -34,9 +51,9 @@ pnpm dev
 通过不同 `.env` 文件区分环境,Vite 按 **mode** 加载:
 
 | 文件 | 何时加载 |
-|------|----------|
+| --- | --- |
 | `.env` | 始终加载(公共默认) |
-| `.env.localdev` | `pnpm dev` / `pnpm preview`(`--mode localdev`) |
+| `.env.localdev` | `pnpm dev`(`--mode localdev`) |
 | `.env.development` | `pnpm build:dev` |
 | `.env.production` | `pnpm build` / `pnpm build:prod` |
 | `.env.test` | `pnpm build:test` |
@@ -47,19 +64,21 @@ pnpm dev
 ## 技术栈
 
 - **框架**:React 18、TypeScript
-- **构建**:Vite 6、pnpm
+- **构建**:Vite 7、pnpm
+- **渲染**:双模式(构建时切换)——SSG(React Router v7 framework 模式 + 静态预渲染,默认)/ CSR(传统单页应用)。详见 [渲染模式](#渲染模式csr--ssg) 与 [SSG 架构文档](./docs/SSG.md)
 - **UI**:Ant Design 5、Tailwind CSS、SCSS/Less
-- **路由**:React Router 7
+- **路由**:React Router 7(SSG 用 `/:lang/...` 路径前缀多语言;CSR 用 `/home` 无前缀)
 - **状态**:基于 Context + Hooks 的 Model 方案(类 Umi max)
-- **国际化**:i18next(zh-CN / en-US / fa-IR
-- **请求**:Axios 封装(`src/utils/request`)
+- **国际化**:i18next(SSG 用 `src/i18n/frameworkI18n.ts` 同步 init;CSR 用 `src/i18n/index.ts` LanguageDetector 异步
+- **请求**:Axios 封装(`src/utils/request`),请求体 brotli 压缩(brotli-wasm)
 - **图标**:@iconify/react + 自定义 SVG 转 Iconify 插件
 - **规范**:ESLint、Prettier、Stylelint、Commitlint、Husky、lint-staged
 
 ## 项目结构
 
 ```
-├── build/              # Vite 构建相关(插件、工具)
+├── build/              # Vite 构建相关(插件、工具、分包/压缩/服务配置)
+│   └── config/         # 细粒度 Vite 配置模块(chunks、build、plugins、server、optimize)
 ├── public/             # 静态资源
 ├── src/
 │   ├── assets/         # 资源
@@ -72,15 +91,18 @@ pnpm dev
 │   ├── layouts/        # 布局
 │   ├── locales/        # 多语言文案(zh-CN、en-US、fa-IR)
 │   ├── models/         # 全局状态 Model
-│   ├── pages/          # 页面(按页面分目录)
-│   ├── router/         # 路由配置
+│   ├── pages/          # 页面(按页面分目录,直接作为 route module
+│   ├── routes/         # framework 路由用的包装组件(langLayout、rootRedirect 等)
 │   ├── services/       # API 接口(按模块分目录)
 │   ├── styles/         # 全局样式
 │   ├── utils/          # 工具与 request 封装
-│   ├── App.tsx
-│   └── main.tsx
+│   ├── root.tsx        # HTML 外壳 + Provider 层(取代 index.html/App.tsx)
+│   ├── routes.ts       # framework 路由配置(含 /:lang 前缀)
+│   ├── entry.client.tsx # 客户端入口(hydrateRoot,取代 main.tsx)
+│   └── entry.server.tsx # 构建期预渲染入口
 ├── types/              # 全局类型与 env 类型
-├── index.html
+├── docs/SSG.md         # SSG / framework 模式架构文档
+├── react-router.config.ts # framework 模式配置(ssr:false + prerender)
 ├── vite.config.ts
 └── package.json
 ```
@@ -92,7 +114,7 @@ pnpm dev
 - 组件:函数组件 + Hooks;样式优先 Tailwind,必要时 SCSS/Less
 - 用户可见文案全部走 `locales`,禁止在代码中硬编码
 - 常量与枚举放在 `src/defines/`,请求在 `src/services/` 按模块组织
-- 新页面:在 `src/pages/` 下建目录、在 `routes` 与 `locales` 中注册
+- 新页面:在 `src/pages/` 下建目录、在 `src/routes.ts` 中注册路由、在 `locales` 补文案;若页面需预渲染,在 `react-router.config.ts` 的 `prerender()` 中加路径(详见 [SSG 架构文档](./docs/SSG.md))
 
 ## 状态管理(Model)
 
@@ -127,5 +149,12 @@ pnpm dev
 
 ## 部署
 
-- 构建产物在 `dist/`,将对应环境(如 test/production)的构建结果部署到静态服务器或 CDN 即可。
-- 多环境通过上述 `build:dev` / `build:test` / `build:prod` 与对应 `.env.*` 区分。
+- 构建产物在 **`dist/client/`**(framework 模式产物结构,非旧的 `dist/`),将对应环境的构建结果部署到静态服务器或 CDN 即可。`ssr:false` 为纯静态预渲染,**无需常驻 Node 服务**,对 CDN 加速零影响。
+- 预渲染路径在 `react-router.config.ts` 的 `prerender()` 中配置;未预渲染的路径由 SPA fallback(`dist/client/index.html`)承载。
+- nginx 需配置 SPA fallback 兜底(详见 [SSG 架构文档](./docs/SSG.md)):
+  ```nginx
+  location / {
+      try_files $uri $uri/ $uri.html /index.html;
+  }
+  ```
+- 多环境通过 `build:dev` / `build:test` / `build:prod` 与对应 `.env.*` 区分。

+ 3 - 3
src/defines/appRoutes.ts

@@ -26,12 +26,12 @@ export interface AppRouteMeta {
 
 export const APP_ROUTE_METAS: AppRouteMeta[] = [
     { name: 'home', path: 'home', locale: 'menus.home' },
-    { name: 'pricing', path: 'pricing', locale: 'menus.pricing' },
-    { name: 'privacyPolicy', path: 'privacy', locale: 'menus.privacyPolicy', hideInMenu: true },
+    { name: 'pricing', path: 'pricing', locale: 'menus.pricing', hideInMenu: true },
+    { name: 'privacyPolicy', path: 'privacy', locale: 'menus.privacyPolicy', hideInMenu: false },
     {
         name: 'termsOfService',
         path: 'terms-of-service',
         locale: 'menus.termsOfService',
-        hideInMenu: true,
+        hideInMenu: false,
     },
 ];

+ 0 - 21
src/defines/navMenu.ts

@@ -1,21 +0,0 @@
-/**
- * 顶部导航菜单项定义。
- *
- * framework 模式迁移前,菜单项是从旧的 library 路由配置(src/router/routes.tsx)反推的
- * (取 hideInMenu !== true 的路由)。迁移后路由配置改为 src/routes.ts(不同格式),
- * 且采用路径前缀多语言(/:lang/xxx),菜单不再适合从路由配置反推,故独立定义于此。
- *
- * - segment: 相对路径段(不含语言前缀),由 Topbar 结合当前 :lang 拼成完整路径 /<lang>/<segment>。
- * - locale: i18n key,缺省回退到 name。
- * 新增可在菜单显示的页面时,在此追加即可。
- */
-export interface NavMenuDef {
-    name: string;
-    segment: string;
-    locale: string;
-}
-
-export const NAV_MENU_ITEMS: NavMenuDef[] = [
-    { name: 'home', segment: 'home', locale: 'menus.home' },
-    { name: 'pricing', segment: 'pricing', locale: 'menus.pricing' },
-];

+ 12 - 11
src/utils/navUtils.ts

@@ -1,5 +1,4 @@
 import { APP_ROUTE_METAS } from '@/defines/appRoutes';
-import { NAV_MENU_ITEMS } from '@/defines/navMenu';
 import { withLangPrefix } from '@/hooks/useAppNavigate';
 
 export interface NavMenuItem {
@@ -27,25 +26,27 @@ function getCsrMenuItems(): NavMenuItem[] {
 }
 
 // ————————————————————————————————————————————————————————————————
-// SSG 模式:读静态定义 src/defines/navMenu.ts,路径前缀由 withLangPrefix 按当前语言补齐。
+// SSG 模式:与 CSR 同源,都从 APP_ROUTE_METAS 派生(取 hideInMenu !== true 的项);
+// 差异仅在路径拼法——SSG 由 withLangPrefix 按当前语言补 /<lang> 前缀。
 // framework 模式的路由源(src/routes.ts)是 route()/prefix() DSL,无 name/hideInMenu 可反推,
-// 故菜单独立声明于 navMenu.ts
+// 故菜单不从 routes.ts 反推,而与 CSR 共用这份纯数据清单
 // ————————————————————————————————————————————————————————————————
 
 function getSsgMenuItems(lang?: string): NavMenuItem[] {
-    return NAV_MENU_ITEMS.map((item) => ({
-        name: item.name,
-        path: withLangPrefix(item.segment, lang),
-        locale: item.locale,
+    return APP_ROUTE_METAS.filter((meta) => meta.hideInMenu !== true).map((meta) => ({
+        name: meta.name,
+        path: withLangPrefix(meta.path, lang),
+        locale: meta.locale,
     }));
 }
 
 /**
- * 获取导航菜单项。两种渲染模式各走各的来源,按编译期常量 VITE_RENDER_MODE 隔离:
- * - CSR:从 defines/appRoutes.ts 的路由清单派生(与 router/routes.tsx 同源)。
- * - SSG:读 defines/navMenu.ts 静态定义,withLangPrefix 补 /<lang> 前缀。
+ * 获取导航菜单项。两种渲染模式同源,都从 defines/appRoutes.ts 的 APP_ROUTE_METAS 派生,
+ * 按编译期常量 VITE_RENDER_MODE 隔离路径拼法:
+ * - CSR:path 为 /<segment>。
+ * - SSG:path 为 /<lang>/<segment>(withLangPrefix 按当前语言补前缀)。
  *
- * 构建时非当前模式的分支会被 dead-code 消除。两套数据源都是纯数据、无组件依赖,不构成循环依赖。
+ * 构建时非当前模式的分支会被 dead-code 消除。APP_ROUTE_METAS 是纯数据、无组件依赖,不构成循环依赖。
  *
  * @param lang 当前语言短码(来自 useParams().lang)。仅 SSG 使用;CSR 忽略。
  */