如何使用React.js从头开始构建TODO应用

发布于:2024-04-19 ⋅ 阅读:(32) ⋅ 点赞:(0)

如果你是React.js的新手,并且渴望投身应用程序开发,那么你来对地方了!

跟着我一起通过这个教程,从头开始构建一个基本的TODO应用程序。

(本文视频讲解:java567.com)

TODO应用对初学者的重要性

TODO应用作为初学者掌握新编程语言或框架基础知识的理想项目。它为学习基本概念提供了实际的上下文,并朝着可实现的结果努力。

如果你正在开始React.js之旅,跟着这个教程一起构建TODO应用可能是一个完美的起点。

先决条件

在我们开始之前,请确保你具备React.js的基本知识,并在计算机上安装了Node.js和npm。如果还没有,请花点时间设置你的开发环境。

我们的目标

我们的目标是创建一个简单的TODO应用,具备以下功能。我们将朝着以下目标努力:

  • 添加新的TODO:使用户能够将新任务添加到列表中。
  • 编辑和删除TODO:提供修改或删除现有任务的功能。
  • 将TODO标记为已完成:允许用户指示任务何时完成。
  • 跟踪已完成的TODO:实现一个功能来跟踪所有已完成的任务。

如果你愿意,可以随意扩展这个列表,添加额外的功能。对于这个教程,我们将专注于这些核心功能。

这是我们将要构建的TODO应用的示例:

我们的todo应用预览

目录:

  1. 如何设置你的 React 应用程序

  2. 如何构建组件

    • 头部组件
    • TODOHero 组件
    • 表单组件
    • TODOList 组件
  3. 将所有内容组合在一起

    • 样式
  4. 构建功能:如何添加待办事项

    • 存储待办事项数据的方式
    • 我们想要存储的数据类型是什么?
    • 如何将待办事项数据传递给我们的组件
    • 向我们的状态添加更多待办事项数据
  5. 如何构建 TODO 应用程序的功能

    • 如何标记待办事项为已完成
    • 如何编辑待办事项
    • 如何删除待办事项
  6. 如何持久化我们的待办事项数据

    • 如何将待办事项数据持久化到 localStorage
    • 如何从 localStorage 中读取待办事项数据
  7. 我们完成了。

如何设置你的React应用

在2024年,使用Next.js或Remix等框架是启动React项目的推荐方法。这两个框架都可以胜任,所以只需选择你最熟悉的一个。在本教程中,我们将使用Next.js。

要使用Next.js创建一个React应用,请转到你喜欢的目录并运行以下命令:

npx create-next-app@latest

注意:我们不会在这个项目中使用TypeScript和TailwindCSS,所以你可以使用默认设置继续。

安装完成后,进入你新创建的应用程序目录(我命名为’todo’)并启动开发服务器:

cd todo
npm run dev
## 或者使用yarn
cd todo
yarn run dev

当你的开发服务器启动并运行时,我们就可以开始构建我们的TODO应用了!

如何构建组件

在React中,我们使用组件来构建用户界面。我们的TODO应用的UI由几个部分组成。让我们一一解析:

头部组件

头部组件用于显示我们应用的标题。我们不会直接嵌入HTML,而是在一个React组件中构建这个功能。

首先,创建一个组件目录:

# 在你的项目根目录下,创建一个新目录
mkdir src/components
# 进入目录
cd src/components
# 为头部组件创建一个新文件
touch Header.jsx

在React中,组件本质上是返回HTML的JavaScript函数。在我们的Header.jsx文件中,定义一个返回我们头部组件的HTML内容的函数:

// src/components/Header.jsx
function Header() {
  return (
    <>
      <svg>
        <path d="" /> 
      </svg>
      <h1>TODO</h1>
    </>
  );
}

export default Header;

我们导出Header函数,以便我们可以在整个项目中使用它。

TODOHero组件

TODO Hero组件在我们的应用程序中起着关键作用。它作为一个部分,我们在其中提供总的todo数和已完成任务数的概览。

与头部组件不同,头部组件在我们应用的使用过程中保持静态不变,TODOHero组件是动态的。它会根据已完成的todo数和总的todo数不断更新。

在构建组件时,早期识别动态部分很重要。在React中,我们通过向组件传递参数(称为props)来实现这一点。

让我们创建TODOHero组件。首先,确保你在src/components目录中:

cd src/components

现在,为TODOHero组件创建一个新文件:

touch TODOHero.jsx

在TODOHero.jsx中,定义一个接受props作为参数的函数:

// src/components/TODOHero.jsx
function TODOHero({ todos_completed, total_todos }) {
  return (
    <section>
      <div>
        <p>Task Done</p>
        <p>Keep it up</p>
      </div>
      <div>
        {todos_completed}/{total_todos}
      </div>
    </section>
  );
}
export default TODOHero;

这个函数返回我们的TODOHero组件的HTML内容。我们使用props来动态更新已完成的todo数和总的todo数。

表单组件

我们的表单组件将是一个简单的输入和一个提交按钮,所以继续创建一个新组件

touch src/components/Form.jsx

就像我说的,这将是一个非常简单的表单:只有一个带有提交按钮的输入。标签是为了可访问性。

// src/components/Form.jsx

function Form() {
  const handleSubmit = (event) => {
    event.preventDefault();
    // 重置表单
    event.target.reset();
  };
  return (
    <form className="form" onSubmit={handleSubmit}>
      <label htmlFor="todo">
        <input
          type="text"
          name="todo"
          id="todo"
          placeholder="Write your next task"
        />
      </label>
      <button>
        <span className="visually-hidden">Submit</span>
        <svg>
          <path d="" />
        </svg>
      </button>
    </form>
  );
}
export default Form;

我们在表单上添加了一个onSubmit事件,使用了一个handleSubmit事件处理程序。event.preventDefault()阻止表单提交并重新加载整个应用程序。最后,我们使用event.target.reset()重置表单。

TODOList组件

最后,让我们创建列表组件。首先创建一个名为TODOList.jsx的新组件文件:

touch src/components/TODOList.jsx

列表本身是一个简单的有序列表:

// src/components/TODOList.jsx

function TODOList() {
  return <ol className="todo_list">{/* <li> list goes here */}</ol>;
}
export default TODOList;

列表项将从todo数据动态生成。但在我们继续之前,让我们为列表项创建一个单独的组件。

在React中,几乎所有东西都是一个组件,所以我们将在TODOList组件旁边创建Item组件:

// src/components/TODOList.jsx

function Item({ item }) {
  return (
    <li id={item?.id} className="todo_item">
      <button className="todo_items_left">
        <svg>
          <circle cx="11.998" cy="11.998" fillRule="nonzero" r="9.998" />
        </svg>
        <p>{item?.title}</p>
      </button>
      <div className="todo_items_right">
        <button>
          <span className="visually-hidden">Edit</span>
          <svg>
            <path d="" />
          </svg>
        </button>
        <button>
          <span className="visually-hidden">Delete</span>
          <svg>
            <path d="" />
          </svg>
        </button>
      </div>
    </li>
  );
}

列表项本身只是一个具有编辑和删除任务按钮的<li>元素。我们确保了<li>本身不可点击,遵循“网页上可点击的任何内容应该是按钮或链接”的原则。

现在,我们可以在我们的列表中使用Item组件:

// src/components/TODOList.jsx

function TODOList({ todos }) {
  return (
    <ol className="todo_list">
      {todos && todos.length > 0 ? (
        todos?.map((item, index) => <Item key={index} item={item} />)
      ) : (
        <p>这里看起来有点寂寞,你在干嘛?</p>
      )}
    </ol>
  );
}
export default TODOList;

有了这些组件,我们的TODO应用程序的UI就完全构建好了。

将所有内容整合起来

到目前为止,我们已经创建了四个独立的组件,每个组件本身并没有做太多事情。现在,我们需要在我们的首页中渲染这些组件。

在Next.js中,页面位于src/app目录中,索引页面通常命名为page.js。

首先,让我们清空文件的内容,因为我们不需要其中的任何内容:

echo -n > src/app/page.js

接下来,导入我们创建的所有组件,并在page.js文件中利用它们,如下所示:

// src/app/page.js

import React from "react";
import Form from "@/components/Form";
import Header from "@/components/Header";
import TODOHero from "@/components/TODOHero";
import TODOList from "@/components/TODOList";
function Home() {
  return (
    <div className="wrapper">
      <Header />
      <TODOHero todos_completed={0} total_todos={0} />
      <Form />
      <TODOList todos={[]} />
    </div>
  );
}
export default Home;

通过在浏览器中查看输出,应该会看到类似于以下内容的内容:

我们的应用程序的预览(没有CSS)

样式

对于样式,我们将坚持使用老式的CSS。让我们创建一个styles.css文件来保存我们的样式:

touch src/app/styles.css

此外,删除安装Next.js时附带的所有CSS文件,因为我们不需要它们:

rm src/app/page.module.css && src/app/globals.css

现在,你可以在styles.css文件中添加你的CSS规则。虽然不完美,但以下CSS应该足够我们简单的示例:

*,
*::after,
*::before {
  padding: 0;
  margin: 0;
  font-family: inherit;
  box-sizing: border-box;
}
html,
body {
  font-family: sans-serif;
  background-color: #0d0d0d;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
}
button {
  cursor: pointer;
}
.visually-hidden {
  position: absolute !important;
  clip: rect(1px, 1px, 1px, 1px);
  padding: 0 !important;
  border: 0 !important;
  height: 1px !important;
  width: 1px !important;
  overflow: hidden;
  white-space: nowrap;
}
.text_large {
  font-size: 32px;
}
.text_small {
  font-size: 24px;
}
.wrapper {
  display: flex;
  flex-direction: column;
  width: 70%;
}
@media (max-width: 510px) {
  .wrapper {
    width: 100%;
  }
  header {
    justify-content: center;
  }
}
header {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 12px;
  padding: 42px;
}
.todohero_section {
  border: 1px solid #c2b39a;
  display: flex;
  align-items: center;
  justify-content: space-around;
  align-self: center;
  width: 90%;
  max-width: 455px;
  padding: 12px;
  border-radius: 11px;
}
.todohero_section div:last-child {
  background-color: #88ab33;
  width: 150px;
  height: 150px;
  border-radius: 75px;
  font-size: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
}
.form {
  align-self: center;
  width: 97%;
  max-width: 455px;
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 38px;
}
.form label {
  width: 90%;
}
.form input {
  background-color: #1f2937;
  color: #fff;
  width: 100%;
  height: 50px;
  outline: none;
  border: none;
  border-radius: 11px;
  padding: 12px;
}
.form button {
  width: 10%;
  height: 50px;
  border-radius: 11px;
  background-color: #88ab33;
  border: none;
}
.todo_list {
  align-self: center;
  width: 97%;
  max-width: 455px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: 27px;
  margin-bottom: 27px;
  gap: 27px;
}
.todo_item,
.edit-form input {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 70px;
  width: 100%;
  max-width: 455px;
  border: 1px solid #c2b39a;
  font-size: 16px;
  background-color: #0d0d0d;
  color: #fff;
  padding: 12px;
}
.edit-form input {
  outline: transparent;
  width: calc(100% - 14px);
  height: calc(100% - 12px);
  border: transparent;
}
.todo_items_left,
.todo_items_right {
  display: flex;
  align-items: center;
}
.todo_items_left {
  background-color: transparent;
  border: none;
  color: #fff;
  gap: 12px;
  font-size: 16px;
}
.todo_items_right {
  gap: 4px;
}
.todo_items_right button {
  background-color: transparent;
  color: #fff;
  border: none;
}
.todo_items_right button svg {
  fill: #c2b39a;
}

最后,我们需要在我们的布局中导入CSS文件。打开位于page.js旁边的layout.js文件,并像下面示例中所示导入CSS文件:

一个显示如何在我们的组件中导入styles.css文件的图像

再次预览应用程序时,它现在应该反映出应用的样式:

添加CSS后应用程序的预览

构建功能:如何添加待办事项

在这个阶段,我们已经创建了一个外观上吸引人的待办事项应用程序,但它缺少功能。在本节中,让我们改变这一点。

存储待办事项数据的方法

首先,我们需要一种方法来存储我们的待办事项数据。在React中,可以使用状态来实现这一点——状态是一个关于组件状态的信息的JavaScript对象。

React提供了一个名为 useState() 的钩子,它使我们能够在React应用程序中管理状态。但在Next.js中,在使用 useState 之前,你需要指定该组件是一个客户端组件。

将以下代码添加到你的src/app/page.js文件的顶部:

"use client";

如下图所示:

一个显示如何将"use client"添加到我们的page.js文件顶部的图像

现在,我们可以使用 useState 钩子来为我们的待办事项数据创建一个状态:

// src/app/page.js

"use client";
import React from "react";
import Form from "@/components/Form";
// 添加其他组件的导入
function Home() {
  const [todos, setTodos] = React.useState([]);
  return (
    <Header />
    // 在这里添加其他组件
  );
}
export default Home;

在上面的代码片段中,你会注意到 useState 最初保存一个空数组。重要的是要理解,useState 返回两个值:todossetTodos(你可以用任何你喜欢的名称命名它们)。

第一个值 todos 保存状态的当前值,而 setTodos(第二个值)是用于更新状态的函数。到目前为止清楚吗?

我们想要存储什么样的数据?

现在我们有了存储数据的方法,让我们定义我们打算存储的数据类型。基本上,它将是一个对象数组,其中每个对象保存了渲染我们的待办事项列表所需的信息:

const [todos, setTodos] = React.useState([
{ /* 对象 */ },
{ /* 对象 */ },
{ /* 对象 */ },
]);

数组中的每个对象将具有以下结构:

{
title: "一些任务",  // 字符串
id: self.crypto.randomUUID(), // 字符串
is_completed: false // 布尔值
}

在这里,self.crypto.randomUUID() 是一个允许浏览器为每个待办事项生成唯一ID的方法。如果你查看控制台,你会观察到生成的ID确实是唯一的。

我们的待办事项数据的console.log

这个结构确保了每个待办事项都有一个标题、一个唯一标识符(id)和一个布尔值,表示任务是否完成(is_completed)。

如何将待办事项数据传递给我们的组件

在React中,有一个称为状态共享的概念,它允许子组件访问其父组件的状态。这意味着我们之前创建的待办事项状态可以在所有我们的组件之间共享。

我们需要数据的第一个地方是在我们的List组件中。让我们将状态传递给List组件:

// src/app/page.js

"use client";
import React from "react";
// 导入其他组件
import TODOList from "@/components/TODOList";

function Home() {
  const [todos, setTodos] = React.useState([
    { title: "一些任务", id: self.crypto.randomUUID(), is_completed: false },
    {
      title: "另一个任务",
      id: self.crypto.randomUUID(),
      is_completed: true,
    },
    { title: "最后一个任务", id: self.crypto.randomUUID(), is_completed: false },
  ]);
  return (
    <div className="wrapper">
      ...
      <TODOList todos={todos} />
    </div>
  );
}
export default Home;

我们已经在List组件中预留了接收 todos 属性的位置:

// src/components/TODOList.jsx

function TODOList({ todos }) {
  return (
    <ol className="todo_list">
      {todos && todos.length > 0 ? (
        todos?.map((item, index) => (
          <Item key={index} item={item} setTodos={setTodos} />
        ))
      ) : (
        <p>这里看起来有点孤单,你在干什么?</p>
      )}
    </ol>
  );
}

现在,todos属性将由我们状态中的数据填充,并且不需要任何额外的操作,它将起作用。以下是一个显示了根据我们的todos数据创建的列表的图像:

我们需要数据的另一个地方是我们的 TODOHero 组件。在该组件中,我们不需要所有的数据——我们只需要计算总待办事项数量和已完成的待办事项数量:

// src/app/page.js

"use client";
import React from "react";
// 导入其他组件
import TODOHero from "@/components/TODOHero";
import TODOList from "@/components/TODOList";
function Home() {
  const [todos, setTodos] = React.useState([
    { title: "一些任务", id: self.crypto.randomUUID(), is_completed: false },
    // 添加其他虚拟数据
  ]);
  const todos_completed = todos.filter(
    (todo) => todo.is_completed === true
  ).length;
  const total_todos = todos.length;
  return (
    <div className="wrapper">
      <Header />
      <TODOHero todos_completed={todos_completed} total_todos={total_todos} />
      <Form />
      <TODOList todos={todos} />
    </div>
  );
}
export default Home;

在这里,使用JavaScript的filter方法来过滤所有 is_completed 设置为true的待办事项,然后我们得到长度。total_todos 简单地是整个数组的长度。

以下是一个显示了更新值的 TODOHero 组件的图像:

显示了更新后的TODOHero组件

向我们的状态添加更多待办事项数据

目前,我们的待办事项应用程序显示了来自我们虚拟数据的待办事项:

const [todos, setTodos] = React.useState([
  { title: "Some task", id: self.crypto.randomUUID(), is_completed: false },
  {
    title: "Some other task",
    id: self.crypto.randomUUID(),
    is_completed: true,
  },
  { title: "last task", id: self.crypto.randomUUID(), is_completed: false },
]);

但创建一个 Form 组件的目的是为了让我们自己创建新的待办事项,而不是依赖于虚拟数据。

好消息是,正如我们可以访问待办事项状态数据一样,我们也可以从子组件更新父组件的状态。这意味着我们可以将用于更新状态的函数 setTodos 传递给我们的 Form 组件:

// src/app/page.js

"use client";
import React from "react";
import Form from "@/components/Form";
// 导入其他组件

function Home() {
  const [todos, setTodos] = React.useState([
    { title: "Some task", id: self.crypto.randomUUID(), is_completed: false },
    // 添加其他虚拟数据
  ]);

  ...
  return (
    <div className="wrapper">
      ...
      <Form setTodos={setTodos} />
      <TODOList todos={todos} />
    </div>
  );
}
export default Home;

有了在我们的 Form 组件中访问 setTodos 函数的权限,我们现在可以在提交表单时向我们的状态添加新的待办事项:

// src/components/Form.jsx

function Form({ setTodos }) {
  const handleSubmit = (event) => {
    event.preventDefault();
    const value = event.target.todo.value;
    setTodos((prevTodos) => [
      ...prevTodos,
      { title: value, id: self.crypto.randomUUID(), is_completed: false },
    ]);
    event.target.reset();
  };
  return (
    <form className="form" onSubmit={handleSubmit}>
      …
    </form>
  );
}
export default Form;

下面的代码片段是魔法发生的地方:

setTodos((prevTodos) => [
  ...prevTodos,
  { title: value, id: self.crypto.randomUUID(), is_completed: false },
]);

这相当于在普通JavaScript中执行以下操作:

let prevTodos = [];

prevTodos.push({
  title: value,
  id: self.crypto.randomUUID(),
  is_completed: false,
});

现在我们可以自己向我们的状态添加新的待办事项,我们可以摆脱虚拟数据了。我们不再需要它。让我们回到使用一个空数组:

const [todos, setTodos] = React.useState([]);

如何构建 TODO 应用程序的功能

如何标记待办事项为完成

在我们的 List 组件中,我们构建了一个带有按钮的 <li> 元素。现在,我们要为第一个按钮附加一个 onClick 事件处理程序。

// src/components/TODOList.jsx

function Item({ item }) {
  const completeTodo = () => {
    // 执行一些操作
  };
  return (
    <li id={item?.id} className="todo_item" onClick={completeTodo}>
      <button className="todo_items_left">
        <svg>
          <circle cx="11.998" cy="11.998" fillRule="nonzero" r="9.998" />
        </svg>
        <p>{item?.title}</p>
      </button>
      <div className="todo_items_right">
        <button>...</button>
        <button>...</button>
      </div>
    </li>
  );
}

当我们点击此按钮并调用 completeTodo 处理程序时,我们的目标是:

  • 过滤数据以找到触发删除事件的待办事项。
  • 修改数据并将 is_completed 值设置为 true。

在我们继续进行数据修改之前,我们需要在我们的 <Item /> 组件中访问 setTodo 函数。幸运的是,React 允许将状态传递给孙组件。

这意味着我们可以从 <List /> 组件将 setTodo 函数传递给我们的 <Item /> 组件:

// src/app/page.js

"use client";
import React from "react";
// 导入其他组件
import TODOList from "@/components/TODOList";

function Home() {
  const [todos, setTodos] = React.useState([]);

...

  return (
    <div className="wrapper">
      ...
      <TODOList todos={todos} setTodos={setTodos} />
    </div>
  );
}
export default Home;

然后,在我们的 <List /> 组件中,我们将 setTodo 函数传递给我们的 <Item /> 组件:

// src/components/TODOList.jsx

function TODOList({ todos, setTodos }) {
  return (
    <ol className="todo_list">
      {todos && todos.length > 0 ? (
        todos?.map((item, index) => (
          <Item key={index} item={item} setTodos={setTodos} />
        ))
      ) : (
        <p>这里看起来有点孤单,你在干什么?</p>
      )}
    </ol>
  );
}

现在,在我们的 <Item /> 组件中,当按钮被点击时,我们可以使用 setTodos 函数来更新待办事项的 is_completed 状态:

// src/components/TODOList.jsx

function Item({ item, setTodos }) {
  const completeTodo = () => {
    setTodos((prevTodos) =>
      prevTodos.map((todo) =>
        todo.id === item.id
          ? { ...todo, is_completed: !todo.is_completed }
          : todo
      )
    );
  };
  return (
    <li id={item?.id} className="todo_item">
      <button className="todo_items_left" onClick={completeTodo}>
        ...
      </button>
      <div className="todo_items_right">
        <button>...</button>
        <button>...</button>
      </div>
    </li>
  );
}

现在,点击待办事项中的第一个按钮将切换其完成状态,从而有效地修改了待办事项数据。

当待办事项标记为已完成时,我们希望增强其视觉表示。这包括向待办事项标题旁边的 SVG 圆圈添加填充,以创建待办事项已完成的幻觉。此外,我们希望为文本添加删除线,以表示已完成。

<button className="todo_items_left" onClick={completeTodo}>
  <svg fill={item.is_completed ? "#22C55E" : "#0d0d0d"}>
    <circle cx="11.998" cy="11.998" fillRule="nonzero" r="9.998" />
  </svg>
  <p style={item.is_completed ? { textDecoration: "line-through" } : {}}>
    {item?.title}
  </p>
</button>;

在上面的代码片段中,按钮的颜色根据待办事项的完成状态更改。如果项目已完成(is_completed 为 true),SVG 圆圈将填充为绿色 - 否则,它将填充为深色。此外,如果待办事项已完成,待办事项标题文本将接收删除线样式,以在视觉上表示其已完成状态。

如何编辑待办事项

在编辑待办事项时,我们希望有一个表单,可以在其中编辑待办事项的标题。当单击编辑按钮时,我们希望将 <li> 中的所有内容替换为表单:

// src/components/TODOList.jsx

function Item({ item, setTodos }) {
  const [editing, setEditing] = React.useState(false);
  const inputRef = React.useRef(null);
  const completeTodo = () => {
    // 标记待办事项为完成
  };
  const handleEdit = () => {
    setEditing(true);
  };
  React.useEffect(() => {
    if (editing && inputRef.current) {
      inputRef.current.focus();
      // 将光标定位到文本的末尾
      inputRef.current.setSelectionRange(
        inputRef.current.value.length,
        inputRef.current.value.length
      );
    }
  }, [editing]);
  const handleInpuSubmit = (event) => {
    event.preventDefault();
    setEditing(false);
  };
  const handleInputBlur = () => {
    setEditing(false);
  };
  return (
    <li id={item?.id} className="todo_item">
      {editing ? (
        <form className="edit-form" onSubmit={handleInpuSubmit}>
          <label htmlFor="edit-todo">
            <input
              ref={inputRef}
              type="text"
              name="edit-todo"
              id="edit-todo"
              defaultValue={item?.title}
              onBlur={handleInputBlur}
              onChange={handleInputChange}
            />
          </label>
        </form>
      ) : (
        <>
          <button className="todo_items_left" onClick={completeTodo}>
            ...
          </button>
          <div className="todo_items_right">
            <button onClick={handleEdit}>...</button>
            <button>...</button>
          </div>
        </>
      )}
    </li>
  );
}

我知道上面的代码有点多。好吧,这是因为我们在这里做了很多事情 - 但我们首先创建了一个状态:

const [editing, setEditing] = React.useState(false);

当单击编辑按钮时,我们将编辑状态的值设置为 true,这将渲染我们的表单:

const handleEdit = () => {
  setEditing(true);
};

现在,当我们通过按回车键提交编辑待办事项表单时,我们还希望将变量设置回 false,以便我们可以重新获取我们的列表:

const handleInpuSubmit = (event) => {
  event.preventDefault();
  setEditing(false);
};

当我们鼠标移出编辑表单时,我们也希望将状态设置回 false:

const handleInputBlur = () => {
  setEditing(false);
};

我们想要做的另一件事是一旦编辑设置为 true,就将焦点集中到输入框上:

React.useEffect(() => {
  if (editing && inputRef.current) {
    inputRef.current.focus();
    // 将光标定位到文本的末尾
    inputRef.current.setSelectionRange(
      inputRef.current.value.length,
      inputRef.current.value.length
    );
  }
}, [editing]);

编辑待办事项本身只有一个输入字段,带有一个 onChange 事件。当我们在输入字段中编辑标题时,我们希望使用更新的标题修改当前待办事项:

const handleInputChange = (e) => {
  setTodos((prevTodos) =>
    prevTodos.map((todo) =>
      todo.id === item.id ? { ...todo, title: e.target.value } : todo
    )
  );
};

JavaScript 的 array.map() 方法非常适合此操作,因为它返回一个具有相同数量的元素的新数组,在修改标题后。

如何删除待办事项

删除待办事项是一个简单的过程。当单击删除按钮时,我们会从待办事项列表中过滤掉触发删除事件的待办事项。

// src/components/TODOList.jsx

const handleDelete = () => {
  setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== item.id));
};

不要忘记为删除按钮添加一个 onClick 事件:

// src/components/TODOList.jsx

function Item({ item, setTodos }) {
  ...
    const handleDelete = () => {
    setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== item.id));
  };
  return (
    <li id={item?.id} className="todo_item">
      {editing ? (
        <form className="edit-form" onSubmit={handleInpuSubmit}>
          ...
        </form>
      ) : (
        <>
          …
          <div className="todo_items_right">
            …
            <button onClick={handleDelete}>
              <span className="visually-hidden">删除</span>
              <svg>
                <path d="" />
              </svg>
            </button>
          </div>
        </>
      )}
    </li>
  );
}

如何持久化我们的待办事项数据

到目前为止,我们的待办事项数据仅存储在应用程序的状态中:

const [todos, setTodos] = React.useState([]);

虽然这种方法有效,但它也带来了一个挑战:当应用程序重新加载时,所有待办事项数据都会丢失。

当涉及到持久化数据时,我们通常会考虑数据库。将我们的待办事项数据存储在数据库中具有几个优点,比如易于从任何设备访问。但还有一种替代方案:localStorage。

LocalStorage 是一个基于浏览器的存储系统。它有一些限制,比如 5MB 的存储限制以及数据只能在存储它的浏览器中访问。尽管存在这些缺点,但出于简单起见,我们将在本教程中使用 localStorage。

如何将待办事项数据持久化到 localStorage

当前,当我们添加新的待办事项时,我们仅在我们的 Form 组件中更新待办事项状态:

// src/components/Form.jsx

const handleSubmit = (event) => {
  event.preventDefault();
  const value = event.target.todo.value;
  setTodos((prevTodos) => [
    ...prevTodos,
    { title: value, id: self.crypto.randomUUID(), is_completed: false },
  ]);
  event.target.reset();
};

我们仍然希望保留这一点,但同时我们也希望将相同的数据添加到 localStorage 中,因此我们将修改上面的代码如下所示:

// src/components/Form.jsx 

const handleSubmit = (event) => {
  event.preventDefault();
  const value = event.target.todo.value;
  const newTodo = {
    title: value,
    id: self.crypto.randomUUID(),
    is_completed: false,
  };
  // 更新待办事项状态
  setTodos((prevTodos) => [...prevTodos, newTodo]);
  // 将更新后的待办事项列表存储到本地存储中
  const updatedTodoList = JSON.stringify([...todos, newTodo]);
  localStorage.setItem("todos", updatedTodoList);
  event.target.reset();
};

我是否提到过你只能在 localStorage 中存储字符串?我们不能在 localStorage 中存储数组或对象。这就是为什么我们首先将我们的待办事项数据数组转换为字符串的原因:

const updatedTodoList = JSON.stringify([...prevTodos, newTodo]);

然后最后我们用这段代码持久化数据在 localStorage 中:

localStorage.setItem('todos', updatedTodoList);

你会注意到我们在我们的 <Form /> 组件中使用了我们的 todos 状态数据:

const updatedTodoList = JSON.stringify([...todos, newTodo]);

所以不要忘记将 todo 状态传递给组件:

// src/app/page.js

<Form todos={todos} setTodos={setTodos} />

还有,由于我们可以在应用程序中编辑和删除待办事项,我们需要相应地更新 localStorage 中的数据。首先,将 todos 数据传递给我们的 <Item /> 组件:

// src/components/TODOList.jsx

function TODOList({ todos, setTodos }) {
  return (
    <ol className="todo_list">
      {todos && todos.length > 0 ? (
        todos?.map((item, index) => (
        // 将 todos 传递给 <Item />
          <Item key={index} item={item} todos={todos} setTodos={setTodos} />
        ))
      ) : (
        <p>这里看起来很孤单,你在干什么呢?</p>
      )}
    </ol>
  );
}

现在我们在我们的 <Item /> 组件中有了访问待办事项数据的权限,我们可以在标记待办事项为已完成后将数据持久化到 localStorage:

// src/components/TODOList.jsx

const completeTodo = () => {
  setTodos((prevTodos) =>
    prevTodos.map((todo) =>
      todo.id === item.id ? { ...todo, is_completed: !todo.is_completed } : todo
    )
  );

  // 标记待办事项为已完成后更新 localStorage
  const updatedTodos = JSON.stringify(todos);
  localStorage.setItem("todos", updatedTodos);
};

我们还希望在编辑待办事项后将数据持久化到 localStorage:

// src/components/TODOList.jsx

const handleInpuSubmit = (event) => {
  event.preventDefault();

  // 编辑待办事项后更新 localStorage
  const updatedTodos = JSON.stringify(todos);
  localStorage.setItem("todos", updatedTodos);
  setEditing(false);
};

const handleInputBlur = () => {
  // 编辑待办事项后更新 localStorage
  const updatedTodos = JSON.stringify(todos);
  localStorage.setItem("todos", updatedTodos);

  setEditing(false);
};

最后,我们还希望在删除待办事项后将数据持久化到 localStorage:

// src/components/TODOList.jsx

const handleDelete = () => {
  setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== item.id));
  // 删除待办事项后更新 localStorage
  const updatedTodos = JSON.stringify(
    todos.filter((todo) => todo.id !== item.id)
  );
  localStorage.setItem("todos", updatedTodos);
};

这就是你所需要的一切 - 相当容易,对吧?现在当我们创建新的待办事项时,它们将在重新加载我们的应用程序后仍然保留在 localStorage 中。

如何从 localStorage 中读取待办事项数据

尽管我们已经成功将数据持久化到 localStorage,但当我们重新加载应用程序或浏览器时,我们的应用程序数据仍然会被擦除。这是因为我们尚未利用存储在 localStorage 中的数据。

为了解决这个问题,当我们的应用程序被挂载(加载)时,我们希望从 localStorage 中检索数据,然后将其传递给我们的状态。

在我们的 src/app/page.js 中,我们将从 localStorage 中读取数据,并将其存储在我们的 todos 状态中。

// src/app/page.js

"use client";
import React from "react";
import Form from "@/components/Form";
import Header from "@/components/Header";
import TODOHero from "@/components/TODOHero";
import TODOList from "@/components/TODOList";

function Home() {
  const [todos, setTodos] = React.useState([]);

  // 组件挂载时从 localStorage 中检索数据
  React.useEffect(() => {
    const storedTodos = localStorage.getItem("todos");
    if (storedTodos) {
      setTodos(JSON.parse(storedTodos));
    }
  }, []);

  const todos_completed = todos.filter(
    (todo) => todo.is_completed == true
  ).length;
  const total_todos = todos.length;

  return (
    <div className="wrapper">
      <Header />
      <TODOHero todos_completed={todos_completed} total_todos={total_todos} />
      <Form todos={todos} setTodos={setTodos} />
      <TODOList todos={todos} setTodos={setTodos} />
    </div>
  );
}

export default Home;

useEffect() 钩子内的代码在组件挂载后运行一次。

这是从 localStorage 中读取数据的部分:

const storedTodos = localStorage.getItem("todos");

由于存储在 localStorage 中的数据是一个字符串,我们必须将其转换回我们的对象数组,然后才能使用它:

JSON.parse(storedTodos)

我们完成了。

恭喜!经过充满编码和坚持的旅程,我们成功地从零构建了一个简单而功能齐备的待办事项应用程序。虽然旅程可能有点漫长,但结果是值得的。

您可以在这里探索应用程序的完整源代码。随意深入代码,看看它是如何一步步完成的。

感谢您加入我一起进行这次编码冒险。我希望您已经获得了关于使用 React 构建应用程序和使用 localStorage 持久化数据的宝贵见解。

(本文视频讲解:java567.com)