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

Next.js 13 App目录下如何将视图统计逻辑迁移至客户端?

问题描述

我的最终目标是基于数据库中的统计数展示浏览量最高的文章。目前仅在重建项目时生效,因为这是服务端逻辑。我希望在客户端获取视图统计数据,让UI自动展示正确的热门文章。

以下是我Next.js 13 App目录下的page.tsx文件:

// Prisma
import prisma from "@/lib/prisma"

// Sanity
import { client } from "@/lib/sanity-client"
import { groq } from "next-sanity"

// Components
import { Container } from "@/components/Container"
import SimpleIntro from "@/components/Intros/SimpleIntro"
import SimpleIntroCentred from "@/components/Intros/SimpleIntroCentred"
import FeaturedList from "@/components/Posts/Lists/FeaturedList"
import List from "@/components/Posts/Lists/List"

async function getArticles() {
  const query = groq`
    *[_type == "article"] {
        ...,
        author->,
        categories[]->} 
    `

  if (!query) {
    throw new Error("Failed to fetch data")
  }

  const article = await client.fetch(query)

  return article
}

async function getViewCount() {
  const views = await prisma.view.findMany({
    where: { contentType: "article" },
  })

  return views
}

export default async function ArticlePage() {
  const articles = await getArticles()
  const views = await getViewCount()

  const mostViewedArticles = views
    .sort((a: any, b: any) => Number(b.count) - Number(a.count))
    .slice(0, 3)
    .map((view: any) => {
      const article = articles.find(
        (article: any) => article.slug.current === view.slug,
      )
      return article
    })

  return (
    <Container className="grid gap-20 lg:gap-32 mb-40">
      <div className="grid gap-10">
        <SimpleIntroCentred
          title="Let's read"
          description="Read my most popular articles and perhaps discovery a new upcoming interest of yours."
          customClass="animate-fade-in-top"
        />

        <SimpleIntro
          title="Most popular"
          description="Read my most popular articles and perhaps discovery a new upcoming interest of yours."
          customClass="animate-fade-in-bottom"
        />

        <FeaturedList
          data={mostViewedArticles}
          contentType="article"
          customClass="animate-fade-in-bottom"
        />
      </div>

      <div className="grid gap-10 animate-fade-in-bottom">
        <SimpleIntro
          title="All articles"
          description="Find something that you find interesting and be creative."
        />

        <List data={articles} contentType="article" />
      </div>
    </Container>
  )
}

我希望将以下部分迁移至客户端:

async function getViewCount() {
  const views = await prisma.view.findMany({
    where: { contentType: "article" },
  })

  return views
}

在App目录结构下该如何实现?


解决方案

在Next.js 13 App Router中,Prisma无法直接在客户端使用(会暴露敏感的数据库连接信息),所以需要先通过API路由封装视图统计查询,再在客户端组件中获取数据。具体步骤如下:

1. 创建API路由

在app/api/views/articles/route.ts路径下创建接口,作为数据库和客户端的中间层:

import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";

export async function GET() {
  try {
    const views = await prisma.view.findMany({
      where: { contentType: "article" },
    });
    return NextResponse.json(views);
  } catch (error) {
    return NextResponse.json({ error: "获取浏览数据失败" }, { status: 500 });
  }
}

2. 改造页面为客户端组件

给原page.tsx添加客户端指令,使用React状态和副作用钩子处理异步数据获取:

'use client'

// Sanity
import { client } from "@/lib/sanity-client"
import { groq } from "next-sanity"

// Components
import { Container } from "@/components/Container"
import SimpleIntro from "@/components/Intros/SimpleIntro"
import SimpleIntroCentred from "@/components/Intros/SimpleIntroCentred"
import FeaturedList from "@/components/Posts/Lists/FeaturedList"
import List from "@/components/Posts/Lists/List"

import { useState, useEffect } from 'react'

async function getArticles() {
  const query = groq`
    *[_type == "article"] {
        ...,
        author->,
        categories[]->} 
    `

  const article = await client.fetch(query)
  return article
}

async function fetchViewCount() {
  const res = await fetch('/api/views/articles')
  if (!res.ok) throw new Error('获取浏览数据失败')
  return res.json()
}

export default function ArticlePage() {
  const [articles, setArticles] = useState<any[]>([])
  const [views, setViews] = useState<any[]>([])
  const [mostViewedArticles, setMostViewedArticles] = useState<any[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    async function loadData() {
      try {
        // 并行获取文章和浏览数据,提升加载效率
        const [articleData, viewData] = await Promise.all([
          getArticles(),
          fetchViewCount()
        ])
        setArticles(articleData)
        setViews(viewData)

        // 计算热门文章
        const sortedViews = viewData.sort((a: any, b: any) => Number(b.count) - Number(a.count))
        const topArticles = sortedViews.slice(0,3).map(view => {
          return articleData.find(article => article.slug.current === view.slug)
        }).filter(Boolean) // 过滤掉匹配失败的文章
        setMostViewedArticles(topArticles)
      } catch (error) {
        console.error('加载数据失败:', error)
      } finally {
        setLoading(false)
      }
    }

    loadData()
  }, [])

  if (loading) {
    return <div>加载中...</div> // 可替换为自定义加载组件
  }

  return (
    <Container className="grid gap-20 lg:gap-32 mb-40">
      <div className="grid gap-10">
        <SimpleIntroCentred
          title="Let's read"
          description="Read my most popular articles and perhaps discovery a new upcoming interest of yours."
          customClass="animate-fade-in-top"
        />

        <SimpleIntro
          title="Most popular"
          description="Read my most popular articles and perhaps discovery a new upcoming interest of yours."
          customClass="animate-fade-in-bottom"
        />

        <FeaturedList
          data={mostViewedArticles}
          contentType="article"
          customClass="animate-fade-in-bottom"
        />
      </div>

      <div className="grid gap-10 animate-fade-in-bottom">
        <SimpleIntro
          title="All articles"
          description="Find something that you find interesting and be creative."
        />

        <List data={articles} contentType="article" />
      </div>
    </Container>
  )
}

3. 核心说明

  • API路由的必要性:Prisma依赖数据库连接字符串(敏感信息),不能在客户端直接调用,API路由作为中间层可以安全地处理数据库查询。
  • 客户端组件改造:添加'use client'指令后,组件就能使用React的useState和useEffect,实现客户端异步数据获取和状态管理。
  • 数据处理逻辑:将热门文章的计算逻辑移到useEffect中,确保只有在文章和浏览数据都就绪后才执行,避免空数据错误。

内容的提问来源于stack exchange,提问作者Mathias Riis Sorensen

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.25 18:47:52