编程 onUpdated 的四个坑:一次渲染只触发一次、父钩子晚于子钩子、改状态成环、SSR 不执行

2026-10-31 00:05:37

onUpdated 的四个坑:一次渲染只触发一次、父钩子晚于子钩子、改状态成环、SSR 不执行

onUpdated() 是 Vue 3 组合式 API 的更新后钩子:注册一个回调,在组件因为响应式状态变更而更新其 DOM 树之后调用,类型为 function onUpdated(callback: () => void, target?: ComponentInternalInstance | null): void。文档见 组合式 API:生命周期钩子,源码仓库 vuejs/core。理解这个钩子的关键判据只有一条:它对齐渲染轮次,不对齐赋值次数。下面四个现象都是这条判据的推论。

现象一:一次渲染只触发一次

文档写明:这个钩子会在组件的任意 DOM 更新后被调用,这些更新可能是由不同的状态变更导致的,因为多个状态变更可以在同一个渲染周期中批量执行(考虑到性能因素)。

一次点击改两个 ref,onUpdated 只打印一行:


import { ref, onUpdated } from 'vue'

const a = ref(0)
const b = ref(0)
let n = 0

onUpdated(() => {
n++
console.log('onUpdated', n, a.value, b.value)
})

{{ a }} / {{ b }}

日志里不会出现两行,因为两次赋值被合并进同一轮渲染。所以不能把 onUpdated 当成「某个 ref 变更的监听器」——谁的变更、变更了几次,它都不区分。如果需要在某个特定的状态更改后访问更新后的 DOM,用 nextTick() 替代:


import { ref, nextTick } from 'vue'

const count = ref(0)

async function handleClick() {
count.value++
await nextTick()
// 此刻 DOM 已同步到 count.value
console.log(document.getElementById('count').textContent)
}

{{ count }}

现象二:在 onUpdated 里改状态

前提先说清:只有被模板读取的状态发生变更,才会再触发渲染。所以「改状态」是否成环,取决于被写的那个 ref 有没有出现在模板里。

文档的 WARNING 是:不要在 updated 钩子中更改组件的状态,这可能会导致无限的更新循环!

height 被 :style 读取,写回它就等于请求下一轮渲染:


import { ref, onUpdated } from 'vue'

const height = ref(40)

onUpdated(() => {
// height 被 :style 读取,写回它就等于请求下一轮渲染
height.value = height.value + 4
})

{{ height }}

可观察结果是高度每轮 +4 一路增长,最终出现递归更新超限的警告。

反例是把值写回一个模板没读取的 ref:不会成环,因为这次写入不构成新的渲染请求,只是一次无效赋值。


import { ref, onUpdated } from 'vue'

const height = ref(40)
const lastHeight = ref(0)

onUpdated(() => {
// lastHeight 没有被模板读取,写它不会请求下一轮渲染
lastHeight.value = height.value
})

{{ height }}

现象三:父组件的更新钩子晚于子组件

文档:父组件的更新钩子将在其子组件的更新钩子之后调用。

父子在同一次更新中都重渲染时,两个钩子都会执行。示例:


import { onUpdated } from 'vue'

const props = defineProps({ msg: String })

onUpdated(() => {
console.log('child onUpdated', props.msg)
})

{{ msg }}


import { ref, onUpdated } from 'vue'
import Child from './Child.vue'

const msg = ref('a')

onUpdated(() => {
console.log('parent onUpdated', document.querySelector('#child')?.textContent)
})

+1

点击后两个钩子都会触发,日志顺序是 child onUpdated → parent onUpdated:父组件自身状态变化导致它重渲染,同时 msg 作为 props 变化导致子组件重渲染,而父的更新钩子在其子组件之后调用(按文档描述)。

如果这一轮只有子组件内部状态变化、父组件没有重渲染,父组件的 onUpdated 不会被调用——顺序保证不包含「父钩子一定会跑」这层含义。

现象四:SSR 期间不执行

文档:这个钩子在服务器端渲染期间不会被调用。服务端首屏输出过程中没有 onUpdated,把初始化测量、埋点上报这类逻辑放在这里,SSR 首屏拿不到任何数据。onMounted 在 SSR 期间同样不执行,所以首屏 HTML 里需要体现的内容只能在 setup 阶段算好,不能依赖任何更新后或挂载后的钩子去补。

什么时候该用别的

  • 需要在 DOM 更新之前读旧值、或想安全地改状态:onBeforeUpdate
  • 只关心某一次特定状态变更后的 DOM:nextTick()
  • 需要在依赖变化、组件更新之后执行副作用:watch 配 flush: 'post'
方式触发时机能否在其中改状态拿到的 DOM
onBeforeUpdate本轮 DOM 更新之前可以,文档明确是安全的更新前
onUpdated本轮 DOM 更新之后不要,可能触发更新循环更新后
watch(..., { flush: 'post' })依赖变更、组件更新之后需自行控制,同样会请求新渲染更新后
nextTick()当前渲染轮次结束之后可以更新后

onBeforeUpdate 的正确用法是「更新前读旧 DOM 尺寸」,此时读到的还是上一轮的样子:


import { ref, onBeforeUpdate } from 'vue'

const width = ref(100)
const before = ref(0)

onBeforeUpdate(() => {
// 此刻 DOM 还是上一轮的尺寸
before.value = document.getElementById('box').offsetWidth
})

{{ width }} / 上一轮 {{ before }}

注册时机

文档开头就说明:所有罗列在本页的 API 都应该在组件的 setup() 阶段被同步调用。用 `` 时写在顶层即可,不要包进 setTimeout、await 之后的回调或事件处理函数里——那时组件实例上下文已经变了,注册不会按预期生效。


import { onUpdated } from 'vue'

// 正确: 顶层,同步注册
onUpdated(() => {
console.log('updated')
})

// 错误:脱离 setup 的同步执行时机
setTimeout(() => {
onUpdated(() => {
console.log('too late')
})
}, 0)

参考

复制全文 生成海报 onUpdated Vue3 Vue 生命周期钩子

推荐文章

程序员茄子在线接单