Nuxt3 SSR模式下Firestore数据丢失及Hydration不匹配问题排查
问题描述
我正在使用Nuxt RC8结合Firestore开发,目标是实现Firestore请求的SSR渲染,在客户端hydration完成后通过Firestore的onSnapshot获取实时更新。
我编写了如下useAssets组合式函数:
import { computed, ref } from 'vue'; import { Asset, RandomAPI, RandomDatabase } from '@random/api'; /** * Asset basic composable * @param dbClient Database client * @param options Extra options, like live data binding */ export function useAssets(dbClient: RandomDatabase) { const assets = ref([]); const unsubscribe = ref(null); const searchQuery = ref(''); const randomAPI = RandomAPI.getInstance(); async function fetchAssets(options?: { live: boolean }): Promise<void> { if (options?.live) { try { const query = randomAPI.fetchAssetsLive(dbClient, (_assets) => { assets.value = _assets as Asset<any>[]; }); unsubscribe.value = query; } catch (error) { throw Error(`Error reading assets: ${error}`); } } else { const query = await randomAPI.fetchAssetsStatic(dbClient); assets.value = query; } } const filteredAssets = computed(() => { return searchQuery.value ? assets.value.filter((asset) => asset.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ) : assets.value; }); function reverseAssets(): void { const newArray = [...assets.value]; assets.value = newArray.reverse(); } return { assets, fetchAssets, filteredAssets, searchQuery, reverseAssets, unsubscribe, }; }
其中randomAPI.fetchAssetsLive来自Firestore查询文件:
export function fetchAssetsLive({ db, callback, options, }: { db: Firestore; callback: ( assets: Asset<Timestamp>[] | QueryDocumentSnapshot<Asset<Timestamp>>[] ) => void; options?: { fullDocs: boolean }; }): Unsubscribe { const assetCollection = collection(db, 'assets') as CollectionReference< Asset<Timestamp> >; if (options?.fullDocs) { return onSnapshot(assetCollection, (querySnapshot) => callback(querySnapshot.docs) ); } // Return unsubscribe return onSnapshot(assetCollection, (querySnapshot) => callback(querySnapshot.docs.map((doc) => doc.data())) ); }
组件代码如下:
<template> <div> <h1>Welcome to Random!</h1> <Button @click="reverseAssets">Reverse order</Button> <ClientOnly> <!-- <Input name="search" label="Search for an asset" v-model="searchQuery" /> --> </ClientOnly> <ul> <li class="list-item" v-for="asset in assets" :key="asset.name"> Asset Name: {{ asset.name }} Type: {{ asset.type }} </li> </ul> </div> </template> <script setup lang="ts"> import { Button, Input } from '@random/ui'; import { useNuxtApp, useAsyncData } from '#app'; const { $randomFirebase, $firestore, $getDocs, $collection } = useNuxtApp(); const { fetchAssets, filteredAssets, searchQuery, reverseAssets, assets } = useAssets($randomFirebase); // const a = process.client ? filteredAssets : assets; onMounted(() => { // console.log(searchQuery.value); // fetchAssets({ live: true }); }); watch( assets, (val) => { console.log('watcher: ', val); }, { deep: true, immediate: true } ); // TODO: make SSR work await useAsyncData(async () => { await fetchAssets(); }); </script>
当前遇到的问题
- 仅通过SSR加载数据后,
assets.value变为空数组;刷新页面能正确渲染数据,但hydration完成后数据消失。 - 同时在
onMounted和useAsyncData中发起请求时,SSR能正确传递数据,客户端也能正常工作,但仍存在hydration不匹配问题,且仅能看到客户端请求的数据,无法显示SSR渲染的内容。
我不想使用firebase-admin作为SSR查询工具,因为未来需要结合Firebase Auth的角色权限功能。请问出现这些问题的原因是什么?有没有更优的实现方案?
问题原因与解决方案
问题原因分析
1. Hydration后数据消失的核心原因
- Nuxt的SSR数据传递依赖
useAsyncData的返回值完成hydration,但当前useAsyncData仅调用fetchAssets(),未将assets的值关联到useAsyncData的序列化数据中。客户端初始化时,本地assetsref为空数组,直接覆盖了SSR渲染的DOM内容。 - Firestore客户端SDK在SSR与客户端环境中是隔离的实例,服务器端获取的数据无法自动同步到客户端的
assetsref,必须通过Nuxt的状态序列化机制传递。
2. Hydration不匹配的原因
同时在useAsyncData(SSR执行)和onMounted(客户端执行)发起请求时,服务器渲染的DOM基于SSR数据生成,但客户端onMounted中启动的onSnapshot会立即覆盖assets值,导致客户端DOM快速更新,与服务器渲染内容不一致,触发hydration mismatch警告。
优化实现方案
核心思路
- 利用
useAsyncData的序列化机制传递SSR数据,确保客户端hydration时状态一致。 - 客户端hydration完成后再启动
onSnapshot实时监听,避免覆盖SSR数据。 - 隔离Firestore在服务器与客户端的实例,避免跨环境状态污染。
步骤1:重构useAssets组合式函数
拆分静态查询与实时监听逻辑,暴露数据返回值供useAsyncData使用:
import { computed, ref, onUnmounted } from 'vue'; import { Asset, RandomAPI, RandomDatabase } from '@random/api'; import type { Unsubscribe } from 'firebase/firestore'; export function useAssets(dbClient: RandomDatabase) { const assets = ref<Asset<any>[]>([]); const unsubscribe = ref<Unsubscribe | null>(null); const searchQuery = ref(''); const randomAPI = RandomAPI.getInstance(); // 静态查询(用于SSR) async function fetchStaticAssets(): Promise<Asset<any>[]> { const query = await randomAPI.fetchAssetsStatic(dbClient); assets.value = query; return query; // 返回数据给useAsyncData序列化 } // 启动实时监听(仅客户端) function startLiveUpdates() { if (unsubscribe.value) return; unsubscribe.value = randomAPI.fetchAssetsLive(dbClient, (_assets) => { assets.value = _assets as Asset<any>[]; }); } // 停止实时监听 function stopLiveUpdates() { if (unsubscribe.value) { unsubscribe.value(); unsubscribe.value = null; } } // 组件卸载时自动清理监听 onUnmounted(() => { stopLiveUpdates(); }); const filteredAssets = computed(() => { return searchQuery.value ? assets.value.filter((asset) => asset.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ) : assets.value; }); function reverseAssets(): void { assets.value = [...assets.value].reverse(); } return { assets, fetchStaticAssets, startLiveUpdates, stopLiveUpdates, filteredAssets, searchQuery, reverseAssets, }; }
步骤2:修改组件逻辑,整合SSR与实时更新
<template> <div> <h1>Welcome to Random!</h1> <Button @click="reverseAssets">Reverse order</Button> <ClientOnly> <Input name="search" label="Search for an asset" v-model="searchQuery" /> </ClientOnly> <ul> <li class="list-item" v-for="asset in assets" :key="asset.id"> <!-- 改用doc的id作为唯一key --> Asset Name: {{ asset.name }} Type: {{ asset.type }} </li> </ul> </div> </template> <script setup lang="ts"> import { Button, Input } from '@random/ui'; import { useNuxtApp, useAsyncData, onMounted } from '#app'; const { $randomFirebase } = useNuxtApp(); const { assets, fetchStaticAssets, startLiveUpdates, filteredAssets, searchQuery, reverseAssets } = useAssets($randomFirebase); // SSR获取数据并序列化传递到客户端 const { data: ssrAssets } = await useAsyncData('assets', fetchStaticAssets); // 客户端hydration完成后启动实时监听 onMounted(() => { // 同步SSR数据到本地ref,确保初始状态一致 if (ssrAssets.value) { assets.value = ssrAssets.value; } startLiveUpdates(); }); </script>
步骤3:隔离Firestore环境实例
创建分环境的Nuxt插件,避免服务器与客户端实例共享:
// plugins/firebase.client.ts(仅客户端加载) import { initializeApp } from 'firebase/app'; import { getFirestore } from 'firebase/firestore'; export default defineNuxtPlugin(() => { const config = useRuntimeConfig(); const app = initializeApp(config.public.firebase); const db = getFirestore(app); return { provide: { randomFirebase: db, }, }; });
// plugins/firebase.server.ts(仅服务器加载) import { initializeApp } from 'firebase/app'; import { getFirestore } from 'firebase/firestore'; export default defineNuxtPlugin(() => { const config = useRuntimeConfig(); const app = initializeApp(config.public.firebase); const db = getFirestore(app); // 服务器端每次请求创建独立实例,避免状态污染 return { provide: { randomFirebase: db, }, }; });
关键注意事项
- 统一资产ID字段:确保
fetchAssetsStatic和fetchAssetsLive返回的资产对象包含Firestore文档的id(比如在映射时添加id: doc.id),避免用name作为v-for的key导致渲染错误。 - 处理时间戳序列化:SSR时Firestore的
Timestamp需转换为ISO字符串,客户端再转回Timestamp,否则会触发hydration不匹配。 - 权限控制兼容:使用客户端SDK做SSR查询时,服务器端无法获取用户Auth状态,可通过Nuxt中间件验证用户身份后传递权限信息,客户端再发起符合权限的查询;或依赖Firebase Security Rules自动验证查询合法性。
内容的提问来源于stack exchange,提问作者Jesús Fuentes
相关产品推荐
相关产品推荐

