虽然 Vue 3 本身是前端框架,但配合 Vite 与 Node.js 运行时,很多团队会选择在同仓库里搭建全栈工程。这种情况下,TypeORM 作为 TypeScript 优先的 ORM,能够与 Vue 3 的类型系统无缝衔接,实体类甚至可以前后端复用。不过真正把 TypeORM 用好,关键不在于写几个实体,而在于实体和迁移文件的组织方式是否工程化。本文围绕这个主题,从目录结构、实体设计、迁移管理三个层面展开。

一、工程结构规划:前后端同仓库下的 TypeORM 落点
典型的 Vue 3 全栈项目可以采用 monorepo 结构,server 目录承担后端职责,TypeORM 的所有内容都集中在这里。一个清晰的目录划分如下:
project-root/ ├── packages/ │ └── shared/ # 前后端共享的类型与实体 │ └── entities/ │ ├── User.ts │ └── Post.ts ├── server/ │ ├── src/ │ │ ├── data-source.ts # DataSource 配置 │ │ ├── migrations/ # 迁移文件集中存放 │ │ │ └── 1700000000000-InitSchema.ts │ │ └── index.ts │ └── package.json ├── src/ # Vue 3 前端 │ ├── main.ts │ └── App.vue └── package.json
把实体放进 shared 包有明显的收益:后端通过 TypeORM 使用它们做数据库操作,前端则可以直接复用类型定义来做表单校验或接口类型推导,避免同一份数据结构写两遍导致不同步。需要注意的一点是,实体文件中引用的装饰器来自 typeorm 包,前端打包时如果 tree-shaking 配置不当,可能把整个 TypeORM 拖进产物。解决办法是在共享包中只导出类型定义和纯数据类,装饰器部分通过装饰器工厂或仅在 server 侧注册。
另一个实践要点是迁移文件必须纳入版本控制。有些教程会教你把 migrations 目录加进 .gitignore,这是绝对错误的。迁移文件是数据库结构演进的唯一历史记录,丢了它就无法在新环境重建数据库,也无法排查线上结构问题。正确做法是把每次生成的迁移文件像普通代码一样提交、评审、合并。
二、实体设计规范:装饰器与类型的正确姿势
TypeORM 的实体通过装饰器声明,一个设计良好的实体应该明确主键策略、列类型和关系映射。下面是一个结合 Vue 3 博客场景的示例:
import {
Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn
} from 'typeorm';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 200 })
title!: string;
@Column({ type: 'text' })
content!: string;
@Column({ type: 'boolean', default: false })
published!: boolean;
@ManyToOne(() => User, { onDelete: 'CASCADE', eager: false })
@JoinColumn({ name: 'author_id' })
author!: User;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}这里有几个值得展开的细节。首先是主键选择,PrimaryGeneratedColumn('uuid') 生成的 UUID 在分布式场景下比自增 ID 更安全,不会暴露业务量,缺点是索引体积略大、可读性差。如果业务以单库为主且需要按时间排序展示,自增整型依然是最简单的选择。
其次是关系的加载策略。TypeORM 提供 eager 和 lazy 两种自动加载方式,但两者都有坑:eager 关系会在每次查询时自动 JOIN,容易产生意外的性能开销,还可能在关系成环时造成无限递归;lazy 关系依赖 Promise,序列化给前端时容易忘记 await 导致输出空对象。工程化实践中更推荐显式使用 relations 选项或 QueryBuilder 手动控制 JOIN,把加载时机掌握在自己手里,这一点在 Vue 3 前端对接接口时尤其重要,因为接口返回的形状应当是确定的。
最后是命名约定。建议在 @Entity 和 @Column 中显式指定 snake_case 的数据库命名,而不是依赖 TypeORM 的默认转换。显式声明虽然啰嗦,但迁移文件和数据库结构一目了然,排查问题时不用在两种命名风格之间来回换算。
三、DataSource 配置与迁移的生成、执行、回滚
TypeORM 0.3 之后推荐使用独立的 DataSource 对象而不是全局连接,这让配置更利于测试和按需初始化。server 侧的配置文件示例如下:
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { User, Post } from '../../packages/shared/entities';
export const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
synchronize: false, // 生产环境必须为 false
logging: process.env.NODE_ENV !== 'production',
entities: [User, Post],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
migrationsTableName: 'migrations_history',
});配置里最关键的一项是 synchronize。开发阶段打开它可以自动同步实体到表结构,省去手写迁移;但一旦库里有真实数据,synchronize 会直接执行 DROP、ALTER 等破坏性语句,且不经过任何确认。因此团队规范应当明确:本地开发随意用,任何共享环境和生产环境一律走迁移脚本。
迁移的日常流程是修改实体后运行命令自动生成差异文件,命令行配置可以写进 server 的 package.json:
{
"scripts": {
"migration:generate": "typeorm-ts-node-esm migration:generate -d src/data-source.ts src/migrations/Migration",
"migration:run": "typeorm-ts-node-esm migration:run -d src/data-source.ts",
"migration:revert": "typeorm-ts-node-esm migration:revert -d src/data-source.ts"
}
}执行 npm run migration:generate 后,TypeORM 会对比实体与当前数据库结构,生成一个包含 up 与 down 方法的迁移类。up 负责向前变更,down 负责回滚,生成的文件建议人工审查一遍再提交,特别是涉及删列、改类型的操作,自动生成的 SQL 可能造成数据丢失,需要手动补充数据迁移逻辑:
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Migration1700000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "posts" ADD "slug" varchar(180) NOT NULL DEFAULT ''`
);
// 手动补充:用标题填充新列
await queryRunner.query(
`UPDATE "posts" SET "slug" = lower(replace(title, ' ', '-'))`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "posts" DROP COLUMN "slug"`);
}
}回滚机制是迁移体系的安全网。migration:revert 每次撤销最近一次已执行的迁移,依赖 migrations 表中的时间戳记录,所以给迁移文件加上精确到毫秒的时间戳前缀非常重要,它决定了执行顺序。部署环节建议把 migration:run 挂在服务启动脚本之前,比如在 Docker 的 entrypoint 或 CI 流水线的部署阶段先跑迁移再启动应用,保证结构与代码版本始终一致。
四、与 Vue 3 开发工作流的整合建议
前端侧的 Vue 3 服务通过 Vite 的 proxy 转发到 Node 后端,TypeORM 的存在对前端完全透明,但开发体验上可以做几件事让它更顺滑。第一,接口层的类型直接从共享实体推导,例如定义 type PostDTO = Omit<Post, 'author'> & { author: { id: string; name: string } },实体字段变化时前端立即得到类型报错。第二,在 Vitest 中为后端编写涉及数据库的测试时,可以基于同一份 DataSource 配置连接独立的测试库,在每个测试套件前执行迁移、结束后清理,保证测试隔离。
还要提醒一个常见误区:不要在 Vue 组件或任何会被浏览器执行的代码里 import 实体文件中依赖 typeorm 的部分。哪怕只是意外引入,也会把 Node 专用的依赖带进浏览器构建。稳妥的做法是用 TypeScript 的 import type 只引入类型,编译后不会留下任何运行时引用。只要把这条边界守住,前后端共享实体的收益就能完整兑现,整个工程的类型安全和结构演进都会变得可控。