Vue 生态(番外):零后端依赖!使用 JSON Server 快速构建 RESTful API

第一章:基础入门 · 构建与消费你的第一个 API 服务器

摘要: 本章是 JSON Server 的“Hello, World!”,但我们将以专业前端工程师的视角来开启它。我们将直面“等待后端接口”的开发瓶颈,然后在一个标准的 Vite + Vue 项目中,集成 axios 并构建一个 可复用的服务层 来封装所有 API 请求。随后,我们将启动 JSON Server,并通过我们亲手编写的服务,完成对 Mock 服务器的完整 CRUD 操作。学完本章,你将同时掌握“构建”和“消费”一个高保真 Mock API 的核心能力,并习得一套专业的 API 请求管理模式。


本章地图

  1. 直面痛点: 我们将明确为何需要 API Mock,并对比 Mock.js 与 JSON Server 的核心差异。
  2. 专业起步: 我们将初始化 Vite 项目,安装 json-server 与 axios,并构想和创建一个 服务层,用以封装所有 API 逻辑。
  3. 启动与对接: 我们将创建 db.json 并启动 JSON Server,然后在 Vue 组件中调用我们封装的服务,实现前后端的首次“握手”。
  4. 实战交互: 我们将通过组件与服务层的优雅协作,完整地实战 JSON Server 提供的复数路由与单数路由。

1.1. 痛点:前端开发的“等待之痛”与 Mock 方案的抉择

在真实的项目协作中,我们经常会遇到这样一种令人沮丧的窘境:作为前端开发者,你已经依据 UI/UX 设计稿,用最快的速度完成了页面的静态布局和组件搭建。你万事俱备,只等后端同学提供数据接口,以便进行数据联调和后续开发。

然而,你得到的答复往往是:“接口还在开发中,下周才能提测。”

此刻,你的工作流被迫中断。为了不延误项目进度,引入“模拟数据 (Mock Data)”成为了必然选择。在前端生态中,主流的 Mock 方案通常遵循两种不同的哲学:

哲学一:前端请求拦截与数据伪造。
这种模式的代表是 Mock.js。它的工作方式是在前端应用内部,通过拦截 Ajax 请求(例如,使用 axios 的拦截器),阻止其真实地发送到网络上。一旦拦截到匹配的请求,就返回一串由 Mock.js 按照预设规则 随机生成 的假数据。

哲学二:本地真实 API 服务器模拟。
这种模式的代表正是我们本章的主角 JSON Server。它不在前端应用内部做任何文章,而是独立运行一个 真实的、微型的 Web 服务器。你的前端应用会像请求真实后端一样,向这个本地服务器 (http://localhost:3001) 发送网络请求,而这个服务器则会像真实后端一样,给予规范的响应。

结论:对于我们即将学习的、需要处理数据增删改查并管理其状态的场景而言,JSON Server 是无疑是更优的选择。它让我们的前端代码无需任何 Mock 相关的“污染”,保持了生产环境的纯净性,同时提供了一个可交互、有记忆的后端模拟。


1.2. 专业起步:环境搭建与服务层封装

现在,我们正式开始搭建我们的实战环境,并引入服务层的架构思想。

第一步:初始化 Vite 项目

1
2
pnpm create vite json-server-lab --template vue-ts
cd json-server-lab

第二步:安装核心依赖

我们需要两个核心库:json-server 用于模拟后端,axios 用于发送前端请求。

1
2
3
4
5
# 安装 json-server 作为开发依赖,很遗憾作者已经停更了,新版本远不如稳定版来的划算,所以我们限制版本在 0.17.4
pnpm add -D json-server@0.17.4

# 安装 axios 作为生产依赖
pnpm add axios

第三步:创建并封装 API 服务层

这是本次重写的核心。我们将遵循 关注点分离 的原则,将所有与 API 通信相关的逻辑,都封装到一个专门的“服务层”中。组件将不再关心 axios 或具体的 URL,只负责调用服务。

1. 创建 axios 实例
首先,我们创建一个可复用的 axios 实例,用于统一配置。

文件路径: src/api/apiClient.ts (新增)

1
2
3
4
5
6
7
8
import axios from 'axios';

const apiClient = axios.create({
baseURL: 'http://localhost:3001', // 指向我们的 Mock 服务器
timeout: 5000,
});

export default apiClient;

2. 封装 posts 资源的服务
接着,我们为 posts 这个数据资源创建一个专门的服务文件。

文件路径: src/api/services/postsService.ts (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import apiClient from "../apiClient";
import type { AxiosResponse } from "axios";

export interface Post {
id: number;
title: string;
author: string;
}

export type CreatePostPayload = Omit<Post, "id">;
// 获取所有帖子
export const getAllPosts = (): Promise<AxiosResponse<Post[]>> => {
return apiClient.get("/posts");
};
// 创建帖子
export const createPost = (
payLoad: CreatePostPayload
): Promise<AxiosResponse<Post>> => {
return apiClient.post("/posts", payLoad);
};
// 更新帖子
export const updatePost = (
id: number,
payload: CreatePostPayload
): Promise<AxiosResponse<Post>> => {
return apiClient.put(`/posts/${id}`, payload);
};
// 删除帖子
export const deletePost = (id: number): Promise<AxiosResponse<void>> => {
return apiClient.delete(`/posts/${id}`);
};

通过这种方式,我们为 posts 资源创建了一套类型安全、语义清晰的 API 服务。组件将通过调用这些函数来与后端交互。

第四步:配置并启动 JSON Server

最后,我们创建“数据库”并配置启动脚本。

文件路径: db.json (新增)

当我们的 db.json 顶层为一个数组时,json-server 就会为我们生成一套增删改查的 restful 系统

1
2
3
{
"posts": []
}

文件路径: package.json (修改 scripts 部分)

1
2
3
4
5
6
7
8
9
{
// ...
"scripts": {
"dev": "vite",
"mock": "json-server --watch db.json --port 3001",
// ...
},
// ...
}

现在,打开 第一个终端 启动 Mock 服务器 (pnpm run mock),再打开 第二个终端 启动 Vite 项目 (pnpm run dev)。我们的专业开发环境已准备就绪。


1.3. 实战交互:组件与服务层的优雅协作

有了封装好的服务层,我们的 Vue 组件现在可以变得极为清爽,只专注于自己的核心职责:调用服务、管理状态和渲染 UI。

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<script setup lang="ts">
import { ref, onMounted } from "vue";
import {
getAllPosts,
createPost,
updatePost,
deletePost,
} from "./api/services/postsService.ts";
import type { Post } from "./api/services/postsService.ts";

const posts = ref<Post[]>([]);

// 生成随机文章或作者
const generateRandomArticleOrAuthor = (option: 'article' | 'author') => {
if (option === 'article') {
return `文章${Math.floor(Math.random() * 1000000)}`;
} else {
return `作者${Math.floor(Math.random() * 1000000)}`;
}
};
// 获取所有文章
const fetchPosts = async () => {
const response = await getAllPosts();
posts.value = response.data;
};

// 新增文章
const addPost = async (author: string) => {
const response = await createPost({
title: generateRandomArticleOrAuthor('article'),
author,
});
posts.value.push(response.data);
};

// 编辑文章
const editPost = async (id: number, author: string) => {
const response = await updatePost(id, {
title: generateRandomArticleOrAuthor('article'),
author,
});
posts.value = posts.value.map((post) =>
post.id === id ? response.data : post
);
};

// 删除文章
const removePost = async (id: number) => {
await deletePost(id);
posts.value = posts.value.filter((post) => post.id !== id);
};

onMounted(() => {
fetchPosts();
});
</script>

<template>
<div class="card">
<h2>文章列表 (Posts)</h2>
<button @click="fetchPosts">刷新文章</button>
<button @click="addPost(generateRandomArticleOrAuthor('author'))">新增文章</button>

<ul>
<li v-for="post in posts" :key="post.id">
<strong>{{ post.title }}</strong> - {{ post.author }}
<div class="controls">
<button @click="editPost(post.id, generateRandomArticleOrAuthor('author'))">編輯</button>
<button @click="removePost(post.id)">刪除</button>
</div>
</li>
</ul>
</div>
</template>

<style scoped>
.card {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: sans-serif;
}
ul {
list-style: none;
padding: 0;
margin-top: 20px;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
li:last-child {
border-bottom: none;
}
button {
margin-left: 10px;
}
</style>

通过这次重构,我们的 App.vue 组件变得职责单一、清晰可读。它完全不知道 axios 的存在,也不知道 API 的具体路径是什么。它只通过一个清晰的、类型安全的服务层接口来与外部世界通信。这,就是现代前端工程中处理数据交互的 最佳实践。

1.4. 交互核心原则:避开常见陷阱

摘要: 在你开始兴奋地使用 POST, PUT, PATCH 等方法与你的 Mock API 进行交互之前,我们需要稍作停留,探讨两条由 HTTP 协议和 RESTful 最佳实践所衍生的“黄金法则”。它们是 json-server 乃至绝大多数真实后端 API 都默默遵守的“潜规则”。提前掌握它们,能让你在开发中避免 90% “请求成功了,但数据没变”的诡异问题。

原则一:Content-Type 的强制性——明确告知服务器你的“语言”

“陷阱”场景
想象一下这个令人困惑的场景:你通过代码发送了一个 POST 请求去创建一个新帖子,开发者工具的网络面板显示请求状态为 200 OK,一切看起来都完美无缺。但当你刷新列表或检查 db.json 文件时,却发现新帖子根本没有被创建。

这是新手在使用 axios 或原生 fetch 时最常遇到的问题,其根源在于你没有明确告知服务器,你发送给它的数据主体(Request Body)是什么格式。

核心原则
对于任何需要携带数据主体的“写”操作 (POST, PUT, PATCH),客户端 必须 在请求头 (Headers) 中包含 Content-Type: application/json。

“为什么”要这么做?
这个请求头就像是在对服务器说:“你好,我接下来要发送给你的数据,是 JSON 格式的,请你用 JSON 解析器来处理它。”

如果没有这个“声明”,json-server(以及大多数后端框架)会收到你的数据,但它不知道该如何解读这串字节流。出于安全和规范的考虑,它会选择直接忽略这个它不理解的请求体,即便它会礼貌地返回一个 200 OK 状态码。

最佳实践:在服务层一劳永逸地解决

这正是我们在 1.2 节建立 apiClient.ts 的价值所在。我们可以在创建 axios 实例时,就将其作为默认配置,确保我们的每一次请求都符合规范。

文件路径: src/api/apiClient.ts (回顾与确认)

1
2
3
4
5
6
7
8
9
10
11
12
13
import axios from 'axios';

const apiClient = axios.create({
baseURL: 'http://localhost:3001',
timeout: 5000,
// (关键) axios 默认就会为 POST/PUT/PATCH 请求设置这个请求头
// 我们在这里显式地了解它的存在,并确认其重要性。
headers: {
'Content-Type': 'application/json',
}
});

export default apiClient;

幸运的是,axios 库的设计者已经预见到了这一点,通常会自动为写操作添加此请求头。但作为“架构师学徒”,我们必须清晰地知道这个行为的存在及其背后的原理,而不是仅仅依赖于框架的“魔法”。


原则二:ID 的不可变性——资源的唯一身份标识

“陷阱”场景
你尝试通过一个 PUT 请求去更新一篇文章,不仅想修改它的标题,还想顺便修改它的 id:

1
2
// 尝试将 id=1 的文章,其 id 修改为 99
updatePost(1, { id: 99, title: "一个全新的标题", author: "New Author" });

请求成功了,文章的 title 和 author 都被更新了,但当你再次获取这篇文章时,发现它的 id 依然是 1,而不是你期望的 99。

核心原则
在 RESTful 架构中,资源的 id 是其在数据库中的唯一主键,是其不可变更的“身份证号”。因此,json-server 会完全忽略 PUT 或 PATCH 请求体中包含的任何 id 字段。

“为什么”要这么做?
“更新” (Update) 操作的语义是“在原地修改一个现有资源的内容”。而修改 id 实质上等于“删除了一个旧资源,同时创建了一个新资源”,这已经超出了 PUT/PATCH 的职责范围。

唯一可以设置 id 的时机,是在 POST 创建一个全新资源的时候。当然,如果你在 POST 时不提供 id,json--server 会为你自动生成一个。

总结: 请将这两条原则刻在你的脑海里:

  1. 写操作,必带 Content-Type: application/json。
  2. id 在创建后,永不更改。

理解并遵守这两个看似简单的规则,将为你扫清通往更高级功能路上的诸多障碍,让你能够更专注于业务逻辑的实现,而不是在基础的通信协议上反复调试。


第二章:高级查询 · 模拟真实世界的复杂 API

摘要: 基础的 CRUD 只是起点。一个专业的 API 还需要具备强大的数据查询与筛选能力。本章,我们将深入探索 JSON Server 丰富且直观的查询参数体系。我们将学习如何实现 多条件过滤、服务端分页、动态排序 等一系列高级查询。更重要的是,我们会将这些能力,通过重构我们的 API 服务层,以一种类型安全、可扩展的方式提供给前端组件,最终构建出一个功能完善、体验真实的动态数据列表。


  1. 服务层重构: 我们将首先重构 postsService,使其能够接收动态的查询参数,为实现高级查询奠定架构基础。
  2. 数据筛选: 学习 JSON Server 的 过滤器 (Filters),实现如“按作者搜索”等功能。
  3. 分页加载: 学习 _page 和 _limit 参数,为我们的文章列表加上 分页 功能,并理解如何获取总数。
  4. 动态排序: 学习 _sort 和 _order 参数,让用户可以 自定义排序 规则。
  5. 进阶查询: 快速掌握 运算符 (_gte, _like) 和 全文检索 (q) 等更精细的查询工具。

2.1. 基础重构:打造一个可查询的服务

我们当前 getAllPosts 服务是一个“硬编码”的函数,它只能获取全部文章。为了支持后续的各种查询,我们必须先对其进行重构,让它能够接收一个动态的参数对象。这是一种重要的架构思想:将不变的逻辑(请求路径)与变化的逻辑(查询参数)分离。

定义查询参数类型

一个良好的实践是为所有可能的查询参数定义一个 TypeScript 接口,作为服务与组件之间清晰的“契约”。

文件路径: src/api/services/postsService.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
// ... Post 和 CreatePostPayload 接口保持不变 ...

// (新增) 定义所有可能的查询参数
export interface PostQueryParams {
_page?: number;
_limit?: number;
_sort?: string;
_order?: 'asc' | 'desc';
q?: string; // 用于全文检索
author?: string; // 用于按作者精确筛选
title_like?: string; // 用于按标题模糊搜索
// 随着查询功能增加,在此处扩展即可
}

重构 getAllPosts 服务

我们让 getAllPosts 接收这个参数对象,并将其传递给 axios。axios 会智能地将这个对象序列化为 URL 查询字符串。

文件路径: src/api/services/postsService.ts (修改)

1
2
3
4
5
6
7
8
9
10
// ... 接口定义 ...

// (重构) 让 getAllPosts 接收一个可选的 params 对象
export const getAllPosts = (params?: PostQueryParams): Promise<AxiosResponse<Post[]>> => {
// axios 的第二个参数是一个配置对象,其 params 属性会自动将对象转换为 URL 查询字符串
// 例如 { _page: 1, _limit: 10 } 会被转换为 "?_page = 1&_limit = 10"
return apiClient.get('/posts', { params });
};

// ... createPost, updatePost, deletePost 保持不变 ...

这次重构是本章最重要的基础。现在,我们的 getAllPosts 服务拥有了接收任意查询条件的“超能力”,准备好应对各种复杂的查询场景。


2.2. 数据筛选 (Filters)

JSON Server 允许你直接通过查询参数对任何字段进行精确匹配。这是构建搜索功能最直接的方式。

实战:在 App.vue 中添加按作者筛选的功能

我们将在 App.vue 中增加一个输入框,用于接收用户输入的作者名,并在请求时将其作为参数传递给我们的服务。

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script setup lang="ts">
// ... 省略其他 import 和 state 定义 ...
import type { Post, PostQueryParams } from "./api/services/postsService.ts";

const posts = ref<Post[]>([]););
const filterAuthor = ref(''); // 新增 state,用于绑定筛选输入框

// 获取所有文章
const fetchPosts = async () => {
const params: PostQueryParams = {};
if (filterAuthor.value) {
params.author = filterAuthor.value;
}
const response = await getAllPosts(params);
posts.value = response.data;
};

// ... 其他函数保持不变 ...
onMounted(fetchPosts);
</script>


<template>
<div class="card">
<h2>文章列表 (Posts)</h2>
<input v-model="filterAuthor" placeholder="筛选作者" />
<button @click="fetchPosts">查询文章</button>
<button @click="addPost(generateRandomArticleOrAuthor('author'))">新增文章</button>
</div>
</template>

现在,在输入框中输入 作者名 并按回车或点击搜索,JSON Server 将只返回 author 字段为 作者 的文章。
JSON Server 还支持更复杂的筛选:
多条件 (与): ?title=...&author=...
多值 (或): ?id=1&id=2
深度过滤: ?address.city=Gwenborough (如果数据有嵌套)
你只需在 PostQueryParams 接口和 fetchPosts 函数中增加相应的逻辑即可轻松支持。


2.3. 分页加载(Page)

当数据量巨大时,一次性加载所有数据是不可接受的。JSON Server 内置了标准的服务端分页支持。

核心参数

  • _page: 指定请求的页码(从 1 开始)。
  • _limit: 指定每页返回的数据条数。

关键响应头

  • X-Total-Count: JSON Server 会在分页请求的响应头中,通过这个字段返回该资源的总记录数。这是我们计算总页数的关键。

实战:为文章列表添加分页

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<script setup lang="ts">
// ...
const currentPage = ref(1);
const postsPerPage = ref(5);
const totalPosts = ref(0);

// 获取所有文章
const fetchPosts = async () => {
const params: PostQueryParams = {
_page: currentPage.value,
_limit: postsPerPage.value,
};
if (filterAuthor.value) {
params.author = filterAuthor.value;
}

const response = await getAllPosts(params);
// 从响应头中获取总记录数
totalPosts.value = Number(response.headers["x-total-count"]);
posts.value = response.data;
};

const goToPage = (page: number) => {
if (page < 1 || (page - 1) * postsPerPage.value >= totalPosts.value) {
return;
}
currentPage.value = page;
fetchPosts();
};
</script>

<template>
<div class="card">

<ul>
<!-- .... -->
</ul>
<div class="pagination">
<button @click="goToPage(currentPage - 1)" :disabled="currentPage <= 1">
上一页
</button>
<span
>第 {{ currentPage }} 页 / 共
{{ Math.ceil(totalPosts / postsPerPage) }} 页</span
>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage * postsPerPage >= totalPosts"
>
下一页
</button>
</div>
</div>
</template>

<style scoped>
/* ... */
.pagination { margin-top: 20px; }
</style>

2.4. 动态排序 (Sorting)

核心参数

  • _sort: 指定用于排序的字段名。
  • _order: 指定排序顺序,asc (升序) 或 desc (降序)。

实战:添加按标题或作者排序功能

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<script setup lang="ts">
// ...
const sortBy = ref('id');
const sortOrder = ref<'asc' | 'desc'>('asc');

const fetchPosts = async () => {
try {
const params: PostQueryParams = {
_page: currentPage.value,
_limit: postsPerPage.value,
_sort: sortBy.value,
_order: sortOrder.value,
};
// ... 合并筛选参数 ...

const response = await getAllPosts(params);
// ...
}
// ...
};

const changeSort = (field: string) => {
if (sortBy.value === field) {
// 如果已经是当前排序字段,则切换排序方向
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
} else {
// 否则,切换到新字段并重置为升序
sortBy.value = field;
sortOrder.value = 'asc';
}
fetchPosts();
}
// ...
</script>

<template>
<div class="card">
<div class="sort-section">
<button @click="changeSort('id')">按id排序</button>
</div>

</div>
</template>

现在,我们已经为应用实现了企业级后台常见的全部核心查询功能:筛选、分页和排序。我们的服务层和组件也展现出了良好的扩展性。


2.5. 进阶查询:运算符与全文检索

我们已经掌握了筛选、分页和排序,这足以构建一个功能强大的数据列表。然而,在真实的应用场景中,我们往往需要更精细、更智能的查询能力。JSON Server 同样为我们提供了这些“进阶武器”。

本节,我们将学习两种最实用的高级查询技术:

  1. 运算符 (Operators): 实现基于字段的模糊搜索、范围查询等。
  2. 全文检索 (Full-Text Search): 实现跨字段的全局关键字搜索。

使用运算符进行精准筛选

JSON Server 允许我们在查询参数的字段名后,追加 _ 和指定的运算符,来实现超越简单“等值匹配”的查询。

最常用的运算符包括:

  • _like: 进行模糊匹配(子字符串查询)。这对于实现搜索框的即时建议功能至关重要。
  • _gte: Greater than or equal to (大于或等于 > =)。
  • _lte: Less than or equal to (小于或等于 <=)。
  • _ne: Not equal to (不等于 !=)。

为了在我们的服务层中支持这些动态的、带后缀的查询键(如 title_like, views_gte 等),我们需要对 PostQueryParams 接口进行一次小小的升级,使用 索引签名 来增加其灵活性。

文件路径: src/api/services/postsService.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... Post 和 CreatePostPayload 接口保持不变 ...

export interface PostQueryParams {
_page?: number;
_limit?: number;
_sort?: string;
_order?: 'asc' | 'desc';
q?: string;
author?: string;
// (关键) 添加索引签名
// 这意味着 PostQueryParams 可以拥有任意的字符串键
// 例如 'title_like', 'views_gte' 等,其值的类型不限
[key: string]: any;
}

// ... 所有服务函数保持不变 ...

现在,我们的服务层已经准备好接收任何形式的运算符查询了。让我们在 App.vue 中实战 title_like 功能。

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<script setup lang="ts">
// ...
const titleLike = ref(''); // 新增 state,用于绑定模糊搜索输入框

const fetchPosts = async () => {
isLoading.value = true;
const params: PostQueryParams = {
_page: currentPage.value,
_limit: postsPerPage.value,
_sort: sortBy.value,
_order: sortOrder.value,
};
if (filterAuthor.value) {
params.author = filterAuthor.value;
}
// (新增) 如果模糊搜索框有值,则添加 title_like 参数
if (titleLike.value) {
params.title_like = titleLike.value;
}

const response = await getAllPosts(params);
// ...
}
// ...
};
// ...
</script>

<template>
<div class="card">
<div class="controls">
<input v-model="titleLike" placeholder="筛选文章" />
<button @click="fetchPosts">查询文章</button>
<button @click="addPost(generateRandomArticleOrAuthor('author'))">
新增文章
</button>
</div>
</div>
</template>

现在,在“按标题模糊搜索”框中输入 模糊内容 并搜索,JSON Server 将会返回所有标题中包含 模糊内容 字符串的文章。

使用 q 参数进行全文检索

q 参数是 JSON Server 提供的一个极其便利的功能。它会在一个资源的所有字段中,进行不区分大小写的全文检索。这非常适合用于实现一个“全局搜索”框。

实战:添加全局搜索功能

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<script setup lang="ts">
// ...
const globalSearch = ref(""); // 新增 state,用于全局搜索
// 获取所有文章
const fetchPosts = async () => {
const params: PostQueryParams = {
_page: currentPage.value,
_limit: postsPerPage.value,
};
// (关键) q 参数与其他筛选条件是互斥的,通常我们只使用一种
// 为了演示,建议您吧之前的搜索条件都删掉
if (globalSearch.value) {
params.q = globalSearch.value;
}

const response = await getAllPosts(params);
// 从响应头中获取总记录数
totalPosts.value = Number(response.headers["x-total-count"]);
posts.value = response.data;
};
</script>

<template>
<div class="card">
<!-- 从filterAuthor改为globalSearch -->
<div class="controls">
<input
v-model="globalSearch"
placeholder="全局搜索所有字段..."
@keyup.enter="fetchPosts"
/>
<button @click="fetchPosts">全局搜索</button>
<hr />
</div>
</div>
</template>

现在,如果你在全局搜索框中输入内容,即使 title 字段不包含它,JSON Server 也会因为 author 字段匹配到了内容而返回对应的文章记录。

2.6. 数据分片 (Slicing):另一种数据截取范式


在上一节,我们学习了分页 (Paging),这是一种非常经典和通用的数据获取方式,它将数据集合划分为固定大小的“页”。然而,在某些现代 UI 模式中,尤其是“无限滚动”或需要按任意区间加载数据的场景下,基于“页码”的思维模式会显得有些笨拙。

为了应对这些场景,JSON Server 提供了另一种更底层、更灵活的数据截取机制——分片 (Slicing)。

2.6.1. 核心思想:从“页”到“索引”的转变

让我们首先厘清分页与分片的核心思想差异:

  • 分页 (Paging):你关心的是 “第几页” 和 “每页多少条”。你告诉服务器:“给我第 3 页的数据,每页 10 条。” 服务器内部会计算出 (3 - 1) * 10,然后从第 20 条记录开始,取 10 条数据返回给你。这是一种高度抽象和封装的模式。

  • 分片 (Slicing):你关心的是数据的 “起始索引” 和 “结束索引”。你直接告诉服务器:“给我从索引号 20 开始,到索引号 30 结束的数据。” 这种模式与 JavaScript 中的 Array.prototype.slice(start, end) 方法在思想上完全一致,它给了前端开发者更直接、更精确的数据控制权。

2.6.2. 核心参数

JSON Server 通过以下三个参数来实现分片功能:

  • _start: 指定截取的 起始索引(从 0 开始)。这个索引位置的元素 会被包含 在结果中。
  • _end: 指定截取的 结束索引。这个索引位置的元素 不会被包含 在结果中,形成一个“半包”区间 [start, end)。
  • _limit: 这个参数可以与 _start 组合使用,表示从 _start 索引开始,连续获取 _limit 条记录。这在实现“加载更多”功能时非常方便。

关键响应头: 和分页一样,使用分片进行请求时,JSON Server 同样会在响应头中返回 X-Total-Count 字段,告诉你该资源的总记录数。这对于计算滚动条位置、判断是否已加载全部数据至关重要。

2.6.3. 实战:改造服务与组件以支持分片

现在,我们将这个新能力集成到我们的项目中。

第一步:更新 PostQueryParams 接口

我们需要在服务层的类型定义中,加入对 _start 和 _end 的支持。

文件路径: src/api/services/postsService.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ... Post 和 CreatePostPayload 接口保持不变 ...

export interface PostQueryParams {
_page?: number;
_limit?: number;
_sort?: string;
_order?: 'asc' | 'desc';
q?: string;
author?: string;

// (新增) 为分片添加类型支持
_start?: number;
_end?: number;

[key: string]: any;
}

// ... 所有服务函数保持不变 ...

第二步:在 App.vue 中演示分片加载

为了清晰地演示分片的效果,我们将暂时移除分页逻辑,并添加一个按钮来加载指定索引区间的数据。

文件路径: src/App.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getAllPosts } from "./api/services/postsService.ts";
import type { Post, PostQueryParams } from "./api/services/postsService.ts";

const posts = ref<Post[]>([]);
const totalPosts = ref(0);

// (重构) fetchPosts 函数,使其能够接收 start 和 end 索引
const fetchPostsBySlice = async (start: number, end: number) => {
isLoading.value = true;
const params: PostQueryParams = {
_start: start,
_end: end,
};
const response = await getAllPosts(params);
posts.value = response.data;
totalPosts.value = Number(response.headers["x-total-count"]);
};

onMounted(() => {
// 初始加载前 10 条数据
fetchPostsBySlice(0, 10);
});
</script>

<template>
<div class="card">
<h2>文章列表 (Slicing Demo)</h2>
<div class="controls">
<button @click="fetchPostsBySlice(10, 20)">
加载索引 [10, 20) 的文章
</button>
<button @click="fetchPostsBySlice(50, 55)">
加载索引 [50, 55) 的文章
</button>
</div>
<p>总文章数: {{ totalPosts }}</p>

<ul>
<li v-for="post in posts" :key="post.id">
<strong>{{ post.title }}</strong> - {{ post.author }}
</li>
</ul>
</div>
</template>

<style scoped>
/* 样式保持不变 */
.card {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: sans-serif;
}
.controls button {
margin-right: 10px;
}
ul {
list-style: none;
padding: 0;
margin-top: 20px;
}
li {
padding: 10px;
border-bottom: 1px solid #eee;
}
</style>

现在,运行你的 Vite 项目和 mock 服务。页面初始会加载索引 0 到 9 的文章。当你点击“加载索引 [10, 20) 的文章”按钮时,列表会更新为索引 10 到 19 的文章数据。

2.6.4. “无限滚动”场景的思考

有了分片的能力,实现一个“无限滚动”列表的逻辑就变得非常清晰了:

  1. 维护状态: 在组件中维护一个 currentIndex 变量,初始值为 0,以及一个 pageSize (例如 10)。
  2. 首次加载: 页面加载时,调用 getAllPosts({ _start: 0, _limit: pageSize })。
  3. 监听滚动: 监听页面的滚动事件。当用户滚动到页面底部时,触发加载更多逻辑。
  4. 加载更多:
    • 更新 currentIndex = currentIndex + pageSize。
    • 调用 getAllPosts({ _start: currentIndex, _limit: pageSize }) 获取下一批数据。
    • 将新获取的数据追加到现有的 posts 数组中。
  5. 终止条件: 当某次请求返回的数据量小于 pageSize,或者 currentIndex 已经超过 totalPosts 时,停止监听滚动,并显示“没有更多了”的提示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getAllPosts } from "./api/services/postsService.ts";
import type { Post, PostQueryParams } from "./api/services/postsService.ts";

const posts = ref<Post[]>([]);
const totalPosts = ref(0);
const pageSize = 10;
// 新增核心的三个属性
const isLoading = ref(false);
const hasMore = ref(true);
const cardRef = ref<HTMLElement | null>(null);

const fetchMorePosts = async () => {
// 若正在加载或没有更多数据,则返回
if (isLoading.value || !hasMore.value) return;
isLoading.value = true;
// 设置查询参数
const params: PostQueryParams = {
_start: posts.value.length,
_limit: pageSize,
};
try {
const response = await getAllPosts(params);
const newPosts = response.data;
// 更新 posts 数组,这里用concat拼接,而不是push,因为push会改变原数组,而concat不会
posts.value = posts.value.concat(newPosts);
totalPosts.value = Number(response.headers["x-total-count"]);
// 如果 posts 数组的长度大于等于 totalPosts,则没有更多数据
if (posts.value.length >= totalPosts.value || newPosts.length < pageSize) {
hasMore.value = false;
}
} finally {
isLoading.value = false;
}
};

const handleScroll = () => {
const el = cardRef.value;
if (!el || isLoading.value || !hasMore.value) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
fetchMorePosts();
}
};

onMounted(async () => {
await fetchMorePosts();
cardRef.value?.addEventListener("scroll", handleScroll);
});
</script>

<template>
<div class="card" ref="cardRef" style="height: 500px">
<h2>文章列表 (无限滚动 Demo)</h2>
<p>总文章数: {{ totalPosts }}</p>
<ul>
<li v-for="post in posts" :key="post.id">
<strong>{{ post.title }}</strong> - {{ post.author }}
</li>
</ul>
<div
v-if="isLoading"
style="text-align: center; color: #888; margin: 16px 0"
>
加载中...
</div>
<div
v-else-if="!hasMore"
style="text-align: center; color: #888; margin: 16px 0"
>
没有更多了
</div>
</div>
</template>

<style scoped>
.card {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: sans-serif;
/* 当内容超出容器时,显示滚动条 */
overflow-y: scroll;
height: 500px; /* 固定高度,确保可以滚动 */
}
.controls button {
margin-right: 10px;
}
ul {
list-style: none;
padding: 0;
margin-top: 20px;
}
li {
padding: 10px;
border-bottom: 1px solid #eee;
}
</style>

总结: 数据分片 (Slicing) 为前端提供了原子级别的、基于索引的数据请求能力。它虽然比分页 (Paging) 更底层,但在实现无限滚动、虚拟列表等需要精确控制数据窗口的场景下,是一种更强大、更符合直觉的工具。掌握了它,意味着你的工具箱里又多了一件应对复杂数据加载需求的利器。


2.7. 本章核心回顾与速查

摘要: 在本章中,我们完成了一次至关重要的蜕变:从仅仅能“获取全部数据”,进化到能够随心所欲地对数据进行 “筛选、排序、截取”。我们不仅学习了 JSON Server 提供的一系列强大查询参数,更重要的是,我们通过对 服务层 (postsService) 的持续重构,建立了一套可扩展、类型安全的 API 查询架构。这套架构思想,将是你未来职业生涯中构建任何复杂数据驱动应用的重要基石。

思想升华:从“数据请求者”到“数据指挥家”

回顾本章的旅程,我们的核心收获并不仅仅是记住几个以下划线开头的参数。真正的价值在于我们思维模式的转变:

  1. 架构先行: 我们做的第一件事,也是最重要的一件事,就是将 getAllPosts 函数从一个无参数的“死”函数,重构为一个接收动态 params 对象的“活”函数。这一步,为后续所有高级查询能力的注入打开了大门。这正是“架构师学徒”所追求的——通过良好的设计,让系统拥抱变化。

  2. 解锁“数据显微镜”: 通过学习 过滤器 (Filters)、运算符 (Operators) 和 全文检索 (q),我们获得了对数据的精细控制能力。我们不再是被动地接收整个数据集,而是可以像使用显微镜一样,精确地定位到我们需要的任何一个数据子集。无论是“查找李四发布的所有标题包含‘Vue’的文章”,还是“全局搜索包含‘pnpm’关键字的任何内容”,都已尽在掌握。

  3. 掌握两种“数据传输带”: 面对海量数据,我们学习了两种核心的解决方案:

    • 分页 (Paging): 经典、可靠,是构建传统后台表格、文章列表等 UI 的基石。我们重点掌握了通过 X-Total-Count 响应头来构建完整分页 UI 的核心技巧。
    • 分片 (Slicing): 更底层、更灵活,是实现无限滚动、虚拟列表等现代 UI 模式的利器。它让我们从“页”的束缚中解放出来,回归到更本质的“索引”层面进行操作。

学完本章,你的前端应用在与 Mock API 交互时,已经具备了与一个真实、成熟的后端系统对话的能力。你不再是一个只会 GET /posts 的初学者,而是一个能够清晰地向后端表达复杂数据需求的“数据指挥家”。

核心功能速查表

以下是本章所有核心查询参数的速查表。在你未来的实战中,它可以作为你快速回忆和查阅的“第二大脑”。

分类关键项核心描述与用法示例
基础筛选[field]=value(精确匹配) 筛选出字段 field 的值等于 value 的所有记录。< br > GET /posts?author=typicode
基础筛选[field].path=value(深度过滤) 当字段值为对象时,通过 . 访问其深层属性进行过滤。< br > GET /users?address.city=Gwenborough
分页_page=<number>指定要获取的数据页码,页码从 1 开始。< br > GET /posts?_page=2
分页_limit=<number>指定每页返回的记录数量。< br > GET /posts?_page=2&_limit=10
分页X-Total-Count (响应头)(总数获取) 在分页请求的响应头中,JSON Server 会通过此字段返回该资源的总记录数。
数据分片_start=<number>(起始索引) 从索引 number 处开始截取(包含此索引)。< br > GET /posts?_start=20
数据分片_end=<number>(结束索引) 截取到索引 number 之前(不包含此索引)。< br > GET /posts?_start=20&_end=30
数据分片_start & _limit(组合使用) 从 _start 索引开始,获取 _limit 条记录。< br > GET /posts?_start=0&_limit=5
排序_sort=<field>指定用于排序的字段。可提供多个字段,用逗号分隔。< br > GET /posts?_sort=author,title
排序_order=<asc|desc>指定排序顺序(升序/降序)。可对应多个排序字段。< br > GET /posts?_sort=views&_order=desc
高级筛选[field]_like=<string>(模糊搜索) 筛选出 field 字段值包含 string 子字符串的所有记录。< br > GET /posts?title_like=awesome
高级筛选[field]_gte=<number>筛选出 field 字段值 大于或等于 指定值的记录。< br > GET /posts?views_gte=1000
高级筛选[field]_lte=<number>筛选出 field 字段值 小于或等于 指定值的记录。< br > GET /posts?views_lte=50
高级筛选[field]_ne=<value>筛选出 field 字段值 不等于 指定值的记录。< br > GET /posts?id_ne=1
全文检索q=<string>(全局搜索) 在一个资源的所有字段中,进行不区分大小写的全文检索。< br > GET /posts?q=pnpm

第三章:专业进阶 · 完全掌控你的 Mock API

摘要: 查询只是 API 的一部分。一个专业的后端模拟,还需要处理数据间的复杂关系、具备生成海量数据的能力,甚至需要模拟自定义的业务逻辑(如权限验证)。本章,我们将深入 JSON Server 的“专家模式”。你将学会如何处理 连表查询、如何 动态生成海量数据、如何 自定义 API 路由,并最终掌握将其作为 Node.js 模块使用的终极武器,让你能够通过中间件注入任何你想要的逻辑,打造出一个无限接近真实后端的、高保真的 Mock 环境。


3.1. 处理数据关系:连表查询 (Relationships)

在真实的业务数据中,资源之间很少是孤立的。文章 (posts) 拥有评论 (comments),评论属于某篇文章;文章有作者,作者是用户 (users)。在前端,如果我们需要展示一篇文章及其所有评论,一种天真的做法是:

  1. 请求 GET /posts/1 获取文章信息。
  2. 再请求 GET /posts/1/comments 获取该文章的评论列表。

这种需要多次网络往返来“拼凑”数据的模式,被称为 “N+1 查询瀑布”,是前端性能的一大杀手。一个设计良好的后端 API,通常会通过“连表查询”机制,让前端可以在一次请求中获取到所有需要的主体数据和关联数据。

JSON Server 通过 _expand 和 _embed 这两个强大的参数,完美地模拟了这一核心能力。

第一步:准备具有关联关系的数据

为了演示连表查询,我们首先需要扩充 db.json,建立 posts 和 comments 之间清晰的父子关系。

文件路径: db.json (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"posts": [
{ "id": "p1", "title": "json-server,太棒了!", "author": "typicode" },
{ "id": "p2", "title": "pnpm,很快!", "author": "pnpm" }
],
"comments": [
{ "id": "c1", "body": "这是一个很棒的工具。", "postId": "p1" },
{ "id": "c2", "body": "我同意,它很棒。", "postId": "p1" },
{ "id": "c3", "body": "确实,pnpm很棒!", "postId": "p2" }
],
"profile": {
"name": "typicode"
}
}

请注意: 我们将 id 改为字符串形式(如 "p1", "c1")以模拟更真实的场景。comments 中的 postId 字段,就是关联到 posts id 的“外键”。


3.1.1. _expand:展开父资源 (获取评论及其所属文章)

当你获取一个“子”资源(如 comment),并希望同时带上其“父”资源(如 post)的完整信息时,使用 _expand。

JSON Server 会根据 [resource]Id 这样的命名约定(如 postId)来识别外键。?_expand=post 的含义是:“请在返回的 comment 数据中,将 postId 字段展开为完整的 post 对象”。

请求示例: GET http://localhost:3001/comments/c1?_expand=post

1
2
3
4
5
6
7
8
9
10
{
"id": "c1",
"body": "这是一个很棒的工具。",
"postId": "p1",
"post": {
"id": "p1",
"title": "json-server,太棒了!",
"author": "typicode"
}
}

你看,我们只发起了一次请求,就同时拿到了评论和它所属的文章信息。


3.1.2. _embed:嵌入子资源 (获取文章及其所有评论)

当你获取一个“父”资源(如 post),并希望同时带上其下所有的“子”资源(如 comments)时,使用 _embed。

?_embed=comments 的含义是:“请在返回的 post 数据中,嵌入一个名为 comments 的数组,该数组包含所有 postId 等于当前 post id 的评论”。

请求示例: GET http://localhost:3001/posts/p1?_embed=comments

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"id": "p1",
"title": "json-server,太棒了!",
"author": "typicode",
"comments": [
{
"id": "c1",
"body": "这是一个很棒的工具。",
"postId": "p1"
},
{
"id": "c2",
"body": "我同意,它很棒。",
"postId": "p1"
}
]
}

同样地,一次请求就获取了文章和它的全部评论,完美解决了 N+1 问题。


3.1.3. 服务层与组件集成实战

现在,我们将这些能力集成到我们的 postsService 和 App.vue 中。

第一步:更新服务层类型与方法

文件路径: src/api/services/postsService.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ... apiClient ...
import type { Comment } from './commentsService'; // 假设我们也会为 comments 创建服务

export interface Post {
id: string;
title: string;
author: string;
}
// (新增) 定义带评论的文章类型
export interface PostWithComments extends Post {
comments?: Comment[];
}

export type CreatePostPayload = Omit<Post, 'id'>;

// (新增) 封装一个根据 ID 获取单篇文章的服务,并支持 embed
export const getPostById = (
id: string,
options?: { embedComments?: boolean }
): Promise<AxiosResponse<PostWithComments>> => {
const params: any = {};
if (options?.embedComments) {
params._embed = 'comments';
}
return apiClient.get(`/posts/${id}`, { params });
};
// ... 其他服务函数保持不变 ...

第二步:在组件中消费带关联数据的 API

文件路径: src/App.vue (简洁的修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<script setup lang="ts">
import { ref, onMounted } from "vue";
import {
getPostById,
} from "./api/services/postsService.ts";
import type { PostWithComments } from "./api/services/postsService.ts";
// 将我们的主数据类型改为PostWithComments
const post = ref<PostWithComments | null>(null);

const fetchPostDetail = async (id : string) => {
const response = await getPostById(id, { embedComments: true });
console.log(response.data);
post.value = response.data;
};

onMounted(() => {
// 默认获取第一篇文章的详情
fetchPostDetail("p1");
});
</script>

<template>
<div class="card">
<h2>文章详情 (Post Detail)</h2>

<div v-if="post">
<h3>{{ post.title }}</h3>
<p>作者: {{ post.author }}</p>

<div v-if="post.comments && post.comments.length > 0">
<h4>评论:</h4>
<ul>
<li v-for="comment in post.comments" :key="comment.id">
{{ comment.body }}
</li>
</ul>
</div>
</div>
</div>
</template>

<style scoped>
.card {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: sans-serif;
}
ul {
list-style: none;
padding: 0;
margin-top: 20px;
}
li {
padding: 10px;
border-bottom: 1px solid #eee;
}
li:last-child {
border-bottom: none;
}
</style>

现在,点击按钮,你的应用将只发起一次网络请求,就能渲染出包含文章和其所有评论的完整视图。通过 _expand 和 _embed,我们的 Mock API 在数据关联的处理上,已经具备了相当高的真实性。


3.2. 摆脱静态 JSON:动态数据生成

在前面的章节中,我们的 db.json 文件一直扮演着“微型数据库”的角色。对于功能验证来说,这非常方便。但当我们需要测试更真实的场景时——例如,一个需要处理上百条数据的分页表格,或一个需要展示多样化用户信息的列表——手动编写一个庞大且内容丰富的 db.json 文件,就变成了一项枯燥、低效且难以维护的工作。

为了解决这个痛点,JSON Server 提供了一个强大的特性:它不仅可以加载静态的 .json 文件,还可以执行一个 .js 文件,并将其导出的数据对象作为 API 的数据源。

这为我们打开了新世界的大门:我们可以利用 JavaScript 的编程能力,结合专业的数据伪造库,来程序化地生成任意数量、任意结构的逼真模拟数据。


3.2.1. 我们的新朋友:认识 Faker.js

在开始编码前,我们必须先隆重介绍即将为我们提供“无限弹药”的强大盟友——@faker-js/faker。

Faker 是一个非常流行的库,其唯一的使命就是生成海量的、看起来非常 逼真 的假数据。它和简单的 Math.random() 有着本质区别。Faker 生成的数据是 有语义 的,这对于构建高保真的 UI 原型至关重要。

为什么需要“逼真”的数据?
想象一下,你的 UI 布局在显示“测试用户 1”时完美无缺,但在显示一个真实的、长短不一的外国人名(如 Mrs. Odell Corwin)时,可能会出现换行或溢出。使用 Faker 生成的逼真数据,可以帮助我们在开发阶段就提前发现并修复这类与内容相关的 UI 问题。

Faker 的能力一瞥

API 调用示例输出
faker.person.fullName()Brendan Gleichner
faker.internet.email()Brendan.Gleichner23@hotmail.com
faker.lorem.sentence()The automobile layout consisted of a sedan.
faker.image.avatar()https://avatars.githubusercontent.com/u/1234567
faker.finance.amount()841.25

Faker 几乎可以为你业务中需要的任何数据类型,提供符合其格式和语义的模拟数据。

现在,让我们正式将这位新朋友集成到我们的项目中。


3.2.2. 第一步:安装依赖

在你的 json-server-lab 项目中,将 @faker-js/faker 安装为开发依赖:

1
pnpm add -D @faker-js/faker

3.2.3. 第二步:创建数据生成脚本

最简单的解决方案是将文件命名为 .cjs 扩展名,这样 Node.js 就会将其作为 CommonJS 模块处理。

现在,我们创建一个 generate-data.cjs 文件来替代 db.json。

文件路径: generate-data.cjs (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const { faker } = require("@faker-js/faker");

// 导出一个函数,JSON Server 在启动时会执行这个函数来获取数据
module.exports = () => {
const data = {
posts: [],
profile: {
name: "typicode",
},
// ... 你可以继续添加其他资源
};

// 创建 500 条逼真的文章数据
for (let i = 0; i < 500; i++) {
data.posts.push({
// 使用 faker 生成一个唯一的字符串 ID
id: faker.string.uuid(),
// 生成一个随机的句子作为标题
title: faker.lorem.sentence(),
// 生成一个随机的全名作为作者
author: faker.person.fullName(),
});
}

return data;
};

这个脚本导出了一个函数。当 JSON Server 加载这个文件时,它会执行此函数,并将返回的 data 对象作为内存中的数据库。我们利用 faker 库,在循环中轻松地创建了 500 条结构一致但内容各异的文章数据。


3.2.4. 第三步:更新启动命令

现在,我们需要告诉 JSON Server 去加载我们的新脚本,而不是旧的 db.json。

文件路径: package.json (修改 mock 脚本)

1
2
3
4
5
6
7
8
9
10
{
// ...
"scripts": {
"dev": "vite",
// 将数据源从 db.json 修改为 generate-data.js
"mock": "json-server generate-data.cjs --port 3001",
// ...
},
// ...
}

注意:由于数据是在服务器启动时一次性生成的,--watch 参数对于 .js 文件意义不大,因此可以省略。如果你修改了 generate-data.cjs,需要重启 mock 服务才能生效。


3.2.5. 第四步:验证成果

文件路径: src/api/services/postsService.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
// ...
// 获取所有帖子
export const getAllPosts = (
params?: PostQueryParams,
options?: { embedComments?: boolean }
): Promise<AxiosResponse<PostWithComments[]>> => {
const queryParams = params || {};
if (options?.embedComments) {
queryParams._embed = "comments";
}
return apiClient.get("/posts", { params: queryParams });
};

文件路径: /src/app.vue (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getAllPosts } from "./api/services/postsService.ts";
import type { PostWithComments } from "./api/services/postsService.ts";
// 将我们的主数据类型改为PostWithComments
const posts = ref<PostWithComments[]>([]);

const fetchPosts = async () => {
const response = await getAllPosts({ _embed: "comments" });
posts.value = response.data;
};

onMounted(() => {
fetchPosts();
});
</script>

<template>
<div class="card">
<h2>文章详情 (Post Detail)</h2>

<div v-if="posts.length > 0">
<div v-for="post in posts" :key="post.id">
<h3>{{ post.title }}</h3>
<p>作者: {{ post.author }}</p>

<div v-if="post.comments && post.comments.length > 0">
<h4>评论:</h4>
<ul>
<li v-for="comment in post.comments" :key="comment.id">
{{ comment.body }}
</li>
</ul>
</div>
</div>
</div>
</div>
</template>

<style scoped>
.card {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: sans-serif;
}
ul {
list-style: none;
padding: 0;
margin-top: 20px;
}
li {
padding: 10px;
border-bottom: 1px solid #eee;
}
/* 选中偶数进行高亮 */
li:nth-child(even) {
background-color: #f5f5f5;
}
li:last-child {
border-bottom: none;
}
</style>
  1. 确保你已经停止了之前运行的 mock 服务 (Ctrl+C)。
  2. 在终端运行新的启动命令: pnpm run mock。
  3. 刷新你的 Vite 应用页面 (或直接访问 http://localhost:3001/posts)。

你将看到,你的文章列表现在充满了由 Faker 生成的、足足 500 条丰富多彩的数据。我们之前在第二章中实现的分页、筛选和排序功能,现在终于有了用武之地,你可以在这个庞大的数据集上尽情测试它们的表现。

通过程序化生成数据,我们成功地将模拟数据的 结构定义(在脚本中)与 内容填充(由 Faker 完成)分离开来。这使得创建和维护大规模、高保真的测试数据集变得轻而易举,是专业前端开发中进行复杂 UI 测试和性能评估的必备技巧。


3.4. 锦上添花:提升效率的辅助特性

摘要: 一个专业的工具箱里,除了完成核心任务的重型装备,还应该有一些精巧、锋利的小工具,它们能在特定场景下极大地提升我们的工作效率。本节,我们将学习 JSON Server 提供的三个辅助特性:数据库快照 (/db)、静态文件服务 (--static) 和 远程数据源。它们将分别帮助我们更高效地进行调试、快速原型验证和团队协作。

3.4.1. 全局数据库快照 (/db):你的终极调试窗口

痛点分析
在开发过程中,你可能会频繁地通过前端页面或 Postman 等工具对数据进行增、删、改。几轮操作下来,你可能会有一个疑问:现在服务器内存里,完整、真实的数据状态 到底是什么样的?尤其是当我们使用 .js 脚本动态生成初始数据后,磁盘上已经没有一个静态的 db.json 文件可供参考了。

解决方案
JSON Server 提供了一个极其有用的内置端点:GET /db。访问这个端点,你将得到一个 JSON 响应,其中包含了当前服务器内存中所有资源的完整数据快照。

实战用法

  1. 启动你的 mock 服务。
  2. 在前端页面上进行任意多次的“新增文章”、“删除文章”操作。
  3. 在浏览器中打开一个新的标签页,访问 http://localhost:3001/db。

你将看到一个包含了 posts, comments, profile 等所有资源的 JSON 对象,其内容精确地反映了你刚才所有操作之后的结果。


3.4.2. 集成静态资源服务 (--static):API 与 UI 的一站式服务

场景分析
设想一个场景:你需要快速验证一个非常简单的 UI 原型,可能只有一个 index.html 文件和几行原生的 JavaScript。为了让这个页面能请求到 json-server 的 API,你通常需要:

  • 终端一:运行 pnpm run mock 启动 API 服务。
  • 终端二:通过 Vite 或其他 live-server 工具启动一个静态文件服务来托管你的 index.html。

这不仅繁琐,而且还可能遇到跨域问题。

解决方案
JSON Server 可以通过 --static 命令行参数,摇身一变,成为一个同时能提供 API 服务和静态文件服务的“二合一”服务器。

实战用法

  1. 在你的项目根目录下,创建一个 public 文件夹。

    1
    mkdir public
  2. 在 public 文件夹中创建一个 index.html 文件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <title>JSON Server Demo</title>
    </head>
    <body>
    <h1>文章列表</h1>
    <ul id="posts-list"></ul>
    <script>
    // 直接请求同源下的 API
    fetch('/api/v1/posts?_limit=5')
    .then(response => response.json())
    .then(posts => {
    const list = document.getElementById('posts-list');
    list.innerHTML = posts.map(p => `<li>${p.title}</li>`).join('');
    });
    </script>
    </body>
    </html>
  3. 修改你的 package.json 启动命令,添加 --static 参数。

    文件路径: package.json (修改 mock 脚本)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    // ...
    "scripts": {
    // ...
    "mock": "json-server generate-data.cjs --routes routes.json --port 3001 --static ./public",
    // ...
    },
    // ...
    }
  4. 重启 pnpm run mock 服务。

现在,你只需一个终端。访问 http://localhost:3001/,你将看到 index.html 的页面内容;同时,页面中的 fetch('/api/v1/posts') 请求也能成功获取到 API 数据,因为它们现在是同源的。


3.4.3. 远程数据源:让数据源走向云端

协作场景
在团队协作中,Mock 数据的“唯一事实来源 (Single Source of Truth)”可能不是你本地的一个文件,而是一个由后端同事维护、托管在公司内部网络或 GitHub Gist 上的 JSON 文件链接。

解决方案
JSON Server 的数据源参数不仅可以接受本地文件路径,还可以直接接受一个 URL。

用法示例
假设 https://jsonplaceholder.typicode.com/db 是一个公开的、包含 posts, comments 等资源的 JSON 文件 URL。你可以直接这样启动服务:

1
json-server https://jsonplaceholder.typicode.com/db

JSON Server 会在启动时下载这个 URL 的内容,并将其作为内存数据库。这使得团队成员之间共享和同步 Mock 数据变得异常简单——只需要共享一个 URL 即可。


第四章:终极武器 · 以专业架构集成 JSON Server

摘要: JSON Server 的声明式配置虽然强大,但终有其能力边界。当我们需要模拟非标准 RESTful 行为、权限校验、或自定义响应结构时,就需要祭出终极武器——将 JSON Server 作为一个标准的 Node.js 模块,在 Express.js 的生态中赋予其无限的扩展能力。本章,你将学会如何通过编写中间件,为你的 Mock API 注入任意自定义逻辑,将其从一个“数据模拟器”升级为一个真正意义上的**“后端行为模拟器”**。


本章地图

4.1. 声明式配置的边界: 明确“为什么”我们需要以编程方式扩展 JSON Server。

4.2. 基础设置:专业的 API 命名空间: 学习以“路由挂载”的最佳实践来启动服务,从源头规范 API 路径。

4.3. 实战核心:中间件与请求体处理: 掌握 Express 中间件的核心概念,并学会处理 POST/PUT 请求的必备工具 bodyParser。

4.4. 实战进阶:模拟权限校验: 编写一个真实世界的权限校验中间件,模拟受保护的 API。

4.5. 终极美化:自定义响应结构: 学习重写 router.render 方法,将所有 API 响应统一包裹成企业级格式。

4.6. 高级技巧:动态路由重写: 探索 jsonServer.rewriter,实现比 routes.json 更强大、更灵活的路由映射。


4.1. 声明式配置的边界

db.json 和 routes.json 让我们能用极低的成本,覆盖约 80% 的标准 REST API 模拟场景。但剩下的 20% 复杂场景,则超出了它们的能力范围,例如:

  • 复杂的业务校验: 如何模拟“创建用户时,用户名不能重复”?
  • 非 RESTful 端口: 如何模拟一个 POST /login 接口,它接收用户名密码,并返回一个 token?
  • 权限控制: 如何模拟“只有管理员角色才能 DELETE 文章”?
  • 动态响应: 如何模拟一定概率下出现的 500 服务器错误,以测试前端的容错能力?

要解决这些问题,我们必须跳出 CLI 的便捷框架,进入一个更强大、更灵活的世界——将 JSON Server 作为我们自己 Node.js 应用中的一个模块来使用。


4.2. 基础设置:专业的 API 命名空间

在真实项目中,所有 API 几乎都会被组织在一个统一的命名空间下,例如 /api/v1/。这样做可以清晰地将 API 与其他资源(如页面、静态文件)区分开,也便于未来进行版本管理。

与其使用 routes.json 做简单的路径替换,更专业、更具扩展性的方式是路由挂载 (Router Mounting)。

第一步:创建 server.cjs

在项目根目录创建一个 server.cjs 文件。我们使用 .cjs 扩展名是为了确保 Node.js 能以 CommonJS 规范正确加载它,这是最直接的方式。

文件路径: server.cjs (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. 引入所需库
const jsonServer = require("json-server");
const path = require("path");

// 2. 创建一个 Express 服务器实例
const server = jsonServer.create();

// 3. 获取 JSON Server 默认提供的一组中间件 (logger, static, cors, no-cache)
const middlewares = jsonServer.defaults();

// 4. 将 generate-data.cjs 作为数据源创建一个路由器
// 使用 path.join 确保路径在不同操作系统下都正确
const router = jsonServer.router(path.join(__dirname, "generate-data.cjs"));

// 5. 在服务器上应用默认中间件
server.use(middlewares);

// 6. (核心) 将 JSON Server 的路由挂载到 '/api' 命名空间下
server.use("/api", router);

// 7. 启动服务器,监听 3001 端口
server.listen(3001, () => {
console.log("JSON Server is running on http://localhost:3001");
});

第二步:更新前端服务层

因为所有 API 路径现在都带上了 /api 前缀,我们需要更新 apiClient 的 baseURL 来匹配。

文件路径: src/api/apiClient.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
import axios from 'axios';

const apiClient = axios.create({
// 将 baseURL 指向新的 API 命名空间
baseURL: 'http://localhost:3001/api',
timeout: 5000,
headers: {
'Content-Type': 'application/json',
}
});

export default apiClient;

第三步:更新启动命令

文件路径: package.json (新增 mock:pro 脚本)

1
2
3
4
5
6
7
8
9
{
// ...
"scripts": {
"dev": "vite",
"mock": "json-server generate-data.cjs --routes routes.json --port 3001",
"mock:pro": "node server.cjs"
},
// ...
}

现在,运行 pnpm run mock:pro。你的 Mock API 服务就以一种更专业的方式启动了。访问 http://localhost:3001/api/posts 将会成功获取数据,而访问 http://localhost:3001/posts 则会返回 404 Not Found。这种清晰的隔离是健壮架构的标志。


4.3. 实战核心:中间件与请求体处理

Express 中间件是一个 (req, res, next) 函数,它像生产线上的工序一样,对流入的请求进行一步步地加工处理。

在添加我们自己的“工序”之前,必须先安装一个关键的设备:请求体解析器 (Body Parser)。

前置知识: 对于自定义的 POST, PUT, PATCH 路由,你需要 req.body 来获取客户端发送的数据。但默认情况下,req.body 是 undefined。你需要一个中间件来读取请求流并将其解析为 JavaScript 对象。

JSON Server 已经内置了这个工具,我们只需启用它。

我们的目标: 启用 bodyParser,并编写第一个中间件,为所有 POST 请求自动添加 createdAt 时间戳。

文件路径: server.cjs (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ... require, create, defaults, router ...

// 5. 在服务器上应用默认中间件
server.use(middlewares);

// 6. (核心) 启用请求体解析器
// 这个中间件必须在任何需要读取 req.body 的自定义路由或中间件之前使用
server.use(jsonServer.bodyParser);

// 7. (新增) 自定义中间件,为 POST 请求添加时间戳
server.use((req, res, next) => {
if (req.method === "POST") {
req.body.createdAt = new Date().toISOString();
}
// 调用 next() 将请求传递给下一个中间件(最终会到 JSON Server 的路由器)
next();
});

// 8. 将 JSON Server 的路由挂载到 '/api' 命名空间下
server.use("/api", router);

// 9. 启动服务器
server.listen(3001, () => {
console.log("JSON Server is running on http://localhost:3001");
});

重启 mock:pro 服务,现在每当你通过前端应用创建一个新帖子,检查返回的数据或 /db 快照,都会发现它自动包含了 createdAt 字段。


4.4. 实战进阶:模拟权限校验

这是一个极其常见的场景:模拟需要登录凭证才能访问的受保护路由。

我们的目标: 保护所有“写”操作 (POST, PUT, PATCH, DELETE),要求请求头中必须包含 Authorization: Bearer my-secret-token。

文件路径: server.cjs (在时间戳中间件之后,路由挂载之前添加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ... (之前的代码) ...
// 7. 自定义中间件,为 POST 请求添加时间戳
server.use((req, res, next) => { /* ... */ });

// 8. (新增) 权限校验中间件
server.use((req, res, next) => {
const protectedRoutes = ['/api/posts', '/api/comments']; // 定义受保护的路由
const protectedMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];

// 检查当前请求的路由和方法是否需要保护
if (protectedRoutes.includes(req.path) && protectedMethods.includes(req.method)) {
if (req.headers.authorization === 'Bearer my-secret-token') {
next(); // 凭证正确,放行
} else {
res.status(401).json({ error: 'Unauthorized: Access token is missing or invalid.' });
}
} else {
next(); // 对于其他所有请求,直接放行
}
});

// 9. 将 JSON Server 的路由挂载到 '/api' 命名空间下
server.use("/api", router);

// 10. 启动服务器
server.listen(3001, () => { /* ... */ });

重启服务后,任何不带正确 Authorization 头的写操作都将被拒绝,并收到 401 错误,完美模拟了受保护的 API 行为。你需要在前端的 apiClient.ts 中通过拦截器或在单个请求中添加此请求头来通过验证。


4.5. 终极美化:自定义响应结构

真实后端的 API 响应,通常会有一个统一的包裹结构,例如 { code, data, msg }。这便于前端进行统一的响应处理。

我们的目标: 将所有成功的 API 响应,从 [...] 或 {...} 格式,统一修改为 { code: 200, data: [...], message: 'Success' } 格式。

文件路径: server.cjs (在路由挂载之后添加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ... (之前的代码) ...
// 9. 将 JSON Server 的路由挂载到 '/api' 命名空间下
server.use("/api", router);

// 10. (新增) 重写 router.render 方法
// 这个方法会在 JSON Server 准备好响应数据后,发送给客户端之前被调用
router.render = (req, res) => {
res.status(200).jsonp({
code: 200,
message: 'Success',
// res.locals.data 中存放着 JSON Server 准备好的原始响应数据
data: res.locals.data
});
};

// 11. 启动服务器
server.listen(3001, () => { /* ... */ });

重启服务,现在访问 http://localhost:3001/api/posts,你得到的将不再是原始的数组,而是经过我们精心包装后的、更具真实感的统一响应结构。


4.6. 高级技巧:动态路由重写

routes.json 是静态的,而 jsonServer.rewriter() 允许你用编程逻辑来动态地重写路由,它比路由挂载更精细,比 routes.json 更灵活。

对比:

  • 路由挂载 (server.use('/api', router)): 适用于全局添加前缀,进行命名空间隔离。
  • 路由重写 (rewriter): 适用于个别路径的转换、美化或动态映射。

我们的目标: 将 GET /api/articles?id=1 这样的“丑陋”路径,重写为 JSON Server 能理解的 GET /api/posts/1。

文件路径: server.cjs (在默认中间件之后,bodyParser 之前添加)

1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
// 5. 在服务器上应用默认中间件
server.use(middlewares);

// 6. (新增) 使用 rewriter 进行路由重写
server.use(jsonServer.rewriter({
"/api/articles": "/api/posts",
"/api/articles/:id": "/api/posts/:id"
}));

// 7. 启用请求体解析器
server.use(jsonServer.bodyParser);
// ... (后续中间件和路由) ...

现在,即使你的前端代码请求的是 /api/articles/1,rewriter 也会在请求到达 router 之前,悄无声息地将其转换为 /api/posts/1,从而获取到正确的数据。这在适配那些路径设计不完全符合 RESTful 规范的旧 API 时尤其有用。


第五章:部署、监控与实用配置

摘要: 恭喜你,已经来到了我们课程的最后一站。在这里,我们将完成从“开发者”到“架构师”的思维跃迁。本地的模拟终究是第一步,一个专业的工程师需要考虑的是:如何让模拟环境更贴近真实世界?如何管理复杂的配置?如何将 Mock API 融入团队协作与自动化流程中?本章,我们将为你补完这至关重要的“最后一公里”,学习部署、监控和进行工业级的专业配置。

5.1. 模拟真实世界:网络延迟与只读模式

在本地开发时,API 请求几乎是瞬时完成的。这种理想环境会掩盖大量在真实网络环境下才会暴露的问题,例如 UI 的 Loading 状态是否健壮、是否处理了重复提交等竞态条件。为了解决这些问题,我们需要人为地制造“真实感”。

JSON Server 提供了两个强大的参数来模拟这些真实世界的限制:

  • 网络延迟 (--delay, -d)

    这个参数会为每一个 API 响应人为地增加一段延迟,对于测试应用的 UI 健壮性至关重要。

    你可以这样在命令行中使用它:

    1
    2
    3
    # 让每个 API 请求都延迟 800毫秒 后再返回
    # 注意:如果 package.json 脚本中已有参数,需要用 -- 分隔
    pnpm run mock -- --delay 800

    启动这个模式后,再去体验你的应用,就能够清晰地检验 Loading 动画、骨架屏以及各种请求状态下的 UI 表现。

  • 只读模式 (--read-only, --ro)

    这个参数会禁用所有的“写”操作(POST, PUT, PATCH, DELETE),让你的 API 变为一个只读的数据源。

    1
    pnpm run mock -- --read-only

    当需要将 Mock API 地址分享给他人进行产品演示,或作为公开文档的示例数据源时,只读模式可以确保数据不会被意外修改,提供了一个稳定、安全的数据环境。


5.2. 超越默认:适配异构后端规范

JSON Server 的默认设定非常标准,但真实世界的后端 API 五花八门。当目标后端 API 规范与默认值不同时,我们需要让工具来适应我们,而不是反过来。

  • 自定义主键 (--id, -i)

    如果你的后端主键字段名为 _id 或 uuid,可以使用此参数进行指定:

    1
    2
    # 指定 _id 作为所有资源的主键字段
    pnpm run mock -- --id _id
  • 自定义外键后缀 (--foreignKeySuffix, --fks)

    如果后端外键字段名为 post_id 而不是 postId,你可以使用此参数来让 _expand 等关系查询功能恢复正常:

    1
    2
    # 指定所有外键都以 _id 结尾
    pnpm run mock -- --fks _id

当配置参数越来越多,package.json 中的 scripts 会变得臃肿不堪。专业的做法是使用 json-server.json 配置文件来统一管理所有设置。

在项目根目录创建 json-server.json 文件:

1
2
3
4
5
6
7
8
9
{
"port": 3001,
"watch": true,
"static": "./public",
"delay": 200,
"id": "id",
"routes": "routes.json",
"foreignKeySuffix": "_id"
}

之后,你的 package.json 启动脚本就可以简化为 json-server -c json-server.json generate-data.cjs。这使得配置的管理和团队同步都变得更加专业和高效。

5.3. 走向云端:协作与部署

首先,要实现协作,需要让你的 Mock 服务从本机走向局域网。使用 --host 0.0.0.0 参数启动服务,局域网内的任何设备便可通过你的电脑 IP 地址(如 http://192.168.1.10:3001)访问你的服务,这对于手机真机调试或团队内部协作至关重要。

而将 Mock API 部署到公网,则能满足更广泛的协作需求,其核心价值在于:

  • 团队协作: 为 App 端、小程序端、或其他前端同事提供一个 24/7 可用的、稳定的 Mock 环境。
  • CI/CD 集成: 在自动化测试流程中(如 Cypress E2E 测试),让测试服务器有一个可依赖的数据源。
  • 产品演示: 在没有真实后端的情况下,向客户或产品经理展示一个功能完整的在线原型。

部署方案主要有两种:

  1. 现代方案: Vercel / Netlify (Serverless)

    这是最符合现代前端技术栈的方案。其核心思路是将 server.cjs 改造为一个 Serverless Function。由于 JSON Server 的 server 对象本身就是 Express 实例,你只需移除 server.listen() 部分,直接导出 server 对象即可。例如,在 Vercel 项目的 api/index.js 文件中导出 server,平台会自动将其部署为可公开访问的 API 服务。

您提的对,光有理论描述确实不够,一个清晰、可执行的代码示例才能真正让概念落地。为这种“现代方案”提供具体代码是完全必要的。

我将为您详细补充这一部分,包括所需的文件结构、代码和配置文件。


5.3.1. 现代方案实战:将 Mock API 部署到 Vercel

Vercel 是一个非常受现代前端开发者欢迎的平台,它可以轻松部署静态网站和 Serverless Functions。我们将利用这个能力来部署 json-server。

第一步:调整项目结构

Vercel 默认会识别项目根目录下的 api 文件夹作为 Serverless Functions 的来源。我们需要创建这个目录,并将我们的服务逻辑和数据文件放进去。

你的项目结构应该看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
/json-server-lab
|
|-- /api
| |-- index.cjs # 我们的 Serverless Function 核心逻辑
| |-- db.json # Mock API 的数据源
|
|-- /src
| |-- ... (你的 Vue 应用源文件)
|
|-- package.json
|-- vercel.json # Vercel 的配置文件 (非常重要)
|-- ... (其他如 vite.config.js 等文件)

第二步:编写 Serverless Function (api/index.cjs)

这个文件和我们在第四章中创建的 server.cjs 非常相似,但有一个关键区别:它不监听端口,而是直接导出 server 实例。

文件路径: api/index.cjs (新增或从 server.cjs 复制修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const jsonServer = require("json-server");
const path = require("path");

// 创建一个 Express 服务器实例
const server = jsonServer.create();

// 核心:在 Serverless 环境中,__dirname 指向当前文件所在目录。
// 我们使用 path.resolve 来确保能正确找到位于同一目录下的 db.json。
const router = jsonServer.router(path.resolve(__dirname, 'db.json'));

const middlewares = jsonServer.defaults();

server.use(middlewares);
server.use(router);

// 关键:不要调用 server.listen()
// 直接导出 Express server 实例,Vercel 会处理后续的启动和路由
module.exports = server;

请注意:为了简单起见,这里我们使用静态的 db.json。由于 Serverless 环境是无状态的,“写”操作(POST, PUT)不会被持久保存。因此,部署到 Vercel 的 Mock API 最适合作为只读的数据源。

第三步:配置 Vercel (vercel.json)

这个配置文件是整个部署过程的“交通警察”。它告诉 Vercel 如何处理收到的请求:哪些请求应该交给我们的 API 处理,哪些应该显示我们的前端应用。

文件路径: vercel.json (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"version": 2,
"builds": [
{
"src": "api/index.cjs",
"use": "@vercel/node"
},
{
"src": "package.json",
"use": "@vercel/static-build",
"config": {
"distDir": "dist"
}
}
],
"rewrites": [
{
"source": "/api/(.*)",
"destination": "/api/index.cjs"
},
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}

配置解读:

  • builds 数组定义了 Vercel 需要构建什么。第一个对象告诉它 api/index.cjs 是一个 Node.js 服务。第二个对象告诉它如何构建我们的前端应用(运行 build 命令,并将产物目录 dist 作为静态网站)。
  • rewrites 数组是路由规则。第一条规则说:所有以 /api/ 开头的请求,都转发给我们的 api/index.cjs 函数处理。第二条规则说:所有其他的请求,都显示 index.html,这是单页应用(SPA)的标准配置。

第四步:部署

  1. 确保你的 package.json 中有 build 命令 ("build": "vite build")。
  2. 将你的整个项目推送到一个 GitHub/GitLab/Bitbucket 仓库。
  3. 在 Vercel 官网上,选择“Import Project”,然后选择你的项目仓库。
  4. Vercel 会自动识别 vercel.json 配置,执行构建和部署。

部署成功后,你将获得一个公开的 URL,例如 https://your-project-name.vercel.app。你的 Mock API 将可以通过 https://your-project-name.vercel.app/api/posts 这样的路径被全世界访问。

5.4. 数据状态管理:使用快照进行场景测试

在进行端到端(E2E)测试时,最大的挑战之一就是保证数据的可预测性。--snapshots (或 -S) 参数为此提供了完美的解决方案。

开启此功能后,每次你进行“写”操作,json-server 都会在项目根目录的 snapshots 文件夹中,自动保存一份当前数据库的完整快照。

这带来了一种专业的测试工作流:

  1. 准备基线数据: 启动服务,通过 API 调用或手动修改 db.json,构造出你需要的第一个测试场景。
  2. 生成快照: 对 API 发起一次任意的写操作(如 POST 一个临时数据再 DELETE 它),这将触发 json-server 在 snapshots 目录生成一份当前的数据库快照 .json 文件。
  3. 命名场景: 将这份快照重命名为有意义的名称,例如 scenario-basic-posts.json。你可以重复此过程,创建所有需要的测试场景快照。
  4. 在测试中使用: 在你的自动化测试脚本中,可以在测试开始前,通过命令行指定本次 json-server 启动时使用哪个快照文件作为数据源。
    1
    2
    # 在 E2E 测试脚本中,为“空状态测试”启动服务
    json-server snapshots/scenario-empty.json

架构师思维: 数据快照功能将你的 Mock API 从一个简单的“模拟器”提升为了一个专业的“测试数据控制器”。它使得状态可复现的自动化测试成为可能,是保障前端应用质量、构建可靠 CI/CD 流水线的关键一环。