加州理工-CS428-高级-Web-应用笔记-全-

加州理工 CS428 高级 Web 应用笔记(全)

001:HTML交互式应用开发(第一部分) 🚀

在本节课中,我们将学习如何使用HTML构建一个交互式故事应用。我们将从零开始,通过迭代的方式逐步添加功能,最终创建一个包含多个场景、数据持久化和分支路径的“选择你自己的冒险”式游戏。我们将重点理解HTML文档结构、元素、属性以及如何通过查询字符串在页面间传递数据。


概述 📋

本教程基于加州理工学院“高级Web应用”课程的第一讲实验内容。我们将通过构建一个交互式故事游戏来学习HTML的核心概念。这个游戏将包含多个HTML页面,玩家可以在不同场景间导航,其状态(如金币、生命值)将通过URL查询字符串在页面间传递和保持。


项目介绍与目标 🎯

上一节我们概述了课程内容,本节中我们来看看本次实验的具体目标和所需工具。

学习目标:

  • 理解HTML文件的结构。
  • 学习HTML元素和属性的概念。
  • 学习HTML输入元素。
  • 了解如何通过<script>标签嵌入少量JavaScript。
  • 理解块级元素与行内元素的区别。
  • 学习URL查询字符串(服务器参数)及其在传递状态数据中的应用。
  • 学习如何将静态网站部署到GitHub。

所需工具:

  • Chrome浏览器(用于测试)。
  • 代码编辑器(如Visual Studio Code)。
  • 免费的GitHub账户(用于部署)。

项目动机与目标:

  • 动机:学习如何使用HTML语法编写多页面网站。
  • 目标:构建一个读者可以自主选择路径的交互式故事。

核心概念:HTML 🌐

在开始编码之前,我们需要理解一些核心的HTML概念。

HTML(超文本标记语言)是创建网页的标准标记语言。它描述了网页的结构,由一系列元素组成。HTML定义了应用在Web客户端视口中的视图

HTML元素由开始标签、内容和结束标签定义。例如,一个段落元素的结构如下:

<p>这是一段内容。</p>

元素告诉浏览器如何显示其内容。

HTML属性在开始标签中提供关于元素的额外信息。例如,图像元素的src属性指定了图片来源:

<img src="path/to/image.jpg">

块级元素 vs. 行内元素:

  • 块级元素(如 <h1>, <p>, <div>)总是从新行开始,并占据其父容器的全部宽度。
  • 行内元素(如 <a>, <span>, <img>)不会从新行开始,仅占据其内容所需的宽度。

HTML文档的典型结构类似于树形数据结构:

  • 根元素<html>
  • 子元素
    • <head>:包含元数据(如标题、样式、脚本),这些内容不会直接显示给用户。
    • <body>:包含文档的所有可见内容(如标题、段落、图像、超链接)。

Web浏览器负责解析HTML文档并将其以美观的方式呈现。除了作为HTML解析器,浏览器还包含JavaScript运行时环境,我们将在本实验中少量使用它来实现数据共享。


项目架构与设计 🗺️

理解了基本概念后,我们来看看项目的整体架构和设计流程。

我们的项目将放在一个名为 html_adventure_story 的目录中。目录内包含一个 assets 文件夹,存放实验所需的所有图像等资源文件。

在实现任何应用之前,设计其流程至关重要。对于交互式故事,这意味着规划每个场景(HTML页面)如何链接到下一个场景。

以下是本实验示例故事的流程图:

index.html -> intro.html -> overworld.html
overworld.html <-> dungeon.html
overworld.html <-> forest.html
overworld.html <-> shop.html
forest.html -> swamp.html -> (分支路径)
...

每个场景(HTML文档)应导向一个分支或死胡同。在开始编写HTML之前,建议先规划好你的故事分支。


目标1:创建初始索引页面 🏁

我们将采用迭代开发策略,每个目标分为三个阶段:设计实现测试。第一个目标是创建应用的入口页面——index.html

实现步骤:

  1. 创建HTML文件:在项目根目录创建 index.html
  2. 添加根元素:每个HTML文档都以 <html> 根元素开始和结束。
    <html>
    </html>
    
  3. 添加头部 (<head>):在 <html> 内添加 <head> 元素,用于存放元数据。
    • 标题 (<title>):定义浏览器标签页上显示的标题。
    • 网站图标 (<link>):定义浏览器标签页上显示的小图标。
    <head>
        <title>冒险游戏</title>
        <link rel="icon" href="assets/dragon-logo.png">
    </head>
    
  4. 添加主体 (<body>):在 <html> 内添加 <body> 元素,用于存放所有可见内容。
    • 主标题 (<h1>):使用最大的标题元素显示游戏名称。
    • 水平线 (<hr>):添加一条水平分隔线。
    • 段落 (<p>):添加游戏描述文本。
    • 图像 (<img>):嵌入游戏标题图片,使用 src 属性指定路径,width 属性调整宽度。
    • 嵌套元素:在段落 (<p>) 中嵌套锚点 (<a>) 和按钮 (<button>) 元素,创建一个可点击的“开始”按钮,点击后将跳转到 intro.html
    • 另一个水平线和标题:添加版权或作者信息。
    <body>
        <h1>HTML冒险游戏</h1>
        <hr>
        <p>当心,此处有怪物。</p>
        <img src="assets/gametitle.gif" width="480">
        <p>
            <a href="intro.html">
                <button>开始</button>
            </a>
        </p>
        <hr>
        <h4>故事代码由作者提供</h4>
    </body>
    

测试:
在浏览器中打开 index.html,检查标题、图标、图片和“开始”按钮是否正常显示。点击“开始”按钮应尝试跳转到 intro.html(即使该文件尚未创建)。


目标2:创建故事介绍页面 📖

上一节我们创建了入口页面,本节中我们来构建故事的起点——介绍页面。

这个页面的目的是向玩家介绍故事背景和任务。

实现步骤:

  1. 创建文件:在项目根目录创建 intro.html
  2. 基本结构:与 index.html 类似,添加 <html><head><body> 基本结构。
  3. 一致的头部:为了统一的用户体验,<head> 中的 <title><link>(图标)应与 index.html 保持一致。
  4. 构建主体内容
    • 主标题 (<h1>):场景标题(如“故事介绍”)。
    • 水平线 (<hr>)
    • 图像 (<img>):展示介绍场景的图片。
    • 段落 (<p>):描述故事背景的文字(例如:“欢迎,旅行者。我们的土地被巨大的邪恶所困扰。请拯救我们。这是5枚金币作为定金。”)。
    • 超链接 (<a>):使用锚点标签创建一个链接,指向 overworld.html。链接文本可以描述下一步行动(如“出城冒险”)。为了突出显示,可以将 <a> 标签嵌套在一个较小的标题元素(如 <h4>)中。
    <body>
        <h1>故事介绍</h1>
        <hr>
        <img src="assets/intro.gif" width="480">
        <p>欢迎,旅行者。我们的土地被巨大的邪恶所困扰。请拯救我们。这是5枚金币作为定金。</p>
        <h4><a href="overworld.html">出城冒险</a></h4>
    </body>
    

测试:
index.html 点击“开始”按钮,应能成功跳转到 intro.html 并看到介绍内容。点击“出城冒险”链接将尝试跳转到 overworld.html


目标3:创建库存页面 📊

到目前为止,我们的页面还是独立的。为了让游戏有进展感,我们需要一个能跟踪玩家状态(如金币、生命值)的库存系统。本节我们将创建一个可重用的库存页面,并学习如何在页面间传递数据。

HTML页面本身无法直接共享数据。为了实现数据持久化,我们将使用URL查询字符串

URL查询字符串是附加在URL末尾的数据,格式为 ?key1=value1&key2=value2。例如:overworld.html?gp=5&hp=3&ap=1&keys=0

实现步骤:

  1. 创建文件:在项目根目录创建 inventory.html。这个页面不会直接被玩家访问,而是被其他页面嵌入。
  2. 基本结构:添加 <html><body>。由于是嵌入页面,<head> 可以留空或省略。
  3. 构建库存表格
    • 添加一个标题“库存”和水平线。
    • 使用 <table> 元素创建一个表格。
    • 第一行 (<tr>) 使用表头单元格 (<th>) 显示库存项图标(金币袋、心、剑、钥匙)。可以使用HTML实体(如 💰)来显示Emoji。
    • 第二行 (<tr>) 使用数据单元格 (<td>) 显示各项的初始数值(例如:0000)。为每个 <td> 赋予一个唯一的 id 属性(如 gp, hp, ap, keys),以便后续用JavaScript操作。
    <body>
        库存
        <hr>
        <table>
            <tr>
                <th>💰</th> <!-- 金币 -->
                <th>❤️</th> <!-- 生命 -->
                <th>⚔️</th> <!-- 攻击 -->
                <th>🔑</th> <!-- 钥匙 -->
            </tr>
            <tr>
                <td id="gp">0000</td>
                <td id="hp">0000</td>
                <td id="ap">0000</td>
                <td id="keys">0000</td>
            </tr>
        </table>
    </body>
    
  4. 添加JavaScript以读取查询字符串
    • </body> 标签前添加 <script> 标签。
    • 使用 new URLSearchParams(location.search) 来解析当前URL中的查询字符串,得到一个 queryString 对象。
    • 使用 queryString.get('key') 方法获取特定键的值。
    • 通过元素的 id(如 gp)直接访问对应的HTML元素,并将其 innerText 设置为从查询字符串中获取的值。
    <script>
        // 解析URL中的查询参数
        let queryString = new URLSearchParams(location.search);
        // 将获取到的值设置到对应的HTML元素中
        gp.innerText = queryString.get('gp');
        hp.innerText = queryString.get('hp');
        ap.innerText = queryString.get('ap');
        keys.innerText = queryString.get('keys');
    </script>
    

测试:
直接在浏览器中打开 inventory.html?gp=5&hp=3&ap=1&keys=0。页面应正确显示解析后的数值(5, 3, 1, 0),而不是“0000”。这证明了查询字符串传递数据的有效性。


目标4:创建主世界页面并集成库存 🌍

现在,我们将创建游戏的核心枢纽——主世界页面。玩家从这里可以选择前往不同的地点。同时,我们将把库存页面嵌入进来,并确保玩家状态能传递到所有链接的页面。

实现步骤:

  1. 创建文件:在项目根目录创建 overworld.html
  2. 基本结构与头部:添加标准的 <html>, <head>, <body> 结构。在 <head> 中设置与之前一致的 <title> 和图标 <link>
  3. 构建主体内容
    • 主标题 (<h1>):显示“主世界”。
    • 水平线 (<hr>)世界图像 (<img>)
    • 提示与小标题 (<h4>):例如“你想去哪里?”
    • 无序列表 (<ul>):包含多个列表项 (<li>)。每个列表项内是一个锚点链接 (<a>),分别指向 dungeon.html, forest.html, shop.html。为每个 <a> 标签赋予一个 id(如 dungeon, forest, shop)。
    • 另一个水平线 (<hr>)
    • 内联框架 (<iframe>):用于嵌入 inventory.html 页面。设置其 src 属性,并赋予一个 id(如 inventory),以及 widthheight
    <body>
        <h1>主世界</h1>
        <hr>
        <img src="assets/overworld.gif" width="480">
        <h4>你想去哪里?</h4>
        <ul>
            <li><a id="dungeon" href="dungeon.html">地下城</a></li>
            <li><a id="forest" href="forest.html">森林</a></li>
            <li><a id="shop" href="shop.html">商店</a></li>
        </ul>
        <hr>
        <iframe id="inventory" src="inventory.html" width="100" height="125"></iframe>
    </body>
    
  4. 添加JavaScript以传递查询字符串
    • </body> 标签前添加 <script> 标签。
    • 关键逻辑:获取当前页面的查询字符串(location.search),并将其附加到所有需要传递状态的链接(<iframe>src 和各个 <a> 标签的 href)的末尾。
    • 这样,当玩家点击链接时,目标页面将收到包含当前状态数据的查询字符串。
    <script>
        // 获取当前页面的查询字符串
        let searchParams = location.search;
        // 将查询字符串附加到库存iframe的源地址上
        inventory.src += searchParams;
        // 将查询字符串附加到各个导航链接上
        dungeon.href += searchParams;
        forest.href += searchParams;
        shop.href += searchParams;
    </script>
    
  5. 修改介绍页面以传递初始状态:为了让主世界页面获得初始数据,需要修改 intro.html 中指向 overworld.html 的链接。为其添加初始的查询字符串。
    <!-- 在 intro.html 中修改链接 -->
    <h4><a href="overworld.html?gp=5&hp=3&ap=1&keys=0">出城冒险</a></h4>
    

测试:

  1. index.html 开始,点击“开始”。
  2. intro.html 点击“出城冒险”。
  3. 现在,overworld.html 应被加载,并且其嵌入的 inventory.html 应正确显示初始状态(5金币,3生命,1攻击,0钥匙)。
  4. 检查“地下城”、“森林”、“商店”这些链接的URL,它们末尾都应附带有 ?gp=5&hp=3&ap=1&keys=0 查询字符串。

总结 🎉

本节课中我们一起学习了如何使用HTML构建一个多页交互式故事应用的基础框架。我们完成了以下核心任务:

  1. 理解了HTML的基本结构:包括根元素 <html>、元数据容器 <head> 和内容容器 <body>
  2. 创建了静态页面:构建了索引页 (index.html) 和故事介绍页 (intro.html),并使用超链接 (<a>) 将它们连接起来。
  3. 引入了数据持久化概念:通过URL查询字符串在不同HTML页面间传递玩家状态数据。
  4. 实现了动态库存页面:创建了 inventory.html,利用嵌入的JavaScript读取查询字符串并更新页面内容。
  5. 构建了中心枢纽页面:创建了 overworld.html,集成了库存显示,并编写JavaScript逻辑将当前状态数据自动附加到所有出口链接上,确保了数据在场景切换时的连续性。

通过这个迭代过程,你不仅学会了HTML语法,还掌握了如何设计一个简单应用的数据流。在接下来的课程中,我们将基于此框架,继续为地下城、森林等场景添加更多交互逻辑和功能。

002:HTML交互式游戏开发(第二部分)

在本节课中,我们将继续构建一个基于HTML和JavaScript的交互式文字冒险游戏。我们将完成从目标5到目标12的开发,实现森林、沼泽、小屋、地牢、商店、哥布林战斗和最终场景。我们将学习如何通过URL查询字符串在页面间传递和更新游戏状态,以及如何使用JavaScript响应用户交互。


目标5:构建森林页面

上一节我们完成了游戏主世界页面,现在我们来构建第一个分支场景——森林页面。

步骤1:创建HTML文件

首先,创建一个名为 forest.html 的新文件。

步骤2:添加基本结构和元数据

forest.html<head> 部分添加标题和网站图标(favicon)。

<!DOCTYPE html>
<html>
<head>
    <title>冒险游戏</title>
    <link rel="icon" type="image/png" href="assets/favicon.png">
</head>
<body>
    <!-- 页面内容将放在这里 -->
</body>
</html>

步骤3:构建页面内容

以下是森林页面的主体内容。它包含一个标题、一张图片、场景描述、玩家选项和一个用于显示库存的iframe。

<h1>森林</h1>
<hr>
<img src="assets/forest.png" width="480">
<p>你漫步进入森林。左边是一条蜿蜒上山的小路,右边则通向沼泽。</p>
<h4>你想做什么?</h4>
<ul>
    <li><a id="hills" href="goblin.html">向左进入山丘</a></li>
    <li><a id="swamp" href="swamp.html">向右进入沼泽</a></li>
</ul>
<p><a id="exit" href="overworld.html">退出</a></p>
<hr>
<iframe id="inventory" src="inventory.html" height="100" width="300"></iframe>

步骤4:添加脚本以传递状态数据

为了在页面跳转时保持玩家的库存状态(如金币、生命值),我们需要使用JavaScript将URL中的查询字符串附加到所有超链接上。

<script>
    // 获取当前URL的查询字符串(例如 "?gp=5&hp=3&ap=1&keys=1")
    let searchParams = window.location.search;

    // 将查询字符串附加到所有需要传递状态的链接上
    document.getElementById('hills').href += searchParams;
    document.getElementById('swamp').href += searchParams;
    document.getElementById('exit').href += searchParams;
    document.getElementById('inventory').src += searchParams;
</script>

核心概念解释

  • window.location.search 获取当前URL中 ? 之后的部分,即查询字符串。
  • +=复合赋值运算符,用于字符串拼接。a.href += searchParams 等同于 a.href = a.href + searchParams

评估:打开森林页面,点击链接,检查URL是否包含了来自主世界页面的状态数据(如 ?gp=5&hp=3)。


目标6:构建沼泽页面

本节我们将构建沼泽页面,并实现第一个“游戏结束”场景。

步骤1:创建HTML文件

创建 swamp.html 文件并添加基本结构。

步骤2:添加页面内容

沼泽页面提供了两个选项:进入小屋(后续开发)或探索外部(触发死亡)。

<h1>沼泽</h1>
<hr>
<img src="assets/swamp.png" width="480">
<p>你踏入一片阴暗的沼泽。前方有一座破旧的小屋,周围雾气弥漫。</p>
<h4>你想做什么?</h4>
<ul>
    <li><a id="hut" href="hut.html">进入小屋</a></li>
    <!-- 注意:此链接使用 # 占位,点击将触发JavaScript函数 -->
    <li><a href="#" onclick="die()">在小屋外搜寻</a></li>
</ul>
<p><a id="exit" href="overworld.html">退出</a></p>
<hr>
<iframe id="inventory" src="inventory.html" height="100" width="300"></iframe>

步骤3:添加交互脚本

我们需要添加两个功能的脚本:1) 传递状态数据;2) 实现 die() 函数来覆盖页面内容,显示游戏结束画面。

<script>
    // 1. 传递状态数据
    let searchParams = window.location.search;
    document.getElementById('hut').href += searchParams;
    document.getElementById('exit').href += searchParams;
    document.getElementById('inventory').src += searchParams;

    // 2. 定义 die() 函数
    function die() {
        // 使用新内容完全覆盖当前页面的<body>内部HTML
        document.body.innerHTML = `
            <h1>你死了</h1>
            <img src="assets/gator.png" width="480">
            <p>一只巨大的短吻鳄吞噬了你。</p>
            <p><a href="index.html">游戏结束</a></p>
            <audio autoplay>
                <source src="assets/death.mp3" type="audio/mpeg">
            </audio>
        `;
    }
</script>

核心概念解释

  • onclick="die()" 是一个事件处理器属性。当用户点击该链接时,会调用JavaScript函数 die()
  • document.body.innerHTML 用于获取或设置 <body> 元素内的HTML内容。通过赋予它一个新的HTML字符串,我们可以动态重写整个页面视图。
  • **反引号()** 用于定义**模板字符串**,它支持多行字符串和字符串插值(${expression}`)。

评估:在沼泽页面点击“在小屋外搜寻”,应触发死亡场景并播放音效。


目标7:构建女巫小屋页面

本节我们将创建一个需要钥匙才能进入的场景,并演示如何根据游戏状态动态更新页面元素。

步骤1:创建HTML文件

创建 hut.html 文件并添加基本结构。

步骤2:添加页面内容

小屋页面有一扇门(图片),其状态(锁定/打开)将由脚本控制。

<h1>女巫的小屋</h1>
<hr>
<!-- 图片本身也是可点击的链接,并绑定点击事件 -->
<a id="door" href=""><img id="doorImage" src="assets/door_locked.png" width="240" onclick="openDoor()"></a>
<!-- 此段落初始为空,脚本将根据情况填充文本 -->
<p id="textBlock"></p>
<div>
    <a id="exit" href="swamp.html">退出</a>
</div>
<hr>
<iframe id="inventory" src="inventory.html" height="100" width="300"></iframe>

步骤3:添加复杂逻辑脚本

脚本需要:1) 传递状态;2) 从查询字符串中读取钥匙数量;3) 根据钥匙数量和门的状态定义 openDoor() 函数的行为。

<script>
    // 1. 传递状态
    let searchParams = window.location.search;
    document.getElementById('exit').href += searchParams;
    document.getElementById('inventory').src += searchParams;

    // 2. 解析状态数据
    let queryString = new URLSearchParams(searchParams);
    // 使用 + 将字符串转换为数字
    let keys = +queryString.get('keys'); // 例如 "1" -> 1

    // 3. 定义 openDoor 函数
    function openDoor() {
        let doorImg = document.getElementById('doorImage');
        let textBox = document.getElementById('textBlock');
        let doorLink = document.getElementById('door');

        // 情况A:门已经是打开的
        if (doorImg.src.includes('door_open')) {
            doorLink.href = `hut_inside.html?${queryString.toString()}`;
            return;
        }

        // 情况B:门锁着,但玩家有钥匙
        if (keys > 0) {
            // 更换图片
            doorImg.src = 'assets/door_open.png';
            // 更新文本
            textBox.innerHTML = '门锁开了,但钥匙也断了。';
            // 更新状态:钥匙数量减1
            keys--;
            queryString.set('keys', keys);
            // 更新页面上的链接以反映新状态
            document.getElementById('inventory').src = `inventory.html?${queryString.toString()}`;
            document.getElementById('exit').href = `swamp.html?${queryString.toString()}`;
        }
        // 情况C:门锁着,且没有钥匙
        else {
            textBox.innerHTML = '门锁着。';
        }
    }
</script>

核心概念解释

  • URLSearchParams 对象提供了读写URL查询字符串的便捷方法。
  • queryString.get('keys') 获取 keys 参数的值。
  • + 运算符在此处作为一元加运算符,将其后的值转换为数字。
  • queryString.set('keys', keys) 更新查询字符串对象中 keys 的值。
  • string.includes(substring) 方法检查一个字符串是否包含另一个字符串。

评估:尝试用钥匙开门和没有钥匙时开门,观察图片、文本和状态(钥匙数量)的变化。


目标8:构建小屋内部页面

本节我们将在小屋内部添加一个视频线索,该线索将在后续地牢场景中使用。

步骤1:创建HTML文件

创建 hut_inside.html 并添加基本结构。

步骤2:添加页面内容(包含视频)

使用 <video> 元素嵌入一个自动播放、循环的线索视频。

<h1>女巫的小屋</h1>
<hr>
<img src="assets/hut_inside.png" width="480">
<p>你进入小屋,发现一本发光的书。</p>
<!-- 视频元素:自动播放、循环、静音(Chrome中自动播放要求) -->
<video autoplay loop muted width="480">
    <source src="assets/runic_video.mp4" type="video/mp4">
</video>
<p>书中展示了一串闪烁的符文,似乎是解除某种封印的咒语。</p>
<div>
    <a id="exit" href="swamp.html">退出</a>
</div>
<hr>
<iframe id="inventory" src="inventory.html" height="100" width="300"></iframe>

步骤3:添加脚本以传递状态

只需确保退出链接和库存iframe携带状态数据。

<script>
    let searchParams = window.location.search;
    document.getElementById('exit').href += searchParams;
    document.getElementById('inventory').src += searchParams;
</script>

评估:页面加载时,视频应自动播放并循环。


目标9:构建地牢页面

本节我们将创建一个谜题场景,要求玩家输入正确的密码(来自小屋的视频线索)才能通过。

步骤1:创建HTML文件

创建 dungeon.html 并添加基本结构。

步骤2:添加页面内容(包含输入框)

使用 <input type="text"> 创建文本输入框,并使用按钮触发检查。

<h1>地牢入口</h1>
<hr>
<p>一个魔法封印挡住了去路。你必须解除它。</p>
<img src="assets/seal.gif">
<p>这里有一本符文咒语翻译法典。念错咒语是危险的。</p>
<img src="assets/runic_translation.png">
<div>
    <label>输入咒语:</label>
    <input type="text" id="inputText">
    <button onclick="magicWord()">提交</button>
</div>
<hr>
<div>
    <a id="exit" href="overworld.html">退出</a>
</div>
<iframe id="inventory" src="inventory.html" height="100" width="300"></iframe>

步骤3:添加密码验证脚本

脚本将检查输入框中的值,正确则前进,错误则触发“游戏结束”。

<script>
    // 传递状态
    let searchParams = window.location.search;
    document.getElementById('exit').href += searchParams;
    document.getElementById('inventory').src += searchParams;

    function magicWord() {
        let input = document.getElementById('inputText');
        let password = input.value;

        // 正确密码是 "open"(根据视频线索)
        if (password === 'open') {
            // 跳转到下一个场景(龙穴),并携带状态
            window.location.href = `dragon.html?${searchParams}`;
        } else {
            // 输入错误,显示死亡场景
            document.body.innerHTML = `
                <h1>你死了</h1>
                <img src="assets/gator.png" width="480">
                <p>你念错了咒语,封印将你吞噬。</p>
                <p><a href="index.html">游戏结束</a></p>
                <audio autoplay>
                    <source src="assets/death.mp3" type="audio/mpeg">
                </audio>
            `;
        }
    }
</script>

核心概念解释

  • input.value 获取文本输入框的当前内容。
  • window.location.href 获取或设置当前页面的完整URL。对其赋值会导致浏览器导航到新URL。

评估:尝试输入正确密码“open”和错误密码,观察不同的结果。


目标10:构建商店页面

本节我们将创建一个商店,玩家可以在这里消费金币来购买生命值、攻击力或钥匙。

步骤1:创建HTML文件

创建 shop.html 并添加基本结构。

步骤2:添加页面内容(包含按钮)

使用 <button> 元素创建三个购买选项。

<h1>商店</h1>
<hr>
<img src="assets/shop.png" width="480">
<h4>你想买什么?</h4>
<div>
    <button onclick="buyFood()">生命 ❤️<br>1 金币</button>
    <button onclick="buyWeapon()">攻击 ⚔️<br>5 金币</button>
    <button onclick="buyKey()">钥匙 🔑<br>10 金币</button>
</div>
<hr>
<div>
    <a id="exit" href="overworld.html">退出</a>
</div>
<iframe id="inventory" src="inventory.html" height="100" width="300"></iframe>

步骤3:添加商店逻辑脚本

脚本需要读取当前状态,并在购买时更新金币和相应属性。

<script>
    // 传递状态
    let searchParams = window.location.search;
    document.getElementById('exit').href += searchParams;
    document.getElementById('inventory').src += searchParams;

    // 解析状态数据为数字
    let queryString = new URLSearchParams(searchParams);
    let gp = +queryString.get('gp');
    let hp = +queryString.get('hp');
    let ap = +queryString.get('ap');
    let keys = +queryString.get('keys');

    function buyFood() {
        if (gp > 0) {
            gp--;
            hp++;
            updateState();
        }
    }

    function buyWeapon() {
        if (gp >= 5) {
            gp -= 5;
            ap++;
            updateState();
        }
    }

    function buyKey() {
        if (gp >= 10) {
            gp -= 10;
            keys++;
            updateState();
        }
    }

    function updateState() {
        // 更新查询字符串对象
        queryString.set('gp', gp);
        queryString.set('hp', hp);
        queryString.set('ap', ap);
        queryString.set('keys', keys);

        // 更新地址栏和页面上的链接,使状态生效
        window.location.search = queryString.toString();
    }
</script>

核心概念解释

  • -= 是用于减法的复合赋值运算符。
  • window.location.search = queryString.toString() 会使用新的查询字符串重新加载当前页面,从而更新所有显示的状态。

评估:购买物品,检查金币和物品数量是否正确更新。


目标11:构建哥布林战斗页面

本节我们将实现一个简单的回合制战斗系统,展示更复杂的游戏状态管理和DOM操作。

步骤1:创建HTML文件

创建 goblin.html 并添加基本结构。

步骤2:添加页面内容

页面显示哥布林图片、战斗文本和“战斗”/“撤退”选项。

<h1>山丘</h1>
<hr>
<img id="goblinImg" src="assets/goblin.png" width="480">
<p id="battleText">一个哥布林挡住了你的去路!</p>
<h4>你想做什么?</h4>
<div id="options">
    <button id="fightBtn" onclick="attack()">战斗</button>
    <a id="retreat" href="forest.html">撤退</a>
</div>
<hr>
<iframe id="inventory" src="inventory.html" height="100" width="300"></iframe>

步骤3:添加战斗逻辑脚本

脚本将模拟战斗回合,更新双方状态,并根据结果动态改变页面内容。

<script>
    // 传递状态并初始化
    let searchParams = window.location.search;
    document.getElementById('retreat').href += searchParams;
    document.getElementById('inventory').src += searchParams;

    let queryString = new URLSearchParams(searchParams);
    let gp = +queryString.get('gp');
    let hp = +queryString.get('hp');
    let ap = +queryString.get('ap');

    // 哥布林属性
    let goblinHp = 3;
    let goblinAp = 2;

    function attack() {
        // 玩家攻击哥布林
        goblinHp -= ap;
        let battleText = document.getElementById('battleText');
        let optionsDiv = document.getElementById('options');

        if (goblinHp <= 0) {
            // 哥布林死亡
            document.getElementById('goblinImg').src = 'assets/treasure.png';
            gp += 3;
            queryString.set('gp', gp);
            queryString.set('hp', hp); // 更新可能受伤后的生命值

            battleText.innerHTML = `你击败了哥布林,找到了3枚金币!`;
            // 移除战斗按钮,只留下返回链接
            optionsDiv.innerHTML = `<a href="forest.html?${queryString.toString()}">返回森林</a>`;
            // 更新库存显示
            document.getElementById('inventory').src = `inventory.html?${queryString.toString()}`;
            return;
        }

        // 哥布林反击
        hp -= goblinAp;
        queryString.set('hp', hp);

        // 更新战斗文本
        battleText.innerHTML = `你对哥布林造成了 <strong>${ap}</strong> 点伤害。它还有 <strong>${goblinHp}</strong> 点生命值。<br>
                                哥布林对你造成了 <strong>${goblinAp}</strong> 点伤害。`;

        // 更新库存显示当前生命值
        document.getElementById('inventory').src = `inventory.html?${queryString.toString()}`;

        // 检查玩家是否死亡
        if (hp <= 0) {
            battleText.innerHTML += '<br><strong>你死了!</strong>';
            optionsDiv.innerHTML = `<a href="index.html">游戏结束</a>`;
        }
    }
</script>

评估:进行战斗,测试胜利、失败和中间回合的情况,观察所有状态和页面元素是否正确更新。


目标12:构建最终场景(龙穴)

这是游戏的最后一个场景,作为一个悬念结局。

步骤1:创建HTML文件

创建 dragon.html 并添加基本结构。

步骤2:添加简单的页面内容

这个页面主要是叙事,没有复杂的交互。

<h1>龙穴</h1>
<hr>
<img src="assets/dragon.png" width="480">
<p>你步入地牢深处,发现一条红龙。它咆哮着向你喷出火焰。你拔出了剑...</p>
<p><strong>未完待续</strong></p>
<hr>
<p><a href="index.html">重新开始</a></p>

评估:确保能从地牢正确导航到此页面。


补充知识:音频自动播放问题

在开发过程中,你可能会遇到 <audio autoplay> 不工作的问题。这是由于现代浏览器的自动播放策略:

  • 文件协议(file://):通常完全禁止音频自动播放。
  • HTTP协议(http://):允许自动播放,但通常需要伴随一个用户手势(如点击链接)。页面刷新不被视为手势。

解决方案

  1. 使用简单的HTTP服务器来托管你的游戏文件。Chrome用户可以使用“Web Server for Chrome”等扩展。
  2. 设计游戏流程,使音频页面(如介绍页面)是从另一个页面(如主页)通过点击链接进入的,这满足了“用户手势”的要求。

示例:index.html 中有一个链接到 intro.html(带自动播放音频)。用户点击链接进入 intro.html 时,音频可以播放。


总结

本节课中,我们一起完成了一个完整HTML交互式游戏的所有核心场景。我们深入学习了:

  1. 状态管理:使用 URLSearchParams 和查询字符串在页面间传递和更新游戏数据(金币、生命值、攻击力、钥匙)。
  2. 动态内容更新:通过 document.getElementById().innerHTML.src.href 等属性和方法,使用JavaScript动态修改页面内容、图片、链接和文本。
  3. 事件处理:使用 onclick 属性将用户操作(点击)与JavaScript函数绑定,实现交互逻辑。
  4. 多媒体集成:在网页中嵌入和控制 <img><audio><video> 元素。
  5. 条件逻辑与游戏机制:实现了解谜(密码门)、购物(商店)、回合制战斗(哥布林)等复杂的游戏机制。

你现在已经掌握了使用纯HTML和JavaScript构建基础交互式应用的核心技能。在接下来的CSS课程中,我们将为这个游戏添加样式,让它变得更加美观。

003:CSS速成教程 🎨

在本节课中,我们将学习如何使用CSS(层叠样式表)来美化网页。我们将从基础开始,逐步探索CSS的核心概念,包括选择器、颜色、字体、间距、尺寸、背景、自定义样式以及使用Flexbox和Grid进行布局。通过本教程,你将掌握使用CSS为网页添加样式的实用技能。

概述 📋

CSS是一种用于描述网页外观和格式的样式表语言。它允许我们控制HTML元素的颜色、字体、间距、布局等视觉属性。本节课将通过一个实践性实验,带你快速上手CSS的各个方面。


目标1:设置项目结构 📁

在开始编写CSS之前,我们需要先建立项目的基本结构。这包括创建HTML文件、CSS文件以及存放资源的文件夹。

首先,创建一个项目根目录,例如 css_crash_course。在该目录下,创建以下内容:

  • index.html:主HTML文档。
  • assets/ 目录:存放图片等资源文件。
  • styles/ 目录:存放CSS样式文件,例如 styles.css

接下来,在 index.html 文件中设置基本的HTML结构,包括 <head><body> 标签。在 <head> 中,我们链接CSS文件。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CSS Crash Course</title>
    <link rel="stylesheet" href="styles/styles.css">
</head>
<body>
    <h1>CSS Crash Course</h1>
    <hr>
    <!-- 后续的实验目标将在这里添加 -->
</body>
</html>

现在,我们已经搭建好了项目的基础框架。接下来,我们将深入CSS的核心部分。


目标2:理解CSS选择器 🎯

CSS选择器用于指定我们要样式化的HTML元素。理解选择器是应用样式的基础。主要有三种基本类型的选择器:元素选择器、类选择器和ID选择器。

以下是三种选择器的示例:

/* 1. 元素选择器:选择所有 <body> 元素 */
body {
    background-color: gray;
}

/* 2. 类选择器:选择所有 class="class-selector" 的元素 */
.class-selector {
    text-align: right;
}

/* 3. ID选择器:选择 id="id-selector" 的元素 */
#id-selector {
    text-align: center;
}

对应的HTML结构如下:

<h3>Goal 1: Selectors</h3>
<p>This is the body set to gray.</p>
<p class="class-selector">This element was selected by its class name.</p>
<p id="id-selector">This element was selected by its ID.</p>

关键点

  • 类选择器(以 . 开头)可以应用于多个元素,一个元素也可以拥有多个类。
  • ID选择器(以 # 开头)应该是唯一的,通常用于在JavaScript中精确选择单个元素。
  • 在样式中,更推荐使用类选择器,因为它们更具复用性。

目标3:使用CSS颜色 🎨

CSS允许我们为元素的背景和文本设置颜色。颜色可以通过名称、RGB值或十六进制值来定义。

以下是如何定义和应用颜色的示例:

/* 使用RGB值定义背景色 */
.bg-white {
    background-color: rgb(255, 255, 255); /* 白色 */
}
.bg-red {
    background-color: rgb(255, 0, 0); /* 红色 */
}
.bg-green {
    background-color: rgb(0, 255, 0); /* 绿色 */
}
.bg-blue {
    background-color: rgb(0, 0, 255); /* 蓝色 */
}
.bg-dark {
    background-color: rgb(0, 0, 0); /* 黑色 */
}

/* 定义文本颜色 */
.text-white {
    color: rgb(255, 255, 255);
}
.text-red {
    color: rgb(255, 0, 0);
}
.text-green {
    color: rgb(0, 255, 0);
}
.text-blue {
    color: rgb(0, 0, 255);
}

在HTML中,我们可以将多个类组合使用:

<p class="bg-green text-white">绿色背景,白色文字</p>
<p class="bg-blue text-red">蓝色背景,红色文字</p>

通过组合不同的类,我们可以轻松创建丰富的颜色搭配。


目标4:应用字体和文本样式 ✍️

CSS提供了丰富的属性来控制字体样式,包括字体系列、大小、粗细和风格。我们还可以利用外部资源,如Google Fonts,来扩展字体选择。

首先,我们可以使用内置字体属性:

.bold-text {
    font-weight: bold; /* 加粗 */
}
.italic-text {
    font-style: italic; /* 斜体 */
}
.large-text {
    font-size: large; /* 大号字体 */
}
.small-text {
    font-size: small; /* 小号字体 */
}

要使用Google Fonts,需要在HTML的 <head> 中链接字体库,然后在CSS中引用:

<!-- 在HTML中链接Google Fonts -->
<head>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bangers&display=swap">
</head>
/* 在CSS中使用Google字体 */
.aerial-font {
    font-family: Arial, sans-serif; /* 内置字体 */
}
.google-font {
    font-family: 'Bangers', cursive; /* Google字体 */
}

这样,我们就可以为网页添加独特且美观的字体样式。


目标5:掌握盒模型与间距 📦

CSS使用“盒模型”来定义元素周围的间距。每个元素都被视为一个盒子,包含内容、内边距、边框和外边距。

以下是盒模型相关属性的示例:

.outline {
    outline: 3px dashed red; /* 轮廓,在边框外部 */
}
.border {
    border: 3px solid blue; /* 边框,在内边距外部 */
}
.padding-example {
    padding: 20px; /* 内边距,内容与边框之间的空间 */
}
.margin-example {
    margin: 20px; /* 外边距,元素与其他元素之间的空间 */
}
.container {
    width: 90%; /* 容器宽度为视口的90% */
    margin: auto; /* 自动外边距实现水平居中 */
}

理解盒模型

  • 内边距 增加元素内容与边框之间的空间。
  • 外边距 控制元素与相邻元素之间的空间。
  • 宽度/高度 设置元素的尺寸,可以使用百分比或固定单位(如像素)。

通过调整这些属性,我们可以精确控制页面的布局和间距。


目标6:控制元素尺寸 📏

我们可以使用CSS控制元素的宽度和高度。widthheight 属性直接设置尺寸,而 max-widthmax-height 则用于限制元素的最大尺寸,这在响应式设计中非常有用。

以下是尺寸控制的示例:

.fill-viewport {
    width: 100%; /* 宽度占满视口 */
    height: 100%; /* 高度占满视口 */
}
.shrink-to-fit {
    max-width: 100%; /* 最大宽度不超过视口 */
    max-height: 100%; /* 最大高度不超过视口 */
}

这些属性特别适用于处理图片,确保它们在不同屏幕尺寸下都能正常显示。


目标7:设置背景样式 🌈

除了背景颜色,CSS还允许我们为元素设置背景图片、渐变等。背景可以应用于任何HTML元素,并且可以多层嵌套。

以下是背景样式的几种应用:

.background-image {
    background-image: url('../assets/bg-image.jpg'); /* 设置背景图片 */
}
.background-no-repeat {
    background-image: url('../assets/bg-image.jpg');
    background-repeat: no-repeat; /* 禁止重复 */
    background-position: center; /* 图片居中 */
}
.background-fixed {
    background-image: url('../assets/bg-image.jpg');
    background-attachment: fixed; /* 背景固定,不随滚动移动 */
}
.background-gradient {
    background: linear-gradient(to right, red, yellow); /* 线性渐变背景 */
}

通过组合这些属性,我们可以创建出视觉上引人注目的背景效果。


目标8:自定义默认样式 🛠️

许多HTML元素(如链接、列表、表格)有默认的浏览器样式。我们可以使用CSS覆盖这些样式,以实现自定义的外观。

以下是一些常见的自定义样式示例:

/* 自定义链接样式 */
a {
    text-decoration: none; /* 移除下划线 */
}
a:hover {
    color: red; /* 鼠标悬停时变为红色 */
}

/* 自定义列表样式 */
ul {
    list-style-image: url('assets/finger.png'); /* 使用自定义图片作为列表标记 */
    list-style-position: inside; /* 标记位于列表项内部 */
}

/* 自定义表格样式 */
table {
    width: 100%;
    border-collapse: collapse; /* 合并边框 */
}
th, td {
    text-align: center;
    padding: 5px;
}
th {
    background-color: green;
    color: white;
}
tr:nth-child(even) {
    background-color: #f2f2f2; /* 偶数行背景色 */
}

这些自定义样式可以大大提升网页的专业性和美观度。


目标9:使用Flexbox进行布局 🔄

Flexbox是一种CSS布局模式,用于更高效地排列、对齐和分配容器内项目之间的空间,即使它们的大小是未知或动态的。

以下是Flexbox布局的几种常见对齐方式:

.flex-container {
    display: flex; /* 启用Flexbox */
    border: 1px solid black; /* 为了可视化容器边界 */
}
.justify-left {
    justify-content: flex-start; /* 项目左对齐 */
}
.justify-center {
    justify-content: center; /* 项目居中对齐 */
}
.justify-right {
    justify-content: flex-end; /* 项目右对齐 */
}
.justify-space-between {
    justify-content: space-between; /* 项目之间间隔相等,首尾贴边 */
}
.justify-space-around {
    justify-content: space-around; /* 项目周围间隔相等 */
}
.justify-space-evenly {
    justify-content: space-evenly; /* 所有间隔完全相等 */
}

Flexbox使得创建复杂布局变得简单直观,特别是在处理动态内容时。


目标10:使用Grid进行布局 📐

CSS Grid布局是一个二维布局系统,允许我们创建基于行和列的复杂布局。它比Flexbox更适合于整体页面布局。

以下是Grid布局的基本示例:

.grid-container {
    display: grid; /* 启用Grid布局 */
    justify-items: center; /* 单元格内容水平居中 */
}
.one-column {
    grid-template-columns: 1fr; /* 一列,占满可用空间 */
}
.two-columns {
    grid-template-columns: 1fr 1fr; /* 两列,等宽 */
}
.three-columns {
    grid-template-columns: repeat(3, 1fr); /* 三列,使用repeat函数 */
}

fr 单位代表“分数”,用于分配可用空间。Grid布局提供了强大的控制能力,可以轻松创建杂志式的多列布局。


总结 🎓

在本节课中,我们一起学习了CSS的核心概念和实用技巧。我们从项目结构开始,逐步探索了选择器、颜色、字体、盒模型、尺寸控制、背景样式、自定义默认样式,以及使用Flexbox和Grid进行现代布局。

通过掌握这些知识,你现在已经具备了使用CSS美化网页的能力。记住,实践是学习的关键。多尝试、多查阅文档(如MDN Web Docs),你将能够创建出既美观又功能强大的网页界面。

祝你编码愉快!

004:Bootstrap实战教程 🚀

在本节课中,我们将学习如何使用Bootstrap框架快速构建一个响应式的个人作品集网站。我们将从核心概念入手,逐步搭建网站的各个页面,包括首页、联系页面、项目展示页、博客页和关于页面,并最终添加一个导航栏将所有页面连接起来。

概述

Bootstrap是一个流行的前端开源工具包,它提供了一套预定义的CSS样式和JavaScript组件,使我们能够快速设计和定制响应式的、移动设备优先的网站。通过本教程,你将学会如何利用Bootstrap的类名来替代手写CSS,高效地创建专业美观的网页。


核心概念:什么是Bootstrap? 🧩

上一节我们介绍了本课程的目标,本节中我们来看看Bootstrap到底是什么。

Bootstrap是一个样式框架或库,用于快速设计和定制响应式、移动设备优先的网站。它是目前最流行的前端开源工具包。

它为我们提供了以下核心功能:

  • 响应式网格系统:帮助网页布局在不同屏幕尺寸下都能良好显示。
  • 预定义样式:提供大量预定义的CSS类,用于设置颜色、间距、排版等。
  • 组件:包含按钮、卡片、轮播图、导航栏等可复用的UI组件。
  • 图标库:提供免费的图标集(Bootstrap Icons)。

响应式网页设计指的是网页能够根据用户设备的屏幕尺寸(如横屏的笔记本电脑或竖屏的智能手机)自动调整布局,以提供最佳的浏览体验。


目标0:设计在线作品集 💡

在开始编码之前,我们需要规划作品集的结构。一个作品集网站是你的个人品牌在互联网上的展示窗口,用于向公众分享你的技能、项目和文章。

以下是作品集通常应包含的几个部分及其目标:

  • 首页:吸引访问者注意力,突出重要信息。
  • 项目页:展示你的编程项目,包含源代码和演示链接。
  • 博客页:发布你感兴趣的主题文章,特别是与项目相关的开发日记。
  • 关于页:展示个人技能、经历等信息。
  • 联系页:让感兴趣的人能够轻松联系到你。

目标1:构建首页 🏠

现在,我们开始构建网站的入口——首页。我们将创建一个 index.html 文件。

步骤1:基础HTML结构与引入Bootstrap

首先,我们设置HTML文档的基本结构,并在 <head> 中引入Bootstrap的CSS文件。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>你的名字</title>
    <!-- 引入Bootstrap CSS -->
    <link rel="stylesheet" href="path/to/bootstrap.min.css">
    <link rel="stylesheet" href="path/to/bootstrap-icons.css">
</head>
<body>
    <!-- 页面内容将放在这里 -->
    <!-- 在body末尾引入Bootstrap JS -->
    <script src="path/to/bootstrap.bundle.min.js"></script>
</body>
</html>

步骤2:构建页面内容与样式

接下来,我们在 <body> 中添加内容,并应用Bootstrap类来实现全屏背景图、文字居中等效果。

以下是首页内容的结构和关键Bootstrap类:

<body>
    <!-- 全屏背景图片 -->
    <img src="assets/background.jpg" class="position-absolute w-100 h-100">
    
    <!-- 主要内容容器 -->
    <div class="container position-relative text-white text-center h-100 d-flex flex-column justify-content-center">
        <h1 class="display-4">Hello World</h1>
        <p>这是一段关于你和你的作品集的介绍文字。</p>
        
        <!-- 按钮组 -->
        <div>
            <button class="btn btn-outline-light" onclick="location.href='projects.html'">
                <i class="bi bi-code-slash"></i> 项目
            </button>
            <button class="btn btn-outline-light" onclick="location.href='blog.html'">
                <i class="bi bi-chat-dots"></i> 博客
            </button>
            <button class="btn btn-outline-light" onclick="location.href='about.html'">
                <i class="bi bi-person"></i> 关于
            </button>
            <button class="btn btn-outline-light" onclick="location.href='contact.html'">
                <i class="bi bi-envelope"></i> 联系
            </button>
        </div>
    </div>
    
    <script src="scripts/bootstrap.bundle.min.js"></script>
</body>

代码解释

  • position-absolute, w-100, h-100:使背景图片绝对定位并覆盖整个视口。
  • container:创建一个居中的内容容器。
  • position-relative, text-white, text-center:设置文字相对定位、白色和居中。
  • h-100, d-flex, flex-column, justify-content-center:使用Flexbox实现内容的垂直居中。
  • display-4:应用Bootstrap的大标题字体样式。
  • btn btn-outline-light:创建带有白色边框的按钮样式。
  • bi bi-code-slash:使用Bootstrap图标。

目标2:构建联系页面 📧

完成了首页,本节我们来创建一个联系表单页面 contact.html

步骤1:创建表单结构

我们首先构建一个包含姓名、邮箱、主题和消息字段的基础表单。

<body class="bg-dark text-light">
    <div class="container mt-4">
        <h1 class="display-1"><i class="bi bi-envelope"></i> 联系</h1>
        <hr>
        
        <form>
            <div class="form-group mb-3">
                <label>姓名</label>
                <input type="text" class="form-control" placeholder="姓名">
            </div>
            <div class="form-group mb-3">
                <label>邮箱</label>
                <input type="email" class="form-control" placeholder="邮箱">
            </div>
            <div class="form-group mb-3">
                <label>主题</label>
                <input type="text" class="form-control" placeholder="主题">
            </div>
            <div class="form-group mb-3">
                <label>消息</label>
                <textarea class="form-control" rows="4" placeholder="消息"></textarea>
            </div>
            <button type="submit" class="btn btn-primary float-end">发送</button>
        </form>
    </div>
</body>

代码解释

  • bg-dark, text-light:设置深色背景和浅色文字。
  • container, mt-4:创建容器并添加上边距。
  • form-group, form-control:Bootstrap提供的表单组和控件样式类。
  • mb-3:为每个表单组添加底部边距。
  • btn btn-primary float-end:创建蓝色主要按钮并使其右浮动。

目标3:构建项目页面 💻

现在,我们创建一个展示个人项目的页面 projects.html,使用Bootstrap卡片组件。

步骤1:使用卡片和卡片组

我们将使用 cardcard-group 类来展示项目。

<body class="bg-primary text-light">
    <div class="container mt-4">
        <h1 class="display-1"><i class="bi bi-code-slash"></i> 项目</h1>
        <hr>
        
        <!-- 卡片组 -->
        <div class="card-group gap-2">
            <!-- 卡片1 -->
            <div class="card bg-dark text-center">
                <div class="card-header">
                    <img src="assets/project1.jpg" class="img-thumbnail">
                </div>
                <div class="card-body">
                    <h5>项目标题一</h5>
                    <p>关于项目一的简要描述。</p>
                </div>
                <div class="card-footer text-muted">
                    <small>HTML, CSS, JavaScript</small>
                    <button class="btn btn-primary float-end">查看更多</button>
                </div>
            </div>
            
            <!-- 卡片2 -->
            <div class="card bg-dark text-center">
                <!-- 结构同卡片1 -->
            </div>
        </div>
    </div>
</body>

代码解释

  • card-group:将多个卡片组合在一起,并自动处理响应式布局。
  • gap-2:在卡片之间添加间距。
  • card, card-header, card-body, card-footer:定义卡片的各个部分。
  • img-thumbnail:为图片添加缩略图样式。
  • text-muted:使文字显示为灰色。

目标4:构建博客页面 📝

接下来,我们创建一个博客页面 blog.html,并使用Bootstrap的轮播图组件展示博客文章摘要。

步骤1:实现轮播图

轮播图包含幻灯片、指示器和控制按钮。

<body class="bg-danger text-light">
    <div class="container mt-2">
        <h1 class="display-1"><i class="bi bi-chat-dots"></i> 博客</h1>
        <hr>
        
        <!-- 轮播图 -->
        <div id="blogCarousel" class="carousel slide" data-bs-ride="carousel" data-bs-interval="1000">
            
            <!-- 轮播指示器 -->
            <ol class="carousel-indicators">
                <li data-bs-target="#blogCarousel" data-bs-slide-to="0" class="active"></li>
                <li data-bs-target="#blogCarousel" data-bs-slide-to="1"></li>
            </ol>
            
            <!-- 轮播内容 -->
            <div class="carousel-inner text-center">
                <!-- 幻灯片1 -->
                <div id="slide1" class="carousel-item active">
                    <img src="assets/blog1.jpg" class="d-block w-100">
                    <div class="carousel-caption">
                        <h5>博客文章一</h5>
                        <p>文章一的简要描述。</p>
                        <button class="btn btn-light" onclick="location.href='articles/article1.html'">阅读更多</button>
                    </div>
                </div>
                <!-- 幻灯片2 -->
                <div id="slide2" class="carousel-item">
                    <!-- 结构同幻灯片1 -->
                </div>
            </div>
            
            <!-- 轮播控制按钮 -->
            <a class="carousel-control-prev" href="#blogCarousel" role="button" data-bs-slide="prev">
                <span class="carousel-control-prev-icon"></span>
            </a>
            <a class="carousel-control-next" href="#blogCarousel" role="button" data-bs-slide="next">
                <span class="carousel-control-next-icon"></span>
            </a>
        </div>
    </div>
</body>

代码解释

  • carousel slide:声明一个轮播图组件。
  • data-bs-ride="carousel", data-bs-interval="1000":设置自动播放和间隔时间。
  • carousel-indicators:轮播图底部的指示点。
  • carousel-inner, carousel-item:轮播图的内容容器和单个幻灯片。
  • carousel-caption:幻灯片上的文字说明区域。
  • carousel-control-prev, carousel-control-next:左右切换按钮。

目标5:构建关于页面 👤

关于页面 about.html 将展示个人信息和技能,并利用Bootstrap的网格系统实现复杂布局。

步骤1:使用网格系统布局

我们使用 rowcol-*-* 类来创建响应式列布局。

<body class="bg-success text-light">
    <div class="container mt-4">
        <h1 class="display-1"><i class="bi bi-person"></i> 关于</h1>
        <hr>
        
        <div class="container-fluid text-center">
            <div class="row">
                <!-- 左侧栏 -->
                <div class="col-md-2">
                    <img src="assets/avatar.jpg" class="img-thumbnail rounded-circle w-50">
                    <div class="d-flex justify-content-center gap-4 m-4">
                        <a href="#"><i class="bi bi-twitter"></i></a>
                        <a href="#"><i class="bi bi-linkedin"></i></a>
                        <a href="#"><i class="bi bi-github"></i></a>
                    </div>
                    <hr>
                    <div class="d-flex flex-row row-cols-1">
                        <span>教育背景</span>
                        <span>计算机科学</span>
                        <span>某某大学</span>
                    </div>
                </div>
                
                <!-- 右侧主内容区 -->
                <div class="col-md-10 bg-dark">
                    <div class="row">
                        <div class="col-md-8 col-12">
                            <h2>你的名字</h2>
                            <p>你的个人陈述或简介。</p>
                        </div>
                        <div class="col-md-4 d-none d-md-block">
                            <img src="assets/photo.jpg" class="img-thumbnail">
                        </div>
                    </div>
                    <hr>
                    <h3>技能</h3>
                    <div class="d-flex flex-row row-cols-5">
                        <img src="assets/skill1.png" class="img-thumbnail">
                        <img src="assets/skill2.png" class="img-thumbnail">
                        <!-- 更多技能图标 -->
                    </div>
                </div>
            </div>
        </div>
    </div>
</body>

代码解释

  • row:定义一行。
  • col-md-2, col-md-10:在中等及以上屏幕尺寸下,左侧栏占2列,右侧内容占10列。
  • d-flex, justify-content-center:使用Flexbox进行水平居中布局。
  • d-none d-md-block:在中等及以上屏幕显示,小屏幕隐藏。
  • row-cols-5:在Flexbox布局中,每行放置5个元素。

目标6:添加导航栏 🔗

最后,我们为所有页面添加一个统一的、可折叠的导航栏。

步骤1:实现导航栏

导航栏包含品牌标识、导航链接和一个移动端可折叠的菜单。

<nav class="navbar navbar-light bg-light container-fluid">
    <!-- 品牌标识 -->
    <a class="navbar-brand" href="index.html">
        <i class="bi bi-house"></i> 你的品牌
    </a>
    
    <!-- 移动端折叠按钮 -->
    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarOptions">
        <span class="navbar-toggler-icon"></span>
    </button>
    
    <!-- 导航链接 -->
    <div class="collapse navbar-collapse" id="navbarOptions">
        <ul class="navbar-nav">
            <li class="nav-item">
                <a class="nav-link" href="projects.html"><i class="bi bi-code-slash"></i> 项目</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="blog.html"><i class="bi bi-chat-dots"></i> 博客</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="about.html"><i class="bi bi-person"></i> 关于</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="contact.html"><i class="bi bi-envelope"></i> 联系</a>
            </li>
        </ul>
    </div>
</nav>

代码解释

  • navbar:声明一个导航栏组件。
  • navbar-toggler, data-bs-toggle="collapse":创建可折叠菜单的按钮。
  • collapse navbar-collapse:定义可折叠的菜单内容区域。
  • navbar-nav, nav-item, nav-link:定义导航链接列表的样式。

将这段导航栏代码复制到每个页面的 <body> 标签开头,即可实现网站的统一导航。


总结 🎉

本节课中,我们一起学习了如何使用Bootstrap框架高效地构建一个完整的、响应式的个人作品集网站。

我们完成了以下内容:

  1. 理解了Bootstrap的核心概念:它是一个提供预定义样式和组件的框架,用于快速实现响应式设计。
  2. 规划了作品集结构:定义了首页、项目页、博客页、关于页和联系页的功能。
  3. 搭建了所有核心页面
    • 使用Flexbox和工具类创建了视觉突出的首页。
    • 利用表单组件构建了功能完整的联系页面。
    • 通过卡片和卡片组优雅地展示了项目。
    • 实现了带有轮播图的动态博客页面。
    • 运用网格系统设计了布局复杂的关于页面。
  4. 实现了网站导航:添加了可折叠的响应式导航栏,将所有页面连接起来。

通过本教程,你掌握了仅通过HTML类名应用Bootstrap样式和组件的能力,无需编写复杂的自定义CSS,就能创建出专业、美观且适配各种设备的网页。现在,你可以运用这些知识去设计和构建属于你自己的个性化作品集网站了。

005:JavaScript面向对象编程 part1

在本节课中,我们将开始学习JavaScript,并着手构建一个基于浏览器的平台游戏。我们将采用面向对象的设计方法,从零开始逐步实现游戏的核心功能。本节课是系列课程的第一部分,我们将完成项目的初始设置和基础架构搭建。


课程概述与目标

本节课的目标是开始一个JavaScript项目,并采用面向对象的设计方法。我们将构建一个平台游戏,玩家需要跳跃方块、躲避危险并找到出口。我们将学习JavaScript的基础知识、类和对象、继承与多态,以及如何使用Canvas API进行绘图。

项目架构与设计

我们将采用迭代开发的方式,将项目分解为多个小目标。项目目录结构如下:

  • platformer.html:应用的HTML入口文件。
  • assets/:存放所有图像和资源文件。
  • scripts/:存放所有JavaScript文件。

我们的游戏将遵循模型-视图-控制器(MVC)设计模式:

  • 模型:管理游戏数据和逻辑(如GameWorldScene类)。
  • 视图:负责渲染图形到Canvas(View类)。
  • 控制器:处理用户输入(如键盘事件)。

以下是计划创建的核心类:

  • Game:管理游戏主循环和规则。
  • World:加载和管理关卡数据。
  • Scene:管理场景中的游戏对象。
  • GameObject:所有游戏实体的基类。
  • Block:继承自GameObject,代表可碰撞的方块。
  • Player:继承自GameObject,代表玩家角色。
  • Physics:管理重力、摩擦力和碰撞检测。
  • Controller:管理玩家控制输入。
  • Animation:管理角色动画。


目标1:创建HTML页面

首先,我们需要创建一个HTML文件作为JavaScript应用的启动点。该文件将包含Canvas元素,并导入后续的JavaScript文件。

实施步骤

  1. 创建名为platformer.html的文件。
  2. 在文件中添加基本的HTML结构,包括<head><body>
  3. <body>中创建一个<canvas>元素,为其设置ID、尺寸和边框样式,以便在浏览器中可见。

以下是platformer.html的初始代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Platformer</title>
</head>
<body>
    <canvas id="viewport" width="500" height="500" style="border: 1px solid black;"></canvas>
</body>
</html>

测试

在浏览器中打开platformer.html文件,应能看到一个带有黑色边框的500x500像素画布。


目标2:创建视图(View)类

上一节我们创建了HTML页面,本节中我们来看看如何创建负责图形渲染的View类。这个类将封装Canvas API,提供绘制图像的方法。

实施步骤

  1. scripts/目录下创建View.js文件。
  2. 在文件中定义一个名为View的类。
  3. 在类中定义私有实例变量#canvas#context,分别用于存储Canvas元素及其绘图上下文。
  4. 创建一个构造函数,用于初始化这些实例变量。
  5. 添加一个picture(image, x, y, width, height)方法,用于将图像绘制到Canvas的指定位置。
  6. 在类外部创建一个全局常量view,并实例化View类,使其在应用中像单例一样使用。
  7. platformer.html<body>末尾导入View.js文件。

以下是View.js的代码:

class View {
    #canvas;
    #context;

    constructor() {
        this.#canvas = document.getElementById('viewport');
        this.#context = this.#canvas.getContext('2d');
    }

    picture(image, x, y, width, height) {
        this.#context.drawImage(image, x, y, width, height);
    }
}

const view = new View();

platformer.html中导入:

<script src="scripts/View.js"></script>

测试

在浏览器中打开开发者工具的控制台,可以手动测试View类:

// 创建一个图像对象
let bg = new Image();
bg.src = 'assets/background.png';
// 使用view单例绘制背景
view.picture(bg, 0, 0, 500, 500);

如果背景图像被成功绘制到画布上,则说明View类工作正常。


目标3:创建游戏(Game)类

现在我们已经有了视图,接下来需要创建管理游戏核心逻辑的Game类。这个类将负责游戏的主循环、状态更新和渲染调度。

实施步骤

  1. scripts/目录下创建Game.js文件。
  2. 定义一个名为Game的类。
  3. 添加私有实例变量#isOver,表示游戏是否结束。
  4. 创建构造函数,将#isOver初始化为false
  5. 创建update()render()方法作为存根,目前仅在控制台输出日志。
  6. 创建一个静态方法main(),作为游戏的主循环。在此方法中,检查游戏是否结束,如果未结束,则调用update()render()方法,并利用window.requestAnimationFrame()递归调用main()自身以实现循环。
  7. 在类外部创建一个全局常量game,并实例化Game类。
  8. platformer.html中导入Game.js文件。
  9. 添加一个脚本,在文档加载完成后,调用Game.main()启动游戏循环。

以下是Game.js的代码:

class Game {
    #isOver;

    constructor() {
        this.#isOver = false;
    }

    update() {
        console.log('game update');
    }

    render() {
        console.log('game render');
    }

    static main() {
        if (!game.#isOver) {
            game.update();
            game.render();
            window.requestAnimationFrame(Game.main);
        } else {
            console.log('game over');
        }
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/3749376bc3066f41ab4d109fbb68a12c_18.png)

const game = new Game();

platformer.html中导入并启动游戏:

<script src="scripts/View.js"></script>
<script src="scripts/Game.js"></script>
<script>
    window.addEventListener('load', Game.main);
</script>

测试

刷新浏览器页面,打开开发者控制台。应该会看到“game update”和“game render”信息在持续不断地滚动输出,这表明游戏主循环已成功运行。

注意:测试完成后,请删除update()render()方法中的console.log语句,以免干扰后续开发。


目标4:创建世界(World)类并解析数据

上一节我们建立了游戏循环,本节中我们来看看如何创建World类来管理关卡数据。我们将使用预先提供的关卡数据文件。

实施步骤

  1. 项目已提供world-data.js文件,其中定义了一个全局数组world,包含三个字符串,每个字符串代表一个关卡的地图。字符含义如下:
    • #:标准方块
    • V:天花板危险
    • A:地板危险
    • @:玩家起始位置
    • !:出口
  2. scripts/目录下创建World.js文件。
  3. 定义一个名为World的类。
  4. 添加私有实例变量#levels,用于存储解析后的关卡数据(二维数组)。
  5. 创建构造函数,使用数组的map()split()等高阶函数方法,将world-data.js中的字符串数据解析为二维字符数组。
  6. Game类中添加一个#world实例变量,并在其构造函数中实例化World类,以便游戏可以访问关卡数据。暂时使用console.log输出world实例以进行验证。
  7. platformer.html中按正确顺序导入world-data.jsWorld.js文件(world-data.js需在World.js之前导入)。

以下是World.js的代码:

class World {
    #levels;

    constructor() {
        this.#levels = world.map(level => level.split('\n').map(row => row.split('')));
    }
}

更新Game.js的构造函数以包含World

class Game {
    #isOver;
    #world;

    constructor() {
        this.#isOver = false;
        this.#world = new World();
        console.log(this.#world); // 用于测试,后续删除
    }
    // ... 其他方法不变
}

platformer.html中按顺序导入:

<script src="scripts/world-data.js"></script>
<script src="scripts/World.js"></script>
<script src="scripts/View.js"></script>
<script src="scripts/Game.js"></script>

测试

刷新浏览器页面,打开开发者控制台。现在除了游戏循环日志,还应该能看到一个World对象的输出。展开该对象,可以看到#levels属性是一个三维数组,准确地表示了三个关卡的字符布局。这证明我们成功解析了关卡数据。


本节课总结

在本节课中,我们一起学习了JavaScript面向对象编程的初步实践。我们完成了以下工作:

  1. 创建了项目的HTML入口文件并设置了Canvas画布。
  2. 构建了View类,它封装了Canvas API,负责游戏的图形渲染。
  3. 构建了Game类,它建立了游戏的主循环,管理更新与渲染流程。
  4. 构建了World类,它成功加载并解析了外部的关卡数据,为后续创建游戏对象奠定了基础。

我们已经搭建起一个基本的、可运行的游戏框架。在接下来的课程中,我们将继续迭代开发,创建SceneGameObjectPlayer等类,并逐步添加物理、控制、碰撞检测等功能,最终完成整个平台游戏。

006:JavaScript面向对象编程 part2

在本节课中,我们将继续构建一个全面的JavaScript应用程序,充分利用面向对象编程原则。我们将从上次视频结束的地方开始,解析游戏关卡数据,并逐步创建场景、游戏对象、方块、玩家角色以及物理系统。

概述

本节课我们将学习如何将外部数据解析为应用程序内部可用的数据结构,并基于此创建游戏场景。我们将定义Scene类来管理游戏场景,GameObject类作为所有游戏对象的基类,并创建BlockPlayer等具体类。最后,我们将实现一个简单的物理系统,让玩家角色受重力影响下落。


解析关卡数据

上一节我们开始处理一个JavaScript应用程序,目标是解析作为实验作业一部分提供的JS文件中的数据。我们有一个包含字符串的数组,需要解析这些字符串,以便将其视为代表每个图块的字符的二维数组。

以下是解析数据的核心代码:

world.map(level => level.split('\n').map(row => row.split('')))

这段代码使用了高阶函数map和一个匿名箭头函数。它遍历world数组中的每个元素(每个关卡字符串),首先按换行符\n分割成行数组,然后对每一行按空字符''分割,最终得到一个二维字符数组,代表关卡的网格布局。

工作原理

  1. world.map(...):遍历world数组中的每个关卡字符串。
  2. level.split('\n'):将每个关卡字符串按换行符分割,得到一个行数组。
  3. .map(row => row.split('')):对上一步得到的行数组再次使用map,将每一行字符串分割成单个字符的数组。

这种编程风格称为函数式编程,它允许我们将一个函数的输出直接作为另一个函数的输入,形成链式调用,无需在函数调用之间维护状态。


定义场景类

现在我们已经可以解析关卡数据,接下来开始定义Scene类。场景类将管理每个可玩游戏场景的所有数据,例如玩家、方块、危险物和出口等游戏对象。

首先,我们创建scene.js文件并定义类的基本结构:

class Scene {
    constructor(map) {
        this.#setScene(map);
    }

    #setScene(worldData) {
        const rows = worldData.length;
        const cols = worldData[0].length;

        for (let y = 0; y < rows; y++) {
            for (let x = 0; x < cols; x++) {
                const tile = worldData[y][x];
                console.log(tile); // 测试用,解析后删除
            }
        }
    }
}

代码解释

  • constructor(map):构造函数接收一个地图数据参数,并调用#setScene方法来构建场景。
  • #setScene(worldData):这是一个私有方法,用于解析传入的二维数组worldData。它首先获取行数和列数,然后通过双层循环遍历每个图块字符。目前,我们只是将每个字符打印到控制台进行测试。

接下来,我们需要更新World类,使其能够根据关卡编号返回对应的二维数组数据。然后,在Game类中初始化当前关卡编号和场景对象,并将场景脚本添加到HTML文档中。

完成这些步骤后,刷新页面,控制台将成功输出第一个关卡的所有图块字符,表明数据解析正确。


创建游戏对象基类

我们的通用方法是先讨论游戏对象模型及其职责。GameObject类将管理所有游戏对象共享的数据。我们假设玩家、方块、危险物等都具有共同的特性,这些特性将在GameObject类中定义。

首先,创建game-object.js文件:

class GameObject {
    #x;
    #y;
    #width;
    #height;
    #image;

    constructor(x, y, width, height, imagePath) {
        this.#x = x;
        this.#y = y;
        this.#width = width;
        this.#height = height;
        this.#image = new Image();
        this.#image.src = imagePath;
    }

    draw() {
        view.drawImage(this.#image, this.#x, this.#y, this.#width, this.#height);
    }

    getX() { return this.#x; }
    getY() { return this.#y; }

    move(x, y) {
        this.#x = x;
        this.#y = y;
    }
}

代码解释

  • 属性:使用#前缀定义私有属性,包括坐标xy,尺寸widthheight和图像image
  • 构造函数:接收位置、尺寸和图片路径参数,并初始化对象。对于图像,它创建一个新的HTML Image对象并设置其源路径。
  • draw方法:调用全局view对象(画布视图管理器)的drawImage方法,将游戏对象绘制到画布上。
  • 访问器方法:提供了getXgetY方法,以便其他类(如物理系统)能够获取对象的位置。
  • move方法:用于更新游戏对象的位置。

现在,我们可以在Scene类中使用GameObject。我们在Scene中添加一个#background属性,并创建一个#setBackground辅助方法来创建背景游戏对象。然后,在Scenedraw方法中调用背景对象的draw方法。最后,在Game类的render方法中调用场景的draw方法,并将game-object.js脚本添加到HTML中。

完成这些步骤后,刷新页面,可以看到背景图片已成功绘制到画布上。


创建方块类

接下来,我们创建一个Block类来建模游戏中的砖块。方块是构成关卡的基本元素,我们将使用继承来复用GameObject的功能。

首先,创建block.js文件:

class Block extends GameObject {
    static size = 32;

    constructor(x, y, imagePath = 'assets/tile-brick.png') {
        super(x * Block.size, y * Block.size, Block.size, Block.size, imagePath);
    }
}

代码解释

  • extends GameObjectBlock类继承自GameObject类。
  • static size = 32:定义了一个静态属性size,表示每个方块的尺寸(32像素)。静态属性属于类本身,而不是类的实例。
  • 构造函数:接收网格坐标xy和可选的图片路径。它调用父类GameObject的构造函数,将网格坐标乘以方块尺寸转换为画布坐标,并传递尺寸和图片路径。

现在,我们需要在Scene类中管理方块。我们在Scene中添加一个#blocks数组属性。然后,在#setScene方法解析每个图块时,调用一个辅助方法#setTile#setTile方法根据字符类型(例如,#代表方块)创建相应的游戏对象并添加到#blocks数组中。我们使用switch语句来实现这个逻辑,这是一种工厂模式的简单应用,根据配置数据(字符)来实例化对象。

最后,更新Scenedraw方法,遍历#blocks数组并调用每个方块的draw方法。将block.js脚本添加到HTML中(注意顺序,Block依赖于GameObject,所以block.js必须在game-object.js之后)。

完成这些步骤后,刷新页面,可以看到所有砖块方块都已正确绘制在它们的位置上。


创建玩家类

现在,我们来创建Player类,建模游戏中的玩家角色及其行为。同样,我们将继承GameObject类。

首先,创建player.js文件:

class Player extends GameObject {
    #physics;

    constructor(x, y) {
        super(x * Block.size, y * Block.size, Block.size, Block.size, 'assets/link-down.png');
        this.#physics = new Physics(4);
    }

    move() {
        const newX = this.getX() + this.#physics.getVelocityX();
        const newY = this.getY() + this.#physics.getVelocityY();
        super.move(newX, newY);
    }

    update() {
        this.#physics.update();
        this.move();
    }
}

代码解释

  • 属性:除了继承的属性,玩家还有一个私有的#physics属性,用于关联物理系统。
  • 构造函数:接收起始网格坐标xy。它调用父类构造函数,传递转换后的画布坐标、尺寸(使用Block.size)和玩家图片路径。同时,它初始化一个Physics实例。
  • move方法:根据物理系统提供的X和Y方向速度,计算新的位置,并调用父类GameObjectmove方法来更新位置。这里使用了super关键字来调用被重写的父类方法。
  • update方法:在游戏循环的每一帧中调用。它先更新物理状态,然后根据新的速度移动玩家。

接下来,在Scene类中添加#player属性,并在#setTile方法中,当遇到代表玩家的字符(例如@)时,实例化Player对象并赋值给#player。然后,更新Scenedraw方法,绘制玩家。最后,将player.js脚本添加到HTML中(确保在physics.js之后,因为Player依赖Physics)。

完成这些步骤后,刷新页面,可以看到玩家角色被绘制在起始位置上。


实现物理系统

为了给游戏增加真实感,我们需要实现一个简单的物理系统。平台游戏的核心物理概念是使用速度来推动角色在X轴和Y轴上移动,并受到重力等力的影响。

首先,创建physics.js文件:

class Physics {
    #speed;
    #gravity = 0.3;
    #terminalVelocity = 8;
    #velocityX = 0;
    #velocityY = 0;

    constructor(speed) {
        this.#speed = speed;
    }

    applyGravity() {
        if (this.#velocityY < this.#terminalVelocity) {
            this.#velocityY += this.#gravity;
        }
    }

    update() {
        this.applyGravity();
    }

    getVelocityX() { return this.#velocityX; }
    getVelocityY() { return this.#velocityY; }
}

代码解释

  • 属性:包括速度#speed、重力加速度#gravity、终端速度#terminalVelocity(最大下落速度)以及当前在X和Y方向上的速度#velocityX#velocityY
  • 构造函数:接收一个speed参数并初始化。
  • applyGravity方法:如果当前Y方向速度小于终端速度,则增加重力加速度。这模拟了物体持续受到向下的力。
  • update方法:在游戏循环的每一帧中调用,目前只应用重力。
  • 访问器方法:提供获取当前速度的方法。

为了让物理系统生效,我们需要在游戏循环中更新状态。我们在Game类中已有的update方法里调用Sceneupdate方法。Sceneupdate方法则调用其Playerupdate方法。Playerupdate方法再调用其Physics实例的update方法,并随后移动玩家。

physics.js脚本添加到HTML中(在player.js之前)。现在,刷新页面,你会看到玩家角色在重力作用下开始下落,并最终掉出屏幕。


总结

本节课中我们一起学习了如何构建一个面向对象的JavaScript游戏框架。我们从解析原始的关卡字符串数据开始,将其转换为可操作的二维数组。接着,我们定义了Scene类作为场景管理器,GameObject类作为所有游戏对象的基类。通过继承,我们创建了具体的Block(方块)和Player(玩家)类。最后,我们实现了一个简单的Physics(物理)系统,为玩家角色添加了重力效果,使其能够下落。

我们混合使用了面向对象和函数式编程范式,例如在解析数据时使用map和箭头函数,在管理对象集合时使用forEach。这种灵活性是JavaScript的强大之处。在下一节课中,我们将继续完善这个游戏,例如添加控制器类来处理用户输入。


注意:本教程根据提供的视频字幕内容整理,已删除语气词,并按照要求进行了结构化、简化表述和格式调整。核心概念已用代码块突出显示。

007:JavaScript面向对象编程 part3

在本节课中,我们将继续构建平台游戏,重点是为游戏实现控制器系统,使角色能够跳跃和移动,并完成物理引擎中的碰撞检测与摩擦力系统。

上一节我们实现了重力系统,使角色能够下落。本节中,我们来看看如何通过控制器响应用户输入,并实现完整的碰撞物理效果。

🎮 目标10:实现控制器类

我们的目标是设计一个控制器,将处理用户输入的逻辑与移动角色的逻辑分离开来。这遵循了常见的模型-视图-控制器(MVC)设计模式。这样,如果我们想改变游戏的控制方案,就无需重构整个代码库,只需修改控制器类即可。

以下是实现控制器类的步骤:

  1. 在物理类中定义跳跃动作:跳跃被定义为施加一个向上的速度推力。

    // 在 Physics 类中添加
    jump() {
        this.velocityY = -this.speed * 2;
    }
    
  2. 在玩家类中管理跳跃状态:添加一个布尔值来追踪玩家是否正在跳跃,防止无限跳跃。

    // 在 Player 类中添加
    #isJumping = false;
    
    jump() {
        if (!this.#isJumping) {
            this.physics.jump();
            this.#isJumping = true;
        }
    }
    
    setIsJumping(value) {
        this.#isJumping = value;
    }
    
  3. 创建控制器类:新建 Controller.js 文件。控制器将持有玩家实例的引用,并监听键盘事件。

    class Controller {
        #player;
        #inputs = new Set();
    
        constructor(player) {
            this.#player = player;
            // 监听按键按下和抬起事件
            document.addEventListener('keydown', this.#buttonPress.bind(this));
            document.addEventListener('keyup', this.#buttonUnpress.bind(this));
        }
    }
    
  4. 实现按键处理方法:将按下的键(如“上箭头”)添加到输入集合中,抬起时则移除。

    #buttonPress(event) {
        switch(event.keyCode) {
            case 38: // 上箭头
                this.#inputs.add('up');
                break;
            // 后续可添加 case 37(左), 39(右)
        }
    }
    
    #buttonUnpress(event) {
        switch(event.keyCode) {
            case 38:
                this.#inputs.delete('up');
                break;
        }
    }
    

    注意:这里使用了 .bind(this)。因为当回调函数被事件监听器调用时,其内部的 this 关键字默认指向触发事件的DOM元素(如 document)。使用 .bind(this) 可以确保在回调函数中,this 仍然指向当前的 Controller 实例,从而能正确访问 #inputs 等私有属性。

  5. 实现控制器更新循环:在游戏每一帧更新时,检查输入集合并命令玩家行动。

    update() {
        const inputs = this.#inputs;
        if (inputs.has('up')) {
            this.#player.jump();
        }
        // 后续检查 'left', 'right'
    }
    
  6. 集成控制器到游戏循环:在 Game 类中实例化控制器,并在每帧更新中调用控制器的 update 方法。

完成以上步骤后,玩家按下上箭头键时,角色应该可以跳跃一次。

↔️ 目标11:实现水平移动

现在,我们让控制器也能控制角色左右移动。水平移动通过改变角色的X轴速度来实现。

以下是实现水平移动的步骤:

  1. 在物理类中添加左右移动方法:这些方法会限制角色的最大水平速度。

    // 在 Physics 类中添加
    moveLeft() {
        if (this.velocityX > -this.speed) {
            this.velocityX--;
        }
    }
    
    moveRight() {
        if (this.velocityX < this.speed) {
            this.velocityX++;
        }
    }
    
  2. 在玩家类中创建适配器方法:通过玩家类的方法来调用其内部物理引擎的方法,这是一种适配器模式的应用。

    // 在 Player 类中添加
    moveLeft() {
        this.physics.moveLeft();
    }
    
    moveRight() {
        this.physics.moveRight();
    }
    
  3. 扩展控制器的输入处理:在控制器的 #buttonPress#buttonUnpress 方法中,添加对左箭头(键码37)和右箭头(键码39)的处理,分别向输入集合添加或删除 ‘left’‘right’

  4. 扩展控制器的更新逻辑:在控制器的 update 方法中,检查并执行水平移动命令。

    update() {
        const inputs = this.#inputs;
        if (inputs.has('left')) this.#player.moveLeft();
        if (inputs.has('right')) this.#player.moveRight();
        if (inputs.has('up')) this.#player.jump();
    }
    

现在,角色应该可以通过方向键进行跳跃和左右移动了。

🧱 目标12:实现地板碰撞检测

没有碰撞,角色会直接穿过地板。我们需要检测玩家与砖块(地板)的碰撞。

碰撞检测需要解决两个问题:

  1. 两个游戏对象是否接触?
  2. 如果接触,是哪个边接触的(上、下、左、右)?

以下是实现地板碰撞的步骤:

  1. 在游戏对象类中添加获取尺寸的方法:碰撞检测需要知道对象的宽高。
    // 在 GameObject 类中添加
    getWidth() { return this.width; }
    getHeight() { return this.height; }
    

  1. 在砖块类中实现碰撞检测方法

    // 在 Block 类中添加
    isTouchingX(gameObject, ratio) {
        const overlap = this.getWidth() * ratio;
        return Math.abs(this.x - gameObject.x) < overlap;
    }
    
    isTouchingY(gameObject, ratio) {
        const overlap = this.getHeight() * ratio;
        return Math.abs(this.y - gameObject.y) < overlap;
    }
    
    isTouching(gameObject) {
        return this.isTouchingX(gameObject, 0.5) && this.isTouchingY(gameObject, 1.0);
    }
    

    方法 isTouchingXisTouchingY 检查在某个轴向上,两个对象的中心点距离是否小于“重叠阈值”(宽度或高度乘以一个比例)。isTouching 方法则要求X方向有50%重叠,Y方向有100%重叠,这构成了一个粗略的矩形碰撞框。

  2. 在物理类中实现碰撞检查:在每帧更新中,检查玩家与所有砖块的碰撞。

    checkCollisions(blocks, player) {
        for (const block of blocks) {
            if (block.isTouching(player)) {
                this.#checkCollisionFloor(block, player);
                // 后续会添加 ceiling, left, right 检查
            }
        }
    }
    
  3. 实现具体的地板碰撞逻辑

    #checkCollisionFloor(block, player) {
        // 玩家在砖块上方且正在下落
        if (player.y < block.y && this.velocityY > 0) {
            // 确保玩家和砖块在垂直方向对齐(防止“墙跳”)
            if (block.isTouchingX(player, 0.5)) {
                this.velocityY = 0; // 停止下落
                player.setIsJumping(false); // 重置跳跃状态
            }
        }
    }
    

  1. 重构更新链以传递砖块数据:需要将砖块列表从 Scene 传递到 Playerupdate 方法,再传递到 PhysicsupdatecheckCollisions 方法中。

现在,角色应该能够落在砖块地板上,而不会掉下去。

🧱 目标13:实现天花板碰撞检测

当玩家从下方撞到砖块时,应该被弹回。

以下是实现天花板碰撞的步骤:

  1. 在物理类中添加天花板碰撞检查

    #checkCollisionCeiling(block, player) {
        // 玩家在砖块下方且正在上升
        if (player.y > block.y && this.velocityY < 0) {
            this.velocityY *= -0.5; // 反转Y速度并减半,产生反弹效果
        }
    }
    
  2. 在总碰撞检查中调用它:在 checkCollisions 方法的循环内,在检测到接触后调用 #checkCollisionCeiling

现在,当玩家跳起来撞到头顶的砖块时,会被向下弹回。

🧱 目标14:实现左右墙壁碰撞检测

我们需要防止玩家穿过左右两侧的墙壁。

以下是实现墙壁碰撞的步骤:

  1. 在物理类中添加左右墙壁碰撞检查

    #checkCollisionRight(block, player) {
        // 玩家在砖块左侧且正在向右移动,并且两者Y轴对齐
        if (player.x < block.x && this.velocityX > 0 && block.isTouchingY(player, 0.5)) {
            this.velocityX *= -1; // 立即反转水平速度
        }
    }
    
    #checkCollisionLeft(block, player) {
        // 玩家在砖块右侧且正在向左移动,并且两者Y轴对齐
        if (player.x > block.x && this.velocityX < 0 && block.isTouchingY(player, 0.5)) {
            this.velocityX *= -1;
        }
    }
    

    检查Y轴50%的重叠是为了确保碰撞发生在同一行,避免角色与上下方砖块的边角接触时被误判为墙壁碰撞。

  2. 在总碰撞检查中调用它们:在 checkCollisions 方法中,依次调用地板、天花板、右侧、左侧的碰撞检查方法。

现在,角色将无法穿过墙壁,撞上时会被弹开。

🌀 目标15:实现摩擦力

目前,角色一旦开始移动,如果没有撞墙就会一直滑动。我们需要摩擦力使其逐渐停止。

以下是实现摩擦力的步骤:

  1. 在物理类中添加摩擦力属性

    #friction = 0.8; // 小于1的值,用于逐渐减速
    
  2. 创建应用摩擦力的方法

    #applyFriction() {
        this.velocityX *= this.#friction;
    }
    

    每一帧都将当前水平速度乘以一个小于1的系数,速度会指数级衰减,最终接近零。

  3. 在物理更新循环中应用摩擦力:在 Physics 类的 update 方法中,依次应用重力、摩擦力和碰撞检测。

    update(blocks, player) {
        this.#applyGravity();
        this.#applyFriction();
        this.#checkCollisions(blocks, player);
        // ... 更新位置
    }
    

现在,当玩家松开方向键时,角色会平滑地减速直至停止。


本节课中我们一起学习了如何构建一个完整的游戏控制器和物理引擎。我们实现了:

  • 控制器类:使用MVC模式分离输入处理,通过事件监听和bind方法管理用户输入。
  • 跳跃与移动:在物理层定义动作,通过玩家类暴露接口,由控制器调用。
  • 2D碰撞检测:利用矩形重叠原理,实现了地板、天花板、左右墙壁的碰撞检测与响应。
  • 摩擦力:通过每帧乘以衰减系数,模拟了角色停止的物理效果。

现在,我们的平台游戏已经具备了基本的可玩性。下一讲我们将为游戏添加更多元素,如危险障碍和出口,最终完成这个项目。

008:JavaScript面向对象编程 part4

在本节课中,我们将继续完善我们的平台跳跃游戏。上一节我们实现了物理效果和摩擦力,使得角色可以移动并与方块互动。本节我们将为游戏添加失败条件(如尖刺陷阱)、胜利条件(如出口)以及角色动画,让游戏变得更加完整和有趣。


🎯 目标16:实现危险方块

上一节我们完成了目标15(摩擦力)。本节的目标是添加能够杀死玩家的危险方块,为游戏引入失败条件。

危险方块有两种:地面尖刺和天花板尖刺。它们是一种特殊的方块,其碰撞检测行为与普通方块不同。当玩家以特定方式(例如踩到尖刺顶部)触碰到它们时,游戏将结束。

为了实现这一点,我们将使用面向对象设计中的继承概念,创建两个新的类:FloorHazardCeilingHazard,它们都继承自 Block 类。

第一步:设计 FloorHazard

FloorHazard 类将继承 Block 类,并重写其 isTouchingY 方法,以实现仅对方块上半部分进行碰撞检测的逻辑。

以下是 FloorHazard 类的实现代码:

class FloorHazard extends Block {
    constructor(x, y) {
        super(x, y, 'assets/tiles-spikes-4.png');
    }

    isTouchingY(player) {
        const topHalf = this.y + this.height / 2;
        const bottomHalf = this.y + this.height;
        return player.y + player.height >= topHalf && player.y <= bottomHalf;
    }

    isTouching(player) {
        return super.isTouchingX(player) && this.isTouchingY(player);
    }
}

代码解释

  • constructor 中调用了父类 Block 的构造函数,并传入了尖刺的图片路径。
  • isTouchingY 方法计算了方块的上半部分(从中间到底部),并检查玩家的底部是否进入了这个区域。
  • isTouching 方法结合了父类的 isTouchingX 和新的 isTouchingY 逻辑。

第二步:设计 CeilingHazard

CeilingHazard 类的逻辑与 FloorHazard 类似,但碰撞检测区域是方块的下半部分(从顶部到中间)。

以下是 CeilingHazard 类的实现代码:

class CeilingHazard extends Block {
    constructor(x, y) {
        super(x, y, 'assets/tiles-spikes-ceiling.png');
    }

    isTouchingY(player) {
        const topHalf = this.y;
        const bottomHalf = this.y + this.height / 2;
        return player.y + player.height >= topHalf && player.y <= bottomHalf;
    }

    isTouching(player) {
        return super.isTouchingX(player) && this.isTouchingY(player);
    }
}

第三步:在场景中集成危险方块

现在我们需要在 Scene 类中管理这些危险方块。

  1. 添加实例变量:在 Scene 类中添加一个 monsters 数组来存储所有危险方块。

    this.monsters = [];
    
  2. 解析地图数据:在 setTile 方法中,根据地图字符(例如 'A' 代表地面尖刺,'V' 代表天花板尖刺)创建相应的危险方块实例,并加入 monsters 数组。

    case 'A':
        this.monsters.push(new FloorHazard(x, y));
        break;
    case 'V':
        this.monsters.push(new CeilingHazard(x, y));
        break;
    
  3. 渲染危险方块:在 Scenedraw 方法中,遍历 monsters 数组并调用每个危险方块的 draw 方法。

    this.monsters.forEach(monster => monster.draw(ctx));
    
  4. 添加脚本依赖:在 HTML 文件中,在 block.js<script> 标签之后,添加 floor-hazard.jsceiling-hazard.js 的引用。

完成以上步骤后,游戏场景中应该能正确显示尖刺方块,但目前它们还不会对玩家造成伤害。


⚠️ 目标17:实现失败条件

现在我们已经有了危险方块,接下来需要定义游戏失败的条件:当玩家触碰到任何危险方块时,游戏结束。

第一步:在场景中添加碰撞检测方法

我们在 Scene 类中添加一个 hasCollisions 方法,用于检查玩家是否碰到了任何危险方块。

hasCollisions() {
    return this.monsters.some(monster => monster.isTouching(this.player));
}

代码解释

  • some 是 JavaScript 数组的高阶方法。它会遍历 monsters 数组中的每个元素(即每个危险方块)。
  • 对于每个危险方块,调用其 isTouching 方法(我们之前重写过的),并传入玩家对象。
  • 如果 任何一个 危险方块的 isTouching 返回 true,则 some 方法返回 true,表示发生了碰撞。

第二步:在游戏主循环中触发失败条件

Game 类的 update 方法中,我们调用场景的 hasCollisions 方法。如果返回 true,则设置游戏结束标志 isOver

update() {
    // ... 其他更新逻辑(如更新场景、控制器)...
    if (this.scene.hasCollisions()) {
        this.isOver = true; // 游戏结束
    }
    // ... 后续逻辑 ...
}

现在,当玩家触碰到尖刺时,游戏会停止循环,并显示游戏结束信息。

优化:实现立即重生

为了让游戏体验更现代(类似《超级食肉男孩》),我们可以在玩家死亡时立即重新开始当前关卡,而不是直接结束游戏。这可以通过在碰撞发生时重新加载当前场景来实现。

修改 Game 类的 update 方法:

update() {
    // ... 其他更新逻辑 ...
    if (this.scene.hasCollisions()) {
        this.loadScene(this.level); // 重新加载当前关卡
    }
    // ... 后续逻辑 ...
}

这样,玩家每次死亡都会立即在当前关卡重生,游戏只有在达成胜利条件时才会真正结束。


🏆 目标18:实现胜利条件与关卡切换

一个完整的游戏需要有胜利的目标。我们将添加“出口”方块,当玩家到达出口时,可以进入下一关。如果已经是最后一关,则玩家获胜。

第一步:创建 Exit

Exit 类同样继承自 Block 类。它的碰撞检测容忍度可以设置得宽松一些,让玩家更容易触发。

class Exit extends Block {
    constructor(x, y) {
        super(x, y, 'assets/tile-exit.png');
    }

    isTouching(player) {
        // 设置较大的碰撞容忍度,例如需要25%的重叠
        const xOverlap = this.isTouchingX(player, 0.25);
        const yOverlap = this.isTouchingY(player, 0.25);
        return xOverlap && yOverlap;
    }
}

第二步:在场景中集成出口

  1. Scene 类中添加一个 exit 实例变量(因为每个关卡只有一个出口)。
  2. setTile 方法中,根据特定字符(例如 '!')创建 Exit 实例并赋值给 this.exit
  3. Scenedraw 方法中,绘制出口方块(顺序建议在玩家之前)。
  4. Scene 中添加一个 getExit 方法,以便其他类能访问到出口对象。
  5. 在 HTML 中添加 exit.js 的脚本引用。

第三步:实现关卡切换逻辑

  1. 添加关卡索引:在 Game 类中,我们需要一个 level 变量来跟踪当前关卡。
  2. 添加场景加载方法:在 Game 类中创建一个 loadScene 方法,它根据给定的关卡索引从 World 类获取地图数据,然后创建新的 SceneController 实例。
    loadScene(levelIndex) {
        const map = this.world.getLevel(levelIndex);
        this.scene = new Scene(map);
        this.controller = new Controller(this.scene.getPlayer());
    }
    
  3. World 类中添加辅助方法:添加一个 getLength 方法,返回总关卡数。
  4. 实现胜利检测:在 Gameupdate 方法中,检查玩家是否触碰到出口。
    • 如果碰到出口,则增加 level 索引。
    • 如果 level 小于总关卡数,调用 loadScene 加载下一关。
    • 如果 level 等于或大于总关卡数,说明已通过所有关卡,设置 isOver = true 并显示胜利信息。
update() {
    // ... 其他逻辑(如失败检测)...

    if (this.scene.getExit().isTouching(this.scene.getPlayer())) {
        this.level++;
        if (this.level < this.world.getLength()) {
            this.loadScene(this.level); // 加载下一关
        } else {
            this.isOver = true; // 游戏胜利
            console.log('You win the game!');
        }
    }
}

现在,玩家可以通过到达出口来闯关,并在完成所有关卡后获得胜利。


🎬 目标19:实现角色动画

最后,为了让游戏看起来更精致,我们将为玩家角色添加行走动画。这涉及到在玩家移动时,在不同图像帧之间切换。

第一步:创建 Animation

这个类的职责是管理一个动画序列,并根据时间决定当前应该显示哪一帧。

class Animation {
    constructor(...images) {
        this.startTime = Date.now();
        this.frameIndex = 0;
        this.sequence = images.map(src => {
            const img = new Image();
            Object.assign(img, { src });
            return img;
        });
    }

    getImage() {
        const index = this.frameIndex;
        const now = Date.now();
        if (now - this.startTime > 75) { // 每75毫秒切换一帧
            this.frameIndex = (index + 1) % this.sequence.length;
            this.startTime = now;
        }
        return this.sequence[index];
    }
}

代码解释

  • constructor 接收一系列图片路径,并为每个路径创建 Image 对象。
  • getImage 方法检查自动画开始后经过的时间。如果超过75毫秒,就切换到下一帧(通过增加 frameIndex)。% 运算符确保帧索引在序列长度内循环。

第二步:创建 Pose 类(姿势枚举)

这个类使用 Animation 类来定义玩家具体的动画姿势(如向左走、向右走)。我们使用静态属性来模拟枚举。

class Pose {
    static RIGHT = new Pose('assets/link-right.png', 'assets/link-right-2.png');
    static LEFT = new Pose('assets/link-left.png', 'assets/link-left-2.png');

    constructor(...images) {
        this.animation = new Animation(...images);
    }

    getImage() {
        return this.animation.getImage();
    }
}

第三步:修改 GameObjectPlayer

  1. GameObject 中添加 setImage 方法:允许动态更改游戏对象显示的图片。
    setImage(img) {
        this.image = img;
    }
    
  2. Player 类中集成动画
    • 添加 currentPose 实例变量。
    • 在构造函数中,初始化 currentPosePose.RIGHT,并调用 setImage 设置初始图片。
    • moveLeftmoveRight 方法中,切换 currentPosePose.LEFTPose.RIGHT
    • 重写 draw 方法:在每次绘制前,从 currentPose 获取当前帧的图片,设置给游戏对象,然后调用父类的 draw 方法。
class Player extends GameObject {
    constructor(x, y) {
        super(x, y, 0, 0, 'assets/link.png'); // 初始图片会被覆盖
        this.currentPose = Pose.RIGHT;
        this.setImage(this.currentPose.getImage());
        // ... 其他初始化 ...
    }

    moveLeft() {
        // ... 移动逻辑 ...
        this.currentPose = Pose.LEFT;
    }

    moveRight() {
        // ... 移动逻辑 ...
        this.currentPose = Pose.RIGHT;
    }

    draw(ctx) {
        this.setImage(this.currentPose.getImage());
        super.draw(ctx);
    }
}
  1. 添加脚本依赖:在 HTML 中,确保 animation.jspose.jsplayer.js 之前被引入。

现在,当玩家左右移动时,角色会播放相应的行走动画,大大增强了游戏的视觉效果和趣味性。


📝 总结

本节课中我们一起学习了如何为一个基本的平台跳跃游戏添加核心功能,使其成为一个完整的、可玩的游戏。我们通过面向对象编程的方式,逐步实现了:

  1. 危险方块:通过继承创建了 FloorHazardCeilingHazard 类,实现了特殊的碰撞逻辑。
  2. 失败条件:在游戏主循环中检测玩家与危险方块的碰撞,并触发游戏结束或重生逻辑。
  3. 胜利条件与关卡系统:创建了 Exit 类作为出口,并实现了关卡加载和切换的逻辑,让玩家可以通关并取得最终胜利。
  4. 角色动画:设计了 AnimationPose 类来管理动画序列,并在 Player 类中集成,使角色在移动时具有流畅的动画效果。

通过这些步骤,我们不仅完成了一个功能丰富的游戏原型,还深入实践了JavaScript的面向对象特性,包括类、继承、方法重写、静态属性以及组合等概念。你现在已经具备了使用纯JavaScript或现代游戏引擎(如Phaser)来开发更复杂2D游戏的基础知识。

009:文档对象模型与事件处理(第一部分)

概述

在本节课中,我们将学习文档对象模型和事件处理。我们将通过构建一个“猜数字”游戏来实践这些概念。课程将分为三个部分:首先构建一个最小可行产品,然后重构代码以采用模型-视图-控制器设计模式,最后改进用户界面和体验。

文档对象模型与事件简介

上一节我们概述了课程目标,本节中我们来看看文档对象模型和事件的基本概念。

文档对象模型

文档对象模型为JavaScript提供了访问HTML文档的能力。DOM是一个内置对象,包含专门用于操作HTML的函数和属性。它将HTML元素转换为JavaScript对象。

document对象是访问DOM API的入口点,它本身是window对象的一个属性。我们可以通过document.getElementById()方法访问元素对象。

代码示例:

const buttonElement = document.getElementById('guessButton');

元素对象的innerHTML属性维护其HTML标记字符串。通过更改innerHTML的值,我们可以添加、更改或删除DOM中的HTML。

浏览器事件

Web浏览器是基于事件的系统。浏览器根据特定操作或触发器生成和管理事件。JavaScript应用程序可以使用这些事件来触发操作或行为。

使用事件驱动系统需要事件监听器。事件监听器将元素对象注册到事件队列,以监听特定类型的事件。当事件发生时,它会调用回调函数。

代码示例:

buttonElement.addEventListener('click', guessNumber);

回调函数是JavaScript应用程序中的一个函数,作为引用传递给事件系统。当事件发生时,事件系统会调用该回调函数,并传递一个事件对象作为参数。

项目架构与单页应用

在开始构建游戏之前,我们先了解一些架构概念。

模型-视图-控制器

MVC是一种设计模式,将应用程序的职责分为三个部分:

  • 模型:管理应用程序的核心逻辑,不关心输入和输出。
  • 控制器:处理用户输入,向模型发送数据并调用操作。
  • 视图:管理从模型输出的数据,并将其呈现给用户。

单页应用

现代Web应用程序将HTML页面数量最小化为一个。这样可以保留JavaScript运行时数据。由于浏览器将所有HTML元素以JavaScript对象树的形式保存在内存中,我们可以实时修改HTML并重新渲染新内容。

第一部分:构建基于MVP的猜数字游戏

现在我们已经介绍了核心概念,本节中我们将开始构建游戏的第一部分。

目标与规划

第一部分的目标是使用最小可行产品设计,构建一个基于回合制和文本的猜数字游戏。

我们需要创建一个HTML文档,提供游戏说明和用户输入。以下是实现步骤:

  1. 创建index.html文件并实现基本的HTML内容。
  2. 为输入字段和按钮添加ID属性,以便在JavaScript中访问。
  3. 将游戏逻辑链接到HTML文件。

实现游戏逻辑

接下来,我们需要实现猜数字游戏的基本算法。游戏输出将显示在控制台中。

首先,创建变量来存储随机密码和剩余尝试次数。

代码示例:

const passcode = Math.floor(Math.random() * 1000);
let tries = 10;

然后,实现评估猜测的逻辑,以确定用户是赢、输还是获得提示。

代码示例:

function guessNumber() {
    const guess = parseInt(number.value);
    tries--;
    console.log(`Number of attempts left: ${tries}`);

    if (guess === passcode) {
        console.log(`You win! Got it in ${10 - tries} attempts.`);
    } else if (tries <= 0) {
        console.log(`You lose! The number was ${passcode}.`);
    } else {
        giveClue(guess);
    }
}

function giveClue(guess) {
    if (guess > passcode) {
        console.log(`${guess} is too high.`);
    } else {
        console.log(`${guess} is too low.`);
    }
}

更新视图

现在,我们将使用DOM API从JavaScript更新HTML元素,而不是使用控制台。

首先,在HTML中添加一个用于显示尝试次数的段落和一个用于显示提示的无序列表,并为它们分配ID。

然后,在JavaScript中获取这些元素,并修改guessNumbergiveClue函数,使用innerHTML属性更新视图。

代码示例:

const attemptsView = document.getElementById('attempts');
const cluesView = document.getElementById('clues');

// 在 guessNumber 函数中更新尝试次数
attemptsView.innerHTML = `Number of attempts left: ${tries}`;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/326ced8ce00a14764095137ccda43ca2_2.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/326ced8ce00a14764095137ccda43ca2_4.png)

// 在 giveClue 函数中添加提示
cluesView.innerHTML += `<li>${guess} is too high.</li>`;

第二部分:重构为MVC模式

我们已经完成了MVP构建,本节中我们将重构代码以采用模型-视图-控制器模式。

代码分离

首先,将现有代码分离到三个不同的JavaScript文件中:model.jsview.jscontroller.js

  • 模型:包含游戏逻辑、密码和尝试次数。
  • 视图:包含更新HTML元素的函数。
  • 控制器:包含设置事件监听器和处理用户输入的代码。

改进用户输入

为了提高输入的容错性,我们将重构用户输入方式。使用类似密码锁的界面,用户可以通过按钮调整百位、十位和个位数字。

首先,修改HTML,将单个文本输入替换为三个数字输入字段和对应的增减按钮。

然后,创建一个Guess类来管理猜测的数字。

代码示例:

class Guess {
    constructor() {
        this.hundreds = 0;
        this.tens = 0;
        this.ones = 0;
    }

    toString() {
        return '' + this.hundreds + this.tens + this.ones;
    }

    increment(key) {
        this[key] = (this[key] + 1) % 10;
    }

    decrement(key) {
        this[key] = (this[key] - 1 + 10) % 10;
    }
}

在控制器中,为增减按钮设置事件监听器,调用Guess实例的相应方法,并更新视图。

总结

本节课中,我们一起学习了文档对象模型和事件处理。我们通过构建一个猜数字游戏,实践了如何将JavaScript与HTML结合创建交互式体验。我们从最小可行产品开始,然后重构代码以采用模型-视图-控制器设计模式,并改进了用户输入的容错性。在下一节课中,我们将继续完善游戏,增加图形界面和计时功能。

010:文档对象模型、事件 part2

在本节课中,我们将继续完善“高低猜测”游戏。我们将把游戏从基于回合的文本界面,改造为基于时间的图形化界面,并在这个过程中深入理解MVC架构的优势。

概述

上一节我们完成了游戏MVC架构的重构。本节中,我们将为游戏添加图形化界面和基于时间的游戏机制。我们将替换所有HTML按钮和输入框为图像,并引入计时器来限制玩家的猜测时间,从而提升游戏的趣味性和挑战性。

回顾:Guest类的工作原理

在开始新内容之前,我们先回顾一下上一节中使用的Guest类,以确保理解其状态管理机制。

Guest类用于管理玩家当前猜测的三位数(百位、十位、个位)。它提供了增加(increment)和减少(decrement)特定数位值的方法。

以下是其核心概念:

代码描述:

class Guest {
    constructor() {
        this.hundreds = 0;
        this.tens = 0;
        this.ones = 0;
    }

    // 增加指定数位的值
    increment(digit) {
        this[digit] = (this[digit] + 1) % 10;
    }

    // 减少指定数位的值
    decrement(digit) {
        this[digit] = (this[digit] - 1 + 10) % 10;
    }

    // 将当前猜测转换为字符串
    toString() {
        return `${this.hundreds}${this.tens}${this.ones}`;
    }
}

该类使用方括号表示法(this[digit])来动态访问属性。例如,当传入字符串'hundreds'时,this[digit]等同于this.hundreds。所有对猜测状态的修改都封装在这个类中,然后由我们的MVC架构中的控制器和模型来调用。

目标三:实现图形化界面与计时机制

现在,我们开始实现第三部分的目标:将游戏升级为具有图形化界面和计时机制的版本。

目标 3.1:将控制器按钮图形化

我们的计划是将HTML中的按钮元素替换为图像,作为新的控制器。

以下是具体步骤:

  1. 在HTML文件中,找到控制按钮的<input type="button">标签。
  2. 将它们替换为<img>标签,并设置src属性指向相应的按钮图片(如button-up.gifbutton-down.gif)。
  3. 保持id属性不变,这样我们就不需要修改JavaScript中绑定事件监听器的代码。

代码描述(HTML修改示例):

<!-- 替换前的按钮 -->
<input type="button" id="up-hundreds" value="+">

<!-- 替换后的图像按钮 -->
<img id="up-hundreds" src="assets/button-up.gif" width="100">

完成此修改后,游戏功能应保持不变,但按钮已变为图像。

目标 3.2:将数字显示视图图形化

接下来,我们将显示猜测数字的输入框也替换为图形化的LED数字图像。

以下是具体步骤:

  1. 在HTML中,将数字输入框替换为<img>标签,初始图片显示为0.gif
  2. 在视图(view.js)的printDigits函数中,修改更新逻辑。不再设置输入框的value,而是设置图片的src属性,根据当前Guest实例的值动态指向对应的数字图片(如5.gif)。

代码描述(printDigits函数修改):

function printDigits() {
    document.getElementById('digit-hundreds').src = `assets/${guess.hundreds}.gif`;
    document.getElementById('digit-tens').src = `assets/${guess.tens}.gif`;
    document.getElementById('digit-ones').src = `assets/${guess.ones}.gif`;
}

至此,我们仅修改了视图和控制器,模型完全未动,就成功地将界面从文本切换为图形。

目标 3.3:将线索提示集成到LED面板

我们希望将“过高/过低”的提示也显示在中央的LED面板上,而不是下方的列表中。

以下是具体步骤:

  1. 从HTML中移除用于显示线索的无序列表(<ul>)。
  2. 在视图的printClue函数中重写逻辑。根据传入的status(‘H’或‘L’),决定显示‘HIGH’还是‘LOW’的图片组合。

代码描述(printClue函数修改):

function printClue(status) {
    // 使用三元运算符和数组解构,根据状态决定显示的字符
    const [digit100, digit10, digit1] = 
        (status === ‘H’) ? [‘H‘, ‘I‘, ‘-’] : [‘L‘, ‘O‘, ‘-’];

    document.getElementById(‘digit-hundreds‘).src = `assets/${digit100}.gif`;
    document.getElementById(‘digit-tens‘).src = `assets/${digit10}.gif`;
    document.getElementById(‘digit-ones‘).src = `assets/${digit1}.gif`;
}

现在,线索会直接覆盖显示在数字面板上。

目标 3.4:为线索提示添加自动清除计时器

目前,线索提示会一直显示,直到玩家再次操作。为了提升体验,我们添加一个计时器,在显示线索1秒后自动恢复显示当前猜测的数字。

以下是实现思路:

  1. 在模型(model.js)中定义一个全局变量thenTime,用于记录起始时间(使用Date.now()获取的毫秒时间戳)。
  2. 创建一个主循环函数main,通过window.requestAnimationFrame重复调用。
  3. main函数中,计算当前时间与thenTime的差值。若差值超过1000毫秒(1秒),则调用printDigits()恢复数字显示,并更新thenTime为当前时间。
  4. printClue函数中,每次显示新线索时,也更新thenTime,以重置1秒计时。

公式/概念描述:

  • 时间差计算timePassed = Date.now() - thenTime
  • 条件判断if (timePassed > 1000) { // 执行操作 }

这样,每次给出线索后,面板都会在1秒后自动切回数字显示。

目标 3.5:实现基于时间的游戏机制

最后,我们将游戏的核心机制从“限制猜测次数”改为“限制游戏时间”。

以下是具体步骤:

  1. 修改模型状态:在模型中,用timeLeft(剩余时间)和isGameOver(游戏是否结束)变量替换之前的triesLeft(剩余尝试次数)。
  2. 更新胜利条件:在guessNumber函数中,当猜测正确时,设置isGameOver = true并宣布胜利。
  3. 更新失败条件:在主循环函数main中,检查timeLeft是否小于等于0。若是,则触发游戏结束(失败),并调用printGameOver
  4. 更新视图显示:修改printAttemptsprintGameOver函数,将显示内容从“剩余尝试次数”改为“剩余时间”和“所用时间”。
  5. 实现时间流逝:在main函数中,每当检测到过去1秒(参考目标3.4的逻辑),就将timeLeft减1,并更新屏幕上显示的时间。

代码描述(main函数核心逻辑更新):

function main() {
    if (isGameOver) return; // 游戏已结束,停止更新
    if (timeLeft <= 0) {
        printGameOver(‘lose‘);
        isGameOver = true;
        return;
    }

    const now = Date.now();
    if (now - thenTime > 1000) {
        timeLeft--; // 时间减少1秒
        printDigits();
        printAttempts(); // 更新显示的时间
        thenTime = now; // 重置计时起点
    }
    window.requestAnimationFrame(main);
}
// 启动游戏循环
main();

现在,游戏变成了在30秒(或你设定的初始时间)内猜出正确数字的挑战。策略也从纯粹的逻辑二分法,转变为需要在时间压力下进行效率权衡,游戏体验变得更加紧张和有趣。

总结

本节课中,我们一起完成了“高低猜测”游戏的最终升级。我们成功地将一个基于回合的文本游戏,改造为基于时间的图形化游戏。在这个过程中,我们实践了以下关键技能:

  1. 图形化界面:将HTML表单元素替换为动态图像,并通过JavaScript控制src属性来更新显示。
  2. 计时器与循环:利用Date.now()requestAnimationFrame创建游戏主循环,实现了基于时间的状态更新和事件触发。
  3. MVC架构的威力:我们仅需修改视图(view.js)和少量控制器逻辑,就彻底改变了游戏的呈现方式和核心规则,而模型(model.js)的核心逻辑保持稳定。这充分证明了良好架构带来的可维护性和可扩展性。

你可以进一步扩展这个游戏,例如添加CSS样式美化、通过本地存储(localStorage)保存最快记录、或者增加不同难度(如更多位数或十六进制数字),使其更加完善。

011:HTTP POST 请求教程 🚀

在本节课中,我们将学习如何使用HTTP POST请求,特别是通过HTML表单和异步JavaScript,将数据从客户端发送到后端服务器。我们将通过构建一个功能完整的联系表单来实践,该表单会将用户提交的数据存储到Google Forms中。


概述 📋

本教程将引导您完成一个联系表单的实现,该表单能够将用户提交的数据发送到Google Forms后端进行存储。我们将从最简单的纯HTML实现开始,逐步过渡到使用JavaScript控制表单属性,最后实现完全使用异步JavaScript发送HTTP请求,而不依赖HTML表单的内置行为。


项目架构 🗂️

我们将创建一个名为 contact_form 的主目录,其中包含以下文件:

  • index.html: 主页面文件。
  • scripts/: 存放JavaScript脚本的子目录。
    • form_model.js: 处理数据逻辑和与后端通信的模型。
    • form_controller.js: 处理DOM交互和事件监听的控制器。


目标1:创建Google Form作为后端数据库 🗄️

在开始构建前端之前,我们需要一个后端服务来接收和存储数据。本节中,我们将使用Google Forms创建一个简单的“数据库”。

步骤:

  1. 访问 forms.google.com
  2. 创建一个新的空白表单,命名为“Contact Response”。
  3. 在表单中添加三个问题字段,类型为“简短答案”:
    • name
    • email
    • message
  4. 保存表单。这个表单的提交地址将作为我们后端的端点(Endpoint)

核心概念:

  • 端点(Endpoint):服务器监听特定HTTP请求的URL地址。对于我们的Google Form,其端点格式为:https://docs.google.com/forms/d/e/{FORM_ID}/formResponse
  • 数据持久化:将用户提交的表单数据(如姓名、邮箱、信息)存储到Google Sheets中,实现数据的长期保存。

为了让我们自己的网页能与这个Google Form通信,我们需要获取两个关键信息:

  1. 表单ID(Form ID):包含在表单的URL中。
  2. 字段名称(Entry Names):Google为表单中每个问题字段自动生成的唯一名称(格式如 entry.123456789)。

我们可以通过查看表单页面的源代码,并使用一些JavaScript代码来提取这些信息。以下是提取字段名称的示例代码逻辑:

// 此代码在Google Form的预览页面控制台中运行,用于获取字段的`name`属性
const entryList = [...document.querySelectorAll('input[name^="entry"]')];
entryList.forEach((element, index) => {
    console.log(`字段 ${index + 1} 的名称(name)为: ${element.name}`);
});

运行类似代码后,控制台会输出类似 entry.1784365446 这样的名称。记下它们,分别对应 nameemailmessage 字段。


目标2:使用纯HTML表单提交数据 📝

上一节我们准备好了后端。本节中,我们来看看如何构建一个最基础的前端——使用纯HTML表单来发送数据。

步骤:

  1. index.html 中创建一个 <form> 元素。
  2. 设置表单的 action 属性为Google Form的端点URL(包含你的表单ID)。
  3. 设置表单的 method 属性为 "post"
  4. 在表单内,为每个数据字段(name, email, message)创建对应的 <input><textarea> 元素。
  5. 关键:为每个输入元素设置 name 属性,其值必须与我们在目标1中获取的Google Form字段名称完全一致
  6. 添加一个提交按钮。

示例代码(index.html 片段):

<form id="contact-form"
      action="https://docs.google.com/forms/d/e/YOUR_FORM_ID_HERE/formResponse"
      method="POST">
    <div>
        <input type="text" placeholder="姓名" name="entry.1784365446" id="name">
    </div>
    <div>
        <input type="text" placeholder="邮箱" name="entry.1529700185" id="email">
    </div>
    <div>
        <textarea placeholder="留言" rows="4" name="entry.31461068812" id="message"></textarea>
    </div>
    <button type="submit">提交</button>
</form>

工作原理:
当用户点击提交按钮时,浏览器会自动触发表单的 submit 事件。它会收集所有具有 name 属性的表单元素的值,按照 method 指定的方式(POST),将数据编码后发送到 action 指定的URL。成功后,浏览器会跳转到Google Form的确认页面。

优缺点:

  • 优点:实现简单,无需JavaScript。
  • 缺点:页面会发生重定向,用户体验中断;所有逻辑硬编码在HTML中,难以灵活控制。

目标3:使用JavaScript设置表单属性 ⚙️

纯HTML表单缺乏灵活性。本节中,我们将使用JavaScript来动态设置表单的属性,为后续完全用JS控制请求做准备。

思路:
我们仍然使用HTML表单来触发提交和发送请求,但表单的 actionmethod 以及输入框的 name 属性都将通过JavaScript来设置。

步骤:

  1. 修改HTML:为表单和输入元素添加 id,方便JS获取。移除之前在HTML中硬编码的 actionmethodname 属性。
  2. 创建模型(Model):在 form_model.js 中,定义从目标1获取的常量数据(表单ID、各字段的Entry Name)。
  3. 创建控制器(Controller):在 form_controller.js 中:
    • 编写 initControllers 函数,获取表单DOM元素,并为其添加 submit 事件监听器。
    • 在事件处理函数中,使用 form_model.js 中的常量,动态地为每个输入元素设置 name 属性,并为表单设置 actionmethod

示例代码(form_controller.js 片段):

import { FORM_ID, ENTRY_NAME_1, ENTRY_NAME_2, ENTRY_NAME_3, getFormEndpoint } from './form_model.js';

function initControllers() {
    const contactForm = document.getElementById('contact-form');
    contactForm.addEventListener('submit', function(event) {
        // 动态设置输入字段的name属性
        document.getElementById('name').name = ENTRY_NAME_1;
        document.getElementById('email').name = ENTRY_NAME_2;
        document.getElementById('message').name = ENTRY_NAME_3;

        // 动态设置表单的action(端点URL)
        contactForm.action = getFormEndpoint(FORM_ID);
        // 设置表单的method
        contactForm.method = 'POST';
        // 注意:事件处理函数执行完毕后,表单会继续其默认提交行为
    });
}
// 页面加载后初始化控制器
document.addEventListener('DOMContentLoaded', initControllers);

过渡: 现在,表单的配置信息已经与HTML结构分离,管理起来更加方便。但我们仍然依赖表单的默认提交行为。接下来,我们将尝试自己构造要发送的数据。


目标4:使用JavaScript构造查询字符串 🔗

上一节我们使用JS配置了表单。本节中,我们将更进一步,自己来编码要发送的数据,将其构造成URL的查询字符串(Query String)。

思路:
我们将拦截表单的 submit 事件,阻止其默认行为。然后,手动收集表单数据,将其编码为 key=value&key2=value2 格式的查询字符串,并拼接到请求的URL末尾。最后,再以编程方式提交表单。

步骤:

  1. 更新控制器:在 submit 事件处理函数中,调用 event.preventDefault() 阻止表单默认提交。
  2. 收集数据:从DOM中获取用户输入的值,并与对应的Entry Name组成键值对对象。
  3. 编码数据:在模型(form_model.js)中创建一个函数(如 buildQueryString),使用 URLSearchParams API或手动拼接,将数据对象转换为查询字符串。
  4. 构造最终URL:将查询字符串附加到表单端点URL之后,形成完整的请求地址(如 端点URL?entry.123=用户输入&...)。
  5. 提交表单:将构造好的URL设置为表单的 action,然后调用表单的 submit() 方法(注意不是触发submit事件)。

核心概念与代码:

  • 查询字符串(Query String):出现在URL中 ? 之后的部分,用于向服务器传递参数。格式为 key1=value1&key2=value2
  • URLSearchParams:JavaScript内置API,用于处理查询字符串。
// form_model.js 中构建查询字符串的函数示例
function buildQueryString(formDataObject) {
    const params = new URLSearchParams();
    for (const key in formDataObject) {
        params.append(key, formDataObject[key]);
    }
    return params.toString(); // 输出如 "entry.123=John&entry.456=john@example.com"
}

// form_controller.js 中收集数据并构造URL
contactForm.addEventListener('submit', function(event) {
    event.preventDefault(); // 阻止默认提交

    const formData = {
        [ENTRY_NAME_1]: document.getElementById('name').value,
        [ENTRY_NAME_2]: document.getElementById('email').value,
        [ENTRY_NAME_3]: document.getElementById('message').value,
    };

    const queryString = buildQueryString(formData);
    const fullUrl = `${getFormEndpoint(FORM_ID)}?${queryString}`;

    contactForm.action = fullUrl;
    contactForm.method = 'POST';
    contactForm.submit(); // 编程方式提交
});

注意: 此方法会将所有提交的数据暴露在URL中,不适合传输敏感信息(如密码)。


目标5:使用异步JavaScript(Fetch API)发送请求 🌐

之前的方法都离不开HTML <form> 元素。本节中,我们将最终摆脱对表单的依赖,使用纯粹的JavaScript——Fetch API来发送HTTP POST请求。

思路:

  1. 将HTML中的 <form> 标签替换为普通的 <div>,因为我们不再需要表单的默认行为。
  2. 为提交按钮添加 click 事件监听器。
  3. 在事件处理函数中,使用Fetch API构造并发送请求。
  4. 使用 async/await 语法优雅地处理异步操作。

步骤:

  1. 修改HTML:用 <div> 包裹表单字段,移除 <form> 标签。
  2. 更新控制器:将事件监听从表单的 submit 改为按钮的 click
  3. 重构模型:创建新的 sendRequest 函数,使用Fetch API。
    • 使用 async 关键字声明异步函数。
    • 在函数内,使用 new Request() 构造函数创建一个请求对象,指定URL、方法(POST)和模式(mode: ‘no-cors’ 或 ‘cors’,根据后端配置)。
    • 使用 fetch(request) 发送请求,并用 await 等待响应。
    • 处理返回的响应对象。
  4. 处理响应:根据服务器返回的状态码或内容,更新前端界面(例如,显示“提交成功”提示,而不是跳转)。

核心概念与代码:

  • Fetch API:现代浏览器提供的用于发起网络请求的接口,比古老的 XMLHttpRequest 更强大易用。
  • async/await:用于处理异步操作的语法糖,让异步代码看起来像同步代码一样直观。
  • Request 对象:封装了HTTP请求的所有信息(URL、方法、头部、体等)。
  • CORS(跨源资源共享):一种安全机制。当我们的前端页面域名与后端接口域名不同时,需要后端配置CORS策略,或在前端请求中设置特定的mode(如‘no-cors’,但限制较多)。
// form_model.js - 使用Fetch API发送请求
async function sendToGoogleDb(formDataObject) {
    const endpoint = getFormEndpoint(FORM_ID);
    const queryString = buildQueryString(formDataObject);
    const url = `${endpoint}?${queryString}`;

    // 1. 创建Request对象
    const request = new Request(url, {
        method: 'POST',
        mode: 'no-cors', // 根据实际情况调整CORS设置
        // 如果需要发送JSON,可以设置headers和body
        // headers: { 'Content-Type': 'application/json' },
        // body: JSON.stringify(formDataObject)
    });

    try {
        // 2. 发送请求并等待响应
        const response = await fetch(request);
        // 注意:`no-cors`模式下response可能不可读,这里主要演示流程
        console.log('请求已发送,状态码:', response.status);

        // 3. 根据响应更新UI(例如,在控制器中调用)
        return { success: true, message: '提交成功!' };
    } catch (error) {
        console.error('提交失败:', error);
        return { success: false, message: '提交失败,请重试。' };
    }
}

// form_controller.js - 处理按钮点击
document.getElementById('submit-btn').addEventListener('click', async function() {
    const formData = {
        [ENTRY_NAME_1]: document.getElementById('name').value,
        [ENTRY_NAME_2]: document.getElementById('email').value,
        [ENTRY_NAME_3]: document.getElementById('message').value,
    };

    const result = await sendToGoogleDb(formData); // 调用异步函数
    alert(result.message); // 根据结果提示用户
    // 或者更优雅地更新页面中的某个元素来显示结果
});

优势:

  • 完全控制:可以精细控制请求和响应的每一个环节。
  • 无刷新体验:可以在不刷新页面的情况下提交数据并更新部分内容,用户体验更好(即实现SPA效果)。
  • 灵活性:可以轻松发送JSON等格式的数据,而不仅仅是表单编码数据。

总结 🎉

本节课中我们一起学习了HTTP POST请求的多种实现方式。我们从创建一个Google Form后端开始,逐步构建前端:

  1. 纯HTML表单:最简单直接,但控制力弱。
  2. JS设置表单属性:将配置与结构分离,增加灵活性。
  3. JS构造查询字符串:开始手动控制数据编码过程。
  4. 异步Fetch API:最终方案,完全通过JavaScript控制HTTP请求,实现了前后端的解耦和无刷新交互。

我们涵盖了端点(Endpoint)查询字符串(Query String)Fetch APIasync/await 以及 CORS 等核心概念。通过这个循序渐进的实验,你应该对客户端如何与服务器通信有了扎实的理解,为后续学习服务端开发和更复杂的Web应用打下了基础。

现在,你可以将这个功能完整的联系表单集成到你的个人作品集页面中,并配置Google Forms的邮件通知,以便及时收到用户的留言。

012:模块化与异步编程 Part 1

概述

在本节课中,我们将开始学习Lab 7,构建一个带有排行榜的问答游戏。我们将重点学习如何构建一个REST客户端应用,这依赖于HTTP协议和JavaScript Fetch API的知识。此外,我们还将首次深入探讨JavaScript模块,这是一种更好的代码组织方式。请注意,使用模块要求我们通过HTTP服务器(而非文件协议)来托管页面。

核心概念解析

在开始构建应用之前,我们先来理解几个关键概念,这些是完成本实验的基础。

REST客户端与API

一个完整的应用(Full-stack App)通过HTTP请求/响应在客户端和服务器之间共享数据。当我们的浏览器内应用开始请求外部数据时,它就可以被视为一个分布式应用,而浏览器应用本身则成为了一个REST API的客户端,即REST客户端

  • REST API:指服务器通过HTTP请求提供的数据接口。
  • HTTP方法:常见的操作包括:
    • GET:读取数据。
    • POST:创建新数据。
    • PUT:更新已有数据。
    • DELETE:删除数据。
      这些操作与数据库的CRUD(增删改查)操作相对应。
  • 端点(Endpoint):服务器上监听特定HTTP请求并返回数据的URL地址。

异步JavaScript

JavaScript提供了非阻塞函数,允许在等待异步操作(如网络请求)完成时,继续执行其他代码。这对于网络任务至关重要。

  • async / await
    • 使用 async 关键字声明一个异步函数。
    • 在需要等待输入/输出(IO)操作完成的语句前使用 await 关键字。
    • 如果一个函数内部使用了 await,则该函数必须声明为 async
  • Promise对象:异步函数返回一个Promise对象。函数执行后立即继续后续任务,Promise对象则在异步操作完成后被“解决”(resolved)。我们可以通过回调函数来处理解决后的结果。

代码示例:一个异步函数

async function fetchData(url) {
  const response = await fetch(url); // 等待网络请求完成
  const data = await response.json(); // 等待将响应解析为JSON
  return data; // 返回数据
}

JavaScript模块

模块允许JavaScript文件之间直接相互导入,而无需在HTML中通过 <script> 标签链接。这使代码更易读、可维护和扩展。

  • 要求:使用模块的HTML页面必须由HTTP服务器托管。
  • 作用域:模块拥有自己的作用域,不属于全局作用域或函数作用域。模块内的数据需要通过导入/导出进行共享。
  • import:允许脚本访问其他模块作用域中的函数或对象。
  • export:提供对模块内函数或对象的外部访问权限。可以导出多个命名项,也可以设置一个默认导出(default export)

代码示例:模块导出与导入

// bar.js - 导出模块
const myObject = { name: "Bar" };
export default myObject;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/e97df8272633486bf8030c3a32dc8a45_2.png)

// foo.js - 导入模块
import importedObject from './bar.js';
console.log(importedObject.name); // 输出: "Bar"
  • 路径说明./ 表示相对于当前目录,../ 表示相对于父目录。


上一节我们介绍了构建本实验所需的核心概念。接下来,我们将开始实际构建问答游戏的第一部分。

第一部分:构建问答游戏

我们的目标是构建一个如下图所示的问答游戏界面,包含计时器、分数、问题类别、难度、问题本身以及多个选项。

我们将采用迭代式开发,分步完成。首先,我们不自己编写问题,而是利用一个免费的在线服务:Open Trivia Database。它提供了一个无需API密钥的JSON API,我们可以通过发送带有查询参数的GET请求来获取随机的问答数据。

目标1:发送GET请求到REST API

我们的第一步是创建一个能够向 trivia API 发送GET请求并获取数据的JavaScript函数。

以下是实现步骤:

  1. 创建HTML文件:创建 index.html,链接CSS和JS文件。页面主体中只需一个带有 id="view" 的元素(如 <header id="view"></header>),后续所有内容都将通过DOM操作动态插入到此元素中。
  2. 创建HTTP模块:在 scripts/ 目录下创建 http.js 文件。定义一个异步函数 sendGetRequest(url),它使用Fetch API向指定URL发送GET请求,并返回解析后的JSON数据。
  3. 创建主应用模块:在 scripts/ 目录下创建 app.js 文件。
    • http.js 导入 sendGetRequest 函数。
    • 定义 trivia API 的端点URL(例如,从Open Trivia DB生成一个获取1个简单问题的URL)。
    • 初始化一个 state 对象来管理游戏状态(目前可为空)。
    • 定义一个异步函数 playGame(),在其中调用 sendGetRequest 获取问题数据,并暂时用 console.log 打印结果。
    • 定义全局函数 start(),在页面加载时调用 playGame()
  4. 链接模块:在 index.html 中,使用 <script type="module" src="scripts/app.js"></script> 引入主应用模块。
  5. 测试:使用Node.js的 npx http-server 启动一个本地HTTP服务器,在浏览器中打开页面,查看控制台是否成功打印出获取的 trivia 数据。

目标2:在DOM中显示问题

获取到数据后,我们需要将其显示在页面上。我们将创建可重用的“组件”函数来生成HTML。

以下是实现步骤:

  1. 创建问题组件:在 scripts/components/ 目录下创建 question.js。定义一个函数,接收 trivia 数据对象作为参数,返回一个包含问题类别、难度和问题文本的HTML字符串。将此函数作为默认导出。
  2. 创建视图模块:在 scripts/ 目录下创建 view.js
    • ./components/question.js 导入问题组件函数。
    • 定义一个 renderDom(html) 函数,它获取 id="view" 的DOM元素,并将其 innerHTML 设置为传入的HTML字符串。
    • 定义一个导出函数 playScene(properties),它从传入的属性中解构出 triviaData,然后调用 renderDom 函数,传入由问题组件生成的HTML。
  3. 更新应用逻辑:在 app.js 中,从 view.js 导入 playScene。修改 playGame() 函数,不再打印日志,而是从获取的数据中提取出 triviaData,然后调用 view.playScene({ triviaData }) 来渲染问题。
  4. 测试:刷新页面,现在问题应该会显示在浏览器视口中,而不仅仅是控制台。

目标3:显示答案选项

一个问题通常伴有多个答案选项。我们需要根据问题类型(布尔型/多选)来渲染不同的按钮。

以下是实现步骤:

  1. 创建选项组件:在 scripts/components/ 目录下创建 options.js
    • 定义 booleanOptions(triviaData) 函数,返回包含“True”和“False”两个按钮的HTML。
    • 定义 multipleOptions(triviaData) 函数。它需要将正确答案和三个错误答案合并到一个数组中,排序打乱顺序,然后返回四个按钮的HTML。每个按钮的 onclick 事件调用一个名为 checkAnswer 的函数,并传入该按钮代表的答案文本。
    • 定义主函数 options(triviaData),根据 triviaData.type 决定调用上述哪个函数,并返回结果。
    • options 函数作为默认导出。
  2. 集成选项到问题中:修改 question.js,从 ./options.js 导入 options 函数。在生成问题HTML的字符串模板中,加入对 options(triviaData) 的调用,将其结果追加到问题文本之后。
  3. 测试:刷新页面,现在每个问题下方应该会出现相应的答案按钮(True/False 或 A/B/C/D 四个选项)。按钮目前还不会交互。

目标4:实现计时器与HUD

为了让游戏更有挑战性,我们需要添加一个计时器和分数显示(HUD)。

以下是实现步骤:

  1. 完善游戏状态:在 app.jsstate 对象中,初始化以下属性:
    state = {
      score: 0,
      timer: 20, // 初始时间
      intervalId: null, // 用于存储计时器ID
      trivia: null // 当前问题数据
    };
    
  2. 创建HUD组件:在 scripts/components/ 目录下创建 hud.js。定义一个函数,接收 timerscore 作为参数,返回显示游戏名称、当前计时和分数的HTML字符串。将其作为默认导出。
  3. 更新视图:在 view.js 中导入 hud 组件。修改 playScene(properties) 函数,使其能从 properties 中解构出 timerscore。在 renderDom 的调用中,同时包含 hud(timer, score)question(triviaData) 的结果。
  4. 实现倒计时逻辑:在 app.js 中:
    • 定义 countdown() 函数。如果 state.timer > 0,则将其减1,并调用 view.playScene(state) 更新界面(这会更新HUD的显示)。如果计时器归零,则暂时什么都不做(下一步处理)。
    • 定义 createGame() 函数。它重置计时器,使用 setInterval(countdown, 1000) 启动一个每秒触发一次的计时器,并将返回的ID存入 state.intervalId。然后调用 playGame() 获取第一个问题。
    • 修改全局 start() 函数,改为调用 createGame()
  5. 测试:启动游戏,现在应该能看到顶部的计时器从20开始倒计时,并且分数显示为0。

目标5:实现游戏结束场景

当计时器归零时,游戏应结束并显示一个“游戏结束”画面。

以下是实现步骤:

  1. 创建游戏结束场景函数:在 view.js 中,定义一个新的导出函数 gameOverScene(properties)。它接收状态,渲染HUD,并添加一个“Game Over”标题以及一个“Start Menu”按钮(按钮的 onclick 事件暂定为 startMenu())。
  2. 修改倒计时逻辑:在 app.jscountdown() 函数中,添加 else 分支。当 state.timer === 0 时,调用 clearInterval(state.intervalId) 停止计时器,然后调用 view.gameOverScene(state) 切换到游戏结束画面。
  3. 测试:让游戏自然结束,确认会显示“Game Over”界面。

目标6:实现“跳过”功能

允许玩家在时间用完前跳过当前问题,获取一个新问题。

以下是实现步骤:

  1. 创建跳过组件:在 scripts/components/ 目录下创建 skip.js。定义一个函数,返回一个带有“Skip”按钮的HTML。按钮的 onclick 调用 playGame() 函数。将其作为默认导出。
  2. 集成跳过按钮:在 view.js 中导入 skip 组件。在 playScene 函数中,将 skip() 的结果也加入到渲染给DOM的HTML字符串中。
  3. playGame函数暴露到全局:为了让HTML按钮的 onclick 能调用到模块内的 playGame 函数,需要在 app.js 中将其赋值给 window 对象的一个属性(例如 window.playGame = playGame;)。同时,确保 playGame 函数会更新 state.trivia 并调用 view.playScene
  4. 测试:游戏中现在应出现“Skip”按钮,点击后能立即获取并显示一个新问题,而计时器继续运行。

目标7:处理答案提交与判断

最后,我们需要让答案按钮真正起作用,判断对错并更新分数。

以下是实现步骤:

  1. 实现答案检查函数:在 app.js 中,定义一个函数 checkAnswer(attempt)。这个函数也需要被暴露到全局(window.checkAnswer = checkAnswer;)。
    • 函数逻辑:
      • state.trivia.correct_answer 获取正确答案。
      • 比较 attempt 参数是否与正确答案匹配。
      • 如果正确
        • 将当前 state.timer 的值加到 state.score 上(回答越快,得分越高)。
        • state.timer 增加一些时间(例如加5秒),作为奖励。
        • 调用 playGame() 获取下一个问题。
      • 如果错误
        • 调用 clearInterval(state.intervalId) 停止计时。
        • 调用 view.gameOverScene(state) 结束游戏。
  2. 确保按钮调用正确:回顾 options.js 中按钮的 onclick 属性,它们已经设置为 checkAnswer('...') 的形式,其中 '...' 是具体的答案文本。现在这个调用将指向我们刚刚定义的全局函数。
  3. 最终测试:完整地玩一次游戏。尝试回答正确(分数增加,时间奖励)和回答错误(游戏结束),确保所有功能按预期工作。

总结

在本节课中,我们一起学习了Lab 7的第一部分。我们成功构建了一个功能完整的问答游戏,在这个过程中深入实践了以下核心技能:

  1. 构建REST客户端:使用Fetch API向外部服务(Open Trivia DB)发送异步GET请求以获取数据。
  2. 使用JavaScript模块:通过 import/export 来组织代码,将应用拆分为职责清晰的独立文件(如 app.js, http.js, view.js, 以及各个组件),提高了代码的可维护性。
  3. 异步编程:利用 async/await 语法处理网络请求,确保UI不会阻塞。
  4. 操作DOM构建单页应用(SPA):所有界面更新都通过JavaScript动态操作一个根DOM元素(id="view")来完成,实现了无页面刷新的流畅体验。
  5. 管理应用状态:使用一个中心化的 state 对象来跟踪分数、计时器、当前问题等游戏状态。
  6. 处理用户事件:为按钮绑定 onclick 事件,并调用相应的全局函数来处理跳过和答案提交逻辑。

目前,我们的游戏还缺少一个关键的社交功能:一个所有玩家共享的全球排行榜。在下一节课中,我们将进入Lab 7的第二部分,学习如何使用另一个名为JSONBin的API来存储和读取排行榜数据,从而完成这个分布式应用的最终形态。

013:模块化与异步编程(第二部分)

在本节课中,我们将继续构建我们的问答游戏,并实现一个共享的排行榜功能。我们将学习如何使用一个名为JSONBin.io的外部REST API服务来存储和获取数据,从而实现跨用户的数据持久化。


概述

上一节我们完成了问答游戏的基础组件。本节中,我们将实现一个排行榜系统。这个排行榜将显示前五名玩家的分数,并且当新玩家获得高分时,能够更新这个列表。为了实现这个功能,我们需要一个后端服务来存储数据,我们将使用JSONBin.io这个免费的JSON存储服务。


使用JSONBin.io作为后端服务

为了存储排行榜数据,我们需要一个所有用户都能访问的公共存储空间。我们将使用JSONBin.io,它提供了一个简单的REST接口来在云端存储和检索JSON数据。

以下是关于JSONBin.io的关键点:

  • 它是一个免费的JSON托管服务,适用于公共或私有数据。
  • 任何从浏览器可访问的数据都应该是公共的。切勿在浏览器代码中使用私有API密钥,因为这些代码对用户是可见的。
  • 我们将主要使用其API的GET(读取)和PUT(更新)操作,因为这些操作不需要API密钥,可以在公共代码中安全使用。

创建你自己的JSON Bin

以下是创建你自己的排行榜数据存储的步骤:

  1. 访问 JSONBin.io 并创建一个免费账户(建议使用GitHub账户登录)。
  2. 登录后,在仪表板中点击“Create New”按钮。
  3. 为你的数据选择一个集合(Collection),或创建一个新的。
  4. 在编辑器中,输入初始的JSON数据。排行榜数据应是一个包含5个对象的数组,每个对象有 namescore 两个属性。例如:
    [
      {"name": "PlayerA", "score": 30},
      {"name": "PlayerB", "score": 20},
      {"name": "PlayerC", "score": 11},
      {"name": "PlayerD", "score": 10},
      {"name": "PlayerE", "score": 5}
    ]
    
  5. 确保将Bin设置为 公开(Public),这样我们就不需要API密钥来访问它。
  6. 创建后,JSONBin会提供一个唯一的访问URL(包含一个Bin ID)。我们将使用这个URL来读写数据。

实现主菜单场景

首先,我们需要创建一个主菜单场景,作为游戏的起始页面,并在这里显示排行榜。

1. 在视图模块中添加主菜单函数

我们将在 view.js 中添加一个导出函数来渲染主菜单。

// 在 view.js 中添加
export const startMenu = (props) => {
  const { timer, score, topScores } = props;
  return renderDOM(`
    ${hud({ timer, score })}
    <hr>
    ${leaderboard(topScores)}
    <button onclick="window.createGame()">Play</button>
  `);
};

2. 重构应用启动逻辑

接下来,我们需要修改 app.js 中的 start 方法,使其在游戏开始时重置分数并显示主菜单。

// 在 app.js 的 start 方法中
const start = async () => {
  state.score = 0; // 重置分数
  state.timer = 20;
  // 获取排行榜数据(稍后实现)
  // 渲染主菜单
  view.startMenu(state);
};

3. 将游戏创建函数全局化

为了让HTML按钮的 onclick 事件能够调用 createGame 函数,我们需要将其注册到全局的 window 对象上。

// 在 app.js 中,定义 createGame 函数后
window.createGame = createGame;

完成这些步骤后,刷新游戏,你应该能看到一个包含“Play”按钮的主菜单界面。


获取并显示排行榜数据

现在,我们需要从JSONBin.io获取排行榜数据并显示在主菜单上。

1. 定义API端点常量

app.js 的顶部,定义用于访问我们特定JSON Bin的常量。

// 你的JSON Bin ID(替换成你自己的)
const BIN_ID = '你的Bin_ID';
// 构建获取排行榜数据的URL
const GET_LEADERBOARD_URL = `https://api.jsonbin.io/v3/b/${BIN_ID}/latest`;

同时,在应用的状态对象中添加一个 topScores 属性来存储这些数据。

const state = {
  score: 0,
  timer: 20,
  intervalId: null,
  trivia: null,
  topScores: [] // 新增:存储排行榜数据
};

2. 在启动时获取数据并更新视图

修改 start 方法,使其在游戏开始时获取排行榜数据。

const start = async () => {
  state.score = 0;
  state.timer = 20;
  // 获取排行榜数据
  state.topScores = await http.sendGetRequest(GET_LEADERBOARD_URL);
  // 渲染主菜单,并传入排行榜数据
  view.startMenu(state);
};

3. 创建排行榜组件

我们需要一个专门的组件来渲染排行榜的HTML。创建一个新文件 components/Leaderboard.js

// components/Leaderboard.js
const listItems = (scores) => {
  let items = '';
  // 确保按分数从高到低排序
  const sortedScores = [...scores].sort((a, b) => b.score - a.score);
  for (const row of sortedScores) {
    items += `<li>${row.name}: ${row.score}</li>`;
  }
  return items;
};

const leaderboard = (topScores) => `
  <h3>Top Scores</h3>
  <section>
    <ol>
      ${listItems(topScores)}
    </ol>
  </section>
`;

export default leaderboard;

4. 在主菜单中使用排行榜组件

首先,在 view.js 中导入 Leaderboard 组件。然后,更新之前创建的 startMenu 函数,将 topScores 数据传递给 leaderboard 组件进行渲染。

// 在 view.js 中
import leaderboard from './components/Leaderboard.js';

export const startMenu = (props) => {
  const { timer, score, topScores } = props; // 解构出 topScores
  return renderDOM(`
    ${hud({ timer, score })}
    <hr>
    ${leaderboard(topScores)} <!-- 在这里渲染排行榜 -->
    <button onclick="window.createGame()">Play</button>
  `);
};

现在,当你启动游戏时,主菜单应该会显示从JSONBin.io获取的排行榜数据。


实现更新排行榜的功能

当玩家获得高分时,我们需要更新后端的排行榜数据。

1. 在HTTP模块中添加PUT请求方法

我们需要扩展 http.js 模块,使其能够发送PUT请求来更新数据。

// 在 http.js 中添加
export const sendPutRequest = async (url, data) => {
  const options = {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data) // 将JavaScript对象序列化为JSON字符串
  };
  const response = await fetch(url, options);
  return response; // PUT请求通常不返回具体数据,只返回响应状态
};

2. 定义更新排行榜的端点

app.js 中,添加用于更新排行榜的URL常量。

const PUT_LEADERBOARD_URL = `https://api.jsonbin.io/v3/b/${BIN_ID}`;

3. 创建更新排行榜的逻辑

我们需要一个函数来比较当前玩家的分数和现有排行榜,并在必要时更新。

// 在 app.js 中添加
const getTopFive = async (newScore) => {
  // 1. 获取当前排行榜
  const currentTopScores = await http.sendGetRequest(GET_LEADERBOARD_URL);
  // 2. 将新分数加入数组
  currentTopScores.push(newScore);
  // 3. 重新排序(从高到低)
  currentTopScores.sort((a, b) => b.score - a.score);
  // 4. 只保留前五名
  currentTopScores.splice(5);
  // 5. 返回新的前五名数组
  return currentTopScores;
};

// 全局函数,用于在游戏结束时被调用
window.updateLeaderboard = async () => {
  // 获取玩家输入的名字(假设有一个id为‘name’的输入框)
  const playerName = document.getElementById('name').value;
  const currentScore = { name: playerName, score: state.score };

  // 计算新的排行榜
  const newTopScores = await getTopFive(currentScore);
  // 将新排行榜发送到后端
  await http.sendPutRequest(PUT_LEADERBOARD_URL, newTopScores);
  // 更新后,重新启动游戏(回到主菜单)
  start();
};

4. 创建高分输入组件

当玩家获得高分时,我们需要一个表单让他们输入名字。创建一个新组件 components/LeaderMenu.js

// components/LeaderMenu.js
const leaderMenu = () => `
  <div>High Score!</div>
  <section>
    <input id="name" type="text" placeholder="Your Name">
    <input type="button" value="Submit" onclick="window.updateLeaderboard()">
  </section>
  <hr>
`;

export default leaderMenu;

5. 在游戏结束场景中条件渲染高分菜单

view.js 中,我们需要判断玩家分数是否进入了前五名,如果是,则显示 LeaderMenu 组件。

首先,导入 LeaderMenu 组件并创建一个判断函数。

// 在 view.js 中
import leaderMenu from './components/LeaderMenu.js';

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/72c674e5898482865379c8508aff778e_9.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/72c674e5898482865379c8508aff778e_10.png)

// 辅助函数:判断分数是否足以进入排行榜
const isTopFive = (score, topScores) => {
  // 如果排行榜不满5人,或者当前分数高于最后一名,则返回true
  return topScores.length < 5 || topScores.some(s => score > s.score);
};

然后,在 gameOverScene 函数中使用这个判断。

export const gameOverScene = (props) => {
  const { score, topScores } = props; // 确保解构出 topScores
  return renderDOM(`
    ${hud({ timer: 0, score })}
    ${isTopFive(score, topScores) ? leaderMenu() : ''}
    <h1>Game Over</h1>
    <button onclick="window.start()">Menu</button>
  `);
};

现在,当玩家游戏结束并且分数足够高时,就会看到一个输入名字的提示,提交后排行榜将被更新。


总结

在本节课中,我们一起学习了如何为我们的问答游戏添加一个完整的排行榜系统。我们主要完成了以下工作:

  1. 引入了外部数据服务:使用JSONBin.io作为简单、免费的后端来存储和共享JSON格式的排行榜数据。
  2. 实现了数据获取与展示:通过HTTP GET请求从服务端获取排行榜数据,并创建专门的组件将其渲染到游戏主菜单中。
  3. 实现了数据更新逻辑:通过HTTP PUT请求将新的高分更新到服务端。我们编写了逻辑来比较分数、对列表进行排序,并确保只保留前五名。
  4. 完善了用户交互:在玩家获得高分时,动态显示一个表单供其输入姓名,并在提交后自动更新本地和远程的排行榜数据。

通过这个实践,我们不仅巩固了模块化开发和异步编程(async/await)的知识,还掌握了如何让前端应用与远程REST API进行交互,从而实现数据的持久化与共享。这为构建更复杂、交互性更强的Web应用打下了坚实的基础。

014:静态Web服务器 🖥️

在本节课中,我们将从前端转向后端,开始探索如何构建自己的Web服务器。我们将学习Node.js和NPM的基础知识,并从头开始构建一个能够处理HTTP请求、发送响应并支持不同状态码的静态Web服务器。


概述 📋

我们将从零开始,逐步构建一个功能完整的静态Web服务器。这个服务器将能够监听客户端请求,解析请求的URL路径,检查文件是否存在,并根据结果返回相应的HTTP状态码(如200成功、404未找到、302重定向、500服务器错误)和内容。通过这个过程,我们将深入理解Web服务器的工作原理。


1. 引言与核心概念

在开始编码之前,我们需要理解几个核心概念。

Node.js 是一个JavaScript运行时环境,它允许我们在操作系统层面(例如通过终端)运行JavaScript代码,而不仅仅局限于浏览器环境。这为我们打开了构建各种应用程序(包括Web服务器)的大门。

NPM(Node Package Manager)是Node.js的包管理器。它用于安装项目依赖的外部模块,并可以运行我们定义的脚本,是管理Node.js项目的标准工具。

一个Web服务器是一个应用程序,它监听来自客户端(如浏览器)的HTTP请求,并发送回HTTP响应。

在构建服务器时,我们需要处理:

  • 路由:应用程序的端点(URL)如何响应客户端请求。
  • MIME类型:指示所传输文档性质和格式的标准(例如 text/html 代表HTML文档,image/jpeg 代表JPEG图片)。
  • HTTP状态码:服务器响应的一部分,用于表明请求的成功或失败状态(例如200成功,404未找到)。

2. 迭代一:使用Node部署“Hello World”服务器

上一节我们介绍了核心概念,本节中我们来看看如何构建一个最简单的Web服务器。

我们的目标是创建一个服务器,监听请求并返回“Hello World”文本。

步骤如下:

  1. 导入HTTP模块:Node.js内置了http模块,用于创建服务器和处理HTTP事务。
    const http = require('http');
    
  2. 配置服务器:定义服务器监听的主机名和端口。
    const hostname = 'localhost';
    const port = 3000;
    
  3. 创建请求处理器:这是一个回调函数,当服务器收到请求时会被调用。它接收requestresponse对象。
    const httpHandler = (request, response) => {
      response.statusCode = 200; // 设置状态码为200(成功)
      response.setHeader('Content-Type', 'text/plain'); // 设置响应头,内容类型为纯文本
      response.end('Hello World\n'); // 结束响应并发送数据
    };
    
  4. 创建服务器并启动监听:使用http模块创建服务器实例,并让它开始监听指定端口。
    const server = http.createServer(httpHandler);
    server.listen(port, hostname, () => {
      console.log(`Server running at http://${hostname}:${port}/`);
    });
    

将以上代码保存为server.js,在终端中运行 node server.js,然后在浏览器中访问 http://localhost:3000/,你将看到“Hello World”。

关键点:必须调用response.end()来结束响应,否则客户端会一直等待。


3. 迭代二:使用NPM部署服务器

上一节我们直接使用Node运行脚本,本节中我们来看看如何使用NPM来管理和启动我们的应用,这是更标准的项目做法。

NPM会寻找项目根目录下的package.json文件来获取配置信息。

步骤如下:

  1. 创建package.json文件:在项目目录下创建此文件,它是一个JSON格式的配置文件。
    {
      "name": "web-server",
      "version": "0.0.1",
      "description": "A simple static web server",
      "main": "server.js",
      "author": "Your Name",
      "scripts": {
        "start": "node server.js"
      }
    }
    
  2. 使用NPM启动:在终端中,进入项目目录并运行 npm start。NPM会读取package.json中的scripts配置,执行node server.js命令。

现在,我们有了一个更规范的项目结构,并且可以通过NPM统一管理启动命令。


4. 迭代三:解析URL路径中的文件名

我们的服务器目前只响应根路径。本节中我们来看看如何解析客户端请求的URL路径,以确定他们想要访问哪个文件。

我们需要使用Node.js内置的urlpath模块。

步骤如下:

  1. 导入所需模块
    const url = require('url');
    const path = require('path');
    
  2. 重构HTTP处理器:在处理器中,解析请求的URL,获取路径名,并将其与当前工作目录拼接,得到完整的本地文件路径。
    const httpHandler = (request, response) => {
      const parsedUrl = url.parse(request.url);
      const pathname = `.${parsedUrl.pathname}`; // `./` 开头表示相对路径
      const filename = path.join(process.cwd(), pathname); // 拼接完整路径
    
      console.log(`URL requested: ${parsedUrl.pathname}`);
      console.log(`File path: ${filename}`);
      // 注意:此时尚未发送响应,浏览器会持续加载
    };
    

运行服务器并访问 http://localhost:3000/index.html,你将在终端看到输出的URL和计算出的文件路径。这证明我们已经能够正确解析客户端的请求目标。


5. 迭代四:处理404“文件未找到”错误

上一节我们学会了解析路径,本节中我们来看看当请求的文件不存在时,如何向客户端返回一个标准的404错误。

我们需要使用fs(文件系统)模块来检查文件是否存在。

步骤如下:

  1. 导入fs模块
    const fs = require('fs');
    
  2. 重构HTTP处理器:尝试获取文件状态。如果文件不存在(抛出异常),则调用一个辅助函数返回404错误。
    const httpHandler = (request, response) => {
      // ... 解析URL和路径的代码 ...
      try {
        const stats = fs.statSync(filename); // 同步获取文件状态
        // 文件存在,后续处理(下一步实现)
      } catch (error) {
        fileNotFound(response); // 文件不存在,返回404
        return;
      }
    };
    
  3. 创建fileNotFound辅助函数:此函数负责构建并发送404响应。
    const fileNotFound = (response) => {
      response.writeHead(404, { 'Content-Type': 'text/plain' });
      response.write('404 Not Found\n');
      response.end();
    };
    

现在,如果你请求一个不存在的文件(如 http://localhost:3000/nonexistent.html),服务器将返回“404 Not Found”。


6. 迭代五:处理200成功并识别MIME类型

当文件存在时,我们需要返回成功响应。本节中我们来看看如何根据文件扩展名确定正确的MIME类型,并返回200状态码。

步骤如下:

  1. 定义MIME类型映射字典:将常见的文件扩展名映射到对应的MIME类型。
    const mimeTypes = {
      '.html': 'text/html',
      '.jpeg': 'image/jpeg',
      '.jpg': 'image/jpeg',
      '.png': 'image/png',
      '.js': 'text/javascript',
      '.css': 'text/css'
    };
    
  2. 重构HTTP处理器:在文件状态获取成功后,检查它是否是一个普通文件。如果是,则调用serveFile辅助函数。
    if (stats.isFile()) {
      serveFile(response, filename);
    }
    // 注意:我们暂时不处理目录情况
    
  3. 创建serveFile辅助函数:此函数根据文件扩展名设置正确的Content-Type头,并返回200状态码。
    const serveFile = (response, filename) => {
      const ext = path.extname(filename); // 获取文件扩展名,如 `.html`
      const mimeType = mimeTypes[ext] || 'text/plain'; // 查找MIME类型,默认为纯文本
    
      response.writeHead(200, { 'Content-Type': mimeType });
      response.write(`200 Success - Serving file as ${mimeType}\n`);
      response.end();
    };
    

创建一个index.html文件,然后访问它。服务器将返回200状态码,并告诉你它识别出这是一个text/html类型的文件。


7. 迭代六:提供HTML文件内容给浏览器

上一节我们识别了文件类型,本节中我们来看看如何将文件的实际内容读取并发送给客户端浏览器进行渲染。

我们将使用fs模块创建可读流,并将文件内容“管道”传输到响应对象中。

重构serveFile函数:

const serveFile = (response, filename) => {
  const ext = path.extname(filename);
  const mimeType = mimeTypes[ext] || 'text/plain';

  response.writeHead(200, { 'Content-Type': mimeType });

  // 创建文件可读流,并将其管道传输到响应中
  const fileStream = fs.createReadStream(filename);
  fileStream.pipe(response);
};

现在,当你访问 http://localhost:3000/index.html 时,浏览器不仅收到200状态码和正确的Content-Type头,还会收到index.html文件的内容并渲染它。


8. 迭代七:实现302重定向

通常,访问一个目录(如根路径/)时,Web服务器会默认返回该目录下的index.html文件。本节中我们来看看如何使用302状态码实现重定向。

我们需要在HTTP处理器中检查请求的目标是否是一个目录。

步骤如下:

  1. 重构HTTP处理器中的条件判断:在文件状态检查成功后,区分它是文件还是目录。
    if (stats.isFile()) {
      serveFile(response, filename);
    } else if (stats.isDirectory()) {
      serveIndex(response); // 是目录,重定向到index.html
    }
    
  2. 创建serveIndex辅助函数:此函数发送302状态码,并在响应头中指定重定向的目标位置。
    const serveIndex = (response) => {
      response.writeHead(302, { 'Location': '/index.html' });
      response.end();
    };
    

现在,当你直接访问 http://localhost:3000/(根目录)时,服务器会返回302重定向响应,浏览器会自动跳转到 /index.html 并加载页面。


9. 迭代八:处理500服务器内部错误

我们已经处理了客户端错误(404)和成功情况(200,302)。本节中我们来看看最后一个常见的状态码——500,它表示服务器端发生了内部错误。

我们将在逻辑中添加一个最终的else分支,作为意外情况的兜底处理。

步骤如下:

  1. 完善HTTP处理器中的条件判断:添加else分支来处理既不是文件也不是目录的意外情况(例如,由于权限问题无法访问)。
    if (stats.isFile()) {
      serveFile(response, filename);
    } else if (stats.isDirectory()) {
      serveIndex(response);
    } else {
      serverError(response); // 意外情况,触发服务器错误
    }
    
  2. 创建serverError辅助函数:此函数发送500状态码。
    const serverError = (response) => {
      response.writeHead(500, { 'Content-Type': 'text/plain' });
      response.write('500 Internal Server Error\n');
      response.end();
    };
    

注意:在正常运行的代码中,这个分支很难被触发。你可以通过故意引入逻辑错误(例如,错误地判断文件类型)来测试这个功能。这是一个重要的安全网,用于处理不可预见的服务器端问题。


总结 🎉

在本节课中,我们一起学习了如何使用纯Node.js和其核心模块构建一个静态Web服务器。我们完成了以下任务:

  1. 使用http模块创建了服务器实例并监听端口。
  2. 使用urlpath模块解析客户端请求的URL路径。
  3. 使用fs模块检查文件是否存在、读取文件内容。
  4. 根据不同的情况,向客户端返回了相应的HTTP状态码:200(成功)、404(未找到)、302(重定向)和500(服务器错误)。
  5. 根据文件扩展名设置了正确的Content-Type(MIME类型)响应头。
  6. 使用NPM来管理项目并启动脚本。

通过这个循序渐进的构建过程,我们深入理解了Web服务器处理请求-响应周期的基本机制。在接下来的课程中,我们将使用Express框架来重构这个服务器。Express会帮我们处理大量底层细节,让我们能够更专注于应用程序的业务逻辑,用更少的代码实现更强大的功能。

015:动态Web服务器 🚀

在本节课中,我们将学习如何使用Express框架构建一个动态Web服务器。我们将从设置项目开始,逐步实现一个能够响应GET和POST请求、提供HTML文件、处理表单数据并返回JSON响应的服务器。最后,我们将学习如何将应用部署到Heroku平台,使其在互联网上公开可用。

概述 📋

上一节我们介绍了静态Web服务器,它只负责向客户端返回HTML文档。本节中,我们将转向动态Web服务器。动态服务器不仅能提供HTML内容,还能处理业务逻辑、响应不同的HTTP方法(如GET、POST),并维护服务器端的数据。我们将使用Express框架来简化这一过程,并最终将应用部署到Heroku。

迭代一:使用NPM设置Express应用 📦

首先,我们需要设置一个Express应用。由于Express不是Node.js的核心模块,我们需要使用NPM(Node包管理器)来安装它。这需要一个配置文件来管理应用的依赖项和启动命令。

以下是创建和配置package.json文件的步骤:

  1. 在项目根目录下创建package.json文件。
  2. 在文件中定义应用的基本元数据,如名称、版本、描述和入口点。
  3. dependencies对象中指定需要安装的包及其版本。
{
  "name": "web-server",
  "version": "1.0.0",
  "description": "A dynamic web server using Express",
  "main": "app.js",
  "author": "Your Name",
  "dependencies": {
    "express": "*"
  }
}

创建好package.json后,在终端运行npm install命令。NPM会读取该文件,自动下载并安装Express及其所有依赖项到node_modules文件夹中。

迭代二:创建发送响应的服务器 💬

现在,我们将创建一个基本的Express服务器,它能够监听请求并发送一个简单的“Hello World”响应。

以下是实现步骤:

  1. 创建主应用文件app.js
  2. 导入express模块并创建Express应用实例。
  3. 定义一个端口号(例如3000)。
  4. 设置一个路由,监听根路径(/)的GET请求,并绑定一个处理函数。
  5. 在处理函数中,使用response.send()方法向客户端发送“Hello World”消息。
  6. 最后,让应用开始监听指定的端口。
// 导入express模块
const express = require('express');
// 创建Express应用实例
const app = express();
// 定义端口
const port = 3000;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_2.png)

// 定义根路径的GET请求处理函数
function serveIndex(request, response) {
  response.send('Hello World');
}

// 设置路由:当收到对根路径的GET请求时,调用serveIndex函数
app.get('/', serveIndex);

// 启动服务器,开始监听指定端口
app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

运行node app.js启动服务器,然后在浏览器中访问http://localhost:3000,你应该能看到“Hello World”消息。

迭代三:创建提供HTML文件的服务器 📄

接下来,我们将改进服务器,使其能够向客户端发送一个HTML文件,而不仅仅是纯文本。

以下是实现步骤:

  1. 在项目目录中创建一个index.html文件,并编写一些简单的HTML内容。
  2. 修改app.js中的serveIndex处理函数。
  3. 使用response.sendFile()方法替代response.send()。该方法需要两个参数:要发送的文件名和一个指定根目录的选项对象。
  4. 使用Node.js的__dirname全局变量来获取当前文件所在的目录路径,作为文件的根目录。

// 修改serveIndex函数以发送HTML文件
function serveIndex(request, response) {
  response.sendFile('index.html', { root: __dirname });
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_14.png)

// 路由设置保持不变
app.get('/', serveIndex);

重启服务器并刷新浏览器,现在你应该能看到index.html文件的内容被正确渲染。

迭代四:提供另一个HTML文件 🔗

一个完整的网站通常有多个页面。现在,我们将创建第二个页面(例如一个联系表单页面),并设置路由来提供它。

以下是实现步骤:

  1. 创建一个新的HTML文件,例如contact.html,并在其中构建一个表单。
  2. app.js中,为/contact.html路径设置一个新的GET路由。
  3. 创建一个新的处理函数(例如serveContact),用于发送contact.html文件。
<!-- contact.html 示例 -->
<form method="POST" action="/contact/send">
  <input type="text" name="name" placeholder="Enter name">
  <input type="email" name="email" placeholder="Enter email">
  <textarea name="message" placeholder="Enter message"></textarea>
  <button type="submit">Submit</button>
</form>

// 新增处理函数,用于发送联系页面
function serveContact(request, response) {
  response.sendFile('contact.html', { root: __dirname });
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_30.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_32.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_33.png)

// 为联系页面设置新的GET路由
app.get('/contact.html', serveContact);

现在,访问http://localhost:3000/contact.html就能看到联系表单页面。

迭代五:设置POST路由 📤

我们的联系表单设置了method="POST"action="/contact/send"。现在,我们需要在服务器端设置相应的路由来处理这个POST请求。

以下是实现步骤:

  1. app.js中,为/contact/send路径设置一个POST路由。
  2. 创建一个新的处理函数(例如contactHandler)。
  3. 在这个处理函数中,我们暂时只在服务器终端打印一条消息,然后将客户端重定向回首页。
// 新增处理函数,用于处理表单提交
function contactHandler(request, response) {
  console.log('Received a POST request');
  response.redirect('/'); // 重定向回首页
}

// 为表单提交设置POST路由
app.post('/contact/send', contactHandler);

填写联系表单并提交后,你会在运行服务器的终端看到“Received a POST request”的日志,并且浏览器会被重定向到首页。

迭代六:解析POST请求中的查询字符串 🔍

表单数据通常以查询字符串的形式在POST请求体中发送。为了在服务器端访问这些数据,我们需要使用一个名为body-parser的中间件。

以下是实现步骤:

  1. 使用NPM安装body-parser模块:npm install body-parser
  2. app.js顶部导入body-parser
  3. 在设置任何路由之前,通过app.use()告诉Express应用使用body-parser的JSON和URL编码解析器。
  4. 修改contactHandler函数,通过request.body来访问解析后的表单数据。

// 导入body-parser模块
const bodyParser = require('body-parser');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_64.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_66.png)

// 在设置路由前,使用body-parser中间件
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

// 修改contactHandler函数以查看请求体
function contactHandler(request, response) {
  console.log(request.body); // 打印表单数据
  response.redirect('/');
}

现在,当你提交表单时,服务器终端会打印出一个包含nameemailmessage字段的对象。

迭代七:创建GET路由以返回JSON数据 📊

我们已经可以接收数据,现在让我们创建一个新的端点,允许客户端通过GET请求获取所有已提交的表单数据。

以下是实现步骤:

  1. app.js顶部创建一个数组(例如submissions)来存储提交的数据。
  2. 修改contactHandler函数,将每次收到的request.body对象推入submissions数组。
  3. 创建一个新的GET路由,例如/submissions
  4. 为该路由创建一个处理函数(例如serveSubmissions),使用response.json()方法将submissions数组作为JSON数据发送给客户端。
// 用于存储提交数据的数组
let submissions = [];

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_70.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_72.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/bb0d34d5369a7f8d2f73cd3eb71961cb_74.png)

// 修改contactHandler,存储数据
function contactHandler(request, response) {
  submissions.push(request.body); // 将新数据存入数组
  response.redirect('/');
}

// 新增处理函数,用于返回所有提交的数据
function serveSubmissions(request, response) {
  response.json(submissions); // 以JSON格式发送数组
}

// 设置获取提交数据的GET路由
app.get('/submissions', serveSubmissions);

现在,访问http://localhost:3000/submissions,你会看到一个包含所有已提交表单数据的JSON数组。你甚至可以在客户端JavaScript中使用fetch API来获取这些数据。

迭代八:使用Heroku部署应用 ☁️

最后,我们将把这个动态Web服务器部署到互联网上,使用Heroku的免费云服务。

以下是部署步骤:

  1. 准备生产环境配置

    • 修改端口设置,使其能适应Heroku动态分配的环境变量。
    • 确保导入了Node.js的path模块(如果sendFile需要)。
    • package.json中添加一个scripts部分,定义启动命令。
    // 动态设置端口:优先使用Heroku提供的端口,否则使用3000
    const port = process.env.PORT || 3000;
    
    // 在package.json中添加启动脚本
    "scripts": {
      "start": "npm install && node app.js"
    }
    
  2. 安装Heroku CLI并登录

    • 根据Heroku文档安装命令行工具。
    • 在终端运行heroku login并完成浏览器登录。
  3. 创建Heroku应用

    • 在项目目录下,运行heroku create。这会创建一个随机的应用名并关联到你的Heroku账户。
  4. 初始化Git并关联Heroku

    • 如果项目还不是Git仓库,运行git init
    • 运行git remote add heroku <你的Heroku应用Git URL>。Heroku CLI通常会自动设置好。
  5. 创建.gitignore文件

    • 运行echo node_modules > .gitignore,确保不将本地的依赖包上传到Heroku。
  6. 提交并推送代码

    • 运行git add .git commit -m "Initial commit"
    • 运行git push heroku master。Heroku会自动构建和部署你的应用。

部署成功后,Heroku会提供一个URL(如https://your-app-name.herokuapp.com)。访问这个URL,你的动态Web应用就在公网可用了!

总结 🎉

在本节课中,我们一起学习了如何使用Express框架构建一个动态Web服务器。我们从设置项目和基本响应开始,逐步实现了提供HTML文件、处理表单POST请求、解析数据、以及通过API端点返回JSON数据的功能。最后,我们成功地将应用部署到了Heroku平台,使其成为一个真正的在线服务。

关键知识点包括:

  • Express框架:简化了路由设置和请求处理。
  • 中间件:如body-parser,用于在请求到达路由处理函数前进行预处理。
  • REST端点:服务器端设置URL路径来响应不同的HTTP方法。
  • 数据流:客户端通过表单POST数据到服务器,服务器存储数据并通过另一个GET端点提供数据。
  • 部署:使用Git和Heroku CLI将Node.js应用部署到云端。

这个动态服务器为构建更复杂的Web应用(如需要用户认证、数据库集成或实时功能的应用)奠定了基础。

016:REST API 第一部分

概述

在本节课中,我们将学习如何使用 Express 框架设计和实现一个 REST API。我们将构建一个多人版的“猜数字”游戏服务器,客户端可以通过发送 HTTP 请求来参与游戏。通过本教程,你将掌握路由参数、查询字符串、唯一标识符(UID)以及如何组织后端代码。


1. 课程介绍与核心概念

上一节我们介绍了后端技术的基础。本节中,我们将深入探讨 REST API 的设计与实现。

REST API 是一种通过网络(通常是 HTTP 协议)提供数据访问的 Web 服务接口。它可以为其他后端服务或前端应用提供数据。

核心概念

  • API (应用程序接口): 一组预定义的函数或方法集合,开发者可以调用它们。
  • REST API (表述性状态转移接口): 指使用 HTTP 协议来提供 API 访问的方式。其核心是通过标准的 HTTP 方法(GET, POST, PUT, DELETE 等)对资源进行操作
  • 端点 (Endpoint): API 与远程系统交互的接触点。对我们而言,端点就是服务器的 URL,用于请求或发送数据。
  • Express: 一个基于 Node.js 的 Web 应用框架,用于快速搭建服务器和处理 HTTP 请求。
  • 路由参数 (Route Parameters): 允许我们在 URL 中定义变量部分。例如,/game/:id 中的 :id 可以匹配任何值。
  • 查询字符串 (Query String): 附加在 URL 问号(?)后面的键值对,用于向服务器传递数据。格式为 ?key1=value1&key2=value2
  • 唯一标识符 (UID): 用于唯一标识某个资源(如一个游戏会话)。我们将使用 shortid 模块来生成易于分享的短 ID。

2. REST API 设计

在开始编码之前,我们需要设计 API 的端点和功能。这有助于我们清晰地规划代码结构。

以下是我们的“猜数字”游戏 API 设计表:

HTTP 方法 端点 (路由) 描述 请求参数 (查询字符串) 服务器行为
GET /api/game/new 创建新游戏 start: 范围起始值
end: 范围结束值
在指定范围内生成一个随机密码,创建一个新游戏实例,并返回其唯一 ID。
GET /api/game/:id 获取游戏信息 根据提供的 :id 返回对应游戏的状态(如范围、是否结束),但不返回密码。
GET /api/game/:id/guess 提交猜测 guess: 猜测的数字 在 ID 对应的游戏中检查猜测。返回结果:correct(正确)、too low(太低)、too high(太高)、game over(游戏已结束)或 error(错误)。
GET /api/game/:id/reset 重置游戏 如果游戏已结束,则重置游戏:生成新密码并将游戏状态设为未结束。

3. 项目初始化与配置

现在,我们开始构建项目。首先,我们需要设置项目的基本结构和配置文件。

步骤 1: 创建项目结构

在终端中执行以下命令,创建项目目录和必要的文件:

mkdir highlow-server
cd highlow-server
touch package.json app.js
mkdir routes controllers models

步骤 2: 配置 package.json

package.json 文件用于管理项目的元数据、依赖和脚本。

打开 package.json 文件,并输入以下内容:

{
  "name": "highlow-rest-api",
  "version": "1.0.0",
  "description": "A backend API for a high-low number guessing game.",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "*",
    "shortid": "*"
  }
}

代码解释:

  • name, version, description: 项目的基本信息。
  • main: 应用的入口文件。
  • scripts: 定义可以通过 npm run <script-name> 运行的命令。npm start 会直接运行 node app.js
  • dependencies: 项目依赖的模块。* 表示使用最新版本。

步骤 3: 安装依赖

在项目根目录下运行以下命令,安装 expressshortid 模块:

npm install

此命令会创建 node_modules 文件夹和 package-lock.json 文件。


4. 搭建基础 Express 应用

接下来,我们创建应用的主文件并启动一个基本的服务器。

打开 app.js 文件,输入以下代码:

// 1. 导入所需模块
const express = require('express');
const path = require('path');
const bodyParser = require('body-parser');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_12.png)

// 2. 创建 Express 应用实例
const app = express();

// 3. 定义端口(优先使用环境变量,便于部署)
const PORT = process.env.PORT || 3000;

// 4. 设置中间件
// 解析 application/json 格式的请求体
app.use(bodyParser.json());
// 解析 application/x-www-form-urlencoded 格式的请求体(用于查询字符串)
app.use(bodyParser.urlencoded({ extended: true }));

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_14.png)

// 5. 启动服务器,监听指定端口
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

代码解释:

  • 我们导入了 express, path, body-parser 模块。
  • body-parser 中间件用于解析客户端发送的请求数据。
  • app.listen() 启动服务器并在指定端口监听请求。

现在,你可以运行 npm start 来启动服务器。如果看到 Server is running on port 3000 的输出,说明服务器已成功启动。


5. 创建测试路由与控制器

为了验证我们的服务器能正常工作,我们先创建一个简单的测试端点。

我们将遵循 MVC(模型-视图-控制器)模式来组织代码:

  • 模型 (Models): 管理数据(游戏状态、逻辑)。
  • 视图 (Views): 本 API 不涉及前端视图。
  • 控制器 (Controllers): 处理请求,调用模型,并发送响应。
  • 路由 (Routes): 定义 URL 端点与控制器方法的映射关系。

步骤 1: 创建控制器

controllers/ 目录下创建 game.controller.js 文件:

// 控制器类,包含处理请求的方法
class GameController {
  // 测试方法,返回一个简单的 JSON 响应
  test(req, res) {
    res.json({ success: true });
  }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_36.png)

// 导出控制器的一个实例
module.exports = new GameController();

步骤 2: 创建路由

routes/ 目录下创建 game.routes.js 文件:

// 1. 导入 Express 的路由功能
const express = require('express');
const router = express.Router();

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_40.png)

// 2. 从控制器导入处理方法(这里使用解构赋值只导入 test 方法)
const { test } = require('../controllers/game.controller');

// 3. 定义路由:当收到对 `/test` 的 GET 请求时,调用控制器的 test 方法
router.get('/test', test);

// 4. 导出配置好的路由器
module.exports = router;

步骤 3: 在主应用中集成路由

修改 app.js 文件,在启动服务器之前添加路由:

// ... 之前的导入和中间件设置代码 ...

// 导入游戏路由
const gameRoutes = require('./routes/game.routes');

// 将游戏路由挂载到 `/api/game` 路径下
// 这意味着所有 `/api/game/*` 的请求都将由 gameRoutes 处理
app.use('/api/game', gameRoutes);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_46.png)

// ... 启动服务器的代码 ...

步骤 4: 测试

  1. 重启服务器 (npm start)。
  2. 在浏览器中访问 http://localhost:3000/api/game/test
  3. 你应该看到返回的 JSON 数据:{"success": true}

这证明我们的路由和控制器已正确连接。


6. 实现游戏模型与创建游戏端点

现在,我们开始实现游戏的核心逻辑。

步骤 1: 创建游戏模型

models/ 目录下创建 game.model.js 文件:

// 导入 shortid 模块,用于生成唯一、易读的 ID
const shortid = require('shortid');

// 游戏服务器类,管理所有游戏实例
class GameServer {
  constructor() {
    // 用一个对象来存储所有游戏,键是游戏ID,值是游戏数据
    this.games = {};
  }

  // 创建新游戏的方法
  create(start, end) {
    // 1. 在 start 和 end 范围内生成随机密码
    const number = Math.floor(Math.random() * (end - start + 1)) + start;

    // 2. 生成唯一游戏 ID
    const id = shortid.generate();

    // 3. 创建游戏对象
    const game = {
      number,     // 密码(不发送给客户端)
      start,      // 范围起始
      end,        // 范围结束
      gameOver: false, // 游戏是否结束
      id          // 游戏 ID
    };

    // 4. 将游戏存入集合
    this.games[id] = game;

    // 5. 返回游戏 ID 给调用者
    return id;
  }
}

// 导出 GameServer 的一个实例(单例模式)
module.exports = new GameServer();

步骤 2: 在控制器中添加创建游戏的方法

修改 controllers/game.controller.js 文件:

// 导入游戏模型
const gameModel = require('../models/game.model');

class GameController {
  test(req, res) { /* ... 保持不变 ... */ }

  // 创建新游戏的方法
  newGame(req, res) {
    // 从查询字符串中获取 start 和 end 参数
    const { start, end } = req.query;

    // 调用模型创建游戏,并获取游戏 ID
    const gameId = gameModel.create(parseInt(start), parseInt(end));

    // 返回成功响应和游戏 ID
    res.json({
      success: true,
      gameId: gameId
    });
  }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_50.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_52.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_54.png)

module.exports = new GameController();

步骤 3: 在路由中注册新端点

修改 routes/game.routes.js 文件:

const express = require('express');
const router = express.Router();
// 从控制器导入 newGame 方法
const { test, newGame } = require('../controllers/game.controller');

router.get('/test', test);
// 注册创建游戏的路由
router.get('/new', newGame);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_61.png)

module.exports = router;

步骤 4: 测试创建游戏

  1. 重启服务器。
  2. 在浏览器中访问:http://localhost:3000/api/game/new?start=0&end=100
  3. 你应该会收到一个包含 gameId 的 JSON 响应,例如:{"success":true,"gameId":"abc123"}
  4. 查看服务器终端,应该会打印出刚创建的游戏数据。


7. 实现获取游戏信息端点

现在,我们需要实现一个端点,让客户端能根据游戏 ID 获取游戏状态(不包含密码)。

步骤 1: 在模型中添加获取游戏的方法

修改 models/game.model.js 文件,在 GameServer 类中添加:

get(id) {
  // 检查游戏 ID 是否存在
  if (this.games[id]) {
    const game = this.games[id];
    // 使用解构赋值,将密码(number)分离出来,不返回给客户端
    const { number, ...publicData } = game;
    return publicData; // 返回不包含密码的游戏数据
  } else {
    return null; // 游戏不存在
  }
}

步骤 2: 在控制器中添加获取游戏的方法

修改 controllers/game.controller.js 文件,在 GameController 类中添加:

getGame(req, res) {
  // 从路由参数中获取游戏 ID
  const { id } = req.params;

  // 调用模型获取游戏数据
  const gameData = gameModel.get(id);

  if (gameData) {
    // 游戏存在,返回数据并标记成功
    gameData.success = true;
    res.json(gameData);
  } else {
    // 游戏不存在,返回失败
    res.json({ success: false });
  }
}

步骤 3: 在路由中注册新端点(使用路由参数)

修改 routes/game.routes.js 文件:

const { test, newGame, getGame } = require('../controllers/game.controller');

router.get('/test', test);
router.get('/new', newGame);
// 定义带参数的路由。:id 是一个占位符,可以匹配任何值
// 注意:具体的路由(如 /test, /new)要放在参数路由前面
router.get('/:id', getGame);

步骤 4: 测试获取游戏

  1. 重启服务器。
  2. 首先创建一个新游戏,获取其 gameId(例如 abc123)。
  3. 在浏览器中访问:http://localhost:3000/api/game/abc123
  4. 你应该会收到类似 {"start":"0","end":"100","gameOver":false,"id":"abc123","success":true} 的响应。注意,其中没有 number 字段。
  5. 尝试访问一个不存在的 ID,应该会收到 {"success":false}

8. 实现提交猜测端点

这是游戏的核心交互端点。

步骤 1: 在模型中添加猜测逻辑

修改 models/game.model.js 文件,在 GameServer 类中添加:

guess(id, guessNumber) {
  const game = this.games[id];
  if (!game) {
    return null; // 游戏不存在
  }

  if (game.gameOver) {
    return { guess: 'game over' }; // 游戏已结束
  }

  // 将猜测值和密码转换为数字进行比较
  const guessNum = parseInt(guessNumber);
  const secretNum = parseInt(game.number);

  if (guessNum === secretNum) {
    game.gameOver = true; // 猜对了,游戏结束
    return { guess: 'correct' };
  } else if (guessNum < secretNum) {
    return { guess: 'too low' };
  } else if (guessNum > secretNum) {
    return { guess: 'too high' };
  } else {
    return { guess: 'error' }; // 例如,guessNumber 不是数字
  }
}

步骤 2: 在控制器中添加处理猜测的方法

修改 controllers/game.controller.js 文件,在 GameController 类中添加:

makeGuess(req, res) {
  // id 来自路由参数
  const { id } = req.params;
  // guess 来自查询字符串
  const { guess } = req.query;

  // 调用模型进行猜测
  const result = gameModel.guess(id, guess);

  if (result) {
    // 模型返回了结果(无论对错)
    result.success = true;
    res.json(result);
  } else {
    // 模型返回 null,说明游戏 ID 无效
    res.json({ success: false });
  }
}

步骤 3: 在路由中注册猜测端点

修改 routes/game.routes.js 文件:

const { test, newGame, getGame, makeGuess } = require('../controllers/game.controller');

// ... 其他路由 ...

// 定义嵌套路由:/api/game/:id/guess
router.get('/:id/guess', makeGuess);

步骤 4: 测试猜测功能

  1. 重启服务器并创建一个新游戏,获取 gameId
  2. 在浏览器中进行猜测,例如:http://localhost:3000/api/game/abc123/guess?guess=50
  3. 根据随机生成的密码,你会收到 {"guess":"too low","success":true}{"guess":"too high","success":true} 等响应。
  4. 不断尝试,直到猜中,你会收到 {"guess":"correct","success":true}。此时游戏状态变为结束。
  5. 游戏结束后再次猜测,会收到 {"guess":"game over","success":true}
  6. 测试无效的 ID 或非数字猜测。

9. 实现重置游戏端点

最后,我们实现一个端点,允许在游戏结束后重置游戏,以便重新开始。

步骤 1: 在模型中添加重置方法

修改 models/game.model.js 文件,在 GameServer 类中添加:

reset(id) {
  const game = this.games[id];
  // 只有游戏存在且已结束时才能重置
  if (game && game.gameOver) {
    // 生成新的随机密码
    game.number = Math.floor(Math.random() * (game.end - game.start + 1)) + game.start;
    game.gameOver = false; // 重置游戏状态

    // 返回公开的游戏数据(不包含新密码)
    const { number, ...publicData } = game;
    return publicData;
  }
  return null; // 无法重置
}

步骤 2: 在控制器中添加重置方法

修改 controllers/game.controller.js 文件,在 GameController 类中添加:

resetGame(req, res) {
  const { id } = req.params;
  const result = gameModel.reset(id);

  if (result) {
    result.success = true;
    res.json(result);
  } else {
    res.json({ success: false });
  }
}

步骤 3: 在路由中注册重置端点

修改 routes/game.routes.js 文件:

const { test, newGame, getGame, makeGuess, resetGame } = require('../controllers/game.controller');

// ... 其他路由 ...

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_89.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/b751a18fb4fe1305e878c78006301e72_90.png)

router.get('/:id/reset', resetGame);

步骤 4: 测试重置功能

  1. 重启服务器。
  2. 创建一个游戏并猜中它(使其状态为 game over)。
  3. 访问重置端点:http://localhost:3000/api/game/abc123/reset
  4. 你应该收到成功响应,并且 gameOver 字段变回 false
  5. 再次获取游戏信息或进行猜测,验证游戏已重置。
  6. 尝试重置一个未结束的游戏或无效 ID,应返回 {"success":false}


10. 处理跨域资源共享 (CORS)

为了在下一节课中能够从另一个本地前端应用访问这个 API,我们需要在服务器端设置 CORS 头,允许跨域请求。

修改 app.js 文件,在设置路由之前添加一个自定义中间件:

// ... 导入模块和基础设置 ...

// 自定义中间件:设置 CORS 头部,允许来自任何源的请求(仅用于开发)
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*'); // 允许任何源
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  next(); // 继续处理请求
});

// ... 导入路由和启动服务器 ...

注意:在生产环境中,应将 * 替换为具体的前端域名以提高安全性。


总结

在本节课中,我们一起学习了如何设计和实现一个完整的 REST API:

  1. 理解了核心概念:REST API、端点、路由参数、查询字符串和 UID。
  2. 设计了 API 规范:通过表格规划了各个端点的功能、方法和参数。
  3. 搭建了项目结构:使用 package.json 管理依赖,并组织了 modelscontrollersroutes 目录。
  4. 实现了游戏模型:使用 GameServer 类管理所有游戏实例的状态和核心逻辑(创建、获取、猜测、重置)。
  5. 创建了控制器:编写了处理不同 HTTP 请求的方法,作为模型和路由之间的桥梁。
  6. 配置了路由:使用 Express Router 将 URL 映射到对应的控制器方法,并学会了使用路由参数(:id)。
  7. 处理了数据交互:通过 req.params 获取路由参数,通过 req.query 获取查询字符串。
  8. 设置了 CORS:为后续的前后端分离开发做好了准备。

你现在已经拥有了一个功能完整的多人“猜数字”游戏后端 API。在下一节课中,我们将构建一个前端客户端来使用这个 API,实现一个真正的可交互游戏。

扩展思考

  • 如何为游戏添加玩家用户名和积分榜?
  • 如何将游戏数据持久化到数据库中,防止服务器重启后数据丢失?
  • 如何将这个 API 部署到云服务器(如 Heroku)上,让其他人也能访问?

017:REST API 客户端修正教程 🔧

在本节课中,我们将学习如何修正一个在REST API客户端实现中常见的状态同步问题。具体来说,我们将分析为何在“重置游戏”功能后,客户端界面未能正确更新,并提供一个清晰的解决方案。


上一节我们介绍了REST API的基本实现,本节中我们来看看一个具体的客户端状态同步问题及其修正方法。

问题分析 🧐

在之前的实现中,resetGame 函数负责向服务器发送请求以重置游戏状态。其核心逻辑是发送一个请求到后端服务器,等待响应,然后直接调用 viewGame 函数来重新渲染游戏界面。

然而,这里存在一个关键缺陷:客户端在收到服务器的重置响应后,没有更新其本地的游戏状态变量。这导致客户端的内存状态与服务器的实际状态不同步。

具体来说,当服务器成功重置游戏并将 gameOver 状态设为 false 后,客户端的本地 gameOver 变量仍然保持为 true。因此,当 viewGame 函数被调用时,它会检查本地的 gameOver 变量,发现其为 true,于是立即跳转到游戏结束的界面,而不是显示可进行猜数的游戏主界面。

修正方案 💡

为了解决这个问题,我们需要修改 resetGame 函数。修正的核心思想是:在从服务器获取到重置后的游戏数据时,立即用这些新数据更新客户端的本地状态变量,确保两者保持一致。

以下是修正后的 resetGame 函数实现步骤:

  1. 发送重置请求:使用 fetch API 向服务器的重置端点发送请求。
  2. 处理响应:等待服务器响应并将其解析为JSON格式的数据。
  3. 更新客户端状态:从响应数据中提取关键属性(如 gameOver),并用它们覆盖客户端的本地变量。
  4. 渲染视图:在状态更新完成后,再调用 viewGame 函数来渲染正确的游戏界面。

代码实现

以下是修正后的 resetGame 函数代码示例:

async function resetGame() {
    // 构建重置游戏的API端点URL
    const url = `/guess/${gameId}/reset`;
    
    // 发送请求并等待响应
    const response = await fetch(url);
    // 将响应解析为JSON数据
    const data = await response.json();
    
    // 检查请求是否成功,并更新客户端状态
    if (data.success) {
        // 关键步骤:用服务器返回的数据更新本地变量
        gameOver = data.gameOver; // 将本地的 gameOver 更新为服务器返回的 false
        // 注意:根据API设计,min 和 max 可能不变,但 gameOver 一定变为 false
        
        // 状态同步后,再渲染游戏视图
        viewGame();
    } else {
        // 处理错误情况,例如显示错误信息
        console.error("重置游戏失败:", data.message);
    }
}

测试验证 ✅

为了验证修正是否有效,我们可以按照以下场景进行测试:

  1. 用户A创建一个新游戏并获胜,导致游戏状态为 gameOver: true
  2. 用户B加入同一个已结束的游戏。
  3. 用户B点击“重玩”(Replay)按钮。

修正前:用户B的界面会短暂显示游戏主界面,但立即又跳转回“你输了”的结束界面,因为本地 gameOver 变量仍为 true
修正后:用户B点击“重玩”后,客户端会更新本地 gameOverfalse,然后稳定地显示游戏主界面,允许用户B开始猜数。

核心概念总结 📚

本节课中我们一起学习了分布式Web应用开发中的一个重要概念:状态同步

  • 问题根源:在客户端-服务器架构中,客户端维护的本地状态必须与服务器上的权威状态保持一致。忽略同步会导致用户看到不一致或错误的应用行为。
  • 解决方案:在客户端发起任何会改变服务器状态的操作(如 POST, PUT, PATCH)后,必须根据服务器的响应结果来更新本地的状态变量。
  • 通用模式:这个模式不仅适用于“重置游戏”,也适用于任何“获取-更新-渲染”的交互流程。其伪代码逻辑可以概括为:
    1. 发起请求 -> 改变服务器状态
    2. 接收响应 -> 获取最新服务器状态
    3. 更新本地变量 -> 与服务器状态同步
    4. 更新UI -> 反映新的状态
    

通过本次修正,我们确保了客户端对游戏状态的认知始终与服务器同步,从而提供了正确且一致的用户体验。这是在构建交互式Web应用时需要时刻牢记的基本原则。

018:Bug修复 🐛

在本节课中,我们将学习如何构建一个前端REST客户端,用于连接我们之前开发的后端API,从而创建一个完整的“猜数字”游戏应用。我们将重点复习异步JavaScript、DOM操作以及如何设计一个遵循MVC架构的前端应用。

概述

上一节我们介绍了如何构建一个RESTful API服务器。本节中,我们将基于该API,构建一个与之交互的前端客户端。通过本教程,你将拥有一个完整的前后端分离的“猜数字”游戏应用。

客户端架构设计

在开始编码之前,我们需要规划前端应用的结构。我们将采用MVC(模型-视图-控制器)架构来分离职责,确保代码清晰且易于维护。

以下是项目文件夹结构:

highlow-client/
├── index.html
└── scripts/
    ├── data.js
    ├── views.js
    ├── game.js
    └── controllers.js

创建主菜单视图 🏠

首先,我们需要创建应用的入口页面,即主菜单视图。这个视图将提供“新游戏”和“加入游戏”两个选项。

以下是实现主菜单的步骤:

  1. index.html 中定义视图容器并引入JavaScript文件。
  2. views.js 中创建 mainMenu 函数,用于渲染主菜单的HTML内容。
  3. controllers.js 中设置按钮的事件监听器。

代码示例 (views.js):

function mainMenu() {
    const view = document.getElementById('view');
    const html = `
        <section>
            <button id="new-game-button">新游戏</button>
            <button id="join-game-button">加入游戏</button>
        </section>
    `;
    view.innerHTML = html;
}

实现新游戏视图 🎮

当用户点击“新游戏”按钮时,应用应切换到新游戏视图,允许用户设置数字范围并开始游戏。

以下是创建新游戏视图的步骤:

  1. views.js 中创建 newGameMenu 函数来渲染输入界面。
  2. controllers.js 中定义回调函数,并绑定到“开始游戏”按钮。
  3. game.js 中实现 startGame 函数,该函数将获取用户输入的范围,并通过 fetch 请求后端API来创建新游戏。

核心概念:异步请求
我们使用 async/awaitfetch 来发送HTTP请求到后端服务器,并等待响应。

公式/代码描述:

async function startGame() {
    const min = getMinValue(); // 从DOM获取最小值
    const max = getMaxValue(); // 从DOM获取最大值
    const url = `http://localhost:3000/api/game/new?start=${min}&end=${max}`;
    const response = await fetch(url);
    const data = await response.json();
    gameId = data.gameId; // 保存游戏ID
    viewGame(); // 切换到游戏视图
}

构建加入游戏视图 🔗

用户也可以选择加入一个已存在的游戏。我们需要创建一个视图,让用户输入游戏ID。

实现加入游戏视图的步骤如下:

  1. views.js 中创建 joinGameMenu 函数。
  2. game.js 中实现 findGame 函数,该函数根据用户输入的游戏ID,向后端请求该游戏的数据。
  3. 根据后端返回的成功状态,决定是跳转到游戏视图还是返回主菜单。

创建游戏核心视图 🎲

游戏视图是用户进行猜测的核心界面。它需要显示游戏信息、提供输入框并展示历史线索。

以下是游戏视图的实现要点:

  1. views.jsviewGame 函数中,渲染游戏ID、数字范围、输入框和提交按钮。
  2. 实现提交猜测的逻辑。在 game.jssubmitGuess 函数中,将用户的猜测发送到后端API。
  3. 根据API返回的线索(如“太高”、“太低”、“正确”或“游戏结束”),更新前端界面。

代码示例 (game.js 提交猜测):

async function submitGuess() {
    const guess = getGuessValue(); // 从DOM获取猜测值
    const url = `http://localhost:3000/api/game/${gameId}/guess?guess=${guess}`;
    const response = await fetch(url);
    const data = await response.json();

    if (data.success) {
        if (data.guess === 'correct') {
            gameOverMenu('你赢了!');
        } else if (data.guess === 'game over') {
            gameOverMenu('你输了!');
        } else {
            // 显示线索(太高或太低)
            viewClue(data.guess, guess);
        }
    }
}

实现游戏结束视图 🏁

当游戏分出胜负后,我们需要向用户展示结果,并提供“重玩”或“返回主菜单”的选项。

创建游戏结束视图的步骤:

  1. views.js 中创建 gameOverMenu 函数,接收“赢”或“输”的结果参数进行显示。
  2. 实现“重玩”功能。在 game.js 中创建 resetGame 函数,调用后端API重置当前游戏。
  3. 实现“退出”功能,简单地调用 mainMenu 函数返回主界面。

总结

本节课中,我们一起学习了如何为一个“猜数字”游戏构建完整的前端REST客户端。我们回顾并应用了以下核心概念:

  • 使用 async/awaitfetch 进行异步HTTP通信。
  • 操作DOM来动态更新单页面应用(SPA)的视图。
  • 采用MVC架构来组织前端代码,分离数据、视图和逻辑。
  • 解析和使用JSON格式的数据。
  • 设计用户流程,并实现各个视图之间的切换。

通过将此前独立学习的后端API与前端技术相结合,你现在已经能够创建功能完整的全栈Web应用了。可以尝试为此应用添加样式、用户系统或使用WebSocket实现实时功能,作为进一步的练习。

019:用户登录(第一部分) 👨‍💻

在本节课中,我们将学习如何为Web应用实现用户认证系统。我们将从上一节引入的会话(Session)概念出发,逐步构建一个完整的登录、注册和登出流程,并确保用户密码的安全存储。

上一节我们介绍了如何使用会话ID来识别客户端,本节中我们来看看如何利用会话来实现用户认证。

概述

我们将使用 Passport.js 这个Node.js中间件来处理用户认证。具体步骤包括:

  1. 安装并配置 passportpassport-local 模块。
  2. 在用户模型中添加查找用户的功能。
  3. 创建Passport配置模块,定义本地认证策略。
  4. 在应用和控制器中集成Passport。
  5. 实现序列化和反序列化用户,以便在请求中访问用户数据。
  6. 使用 express-flash 显示认证错误信息。
  7. 创建中间件来保护路由,防止未授权访问。
  8. 实现安全的密码哈希存储(使用 bcrypt)。
  9. 使用 .env 文件保护敏感配置信息。

配置Passport和用户模型

首先,我们需要安装必要的依赖包。

// package.json 中添加
"dependencies": {
  "passport": "^0.5.0",
  "passport-local": "^1.0.0"
}

接下来,我们在用户模型 users.model.js 中添加一个根据键值对查找用户的方法。

// users.model.js
findUser(key, value) {
  return this.users.find(user => user[key] === value);
}

创建Passport配置模块

我们创建一个中间件模块 passport-config.js 来配置Passport的本地策略。

// middlewares/passport-config.js
const LocalStrategy = require('passport-local').Strategy;
const passport = require('passport');
const users = require('../models/users.model');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_30.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_32.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_34.png)

const formNames = {
  usernameField: 'email',
  passwordField: 'password'
};

const authenticateUser = async (email, password, done) => {
  const user = users.findUser('email', email);
  if (!user) {
    console.log('No user with that email');
    return done(null, false, { message: 'No user with that email' });
  }
  try {
    if (await bcrypt.compare(password, user.password)) {
      console.log('User authenticated');
      return done(null, user);
    } else {
      console.log('Password incorrect');
      return done(null, false, { message: 'Password incorrect' });
    }
  } catch (e) {
    return done(e);
  }
};

const strategy = new LocalStrategy(formNames, authenticateUser);
passport.use(strategy);

passport.serializeUser((user, done) => {
  done(null, user.id);
});

passport.deserializeUser((id, done) => {
  const user = users.findUser('id', id);
  done(null, user);
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_36.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_38.png)

module.exports = passport;

在应用中集成Passport

app.js 中,我们导入配置好的Passport并初始化它,同时启用会话支持。

// app.js
const passport = require('./middlewares/passport-config');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_46.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_47.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_49.png)

// 在 express-session 之后,定义路由之前
app.use(passport.initialize());
app.use(passport.session());

在控制器 users.controller.js 中,我们重构登录路由的处理逻辑,使用Passport进行认证。

// controllers/users.controller.js
const passport = require('../middlewares/passport-config');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_61.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_63.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_65.png)

exports.postLogin = (req, res, next) => {
  const config = {
    successRedirect: '/',
    failureRedirect: '/login',
    failureFlash: true
  };
  const authFunction = passport.authenticate('local', config);
  authFunction(req, res, next);
};

序列化用户与访问用户数据

Passport通过序列化和反序列化将用户信息存入会话。我们在配置模块中已经定义了相关方法。现在,我们可以在受保护的页面(如首页)中访问用户数据。

// controllers/users.controller.js
exports.getIndex = (req, res) => {
  res.render('index', { name: req.user.name });
};

使用Flash消息和路由保护

我们安装 express-flash 来在认证失败时向用户显示错误信息。

// package.json
"express-flash": "^0.0.2"

app.js 中启用它,并在登录EJS模板中显示错误。

<!-- views/login.ejs -->
<% if (messages.error) { %>
  <p><%= messages.error %></p>
<% } %>

为了保护路由,我们创建中间件 auth.js

// middlewares/auth.js
exports.checkAuthenticated = (req, res, next) => {
  if (req.isAuthenticated()) {
    return next();
  }
  res.redirect('/login');
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_111.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_113.png)

exports.checkNotAuthenticated = (req, res, next) => {
  if (req.isAuthenticated()) {
    return res.redirect('/');
  }
  next();
};

然后在路由文件中应用这些中间件。

// routes/users.routes.js
const { checkAuthenticated, checkNotAuthenticated } = require('../middlewares/auth');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_123.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_125.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_127.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_129.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/517e2071587e8157cfa6c9556183a85f_130.png)

router.get('/', checkAuthenticated, usersController.getIndex);
router.get('/login', checkNotAuthenticated, usersController.getLogin);
router.get('/register', checkNotAuthenticated, usersController.getRegister);

实现登出和密码安全

登出功能非常简单,Passport在请求对象上提供了 logout() 方法。

// controllers/users.controller.js
exports.postLogout = (req, res) => {
  req.logout();
  res.redirect('/login');
};

为了安全,我们使用 bcrypt 对密码进行哈希处理,而不是存储明文。

// package.json
"bcrypt": "^5.0.0"

在用户模型和Passport配置中更新相关代码。

// users.model.js
const bcrypt = require('bcrypt');
async add(name, email, password) {
  const hashedPassword = await bcrypt.hash(password, 10);
  const id = shortid.generate();
  this.users.push({ id, name, email, password: hashedPassword });
  return id;
}

使用环境变量保护密钥

最后,我们使用 dotenv 模块将敏感信息(如会话密钥)移出代码,存入 .env 文件。

// package.json
"dotenv": "^10.0.0"

// app.js
if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config();
}
// ...
app.use(session({
  secret: process.env.SESSION_SECRET,
  // ...
}));

创建 .env 文件并添加到 .gitignore 中。

# .env
SESSION_SECRET=your_secret_word_here

# .gitignore
node_modules
.env

总结

本节课中我们一起学习了如何构建一个完整的用户认证系统。我们从配置Passport开始,实现了用户登录、注册和登出功能,并利用会话保持了用户状态。我们通过中间件保护了路由,确保只有授权用户才能访问特定页面。为了提高安全性,我们使用 bcrypt 对密码进行哈希处理,并使用 .env 文件保护了应用的敏感配置信息。你现在已经拥有了一个具备基本生产级安全考虑的认证系统骨架。

020:构建实时聊天应用

在本节课中,我们将学习 WebSocket 协议以及如何使用 Socket.IO 库在 Node.js 和浏览器之间建立实时、双向的通信。我们将通过构建一个基础的实时聊天应用来实践这些概念。

概述

WebSocket 是一种不同于 HTTP 的通信协议,它允许在客户端和服务器之间建立持久、全双工的连接。这使得服务器可以主动向客户端推送数据,非常适合构建聊天应用、实时游戏或数据仪表盘等场景。Socket.IO 是一个 JavaScript 库,它封装了 WebSocket 协议,并提供了更简单、更强大的 API 来处理实时通信。

1. 核心概念介绍

上一节我们概述了课程目标,本节中我们来看看构建实时应用所需理解的核心概念。

1.1 WebSocket 协议

WebSocket 协议是 HTTP 协议的替代方案,用于客户端与服务器之间的通信。它与 HTTP 的关键区别在于:

  • 连接性质:HTTP 是无状态、非持久的连接。每次通信都需要客户端发起一个新的请求,服务器响应后连接即断开。这就像发送信件
  • 通信方向:WebSocket 是全双工、双向的持久连接。一旦建立,客户端和服务器都可以随时主动发送或接收数据。这就像接打电话

在 WebSocket 出现之前,为了实现“实时”效果,客户端需要不断向服务器发送请求以检查是否有新数据,这种方式称为“轮询”。轮询效率低下且浪费资源。WebSocket 解决了这个问题,允许服务器在数据变化时立即广播给所有已连接的客户端。

1.2 Socket.IO 库

Socket.IO 是一个实现了 WebSocket 协议的 JavaScript 库。它简化了在浏览器和 Node.js 服务器中使用 WebSocket 的复杂性,并自动处理了连接稳定性、断线重连等底层细节。使用 Socket.IO 需要在服务器端和客户端都进行设置。

1.3 事件驱动通信

Socket.IO 的核心是事件驱动的通信模型。通信双方(客户端或服务器)可以发射(emit) 命名事件,并附带数据。另一方则可以监听(on) 这些事件,并绑定回调函数来处理它们。

事件发射的基本语法

// 发射一个事件,附带数据
socket.emit(‘事件名称‘, 数据负载);

事件监听的基本语法

// 监听一个事件,并定义处理函数
socket.on(‘事件名称‘, (数据负载) => {
    // 处理接收到的事件和数据
});

数据负载通常以 JSON 格式传输,因为它易于序列化和反序列化,并且在 JavaScript 环境中可以直接作为对象使用。

1.4 应用设计模式

在设计分布式实时应用时,可以考虑以下模式:

  • 服务器角色:通常作为“单一数据源”,维护应用的真实状态。它负责接收来自某个客户端的更新,然后将状态变更广播给所有其他连接的客户端。
  • 客户端角色:提供用户界面和交互。当用户操作改变状态时,客户端向服务器发射事件。客户端不应直接更新本地状态并显示,而应等待服务器广播的更新,以确保所有用户视图同步。

2. 项目初始化与结构

理解了核心概念后,我们开始动手构建项目。首先需要设置项目的基本结构。

以下是创建项目目录和文件的步骤:

  1. 创建一个项目根目录,例如 chat-app
  2. 在根目录下创建 package.json 文件,用于管理项目依赖和脚本。
  3. 创建 app.js 文件,作为服务器的主逻辑文件。
  4. 创建一个 public 目录,用于存放所有提供给客户端的静态文件(HTML, CSS, JS)。
  5. public 目录下创建 index.html 文件。
  6. public 目录下创建 scripts 目录,用于存放客户端 JavaScript 文件。
  7. public/scripts 目录下创建 chat.js 文件。

最终目录结构应如下所示:

chat-app/
├── app.js
├── package.json
├── node_modules/
└── public/
    ├── index.html
    └── scripts/
        └── chat.js

3. 配置项目与 Express 服务器

现在我们已经有了项目骨架,本节将配置项目依赖并搭建一个基础的 Express 静态服务器。

3.1 配置 package.json

首先,编辑 package.json 文件,定义项目元信息和依赖。

{
  “name“: “socket-chat-app“,
  “version“: “0.0.1“,
  “description“: “A real-time chat app with Socket.IO“,
  “main“: “app.js“,
  “scripts“: {
    “start“: “node app.js“
  },
  “dependencies“: {
    “express“: “^4.18.2“
  }
}

然后,在终端中进入项目根目录,运行以下命令安装依赖:

npm install

此命令会根据 package.json 安装 Express,并生成 node_modules 文件夹及 package-lock.json 文件。

3.2 创建基础 Express 服务器

接下来,在 app.js 中设置一个基本的 Express 服务器,用于托管静态文件。注意,为了后续集成 WebSocket,我们创建服务器的方式略有不同。

// 导入所需模块
const express = require(‘express‘);
const http = require(‘http‘); // 需要 http 模块来创建服务器

// 初始化 Express 应用
const app = express();
// 使用 http 模块基于 Express 应用创建服务器
const server = http.createServer(app);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_18.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_19.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_20.png)

// 设置静态文件中间件,public 目录下的文件可通过 HTTP 直接访问
app.use(express.static(‘public‘));

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_22.png)

// 服务器开始监听 3000 端口
server.listen(3000, () => {
    console.log(‘Server listening on port 3000‘);
});

3.3 创建基础 HTML 页面

为了让服务器有内容可提供,我们在 public/index.html 中创建一个简单的聊天界面。

<!DOCTYPE html>
<html lang=“en“>
<head>
    <meta charset=“UTF-8“>
    <title>Socket.IO Chat</title>
</head>
<body>
    <ul id=“chat“></ul> <!-- 用于显示聊天消息的列表 -->
    <form id=“form“ action=““>
        <input id=“input“ autocomplete=“off“ /> <!-- 消息输入框 -->
        <button type=“submit“>Send</button> <!-- 发送按钮 -->
    </form>
</body>
</html>

现在,运行 npm start 并访问 http://localhost:3000,你应该能看到一个简单的输入框和按钮。这证明我们的静态服务器工作正常。

4. 集成 Socket.IO

基础服务器就绪后,本节我们将把 Socket.IO 集成到项目中,建立客户端与服务器之间的 WebSocket 连接。

4.1 安装 Socket.IO

在项目根目录下运行以下命令来安装 Socket.IO 库:

npm install socket.io

这个命令会自动将 socket.io 依赖添加到 package.json 文件中。

4.2 设置服务器端 Socket.IO

修改 app.js,引入 Socket.IO 并将其绑定到我们的 HTTP 服务器上。

const express = require(‘express‘);
const http = require(‘http‘);
const socketIo = require(‘socket.io‘); // 导入 Socket.IO

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_43.png)

const app = express();
const server = http.createServer(app);
const io = socketIo(server); // 将 Socket.IO 绑定到 HTTP 服务器

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_45.png)

app.use(express.static(‘public‘));

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_47.png)

// 监听客户端连接事件
io.on(‘connection‘, (socket) => {
    console.log(‘A user connected‘);

    // 监听客户端断开连接事件
    socket.on(‘disconnect‘, () => {
        console.log(‘User disconnected‘);
    });
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_49.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_50.png)

server.listen(3000, () => {
    console.log(‘Server listening on port 3000‘);
});
  • io.on(‘connection‘, ...):当有客户端通过 WebSocket 连接到服务器时,触发此事件。回调函数中的 socket 对象代表与这个特定客户端的连接。
  • socket.on(‘disconnect‘, ...):监听这个特定客户端的断开连接事件。

4.3 设置客户端 Socket.IO

服务器端设置好后,客户端也需要加载 Socket.IO 的客户端库并建立连接。修改 public/index.html,在 </body> 标签前添加以下脚本。

    <!-- 由 Socket.IO 服务器自动提供的客户端库 -->
    <script src=“/socket.io/socket.io.js“></script>
    <!-- 我们自己的客户端逻辑 -->
    <script src=“/scripts/chat.js“></script>
</body>

注意:/socket.io/socket.io.js 这个路径是由 Socket.IO 服务器自动生成的,无需我们手动放置文件。

现在,在 public/scripts/chat.js 中,我们初始化客户端 Socket 连接。

// 建立与服务器的 Socket 连接
// 它会自动连接到提供当前页面的主机(localhost:3000)
const socket = io();

4.4 测试连接

  1. 重启服务器:npm start
  2. 打开浏览器访问 http://localhost:3000
  3. 查看运行服务器的终端,你应该能看到 “A user connected” 的日志。
  4. 刷新浏览器页面,终端会先显示 “User disconnected”,然后显示新的 “A user connected”。这表明连接和断开事件都被成功捕获。

5. 实现聊天功能:发送与广播消息

我们已经建立了双向连接,本节将实现聊天的核心功能:客户端发送消息,服务器接收并广播给所有客户端。

5.1 客户端:发送消息事件

首先,在客户端处理表单提交,将输入框中的消息发送给服务器。更新 public/scripts/chat.js

const socket = io();
// 获取 DOM 元素
const form = document.getElementById(‘form‘);
const input = document.getElementById(‘input‘);
const chat = document.getElementById(‘chat‘); // 消息列表容器

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/9cfac7f54709a60a64f397cb3bb38731_73.png)

// 监听表单提交事件
form.addEventListener(‘submit‘, (event) => {
    event.preventDefault(); // 阻止表单默认的 HTTP 提交行为

    if (input.value) {
        // 向服务器发射一个自定义事件 ‘chat-message‘,并携带消息内容
        socket.emit(‘chat-message‘, input.value);
        input.value = ‘‘; // 清空输入框
    }
});

5.2 服务器端:接收并广播消息

然后,在服务器端监听客户端发来的 chat-message 事件,并将消息广播给所有已连接的客户端。更新 app.jsio.on(‘connection‘) 内的代码:

io.on(‘connection‘, (socket) => {
    console.log(‘A user connected‘);

    // 监听客户端发来的 ‘chat-message‘ 事件
    socket.on(‘chat-message‘, (msg) => {
        console.log(‘Message:‘, msg); // 在服务器控制台打印消息
        // 将消息广播给所有客户端(包括发送者)
        io.emit(‘chat-message‘, msg);
    });

    socket.on(‘disconnect‘, () => {
        console.log(‘User disconnected‘);
    });
});
  • socket.on(‘chat-message‘, ...):监听来自当前客户端的 chat-message 事件。
  • io.emit(‘chat-message‘, msg):使用 io(服务器对象)向所有已连接的客户端发射 chat-message 事件。如果想排除发送者,可以使用 socket.broadcast.emit(...)

5.3 客户端:接收并显示广播的消息

最后,客户端需要监听服务器广播的 chat-message 事件,并将消息显示在页面上。在 public/scripts/chat.js 的末尾添加:

// 监听服务器广播的 ‘chat-message‘ 事件
socket.on(‘chat-message‘, (msg) => {
    // 创建一个新的列表项元素来显示消息
    const item = document.createElement(‘li‘);
    item.textContent = msg;
    // 将消息添加到聊天列表中
    chat.appendChild(item);
    // 滚动到窗口底部,以便看到最新消息
    window.scrollTo(0, document.body.scrollHeight);
});

5.4 测试聊天功能

  1. 重启服务器。
  2. 打开两个或多个浏览器标签页,均访问 http://localhost:3000
  3. 在其中一个标签页的输入框中输入消息并点击发送。
  4. 观察所有打开的标签页,消息应该会实时出现在每个页面的聊天列表中。同时,服务器终端也会打印出发送的消息。

总结

在本节课中,我们一起学习了 WebSocket 和 Socket.IO 的基础知识,并成功构建了一个简单的实时聊天应用。我们涵盖了以下关键步骤:

  1. 理解核心概念:了解了 WebSocket 的全双工、持久连接特性,以及它与 HTTP 轮询的区别。
  2. 项目初始化:设置了项目结构、package.json 和基础的 Express 静态服务器。
  3. 集成 Socket.IO:在服务器端和客户端安装了 Socket.IO 并建立了 WebSocket 连接。
  4. 实现事件通信
    • 客户端使用 socket.emit() 向服务器发送自定义事件(chat-message)。
    • 服务器使用 socket.on() 监听来自客户端的事件。
    • 服务器使用 io.emit() 将接收到的消息广播给所有客户端。
    • 客户端使用 socket.on() 监听服务器广播的事件,并更新用户界面。

你现在已经掌握了使用 Socket.IO 进行实时双向通信的基本模式。你可以在此基础上扩展功能,例如添加用户昵称、私聊、房间系统、消息持久化存储或更复杂的实时交互应用(如协作白板、简单多人在线游戏等)。

021:实时聊天应用

在本节课中,我们将学习如何使用MongoDB作为数据库,并完成MongoDB官方提供的免费认证课程。我们将了解MongoDB的核心概念、操作以及如何通过Atlas云服务部署数据库。


概述

本节课我们将学习MongoDB数据库技术。我们将了解什么是集合、文档和字段,学习CRUD(创建、读取、更新、删除)操作,并使用MongoDB查询语言(MQL)进行数据查询。课程目标是帮助大家获得MongoDB官方认证,为简历增添竞争力。


MongoDB简介

MongoDB是一种文档型数据库,它以JSON格式存储数据。这与JavaScript对象在内存中的存储方式一致,使得数据序列化和存储变得非常方便。

核心概念

  • 数据库:数据的最高层级容器。
  • 集合:相当于关系型数据库中的“表”,是一组文档的集合。
  • 文档:相当于关系型数据库中的“行”,是数据的基本单元,以BSON(一种二进制JSON)格式存储。
  • 字段:文档中的键值对,相当于“列”。

示例代码(一个文档):

{
  “_id”: ObjectId(“507f1f77bcf86cd799439011”),
  “name”: “张三”,
  “age”: 25,
  “city”: “北京”
}

课程与认证路径

上一节我们介绍了MongoDB的基本概念,本节中我们来看看如何系统地学习并获取认证。

MongoDB University提供免费的认证课程。完成“MongoDB基础”课程并通过测试后,可以获得官方认证证书。该证书可以链接到LinkedIn等职业平台,验证你的技能。

课程涵盖六个章节,预计总耗时约8.5小时,包括视频讲座、实验和测验。注册后有60天时间完成。

以下是获取认证的步骤

  1. 从课程平台下载提供的幻灯片作为参考,其中包含与认证测试类似的练习题。
  2. 访问MongoDB University,注册并开始“MongoDB基础”课程。
  3. 按顺序完成所有视频讲座、动手实验和章节测验。
  4. 完成所有内容后,参加最终认证测试。
  5. 通过测试后,将获得的证书提交到课程指定平台。

工具与环境:Atlas vs 本地部署

在学习过程中,我们将主要使用MongoDB Atlas。它是一个云端的数据库即服务(DBaaS)平台,提供了免费的集群套餐,非常适合学习和开发。

Atlas的优势

  • 免费套餐:提供512MB至5GB的存储空间,无需信用卡。
  • 易于管理:无需在本地安装和配置数据库服务器。
  • 便于部署:与Heroku等Web应用托管平台结合,可以轻松部署全栈应用。

当然,你也可以选择在本地计算机上安装和运行MongoDB服务器。这对于本地开发和测试非常方便。课程也可能提供可选的实验,指导如何进行本地部署。

选择建议:对于希望项目能被公开访问的同学,建议使用Atlas;对于仅在本地进行练习和测试的同学,本地部署是更直接的选择。


学习资源与支持

为了确保大家能顺利通过认证,这里汇总了可用的学习资源和支持渠道。

以下是主要的学习资源

  • 官方课程视频与实验:MongoDB University平台上的核心内容。
  • 参考幻灯片:老师提供的幻灯片,内含重点总结和模拟练习题。
  • 社区论坛:MongoDB官方社区论坛,全球学习者和教育者在此讨论问题。
  • 课程Discord/课堂讨论:在课程进行中,可以随时向老师和同学提问。

如果在实验或测试中遇到问题,可以随时在课堂时间或通过Discord寻求帮助。认证测试中的问题通常允许多次尝试,因此可以利用这些机会弄懂每一个概念。


总结

本节课中我们一起学习了MongoDB的入门路径。我们了解了MongoDB作为文档数据库的核心优势,明确了通过MongoDB University获取官方认证的流程,并熟悉了Atlas云服务这一重要工具。此外,我们也掌握了包括官方课程、参考幻灯片和社区论坛在内的多种学习资源。

接下来,请大家按照步骤开始课程学习,利用课堂时间推进进度,并积极利用支持渠道解决遇到的问题。掌握MongoDB将为构建动态Web应用打下坚实的数据存储基础。

022:MongoDB CRUD + JWT 教程 🚀

在本教程中,我们将学习如何构建一个简单的全栈应用。我们将部署一个本地的MongoDB数据库服务器,并使用Express应用作为客户端连接到它。然后,我们将实现CRUD(创建、读取、更新、删除)操作,并集成JSON Web Token(JWT)进行身份验证。通过本教程,你将了解客户端、服务器和数据库之间如何通信,为你的最终项目打下基础。

概述 📋

我们将分三个阶段进行:

  1. 基础设置:启动MongoDB服务器和Express Web服务器。
  2. 连接与CRUD:使用Mongoose连接Express应用到MongoDB,并实现所有CRUD操作。
  3. JWT身份验证:实现基于JWT的登录和受保护路由。

第一部分:基础设置

1.1 启动MongoDB服务器 🗄️

首先,你需要在本地机器上安装并运行MongoDB服务器(mongod)。

步骤:

  1. 从MongoDB官网下载并安装MongoDB社区版。
  2. 在项目根目录下创建一个名为 data 的文件夹,用于存储数据库文件。
  3. 打开终端,导航到项目根目录,运行以下命令启动MongoDB服务器:
mongod --dbpath ./data --port 27017 --bind_ip 127.0.0.1

验证:
如果服务器成功启动,终端将持续输出日志信息。同时,data 文件夹中会生成MongoDB所需的文件。

1.2 配置Express服务器 🌐

接下来,我们将设置一个基本的Express服务器来提供静态文件。

步骤:

  1. 在项目根目录初始化Node.js项目并安装Express。
  2. 创建 app.js 文件作为服务器入口。
  3. 创建 public 文件夹存放前端文件(HTML, CSS, JS)。

app.js 基础代码:

const express = require('express');
const app = express();
const port = 3000;

// 提供静态文件
app.use(express.static('public'));

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_2.png)

// 根路由,返回首页
app.get('/', (req, res) => {
    res.sendFile(__dirname + '/public/index.html');
});

// 启动服务器
app.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

验证:
运行 npm start 后,访问 http://localhost:3000,应能看到你的 index.html 页面。


第二部分:连接数据库与CRUD操作

上一节我们成功运行了Web服务器和数据库服务器。本节中,我们将使用Mongoose库连接两者,并开始操作数据。

2.1 连接MongoDB 🔗

我们将使用Mongoose,它是一个基于MongoDB原生驱动的ODM(对象文档映射)库,支持模式(Schema)定义。

步骤:

  1. 安装Mongoose:npm install mongoose
  2. 创建配置文件 config/database.js,存放数据库连接URL。
  3. app.js 中导入配置并使用Mongoose连接数据库。

config/database.js

module.exports = {
    database: 'mongodb://127.0.0.1:27017/my_app_data'
};

app.js 中添加连接代码:

const mongoose = require('mongoose');
const { database } = require('./config/database');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_30.png)

// 连接MongoDB
mongoose.connect(database, {
    useNewUrlParser: true,
    useUnifiedTopology: true
}).then(() => {
    console.log('Database Connected');
}).catch(err => {
    console.log('Connection Error: ' + err);
});

2.2 定义数据模型(Schema)📝

在操作数据之前,我们需要定义数据的结构。我们将创建一个 User 模型。

步骤:

  1. 创建 models/User.js 文件。
  2. 使用Mongoose的Schema定义用户字段。

models/User.js

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_38.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_40.png)

// 定义用户模式
const userSchema = new Schema({
    email: String,
    password: String
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_42.png)

// 创建并导出模型
module.exports = mongoose.model('User', userSchema);

2.3 实现CRUD端点 🛠️

我们将创建API路由来处理对用户数据的各种操作。以下是核心操作及其对应的HTTP方法:

  • C (Create) -> POST
  • R (Read All) -> GET
  • R (Read One) -> GET (带ID参数)
  • U (Update) -> PUT
  • D (Delete) -> DELETE

我们将路由逻辑组织在 api/user-routes.js 中。

首先,安装并配置body-parser以解析请求体:
npm install body-parser

app.js 中启用:

const bodyParser = require('body-parser');
const userRoutes = require('./api/user-routes');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_56.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_58.png)

app.use(bodyParser.json()); // 解析JSON格式的请求体
app.use('/', userRoutes); // 使用用户路由

创建 (Create) - POST /register

api/user-routes.js 中的实现:

const express = require('express');
const router = express.Router();
const User = require('../models/User');

// 创建用户
router.post('/register', async (req, res) => {
    const { email, password } = req.body;
    const newUser = new User({ email, password });
    const savedUser = await newUser.save();
    res.json({
        status: true,
        message: 'Inserted',
        data: savedUser
    });
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_66.png)

module.exports = router;

前端测试 (public/scripts/api-tester.js):

async function testCreate() {
    const config = {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            email: document.getElementById('create-email').value,
            password: document.getElementById('create-password').value
        })
    };
    const response = await fetch('/register', config);
    const data = await response.json();
    document.body.innerHTML += `<p>${JSON.stringify(data)}</p>`;
}

读取所有 (Read All) - GET /users

api/user-routes.js 中的实现:

router.get('/users', async (req, res) => {
    const users = await User.find();
    res.status(200).json({
        message: 'Users fetched',
        data: users
    });
});

读取单个 (Read One) - GET /user/:id

api/user-routes.js 中的实现:

router.get('/user/:id', async (req, res) => {
    const user = await User.findById(req.params.id);
    if (user) {
        res.status(200).json(user);
    } else {
        res.status(404).json({ message: 'Data not found' });
    }
});

更新 (Update) - PUT /update/:id

api/user-routes.js 中的实现:

router.put('/update/:id', async (req, res) => {
    const updatedUser = await User.findOneAndUpdate(
        { _id: req.params.id },
        { $set: { email: req.body.email, password: req.body.password } },
        { new: true }
    );
    if (updatedUser) {
        res.status(200).json(updatedUser);
    } else {
        res.status(404).json({ message: 'Data not found' });
    }
});

删除 (Delete) - DELETE /delete/:id

api/user-routes.js 中的实现:

router.delete('/delete/:id', async (req, res) => {
    const result = await User.deleteOne({ _id: req.params.id });
    res.status(200).json({
        message: 'User data deleted',
        data: result
    });
});

提示: 在实际应用中,请为所有数据库操作添加 try...catch 块以进行错误处理,防止服务器因客户端错误请求而崩溃。


第三部分:JWT身份验证

上一节我们完成了数据的增删改查。本节我们将引入一种无状态的身份验证机制——JSON Web Token(JWT)。

3.1 JWT概念 🔐

JWT是一种令牌,包含三部分:

  1. Header:标识生成签名所用的算法。
  2. Payload:包含声明(如用户ID)。
  3. Signature:由编码后的Header、Payload和密钥通过加密算法生成。

与基于Session的认证不同,JWT将认证状态存储在客户端令牌中,服务器无需保存会话列表,因此更具可扩展性。但需注意,令牌一旦泄露,他人即可冒充用户,因此必须妥善处理(如设置短期有效期)。

3.2 实现登录并签发JWT 🎫

步骤:

  1. 安装JWT库:npm install jsonwebtoken
  2. 创建登录端点,验证用户凭证后签发令牌。

api/user-routes.js 中的登录实现:

const jwt = require('jsonwebtoken');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/6c68f1f2b6558d155ff370ad0b7cb864_116.png)

router.post('/login', async (req, res) => {
    const { email, password } = req.body;
    const user = await User.findOne({ email });

    if (!user) return res.status(401).json({ message: 'Invalid email' });
    if (user.password !== password) return res.status(401).json({ message: 'Invalid password' });

    // 创建JWT载荷
    const payload = { subject: user._id };
    const token = jwt.sign(payload, 'secret_key'); // 生产环境应使用环境变量存储密钥
    res.status(200).json({ token });
});

前端登录测试:

async function testLogin() {
    const config = {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            email: document.getElementById('login-email').value,
            password: document.getElementById('login-password').value
        })
    };
    const response = await fetch('/login', config);
    const data = await response.json();
    // 将令牌存储在客户端(例如sessionStorage)
    sessionStorage.setItem('token', data.token);
    document.body.innerHTML += `<p>${JSON.stringify(data)}</p>`;
}

3.3 创建受保护的路由 🛡️

我们将创建一个中间件来验证JWT,并保护一个特定的路由。

api/user-routes.js 中的验证中间件和受保护路由:

// JWT验证中间件
function verifyToken(req, res, next) {
    if (!req.headers.authorization) {
        return res.status(401).json({ message: 'Unauthorized request' });
    }
    const token = req.headers.authorization.split(' ')[1];
    if (!token) {
        return res.status(401).json({ message: 'Unauthorized request' });
    }
    try {
        const payload = jwt.verify(token, 'secret_key');
        req.userId = payload.subject; // 将用户ID附加到请求对象
        next();
    } catch (err) {
        return res.status(401).json({ message: 'Unauthorized request' });
    }
}

// 受保护的路由
router.get('/special', verifyToken, (req, res) => {
    res.json({ user: req.userId });
});

前端访问受保护路由:

async function testProtected() {
    const token = sessionStorage.getItem('token');
    const config = {
        method: 'GET',
        headers: {
            'Authorization': 'Bearer ' + token
        }
    };
    const response = await fetch('/special', config);
    const data = await response.json();
    document.body.innerHTML += `<p>${JSON.stringify(data)}</p>`;
}

安全提醒: 此示例将令牌存储在 sessionStorage 中仅用于演示。在实际应用中,需要考虑令牌的存储安全性和过期策略,例如使用HttpOnly Cookie或短期的内存存储,以降低XSS攻击导致令牌被盗的风险。


总结 🎉

在本教程中,我们一起学习了如何构建一个基础的全栈应用:

  1. 环境搭建:我们部署了本地的MongoDB服务器和Express Web服务器。
  2. 数据库连接:使用Mongoose库连接Express应用到MongoDB,并定义了User数据模型。
  3. CRUD操作:我们实现了完整的创建、读取、更新和删除端点,并通过前端界面进行了测试。
  4. JWT身份验证:我们引入了基于JWT的无状态认证,实现了登录接口、令牌签发以及受保护路由的访问控制。

这个应用展示了客户端、服务器和数据库三层架构之间的基本通信模式。你可以以此为基础,添加更复杂的业务逻辑、改进错误处理、美化前端界面,并将其部署到生产环境(如使用Heroku和MongoDB Atlas)。

下一步建议:

  • 为服务器添加全面的错误处理(try...catch)。
  • 防止数据重复(如唯一邮箱)。
  • 使用环境变量管理敏感配置(如JWT密钥、数据库URL)。
  • 为令牌设置合理的过期时间。
  • 将应用部署到云平台,体验完整的开发流程。

祝你构建出功能强大的Web应用!

023:用户认证与Passport.js入门

在本节课中,我们将学习如何为全栈应用构建一个用户认证系统。我们将使用Node.js的Passport模块来处理授权和认证逻辑,并使用BCrypt来安全地哈希密码。此外,我们还将学习如何使用EJS(嵌入式JavaScript)进行服务器端HTML渲染。

概述

用户认证是验证用户凭据以访问非公开或受限数据的过程。本实验将指导我们构建一个基本的用户登录页面,涵盖从设置服务器到实现完整认证流程的多个步骤。我们将学习Passport.js、会话管理、受保护路由以及如何安全地存储用户密码。

设计阶段

在开始编码之前,我们先设计应用的基本视图和流程。这有助于明确所需的路线和控制器。

我们的应用将包含三个主要视图:

  1. 主页(受保护路由):用户登录后看到的个人资料页面,显示欢迎信息并提供注销选项。
  2. 注册页面(仅限未登录用户访问):包含姓名、邮箱和密码输入的表单。
  3. 登录页面(仅限未登录用户访问):包含邮箱和密码输入的表单。

项目初始化与基础服务器

首先,我们需要创建项目目录结构并初始化一个基础的Express服务器。

以下是项目初始化的步骤:

  1. 创建项目目录 user-login-app
  2. 进入该目录并初始化 package.json 文件。
  3. 创建主要的应用文件 app.js
  4. 创建组织代码的子目录:controllersmiddlewaresmodelsroutesviews

package.json 中,我们定义项目信息并添加 express 作为初始依赖。

{
  "name": "user-login-app",
  "version": "1.0.0",
  "description": "A project that implements user authentication using Passport.js",
  "main": "app.js",
  "scripts": {
    "start": "npm install && node app.js"
  },
  "author": "Your Name",
  "dependencies": {
    "express": "^4.18.2"
  }
}

app.js 中,我们设置一个监听端口的简单Express服务器。

const express = require('express');
const app = express();
const port = 3000;

function setupApp() {
  // 中间件和路由设置将在这里进行
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/522462dd3645ce38022515a28b7a2663_2.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/522462dd3645ce38022515a28b7a2663_3.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/522462dd3645ce38022515a28b7a2663_4.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/522462dd3645ce38022515a28b7a2663_6.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/522462dd3645ce38022515a28b7a2663_8.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/caltech-cs428-adv-webapp/img/522462dd3645ce38022515a28b7a2663_10.png)

setupApp();
app.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

运行 npm start 可以启动服务器并验证其是否正常工作。

使用EJS渲染服务器端HTML

上一节我们设置了基础服务器,本节中我们来看看如何使用EJS模板引擎来动态生成并发送HTML页面给客户端。

EJS允许我们在HTML中嵌入JavaScript逻辑,并在服务器端进行渲染。

以下是实现此目标的步骤:

  1. package.json 的依赖中添加 ejs
  2. app.jssetupApp 函数中,将视图引擎设置为 ejs
  3. views 目录下创建 index.ejs 文件作为主页模板。
  4. 创建路由 (routes/user-routes.js) 和控制器 (controllers/user-controller.js) 来处理请求并渲染EJS模板。
  5. 在控制器中,使用 res.render('index.ejs', { name: 'data' }) 来渲染模板并传递数据。

index.ejs 中,我们可以使用 <%= name %> 语法来显示从服务器传递过来的数据。

<h1>Hi <%= name %></h1>

启动服务器并访问主页,可以看到动态生成的“Hi data”内容。

设置登录与注册页面路由

现在我们已经能够渲染主页,接下来需要为登录和注册功能创建对应的页面和路由。

以下是创建登录和注册页面的步骤:

  1. views 目录下创建 login.ejsregister.ejs 文件。
  2. 在用户控制器 (user-controller.js) 中,添加 getLogingetRegister 方法,分别用于渲染登录和注册页面。
  3. 在用户路由 (user-routes.js) 中,为 /login/register 路径设置GET请求处理,并关联到上一步创建的控制器方法。
  4. app.js 中确保正确加载了用户路由。

完成这些步骤后,访问 /login/register 路径就能看到对应的页面。

创建表单并处理POST请求

我们有了登录和注册页面的框架,现在需要在页面上添加表单,以便用户提交数据。

register.ejslogin.ejs 中,我们创建包含输入字段(如姓名、邮箱、密码)的HTML表单。表单的 action 属性指向相应的端点(如 /register),method 属性设置为 post

为了在服务器端解析POST请求的正文数据,我们需要使用 body-parser 中间件。

以下是处理表单提交的步骤:

  1. Express 4.16+ 版本内置了 express.jsonexpress.urlencoded 中间件,我们可以直接使用它们来代替独立的 body-parser 包。
  2. app.jssetupApp 函数中,通过 app.use(express.urlencoded({ extended: false })) 来启用URL编码数据的解析。
  3. 在用户控制器中,创建 postLoginpostRegister 方法。在这些方法中,可以通过 req.body 访问表单提交的数据(例如 req.body.email, req.body.password)。
  4. 在用户路由中,为 /login/register 路径设置POST请求处理,并关联到新的控制器方法。

现在,当用户提交登录或注册表单时,服务器能够接收到数据并在控制台打印出来,同时返回一个“success”消息给客户端。

创建用户数据模型

当用户注册时,我们需要将他们的信息存储起来。本节我们将创建一个简单的内存数据模型来管理用户。

我们将使用 shortid 库为每个新用户生成唯一ID。

以下是创建用户模型的步骤:

  1. package.json 的依赖中添加 shortid
  2. models 目录下创建 user-model.js 文件。
  3. 在该文件中,定义一个 Users 类。这个类有一个 users 数组属性来存储所有用户,并有一个 add 方法用于接收姓名、邮箱和密码,生成ID后创建用户对象并推入数组。
  4. 导出该类的一个实例。

在用户控制器的 postRegister 方法中,我们导入这个用户模型实例,并调用其 add 方法来存储新用户。注册成功后,将用户重定向到登录页面。

现在,当用户成功注册时,他们的信息会被添加到服务器的内存存储中。

使用Express Session管理客户端会话

为了识别来自同一客户端的连续请求,我们需要使用会话(Session)。Express Session 中间件会为每个客户端生成一个唯一的会话ID,并通过Cookie进行管理。

以下是设置会话的步骤:

  1. package.json 的依赖中添加 express-session
  2. app.js 中导入 express-session
  3. setupApp 函数中,配置会话选项(如密钥 secret)并使用该中间件。

配置完成后,每个请求对象 (req) 都会包含一个 session 属性。我们可以通过 req.sessionID 来查看当前会话的ID。通过测试可以发现,同一浏览器标签页的多次请求会共享相同的会话ID,而不同浏览器或隐身窗口则会有不同的ID。这为后续基于会话的用户认证打下了基础。

总结与下节预告

本节课中我们一起学习了构建用户认证系统的初始步骤。我们成功设置了项目结构、基础Express服务器,并利用EJS实现了服务器端HTML渲染。我们创建了登录和注册页面及其表单,能够接收并处理用户提交的POST数据。此外,我们还建立了一个内存中的用户数据模型来存储注册信息,并配置了Express Session来管理客户端会话。

在下一节课中,我们将在此基础上继续深入。我们将学习如何使用Passport.js来验证用户的登录凭据,如何序列化和反序列化用户信息,以及如何使用Flash消息在请求间传递临时信息。我们还将实现路由保护,确保只有登录用户才能访问主页,而未登录用户只能访问登录和注册页。最后,我们会使用BCrypt来安全地哈希存储密码,并使用dotenv来管理敏感的环境变量。这将使我们完成一个功能完整的用户认证系统。

posted @ 2026-03-29 09:40  布客飞龙I  阅读(9)  评论(0)    收藏  举报