Next.js v14 实现乐观更新,面向未来的 UI 更新方式,你可以不去做,但你不应该不了解

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

所谓乐观更新,举个例子,当用户在 ToDoList 中添加一项 ToDo 的时候,传统的做法是等待接口返回成功时再更新 UI。乐观更新是先更新 UI,同时发送数据请求。如果数据请求成功,相安无事,用户感受到流畅的操作,提升了用户体验,数据也得到更新。如果更新失败,则视情况对错误进行处理。

一种交互效果如下:

React 为了实现乐观更新,提供了 useOptimistic 这个官方 hook(目前已经在 Canary 和实验阶段了),本篇我们不仅会介绍 useOptimistic,还会用 Next.js v14,结合最新的 Server Actions 特性来实现乐观更新。

同时我们会讲解在出现错误的时候,如何进行撤回或者重置。以及处理一个有意思的问题:乐观更新的时候,用户要关闭网页怎么办?

PS:其实乐观更新并不是一个新潮的思想,很多年前就有人开始做了,但是大家普遍不会去实现乐观更新,一是产品、设计不会过多考虑网速慢的情况,二是就算手动实现乐观更新,虽然并不复杂,但是有一些麻烦,接口那么多,我都加个乐观更新,代码写着写着也可能乱糟糟了,何必去实现呢?

归根到底还是实现成本太高。所以 本篇会结合 Next.js 和 useOptimistic 讲解如何低成本并考虑全面的实现一个乐观更新。 欢迎收藏点赞本篇文章,万一以后用到了呢?如果有关于乐观更新的经验和看法,欢迎留言评论!

PS:学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

废话不多说,让我们直接开始吧!

创建 Next.js 项目

使用 Next.js 官方脚手架创建项目:

bash 复制代码
npx create-next-app@latest

运行效果如下:

为了样式美观,我们会用到 Tailwind CSS,所以注意勾选 Tailwind CSS,其他随意。

进入项目目录,开启本地模式,检查项目是否能够启动成功:

bash 复制代码
npm i && npm run dev

我们以实现这样一个 ToDoList 为例进行讲解:

涉及的文件和目录结构如下:

javascript 复制代码
app               
└─ todo           
   ├─ actions.js  
   ├─ page.js     
   └─ todo.js         

新建 app/todo/page.js,代码如下:

javascript 复制代码
import { findToDos } from './actions';
import ToDoList from './todo';

export default async function Page() {
  const todos = await findToDos();
  return (
    <ToDoList todos={todos} />
  )
}

新建 app/todo/todo.js,代码如下:

javascript 复制代码
'use client'

import { useRef } from 'react'
import { createToDo } from './actions';

export default function ToDoList({ todos }) {
  const formRef = useRef(null);

  return (
    <div className="p-10">
      <form className="space-y-6" ref={formRef} action={async (formData) => {
        await createToDo(formData)
        formRef.current?.reset()
      }}>
        <div>
          <label htmlFor="todo" className="block text-sm font-medium leading-6 text-gray-900">
            添加一项任务列表
          </label>
          <div className="mt-2">
            <input id="todo" name="todo" type="todo" required
              className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
            />
          </div>
        </div>
        <button
          type="submit"
          className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
        >
          添加任务
        </button>
      </form>
      <ul role="list" className="divide-y divide-gray-100 list-decimal mt-4 list-inside">
        {todos.map((todo, i) => (
          <li key={i} className=" py-2">
            {todo}
          </li>
        ))}
      </ul>
    </div>
  )
}

新建 app/todo/actions.js,代码如下:

javascript 复制代码
'use server'

import { revalidatePath } from "next/cache";

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

let data = ['阅读', '写作', '冥想']
 
export async function findToDos() {
  return data
}

export async function createToDo(formData) {
  await sleep(2500)
  const todo = formData.get('todo')
  data.push(todo)
  revalidatePath("/todo");
}

我们使用 sleep 函数来模拟接口请求的费时,这里我们添加了一个 2.5s 的延时,此时访问 http://localhost:3000/todo,交互效果如下:

当点击"添加任务"的时候,请求立刻发出,2.5s 后接口返回成功。此时表单清空,任务内容添加到下方的任务列表中。

如果接口返回快,这个过程其实还算流畅。但如果接口慢了,这种停顿感就让人感到不快了......那不妨用乐观更新试试。

React useOptimistic hook

我们先讲讲 React 新增的 useOptimistic hook。

useOptimistic,顾名思义,就是用来处理乐观更新。它允许你在进行异步操作时显示不同 state。它接受 state 作为参数,并返回该 state 的副本,在异步操作(如网络请求)期间可以不同。你需要提供一个函数,该函数接受当前 state 和操作的输入,并返回在操作挂起期间要使用的乐观状态。

这个状态被称为"乐观"状态是因为通常用于立即向用户呈现执行操作的结果,即使实际上操作需要一些时间来完成:

javascript 复制代码
import { useOptimistic } from 'react';

function AppContainer() {
  const [optimisticState, addOptimistic] = useOptimistic(
    state,
    // 更新函数
    (currentState, optimisticValue) => {
      // 使用乐观值
      // 合并并返回新 state
    }
  );
}

React 官方提供了完整可用的示例代码:

javascript 复制代码
import { useOptimistic, useState, useRef } from "react";
import { deliverMessage } from "./actions.js";

function Thread({ messages, sendMessage }) {
  const formRef = useRef();
  async function formAction(formData) {
    addOptimisticMessage(formData.get("message"));
    formRef.current.reset();
    await sendMessage(formData);
  }
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage) => [
      ...state,
      {
        text: newMessage,
        sending: true
      }
    ]
  );

  return (
    <>
      {optimisticMessages.map((message, index) => (
        <div key={index}>
          {message.text}
          {!!message.sending && <small> (Sending...)</small>}
        </div>
      ))}
      <form action={formAction} ref={formRef}>
        <input type="text" name="message" placeholder="Hello!" />
        <button type="submit">Send</button>
      </form>
    </>
  );
}

export default function App() {
  const [messages, setMessages] = useState([
    { text: "Hello there!", sending: false, key: 1 }
  ]);
  async function sendMessage(formData) {
    const sentMessage = await deliverMessage(formData.get("message"));
    setMessages((messages) => [...messages, { text: sentMessage }]);
  }
  return <Thread messages={messages} sendMessage={sendMessage} />;
}

至于这个例子中的 actions.js的代码则很简单:

javascript 复制代码
export async function deliverMessage(message) {
  await new Promise((res) => setTimeout(res, 1000));
  return message;
}

其实乐观更新,我们自己也很容易实现,主要是 2 步:

  1. 调用接口的时候设置一个状态,我们称之为乐观状态
  2. 当接口数据返回的时候更新状态

理解 useOptimistic 的使用其实也就是这两步,一是明白如何设置乐观状态,一是如何更新为最新的状态,让我们将刚才的示例代码简化一下:

javascript 复制代码
import { useOptimistic } from "react";

function Thread({ messages, sendMessage }) {
  async function formAction(formData) {
    // 3. 接口调用的时候通过 addOptimisticMessage 设置乐观状态 
    addOptimisticMessage(...);
    await sendMessage(formData);
  }

  // 1. 使用乐观更新
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(...);

  return (
    <>
      //  2. 使用 optimisticMessages 渲染列表
      {optimisticMessages.map(...)}
      <form action={formAction}>
        // ...
      </form>
    </>
  );
}

export default function App() {
  const [messages, setMessages] = useState(...);
  
  async function sendMessage(formData) {
    // 4. 在这里调用接口,接口返回的时候设置父级状态,optimisticMessages 会自动更新
    const sentMessage = await deliverMessage(...);
    setMessages(...);
  }
  return <Thread messages={messages} sendMessage={sendMessage} />;
}

试想如果我们用 useState 来实现乐观更新,当接口数据返回的时候,我们还需要在 Thread 组件中,监听 messages 数据的改变,然后设置为最新的状态。使用 useOptimistic 则会自动更新,省了不少代码。

Next.js 与 useOptimistic

理解了 useOptimistic 的用法,那就让我们在 Next.js 项目中使用 useOptimistic 吧。

回到我们的项目,修改 app/todo/todo.js,代码如下:

javascript 复制代码
'use client'

import { useRef, useOptimistic } from 'react'
import { createToDo } from './actions';

export default function ToDoList({ todos }) {
  const formRef = useRef(null);

  const [optimisticToDoList, addOptimistic] = useOptimistic( todos, (currentState, optimisticValue) => {
      return [
        ...currentState,
        optimisticValue
      ]
    }
  );

  return (
    <div className="p-10">
      <form className="space-y-6" ref={formRef} action={async (formData) => {
        addOptimistic(formData.get("todo"))
        formRef.current?.reset()
        await createToDo(formData)
      }}>
        <div>
          <label htmlFor="todo" className="block text-sm font-medium leading-6 text-gray-900">
            添加一项任务列表
          </label>
          <div className="mt-2">
            <input id="todo" name="todo" type="todo" required
              className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
            />
          </div>
        </div>
        <button
          type="submit"
          className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
        >
          添加任务
        </button>
      </form>
      <ul role="list" className="divide-y divide-gray-100 list-decimal mt-4 list-inside">
        {optimisticToDoList.map((todo, i) => (
          <li key={i} className="py-2">
            {todo}
          </li>
        ))}
      </ul>
    </div>
  )
}

不需要进行其他的修改,就实现了乐观更新,此时交互效果如下:

当点击"添加任务"的时候,表单清空,任务内容立刻添加到下方的任务列表中,同时请求发出,2.5s 后接口返回成功。

错误处理

我知道大家肯定要问,如果接口返回错误了怎么办?

不同于 React 官方示例中直接使用 useState 来更新状态,在 Next.js 中,当调用 revalidatePath 等重新验证方法的时候,会返回最新的数据,Next.js 会根据最新的数据自动进行状态更新。

所以面对错误处理,我们需要用 try catch 捕获错误,以及无论成功与否,都触发重新验证,返回最新的数据。所以修改 app/todo/actions.js,代码如下:

javascript 复制代码
'use server'

import { revalidatePath } from "next/cache";

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

let data = ['阅读', '写作', '冥想']

export async function findToDos() {
  return data
}

export async function createToDo(formData) {
  try {
    await sleep(2500)
    throw new Error('error')
    const todo = formData.get('todo')
    data.push(todo)
  } catch (error) {
    return { error: 'something is wrong' }
  } finally {
    revalidatePath("/todo");
  }
}

此时交互效果如下:

当点击"添加任务"的时候,表单清空,任务内容立刻添加到下方的任务列表中,同时请求发出,2.5s 后接口返回。这是一个 RSC 接口,会包含最新的数据(也就是更新失败后的最新数据,在这个例子中,数据跟之前是一样的),于是页面状态更新,添加的数据被"撤回"了。

当然你也可以根据接口返回的数据,给与一个更为明显的错误提醒。修改 app/todo/todo.js中的表单 action 函数如下:

javascript 复制代码
<form className="space-y-6" ref={formRef} action={async (formData) => {
  addOptimistic(formData.get("todo"))
  formRef.current?.reset()
  const res = await createToDo(formData)
  if (res?.error) {
    alert('任务添加失败!请重新添加!')
  }
}}>

交互效果如下:

用户要离开了怎么办?

假设这个接口实在是太慢了,比如 10s 才返回,当任务内容添加到任务列表的时候,用户就会认为添加成功,他才不管你乐观悲观更新呢,然后他就要关闭网页走了,请问此时该怎么办?

一种解决方案是添加加载状态,既然用户认为添加到任务列表就算添加成功,那就在添加的时候,在任务旁边添加一个加载状态,让用户知道,此任务还在添加中,请不要随便离开。

修改 app/todo/todo.js,代码如下:

javascript 复制代码
'use client'

import { useRef, useOptimistic } from 'react'
import { createToDo } from './actions';

export default function ToDoList({ todos }) {
  const formRef = useRef(null);

  const [optimisticToDoList, addOptimistic] = useOptimistic( todos.map((i) => ({text: i})), (currentState, optimisticValue) => {
      return [
        ...currentState,
        {
          text: optimisticValue,
          sending: true
        }
      ]
    }
  );

  return (
    <div className="p-10">
      <form className="space-y-6" ref={formRef} action={async (formData) => {
        addOptimistic(formData.get("todo"))
        formRef.current?.reset()
        const res = await createToDo(formData)
        if (res?.error) {
          alert('任务添加失败!请重新添加!')
        }
      }}>
        <div>
          <label htmlFor="todo" className="block text-sm font-medium leading-6 text-gray-900">
            添加一项任务列表
          </label>
          <div className="mt-2">
            <input id="todo" name="todo" type="todo" required
              className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
            />
          </div>
        </div>
        <button
          type="submit"
          className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
        >
          添加任务
        </button>
      </form>
      <ul role="list" className="divide-y divide-gray-100 list-decimal mt-4 list-inside">
        {optimisticToDoList.map(({text, sending}, i) => (
          <li key={i} className="py-2">
            {text} {!!sending && <small> (Adding...)</small>}
          </li>
        ))}
      </ul>
    </div>
  )
}

注释掉 actions.js中的抛出错误代码,此时交互效果如下:

第二种解决方案就是监听表单提交状态,如果还在处理中,那就监听页面 unload 事件,给与用户离开提醒。为此我们需要用到 useFormStatus,这也是 React 的官方 hook。

修改 app/todo/todo.js,代码如下:

jsx 复制代码
'use client'

import { useRef, useOptimistic, useEffect } from 'react'
import { useFormStatus } from 'react-dom'
import { createToDo } from './actions';

export function SubmitButton() {
  const state = useFormStatus()

  useEffect(() => {
    function handler(e) {
      if (!state.pending) return;
      e.preventDefault();
    }

    window.addEventListener("beforeunload", handler);

    return () => {
      window.removeEventListener("beforeunload", handler);
    }
  }, [state.pending])

  return (
    <button
      type="submit"
      className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
      >
      添加任务
    </button>
  )
}

export default function ToDoList({ todos }) {
  const formRef = useRef(null);

  const [optimisticToDoList, addOptimistic] = useOptimistic(todos.map((i) => ({ text: i })), (currentState, optimisticValue) => {
    return [
      ...currentState,
      {
        text: optimisticValue,
        sending: true
      }
    ]
  }
                                                           );

  return (
    <div className="p-10">
      <form className="space-y-6" ref={formRef} action={async (formData) => {
      addOptimistic(formData.get("todo"))
      formRef.current?.reset()
      const res = await createToDo(formData)
      if (res?.error) {
        alert('任务添加失败!请重新添加!')
      }
    }}>
        <div>
          <label htmlFor="todo" className="block text-sm font-medium leading-6 text-gray-900">
            添加一项任务列表
          </label>
          <div className="mt-2">
            <input id="todo" name="todo" type="todo" required
              className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
              />
          </div>
        </div>
        <SubmitButton />
      </form>
      <ul role="list" className="divide-y divide-gray-100 list-decimal mt-4 list-inside">
        {optimisticToDoList.map(({ text, sending }, i) => (
      <li key={i} className="py-2">
        {text} {!!sending && <small> (Adding...)</small>}
      </li>
    ))}
      </ul>
    </div>
  )
}

此时交互效果如下:

可惜浏览器的弹窗文案已经不能自定义,否则效果会更好。

总结

本篇我们讲解了乐观更新的概念,以及如何在 Next.js 项目中使用乐观更新。实现乐观更新并不复杂,相信随着 hook 的推广,实现成本的降低,以及大家在交互体验上越来越卷,乐观更新会是未来前端开发的必修功课。

PS:如果对 Next.js 不熟悉,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

相关推荐
xjt_09016 分钟前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农18 分钟前
Vue 2.3
前端·javascript·vue.js
夜郎king42 分钟前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳1 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵2 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星2 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_2 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js
未来龙皇小蓝2 小时前
RBAC前端架构-01:项目初始化
前端·架构
程序员agions2 小时前
2026年,微前端终于“死“了
前端·状态模式
万岳科技系统开发2 小时前
食堂采购系统源码库存扣减算法与并发控制实现详解
java·前端·数据库·算法