在 Server Functions 中使用 Local API 操作

在 Next.js 中,server functions(之前称为 server actions)是专门在服务器端运行的函数,能够安全地执行后端逻辑同时可从前端调用。这些函数弥合了客户端与服务器之间的鸿沟,让前端组件可以执行后端操作而不会暴露敏感逻辑。

为什么要使用 Server Functions?

  • 从前端执行后端逻辑:Local API 专为服务器环境设计,无法直接从客户端代码访问。Server functions 让前端组件能够安全地触发后端操作。
  • 安全性优势:相比暴露完整的 REST 或 GraphQL API,server functions 只提供必要操作的访问权限,降低了潜在的安全风险。
  • 性能优化:Next.js 高效处理 server functions,相比传统 API 调用,提供了缓存、优化的数据库查询和减少网络开销等优势。
  • 简化开发流程:无需设置完整的 API 路由并进行身份验证和授权检查,server functions 允许轻量级地直接执行必要操作。

何时使用 Server Functions

当你需要从前端调用 Local API 操作时,就可以使用 server functions。由于 Local API 只能从后端访问,server functions 充当了安全的桥梁,无需暴露额外的 API 端点。

示例

所有 Local API 操作都可以在 server functions 中使用,让你能够安全地与 Payload 的后端交互。

完整可用操作列表请参阅 Local API 概述。

在以下示例中,我们将介绍一些常见用例,包括:

  • 创建文档
  • 更新文档
  • 创建或更新文档时处理文件上传
  • 用户认证

创建文档

首先,我们来创建服务器函数。以下是该流程的关键要点:

  • 在文件顶部添加 'use server' 声明
  • 仍然可以使用 getPayload() 等实用工具
  • 构建好函数结构后,调用 Local API 操作 payload.create() 并传入相关数据
  • 建议使用 try...catch 块进行错误处理
  • 最后确保返回创建的文档(不要仅执行操作)
'use server'

import { getPayload } from 'payload'
import config from '@payload-config'

export async function createPost(data) {
  const payload = await getPayload({ config })

  try {
    const post = await payload.create({
      collection: 'posts',
      data,
    })
    return post
  } catch (error) {
    throw new Error(`Error creating post: ${error.message}`)
  }
}

现在,我们来看看如何在用户点击按钮时,从 React 组件前端调用刚创建的 createPost 函数:

'use client';

import React, { useState } from 'react';
import { createPost } from '../server/actions'; // 导入服务器函数

export const PostForm: React.FC = () => {
  const [result, setResult] = useState<string>('');

  return (
    <>
      <p>{result}</p>

      <button
        type="button"
        onClick={async () => {
          // 调用服务器函数
          const newPost = await createPost({ title: 'Sample Post' });
          setResult('文章已创建: ' + newPost.title);
        }}
      >
        创建文章
      </button>
    </>
  );
};

更新文档

前例中的关键点同样适用于此处。

要更新文档而非创建新文档,你需要使用 payload.update() 并传入相关数据以及文档 ID

以下是服务器端函数的示例:

'use server'

import { getPayload } from 'payload'
import config from '@payload-config'

export async function updatePost(id, data) {
  const payload = await getPayload({ config })

  try {
    const post = await payload.update({
      collection: 'posts',
      id, // 必须提供文档 ID
      data,
    })
    return post
  } catch (error) {
    throw new Error(`更新文章时出错: ${error.message}`)
  }
}

以下是如何在前端 React 组件中调用 updatePost 函数的示例:

'use client';

import React, { useState } from 'react';
import { updatePost } from '../server/actions'; // 导入服务器端函数

export const UpdatePostForm: React.FC = () => {
  const [result, setResult] = useState<string>('');

  return (
    <>
      <p>{result}</p>

      <button
        type="button"
        onClick={async () => {
          // 调用服务器端函数更新文章
          const updatedPost = await updatePost('your-post-id-123', { title: '更新后的文章' });
          setResult('文章已更新: ' + updatedPost.title);
        }}
      >
        更新文章
      </button>
    </>
  );
};

用户认证

在这个示例中,我们将使用 Payload 的认证系统来检查用户是否已认证。具体工作原理如下:

  • 首先,使用 next/headers 中的 headers 函数获取请求头
  • 接着,将这些请求头传递给 payload.auth() 来获取用户的认证信息
  • 如果用户已认证,则返回用户信息;否则,相应地处理未认证的情况

以下是用于用户认证的服务器端函数:

'use server'

import { headers as getHeaders } from 'next/headers'
import config from '@payload-config'
import { getPayload } from 'payload'

export const authenticateUser = async () => {
  const payload = await getPayload({ config })
  const headers = await getHeaders()
  const { user } = await payload.auth({ headers })

  if (user) {
    return { hello: user.email }
  }

  return { hello: 'Not authenticated' }
}

以下是从前端调用认证服务器函数进行测试的基本示例:

'use client';

import React, { useState } from 'react';

import { authenticateUser } from '../server/actions'; // 导入服务器函数

export const AuthComponent: React.FC = () => {
  const [userInfo, setUserInfo] = useState<string>('');


  return (
    <React.Fragment>
      <p>{userInfo}</p>

      <button
        onClick={async () => {
          // 调用服务器函数进行用户认证
          const result = await authenticateUser();
          setUserInfo(result.hello);
        }}
        type="button"
      >
        检查认证状态
      </button>
    </React.Fragment>
  );
};

创建带文件上传的文档

这个示例展示了如何编写一个服务器函数来创建带有文件上传的文档。以下是关键步骤:

  • 传入两个参数:data 用于文档内容,upload 用于文件
  • 将上传的文件合并到文档数据中作为媒体字段
  • 使用 payload.create() 创建包含文档数据和文件的新文章文档
'use server'

import { getPayload } from 'payload'
import config from '@payload-config'

export async function createPostWithUpload(data, upload) {
  const payload = await getPayload({ config })

  try {
    // 准备包含文件的数据
    const postData = {
      ...data,
      media: upload,
    }

    const post = await payload.create({
      collection: 'posts',
      data: postData,
    })

    return post
  } catch (error) {
    throw new Error(`Error creating post: ${error.message}`)
  }
}

以下是如何在前端组件中使用我们刚创建的服务器函数,允许用户提交文章并上传文件:

  • 用户输入文章标题并选择要上传的文件
  • 提交表单时,handleSubmit 函数检查是否选择了文件
  • 如果选择了文件,它将标题和文件一起传递给 createPostWithFile 服务器函数
  • 这样就完成了!
'use client';

import React, { useState } from 'react';
import { createPostWithUpload } from '../server/actions';

export const PostForm: React.FC = () => {
  const [title, setTitle] = useState<string>('');
  const [file, setFile] = useState<File | null>(null);
  const [result, setResult] = useState<string>('');

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFile(e.target.files[0]);
    }
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!file) {
      setResult('请上传文件。');
      return;
    }

    try {
      // 调用服务器函数创建带文件的文章
      const newPost = await createPostWithUpload({ title }, file);
      setResult('文章创建成功,标题: ' + newPost.title);
    } catch (error) {
      setResult('错误: ' + error.message);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="文章标题"
      />
      <input type="file" onChange={handleFileChange} />
      <button type="submit">创建文章</button>
      <p>{result}</p>
    </form>
  );
};

可复用的 Payload 服务器函数

使用 Local API 管理认证可能会比较复杂,因为你需要自行处理 cookies 和 tokens,而且没有内置的登出或刷新函数,因为这些操作只修改 cookies。为了简化这一过程,我们提供了 loginlogoutrefresh 作为开箱即用的服务器函数。它们会处理底层复杂性,你无需关心这些细节。

登录 (Login)

通过验证凭证并设置认证 cookie 来登录用户。根据 collection 的认证配置,该函数允许通过用户名或邮箱进行登录。

导入 login 函数

import { login } from '@payloadcms/next/auth'

login 函数需要你的 Payload 配置,而配置无法在客户端组件中导入。为了解决这个问题,可以创建一个简单的服务器函数(如下所示),然后从你的客户端调用它。

'use server'

import { login } from '@payloadcms/next/auth'
import config from '@payload-config'

export async function loginAction({
  email,
  password,
}: {
  email: string
  password: string
}) {
  try {
    const result = await login({
      collection: 'users',
      config,
      email,
      password,
    })
    return result
  } catch (error) {
    throw new Error(
      `登录失败: ${error instanceof Error ? error.message : '未知错误'}`,
    )
  }
}

从 React 客户端组件登录

'use client'

import { useState } from 'react'
import { loginAction } from '../loginAction'

export default function LoginForm() {
  const [email, setEmail] = useState<string>('')
  const [password, setPassword] = useState<string>('')

  return (
    <form onSubmit={() => loginAction({ email, password })}>
      <label htmlFor="email">邮箱</label>
      <input
        id="email"
        onChange={(e: ChangeEvent<HTMLInputElement>) =>
          setEmail(e.target.value)
        }
        type="email"
        value={email}
      />
      <label htmlFor="password">密码</label>
      <input
        id="password"
        onChange={(e: ChangeEvent<HTMLInputElement>) =>
          setPassword(e.target.value)
        }
        type="password"
        value={password}
      />
      <button type="submit">登录</button>
    </form>
  )
}

登出

通过清除认证 cookie 来登出当前用户。

导入 logout 函数

import { logout } from '@payloadcms/next/auth'

与登录函数类似,你需要将 Payload 配置传递给此函数,这不能在客户端组件中完成。使用如下所示的辅助服务器函数。

'use server'

import { logout } from '@payloadcms/next/auth'
import config from '@payload-config'

export async function logoutAction() {
  try {
    return await logout({ config })
  } catch (error) {
    throw new Error(
      `登出失败: ${error instanceof Error ? error.message : '未知错误'}`,
    )
  }
}

从 React 客户端组件登出

'use client'

import { logoutAction } from '../logoutAction'

export default function LogoutButton() {
  return <button onClick={() => logoutFunction()}>登出</button>
}

刷新

为已登录用户刷新认证令牌。

导入 refresh 函数

import { refresh } from '@payloadcms/next/auth'

与登录和登出操作类似,你需要将 Payload 配置传递给这个函数。创建一个如下所示的辅助服务器函数。直接将配置传递给客户端是不可能的,会导致错误。

'use server'

import { refresh } from '@payloadcms/next/auth'
import config from '@payload-config'

export async function refreshAction() {
  try {
    return await refresh({
      collection: 'users', // 传入你的 collection slug
      config,
    })
  } catch (error) {
    throw new Error(
      `刷新失败: ${error instanceof Error ? error.message : '未知错误'}`,
    )
  }
}

在 React 客户端组件中使用 Refresh

'use client'

import { refreshAction } from '../actions/refreshAction'

export default function RefreshTokenButton() {
  return <button onClick={() => refreshFunction()}>刷新</button>
}

服务器函数中的错误处理

使用服务器函数时,正确的错误处理对于防止未捕获的异常和向前端提供有意义的反馈至关重要。

最佳实践

  • 将本地 API 调用包裹在 try/catch 块中以捕获潜在错误
  • 在服务器上记录错误以便调试
  • 返回结构化的错误响应,而不是将原始错误暴露给前端

良好的错误处理示例:

export async function createPost(data) {
  try {
    const payload = await getPayload({ config })
    return await payload.create({ collection: 'posts', data })
  } catch (error) {
    console.error('创建文章时出错:', error)
    return { error: '创建文章失败' }
  }
}

安全注意事项

使用服务器函数有助于防止本地 API 操作直接暴露给前端,但仍应遵循以下额外的安全最佳实践:

最佳实践

  • 限制访问:确保敏感操作(如用户管理)只能由授权用户调用。
  • 避免传递敏感数据:不要返回用户数据、密码等敏感信息。
  • 使用认证与授权:在执行操作前检查用户角色。

基于用户角色限制访问的示例:

export async function deletePost(postId, user) {
  if (!user || user.role !== 'admin') {
    throw new Error('Unauthorized')
  }

  const payload = await getPayload({ config })
  return await payload.delete({ collection: 'posts', id: postId })
}