Next.js
37-实战篇 React Notes 项目介绍与创建

前言

欢迎来到实战篇!基础篇的目标是带大家复习基础知识,以及用作使用手册,方便大家在以后的项目开发中查询 API 用法,属于这本小册的“赠送面积”。从本篇起就进入小册的正式内容了。

我们的第一个实战项目是 React Notes,因为 Next.js v14 基于 React Server Component 构建的 App Router,而 React Server Component 的起源是 2020 年 12 月 21 日 React 官方发布的关于 React Server Components 的介绍文章 (opens in a new tab)

这篇文章同时配上了由 Dan Abramov 和 Lauren Tan 两位 React 团队的工程师分享的长约 1h 的演讲 (opens in a new tab)Demo (opens in a new tab),详细的介绍了 React Server Components 的出现背景和使用方式(这是这个 Demo 的一个线上工程 (opens in a new tab),你可以在这个地址上调试学习)。

当时这个 Demo (opens in a new tab) 就是 React Notes,实战篇的第一个项目从这个“起源 Demo”开始讲起,既是一种追溯致敬,也是为了帮助大家在实战中体会 React Server Component 的特性和优势,毕竟当时 React 的工程师写了这个 Demo 用于新特性的展示,自然是要覆盖它的各种用法和特性。

这个 Demo 中的 Server 是自己写的,数据库用的是 PostgreSQL,如果要本地预览原本的 Demo 效果,参照 Demo 的介绍,本地安装 PostgreSQL,创建数据库,连接数据库,再运行项目即可成功开启。这里具体的实现步骤就不多讲了,反正我们的实战篇会用 Next.js 重新实现这个项目。

需求文档

先让我介绍下 React Notes 的项目效果,正如它的名字表明的那样,这是一个笔记系统,可以增删改查笔记,笔记支持 markdown 格式。

首页效果如下,界面分为两列,左侧是笔记列表,右侧是笔记内容:

image.png

点击左边的 New 按钮,可以增加一个 Note,增加后,左侧笔记列表也会同时更新:

React Notes 增加.gif

在编辑的时候,也可以删除一个 Note,删除后左侧笔记列表也会同时更新:

React Notes 删除.gif

可以对现有的 Note 进行修改:

React Notes 修改.gif

还可以在左侧用搜索框查找一个 Note:

React Notes 查找.gif

看起来效果是不是平平无奇?但是注意一点,在这个例子中,我们先在左侧笔记列表中展开了一个笔记,然后又新建了一个笔记,在新建后,左侧笔记列表刷新,但展开的笔记依然保持了之前的状态。

技术文档

现在我们要用 Next.js 实现这个项目,该怎么实现呢?

首先是技术选型,Next.js 的 App Router 自然是要用的,TypeScript 为了减少代码展示量就不使用了,ESLint 要使用,用于校验代码,Tailwind CSS 不需要,因为重写样式浪费时间,我们直接导入原 Demo 的样式文件即可。

后端数据库选择什么都可以,不过考虑到初期大家对 Next.js 尤其是 App Router 的使用不太习惯,再加上数据库的安装和使用也需要额外学习,我们先集中学习如何写好 Next.js 项目,数据方面先使用模拟数据来实现。

那么新的问题来了,怎么写模拟数据呢?第一种方式是在代码里直接写入数据。第二种方式是使用比如 faskMock 这样的工具生成静态接口。但是我们毕竟要做增删改查,无论是直接写数据还是静态接口都难以实现真的对数据源进行修改,所以最后我想了下,干脆用 Redis (opens in a new tab) 做好了,作为经典的 NoSQL 数据库,使用起来也很方便。等 Next.js 部分完成学习之后,我们再替换为其他数据库。(其实我还试了用维格表 (opens in a new tab)做数据库,但维格表接口有每秒最多 2 次的限制,于是就放弃了)

其次是路由分析,原 Demo 中都是在 localhost:4000下实现的,各种操作并不会产生路由变化,但既然我们用了 Next.js,不妨改成使用路由的方式,想了下,应该有这样几个路由:

  1. 首页肯定是 /,点击左上角的 React Note Logo 会导航至首页 /
  2. 点击左侧笔记列表中的一项,导航至 /note/xxxx路由,渲染具体笔记内容
  3. 当点击 NEW 按钮的时候导航到 /note/edit路由上,点击 Done导航至刚创建的 /note/xxxx路由
  4. 导航至 /note/xxxx后,点击 EDIT 按钮,进入 /note/edit/xxxx 路由,点击 Done导航至刚修改的 /note/xxxx路由,点击 DELETE 导航至首页 /
  5. 当在左侧搜索框输入字符的时候,对应路由添加 ?q=searchText 参数

对应到 Next.js 的项目目录,至少要有这些文件:

next-react-notes                 
├─ app                                     
│  ├─ note                       
│  │  ├─ [id]                         
│  │  │  └─ page.js              
│  │  └─ edit                    
│  │     ├─ [id]                 
│  │     │  └─ page.js              
│  │     └─ page.js                        
│  ├─ layout.js                  
│  └─ page.js                                

考虑到左侧笔记列表出现在所有的路由中,我们将左侧的内容包括搜索栏和笔记列表,统一放在根布局 layout.js 中。

再者是组件划分,示意图如下:

截屏2023-12-14 下午4.08.38.png

左侧是 <Sidebar> 组件,子组件中有:

  1. <SidebarSearchField> 组件负责搜索框
  2. <EditButton> 组件负责添加按钮
  3. <SidebarNoteList> 组件负责笔记列表
    1. 再拆分为具体的 <SidebarNoteItem> 组件负责每一条具体的笔记内容

右侧是 <Note> 组件,子组件有:

  1. <EditButton> 组件负责编辑按钮
  2. <NoteEditor> 组件负责笔记的编辑界面
  3. <NotePreview> 组件负责笔记的预览界面

对项目有了大致的了解和规划,剩下的就让我们在项目里具体完善吧,现在开始动手吧。

开始项目

1. 创建项目

使用 create-next-app脚手架创建项目 (opens in a new tab),运行:

npx create-next-app@latest

相关选择如下:

image.png

运行 npm run dev,打开 localhost: 3000开启项目:

image.png

2. 配置路径别名

为了让代码文件职责清晰,我们将组件统一放在根目录下的 components目录下,工具库放在根目录下的 lib目录下,为了方便引入,我们配置一下路径别名 (opens in a new tab),修改 jsconfig.json

{
  "compilerOptions": {
    "paths": {
      "@/components/*": ["components/*"],
      "@/lib/*": ["lib/*"]
    }
  }
}

3. 修改根布局和根页面

修改 app/page.js

// app/page.js
export default async function Page() {
  return (
    <div className="note--empty-state">
      <span className="note-text--empty-state">
        Click a note on the left to view something! 🥺
      </span>
    </div>
  )
}
 

修改 app/layout.js

import './style.css'
import Sidebar from '@/components/Sidebar'
 
export default async function RootLayout({
  children
}) {
 
  return (
    <html lang="en">
      <body>
        <div className="container">
          <div className="main">
            <Sidebar />
            <section className="col note-viewer">{children}</section>
          </div>
        </div>
      </body>
    </html>
  )
}
 

/components下新建一个名为 Sidebar.js 的文件,代码为:

import React from 'react'
import Link from 'next/link'
 
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">
            {/* SideSearchField */}
        </section>
        <nav>
          {/* SidebarNoteList */}
        </nav>
      </section>
    </>
  )
}

4. 引入所需样式和图片文件

在根布局里我们引用了 style.cssstyle.css里声明了所有的样式,但这个文件不需要我们自己写,因为原 Demo (opens in a new tab) 里就已经将所有的样式写到了一个 style.css (opens in a new tab) 文件,我们只需要将这个文件拷贝到 app目录下即可。

这个项目里还会用到一些图片,我们将原 Demo 里 public 目录 (opens in a new tab)下的 5 张 SVG 图片:checkmark.svgchevron-down.svgchevron-up.svgcross.svglogo.svg 拷贝到 public目录下。

5. 第一步完成!

如果步骤正确的话,此时再访问 http://localhost:3000/应该效果如下:

image.png

是不是有原 Demo 的样子了?

数据请求

现在我们来处理数据的问题,正如之前所说,为了方便起见,我们使用 Redis 做数据库。简单介绍一下 Redis,它是一个高性能的 key-value 数据库,是现在最受欢迎的 NoSQL 数据库之一,常用于缓存、计数器、消息队列系统、排行榜等场景。

使用 Redis 很简单,一共分为三步:

1. 安装 Redis

macOS 安装 redis 很简单,按照官网安装说明 (opens in a new tab),使用 Homebrew 安装即可:

brew install redis

Windows 安装略微复杂一点,因为我手边没有 Windows 电脑,就不提供安装方法了,教程很多。

2. 启动 Redis

运行以下命令,如果出现下图界面即表示运行成功:

redis-server

image.png

3. 项目引入 Redis

在项目里使用 redis 的时候,我们借助 ioredis (opens in a new tab) 这个库,安装 ioredis:

npm install ioredis

在根目录下新建一个 lib文件夹,在 lib下新建一个名为 redis.js的文件,代码如下:

import Redis from 'ioredis'
 
const redis = new Redis()
 
const initialData = {
  "1702459181837": '{"title":"sunt aut","content":"quia et suscipit suscipit recusandae","updateTime":"2023-12-13T09:19:48.837Z"}',
  "1702459182837": '{"title":"qui est","content":"est rerum tempore vitae sequi sint","updateTime":"2023-12-13T09:19:48.837Z"}',
  "1702459188837": '{"title":"ea molestias","content":"et iusto sed quo iure","updateTime":"2023-12-13T09:19:48.837Z"}'
}
 
export async function getAllNotes() {
  const data = await redis.hgetall("notes");
  if (Object.keys(data).length == 0) {
    await redis.hset("notes", initialData);
  }
  return await redis.hgetall("notes")
}
 
export async function addNote(data) {
  const uuid = Date.now().toString();
  await redis.hset("notes", [uuid], data);
  return uuid
}
 
export async function updateNote(uuid, data) {
  await redis.hset("notes", [uuid], data);
}
 
export async function getNote(uuid) {
  return JSON.parse(await redis.hget("notes", uuid));
}
 
export async function delNote(uuid) {
  return redis.hdel("notes", uuid)
}
 
export default redis

这块代码并不复杂,我们导出了 5 个函数,表示 5 个用于前后端交互的接口,分别是:

  1. 获取所有笔记的 getAllNotes,这里我们做了一个特殊处理,如果为空,就插入 3 条事先定义的笔记数据
  2. 添加笔记的 addNote
  3. 更新笔记的 updateNote
  4. 获取笔记的 updateNote
  5. 删除笔记的 delNote

其中我们使用了 ioredis 的 hash 结构(ioredis 提供了相关写法示例 (opens in a new tab)API 说明 (opens in a new tab))。也就是说,我们在 redis 服务器中存储的数据大概长这样:

{
  "1702459181837": '{"title":"sunt aut","content":"quia et suscipit suscipit recusandae","updateTime":"2023-12-13T09:19:48.837Z"}',
  "1702459182837": '{"title":"qui est","content":"est rerum tempore vitae sequi sint","updateTime":"2023-12-13T09:19:48.837Z"}',
  "1702459188837": '{"title":"ea molestias","content":"et iusto sed quo iure","updateTime":"2023-12-13T09:19:48.837Z"}'
}

使用 macOS 的同学可以再下载一个 Medis (opens in a new tab),用于查看 Redis 中的数据(当然此时 Redis 还没有写入这些数据):

image.png

其中,key 值用的是创建笔记时的时间戳,value 值是具体的笔记数据,分为 3 个字段,分别是 titlecontentupdateTime

Sidebar 组件

现在让我们用此数据接口来写左侧的笔记列表吧!

1. 笔记列表

修改 components/Sidebar.js

import React from 'react'
import Link from 'next/link'
import { getAllNotes } from '@/lib/redis';
import SidebarNoteList from '@/components/SidebarNoteList';
 
export default async function Sidebar() {
  const notes = await getAllNotes()
  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">
          {/* SideSearchField */}
        </section>
        <nav>
          <SidebarNoteList notes={notes} />
        </nav>
      </section>
    </>
  )
}

在代码中,我们将笔记列表抽成了单独的 components/SidebarNoteList.js组件,代码如下:

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]) => {
    const { title, updateTime } = JSON.parse(note);
    return <li key={noteId}>
      <header className="sidebar-note-header">
        <strong>{title}</strong>
        <small>{updateTime}</small>
      </header>
    </li>
  })}
  </ul>
}

如果步骤正确的话,此时再访问 http://localhost:3000/应该效果如下:

image.png

我们已经成功的获取了 Redis 数据库中的数据,然后服务端渲染到了页面上。

现在在 Medis 中应该已经可以查看到写入的数据:

image.png

现在你在 Medis 中修改下数据,http://localhost:3000/刷新后也会展示出来。

2. 时间处理库

现在你会发现,左侧笔记列表中的时间展示非常“难看”,为此我们需要一个将时间格式化的库,这里我们选择大家经常会用到的 Day.js (opens in a new tab),安装一下:

npm install dayjs

修改 SidebarNoteList.js

import dayjs from 'dayjs';
 
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]) => {
      const { title, updateTime } = JSON.parse(note);
      return <li key={noteId}>
        <header className="sidebar-note-header">
          <strong>{title}</strong>
          <small>{dayjs(updateTime).format('YYYY-MM-DD hh:mm:ss')}</small>
        </header>
      </li>
    })}
  </ul>
}

时间效果展示如下:

image.png

是不是好看多了?但其实效果并不重要,重要的是我们引用了 day.js 这个库。我们引入 day.js 的 SidebarNoteList 组件使用的是服务端渲染,这意味着 day.js 的代码并不会被打包到客户端的 bundle 中。我们查看开发者工具中的源代码:

截屏2023-12-14 下午10.56.02.png

你会发现 node_modules 并没有 day.js,但如果你现在在 SidebarNoteList 组件的顶部添加 'use client',声明为客户端组件,你会发现立刻就多了 day.js:

截屏2023-12-14 下午10.59.07.png

3. 最佳实践:多用服务端组件

这就是使用 React Server Compoent 的好处之一,服务端组件的代码不会打包到客户端的 bundle 中:

image.png

总结

那么今天的内容就结束了,本篇我们大致知道了要做的项目内容,并新建了 Next.js 项目,学会了用 Redis 做个简易的数据库,最后通过引入时间处理库,了解了使用 React Server Component 的一个优势。

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

参考链接

  1. https://github.com/reactjs/server-components-demo (opens in a new tab)
  2. https://www.youtube.com/watch?v=TQQPAU21ZUw&t=15s&ab_channel=MetaOpenSource (opens in a new tab)
  3. https://redis.io/docs/install/install-redis/install-redis-on-mac-os/ (opens in a new tab)