JackyLove 的技术人生

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

第35章—实战篇ReactNotes侧边栏笔记列表

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

前言

本篇我们来实现 React Notes 的左侧侧边栏部分。

SidebarNoteList

现在我们接着完善笔记列表,毕竟笔记列表这里还要实现展开和收回功能:

展开收回.gif

修改 components/SidebarNoteList.js代码:

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 组件,components/SidebarNoteItem.js代码如下:

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 传递给了 SidebarNoteItemContentcomponents/SidebarNoteItemContent.js代码如下:

'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 来控制展开和收回的状态,然后添加了一些动画效果,仅此而已。如果步骤正确的话,此时的页面效果为:

展开收回2.gif

我们成功的实现了组件的展开和收回功能!

服务端组件和客户端组件

现在让我们回顾下此时的侧边栏组件结构情况:

我们声明了一个 Sidebar 组件用于实现侧边栏,其中有一个子组件 SidebarNoteList 用于实现侧边栏的笔记列表部分,针对每一条笔记,我们抽离了一个 SidebarNoteItem 组件来实现,在 SidebarNoteItem 中,我们又抽离了一个名为 SidebarNoteItemContent 的客户端组件用于实现展开和收回功能,然后我们在 SidebarNoteItem 这个服务端组件中将笔记的标题和时间这段 JSX 作为 children 传递给 SidebarNoteItemContent

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

使用指南

倒不着急回答这个问题。因为这段功能的实现涉及到我们开发 Next.js 项目常用的服务端组件和客户端组件导入,所以先让我们回顾下相关的使用知识(这很重要,开发的时候要谨记):

  1. 服务端组件可以导入客户端组件,但客户端组件并不能导入服务端组件
  2. 从服务端组件到客户端组件传递的数据需要可序列化,以刚才的例子为例:
// components/SidebarNoteItem.js

export default function SidebarNoteItem({ noteId, note}) {
	// ...
  return (
    <SidebarNoteItemContent
      id={noteId}
      title={note.title}
			fun={() => {}}
      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>
  );
}

所谓可序列化,简单的理解就是 JSON.stringify() 这段数据不会出现错误,如果我们在这里传递一个函数 fun={() => {}},就会出现错误提示:

image.png

但 JSX 对象是可以正常传递的,正如这个例子中展示的那样。

  1. 但你可以将服务端组件以 props 的形式传给客户端组件,其实刚才的实现里就展现了两种传递服务端组件的形式:
// components/SidebarNoteItem.js

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>
  );
}

服务端组件特性

现在让我们重新审视一下 SidebarNoteItem 的代码:

// components/SidebarNoteItem.js
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>
  );
}

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

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

关于这个结果,反正效果我们都实现了,直接去查看一下就知道了:

image.png

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

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

而是编译后的如:

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

其实这里我们也完全可以把 header 抽离成一个 SidebarNoteItemHeader 服务端组件,这样效果会更明显:

新建 /components/SidebarNoteItemHeader.js,代码如下:

import dayjs from 'dayjs';

export default function SidebarNoteItemHeader({title, updateTime}) {
  return (
      <header className="sidebar-note-header">
          <strong>{title}</strong>
          <small>{dayjs(updateTime).format('YYYY-MM-DD hh:mm:ss')}</small>
      </header>
  );
}

修改 SidebarNoteItem.js文件代码为:

import SidebarNoteItemContent from '@/components/SidebarNoteItemContent';
import SidebarNoteItemHeader from '@/components/SidebarNoteItemHeader';

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>
      }>
        <SidebarNoteItemHeader title={title} updateTime={updateTime} />
    </SidebarNoteItemContent>
  );
}

现在我们查看下开发者工具中的的 Source 选项:

截屏2023-12-15 下午8.45.30.png

你会发现 components 下并没有我们刚建立的 SidebarNoteItemHeader 组件,只有一个客户端组件 SidebarNoteItemContent,node_modules 下也没有 dayjs(如果你把 SidebarNoteItemHeader 声明为客户端组件就有了)。

最后让我们查看下 localhost 这个文件的 HTML:

截屏2023-12-15 下午4.06.39.png

传给客户端组件的 JSX 这些内容也会直接渲染到 HTML 中,当然这里跟客户端组件还是服务端组件无关,是客户端组件也会预渲染,只是提一下而已。

最佳实践:客户端组件下移

我们在 《渲染篇 | 服务端组件和客户端组件》中讲到 “尽可能将客户端组件在组件树中下移”,这里就是一个很好的例子。我们本可以直接把 SidebarNoteItem 声明为客户端组件,然后直接在这个组件里全部实现,但是却抽离了一个名为 SidebarNoteItemContent 的客户端组件用于实现展开和收回功能。

SidebarNoteItemContent 的内容原本是 SidebarNoteList 的子组件,现在却是 SidebarNoteItem 的子组件。虽然在组件树中的位置下移了,但我们却因此避免了 dayjs 这个库被打包到客户端 bundle 中。在开发的时候,应该尽可能缩减客户端组件的范围。

EditButton

NewEdit 按钮考虑到复用,我们单独抽离成一个 EditButton组件:

// components/EditButton.js
import Link from 'next/link'

export default function EditButton({noteId, children}) {
  const isDraft = noteId == null;
  return (
    <Link href={`/note/edit/${noteId || ''}`} className="link--unstyled">
      <button
        className={[
          'edit-button',
          isDraft ? 'edit-button--solid' : 'edit-button--outline',
        ].join(' ')}
        role="menuitem">
        {children}
      </button>
    </Link>
  );
}

Sidebar 组件引入一下:

// ...
import EditButton from '@/components/EditButton';

export default async function Sidebar() {
  const notes = await getAllNotes()
  return (
    <>
      	// ...
        <section className="sidebar-menu" role="menubar">
          <EditButton noteId={null}>New</EditButton>
        </section>
        <nav>
          <SidebarNoteList notes={notes} />
        </nav>
      </section>
    </>
  )
}

Suspense

原 Demo 中当笔记列表加载的时候是有骨架图的效果的:

suspense2.gif

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

修改Sidebar.js 代码如下:

import React, { Suspense } from 'react'
import Link from 'next/link'

import SidebarNoteList from '@/components/SidebarNoteList';
import EditButton from '@/components/EditButton';
import NoteListSkeleton from '@/components/NoteListSkeleton';

// // 移除数据请求部分,为 SidebarNoteList 添加 Suspense 以及 fallback UI NoteListSkeleton
export default async function Sidebar() {

  return (
    <>
      <section className="col sidebar">
        <Link href={'/'} className="link--unstyled">
          <section className="sidebar-header">
            <img
              className="logo"
              src="/logo.svg"
              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>
    </>
  )
}

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

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>
  );
}

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

import SidebarNoteItem from '@/components/SidebarNoteItem';
import { getAllNotes } from '@/lib/redis';

export default async function NoteList() {
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  await sleep(10000);
  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>
}

此时页面效果如下:

suspense3.gif

我们成功实现了骨架图效果!

那么问题来了,SidebarNoteListSuspense 和不用 Suspense,具体有什么改变呢?比如,使用 Suspense 会带来新的请求吗?

我们可以自己测试一下,答案是不会。 之前是那 10 个请求,使用后还是那 10 个请求。

那么使用 Suspense 和不使用 Suspense,到底有什么差别呢?其实我们看下用和不用的效果就知道了。

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

use suspense.gif

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

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

use suspense2.gif

输入地址后,我们立刻就跳转到了页面,笔记列表部分等待了 5s 后,开始展现。除此之外,使用 Suspense,数据加载不会阻塞页面,也就是说在笔记列表还在加载的时候,用户依然可以与页面其他部分进行交互,比如点击 New 按钮新建笔记。

那么问题又来了,页面请求数没有变化,也没有新的请求,这些又都是服务端组件,数据请求都在服务端,到底谁等待了那 5s 呢,然后把数据返回的呢?

答案在于 localhost 这个 HTML 页面,查看网络请求,刚开始页面加载的时候,localhost 的 Time 为 126ms:

image.png

加载完成后,localhost 的 Time 变成了 5s:

image.png

之所以这样,正如《渲染篇 | Streaming 和 Edge Runtime》中介绍的那样,答案在于 HTML 是通过 stream 格式进行传输的,查看 HTML 文件的响应头:

截屏2023-12-15 下午7.42.29.png

Transfer-Encoding 标头的值为 chunked,表示数据将以一系列分块的形式进行发送。HTML 首先收到骨架图的 HTML 进行渲染,再收到渲染完成的笔记列表 HTML 进行渲染,查看一下 localhost HTML 文件的返回:

<!DOCTYPE html>
<html lang="en">
  <head>

  </head>
  <body>
    <div class="container">
      <div class="main">
        // ...
        <nav>
          <!--$?-->
          <template id="B:0"></template>
          <!--这里是骨架图 NoteListSkeleton 的 HTML-->
          <div>
            <ul class="notes-list skeleton-container">
              <li class="v-stack">
                <div class="sidebar-note-list-item skeleton" style="height:5em"></div>
              </li>
              <li class="v-stack">
                <div class="sidebar-note-list-item skeleton" style="height:5em"></div>
              </li>
              <li class="v-stack">
                <div class="sidebar-note-list-item skeleton" style="height:5em"></div>
              </li>
            </ul>
          </div>
          <!--/$-->
        </nav>
      </div>
    </div>
    // ...
    <div hidden id="S:0">
      <!--这里是笔记列表 SidebarNoteList 的 HTML-->
      <ul class="notes-list">
        <li>
          <div class="sidebar-note-list-item ">
            <header class="sidebar-note-header">
              <strong>ea molestias</strong>
              <small>2023-12-13 05:19:48</small>
            </header>
            <button class="sidebar-note-open" style="border:1px solid transparent">Open note for preview</button>
            <button class="sidebar-note-toggle-expand">
              <img src="/chevron-up.svg" width="10px" height="10px" alt="Expand"/>
            </button>
          </div>
        </li>
        <li>
          <div class="sidebar-note-list-item ">
            <header class="sidebar-note-header">
              <strong>qui est</strong>
              <small>2023-12-13 05:19:48</small>
            </header>
            <button class="sidebar-note-open" style="border:1px solid transparent">Open note for preview</button>
            <button class="sidebar-note-toggle-expand">
              <img src="/chevron-up.svg" width="10px" height="10px" alt="Expand"/>
            </button>
          </div>
        </li>
        <li>
          <div class="sidebar-note-list-item ">
            <header class="sidebar-note-header">
              <strong>sunt aut</strong>
              <small>2023-12-13 05:19:48</small>
            </header>
            <button class="sidebar-note-open" style="border:1px solid transparent">Open note for preview</button>
            <button class="sidebar-note-toggle-expand">
              <img src="/chevron-up.svg" width="10px" height="10px" alt="Expand"/>
            </button>
          </div>
        </li>
      </ul>
    </div>
    <script>
      $RC = function(b, c, e) {
         // ... 	
      }
      $RC("B:0", "S:0")
    </script>
  </body>
</html>

因为代码比较多,所以做了一点精简,你会发现在这个 HTML 里,骨架图的 HTML 和笔记列表的 HTML 都返回了,所以使用 Suspense 和 Streaming 不用担心会对 SEO 造成影响。

最佳实践:使用 Suspense

Suspense 的效果就是允许你推迟渲染某些内容,直到满足某些条件(例如数据加载完毕)。在开发 Next.js 项目的时候,有数据加载的地方多考虑是否可以使用 Suspense 或者 loading.js带来更好的体验。

总结

那么今天的内容就结束了,本篇我们完善了侧边栏笔记列表的效果,了解了在 Next.js 中使用服务端组件和客户端组件的注意事项,学习到了两个最佳实践:

  1. 客户端组件下移
  2. 使用 Suspense

本篇的代码我已经上传到代码仓库的 Day2 分支:https://github.com/mqyqingfeng/next-react-notes-demo/tree/day2,直接使用的时候不要忘记在本地开启 Redis。

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