返回

Nuxt 数据获取:SSR 直连 vs. 代理 API

vue.js

Nuxt 数据获取:直接访问 vs. 代理访问

在 Nuxt 应用中使用服务端渲染(SSR)进行数据获取时,会遇到客户端与服务器端数据交互的抉择问题。特别是在使用类似 Supabase 这样的后端服务时,如何合理组织数据获取逻辑,避免重复代码并确保应用性能,是需要考虑的重点。这篇文章分析直接访问后端服务 API 和通过 Nuxt Nitro 服务器代理访问 API 的差异,并给出最佳实践。

问题:客户端数据获取与 SSR 的冲突

通常,客户端单页面应用 (SPA) 会直接通过客户端代码中的 supabase-js 库从 Supabase API 获取数据。这种方式在客户端渲染(CSR)环境中工作良好,因为所有的逻辑都运行在浏览器端。当引入 SSR 后,需要在服务器端获取数据进行预渲染,这时直接使用 supabase-js 就无法实现,因为它在 Node.js 环境中无法正常运行。

为了解决这个问题,通常采用以下策略:

  • 使用 Nuxt 的 $fetchuseFetch 或者 useAsyncData 这些 composable 函数从服务端获取数据。
  • 在 Nuxt Nitro 服务器 API 中代理后端服务请求。
  • 服务器代理后端请求的思路是:先由服务器获取数据,并将数据返回给客户端,客户端再通过 useFetch 等函数来访问 Nuxt 服务器,这种做法可以在一定程度上隐藏后端服务 API,降低安全风险,并可以缓存响应数据提升性能。
//客户端 SPA (仅限客户端渲染)
<script setup lang="ts">
    await supabase.from('recipes').select(...)
</script>

// 使用 Nuxt SSR,引入 nitro api server (简化示例)
<script setup lang="ts">
    useFetch("$supabase-server/api/recipes")
</script>

// Nitro 服务器 API (/server/api/recipes.ts)
export default defineEventHandler(async (event) => {
   const data = await supabase.from('recipes').select(...)
    return data
});

看起来问题好像得到了解决,但 SSR 初次渲染之后,当客户端进行数据刷新、用户进行页面互动操作时,如何进行客户端的数据请求?这时又将面对新的抉择:

  • 客户端是否需要重复访问 Nitro 服务器?
  • 直接访问后端服务 API 是否会更好,是否可以避免重复代码,并降低服务器的性能损耗?

解决方案与分析

上述抉择核心在于服务端渲染后的客户端数据同步,以及代码复用和维护性考虑,并没有“银弹”一样的标准答案。一般要根据实际应用情况进行决策,通常有两种方案可供选择:

方案一:服务端代理请求 (红线方案)

  • 原理: 客户端始终通过 Nitro 服务器 API 进行数据请求。在初始的 SSR 渲染和客户端交互更新时,均通过代理层进行。

  • 实现步骤:

    1. 在 Nuxt 服务器的 /server/api/ 目录下创建 API 路由(例如 recipes.ts),该 API 路由处理向后端服务(如 Supabase)发送请求的逻辑。
    2. 在客户端组件中,使用 useFetch 等函数向此 Nitro API 路由发送请求,获取数据。
    3. 后续数据刷新操作,也仍然通过此 API 请求。
    // /server/api/recipes.ts
    export default defineEventHandler(async (event) => {
        const supabase = useSupabase(); // 假定有一个useSupabase的composable返回一个Supabase客户端实例
       return  await supabase.from('recipes').select('*')
    })
    
    // Home.vue
    <script setup lang="ts">
     const {data: recipes } = await useAsyncData('recipes', () =>  $fetch('/api/recipes') )
    </script>
    
  • 优点:

    • 统一管理请求逻辑,服务端拥有后端服务 API 的访问权限。
    • 客户端请求不直接暴露后端服务 API。
    • Nitro API 提供了缓存机制(可以利用Nuxt caching 或是 http header 的 cache-control)。
    • 方便添加服务端授权/鉴权检查。
  • 缺点:

    • 增加了额外的请求跳转(客户端 => Nitro 服务器 => Supabase)。
    • 当大量数据被访问的时候,需要大量的内存处理,消耗更多的服务器资源。

方案二:客户端直接请求后端服务 API (橙线方案)

  • 原理: 客户端在初始的 SSR 渲染时通过 Nitro 服务器 API 获取数据,但客户端交互的数据请求则直接访问 Supabase 的 API 。

  • 实现步骤

    1. 在 Nuxt 服务器端 API 中保留向 Supabase 获取数据的逻辑,仅用于初始页面渲染。
    2. 在客户端定义可复用的函数或服务 (例如,使用 composables/supabase-client.ts )封装客户端数据获取的逻辑,以便在组件中直接调用 Supabase API。
      // /server/api/recipes.ts
      export default defineEventHandler(async (event) => {
        const supabase = useSupabase();
       return await supabase.from('recipes').select('*');
       });
     //  composables/supabase-client.ts
     export function useSupabaseClient (){
       const config = useRuntimeConfig();
       const supabaseUrl = config.supabaseUrl
        const supabaseKey = config.supabaseKey;
       const supabase = createClient(supabaseUrl, supabaseKey)
         return {
            supabase,
         }
    }
    
     // Home.vue
    <script setup lang="ts">
      const {data: recipes} = await useAsyncData('recipes', () =>  $fetch('/api/recipes') );
    //  在客户端操作时进行数据刷新:
     const { supabase} = useSupabaseClient();
      const onRefresh =  async ()=> {
       const res =  await supabase.from("recipes").select("*")
       recipes.value=res.data;
    
    }
    </script>
    
    
  • 优点:

    • 客户端直接连接到后端服务,减少一次网络跳转,请求更快。
    • 对于客户端频繁数据更新操作,性能较高,且不需要消耗服务端性能。
    • 通过封装 supabase-client ,复用客户端的数据获取逻辑。
  • 缺点:

    • 客户端需要管理后端服务的访问凭证,存在一定的安全风险。可以使用环境变量的方式解决。
    • 如果对安全有极高的要求,不希望在客户端暴露 api key等敏感信息,此方法慎用。
    • 部分复杂的授权、鉴权需要在客户端自行处理,可能造成代码逻辑不清晰。

最佳实践

最佳实践通常取决于项目实际情况,在两个方案之间做出合理取舍。

如果安全性是首要考虑,并要求统一管理 API 访问控制,推荐方案一:使用服务端代理 API 请求。此时应谨慎处理好请求的并发量和缓存,以防止过大的服务器负载。

如果性能是首要考虑,特别是那些客户端有频繁数据更新的应用,可以考虑方案二:让客户端直接访问后端服务 API。 此时注意安全控制, 使用 composables 函数和 .env 管理 key。 此外还需考虑对代码的可维护性和后期升级改造。

通常,较小的、内部使用的应用倾向于直接连接后端服务以提高响应速度。而大型、公用的、对安全性要求较高的应用更适合使用代理服务进行数据交互。 可以考虑方案的混合模式:将所有安全相关的请求,如用户认证等,由服务器 API 进行代理;其他数据相关的请求直接由客户端发出。