Mongoose实战指南:从基础用法到高级查询优化技巧
又踩坑了,Mongoose 的默认值居然没生效?
上周改一个老项目,加了个新字段 isPublished,类型是 Boolean,默认值设成 false。结果测试的时候发现,有些文档里这个字段压根没有!查数据库一看,空的,不是 false,是根本不存在这个 key。我当时就懵了——Mongoose 的 default 不是应该自动补上吗?怎么还能漏掉?
折腾了半天,试了一堆方法,最后才发现问题出在一个我完全没想到的地方。
一开始我以为是 schema 写错了
先贴一下最开始的 schema(简化版):
const postSchema = new mongoose.Schema({
title: String,
content: String,
isPublished: {
type: Boolean,
default: false
}
});
看起来没问题吧?官方文档也是这么写的。我新建文档的时候也没传 isPublished,按理说应该自动填 false。但实际存进 MongoDB 的文档长这样:
{
"_id": "65f1a...",
"title": "测试文章",
"content": "内容..."
}
确实没有 isPublished 字段。我第一反应是:是不是 default 只在 new Model() 的时候生效,而用 Model.create() 或者 insertMany 就不生效?于是我把代码改成显式 new 一个实例再 save,结果还是一样。
后来我又怀疑是不是 Mongoose 版本问题(我们用的是 6.x),去 GitHub issues 搜了一圈,发现不少人遇到类似问题,但原因五花八门。有人说是 strict 模式的问题,有人说是 setDefaultsOnInsert 没开…… 我一个个试,都没解决。
关键线索:findOneAndUpdate 的锅
直到我注意到——这个字段是在调用 findOneAndUpdate 的时候才被创建的!而我的业务逻辑里,很多地方用的是 upsert 操作:
Post.findOneAndUpdate(
{ slug: 'test-post' },
{ title: '更新标题', content: '新内容' },
{ upsert: true, new: true }
)
问题就出在这儿!Mongoose 的 default 值在普通 save 时会生效,但在 findOneAndUpdate 这类原子操作中默认不会触发。这是个经典坑点,但文档写得特别隐晦。
官方其实提过一句:default 值只在 document 实例化时应用,而 findOneAndUpdate 是直接发命令给 MongoDB,绕过了 Mongoose 的中间件和默认值处理流程。所以即使你设置了 default,在 upsert 的时候也不会自动加上。
解决方案:setDefaultsOnInsert
好在 Mongoose 提供了一个选项叫 setDefaultsOnInsert,专门用来解决这个问题。你得在查询选项里显式开启它:
Post.findOneAndUpdate(
{ slug: 'test-post' },
{ title: '更新标题', content: '新内容' },
{
upsert: true,
new: true,
setDefaultsOnInsert: true // 👈 关键!
}
)
加上这行之后,upsert 时就会把 schema 里定义的 default 值自动补上。亲测有效。
不过这里有个细节要注意:setDefaultsOnInsert 只对 完全缺失 的字段生效。如果你传了 isPublished: undefined,它不会帮你转成 default 值;只有字段压根没出现在 update 对象里,才会触发 default。
更彻底的做法:配合 runValidators
既然都改到这儿了,索性把校验也加上。因为默认情况下,findOneAndUpdate 也不会跑 schema 的 validator。所以完整配置应该是:
Post.findOneAndUpdate(
{ slug: 'test-post' },
{ title: '更新标题', content: '新内容' },
{
upsert: true,
new: true,
setDefaultsOnInsert: true,
runValidators: true
}
)
这样既能保证默认值,又能确保数据符合规则。不过要注意,validator 在 upsert 时的行为可能和普通 save 有点差异,比如 required 字段如果没传,在 upsert 时可能会报错——得根据业务判断是否需要调整。
那能不能全局开启?
我一开始想:每个地方都写 setDefaultsOnInsert: true 太麻烦了,能不能全局设置?查了下,Mongoose 确实支持在 schema 级别设置默认选项:
const postSchema = new mongoose.Schema({
title: String,
content: String,
isPublished: {
type: Boolean,
default: false
}
}, {
// 全局设置 findOneAndUpdate 的默认行为
defaults: {
setDefaultsOnInsert: true,
runValidators: true
}
});
但很遗憾,这个写法不生效。Mongoose 并不支持通过 schema 配置来自动附加这些选项到查询方法上。你必须在每次调用 findOneAndUpdate、findOneAndReplace、findByIdAndUpdate 等方法时手动传入。
后来我干脆封装了一个工具函数:
function safeUpsert(Model, filter, update, options = {}) {
return Model.findOneAndUpdate(
filter,
update,
{
upsert: true,
new: true,
setDefaultsOnInsert: true,
runValidators: true,
...options
}
);
}
// 使用
safeUpsert(Post, { slug: 'test' }, { title: 'xxx' });
虽然多一层封装,但至少不用到处复制粘贴那几行配置了。
顺便提一嘴:default 函数的陷阱
还有一个小坑,跟 default 本身有关。比如你这样写:
createdAt: {
type: Date,
default: Date.now()
}
注意这里是 Date.now() 而不是 Date.now。前者会在 schema 定义时就执行一次,导致所有文档的 createdAt 都是同一个时间戳!正确写法应该是传函数引用:
createdAt: {
type: Date,
default: Date.now // 不加括号!
}
这个我早年踩过,现在看到 default 就条件反射检查有没有括号。不过这次的问题跟这个无关,只是顺带提醒下。
总结一下
这次的问题根源在于:**Mongoose 的 default 值机制不适用于原子更新操作(如 findOneAndUpdate)**,除非你显式开启 setDefaultsOnInsert。这个设计其实有道理——MongoDB 本身的 upsert 就不处理应用层逻辑,Mongoose 默认保持一致。但对开发者来说确实容易踩坑,尤其当项目里既有 save 又有 findOneAndUpdate 的时候,行为不一致很容易引发 bug。
改完之后,大部分情况都正常了。不过我发现如果 update 对象里显式传了 isPublished: null,还是会存成 null 而不是 default 值。这其实是预期行为,因为 null 被视为“已提供值”,不算缺失字段。如果业务上不允许 null,得靠 validator 或 pre middleware 来拦截。暂时没改,因为影响不大,后续再看。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。这个技巧的拓展用法还有很多,比如配合 timestamps、virtuals 等,后续会继续分享这类博客。

暂无评论