目录
- 项目概述
- 技术栈
- 项目初始化
- 目录结构
-
核心配置文件
- 服务端 — Express 服务器
-
Vike 页面约定与 Hook 体系
-
状态管理 — Pinia
- 国际化 — Vue I18n
-
API 层 — Alova + Axios
-
Layout 系统
- Element Plus 集成(SSR 兼容)
-
权限系统
-
路由与导航
-
业务页面示例
- SSR 与 CSR 策略
- 关键踩坑与解决方案
- 开发与构建命令
-
生产部署
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 创建项目
# 创建目录
mkdir vike-zyh-test && cd vike-zyh-test
# 初始化 package.json
npm init -y
3.2 安装依赖
运行时依赖:
npm install vue vike vike-vue express compression cookie-parser sirv \
pinia vue-i18n element-plus alova @alova/adapter-axios axios
开发依赖:
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
{
"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. 目录结构
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
{
"type": "module",
"imports": {
"#*": "./*",
"#server/*": "./server/*"
}
}
-
"type": "module" — 启用 ESM
-
"imports" — Node.js 原生子路径导入映射,配合 tsconfig.json 的 paths 实现统一的 # 前缀路径别名
5.2 vite.config.ts
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 中配置了所有 # 前缀的路径映射:
{
"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:
// 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 服务器:
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 的核心理念:通过 + 前缀文件约定替代路由配置。每个约定文件承担特定职责,按以下顺序执行:
请求进入 → +guard.ts(权限验证)→ +data.ts(数据预取)→ +Page.vue(页面渲染)
↑
+Layout.vue 包裹
+Head.vue 注入 <head>
7.1 +config.ts — 全局/页面级配置
全局配置 src/pages/+config.ts:
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:
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)都会执行此钩子,用于注册全局插件和指令:
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 配置提供者:
<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
<template>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</template>
7.5 +guard.ts — 路由守卫(权限验证)
核心权限验证机制,在 SSR 阶段拦截请求:
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() 在页面组件中使用:
// 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 来阻止继承:
// 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 预取数据:
<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:
<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)
// 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)
// 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;
},
},
});
类型定义:
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 实例
// 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 语言包结构
// 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 在组件中使用
<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() 翻译:
// 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)
// 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)
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)
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:
// 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 暴露的方法来修改布局状态。
+Layout.vue(全局布局)
├── AppSidebar(侧边栏 — 读取 layoutStore.sidebarMenus)
├── AppHeader(顶部栏 — 读取 layoutStore.breadcrumbs / headerActions)
└── <slot />(页面内容)
↑
页面在 onMounted 中调用 useLayout() 设置标题、面包屑、按钮等
11.2 useLayout 组合式函数
// 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(); },
};
}
页面中使用示例:
<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 组件
<!-- 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 的 router prop,因为它依赖 Vue Router。Vike 项目中应使用 @select 事件 + navigate() 手动导航。
11.4 AppHeader 组件
<!-- 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 文件。
解决方案:
// vite.config.ts
Components({
resolvers: [ElementPlusResolver({ importStyle: false })], // 禁用自动导入样式
}),
<!-- +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
解决方案:
// +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 国际化
<!-- +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 中管理:
// 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:
// 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,
],
};
// 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 状态:
// 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 };
}
列表页使用示例(控制添加/编辑/删除按钮):
<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 控制表单保存按钮):
<PermissionForm
:initial-data="detail"
:is-sending="isSending"
:can-submit="canUpdate"
@submit="submit"
@cancel="goBack"
/>
PermissionForm.vue 中保存按钮根据 canSubmit 属性禁用:
<el-button type="primary" :loading="isSending" :disabled="canSubmit === false" @click="submit">
{{ t('common.save') }}
</el-button>
13.4 Mock 权限验证(server/server.ts)
开发阶段通过 Mock 接口模拟权限检查:
// 模拟无权限的 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 权限流程图
用户请求页面
│
▼
+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 函数实现客户端路由跳转(无刷新):
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() 获取数据,展示统计卡片和操作日志表格 |
<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 中加载详情数据:
<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 同时服务于新增和编辑页面:
<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 |
数据流:
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:
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():
<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. 开发与构建命令
# 开发(启动 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/ 目录:
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. 构建
npm run build
2. 部署所需文件
将以下文件/目录上传到服务器:
dist/ # 构建产物(client + server)
server/server.ts # Express 服务器入口
package.json # 依赖声明
node_modules/ # 或在服务器上 npm install
3. 启动服务
# 方式一:直接用 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 自动切换行为:
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 暴露服务:
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 部署(可选)
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"]
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