构建高效的 TodoList Web 应用:基于 Firebase 的全栈实现

构建高效的 TodoList Web 应用:基于 Firebase 的全栈实现

前言

关于Firebase

Firebase 最初是由 Firebase, Inc. 于 2011 年推出的,最初是一个实时数据库。2014 年,Google 收购了 Firebase,并作为Google Cloud一部分,随后逐渐将其扩展为一个全面的开发平台。

Firebase能做什么?

  • Firestore Database:Firebase 提供的云数据库功能,一个 NoSQL 文档数据库;

  • Authentication:用户身份验证系统,旨在简化应用程序的用户注册和登录过程。支持多种身份验证方式;

  • Hosting:静态网站托管服务;

  • Cloud Functions:一种无服务器框架,利用它自动运行后端代码来响应 Firebase 功能和 HTTPS 请求触发的事件;

  • Storage:对象存储服务

  • ......

开发者通过在Firebase控制台创建【项目】,自由搭配Firebase提供的功能/服务,部分服务需升级收费方案,本项目仅使用了Firestore Database、Authentication以及Hosting功能,都是免费但有一定限额功能。

Firebase官网firebase.google.com/?hl=zh-cn

Firebase项目控制台console.firebase.google.com/

Firebase官方文档 (包含所有功能使用说明):firebase.google.com/docs/build?...

关于本项目

本项目是对 Todoist (国外一款流行的任务管理应用:todoist.com)的简易功能版实现。另外参考了github 项目 todoist-clone 对其TS改写并二次开发;

体验地址todolist-react-f22cc.web.app todolist-react-f22cc.firebaseapp.com

githubgithub.com/KID-1912/to...

Firebase准备

Firebase项目-应用

Firebase控制台 创建一个新的 todolist 项目 ,并在项目内选择添加 web(网页)应用

引入Firebase

在IDE新建一个React初始项目,这里提供一个我的空白项目模板:github.com/KID-1912/vi...

Firebase SDK

shell 复制代码
npm install firebase --save

新增 src/firebase.ts 文件,为项目编写初始化Firebase逻辑:

ts 复制代码
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: "xxx",
  authDomain: "xxx",
  projectId: "xxx",
  storageBucket: "xxx",
  messagingSenderId: "xxx",
  appId: "xxx",
  measurementId: "xxx",
};

// Initialize Firebase
export const firebaseApp = initializeApp(firebaseConfig);
export const firebaseAuth = getAuth(firebaseApp);

这段代码导出firebase App对象,和firebase 认证对象;

关于 firebaseConfig 配置,在上一节创建Firebase项目-应用引导中有出现,你也可以在控制台的 【项目概览】---【项目设置】---【常规】---【您的应用】---【SDK 设置和配置】中查看到项目配置信息;

Authentication登录

在开发项目新增登录页 src/pages/login/login.tsx,编写一个填写邮箱+密码的登录表单 LoginForm.tsx

firebase 登录核心逻辑:

ts 复制代码
import { signInWithEmailAndPassword } from "firebase/auth";
import { firebaseAuth } from "@/firebase.ts";

...
    try {
      // 使用 电子邮件地址和密码登录API
      const userCredential = await signInWithEmailAndPassword(firebaseAuth, email, password);
      // const user = userCredential.user; // 这里可以拿到用户信息,但我们通过useAuthState维护用户状态
      navigate("/");
      message.success("登录成功");
    } catch (error) {
      message.error("登录失败");
      console.warn("登录失败", error);
    }
...

相关Firebase Authentication 文档见:firebase.google.com/docs/auth/w...

UserContext

为了后续应用中其他组件访问 User 用户信息,为App编写 UserContext:src/context/user.tsx 提供用户状态。

jsx 复制代码
import { useAuthState } from "@/hooks/useAuthState.ts";
import type { UserState } from "@/hooks/useAuthState.ts";

export const UserContext = createContext<UserState>({ user: null, loading: true });

export const UserProvider = ({ children }: { children: React.ReactNode }) => {
  const { user, loading } = useAuthState();
  return <UserContext.Provider value={{ user, loading }}>{children}</UserContext.Provider>;
};

useAuthState

编写 src/hooks/useAuthState.ts,它在 UserContext 中被使用:

ts 复制代码
import { firebaseAuth } from "@/firebase.ts";
import { onAuthStateChanged } from "firebase/auth";

import type { User } from "firebase/auth";

export interface UserState {
  user: User | null;
  loading: boolean;
}

export const useAuthState = (): UserState => {
  const [loading, setLoading] = useState(true);
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
      setLoading(false);
      setUser(user);
    });
    return unsubscribe;
  }, [firebaseAuth]);

  return { user, loading };
};

通过 onAuthStateChangedAPI 监听Firebase App的User状态更新,它会在Firebase App认证状态改变,如登录/退出登录时被调用;

初始用户

由于目前没有添加注册逻辑,我们可以在Firebase 控制台手动新增一个初始用户

为项目开启Authentication

控制台侧边栏【构建】---【Authentication】点击【开始】开启 Authentication

设置【登录方法】选择"电子邮箱/密码"

回到【用户】新增用户:example@example.com 密码:testtest

完成所有步骤,就可以在开发项目的登录页,输入初始用户邮箱和密码,测试登录功能;

Home首页

用户通过登录页进入应用首页,编写以下文件实现首页:

同时涉及路由跳转,完善项目的路由配置:src/router/index.tsx

其中,我们通过一个 AuthGuard 验证用户状态有效性

jsx 复制代码
import { UserContext } from "@/context/user.tsx";
import LoadingLayer from "@/components/base/LoadingLayer.tsx";

export default function AuthGuard({ children }: { children: React.ReactNode }) {
  const { user, loading } = useContext(UserContext);
  if (loading) return <LoadingLayer />;
  if (!user) return <Navigate to="/login" replace />;
  return children;
}

Task 任务

在首页新增组件:

Firestore Database

创建数据库

控制台侧边栏【构建】---【Firestore Database】点击【创建数据库】

Task数据操作

在项目 api 目录下新增 src/api/tasks/tasks.ts 文件编写task数据操作接口

src/types/index.d.ts 存放项目的业务数据类型,包含Task、Project等

新增Task(addDoc)

新增Task核心逻辑:

ts 复制代码
import { addTaskDoc } from "@/api/tasks/tasks.ts";
......
  const { user } = useContext(UserContext); 
  ......
  const newTask: NewTask = {
      __type: "task",
      userId: user!.uid,
      done: false,
      name: values.name,
      description: values.description,
      scheduledAt: scheduledAtDate ? scheduledAtDate.toDate() : scheduledAtDate,
    };
    try {
      setLoading(true);
      await addTaskDoc({ task: newTask, taskGroup, userId: user!.uid });
      message.success("任务已添加");
      onAddTaskSuccess?.();
    } catch (error) {
      console.error(error);
      message.error("操作失败");
    }
  ......

其中 addTaskDoc 的第2个参数 taskGroup 为任务所属群组,类型信息如下:

ts 复制代码
type InboxType = { __type: "inbox"; name: "__inbox__" };

type TodayFilterType = { __type: "today"; name: "__today__" };

type RecentFilterType = { __type: "recent"; name: "__recent__" };

type ProjectType = {
  __type: "project";
  id?: string;
  name: string;
  createdAt: Date;
  color: string;
};

type TaskGroup = InboxType | TodayFilterType | RecentFilterType | ProjectType;

分别对应首页侧边栏 【收件箱】【今天】【即将到来】【我的项目】4个群组

addTaskDoc 新增Task 方法实现:

ts 复制代码
import { collection, addDoc } from "firebase/firestore";
import { getTasksCollectionPath, TaskConverter } from "./helper.js";
import { db } from "@/context/firestore.tsx"; 

// 新增任务
export const addTaskDoc = async (data: { task: NewTask; taskGroup: TaskGroup; userId: string }) => {
  const { task, taskGroup, userId } = data;
  const path = getTasksCollectionPath(taskGroup, userId);
  const col = collection(db, path).withConverter(TaskConverter);
  const newDoc = await addDoc(col, task);
  return newDoc;
};

接下来以 addTask 涉及的操作,介绍 Firestore 各部分

相关Firestore Database 文档见:[firebase.google.com/docs/firestore/quickstart?hl=zh-cn](Cloud Firestore 使用入门 | Firebase)

Firestore

@/context/firestore.tsx 中,我们提供 Firestore 对象,实现应用与 Firebase 的 Firestore 数据库交互;

ts 复制代码
import { getFirestore } from "firebase/firestore";
import type { Firestore } from "firebase/firestore";

export const db = getFirestore();
Collection Path

在 NoSQL 数据库中,集合(collection)文档(document) 是数据组织的两个核心概念.集合是文档的容器,类似于关系型数据库中的表(table);文档是实际存储数据的实体,类似于关系型数据库中的行(row)。

Collection Path 意义

唯一标识集合位置:

  • Firestore 是一个文档型 NoSQL 数据库,数据被组织为集合和文档。每个集合和文档都有一个唯一的路径。通过指定集合路径(collectionPath),你能够准确地找到集合的位置。
  • 比如,users/{userId}/projects/{projectId}/tasks 是一个具体的路径,指向某个用户的某个项目下的任务集合。

结构示意如下:

python 复制代码
Collection: users
  ├── Document (ID: {userId})
       ├── Collection: tasks         // taskGroup.__type 是 "inbox" "today" "recent" 时
           ├── Document (task data)
           ├── Document (task data)
       ├── Collection: projects      // taskGroup.__type 是 "project" 时
           ├── Document (ID: {project.id})
                ├── Collection: tasks
                    ├── Document (task data)
                    ├── Document (task data)

通过判断任务所属群组,getTasksCollectionPath 计算出Collection Path返回此次操作任务集的位置

ts 复制代码
export const getTasksCollectionPath = (taskGroup: TaskGroup, userId: string) => {
  let basePath = `users/${userId}`;
  if (taskGroup.__type === "project") {
    basePath += `/projects/${taskGroup.id}`;
  }
  return basePath + "/tasks";
};
FirestoreDataConverter

定义文档数据与应用程序数据之间的转换逻辑,方便数据的序列化和反序列化。

在项目的 /src/api/tasks/helper.ts下的 TaskConverter,为 Task数据操作定义数据存取的转换逻辑;

ts 复制代码
import { serverTimestamp, Timestamp } from "firebase/firestore";
import type { FirestoreDataConverter } from "firebase/firestore";

export const TaskConverter: FirestoreDataConverter<Task> = {
  // 存到Firestore
  toFirestore(task) {
    return {
      __type: "task",
      userId: task.userId,
      done: task.done,
      name: task.name,
      description: task.description,
      scheduledAt: task.scheduledAt ? Timestamp.fromDate(task.scheduledAt as Date) : null,
      createdAt: task.createdAt ? Timestamp.fromDate(task.createdAt as Date) : serverTimestamp(),
    };
  },
  // 从Firestore取出时
  fromFirestore(snapshot) {
    const data = snapshot.data();
    const task = {
      id: snapshot.id,
      ...data,
      scheduledAt: data.scheduledAt?.toDate(),
      createdAt: data.createdAt.toDate(),
    } as Task;
    return task;
  },
};
索引

查询Task列表(getDocs)

在Task的数据设计中,inbox、today、recent都是直接查询user的task集合,只是 today、recent 附带了各自查询条件以区分群组;

taskGroup为 ProjectType类型时,是查询user的project的task集合;

ts 复制代码
// 查询任务 by taskGroup
export const getTaskDocsByGroup = async (data: { taskGroup: TaskGroup; userId: string }) => {
  const { taskGroup, userId } = data;
  if (["inbox", "project"].includes(taskGroup.__type)) {
    const path = getTasksCollectionPath(taskGroup, userId);
    const col = collection(db, path).withConverter(TaskConverter);
    const querySnapshot = await getDocs(
      query(col, where("done", "==", false), orderBy("createdAt")),
    );
    return querySnapshot.docs.map((docSn) => docSn.data());
  }
  if (["today", "recent"].includes(taskGroup.__type)) {
    const col = collectionGroup(db, "tasks").withConverter(TaskConverter);
    const op = taskGroup.__type === "today" ? "<=" : ">=";
    const querySnapshot = await getDocs(
      query(
        col,
        where("userId", "==", userId),
        where("done", "==", false),
        where("scheduledAt", op, new Date()),
        orderBy("scheduledAt"),
      ),
    );
    return querySnapshot.docs.map((docSn) => docSn.data());
  }
};

上述查询涉及了 复合查询排序、过滤和混合查询,必须在控制台 Firestore Database添加索引如下图:

CRUD

移除Task(deleteDoc)

ts 复制代码
// 移除任务
export const deleteTaskDoc = async (data: { task: Task; taskGroup: TaskGroup; userId: string }) => {
  const { task, taskGroup, userId } = data;
  const path = getTasksCollectionPath(taskGroup, userId);
  const col = collection(db, path).withConverter(TaskConverter);
  await deleteDoc(doc(col, task.id));
};

更新Task(setTaskDoc)

修改Task信息

ts 复制代码
// 更新/编辑任务信息
export const setTaskDoc = async (data: { task: Task; taskGroup: TaskGroup; userId: string }) => {
  const { task, taskGroup, userId } = data;
  const path = getTasksCollectionPath(taskGroup, userId);
  const col = collection(db, path).withConverter(TaskConverter);
  await setDoc(doc(col, task.id), task);
}; 
// 勾选/完成任务
export const doneTaskDoc = async (data: { task: Task; taskGroup: TaskGroup; userId: string }) => {
  const { task, taskGroup, userId } = data;
  const path = getTasksCollectionPath(taskGroup, userId);
  const col = collection(db, path).withConverter(TaskConverter);
  await setDoc(doc(col, task.id), { ...task, done: true });
};

Project 项目

有了 Task 的基础,侧边栏的项目列表实现:

src/api/projects/projects.ts:project 数据操作接口

src/pages/home/components/Sidebar/Sidebar.tsx:侧边栏 project 列表

src/pages/home/components/AddProjectModal/AddProjectModal.tsx:新增 project 弹窗

Authentication注册

邮箱链接认证

Firebase Authentication 官方无强制的标准注册流程;Authentication更多关注身份验证

包括:邮箱+密码验证电子邮箱链接验证、其它平台账号验证(GoogleFacebookGithubApple ....)、电话号码身份验证接入自定义身份验证系统 ....

其中【电子邮箱链接验证】:Authentication根据传入的邮箱值(如aa@emial.com)生成并发送验证链接,任何设备通过该链接进入应用后,应用只需提供生成链接时传入的邮箱(即aa@email.com),即可获取用户状态(若为新用户,则新增为无密码新用户);

使用【电子邮箱链接验证】API前,需确保前面在开启 Firebase Authentication 时勾选了【电子邮件链接(无密码登录)】;或者可以在控制台依次【Authentication】---【登录方法】---【登录提供方】---【编辑/修改配置图标】检查是否开启,如下图所示:

更多相关细节见firebase文档:firebase.google.com/docs/auth/w...

实现

我们思考通过电子邮箱链接验证相关API (signInWithEmailLink) + 更新用户密码API(updatePassword)实现自定义的注册流程

发送验证链接

注册页

在开发项目新增注册页 src/pages/register/register.tsx,其中编写一个填写邮箱+密码的注册表单 RegisterForm.tsx

点击注册按钮 调用 signInWithEmailLink 发送验证链接:

ts 复制代码
  import { sendSignInLinkToEmail } from "firebase/auth";
  import { firebaseAuth } from "@/firebase.ts";

  interface RegisterFieldType {
    email: string;
    password: string;
  }

  // 电子邮箱链接验证
  const [sendEmail, setSendEmail] = useState(false);
  const handleFinish = async (values: RegisterFieldType) => {
    const { email, password } = values;
    setLoading(true);
    const actionCodeSettings = {
      url: `${location.origin}?eml=${email}&pwd=${password}#/login`,
      handleCodeInApp: true,
    };
    try {
      // 向用户填写的邮箱发送验证链接
      await sendSignInLinkToEmail(firebaseAuth, email, actionCodeSettings);
      message.success({
        content: "验证链接已发送至邮箱,请验证后去登录",
        duration: 5,
      });
      setSendEmail(true);
    } catch (error) {
      message.error("邮箱验证链接发送失败,请稍后再试");
      console.warn("邮箱验证链接发送失败", error);
    }
    setLoading(false);
  };

signInWithEmailLink 接收3个参数,其中关键的第3个参数 actionCodeSettings,其url值即要嵌入的深层链接,用户在邮箱点击验证链接后将重定向该地址;我们通过拼接查询字符串传递注册信息,包含邮箱和密码;

注意:配置的深层链接的域名,必须在 Firebase 控制台的"已获授权的网域"列表中。其中默认已包含localhost,即支持访问本地应用服务,默认值如:

设置密码

当用户访问了firebase发送的验证身份链接,将跳转到 actionCodeSettings.url 配置的todolist应用的登录页;我们再编写一个邮箱验证链接进入时,验证身份并更新密码的逻辑:

src/pages/login/hooks/useValidateURLAuth.ts

ts 复制代码
import { isSignInWithEmailLink, signInWithEmailLink, updatePassword } from "firebase/auth";
import { firebaseAuth } from "@/firebase.ts";

export const useValidateURLAuth = () => {
  const { message } = App.useApp();

  // 尝试从浏览器URL解析出传递的状态
  const URLObject: URL = new URL(location.href);
  const emailParam: string | null = URLObject.searchParams.get("eml");
  const passwordParam: string | null = URLObject.searchParams.get("pwd");
  const signIn = async () => { 
    // 如果当前进入登录页不是通过邮箱验证链接,不执行
    if (isSignInWithEmailLink(firebaseAuth, location.href) === false) return;
    if (emailParam && passwordParam) {
      try {
        // 验证身份,此时User状态将更新,成功通过邮箱验证链接登录
        await signInWithEmailLink(firebaseAuth, emailParam, location.href);
        // 设置User的密码为注册信息的password
        await updatePassword(firebaseAuth.currentUser!, passwordParam);
        message.success("邮箱验证通过,注册成功");
      } catch (error) {
        message.error("验证链接失败");
        console.warn(error);
      }
    }
  };

  useEffect(() => {
    signIn();
  }, []);
};
ts 复制代码
// 登录页 Login.tsx
export default function Login() {
  useValidateURLAuth(); // 邮箱验证链接 
  // ......
}

isSignInWithEmailLink:firebase/auth中用于判断当前页面URL是否为可验证身份链接

signInWithEmailLink:通过身份验证链接实现登录,需传入邮箱(email)和身份验证链接(authURL)

本地测试

  1. 本地pc运行项目 npm run dev,在注册页填写邮箱+密码点击【注册按钮】,将发送验证链接到邮箱

  2. pc或手机设备的邮箱收件箱中,复制邮件中验证链接的地址,在本地pc打开(要求支持科学上网),将重定向到 localhost:5173/?code=xxx....eml=xxx&pwd=xxx#/login

  3. 登录页中 useValidateURLAuth 检测到邮箱验证链接访问,验证身份并设置用户密码;注册流程完成,以后用户即可通过邮箱+密码登录;

部署

当前项目仅依赖对Firebase相关服务的调用,只需打包出静态资源包到存放Web服务器即可被访问;(确保Firebase Authentication安全域中包含你的网站域名)

Hosting

Firebase提供的Web 内容托管服务,只需几个步骤即可实现web应用部署;

开启Firebase Hosting:

Firebase CLI

用于管理、查看 Firebase 项目并在其中进行部署的工具

安装npm install -g firebase-tools

登录firebase login,根据命令行提示步骤通过身份验证

注意

本节大部分步骤须【科学上网】,如出现无法登录/部署成功情况,大概率由于你是本地代理的,尝试在当前命令行运行:

shell 复制代码
set HTTP_PROXY=http://127.0.0.1:7890 // 你自己的本地代理端口
set HTTPS_PROXY=http://127.0.0.1:7890 // 你自己的本地代理端口

这两行代码使用 set 命令,表示临时设置环境变量,关闭终端后这些设置会消失。仅适用windows,其它环境设置方法自行百度;

初始化Firebase项目

在你的项目根目录打开命令行(注意是项目根目录不是打包输出资源目录),运行命令:

shell 复制代码
firebase init

初始化过程中你可以设置当前目录对应的Firebase控制台应用;

初始化完成后,项目目录生成 firebase.jsonfirebaserc 文件;其中 firebase.json 为重要配置文件;

如我们当前项目打包输出的静态资源目录为 dist,那么我修改 firebase.json 的public字段,指定要部署到 Firebase Hosting 的目录(默认为public)

json 复制代码
{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

deploy

最后,在项目根目录命令行运行 firebase deploy,完成后即可通过firebase控制台应用域名访问

甚至支持配合GitHub 工作流,在合并PR时自动部署;更多相关文档见:firebase.google.com/docs/hostin...

本项目迁移

如果你需要将本项目迁移到自己的Firebase 应用,你需要:

  1. 创建Firebase 应用,将本项目 src/firebase.ts 的中 firebaseConfig 调整为你的应用配置;见本文【Firebase项目-应用】章节

  2. 为你的Firebase 应用开启 Authenticaion,新建一个初始用户,检查是否开启【电子邮件链接(无密码登录)】;见本文【初始用户】章节

  3. 创建 Firestore Database 数据库,并设置【索引】;见本文【Firestore Database】章节

  4. 部署,见本文【部署】章节

相关推荐
哑巴语天雨7 小时前
React+Vite项目框架
前端·react.js·前端框架
初遇你时动了情7 小时前
react 项目打包二级目 使用BrowserRouter 解决页面刷新404 找不到路由
前端·javascript·react.js
码农老起8 小时前
掌握 React:组件化开发与性能优化的实战指南
react.js·前端框架
前端没钱8 小时前
从 Vue 迈向 React:平滑过渡与关键注意点全解析
前端·vue.js·react.js
高山我梦口香糖11 小时前
[react] <NavLink>自带激活属性
前端·javascript·react.js
撸码到无法自拔12 小时前
React:组件、状态与事件处理的完整指南
前端·javascript·react.js·前端框架·ecmascript
高山我梦口香糖12 小时前
[react]不能将类型“string | undefined”分配给类型“To”。 不能将类型“undefined”分配给类型“To”
前端·javascript·react.js
乐闻x14 小时前
VSCode 插件开发实战(四):使用 React 实现自定义页面
ide·vscode·react.js
irisMoon0614 小时前
react项目框架了解
前端·javascript·react.js
web150850966411 天前
【React&前端】大屏适配解决方案&从框架结构到实现(超详细)(附代码)
前端·react.js·前端框架