Nuxt3中ref值更新后DOM绑定未更新的问题求助
我在组件的:class绑定上遇到了问题,已将问题提取到最小可复现仓库。复现步骤:在视口宽度大于767像素的浏览器中加载页面。
我有一个名为isMobileDevice的composable,通过window.matchMedia实现,返回一个ref。直接在组件中把这个ref作为文本值使用时,它是响应式的(但会破坏hydration,这是附带问题)。但当把它作为:class的一部分使用时,即便值已经变为false,关联的类也从未从DOM元素中消失。通过Vue DevTools查看该属性时(我尝试在setup内定义单独变量观察更新),值显示正确为false。
问题
- 主问题:当
ref更新时,是否需要额外操作才能让DOM元素同步更新? - 附带问题:在Nuxt3中,文本值的更改(比如
<p>foo</p>变为<p>bar</p>)会破坏hydration,而Nuxt2中不会。有没有办法避免这种情况?
补充代码
./app.vue
<template> <div> <h1>Testing Nuxt reactivity</h1> <p>We are testing the boolean value <span class="mono">isMobileDevice</span>, which is a composable. </p> <hr> <p :class="{'is-blue': isMobileDevice }"> This text should be blue if it's a narrow device. (testing binding to a class) </p> <hr> <p> The value below is the <span class="mono">isMobileDevice</span> boolean getter property, and will break hydration if loaded on a wide page:<br> {{ isMobileDevice }} </p> <hr> <p> The value below is <span class="mono">isMobileDevice</span> property wrapped in a <span class="mono">ClientOnly</span> tag, and updates correctly<br > <ClientOnly> <template #placeholder> </template> {{ isMobileDevice }} </ClientOnly> </p> </div> </template> <script setup> const foo = isMobileDevice; watchEffect(() => { console.warn("Foo has updated", foo.value); }) </script> <style scoped> .is-blue { color: blue !important; } .mono { font-family: monospace; color: orange; } </style>
./composables/screenSizes.ts
import { ref, watchEffect } from "vue" const useMedia = (query: string, defaultValue = false) => { const matches = ref(defaultValue) watchEffect((onInvalidate) => { if (process.server) { return } const media = window.matchMedia(query) if (media.matches !== matches.value) { matches.value = media.matches } const onChange = () => { matches.value = media.matches } media.addEventListener("change", onChange) onInvalidate(() => { media.removeEventListener("change", onChange) }) }) return matches } export const isMobileDevicComp = computed(() => { return useMedia("(max-width: 768px)", true) }) export const isMobileDevice = useMedia("(max-width: 767px)", true) export const isMediumPortraitDevice = useMedia("(max-width: 959px)", false) export const isMediumDevice = useMedia("(max-width: 1024px)", false) export const isLargeDevice = useMedia("(max-width: 1200px)", false)
主问题修复:ref更新后DOM未同步的原因
问题出在composables/screenSizes.ts中:直接在模块顶层调用useMedia函数,导致该函数仅执行一次。服务器端初始化时process.server为true,watchEffect直接返回,客户端挂载后不会重新运行逻辑,最终ref无法响应视口变化。
正确做法是将媒体查询变量封装成可复用的composable函数,而非直接导出模块级的ref:
修改screenSizes.ts:
import { ref, watchEffect } from "vue" const useMedia = (query: string, defaultValue = false) => { const matches = ref(defaultValue) watchEffect((onInvalidate) => { if (process.server) { return } const media = window.matchMedia(query) matches.value = media.matches const onChange = () => { matches.value = media.matches } media.addEventListener("change", onChange) onInvalidate(() => { media.removeEventListener("change", onChange) }) }) return matches } // 改为导出函数,在组件中调用获取ref export const useScreenSizes = () => { return { isMobileDevice: useMedia("(max-width: 767px)", true), isMediumPortraitDevice: useMedia("(max-width: 959px)", false), isMediumDevice: useMedia("(max-width: 1024px)", false), isLargeDevice: useMedia("(max-width: 1200px)", false) } }
在app.vue中使用:
<script setup> const { isMobileDevice } = useScreenSizes() watchEffect(() => { console.warn("Foo has updated", isMobileDevice.value); }) </script>
这样每个组件调用useScreenSizes时都会创建新的ref并运行watchEffect,客户端挂载后会正确监听媒体查询变化,:class绑定也会随ref值更新同步DOM。
附带问题:避免Nuxt3 hydration破坏
Nuxt3的hydration校验比Nuxt2更严格,当服务器渲染内容与客户端首次渲染内容不一致时,就会触发hydration错误。有两种解决方式:
- 用
ClientOnly组件包裹客户端特有内容:将依赖window等客户端API的内容放在<ClientOnly>中,服务器不渲染这部分内容,由客户端单独渲染,避免内容不匹配。 - 服务器端提供一致默认值:通过Nuxt的
useRequestHeaders获取用户user-agent,在服务器端判断设备类型,给useMedia设置与客户端初始值一致的服务器端默认值,确保两端渲染内容匹配。
示例(修改useMedia):
import { ref, watchEffect } from "vue" import { useRequestHeaders } from "#app" const useMedia = (query: string, serverDefaultValue = false) => { // 服务器端根据user-agent判断设备 let defaultValue = serverDefaultValue if (process.server) { const headers = useRequestHeaders() const userAgent = headers["user-agent"] || "" // 简单判断移动设备 defaultValue = /mobile|android|iphone|ipad/.test(userAgent.toLowerCase()) } const matches = ref(defaultValue) watchEffect((onInvalidate) => { if (process.server) { return } const media = window.matchMedia(query) matches.value = media.matches const onChange = () => { matches.value = media.matches } media.addEventListener("change", onChange) onInvalidate(() => { media.removeEventListener("change", onChange) }) }) return matches }
服务器端会根据用户代理返回正确初始值,客户端渲染时初始值与服务器一致,避免hydration错误,同时客户端仍能响应视口变化。
内容的提问来源于stack exchange,提问作者Ash Menon

