此示例的代码可以在 GitHub 上找到:分支为 credentialssignup

地址:https://github.com/peterlidee/NNAS/tree/credentialssignup

我们已经有了表单,接下来该做什么呢?我们还需要完成哪些步骤?

  1. 调用 Strapi 的注册端点。
  2. 处理错误和成功情况。
  3. 验证字段。
  4. 向用户显示错误信息。
  5. 发送确认邮件。
  6. 设置加载状态。

在前面的章节中,我们构建的登录表单中,我们手动设置了数据、错误和加载状态。我们不得不这样做,因为 NextAuth 的 signIn 函数只能在客户端使用。

然而,现在我们不需要使用 NextAuth。我们只需要调用一个 Strapi 的端点,这可以在服务器端完成。怎么做呢?可以设置一个自定义的 Next.js API 端点(即路由处理器),或者更简单的方法是使用服务器操作。与服务器操作搭配得最好的是什么?是 useFormStateuseFormStatus 钩子,用于处理我们的数据、错误和加载状态。

因此,我们将创建一个 signUpAction 服务器操作来:

  • 使用 Zod(服务器端)验证输入字段。
  • 调用 Strapi 注册端点并处理错误或成功情况。

如果一切顺利且没有错误,signUpAction 会将用户重定向到另一个页面,并显示反馈消息:“我们已向您发送了一封电子邮件……”。如果出现错误(无论是 Zod 还是 Strapi),我们的服务器操作会将带有错误属性的状态返回给 useFormState

signUpAction

我们来编写这段代码。由于我们使用了 useFormState,我们的服务器操作将接收两个参数:

  • prevState: 当前的 useFormState 状态。
  • formData: 接口,允许读取和操作表单的所有字段。

我们首先使用 Zod 验证字段:

// frontend/src/components/auth/signup/signUpAction.ts

'use server';

import { z } from 'zod';

const formSchema = z.object({
  username: z.string().min(2).max(30).trim(),
  email: z.string().email('请输入有效的邮箱。').trim(),
  password: z.string().min(6).max(30).trim(),
});

export default async function signUpAction(
  prevState: SignUpFormStateT,
  formData: FormData
) {
  const validatedFields = formSchema.safeParse({
    username: formData.get('username'),
    email: formData.get('email'),
    password: formData.get('password'),
  });

  if (!validatedFields.success) {
    return {
      error: true,
      fieldErrors: validatedFields.error.flatten().fieldErrors,
      message: '请核实您的数据。',
    };
  }

  const { username, email, password } = validatedFields.data;
  // 进一步处理
}

Zod 验证的部分应该很清晰。我们创建了一个模式,用于检查输入字段是否符合要求。如果不符合,我们返回一个带有 error 属性的对象给 useFormState。需要注意的是:

  1. 这里的代码会报错,因为我们还没有定义 SignUpFormStateT 类型,我们稍后会处理这个问题。
  2. 如果你需要更高级的密码要求,可以添加它们。

接下来,我们使用验证过的字段调用 Strapi。我们将整个调用包装在 try-catch 块中:

// 进一步处理
try {
  const strapiResponse = await fetch(
    process.env.STRAPI_BACKEND_URL + '/api/auth/local/register',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ username, email, password }),
      cache: 'no-cache',
    }
  );

  // 处理 Strapi 错误
  if (!strapiResponse.ok) {
    const response = {
      error: true,
      message: '',
    };
    // 检查响应是否为 JSON 格式
    const contentType = strapiResponse.headers.get('content-type');
    if (contentType === 'application/json; charset=utf-8') {
      const data = await strapiResponse.json();
      response.message = data.error.message;
    } else {
      response.message = strapiResponse.statusText;
    }
    return response;
  }
} catch (error) {
  // 网络错误或其他问题
  return {
    error: true,
    message: 'message' in error ? error.message : error.statusText,
  };
}

我们之前已经介绍过 fetch。我们然后检查 strapiResponse 是否有错误。注意,并非所有 Strapi 错误都会是 JSON 格式,因此我们需要进行内容类型检查。如果有错误,我们会返回一个带有一些属性的对象给 useFormStatecatch 块的处理方式类似。

这里还缺少一个部分:成功处理。在成功时,我们将使用 next/navigationredirect 函数将用户重定向到消息页面。但你不应该在 try-catch 块内调用 redirect。因此,我们让程序通过 try-catch 块,只有在通过后,我们才添加重定向:

// 页面尚未创建
redirect('/confirmation/message');

希望这部分内容易于理解。当 strapiResponse 成功时,我们希望将用户重定向到另一个页面。我们不需要从这个响应中获取数据。当没有错误时,JavaScript 将继续执行服务器操作中的下一行代码,即重定向。

现在我们完成了,除了 SignUpFormStateT 类型,稍后我们会涉及。

useFormState

现在我们已经有了服务器操作,我们回到表单中。我们需要设置 useFormState 钩子。useFormState 是一个将服务器操作与状态关联起来的钩子。使用 useFormState 意味着你不必手动设置状态。

我们用一个服务器操作(signUpAction)和一个初始状态来初始化 useFormState。它返回这个状态和一个 formAction。基本上,formAction 是一个包装了我们传递的服务器操作的函数。任何从我们的服务器操作返回的内容都会成为我们的新状态。当调用服务器操作时,useFormState 也会将该状态和 formData 传递给服务器操作,这样我们就可以访问这些内容。我们在上面的 signUpAction 中已经看到了这一点:prevStateformData 参数。

让我们将它添加到我们的表单中:

const initialState = {
  error: false,
};
const [state, formAction] = useFormState(signUpAction, initialState);
// ...
<form className='my-8' action={formAction}></form>;

如何为 useFormState 和服务器操作设置类型

useFormState 设置类型可能有点棘手,因为你需要考虑服务器操作的每个可能的返回值以及 initialState。这需要你认真思考并付出努力。

signUpAction 中:我们知道它只在出现错误时返回:Zod 错误、Strapi 错误或 catch 错误。成功时,它不会返回而是重定向。那么我们在这些情况下返回了什么呢?

{
  error: true,
  message: '一些错误信息'
}

只有 Zod 错误有一个额外的属性,即 Zod 错误对象:

export type InputErrorsT = {
  username?: string[],
  email?: string[],
  password?: string[],
};

由于 signUpAction 在成功时不会返回,其返回类型看起来像这样:

{
  error: true,
  message: string,
  inputErrors?: InputErrorsT
}

inputErrors(即 Zod 错误)是可选的。我们的 initialState 只有一个属性:error: false。我们可以通过将 message 设置为可选来更新我们的类型。

TypeScript 会在 useFormState 钩子中正确推断这一点,而无需显式设置类型:

image.png

但我们确实需要在 signUpAction 操作中显式设置类型,因为 TypeScript 的工作方式就是如此:

export default async function signUpAction(
  prevState: SignUpFormStateT,
  formData: FormData
) {
  //...
}

既然我们必须在服务器操作中显式设置它,我们也可以在 useFormState 钩子中显式设置它。以下是具体的实现:

export type InputErrorsT = {
  username?: string[];
  email?: string[];
  password?: string[];
};

export type RegisterFormStateT = {
  error: boolean;
  inputErrors?: InputErrorsT;
  message?: string;
};

// ...

const [state, formAction] = useFormState<SignUpFormStateT, FormData>(
  signUpAction,
  initialState
);

这个设置是正确的,没有 TS 错误。但我们可以做得更好。我们将更新类型为区分联合类型。这

很有意义:它要么是错误状态,要么是 initialState。这是我们的更新:

type SignUpFormInitialStateT = {
  error: false; // 不是 boolean
};
type SignUpFormErrorStateT = {
  error: true; // 不是 boolean
  message: string; // 不是可选的
  inputErrors?: InputErrorsT;
};
// 区分联合类型
export type SignUpFormStateT = SignUpFormInitialStateT | SignUpFormErrorStateT;
// 在这里显式设置类型
const initialState: SignUpFormInitialStateT = {
  error: false,
};

// ...
const [state, formAction] = useFormState<SignUpFormStateT, FormData>(
  signUpAction,
  initialState
);

希望这部分内容对你有意义。尽管这是一个简单的例子,但已经变得有些复杂了。这需要一些努力。

使用状态

我们还没有实际使用状态向用户显示反馈信息。现在我们来添加它。对于每个输入字段,我们检查相应的 Zod 错误并显示它。

<div className='mb-3'>
  <label htmlFor='username' className='block mb-1'>
    用户名 *
  </label>
  <input
    type='text'
    id='username'
    name='username'
    required
    className='border border-gray-300 w-full rounded-sm px-2 py-1'
  />
  {state.error && state?.inputErrors?.username ? (
    <div className='text-red-700' aria-live='polite'>
      {state.inputErrors.username[0]}
    </div>
  ) : null}
</div>

// 处理其他输入字段

最后,在表单的底部显示 error.message

{
  state.error && state.message ? (
    <div className='text-red-700' aria-live='polite'>
      {state.message}
    </div>
  ) : null;
}

使用 useFormStatus 处理加载状态

最后一个任务是设置加载状态。为此,我们将使用 useFormStatus 钩子:

useFormStatus 是一个钩子,可以为你提供上一次表单提交的状态信息。

在登录功能中,我们手动设置了加载状态。由于我们在这个表单中使用了 useFormState,我们还可以使用 useFormStatus 来为我们处理加载状态。有一个小问题,我们必须在与调用 useFormState 的组件不同的组件中调用 useFormStatus。创建一个新组件:

// frontend/src/components/auth/PendingSubmitButton.tsx

import { useFormStatus } from 'react-dom';

export default function PendingSubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type='submit'
      className={`bg-blue-400 px-4 py-2 rounded-md disabled:bg-sky-200 disabled:text-gray-400 disabled:cursor-wait`}
      disabled={pending}
      aria-disabled={pending}
    >
      发送
    </button>
  );
}

然后我们将其插入到我们的表单组件中。这样就完成了注册功能。我们进行了一些快速测试:

  • 输入太短的内容会导致 Zod 错误。

image.png

  • 如果用户名或邮箱已存在于数据库中,会导致 Strapi 错误。

image.png

  • 提交按钮在提交时有一个(非常快的)加载状态。
  • 成功后,我们将被重定向到尚未构建的页面。

一切正常!

结论

我们刚刚构建了注册页面。我们从 Google 登录按钮的问题开始,因为此按钮的错误处理仅在登录页面上有效,我们不得不将其从注册页面中去除。虽然这不是最优的解决方案,但有一些方法可以解决这个问题。

接下来,我们研究了如何为这个测试应用设置注册流程。这个流程有很多种实现方式,每种方式都有其自身的挑战和解决方案。我们选择了一种经典的方法,即不立即让用户登录。

由于这个流程的结果,我们能够将 NextAuth 从注册流程中移除。这反过来又意味着我们可以使用 useFormState 结合服务器操作。这非常好,因为它让我们的工作变得更容易。

最后,我们处理了为 useFormState 设置类型,这可能有点复杂,需要一些努力。

下一步将是发送电子邮件并设置电子邮件验证页面。

首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。


王大冶
68k 声望104.9k 粉丝