Nuxt3中<client-only>解决初始布局闪烁问题的原因探究
<client-only>能解决页面初始加载的布局闪烁问题? 问题背景
在default.vue中,我们用<client-only>组件包裹了所有内容,如果不这么做,页面初始加载时会出现布局未完全构建、部分内容短暂闪烁的现象。结合代码来看,核心原因和Nuxt的SSR(服务端渲染)机制以及代码里的浏览器环境依赖有关。
原因解析
Nuxt SSR的特性
Nuxt默认会执行服务端渲染:服务端先生成页面的HTML并返回给浏览器,浏览器拿到HTML后会先渲染这个"初始快照",同时加载客户端JS,之后再进行hydration(激活)过程,把静态HTML变成可交互的Vue应用。代码里的浏览器环境依赖导致布局不匹配
你的代码里存在依赖浏览器专属API的逻辑:
handleResize函数依赖window.innerWidth判断屏幕宽度,进而设置shouldShowDrawer和mobileLayout的值- 这些变量的初始值都是
false,而它们的正确值要等到客户端的onMounted钩子执行时才会计算更新
服务端渲染时根本没有window对象,所以服务端生成的HTML是基于变量初始false值渲染的——比如不会显示侧边栏(SideMenu)、不会显示移动端TOC(ResponsiveTableOfContents)。当浏览器加载完客户端JS后,onMounted执行,handleResize计算出正确的屏幕宽度并更新变量,Vue会立刻重新渲染DOM,补上之前缺失的侧边栏、TOC,这就导致了布局突变的闪烁。
<client-only>的作用<client-only>是Nuxt提供的组件,核心功能是:让包裹的内容完全跳过服务端渲染,只在客户端执行渲染。
这样一来,服务端返回的HTML里不会包含<client-only>包裹的内容,浏览器加载完客户端JS后,会直接根据当前浏览器的window.innerWidth计算出正确的shouldShowDrawer和mobileLayout值,渲染出符合当前屏幕尺寸的完整布局——从一开始就是正确的布局,自然不会出现先渲染服务端"错误快照"再替换的闪烁问题。
相关代码
<script setup lang="ts"> import { inject } from 'vue'; const route = useRoute(); const { baseUrl } = useRuntimeConfig().public; const { locale } = useI18n(); const shouldShowDrawer = ref(false); const mobileLayout = ref(false); const handleResize = () => { shouldShowDrawer.value = window.innerWidth >= 1023; mobileLayout.value = window.innerWidth <= 1023; }; onMounted(() => { window.addEventListener('resize', handleResize); handleResize(); console.log('mobileLayout', mobileLayout.value); }); onUnmounted(() => { window.removeEventListener('resize', handleResize); }); let redirectPath = route.path.endsWith('/') ? route.path.slice(0, -1) : route.path; if (redirectPath.startsWith('/en')) { locale.value = 'en'; } else if (redirectPath.startsWith('/ja')) { locale.value = 'ja'; } if (process.client) { switch (redirectPath) { case '': navigateTo(redirectPath + '/ja', { replace: true }); break; } } let ogLocale = ''; if (redirectPath.startsWith('/ja')) { ogLocale = 'ja_JP'; } else if (redirectPath.startsWith('/en')) { ogLocale = 'en_US'; } useSeoMeta({ ogLocale, ogUrl: baseUrl + route.path, ogType: 'website', ogSiteName: '', ogImage: baseUrl + '/images/social-image.jpg', ogImageWidth: 1200, ogImageHeight: 627, ogImageType: 'image/png', twitterCard: 'summary_large_image', twitterImage: baseUrl + '/images/social-image.jpg', twitterSite: '', }); const mobileDrawer = ref(false); watch(mobileDrawer, (newVal, oldVal) => { console.log('mobileDrawer changed from', oldVal, 'to', newVal); }); function handleMobileDrawerClicked(newValue: boolean) { mobileDrawer.value = newValue; console.log('変更'); } </script> <template> <client-only> <v-app> <div v-if="mobileDrawer" class="responsive-side-menu-wrapper"> <ResponsiveSideMenu></ResponsiveSideMenu> </div> <Header :mobileDrawer="mobileDrawer" @mobileDrawerClicked="handleMobileDrawerClicked" /> <div class="responsive-toc-wrapper" v-if="mobileLayout"> <ResponsiveTableOfContents></ResponsiveTableOfContents> </div> <v-container> <div class="d-flex flex-0-1-100"> <SideMenu v-if="shouldShowDrawer" /> <v-main class="pl-0 pr-0 flex-shrink-1" :class="{ 'v-main-border': !mobileLayout }" > <slot /> <NavigationLink /> </v-main> <TableofContents v-if="shouldShowDrawer" /> </div> </v-container> <Footer /> </v-app> </client-only> </template> <style lang="scss"> .v-main-border { border-left: 1px solid $border-color-gray; border-right: 1px solid $border-color-gray; } video { width: 100%; } // headerの背景色はresponsive-toc-wrapperのbackground-colorに設定されている header { position: sticky !important; backdrop-filter: saturate(200%) blur(20px); } @media (max-width: 1023px) { header { background: none !important; } } .responsive-side-menu-wrapper { position: fixed; z-index: 1100; height: 100%; } .responsive-toc-wrapper { position: sticky; top: 64px; z-index: 1000; background-color: $bg-color-white !important; backdrop-filter: saturate(200%) blur(20px); border-bottom: 1px solid $border-color-gray; } .v-navigation-drawer { z-index: 0 !important; } </style>
内容的提问来源于stack exchange,提问作者shinya

