第十五章 Next.js状态管理方案与实战

3,137 阅读5分钟

一、快速开始

Zustand

一个小型、快速且可扩展的 Bearbones 状态管理解决方案。Zustand 有一个基于 hooks 的舒适 API。它不是样板文件或固执己见,但有足够的惯例来明确和类似通量。

不要因为它很可爱而忽视它,它有爪子!我们花了很多时间来处理常见的陷阱,比如可怕的僵尸子问题React 并发以及混合渲染器之间的上下文丢失 。它可能是 React 领域中唯一一个能够满足所有这些要求的状态管理器。

npm i zustand

首先创建一个商店

你的商店是一个钩子!您可以在其中放入任何内容:原语、对象、函数。该set函数合并状态。

import { create } from 'zustand'

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

然后绑定您的组件,就这样!

您可以在任何地方使用该钩子,而不需要提供者。选择您的状态,当该状态发生变化时,使用组件将重新渲染。

function BearCounter() {
  const bears = useStore((state) => state.bears)
  return <h1>{bears} around here...</h1>
}

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

二、创建Store

创建一个Store,存储用户信息和加载状态

// src/store/userStore.ts
import { create } from 'zustand'

interface BearState {
  user: IUser | null | undefined
  setUser: (user: IUser | null | undefined) => void
  updateUser: (user: Partial<IUser>) => void
  loading: boolean
  setLoading: (loading: boolean) => void
}

export const useUserStore = create<BearState>((set, get) => ({
  user: null,
  isLogin: false,
  setUser: (user) => {
    if (user) {
      set({ user: user })
      return
    }
    set({ user: null })
  },
  // 更新user中的部分字段
  updateUser: (user) => {
    const oldUser = get().user
    if (!oldUser) return
    const newUser = { ...oldUser, ...user }
    set({ user: newUser })
  },
  loading: true,
  setLoading: (loading) => set({ loading }),
}))

定义用户信息类型

// types/typings.d.ts
interface IUser {
  email: string | null
  phone: string | null
  id: string
  bio: string | null
  name: string | null
  image: string | null
  expiredAt: string | null
  active: boolean
  createdAt: string
}

三、绑定组件

抽离一个Provider,处理用户信息

// src/app/(page)/layout.tsx
import Navbar from '@/components/Navbar'
import Providers from '@/components/Providers'
import { Toaster } from '@/components/ui/toaster'
import { getServerSession } from 'next-auth'
import { authOptions } from '../api/auth/[...nextauth]/route'
import UserProvider from '@/components/UserProvider'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await getServerSession(authOptions)
  // 如果全局保护路由,这里可以做一些判断
  return (
    <Providers>
      <UserProvider session={session}>
        <Navbar />
        <main>{children}</main>
      </UserProvider>
      <Toaster />
    </Providers>
  )
}

更新用户信息与状态到store

// src/components/UserProvider.tsx
'use client'

import { trpc } from '@/app/_trpc/client'
import { reactQueryRetry } from '@/lib/utils'
import { useUserStore } from '@/store/userStore'
import { useEffect, useState } from 'react'

type Props = {
  children?: React.ReactNode
  session?: any
}

const UserProvider = ({ children, session }: Props) => {
  const [enabled, setEnabled] = useState(false)
  const [setUser, setLoading] = useUserStore((state) => [
    state.setUser,
    state.setLoading,
  ])
  const { data, isLoading } = trpc.getUserInfo.useQuery(undefined, {
    retry: reactQueryRetry,
    enabled: enabled, // 控制查询的启动
  })
  useEffect(() => {
    if (session) {
      setEnabled(true)
    }
    setUser(data)
    setLoading(isLoading)
  }, [session, data, setUser, isLoading, setLoading])
  return <>{children}</>
}

export default UserProvider

四、更新数据源

1. 导航

导航数据原读取session,由src/components/Navbar/index.tsx传到MenuUser,可以在MenuUser读取store的数据,除了优化数据源外优化了过度动画

// src/components/Navbar/MenuUser.tsx
'use client'

import { LogOut, User } from 'lucide-react'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useRouter } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { useUserStore } from '@/store/userStore'
import ImageAvatar from './ImageAvatar'

export default function DropdownMenuDemo() {
  const router = useRouter()
  const [userInfo, userLoading] = useUserStore((state) => [
    state.user,
    state.loading,
  ])
  const closeOnCurrent = (href: string) => {
    router.push(href)
  }
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <div className="rounded-full w-10 h-10 flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden cursor-pointer">
          <ImageAvatar userInfo={userInfo} userLoading={userLoading} />
        </div>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56">
        <DropdownMenuLabel>我的账户</DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuGroup>
          <DropdownMenuItem
            onClick={() => {
              closeOnCurrent('/account')
            }}
            className="cursor-pointer"
          >
            <User className="mr-2 h-4 w-4" />
            <span>账户信息</span>
          </DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem
          className="cursor-pointer"
          onClick={() => {
            signOut()
          }}
        >
          <LogOut className="mr-2 h-4 w-4" />
          <span>退出登录</span>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

这里抽离了一个处理头像的通用方法,使加载过渡更流畅,兼容了上传头像的提示,需要自定义自行改造

为什么会有如此复杂的判断逻辑
无头像,前两个状态需要加载
userLoading  userInfo?.image setIsDefaultImageLoaded
T            F            F
T            F            F
F            F            T
有头像,前三个状态需要加载
userLoading  userInfo?.image  setIsUserImageLoaded
T            F            F
T            F            F
F            T            F
F            T            T
// src/components/Navbar/ImageAvatar.tsx
'use client'

import { useState } from 'react'
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface ImageAvatarProps {
  userInfo: IUser | null | undefined
  userLoading: boolean
  animateSize?: string
  showTips?: boolean
  tipsSize?: string
}
const ImageAvatar = (props: ImageAvatarProps) => {
  const { userInfo, userLoading, animateSize, showTips, tipsSize } = props
  const [isUserImageLoaded, setIsUserImageLoaded] = useState(false)
  const [isDefaultImageLoaded, setIsDefaultImageLoaded] = useState(false)
  const showLoading =
    userLoading ||
    (userLoading && !userInfo?.image && !isDefaultImageLoaded) ||
    (userLoading && !userInfo?.image && !isUserImageLoaded) ||
    (!userLoading && userInfo?.image && !isUserImageLoaded)
  return (
    <>
      {userInfo?.image ? (
        <Image
          sizes="100%"
          fill={true}
          src={userInfo.image}
          alt="avatar"
          priority={true}
          onLoad={() => setIsUserImageLoaded(true)}
        />
      ) : (
        <Image
          sizes="100%"
          fill={true}
          src={'/images/user.png'}
          alt="avatar"
          priority={true}
          onLoad={() => setIsDefaultImageLoaded(true)}
        />
      )}
      {showLoading && (
        <div
          className={cn(
            'absolute animate-pulse rounded-full bg-zinc-300 opacity-25',
            animateSize ? animateSize : 'w-10 h-10'
          )}
        ></div>
      )}
      {!showLoading && showTips && (
        <div
          className={cn(
            'absolute top-3/4 bg-zinc-600 opacity-90 text-center pt-3 text-sm text-white',
            tipsSize ? tipsSize : 'w-64 h-16'
          )}
        >
          <div>拖拽图片到此处</div>
          <div>或点击</div>
        </div>
      )}
    </>
  )
}

export default ImageAvatar

2. 用户信息

用户设置页面Card组件,只需要更新前半部分的数据源和头像组件

// src/components/UserCard.tsx
'use client'

import { cn } from '@/lib/utils'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Skeleton } from './ui/skeleton'
import Link from 'next/link'
import { buttonVariants } from './ui/button'
import { Mail, PhoneCall, UserCog2 } from 'lucide-react'
import { useUserStore } from '@/store/userStore'
import ImageAvatar from './Navbar/ImageAvatar'
function UserCard() {
  const [userInfo, isLoading] = useUserStore((state) => [
    state.user,
    state.loading,
  ])

  return (
    <Card className="w-[350px] md:w-[400px]">
      <CardHeader>
        <div className="flex gap-4 relative">
          <div className="w-16 h-16">
            {isLoading ? (
              <Skeleton className="h-full w-full rounded-full" />
            ) : (
              <div className="rounded-full w-full h-full flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden">
                <ImageAvatar
                  userInfo={userInfo}
                  userLoading={isLoading}
                  animateSize="w-16 h-16"
                />
              </div>
            )}
          </div>
 // ...

3. 用户设置

设置页面不再单独请求用户的信息,直接交给子组件去从store获取

'use client'

import Settings from '@/components/Settings'
import UploadAvatar from '@/components/UploadAvatar'

function SettingsPage() {
  return (
    <div className="flex-1 justify-between flex flex-col h-[calc(100vh-3.5rem)]">
      <div className="mx-auto w-full max-w-8xl grow lg:flex lg:flex-row-reverse xl:px-2">
        <div className="shrink-0 flex-[0.5] lg:w-96">
          <UploadAvatar />
        </div>
        <div className="flex-1 xl:flex">
          <div className="px-4 py-6 sm:px-6 lg:pl-8 xl:flex-1 xl:pl-6">
            <Settings />
          </div>
        </div>
      </div>
    </div>
  )
}

export default SettingsPage

编辑信息组件,改变数据源,更新信息成功后直接更新到store

// src/components/Settings.tsx
'use client'

import { useCallback, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { trpc } from '@/app/_trpc/client'
import { toast } from '@/components/ui/use-toast'
import { getMessages } from '@/lib/tips'
import { Loader2 } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { UpdateProfile, TUpdateProfile } from '@/lib/validator'
import { Label } from './ui/label'
import { Input } from './ui/input'
import { useUserStore } from '@/store/userStore'

function Settings() {
  const [userInfo, updateUser] = useUserStore((state) => [
    state.user,
    state.updateUser,
  ])

  const {
    register,
    handleSubmit,
    formState: { errors },
    setValue,
  } = useForm<TUpdateProfile>({
    defaultValues: {},
    resolver: zodResolver(UpdateProfile),
  })

  useEffect(() => {
    if (userInfo) {
      setValue('bio', userInfo.bio ?? undefined)
      setValue('name', userInfo.name ?? undefined)
    }
  }, [userInfo, setValue])

  const { mutate: startUpdateProfile, isLoading: updateProfileLoading } =
    trpc.updateProfile.useMutation({
      onSuccess: (data) => {
        if (data) {
          updateUser(data)
        }
        toast({
          title: getMessages('10036'),
          description: getMessages('10037'),
          variant: 'default',
        })
      },
      onError: (error) => {
        toast({
          title: getMessages('10036'),
          description: error.message,
          variant: 'destructive',
        })
      },
    })
  const handlePageSubmit = useCallback(
    async ({ bio, name }: TUpdateProfile) => {
      startUpdateProfile({ bio, name })
    },
    [startUpdateProfile]
  )
  return (...)
}

export default Settings

需要updateProfile来支持返回更新的数据

// src/trpc/index.ts
updateProfile: privateProcedure
    .input(UpdateProfile)
    .mutation(async ({ input, ctx }) => {
      try {
        const { userId } = ctx
        const { bio, name } = input
        const userInfo = await db.user.update({
          where: {
            id: userId,
          },
          data: {
            bio,
            name,
          },
        })
        // 不能给出全部的值
        return {
          id: userInfo.id,
          bio: userInfo.bio,
          name: userInfo.name,
        }
      } catch (error) {
        handleErrorforInitiative(error)
      }
    }),

头像组件

// src/components/UploadAvatar.tsx

const [userInfo, updateUser, userLoading] = useUserStore((state) => [
    state.user,
    state.updateUser,
    state.loading,
])

// 删除头像回调
updateUser({ image: undefined })
// 更换头像回调
updateUser({ image: data.image })

// 直接使用组件
<ImageAvatar
  userInfo={userInfo}
  userLoading={userLoading}
  animateSize="w-64 h-64"
  showTips={true}
  tipsSize="w-64 h-16"
/>

// 原先更新头像到state也不需要,直接使用userInfo?.image