从零开始编写你的网站

· · 个人记录

0. 前言

也许你使用过各种各样的网站,有的时候你可能想要建立一个属于自己的网站。实现你的想法的方式有很多,也许是一些开源项目,也许是一些现成的网站。这篇文章将带来一种更有意思的解决方案,就是自己编写。

根据数据显示,在 Server-Side 编程语言的使用情况统计中,提到了 JavaScript 作为第四常用的后端语言,占比约 4.9\%(第一的 PHP 占据了 73.6\%),在 StackOverflow 的最受欢迎的语言的统计中,JavaScript 以 63.61\% 的得票率占据第一,再加上其语法与 C++ 有较多的相似之处,本篇文章笔者将以 JavaScript 作为后端语言进行编写。

笔者所给出的代码或命令,如无特殊说明,均基于 Ubuntu 22.04 LTS 和 NodeJS v22.18.0。

本文部分文字取自 MDN 文档(developer.mozilla.org)和中文维基百科(zh.wikipedia.org),部分示例代码由 LLM 生成。

:::info[目录]{open}

1. 基础准备和 JavaScript 初探

2. HTTP,路由与模板引擎

3. 前端设计与用户交互

4. 数据的力量:数据库

5. 走向完整应用:用户系统

6. 处理用户输入

7. 让网站更健壮:ORM,错误处理和日志系统

8. 从本地到网络:部署你的网站

9. 后端性能优化

10. 网站加载速度优化

11. 结束了?

:::

1. 基础准备和 JavaScript 初探

1-1. 开发环境准备:Hello, world!

笔者默认你已经有了一个 Ubuntu 22.04 LTS 的可用系统(其实不是 22.04 LTS 也可能没有什么关系喵...),并且可以流畅连接 GitHub。

首先我们要来更新你的软件包列表,这很简单,打开你的终端(也许是 SSH),输入下面的命令:

sudo apt update

执行后可能要求你输入密码,输入就好啦。

这是什么意思?sudo 表示以管理员的身份执行,而 apt 则是 Ubuntu 系统默认的软件包管理器,update 是更新软件包列表的谓词。

既然我们将使用 JavaScript,那么我们就应当准备一个正确的 NodeJS 环境。

Ubuntu 的软件源中的 NodeJS 版本可能过旧,我们使用 nvm 进行 NodeJS 版本管理。

输入下面的命令安装 nvm:

sudo apt install wget -y
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm

我们需要的是 NodeJS 22 喵!执行:

nvm install 22

等读条读完过后,执行:

node -v

如果有输出,就说明安装好了,输出可能长这样:

v22.18.0

接下来你需要新建一个文件夹,作为你的工作目录。

选择一个适合你的 IDE,比如 Visual Studio Code,或是 WebStorm,好的 IDE 可以改善你的开发体验和加快开发速度。

打开你的工作目录并在目录下执行:

npm init

接下来 npm 会引导你填写一些信息,示例输出如下(笔者拿 Windows 跑的,不过在 Ubuntu 上是一样的):

This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help init` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (tutorial)
version: (1.0.0)
description:
entry point: (index.js) app.js
test command:
git repository:
keywords:
author: Federico2903
license: (ISC) GPL-3.0-or-later
About to write to D:\Users\asus\Desktop\tutorial\package.json:

{
  "name": "tutorial",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Federico2903",
  "license": "GPL-3.0-or-later"
}

Is this OK? (yes) yes

然后 npm 会生成一个 package.json 到这个目录下,这是你这个项目的基本信息。

注意一点,你要记住你上面的 entry point,如果你直接回车(默认值),就是 index.js,否则就是你填的值。

我喜欢用 app.js 作为入口点,后续代码也会这样写,如果你喜欢 index.js,直接替换就行了。

接下来的操作你暂时不需要理解,直接照做即可,在工作目录下执行:

npm install express --save

创建文件 app.js(或是你的入口点),写入如下内容:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.send('Hello World!');
});

app.listen(3000);

然后回到工作目录下执行:

node app.js

这段命令的意思是让 NodeJS 执行

此时你会发现他卡住了,什么也不会输出。不要关闭终端窗口,打开浏览器,网址栏输入 http://localhost:3000

不出意外的话,你会看见一条 Hello World!。这就是你的第一个网站了。

:::info[这是什么]{open} 这是一个真正的后端(服务端),它已经能够处理来自用户的请求。

Express 是一个快速、灵活、极简的 Web 框架,可以用来方便的解析和处理请求。

这段代码的第一行是一个 require,像 C++ 一样,需要导入你需要的包。

第二行创建了一个 Express 实例,用于设置路由等等。

高亮部分的 app.get('/', () => {...}) 点明了当以 GET 方法请求 / 时,执行后面的代码。

这个 / 是一个路径,例如 https://www.luogu.com.cn/article 的路径是 /article

https://www.luogu.com.cn 的路径就是 /,可以理解为什么都没有。

而这个的第二个参数,会在后面的 JavaScript 语法部分提到,你可以把它看作 C++ 的 lambda 表达式。

其中的代码 res.send() 则是将括号内的数据发回到前端(客户端)。

而最开始执行的 npm install express --save 则是在安装 Express。

npm 是 NodeJS 自带的包管理器,类似的还有 pnpmyarn 等。

安装的文件会存放于 node_modules 文件夹下,依赖信息会写入 package.json。 :::

在终端中按 Ctrl + C 可以停止运行后端。

尝试修改 res.send() 里的文字,重新运行 node app.js,看看变化。

:::warning[请不要删除这个工作目录]{open}

我们的后续教学会继续使用这个工作目录内的文件!

:::

1-2. 一切的背后:JavaScript,HTML 和 CSS

想象一下你要制作一个机器人:

HTML 是机器人的骨架和零部件。它定义了结构——哪里是头,哪里是手臂,哪里是按钮。但它看起来只是个灰秃秃的金属架子。

例子:<button>点击我!</button>——这里有一个按钮零件。

CSS 是机器人的皮肤、油漆和外观设计。它负责让机器人变得好看——给金属外壳涂上颜色,让眼睛发光,调整手臂的粗细。

例子:button { color: white; background-color: blue; }——把按钮涂成蓝底白字。

JavaScript 是机器人的程序和电路。它负责让机器人动起来——告诉它“当用户按下这个按钮时,手臂要抬起来”。

例子:button.addEventListener('click', () => { alert('你好!'); });——让按钮被点击时弹出问候。

它们三者协同工作,缺一不可。HTML 搭建结构,CSS 进行美化,JavaScript 实现交互。

1-2.1. JavaScript

JavaScript 和 C++ 的最大的区别在于:解释型,弱类型和垃圾回收

下面展示的两段代码,一段由 C++ 编写,一段由 JavaScript 编写,但实现的功能是一样的:

let message = "Hello"; // 用 let 声明变量
const pi = 3.14;       // 用 const 声明常量
if (true) {
    console.log(message);
}
for (let i = 0; i < 5; i++) {
    console.log(i);
}
function greet(name) { // 函数定义
    return "Hello, " + name;
}
#include <iostream>
using namespace std;

string greet(string name) {
    return "Hello, " + name;
}

int main() {
    string message = "Hello";
    const double pi = 3.14;
    if (true) {
        cout << message << endl;
    }
    for (int i = 0; i < 5; i++) {
        cout << i << endl;
    }
    return 0;
}

不难发现,无论是变量类型,形参类型还是函数返回值,在 JavaScript 中都不需要指明类型,这就是弱类型

我们再来看一段代码:

console.log("This is the first message.");
throw new Error('Example error'); // 抛出一个错误
console.log("This is the second message.");

这段代码在笔者 Windows 中的输出为:

This is the first message.
D:\Users\asus\Desktop\tutorial\test.js:2
throw new Error('Example error');
^

Error: Example error
    at Object.<anonymous> (D:\Users\asus\Desktop\tutorial\test.js:2:7)
    at Module._compile (node:internal/modules/cjs/loader:1688:14)
    at Object..js (node:internal/modules/cjs/loader:1820:10)
    at Module.load (node:internal/modules/cjs/loader:1423:32)
    at Function._load (node:internal/modules/cjs/loader:1246:12)
    at TracingChannel.traceSync (node:diagnostics_channel:322:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:235:24)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
    at node:internal/main/run_main_module:36:49

Node.js v22.18.0

你会发现他执行了第一条语句过后,由于出现错误,停止了后续代码执行并打印了堆栈。

这因为 JavaScript 是一门解释型语言

至于垃圾回收,你可以认为 JavaScript 会检测你哪些变量再也用不到,并把它们从内存中永远删除掉。

::::info[箭头函数]{open}

在 JavaScript 中,你会经常看到一种看起来有点奇怪的函数写法:() => {}。这叫做箭头函数,它是传统函数表达式的一种更简洁的写法。

const greet1 = function(name) {
    return "Hello, " + name;
};

const greet2 = (name) => {
    return "Hello, " + name;
};

执行 greet1greet2 的效果是完全一样的。

如果函数只有一个参数,可以省略括号 ()。如果函数体只有一行返回语句,可以省略大括号 {}return 关键字:

const greet = name => "Hello, " + name;

// 等价于
const greet = function(name) {
    return "Hello, " + name;
};

箭头函数还有一个非常重要的特性:它没有自己的 this。它的 this 值继承自定义它时所处的上下文(父级作用域)。这解决了传统函数中 this 指向混乱的著名难题(P.S. 你不需要理解下面这段代码,因为他所涉及的 document 对象只存在于浏览器中)。

document.addEventListener('click', function() {
    console.log(this); // this 指向 document
});

// this 继承自外部(定义它的地方)
document.addEventListener('click', () => {
    console.log(this); // this 指向 window(或外部作用域的this)
});

在需要传递一个回调函数时,使用箭头函数通常更安全、更不容易出错。

:::info[回调函数]{open}

JavaScript 的参数可以是一个函数,常见的使用方式是当函数逻辑执行完之后,执行位于参数列表最末端的函数,上报结果。我们称其为回调函数(callback)。

:::

示例代码:

function add(x, y, callback) {
    const result = x + y;
    callback(result);
}

add(1, 2, result => console.log(result));

以上这段代码输出 3

::::

JavaScript 也可以运行在浏览器中(当网页上包含 JavaScript 时会自动运行)。

此时的 JavaScript 使用的是 ESM 语法(如果你好奇的话可以去搜一下,最显著的区别是使用 import 进行导入)。

并且这个时候的 JavaScript 脚本会多出几个对象,比较常用的有 documentwindow,后面设计前端时我们再来说这个。

将 JavaScript 嵌入 HTML 的方式是使用 <script> 标签。可以使用 src 属性加载外部 JavaScript,也可以直接包裹 JavaScript 代码:

<script src="script.js"></script>
<!-- 这个会加载同目录下的 script.js -->
<script>
    alert('Alert');
</script>
<!-- 这个会在你打开的时候弹出一个提示 -->

可能对你有用的 MDN 文档链接:

1-2.2. JSON(JavaScript 对象表示法)

JSON 是一种轻量级资料交换格式,其内容由属性和值所组成,因此也有易于阅读和处理的优势。

JSON的基本数据类型:

以下是一段有效的 JSON:

{
     "firstName": "John",
     "lastName": "Smith",
     "sex": "male",
     "age": 25,
     "address": {
         "streetAddress": "21 2nd Street",
         "city": "New York",
         "state": "NY",
         "postalCode": "10021"
     },
     "phoneNumber": [
         {
           "type": "home",
           "number": "212 555-1234"
         },
         {
           "type": "fax",
           "number": "646 555-4567"
         }
     ]
 }

JavaScript 处理 JSON 的方法也很简单:

const json = '{"name":"Federico2903","uid":381949}';
// 这是一个 JSON 字符串
const jsonObject = JSON.parse(json);
// 解析成一个 JSON 对象
console.log(jsonObject.name);
// 输出 Federico2903
console.log(JSON.stringify(jsonObject));
// 输出 {"name":"Federico2903","uid":381949}
// 这就变回了字符串

const coyoteStrengthInfo = {
    type: "msg",
    strength: {
        strengthA: 0,
        strengthALimit: 200,
        strengthB: 0,
        strengthBLimit: 200
    }
};
// 直接定义 JSON 对象也是可以的

console.log(coyoteStrengthInfo.strength.strengthA);
// 输出 0

const coyotePulseInfo = {
    type: "msg",
    pulse: [
        "0A0A0A0A000A141E",
        "0F0F0F0F28323C46"
    ]
}

console.log(coyotePulseInfo.pulse[0]);
// 输出 0A0A0A0A000A141E

所以通过 JSON,可以很方便地序列化信息。

可能对你有用的链接:

1-2.3. HTML(超文本标记语言)

HTML(超文本标记语言——HyperText Markup Language)是构成 Web 世界的一砖一瓦。它定义了网页内容的含义和结构。

——MDN 文档

正如最开始提到的那样,HTML 规划了整个网页的基本结构。

HTML 使用标签来描述网页。HTML 标签是由尖括号包围的关键词,比如 <html>。HTML 标签通常是成对出现的,比如 <b></b>。标签对中的第一个标签是开始标签,第二个标签是结束标签,结束标签的尖括号内有一个正斜杠。标签大小写不敏感

HTML 元素指的是标签对中的内容以及标签对本身

HTML 元素都含有属性。这些额外的属性值可以通过各种途径对元素进行配置或调整其行为。

HTML 文件的后缀名是 .html,可以直接在浏览器中打开,所以下面的 HTML 你都可以直接保存进一个文件,放进浏览器看效果!

<p style="font-size: 2em; text-align: center;" class="title">An example title</p>

上面的这段 HTML 会展示一个居中的标题,字体大小是 2em(这个环境下是 32px)。

这个段落元素(就是 <p>)具有两个属性,一个是 style,其中包含两个属性,一个是 font-size,表示字体大小,一个是 text-align,表示对齐情况。同时这个元素具有一个 title 的类。

:::info[HTML 文本]

<!-- 标题,h1最大,h6最小 -->
<h1>这是一个一级标题</h1>
<h2>二级标题</h2>
<h3>三级标题</h3>

<!-- 段落 -->
<p>这是一个段落。HTML让文本有了结构和意义。</p>

<!-- 加粗和斜体 -->
<p>这是<strong>重要</strong>的文字,这是<em>强调</em>的文字。</p>

<!-- 换行和水平线 -->
第一行<br>第二行
<hr>
<p>上面有一条水平分割线</p>

<!-- 超链接 -->
<a href="https://www.example.com">访问示例网站</a>
<a href="/about">绝对路径</a>
<a href="about">相对路径</a>

<!-- 图片 -->
<img src="cat.jpg" alt="一只可爱的猫" width="300">
<img src="https://placekitten.com/200/300" alt="随机猫咪图片">

<!-- 链接包裹图片,点击图片就会跳转 -->
<a href="large-image.jpg">
    <img src="thumbnail.jpg" alt="查看大图">
</a>

<!-- 无序列表(带项目符号) -->
<ul>
    <li>苹果</li>
    <li>香蕉</li>
    <li>橙子</li>
</ul>

<!-- 有序列表(带数字) -->
<ol>
    <li>起床</li>
    <li>刷牙</li>
    <li>吃早餐</li>
</ol>

<!-- 定义列表 -->
<dl>
    <dt>HTML</dt>
    <dd>超文本标记语言,用于创建网页结构</dd>

    <dt>CSS</dt>
    <dd>层叠样式表,用于美化网页</dd>
</dl>

:::

上面的 HTML 展示了一些常用标签(LLM 写的(逃))。

注意到这里会有特例,<br><hr><img> 等没有结束标签。

两个比较特殊的标签:<div><span>,他们用来分组元素,区别是一个是块级(会占据整行,挤掉别的内容),一个是行间(嵌在一行里面的)。

HTML 标签可以无限嵌套,形成一个树形结构,这个也叫 DOM 树

你可以用 style 属性给元素加一段 CSS,可以用 class 属性给元素加一些类(比如你想做相同样式的卡片,不需要重复写 style,只需要在 CSS 里面选中所有含有某个类的进行设置,然后给对应元素相应的 class 就好了。还可以使用 id 属性给元素加一个唯一 ID,可以用于选择器的选择。

:::info[HTML 布局]{open}

每个元素从内到外是内容区域(content),内边距(padding),边框(border),外边距(margin)。这个又称为盒模型。在后面的前端设计会很有用。

:::

可能对你有用的 MDN 文档链接:

1-2.4. CSS(层叠样式表)

层叠样式表(Cascading Stylesheet,简称 CSS),其基本目标是让浏览器以指定的特性去绘制页面元素,比如颜色、定位、装饰。CSS 的语法反映了这个目标,由下面两个部分构建:

属性(property)是一个标识符,用可读的名称来表示其特性。
值(value)则描述了浏览器引擎如何处理该特性。每个属性都包含一个有效值的集合,它有正式的语法和语义定义,被浏览器引擎实现。

CSS 的核心功能是将 CSS 属性设定为特定的值。一个属性与值的键值对被称为“声明”(declaration)。
CSS 引擎会计算页面上每个元素都有哪些声明,并且会根据结果绘制元素,排布样式。

在 CSS 中,无论是属性名还是属性值都是对大小写不敏感的。属性与值之间以英文冒号 ':' (U+003A COLON)隔开。属性与值前面、后面与两者之间的空白不是必需的,会被自动忽略。

——MDN 文档

CSS 文件的后缀名是 .css,可以通过 <link> 标签引入到 HTML 中。

例如:<link rel="stylesheet" href="style.css">,就会把同目录下的 style.css 引入到这个 HTML。

也可以用 <style> 标签包裹,直接写进 HTML,比如:

<style>
p {
    font-size: 2em;
    color: red;
}
</style>
<p>An example paragraph.</p>
<!-- 这段文字会显示为大一些的红色 -->

CSS 一个很重要的部分,叫做选择器。这个东西比较复杂,笔者只讲解常用的部分。

一个 CSS 声明块是由一对大括号包裹起来的,就像这样:

{
    Here is you css content.
}

CSS 可以在声明块前面放置选择器(selector),选择器是用来选择页面多个元素的条件。

一对选择器与声明块称为规则集(ruleset),常简称为规则(rule)。

比如刚刚给出的第一段代码的高亮部分,就是一个规则,他选中了所有的 <p> 标签。

常用的选择方式有:

span {
    /* 选中所有 <span> */
}

.card {
    /* 选中所有类中含有 card 的元素 */
}

#element {
    /* 选中 ID 是 element 的元素 */
}

选择器可以复合,例如 .card.shadow 就会选中同时具有 cardshadow 两个类的元素。

当多个选择器共享相同的声明时,它们可以被编组进一个以逗号分隔的列表。

.card-1, .card-2 {
    /* 选中所有类中含有 card-1 或 card-2 的元素 */
}

常用的 CSS 属性:

可能对你有用的 MDN 文档链接(加粗的进阶必读):

1-2.5. 综合运用

我给出一段实现了简单任务列表的 HTML,包含 CSS 和 JavaScript,我们基于这个讲解这三者的综合运用。

:::info[HTML 文本]

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>简单的 DOM 操作</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 50px auto;
            padding: 20px;
            /*
                设置字体为 Arial,如果出现不支持的字符使用 sans-serif(fallback)
                容器最大宽度为 600 像素
                外边距上下各为 50 像素,左右自动设置
                内边距上下左右均为 20 像素
            */
        }
        .container {
            background: #f9f9f9;
            padding: 20px;
            border-radius: 10px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
            /*
                设置背景颜色为 #f9f9f9
                内边距上下左右均为 20 像素
                边框圆角半径 10 像素
                设置阴影为 x 不偏移,y 偏移 2 像素
                模糊半径 5 像素,颜色黑色,不透明度 10%
                https://developer.mozilla.org/zh-CN/docs/Web/CSS/box-shadow
            */
        }
        input, button {
            padding: 10px;
            margin: 5px;
            border: 1px solid #ddd;
            border-radius: 5px;
            /*
                内边距上下左右均为 10 像素
                外边距上下左右均为 5 像素
                边框圆角半径 5 像素
                边框粗细 1 像素,实线,颜色 #ddd
                https://developer.mozilla.org/zh-CN/docs/Web/CSS/border
            */
        }
        button {
            background: #4CAF50;
            color: white;
            cursor: pointer;
            /*
                设置背景颜色为 #4CAF50
                设置字体颜色为白色
                设置鼠标悬浮时变为小手
            */
        }
        button:hover {
            background: #45a049;
        }
        .item {
            background: white;
            padding: 10px;
            margin: 10px 0;
            border-radius: 5px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            /*
                这里涉及到了 Flex 布局
                如果你有兴趣的话请阅读:
                https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_flexible_box_layout/Basic_concepts_of_flexbox
                简单来说,这个地方的 display: flex 指定了使用 flex 布局
                元素之间的分割方式是用间距分割并占完剩余空间(justify-content)
                每个元素交叉轴方向上居中对齐
            */
        }
        .completed {
            text-decoration: line-through;
            opacity: 0.6;
            /*
                设置文本装饰为删除线
                设置不透明度为 60%
            */
        }
        .delete-btn {
            background: #ff4444;
            padding: 5px 10px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h2>简单的任务列表</h2>

        <div>
            <input type="text" id="taskInput" placeholder="输入新任务">
            <button onclick="addTask()">添加任务</button>
        </div>

        <div id="taskList">
            <!-- 任务会动态添加在这里 -->
        </div>

        <div>
            <p>总任务数: <span id="totalCount">0</span></p>
        </div>
    </div>

    <script>
        function addTask() {
            const taskInput = document.getElementById('taskInput');
            const taskText = taskInput.value.trim();
            if (taskText === '') {
                alert('请输入任务内容!');
                return;
            }

            // 创建新的任务元素
            const taskItem = document.createElement('div');
            taskItem.className = 'item';
            taskItem.innerHTML = `
                <span>${taskText}</span>
                <div>
                    <button onclick="toggleTask(this)">完成</button>
                    <button class="delete-btn" onclick="deleteTask(this)">删除</button>
                </div>
            `;

            // 添加到任务列表
            document.getElementById('taskList').appendChild(taskItem);

            // 清空输入框
            taskInput.value = '';

            // 更新计数
            updateCount();
        }

        // 切换任务完成状态
        function toggleTask(button) {
            const taskItem = button.parentElement.parentElement;
            const taskText = taskItem.querySelector('span');

            taskText.classList.toggle('completed');

            if (taskText.classList.contains('completed')) {
                button.textContent = '未完成';
            } else {
                button.textContent = '完成';
            }
        }

        // 删除任务
        function deleteTask(button) {
            const taskItem = button.parentElement.parentElement;
            taskItem.remove();
            updateCount();
        }

        // 更新任务计数
        function updateCount() {
            const taskList = document.getElementById('taskList');
            const totalCount = document.getElementById('totalCount');

            totalCount.textContent = taskList.children.length;
        }

        // 初始化
        document.addEventListener('DOMContentLoaded', function() {
            // 给输入框添加回车键支持
            document.getElementById('taskInput').addEventListener('keypress', function(e) {
                if (e.key === 'Enter') {
                    addTask();
                }
            });
        });
    </script>
</body>
</html>

:::

以上 HTML 涉及的知识点:

::::info[页面效果预览]

:::align{center}

:::

::::

你会发现这份 HTML 好像多了很多不熟悉的标签,比如 <head><html>,这些标签你可以理解为一个 HTML 所需要的模板,并且有一些规则:例如我们常常在 <head> 里面引入外部 JavaScript 和 CSS 文件。

:::warning[无需深究]{open}

不完全理解上述内容对你设计网页影响不会很大。粗略了解一般写法即可。

如果想详细了解,访问以下 MDN 文档地址:

https://developer.mozilla.org/zh-CN/docs/Web/HTML/Reference/Elements#文档元数据

:::

CSS 的设计细节我已经写进注释了,我们重点来讲一下 JavaScript 这些你没见过的函数。

document.getElementById():这个方法输入一个 ID,返回 ID 为指定值的 HTML 元素

这个 HTML 元素会拥有一些属性(可以直接访问或是修改),例如 innerHTML,就可以修改他的 HTML(但是请注意,这个地方你修改的内容是写在标签里面的,就是你修改不到标签,如果你要修改标签,应该去获取它的父级元素),对于可能存在值的元素(例如 input),会有 value 属性,表示当前他内部的值。

document.createElement():这个方法输入一个标签(不带尖括号),会新建并返回一个元素,表示标签对应的元素,此时的元素还没有进入 DOM 树(没有插入到网页上)。

element.parentElement:这个成员是它的父级元素。

element.classList:这个成员是它的类列表。

element.addEventListener(event, callback):表示为这个元素的某个事件绑定一个函数。

例如按钮点击事件是 click,网页加载完成事件是 DOMContentLoaded

:::success[小任务]{open}

这个删除按钮的大小不太对,你能把他修正吗?

:::

2. HTTP,路由与模板引擎

2-1. HTTP:与服务器交流的语言

HTTP(HyperText Transfer Protocol,超文本传输协议)是互联网上应用最为广泛的一种网络协议,用于客户端和服务器之间的通信。它是Web数据通信的基础,定义了客户端如何向服务器请求资源,以及服务器如何响应这些请求。

HTTP 采用请求-响应模型:

  1. 客户端(如浏览器)向服务器发送 HTTP 请求
  2. 服务器处理请求并返回 HTTP 响应
  3. 客户端接收并处理响应

:::info[HTTP 常见请求方法]{open}

方法 描述 特点
GET 请求指定的资源 参数在 URL 中
POST 向指定资源提交数据 参数在请求体中
PUT 替换指定资源 用于更新操作
DELETE 删除指定资源 用于删除操作

:::

:::info[HTTP 状态码]{open}

状态码范围 类别 常见示例
100-199 信息响应 100 Continue
200-299 成功响应 200 OK, 201 Created
300-399 重定向 301 Moved Permanently
400-499 客户端错误 404 Not Found, 400 Bad Request
500-599 服务器错误 500 Internal Server Error

:::

想象一下,你走进一家餐厅。你的浏览器,就像是顾客(你)。你的代码,就像是餐厅后厨。

HTTP,就是你们之间使用的点餐语言。一次完整的“点餐”过程是这样的:

你(浏览器)发起请求:你在地址栏输入 http://localhost:3000/about 然后按下回车。这相当于你对服务员猫猫说:“我想看看‘关于’页面(GET /about)”。这就是一个 HTTP 请求。

GET 是一种最常见的请求方法,意思是“我想获取点东西喵”。就像你说“我想看看菜单喵”。

另一种常见方法是 POST,意思是“我想提交点东西喵”。就像你吃完后说“我要给钱了喵”。

后厨(服务器)处理请求:服务员把你的请求(“关于页面”)告诉后厨。后厨的厨师(你的代码)一听:“哦,是要 /about 啊,收到喵!”

后厨做出响应:厨师迅速准备好了一份“关于页面”的套餐,让服务员猫猫端给你。这就是 HTTP 响应。这个响应里包含了:

状态码:比如 200 OK(一切顺利)或 404 Not Found(找不到你点的菜)。

响应内容:最终的 HTML、文本或图片,就是你会在浏览器里看到的东西。

所以,HTTP 就是一套规则,规定了浏览器和服务器之间如何“说话”才能互相理解。

2-2. 路由:构建网站导航的基石

我们的后端框架采用的是 Express,在 Express 中,路由用于确定应用程序如何响应对特定端点的客户机请求,包含一个 URI(或路径)和一个特定的 HTTP 请求方法(GET、POST 等)。

每个路由可以具有一个或多个处理程序函数,这些函数在路由匹配时执行。

路由可以由如下方法定义:

app.method(path, handler);

method 表示请求的方法,path 表示路径(回忆章节 1-1 中提到的路径的定义),handler 是回调函数。

如果你希望处理 /user 路径上的 PUT 方法请求,你可以像下面的代码这样写:

app.put('/user', (req, res) => {
    res.send('Got a PUT request at /user')
})

观察这个 Handler,他是一个有两个参数的箭头函数。

我们一般命名为 reqres,分别是请求对象和响应对象。

req 包含了这个请求携带的信息,res 则是你要做出的响应的信息。

常用的 res 的方法如下表:

方法 描述 是否结束响应过程
res.send() 发送各种类型的响应
res.json() 发送 JSON 响应
res.status() 设置 HTTP 状态码
res.sendStatus() 设置状态码并发送对应的状态消息
res.sendFile() 发送文件作为响应
res.download() 提示下载文件
res.redirect() 重定向请求
res.render() 渲染视图模板
res.set() 设置响应头
res.get() 获取响应头值
res.cookie() 设置 cookie
res.clearCookie() 清除 cookie
res.type() 设置 Content-Type
res.end() 结束响应过程
res.format() 根据 Accept 头进行内容协商
res.location() 设置 Location 响应头
res.append() 追加响应头

以上函数的详细用法可查看 Express 文档:

如果你一直不结束响应过程,HTTP 连接就会一直挂起,直到超时。而响应过程结束后也不能再发送数据,例如:

// 正确用法:先设置后发送
app.get('/api/user', (req, res) => {
    res.set('X-Custom-Header', 'value');  // 不结束响应
    res.status(200);                      // 不结束响应
    res.json({ user: 'John' });           // 结束响应
});

// 错误用法:发送后不能再设置
app.get('/error-1', (req, res) => {
    res.send('Hello');                    // 结束响应
    res.set('X-Header', 'value');         // 错误:响应已结束
});

// 错误用法:未结束响应过程
app.get('/error-2', (req, res) => {
    res.set('X-Custom-Header', 'value');  // 不结束响应
    res.status(200);                      // 不结束响应
    // HTTP 连接将一直等待响应结束,但是永远也不会结束,直到超时
});

到现在我们还没有讲到怎么跟用户输入做交互,这就需要使用我们的 req 对象。

常用的 req 属性和方法如下表:

方法/属性 描述
req.params 路由参数对象
req.query URL 查询参数对象
req.body 请求体数据(需要中间件解析)
req.headers 请求头对象
req.cookies cookies 对象(需要中间件解析)
req.method HTTP 请求方法
req.url 请求的 URL
req.originalUrl 原始请求 URL
req.baseUrl 路由的基本 URL
req.path 请求路径
req.hostname 主机名
req.ip 客户端 IP 地址
req.get() 获取请求头

现在进入我们的工作目录,执行下面的命令:

npm install cookie-parser --save

这指示 npm 为我们的项目安装 cookie-parser 库,我们会用他来解析 Cookie。

在你的 app.jsapp.get 前面插入这样一段代码:

app.use(cookieParser());
// 解析 Cookie
app.use(express.json());
// 解析 application/json
app.use(express.urlencoded({ extended: true }));
// 解析 application/x-www-form-urlencoded

:::warning[无需深究]{open}

在这里你暂时不需要理解它,这是 Express 的中间件,在这里用来解析 Cookie 和 req.body

中间件将在“5-3. 鉴权:Express 中间件的使用”中提及。

:::

把路由改成这样:

app.post('/', (req, res) => {
    res.send(req.body);
});

这段所构建的一个路由,接收到 POST 请求后会输出你的请求体。

打开你的终端,我们安装一个工具:curl

sudo apt install curl -y

随后我们可以用这样的方法来构建请求:

curl -X POST -H "Content-Type: application/json" -d "{\"x\":1}" http://localhost:3000
        ^            ^                                    ^             ^
  指定请求方法    指定是 JSON 格式                      请求体           网址

如果你已经修改了路由并在终端里执行了这个的话,你会看到他原样返回了你输入的请求体:

$ curl -X POST -H "Content-Type: application/json" -d "{\"x\":1}" http://localhost:3000
{"x":1}

尝试一下,在 res.send 之前输出 req.body 到终端,并重新发送请求,你应该会看见这样的内容:

$ node app.js
{ x: 1 }

注意到什么不同了吗?这里的 req.body 已经是一个 JSON 对象了,可以用“1-2.2. JSON(JavaScript 对象表示法)”中提到的方式读取出里面的内容。

我们尝试做一点点用户交互:

app.post('/', (req, res) => {
    res.send(`Hello, ${req.body.username}`);
});

:::info[模板字符串]{open} 像上面代码一样的,用反引号包起来的字符串叫模板字符串。

可以用 ${expression} 的方式计算一个表达式,计算结果会替换到相应的位置。 :::

此时我们模拟一个用户请求,这个用户正在发送自己的用户名到后端:

$ curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"Federico2903\"}" http://localhost:3000
Hello, Federico2903

你可以看到它和你打了一个招呼!这就是最基础的用户交互,根据用户的输入返回不同的内容。

这个东西,已经是一个 API 了。但是常常这样并不够,因为和这个 API 的交互太麻烦,我们需要让用户能更简单地操作它。这一部分会在“3. 前端设计与用户交互”中提到。

路由还支持占位符形式的定义,例如这个路由:

app.get('/user/:id', (req, res) => res.send(req.params.id));

你可以尝试着把他插入到我们的 app.jsapp.listen 前并运行,打开你的浏览器,输入 http://localhost:3000/user/Federico2903,看看网页上是什么。

不出意外的话,应该显示 Federico2903

Federico2903 取代了路由定义中的 :id 的部分,并且被记录到了 req.params.id 中。

如果你定义一个 /user/:abcd,自然获取它的方式就变成了 req.params.abcd

除了路由参数,我们还可以通过 URL 查询参数来获取用户输入的数据。这些参数通常写在 URL 的 ? 后面,用 & 分隔,例如 http://localhost:3000/search?keyword=node&limit=10,这里 keyword=nodelimit=10 就是查询参数。

我们可以通过 req.query 来获取它们,在你的 app.jsapp.listen 前插入如下内容:

app.get('/search', (req, res) => {
    console.log(req.query);
    res.send(`搜索关键词:${req.query.keyword},限制条数:${req.query.limit}`);
});

打开你的浏览器,输入 http://localhost:3000/search?keyword=node&limit=10

你会在终端看到:

{ keyword: 'node', limit: '10' }

而网页上会显示:

搜索关键词:node,限制条数:10

:::warning[注意]{open}

req.query 中的值都是字符串类型,即使你在 URL 里写的是数字,它也会被当作字符串处理。如果需要数值,可以用 Number() 或者 parseInt() 转换。

:::

也就是说,req.params 是用来匹配路径占位符的,而 req.query 是用来获取 URL 中 ? 后的查询参数的,而 req.body 则是 POST 得到的内容。

利用好这三个属性,就可以实现大部分后端逻辑。

可能对你有用的文档链接:

2-3. 模板引擎:网页的蓝图

我们刚刚已经得到了一个 API,