6.2 为什么用内存存储做示例
本教程用内存数组来演示 Model 层,而不是直接上数据库。原因是:
- 聚焦架构思想,不用纠结数据库语法
- 没有数据库也能跑完整项目
- 换成真实数据库时,只改 Model 层,其他层不动
第 14 篇会讲怎么换成真实数据库。
6.3 类型定义
先定义数据长什么样。
src/models/project/project.ts:
typescript
export interface Project {
id: number;
name: string;
description: string;
status: 'active' | 'inactive';
created_at: number; // Unix 时间戳(秒)
updated_at: number;
}
用时间戳还是 Date 对象?
两种都常见,各有优劣:
| 方式 | 优点 | 缺点 |
|---|---|---|
| Unix 时间戳(秒) | 传输方便、数据库存数字就行 | 可读性差,前端要格式化 |
| Date 对象 | 可读性好 | 序列化后是字符串,前后端要约定格式 |
| ISO 字符串 | 人类可读 | 字符串比较大小不如数字方便 |
本教程用秒级时间戳,和大多数管理后台项目一致。
6.4 Model 类的实现
src/models/project/project-model.ts:
typescript
import type { Project } from './project.js';
export class ProjectModel {
// 用私有字段存数据,外部不能直接访问
#projects: Project[];
constructor(initialData: readonly Project[] = []) {
// 深拷贝一份,防止外部修改影响内部数据
this.#projects = initialData.map((item) => structuredClone(item));
}
// ===== 查询 =====
// 获取全部列表
list(): Project[] {
return structuredClone(this.#projects);
}
// 按 ID 查找
findById(id: number): Project | undefined {
const project = this.#projects.find((p) => p.id === id);
return project ? structuredClone(project) : undefined;
}
// 按名称查找(用于查重)
findByName(name: string): Project | undefined {
const project = this.#projects.find((p) => p.name === name);
return project ? structuredClone(project) : undefined;
}
// ===== 新增 =====
create(input: Pick<Project, 'name' | 'description' | 'status'>): Project {
const now = Math.floor(Date.now() / 1000);
const project: Project = {
...input,
id: this.#nextId(),
created_at: now,
updated_at: now,
};
this.#projects.push(structuredClone(project));
return structuredClone(project);
}
// ===== 更新 =====
update(
id: number,
input: Partial<Pick<Project, 'name' | 'description' | 'status'>>,
): Project | undefined {
const project = this.#projects.find((p) => p.id === id);
if (!project) return undefined;
if (input.name !== undefined) project.name = input.name;
if (input.description !== undefined) project.description = input.description;
if (input.status !== undefined) project.status = input.status;
project.updated_at = Math.floor(Date.now() / 1000);
return structuredClone(project);
}
// ===== 删除 =====
delete(id: number): boolean {
const index = this.#projects.findIndex((p) => p.id === id);
if (index < 0) return false;
this.#projects.splice(index, 1);
return true;
}
// ===== 内部方法 =====
// 生成自增 ID
#nextId(): number {
if (this.#projects.length === 0) return 1;
return Math.max(...this.#projects.map((p) => p.id)) + 1;
}
}
6.5 几个关键设计
1. 私有字段(#projects)
用 # 而不是 private 关键字。
private是 TypeScript 的,编译后就没了,运行时还是能访问#是 JavaScript 原生私有字段,运行时也访问不了
真正想保护数据,用 #。
2. 每次返回都深拷贝
typescript
list(): Project[] {
return structuredClone(this.#projects);
}
为什么要深拷贝?因为如果直接返回内部数组的引用,外部修改了返回值,内部数据也会被改,绕过了 Model 的控制。
typescript
// 如果不拷贝,外部可以直接改内部数据
const list = projectModel.list();
list[0].name = '被篡改了'; // 内部数据也变了!
深拷贝保证了外部只能通过 Model 的方法修改数据,数据一致性有保障。
3. 新增和更新时自动维护时间戳
created_at 和 updated_at 由 Model 层自动维护,调用方不用管。
4. 用 Pick / Partial 定义入参
typescript
create(input: Pick<Project, 'name' | 'description' | 'status'>): Project
update(id: number, input: Partial<Pick<Project, 'name' | 'description' | 'status'>>)
Pick表示从接口里挑出哪些字段Partial表示这些字段都是可选的(更新时不需要传全部)
这样定义入参,类型安全又不重复。
6.6 初始数据
src/data/project/index.ts:
typescript
import type { Project } from '@/models/project/project.js';
const CREATED_AT = 1_700_000_000;
export const projectData: readonly Project[] = [
{
id: 1,
name: '示例项目A',
description: '这是一个示例项目',
status: 'active',
created_at: CREATED_AT,
updated_at: CREATED_AT,
},
{
id: 2,
name: '示例项目B',
description: '这是另一个示例项目',
status: 'inactive',
created_at: CREATED_AT,
updated_at: CREATED_AT,
},
];
在 bootstrap 里把初始数据传给 Model:
typescript
import { projectData } from './data/project/index.js';
import { ProjectModel } from './models/project/project-model.js';
const projectModel = new ProjectModel(projectData);
6.7 Model 层能有查询方法吗
可以。Model 层除了基础的 CRUD,还可以有各种查询方法,比如:
typescript
// 按状态筛选
listByStatus(status: Project['status']): Project[] {
return this.#projects
.filter((p) => p.status === status)
.map((p) => structuredClone(p));
}
// 模糊搜索名称
searchByName(keyword: string): Project[] {
const lower = keyword.toLowerCase();
return this.#projects
.filter((p) => p.name.toLowerCase().includes(lower))
.map((p) => structuredClone(p));
}
原则:Model 层只管「怎么查数据」,不管「为什么要查这些数据」。
比如"管理员能看到所有项目,普通用户只能看到自己的项目"------这是业务规则,应该在 Service 层做判断,然后调用 Model 的不同方法。
6.8 小结
- Model 层只做数据 CRUD,不包含业务逻辑
- 用私有字段保护数据,外部只能通过方法操作
- 返回数据时深拷贝,防止外部直接修改内部状态
- 时间戳、ID 自增等由 Model 层自动维护
- 初始数据放在
data/目录,启动时注入 Model