You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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>

当前遇到的问题

  1. 仅通过SSR加载数据后,assets.value变为空数组;刷新页面能正确渲染数据,但hydration完成后数据消失。
  2. 同时在onMounted和useAsyncData中发起请求时,SSR能正确传递数据,客户端也能正常工作,但仍存在hydration不匹配问题,且仅能看到客户端请求的数据,无法显示SSR渲染的内容。

我不想使用firebase-admin作为SSR查询工具,因为未来需要结合Firebase Auth的角色权限功能。请问出现这些问题的原因是什么?有没有更优的实现方案?


问题原因与解决方案

问题原因分析

1. Hydration后数据消失的核心原因

  • Nuxt的SSR数据传递依赖useAsyncData的返回值完成hydration,但当前useAsyncData仅调用fetchAssets(),未将assets的值关联到useAsyncData的序列化数据中。客户端初始化时,本地assets ref为空数组,直接覆盖了SSR渲染的DOM内容。
  • Firestore客户端SDK在SSR与客户端环境中是隔离的实例,服务器端获取的数据无法自动同步到客户端的assets ref,必须通过Nuxt的状态序列化机制传递。

2. Hydration不匹配的原因

同时在useAsyncData(SSR执行)和onMounted(客户端执行)发起请求时,服务器渲染的DOM基于SSR数据生成,但客户端onMounted中启动的onSnapshot会立即覆盖assets值,导致客户端DOM快速更新,与服务器渲染内容不一致,触发hydration mismatch警告。

优化实现方案

核心思路

  1. 利用useAsyncData的序列化机制传递SSR数据,确保客户端hydration时状态一致。
  2. 客户端hydration完成后再启动onSnapshot实时监听,避免覆盖SSR数据。
  3. 隔离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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.20 15:12:48