MIT-6-962-Web-开发笔记-全-

MIT 6.962 Web 开发笔记(全)

001:课程主题揭晓 🎬

在本节课中,我们将了解MIT 6.962 Web开发速成课程(IAP 2025)的核心主题与目标。课程旨在通过实践项目,带领大家快速掌握现代Web开发的基础技能。

课程的核心是构建一个功能完整的Web应用程序。这不仅仅是学习孤立的语法,而是将前端、后端和数据库知识融合起来,解决实际问题。


课程核心:项目驱动学习

上一节我们了解了课程的整体目标,本节中我们来看看其核心教学方法——项目驱动学习。

这意味着所有理论知识都将围绕一个具体的、可交付的最终项目展开。你将亲自动手,从零开始搭建一个网站。

以下是项目驱动学习的几个关键优势:

  • 目标明确:所有学习都指向一个具体的成果,动力更足。
  • 实践出真知:在编码中理解概念,记忆更深刻。
  • 技能整合:学会如何让前端界面、后端逻辑和数据库协同工作。

技术栈概览

了解了学习方法后,我们来看看构建现代Web应用需要哪些关键技术。一个典型的应用就像一座房子,需要不同的“建材”和“工种”。

我们将学习三大核心组成部分:

  1. 前端(Front-end):用户直接看到和交互的部分。就像房子的装修和门窗。

    • 使用 HTML 搭建页面结构。
    • 使用 CSS 美化页面样式。
    • 使用 JavaScript 让页面产生动态交互。
  2. 后端(Back-end):在服务器上运行的逻辑,处理数据、验证身份。就像房子的地基、承重墙和管线。

    • 使用 Node.jsPython (Flask/Django) 等语言和框架编写服务器端程序。
    • 核心任务是处理HTTP请求(GET, POST)并返回响应。

  1. 数据库(Database):持久化存储所有数据的地方。就像房子的仓库。
    • 使用如 SQLite, PostgreSQLMongoDB 等系统。
    • 通过SQL语句(如 SELECT * FROM users;)或ORM(对象关系映射)来操作数据。

这三者通过HTTP协议通信,共同构成一个完整的Web应用。


你将能构建什么?

掌握了这些技术栈后,你的能力边界在哪里?本节我们通过一些例子来展望你能实现的成果。

课程结束时,你将有能力独立开发出具有实用价值的Web应用。例如:

  • 个人博客系统:发布文章,管理评论。
  • 任务管理工具:类似Todo List,可以创建、更新、删除任务。
  • 简单的社交功能:用户注册、登录、发布状态。

这些项目都涵盖了用户输入、数据存储、动态内容展示等核心Web开发模式。


总结

本节课中,我们一起学习了MIT Web开发速成课程的主题与路径。我们明确了课程采用项目驱动的学习方式,概述了构建Web应用必需的前端、后端、数据库技术栈,并预览了通过学习能够实现的项目类型

准备好迎接挑战,开始你的Web开发之旅吧。下一节,我们将深入第一个技术模块。

002:课程介绍与启动 🚀

在本节课中,我们将学习Web开发课程(Weblab 2025)的整体介绍、课程结构、学习目标以及你需要完成的准备工作。

大家好,欢迎来到Weblab 2025。我是Stanley,我是联合主席之一。我是Andy,我也是联合主席。在介绍课程的同时,我们需要快速过几件事。请确保你已经完成了环境设置,因为我们今天有实践工作坊,你需要跟随操作。如果你还没有完成设置,你将无法参与。如果你还没有完成,请访问 weblab.is/homework-zero 并确保完成此设置。如果你已经完成,那么做得很好。

什么是Weblab?💻

Weblab本质上是一门Web开发课程。我们教授从前端到后端的全栈Web开发。我们每天讲座都提供免费午餐。在IAP的后半段,我们还会举办一场竞赛,有很多现金奖品可以赢取。如果你在Web.is上注册,我们的评分是PDF制,并且会给你们6个学分的无限制选修课学分。

这是今年的课程团队。他们坐在那边。他们将在整个课程中为你们提供帮助。

重要提醒与后勤 📋

请确保你在门户网站上注册。门户网站也是我们发送电子邮件通讯的地方,同时也是你们提交课程里程碑的地方,所以请确保完成注册。

请加入Piazza。我们会在Piazza上发布很多重要信息,同时它也是一个提问的好地方,所以请确保完成加入。

讲座也将进行直播并录制上传到YouTube。你可以通过 weblab.is/live-stream 加入直播。讲座不是强制性的,我们也不点名。这门课纯粹是你投入多少,就收获多少。你也可以直接加入Zoom,如果你只想观看讲座直播的话。

关于竞赛 🏆

竞赛不是强制性的。它是课程后半段的内容,完全可选,但我们强烈鼓励你无论如何都提交你的网站,因为谁知道呢,也许你会赢得一些奖金。

如何获取帮助?🆘

在实践工作坊和讲座期间,我们提供现场帮助。你可以访问 weblab.is/q,创建一个帮助票,我们会四处走动,帮助你解答问题。

你也可以访问问题文档 weblab.is/questions,在这里你可以匿名提问。这里还有一个包含往年问题的旧文档,所以你也可以查看你的问题是否曾被问过。

我们还有 weblab.is/info,你可以在这里找到一堆有用的短链接、资源等。

今天讲座结束后,我们还会有一个团队组建交流会。如果你还没有团队,别担心,你可以课后留下来找一些人组队。

课程概览 📅

这是我们的总体时间表。第一周,我们将打下基础,学习基础知识,并一起从头开始构建一个应用。第二周,我们将开始涉及更复杂的内容,添加一些酷炫的功能,学习一些高级主题,并有一些赞助商讲座。第三周,课程部分结束,我们进入竞赛阶段,现在你可以开始工作,可以选择将你的网站提交给竞赛,或者仅仅为了学分而提交里程碑作业。第四周,你完成项目,进行最终提交,我们将进行评审,然后举行颁奖典礼。你可以在网站上查看具体的时间表。

从端到端:网站如何工作?🌐

我们将带你进行端到端的学习。这意味着我们将带你从前端走到后端。网站的世界非常庞大。像Facebook、Club Penguin、ChatGPT这样的网站背后有很多东西在运作。

那么这一切是如何工作的呢?每当我们访问一个网站时,比如打开Chrome浏览器,然后输入一个URL。但真正发生了什么?你是客户端。把自己想象成一台电脑。把服务器想象成另一台电脑,比如Facebook的电脑。你发送一个请求到那台电脑,以获取你输入到Chrome地址栏中的网站。本质上,你是在向Facebook发送一个GET请求,以获取你想要的网站。然后这台电脑会响应,因为它存储并提供你实际请求的网站。它用网页文件进行响应。这些文件随后被你使用的浏览器(可能是Chrome)接收,然后用来显示网站。这就是我们在互联网上看到一切的方式。我们有URL,然后我们向服务器请求网站,取回那些网页文件,然后我们看到屏幕上显示的一切。

这是对客户端-服务器架构的一个相当简化的视图。而我们实际接收到的这些网页文件是HTML文件,用于显示内容,是基本的构建块;CSS文件,用于样式化HTML;JavaScript,让这些文件具有交互性;以及像图片、GIF等资源。

第一周我们将构建什么?🐱

我们将从头开始构建整个网站。它将被称为Catbook,你也可以通过访问这个链接来查看。它将有一个主页、个人资料、实时聊天、游戏,以及其他方面,比如身份验证。

课程里程碑 📝

如果你为了学分而选修这门课,有四个里程碑需要完成。里程碑0将于本周三截止。这将是构思阶段。本质上,你只需要在一个Google表单上创建一个主题或项目想法列表,然后提交该表单。这很简单,应该只需要一个小时甚至更少。我们很快就会发出相关信息。

接下来是项目提案,将在本周末进行。你需要为某个时间段(周六或周日下午1点到5点之间)注册一个时间槽。大约30分钟或更短。到这个时候,你应该有一个项目想法,并向一些工作人员展示你的项目提案。我们会给出反馈,并希望能提供一些有用的建议,比如如何开始,你可能会遇到哪些陷阱,以及如何驾驭整个课程。

里程碑2的时间较长,将于1月22日星期三截止,这是你的最小可行产品。所以是你的项目的某种工作原型。我们不期望看到完整的成品,但我们希望到那时已经实现了很多功能。

然后是最终网站,里程碑3,将于1月29日截止。这大约是在IAP第四周结束的时候。关于这些里程碑有任何问题吗?提醒一下,通过这门课只需要你完成所有这四个里程碑。所以只要你付出努力,并且清楚地表明你付出了,那么通过这门课并不难。

你将构建的网站要求 🛠️

你将构建一个由后端支持的动态网站。不仅仅是像通过某些网站构建器可能构建的前端,你几乎要自己创建整个东西。所以前端、后端、数据库,一切都将由你构建。我们希望网站能基于用户账户提供个性化体验,就像你在示例网站上看到的某种身份验证。我们希望它能根据登录用户的不同而不同。满足最低安全要求。这不算大事,但我们不希望你的网站存在漏洞之类的问题。我们希望你有创意,为网站和实现提供自己原创的设计。很多代码将通过我们提供的骨架代码给你,但除此之外,我们希望你自己尽可能多地实现。所有代码都应该是你自己的。如果你确实使用了任何外部代码,请正确引用,并尽可能限制其使用。

现在每个人都应该有一个GitHub仓库了。如果你还没有,没关系。你应该已经收到了一封电子邮件,指导你设置GitHub仓库。这个仓库应该在你的团队成员之间共享,只对你们私有。我们也可以查看这个仓库,以便我们能够帮助你们。

网站构建的禁止事项 🚫

你不能使用现有的网站构建器(如WordPress或Squarespace)创建网站。你不能使用以前项目的任何部分,以确保对每个人来说竞争环境是公平的。你不能外包你的开发工作。你不能让你那些精通Web开发但不修这门课的朋友为你做网站。我们希望这门课主要是一个让你通过实际创造来学习的机会。

评审标准 🏅

评审有四个类别。第一个是功能性。就是你的网站是否按预期工作。这些是技术组件,我们会像玩一样测试你的网站,确保它运行良好。可用性更多是关于用户体验,确保网站易于使用。美观性,不言自明。然后是概念执行。我们希望你的网站有某种目的。Web开发的主要用途实际上是创造真正有用或实现某个目标的产品。所以我们希望你的网站能做一些事情,或者解决你提出的某个问题,并提供一个好的解决方案。

如何在整个课程中获得帮助?🤝

首要工具是Piazza。如果你还没有加入,请加入 weblab.is/piazza。随时提问。我认为在课程期间,我们的平均响应时间大约是5分钟。我们一直在上面,尽可能快地回答问题。

如果你有更多关于课程后勤的问题,想查看时间表,或者想阅读更多关于竞赛的规则,请访问课程网站 weblab.is/info 获取资源。它包含了所有你可能需要的短链接。在底部,你可以找到我们在这几张幻灯片中将要介绍的所有内容,以及更多关于竞赛的信息和常见问题解答。

也请参加办公时间。我认为面对面的反馈和帮助实际上比Piazza更有用。办公时间通常是晚上7点到9点。你可以在时间表上找到它们。是的,今天我们从晚上7点到9点有办公时间。如果你需要关于作业0设置方面的帮助,请参加。或者如果你有关于讲座的其他问题,也请参加。办公时间将在Stata中心的32-082室举行。

最后,我们有延长的办公时间,称为黑客松。它们基本上从晚上7点运行到凌晨1点。我们的赞助商也会在那里看看大家的进展。这些活动在第二周和第三周举行,会有两次。会提供食物、饮料等。这很有趣,也很有帮助。

除了构建网站,你还能学到什么?🎓

除了从头开始构建网站,你还会学到很多关于UI设计的知识。学习版本控制,这在MIT未来你选修的所有课程中都非常有帮助。这就是我之前提到的Git。如果你不是6-3专业或某些6系专业的学生,这算是MIT 6-3课程的一个体验。希望你喜欢它,并在未来选修更多的计算机科学课程。如果这是你第一次学习Web开发,希望这是一个很好的初次体验,让你从头开始创造一些东西,创造你自己的东西。还有更多。

最重要的是,Weblab不是一门传统的MIT课程。它在IAP期间开设,我们采用PDF评分制。所以你基本上是按通过/不通过来评分的,只要你完成所有里程碑,你就会通过,并获得通过的成绩。再次确保你也在门户网站上注册,以便我们能够为你记录里程碑。

这门课也是完全由学生运营的。所以Weblab的每一位助教或工作人员都在这里,以确保你在这门课上的体验尽可能好。我们想尽可能多地提供帮助。这就是我们开设这门课的原因。任何时候,如果你在课堂上有问题,请访问 weblab.is/questionsweblab.is/q。Q让你在票上提问。然后工作人员会亲自去拜访你,帮助你回答问题。

Weblab也是一个在讲座、办公时间和黑客松期间结识新朋友的好机会。希望你会花很多时间和你的团队在一起,创建一个你们都喜欢的网站,然后度过一段美好的时光。

年度主题 🎨

最后一部分是,每年我们都有一个主题。过去几年的主题有“环游世界”、“记住未来”,然后两年前是“你和我”。去年是“发送它还是混合它”。它们相当开放。但这个主题本质上是你的网站将围绕这个主题展开。希望它足够开放,让你可以做任何你想做的事情,并以某种方式与主题相关联。按照传统,我们有一个主题揭晓视频。


本节课中我们一起学习了Weblab 2025课程的总体介绍、课程结构、里程碑要求、网站构建的基本概念以及如何获取帮助。请确保完成环境设置并加入必要的沟通渠道,为接下来的学习做好准备。

003:Git基础 🐙

在本节课中,我们将要学习代码协作的核心工具——Git。我们将了解为什么需要Git,它的基本工作原理,以及如何使用它来管理代码版本和与他人协作。

为什么需要Git?

上一节我们介绍了代码协作的挑战,本节中我们来看看具体的解决方案。

想象一下,你和你的队友正在共同开发一个网站项目。如果直接共享和编辑同一份代码文件,会遇到几个问题:

  • 相互干扰:如果队友正在修改网站的一个核心功能,导致网站暂时无法运行,你的工作也会被迫中断。
  • 合并困难:如果你们同时修改了同一个文件的不同部分,最后需要手动合并这些更改,过程繁琐且容易出错。
  • 版本混乱:如果修改后发现了问题,很难快速回退到之前能正常工作的版本。

因此,一个理想的协作工具需要满足以下几点:

  1. 独立的本地副本:每个人都能在自己的电脑上工作,不影响他人。
  2. 便捷的合并:能够轻松地将不同人的修改合并到一起。
  3. 版本追踪:能够记录每次更改,并可以轻松回退到任意历史版本。
  4. 同步机制:能清楚地知道哪个版本是最新的。

Git就是这样一个工具,它通过追踪文件的变化历史,完美地解决了上述问题。

Git的核心概念:提交(Commit)

Git在本质上是一个版本控制系统,它通过一些巧妙的数学方法追踪文件的变化。

你可以将Git的提交(Commit)理解为代码变化的“打包快照”。以下是创建一个提交的基本流程:

  1. 工作区修改:你在本地文件中进行编辑,例如添加或删除代码行。
  2. 暂存更改:使用 git add <文件名> 命令,将你想要保存的更改放入“暂存区”(Staging Area)。这就像把要打包的物品放进一个箱子。
  3. 创建提交:使用 git commit -m “提交说明” 命令,将暂存区中的所有更改打包成一个永久的记录(即提交),并附上描述信息。这就像给箱子贴上标签并存入仓库。

这个流程的代码表示如下:

# 1. 修改文件(例如,编辑了 index.html)
# 2. 将更改添加到暂存区
git add index.html
# 3. 将暂存区的更改创建为一个提交
git commit -m “添加了网站标题”

每个提交都有一个唯一的ID(一长串哈希值),用于在历史记录中精确标识它。

实践:初始化仓库与基本命令

现在,让我们动手创建一个Git仓库并体验基本操作。首先,你需要打开终端(Windows用户建议使用Git Bash)。

以下是几个必须掌握的基本终端命令:

  • ls:列出当前目录下的所有文件和文件夹。
  • cd <目录名>:进入指定的目录。
  • cd ..:返回上一级目录。
  • mkdir <新目录名>:创建一个新的文件夹。

接下来,按照以下步骤初始化你的第一个Git仓库:

  1. 创建一个用于练习的目录并进入:
    mkdir git_demo
    cd git_demo
    
  2. 将该目录初始化为Git仓库:
    git init
    
    这个命令会创建一个隐藏的 .git 文件夹,Git用它来管理所有版本信息。

现在,你的 git_demo 文件夹就是一个Git仓库了。你可以尝试创建一个文件(比如 myfile.txt),写入一些内容,然后按照上一节介绍的 git addgit commit 流程创建你的第一个提交。使用 git log 命令可以查看提交历史。

分支(Branch)的作用

上一节我们学会了创建线性历史,本节中我们来看看如何并行开发。

在实际项目中,我们经常需要同时进行多项任务。例如,在开发新功能的同时,需要修复旧版本的一个紧急错误。如果都在同一条开发线上修改,代码会变得非常混乱。

Git的分支功能解决了这个问题。分支可以理解为从某个提交点衍生出来的独立开发线

创建并切换到一个新分支的命令是:

git checkout -b <新分支名>

例如,从 main 分支创建一个用于开发“点赞功能”的新分支:

git checkout -b feature-like-button

你可以在不同的分支上独立工作,互不干扰。完成一个分支的工作后(例如功能开发完成或bug修复完毕),可以将其合并回主分支。

合并(Merge)与远程协作

当你在一个分支(如 feature-like-button)上完成了工作,需要将其成果整合到主分支(main)时,就需要进行合并。

  1. 首先,切换回主分支:
    git checkout main
    
  2. 然后,执行合并命令:
    git merge feature-like-button
    
    Git会自动尝试将两个分支的修改合并到一起。合并完成后,通常可以删除已合并的特性分支:
    git branch -d feature-like-button
    

到目前为止,我们的操作都在本地进行。为了与他人协作,我们需要一个中央服务器来同步大家的代码。GitHub 就是最流行的Git远程仓库托管服务。

以下是远程协作的核心命令:

  • git clone <仓库地址>:将远程仓库(如GitHub上的项目)完整地复制到本地。
  • git push:将你本地的提交上传到远程仓库。
  • git pull:从远程仓库获取最新的提交并合并到本地。这相当于先执行 git fetch(获取更新)再执行 git merge(合并到当前分支)。

此外,在WebLab工作坊中,我们可能会用一个命令来放弃所有未提交的本地修改,让仓库回到最近一次提交的状态(注意:此操作会丢失未保存的更改,请谨慎使用):

git reset --hard

总结

本节课中我们一起学习了Git的基础知识。我们了解了使用Git进行代码版本管理和团队协作的必要性,掌握了提交(Commit)、分支(Branch)和合并(Merge)这些核心概念。我们还实践了从初始化仓库、创建提交到分支管理的基本操作,并介绍了如何通过GitHub进行远程协作。

记住,Git是一个强大的工具,初学时可能会感到复杂,但通过不断练习,你会逐渐熟悉它。在接下来的课程和项目中,你将有很多机会应用这些知识。

004:HTML与CSS基础

在本节课中,我们将要学习网页开发的两大基石:HTML和CSS。HTML负责构建网页的结构,而CSS则负责美化网页的外观。我们将从最基础的概念开始,逐步了解如何创建和设计一个简单的网页。

概述:网页的骨架与皮肤

在开始之前,让我们先谈谈为什么学习HTML和CSS很重要。网页设计的核心目标是以美观的方式向用户传递信息。HTML和CSS正是实现这一目标的前端开发核心技术。

一个常见的比喻是:如果把一个完整的网页比作一个人,那么HTML就是支撑结构的骨架,而CSS则是让这个人看起来赏心悦目的华丽服饰

幸运的是,HTML的核心思想非常简单,你可以把它想象成一堆嵌套的盒子或容器。让我们看看这具体意味着什么。

HTML:网页的结构骨架

HTML文档的基本结构

首先,在任何HTML文档的开头,你都需要包含一个声明:<!DOCTYPE html>。这行代码告诉你的网页浏览器使用最新版本的HTML来解析页面。它很容易被忘记,但很重要。

接下来,我们进入第一个HTML元素。你会看到它有一种“开始”和“结束”的结构:

<元素名>内容</元素名>

这是因为HTML元素本质上是容器。一个元素由开始标签(<元素名>)、内容和结束标签(</元素名>)组成。任何包含在其中的内容都放在开始和结束标签之间,非常直观。

你可以在这个结构中看到容器的嵌套关系:<html>容器包含了<head><body>,而它们内部又包含了其他元素。

HTML的语法只要记住它必须尊重容器结构就很简单。你不能让标签错误地交叉嵌套,必须确保它们被正确地嵌套闭合。

核心HTML元素解析

让我们回到代码,更深入地看看各个部分的作用。

  • <html>:这是根元素,包含了网页中的所有内容。
  • <head>:这里存放的是“元数据”。元数据不会直接显示在网页上,但有助于网页正常运行。例如,<title>标签定义的内容会显示在浏览器标签页上,而不是网页正文里。你还可以在这里链接外部文件,比如样式表。
  • <body>:这里包含所有实际显示在网页上的内容,比如标题和段落。

以下是目前我们介绍过的一些基本标签:

  • <h1>, <h2>, <h3>...:标题标签,数字越小,标题级别越高。
  • <p>:段落标签。
  • <div><span>:这两个是通用容器标签,非常重要。当你不知道用其他什么标签时,可以使用它们。<div>是块级元素,通常用于组合其他元素并独占一行;<span>是行内元素,用于在行内组合内容,不会导致换行。

为元素添加属性

我们看到了HTML元素的基本结构。但如果你想以无法仅通过添加内部文本来实现的方式修改元素,该怎么办呢?

你可以在开始标签内添加“属性”。一个属性由属性名和其值(用引号包裹)组成。

以下是一些使用属性的常见场景:

1. 插入链接
链接的语法是使用<a>标签(“a”代表“anchor”,锚点)。你需要添加href属性,其值就是链接地址。

<a href="https://web.mit.edu">点击这里访问MIT网站</a>

2. 插入图片
要插入图片,使用<img>标签和src属性,其值是图片文件的路径(相对于你的项目目录)。

<img src="my_image.jpg">

这里有个特别之处:图片标签是“自闭合”的,因为它内部不需要包含任何内容,所以没有单独的结束标签,而是在标签末尾用/闭合(现代HTML中/通常可省略)。

另外,为了在图片无法加载时提供替代文本,以及便于代码阅读和搜索引擎理解,建议总是为图片添加alt属性。

<img src="logo.png" alt="公司Logo">

其他常用元素:列表

列表非常简单,分为有序列表和无序列表。

  • <ul>:无序列表(项目符号列表)。
  • <ol>:有序列表(数字编号列表)。
  • <li>:列表项,用于<ul><ol>内部。

关于<div>和语义化HTML

<div>是一个非常通用的元素,在做样式设计和引用元素组时可能会大量使用。你可能会想,为什么不直接用<div>来做所有事情呢?

这有几个原因。根据MDN Web文档的建议,<div>应该只在没有其他更合适的“语义化元素”时使用。语义化元素是指那些本身就表明了其用途的标签,例如:

  • <nav>:导航栏
  • <header>:页眉
  • <footer>:页脚
  • <section>:章节
  • <article>:文章

使用语义化元素的好处:

  1. 提高代码可读性:当你一周后回看项目时,更容易理解代码结构。
  2. 有助于搜索引擎优化(SEO):网络爬虫和机器人能更好地理解你网站的内容结构。

总结:不要滥用<div>。虽然它很有用,通常是你的首选,但请尽量使用MDN Web Docs等资源来寻找最能满足你需求的语义化元素。

上一节我们介绍了如何使用HTML构建网页的骨架结构,本节中我们来看看如何用CSS为这个骨架穿上漂亮的“衣服”。

CSS:网页的样式皮肤

CSS代表“层叠样式表”,用于设置浏览器中元素的样式。“层叠”指的是规则的应用方式,因为规则会按照特定顺序相互覆盖。

回到之前的比喻,CSS为你HTML提供的骨架增添了外观

CSS规则集的基本结构

一个基本的CSS规则集看起来是这样的:

选择器 {
    属性: 值;
}
  • 选择器:用于指定你想要样式化的HTML元素。可以是元素名(如divh1),也可以是类(class)或ID。
  • 属性:你想要调整的样式属性,例如color(颜色)、font-family(字体)。
  • :你为属性设置的具体值,例如red(红色)、Arial(字体)。

选择器:类(Class)与ID(ID)

除了直接使用元素名作为选择器,更常用且灵活的是使用ID

类的使用

  1. 在HTML中,通过class属性为元素添加类名,一个元素可以有多个类。
    <div class="info highlight">这是一个信息框</div>
    
  2. 在CSS中,使用点号.后跟类名来定义样式。
    .info {
        color: blue;
        font-weight: bold;
    }
    .highlight {
        background-color: yellow;
    }
    

ID的使用

  1. 在HTML中,通过id属性为元素添加ID,一个元素只能有一个ID,且同一ID在整个HTML文档中应该是唯一的。
    <div id="main-header">主标题</div>
    
  2. 在CSS中,使用井号#后跟ID名来定义样式。
    #main-header {
        font-size: 2em;
        text-align: center;
    }
    

类与ID的关键区别

  • 唯一性:ID在文档中必须是唯一的;类可以被多个元素共享。
  • 特异性:在CSS的“层叠”规则中,ID选择器的优先级高于类选择器,类选择器的优先级又高于元素选择器。
  • 最佳实践主要使用类,因为它们更灵活、可复用,并且优先级足够覆盖通用的元素样式,同时避免了ID的唯一性限制。

将CSS链接到HTML

我们编写了CSS,但如何让它应用到HTML上呢?

你需要将CSS文件链接到HTML文档的<head>部分。这是通过<link>标签实现的。

<!DOCTYPE html>
<html>
<head>
    <title>我的网页</title>
    <!-- 链接外部样式表 -->
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    ...
</body>
</html>

你可以链接多个样式表来组织你的代码,例如一个专门负责字体,另一个负责布局。

总结

本节课中我们一起学习了网页开发的基础:

  • HTML是你的嵌套盒子,它定义了网页的结构和内容。记住使用语义化标签,并正确嵌套元素。
  • CSS是你的样式描述列表,它通过选择器、属性和值为HTML元素添加颜色、布局等视觉效果。掌握类和ID的使用是高效编写CSS的关键。
  • 最后,通过在HTML的<head>中使用<link>标签,将两者结合起来,你就得到了一个完整的、有样式的网站。

记住,实践是学习的最佳方式。尝试创建你自己的简单HTML文件,并添加一些CSS样式,看看会发生什么变化!

005:Catbook 个人资料页构建

在本节课中,我们将学习如何使用HTML和CSS构建一个名为“Catbook”的个人资料页面。我们将从零开始,逐步添加内容、样式和布局,最终完成一个结构清晰、样式美观的网页。

准备工作

首先,我们需要设置开发环境并获取项目文件。

我们将使用VS Code作为代码编辑器,并使用终端来执行命令。请确保你已经打开了这两个工具。

以下是设置步骤:

  • 克隆仓库:访问 weblab.is/catbook,点击绿色的“Code”按钮,复制仓库链接。在终端中,使用 git clone 命令加上你复制的链接来克隆项目。
  • 切换分支:克隆完成后,使用 cd catbook-react 命令进入项目目录。然后,运行 git checkout w0-starter 命令切换到本课程的起始分支。
  • 打开项目:在VS Code中打开 catbook-react 文件夹。然后,你可以将 index.html 文件拖拽到编辑区查看初始代码。
  • 安装代码格式化工具:为了保持代码整洁,我们安装一个名为“Prettier”的扩展。在VS Code的扩展面板中搜索并安装它。安装后,进入设置,搜索“formatter”并将其设置为“Prettier”。接着,搜索“format on save”并勾选该选项,这样每次保存文件时代码都会自动格式化。
  • 在浏览器中预览网页:要查看网页效果,只需在文件管理器(如Finder或资源管理器)中找到 index.html 文件,并将其拖拽到浏览器窗口中即可。之后,每次修改代码并保存后,在浏览器中刷新页面就能看到最新效果。

我们的最终目标是构建一个如下图所示的个人资料页面。

第一步:添加基础HTML内容

上一节我们完成了环境设置,本节中我们来看看如何为网页添加基础内容。

首先,我们将在HTML文档中添加一些文本内容,并使用合适的语义化标签。我们的目标是让页面初步具备“关于我”和“最喜欢的猫”两个部分。

以下是需要添加的内容和对应的HTML标签:

  • 主标题:使用 <h1> 标签添加一个“Bucabuca”的大标题。
  • 水平分割线:使用自闭合标签 <hr> 在主标题下方添加一条横线。
  • “关于我”部分:使用 <section> 标签创建一个区域,内部使用 <h4> 作为小标题,<p> 标签添加段落文本。
  • “最喜欢的猫”部分:同样使用 <section> 标签创建另一个区域,结构同上。

完成后,你的页面应该会显示出结构化的文本内容。如果你在过程中遇到困难,可以随时使用以下命令将代码重置到本步骤的完成状态:

git reset --hard
git checkout w0-step1

第二步:添加图片

现在我们的页面有了文字,接下来让它更生动一些,添加一张图片。

我们将在主标题上方添加一张已经存在于项目文件夹中的猫咪图片(cat.png)。我们需要使用 <img> 标签,并为其设置 src(图片路径)和 alt(替代文本)属性。

尝试自己实现一下,让图片显示出来。完成后,可以使用以下命令核对答案:

git reset --hard
git checkout w0-step2

第三步:链接CSS并实现居中

为了让页面更好看,我们需要引入CSS。首先,我们要将CSS文件链接到HTML。

在HTML文档的 <head> 部分,添加一个 <link> 标签来链接外部的 styles.css 样式表。这是一个自闭合标签。

链接好样式表后,我们就可以开始添加样式了。首先,我们尝试让主标题居中显示。

  1. styles.css 文件中,我们创建一个名为 .ut-center 的CSS类(类选择器以点号开头),并为其添加 text-align: center; 属性。
  2. 然后,在HTML中,为 <h1> 标签添加 class="ut-center" 属性。

这样,主标题就应该居中了。这种只负责单一功能(如居中)的类,我们通常称为“工具类”(utility class)。

完成后,使用以下命令进入下一步:

git reset --hard
git checkout w0-step3

第四步:应用居中样式

我们已经学会了如何创建并使用工具类,现在来发挥它的复用能力。

我们希望“关于我”和“最喜欢的猫”这两个部分的内容也能居中。由于我们之前已经定义了 .ut-center 类,现在只需将这个类分别添加到这两个 <section> 标签上即可。

一个技巧是:将类应用在容器元素(如 <section>)上,其内部的所有子元素通常也会继承相关的样式效果,这比给每个内部元素单独加类要高效得多。

完成后,使用以下命令重置并进入字体学习环节:

git reset --hard
git checkout w0-step4

第五步:引入自定义字体

默认字体可能有些单调,现在我们来为网页添加一个更美观的字体。

我们将使用 Google Fonts 网站来寻找并引入字体。以“Open Sans”字体为例:

  1. 访问 fonts.google.com,搜索并选择你喜欢的字体。
  2. 点击“Get font”按钮,在弹出页面中选择“@import”方式。
  3. 复制 @import 语句后面的CSS代码(不包含 <style> 标签)。
  4. 将这段代码粘贴到你的 styles.css 文件的最顶部。
  5. 最后,在CSS中为 body 选择器添加 font-family 属性,将其值设置为你引入的字体名,例如 ‘Open Sans‘。为了兼容性,可以在后面添加备用字体,如 sans-serif

刷新页面,你会发现整个页面的字体都发生了变化。

第六步:创建导航栏并引入CSS变量

接下来,我们为页面添加一个顶部的导航栏。

在HTML文档的 <body> 标签内的最顶部,添加一个 <nav> 元素,并在其中放入一个包含“Catbook”文字的 <h1> 标题。为了后续样式化,我们可以预先为这些元素添加一些类名,例如 class=“nav-container“class=“nav-title“

在开始样式化导航栏之前,我们先学习一个有用的CSS功能:变量。CSS变量允许你定义可重复使用的值(如颜色、尺寸)。

styles.css 文件的 :root 选择器内(这代表整个文档的根元素),我们可以定义变量。语法是 --变量名: 值;。例如,定义主色和白色:

:root {
  --primary: #007bff;
  --white: #ffffff;
}

定义好变量后,我们就可以在样式表中使用 var(--变量名) 来引用它们。现在,让我们用变量来为导航栏标题添加样式:设置颜色和字体大小,并清除一些默认的边距。

第七步:完善导航栏样式与盒模型

现在我们的导航栏有了基本样式,但看起来还不太理想。本节我们将深入CSS盒模型,调整边距(margin)和内边距(padding),让导航栏更美观。

首先,我们希望导航栏有蓝色的背景和白色的文字。这可以通过为我们之前添加的 .nav-container 类设置 background-colorcolor 属性来实现,并使用我们定义好的CSS变量。

接着,你可能注意到页面整体与浏览器边缘有空白。这是因为 <body> 元素有默认的 margin。我们可以通过开发者工具(在页面右键点击“检查”)来查看这个“盒模型”。选中 <body> 元素,你会看到表示外边距(margin)的区域。

为了消除这个空白,我们可以在CSS中为 body 选择器设置 margin: 0;margin 属性可以接受1到4个值,分别代表上、右、下、左四个方向(顺时针顺序)。

然后,我们发现导航栏内的文字紧贴边缘也不好看。这时需要使用 padding(内边距)在容器内部创建空间。我们为 .nav-container 添加 padding: 8px 16px;,这会在上下方向添加8像素,左右方向添加16像素的内边距。这里遵循了“8点网格系统”的设计规范,即使用8的倍数作为间距单位,以使设计更协调。我们可以将这些常用间距也定义为CSS变量以便复用。

最后,我们来给头像图片添加圆角。首先给图片 <img> 标签添加一个类,例如 class=“avatar“。然后在CSS中创建 .avatar 选择器,使用 border-radius 属性设置圆角半径,例如 border-radius: var(--medium);(假设 --medium 是16px)。

一个有趣的练习是尝试将头像变成完美的圆形,你可以课后尝试一下。

第八步:使用Flexbox进行布局

目前,我们的“关于我”和“最喜欢的猫”两部分是上下堆叠的。最终效果中,它们应该是并排显示的。本节我们将学习使用Flexbox来实现这种布局。

Flexbox是一种现代的CSS布局模型,可以轻松控制子元素的排列、对齐和尺寸。

首先,我们需要一个容器来包裹这两个 <section>。在它们外面添加一个 <div>,并为其添加一个工具类,例如 class=“flex-container“

然后,在CSS中为 .flex-container 设置 display: flex;。默认情况下,Flexbox会将子元素水平排列成一行(flex-direction: row),所以这两个部分就会并排显示了。你可以尝试将 flex-direction 改为 column,看看它们如何变回垂直排列。

第九步:控制Flex子项尺寸

现在两个部分并排了,但它们的宽度并不相等,也没有占满可用空间。本节我们学习如何使用Flexbox属性控制子元素的尺寸。

Flexbox提供了 flex-growflex-basis 等属性来控制子项的伸缩性。

  • flex-grow 定义子项的放大比例,所有子项默认值为0(不放大)。如果其中一个设为2,它将获得其他子项两倍的剩余空间。
  • flex-basis 定义了在分配多余空间之前,子项占据的主轴空间。

为了让两个部分宽度相等并填满容器,我们可以创建一个新的类(例如 .flex-item),并为其设置 flex-grow: 1;flex-basis: 0;。然后将这个类同时应用到两个 <section> 上。这样,它们就会以相同的比例增长,从而获得相等的宽度。

总结与扩展

本节课中我们一起学习了构建一个完整个人资料页面的全过程。我们从创建基础的HTML结构开始,逐步添加图片、链接CSS、使用工具类、引入外部字体、创建导航栏、运用CSS变量、理解盒模型(margin和padding),最后使用强大的Flexbox模型实现了复杂的水平布局。

你可以在 w0-complete 分支查看最终完整的代码。此外,这里有一些有用的资源供你深入学习:

  • Flexbox详解weblab.is/flex (CSS-Tricks指南)
  • 交互式学习游戏:Flexbox Froggy, Grid Garden

通过本课的学习,你已经掌握了前端开发的核心基础。下周我们将进入更高级的CSS主题。

006:JavaScript基础入门

在本节课中,我们将要学习JavaScript的基础语法和核心概念。JavaScript是Web开发的“肌肉”,它能让静态的网页变得动态和可交互。我们将从基本数据类型开始,逐步介绍变量、运算符、控制流、数组、对象和函数。

概述

JavaScript是一种编程语言,用于操作网页内容,使其能够响应用户交互。它与Java无关。本节课将快速介绍JavaScript的基本语法,假设你已有类似Python的编程基础。如果你完全没有编程经验,建议课后参考课程网站上的补充资源。

基本语法与数据类型

上一节我们介绍了JavaScript在Web开发中的角色,本节中我们来看看它的基本语法和数据类型。

JavaScript有五种原始数据类型:

  • 数字:包括整数和浮点数,例如 423.14
  • 布尔值truefalse
  • 字符串:文本数据,例如 "Hello"
  • Undefined:表示变量已声明但尚未赋值。
  • Null:表示变量被显式地设置为“无值”。

定义变量使用 let 关键字,常量使用 const 关键字。请避免使用旧的 var 关键字。

let count = 10; // 变量
const PI = 3.14159; // 常量

每个语句以分号结尾。代码块使用花括号 {} 界定。注释使用双斜杠 //

运算符与比较

了解了如何存储数据后,我们来看看如何操作和比较它们。

算术运算符(+, -, *, /, **)和字符串拼接(+)的行为符合预期。

然而,比较运算符需要特别注意。在JavaScript中,我们使用三重等号 === 来严格比较两个值是否相等(包括类型和值)。使用双重等号 == 进行比较时,JavaScript会进行类型转换,这可能导致意想不到的结果,因此通常避免使用。

console.log(2 == "2"); // true (类型转换后相等)
console.log(2 === "2"); // false (类型不同)

不等于的比较使用 !==

控制流:条件与循环

掌握了数据操作,接下来我们学习如何控制程序的执行流程。

条件语句(if, else if, else)和循环语句(while, for)的逻辑与其他语言类似。

以下是条件语句的示例:

if (hour < 12) {
    console.log("Good morning!");
} else if (hour < 18) {
    console.log("Good afternoon!");
} else {
    console.log("Good evening!");
}

以下是遍历数组的两种常见 for 循环方式:

// 方式一:通过索引遍历
for (let i = 0; i < pets.length; i++) {
    console.log(pets[i]);
}

// 方式二:直接遍历元素 (for...of)
for (const animal of pets) {
    console.log(animal);
}

数组与对象

控制流让我们能处理复杂逻辑,而数组和对象则让我们能组织更复杂的数据。

数组类似于Python中的列表,可以存储一系列值。它们是零索引的。

let pets = ["cat", "dog", "guinea pig", "bird"];
console.log(pets[0]); // 输出 "cat"
pets.push("rabbit"); // 在末尾添加元素
let lastPet = pets.pop(); // 移除并返回最后一个元素

JavaScript对象类似于Python的字典,是键值对的集合。

let car = {
    make: "Toyota",
    model: "Camry",
    year: 2020
};
// 访问属性
console.log(car["make"]); // 方式一
console.log(car.model); // 方式二

可以使用展开运算符 ... 来复制数组或对象,以避免引用同一内存地址的问题。

let originalArray = [1, 2, 3];
let copiedArray = [...originalArray]; // 创建新数组

函数

最后,我们将学习如何将代码封装成可重用的模块——函数。

在JavaScript中,函数是一等公民,可以像其他值一样被赋值、传递。我们主要使用箭头函数语法。

// 定义一个函数并将其赋值给一个常量
const celsiusToFahrenheit = (celsius) => {
    return (celsius * 9/5) + 32;
};

// 调用函数
let roomTemp = celsiusToFahrenheit(26);
console.log(roomTemp); // 输出 78.8

函数可以作为参数传递给另一个函数,这种函数被称为回调函数

// 定义一个简单的函数
const printSomething = () => {
    console.log("Hello after 5 seconds!");
};

// 将函数本身(而不是调用结果)传递给 setTimeout
setTimeout(printSomething, 5000);

总结

本节课中我们一起学习了JavaScript的核心基础。我们了解了它的基本数据类型、变量声明、运算符(特别是严格相等 ===)、控制流语句、数组与对象的使用,以及如何定义和调用函数。理解这些概念是进行后续Web交互开发的关键。请务必完成课后设置,安装Node.js,为明天的课程做好准备。

007:高级JavaScript概念

概述

在本节课中,我们将深入学习JavaScript中的两个核心概念:回调函数数组方法。这些概念对于理解后续的React框架至关重要。我们将从复习基础函数和数组操作开始,逐步探讨如何编写更通用、可复用的代码。


函数回顾

上一节我们介绍了JavaScript的基本语法,本节中我们来看看函数的定义方式。

一个函数可以看作是一个输入输出的机器。输入位于括号内,箭头 => 表示将输入“喂”给函数体,函数体包裹在花括号 {} 中,并产生输出。

公式

(输入参数) => { 函数体; return 输出; }

为了能调用这个函数,我们需要将其赋值给一个变量。

代码

const celsiusToFahrenheit = (tempC) => {
    return (tempC * 9/5) + 32;
};

数组操作回顾

在深入新概念之前,我们需要回顾两个基本的数组操作:pushpop

  • push:向数组末尾添加一个新元素。
  • pop:从数组末尾移除一个元素。

此外,遍历数组是常见操作。以下是使用 for 循环遍历数组的标准语法。

代码

for (let i = 0; i < array.length; i++) {
    // 对 array[i] 进行操作
}

实践:转换温度数组

基于以上知识,我们来完成一个练习:编写一个函数,它接收一个摄氏温度数组,并返回一个对应的华氏温度新数组。注意:不应修改原数组。

以下是实现该功能的一种方法。

代码

const arrayCelsiusToFahrenheit = (arrayCelsius) => {
    const arrayF = []; // 创建新数组
    for (let i = 0; i < arrayCelsius.length; i++) {
        const tempF = (arrayCelsius[i] * 9/5) + 32; // 转换单个温度
        arrayF.push(tempF); // 将结果加入新数组
    }
    return arrayF; // 返回新数组
};

引入回调函数

现在,假设我们还需要将数组转换为开尔文温度,或者进行其他运算。复制粘贴上述代码并只修改核心计算部分(高亮部分)效率很低。

我们希望重用那90%相同的代码(循环和构建新数组),只替换核心计算逻辑。这就是回调函数的用武之地。

我们将创建一个通用的 modifyArray 函数。它接收一个数组和一个“转换函数”作为参数,然后对数组的每个元素应用这个转换函数,并返回新数组。

概念modifyArray(原始数组, 转换函数) => 新数组


实现通用数组修改函数

以下是 modifyArray 函数的实现。注意它与之前特定温度转换函数的区别。

代码

const modifyArray = (inputArray, transformFunc) => {
    const outputArray = [];
    for (let i = 0; i < inputArray.length; i++) {
        // 对每个元素应用传入的转换函数
        const transformedElement = transformFunc(inputArray[i]);
        outputArray.push(transformedElement);
    }
    return outputArray;
};

// 定义具体的转换函数
const cToF = (tempC) => (tempC * 9/5) + 32;

// 使用通用函数
const tempsC = [0, 20, 100];
const tempsF = modifyArray(tempsC, cToF);

通过这种方式,我们分离了“遍历数组”的逻辑和“处理每个元素”的逻辑,代码变得更具可复用性


数组的 map 方法

实际上,JavaScript 已经内置了与我们 modifyArray 功能几乎相同的方法,叫做 map。区别在于调用语法:它是数组对象的一个方法。

代码

const newArray = originalArray.map((element) => {
    // 返回转换后的元素
    return transformedElement;
});

使用 map 重写温度转换:

代码

const tempsF = tempsC.map((tempC) => (tempC * 9/5) + 32);

map 方法封装了循环过程,让我们只需关心对每个元素的转换规则。


数组的 filter 方法

另一个强大的数组方法是 filter。它用于根据条件筛选数组元素。它接收一个函数,该函数对每个元素返回 true(保留)或 false(过滤掉)。

代码

const numbers = [1, -1, 2, -2, 3];
const positiveNumbers = numbers.filter((x) => x > 0);
// positiveNumbers 现在是 [1, 2, 3]

const staff = [‘Alice‘, ‘Bob‘, ‘Annabel‘, ‘Charlie‘];
const filteredStaff = staff.filter((name) => name !== ‘Annabel‘);
// filteredStaff 现在是 [‘Alice‘, ‘Bob‘, ‘Charlie‘]

为什么使用回调函数?

我们使用回调函数主要有两个原因:

  1. 可复用性:如 mapfilter,避免为每种操作重复编写遍历数组的代码。
  2. 抽象:将“做什么”(由我们定义的回调函数实现)与“何时做”(由系统或库函数控制)分离开。

示例setInterval 函数允许我们定期执行某个操作,我们只需定义操作内容(回调函数),而无需管理计时器底层逻辑。

代码

setInterval(() => {
    updateAnimation(); // 这是我们的回调函数,定义“做什么”
}, 10); // 每10毫秒执行一次,由 setInterval 控制“何时做”

回调函数在Web开发中的应用

在后续的Web开发中,你会频繁遇到回调函数,例如:

  • 数据库操作:在从数据库获取数据后执行特定操作。
  • 处理HTTP请求:在接收到特定网络请求时运行处理逻辑。

当你看到 (参数) => { ... } 这样的结构作为参数传递给另一个函数时,请思考:这里的“当……发生时”是什么?这里的“做这个”又是什么?这种思考方式将帮助你更好地理解异步编程和事件驱动架构。


总结

本节课我们一起学习了JavaScript中的高级概念。我们回顾了函数和数组基础,然后深入探讨了回调函数的核心思想——将函数作为参数传递以实现代码的复用与抽象。我们实践了如何创建通用的数组处理函数,并介绍了JavaScript内置的 mapfilter 方法,它们都是回调函数的经典应用。理解这些概念是学习现代前端框架(如React)和进行异步编程的重要基石。

008:React 入门 🚀

在本节课中,我们将要学习 React,这是一个在现代 Web 开发中无处不在的前端框架。我们将了解 React 的核心概念——组件,以及如何使用它们来构建更清晰、更模块化的用户界面。

课程概述

React 是另一个前端框架,它与 HTML、CSS 和 JavaScript 并非完全不同。你将使用相同的概念,但 React 是我们最终项目将使用的框架,也是 Web 开发领域广泛采用的框架。

为了回顾,让我们再次讨论 HTML、CSS 和 JavaScript。如果你还记得这个类比:HTML 是骨架,CSS 是样式,而 JavaScript 让一切运作起来。

例如,我们可以再次以 Facebook 网站为例。网站的第一个部分可能看起来像这样,其中有一个好友区域,包含许多带有不同类的 div,样式也包括图片等元素。

作为热身,请与你旁边的人讨论一下,主信息流在 HTML 结构上可能是什么样子。它可能看起来像这样。我们这里有很多不同的嵌套盒子。我们需要为所有不同的标题和样式设置许多不同的类。

本质上,这非常混乱。如果你在任何网站上打开开发者工具,你会注意到里面有很多 div。如果你想找到你要找的东西,那将是一团糟。

因此,如果我们可以不用编写所有这些代码,而是只写一行代码说“构建 Facebook”,那将非常好。但这当然没有意义,因为我们实际上必须写下我们希望 Facebook 看起来是什么样子。

所以,这里的想法是我们希望拥有某种模块化框架,通过只写下 Facebook 标签来构建 Facebook。但要写下 Facebook 标签,我们可以用其他标签来定义它,比如导航栏、好友信息流、好友和帖子。如果我们有某种框架可以进行这种抽象,能够调用一些新的自定义 HTML 标签,然后用它来渲染信息流的一部分,那么我们就可以让代码变得更好,因为我们可以模块化地构建它。如果我们有重复的不同模块,例如信息流中反复出现的帖子,这也有助于提高可读性。

这种结构会沿着树继续向下延伸。你将用其他“伪”HTML 标签或原始 HTML 来定义这些新的伪 HTML 标签,如导航栏、好友和信息流。

这就是 React 背后的核心理念,它是一个用于构建前端的、无处不在的库。我刚才提到的这些伪 HTML 标签被称为组件。这些是 React 的构建模块。

什么是组件?🧩

组件是你自己命名的东西。它们可以看起来像任何东西,比如 FacebookNavbarPostFeed,但可以是任何你想要的名称。不过,通常你会希望给它起一个有意义的名字。

那么,组件到底是什么?它是一种抽象,告诉你如何渲染网站的某个部分。它非常通用,你可以用它做任何你想要的前端功能,因为在一个组件内,你可以同时渲染所有的 HTML、CSS 和 JavaScript。你可以定义按钮点击时发生的操作,定义它的外观,定义实际的 HTML 结构。所有这些都被整齐地打包成一个组件,你可以重复使用并调用来构建网站的一部分。

它有点像 JavaScript 中的函数。正如 Abby 在前两节课中谈到的,你调用这个组件,然后它为你返回网站的一部分。

以 Facebook 为例,我们可以讨论组件可能是什么样子。根级组件叫做 App。这只是你网站中的所有内容。

在这个总的 React 组件中,将有许多不同的组件,比如导航栏、信息栏(包含你的所有好友等)以及你的信息流(包含你的所有帖子)。

这些组件还可以进一步分解。例如,Feed 组件可以分解成你构建的更小组件,比如 Post 组件。这些 Post 组件可以分解成更小的组件,比如帖子的实际内容、帖子正文和出现在下方的评论。

你可以看到这里存在一种依赖关系。每个组件都依赖于构成它的其他组件。这种结构有点像一棵树,我们称之为组件树。所以 App 组件被称为根是有道理的,因为它是树的根。箭头表示依赖关系。App 依赖于三个组件:NavbarFeedInfoBar,因为这是构建它所需的东西。Feed 依赖于 Post,而 Post 依赖于 ContentComments

基本上,总结来说,React 的主要思想是拥有这些依赖于其他组件且可重复使用的组件。这些就像函数调用。你调用函数来构建网站的一部分。这非常好,因为现在你在编写网站时可以看到网站的样子,而不是有一堆 div,而是有由帖子和评论组成的 PostBody,以及由 PostBody(由帖子和类似内容组成)组成的 Feed

在每个组件内部,你可以放置任何你需要的 HTML、CSS、JavaScript,以实现任何你需要的任何前端功能。

这就是 React 中组件工作原理的总结。但要实际让它们工作,我们需要了解几个关键特性。

使用 Props 实现通用化 🎯

例如,如果我们有一个 Comment 组件,即使我们想重复使用它,即使有许多大致相同但又不完全相同的评论,它们并不具有完全相同的 HTML。所以我们需要能够将其通用化。

我们实现这一点的方式是,评论之间可能不同的地方包括评论的实际内容、评论作者、发布时间,甚至个人资料图片。但其一般结构,即 HTML 元素相对于彼此的排列顺序,是相同的。为了能够重复使用这个结构,我们使用一个骨架,其中结构相同,比如点赞和回复按钮是相同的。我们希望我们的评论有一些输入。这些被称为 props

就像函数有输入一样,你的组件也可以接收输入,然后根据这些输入渲染网站的一部分。对于我们的评论组件,由于我们希望作者姓名、评论的实际内容、个人资料图片和发布日期在评论之间变化,我们可以将这些 props(输入)传递给评论组件,然后得到我们想要的评论。

这种我们拥有依赖于评论的帖子的结构,我们称箭头起始的组件为父组件,箭头结束的组件为子组件。props 从父组件传递给子组件,指示父组件希望子组件记住什么。

当你传入 Kenny Choy、评论内容和 一分钟前发布 这些 props 时,你就可以渲染你想要的评论。

需要注意的一点是,当你传入 prop 时,你不能在子组件端改变这个 prop。你可以对 props 进行操作,就像在一个函数中,你传入一个数字,你可以对该数字进行计算,但不能改变数字本身。这些 props 是在父组件中确定的,并传递给子组件,它们不能被子组件改变。

另外,你也不能将 props 从子组件传递给父组件。

props 的主要作用是让我们能够通用化这些 React 组件。如果一个组件有可变的部分,那么我们可以传入这些变量来决定我们希望它如何渲染。

使用 State 管理动态内容 🔄

但这就是我们实际渲染随时间变化的内容的方式吗?这些 props 是不可变的。所以如果我们的网站随时间更新,比如说有更多评论涌入,那么我们需要一种方法来跟踪我们想在网站上渲染什么。

为此,我们将使用一种叫做 state 的东西。这是 React 组件的第二个重要部分。实际上,主要就是这两个:props 和 state。

state 是组件维护的某种信息。它不是组件接收的东西,并且与 props 不同,它是可变的。它是你可以用 JavaScript 中的任何逻辑更新的东西。

例如,在我们发布评论的例子中。在 Post 组件内部,我们可以维护一个包含评论列表的 state。我们可以有一个数组,包含第一个评论,然后当另一个评论添加时,只需维护一个可以随时间变化的评论 state。

我们使用这个 state 将 props 传递给评论的子组件。一旦我们传递下去,评论组件就可以渲染我们在 state 中拥有的评论。

为什么我们将评论数据保存在 Post 的 state 中,而不是 Comment 的 state 中?Post 的 state 是更大的组件,它包含所有评论。我们想知道在 Post 的 state 中要渲染哪些评论。如果我们把这些保存在 Comment 的 state 中,我们就不知道需要有多少个评论。例如,如果我们添加另一个评论,那么我们没有另一个可以存储该 state 的评论组件。

所以我们需要在 Post 的 state 中保存,这样我们才能实际渲染我们需要的 X 条评论。在这个例子中,如果我们添加另一个评论,我们需要能够渲染那个额外的评论。但如果我们没有在 Post 中保存 state,我们就无法做到这一点。

我们可以看另一个使用 state 和 props 的例子。Facebook 上有一个功能,你可以点击“查看回复”。你与评论互动,点击按钮,网站需要通过渲染这些评论回复来调整。

与旁边的人讨论一下,哪个选项最适合显示评论回复。在这种情况下,最好的做法是在 Comment 组件内部存储 showReplies 的 state。这与之前的结构相同,只是现在我们的 Comment 组件是父组件,它需要维护是否渲染回复(其子组件)。

如果我们在 Comment 组件中维护这个 showReplies state,我们可以将其切换为 truefalse。如果它是 true,那么我们将渲染其子组件,即回复。

作为另一个回顾,state 是你在组件中存储的一些信息,它就是它本身。它存储组件的状态,这决定了它如何渲染事物以及如何渲染其子组件。它是可变的。你可以使用 state 并将其作为 props 传递给子组件,以决定渲染什么。

这里有一个关于正在发生的事情的总体回顾。我们有 state 和 props。state 被传递给子组件,然后进行渲染。

在代码中实践 React 💻

好了,以上就是 React 和 React 组件的基本结构。到目前为止有什么问题吗?

如果没有,那么我们将转向一些实际应用。我们已经看到了 React 如何通过组件帮助你进行抽象和可重用性,使你的网站代码更加清晰。

那么让我们看看这在代码中实际上是什么样子。我的工作流程将是从硬编码版本开始,我们直接写入渲染某物所需的所有信息,然后慢慢地将其抽象出来。

让我们从这个非常简单的组件开始。它只是一个评论回复,信息不多。关键的是,它只有名字和内容。

让我们看看一个通用 React 组件的代码。在你编写的每个文件中,你需要三样东西。首先,你需要导入 React。React 是一个库,它不是 JavaScript 内置的,所以你需要先导入 React。我们还将导入 useState 这个东西,它将帮助我们维护 state,稍后会详细介绍。

接下来,我们必须实际定义我们的组件。我希望你开始将这些组件视为函数。函数只是对具有输入和输出的东西的一个花哨名称。在这种情况下,我们的输入将是从父组件传递给我们的任何 props。我们的输出将只是我们在实际页面上渲染的内容。

最后,我们必须导出组件,这将允许我们在顶级组件中重复使用它。

再次强调,我希望你开始将组件视为接收 props 然后输出要渲染内容的函数。

正如 Evan 之前提到的,一个 React 组件将一堆 JavaScript 逻辑、HTML 渲染以及 CSS 捆绑在一起。回到函数类比,你可以将函数的逻辑视为所有的 JavaScript,然后你想要渲染的东西叫做 JSX。它有点类似于 HTML,我会稍作解释。但这是基本思想。

让我们尝试渲染这个评论回复。如你所见,它看起来与我们之前所做的非常相似。我们将创建一个 div。同样,我们现在是硬编码。所以我们只添加标题和一些文本。但这当然不是我们最终想要的目标。我们希望能够为任何任意的名字或评论文本重复使用这个组件。

这就是我们使用 props 的地方。如果你还记得,props 就像一个传递给函数的对象。在 JavaScript 中,我们从对象中提取信息的方式是使用点符号。

所以我们将执行 props.nameprops.content。现在我们已经完成了这个,我们移除了所有的硬编码。我们可以轻松地重复使用这个组件来至少渲染名字和内容。

那么,JSX 是什么?我提到它非常类似于 HTML,确实如此。但有一些区别。一个区别是,在 JSX 中,你实际上也可以“转义”到 JavaScript,如果你想在组件内做一些逻辑的话。例如,你可以在这里看到,在花括号内,里面的内容实际上不是 HTML,它是 JavaScript,因为我们正在从一个对象中解包东西。

所以,任何时候我们想从 props 中提取某些东西,我们都必须使用这些花括号。

还要注意,我们使用的是 className,而不是我们通常想用 CSS 样式化某物时会使用的 class。这是因为 class 在 JavaScript 中已经是一个关键字,所以我们不能直接使用相同的东西。但对于大多数目的,你可以把它们看作是相同的东西。只需记住关于转义以便使用 JavaScript 和 className 这两点。

实现 State:点赞按钮示例 👍

好了,我们已经稍微讨论了 props。让我们继续讨论 state。在我们的组件中,有两样东西可以很容易地从父组件传递到这个组件。但像点赞按钮这样的东西,将其存储在父组件中并不合理,因为我们可以在每个模块化的评论回复中完成这个操作。

那么,我们让这个组件自己维护 state 怎么样?维护 state 的语法如下所示,你会得到更多这方面的练习,所以一开始不理解也不用担心。但思想是初始化一个 state。

你将使用 const,然后你必须放置状态变量以及状态设置函数。状态变量就像 state 本身,而设置函数是一个可以改变 state 的函数。然后你将其设置为等于 useState 并初始化它。括号里面是初始值。所以这就像将 isLiked 初始化为 false,同时也给了我们一个可以改变它的函数。

让我们使用我们的 state 来渲染点赞功能。这里缺少一些样式,但基本思想是,我们只想:如果 isLikedtrue,那么我们显示“已点赞”;如果不是,那么我们显示“点赞”,以便你可以点赞它。

这里的语法对你们中的一些人来说可能是新的。同样,我们把它放在花括号里,因为它是 JavaScript 逻辑。这个语句叫做三元运算符。它就像一个 if-else 语句,但写得更简洁(或者不简洁,取决于你的喜好)。

你把条件放在这里,然后是一个问号。如果条件为真,那么它将返回这个;如果不是,那么它将返回那个。所以,如果 isLiked 为真,那么我们将按我们想要的那样显示“已点赞”。如果不是,那么我们将显示“点赞”。

现在,跳过一些小细节,我们有了一个可以重复使用的漂亮组件。是的,名字或消息是什么并不重要,我们可以将其用于基本上任何东西。

构建完整页面:组件树分析 🌳

好了,现在我们已经编写了第一个组件,让我们尝试构建一个完整的网页。

我们需要做的第一件事是理解这个东西的结构。所以让我们尝试生成 Evan 之前谈到的组件树。

在任何网页中,根都将是这个 App 组件,它将包含所有内容。让我们尝试在这里寻找一些其他组件。我们可以想到的一些东西首先是导航栏。这看起来相当模块化,我们可能想在其中抽象一些东西。介绍部分也可以组合成一个特定的组件。将其抽象出来会很好。然后帖子肯定是我们要重复使用很多次的东西。这是一个很好的组件候选。

这就是一棵树的样子。我们在顶层有 App,它依赖于这里所有这些不同的组件。

重申一下,React 组件之所以强大,是因为它们可以帮助我们进行抽象和重用。抽象意味着将一堆东西放入一个黑盒中,你不需要查看细节,但你可以通过导入它然后写下它的名字来重复使用它。可重用性,我认为很清楚。是的,导航栏、介绍和照片,它们都很好作为组件保留,因为你可以抽象掉那些你每次查看网页时不想看到的混乱代码。帖子对抽象和可重用性都有帮助,因为我们会在一个页面上有很多帖子。

编写组件代码 🖋️

同样,我的工作流程将从硬编码版本开始,然后将其回退到更抽象和合理的模块。让我们从 App 开始。这个,我将从一个完全模块化的版本开始。但你可以看到这个 App 非常简单。它所做的就是实例化所有这些不同的组件,除此之外真的没有太多内容。

我们缺少的一样东西是 props。如果你回想一下组件树,所有这些不同的组件都是 App 组件的直接子组件,这意味着 App 负责将 props 传递给它们。

在 React 中传递 props 的语法与 HTML 中的属性非常相似。你可以通过做这种 变量=值 的事情来设置 props 对象中的一个变量。

如果我们想将 props 添加到我们的组件中,这就是它的样子。例如,在 Intro 中,我们想知道这个个人资料所属的人的教育背景和城市。所以我们传递下去。Photos 我们想传递下去,也许是我们数据库中所有照片的路径。然后对于 Post,显然,我们需要名字和内容。

让我们看看其中一些子组件。同样,我们从硬编码开始,然后回退。这里的 Intro 非常简单。它只有两条信息:你在哪里学习以及你来自哪里。

让我们尝试使用 props 将其回退。再次记住,每个 React 组件只是一个接收 props 并将其转换为要渲染内容的函数。所以假设我们提供了 props,我们需要做的就是访问 props 的这些属性。所以使用花括号,然后 props.education,对于城市,使用 props.city

让我们看看 PhotosPhotos 稍微复杂一点。为了渲染这个组件,我们希望访问这里将使用的所有图片。我们有大约 7 张 Kenny Facebook 页面上的图片。

让我解释一下这个。这里的东西是将每张照片映射到 JSX 中的一个图像元素。其语法是使用这个 map 函数。.map 接收一个函数,并将该函数应用于数组的每个元素。例如,我们有一个照片数组。然后因为我们使用了 .map,它会将每个不同的图像转换为 JSX 元素。这就是为什么我们可以将所有图片排列在一起。

我认为你稍后会得到更多这方面的练习。但是的,知道这个很好。

如果我们想将其“React 化”,那么我们可以用 props 替换所有这些图片链接,这些 props 将从 App 传递下来。所以与之前相比,我们只是用 props.links 替换了硬编码的链接数组。它看起来整洁多了。

接下来,让我们看看 PostPost 同样非常简单。我们有名字和内容。我们还有点赞按钮,它可能使用 props 以外的东西。但让我们先看看 props 相关的东西。

Intro 非常相似。我们将使用 JavaScript 访问那些东西。好了,让我们谈谈点赞按钮。对于点赞按钮,我们可能不会传递一个 prop 来表示它是否被点赞,因为这是组件内部非常固有的东西。这不是 Evan 之前提到的那种情况,即你希望它处于高层级的东西中。

所以让我们再次使用 state 来实现点赞按钮。我们还希望实现当按钮被点击时,点赞按钮会改变状态的功能。这使其成为 state 的一个非常好的候选者。

所以我们将使用与之前相同的语法。这就是我们将使用我们导入的 useState 的地方。同样,我们将有 const,然后我们有一个数组,定义为 [isLiked, setIsLiked]。第一个是状态,下一个是状态设置函数,然后我们用 useState 初始化默认值。

在这里,实际上有一个叫做 onClick 的属性。所以,每当你渲染一个按钮时,都有一个属性用于按钮被点击时,那就是 onClickonClick 将被设置为你希望每次按钮被点击时调用的函数。

例如,这里的 onClick 被设置为这个回调函数(或者说不是回调,而是一个函数),它将 isLiked 设置为它之前值的相反值,你可以看到他们使用了一个感叹号。这基本上会在每次点击按钮时切换 isLiked

然后,在其中,我们渲染与之前评论回复相同的条件语句。所以,如果 isLiked 为真,那么我们将显示“已点赞”;如果不是,我们将显示“点赞”。

如果这对你现在有点困惑,请不要担心。我们涵盖了很多内容,节奏很快。在今天的研讨会和稍后的课程中,你会得到更多练习。

重申一下,这个 setIsLiked 函数,括号内的值会将其设置为该值。所以每次 onClick 被触发时,我们将把 isLiked 设置为它之前值的相反值。

课程总结 📚

好了,以上就是关于 React 的很多内容。如果你没有完全理解我讲的所有内容,绝对不用担心。我们有一个指南供你复习,非常有帮助,随时可以回看。网址是 bla.dot.is/slash/rack-guide

总结一下,React 很有用,因为我们可以进行抽象和重用。我们希望将我们的应用划分为一堆组件。每个组件放在一个文件中,然后每个组件将捆绑一些 JSX 或一些 JavaScript 逻辑,然后输出一些 JSX。

实现这一点的语法如下。Props 和 state 是 React 的核心。我们将使用类似属性的语法传入 props,然后通过作为 JavaScript 对象访问来读取这些 props。对于 state,我们将声明状态,语法如我所展示的。我们总是可以通过调用设置函数并传入我们决定的值来设置我们的状态。

哦,对了,每当你进行样式设计时,请确保使用 className 而不是 class

在本节课中,我们一起学习了 React 的基本概念,包括组件、props 和 state。我们了解了如何通过组件化来构建模块化、可重用的前端界面,并初步接触了 JSX 语法和状态管理。这些是构建现代交互式 Web 应用的基础。

009:React 实战工作坊

在本节课中,我们将通过一个实战工作坊,将之前学到的React核心概念应用到CatBook项目中。我们将学习如何将静态页面转换为React组件,并实现一个具有交互性的功能。

概述

上一节我们介绍了React的核心概念,如组件、Props和State。本节中,我们将通过构建CatBook的导航栏和实现一个“猫咪快乐值”计数器,来实践这些概念。我们将学习如何创建组件、管理状态以及处理用户交互。

组件树回顾

首先,让我们回顾一下React的核心理念。React允许你将用户界面分解为可复用的模块化组件。每个组件可以接收Props,并管理自己的State。组件之间通过父子关系进行交互,Props从父组件传递给子组件,形成一个树状结构。

对于CatBook项目,我们的组件树结构如下:

  • 根组件App
  • 直接子组件Navbar(导航栏)和 Profile(个人资料页)
  • Profile的子组件CatHappiness(猫咪快乐值计数器)

我们的目标是构建这个结构并实现交互。

环境设置与项目启动

在开始编码之前,我们需要确保开发环境已就绪。请按以下步骤操作:

  1. 检查Node.js:在终端中运行 node -v,确保版本在18以上。
  2. 进入项目目录:打开终端,导航到 catbook-react 文件夹。
  3. 安装依赖并启动:在项目根目录下运行以下命令:
    npm install
    npm run dev
    
  4. 访问项目:命令执行后,终端会输出一个本地服务器地址(通常是 http://localhost:5173)。在浏览器中打开此地址即可查看项目。

如果遇到任何问题,请随时向助教寻求帮助。

第一步:实现导航栏组件

现在,我们开始实现第一个组件——导航栏。这与我们在第一个工作坊中创建的导航栏类似,但这次我们将使用React的语法。

导航栏组件的基本结构是一个包含标题的容器。在React中,我们使用 return 语句来定义组件渲染的HTML内容。

以下是实现步骤:

  1. 编写JSX结构:在 Navbar.jsx 文件的 return 语句中,构建导航栏的HTML结构。
  2. 添加样式类名:使用 className 属性(而非 class)为元素添加CSS类名。良好的实践是使用组件名作为前缀,例如 navbar-container
  3. 导入并编写CSS:在文件顶部使用 import ‘./Navbar.css‘; 导入样式表。然后,在 Navbar.css 文件中,为你定义的类名编写样式。

完成后的导航栏应显示在页面顶部。

第二步:添加猫咪快乐值组件与状态

接下来,我们将在个人资料页中添加一个有趣的 CatHappiness 组件。这是一个计数器,点击个人资料头像时,数值会增加。

这里引出一个关键问题:状态(State)应该存放在哪个组件中?

选项有:AppNavbarProfileCatHappiness 自身。正确答案是 Profile。原因如下:

  • 点击事件发生在 Profile 组件内的头像上,因此 Profile 能直接响应该事件。
  • CatHappiness 组件仅负责显示这个数值。
  • 我们将状态存储在 Profile 中,然后通过 Props 将其传递给 CatHappiness 组件用于显示。

以下是具体步骤:

  1. 在Profile中添加状态:在 Profile.jsx 中,使用 useState Hook 创建一个状态变量,例如 catHappiness,初始值设为0。
    import { useState } from 'react';
    // ... 在组件函数内部
    const [catHappiness, setCatHappiness] = useState(0);
    
  2. 导入并放置CatHappiness组件:在 Profile.jsx 顶部导入 CatHappiness 组件,并在JSX中希望它出现的位置(例如“关于我”和“我最爱的猫”部分之间)添加 <CatHappiness />
  3. 传递状态作为Prop:将 Profile 中的 catHappiness 状态作为prop传递给 CatHappiness 组件。
    <CatHappiness catHappiness={catHappiness} />
    
  4. 在CatHappiness中接收并显示Prop:在 CatHappiness.jsx 组件函数中,接收 props 参数,并在JSX中使用 {props.catHappiness} 来显示传递过来的数值。

完成此步骤后,页面应能显示猫咪快乐值,但点击头像还不会改变它。

第三步:实现点击交互功能

最后,我们需要实现点击头像时,catHappiness 状态递增的功能。

这需要两个步骤:

  1. 定义更新函数:在 Profile 组件中,定义一个函数(例如 incrementCatHappiness),其内部调用 setCatHappiness 来更新状态。
    const incrementCatHappiness = () => {
        setCatHappiness(catHappiness + 1);
    };
    
  2. 绑定点击事件:在 Profile 组件中包裹头像的 div 元素上,添加 onClick 事件处理器,并将其值设置为 incrementCatHappiness 函数。
    <div className=“profile-avatar-container” onClick={incrementCatHappiness}>
        {/* 头像图片 */}
    </div>
    

重要提示:传递给 onClick 的应该是一个函数引用(如 incrementCatHappiness),而不是一个函数调用(如 incrementCatHappiness())。后者会在渲染时立即执行,而非点击时。

现在,点击猫咪头像,你应该能看到猫咪快乐值计数器随之增加!

总结

本节课中,我们一起完成了一个完整的React功能实现:

  1. 创建了可复用组件Navbar)。
  2. 理解了状态提升,将需要跨组件共享或由父组件控制的状态(catHappiness)放在了共同的父组件(Profile)中。
  3. 使用Props进行父子通信,将父组件的状态传递给子组件(CatHappiness)进行显示。
  4. 处理了用户交互,通过 onClick 事件和状态更新函数来改变应用状态。

你已成功将静态页面转换为了一个具有动态交互的React应用。这些构建块——组件、Props、State和事件处理——是构建所有React应用的基础。请继续练习以巩固这些概念。

010:React组件生命周期与Hooks 🧬

在本节课中,我们将要学习React组件的生命周期,以及如何使用useEffect这个重要的Hook。理解这些概念对于构建动态、响应式的Web应用至关重要。

组件拆分原则回顾

上一节我们介绍了组件的基础概念,本节中我们来看看如何合理地拆分组件。将大型组件拆分为更小的组件主要有三个原因:

以下是拆分组件的三个主要动机:

  1. 代码可读性与维护性:当一个组件的代码过长、难以阅读时,将其拆分有助于在大型代码库中协作和追踪变更。
  2. 功能分离:当UI的不同部分承担不同的功能职责时(例如,导航栏和帖子内容),应将它们拆分为独立的组件。
  3. 单一职责:如果一个组件处理了过多不同的任务,为了代码清晰,应将其拆分为多个各司其职的组件。

最终,如何设计组件树取决于开发者自身,这需要通过实践和参考优秀代码来积累经验。

State与Props的核心概念

State和Props是React中数据流动的基石。

  • State(状态):由组件自身维护的一块“记忆”数据,在多次渲染间持续存在。它是可变的,只能通过调用useState返回的setter函数来更新。State使我们能够控制应用显示的内容并存储数据。
    • 创建Stateconst [value, setValue] = useState(initialValue);
  • Props(属性):从父组件传递给子组件的数据,类似于函数的参数。它们是只读的,只有当父组件的状态更新并重新传递给子组件时,子组件的Props才会改变。

在更新数组或对象类型的State时,我们通常使用扩展运算符(...)来创建一个新的引用,而不是直接修改原State。

状态提升(Lifting State Up)

当多个子组件需要共享或同步状态时,一个常见的设计模式是“状态提升”。

考虑一个包含标签页(Tabs)和内容面板(Tweet Panel)的Feed组件。如果每个组件内部都有自己的状态(如selectedTabcontentType),那么切换标签页时,内容面板无法自动感知。

解决方案:将相关的状态(selectedTab)提升到它们共同的父组件(Feed)中。然后,父组件通过Props将状态值和更新状态的函数传递给子组件。

核心原则:如果多个子组件需要交互或共享状态,应将该状态提升到它们最近的共同祖先组件中。子组件之间无法直接共享状态,必须通过父组件传递。

组件生命周期详解

一个React组件的生命周期包含三个阶段:挂载(Mounting)、更新(Updating)和卸载(Unmounting)。每个阶段都伴随着“触发(Trigger) -> 渲染(Render) -> 提交(Commit)”的流程。

  1. 挂载(Mounting):组件首次被创建并插入DOM中。
    • 触发:组件首次渲染。
    • 渲染:执行组件函数内的所有JavaScript逻辑(useState, useEffect等),并计算返回的JSX。
    • 提交:React将计算出的JSX更新到浏览器的真实DOM中,用户此时可以看到UI。

  1. 更新(Updating):组件因状态或Props改变而重新渲染。

    • 触发:组件的State改变,或其接收的Props改变,或其祖先组件重新渲染。
    • 渲染与提交:与挂载阶段类似,重新执行组件函数并更新DOM。React会通过“虚拟DOM对比(Diffing)”高效地只更新发生变化的部分。
  2. 卸载(Unmounting):组件从DOM中被移除。

常见触发条件

  • 组件自身State改变。
  • 组件接收的Props改变。
  • 组件的祖先组件重新渲染(会导致所有后代组件重新渲染,后续会学习优化方法)。

深入useEffect Hook

useState用于管理状态并触发重新渲染,但有时我们需要在状态变化后执行一些“副作用”操作,例如数据获取、订阅或手动修改DOM。这时就需要useEffect

基本语法useEffect(callbackFunction, [dependencyArray])

useEffect根据依赖数组的不同,有三种主要使用方式:

以下是useEffect的三种常见用法:

  1. 在每次渲染后执行useEffect(() => { console.log('Rendered!'); }); (无依赖数组)
  2. 仅在挂载时执行一次useEffect(() => { console.log('Mounted!'); }, []); (空依赖数组)
  3. 在特定依赖变化后执行useEffect(() => { console.log('Value changed:', value); }, [value]); (包含依赖的数组)

清理函数(Cleanup):如果useEffect的回调函数返回一个函数,那么这个返回的函数会在组件卸载前依赖项变化导致该useEffect重新执行前被调用,用于清理资源(如取消订阅、清除定时器)。

useEffect(() => {
  const timer = setInterval(() => { /* ... */ }, 1000);
  // 清理函数
  return () => clearInterval(timer);
}, []);

常见JSX模式

在编写组件时,我们经常用到以下两种模式:

  • 条件渲染(Conditional Rendering):使用三元运算符根据条件决定渲染内容。
    return (
      <div>
        {isLoading ? <Spinner /> : <Content data={data} />}
      </div>
    );
    

  • 列表渲染(List Rendering):使用map函数将数据数组渲染为JSX元素数组。为每个元素提供一个稳定的key属性有助于React优化渲染性能。
    return (
      <ul>
        {items.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    );
    

实战:创建一个计时器

让我们综合运用所学知识,创建一个简单的计时器组件。

  1. 创建状态:使用useState来存储当前时间。
    const [time, setTime] = useState(0);
    
  2. 使用副作用更新状态:使用useEffect在组件挂载后,每秒更新一次时间。
    useEffect(() => {
      const intervalId = setInterval(() => {
        setTime(prevTime => prevTime + 1); // 使用函数式更新确保拿到最新值
      }, 1000);
      // 清理函数:在组件卸载时清除定时器
      return () => clearInterval(intervalId);
    }, []); // 空依赖数组,确保effect只运行一次
    
  3. 渲染状态:在JSX中显示时间。
    return <div>Time: {time} seconds</div>;
    

这个例子完整展示了State管理、生命周期(挂载、更新、卸载)以及useEffect(包含清理函数)的协同工作。


本节课中我们一起学习了React组件的完整生命周期(挂载、更新、卸载),深入理解了useEffect Hook的用法及其在管理副作用中的核心作用,并掌握了状态提升、条件渲染和列表渲染等关键开发模式。这些概念是构建复杂、交互式React应用的坚实基础。

011:工作坊2(动态信息流与路由)🚀

在本节课中,我们将基于工作坊1构建的页面,创建一个类似Facebook的动态信息流页面,包含帖子和评论功能。我们将学习如何使用React状态管理动态内容,并实现页面间的路由导航。


概述 📋

今天的工作坊将分为几个主要部分:

  1. 渲染单个帖子组件。
  2. 使用状态管理帖子列表。
  3. 实现添加新帖子的功能。
  4. 学习并实现React Router进行页面导航。
  5. 为帖子添加评论功能。

我们将从基础开始,逐步构建一个功能完整的动态信息流页面。


步骤0:导入Feed页面 🏁

首先,我们需要将应用的主页从个人资料页面切换到信息流页面。

App.jsx 文件中,找到导入 Profile 组件的地方,将其替换为导入 Feed 组件。

// 在 App.jsx 中
import Feed from './pages/Feed';

然后,在渲染部分,将 <Profile /> 组件替换为 <Feed /> 组件。

// 在 App.jsx 的渲染部分
<Feed />

完成此步骤后,访问应用将看到“This is the feed”的占位文本,表明我们已成功切换到信息流页面。


步骤1:渲染单个帖子组件 📝

上一节我们设置了Feed页面,本节中我们来看看如何渲染一个帖子。

我们的目标是创建一个 SingleStory 组件,用于显示帖子的发布者名称和内容。这个组件将接收 creatorNamecontent 作为属性(props)。

以下是创建 SingleStory 组件的步骤:

  1. modules/SingleStory.jsx 文件中,导入必要的CSS样式。
  2. 在组件函数中,使用 props.creatorNameprops.content 来接收数据。
  3. 在JSX中,使用一个 <div> 包裹内容,并为其添加 card-story 类名。
  4. <div> 内部,使用一个 <h1> 标签来加粗显示 creatorName
  5. <h1> 下方,使用另一个 <div> 并添加 card-story-content 类名来显示 content

完成后的 SingleStory 组件代码大致如下:

import './Card.css';

function SingleStory(props) {
  return (
    <div className="card-story">
      <h1>{props.creatorName}</h1>
      <div className="card-story-content">{props.content}</div>
    </div>
  );
}

export default SingleStory;

现在,在 Feed.jsx 中导入并使用 <SingleStory /> 组件,并传入一些测试数据,你就能在页面上看到一个格式化的帖子了。


步骤2:创建帖子状态管理 🗂️

我们已经可以渲染单个帖子,但一个信息流需要显示多个帖子。本节我们将学习如何使用React的 useState 钩子来管理一个帖子列表的状态。

Feed.jsx 组件中,我们首先需要导入 useState

import { useState } from 'react';

然后,在 Feed 组件函数内部,声明一个状态变量来存储帖子列表。初始状态可以设为一个空数组。

const [stories, setStories] = useState([]);

接下来,我们使用 useEffect 钩子在组件首次加载时,向状态中填充一些硬编码的初始数据。这模拟了从服务器获取数据的过程。

import { useState, useEffect } from 'react';

function Feed() {
  const [stories, setStories] = useState([]);

  useEffect(() => {
    // 创建一些初始帖子对象
    const story1 = { id: ‘id1‘, creatorName: ‘Person One‘, content: ‘First post content‘ };
    const story2 = { id: ‘id2‘, creatorName: ‘Person Two‘, content: ‘Second post content‘ };
    // 使用 setStories 更新状态
    setStories([story1, story2]);
  }, []); // 空依赖数组确保只在组件挂载时运行一次

  // ... 其余渲染代码
}

现在,stories 状态中就包含了我们的帖子数据,为下一步渲染列表做好了准备。


步骤3:渲染帖子列表 📜

有了帖子数据的状态,我们现在需要将数组中的每个帖子对象渲染成 SingleStory 组件。我们将使用JavaScript数组的 map 方法来实现。

Feed.jsx 的渲染部分,我们可以根据 stories 数组的长度来决定渲染内容。

以下是渲染逻辑:

  1. 检查 stories 数组是否为空。
  2. 如果为空,则显示“暂无帖子”之类的提示。
  3. 如果不为空,则使用 map 方法遍历 stories 数组,为每个帖子对象生成一个 <SingleStory /> 组件。

具体实现代码如下:

function Feed() {
  // ... 之前的 useState 和 useEffect 代码

  let storiesList;
  if (stories.length === 0) {
    storiesList = <div>No stories yet.</div>;
  } else {
    storiesList = stories.map((story) => (
      <SingleStory
        key={story.id}
        id={story.id}
        creatorName={story.creatorName}
        content={story.content}
      />
    ));
  }

  return (
    <div>
      {/* 其他内容,比如添加新帖子的输入框 */}
      {storiesList}
    </div>
  );
}

注意:我们为每个 SingleStory 组件添加了一个唯一的 key 属性(使用 story.id),这有助于React高效地更新列表。

现在,页面上应该会显示我们硬编码的两个帖子了。


步骤4:实现添加新帖子功能 ➕

一个动态的信息流需要允许用户创建新内容。本节我们将构建一个表单,让用户可以提交新帖子,并实时更新到帖子列表中。

这个功能涉及几个组件协作:

  • NewPostInput: 一个通用的输入框组件,包含文本框和提交按钮。
  • NewStory: 一个特定于发布帖子的组件,它使用 NewPostInput 并定义提交后的行为。
  • Feed: 顶层组件,持有 stories 状态和更新该状态的函数。

首先,在 Feed 组件中创建一个函数,用于向 stories 状态添加新帖子。

function Feed() {
  const [stories, setStories] = useState([]);

  const addNewStory = (content) => {
    const newStory = {
      id: `story-${Date.now()}`, // 生成一个简易唯一ID
      creatorName: ‘Anonymous‘, // 目前默认为匿名
      content: content,
    };
    setStories([...stories, newStory]); // 将新帖子添加到列表末尾
  };

  // ... 其余代码
}

接着,我们创建 NewStory 组件(在 modules/NewPostInput/NewStory.jsx 中)。这个组件会渲染 NewPostInput,并将 addNewStory 函数作为回调传递给它。

import NewPostInput from ‘./NewPostInput‘;

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6962-webdev/img/374f9e896f9309ae77e8daaaad756d9c_33.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6962-webdev/img/374f9e896f9309ae77e8daaaad756d9c_35.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6962-webdev/img/374f9e896f9309ae77e8daaaad756d9c_36.png)

function NewStory(props) {
  // 这个函数会被 NewPostInput 在提交时调用
  const addStory = (value) => {
    props.addNewStory(value); // 调用从 Feed 传入的函数
  };

  return (
    <NewPostInput
      defaultText=“Enter your story here...“
      onSubmit={addStory} // 传递回调函数
    />
  );
}

export default NewStory;

最后,在 Feed 组件中导入并使用 NewStory 组件,并将 addNewStory 函数作为属性传递给它。

import NewStory from ‘./modules/NewPostInput/NewStory‘;

function Feed() {
  // ... addNewStory 函数定义

  return (
    <div>
      <NewStory addNewStory={addNewStory} />
      {/* 渲染 storiesList */}
      {storiesList}
    </div>
  );
}

现在,页面顶部会出现一个输入框。输入文字并点击提交后,新的帖子就会立刻出现在信息流列表中。


步骤5:实现页面路由导航 🧭

目前我们的应用只有一个页面。为了在“信息流”和“个人资料”页面间切换,我们需要引入路由功能。本节将使用 react-router-dom 库来实现客户端路由。

首先,确保 package.json 中已包含 react-router-dom 依赖,并运行 npm install 安装。

路由配置通常在应用的入口文件进行。我们打开 index.jsx

以下是配置路由的步骤:

  1. react-router-dom 导入必要的组件。
  2. 使用 createBrowserRouter 函数创建路由配置。
  3. 定义路径(path)与组件(element)的对应关系。
  4. <RouterProvider> 替换之前直接渲染 <App> 的代码。

具体代码如下:

// index.jsx
import { createBrowserRouter, RouterProvider } from ‘react-router-dom‘;
import App from ‘./App‘;
import Feed from ‘./pages/Feed‘;
import Profile from ‘./pages/Profile‘;
import NotFound from ‘./pages/NotFound‘;

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6962-webdev/img/374f9e896f9309ae77e8daaaad756d9c_38.png)

// 1. 创建路由配置
const router = createBrowserRouter([
  {
    path: ‘/‘,
    element: <App />,
    errorElement: <NotFound />, // 用于匹配未定义路径
    children: [
      // 定义子路由,这些组件将在 App 组件的 <Outlet /> 位置渲染
      {
        index: true, // 当路径为 ‘/‘ 时渲染 Feed
        element: <Feed />,
      },
      {
        path: ‘profile‘,
        element: <Profile />,
      },
    ],
  },
]);

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6962-webdev/img/374f9e896f9309ae77e8daaaad756d9c_40.png)

// 2. 使用 RouterProvider 并提供路由配置
const root = ReactDOM.createRoot(document.getElementById(‘root‘));
root.render(
  <RouterProvider router={router} />
);

然后,我们需要修改 App.jsx,使其能根据当前路由渲染不同的子页面。我们使用 <Outlet /> 组件。

// App.jsx
import { Outlet } from ‘react-router-dom‘;
import NavBar from ‘./modules/NavBar‘;

function App() {
  return (
    <div>
      <NavBar />
      {/* Outlet 是子路由组件渲染的位置 */}
      <Outlet />
    </div>
  );
}

最后,更新导航栏 NavBar.jsx,使用 <Link> 组件替代普通的 <a> 标签,以实现无页面刷新的导航。

import { Link } from ‘react-router-dom‘;

function NavBar() {
  return (
    <div className=“nav-bar“>
      <div className=“nav-bar-link-container“>
        <Link to=“/“ className=“nav-bar-link“>Home</Link>
        <Link to=“/profile“ className=“nav-bar-link“>Profile</Link>
      </div>
    </div>
  );
}

现在,点击导航栏的链接,URL会变化,主内容区也会在信息流和个人资料页面之间切换,而不会重新加载整个页面。


步骤6:创建帖子卡片组件 🃏

为了让单个帖子包含其下方的评论,我们需要创建一个新的 Card 组件作为容器。本节我们将把 SingleStory 和未来的评论列表包裹进这个 Card 组件。

首先,修改 Feed.jsx,不再直接映射 storiesSingleStory,而是映射到 Card 组件,并将整个帖子对象作为属性传递。

// Feed.jsx
import Card from ‘./modules/Card‘;

// ... 在映射 stories 的地方
storiesList = stories.map((story) => (
  <Card
    key={story.id}
    id={story.id}
    creatorName={story.creatorName}
    content={story.content}
  />
));

然后,在 Card.jsx 组件中,我们接收这些属性,并首先渲染 SingleStory 组件。

// Card.jsx
import SingleStory from ‘./SingleStory‘;

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6962-webdev/img/374f9e896f9309ae77e8daaaad756d9c_48.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/mit-6962-webdev/img/374f9e896f9309ae77e8daaaad756d9c_50.png)

function Card(props) {
  return (
    <div className=“card“>
      <SingleStory
        id={props.id}
        creatorName={props.creatorName}
        content={props.content}
      />
      {/* 评论区域将在这里渲染 */}
    </div>
  );
}

这样,我们就为每个帖子创建了一个卡片容器,为下一步添加评论打下了基础。


步骤7:渲染帖子评论 💬

现在,我们让每个帖子卡片能够显示其关联的评论。思路与渲染帖子列表类似:在 Card 组件中管理评论状态,并映射渲染。

首先,在 Card 组件中为评论创建状态。

import { useState, useEffect } from ‘react‘;

function Card(props) {
  const [comments, setComments] = useState([]);

  // 模拟组件加载时获取该帖子的评论
  useEffect(() => {
    // 假设有一个所有评论的硬编码列表
    const allComments = [
      { id: ‘c1‘, parentId: ‘id1‘, creatorName: ‘UserA‘, content: ‘Great post!‘ },
      { id: ‘c2‘, parentId: ‘id2‘, creatorName: ‘UserB‘, content: ‘Thanks for sharing.‘ },
      // ... 更多评论
    ];
    // 过滤出属于当前帖子的评论
    const postComments = allComments.filter(comment => comment.parentId === props.id);
    setComments(postComments);
  }, [props.id]); // 依赖项包含 props.id,当帖子ID变化时重新获取

  // ... 渲染 SingleStory
}

接着,我们创建一个 SingleComment 组件来显示单条评论(类似于 SingleStory),然后在 Card 组件中映射 comments 状态来渲染评论列表。

// 在 Card.jsx 的渲染部分
<div className=“comments-section“>
  <h3>Comments</h3>
  {comments.length === 0 ? (
    <div>No comments yet.</div>
  ) : (
    comments.map((comment) => (
      <SingleComment
        key={comment.id}
        creatorName={comment.creatorName}
        content={comment.content}
      />
    ))
  )}
</div>

现在,每个帖子卡片下方都会显示其对应的评论了。


步骤8:实现添加新评论功能 🗨️

最后一步是允许用户在每个帖子下添加评论。这需要我们在 Card 组件中创建一个类似添加帖子的功能。

我们创建一个 NewComment 组件(它内部也使用通用的 NewPostInput)。在 Card 组件中,定义添加评论的函数,并将其传递给 NewComment

首先,在 Card 组件中定义添加评论的函数:

function Card(props) {
  const [comments, setComments] = useState([]);

  const addNewComment = (commentText) => {
    const newComment = {
      id: `comment-${Date.now()}`,
      parentId: props.id, // 关联到当前帖子
      creatorName: ‘Anonymous‘,
      content: commentText,
    };
    setComments([...comments, newComment]); // 更新评论状态
  };

  // ... 其余代码
}

然后,创建并渲染 NewComment 组件,将 addNewComment 函数和当前帖子的 id 传递给它。

import NewComment from ‘./modules/NewPostInput/NewComment‘;

// ... 在 Card 组件的渲染部分,放在评论列表之后
<NewComment storyId={props.id} addNewComment={addNewComment} />

NewComment 组件内部,它会调用 NewPostInput,并在提交时,将输入框的值和接收到的 storyId 一起,通过 addNewComment 回调函数传回给 Card 组件。

完成这一步后,每个帖子卡片底部都会有一个输入框,用户可以输入并提交评论,新评论会立即显示在该帖子下方。


总结 🎉

本节课中我们一起学习了如何构建一个动态的Web应用信息流页面。我们涵盖了以下核心概念:

  1. 组件化与Props:通过 SingleStorySingleComment 等组件复用UI。
  2. 状态管理:使用 useState 钩子管理 storiescomments 等动态数据。
    const [state, setState] = useState(initialValue);
    
  3. 列表渲染:使用数组的 map 方法将数据数组渲染为组件列表。
  4. 事件处理与状态更新:通过表单提交事件触发回调函数,更新父组件的状态。
  5. 客户端路由:使用 react-router-dom 库实现基于URL的页面导航,主要涉及 <RouterProvider>createBrowserRouter<Link>
  6. 组件层次结构与数据流:理解了数据如何通过props从父组件流向子组件,以及回调函数如何将子组件的数据传回父组件。

通过这些练习,你已经掌握了构建具有交互性和多页面视图的现代React应用的基础。在接下来的课程中,我们将为这个前端应用连接后端服务器,实现数据的持久化存储。

012:API与Promise

在本节课中,我们将学习如何让我们的应用从静态变得动态。我们将了解客户端如何与服务器通信以获取和存储数据,并介绍实现这一通信的核心技术:HTTP协议、API和Promise。

概述:从静态到动态

目前,我们的Techbook应用是静态的。它没有根据用户的不同显示个性化信息,并且每次刷新页面,数据(例如点赞数)都会重置。我们希望应用能存储和操作用户特定的数据,这就需要引入后端。

后端负责数据存储和操作。前端会向后端发送请求,后端则返回前端所需的信息。这种模式被称为客户端-服务器架构。你们现在使用的浏览器就是客户端,它会向服务器发送请求,服务器处理请求后,将响应发送回客户端。

HTTP请求的结构

那么,客户端如何向服务器发送请求呢?我们需要一个标准化的协议,这就是HTTP。每次你访问一个网站或观看视频,背后都在使用HTTP请求。HTTPS则是HTTP的安全版本,对传输的数据进行加密。

一个HTTP请求包含以下几个核心部分:

以下是HTTP请求的主要组成部分:

  1. 请求目标与查询参数:这是请求发送的地址。例如,https://www.youtube.com/results?search_query=weblabsearch_query=weblab 就是查询参数,用于向服务器说明我们想要的具体数据(搜索“weblab”的视频)。
  2. HTTP方法:定义了请求的类型。最常用的有:
    • GET:用于获取数据。
    • POST:用于发送(提交)数据。
    • PUT:用于更新数据。
    • DELETE:用于删除数据。
      在本课程中,我们将主要使用GET和POST。
  3. 请求头:包含请求的元数据,例如请求的时间戳、使用的语言等。
  4. 请求体:在POST请求中,用于存放要发送给服务器的数据。数据通常以JSON格式组织,即一系列的键值对。例如:
    {
      "name": "Alice",
      "language": "Chinese"
    }
    

HTTP响应的结构

服务器收到请求后,会返回一个HTTP响应。响应也包含几个关键部分:

以下是HTTP响应的主要组成部分:

  1. 状态码:一个三位数字,表示请求的结果。
    • 2xx (成功):请求成功。最常见的是 200 OK
    • 3xx (重定向):资源位置已改变。
    • 4xx (客户端错误):请求有问题。例如 404 Not Found(资源未找到),400 Bad Request(请求格式错误)。
    • 5xx (服务器错误):服务器处理请求时出错。例如 500 Internal Server Error
  2. 响应头:与请求头类似,包含响应的元数据,如内容类型、长度等。
  3. 响应体:包含服务器返回的实际数据。对于GET请求,这就是我们想要的信息;对于POST请求,可能是操作成功的确认信息。响应体通常也是JSON格式。

发送HTTP请求的方式

有多种方式可以发送HTTP请求:

  1. 浏览器地址栏:输入URL并回车,就是发送一个GET请求。
  2. 浏览器开发者工具:在“网络”(Network)标签页中,你可以看到页面加载时发出的所有请求,并查看其详情。
  3. 终端命令:使用 curl 命令可以直接发送请求,例如 curl https://www.google.com
  4. JavaScript:这是我们将在前端代码中使用的主要方式,通过 fetch 函数来发送请求。

什么是API?

上一节我们介绍了HTTP通信的基础,本节中我们来看看如何利用这套机制来使用现成的服务,这就是API。

API 代表应用程序编程接口。简单来说,它是一个服务提供的一组标准化端点,允许你通过发送请求来使用该服务的特定功能。

许多公司都提供API,你可以将它们集成到自己的网站中。例如:

  • Google Calendar API:可以用来在网站上显示日历事件。
  • Amazon Selling Partner API:可以在网站上集成购买商品的功能。
  • OpenAI API:可以发送请求,让人工智能模型回答问题或生成内容。

API的作用是让你能够访问数据或执行某些功能,而无需直接操作服务提供商的服务器,这既方便又安全。

客户端-服务器-API的协作模型

在我们的Web应用中,数据流是这样的:

  1. 客户端(浏览器)向我们的服务器发送HTTP请求(GET或POST)。
  2. 我们的服务器接收请求。它可能需要:
    • 数据库中存取数据。
    • 将请求转发给第三方API(如Google Calendar)。
  3. 我们的服务器处理完所有操作后,生成一个HTTP响应,并将其发送回客户端

为什么需要“我们的服务器”这个中间层?主要是为了安全。如果客户端直接连接数据库或使用API密钥调用第三方服务,这些敏感信息就会暴露在浏览器中,任何用户都能看到。服务器代码由我们完全控制,可以安全地保管这些凭证。

在JavaScript中使用API

我们将使用JavaScript中的 getpost 函数(它们内部基于 fetch 函数)来与API交互。

  • GET请求示例:获取故事列表。
    get('/stories').then((stories) => {
      // 使用返回的 stories 数据更新页面
      console.log(stories);
    });
    
  • POST请求示例:提交一个新故事。
    post('/stories', { content: '这是我的新故事!' }).then((response) => {
      // 处理服务器返回的响应,例如提示“发布成功”
      console.log(response);
    });
    

这些函数简化了操作:你只需要提供端点(如 /stories)和参数,它们会自动帮你构建完整的HTTP请求并发送。

总结

本节课中我们一起学习了Web应用动态化的核心。

  1. 我们了解了客户端-服务器架构,以及后端在数据持久化中的作用。
  2. 我们学习了HTTP协议,包括请求(方法、头、体)和响应(状态码、体)的结构。
  3. 我们认识了API,它允许我们安全、规范地使用外部服务提供的功能。
  4. 我们看到了完整的数据流:客户端请求我们的服务器,服务器可能操作数据库或调用第三方API,最后将结果响应给客户端。
  5. 我们知道了在JavaScript中,可以使用 getpost 函数来方便地发送请求和处理响应。

在接下来的实践中,你将运用这些知识,让你的Techbook应用能够真正地保存和显示用户数据。

013:服务器与Node.js 🚀

在本节课中,我们将要学习服务器的基础概念、Node.js的作用,以及如何使用Express框架来创建API端点。我们还会深入探讨Promise在异步JavaScript请求中的重要性。


概述

上一节我们介绍了如何在前端向API发送请求。本节中,我们来看看这些请求的接收方——服务器。我们将了解什么是服务器、为什么需要它,并学习使用Node.js和Express来构建我们自己的服务器端逻辑。


什么是服务器?🖥️

服务器是一台为客户端提供数据的计算机。客户端向服务器发送请求,服务器则返回相应的数据。一台服务器可以同时处理来自多个客户端的请求,这实现了可扩展性。

我们需要服务器的原因主要有三点:

  1. 集中数据源:我们希望所有客户端都知道可以从一个统一的网址(URL)获取所需数据。
  2. 数据一致性:我们需要一个代表数据“真实状态”的中心点,例如多人在线游戏中的游戏状态。
  3. 安全性:我们不希望客户端能直接访问数据库并执行任意查询。服务器作为中间层,负责处理逻辑并返回经过筛选的、安全的数据。


请求如何到达服务器?

严格来说,服务器不是一个物理计算机,而是一个运行在计算机上的程序(或称为进程)。这台计算机拥有多个端口,服务器程序会绑定到其中一个端口上,并监听来自其他计算机的请求。

一个服务器地址的通用结构如下:

协议://域名:端口/路径

例如:

  • https://youtube.com:443 - 通过HTTPS协议访问YouTube,默认端口是443。
  • http://localhost:3000 - 访问本地计算机上运行在3000端口的服务器。
  • minecraft.example.com:25565 - 访问一个Minecraft游戏服务器。

你的计算机有一个特殊的域名叫做 localhost,它代表你本机的IP地址。当你在浏览器中输入localhost:3000时,你就是在向你自己的计算机(3000端口)发送请求。


如何编写服务器代码?

作为开发者,我们无需关心底层网络通信的细节。框架 为我们处理了这些样板代码,让我们能更轻松地构建服务器。

一些流行的后端框架包括:

  • Flask (Python)
  • Django (Python)
  • Express.js (JavaScript)
  • Apache (Java)

在本课程中,我们将使用 Express.js


Node.js 是什么?🤔

你可能会有疑问:我们已经在浏览器中运行JavaScript了,为什么还需要别的工具?

浏览器中运行的JavaScript是客户端代码,用于渲染网页和实现交互。而服务器端代码(比如用Express.js写的)需要在计算机上直接运行。你的计算机操作系统本身并不理解JavaScript。

Node.js 就是一个能让你的计算机运行JavaScript代码的环境。我们之前一直使用的 npm (Node Package Manager) 就是Node.js的一部分,用于安装和管理项目依赖。


项目结构概览

我们的Capstone项目文件夹结构大致如下:

capstone-react/
├── client/       # 所有前端React代码(组件、页面等)
├── server/       # 所有后端Express.js代码
├── package.json  # 项目元数据和依赖列表
└── node_modules/ # 已安装的依赖包(通常不上传至Git)

package.json 文件记录了项目名称、描述、脚本命令(如 npm run dev)以及依赖项。当我们运行 npm install 时,就会根据这个文件下载依赖包到 node_modules 文件夹。


理解API端点

端点 是服务器上特定功能的地址。当客户端向某个端点发送请求时,服务器就会执行与该端点关联的代码。

想象一下,服务器就像一个社区(端口3000),每个房子都有一个地址(端点):

  • /api/videos - 处理视频相关请求的房子。
  • /api/comments - 处理评论相关请求的房子。

当你向 localhost:3000/api/comments 发送一个 POST 请求(比如发布一条新评论)时,你就走进了“评论处理屋”,执行里面的功能,然后带着结果(响应)离开。


创建你的第一个Express端点

现在,让我们看看如何用Express.js创建一个简单的API端点。核心文件通常是 server.js

以下是创建一个基本服务器和端点的代码:

// 1. 导入express库
const express = require('express');
// 2. 创建Express应用实例
const app = express();

// 3. 定义一个GET请求端点
app.get('/api/test', (req, res) => {
  res.send('我爱Web开发!');
});

// 4. 启动服务器,监听3000端口
app.listen(3000, () => {
  console.log('服务器正在端口3000上运行...');
});

代码解析:

  1. const express = require('express');:这类似于前端的 import,用于引入Express库。
  2. const app = express();:创建一个Express应用对象,它是我们定义所有路由和中间件的基础。
  3. app.get('/api/test', (req, res) => {...}):这定义了一个GET端点。
    • 第一个参数 '/api/test' 是端点的路径。
    • 第二个参数是一个回调函数,当有请求到达这个端点时被调用。该函数接收两个对象:
      • req (Request):包含客户端请求的信息(如参数、请求体)。
      • res (Response):用于向客户端发送回响应。res.send() 是发送响应内容的方法。
  4. app.listen(...):启动服务器,开始监听指定端口(这里是3000)的请求。

中间件:请求的流水线工人 🛠️

中间件 是在请求到达最终端点处理函数之前(或之后)执行的一系列函数。你可以把它想象成流水线上的工人,每个工人都对“产品”(请求/响应对象)进行一些加工,然后传递给下一个工人。

中间件常见的用途包括:

  • 日志记录:记录每个请求的详细信息(如时间、URL)。
  • 身份验证:在允许访问受保护端点前检查用户是否登录。
  • 错误处理:统一捕获和处理过程中发生的错误。
  • 解析请求体:将传入的JSON字符串转换为JavaScript对象。

如何使用中间件?

使用 app.use() 函数来注册中间件。

// 一个简单的日志记录中间件
app.use((req, res, next) => {
  console.log(`请求时间: ${new Date().toISOString()}`);
  next(); // 将控制权传递给下一个中间件或路由处理器
});

// 用于解析JSON请求体的内置中间件(非常重要!)
app.use(express.json());
  • next() 是一个函数,调用它会将请求传递给堆栈中的下一个中间件。如果忘记调用 next(),请求将会被挂起。

错误处理中间件

错误处理中间件稍有不同,它接受四个参数 (err, req, res, next),并且通常定义在所有其他 app.use() 和路由(app.get, app.post之后。这样,如果前面的任何处理过程中抛出错误,Express会自动跳转到这个错误处理中间件。

// 放在所有路由和其他中间件之后
app.use((err, req, res, next) => {
  console.error('服务器错误:', err);
  res.status(err.status || 500).send('服务器内部错误');
});

通配符(Catch-All)端点

有时,客户端可能会请求一个我们未定义的端点。我们可以使用通配符 * 来定义一个“捕获所有”的路由,通常用于返回404(未找到)页面。

// 这个端点会匹配所有未被前面路由匹配的GET请求
app.get('*', (req, res) => {
  res.status(404).send('页面未找到!');
});

重要提示路由和中间件的顺序至关重要。Express会按照它们在代码中出现的顺序依次匹配。因此,通配符路由必须放在所有其他具体路由之后定义,否则它会拦截所有请求,导致其他路由永远无法被访问。


运行你的完整应用

现在我们的项目同时拥有前端(Client)和后端(Server)。

  1. 运行前端:在项目根目录下打开一个终端,运行 npm run dev。这会在 localhost:5173 启动开发服务器(提供React文件)。
  2. 运行后端再打开一个新的终端窗口,在项目根目录下运行 npm start。这会在 localhost:3000 启动Express服务器。

两个服务器都会在代码文件更改时自动重启,方便开发。


总结

本节课中我们一起学习了:

  1. 服务器 是处理客户端请求并返回数据的程序,运行在特定的端口上。
  2. Node.js 允许我们在计算机上运行JavaScript,是后端开发的基础。
  3. Express.js 是一个Node.js框架,简化了路由、中间件等服务器功能的创建。
  4. API端点 是服务器上特定功能的访问地址,通过 app.get()app.post() 等方法定义。
  5. 中间件 是在请求处理流程中执行额外功能的函数,使用 app.use() 注册,常用于日志、解析、验证和错误处理。
  6. 理解代码顺序对路由和中间件的执行逻辑非常关键。

现在,你已经掌握了构建一个简单全栈应用(前端React + 后端Express)所需的核心后端知识。在接下来的实践中,你将有机会亲手创建并连接这些部分。

014:UI与Figma 🎨

在本节课中,我们将要学习网站设计的核心概念:用户界面(UI)和用户体验(UX)。我们将探讨如何通过字体、颜色和布局来设计一个美观且易用的网站,并介绍如何使用Figma工具来创建网站原型。


网站设计的核心:UI与UX

上一节我们介绍了网站开发的基础,本节中我们来看看如何设计网站。网站设计的中心是我们的用户。我们设计网站是为了服务用户,我们希望网站是可用的。

需要考虑的两个核心概念是UIUX

  • UI(用户界面)决定了网站的视觉效果。它是用户访问网站并与之互动时的第一印象。
  • UX(用户体验)更多地描述了用户从一个页面到另一个页面、与网站互动时的流程。

一个好的UI/UX设计能够将网站的内容和功能有效地传递给用户。但这可以有很多不同的形式,没有一个固定的“好”的配方。


用户界面设计

首先,我们来看看用户界面。你的UI应该由一个设计指南来引导。这个指南规定了你在网站中使用的字体、配色方案、间距布局以及前端可能使用的可复用组件。

以下是一个设计指南的例子,它曾用于Web.lab网站多年(虽然最近网站UI已更新):

  • 字体:为网站中不同的标签(如标题、正文)指定了使用的字体。
  • 配色方案:规定了整个网站使用的颜色。

你使用的字体和颜色会给用户留下非常强烈的第一印象,因此它们非常重要。在设计网站时,你应该花时间思考为UI指南选择什么样的字体和颜色。

字体选择

如果你是一名麻省理工学院的学生,你可以访问Adobe Creative Cloud,从而使用Adobe Fonts。这是一个寻找网站字体的绝佳工具。你可以按标签筛选,寻找不同类型的字体。

不同的字体已经能传达出非常不同的印象。例如:

  • Ivy Style:一种非常现代、简洁的字体,适合用于现代的用户界面。
  • Mini ArcadeLimon:可能不适合用作网站正文,但更适合用作标题。

颜色选择

颜色同样能给用户留下强烈的第一印象。你可以使用像Coolors这样的工具来挑选和编辑配色方案。

另一个需要考虑的是色彩心理学,你可以自行深入研究。

UI趋势与文化差异

正如我们所见,UI可以通过字体和颜色给用户留下深刻印象,但这在很大程度上取决于你的用户群体。用户会随着时间和文化而变化。

UI趋势会随时间改变,并因文化而异。例如,麻省理工学院的网站在2003年和现在的对比显示,它已转向更极简的UI:白色背景取代了茶绿色背景,文字间距更大,布局更空灵,并尽可能用图标代替文字。

过去十年,我们看到了向更极简UI发展的趋势。观察图标随时间的变化是一个很好的例子:

  • 早期:非常原始和像素化的图标。
  • 90年代末至21世纪初:拟物化图标,模仿现实生活中的物品。
  • 2010年代至今:更极简、扁平的UI图标布局(尽管最近开始出现更多阴影和立体感的趋势)。

你可以通过“网页设计博物馆”网站查看你喜欢的网站是如何随时间变化的。

极简UI并非在所有文化中都受欢迎。一个例子是西方网站和东亚(如日本)网站在UI设计上的差异。日本雅虎的首页更类似于西方在互联网泡沫时期的网站,信息更密集,没有明确的视觉焦点,远不如西方网站极简。这是因为在不同文化中,用户有不同的需求:西方用户偏好简洁、有重点的布局;而东亚文化属于高语境文化,偏好能提供更多信息的网站。

这充分说明,在设计网站时,你真的需要考虑你的用户群体。

如何让UI看起来更好

如前所述,你应该使用UI指南,因为它能确保整个网站的一致性。以下是一些挑选颜色和字体的工具。

另一个重点是复用组件。你可以使用UI组件库,例如Mantine,它允许你自定义组件并在整个网站中复用它们。

让UI看起来好的另一个重要部分是响应式设计。这意味着你的网站在不同尺寸的设备上都能良好显示。你可以使用浏览器的“检查元素”功能,并切换设备工具栏来查看网站在不同屏幕尺寸下的效果。

交互性

交互性也非常酷。当用户可以与网站互动时,例如悬停或点击某物会得到反馈,这能让网站更吸引眼球。但交互性也有缺点,它可能影响可用性,或对浏览器造成较大的资源负担。


用户体验设计

上一节我们介绍了UI,本节中我们来看看UX。UX需要考虑诸如导航对用户来说有多直观、用户在网站上可以使用哪些线索、我们想要突出的基本元素有多明显,以及我们呈现的信息是否以逻辑清晰、易于理解的方式组织。

我们希望使用在不同文化中普遍理解的符号、概念和颜色。图标几乎是通用的,我们可以使用大多数人都能理解的特定符号来表示某些事物。例如,房子图标代表主页,放大镜图标代表搜索栏。颜色也有其象征意义,例如绿色通常象征金钱或财富。

关于色彩心理学,一个好的配色比例是:60%主色,30%次要色,10%强调色。有研究表明不同性别对颜色的偏好。此外,像“对比度检查器”这样的网站对于确保网站的可访问性非常有用,你可以检查网站在灰度模式下是否仍然可见,以及是否有足够的对比度供视力障碍人士使用。

以Discord的UI为例,它通过将按钮集中放置且易于点击来优化UX,让用户一进入页面就清楚自己的选项。相比之下,Facebook的旧UI就不太直观,因为“创建账户”的输入框非常大,而用户更常用的“登录”选项却很小,放在顶部。因此,Facebook切换到了新UI,将登录框放在中心位置,创建新账户放在下方,这样更加直观。


线框图与原型设计

现在我们来谈谈线框图。这对于你们的项目来说超级重要。线框图本质上就是为你的Web应用创建一个非常粗略的草图。它非常有帮助,因为一旦你开始硬编码,再想改变会比在开始编码前就清楚应用的样子要困难得多。

你需要关注应用的整体结构以及关键元素的位置。线框图可以非常草率,不需要很正式,可以把它看作是你网站外观的粗略草稿。

制作线框图有几种不同的选择:我们可以使用Figma(我们稍后会讨论),你也可以在Google Slides上制作(便于展示页面间的过渡),或者直接画在纸上,或使用其他线框图软件如Photoshop或Sketch。我们将使用Figma,因为它有一个非常有用的功能叫做原型设计

原型设计基本上就是让你的线框图“活”起来。你将为线框图添加交互,展示页面之间的跳转关系。这有助于改进你的UI,因为你确切地知道了编码所需的技术要求以及用户应该被重定向到哪里。原型可以非常接近真实的产品。

我们将使用Figma,因为它具有非常好的实时协作功能,以及为线框图添加交互和制作原型的选项,这对我们的目的非常有帮助,而且对初学者来说也很直观。


Figma 基础演示

我们将快速过渡到一个Figma演示。我将概述关键功能的位置,然后你们将有一些时间自己动手尝试。

首先,在 figma.com 创建一个Figma账户。创建新文件时,选择“设计文件”。

Figma的基础是,所有内容都将包含在一个框架中。框架是元素的容器,它将容纳我们应用页面上的所有内容。框架非常强大,比简单的“组合”对象功能更强大,因为你可以独立调整框架内所有内容的尺寸,可以利用溢出内容选项(如裁剪文本),并且更容易为所有内容应用一致的样式。

例如,要制作Catbook的主页导航栏,你可以先创建一个框架,然后更改其填充颜色。接着,你可以使用文本工具在框架内添加文字,如“Catbook”,并在右侧面板调整字体大小、样式等。

Figma一个非常棒的功能是组件。假设我们想制作一个个人资料页面,我们不需要重新制作或复制粘贴导航栏,而是可以将其创建为组件。组件是可复用元素的实例。创建后,你可以在“资源”面板中找到它,并将其作为“实例”插入到其他页面中。当你编辑主组件时,所有实例都会自动更新。


总结

本节课中我们一起学习了网站设计的两个核心支柱:UI和UX。我们探讨了如何通过字体、颜色和布局来塑造用户的第一印象和整体体验,并了解了设计趋势会随文化和时间变化。最后,我们介绍了使用Figma进行线框图和原型设计的基本方法,这是规划和组织网站结构的强大工具。记住,好的设计始终以用户为中心。

015:工作坊3(后端)🚀

在本节课中,我们将学习如何为我们的CatBook应用实现后端功能。目前,我们的帖子和评论都存储在React状态中,一旦刷新页面就会消失。我们的目标是让这些数据持久化,即刷新后帖子与评论依然存在。我们将通过将数据存储在后端服务器,并让前端通过发送HTTP请求来获取和提交数据来实现这一点。


移动数据到后端 📦

上一节我们了解了前后端分离的基本概念。本节中,我们来看看如何将前端的数据移动到后端。

目前,我们的故事(stories)数据硬编码在 feed.jsx 文件中。这意味着所有修改都只是本地的,刷新页面就会恢复原样。我们需要将这些数据移动到后端服务器。

以下是需要完成的步骤:

  1. 打开 feed.jsx 文件,找到名为 stories 的数组定义。
  2. 复制整个 stories 数组的定义代码。
  3. 打开 server.js 文件。
  4. 将复制的 stories 数组代码粘贴到 server.js 文件顶部,步骤一的注释之前。

现在,故事数据已经存储在服务器端了。


发送GET请求获取故事 🔍

现在数据在后端,我们需要让前端能够请求这些数据。我们将通过发送一个HTTP GET请求来实现。

我们将使用项目提供的 utilities.js 文件中的 get 辅助函数来发送请求。这个函数是对JavaScript原生 fetch 函数的封装。

feed.jsx 文件中,我们需要在组件加载时发送GET请求。我们将使用 useEffect Hook。请求的端点(URL)将是 /api/stories

以下是实现步骤:

  1. feed.jsxuseEffect 函数内,调用 get('/api/stories')
  2. 由于 get 函数返回一个Promise(代表异步操作),我们使用 .then() 方法来处理服务器返回的数据。
  3. .then() 的回调函数中,我们会收到服务器返回的故事对象数组。
  4. 我们希望故事按从早到晚的顺序显示,所以对数组调用 .reverse() 方法进行反转。
  5. 最后,使用 setStories 函数将反转后的数组更新到React状态中。

代码示例如下:

useEffect(() => {
  get('/api/stories').then((storyObjects) => {
    const reversedStories = storyObjects.reverse();
    setStories(reversedStories);
  });
}, []);

核心概念get 函数向指定端点发送HTTP GET请求,并返回一个Promise。我们使用 .then() 来等待请求完成并处理响应数据。


实现GET故事端点 ⚙️

前端已经发送了请求,现在我们需要在后端创建对应的端点来处理这个请求。

我们将在 server.js 中创建一个路由。Express框架使用 app.get() 方法来定义处理GET请求的端点。

这个端点需要做以下事情:

  1. 监听发送到 /api/stories 的GET请求。
  2. 当请求到达时,将我们之前存储在 server.js 中的 stories 数组发送回前端。

在Express中,每个路由处理函数接收两个参数:req(请求对象)和 res(响应对象)。我们使用 res.send() 来发送响应。

代码示例如下:

app.get('/api/stories', (req, res) => {
  res.send(stories);
});

核心概念:在Express中,app.get(path, handler) 用于定义GET请求的路由。handler 函数使用 res.send(data) 向客户端返回数据。


发送POST请求提交新故事 📨

现在我们可以获取故事了,但还需要能够发布新故事。这将涉及发送一个HTTP POST请求。

与GET请求类似,我们将使用 utilities.js 中的 post 辅助函数。POST请求通常用于向服务器提交数据。

feed.jsx 中,找到 addNewStory 函数。这个函数在当前版本中只是将新故事添加到本地状态。我们需要修改它,使其将新故事发送到后端。

实现步骤:

  1. addNewStory 函数中,调用 post('/api/story', value)。这里的 value 是包含新故事内容(content)、创建者(creatorName)和ID的对象。
  2. post 函数同样返回一个Promise。我们在 .then() 回调中处理服务器的响应。
  3. 服务器成功保存故事后,会将该故事对象返回给我们作为确认。
  4. .then() 中,我们将这个返回的新故事对象添加到前端的React状态中,以便立即显示。

代码逻辑如下:

const addNewStory = (value) => {
  post('/api/story', value).then((newStory) => {
    setStories([newStory, ...stories]);
  });
};

注意:将 setStories 放在 .then() 内部至关重要。这确保了只有在前端确认服务器已成功接收并存储了新故事后,才更新本地界面。如果放在外面,即使服务器失败,用户也会看到帖子已发布的假象。


实现POST故事端点 🛠️

现在,我们需要在后端创建端点来接收前端发来的新故事。

我们将在 server.js 中使用 app.post() 方法。

这个端点需要:

  1. 监听发送到 /api/story 的POST请求。
  2. 从请求体中获取新故事数据。在Express中,POST请求的数据位于 req.body 中。
  3. 将这个新故事对象添加到后端的 stories 数组中。
  4. 将刚添加的故事对象发送回前端,作为操作成功的确认。

代码示例如下:

app.post('/api/story', (req, res) => {
  const newStory = req.body;
  stories.push(newStory);
  res.send(newStory);
});

核心概念:对于POST请求,客户端发送的数据包含在请求体(body)中。在Express中,我们需要使用 express.json() 中间件(通常在 server.js 顶部已配置)来解析 req.body


处理评论:GET请求与查询参数 💬

我们已经为故事实现了完整的流程。现在,我们需要为评论(comments)实现类似的功能,但有一个关键区别:我们需要获取特定故事下的评论。

首先,将硬编码在 card.jsx 中的评论数据移动到 server.js 中,就像我们之前对故事做的那样。

接下来,在 card.jsx 中发送GET请求获取评论。由于我们只需要特定故事的评论,我们需要告诉服务器是哪个故事。我们通过URL的查询参数(query parameters)来实现。

例如,要获取父故事ID为 123 的评论,请求的URL可能看起来像:/api/comments?parent=123

card.jsx 中:

  1. 我们使用 get(/api/comments?parent=${props.storyId}) 来发送请求,其中 props.storyId 是当前故事组件的ID。
  2. .then() 回调中,用服务器返回的评论数组更新React状态。

代码示例如下:

useEffect(() => {
  get(`/api/comments?parent=${props.storyId}`).then((commentObjs) => {
    setComments(commentObjs);
  });
}, [props.storyId]);

实现GET评论端点 🔎

在后端,我们需要创建 /api/comments 端点来处理这个带查询参数的GET请求。

在Express中,可以通过 req.query 对象访问查询参数。

这个端点需要:

  1. req.query.parent 获取前端传递的父故事ID。
  2. 从后端的 comments 数组中,过滤出所有 parent 字段值与这个ID匹配的评论。
  3. 将这些过滤后的评论发送回前端。

代码示例如下:

app.get('/api/comments', (req, res) => {
  const parentId = req.query.parent;
  const filteredComments = comments.filter(comment => comment.parent === parentId);
  res.send(filteredComments);
});

核心概念:GET请求的参数通过URL的查询字符串传递(如 ?key=value)。在Express后端,使用 req.query.key 来获取这些值。


处理评论:POST请求 📤

最后,我们需要实现发布新评论的功能。

card.jsxaddNewComment 函数中,我们将发送一个POST请求到 /api/comment。请求体(body)中包含新的评论对象。

代码逻辑如下:

const addNewComment = (comment) => {
  post('/api/comment', comment).then((newComment) => {
    setComments([...comments, newComment]);
  });
};

同样,我们将更新状态的逻辑放在 .then() 内部,以确保与服务器状态同步。


实现POST评论端点 ⚡

在后端,创建 /api/comment 端点来处理新评论的提交。

这个端点与POST故事的端点非常相似:

  1. req.body 获取评论数据。
  2. 将新评论添加到后端的 comments 数组中。
  3. 将新增的评论对象发送回前端作为响应。

代码示例如下:

app.post('/api/comment', (req, res) => {
  const newComment = req.body;
  comments.push(newComment);
  res.send(newComment);
});


代码重构:使用路由中间件进行模块化 🧩

现在,我们的 server.js 文件包含了所有API端点,随着功能增加,它会变得冗长且难以管理。为了提高代码的组织性和模块化,我们将API相关的路由移到一个单独的文件中。

项目已经创建了一个 api.js 文件。我们将:

  1. api.js 中创建一个Express路由器(const router = express.Router())。
  2. server.js 中所有以 app.getapp.post 开头的API端点代码移动到 api.js 中。
  3. 将这些端点中的 app 替换为 router,并移除URL路径中的 /api 前缀(例如,/api/stories 变成 /stories)。
  4. api.js 文件末尾,导出这个路由器:module.exports = router
  5. server.js 中,导入这个路由器:const api = require('./api')
  6. server.js 中,添加一行中间件:app.use('/api', api)。这行代码告诉Express:任何以 /api 开头的请求,都交由 api.js 中的路由器来处理。

核心概念app.use('/api', router) 是一个中间件,它将特定路径前缀(/api)下的所有请求,转发给指定的路由器(router)处理。这有助于按功能模块组织代码。


总结 🎉

本节课中我们一起学习了如何构建一个完整的后端来支持前端应用。

我们完成的主要工作包括:

  1. 数据持久化:将故事和评论数据从React状态移动到后端服务器。
  2. HTTP通信:使用 getpost 函数在前端发送HTTP请求,与后端进行交互。
  3. 后端端点开发:在Express服务器上创建对应的GET和POST端点,以处理前端的请求,执行数据检索、添加等操作,并返回响应。
  4. 查询参数的使用:在获取评论时,通过URL查询参数 ?parent=storyId 来指定请求上下文。
  5. 代码组织:通过Express的路由器(Router)和中间件(Middleware)将API路由模块化到单独的文件中,使项目结构更清晰。

现在,你的CatBook应用已经具备了基本的前后端交互能力,帖子与评论在刷新页面后也能得以保留!

016:UI与Figma 🎨

在本节课中,我们将学习网站设计的两个核心概念:用户界面(UI)和用户体验(UX)。我们将探讨如何通过字体、颜色和布局来设计UI,以及如何优化用户与网站的交互流程。最后,我们将介绍一个强大的设计工具——Figma,并学习如何用它来创建网站线框图和原型。

网站设计的目的与核心概念

我们设计网站是为了服务用户。任何网站设计的中心都是用户。我们希望网站易于使用。

网站设计师需要考虑的两个核心概念是 UIUX

  • UI 决定了网站的视觉效果。它是用户访问网站并与之互动时获得的第一印象。
  • UX 更多地描述了用户从页面到页面、与网站互动时的用户流程。

一个好的UI/UX设计能够将网站的内容和功能有效地传递给用户。但这可以有很多不同的形式,没有一个固定的“好”的配方。

深入理解用户界面(UI)

上一节我们介绍了UI和UX的基本概念,本节中我们来看看如何构建一个优秀的用户界面。

你的UI应该由一个设计指南来引导。这个指南规定了你在网站中使用的字体、配色方案、间距布局以及前端可能使用的可复用组件。

以下是Web.lab网站和我们的幻灯片多年来使用的设计指南。最近,我们的网站进行了UI更新,所以你可能在网站上已经看不到这个UI格式了。但你可以看到,在这个UI指南中,我们规定了整个网站中不同标签使用的字体和配色方案。

你使用的字体和颜色能给用户留下非常强烈的第一印象,因此它们非常重要。在设计网站时,你应该花时间思考为UI指南选择什么样的字体和颜色。

字体选择

首先,我们从字体开始。如果你是麻省理工学院的学生,你可以访问Adobe Creative Cloud,这允许你使用Adobe字体。

这是Adobe字体的网站,你可以将任何字体家族添加到你的Web应用程序中或下载任何字体。我强烈推荐它,它是一个为网站寻找字体的绝佳工具。你可以按标签过滤,寻找不同的字体。

这是Adobe字体的首页。正如你所见,这些不同的字体已经给人留下了非常不同的印象。我写下了我对这些字体的初步印象。我相信你对这些字体也有不同的印象。例如,Ivy Style是一种非常现代和简洁的字体,你可能想把它用于非常现代的用户界面。而对于Mini Arcade或Limon,你可能不想将它们用于任何网站的正文文本,它们可能更适合网站的标题。

颜色选择

颜色也是如此。这是Coolors.co网站,它允许你选择和编辑配色方案。同样,颜色也会给用户留下非常强烈的第一印象。

我也浏览了这个网站并写下了我的初步印象,你可能也有类似的初步印象,也可能没有。这是需要考虑的一点。

以下是你在Coolors上可以使用的一些工具。你可以选择调色板并调整它们。

另一件需要考虑的事情是色彩心理学,你可以自行查阅相关资料。

UI趋势与文化差异

正如我们刚刚看到的,UI可以通过字体和颜色给用户留下非常深刻的印象,但这在很大程度上取决于你的用户群体。你的用户会随着时间和文化而变化。

UI趋势会随着时间变化,并因文化而异。现在我们将看一些例子,看看它是如何变化的。

这是2003年的麻省理工学院网站与现在的对比。正如你所见,麻省理工学院网站转向了更简约的UI。我们现在有了白色背景,而不是茶绿色背景;文字之间有更多间距,因此布局更加简约和空旷。我们还尽可能用图标替换了文字。

总的来说,在过去十年中,我们看到了向更简约UI发展的趋势。一个很好的例子是观察图标如何随时间变化。这是垃圾桶图标。图标最初是在1973年施乐公司引入图形用户界面时出现的。

在最初阶段,我们有非常原始和像素化的图标。随着我们经历90年代末和21世纪初的互联网泡沫,我们转向了拟物化图标,这些图标最初设计得非常用户友好,因为它们模仿了这些图标在现实生活中的样子。但随着我们进入2010年代及过去十年,我们有了更简约和平坦的UI图标布局。

但最近,我认为我们开始远离极简UI,因为现在很多图标有了更多阴影,可能还增加了一些维度感。

这是一个很酷的网站,你可以在自己的时间查看。你可能想看看你最喜欢的网站是如何随时间变化的。这是Web设计博物馆。

文化差异对UI的影响

正如我们刚刚看到的,我们一直在朝着更简约的UI发展,但在当今数字时代,并非所有用户都喜欢简约UI。

一个例子是网页设计在西方文化和东亚文化网站之间的UI差异。这是雅虎首页。左边是你在美国可以登录的英文雅虎网页,右边是日本雅虎首页。

一个非常有趣的现象是,日本的UI更类似于西方在互联网泡沫时期的网站外观,而且更加杂乱。没有中心焦点,也远没有那么简约。这是因为在不同文化中,用户有不同的需求。在西方,用户更喜欢具有主要焦点、能总结信息的更简化布局。而在东亚文化中,这是一种高语境文化,用户更喜欢能告诉他们更多关于网站的信息。

这确实强调了在设计网站时,你真的需要考虑你的用户群体。

如何让网站UI看起来更好

正如前面提到的,你应该使用UI指南,因为这能为你的网站提供一致性。

以下是一些供你选择颜色和排版风格的工具。

另一个重要的点是复用组件。你可以使用UI组件库。以下是一个我喜欢的组件库示例,它叫做Mantine。它允许你自定义组件并在整个网站中复用它们。

让你的UI看起来好的另一个重要部分是响应式设计。这意味着你的网站在不同尺寸的所有设备上都能看起来很好。

你可以使用检查元素并更改网站的尺寸来检查你的网站在不同尺寸屏幕上的显示效果。例如,这是Web.lab网站。

我们可以进入检查模式。在右侧面板的左上角,你可以切换设备工具栏并选择尺寸。这是它在iPhone SE上的显示效果。正如你所见,所有内容都重新调整了大小,在小屏幕上仍然看起来很好。这就是我们所说的响应式设计。

交互性

顺便提一下,交互性也很酷。交互性是指用户可以与网站互动。当你悬停或点击某些东西时,你会得到反馈。这很酷,它能让你的网站吸引眼球。但也有缺点,它可能会影响可用性,或者对浏览器资源消耗很大。

以下是由我们的助教Stanley设计的一个交互式网站示例。

正如你所见,我们最近进行了UI更改,新的UI更具交互性。你可以悬停在物体上,它们会改变颜色或不透明度。或者你有一个表情符号。

我们的旧网站没有任何用户交互性。所以新网站更加吸引眼球。交互性的另一个例子是,对于HackMIT,我们正在为我们即将举办的高中黑客马拉松Blueprint制作一个Three.js网站。

我们使用了一个3D建模网络应用程序来制作这个酷炫的房间。它允许用户与房间互动并点击房间里的不同物品。右边只是一个我找到的很酷的作品集页面,你可以在网站上与不同的物体互动。

深入理解用户体验(UX)

现在我们将转向UX。我会讲得快一点,因为我认为Sophie在她的UI部分涵盖了很多非常重要的点。

但简单来说,对于UX,我们需要考虑诸如导航对用户来说有多直观、他们可以在网站上使用什么线索、我们真正想要在网页上突出显示的基本元素有多明显,以及我们呈现的信息是否以逻辑方式组织,使用户能够轻松理解信息流。

让我们快速在这两个番茄酱瓶1和2之间进行一个投票。你认为哪一个更好地优化了用户体验?

是的,我只看到选2的。这是正确的,因为基本上,在旧的亨氏番茄酱瓶设计中,使用起来非常不直观,因为如果有剩余的番茄酱,你需要挖瓶子才能把剩余的弄出来。而在这里,你可以更容易地挤压它。所以它非常优先考虑用户体验。我们希望我们的Web应用程序也能做到同样的事情。

我们希望使用在不同文化中普遍理解的符号、概念和颜色。Sophie谈到了西方和东亚应用程序之间的差异,但图标是通用的。我们可以使用大多数人能理解的特定符号来表示某些事物。例如,你可以看这里的图标栏,看到主页用一个房子表示,搜索栏用一个放大镜表示,等等。还有很多颜色,比如绿色通常象征金钱或与财富相关的事物。

色彩心理学与可访问性

关于Sophie之前提到的色彩心理学,这是一个很好的理想比例:60%的主色,30%的次要色和10%的强调色。也有研究表明不同性别更喜欢哪些颜色。此外,下面的网站对比度检查器对于使你的网站具有可访问性非常有用,因为你可以检查以确保你的网站在灰度模式下仍然对红绿色盲用户可见,并且你有足够的对比度,让有视力问题的人能够适当地看到所有颜色。

优化UX的实例

我相信你们都熟悉Discord的UI,但Discord优化其UX的方式之一是将按钮真正集中且非常易于点击。所以进入网页时,你确切地知道你的选项是什么。

这是Facebook的旧UI。正如你所见,它相当不直观,因为创建账户的框非常大,这不太好,因为大多数时候用户不会创建新账户,而是可能会登录,而登录框在顶部这里非常小。所以这非常不直观,对吧?

我们希望信息流对用户来说非常容易消化和理解。这就是为什么Facebook切换到他们的新UI,这非常好,登录框位于前面和中心,然后创建新账户在下面,这更加直观,因为我们可能不会每次都创建新账户。

线框图与Figma入门

现在让我们谈谈线框图。这对你们的项目来说超级重要。如果你之前走神了,现在应该注意了。

基本上,线框图就是为你的Web应用程序创建一个超级粗略的草图。但它非常有帮助,因为一旦你硬编码了东西,改变起来会比你在实际开始编码之前对应用程序的外观有一个清晰的想法要困难得多。

所以你要专注于应用程序的整体结构以及关键元素将放在哪里。你可以在这里的这个例子中看到,他们有侧边栏,他们知道导航栏会是什么样子,他们知道应用程序上想要什么样的标签和页面。

它可以非常草稿化,不需要任何正式的东西。就把它想象成你的网站可能看起来的粗略草图。

制作线框图的工具

要制作线框图,我们有几种不同的选择。我们可以使用Figma,这是我们稍后要讨论的。你也可以在Google幻灯片上制作一个,这很好,因为你可以显示不同页面之间的过渡。你可以用笔和纸画出来,或者使用任何其他线框图软件,如Photoshop或Sketch。但我们将使用Figma,因为它有一个非常有用的功能叫做原型设计。

原型设计基本上是将你的线框图变为现实。所以你将为你的线框图添加交互,并显示哪些页面通向哪里。这基本上有助于改进你的UI,因为你确切地知道编码所需的技术要求,以及用户应该被重定向到哪里。

它可以非常类似于真实的东西。正如你所见,这是一个非常好的原型,包含了所有交互,如滚动、重定向等。

为什么选择Figma

我们将使用Figma,因为它具有非常好的实时协作功能,以及为线框图添加交互和原型设计的选项。所以它对我们来说非常有帮助。而且,我认为它对初学者来说非常直观。

好的,现在我们将过渡到Figma演示或小型研讨会。基本上,我将介绍关键功能的位置步骤,然后你们将有一些时间自己动手操作。我知道我们没有很多时间,但希望我能快速讲解,然后你们将有时间自己尝试。

Figma基础演示

是的,你可以等一下。还有幻灯片。我想...这个可以吗?PC1上可以。好的,很酷。

基本上,我们都记得Catbook,如果我们要为Catbook创建一个线框图会是什么样子。所以我们将快速过一遍。你们现在可以跟着这个步骤操作。

基本上,你只需要在Figma.com创建一个Figma账户。正如你所见,我已经有了我的账户。我们要做的是,一旦你进入Figma首页,你想创建一个新文件。你只需点击这里的“创建文件”或“新建”。然后你将选择设计文件。我将使用我已经创建的Catbook线框图。

但在创建新文件时,哦,如果你需要任何帮助或在某些地方卡住了,你也可以打开这个速查表作为参考:weblab.is/figma-cheatsheet。

Figma界面基础

只是一些快速的基础知识,Figma中的所有内容都组织成图层。所以你会在侧边栏看到你有不同的页面,然后你的所有元素都会显示在那里。画布,这个中心部分,是你所有东西将要显示的地方。工具在底部。所以这显示的是Figma的旧UI。现在工具被重新定位到了底部。然后在右侧,你有选择其他东西的选项,我们稍后会介绍。

但我认为这些工具很直观,所以我不打算深入介绍,但基本上你有移动和缩放选项,你可以创建框架,你可以创建形状,你可以用钢笔和铅笔绘图,你也可以做文本,你还可以留下评论,这就是为什么它非常适合协作。

创建框架与组件

好的,Figma的基础是一切都将包含在一个框架中。让我们从创建一个框架开始。我只是点击这里,然后你可以看到你可以为你的框架选择不同的设备。我只是选择,比如,桌面。然后我们选择MacBook Pro 14英寸。这是我们的框架。

框架是元素的容器,所以这将容纳我们应用程序页面上的一切,对吧?

框架非常强大。如果你们以前使用过任何图像创建软件,即使是PowerPoint或Google Slides,我相信你可能熟悉对项目或对象进行分组。但对于Figma,与其对对象进行分组,我建议你每次想要对对象进行分组时都创建一个框架,因为它比分组对象更强大,因为你可以独立调整框架内所有内容的大小。你还可以有很酷的溢出内容选项。这允许你利用文本裁剪。总的来说,如果你想对所有内容应用一致的样式,它会更好。

让我们制作Catbook主页,对于Catbook导航栏,我将在这里创建一个框架。

然后你可以在这里更改填充颜色。所以你可以看到我正在更改颜色。十六进制代码是#2B73FF。

这将是我们的导航栏框架,对吧?然后我们可以将文本拖到里面。文本在底部的工具这里。我们只想写“catbook”。是的,然后你可以根据需要在侧面调整大小,比如你想要多大的文本,什么字体等等。我现在不会全部介绍,因为我认为你们可以自己动手尝试。

但Figma的一个关键优点是,假设我们想制作一个个人资料页面,对吧?我们会把导航栏复制粘贴到我们的个人资料页面吗?如果是,请竖起大拇指;如果不是,请竖起小拇指。

是的,如果答案是肯定的,我可能不会问这个问题,对吧?所以我们将复用它作为一个组件。你可以看到这里,我已经方便地做好了。但基本上,如果我转到我们刚才所在的框架,我点击它,我可以右键点击,然后我可以选择“创建组件”,这基本上会为我创建这个导航栏元素的可复用实例。

所以如果我转到我的资源,我已经在这里制作了一个更好看的导航栏组件。所以这个就是我们将要使用的。但基本上,你可以创建组件,这些是你想要在多个页面(比如你的导航栏)上复用的东西的可复用实例。

所以现在,如果我们想制作一个个人资料页面,而不是重新制作导航栏或复制粘贴它,我们可以转到资源。然后我们可以在这里插入实例。这样我们就可以得到另一个导航栏。是的,这就是我们复用组件的方式。

这些东西也很直观。你基本上只是复制粘贴图像。你可以通过到这里来塑造形状,你可以更改半径。我就是这样让它变圆的。

你可以在自己的时间更多地复习幻灯片,但基本上,当你编辑组件时,当你编辑主组件时,它会改变所有其他实例。


在本节课中,我们一起学习了网站设计的核心——UI与UX。我们探讨了如何通过字体、颜色和布局来塑造UI给用户的第一印象,以及如何从用户流程、直观性和可访问性角度优化UX。最后,我们初步掌握了使用Figma进行线框图和原型设计的基本方法,这是将设计想法可视化和测试的重要工具。记住,好的设计始终以用户为中心。

017:Catbook调试挑战第一部分解答 🐛

在本节课中,我们将一起解决Catbook应用的第一部分调试挑战。我们将逐步分析代码中的错误,理解错误信息,并学习如何定位和修复这些常见的Web开发问题。


准备工作

在开始调试之前,我们需要确保开发环境处于一个干净的状态。首先,我们重置本地仓库,然后检出debug-challenge分支。

git reset
git checkout debug-challenge

接下来,启动开发服务器。

npm start
npm run dev

确保服务器正常运行后,我们打开浏览器查看应用。


第一个错误:语法错误

打开应用后,浏览器控制台立即显示了一个错误。

“let cannot be used as the identifier in strict mode.”

这个错误信息本身可能有些令人困惑,但它指出了错误发生在feed.jsx文件中。我们点击错误链接,或者直接打开该文件进行查看。

feed.jsx文件中,VS Code编辑器已经用红色波浪线标出了一个语法错误:“reverse story objects, comma expected.”。这表明代码结构存在问题。

具体来看,问题出在fetch请求的.then()方法处理上。.then()方法期望接收一个回调函数作为参数。然而,当前的代码并不是一个标准的函数定义。

一个标准的箭头函数定义应该包含括号(用于参数)、箭头符号=>以及花括号{}来包裹函数体。我们需要修正这个结构。

此外,代码中使用了storiesResponse变量。这个变量应该是从fetch请求的响应中获取的数据,并作为参数传递给回调函数。我们需要确保它被正确定义。

修正后的代码逻辑是:向/api/stories发送GET请求,将返回的storiesResponse(一个故事列表)传递给回调函数,在回调函数中将其反转,然后使用React的setState方法更新状态。

// 修正前
fetch("/api/stories").then(storiesResponse => storiesResponse.reverse())

// 修正后
fetch("/api/stories").then((storiesResponse) => {
    setStories(storiesResponse.reverse());
})

保存文件并刷新页面,我们来看看是否解决了第一个问题。


第二个错误:Props未定义

页面刷新后,我们看到了一个不同的错误。

“ReferenceError: props is not defined at SingleStory”

这个错误指向SingleStory组件。我们打开singlestory.jsx文件进行查看。

在文件中,代码试图访问props.creator_nameprops.content。然而,SingleStory组件函数目前并没有将props作为参数接收。

在React中,一个组件本质上是一个接收props(属性)作为输入的函数,它根据这些props返回JSX(类似于HTML的React元素)。因此,我们需要修改函数签名,使其接收props参数。

// 修正前
export default function SingleStory() {
    return (
        <div>
            <h3>{props.creator_name}</h3>
            <p>{props.content}</p>
        </div>
    );
}

// 修正后
export default function SingleStory(props) {
    return (
        <div>
            <h3>{props.creator_name}</h3>
            <p>{props.content}</p>
        </div>
    );
}

保存修改并再次刷新页面。


第三个错误:API请求URL构造错误

页面现在看起来好一些了,但浏览器控制台仍然有错误,并且评论没有显示出来。

错误信息显示:“GET .../api/commentsParentID=3 404 (Not Found)”。同时,在浏览器的“网络”(Network)标签页中,我们可以看到这个失败请求的详细信息。

关键问题在于请求的URL格式不正确。一个标准的GET请求URL应该使用问号?来分隔路径和查询参数。当前的URL是/api/commentsParentID=3,缺少了问号,导致服务器无法正确解析。

我们需要找到构造这个URL的代码。根据经验,这个请求很可能是在Card组件中发起的。我们打开card.jsx文件。

在文件中,我们看到了一个get函数的调用:get("/api/comments", { parent: props.id })。这表明URL的拼接逻辑被封装在了这个get函数内部。

我们通过右键点击get函数并选择“转到定义”,跳转到utilities.js文件。在这里,我们找到了构造完整URL的代码。

问题在于,代码直接将端点(endpoint)和参数字符串拼接在一起,中间缺少了问号?。我们需要添加这个问号。

// 修正前
const fullPath = endpoint + paramsString;

// 修正后
const fullPath = endpoint + '?' + paramsString;

保存修改并刷新页面。现在,评论应该能够正确加载并显示了。


第四个错误:Prop命名不匹配

现在应用的基本功能已经正常,但我们发现故事的作者名显示为空白。没有错误信息,但显然有数据没有正确传递。

我们需要检查负责显示作者名的组件。根据React组件树,故事内容由SingleStory组件渲染,而它的父组件是Card

首先检查SingleStory组件,它从props中读取creator_name并显示,这部分代码看起来是正确的。

问题可能出在父组件Card向子组件SingleStory传递props的时候。我们回到card.jsx文件。

Card组件中,渲染SingleStory组件时,传递了一个名为creator的属性:<SingleStory creator={props.creator_name} ... />

然而,在SingleStory组件内部,它期望接收的属性名是creator_name。这个命名上的不匹配导致了数据无法正确传递。

我们需要将传递属性时的名称与子组件期望的名称保持一致。

// 修正前(在Card组件中)
<SingleStory creator={props.creator_name} ... />

// 修正后
<SingleStory creator_name={props.creator_name} ... />

保存并刷新后,故事的作者名现在可以正确显示了。


第五个错误:评论无法持久化

最后,我们来测试交互功能。发布新故事工作正常。但是,当我们发布一条新评论并刷新页面后,评论消失了。这表明评论没有被成功保存到后端。

由于评论在发布后能立即在前端显示,说明前端的状态更新是正常的。问题可能出在将数据发送到后端,或者后端存储数据的过程中。

我们需要追踪一条评论从提交到发送的完整链路。

  1. 起点:评论的提交发生在NewComment组件中(位于newpostinput.jsx)。当点击提交按钮时,会触发一个名为addComment的函数。
  2. 传递addComment函数内部调用了从父组件传递下来的addNewComment函数。为了找到这个函数的定义,我们沿着组件树向上查找。
  3. 溯源NewComment的父组件是CommentsBlock,而CommentsBlock的父组件是Card。最终,我们在Card组件中找到了addNewComment函数的定义。
  4. 分析:这个函数的作用是向/api/comments发送一个POST请求,请求体就是调用时传入的参数。
  5. 对比:现在,我们需要对比前端发送的数据格式和后端期望接收的格式。我们查看后端API文件api.js中的postComment端点。
    后端期望接收一个具有特定字段的评论对象。为了确认格式,我们可以查看现有评论的数据结构。
    通过对比发现,前端在构造新评论对象时,使用了parent_story作为字段名,但后端期望的字段名是parent。这个不一致导致后端无法正确识别和处理新评论,因此评论没有被保存。

我们需要将前端代码中的字段名parent_story改为parent

// 修正前(在NewComment组件中)
addComment: (text) => {
    addNewComment({
        "_id": String(Math.random()),
        "creator_name": "placeholder",
        "content": text,
        "parent_story": props.parentId // 错误的字段名
    });
}

// 修正后
addComment: (text) => {
    addNewComment({
        "_id": String(Math.random()),
        "creator_name": "placeholder",
        "content": text,
        "parent": props.parentId // 修正为正确的字段名
    });
}

保存修改后,发布一条新评论并刷新页面,评论现在可以持久化保存了。


总结

在本节课中,我们一起完成了Catbook应用第一部分的调试挑战。我们学习了:

  1. 阅读错误信息:如何从浏览器控制台和编辑器提示中理解错误来源。
  2. 理解React组件通信:修复了因未接收props参数和props命名不匹配导致的数据传递问题。
  3. 调试网络请求:通过浏览器“网络”标签页检查API请求,并修正了URL构造错误。
  4. 追踪数据流:从前端表单提交开始,沿着组件层级和函数调用链,一直追踪到后端API,定位了数据格式不匹配的根源问题。

调试是开发过程中至关重要的技能,需要耐心、细致的观察和对系统工作原理的理解。希望这次调试练习能帮助你建立解决实际问题的信心和方法。

018:Catbook调试挑战第二部分解答 🐛

在本节课中,我们将一起解决Catbook应用中的一系列前端Bug。我们将学习如何识别和修复React组件中的语法错误、路由问题、数据传递失败以及API请求发送错误。通过逐步调试,你将理解如何追踪数据流和修复常见的Web开发问题。


修复JSX语法错误

上一节我们遇到了一个JSX语法错误。本节中我们来看看如何修复它。

错误信息指出“JSX值应为表达式或引用的JSX文本”。问题出现在profile.jsx文件中,具体是在渲染CatHappiness组件时。

// 错误示例:缺少花括号包裹JavaScript表达式
<CatHappiness catHappiness=catHappiness />

在JSX中,当我们需要将JavaScript变量或表达式作为属性值传递给组件时,必须用花括号{}将其包裹。catHappiness是一个定义在Profile组件内部的React状态变量,我们需要将其值传递给CatHappiness组件的同名属性。

// 正确示例:使用花括号包裹JavaScript表达式
<CatHappiness catHappiness={catHappiness} />

这样,catHappiness状态的值(一个数字,如0, 1, 2, 3)就会被正确地计算并传递给子组件。


修复导航栏路由

修复语法错误后,我们发现无法通过导航栏进入个人资料页面。让我们检查导航栏的代码。

问题在于导航栏中指向个人资料页面的链接没有设置正确的目标路径。

// 错误示例:链接缺少目标路径
<Link>Profile</Link>

Link组件需要to属性来指定导航目标。主页链接指向/,个人资料页链接应指向/profile

// 正确示例:指定to属性
<Link to=“/profile”>Profile</Link>

点击此链接会将URL更改为/profile。然后,在index.jsx中定义的路由逻辑会读取当前URL,并决定渲染Profile组件。


修复动态内容显示

现在我们可以进入个人资料页了,但发现动态内容(如故事正文)无法显示。我们需要追踪数据是如何传递的。

故事数据从Feed组件开始,通过Card组件,最终到达SingleStory组件进行渲染。问题很可能出在数据传递链的某个环节。

检查Feed.jsx,我们发现它在渲染Card组件时,只传递了keyidcreatorName属性,但没有传递关键的content属性。

// 错误示例:未传递content属性
<Card key={story.id} id={story.id} creatorName={story.creatorName} />

Feed组件通过map函数遍历一个故事对象数组。每个故事对象都包含content字段。我们需要将这个字段也传递给Card组件。

// 正确示例:传递所有必要属性
<Card key={story.id} id={story.id} creatorName={story.creatorName} content={story.content} />

这样,content数据就会沿着组件树向下传递,最终在SingleStory组件中显示出来。


修复发布新故事功能

接下来,我们发现发布新故事的功能有问题:故事能发布,但内容为空。这表明前端发送的请求体中缺少内容数据。

我们需要同时检查前端发送请求的代码和后端接收请求的代码。

在前端Feed.jsx中,我们找到了addNewStory函数,它负责发送POST请求到/api/story。但是,查看调用这个函数的地方(在NewPostInput组件中),我们发现调用时没有传入任何参数。

// 错误示例:调用函数时未传递参数
addNewStory();

为了创建一个新故事,我们需要向服务器发送一个包含故事信息(如idcreatorNamecontent)的对象。addNewStory函数应该接收这个对象作为参数。

NewPostInput组件中,用户输入的内容存储在组件的状态value中。当用户提交时,我们应该调用addNewStory并传入一个包含所有必要信息的新故事对象。

// 正确示例:构造故事对象并传递给函数
const newStory = {
  id: id, // 假设id已定义
  creatorName: “anonymousUser”, // 或从状态获取
  content: value // 用户输入的内容
};
addNewStory(newStory);

同时,需要确保Feed.jsx中的addNewStory函数正确地接收这个参数并将其作为请求体发送。

const addNewStory = (storyData) => {
  fetch(“/api/story”, {
    method: “POST”,
    headers: { “Content-Type”: “application/json” },
    body: JSON.stringify(storyData) // 将故事对象转换为JSON字符串
  })
  .then(...)
}


修复发布新评论功能

最后,我们发现发布新评论的功能完全失效。打开浏览器开发者工具的“网络”选项卡,发现点击提交后根本没有POST请求发出。这说明问题出在前端,函数可能没有被正确调用。

我们沿着组件树追踪addNewComment函数的传递路径:从Card传到CommentsBlock,再传到NewPostInput组件。

NewPostInput组件中,我们接收到了一个名为addNewComment的属性(prop)。然而,这个组件需要一个名为onSubmit的属性来指定提交按钮被点击时的回调函数。当前的代码没有将addNewComment函数赋值给onSubmit属性。

// 错误示例:NewPostInput组件缺少onSubmit处理函数
<NewPostInput />

我们需要将addNewComment函数作为onSubmit属性传递给NewPostInput组件。

// 正确示例:传递onSubmit处理函数
<NewPostInput onSubmit={addNewComment} />

这样,当用户在评论输入框中点击提交时,NewPostInput组件内部就会调用我们传入的addNewComment函数,从而触发向服务器发送POST请求的流程。


总结

本节课中我们一起学习了如何系统性地调试一个React前端应用。我们修复了以下问题:

  1. JSX语法错误:学会了使用{}包裹JavaScript表达式以传递动态值。
  2. 路由链接错误:修正了导航栏Link组件缺失的to属性。
  3. 数据传递断裂:追踪了从FeedSingleStory的组件树,补全了缺失的content属性传递。
  4. API请求参数错误:分析了发布新故事失败的原因,修复了函数调用时缺失参数以及请求体构造的问题。
  5. 事件处理函数未绑定:发现了评论功能失效是由于onSubmit回调函数未正确绑定,并进行了修复。

调试的关键在于耐心追踪数据流和函数调用链,从错误信息或现象出发,逐层排查可能的原因。恭喜你完成了第二部分调试挑战!

019:调试Catbook第三部分答案

在本节课中,我们将一起回顾并解决调试挑战三中的问题。我们将学习如何使用浏览器开发者工具的网络面板来诊断前端与后端之间的通信问题,并修复导致评论不显示、故事发布失败以及评论无法持久保存的代码错误。

检查评论显示问题

上一节我们介绍了调试的基本思路。本节中我们来看看如何定位评论不显示的问题。

首先,我们注意到主页上的评论没有正确显示。这通常意味着从服务器获取评论的GET请求可能存在问题。我们知道正常情况下应该有一些评论存在。

以下是检查网络请求的步骤:

  1. 打开浏览器开发者工具,切换到“网络”面板。
  2. 刷新页面,观察发出的请求。
  3. 找到向/comments端点发出的GET请求并检查其详情。

通过检查,我们发现请求URL是正确的(例如/comments?parent=ID1),并且服务器返回了成功的状态码(如200)。然而,响应体是空的。这表明问题很可能出在服务器端,它没有返回预期的评论数据。

诊断后端API错误

既然前端请求看起来正常,那么问题可能出在后端处理逻辑上。让我们检查服务器端的API代码。

api.js文件中,处理/comments GET请求的代码如下:

app.get("/comments", (req, res) => {
  const parent = req.body.parent; // 这里使用了错误的属性
  const matchingComments = comments.filter(c => c.parent === parent);
  res.send(matchingComments);
});

这里存在一个关键错误:我们试图从req.body中获取parent参数。然而,对于GET请求,参数是通过查询字符串(URL中?后面的部分)传递的,应该使用req.query来访问。

为了验证这一点,我们可以在服务器代码中添加调试语句:

console.log("Received parent:", req.body.parent);

这个console.log的输出会显示在运行服务器的终端中,而不是浏览器的控制台。通过观察,我们发现req.body.parent的值是undefined,这证实了我们的猜测。

修复方法是将req.body.parent改为req.query.parent

修复故事发布功能

解决了评论显示问题后,我们接下来看看发布新故事的功能为何失效。

当我们尝试发布一个新故事时,故事会出现在列表中,但没有内容。我们需要检查发布故事的POST请求。

在网络面板中,我们找到向/api/story发出的POST请求。检查其“载荷”标签页,可以看到前端发送的数据包含contentcreatorname_id字段,这看起来是正确的。

然而,服务器没有返回任何响应。通常,成功创建资源后,服务器应该将新创建的故事对象返回给前端。

检查api.js中处理POST /story的代码:

app.post("/story", (req, res) => {
  const newStory = req.query; // 错误:使用了req.query
  stories.push(newStory);
  // 缺少 res.send(...) 语句
});

这里有两个问题:

  1. 我们错误地从req.query中获取数据,但POST请求的数据在请求体req.body中。
  2. 我们在stories数组中添加了新故事,但没有使用res.send将新故事对象返回给前端。

修复方法是:

  1. req.query改为req.body
  2. 在函数末尾添加res.send(newStory);

确保评论被持久保存

最后一个问题是:新发布的评论在页面刷新后会消失。虽然提交评论后能立即显示,说明前端接收和显示响应是正常的,但刷新后数据丢失,说明服务器没有将评论正确保存到内存中。

检查处理POST /comment的代码:

app.post("/comment", (req, res) => {
  const newComment = req.body;
  res.send(newComment);
  // 缺少将评论保存到数组的步骤
});

代码接收了评论数据并返回给了前端,但没有将其存入comments数组。因此,当服务器重启或新的GET请求到来时,这些数据就丢失了。

修复方法是在发送响应之前,将新评论添加到数组中:

app.post("/comment", (req, res) => {
  const newComment = req.body;
  comments.push(newComment); // 保存评论
  res.send(newComment);
});

总结

本节课中我们一起学习了如何系统性地调试一个全栈Web应用。

  1. 利用网络面板:通过检查请求和响应的具体内容,我们可以快速定位问题是发生在前端(请求格式错误)还是后端(响应错误或缺失)。
  2. 理解请求类型:我们明确了GET请求的参数通过req.query访问,而POST请求的数据通过req.body访问。
  3. 完整的CRUD操作:对于创建(POST)操作,后端除了处理数据,通常还需要将创建的资源返回给前端,并确保数据被持久保存(例如,推入内存数组)。
  4. 添加调试日志:在服务器代码中使用console.log可以帮助我们理解数据的流动和变量的实际值。

通过修复这三个bug——评论获取、故事发布和评论保存——我们巩固了对于前后端交互和数据流管理的理解。

020:调试Catbook第四部分答案

在本节课中,我们将一起解决Catbook项目的第四个调试挑战。我们将定位并修复导致页面白屏、评论显示错误以及新故事发布后前端不更新的问题。

概述与问题重现

上一节我们完成了代码的拉取和服务器重启。现在,让我们访问Catbook页面,看看具体出现了什么问题。

页面显示为白屏。一个实用的调试技巧是:每当遇到白屏,首先应打开浏览器的JavaScript控制台,那里通常会有更详细的错误信息。

控制台显示错误:Uncaught SyntaxError: The requested module ‘./NewPostInput’ does not provide an export named ‘default’。这个错误与组件的导出和导入有关,并且指向了FeedNewPostInput组件。

修复导出/导入语法错误

让我们先检查NewPostInput.jsx文件,看看它是如何导出的。

NewPostInput.jsx中,我们定义了NewStoryNewComment两个组件,并使用以下方式导出:

export { NewComment, NewStory };

注意,这里使用的是命名导出,而不是export default

接下来,我们看看在Feed.jsx中是如何导入NewStory的。

问题找到了!在Feed.jsx中,导入语句缺少了花括号 {}。对于命名导出的模块,导入时必须使用花括号指定具体的导出名称。

修复方法:
import NewStory from ‘./NewPostInput’; 修改为:

import { NewStory } from ‘./NewPostInput’;

保存更改后,页面错误更新为:The requested module ‘./Feed’ does not provide an export named ‘default’。这说明Feed组件本身的导出也有问题。

修复Feed组件的导出

这个错误可能出现在Feed.jsx的导出部分,或者index.jsx的导入部分。我们同时打开这两个文件进行检查。

index.jsx中,导入语句是 import Feed from ‘./Feed’;,这表示它期望导入Feed.jsx的默认导出。
然而,在Feed.jsx文件的底部,我们并没有任何导出语句。

修复方法:
Feed.jsx文件底部添加默认导出:

export default Feed;

保存后,页面成功加载,但出现了新问题:所有评论都显示在每一条故事下面,而不是只显示在对应的父故事下。

修复评论过滤逻辑

所有评论都重复显示,这通常是后端API过滤逻辑出了问题。我们打开浏览器开发者工具的“网络”选项卡,查看获取评论的请求。

请求URL是 /api/comments?parentId=3,这看起来是正确的。服务器也返回了三条评论。问题可能出在服务器没有正确根据parentId进行过滤。

打开服务器端的api.js文件,查看处理/api/comments GET请求的代码。

代码中,我们试图根据req.query.parentStory来过滤评论。为了调试,我们在服务器终端打印相关值:

console.log(‘comment.parentStory:’, comments[0].parentStory);
console.log(‘req.query.parentStory:’, req.query.parentStory);

刷新页面后,终端两个值都打印为undefined。这说明:

  1. 评论对象中没有parentStory字段,正确的字段名是parent
  2. 查询参数中也没有parentStory,正确的参数名是parentId

修复方法:
将代码中的parentStory全部更正为正确的字段名。

  1. comment.parentStory 改为 comment.parent
  2. req.query.parentStory 改为 req.query.parentId

修改后,评论显示恢复正常,每条故事下只显示属于自己的评论。

修复发布新故事后前端不更新的问题

现在测试发布新故事功能。提交后,新故事没有立即出现在页面上,但刷新页面后会显示。这说明后端成功保存了数据,但前端没有用返回的数据更新状态。

首先,我们检查后端api.js中处理/api/story POST请求的代码。

代码将新故事加入了数组,但没有通过res.send()将其返回给前端。根据RESTful API设计,创建资源后通常将新创建的对象返回。

修复方法:
在添加故事到数组后,发送新故事作为响应:

res.send(newStory);

但这还不够。我们还需要修改前端,使其在收到服务器响应后更新本地状态。打开Feed.jsx,找到addNewStory方法。

当前,addNewStory方法只发送了POST请求,但没有处理响应。我们需要在Promise的.then()回调中,使用服务器返回的新故事来更新React的状态。

修复方法:
修改addNewStory方法,在收到响应后更新stories状态:

addNewStory = (value) => {
    fetch(‘/api/story’, {
        method: ‘POST’,
        headers: {
            ‘Content-Type’: ‘application/json’,
        },
        body: JSON.stringify(value),
    })
    .then((response) => response.json())
    .then((storyResponse) => {
        // 将新故事添加到现有故事列表的前面
        this.setStories([storyResponse, …this.state.stories]);
    });
}

这里,setStories用于更新状态,将新故事(storyResponse)放在原有故事数组(…this.state.stories)的最前面。

总结

本节课中,我们一起学习了如何系统性地调试一个React全栈应用。我们解决了三个主要问题:

  1. 组件导入/导出错误:修复了因错误使用命名导出和默认导出而导致的语法错误。
  2. 后端API逻辑错误:修正了字段名不匹配导致的评论过滤失效问题。
  3. 前端状态更新缺失:补充了发布新故事后,前端根据服务器响应更新本地状态的逻辑。

调试的关键在于耐心和有条理:从错误信息出发,利用浏览器控制台和网络工具,定位问题出现在前端还是后端,然后逐层检查相关代码。希望这次调试过程能帮助你更好地理解模块导出和状态管理的概念。

021:数据库 🗄️

在本节课中,我们将要学习数据库的基础知识,了解为什么在Web应用中需要数据库,并初步认识我们将要使用的MongoDB。

概述

目前,我们在Catbook中存储数据的方式是将数据直接保存在服务器的一个变量(如 stories 数组)中。然而,这种方式存在一些问题。本节我们将探讨这些问题,并介绍数据库作为更优的解决方案。

当前数据存储方式的问题

上一节我们介绍了将数据存储在服务器变量中的方式。本节中我们来看看这种方式存在哪些主要缺陷。

当客户端发送请求(例如向 /api/story 端点提交新故事)时,数据被添加到服务器上的 stories 数组中。代码如下:

// 在 api.js 中
const data = {
  stories: [
    { id: 1, creator: ‘Alice‘, content: ‘Hello‘ },
    // ... 更多故事
  ]
};

然而,这种方式有几个严重问题:

  1. 数据易失性:如果服务器崩溃、终端关闭或电脑断电,存储在内存变量中的所有数据都会丢失。
  2. 内存限制:随着用户和故事数量增长,大量数据存储在服务器的RAM中,可能导致内存不足。
  3. 性能问题:随着数据量增大,直接在内存中管理和查询数据会变得低效。

尝试改进:使用文本文件

为了解决数据持久化的问题,我们可能会考虑将数据存储到硬盘的文本文件(如 data.txt)中。我们可以使用读写文件的函数。

以下是读写文件函数的伪代码示例:

function readDataFromFile() {
  // 从 data.txt 读取数据
}
function writeDataToFile(data) {
  // 将数据写入 data.txt
}

在API端点中,我们可能会这样实现:

// 加载数据
const stories = readDataFromFile();
// 保存数据
writeDataToFile(updatedStories);

虽然这解决了数据持久化的问题,但仍有其他弊端:

  1. 读写速度慢:每次读写都需要进行文件I/O操作,非常耗时。
  2. 查询效率低:要查找特定用户的故事,需要线性遍历整个文件内容。
  3. 硬盘仍会损坏:存储文件的硬盘本身并非绝对可靠。
  4. 并发问题:如果两个用户同时写入,无法确定最终哪个数据被保存,可能导致数据覆盖或丢失。

解决方案:数据库

那么,如何解决上述所有问题呢?答案是使用数据库。

数据库是一个高度组织化的数据集合,用于以结构化的方式存储应用中的所有数据。数据库管理系统(DBMS)则是一组函数的集合,允许你对数据库中的数据进行检索、添加、修改和删除等操作。

数据库有很多类型,例如:

  • 关系型数据库(如SQL):擅长处理结构化的关系数据。
  • 文档数据库(如MongoDB):以类似JSON的文档形式存储数据,更加灵活。
  • 图数据库:强调数据之间的关系。
  • 时序数据库(如InfluxDB):专门处理带时间戳的数据。
  • 层次数据库(如IBM IMS):以树形结构组织数据。

在我们的Web开发课程中,我们将使用MongoDB,因为它更适合我们应用的灵活需求。

数据库如何工作

我们刚刚讨论了用数据库替代文本文件,用DBMS替代读写函数。现在让我们更深入地了解通过DBMS进行读写操作的过程。

读取数据的过程

  1. 前端向服务器发送GET请求(例如 /api/comments)。
  2. 后端服务器接收到请求。
  3. 服务器通过DBMS调用一个“读”函数。
  4. DBMS访问数据库,获取所有评论数据。
  5. 数据库将数据返回给DBMS,再传回服务器。
  6. 服务器最终将数据发送回前端客户端。

写入数据的过程

  1. 前端向服务器发送POST请求(例如 /api/comments)。
  2. 后端服务器接收到请求和新数据。
  3. 服务器通过DBMS调用一个“写”函数。
  4. DBMS将新数据添加到数据库中。

以下是DBMS操作的一些伪代码示例:

// 导入DBMS包
const dbms = require(‘some-dbms-package‘);

// 读取所有故事
const allStories = dbms.read(‘stories‘);

// 带查询地读取(例如查找id为4的故事)
const storyWithId4 = dbms.read(‘stories‘, { id: 4 });

// 写入新故事
dbms.write(‘stories‘, newStory);

// 删除数据(例如删除用户Joyce的所有故事)
dbms.delete(‘stories‘, { author: ‘Joyce‘ });

// 更新数据(例如更新作者为Jay的故事)
dbms.update(‘stories‘, { author: ‘Jay‘ }, { content: ‘Updated content‘ });

请注意,以上并非真实代码,仅为示意。

关系型数据库 vs 文档数据库

接下来,我们比较一下两种主要的数据库类型。

关系型数据库(如SQL)

关系型数据库将数据存储在具有行和列的表格中,类似于Excel或Google Sheets。

  • 每个电子表格称为一个“表”。
  • 例如,你可能有一个 users 表和一个 stories 表。
  • 表之间通过关系连接(例如,stories 表中有一个 user_id 字段指向 users 表)。

存在的问题:

  • 一个用户的数据可能分散在多个表中。
  • 随着应用增长,表之间的关系会变得非常复杂,需要编写大量代码来维护这些关系。
  • 这增加了代码的理解难度和添加新功能的困难。
  • 数据库结构不够灵活,需要程序员编写代码去适应它。

文档数据库(如MongoDB)

文档数据库将数据存储为“文档”,这非常类似于我们熟悉的JavaScript对象。

  • 每个文档是一个对象,包含所有相关字段。
  • 文档不需要拥有完全相同的字段,这提供了极大的灵活性。
  • 例如,一个用户文档可以包含 nameagehobbies 等字段。

这种以对象为导向的方式对程序员来说更加直观和自然。在Catbook中,我们的评论对象结构如下,这与文档数据库的存储方式完美契合:

{
  “id“: 123,
  “creator“: “Sophie“,
  “content“: “Great workshop!“
}

深入MongoDB

我们将要使用的具体文档数据库是MongoDB。它直接以类似JSON的文档形式存储数据。

为什么选择MongoDB?

  1. 高效写入:MongoDB(名称源于“Humongous”,意为巨大)擅长处理海量数据写入。
  2. 结构灵活:数据模型容易变化,可以轻松添加或修改文档中的字段。
  3. 易于使用:其面向对象的特性对程序员非常直观。

MongoDB的结构

MongoDB的数据组织方式可以类比为一个仓库:

  • 文档:相当于一个具体的物品(例如,一只柯基犬的信息)。它是一个JSON对象。
    { “name“: “Sophie“, “age“: 3, “color“: “brown“ }
    
  • 集合:相当于一个装有同类物品的板条箱(例如,一箱柯基犬)。它是文档的集合。
  • 数据库:相当于一个仓库,里面存放着许多不同的板条箱(集合)。它通常对应一个完整的应用程序。
  • MongoDB实例:相当于一个数据中心,里面运行着一组数据库。

层级关系总结: MongoDB实例 > 数据库 > 集合 > 文档。

实现容错:MongoDB Atlas

即使使用数据库,如果硬盘损坏,数据仍然会丢失。如何使数据库存储具有容错性?

答案是冗余。MongoDB Atlas(MongoDB的云服务)通过在不同硬盘上复制数据来实现这一点。如果主硬盘发生故障,系统可以自动访问存有相同数据的副本硬盘。

这样做的好处:

  • 高可靠性:数据在云端被复制,远比个人电脑硬盘可靠。
  • 易于管理:无需在本地运行和维护数据库。
  • 便于协作:团队成员可以轻松共享和访问同一份云数据。

使用Mongoose规范结构

虽然MongoDB的灵活性是优势,但在实际开发中,我们通常希望一个集合内的文档具有一致的结构(相同的字段和类型)。为此,我们将使用 Mongoose

Mongoose是一个用于MongoDB的对象数据建模(ODM)JavaScript库。

Mongoose的作用:

  1. 连接数据库:提供代码连接MongoDB集群。
  2. 强制模式:通过定义“模式”和“模型”,来规定集合中文档的结构。
  3. 提供操作方法:提供创建、读取、更新、删除(CRUD)等函数,让我们能方便地与数据库交互。

通过Mongoose,我们可以确保所有“故事”文档都具有 idcreatorcontent 等字段,从而使数据处理更加可控和 predictable。

总结

本节课中我们一起学习了:

  1. 问题:在服务器变量或本地文件中存储数据存在易失性、性能差和可靠性低等问题。
  2. 解决方案:使用数据库进行持久化、高效的数据管理。
  3. 数据库类型:重点了解了文档数据库(如MongoDB)相对于关系型数据库的灵活性优势。
  4. MongoDB结构:理解了文档、集合、数据库和实例的层级概念。
  5. 容错与云服务:通过MongoDB Atlas实现数据冗余和云端存储,提升可靠性。
  6. 模式规范:引入Mongoose库来为MongoDB文档定义和强制执行一致的数据结构。

通过引入数据库,我们为Web应用构建了坚实、可靠且高效的数据存储基础。在接下来的实践中,你将学习如何具体连接和操作MongoDB数据库。

022:第5讲 - 数据库集成 🗄️

在本节课中,我们将学习如何将MongoDB数据库连接到你的Cat App后端。我们将创建数据模型,并修改API端点,以实现用户信息、故事信息和评论信息在数据库中的存储与检索。


连接应用与数据库 🔌

上一节我们介绍了数据库的基本概念,本节中我们来看看如何将你的应用连接到MongoDB实例。

首先,你需要获取你的MongoDB连接字符串。这个字符串通常以 mongodb+srv:// 开头。请将其复制并粘贴到 server.js 文件中的相应位置。

代码示例:

// 在 server.js 文件中
const MONGO_CONNECTION_URL = process.env.MONGO_URL; // 请在此处替换为你的连接字符串

完成此步骤后,运行 npm installnpm start。如果连接成功,你将在终端看到“Server running on port 3000 and connected to MongoDB”的消息。


创建数据模型 📐

现在我们已经建立了数据库连接,接下来需要定义数据的结构。我们将为“故事”和“评论”创建Mongoose模型。

在MongoDB中,我们使用模式(Schema)来定义文档的结构,并使用模型(Model)作为构建文档的构造函数。

以下是创建“故事”模型的步骤,你需要对“评论”模型进行类似操作。

步骤列表:

  1. models/story.js 文件中,导入 mongoose
  2. 使用 new mongoose.Schema() 定义一个模式,指定 creatorNamecontent 字段及其类型(例如 String)。
  3. 使用 mongoose.model() 基于该模式创建一个模型。
  4. 使用 module.exports 导出该模型。

代码示例(story.js):

const mongoose = require('mongoose');

const storySchema = new mongoose.Schema({
  creatorName: String,
  content: String
});

const Story = mongoose.model('Story', storySchema);
module.exports = Story;

对于“评论”模型,你还需要添加一个 parent 字段,用于关联其所属的故事。


修改API端点以使用数据库 🔄

我们已成功创建了数据模型,本节我们将修改后端API,使其能够与数据库进行交互。

首先,需要在 api.js 中导入我们刚刚创建的模型。

代码示例:

const Story = require('./models/story');
const Comment = require('./models/comment');

处理GET请求

对于获取所有故事的端点(GET /stories),我们将使用模型的 .find() 方法从数据库中检索所有文档。

代码示例:

router.get('/stories', (req, res) => {
  Story.find()
    .then(stories => {
      res.send(stories);
    })
    .catch(error => {
      // 处理错误
    });
});

对于获取特定故事评论的端点(GET /comments),我们需要根据查询参数 parent(即故事ID)来过滤评论。

代码示例:

router.get('/comments', (req, res) => {
  Comment.find({ parent: req.query.parent })
    .then(comments => {
      res.send(comments);
    })
    .catch(error => {
      // 处理错误
    });
});

处理POST请求

对于创建新故事的端点(POST /story),我们需要从请求体(req.body)中获取数据,创建一个新的模型实例,然后使用 .save() 方法将其存入数据库。

代码示例:

router.post('/story', (req, res) => {
  const newStory = new Story({
    creatorName: '你的名字', // 可以动态设置
    content: req.body.content
  });
  newStory.save()
    .then(() => {
      res.send({ message: 'Story created!' });
    })
    .catch(error => {
      // 处理错误
    });
});

创建新评论的端点(POST /comment)逻辑类似,但需要额外保存 parent 字段。

代码示例:

router.post('/comment', (req, res) => {
  const newComment = new Comment({
    parent: req.body.parent,
    content: req.body.content
  });
  newComment.save()
    .then(() => {
      res.send({ message: 'Comment created!' });
    })
    .catch(error => {
      // 处理错误
    });
});

完成这些修改后,你的前端应用就可以通过API将数据持久化到MongoDB中,并在需要时从数据库获取数据。


测试与验证 ✅

修改完成后,请务必进行测试。

  1. 运行你的前端(npm run dev)和后端(npm start)服务器。
  2. 尝试在应用中发布新的故事和评论。
  3. 登录你的MongoDB Atlas集群,在对应的数据库集合中,检查是否出现了新创建的“故事”和“评论”文档。这是验证数据是否成功存入数据库的好方法。

总结 📝

本节课中我们一起学习了数据库集成的核心步骤:

  1. 连接数据库:将MongoDB连接字符串配置到后端应用中。
  2. 定义数据模型:使用Mongoose模式与模型为“故事”和“评论”定义数据结构。
  3. 集成后端API:修改GET和POST端点,使用模型方法与数据库进行交互,实现数据的创建与查询。

现在,你的Cat App已经具备了数据持久化能力,所有用户生成的内容都将安全地存储在云端数据库中。

023:第5天 - Promise与async/await 🚀

在本节课中,我们将要学习JavaScript中处理异步操作的两个核心概念:Promise(承诺)和async/await(异步/等待)。我们将通过一个简单的比喻来理解它们的工作原理,并学习如何在代码中有效地使用它们,以提高程序的效率和响应速度。

同步与异步:基本概念 🔄

在深入Promise之前,我们需要理解两个基础术语:同步(Synchronous)和异步(Asynchronous)。

上一节我们介绍了课程概述,本节中我们来看看同步与异步的核心区别。

  • 同步:指一个按顺序发生的过程,即一件事必须在前一件事完成后才能开始。这就像大学选课,你必须先修完6.100A(前提课程),才能选修6.101。
  • 异步:指多个过程可以同时运行。这就像你可以同时选修6.100A和6.101(如果它们没有冲突的前提要求)。

Promise:一个生动的比喻 📦

为了理解Promise,我们引入一个比喻:假设有一位爱猫人士Abby,她想从网上购买猫咪。

以下是Abby下单后可能发生的三种情况:

  1. 履行/解决(Fulfilled/Resolved):订单一切顺利,包裹成功送达。Abby很开心。
  2. 待定(Pending):订单正在处理中,包裹被延迟了。Abby在焦急等待。
  3. 拒绝(Rejected):订单出了问题,包裹丢失了。Abby很伤心。

这个比喻完美地对应了Promise的三种状态。创建一个Promise就像下一个订单,之后它可能进入“履行”、“待定”或“拒绝”状态。

使用 .then.catch 处理Promise

现在,我们将比喻转化为代码。.then 方法用于处理成功的Promise,而 .catch 方法用于处理失败的Promise。

以下是一个使用 .then.catch 处理异步请求的代码示例:

// 模拟一个下单(发起请求)的Promise
placeOrderForCats()
  .then((deliveredCats) => {
    // 如果Promise履行(包裹送达),执行此函数
    hugCats(deliveredCats); // 例如:拥抱送达的猫咪
  })
  .catch((error) => {
    // 如果Promise被拒绝(包裹丢失),执行此函数
    console.log("Oh no, something went wrong:", error);
  });

在上面的代码中,deliveredCats 参数代表从成功Promise中获取的数据(比如猫咪),而 error 参数则包含了失败的原因。

同步 vs 异步:效率对比 ⏱️

让我们从效率角度比较同步和异步。

同步过程中,Abby下单后必须干等着,直到包裹送达,期间不能做饭、讲课或健身。这非常浪费时间。

异步过程中,Abby下单后,包裹会在后台派送。与此同时,她可以自由地去做饭、讲课或健身。这大大提高了效率。

Promise的作用正是如此:它允许我们在等待一个耗时操作(如网络请求)完成的同时,继续执行其他代码,从而避免程序“阻塞”。

异步代码的执行顺序

在代码中,异步操作不会阻塞后续代码的执行。即使一个异步函数写在前面,它后面的代码也可能先执行完毕。

考虑以下代码:

useEffect(() => {
  // 一个耗时的异步函数(蓝色区域)
  slowCatOrder().then((cats) => {
    console.log("Cats delivered:", cats);
  });

  // 后续的同步代码(红色区域)
  console.log("Cooking some food");
  console.log("Lecturing lectures");
  console.log("Getting gains");
}, []);

运行结果很可能是先打印出“Cooking some food”等三条日志,然后才打印“Cats delivered”。这证明了 slowCatOrder() 在后台运行,而其他代码继续执行。

处理多个Promise

当我们需要处理多个Promise时,有几种有用的方法。

假设我们有两个返回数字的异步函数 slowNumber(9)slowNumber(10),每个都需要1秒。直接 console.log(a + b) 会输出 [object Promise][object Promise],因为 ab 是Promise对象本身,而不是它们内部的值。

为了得到数字之和,我们必须等待Promise解决:

slowNumber(9).then((aVal) => {
  slowNumber(10).then((bVal) => {
    console.log(aVal + bVal); // 输出 19
  });
});

但嵌套的 .then 会形成“回调地狱”,代码难以阅读。为此,JavaScript提供了 Promise.allPromise.racePromise.any 等静态方法来更好地处理多个Promise。

以下是这些方法的简要说明:

  • Promise.all([promise1, promise2, ...]):等待所有传入的Promise都解决,或者其中任何一个被拒绝。
  • Promise.race([promise1, promise2, ...]):等待第一个解决或拒绝的Promise,并采用其结果。
  • Promise.any([promise1, promise2, ...]):等待第一个解决的Promise。只有当所有Promise都被拒绝时,它才返回拒绝。

使用 async/await 简化代码 ✨

asyncawait 关键字提供了一种更简洁、更同步化的方式来编写异步代码。

await 会暂停其所在函数的执行,直到后面的Promise解决,并返回结果。但 await 只能在被 async 关键字标记的函数内部使用。

定义一个异步函数很简单:

async function myAsyncFunction() {
  // 可以在这里使用 await
}

让我们比较一下使用 .thenasync/await 的代码。假设我们已经有了两个Promise:promiseApromiseB

使用 async/await 的代码清晰直观:

async function getSum() {
  const a = await promiseA;
  const b = await promiseB;
  console.log(a + b);
}

使用 .then 的代码则有多层嵌套:

promiseA.then((a) => {
  promiseB.then((b) => {
    console.log(a + b);
  });
});

显然,async/await 的写法更接近同步代码的思维模式,易于理解和维护。

在React的 useEffect 中使用 async/await

在React中,useEffect 的回调函数不能直接是 async 函数。我们需要在内部定义一个 async 函数并调用它。

以下是正确的做法:

useEffect(() => {
  // 在 useEffect 内部定义一个 async 函数
  const fetchData = async () => {
    const data = await fetch('/api/stories');
    // ... 处理 data
  };

  // 调用这个 async 函数
  fetchData();
}, []);

何时使用异步函数?

我们主要在以下场景使用异步函数(Promise):

  • 网络请求:如从API获取数据(fetch)。
  • 文件操作:如上传或下载大文件。
  • 定时任务:如 setTimeout

异步编程对于保持Web应用的交互性至关重要。例如,在YouTube页面加载时,视频、评论等数据在后台异步获取,用户无需等待所有内容加载完毕就可以滚动页面或进行其他操作。如果全部采用同步方式,用户就必须等待所有数据加载完成后才能与页面交互,体验会非常糟糕。

总结 📝

本节课中我们一起学习了JavaScript异步编程的核心。

  1. 我们理解了同步(顺序执行)和异步(并发执行)的根本区别。
  2. 我们通过“网购猫咪”的比喻,掌握了Promise的三种状态:待定(Pending)、履行(Fulfilled)和拒绝(Rejected)。
  3. 我们学会了使用 .then() 处理成功Promise,使用 .catch() 处理失败Promise。
  4. 我们探讨了 Promise.allPromise.racePromise.any 来处理多个Promise。
  5. 我们重点学习了 async/await 语法,它用更清晰、更类似同步代码的方式编写异步逻辑,避免了“回调地狱”。
  6. 我们了解了在React的 useEffect 钩子中正确使用 async/await 的方法。
  7. 最后,我们明确了异步编程对于构建高效、响应式用户体验的重要性。

掌握Promise和async/await是现代JavaScript开发的必备技能,它们能帮助你写出更干净、更强大、更易于维护的代码。

024:Git协作指南 🚀

在本节课中,我们将学习如何在团队项目中使用Git进行高效协作。我们将回顾提交和分支的基础知识,深入探讨合并与合并冲突,并详细讲解远程仓库的工作流程。

提交与分支回顾 📝

上一节我们介绍了Git的基本概念,本节中我们来看看如何创建提交和分支。

提交的工作原理

一个提交代表一组代码变更,被打包并贴上标签,通常与某个功能或修复相关。我们可以将其视为代码的版本历史。HEAD指针则指向当前工作目录(例如VS Code中打开的文件)所代表的版本。

创建新提交的步骤如下:

  1. 在文件中进行代码修改。
  2. 使用 git add <文件路径> 将变更添加到暂存区。
  3. 使用 git commit -m "提交信息" 将暂存区的变更打包成一个新的提交,并添加到提交历史中。

每个提交在Git中都是一个对象,拥有一个唯一的ID(长字符串)和一条提交信息。

为什么需要分支?

假设开发者Tony需要同时处理多个任务(功能A、Bug B、功能C)。如果没有分支,他所有的提交都会混杂在一条主线上,难以区分和管理。当需要定位某个Bug的引入点时,排查会非常困难。

分支的作用就是隔离不同任务的开发工作。每个任务可以在独立的分支上进行,完成后,再将稳定的代码合并回主分支。

合并与合并冲突 🤝

上一节我们了解了分支的用途,本节中我们来看看如何将分支合并,以及如何处理合并冲突。

顺利合并

以下是顺利合并的示例流程:

  1. 创建并切换到一个新分支:git checkout -b new-hat
  2. 在新分支上修改代码并提交。
  3. 切换回主分支:git checkout main
  4. 将新分支合并到主分支:git merge new-hat

如果两个分支的修改没有冲突,合并会自动完成。

处理合并冲突

当两个分支对同一文件的同一部分进行了不同的修改时,就会发生合并冲突。执行 git merge 命令后,Git会提示冲突信息。

此时,你需要:

  1. 打开冲突文件,手动选择保留哪个分支的修改(或进行整合)。
  2. 解决冲突后,使用 git add 标记冲突已解决。
  3. 使用 git commit 完成合并提交。

远程协作工作流 🌐

前面我们讨论了本地操作,本节中我们来看看如何与远程仓库(如GitHub)协作。

基础远程工作流

在简单的单分支协作中,基本流程如下:

  1. 拉取:开始工作前,使用 git pull 获取远程最新代码。
  2. 工作:进行修改,使用 git addgit commit
  3. 再次拉取:推送前,再次使用 git pull 确保合并了他人在此期间推送的更改。
  4. 推送:使用 git push 将本地提交推送到远程仓库。

基于分支的协作工作流(推荐)

对于团队项目,更推荐使用基于分支的工作流:

  1. 创建本地分支:基于最新的 main 分支创建功能分支:git checkout -b weblab-tinder
  2. 建立远程关联:首次推送本地分支时,需建立与远程分支的链接:git push --set-upstream origin weblab-tinder
  3. 开发与提交:在分支上进行开发并提交。
  4. 合并到主分支
    • 切换回 main 分支:git checkout main
    • 拉取最新代码:git pull origin main
    • 合并功能分支:git merge weblab-tinder(解决可能出现的冲突)
    • 推送合并结果:git push origin main
  5. 清理分支:合并完成后,可以删除本地和远程的功能分支。

行业实践:Pull Request

在企业环境中,开发者通常没有直接向主分支推送的权限。标准的做法是:

  1. 将功能分支推送到远程。
  2. 在GitHub等平台上创建 Pull Request
  3. 邀请团队成员审查代码。
  4. 审查通过后,由有权限的人在平台上完成合并。
  5. 本地拉取最新的 main 分支,并删除已合并的本地功能分支。

实用工具:Git Stash 💾

当你修改了文件但尚未提交,又需要切换分支时,Git会阻止你,以免未保存的更改丢失。这时可以使用 git stash

Git Stash 工作流

  1. 储藏更改git stash 将未提交的修改临时保存起来。
  2. 自由切换:现在可以安全地切换到其他分支(如 git checkout main)。
  3. 恢复更改:切换回来后,使用 git stash pop 将储藏的修改重新应用到当前工作区。

总结与资源 📚

本节课中我们一起学习了Git团队协作的核心知识。我们回顾了提交与分支,探讨了合并冲突的解决方法,详细讲解了本地与远程仓库的协作流程,并介绍了 git stash 这个实用工具。

对于初学者,以下资源很有帮助:

  • Learn Git Branching:一个游戏化的Git学习网站。
  • Git Cheat Sheet:命令速查表。
  • Stack Overflow:遇到具体问题时搜索解决方案的好地方。

记住,协作的关键是频繁沟通遵循团队约定的工作流程。祝你在项目开发中协作顺利!

025:高级React(Context与Router)🚀

在本节课中,我们将学习React中两个强大的功能:Context APIReact Router。我们将了解如何使用Context来简化组件间的状态传递,以及如何利用Router构建具有多个页面的单页应用。


状态管理回顾

上一节我们介绍了React的基础状态管理。本节中我们来看看当状态需要在组件树中深层传递时可能遇到的问题。

React中管理状态的主要工具是 useState Hook。其基本用法如下:

import { useState } from 'react';

function ParentComponent() {
  const [name, setName] = useState('初始值');
  // ... 其他逻辑
}

如果我们需要在子组件中使用这个状态,通常的做法是将其作为属性(prop)传递下去:

<ChildComponent name={name} />

然而,当组件树变得很深时,这种“逐层传递”的方式会变得繁琐且难以维护。


引入Context API 🧩

为了解决上述问题,React提供了Context API。它允许父组件向其下所有子组件“广播”数据,而无需显式地通过每一层传递props。

你可以将Context想象成一个共享的“信息簿”,任何在“图书馆”(Provider)内的组件都可以查阅它。

以下是使用Context的基本步骤:

  1. 创建Context:使用 createContext 函数创建一个Context对象。
  2. 提供Context:在父组件中使用Context的 Provider 组件包裹其子组件,并通过 value 属性提供数据。
  3. 消费Context:在任何子组件中使用 useContext Hook来获取Context中的数据。

代码示例对比

不使用Context(Prop Drilling):

// Component1.jsx
function Component1() {
  const [user, setUser] = useState('Daniel');
  return <Component2 user={user} />;
}
// Component2.jsx -> Component3.jsx -> Component4.jsx 需要逐层传递 `user` prop

使用Context:

// 1. 创建Context (例如在 userContext.js 文件中)
import { createContext } from 'react';
export const UserContext = createContext();

// 2. 在顶层组件提供Context
import { UserContext } from './userContext';
function Component1() {
  const [user, setUser] = useState('Daniel');
  return (
    <UserContext.Provider value={{ user, setUser }}>
      <Component2 /> {/* 不再需要传递user prop */}
    </UserContext.Provider>
  );
}

// 3. 在深层子组件消费Context
import { useContext } from 'react';
import { UserContext } from './userContext';
function Component4() {
  const { user } = useContext(UserContext); // 直接获取user
  return <div>Hello, {user}!</div>;
}

通过使用Context,Component2Component3 等中间组件无需关心 user 状态,代码变得更加清晰。


深入React Router 🌳

上一节我们介绍了Context来管理状态,本节中我们来看看如何使用React Router来管理应用的不同视图(页面)。

React Router是一个库,它帮助我们在单页应用(SPA)中模拟多页面的体验,根据URL的变化渲染不同的组件。

路由基础结构

一个典型的React Router设置包含以下部分:

import { createBrowserRouter, RouterProvider } from 'react-router-dom';

// 定义路由配置
const router = createBrowserRouter([
  {
    path: "/",
    element: <App />, // 根布局组件
    children: [ // 子路由
      { path: "feed", element: <Feed /> },
      { path: "profile", element: <Profile /> },
    ],
    errorElement: <NotFound />, // 404页面
  },
]);

// 渲染路由
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

RouterProvider 本身就是一个Context Provider,它向整个应用提供路由信息。

路由嵌套与Outlet

<Outlet /> 是一个占位符组件,用于在父路由组件中渲染其子路由。

例如,在 App 组件中:

function App() {
  return (
    <div>
      <NavBar /> {/* 导航栏,在所有子页面都会显示 */}
      <Outlet /> {/* 此处会根据URL渲染 Feed 或 Profile */}
    </div>
  );
}
  • 访问 /feed 时,<Outlet /> 渲染 <Feed />
  • 访问 /profile 时,<Outlet /> 渲染 <Profile />
    这样,<NavBar /> 就成为了一个共享的布局。

向子路由传递数据

如果父路由需要向所有子路由传递数据,可以使用 useOutletContext

在父路由(App)中提供上下文:

<Outlet context={{ myTestProp: “来自App的数据” }} />

在子路由组件(如Feed)中获取:

import { useOutletContext } from 'react-router-dom';
function Feed() {
  const { myTestProp } = useOutletContext();
  console.log(myTestProp); // 输出:“来自App的数据”
}

动态路由与参数

我们经常需要根据URL中的一部分(如用户ID)来渲染内容。React Router支持动态路由参数。

定义动态路由:
在路径中使用冒号 (:) 前缀来定义参数。

{
  path: "profile/:username", // :username 是动态参数
  element: <ProfilePage />,
}

在组件中获取参数:
使用 useParams Hook。

import { useParams } from 'react-router-dom';
function ProfilePage() {
  const { username } = useParams(); // 获取URL中的 `username` 值
  return <h1>{username}的个人主页</h1>;
}

访问 /profile/alice,页面将显示 “alice的个人主页”。


总结与资源 📚

本节课中我们一起学习了:

  1. React Context:用于跨组件树共享状态的机制,避免了“Prop Drilling”,通过 createContext, ProvideruseContext 来使用。
  2. React Router:用于构建单页应用路由的库。我们学习了如何设置路由 (createBrowserRouter)、使用嵌套路由和 <Outlet />、以及如何通过动态路由参数 (useParams) 和上下文 (useOutletContext) 传递数据。

这些工具能极大地提升前端项目的开发效率和代码可维护性。要深入了解,请查阅官方文档:

当你构建项目时,善用官方文档和社区资源(如Stack Overflow)是解决问题的关键。

026:认证与授权 🔐

在本节课中,我们将要学习Web开发中至关重要的两个概念:认证授权。我们将探讨它们之间的区别,并深入了解如何安全地管理用户登录状态,特别是如何利用第三方服务(如Google)来实现认证,以及如何使用会话和令牌来维持登录状态。


认证:证明“我是谁”

上一节我们介绍了课程概述,本节中我们来看看认证。认证是验证用户身份的过程。例如,在Facebook或我们的Catbook应用中,我们需要知道当前登录的用户是谁,以及哪些故事和评论属于该用户。

当客户端向服务器的登录API端点发送POST请求时,服务器需要在MongoDB等数据库中查找用户信息,以核对用户提供的凭据(如用户名和密码)是否正确。

一种存储用户信息的方式是直接在数据库中创建一个用户模式(Schema)。模式可以强制规定文档的结构。例如,我们可以创建一个包含nameemailpassword字段的用户模式,并将它们作为字符串存储。

代码示例:一个简单的用户模式

const userSchema = new mongoose.Schema({
  name: String,
  email: String,
  password: String // 直接存储密码
});

然而,直接以明文存储密码是极其危险的。如果数据库泄露,黑客将轻易获得所有用户的密码。考虑到用户可能在多个平台使用相同密码,这将造成严重后果。


提升密码存储的安全性

为了解决明文存储密码的问题,我们引入了哈希函数。哈希函数接收一个字符串(如密码),通过数学函数生成一个加密的字符串输出。

公式:哈希函数

hash_output = hash_function(password_input)

哈希函数具有以下特点:

  • 单向性:无法从输出哈希值反推出原始输入。
  • 确定性:相同的输入总是产生相同的输出哈希值。

这似乎更安全了,因为数据库中存储的是加密后的哈希值,而非明文密码。但黑客可以使用“彩虹表”来反向查找常见密码对应的哈希值,因此单纯哈希仍不够安全。

为了进一步增强安全性,我们使用加盐哈希。盐是一段随机生成的数据,在哈希计算前将其与密码组合。

公式:加盐哈希

salted_hash_output = hash_function(password_input + random_salt)

加盐使得每个密码的哈希值都独一无二,即使密码相同,哈希值也不同,从而有效抵御彩虹表攻击。然而,如果攻击者可以无限次尝试密码(即没有速率限制),理论上仍有可能通过暴力破解找到密码。

综上所述,自行安全地存储和管理密码非常复杂。因此,许多现代应用选择使用成熟的第三方认证服务,如Google、Facebook或MIT的Okta。我们将利用Google的认证服务来简化这一过程。


授权:决定“我能做什么”

在用户通过认证,证明了自己的身份之后,下一步就是授权。授权是验证用户权限的过程,并根据用户的角色或权限有条件地展示不同的内容。

例如,在Piazza或Catbook中,讲师和学生拥有不同的权限。讲师可以发布公告,而学生则不能。因此,讲师的控制面板应该与学生不同。


信任问题:客户端不可信

现在,我们面临一个核心问题:如何在不信任客户端的情况下,安全地管理认证和授权状态?

考虑以下场景:多个用户同时向服务器发送请求。服务器如何区分是Sophie还是Aranddo在请求获取他们自己的故事?

以下是两种不可行的方案:

  1. 使用查询参数:在GET请求的URL中包含用户名(如 ?username=Sophie)。用户可以直接修改URL中的参数来冒充他人。
  2. 使用IP地址:通过发送请求的IP地址来区分用户。但IP地址很容易被黑客伪造(IP欺骗)。

结论是:我们永远不能完全信任客户端。无论是恶意用户伪造查询参数,还是欺骗IP地址,如果服务器盲目信任客户端提供的信息,就无法确保安全。

因此,我们需要一种机制,让服务器能够可靠地识别用户身份,而无需依赖客户端提供的易篡改信息。


解决方案:会话与令牌

为了解决上述信任问题,我们引入了会话令牌两种机制。

会话

会话的工作流程如下:

  1. 用户登录时,将凭据发送给服务器。
  2. 服务器验证凭据后,在服务器端创建一个全局会话查找表,表中存储用户信息(如用户名、用户ID)和一个为其唯一生成的会话ID
  3. 服务器在响应中将会话ID发送给客户端。
  4. 客户端在后续请求中,通过Cookie的形式将这个会话ID发回给服务器。
  5. 服务器收到Cookie中的会话ID后,在查找表中找到对应的用户信息,从而处理该用户的请求。

会话的关键点在于:所有敏感的用户信息都安全地存储在服务器端。 客户端只持有一个由服务器生成的、难以伪造的会话ID。这解决了客户端直接发送易篡改信息(如用户名)的问题。

然而,会话也存在一个缺点:可扩展性。当应用需要运行多个服务器时,每个服务器都需要维护自己的会话查找表,同步这些会话数据会变得复杂。

令牌

令牌(如JWT - JSON Web Token)提供了另一种解决方案,并改善了可扩展性问题。

令牌的工作流程如下:

  1. 用户登录,发送凭据给服务器。
  2. 服务器验证凭据后,创建一个加密的JSON Web Token对象返回给客户端。这个令牌本身包含了用户的身份信息(如用户ID、角色),但经过了签名加密。
  3. 客户端将令牌存储在本地(如LocalStorage)。
  4. 在后续请求中,客户端在请求头(如 Authorization 头)中携带此令牌。
  5. 服务器收到令牌后,使用密钥验证其签名。如果签名有效,则信任令牌中的用户信息,并据此处理请求。

令牌的关键点在于:用户信息被加密并包含在令牌本身中,由客户端存储和发送。 服务器无需在本地存储会话状态,只需验证令牌的签名即可。这使得它在多服务器环境下具有很好的可扩展性。

会话与令牌对比:

特性 会话 令牌
信息存储位置 服务器端(会话查找表) 客户端(令牌本身)
客户端发送什么 会话ID(通过Cookie) 完整的令牌(通过请求头)
服务器如何验证 用会话ID查找服务器端存储的信息 解密并验证令牌的签名
服务器端安全操作 可以,因为信息在服务器端 受限,因为信息在客户端
可扩展性 多服务器时较复杂 多服务器时很简单

Catbook的认证流程实践

在Catbook项目中,我们结合使用了第三方认证(Google OAuth)、令牌和会话来管理登录。

以下是Catbook的登录流程:

  1. 前端:用户点击“使用Google登录”,被重定向到Google的认证页面。
  2. Google OAuth服务器:用户输入Google账号密码完成认证。认证成功后,Google生成一个安全的JWT令牌,并将其返回给Catbook前端。
  3. 前端 → Catbook服务器:前端将收到的Google令牌发送到Catbook服务器的 /api/login 端点。
  4. Catbook服务器验证:服务器使用Google OAuth库验证该令牌的签名,确认用户确实已通过Google认证。
  5. 服务器创建会话:令牌验证通过后,服务器在MongoDB中创建或查找对应用户记录,并在服务器端为该用户创建一个会话
  6. 服务器设置Cookie:服务器生成一个唯一的会话ID,并通过响应头中的 Set-Cookie 指令,将其设置为客户端的Cookie(例如 connect.sid)。
  7. 维持登录状态:此后,用户每次向Catbook服务器发送请求,浏览器都会自动携带这个Cookie。服务器通过Cookie中的会话ID找到对应的会话,从而知道是哪个用户发出的请求,并执行相应的授权逻辑(如显示该用户的故事)。
  8. 登出:当用户点击登出,或手动删除浏览器中的Cookie后,由于后续请求无法提供有效的会话ID,服务器将无法识别用户,从而实现登出。

关键安全点:我们不信任前端声称的“我已通过Google登录”。我们只信任Google签名的令牌,因为客户端无法伪造一个有效的、带有正确签名的JWT令牌。


总结

本节课中我们一起学习了Web开发中认证与授权的核心概念。

  • 认证是验证用户身份(“你是谁”),我们利用Google OAuth服务来安全地完成这一过程。
  • 授权是验证用户权限(“你能做什么”),根据用户角色展示不同内容。
  • 由于客户端不可信,我们不能依赖其提供的易篡改信息(如URL参数、IP地址)。
  • 会话将用户信息存储在服务器端,通过Cookie传递会话ID来识别用户,但在多服务器环境下可扩展性有挑战。
  • 令牌将加密的用户信息存储在客户端,服务器通过验证签名来信任令牌,具有良好的可扩展性。
  • 在Catbook中,我们结合了二者的优点:使用Google的令牌进行初始认证,然后在服务器端使用会话和Cookie来维持用户的登录状态,并管理授权。

通过理解这些机制,你可以为自己的Web应用构建安全可靠的用户身份验证和权限管理系统。

027:账户与认证

在本节课中,我们将实现完整的Google登录流程,并对前端和后端进行修改以支持登录功能。这是一个内容丰富的实践环节,我们将一步步共同完成。

概述

我们将从实现基本的登录/登出按钮开始,然后逐步构建用户数据存储、用户认证、动态用户资料页面,并最终使用React Context来全局管理用户状态,以优化应用性能。

6.1:实现前端登录状态

上一节我们了解了工作坊的整体目标,本节中我们来看看如何在前端实现登录状态的跟踪。

首先,我们需要在导航栏组件中创建一个React状态来跟踪用户是否已登录。

navbar.jsx 文件中,使用 useState 钩子创建一个名为 loggedIn 的状态,初始值设为 false

const [loggedIn, setLoggedIn] = useState(false);

现在,当Google登录组件成功返回时,前端应知道用户已登录。它会调用 handleLogin 函数。我们需要在这个函数中更新状态。

const handleLogin = (response) => {
    console.log(response);
    setLoggedIn(true);
};

对于登出功能,Google的库没有提供现成的登出按钮。我们将使用一个普通的按钮组件,并为其定义 handleLogout 函数。

以下是需要添加的登出按钮和函数:

<button onClick={handleLogout}>Log Out</button>

const handleLogout = () => {
    console.log("Logged out");
    setLoggedIn(false);
};

接下来,我们需要根据 loggedIn 状态来条件性地渲染登录或登出按钮。

以下是条件渲染的模板:

{loggedIn ? (
    <button onClick={handleLogout}>Log Out</button>
) : (
    <GoogleLogin
        onSuccess={handleLogin}
        // ... 其他属性
    />
)}

至此,我们实现了根据前端状态显示不同按钮的功能。当用户登录后,应能看到登出按钮;未登录时,则看到登录按钮。

6.2:创建用户数据模型

现在用户可以进行登录,让我们来存储使用Catbook的用户数据。思考一下如何实现。

我们将在MongoDB数据库中添加一个用户集合来跟踪所有用户。

正如在数据库课程中介绍的,我们需要在后端定义用户模型和模式。在 server/models 文件夹中,新建一个 user.js 文件。

你可以从 story.js 复制模板,然后修改名称和集合。对于这个模式,我们需要两个字段:name(字符串类型)和 googleId(字符串类型)。

以下是 user.js 文件的内容:

const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema({
    name: String,
    googleId: String
});

const UserModel = mongoose.model('user', UserSchema);
module.exports = UserModel;

这样,我们就创建了一个遵循该模式的结构化用户集合。

6.3:连接前后端认证

上一节我们创建了用户模型,本节中我们来看看如何将前端的登录令牌传递到后端进行验证。

首先,后端已经使用会话来保持认证状态。我们还有一个由Web Lab工作人员编写的 auth.js 库,其中提供了 authLoginauthLogout 函数。

  • authLogin 函数:接收前端发送的令牌,向Google验证其有效性。如果有效,则为首次登录的用户创建新用户记录,并在会话中设置用户信息。
  • authLogout 函数:将 req.session.user 设置为 null,从而清除会话中的用户信息。

现在,我们需要将前端获取的令牌发送到后端。为此,我们将在服务器上创建两个新的POST端点:/api/login/api/logout

api.js 中添加以下路由:

router.post('/login', authLogin);
router.post('/logout', authLogout);

接下来,在前端的 navbar.jsx 中,修改 handleLoginhandleLogout 函数来调用这些API端点。

handleLogin 中,从Google的响应中获取令牌,然后将其发送到后端:

const handleLogin = async (response) => {
    const userToken = response.credential;
    await post('/api/login', { token: userToken });
    // 后续会更新状态
};

handleLogout 中,只需调用登出端点:

const handleLogout = async () => {
    await post('/api/logout');
    // 后续会更新状态
};

现在,我们的登录流程就完整了:前端通过Google界面登录,获取令牌,然后通过POST请求将令牌发送到后端进行验证和会话持久化。点击登出则会清除会话中的用户信息。

6.4:关联故事和评论与用户

现在我们已经可以登录,但发布故事和评论时仍然显示“匿名用户”。我们希望显示实际登录用户的名称。

我们需要跟踪所有故事和评论的创建者。为此,我们将在故事和评论的模式中添加一个 creatorId 字段。

修改 story.jscomment.js 中的模式,添加 creatorId: String 字段。

接着,更新 api.js 中的POST路由。当创建新故事或评论时,我们不再传递“匿名用户”,而是传递实际用户的名称和ID。

在发布故事的路由中,进行如下修改:

const newStory = new StoryModel({
    creator_name: req.user.name, // 使用登录用户的名字
    creator_id: req.user._id,    // 使用登录用户的ID
    content: req.body.content
});

对发布评论的路由进行类似的修改。

完成这些更改后,新发布的故事和评论就应该显示发布者的真实姓名了。

6.5:实现动态用户资料页

现在,每个故事和评论都显示了用户名。我们希望点击这些名字时,能跳转到相应用户的资料页面。

首先,我们需要创建一个唯一的URL,格式为 /profile/:userId。这可以通过在React Router的路径中传递 userId 参数来实现。

index.jsx 中,将个人资料页的路由从静态路径 /profile 改为动态路径 /profile/:userId

<Route path="/profile/:userId" element={<Profile />} />

现在,我们需要在点击故事中的用户名时,导航到 /profile/creatorIdcreatorId 信息来自 feed.jsx,它通过API请求从后端获取故事数据,其中包含了新的 creatorId 字段。

我们需要将 creatorId 作为属性(prop)从 Feed 组件传递到 Card 组件,再传递到 SingleStory 组件。

SingleStory 组件中,将显示用户名的 <span> 标签替换为 Link 组件:

<Link to={`/profile/${props.creatorId}`}>{props.creator_name}</Link>

这样,点击用户名就会跳转到动态生成的用户资料页面。

6.6:在资料页获取并显示用户信息

点击链接后,我们进入了用户资料页,但页面显示的名字是错误的。我们需要在资料页组件中获取并显示对应用户的名称。

Profile.jsx 组件中,我们可以通过 useParams 钩子获取URL中的 userId 参数。

为了获取用户信息,我们需要在后端创建一个新的API端点。这个端点将接收 userId,使用Mongoose查询在数据库中查找对应用户,并将用户信息返回给前端。

api.js 中添加以下GET端点:

router.get('/user', async (req, res) => {
    const user = await UserModel.findById(req.query.userId);
    res.send(user);
});

现在,在前端的 Profile.jsx 组件中,我们可以使用 useEffect 钩子来调用这个API端点,获取用户数据并保存到状态中。

const [user, setUser] = useState(null);
const { userId } = useParams();

useEffect(() => {
    const fetchUser = async () => {
        const userData = await get(`/api/user?userId=${userId}`);
        setUser(userData);
    };
    fetchUser();
}, [userId]);

为了避免在用户数据加载完成前出现错误,我们可以进行条件渲染:

if (!user) {
    return <div>Loading...</div>;
}
return (
    <div>
        <h1>{user.name}'s Profile</h1>
        {/* ... 其他内容 */}
    </div>
);

现在,点击用户名就会跳转到显示正确用户名的个人资料页面了。

6.7:更新导航栏与登录状态持久化

你可能注意到,导航栏上的“Profile”链接现在失效了,因为我们将其改为了动态路由 /profile/:userId。我们希望这个链接能指向当前登录用户的个人资料页。

为此,我们需要在导航栏中维护一个 userId 状态,并用它来替换之前的 loggedIn 状态。

navbar.jsx 中,将 loggedIn 状态替换为 userId 状态,初始值为 null。然后更新 handleLoginhandleLogout 函数,分别设置和清除 userId

const [userId, setUserId] = useState(null);

const handleLogin = async (response) => {
    const userToken = response.credential;
    const user = await post('/api/login', { token: userToken });
    setUserId(user._id); // 假设后端返回用户对象
};

const handleLogout = async () => {
    await post('/api/logout');
    setUserId(null);
};

接着,将导航栏中“Profile”链接的 to 属性从 /profile 改为 /profile/${userId}

现在,登录后点击导航栏的“Profile”链接,就会跳转到自己的资料页。

但是,如果刷新页面,userId 状态会重置,用户会显示为登出状态。这是因为 userId 只存储在前端状态中。然而,后端已经在会话中保存了用户信息。

为了解决这个问题,我们需要在导航栏加载时,询问后端当前用户是否仍然登录。我们将在后端创建一个名为 /api/whoami 的GET端点。

api.js 中添加:

router.get('/whoami', (req, res) => {
    if (req.user) {
        res.send(req.user);
    } else {
        res.send({});
    }
});

然后,在 navbar.jsxuseEffect 钩子中调用这个端点,如果返回用户信息,则设置 userId 状态。

useEffect(() => {
    const checkLogin = async () => {
        const user = await get('/api/whoami');
        if (user._id) {
            setUserId(user._id);
        }
    };
    checkLogin();
}, []);

最后,根据 userId 是否存在,条件性地渲染“Profile”链接。

这样,登录状态在页面刷新后也能得以保持。

6.8:使用Context优化状态管理

目前,我们在多个组件(如 FeedCommentsBlock)中都需要知道用户是否登录,以便决定是否显示发布故事和评论的输入框。如果每个组件都调用 /api/whoami,会产生大量不必要的网络请求。

一个更好的解决方案是使用React Context进行全局状态管理。我们将只在应用的根组件(App)中调用一次 /api/whoami,然后将 userId 存储在Context中,所有子组件都可以访问它。

首先,创建一个Context文件,例如 UserContext.jsx

import { createContext } from 'react';
export const UserContext = createContext(null);

然后,在 App.jsx 中:

  1. userId 状态、whoami 请求、handleLoginhandleLogout 函数从 navbar.jsx 移动到 App.jsx
  2. handleLoginhandleLogout 作为属性传递给 Navbar 组件。
  3. 使用 UserContext.Provider 包裹应用的主要部分,并将 userId 作为值提供。
<UserContext.Provider value={userId}>
    <Navbar handleLogin={handleLogin} handleLogout={handleLogout} />
    <Outlet />
</UserContext.Provider>

Navbar.jsx 和其他需要 userId 的组件(如 Feed.jsxCommentsBlock.jsx)中,使用 useContext 钩子来消费这个Context值。

const userId = useContext(UserContext);

现在,我们可以在 FeedCommentsBlock 中根据 userId 是否存在,条件性地渲染输入框组件。

{userId && <NewStory />}
{userId && <NewComment />}

这样,我们只发起了一次API请求,并通过Context高效地管理了全局用户状态,避免了属性钻取(prop drilling)和重复请求。

总结

本节课中,我们一起完成了Catbook应用的账户与认证系统。我们从实现前端登录状态开始,逐步构建了后端用户模型、认证API、动态用户资料页,并最终使用React Context优化了全局状态管理。现在,用户可以使用Google登录,其登录状态得以持久化,发布的内容会关联用户信息,并且界面会根据登录状态动态显示相关功能。

028:课程闭幕式与项目展示 🎉

在本节课中,我们将回顾MIT Web.lab (6.962) 2025年课程的闭幕式。我们将了解课程的整体情况、学生们的学习成果,并欣赏在最终竞赛中脱颖而出的优秀项目展示。


欢迎来到Web.lab 2025闭幕式。这标志着超过200名学生历时一个多月、致力于开发网络应用的学习旅程圆满结束。所有课程工作人员也在此过程中收获颇丰。

对于之前不了解Web.lab的同学,以下是课程简介:我们在两周时间内,教授全栈Web开发所需的所有前端与后端知识。在IAP(独立活动期)的前两周,我们每天提供免费午餐。课程结束后,我们会举办一场设有多个现金奖项的竞赛。本课程的所有免费资源及奖金总额超过2万美元。此外,学生可以轻松获得课程学分,这是一个标准的MIT课程,提供6个学分,采用通过/不通过(P/F)的评分制。

课程数据与成果 📊

上一节我们介绍了课程的基本情况,本节中我们来看看课程期间的具体数据和学生们的最终成果。

对于所有Web.lab的学员和工作人员来说,这都是一段漫长的旅程。课程门户注册学生超过370人,进行了25天的教学与网站开发,安排了多次办公时间和黑客松。以下是课程期间的一些关键数据:

  • Piazza上共有155个公开和私密帖子。
  • 在办公时间和黑客松期间,我们解决了386个帮助队列工单。
  • Piazza上的平均响应时间为10分钟,帮助队列的平均响应时间为4分钟。
  • 提供的办公时间(包括黑客松)总计超过48小时,此外还有许多工作人员在9点后自愿留下帮助团队。

总而言之,60支优秀的团队创建了60个出色的网络应用。今天,我们将看到这些团队中最顶尖的作品,有10支半决赛队伍将展示他们的网站。希望这些精彩的网站能激励你也开始学习Web开发。

如果你想查看所有项目,可以访问 weblab.is/webby,该链接指向我们的Instagram账户。

学生所学技术栈 🛠️

学生们在课程中需要学习大量知识。关于Web.lab的一个常见误解是它只涉及前端,但事实并非如此。尽管我们从HTML、CSS和JavaScript等前端技术开始,但学生们很快会深入学习React JS这一强大的前端框架。随后,他们将进入后端,学习使用Node和Express服务器,使用MongoDB将网站连接到数据库,并使用Socket实现实时连接,此外还包括课程中学习的许多其他内容。

项目开发花絮与致谢 🙏

作为传统,我们收集了一些团队在项目开发过程中提交的“有趣”的Git提交记录。

我们看到了许多表示成功的提交,当然也有一些令人沮丧的提交,甚至还有一些完全令人困惑的提交。如果展示的内容涉及你的团队并让你感到不快,我们表示歉意。但无论如何,大家都成功完成了项目。我们衷心感谢所有让Web.lab成为可能的人们。

首先,要特别感谢Ellen Reed, Li Bella, Arvin Saya Or Ryan以及整个EECS和Course 6团队。请为出色的课程工作人员们送上热烈的掌声,没有他们,这一切都不可能实现。

此外,我们还有许多赞助商和支持者,他们也为本课程提供了重要支持。

赞助商介绍

以下是部分赞助商的介绍:

  • Fetch AI: 一位AI工程师介绍了Fetch AI,这是一个开发智能体间通信协议的平台,拥有自己的UAG框架和名为“Agent World”的平台。他们还提供了实习项目和加速器计划。
  • Akamai: 校园招聘官介绍了Akamai公司。该公司最初由一位MIT教授和学生共同创立,专注于内容分发网络服务,现已发展成为涵盖网络安全和云计算服务的全球性公司。他们提供了全职和实习机会。
  • Codedium: 一位MIT大四学生介绍了Codedium公司,由两位MIT 2018届毕业生创立。其旗舰产品Windsurf AI是一个智能IDE,能显著提升编码速度。他们为Web.lab学生提供了免费使用码:MITIAPWeblab2025,可兑换一个月免费使用权限,同时也在招聘实习生和全职员工。
  • Vercel: 特别感谢Vercel作为技术赞助商为学生提供托管服务。Vercel团队与Web.lab创建了共享Slack频道,供学生展示作品、咨询Next.js或Vercel产品路线图问题,这也是未来实习和全职机会的交流平台。感兴趣的同学可以填写 weblab.is/vercel 的表单加入频道,也可在 weblab.is/resumedrop 提交简历。

半决赛项目展示 🚀

现在,是半决赛队伍展示他们项目的时间了。我们有10支半决赛队伍,以下是部分项目的演示摘要。

Chain Reaction

这是一个测试你对谷歌上最流行短语直觉的文字游戏。玩家根据给定的第一个词和下一个词的首字母,输入能构成最热门搜索词组的词来获得分数。游戏包含单人和多人模式,多人模式下有实时积分榜。

Chill Deck

这是一个旨在让DJ变得触手可及的网络应用。用户可以在个人资料页面进行个性化设置,帮助页面提供了详细的使用教程。核心功能包括导入歌曲、音频可视化、调整BPM(速度)、控制转盘和音量、精准定位音频,以及将音轨分离为鼓点、旋律和人声等。

Battlelingo

这是一个外语打字对战游戏。玩家通过快速准确地输入外语单词来施放“冻结”、“攻击”、“治疗”等法术,与朋友或AI进行对战。游戏包含玩家对战(PVP)和AI对战模式,并有个人数据统计和排行榜。

Route Setter

这是一个关于攀岩路线设计的工具。用户可以在3D编辑器中将岩点放置到墙上,规划攀岩路线。工具支持移动岩点、更改颜色、设置起点/终点、调整墙面角度等功能,并能保存和重新打开设计的路线。

Letter Grove

这是一个收集字母和水果来组词得分的文字游戏。玩家从金色格子出发,通过输入单词在棋盘上移动,收集路径上的水果和字母来得分。游戏提供“相同棋盘”(多人互动)和“不同棋盘”(各自为战)两种模式,并支持实时棋盘状态同步。

Table Talk

这是一个旨在通过共同烹饪和分享食物来连接人们的平台。功能包括:在地图页面查看附近的活动;在动态页面筛选和查看活动详情;创建或加入小组;管理个人资料和饮食偏好;使用AI生成或编辑食谱。

Diffuse and Deduce

这是一个受AI扩散模型启发的猜图游戏。玩家需要在一张逐渐从噪点中变得清晰的图片完全显现前,尽快猜出它是什么。游戏支持单人和多人模式,提供多种主题、回合设置,甚至包含“ sabotage”(干扰对手)功能。

Dreamscape

这是一个记录、分享和探索梦境的平台。旨在解决人们容易忘记或难以分享梦境的问题。通过这个平台,用户可以保存自己的梦境,并与朋友交流。


本节课中我们一起学习了MIT Web.lab 2025课程的闭幕盛况。我们回顾了课程的规模、学生掌握的全栈技术,并领略了10支优秀半决赛团队开发的创新网络应用,涵盖游戏、工具、社交平台等多种类型。这些项目充分展示了在短时间内学习并应用Web开发技术的巨大潜力。希望本课程和这些展示能激发你开始自己的Web开发之旅。

posted @ 2026-03-29 09:22  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报