MongoDB: mongoose模型与Schema设计

最后更新:2026-08-26

mongoose Schema 是 Node.js 生态最流行的 MongoDB ODM——掌握 Schema 设计是构建稳健应用的基础。

本课程系统讲解 mongoose Schema 的字段类型、Model vs Document、virtual、middleware 中间件。

ODM vs 驱动:为什么选 mongoose:MongoDB Node.js 生态有两个主流方案——1. 原生驱动(mongodb 包):轻量、灵活、无抽象,直接操作 BSON 文档,适合对性能和控制有极致要求的场景;2. mongoose(ODM):提供 Schema 定义、自动类型转换、校验器、中间件、populate 等高级功能,适合业务应用开发。选择 mongoose 的核心理由:1. Schema 即文档(字段类型和约束自描述);2. 自动校验拦截脏数据;3. 中间件机制实现横切关注点(密码哈希/软删除/日志);4. populate 替代 $lookup 简化关联查询。选择原生驱动的场景:1. 性能敏感(mongoose 有抽象开销);2. Schema 不固定(日志/物联网数据);3. 已有自己的校验/中间件体系。

1. 你将学到


2. mongoose 入门回顾

mongoose = MongoDB + ORM 增强:

mongoose 的核心抽象层:mongoose 提供三层抽象——1. Schema(结构定义):声明字段类型、验证规则、默认值、索引、虚拟字段和中间件,相当于 SQL 的 DDL + 约束;2. Model(集合操作):由 Schema 编译生成,提供 find/create/update/delete 等类方法,相当于 SQL 的 CRUD 接口;3. Document(文档实例):Model 创建的实例对象,拥有 save/validate/remove 等实例方法和虚拟字段,相当于 ORM 的一行记录。三层抽象让开发者用面向对象的方式操作文档数据库。

mongoose vs 原生驱动的选择:何时用 mongoose、何时用原生 MongoDB 驱动?mongoose 适合——1. 业务逻辑复杂(需要验证、中间件、虚拟字段);2. 团队协作(Schema 即文档,类型安全减少 bug);3. 数据结构稳定(Schema 变更可控)。原生驱动适合——1. 追求极致性能(mongoose 的 Document 包装有 10-20% 性能开销);2. 数据结构高度动态(Schema 反而限制灵活性);3. 简单的读写操作(CRUD 无验证,直接操作 BSON)。大多数 Node.js 项目用 mongoose 是正确选择——开发效率比微小的性能差异更重要。

JAVASCRIPT
const mongoose = require('mongoose');

// 连接
await mongoose.connect('mongodb://localhost:27017/shopdb');

// 定义 Schema
const UserSchema = new mongoose.Schema({...});

// 创建 Model
const User = mongoose.model('User', UserSchema);

// 使用 Model CRUD
const user = await User.create({...});

100%
graph TB
    A[mongoose Schema] --> B[SchemaType<br/>字段类型]
    A --> C[Model<br/>构造函数]
    A --> D[Document<br/>实例]
    A --> E[virtual<br/>虚拟字段]
    A --> F[middleware<br/>中间件]

    B --> B1[String/Number/Date]
    B --> B2[ObjectId/Decimal128]
    B --> B3[Mixed/Map/Array]

    C --> C1[find/create]
    D --> D1[save/validate]
    F --> F1[pre/post hooks]

    style C fill:#d4edda
    style D fill:#cce5ff

3. Schema 字段类型

概念说明:Schema 是 mongoose 对 MongoDB 文档结构的定义层。它声明每个字段的类型、验证规则、默认值和索引策略。虽然 MongoDB 本身是 schema-less 的,但 mongoose 在应用层强制类型和验证,为 Node.js 应用提供类似传统 ORM 的数据安全保障。

工作原理:mongoose Schema 在应用内存中维护字段元数据(SchemaType 对象)。当创建或更新 Document 时,mongoose 按字段逐一执行类型转换和验证,不符合规则的值会在 save() 之前抛出 ValidationError。Schema 不影响 MongoDB 存储——旧文档缺少新字段时,mongoose 返回 undefined(可用 default 填充)。

Schema 的隐式行为:Schema 有几个容易被忽视的隐式行为——1. 每个文档自动包含 _id: ObjectId(即使 Schema 没声明);2. _id 默认不参与 toJSON 输出(除非设置 toJSON: {virtuals: true});3. 类型转换是隐式的——传入 '123' 给 Number 字段会自动转为 123(严格模式 strict: true 下),传入 null 给 required: true 字段不通过验证(null ≠ undefined,required 只检查 undefined);4. 嵌套对象的默认值需要用函数返回(default: () => ({})),否则所有文档共享同一个引用(经典的 JavaScript 陷阱)。

100%
graph LR
    A[Schema 定义] --> B[SchemaType<br/>字段元数据]
    B --> C[类型转换<br/>String/Number/Date...]
    B --> D[验证规则<br/>required/min/max/enum]
    B --> E[默认值<br/>default/immutable]
    B --> F[索引策略<br/>index/unique]
    
    C --> G[Document.save]
    D --> G
    E --> G
    G --> H{验证通过?}
    H -->|Yes| I[MongoDB insertOne]
    H -->|No| J[ValidationError]
    
    style I fill:#d4edda
    style J fill:#f8d7da
类型分类 mongoose 类型 典型场景
基本类型 String/Number/Boolean/Date 名称、价格、开关、时间戳
二进制 Buffer 图片缩略图、文件内容
引用 ObjectId + ref 外键关联(如 categoryId → Category)
精确数值 Decimal128 货币金额(避免浮点误差)
嵌套 Schema 嵌套 地址、规格等固定结构
数组 [Type] 标签列表、图片集合
动态 Mixed/Map 不确定结构的元数据、多语言翻译

(1) 12+ 种 SchemaType

SchemaType 选择决策:选择正确的 SchemaType 是数据建模的第一步——错误的选择会导致数据质量问题和性能隐患。核心原则:1. 货币金额必须用 Decimal128 而非 Number(0.1+0.2≠0.3 的浮点陷阱);2. 外键关联用 ObjectId + ref 而非 String(mongoose populate 依赖 ObjectId);3. 不确定结构的字段用 Mixed 而非 Object(Mixed 允许任意值,Object 可能触发意外验证);4. 大量键值对用 Map 而非 Object(Map 的 key 可以是任意类型且支持 forEach/map)。

类型 mongoose 定义 BSON 类型 示例
String String String String
Number Number Double Number
Boolean Boolean Boolean Boolean
Date Date Date Date
Buffer Buffer Binary Buffer
ObjectId mongoose.Schema.Types.ObjectId ObjectId ObjectId
Decimal128 mongoose.Schema.Types.Decimal128 Decimal128 Decimal128
Map Map Object Map
Schema new mongoose.Schema({...}) Object embedded
Array [Type] Array [String]
Mixed mongoose.Schema.Types.Mixed Object Mixed

设计原则:Schema 字段定义遵循"约束即文档"理念——每个字段的选项(required、min、max、enum、match)不仅是运行时校验规则,更是数据契约的显式声明。良好的字段定义让 Schema 自身成为可执行的文档规范,团队成员无需翻阅 Wiki 即可理解每个字段的约束。

架构决策:字段选项的选择需要在严格性和灵活性之间取平衡。过严的约束(如过多 required 字段)会降低系统演进能力——新增字段默认 optional,稳定后再收紧。过松的约束则积累技术债。建议策略:核心标识字段(email、sku)严格约束,辅助字段(nickname、avatar)宽松处理,业务状态字段(role、status)用 enum 限定。

(2) 字段定义选项

JAVASCRIPT
const UserSchema = new mongoose.Schema({
  email: {
    type: String,
    required: [true, 'Email is required'],
    unique: true,
    lowercase: true,
    trim: true,
    match: [/^\S+@\S+\.\S+$/, 'Invalid email'],
    minlength: 5,
    maxlength: 100,
    index: true
  },
  age: {
    type: Number,
    required: true,
    min: [0, 'Age cannot be negative'],
    max: 150,
    default: 18
  },
  role: {
    type: String,
    enum: {
      values: ['customer', 'admin', 'moderator'],
      message: 'Invalid role: {VALUE}'
    },
    default: 'customer'
  },
  isActive: {
    type: Boolean,
    default: true
  },
  createdAt: {
    type: Date,
    default: Date.now,
    immutable: true  // 创建后不可修改
  }
});

最佳实践:生产环境 Schema 类型选择指南——1. 货币金额务必用 Decimal128 而非 Number,浮点误差在财务计算中不可接受;2. 引用关系用 ObjectId + ref 而非嵌入完整文档,避免数据冗余和更新不一致;3. 不确定结构的元数据用 Mixed/Map,但需承担无验证风险;4. 日期字段始终用 Date 类型配合 timestamps: true,避免字符串日期的时区混乱。

Schema 选项的生产配置:mongoose Schema 的选项对象控制全局行为——1. timestamps: true:自动管理 createdAt/updatedAt, createdAt 设 immutable: true 防止误改;2. toJSON: {virtuals: true}:JSON 序列化包含虚拟字段(默认不包含);3. toObject: {virtuals: true}:toObject() 也包含虚拟字段;4. minimize: false:不压缩空对象(默认 mongoose 会移除空值字段,可能导致前端期望字段缺失);5. strict: true:拒绝未在 Schema 中定义的字段(默认开启,生产环境必须保持开启)。这些选项应在项目初期就确定,后期修改可能影响现有数据和行为。

Embedding vs Referencing 权衡:这是 MongoDB Schema 设计最核心的决策。嵌入式将关联数据存在同一文档中,查询一次获取所有数据,但面临文档膨胀(16MB 限制)和更新复杂性问题。引用式通过 ObjectId 关联,数据独立性强、无大小限制,但需额外 populate/$lookup 查询。决策依据:1. 数据是否总是一起读取?是→嵌入;2. 关联数据是否无限增长?是→引用;3. 关联数据是否需要独立更新?是→引用。

维度 Embedding 嵌入 Referencing 引用
查询性能 高(单次读取) 低(需 populate)
数据一致性 弱(冗余更新) 强(单点更新)
文档大小 ⚠️ 可能超 16MB ✅ 每文档独立
适用场景 1:N 少量且固定 1:N 大量或增长

▶ 示例 1:综合 Schema 类型实战

JAVASCRIPT
const ProductSchema = new mongoose.Schema({
  // 基本类型
  sku: { type: String, required: true, unique: true },
  title: { type: String, required: true },
  price: { type: mongoose.Schema.Types.Decimal128, required: true },
  stock: { type: Number, default: 0, min: 0 },
  isActive: { type: Boolean, default: true },

  // 日期
  releaseDate: { type: Date, required: true },
  expiryDate: { type: Date },

  // 二进制
  thumbnail: { type: Buffer },

  // 引用(外键)
  categoryId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Category',
    required: true
  },

  // 嵌套文档
  specs: {
    screen: String,
    battery: String,
    weight: Number
  },

  // 数组
  tags: [String],
  images: [{
    url: String,
    alt: String
  }],

  // Map(动态键值对)
  translations: {
    type: Map,
    of: String
  },

  // Mixed(任意类型)
  metadata: mongoose.Schema.Types.Mixed
}, { timestamps: true });

输出:

TEXT 📖 仅展示
// mongoose 操作成功执行
// 数据库查询/更新结果

4. Model 与 Document

概念说明:Model 是 mongoose 对 MongoDB 集合的抽象——它是一个构造函数(class),提供 findcreateupdateOne 等静态方法。Document 是 Model 的实例,代表数据库中的一条记录,提供 savevalidateremove 等实例方法。理解 Model 与 Document 的区别是正确使用 mongoose 的基础。

工作原理mongoose.model('User', schema) 做了两件事:(1) 将 Schema 编译为 Model 构造函数;(2) 注册到 mongoose 连接,映射到 users 集合(自动复数化)。new User({...}) 创建一个 Document 实例,此时数据只在内存中,调用 save() 才写入 MongoDB。User.create({...}) 等价于 new User() + save()

100%
sequenceDiagram
    participant App as 应用代码
    participant Model as User Model
    participant Doc as User Document
    participant DB as MongoDB

    App->>Model: User.create({email, age})
    Model->>Doc: new User(data)
    Doc->>Doc: validate()
    Doc->>DB: insertOne()
    DB-->>Doc: _id, createdAt
    Doc-->>App: 返回 Document

    App->>Model: User.find({role: 'admin'})
    Model->>DB: find().toArray()
    DB-->>Model: 原始文档数组
    Model->>Doc: hydrate(docs)
    Doc-->>App: Document 数组
    
    style Model fill:#d4edda
    style Doc fill:#cce5ff

(1) 核心区别

维度 Model Document
本质 构造函数(class) Model 实例
创建 mongoose.model('User', schema) new User({...})User.create()
数量 每个集合 1 个 每个文档 1 个
方法 静态方法(find、create) 实例方法(save、validate)

类型系统设计原则:mongoose 的类型系统在应用层提供了 MongoDB 原生不具备的类型安全。SchemaType 对象在 Document 创建时执行类型转换(如字符串 "42" 自动转 Number 42),转换失败则抛出 CastError。这种隐式转换虽方便,但也可能掩盖数据问题——生产环境建议在 Schema 中设置严格模式 strict: true(默认开启),拒绝未定义的字段。

隐式类型转换的风险与防范:mongoose 的隐式类型转换是双刃剑——1. 便利性:前端传 {age: "25"} 自动转为 Number 25,开发者无需手动转换;2. 风险:"abc" 转为 Number 时变成 NaN(CastError),但如果 Schema 定义是 String 而 MongoDB 存的是 Number,查询时可能"找不到"(类型不匹配);3. 防范策略:在 Schema 中明确类型(不为 String 存 Number)、在应用层先验证类型(joi 验证在 mongoose 转换之前)、在路由层做输入清洗(删除多余字段)。最危险的隐式转换:ObjectId 字段接收非 24 位十六进制字符串时抛 CastError(如 req.params.id 传入 "abc"),应在路由层验证 ObjectId 格式。

(2) Document 实例方法

JAVASCRIPT
// === 创建 Document ===
const user = new User({ email: 'alice@example.com' });

// === Document 属性 ===
user.email;            // 'alice@example.com'
user._id;              // ObjectId
user.createdAt;        // Date
user.isNew;            // true(未保存)

// === Document 实例方法 ===
await user.save();                  // 保存
await user.validate();              // 验证(不保存)
user.toJSON();                      // 转 JSON
user.toObject();                    // 转普通对象
user.remove();                      // 删除(已废弃,用 deleteOne)
await user.deleteOne();             // 删除(推荐)
await user.populate('orders');      // 关联填充

▶ 示例 2:Document 操作实战

Document 生命周期管理:Document 从创建到销毁经历四个阶段——1. new User(data) 创建内存实例(isNew: true,未写入数据库);2. await user.save() 持久化到 MongoDB(触发 pre save 中间件和验证);3. user.property = newValue 修改内存属性(dirty tracking 标记修改字段);4. await user.deleteOne() 删除文档。关键区别:new + save 是两步操作(可在 save 前修改数据),User.create() 是一步操作(直接写入)。

JAVASCRIPT
// === 创建并保存 ===
const user = new User({
  email: 'alice@example.com',
  username: 'alice',
  passwordHash: '...'
});
await user.save();

// === 修改后保存 ===
user.lastLoginAt = new Date();
user.loginCount += 1;
await user.save();

// === 转换为对象 ===
const userObj = user.toObject();
delete userObj.passwordHash;

// === populate 关联 ===
const user = await User.findById(userId).populate({
  path: 'orders',
  options: { sort: { createdAt: -1 } }
});

输出:

TEXT 📖 仅展示
// mongoose 操作成功执行
// 数据库查询/更新结果

5. virtual 虚拟字段

概念说明:virtual 是 mongoose 提供的"计算字段"机制——它在 Schema 上定义 getter/setter,但不会持久化到 MongoDB。virtual 字段在 Document 被 toJSON()toObject() 转换时计算生成,非常适合派生属性(如 fullName = firstName + lastName)和关联统计(如 orderCount)。

工作原理:virtual getter 是一个函数,每次访问 doc.fullName 时执行计算。virtual setter 接收一个值,反向拆分到多个字段。关联型 virtual(ref + localField + foreignField)本质是 populate() 的快捷声明,查询时仍需调用 populate('orders') 触发关联填充。

100%
graph TB
    A[virtual 字段] --> B[计算型<br/>fullName = firstName + lastName<br/>discountedPrice = price * 1-discount]
    A --> C[关联型<br/>orders: ref Order<br/>orderCount: count true]
    
    B --> D[不持久化<br/>仅内存计算]
    C --> E[需 populate<br/>触发关联查询]
    
    D --> F[toJSON时输出<br/>需设置 virtuals true]
    E --> F
    
    style D fill:#d4edda
    style E fill:#cce5ff
virtual 类型 定义方式 是否持久化 是否需要 populate
计算型 getter schema.virtual('x').get(fn)
计算型 setter schema.virtual('x').set(fn)
关联型(文档) schema.virtual('x', {ref, localField, foreignField})
关联型(计数) 同上 + count: true
JAVASCRIPT
// === 定义 virtual ===
UserSchema.virtual('fullName').get(function() {
  return `${this.firstName} ${this.lastName}`;
});

UserSchema.virtual('isAdult').get(function() {
  return this.age >= 18;
});

// === virtual setter(反向设置)===
UserSchema.virtual('fullName').set(function(name) {
  const parts = name.split(' ');
  this.firstName = parts[0];
  this.lastName = parts[1];
});

// === 启用 virtual ===
UserSchema.set('toJSON', { virtuals: true });
UserSchema.set('toObject', { virtuals: true });

▶ 示例 3:virtual 实战

virtual 的性能特征:virtual getter 在每次访问时计算(非缓存)——如果 virtual 依赖的字段被修改,下次访问自动返回新值。这意味着:1. 在列表查询中,对每个 Document 调用 virtual 都会执行一次计算函数;2. virtual 不能用于 $match 过滤(MongoDB 不知道 virtual 字段的存在);3. virtual 不能用于排序(同前);4. toJSON 中启用 virtuals: true 后,JSON 序列化时计算所有 virtual 并包含在输出中。

virtual vs 计算字段的选型:何时用 virtual、何时在 Schema 中存储计算字段——1. virtual 适用:计算结果依赖当前文档字段(如 fullName = firstName + lastName)、不需要用于查询/排序/聚合、计算代价低(简单字符串拼接/数学运算);2. 存储字段适用:需要用于查询/排序(如 discountedPrice 需要按折扣价排序)、计算代价高(如跨集合聚合)、需要持久化(如 commentCount 冗余计数)。选型原则——"如果需要在 MongoDB 查询中使用,就必须存储;如果只在应用层展示,用 virtual 更干净"。

JAVASCRIPT
// === 计算字段 ===
ProductSchema.virtual('discountedPrice').get(function() {
  if (!this.discount) return this.price;
  return this.price * (1 - this.discount);
});

// === 关联字段(不持久化)===
UserSchema.virtual('orders', {
  ref: 'Order',
  localField: '_id',
  foreignField: 'userId'
});

// 使用:
const user = await User.findById(userId).populate('orders');
console.log(user.orders);  // 关联的订单数组

// === 反向关联 ===
UserSchema.virtual('orderCount', {
  ref: 'Order',
  localField: '_id',
  foreignField: 'userId',
  count: true  // 只统计数量,不返回文档
});

const user = await User.findById(userId).populate('orderCount');
console.log(user.orderCount);  // 25

输出:

TEXT 📖 仅展示
// 执行成功

6. middleware 中间件

概念说明:mongoose middleware(中间件)是在指定数据库操作前后自动执行的钩子函数。pre 中间件在操作前运行(如密码哈希、数据清理),post 中间件在操作后运行(如审计日志、通知推送)。中间件是 mongoose 最强大的扩展机制,让业务逻辑与数据操作解耦。

工作原理:mongoose 中间件采用"洋葱模型"——多个 pre 中间件按注册顺序依次执行,然后执行实际操作,最后 post 中间件按注册顺序执行。每个 pre 中间件必须调用 next() 或返回 Promise,否则操作挂起。post 中间件接收操作结果作为参数,不可修改操作行为。

100%
sequenceDiagram
    participant App as 应用代码
    participant Pre1 as pre save #1<br/>密码哈希
    participant Pre2 as pre save #2<br/>邮箱小写
    participant DB as MongoDB
    participant Post1 as post save #1<br/>审计日志
    participant Post2 as post save #2<br/>欢迎邮件

    App->>Pre1: doc.save()
    Pre1->>Pre2: next()
    Pre2->>DB: insertOne()
    DB-->>Post1: 成功
    Post1->>Post2: next(doc)
    Post2-->>App: 返回 doc

(1) 中间件类型

中间件执行链设计:mongoose 中间件采用洋葱模型——请求从外层穿入内层,响应从内层穿出外层。pre 钩子按注册顺序依次执行,每个必须调用 next() 传递控制权;实际操作执行后,post 钩子按注册顺序执行。这种设计让关注点天然分离:密码哈希、数据清理、审计日志各占一个中间件,互不耦合。

| 类型 | 触发时机 | 用途 | | pre('save') | 保存前 | 密码哈希、时间戳 | | post('save') | 保存后 | 日志、通知 | | pre('validate') | 验证前 | 数据清理 | | pre('find') | 查询前 | 过滤条件 | | pre('remove') | 删除前 | 清理关联数据 |

(2) pre save 中间件

最佳实践:pre save 中间件最常见的三个用途——密码哈希(isModified 检测避免重复哈希)、数据标准化(邮箱转小写、字符串 trim)、时间戳维护。关键点是 this 指向当前 Document 实例,因此 pre save 不能在 Model.updateOne() 等批量操作中触发——批量操作需用 Query 中间件 pre('updateOne') 替代。

JAVASCRIPT
// === 密码哈希(经典场景)===
UserSchema.pre('save', async function(next) {
  if (!this.isModified('passwordHash')) return next();

  // 密码已修改,重新哈希
  this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
  next();
});

// === 时间戳 ===
UserSchema.pre('save', function(next) {
  this.updatedAt = new Date();
  next();
});

(3) pre find 中间件

Query 中间件 vs Document 中间件:pre find 是 Query 中间件(this 指向 Query 对象而非 Document),适用于全局查询行为控制——自动过滤已删除文档、默认 populate 关联、默认排序。正则形式 pre(/^find/) 同时匹配 find、findOne、findById 等所有查询操作,确保行为一致。注意:Query 中间件无法访问文档数据(因为查询尚未执行),只能修改查询条件。

JAVASCRIPT
// === 自动过滤已删除文档 ===
UserSchema.pre(/^find/, function(next) {
  this.find({ isDeleted: { $ne: true } });
  next();
});

// === 自动 populate 关联 ===
UserSchema.pre('find', function(next) {
  this.populate('categoryId');
  next();
});

// === 默认排序 ===
UserSchema.pre('find', function(next) {
  this.sort({ createdAt: -1 });
  next();
});

(4) post save 中间件

Post 中间件的副作用设计:post 中间件在操作完成后执行,不可修改文档数据(已写入数据库),适合触发副作用——审计日志、通知推送、缓存更新。关键特性:1. post save 接收 doc 参数(已保存的文档);2. this.wasNew 判断是新建还是更新;3. this.modifiedPaths() 获取被修改的字段列表;4. 错误处理中间件是 4 参数版本 (err, doc, next),专门捕获操作异常。

JAVASCRIPT
// === 保存后发送欢迎邮件 ===
UserSchema.post('save', function(doc, next) {
  if (this.wasNew) {
    sendWelcomeEmail(doc.email);
  }
  next();
});

// === 保存后记录审计日志 ===
UserSchema.post('save', function(doc) {
  AuditLog.create({
    action: 'user.updated',
    userId: doc._id,
    changes: this.modifiedPaths()
  });
});

▶ 示例 4:综合中间件实战

JAVASCRIPT
// === 用户 Schema 中间件 ===
UserSchema.pre('save', async function(next) {
  // 1. 密码哈希
  if (this.isModified('passwordHash')) {
    this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
  }

  // 2. 邮箱小写
  if (this.isModified('email')) {
    this.email = this.email.toLowerCase();
  }

  next();
});

UserSchema.pre(/^find/, function(next) {
  // 默认不返回已删除用户
  this.find({ isDeleted: { $ne: true } });
  next();
});

// === 错误处理中间件 ===
UserSchema.post('save', function(error, doc, next) {
  if (error.name === 'MongoServerError' && error.code === 11000) {
    next(new Error('Email already exists'));
  } else {
    next(error);
  }
});

输出:

TEXT 📖 仅展示
// mongoose 操作成功执行
// 数据库查询/更新结果

7. 实例方法与静态方法

概念说明:mongoose 允许在 Schema 上定义两种自定义方法:实例方法(methods)挂载在每个 Document 上,操作单个文档(如 user.comparePassword());静态方法(statics)挂载在 Model 上,操作整个集合(如 User.findByEmail())。这两种方法让业务逻辑内聚到数据模型中,遵循"胖模型"设计原则。

工作原理:实例方法通过 Schema.methods 定义,mongoose 在 new Model() 时将方法绑定到 Document 原型链上,方法内 this 指向当前 Document。静态方法通过 Schema.statics 定义,挂载到 Model 构造函数上,方法内 this 指向 Model 本身,可直接调用 this.find() 等。

100%
graph TB
    A[Schema 方法] --> B[实例方法<br/>Schema.methods]
    A --> C[静态方法<br/>Schema.statics]
    
    B --> D["user.comparePassword(pwd)<br/>this = 当前 Document"]
    B --> E["user.generateToken()<br/>this = 当前 Document"]
    B --> F["user.softDelete()<br/>this = 当前 Document"]
    
    C --> G["User.findByEmail(email)<br/>this = User Model"]
    C --> H["User.findActive()<br/>this = User Model"]
    C --> I["User.getStatistics()<br/>this = User Model"]
    
    style B fill:#cce5ff
    style C fill:#d4edda
对比维度 实例方法 (methods) 静态方法 (statics)
定义位置 Schema.methods Schema.statics
挂载对象 Document 原型 Model 构造函数
this 指向 当前 Document Model 本身
调用方式 doc.method() Model.method()
典型用途 密码比对、软删除 按条件查找、聚合统计

方法设计的职责边界:实例方法和静态方法的划分遵循单一职责原则——1. 实例方法:操作单个文档自身的数据(comparePassword 比对密码、softDelete 标记删除、toJSON 脱敏输出),不需要查询数据库(或只查询自身关联数据);2. 静态方法:操作集合级别的数据(findByEmail 跨文档查询、getStatistics 聚合统计、bulkImport 批量导入),需要 Model 的查询能力。混淆边界会导致设计混乱——把 findByEmail 放在实例方法中(需要先有实例才能查找,逻辑矛盾),把 comparePassword 放在静态方法中(需要传入文档和密码,多此一举)。

(1) 实例方法

JAVASCRIPT
// === 定义实例方法 ===
UserSchema.methods.comparePassword = async function(candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.passwordHash);
};

UserSchema.methods.generateAuthToken = function() {
  return jwt.sign({ id: this._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
};

// === 使用 ===
const user = await User.findOne({ email: 'alice@example.com' });
const isValid = await user.comparePassword('password123');
const token = user.generateAuthToken();

(2) 静态方法

JAVASCRIPT
// === 定义静态方法 ===
UserSchema.statics.findByEmail = function(email) {
  return this.findOne({ email: email.toLowerCase() });
};

UserSchema.statics.findActive = function() {
  return this.find({ isActive: true });
};

// === 使用 ===
const user = await User.findByEmail('ALICE@example.com');
const activeUsers = await User.findActive();

▶ 示例 5:综合方法实战

胖模型 vs 瘦模型:mongoose 推崇"胖模型"设计——业务逻辑内聚到 Model/Document 方法中,Controller 只需调用 user.comparePassword() 而非自己写 bcrypt.compare。胖模型的优势:1. 逻辑复用(多个 Controller 共享同一方法);2. 封装性(外部不知道密码如何验证);3. 可测试性(Model 方法可独立单元测试)。瘦模型的 Controller 充满重复代码,改一个验证规则要改 N 个 Controller。

JAVASCRIPT
// === 完整 User 模型 ===
const UserSchema = new mongoose.Schema({...});

// 实例方法
UserSchema.methods = {
  comparePassword: async function(candidate) {
    return await bcrypt.compare(candidate, this.passwordHash);
  },
  softDelete: async function() {
    this.isDeleted = true;
    this.deletedAt = new Date();
    return await this.save();
  }
};

// 静态方法
UserSchema.statics = {
  findByEmail: function(email) {
    return this.findOne({ email: email.toLowerCase() });
  },
  getStatistics: async function() {
    return await this.aggregate([
      { $group: { _id: '$role', count: { $sum: 1 } } }
    ]);
  }
};

输出:

TEXT 📖 仅展示
// mongoose 操作成功执行
// 数据库查询/更新结果

❓ 常见问题

Q mongoose Schema 改了,数据库要迁移吗?
A 不需要。mongoose Schema 是应用层,MongoDB 是 schema-less 的。新字段自动添加,旧文档可能缺字段。
Q virtual 字段会保存到数据库吗?
A 不会。virtual 是计算字段,不持久化。但需要在 toJSON 中启用 virtuals: true 才能在 API 返回。
Q pre save 中间件可以异步吗?
A 可以。用 async function 或返回 Promise。必须调用 next() 或返回 Promise,否则挂起。
Q middleware 报错怎么处理?
A 在 pre 中 next(error) 抛出;在 post 中 next(error) 抛出错误会被传递到 Mongoose 错误处理。
Q Schema 继承怎么实现?
Adiscriminators(鉴别器):const AdminUser = User.discriminator('admin', AdminSchema),所有 discriminator 共享同一集合。

📖 小节


📝 作业

  1. 基础题(⭐):定义 User Schema(含 email/username/age/role),创建 User Model。
  2. 基础题(⭐):用 virtual 定义 fullName 字段(firstName + lastName)。
  3. 进阶题(⭐⭐):用 pre save 中间件实现密码哈希(isModified 检测)。
  4. 进阶题(⭐⭐):用 pre find 中间件自动过滤已删除用户。
  5. 挑战题(⭐⭐⭐):实现完整的 User 模型(含密码哈希、虚拟字段、实例方法、静态方法),支持注册、登录、软删除、统计功能。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏