返回博客

Vue Object Prop 没有 default,$emit 被悄悄截断

某天在维护一个后台管理系统时,发现一个列表页的搜索按钮点了没反应——表格纹丝不动,分页点了也不刷新,排序也失效。但是翻遍页面,没有任何报错弹窗,UI 交互也看起来正常:输入框可以输字,按钮有水波纹点击效果。

打开 DevTools,才发现控制台角落里躺着一行小小的警告,极易被忽略:

[Vue warn]: Error in v-on handler: "TypeError: Cannot set properties of undefined (setting 'query')"

现象

父组件正确绑定了基础组件的 @on-change 事件,却从未收到回调。具体表现:

  • 搜索:输入关键词点查询,表格不刷新
  • 分页:点第 2 页,列表不重载
  • 排序:点列头,顺序不变化

其他页面复用同一个基础组件,功能完全正常。唯独这一个消费页面失效,而且没有任何可见的错误提示。

根因

基础组件在 props 中声明了一个 Object 类型的 prop,但没有设置 default

// BaseTable.vue(基础组件)
props: {
  routeData: {},  // ⚠️ 没有 type,没有 default
}

失效的消费页面碰巧没有传这个 prop:

<!-- 消费页面:忘了传 :route-data -->
<base-table @on-change="getData" />

于是 this.routeDataundefined。基础组件的内部方法里有这样一段:

dispatch() {
  var vars = this.makeData()
  this.routeData.query = vars      // 💥 undefined.query = vars → TypeError
  this.$router.push(this.routeData)
  this.$emit('on-change', vars)    // ⛔ 永远跑不到这里
}

undefined.query = vars 直接抛出 TypeError,方法中止,$emit 成了死代码。父组件的 @on-change="getData" 永远收不到事件,整个功能全线瘫痪。

坑:Object/Array prop 缺少 default,内部解引用会截断整条事件链

Vue 对 prop 缺失的处理是赋值 undefined,而不是报错。TypeError 发生在 Vue 的事件处理器内部,只产生一条 [Vue warn] 警告,不会触发 app 级别的错误边界或 toast,极易被忽视。更危险的是:其他正常传递该 prop 的消费页面完全不受影响,组件"看起来没问题",让定位变得更难。

正解

修复分两步,缺一不可。

第一步:给 Object/Array prop 声明 type + default 工厂函数

props: {
  routeData: {
    type: Object,
    default: () => ({}),  // ✅ 永远不是 undefined
  },
}

注意:Vue 2 和 Vue 3 中,Object / Array 类型的 default 必须是返回值的函数,不能写 default: {}。后者会让所有组件实例共享同一个对象引用,引发另一个响应式污染 bug。

第二步:解引用加守卫,但 $emit 放在守卫外

dispatch() {
  var vars = this.makeData()
  if (this.routeData && this.routeData.name) {
    this.routeData.query = vars
    this.$router.push(this.routeData)
  }
  this.$emit('on-change', vars)   // ✅ 守卫之外,始终执行
}

守卫只包裹解引用和 router push 这两行可选逻辑,$emit 保持在守卫外部。这样:

  • 传了 routeData 的消费页:完整功能,包含 URL 同步
  • 没传 routeData 的消费页:核心搜索/分页依然正常,只丢失 URL 恢复能力

如果把 $emit 也圈进守卫里,忘传 prop 的消费页连核心功能都会静默失效,爆炸半径由基础组件作者决定,应该选择"尽量宽容"的降级策略。

正解:prop 加 default 工厂函数 + 解引用加守卫 + $emit 不进守卫

三件事同时做:default: () => ({}) 防止 undefined、if (this.prop && this.prop.key) 守卫每个解引用赋值、$emit 放在守卫外保证事件链完整。

排查速查

遇到"父组件绑了 @event,但回调从没触发"时,按这个顺序查:

  1. 基础组件 props 里每个 Object/Array prop 是否都有 default: () => (...)
  2. 方法或生命周期里是否有 this.<prop>.<key> = value 的直接赋值?
  3. 消费页模板里是否真的传了 :<prop-name>
  4. $emit 前一行加 console.log('emit fires', vars)——如果没有打印,说明方法在此之前就已经中止。

开启 ESLint 的 vue/require-default-prop + vue/require-prop-types 规则可以在 lint 阶段拦截这类问题,推荐写进项目 .eslintrc

一句话外卖

Vue 基础组件的 Object/Array prop 没有 default,任何一个消费页忘传,就会在组件内部无声截断整条 emit 链——加 default: () => ({}) 是防御性编程的基本动作。