金数据技术博客 · №14

The guardian of Server Component in Next.js

· iwfan · nextjs / build in public

在服务端开发中,鉴权、认证、校验等业务规则检查是不可或缺的,但当这些代码与 Server Component 结合时,我们应该如何更优雅地处理呢?🤔

通常最简单的处理方式就是通过增加 IF/ELSE 控制流来完成业务规则检查。

const DashboardPage = async () => {
  const isUserLoggedIn = await fetchUser()
  
  if (!isUserLoggedIn) {
    redirect('/login')
  }
  
  const canUseDashboard = await queryFeatureFlag('dashboard')
  
  if (!canUseDashboard) {
    redirect('/access-denied')
  }
  
  return <Dashboard />
}

但当业务规则检查越来越多的时候,更恰当的方式是使用 Container Pattern ,通过分离关注点的方式,增强代码的可维护性。

const UserLoggedGuard = async ({ children }) => {
  const isUserLoggedIn = await fetchUser()
  
  if (!isUserLoggedIn) {
    redirect('/login')
  }
  
  return children
}

const CanUserDashbordGuard = async ({ children }) => {
  const canUseDashboard = await queryFeatureFlag('dashboard')
  
  if (!canUseDashboard) {
    redirect('/access-denied')
  }
  
  return children
}


const DashboardPage = async () => {
  return (
    <UserLoggedGuard>
      <CanUserDashbordGuard>
        <Dashboard />
      </CanUserDashbordGuard>
    </UserLoggedGuard>
  )
}

但带来的另一个问题就是,一旦 Guard 组件过多,对业务组件 JSX 的侵入就变得很严重。

const DashboardPage = () => {
  return (
    <UserLoggedGuard>
      <CanUserDashbordGuard>
        <ParamsSafeGuard>
          <ForbiddenAccessGuard>
            <Dashboard />
          </ForbiddenAccessGuard>
        <ParamsSafeGuard>
      </CanUserDashbordGuard>
    </UserLoggedGuard>
  )
}

看到上面的代码,你脑海里是否又响起了熟悉的“Hadoken!” 🫨
code-image-hadoken
此时为了避免 Guard 组件对业务组件的侵入,在设计上可以考虑根据 Server Component 的特性把这些 Guard 抽离出来,变成这样:

const DashboardPage = () => {
  return (
    <Dashboard />
  )
}

export default composeGuard(
  UserLoggedGuard, 
  CanUserDashbordGuard, 
  ParamsSafeGuard,
  ForbiddenAccessGuard,
  DashboardPage,
)

现在看起来 Dashboard 的业务代码是不是更干净了?Guard 组件也更集中,方便统一查看检查规则。


composeGuard 要如何实现呢?在 Server Component 体系下,每个 Guard 都是一个独立的 Server Component,也是一个个的异步函数。想要实现这个逻辑也非常简单,就是把一堆异步函数放在一起,按顺序执行。我们可以按照这样的思路来实现:

首先在 TS 中定义出来我们需要的类型:

import type React from 'react'
import type { ParsedUrlQuery } from 'node:querystring'

export type NextPageProps = {
  params: NodeJS.Dict<string>
  searchParams: ParsedUrlQuery
  children: React.ReactNode
}

export type NextPage<T extends NextPageProps> = (props: T) => Promise<React.JSX.Element | null>

之后再来实现 compose 函数:

const composeGuard = (...guards: NextPage[]): NextPage => {
  return async (props: NextPageProps) => {
     for (const guard of guards) {
         return await guard(props)
    }
  }
}

💂 composeGuard 现在的逻辑非常简单,就是生成一个新的 ServerComponent,然后在这个新生成的 Server Component 中逐个运行给定的 guard。

但是 Bug 🐛 也非常明显,因为我们直接 return 了第一个 guard,导致现在 composeGuard 的结果只会渲染出第一个 Component 💩。但我们期望的是 guard 在业务规则检查通过之后就运行后面的 Guard,直到所有的 guard 都运行完为止。这里我们可以参考 Next.js 中 notFoundredirect 的 API 设计,通过抛异常来明确程序行为。

这里我们先定义出 NextGuardError

class NextGuardError extends Error {}

export function nextGuard(): never {
  throw new NextGuardError('go to next guard')
}

export function isNextGuardError(error: any): error is NextGuardError {
  return error instanceof NextGuardError
}

之后再补全 composeGuard 的代码逻辑:

type ExtractNextPageProps<T extends NextPage<any>[]> = T extends Array<NextPage<infer U>> ? U : never

function composeGuard<T extends NextPage<any>[]>(...guards: T) {
  return async function ComposeGuardWrapper(props: ExtractNextPageProps<T>) {
    for (const guard of guards) {
      try {
        return await guard(props)
      } catch (error) {
        if (isNextGuardError(error)) {
          continue
        }
        throw error
      }
    }
    return null
  }
}

至此,我们的 composeGuard 基本就已经写完了,使用起来也很简单。以CanUserDashbordGuard 为例:

const CanUserDashbordGuard = async ({ children }) => {
  const canUseDashboard = await queryFeatureFlag('dashboard')
  
  if (!canUseDashboard) {
    redirect('/access-denied')
  }
  
  nextGuard() // 逻辑检查通过之后,执行后续 guard
}

在业务逻辑检查中,如果不符合条件可以直接渲染 UI 告知用户,也可以使用 notFound 或者 redirect 跳转到其他的页面中。但逻辑检查通过之后,可以使用 nextGuard 继续执行后续的 guard。


回过头来再看一下之前的代码示例:

const DashboardPage = () => {
  return (
    <Dashboard />
  )
}

export default composeGuard(
  UserLoggedGuard, 
  CanUserDashbordGuard, 
  ParamsSafeGuard,
  ForbiddenAccessGuard,
  DashboardPage,
)

现在用户打开 Dashboard 页面时,会顺序经过 UserLoggedGuardCanUserDashbordGuardParamsSafeGuardForbiddenAccessGuard。 但这些 guard 全部运行结束之后就会进入到 DashboardPage 执行核心的业务逻辑。这样,通过 Server Component 守卫,我们能够更清晰、更优雅地组织和处理业务逻辑,使代码更易读、易维护。