JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第37章—实战篇ReactNotes笔记编辑界面

首次发表于 2024-03-22, 更新于 2024-03-22

前言

本篇我们来实现右侧笔记编辑部分。

笔记编辑界面

当点击 New 按钮的时候进入编辑界面:

image.png

当点击具体笔记的 Edit 按钮的时候进入该笔记的编辑页面:

image.png

回忆下之前的路由设计,当点击 New 的时候,导航至 /note/edit路由,当点击 Edit 的时候,导航至 /note/edit/xxxx 路由。

那么我们开始动手吧!

/app/note/edit/page.js代码如下:

import NoteEditor from '@/components/NoteEditor'

export default async function EditPage() {
  return <NoteEditor note={null} initialTitle="Untitled" initialBody="" />
}

/app/note/edit/loading.js代码如下:

export default function EditSkeleton() {
  return (
    <div
      className="note-editor skeleton-container"
      role="progressbar"
      aria-busy="true"
    >
      <div className="note-editor-form">
        <div className="skeleton v-stack" style={{ height: '3rem' }} />
        <div className="skeleton v-stack" style={{ height: '100%' }} />
      </div>
      <div className="note-editor-preview">
        <div className="note-editor-menu">
          <div
            className="skeleton skeleton--button"
            style={{ width: '8em', height: '2.5em' }}
          />
          <div
            className="skeleton skeleton--button"
            style={{ width: '8em', height: '2.5em', marginInline: '12px 0' }}
          />
        </div>
        <div
          className="note-title skeleton"
          style={{ height: '3rem', width: '65%', marginInline: '12px 1em' }}
        />
        <div className="note-preview">
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
          <div className="skeleton v-stack" style={{ height: '1.5em' }} />
        </div>
      </div>
    </div>
  )
}

你可能会问,同级的 page.js 又没有数据请求,添加 loading.js 有什么用?

同级的page.js确实没有请求,但 loading.js会将 page.js 和其 children 都包裹在 <Suspense> 中,所以 /app/note/edit/[id]/page.js中的请求也会触发该 loading.js

/app/note/edit/[id]/page.js代码如下:

import NoteEditor from '@/components/NoteEditor'
import {getNote} from '@/lib/redis';

export default async function EditPage({ params }) {
  const noteId = params.id;
  const note = await getNote(noteId)

  // 让效果更明显
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  await sleep(5000);

  if (note === null) {
    return (
      <div className="note--empty-state">
        <span className="note-text--empty-state">
          Click a note on the left to view something! 🥺
        </span>
      </div>
    )
  }

  return <NoteEditor noteId={noteId} initialTitle={note.title} initialBody={note.content} />
}

我们抽离了一个 <NoteEditor> 组件用于实现编辑功能,/components/NoteEditor.js 代码如下:

'use client'

import { useState } from 'react'
import NotePreview from '@/components/NotePreview'
import { useFormStatus } from 'react-dom'

export default function NoteEditor({
  noteId,
  initialTitle,
  initialBody
}) {

  const { pending } = useFormStatus()
  const [title, setTitle] = useState(initialTitle)
  const [body, setBody] = useState(initialBody)
  const isDraft = !noteId

  return (
    <div className="note-editor">
      <form className="note-editor-form" autoComplete="off">
        <label className="offscreen" htmlFor="note-title-input">
          Enter a title for your note
        </label>
        <input
          id="note-title-input"
          type="text"
          value={title}
          onChange={(e) => {
            setTitle(e.target.value)
          }}
        />
        <label className="offscreen" htmlFor="note-body-input">
          Enter the body for your note
        </label>
        <textarea
          value={body}
          id="note-body-input"
          onChange={(e) => setBody(e.target.value)}
        />
      </form>
      <div className="note-editor-preview">
        <form className="note-editor-menu" role="menubar">
          <button
            className="note-editor-done"
            disabled={pending}
            type="submit"
            role="menuitem"
          >
            <img
              src="/checkmark.svg"
              width="14px"
              height="10px"
              alt=""
              role="presentation"
            />
            Done
          </button>
          {!isDraft && (
            <button
              className="note-editor-delete"
              disabled={pending}
              role="menuitem"
            >
              <img
                src="/cross.svg"
                width="10px"
                height="10px"
                alt=""
                role="presentation"
              />
              Delete
            </button>
          )}
        </form>
        <div className="label label--preview" role="status">
          Preview
        </div>
        <h1 className="note-title">{title}</h1>
        <NotePreview>{body}</NotePreview>
      </div>
    </div>
  )
}

因为需要控制输入框的状态,所以 <NoteEditor> 使用了客户端组件,我们在 <NotePreview> 中引用了 <NotePreview>组件,用于实现编辑时的实时预览功能。

此时编辑页面应该已经可以正常显示:

image.png

此时 DoneDelete 按钮还不能使用,这里我们使用 Server Actions 来实现。但实现之前,我们先看下目前的实现中一些要注意的点。

服务端组件和客户端组件

前面我们讲到关于服务端组件和客户端组件的使用指南,其中有一条:

服务端组件可以导入客户端组件,但客户端组件不能导入服务端组件

但是这个例子中就很奇怪了。<NoteEditor> 是客户端组件,<NotePreview>是服务端组件,但我们却在 <NoteEditor> 中引用了 <NotePreview>组件,不是说不可以吗?怎么还成功渲染了!

这是一个初学者经常会遇到的误区。让我们回忆下《渲染篇 | 服务端组件和客户端组件》中是如何定义客户端组件的:

我们会在文件顶部添加一个 'use client' 声明。但准确的说,'use client' 声明的是服务端和客户端组件之间的边界,这意味着,当你在文件中定义了一个 'use client',导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。

换句话说,所有组件都是服务器组件,除非它使用了 'use client' 指令,或者被导入到 'use client' 模块中。此时它们会被视为客户端组件。视为客户端组件,就意味着它的代码要被打包到客户端 bundle 中。

比如这里的 <NotePreview>,它被导入到 <NoteEditor>这个客户端组件中,它就变成了客户端组件。变成客户端组件,意味着 <NotePreview>中的代码,包括用到的 markedsanitize-html库也要被打包到客户端中,要知道,这两个库没压缩前可是有几百 kB 的。

所以我们才要将服务端组件通过 props 的形式传给客户端组件,当通过这种形式的时候,组件还是服务端组件,会在服务端执行渲染,代码也不会打包到客户端中。当然在这个例子中,我们就是需要在客户端渲染 markdown 文件,所以代码就是要打包到客户端中的,没有办法避免。

让我们查看下 http://localhost:3000/note/1702459188837此时的源代码:

截屏2023-12-18 下午4.57.17.png

预览的时候,我们虽然用了 <NotePreview> 这个组件,但是代码没有打包到客户端中。但是当我们打开 http://localhost:3000/note/edit/1702459188837

截屏2023-12-18 下午4.58.35.png

你会发现,下载了客户端组件 <NoteEditor><NotePreview>,对应也使用了很多库。page.js 也变大了很多(424 kB):

截屏2023-12-18 下午5.02.22.png

最后再说说使用客户端组件时的一个注意事项,那就是不要使用 async/await,可能会出现报错:

image.png

笔记编辑和删除

当点击 Done 的时候,导航至对应的笔记预览页面 /note/xxxx。当点击 Delete 的时候,导航至首页。

正常开发笔记的增加、更新和删除功能,为了实现前后端交互,可能要写多个接口来实现,比如当点击删除的时候,调用删除接口,接口返回成功,前端跳转至首页。但既然我们都用了 Next.js 14 了,没必要这么麻烦,Server Actions 直接搞定,省的一个个写接口了。

修改 /components/NoteEditor.js 代码:

'use client'

// ...
import { deleteNote, saveNote } from '../app/actions'

export default function NoteEditor({
  noteId,
  initialTitle,
  initialBody
}) {
	//...
  return (
    <div className="note-editor">
    	// ...
      <div className="note-editor-preview">
        <form className="note-editor-menu" role="menubar">
          <button
            className="note-editor-done"
            disabled={pending}
            type="submit"
            formAction={() => saveNote(noteId, title, body)}
            role="menuitem"
          >
            // ...
            Done
          </button>
          {!isDraft && (
            <button
              className="note-editor-delete"
              disabled={pending}
              formAction={() => deleteNote(noteId)}
              role="menuitem"
            >
              // ...
              Delete
            </button>
          )}
        </form>
      	// ...
      </div>
    </div>
  )
}

其中最为核心的代码就是:

<form className="note-editor-menu" role="menubar">
  <button formAction={() => saveNote(noteId, title, body)}>
    Done
  </button>
  <button formAction={() => deleteNote(noteId)} >
    Delete
  </button>
</form>

app/actions.js的代码如下:

'use server'

import { redirect } from 'next/navigation'
import {addNote, updateNote, delNote} from '@/lib/redis';

export async function saveNote(noteId, title, body) {
  
  const data = JSON.stringify({
    title,
    content: body,
    updateTime: new Date()
  })

  if (noteId) {
    updateNote(noteId, data)
    redirect(`/note/${noteId}`)
  } else {
    const res = await addNote(data)
    redirect(`/note/${res}`)
  }

}

export async function deleteNote(noteId) {
  delNote(noteId)
  redirect('/')
}

此时新增和删除看似可以“正常运行”了:

添加文章.gif

删除文章.gif

注:写这个 demo 的时候可能会遇到点了按钮没有反应,卡顿 5s 的情况,这是因为之前的 demo 里我们有在多个组件里写 sleep 5s,删除相应的代码即可。

Server Actions

借助 Server Actions,我们很简单的就实现了笔记的新增和删除效果,但其实目前的代码中还有很多问题。

1. 完整路由缓存与 revalidate

比如当我们连续 2 次新增笔记时,观察左侧的笔记列表变化:

多次新增出现问题.gif

笔记列表初始有 3 条,新增第 1 条笔记后,左侧的笔记列表显示 4 条,但当我们新增第 2 条笔记的时候,左侧的笔记列表又变成了 3 条,新增第 2 条笔记后,左侧的笔记列表显示 5 条。

如果你导航至首页 /,你会发现还是 3 条,而且哪怕你清空缓存并硬性重新加载,还是 3 条,这是为什么呢?

这就是完整路由缓存。以 /note/edit为例,路由默认是静态渲染,也就是说,会在构建的时候,读取数据,然后将编译后的 HTML 和 RSC Payload 缓存,构建的时候,数据库里有 3 条数据,所以 HTML 中也只有 3 条数据,所以后续打开 /note/edit也都是 3 条数据。

还记得如何让完整路由缓存失效吗?

有两种方式可以使完整路由缓存失效:

  • 重新验证数据:重新验证数据缓存将使完整路由缓存失效,毕竟渲染输出依赖于数据
  • 重新部署:数据缓存是可以跨部署的,但完整路由缓存会在重新部署中被清除

此外,客户端路由缓存的失效也需要借助 revalidate:

有两种方法可以让路由缓存失效:

  • 在 Server Action 中:
    • 通过 revalidatePathrevalidateTag 重新验证数据
    • 使用 cookies.set 或者 cookies.delete 会使路由缓存失效
  • 调用 router.refresh 会使路由缓存失效并发起一个重新获取当前路由的请求

所以在进行数据处理的时候,一定要记得重新验证数据,也就是 revalidatePathrevalidateTag。现在我们修改下 app/actions.js

'use server'

import { redirect } from 'next/navigation'
import {addNote, updateNote, delNote} from '@/lib/redis';
import { revalidatePath } from 'next/cache';

export async function saveNote(noteId, title, body) {
  
  const data = JSON.stringify({
    title,
    content: body,
    updateTime: new Date()
  })

  if (noteId) {
    updateNote(noteId, data)
    revalidatePath('/', 'layout')
    redirect(`/note/${noteId}`)
  } else {
    const res = await addNote(data)
    revalidatePath('/', 'layout')
    redirect(`/note/${res}`)
  }

}

export async function deleteNote(noteId) {
  delNote(noteId)
  revalidatePath('/', 'layout')
  redirect('/')
}

这里我们简单粗暴了清除了所有缓存,此时新增、编辑、删除应该都运行正常了。

2. 实现原理

现在让我们来看看当我们点击 Done 按钮的时候做了什么?

我们先注释掉 actions.js 中的 redirect,这样当更新笔记的时候,不会发生重定向。然后我们编辑一条笔记,然后点击 Done,可以看到页面发送了一条 POST 请求:

image.png

请求地址是当前页面,请求方法为 POST。请求内容正是我们传入的内容:

image.png

响应内容为:

image.png

如果我们不注释掉 actions.js 中的 redirect,然后我们编辑一条笔记,然后点击 Done,可以看到页面发送了一条 POST 请求:

image.png

因为有重定向,所以请求状态变成了 303。响应内容为:

3:I[5613,[],""]
5:I[1778,[],""]
4:["id","1702459182837","d"]
0:["SN0qCiPbAaKKSAlQfIuYC",[[["",{"children":["note",{"children":[["id","1702459182837","d"],{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],["",{"children":["note",{"children":[["id","1702459182837","d"],{"children":["__PAGE__",{},["$L1","$L2",null]]},["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","note","children","$4","children"],"loading":["$","div",null,{"className":"note skeleton-container","role":"progressbar","aria-busy":"true","children":[["$","div",null,{"className":"note-header","children":[["$","div",null,{"className":"note-title skeleton","style":{"height":"3rem","width":"65%","marginInline":"12px 1em"}}],["$","div",null,{"className":"skeleton skeleton--button","style":{"width":"8em","height":"2.5em"}}]]}],["$","div",null,{"className":"note-preview","children":[["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}]]}]]}],"loadingStyles":[],"loadingScripts":[],"hasLoading":true,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}]]},["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","note","children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}]]},[null,"$L6",null]],[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/10169c963ccea784.css","precedence":"next","crossOrigin":"$undefined"}]],"$L7"]]]]
9:I[5250,["250","static/chunks/250-3c648b94097e3c7b.js","156","static/chunks/app/note/%5Bid%5D/page-5070a024863ac55b.js"],""]
6:["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","div",null,{"className":"container","children":["$","div",null,{"className":"main","children":["$L8",["$","section",null,{"className":"col note-viewer","children":["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[],"styles":null}]}]]}]}]}]}]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","link","2",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}]]
1:null
2:["$","div",null,{"className":"note","children":[["$","div",null,{"className":"note-header","children":[["$","h1",null,{"className":"note-title","children":"3qui est"}],["$","div",null,{"className":"note-menu","role":"menubar","children":[["$","small",null,{"className":"note-updated-at","role":"status","children":["Last updated on ","2023-12-19 05:33:09"]}],["$","$L9",null,{"href":"/note/edit/1702459182837","className":"link--unstyled","children":["$","button",null,{"className":"edit-button edit-button--outline","role":"menuitem","children":"Edit"}]}]]}]]}],["$","div",null,{"className":"note-preview","children":["$","div",null,{"className":"text-with-markdown","dangerouslySetInnerHTML":{"__html":"<p>est rerum tempore vitae sequi sint</p>\n"}}]}]]}]
a:"$Sreact.suspense"
8:["$","section",null,{"className":"col sidebar","children":[["$","$L9",null,{"href":"/","className":"link--unstyled","children":["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"/logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":["$","$L9",null,{"href":"/note/edit/","className":"link--unstyled","children":["$","button",null,{"className":"edit-button edit-button--solid","role":"menuitem","children":"New"}]}]}],["$","nav",null,{"children":["$","$a",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"$Lb"}]}]]}]
c:I[610,["250","static/chunks/250-3c648b94097e3c7b.js","185","static/chunks/app/layout-7bae744084688543.js"],""]
b:["$","ul",null,{"className":"notes-list","children":[["$","li","1702459182837",{"children":["$","$Lc",null,{"id":"1702459182837","title":"3qui est","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"est rerum tempore vi"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"3qui est"}],["$","small",null,{"children":"2023-12-19 05:33:09"}]]}]}]}],["$","li","1702459181837",{"children":["$","$Lc",null,{"id":"1702459181837","title":"sunt aut","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"quia et suscipit sus"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"sunt aut"}],["$","small",null,{"children":"2023-12-13 05:19:48"}]]}]}]}],["$","li","1702459188837",{"children":["$","$Lc",null,{"id":"1702459188837","title":"ea molestias","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"et iusto sed quo iur"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"ea molestias"}],["$","small",null,{"children":"2023-12-13 05:19:48"}]]}]}]}]]}]

此时重定向地址为 /note/1702459182837,从响应的内容中可以看出,其中包含了渲染后的笔记列表和此条笔记的具体内容。该内容也是流式加载的,所以内容会逐步渲染出来。比如我们把 /note/[id]/page.jssleep 设置为 10s,/components/SidebarNoteList.js的 sleep 设置为 3s,效果如下:

ReactNotes更新流式渲染.gif

点击后,左侧笔记列表 3s 后发生了变化,右侧笔记预览 10s 后发生了变化。

所以当提交表单的时候发生了什么呢?其实就是将数据以 POST 请求提交给当前页面,服务端根据 Server Actions 中的定义进行处理。Next.js 怎么实现的呢?其实就相当于替你写了原本用于交互的接口。

3. 渐进式增强

使用 Server Actions 的一大好处就是渐进式增强,也就是说,即便你禁用了 JavaScript,照样可以生效。现在让我们查看 DoneDelete按钮的源码:

image.png

按钮的 formaction 属性变成了:

javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you're trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')"

这说明……代码写的有问题……

现在我们提交表单的代码为:

<form className="note-editor-menu" role="menubar">
  <button formAction={() => saveNote(noteId, title, body)}>
    Done
  </button>
</form>

虽然这种写法也可以生效,但在禁用 JavaScript 的时候会失效,为了避免这个错误,最好是像下面这样写:

<form className="note-editor-menu" role="menubar">
  <button formAction={saveNote}>
    Done
  </button>
</form>

那么 noteId 该如何传入呢?我们可以使用传统的隐藏 input:

<input type="hidden" name="noteId" value={noteId} />

现在让我们重新写下 components/NoteEditor.js 的代码:

'use client'

import { useState } from 'react'
import NotePreview from '@/components/NotePreview'
import { useFormStatus } from 'react-dom'
import { deleteNote, saveNote } from '../app/actions'

export default function NoteEditor({
  noteId,
  initialTitle,
  initialBody
}) {

  const { pending } = useFormStatus()
  const [title, setTitle] = useState(initialTitle)
  const [body, setBody] = useState(initialBody)
  const isDraft = !noteId

  return (
    <div className="note-editor">
      <form className="note-editor-form" autoComplete="off">
        <div className="note-editor-menu" role="menubar">
          <input type="hidden" name="noteId" value={noteId} />
          <button
            className="note-editor-done"
            disabled={pending}
            type="submit"
            formAction={saveNote}
            role="menuitem"
          >
            <img
              src="/checkmark.svg"
              width="14px"
              height="10px"
              alt=""
              role="presentation"
            />
            Done
          </button>
          {!isDraft && (
            <button
              className="note-editor-delete"
              disabled={pending}
              formAction={deleteNote}
              role="menuitem"
            >
              <img
                src="/cross.svg"
                width="10px"
                height="10px"
                alt=""
                role="presentation"
              />
              Delete
            </button>
          )}
        </div>
        <label className="offscreen" htmlFor="note-title-input">
          Enter a title for your note
        </label>
        <input
          id="note-title-input"
          type="text"
          name="title"
          value={title}
          onChange={(e) => {
            setTitle(e.target.value)
          }}
        />
        <label className="offscreen" htmlFor="note-body-input">
          Enter the body for your note
        </label>
        <textarea
          name="body"
          value={body}
          id="note-body-input"
          onChange={(e) => setBody(e.target.value)}
        />
      </form>
      <div className="note-editor-preview">
        <div className="label label--preview" role="status">
          Preview
        </div>
        <h1 className="note-title">{title}</h1>
        <NotePreview>{body}</NotePreview>
      </div>
    </div>
  )
}

app/actions.js的代码为:

'use server'

import { redirect } from 'next/navigation'
import {addNote, updateNote, delNote} from '@/lib/redis';
import { revalidatePath } from 'next/cache';

export async function saveNote(formData) {

  const noteId = formData.get('noteId')

  const data = JSON.stringify({
    title: formData.get('title'),
    content: formData.get('body'),
    updateTime: new Date()
  })

  if (noteId) {
    updateNote(noteId, data)
    revalidatePath('/', 'layout')
    redirect(`/note/${noteId}`)
  } else {
    const res = await addNote(data)
    revalidatePath('/', 'layout')
    redirect(`/note/${res}`)
  }

}

export async function deleteNote(formData) {
  const noteId = formData.get('noteId')

  delNote(noteId)
  revalidatePath('/', 'layout')
  redirect('/')
}

此时再查看 DoneDelete 按钮元素:

image.png

此时就没有刚才的错误信息了。现在让我们在开发者工具中禁用 JavaScript,你会发现表单依然能用:

ReactNotes停用JS.gif

当然在这个例子中,因为禁用了 JavaScript,所以左侧的笔记列表加载不出来,更改内容的时候右边也不会实时渲染,但至少表单提交成功了。

4. useFormState 与 useFormStatus

React 的 useFormStateuseFormStatus 非常适合搭配 Server Actions 使用。useFormState 用于根据 form action 的结果更新表单状态,useFormStatus 用于在提交表单时显示待处理状态。

比如使用 useFormStatus 实现表单提交时按钮的禁用效果:

export default function NoteEditor() {
  const { pending } = useFormStatus()

  return (
    <button disabled={pending}> Done </button>
  )
}

又或者在提交的时候按钮的文字变成 Saving

export default function NoteEditor() {
  const { pending } = useFormStatus()

  return (
    <button> { pending ? 'Saving' : 'Done' } </button>
  )
}

注意使用 useFormStatus 的时候,建议将按钮抽离成单独的组件,在组件中使用 useFormStatus

现在让我们修改下项目的效果,当点击 Done 的时候,不再重定向,而是出现 Add Success!提示,我们再加入 useFormState重写下 components/NoteEditor.js 的代码:

'use client'

import { useState } from 'react'
import NotePreview from '@/components/NotePreview'
import { useFormState } from 'react-dom'
import { deleteNote, saveNote } from '../app/actions'
import SaveButton from '@/components/SaveButton'
import DeleteButton from '@/components/DeleteButton'

const initialState = {
  message: null,
}

export default function NoteEditor({
  noteId,
  initialTitle,
  initialBody
}) {

  const [saveState, saveFormAction] = useFormState(saveNote, initialState)
  const [delState, delFormAction] = useFormState(deleteNote, initialState)

  const [title, setTitle] = useState(initialTitle)
  const [body, setBody] = useState(initialBody)
  
  const isDraft = !noteId

  return (
    <div className="note-editor">
      <form className="note-editor-form" autoComplete="off">
        <div className="note-editor-menu" role="menubar">
          <input type="hidden" name="noteId" value={noteId} />
          <SaveButton formAction={saveFormAction} />
          <DeleteButton isDraft={isDraft} formAction={delFormAction} />
        </div>
        <div className="note-editor-menu">
          { saveState?.message }
        </div>
        <label className="offscreen" htmlFor="note-title-input">
          Enter a title for your note
        </label>
        <input
          id="note-title-input"
          type="text"
          name="title"
          value={title}
          onChange={(e) => {
            setTitle(e.target.value)
          }}
        />
        <label className="offscreen" htmlFor="note-body-input">
          Enter the body for your note
        </label>
        <textarea
          name="body"
          value={body}
          id="note-body-input"
          onChange={(e) => setBody(e.target.value)}
        />
      </form>
      <div className="note-editor-preview">
        <div className="label label--preview" role="status">
          Preview
        </div>
        <h1 className="note-title">{title}</h1>
        <NotePreview>{body}</NotePreview>
      </div>
    </div>
  )
}

我们将 Done 和 Delete 按钮抽离成了两个组件。

components/SaveButton.js代码如下:

import { useFormStatus } from 'react-dom'

export default function EditButton({ formAction }) {
  const { pending } = useFormStatus()
  return (
    <button
      className="note-editor-done"
      type="submit"
      formAction={formAction}
      disabled={pending}
      role="menuitem"
    >
      <img
        src="/checkmark.svg"
        width="14px"
        height="10px"
        alt=""
        role="presentation"
      />
      {pending ? 'Saving' : 'Done'}
    </button>
  );
}

components/DeleteButton.js代码如下:

import { useFormStatus } from 'react-dom'

export default function DeleteButton({ isDraft, formAction }) {
  const { pending } = useFormStatus()
  return !isDraft && (
      <button
        className="note-editor-delete"
        disabled={pending}
        formAction={formAction}
        role="menuitem"
      >
        <img
          src="/cross.svg"
          width="10px"
          height="10px"
          alt=""
          role="presentation"
        />
        Delete
      </button>
    )
}

app/actions.js的代码为:

'use server'

import { redirect } from 'next/navigation'
import {addNote, updateNote, delNote} from '@/lib/redis';
import { revalidatePath } from 'next/cache';
const sleep = ms => new Promise(r => setTimeout(r, ms));

export async function saveNote(prevState, formData) {

  const noteId = formData.get('noteId')

  const data = JSON.stringify({
    title: formData.get('title'),
    content: formData.get('body'),
    updateTime: new Date()
  })

  // 为了让效果更明显
  await sleep(2000)

  if (noteId) {
    updateNote(noteId, data)
    revalidatePath('/', 'layout')
  } else {
    const res = await addNote(data)
    revalidatePath('/', 'layout')
  }
  return { message: `Add Success!` }
}

export async function deleteNote(prevState, formData) {
  const noteId = formData.get('noteId')
  delNote(noteId)
  revalidatePath('/', 'layout')
  redirect('/')
}

此时再点击 Done 按钮:

ReactNotes-useForm.gif 当点击 Done 按钮的时候,DoneDelete 按钮都出现了 disabled 样式(毕竟这两个按钮在一个表单内),2s 后,出现 Add Success! 提示。

5. 数据校验

如果需要对数据进行校验,Next.js 推荐使用 zod,我们使用 zod 修改下 /app/actions.js

'use server'

import { redirect } from 'next/navigation'
import {addNote, updateNote, delNote} from '@/lib/redis';
import { revalidatePath } from 'next/cache';
import { z } from "zod";

const schema = z.object({
  title: z.string(),
  content: z.string().min(1, '请填写内容').max(100, '字数最多 100')
});

const sleep = ms => new Promise(r => setTimeout(r, ms));

export async function saveNote(prevState, formData) {

  // 获取 noteId
  const noteId = formData.get('noteId')
  const data = {
    title: formData.get('title'),
    content: formData.get('body'),
    updateTime: new Date()
  }

  // 校验数据
  const validated = schema.safeParse(data)
  if (!validated.success) {
    return {
      errors: validated.error.issues,
    }
  }

  // 模拟请求时间
  await sleep(2000)

  // 更新数据库
  if (noteId) {
    await updateNote(noteId, JSON.stringify(data))
    revalidatePath('/', 'layout')
  } else {
    await addNote(JSON.stringify(data))
    revalidatePath('/', 'layout')
  }
  
  return { message: `Add Success!` }
}

export async function deleteNote(prevState, formData) {
  const noteId = formData.get('noteId')
  delNote(noteId)
  revalidatePath('/', 'layout')
  redirect('/')
}

components/NoteEditor.js代码如下:

'use client'

import { useEffect, useRef, useState } from 'react'
import NotePreview from '@/components/NotePreview'
import { useFormState } from 'react-dom'
import { deleteNote, saveNote } from '../app/actions'
import SaveButton from '@/components/SaveButton'
import DeleteButton from '@/components/DeleteButton'

const initialState = {
  message: null,
}

export default function NoteEditor({
  noteId,
  initialTitle,
  initialBody
}) {

  const [saveState, saveFormAction] = useFormState(saveNote, initialState)
  const [delState, delFormAction] = useFormState(deleteNote, initialState)

  const [title, setTitle] = useState(initialTitle)
  const [body, setBody] = useState(initialBody)

  const isDraft = !noteId

  useEffect(() => {
    if (saveState.errors) {
      // 处理错误
      console.log(saveState.errors)
    }
  }, [saveState])

  return (
    <div className="note-editor">
      <form className="note-editor-form" autoComplete="off">
        <input type="hidden" name="noteId" value={noteId || ''} />
        <div className="note-editor-menu" role="menubar">
          <SaveButton formAction={saveFormAction} />
          <DeleteButton isDraft={isDraft} formAction={delFormAction} />
        </div>
        <div className="note-editor-menu">
          { saveState?.message }
          { saveState.errors && saveState.errors[0].message }
        </div>
        <label className="offscreen" htmlFor="note-title-input">
          Enter a title for your note
        </label>
        <input
          id="note-title-input"
          type="text"
          name="title"
          value={title}
          onChange={(e) => {
            setTitle(e.target.value)
          }}
        />
        <label className="offscreen" htmlFor="note-body-input">
          Enter the body for your note
        </label>
        <textarea
          name="body"
          value={body}
          id="note-body-input"
          onChange={(e) => setBody(e.target.value)}
        />
      </form>
      <div className="note-editor-preview">
        <div className="label label--preview" role="status">
          Preview
        </div>
        <h1 className="note-title">{title}</h1>
        <NotePreview>{body}</NotePreview>
      </div>
    </div>
  )
}

实现效果如下:

ReactNotes-zod.gif

6. 最佳实践:Server Actions

写 Server Actions 基本要注意的点就这些了,定义在 actions 的代码要注意:

  1. formData 中获取提交的数据
  2. 使用 zod 进行数据校验
  3. 使用 revlidate 更新数据缓存
  4. 返回合适的信息

定义表单的代码要注意:

  1. 搭配使用 useFormStateuseFormStatus
  2. 特殊数据使用隐藏 input 提交

总结

那么今天的内容就结束了,本篇我们完善了笔记的编辑效果,了解了客户端组件与服务端组件的划分以及在实战中使用 Server Actions,学习书写 Server Actions 时的注意事项和最佳实践。

本篇的代码我已经上传到代码仓库的 Day 4 分支:https://github.com/mqyqingfeng/next-react-notes-demo/tree/day4,本篇的不同版本以不同的 commit 进行了提交,此外直接使用的时候不要忘记在本地开启 Redis。

© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru