Nextjs15 实战 - React Notes之SidebarNoteList优化和Suspense的使用

current branch 对应如下文档

redis

ioredis

本专栏内容均可在Github:notes_02 找到

完整项目使用技术栈:

Nextjs15 + MySQL + Redis + Auth + Prisma + i18n + strapi + Docker + vercel

一、本节目标

实现笔记列表展开回收和 Suspense 的实践

二、修改根布局和其他页面

修改 app/components/SidebarNoteList.tsx

ts 复制代码
import SidebarNoteItem from "@/components/SidebarNoteItem";

export default async function NoteList({ notes }) {
  const arr = Object.entries(notes);

  if (arr.length == 0) {
    return <div className="notes-empty">{"No notes created yet!"}</div>;
  }

  return (
    <ul className="notes-list">
      {arr.map(([noteId, note]) => {
        return (
          <li key={noteId}>
            <SidebarNoteItem noteId={noteId} note={JSON.parse(note)} />
          </li>
        );
      })}
    </ul>
  );
}

这里我们将具体的每条笔记抽离成单独的 SidebarNoteItem 组件,

app/components/SidebarNoteItem.tsx 代码如下:

ts 复制代码
import dayjs from "dayjs";
import SidebarNoteItemContent from "@/components/SidebarNoteItemContent";

export default function SidebarNoteItem({ noteId, note }) {
  const { title, content = "", updateTime } = note;
  return (
    <SidebarNoteItemContent
      id={noteId}
      title={note.title}
      expandedChildren={
        <p className="sidebar-note-excerpt">
          {content.substring(0, 20) || <i>(No content)</i>}
        </p>
      }
    >
      <header className="sidebar-note-header">
        <strong>{title}</strong>
        <small>{dayjs(updateTime).format("YYYY-MM-DD hh:mm:ss")}</small>
      </header>
    </SidebarNoteItemContent>
  );
}

这里我们又抽离了一个 SidebarNoteItemContent 组件,用来实现展开和收回功能,我们将笔记的标题和时间的 JSX 作为 children 传递给了 SidebarNoteItemContent

app/components/SidebarNoteItemContent.tsx 代码如下:

ts 复制代码
"use client";

import { useState, useRef, useEffect, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";

export default function SidebarNoteContent({
  id,
  title,
  children,
  expandedChildren,
}) {
  const router = useRouter();
  const pathname = usePathname();
  const selectedId = pathname?.split("/")[1] || null;

  const [isPending] = useTransition();
  const [isExpanded, setIsExpanded] = useState(false);
  const isActive = id === selectedId;

  // Animate after title is edited.
  const itemRef = useRef(null);
  const prevTitleRef = useRef(title);

  useEffect(() => {
    if (title !== prevTitleRef.current) {
      prevTitleRef.current = title;
      itemRef.current.classList.add("flash");
    }
  }, [title]);

  return (
    <div
      ref={itemRef}
      onAnimationEnd={() => {
        itemRef.current.classList.remove("flash");
      }}
      className={[
        "sidebar-note-list-item",
        isExpanded ? "note-expanded" : "",
      ].join(" ")}
    >
      {children}
      <button
        className="sidebar-note-open"
        style={{
          backgroundColor: isPending
            ? "var(--gray-80)"
            : isActive
            ? "var(--tertiary-blue)"
            : "",
          border: isActive
            ? "1px solid var(--primary-border)"
            : "1px solid transparent",
        }}
        onClick={() => {
          const sidebarToggle = document.getElementById("sidebar-toggle");
          if (sidebarToggle) {
            sidebarToggle.checked = true;
          }
          router.push(`/note/${id}`);
        }}
      >
        Open note for preview
      </button>
      <button
        className="sidebar-note-toggle-expand"
        onClick={(e) => {
          e.stopPropagation();
          setIsExpanded(!isExpanded);
        }}
      >
        {isExpanded ? (
          <img
            src="/chevron-down.svg"
            width="10px"
            height="10px"
            alt="Collapse"
          />
        ) : (
          <img src="/chevron-up.svg" width="10px" height="10px" alt="Expand" />
        )}
      </button>
      {isExpanded && expandedChildren}
    </div>
  );
}

这里 SidebarNoteItemContent 具体的实现其实并不重要,你只要知道这是一个客户端组件就行了。在这个客户端组件里我们用了 useState 来控制展开和收回的状态,然后添加了一些动画效果,仅此而已。如果步骤正确的话,此时的页面效果为:

这个时候你可能会有个疑问:为什么要这样做呢?为什么不直接把 SidebarNoteItem 声明为客户端组件,然后直接在这个组件里全部实现呢?还要用传递 children 这么复杂的方式?

考验你是否认真学习了之前的知识到了!

在这段代码中,SidebarNoteItem 是一个服务端组件,在这个组件中我们引入了 dayjs 这个库,然而我们却是在 SidebarNoteItemContent 这个客户端组件中使用的 dayjs。请问最终客户端的 bundle 中是否会打包 dayjs 这个库?

答案是不会。在服务端组件中使用 JSX 作为传递给客户端组件的 propJSX 会先进行服务端组件渲染,再发送到客户端组件中。也就是说,发送给客户端组件的并不是:

ts 复制代码
<header className="sidebar-note-header">
  <strong>{title}</strong>
  <small>{dayjs(updateTime).format("YYYY-MM-DD hh:mm:ss")}</small>
</header>

而是编译后的如:

ts 复制代码
<header class="sidebar-note-header">
  <strong>ea molestias</strong>
  <small>2023-12-13 05:19:48</small>
</header>

三、使用 Suspense 实现骨架图的效果

因为我们现在将笔记列表数据的获取放在了顶层,所以直接为 SidebarNoteList 添加 Suspense 是没有效果的,我们需要将数据获取改为在 SidebarNoteList 组件内部。

修改 app/components/Sidebar.tsx 代码如下:

ts 复制代码
import React, { Suspense } from "react";
import Link from "next/link";
import SidebarNoteList from "../components/SidebarNoteList";
import EditButton from "./EditButton";
import NoteListSkeleton from "./NoteListSkeleton";

export default async function Sidebar() {
  return (
    <>
      <section className="col sidebar">
        <Link href={"/"} className="link--unstyled">
          <section className="sidebar-header">
            <img
              className="logo"
              src="/favicon.ico"
              width="22px"
              height="20px"
              alt=""
              role="presentation"
            />
            <strong>React Notes</strong>
          </section>
        </Link>
        <section className="sidebar-menu" role="menubar">
          <EditButton noteId={null}>New</EditButton>
        </section>
        <nav>
          <Suspense fallback={<NoteListSkeleton />}>
            <SidebarNoteList />
          </Suspense>
        </nav>
      </section>
    </>
  );
}

添加 app/components/NoteListSkeleton.tsx,代码如下:

ts 复制代码
export default function NoteListSkeleton() {
  return (
    <div>
      <ul className="notes-list skeleton-container">
        <li className="v-stack">
          <div
            className="sidebar-note-list-item skeleton"
            style={{ height: "5em" }}
          />
        </li>
        <li className="v-stack">
          <div
            className="sidebar-note-list-item skeleton"
            style={{ height: "5em" }}
          />
        </li>
        <li className="v-stack">
          <div
            className="sidebar-note-list-item skeleton"
            style={{ height: "5em" }}
          />
        </li>
      </ul>
    </div>
  );
}

app/components/SidebarNoteList.tsx 代码如下,为了让效果更加明显,我们添加了一个 sleep 函数:

ts 复制代码
import SidebarNoteItem from "../components/SidebarNoteItem";
import { getAllNotes } from "@/lib/redis";

export default async function NoteList() {
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
  await sleep(1000);
  const notes = await getAllNotes();

  const arr = Object.entries(notes);
  if (arr.length == 0) {
    return <div className="notes-empty">{"No notes created yet!"}</div>;
  }

  return (
    <ul className="notes-list">
      {arr.map(([noteId, note]) => {
        return (
          <li key={noteId}>
            <SidebarNoteItem noteId={noteId} note={JSON.parse(note)} />
          </li>
        );
      })}
    </ul>
  );
}

此时页面效果如下:

那么问题来了,SidebarNoteListSuspense 和不用 Suspense,具体有什么改变呢?

我们把 sleep 的时间设置为 3s。这是不使用 Suspense 的效果,我们从 github 主页输入地址 http://localhost:3000/:

输入地址后,我们等待了大概 3s 后,页面突然完全展现。

这是使用 Suspense 的效果,我们还是从github 主页输入地址 http://localhost:3000/:

我们可以看到,使用 Suspense,数据加载不会阻塞页面,也就是说在笔记列表还在加载的时候,用户依然可以与页面其他部分进行交互,比如点击 New 按钮新建笔记。

相关推荐
素葩几秒前
深度解构React setState:从异步迷雾到Fiber内核的掌控之道
前端·javascript
@蓝莓果粒茶9 分钟前
LeetCode第131题_分割回文串
开发语言·前端·算法·leetcode·职场和发展·c#·.net
wending-Y11 分钟前
记录clickhouse记录一次性能优化,从60s到1s
前端·clickhouse·性能优化
*星星之火*24 分钟前
【GPT入门】第 34 课:深度剖析 ReAct Agent 工作原理及代码实现
前端·gpt·react.js
熊的猫39 分钟前
不想麻烦,就学会用 node 处理你的 excel 文件!
前端·javascript·vue.js
J船长1 小时前
App store常见 审核拒绝代码列表
前端
yinuo1 小时前
Tailwind CSS v4深度解析!新特性、优化、最佳实践
前端
掘金安东尼1 小时前
🧭 本周 Web 前端精选|2025年第14周
前端·javascript·面试
这里有鱼汤1 小时前
Python模块化开发,从此告别乱糟糟的代码!
前端·后端·python
这里有鱼汤1 小时前
高手也容易忽略的 Python 命名空间与作用域
前端·后端·python