返回

Nuxt 3 SSR Cookie首次加载undefined? 两种解决方案

vue.js

搞定 Nuxt 3 SSR 中 Cookie 首次加载 undefined 的问题

写 Nuxt 3 应用的时候,用 useCookie 处理 Cookie 是个挺方便的事儿。但有时候,尤其是在服务端渲染(SSR)的时候,可能会遇到一个头疼的问题:第一次加载页面,或者清了浏览器 Cookie 再访问,想从 Cookie 里拿个值,结果拿到的是 undefined。后面刷新就好了,就很奇怪。

具体场景是这样:你想根据用户的偏好(比如语言)来设置一个 Cookie,fav_lang。这个偏好的默认值,得从一个 API 接口那儿取。你希望在任何组件或 Composable 函数(特别是在 SSR 期间)访问这个 fav_lang Cookie 时,它都应该有个正确的初始值,而不是 undefined。试了在 Nuxt 插件里去获取 API 并设置 Cookie,但首次 SSR 访问时,其他地方读到的 fav_lang 还是 undefined

这篇就来捋一捋,为啥会这样,以及怎么解决在 Nuxt 3 SSR 场景下,确保 Cookie 在首次访问时也能读到正确的、从 API 获取的默认值。

为啥会这样?深挖原因

这事儿的关键在于 Nuxt 3 SSR 的运行机制和 useCookie 的工作方式。

  1. SSR 请求来了: 当用户的浏览器第一次请求页面(或者清了 Cookie 后请求),这个请求先打到服务器。
  2. Nuxt 开始渲染: 服务器上的 Nuxt 开始处理这个请求,准备渲染页面。这包括运行 Nuxt 插件、执行组件的 setup 函数(里面可能调用了 Composable)。
  3. useCookie 执行: 当你的代码(无论在插件、组件还是 Composable 里)执行到 useCookie('fav_lang') 时,它会尝试从当前进来的这个服务器请求里读取 fav_lang Cookie。
  4. Cookie 不存在: 因为是首次访问或者 Cookie 被清了,请求头里根本没有 fav_lang 这个 Cookie。所以,useCookie 自然就返回了 undefined
  5. 异步操作的“时差”: 如果你尝试在插件里通过 $fetch 去异步调用 API 获取默认值,然后再设置 Cookie (fav_lang.value = defaultValue),这里有个时间差。$fetch 是个异步操作,它需要时间去请求 API 并等待响应。但在它完成之前,Nuxt 的渲染流程可能已经继续往下走了,其他的组件或 Composable 可能已经执行了它们自己的 useCookie('fav_lang'),这时它们读到的就是那个初始的 undefined。等到你的 API 调用成功并设置了 Cookie 值时,那些代码已经执行完了。这就是为啥第一次是 undefined,而之后刷新页面(Cookie 已经被设置上了)就正常了。
  6. 客户端激活 (Hydration): 服务器渲染完成后,HTML 发送到浏览器。浏览器加载 JS,Vue 开始“激活”过程,接管页面。这时如果再执行 useCookie,它会读取浏览器端的 Cookie,或者沿用 SSR 时传递过来的状态,情况可能会变好,但 SSR 那一瞬间的问题已经发生了。

所以,核心矛盾点在于:需要在 SSR 期间、在所有需要该 Cookie 的地方读取它 之前,就完成 ①检查 Cookie 是否存在 -> ②如果不存在,则异步获取 API 数据 -> ③用获取到的数据设置 Cookie 默认值 这一整套操作。

解决方案

要解决这个问题,就得确保获取和设置默认 Cookie 的操作,能在 Nuxt 应用初始化和渲染的关键路径上,足够早且正确地完成。下面提供几种思路和具体操作方法。

方案一:利用 useCookie 的异步默认值工厂 (主要推荐)

Nuxt 3 的 useCookie 其实非常灵活,它的第二个参数可以传入一个选项对象,其中有个 default 属性。这个 default 属性不仅仅能接受一个普通的值,还能接受一个函数 ,甚至是一个异步函数 (async function)

这简直就是为咱们这个场景量身定做的。当 useCookie 在读取 Cookie 时发现它不存在,它就会调用这个 default 函数来获取默认值。如果是异步函数,Nuxt 会正确地处理(await)它,确保拿到结果后再继续。

原理:

useCookie(name, { default: async () => { ... } }) 会在 Cookie 不存在时执行这个异步工厂函数。在 SSR 期间,Nuxt 会等待这个异步函数完成,拿到返回值作为 Cookie 的初始值。这样,后续所有(在同一个 SSR 请求处理流程中)调用 useCookie('fav_lang') 的地方,都能读到这个通过 API 获取并设置的默认值了。

步骤与代码:

假设你有一个 Composable ~/composables/useLanguages.js 负责获取语言列表和 API 调用:

// ~/composables/useLanguages.js (示例)
import { ref } from 'vue'
import { $fetch } from 'ofetch' // 或者 #app 里的 $fetch

export function UseLanguages() {
  const languages_list = ref(null);
  const languages_list_api = async () => {
    // 模拟API请求延迟
    await new Promise(resolve => setTimeout(resolve, 150)); // 模拟网络延迟
    // 实际项目中换成你的 $fetch 调用
    // const data = await $fetch('/api/languages');
    const data = [ // 假设这是 API 返回的数据
      { code: 'en', name: 'English', default_web: 0 },
      { code: 'zh-CN', name: 'Simplified Chinese', default_web: 1 },
      { code: 'ja', name: 'Japanese', default_web: 0 },
    ];
    languages_list.value = data;
    console.log('API fetched, languages list updated.');
  };

  return { languages_list, languages_list_api };
}

现在,直接在你的 Composable 或者需要用到 Cookie 的地方使用 useCookie,并提供异步 default 工厂:

// 在某个 composable 或 setup 函数里
import { useCookie } from '#app'; // 从 #app 导入
import { UseLanguages } from '~/composables/useLanguages'; // 引入你的语言 Composable

// ... 可能在 setup() 或另一个 composable function 内部 ...

const favLangCookie = useCookie<string | null>('fav_lang', {
  // ----- 这就是关键 👇 -----
  async default() {
    console.log('Cookie "fav_lang" not found, executing async default factory...');
    // 这个 default 函数只在 Cookie 值是 undefined 时执行
    // 并且我们最好只在服务端执行 API 调用来设置初始默认值
    if (process.server) {
      try {
        const { languages_list_api, languages_list } = UseLanguages();
        // 调用 API 获取语言列表
        await languages_list_api();

        // 从返回结果里找到默认语言
        const defaultLang = languages_list.value?.find(lang => lang.default_web === 1);

        if (defaultLang) {
          console.log(`Default language found via API: ${defaultLang.code}. Setting it as default.`);
          return defaultLang.code; // 返回找到的默认语言代码
        } else {
          console.log('No default language marked in API response.');
          return 'en'; // 提供一个硬编码的后备默认值,避免返回 null/undefined
        }
      } catch (error) {
        console.error('Error fetching default language in cookie factory:', error);
        return 'en'; // 出错时也返回一个后备值
      }
    }
    // 如果在客户端执行到这里(比如 hydration 期间检查),通常不需要重新 fetch
    // Nuxt 的 SSR 数据传递机制会处理好
    // 或者,如果 cookie 仍然缺失,可能返回一个客户端的默认值或 null
    console.log('Running default factory on client, returning null (or client default).');
    return null; // 在客户端,如果没有从服务端获取到值,可以返回 null 或客户端默认值
  },
  // ----- 异步 default 工厂结束 -----

  // 其他 Cookie 配置,很重要!
  path: '/', // 保证 Cookie 在全站可用
  maxAge: 60 * 60 * 24 * 365, // Cookie 有效期,比如一年
  sameSite: 'lax', // SameSite 策略,'lax' 或 'strict' 通常更安全
  // secure: process.env.NODE_ENV === 'production', // 生产环境建议启用 Secure
  // httpOnly: true, // 如果你的 JS 不需要直接读取它,建议设为 true,更安全
});

// 现在,favLangCookie.value 在 SSR 首次加载时,也会有值了(如果 API 成功返回的话)
console.log('Current fav_lang value:', favLangCookie.value);

// 你可以把 favLangCookie 暴露出去或者在当前作用域使用
// return { favLangCookie };

解释:

  1. useCookie('fav_lang', { ... }):定义我们要操作的 Cookie 名 fav_lang 和配置项。
  2. async default():声明这是一个异步的默认值生成函数。
  3. if (process.server):我们通常只想在服务器端执行这个 API 调用来 首次 设置默认值。客户端的 Cookie 应该由服务器响应头里的 Set-Cookie 或者后续的用户操作来设定。
  4. UseLanguages():调用你的 Composable 来获取 API 功能。
  5. await languages_list_api():执行异步 API 调用。Nuxt 在 SSR 期间会等待这个 await 完成。
  6. find(lang => lang.default_web === 1):根据你的逻辑找到默认语言。
  7. return defaultLang.code:返回找到的值。这个值会被 useCookie 用作当前的初始值,并且 useCookie 会自动帮你把这个值通过 Set-Cookie 响应头发送给浏览器 ,这样浏览器也就存下了这个 Cookie。
  8. 错误处理和后备值:添加 try...catch 并在出错或没找到默认语言时,返回一个可靠的后备值(比如 'en'),防止 Cookie 值意外变成 nullundefined
  9. Cookie 选项:path, maxAge, sameSite, secure, httpOnly 这些选项对于 Cookie 的行为和安全至关重要,记得根据需求配置好。

优点:

  • 逻辑内聚:获取默认值的逻辑和 useCookie 本身结合紧密。
  • Nuxt 友好:利用了 Nuxt 提供的标准机制,写法比较优雅。
  • 时机正确:Nuxt 会确保在 SSR 渲染需要用到 Cookie 值之前,等待这个异步默认值工厂执行完毕。

安全建议:

  • 仔细设置 Cookie 的 path, maxAge, sameSite, secure (生产环境用 true) 和 httpOnly (如果 JS 不需要直接读,推荐 true) 属性,增强安全性。
  • API 端点需要做好权限控制。

进阶使用技巧:

  • 如果 UseLanguages Composable 本身也需要在应用启动时做些初始化,确保它或者它的初始化逻辑(可能在某个插件里)能在 useCookiedefault 工厂执行前准备好。不过,直接在 default 工厂里调用 Composable 通常是没问题的。
  • 对于非常复杂的初始化逻辑,可以考虑将获取默认值的逻辑封装成一个独立的异步函数,然后在 default 工厂里调用它。

方案二:使用 Nuxt Server Middleware (备选方案)

如果你希望把这种“请求预处理”的逻辑完全放到 Nuxt 的服务端处理流程的最前端,可以使用 Server Middleware。它在 Nitro 服务器处理请求时、在 Vue 应用开始渲染之前执行。

原理:

Server Middleware 可以拦截所有(或特定路径的)进入服务器的请求。在中间件里,你可以检查请求中是否带有 fav_lang Cookie。如果没有,就调用 API 获取默认值,然后使用 h3 提供的 setCookie 辅助函数,将这个默认值设置到响应 (response) 头里。

注意: 这个方法主要是为后续的请求设置好 Cookie 。对于当前这次触发 Middleware 的请求,如果在它执行完毕之后、但在同一个请求响应周期内,Vue 应用内部的代码(比如某个 setup 函数里的 useCookie)去读取 Cookie,它读到的可能仍然是请求(request)头里的原始状态(即 undefined ,而不是你在 Middleware 中刚设置到 响应 头里的值。

要解决这个问题,Middleware 在获取到默认值后,除了用 setCookie 写入响应头,还可以把这个值存入当前请求的上下文 event.context 中。然后,你的插件或者 useCookiedefault 工厂(是的,可能还是要结合使用)可以优先从 event.context 读取这个预设的值。

这比方案一复杂,但提供了更早介入请求处理的机会。

步骤与代码:

  1. 创建 Server Middleware 文件:

    在你的 Nuxt 项目根目录下创建 server/middleware/01.set-default-lang.ts (文件名可以自定,数字前缀影响执行顺序)。

    // server/middleware/01.set-default-lang.ts
    import { defineEventHandler, getCookie, setCookie } from 'h3'
    import { $fetch } from 'ofetch' // Nitro 中可以直接用 ofetch
    
    // 假设 API 获取逻辑 (实际应更健壮)
    async function fetchDefaultLanguageCode(): Promise<string | null> {
      console.log('[Middleware] Fetching default language from API...');
      try {
        // 替换成你的真实 API 地址和逻辑
        // const languages = await $fetch('/api/languages');
        await new Promise(resolve => setTimeout(resolve, 100)); // 模拟延迟
        const languages = [
          { code: 'en', name: 'English', default_web: 0 },
          { code: 'zh-CN', name: 'Simplified Chinese', default_web: 1 },
        ];
        const defaultLang = languages.find(lang => lang.default_web === 1);
        if (defaultLang) {
           console.log(`[Middleware] API returned default: ${defaultLang.code}`);
          return defaultLang.code;
        }
         console.log('[Middleware] No default language found in API response.');
        return 'en'; // 后备值
      } catch (error) {
        console.error('[Middleware] Error fetching default language:', error);
        return 'en'; // 出错时的后备值
      }
    }
    
    export default defineEventHandler(async (event) => {
      // 只处理页面请求,忽略 API 请求或其他资源请求(根据需要调整)
      const path = event.node.req.url || '';
      if (!path.startsWith('/_nuxt/') && !path.startsWith('/api/') && (path === '/' || path.endsWith('.html') || !path.includes('.'))) {
         console.log(`[Middleware] Processing request for path: ${path}`);
        const favLang = getCookie(event, 'fav_lang');
        console.log(`[Middleware] Existing 'fav_lang' cookie value: ${favLang}`);
    
        if (favLang === undefined || favLang === null || favLang === '') {
          console.log(`[Middleware] 'fav_lang' cookie not found or empty. Fetching default.`);
          const defaultLangCode = await fetchDefaultLanguageCode();
    
          if (defaultLangCode) {
            console.log(`[Middleware] Setting 'fav_lang' cookie to: ${defaultLangCode}`);
            // 设置 Cookie 到响应头,为下次请求和浏览器准备
            setCookie(event, 'fav_lang', defaultLangCode, {
              path: '/',
              maxAge: 60 * 60 * 24 * 365, // 一年
              sameSite: 'lax',
              // secure: process.env.NODE_ENV === 'production',
              // httpOnly: true,
            });
    
            // 【可选但推荐】将获取到的值存入当前请求上下文,
            // 供同一请求后续流程(如 useCookie 的 default 工厂)使用
            event.context.fetchedDefaultLang = defaultLangCode;
             console.log(`[Middleware] Stored fetched value in event.context.fetchedDefaultLang`);
          }
        } else {
           console.log(`[Middleware] 'fav_lang' cookie already exists. No action needed.`);
        }
      } else {
         // console.log(`[Middleware] Skipping middleware for path: ${path}`);
      }
    })
    
  2. (可选)在 useCookie 中利用 event.context

    修改方案一中的 useCookie,使其优先检查 event.context

    // composable 或 setup 中
    import { useCookie, useRequestEvent } from '#app';
    
    const favLangCookie = useCookie<string | null>('fav_lang', {
      async default() {
        const event = useRequestEvent(); // 获取当前请求事件对象 (仅在 SSR 或 setup 中可用)
    
        // 优先检查 middleware 是否已在 context 中存了值
        if (process.server && event && event.context.fetchedDefaultLang) {
          console.log('Using default language from event.context:', event.context.fetchedDefaultLang);
          return event.context.fetchedDefaultLang;
        }
    
        // 如果 context 没有,再执行原来的 API 调用逻辑 (或只返回后备值)
        console.log('Cookie or context value not found, proceeding with async default factory fetch (or fallback)...');
        if (process.server) {
           // ... (原来的 API fetch 逻辑) ...
           // 保持这里的逻辑作为第二道防线,或处理 middleware 未覆盖的情况
        }
        return 'en'; // 或者 null
      },
      // ... 其他 cookie 选项 ...
    });
    

优点:

  • 执行时机非常早,在 Vue 应用初始化之前。
  • 职责分离,将请求的预处理逻辑放到 Middleware 层。

缺点:

  • 逻辑分散:Cookie 的设置在 Middleware,读取和使用在 Vue 应用内部,可能还需要 event.context 作为桥梁,比方案一复杂。
  • 潜在性能影响:Middleware 是阻塞性的,如果在里面做很慢的 API 调用,会延迟整个页面的响应。要确保 API 调用足够快或做好缓存。
  • 对于解决“首次读取是 undefined”的问题,单纯使用 Middleware 设置响应头并不直接解决,还需要配合 event.context 或让 useCookiedefault 逻辑感知到 Middleware 的结果。

安全建议:

  • 同方案一,注意 Cookie 属性的配置。
  • 保护好 Middleware 中调用的 API 端点。
  • 避免在 Middleware 中执行过于耗时的操作。

进阶使用技巧:

  • 可以创建多个 Middleware 文件,通过文件名前缀的数字来控制它们的执行顺序。
  • nitro.config.ts 中可以更精细地配置 Middleware 的应用范围和顺序。
  • 对于全局共享的数据,除了 event.context,还可以考虑使用 Nitro 的缓存或其他状态管理机制。

总的来说,对于“需要在 SSR 首次加载时,根据 API 结果为 useCookie 提供一个默认值”的场景,利用 useCookie 的异步 default 工厂(方案一)是最直接、最符合 Nuxt 3 设计理念、也通常是代码最简洁的解决方案 。它能确保在需要读取 Cookie 值的时候,异步获取默认值的操作已经完成,从而避免了首次访问得到 undefined 的问题。Server Middleware 作为一种备选方案,更适合做一些更通用的请求预处理,但解决这个特定问题时相对迂回一些。