目录
- 项目概述
- 技术栈
- 项目初始化
- 目录结构
- 核心配置文件
- 5.1 package.json
- 5.2 vite.config.ts
- 5.3 [TypeScript 配置](#TypeScript 配置 "#53-typescript-%E9%85%8D%E7%BD%AE")
- 5.4 [ESLint 配置](#ESLint 配置 "#54-eslint-%E9%85%8D%E7%BD%AE")
- [服务端 --- Express 服务器](#服务端 — Express 服务器 "#6-%E6%9C%8D%E5%8A%A1%E7%AB%AF--express-%E6%9C%8D%E5%8A%A1%E5%99%A8")
- [Vike 页面约定与 Hook 体系](#Vike 页面约定与 Hook 体系 "#7-vike-%E9%A1%B5%E9%9D%A2%E7%BA%A6%E5%AE%9A%E4%B8%8E-hook-%E4%BD%93%E7%B3%BB")
- 7.1 [+config.ts --- 全局/页面级配置](#+config.ts — 全局/页面级配置 "#71-configts--%E5%85%A8%E5%B1%80%E9%A1%B5%E9%9D%A2%E7%BA%A7%E9%85%8D%E7%BD%AE")
- 7.2 [+onCreateApp.ts --- Vue 应用创建钩子](#+onCreateApp.ts — Vue 应用创建钩子 "#72-oncreateappts--vue-%E5%BA%94%E7%94%A8%E5%88%9B%E5%BB%BA%E9%92%A9%E5%AD%90")
- 7.3 [+Layout.vue --- 全局布局](#+Layout.vue — 全局布局 "#73-layoutvue--%E5%85%A8%E5%B1%80%E5%B8%83%E5%B1%80")
- 7.4 [+Head.vue --- 全局 HTML Head](#+Head.vue — 全局 HTML Head "#74-headvue--%E5%85%A8%E5%B1%80-html-head")
- 7.5 [+guard.ts --- 路由守卫(权限验证)](#+guard.ts — 路由守卫(权限验证) "#75-guardts--%E8%B7%AF%E7%94%B1%E5%AE%88%E5%8D%AB%E6%9D%83%E9%99%90%E9%AA%8C%E8%AF%81")
- 7.6 [+data.ts --- SSR 数据预取](#+data.ts — SSR 数据预取 "#76-datats--ssr-%E6%95%B0%E6%8D%AE%E9%A2%84%E5%8F%96")
- 7.7 [+Page.vue --- 页面组件](#+Page.vue — 页面组件 "#77-pagevue--%E9%A1%B5%E9%9D%A2%E7%BB%84%E4%BB%B6")
- 7.8 [_error/+Page.vue --- 错误页面](#_error/+Page.vue — 错误页面 "#78-_errorpagevue--%E9%94%99%E8%AF%AF%E9%A1%B5%E9%9D%A2")
- [状态管理 --- Pinia](#状态管理 — Pinia "#8-%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86--pinia")
- 8.1 [全局状态 (global.ts)](#全局状态 (global.ts) "#81-%E5%85%A8%E5%B1%80%E7%8A%B6%E6%80%81-globalts")
- 8.2 [布局状态 (layout.ts)](#布局状态 (layout.ts) "#82-%E5%B8%83%E5%B1%80%E7%8A%B6%E6%80%81-layoutts")
- [国际化 --- Vue I18n](#国际化 — Vue I18n "#9-%E5%9B%BD%E9%99%85%E5%8C%96--vue-i18n")
- [API 层 --- Alova + Axios](#API 层 — Alova + Axios "#10-api-%E5%B1%82--alova--axios")
- 10.1 [核心实例管理 (alovaInstance.ts)](#核心实例管理 (alovaInstance.ts) "#101-%E6%A0%B8%E5%BF%83%E5%AE%9E%E4%BE%8B%E7%AE%A1%E7%90%86-alovainstancets")
- 10.2 [客户端 API (createClientApi.ts)](#客户端 API (createClientApi.ts) "#102-%E5%AE%A2%E6%88%B7%E7%AB%AF-api-createclientapits")
- 10.3 [服务端 API (createServerApi.ts)](#服务端 API (createServerApi.ts) "#103-%E6%9C%8D%E5%8A%A1%E7%AB%AF-api-createserverapits")
- 10.4 [业务 API 定义](#业务 API 定义 "#104-%E4%B8%9A%E5%8A%A1-api-%E5%AE%9A%E4%B9%89")
- [Layout 系统](#Layout 系统 "#11-layout-%E7%B3%BB%E7%BB%9F")
- 11.1 公共布局与页面自定义
- 11.2 [useLayout 组合式函数](#useLayout 组合式函数 "#112-uselayout-%E7%BB%84%E5%90%88%E5%BC%8F%E5%87%BD%E6%95%B0")
- 11.3 [AppSidebar 组件](#AppSidebar 组件 "#113-appsidebar-%E7%BB%84%E4%BB%B6")
- 11.4 [AppHeader 组件](#AppHeader 组件 "#114-appheader-%E7%BB%84%E4%BB%B6")
- [Element Plus 集成(SSR 兼容)](#Element Plus 集成(SSR 兼容) "#12-element-plus-%E9%9B%86%E6%88%90ssr-%E5%85%BC%E5%AE%B9")
- 权限系统
- 13.1 [权限 URL 统一管理](#权限 URL 统一管理 "#131-%E6%9D%83%E9%99%90-url-%E7%BB%9F%E4%B8%80%E7%AE%A1%E7%90%86")
- 13.2 [页面级权限 --- +guard.ts](#页面级权限 — +guard.ts "#132-%E9%A1%B5%E9%9D%A2%E7%BA%A7%E6%9D%83%E9%99%90--guardts")
- 13.3 [按钮级权限 --- usePermission](#按钮级权限 — usePermission "#133-%E6%8C%89%E9%92%AE%E7%BA%A7%E6%9D%83%E9%99%90--usepermission")
- 13.4 [Mock 权限验证](#Mock 权限验证 "#134-mock-%E6%9D%83%E9%99%90%E9%AA%8C%E8%AF%81serverserverts")
- 13.5 权限流程图
- 路由与导航
- 业务页面示例
- [SSR 与 CSR 策略](#SSR 与 CSR 策略 "#16-ssr-%E4%B8%8E-csr-%E7%AD%96%E7%95%A5")
- 关键踩坑与解决方案
- 开发与构建命令
- 生产部署
1. 项目概述
本项目是一个基于 Vike(前 vite-plugin-ssr)+ Vue 3 的企业级管理后台模板。核心思路是利用 Vike 框架的原生 Hook 体系(+config.ts、+guard.ts、+data.ts、+Layout.vue、+onCreateApp.ts)替代传统 Vue Router 的路由守卫和路由配置方式,实现:
- SSR 首屏渲染 --- 首屏数据通过
+data.ts在服务端预取,直接输出到 HTML - 统一权限验证 --- 通过
+guard.ts在 SSR 阶段调用后端权限接口,无权限直接渲染 403 页面 - 公共 Layout 可定制 --- 每个页面可通过 Pinia Store 方法动态修改 Layout 标题、面包屑、顶部按钮等
- 国际化 --- Vue I18n 支持中英文切换,菜单、标题、错误页均支持多语言
- UI 组件库 --- Element Plus 全量引入,SSR 兼容
2. 技术栈
| 类别 | 技术 | 版本 | 说明 |
|---|---|---|---|
| 框架 | Vue 3 | ^3.5 | Composition API |
| 元框架 | Vike | ^0.4.252 | SSR / 文件系统路由 |
| Vue 适配 | vike-vue | ^0.9.10 | Vike 的 Vue 3 适配器 |
| UI 组件库 | Element Plus | ^2.9 | 管理后台 UI 组件 |
| 状态管理 | Pinia | ^3.0 | Vue 3 官方状态管理 |
| 国际化 | Vue I18n | ^11.1 | 多语言支持 |
| HTTP 请求 | Alova + Axios | ^3.2 / ^1.9 | 请求策略库 + HTTP 客户端 |
| 服务端 | Express 5 | ^5.2 | Node.js HTTP 服务器 |
| 构建工具 | Vite 7 | ^7.3 | 开发服务器 + 打包 |
| 语言 | TypeScript | ^5.9 | 类型安全 |
| CSS 预处理 | SCSS | ^1.87 | 样式预处理 |
| 代码规范 | ESLint + typescript-eslint | ^9.39 | 代码质量保障 |
3. 项目初始化
3.1 创建项目
bash
# 创建目录
mkdir vike-zyh-test && cd vike-zyh-test
# 初始化 package.json
npm init -y
3.2 安装依赖
运行时依赖:
bash
npm install vue vike vike-vue express compression cookie-parser sirv \
pinia vue-i18n element-plus alova @alova/adapter-axios axios
开发依赖:
bash
npm install -D vite @vitejs/plugin-vue typescript tsx sass \
unplugin-auto-import unplugin-vue-components \
@intlify/unplugin-vue-i18n cross-env \
eslint @eslint/js eslint-plugin-vue typescript-eslint vue-eslint-parser globals \
@types/express @types/compression @types/cookie-parser
3.3 设定 package.json Scripts
json
{
"type": "module",
"scripts": {
"dev": "tsx server/server.ts",
"build": "vike build",
"preview": "vike build && cross-env NODE_ENV=production tsx server/server.ts",
"lint": "eslint .",
"fix": "eslint . --fix"
},
}
关键点 :开发模式使用
tsx直接运行 TypeScript 编写的 Express 服务器,而非vite dev。这允许我们完全掌控服务端中间件、Mock API 和渲染流程。
4. 目录结构
csharp
vike-zyh-test/
├── server/ # Express 服务端
│ └── server.ts # 入口:中间件 + Mock API + Vike 渲染
├── src/
│ ├── api/ # API 层
│ │ ├── alovaInstance.ts # Alova 实例管理 + apiCreator 统一请求工厂
│ │ ├── createClientApi.ts # 客户端 Alova 实例创建
│ │ ├── createServerApi.ts # 服务端 Alova 实例创建(用于 +data.ts / +guard.ts)
│ │ ├── dashboardApi.ts # Dashboard 业务 API
│ │ └── permissionApi.ts # 权限业务 API
│ ├── composables/ # 组合式函数
│ │ ├── useLayout.ts # Layout 控制接口(setTitle / setBreadcrumbs / setHeaderActions ...)
│ │ ├── usePagination.ts # 分页逻辑封装
│ │ └── usePermission.ts # 权限检查(hasPermission)
│ ├── constants/ # 常量
│ │ ├── constants.ts # 通用常量(分页默认值、枚举等)
│ │ ├── menu.ts # 侧边栏菜单配置
│ │ └── permissionApis.ts # 权限 API URL 常量(统一管理)
│ ├── directive/ # 自定义指令
│ │ └── directive.ts # 指令注册入口(如权限指令 v-permission)
│ ├── i18n/ # 国际化
│ │ ├── i18n.ts # createI18n 工厂函数
│ │ ├── zh-CN.json # 中文语言包
│ │ └── en-US.json # 英文语言包
│ ├── layout/ # Layout 组件
│ │ ├── AppSidebar.vue # 侧边栏
│ │ └── AppHeader.vue # 顶部导航栏
│ ├── pages/ # Vike 文件系统路由 ★
│ │ ├── +config.ts # 全局页面配置
│ │ ├── +onCreateApp.ts # Vue App 创建钩子(注册 Pinia/I18n/ElementPlus)
│ │ ├── +guard.ts # 全局路由守卫(权限验证)
│ │ ├── +Layout.vue # 全局 Layout
│ │ ├── +Head.vue # 全局 HTML <head>
│ │ ├── _error/ # 错误页面(401/403/404/500)
│ │ │ └── +Page.vue
│ │ ├── index/ # 首页 /
│ │ │ ├── +config.ts
│ │ │ ├── +data.ts # SSR 数据预取
│ │ │ └── +Page.vue
│ │ └── permission/ # 权限管理模块
│ │ ├── +config.ts
│ │ ├── +data.ts # SSR 数据预取(权限列表)
│ │ ├── +Page.vue # 权限列表页
│ │ ├── add/ # 新增权限 /permission/add
│ │ │ ├── +config.ts
│ │ │ ├── +data.ts # 空 data,阻止继承父级
│ │ │ └── +Page.vue
│ │ └── @id/ # 动态路由 /permission/:id
│ │ └── edit/ # 编辑权限 /permission/:id/edit
│ │ ├── +config.ts
│ │ ├── +data.ts # 空 data,阻止继承父级
│ │ └── +Page.vue
│ ├── scss/ # 全局样式
│ │ └── common.scss
│ ├── stores/ # Pinia 状态管理
│ │ ├── global.ts # 全局状态(env/lang/user)
│ │ └── layout.ts # 布局状态(title/breadcrumbs/headerActions/sidebar)
│ └── viewComponents/ # 页面级可复用组件
│ └── permission/
│ └── PermissionForm.vue # 权限表单组件(新增/编辑复用)
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 根配置(引用子配置)
├── tsconfig.app.json # 前端 TS 配置
├── tsconfig.node.json # Vite 配置用 TS 配置
├── tsconfig.server.json # 服务端 TS 配置
├── eslint.config.ts # ESLint 配置
└── package.json
约定说明 :
pages/目录下以+开头的文件是 Vike 框架约定文件,分别承担配置、数据预取、守卫、布局、渲染等职责。@id目录名表示动态路由参数。_error为 Vike 约定的错误页面目录。
5. 核心配置文件
5.1 package.json
json
{
"type": "module",
"imports": {
"#*": "./*",
"#server/*": "./server/*"
}
}
"type": "module"--- 启用 ESM"imports"--- Node.js 原生子路径导入映射,配合tsconfig.json的paths实现统一的#前缀路径别名
5.2 vite.config.ts
typescript
import { fileURLToPath, URL } from 'node:url';
import { readdir } from 'node:fs/promises';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vike from 'vike/plugin';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
// 自动扫描 src/ 下的子目录,生成路径别名
const srcSubDirs = (
await readdir(new URL('./src', import.meta.url), { withFileTypes: true })
)
.filter((d) => d.isDirectory())
.map(({ name }) => name);
export default defineConfig({
plugins: [
vue(),
vike(),
AutoImport({
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
VueI18nPlugin({ ssr: true, strictMessage: false }),
],
resolve: {
alias: {
'#': fileURLToPath(new URL('./', import.meta.url)),
'#src': fileURLToPath(new URL('./src', import.meta.url)),
'#server': fileURLToPath(new URL('./server', import.meta.url)),
// 自动生成: #api, #composables, #stores, #i18n, #layout, #pages ...
...Object.fromEntries(
srcSubDirs.map((name) => [
`#${name}`,
fileURLToPath(new URL(`./src/${name}`, import.meta.url)),
]),
),
},
},
build: { target: 'es2022' },
});
关键设计点:
| 配置项 | 说明 |
|---|---|
vike() |
启用 Vike 插件,提供 SSR + 文件系统路由 |
ElementPlusResolver({ importStyle: false }) |
禁用 样式自动导入,避免 SSR 中加载 CSS 文件报错。样式改为在 +Layout.vue 中手动 import 'element-plus/dist/index.css' |
VueI18nPlugin({ ssr: true }) |
开启 i18n 的 SSR 优化,编译时处理 <i18n> 块 |
| 路径别名自动扫描 | 自动读取 src/ 子目录,无需手动逐个配置别名 |
5.3 TypeScript 配置
项目采用三配置策略:
| 文件 | 作用 | module |
|---|---|---|
tsconfig.app.json |
前端源码 (src/) |
ES2022 / Bundler |
tsconfig.node.json |
Vite 配置文件 | ES2022 / Bundler |
tsconfig.server.json |
服务端代码 (server/) |
Node16 / Node16 |
tsconfig.app.json 中配置了所有 # 前缀的路径映射:
json
{
"compilerOptions": {
"paths": {
"#*": ["./*"],
"#src/*": ["./src/*"],
"#api/*": ["./src/api/*"],
"#stores/*": ["./src/stores/*"],
"#i18n/*": ["./src/i18n/*"],
"#layout/*": ["./src/layout/*"],
"#composables/*": ["./src/composables/*"],
"#constants/*": ["./src/constants/*"],
"#directive/*": ["./src/directive/*"],
"#viewComponents/*": ["./src/viewComponents/*"],
"#server/*": ["./server/*"]
}
}
}
5.4 ESLint 配置
使用 ESLint 9 Flat Config,集成 typescript-eslint 和 eslint-plugin-vue:
typescript
// eslint.config.ts
import eslint from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
import vueParser from 'vue-eslint-parser';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
// Vue 文件使用 vue-eslint-parser 嵌套 typescript parser
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: { parser: tseslint.parser },
},
},
...pluginVue.configs['flat/recommended'],
);
6. 服务端 --- Express 服务器
server/server.ts 是项目入口,使用 Express 5 搭建 HTTP 服务器:
typescript
import express from 'express';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { renderPage, createDevMiddleware } from 'vike/server';
async function startServer() {
const app = express();
// 1. 基础中间件
app.use(compression()); // Gzip 压缩
app.use(cookieParser()); // Cookie 解析
app.disable('x-powered-by'); // 隐藏 Express 标识
// 2. 静态文件 / Vite 开发中间件
if (isProd) {
app.use(sirv('dist/client')); // 生产环境:静态文件
} else {
const { devMiddleware } = await createDevMiddleware({ root });
app.use(devMiddleware); // 开发环境:Vite HMR
}
// 3. Mock API(开发阶段可替换为真实后端代理)
app.use(express.json());
app.get('/api/v1/dashboard/stats', ...);
app.get('/api/v1/permissions', ...);
app.post('/api/v1/permission/check', ...);
// 4. Vike 页面渲染 --- 所有未匹配的 GET 请求
app.get('/{*path}', async (req, res, next) => {
const pageContext = await renderPage({
urlOriginal: req.originalUrl,
headersOriginal: req.headers,
cookies: req.cookies,
});
if (!pageContext.httpResponse) return next();
const { body, statusCode, headers } = pageContext.httpResponse;
headers.forEach(([name, value]) => res.setHeader(name, value));
res.status(statusCode).send(body);
});
app.listen(3000);
}
重点说明:
- Express 5 路由语法 :
app.get('/{*path}', ...)--- Express 5 使用命名通配符,不再支持app.get('*', ...) - pageContext 初始化 :
headersOriginal和cookies被传入pageContext,供+guard.ts和+data.ts中的 SSR API 调用使用(转发原始请求头实现登录态传递) - Mock API 位于 Vike 渲染之前:确保 API 请求不会被 Vike 拦截
7. Vike 页面约定与 Hook 体系
Vike 的核心理念:通过 + 前缀文件约定替代路由配置。每个约定文件承担特定职责,按以下顺序执行:
bash
请求进入 → +guard.ts(权限验证)→ +data.ts(数据预取)→ +Page.vue(页面渲染)
↑
+Layout.vue 包裹
+Head.vue 注入 <head>
7.1 +config.ts --- 全局/页面级配置
全局配置 src/pages/+config.ts:
typescript
import vikeVue from 'vike-vue/config';
import type { Config } from 'vike/types';
export default {
extends: [vikeVue], // 继承 vike-vue 默认行为
title: 'Admin',
passToClient: ['user', 'locale', 'permissionResult', 'routeName'],
meta: {
permissionUrls: {
env: { server: true, client: true }, // 自定义配置项,服务端和客户端均可访问
},
},
} satisfies Config;
passToClient--- 指定哪些pageContext属性传递到客户端(SSR → CSR 数据桥接)meta.permissionUrls--- 声明自定义页面配置项,用于权限验证
页面级配置 src/pages/permission/+config.ts:
typescript
import { PERMISSION_APIS } from '../../constants/permissionApis';
export default {
title: '权限列表',
permissionUrls: [
PERMISSION_APIS.LIST,
PERMISSION_APIS.CREATE,
PERMISSION_APIS.UPDATE,
PERMISSION_APIS.DELETE,
],
};
每个页面的
+config.ts中的permissionUrls会被+guard.ts读取,用于权限验证。权限 URL 常量统一定义在src/constants/permissionApis.ts中。
7.2 +onCreateApp.ts --- Vue 应用创建钩子
每次渲染(SSR 和 CSR)都会执行此钩子,用于注册全局插件和指令:
typescript
import type { OnCreateAppSync } from 'vike-vue/types';
import { createPinia } from 'pinia';
import { ID_INJECTION_KEY, ZINDEX_INJECTION_KEY } from 'element-plus';
import { createI18n } from '#i18n/i18n';
import directives from '#directive/directive';
const onCreateApp: OnCreateAppSync = (pageContext) => {
const { app } = pageContext;
// 1. Pinia 状态管理
app.use(createPinia());
// 2. Vue I18n 国际化
app.use(createI18n());
// 3. Element Plus SSR 兼容 --- 必须 provide ID 和 ZIndex
app.provide(ID_INJECTION_KEY, { prefix: 1024, current: 0 });
app.provide(ZINDEX_INJECTION_KEY, { current: 0 });
// 4. 自定义指令
Object.entries(directives).forEach(([name, directive]) => {
app.directive(name, directive);
});
};
export default onCreateApp;
7.3 +Layout.vue --- 全局布局
公共 Layout 包裹所有页面,集成侧边栏、顶部导航、Element Plus 配置提供者:
vue
<template>
<el-config-provider :locale="elementLocale">
<div class="app-layout">
<aside v-if="layoutStore.showSidebar" :class="['app-sidebar', { collapsed: layoutStore.sidebarCollapsed }]">
<AppSidebar :menus="defaultMenus" :collapsed="layoutStore.sidebarCollapsed" />
</aside>
<div class="app-main">
<AppHeader
v-if="layoutStore.showHeader"
:breadcrumbs="layoutStore.breadcrumbs"
:header-actions="layoutStore.headerActions"
@toggle-sidebar="layoutStore.toggleSidebar()"
/>
<main class="app-content">
<slot /> <!-- 页面内容插入点 -->
</main>
</div>
</div>
</el-config-provider>
</template>
<script lang="ts" setup>
import 'element-plus/dist/index.css'; // 手动引入样式(SSR 兼容)
import '#scss/common.scss';
// ...组件引入与状态管理
</script>
7.4 +Head.vue --- 全局 HTML Head
vue
<template>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</template>
7.5 +guard.ts --- 路由守卫(权限验证)
核心权限验证机制,在 SSR 阶段拦截请求:
typescript
import type { GuardAsync } from 'vike/types';
import { render } from 'vike/abort';
const guard: GuardAsync = async (pageContext) => {
const permissionUrls = (pageContext.config as any).permissionUrls;
// 没有配置权限 URL 的页面,直接放行
if (!permissionUrls || permissionUrls.length === 0) return;
// SSR 时调用后台权限验证接口
if (typeof window === 'undefined') {
try {
const { createDefaultAPI } = await import('#api/createServerApi');
const port = process.env.PORT || 3000;
const alova = createDefaultAPI({
baseURL: `http://localhost:${port}/api/v1`,
headers: (pageContext as any).headersOriginal, // 转发原始请求头
});
const result = await alova.Post('/permission/check', {
urls: permissionUrls,
pagePath: pageContext.urlPathname,
});
if (!result?.data?.allowed) {
throw render(403); // 渲染 403 错误页
}
// 权限结果存入 pageContext,传到客户端
(pageContext as any).permissionResult = result.data;
} catch (error) {
if ((error as any)?.isAbort) throw error; // 已是 abort 直接抛出
throw render(403); // 异常也视为无权限
}
}
};
7.6 +data.ts --- SSR 数据预取
在服务端获取数据,通过 useData() 在页面组件中使用:
typescript
// src/pages/index/+data.ts
import type { PageContextServer } from 'vike/types';
import { createDefaultAPI } from '#api/createServerApi';
const SSR_API_BASE = `http://localhost:${process.env.PORT || 3000}/api/v1`;
export type Data = DashboardStats;
export async function data(_pageContext: PageContextServer): Promise<Data> {
const alova = createDefaultAPI({
baseURL: SSR_API_BASE,
headers: (_pageContext as any).headersOriginal,
});
const res = await alova.Get('/dashboard/stats');
return res.data;
}
注意 :
+data.ts的继承问题 --- 子路由会继承父目录的+data.ts。如果子页面不需要父级数据,需要创建空的+data.ts来阻止继承:
typescript// src/pages/permission/add/+data.ts export type Data = Record<string, never>; export async function data() { return {}; }
7.7 +Page.vue --- 页面组件
每个目录下的 +Page.vue 即该路由对应的页面组件。通过 useData() 获取 SSR 预取数据:
vue
<script lang="ts" setup>
import { useData } from 'vike-vue/useData';
import type { Data } from './+data';
const data = useData<Data>(); // 类型安全地获取 SSR 数据
</script>
7.8 _error/+Page.vue --- 错误页面
统一的错误页面,支持 401/403/404/500:
vue
<script lang="ts" setup>
import { usePageContext } from 'vike-vue/usePageContext';
const pageContext = usePageContext();
const errorCode = computed(() => {
return pageContext.is404 ? 404 : (pageContext.abortStatusCode || 500);
});
</script>
当 +guard.ts 中 throw render(403) 时,Vike 会自动渲染 _error/+Page.vue 并传递 abortStatusCode: 403。
8. 状态管理 --- Pinia
8.1 全局状态 (global.ts)
typescript
// src/stores/global.ts
import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({
env: '',
lang: 'zh-CN',
user: null as null | { name: string; role: string },
}),
actions: {
updateEnv(env: string) { this.env = env; },
updateLang(lang: string) { this.lang = lang; },
updateUser(user: { name: string; role: string } | null) { this.user = user; },
},
});
8.2 布局状态 (layout.ts)
typescript
// src/stores/layout.ts
export const useLayoutStore = defineStore('layout', {
state: () => ({
title: '',
breadcrumbs: [] as BreadcrumbItem[],
sidebarMenus: [] as MenuItem[],
showSidebar: true,
showHeader: true,
sidebarCollapsed: false,
headerActions: [] as HeaderAction[],
}),
actions: {
setTitle(title: string) { this.title = title; },
setHeaderActions(actions: HeaderAction[]) { this.headerActions = actions; },
clearHeaderActions() { this.headerActions = []; },
setBreadcrumbs(items: BreadcrumbItem[]) { this.breadcrumbs = items; },
toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed; },
resetLayout() {
this.title = '';
this.breadcrumbs = [];
this.headerActions = [];
this.showSidebar = true;
this.showHeader = true;
},
},
});
类型定义:
typescript
export interface BreadcrumbItem {
label: string;
path?: string;
}
export interface MenuItem {
label: string;
path: string;
icon?: string;
children?: MenuItem[];
}
export interface HeaderAction {
key: string;
label: string;
icon?: string;
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default';
handler: () => void;
}
9. 国际化 --- Vue I18n
9.1 创建 I18n 实例
typescript
// src/i18n/i18n.ts
import { createI18n as _createI18n } from 'vue-i18n';
import zhCN from '#i18n/zh-CN.json';
import enUS from '#i18n/en-US.json';
export const LANGUAGE = {
ZH_CN: 'zh-CN',
EN_US: 'en-US',
} as const;
export function createI18n() {
return _createI18n({
legacy: false, // 使用 Composition API
locale: LANGUAGE.ZH_CN, // 默认中文
fallbackLocale: LANGUAGE.ZH_CN,
messages: {
[LANGUAGE.ZH_CN]: zhCN,
[LANGUAGE.EN_US]: enUS,
},
});
}
9.2 语言包结构
json
// zh-CN.json
{
"app": { "title": "管理后台" },
"error": {
"unauthorized": "登录已过期,请重新登录",
"forbidden": "暂无权限访问此页面",
"notFound": "页面不存在",
"serverError": "服务器内部错误,请稍后重试"
},
"menu": {
"home": "首页",
"permission": "权限管理",
"permissionList": "权限列表",
"permissionAdd": "新增权限"
},
"common": { "add": "新增", "edit": "编辑", "delete": "删除", ... },
"permission": { "name": "权限名称", "code": "权限编码", ... },
"dashboard": { "totalPermissions": "总权限数", ... }
}
9.3 在组件中使用
vue
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<span>{{ t('app.title') }}</span>
<span>{{ t('menu.home') }}</span>
</template>
9.4 菜单配置与 i18n
菜单的 label 字段使用 i18n key,在渲染时通过 t() 翻译:
typescript
// src/constants/menu.ts
export const SIDEBAR_MENUS: MenuItem[] = [
{ label: 'menu.home', path: '/', icon: 'House' },
{
label: 'menu.permission', path: '/permission', icon: 'Lock',
children: [
{ label: 'menu.permissionList', path: '/permission' },
{ label: 'menu.permissionAdd', path: '/permission/add' },
],
},
];
10. API 层 --- Alova + Axios
项目使用 Alova 作为请求策略层,底层适配 Axios。分为客户端和服务端两套实例。
10.1 核心实例管理 (alovaInstance.ts)
typescript
// src/api/alovaInstance.ts
// API 类型枚举
export const API_TYPE = { DEFAULT: 'default', LOCAL: 'local' } as const;
// 基础 URL 映射
export const API_BASE_URL = {
[API_TYPE.DEFAULT]: '/api/v1',
[API_TYPE.LOCAL]: '/local-api',
};
// 统一请求工厂
export function apiCreator(options: ApiOption, data?: any, customInstances?: AlovaInstances) {
const { method = 'get', type = API_TYPE.DEFAULT, pathVariable, ...restOptions } = options;
const instance = getAlovaInstance(customInstances, type);
let { url = '' } = restOptions;
if (pathVariable) url = templateUrl(url, pathVariable); // URL 模板变量替换
const methodName = method.charAt(0).toUpperCase() + method.slice(1);
if (['Post', 'Put', 'Patch', 'Delete'].includes(methodName)) {
return instance[methodName](url, data, restOptions);
}
return instance[methodName](url, { params: data, ...restOptions });
}
10.2 客户端 API (createClientApi.ts)
typescript
import { createAlova } from 'alova';
import VueHook from 'alova/vue';
import { axiosRequestAdapter } from '@alova/adapter-axios';
export function createClientAlova({ baseURL, timeout = 30000 }) {
return createAlova({
baseURL,
timeout,
cacheFor: null, // 禁用缓存
statesHook: VueHook, // 绑定 Vue 响应式
requestAdapter: axiosRequestAdapter(),
responded: {
onSuccess: async (response) => response.data, // 自动解包 Axios 响应
onError: (error) => { throw error; },
},
});
}
10.3 服务端 API (createServerApi.ts)
typescript
export function createServerAlova({ baseURL, headers, timeout = 30000 }) {
return createAlova({
baseURL,
timeout,
cacheFor: null,
statesHook: VueHook,
requestAdapter: axiosRequestAdapter(),
beforeRequest(method) {
// 转发原始请求头(携带 Cookie/Authorization 等)
if (headers) {
Object.assign(method.config, {
headers: { ...method.config.headers, ...headers },
});
}
},
responded: {
onSuccess: async (response) => response.data,
onError: (error) => { throw error; },
},
});
}
客户端 vs 服务端的关键差异 :服务端实例在
beforeRequest中转发原始请求头(headersOriginal),用于传递登录态(Cookie、Token)。服务端还需要使用绝对 URL (http://localhost:3000/api/v1)而非相对路径。
10.4 业务 API 定义
业务 API 通过 apiCreator 统一创建,例如权限 API:
typescript
// src/api/permissionApi.ts
import { apiCreator, API_TYPE } from '#api/alovaInstance';
export function fetchPermissionList(params, options?, customInstances?) {
return apiCreator(
{ ...options, method: 'get', url: '/permissions', type: API_TYPE.DEFAULT },
params, customInstances,
);
}
export function createPermission(data, options?, customInstances?) {
return apiCreator(
{ ...options, method: 'post', url: '/permissions', type: API_TYPE.DEFAULT },
data, customInstances,
);
}
11. Layout 系统
11.1 公共布局与页面自定义
设计理念:Layout 是全局公共的,但每个页面可以通过 Pinia Store 暴露的方法来修改布局状态。
scss
+Layout.vue(全局布局)
├── AppSidebar(侧边栏 --- 读取 layoutStore.sidebarMenus)
├── AppHeader(顶部栏 --- 读取 layoutStore.breadcrumbs / headerActions)
└── <slot />(页面内容)
↑
页面在 onMounted 中调用 useLayout() 设置标题、面包屑、按钮等
11.2 useLayout 组合式函数
typescript
// src/composables/useLayout.ts
export function useLayout() {
const layoutStore = useLayoutStore();
onMounted(() => {
layoutStore.resetLayout(); // 每次页面挂载时重置布局状态
});
return {
setTitle(title: string) { layoutStore.setTitle(title); },
setBreadcrumbs(items: BreadcrumbItem[]) { layoutStore.setBreadcrumbs(items); },
setHeaderActions(actions: HeaderAction[]) { layoutStore.setHeaderActions(actions); },
setShowSidebar(show: boolean) { layoutStore.setShowSidebar(show); },
setShowHeader(show: boolean) { layoutStore.setShowHeader(show); },
toggleSidebar() { layoutStore.toggleSidebar(); },
clearHeaderActions() { layoutStore.clearHeaderActions(); },
};
}
页面中使用示例:
vue
<script lang="ts" setup>
import { onMounted } from 'vue';
import { useLayout } from '#composables/useLayout';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const layout = useLayout();
onMounted(() => {
layout.setTitle(t('menu.home'));
layout.setBreadcrumbs([{ label: t('menu.home') }]);
layout.setHeaderActions([
{ key: 'refresh', label: '刷新', type: 'primary', handler: () => loadData() },
]);
});
</script>
11.3 AppSidebar 组件
vue
<!-- src/layout/AppSidebar.vue -->
<template>
<div class="sidebar-menu">
<div class="logo">
<span class="logo-text">{{ t('app.title') }}</span>
</div>
<el-menu :default-active="activePath" :collapse="collapsed" @select="handleSelect">
<template v-for="item in menus" :key="item.path">
<el-sub-menu v-if="item.children?.length" :index="item.path">
<template #title>
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<span>{{ t(item.label) }}</span>
</template>
<el-menu-item v-for="child in item.children" :key="child.path" :index="child.path">
{{ t(child.label) }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="item.path">
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<span>{{ t(item.label) }}</span>
</el-menu-item>
</template>
</el-menu>
</div>
</template>
<script lang="ts" setup>
import { navigate } from 'vike/client/router';
function handleSelect(index: string) {
navigate(index); // 使用 Vike 的 navigate 进行客户端路由跳转
}
</script>
重要 :不能使用 Element Plus 的
routerprop,因为它依赖 Vue Router。Vike 项目中应使用@select事件 +navigate()手动导航。
11.4 AppHeader 组件
vue
<!-- src/layout/AppHeader.vue -->
<template>
<div class="app-header">
<div class="header-left">
<el-icon class="toggle-btn" @click="emit('toggle-sidebar')">
<Fold v-if="!collapsed" /><Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.label" :to="item.path">
{{ item.label }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<!-- 页面自定义按钮区域 -->
<el-button v-for="action in headerActions" :key="action.key" :type="action.type" @click="action.handler">
{{ action.label }}
</el-button>
<!-- 用户信息 -->
<el-dropdown>
<span class="user-info">
<el-icon><User /></el-icon> {{ user?.name || '未登录' }}
</span>
</el-dropdown>
</div>
</div>
</template>
12. Element Plus 集成(SSR 兼容)
在 SSR 项目中集成 Element Plus 需要解决三个问题:
12.1 CSS 加载问题
问题 :unplugin-vue-components 默认会自动导入组件对应的 CSS 文件,但 SSR 时 Node.js 无法处理 .css 文件。
解决方案:
typescript
// vite.config.ts
Components({
resolvers: [ElementPlusResolver({ importStyle: false })], // 禁用自动导入样式
}),
vue
<!-- +Layout.vue 中手动全量引入 -->
<script setup>
import 'element-plus/dist/index.css';
</script>
12.2 ID 注入问题
问题 :ElementPlusError: [IdInjection] Looks like you are using server rendering, you must provide a id provider
解决方案:
typescript
// +onCreateApp.ts
import { ID_INJECTION_KEY, ZINDEX_INJECTION_KEY } from 'element-plus';
app.provide(ID_INJECTION_KEY, { prefix: 1024, current: 0 });
app.provide(ZINDEX_INJECTION_KEY, { current: 0 });
12.3 Locale 国际化
vue
<!-- +Layout.vue -->
<template>
<el-config-provider :locale="elementLocale">
<!-- ... -->
</el-config-provider>
</template>
<script setup>
import zhCN from 'element-plus/es/locale/lang/zh-cn';
import enUS from 'element-plus/es/locale/lang/en';
const elementLocale = computed(() => locale.value === 'en-US' ? enUS : zhCN);
</script>
13. 权限系统
13.1 权限 URL 统一管理
所有需要权限验证的 API URL 统一在 src/constants/permissionApis.ts 中管理:
typescript
// src/constants/permissionApis.ts
export const PERMISSION_APIS = {
/** 查询权限列表 */
LIST: 'GET /api/v1/permissions',
/** 新增权限 */
CREATE: 'POST /api/v1/permissions',
/** 编辑权限 */
UPDATE: 'PUT /api/v1/permissions',
/** 删除权限 */
DELETE: 'DELETE /api/v1/permissions',
} as const;
注意 :
+config.ts文件由 vike 的 esbuild 插件编译,不支持 Vite 路径别名(#constants/...)。因此在+config.ts中必须使用相对路径 导入常量,而在+Page.vue中可正常使用#别名。
各页面 +config.ts 中按需声明所需的权限 URL:
typescript
// src/pages/permission/+config.ts(列表页 --- 需要所有操作权限)
import { PERMISSION_APIS } from '../../constants/permissionApis';
export default {
title: '权限列表',
permissionUrls: [
PERMISSION_APIS.LIST,
PERMISSION_APIS.CREATE,
PERMISSION_APIS.UPDATE,
PERMISSION_APIS.DELETE,
],
};
typescript
// src/pages/permission/add/+config.ts(新增页 --- 只需 CREATE 权限)
import { PERMISSION_APIS } from '../../../constants/permissionApis';
export default {
title: '新增权限',
permissionUrls: [PERMISSION_APIS.CREATE],
};
13.2 页面级权限 --- +guard.ts
流程:
- 页面在
+config.ts中声明permissionUrls(引用统一常量) +guard.ts读取该配置,在 SSR 阶段调用后端POST /api/v1/permission/check- 后端返回
{ allowed: true/false, urlPermissions: { [url]: boolean } } allowed: false时throw render(403),整页渲染错误页(如新增权限页无 CREATE 权限)allowed: true时将urlPermissions写入pageContext.permissionResult,通过passToClient传到客户端
13.3 按钮级权限 --- usePermission
通过 usePermission() 组合式函数在组件中检查单个 URL 的权限,控制按钮 disabled 状态:
typescript
// src/composables/usePermission.ts
export function usePermission() {
const pageContext = usePageContext();
const permissionResult = computed(() => (pageContext as any).permissionResult || {});
function hasPermission(url: string): boolean {
return permissionResult.value?.urlPermissions?.[url] ?? true;
}
return { permissionResult, hasPermission };
}
列表页使用示例(控制添加/编辑/删除按钮):
vue
<script setup>
import { usePermission } from '#composables/usePermission';
import { PERMISSION_APIS } from '#constants/permissionApis';
const { hasPermission } = usePermission();
const canCreate = hasPermission(PERMISSION_APIS.CREATE);
const canUpdate = hasPermission(PERMISSION_APIS.UPDATE);
const canDelete = hasPermission(PERMISSION_APIS.DELETE);
</script>
<template>
<el-button type="primary" :disabled="!canCreate" @click="handleAdd">新增</el-button>
<!-- 表格操作列 -->
<el-button :disabled="!canUpdate" @click="handleEdit(row)">编辑</el-button>
<el-button :disabled="!canDelete" @click="handleDelete(row)">删除</el-button>
</template>
编辑页使用示例 (通过 canSubmit prop 控制表单保存按钮):
vue
<PermissionForm
:initial-data="detail"
:is-sending="isSending"
:can-submit="canUpdate"
@submit="submit"
@cancel="goBack"
/>
PermissionForm.vue 中保存按钮根据 canSubmit 属性禁用:
vue
<el-button type="primary" :loading="isSending" :disabled="canSubmit === false" @click="submit">
{{ t('common.save') }}
</el-button>
13.4 Mock 权限验证(server/server.ts)
开发阶段通过 Mock 接口模拟权限检查:
typescript
// 模拟无权限的 URL 列表
const DENIED_URLS = new Set([
'POST /api/v1/permissions', // 新增权限
'PUT /api/v1/permissions', // 编辑权限
]);
// pagePath + URL 命中时整页拒绝(403)
const PAGE_BLOCKED_RULES = [
{ pathPattern: /^\/permission\/add$/, url: 'POST /api/v1/permissions' },
];
app.post('/api/v1/permission/check', (req, res) => {
const { urls = [], pagePath = '' } = req.body;
const urlPermissions = {};
urls.forEach((url) => { urlPermissions[url] = !DENIED_URLS.has(url); });
// 命中 PAGE_BLOCKED_RULES 则整页拒绝
const allowed = !PAGE_BLOCKED_RULES.some(
(rule) => rule.pathPattern.test(pagePath) && urls.includes(rule.url) && DENIED_URLS.has(rule.url),
);
res.json({ code: 0, data: { allowed, urlPermissions } });
});
DENIED_URLS--- 控制哪些 URL 返回无权限(按钮 disabled)PAGE_BLOCKED_RULES--- 当特定页面路径命中被拒绝的 URL 时,整页返回 403
13.5 权限流程图
sql
用户请求页面
│
▼
+guard.ts 读取 +config.ts 中的 permissionUrls(引用 PERMISSION_APIS 常量)
│
├── 未配置 → 直接放行
│
└── 已配置 → SSR 调用 POST /api/v1/permission/check { urls, pagePath }
│
├── allowed: false → throw render(403) → 渲染 _error/+Page.vue
│ (如: /permission/add 页面无 CREATE 权限 → 整页 403)
│
└── allowed: true → permissionResult 存入 pageContext
│
└── 组件中通过 usePermission().hasPermission(url) 判断
│
├── true → 按钮正常可用
└── false → 按钮 disabled
(如: 编辑页无 UPDATE 权限 → 保存按钮禁用)
14. 路由与导航
14.1 文件系统路由
Vike 根据 src/pages/ 目录结构自动生成路由:
| 目录结构 | 路由路径 | 说明 |
|---|---|---|
pages/index/+Page.vue |
/ |
首页 |
pages/permission/+Page.vue |
/permission |
权限列表 |
pages/permission/add/+Page.vue |
/permission/add |
新增权限 |
pages/permission/@id/edit/+Page.vue |
/permission/:id/edit |
编辑权限(动态路由) |
pages/_error/+Page.vue |
错误页面 | 401/403/404/500 |
@id是 Vike 的动态路由语法,等效于 Vue Router 的:id。通过pageContext.routeParams.id获取。
14.2 客户端导航
Vike 提供 navigate 函数实现客户端路由跳转(无刷新):
typescript
import { navigate } from 'vike/client/router';
// 跳转到指定页面
navigate('/permission');
// 跳转并替换历史记录
navigate('/permission', { overwriteLastHistoryEntry: true });
在
+config.ts中已设置clientRouting: true(由vike-vue默认配置),启用客户端路由。
15. 业务页面示例
15.1 Dashboard 首页
文件 :src/pages/index/
| 文件 | 作用 |
|---|---|
+config.ts |
配置标题 '首页' |
+data.ts |
SSR 调用 /api/v1/dashboard/stats 预取统计数据 |
+Page.vue |
通过 useData() 获取数据,展示统计卡片和操作日志表格 |
vue
<script setup>
const data = useData<Data>(); // SSR 预取的数据,无需 onMounted 加载
const statCards = computed(() => [
{ key: 'total', label: t('dashboard.totalPermissions'), value: data.totalPermissions },
// ...
]);
</script>
15.2 权限列表页
文件 :src/pages/permission/
| 文件 | 作用 |
|---|---|
+config.ts |
配置标题 + permissionUrls(启用权限验证) |
+data.ts |
SSR 预取第一页权限列表 |
+Page.vue |
展示列表 + 搜索 + 分页 |
SSR + CSR 混合:首页数据通过 SSR 预取,后续翻页/搜索通过客户端 Alova 调用。
15.3 新增权限页
文件 :src/pages/permission/add/
| 文件 | 作用 |
|---|---|
+config.ts |
配置标题 + permissionUrls |
+data.ts |
空 data 文件(阻止继承父级的 +data.ts) |
+Page.vue |
使用 PermissionForm 组件 |
关键 :必须创建空的
+data.ts,否则会继承permission/+data.ts的数据加载逻辑,导致不需要的 API 调用甚至报错。
15.4 编辑权限页
文件 :src/pages/permission/@id/edit/
与新增页类似,额外通过 pageContext.routeParams.id 获取路由参数,在 onMounted 中加载详情数据:
vue
<script setup>
const pageContext = usePageContext();
const routeParams = pageContext.routeParams as { id: string };
onMounted(() => {
fetchDetail(routeParams.id);
});
</script>
15.5 可复用组件 --- PermissionForm
src/viewComponents/permission/PermissionForm.vue 同时服务于新增和编辑页面:
vue
<script setup>
const props = defineProps<{
initialData?: Record<string, any>; // 编辑时传入已有数据
isSending?: boolean; // 提交中状态
canSubmit?: boolean; // 是否有提交权限(false 时禁用保存按钮)
}>();
const emit = defineEmits<{
submit: [data: Record<string, any>];
cancel: [];
}>();
// 表单验证规则
const rules: FormRules = {
name: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入权限编码', trigger: 'blur' }],
type: [{ required: true, message: '请选择权限类型', trigger: 'change' }],
};
</script>
16. SSR 与 CSR 策略
| 场景 | 策略 | 实现方式 |
|---|---|---|
| 首屏数据 | SSR | +data.ts → useData() |
| 权限验证 | SSR | +guard.ts → throw render(403) |
| 翻页/搜索 | CSR | 组件内直接使用客户端 Alova |
| 表单提交 | CSR | 组件内调用 API 后 navigate() |
| 页面跳转 | CSR | navigate() 客户端路由 |
| 初始页面加载 | SSR | Express → renderPage() → HTML |
数据流:
css
SSR 阶段:
Express → renderPage() → +guard.ts → +data.ts → +Layout.vue + +Page.vue → HTML
CSR 阶段 (客户端路由):
navigate() → +guard.ts (client) → +data.ts → 组件更新
17. 关键踩坑与解决方案
17.1 Express 5 路由语法变更
问题 :app.get('*', ...) 报错 Missing parameter name
原因 :Express 5 使用新版 path-to-regexp,不再支持裸通配符
解决 :改为命名通配符 app.get('/{*path}', ...)
17.2 Element Plus CSS SSR 加载失败
问题 :TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css"
原因:Node.js SSR 环境无法处理 CSS 文件
解决:
ElementPlusResolver({ importStyle: false })禁用自动导入样式- 在
+Layout.vue中import 'element-plus/dist/index.css'(Vite 会正确处理)
17.3 Element Plus SSR ID/ZIndex 注入
问题 :Hydration 失败,控制台报 IdInjection 和 ZIndexInjection 错误
解决 :在 +onCreateApp.ts 中 app.provide(ID_INJECTION_KEY, ...) 和 app.provide(ZINDEX_INJECTION_KEY, ...)
17.4 服务端 API 调用使用相对 URL
问题 :+data.ts 和 +guard.ts 中使用 /api/v1/xxx 相对路径在 SSR 中无法工作
原因 :Node.js 中没有浏览器的 location.origin,相对 URL 无法解析
解决 :SSR 中使用绝对 URL http://localhost:${process.env.PORT || 3000}/api/v1
17.5 +data.ts 的继承问题
问题 :/permission/add 页面继承了 /permission/+data.ts 的数据加载,导致不必要的 API 调用
原因 :Vike 的 +data.ts 会沿目录树向上继承
解决 :在子目录创建空的 +data.ts:
typescript
export type Data = Record<string, never>;
export async function data() { return {}; }
17.6 El-Menu 的 router prop 不兼容 Vike
问题:侧边栏菜单点击无反应或报错
原因 :Element Plus 的 el-menu router prop 依赖 Vue Router,Vike 项目不使用 Vue Router
解决 :移除 router prop,使用 @select 事件 + navigate():
vue
<el-menu @select="handleSelect">
<!-- ... -->
</el-menu>
<script setup>
import { navigate } from 'vike/client/router';
function handleSelect(index: string) {
navigate(index);
}
</script>
17.7 process.env 在客户端不可用
问题 :ReferenceError: process is not defined
原因 :+guard.ts 在客户端也会执行,但 process.env 仅在 Node.js 中可用
解决 :将 process.env 访问放在 if (typeof window === 'undefined') 分支内
18. 开发与构建命令
bash
# 开发(启动 Express + Vite HMR)
npm run dev
# 构建(生成 dist/client + dist/server)
npm run build
# 生产预览
npm run preview
# 代码检查
npm run lint
# 自动修复
npm run fix
开发环境 :tsx server/server.ts → Express 启动 → createDevMiddleware 注入 Vite HMR → 访问 http://localhost:3000
生产构建 :vike build → 输出 dist/client(静态资源)+ dist/server(SSR Bundle)
19. 生产部署
19.1 构建产物结构
执行 npm run build(即 vike build)后生成 dist/ 目录:
csharp
dist/
├── assets.json # 资源映射文件(Vike 内部使用)
├── client/ # 静态资源(浏览器端)
│ └── assets/
│ ├── chunks/ # JS 代码分割块
│ ├── entries/ # 各页面入口 JS
│ └── static/ # CSS 文件
└── server/ # SSR 服务端代码
├── entry.mjs # SSR 入口(Vike renderPage 用)
├── entries/ # 各页面的 SSR 渲染逻辑
├── chunks/ # 服务端公共模块
└── package.json # { "type": "module" }
19.2 部署方式
本项目使用 Express 作为生产服务器 ,server/server.ts 同时处理静态文件托管和 SSR 渲染。部署步骤:
1. 构建
bash
npm run build
2. 部署所需文件
将以下文件/目录上传到服务器:
bash
dist/ # 构建产物(client + server)
server/server.ts # Express 服务器入口
package.json # 依赖声明
node_modules/ # 或在服务器上 npm install
3. 启动服务
bash
# 方式一:直接用 tsx 运行 TypeScript(需安装 tsx)
cross-env NODE_ENV=production tsx server/server.ts
# 方式二:用 PM2 管理进程(推荐)
pm2 start "cross-env NODE_ENV=production tsx server/server.ts" --name vike-admin
# 自定义端口
cross-env NODE_ENV=production PORT=8080 tsx server/server.ts
运行原理: server/server.ts 中根据 NODE_ENV 自动切换行为:
typescript
if (isProd) {
// 生产环境:sirv 托管 dist/client 静态文件
const sirv = (await import('sirv')).default;
app.use(sirv(`${root}/dist/client`));
} else {
// 开发环境:Vite HMR 开发中间件
const { devMiddleware } = await createDevMiddleware({ root });
app.use(devMiddleware);
}
Vike 的 renderPage() 在生产环境会自动加载 dist/server/entry.mjs 进行 SSR 渲染。
19.3 Nginx 反向代理(可选)
如果需要通过 Nginx 暴露服务:
nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
19.4 Docker 部署(可选)
dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production=false
COPY . .
RUN yarn build
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["npx", "tsx", "server/server.ts"]
bash
docker build -t vike-admin .
docker run -d -p 3000:3000 vike-admin
19.5 注意事项
| 事项 | 说明 |
|---|---|
NODE_ENV |
必须设为 production,否则会尝试启动 Vite 开发中间件 |
| Mock API | 生产环境应替换为真实后端 API 代理,移除 Mock 路由 |
tsx |
生产环境仍需 tsx 来运行 TypeScript 的 server.ts,也可预编译为 JS |
| 端口 | 默认 3000,可通过 PORT 环境变量修改 |
dist/ 路径 |
server.ts 通过 __dirname + '/.. 定位 dist,部署时保持目录相对关系 |
本文档对应项目版本:2026-02-12 · Vike 0.4.252 · Vue 3.5 · Element Plus 2.9 · Express 5.2