UNSW-COMP6080-前端笔记-全-

UNSW COMP6080 前端笔记(全)

001:课程概述 🚀

在本节课中,我们将一起了解COMP6080前端编程课程的整体结构、学习目标、评估方式以及如何开始你的第一个作业。课程将涵盖HTML、CSS和JavaScript的核心知识,并带你了解现代前端开发的演变。


课程介绍与目标

首先,感谢大家的耐心等待。课程开始时总会遇到一些技术问题,但我们会尽力解决。

关于线下讲座的安排,这取决于大家的参与意愿。我们在第一周和第七周曾举办过线下讲座,但参与人数差异很大。我们可能会在下周继续尝试线下讲座,并根据情况决定后续安排。不过,线上教学对于展示代码等内容通常效果很好。

我的名字是Hayden。如果你已经访问过课程网站,说明你已经对课程有所了解。如果你只是按照课表前来,也欢迎你加入。

今天的讲座内容比较轻松,主要是介绍课程结构、评估方式以及本学期的安排。我们不会深入讲解具体的HTML知识,而是进行一些高层次的讨论。


为什么学习前端开发?

为什么选择学习COMP6080?这是一个选修课。原因可能多种多样:有人想成为前端开发者,有人对前端感兴趣,有人想为个人项目编码,也有人是因为其他选修课已满而选择了这门课。

学习前端开发有很强的行业相关性。如今,大量的代码是用JavaScript编写的。2018年的数据显示,四分之一的招聘岗位要求使用JavaScript构建前端。前端应用越来越普遍,因此掌握这些技能在当前工业界极具价值。

在10周内,我希望大家能自信地快速构建自己的Web应用,并尽可能保证其正确性和稳定性。课程结束后,你应该能够将脑海中的界面构思变为现实。


什么是前端开发者?

前端开发者编写在客户端执行的代码,即在Google Chrome、Safari、Firefox等浏览器中运行的代码。这与在命令行或服务器上运行的后端代码不同。

前端开发主要涉及三种语言:HTMLCSSJavaScript

  • HTML 负责网页的结构和内容。
  • CSS 负责网页的样式和外观。
  • JavaScript 负责网页的交互和动态行为。

总结来说,前端开发者就是能够综合运用这三种语言,在浏览器中创建美观页面的人。这与算法课程(如COMP2521)不同,前端开发更注重构建供人使用的、具有交互性的产品,例如Canva这样的应用,非常注重用户体验和产品设计。


Web技术的发展简史

Web技术是计算机领域发展最快的部分之一。让我们简单回顾一下它的演变:

  1. 早期Web(文档共享):HTML最初被设计用于在网络上共享文档,类似于PDF,但支持超链接。早期的网页很像现在的维基百科,主要由文本、图片、列表和链接构成。
  2. 引入样式(CSS):为了让网页更美观,CSS应运而生。它负责颜色、间距等样式,让网页变得生动起来。
  3. 增加交互性(JavaScript):JavaScript使网页能够动态响应。最初,它只能实现简单的功能,如显示/隐藏元素或弹出提示框。
  4. Ajax革命:Ajax技术允许JavaScript在后台与服务器通信并交换数据,而无需刷新整个页面。这是Web发展的一个关键转折点,使得Gmail、Facebook等应用能够实时更新内容,网页开始变得像原生应用一样强大。
  5. 工业化与全栈化
    • Node.js:允许JavaScript在服务器端运行,使得开发者可以用同一种语言开发前后端。
    • TypeScript:为JavaScript添加了类型系统,提高了大型项目的代码可靠性和可维护性。这两项技术极大地推动了Web技术的工业化应用。
  6. 混合应用(Hybrid Apps):像Electron这样的技术,允许将网站打包成桌面应用(如Slack、Discord、Microsoft Teams)。这意味着开发者只需编写一次代码(一个网站),就可以部署到Web、Windows、macOS等多个平台。
  7. 声明式框架:如React、Vue.js、Angular等框架的出现,极大地简化了复杂用户界面的开发,提高了开发效率。

所有这些技术的发展,使得HTML、CSS和JavaScript及其衍生技术成为构建绝大多数面向用户的应用的最主流方式。


课程结构与学习方法

本课程假设你已具备Git、HTTP、Web服务和脚本语言的基本知识。如果你是本科生,应该已经掌握;如果你是研究生或需要复习,我们提供了相关补充材料。

学习成果包括:

  • 编写、测试和调试前端代码。
  • 理解HTML、CSS、JavaScript及其运行环境(浏览器)。
  • 了解现代前端框架(如React)。
  • 理解异步编程(这对许多同学来说可能是新概念)。
  • 具备前端安全、UI/UX和可访问性设计的基本意识。

教学方式:前端开发是一门广度课程,与深度钻研算法的课程不同。一个挑战是,你总能直观地看到自己的作品与目标的差距,这可能会带来压力。请记住,这是一个渐进的过程,不要对自己过于苛刻。课程具有挑战性,但我们会提供充分支持。

课程网站:我使用本课程所教的技术搭建了这个网站。它的设计是为了展示前端知识点的相互关联性,并帮助你按需选择学习路径。网站上的视频、幻灯片和代码都可供学习。

课程团队:本学期有约700名学生,教学团队规模很大,有大约30名导师,提供了大量的帮助时段(Help Sessions)。


评估方式与作业

课程的评估结构如下:

  1. 作业1(15%):静态HTML和CSS。截止日期:第3周初
  2. 作业2(5%):基础的“老派”JavaScript。截止日期:第4周初
  3. 作业3(30%):更多的“老派”JavaScript。可与同伴合作完成。截止日期:第7周初
  4. 作业4(30%):使用现代框架(React.js)开发。可与同伴合作完成。截止日期:第10周初
  5. 期末考试(20%):开卷考试,形式是在有限时间内完成一个小型前端任务,类似于一次速度挑战。

关于截止日期:根据学校政策,我们设定了提交截止日期,并在截止日期后留有宽容期。请尽量在截止日期前提交。

为什么学习“老派”JavaScript?:理解JavaScript的基础原理和科学,对于深入掌握现代框架至关重要。许多行业反馈表明,只懂框架而不懂底层原理的开发者在解决问题时会遇到困难。

获取帮助的途径

  1. 课程论坛(首选)。
  2. 帮助时段(Help Sessions)。
  3. 如需更个性化的帮助,可以给我(Hayden)发邮件。


环境设置与资源

本课程几乎无需特殊设置。你的“编译器”就是网页浏览器(推荐使用Google Chrome)。大部分开发可以在你自己的电脑上完成。

所有作业和内容都通过GitLab管理。本科生对此应该很熟悉;研究生如果有疑问,可以在帮助时段寻求导师的特别指导。

课程网站上有一个“资源”页面,包含一些有用的外部链接和小游戏(例如一个学习CSS Flexbox的青蛙游戏),可以帮助你巩固知识。

虽然没有严格的代码风格指南,但在课程后期(特别是React部分),我们推荐参考Airbnb的JavaScript风格指南

你可以通过网站底部的按钮提交匿名(或署名)反馈,帮助我们改进课程。

请始终保持友善,尊重同学和导师。


作业1详解与快速上手

现在,让我们快速了解一下第一个作业。

作业1:P2 Code

  • 内容:共有3个任务。每个任务会给你一张网页效果的图片(PNG/JPEG),你需要使用HTML和CSS编写代码,使生成的网页与图片看起来完全一致。
  • 目的:巩固第1到第3周所学知识。观看完本周的预录讲座后,你就有能力开始这个作业。
  • 建议尽早开始。你可能很快就能完成大部分内容,但调整细节(如精确的间距、颜色)可能会花费较多时间。这是前端开发的常态,即使是经验丰富的开发者也需要反复尝试和调整。

三个任务简介

  1. 任务一:以文本和列表为主的页面,非常简单,主要练习HTML。
  2. 任务二:构建一个类似“俄罗斯方块”初始界面的彩色网格,主要练习CSS布局(提示:会用到grid)。
  3. 任务三:一个包含多个卡片区域和底部横幅的页面。这个任务的挑战在于需要实现响应式设计,即页面在桌面和移动设备上都需要有良好的显示效果。

如何开始

  1. 在课程网站的“Assignments”页面找到作业1,点击链接进入你的GitLab仓库。
  2. 点击“Clone”按钮,在终端中使用git clone命令将仓库克隆到本地。
  3. 进入相应任务的文件夹(如task1),你会看到一个index.html文件。
  4. 用浏览器直接打开这个HTML文件,就能看到你的网页效果。
  5. 使用任何文本编辑器(如VSCode)编辑index.html和相关的.css文件,然后刷新浏览器查看变化。

示例代码

<!-- 这是一个简单的HTML示例 -->
<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>这是一个标题</h1>
    <p>这是一段<strong>加粗的</strong>文本。</p>
    <a href="https://google.com">这是一个指向Google的链接</a>
</body>
</html>
/* 这是对应的CSS示例 */
p {
    color: blue; /* 将段落文字变为蓝色 */
}

评分标准

  • 50% 基于视觉效果是否与目标一致(允许细微像素差异)。
  • 50% 基于代码质量(缩进、有意义的命名、避免重复等)。

重要提醒:请勿抄袭。我们有检测机制,抄袭会导致严重后果。


总结与后续安排

本节课中,我们一起学习了COMP6080前端编程课程的概览。我们探讨了学习前端的原因、前端开发者的角色、Web技术的演变历程、课程的教学与评估结构,并详细了解了第一个作业。

后续安排

  • 本周没有周三的现场讲座。
  • 你需要在本周内观看预录讲座(在课程网站上),为作业1做准备。
  • 作业1的截止日期是第3周周一上午
  • 如果有任何问题,请首先在课程论坛上提问。

再次感谢大家今天的参与,也感谢大家对技术问题的包容。祝大家本周学习顺利!我将在周五给大家发送一封邮件。

祝你好运!😊

002:Git - 单人使用 🎯

在本节课中,我们将要学习 Git 版本控制系统的基础知识,特别是如何仅在一台计算机上使用它来管理代码。Git 是一个强大的工具,用于跟踪代码历史并支持多人协作。虽然它功能强大,但入门并不难。我们将从最基础的概念开始,逐步构建你的理解。

课程概述与心态调整

这是我们的第一堂 Git 课。Git 是一个我们即将讨论的程序,它是一个难以完全掌握的主题。我想把它比作弹钢琴:大多数人都能很快学会弹一些基本的音符,但你也见过有人在钢琴上演奏出令人惊叹的复杂乐章。Git 非常类似。你会发现今天很快就能掌握一些基本操作,并且在课程中应用这些操作会很容易。但如果你感觉没有完全理解某些内容,或者有很多不懂的地方,这实际上是完全正常的。Git 是一门需要终身学习的课程和程序。所以,如果今天感觉内容很多,请不要感到压力,因为我们确实会向你介绍很多内容。归根结底,本课程只涵盖非常基础的部分,你只有通过实践才能变得熟练。给自己一些时间。如果你到了第 8 周仍然有些困惑,我们会支持你。但如果你今天有点困惑,那意味着你完全正常。

关于实验课,实验 1 包含三个活动。其中一个活动基于 Git,另外两个活动基于 Git 和 JavaScript(我们明天晚上会讲)。所以,你可以在今晚之后轻松完成第一个活动,我建议你等到明天之后再完成另外两个。

为什么需要 Git?🤔

首先,让我们设定一下背景。问题在于,为了有效地在大型项目和工程师团队中工作(想象一下我们 12 个人一起工作),我们需要一个更复杂的方法来管理所有人尝试编写代码的过程。这需要满足两个核心需求。

第一个需求是版本控制。我们需要跟踪历史。如果你用过 Google Drive、Dropbox,你会知道有时可以获取文件的旧版本。Google Sheets 和 Google Docs 都有这个功能,即历史记录。我们需要一种能够“回到过去”或记录发生了什么的方法,这样在出错时容易回退,也容易看到导致某个结果的步骤。

第二个需求是能够并发编程,即我们需要多个人能够同时在同一项目上工作,而不必共享文件或等待对方完成工作。

你可能用过的 Dropbox 和 OneDrive 等程序能很好地维护文件历史记录,并帮助你在多个来源之间同步。但你知道,如果两个人同时编辑 Dropbox、OneDrive 或 Google Drive 上的 Word 文档,它会变得非常混乱,不知道如何合并这些更改。还有其他工具,如 Google Docs,在某种程度上能很好地跟踪历史记录。Google Docs、Google Sheets 等工具允许你与他人实时协作,你们可以同时处理同一份文档(虽然不能在同一行文本上同时书写,但可以在不同部分书写,它会处理好)。

像 Google Docs 这样的程序在某种程度上解决了我们程序员面临的许多问题,但在很多方面,它们过于简单,不足以应对我们作为程序员所面临的特定挑战。因此,我们需要一个更好的解决方案,而一个流行的解决方案就是 Git

什么是 Git?💻

Git 是一个程序,就像 gedit、VS Code、gcc 一样。如果你来自 COMP1511 课程,你知道可以使用 dcc 或 gcc 程序。Git 只是计算机上的另一个程序。Git 是一个版本控制工具,这意味着它是一个历史跟踪器,使人们能够在同一代码库上并发工作。因此,如果我和其他 10 个人一起工作,我们都可以(可能不是在同一行代码上,但在同一文件和同一文件集合内)同时协作,而不必等待对方完成工作才开始。

与许多其他你可能用过的工具不同,Git 是为程序员构建的,并且是为管理代码而设计的。它是专门为解决我们程序员想要解决的这个问题而构建的。市面上还有其他解决方案,如果你感兴趣,可以看看 Mercurial 或 SVN。一些公司使用这些工具,但 Git 通常是最流行的。这就是我们教授它的一个重要原因。

关于 Git 的一个非常重要的事情是,Git 是一种我们称之为分布式版本控制软件。这意味着它实际上有点像 Dropbox 或 OneDrive。如果你有三台电脑:一台台式机、几台笔记本电脑和手机,每台设备上都有 Dropbox,并与 Dropbox 同步,那么它们都有一份副本。你可以说这是另一种分布式形式。你们并不都依赖云端。如果 Dropbox 云端爆炸消失了,你的台式机、笔记本电脑和手机仍然有所有文件的副本。Git 是一种分布式版本控制软件,它始终依赖于这样一个事实:理论上你拥有所有最新的更改。

Git 的使用方式:命令行与网站 🖥️

Git 是一个命令行程序。正如我们在本课程中经常做的那样,我们将在 Linux 的命令行上使用 Git(例如在 CSE 机器上使用终端)。然而,为了让我们的生活更轻松,也为了让每个人的生活更轻松,存在一些本质上使用 Git 底层功能的网站,作为用户界面。这样,你就不必总是学习每一个 Git 命令,你可以使用这些网站来帮助你。在本课程中,我们将同时使用这两种方式:使用让生活更轻松的网站,也使用命令行。本质上,这些网站是 Git 的前端,所有内容都存储在那里。这有点像 Dropbox 是一个网站,但其下有软件来完成复杂的工作。这些 Git 网站也类似。

有三个主要的 Git 软件工具实现了 Git 语言,它们是 GitHubBitbucketGitLab。它们的功能大致相同。GitHub 是目前最流行的,可能是如今人们的主要选择(现在由微软拥有)。Bitbucket 由 Atlassian(一家澳大利亚科技公司)拥有。它们都是私有的、营利性的公司产品。近年来,Atlassian 对 Bitbucket 的投入不如微软对 GitHub 的投入,所以两者之间的差距在过去几年里确实扩大了。GitLab 则更像是开源友好的、社区友好的软件,你可以获取自己的副本。它是开源的。这意味着像 UNSW 这样的组织实际上可以获取 Git 软件的副本,因为它是免费提供的,我们实际上在 CSE 自己的系统上运行 GitLab。事实上,你已经见过这个 GitLab 网站了。这与 gitlab.com 不同。gitlab.com 是 GitLab 公司管理软件的网站。但在 UNSW 的计算机科学与工程学院,我们获取了该软件的副本并自行运行。这是我们社区的 GitLab。因此,我们将在整个课程中使用 GitLab。我们不会接触另外两个,尽管我们可能会在某个时候顺便提到它们。

学习路径与设置 🛠️

现在,学习 Git(这是我们今晚来这里的目的)可能会有点吃力。但关于 Git 的好处是,虽然我们今晚谈论 Git 讲座,但实际上我们在整个课程中都会使用 Git。这就是为什么它是第一讲。所以,再次强调,如果你今晚感到有点不知所措,请记住,这只是理论部分,然后我们实际上会在接下来的 10 周里每周都实践它。

我们将分三个阶段学习 Git。第一阶段是学习如何在单台机器上使用 Git 或版本控制(也就是本讲内容)。然后,我们将学习如何在自己多台计算机上使用 Git(例如 CSE 机器和个人笔记本电脑)。今晚的下一讲,我们将学习如何在工程师团队中使用 Git。

今晚我们所做的大部分内容将是实践性的,这意味着会很有趣。关于设置,课程设置中有很多说明。对于在 Windows 上使用 Git 的问题,你可以按照自己的方式操作,但我们只支持我们指定的方式。如果你用自己的方式遇到问题,助教可能更难帮助你。所以,Git for Windows 是可以的,但对于从未听说过这些内容且不知道自己在做什么的其他人,你可以按照这里的设置说明操作,或者如果你使用 CSE 机器(我们今晚将使用),则不需要进行任何设置。我仍然喜欢 CSE 机器,我认为它们很棒。

开始实践:在单台机器上学习 Git 🚀

现在,我将引导你完成一些流程。如果你有问题,可以随时提问。首先,在开始克隆之前,你必须设置 SSH 密钥。这部分涉及很多说明,但你可以按照实验课的步骤进行。实验课中有大量关于如何设置、处理密钥、添加密钥和使用 Git 的说明。这可能需要我花 10 分钟来逐步讲解,所以请按照我们花了很多时间编写的步骤操作,然后你可以进行下一步。

下一步是克隆一个仓库。在这个课程中,你有这些代码仓库(文件的桶),每个实验都有一个。所以你每周会有几个这样的仓库。还有一个用于主要项目。它们实际上只是文件的集合。

克隆仓库

我将创建一个名为 lecture_test 的项目(你不需要这样做,因为在课程中你不创建这些,但为了本次讲座的目的,我现在创建一个)。这是 GitLab 上的一个 Git 仓库。更具体地说,它是文件的集合,但对我们来说通常是代码的集合。这只是一个用户界面,这里有很多信息,其中大部分你不需要关心。不过,对你来说最有趣的是,我想在自己的计算机上获取这个仓库并与之交互。因此,我们要学习的第一件事就是设置,即 Git 克隆

在 GitLab 用户界面上,我可以点击克隆按钮,然后复制这个 Git 仓库的 URL。然后,我可以打开终端(例如通过 TigerVNC 远程连接到 CSE 机器),创建一个新文件夹,进入该文件夹,然后输入 git clone 命令,粘贴我复制的 URL。这将把 GitLab 上的内容放到我自己的计算机上。克隆完成后,如果我输入 ls 查看文件,实际上会看到创建了一个新文件夹,但该文件夹内部是空的,因为 GitLab 上显示它是空的。我创建了一个项目(一个文件桶),但那个桶里什么都没有。

添加文件到仓库

也许我想向其中添加一个文件,向桶里添加一些东西。我将使用 gedit 创建一个名为 names.txt 的新文件,并在其中输入名字 “Hayden”。保存并关闭文件后,如果我输入 ls,文件就在那里了。但我要使用一些 Git 命令来看看 Git 在关注什么。

第一个命令是 git statusgit status 会显示很多信息,其中有些分散注意力,但主要意思是存在未跟踪的文件。这意味着 Git 可以看到添加了一个新文件 names.txt,但 Git 没有跟踪这个文件。Git 就像在说:“我看到了,但除非你告诉我,否则我不会太关注它。”

如果我想将这个文件添加到我的桶中,我现在要输入 git add names.txtgit 是程序,add 是命令,names 是我想添加的文件。现在,当我再次运行 git status 时,你会看到 Git 显示的内容发生了变化。它不再说文件未跟踪,而是说“要提交的更改”。这意味着我的文件已经在 GitLab 上了吗?如果我刷新 GitLab 页面,不,看起来还是一样。它仍然只在我的计算机上,或者更具体地说,在 CSE 计算机上。

它说“要提交的更改”是因为 Git 的工作方式是:它本质上允许你随意处理代码,然后当你对代码满意时,你本质上会为其拍摄快照并保存。没有自动保存。就像过去,如果你不保存工作,丢失了就没了。这个保存过程就是我们所说的提交,因为你正在确认你所做的更改。我已经做了一些更改,添加了一个文件。这是一个单行文件,我做了那个更改。让我们确认那个更改。所以现在我要看下一个命令:git commit

git commit 的作用是:它会对 Git 正在关注的所有内容、Git 正在监视的所有内容拍摄快照。关于 git commit 的一点是,它需要一条消息,即对所发生事情的描述,这是一个好习惯。你可以使用 -m 参数,然后在一些引号内写入消息。消息可以是任何内容,完全由你决定,这是一个非常人性化的体验。在这个例子中,消息将是 “created a new names file”。然后我按回车键。它显示已经完成了某种提交。它现在在 GitLab 上了吗?不,仍然不在。原因在于,虽然我们拍摄了快照、保存了它,但我们只是本地保存了它,只保存在我的 CSE 账户内。

推送更改到远程仓库

所以,要把它上传到 GitLab,我需要推送。我需要执行 git push。推送意味着推送到 GitLab。当我执行 git push 时,它会将其推送到 GitLab。现在,当我刷新页面时,整个屏幕看起来不同了,你还会看到下面显示了我的文件 names.txt。所以我的文件现在在 GitLab 上了。它会告诉你我上次提交是多久以前,以及其他一些信息。

修改文件并提交更改

现在,如果我想更改那个文件,想进一步修改它,这很简单。我可以再次打开那个文件,添加另一行。例如,添加 “Fulwa”。然后关闭文件。当我再次运行 git status 时,你会看到 Git 提示自上次快照以来你更改了那个文件。Git 总是跟踪自上次提交以来你所做的更改。事实上,另一个有用的 Git 命令是 git diffgit diff 会显示实际更改了什么。当我运行 git diff 时,你会看到它说:你更改了 names.txt,原始文件只有 “Hayden”,但你添加了一行。这些都是你可以了解更多信息的方式。

如果我想提交那个更改,我想把它从红色变成绿色,因为现在它说这些更改没有暂存以备提交。在你可以实际为文件拍摄快照之前,你需要像这样添加它:git add names.txt。通过添加,我告诉 Git 不仅要关注它,还要让它准备好拍摄快照,准备好提交。所以现在当我执行 git status 时,你会看到它从“未暂存以备提交的更改”变为“要提交的更改”。此时,我可以再次提交,并说 “added an awesome person”。然后我可以推送。我已经提交了,但 GitLab 上还没有任何东西,我只是在本地拍摄了快照。但我可以将其推送到 GitLab。现在,当我在网站上查看 names.txt 时,两者都在那里了。

查看提交历史

为了帮助理解本地(我指的是 CSE,因为我正在使用 VNC)和 GitLab 之间的区别,当我转到 GitLab,进入仓库,然后进入提交(因为我通常看的是文件,文件就是那里的所有文件),实际上可以看到我拍摄的两个提交或快照。GitLab 非常有用,你实际上可以点击它们,它会显示每个提交包含的内容:第一个提交是这一行,第二个提交添加了这一行。如果我在本地的命令行查看,我可以执行 git loggit log 实际上会显示相同的内容:两个提交、谁做的(这是一个假邮箱)、何时发生以及描述是什么。

为了进一步说明,假设我进一步修改文件,添加一个随机的人名 “Jess”。然后我创建一个新文件,比如 drinks.txt,并放入 “coke” 和 “water”。现在当我运行 git status 时,你会看到 Git 说:有一个我正在监视的文件 names.txt 已经更改,但你还没有准备好提交它。你还没有准备好拍摄那个快照,所以我必须添加那个文件。再次 git status 后,它显示这个文件已准备好提交。但我也看到有一个我以前没见过的新文件,我没有跟踪它,没有密切关注它。它就像在更外围的一步。我想让 Git 跟踪那个文件,所以我也要 git add 那个文件:git add drinks.txt。现在你会看到这两个文件、它们的更改都已准备就绪,所以我可以提交:git commit -m "added 2 drinks and a new name"

现在,当我运行 git log 显示提交时,你会看到有 3 个提交。但当我转到 GitLab 时,只有两个,因为就 GitLab 而言,它还没有收到我的任何新更新。我最近根本没有推送过。所以现在我需要推送,对吧?当我推送时,GitLab 现在将拥有三个东西。所以推送是你用来使用 GitLab 更新更改的方式。

关于命令行与图形界面的问题

有人问了一个很好的问题:为什么我们使用命令行来做所有这些事情,而不是图形界面?为什么我们要费心使用命令行?我想说的是,在不深入许多可能还不太有意义的例子之前,命令行的许多功能是图形界面无法实现的。使用命令行实际上通常更快,原因有很多。但一个好的程序员会同时使用图形界面和命令行,两者在不同情况下都很重要。因此,熟悉两者都很重要。

另一个问题是:为什么你只想本地更新仓库,而不是同时提交它?嗯,有时你处理的事情还没有准备好保存。就像有时事情在变好之前会先变坏。你画过画吗?你不会想在只画了两三条线或甚至不确定自己在做什么的时候就保存副本。有时你只是想再深入一点,然后才觉得“好了,现在是时候保存了”。

还有一个问题是:如果两个人同时推送到 GitLab 会怎样?我们将在下一讲中讨论这个问题。下一讲就是关于这个的。

克隆与获取文件

另一个好问题是:如果我们克隆,会得到 names.txt 吗?这也是之前被问到的问题。假设我删除了这个文件夹,它消失了。幸运的是,我在删除之前推送了所有内容。但因为所有内容都在 GitLab 上,我现在可以转到 GitLab,复制 URL,然后执行 git clone,粘贴进去。它会克隆,我将拥有所有文件,以及我期望的提交历史。

跨多台机器的版本控制 🔄

第二部分是关于跨多台机器的版本控制。我的意思是,就像你自己在多台设备上工作,例如笔记本电脑和台式机,或者如果你使用笔记本电脑和 VNC,这些都是不同的软件环境。我们将看看如何在你自己的两台不同设备上工作,这就像是进入下一个主题的婴儿步。

为了演示这一点,我将使用我的本地机器(我正在讲课的电脑)和 CSE 机器。想象一下,你一直在 CSE 机器上处理 lecture1_test,现在你想在本地机器上处理它。你要做的就是:从大学回家后,登录 GitLab,点击克隆按钮,复制 URL,然后到本地机器上执行 git clone,就像在 CSE 机器上那样克隆它。然后我可以进入那个文件夹。现在,我的本地机器上有 names.txt,CSE 机器上也有。如果我执行 git log,两边都有我的四个提交,每个人都是同步的。

所以现在你有了一个模型:GitLab 在上方,我的本地机器和 CSE 机器都与 GitLab 通信,它们也通过 GitLab 间接通信。这些设备都以某种方式连接在一起。

同步更改:推送与拉取

现在,我们想象一下在 CSE 机器上做更多更改并尝试同步它们。假设在 CSE 机器上,我创建一个新文件 food.txt,内容是我午餐吃了米纸卷,晚餐吃了三明治。我创建那个文件,git statusgit add food.txtgit statusgit commit -m "adding food",然后 git push。现在它在 GitLab 上了。你可以看到,因为我有五个提交和两个文件。但在本地机器上,我只有四个提交和一个文件。GitLab 上有两个文件。所以我在机器 1 上做了更改,然后推送到 GitLab。现在 GitLab 也有了,但我的另一台机器没有。这就是为什么我们需要看这个非常重要的命令:git pull

git pull 的作用是:它是 git push 的反向操作。不是将你的东西推送到 GitLab,而是从 GitLab 拉取下来。不是向上同步,而是向下同步。现在我在本地机器上执行 git pull,它将从 GitLab 拉取下来,并告诉你一些事情,比如 food.txt 文件被创建,插入了两行。现在当我执行 ls 时,我有两个文件,当我执行 git log 时,我有五个提交。

类似地,我可以在本地机器上做一些事情。例如,修改 food.txt 文件,添加昨天吃的 “Zeus” 希腊菜。然后 git statusgit add foodgit commit -m "adding more food",然后推送到 GitLab。然后 GitLab 有了更新后的文件。但同样,如果我在 CSE 机器上打开 food.txt,它就不是最新的,因为它只是我本地推送的。所以如果我想获取最新的更改,我需要执行 git pull。执行 git pull 后,我就有了那些最新的更改。

克隆与拉取的区别

有人问:git pull 和克隆有什么区别?克隆是创建仓库,有点像第一次拉取。你只克隆一次。一旦克隆并存在,拉取就只是关于保持同步。这有点像创建 Dropbox 账户和不断同步之间的区别:你只创建一次,然后就一直同步。

处理冲突

在我们休息之前的最后一件事是那个永恒的问题:如果两个人同时修改同一行会发生什么?这在你一个人使用时不太会发生,但这是下一讲内容的前奏。想象一下,我在 CSE 机器上(版本 1)修改这个文件,说三明治是火腿三明治。然后我 git addgit commit -m "ham sandwich addition"git push 到 GitLab。然后我回家,忘记了我修复了那个。我打开文件,以为我没做,然后我改成“火腿奶酪三明治”。然后我 git addgit commit -m "ham and cheese sandwich description added",然后尝试 git push。它会说:不,你不能这样做。它给了我一些非常可怕的红色和黄色错误。这是有道理的,因为现在 GitLab 上显示的是“火腿三明治”,但本地我有一个内容略有不同的文件。

这里的问题不一定是该行内容不同,问题实际上与我们所说的 Git 树 有关(我们在这节课中不会深入讨论,下一讲会多谈一点)。实际发生的情况是:GitLab 网站位于中间,它有一系列提交。想象一下到目前为止只有三个提交。Git 的工作方式是:虽然你可能认为 Git 是跟踪你的文件,但更具体地说,Git 实际上是跟踪你的提交。将 Git 视为一个提交跟踪工具是非常有帮助的思考方式。

当我进行另一个提交时,想象这些提交像一条长链一样连接在一起。当我在 CSE 上提交时,我是在链上添加另一个提交。git push 的作用非常重要,从理论理解的角度来看:它不推送你的文件,它实际上是同步你的提交链,同步你的提交历史。它说:“嘿 GitLab,自从我们上次交谈以来,我实际上做了一些事情。给你,现在你更新了。” 你在将那些新提交推送到 GitLab。

但问题是:CSE 上的蓝色提交是“火腿三明治”版本,但本地(我没有做任何 git pull)在我回家后,就本地计算机而言,只发生了那些。我在本地做了一个提交(绿色提交,“火腿奶酪三明治”)。现在的问题是:Git 的工作方式不会在你推送时自动为你合并这些东西。所以当我在本地机器上尝试推送时,Git 会拒绝,因为它发现历史定义不同。Git 希望你所拥有的只是它所理解的历史的扩展。

解决这个问题的方法是:当我尝试推送失败时,尝试拉取。当你拉取时,Git 会说:“好吧,好吧,好吧,我要接受这个,并尝试为你合并它们。” 然后你在自己的机器上修复它。基本上,Git 可以合并东西,但它不想在 GitLab 服务器上做,它希望你在自己的机器上做,然后将新东西推送到 GitLab 服务器。

所以当我在 CSE 机器上查看时,最后一个提交是蓝色提交“火腿三明治”。在本地,我的最后一个提交是绿色提交“火腿奶酪三明治”。当我执行 git pull 时,Git 拉取了文件,然后说冲突:food.txt 中存在合并冲突。当我打开 food.txt 时,你会看到 Git 有一个非常丑陋的东西,它基本上说:“嘿,Hayden,这是我看到的两个东西。我看到你有‘火腿奶酪三明治’,另一个你有‘火腿三明治’。我不知道该怎么办。所以这是内容。” 它会放一些等号和一些行,让你看到位置。此时,需要一个人(你、我或其他人)介入,查看并与他人讨论应该是什么。显然,这应该是“火腿奶酪三明治”,因为它描述更详细。修复后,我保存文件,Git 希望我添加那个文件,然后我可以提交,说“三明治解决”。然后我推送那个。

现在有趣的是:当我推送时,它在 GitLab 上更新了。但看看 Git 历史:在我的 CSE 机器上,仍然只是那些提交(五个提交加上新的三明治版本,只是蓝色提交)。但在我的本地机器上,情况更复杂:它们以某种方式加入了蓝色提交、绿色提交,然后另一个我们称之为合并提交的提交。所以基本上发生的是:当我从 GitLab 拉取时,它说:“好吧,去拿 GitLab 上的东西,把它塞到这里,把你的东西放在上面。” 但因为那里有问题,所以有点复杂,所以我们实际上又做了一个提交。然后当我推送到 GitLab 时,你会注意到现在的区别是:我本地现在只是 GitLab 的扩展。这就是 Git 在你推送时想要的:它希望你拥有的只是 GitLab 上内容的扩展,而不是不同的东西。所以现在当我推送到 GitLab 时,它看起来像那样。但我本地(CSE 机器)仍然落后,因为 CSE 不知道任何这些发生,所以 CSE 现在必须拉取,这将从 GitLab 拉取新东西。

Git 本质上只是一个提交链的历史,它不断尝试与使用它的设备保持同步。

Git 的自动合并

在我展示的绝对最坏情况之前,还有一个非常好的问题。实际上,我向你展示了最坏的情况,因为 Git 相当聪明。如果你没有修改同一行,Git 会解决这个问题。基本上,如果 Git 能判断出你们编辑了不同的东西,它会自动合并。让我演示一下我的意思。

想象一下,类似的事情现在发生在 names.txt 文件中:我添加了 Hayden 的姓氏。我 git addgit commit -m "adding last name to Hayden",然后推送。所以现在 GitLab 和我的本地机器都有了那个提交。然后,在 CSE 机器上,假设我更新 Jess 的名字,加上姓氏 “L”。我做同样的事情:git statusgit addgit commit -m "adding Jess last name",然后尝试 git push。当我尝试推送时,历史又不同了,Git 会阻止。我像之前解决它一样,执行 git pull。你会看到当我执行 git pull 时,我得到了一个奇怪的消息,你可以关闭它。Git 实际上已经自动完成了合并部分,因为 Git 足够聪明,意识到这两个更改发生在文件的不同部分,它只是把它们混合在一起。所以现在我不需要做任何事情,只需推送它。所以发生的情况是:GitLab 有一个版本,我在 CSE 机器上有另一个版本,我拉取,Git 解决了所有问题,然后它说:“这是新的弗兰肯斯坦代码。你能把它推回 GitLab 吗?” GitLab 就有了那个。

所以,经常拉取可以避免这种情况吗?是的。任何团队协作的最佳方式之一(我们将在休息后的下一讲中讨论)就是频繁推送代码和频繁拉取。你与团队成员同步得越频繁,效果越好。

但我今天展示的所有内容都适用于没有团队的情况。这是你独自在实验室和本地,或在两台设备上本地工作可能经历的所有体验。

总结与休息 🎉

在进入反馈屏幕之前,总结一下我们今天看过的所有命令:clonestatuslogadddiffcommitpushpull。还有一些细微差别我今晚不会讨论,因为这是第一讲,但我们会在整个课程中回过头来讨论它们。

最后,对于这节课,如果你想留下一些反馈,非常欢迎。让我们休息 7 分钟,然后继续。


本节课中我们一起学习了 Git 的基本概念和单人使用场景下的核心工作流。我们了解了为什么需要版本控制,Git 作为分布式版本控制系统的特点,以及通过命令行进行的基本操作:克隆仓库、查看状态、添加文件、提交更改、推送和拉取更新。我们还初步接触了合并冲突的概念。记住,实践是掌握 Git 的关键,我们将在整个课程中不断应用这些知识。

003:HTML入门 🐲

在本节课中,我们将学习HTML的基础知识。HTML是构成所有网站结构的基本语言,是网页开发的起点。我们将了解HTML是什么、它的工作原理以及如何使用各种标签来构建网页内容。


什么是HTML?🤔

HTML代表超文本标记语言。它是构成互联网上任何网站的基本结构。简单来说,HTML是我们用来描述网页内容的语言。它更侧重于结构,而非外观样式。网页的动态效果和视觉样式将由后续学习的CSS和JavaScript来处理。

HTML如何工作?⚙️

与Python或C等需要通过命令行编译或解释的语言不同,HTML的“编译器”就是我们日常使用的网页浏览器。浏览器会读取我们编写的HTML代码,并将其渲染成可视化的网页。

这意味着你甚至不需要安装任何特殊软件来运行HTML。只需将代码保存为 .html 文件,然后用浏览器打开它即可看到效果。

示例:

<!DOCTYPE html>
<html>
  <head>
    <title>我的第一个网页</title>
  </head>
  <body>
    <p>你好,世界!</p>
  </body>
</html>

将上述代码保存为 page.html 并用浏览器打开,你就能看到一个简单的网页。

在浏览器中,你还可以通过“查看页面源代码”来查看任何网页背后的原始HTML代码。


网页的基本结构 🏗️

一个标准的HTML文档通常包含以下几个关键部分:

  1. 文档类型声明<!DOCTYPE html>。这告诉浏览器这是一个HTML5文档。虽然不是强制要求,但遵循最佳实践总是好的。
  2. <html> 标签:整个网页内容的根容器。
  3. <head> 标签:包含页面的元信息,如标题、字符集、引入的外部文件(CSS、JavaScript)等。这部分内容不会直接显示在页面上。
    • <title> 标签:定义浏览器标签页上显示的标题。
  4. <body> 标签:包含所有会直接显示在网页上的内容,如文本、图片、链接等。

结构示例:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>页面标题</title>
  </head>
  <body>
    <!-- 所有可见内容都放在这里 -->
  </body>
</html>


常用布局与格式化标签 📐

上一节我们介绍了网页的基本骨架,本节中我们来看看可以在 <body> 标签内使用的各种内容标签。这些标签用于定义内容的结构和基本格式。

以下是HTML中一些最常用的布局和格式化标签:

  • 标题<h1><h6> 标签用于定义标题,<h1> 最大,<h6> 最小。它们表示内容的结构层级。
    <h1>主标题</h1>
    <h2>次级标题</h2>
    <h3>小标题</h3>
    
  • 段落<p> 标签用于定义段落。HTML会忽略普通的换行和空格,使用 <p> 标签可以正确地分隔文本块。
    <p>这是第一个段落。</p>
    <p>这是第二个段落。</p>
    
  • 换行<br> 是一个空标签(没有闭合标签),用于在文本中强制换行。
    第一行<br>第二行
    
  • 文本格式化
    • <b>加粗文本。
    • <i>斜体文本。
    • <u>下划线文本。
    这是<b>加粗</b>,这是<i>斜体</i>,这是<u>下划线</u>的文本。
    
  • 列表
    • 无序列表 <ul>:项目以圆点等符号开头。
    • 有序列表 <ol>:项目以数字或字母顺序开头。
    • 列表项 <li>:定义列表中的每一项。
    <ul>
      <li>苹果</li>
      <li>香蕉</li>
    </ul>
    <ol>
      <li>第一步</li>
      <li>第二步</li>
    </ol>
    
  • 通用容器
    • <div>:块级容器。默认独占一行,常用于布局和组合其他元素。
    • <span>:行内容器。不会导致换行,常用于对文本的一部分进行样式设置。
    <div>这是一个块级区域。</div>
    <div>这是另一个块级区域。</div>
    <p>这是一段<span style="color: red;">红色</span>的文字。</p>
    


链接与媒体 🔗

除了基本的文本和布局,HTML还能轻松地嵌入链接和多媒体内容,让网页变得丰富多彩。

链接(锚点标签)

<a> 标签用于创建超链接,可以链接到其他网页、同一页面的不同部分或文件。

核心属性:

  • href:指定链接的目标地址(URL)。
  • title:鼠标悬停在链接上时显示的提示文本。
  • target:指定如何打开链接。_blank 表示在新标签页中打开。

示例:

<a href="https://www.example.com" title="访问示例网站" target="_blank">点击这里</a>

图像标签

<img> 标签用于在网页中嵌入图像。它也是一个空标签

核心属性:

  • src:指定图像文件的路径(可以是网络URL或本地文件路径)。
  • alt:为图像提供替代文本。如果图像无法加载,将显示此文本。这对可访问性和SEO非常重要。
  • width / height:设置图像的宽度和高度(单位是像素)。

示例:

<img src="minidog.jpg" alt="一只可爱的小狗" width="400" height="300">

高级媒体嵌入

HTML5引入了原生支持多媒体内容的标签。

  • 视频<video> 标签用于嵌入视频。
    <video width="500" controls>
      <source src="meeting.mov" type="video/mp4">
      您的浏览器不支持视频标签。
    </video>
    
  • 音频<audio> 标签用于嵌入音频。
    <audio controls>
      <source src="sound.mp3" type="audio/mpeg">
      您的浏览器不支持音频元素。
    </audio>
    
  • 内嵌框架<iframe> 标签用于在当前网页中嵌入另一个网页。
    <iframe src="https://www.example.com" width="800" height="600"></iframe>
    


表单:与用户交互 📝

表单是网页与用户交互的核心组件,用于收集用户输入的数据,例如登录框、搜索栏、调查问卷等。

<form> 标签用于创建一个包含各种输入控件的表单区域。虽然其传统的提交机制(actionmethod)在现代前端开发中不常直接使用,但其中的输入元素本身极其重要。

以下是表单中常见的输入元素:

  • 文本输入<input type="text"> 是最基本的单行文本框。
    <input type="text" name="username" placeholder="请输入用户名">
    
  • 数字输入<input type="number"> 只允许输入数字,并带有调节按钮。
  • 单选按钮<input type="radio"> 用于从一组选项中选择一项。通过给同一组的单选按钮设置相同的 name 属性来实现互斥。
    <input type="radio" id="age1" name="age" value="18-25">
    <label for="age1">18-25</label>
    <input type="radio" id="age2" name="age" value="26-35">
    <label for="age2">26-35</label>
    
  • 复选框<input type="checkbox"> 用于从一组选项中选择多项
    <input type="checkbox" id="hobby1" name="hobby" value="dancing">
    <label for="hobby1">跳舞</label>
    <input type="checkbox" id="hobby2" name="hobby" value="singing">
    <label for="hobby2">唱歌</label>
    
  • 下拉选择框<select><option> 标签结合创建下拉列表,适用于选项较多的情况。
    <select name="city">
      <option value="bj">北京</option>
      <option value="sh" selected>上海</option>
      <option value="gz">广州</option>
    </select>
    
  • 多行文本域<textarea> 用于输入多行文本。
    <textarea name="comments" rows="4" cols="50" placeholder="请输入您的意见..."></textarea>
    
  • 标签<label> 标签用于关联文本和表单控件,提高可用性。点击标签文字也能选中对应的控件。使用 for 属性指向控件的 id
  • 按钮
    • <button type="button">点击我</button>:通用按钮,通常与JavaScript配合使用。
    • <input type="submit" value="提交">:传统的表单提交按钮。


总结 🎯

本节课中我们一起学习了HTML的基础知识。我们了解到:

  1. HTML(超文本标记语言) 是构建网页内容的结构性语言。
  2. 网页浏览器充当了HTML的“编译器”,将代码渲染成可视化的页面。
  3. 一个标准的HTML文档包含 <!DOCTYPE html><html><head><body> 等基本结构。
  4. 我们学习了多种布局标签(如 <h1>-<h6><p><div><span>、列表、表格)来组织内容。
  5. 我们掌握了如何使用链接 (<a>)、图像 (<img>) 和多媒体 (<video><audio>) 标签来丰富页面。
  6. 最后,我们探索了表单 (<form>) 及其各种输入控件(文本、单选、复选、下拉框等),这是实现用户交互的基础。

记住,HTML主要负责网页的骨架和结构。在接下来的课程中,我们将学习CSS来为这个骨架添加样式,以及使用JavaScript来让它动起来。

004:图像类型 🖼️

在本节课中,我们将要学习网页开发中常见的图像类型。我们将了解不同图像格式(如JPEG、PNG、GIF等)之间的核心区别,它们各自的优缺点,以及在实际开发中如何选择和使用它们。此外,我们还将探讨一种特殊的图像编码方式——Base64编码,并理解其在现代网页性能优化中的作用。

图像类型概述

互联网上充满了图像,它们通过在屏幕上渲染像素来向用户传递信息。你可能已经注意到,不同的图像文件扩展名,例如 .jpg.png.gif。这些不同的格式服务于不同的目的。

图像的两大类别:矢量图与位图

在深入了解具体格式之前,理解图像的两大核心类别至关重要:矢量图位图

  • 矢量图:这类图像由数学公式和指令构成,描述如何绘制形状、线条和颜色。它们可以无限缩放而不失真。SVG格式是网页中常见的矢量图格式。
  • 位图:这是我们今天讨论的重点。位图图像,也称为栅格图像,本质上是一个由彩色像素组成的二维网格。你可以将其想象成一个巨大的表格,每个单元格都填充了一种颜色。我们熟悉的JPEG、PNG、GIF和BMP都属于位图。

上一节我们介绍了图像的基本分类,本节中我们来看看几种具体的位图格式。

常见位图格式对比

以下是几种常见位图格式的简要对比:

格式 色彩深度 压缩方式 主要特点
BMP 可变 无压缩 无压缩的原始像素数据,文件体积大,现已较少使用。
GIF 8位 无损压缩 支持动画和透明色,但色彩范围有限(最多256色)。
JPEG 24位 有损压缩 适用于照片类图像,通过牺牲一些画质来大幅减小文件体积。
PNG 8/24位 无损压缩 支持透明通道(Alpha通道),画质无损,适用于图标、图形和需要精确显示的图像。

目前,网页开发中最常用的是JPEG和PNG格式。

深入理解JPEG与PNG

色彩深度与画质

“色彩深度”(如24位)指的是每个像素可以存储的颜色信息量。公式可以简单理解为:
可表示的颜色数量 ≈ 2^(色彩深度)

更高的色彩深度意味着更丰富的颜色渐变和更平滑的图像过渡,从而带来更高的视觉质量。JPEG和PNG-24都支持丰富的色彩。

核心区别:有损 vs 无损压缩

JPEG和PNG最根本的区别在于它们的压缩算法:

  • JPEG 使用有损压缩:为了显著减小文件大小,它在压缩过程中会丢弃一些被认为“不重要的”图像数据。这意味着每次编辑和保存JPEG文件都可能造成画质损失。它非常适合颜色和细节复杂的照片。
  • PNG 使用无损压缩:压缩过程不会丢失任何原始图像数据。解压后的图像与原始图像完全一致。这使得PNG非常适合需要精确还原的图形,如Logo、图标、截图或包含文字、线条的图片。

实际应用场景

  • 使用JPEG的场景:照片、渐变丰富的背景图、对文件大小敏感且画质轻微损失可接受的情况。
  • 使用PNG的场景:需要透明背景的图片、颜色对比强烈的图形、文本截图、图标以及需要多次编辑保存的原始素材。

Base64编码图像

现代网页包含大量图像,但浏览器对同一域名下的并发请求数量有限制(例如Chrome是6个)。为了优化页面加载性能,特别是对于大量小图标,Base64编码技术被广泛应用。

什么是Base64编码?

Base64是一种将二进制数据(如图像文件)编码成由ASCII字符组成的文本字符串的方法。编码后的字符串可以直接嵌入到HTML或CSS文件中。

传统方式 vs Base64方式

  • 传统方式:HTML文件包含 `` 标签,其 src 属性指向一个图像URL。浏览器需要先加载HTML,再根据URL发起额外的HTTP请求来获取图像。
    <img src="image.jpg" alt="示例">
    
  • Base64方式:图像数据被转换成Base64字符串,并直接内联在 src 属性中。浏览器在加载HTML文件时一次性获得了所有数据,无需额外请求。
    <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..." alt="内联图像">
    

优缺点

  • 优点:减少HTTP请求数量,提升小图片的加载速度,特别适合移动端或图标字体(Icon Font)的替代方案。
  • 缺点:编码后的文本体积比原二进制文件大约增大33%。它会增加HTML/CSS文件的大小,可能影响首屏加载时间,且不利于浏览器缓存单独的图像资源。

最佳实践:仅对非常小的(例如小于10KB)、需要与页面同时加载的、且不常更改的图片使用Base64编码。大图片仍然应该使用传统的URL引用方式。

总结

本节课中我们一起学习了网页开发中的图像知识。我们首先了解了矢量图与位图的区别,然后重点分析了JPEG、PNG、GIF等常见位图格式的特性,特别是JPEG的有损压缩与PNG的无损压缩这一核心区别。最后,我们探讨了Base64编码图像的原理及其在优化网页性能中的应用场景。掌握这些知识,将帮助你在实际项目中做出更合理的图像格式选择和技术决策。

005:HTML进阶标签 🐲

在本节课中,我们将学习一些在核心HTML基础课程中未涵盖的进阶标签。这些标签虽然不常用,但了解它们有助于我们认识到HTML功能的广度。

废弃标签与浏览器支持

上一节我们介绍了HTML的基础结构,本节中我们来看看一些特殊的标签。首先,我们需要理解“废弃”的概念。废弃标签是指那些不再被官方规范支持,但浏览器可能仍会兼容的标签。

例如,<center> 标签曾经非常流行,用于居中内容。虽然它已被废弃,但在许多现代浏览器中仍能工作。

<center><b>Hello</b></center>

浏览器(如Microsoft Edge)可能仍会支持它,但这并非规范要求。浏览器作为HTML规则的“编译器”,可以选择支持废弃功能,但并非必须。

代码与预格式化标签

接下来,我们探讨用于展示代码的标签。<code> 标签用于定义计算机代码片段。

<code>#include <stdio.h></code>

单独使用 <code> 标签通常不会产生显著的视觉效果,它主要是一个语义化标签。为了保持代码的原始格式(如空格和换行),我们通常将其与 <pre> 标签结合使用。

<pre> 标签代表“预格式化文本”。HTML默认会忽略多余的空格和换行,而 <pre> 标签会保留这些空白字符。

<pre>
    <code>
        #include <stdio.h>
        int main() {
            return 0;
        }
    </code>
</pre>

<code> 标签的另一个作用是默认以等宽字体显示内容。等宽字体确保每个字符宽度相同,类似于代码编辑器的显示效果,便于阅读。

以下是两者的核心区别:

  • <code>: 提供语义含义并使用等宽字体。
  • <pre>: 保留所有空白字符(空格、换行)并使用等宽字体。

将它们结合使用,既能清晰地表达“这是代码”,又能完美地呈现代码格式。

语义化标签

现在,我们进入语义化标签的部分。语义化标签本身不提供强烈的视觉样式,但它们为浏览器、搜索引擎和其他工具提供了关于页面结构的明确含义。

常见的语义化标签包括 <header><nav><footer>

<body>
    <header>这里是页头,可能包含Logo和导航</header>
    <nav>这里是主导航链接</nav>
    <main>这里是页面主要内容</main>
    <footer>这里是页脚,可能包含版权信息</footer>
</body>

使用语义化标签而非普通的 <div> 加注释,有两大好处:

  1. 对开发者友好: 使代码结构更清晰,易于理解和维护。
  2. 对机器友好: 帮助搜索引擎(如Google)和辅助技术(如屏幕阅读器)更好地理解页面内容,从而提升搜索排名和可访问性。

元数据标签

上一节我们了解了页面结构的语义,本节中我们来看看如何向外部描述页面本身。<meta> 标签用于定义HTML文档的元数据,这些信息不会显示在页面上,但至关重要。

<meta> 标签位于文档的 <head> 部分。

以下是几个关键用途:

  • 页面描述: 提供给搜索引擎的摘要。
    <meta name="description" content="关于前端编程的免费教程和参考资料。">
    
  • 关键词: 过去用于SEO,现在重要性已降低。
    <meta name="keywords" content="HTML, 教程, 前端">
    
  • 社交媒体预览: 控制链接在社交媒体(如Facebook、Twitter)上分享时的显示效果。
    <meta property="og:image" content="预览图片的URL">
    <meta property="og:title" content="分享时显示的标题">
    <meta property="og:description" content="分享时显示的描述">
    

其他实用标签

最后,我们快速浏览几个有特定用途的标签。

缩写标签 <abbr>
用于标记缩写,当用户鼠标悬停时会显示完整标题。

<abbr title="World Health Organization">WHO</abbr> 成立于1948年。

这不仅对普通用户有用,也能帮助屏幕阅读器等辅助工具更准确地朗读内容。

无脚本标签 <noscript>
用于定义当浏览器禁用JavaScript时显示的替代内容。

<noscript>
    <p>请启用JavaScript以正常使用本网站。</p>
</noscript>

如果JavaScript已启用,浏览器会忽略 <noscript> 内的内容。

嵌入标签 <embed>
用于嵌入外部内容,如PDF、Flash或媒体内容。一个常见的例子是嵌入YouTube视频。

<embed src="https://www.youtube.com/embed/视频ID" width="400" height="300">

请注意,<embed> 通常用于嵌入具体的媒体对象。若要在页面中嵌入整个其他网页,更常用的标签是 <iframe>

<iframe src="https://example.com" width="800" height="600"></iframe>

总结

本节课中我们一起学习了多种HTML进阶标签。我们了解了废弃标签的概念,掌握了使用 <code><pre> 展示代码的方法,认识了 <header><footer> 等语义化标签对结构和可访问性的重要性,探索了 <meta> 标签如何定义页面元数据,并简要介绍了 <abbr><noscript><embed> 等专用标签。

虽然这些标签在日常开发中可能不常被直接使用,但理解它们的存在和用途,能让我们更全面地认识HTML的生态系统,并在特定场景下做出更合适的选择。要探索更多标签,可以参考 W3Schools的HTML标签列表

006:CSS 规则 🌝

在本节课中,我们将要学习层叠样式表,也就是大家常听到的CSS。CSS是让网页变得美观的方式,它负责样式、美学、颜色和布局。正是CSS让网页真正“活”了起来,并与我们之前讨论的维基百科那种纯文本和图片的旧式网页形成鲜明对比。

在深入探讨CSS背后的一些理论和高级规则之前,我们首先来看一个非常简单的例子,了解CSS到底是什么。如果你之前没有接触过它,理解它如何与我们一直在使用的HTML结合是非常重要的。

CSS 是什么?

CSS本质上是构建在HTML之上的。这并不是说它们是同一种语言,但你几乎不会只用CSS而不使用HTML来构建网站。HTML就像是一切的基础,而CSS则像是一个附加组件,一种增强和扩展。这很合理,因为CSS是在HTML出现之后才被引入世界的。

下面是一个我们之前见过的相当简单的HTML文件。

<!DOCTYPE html>
<html>
<head>
    <title>我的网页</title>
</head>
<body>
    <p>这是一段文字。</p>
</body>
</html>

我们之前看到,我们可以做一些有趣的事情,比如给表格加边框,或者制作项目符号列表。但有时我们可能想做更有趣的事情,比如增大某些文本的字体大小。

在CSS被发明之前,我们曾经通过HTML标签内置的属性和值来设置样式。例如,我们可以这样做:

<span color="red">你好</span>

这会将span标签内的文本颜色设置为红色。这本身没有问题,效果也不错。但CSS的做法略有不同。

如何应用CSS?

使用CSS时,所有CSS都定义在style属性内部的双引号中。几乎每个HTML元素都有一个style属性,你可以在里面放入CSS代码。

<span style="color: red;">你好</span>

现在,当我打开这个页面时,我会看到红色的“你好”文本。我可以把它从红色改成蓝色:

<span style="color: blue;">你好</span>

你可以看到,CSS是由这些键值对组成的。color是键,blue是值。你只需将这些样式应用到许多HTML元素上,比如列表、标题、段落,甚至是那些无样式的标签。

引入CSS的两种主要方式

有两种主要方式可以将CSS引入你的网页。

第一种方式就是我们刚才所做的,创建一个元素(比如一个divspan),然后将CSS作为style属性的值放入。

第二种方式更为常见,也是你在作业中会用到的方式,那就是从另一个文件导入CSS。

例如,我有一个名为stuff.css的文件。我可以在里面这样写:

.blue-text {
    color: blue;
}

然后,我想把这个样式应用到我的页面上,我不再写style="color: blue",而是说class="blue-text"

class用于表示“我有一个描述样式的名称,我想应用这个名称”。这有点像函数、规则或变量名,是一种抽象。而style则是我们实际内联编写CSS时使用的,我们称之为内联CSS。

但是,当我这样做时,文本又变回了黑色。原因是这个HTML文件本身并不理解“blue-text”是什么。它不知道那是什么。

所以,如果你想在页面上导入特定的CSS,你实际上需要在<head>标签内添加一个链接:

<link rel="stylesheet" href="stuff.css">

现在,因为那个CSS文件被“复制”到了我的文件中,blue-text这个类就生效了。如果我把stuff.css文件里的颜色从蓝色改成红色,文本也会随之改变。

第三种方式是,你实际上可以直接在页面中编写CSS,而不需要导入。HTML中有一个<style>标签,浏览器会将这个标签内的任何内容视为CSS并进行处理。

一般来说,我们有这三种层级:我们首先做的内联样式;另一端的在另一个文件中定义的外部样式;以及一个中间地带,即样式仍然与你的HTML分开定义,但在同一个HTML文件中。

应该使用哪种方式?

作为一个通用规则,我总是建议将你的样式外部化,放在另一个文件中。唯一可能适合内联样式的情况是在开发或调试阶段。有时,如果你想让页面看起来是某种样子,或者在进行实验,直接在这里快速添加一个样式(比如font-size: 200%)会容易得多。这比查找类名、记住它在哪个文件、找到它、再添加样式要方便,尤其是当其他元素也在使用那个样式时。

所以,可能只有早期开发阶段值得使用内联样式,这是一个很好的实验方式。不要给自己压力,要求一开始就把所有代码完美地组织到外部样式表中。但通常,我们确实喜欢把所有东西都放在外部样式表中。

将样式外部化有几个好处。其中一个好处是你可以重用样式。另一个更技术性的好处是,当浏览器加载页面时,它实际上会缓存这些外部CSS文件。浏览器会存储这些文件的本地副本,并且在几天、一周或几小时内(取决于配置)不会请求新的副本。这很好,因为它加快了网站速度。这就是为什么当你第一次访问一个网站时,页面加载可能会有点慢,但正常刷新时就会快很多。

CSS的基本结构

我们已经有了简单的“你好”示例。正如在HTML讲座中提到的,为了让它们成为div,每个元素都变成了自己的块,这在布局中非常重要。但这就是导入CSS的要点。

现在,让我们看看能用CSS做什么。在定义方面,CSS的一切(这里不再讨论内联样式,而是讨论如何单独定义它)都是由选择器和属性-值对组成的。

选择器只是一种描述样式应用位置的方式,即定义规则所适用的元素。而属性和值就是键值对。

你已经在这里看到了演示,上面这部分是选择器(它应用的类名),然后在这里我可以添加属性,比如font-weightcolor: blue

.blue-text {
    color: blue;
    font-size: 16px;
}

你可以看到,所有内容都由这些键值对组成,并且都包含在一个选择器内。你的文件可以有许多选择器。你可以在这里创建另一个,比如red-text

.red-text {
    font-size: 200%;
    color: red;
}

然后我们可以让第二个div应用red-text类。你会看到它是这样分开的。

所以,CSS中的一切都是:选择器 { 属性: 值; }。这种方式实际上使CSS成为你可能编写过的最简单的东西之一,它甚至可能感觉不像编程。它是一种非常直接的标记语言。当然,它也有规则,这就是本讲座的内容。但从结构上讲,你已经了解了CSS的结构。

选择器详解

接下来我们要开始学习选择器。这占了本讲座的大部分内容,都是关于这些应用的规则。在未来的讲座中,我们将学习更多关于格式和布局的属性。

首先,我们有通用选择器。这是使用星号*的地方,它的意思是样式应用于页面上的所有元素。

例如,假设我想让页面上所有文本的字体大小都变为200%。我只需使用星号这个通用选择器。

* {
    font-size: 200%;
}

因为我已经把它放在那里了,我不需要再把它放在其他地方。你可以看到我的页面发生了变化,因为它应用于所有元素。同样,如果我把它改成橙色,你会看到文本现在变成了橙色。

你什么时候会使用它?其实并不常见。大多数时候是为了处理文本,因为有时你想在整个网页上应用相同的基本字体大小或字体类型(如Arial或Times New Roman)。但总的来说,它并不超级常用。

另一种实现类似效果但不是通过星号的方式是通过body标签选择器。这个略有不同,它只是说样式应用于最外层的body标签。但这并不总是能达到你期望的效果,因为当你使用body标签选择器时,你本质上是在说最外层的标签应该有一个属性;而通用选择器是说我希望所有东西都有一个属性。不过你不需要太担心,因为它并不超级常用。

另一种使用选择器的方式是通过类型选择器。我们在这里讨论的是不同级别的特异性。类型选择器是指你实际上不给它一个名字(就像我们之前用类名作为例子那样),而是直接引用一个特定的标签。

例如,引用一个div标签,或者一个链接标签<a>。假设我们有一个指向Google的链接。

<a href="https://www.google.com">Google</a>

我想让我所有的锚标签,我所有的链接标签都变成黄色。现在,我所有的链接都变成了黄色。在这里,我不需要给它一个样式名,也不需要给它一个类,但我使用了标签选择器。

标签选择器非常有用,特别是对于像段落标签<p>这样的元素。对于文本或图片,如果你想给每张图片加个边框,也很常用。我最常见到它们用于无序列表<ul>、列表项<li>、标题<h1>-<h6>等。这是一个非常常见的方式,因为有时你希望你的标题非常大或非常小。这就是标签选择器。

接下来是类选择器。这是我们一开始演示的那种。它本质上是一种为一组样式命名的方式,是经典的做法。就像我们在这里做的:.blue-text.red-text。点号.表示类。如果我直接写blue-text,浏览器引擎会尝试寻找一个名为bluetex的标签。这就是为什么我们需要点号,因为<a>标签、<h1>标签、<p>标签都是标签,点号是我们表示“这不是一个标签,这是一个类”的方式。类只是一组要应用的属性的名称。

然后,你只需通过选择一个特定元素并说这个元素的class属性等于这个值来应用它们,浏览器就知道去查找它。这个非常直接,我们已经看过了。

与类选择器非常相似的是ID选择器。ID选择器看起来非常相似,行为也超级相似。例如,我可以创建另一个叫green-text的,颜色为绿色,但不用点号,而是用井号#

点号和井号之间的区别非常非常小,但它们改变我们使用方式的地方在于,我们现在说div id="green-text"

井号#表示ID,点号.表示类。这就是我们将这些样式应用到页面的方式。那么它们之间有什么区别呢?

基本上,如果一个元素在页面上只存在一次,通常应该使用ID;如果它可能在页面上存在多次,则应该使用类。所以,ID是单一的,类是多个的。

从编程的角度来看,可能很常见的是把所有东西都做成类,也许有些是ID。所以不要觉得你必须不断地问自己“这应该是一个类吗?这应该是一个ID吗?”,只使用类是可以的。

但ID在某些情况下也很有用。例如,假设我后来创建了一个叫做header-blockdiv。它只存在一次,因为页面上只有一个标题块。这可能是一个我会给它一个ID的例子。

这也引出了另一点,就是关于CSS样式的命名约定,我们通常使用短横线-,而不是驼峰命名法或下划线。所以是header-block。为了更清晰,很多类名应该像blue-text这样。这种短横线语法非常常见。

这就是ID选择器。现在,你做的95%的事情都将是类和ID选择器,然后4%可能是标签选择器。所以我们已经涵盖了大部分内容。

接下来我们要继续讨论的很多东西通常是CSS中更晦涩的部分,它们确实很酷,肯定有一些用途。

下一个是属性选择器。属性选择器有时感觉像标签选择器,这取决于上下文。假设我们有几个单选按钮。

<input type="radio" name="choice" value="1"> 选项1
<input type="radio" name="choice" value="2"> 选项2

显然,如果你想样式化这些单选按钮,也许你想让它们变成特定大小。我可以给它们一个类,比如my-radio

但假设我不想到处使用类,因为我知道这将应用于我网站上的每一个单选按钮。我不想到处放类,但我不想只说所有的input都应该这么大,因为那会完全搞砸事情。如果我创建一个文本输入框,现在我的文本输入框看起来会很奇怪。

相反,我可以做的是使用属性选择器,它是一个带有额外内容的标签选择器。在这种情况下,如果我的type等于radio。你基本上把HTML属性-值对放在标签选择器后面的方括号里,现在这只应用于特定的一组东西。

input[type="radio"] {
    width: 100px;
    height: 100px;
}

有一些很酷的教程问题我们演示了这方面的例子,但这就是属性选择器的要点,有点意思。

组合器

组合器是应用CSS规则的方式,因为这些都是关于CSS选择器的。我们不是在讨论属性和其他东西。组合器是那些你完全可以避免的东西,你不需要知道它们,没有它们你也能解决所有问题。

但如果你学会了如何使用它们,你将能够编写更简洁、更强大、更容易的代码。

你可以看到这里我们有一系列divspan。有时你可能希望能够更具体地指定一些东西,比如我希望所有在A类里面的B类都有一个特定的样式,或者我希望所有在某个类里面的div都有一个特定的样式。

这可以是标签、通用选择器、类或ID,这不局限于其中一种,而是解释它们之间关系的方式。

例如,第一个是如果你把两个选择器放在一起,比如.A .B。这表示这些样式适用于所有在具有A类的元素内部的具有B类的元素。

如果你看左边的例子,看看所有在A类内部的B类元素,那就是这里的1、3、4、5、6。所以现在如果我们加载页面,所有在A类里面的B类,假设它们都是红色,你会看到我们有1、2、3、4、5。C类这里的2是黑色的,所以没有应用。这个超级有用。

显然,你可能会想,为什么我需要那样做?为什么我不能让所有B类都是红色?那样也行。答案是,因为有时你可能会在多个地方使用非常常见的名称。例如,假设你的input也有一个B类。也许B类是像redstrongprimarysecondarywarningdanger这类通用词的代名词。通常,B类可能到处都有样式,但然后你想在某个区域内的所有B类上添加额外的样式,这就是我们得到这些后代选择器的地方。

子选择器非常相似,因为它是一种继承关系,但子选择器只适用于那些是A的直接子元素的B。正如我们之前看到的,第一个.A .B适用于A内的所有B。这个只适用于父元素是A的B,这意味着这里的5不适用,6也从未计入,因为我们之前看到过。

下一个是相邻兄弟选择器。这基本上是说任何紧跟在C后面的B。

这个相当晦涩,说实话,我从来没有在实际项目中使用过它,更多只是为了玩玩。所以C + B,这意味着只有这个3有那个颜色。规则是任何与C相邻的B。

最后一个是一般兄弟选择器,即C之后的所有兄弟B。在这种情况下,那将使3和4变色。显然,这里的B不适用,因为它嵌套在里面;同样,这里的6B也不是兄弟,因为它嵌套在外面。兄弟是指在同一代码块中的东西,而不是更深层、更外层代码块中的东西。

这就是组合器。再次强调,前两个超级有趣,我总是觉得我应该更多地使用它们,所以我鼓励你使用。后两个我没有找到太多用例,但它让你感受到了CSS选择器的强大。

伪类

伪类是一种将属性应用于特定标签、类或ID当其处于特定状态的方式。这可能是一个新概念,因为状态大多是新的。

我们将把它们应用到几个元素上,我们可以练习一下。这也提醒我,我们在这里做的例子包含了很多关于类的东西,但东西不一定非得是类。例如,假设我们看这里,我们说所有在A类里面的B类。现在这会让我们回到之前看到的红色那个。但它们不一定总是类,有时也可以只是标签。例如,这里我不必说所有在A类里面的B类,我实际上可以说所有在div里面的B类,这通常也会应用,但你会看到它有一个有趣的副作用。

现在,之前没有被选中的5被选中了,因为你知道,它是一个div的直接子元素,因为在这种情况下,那个div没有类。但当我把它改成div时,突然就适用了。这也意味着,如果我把它改成span,那就不起作用了,因为在这个代码块里没有B类是span的直接子元素。

这非常非常有趣。现在你可能,你知道,不要从这个开始,比如一个span的B类,因为这实际上是完全不同的东西,那意味着一个span的B类。所以,如果你愿意,你甚至可以更具体。你可以说给我所有具有A类的div,然后只应用于那些是B类span的直接子元素。所以你可以这样连接它们。你可以这样连接标签选择器和类/ID选择器,比如span#Bspan.B,非常有趣。

回到伪类。伪类是那些你不经常使用的东西,但当你使用时,它们真的很有用。可能我见过或见过的最常见的伪类是:hover:disabled:focus:visited。我的意思是,所有这些都在某个地方被使用,只是使用频率的问题。

我们先做:visited。例如,如果我回到我的CSS文件,我实际上会添加一个新属性。我会给a标签选择器添加一个名为:visited的伪类,这是指如果你之前访问过那个链接,应用的样式。在这种情况下,如果我访问过这个指向Google的链接,那么它现在显示为紫色。

这并不意味着你曾经访问过Google,它意味着如果你点击过那个链接并访问过Google。这就是为什么当你回到网站时,有些链接颜色不同,这就是你知道哪些是你点击过的方式。或者你去Google,你之前搜索过一些东西,你可以看到你搜索过什么。有时我觉得这很有趣,当我想向某人展示一些东西,并试图让它看起来像是边做边想的时候,但然后你会看到有学生也会这样,你会想,哦,就像在讲课一样,那里什么都没有,但会有一个链接,显然之前被点击过,那是因为某个地方应用了:visited伪类。

同样,它不一定非得是标签,也可以是类。例如,purple-visited可以是这里的类名。所以,在我的a标签上,我可以说class="purple-visited",现在它将根据类来应用,不一定非得是标签。

我们也谈到了input。例如,我们这里有我们的文本输入框。让我们给它一个类名,比如name-field。我可能会用短横线让事情更清晰。所以,我们写.name-field,我有这个东西在底部,我应该直接用那个。

所以,我有我的name-field。假设颜色是蓝色。当我输入时,那是蓝色的,我可以预先填充一个值。但现在想象一下这个字段被禁用了,也许我希望字段在禁用时有不同的属性。例如,当它被禁用时,我希望它是红色的。

所以,如果我们移除disabled,它是蓝色的;如果加上disabled,它是红色的。这是伪类的另一个例子。

正如我之前所说,可能我遇到的最常见的伪类是:hover。让我们给这个链接应用一个:hover,但让链接变成绿色。所以,当你把鼠标移到它上面时,让我们看看是否能把它变成不同的颜色。如果我们说a:hover { color: green; },我刷新这个页面,你会注意到在这种情况下它没有变成绿色,可能是因为:visited覆盖了它。但如果我去掉那个,你会看到,当我的鼠标移到Google链接上时,它变成了绿色。真的非常有用,非常方便。你会经常看到它被用来去除下划线。例如,text-decoration: none;可以去除下划线。在这种情况下,把鼠标移到上面就会那样做。实际上,反过来用也很常见。在过去的10到15年里,有一个非常常见的趋势,你会看到现在大多数链接都没有下划线,直到你把鼠标移到上面,这才表明它是一个真正的链接。

还有其他一些伪类。:focus是当你点击一个输入框时,这在学习DOM的事件讲座时会更有意义。还有更多的伪类,如果你愿意,可以去玩玩。

伪元素

然后我们进入一个更晦涩的领域,那就是伪元素。伪元素是指你可以为元素或元素的特定部分创建装饰性内容。它们真的很神奇,我用得不多。

但你可以看到,例如,你可以使用这个让第一个字母变成红色。

这很有趣,因为想象一下你这里有一个段落,比如“你好吗?”。我真的很想念拍你的狗。我希望我们能吃泰国菜。我们这里有几个段落。我可能会在这种情况下使用p标签选择器,并说第一个字母是红色的,让我们看看这做了什么。

现在所有这些标签的第一个字母都变成了红色。显然,这不会应用于每一个HTML元素。你有像图片这样的元素,如果你把这种伪元素应用到选择器上,比如应用到图片上,它就会忽略它。所以这些东西并不是完全通用的,但你肯定可以在很多场景中应用它们。

还有另一个伪元素,比如::after。你注意到伪元素有两个冒号,如果那不明显的话。伪类有一个冒号,伪元素有两个冒号。这里有另一个,在点文本元素之后会显示一个星号。

非常有趣。所以这意味着,假设我想在段落末尾强制加上句号,因为这变得非常晦涩和有趣。就像我不想添加它们。我希望我所有的句号都很大,或者让我们玩一下。所以就像p::after。所以,紧接着之后,我希望有一个星号内容。让我们先试试那个。好的,现在有一个星号,让我们做我提到的句号。好的,但是,你知道,让我们试试。让我们试着让它真的很大。让字体大小变成200像素。好的,所以我们得到了一个非常大的……抱歉,不是200像素,是500%。所以在每个段落之后,现在都有一个五倍大的句号。这有什么用?我自己还没想出来,但再次强调,这里的重点是演示这些领域,知道这就是我们希望你思考的,因为以后当你看到这些双冒号的东西时,知道也许你有一个特定问题要解决,你去Google,你去Stack Overflow,你看到这些双冒号,你会想“那是什么?”,然后就像“哦,我记得那是一个伪元素”。我从来没有用过它们,海登听起来你也没怎么用过,但我很兴奋我可以用它们。所以,这真的是关于这个。

没有太多要讲的了,只剩下几张幻灯片。

层叠

我之前提到过这个。但总有一个问题,如果你定义了两个相同的东西会发生什么?假设我的代码中有两个.blue-text的定义,其中一个因为有人不知道蓝色是什么而说紫色。这两个“你好”会变成什么?它们会变成蓝色。如果我交换它们的顺序,它们会变成紫色。

这意味着,定义的CSS属性基本上是最近定义的那个。所以这些讲座幻灯片的其余部分真的集中在,就像你在学校学习运算顺序一样,哪个操作先进行。这有点像这里的优先级,我们在计算机科学和编程中经常讨论优先级,特别是运算。所以总是最后定义的那个获胜。

你可能会想,为什么世界上我会有两个.blue-text类?我想解决什么问题?答案是,当你构建非常大的网页时,你可能会有大量的CSS,有时跨越许多文件。有时你会导入别人制作的文件或库,他们定义了所有的blue-textred-textgreen-textbig-textsmall-text,你可能想重用相同的名称,或者你可能只是想覆盖它。只要你的定义是最新的,它就会应用。

另一个有趣的问题是,如果我把字体大小变得更大,会发生什么?你可以看到那似乎不起作用,但如果我把它移到这里,它应该起作用。那是因为当你定义第一个,然后在它之后定义第二个时,并不是第二个把第一个扔进垃圾桶然后完全替换它,它只替换已经定义的样式。这也是要记住的,它不会删除所有东西,只覆盖已经存在的东西。这就是当选择器相同时,层叠的工作方式。

不过,你更常见到的是继承。这真的需要注意,因为这很合理,因为通常你会有一个块,定义里面的所有东西都是红色的,然后在里面你可能会有另一个span,说里面的所有东西都是蓝色的,或者它没有特别说明什么。

CSS在这里的工作方式是,当你定义外部元素的某些属性时,它会继承到所有子属性中。这不是对每个属性值都成立,但对很多基本样式属性成立,比如颜色、大小等等。

例如,我想给这个特定的div添加另一个类。顺便说一下,我可以通过直接写在后面来添加另一个类。我称这个为bold。这就是你如何将两个类应用到同一个标签上的方式。所以如果我在这里说.bold { font-weight: bold; },它现在将应用于所有那些东西,因为所有这些span、这个div和这个span都继承了它。如果我想阻止这一点,我必须去显式地定义它。例如,我可能不得不说,你知道,所有在A类div里面的B类span,我希望字体粗细是正常的,也就是不加粗。你会看到那现在会取消其中一些的加粗。

现在你可能会想,好吧,现在我真的很困惑,因为海登之前说过,如果我们后来定义东西,它应该覆盖它,或者它应该先来。这只有在两个选择器完全相同时才成立。所以在这种情况下,这里的.bold和这个选择器是不同的。我们将在最后讨论特异性。但重点是,我提到的覆盖只在你处理具有同等“权力”的东西时才真正适用。我们稍后再谈这个,否则我们会感到困惑。

关键是,像颜色、粗细等属性会通过树结构继承,如果你想改变这一点,你必须明确说明。

特异性

我刚才谈到的特异性,那就是特异性。你会遇到的最棘手的事情之一就是,通常一个元素有多个具有完全相同属性的规则,而你不太确定哪一个应该适用。

我们已经在这里看到了那个例子,我把.bold移到这里,然后你想,嗯,哪一个应该适用?因为在某些方面,我说了bold类里面的所有东西都应该是加粗的,但这里我说了每一个是A类div直接子元素的B类span应该是不加粗的。所以为什么其中一个会胜出?本质上,你知道哪个有更多的“权力”,就像我之前提到的那样。

但这里有一个有点像公式的东西。首先,有一个通用的层次结构,这非常直接。标签的“权力”最低,然后是类,然后是ID,然后是内联样式。

一个非常简单的例子是,如果我在这里创建另一个div,或者我创建一个h3,ID是cat,类是dog,内联样式是color: red

现在我的标题是红色的,这说得通。如果我应用三个单独的样式:首先我说h3将是蓝色,然后我说任何ID为cat的东西将是绿色,然后我说任何类为dog的东西将是黄色。现在,内联样式总是获胜。

但是,如果我去掉内联样式,什么会获胜?将是ID,也就是绿色。如果我去掉ID,将是类,也就是黄色。如果我去掉类,将是标签。所以当涉及到这些冲突的CSS属性时,你会遵循这些优先规则。它总是:内联 > ID > 类 > 类型。

实际的规则更复杂一些。就像这个数学化的东西。我从来没有真正使用过它,但当我描述的东西属于不同类型时,它很有意义。当有标签定义、ID定义和类定义等时,我刚才描述的很有意义。但当有两个类定义时,事情就不那么明显了。

一个好的例子是,假设我有我的标题,但有dogmouse两个类。然后在我的样式中,我不仅有.dog,还有.mouse.mouse的颜色是紫色。现在在这种情况下,嗯,让我们去掉这个内联样式red,因为它覆盖了一切。内联样式总是获胜。现在,这是绿色的。为什么是绿色的?实际上不太合理,哦,我做了,抱歉,当然。我很困惑。所以,你知道,就像这里哪一个应该获胜?在这种情况下,紫色会获胜,因为层叠。现在它们相等了,所以你可以看到这是如何循环回来的。有这些特异性规则帮助你决定哪个应该有更多的“权威”,但当两个东西具有同等权威时,总是最后定义的那个获胜。

现在,我们通过这些规则幻灯片和这个有点数字化的东西要说明的是,如果你确实遇到这些冲突,获胜的通常是选择器最多的那个。

所以这是另一种格式,你在这里看到的是,color: red应该应用于所有具有ID id、类class1和类class2div

类似地,在这里,如果一个是dog是黄色,mouse是紫色,如果我说任何同时具有dogmouse类的东西是紫色,那么显然那个会获胜。让我把它移到上面来。任何是dogmouse的东西会先出现,因为当你有那种直接冲突时,就像,好吧,我有两个类定义,我知道类比ID不重要,ID比内联不重要,但比标签重要。那么,哪一个具有最多的类特异性?这很有趣。

正如你从定义中看到的,它基本上是说哪个数字更大。在这种情况下,它们都没有内联样式。在这种情况下,它们都有一个ID选择器,但其中一个有两个类。

这可能会变得非常理论化。但让我们想象一下我们的h3确实有一个ID cat,让我们去掉cat的样式。这里说的是,你知道,像这样把cat放在这里不会改变任何事情。事实上,如果我不改变任何东西,但如果我在dog mouse那个上去掉cat,那么紫色获胜,因为如果我们回到特异性规则,你会看到ID选择器更高。如果你要计算这个的值,会有一个ID和一个mouse,使它变成0,1,1,0,就像这里的例子一样。但dog mouse是0,0,2,0,类似于这里的。所以这个会是0,0,2,0,而这个会是0,1,1,0。

我不想让你觉得CSS超级技术化或数学化。我从来没有想过这个,我编码时从来不会想“特异性规则”,因为你不会经常遇到。但为什么这是一个有用的练习呢?因为作为计算机科学家,我们在这里是为了理解事物如何工作。这就是它的工作原理,现在它就有意义了。所以,即使你可能永远不会在脑子里做那种数学计算(虽然不难),你永远不会在脑子里求和,但至少你现在理解了它的底层机制,这非常重要。

这把我们带到了关于特异性的最后一部分,那就是!important规则。

!important 规则

这个是一个有趣的小东西,它有点像一种反模式,就像一个黑客。它真的会把事情搞乱,但就像,你知道,我不确定,它就像一把火焰喷射器。我敢肯定有时候它真的很有用,但这并不意味着你应该拥有它。它让你很难弄清楚。让我解释一下。

!important本质上是一种覆盖所有特异性规则的方式。这对于黑客手段可能有用,但对于编写干净的代码可能非常糟糕。我的意思是,假设我们看像这样的东西。我们之前有所有这些关于h3的东西,我们有内联样式,color: red。显然,内联样式会获胜。假设我现在去把我的一个属性加上!important,它会去覆盖特异性链中的所有其他东西。所以你会看到现在这是蓝色的,它覆盖了ID、类、内联样式。这就像是一个全新的层级。所以你可以想象,你这里有所有这些决定事物去向的规则,一旦你放了一个带!important的东西,它突然就比所有都重要了。然后你可能会问,如果我在多个地方放!important会怎样?那么你基本上又回到了旧的特异性规则,就像如果我在这里放!important,那么现在它突然变成了紫色。

如果你想要一个快速的解释,就像,你基本上有从最不重要到最重要的顺序:标签、类、ID、内联,然后你有标签!important、类!important、ID!important、内联!important。所以你可以这样想。这就是为什么!important有点烦人,因为你可以使用它,它解决了一些问题,因为你不需要找出它在哪里或记住所有这些规则,有时它是必要的,但它只是让事情复杂化,因为现在突然一切都乱套了。如果你想覆盖它,你必须弄清楚如何在特异性链中更上层的地方让它变得!important

这就是为什么你会看到讲座幻灯片基本上只是说:谨慎使用,最好避免。它很难调试,也很难维护代码。但在紧要关头,你知道,火焰喷射器。

总结

这就是所有内容。快速回顾一下,我们在这里学到的大部分内容是:我们学习了一点CSS的基本结构,如何导入它,然后我们学习了很多关于选择器的知识:类型选择器、类选择器、ID选择器、属性选择器、组合器、伪类和伪元素。然后我们谈了一点关于如何解决冲突:当两个东西具有同等“权力”或权威时的层叠,属性如何向下传递的继承,不同事物如何相互优先的特异性规则,以及那个破坏一切但有时有用的火焰喷射器!important

希望这很有用。谢谢。

007:CSS 格式化 🌈

在本节课中,我们将学习如何使用 CSS 来格式化网页元素,使其在视觉上更具吸引力和动态感。我们将重点探讨文本和背景图像的样式设置,这是创建美观网页的基础。


文本格式化 ✍️

上一节我们介绍了 CSS 选择器的基本规则。本节中,我们来看看如何具体地设置文本的样式。文本是网页中最具可塑性的元素之一,我们可以调整其颜色、大小、字体等多种属性。

以下是一些常用的文本 CSS 属性及其示例:

  • color: 设置文本颜色。
    color: red;
    
  • font-weight: 设置字体粗细,例如加粗。
    font-weight: bold;
    
  • font-family: 设置字体家族,如 Arial。
    font-family: Arial;
    
  • font-size: 设置字体大小。推荐使用 em 单位,1em 是标准大小。
    font-size: 1.5em; /* 比标准大50% */
    
  • font-style: 设置字体样式,如斜体。
    font-style: italic;
    
  • text-decoration: 设置文本装饰,如下划线。
    text-decoration: underline;
    
  • line-height: 设置行高,控制文本行之间的间距。
    line-height: 2; /* 双倍行高 */
    
  • letter-spacing: 设置字符间距,可以为正值或负值。
    letter-spacing: 3px; /* 每个字符间增加3像素间距 */
    


背景图像 🖼️

除了文本,我们还可以为元素(如 div)设置背景样式,使其更加生动。接下来,我们学习如何设置背景图像。

首先,我们可以使用 background-color 设置纯色背景:

background-color: blue;

更常用的是使用 background-image 属性来设置图片背景:

background-image: url('图片链接');

设置背景图像后,通常需要配合其他属性来控制其显示效果:

  • background-size: 控制背景图像的尺寸。可以使用具体像素值,或关键字 contain(缩放图像以完全装入元素)和 cover(缩放图像以完全覆盖元素,可能裁剪)。
    background-size: 400px 300px;
    background-size: contain;
    
  • background-repeat: 控制背景图像是否及如何重复。
    background-repeat: no-repeat; /* 不重复 */
    background-repeat: repeat-x; /* 水平重复 */
    
  • background-position: 设置背景图像的起始位置。可以使用关键词(如 top center)、百分比或像素值。
    background-position: 10px 10px; /* 向右10px,向下10px */
    background-position: center;
    

这些属性可以分开写,也可以使用 background 这个复合属性(shorthand property)来简写。复合属性有特定的书写顺序,例如:

background: url('图片链接') no-repeat center / cover;


其他常用格式化属性 🎨

CSS 提供了丰富的格式化属性,以下是另外几个非常实用的例子:

  • 边框 (border): 可以为元素添加边框。同样有复合写法。
    /* 分开写法 */
    border-width: 1px;
    border-style: solid;
    border-color: black;
    
    /* 复合写法 */
    border: 3px dotted black;
    
  • 盒子阴影 (box-shadow): 为元素添加阴影效果。参数依次为:水平偏移、垂直偏移、模糊半径、颜色。
    box-shadow: 10px 10px 5px rgba(0,0,0,0.5);
    
  • 文本阴影 (text-shadow): 为文本添加阴影效果,原理与 box-shadow 类似。
    text-shadow: 2px 2px 4px #ff0000;
    

颜色的表示方法 🎨

之前我们使用颜色名称(如 red, blue)来设置颜色。CSS 还支持其他更精确的颜色表示方法:

  • RGB: 通过红、绿、蓝三原色的强度(0-255)来定义颜色。
    color: rgb(200, 100, 0); /* 一种橙黄色 */
    
  • 十六进制 (Hex): 这是 RGB 值的十六进制表示法,更为常见。
    color: #c86400; /* 与 rgb(200, 100, 0) 相同 */
    

在实际开发中,我们不需要手动计算这些值。可以使用在线的颜色选择器 (Color Picker) 工具来直观地选取颜色并获取对应的代码。


总结 📚

本节课中我们一起学习了 CSS 格式化的核心知识。我们掌握了如何通过一系列属性来美化文本,包括颜色、字体、间距等。我们也学会了如何为元素设置背景图像,并控制其大小、位置和重复方式。此外,我们还了解了边框、阴影效果的设置,以及 RGB 和十六进制两种颜色表示法。

CSS 的格式化属性非常丰富,关键在于多实践、多查阅文档。通过组合使用这些属性,你可以创造出无限多样的视觉效果,让你的网页脱颖而出。

008:CSS布局基础

在本节课中,我们将学习CSS布局的核心概念,包括盒模型、元素的显示类型以及几种传统的页面布局方法。掌握这些基础知识是构建复杂网页结构的第一步。

盒模型 📦

上一节我们介绍了CSS的基本格式化,本节中我们来看看网页布局的基石——盒模型。

网页上的每个<div>元素(以及许多其他元素)都遵循盒模型。盒模型是一个简单的概念:每个元素都被视为一个矩形盒子,这个盒子由内到外依次是内容内边距边框外边距

  • 内容:元素的实际内容,如文本或图片,具有宽度和高度。
  • 内边距:内容与边框之间的透明区域。
  • 边框:围绕在内边距外部的边界线。
  • 外边距:边框外部的透明区域,用于分隔其他元素。

我们可以通过CSS来设置这些属性。例如,创建一个名为.box<div>

.box {
  width: 200px;
  height: 200px;
  background-color: blue;
  color: white;
  border: 1px solid yellow;
  padding: 10px;
  margin: 20px;
}

  • widthheight定义了内容区域的尺寸。
  • background-color会覆盖内容和内边距区域,但不会覆盖外边距。
  • border在元素周围创建可见的边界。
  • padding在内容和边框之间增加空间。
  • margin在元素外部创建空间,用于与其他元素隔开。

内边距和外边距的主要区别在于:内边距是元素内部空间,背景色会覆盖它;外边距是元素外部空间,用于控制元素与其他元素的距离。

块级与行内元素 🧱

理解了盒模型后,我们需要知道并非所有元素都以相同方式参与布局。这主要取决于元素的display属性。

<div>默认是一个块级元素。块级元素的行为特点是:

  • 总是从新的一行开始。
  • 默认会占据其父容器100%的宽度(即使设置了固定宽度,剩余空间也会被外边距自动填充)。

<span>默认是一个行内元素。行内元素的行为特点是:

  • 不会从新行开始,会与其他行内元素并排显示。
  • widthheight属性不敏感,其尺寸由内容决定。

我们可以通过CSS的display属性来改变元素的默认行为:

  • display: block;:将元素变为块级元素(如将<span>变为块级)。
  • display: inline;:将元素变为行内元素(如将<div>变为行内)。
  • display: inline-block;:一种混合模式。元素像行内元素一样并排显示,但同时可以设置widthheight,非常实用。

此外,display还有两个特殊值用于控制元素可见性:

  • display: none;:完全从页面布局中移除元素,就像它不存在一样。
  • visibility: hidden;:元素仍占据布局空间,只是不可见。相比之下,display: none;更常用。

传统布局方法:浮动 🎈

现在我们已经掌握了布局的基本组件,本节中我们来看看几种传统的页面布局方法。首先是浮动

float属性是一个较老的布局技术,现在已不推荐广泛使用,但在某些特定场景或旧代码中仍可能遇到。

float属性允许你将一个元素向左或向右移动,使其脱离正常的文档流,并允许后续的行内内容(如文本)环绕它。

.floater {
  width: 50px;
  height: 50px;
  background-color: purple;
  float: left;
  margin: 10px;
}

浮动元素的怪异行为在于:它只会影响后面的行内内容(使其环绕),但不会影响后面的块级元素。为了清除浮动对后续块级元素的影响,可以使用clear属性:

.cleared-box {
  clear: both; /* 也可以是 left 或 right */
}

浮动在过去常被用来创建多栏布局,例如一个侧边栏加一个主内容区。但这种方法计算复杂且不灵活,现代布局技术已提供了更好的解决方案。

传统布局方法:定位 🎯

另一种更强大且至今仍有用的布局技术是定位position属性有四个主要值:staticrelativeabsolutefixed

  • static:默认值。元素处于正常的文档流中。
  • relative:元素先被放置在正常文档流中,然后可以使用toprightbottomleft属性相对于其原始位置进行偏移。
    .relative-box {
      position: relative;
      top: 10px;
      left: 20px;
    }
    
  • absolute:元素被移出正常文档流。其位置相对于最近的、非static定位的祖先元素进行偏移。如果没有这样的祖先,则相对于整个文档(<body>)。
    .parent {
      position: relative; /* 为绝对定位的子元素提供参照 */
    }
    .absolute-box {
      position: absolute;
      bottom: 10px;
      right: 10px;
    }
    
    position: absolute;非常实用,常用于创建对话框、图标角标或精确放置在特定位置的元素。
  • fixed:元素被移出正常文档流,但其位置是相对于浏览器视口固定的。即使页面滚动,它也会停留在屏幕的同一位置。常用于导航栏、悬浮按钮或弹窗。
    .fixed-popup {
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%); /* 一种居中技巧 */
      width: 300px;
      background: black;
      color: white;
    }
    

其他布局相关技巧 🛠️

最后,我们补充几个与布局密切相关的实用CSS技巧。

居中一个块级元素:这是一个非常常见的需求。可以通过将左右外边距设置为auto来实现。

.centered-div {
  width: 300px;
  margin: 0 auto; /* 上下外边距为0,左右自动平均分配 */
}

处理内容溢出:当元素内容超出其指定大小时,overflow属性决定了如何处理。

.overflow-box {
  height: 100px;
  overflow: hidden; /* 隐藏超出的内容 */
  /* overflow: scroll; 添加滚动条 */
  /* overflow: auto; 仅在需要时添加滚动条 */
}

  • visible:默认值,内容会渲染到盒子外面。
  • hidden:超出的内容被裁剪,不可见。
  • scroll:始终显示滚动条。
  • auto:由浏览器决定,仅在内容溢出时显示滚动条。


本节课中我们一起学习了CSS布局的基础知识。我们从盒模型开始,理解了每个元素的构成。然后探讨了块级与行内元素的根本区别。接着,我们介绍了两种传统布局方法:略显过时但需了解的浮动,以及依然强大实用的定位relativeabsolutefixed)。最后,我们学习了一些实用技巧,如元素居中处理溢出内容

布局是CSS中最具挑战性的部分之一,需要不断练习和积累。当遇到困难时,多参考在线示例和文档是非常有效的方法。在接下来的课程中,我们将学习更现代的布局工具,如Flexbox和Grid。

009:CSS Flexbox 布局指南 🧩

在本节课中,我们将要学习 CSS Flexbox 布局。Flexbox 是一种现代的网页布局方式,旨在创建响应式、动态的网页结构,它能轻松处理不同尺寸的屏幕和内容变化。

为什么需要 Flexbox?

在传统布局中,若要将多个元素(例如三个盒子)水平排列并均匀分布,可能会遇到困难。例如,使用 display: inline-block 并设置 width: 33% 时,元素间的空白和边距会导致布局错乱,无法精确计算总宽度。

Flexbox 正是为了解决这类布局难题而设计的。它通过一个容器(父元素)来管理其内部项目(子元素)的排列方式,使得创建复杂的响应式布局变得非常简单。

Flexbox 的核心概念

Flexbox 布局主要涉及两个部分:容器(Container)和项目(Items)。所有 Flexbox 属性都应用于这两者之一。

1. 创建 Flex 容器

要使用 Flexbox,首先需要将一个元素设置为 Flex 容器。这是通过给该元素的 CSS 添加 display: flex; 属性来实现的。

.container {
  display: flex;
}

一旦容器被设置为 flex,其直接子元素就会自动成为 Flex 项目,并按照 Flexbox 的规则进行排列。

2. 控制排列方向:flex-direction

flex-direction 属性定义了 Flex 项目在容器内的排列方向(主轴方向)。它是一个应用于容器的属性。

以下是 flex-direction 的主要取值:

  • row(默认值):项目从左到右水平排列。
  • row-reverse:项目从右到左水平排列。
  • column:项目从上到下垂直排列。
  • column-reverse:项目从下到上垂直排列。

通过改变 flex-direction,你可以轻松地重新排列内容的流向。

3. 主轴对齐:justify-content

justify-content 属性定义了 Flex 项目在主轴(由 flex-direction 定义的方向)上的对齐方式。它也是一个容器属性。

以下是 justify-content 的常用取值:

  • flex-start(默认):项目向主轴起点对齐。
  • flex-end:项目向主轴终点对齐。
  • center:项目在主轴居中对齐。
  • space-between:项目均匀分布,第一个在起点,最后一个在终点,项目间间隔相等。
  • space-around:项目均匀分布,每个项目两侧的间隔相等,项目之间的间隔是项目与边框间隔的两倍。
  • space-evenly:项目均匀分布,所有间隔(项目之间、项目与边框)完全相等。

这个属性让你可以精细控制项目在水平(或垂直)方向上的分布。

4. 交叉轴对齐:align-items

align-items 属性定义了 Flex 项目在交叉轴(与主轴垂直的方向)上的对齐方式。它同样应用于容器。

以下是 align-items 的常用取值:

  • stretch(默认):如果项目未设置高度,它将拉伸以填满容器高度。
  • flex-start:项目向交叉轴起点对齐(顶部)。
  • flex-end:项目向交叉轴终点对齐(底部)。
  • center:项目在交叉轴居中对齐。
  • baseline:项目按它们的基线对齐。

为了让 align-items 生效,通常需要为容器设置一个明确的高度。

5. 处理溢出:flex-wrap

默认情况下,所有 Flex 项目都会尝试排在一行(或一列)上。flex-wrap 属性定义了当一行(列)空间不足时,项目是否换行以及如何换行。

以下是 flex-wrap 的取值:

  • nowrap(默认):不换行。
  • wrap:当空间不足时,项目换行到下一行(列)。
  • wrap-reverse:换行,但顺序相反。

当使用 flex-wrap: wrap; 后,你可以使用 align-content 属性来控制多行/多列在交叉轴上的对齐方式,其取值与 justify-content 类似。

项目(子元素)的属性

除了容器属性,Flex 项目本身也有一些重要属性。

1. 分配剩余空间:flex

flex 属性是 flex-growflex-shrinkflex-basis 的简写。它定义了项目如何分配容器中的剩余空间。

一个最常见的用法是 flex: 1;。这表示该项目会“增长”以填充可用空间。如果所有项目都设置为 flex: 1;,它们将等分空间。如果一个项目设置为 flex: 2;,而其他为 flex: 1;,那么前者占据的空间将是后者的两倍。

你也可以为某个项目设置固定宽度(如 width: 50px;),Flexbox 会智能地处理,让固定尺寸的项目保持大小,而其他具有 flex 属性的项目按比例分配剩余空间。

2. 调整顺序:order

order 属性可以改变 Flex 项目的显示顺序,而无需修改 HTML 结构。默认值为 0。数值越小,排列越靠前。

实践与学习资源

Flexbox 功能强大但选项较多,最佳学习方式是动手实践。你可以通过修改在线编辑器的代码来观察不同属性的效果。

此外,推荐两个优秀的学习资源:

  1. CSS-Tricks 的 Flexbox 指南:这是一个非常全面的属性参考网站,清晰地列出了容器和项目的所有属性。
  2. Flexbox Froggy 游戏:一个互动式学习游戏,通过解决谜题来掌握 Flexbox 的各种属性,寓教于乐。

总结

本节课我们一起学习了 CSS Flexbox 布局的核心知识。我们了解到 Flexbox 通过 display: flex 创建一个容器,并通过 flex-directionjustify-contentalign-items 等容器属性来控制项目的排列、对齐与分布。同时,项目自身的 flexorder 属性提供了更精细的空间分配和顺序控制能力。

Flexbox 是构建现代、响应式网页布局的基石工具。虽然初学时有较多概念,但通过不断练习和查阅文档,你将能熟练运用它来创建各种复杂的页面结构。

010:SVG 入门教程 🎨

在本节课中,我们将要学习 SVG(可缩放矢量图形)的基础知识。我们将了解什么是 SVG,它与传统图像格式的区别,并学习如何手动创建和操作 SVG 图形。


什么是 SVG?

SVG 代表可缩放矢量图形。它是一种图形格式,类似于 JPEG 和 PNG。但 SVG 具有某些特性,使其在处理网页图形时特别有用。

在前端开发中,SVG 非常常见。例如,在第一个作业中,你收到的图标文件就是 SVG 格式。


栅格图形与矢量图形

在深入了解 SVG 之前,我们首先需要区分不同类型的媒体。媒体通常分为栅格图形和矢量图形。

栅格图形由网格上的像素组成,每个像素具有特定颜色,像素组合在一起形成图像。栅格图形具有分辨率,即它们包含的像素数量。这意味着它们有固定数量的像素,因此放大时会损失质量。例如,屏幕上的原始栅格图形清晰锐利,但放大后眼睛部分会变得模糊。

另一方面,矢量图形不包含任何像素。它们使用数学公式来显示图像。由于不依赖像素,矢量图形可以无限放大而不会损失质量。例如,放大矢量图形的眼睛部分,它仍然像缩小视图时一样清晰。

常见的栅格图形文件类型包括 PNG、JPEG 或 GIF。常见的矢量图形文件类型包括 PDF、AI(Adobe Illustrator)以及 SVG。


SVG 的构成

SVG 本质上基于几何学。在几何学中,向量是具有大小和方向的量。

处理 SVG 时,我们实际上是在一个坐标系中工作。我们使用的坐标系是左上角系统,这意味着原点位于左上角,所有坐标都表示为相对于该系统的 XY 坐标。

SVG 元素通常具有特定的尺寸。例如,一个 SVG 元素的宽度和高度为 100,意味着它在屏幕上将是 100 x 100 像素。

SVG 的另一个重要概念是视图框。我们可以将视图框视为 SVG 的可见区域。如果 SVG 的内容是某种场景,那么视图框就是我们观看该场景的窗口。视图框是有边界的,我们通过定义其原点(0,0)、宽度和高度来设置它。

视图框内的任何内容都会被渲染,而视图框外的任何内容都会被裁剪掉。重要的是,视图框的坐标和单位与定义 SVG 元素本身时使用的单位不同。我们可以将这些坐标视为 SVG 的内部单位,而 SVG 元素的尺寸是外部单位。尽管目前它们看起来相同,但稍后我们会看到它们如何不同。

SVG 的核心是其内容本身。SVG 由路径和矢量形状组成,这些是构成 SVG 并让我们看到图形的元素。

例如,一个笑脸图形可以由几个不同的元素组成:一个大圆作为背景,两个椭圆作为眼睛,以及一个由路径定义的形状作为嘴巴。


SVG 内置形状

现在,我们来看看不同类型的 SVG 内置形状。

线条

线条由两个端点组成,每个端点都有 X 和 Y 坐标。线条接受参数 x1y1x2y2,分别对应其端点。线条还需要指定描边颜色,否则在屏幕上不可见。

代码示例:

<line x1="20" y1="20" x2="80" y2="80" stroke="gray" />

圆形

圆形由一个中心点和一个半径组成。由于圆形是闭合形状(与开放的线条不同),它还具有填充颜色属性。填充指的是内部填充的颜色,描边指的是外部轮廓的颜色。

代码示例:

<circle cx="50" cy="50" r="40" fill="yellow" />

椭圆

椭圆与圆形非常相似,但它有两个半径:一个用于 X 轴,一个用于 Y 轴。中心点相同。

代码示例:

<ellipse cx="50" cy="50" rx="20" ry="40" fill="yellow" />

矩形

矩形稍微复杂一些,但也不难理解。矩形接受一个 X 和 Y 坐标作为原点,以及宽度和高度尺寸,这定义了矩形的宽度和深度。我们还可以通过指定 X 和 Y 半径值来定义矩形角的圆度。

代码示例:

<rect x="20" y="30" width="60" height="40" fill="yellow" />
<rect x="20" y="30" width="60" height="40" rx="5" fill="yellow" />

SVG 路径

SVG 路径是 SVG 强大功能的核心。使用内置形状时,我们能创建的图形类型有限。虽然仅用形状也能创造出许多不同的图形,但矩形只能产生矩形,圆形只能产生圆形。我们需要创造性地组合它们来形成更大的图像。而使用路径,我们可以定义任何类型的通用形状。

路径本质上是一种自由形式的绘图。你可以将路径想象成一组命令,如果你给某人一支铅笔和一张纸,并给他们这个路径,他们就能准确地知道绘图应该是什么样子,因为命令会指定他们需要在哪些坐标绘制,以及绘图的曲率应该如何。

路径命令由字母和数字组成。字母对应不同的命令类型,数字对应我们给命令的不同参数。

以下是主要的 SVG 路径命令:

  • M 代表移动到。该命令表示,无论我们当前在路径中的哪个位置,现在要跳转到另一个位置。
  • L 代表画线到。该命令表示,无论我们当前在路径中的哪个位置,要画一条直线到另一个位置。
  • HV 分别代表水平画线到和垂直画线到。它们与画线到类似,但更具体,表示我们想专门画一条水平线或垂直线到另一个点。
  • 还有一系列曲线命令。
  • A 代表弧线。弧线对于绘制不同类型的曲线图形特别有用。
  • Z 是一个特殊命令,表示我们想要闭合路径。如果我们已经绘制了一条路径,使用 Z 命令意味着要从当前路径位置画一条直线回到路径的起点。

路径命令可以使用绝对坐标或相对坐标。我们使用大写字母表示绝对命令,使用小写字母表示相对命令。绝对命令意味着无论我们在哪里,都将到达指定的坐标点。相对命令意味着我们指定相对于当前位置的值。

路径示例(绝对坐标):

<path d="M 0 0 L 100 100 L 0 100 Z" />

这个路径从点 (0,0) 开始,画一条线到 (100,100),再画一条线到 (0,100),最后闭合路径回到起点。

路径示例(相对坐标):

<path d="M 0 0 l 100 100 l -100 0 Z" />

这个路径从点 (0,0) 开始,相对移动 (100,100) 到新点,再相对移动 (-100,0),最后闭合路径。


实践:绘制笑脸表情符号

现在,我们将综合运用形状和路径来绘制一个笑脸表情符号。

首先,我们需要绘制表情符号的头部,即黄色的圆形。我们希望圆形在视图框中居中。由于视图框尺寸为 100x100 且原点在 (0,0),圆形的中心将是 (50,50),半径设为 50 以延伸到视图框边缘。

代码:

<circle cx="50" cy="50" r="50" fill="yellow" />

接下来,绘制眼睛。眼睛本质上是椭圆形的。我们将使用椭圆来创建眼睛。左眼的中心坐标是 (35, 40),X 半径为 5,Y 半径为 7,这样在 X 轴上会更瘦一些,并填充为黑色。右眼相同,只需调整 X 坐标使其更靠右,例如 (65, 40)。

代码:

<ellipse cx="35" cy="40" rx="5" ry="7" fill="black" />
<ellipse cx="65" cy="40" rx="5" ry="7" fill="black" />

最后,绘制嘴巴。嘴巴不是内置形状,因此需要使用路径。我们将从左侧坐标 (25,60) 开始,画一条水平线到右侧坐标,然后画一条弧线连接回左侧角。

对于弧线命令,我们主要关心前两个参数(X 和 Y 半径)以及最后两个参数(弧线的终点)。我们将使用相对坐标来定义路径。

代码:

<path d="M 25 60 l 50 0 a 10 8 0 0 1 -50 0 Z" fill="black" />

这样,我们就完成了一个完整的笑脸表情符号。我们还可以通过稍微调整路径来改变表情,例如将弧线标志翻转,使其沿逆时针方向绘制,并调整起始点的高度,就可以得到一个悲伤的脸。

悲伤脸路径示例:

<path d="M 25 75 l 50 0 a 10 8 0 0 0 -50 0 Z" fill="black" />

总结

在本节课中,我们一起学习了 SVG 的基础知识。我们了解了 SVG 是什么,它与栅格图形的区别,以及 SVG 的基本构成,包括坐标系、视图框和内容。我们探索了 SVG 的内置形状,如线条、圆形、椭圆和矩形,并学习了如何使用它们。更重要的是,我们深入了解了强大的 SVG 路径,它允许我们定义任何自由形状。最后,我们通过组合形状和路径亲手创建了一个笑脸表情符号,并看到了如何通过简单修改路径来改变其表情。

希望这节课能让你体会到 SVG 的强大和实用性。祝你设计愉快!

011:CSS字体 🌝

在本节课中,我们将学习CSS字体的相关知识,特别是字体族和字体大小的使用。我们将了解如何指定系统字体、如何引入并使用自定义字体,以及如何选择合适的字体大小单位。

字体族

上一节我们介绍了CSS的基础,本节中我们来看看如何设置字体族。字体族定义了文本使用的字体。默认情况下,浏览器会使用用户操作系统上安装的字体。

使用系统字体

你可以通过 font-family 属性指定一个或多个字体。如果字体名称包含空格,通常需要用引号包裹。

span {
  font-family: "Arial", sans-serif;
}

使用系统字体时,必须确保该字体在大多数用户的设备上都有安装。否则,浏览器会回退到默认字体(如Times New Roman)。

引入自定义字体

如果你希望使用特定的、用户设备上可能没有的字体,你需要将该字体文件与你的网页一起提供。这可以通过 @font-face 规则实现。

以下是引入并使用自定义字体的步骤:

  1. 获取字体文件:从Google Fonts等网站下载你喜欢的字体(通常是 .ttf.woff 格式)。
  2. 将字体文件放入项目文件夹
  3. 使用 @font-face 规则定义字体:在CSS中,你需要告诉浏览器这个新字体的名称和文件位置。
  4. font-family 属性中使用该字体
/* 步骤3:定义自定义字体 */
@font-face {
  font-family: "MyCustomFont"; /* 为字体起一个名字 */
  src: url("Babylonica-Regular.ttf"); /* 指向字体文件 */
}

/* 步骤4:使用自定义字体 */
h1 {
  font-family: "MyCustomFont", cursive;
}

这样做的好处是,无论用户设备上是否安装了该字体,网页都能正确显示,同时也有利于搜索引擎优化和页面加载性能。

字体大小

接下来,我们探讨如何设置字体大小。CSS提供了多种单位来定义尺寸,选择正确的单位很重要。

常用单位对比

以下是定义字体大小的几种主要方法:

  • 像素font-size: 16px;
    • 优点:绝对单位,精确控制。
    • 缺点:无视用户的浏览器默认设置或辅助功能设置(如放大文本),可访问性较差。
  • EMfont-size: 1.5em;
    • 公式1em = 当前元素的父元素的 font-size 值。
    • 特点:相对单位。具有复合效应。如果父元素字体放大,子元素使用 em 会基于放大后的值继续计算。
  • REMfont-size: 1.5rem;
    • 公式1rem = 根元素(通常是 <html>)的 font-size 值。
    • 特点:相对单位。避免了复合效应。所有使用 rem 的元素都相对于同一个根字体大小,更容易预测和管理。
  • 百分比font-size: 150%;
    • 类似于 em,是相对于父元素字体大小的百分比。

推荐使用 REM

对于大多数情况,推荐使用 rem 作为字体大小的单位。它兼具相对单位的灵活性(尊重用户的浏览器设置),又避免了 em 可能带来的复杂复合计算问题,使得样式更易于维护。

html {
  font-size: 16px; /* 设置根字体大小,1rem 将等于 16px */
}

body {
  font-size: 1rem; /* 16px */
}

h1 {
  font-size: 2rem; /* 32px */
}

p {
  font-size: 1.125rem; /* 18px */
}

本节课中我们一起学习了CSS字体的核心知识。我们掌握了如何通过 font-family 使用系统字体和通过 @font-face 引入自定义字体。同时,我们重点比较了 pxemrem 等字体单位,并得出结论:为了更好的可访问性和可维护性,在定义字体大小时应优先使用 rem 单位。

012:CSS 预处理器 🌟

在本节课中,我们将要学习 CSS 预处理器。我们将探讨它们是什么、为什么需要它们,并深入了解最流行的预处理器之一——Sass 的核心功能。

概述

CSS 本身并非图灵完备的编程语言,这意味着它缺乏函数、循环等高级编程特性。虽然这对于保持浏览器性能是件好事,但在管理大型项目的样式时,可能会变得棘手。CSS 预处理器应运而生,它们允许我们使用变量、嵌套、混合等更强大的语法编写样式,然后将其编译成标准的、浏览器可识别的 CSS。

CSS 是编程语言吗?

上一节我们提到了 CSS 的特性,本节中我们来看看一个根本问题:CSS 是编程语言吗?

判断标准之一是它是否图灵完备,即是否存在停机问题。对于任何给定的 CSS,我们能否知道它是否会执行完毕?答案是否定的。CSS 不是图灵完备的,这意味着它不被视为一个完整的编程语言。

但这带来了一个问题:我们经常希望 CSS 能拥有函数等功能,以便更轻松地分解和重用代码。然而,使 CSS 图灵完备会带来性能问题。浏览器和用户都希望 CSS 解析速度尽可能快。此外,如果需要在 Web 上使用编程语言,我们已经有 JavaScript 了。

为何需要 CSS 预处理器?

虽然原生 CSS 对于小型项目来说足够好,但在大型项目中很容易变得混乱不堪。

原生 CSS 提供了一些辅助功能,例如:

  • CSS 变量:有助于减少代码库中颜色值不一致的问题。
  • calc() 函数:允许对像素、百分比等单位进行数学计算。

这是一个很好的开始,但我们还可以使用名为 CSS 预处理器 的工具来获得更多功能。

什么是 CSS 预处理器?

预处理器将我们编写的、具有更多特性的代码,转换为另一个系统(这里是浏览器)所期望的输入格式。具体来说,它将功能更强大的代码转换为标准的 CSS。

这可以是一个 Node.js 库、一个命令行工具,或者集成在你使用的 Web 框架中。

以下是几个流行的选择:

  • Sass:使用 Dart 编写,可通过 NPM 获取。
  • PostCSS:严格来说不是一个预处理器,而是一个用于转换 CSS 的工具,但常被用作预处理器。
  • Less:基于 JavaScript。
  • Stylus:基于 JavaScript。

由于 Sass 最为流行,让我们深入了解一下它。

深入 Sass

Sass 有两种语法,你不能混合使用它们:

  1. Sass 语法:不使用花括号和分号,格式更简洁。
  2. SCSS 语法:是 CSS 的严格超集,任何有效的 CSS 都是有效的 SCSS,因此更容易上手。

无论选择哪种输入语法,Sass 的输出始终是标准 CSS。接下来的示例将使用 SCSS 语法。

变量

你可以在标准 CSS 中使用变量,但在 Sass 中,变量是有作用域的,而不像 CSS 变量那样是全局的。

$primary-color: #333;
$font-stack: Helvetica, sans-serif;

body {
  color: $primary-color;
  font: 100% $font-stack;
}

局部文件与导入

通常,Sass 工具会转换它找到的所有 .scss 文件。但有时我们可能需要创建可重用的、但自身不生成 CSS 输出的样式片段。

局部文件允许我们创建可导入的可重用样式表,但它们不会自行生成输出文件。例如,你可以有一个包含所有品牌颜色或网格系统尺寸的局部文件。

Sass 目前正在将其导入语法从 @import 更改为 @use,我们的代码片段将引用较新的 @use 语法。

嵌套

嵌套可能是 Sass 和大多数 CSS 预处理器中最方便的功能之一。它允许你按照 HTML 的结构来组织样式表,从而更容易编写特定选择器,并有助于减少滥用 CSS 中的 !important 注解。

// SCSS 嵌套语法
nav {
  ul {
    margin: 0;
    padding: 0;
    list-style: none;
  }
  li { display: inline-block; }
  a {
    display: block;
    padding: 6px 12px;
    text-decoration: none;
  }
}
/* 编译后的 CSS */
nav ul { margin: 0; padding: 0; list-style: none; }
nav li { display: inline-block; }
nav a { display: block; padding: 6px 12px; text-decoration: none; }

当然,嵌套也可能被过度使用,因此与编写 CSS 一样,应使选择器的特异性恰到好处。

混合

混合允许我们参数化样式。在过去,当新的 CSS 特性需要浏览器厂商前缀时,混合非常有用。例如,如果你需要在所有浏览器中应用相同的渐变,可以创建一个混合来处理,而不是重复编写四到五种不同语法的相同代码。

以下是一个混合示例,用于包装任何你只想在高分辨率(视网膜)屏幕上发生的样式:

@mixin retina-display {
  @media only screen and (-webkit-min-device-pixel-ratio: 2),
         only screen and (min-resolution: 192dpi) {
    @content;
  }
}

.box { @include retina-display { border-width: 0.5px; } }

混合对于避免复制粘贴相似但略有不同的代码的各种情况也非常有用。

运算

CSS 有一个用于数学计算的函数叫 calc()。它非常实用,因为它允许你基于实时值进行计算,例如将像素、百分比和 rem 值相加。但它只提供加、减、乘、除运算。

在 Sass 中,你可以进行更多运算,例如取整、取模、平方根、余弦,甚至可以使用 π 和 e。Sass 还提供了操作颜色的函数,例如使颜色变亮、变暗或更透明。

// 使用 Sass 数学运算
.container { width: math.div(600px, 960px) * 100%; }

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/97a44cf4522e73ab29dec6a18b818577_13.png)

// 操作颜色
$primary: #036;
.button {
  background-color: rgba($primary, 0.4); // 使原色透明度为 40%
}

为何要在项目中使用 CSS 预处理器?

如果你的项目足够大,以至于你想使用像 React 或 Angular 这样的框架,那么在其中加入预处理器是合情合理的。此外,如果你从一个模板项目开始,预处理器很可能已经包含在内了。

预处理器还使得根据框架组件来分解样式表变得更加容易,从而为你的 MVC(模型-视图-控制器)模式添加样式层。

预处理器添加了许多额外功能,但你并非必须使用它们。如果你使用 Sass 的 SCSS 语法,你可以只编写普通的旧 CSS,这也是有效的 SCSS。当你需要更多功能时,Sass 随时为你准备。

此外,Sass、Less 和 PostCSS 的功能远不止我们这里所涵盖的。你可以创建自定义函数、循环和其他控制流,使用 & 符号引用父类,还有调试装饰器等等。

总结

本节课中我们一起学习了 CSS 预处理器。我们了解了它们如何通过提供变量、嵌套、混合和高级运算等功能,来弥补原生 CSS 在大型项目管理和代码复用方面的不足。Sass 作为最流行的预处理器,其 SCSS 语法与 CSS 高度兼容,使得开发者可以平滑过渡并逐步采用其强大特性。现在,带着这些知识,去打造更优雅、更易维护的样式吧。

013:CSS 展示 🌟

在本节课中,我们将探索一系列有趣且富有创意的CSS技术。这些内容旨在激发灵感,不会在考试中涉及。我们将学习如何创建背景图案、CSS艺术、动画、遮罩与裁剪、3D CSS效果,以及如何将CSS与SVG结合,最后还会了解自定义CSS的Houdini API。

背景与图案 🎨

上一节我们介绍了课程概述,本节中我们来看看如何使用CSS创建背景和图案。CSS的background属性功能强大,远不止设置单一颜色。

以下是几种创建渐变背景的方法:

  • 线性渐变:使用linear-gradient()函数。可以指定方向(如to right)或角度(如135deg),并定义颜色变化。例如:
    background: linear-gradient(to right, green, pink);
    background: linear-gradient(135deg, green, pink);
    
  • 径向渐变:使用radial-gradient()函数。颜色从中心点向外辐射。可以指定形状(circleellipse)和中心位置。例如:
    background: radial-gradient(circle at 20px 80px, green 0% 20%, pink 20% 60%, green 60%);
    
  • 重复渐变:使用repeating-linear-gradient()repeating-radial-gradient()可以创建条纹或同心圆等重复图案。通过设置相同的色标位置可以实现硬切边效果。
  • 多重背景:可以在一个元素上叠加多个背景图像(包括渐变)。列表中的第一个背景会绘制在最上层。
  • 圆锥渐变:使用conic-gradient()围绕中心点进行颜色渐变。结合repeating-conic-gradient()可以创建饼图切片或雷达图效果。
  • 像素级图案:通过为每个“像素”设置一个微小的linear-gradient作为背景,理论上可以用单个<div>元素拼出任何图片。网站 singlediv.com 展示了这种技术的惊人潜力。
  • 资源推荐:网站 CSS3 Patterns 提供了大量可直接复用的高级CSS背景图案代码。

CSS艺术 🖼️

CSS艺术是指仅使用HTML和CSS来绘制图像或复杂图形。这通常需要组合大量元素,并巧妙运用多种CSS属性。

以下是创建CSS艺术的关键技术:

  • 形状组合:使用多个<div>元素,并通过border-radiuslinear-gradientradial-gradient来创建基本形状(如圆形、椭圆)和阴影,模拟3D效果。
  • 变换:使用transform属性(如rotatetranslatescale)来精确调整和组合这些形状。
  • 资源推荐:网站 cssart.com 汇集了许多令人惊叹的CSS艺术作品,例如“VW Bug”。
  • 球体生成器示例:通过JavaScript动态计算并应用一系列渐变的radial-gradient,可以模拟出具有立体感的球体。核心是创建一个从中心点(通常偏移一点以模拟光源)向外颜色逐渐变暗的径向渐变列表。

遮罩与裁剪 ✂️

本节我们将学习如何控制元素的可见区域,即遮罩与裁剪。这允许我们创造出非矩形的显示效果。

以下是几种遮罩与裁剪的方法:

  • 文字遮罩:使用background-clip: text属性可以让背景(如图片)只在文字形状内显示。需要将文字颜色设为transparent
  • 路径裁剪:使用clip-path属性可以按照指定形状裁剪元素。
    • circle():裁剪为圆形。
    • polygon():裁剪为多边形,通过一系列坐标点定义。
    • path():使用SVG路径语法进行更复杂、包含曲线的裁剪(目前主要Firefox支持)。
  • 图像遮罩:使用mask-image属性,通过一张灰度图片来定义元素的透明度(黑色为不透明,白色为透明)。这可以实现渐变擦除等高级过渡效果。
  • CSS滤镜:使用filter属性可以为元素添加视觉效果,如blur()模糊、brightness()调整亮度、hue-rotate()改变色相等。
  • 实践案例:一个波浪形遮罩动画的原型,结合了clip-path动态裁剪和filter颜色过滤,创造出流动的视觉特效。

CSS动画 🎬

CSS动画能让界面元素动起来,增加交互的趣味性。我们已经了解了基础动画,这里看一些创意应用。

以下是两个创意动画技巧:

  • 背景位置动画:对复杂的多重背景(如图案)应用background-position动画,可以创造出平移、滚动的视觉效果。单元素动画艺术作品常使用此技术。
  • 溢出隐藏技巧:将动画元素(如移动的波浪SVG)放置在一个设置了overflow: hidden的容器中,可以只显示容器范围内的部分,从而制造出元素“进入”或“离开”视口的错觉。常用于创建无限循环的背景或特殊转场。

结合SVG的CSS 🖍️

SVG(可缩放矢量图形)本身是XML格式,其元素也可以使用CSS进行样式化和动画。

以下是两种结合SVG的CSS动画技术:

  • 描边动画:利用SVG路径的stroke-dasharray(虚线样式)和stroke-dashoffset(虚线偏移)属性。通过动画将stroke-dashoffset从路径总长变化到0,可以模拟出画笔绘制路径的过程。
  • 路径变形动画:对SVG <path>元素的d(路径数据)属性应用CSS过渡或动画,可以实现从一个形状平滑变形到另一个形状的效果。注意,两个路径需要有相同数量的命令和点才能流畅过渡。

3D CSS 🧊

通过CSS的3D变换功能,我们可以让元素在三维空间中旋转和移动。

以下是关于3D CSS的介绍:

  • 核心属性:主要使用transform属性的3D函数,如rotateX()rotateY()rotateZ()translate3d(),并配合perspective设置透视点来创造深度感。
  • 创意作品:许多艺术家创作了令人印象深刻的纯CSS 3D作品,如可旋转的房屋、复杂的3D场景等。
  • 动手实践:可以尝试修改提供的“等距立方体”示例代码,通过JavaScript动态添加更多立方体、改变其位置、大小和颜色,来构建自己的3D世界(如3D图表或简单游戏)。

自定义CSS与Houdini 🪄

当现有CSS属性无法满足特定设计需求时(例如,想要一个双色波浪下划线),我们可以借助CSS Houdini API来自定义绘制逻辑。

以下是使用CSS Painting API的基本步骤:

  1. 注册绘制工作:在单独的JavaScript文件中,使用registerPaint注册一个自定义绘制器,并实现paint方法。在该方法中,可以使用类似Canvas的API进行绘制。
  2. 使用自定义属性:在CSS中,通过background-image: paint(自定义绘制器名称)来调用。可以定义并使用自定义CSS属性(如--underline-color-1)来传递参数给绘制器。
  3. 动画支持:由于自定义属性可以被CSS动画系统插值,因此只需对自定义属性(如--wave-time)应用关键帧动画,就能让自定义绘制效果动起来。

注意:Houdini API较新,浏览器支持度不一(如Painting API主要支持Chrome/Edge),使用时需检查兼容性。

总结 📚

本节课我们一起探索了CSS在创意表达方面的多种可能性。我们学习了如何制作复杂的背景图案、用CSS绘制艺术图形、实现遮罩裁剪效果、创作CSS和SVG动画、构建3D场景,甚至通过Houdini API扩展CSS本身的功能。希望这些内容能激发你的灵感,鼓励你在未来的项目中尝试更多有趣、独特的CSS技术。

014:HTML与CSS实战演示 🚀

在本节课中,我们将通过一个实战演示,来回顾和巩固上周学习的HTML与CSS核心概念。我们将尝试模仿Airtable网站的一个页面,在这个过程中,你将看到如何将理论知识应用到实际项目中,包括布局、样式、响应式设计以及简单的动画效果。


概述与准备工作

上一节我们介绍了课程安排和本周的演示目标。本节中,我们来看看如何从零开始构建一个网页。

首先,我们选择Airtable网站的一个页面作为模仿对象。这是一个结构清晰、包含常见元素(如导航栏、按钮、图片和文本)的页面,非常适合用于练习。

我们将创建一个名为 our_page.html 的新HTML文件,并搭建基本的页面结构。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Airtable Clone</title>
</head>
<body>
    <!-- 页面内容将在这里构建 -->
</body>
</html>

构建页面宏观结构

以下是构建页面的第一步,我们将创建页面的主要宏观区块。

我们观察到页面顶部有一个白色的导航栏,下方是一个蓝色背景的区域,其中包含文本和图片。因此,我们首先创建两个主要的 div 容器。

<body>
    <div id="header">
        <!-- 导航栏内容将放在这里 -->
    </div>
    <div id="main-content">
        <!-- 主要内容区域将放在这里 -->
    </div>
</body>

填充导航栏内容

在导航栏中,我们看到了Logo、几个导航链接和两个按钮。以下是填充这些内容的方法。

我们使用 divspan 等元素来组织内容。初始阶段,我们更关注结构而非样式。

<div id="header">
    <span>Airtable Logo</span>
    <span>Product</span>
    <span>Solutions</span>
    <span>Pricing</span>
    <span>Enterprise</span>
    <span>Resources</span>
    <button>Contact Sales</button>
    <button>Sign up for free</button>
    <a href="#">Sign in</a>
</div>

创建主要内容区域

主要内容区域包含一个大标题、一段描述文字和一个图片(或视频)。我们使用标题标签和 div 来构建。

<div id="main-content">
    <h1>Achieve anything that's possible on a spreadsheet</h1>
    <h4>But 10x faster, with 10x less effort.</h4>
    <img src="lasagna.jpg" alt="Demo Image">
</div>

应用CSS样式与布局

上一节我们搭建了基本的HTML骨架。本节中,我们来看看如何使用CSS来改善布局和外观。

我们首先使用内联样式进行快速原型设计,然后逐步将样式提取到独立的样式块中,以提高可维护性。

设置基础样式

我们为页面主体和主要区域设置一些基础样式,比如背景色和字体。

body {
    margin: 0;
    font-family: Arial, sans-serif;
}
#main-content {
    background-color: rgb(230, 240, 255); /* 一个浅蓝色 */
    padding: 20px;
}

使用Flexbox进行布局

为了将导航栏的元素水平排列,并将主要内容区的文本和图片并排,我们使用Flexbox。

#header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    background-color: white;
    padding: 10px 20px;
}
#main-content {
    display: flex;
}
#main-content div {
    flex: 1;
}
#main-content img {
    max-width: 100%;
    min-width: 50px;
}

处理图片与SVG

在网页中,图片格式的选择和尺寸控制非常重要。我们来看看如何处理不同类型的图像。

图片格式与优化

对于照片类图像(如我们使用的“千层面”图片),JPEG格式通常能在保证质量的同时大幅减小文件体积。我们通过CSS控制其响应式尺寸。

img {
    max-width: 100%;
    height: auto;
}

使用SVG图标

Logo或图标通常使用SVG格式,因为它是矢量图形,可以无限缩放而不失真。我们可以直接从目标网站复制SVG代码。

<div id="logo">
    <svg width="100" height="50" viewBox="0 0 100 50">
        <!-- SVG路径数据 -->
        <path fill="#FFD600" d="..."></path>
        <path fill="#4285F4" d="..."></path>
        <!-- 更多路径 -->
    </svg>
</div>

为了控制SVG的大小,我们将其包裹在一个容器 div 中,并对容器设置尺寸。

#logo {
    width: 100px;
    height: 50px;
}
#logo svg {
    width: 100%;
    height: 100%;
}

设计按钮与交互效果

按钮是网页中常见的交互元素。我们将为按钮创建样式,并添加悬停效果。

以下是定义按钮基础样式和悬停状态的CSS代码。

.header-button {
    padding: 10px 20px;
    border-radius: 5px;
    border: 1px solid #ccc;
    background-color: white;
    cursor: pointer;
    transition: all 0.3s ease;
}
.header-button.blue {
    background-color: #1a73e8;
    color: white;
    border: none;
}
.header-button:hover {
    background-color: #f1f1f1;
}
.header-button.blue:hover {
    background-color: #0d62c9;
}

在HTML中为按钮应用这些类:

<button class="header-button">Contact Sales</button>
<button class="header-button blue">Sign up for free</button>

实现固定定位导航栏

我们希望导航栏在页面滚动时始终固定在顶部。这可以通过 position: fixed 实现。

#header {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 80px;
    background-color: white;
    /* 其他样式 */
}

由于固定定位元素脱离了正常文档流,我们需要为 #main-content 添加上边距,以防止内容被导航栏遮挡。

#main-content {
    margin-top: 80px;
}


添加响应式设计与媒体查询

上一节我们完成了桌面端的布局。本节中,我们来看看如何让页面适应移动设备。

我们使用CSS媒体查询来根据屏幕宽度应用不同的样式规则。

调整布局方向

在移动设备上,我们希望主要内容区的文本和图片从并排变为上下堆叠。

@media (max-width: 600px) {
    #main-content {
        flex-direction: column;
    }
}

隐藏或显示元素

我们还可以在移动端隐藏导航栏中的某些文本,只保留图标或关键按钮。

@media (max-width: 600px) {
    .nav-text {
        display: none;
    }
    #logo {
        width: 30px;
        /* 调整Logo大小 */
    }
}

创建简单的CSS过渡动画

CSS过渡(transition)可以为元素状态的变化(如悬停、媒体查询触发的样式改变)添加平滑的动画效果。

以下是一个示例:当屏幕变窄时,让一个元素的宽度平滑地变为0。

.animated-element {
    width: 50px;
    background-color: blue;
    transition: width 1s ease;
}
@media (max-width: 600px) {
    .animated-element {
        width: 0;
    }
}

注意transition 用于简单的属性过渡,而更复杂的多关键帧动画应使用 @keyframesanimation 属性。


总结与回顾

本节课中,我们一起学习了如何通过一个完整的实战演示来应用HTML和CSS知识。我们从分析一个现有网页开始,逐步构建了其HTML结构,并使用CSS实现了布局、样式、响应式设计以及简单的交互效果。

我们回顾了以下核心概念:

  • HTML结构:使用语义化或通用的标签(如 div, span, button)搭建页面骨架。
  • CSS布局Flexbox 是实现灵活布局的主要工具。
  • 图片处理:根据场景选择 JPEG(照片)或 SVG(图标/图形),并控制其响应式尺寸。
  • 组件样式:通过定义 CSS类 来复用样式,并为按钮等元素添加 :hover 伪类实现交互。
  • 定位:使用 position: fixed 创建固定定位的元素(如导航栏)。
  • 响应式设计:通过 @media 查询 针对不同屏幕尺寸应用不同的CSS规则。
  • 动画效果:使用 transition 属性为CSS属性的变化添加平滑过渡。

希望这个演示能帮助你更好地理解如何将分散的知识点组合起来,构建一个完整的网页。记住,网页开发是一个迭代的过程,从宏观结构到微观样式,不断调整和优化是常态。

015:移动端CSS入门 🌝

在本节课中,我们将学习如何构建能在手机上完美显示的网站。我们将探讨移动端CSS的重要性、实现响应式设计的技术,以及如何测试不同设备上的显示效果。

概述

随着用户通过手机、平板、笔记本电脑等多种设备访问互联网,确保网站在所有设备上都能提供良好的用户体验变得至关重要。本节课将深入介绍如何利用CSS媒体查询等技术,创建能够自适应不同屏幕尺寸的响应式网站。

移动端设计的动机

在2020年,用户有多种方式访问互联网。他们可以使用手机、平板、大屏笔记本、小屏笔记本,甚至是4K超宽显示器或电视。网站用户通常会使用多种不同的设备。就像我们希望为用户打造出色的网站一样,如果网站不能响应用户使用的设备类型,那么它就无法称得上出色。

如今,大量的互联网消费发生在移动设备上。具体数据取决于您所在的公司、应用类型和目标市场,但可以肯定的是,移动端流量非常庞大,并且不会消失。桌面端同样如此。因此,作为一名Web开发者,您通常需要同时支持移动端和桌面端。今天我们将深入探讨如何同时支持移动端和桌面端网站。虽然可能还需要支持平板等其他设备,但核心技能是相同的,所以我们将专注于这两个主要平台。

支持移动端的方法:独立移动站点

支持移动端的一种方法是构建网站的桌面版本,然后为移动端单独制作一个版本。这种方法有其优缺点。

一些网站采用这种方法。例如,访问 www.facebook.com 是桌面版Facebook。而在手机上,您访问 m.facebook.com,它会显示一个更适应移动端的版本,通常只有单列,没有侧边栏。这是一种可行的方式。

像Facebook这样做的网站,其优点在于能为移动端进行高度优化。因为创建的是独立网站,通常可以由独立的团队、使用独立的代码库,甚至独立的设计师来完成。独立的设计师可以打造出专为移动端优化的、卓越的移动优先体验。用户体验也可以完全不同。例如,Instagram在移动端和桌面端可能希望有很大不同,因为在移动端可以拍照,而桌面端通常没有后置摄像头。

独立移动站点的另一个优点是调试更容易。因为这是两个完全独立的代码库,而不是一个代码库处理许多不同的事情。一个代码库始终处理移动端,另一个始终处理桌面端,这样思考起来非常简单。

然而,这种方法也有明显的缺点。最大的缺点是您现在拥有两个代码库,这可能需要更多时间、更多人员参与项目,成本更高,并且在项目维护上可能带来真正的麻烦。您可能经历过这种情况,例如某些银行应用,其桌面网站提供的某些功能在移动网站上不可用,反之亦然,这会让用户非常烦恼。此外,每当您想对网站进行任何更改时,都必须重复工作,这也非常烦人。

另一个问题是,在移动站点和桌面站点之间重定向用户可能非常困难。在移动设备上,您希望加载 www.facebook.com 时能重定向到 m.facebook.com,反之亦然。但正确实现这一点可能很困难。如果将用户重定向到错误的版本,他们可能会得到错误的体验。特别是当您制作的独立移动站点体验差异很大时,用户如果得到错误的版本会非常恼火。

第三个问题,实际上非常有趣,是SEO(搜索引擎优化)问题。SEO旨在让Google等搜索引擎更容易找到您的网站,并确保您的网站在搜索结果中排名靠前。当您拥有一个M站点(移动站点)和一个www站点(桌面站点)时,相同的内容就有了两个URL。例如,Facebook上可能同时存在 m.facebook.com/sams-amazing-birthday-partywww.facebook.com/sams-amazing-birthday-party。这会让Google感到困惑,因为它可能认为这是两个同时发生的不同派对。所有用于尝试排名一个页面的信号可能会分散到两个页面上,导致这两个页面可能排在列表中间,而不是如果它们是同一个页面时可能排在列表顶部。显然,这对Facebook来说不是问题,因为它不会把我的活动放到Google上。但如果您是一家更依赖SEO的企业,那么拥有独立的移动版本可能是一个不利因素。

响应式设计:一种解决方案

但有一个解决方案,叫做响应式设计。响应式网站是一种较新的现象,它通过访问一个单一的网站,利用CSS根据您所使用的设备应用不同的样式,从而在移动端和桌面端都看起来很好。

这种方法的好处显而易见是节省时间。我们只是做一些CSS更改,可能只需要增加10%-20%的工作量,而不是制作两个网站所需的两倍工作量。此外,因为我们共享一个代码库,在不同设备上保持相同的功能要容易得多,因为它们从根本上就是同一个代码库。

缺点是您必须在那个代码库中添加额外的代码,这也带来了额外的复杂性。需要更多的测试,更改代码时需要思考更多,因为您必须考虑:“我更改这段代码会破坏它在移动设备上的显示吗?”您可能需要测试两次,这是额外的工作。此外,由于您可能试图将桌面端的体验改造到移动端,或者反之,并尽可能多地共享代码,而不是像两个独立团队那样在“洁净室”环境中分别思考如何制作移动端和桌面端体验,因此很难在移动端实现完全高质量的用户体验。响应式网站的用户体验可能不如独立移动站点那么高质量。

然而,响应式网站是目前行业最大的趋势,许多公司都在采用这种方法。因此,这就是我们今天要重点学习的内容。

核心技术:媒体查询

那么,是什么技术允许我们在不同设备上应用不同的CSS呢?这项技术叫做媒体查询。媒体查询就像是CSS中的 if 语句。

以下是一个CSS文件的示例。在紫色部分,我们看到一些普通的CSS规则。例如,我们有一个类为 article 的元素应用了一些样式。然后我们有一个 @media 规则。这基本上就像一个 if 语句,但它是基于用户当前使用的设备或浏览器的条件判断。我们说“如果设备的最小宽度大于600像素”,那么我们就应用这些额外的样式。

浏览器解释这种方式是:如果条件不满足(例如,我们有一个500像素宽的设备),它就会简单地忽略 @media 块内的所有内容,不应用这些样式。如果条件满足,它基本上就像删除 @media 查询一样,同时应用这些样式。

因此,在宽度超过600像素的情况下,padding: 5px 10pxpadding: 10px 20px 都会应用。但由于媒体查询中的样式在CSS文件中出现得更晚,它们被赋予了更高的优先级,因此会覆盖原始样式,最终得到 padding: 10px 20px

为了更直观地理解,让我们看一个演示。这里有一个简单的HTML页面,包含一个带有 article 类的 div 和一个段落。它有一些基本的CSS:边框、背景色和内边距。目前,如果我们调整浏览器窗口大小,文本宽度会正常变化。

现在,如果我们取消注释媒体查询,最初不会看到任何变化,因为我们的媒体查询条件是 min-width: 600px,它只适用于宽度大于600像素的浏览器窗口或设备。当前窗口宽度大约是500像素,所以不满足条件。但是,如果我将窗口拉大到超过600像素,我们现在看到它也应用了媒体查询中的样式。除了原有的内边距、边框和背景,我们还应用了 width: 500px(这就是为什么它不再占据整个宽度)和 margin: auto(这就是为什么它居中了)。更重要的是,我们应用了 background: goldenrod

由于这个媒体查询出现在后面,并且浏览器在匹配媒体查询时,会将其视为普通的CSS规则。它看到两个针对 .article 类的规则:一个是 background: lightblue,另一个是 background: goldenrod。因为 goldenrod 出现在后面,所以它胜出,背景变成了金色。

为了强调这一点,我们看看如果把媒体查询放在默认样式之前会发生什么。我们看到只有默认样式时,什么都没发生。把媒体查询放在前面,我们看到宽度发生了变化(因为没有其他宽度定义),边距也应用了(因为没有其他边距定义)。但是因为 lightblue 是最后定义的背景色,所以它胜出。

因此,请记住,您通常希望将媒体查询放在默认样式之后,这一点非常重要。我们的基本思路是:先有默认样式,然后是一个 if 语句,最后是如果该语句为真时我们想要做的所有额外事情。

媒体查询的条件类型

既然我们已经了解了媒体查询如何工作,让我们深入了解一下可以在条件中使用的类型。就像 if 语句一样,您可以在语句内和语句外放置代码,但条件块本身(即 @media 后面的括号)决定了何时应用内部的样式。

主要有两种类型的条件:媒体类型媒体特性

媒体类型非常简单,主要有四种(还有一些遗留类型,我们不深入讨论):

  • all:匹配所有设备。
  • print:匹配打印模式(例如,按 Ctrl+P 打印到物理纸张时)。
  • screen:匹配屏幕显示(就像我现在正在看屏幕一样)。
  • speech:匹配语音合成器(例如,有人使用文本转语音等辅助功能时)。

媒体特性是您将主要使用的东西。这些特性不是互斥的;一个设备可以同时满足宽度大于500像素和支持悬停。所以我们称它们为“特性”而不是“类型”。

例如:

  • max-width:匹配任何宽度小于或等于指定值的设备。例如,max-width: 500px 会匹配宽度为500像素或更窄的设备,比如纵向握持的手机。
  • min-width:匹配任何宽度大于或等于指定值的设备。例如,min-width: 501px 会匹配宽度为501像素或更宽的设备,比如横向握持的手机、平板,或者这台电脑。
  • 还有许多其他类型,您可以在 MDN 上搜索“媒体查询”,查看“媒体特性”。例如:
    • hover:如果设备的主要输入方式支持悬停。这很酷,例如,当用户使用触摸屏(不支持悬停)时,您可以显示略有不同的内容。
    • prefers-color-scheme:如果用户使用的是现代笔记本电脑并启用了深色模式,我们也可以让网站变暗。
    • prefers-reduced-motion:对于无障碍访问非常重要。
    • prefers-contrast:偏好高对比度。

媒体特性非常酷,但 min-widthmax-width 可能是您最常用的,因为它们与使内容适应手机等小屏幕设备最相关。

组合多个条件

我们可以使用多个条件,就像在 if 语句中使用 and 一样。

我们也可以使用 not。但需要注意的是,这里的 not 与 JavaScript 中的 not 不完全相同,因为它会反转整个查询,而不仅仅是 screen。您可能会误读这个查询,认为它的意思是“当不是屏幕且最小宽度为...时”,但实际并非如此。您必须将其理解为“当不满足(屏幕且最小宽度为...且方向为纵向)时”。想象一下 not 应用于整个条件。这是一个需要注意的重要事项。

由于这种反直觉的行为(如果您习惯了其他编程语言中的 not),通常建议避免使用它。

您还可以组合查询。这个媒体查询将匹配打印模式,或者屏幕宽度至少为320像素的设备。

视口的概念

我们已经讨论了很多关于最小和最大宽度,但我们在讨论谁的宽度呢?答案是视口

视口是您的页面在浏览器中渲染的区域。在桌面上,这是浏览器窗口的区域。在手机上,这大概是设备的屏幕区域。非常重要的一点是,视口的大小单位是CSS像素。现代手机可能拥有像4K屏幕这样惊人的分辨率,但视口宽度可能只有500 CSS像素。这是因为在超高分辨率设备上,它们通常会将一个CSS像素映射为两个(或更多)物理像素。

这起初可能有点令人困惑,但其意义在于,不同物理宽度设备的CSS像素宽度实际上是相对一致的。如果我们定位宽度小于500 CSS像素的设备,这实际上几乎涵盖了所有制造出来的手机,从最初的iPhone到现在的4K超清手机,因为它们都有这些硬件缩放因子。作为开发者,我们不需要考虑这些细节。

这真的很好。这里有一个不同视口范围的例子:手机通常小于600px,平板纵向在600px到900px之间,桌面端通常在1200px以上。显然,这需要您根据自己用户的情况做一些研究,或者遵循像这样的最佳实践。

移动端视口与元标签

现在,在移动设备上,意识到视口可能有点复杂,所以我们进入下一个主题:视口元标签

在移动设备发展的早期,网络并没有为移动网站做好准备。如果他们将巨大的桌面网站原样缩小到手机屏幕上,看起来会很糟糕。因此,像 iPhone 上的 Safari 或 Android 手机上的 Chrome 这样的移动浏览器,默认情况下会将页面渲染为大约缩小三倍的状态。最初的 iPhone 屏幕宽度是320物理像素,但浏览器会谎称其视口宽度为900 CSS像素。网页会按照900像素宽的窗口来渲染,然后被缩小以适应iPhone的屏幕。

这对于向后兼容性确实很好,但如果您正在制作现代网站,您不希望有这种行为。这就是第一个视口元标签的用武之地。这甚至不是一个真正的规范,只是 Safari 和 Chrome 等移动浏览器支持的东西。

您添加 <meta name="viewport" content="width=device-width"> 这个元标签,它告诉浏览器不要对您撒谎,给出正确的宽度,然后一切就正常了。例如,在 iPhone 上添加这个标签后,它会报告宽度为300或400像素,而不是谎称900像素宽然后进行缩放。

这个标签非常重要,您应该将其添加到您制作的每个网站中。这基本上是每个网站都会用到的标准配置。

下面的例子展示了视口标签更高级的用法。它支持一些其他功能,但不同设备间的支持情况可能不同。例如,您可以限制缩放,这样用户就不能过度缩放。您可能不想这样做,因为这可能会使您的网站无法访问,但知道这个功能的存在是件好事,如果您需要限制网站上的缩放或其他视口操作时可以考虑。

视口单位

有时在设计布局时,我们可能希望使用视口作为参考。例如,我们可能有一张图片,不希望它占据超过一个屏幕的高度。在布局一篇文章时,我不希望用户必须滚动一整张悉尼海港大桥的照片,我只希望它占据一个屏幕,这样他们可以快速滚动过去。我们可以使用 vhvw 单位来实现。

vw 是视口宽度的百分比。100vw 等于视口的宽度。vh 是视口高度的百分比。100vh 等于视口的高度。我可以在CSS的 widthheight 等属性中使用这些单位,就像我通常使用像素或百分比一样。

唯一的注意事项是,在移动设备上(实际上在桌面上也可能),它们的行为可能有些反直觉。在移动设备上,当您向下滚动时,URL栏通常会消失。100vh 实际上是URL栏和所有其他滚动元素隐藏时的视口高度,这有点令人困惑。这意味着,如果您有一个 100vh 高的元素,当用户首次加载页面且地址栏可见时,他们将无法看到该元素的全部。

您也可以在这里使用百分比,但百分比也有一个注意事项:百分比会随着视口高度因滚动而变化。如果您试图在移动设备上让某个元素填满整个屏幕,您需要做更多的工作。这是您需要研究的问题。

同样,在桌面上,在某些平台(如Windows)上,滚动条会占用一些宽度。100vw100vh 是包含滚动条宽度的。如果您将某个元素的宽度设为 100vw,但页面有垂直滚动条,那么实际上它会比视口稍宽,从而添加一个水平滚动条,因为现在页面稍微宽了一点,无法容纳在滚动条内部。

视口单位有时很有用,但它们并不能解决所有问题。

测试工具:浏览器开发者工具

我们讨论了很多关于不同视口单位、使用 @media 查询基于视口应用CSS的方法。但是,如果您手头没有一千部手机(不幸的是,我没有),测试这些不同的视口可能非常困难。因此,我要告诉您一个神奇的工具,它存在于 Chrome 和 Firefox 的开发者工具中。

这就是开发者工具中的那个小移动设备图标。它允许您更改视口,还可以从预定义的设备中选择(您不必记住iPhone的尺寸,这非常方便)。它还允许您更改方向,并进行一些更高级的操作,比如更改DPR(设备像素比),如果您在进行Canvas游戏开发可能会用到。

现在,我将切换到 Chrome 进行今天的第二个演示,向您展示如何使用这个工具。

我已经打开了开发者工具。就是这个按钮,在元素选择器旁边。点击“切换设备工具栏”(在Firefox中图标和功能完全相同)。如您所见,网站被压缩了,现在看起来像在手机上一样。

在Chrome中,有两种控制方式。一种方式是使用这个下拉菜单,您可以选择一些设备预设,比如 iPhone X。现在它的大小就和在 iPhone X 上一样。需要注意的是,这基本上只是为您调整窗口大小,并不是真正模拟 iPhone X。例如,如果我点击某个输入框,它不会弹出键盘。它也不会显示刘海屏,这有点遗憾。

您可以在不同设备之间切换,测试在 iPad 上的显示效果。顶部还有这些控制手柄,您可以点击来改变宽度,或者直接拖动。

好了,这就是那个方便的移动设备测试工具。

总结

今天,我们学习了如何使用 @media 规则。我们了解了为什么需要创建移动网站,以及为什么我们希望使其具有响应式,从而不必维护两个独立的代码库。我们学习了可以使用 @media 查询来实现类似 if 语句的功能,根据设备条件性地应用CSS。我们了解了视口大小,以及它如何在屏幕质量完全不同的设备间保持相对一致(这有点不可思议)。我们学习了视口单位,以及由于在移动端存在一些注意事项,您可能不想使用它们。我们还学习了如何在 Chrome 或 Firefox 开发者工具中使用设备工具栏来预览您的网站在不同尺寸设备上的显示效果。

好了,祝您在创建移动响应式网站时玩得开心!

016:CSS框架 🌝

在本节课中,我们将要学习CSS框架。CSS框架是一种工具,可以帮助我们避免在构建网页时重复编写常见的样式,从而提升开发效率和页面美观度。

什么是CSS框架?🤔

CSS框架的核心思想是避免“重复造轮子”。当你在制作网页时,经常会需要一些具有特定样式的组件,例如漂亮的按钮、表单或导航栏。手动为每一个元素编写CSS样式既耗时又困难。

例如,一个默认的HTML按钮看起来可能不太美观:

<button>普通按钮</button>

它的颜色、交互效果都很基础。然而,许多优秀网站上的按钮看起来更精致、交互更流畅。作为一名网页开发者,你并不需要从零开始制作所有这些组件。绝大多数时候,你的样式需求已经被其他人解决了。使用CSS框架,就是利用这些现成的解决方案。

流行的CSS框架 🏆

市面上有许多CSS框架,其中有两个非常流行,你可能在大型作业或课程后期会用到:

  1. Material UI:这是一个非常流行的框架,我们将在课程后期结合JavaScript框架进行讲解。
  2. Bootstrap:这是我们将要重点探讨的框架。Bootstrap可以说是最初的、也是最流行的CSS框架之一,它的理念非常简单。

深入Bootstrap 🚀

Bootstrap提供了一系列基础的HTML组件。你可以在代码中写入这些带有特定类名的HTML元素,它们看起来可能很普通。但当你将Bootstrap库添加到页面后,这些元素就会“焕发生机”。

上一节我们介绍了CSS框架的概念,本节中我们来看看如何使用Bootstrap。

基本使用方法

首先,你需要在HTML页面的<head>部分引入Bootstrap的样式表。从Bootstrap官网可以获取一个链接,类似于:

<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">

这行代码会导入Bootstrap的样式文件。引入后,页面中带有Bootstrap类名的元素就会自动应用相应的样式。

核心机制:CSS类

Bootstrap的强大之处在于其预设的CSS类。例如,要创建一个漂亮的蓝色主按钮,你可以这样写:

<button class="btn btn-primary">漂亮按钮</button>

这里的btnbtn-primary就是Bootstrap定义的类。没有引入Bootstrap样式时,它看起来是默认按钮;引入后,它会变成一个具有特定颜色、圆角和悬停效果的精致按钮。

通过组合不同的类,你可以轻松创建多种样式的按钮:

<button class="btn btn-success">成功按钮</button>
<button class="btn btn-warning">警告按钮</button>
<button class="btn btn-lg">大号按钮</button>

以下是Bootstrap提供的一些常用组件类别:

  • 按钮:通过btn类及btn-primarybtn-success等修饰类实现。
  • 表单控件:如输入框、选择框、单选框,具有统一的样式。
  • 导航栏:完整的导航栏组件,包含响应式折叠菜单。
  • 下拉菜单:动态的下拉菜单组件。
  • 模态框:弹出对话框。
  • 布局系统:基于栅格系统的响应式布局工具。

动态组件与JavaScript

有些Bootstrap组件(如下拉菜单、模态框)需要交互功能。对于这些动态组件,仅引入CSS是不够的,你还需要引入Bootstrap的JavaScript文件:

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>

引入后,组件的交互功能(如点击下拉菜单展开选项)才能正常工作。对于静态组件(如普通按钮),则只需要CSS文件。

组件库的力量 💪

像Bootstrap这样的框架也常被称为“组件库”。它们提供了一整套可即插即用的UI组件,就像搭积木一样。你可以通过组合和配置这些预设组件,快速构建出美观且功能完整的网页界面,而无需关心底层样式的复杂细节。

本课程网站就大量使用了类似的组件库来构建导航栏、图标、布局和交互元素。

总结 📚

本节课中我们一起学习了CSS框架,特别是Bootstrap。

  • CSS框架的作用是提供预制的样式和组件,避免重复开发。
  • Bootstrap是一个流行且易用的框架,通过为HTML元素添加特定的CSS类来应用样式。
  • 使用Bootstrap需要引入其CSS文件,对于动态组件还需引入JavaScript文件。
  • 这类框架极大地提升了开发效率与页面一致性,非常适合初学者和快速原型开发。

建议你从Bootstrap开始实践,探索其官方文档,尝试使用不同的组件。它将显著减少你的编码时间,并提升页面的视觉美感。

017:CSS Grids 🌐

在本节课中,我们将学习CSS Grid布局。这是一种强大的布局工具,特别适合创建由直线分隔的、结构化的网页布局。我们将从基本概念开始,逐步学习如何创建网格、定义行列以及使用网格区域来构建复杂的页面结构。

概述

CSS Grid是Flexbox布局的延伸,专门用于创建二维网格布局。它非常适合那些可以用一系列直线清晰分隔内容的页面结构。本节课我们将学习其核心概念和基本用法。

网格的基本概念与应用场景

上一节我们介绍了CSS Grid的概述,本节中我们来看看它的具体应用场景。

CSS Grid适用于结构可以用直线清晰分隔的布局。例如,一个典型的网页布局,如头部、侧边栏、主内容和页脚,就非常适合使用网格。

CSS Grid不适用于元素位置完全杂乱无章的结构。对于那种情况,Flexbox可能是更好的选择。

历史上,网页上的网格布局是通过HTML表格(<table>)构建的。表格在样式设计上能力有限,且其元素并非为灵活的动态布局而设计。表格应保留用于展示真正的表格数据。

创建第一个网格:井字棋游戏

了解了应用场景后,我们来动手创建第一个网格。

首先,需要一个容器元素,并为其设置 display: grid 属性。这会将容器内的所有项目定义为网格项目。

.container {
  display: grid;
}

接下来,我们需要定义网格的列和行。例如,要创建一个3x3的井字棋棋盘:

.container {
  display: grid;
  grid-template-columns: 50px 50px 50px;
  grid-template-rows: 50px 50px 50px;
}

现在,网格已经定义好了,但还看不到内容。我们需要在容器内添加项目。对于井字棋,我们添加9个<div>元素。

<div class="container">
  <div>X</div>
  <div>O</div>
  <div>X</div>
  <!-- ... 总共9个div -->
</div>

这样,一个简单的井字棋游戏布局就完成了。使用CSS Grid创建这种结构比使用Flexbox更快捷,因为它是为此类网格结构量身定制的。

使用fr单位和构建页面布局

我们创建了一个固定像素尺寸的网格。现在,让我们看看如何使用更灵活的单位,并构建一个更实用的网页布局。

CSS Grid引入了一个名为fr(fraction,分数)的单位。它类似于Flexbox中的flex属性,用于分配剩余空间。例如,grid-template-columns: 1fr 1fr 1fr;会创建三个等宽的列。

让我们用网格来构建一个典型的网页布局:头部、侧边栏、主内容和页脚。

首先,我们使用grid-template-areas属性来定义网格区域。这允许我们为网格的每个单元格命名。

.container {
  display: grid;
  grid-template-areas:
    "header header header"
    "sidebar main main"
    "footer footer footer";
  grid-template-rows: 1fr 3fr 1fr; /* 中间行更大 */
  grid-template-columns: 100px 1fr; /* 侧边栏固定宽度,主内容自适应 */
}

然后,我们为每个区域创建对应的类,并使用grid-area属性将它们映射到上面定义的区域名。

.header { grid-area: header; background: red; }
.sidebar { grid-area: sidebar; background: green; }
.main { grid-area: main; background: blue; }
.footer { grid-area: footer; background: orange; }

最后,在HTML中使用这些类:

<div class="container">
  <div class="header">Header</div>
  <div class="sidebar">Sidebar</div>
  <div class="main">Main Content</div>
  <div class="footer">Footer</div>
</div>

通过这种方式,我们可以轻松地创建出结构清晰的页面布局,而无需使用浮动(float)或绝对定位(absolute positioning)。

网格间隙与项目对齐

我们已经构建了页面布局,现在来看看如何调整网格项目之间的间距和对齐方式。

与Flexbox类似,CSS Grid的属性也分为容器属性项目属性

以下是控制网格间隙(Gap)的属性:

  • row-gap: 设置行与行之间的间隙。
  • column-gap: 设置列与列之间的间隙。
  • gap: 是row-gapcolumn-gap的简写属性。例如,gap: 10px;会同时设置行和列的间隙为10像素。

以下是控制网格内项目对齐的属性(作用于容器):

  • justify-items: 控制所有网格项目在水平方向(沿行轴)的对齐方式(如start, center, end, stretch)。
  • align-items: 控制所有网格项目在垂直方向(沿列轴)的对齐方式。
  • place-items: 是align-itemsjustify-items的简写。

例如,设置justify-items: center;会使所有网格项目在其各自的网格单元格内水平居中。

总结

本节课中我们一起学习了CSS Grid布局的核心知识。

我们了解到CSS Grid是创建二维网格布局的强大工具,尤其适合结构规整的页面。我们学习了如何通过display: grid启动网格,使用grid-template-columnsgrid-template-rows定义行列,以及利用fr单位创建弹性布局。

更重要的是,我们掌握了使用grid-template-areasgrid-area来直观地构建复杂页面布局(如包含头部、侧边栏、主内容和页脚的布局)的方法。最后,我们还简要介绍了如何通过gap属性设置间隙,以及使用justify-itemsalign-items来控制项目对齐。

CSS Grid提供了一种高效、直观的方式来构建响应式网页结构,当你的设计可以被清晰的网格线描述时,它就是最佳选择。

018:开发者工具入门指南 🛠️

在本节课中,我们将学习如何使用浏览器内置的开发者工具,特别是谷歌Chrome的开发者工具。开发者工具是用于调试、原型设计和分析网页的强大工具。我们将重点探索其核心功能,特别是“元素”面板,它允许你查看和操作网页的HTML和CSS。

打开开发者工具

首先,我们需要知道如何打开开发者工具。通常有两种方式:

  • 使用键盘快捷键(例如,在Windows电脑上按 F12)。
  • 在网页上右键点击,然后选择“检查”或“检查元素”。

打开后,你会看到一个类似下图的界面。为了有更多空间查看工具,我们可以适当调整代码窗口的大小。

探索“元素”面板

上一节我们打开了开发者工具,本节中我们来看看最核心的“元素”面板。这是我们将花费大部分时间的地方。

“元素”面板展示了网页上所有元素的原始源代码(DOM)。当你将鼠标悬停在面板中的代码上时,浏览器会智能地高亮显示页面对应的区域。你可以点击展开或收起元素,来查看嵌套结构。

你还可以直接在页面上选择元素。以下是具体操作:

  • 点击开发者工具左上角的“选择元素”图标(或按 Ctrl+Shift+C)。
  • 然后将鼠标移动到网页上,你想检查的元素会被高亮。
  • 点击该元素,开发者工具会自动在“元素”面板中定位并选中对应的代码。

另一种方法是直接在网页元素上右键点击,然后选择“检查”。

实时编辑HTML

除了查看,“元素”面板更强大的功能在于实时编辑。你可以在不修改源代码文件的情况下,直接在这里修改页面。

以下是几种编辑HTML的方式:

  • 修改文本:在“元素”面板中,双击任何文本节点,直接输入新内容并按回车,页面会立即更新。
  • 添加/删除元素:右键点击一个元素,可以选择“复制元素”或“删除元素”。
  • 拖拽重新排列:点击并拖拽“元素”面板中的代码行,可以改变元素在DOM树中的位置,从而改变其在页面上的渲染顺序。
  • 启用内容编辑:这是一个有趣的小技巧。在“元素”面板中找到顶层的 <html> 标签,右键选择“编辑为HTML”,为其添加 contenteditable 属性。
    <html contenteditable>
    
    保存后,整个页面将变成可编辑状态,你可以像在文档中一样直接点击修改任何文本。移除该属性后,编辑功能消失。

注意:所有这些修改都是临时的,刷新页面后就会恢复原状。它们主要用于调试和实验。

操作CSS样式

了解如何编辑HTML后,我们来看看如何操作CSS样式,这是开发者工具最常用的功能之一。

在“元素”面板的右侧或下部,你会找到“样式”子面板。当你选中一个元素时,这里会列出所有应用到该元素上的CSS规则。

以下是“样式”面板的核心功能:

  • 查看与定位规则:每条规则旁边会显示它定义在哪个文件或 <style> 标签中。点击文件名可以跳转到源代码的对应位置。
  • 启用/禁用样式:每条规则前都有一个复选框,取消勾选可以临时禁用该条CSS规则,让你快速测试没有该样式时的效果。
  • 添加新样式:你可以在空白处直接编写新的CSS规则,并实时看到效果。
  • 交互式修改值:对于颜色、尺寸等属性,开发者工具提供了交互式控件。例如,点击颜色值会出现取色器;修改 paddingwidth 时可以使用上下箭头微调。
  • 可视化编辑复杂属性:对于如 box-shadowgradient 等复杂属性,点击其值旁边的小图标可以打开一个可视化编辑器,通过拖拽滑块和选择颜色来直观地调整效果,这比手动编写代码要方便得多。

强制元素状态与查看计算样式

有时,我们需要测试元素在不同状态下的样式,例如 :hover(悬停)、:focus(聚焦)。手动触发这些状态可能不方便。

以下是强制状态的方法:

  1. 在“样式”面板顶部找到 :hov 按钮。
  2. 点击后,会显示一系列伪状态复选框。
  3. 勾选如 :hover,即可强制让当前选中的元素一直处于悬停状态,方便你查看和调试其样式。

接下来,我们看看“计算样式”标签页。它与“样式”标签页相邻。

  • “样式”面板:显示的是开发者编写的或浏览器默认的CSS规则。
  • “计算样式”面板:显示的是浏览器将所有规则(包括继承的、默认的)计算、合并后,最终应用到该元素上的每一个属性的最终值

当你发现页面上出现了意想不到的样式(比如多了一个你没设置的 margin),在“样式”面板里又找不到来源时,去“计算样式”面板里查看该属性的最终值,就能知道是哪个规则生效了,这是深度调试的利器。

其他实用功能与总结

除了核心的“元素”面板,开发者工具还有其他有用的标签页,虽然本课不深入讲解,但值得了解:

  • 控制台:用于查看JavaScript日志、错误信息,并可以直接执行JS代码进行调试。
  • 源代码:用于调试JavaScript,可以设置断点、单步执行。
  • 网络:监控网页加载过程中所有请求(HTML、CSS、JS、图片等)的详细信息,如状态、耗时、大小,对性能优化至关重要。
  • Lighthouse:自动化工具,可以分析网页的性能、可访问性、SEO等方面,并给出改进建议。
  • 应用:查看和管理本地存储、Cookie、缓存等数据。

最后,在“元素”面板的底部,还有一个盒子模型图示,它直观地展示了选中元素的 contentpaddingbordermargin 的尺寸和层级关系,并且这些值会随着页面缩放而实时更新。


本节课中我们一起学习了谷歌Chrome开发者工具的基础用法。我们重点掌握了如何通过“元素”面板查看和实时编辑HTML与CSS,包括修改内容、调整样式、强制元素状态以及查看最终的计算样式。开发者工具功能强大,是前端开发不可或缺的调试和探索利器。请务必多加练习,用它来调试你的作业,你会发现这比盲目修改代码文件要高效得多。祝你学习顺利!

019:🛠️ Javascript

在本节课中,我们将要学习Javascript编程语言。我们将从学习第二门编程语言的一般方法开始,然后重点探讨Javascript的核心语言特性,特别是它与C语言的区别。我们将涵盖变量、字符串、控制结构、函数以及两种主要的数据结构:数组和对象。

为什么学习Javascript?

Javascript是一种高级、多范式、解释型的脚本语言。它之所以重要,是因为它在基于Web的软件工程领域拥有极高的普及度。如今,大多数软件都是基于Web的,而Javascript是构建Web应用程序的通用首选语言。

此外,Javascript拥有极其丰富的开源库和包管理器生态系统,这能帮助你快速构建应用,避免重复造轮子。它是一门高级语言,编写代码非常快捷,在工业界得到了极好的支持,是大型软件工程项目的常见选择。

从C到Javascript:核心差异

你已经熟悉了C语言,学习Javascript时,关注两者的差异会很有帮助。以下是主要区别:

  • 编程范式:C是过程式语言,而Javascript是多范式语言,支持过程式、函数式和面向对象编程。
  • 类型系统:C是静态类型语言,而Javascript是动态类型语言,变量在声明时无需指定类型。
  • 内存管理:C涉及指针和手动内存管理(malloc/free),Javascript是高级语言,无需直接操作内存。
  • 编译与解释:C是编译型语言,需要单独的编译和运行步骤。Javascript是解释型语言,通常使用Node.js等工具直接解释执行代码,步骤合二为一。

简单来说,Javascript的语法更简洁,开发更便捷,但语言本身感觉更“庞大”,功能也更丰富。

运行Javascript代码

在C语言中,你需要使用GCC等编译器将代码编译成可执行文件。在Javascript中,我们通常使用Node.js来运行代码。

Node.js是一个命令行接口,用于解释和执行Javascript代码。运行一个Javascript文件非常简单:

node 你的文件名.js

例如,对于一个简单的打印函数:

// compare.js
function minimum(a, b) {
    if (a > b) return b;
    return a;
}
console.log(minimum(3, 5));

运行命令 node compare.js 将在终端输出 3。在Javascript中,console.log 相当于C语言中的 printf,用于向控制台输出信息,并且它会自动在末尾添加换行符。

变量与常量

在Javascript中声明变量时,你需要使用 letconst 关键字,而不是像C那样指定类型(如 int, char)。

  • let 用于声明可以改变的变量。
  • const 用于声明不可改变的常量(注意:对于对象和数组,const 保证的是变量指向的引用不变,但对象内部属性或数组元素可以改变)。
let changeable = 10; // 这个值以后可以改变
changeable = 20; // 正确

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_19.png)

const constantVal = 5; // 这个值不能改变
// constantVal = 6; // 错误!不能给常量赋值

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_21.png)

// 对于对象和数组
const myArray = [1, 2, 3];
myArray.push(4); // 正确:修改了数组内容
// myArray = [5, 6, 7]; // 错误:不能改变 myArray 指向的引用

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_23.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_25.png)

const myObject = { name: 'Alice' };
myObject.age = 25; // 正确:修改了对象属性
// myObject = { name: 'Bob' }; // 错误:不能改变 myObject 指向的引用

Javascript中的基本数据类型包括数字、字符串、布尔值,以及两个表示“空”的特殊值:undefined(未定义)和 null(空值)。

字符串操作

Javascript中的字符串操作比C语言简单直观得多。你可以使用加号 + 来连接字符串。

let greeting = 'Hello';
let name = 'World';
let sentence = greeting + ', ' + name + '!'; // "Hello, World!"

更强大的功能是模板字符串,它使用反引号 ` 定义,并允许你在字符串中直接嵌入变量或表达式,语法是 ${变量名}

const age = 7;
const name = 'Hayden';
// 使用模板字符串
const phrase = `Hello, my name is ${name} and I am ${age}.`;
console.log(phrase); // 输出: Hello, my name is Hayden and I am 7.

模板字符串使得构建复杂字符串变得非常清晰和方便。

控制结构

如果你熟悉C语言,那么Javascript中的 ifelse ifelsewhilefor 循环看起来会非常熟悉。语法几乎一致,只是去掉了类型声明。

let number = 5;

if (number > 10) {
    console.log('Large');
} else if (number > 0) {
    // 什么都不做
} else {
    console.log('Small or negative');
}

// while循环
let i = 0;
while (i < 5) {
    console.log(i);
    i++;
}

// for循环
for (let j = 0; j < 5; j++) {
    console.log(j);
}

函数

在Javascript中定义函数使用 function 关键字。由于是动态类型,你不需要指定参数和返回值的类型。

function minimum(a, b) {
    if (a > b) {
        return b;
    }
    return a;
}

// 调用函数
let result = minimum(10, 20);
console.log(result); // 输出: 10

函数是Javascript中的一等公民,可以作为参数传递,也可以从其他函数返回,这为函数式编程风格提供了支持。

数据结构:顺序集合 (数组)

在Javascript中,数组 是主要的顺序集合,它是可变的、有序的,并且通常包含相同类型的元素(但语言并不强制)。你可以动态地添加或删除元素。

以下是数组的基本操作:

// 1. 创建数组
const names = ['Hayden', 'Jake', 'Nick', 'Emily'];

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_45.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_46.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_48.png)

// 2. 访问和打印
console.log(names); // 打印整个数组: ['Hayden', 'Jake', 'Nick', 'Emily']
console.log(names[1]); // 访问第二个元素 (索引从0开始): 'Jake'

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_50.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_52.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_53.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_54.png)

// 3. 修改元素
names[1] = 'Jacob'; // 数组变为: ['Hayden', 'Jacob', 'Nick', 'Emily']

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_56.png)

// 4. 添加元素到末尾
names.push('Rennie'); // 数组变为: ['Hayden', 'Jacob', 'Nick', 'Emily', 'Rennie']

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_58.png)

// 5. 遍历数组
// 方式一:传统的 for 循环
for (let i = 0; i < names.length; i++) {
    console.log(names[i]);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_60.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_62.png)

// 方式二:for...of 循环 (遍历值)
for (const name of names) {
    console.log(name);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_64.png)

// 方式三:for...in 循环 (遍历索引)
for (const index in names) {
    console.log(`Index ${index}: ${names[index]}`);
}

数组自带很多有用的方法,例如 push, pop, length 属性等。array.length 可以直接获取数组的长度。

数据结构:关联集合 (对象)

对象 是Javascript中主要的关联集合,类似于C语言中的结构体,但功能更强大、更灵活。对象是键值对的集合,键通常是字符串,值可以是任何类型。对象中的元素没有内在的顺序。

以下是对象的基本操作:

// 1. 创建对象
const student = {
    name: 'Sally',
    score: 95,
    rank: 1
};

// 2. 访问和打印
console.log(student); // 打印整个对象
console.log(student.name); // 使用点语法访问: 'Sally'
console.log(student['score']); // 使用方括号语法访问: 95

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_70.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_72.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_74.png)

// 3. 修改属性
student.name = 'Hayden';

// 4. 动态添加新属性
student.height = 159; // 对象现在拥有 height 属性

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_76.png)

// 5. 遍历对象
// 遍历对象的键
for (const key in student) {
    console.log(`Key: ${key}, Value: ${student[key]}`);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_78.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_80.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_82.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_84.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_85.png)

// 使用 Object.keys(), Object.values(), Object.entries()
const keys = Object.keys(student); // 返回键的数组: ['name', 'score', 'rank', 'height']
const values = Object.values(student); // 返回值的数组: ['Hayden', 95, 1, 159]
const entries = Object.entries(student); // 返回键值对数组: [['name','Hayden'], ['score',95], ...]

// 6. 检查对象是否拥有某个属性
if ('name' in student) {
    console.log('Student has a name property');
}

对象在表示现实世界的实体(如用户、产品)时非常有用,你可以通过有意义的属性名(键)来访问数据。

数组与对象的结合

在实际应用中,我们经常将数组和对象组合使用,例如创建一个对象数组。

// 一个包含学生对象的数组
const students = [
    { name: 'Alice', score: 88 },
    { name: 'Bob', score: 92 },
    { name: 'Charlie', score: 76 }
];

// 遍历数组,并访问每个对象的属性
for (const student of students) {
    if (student.score > 85) {
        console.log(`${student.name} got a High Distinction!`);
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_101.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/af879d0ef4b899e7e429a73a844847fb_102.png)

// 也可以是一个以字符串为键,对象为值的对象
const classData = {
    'Alice': { age: 20, grade: 'A' },
    'Bob': { age: 21, grade: 'B+' }
};
console.log(classData['Alice'].age); // 访问 Alice 的年龄: 20

选择使用数组还是对象,取决于你的数据是否需要顺序(用数组),还是需要通过唯一的字符串标识符来访问(用对象)。

重要注意事项

  1. 相等性比较:在Javascript中,使用 == 进行相等比较时,如果类型不同,它会尝试进行类型转换再比较,这可能导致意想不到的结果。最佳实践是始终使用严格相等运算符 ===(值和类型都相等)和严格不相等运算符 !==

    console.log(5 == '5'); // true (类型转换)
    console.log(5 === '5'); // false (类型不同)
    console.log(0 == false); // true
    console.log(0 === false); // false
    
  2. 注释:Javascript的注释语法与C语言相同。

    // 这是单行注释
    
    /*
    这是
    多行
    注释
    */
    

  1. 一切都是对象:在Javascript中,几乎所有东西(数组、函数、甚至数字和字符串)在底层都可以被视为对象,这意味着它们可以拥有属性和方法。例如,数组有 .length 属性、.push() 方法;字符串有 .toUpperCase().slice() 等方法。这是语言强大的一部分,但也意味着有更多需要学习的内容。

总结

本节课中我们一起学习了Javascript的基础知识。我们了解了它作为一门高级、解释型脚本语言的特点,以及它与C语言在类型系统、内存管理和执行方式上的主要区别。我们掌握了如何使用 letconst 声明变量,如何操作字符串(特别是模板字符串),以及控制结构和函数的写法。重点探讨了两种核心数据结构:数组(用于有序列表)和对象(用于键值对集合),并学习了如何遍历和操作它们。最后,我们提醒了严格相等 (===) 的重要性。Javascript是一门功能丰富且灵活的语言,初学时会感觉比C“庞大”,但通过实践,你会逐渐熟悉并享受其高效的开发体验。

020:JavaScript 语言特性与语法入门 🐻

在本节课中,我们将学习 JavaScript 语言的基础语法和核心特性。课程内容涵盖变量声明、数据类型、函数、控制流、循环以及一些高级概念,如一等函数和数组方法。这些基础知识是后续学习 Node.js、浏览器 JavaScript 和 React 等框架的基石。

运行 JavaScript 代码

JavaScript 代码可以在两个主要环境中运行:Node.js 命令行解释器和 Web 浏览器。

以下是运行代码的两种方式:

  • Node.js 命令行:如果你已安装 Node.js,可以在终端中运行 node 文件名.js 来执行代码。
  • Web 浏览器:在 HTML 文件中使用 <script> 标签嵌入 JavaScript 代码。代码的输出会显示在浏览器的开发者工具控制台中。

两种环境下的 JavaScript 语法是相同的。

变量声明与基本类型

JavaScript 是弱类型语言,包含字符串、数字、布尔值等基本类型。使用 let 声明可变变量,使用 const 声明常量。

const constantValue = 0; // 常量,不可重新赋值
let variableValue = 0;   // 变量,可以重新赋值
variableValue = "buy";   // 弱类型,可以改变值的类型

除了基本类型,JavaScript 还有 nullundefined 来表示“空”或“未定义”。它们含义不同,且不相等。

let declaredButUndefined; // 值为 undefined
const emptyValue = null;  // 值为 null
console.log(typeof declaredButUndefined); // 输出 "undefined"
console.log(typeof emptyValue);           // 输出 "object" (JavaScript 的一个历史遗留特性)

对象与数组

对象是键值对的集合,类似于 Python 中的字典。数组是元素的有序列表。

// 对象
const person = {
  name: "Hayden",
  age: 22,
  gender: "male"
};
console.log(person.name); // 点号访问
console.log(person["age"]); // 方括号访问

// 数组
const list = [1, 2, 3, person];
console.log(list[0]); // 输出 1
console.log(list.length); // 输出 4

函数定义

JavaScript 有多种定义函数的方式。

// 方式 1:函数声明
function add(a, b) {
  console.log(a + b);
}

// 方式 2:函数表达式
const add = function(a, b) {
  console.log(a + b);
};

// 方式 3:箭头函数 (现代推荐)
const add = (a, b) => {
  console.log(a + b);
};

// 调用函数
add(1, 2); // 输出 3

箭头函数语法更简洁,特别是当函数体只有一行时,可以省略大括号和 return 关键字。

const double = (x) => x * 2;
console.log(double(5)); // 输出 10

类型检查与转换

使用 typeof 操作符可以检查值的类型。使用 Number()String() 等方法可以进行类型转换。

const num = 10;
const str = "10";

console.log(typeof num); // "number"
console.log(typeof str); // "string"

// 转换为字符串
const strFromNum = num.toString(); // 或 String(num)
// 转换为数字
const numFromStr = Number(str); // 或 parseInt(str, 10)

比较运算符

JavaScript 有宽松相等 (==) 和严格相等 (===) 两种比较方式。强烈建议始终使用严格相等 (===),因为它同时比较值和类型,行为更可预测。

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

const obj = { key: 'value' };
console.log(obj == "[object Object]"); // true (对象被强制转换为字符串)
console.log(obj === "[object Object]"); // false

控制流:条件与循环

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

// if 语句
if (condition) {
  // 执行代码
} else if (anotherCondition) {
  // 执行代码
} else {
  // 执行代码
}

// 三元运算符 (简洁的条件表达式)
const result = mark < 50 ? "Fail" : "Pass";

// for 循环
for (let i = 0; i < 5; i++) {
  console.log(i);
}

// for...of 循环 (遍历数组值)
const names = ["Hayden", "Jake", "Emily"];
for (const name of names) {
  console.log(name);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/66087a5991774271c6a1069657ed2916_8.png)

// for...in 循环 (遍历对象键)
for (const key in person) {
  console.log(key, person[key]);
}

真值 (Truthiness)

在条件判断中,JavaScript 会将值转换为布尔值。以下值被视为 假值 (falsy)false0"" (空字符串)、nullundefinedNaN。其他所有值都为 真值 (truthy)

if ("hello") { console.log("真值"); } // 会执行
if (0) { console.log("真值"); }       // 不会执行
if (null) { console.log("真值"); }    // 不会执行

数组方法:map 与 filter

mapfilter 是处理数组的强大方法,它们接受一个函数作为参数。

  • map:遍历数组,对每个元素应用函数,并返回一个由结果组成的新数组。
  • filter:遍历数组,根据函数返回的真假值筛选元素,并返回一个新数组。
const numbers = [1, 2, 3, 4, 5, 6, 7];

// 使用 map 将每个数字加倍
const doubled = numbers.map(num => num * 2);
console.log(doubled); // [2, 4, 6, 8, 10, 12, 14]

// 使用 filter 筛选出偶数
const evens = numbers.filter(num => num % 2 === 0);
console.log(evens); // [2, 4, 6]

// 链式调用:先筛选再加倍
const doubledEvens = numbers.filter(num => num % 2 === 0).map(num => num * 2);
console.log(doubledEvens); // [4, 8, 12]

一等函数 (First-class Functions)

在 JavaScript 中,函数是“一等公民”,这意味着函数可以像其他值(数字、字符串、对象)一样被赋值给变量、作为参数传递、或作为其他函数的返回值。

// 函数作为变量
const sayHello = () => "Hello";

// 函数作为参数
function greet(greetingFunction, name) {
  console.log(greetingFunction() + " " + name);
}
greet(sayHello, "Hayden"); // 输出 "Hello Hayden"

// 函数作为返回值 (高阶函数)
function createMultiplier(factor) {
  return function(number) {
    return number * factor;
  };
}
const double = createMultiplier(2);
console.log(double(5)); // 输出 10

理解函数作为值传递和函数调用之间的区别至关重要。someFunction 是传递函数本身,而 someFunction() 是立即调用该函数并传递其返回值。

总结

本节课我们一起学习了 JavaScript 的核心语法和特性。我们了解了如何声明变量和常量,认识了基本数据类型和对象,掌握了多种定义函数的方式。我们强调了使用严格相等 (===) 进行比较的重要性,并介绍了控制流和循环。我们还探讨了数组的 mapfilter 方法,它们能让你以声明式、简洁的方式处理数据。最后,我们接触了“一等函数”的概念,这是理解 JavaScript 中回调、高阶函数和许多现代框架模式的关键基础。掌握这些内容后,你已经具备了编写基础 JavaScript 代码并在 Node.js 或浏览器环境中运行的能力。

021:🛠️ 高级函数

在本节课中,我们将要学习 JavaScript 中函数的多种定义方式,以及“一等函数”和“高阶函数”这两个核心概念。这些概念是理解现代 JavaScript 编程风格(如回调函数、数组方法)的基础,并能帮助我们编写更简洁、更易维护的代码。

函数的三种定义方式

在 JavaScript 中,有三种主要的方式来定义一个函数。理解这些方式有助于我们理解函数在 JavaScript 中是如何被处理的。

方法一:传统函数声明

这是最经典、我们最熟悉的函数定义方式,类似于 C 语言中的函数定义。它使用 function 关键字,后跟函数名和参数列表。

function sum(a, b) {
    return a + b;
}

方法二:函数表达式

在这种方式中,我们将一个匿名函数赋值给一个变量(常量)。这强调了“函数可以像变量一样被赋值”的概念。

const sum = function(a, b) {
    return a + b;
};

方法三:箭头函数

这是现代 JavaScript 中最常用的简洁语法。它移除了 function 关键字,并使用箭头 (=>) 来连接参数和函数体。

const sum = (a, b) => {
    return a + b;
};

箭头函数还有一个重要的特性:如果函数体只有单行返回语句,可以省略大括号 {}return 关键字,进一步简化代码。

const sum = (a, b) => a + b;

上一节我们介绍了函数的三种定义方式,本节中我们来看看这些方式如何引出一个核心概念:一等函数。

一等函数

JavaScript 中的函数是“一等公民”或“一等函数”。这意味着函数可以像其他任何值(如字符串、数字)一样被使用。具体来说,函数可以被赋值给变量,可以作为参数传递给其他函数,也可以作为其他函数的返回值。

以下是一个将函数作为参数传递的示例:

// 定义两个格式化函数
const brackets = (str: string): string => `(${str})`;
const fullStop = (str: string): string => `${str}.`;

// 定义一个接受函数作为参数的函数
const sayHi = (name: string, format: (str: string) => string): string => {
    return `Hello ${format(name)}`;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/385031d2a9f0b9ff6eba7402c73d3934_31.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/385031d2a9f0b9ff6eba7402c73d3934_33.png)

// 使用
const result = sayHi("Hayden", brackets) + " " + sayHi("Hayden", fullStop);
console.log(result); // 输出:Hello (Hayden) Hello Hayden.

在这个例子中,sayHi 函数接受一个字符串 name 和一个函数 format 作为参数。通过传递不同的 format 函数(bracketsfullStop),我们可以灵活地改变输出格式,而无需重写 sayHi 函数的逻辑。

理解了函数可以作为参数传递后,我们自然会遇到一种常见情况:匿名函数。

匿名函数

当我们只需要在一个地方使用一个简单的函数,而不想为其专门命名时,可以使用匿名函数。匿名函数通常直接作为参数传递给其他函数(如 mapfilter)。

以下是使用匿名函数简化之前 sayHi 调用的示例:

// 直接内联定义函数,而不是先定义再传递
const result = sayHi("Hayden", (str) => `[${str}]`);
console.log(result); // 输出:Hello [Hayden]

匿名函数与数组方法结合使用时尤其强大。接下来,我们将探讨 JavaScript 数组中最常用的三个高阶函数方法。

数组的高阶函数方法:Map, Filter, Reduce

JavaScript 数组提供了 mapfilterreduce 等方法,它们都接受一个函数作为参数,用于对数组元素进行操作。这些方法可以极大地简化循环遍历数组的代码。

Map 方法

map 方法遍历数组的每个元素,对其应用一个函数,并返回一个由函数结果组成的新数组。新数组长度与原数组相同。

以下是使用循环和使用 map 方法的对比:

// 使用循环
const tutors = ["Sim", "Theresa", "Kai", "Michelle"];
const shoutedTutors = [];
for (const tutor of tutors) {
    shoutedTutors.push(tutor.toUpperCase() + "!");
}

// 使用 map 方法
const shoutedTutorsMap = tutors.map(tutor => tutor.toUpperCase() + "!");

Filter 方法

filter 方法遍历数组,对每个元素应用一个测试函数。只有使该函数返回 true 的元素会被包含在返回的新数组中。新数组长度可能小于或等于原数组。

以下是使用循环和使用 filter 方法的对比:

// 使用循环
const marks = [39, 43, 48, 24, 33];
const passMarks = [];
for (const mark of marks) {
    if (mark >= 50) {
        passMarks.push(mark);
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/385031d2a9f0b9ff6eba7402c73d3934_99.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/385031d2a9f0b9ff6eba7402c73d3934_101.png)

// 使用 filter 方法
const passMarksFilter = marks.filter(mark => mark >= 50);

Reduce 方法

reduce 方法将数组“缩减”为单个值。它接受一个“累加器”函数和一个初始值。该函数会遍历数组,将当前元素与当前的“累加值”合并,最终返回一个结果。

以下是使用循环和使用 reduce 方法求总和的对比:

// 使用循环
const students = [
    { name: "Hayden", mark: 55 },
    { name: "Juliana", mark: 43 },
    // ... 其他学生
];
let total = 0;
for (const student of students) {
    total += student.mark;
}

// 使用 reduce 方法
const totalReduce = students.reduce((prevTotal, student) => prevTotal + student.mark, 0);

我们已经看到了函数如何作为参数传递。现在,让我们看看更进阶的概念:函数也可以作为返回值,这就是高阶函数。

高阶函数

高阶函数是指那些返回另一个函数的函数。你可以把它们想象成“函数工厂”,用于生成具有特定行为的函数。

假设我们需要多个功能相似但细节不同的函数:

// 传统方式:定义多个独立函数
const congratulateMarkPass = (name: string) => `Congratulations ${name} on your pass!`;
const congratulateMarkCredit = (name: string) => `Congratulations ${name} on your credit!`;
// ... 更多

我们可以使用高阶函数来生成这些函数,避免重复代码:

// 高阶函数:生成特定祝贺语的函数
const generateCongratulateMark = (mark: string) => {
    // 返回一个新的函数
    return (name: string) => `Congratulations ${name} on your ${mark}!`;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/385031d2a9f0b9ff6eba7402c73d3934_119.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/385031d2a9f0b9ff6eba7402c73d3934_121.png)

// 使用高阶函数“工厂”来创建我们需要的具体函数
const congratulateMarkPass = generateCongratulateMark("pass");
const congratulateMarkCredit = generateCongratulateMark("credit");

// 使用生成的函数
console.log(congratulateMarkCredit("Hayden")); // 输出:Congratulations Hayden on your credit!

在这个例子中,generateCongratulateMark 是一个高阶函数。它根据传入的 mark 字符串,动态地创建并返回一个新的函数。这种方式在需要创建多个逻辑相似、仅某些参数或行为不同的函数时非常有用,可以使代码更简洁、更易于维护。

本节课中我们一起学习了 JavaScript 中函数的多种定义方式,深入理解了一等函数(函数可作为参数传递)和高阶函数(函数可作为返回值)的概念。我们还探讨了如何利用这些概念,通过 mapfilterreduce 等数组方法以及匿名函数来编写更简洁、更声明式的代码。掌握这些高级函数技巧,将帮助你更好地理解和运用现代 JavaScript 的编程范式,构建更清晰、更易维护的应用程序。

022:JavaScript 生态系统 🐻

在本节课中,我们将要学习 JavaScript 的生态系统。JavaScript 不仅仅是一门编程语言,它还是一个多层次的、应用广泛的技术集合。理解其不同组成部分(如语言标准、引擎和运行时环境)之间的区别与联系,对于掌握前端开发至关重要。

什么是 JavaScript?

JavaScript 是一门编程语言。但它的运行方式与我们熟悉的其他语言(如 Python 或 C)有所不同。我们可以在网页浏览器中运行 JavaScript,也可以在命令行中使用 Node.js 运行它。这引出了几个问题:网页浏览器是编译器吗?Node.js 是什么?React 和 JavaScript 是同一个东西吗?为了解答这些疑问,我们需要退一步,看看运行 JavaScript 时到底发生了什么。

语言定义与源代码

当我们编写一段简单的 JavaScript 代码时,例如:

console.log(“Hello World”);

这段文本就是源代码。它只是一系列遵循特定规则的 ASCII 字符。

源代码如何变成计算机上可运行的程序?这涉及到语言定义编译器/解释器

  • 语言定义:本质上是一套规则手册,规定了哪些语法是有效的,哪些是无效的。例如,ECMAScript 就是 JavaScript 的语言标准。
  • 编译器/解释器:是读取源代码、根据语言规则将其转换为可执行代码的程序。例如 Python 解释器、GCC(C 编译器)、Node.js 和网页浏览器中的 JavaScript 引擎。

上一节我们介绍了源代码和语言的基本概念,本节中我们来看看 JavaScript 的具体语言标准。

ECMAScript:JavaScript 的语言标准

我们通常所说的 JavaScript,其正式名称是 ECMAScript,常缩写为 ES。你可以将 JavaScript 视为 ECMAScript 标准的一种实现。对于入门学习,我们可以认为两者是相同的。

ECMAScript 标准会不断更新,发布新的版本。了解这些版本很重要,因为它们引入了新的语言特性。

以下是几个关键的 ECMAScript 版本:

  • ES5 (ECMAScript 2009):一个广泛支持且稳定的版本。
  • ES6 (ECMAScript 2015):一个重大的更新,引入了class(类)、let/const、箭头函数等许多现代特性。
  • ES2016, ES2017...:ES6 之后,标准开始按年份命名,每年都会有增量更新。

例如,JavaScript 中的 class 关键字就是在 ES6 中引入的。在之前的课程中,我们学习的大多数语法都属于较旧的 ES 版本,只有类算是较新的特性。

理解了语言标准后,我们来看看这些标准是如何被“执行”的。

引擎与运行时环境

将 ECMAScript 代码变成实际行动的,是 ECMAScript 引擎运行时环境

  • ECMAScript 引擎:核心部件,负责理解和执行 JavaScript 代码。它处理if语句、函数调用、变量运算等基础逻辑。最著名的引擎是 Google 的 V8(用于 Chrome 和 Node.js)。
  • 运行时环境:建立在引擎之上的一个层,提供了与特定平台交互的能力(API 或输入/输出层)。它决定了 JavaScript 能“做什么”。

关键区别:引擎负责“如何计算”,运行时环境负责“能接触到什么”。

最常见的两种运行时环境是:

  1. 网页浏览器(如 Chrome、Safari):提供了操作网页(DOM)、获取窗口大小、发起网络请求等 API。
  2. Node.js:提供了操作文件系统、运行命令行工具、创建服务器等 API。

同一个 V8 引擎,可以分别用于 Chrome 浏览器和 Node.js 运行时,但它们提供的 API 完全不同。

环境差异示例

为了更直观地理解引擎和运行时环境的区别,让我们看两个具体的代码示例。

以下代码展示了浏览器运行时环境特有的 API(window对象):

// 这段代码只能在浏览器中运行
console.log(window.innerWidth); // 打印浏览器窗口的宽度

如果你在 Node.js 中运行上述代码,会得到 ReferenceError: window is not defined 的错误,因为 window 对象是浏览器环境提供的,不是 JavaScript 语言或 V8 引擎的一部分。

相反,以下代码展示了 Node.js 运行时环境特有的 API(文件系统模块):

// 这段代码只能在 Node.js 中运行
const fs = require(‘fs’); // ‘require’ 是 Node.js 的模块引入语法
fs.writeFileSync(‘hello.txt’, ‘Hello World’);

如果你在浏览器中运行上述代码,会得到 ReferenceError: require is not defined 的错误,因为 requirefs 模块是 Node.js 环境提供的。

当然,也有一些功能在两个环境中都能实现,但实现方式可能不同,例如发起网络请求(在浏览器中用 fetch,在 Node.js 中可以用 http 模块)。这些功能同样是运行时环境提供的 API,而非语言核心。

总结

本节课中我们一起学习了 JavaScript 生态系统的核心组成部分:

  1. ECMAScript 是 JavaScript 的语言标准,有不同的版本(如 ES5、ES6)。
  2. ECMAScript 引擎(如 V8)是执行 JavaScript 代码的核心。
  3. 运行时环境(如浏览器和 Node.js)建立在引擎之上,提供了特定的 API,使得 JavaScript 能在不同场景下发挥作用(操作网页或操作服务器/文件)。

理解这三者的关系,能帮助你明白为何同一段 JavaScript 代码在不同环境下可能有不同的表现和能力,这是成为合格 JavaScript 开发者的重要一步。在接下来的课程中,你将分别深入学习浏览器中的 JavaScript 和 Node.js 环境。

023:🌏 包管理

在本节课中,我们将要学习包管理。这是项目管理和协作开发中的核心概念。我们将了解为什么需要包管理器,如何使用 NPM(Node Package Manager)来安装和管理他人编写的代码库,以及如何确保团队成员之间的开发环境一致。


上一节我们介绍了前端开发的基础,本节中我们来看看如何高效地使用和管理外部代码库。

为什么需要包管理?

在编程时,我们经常需要使用他人编写的代码库。例如,在 C 语言中,你可以使用 #include <stdio.h> 来引入标准库。然而,在 JavaScript 中,许多有用的库(如验证日期的库)并非预装在计算机上。我们需要一种方法来下载、安装和管理这些外部代码库,并在多台计算机上保持一致性。

初识 NPM

NPM(Node Package Manager)是 Node.js 的包管理器。当你安装 Node.js 时,NPM 也会被一同安装。它主要有两个核心功能:

  1. 从互联网上的中央仓库(npmjs.com)下载和管理代码包。
  2. 通过配置文件记录项目所依赖的包,确保环境可重现。

以下是两个最常用的命令行工具:

  • node:用于执行 JavaScript 代码。
  • npm:用于管理 JavaScript 模块。

一个具体的例子:安装并使用库

假设我们需要一个函数来验证日期是否有效。我们可以使用一个名为 date-fns 的流行库。

首先,我们尝试直接使用该库中的 isValid 函数:

// dates.js
import { isValid } from 'date-fns';

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/a9702cfd27d7d2f4b90bf2efed561a1a_15.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/a9702cfd27d7d2f4b90bf2efed561a1a_16.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/a9702cfd27d7d2f4b90bf2efed561a1a_18.png)

function dateIsValid(year, month, day) {
  return isValid(new Date(year, month, day));
}

console.log(dateIsValid(2023, 13, 1)); // 月份13是无效的

如果直接运行 node dates.js,你会遇到一个错误:Cannot find package 'date-fns'。这是因为 date-fns 库还没有被安装到你的项目中。

创建并配置 NPM 项目

要安装库,你需要先初始化一个 NPM 项目。这会在当前目录下创建一个 package.json 文件,它是项目的“蓝图”或“食谱”。

  1. 创建一个新文件夹并进入:
    mkdir my-project && cd my-project
    
  2. 初始化 NPM 项目:
    npm init -y
    
    -y 参数会使用默认值快速生成 package.json 文件。
  3. 为了让项目支持 import 语法,需要在 package.json 中添加一行:
    {
      "name": "my-project",
      "version": "1.0.0",
      "type": "module", // 添加这一行
      ...
    }
    

安装依赖包

现在,我们可以安装 date-fns 库了:

npm install date-fns

执行这个命令后,会发生以下几件事:

  1. NPM 会从 npmjs.com 下载 date-fns 库及其所有代码。
  2. 这些代码会被保存在项目根目录下的 node_modules 文件夹中。这是存放所有“食材”(即下载的库)的地方。
  3. package.json 文件中会自动添加一个 dependencies 字段,记录了你所安装的包及其版本。

{
  "name": "my-project",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "date-fns": "^4.4.2"
  }
}

现在再次运行 node dates.js,代码就能成功执行并输出结果了。

理解关键文件

以下是 NPM 项目中的三个关键文件/文件夹:

  1. package.json:项目的“食谱”。它列出了项目的基本信息以及所有依赖项,但不包含实际的代码。这个文件必须提交到 Git 等版本控制系统中。
  2. node_modules:项目的“食材仓库”。所有通过 npm install 下载的库的实际代码都存储在这里。这个文件夹通常提交到版本控制系统,因为它体积庞大且可以根据 package.json 重新生成。
  3. package-lock.json:版本的“精确快照”。它由 NPM 自动生成,记录了每个依赖包的确切版本号以及它们之间的依赖关系树。这确保了所有团队成员和部署环境安装完全一致的包版本。这个文件必须提交到版本控制系统。

团队协作与依赖管理

package.jsonpackage-lock.json 的强大之处在于确保了环境的一致性。

当你的队友克隆了项目代码后,他们只需要运行一个命令:

npm install

NPM 会读取 package.jsonpackage-lock.json,然后自动下载所有指定版本的依赖包到他们本地的 node_modules 文件夹中。这样,所有人的开发环境就完全一致了。

如果你需要安装新库,使用 npm install <library-name>。安装后,记得将更新后的 package.jsonpackage-lock.json 提交到代码仓库。

自定义脚本

package.jsonscripts 字段中,你可以定义一些快捷命令。

例如,添加一个运行日期检查的脚本:

{
  "name": "my-project",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "check-date": "node dates.js",
    "start": "node index.js"
  },
  "dependencies": {
    "date-fns": "^4.4.2"
  }
}

然后,你可以通过以下命令来运行这些脚本:

npm run check-date
npm run start
# `start` 是一个特殊脚本,可以直接用 `npm start` 运行

这在后续设置项目构建、测试等自动化流程时非常有用。


本节课中我们一起学习了包管理的基础知识。我们了解了为什么需要 NPM,如何创建项目、安装和管理外部依赖库。最重要的是,我们掌握了通过 package.jsonpackage-lock.json 来保证项目环境可重现的方法,这是现代软件开发中团队协作的基石。下一节,我们将探讨如何利用这些工具来构建更复杂的项目。

024:JavaScript NodeJS 🐸 NPM (进阶)

在本节课中,我们将更详细地探讨 NPM 和 Yarn 这两个 JavaScript 包管理工具。我们将学习 package.json 文件的结构、不同类型的依赖项、如何创建自定义脚本,以及 NPM 与 Yarn 之间的主要区别。

📦 理解 package.json 文件

到目前为止,你应该已经知道 NPM 和 Yarn 都是 JavaScript 的包管理系统。它们允许你从网络安装库并在你的应用中使用。你可以使用 npm install 安装新包,使用 npm start 启动你的应用等。你也应该知道 package.json 文件的存在,并且 Yarn 是 NPM 的一个替代品。

右侧是一个典型的、使用 create-react-app 创建的 React 应用的 package.json 文件示例。它包含元数据、依赖信息等内容。请注意,在本讲座中,特别是在 package.json 的上下文中,Yarn 和 NPM 可以互换使用。

元数据字段

当你将一个包发布到 NPM 时,package.json 中的许多字段会被用来生成显示在你包信息页面上的内容,该页面位于 npmjs.org

nameauthorcontributors 等字段只是与包相关的元数据,用于向你的包的使用者传递信息。license 是一个特例,因为它不仅显示在页面上,还具有法律意义。

让我们看一个例子。这是 React 的 NPM JS 页面。这里显示了很多元数据,例如许可证、主页、仓库。我们还有版本号,稍后会讨论。协作者列在底部,左侧有关键词,当然,顶部是包的名称。

版本字段

version 字段指定了你包的当前版本。如果你对一个包进行了更改,你也应该更改版本号。当你安装一个包时,你指定希望安装的包版本,这映射到 package.json 中显示的包版本。

版本使用语义化版本控制来指定。版本号的第一个数字是主版本号,第二个数字是次版本号,第三个数字是修订版本号。它们代表不同的含义。

  • 主版本号:当你更改主版本号时,表示 API 发生了破坏性变更。如果有人升级主版本,他们的应用可能会遇到破坏。
  • 次版本号:次版本号的更改意味着你添加了新功能,但以向后兼容的方式进行。因此,可能会有新功能,但如果你升级,不应该预期有任何破坏。
  • 修订版本号:修订版本号的更改意味着你进行了向后兼容的错误修复,因此你可以安全地升级修订版本,而无需担心任何东西会损坏。

私有字段

private 字段可以防止你意外地将包发布到 NPM。你可以使用 npm publish 命令发布,但如果 private 设置为 true,发布命令将会失败。

🔗 依赖项详解

你可能已经熟悉 dependencies 字段。当你使用 npm installyarn add 时,一个条目会被添加到 package.json 中。

dependencies 列表是你指定希望使用的库版本的地方。你可以看到这里开头有一些特殊字符。这些允许你指定版本范围。

在幻灯片中,我列出了指定版本范围的方法,以便你可以获取最新的主版本、次版本或修订版本。根据你的使用情况,你可以选择使用其中任何一种。一个生产级别的应用可能只想要最新的修订版本,以降低出现破坏性变更的风险;但对于你业余时间的一个有趣实验,你可能想要最新的主版本。

还有其他符号可以用来指定版本的最小和最大边界,或任何大于或小于某个版本的版本。这些超出了本幻灯片的范围,我留下了一个链接供你自行查阅。

一个普通的 package.json 会有一个 dependencies 字段,但这并不是我们指定依赖项的唯一地方。我们还可以有 devDependenciespeerDependenciesoptionalDependencies。任何你喜欢的依赖项也可以添加到这些字段中。但它们的作用截然不同。

广义上讲,依赖项就是你的代码需要的任何东西。然而,我们有四个不同的字段,它们意味着不同的事情。

  • dependenciespackage.json 中的 dependencies 字段是应用程序运行所需的任何东西。React 就是一个例子,如果你正在制作一个 React 应用。
  • devDependenciesdevDependencies 不是运行应用程序所必需的,但在构建应用程序时是必需的。测试库和 Linter 就是例子,因为你需要这些来构建,但你的用户不需要这些来查看你的页面。
  • peerDependenciespeerDependencies 不太常见。假设你有一个包 A,它有一个对等依赖项 B。如果我安装包 A,我也需要独立安装包 B。否则,包 A 将无法工作。在 React 应用中,react-dom 就是这样一个例子,你不仅安装 react 库,还安装 react-dom 库。对于普通依赖项,如果我安装了一个有其他依赖项的依赖项,NPM 会为我们解析这些并安装子依赖项。然而,如果是对等依赖项,则不会发生这种情况,依赖项解析的传递性就丢失了。
  • optionalDependenciesoptionalDependencies 是我们会尝试安装的依赖项。但如果 NPM 或 Yarn 无法安装它,它仍然会将安装列为成功,并且不会发生任何坏事。

在一个普通的应用程序中,你通常不会看到很多对等依赖项或可选依赖项。它们更多是在你发布库或包时使用。然而,它们确实有一些优势。对等依赖项允许你确保你的包的使用者安装了特定版本的另一个包,以便这些包保持同步。如果你依赖某个包的新版本中的某个功能,你可以确保用户也安装了支持该功能的该包版本。可选依赖项适用于这样的情况:虽然你可能想要一个依赖项,但你可能不需要它。一个很好的例子是像样式化控制台日志这样的东西。拥有它会很好,但如果依赖项安装失败,你仍然希望应用程序能够正常运行并正常输出到日志。

🛠️ 脚本字段

scripts 字段允许你指定 NPM 可以代表你运行的命令行脚本。当你运行 npm startyarn start 时,你实际上是告诉 NPM 运行 package.json 中指定的 scripts.start 命令。

scripts 对象是一个键值对象,其中键是 NPM 识别的命令,值是我们运行的实际 shell 命令。然而,有一些命令,如 install,不能被覆盖。

让我们看一个例子。我有一个简单的 React 应用,并且我已经将 prettier 安装为开发依赖项。Prettier 是一个自动格式化文件的工具,你可以看到右边我的 App.js 文件格式不正确。所以,我将构建一个脚本,允许我通过 NPM 运行 prettier。我将添加一个 prettier 条目。在最后,我指定了 prettier 命令,它表示写入 src 文件夹。就这么简单。现在,如果我移动到我的终端,我可以使用 NPM 或 Yarn 来运行这个命令。在这种情况下,我将使用 NPM,所以我输入 npm run prettier,它就会按照我们在 package.json 中指定的那样,在 src 文件夹上运行 prettier 命令。如果我重新打开 App.js,你可以看到组件已经按照应有的方式正确格式化。

⚙️ 扩展字段

需要注意的一点是,package.json 只是一个 JSON 文件。某些库可能会选择任意地使用新字段来扩展它,这些字段库可以在需要时尝试读取。某些库的配置通常保存在 package.json 中。

eslintConfig 就是这样一个例子。它为我们的 linter 提供了一个配置。在这种情况下,它表示使用 react-app linting 预设。browserslist 是另一个例子。它被一个名为 browserslist 的插件使用,它允许我们指定我们的应用程序支持哪些浏览器类型。browserslist 的确切语法超出了本讲座的范围,但是,你可以查看 browserslist 包来了解语法。然而,你可以在这里看到,我们根据是在生产环境还是开发环境中来指定不同的支持版本,对于我们的开发环境,我们指定了 Chrome、Firefox 和 Safari 的最新版本。

以上就是关于 package.json 的内容。现在,你应该对一个基本的 React 应用的典型 package.json 有了很好的了解。

🔄 NPM 与 Yarn 的区别

现在,让我们看看 Yarn 和 NPM。有什么区别?记住一些历史很重要。在 Yarn 创建的时候,Facebook 正在使用 NPM 并遇到了一些麻烦。NPM 很慢。它不是确定性的,这意味着给定两台计算机上完全相同的 package.json,安装后 node_modules 文件夹的结构在两台计算机上是不同的。它也没有办法锁定版本,以便每个开发人员始终使用每个包的相同版本,版本范围等依赖项解析经常导致开发人员拥有不同或冲突的版本。Yarn 的开发就是为了解决这些问题。

在发布时,Yarn 速度明显更快。它使用了确定性的安装算法,并且使用了锁定文件,这允许我们锁定我们的依赖项。

首先,在 Yarn 中,彼此没有依赖关系的包可以在单独的线程中安装,这意味着安装时间被缩短了。话虽如此,NPM 后来发布了 NPM 5 更新,缩小了两者之间的差距。然而,在 Yarn 发布时,据报道,安装过程可以从几分钟减少到几秒钟。

Yarn 的另一个显著优势是锁定文件。锁定文件记录了在计算机上安装的依赖项的确切版本。然后,你可以将锁定文件提交到版本控制中,此时如果另一个开发人员安装该包,他们将获得与第一个开发人员完全相同的版本,确保两台计算机上的两个包的版本保持同步,并且你不会遇到任何与冲突或不同版本相关的问题。

在 Yarn 中,每次安装或更新包时都会创建一个锁定文件。你可以将此锁定文件检入版本控制,这确保了下次检出安装时,版本保持不变。然而,在 NPM 中,也存在一个名为 npm shrinkwrap 的命令,它会创建一个 npm-shrinkwrap.json 文件,做同样的事情。区别在于,在 NPM 中,为了获得锁定文件,你需要显式地运行 shrinkwrap 命令,而在 Yarn 中,它会自动为你完成。

这就总结了 NPM 和 Yarn 之间的区别。还有一些我没有列出的性能优化,你可以在 Facebook 的工程博客或 Github 上找到。

📝 总结

在本节课中,我们一起学习了 package.json 文件的结构、不同类型的依赖项如何工作、如何创建自己的脚本,以及 Yarn 和 NPM 之间的区别。请继续关注未来关于 NPM 的讲座,同时,祝一切顺利。

025:NodeJS 演示教程 🚀

在本节课中,我们将通过两个具体的编程演示,学习如何在 NodeJS 环境中使用 JavaScript。我们将了解如何读取命令行参数、处理日期、使用外部库、读写文件,以及如何创建一个简单的 HTTP 服务器。

上一节我们介绍了课程的整体结构和本周的学习目标,本节中我们来看看具体的 NodeJS 演示。

演示一:处理日期并输出 JSON 文件

在这个演示中,我们将编写一个 NodeJS 脚本,从命令行读取日期,计算这些日期距今的天数,并将结果以 JSON 格式保存到文件中。

步骤 1:设置项目与读取命令行参数

首先,我们需要创建一个 NodeJS 项目并读取从命令行传入的参数。

以下是创建项目并读取 process.argv 的代码示例:

// hello.js
const process = require('process');
const argv = process.argv;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/9b2334de6b7bce29a33363e7b68f3198_15.png)

let dates = [];
for (let i = 2; i < argv.length; i++) {
    dates.push(argv[i]);
}
console.log(dates);

运行脚本时,process.argv 的前两个元素是 NodeJS 执行路径和脚本文件路径,因此我们从索引 2 开始读取用户输入的日期。

步骤 2:计算日期差

接下来,我们需要计算每个输入日期距离今天的天数。我们将使用一个名为 date-fns 的库来简化日期计算。

首先,安装 date-fns 库:

npm install date-fns

然后,在脚本中使用它来计算天数差:

const { differenceInCalendarDays } = require('date-fns');

let dateDaysSincePairs = {};
for (let dateStr of dates) {
    let targetDate = new Date(dateStr);
    let daysBetween = differenceInCalendarDays(new Date(), targetDate);
    dateDaysSincePairs[dateStr] = daysBetween;
}
console.log(dateDaysSincePairs);

这段代码遍历日期数组,为每个日期计算与当前日期的日历天数差,并将结果存储在一个对象中。

步骤 3:将结果写入 JSON 文件

最后,我们需要将包含日期和天数差的对象写入一个 JSON 文件。我们将使用 NodeJS 内置的 fs(文件系统)模块。

以下是写入文件的代码:

const fs = require('fs');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/9b2334de6b7bce29a33363e7b68f3198_42.png)

let jsonOutput = JSON.stringify(dateDaysSincePairs);
fs.writeFileSync('dates.json', jsonOutput);
console.log('数据已写入 dates.json 文件。');

JSON.stringify() 方法将 JavaScript 对象转换为 JSON 字符串,然后 fs.writeFileSync 将其同步写入文件。

上一节我们完成了第一个演示,学会了基本的文件操作和库的使用。本节中我们来看看第二个更复杂的演示:创建一个简单的 HTTP 服务器。

演示二:创建简单的 HTTP 服务器

在这个演示中,我们将使用 Express 框架创建一个 HTTP 服务器。该服务器将提供一个 /scrape 路由,用于接收 URL 和 HTML 标签,并返回该标签在指定网页中出现的次数。

步骤 1:设置 Express 服务器

首先,创建一个新的文件(如 server.js)并初始化一个 Express 应用。

安装 Express 框架:

npm install express

创建基本的服务器代码:

// server.js
const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => {
    res.send('Hello World!');
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/9b2334de6b7bce29a33363e7b68f3198_72.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/9b2334de6b7bce29a33363e7b68f3198_74.png)

app.listen(port, () => {
    console.log(`服务器运行在 http://localhost:${port}`);
});

运行 node server.js 后,访问 http://localhost:3000 将会看到 “Hello World!”。

步骤 2:创建 /scrape 路由并获取查询参数

我们需要创建一个 /scrape 路由,它通过 URL 查询参数接收 urltag

以下是捕获查询参数的代码:

app.get('/scrape', (req, res) => {
    const url = req.query.url;
    const tag = req.query.tag;
    console.log(`URL: ${url}, Tag: ${tag}`);
    // 暂时返回一个模拟的计数
    res.json({ count: 5 });
});

现在,访问 http://localhost:3000/scrape?url=https://example.com&tag=div 将会在服务器终端打印出 URL 和标签,并返回 {"count":5}

步骤 3:获取网页内容并统计标签

为了真实地统计标签,我们需要获取指定 URL 的网页内容。我们将使用 sync-request 库来同步地获取网页 HTML。

安装 sync-request 库:

npm install sync-request

然后,在路由中获取网页内容并统计标签出现次数:

const request = require('sync-request');

app.get('/scrape', (req, res) => {
    const url = req.query.url;
    const tag = req.query.tag.toLowerCase(); // 转换为小写以统一处理

    // 获取网页内容
    const response = request('GET', url);
    const allHtml = response.getBody('utf8').toLowerCase();

    // 简单的统计方法:通过分割字符串来计数
    // 注意:这种方法不完美,仅用于演示
    const count = (allHtml.split(`<${tag}`).length - 1) + (allHtml.split(`<${tag} `).length - 1);

    res.json({ count: count });
});

这段代码获取网页的 HTML 内容,将其转换为小写,然后通过计算特定字符串(如 <div<div )出现的次数来估算标签数量。请注意,这种方法在遇到复杂或格式不规范的 HTML 时可能不准确,但它演示了基本的思路。

总结

本节课中我们一起学习了 NodeJS 的两个核心应用演示。

在第一个演示中,我们:

  • 读取了命令行参数。
  • 使用 date-fns 库进行日期计算。
  • 将 JavaScript 对象转换为 JSON 并写入文件。

在第二个演示中,我们:

  • 使用 Express 框架创建了一个 HTTP 服务器。
  • 定义了路由并处理了查询参数。
  • 使用 sync-request 库获取了远程网页内容。
  • 实现了一个简单(但不完美)的 HTML 标签计数器。

这些练习帮助你熟悉了在终端环境中使用 JavaScript 的基本流程,包括项目初始化、包管理、使用外部库以及处理输入输出。虽然第二个演示中的服务器功能在本次前端课程中不会直接用到,但它有助于你理解 Web 应用前后端交互的基本原理。接下来,请继续学习本周关于 Web JavaScript 的预录课程,为完成作业打下坚实基础。

026:JavaScript 在浏览器中的引入方式 🦄

在本节课中,我们将要学习如何在网页中引入 JavaScript 代码。我们将探讨两种主要的引入方式:内联引入和外部引入,并分析它们各自的优缺点以及最佳实践。

大家好,我是 Hayden,今天我们来谈谈浏览器中的 JavaScript。如果你已经学习到了这部分内容,通常意味着你已经对 JavaScript 语言本身有了一些了解,比如如何进行数字运算、使用 for 循环和 while 循环。你可能也初步接触了 DOM(文档对象模型),它使得 JavaScript 能够与 HTML 页面进行交互。你的 HTML 页面上有许多元素,而 JavaScript 经过增强后可以修改这个 DOM。这就是我所说的“浏览器 JavaScript”。还有另一种 JavaScript,用于 Node.js 或命令行脚本,这会在其他课程中讨论。不过,这两种本质上都是 JavaScript,只是在两端增加了一些额外功能。本节课,我们将更深入地探讨 JavaScript 如何操作 DOM,特别是假设你已经开始了编写 JavaScript 的旅程,但问题是:你把 JavaScript 代码放在哪里?如何将它包含在页面中?

如何将 JavaScript 包含在网页中

有两种主要方式:你可以将其内联包含,也可以通过外部链接引入。

以下是两种方式的代码示例。

方式一:内联引入

第一个例子是 my_page1.html。请注意,这是一个 HTML 文件,而不是 JavaScript 文件。它是一个包含了 JavaScript 代码的 HTML 文件。

<!DOCTYPE html>
<html>
<head>
    <title>内联 JS 示例</title>
</head>
<body>
    <script type="text/javascript">
        let result = 1 + 2;
        console.log(result); // 输出 3
    </script>
</body>
</html>

如果你有一个 <script> 标签(通常最好包含 type="text/javascript"),你可以直接在标签内编写 JavaScript 代码并运行它。将上述 HTML 文件拖入浏览器并打开控制台,刷新页面后,你会看到控制台打印出数字 3。这是因为代码执行了 1 + 2 的加法运算,并通过 console.log 输出了结果。

方式二:外部引入

另一种方式是通过外部文件引入 JavaScript。我们仍然使用 <script> 标签,但不在标签之间放置代码,而是通过 src 属性指定一个文件路径。这个路径可以是相对路径或绝对路径。

以下是 my_page2.html 文件:

<!DOCTYPE html>
<html>
<head>
    <title>外部 JS 示例</title>
</head>
<body>
    <h1>Hello</h1>
    <script type="text/javascript" src="my_work.js"></script>
</body>
</html>

这是外部的 my_work.js 文件:

// my_work.js
let result = 1 + 2;
console.log(result); // 输出 3

现在,HTML 文件包含了这个 JavaScript 文件。打开这个网页,控制台同样会打印出 3。但如果你查看页面源代码,会发现它只是一个链接到外部文件的 <script> 标签。

如何选择引入方式

自然会产生一个问题:我该选择哪种方式?答案是,这涉及到一些权衡。

外部引入的优势

如果你使用我们看到的第二种方法(外部链接 JavaScript),首先会看到浏览器缓存带来的性能提升。当浏览器从服务器加载像 my_page2.html 这样的页面时,它通常会尝试获取该页面的新版本,因为页面内容可能会改变。然而,在解析(处理)页面时,如果浏览器看到外部链接,它通常会尝试缓存这些文件。这意味着浏览器会在本地存储一个副本,这样就不必每次都向服务器发起网络请求,因为请求更多文件会消耗更多时间,拖慢你的网络速度。

例如,当你访问 my_page2.html 时,浏览器会加载 HTML 页面和 JavaScript 文件。通常,JavaScript 文件本身会被缓存。这意味着,每次浏览器重新加载此页面时,在尝试获取文件之前,它会检查:“我已经有这个文件了,所以不需要再次加载它。” 因此,如果你已经拥有该文件,外部引入有时会更快。

如果你的某个文件在每个页面、每次加载时都需要,那么将其外部化是有好处的。相比于将其作为 HTML 文件的一部分(这会使文件变大,传输速度变慢),外部化并允许其被缓存是更好的选择。

另一个潜在优势是,你的初始 HTML 文档加载时间可能会更短,因为你只需要加载一个更小的文档(具体取决于你将脚本放在哪里)。

内联引入的优势

内联放置 JavaScript 的好处通常在于:它节省了初始的网络请求。因为外部加载 JavaScript 的缓存优势只有在文件被缓存后才会体现,而要让文件被缓存,你首先必须加载它一次。所以这个好处不是立竿见影的,只会在第一次加载之后发生。

一般性建议

作为一般规则,我们鼓励尽可能将 JavaScript 放在外部脚本中。不过,你可能希望限制外部脚本的数量,将其保持在最少的几个文件,因为浏览器获取少量文件的速度很快,但如果你试图加载几十个文件,速度可能会相当慢。

想将 JavaScript 外部化的一个主要情况是,如果你的代码只有一两行。例如,你几乎永远不会将上面那个简单的加法示例外部化,因为代码量太小,从缓存中获得的收益微乎其微。对于小文件,大部分时间不是花在数据传输上,而是花在建立连接等固定成本上。因此,如果这是你页面上所有的 JavaScript,将其外部化并不划算。

在页面中的放置位置

上一节我们介绍了如何引入代码,本节我们来看看在页面中放置代码的位置。对于一个 HTML 页面,你可以在几个不同的地方包含 JavaScript 代码:可以放在页面的 <head> 标签内,可以放在 <body> 标签的顶部,也可以放在 <body> 标签的底部。

显然,你也可以放在 <body> 的中间,但通常没有理由这样做。

如果你将 JavaScript 放在 <head> 标签内或 <body> 的顶部,会发生的情况是:你的 JavaScript 代码会在页面渲染之前运行。因为 DOM 的生成和页面的构建是线性地、从上到下进行的。因此,在真正构建页面之前,你需要执行的 JavaScript 代码越多,你的页面构建速度就越慢。

出于这个原因,我们倾向于将所有脚本放在 <body> 的底部。这样做的原因是:我们希望整个页面先加载完毕,然后一旦加载完成,再开始花费时间执行脚本。试想一下用户如何使用网页:他们对立即看到页面渲染和加载非常敏感。他们希望页面快速呈现,即使动态内容稍晚一点加载也没关系。例如,加载 Facebook 页面时,你希望它快速出现,即使需要一秒钟来加载时间线或填充聊天内容。因此,初始加载时间非常重要。

这就是为什么我们通常将脚本放在页面底部:先完成加载时间,然后再进行更有趣的处理。

示例:脚本放置

在一个典型的网页中,你会这样做:

<!DOCTYPE html>
<html>
<head>
    <title>脚本放置示例</title>
</head>
<body>
    <h1>Hello World</h1>
    <div id="main-page">Loading...</div>
    <!-- 所有脚本放在 body 末尾 -->
    <script type="text/javascript" src="my_work.js"></script>
</body>
</html>

然后,在 my_work.js 中,你可以在页面加载后更新内容:

// my_work.js
// 假设进行了一些检查(如用户登录验证)
document.getElementById('main-page').innerHTML = 'Welcome!';
document.getElementById('main-page').style.display = 'block';

当加载这个页面时,它会先显示“Loading...”。在 JavaScript 文件被处理之后,它会立即更新这个 <div> 的内容。如果你仔细观察一些刷新操作,实际上可以看到“Loading...”闪现一下。这就是页面尽可能快地渲染,然后 JavaScript 再执行并更新它。你在很多网站上都能看到这种现象:先加载出很多内容,然后某些部分会快速更新。这是正常的方式。

何时不放在底部?

很难想到太多例子,但一般来说,只有当你需要某些 JavaScript 在页面渲染之前执行时,才会将其放在顶部。例如,你可能需要编写一些 JavaScript 来发起网络请求,在用户看到页面之前验证他们是否是有效用户。但一般来说,你不会在日常开发中遇到这种情况。

对于上面那个“需要登录才渲染内容”的例子,更常见的做法是像我们刚才展示的那样:先放一个占位符(如“Loading...”),然后在底部的 JavaScript 中,在完成检查后更新这个占位符的内容。你甚至可以完全跳过占位符,直接用 JavaScript 在末尾构建整个内容。这里有很多选择,但通常将脚本放在末尾

总结

本节课中,我们一起学习了在网页中引入 JavaScript 的两种主要方式:内联引入外部引入。我们分析了外部引入在缓存和性能上的优势,以及内联引入在节省初始请求上的特点。我们还探讨了脚本在 HTML 页面中的最佳放置位置——通常是在 <body> 标签的底部,以确保页面能快速呈现给用户。记住,对于少量代码,内联可能是更简单的选择;而对于可重用或较大的代码块,外部引入并利用浏览器缓存是更佳实践。

027:DOM 操作入门 🧩

在本节课中,我们将学习文档对象模型(DOM)。我们将了解 DOM 是什么,探索它允许我们在 JavaScript 中做什么,并通过一些实时编码示例来实践。

网页构成回顾

几乎所有网页都由 HTML、CSS 和 JavaScript 构成。

  • HTML 用于定义页面的标记和结构。
  • CSS 用于为文档中的元素应用样式。
  • JavaScript 可用于动态修改页面内容。

什么是 DOM?

DOM 代表文档对象模型。它是一个接口,允许 JavaScript 通过浏览器与 HTML 进行交互。你可以将 DOM 视为网页的一种表示形式。浏览器接收你编写的 HTML,解析它,然后将其转换为 DOM。

回顾上面的图表,DOM 就像一个接口,允许 JavaScript 访问和更新网页的内容,例如内容、结构和样式。

DOM 的树状结构

DOM 的一个特点是它具有树状结构。在 HTML 中,标签可以拥有子标签,形成树状结构。DOM 也是如此。

以下是一个基本 HTML 及其对应 DOM 的示例:

<html>
  <head>
    <title>My Page</title>
  </head>
  <body>
    Hello
    <b>World</b>
  </body>
</html>

DOM 树的根节点称为 documentdocument 是客户端 JavaScript 中一个预定义的对象,代表整个 DOM 的根。

HTML 中最外层的 <html> 标签是 DOM 树的第一个子节点。<body> 标签有两个子节点:一些文本和一个 <b> 标签,这在 DOM 树中表示为两个子节点。

DOM 数据类型

上一节提到了 document 对象。document 对象是整个 DOM 树的根,其类型就是 Document

接下来是 Element 类型。Element 是 DOM 树中所有节点的类型。这个接口可用于操作页面上的元素并获取其信息。

最后是 NodeList 类型。它是一个类似数组的元素结构,由 querySelectorAll 等方法返回,我们稍后会看到。

深入了解 Element 类型

Element 是 DOM 中所有元素的类型。例如,页面上的一个 <div> 在 DOM 中就是一个 Element

但 JavaScript 会根据我们拥有的 HTML 元素类型,提供更具体的类型。例如:

  • 一个 <div> 将是 HTMLDivElement
  • 一个 <input> 标签将是 HTMLInputElement

有数十种这样的特定元素类型。基本上,Element 是所有 DOM 对象类型的基类,而像 HTMLElement 这样的类型是其子类型。它们拥有 Element 的所有属性和方法,并根据元素类型增加了一些额外功能。

Element 接口的功能

Element 接口具有许多属性和方法,我们可以使用它们来读写 DOM。

我们可以读取元素的大小、位置、颜色或其中的文本。我们也可以写入内容,例如更改样式,甚至添加和移除元素。

以下是 Element 的一些属性示例:

  • style:用于访问或修改内联样式,覆盖 CSS。
  • offsetLeftoffsetTop:获取元素的位置。
  • clientWidthclientHeight:获取元素的大小。

如前所述,不同的 HTML 标签类型在 DOM 中有不同的类型,它们添加了额外的属性和方法。例如:

  • HTMLImageElement 拥有 alt 属性(存储图像的替代文本)和 src 属性(图像的来源)。
  • HTMLInputElement 拥有 focus() 方法(聚焦文本框)和 select() 方法(选择输入框中的所有文本并聚焦)。

参考 MDN 文档

MDN 文档是查找不同类型信息的非常有用的资源。例如,HTMLElement 的页面列出了它从 Element 继承的所有属性,以及它自己添加的许多其他属性,如 draggablehiddenoffsetHeight 等。它还列出了事件处理程序和一些方法,如 blur()click()focus()

在侧边栏中,你可以看到 HTMLElement 的所有子类型,它们代表了所有不同的 HTML 标签。

如何获取 DOM 元素

我们已经看到如何在 JavaScript 中读取和操作 DOM 元素,但如何实际获取这些元素呢?

有几种方法可以检索 DOM 元素。本质上,当你知道元素的 ID、类名或标签类型时,可以使用这些信息来检索它们。

以下是几种方法:

  • document.getElementById(id):使用元素的 ID 属性。返回单个节点,因为 ID 在 HTML 中是唯一的。
  • document.getElementsByTagName(tagName):接受标签名(如 'div''img'),返回文档中所有具有该标签名的元素。
  • document.getElementsByClassName(className):与 getElementById 类似,但返回所有匹配的元素,因为类名不是唯一的。
  • document.querySelector(selector):返回与提供的选择器查询字符串匹配的第一个元素。
  • document.querySelectorAll(selector):返回与提供的选择器查询字符串匹配的所有元素。

选择器字符串的写法与 CSS 选择器相同:

  • 按 ID 查询:使用 #,例如 #myId
  • 按类名查询:使用 .,例如 .myClass
  • 按标签名查询:直接使用标签名,例如 div

如何写入 DOM

上一节我们学习了如何读取 DOM,现在来看看如何写入 DOM。

第一种方法是 document.createElement(tagName)。它用于创建一个元素作为变量,但不会将其插入到 DOM 中。它只创建元素,并接受标签名作为参数。例如:document.createElement('div')

我们也可以用类似的方式创建文本节点:document.createTextNode(string)

我们还有将节点添加到 DOM 中的方法。如果你有一个元素,可以使用 element.appendChild(newNode) 来添加新元素作为其子节点。

我们还可以设置元素的属性。例如,如果有一个按钮,可以在 JavaScript 中轻松设置属性,如添加 disabled 属性或 id 属性。

修改元素样式

除了向 DOM 添加新元素和修改其属性外,我们还可以更改元素的样式,类似于动态更改 CSS 样式。

我们通过元素的 style 属性来实现。这个 style 属性对应于 HTML 元素的 style 属性。通过 element.style 后跟任何 CSS 属性名,可以访问或修改该样式。

例如:

  • element.style.left = '50px';left 样式属性设置为 50 像素。
  • 要递增 left 值,可以先获取当前值,进行计算,然后重新设置。
  • element.style.backgroundColor = 'red'; 将背景颜色改为红色。注意,在 JavaScript 中,CSS 属性名使用驼峰命名法(如 backgroundColor),而不是连字符(如 background-color)。

需要注意的是,element.style.left 只有在通过内联样式或脚本设置了 left 属性时才会存在。如果 left 值仅设置在 CSS 中,尝试检索 element.style.left 将无效。

要获取元素的实际样式(即使不是通过内联样式或脚本设置的),可以使用 window.getComputedStyle(element)。然后,可以使用返回的计算样式对象的 getPropertyValue 方法来检索任何样式属性的值。

另一种修改样式的方法是更改元素上存在的类名,这可以通过 classList 属性来完成。例如:

  • element.classList.add('className'):添加一个类。
  • element.classList.remove('className'):移除一个类。
  • element.classList.toggle('className'):切换一个类(如果存在则移除,不存在则添加)。
  • element.classList.contains('className'):检查元素是否包含某个类。

滚动页面

在 JavaScript 中,我们还能做的一件很酷的事情是手动将页面滚动到特定位置。

window 对象提供了 scrollXscrollY,它们告诉我们页面的当前滚动位置。

此外,它还提供了 scrollTo() 方法。这个方法接受一个 top 位置、一个 left 位置和一个 behavior 参数(如 'smooth''auto'),允许我们通过 JavaScript 滚动到页面上的某个位置。

例如,要滚动到一个元素,可以先使用 getBoundingClientRect() 方法获取该元素的位置信息,然后调用 window.scrollTo()

getBoundingClientRect() 返回一个 DOMRect 对象,它提供了元素的大小和位置信息,例如 top 值表示元素顶部距离当前视口顶部的像素距离。

scrollTobehavior 参数设置为 'smooth' 可以实现平滑滚动动画。

总结

在本节课中,我们一起学习了文档对象模型(DOM)。我们了解了 DOM 是网页的编程接口,允许 JavaScript 动态访问和更新内容、结构和样式。我们探讨了 DOM 的树状结构、关键数据类型(如 DocumentElementNodeList),并学习了如何使用各种方法(如 getElementByIdquerySelector)来读取 DOM 元素。我们还实践了如何创建新元素、修改元素属性和样式,以及如何将它们添加到 DOM 中。最后,我们了解了如何使用 scrollTo 方法控制页面滚动。掌握这些基础知识是进行动态网页交互和构建丰富用户体验的关键。

028:JavaScript事件 🎯

在本节课中,我们将要学习JavaScript中的事件。事件是网页实现交互功能的核心,它允许我们的代码响应用户的操作,例如点击、键盘输入或鼠标移动。我们将从事件的基本概念开始,逐步学习如何监听事件、处理事件,并最终通过一个完整的拖放游戏示例来巩固所学知识。

什么是事件?

首先,我们来看看什么是事件。事件本质上是一个信号,表示DOM(文档对象模型)中的某个元素发生了一件事。如果我们监听这些信号,就可以运行JavaScript代码来响应它们。这个信号通常由用户的操作触发。

因此,事件是我们将用户交互融入应用程序的一种方式。

当我说“DOM元素发生了一件事”时,这通常意味着用户以某种方式与该元素进行了交互。

事件有多种形式,最常见的是鼠标事件和键盘事件。以下是一些鼠标事件的例子:

  • click:点击。
  • double click:双击。
  • mouse down:鼠标按下(点击的开始)。
  • mouse up:鼠标释放(点击的结束)。
  • mouse enter:鼠标光标进入元素区域。
  • mouse leave:鼠标光标离开元素区域。

以下是键盘事件的例子:

  • key down:按键被按下。
  • key press:按键被按下(通常代表字符输入)。
  • key up:按键被释放。

key downkey upkey press的区别在于:key down是首次按下键时触发,key up是释放键时触发,而key press代表一个字符被输入。一次key down可能会触发多次key press事件。

此外,还有许多其他类型的事件,其中一些仅适用于特定类型的元素。例如:

  • focus:当输入文本框获得焦点时触发。
  • canplay:视频元素的事件,当用户代理(浏览器)可以播放视频时触发。

幻灯片上列出了很多事件,但实际还有更多。建议你查阅MDN文档以了解所有可用的事件。

如何监听事件?

我们知道事件会发出信号,但我们如何实际监听这个信号呢?事件监听器 就是一个在事件发生时运行的处理器函数。

本质上,处理器是一种运行JavaScript回调函数以响应用户交互的方式。

那么,我们如何添加事件处理器呢?

主要有三种添加事件处理器的方法。

第一种方法是通过HTML属性添加。 以下是一个HTML按钮代码片段,我添加了一个onclick处理属性,并将要运行的JavaScript代码放在引号内。

<input type="button" value="Click me" onclick="alert('The button is clicked')" />

在这个例子中,当按钮被点击时,这里的JavaScript代码就会运行,弹出一个提示框显示“The button is clicked”。

第二种方法是设置DOM元素的属性。 当我通过getElementById或其他方法在JavaScript中获取一个元素后,我可以将一个函数赋值给该元素的onclick属性。

const element = document.getElementById('myButton');
element.onclick = () => {
    alert('The button was clicked');
};

onclick是DOM对象的一个属性。这里的语法是一个箭头函数,我将其赋值给onclick,这基本上就像一个简单的lambda或匿名函数。同样,当我们点击按钮时,它会提示“The button was clicked”。

一个小测验: 我这里有一个名为doSomething的函数,然后我写element.onclick = doSomething();。这段代码有什么问题?

这段代码的问题在于我在doSomething后面加了括号(),这意味着我是在执行这个函数(调用它),而不是将onclick赋值doSomething函数。

正确的写法应该是去掉括号:

element.onclick = doSomething; // 正确

DOM元素上的onclick回调对象是一个函数。

第三种,也是最终的方法是使用addEventListener函数。 这个函数接受两个参数:第一个是事件类型(例如'click''mousemove'),第二个是监听器,同样是一个回调函数。

element.addEventListener('click', () => {
    alert('The button was clicked');
});

添加事件监听器后,你也可以使用removeEventListener来移除它。你可以在本幻灯片最后两行看到addEventListenerremoveEventListener的用法。

addEventListener还接受第三个可选的options参数。options是一个对象,用于指定该事件监听器的一些额外设置。例如:

  • once:如果设置为true,意味着该监听器只能被调用一次,调用后会自动移除。
  • capture:一个布尔值参数,指示事件是否应该在捕获阶段被处理(我们稍后会探讨其含义)。
  • passive:也是一个布尔值,当设置为true时,意味着处理函数永远不会调用preventDefault()(我们也会在后面学习preventDefault的含义)。

你可以在MDN上查看关于addEventListener及其所有选项的详细信息。

事件对象

我们已经了解了如何设置一个函数作为事件监听器。现在,事件监听器实际上有一个可选的参数。这个处理器的参数是一个Event类型的对象,它包含了与已发生事件相关的所有详细信息。

以下是一个使用事件参数的小例子:

document.addEventListener('mousemove', (event) => {
    console.log(event.clientX, event.clientY);
});

这段代码通过addEventListener给文档添加了一个mousemove事件监听器,当鼠标在文档上移动时触发。然后在回调函数中,我使用console.log输出了event.clientXevent.clientY,它们代表了鼠标事件的坐标。

让我们在浏览器中测试一下这个鼠标移动的例子。我添加了事件监听器后,如果在文档上移动鼠标,可以在控制台看到我的鼠标坐标被不断打印出来。当我移动到左上角时,坐标接近(0, 0)。Y坐标随着我向下移动而增加,X坐标随着我向右移动而增加。

那么,这个事件接口到底包含哪些信息呢?

正如我们之前看到的,事件有很多不同的类型,但有一些属性是所有事件共有的。例如:

  • event.currentTarget:当前正在运行处理器函数的元素。
  • event.timestamp:事件创建的时间(毫秒)。
  • event.type:事件的名称(例如'click')。

还有一些属性是特定于某些事件类型的。例如:

  • clientXclientY:仅存在于鼠标事件中,表示鼠标坐标。
  • key:仅存在于键盘事件中,表示被按下的键的键码。

现在,让我们运用事件知识,编写一个使用键盘事件的小例子。

键盘事件示例:移动方块

让我们做一个键盘事件的例子。首先,我要给文档添加一个keydown事件监听器。

document.addEventListener('keydown', (event) => {
    console.log(event.key);
});

我定义了一个处理器函数,它使用console.log(event.key)来显示当我按下不同键时控制台输出的键码。在浏览器中运行,如果我按a键,控制台会输出'a';按b键输出'b';按数字键输出数字;按方向键会输出'ArrowUp''ArrowLeft''ArrowRight''ArrowDown';按Shift键输出'Shift'

基本上,event.key给了我们被按下键的名称。

现在,我有一个简单的HTML页面,里面有一个被我设置为蓝色、50x50像素的div

接下来,我们要编写一个脚本,使得当我按下方向键时,这个方块会沿着我按下的方向在页面上移动。

现在,我要从HTML中获取这个方块(我给它设置了ID square),使用getElementById

const square = document.getElementById('square');

接下来,我要写一个函数,它接受leftDeltatopDelta参数,并按这个量移动方块。

function moveSquare(leftDelta, topDelta) {
    const currentLeft = parseInt(square.style.left) || 0;
    const currentTop = parseInt(square.style.top) || 0;
    square.style.left = (currentLeft + leftDelta) + 'px';
    square.style.top = (currentTop + topDelta) + 'px';
}

为了测试,当我按下任何键时,我想让方块移动50像素。按第二次再移动50像素,这样我们的moveSquare函数就工作了。

现在,我想根据按下的键来检查是哪个方向键被按下,并设置不同的lefttop值。

document.addEventListener('keydown', (event) => {
    switch(event.key) {
        case 'ArrowDown':
            moveSquare(0, 5); // 向下移动,top增加
            break;
        case 'ArrowUp':
            moveSquare(0, -5); // 向上移动,top减少
            break;
        case 'ArrowLeft':
            moveSquare(-5, 0); // 向左移动,left减少
            break;
        case 'ArrowRight':
            moveSquare(5, 0); // 向右移动,left增加
            break;
    }
});

让我们尝试运行这段代码。现在我按下右方向键,方块向右移动;按下上方向键,方块向上移动。看起来代码运行正常。让我快速增加每次移动的像素数,比如改为5像素,这样移动得更快。这样,我们的键盘示例就完成了。

事件循环

现在我们来谈谈事件循环。JavaScript的并发模型基于一种称为事件循环的机制。

事件循环本质上是一个消息队列,它列出了需要处理的消息或事件。当一个事件被触发时,它会被添加到队列中。页面在处理完当前事件之前,不会运行下一个事件。

我们称之为事件循环,是因为它类似于这样一个循环:JavaScript持续地、同步地等待消息,同时处理任何正在等待处理的消息。

JavaScript的事件循环使用一种我们称之为运行到完成的模型。这意味着每个消息在被处理之前都会被完全处理。这样做的好处是,这是一个非常简单且易于理解的模型,我们在编写JavaScript代码时经常可以利用这一点。但缺点是,由于它是同步的,并且我们必须等待消息完全处理完毕才能处理其他消息,如果单个消息处理时间过长,就会占用主浏览器线程,导致浏览器无法处理其他任何事情。这意味着如果浏览器线程非常繁忙,我们甚至无法点击元素或滚动页面,因为这些事件没有被处理。

事件捕获与冒泡

了解了事件循环后,现在让我们看看事件捕获和冒泡。这是一种理解事件如何在DOM中传播的方式。

当一个标准的DOM事件发生时,事件会经历三个传播阶段。下图展示了点击表格内一个<td>标签后的事件流:我们有<table>标签、<tbody>标签、<tr>标签,然后是<td>标签。

  1. 捕获阶段:事件从最高层级(window)开始,向下经过document<html><body>,一直穿过不同的标签,直到到达目标元素(本例中的<td>标签)。
  2. 目标阶段:事件实际到达目标元素(<td>元素)的阶段。
  3. 冒泡阶段:事件从目标元素开始,向上冒泡,一路返回到window

冒泡的概念是:当一个事件在一个元素上发生时,它首先在该元素上运行处理器,然后在它的父元素上运行,然后一路向上在其祖先元素上运行,直到window

让我们做一个快速示例来演示事件捕获和冒泡。

好的,这里我有一个示例HTML页面,其中有三个嵌套的divfirstsecondthird。我应用了一些样式,使它们看起来像嵌套在一起。

我还快速添加了一些JavaScript:在这里,我调用getElementById获取所有三个方块,然后给每个方块添加了一个onclick监听器。

让我快速演示一下运行时的效果:

  • 如果我点击first,会弹出一个提示框显示“first”,因为我点击了第一个元素。
  • 如果我点击second,事件处理器会先在目标元素(我们的第二个div)上被调用,然后它会冒泡到父元素(first)。所以会先提示“second”,然后提示“first”。
  • 我对third做同样的事情:它会提示“third”,然后“second”,然后“first”,因为它从目标元素冒泡到父元素,再到父元素,一路向上直到DOM根。

我们有一个叫做stopPropagation的函数。stopPropagation的作用是阻止事件冒泡到其父元素。让我演示一下:如果我在监听器中添加event对象并执行event.stopPropagation(),那么当我点击third时,它就不会再冒泡到secondfirst了。它只会提示“third”。

以上就是关于事件冒泡和stopPropagation的内容。现在让我们看看preventDefault

阻止默认行为

基本上,某些类型的DOM元素具有默认行为。例如:

  • 点击复选框会切换复选框的选中状态(浏览器默认)。
  • 图像也有默认的拖放行为,允许你将它们拖到另一个位置。
  • 在文本输入字段中按键具有默认行为,即当你键入时,文本会输入到字段中。

在我们的事件监听器中,我们可以使用preventDefault函数来阻止默认行为发生,以防我们想要阻止它。让我们做一个快速示例来演示preventDefault

好的,我这里有一个HTML页面,上面有一个输入复选框字段(只是一个复选框)和一个输入文本字段(允许我输入)。

正如我之前所说,复选框的默认行为是当你点击它时,它会被选中;文本框的默认行为是当你在其中键入时,你输入的内容会出现在文本框中。

首先,我将为复选框演示preventDefault。我使用getElementById获取这个复选框,然后给它添加click事件监听器。在里面,我执行event.preventDefault()。如果我运行这段代码,当我尝试点击复选框时,它实际上不会被选中,因为我阻止了默认行为。如果我取消这行代码,它又能正常工作了。

现在,让我们为文本框做一个稍微不同的例子。首先,我也通过ID获取文本框,并为它添加keypress事件监听器。如果我执行preventDefault,它将完全不允许我输入。

现在我想做的是:允许输入,但只允许输入小写字母。如果有人尝试输入数字或符号,我不允许。我将使用事件的charCode属性来检查按下的键是否是小写字母。

textBox.addEventListener('keypress', (event) => {
    if (event.charCode < 'a'.charCodeAt(0) || event.charCode > 'z'.charCodeAt(0)) {
        event.preventDefault();
        alert('Please use lowercase letters only.');
    }
});

让我们再次尝试运行。如果我输入小写字母,没问题。如果我尝试输入大写字母,我会收到提示“Please use lowercase letters only.”。这就是如何使用preventDefault

综合示例:篮球拖放游戏

最后,我们将做一个结合了所有事件知识的例子:一个拖放篮球游戏

让我们开始我们的篮球拖放示例。我有一个HTML页面,上面有两张图片:一个篮球和一个篮筐。我还有一个标签显示分数,目前是0分。我已经将篮筐绝对定位在远离页面的位置。

首先,我要尝试让球本身可以被拖动。我将从DOM中获取这个元素。为了实现可拖动,我将结合使用三个事件:

  • mousedown:当球被按下时(相当于捡起球)。
  • mouseup:当释放时(相当于放下拖动)。
  • mousemove:鼠标移动事件。

我们之前在视频中看到,可以使用event.clientXclientY来获取鼠标位置。我将使用mousemove事件将球定位到我的鼠标所在位置。

首先,从DOM中获取元素。

const ball = document.getElementById('ball');
const hoop = document.getElementById('hoop');
const scoreboard = document.getElementById('scoreboard');

现在,我要给球添加mousedownmouseup事件监听器。

ball.addEventListener('mousedown', () => {
    console.log('mouse down');
});

ball.addEventListener('mouseup', () => {
    console.log('mouse up');
});

现在,我想添加mousemove事件监听器,但我只想在鼠标当前按下的情况下监听mousemove。为此,我将在mousedown内部添加mousemove事件监听器,并在mouseup内部移除它。

let isDragging = false;

ball.addEventListener('mousedown', () => {
    isDragging = true;
    document.addEventListener('mousemove', onMouseMove);
});

ball.addEventListener('mouseup', () => {
    isDragging = false;
    document.removeEventListener('mousemove', onMouseMove);
});

function onMouseMove(event) {
    if (!isDragging) return;
    console.log('mouse move', event.clientX, event.clientY);
}

运行这个,当我在文档上移动鼠标时,如果没有在球上按下鼠标,控制台不会打印。当我在球上按下鼠标并开始移动时,控制台会打印mousemove事件。

你会注意到,当我捡起这个球时,球有一种奇怪的拖拽行为。但这不是自定义的拖放,这实际上是浏览器对图像元素的默认行为。我们想要做的是阻止拖动这个图像的默认行为。所以我只需要执行event.preventDefault()

现在,我们想要做的是将球的位置更改为与鼠标坐标相同。为此,我将改变ball.style.leftball.style.top,使它们等于鼠标的坐标。

function onMouseMove(event) {
    if (!isDragging) return;
    event.preventDefault();
    ball.style.left = event.clientX + 'px';
    ball.style.top = event.clientY + 'px';
}

现在你可以看到,当我在球上按下鼠标并移动时,它跟着我的光标移动。但你可能注意到,当我按下鼠标并开始移动球时,球会跳一下。这是因为我将球的topleft位置设置为等于我的光标位置,而我想要的是我的光标保持在球元素内的相同相对位置。

为了避免第一次捡起时的跳跃,我需要计算光标在球元素内的偏移量。为了获取光标在球内的偏移量,我想获取光标的位置,然后从中减去球的边界客户端矩形的左上角点。

let offsetX, offsetY;

ball.addEventListener('mousedown', (event) => {
    isDragging = true;
    const rect = ball.getBoundingClientRect();
    offsetX = event.clientX - rect.left;
    offsetY = event.clientY - rect.top;
    document.addEventListener('mousemove', onMouseMove);
});

function onMouseMove(event) {
    if (!isDragging) return;
    event.preventDefault();
    ball.style.left = (event.clientX - offsetX) + 'px';
    ball.style.top = (event.clientY - offsetY) + 'px';
}

现在再次运行,你可以看到它不再跳动了。

接下来我想做的是让这个游戏能够实际得分。我想实现:如果我把球拖到我们的小篮筐图形上,它将触发一个增加分数的函数。

首先,我想获取篮筐图片中那个红色小篮筐部分的坐标。这些偏移量等于红色篮筐相对于整个篮筐图片左上角的偏移量。

const hoopOffsetX = 50; // 示例硬编码值
const hoopOffsetY = 30; // 示例硬编码值

让我们在我们的onMouseMove函数中添加一些逻辑,来计算我们当前的鼠标位置是否穿过了这个篮筐。让我们在一个全局变量中跟踪分数。

let score = 0;
let hasScored = false; // 防止一次拖动中重复计分

function onMouseMove(event) {
    if (!isDragging) return;
    event.preventDefault();
    ball.style.left = (event.clientX - offsetX) + 'px';
    ball.style.top = (event.clientY - offsetY) + 'px';

    // 获取篮筐的实际位置
    const hoopRect = hoop.getBoundingClientRect();
    const hoopCenterX = hoopRect.left + hoopOffsetX;
    const hoopCenterY = hoopRect.top + hoopOffsetY;

    // 简单碰撞检测:检查球中心是否在篮筐中心附近
    const ballCenterX = event.clientX;
    const ballCenterY = event.clientY;
    const distance = Math.sqrt(
        Math.pow(ballCenterX - hoopCenterX, 2) +
        Math.pow(ballCenterY - hoopCenterY, 2)
    );

    if (distance < 25 && !hasScored) { // 25是碰撞阈值
        score++;
        hasScored = true;
        scoreboard.textContent = `Score: ${score}`;
        console.log('Goal scored!');
    }
}

// 在 mouseup 中重置 hasScored
ball.addEventListener('mouseup', () => {
    isDragging = false;
    hasScored = false;
    document.removeEventListener('mousemove', onMouseMove);
});

现在你可以看到我们的分数在增加。但你可能注意到一个错误:它增加得太多次了。因为它在mousemove上触发,当我移动到篮筐的红色部分时,它会增加多次。

为了解决这个问题,我们使用一个布尔值hasScored来存储在当前移动过方块的过程中分数是否已经增加过。我们只在hasScoredfalse时才增加分数,并在mouseup时重置它。

再次尝试。很好,现在每次拖动穿过篮筐只计分一次。

酷,这就是我们的篮球示例。这也结束了我们关于事件的这一讲。

总结

在本节课中,我们一起学习了JavaScript事件的核心概念。我们从了解什么是事件以及常见的事件类型(如鼠标和键盘事件)开始。然后,我们探讨了三种添加事件监听器的方法:HTML属性、DOM元素属性和addEventListener函数。

我们深入研究了事件对象,它包含了事件的详细信息,并学习了如何使用它来获取鼠标位置或按键信息。通过键盘控制方块移动的示例,我们实践了事件处理。

接着,我们了解了JavaScript的事件循环运行到完成模型,理解了事件是如何被排队和处理的。我们还学习了事件的捕获和冒泡传播机制,以及如何使用stopPropagation来阻止冒泡。

最后,我们学习了如何使用preventDefault来阻止元素的默认行为,并通过构建一个完整的篮球拖放游戏综合示例,将事件监听、坐标计算、碰撞检测和状态管理结合在一起,巩固了所有关于事件的知识。

希望你能从这节课中学到有用的知识!

029:JavaScript 闭包 🐻

在本节课中,我们将要学习 JavaScript 中的一个核心概念:闭包。我们将通过一个创建动态计时器的例子,来理解闭包是什么、它如何工作,以及它在现代 JavaScript 开发中的角色。

概述

闭包是 JavaScript 中一个独特且强大的特性。简单来说,闭包是一个函数与其周围状态(词法环境)的组合。它允许内部函数访问其外部函数的作用域,即使外部函数已经执行完毕。这使得我们可以创建具有私有状态的函数,其行为类似于轻量级的对象或类。

什么是闭包?

闭包是 JavaScript 中一个你可能听说过的概念。本质上,它是少数编程语言才拥有的特性,JavaScript 和 Python 是其中的例子,而像 C 语言等许多其他语言则没有。

闭包允许我们创建执行环境,这与你在对象和类中看到的情况非常相似。如果你以前使用过面向对象的语言,你会知道你可以定义一个模板(类),然后当你创建该类的实例时,你就是在创建它自己的一个小型执行环境,或者你可以称之为闭包。

因此,当你听到“闭包”这个词时,它本质上就是一个执行环境。MDN 上有大量关于闭包的详细信息,如果你感兴趣,我推荐你去阅读。它非常全面且理论化。

闭包的历史背景与现代应用

这里提到的许多内容如今已不再那么关键。这是因为在 ES6(ECMAScript 6)之前,很多地方都需要使用闭包,特别是由于 JavaScript 过去的作用域规则。

一个非常常见的例子是立即调用函数表达式(IIFE)。这在互联网上可能会看到。在 letconst 这类术语出现之前,IIFE 被大量使用,以帮助解决一些问题。如果你想了解更多,可以去阅读关于闭包和 IIFE 的资料。

但如今,自从 ES6 以来,很多情况下实际上并不太需要闭包。你可以向初学者介绍 JavaScript,然后去构建东西,并且你可能从 Python 或你自己的编程直觉中对闭包有一种直观的感觉,但现在你并不需要过多地思考它。这就是为什么它在课程中不是一个大问题的原因。

你可以去阅读更多关于它的例子。这里链接了一篇去年的文章,名为《闭包的实际用途》,它以更轻松有趣的方式介绍了它。它给出了一个关于如何用它创建动态 HTML 生成器的例子。

但再次强调,大部分内容如果你不读,也不会有什么坏处。我们今天要做的只是一个非常快速的演示,本质上是为了突出闭包是什么以及它的一个使用示例。

实践:创建一个计时器应用

上一节我们介绍了闭包的基本概念和历史背景,本节中我们来看看一个具体的例子。我们将创建一个简单的网页,其中有一个按钮,每次点击都会生成一个新的独立计时器。

首先,我们有一个非常简单的 HTML 页面。我们将使用一点 DOM 操作,因为这比较简单。

以下是初始的 HTML 结构:

<!DOCTYPE html>
<html>
<head>
    <title>Closure Demo</title>
</head>
<body>
    <div id="timers"></div>
    <button id="createTimer">Create Timer</button>
    <script src="script.js"></script>
</body>
</html>

现在,我们想要在这个网页上创建一个按钮。每次点击该按钮时,我们想要创建一个新的小区域来启动一个计时器。想象一下,这是一个我们可以点击任意次数的按钮,每次点击都会弹出一个新的计时器作为 DOM 元素。

首先,我要创建一个按钮,上面写着“创建计时器”。我们将给这个按钮一个 ID,以便可以为其添加事件监听器。

// 获取按钮并添加点击事件
const createTimerButton = document.getElementById('createTimer');
createTimerButton.addEventListener('click', function() {
    console.log('clicked');
});

现在,当我点击它时,我们在控制台得到“clicked”。接下来,我希望每次点击时,都出现一个新的小盒子,里面有一个开始计时的计时器,并且每秒计数一次。所以我们需要使用 setInterval

我们创建一个名为 timers 的 div 来存放这些计时器元素。

const timersContainer = document.getElementById('timers');
let time = 0; // 计时器计数器

createTimerButton.addEventListener('click', function() {
    // 创建新的 DOM 元素
    const newTimer = document.createElement('div');
    newTimer.innerText = time;

    // 添加到容器中
    timersContainer.appendChild(newTimer);

    // 设置一个每秒更新时间的间隔
    setInterval(function() {
        time++;
        newTimer.innerText = time;
    }, 1000);
});

现在,我点击“创建计时器”,它创建了一个计时器 1, 2... 它在向上计数。但问题是,如果我想创建更多计时器会怎样?因为你会发现,如果我再次点击,它们都使用同一个 time 变量。并且时间在疯狂增加,因为现在有多个间隔都在递增同一个计时器。

使用数组管理多个计时器

为了解决多个计时器共享同一个计数器的问题,我们可以尝试使用一个数组来分别存储每个计时器的值。

以下是使用数组的解决方案:

const timersContainer = document.getElementById('timers');
let times = []; // 用于存储每个计时器的时间

createTimerButton.addEventListener('click', function() {
    // 为新的计时器在数组中添加一个初始值 0
    times.push(0);
    const currentIndex = times.length - 1;

    // 创建新的 DOM 元素
    const newTimer = document.createElement('div');
    newTimer.innerText = times[currentIndex];

    // 添加到容器中
    timersContainer.appendChild(newTimer);

    // 设置一个每秒更新特定计时器时间的间隔
    setInterval(function() {
        times[currentIndex]++;
        newTimer.innerText = times[currentIndex];
    }, 1000);
});

现在,我可以创建许多计时器,它们看起来都是独立的。这段代码完全没问题,你不需要闭包来解决这个问题。但请注意,这些计时器实际上都是操作同一个 times 数组的一堆事件监听器和 setInterval

引入闭包:创建独立的执行环境

上一节我们使用数组管理了多个计时器的状态,本节中我们来看看如何使用闭包来创建真正独立的执行环境,从而摆脱对共享数组的依赖。

闭包的一个简单例子是创建一个函数,它返回另一个函数,并且内部函数可以访问外部函数的变量。

function createTimer() {
    let time = 0; // 局部作用域变量

    // 返回一个对象,包含两个方法
    return {
        getTime: function() {
            return time;
        },
        incrementTime: function() {
            time++;
        }
    };
}

现在,当我点击按钮时,我可以这样做:

createTimerButton.addEventListener('click', function() {
    // 创建一个新的闭包(独立的计时器环境)
    const thisTimer = createTimer();

    // 创建新的 DOM 元素
    const newTimer = document.createElement('div');
    newTimer.innerText = thisTimer.getTime(); // 初始时间为 0

    // 添加到容器中
    timersContainer.appendChild(newTimer);

    // 设置一个每秒更新时间的间隔
    setInterval(function() {
        thisTimer.incrementTime(); // 增加这个特定计时器的时间
        newTimer.innerText = thisTimer.getTime(); // 更新显示
    }, 1000);
});

现在,每次点击“创建计时器”时,createTimer 函数都会创建一个独立的闭包,即一个独立的执行环境。每个环境都有自己的 time 变量和操作它的方法。这样,我们就不再需要共享的 times 数组,代码也更容易阅读,因为我们不需要处理索引。

结合两种方法:灵活性与控制力

闭包和数组方法各有优劣。闭包提供了清晰的封装和独立性,而数组方法则便于集中管理和批量操作。实际上,我们可以将两者结合起来,以获得两者的优点。

例如,我们可以将每个创建的闭包存储在一个数组中,这样既保持了每个计时器的独立性,又能在需要时对所有计时器进行统一操作(如重置所有计时器)。

const timersContainer = document.getElementById('timers');
const timerClosures = []; // 存储所有闭包实例

createTimerButton.addEventListener('click', function() {
    // 创建新的闭包
    const newClosure = createTimer();
    timerClosures.push(newClosure); // 保存起来以便管理

    // 创建并显示计时器
    const newTimer = document.createElement('div');
    newTimer.innerText = newClosure.getTime();
    timersContainer.appendChild(newTimer);

    setInterval(function() {
        newClosure.incrementTime();
        newTimer.innerText = newClosure.getTime();
    }, 1000);
});

// 例如:每5秒重置所有计时器
setInterval(function() {
    for (let closure of timerClosures) {
        // 假设我们在 createTimer 里也添加了一个 reset 方法
        // closure.reset();
    }
}, 5000);

总结

本节课中我们一起学习了 JavaScript 的闭包。我们了解到:

  1. 闭包的本质:它是一个函数以及其周围词法环境的组合,使得内部函数可以“记住”并访问外部函数的作用域。
  2. 闭包的用途:它可以用来创建具有私有状态的函数,模拟类似于类的行为,管理独立的作用域。
  3. 实践对比:我们通过构建一个多计时器应用,对比了使用全局变量、数组和闭包三种不同的状态管理方式。
  4. 现代开发中的角色:在 ES6 引入了 let/const 和正式的 class 语法后,显式使用闭包模式(如 IIFE)的需求减少了,但理解闭包对于深入理解 JavaScript 的执行机制、作用域链以及许多高级模式(如函数工厂、模块模式)仍然至关重要。

闭包是 JavaScript 中无处不在的概念。它不仅仅是一种技术,更是一种思维方式,帮助你更好地组织代码、管理状态和设计架构。希望本节课能帮助你揭开闭包的神秘面纱,并在未来的编程实践中有效地运用它。

030:表单处理 🦄

在本节课中,我们将学习HTML表单的基础知识,包括如何创建表单、如何在JavaScript中访问表单数据,以及如何处理表单提交事件。我们将通过一个创建账户表单的实例,将所学知识融会贯通。

什么是表单?

表单是HTML页面或网页上的一种元素,允许用户输入数据。常见的应用场景包括调查问卷、登录或注册页面,以及求职申请表等。

用户输入的数据可以是多种格式,例如文本、数字、日期选择器、复选框或单选按钮。客户端或服务器可以接收用户输入到表单中的信息,并对其进行处理。

在HTML中创建表单

创建HTML表单的基础是 <form> 元素。这个元素用于定义一个表单,其内部包含表单的内容,通常是标签和各种类型的输入元素。

以下是一个简单的表单示例:

<form name="userForm">
  <label for="fname">First name:</label>
  <input type="text" id="fname" name="fname">
  <label for="lname">Last name:</label>
  <input type="text" id="lname" name="lname">
</form>

注意,我们使用 type 属性来指定输入元素的类型。type 属性的有效值包括 buttonradiocheckboxemail 等。

在JavaScript中访问表单

上一节我们介绍了如何编写表单的HTML代码,本节中我们来看看如何在JavaScript中读取这些表单。

首先,你的网页上可能有一个或多个表单。JavaScript中的 document 对象有一个名为 forms 的属性,你可以通过它找到文档中的所有表单。

document.forms 是一个命名集合,这意味着我们可以像数组一样索引它,也可以使用表单的名称来查找表单。

以下是访问表单的几种方式:

  • 通过名称访问document.forms.testdocument.forms["test"] 可以访问名为 test 的表单。
  • 通过索引访问document.forms[0] 可以获取文档中的第一个表单。

document.forms 返回的是一个 HTMLCollection,类似于一个 HTMLFormElement 对象的数组。

访问表单中的输入元素

获取到表单元素后,我们还需要知道如何获取表单中的具体输入元素。表单元素有一个名为 elements 的字段,它也是一个命名集合,包含了该表单中的所有输入元素。

以下是一个HTML表单示例及其对应的JavaScript访问方式:

<form name="sampleForm">
  <input type="text" name="fname">
  <input type="radio" name="age" value="young">
  <input type="radio" name="age" value="old">
</form>

// 获取表单
const form = document.forms[0]; // 或 document.forms.sampleForm

// 访问名为“fname”的输入元素
const firstNameInput = form.elements.fname; // 或 form.fname (简写)
const firstNameValue = firstNameInput.value; // 获取用户输入的值

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/de9ec9acf828c269a97494ae91975815_12.png)

// 访问名为“age”的单选按钮组
const ageRadioGroup = form.elements.age; // 返回一个集合

对于像单选按钮组这样具有相同名称的多个元素,form.elements.age 会返回一个集合。

反向引用

表单元素的一个核心特性是反向引用。当你有一个表单内的输入元素时,该元素实际上有一个 form 属性,它存储了对包含它的表单的反向引用。

这意味着,从一个输入元素出发,你可以轻松地找到它所属的表单。

// 假设有一个名为“login”的输入元素
const loginInput = document.forms[0].login;
// 通过反向引用获取其所属的表单
const parentForm = loginInput.form; // 这与 document.forms[0] 相同

读取输入值

获取到表单内的元素后,我们需要读取用户实际输入的值。读取方式取决于输入元素的类型。

以下是几种常见输入类型的值读取方法:

  • 文本输入input.value 返回用户输入的字符串。
  • 复选框/单选按钮input.checked 返回一个布尔值,表示该选项是否被选中。
  • 下拉选择框
    • select.options 返回所有选项的集合。
    • select.value 返回当前选中选项的值。
    • select.selectedIndex 返回当前选中选项在选项数组中的索引。

处理表单提交事件

现在我们已经学会了如何读取用户输入到表单中的数据,接下来看看如何知道用户何时提交了表单,以便在适当的时机运行我们的代码。

当你创建一个表单时,应该添加一个特殊类型的按钮,即提交按钮 (type="submit")。当用户点击此按钮时,会触发一个 submit 事件。我们可以在JavaScript中监听这个事件,并在事件触发时执行回调函数。

以下是如何为表单添加提交事件监听器的示例:

const myForm = document.forms.myForm;

myForm.addEventListener('submit', function(event) {
  // 阻止表单的默认提交行为(例如,防止页面跳转)
  event.preventDefault();

  // 在这里进行表单验证或处理数据
  console.log('表单已提交!');
  // ... 你的处理逻辑
});

实战演练:创建账户表单

现在,我们将把前面学到的所有知识结合起来,创建一个“创建账户”表单,并编写JavaScript代码来处理表单数据。

第一步:编写HTML表单结构

我们将创建一个包含用户名、出生日期、密码、确认密码、所在州和头像上传字段的表单。

<form name="createAccount">
  <input type="text" name="name" placeholder="Enter your name">
  <input type="date" name="dob">
  <input type="password" name="password1" placeholder="Enter your password">
  <input type="password" name="password2" placeholder="Confirm your password">
  <select name="state">
    <option value="nsw">NSW</option>
    <option value="vic">VIC</option>
    <option value="qld">QLD</option>
  </select>
  <input type="file" name="photo">
  <input type="submit" value="Sign Up">
</form>

第二步:添加提交事件监听器

首先,在JavaScript中获取表单元素,并为其添加 submit 事件监听器。初始时,我们只弹出一个提示框。

const form = document.forms.createAccount;

form.addEventListener('submit', function(event) {
  event.preventDefault(); // 阻止默认提交行为
  alert('Form submitted');
});

第三步:读取并显示表单数据

在事件监听器中,我们读取各个字段的值,并将它们组合成一个字符串显示出来。

form.addEventListener('submit', function(event) {
  event.preventDefault();

  const name = form.elements.name.value;
  const dob = form.elements.dob.value;
  const password1 = form.elements.password1.value;
  const password2 = form.elements.password2.value;
  const state = form.elements.state.value;
  const photo = form.elements.photo.files[0]?.name; // 获取文件名

  const message = `Name: ${name}\nDOB: ${dob}\nPassword1: ${password1}\nPassword2: ${password2}\nState: ${state}\nPhoto: ${photo}`;
  alert(message);
});

第四步:添加表单验证

最后,我们添加一些基本的表单验证逻辑,例如检查所有字段是否已填写,以及两次输入的密码是否一致。

form.addEventListener('submit', function(event) {
  event.preventDefault();

  const name = form.elements.name.value;
  const dob = form.elements.dob.value;
  const password1 = form.elements.password1.value;
  const password2 = form.elements.password2.value;
  const state = form.elements.state.value;

  // 1. 检查必填字段
  if (!name || !dob || !password1 || !password2 || !state) {
    alert('Please enter all fields');
    return; // 停止执行后续代码
  }

  // 2. 检查密码是否匹配
  if (password1 !== password2) {
    alert('Passwords do not match');
    return;
  }

  // 3. 如果验证通过,则显示数据(或发送到服务器)
  const photo = form.elements.photo.files[0]?.name;
  const message = `Name: ${name}\nDOB: ${dob}\nState: ${state}\nPhoto: ${photo}`;
  alert(`Account created successfully!\n${message}`);
});

总结

本节课中我们一起学习了HTML表单的核心知识。我们了解了如何使用 <form> 元素和各类 <input> 元素构建表单,掌握了在JavaScript中通过 document.formsform.elements 访问表单及其内部元素的方法,并学习了如何利用 valuechecked 等属性读取用户输入的数据。

最后,我们通过一个完整的“创建账户”表单实例,实践了如何监听 submit 事件、阻止默认提交行为、读取表单数据以及实现基本的客户端验证。这些是构建交互式网页应用的基础技能。

031:本地存储 🗄️

在本节课中,我们将学习一种在不同浏览器会话间保存数据的方法,这种方法被称为本地存储。

概述

到目前为止,在我们的JavaScript和React项目中,内存中的数据状态是临时的。这意味着当我们刷新页面时,数据会消失,并且无法在不同的浏览器会话之间访问。然而,我们常常希望数据能在会话之间持久保存。例如,用户可能有一些设置,我们希望在他们下次访问网站时保存这些设置。当我们想要存储数据时,可以将其存储在服务器端的数据库中,也可以存储在客户端,例如本地存储中。

数据持久化的两种方式

上一节我们介绍了数据持久化的需求,本节中我们来看看实现它的两种主要方式。

  • 服务器端持久化:这是最传统的方式。当我们想要存储数据时,会将其存储在服务器的数据库中。这涉及在需要检索和修改数据时向后端服务器发出请求,然后后端端点会处理这些数据,并将其保存到数据库或从数据库中检索。
  • 客户端持久化:这意味着将信息存储在用户的机器上,即他们的浏览器中。这可以通过使用本地存储来实现。

客户端持久化不能替代服务器端持久化,它有一些优缺点,我们稍后会深入探讨。其主要限制是,存储在客户端的数据只能由该特定客户端访问,因为它不在服务器上,只存在于客户端。然而,客户端持久化仍然是为你的网站添加持久性的一种非常快速和简单的方法。因此,在本视频中,我们将重点介绍如何使用本地存储来实现这一点。

什么是本地存储?🤔

本地存储是浏览器中存在的一个API,它允许你读写文档中的存储对象。你写入存储对象的数据会在会话之间持久保存,这意味着即使用户刷新、关闭标签页或浏览器,数据也会保留。

本地存储的优缺点

在深入了解如何在JavaScript中使用本地存储API之前,我们先来看看使用本地存储的一些优缺点。

以下是本地存储的一些限制,以及一些你不希望使用本地存储的情况:

  1. 不安全:本地存储可以被任何网页访问。因此,如果任何网页知道你用来存储数据的密钥,它就可以访问和更改你存储的数据。因此,在任何数据安全性很重要的情况下,例如密码或个人身份信息,不应使用本地存储。
  2. 存储限制:本地存储可以存储的数据量是有限制的,具体限制取决于所使用的浏览器。即使用户访问的其他网站已经在本地存储中存储了大量数据,用户的浏览器可能已经达到了存储限制。当你尝试使用本地存储时,可能会遇到配额限制。当你达到配额限制时,你将无法成功保存数据。因此,对于任何关键信息或大量数据,本地存储不是一个好的解决方案。
  3. 仅支持字符串:本地存储只支持存储字符串。它接受键值对,但值只能是字符串。因此,它不适合存储复杂数据。这并不是一个非常困难或严重的限制,因为你总是可以将JSON对象序列化为字符串。但值得注意的是,如果你使用本地存储来存储复杂数据,则需要做一些额外的工作来序列化和反序列化数据。
  4. 不适用于多设备数据:由于它存储在客户端,因此只能在该特定客户端上访问。因此,它不适用于多个用户需要的数据,也不适用于用户在不同设备上访问你的网站时需要该数据的情况。

现在,让我们看看本地存储的适用场景。本地存储非常适合以下几种情况:

  1. 纯前端网站:对于没有服务器的纯前端网站,它是一种非常快速和简单的添加持久性的方法。
  2. 存储非关键信息:它适合存储如果丢失也不关键的信息。存储在客户端意味着如果用户清除了他们的本地存储或浏览器存储,他们将丢失你存储的内容。此外,如果他们想从另一台设备访问你的网站,他们将无法访问存储在本地存储中的数据。因此,我建议将其用于非关键数据。
  3. 特定用户数据:数据存储在客户端意味着它只对特定用户可用。

一些适合使用本地存储的例子包括:个人网站偏好设置(例如用户自定义的颜色方案)、持久化用户之前的活动(例如,在购物网站上,你可能希望存储他们购物车的内容,并在他们离开页面时不丢失这些信息)。

如何使用本地存储API

现在,让我们来看看如何在JavaScript中实际使用本地存储API。

浏览器的本地存储是一个由键值对组成的大对象。我们可以像操作JavaScript中的Map一样读写它。

以下是核心操作方法:

  • 添加/更新数据:要向本地存储对象添加键值对,可以使用 setItem 方法。如果已存在具有该键的项目,该项目将被覆盖。
    localStorage.setItem('key', 'value');
    
  • 读取数据:可以使用 getItem 方法并传入一个键来从本地存储中检索数据。如果键存在则返回值,如果不存在则返回 null
    const value = localStorage.getItem('key');
    
  • 删除数据:要使用给定的键从本地存储中删除一个项目,可以使用 removeItem 方法。
    localStorage.removeItem('key');
    
  • 清空所有数据:如果要清除本地存储中的所有项目,可以使用 clear 方法。
    localStorage.clear();
    

实践示例:表单数据持久化

让我们通过一个例子来实践。你可能还记得上周关于表单的讲座,我们创建了一个用户创建账户的表单示例。它包含用户名、出生日期、密码、州和文件处理器等字段,并且我们还有一些处理表单提交的JavaScript。

现在,我们将添加一些本地存储功能,以持久化用户输入到表单中的数据。为了快速演示这里的问题,如果我输入一些数据,然后刷新页面,我输入的所有数据都会丢失,这对用户来说可能相当令人沮丧。因此,网站通常会使用本地存储来保留表单中的数据。

在本讲座前面提到过,在本地存储中存储安全数据不一定是个好主意,因为本地存储确实存在一些安全问题。但仅为了演示如何使用本地存储,我们将把所有创建账户的信息存储在本地存储中。

以下是实现步骤:

  1. 监听输入变化:我想做的是,当用户更改他们在这些字段中输入的内容时,添加一个事件监听器。为此,我将监听表单中每个元素的 change 事件。
    const formElements = Array.from(signUpForm.elements);
    formElements.forEach(element => {
        element.addEventListener('change', (event) => {
            // 获取元素名称和值
            const key = element.name;
            const value = element.value;
            // 保存到本地存储
            localStorage.setItem(key, value);
        });
    });
    
  2. 页面加载时填充数据:最后,当页面加载时,我希望用之前输入到本地存储中的所有字段来填充这个表单。
    formElements.forEach(element => {
        // 确保不是文件输入或提交按钮
        if (element.type !== 'file' && element.type !== 'submit') {
            const savedValue = localStorage.getItem(element.name);
            if (savedValue) {
                element.value = savedValue;
            }
        }
    });
    

运行此代码后,当我在表单中输入信息并刷新页面时,所有字段都会自动填充我之前输入的数据。

总结

本节课中我们一起学习了本地存储。我们了解了数据持久化的概念、本地存储的定义、它的优缺点以及适用场景。我们重点掌握了如何使用 setItemgetItemremoveItemclear 这些核心API来操作本地存储。最后,通过一个表单数据持久化的实践示例,我们巩固了如何在实际项目中应用本地存储来提升用户体验。记住,本地存储适合存储非关键、用户特定的临时数据,但对于敏感或需要在多设备间同步的数据,应考虑服务器端解决方案。

032:JavaScript 异步编程 🐟 事件与回调

在本节课中,我们将学习 JavaScript 中的并发模型,特别是异步编程、事件循环和回调函数。我们将探讨同步与异步编程的区别,理解 JavaScript 如何通过事件循环处理多个任务,并学习如何避免阻塞用户界面。


概述

JavaScript 是一种单线程语言,但它通过事件循环和异步编程模型,能够高效地处理多个任务,如网络请求和用户交互。本节课将解释这些核心概念,帮助你理解 JavaScript 如何在不阻塞主线程的情况下执行耗时操作。


同步与异步编程

上一节我们介绍了客户端-服务器模型和 AJAX 的基本概念。本节中,我们将探讨同步与异步编程的区别。

同步编程是大多数初学者首先接触的编程方式。程序从上到下顺序执行,每一行代码必须完全执行完毕后,才会执行下一行。这保证了代码的可预测性和易于推理。

let a = b;
b = b + 1;
let c = b; // c 的值是 a + 1

异步编程则允许多个控制流同时存在。一个控制流可以称为一个“线程”。在异步模型中,程序可以在等待一个任务(如网络请求)完成的同时,继续执行其他任务。

在同步世界中,进程 A 调用进程 B 的函数后,会等待进程 B 完成并返回结果,然后进程 A 才继续执行。

在异步世界中,进程 A 启动进程 B 的任务后,不会等待,而是立即继续执行自己的代码。进程 B 在后台运行,完成后通过某种方式通知进程 A。


并发模型:抢占式与协作式

有两种主要的并发模型:抢占式和协作式。

抢占式多任务处理中,多个进程真正同时运行在不同的 CPU 核心上。它们通过共享内存等机制通信,但这也带来了数据竞争的风险,即多个进程同时修改同一数据导致不可预测的结果。

协作式多任务处理中,多个进程在一个 CPU 核心上交替运行。进程 A 运行一段时间,然后主动让出控制权,进程 B 开始运行,如此循环。这种模型非常适合 I/O 密集型任务。

I/O 密集型任务是指大量时间花在等待输入/输出操作上的任务,例如读取磁盘、等待网络响应或等待用户输入。CPU 的运算速度极快,而 I/O 操作相对非常慢。在等待 I/O 时,CPU 可以转而执行其他任务,从而大幅提高效率。

JavaScript 采用的就是协作式模型。每个待处理的任务被称为一个“事件”。JavaScript 运行时维护一个“事件循环”,不断检查事件队列。每个事件处理器(或回调函数)都会独立且完整地运行,不会中途被其他事件打断。这避免了数据竞争问题,因为同一时刻只有一个事件处理器在操作内存。


JavaScript 事件循环详解

现在,让我们深入了解 JavaScript 事件循环的具体工作机制。

事件循环的核心是一个先进先出的事件队列。各种事件源(如网络活动、键盘输入、鼠标点击)会产生事件,并被推送到这个队列中。

JavaScript 运行时会持续运行一个循环。在循环的每一次迭代中,它会检查事件队列的头部。如果队列中有事件,它会取出该事件,查找并执行与之关联的事件处理器。关键点在于,每个事件处理器都会运行至完成。在此期间,事件循环会暂停,不会处理队列中的下一个事件。只有当当前处理器执行完毕后,事件循环才会继续处理下一个事件。

全局作用域中的代码可以看作是第一个要运行至完成的“任务”。之后,事件循环才正式开始工作。

浏览器环境为 JavaScript 引擎提供了一些支持,例如倒计时计时器和网络通信的实际操作。JavaScript 事件循环本身不处理这些底层细节,它只负责响应“计时器到期”或“网络响应返回”这类高层事件。


实践示例:setTimeout

以下是理解事件循环顺序的一个经典示例。

function foo() {
    const h1 = document.createElement('h1');
    h1.innerText = 'foo';
    document.body.appendChild(h1);
}

function bar() {
    const h1 = document.createElement('h1');
    h1.innerText = 'bar';
    document.body.appendChild(h1);
}

function baz() {
    const h1 = document.createElement('h1');
    h1.innerText = 'baz';
    document.body.appendChild(h1);
}

// 全局作用域开始执行
foo();
setTimeout(bar, 2500); // 延迟 2.5 秒
setTimeout(baz, 0); // 延迟 0 毫秒
console.log('Hello');

执行流程分析:

  1. 首先,全局代码从上到下执行。foo() 被调用并立即执行,页面显示 “foo”。
  2. 遇到 setTimeout(bar, 2500)。它不会立即执行 bar,而是将“在 2.5 秒后执行 bar”这个任务安排到事件队列中,然后立即返回。
  3. 遇到 setTimeout(baz, 0)。同样,它将“尽快执行 baz”这个任务安排到事件队列中,然后立即返回。
  4. 执行 console.log('Hello'),控制台输出 “Hello”。
  5. 全局代码执行完毕。此时,事件队列中有两个任务:bazbarbar 要等 2.5 秒后才可执行)。
  6. 事件循环开始工作。它取出队列中的第一个任务(此时是 baz),并执行它,页面显示 “baz”。
  7. 大约 2.5 秒后,bar 任务变为可执行状态,事件循环取出并执行它,页面显示 “bar”。

因此,最终输出顺序是:页面显示 “foo”,控制台输出 “Hello”,页面显示 “baz”,2.5 秒后页面显示 “bar”。


阻塞事件循环的警告

由于事件处理器是运行至完成的,如果一个处理器执行了非常耗时的计算(CPU 密集型任务),那么在这段时间内,事件循环将被完全阻塞。

这意味着:

  • 页面动画会卡顿。
  • 用户输入(点击、打字)无法响应。
  • 其他网络请求的回调也无法执行。

以下是一个模拟阻塞的例子:

// 一个模拟的耗时计算函数
function longCalculation() {
    let result = 0;
    for (let i = 0; i < 100000000; i++) { // 很大的循环
        result += i;
        // 尝试在循环中更新DOM,但UI在循环结束前不会刷新
        document.getElementById('result').innerText = i;
    }
    return result;
}
// 点击按钮触发这个函数
document.getElementById('calcBtn').addEventListener('click', longCalculation);

点击按钮后,页面会冻结数秒,直到循环结束。在此期间,你无法进行任何交互。

最佳实践:避免在浏览器主线程中进行复杂的 CPU 计算。应将这类计算任务转移到 Web Worker 或在服务器端(后端)完成。


总结

本节课中我们一起学习了 JavaScript 异步编程的核心机制。

我们首先对比了同步和异步编程模型。然后深入探讨了协作式并发模型,这是 JavaScript 高效处理 I/O 密集型任务的基础。我们详细解析了 事件循环 的工作原理:它管理着一个事件队列,并依次执行每个事件的处理器至完成。

通过 setTimeout 的例子,我们看到了异步代码的执行顺序。最后,我们强调了避免阻塞事件循环的重要性,长时间运行的同步任务会冻结整个页面交互。

下一节课,我们将运用关于事件循环的知识,学习第一种在 JavaScript 中进行网络请求的技术:XMLHttpRequest。

033:JavaScript 异步编程之 Promise 🐟

在本节课中,我们将要学习 JavaScript 异步编程的核心概念之一:Promise。我们将了解它如何解决传统回调函数带来的问题,并学习其基本语法、创建方法以及高级用法,如链式调用和组合。

概述

JavaScript 与 Python 或 C 等语言不同,默认内置了异步特性。我们已经探讨过回调和事件监听器,它们意味着 JavaScript 中的操作本质上是并发的,不会一直阻塞程序执行。例如,setTimeout 允许我们在设定时间后执行代码,而程序会继续运行。本节我们将深入探讨 Promise,它是回调的演进,旨在更优雅地管理异步操作。

回调函数回顾

在深入 Promise 之前,让我们快速回顾一下回调函数,这将帮助我们理解 Promise 的动机。

回调函数有两种关键形式。第一种是最基本的线性风格回调。以下代码使用 Node.js 的文件系统模块异步读取五个文件。

const fs = require('fs');
fs.readFile('chapter1.txt', 'utf8', (err, data) => {
    if (err) console.log(err);
    else console.log('Chapter 1:', data);
});
// ... 为 chapter2 到 chapter5 重复类似操作

当我们运行此代码时,输出顺序可能不是 chapter1chapter5,而是混乱的。这是因为 JavaScript 会快速触发五个独立的文件读取事件,由事件循环处理。它们完成的顺序取决于磁盘响应,因此回调的执行顺序是不可预测的。

为了防止这个问题,我们可以使用嵌套回调,也称为回调链。

fs.readFile('chapter1.txt', 'utf8', (err, data1) => {
    if (err) console.log(err);
    else {
        console.log('Chapter 1:', data1);
        fs.readFile('chapter2.txt', 'utf8', (err, data2) => {
            // ... 继续嵌套
        });
    }
});

这种方式确保了操作按顺序执行,但导致了代码嵌套过深,难以阅读和维护,我们称之为“回调地狱”。

引入 Promise

Promise 是 ES6 引入的、旨在更好管理异步操作的概念。与回调一样,它用于处理那些耗时的操作(如文件 I/O 或网络请求),防止它们阻塞主线程。Promise 提供了更强大的功能和更清晰的语法。

Promise 基础语法

让我们比较用回调和 Promise 解决同一个问题的代码。

回调版本:

fs.readFile('chapter1.txt', 'utf8', (err, data) => {
    if (err) console.log(err);
    else console.log('Chapter 1:', data);
});

Promise 版本:

const fs = require('fs').promises; // 使用 promises 版本的 fs 模块
fs.readFile('chapter1.txt', 'utf8')
    .then(data => console.log('Chapter 1:', data))
    .catch(err => console.log(err));

在 Promise 版本中,readFile 返回一个 Promise 对象。我们使用 .then() 方法处理成功情况,使用 .catch() 方法处理失败情况。这成功和错误逻辑分离开来,使代码结构更清晰。

Promise 对象在创建时处于 pending(待定)状态。当异步操作完成时,它要么变为 fulfilled(已兑现),要么变为 rejected(已拒绝),这两种状态统称为 settled(已敲定)。

创建 Promise

理解如何创建 Promise 有助于深入理解其工作原理。Promise 是基于回调构建的语法糖。

以下是一个将回调式函数包装成 Promise 的示例:

function readFilePromise(filename, encoding) {
    return new Promise((resolve, reject) => {
        // 调用原始的、基于回调的函数
        fs.readFile(filename, encoding, (err, data) => {
            if (err) {
                reject(err); // 失败时调用 reject
            } else {
                resolve(data); // 成功时调用 resolve
            }
        });
    });
}

Promise 构造函数接受一个执行器函数,该函数本身接收两个参数:resolvereject,它们都是回调函数。在异步操作中,我们根据结果调用 resolve(value)reject(reason)

更简洁的写法是使用箭头函数:

const readFilePromise = (filename, encoding) => 
    new Promise((resolve, reject) => {
        fs.readFile(filename, encoding, (err, data) => err ? reject(err) : resolve(data));
    });

Promise 链式调用

上一节我们介绍了 Promise 的基本创建和使用,本节我们来看看 Promise 最强大的特性之一:链式调用。它可以优雅地解决“回调地狱”,实现顺序执行异步操作。

链式调用的核心在于:.then() 方法本身返回一个新的 Promise。这意味着我们可以在一个 .then() 后面连接另一个 .then()

readFilePromise('chapter1.txt', 'utf8')
    .then(data1 => {
        console.log('Chapter 1:', data1);
        return readFilePromise('chapter2.txt', 'utf8'); // 返回一个新的 Promise
    })
    .then(data2 => {
        console.log('Chapter 2:', data2);
        return readFilePromise('chapter3.txt', 'utf8');
    })
    // ... 可以继续链接更多 .then
    .catch(err => console.log('An error occurred:', err));

这种写法将嵌套的异步操作“扁平化”为线性代码,极大提高了可读性。此外,链式调用中只需要一个 .catch() 在末尾,就可以捕获链中任何地方抛出的错误。

Promise 组合与分支

除了顺序执行,我们常常需要管理多个并行的异步操作。Promise 提供了静态方法来实现这种“编排”。

Promise.all()

Promise.all() 接收一个 Promise 数组,并返回一个新的 Promise。这个新的 Promise 会在所有输入的 Promise 都成功完成时才会变为 fulfilled 状态,其结果是一个包含所有 Promise 结果的数组。如果其中任何一个 Promise 被拒绝,则 Promise.all() 返回的 Promise 会立即被拒绝。

const promise1 = readFilePromise('chapter1.txt', 'utf8');
const promise2 = readFilePromise('chapter2.txt', 'utf8');
const promise3 = readFilePromise('chapter3.txt', 'utf8');

Promise.all([promise1, promise2, promise3])
    .then(results => {
        console.log('All files opened successfully.');
        console.log(results); // results 是一个数组,包含三个文件的内容
    })
    .catch(err => console.log('One of the files failed to open:', err));

这在需要等待多个独立异步任务全部完成后再执行下一步操作时非常有用,例如同时加载多个资源。

其他组合方法

以下是其他有用的 Promise 组合方法:

  • Promise.race(iterable):当 iterable 中任意一个 Promise 兑现或拒绝时,返回的 Promise 就会以相同的值兑现或拒绝。
  • Promise.any(iterable):当 iterable 中任意一个 Promise 兑现时,返回的 Promise 就会兑现。只有当所有 Promise 都被拒绝时,它才会被拒绝。
  • Promise.allSettled(iterable):等待所有 Promise 都敲定(无论兑现或拒绝)。返回一个 Promise,其结果是一个对象数组,每个对象描述了对应 Promise 的最终状态和值/原因。
  • Promise.resolve(value):返回一个立即以给定值兑现的 Promise 对象。
  • Promise.reject(reason):返回一个立即以给定原因拒绝的 Promise 对象。

总结

本节课中我们一起学习了 JavaScript 的 Promise。我们从回顾回调函数及其问题(如“回调地狱”)开始,引入了 Promise 作为更优的解决方案。我们学习了 Promise 的基本语法,如何使用 .then().catch() 处理异步结果。接着,我们深入探讨了如何自己创建 Promise,并掌握了强大的链式调用来顺序执行异步任务。最后,我们了解了如何使用 Promise.all() 等静态方法来编排多个并行的 Promise。

Promise 是现代 JavaScript 异步编程的基石,它将贯穿于后续关于网络请求(如 AJAX、Fetch API)等主题的学习中。虽然 Promise 的概念很深,有很多细节可以探索,但掌握其核心用法足以应对大多数开发场景。记住,Promise 本质上是一种更优雅地管理异步操作流程的语法工具。

034:JavaScript Async/Await 🐟

在本节课中,我们将要学习 JavaScript 中的 async/await 语法。这是一种用于处理异步操作的现代语法,旨在简化 Promise 的使用,特别是在需要同步执行异步代码的场景中。我们将通过对比 Promise 的传统用法,来理解 async/await 如何让代码更清晰、更易读。

上一节我们介绍了 Promise 的基本概念和使用方法。本节中我们来看看 async/await 语法如何作为 Promise 的扩展,提供一种更直观的编码方式。

为什么需要 Async/Await?

如果你已经使用过 Promise,可能会发现,在处理需要顺序执行的异步操作时,代码会变得有些繁琐。虽然 Promise.then() 链式调用很适合处理异步流程,但在编写同步风格的代码时,它不如其他语言(如 Python 或 Java)的写法直接。

async/await 语法在 ES2017 中被引入,它允许我们以更接近同步代码的风格来“消费”(使用)Promise

基础语法与对比

让我们通过一个读取文件的例子来对比两种写法。

使用 Promise 的写法:

readFile(“chapter1.txt”, “utf8”)
  .then((chapter) => console.log(chapter))
  .catch((err) => console.log(err));

使用 Async/Await 的写法:

async function readFirstChapter() {
  try {
    const chapter = await readFile(“chapter1.txt”, “utf8”);
    console.log(chapter);
  } catch (err) {
    console.log(err);
  }
}

可以看到,async/await 的写法有以下几个关键点:

  1. 函数前需要加上 async 关键字。
  2. 在返回 Promise 的函数调用前加上 await 关键字。
  3. 使用传统的 try...catch 块来处理错误,而不是 .catch() 方法。

await 关键字的作用是:它会暂停当前 async 函数的执行,等待后面的 Promise 完成(resolve),然后返回 Promise 成功的结果。这使得异步代码看起来像是同步执行的。

核心规则与注意事项

以下是使用 async/await 时需要了解的核心规则。

1. Await 必须在 Async 函数内使用

await 关键字只能在标记为 async 的函数内部使用。如果你在普通函数中使用它,会得到一个语法错误。

2. Async 函数总是返回 Promise

一个函数被标记为 async 后,无论其内部返回什么,它总是返回一个 Promise

  • 如果函数显式返回一个值,该值会被自动包装成一个已解决的 Promise
  • 如果函数抛出错误,则返回一个被拒绝的 Promise

这意味着,调用一个 async 函数后,你仍然需要用 .then().catch() 或另一个 await 来处理它的结果。

3. 顺序执行与并发执行

async/await 最大的优势在于简化顺序执行的异步代码。

Promise 链式调用:

readFile(“chapter1.txt”, “utf8”)
  .then((c1) => readFile(“chapter2.txt”, “utf8”))
  .then((c2) => readFile(“chapter3.txt”, “utf8”))
  .then((c3) => console.log(c3));

使用 Async/Await:

async function readChapters() {
  const c1 = await readFile(“chapter1.txt”, “utf8”);
  const c2 = await readFile(“chapter2.txt”, “utf8”);
  const c3 = await readFile(“chapter3.txt”, “utf8”);
  console.log(c3);
}

后者的代码结构更清晰,更接近我们阅读代码的直觉。

然而,async/await 的缺点是它不擅长处理需要并发执行的异步操作。因为 await 会阻塞并等待当前操作完成,然后再执行下一行。对于需要同时发起多个独立请求的场景,使用 Promise.all() 等组合 Promise 的方法仍然是更好的选择。

优缺点总结

在了解了基本用法后,我们来总结一下 async/await 的优缺点。

优点:

  • 可读性高:代码结构更清晰,更接近同步代码的思维模式。
  • 错误处理直观:可以使用熟悉的 try...catch 语法。
  • 调试方便:代码的执行流程更线性,便于设置断点和跟踪。

缺点:

  • 仍需理解 Promiseasync/await 是基于 Promise 的语法糖,理解 Promise 是有效使用它的前提。
  • 不适用于并发:它本质上是顺序执行的,对于需要并行处理多个异步任务的场景,不如原生 Promise 灵活。
  • 顶层调用仍需处理 Promise:由于 async 函数返回 Promise,在程序的最顶层调用时,仍然需要使用 .then() 或一个立即执行的 async 函数包装器来启动。

总结

本节课中我们一起学习了 JavaScript 的 async/await 语法。它是一种强大的工具,能够显著提升异步代码的可读性和可维护性,尤其适用于需要顺序执行异步操作的场景。记住,它是对 Promise 的补充而非替代,理解 Promise 的工作原理是掌握 async/await 的关键。在合适的场景中使用它,可以让你的代码更加简洁优雅。

035:JavaScript 异步网络入门 🦊

在本节课中,我们将要学习 JavaScript 中的异步网络编程。我们将从客户端-服务器模型开始,并了解什么是 Ajax。这些概念是现代 Web 应用开发的基础。

客户端-服务器模型

首先,我们来回顾一下客户端-服务器模型。这是一种网络架构模式,不仅用于网络,也用于 API 设计。客户端是请求服务的一方,例如智能手机、笔记本电脑或 Web 浏览器。服务器是提供服务的一方,通常是数据中心里由谷歌、Facebook、亚马逊等公司运营的机器集群。

其工作流程如下:

  1. 客户端(例如用户)向服务器发送一个请求
  2. 服务器处理该请求,包括参数验证和执行相关 API 逻辑。
  3. 服务器将处理结果作为响应返回给客户端。

例如,当你在谷歌搜索框中输入“cats”时,你的浏览器会向谷歌服务器发送请求。服务器处理你的查询,并返回一系列自动补全建议。然后,浏览器负责接收这些原始数据,并可能结合 HTML、CSS 和 JavaScript 将其渲染成你看到的页面。

现代 Web 的现实情况

上一节我们介绍了基本的客户端-服务器交互,但现实情况要复杂得多。现代 Web 应用通常涉及多个客户端和多个服务器。

以下是现代 Web 架构的典型场景:

  • 一个应用可能拥有多个客户端,如移动端、桌面端等。
  • 这些客户端会向多个不同的服务器发送请求,以获取各种 API 服务(例如新闻 API、图片 API)。
  • 这形成了一个类似“二分图”的连接网络,每个客户端都可能连接到多个服务器。

这种架构带来了设计上的挑战,我们需要分别考虑客户端和服务器的核心需求。

客户端与服务器的核心需求

面对复杂的网络连接,客户端和服务器各有不同的优化目标。

对于客户端,最重要的目标是:

  • 交互性与响应速度:用户输入需要被立即处理,长时间操作需要有进度反馈。
  • 快速初始加载:首次页面加载应该非常迅速。
  • 避免整页重载:理想情况下,应用只进行一次完整的页面加载,后续交互通过局部更新完成,以避免用户丢失当前状态(如表单填写进度、页面滚动位置)。

对于服务器,最重要的目标是:

  • 高吞吐量:每秒处理成千上万个请求。
  • 低延迟:每个请求的响应时间应尽可能短(毫秒级)。
  • 资源效率:优化计算、电力等资源使用,以控制成本和环境影响。

简而言之,客户端追求最小化延迟,服务器追求最大化吞吐量

技术演进简史

为了理解我们如何满足上述需求,让我们简要回顾一下 Web 技术的发展历程。

  • 1990年代至21世纪初:这是“静态网页”时代。客户端主要是桌面浏览器,服务器托管在本地机房。每次页面更新都需要向服务器请求全新的 HTML 页面,这被称为服务器端渲染。扩展应用的方式是升级单台服务器的硬件(垂直扩展)。
  • 21世纪中期:JavaScript(尤其是 jQuery)的使用越来越广泛,客户端计算能力提升。但架构上仍以服务器端渲染为主,开始出现水平扩展(增加服务器数量)的雏形。
  • 当今时代(智能手机与云计算兴起后):客户端变得多样化(手机、物联网设备),且性能强大。渲染工作大量转移到客户端。服务器端依托云平台(如 AWS、GCP)进行大规模水平扩展和负载均衡。关键技术变为异步处理懒加载,即先加载最小必要资源,再按需异步获取其他数据。

什么是 Ajax?

经过历史的演进,我们形成了一套实现异步 Web 应用的技术集合,这就是 Ajax

Ajax 是 Asynchronous JavaScript and XML 的缩写。尽管如今 XML 大多被 JSON 取代,但“Ajax”这个名称保留了下来。它不是一项单一技术,而是一系列用于创建异步 Web 应用的技术手段

使用 Ajax 的好处包括:

  • 流畅的用户体验:页面无需完全刷新即可更新内容。
  • 前后端分离:前端专注于表现层,后端专注于数据和业务逻辑,模块化更清晰。
  • 支持离线应用:前端可以在本地保存状态,并在恢复网络连接后异步同步到服务器。

其工作模式可以理解为:浏览器中的用户界面与一个“Ajax 引擎”(代表异步处理逻辑)进行交互。这个引擎在后台与服务器通信,获取数据后更新界面,形成了一个高效的闭环。

总结

本节课中,我们一起学习了客户端-服务器模型的基本概念和 Ajax 的定义。我们了解到,现代 Web 开发通过将渲染工作转移到客户端并广泛采用异步通信(Ajax),来满足客户端对低延迟、高响应度的需求,同时利用服务器的水平扩展和异步处理来应对高吞吐量的挑战。

接下来,我们将深入探讨 JavaScript 是如何处理并发和异步操作的,这是理解 Ajax 如何实现的技术基础。

036:JavaScript AJAX 🦊 XHR

在本节课中,我们将要学习如何使用 JavaScript 进行异步网络通信,核心是掌握 XMLHttpRequest (XHR) 对象。我们将了解如何发起请求、处理响应以及应对各种错误情况。

概述

上一节我们介绍了 JavaScript 的事件循环,它允许我们执行异步操作。本节中,我们来看看如何利用 XMLHttpRequest API 来实现网络请求,这是浏览器内置的、用于发起 HTTP 请求的经典方法。

客户端-服务器模型与并发

我们首先讨论了客户端-服务器模型,然后探讨了 JavaScript 的并发模型,特别是事件循环。现在,我们将学习如何将异步网络请求变为现实。

认识 XMLHttpRequest

XMLHttpRequest 是浏览器内置的 API,用于发起 HTTP 请求。它默认是异步的,这避免了同步执行可能导致的浏览器阻塞问题。

以下是创建一个基本 XHR 请求的流程:

  1. 创建对象:实例化一个新的 XMLHttpRequest 对象。
    const xhr = new XMLHttpRequest();
    
  2. 初始化请求:使用 open 方法指定请求方法和 URL。
    xhr.open('GET', 'https://api.example.com/data');
    
  3. 发送请求:调用 send 方法发起请求。建议将其包裹在 try...catch 中,以捕获发送阶段可能抛出的异常。
    try {
      xhr.send();
    } catch (error) {
      console.error('请求发送失败:', error);
    }
    
  4. 处理响应:通过设置回调函数(如 onload)来处理请求完成后的逻辑。在回调中,可以检查状态码(如 xhr.status === 200)并获取响应数据(如 xhr.responseText)。
    xhr.onload = function() {
      if (xhr.status === 200) {
        console.log('成功:', xhr.responseText);
      } else {
        console.error('请求出错,状态码:', xhr.status);
      }
    };
    

实战演示:获取IP地址

让我们通过一个简单的例子来实践。我们将创建一个按钮,点击后通过 XHR 从远程 API 获取并显示用户的 IP 地址。

以下是实现步骤:

  1. 创建 XMLHttpRequest 对象。
  2. 使用 open 方法初始化一个 GET 请求到 https://api.ipify.org?format=json
  3. 设置 onload 回调函数,在请求成功(状态码 200)时,解析返回的 JSON 字符串,并更新页面上的文本内容。
  4. send 调用包裹在 try...catch 块中。
  5. 为按钮的点击事件绑定上述请求函数。

核心代码片段:

function getIP() {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', 'https://api.ipify.org?format=json');
  xhr.onload = function() {
    if (xhr.status === 200) {
      const data = JSON.parse(xhr.responseText); // 解析JSON字符串
      document.getElementById('ip-text').textContent = data.ip;
    } else {
      console.error('获取IP失败,状态码:', xhr.status);
    }
  };
  try {
    xhr.send();
  } catch (error) {
    console.error('请求发送异常:', error);
  }
}

错误处理策略

在网络请求中,错误处理至关重要。我们可以将错误分为两类:预期错误和意外错误。

以下是处理这些错误的方法:

  • 预期错误:通常通过 HTTP 状态码传达。
    • 4xx (如 400, 401, 404):通常表示客户端请求有问题(如参数错误、资源不存在)。
    • 5xx (如 500, 502):通常表示服务器端出现问题。
    • 处理方式:在 onload 回调中检查 xhr.status,并根据不同的状态码进行相应处理(如提示用户、重试)。
  • 意外错误:包括网络断开、请求超时等。
    • 处理方式1:使用 try...catch 包裹 xhr.send() 来捕获发送阶段的异常。
    • 处理方式2:设置 onerror 回调函数来处理请求过程中发生的网络错误。
    • 处理方式3:设置 ontimeout 回调来处理请求超时。
    • 最佳策略:对于意外错误,通常采用“指数退避”算法进行重试,即每次重试的等待时间逐渐增加,达到一定次数后最终失败并告知用户。

示例:设置 onerror 回调

xhr.onerror = function() {
  console.error('网络请求过程中发生错误');
};

避免回调地狱

当多个异步操作依赖前一个操作的结果时,代码容易写成多层嵌套的回调函数,形成所谓的“回调地狱”,这会使代码难以阅读和维护。

以下是避免回调地狱的建议:

  • 扁平化优于嵌套:尽量避免深度嵌套的回调。
  • 使用命名函数:对于包含非平凡逻辑的回调,将其定义为独立的命名函数,而不是匿名函数。这提高了代码的可读性和可维护性。
  • 善用箭头函数:对于简单的单行回调逻辑,可以使用箭头函数使代码更简洁。
  • 遵循软件工程原则:如单一职责原则(SRP),确保函数只做一件事。

总结

本节课中我们一起学习了 XMLHttpRequest 的基本用法。我们了解了如何创建请求、发送请求、处理成功响应以及应对各种错误。虽然 XHR 是一个较老的 API,但在许多遗留代码库中仍然存在,理解它非常重要。同时,我们也探讨了如何通过良好的代码组织来避免“回调地狱”。

在下一讲中,我们将学习更现代、更优雅的 fetch API 以及 Promise,它们提供了更强大的异步网络编程能力。

037:JavaScript AJAX 🦊 Fetch

在本节课中,我们将学习如何使用现代JavaScript的Fetch API来替代传统的XMLHttpRequest进行网络请求。Fetch API基于Promise构建,提供了更简洁、更强大的异步操作方式。

概述

Promise本身是一个有趣的计算机科学理论概念。然而,对于注重实效的程序员而言,在引入Fetch API之前,它的实用性有限。Fetch API旨在替代XMLHttpRequest,其核心特点是原生基于Promise。它允许我们完成之前的所有任务,但提供了更优雅的Promise实现方式。

Fetch API 基础

上一节我们提到了Promise的概念,本节中我们来看看如何将其应用于实际的网络请求。

Fetch API的主要优势在于它直接使用Promise。与之前XMLHttpRequest在遇到网络错误时会抛出异常不同,Fetch API中的网络错误会导致Promise被拒绝(reject)。但需要注意的是,它不会仅仅因为HTTP状态码不是200而拒绝,这只针对网络错误。这一点非常有用,因为它意味着你可以用一种清晰的方式捕获意外错误,并使用.catch方法以同样清晰的方式处理预期错误。

Fetch API接收两个主要参数:请求的URL和一些可选的配置参数,用于自定义请求行为。

以下是配置参数的一个基本示例:

fetch(url, {
  method: 'POST', // 默认为 GET
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer your_token_here'
  },
  // ... 其他配置
})

最常用的配置是更改HTTP方法(默认为GET)。你还可以添加更多内容,例如更改认证令牌、添加头部信息(如Accept、Access-Control等),当然也可以更改HTTP方法。这使得代码看起来更加清晰。

实践:转换Chuck Norris API示例

我们将把之前课程中使用XMLHttpRequest调用Chuck Norris API的例子转换为使用Fetch API。

首先,我们很少需要手动创建原始的Promise,因为调用fetch函数会自动为我们创建Promise,这非常方便。我们只需要提供URL,然后进行链式调用。

让我们先处理错误情况。记住,当发生网络错误时,fetch返回的Promise会被拒绝。在我们的例子中,两种错误情况都只是将信息输出到控制台。

以下是处理网络错误的基本结构:

fetch(url)
  .catch(error => {
    console.error('There was a network error:', error);
  });

那么如何处理成功响应呢?我们通过链式调用.then方法来实现。使用Fetch时需要注意,当Promise完成(fulfill)时,它返回的是一个Response对象。我们需要通过这个对象来检查状态和进行后续操作。

Response对象有一个ok属性,如果响应成功(状态码在200-299之间)则为true,否则为false

以下是检查响应状态的基本逻辑:

fetch(url)
  .then(response => {
    if (!response.ok) {
      // 处理HTTP错误(如404)
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json(); // 解析JSON数据,它返回另一个Promise
  })
  .then(data => {
    // 在这里处理解析后的数据
    console.log(data);
  })
  .catch(error => {
    // 捕获网络错误或上面抛出的HTTP错误
    console.error('There was a problem:', error);
  });

如果响应是成功的,我们需要获取数据。Fetch原生支持JSON,有一个名为.json()的方法。但请注意,.json()方法本身也返回一个Promise。因此,我们需要再链接一个.then来获取实际的数据。

将上述逻辑整合,完整的Fetch调用代码如下所示。与之前的XMLHttpRequest版本相比,代码行数更少,结构更清晰。

运行这个转换后的代码,功能与之前完全相同。为了演示错误捕获,我们可以模拟网络断开的情况。断开网络后尝试请求,控制台会显示网络错误。重新连接网络后,请求又能正常进行。如果请求一个不存在的URL(返回404),错误也会被.catch块捕获。

一切功能都与XMLHttpRequest相同,但代码更加优雅。

Fetch 与 XMLHttpRequest 的差异

虽然Fetch带来了诸多便利,但它并非万能银弹,与XMLHttpRequest存在一些差异。

以下是两者的一些主要区别:

  • 浏览器兼容性:XMLHttpRequest甚至可以在IE5等非常古老的浏览器版本中工作。如果你的项目需要支持这些环境,而Fetch不可用,那么你可能仍然需要使用XMLHttpRequest。
  • 进度监控:据我所知,XMLHttpRequest是唯一允许你监控大文件下载进度等的API。使用Fetch和Promise很难直接实现这一点,除非使用Streams API(这是另一个基于Promise的独立API)。
  • 请求取消:取消Promise并不容易,而这在某些场景下(如超时处理)非常重要。XMLHttpRequest可以更直接地中止请求。

每种技术都有其优点和缺点,但总体而言,Fetch的优点更多,因此你应该优先使用它。与Fetch配合使用的Streams API学习曲线并不平缓,如果你有兴趣,可以查阅相关文档链接。

我们正在向前发展,Fetch API是强大、灵活且基于Promise的,这非常棒。

总结

本节课中我们一起学习了现代JavaScript的Fetch API。我们了解了它如何作为XMLHttpRequest的替代品,基于Promise提供了更清晰的异步请求语法。我们通过将Chuck Norris API的示例从XMLHttpRequest转换为Fetch,实践了其基本用法,包括链式调用.then处理响应、使用.json()方法解析数据以及用.catch捕获错误。最后,我们讨论了Fetch与旧式XMLHttpRequest在浏览器兼容性、进度监控和请求取消等方面的主要差异。尽管存在一些限制,但Fetch API因其简洁性和现代性而成为网络请求的推荐选择。

038:🌐 HTTP服务器(第一部分)

在本节课中,我们将要学习网络、互联网、万维网(Web)以及HTTP服务器的基本概念。这是COMP1531课程中一个非常重要的主题,我们将通过构建自己的Web服务器来理解其工作原理。本节课是第一部分,主要介绍背景知识和一个简单的Express服务器示例。


概述:网络、互联网与万维网

在深入探讨Web服务器之前,我们需要理解几个核心概念:网络、互联网和万维网。它们是层层递进的关系。

网络 是一个广义概念,指一组能够相互通信的互联计算机。例如,你家里的所有设备通过路由器连接,就构成了一个家庭网络。

互联网 是一个全球性的基础设施,用于连接世界各地的计算机,使它们能够相互通信。它是一个巨大的网络。

万维网 是运行在互联网之上的一个系统,由通过URL链接的文档和资源组成,主要通过HTTP协议访问。我们日常使用的网站、YouTube、GitLab等都是万维网的一部分。

简单来说:网络包含互联网,而互联网又承载着万维网。我们即将学习的Web服务器,就运行在万维网上。


网络协议:沟通的规则

当计算机通过网络通信时,它们需要遵循特定的结构或规则,以确保彼此能够理解。这就像打电话时有固定的开场白和结束语一样。

这些规则被称为 网络协议。不同的通信类型使用不同的协议。

  • SSH:用于安全地远程连接另一台计算机(如连接VLab)。
  • SMTP/IMAP:用于发送和接收电子邮件。
  • HTTP超文本传输协议,是万维网的专用协议。当你在浏览器中访问网站时,使用的就是HTTP协议。

在本课程中,我们将重点学习 HTTP协议


Web服务器基础模型

万维网最初的设计非常简单,就像一个在线图书馆。其基本工作模型如下:

  1. 你(客户端)使用Web浏览器(如Chrome)。
  2. 浏览器向Web服务器发送一个HTTP请求,例如“我想要index.html这个资源”。
  3. Web服务器收到请求后进行一些处理。
  4. 服务器向你的浏览器返回一个HTTP响应,其中包含你请求的资源(如HTML文档)。

这个过程与在餐厅点餐类似:你是顾客(客户端),向服务员(服务器)提出请求,服务员处理后为你提供食物(响应)。

在本课程中,我们的重点是构建自己的Web服务器


引入Express:一个Node.js Web服务器库

我们将使用一个名为 Express 的Node.js库来构建HTTP服务器。它是一个NPM模块,使用起来非常直接。

以下是一个最基本的Express服务器代码示例。我们将逐行分析:

import express from 'express';

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/5235cb1364d5cbf0d954368cf2cfbc52_32.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/5235cb1364d5cbf0d954368cf2cfbc52_34.png)

const app = express();
const port = 3000;
app.use(express.json());

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/5235cb1364d5cbf0d954368cf2cfbc52_36.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/5235cb1364d5cbf0d954368cf2cfbc52_38.png)

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

app.listen(port, () => {
    console.log(`Example app listening on port ${port}`);
});

代码解析:

  1. import express from 'express';
    • 导入Express库。
  2. const app = express();
    • 创建一个Express应用实例。这类似于你之前使用prompt-sync库时创建的对象。
  3. const port = 3000;
    • 定义一个端口号。端口的概念稍后会详细解释。
  4. app.use(express.json());
    • 这是Express的一个配置项,用于解析JSON格式的请求数据。目前可以暂时忽略其具体含义,但需要包含这行代码。
  5. app.get('/', (req, res) => { ... });
    • 这是定义服务器路由行为的核心部分。
    • app.get 表示监听对特定URL路径的GET请求。
    • '/' 是URL路径,这里代表网站的根目录(例如 http://localhost:3000/)。
    • (req, res) => { ... } 是一个回调函数(匿名函数),当有请求到达该路径时被调用。
    • req 对象包含请求的详细信息(如参数、头部)。
    • res 对象用于构建和发送响应。这里我们使用 res.send('Hello World') 来返回文本“Hello World”。
  6. app.listen(port, ...);
    • 这行代码启动服务器。它让服务器开始监听指定端口(这里是3000),等待客户端连接。


运行服务器并理解关键概念

当你运行上述代码时,服务器并不会像普通脚本一样执行完就退出。它会持续运行,就像一个在岗待命的服务员,循环等待客户的请求,直到你手动终止它(例如按 Ctrl+C)。

服务器运行后,你可以在浏览器中访问 http://localhost:3000 来测试它。这里引出了两个新概念:

  • localhost:这是一个特殊的主机名,指代你当前正在使用的计算机本身。它对应的IP地址通常是 127.0.0.1。在开发阶段,我们通常在本地计算机上运行和测试服务器。
  • 端口:你可以将端口理解为计算机上的一个“服务窗口”。一台计算机可以同时运行多个网络服务(如Web服务器、数据库、邮件服务器),每个服务监听一个不同的端口号,就像一家商店有多个接待窗口一样。端口号范围很大,开发中常用 30008080 等。

重要提示:开发环境的隔离性
当你在VLab或自己的开发机上运行这个Express服务器时,它仅运行在该台具体的计算机上,并没有自动发布到公共互联网。因此:

  • 只有连接到同一台计算机(例如,同一个VLab服务器实例,如 vx8)的其他用户,才能通过 localhost:端口号 访问你的服务器。
  • 你在家用电脑的浏览器里输入 localhost:3000,访问的是你自己电脑上的服务,无法访问到运行在VLab上的服务器。


端口冲突与解决方案

由于端口是计算机上的共享资源,可能会发生端口冲突。例如,如果你在VLab的 vx8 机器上运行服务器在端口 2022,而另一位同学也登录到 vx8 并试图在同一个端口 2022 上启动他的服务器,他就会收到“地址已被占用”的错误。

解决方法很简单:更改端口号。 只需将代码中的 const port = 2022; 改为另一个未被占用的数字(例如 30224000)即可。在开发中,这是一个常见且容易解决的问题。


扩展服务器:定义多个路由

Express的强大之处在于可以轻松定义多个路由来处理不同的URL请求。以下是如何扩展我们的服务器:

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/5235cb1364d5cbf0d954368cf2cfbc52_75.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/5235cb1364d5cbf0d954368cf2cfbc52_76.png)

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/5235cb1364d5cbf0d954368cf2cfbc52_78.png)

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/5235cb1364d5cbf0d954368cf2cfbc52_80.png)

app.get('/oh/my/god/why/is/this/url/so/long', (req, res) => {
    res.send('You found the long URL!');
});

现在,你的服务器可以响应四个不同的URL路径:

  • http://localhost:3000/ -> 返回 “Hello World”
  • http://localhost:3000/1 -> 返回 “1”
  • http://localhost:3000/1/2 -> 返回 “2”
  • http://localhost:3000/oh/my/god/why/is/this/url/so/long -> 返回 “You found the long URL!”

这展示了如何根据不同的URL路径提供不同的内容,这是构建Web应用API的基础。


总结

本节课我们一起学习了以下核心内容:

  1. 概念区分:理解了网络互联网万维网之间的关系。
  2. 网络协议:知道了HTTP协议是万维网的专用通信协议。
  3. Web服务器模型:掌握了客户端-服务器请求-响应的基本交互模型。
  4. Express入门:使用Node.js的Express库创建了一个最简单的HTTP服务器。
  5. 关键术语:明白了 localhost(本地主机)和 端口 的含义及其在开发中的作用。
  6. 路由定义:学会了如何使用 app.get() 为不同的URL路径定义处理逻辑。

简而言之,一个Web服务器就是一个运行在特定计算机、监听特定端口的程序,它接收HTTP请求,并根据请求的URL等信息返回HTTP响应。Express让我们能够用JavaScript轻松地编写这样的程序。

在下一节课中,我们将深入探讨如何构建更复杂的API、处理不同类型的HTTP请求(如POST、PUT、DELETE),并开始将它们与完整的软件项目结合起来。

039:Week 5 - 作业3演示 🚀

在本节课中,我们将学习如何完成作业3。作业3要求我们创建一个交互式网页,该网页能够根据用户输入动态生成内容。我们将通过一个具体的演示来理解如何实现这一目标。

上一节我们介绍了作业3的基本要求,本节中我们来看看如何具体实现一个动态生成列表的功能。

核心概念:动态生成列表

动态生成列表的核心在于使用JavaScript来操作DOM(文档对象模型)。我们将根据用户输入的数据,在网页上创建并显示相应的列表项。

以下是实现动态生成列表的关键步骤:

  1. 获取用户输入:我们需要从HTML输入框中获取用户输入的值。
  2. 创建列表项:根据输入的值,使用JavaScript创建一个新的列表项元素(<li>)。
  3. 将列表项添加到列表:将新创建的列表项添加到HTML中已存在的无序列表(<ul>)中。
  4. 清空输入框:在添加完成后,清空输入框以便用户输入下一个项目。

代码实现

让我们通过代码来具体实现上述步骤。首先,我们需要一个基本的HTML结构。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>动态列表生成器</title>
</head>
<body>
    <h1>我的待办事项列表</h1>
    <input type="text" id="itemInput" placeholder="输入一个新项目...">
    <button id="addButton">添加</button>
    <ul id="itemList"></ul>

    <script src="script.js"></script>
</body>
</html>

接下来,我们编写JavaScript代码(script.js)来实现交互逻辑。

// 获取页面上的元素
const inputElement = document.getElementById('itemInput');
const buttonElement = document.getElementById('addButton');
const listElement = document.getElementById('itemList');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/19a916dab9b86403ca44cf2d7894d7ac_10.png)

// 为按钮添加点击事件监听器
buttonElement.addEventListener('click', function() {
    // 步骤1:获取用户输入
    const newItemText = inputElement.value.trim();

    // 检查输入是否为空
    if (newItemText === '') {
        alert('请输入内容!');
        return; // 如果为空,则停止执行
    }

    // 步骤2:创建新的列表项元素
    const newListItem = document.createElement('li');
    // 将用户输入的文字设置为列表项的文本内容
    newListItem.textContent = newItemText;

    // 步骤3:将新列表项添加到无序列表中
    listElement.appendChild(newListItem);

    // 步骤4:清空输入框
    inputElement.value = '';
});

功能扩展:添加删除功能

一个完整的待办事项列表通常还需要删除功能。我们可以为每个列表项添加一个删除按钮。

以下是修改后的JavaScript代码,为每个新增的列表项添加删除按钮:

buttonElement.addEventListener('click', function() {
    const newItemText = inputElement.value.trim();

    if (newItemText === '') {
        alert('请输入内容!');
        return;
    }

    const newListItem = document.createElement('li');
    newListItem.textContent = newItemText;

    // 创建删除按钮
    const deleteButton = document.createElement('button');
    deleteButton.textContent = '删除';
    // 为删除按钮添加点击事件
    deleteButton.addEventListener('click', function() {
        // 当点击删除按钮时,从列表中移除其父元素(即<li>)
        listElement.removeChild(newListItem);
    });

    // 将删除按钮添加到列表项中
    newListItem.appendChild(deleteButton);

    // 将列表项添加到列表
    listElement.appendChild(newListItem);

    inputElement.value = '';
});

总结

本节课中我们一起学习了如何构建一个动态生成列表的交互式网页。我们掌握了以下关键点:

  • 使用document.getElementById获取DOM元素。
  • 使用addEventListener为元素绑定事件(如点击事件)。
  • 使用document.createElement动态创建新的HTML元素。
  • 使用appendChild将新元素插入到DOM树中。
  • 使用removeChild从DOM树中移除元素。

通过组合这些基本的DOM操作方法,我们可以创建出丰富多样的前端交互效果。理解这些原理是完成作业3以及未来更复杂前端开发任务的基础。

040:UI基础 🥑

在本节课中,我们将学习用户界面设计的基础知识。我们将探讨如何通过遵循一些核心的视觉设计原则,使你的应用程序看起来更专业、更易于使用。课程将分为三个主要部分:首先,我们将学习如何通过布局、字体和颜色让用户不感到“害怕”;其次,我们将了解如何通过视觉层次帮助用户快速找到他们需要的内容;最后,我们将讨论如何通过“可供性”让用户知道如何与界面元素进行交互。


第一部分:让用户不感到害怕——图形设计基础

上一节我们介绍了课程的整体目标,本节中我们来看看如何通过遵循一些基础的图形设计原则,让用户对你的应用感到信任和舒适,而不是望而却步。

我们将重点讨论三个基础概念:对齐字体颜色。人类天生喜欢一致、有序的图案,遵循这些原则可以创造出和谐、专业的视觉体验。

对齐

对齐意味着元素在页面上的排列方式。人类大脑偏爱一致的模式、均匀的间距和对称性。在应用程序中,我们通常使用网格来创建出色的对齐效果,即使界面中包含许多不同类型的元素。

以下是网格的基本构成:

  • :页面被垂直划分的区域。
  • 间距:列与列之间的空隙。

一个常见的做法是使用12列网格,因为它可以被灵活地划分为2、3、4、6列,以适应不同的布局需求。同时,为页面内容设置一个最大宽度(例如1200像素),可以确保文本在宽屏显示器上的可读性。

示例:BBC新闻网站就使用了12列网格。页面上的所有内容,无论是顶部复杂的大图配小文布局,还是底部整齐排列的三栏新闻,都严格对齐到网格线上,从而创造出高度一致的视觉感受。

字体

在字体选择上,一个核心建议是:少即是多。限制字体种类、字号和样式的数量,可以创造一致性,让设计更易于理解。

专业设计(如海报、网站)中,超过90%的情况只使用一到两种字体。你可以通过创建一个字体调色板来提前规划。

示例:Google的Material Design设计语言中,整个产品系列主要使用Roboto这一种字体,但通过定义10-15种不同的字号和字重(如粗体、细体)变体,来满足所有设计需求。创建一个类似的表格,明确规定标题、副标题和正文的字体样式,是确保界面字体一致性的有效方法。

颜色

颜色是创造视觉吸引力和品牌一致性的关键。应用程序通常使用非常简单的配色方案。

一个典型的应用配色方案包括:

  • 一个主色调
  • 一个辅助色
  • 黑色、白色及各种灰度

挑战在于如何选择能和谐搭配的多种颜色。这里可以借助数学和色彩模型,而不仅仅是依赖RGB值。

核心概念:RGB色彩空间对于设计并不直观。我们可以使用如HSL这样的色彩空间,它将颜色分解为:

  • 色相:颜色的种类(如红、蓝),范围是0-360度。
  • 饱和度:颜色的鲜艳程度。
  • 明度:颜色的明亮程度。

要创建和谐的配色,可以固定色相,只调整饱和度和明度,来生成同一色系下深浅不同的颜色。在线工具如CoolorsAdobe Color可以帮助你轻松创建这样的和谐配色盘。

与字体一样,颜色的使用也需要保持一致性。选择一两个核心品牌色,并在整个应用中坚持使用,是让应用感觉统一和专业的关键。


第二部分:帮助用户快速找到目标——视觉层次

现在,我们已经学会了如何通过基础设计让界面看起来舒适。接下来,我们将探讨如何帮助用户在复杂的界面中快速定位到最重要的信息,这需要通过建立清晰的视觉层次来实现。

视觉层次是指通过设计手段,将信息按重要性进行排序和呈现,让用户的视线能自然地被引导至最关键的内容。

我们可以通过以下六个元素来构建视觉层次:

以下是构建视觉层次的六个关键技巧:

  1. 尺寸:更大的元素看起来更重要。
  2. 颜色:鲜艳、明亮的颜色比灰暗的颜色更吸引眼球。
  3. 对比:在颜色区块内部使用高对比度(如白底蓝框内的白字),能进一步突出元素。
  4. 间距:在元素周围留出充足的空白(内边距),能使其在密集布局中脱颖而出。
  5. 对齐:在一组对齐整齐的元素中,一个打破对齐规则的项目会显得格外突出。
  6. 重复:重复的、模式化的元素容易被整体感知,而打破模式的单个元素则更引人注意。

示例一:电费账单。最重要的信息是“应付总额”和“付款截止日”。设计上,这些信息被放在一个亮蓝色的方框内,使用大号加粗字体,并与周围内容有显著间距,这使得用户一眼就能看到关键信息。

示例二:ABC新闻网站

  • 弱化非核心:顶部导航栏使用了深色,降低其视觉重要性,因为用户预期它就在那里。
  • 突出头条:最重要的新闻使用最大的字体和醒目的横幅(如亮蓝色背景+大量内边距)。
  • 区分内容区块:“专题报道”部分使用了丰富的图片和颜色,而“最新消息”部分则几乎全是文字,颜色单调,这种对比引导用户优先关注“专题报道”。

视觉层次的核心是相对重要性。你需要决定应用中什么功能对大多数用户最重要,并运用上述技巧将其凸显出来。


第三部分:帮助用户执行操作——可供性

在上一节,我们学习了如何帮助用户找到他们需要的内容。本节中,我们来看看如何确保用户知道如何与他们找到的内容进行交互,这就需要用到“可供性”的概念。

可供性是指一个元素的视觉属性,它向用户暗示了可以对这个元素执行什么操作。简单说,就是“看起来能做什么”。

可供性的类型

以下是几种常见的可供性类型:

  1. 显性可供性:直接告诉用户如何交互。
    • 示例:“点击此处注册”链接、“滑动解锁”滑块、具有背景色、圆角和阴影的按钮。这些设计明确地发出了行动号召。

  1. 模式可供性:利用用户从其他应用中熟悉的常见模式来暗示交互可能性。
    • 示例:带下划线的蓝色文本(暗示是链接)、汉堡菜单图标(三条横线,暗示是菜单)、复选框。使用这类可供性时,必须确保你采用的模式是目标用户所熟知的。

  1. 错误可供性(需避免):某个元素看起来可以交互,但实际上不能。这会造成用户的困惑和挫败感。
    • 示例:一个看起来像按钮但无法点击的图形。在设计时,应请他人测试界面,找出任何可能产生错误可供性的地方并进行修正。

总结

本节课我们一起学习了用户界面设计的基础知识,旨在让你的应用看起来更专业、更易用。

我们首先探讨了如何通过对齐(使用网格)、字体(创建精简的字体调色板)和颜色(使用HSL等色彩模型创建和谐配色)来奠定坚实的视觉基础,让界面看起来有序、可信。

接着,我们学习了如何运用视觉层次,通过尺寸、颜色、对比、间距等技巧,突出界面中最重要的信息,帮助用户快速扫描并找到目标。

最后,我们了解了可供性的概念,知道了如何通过显性或模式化的设计,明确地向用户展示哪些元素可以交互以及如何交互。

掌握这些基础原则,你就能有效地提升前端作品的专业度和用户体验。

041:用户界面设计基础 🎨

在本节课中,我们将学习用户界面设计的基础知识。课程分为三个主要部分:首先,我们将从用户视角分析一些优秀和糟糕的UI设计案例,以提升我们的设计意识。其次,我们将学习如何评估和改进自己的UI设计。最后,我们将探讨一些可选的进阶设计概念,以进一步提升界面水平。


第一部分:UI分析实践 👁️

上一节我们介绍了课程的整体结构,本节中我们来看看如何进行UI分析。我们将分析四到五个不同的网站,每个网站都有不同的用户目标,以此来评估它们在帮助用户达成目标方面的有效性。

我们的目标是练习站在用户的角度思考,观察UI设计。请记下你注意到的设计策略、之前从未想过的事情以及未来可能想记住的点。

案例一:LinkedIn

LinkedIn的页面主要面向两类用户:想要登录的回头客,以及想要了解LinkedIn并注册的新用户。

  • 设计特点:这个网站的设计非常简约。对于回头客,最重要的登录信息直接显示在顶部,他们可以轻松登录。
  • 视觉引导:用户的注意力会立刻被这个大标题吸引,界面清晰、易用、易于导航。
  • 色彩与形状:色彩方案非常柔和,形状上有很多平衡感,例如许多元素都是圆角矩形。
  • 辅助图形:页面中包含许多有帮助的图形。其中一个图形很好地传达了“保持联系”的理念,这与LinkedIn的核心功能完美契合。
  • 逻辑顺序:页面的信息顺序非常合乎逻辑。首次访问的用户会先阅读关于LinkedIn的信息,了解它是什么。在介绍完毕后,“开始使用”按钮被策略性地放置在底部。接着,用户可以看到一个包含所有可能想查找页面的目录图。
  • 总体评价:这是一个优秀的UI设计,非常干净、专业,这正符合LinkedIn的定位。

案例二:Canva

我们的目标是使用Canva制作一个营销海报。

  • 即时引导:Canva一打开,这个横幅会立刻吸引用户的视线。Canva有效地运用了色彩和比例,使网站非常易于导航。
  • 理解用户需求:这是一个很好的例子,说明网站了解用户可能需要什么,并将其呈现出来。我想制作营销海报,可以从建议中看到“营销”是其中之一,点击“海报”即可。
  • 新手帮助:对于首次使用的用户,Canva提供了充足的帮助,例如入门视频和教程。用户可以选择观看教程或跳过。
  • 模板效率:进入编辑器后,Canva提供了大量模板,这使得设计变得超级简单、快速、高效。
  • 目标达成:看,我已经完成了设计,可以立即下载。我在短短几步内就完成了目标。
  • 设计启示:从这个案例中,我们可以学到两点:一是为新手用户提供大量帮助;二是理解用户目标并为之设计。

案例三:Quora

我们的目标是在Quora上找到一个问题的答案。

  • 入口障碍:一加载Quora网站,我就遇到了这个登录界面。不幸的是,与Canva不同,Quora要求用户登录才能查看其内容和界面。对于一个没有Quora账户但急需立即找到答案的用户来说,这已经不是一个理想的体验,因为它设置了进入门槛。
  • 优点:导航栏非常简约,色彩方案简单,这部分本身易于使用。我喜欢“相关问题”栏是固定的,无论我向下滚动多少,它都保持可见,这非常有用。我也喜欢这个粗体大字号的标题。
  • 缺点:Quora上充斥着广告。在问题下方首先看到的就是一个完全不相关的广告。即使在答案中,也遍布着广告,这让人分不清哪些是答案,哪些是问题。我不确定这是对初始问题的回答,还是对这个新问题的回答。导航非常混乱,阅读困难。
  • 总体评价:如果我只是需要一个简单快速的答案,我不会去Quora。这是一个糟糕的用户体验。

案例四:耶鲁大学艺术学院

我们的目标是找到如何申请入读这所学校。

  • 第一印象:我的视线被吸引到这里,也因为背景非常分散注意力。许多文本没有坚实的背景,你可以看到文本只是被高亮显示。
  • 布局问题:布局缺乏一致性和秩序感。这里有很多不同大小的文字,我认为比例运用得非常差。“快速链接”在这里,也许我应该点这里,或许“机会”是我需要去的地方,但我发现我点错了地方。
  • 分散注意:这个背景本身就很分散注意力。最后我才注意到这个小小的侧边栏,它非常重要,但起初我并没有注意到。
  • 进入申请页面:点击“申请入学”后,我们终于看到了一个白色背景。
  • 总体评价:你可以看到,这里对比例的感觉很差,这些不同的尺寸非常不一致。是的,导航真的非常困难,很难完成我的目标,因为我的大脑需要花时间去处理眼前的信息。

案例五:UNSW网站

同样以申请入学为目标,在UNSW的网站上很容易找到我需要的信息。

  • 清晰导航:我已经可以看到这里有几个选项,“学习”可能是我想要的。我可以看到这里可以找到我可能需要的所有信息:学什么、体验、如何入学。我立刻就能看到“立即申请”,这是我的初始目标,我可以快速点击它。
  • 目标达成:看,我需要的一切都在这里。
  • 设计启示:正如你所见,干净、简约的界面无疑是正确的方向,因为它们不仅看起来更美观,而且更容易导航,用户也能更好地完成任务。

第二部分:评估与改进自己的UI 🔧

上一节我们分析了他人的用户界面,本节中我们来看看如何评估和改进自己的设计。你可能已经学习了一些关于用户界面设计、规划、原型设计、迭代和测试等方面的良好实践。

然而,很可能你在完成作业时发现无法按时完成所有任务,因此你正在寻找方法在UI部分多拿几分。我曾经也这样做过。

因此,我整理了一系列以用户为中心的问题和一些快速实用的技巧,希望能帮助你改进设计。

我认为这个部分在你即将开始作业或已经开始作业时最有用。我建议你把它当作一个备忘单,也许可以在逐步完成作业时观看。它非常简短,我尽量让它易于理解和直接。

以下是评估和改进UI的关键维度列表:

可见性

评估可见性时,可以问自己:其他功能、选项和控件有多明显?首次使用的用户是否需要猜测或点击随机的东西来弄清楚控件在哪里?

  • 重要控件置顶:不要让用户需要滚动才能找到重要控件。你应该将最重要的功能放在页面最顶部,可见且易于访问,就像Google Drive那样。
  • 视觉流:视觉流通常是从左到右,从上到下。虽然并非总是如此,但在设计时记住这一点是有好处的。
  • 按钮优于汉堡菜单:优先使用按钮而不是汉堡菜单,因为按钮通常有更好的可见性,因为它们有标签。但如果屏幕空间不允许,汉堡菜单也可以。
  • 响应式检查:确保检查不同的屏幕尺寸,确保控件不会消失或出现异常。
  • 当前位置指示:确保用户知道他们在哪个页面上。良好的可见性可能意味着粗大的标题,甚至是面包屑导航,以便于导航。
  • 识别而非回忆:这意味着向用户展示他们的选择,而不是让他们回忆或搜索某些东西。Canva就是一个很好的例子,它向用户展示了他们可能想要设计的建议,这使得过程变得容易得多。相反,UNSW的研究人员网站是一个反例,用户必须搜索研究人员姓名或主题。

可供性

可供性基本上围绕给我的用户多少提示或线索,以表明我的用户界面应该如何被使用。

评估时问自己:每个按钮的作用清楚吗?给出了哪些提示来建议控件应如何使用?如果使用了图标,它们是否直观或通用?

  • 链接样式:链接通常带有下划线,并且应与文本颜色不同,以便用户知道它们可以点击。惯例是使用蓝色。
  • 按钮反馈:按钮应该有轮廓,或者至少在悬停时改变颜色或样式,以表明它是可点击的。
  • 图标隐喻:图标通常匹配现实世界的物体、应用程序或事物的自然顺序。

反馈

良好的反馈有助于用户完成任务。

问自己:一旦我的用户完成了一个操作,他们如何知道系统已经确认了它?

  • 操作确认:当用户执行操作时,例如删除帖子或发送电子邮件,给他们一些反馈:弹窗、成功或失败消息。
  • 按钮状态:对于按钮或链接,在按下后改变其颜色。Instagram的点赞按钮就是一个例子。
  • 后台操作提示:如果系统在后台执行某项操作时间较长(通常大于1.5秒),向用户显示加载消息或旋转图标。形式不一定要完全一样,只要能向用户表明后台正在发生某事即可。

一致性

一致性很重要,这样你的网站才能感觉像一个连贯的产品。

问自己:不同的屏幕看起来一样吗?按钮、控件和卡片也是如此吗?你的色彩方案在整个应用程序中是否一致?

  • 反面教材:YouTube的探索页面是糟糕一致性的例子。在“学习”页面上,横幅看起来是这样;在“新闻”页面上,它是静态横幅;而在“时尚”页面上,不知为何它处于深色模式。
  • 字体建议:最好在整个应用程序中坚持使用一种字体,然后使用不同的字重或样式。如果需要,也可以使用两种字体,但只是一个建议。
  • 按钮变体:对于按钮,通常建议坚持两种不同的变体,例如主要和次要按钮,如Instagram所示。
  • 避免突兀变化:不要实施突兀的变化。

约束

实施约束有助于防止用户犯错。

可以问的问题是:我是否通过限制控件来限制用户可能犯的错误?用户是否能够访问他们不应该有权限的控件?

  • 输入验证:如果用户尚未输入任何内容,则禁用“继续注册”按钮。
  • 权限控制:如果用户未登录,不要显示只有注册用户才能访问的功能。
  • 减少错误空间:尽量减少出错的空间。

有效性与效用

这主要围绕用户是否拥有实现目标所需的工具。

问自己:我的用户能完成任务吗?他们能实现目标吗?确保用户可以访问他们需要的所有控件。

  • 工具示例:Adobe Photoshop的工具栏和Canva都提供了良好的效用示例,它们提供了用户进行编辑或创建文档可能需要的所有控件。
  • 理解用户目标:确保你的应用程序具有良好的有效性的一个好方法是理解每个检查点的用户目标,并确保该目标可以实现。
  • 正面案例:我认为“When2meet”平台在有效性和效用方面做得很好。它们有日历、时间,为用户提供了创建投票或活动所需的所有工具。
  • 导航栏可见性:导航栏应存在于所有页面上并可见。有一些例外情况,例如用户正在完成测验或测试,他们不应该能够退出,但这些情况非常罕见。

效率

效率与有效性不同,因为它围绕用户完成任务的速度。

可以问的问题是:我的用户能否以最少的步骤执行任务?我的用户能否保持高水平的生产力?

  • 减少步骤:尽可能限制步骤数量。你不想让你的应用使用起来很繁琐。完全删除任何不必要的步骤。
  • 进度指示:如果必须需要多个步骤,至少通过进度条或显示用户当前步骤的文本来向用户展示他们的进度。
  • 返回顶部按钮:实现效率的一个小方法是通过一个让用户返回顶部的按钮,特别是对于需要大量滚动的应用程序。Reddit在这方面做得很好。
  • 简化原则:基本上,对于效率,规则就是尽可能简化事情。

安全性

与约束类似,安全性对于防止错误非常重要。

问自己:我的用户是否受到保护,不会犯错?一旦发生错误,我是否帮助他们恢复?我是否帮助我的用户感觉他们在掌控之中,或者可以安全地探索我的界面?

  • 撤销操作:如果用户意外做了某事,确保他们能够撤销它。
  • 危险操作警告:在不可能撤销的情况下,则在系统完成该操作之前提供警告。例如,在文本编辑器中,如果我在没有保存工作的情况下关闭应用程序,它会显示一个关于保存文档的警告。对于潜在危险的操作,如删除、发布等,一定要这样做。
  • 按钮间距:另一个重要的技巧是不要将按钮放得太近,尤其是当它们功能相反时。在这里,你可以看到删除按钮离保存按钮非常远,因为如果它们靠得太近,用户很容易在想要点击另一个时意外点击这一个。
  • 错误处理:当用户确实犯错时,做两件事:第一,用通俗的语言解释错误;第二,建议解决方案。在Adobe Photoshop的错误提示中,顶部的错误解释得不好,它使用了很多技术术语,应尽量避免,并且没有建议解决方案。然而,底部的错误用通俗的语言解释了错误并提出了解决方案,这是一种更好的用户体验。

可学习性

可学习性很重要,因为你希望你的用户,尤其是首次使用的用户,能够理解如何使用你的应用程序。

可以问的问题是:我的应用程序直观吗?我是否提供了说明、教程或提示,特别是对首次使用的用户?

  • 输入占位符:我建议在输入框中使用占位符来演示用户应该输入什么。正如你在Canva中看到的,它显示了姓名、电子邮件和密码的示例。这使得界面更容易学习和掌握。
  • 复杂功能帮助:对于复杂的功能,我建议提供帮助页面或给用户一个教程。

通用标准与惯例

这与可学习性密切相关,因为应用程序越遵守惯例,人们就越容易上手,因为它更熟悉。

问:我的界面对用户来说是否有些熟悉?你是否遵循惯例?

  • 用户档案位置:一些惯例是用户个人资料控件通常位于右上角,正如你在亚马逊上看到的那样。
  • Logo链接首页:点击Logo通常会跳转到首页。
  • 图标隐喻:符号应匹配其现实世界的物体或应用程序。以下是一些常见的:放大镜与搜索栏一起使用;购物车或篮子通常意味着购买;心形或点赞符号;软盘意味着保存。还有更多,但时间有限。如果你好奇,一定要搜索一下。

兼顾所有用户

这与是否兼顾新用户和回头客有关。

可以问:我给新用户足够的支持或指导了吗?我是否拖慢了专家用户的速度?专家不再需要提示、教程、警告等,所以想办法关闭这些功能。

  • “不再显示”选项:通常是一个“不再显示”复选框。
  • 高级选项:决定哪些功能不是绝对必要的,可以移到“高级选项”中。这可以防止你的应用程序因过多的控件和功能而让新用户不知所措。
  • 平衡点:通常需要在可学习性和效率之间取得平衡:新用户学习如何使用你的应用程序,以及专家用户或回头客能够保持高水平的生产力。
  • 无障碍考量:我想快速补充一下关于无障碍性的考量。请考虑替代文本、易读选项、字幕和层级结构,以方便屏幕阅读器的使用。

美学与简约主义

我们之前看到,简约的应用程序通常更容易导航。

要评估你自己的用户界面,问自己:我是否只在页面上显示必要的信息或控件?我是否有不必要的杂乱?

  • 信息相关性:养成习惯问自己:这个信息现在相关吗?如果不相关,也许你可以把它移到更有用的页面。例如,“更改用户名”可以移到“设置”中。用户不需要在主页上看到“更改用户名”。
  • 平衡艺术:通常需要在简约主义和良好的可见性之间取得平衡。因此,对于每种情况,评估你认为哪一个更重要。
  • 视觉设计原则:最后,需要考虑的是对称、平衡、对比、比例和统一。这些是视觉设计原则,你可以进一步研究,它们有助于提高应用程序的美观性和整体性。

第三部分:进阶UI设计概念(可选)🚀

本节课程是可选的,如果你时间紧张,可以跳过。基本上,这些只是一些不错的补充,可以提升你的UI水平,增强设计感。它们不是超级重要,也非常简短,只是一个简单的介绍。如果你感兴趣,一定要进一步研究这些内容。

深色模式

深色模式是增强用户体验的一个很好的补充。它现在绝对是一种趋势。

  • 学习价值:如果你正在考虑进入前端开发领域,学习如何实现它是有好处的。
  • 设计考量:我认为它非常不言自明,所以不会深入细节,但绝对是值得思考的事情。

微交互

我喜欢微交互,我写过一篇相关的论文。

  • 定义:基本上,之前我们讨论了改进UI的更大方法。另一方面,微交互是增强用户体验的微小细节。
  • 表现形式:它们可能以小小的动画形式出现,我在这里添加了一些例子。
  • 研究建议:如果你对设计小细节感兴趣,我绝对建议你研究一下这个。

为情感而设计

为情感而设计也相当不言自明。

  • 建议方法:我建议搜索“理想的用户体验目标”,并在设计时牢记这些目标,这绝对会增强你的UI。
  • 缓解紧张情绪:为情感而设计的一种方法是缓解紧张情绪。这里有两个例子:谷歌的恐龙游戏和一个“Woolwars”的例子,其中L6出现了问题。你可以使用幽默和游戏化来缓解紧张情绪,特别是当用户遇到错误时。这就是为情感而设计的一个例子。

认知负荷

这涉及到用户的大脑在浏览和查看网站时需要处理多少信息。

  • 反面案例:之前我们看了耶鲁大学艺术学院的网站,它需要很高的认知负荷,因为我们的大脑试图处理这个页面上到底发生了什么。
  • 设计目标:这是你不想做的事情,这就是为什么简约主义是正确的方式,这样你的用户就可以节省他们的认知资源来完成他们的任务。


总结 📝

本节课中我们一起学习了用户界面设计的基础。我们首先通过分析LinkedIn、Canva、Quora、耶鲁大学艺术学院和UNSW网站等案例,从用户视角理解了优秀和糟糕UI设计的特点。接着,我们系统地学习了如何从可见性、可供性、反馈、一致性、约束、有效性、效率、安全性、可学习性、通用标准、兼顾所有用户以及美学等维度评估和改进自己的UI设计。最后,我们简要了解了一些可选的进阶概念,如深色模式、微交互、为情感而设计和认知负荷。希望这些知识能帮助你在课程作业和未来的前端开发中设计出更优秀的用户界面。

042:UI设计原则与实战优化 🎨

在本节课中,我们将学习构成良好用户界面的核心设计原则。我们将探讨如何通过视觉层次、色彩、对齐和分组等技巧,让界面不仅看起来美观,而且清晰易用。课程最后,我们将通过一个实战案例,应用这些原则来优化一个存在问题的界面。


视觉层次:引导用户的视线

上一节我们介绍了UI设计的重要性,本节中我们来看看如何通过视觉层次来组织信息。视觉层次的核心是让用户能够快速识别页面上最重要的元素。

以下是构建视觉层次的几个关键原则:

  1. 尺寸等于重要性:元素越大,在画面中就越占主导地位,用户就越容易注意到它,也显得越重要。公式可以表示为:视觉重要性 ∝ 元素尺寸
  2. 使用对比色:使用易于区分的颜色。如果你想强调某个元素,就使用能从周围颜色中“跳出来”的对比色。
  3. 利用对齐:你可以通过对齐或不对齐的方式来强调或突出某些内容。例如,当鼠标悬停在按钮上时让按钮上浮,就是一种利用对齐变化的强调方式。
  4. 邻近性原则:被组合在一起或彼此靠近的物体看起来是相关的。例如,卡片内的所有元素都被视为属于同一内容单元。

设计原则实例分析

现在,让我们看看这些原则在实际组件中的应用。以下是几个遵循了上述原则的卡片和按钮设计示例。

  • 在卡片设计中,标题文字更大、更粗且颜色与正文不同,这清晰地标识了它们的区别。
  • 我们使用了对比色和阴影(当悬停时)来强调卡片。
  • 按钮设计上,成功操作按钮倾向于使用绿色,危险操作按钮使用红色,这是一种普遍的颜色含义共识。
  • 一个受Airbnb启发的按钮使用了渐变色彩,使文字更具层次感和吸引力。

实战:分析YouTube主页

基于我们刚刚学到的原则,让我们分析YouTube主页的设计。

  • 尺寸与重要性:最重要的部分是视频缩略图,因为图像容易吸引眼球。视频标题也使用了加粗字体,与其他信息区分开。
  • 色彩对比:登录按钮使用了页面上几乎唯一的蓝色,使其非常突出。
  • 负空间运用:页面留有大量空白,使内容区域更聚焦。
  • 一致性:页面保持了统一的字体方案和极简的色彩搭配(黑、白、红,以及少量的蓝),避免因使用过多颜色而使用户感到超载或难以专注。

实战演练:优化一个“糟糕”的界面

理论需要实践来巩固。现在,让我们一起来优化一个存在问题的界面。我们将使用Figma这个原型设计工具进行快速修改。Figma允许你在编写代码前先绘制和构思界面,对学生非常友好。

首先,我们列出原始界面存在的问题:

  • 提交时间戳的字体大小和粗细与实验标题相同,暗示了同等的重要性,但实际上提交时间并不那么重要。
  • 顶部导航栏中,“成绩”标签的激活状态颜色与未激活标签(如“表格”、“讲义”)的颜色对比不够明显。
  • 按钮内的字体过小。
  • 内容区域的对齐和负空间使用不当,导致视觉上不协调,信息获取效率低。

现在,让我们开始逐步优化:

  1. 调整视觉层次:将提交时间戳的字体改小、改为常规粗细,降低其视觉重要性。
  2. 增强状态对比:修改导航栏激活状态的指示方式,例如使用更明显的颜色或添加下划线,使其与未激活状态清晰区分。
  3. 优化布局与对齐:将卡片内的元素(如标题、分数、按钮)在水平轴上左对齐,消除不必要的巨大空白区域,使信息更紧凑、易读。
  4. 区分按钮功能:让功能不同的按钮在视觉上有所区别。例如,将一个按钮设计为深色背景配浅色文字,另一个设计为浅色背景配深色文字和边框。
  5. 简化色彩:减少不必要的颜色使用,保持色彩方案的一致性。例如,将一些无特殊意义的颜色改为更中性的黑色或灰色。

经过这些调整,界面的清晰度和美观度得到了显著提升。


核心概念延伸:模式可供性与识别优于回忆

除了视觉层次,还有两个重要的UI设计概念:

  • 模式可供性:用户喜欢他们熟悉的东西。这就是为什么菜单常放在右上角,以及“汉堡包菜单”图标被广泛使用的原因。使用熟悉的模式(如标签页、主页图标、用户图标)可以降低用户的学习成本。
  • 识别优于回忆:让用户识别一个元素比让他们回忆该元素的功能要容易得多。一个典型的用户图标,即使没有文字,用户也能立刻明白它的作用。在设计时应尽量利用识别,而非强迫用户记忆。


本节课中我们一起学习了UI设计的核心原则,包括通过尺寸、色彩、对齐和分组建立视觉层次,以及模式可供性和识别优于回忆的重要性。我们通过分析YouTube主页和动手优化一个案例,实践了这些原则的应用。记住,设计是一个迭代和实验的过程,不断尝试和调整才能创造出既美观又实用的用户界面。

043:可感知性 🥕

在本节课中,我们将学习无障碍访问(Accessibility)的基础知识,特别是其四大原则中的“可感知性”。我们将了解为什么需要为所有用户提供可感知的内容,并学习如何通过提供文本替代方案和确保内容可区分性来实现这一目标。


大家好,我是Mike,我将为大家带来一个关于无障碍访问的四部分系列讲座。无障碍访问已成为业界日益关注的重要议题,我们期望所有前端工程师将其视为与代码可读性、性能和可测试性同等重要的首要任务。它应该被当作一等公民来对待。

那么,我想首先提出的问题是:什么是无障碍访问?我们为什么要关心它?本质上,每个人对生活方式都有不同的偏好,而无障碍访问就是关于如何适应所有人。并非每个人都会以你习惯的相同方式浏览网页。在使用台式机或笔记本电脑时,并非每个人都会使用鼠标。有些人可能只喜欢使用键盘,也并非每个人都会看屏幕。有些人可能希望使用盲文点显器来感知内容,或者他们可能希望使用一种称为屏幕阅读器的技术。这种技术会读取屏幕内容并将其呈现给用户,用户也可以与这些元素进行交互。这并非专门针对残障人士,而是关于不同的偏好。如果你希望构建一个对所有人都通用的网络,我们就必须满足所有这些不同的需求。

你可能会问,这听起来是个很大的命题,人们体验网络的方式确实有很多种。主要的国际标准被称为Web内容无障碍指南。Web内容无障碍指南是一个通用标准,它编纂了一系列可测试、可衡量的声明,称为“成功标准”。这些指南适用于包括移动应用在内的所有网络技术,并且是全球法律标准的基础。本课程将仅涵盖WCAG 2.0,但它已经更新并扩展为更高级的2.1版本。你可以查看链接,内容相当详细,本课程将对其进行解读。

WCAG有三个级别。A级是最低标准,它专门围绕某些可能对特定用户群体构成障碍的标准而设计。AA级是大多数公司力求达到的标准。AAA级则专门针对那些以残障人士为目标用户的网站。因此,我们将主要涵盖AA级,并在相关时提及AAA级。

WCAG有四大原则,这也将是我们四节课的主题,每个主题一讲。它们分别是:可感知、可操作、可理解和稳健。我们将逐一解读,首先从“可感知性”开始。


可感知性

为了使所有内容都可感知,组件必须以用户能够感知的方式呈现给用户。这是一个相当宽泛的声明。它包含几个不同的组成部分,其中最重要的部分之一是非文本内容

所有呈现给用户的非文本内容都应有一个服务于同等目的的文本替代方案。文本替代方案是实现信息可访问性的主要方式,因为它们可以通过任何感官模态(例如视觉、听觉或触觉)来呈现,以满足用户的需求。提供文本替代方案允许信息通过各种用户代理以多种方式呈现。例如,如果你觉得阅读内容很困难,你可能更希望有一个语音助手为你朗读;或者如果你看不见但更喜欢通过触觉感知内容,那么你可能希望将其转换为盲文上下文。而这一切,只有在有文本存在的情况下才能实现。

所有非文本内容包括但不限于图像、图标、视频、音频和图表。我们将从一个图像示例开始,并以此展开。

以这张图片为例,如果不看它,这张图片是无法被感知的。这为屏幕阅读器用户甚至只是低带宽用户带来了糟糕的用户体验。应该提供替代文本来改善这种情况。即使图像的源文件包含了它是什么,也不足以描述这种体验。

以下是优化替代文本的三个步骤:

  1. 描述图像。
  2. 根据上下文进行调整。
  3. 标记装饰性图像。

我们需要的是一个能最好地描述图像内容的句子或短语,而不是一两个词,这一点非常重要。

例如,我们可以这样描述:“一个晴朗的日子里,回望城市的沙滩景观。”这是一个非常好的替代文本,因为它捕捉了我们在这里看到的内容。这很好,但它并不总是最有用的替代文本。你并不总是希望在上传图像或拥有图像时就确定替代文本,你需要使其匹配上下文

如果它用在一篇关于悉尼旅游的文章中,也许更好的替代文本是:“悉尼世界著名的邦迪海滩,一个晴朗的下午。”这样做捕捉了它的位置,这在关于悉尼旅游的文章中特别有用,但在其他上下文(例如位置不明显时)可能就不那么合适了。你希望尽可能匹配看到图像的体验。

如果它只是用作装饰性的、无意义的横幅(例如,重复出现多次),那么空字符串是首选。这与没有替代文本不同,因为这将明确告诉任何试图捕捉此体验的软件:不用担心这张图片,这是一个装饰性的、无意义的横幅。

让我们再看一个例子。这里我们有一个软盘图标。我们并不真正知道它的用途。

请思考一下,什么会是更合适的替代文本。本质上,这取决于上下文。如果它是一系列图标列表中的一个,“软盘”可能是正确的。如果它是一个保存图标,你希望做的不是说“这是一个软盘”,而是说“这是一个保存图标”,或者直接说“保存”。如果你试图描述一个操作,最好将标签放在按钮上,并说明这是一个装饰性图像。我们将在未来的讲座中更详细地阐述按钮的标签要求。

作为练习,请思考一个简单的折线图。想想什么有意义的脚注信息对用户来说是相关的、你想要传达的。这里给你一个提示:理解一系列坐标会非常困难。相反,请思考折线图的趋势方向,想想你试图向用户传达什么信息或消息。


上一节我们介绍了非文本内容的文本替代方案。接下来,我们来看看可感知性的另一个重要部分:可区分性

我们已经介绍了非文本内容。对于存在的文本,还有另一个要求:它必须是清晰易读的

应使用文本而非文本图像。这相当直接,如果是文本图像,很多软件将无法真正理解其中的内容。页面还必须允许被放大到200%,并且不能导致内容或功能的丢失。许多无障碍目标的一个共同主题是:不要抗拒浏览器所做的事情,因为只要你不抗拒很多事情,浏览器在很大程度上能很好地处理无障碍访问。

可区分性中最常见、最重要的问题是文本与背景(包括图像)的颜色对比度。该对比度必须足够高。在实践中,这意味着:

  • 对于小于18磅的文本,对比度比率应为 4.5:1
  • 对于18磅或更大的文本,对比度比率应为 3:1

这个链接会带你到一个对比度检查器,你可以测试各种不同的颜色。

让我们看这个例子。这里,图像上有文字,在某种程度上,很难阅读。文字位于图像中视觉上相当强烈的部分。顺便说一下,当你比较文本和图像时,你通常希望选择图像中最差的部分,并根据对比度指南进行测试。在右侧,我们有文字和变暗的图像。在这里,阅读文字要容易得多。

并非每个人都希望调暗他们的图像,所以这需要一些设计或创意。你可以改变图像的对比度,可以调暗图像,可以改变文字颜色,也可以在文字周围添加一个框。你可能在电影字幕中看到过,文字通常带有轮廓,这同样有效。指南相当灵活,只要有一些方法能在背景上看到文字即可。


总结

本节课中,我们一起学习了无障碍访问的第一个原则:可感知性。这意味着内容必须能够被用户感知和区分。这既适用于文本内容,也适用于非文本内容。我们学习了如何为图像等非文本内容提供有意义的文本替代方案,以及如何通过确保足够的颜色对比度来使文本内容清晰易读。记住,构建一个对所有人都可访问的网络,始于确保每个人都能感知到其中的信息。

下次见。

044:可操作性 🎮

在本节课中,我们将学习网页可访问性的第二个核心原则:可操作性。可操作性意味着网页的组件和导航必须对所有用户都是可操作的,无论他们使用何种交互方式。

什么是可操作性?🤔

可操作性是指网页的组件和导航必须可以被操作。界面不能要求用户执行他们无法完成的交互。

并非所有与网页交互的用户都偏好使用鼠标或触摸设备。因此,确保所有页面交互都支持键盘操作至关重要。

键盘导航基础 ⌨️

许多键盘导航功能是浏览器默认提供的,只要你不覆盖它们即可。以下是主要的键盘导航方式:

  • 方向键、空格键、Page Up/Down:用于在页面上滚动。
  • Tab 和 Shift+Tab:用于在可交互元素(如表单输入框、按钮、链接)之间跳转。
  • Enter 和方向键:用于激活或与当前聚焦的元素交互。

具体到元素,这意味着:

  • 按钮 需要支持点击、触摸点击,以及 Enter空格键 的激活。
  • 链接 需要支持点击、触摸点击,以及 Enter 键的激活。
  • 下拉选择框 需要支持方向键来改变选中项,并支持 Enter 键确认选择。

正如之前所说,许多功能是默认提供的。关键是要确保你不覆盖浏览器的默认行为,并且在实现任何自定义组件时,也要实现相应的键盘支持。

避免键盘陷阱 🚫

最重要的原则之一是不要设置键盘陷阱。

如果你从页面顶部开始按 Tab 键,应该能依次访问到每一个可交互元素。在访问完最后一个元素后,焦点应该循环回到页面顶部。这意味着至少要为键盘用户显示每个元素的焦点轮廓线。

以下是一个需要警惕的例子:

element.addEventListener(‘keydown‘, (e) => {
    if (e.key === ‘Tab‘) {
        e.preventDefault(); // 这会阻止Tab键的默认行为,导致键盘陷阱
    }
});

这段代码的意图可能是为了实现某种特定的用户体验,但它无意中使键盘用户无法使用 Tab 键离开该元素。在开发时,如果你在为视觉用户设计特定交互,必须同时考虑键盘用户的体验。

使用正确的HTML元素 📝

使用标准的HTML元素通常能获得更好的可操作性和浏览器支持。

表单和表单元素应使用正确的标记。通常,使用标准的HTML元素(如 <button><input><textarea>)比使用 <div> 来模拟更好。

使用标准HTML标记不仅让浏览器更容易支持可操作性,也让人更容易理解。有时你可能确实需要使用 <div> 来自定义样式,但这将需要花费更多时间来手动添加所有必要的键盘事件处理和ARIA属性。

例如,原生的 <button> 样式可能不美观,但覆盖它的样式通常比为 <div> 重新实现所有功能更容易。

为元素添加适当的属性也很重要,这将在下一讲中详细展开。

以下是一个表单元素的例子:

<form onsubmit=“handleSubmit(event)“>
    <label for=“name“>姓名:</label>
    <input type=“text“ id=“name“ name=“name“>
    <button type=“submit“>提交</button>
</form>

注意,这里的按钮没有 onclick 函数,而是表单有一个 onsubmit 函数。这样做的好处是,当用户聚焦在输入框内时(无论是在手机设备上还是使用屏幕阅读器),他们可以直接提交表单,这对许多不习惯使用鼠标或触摸操作的用户非常友好。

业内一个普遍接受的例外是 <select> 元素。与按钮、输入框等不同,<select> 元素的CSS自定义能力非常有限。因此,直接使用 <select> 元素比用 <div> 模拟并手动添加所有键盘和标记支持要简单得多。

使用语义化HTML结构 🏗️

使用标题、区域、侧边栏和页脚等语义化标签对可操作性至关重要。

对于视觉用户,页面的版块和分类一目了然。但对于无法看到屏幕的用户,我们需要通过语义化标记来提供相同的信息结构,以在视觉用户和依赖标记、音频或触觉反馈的用户之间创造尽可能对等的体验。

  • <header>:表示页面顶部区域。屏幕阅读器用户可能希望跳过这部分常见内容。
  • <nav>:表示导航。如果有多个导航区域,需要使用 aria-label 等属性为它们添加标签,说明其作用。
  • <main><section>:表示页面的主要内容和各个部分。确保结构层次正确(例如,不要在 <section> 内嵌套 <main>)。<aside>(侧边栏)可以放在 <main> 之前,关键是拥有正确的标记。
  • <footer>:表示页脚。

跳过链接 ⏭️

对于重复出现的内容(如主导航),使用“跳过链接”让用户能够绕过它。

跳过链接是一种行业实践,对屏幕阅读器用户非常有用。它本质上是一段默认不可见、只有在获得焦点时才显示的文本或链接。注意,不能使用 display: nonevisibility: hidden 来隐藏它,因为这会将其从Tab键顺序中移除。正确的做法是使用CSS将其视觉上移出屏幕,但在获得焦点时使其可见。

<a href=“#main-content“ class=“skip-link“>跳转到主要内容</a>
...
<main id=“main-content“>
    <!-- 页面主要内容 -->
</main>
.skip-link {
    position: absolute;
    top: -40px;
    left: 0;
    background: #000;
    color: white;
    padding: 8px;
}
.skip-link:focus {
    top: 0;
}

跳过链接的常见用途包括:

  1. 跳过轮播图等可能造成键盘陷阱的复杂组件。
  2. 跳过页眉,直接跳转到 <main> 主要内容区域。
    通常,在页面底部也提供一个“返回顶部”的链接,以支持完整的键盘导航循环。

引入ARIA 🛠️

HTML的能力是有限的,它最初并非为复杂的Web应用程序而设计。为了弥补这一点,WAI-ARIA(无障碍富互联网应用)应运而生。

ARIA是一系列补充HTML属性的规范,并非旨在取代HTML。它应该谨慎且有意识地与HTML标签和角色结合使用,以适应现代Web应用程序的复杂交互。

以下是几个关键的ARIA用例:

1. 为纯图标按钮添加标签

<button aria-label=“显示菜单“>
    <img src=“hamburger-icon.png“ alt=““>
</button>

aria-label 为屏幕阅读器提供了按钮功能的描述,而不会影响视觉呈现。

2. 为自定义组件添加状态
对于没有原生HTML对应的自定义组件(如开关),可以使用ARIA状态属性。

<button role=“switch“ aria-checked=“false“ onclick=“toggleCookies(this)“>
    接受Cookie
</button>
function toggleCookies(button) {
    const isChecked = button.getAttribute(‘aria-checked‘) === ‘true‘;
    button.setAttribute(‘aria-checked‘, !isChecked);
    // 同时更新UI状态
}

aria-checked 属性应与UI的视觉状态同步。

3. 定义模态对话框
ARIA可以标记模态对话框,但实际的交互锁定仍需JavaScript实现。

<div role=“alertdialog“ aria-modal=“true“ aria-labelledby=“dialog-title“>
    <h2 id=“dialog-title“>需要登录</h2>
    <p>请登录以执行此操作。</p>
    <button onclick=“closeDialog()“>关闭</button>
</div>

role=“alertdialog“aria-modal=“true“ 告诉辅助技术这是一个需要立即注意的模态层。开发者仍需用JS阻止背景内容的交互。

4. 定义元素间控制关系
aria-controls 属性可以指明一个元素控制着另一个元素。

<button aria-controls=“expanded-menu“ aria-expanded=“false“ onclick=“toggleMenu()“>显示菜单</button>
<ul id=“expanded-menu“ role=“menu“ hidden>
    <li role=“menuitem“><a href=“#“>项目一</a></li>
</ul>

这指明了按钮控制着ID为 expanded-menu 的菜单。aria-expanded 表示菜单的展开/收起状态。

5. 使用 aria-haspopup
这个属性指示元素在激活时会弹出什么类型的内容。

<button aria-haspopup=“menu“>选项</button>
<button aria-haspopup=“dialog“>登录</button>

常见值有 “menu““listbox“(类似下拉选择)、“dialog“

构建自定义选择组件 🧩

结合以上ARIA属性,我们可以标记一个自定义的选择组件:

<!-- 触发按钮 -->
<button aria-controls=“custom-listbox“ aria-expanded=“false“ aria-haspopup=“listbox“>
    选择一项
</button>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/fba8e31008d96070cd2452447abb914d_7.png)

<!-- 下拉列表 -->
<ul id=“custom-listbox“
    role=“listbox“
    aria-label=“自定义选项“
    hidden>
    <li role=“option“ aria-selected=“false“ tabindex=“-1“>选项 A</li>
    <li role=“option“ aria-selected=“true“ tabindex=“0“>选项 B</li>
    <li role=“option“ aria-selected=“false“ tabindex=“-1“>选项 C</li>
</ul>

请注意,你仍然需要使用JavaScript来处理键盘交互(例如,用方向键选择选项,用 Enter 确认,用 Escape 关闭列表,并管理焦点在按钮和列表之间的移动)。

额外注意事项 ⚠️

最后,还有一些重要的通用注意事项:

  1. 避免引发癫痫的内容:不要设计已知会引发癫痫发作的内容,这包括快速闪烁的内容,尤其是红色内容。请遵循相关规范并运用常识。
  2. 提供充足的操作时间:除非绝对必要(如在线拍卖),否则应为用户提供足够的时间来完成任务。错误信息应在相关期间保持可见。工具提示在鼠标悬停于其上时也应保持可见,而不仅仅是悬停在触发元素上时。

总结 📚

本节课我们一起学习了可操作性的核心原则。关键要点是:不要轻易覆盖浏览器的默认可访问行为。如果必须实现自定义交互组件,则需要仔细添加键盘事件处理和ARIA属性。ARIA是一个强大的工具,可以很好地补充HTML,帮助我们构建对所有人都可操作的现代Web界面。

下次见!👋

045:可理解性 🧠

在本节课中,我们将学习无障碍设计原则中的“可理解性”。我们将探讨如何确保网站内容和用户界面的操作对所有用户都是清晰易懂的,包括如何设置语言、创建可预测的导航、设计易于理解的表单以及提供有效的错误提示。

上一节我们介绍了“可操作性”,它侧重于让所有操作在技术上可行。本节中我们来看看“可理解性”,它关注的是让所有内容和操作本身易于理解。信息和用户界面的操作必须清晰明了,不能超出用户的理解能力。


语言定义 🌐

可理解性的一个重要部分是明确定义页面语言。这对屏幕阅读器和语音辅助工具至关重要。

  • 你需要在 <html> 标签上设置语言属性。
  • 如果页面中某个特定部分的语言发生了变化,你可以在该元素上设置相同的语言标签。
  • 确保顶级元素(即 <html> 标签)设置了正确的语言属性,这对于元数据的正确传递和屏幕阅读器(它们通常会朗读页面标题)的正常工作非常重要。

这听起来是件小事,但它会产生巨大的影响,并且可能会出现在你的评估标准中。


可预测性 🔮

可理解性的另一个方面是确保操作是可预测的。这一点也相对容易满足。本节的一个核心主题就是避免做出不可访问的设计。

以下是保持可预测性的要点:

  • 不要在获得焦点(focus)或输入(input)时引发页面变化或表单提交。
  • 为了辅助无障碍工具,请保持标识的一致性。除非内容改变,否则不要更改底层的HTML结构。

需要澄清的是,如果是一个搜索栏,并且拥有所有正确的标记,表单提交是异步的(例如通过AJAX请求显示自动补全结果),只要标记正确且符合用户预期,这是可以接受的。核心原则是不要做任何过于“疯狂”的改动。

为了帮助低带宽用户,内容在不加载CSS的情况下也应尽可能清晰易懂。

这意味着:

  • 不要使用CSS来传达有意义的信息。
  • 不要使用CSS来重新排序内容。

你可以用CSS来定位元素,但不要用它来创建依赖于特定视觉位置才能理解的内容顺序。这一条通常需要你刻意去违反才能不满足其标准。


页面内与站点内导航 🧭

上一讲我们介绍了如何在页面内导航。在站点内导航也同样重要。我们需要知道自己在哪以及链接将指向何处。

这里的要点是简洁,并为每个页面创建独特的标题。这也有助于搜索引擎优化,因为站点预览器和屏幕阅读器处理信息的方式非常相似。

  • 页面标题:应以页面的独特性内容开头,例如本页关于“可理解性”,而不是以站点名“无障碍设计”开头。标题长度最好不超过60个字符。
  • 页面描述:应使用 <meta name="description" content="..."> 标签。描述应该是一个简短的段落,一两句话即可。一个好的启发式方法是思考在搜索引擎结果(如Google)被截断前,什么内容看起来合适并能传达要点,这也是语音助手用户希望一次性听到的信息量。

作为上一部分的延续,我们来谈谈链接的目的。

  • A级要求:链接文本在所在句子的上下文环境中应该是可理解的。
  • AAA级要求:链接文本本身(脱离上下文)就应该是可理解的(本课程不评估此级别,但实现起来相对容易)。

例如,链接文本“点击此处查看服务条款”在上下文中(A级)可能可行,但“服务条款”本身作为链接文本(AAA级)则更清晰。链接文本应说明其去向,而不必说“点击此处去...”。

此外,任何将用户导向其他位置(站内或站外)的交互项都应使用 <a> 链接标签,而不是创建一个带有点击事件跳转的按钮。当然也有例外,比如表单提交按钮会带你到不同页面,但这里特指那些本应是链接的元素。


使表单易于理解 📝

上一讲我们介绍了如何使表单可操作,本节将在此基础上探讨其可用性。

如何标注输入框?

这是标注输入框最理想的方式,支持度最高:

<label for="name-input">姓名:</label>
<input id="name-input" type="text">

通过 for 属性和 id 的关联,可以将标签与输入框连接起来。它们在视觉上应相邻且看起来相关。

另一种可接受的方式是将输入框包裹在 <label> 标签内:

<label>
  姓名:
  <input type="text">
</label>

但这种方式对于自定义组件(如自定义下拉选择框)可能存在一些问题,因为内部的文本会被当作标签处理,需要谨慎使用。

占位符文本不应被用作标签。 这种方式对屏幕阅读器的可用性很差。即使你同时使用了 <label>,屏幕阅读器也会先朗读标签,再朗读占位符,体验不佳。占位符应作为示例,而不是替代标签。通常,没有太大必要使用占位符。

有时,采用与第一种方式相反的结构很有用:

<label id="date-label">预约日期</label>
<input type="date" aria-labelledby="date-label">

这适用于两个或多个相关且相邻的输入元素需要共享同一个标签的情况,例如一组单选按钮附带一个自定义输入框。

如果你有一个带有不可见标签的输入框(这在搜索栏中很常见),请使用 aria-label

<input type="search" aria-label="搜索网站内容">

你也可以使用之前隐藏跳过链接的方法来隐藏一个传统的 <label>,但通常使用 aria-label 即可,它的支持度很好。

补充描述与错误提示

作为补充,你还可以为输入框添加描述。这不应替代标签,也不应过长。当有更多相关信息需要提供给用户时(例如字段需要特定格式,而HTML没有标准方法实现时),这很有用。

这里也是添加自定义表单错误提示的地方。如果你有错误提示(例如密码无效),你希望将错误提示附加到密码字段上。

以下是一个很好的例子,它使用了 aria-describedby

<label for="name">姓名:</label>
<input id="name" type="text" aria-describedby="name-desc">
<p id="name-desc" role="alert">请使用您的全名,这将打印在邀请函上。</p>

如果只是一个描述,不一定需要 role="alert"。但对于错误信息,以可访问的方式创建良好的提示非常重要。

附加属性

你可能熟悉 required(必填)和 type(输入类型)属性。它们肯定有助于提升无障碍性,因为错误可以被立即识别,而不是在最后才被发现。能够及早发现和识别错误非常重要。

你还应该使用 aria-invalid 来指示输入字段是否需要修正。这对于HTML默认验证之外的自定义错误验证特别有用。aria-invalid 应该只在验证失败后设置为 true,而不是默认设置。

如果可能,最好直接修正格式,而不是显示错误。无论是在用户体验还是无障碍性上,在提交表单时处理错误都不是一种愉快的体验。

以下是一些例子。对于空值或负数,如果已通过HTML属性(如 requiredminlength)定义,则无需使用 aria-invalid。但对于任何自定义验证,它非常有用:

<label for="booking-date">预订日期:</label>
<input id="booking-date" type="date" aria-describedby="date-error" aria-invalid="true">
<p id="date-error" role="alert">预订仅在工作日接受。</p>

这是一种非常优雅的显示错误、指示无效并加以描述的方式,仅靠默认的HTML属性很难实现这种效果。


错误提示与实时区域公告 🔊

我已经多次提到错误提示,这里我想再详细解释一下其可理解性。

错误提示信息应该简洁且具体。说“开始日期无效”不如说“开始日期必须是星期一”有帮助。信息应简短,因为长信息很烦人,并且对屏幕阅读器用户来说很难跟上。如果他们看不到信息或无法重读,实际上会带来很大的认知负担。

从技术上讲,错误信息应该具有 role="alert" 以表示其重要性,并且通常会将焦点设置到第一个无效的元素上。添加 alert 角色会打断屏幕阅读器的当前朗读,以向用户提示错误。

其他实时区域角色

我提到了使用 alert 角色进行错误提示,这里再介绍几个其他角色:

  • role="progressbar":适用于文件上传或下载进度。它会播报进度增量,屏幕阅读器用户可以设置只听取开始、结束或卡顿时的通知。
  • role="status":用于全局状态更新,例如购物车商品数量更新、购买成功提示。这对于屏幕阅读器或语音助手用户跟踪状态变化是很好的体验。
  • role="log":用于聊天消息的出现。
  • role="timer":用于倒计时或秒表读数。类似于进度条,屏幕阅读器用户可以更改其播报频率。

默认情况下,如果文本更新,屏幕阅读器只会播报发生变化的部分。如果你希望重新播报整个文本(这对计时器最有用),请使用 aria-atomic="true"

另一个例子是邮件客户端的新邮件通知,这是一种“礼貌的”公告(role="status"),它不会打断用户当前的操作,而是在当前朗读结束后才播报“新邮件来自...”。

如果你需要发布公告,但没有合适的角色可用,请使用 aria-livearia-live 可以添加到大多数元素上,其值可以是 polite(礼貌的)或 assertive(强制的)。不过,如果你试图使用 assertive,可能更想要的是 alert 角色。几乎总是希望使用 politearia-live 的紧急程度可以从角色中推断出来。

你应该谨慎使用 aria-live 和上述公告角色,因为它们本质上是一种打断性的体验。当然,在确实需要通知用户重要事件时(例如在线拍卖中物品已被买走或计时器出错),有充分的理由使用它们。

这里有一个需要注意的浏览器怪癖:同时添加 aria-live 和一个公告角色(如 role="status")可能会导致同一条消息被播报两次。在这种情况下,最好只使用角色。这是一个不太理想的浏览器体验。

以下是一个使用 aria-live="polite" 的例子(也可以认为这是 role="status" 的用例):

<div aria-live="polite">
  本网站将在30分钟后进行例行维护,请保存您的数据。
</div>

这对于视觉用户来说可能很明显(因为消息会以醒目的颜色显示),但对于非视觉用户,这样的公告能清晰地说明正在发生什么。


为全屏媒体用户提供同等体验 🎬

你需要为使用全屏媒体(如视频)的用户提供同等的可理解性体验,确保所有控制和信息对他们也是可访问的。


本节课中,我们一起学习了“可理解性”的核心原则,包括定义语言、确保操作可预测、设计清晰的导航和表单,以及提供有效的错误提示与实时公告。这些实践能确保所有用户都能轻松理解并与你的网站内容互动。下一讲,我们将探讨无障碍设计的最后一个原则——“健壮性”,并总结整个无障碍设计系列。

046:可访问性(四)🥕 健壮性与总结

在本节课中,我们将学习可访问性系列的最后一部分,主要聚焦于“健壮性”原则,并对整个系列进行总结。健壮性要求我们的内容必须足够可靠,以便能被包括辅助技术在内的各种用户代理正确解读。


健壮性原则

上一节我们讨论了可感知性和可操作性。本节中,我们来看看可访问性的第四个核心原则:健壮性。

内容必须足够健壮,以便能被广泛的用户代理(包括辅助技术)可靠地解读。这听起来很直接,核心就是遵循标准。尽管像Chrome、Safari或Firefox这样的浏览器可能会比较“宽容”,尽力去解析不完美的HTML、CSS或JS,但我们必须格外小心。因为有些用户代理(如屏幕阅读器)需要解析页面的更多信息,拥有准确的内容对它们帮助巨大。

基础HTML要求

健壮性的基础是HTML必须有效。现代JS框架通常会自动处理很多这类问题,因此我们不会在此花费太多时间。以下是需要确保的几个关键点:

  • 标签和属性格式必须正确:例如,标签必须有正确的开始和结束,属性值必须用引号包裹。格式错误的HTML会破坏标签的嵌套结构。
  • 必须定义文档类型(Doctype):例如 <!DOCTYPE html>。这告诉浏览器页面使用的是HTML5标准。JS框架通常不处理这个,因为它需要在任何框架代码之前定义。
  • 避免重复的ID:页面中每个元素的 id 属性必须是唯一的。重复的ID会导致辅助技术混淆。在使用组件时,需要确保为动态生成的ID添加前缀以保证唯一性。
  • 引用的ID必须存在:在 aria-labelledbyfor 属性中引用的ID,必须在页面中存在。例如,一个只在悬停时出现的工具提示,如果其标签指向一个可能被卸载的元素,就会出问题。在这种情况下,考虑使用 aria-label 直接提供文本。
  • 属性必须用在正确的标签上:不要将只属于特定元素的属性(如 value 用于 div)用在错误的标签上。JS框架允许创建抽象,但最终渲染的HTML必须正确。

交互元素的角色

对于交互元素,有一个特别重要的要求。

任何交互元素都必须要么具有明确的 role(角色),要么使用本身就暗示了角色的特定标签(如 button, input, a)。

  • 只要你使用 buttoninputa 标签,这几乎不会成为问题。
  • 但如果你定义自己的自定义交互元素(例如,一个带有 onClick 事件的 div),必须为其添加一个合适的 role,例如 role=”button”
  • 你添加的任何 role 都必须符合该元素的用户体验目的。除非确实是一个纯展示性的按钮,否则不要覆盖原生按钮的角色(例如,不要给 button 标签设置 role=”none”)。

总的来说,如前面章节所述,尽量避免使用 div 来制作交互控件,这不仅有助于获得更好的键盘处理支持,明确角色定义也同样关键。

以上就是健壮性的核心内容。在学习了前三个部分之后,理解健壮性应该相当直接。


额外内容与工具

虽然健壮性部分已经讲完,但我想花点时间介绍一些额外内容。这些不会纳入考核,但对于你们未来进入职场非常重要。

使用可访问性工具

首先,是使用可访问性工具。这能极大地帮助你理解如何创建良好的标签、避免信息重复等。使用这些工具应该被视为与用户测试同等重要的一环。

以下是几个主流且免费的工具推荐:

  • Apple 系统 (macOS / iOS):内置 VoiceOver 屏幕阅读器。它可能是最直观的工具之一,信息呈现方式做得很好。
  • Chrome 浏览器 (跨平台):内置 Chrome 屏幕阅读器。虽然不如VoiceOver应用广泛,但对于基础测试仍有帮助。
  • Android 系统:内置 TalkBack 屏幕阅读器。

除了屏幕阅读器,还有其他有用的工具,例如检查颜色对比度的工具,以及可以模拟不同色盲类型的Chrome插件。务必使用你正在为之构建产品的工具进行测试。

其他可访问性主题

接下来,我们快速浏览几个其他重要的可访问性主题。

  • 减少动效 (Reduced Motion):之前在关于癫痫的部分提到过,我们应避免过多运动。CSS提供了 @media (prefers-reduced-motion: reduce) 查询,允许我们根据用户的系统设置来减少或移除非必要的动画。请注意,这是“减少动效”,而非“减少所有动画”,淡入淡出等柔和动画对可访问性仍有好处。
  • 深色模式 (Dark Mode):深色模式是现代趋势,也能提升可访问性(例如在暗光环境下减轻眼疲劳),但也可能降低可访问性(如果颜色对比度处理不当)。在设计深色模式时,仍需警惕颜色对比度,并谨慎处理非黑白的颜色。
  • 音频描述 (Audio Descriptions):这与字幕类似,但针对视频内容。它为视障用户提供额外的音轨,在视频对话间隙描述屏幕上发生的视觉内容、角色动作等。这能极大地提升参与感,许多迪士尼、皮克斯和漫威电影都提供了此功能。


总结

好了,这就涵盖了可访问性的基础知识。当然,还有更多知识可以学习,我们只是浅尝辄止。但这将为你提供一个良好的框架,帮助你理解如何以开发者的身份思考和考量可访问性。

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

  1. 健壮性原则,包括编写有效HTML、确保ID唯一性、为交互元素正确分配角色。
  2. 使用可访问性工具(如屏幕阅读器)进行测试的重要性。
  3. 几个额外的可访问性主题:减少动效深色模式音频描述

希望这个系列能帮助你构建对所有人都更加友好的网络体验。

047:Git - 团队协作 🧑‍🤝‍🧑

在本节课中,我们将要学习 Git 在团队环境中的核心用法。我们将重点介绍分支、合并以及合并请求的概念,这些都是多人协作开发中至关重要的工具。通过理解这些概念,你将能够与团队成员高效地协同工作,管理代码的不同版本。

上一节我们介绍了 Git 的基本概念,如提交、推送和拉取。本节中我们来看看如何在团队中实际运用这些知识。

Git 的树状结构 🌳

在上一讲中,我们提到 Git 本质上是一系列提交的历史记录链。然而,在实际的日常使用中,Git 的结构更像一棵树,而不仅仅是一条直线。虽然提交链是线性的,但它可以像真正的树一样分叉。

每个提交总是有一个父提交,就像树一样,一个分支可能衍生出几个不同的子分支,但它始终有一个起源。这一点非常重要。同时,每个提交可以有多个子提交。每个提交都只来自另一个提交,但每个提交可能会朝着几个不同的提交方向前进。

每次我们朝着多个提交方向前进,我们称之为分支。而每次我们试图将这些分支重新合并到一起,我们称之为合并。在许多方面,我们使用 Git 的本质实际上是尽量避免这种分支结构。世界上最完美的 Git 仓库实际上看起来就像我们在第一讲中看到的那条链,它是一个线性的历史。

理解分支 🌿

我们尚未详细讨论 Git 的分支概念。如果我们回顾上一讲中查看的仓库,你会注意到当我输入 git status 时,这里有一个名为 master 的分支。master 本质上是仓库中的主分支,是主要的分支,你可以把它看作是负责的、主要的分支。

在 GitLab 上,你会看到如果我去到分支页面,目前只有一个分支,即 master,我的文件都在上面。为了简化,让我们想象一下,到目前为止我们所有的提交都是线性的,我的 master 分支看起来就是这样。

现在,我可以使用这个命令:git checkout -bcheckout 的意思是检出另一个分支,去查看一个不同的版本。而 -b 参数意味着创建一个新分支。Git 的语法有点奇怪,但这意味着创建一个新分支。

如果你不知道我所说的分支是什么意思,并且感到有些困惑,可以把它们想象成平行宇宙。就像电影《奇异博士2》里的多重宇宙一样,这些平行宇宙同时运行。所以,我在这里有我的 master 分支,当我输入这个命令并创建一个名为 brandon 的新分支时,我实际上是在移动到另一个名为 brandon 的分支。

你会注意到,如果我输入 git status,文本中现在显示在 brandon 分支上。然而,在 GitLab 上,你注意到我仍然只有一个分支。这是因为和 Git 的其他操作一样,我还没有实际推送任何东西到 GitLab。所以,我处在这个新分支上。你会看到,如果我执行 git push,我实际上会收到一个奇怪的错误。

这个错误本质上是在说:嘿,我明白你想推送,但你告诉我你在一个我从未见过的分支上。它基本上是说 GitLab 不知道这个分支存在。所以,你实际上需要复制并粘贴这个命令一次。这是你第一次推送时必须做的,之后你就可以继续推送了。因为这是一个新分支,第一次推送必须使用那个命令。

现在,你注意到当我在 GitLab 上查看时,我现在有两个分支,就像代码的两个平行宇宙。如果我回到我的文件这里,我可以看到这是我的 master 分支,里面有我的食物列表,然后我还有我的 brandon 分支,就像另一个平行宇宙。

然后我想,好吧,我实际上想对我的 brandon 分支做一些更改。所以我去到 names.txt 文件,删除所有三个名字,写上 Doctor Strange。和之前类似,我将执行 git add names.txtgit commit -m "Doctor Strange something"。然后我将推送它,这会推送到 brandon 分支。

现在你注意到在 GitLab 上,如果我在 brandon 分支上点击 names.txt,它显示 Doctor Strange,但如果我切换到 master 分支,它显示原始内容。因为 Git 实际发生的情况是,我在这里创建了一个分支。现在,brandon 分支指向那个提交,即 Doctor Strange 提交。

类似地,如果我愿意,我可以用 Git 命令 git checkout 来切换分支。我不再需要 -b 参数,因为分支已经创建了,所以我可以回到 master。这就像我在这些平行宇宙之间切换。然后如果我打开 names.txt,你会看到在我的 master 分支上,我有所有原始的名字,因为 master 分支目前指向这个提交。

如果我检出 brandon 分支并查看 names.txt,它是不同的代码。你一次只能查看一个分支,这一点非常重要。Git 命令行的工作方式是你一次只能查看一个分支。你可以在它们之间切换,但你必须一次查看一个。

我可以回到 master 分支,我也可以修改 names.txt,比如改成 Scarlet Witch。我可以在这个分支上提交,然后推送到 master 分支。现在你在 GitLab 上会看到,如果我在 master 分支上,它显示 Scarlet Witch,如果我在 brandon 分支上,它显示 Doctor Strange。如果我查看提交历史,它们都有一个共同的历史记录,即这个合并提交,但它们各自有不同的下一个提交。你可以在这里看到 Git 的历史记录正在发生。

分支的本质 🎯

对于分支和 Git,这在概念上非常奇怪。说实话,我花了好几年才自然理解,因为大学里很多人没教过这个。但分支实际上只是指向某个提交的指针。有两种方式来看待这个话题。

你可以用一种非常具体的方式来处理,我喜欢这样,因为我有时脑子转不过来,我喜欢把它们想象成完全独立的平行宇宙。就像我说的,好吧,我的 brandon 分支在这里,我的 master 分支在那里,它们完全不同。

还有一种更学术、更字面、更理论化的看待方式,即分支本质上只是指向特定提交的指针。每个提交都有一个链,因为记住我们之前说过每个提交只有一个父提交。这意味着如果你能指向一个提交,你就可以获得整个线性历史,因为每个提交之前只有一个提交。这就是 Git 提供历史的方式,因为这是一个线性历史。

所以,即使 master 指向这里,但线性历史在那里,brandon 指向那里,但线性历史也在那里。这就是关于分支的一些背景知识。

分支的实际用途 🛠️

现在,回答 Meriddian 的问题:拥有多个分支有哪些不同的用途?有一个特别的用途是,当你编码时,如果你在一个团队中工作。假设你有一个稳定的代码仓库,代码可以运行,你和五个人一起工作,每个人都在做不同的事情。

你不想所有人都推送到同一个 master 分支。否则,你们将不断地拉取代码、遇到合并冲突等等。你希望能够像在独立的平行宇宙中完成所有工作,当完成后,再把它们带回代码的主宇宙。

一个好的例子是,假设我们有我们的 master 分支,暂时忘记 brandon。我在 master 上。现在,本地的人和 CSE 上的人将分别去开发一个功能。当我提到功能时,我指的是代码,他们将要添加一些东西,这非常简单,因为我们课程还没有做任何编码。

本地的人将创建一个新分支,命名为 hayden/drinks-list,他们要添加的功能是一个饮料列表。同时,在同一个晚上,CSE 上会创建另一个名为 kai/cars-list 的分支,Kai 要去做一些关于汽车的事情。

所以,Hayden 在这里处理饮料列表。我创建一个名为 drinks.txt 的新文件,添加 Coke。我执行 git add drinks.txt,你会看到我在 hayden/drinks-list 分支上,然后我做了更改,我将推送它。但因为是第一次推送,我必须使用特定的命令。你会看到我推送了我的饮料列表,我在 GitLab 上有了一个带有饮料列表的新分支。

看起来文件没有出现?哦,我忘记提交了。所以发生的情况是,我实际上创建了一个新分支,但没有做任何提交,所以当我推送时,它有点奇怪。实际发生的情况是,假设你在 master 上,我创建了一个名为 hayden/drinks-list 的新分支。当你创建一个新分支时,你实际上只是指向完全相同的提交,然后一旦我说做一个提交,它就像指向那个提交。因为我推送时没有提交,hayden/drinks-list 实际上指向完全相同的提交。

你可以在这里看到,因为如果我查看提交历史,hayden/drinks-listmaster 有完全相同的历史,它们是相同的历史。所以现在我要添加 drinks.txt,我已经添加了,我忘记提交了。提交信息为 First drink,然后我推送。现在,master 上没有新的提交,但 drinks-list 分支上有一个新提交,因为我做了另一个提交。

让我们再做一些提交,只是为了保持这个比喻。打开 drinks.txt 再添加几个,比如 PepsiFanta。执行 git add drinks.txtgit commit -m "Two more drinks",然后 git push。然后我想进一步编辑它,再添加一个,比如 Water。执行 git add drinks.txtgit commit -m "Forgot to add water",然后 git push

所以现在我这里有三个提交,三个新的提交,分别是 First drinkTwo more drinksForgot to add water。这一切都发生在这里的一个平行宇宙中。

但与此同时,CSE 机器上的某人可能正在处理另一个功能,比如 Kai 的汽车列表,Kai 可能会创建一个名为 cars.txt 的新文件,里面有 HondaToyota。然后 Kai 会添加文件,提交信息为 Added first two cars,然后推送,但这是我们第一次推送这个分支,所以我们需要复制那个命令并粘贴。我们推送上去。

现在你会看到,master 上有 Scarlet Witch added 提交,hayden/drinks-list 分支在那里有一个提交,而我的另一个分支,即 Kai 的 cars-list 分支,在那里有一个提交。所以 Kai 和我已经分别去做了工作。我在这里工作,Kai 在那里工作,这很好,因为我们的工作理论上仍在进行中,我们还没有完成,所以我们还不想把所有这些东西添加到代码库中,因为我们还不知道代码是否有效。

但一旦我们满意它能够工作,感觉良好,下一步就是我们第一次使用 GitLab 的 UI。

合并请求 🤝

因为你会看到我这里有了这些分支:hayden/drinks-list。现在假设我完成了我的饮料列表,我写的代码完成了,可以合并了,我想把它合并到 master 中,带回到 master

所以我可以做的是,我可以点击这个大的“合并请求”按钮。我现在要使用 GitLab UI。我点击那个合并请求按钮。合并请求是什么?合并请求是一种基于 Git 的方式,用于将一个分支合并到另一个分支。这是一种尝试将这些分支重新合并在一起、消除我的分支的方法,因为我只希望分支在我实际需要完成某件事时存在。

这是我的标题“Drinks list”。我可能称之为“Addition of drinks list”。然后我写描述,比如“Added a drinks list with four drinks that were popular”。这是一个好的开始。然后我下来这里,点击“创建合并请求”。

现在,这实际上在这里创建了一个可视化窗口。合并请求本质上是一个请求,请求将代码合并到 master 中。它非常字面化,就像请求将我的分支合并到 master 中。这是它的标题。你可以看到所做的提交,你可以看到更改的摘要。这是 master 和我添加的工作之间的差异。

我们实际上有这些合并请求的原因,不一定是为了你或 Hayden,因为 Hayden 写了代码。它是为了 Kai、Angus、Ran,我团队中的其他五个人。通常发生的情况是,我做了这样的工作,然后我会说,好了,然后我会在 Microsoft Teams 上把这个链接发给 Kai 或 Angus。

如果 Kai 在线,我可以把 Kai 添加到合并请求中。你不需要做我在这里做的事情,因为我们会为你设置好。但假设我和 Kai 在一个团队中工作,我们一起处理这个小代码,你会有很多人,你会在一个五人小组中。

你会看到这里,Kai 可能会过来给个赞,所以 Kai 实际上给了一个赞,Kai 和我团队合作的方式取决于我们,比如 Kai 可能会点击批准按钮或点赞按钮,或者 Kai 可能会说“对我来说看起来不错”。本质上,当你写代码时,一个好的实践是确保在你将代码合并到 master 之前,团队中的其他人说没问题。这就像一种简单的理智检查批准。

所以 Kai 在这里给个赞,就像说,是的,这没问题。然后我可以做的是,我可以点击“合并”。当我点击合并时,Git 界面会字面上接受这个,并将其合并到 master 中。因为记住,master 上次是在 Scarlet Witch。所以现在 master 变成了这样。

这就像我们把这两个平行宇宙合并在一起。所以,与其让这两件事分开发生,它们现在一起发生了,回到了 master 中。

从技术上讲,实际发生的情况是,当我合并到 master 时,我技术上只是改变了 master 指向的位置,因为记住分支只是指向提交的指针。所以现在 master 是整个东西,如果你回到 Git 并查看提交历史,切换到 master,你实际上会看到所有那些其他提交都在那里。

命令行合并与合并请求 ⚙️

有人问,我们能否通过命令行进行合并请求?你可以通过命令行合并,我来展示一下。假设 Kai 在这里有他的更改,Kai 有他对代码库的更改,即添加汽车,Kai 想做的就是现在将其合并到 master 中。

实际上有一个 Git 命令叫做 mergemerge 的作用是合并一个分支到你当前所在的分支。合并是一种拉取方法,是拉取到那个分支的请求。

这意味着,如果我在这里,假设 Kai 想合并到 master。所以 Kai 切换到 master 分支,拉取以确保 Kai 拥有所有最新的更改,现在 Kai 做了。现在 Kai 想做的就是将他的分支合并到 master 中,Kai 只需输入 git merge kai/cars-list。这将执行与合并请求完全相同的操作,将 Kai 分支的更改拉取到 master 中。

现在的问题是,本地 Kai 有了更新的 master 版本,Kai 需要将其推送到 GitLab,因为如果你看一下 Kai 目前在 master 上有什么,再看看 GitLab 上实际的 master,你会看到在 GitLab 的 master 上只有 Forgot to add water 和这个合并提交,而我们没有 Kai 的 Added first two cars 和这个合并提交。为什么这些顺序不对是一个非常复杂的问题,你不需要在第一天就理解。

但本质上,Kai 有他的提交在这里,现在他有了他的提交,我们推送到 GitLab,你会看到那些提交现在会出现。蓝色的提交是我的 CSE 账户,紫色的提交是我的本地账户。

那么,如果你可以在命令行合并,为什么我们还要使用合并请求呢?原因很简单,因为合并请求是一种非常协作的体验,它是一个让人们以可视化方式评论或审查你的代码、批准你的代码的机会,而这些功能非常适合 GUI,所以我们真的不在命令行进行合并。

不过,有一个地方你会在命令行进行合并,这很重要。假设 Hayden 现在要去开发另一个功能。Hayden 在 master 上,我执行 git status 来获取我的分支。我在错误的分支上,我要检出 master,拉取 master 以确保我有最新的更改。我说我要去开发另一个功能,那个新功能可能是 hayden/dogs

然后我创建一个名为 dogs.txt 的新文件,里面可能有 huskygreyhound。我正在处理那个。同时,Kai 在这里说我要去开发一个竞争性的功能,叫做 cats。我不知道猫有哪些品种,但假设 Kai 在创建另一个名为 cats.txt 的文件,Kai 可能在编辑文件,不一定是新文件,里面可能有 loud catquiet catsleepy cat

Kai 正在处理那个,Kai 也在为那个做一些提交。添加三只猫,命名得不太好。所以 Kai 在那里做那个,Hayden 在这里添加狗。另一个提交,命名为 2 awesome dog breeds。我们这里有这两件事在发生。

如果我尝试推送我的新分支,它不起作用,因为这是我第一次推送我的分支。如果 Kai 尝试推送他的分支,这是他第一次推送他的分支,所以也不起作用。所以 Kai 必须使用特定的命令推送。

顺便说一下,我们时间控制得还不错。我们会稍微超时一点,但不会太多,也许10或15分钟。我们会尽力完成。所以 Kai 在这里推送,现在一切都好。master 稳定地在那里。我在这里分出去了,Kai 在那里分出去了。

但有时会发生一件事,就是我们可能处理同一个文件或其他东西。例如,现在我开始再次编辑 names.txt,我创建了一个新名字叫 Blinky Bill。然后我添加 names.txt,提交信息为 Adding a cool name,然后我推送上去。

同时,Kai 在这里,Kai 说,实际上我想添加一个名字到 names.txt,那个名字可能是 Donald Duck。所以我们最终处理了相同的文件,这有时会发生,有时你处理重要的文件,你们都修改了相同的部分。提交信息为 Doc added

现在会发生什么?也许我会过来,我的代码完成了。所以我可以去保存我的分支。我可以去我的分支页面,点击我的 dogs 分支,创建一个新的合并请求,写一些文本,点击创建合并请求,然后有人会批准它。我不知道,也许 Kai 很快会批准。

在 Kai 批准的时候,有人之前问了一个好问题,Brandon 说我们应该勾选“删除源分支”选项吗?简短的回答是我们不介意。是否勾选它取决于你。所以,Kai 刚刚批准了,你实际上看到这里写着“Approved by Kai”,如果我把鼠标放上去,会显示 Kai 的名字。所以 Kai 实际上批准了它,现在我要点击合并。所以现在它合并到 master 中了。

但有一件事是,Kai 还没有完成他的 cats 分支。所以他要做的是,出于良好的习惯,不断地获取最新的 master 并确保它合并到他的代码中。所以 Kai 要在 CSE 机器上检出 master,拉取 master,然后 Kai 实际上要回到他的 cats 分支,他将把 master 合并到他的分支中。他不是试图将他完成的工作合并到 master,而是试图让他未完成的工作与 master 保持同步。

这种合并会把东西合并进来。然后你可以在这里看到,Kai 已经显示 names.txt 有冲突,这是有道理的,因为 Hayden 在他的本地机器上过来修改了 names.txt 文件以包含 Blinky Bill,而 Kai 以类似的方式修改它以添加 Donald Duck

所以当我尝试合并东西后打开 names.txt 文件时,有这个冲突,我想,哦,Hayden 有 Blinky Bill,好吧,这很有趣。然后 Kai 手动处理那个冲突,保存它,然后 Kai 会添加两个名字,dogs.txt 已经暂存了,然后 Kai 可能会再做一次提交,命名为 Integrated names

然后 Kai 可能会推送到他的 kai/cats 分支。Kai 会继续处理一段时间,Kai 可能会再添加一只猫,例如 Stinky cat。所以 Kai 在做更多的工作,然后 Kai 想,好了,随着最后一只猫 Stinky cat 的添加,Kai 可以去创建一个合并请求。实际上,我想知道 Kai 是否在那里,如果你想创建一个的话。所以我不会创建这个。Kai 可能就在那里创建。

在 GitLab 上有一个部分叫做“合并请求”,如果你点击它,它实际上会显示所有存在的合并请求。所以当你的团队成员创建合并请求时,你可以在这里看到它们。通常,如果你创建了一个合并请求,你通常会告诉你的团队你已经创建了一个,以便他们知道,他们可以去看。

我不知道 Kai 是否已经创建了,也许我搞错了。是的,所以 Zach(我不知道怎么念你的名字),Kai 已经创建了这个合并请求。我过去看,嗯,好吧。我可以看到那里,Kai 添加了 Donald Duckloud catquiet catsleepy cat。好吧,然后我看看,我想,哦,都很好。然后我可以点击批准,然后 Kai 会去合并它。

写代码的人合并代码,这一点非常重要。原因是,理论上,理解代码、理解代码何时完成、理解代码何时安全的人是写代码的人。所以一般来说,写代码的人是合并代码的人。你不能批准自己的代码,其他人也不应该合并你的代码。

我不知道 Kai 是否合并了,没关系。Zachara 问了一个好问题:如果你没有将 master 合并到分支中,而是直接做了一个有冲突行的合并请求,会怎样?这是一个好问题。

让我展示一下那是什么意思。让我们想象一下,完全独立于这个,出于某种原因,Hayden 是个坏人,直接进入 names.txt 并添加了另一个行,比如 Silly Sam。然后 Hayden,坏 Hayden,直接推送到 master。提交信息为 Out of a silly name,推送到 master

现在,我们会有问题,因为如果你查看 master 上的 names.txt 文件,里面有 Scarlet WitchSilly SamBlinky Bill。如果你查看 Kai 的合并请求,Kai 试图在这里这两行之间合并 Donald Duck

我不确定为什么没有更新。哦,是的,它在这里告诉你:合并被阻止,必须解决合并冲突。所以有时如果你有一个 GitLab 无法解决的合并问题,即通常需要手动干预,你可以在本地解决,就像我们之前做的那样,我可以拉取 master 并将 master 合并到我的分支中,或者如果我是 Kai,我可以点击这个“解决冲突”按钮。

我可以选择这两个中的一个。在这里解决冲突很棘手的原因是,在 GitLab 上解决冲突通常很麻烦。你实际上可以在 GitLab 上编辑它,如果你想的话,所以我实际上可以在 GitLab 上编辑它,就这样更改代码,然后我可以点击“提交到源分支”。所以 Kai 去修复了这个,然后 Kai 想,好了,现在可以工作了。然后我想象我是 Kai,我可以去合并那个。好了,合并了。

总结 📝

无论如何,这就是今晚我们讨论的一切。我们讨论了 Git 的提交、推送和拉取,讨论了这些如何存在于多台机器上以及这些机器如何保持同步。然后我们讨论了当你与他人合作时,你在一个分支上工作直到完成,然后将其合并回去。

你可以想象 Git 本质上就像一条坚固的线,随着时间向前移动,总是充满良好、稳定、可工作的代码。然后像你这样的人在开发一些东西直到它工作,然后把它塞回去,然后你的分支消失,你团队中的其他人正在处理更复杂的东西,然后把它塞回去。然后有人过来在另一个分支上做一个快速修复,然后把它放回去。

你们都是这样分支出去直到某事完成,然后将其反馈到系统中。所以即使我们说分支像一棵树,它实际上更像一条线,只是有一些东西漂移出去又回来。这就是工作流程。

至于你何时使用这个工作流程,你实际上不需要使用它,直到本周结束,因为你在这门课程中的所有实验都是单人完成的。所以你的所有实验,我们讨论的关于分支和合并请求的大部分内容,对于你的实验来说并不是必需的。这最后一讲关于分支和合并请求的内容都是关于你的项目的,项目从周五开始。

Kai 发给我一个链接,关于 GitLab 有一些其他可视化工具,非常方便,这是一个可视化工具。由于复杂的原因,我需要在这里登录。所以 Kai 实际上展示了你可以看到,红色的是这里的 master 分支,你可以看到分支分出来然后又合并回去。你可以看到那里有 Brandon 的东西,然后 Kai 的 cats 和 Hayden 的分支在这里。你知道,这不是世界上最流畅的东西,但它向你展示了,即使我们谈论分支,它实际上只是那条线,有东西出去又回来。

如果我们回顾一下讲座幻灯片来总结,我们讨论了合并。让我们确保我们已经涵盖了所有内容。我们讨论了分支,讨论了你的 master 分支和带有新功能的分支,然后最终合并回去。我们讨论了如何在分支之间切换,很多这些幻灯片我没有讲,因为我们演示了。我们讨论了合并,如何将一个分支合并到你的分支中。然后我们也讨论了合并请求,这是一种通过 GUI 进行合并的方式。

这是合并的总结。我认为这个,我不认为我们会花很多时间在这上面,因为如果你没有做过很多 Git,我认为这对你来说没有意义。我认为这是你可以在一两周后回来看看的东西。但基本上,我现在给出的总结是,合并通常有两件事要做。

其中一件是当你将你的工作合并到 master 时。这发生在你完成一个功能时,你创建一个合并请求,以便你的团队可以在它进入 master 之前审查它,因为 master 中的代码应该始终是功能完整的完成代码。

然后你有另一种方式,即当你将 master 合并到你的分支时。这是你经常做的,以确保当你的团队成员完成他们的工作并将东西反馈到 master 时,你总是获得最新的更改。

所以这里绿色小波浪线上的人,当他们在工作时,当红色分支合并时,他们应该尝试从 master 拉取,就像这里和这里,就在他们工作时,比如每天或什么,这样当他们的团队成员完成工作时,他们可以不断获得最新的更改。

你可能会想,为什么我需要最新的更改,我又不用 Hayden 愚蠢的狗东西。原因很简单。你获取更改的时间间隔越长,你可能需要做更多的工作来解决冲突。所以这就像,与其突然浪费四个小时试图合并东西,不如每天花四分钟做那个要容易得多。这是一个非常极端的例子,你不会每天花四分钟在这上面。

结束语与答疑 🎓

这基本上把我们带到了结尾。让我回答几个问题,然后我们就结束。Leo 说,所以蓝色节点是在最新的屏幕节点之后添加的,合并节点在那之后添加。我不明白你是什么意思。是在画图里吗?抱歉。

Leo 说在画图里,当 Kai 的分支刚刚合并到 master 时会发生什么?我的意思是,说实话,因为我们超时了,我可能不会花时间详细说明,但你可以诚实地看看 GitLab 的历史记录。我会让这个仓库公开,如果人们想看看的话。是的,我可以把它设为内部。所以你可以直接访问这个 URL:gitlab.cse.unsw.edu.au/my-zid/lecture1-test,你可以输入那个,查看提交和分支等等。现在任何人都可以在 GitLab 上查看。

Meriddian 之前问,是否可以撤销合并请求?你实际上可以在 Git 中撤销东西,虽然相当复杂。在这门课程中,我们主要专注于让你熟悉 Git,我们让你熟悉 Git 的原因是为了让更聪明的人或未来的你能够舒适地使用像 Git 这样的工具,这些工具允许你撤销东西。你实际上不需要做那么多,但我们在第9周左右有一个额外的讲座,讨论如何撤销东西,但你不需要在整个课程中使用它,我们也不喜欢你撤销东西,因为我们必须评估你在这门课程中的贡献,所以我们真的不希望你篡改历史,因为历史对我们评估人们做了什么非常重要。

这把我们带到了结尾。我最后的评论,我会第三次重复,非常简单。我刚才向你抛出了一大堆信息。如果你 somehow 看了整个讲座,然后想,嗯,这完全合理,我懂了。很好。你要么是个天才,要么以前用过 Git。

90% 的人可能属于要么非常困惑,要么属于,嗯,我有点明白你的意思,但天哪,我希望你永远不要让我做。对于那 90% 的人来说,很好,你正是我们希望你所在的位置。你现在将去做你所有的实验,使用 Git。你将在接下来的10周里,在教程、实验、项目和其他一切中学习,你会慢慢习惯这个。这是因为讲座不会涵盖所有内容。

从明天开始,我们将讨论 JavaScript,我们也将开始讨论其他一些事情,所以 Git 就讲到这里,我们不会再在讲座中讲它,但你仍然会在每堂课中看到它,因为它将是我们所做事情的一部分,它只是课程中一切的一部分。

所以,如果你感觉这看起来很有趣,我有点明白一些概念,我仍然有点困惑,我害怕自己动手。你正走在正确的轨道上。让我们继续前进。

今晚到此结束。我很惭愧,我很遗憾我们不能有一个更轻松的第一周,但正如我所说,我们有一个更轻松的学期末。所以希望这也能平衡你其他繁忙的课程。这是本次讲座的一些反馈,非常感谢你的时间。感谢你待到这么晚。

我想我们明天下午6点直播见,周二。我们会尽量找点乐子。编程可能比 Git 更有趣一点,可能更容易保持你的注意力。感谢你在聊天中玩得这么开心。你是我迄今为止教过的最有趣的学生。你还有很多时间犯错,但我们看看会怎样。今晚和大家在一起很开心,请度过一个愉快的夜晚,如果你需要什么,请在论坛上发帖,我们有一群非常有爱心、非常聪明的导师可以帮助你解决所有问题,所以祝你有个美好的夜晚。

048:ReactJS 入门 💥

在本节课中,我们将学习 ReactJS 的基础知识,包括它是什么、为什么使用它,以及如何创建和运行你的第一个 React 应用。

为什么使用 React?

上一节我们介绍了课程目标,本节中我们来看看为什么需要 React。

如果不使用 React,我们也可以使用原生 JavaScript 或 JQuery 来构建网站。简短的回答是,这可能会是一场噩梦。真正的原因是,一旦你的应用超越了基础的演示阶段,其复杂性就会呈指数级增长。实现小功能也需要大量代码。当更多开发者加入项目时,大家会在相同的文件中工作,容易相互干扰。通常,如果一个网站功能复杂且需要大量动态交互,使用原生 JavaScript 容易导致代码混乱和难以维护。

显然,我们需要一个解决方案。历史上曾有过许多尝试,例如 Angular、Ember、Meteor。目前,React 是解决此问题最流行的方案。

什么是 React?

上一节我们探讨了使用 React 的原因,本节中我们来定义 React 是什么。

React 是一个用于构建用户界面的 JavaScript 库。它的主要优势在于允许我们一点一点地构建 UI 的各个独立部分,然后将它们组合在一起,形成功能强大的应用。

React 允许你以声明式的方式编写 UI。当你的应用状态发生变化时,无论是从后端服务器获取数据还是增加一个变量的值,React 都会监听这些变化,并确保 UI 反映出新的状态。

这听起来很复杂,但其含义是你无需担心应用底层发生了什么。如果你决定改变某些状态,你的页面会自动更新。

当我们创建一个 React 应用时,它会创建一个树状数据结构。我们创建称为“组件”的独立 UI 片段,并将它们组合在一起。我们将在后续课程中深入探讨这一点,但现在你只需要知道:组件是简单的 JavaScript 对象,并且组件可以相互包含。一个 React 应用通常由一个根组件构成,其内部包含其他组件,而这些组件内部可能又包含更多组件,如此形成之前提到的树状结构。

有趣的是,React 最常用于 Web 开发,但它也可以用于创建移动应用、桌面应用甚至命令行应用。实际上,任何涉及 UI 的地方,你都可以考虑使用 React。

创建你的第一个 React 应用

上一节我们了解了 React 的核心概念,本节中我们动手创建第一个 React 应用。

Facebook 开发了一个名为 Create React App 的工具,可以帮助我们快速开始。Create React App 是一个命令行工具,它会创建一个包含一些基础代码、可直接使用的应用文件夹。

请确保在家的电脑上跟随本讲座中的所有练习进行操作。

我们可以通过 NPM 使用 Create React App。在任何你喜欢的位置打开终端,并输入以下命令:

npx create-react-app my-first-app

命令完成后,会出现一个名为 my-first-app 的文件夹。让我们试试看。

当我们输入命令时,React 的所有依赖项都会被安装。安装完成后,会显示一组说明,告诉你如何继续。

现在,安装已完成,在编辑器中打开该文件夹,我们开始查看内容。

在文件夹内部,你会看到大量文件。在本幻灯片中,我标明了哪些文件是重要的。你应该已经熟悉其中一些文件,例如 package.json,它包含了我们的 NPM 清单。src 文件夹内是所有与 React 应用相关的代码。

用黄色高亮显示的是 App.jsApp.css 文件,它们是我们根级别的 React 组件文件。在深入了解其工作原理之前,让我们先启动应用,看看它是什么样子。

启动并查看应用

上一节我们创建了项目结构,本节中我们来启动应用。

启动应用很简单。首先运行 yarn install 来安装运行应用所需的依赖项,然后运行 yarn start 来启动它。你只需要运行一次 yarn install

应用启动后,你应该会看到一个指向本地 3000 端口的链接。在浏览器中打开它,你就能看到应用。让我们试试看。

在终端中运行 yarn start。应用启动后,它会在你的浏览器中加载 localhost:3000 并显示示例应用。

现在,让我们打开编辑器,查看 src/App.jsApp.js 是我们的组件文件。

在顶层,我们导入了 React。然后我们看到一个名为 App 的函数,它从该文件导出。在这个例子中,App 返回一个描述 UI 应该是什么样子的规范。现在,这个函数返回的东西看起来有点像 HTML,但它并不完全是 HTML。有一些关键的区别。此外,你可能不习惯在 JavaScript 文件中以这种方式看到 HTML 定义。大多数 React 函数都会返回类似这样的东西,所以让我们来探究一下原因。

理解 JSX

上一节我们查看了 App.js 的代码,本节中我们来理解 JSX。

React 函数通常返回一种我们称之为 JSX 的东西。JSX 代表 JavaScript XML,它像是 JavaScript 的扩展,允许我们像处理任何 JavaScript 对象一样处理 HTML 代码。这非常有用,因为现在 HTML 就像我们使用的任何其他数据一样,这为我们提供了很大的灵活性。在右侧的示例中,你可以看到我们可以将 JSX 赋值给一个变量,甚至可以对 JSX 执行条件逻辑。

现在,JSX 与 HTML 并不完全相同。它实际上提供了许多 HTML 无法提供的功能,并且有一些 HTML 没有的限制。例如,它提供的功能之一是通过使用花括号将 HTML 转义回 JavaScript 的能力。这允许你在 JSX 中嵌入变量,正如你在右侧第三个示例中看到的那样。

在 Web 开发中,React 和 JSX 是紧密相连的,你不能只使用其中一个而不使用另一个。所以,当你在项目中看到一个返回 JSX 的函数时,它很可能是一个 React 组件。

说到 React 组件,JSX 的一大优点是它允许我们将组件视为标准的 JavaScript 函数。它们所要做的就是返回一个对象,即 JSX。

剖析 App.js 组件

上一节我们学习了 JSX,本节中我们再次查看 App.js

你可以看到这里的 App 函数返回一些 JSX 代码。这里的 JSX 是一个 divdiv 内部有一个 header、一张 image、一些文本和一个 link。所有这些看起来都相当标准的 HTML,但有几个例外。在 JSX 中,我们使用 className 而不是 class,其中 N 是大写的。通常,HTML 中的属性会以这种方式转换为驼峰命名法。这适用于大多数由多个单词组成或包含连字符的 HTML 标签。转换为驼峰命名法可以使其与我们的外部 JavaScript 风格保持一致。

此外,你在 JSX 开头和结尾看到的花括号允许我们将 JSX 整齐地跨越多行。还有许多其他差异,但我们将留到以后的课程中讨论。希望我们今天展示的内容足以让你对 JSX 有一个基本的了解。现在,让我们通过编辑我们的 App 文件来看看我们能做什么,以此来巩固所学知识。

编辑你的第一个组件

上一节我们分析了默认组件,本节中我们来修改它。

首先,让我们尝试向组件添加一些东西。我们要做的是在现有的 App.js 文件中添加一个显示 “Hello World” 的标题。

让我们开始吧。我将首先启动应用,让页面加载到浏览器中。然后,我可以通过在 header 标签下添加一个 “Hello World” 标题来编辑 App 函数。你可以看到它会自动为我们更新。非常酷。

你可能已经注意到,保存文件后我不需要刷新浏览器。应用会自动重新加载。这被称为热模块替换,它允许我们在文件保存后立即看到对应用所做的更改反映在页面上。

好的,现在做一个更大的改动。我们将删除 App 函数中当前存在的所有 JSX,并将其替换为一个简单的 div,显示我们加载页面的日期和时间。

首先,我将删除已存在的 JSX。然后,我将创建一个存储当前日期的变量。现在,我可以使用 JSX 的嵌入功能在 div 内部将日期转换为字符串。并移除一些未使用的导入。

总结

在本节课中,我们一起学习了 ReactJS 的基础知识。我们探讨了为什么需要 React,了解了 React 是一个用于构建声明式 UI 的 JavaScript 库,并理解了组件和 JSX 的核心概念。我们使用 create-react-app 创建了第一个 React 应用,启动了开发服务器,并成功编辑了 App 组件,看到了热重载的效果。

核心概念总结如下:

  • 组件:构建 UI 的独立、可复用的代码单元。
  • JSX:一种 JavaScript 语法扩展,允许在 JavaScript 代码中编写类似 HTML 的结构。它与 HTML 的关键区别包括使用 className 代替 class,以及可以使用 {} 嵌入 JavaScript 表达式。

请随意摆弄你今天创建的应用,看看你能让它做什么。在接下来的几周里,我们将为你提供对 React 内部工作原理的深入理解。在下一讲中,我们将讨论 CSS 以及如何将其与 React 集成以创建漂亮的用户界面。此外,本幻灯片末尾还有一些内容,解释了 React 如何挂载到网页上。下次见,保重。

049:在React中集成CSS 🎨

在本节课中,我们将学习如何将CSS集成到React应用中,以创建美观的用户界面。我们将从基础的内联样式开始,逐步介绍使用CSS类名的方法,并探讨全局CSS引入带来的挑战。


概述

React不仅是一个构建用户界面的框架,更应致力于构建优秀的用户界面。为了实现这一点,我们需要为其添加样式。幸运的是,我们可以像在普通HTML中一样,在React中使用CSS。

上一节我们介绍了React的基础,本节中我们来看看如何为React组件添加样式。


创建新的React应用

我们将首先创建一个新的React应用来实践CSS。

在终端中运行以下命令:

npx create-react-app my-css-app

创建完成后,在编辑器中打开项目。


内联样式

就像在HTML中一样,我们可以向JSX元素传递一个style属性来应用CSS样式。在JSX中,style属性接受一个普通的JavaScript对象,其中键是CSS属性,值是对应的CSS值。

需要注意的是,任何带有连字符的CSS属性名在JSX中都需要转换为驼峰命名法。

以下是一个定义样式对象并将其应用于元素的示例:

const myStyle = {
  color: 'red'
};

function App() {
  return <div style={myStyle}>Hello World</div>;
}

实际上,我们无需单独定义样式变量,可以直接在行内定义对象。这种技术被称为内联样式

function App() {
  return <div style={{ color: 'red' }}>Hello World</div>;
}

内联样式有其用武之地,但它也存在与在HTML中定义样式相同的局限性:在大型代码库中难以追踪样式,并且难以将相同样式一次性应用于多个JSX元素。


使用CSS类名

幸运的是,我们可以在JSX中使用CSS类,就像在HTML中一样。

在JSX中,我们使用className属性来替代HTML的class属性。它接受一个字符串,用于指定类名。

function App() {
  return <div className="my-app">Hello World</div>;
}

现在我们可以使用类名,让我们的应用看起来独一无二。

在你的应用文件夹中,你会看到一个App.css文件。我们可以在这里定义.my-app类的样式。

以下是App.css文件的一个示例:

.my-app {
  max-width: 600px;
  text-align: center;
  padding: 20px;
  margin: 0 auto;
  border-radius: 10px;
  color: white;
  background-color: black;
}

.my-app:hover {
  transform: scale(1.05);
}

导入CSS文件

我们已经编写了CSS文件,并为标签指定了类名。但为了将两者关联起来,我们需要导入CSS文件,这样JSX才能找到我们提供的类。

我们可以像导入其他文件一样导入CSS文件。当页面重新加载时,App.js文件将加载CSS文件中的类,日期文本将附加上正确的样式。

import './App.css';

function App() {
  return <div className="my-app">Hello World</div>;
}

通过这种方式创建样式非常容易。


混合使用类名和内联样式

我们可以根据需要混合使用内联样式和类名,没有任何限制。

以下是一个更高级的示例,展示了如何混合使用:

import './App.css';

function App() {
  return (
    <div className="my-app">
      <h1 style={{ fontSize: '2em' }}>Welcome</h1>
      <p style={{ marginTop: '20px' }}>This is a paragraph with inline styles.</p>
    </div>
  );
}

请注意,在内联样式中,像font-sizemargin-top这样的属性已转换为驼峰命名法(fontSize, marginTop)。


全局CSS的挑战

如果编程的一切都这么简单就好了。当我们在一个文件中导入CSS时,你可能会认为我们不能在另一个文件中使用相同的CSS。毕竟,我们只在一个文件中导入了它。如果这是真的,那就太好了,因为这意味着我们不再需要担心不同CSS文件之间类名重叠的问题。

不幸的是,事实并非如此。当我们导入一个CSS文件时,该CSS中的类在所有其他文件中都可用。

这可能会让人有些困惑,让我来解释一下。

假设我们有两个组件:AppOtherComponent

  • App.js 导入了 App.css,其中定义了 .my-app 类。
  • OtherComponent.js 导入了 OtherComponent.css,它也定义了一个 .my-app 类。

即使我们没有在App中渲染OtherComponent,仅仅导入OtherComponent.js就可能导致两个.my-app类的样式发生冲突和覆盖。具体哪个样式生效取决于文件导入的顺序,这会导致不一致的行为。

这表明,以这种方式导入CSS虽然有用,但像一把钝器,存在许多我们想要解决的问题和不一致性。


解决方案展望

为了使我们的组件真正隔离,我们需要确保在导入CSS文件时,其类仅在我们导入的文件中使用,而不会与其他组件混淆。

好消息是,有许多方法可以实现这一点,例如CSS ModulesCSS-in-JS。在后续的课程中,我们将涵盖这些高级的CSS方法。


总结

本节课中我们一起学习了在React中集成CSS的基础知识。我们介绍了如何使用内联样式和CSS类名为组件添加样式,并演示了如何导入CSS文件。同时,我们也了解了全局CSS引入可能带来的样式冲突问题,并简要提及了后续将学习的更先进的样式隔离方案(如CSS Modules)。现在,你已经能够为你的React组件添加样式了,我们期待看到你的创意作品。


下节课预告:在接下来的课程中,我们将探讨React中一些高级的CSS方法,学习它们如何帮助我们提升组件的样式管理水平。

050:React如何工作 💥

在本节课中,我们将学习React是什么,它试图解决什么问题,以及它如何通过声明式渲染和虚拟DOM等核心概念来解决这些问题。


React是什么?

React本质上是一个用于构建用户界面的JavaScript库。它在大约七年前以可识别的形式发布,这在JavaScript框架领域已经是非常长的时间了。它源自Facebook的一个内部项目,此后一直由Facebook开源和维护。

React在2013年带来的主要思想与它现在使用的思想相同,即声明式渲染组件。本节课我们将主要关注声明式渲染,组件将在本学期的后续课程中介绍。


声明式渲染 vs. 命令式渲染

声明式渲染直接与命令式渲染形成对比,就像声明式编程与命令式编程形成对比一样。

在命令式编程中,我们告诉计算机需要按顺序执行的确切步骤,以达到特定的结果。我们指定过程,但并不断言执行所有这些步骤后会发生什么。

声明式编程则相反。我们给计算机期望的最终状态,并说:“我不关心你如何到达那里,我只希望你最终能达到那个状态。”

一个有趣的思考方式是在烘焙蛋糕的背景下。命令式编程就像使用一本没有照片的食谱书,只有一系列指令,例如用几个鸡蛋、多少面粉。你假设正确执行这些指令后,最终会得到一个蛋糕。声明式编程则更像是找到一张你喜欢的蛋糕照片,然后把照片交给厨师,说:“我不在乎你怎么做这个蛋糕,用什么步骤,我只想要一个最终看起来像这样的蛋糕。”

为了对比命令式和声明式编程,让我们看一个非常简单的Web场景。


一个简单的Web场景

我们有一个空白页面,背景为绿色,我们称之为页面一。

命令式方法

命令式方法要求我们按顺序执行确切的步骤,以从A点到达B点。在这种情况下,这很简单。我们只需要操作document.body.style对象,修改backgroundColor属性并将其设置为绿色。

document.body.style.backgroundColor = 'green';

在命令式方法中,我们指定的是过程,而不是结果。我们并没有告诉浏览器:“执行此操作后,背景颜色必须是绿色。”我们只是说“改变这个变量”,并假设我们会达到目标。

声明式方法

声明式方法则是声明期望的UI,并让React来找出如何实现它。

function App() {
  return <body style={{ backgroundColor: 'green' }} />;
}

在声明式方法中,我们指定想要的结果,但并不关心实现它的过程。

这看起来似乎需要更多代码才能达到相同的结果,特别是考虑到我们还需要包含React库本身及其浏览器兼容性库(大约109KB的JavaScript)。如果结果相同,为什么还要使用这种声明式方法呢?

让我们来看一个稍微复杂一点的场景。


更复杂的场景:状态转换

假设我们现在有两个页面:页面一(绿色背景)和页面二(白色背景,有三个按钮)。

我们想要从页面一转换到页面二。假设我们从页面一的状态开始,然后想移动到页面二的状态。

命令式方法

命令式方法是执行从页面一到页面二的确切步骤。

// 转换到页面二
document.body.style.backgroundColor = 'white';
const button1 = document.createElement('button');
const button2 = document.createElement('button');
const button3 = document.createElement('button');
document.body.appendChild(button1);
document.body.appendChild(button2);
document.body.appendChild(button3);

声明式方法

声明式方法则是声明我们的期望状态,并让React通过在我们的功能组件中使用if语句来找出如何进行更改。

function App({ pageType }) {
  if (pageType === 1) {
    return <body style={{ backgroundColor: 'green' }} />;
  } else {
    return (
      <body style={{ backgroundColor: 'white' }}>
        <button />
        <button />
        <button />
      </body>
    );
  }
}

这看起来仍然是更多代码,但我们已经忘记了我们刚刚构建的这个UI系统的一个主要部分。

我们当前的UI实际上是一个状态机。如果我们把页面看作状态,我们必须记住,在一个状态机中,存在组合数量的可能状态转换。仅看我们这里可能的状态转换,实际上有六个:从空页面到页面一、从空页面到页面二、从页面一到页面二、从页面二回到页面一,以及我们不必担心的两个状态(页面一和页面二都回到空页面,因为我们不太可能希望将网站擦除回白屏)。但我们仍然需要处理首次状态转换。

让我们看看我之前演示的反向示例:从页面二(带有三个按钮的白色页面)转换回页面一(带有绿色背景的空白页面)。

命令式方法(反向转换)

命令式方法变得复杂得多。

// 假设我们之前创建了按钮并存储了引用
let button1, button2, button3;

// 转换到页面二(同上)
document.body.style.backgroundColor = 'white';
button1 = document.createElement('button');
button2 = document.createElement('button');
button3 = document.createElement('button');
document.body.appendChild(button1);
document.body.appendChild(button2);
document.body.appendChild(button3);

// 从页面二转换回页面一
if (button1) document.body.removeChild(button1);
if (button2) document.body.removeChild(button2);
if (button3) document.body.removeChild(button3);
button1 = button2 = button3 = null;
document.body.style.backgroundColor = 'green';

声明式方法(反向转换)

如果你仔细观察,你会发现这段代码实际上和之前完全一样。这就是声明式渲染带来的主要好处。

function App({ pageType }) {
  if (pageType === 1) {
    return <body style={{ backgroundColor: 'green' }} />;
  } else {
    return (
      <body style={{ backgroundColor: 'white' }}>
        <button />
        <button />
        <button />
      </body>
    );
  }
}

因为我们只是声明状态,并让React找出如何进行更改,所以我们的声明式代码,根据定义,为我们处理了所有的状态转换。随着我们的应用程序变得越来越复杂,这是一个巨大的好处。


声明式UI解决的问题

1. 状态转换的复杂性

一般来说,随着Web应用程序变得越来越复杂,我们最终会有更多的可能状态,因此这些状态之间可能的转换数量会非常迅速地膨胀。即使只是在页面的高层次概念上,如果我们有50个页面,并且只从一个页面转换到另一个页面,那至少是2450种可能的转换,我们需要处理它们的边界情况。

当我们使用像React这样的声明式框架时,我们只需要描述这50个状态的完整输出UI,React将为我们管理所有的转换。因此,它为我们必须处理的所有可能边界情况的复杂性设定了一个上限。

2. 未跟踪的UI变更

声明式UI旨在解决的第二个问题是未跟踪的UI变更问题。随着团队规模的扩大,这种情况变得更加可能。

例如,假设我们组织中的另一个团队说:“哦,如果能在页面二的右下角添加一个额外的div,显示‘请接受cookies’之类的消息,那该多好啊。”他们认为这只是一个消息,不具交互性,足够简单,不需要告诉构建页面二的团队。

但你可以看到,如果我们重用从页面二到页面一的相同命令式转换代码(执行确切的指令集来更改背景颜色并删除三个按钮),我们会错过删除那个新的div。现在我们就有了一个所谓的“未跟踪的UI变更”:在页面一上有一个div,除了用户显然能在浏览器中看到它之外,没有人知道它的存在。当你拥有非常复杂的状态转换,并且没有使用一个能确保UI最终一致的框架时,你最终会遇到类似这样的情况。

另一个有趣的例子:假设我们有两个异步加载的脚本标签。第一个将背景颜色设置为红色,第二个尝试将背景颜色设置为蓝色。问题是,body的颜色是什么?答案是:我不知道。这取决于实际情况,因为这两个脚本之间没有同步,它们可能在任何时间加载,一个在另一个之前或之后,这取决于网络条件、服务器负载、你机器上的CPU等。在这种情况下,我们最终可能会有多个命令式脚本试图同时修改DOM,而不知道期望的最终状态应该是什么。


何时使用声明式框架?

命令式渲染并不全是坏事。我们使用了很长时间的命令式渲染,Web从90年代初一直到2010年代初声明式框架开始出现时都很活跃。对于做简单的事情来说,它非常方便和符合人体工程学。你不需要任何额外的库,不需要将代码转译来构建JSX,也不需要担心包管理。

我认为,当你决定是否需要使用声明式框架来做UI时,可以遵循的一般规则是:你是否有任何数据是独立于DOM存在的?

让我解释一下这是什么意思。

假设我们有一个表单,其中有一个复选框,指示表单是否应该被提交(例如,一个条款和条件表单)。我们有一个复选框写着“我接受条款和条件”,然后是一个允许我们提交表单的按钮。

在这种情况下,DOM中有一个带有ID的复选框,还有一个可以提交表单的按钮。当我们点击按钮时,我们将直接查看HTML中的复选框元素,查看UI中的复选框元素,并根据其checked属性来确定它是否被选中。

这意味着,因为我们在点击另一个按钮时进行检查,并且使用UI作为真相来源,所以我们有一个相当好的保证,我们的UI将与我们的数据保持一致,我们不会意外地允许用户在复选框未选中的情况下点击提交按钮。

但是,一旦你有了仅存储在JavaScript中的数据,情况就不同了。例如,你向服务器发出请求,得到一个项目数组(比如一个待办事项列表),并且你想渲染这个待办事项列表。

现在你有了两个不同的状态机:你的JavaScript代码(JavaScript中的项目数组)和DOM中的元素(待办事项列表中的div或按钮)。你试图让这两个状态机保持同步。如果你从JavaScript的待办事项列表数组中删除一个项目,你必须确保记得也从DOM中删除相应的项目。重新排序、添加新元素、更改元素内容也是如此。

你最终会陷入这样一种情况:随着数据变得越来越复杂,你需要处理的边界情况呈指数级增长。因此,我们需要一个解决方案来帮助我们解决这个问题,而声明式渲染很好地解决了我们的问题,因为它意味着我们可以使用JavaScript中的数据作为我们的真相来源,并且我们的UI将始终保持一致。


React如何实现声明式渲染?

你可能在想:“哦,这太棒了,React为我们提供了这种UI魔法,但这听起来很复杂,听起来像是一个黑盒子。”实际上并非如此。我们可以使用非常基本的逻辑来构建我们自己非常简单的React版本。如果你愿意尝试,你实际上可以在业余时间自己动手做。

第一步:确保契约

第一步,也是迄今为止最重要的一步,是我们需要一种方法来确保声明式渲染提供的契约。声明式渲染保证,如果你说UI应该看起来像这样,那么它就会看起来像这样。如果我们的功能组件返回三个按钮,而React只在屏幕上放了两个按钮,那就全完了,我们无法再信任React。

那么,我们如何履行这个契约呢?让我们尝试想一个非常愚蠢、天真的方法,我们能想到的最简单的方法。

一个想法是:我们每次有任何变化时,都擦除整个DOM,将整个页面还原为白屏,然后重新渲染整个界面。例如,当复选框从未选中变为选中时,我们实际上删除整个界面,然后用复选框处于正确状态的方式重新渲染整个界面。

从抽象的角度思考,如果我们总是从头开始,我们就不需要考虑任何状态转换,对吗?如果我们擦除整个页面,你可以看到,右下角那个错误的div现在会因为页面被删除而被清除,然后我们可以使用相同的代码干净地转换回页面一。

问题:性能开销

如果这真的有效,那React的意义何在?为什么我们不能只写一个函数来删除整个DOM,然后就完事了呢?问题是,每次有小的变化时,删除和重新渲染整个UI在计算上过于昂贵。如果你有10,000个元素,仅仅检查一个复选框就导致你重新渲染、删除整个UI并不得不重新渲染那10,000个元素,这代价太大了。

有趣的是,理解为什么删除整个DOM并重新渲染它在计算上如此昂贵。我认为,使HTML和CSS如此出色的原因之一是布局引擎,如Flexbox和CSS Grid,一旦你掌握了它们,使用起来就是一种乐趣。我们可以使用它们非常容易地制作能够灵活扩展到不同设备尺寸、不同方向、布局的应用程序。

但我们必须为这种能力和灵活性付出的代价是,通常当我们删除一个元素时,因为我们的布局是程序化的,不是绝对的,这意味着它们需要重新计算。例如,我们有三个并排的按钮,它们都应该等宽,它们开始时是33%的宽度。然后如果我们删除中间的那个,预计第一个和第三个按钮将扩展到50%的宽度。当我们需要对数万个节点进行这种计算时,它会变得非常、非常昂贵。

解决方案:虚拟DOM

所以,我们现在知道如何履行声明式渲染的契约,但我们只需要让“删除整个DOM”的方法更高效。

一个想法是:我们想办法只删除和重新创建我们真正需要删除和重新创建的部分

诀窍在于:在修改DOM的过程中,我们实际上并不关心布局,对吗?我们想让浏览器在我们完成所有更改后,再计算如何更新布局。

既然布局重新计算是昂贵的部分,我们最终做的是:我们在JavaScript中保留整个HTML树的虚拟副本。我们只对我们的虚拟树进行更改:删除元素、移动元素。然后我们比较之前的树和新的树,找出这两棵树之间到底发生了什么变化。然后,我们可以使用这些精确的变化对实际的DOM进行一组非常小的更改,从而最小化我们需要为重新布局付出的代价。

这种技术通常被称为虚拟DOM。它不仅仅被React使用,许多其他声明式框架也使用它。

虚拟DOM良好工作的关键在于,我们能够廉价地进行diff(即找出差异),比较之前的状态和新的状态。我们之所以能够廉价地做到这一点,是因为在重新创建新树时,我们不需要为所有元素重新布局付费。

例如,看一个之前未跟踪UI变更的例子(那个悬停的div)。我们可以看到,我们从拥有三个按钮和一个div的状态,转换到只拥有三个按钮的状态。在右侧,我们用灰色表示我们根本不需要更改那些元素,用红色表示我们需要删除那个div。

在这种特定情况下,React不需要支付删除外部容器或任何三个按钮的成本,因为它可以看到,在状态1和状态2之间,除了右下角的那个div之外,其他所有东西都是相同的。

在底层,React基本上会获取UI之前的样子和现在在廉价虚拟DOM中的样子的快照。它比较它们,然后说:“这个div是唯一被移除的东西,其他都没有改变。”然后,在内部,它只是调用removeElement来从DOM中删除一个div,就像我们在命令式方法中做的那样,从而避免了删除和重建整个DOM的成本。


总结

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

  1. React是什么:一个用于构建用户界面的JavaScript库,核心思想是声明式渲染和组件。
  2. 声明式渲染与命令式渲染的区别:声明式关注“最终状态是什么”,命令式关注“如何达到最终状态”。
  3. 声明式UI的优势
    • 自动处理复杂的状态转换,为应用程序的复杂性设定上限。
    • 防止未跟踪的UI变更,确保UI与数据源(通常是JavaScript状态)保持一致。
  4. 何时考虑使用声明式框架(如React):当你的应用程序状态(数据)独立于DOM存在,并且你需要保持两者同步时。
  5. React的核心机制:虚拟DOM:React通过在JavaScript中维护一个UI的虚拟表示(虚拟DOM),并智能地比较状态变化前后的差异(diffing),然后只将必要的最小更改应用到真实DOM上。这避免了昂贵的全量DOM操作和布局重计算,从而高效地实现了声明式渲染的契约。

通过理解这些基本原理,你可以更好地理解React的工作方式,并更有效地使用它来构建复杂且可维护的用户界面。

051:JavaScript 与 NodeJS 转译 🐸

概述

在本节课中,我们将要学习 转译 的概念。转译是前端开发中一个至关重要的过程,它允许我们使用现代或不同的编程语言编写代码,并将其转换为浏览器能够理解和执行的 JavaScript 代码。我们将探讨转译的定义、常见用例、工作原理以及它带来的利弊。


什么是转译?🤔

上一节我们介绍了课程主题,本节中我们来看看转译的具体定义。

转译转换编译 两个词的结合。编译是将源代码(如 C++)转换为机器码的过程,以便计算机直接执行。相比之下,转译是将一种源代码转换为另一种源代码的过程。转译的输出通常仍然需要被编译或解释才能最终执行。

一个直接的例子是:

  • 编译:使用编译器将 C++ 代码转换为机器码(可执行文件)。
  • 转译:将 C++ 代码转换为功能等效的 JavaScript 代码。

为什么需要转译?🔧

理解了转译的基本概念后,我们来看看它在实际开发中的主要应用场景。以下是转译的几个关键用例:

1. 将其他语言转换为 JavaScript

由于历史上 JavaScript 是浏览器原生支持的唯一语言,如果你想用其他语言(如 C++)编写程序并在浏览器中运行,就必须将其转译为 JavaScript。这在将大型遗留代码库迁移到 Web 平台时非常常见。

例如,使用 Emscripten 库可以将 C++ 代码转译为 JavaScript。为了模拟 C++ 的接口和行为,转译后的代码通常包含大量包装和模拟代码,即使是一个简单的“Hello World”程序也可能生成上万行 JavaScript 代码。

2. 将新 JavaScript 转换为旧 JavaScript

随着 JavaScript 语言的发展,新特性和 API 不断加入。但用户可能使用各种旧版浏览器访问网站。为了保持兼容性,我们可以用新语法编写代码,然后将其转译为旧版浏览器能理解的 JavaScript。

例如,在原生 class 语法不被广泛支持时,转译工具(如 Babel)会将 class 转换为使用原型和构造函数的老式写法。

代码示例:

// 转译前 (ES6+)
class Test {
  method() {
    console.log('test');
  }
}

// 转译后 (ES5)
var Test = (function () {
  function Test() {}
  Test.prototype.method = function () {
    console.log('test');
  };
  return Test;
})();

3. 将 JavaScript 变体/超集转换为 JavaScript

一些语言在 JavaScript 基础上进行了扩展,以解决其某些不足,但它们本身无法在浏览器中直接运行。

  • TypeScript:为 JavaScript 添加了静态类型检查。转译过程会剥离类型注解,生成纯 JavaScript。
    // TypeScript 源码(带类型注解)
    function greet(name: string): string {
      return `Hello, ${name}`;
    }
    // 转译为 JavaScript
    function greet(name) {
      return `Hello, ${name}`;
    }
    
  • CoffeeScript:提供更简洁的语法(现已不太流行)。

4. 代码压缩与混淆

这是转译在前端优化和安全方面的应用。

  • 压缩:目的是减少需要下载的代码体积,以加快网页加载速度。过程包括:
    • 删除空白字符和注释。
    • 缩短变量和函数名。
    • 进行更高级的优化(如删除死代码)。
  • 混淆:目的是增加代码被反向工程的难度,保护知识产权。过程与压缩类似,但更侧重于让代码难以阅读和理解。

Google Closure Compiler 在高级模式下是压缩和优化的极致体现。它能深度分析代码,删除未使用的部分,甚至内联函数,最终输出执行结果相同但体积最小化的代码。


转译是如何工作的?⚙️

了解了各种用例后,我们来看看转译通常如何集成到开发流程中。

在现代前端开发中,我们使用构建工具(如 Webpack、Rollup、Vite)来管理转译流程。开发人员编写的源代码通常不能直接运行,需要经过一个“构建”步骤。

这个构建管道由一系列插件或加载器组成,每个负责一项特定的转译任务(例如,用 Babel 转译 JS,用 Sass 编译器处理 CSS)。通过配置这些工具,我们可以将 TypeScript、JSX、现代 JavaScript 语法等,一步步转换为浏览器兼容的、优化过的最终代码。


转译的代价 💡

虽然转译非常强大,但它并非没有成本。在结束之前,我们需要了解其潜在缺点:

  1. 构建速度:对于大型项目,转译(尤其是 TypeScript 编译)可能非常耗时。复杂的构建管道会拖慢开发迭代速度。
  2. 调试难度:经过重度压缩和转译后,浏览器中运行的代码与源代码截然不同。当发生错误时,错误堆栈指向的是转译后的代码,难以定位原始问题。
  3. 需要源映射:为了解决调试问题,需要生成和维护 源映射 文件。源映射能将压缩代码中的行号映射回原始源代码,但这要求所有调试和错误监控工具都支持源映射。

总结

本节课中我们一起学习了 转译 在前端开发中的核心作用。我们了解到,转译是将源代码转换为另一种源代码的过程,它使得我们能够使用现代语言特性、确保浏览器兼容性、优化代码性能并保护代码逻辑。

关键要点包括:

  • 转译不同于编译,其输出仍是源代码。
  • 主要应用包括:语言转换、版本降级、处理 JavaScript 超集以及代码压缩混淆。
  • 转译通过 Webpack 等构建工具及其插件生态系统在开发流程中自动完成。
  • 转译会带来构建耗时和调试复杂化等代价,需要通过源映射等技术来缓解。

希望本讲能帮助你理解,当你在项目中运行 npm run build 时,背后有多少“转译”魔法正在发生。

052:ReactJS 💥 useState Hook

在本节课中,我们将要学习React框架中一个核心概念:状态(State)。我们将探讨如何使用useState Hook来管理组件内部的数据,以及状态变化如何驱动用户界面的更新。

我们已经掌握了如何声明要在屏幕上显示的UI,也理解了React如何高效地对比UI的变化,并将这些变化应用到真实的DOM中,以最小化布局成本。然而,要构建一个真正像React一样工作的框架,还缺少一个关键部分:我们如何告诉React数据发生了变化,从而需要重新生成组件并对比新旧状态?

答案是,在React中,只有一种方式可以触发UI更新。

触发UI更新的唯一方式:React状态

React状态是React内部的一个子系统,它允许我们告知React哪些数据会影响用户界面的呈现方式。

基础示例:计数器

以下是一个标准的函数式组件示例:

function App() {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  return (
    <button onClick={increment}>
      {count}
    </button>
  );
}

我们来分解这段代码:

  • useState(0):我们调用useState函数,并传入初始值0
  • countuseState返回的第一个值是状态的当前值,初始为0
  • setCountuseState返回的第二个值是一个函数,用于更新状态。
  • increment:这是一个简单的函数,它调用setCountcount的值增加1。
  • 组件返回一个按钮,点击时调用increment函数,并将当前的count值显示在按钮内部。

内部工作原理

当我们点击按钮时,count状态会增加。这会导致函数组件被再次执行。第一次执行和第二次执行的结果,构成了React将要进行对比的两个虚拟DOM树。

React通过对比发现,外层的<button>元素没有变化,因此不会从真实DOM中删除它。但是,React能识别出按钮内部的文本内容发生了变化,因此会重新渲染按钮的文本内容。

关于setState的重要细节

理解setState的工作方式有几个关键点:

  1. 状态更新是异步的:当你调用setState时,状态不会立即更新,而是要等到JavaScript事件循环的下一个周期才会更新。
  2. 组件重新渲染:状态更新完成后,我们的渲染函数(即函数组件)会被再次调用。
  3. 获取新状态值:在这次调用中,useState将返回更新后的状态值。

React使用新旧状态值来生成两个虚拟DOM树并进行对比。一旦计算出需要进行的更改,React就会将这些更改应用到真实的UI上。

进阶示例:下拉菜单

上一节我们介绍了基础的计数器,本节中我们来看看一个更强大、更贴近实际应用的例子:一个简单的下拉菜单。

function App() {
  const [dropdownVisible, setDropdownVisible] = useState(false);
  return (
    <div>
      <button onClick={() => setDropdownVisible(!dropdownVisible)}>
        Open Dropdown
      </button>
      {dropdownVisible && (
        <ul>
          <li>Dropdown Item 1</li>
          <li>Dropdown Item 2</li>
          <li>Dropdown Item 3</li>
        </ul>
      )}
    </div>
  );
}

以下是代码解析:

  • useState(false):我们使用一个布尔值作为状态的初始值,命名为dropdownVisible
  • setDropdownVisible:这是用于更新该状态的函数。
  • 按钮的onClick事件调用setDropdownVisible(!dropdownVisible),这会将状态在truefalse之间切换。
  • 我们使用JavaScript的短路求值语法:{dropdownVisible && (...)}。这意味着只有当dropdownVisibletrue时,才会继续渲染右侧的<ul>列表。

这个组件实现了一个基本的下拉菜单功能:

  • 初始时,dropdownVisiblefalse,因此列表不会渲染。
  • 点击按钮后,dropdownVisible变为true,列表随之显示。
  • 再次点击按钮,状态变回false,列表消失。

使用声明式框架的优势在于,我们永远不会陷入“忘记从DOM中移除下拉菜单内容”或“菜单的打开/关闭状态与实际DOM不同步”的困境。通过查看这段代码,我们可以保证只有两种可能的UI状态,而React会负责处理它们之间的所有过渡。

总结

本节课中我们一起学习了React的核心概念——状态管理。我们了解到:

  • 在React中,状态(State)是触发UI更新的唯一方式
  • 我们使用 useState Hook 来在函数组件中声明和更新状态。
  • 状态更新是异步的,会触发组件的重新渲染
  • React通过对比新旧虚拟DOM树,高效地更新真实DOM
  • 通过声明式的编程方式(如示例中的下拉菜单),我们可以清晰地定义UI与状态的关系,避免手动操作DOM带来的错误。

理解“渲染 -> 改变状态 -> 再次渲染”这个极其简单、灵活且强大的循环的最佳方式,就是亲自尝试构建一些简单的组件。你可以模拟下拉菜单、计时器等有趣的功能。多动手实践,并享受其中的乐趣。

053:ReactJS入门演示 🚀

在本节课中,我们将学习ReactJS的基础知识。我们将通过构建一个简单的Web应用,来对比React与之前学习的原生JavaScript在开发方式上的不同。本节课的核心是理解React的声明式编程模型、状态管理以及JSX语法。


概述

React是一个用于构建用户界面的JavaScript库。它采用声明式编程范式,允许开发者通过描述“UI应该是什么样子”来构建应用,而React负责处理UI的更新。本节课我们将通过重构第4周的原生JavaScript应用,来直观地感受React的工作方式。


环境设置与项目结构

上一节我们介绍了React的基本概念,本节中我们来看看如何设置一个React开发环境。

首先,我们可以使用官方命令创建一个新的React应用:

npx create-react-app my-app

这将创建一个包含所有必要依赖和配置的项目。

为了与本课程内容保持一致,我们也可以使用一个预先配置好的起始代码仓库。克隆后,运行以下命令安装依赖并启动开发服务器:

npm install
npm start

npm start 会启动一个热重载开发服务器。这意味着你对代码的任何修改都会立即被检测到,React会重新编译代码并刷新页面。

核心概念:React项目中的代码(通常写在 .jsx.js 文件中)并不是浏览器直接运行的。React有一个编译步骤,会将我们编写的JSX代码转换成标准的JavaScript和HTML。你可以通过查看网页源代码来确认这一点,你会看到一个被压缩的 bundle.js 文件,其中包含了所有运行应用所需的代码。


理解JSX与基础渲染

现在我们已经有了运行中的项目,让我们来看看React代码的核心部分。

打开 src/App.js 文件,你会看到类似HTML和JavaScript混合的代码,这被称为JSX。JSX是JavaScript的语法扩展,它允许我们在JavaScript代码中编写类似HTML的结构。

核心概念:在React中,一个组件本质上是一个函数,这个函数返回一些描述UI的JSX。例如,一个简单的组件可能如下所示:

function App() {
  return (
    <div>
      <h1>Hello, COMP6080!</h1>
    </div>
  );
}

这个 App 函数返回的JSX最终会被React转换成真实的DOM元素并渲染到页面上。


状态管理与事件处理

理解了基础渲染后,我们来看看React如何管理动态数据,即“状态”。

在原生JavaScript中,我们通过直接操作DOM来更新页面内容。在React中,我们采用不同的模式:我们定义“状态”,描述状态如何更新,以及UI如何根据状态进行渲染。React会自动处理中间的更新逻辑。

让我们通过一个计数器例子来理解:

  1. 定义状态:我们使用 useState 钩子来创建一个状态变量。

    const [number, setNumber] = React.useState(1);
    

    这里,number 是当前的状态值(初始为1),setNumber 是用于更新这个状态的函数。

  2. 根据状态渲染UI:在JSX中,我们可以直接使用状态变量。

    <div>Number: {number}</div>
    
  3. 通过事件更新状态:我们为按钮绑定点击事件,事件处理函数调用 setNumber 来更新状态。

    <button onClick={() => setNumber(number - 1)}>Minus</button>
    <button onClick={() => setNumber(number + 1)}>Plus</button>
    

    当状态 number 更新时,React会检测到变化,并自动重新调用组件函数,使用新的 number 值来更新UI。

核心概念:你永远不要直接修改状态变量(如 number = number + 1),必须通过React提供的更新函数(如 setNumber)来进行。这是React能够跟踪变化并高效更新UI的关键。


构建表单:受控组件

掌握了状态管理后,我们来构建一个更复杂的例子:一个表单输入框。

在React中,表单元素通常被处理为“受控组件”。这意味着表单元素的值由React状态控制,而不是由DOM自身管理。

以下是实现步骤:

  1. 为输入值创建状态

    const [textArea, setTextArea] = React.useState('');
    
  2. 将输入框的值绑定到状态

    <textarea value={textArea} />
    
  3. 监听输入变化并更新状态

    <textarea
      value={textArea}
      onChange={(event) => setTextArea(event.target.value)}
    />
    

    每次用户输入,onChange 事件被触发,我们调用 setTextArea 用新的输入值更新状态。状态更新导致组件重新渲染,输入框显示新的值。

这种模式确保了React状态是表单数据的“唯一来源”。


条件渲染与样式动态应用

现在,让我们为表单添加一些交互逻辑:如果文本框为空,则背景显示为红色。

这展示了React的另一个强大特性:我们可以根据状态轻松地条件化渲染内容或应用样式。

动态样式示例

const myStyle = {
  background: textArea === '' ? 'red' : 'white'
};

<textarea style={myStyle} ... />

这里,我们根据 textArea 状态是否为空字符串,来决定背景色样式对象。

条件渲染示例:我们也可以控制整个元素的显示与隐藏。

{showModal && <div>This is a modal!</div>}

只有 showModal 状态为 true 时,模态框的 div 才会被渲染到页面上。


列表渲染与动态添加项目

许多应用需要渲染列表数据。React 使得渲染动态列表变得非常直观。

假设我们有一个电子邮件地址列表需要渲染,并且可以添加更多输入框。

  1. 使用状态管理列表

    const [emailList, setEmailList] = React.useState(['', '']);
    
  2. 使用 map 方法渲染列表

    {emailList.map((email, index) => (
      <div key={index}>
        <input value={email} onChange={...} />
      </div>
    ))}
    

    我们使用数组的 map 方法遍历 emailList,为每个邮箱地址返回一个输入框。key 属性帮助React高效识别哪些项被改变、添加或删除。

  3. 添加新项目:要添加新的输入框,我们需要更新状态。重要的是,我们应该创建一个新的数组,而不是修改原数组。

    const addEmail = () => {
      setEmailList([...emailList, '']); // 使用扩展运算符创建包含新项的新数组
    };
    

    然后,我们可以条件化地显示“添加更多”按钮:

    {emailList.length < 10 && <button onClick={addEmail}>Add More</button>}
    

组件化与代码组织

随着应用变复杂,将UI拆分为独立、可复用的部分至关重要。这就是“组件化”。

在React中,组件就是一个返回JSX的函数。我们可以将一大块JSX提取出来,变成一个独立的组件。

示例

function Header() {
  return <h1>My Application Header</h1>;
}

function App() {
  return (
    <div>
      <Header /> {/* 使用自定义的 Header 组件 */}
      ...其他内容...
    </div>
  );
}

通过将UI拆分为小的、专注于单一功能的组件,代码会变得更易于管理、测试和复用。我们将在后续课程中深入探讨组件和属性(Props)。


总结

本节课中我们一起学习了ReactJS的核心基础。我们了解了:

  • 声明式UI:通过描述状态与UI的对应关系来构建应用。
  • JSX:一种允许在JavaScript中编写HTML结构的语法。
  • 状态管理:使用 useState 钩子来定义和更新组件内部的状态,并通过事件处理函数触发状态更新。
  • 核心模式:受控组件、条件渲染、列表渲染。
  • 组件化思想:将UI拆分为独立、可复用的部分。

React通过其状态驱动和组件化的模型,为构建复杂、交互式的Web界面提供了一套强大而高效的范式。虽然初学时的概念和语法可能有些陌生,但一旦掌握,它将极大地提升开发效率和代码可维护性。在接下来的课程和作业中,我们将继续探索React更高级的特性。

054:ReactJS useEffect Hook 💥

在本节课中,我们将学习React中一个强大但有时令人困惑的钩子:useEffect。我们将通过构建一个秒表应用来理解它的工作原理、语法以及如何避免常见的陷阱。


概述

useEffect 是React的核心钩子之一,它允许我们在函数组件中执行副作用操作。副作用是指那些与组件渲染结果没有直接关系的操作,例如数据获取、订阅或手动修改DOM。本节课我们将通过一个秒表应用的逐步构建,来掌握 useEffect 的用法。


从基础应用开始

几周前,我们创建了一个基础的React应用,它在页面加载时显示本地格式的日期和时间。这个程序本身并不十分实用。今天,我们将更新它。在本节课结束时,我们将创建一个秒表应用,可以计算自页面加载以来经过的分钟和秒数。

理论上,我们已经拥有了所需的一切。我们有组件,也有允许我们在定时器上运行代码的 setInterval 函数。所以这应该相当简单。让我们进行第一次尝试。


第一次尝试:定义外部变量

以下是一个示例应用,目前还没有数据。让我们尝试在函数组件外部定义一个定时器和一个重置函数,然后添加一些JSX。

let seconds = 0;
let intervalId;

function reset() {
  seconds = 0;
}

intervalId = setInterval(() => {
  seconds++;
}, 1000);

我在这里设置了一个每秒运行一次的定时器,它会使秒数加一。我还定义了一个重置函数,将秒数设回0。现在,我有了逻辑,只需要显示它。所以我在一个 div 中渲染秒数,并添加一个重置按钮。

但是,秒数显示在这里,却没有任何变化。秒数没有增加,我也不确定重置按钮是否起作用,因为秒数已经是0了。


第二次尝试:使用 useState 钩子

我们可以尝试使用 useState 钩子来存储秒数,这在前面的课程中你应该已经熟悉了。

import { useState } from 'react';

function App() {
  const [seconds, setSeconds] = useState(0);

  const intervalId = setInterval(() => {
    setSeconds(seconds + 1);
  }, 1000);

  function reset() {
    setSeconds(0);
  }

  return (
    <div>
      <div>Seconds elapsed: {seconds}</div>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

现在秒数在流逝,很好。我可以重置。但是看看秒数发生了什么变化:我点击重置的次数越多,数字增长得越快。简单解释一下,这里发生的情况是,每次我重置时,setInterval 调用都会被再次执行,这意味着它试图每秒多次增加秒数。


解决方案:使用 useEffect 钩子

让我们尝试使用 useEffect 调用。在这个方法中,我们实际上将 setInterval 放在了 useEffect 函数内部。

import { useState, useEffect } from 'react';

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/7948c9f77b621fec8379dcca8ff91e64_11.png)

function App() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  function reset() {
    setSeconds(0);
  }

  return (
    <div>
      <div>Seconds elapsed: {seconds}</div>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

秒数在流逝,到目前为止看起来不错。让我们试试重置按钮,仍然正常。


理解 useEffect 的作用

我刚刚尝试了一系列方法,但效果都不太好。在函数组件外部定义计数器意味着React实际上无法看到何时有东西被增加。使用 useState 钩子稍好一些,但也好不了多少。它完美地增加了秒数,但当我点击重置时,它重新定义了定时器,导致非常混乱。当我放弃那种方法并使用 useEffect 钩子时,一切都完美运行了。

很明显 useEffect 很有用,但我们还不理解为什么或它是如何工作的。它到底是什么?

useEffect 是一个React钩子。你已经使用了 useState,与 useEffect 配对,这两个钩子是基础性的。没有它们,你基本上无法做任何有意义的事情。简而言之,useEffect 允许我们传入自定义代码,这些代码将在组件中的特定条件下被触发。它使我们能够运行任何我们想要的逻辑,并确保它在组件生命周期的正确时间被触发。

因此,useEffect 是一个非常强大的钩子。但代价是语法有点令人困惑,并且有很多边缘情况需要处理。


useEffect 语法详解

因为语法如此,我们将逐步构建对它的理解。在幻灯片的右侧,你可以看到一个 useEffect 声明。

useEffect 是一个函数,它接受一个或两个参数。useEffect 的第一个参数是你希望被触发的函数。在这个例子中,我们设置了一个定时器,使其大约每秒运行一次。

钩子非常强大,因为你可以将它们组合在一起。所以在这个例子中,我们使用一个状态钩子来存储经过的秒数,然后在定时器内部,我们使用 setSeconds 回调函数将其增加一。


依赖数组的作用

让我们看看使用我们半成品的钩子会发生什么。

我相当确定时间不会过得那么快。所以很明显,我们仍然缺少一些东西。

这就是 useEffect 的第二个参数发挥作用的地方。我们称这个参数为依赖数组。在这个数组中,我们提供了一系列props或状态对象的列表。

我之前提到过,有一组条件将决定你提供给 useEffect 的函数是否运行。因此,如果你的依赖数组中定义的任何引用以任何方式发生变化,那么 useEffect 内部的函数将被触发。如果组件渲染了,但数组中的props和状态没有改变,那么函数就不会触发。

如果你不提供依赖数组,那么该函数将在组件每次渲染后运行。这通常是不希望的。它可能导致无限循环、性能问题,并且其使用场景非常有限。

你可以看到我这里提供了一个空数组。这意味着该效果将在组件首次渲染时运行,但之后永远不会再运行,因为它不依赖于任何props或状态。

区分空数组和根本没有参数非常重要。没有参数意味着没有依赖数组,但空数组意味着它不依赖于任何东西。


清理函数

至此,我们有了一个可用的 useEffect 函数。它调用 setInterval 来设置每秒增加秒数。然而,如果组件从树中被移除或不再渲染,会发生什么?定时器还会运行吗?答案是会的,定时器会一直运行,因为它绑定到了window对象上。

我们需要一种方法来确保当组件不在树上或被移除时,可以清理这个定时器。这就是清理函数的作用。

useEffect 允许我们返回一个回调函数,我们称之为清理函数。在这个例子中,我们用它来清除之前设置的定时器。

清理函数在两种情况下被触发:当组件从树中被移除时,以及在 useEffect 下一次运行之前立即触发。

useEffect 最常见的用例之一是设置订阅。在这个例子中,我们订阅了一个时间间隔。清理函数允许我们在订阅不再相关时取消订阅。


完整的秒表示例

刚才的内容信息量很大。所以让我们看看之前的 useEffect 调用。

你可以看到这里我定义了一个 useEffect 函数。第一个参数是一个函数,第二个是一个空数组,这意味着该函数只会在组件首次渲染后触发。

在函数内部,我们设置了一个定时器,每秒增加我们的存储值。我们还返回了一个清理函数,用于清除之前定义的定时器。每当组件从树中被移除,或者效果函数再次运行时(由于我们添加了空的依赖数组,它不会再次运行),清理函数都会运行。

所有这些都意味着,定时器在组件渲染时被设置,在组件消失时被清除。


扩展应用:添加分钟计数

好的,现在我们有了一个计算经过秒数的应用。我们可以在此基础上扩展,同时存储经过的分钟数。为此,我们将使用一个 useState 钩子来存储我们的分钟数。

你可以看到这里我做了一些更改:我们使用了两个 useState 钩子。重置调用将分钟数设置为0,并且我们同时渲染分钟和秒数。我们仍然需要使用 useEffect 钩子。所以我们在当前的 useEffect 钩子下面添加它。

我们所做的是传入一个函数,规定如果我们有超过60秒,就将秒数重置为0,并将分钟数增加一。

你可以看到,我们的依赖数组中实际上有东西:seconds。每次 seconds 发生变化时,这个效果都会被触发。这意味着当定时器每秒被调用时,它会增加 seconds 变量,这将自动触发下一个效果。

如果你定义了多个同时运行的效果,它们被声明的顺序也是它们被触发的顺序。


最终代码演示

让我们看看。首先,我在一个 useState 钩子中存储我的分钟数。然后我更新重置回调函数,将分钟数和秒数都设置为0。现在,我的效果钩子:当秒数达到60时重置秒数并增加分钟数。我们还需要确保效果钩子在正确的时间被触发,所以我们添加了依赖数组并把 seconds 放进去。我们现在知道,每当 seconds 改变时,该效果就会触发。

现在你可以看到秒数在流逝。当我们达到一分钟时,分钟数应该增加。


总结

就这样,我们成功地创建了一个秒表。如果你愿意,我们将任何其他细节留作你在家完成的练习。同时,我们将更深入地探讨 useEffect 是如何工作的以及它是如何被触发的。

首先,让我们回顾一下React组件中何时会发生渲染。如果一个React组件的props发生变化、其内部状态发生变化,或者树中它上面的组件被重新渲染,那么它就会重新渲染。

如果其数组中的某个依赖项发生突变,或者如果你没有提供数组,那么在每次渲染之后,useEffect 的回调函数都会被调用。函数本身总是在渲染之后直接调用。

人们批评 useEffect 不必要地复杂,但它实际上非常重要,因为它有助于保持我们的函数纯粹。我的意思是,给定相同的props和状态,组件应该总是返回相同的JSX。如果我们没有这一点,那就意味着我们有像副作用这样的东西,这使得保持代码正确和可维护变得非常困难。使用 useEffect 允许我们将自定义代码沙盒化。

我们也将其称为确定性,这也是我们所有状态都需要被React跟踪的相同原因。如果它需要跟踪组件外部的变量,那么我们永远无法真正保证我们的函数组件是确定性的或纯粹的,因为这些变量在它们外部,并且可能随时改变。这就是为什么我们使用 useState 在React循环内部存储变量,以便它们可以被跟踪。同样,useEffect 也是如此,但它不是存储状态,而是允许我们沙盒化和执行自定义代码。

这允许我们做很多事情。我们可以从远程源获取数据,这是 useEffect 非常常见的用例;管理订阅;像我们用秒表那样设置定时器;以及在需要时执行原生DOM操作。


其他用例:获取数据

让我们看看从远程源获取数据。

好的,我在网上找到了一个免费的API,可以给你提供关于猫的随机事实。我喜欢猫,所以这对我来说是个胜利。我将加载状态和猫的事实存储在不同的 useState 钩子中。

useEffect 是一个异步函数。它做的第一件事是将我们的加载状态设置为“加载中”。然后执行获取逻辑。我们获取数据,获取JSON,获取猫事实的数量,并从中提取一个随机的猫事实。最后,我们设置猫事实并将加载状态设置为“成功”。现在,如果我刷新,你可以看到每次都会得到一个随机的猫事实。

还要注意在JSX中,我实际上根据加载状态渲染不同的内容。这是React中一个非常常见的模式。


注意事项和边缘情况

在结束之前,我只想提醒你一些关于 useEffect 可能会让你出错的边缘情况。

当你在 useEffect 中定义依赖数组时,ESLint会自动添加你使用的任何状态或prop,而你对此没有太多发言权。这意味着我们需要小心在效果中读取什么状态。

在第一个例子中,从技术上讲,我正在读取 seconds 变量,所以它会被添加到依赖数组中。但我也在效果中设置它。所以如果我在效果中设置 seconds,而它也是我依赖项的一部分,那么效果将在无限循环中反复触发自己。这就是为什么我向 setSeconds 传递一个函数。

此外,永远记住效果函数只在渲染之后被调用。所以你的组件第一次渲染时,不会有任何效果被触发。如果你期望你的函数在组件渲染之前被调用,这可能会让你措手不及。useLayoutEffect 是解决这个问题的方案。它是与 useEffect 相同的钩子,但它在渲染之前触发。但请注意,在可能的情况下,不建议使用 useLayoutEffect,你应该使用 useEffect 工具。


课程总结

在本节课中,我们一起学习了React的 useEffect 钩子。我们从构建一个简单的秒表应用开始,逐步理解了其核心语法:一个包含副作用逻辑的回调函数和一个可选的依赖数组。我们探讨了依赖数组如何控制副作用的执行时机,以及清理函数如何帮助我们管理资源(如定时器和订阅),避免内存泄漏。最后,我们还了解了 useEffect 在数据获取等常见场景中的应用,并提醒了一些需要注意的边缘情况。

useEffect 是管理React组件副作用的关键工具,熟练掌握它将使你能够构建更健壮、更高效的Web应用。

055:多文件与导入 🛠️

在本节课中,我们将学习如何在JavaScript项目中处理多个文件,以及如何在这些文件之间共享代码。你将了解如何导入内置库、第三方库以及你自己编写的模块。

概述

在软件开发中,很少会将所有代码都写在一个文件里。本节课将介绍JavaScript中多文件项目的组织方式,重点讲解importexport语句的用法,以及它们与C语言中#include的区别。

与C语言的对比

上一节我们提到了模块化的概念,本节中我们来看看JavaScript与C语言在处理多文件时的具体差异。

在C语言中,我们使用#include <stdio.h>来引入标准库。预处理器会将该文件的内容“复制粘贴”到当前文件中,因此我们可以直接使用printf等函数。

JavaScript(以及大多数非C系语言)的做法则更加明确。我们使用import语句来从库中“获取”一个特定的对象或功能。

例如,导入Node.js内置的path库:

import path from 'path';

然后通过path.resolve()来使用其中的函数。这里的path是一个代表整个库的变量。

核心区别:C语言的#include是文本替换,而JavaScript的import是按需获取对象。

导入内置模块

Node.js自带了一系列内置模块,无需通过npm安装即可使用。最常用的两个是fs(文件系统)和path(路径处理)。

以下是使用path.resolve的示例:

import path from 'path';
console.log(path.resolve('./m1'));

这段代码会解析并输出./m1目录的完整绝对路径,功能类似于在命令行中先cd到该目录再执行pwd

导出和导入自定义模块

现在,让我们看看如何在自己创建的文件之间共享代码。

假设我们有一个文件 manyString.js,其中包含一个函数:

function manyString(times, str) {
    let result = '';
    for (let i = 0; i < times; i++) {
        result += str;
    }
    return result;
}
// 导出这个函数,使其可供其他文件使用
export default manyString;

在另一个文件中,我们可以这样导入并使用它:

import manyString from './manyString.js';
console.log(manyString(5, 'hello '));

关键点:一个文件通过export来“发送”代码,另一个文件通过import来“接收”代码。

如果被导入的文件位于不同目录,需要在路径中指明:

import manyString from '../lib/manyString.js';
  • ./ 表示当前目录。
  • ../ 表示上一级目录。

导出多个项目(命名导出)

上一节我们导出了单个函数,本节中我们来看看如何从一个模块中导出多个函数或变量。

当需要导出多个项目时,我们使用命名导出,并省略default关键字。

以下是导出多个函数的方法:

// 方法一:在函数声明前直接导出
export function manyString(times, str) { ... }
export function addBrackets(str) { ... }

// 方法二:在文件末尾统一导出
export { manyString, addBrackets };

在导入时,需要使用花括号{}来指定要导入的项目:

import { manyString, addBrackets } from './myLib.js';

注意:导入时使用的名称必须与导出时完全一致。

默认导出 vs. 命名导出

我们已经见到了两种导出方式,理解它们的区别和适用场景非常重要。

默认导出 (export default):

  • 一个模块只能有一个默认导出。
  • 导入时可以任意命名导入的项目。
  • 例如:import myName from ‘./module.js‘; 这里的myName可以是任何名字。

命名导出 (export { thing }):

  • 一个模块可以有多个命名导出。
  • 导入时必须使用导出时的确切名称(但可以通过“别名”重命名)。
  • 通常更受推荐,因为它更明确,且避免了未来扩展时可能出现的兼容性问题。

命名导出的优势

  1. 明确性:清晰指出导出了什么。
  2. 可扩展性:未来添加新导出项时,不影响现有导入代码的结构。
  3. 避免命名冲突:可以通过as关键字创建别名。

重命名示例:

// 导入时将 `isValid` 重命名为 `dateFuncIsValid`
import { isValid as dateFuncIsValid } from 'date-fns';

代码风格与最佳实践

随着项目变大,良好的代码风格有助于提高可读性。

当从同一个模块导入很多项目时,建议将导入语句分成多行:

import {
    functionA,
    functionB,
    constantC,
    ClassD
} from './largeModule.js';

在编程中,可读性通常比微小的“聪明”优化更重要。现代编译器和解释器非常智能,能够高效处理代码。清晰的代码能让团队成员(包括未来的你)更容易理解和维护。

总结

本节课中我们一起学习了JavaScript中多文件编程的核心机制:

  1. 使用 importexport 在文件间共享代码。
  2. 默认导出用于导出一个主要项目,导入时可自定义名称。
  3. 命名导出用于导出多个项目,导入时需使用对应名称,更推荐使用。
  4. 可以通过 as 关键字解决命名冲突或提高清晰度。
  5. 良好的代码组织和可读性是高质量软件的基础。

记住,虽然掌握语法很重要,但编写清晰、易于他人理解的代码是更重要的长期技能。

056:编码前的产品思考 🍪

在本节课中,我们将学习如何在实际编码之前,从用户体验和用户界面的角度来规划和设计应用。我们将探讨如何理解用户、如何通过低保真原型来规划应用,以及如何利用设计系统来确保界面的一致性和高效性。

理解用户体验与用户界面

上一节我们介绍了课程的整体目标,本节中我们来看看用户体验和用户界面的基本概念。

用户体验是关于应用可用性的更高层次思考。它涉及用户为完成任务所经历的过程、他们的思考方式,以及应用是否允许他们实现目标。这还包括对用户的研究和访谈。

用户界面则更偏向于视觉设计和实现层面。它涉及图形设计、按钮和标题的文案撰写、像素、阴影,以及通过视觉媒介创造应用的品牌和感觉。

在现实世界中,这些职责可能由产品设计师共同承担,但用户体验是整个团队共享的责任。作为前端开发者,理解这些概念非常有价值,即使你不直接设计像素,也能在团队协作中推动项目进展。

为何要制作应用?理解用户与目标

理解了基本概念后,我们需要思考一个根本问题:我们为何要制作应用?

从用户体验的角度看,一个有用的答案是:我们制作应用是为了帮助用户实现他们的目标。这引出了两个核心问题:

  1. 我们的用户是谁?
  2. 他们的目标是什么?

深刻理解这两点是极具挑战性的,但一个好的总结和心智模型能帮助你做出出色的优先级决策、用户体验和界面决策。

理解“谁”:你的用户

理解用户为何重要?因为不同的人有不同的经历和使用过不同的应用。构建产品是一种沟通,需要既让用户感到熟悉,又能出色地帮助他们实现目标。

以下是理解用户的两个关键方面:

  • 用户来自的市场:例如,针对东南亚市场的Grab应用界面比针对欧美市场的Uber更繁忙、功能展示更明显,因为它们的目标用户熟悉不同的交互模式。
  • 用户的心智模型:即用户对事物和工作方式的思考模式。例如,专业设计软件Photoshop使用“图层”概念,而面向大众的Canva则避免使用该术语,以匹配其用户的心智模型。另一个例子是“文件夹”,从技术上讲,一个文件可以存在于多个文件夹,但为了匹配用户“一个文件只能在一个文件夹里”的心智模型,许多系统在用户体验上并不支持此功能。

理解“什么”:用户的目标

理解用户目标同样重要,因为它帮助你进行功能优先级排序。

一个例子是Canva和Google Docs首页的差异。Canva用户更常创建新内容(如Instagram帖子),因此首页更侧重于创建新项目。Google Docs用户则更频繁地编辑现有文档(如论文),因此首页更侧重于查找和管理已有文档。

用户故事:将理解转化为行动

将上述理解融入思考的一个好方法是使用用户故事

用户故事的格式是:作为一个 <角色>, 我想要 <执行某个任务>, 以便于 <实现某个目标>

例如:作为一个通勤者,我想要查看火车时刻表,以便我知道何时离开办公室。

用户故事将工程需求语言(如“显示火车时刻列表”)转化为用户语言,在整个设计过程中保持用户同理心,帮助你从用户角度进行功能优先级排序。

从想法到规划:使用低保真原型

现在我们已经从用户角度审视了应用,接下来需要将模糊的想法转化为更清晰、可构建的方案。这时,原型就非常有用了。

为什么原型有帮助?因为你不会在没有计划的情况下写一篇文章。同样,在没有原型的情况下开发应用也是不明智的。原型比编程更快,能让我们更早地获得反馈。你可以向朋友或目标用户展示原型,测试其是否易于理解、是否符合他们的心智模型和过往经验。一幅原型图胜过千言万语,它能让你测试应用将来如何与用户沟通。

原型可以从两个维度来看:

  1. 范围:涵盖多少内容?可以是一个组件、一个单屏界面,或一个完整的用户操作流程。
  2. 保真度:细节的精细程度。分为低保真原型(如草图、线框图)和高保真原型(像素级完美的设计图)。

作为前端开发者,如果你熟悉CSS和HTML,从低保真原型直接开始构建应用可能比通过高保真原型更高效。本节课我们将聚焦于低保真原型。

创建低保真原型的技巧

以下是创建低保真原型的一些实用方法:

  • 疯狂八分钟法:拿一张纸,快速折叠出八个格子。只用一支笔,在每格中草绘一个解决当前问题的界面想法。目标是快速进行视觉头脑风暴,不求完美。
  • 纸上草图:选择某个想法后,继续用笔在纸上细化,只画出关键元素,无需纠结按钮圆角或阴影等细节。
  • 基于截图绘制:如果你是在现有应用上提议修改,一个有效的方法是将相关截图拼接起来,并用简单的线条和形状标注出你的改动想法。这能非常直观地传达意图。

保持低保真、使用纸笔或将截图组合在画图工具中,都是快速获得反馈、明确应用方向的好方法。

实现规划:利用设计系统

当你有了原型,想法变得更具体后,接下来就是实现。在实现用户界面时,专业设计师也不会每次都从零开始设计每个按钮。用户需要一致性,设计师也需要效率。因此,就像程序员使用代码库和规范一样,设计师使用设计系统

设计系统就像是设计师的“代码库”。许多公司都公开了他们的设计系统(如Google的Material Design、微软的Fluent、苹果的人机界面指南、Atlassian、Shopify等)。大公司内部也都有自己的设计系统。

设计系统包含什么?

设计系统通常包含以下几个核心部分,它们能帮助回答你在创建界面时遇到的许多问题:

  • UI组件库:包含按钮、导航栏、横幅等可复用组件的详细规格和使用指南。例如,Material Design的文档会说明带图标的按钮应该如何布局。
  • 设计与品牌指南:关于颜色、字体、间距等视觉设计要素的规范。
  • 内容指南:关于文案撰写、语气、语法的指导原则。例如,英国政府网站GOV.UK就有非常详细的写作指南。

坚持使用一个设计系统有助于保持应用的一致性,避免用户在不同部分感到困惑。设计系统中的组件可能看起来不那么“酷”,但它们的价值在于让用户专注于任务本身,而不是费力理解新颖的界面。当然,设计系统是指导手册而非法律,有时你也可以根据需求创建自定义组件。

总结

本节课中我们一起学习了编码前进行产品思考的三个关键步骤:

  1. 用户体验基础:如何从用户视角定义问题,思考“用户是谁”和“目标是什么”,并尝试使用用户故事作为 <角色>, 我想要 <任务>, 以便 <目标>)来替代工程需求描述任务。
  2. 使用低保真原型进行规划:如何利用纸上草图疯狂八分钟法基于截图的绘制来快速创建原型,以便在编码前验证想法并获得反馈。
  3. 用户界面与设计系统:为何要选择一个设计系统作为项目参考,而不是每次都重新设计按钮等元素。这能帮助你创建用户易于理解的一致性产品。

下节课,我们将深入探讨设计系统,学习它们如何从技术层面被实现,并介绍一些真实世界中的开源工具。

057:JavaScript代码检查(Linting) 🧹

在本节课中,我们将学习代码检查(Linting)的概念,了解它如何应用于JavaScript,并通过一些示例来演示其工作原理和实际应用。

概述

代码检查是一种用于发现代码中错误的方法。它属于静态代码分析的一种形式,这意味着它分析你编写的代码文本本身,而不尝试编译或运行代码。代码检查有助于避免常见错误或偏离最佳实践,通常作为防护栏,防止那些容易忽略的小错误。

什么是代码检查?

简单来说,代码检查是一种在代码中查找错误的方法。它是静态代码分析的一种变体。静态代码分析将你编写的代码作为输入并原地进行分析,它不尝试编译或运行你的代码,只是查看代码文本来发现常见错误。

代码检查很有用,因为它允许你避免常见错误或偏离最佳实践。它通常用作防护栏,保护你免受那些经常被忽略的小错误的影响。它能有效做到这一点是因为它速度快。检查器运行非常快,并且可以集成到你的编辑器中,以便在错误出现时立即报告。

检测常见错误和速度快这两点结合,意味着代码检查能极大地提高生产力。代码检查器在你编码时有效地引导你,让你更专注于功能,而不是你所使用的编程语言的特性。

然而,代码检查不能替代良好的测试框架。它只能发现与语言相关的错误,无法判断你想要实现什么,也无法检测运行时错误。代码检查不是强制执行良好系统设计或架构的方法,也不是让你的代码工作的灵丹妙药。它本身甚至无法检测你提供错误类型的错误,例如向函数传递数字而不是字符串。它也不是静态分析代码的唯一方法,尽管是最常见的。

JavaScript的历史背景

在我们继续之前,让我们深入了解一些JavaScript的历史。JavaScript因其历史而有一些奇怪的行为,名声有些粗糙。JavaScript的第一个版本由Brendan Eich在大约10天内编写完成,他最初想把它带向一个完全不同的方向。多年来,JavaScript积累了许多人们可能不应该碰的功能和怪癖,早期的JavaScript代码因此常常充满错误且难以维护。

多年来,我们经历了一段JavaScript表现不佳的时期,但它是我们唯一的选择。然后在2008年,《JavaScript语言精粹》一书发布。这本书详细介绍了我们应该使用的JavaScript部分,以及我们可能应该隐藏并忘记的部分。虽然你可能在现实中遇到一些不同意见,但可以说很多现代地道的JavaScript代码都是建立在这本书的基础之上的。

那么,为什么这与代码检查相关呢?在这本书发布六年前,作者Douglas Crockford发布了JS Lint,这是第一个JavaScript代码检查器。在JavaScript中,你会发现代码检查被大量使用。在像JavaScript、Python、Ruby等动态且缺乏类型系统的语言中,这种情况很常见。原因在于没有编译器和类型系统,语言对你编写的内容不是很严格,并试图不阻碍你,但这意味着你有很大的空间犯错误。这在JavaScript中尤其常见,因为该语言有很多不好的部分。代码检查帮助我们确保只使用好的部分。

虽然JS Lint是2002年发布的第一个检查器,但ESLint现在是JavaScript行业标准的代码检查工具。

代码检查如何工作?

代码检查将你的代码作为输入。它被配置为具有一组规则。如果代码的某个区域违反了这些规则,检查器将检测到并报告这些情况。

代码检查规则通常分为两类:可能的错误和简单的最佳实践、风格等。它们可以用参数配置,可以设置为警告你或将其报告为错误。

当你运行检查器时,你可以将其作为命令运行,也可以设置为监视代码中的更改。这意味着当文件系统上发生更改时,它将自动检查并向你报告结果。

听起来很简单,尽管有点模糊。那么代码检查实际上是如何工作的呢?首先,检查器将代码作为原始字符串提供。它解析字符串并生成一个树状结构,我们称之为抽象语法树(AST)。这代表了程序的结构。它就像二叉搜索树,但节点不止两个。

树中的每个节点代表一个代码块,节点还包含有关代码开始和结束的行号和列号的信息。现在,代码检查配置有一个称为规则集的规则列表。每条规则都是一个函数,它接收AST(树状结构)并检查其中违反规则的模式。如果找到任何违规,它会报告它们以及它们开始和结束的位置。

ESLint架构

这是ESLint的架构图,ESLint是最常见的JavaScript代码检查工具。在这里,你可以看到像CLI、源代码、规则、CI引擎等模块。

CLI模块是我们的用户界面,它读取我们的用户输入以决定做什么。我们有规则模块,它定义了我们的规则集。我们的源代码模块是负责解析代码并生成语法树的模块。而检查器接收源代码模块和规则模块,对树执行规则并报告发现的任何错误。

代码检查规则示例

ESLint中有大量可用的规则,但让我们看一些小例子。

no-unreachable 规则阻止你编写无法到达的代码。例如,如果你在return语句之后有代码,它会将其标记为警告或错误。

no-unused-vars 规则阻止你定义一个变量然后在任何地方都不使用它。

no-use-before-define 规则阻止你在代码中定义变量之前引用它。

最后三条规则是关于检测可能的错误。接下来的三条更多是关于最佳实践。

indent 规则强制代码中的一致缩进。

no-var 规则阻止你使用var关键字,强制使用ES6引入的letconst

no-console 规则阻止你使用console上的方法。

如何开始使用代码检查?

幸运的是,你已经有了Create React App,它捆绑了预配置的ESLint安装。

让我们来看一下。这里你可以看到,左边是我的App.js文件,右边是一个名为.eslintrc.json的文件。这是检查器的配置文件,它位于项目的根级别。你可以看到这里我扩展了react-appreact-app是随Create React App捆绑的代码检查配置。

在左边,我已经注释了eslint-disable,这会禁用检查器。我现在移除它。突然,报告了一堆错误。第一个错误是一个警告,说函数已定义但从未使用。我们还有一些错误,说我们的一些变量引用未定义。此外,我们还有一个return语句没有引用正确的变量,以及其后出现的一些无法到达的代码。

首先,让我们通过将函数存储在结果中并在我们的应用程序中渲染它来引用我们的函数。这应该能消除我们函数上的错误,你可以看到在第5行,它消失了。需要注意的是,检查器可以检测错误,但有些错误它不会注意到。例如,我在这里没有引用正确的变量,检查器无法知道我想引用哪个变量,所以需要我手动修复。

我可以修复无法到达的代码错误,只需简单地将for语句移到return之前。你可以看到这里,numberMultiplier在其引用之后定义,所以我们需要做的就是将其向上移动。

很好,我们已经修复了代码检查错误,但我们的函数看起来仍然有点乱,仍然有一些问题。我可以做的是通过添加我自己的规则来扩展我们的代码检查配置。

这里的缩进有点奇怪,所以首先我要添加一个缩进规则。缩进规则说将缩进报告为错误,并寻找两个空格的缩进。你可以看到,当我自动保存时,它自动为我修复了缩进,现在我已经添加了那条规则。

我还可以看到一些使用var的地方,而我更希望使用letconst,所以我将在我们的规则中添加一个错误配置。当我自动保存时,你可以看到我顶部的var引用已被定义为let

这看起来好一点了,但我的一些变量使用下划线而不是驼峰命名法,这不是最佳风格。所以我将在我的规则集中添加一个驼峰命名错误。有时检查器直到我重新加载文件才会报告错误,所以我现在重新加载它。

好了。你可以看到这里,所有我引用或定义使用下划线的变量的实例都被报告为错误。不幸的是,检查器无法为我们修复这个问题,因为它不知道变量名应该是什么。所以我需要手动修复。

好的,命名看起来好一点了,但实际上这个函数中有一个可能的错误与for循环有关。for循环继续的方向是错误的。但幸运的是,有一个代码检查规则可以保护我免受这种错误。通过设置for-direction规则报告错误,然后重新加载,你可以看到我的for循环现在报告循环中的更新子句使变量向错误方向移动。简单地将其改为递增即可修复。

在生产应用程序中,使用console语句也不太合适,所以我要移除它。我可以通过在我的代码检查配置中添加no-console规则来强制执行移除。我需要再次重新加载文件。现在你可以看到这里,有一些与意外console语句相关的错误,所以我将移除任何使用console的东西。现在我有了一个漂亮的函数。

你可以看到,通过向我们的检查器添加一些规则,我们能够强制执行最佳实践、良好风格并防范可能的错误。这是一个任意的例子,但这在大型代码库中带来的差异怎么强调都不为过。

最后需要注意的是,我们需要重新加载文件的原因是因为在这种情况下,检查器集成到了我们的编辑器中。如果我们更改代码检查配置,编辑器不一定会知道,直到我们重新加载文件,这会再次加载检查器。

总结

在本节课中,你学习了代码检查器的一般工作原理,它们如何应用于JavaScript,并且看到了ESLint如何集成到你的编辑器中以提供更好编码体验的演示。

058:React Router Dom 入门指南 🧭

在本节课中,我们将学习如何使用 React Router Dom 库来构建具有多个“页面”的 React 单页应用。我们将解决如何将组件与特定URL关联、如何在不刷新页面的情况下进行导航,以及如何捕获动态URL参数。

概述

传统的多页网站在点击链接时会刷新整个页面。React 单页应用的目标是提供无缝的导航体验,只更新页面中需要变化的部分。React Router Dom 是实现这一目标的流行库。

安装与基础设置

首先,我们需要创建一个新的 React 应用并安装 React Router Dom 库。

以下是安装命令:

npm install react-router-dom

请注意,本教程基于 React Router Dom v6 版本,它与 v5 版本有较大差异。

关联组件与路由

上一节我们安装了库,本节中我们来看看如何将不同的 React 组件映射到不同的 URL 路径上。

核心概念是使用 BrowserRouterRoutesRoute 组件。BrowserRouter 是路由的容器,Routes 定义了路由规则,Route 将路径映射到具体的组件。

以下是基础的路由结构代码:

import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </BrowserRouter>
  );
}

在上面的代码中,我们定义了三个简单的组件:HomeAboutProfile。当用户访问根路径 / 时,会渲染 Home 组件;访问 /about 时,渲染 About 组件,依此类推。

创建导航链接

我们已经将组件与路由关联起来,现在需要一种方式让用户在不同“页面”间跳转。如果使用传统的 <a> 标签,会导致页面完全刷新,这不是我们想要的。

React Router Dom 提供了 Link 组件来解决这个问题。Link 组件在内部处理导航,只更新必要的部分,而不会导致整个页面重新加载。

以下是创建导航栏的步骤:

首先,我们创建一个 Nav 组件。重要:所有使用 React Router Dom 组件(如 Link)的代码,必须位于 BrowserRouter 组件内部。

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

function Nav() {
  return (
    <nav>
      <Link to="/">Home</Link>
      &nbsp;&nbsp;|&nbsp;&nbsp;
      <Link to="/about">About</Link>
      &nbsp;&nbsp;|&nbsp;&nbsp;
      <Link to="/profile">Profile</Link>
    </nav>
  );
}

然后,我们将 Nav 组件放入 App 组件中,确保它在 BrowserRouter 内部,但在 Routes 外部,这样它就会在所有页面上显示。

function App() {
  return (
    <BrowserRouter>
      <Nav />
      <Routes>
        {/* ... 路由定义 ... */}
      </Routes>
    </BrowserRouter>
  );
}

现在,点击这些链接可以在不同组件间无缝切换,浏览器不会完全刷新。

捕获动态路由参数

在实际应用中,我们经常需要根据URL中的信息动态渲染内容,例如用户资料页 /profile/hayden。我们不可能为每个用户都创建一个单独的组件文件。

React Router Dom 允许我们在路径中使用“通配符”(或参数)来匹配动态URL。

我们可以这样定义路由:

<Route path="/profile/:name" element={<Profile />} />

这里的 :name 就是一个参数占位符,可以匹配 /profile/hayden/profile/emily 等路径。

接下来,在 Profile 组件内部,我们需要获取这个 name 参数的值。这需要使用 useParams 钩子。

以下是 Profile 组件的示例代码:

import { useParams } from 'react-router-dom';

function Profile() {
  const params = useParams();
  const name = params.name; // 获取URL中的 :name 部分

  if (!name) {
    return <p>请输入一个名字。</p>;
  }

  return <h1>个人资料:{name}</h1>;
}

useParams() 返回一个对象,其键值对对应于路由定义中的参数(例如 { name: ‘hayden’ })。这样,我们就可以根据不同的URL动态显示内容。

编程式导航

有时我们需要在代码中触发导航,例如表单提交后。虽然可以使用 window.location.href,但这会导致页面刷新。

React Router Dom 提供了 useNavigate 钩子来实现不刷新的编程式导航。

以下是一个带有输入框和按钮的组件示例,点击按钮后导航到对应的个人资料页:

import { useState } from ‘react’;
import { useNavigate } from ‘react-router-dom’;

function NameInput() {
  const [inputName, setInputName] = useState(‘’);
  const navigate = useNavigate(); // 获取导航函数

  const handleGo = () => {
    // 使用 navigate 函数进行导航,不会刷新页面
    navigate(`/profile/${inputName}`);
  };

  return (
    <div>
      <input
        value={inputName}
        onChange={(e) => setInputName(e.target.value)}
      />
      <button onClick={handleGo}>前往</button>
    </div>
  );
}

嵌套路由

React Router Dom v6 支持嵌套路由,这有助于组织具有共同布局或父级路径的页面。

例如,假设我们有 /about/about/team/about/history 这几个页面。我们可以这样组织路由:

<Routes>
  <Route path=“/about” element={<About />}>
    <Route path=“team” element={<AboutTeam />} />
    <Route path=“history” element={<AboutHistory />} />
  </Route>
</Routes>

在父路由组件 About 中,我们需要使用 <Outlet /> 组件来指定子路由组件渲染的位置。

import { Outlet } from ‘react-router-dom’;

function About() {
  return (
    <div>
      <h1>关于我们</h1>
      {/* 子路由组件将在这里渲染 */}
      <Outlet />
    </div>
  );
}

当访问 /about/team 时,About 组件和 AboutTeam 组件会一起渲染,AboutTeam 的内容会出现在 <Outlet /> 的位置。

总结

本节课中我们一起学习了 React Router Dom 的核心功能。

我们首先学习了如何将特定组件关联到 URL 路由,使用 BrowserRouterRoutesRoute 组件搭建应用骨架。

接着,我们探索了如何使用 Link 组件在路由间进行无缝导航,避免页面完全刷新,从而提升用户体验。

然后,我们解决了如何捕获动态 URL 参数的问题,通过 :parameter 语法定义通配符路径,并使用 useParams 钩子在组件中获取这些参数值,以实现动态内容渲染。

此外,我们还介绍了如何使用 useNavigate 钩子进行编程式导航,以及在 v6 版本中如何利用嵌套路由和 <Outlet /> 组件来组织复杂的页面结构

通过掌握这些概念,你现在可以构建具有多个“视图”、支持动态参数且导航流畅的现代 React 单页应用了。

059:在React中使用CSS框架与组件库 🎨

在本节课中,我们将学习如何在React应用中使用CSS框架和组件库,特别是Material UI。这是一种快速构建大规模、美观应用的有效方法。

在React中,有多种方式可以为页面添加样式并组织CSS。今天,我们主要关注一种可能最简单、最快速构建大型应用的方法。

在React中使用CSS的方法

上一节我们提到了React中处理CSS的多种方式。本节中,我们来看看其中两种主要方法。

首先,在默认的React应用中,你会看到类似 import './App.css' 的语句。这是第一种方法,即直接导入样式表,我们在之前的课程中已经介绍过。

今天,我们将直接跳到第二种方法,这是一种高度抽象化、非常简便的方法:使用像Material UI这样的组件库。使用组件库意味着我们无需为按钮、表格、页眉等常见元素编写大量CSS,可以快速完成编码。

引入Material UI组件库

Material UI是一个为React等应用设计的库,可以轻松安装。以下是开始使用它的步骤。

  1. 安装库:访问Material UI官网,复制并执行其提供的安装命令,从NPM安装必要的模块。

    npm install @mui/material @emotion/react @emotion/styled
    
  2. 浏览组件:安装完成后,可以访问Material UI文档,查看其提供的众多预样式化组件,如按钮、复选框、卡片、对话框等。

使用Material UI组件

现在,让我们在一个简单的React页面中实际使用一个组件。

  1. 选择并复制组件:在文档中找到你想要的组件,例如“按钮”。点击“显示源代码”,复制示例代码。
  2. 粘贴并导入:将复制的JSX代码粘贴到你的React组件文件中。同时,确保从Material UI库中导入该组件。
    import Button from '@mui/material/Button';
    
    function MyComponent() {
      return (
        <Button variant="contained">Hello</Button>
      );
    }
    
  3. 自定义组件:现在页面上会出现一个带有Material UI样式的按钮。你可以轻松修改其文本、变体(如containedoutlinedtext)或添加属性(如disabled)来改变其外观和行为。

组件的深入定制与主题

Material UI组件库的强大之处在于其可定制性和主题系统。

  • 修改属性:你可以通过改变组件的属性来调整其外观,例如将按钮颜色改为success(绿色)或warning(橙色)。
  • 理解主题:许多库都有预设的主题,其中颜色等样式通过语义化名称(如primarysecondarysuccessinfo)定义。这有助于保持设计的一致性,并方便日后整体更换主题。

使用更复杂的组件

除了按钮,我们还可以使用更复杂的组件,如对话框。

  1. 复制复杂组件:在文档中找到“对话框”组件,复制其JSX代码和相关的状态逻辑(如useState)。
  2. 处理导入:确保导入所有需要的组件和React钩子。
  3. 整合与简化:将代码粘贴到你的组件中,并根据需要清理和简化逻辑,例如将内联函数提取出来。

组件库的利弊权衡

使用组件库时,需要权衡便利性与定制自由度。

  • 快速实现:使用组件库可以非常快速地实现约80%你期望的视觉效果,几乎不费力气。
  • 定制挑战:当你需要实现一些特殊或精细的定制时(例如在对话框的特定位置添加一个图标或调整内边距),可能会遇到困难。你可能需要深入研究组件的内部结构或使用CSS覆盖,这有时会耗费较多时间。

这符合“80-20法则”:80%的成果可能来自20%的努力,而剩下20%的成果可能需要80%的努力。

条件渲染与组合使用

你可以将Material UI组件与React的条件渲染等功能结合,创建动态的交互界面。

例如,可以仅在对话框打开时显示一个圆形进度条。这展示了如何快速构建功能丰富的界面。

探索与查找资源

在实际开发中,你经常需要查找特定组件的用法或示例。

  • 利用搜索:如果你在官方文档中找不到完全符合需求的示例(例如多级侧边栏导航),可以尝试在网络上搜索,如“navbar material UI”。
  • 参考现有代码:查看使用了Material UI的现有项目(如本课程网站)的源代码,是学习组件实际用法的好方法。

第三种样式方案:Styled Components

除了全局CSS和组件库,还有第三种流行的样式方案:Styled Components。

  • 什么是Styled Components:它是一个CSS-in-JS库,允许你通过JavaScript创建具有样式的组件。
  • 在Material UI中使用:Material UI也提供了自己的styled函数,功能类似。
    import { styled } from '@mui/material/styles';
    import Button from '@mui/material/Button';
    
    const BigButton = styled(Button)({
      width: '200px',
    });
    
    // 使用 <BigButton>Click me</BigButton>
    
  • 优势
    • 作用域化:样式被限定在组件内,避免了全局CSS的污染问题。
    • 可复用与可组合:可以创建自定义的“原始”组件,并能基于现有组件进行样式继承和扩展,非常适合构建自己的设计系统。
    • 动态样式:可以方便地根据组件props应用不同的样式。

内联样式与SX属性

对于微小的样式调整,你可以直接使用内联style属性或Material UI组件的sx属性。

  • style属性:适用于简单的覆盖。
  • sx属性:更强大,可以访问主题(theme)中的值,进行更复杂的响应式设计。
    <Button sx={{ width: 200, color: 'primary.main' }}>Styled with sx</Button>
    
  • 最佳实践建议:如果样式规则超过一两条,建议使用styled函数创建新的组件,以保持代码的整洁和可复用性。

总结

本节课中,我们一起学习了在React应用中使用CSS框架和组件库的核心知识。

我们首先对比了直接导入CSS和使用组件库(如Material UI)两种方法。然后,我们逐步实践了如何安装Material UI、查找并使用其组件(从简单的按钮到复杂的对话框),并探讨了通过属性和主题进行定制。

我们也客观分析了组件库的优缺点,即它能极大提升开发速度,但在深度定制时可能面临挑战。接着,我们介绍了Styled Components这种CSS-in-JS方案,它提供了作用域化、可复用的样式定义方式,是介于全局CSS和全功能组件库之间的灵活选择。最后,我们提到了内联样式和sx属性的使用场景。

掌握这些工具和方法,将帮助你高效地构建出美观且功能完善的React用户界面。

060:useContext Hook 💥

在本节课中,我们将要学习 React 中的 useContext Hook。这是一个使用频率相对较低的 Hook,但在特定场景下非常有用。它主要解决的是如何在组件之间共享状态的问题。

状态管理的三种方式

上一节我们介绍了 useContext 的基本概念,本节中我们来看看在 React 中管理跨组件状态的几种主要方法。

以下是三种常见的方式:

  1. 通过 Props 逐层传递:这是最简单的方法,将状态作为属性(props)从父组件一层层传递给深层嵌套的子组件。
  2. 使用 React Context (useContext):这本质上是一种在 React 应用中创建“全局变量”的较为整洁的方式。
  3. 使用成熟的状态管理库:例如 Redux 或 MobX。它们功能更强大、可扩展性更好,适合大型复杂应用,但学习曲线也更陡峭。本课程不要求掌握这些工具。

通过 Props 传递状态可以解决大部分问题,但有时你确实需要一个类似全局变量的状态管理方案,这时 useContext 就派上用场了。

通过 Props 传递状态

让我们先看一个通过 Props 传递状态的例子,以便理解 useContext 要解决的问题。

假设我们有一个主页面组件 MainPage,它管理着一个计数器状态 counter。我们还有两个子组件 Page1Page2,它们都需要访问这个计数器。

MainPage 组件中,我们这样定义状态并传递给子组件:

// MainPage.jsx
import React, { useState } from 'react';
import Page1 from './Page1';
import Page2 from './Page2';

function MainPage() {
  const [counter, setCounter] = useState(0);

  const handleClick = () => {
    setCounter(counter + 1);
  };

  return (
    <div>
      <button onClick={handleClick}>增加计数器</button>
      <p>当前计数: {counter}</p>
      <Page1 counter={counter} />
      <Page2 counter={counter} />
    </div>
  );
}

然后,在 Page1Page2 组件中,通过 props 接收这个值:

// Page1.jsx
function Page1(props) {
  return <div>页面 1 的计数器: {props.counter}</div>;
}
// Page2.jsx
function Page2(props) {
  return <div>页面 2 的计数器: {props.counter}</div>;
}

这种方法在组件层级不深时工作良好。但是,如果 Page1Page2 嵌套在很多层组件内部,你就需要将 counter 作为 props 穿过每一层,这会使代码变得冗长和难以维护。

使用 useContext Hook

现在,我们来看看如何使用 useContext Hook 来避免“props 逐层透传”的问题。其核心思想是创建一个“上下文”(Context),任何组件都可以直接从这个上下文中读取值,而无需通过中间组件传递。

以下是实现步骤:

1. 创建 Context

首先,创建一个独立的文件来定义 Context 及其初始值。

// context.js
import { createContext } from 'react';

// 1. 使用 createContext 创建一个 Context 对象,并设置默认值
export const MyContext = createContext({
  getters: { counter: 0, var1: '' },
  setters: { setCounter: () => {}, setVar1: () => {} },
});

2. 提供 Context (Provider)

在应用的根组件(如 App.jsx)中,使用 Context 的 Provider 组件包裹你的应用。Providervalue 属性就是你要共享给所有子组件的数据。

// App.jsx
import React, { useState } from 'react';
import { MyContext } from './context';
import MainPage from './MainPage';

function App() {
  // 2. 在顶层组件中定义你想要共享的状态
  const [counter, setCounter] = useState(0);
  const [var1, setVar1] = useState('初始值');

  // 3. 将状态和更新函数组织成一个对象,作为 Provider 的 value
  const contextValue = {
    getters: { counter, var1 },
    setters: { setCounter, setVar1 },
  };

  return (
    // 4. 用 Provider 包裹子组件,并传入 value
    <MyContext.Provider value={contextValue}>
      <MainPage />
    </MyContext.Provider>
  );
}

3. 在子组件中消费 Context (Consumer)

现在,在任何子组件(如 Page1, Page2 甚至 MainPage 内部)中,你都可以使用 useContext Hook 来获取共享的值和函数。

// Page1.jsx
import React, { useContext } from 'react';
import { MyContext } from './context';

function Page1() {
  // 5. 使用 useContext 钩子,传入我们创建的 Context 对象
  const { getters, setters } = useContext(MyContext);

  const handleIncrement = () => {
    setters.setCounter(getters.counter + 1);
  };

  return (
    <div>
      <h2>页面 1</h2>
      <p>从 Context 获取的计数器: {getters.counter}</p>
      <p>从 Context 获取的变量1: {getters.var1}</p>
      <button onClick={handleIncrement}>在页面1增加计数</button>
    </div>
  );
}

Page2 组件可以做完全相同的事情,无需从 MainPage 接收任何 props。

核心机制图解

为了更直观地理解,下图展示了 useContext 的工作流程:

  1. 在根组件(App)中,状态被创建并放入 Context.Providervalue 中。
  2. Provider 像一个“广播站”,将其 value 提供给所有被它包裹的子孙组件。
  3. 任何子孙组件只要调用 useContext(MyContext),就能直接接收到这个 value,实现状态的跨层级共享。

总结

本节课中我们一起学习了 React 的 useContext Hook。

  • 它解决了什么问题:避免了在多层嵌套组件中通过 props 逐层传递状态的繁琐过程。
  • 它的本质是什么:一种在 React 组件树内进行“全局”状态共享的机制,可以看作是更优雅的“全局变量”。
  • 如何使用它:遵循“创建 Context → 提供 Context (Provider) → 消费 Context (useContext)”三步法。
  • 适用场景:适用于主题、用户认证信息、语言偏好等需要被许多组件访问的全局数据。

虽然 useContext 加上 useReducer 可以构建小型应用的状态管理,但对于非常复杂的状态逻辑和大型应用,你可能仍需考虑 Redux 等专业库。不过,对于本课程的学习目标而言,掌握 useContext 已经足够让你应对许多常见的状态共享需求了。

061:状态管理入门指南 🚀

在本节课中,我们将要学习React应用中的状态管理。我们将从基本概念和术语开始,解释为什么状态管理很重要,然后通过实际例子对比正式的状态管理方案与使用本地存储的区别。最后,我们会总结核心要点。

什么是应用状态?

每一个交互式应用都涉及对事件的响应,例如用户点击按钮时侧边栏关闭,或者有人发送消息时聊天窗口出现新消息。当这些事件发生时,应用会更新以反映它们。我们称之为更新应用状态。应用看起来与之前不同,或者因为此事件而进入新的模式。

在编程术语中,像侧边栏是否打开、聊天框中的消息等,都是状态的片段。你的应用中可能有一个 isSidebarOpen 变量设置为 true,以及一个包含已接收消息的 chatMessages 数组。在任何时刻,这些数据的总和构成了你的应用状态,进而决定了你的应用应如何呈现给用户。所有这些单独的变量,无论是存储在React组件状态、本地存储还是像Redux这样的第三方状态管理库中,都是你的应用状态。

什么是状态管理?

状态管理是存储、分发到组件以及修改这些应用数据的方法。正如前面提到的,没有单一的正确方法,每个系统都有其优缺点,我们稍后会讨论。

React提供了一套强大的函数和钩子工具包来管理组件级别的状态,例如函数式组件中的 useState 或类组件中的 this.state。这些工具非常适合存储组件级别的数据,例如用户选择的列表项或用户输入但尚未提交的内容。这是组件级别的数据,因为其他组件不需要知道它。

另一方面,不包含在单个或少量组件中的数据很可能是应用级别的状态,应该由更专业的解决方案来管理。当多个工程师、团队甚至整个工程组织在同一个代码库上工作时,这种区分变得尤为重要,它能显著降低处理数据的开销。

组件状态 vs. 应用状态

状态管理使得在不相关的组件之间共享应用状态变得更加容易。例如,用户是否已认证或他们的主题设置等数据,需要影响大量其他不相关的组件,因此这绝对是应用级别的状态。

截至撰写本文时,我们在Canva有数百名前端工程师在同一个React应用上工作。如果没有适当的状态管理,开发人员数量超过几个后,项目将无法扩展。

另外,一个重要的注意事项是:应用级别的状态有时被称为全局状态,因为它是应用的全局状态;而组件级别的状态被称为局部状态。

回顾总结

组件级别或局部状态被限制在一个组件或一小部分相关组件内。例如,尚未提交的输入文本、用户聚焦的列表项、下拉菜单是否打开。这些对更广泛的应用来说无关紧要。

另一方面,应用级别状态或全局状态是多个不相关组件或每个组件都需要的数据。例如:用户是否登录、主题设置、用户所在的页面以及路由信息等。

一个具体的数据分类示例

假设你有一个具有额外复杂性的待办事项应用。每个待办事项都有一组与之关联的标签,例如“大学”、“个人”和“COMP6080”,以及一个截止日期。在JSON中,它可能看起来像右边这样。

这个应用的独特之处在于,你可以同时显示多个列表,每个列表有不同的过滤器。也许一个列表显示下周到期的任务,另一个显示“大学”标签的任务,最后一个专门显示“COMP6080”标签的任务。

那么,你认为这些数据中哪些部分应该存储在组件中?哪些部分属于应用级别状态?

个人而言,我会从那些明显属于其中一类的情况开始。在这个例子中,我会将待办事项列表放在应用状态中,因为它需要被所有列表以及页头等其他组件访问。

如果我们允许用户自定义每个列表的过滤器,我会将这些未保存的更改状态放在组件级别,因为整个应用不需要知道用户尚未完成编辑的状态。不过,也许全局应用需要知道有东西正在被编辑,以确保用户在离开页面之前保存或丢弃更改。

这个例子的难点在于存储列表本身的状态,例如应用的过滤器和可能的标题。这里没有真正正确的方法,只有权衡。个人而言,我会倾向于将这些存储在应用级别状态中,因为它不会频繁更新,应用需要知道有多少个列表,并且我们很可能希望跨多次页面加载持久化列表及其过滤器。这在应用级别状态中更容易实现。

与此相反的做法是将其放入组件状态,这在短期内会更直接,但从长远来看难以维护和扩展。哪种方案适合你,取决于你愿意接受哪些权衡。假设你是一名大学生,正在做一个作业,其未来功能没有不确定性,因为代码在提交日期后就不会再有未来。假设你知道数据不需要跨页面加载持久化,那么将数据放入组件状态可能很有意义,因为解决方案的简单性更有益,尤其是在时间压力下。

介绍Redux

今天我将讨论Redux,但市面上有许多公共的状态管理库,不幸的是,还有更多自定义的内部库。幸运的是,一旦你了解了一个,大多数都一目了然。开发者不需要了解一个以上,而是理解可以普遍应用的基本概念。

什么是Redux?

Redux的核心是在一个集中式的存储中存储全局应用状态,并以可预测的方式修改它。它严重依赖函数式编程范式,例如不可变性。它有三个基本方面:存储、Reducer和Action。

  • 存储 是任何给定时间点的应用状态。
  • Action 只是普通的JavaScript对象,用于描述事件或更改,例如“用户向账户添加10”。
  • Reducer 是函数,它接收状态和Action作为参数,并在该Action之后返回一个新的状态。

总结:Action触发Reducer来修改存储,只有Reducer可以改变存储。

以下是代码中的样子:

// 初始存储
const initialStore = {
  count: 0
};

// Action
const incrementAction = {
  type: 'INCREMENT',
  payload: 10
};

// Reducer
function counterReducer(state = initialStore, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + action.payload };
    default:
      return state;
  }
}

你可以看到初始存储只是一个包含数字的计数。这可能是账户余额,也可以是任何可以存储在JSON中的数据。Action只是一个类型和一个负载。类型几乎总是存在于每个Action中,作为Reducer的区分方法。在Reducer中,我们只需判断这是什么类型。如果是增量类型,我们就执行相应操作,而状态中的其他所有内容保持不变。由于在更复杂的应用中可以有多个Reducer,我们总是有这个默认路径,直接返回未更改的状态,因为这个Reducer不必关心每一个Action。

Redux的优势与注意事项

Redux的优势之一是状态是可预测且完全可审计的,因为它只能由Reducer编辑。对于开发者来说,调试也可能更直接,因为开发者工具会记录页面上的所有Action,从而允许开发者通过跳转到Action列表中的特定点来进行时间旅行调试。到达该点后,通过重新计算直到该点的状态并忽略后续的Action,我们可以有效地将应用状态移动到那个时间点,从而更新整个页面,或者至少更新所有依赖于此应用级别数据的部分。这不会改变组件级别的状态,组件状态将保持不变,这在调试时可能导致问题,因为应用级别状态可能与组件状态不再匹配。

Redux中最常见的陷阱之一是在存储中放入太多东西,因为它设计用于全局和应用级别状态,而不是整个状态。这是一个非常微妙的平衡,过度使用会导致性能问题。除此之外,它还会在创建原本简单的功能时带来显著的开销。即使性能问题可以缓解,这里有一些通用指南来帮助回答这个问题:

以下是判断数据是否应放入全局状态的一些指导性问题:

  • 应用的多个部分是否需要关心这些数据?
  • 我们是否需要能够基于原始数据创建彻底的派生数据,例如之前我们从单个全局待办事项列表生成多个待办事项列表?
  • 这些数据是否用于驱动多个组件?
  • 通过时间旅行调试将状态恢复到给定时间点是否有价值?
  • 你想缓存或持久化这些数据吗?

如果你对其中任何一个问题的回答是“可能”,那么答案可能就是“是”。如果你对多个或所有问题的回答是“是”,那么答案几乎肯定是“是”。

关于本地存储的讨论

那么,使用本地存储或将某些东西放在全局 window 对象中呢?虽然这些是问题的有效且直接的解决方案,但它们也有自己的权衡。最值得注意的是,使用本地存储时,无论你是否愿意,或者是否实际可行,你都在跨页面刷新持久化数据。如果应用最终形式只由开发者运行,而开发者可以使用开发者工具清除本地存储,那么这不是问题。但当代码发生变化时,问题就来了,开发者突然需要考虑最终用户机器上的本地存储中已经有什么。

假设在你的应用版本一中,你将一段HTML保存在本地存储中,可能来自某人在博客上的草稿评论或类似内容。一切正常,因为你的应用知道它将是HTML并会正确显示。但后来你想换成纯文本,因为安全考虑或其他原因。现在应用期望的是纯文本。如果它从本地存储加载HTML,它应该怎么做?你现在必须考虑到这种可能性,否则你的应用在向返回的用户显示HTML作为纯文本时会看起来是坏的。很有可能大多数返回的用户不会认出HTML是什么,只会认为你的应用坏了。

持久化数据是一个复杂的问题,从长远来看会带来相当多的复杂性,因此应该只在必要时使用。

本地存储的另一个潜在问题是它是针对整个网站的,而不仅仅是一个页面或特定系统。例如,假设主页在一个键下保存了一段数据,即某人所在的标签页ID。我们称这个键为 tabId。而咨询页面也有类似的机制,同样保存在 tabId 下。这几乎最终会导致它们发生冲突。假设咨询页面将 tabId 设置为6,但主页上只有3个标签页。这至少会使主页进入无效状态,最坏情况下会导致崩溃。显然,你可以做一些聪明的事情来缓解这个问题,但这要求项目中的每个开发者都知道其他开发者正在使用的每一个键。

归根结底,对于一个大学作业来说,使用本地存储是可能的,可能行得通。但在我看来,要让每个开发者都跟踪应用曾经设置过的每一个键,即使对于一个为期一个月的单人项目来说也太困难了,特别是如果存在向最终用户的迭代部署。对于一个小团队或更大的团队来说,这几乎是不可能的。

总结

本节课中我们一起学习了React状态管理的核心概念。状态管理对于构建用户可以成功交互且开发者可以长期维护的网站至关重要。它允许开发者在整个应用中创建复杂的交互性。良好的状态管理使得在保持可维护性的同时扩展开发人员数量成为可能。有许多像Redux这样专门用于状态管理的资源,它们在科技行业中被广泛使用并经过充分测试。

这是一个重要的主题,网上有更多资源可用。特别推荐Redux文档网站 redux.js.org,他们在这个主题上有一些详尽的文档和教程。希望这节课对你有帮助。

062:ReactJS 类组件 💥

在本节课中,我们将学习 React 中的类组件。虽然现代 React 开发主要使用函数式组件,但理解类组件对于阅读遗留代码和网络资源仍然至关重要。我们将通过对比函数式组件和类组件,来理解它们的基本结构和核心概念。

概述

React 提供了两种创建组件的方式:类组件函数式组件。函数式组件是现代 React 开发的主流,因其简洁性而广受欢迎。然而,互联网上仍存在大量使用类组件的旧项目和教程。本节课的目标是帮助你理解类组件的语法和逻辑,以便你能读懂并转换它们。

函数式组件回顾

在深入类组件之前,我们先快速回顾一下函数式组件。以下是一个简单的函数式组件示例:

function App() {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);

  useEffect(() => {
    console.log('Name updated:', name);
  }, [name]);

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={age} onChange={(e) => setAge(e.target.value)} />
      <p>Name: {name}, Age: {age}</p>
    </div>
  );
}

这个组件管理两个状态(nameage),并使用 useEffect 钩子在 name 变化时执行副作用。它返回要渲染的 JSX。

类组件基础

现在,我们来看看如何用类组件实现相同的功能。类组件使用 ES6 的 class 语法,并继承自 React.Component

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      name: '',
      age: 0
    };
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.name !== this.state.name) {
      console.log('Name updated:', this.state.name);
    }
  }

  render() {
    return (
      <div>
        <input
          value={this.state.name}
          onChange={(e) => this.setState({ ...this.state, name: e.target.value })}
        />
        <input
          value={this.state.age}
          onChange={(e) => this.setState({ ...this.state, age: e.target.value })}
        />
        <p>Name: {this.state.name}, Age: {this.state.age}</p>
      </div>
    );
  }
}

核心概念对比

上一节我们介绍了类组件的基本结构,本节我们来详细对比几个核心概念。

1. 组件定义

  • 函数式组件:是一个 JavaScript 函数。
    function App() { ... }
    
  • 类组件:是一个继承自 React.Component 的 ES6 类。
    class App extends React.Component { ... }
    

2. 状态管理

  • 函数式组件:使用 useState 钩子。每个状态变量都有独立的设置函数。
    const [name, setName] = useState('');
    const [age, setAge] = useState(0);
    
  • 类组件:在 constructor 中初始化一个名为 state 的对象。使用 this.setState 方法来更新整个状态对象。
    constructor(props) {
      super(props);
      this.state = { name: '', age: 0 };
    }
    // 更新状态
    this.setState({ ...this.state, name: newName });
    

3. 生命周期与副作用

  • 函数式组件:使用 useEffect 钩子来执行副作用,并可以指定依赖项。
    useEffect(() => { console.log(name); }, [name]);
    
  • 类组件:使用特定的生命周期方法,如 componentDidUpdate。需要手动比较前后状态。
    componentDidUpdate(prevProps, prevState) {
      if (prevState.name !== this.state.name) {
        console.log('Name updated:', this.state.name);
      }
    }
    

4. 渲染内容

  • 函数式组件:直接返回 JSX。
    return <div>...</div>;
    
  • 类组件:在 render 方法中返回 JSX。
    render() {
      return <div>...</div>;
    }
    

5. 访问状态与属性

  • 函数式组件:直接使用状态变量名。
    <p>{name}</p>
    
  • 类组件:通过 this.statethis.props 访问。
    <p>{this.state.name}</p>
    

为什么类组件不再流行?

通过以上对比,我们可以看出类组件的主要缺点:

  1. 代码冗长:需要更多的样板代码(如 constructor, render, super(props))。
  2. 逻辑复杂:状态管理集中在单个对象中,更新时需要处理整个对象(使用扩展运算符 ...),容易出错。
  3. 生命周期方法繁琐:副作用逻辑分散在不同的方法中(如 componentDidMount, componentDidUpdate, componentWillUnmount),不如 useEffect 集中和清晰。
  4. this 绑定问题:在事件处理程序中需要小心处理 this 的指向,通常需要在构造函数中绑定或使用箭头函数。

函数式组件配合 Hooks API,提供了更简洁、更模块化且更易于理解和测试的代码结构。

总结

本节课中,我们一起学习了 React 的类组件。我们通过一个具体的例子,对比了函数式组件和类组件在定义、状态管理、副作用处理和渲染方面的不同。虽然类组件正在逐渐被函数式组件取代,但理解它们对于维护旧代码和阅读广泛的网络资源仍然非常重要。现在,当你遇到一个类组件时,你应该能够理解其结构,并知道如何将其转换为更现代、更简洁的函数式组件。核心要点是:两者功能等价,只是语法和代码组织方式不同

063:产品可用性测试 🍪

在本节课中,我们将要学习如何获取用户反馈,这是前端开发中至关重要的一环。我们将探讨反馈的重要性、不同类型的反馈,并深入介绍两种核心的反馈收集方法:可用性测试和应用数据分析。

反馈的重要性

上一节我们介绍了反馈是前端开发不可或缺的一部分。本节中我们来看看为什么反馈如此重要。

前端开发的核心是为用户构建应用。我们无法构建出优秀的应用,除非用户认为它优秀。我们无法让它变得优秀,除非我们知道用户喜欢什么。因此,反馈是前端开发中一个真正不可或缺的部分。

在大型团队中,前端开发者通常不直接负责收集反馈,这项工作可能由设计师或专门的用户体验研究员完成。然而,反馈与前端开发的目标高度一致,并且紧密相关。反馈会影响需求设定,在一个健康的组织中,它会决定你要构建什么。它让人们能基于反馈做出更好的决策,也让前端开发者能在日常编程中做出更好的决策。例如,如果你知道某个功能使用率很高,你就会更关注其错误或边界情况。此外,前端开发者也经常被要求协助收集反馈,例如构建原型、快速模拟功能以获取反馈,或者发送分析事件来跟踪按钮点击等。

反馈的类型

了解了反馈的重要性后,我们需要知道反馈有多种类型。根据你处于产品流程的哪个阶段以及你想了解什么,选择合适的反馈类型至关重要。

以下是反馈的几个关键维度:

  • 定性反馈 vs. 定量反馈:定性反馈关乎感受和体验,例如用户是否觉得某个功能易于理解。定量反馈则关乎数字,例如某个功能被使用了多少次。
  • 样本范围:有时你需要从所有用户那里获取反馈(例如关于收入的数据),有时你只需要一个小样本进行快速实验。
  • 应用要求:有些反馈(如应用内分析)只能在应用构建完成后收集。而另一些反馈(如对原型的可用性测试)则可以在开发前进行,只需要设计草图。

反馈收集方法概览

基于上述不同的反馈类型,值得考虑有哪些不同的收集方法。反馈收集方法多种多样,我们无法一一涵盖,但了解各种选项有助于你根据应用开发阶段和需求选择合适的方法。

以下是一些常见的反馈收集方法,横轴表示是否需要已开发的应用,纵轴表示样本范围:

  • 开发前,大样本:市场研究。
  • 开发前,小样本:调查、访谈、可用性测试。
  • 发布后/开发中,小样本:应用内可用性测试、应用内反馈提示(如评分、反馈表单)、支持工单分析、用户会话录制。
  • 发布后/开发中,大样本:应用分析、业务指标(如收入)。

可用性测试 🧪

现在,让我们深入探讨第一种核心方法:可用性测试。首先,我们需要理解什么是“可用性”。

什么是可用性?

可用性简单来说就是应用是否“可用”。其关键要素包括:用户能否理解这个应用?用户能否在应用中完成他们的任务?用户使用应用是否高效?用户对体验是否满意?

什么是可用性测试?

从可用性的定义出发,可用性测试就是一种测试应用是否可用的方法。它通常包含三个主要元素:用户任务协调者

  • 用户:参与测试的研究对象。你需要寻找能代表目标受众的用户,并要求他们在测试过程中“出声思考”,以便了解他们的想法。
  • 任务:你需要测试的具体活动。任务必须代表你希望应用完成的核心功能,并且对用户来说感觉自然、真实。例如,测试图形设计软件Canva时,应该要求用户设计海报,而不是寻找食谱。
  • 协调者:运行测试会话的人。协调者引导用户完成任务,记录反馈和观察结果。协调方式可以是同步的(一对一,实时),也可以是异步的。

可用性测试实例:Canva Magic Resize

让我们看一个来自Canva的真实异步可用性测试例子。目标是改进“Magic Resize”功能,使其更易用。

第一步:设计任务
我们编写了模糊的任务场景,不提及具体界面元素,以观察用户的自然思考过程。

场景:你是一名使用Canva为多个社交媒体平台创建设计的营销人员。
步骤1:请选择一个模板或创建自己的模板来创建一个Instagram帖子,然后进行下一步。
步骤2:现在调整你的设计尺寸,以便在Facebook、Twitter和Pinterest上发布。

第二步:招募用户
我们通过UserTesting.com服务招募了5名用户参与测试。5是一个行业公认的合理数字,能在样本多样性和分析工作量之间取得平衡。

第三步:分析结果
用户录制他们的屏幕并发送回视频。我们观看录像,记录每位用户是否能成功完成每个步骤,并总结他们遇到的障碍和问题。例如,第一位用户因为没看到弹窗提示而迷失了方向。从这5名用户身上,我们可以洞察到真实用户可能遇到的困难,并据此得出结论和改进见解。

协调测试与非协调测试

上述例子是非协调测试。用户独立完成任务并录制视频,协调者事后分析。这种方法相对简单,适合测试具体的任务流程。

协调测试则不同,用户和协调者实时在一起(线上或线下)。协调者可以提出更多后续问题,深入挖掘用户想法。这在产品早期、功能概念比较模糊、需要探索性反馈时非常有用。但协调测试对协调者要求更高,需要克制住解释设计或帮助用户的冲动,真正做到倾听和观察。

应用数据分析 📊

接下来,我们转向另一种完全不同的反馈收集方法:数据分析。与通过访谈了解用户主观感受不同,数据分析通过观察用户的实际行为来回答问题。

数据分析 vs. 可用性测试

可用性测试让我们能深入探索,发现用户遇到的新问题和新痛点。而数据分析更适合回答我们已有的具体问题,尤其是关于“多少”而非“什么”的问题。例如,它可以告诉我们某个事件发生的频率是否超出预期,但在发现全新问题方面可能不如直接观察用户有效。

数据分析流程

数据分析反馈流程通常如下:

  1. 应用指示:在前端代码中发送分析事件。
  2. 事件存储:分析事件被存储在数据库或数据仓库中。
  3. 运行查询:使用SQL等语言对数据库进行查询。
  4. 计算指标:从查询结果中计算出度量指标。
  5. 分析洞察:分析这些指标,获得洞察并产生新的应用改进想法。

在现代科技公司中,数据基础设施可能非常复杂,涉及多种数据源和平台。我们这里只关注与前端开发者最相关、最常接触的这部分:应用内分析收集。

常见指标与使用

在收集数据之前,需要知道我们想计算什么指标。指标是对原始数据的聚合计算,用于衡量某些方面。

以下是一些常见指标:

  • 计数:页面访问次数、点击按钮的独立用户数。
  • 时长:会话持续时间。
  • 比率:跳出率(用户快速离开页面的百分比)、激活率(完成关键操作的用户比例,如银行应用中存入第一笔钱)。

我们可以通过比较指标来获得洞察:

  • 跨版本比较(A/B测试):比较应用不同版本(如红色按钮 vs. 蓝色按钮)的同一指标,看哪个版本表现更好。
  • 指标间比较:例如,发现20%的用户点击了一个无效按钮,这可能提示按钮位置或设计有问题。

前端如何收集数据

作为前端开发者,我们通常通过集成分析库来与指标系统交互。例如,使用 analytics.js 这样的库来跟踪事件。

一个事件记录某件事情发生了。例如,在一个搜索功能中:

// 当搜索结果展示时
analytics.track('search_result_shown', { resultId: '123' });
// 当用户点击搜索结果时
analytics.track('search_result_clicked', { resultId: '123' });

这些库会向数据管道中的服务器发送HTTP请求,服务器最终将事件以简单的格式(如时间戳、用户ID、事件名称、事件属性)存储到数据仓库中。

伦理与法律考量:在存储这些事件数据时,必须考虑伦理和法律问题。在许多国家和地区(如欧盟的GDPR),法律要求提供让用户删除其个人数据的途径。存储个人身份信息也需谨慎评估其合理性。

数据仓库通常使用针对在线分析处理(OLAP) 优化的数据库(如Snowflake、Google BigQuery、Amazon Redshift),它们擅长处理海量数据的高吞吐量查询,这与我们熟悉的用于低延迟事务处理(OLTP)的数据库(如PostgreSQL、MySQL)不同。

总结

本节课中我们一起学习了用户反馈的世界。

反馈非常令人兴奋,它有多种多样的类型和收集方法。了解这些选项,能帮助你在应用开发过程的不同阶段选择合适的反馈方式。

  • 可用性测试允许我们深入观察一小部分用户,非常适合发现新的、未曾预料到的问题,侧重于理解“发生了什么”和“为什么”。
  • 应用数据分析则需要在应用构建后才能进行,它让我们能够观察所有用户的行为,理解问题的规模,并通过A/B测试等方式比较细微差异以进行优化,侧重于“有多少”和“影响多大”。

掌握这些方法,将帮助你更好地理解用户,从而构建出更优秀、更受欢迎的前端应用。

064:组件库与Figma 🍪

在本节课中,我们将学习组件库的概念,了解设计师如何使用Figma等工具构建组件库,并探讨作为前端工程师如何实现自己的组件库。我们还将介绍一个强大的工具——Storybook,它能帮助我们高效地开发和测试组件。

什么是组件库?🧩

上一节我们介绍了设计系统,本节中我们来看看其日常实践的核心——组件库。正如建筑工人需要维护工具一样,前端工程师也需要创建自己的工具来完成工作,这意味着我们需要创建可以在整个应用程序中复用的共享UI组件,即组件库。

组件是用户界面中可以组合在一起以构建应用程序的小片段。例如,警报框、按钮、滑块、徽章和卡片都是组件。在日常生活中,你与无数组件进行交互。

我们通常不希望每次构建新页面时都从头开始重新制作一个按钮,因此我们使用组件库。组件库允许我们在一个地方定义按钮的外观,然后在整个应用程序中重复使用它。

需要特别注意的是,组件库不仅仅是开发者需要考虑的事情。重用和创建一致性的理念对用户界面设计至关重要。一个优秀的用户界面不会让用户感到困惑。你不希望用户在每个屏幕上都猜测“这是按钮吗?”,而是希望在整个用户界面中保持一致性。

因此,设计师也经常拥有组件库。对设计师而言,组件库是他们可以重复使用的图形元素集合。例如,在Canva设计一个新应用程序的模型时,我不必重新设计按钮的外观,只需从我的库中抓取一个按钮,放入应用程序中即可开始使用。

对于前端工程师来说,组件库是代码片段。如果我需要一个按钮,我可以从UI库导入一个按钮组件,例如一个React组件,然后直接使用它,而无需考虑样式问题。

设计师如何理解组件库?🎨

为了真正理解组件库,我们需要退一步,了解用户界面设计师如何创建他们的设计,以及组件在这个过程中扮演的角色。

如果我们查看流行的用户界面设计应用程序,如Sketch、Figma或Adobe XD,会发现它们与我们熟悉的应用程序(如用于编辑栅格图像的Photoshop和用于编辑矢量图像的Illustrator)不同。用户体验和用户界面设计通常使用矢量图像,它们由路径而非像素组成,这使得设计可以调整大小。

然而,用户界面设计师使用的应用程序通常与标准的矢量应用程序(如Adobe Illustrator或Inkscape)不同。这些标准应用程序非常适合绘制精美的漩涡路径和为杂志创建插图。但作为用户界面设计师,你通常不需要漩涡状的卡片。用户界面设计通常是直线、文本和一些示例图像。

因此,存在一类不同的应用程序,它们同样是矢量工具,但具有专门为创建用户界面而定制的功能。这些功能通常以样式和组件的形式出现。今天我们将快速了解这两者。我将使用Figma的术语,不同的工具可能有不同的叫法(例如,Figma中的组件在Sketch中被称为符号),但概念是相同的。

在Figma中,有样式的概念。样式类似于变量。你可以有一个颜色样式,在共享库中定义,例如“这是Canva蓝色”、“这是Google蓝色”等,然后在整个设计中重复使用这些颜色,而无需到处记住十六进制代码。这有点像编程中的变量。

此外,还有组件的概念。在React术语中,组件是HTML元素的集合,并带有一些创建它们的代码条件。在Figma术语中,组件非常相似,它们是图形元素的集合(虽然没有代码)。但就像React中按钮因为由相同代码生成而相互关联一样,在Figma中,如果你创建一个组件,那么每次复制、粘贴或复制该组件时,所有实例都保持链接在一起。

这意味着如果你编辑组件的原始版本,该组件的所有实例都会更新你所做的更改。这使你能够创建一致性,同时也为设计师提供了灵活性。例如,在设计一个复杂应用程序时,我可能不想先设计按钮,或者在设计完成后,客户可能说“我喜欢它,但我不喜欢阴影的厚度”。我不希望必须单独回去更改每个实例,而是希望能够切换设计基础组件和应用程序,并让所有内容流畅地更新。

为了举例说明,我将使用Figma进行一个快速演示。

Figma组件演示 📐

演示场景:我正在为一个类似LinkedIn的求职网站设计一个移动应用程序。这个移动应用程序的一个重要组件将是我可以点击的列表项。

首先,我在Figma中草绘这个组件。我画一个矩形,使用网站上的样式。例如,我选择预设的“单色白色”样式,而不是冒险使用错误的白色阴影。然后我添加阴影和边框半径。

接下来,我添加一些文本,同样使用样式。我添加一个大标题和一个中等标题,例如职位名称和职位地点。这样,我就有了一个希望在应用程序中使用的组件。

目前,这只是一个组。如果我复制这个组,会得到另一个组,我可以拖动和编辑它,但这两个组之间没有关联。这不是一个组件。

但是,如果我首先点击“创建组件”按钮,它会变成紫色,表示它是一个组件。然后我可以复制这个组件,并在我的模型中使用它。例如,我设置一个包含许多职位的职位页面模型。

现在,如果我去修改原始组件(例如,调整徽标圆圈的大小),我们会看到所有组件实例都更新了。这就是我所说的“链接”。我可以在左侧的原始组件中移动它,右侧的所有实例都会更新。这非常有用。

组件的另一个很酷的功能是它们允许一定程度的灵活性。在这个职位列表中,我可能想展示不同的职位。例如,一个可能是Canva的职位,另一个可能是其他公司的职位。我可以编辑组件的某些部分(如职位名称),但如果我更改了原始组件的其他部分(例如,决定让标题更突出),那些未被覆盖的属性(如职位名称的颜色)仍然会更新。

设计师使用的另一种技术是显示/隐藏。例如,在我的组件中可能有一个“新”徽章。我可以在实例中显示或隐藏这个徽章。这样,我以后可以编辑“新”徽章(例如,让它更大),所有带有该徽章的实例都会更新,而不会影响那些隐藏了徽章的实例。

这就是组件。它们让我作为用户界面设计师能够在开始使用某些东西之后进行更改,这为我的设计过程提供了更多自由,让我可以更灵活地在设计小部分和整体应用之间来回切换,从而更快地设计应用程序,因为我不必一次性把所有组件的设计都做对且永不更改。这更加灵活,也使我作为设计师更有效率,能创建更一致的用户界面。

这也意味着,任何为你设计用户界面的人可能也在以组件的方式思考。作为设计师,你需要做出决定:例如,“新”按钮应该是一个独立的组件吗?什么太大而不能成为一个组件?职位标题的单个文本可能太小而不能成为组件,因为重用它的意义不大。那么,什么是正确的抽象级别?

这在很多方面实际上与开发者创建组件库时需要做的决策非常相似。你需要决定什么是一个组件,什么只是一些不需要放入库中、不值得在多个地方重用、只需在需要时复制粘贴的用户界面。

这个故事的寓意是:你的设计师正在经历类似的事情。因此,你可以参考他们的做法来获得灵感。

开源组件库示例 📚

为了举例说明,我想分享一个内置在Figma中的组件库示例。这里有一个漂亮的组件库,展示了组件可能包含的内容。我们看到有不同的输入框、不同的按钮、不同的复选框、不同的加载内容等。这非常有帮助,因为这意味着如果我想为某些东西创建新设计,只需拖放搜索框、拖放其他组件,非常简单。这使我作为设计师的生活更轻松。

以同样的方式思考,我们希望通过拖放组件来创建组件库,使我们作为前端工程师的生活更轻松。

如何实现组件库?⚙️

既然我们知道有一些组件,并且想要重用它们,那么作为前端工程师,我们该如何做呢?

我认为实现组件库主要有两种策略。第一种是CSS策略,第二种是使用你当前框架(如React)的策略。

在CSS策略中,例如Bootstrap或Materialize,会提供一个共享的CSS文件或库CSS文件,这些文件提供类名,我们可以使用这些类名来创建组件。例如,我们知道会实现一个 .button 类、.alert 类、.slider 类等。

在使用React或框架的策略中,它会为我们提供可以导入的React组件,用于每个UI组件。

两种方法的优缺点 ⚖️

让我们看看每种方法的一些优缺点。

纯CSS组件库(如Bootstrap)

  • 优点:超级易于使用,非常简单。你只需要添加适当的类名。它还可以跨非React页面使用,例如静态HTML营销页面,只需导入相同的CSS文件即可保持一致性。
  • 缺点
    1. 并非所有功能都能用纯CSS实现。例如,点击时弹出的菜单通常无法用CSS实现,需要一些JavaScript。这些库通常也提供一些JavaScript,但这可能与React对DOM的修改产生冲突,增加了复杂性。
    2. 缺乏类型安全或错误提示。如果你拼错了类名,唯一能注意到的方式是视觉上,它不会抛出错误。如果使用TypeScript,也无法从中获得类型错误。
    3. 可访问性差。如果你想为元素添加额外的属性(如用于可访问性或功能的role属性),必须手动添加。
    4. 封装性差。很难定义允许人们轻松编辑样式的API。因为CSS本质上是全局的,人们可以随意添加更多CSS来覆盖样式,这很容易变得混乱。

React组件库(如Material-UI)

  • 优点
    1. 可以精确定义API。例如,你可以规定自定义警报组件的唯一方式是通过传递一个severity属性。这使得使用和后续升级都更容易。
    2. 每个组件都定义了自己的API,这使得类型更安全。
    3. 每个组件几乎总是返回一个DOM节点,这使得封装性更好。
    4. 你可以使用任何你想要的样式方法,如styled-components、CSS-in-JS或纯CSS文件。

因此,人们通常选择通过导出React文件来创建组件库,而不是共享一个CSS文件。

组件演示页面的重要性 🖼️

当你查看开源组件库(如Material-UI for React或Bootstrap)时,会发现它们都有很棒的演示页面。这些页面通常展示了每个组件的所有状态示例。

这对于使用组件库的人来说非常好,因为你可以看到人们构建了哪些组件。同时,对于组件库的开发者来说也非常好,因为你可以快速看到组件的每个版本。当你进行更改时,可以看到它如何更新每个状态,这让你在进行更改时更有信心,有助于防止错误。

通常,你希望在进行更改时充满信心,避免引入bug。当你能看到按钮的所有不同状态(左侧带图标的、右侧带图标的)时,你就可以充满信心地进行更改。

使用Storybook进行高效开发 🛠️

作为今天课程的第三部分,我们将看看如何创建像这样出色的演示页面,让我们能够充满信心地进行更改,并更快地开发。

如果我正在应用程序中开发一个按钮,可能需要等到开发完应用程序的其他部分,直到我有地方查看它。另一方面,如果我有一个像这样的酷炫演示页面,我就不需要等待,可以直接开始开发所有按钮,然后在应用程序更完善后再使用它们。

这在大型应用程序中非常重要。想象一下,你正在开发一个日期选择器,而选择日期的唯一方式是在设置屏幕中深入三层菜单。每次你重新加载页面以查看日期选择器的更改时,如果只能通过应用程序访问它,就意味着你每次都必须导航三层菜单。这是一个糟糕的开发体验,如果你不能快速看到变化,效率就不会高。

因此,拥有一个包含所有组件的优秀演示页面的第三个原因是:它允许你独立开发组件,而不必在主应用程序内部开发。

那么,我们如何实现这一点呢?答案是使用Storybook。它拥有我们谈到的所有优点:我们可以在组件所属的应用程序之前构建组件;我们可以并排查看组件的所有状态;我们可以在不导航到应用程序其他部分的情况下重新加载和预览它们。

Storybook的工作方式是:它是你应用程序的一个独立入口点。你拥有单一的代码库和所有组件文件。在顶层,我们有正常的入口点(例如,运行 yarn run startyarn run build 来构建你的应用程序)。同时,我们有一个不同的入口点(例如,运行 yarn run storybook),它使用相同的代码但构建不同的文件集,即构建你的组件库演示页面。

显然,你将正常的入口点部署给用户(例如,访问Canva.com看到的是Canva应用程序),而不会将你的Storybook演示页面部署给用户。它只保留在代码库中,供开发和内部使用。

Storybook 演示 🎬

我将跳入一个使用Create React App准备的快速演示。它有一个简单的警报组件。我将演示如何在CRA中设置Storybook,并为该警报创建一个Story,展示它的样子以及它将如何帮助我未来的开发。

在这个演示中,如果我运行 yarn run start,它会启动我的正常应用程序。这是我的正常位置,我看不到正在开发的组件,因为(假设)它在我应用程序中三层菜单深的地方,或者实际上我还没有编写应用程序的其他部分。

我有一个警报组件,它接收一个children属性作为内容,以及一个mode属性,该属性通过类名定义样式,并且我们根据mode切换来添加图标。

现在,我们将使用Storybook。按照文档,我们使用 npx storybook init 来初始化Storybook。然后运行 yarn run storybook 来启动开发服务器。

Storybook添加了一个 stories 文件夹。我们为我们的警报故事创建一个新文件:Alert.stories.jsx。我们导入警报组件,并导出故事。

我们设置故事的标题,然后导出具体的故事,例如“Error”。我们设置组件的参数:mode 设置为 “error”,children 设置为一些文本。

现在,当我们加载Storybook时,可以看到警报组件。我们还有控件可以更改传递给它的属性。这太棒了。这意味着作为组件开发者,我可以轻松测试组件,而无需通过主应用程序导航。

我们可以添加更多示例,如“Warning”故事。通过使用Storybook的模式(如 bind({}) 创建函数副本),我们可以确保每个故事都是独立的。

这样,当我想进行更改时(例如,决定不再需要图标),我可以快速返回Storybook,并查看我组件所有不同版本的示例。这使我无需通过整个应用程序就能快速看到更改。

视觉回归测试 👁️

我想强调Storybook的一个很酷的扩展功能:它可以用于视觉回归测试。对于更复杂的组件或页面,视觉回归测试基本上会在每次更改时为每个Storybook项目截图。

就像运行测试一样,它可以在提交时、拉取请求时或本地运行时进行。视觉回归测试套件将遍历所有Storybook,截图并与上次版本的截图进行差异比较。这对于检测意外的更改非常有用。你还可以在多个浏览器上运行它,查看某些东西在不应改变时是否看起来不同。这是创建Storybook或演示页面的另一个重要原因。

总结 📝

本节课中,我们一起学习了UI组件库。我们首先了解了Figma等用户界面软件如何通过出色的工具支持你创建用户界面和组件库。接着,我们探讨了实现组件库的两种方式:基于CSS的方法和基于React的方法,两者各有优缺点,但通常你会希望创建React组件。最后,我们介绍了Storybook,它是一个创建优秀演示页面的工具,让你无需围绕整个应用程序开发就能开发组件库,这种流畅性最终让你更有效率,更快地完成更多工作。

065:ReactJS 预渲染 💥

在本节课中,我们将学习单页应用(SPA)的优缺点,并深入探讨预渲染技术。预渲染是解决SPA核心问题(如SEO不佳和初始加载慢)的关键方案。我们将介绍两种主要的预渲染方法:静态站点生成和服务器端渲染,并通过Next.js框架的演示来理解其实际应用。

单页应用回顾

上一节我们介绍了单页应用的基本概念。本节中,我们来详细看看其优缺点。

单页应用是一种仅下载单个HTML文档的Web应用。这意味着,用户无需通过浏览器导航页面,而是使用JavaScript在不离开当前页面的情况下请求和处理响应。

例如,要导航到“关于”页面,不是点击链接让浏览器向服务器请求HTML、JavaScript和图像,而是使用JavaScript的Fetch API向“关于”路由发起请求,并仅重新渲染部分组件来显示目标页面。

单页应用的优点

以下是单页应用的主要优点:

  • 更快的加载后性能:一旦HTML文档下载完成,后续的页面或转换都非常迅速。
  • 近乎即时的页面切换:由于无需重新加载整个页面,页面间的过渡可以非常快。

单页应用的缺点

以下是单页应用的主要缺点:

  • 初始加载时间差:需要下载大量JavaScript,在网络性能较慢时,用户可能一段时间内看不到任何内容。
  • 浏览器支持差异:不同用户的浏览器环境可能支持不同版本的JavaScript,需要处理兼容性问题。
  • 搜索引擎优化差:这对任何规模的企业都至关重要,因为大多数人通过搜索引擎获取信息。

对于单页应用,默认的HTML文档通常是空的(<body>中无内容),只有<script>标签中的JavaScript。网络爬虫无法理解将要加载的内容,也无法获取尚未被JavaScript渲染的页面信息,因此难以全面理解网站的重要细节。

预渲染解决方案

我们已经理解这个问题存在已久。幸运的是,现在有了解决方案。

这个解决方案称为预渲染,其基本思想是提前为每个页面生成HTML,而不是由客户端浏览器来完成。

每个生成的页面都包含该页面所需的最少量JavaScript。页面下载后,附带的JavaScript会运行,使页面完全可交互,这个过程称为水合

预渲染有两个主要好处:

  1. 更好的性能。
  2. 更好的搜索引擎优化。

因为HTML页面本身已包含内容,网络爬虫更有可能理解页面内容,不会遗漏网站的重要细节。

预渲染的两种类型

预渲染主要有两种类型:静态生成和服务器端渲染。我们将逐一介绍。

静态站点生成

首先,静态站点生成 涉及在构建时进行预渲染。

构建时是部署流水线中的一个步骤,在此步骤中构建应用程序。实际上,HTML页面只需编译一次,之后便可分发到不同的CDN。

尽可能使用静态站点生成是最佳实践,因为只需构建HTML页面一次,之后便可服务大量用户请求。

通常,即使页面依赖外部数据,也有方法可以填充数据,因此依赖外部数据并不会禁用预渲染。

唯一不适合使用静态站点生成的情况是外部数据频繁更新时。例如,有一个每小时更新的新闻API,这种情况下,让客户端浏览器渲染或使用服务器端渲染可能更合适。

服务器端渲染

接下来,服务器端渲染 基本上意味着每次用户发出请求时都会进行预渲染。

这使得服务器端渲染比静态生成慢,因为一个预渲染的页面只服务一个用户请求。每次用户请求同一页面时,都需要重新构建新的HTML页面,因此使用服务器端渲染会产生重复工作。

由于性能较差,除非绝对必要,否则不应使用服务器端渲染进行预渲染。这意味着你可能不会经常使用它,甚至完全不用。

Next.js 框架简介

现在,Next.js 是一个非常流行的框架,它默认启用了预渲染。Next.js 最大的优势之一是允许你为每个页面选择所需的渲染类型。

无论是客户端渲染、静态站点生成还是服务器端渲染,Next.js 都能处理,并提供了高度的灵活性和粒度控制。

此外,Next.js 还有一些非常酷的功能,包括直观的路由系统、CSS和JS支持、自动代码分割以及快速刷新(热加载)。

演示:在 Next.js 中实现预渲染

现在,我们将进行一个演示。如果你想了解更多信息或教程,请访问 Next.js 文档。实际上,本演示中的很多笔记都直接来自该文档,它是一个非常好的资源,特别适合刚开始使用该框架的人。

创建 Next.js 应用

在我们的演示中,首先使用 create-next-app 库创建一个 Next.js 应用。本质上,你只需要安装 yarn 或 npm,然后输入 yarn create next-app

这将为你安装一些包,基本上是拥有一个功能正常的 Next.js 应用所需的最少包。我们将项目命名为 nextjs-demo。它会很快安装好所有包。

在 VS Code 中打开项目,可以看到左侧是应用的文件结构。create-next-app 提供了三个文件夹:

  • styles:基本上是一个CSS文件夹,你可以附加CSS文件。
  • public:包含所有静态文件,如图像和网站图标。
  • pages:任何路由都应放在 pages 目录中。Next.js 将自动从该页面创建路由。

例如,这里的 index.js 文件将是主页路由。你可以看到这是主页路由,显示的是 Next.js 的默认页面。api 文件夹是 pages 目录的一个特殊子文件夹,基本上你所有的API都可以放在这里。

创建示例组件

现在,让我们在 pages 目录中创建一个示例组件,命名为 example.js

我们将做一些简单的事情:

export default function Example() {
  return <h1>Hello World</h1>;
}

这是一个示例组件,它将显示“Hello World”。

构建应用与预渲染

如果我们停止开发服务器,并使用 yarn build 构建我们的应用程序,本质上这将预渲染我们所有的页面。在这个例子中,由于我们有一个示例组件,它应该构建一个包含此内容的HTML页面。

通常,构建步骤比使用开发服务器要长一些。所以在开发时,记得使用 yarn dev 而不是 yarn build

如你所见,Next.js 基本上给出了每个页面/组件是如何渲染的概述。example 路由旁边的白点显示它是静态渲染的。在这种情况下,静态渲染意味着没有获取外部数据。

.next 文件夹中,如果我们进入 server/pages/example.html 并稍作格式化,可以在 <body> 的第一行看到我们的“Hello World”行。这个 .next/server 文件夹中的内容将被分发到不同的CDN上。

获取外部数据(静态生成)

现在,我们希望我们的示例组件获取某种外部数据并动态渲染。如何在 Next.js 中实现呢?

首先,我们获取一个示例API。这个API将从某个地方获取一些数据。假设我们使用 namelocation 来显示在这个示例组件中。

为了在构建时获取外部数据,我们可以导出一个名为 getStaticProps 的函数:

export async function getStaticProps() {
  const res = await fetch('https://api.example.com/data');
  const data = await res.json();

  return {
    props: {
      name: data.name,
      location: data.location
    }
  };
}

在这个数据结构中,我们期望返回这个对象。如果我们想将 props 传递给这个示例组件,我们需要让 getStaticProps 返回一个 props 对象。在这个对象内部的所有内容都将被传递到 Example 组件中。

如前所述,我们想使用 namelocation。然后我们可以在组件中解构它们:

export default function Example({ name, location }) {
  return (
    <h1>
      Hello {name}, how is the weather in {location}?
    </h1>
  );
}

现在,getStaticProps 函数已经将这些 props 传递给了这个组件。

如果我们现在构建,可以看到我们的示例组件已经改变了预渲染方法。因为我们现在通过API获取外部数据,Next.js 正确地假设我们需要使用不同的预渲染方法。在这种情况下,它决定使用静态生成,因为如前所述,这可能是进行预渲染的最佳方式。

切换到服务器端渲染

如果我们想使用服务器端渲染呢?可以看到,这个 Lambda 图标用于 api/hello。让我们尝试为示例组件启用服务器端渲染。

实际上,你只需要将 getStaticProps 替换为 getServerSideProps 即可启用服务器端渲染:

export async function getServerSideProps() {
  // ... 获取数据的逻辑相同
}

重建后,可以看到我们的示例组件已经使用服务器端渲染作为其预渲染方法。

总结

本节课中,我们一起学习了单页应用的局限性,并深入探讨了预渲染作为解决方案。我们介绍了两种预渲染类型:静态站点生成和服务器端渲染,并了解了它们各自的适用场景。最后,我们通过 Next.js 框架的演示,实际看到了如何通过 getStaticPropsgetServerSideProps 函数来实现这两种预渲染策略,从而改善应用性能和搜索引擎优化。

066:ReactJS 进阶与答疑

在本节课中,我们将学习 ReactJS 的进阶概念,包括状态管理、路由导航以及如何在实际项目中处理常见的开发问题。我们将通过一个问答环节,探讨同学们在完成作业时遇到的具体挑战,并演示如何解决它们。

概述

本节课的核心是探讨前端开发中的状态管理和页面路由。我们将从简单的组件状态(useState)开始,逐步深入到全局状态管理(如 useContext)和持久化存储(如 localStorage)。接着,我们将重点介绍如何使用 react-router-dom 库来实现单页面应用(SPA)的路由导航,并解决在传递状态(如用户令牌)和页面保护时遇到的常见问题。

状态管理概览

在构建前端应用时,管理状态是一个核心概念。状态可以理解为应用在某一时刻的数据快照。根据其作用范围和生命周期,我们可以将状态分为几个层次。

上一节我们介绍了状态的基本概念,本节中我们来看看状态的不同类型及其管理工具。

状态类型

以下是前端应用中常见的几种状态类型:

  1. 局部非持久化状态:使用 useState 在组件内部管理。这种状态存在于组件函数的作用域内,页面刷新后即消失。

    const [count, setCount] = useState(0);
    
  2. 全局非持久化状态:需要在多个组件间共享,但同样不持久化。简单的实现方式包括通过组件树层层传递 props,或使用 React 的 useContext Hook。

  1. 持久化状态:需要跨越页面会话保存的数据。在浏览器中,这主要通过 localStorageCookies 实现。localStorage 是 HTML5 规范引入的(约2008年),比传统的 Cookies 使用起来更方便。

  1. 完整的状态管理库:对于大型工业级应用,通常会使用专门的状态管理库,如 ReduxMobX。这些工具提供了更强大、可预测的状态管理机制。

使用 React Router Dom 进行路由

在单页面应用中,路由负责管理不同 URL 对应的视图组件,而无需重新加载整个页面。react-router-dom 是 React 生态中最流行的路由库。

上一节我们讨论了状态管理,本节中我们来看看如何管理应用中的不同视图和页面导航。

安装与基本设置

首先,需要在项目中安装 react-router-dom

npm install react-router-dom

安装后,你可以在 package.json 中看到其版本(例如 v6)。

基本路由结构

在应用的顶层(通常是 App.jsx),你需要用 BrowserRouter 包裹你的应用,并使用 RoutesRoute 组件定义路径与组件的映射关系。

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/signin" element={<SignIn />} />
        <Route path="/dashboard" element={<Dashboard />} />
      </Routes>
    </Router>
  );
}

导航与链接

在组件内部,你不能使用传统的 <a> 标签进行导航,因为那会导致页面刷新。应该使用 react-router-dom 提供的 Link 组件或 useNavigate Hook。

  • Link 组件:类似于 <a> 标签,但实现的是客户端路由跳转。
    import { Link } from 'react-router-dom';
    <Link to="/signup">去注册</Link>
    
  • useNavigate Hook:用于在 JavaScript 逻辑中进行编程式导航。
    import { useNavigate } from 'react-router-dom';
    const navigate = useNavigate();
    // 登录成功后跳转
    navigate('/dashboard');
    

嵌套路由与布局

对于有共享布局(如导航栏)的页面,可以使用嵌套路由。父路由通过 <Outlet /> 组件来渲染子路由的内容。

// 定义一个布局组件 SiteLayout.jsx
import { Outlet } from 'react-router-dom';
function SiteLayout() {
  return (
    <div>
      <nav>这里是导航栏</nav>
      <main>
        <Outlet /> {/* 子路由内容将在这里渲染 */}
      </main>
    </div>
  );
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/a0329e44501b9a4d61bb7af00f2f1afc_33.png)

// 在 App.jsx 中配置嵌套路由
<Routes>
  <Route path="/" element={<SiteLayout />}>
    <Route index element={<Home />} /> {/* 路径为 / */}
    <Route path="dashboard" element={<Dashboard />} /> {/* 路径为 /dashboard */}
  </Route>
  <Route path="/signin" element={<SignIn />} /> {/* 独立页面 */}
</Routes>

实战:认证与路由保护

一个常见的需求是:用户未登录时,访问受保护页面(如仪表盘)应被重定向到登录页;用户已登录时,访问登录/注册页应被重定向到主页。

上一节我们建立了路由结构,本节中我们来看看如何将认证状态与路由逻辑结合起来。

全局状态与令牌传递

  1. 在顶层管理令牌:通常将用户认证令牌(token)存储在应用顶层(如 App 组件)的状态中,或使用 useContext 使其全局可用。同时,将令牌也保存到 localStorage 以实现持久化。

    const [token, setToken] = useState(localStorage.getItem('token') || null);
    
  2. 传递令牌和更新函数:通过 props 或将 tokensetToken 放入 Context,将它们传递给需要访问令牌或修改令牌的子组件(如 Dashboard, SignIn)。

路由保护逻辑

在包裹所有路由的顶层组件(我们称之为 Wrapper)中,可以添加以下逻辑:

  1. 检查登录状态:在 useEffect 中,检查 localStorage 或状态中的 token
  2. 条件导航:使用 useNavigateuseLocation Hooks。
    • useLocation 可以获取当前路径信息。
    • 根据 token 是否存在以及当前路径,决定是否重定向用户。
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';

function Wrapper() {
  const [token, setToken] = useState(null);
  const [isLoading, setIsLoading] = useState(true); // 添加加载状态
  const navigate = useNavigate();
  const location = useLocation();

  useEffect(() => {
    const storedToken = localStorage.getItem('token');
    setToken(storedToken);
    setIsLoading(false); // 数据加载完毕
  }, []);

  useEffect(() => {
    if (!isLoading) {
      const publicPaths = ['/signin', '/signup'];
      const isPublicPath = publicPaths.includes(location.pathname);

      if (!token && !isPublicPath) {
        // 未登录且不在公开页面,跳转到登录页
        navigate('/signin');
      } else if (token && isPublicPath) {
        // 已登录但尝试访问登录/注册页,跳转到仪表盘
        navigate('/dashboard');
      }
    }
  }, [token, isLoading, location, navigate]);

  if (isLoading) {
    return <div>Loading...</div>; // 防止在检查完成前渲染受保护内容
  }

  return (
    // ... 渲染 Routes 和其他内容
  );
}

处理异步令牌获取

由于 localStorage 是同步操作,上述示例是可行的。但如果令牌来自一个异步函数(如 API 调用),你需要确保在令牌状态确定之前,应用不会渲染依赖于令牌的路由内容。使用一个 isLoading 状态是一种清晰的解决方案。

总结

本节课我们一起学习了 React 前端开发的进阶主题。我们回顾了从局部状态到全局状态管理的不同层次,并重点探讨了如何使用 react-router-dom v6 构建单页面应用的路由系统。通过一个整合认证状态与路由保护的实战示例,我们演示了如何管理用户令牌、在组件间传递状态、实现编程式导航,以及根据认证状态保护特定路由。记住,在构建复杂交互时,合理规划状态流和组件结构是保持代码清晰和可维护的关键。

067:测试概述 🦔

在本节课中,我们将要学习软件测试的基础理论。我们将探讨什么是测试、为什么需要测试,以及不同类型的自动化测试方法,特别是单元测试和集成测试。本节内容偏重理论,旨在为后续的实践课程打下基础。

为什么软件会出错?🤔

上一节我们介绍了测试的基本概念,本节中我们来看看软件为何会出错。软件出错的根本原因在于复杂性。简单的代码(例如只有两行)很容易手动验证其正确性。但随着软件规模的增长,其组成部分增多,相互连接的方式也变得更加复杂,出错的概率也随之增加。

编写复杂软件的过程,本质上是一系列“技巧”或“变通”的集合。即使我们遵循最佳实践和设计模式,为了满足开发需求,有时也不得不采取一些非理想化的手段(例如使用 useContext 来绕过清晰的树状结构)。这些做法虽然必要,但也引入了潜在的错误风险。

测试的核心目的 🎯

既然软件会出错,我们就需要通过测试来应对。测试的核心目的是验证软件没有损坏,而不是证明软件完全没有错误。测试是一个寻找缺陷的过程,而非确认缺陷的完全不存在。后者需要更深入、更复杂的方法。

人们会犯错,这是测试存在的重要原因。开发者可能会遗忘某些情况、误解需求、忽略他人代码的影响或做出错误的假设。因此,测试是一个系统化、有意识的活动,旨在发现并修复这些人为错误。

测试案例与测试场景 📋

在深入测试方法之前,我们需要理解两个基本概念:测试案例和测试场景。

  • 测试案例:这是最小的测试单元,用于检查一个特定的动作是否产生预期的结果。它通常遵循“执行此操作,检查是否为那个结果”的模式。
    • 示例:当我在输入框中输入了用户名和密码后,提交按钮应变为可用状态。
  • 测试场景:这是一组需要验证的功能或特性的集合,通常级别更高,可能描述一个完整的用户故事。
    • 示例:使用用户名和密码登录功能正常工作。这个场景可能由多个测试案例组成,包括上面提到的“提交按钮状态”案例。

良好的测试不仅能验证功能,还能帮助理清产品需求和用户旅程。反之,清晰的需求也有助于设计出更全面的测试。

如何进行测试?🔍

了解了测试的基本概念后,本节我们来看看具体的测试方法。测试主要分为手动测试和自动化测试两大类。

手动测试

以下是手动测试的特点:

  • 方式:开发者或测试人员像真实用户一样,在浏览器或应用中点击、操作,并观察结果。
  • 优点:非常方便,几乎不需要任何基础设施搭建。
  • 缺点
    1. 无法提供强有力的质量保证,只能给出“软件大体上能工作”的感觉。
    2. 无法扩展。对于简单的应用尚可应付,但对于像Instagram这样功能复杂的应用,存在海量的使用场景和排列组合,手动测试完全不可行。
  • 应用场景:通常在大型项目中作为发布前的最后一道人工检查,称为“用户验收测试”,在模拟生产环境的“预发布”或“测试”服务器上进行。

由于手动测试的局限性,我们需要引入自动化测试。

自动化测试

自动化测试主要分为两种类型:单元测试集成测试

单元测试

单元测试针对独立的、最小的代码单元(例如React中的一个函数组件)进行测试。它不关心产品的整体目的或用户行为,只确保这个单元本身能完成其设计的功能。

  • 示例:测试一个按钮组件是否能正确渲染样式,以及在点击时触发指定的回调函数。
  • 公式测试通过 = 独立组件的行为符合其规范
  • 优点
    1. 运行快速:因为测试对象简单、独立。
    2. 易于重现:不涉及复杂的依赖和集成。
    3. 稳定性高:只要组件规范不变,测试就有效。
    4. 扩展性好:可以随着组件数量增加而不断增加测试。
  • 挑战:编写单元测试有时会显得枯燥,并且要求良好的软件设计。如果组件之间耦合度过高,将难以进行有效的单元测试。

集成测试

单元测试保证了每个“零件”是好的,但无法保证它们组装起来的“机器”能正常工作。集成测试就是为了解决这个问题,它在更高的层次上验证多个组件如何协同工作。

  • 示例:测试整个登录流程,包括表单输入、按钮点击、API调用和页面跳转。
  • 优点:更贴近真实的用户交互和产品目标,能发现组件间集成的问题。
  • 缺点
    1. 更重、更慢:需要搭建更完整的环境(如模拟或真实的DOM)。
    2. 更复杂:需要更多资源,设置更繁琐。
    3. 维护成本高:因为涉及多个组件,任何一个组件的改动都可能需要更新集成测试。

一个经典的比喻是:两个门把手各自都通过了单元测试(都能转动),但集成测试发现,当它们装在同一扇门的两侧时,却无法让人从门内走出去。

真实DOM vs 模拟DOM

在进行前端集成测试时,一个关键选择是使用真实DOM还是模拟DOM(如jsdom)。

  • 真实DOM:使用真实的浏览器环境(如无头Chrome)。结果最准确,但速度慢,环境搭建复杂。
  • 模拟DOM:在Node.js环境中模拟一个DOM。速度更快,设置简单,但可能会错过一些只有真实浏览器才有的细微差别(如某些渲染延迟或事件处理的边界情况)。

对于大多数情况,模拟DOM已经足够。我们将在后续课程中介绍相关的工具。

UI测试

UI测试是集成测试的一个子集,它专门模拟真实用户通过界面(鼠标、键盘、触摸屏)与应用进行交互的过程。

  • 方式:自动化工具(如Cypress, Selenium)像真人一样寻找页面元素、触发事件、等待界面更新并验证结果。
  • 特点:这是最有价值但也最难编写和维护的测试,因为它覆盖了最广的范围,对应用的任何改动都可能敏感。
  • 核心价值:UI测试最能代表最终用户的真实体验,确保软件不仅内部零件完好,组装起来后也能提供正确的服务。

测试金字塔 🏔️

最后,我们可以用一个“测试金字塔”来形象地理解不同测试类型的比例和关系:

        UI 测试
      /         \
     /           \
    / 集成测试    \
   /               \
  /                 \
 /                   \
/ 单元测试            \
  • 底层(最多)单元测试。数量最多,运行最快,是测试的基础。
  • 中层(较少)集成测试。数量较少,用于验证单元之间的协作。
  • 顶层(最少)UI测试。作为集成测试的一部分,数量最少但价值最高,直接模拟用户操作。


本节课中我们一起学习了软件测试的基础理论。我们了解了测试是为了寻找缺陷,并探讨了手动测试的局限性。重点介绍了两种自动化测试:单元测试用于验证独立组件,集成测试(包括其子集UI测试)用于验证多个组件的协同工作。我们还通过“测试金字塔”理解了它们之间的关系。在接下来的课程中,我们将动手实践,学习如何为React组件编写单元测试和集成测试。

068:React组件测试 🧪

在本节课中,我们将学习如何在React中进行单元测试和组件测试。组件测试是指独立测试每个组件,确保它们按预期工作,而不考虑它们在更广泛应用中的集成情况。

概述

我们将使用两个主要的测试库:Jest(通用的JavaScript测试框架)和React Testing Library(专门用于测试React组件的框架)。如果你使用Create React App,这两个库已经默认安装,无需额外配置。

组件测试示例

让我们通过一个具体的例子来理解如何测试React组件。假设我们有一个按钮组件,它接收三个属性:onClick(一个函数)、title(默认值)和mode。如果mode是“dark”,则应用深色模式类;否则应用浅色模式类。组件最终渲染一个带有相应类名和标题的按钮。

编写基础测试

上一节我们介绍了测试的基本概念,本节中我们来看看如何为这个按钮组件编写具体的测试用例。

以下是编写基础测试的步骤:

  1. 导入必要的库和组件:首先,我们需要导入React Testing Library的renderscreen方法,以及要测试的按钮组件。
  2. 使用describeit组织测试describe用于将相关的测试分组,it(或test)用于定义单个测试用例。
  3. 渲染组件:使用render函数在测试环境中渲染按钮组件。
  4. 查询DOM元素:使用screen.getByRolescreen.getByText等方法查询渲染出的按钮元素。
  5. 进行断言:使用Jest的expect函数来断言元素是否存在于文档中。
import { render, screen } from '@testing-library/react';
import Button from './Button';

describe('Button', () => {
  it('renders button with default title', () => {
    render(<Button />);
    const buttonElement = screen.getByRole('button', { name: /click me/i });
    expect(buttonElement).toBeInTheDocument();
  });
});

运行测试的命令是yarn testnpm test。Create React App会运行所有以.test.js结尾的文件中的测试。

使用不同的查询方法

在查询DOM元素时,有多种方法可供选择。React Testing Library的文档提供了一个优先级指南。

以下是推荐的查询方法优先级:

  • getByRole:最高优先级。用于查询具有可访问性角色(如buttonheading)的元素。这有助于确保你的组件是可访问的。
  • getByLabelText:适用于表单标签关联的输入元素。
  • getByPlaceholderText:适用于输入框的占位符文本。
  • getByText:适用于查找包含特定文本的元素,如段落(<p>)。

对于按钮、标题等元素,优先使用getByRole。对于纯文本段落,可以使用getByText

有用的调试工具

在编写测试时,如果查询失败,可以利用一些工具进行调试。

以下是几个有用的调试技巧:

  • screen.debug():打印出当前渲染的完整DOM结构,方便你查看有哪些元素。
  • 无效角色提示:如果你向getByRole传递了一个无效的角色(如空字符串),测试失败时会打印出组件中所有可用的角色列表,这能指导你使用正确的角色。
  • Testing Playground:调用screen.logTestingPlaygroundURL()会生成一个链接。打开这个链接(testing-playground.com),你可以看到渲染的HTML和UI,并点击元素来获取React Testing Library推荐的查询代码。

编写更全面的测试用例

我们已经学会了如何测试组件的默认渲染。接下来,让我们为组件的不同属性和交互行为添加更多测试。

测试自定义属性

我们可以测试组件在接收不同属性时的行为。

以下是测试自定义标题和模式的示例:

it('renders button with custom title', () => {
  render(<Button title="Custom Text" />);
  const buttonElement = screen.getByRole('button', { name: /custom text/i });
  expect(buttonElement).toBeInTheDocument();
});

it('has light mode class by default', () => {
  render(<Button />);
  const buttonElement = screen.getByRole('button');
  expect(buttonElement).toHaveClass('light-mode');
});

it('has dark mode class when mode prop is "dark"', () => {
  render(<Button mode="dark" />);
  const buttonElement = screen.getByRole('button');
  expect(buttonElement).toHaveClass('dark-mode');
});

toHaveClass断言来自jest-dom库,它扩展了Jest的断言能力,用于检查DOM元素的类名。

测试交互行为(如点击事件)

最后,我们来测试组件的交互功能,例如按钮的点击事件。

以下是测试点击事件的步骤:

  1. 创建模拟函数:使用jest.fn()创建一个模拟函数。这个函数本身不执行任何操作,但会记录它被调用的次数和传入的参数。
  2. 渲染组件并传入模拟函数:将模拟函数作为onClick属性传递给按钮组件。
  3. 模拟用户事件:使用user-event库来模拟用户的点击操作。
  4. 断言函数被调用:断言模拟函数被调用了一次。
import userEvent from '@testing-library/user-event';

it('calls onClick handler when clicked', () => {
  const handleClick = jest.fn(); // 创建模拟函数
  render(<Button onClick={handleClick} />);
  const buttonElement = screen.getByRole('button');
  userEvent.click(buttonElement); // 模拟点击
  expect(handleClick).toHaveBeenCalledTimes(1); // 断言函数被调用一次
});

总结

本节课中我们一起学习了React组件测试的基础知识。我们介绍了如何使用Jest和React Testing Library,学习了如何编写测试用例来验证组件的渲染、属性应用以及用户交互行为。关键步骤包括:使用render渲染组件,使用screen.getByRole等优先级高的方法查询元素,使用expect进行断言,以及利用jest.fn()userEvent来测试函数调用和用户交互。掌握这些技能将帮助你构建更健壮、可维护的React应用。

069:浏览器自动化与UI测试 🦔

在本节课中,我们将学习关于浏览器自动化与UI测试的核心概念。我们将探讨自动化测试的重要性、不同类型的测试方法,并深入了解如何使用Selenium和Cypress等工具进行UI和集成测试。课程最后,我们将通过一个实际的Cypress演示来巩固所学知识。


测试的重要性

测试是防止软件缺陷的最终保障。它让我们能更好地理解代码,并在进行修改时避免出现“回归”现象——即新引入的代码导致原有功能出错。此外,测试实践本身就能提高代码库的质量。

从个人角度出发,我对测试持教条态度:如果任何非平凡的代码没有附带测试,那么我更倾向于认为这段代码是“侥幸运行”而非开发者有意为之。这意味着世界上有很多软件是靠运气工作的。

我们花了很多时间告诉你测试对你的代码为何重要,但可能较少提及它对你的生活为何重要。原因很简单:你们中的大多数人可能都有志于进入IT相关领域,无论是作为程序员、项目经理还是业务分析师。你很可能会以某种方式参与软件开发。

如果你在工作中不写测试,软件突然崩溃,那么你就是那个需要修复它的人。软件不会只在朝九晚五的工作时间内出问题,有时它会在午夜崩溃,或者在你约会时、与朋友外出时崩溃。根据你所在的行业、公司和问题的严重性,你可能需要立即修复它。因此,测试不仅是防止代码崩溃的护栏,也是你职业生涯的护栏。它让你能保持良好的工作灵活性和工作与生活的平衡,这一点至关重要。


黑盒测试与白盒测试

在之前的课程中,你已经学习了黑盒测试与白盒测试。让我们再回顾一下。

黑盒测试是一种测试形式,你的测试不了解应用程序是如何实现的。你的主要目标是测试行为和呈现给用户的体验。

白盒测试则是测试程序的结构。主要目标是测试可能的代码路径、可能不会对用户显现的边缘情况以及一般的程序结构。在白盒测试中,测试确实了解应用程序的实现方式。

两者都很重要,各有其用武之地,但今天我们主要关注黑盒测试。

那么,我们如何在浏览器中对应用程序进行黑盒测试呢?

从我们对黑盒测试的了解开始。在黑盒测试中,我们不知道应用程序中的代码是如何运行的,我们根据代码的输出来进行测试。幸运的是,在浏览器中,代码输出的是更多的代码——它输出DOM。因此,如果我们想在浏览器中进行黑盒测试,我们将直接针对DOM的输出进行测试。


集成测试与UI测试

现在,让我们回顾一下集成测试。你可能已经知道,但让我们刷新一下记忆。

集成测试是一种测试应用程序不同组件之间边界的方法。与单元测试(可能孤立地测试一个类或函数)不同,我们真正关心的是各个单元之间的交互。

广义上讲,集成测试不像单元测试那样廉价。它们运行速度更慢,需要更多时间来设置和编写,并且更容易出错。但这种昂贵性被其有效性所抵消。一个集成测试可以覆盖20个代码单元,因此在检测故障方面要有效得多,尤其是那些最终影响到用户的故障。

UI测试是集成测试的一种。UI测试是集成测试套件的重要组成部分,在浏览器中尤其重要。它是一种针对DOM进行测试的黑盒测试形式。UI测试通常涉及在隔离环境中模拟用户可能进行的操作,例如模拟打字、点击事件等。它们在捕捉用户最可能注意到的问题方面非常有效。UI测试可以手动完成,但也可以自动化。


何时编写集成或UI测试?

考虑到集成测试编写成本高昂,最常见的策略是从用户最常走的路径开始。我们通常称之为“快乐路径”。一个例子是你网站上的注册流程。我们可以根据如果出现问题的影响以及受测试影响的组件用户数量来做出评估。

集成测试很昂贵。因此,将时间花在编写覆盖用户不受影响或我们不太关心的领域的昂贵测试上,是对我们时间的低效利用。在这些情况下,单元测试可能足以覆盖我们的需求。

因此,我们通常从为核心流程编写覆盖“快乐路径”的测试开始。“快乐路径”是用户一切顺利时走过的路径,而“核心流程”是你的用户预期与你的应用程序交互的主要方式。例如,谷歌的核心流程是执行搜索。如果你的核心流程的“快乐路径”不工作,那么你的软件就坏了。想象一下,如果谷歌的搜索功能一直出问题,还会有人用它吗?在工作环境中,这种类型的故障通常具有最高的严重性,你可能最终需要加班来修复它。


自动化UI测试:Selenium与WebDriver

我已经决定要测试核心流程的“快乐路径”。如何编写一个自动化UI测试来覆盖这一点呢?答案在于我们模拟浏览器的能力。

在之前的课程中,我们概述了你可以使用假DOM模拟浏览器,也可以连接到浏览器的真实实例。在本讲座中,我们将针对真实的浏览器和真实的DOM进行测试。

这可能会让你感到惊讶,但所有主流浏览器都可以被自动化。你可以使用代码模拟你能想到的任何用户事件。有多种框架可以实现这一点,但今天我们先从Selenium开始。

Selenium是一个允许你自动化浏览器的项目。其核心是一个称为WebDriver的规范。WebDriver是一个最初由Selenium创建和实现的接口,它允许浏览器提供一组通用的API,供编程语言用于自动化浏览器。WebDriver过去只是Selenium的东西,但现在它已成为所有浏览器采用的标准。你可以合理地预期,任何符合W3C标准的浏览器都会提供一个使用WebDriver的接口供你自动化。

那么它是如何工作的呢?在调用层面,WebDriver通过一个驱动程序软件与浏览器通信。想象一下你购买打印机时,它会在你的电脑上安装一个驱动程序。没有那个驱动程序,你的电脑就不知道如何使用打印机。WebDriver完全一样,只不过对象是浏览器而不是打印机。因此,有一个驱动程序位于你的浏览器和代码之间,充当两者之间的代理。你的代码通过驱动程序向浏览器发送信息并接收返回的信息。

驱动程序通常是特定于浏览器的,例如Chrome有ChromeDriver,Firefox有GeckoDriver等。还有一个重要的注意事项:驱动程序必须始终运行在与它通信的浏览器所在的同一台机器上,但你的代码可以在同一台机器上运行,也可以远程在另一台机器上运行。

这展示了我所说的内容。一端是我们的代码,另一端是Firefox。GeckoDriver位于中间,来回传递信息。因此,代码可能会发送一个点击按钮的指令。驱动程序将接收该指令并将其传输到Firefox的原生API。然后Firefox将执行该操作并通过驱动程序将结果发送回来。Chrome的工作方式相同,但正如你所见,区别在于驱动程序:中间是ChromeDriver而不是GeckoDriver。

这是我之前提到的注意事项的一个例子。虽然你的驱动程序必须始终位于你正在自动化的浏览器所在的同一台机器上,但你的代码实际上可以远程执行。这意味着代码将通过网络向驱动程序发送指令,并通过网络接收返回的信息。

为什么要有这个功能?一个原因是,针对本地机器的集成测试容易出现很多配置问题。如果你的本地机器配置错误,可能会在测试中产生误报。远程执行环境更容易控制,可以使其更符合真实的用户环境。


定位器:与DOM交互的工具

WebDriver让你可以自动化浏览器,但它对测试一无所知。测试框架可以运行和执行WebDriver代码来执行UI测试。测试框架可能会包装代码,允许你在测试框架的范围内执行操作并针对浏览器验证结果。

一旦我们有了驱动程序,我们要做的第一件事就是调用driver.get并等待。这是等待浏览器加载函数参数中的URL,在本例中是Google.com。然后我们调用driver.findElement,在函数参数中,我们寻找一个具有名称属性为“q”的元素。我们等待该调用返回,然后向它发送一些按键。同样,对于每个实例,我们在这里使用await,因为我们调用的是驱动程序,它是一个外部代码片段,因此本质上是一个异步操作。

最后,在我们最后一次调用driver.waitUntilElementLocated时,我们使用by.css放入一个CSS选择器。在Selenium术语中,我们称之为“定位器”,它是我们能够对浏览器进行操作的基础。定位器允许我们在DOM中导航和遍历。我们总是针对DOM进行测试,因此我们需要一种方法来验证和与定位到的元素进行交互。定位器就像一个工具包,让我们能够做到这一点。

一个成功的定位器将始终返回一个可以与之交互的元素,好消息是我们有许多不同的定位器可用于不同的情况。

我们有用于CSS ID和CSS类名的定位器。我们还有一个用于标签上name属性的定位器,以及仅用于通用HTML标签名的定位器。我们还有一些用于链接文本和部分链接文本的便捷定位器,这使我们能够轻松找到页面中的链接。我们还有一个用于称为XPath的定位器。

XPath是一个非常强大的定位器,我想稍微详细地介绍一下。在我们拥有的众多便捷定位器中,比如CSS ID、类名和名称,如果它们都不够用,我们可以使用XPath。XPath是一种非常强大的语法,允许我们定义通过XML文件的路径。HTML与XML有足够的共同点,我们可以使用它。

这里我包含了一个XPath查询示例。它的作用是找到一个ID属性为“login-form”的form元素,然后从中提取第一个input元素,即该form的第一个类型为input的子元素。因此,你可以看到XPath非常强大。它允许我们定义通过DOM的复杂遍历。也就是说,我们拥有的其他定位器通常足以满足我们的用例,我们并不经常使用XPath。


不稳定的测试及其成因

随着XPath的结束,我们现在将停止讨论Selenium,开始讨论不稳定的测试。但在那之前,如果你需要休息,需要暂停视频,伸展一下腿脚,现在是个好时机,因为下一部分与Selenium的关系稍小一些。

让我们谈谈不稳定性。如果一个测试有时成功有时失败,那么它就是“不稳定”的。出于某种原因,这在UI和集成测试中很常见。为什么?

当我们编写测试时,重要的是它们具有一个关键属性:确定性。给定相同的输入,确定性测试将始终输出相同的结果。编写确定性的UI测试可能相当困难,因为UI暴露于大量异步行为和基于时间的行为等。但非确定性的测试不是一个好选择。在本节中,我将讨论导致测试不稳定的原因以及我们可以考虑解决这个问题的方法。

我们今天要讨论的第一个不稳定性原因是,UI通常与我们无法控制的其他组件(我们的后端)进行交互。我们的许多用户流程将涉及通过网络与某种后端服务器通信。因此,如果后端服务器产生我们不期望的结果,我们的测试可能会不稳定;或者如果它花费的时间比预期长,测试也可能不稳定。

确定性测试的一个关键属性是可靠的输入,但我们这里有一个问题:我们依赖于我们无法访问或控制的其他组件。我们无法锁定它们,它们提供给我们的输入从根本上说是不可靠的。你可能会说,后端的错误或没有收到我们期望的结果是件好事。但请考虑,那些后端会有它们自己的测试。对我们页面的测试应该只测试我们的页面控制的内容。因此,如果后端出现错误并破坏了我们的测试,那并不是一件好事。

我们可以使用称为“存根”的东西来解决这个问题。其思想是,在我们的测试环境中,我们不调用远程后端服务,而是创建后端服务的存根实现,这些实现将100%产生可靠的结果。存根是一个实现另一个对象接口的对象。然而,该实现返回一个虚假的响应。其思想是,存根可以与真实对象互换而不会造成中断。

这里我们有一个模拟或假的登录服务。真正的登录服务进行远程调用,但假的登录服务不这样做,只提供模拟信息。通过使用存根,我们可以确保测试环境中的后端服务始终返回相同的结果,为我们的测试提供确定性的输入。

另一个需要注意的是,你可以看到测试如何普遍促进良好的代码。存根的存在意味着我们需要将远程代码封装在某种类或函数中。我们今天没有时间详细讨论,但我只想提一下“依赖注入”这个词。

所以我们有了这个存根服务,它不再针对我们的真实后端进行测试。但你会说,等等,集成测试不是应该测试我们应用程序组件之间的边界吗?最重要的边界难道不是前端和后端之间的边界吗?如果那个边界失败了,其他一切都会崩溃。你说得对。但首先,确保我们的前端组件之间良好地交互更为重要。端到端测试是我们可能用来测试前端和后端之间边界的一种测试形式,它有一套自己的最佳实践。

下一个导致测试不稳定的原因是随机性或熵。例如,一个随机数生成器在不同的测试运行中产生不同的结果。你可能认为这不太可能,但考虑一下Java中的日期模块,它是基于时间的。如果你的应用程序基于日期和时间进行计算(这非常常见),而你的测试试图验证这些计算,你可能无意中创建了一个不稳定的测试。我们可以再次通过使用适当的存根,或者只是更小心地编写处理这些情况的测试来解决这个问题。

为了强调这种情况比你想象的更常见,我遇到过一些情况,测试在一天中的特定时间(例如上午10点)会失败。下一个导致不稳定的主要原因是性能。UI测试在浏览器中进行,这意味着影响浏览器的条件会影响测试的可靠性。你的浏览器可能受到CPU处理能力、内存分配或带宽的限制。例如,在你的手机上渲染页面可能比在你的定制游戏PC上花费更长时间,这确实会影响我们的测试。

考虑这个图表。顶部是我们的浏览器,它正在过渡到不同的UI状态。底部是我们的测试用例。在每个阶段,我们执行一个操作,然后验证浏览器的状态。我在每个浏览器过渡和每个测试用例之间放了一秒钟,所以测试用例和浏览器彼此一致。在这种情况下,浏览器不受节流、内存问题或慢速连接的影响,因此测试通过。

在这里你可以看到,浏览器到达第二个UI状态(带有星星)花费了更长时间。不是一秒钟,而是1.5秒。这可能是由于CPU节流造成的。测试用例仍然在一秒后运行,但浏览器状态还没有达到需要的位置,因此测试失败。这是否意味着我们的功能有问题?不,当然不是。代码仍然是正确的,但浏览器的性能导致测试对我们不稳定。

那么你可能会问,为什么我们不能等到浏览器渲染出星星后再验证它呢?嗯,我们正在进行黑盒测试,所以我们真的不知道要等待什么。不幸的是,对我们来说,浏览器性能问题更难解决。它们可能有多种原因和多种解决方案。

这里有一些我们可以调整的常见杠杆来改善我们的性能问题。第一个是超时。Selenium中的每个测试步骤都可以提供一个超时阈值,超过该阈值测试将失败。这为我们提供了一些时间来等待浏览器跟上。如果一个测试步骤持续失败,你可以增加该测试步骤的超时时间,为渲染发生提供更多时间。不过要小心,如果你对每个测试步骤都这样做,突然之间你可能有一个需要30分钟的测试。最好只在绝对需要时才使用超时。

我们还可以查看执行测试的环境。如果它们没有正确的资源或处于不一致的状态怎么办?例如,你可能在一台性能很差的机器上运行测试。如今的浏览器很昂贵,它们占用大量CPU和内存,所以请确保你的执行环境能够承受。

最后,另一个导致不稳定的原因不幸的是位于代码和浏览器之间的驱动程序本身。Safari就是一个很好的例子。Safari有一个相当不一致且不可靠的驱动程序,即使你的代码正确,也经常导致测试失败。最后,忽略“重试”这个简单的解决方案是不对的。这听起来很蠢,但有时最蠢的解决方案就是为你解决问题的方案。


实践:使用Cypress进行UI测试

到目前为止,我们已经讨论了UI测试和黑盒测试,了解了Selenium和WebDriver,讨论了集成测试的不稳定性,并谈到了通过性能、后端存根等方式解决它的方法。现在我们将继续进行一些实际示例。再次提醒,如果你想休息一下,现在是个好时机。

今天在我们的实践部分,我们将使用一个框架为我之前编写的示例应用程序编写一些自动化UI测试。我们今天将使用的框架叫做Cypress。它旨在使自动化UI测试更容易,通常用于端到端测试,但也可用于集成测试。

现在,Cypress不是一个基于Selenium的框架,但不要因此认为过去的10分钟是浪费时间。那么,为什么花所有时间教授Selenium的架构,而示例却不使用它呢?Cypress仅限于JavaScript实现,而Selenium是语言无关的。这使得Selenium非常常用。但不幸的是,由于驱动程序等原因,它更难配置。不过,我们讨论的广泛原则将始终适用。你总是需要使用某种定位器。你总是需要编写确定性测试。你总是会有一个执行环境,无论是本地的还是远程的。你总是会面临浏览器性能问题。所以相信我,你最终会在某个时候接触到Selenium。

幸运的是,我们不需要在Cypress的理论或架构上花费太多时间,因为Cypress真的很容易上手。它由两部分组成:一个测试运行应用程序和一个用于查看测试的仪表板。它为你做了很多设置工作。它为你处理超时。它截取屏幕截图。最重要的是,它真的很容易配置。也就是说,也有一些缺点。Cypress仅支持使用JavaScript创建测试用例,并且在撰写本文时,它不提供对Safari或Internet Explorer等浏览器的支持。因此,Selenium可能更难设置,但它要灵活得多。现在,正如我之前提到的,像超时这样可以帮助减少不稳定性的东西,通常在Selenium中需要手动配置,但Cypress为我们自动处理了所有这些。

最后一个注意事项:你可能在之前的课程中见过或学过Jest这个框架,它用于我们的单元测试。Cypress不支持Jest。不幸的是,它支持一个叫做Mocha和Chai的库。它们基本上和Jest做同样的事情,但它们比Jest早了好几年。


Cypress演示:编写一个注册流程测试

让我们开始吧。今天我们将用Cypress编写一些测试。为了节省时间,我预先编写了一个应用程序,它将在Gitlab上提供。不过,你可以使用yarn add cypress --dev命令轻松地将Cypress安装到你自己的项目中。然后我们可以运行Cypress来打开测试运行器和仪表板。第一次运行此命令时,它将配置Cypress并在项目的根级别创建一个cypress文件夹。执行此操作的命令是yarn run cypress open。当你运行此命令时,还会看到一个浏览器窗口弹出,那就是你的仪表板。

当你首次配置Cypress时,integrations文件夹内有许多示例。这个特定的示例获取一个CSS类名为action-focus的对象,然后尝试聚焦它。如果聚焦了,它将尝试查找类名并验证该类名是focused。然后,它将查找该元素紧邻的前一个兄弟元素,并检查其内联样式是否为orange。Cypress附带的示例文件夹充满了非常有用的示例测试,应该能帮助你入门。

好的,演示时间到了。

我这里有一个示例应用程序,它只是一个简单的注册流程。它接收姓名、电子邮件和密码。当我提交时,会播放一个很酷的动画。闪烁的只是一个图标。实际上,那里有一个返回按钮,可以带你回到注册表单,你可以看到它要求你检查你的电子邮件。

关于示例应用程序的一个注意事项:这里没有后端服务交互。你可以看到在我的package.json中,我已经安装了Cypress。我还安装了styled-components,你可能还记得我们在CSS-in-JS讲座中提到过它。还有一个叫做framer-motion的库,我们没有讨论过,但它处理快速简单的动画。

在应用程序的顶层,我们有两个容器组件,我们还在一个钩子中设置了一些用户详细信息。有一个变量存储表单是否已提交,还有一个重置回调将我们的用户详细信息设置回空。你可以看到,根据表单是否提交,我们要么显示一个横幅,要么显示一个标题和一个注册表单。

示例应用程序有一个components文件夹和一个hooks文件夹。在components文件夹内,我们有许多仅用于展示的组件,比如我们的布局、容器。其中大多数只是普通的样式化组件。例如,按钮映射到你在注册表单中看到的按钮。你可以看到我们的根容器只是一个样式化的main组件,它是一个flex布局。我们的标题也只是一个样式化的H1标签,只是为了格式化得好看一些。但大部分逻辑存在于我们的注册表单组件中。

这是注册表单组件。你可以看到它保存着一些映射到我们表单的状态片段。我们有姓名、电子邮件、密码各一个。我们还使用了一些钩子进行空值验证和电子邮件验证,我稍后会介绍。在所有这些过程中,我们还有一个计算出的readyToSubmit函数,它只是检查我们所有的表单详细信息是否都已正确填写。如果是,那么在提交处理程序中,我们调用onSubmit回调函数,它是一个属性。你可以看到,因为我们使用表单,所以我们必须阻止表单的默认事件。有更好的方法来解决这个问题,但由于这是一个示例应用程序,我们只想要一些快速而粗糙的东西。所以我们用包含姓名、电子邮件和密码的对象调用onSubmit。这是在我们的表单中调用的回调函数。

表单本身只是一个简单的表单,中间有一个布局组件和三个文本输入组件,这些样式化组件与我展示的标题或容器相同。这里的按钮在未准备好提交时被禁用,如果准备好提交,则显示提交文本,否则会提示你输入详细信息。请记住,每个文本输入也有一个名称属性,我们有姓名、电子邮件和密码。

在我们的钩子中,我们的空值验证钩子只是我们自己编写的一个钩子,它所做的就是检查字符串是否不为null、undefined或空。电子邮件验证是一个电子邮件正则表达式测试,它只是检查字符串是否具有与电子邮件相同的格式。不要太在意正则表达式,它看起来很复杂,我从Stack Overflow上得到的。你可以看到,除非你把它变成电子邮件格式,否则它是无效的。

让我们打开Cypress。

好的,我已经运行了Cypress,虽然花了一些时间,但它确实打开了。你可以看到这里有一个JS文件列表,这些文件对应着测试。

回到代码,当你第一次运行cypress open时,会创建一个cypress.json文件用于配置。目前它是空的,因为我们还没有配置Cypress,我们只是依赖默认设置。还会创建一个cypress文件夹。里面有四个子文件夹,但我们关心的是integration。现在,如果我打开它,里面有一个examples子文件夹,没有其他东西。这是你存储测试的地方,你可以看到Cypress很贴心地添加了一个示例测试列表,可以帮助你入门。

接下来我要做的是在Cypress的integration文件夹内创建一个子文件夹。我将其命名为my-tests。在里面,我将创建我的第一个测试文件。我犯了个错误。好了,这就是我们将要编写测试的测试文件。

我们要做的第一件事是创建一个context。这有点像Jest中的describe,就像是你需要编写的所有测试的父包装器。然后我们将插入一个beforeEach钩子。这将在每个测试之前运行代码,你可以看到我们在这里调用cy.visitcy是Cypress API的包装器,我们说要访问localhost:3000,这是我们本地React应用程序运行的地方。我现在要写一个测试,它将是一个成功的注册测试。这是我们的核心流程,我们正在测试“快乐路径”。

首先,我要定义一些常量。这些是我想输入到注册表单中的值。现在我有了这些值,可以开始编写测试了。cy.get函数允许我传入一个定位器,类似于我在Selenium中使用的定位器,但在这里我说我想要一个名称等于字符串“name”的输入。所以这应该找到我的名称文本输入,即具有名称“name”的文本输入。

进入我的文本输入组件或我的注册表单,首先,你可以看到我的文本输入组件有一个名称“name”。所以它应该获取这个输入。它们以这种方式链接在一起。文本输入组件接受一个inputName作为属性,但它只是将其传递给一个样式化的输入组件,所以我们知道当它输出到DOM时,它将是一个input标签。

我要做的第一件事是聚焦这个标签。现在我可以开始输入了。所以我需要做的就是输入我的姓名变量。我可以为接下来的两个输入标签做同样的事情,它的功能完全相同。在这个例子中,我打算将电子邮件变量输入到我的电子邮件输入中,名称属性为“email”,我也会为我的密码做同样的事情。

现在完成了,让我们再次运行Cypress测试运行器,尝试运行我们的测试,看看会发生什么。

所以我们等待它加载。好了。所以我们这里有一个examples文件夹,我们还有一个my-tests文件夹,我打开了它,里面有一个my-test.js文件。如果我点击它,它将运行测试。你可以看到速度有多快,但你可以看到它已经按照我们的要求输入了Jane Doe姓名、Jane Doe电子邮件和密码。

让我们扩展我们的测试。首先,我们将通过获取一个类型为“submit”的按钮来扩展它。然后我们将在其上模拟一个点击事件。

好的,让我们测试一下这个。我可以点击这里运行所有测试,你可以看到按钮被点击了,并按计划显示了注册成功横幅。不过,除了在屏幕上看到它之外,我们并没有真正的方法来验证成功是否正确,这在技术上是一项手动任务。我们不想监督我们的集成测试,所以我们需要做的是验证表单是否成功提交。

在这里,我有我的注册成功横幅组件,你可以看到有一堆样式化组件。有一个div的包装器,还有我们的标题文本,就在中间那里,它有一个data-test-target属性,叫做caption-text。在代码中,它写着“check your email”并输入我们的电子邮件。所以我们在这里使用了一个data-test-target属性。这是一种能够快速定位DOM中元素的方法。通过传入一个仅用于测试的自定义属性,我们通常不推荐这样做。如果你能使用CSS类名会更好,但我们使用的是样式化组件,它为我们处理CSS类名。使用data-test-target的风险在于,你的应用程序编码方式的细节会泄露在DOM中,因此data-test-target将对用户可见,所以如果可能的话,不建议这样做。

所以我在这里做的是,检查任何具有属性data-test-targetcaption-text的标签。我期望里面的文本包含我的电子邮件字段。所以这将执行,它会在我的标题中找到,并检查文本中是否包含电子邮件。

如果我运行测试,你可以看到断言通过了,因为电子邮件包含在那个文本字段中。所以我们有一个成功的测试。

在我们结束之前,我只想重申一下在这里使用data-test-target的理由。data-test-target是一个自定义属性,在这里用于让我们能够非常容易地获取文本。但一般来说,这不是一个好主意,因为它向用户暴露了你测试的某些方面,这通常不是你真正想做的。通常你可以使用其他属性,比如如果你想获取链接,你可以使用href、类名,ID是一个非常好的选择。但是因为我们使用的是样式化组件,我们实际上并不控制这些,因为这是我们不知道的实现细节,所以在这个例子中我们使用了data-test-target。但一般来说,你应该有其他可以使用的属性。

无论如何,演示到此结束。在其中,我们为示例应用程序的“快乐路径”创建了一个成功的自动化UI测试,所有代码都在GitHub上,如果你需要查看的话。


行为驱动开发简介

在我离开之前,还有一件事我们需要涵盖,那就是关于“行为驱动开发”的一点说明。你会在各处看到它,如果不谈论它,谈论集成测试会感觉不完整。BDD是一种开发方法,旨在鼓励开发人员、测试人员和业务人员形式化对应用程序工作方式的共同理解,并且它有自己的语言。我们有“场景”这个概念,然后你概述一个预先存在的条件,比如“Given”,以及之后你可以用“And”指定的一组条件。“When”条件是动作开始发生时,“Then”应该是验证。所以你有这样的概念:设置一个上下文,说明当某事发生时,那么另一件事应该发生。它通常用作编写自动端到端集成测试的接口,以一种业务人员可以理解的方式。这是你可能会遇到的东西,它在像Robot Framework这样的框架中相当流行,该框架使用这种语法来定义自动化测试,尽管从广义上讲,开发人员仍然需要编写将这些测试粘合在一起的代码,所以从这个角度来看,它在实现“业务人员将开始编写自动化测试”这一理念方面有些失败。开发人员仍然在做这件事。你可能会发现自己也在做,但这是一个很好的注意事项,你可能会在讲座中看到它。


总结

本节课中,我们一起学习了浏览器自动化与UI测试的核心知识。我们从测试的重要性开始,区分了黑盒测试与白盒测试,并深入探讨了集成测试与UI测试的概念。我们了解了如何使用Selenium和WebDriver进行浏览器自动化,认识了各种定位器,并讨论了导致测试不稳定的常见原因及其解决方案。最后,我们通过一个实际的Cypress演示,动手编写了一个自动化UI测试,覆盖了示例应用程序注册流程的“快乐路径”。希望这些知识能帮助你在未来的开发工作中编写更可靠、更健壮的测试。

070:UI测试入门 🧪

在本节课中,我们将学习如何使用Cypress进行端到端(E2E)UI测试。我们将通过一个具体的示例,编写一个“快乐路径”测试,涵盖从导航到主页、成功登录、上传文件到成功退出的完整用户流程。课程内容将基于UNSW COMP6080课程的相关知识进行扩展。


概述

我们将创建一个模拟的Web应用,并为其编写Cypress测试。测试的主要目标是验证以下核心用户操作序列(即“快乐路径”)能否正确执行:

  1. 导航到主页。
  2. 成功登录。
  3. 成功上传文件。
  4. 成功退出。

我们将逐步构建测试代码,并解释每个步骤的作用。


准备演示应用

在深入代码之前,我们首先需要一个用于测试的Web应用。这个演示应用包含以下页面和功能:

  • 主页 (/): 包含一个导航栏和一个“登录”按钮。
  • 登录页 (/login): 包含一个表单,有emailpassword输入框以及一个submit提交按钮。
  • 仪表盘页 (/dashboard): 登录后显示,包含一个用于上传文件的inputname="your-image")和一个“退出”按钮。成功上传图片后,会显示一个图片预览(img标签,name="image-preview")。

为方便Cypress选择元素,我们已为所有关键交互元素(如按钮、输入框)设置了name属性。


安装与配置Cypress

首先,我们需要在项目中安装Cypress。如果你的项目尚未安装,可以使用以下命令:

yarn add cypress

安装完成后,为了方便启动Cypress,可以在package.json文件的scripts部分添加一个快捷命令:

{
  "scripts": {
    "cypress:open": "cypress open"
  }
}

现在,你可以通过运行 yarn cypress:open 来启动Cypress测试运行器。

当你首次运行此命令时,Cypress会进行初始化配置,并创建 cypress 目录及其默认文件结构。

在Cypress测试运行器界面中,选择“E2E Testing”并选择一个浏览器(推荐使用Chrome,因为这是评分时的默认浏览器)。然后,你可以创建一个新的测试文件,例如将其命名为 userFlow.cy.js


编写“快乐路径”测试

上一节我们配置好了Cypress环境,本节中我们来看看如何编写具体的测试用例。我们将在一个 describe 代码块中定义我们的测试套件。

以下是完整的测试步骤,我们将逐一拆解:

describe('用户流程测试', () => {
  it('应成功完成登录、上传和退出流程', () => {
    // 测试步骤将在这里编写
  });
});

步骤 1: 访问主页并验证URL

每个测试开始时,我们首先需要导航到应用的起始URL。

cy.visit('http://localhost:3000');
cy.url().should('eq', 'http://localhost:3000/');
  • cy.visit() 命令用于访问指定的URL。
  • cy.url().should() 是一个断言,用于检查当前URL是否与预期一致。

步骤 2: 导航到登录页面

接下来,我们需要点击主页上的“登录”按钮,跳转到登录页。

cy.get('button[name="login-button"]').click();
cy.url().should('include', '/login');

  • cy.get() 命令通过CSS选择器(这里使用了属性选择器 [name="..."])来获取DOM元素。
  • .click() 命令模拟用户点击操作。
  • 我们再次使用 cy.url().should() 来断言当前URL包含 /login 路径。

步骤 3: 填写表单并成功登录

现在,我们需要在登录表单中填写信息并提交。

cy.get('input[name="email"]').focus().type('example@email.com');
cy.get('input[name="password"]').focus().type('password123');
cy.get('button[name="submit"]').click();
cy.url().should('eq', 'http://localhost:3000/dashboard');
  • .focus() 命令将焦点置于输入框。
  • .type() 命令用于模拟键盘输入,向输入框填入文本。
  • 填写完成后,找到提交按钮并点击。
  • 断言页面成功跳转至仪表盘 (/dashboard)。

步骤 4: 成功上传文件

登录后,在仪表盘页面上传一个图片文件。

cy.get('input[name="your-image"]').selectFile('cypress/assets/cute-cat.jpg');
  • .selectFile() 是Cypress提供的便捷命令,用于处理文件上传。它接收一个相对于项目根目录的文件路径。

处理异步渲染:有时,文件上传后,前端需要时间处理并渲染预览图。如果测试立刻断言图片存在,可能会因为渲染未完成而失败。为了解决这个问题,我们需要使用 cy.wait() 命令。

// 在上传命令后,等待一段时间让UI更新
cy.wait(6000); // 等待6秒
// 然后断言预览图片存在且可见
cy.get('img[name="image-preview"]').should('be.visible');

  • cy.wait(6000) 会使测试暂停6000毫秒(6秒),等待可能的异步操作(如API调用、图片处理)完成。
  • 等待之后,我们再断言名为 image-preview 的图片元素应该处于可见状态。

步骤 5: 成功退出系统

最后,我们点击退出按钮,并验证是否返回主页。

cy.get('button[name="logout-button"]').click();
cy.url().should('eq', 'http://localhost:3000/');


运行与验证测试

将以上所有步骤组合到 it 代码块中,就构成了完整的“快乐路径”测试。在Cypress测试运行器中打开对应的测试文件(如 userFlow.cy.js),Cypress将自动执行测试。

你会看到Cypress模拟一个浏览器,逐步执行你的每一个命令:访问页面、点击按钮、输入文本、上传文件。所有断言(should)如果通过,测试则显示为绿色;如果有任何一步失败(例如元素未找到、URL不匹配、超时),测试将停止并报告错误。


总结

本节课中我们一起学习了如何使用Cypress进行端到端UI测试。我们重点掌握了:

  1. 环境搭建:安装Cypress并配置测试脚本。
  2. 测试结构:使用 describeit 组织测试套件和用例。
  3. 核心命令
    • cy.visit() 用于导航。
    • cy.get() 用于选择元素。
    • .click(), .type(), .selectFile() 用于模拟用户交互。
    • .should() 用于添加断言,验证应用状态。
  4. 处理异步:使用 cy.wait() 命令处理需要等待的UI更新或网络请求,防止测试因超时而失败。
  5. “快乐路径”测试:编写了一个完整的流程测试,模拟了用户从访问到退出的关键操作。

通过为关键HTML元素设置清晰的 name 属性,可以让我们更稳定、更易读地编写选择器,这是编写健壮E2E测试的一个良好实践。

071:期末考试指南 🎓

在本节课中,我们将详细介绍COMP6080课程的期末考试安排、形式、准备方法以及相关规则。课程讲师将帮助你全面了解考试,以便你能自信地应对。

考试基本信息

你的期末考试是一场3小时的在线考试,时间为悉尼时间5月2日上午9点至12点。考试仅占课程总成绩的20%,并且你将在考试前得知自己是否已通过本课程。

考试形式与作业4非常相似,但范围更小,工作量也大幅减少。考试将基于React,主要评估你实现特定UI功能的能力。

考试结构与评分

上一节我们介绍了考试的基本信息,本节中我们来看看考试的具体结构和评分标准。

考试的核心是完成一个简化的React应用。与作业4不同,考试没有后端API、没有测试要求、也不评估代码质量或用户体验设计。评分标准非常简单明了:

  • 80% 的功能实现:根据考试说明(spec)实现具体的UI功能。评分者会逐项核对。
  • 20% 的移动端响应式:确保你的应用在不同屏幕尺寸下能正常显示。

考试通常包含一个概览页面和三个小型互动“游戏”。设置多个任务是为了避免你因单个复杂问题而卡住。你不需要完成所有任务,大多数学生通常无法完成第三个游戏,这很正常。

考试规则与注意事项

了解了考试内容后,我们来看看参加考试必须遵守的规则和注意事项。

考试是开卷的,你可以使用整个互联网,但不能寻求他人帮助(例如在Stack Overflow上提问或与同学讨论)。使用AI工具(如ChatGPT)在技术上无法禁止,但请注意,如果多名学生提交了高度相似的代码,仍可能被判定为抄袭。

关于特殊考虑(Special Consideration),大学有“适合参加考试”政策。如果你在考试前就感到不适,请不要参加考试,并直接申请特殊考虑。如果考试期间发生突发意外(如疾病、网络中断),应立即停止考试并通过邮件联系讲师。

考试期间如有问题,应在课程论坛上提问。请确保问题具体明确,以便助教能快速有效地回复。

以下是考试当天需要记住的几个关键点:

  • 考试在GitLab上进行,你会提前获得一个空的React项目仓库。
  • 提交方式与作业相同:完成代码后,推送到master分支。
  • 考试前建议提前克隆仓库并运行npm install,以节省考试时间。
  • 不允许导入复杂的CSS框架(如Material UI),使用原生HTML元素和CSS即可。

如何准备考试

现在我们已经清楚了考试的形式和规则,接下来看看如何高效地准备。

最好的准备就是你已经完成的作业,尤其是作业4。考试内容不会超出你已经练习过的范围。为了在考试时保持熟练度,建议你:

  • 完成并理解作业4:这是最直接的准备。
  • 练习教程中的React活动:特别是那些涉及状态管理和交互的强制性活动,例如:
    • Tic Tac Toe
    • 2048游戏
    • 使用useEffect
    • 使用React Router
  • 尝试模拟考试:课程将提供一个模拟考试(Sample Exam),其形式与真实考试完全相同。强烈建议你在考前(例如考前的周六)计时完成一次,以熟悉流程和节奏。
  • 复习核心概念:确保你熟悉useStateuseEffect、事件处理、表单和localStorage在React中的使用。无需复习Vanilla JavaScript或理论。

模拟考试示例

为了让你对考试有更直观的认识,讲师展示了一个过往的模拟考试例子。考试说明会非常详细地列出每一项需要实现的功能,例如:

“所有屏幕都应有一个页脚栏,高度为50像素,宽度与视口同宽。它不是固定在视口底部,而是固定在文档页面底部。背景色为#f0f0f0。”

你的任务就是根据这些明确的指示进行编码。考试还会为每个“游戏”提供演示视频或在线示例链接,帮助你理解预期效果。

总结与建议

本节课中我们一起学习了COMP6080期末考试的方方面面。

总结一下关键点:

  1. 考试形式:3小时开卷、基于React的实践性考试,占总成绩20%。
  2. 考试内容:实现一个包含多个小型互动功能的Web应用,侧重UI实现和响应式设计。
  3. 考前准备:通过作业4、教程活动和模拟考试进行练习,重点是React状态管理和组件交互。
  4. 考试策略:仔细阅读说明,优先完成明确的任务,如果卡在某处,应果断跳过以获取其他分数。
  5. 重要规则:独立完成,可查阅资料但不可求助他人;如有突发情况需及时联系讲师。

最后,请放平心态。考试设计时已考虑到难度,并且会进行分数调整。只要你认真完成了课程作业,就已经为考试做好了充分准备。祝你好运!

072:z-index 深度解析 🎨

在本节课中,我们将要学习 CSS 的 z-index 属性。这个属性允许我们控制网页元素在垂直方向上的堆叠顺序,就像在三维空间中调整元素的“前后”位置一样。理解 z-index 对于创建复杂的布局和层叠效果至关重要。


从二维到三维:理解网页的层叠上下文

上一节我们介绍了如何使用 positiontopleft 等属性在二维平面上定位元素。例如,通过设置 position: relative;left: 300px;,我们可以将一个 500x500 像素的红色方框在 X 轴上移动 300 像素。

.box {
  width: 500px;
  height: 500px;
  background-color: red;
  position: relative;
  left: 300px;
}

然而,网页实际上更像一个三维空间,存在一个第三轴——Z 轴。我们可以使用 z-index 属性来改变元素的 Z 轴值,从而控制其在网页上的垂直位置。

默认情况下,所有元素都是静态定位的(position: static),它们会被渲染在同一个层级上。但是,如果将元素的 position 值从默认的 static 改为 absoluterelativesticky,就会改变元素被渲染的层级。


z-index 的核心概念

z-index 是一个 CSS 属性,用于调整已定位元素(即 position 值不为 static 的元素)被渲染的垂直层级。这一点非常重要,因为 z-index 对静态定位的元素无效。

  • 更高的 z-index:意味着元素看起来更靠近网页的浏览者,即处于顶层。
  • 更低的 z-index:意味着元素会被推向后方。

你可以把 z-index 想象成 Microsoft Word、PowerPoint 或 Canva 中的“置于顶层”或“置于底层”功能。

在过去,z-index 更常被用于模态框、弹出窗口和对话框等需要出现在内容上方以便用户交互的元素。如今,它的使用频率有所降低,但在许多现有代码库中仍然很常见。了解其基础知识对于前端工作非常有帮助。


为什么建议谨慎使用 z-index

我通常建议尽可能避免使用 z-index。原因在于,如果页面上有很多元素都设置了 z-index,你很容易陷入一个循环:为了提高一个元素的层级而增加其 z-index 值,然后不得不去提高其他相关元素的 z-index 值,因为 z-index 的效果是相对于页面上的其他元素而言的。

理想的做法是,只在网页的一两个元素上使用 z-index,然后利用浏览器和 HTML 提供的默认渲染顺序来组织页面上的其他元素。

以下是关于层级顺序的要点:

  • 静态定位的元素(position: static)位于一个默认的基础层。
  • 已定位且未设置 z-index 的元素(或 z-index: auto)位于 0 层。
  • 可以设置正的 z-index 值(如 1, 2, 1000)来创建更高的层。
  • 也可以设置负的 z-index 值(如 -1, -10),这些元素会渲染在静态层的下方。

一个简单的 z-index 示例

为了更好地理解,我们来看一个包含四个 <div> 方框的简单例子。

假设我们有四个不同颜色的方框:红、蓝、橙、紫。它们最初都是静态定位的,按照 HTML 顺序堆叠。

目标:我们希望紫色方框(box4)位于红色方框(box1)之上。

首先,我们可以通过给紫色方框设置 position: absolutetop: 50px 来实现。这是因为红、蓝、橙三个方框是静态定位的,而紫色方框不是,因此它被渲染在了比其他三个方框更高的层级(第 0 层)上。

接下来,如果我们给蓝、橙、紫三个方框都设置 position: absolute,它们会全部移动到第 0 层。由于它们处于同一位置,并且按照 HTML 顺序渲染(蓝 -> 橙 -> 紫),所以紫色方框会盖住蓝色和橙色方框。

新目标:现在我们希望蓝色方框位于橙色和紫色方框之上。

这时就需要 z-index 出场了。由于它们都在第 0 层,我们可以:

  1. 给蓝色方框设置 z-index: 1(大于 0),它会出现在顶层。
  2. 给橙色方框设置 z-index: 2,它会出现在蓝色方框(z-index: 1)和紫色方框(z-index: 0)之上。
  3. 给紫色方框设置 z-index: 3,它又回到了最顶层。

现在,我们有了静态层(红色),以及 z-index 为 1、2、3 的层,它们按照预期相互堆叠。

重要提醒:如果我们尝试给静态定位的红色方框设置 z-index: 4,它不会生效,也不会出现在其他方框之上。z-index 只对已定位元素有效。

我们还可以使用负值,例如将所有方框的 z-index 设为 -1,它们就会渲染在静态默认层的下方。如果将紫色方框的 z-index 设为 -10,它会被推到最底层。


实战案例:模拟 Google 日历

现在,我们来看一个更复杂的真实案例:模拟 Google 日历的界面。

我们的页面包含:

  1. 一个日期显示框(静态定位)。
  2. 一个“小时 breakdown 容器”,用线条表示一天中的各个小时(静态定位)。
  3. 一个“当前时间指示器”,包含一个红色三角形和一条向右延伸的线,用于指示当前时间。
  4. 一个“工作事件”框,代表日历上的一个事件。

目标

  • 将“工作事件”框和“当前时间指示器”移动到“小时 breakdown 容器”之上。
  • 确保“当前时间指示器”的线在“工作事件”框之上。

实现步骤

  1. 首先,我们给“工作事件”框添加 position: absolutetop: 240px。由于它变成了已定位元素(第 0 层),它会自动出现在静态定位的“小时 breakdown 容器”之上。
  2. 接着,调整“当前时间指示器”内部线条的位置,使其与三角形对齐。我们可以使用 position: relativebottom: 10.5px 进行微调。
  3. 然后,给“当前时间指示器”容器本身添加 position: absolutetop: 280px,将其移动到“工作事件”框的大致区域。但由于 HTML 顺序,“当前时间指示器”默认会位于“工作事件”框之下。
  4. 最后,为了解决层叠问题,我们给“当前时间指示器”设置 z-index: 1。这样,它就会渲染在“工作事件”框(未设置 z-index,位于第 0 层)之上。

这个案例展示了 z-index 的一个典型应用场景:当你需要在现有 UI 之上绘制线条、图标或某个独立容器,并且该元素不会引发复杂的层叠链时,使用 z-index 将其提升一个层级是合理的做法。正如之前强调的,我们应该避免为多个元素设置 z-index 以防止层叠混乱,在这个例子中,我们只对“当前时间指示器”这一个元素使用了 z-index


总结

本节课中我们一起学习了 CSS z-index 属性。我们了解到:

  • z-index 用于控制已定位元素position 不为 static)的垂直堆叠顺序。
  • 数值越大,元素越靠近用户(顶层);数值越小(包括负值),元素越靠后。
  • 应谨慎使用 z-index,尽量避免创建复杂的层叠上下文,优先利用浏览器的默认渲染顺序。
  • 在需要将单个独立元素(如指示线、图标)置于顶层时,z-index 是一个有效的工具。

希望本教程能帮助你清晰地理解 z-index 的工作原理和应用场景。

073:React组件与属性 🧩

在本节课中,我们将要学习React中一个核心概念:组件属性。我们将探讨如何通过创建可复用的UI组件来减少代码重复,提高开发效率,并保持应用的一致性。

概述

组件化开发是现代前端框架的核心思想。它允许我们将用户界面拆分成独立、可复用的代码片段。本节课将介绍如何创建React组件,以及如何使用属性来定制组件的行为和外观。

为什么需要可复用性?

可复用性是一个基础概念,其核心在于避免重复造轮子。这有多个好处:

  • 提高效率:重用已编写的代码可以节省开发时间。
  • 保证一致性:拥有单一数据源意味着修改一处,所有使用该组件的地方都会同步更新。
  • 便于测试和调试:同样因为单一数据源,问题更容易定位和修复。

我们的目标是,将这些可复用性原则应用到前端代码中,特别是在React中,以减少重复。

什么是组件化开发?

组件化开发,有时也称为组合模式,是一种将UI封装成独立“盒子”以便重用的方法。这与编程中提取公共值到变量,或提取公共逻辑到函数的思想一脉相承。在React中,我们将对UI组件做同样的事情。

React天生为组件化而设计,因此我们可以用最少的设置和已经熟悉的JavaScript/JSX语法来获得这种能力。它允许我们复用视觉元素和背后的逻辑。

创建基础React组件

上一节我们介绍了组件化的理念,本节中我们来看看如何创建一个基础的React组件。

一个React组件本质上是一个返回JSX(类似HTML的语法)的JavaScript函数。即使是整个应用的主页,在React中也是一个组件(通常是App.js)。

假设我们有一个简单的按钮:

<button>Click me</button>

我们可以将这个按钮提取成一个独立的组件。首先,在同一个文件中创建一个函数:

function MyButton() {
  return (
    <button>Click me</button>
  );
}

然后,在App组件中,我们可以像使用HTML标签一样使用<MyButton />

function App() {
  return (
    <div>
      <MyButton />
      <MyButton />
    </div>
  );
}

现在,我们就拥有了一个可复用的MyButton组件。更新组件内部的代码,所有使用它的地方都会自动更新。这非常有用,例如,如果想禁用所有按钮,只需在一个地方修改即可。

使用属性定制组件

虽然提取组件很好,但组件的内容和样式并不总是一成不变。例如,按钮可能需要显示不同的文本,或处于禁用状态。为了解决这个问题,React引入了属性

属性允许我们在创建组件时传入不同的参数来定制它,其概念类似于函数的参数。

在React中,每个组件函数接收一个名为props的参数,它是一个包含了所有传入属性的对象。传递属性的方式类似于HTML属性:

<MyButton text="Hayden" width="50" />

MyButton组件内部,我们可以通过props对象来访问这些值:

function MyButton(props) {
  return (
    <button style={{ width: props.width }}>
      {props.text}
    </button>
  );
}

现在,我们可以通过传入不同的textwidth属性值来创建具有不同文本和宽度的按钮,同时保持了组件的可复用性。

属性的解构与children

为了使代码更简洁,我们通常使用解构语法直接从props中提取属性:

function MyButton({ text, width }) {
  return (
    <button style={{ width: width }}>
      {text}
    </button>
  );
}

另一种常见的传递内容的方式是使用children。当像使用双标签HTML元素一样使用组件时,标签之间的内容会被传递给props.children

<MyButton>Hayden Smith</MyButton>
function MyButton({ children, width }) {
  return (
    <button style={{ width: width }}>
      {children}
    </button>
  );
}

传递属性与展开运算符

有时,我们希望创建一个“包装”组件,它能将接收到的所有属性直接传递给底层的HTML元素。这时可以使用展开运算符...

function MyButton(props) {
  return (
    <button {...props}>
      {props.children}
    </button>
  );
}

这样,使用<MyButton disabled onClick={handleClick}>Click</MyButton>时,disabledonClick属性会自动添加到内部的<button>元素上。这种方法常用于创建基础组件的简单包装。

组件的类型与最佳实践

根据功能和复杂度,组件可以分为几种类型,但这并非严格的分类,而是帮助我们理解组件的不同用途:

  1. 无状态/展示型组件:不接收props或仅用于静态展示,例如一个固定的图标组件。
  2. 展示型组件:接收props来动态决定其内容和样式,这是我们目前主要讨论的类型。
  3. 容器/包装型组件:通常用于封装逻辑、提供上下文或包装其他组件,例如我们上面提到的使用展开运算符的按钮包装器。

一个重要的最佳实践是:将组件视为纯函数。这意味着组件不应修改其输入参数(props),也不应产生副作用(如直接修改DOM或发起网络请求),对于给定的输入,应始终返回相同的输出。这使组件更可预测、易于测试。

组件的文件组织

随着项目变大,将每个组件放在单独的文件中是良好的实践。通常我们会创建一个components文件夹来存放所有组件。

例如,将MyButton组件移动到components/MyButton.js

// components/MyButton.js
export default function MyButton({ children, width }) {
  return (
    <button style={{ width: width }}>
      {children}
    </button>
  );
}

然后在主文件中导入并使用:

// App.js
import MyButton from './components/MyButton';

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6080-fe/img/3e6116d87aade573294f65d0255cabd6_38.png)

function App() {
  return (
    <div>
      <MyButton width="100">Button 1</MyButton>
    </div>
  );
}

组件之间也可以嵌套使用,一个组件可以导入并渲染另一个组件,从而构建出复杂的UI树。

总结

本节课中我们一起学习了React组件与属性的核心知识。我们了解到:

  • 组件是构建React应用的基石,是可复用的UI代码片段。
  • 通过属性,我们可以向组件传递数据,实现组件的定制化。
  • 使用解构children可以让组件代码更清晰。
  • 遵循纯函数原则和良好的文件组织习惯,能使项目更易于维护。

组件化开发让前端代码变得模块化、可复用且高效,是React强大功能的关键所在。

posted @ 2026-03-29 09:32  布客飞龙II  阅读(6)  评论(0)    收藏  举报