FullStack-THE COMPLETE DEVELOPER Master the Full Stack with TypeScript, React, Next.js, MongoDB, and Docker

Learn to be a full stack developer!

资源

正文

INTRODUCTION

全栈 web 开发通常是指使用 JavaScript 及其众多框架创建完整的 web 应用程序。它需要掌握前端和后端开发的传统技能,以及编写中间件和各种应用程序编程接口(API)的能力。最后,一名全面的全栈开发人员能够处理数据库,并具备专业技能,例如能够自己编写自动化测试并部署代码。

章节如下:

  • 技术栈

    • Node.js

    • 现代 JavaScript

    • TypeScript

    • React

    • Next.js

    • REST 及 GraphQL APIs

    • MongoDB 及 Mongoose

    • Jest

    • OAuth

    • Docker

  • 全站应用

    • 配置 Docker 环境
    • 构建 Middleware
    • 构建 GraphQL API
    • 构建前端
    • 添加 OAuth
    • 在 Docker 中自动化测试

The Parts of a Full- Stack Application

一个完整的全栈 Web 应用通常由 前端(Frontend)中间层 / 中间件(Middleware)后端(Backend) 三个部分构成,它们各自承担不同但相互协作的职责。

前端(Frontend)

前端是直接面向用户的部分,运行在客户端(通常是浏览器中),可以理解为应用的“前台”。 它负责页面展示、用户交互和整体使用体验。

前端开发主要使用:

  • HTML:构建页面结构
  • CSS:控制样式和布局
  • JavaScript:处理交互逻辑
  • 前端框架(如 Next.js):整合和组织复杂应用

前端开发者关注的是用户体验、交互设计和界面表现。

中间层 / 中间件(Middleware)

中间件位于前端和后端之间,负责连接两者并处理各种业务流程,可以理解为应用的“中台”或“生产线员工”。

中间件通常负责:

  • 路由(根据 URL 返回正确的数据)
  • 调用后端接口和数据库
  • 用户身份认证与权限控制
  • 与第三方服务集成
  • 整合多来源数据并返回给前端

其中一个非常重要的组成是 API 层,用于对外暴露接口,让前端或第三方系统访问后端数据。 常见的 API 架构方式包括 RESTGraphQL

中间件可以使用多种语言实现,但现代全栈开发中最常见的是 JavaScript / TypeScript,当然也可以使用 PHP、Ruby、Go 等。

后端(Backend)

后端是用户看不见的部分,运行在服务器上,可以理解为应用的“后台”。

后端的核心职责是:

  • 处理数据相关逻辑
  • 对数据库进行 增删改查(CRUD)
  • 根据请求返回所需的数据结果

在 JavaScript 技术栈中,后端通常使用 Express.js,也可能基于 Apache、NGINX 等服务器环境。 后端接收来自中间件的请求,执行数据处理后,将结果返回给中间件,再由前端展示给用户。

后端同样不限定语言,除了 JavaScript / TypeScript,还常见:

  • PHP、Python、Java、Ruby、Elixir
  • 框架如 Django、Ruby on Rails、Symfony、Phoenix 等

注意

  • 前端:负责“展示和交互”
  • 中间件:负责“连接、调度和整合”
  • 后端:负责“数据和业务逻辑”

三者协同工作,构成一个完整的全栈 Web 应用。

1 Node.js

常用命令

注意

把 Node.js、npm、package.json 和 package-lock.json 当成做菜

  • Node.js 是环境——厨房(能做菜)
  • npm 是管理包——采购系统
  • package.json 说需求——菜谱(需要哪些食材)
  • package-lock.json 锁结果——购物清单(买了哪些具体品牌)

创建一个项目:

shell
mkdir sample-express
cd sample-express
npm init
webp

这将生成一个 package.json 如下:

json
{
  "name": "sample-express",
  "version": "1.0.0",
  "description": "",
  "license": "ISC",
  "author": "",
  "type": "commonjs",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

安装 Express 及其依赖。Express 是运行在 Node.js 上的 Web 后端框架。

shell
npm install express@4.18.2

这将会生成 node_moudlespackage-lock.json

安装 Karma 测试工具(只在 开发 / 测试阶段 用,不参与生产环境部署):

shell
npm install --save-dev karma@5.0.0
webp

npm 提示这存在漏洞,使用 npm audit 检查是否存在安全漏洞:

shell
npm audit --registry=https://registry.npmjs.org/

重要

建议每隔几个月使用一次 npm audit,并结合 npm update,以避免使用过时的依赖项并产生安全风险。

可以修复之,不过最新的 karma 版本可能会带来破坏性变化。

shell
npm audit fix --registry=https://registry.npmjs.org/ --force

如果某些插件没有维护,问题可能解决失败(哪怕是 --force)。

当:

  • 删除了依赖(改了 package.json
  • 切换分支
  • 合并代码
  • 手动复制过 node_modules

很容易出现:node_modules残留了一堆项目已经不需要的包。通过以下命令清除

shell
npm prune

更新所有包

shell
npm update

注意

项目npm updatenpm audit fix --force
主要目的更新依赖到新版本消除安全漏洞
触发原因人为升级漏洞扫描结果
升级依据package.json 的版本范围漏洞数据库
是否允许主版本升级❌ 否(遵守 semver)✅ 是
是否可能破坏代码
是否改 package.json✅(可能)
是否适合生产项目常用⚠️ 谨慎

移除依赖

shell
npm uninstall karma

根据配置文件安装包:

shell
npm install

注意

场景npm install 行为
有 package-lock.json按 package-lock.json 装
没有 package-lock.json按 package.json 算
package.json 改了重算相关部分
CI / 自动化应使用 lock

npx

注意

npx 是随 Node.js 一起安装的工具

  • 全称:Node Package Execute
  • 功能:不用提前安装包,就能直接运行注册表里的包

npx 的用途

  • 当你只想临时执行某个包的命令,而不想把它加到项目依赖里(dependencies 或 devDependencies)
  • 典型场景:脚手架脚本一次性工具检查工具

示例:

  • 你想检查 package.json 是否有语法错误,可以用 jsonlint
  • 这个包不需要长期安装在项目里,只用一次
  • 用 npx 就可以临时运行,而不用 npm install jsonlint(会将包安装至中央缓存中)

总结:

  • npx = “临时执行 npm 包命令”

  • 适合“一次性使用、不想加依赖”的场景

  • 运行时会检查 PATH、本地 node_modules,如果没有就从中央缓存下载

  • 不会污染项目依赖

Exercise 1 搭建一个 Express.js 服务器

构建一个基于 Express.js 的后端的 hello world。空白文件夹下:

shell
npm init
npm install express@4.18.2

创建一个 index.js

javascript
const express = require('express');
const server = express();
const port = 3000;
server.get('/hello', function (req, res) {
    res.send('Hello World!');
});
server.listen(port, function () {
    console.log('Listening on ' + port);
})

注意

功能:

  1. 启动一个 HTTP 服务器,监听 3000 端口
  2. 当访问 /hello 路径时,返回 “Hello World!”
  3. 启动成功后在控制台输出提示

运行服务器:

shell
node index.js

访问 http://localhost:3000/hello,将会看到 Hello World!

而访问其它的地址将会 404(未定义路由)。


Node.js Tutorial 学习 Node.js。

Free Express Framework Tutorial - ExpressJS Fundamentals | Udemy 学习 ExpressJS。

2 Modern JS

Modern JS(也就是 ES.Next / ES6+)包含 Named Export

注意

版本 / 年份别名主要特性
ES1 (1997)ECMAScript 首个标准,基本语法、数据类型、操作符、控制结构
ES2 (1998)小幅修订,修正错误
ES3 (1999)正则表达式、try/catch、字符串方法、数组方法等
ES4被废弃,部分特性后被 ES6 采纳
ES5 (2009)严格模式 "use strict",JSON 支持,Object API (create/defineProperty),数组方法 (map, filter, forEach)
ES6 (2015)ES2015let/const,模板字符串,箭头函数,解构赋值,默认参数/剩余参数,模块 (import/export),class 和继承,Map/Set/WeakMap/WeakSet,Promise,迭代器/生成器 (for...of)
ES7 (2016)ES2016指数运算符 **Array.prototype.includes
ES8 (2017)ES2017async/awaitObject.values / Object.entries,字符串填充方法 (padStart/padEnd)
ES9 (2018)ES2018异步迭代器 (for await...of),正则增强,对象扩展语法 (...rest)
ES10 (2019)ES2019Array.flat / flatMapObject.fromEntries,可选逗号,trimStart / trimEnd
ES11 (2020)ES2020BigInt,动态 import(),可选链 ?.,空值合并 ??,全局 This 改进
ES12 (2021)ES2021逻辑赋值运算符 (&&=, `
ES13 (2022)ES2022顶级 await,类字段和私有方法,Error Cause
ES14 (2023)ES2023数组查找方法 (findLast / findLastIndex),Symbol 扩展,WeakRefs
ES15 (2024)ES2024临时变量暂不定,但继续小幅特性改进
ES.Next (2025+ )包含未来每年的新特性,如管道运算符、模式匹配、元组/记录类型等

模块化

ES.Next 模块 = 官方模块系统,让你可以安全地拆分、复用 JS 代码,用 export / import 替代老的 require

注意

以前 JavaScript 没有官方模块,大家用 UMD / AMD 或 Node.js 的 require

  • 例如之前 Node.js 里用 require('express') 引入 Express.js

ES.Next 模块 引入官方语法:

  • export:导出模块内部的函数或变量
  • import:在其他模块中使用导出的内容
概念说明
ES.Next最新 JS 标准,包含新语法和特性
模块 (Module)一个 JS 文件就是一个模块,封装自己的逻辑和变量
export从模块导出函数或变量,让其他模块可以使用
import在模块中导入其他模块导出的内容
作用域隔离模块内变量不会影响其他模块,可安全使用同名变量

**Name Export(命名导出)**及 Default Export(默认导出)

Named Export(命名导出)

  • 可以在一个模块里导出 多个函数、变量或类

  • 导入时可以选择重命名,也可以直接用原名

  • 导入语法:

    javascript
    // utils.js
    export function add(a, b) { return a + b; }
    export function sub(a, b) { return a - b; }
     
    // main.js
    import { add, sub as subtract } from './utils.js';
    console.log(add(2,3));      // 5
    console.log(subtract(5,2)); // 3

Default Export(默认导出)

  • 一个模块只能有一个默认导出

  • 导入时可以自定义名字,不受导出名字限制

  • 导出语法:

    javascript
    // math.js
    export default function multiply(a, b) { return a * b; }
     
    // main.js
    import mul from './math.js'; // 名字可以自己定义
    console.log(mul(2,3)); // 6

注意

特性Named ExportDefault Export
数量限制可以多个只能一个
导入时可重命名,可选择性导入名字自定义,不可选择性导入
导入方式{ name }自定义名字
接口清晰度低(可能被重命名混淆)
TypeScript 建议多个导出用它模块只有一个核心功能时用它
  • Named Export = 多个明确功能,接口清晰
  • Default Export = 单一核心功能,导入时名字可自定义
  • 即使你倾向使用 Named Export,也必须了解 Default Export 的语法
  • 很多第三方库仍然使用 Default Export

声明变量

虽然很多人说“不要用 var”,它不是完全过时,你仍然需要理解它的行为,才能在不同场景下选择正确的变量声明方式。

注意

javascript
// var 作用域
function testVar() {
    if (true) {
        var x = 10;  // 函数作用域
    }
    console.log(x); // 10 (x 在整个函数内都可访问)
}
 
// let / const 作用域
function testLetConst() {
    if (true) {
        let y = 20;  // 块作用域
        const z = 30; // 块作用域
    }
    console.log(y); // 报错,y 不在作用域内
    console.log(z); // 报错,z 不在作用域内
}
 
声明方式作用域是否可重新赋值建议使用场景
var函数作用域可以了解即可,老代码可能还在用
let块作用域可以常规可变变量
const块作用域不可常量或引用不变的对象

变量提升(Hoisting)

var 会变量提升,letconst 不会。

其他语言(Java、C)不能在声明前使用变量,而 JS 因 Hoisting 可以。

javascript
function scope() {
    foo = 1; // 赋值
    var foo; // 声明
}
 
// 等价于
 
function scope() {
    var foo;  // 声明被提升
    foo = 1;  // 赋值
}
javascript
var globalVar = "global"; // 全局变量
 
function scope() {
    var foo = "1";  // 函数作用域
    if (true) {
        var bar = "2"; // 块作用域无效,仍属于函数作用域
    }
    console.log(globalVar); // "global"
    console.log(window.globalVar); // "global"
    console.log(foo); // "1"
    console.log(bar); // "2"
}
scope();
 
  • bar 虽然在 if 块里,但也被提升到函数顶部,可以在函数内任意位置访问

  • foobar 都是函数作用域,不受块作用域限制

  • 全局 var 会挂到 window(浏览器)或 global(Node.js)上

  • 如果是 let bar,则会报错。

箭头函数

基本写法:

javascript
const traditional = function(x) {
  return x * x;
}
 
const arrow = (x) => {
  return x * x;
}

简洁写法:

如果只有一个参数和一个返回值

  • 可以省略圆括号、花括号和 return
javascript
const conciseBody = x => x * x;

词法作用域(Lexical Scope)

传统函数 vs 箭头函数

  • 传统函数
    • this 指向调用函数的对象
    • 可能被改变(例如用作回调时)
  • 箭头函数
    • 不绑定调用对象
    • this 的值由定义函数时所在的外层作用域决定
    • 所以 this 更直观、少出错
javascript
this.scope = "lexical scope";
 
const scopeOf = {
    scope: "defining scope",
    traditional: function() {
        return this.scope;
    },
    arrow: () => {
        return this.scope;
    }
};
 
console.log(scopeOf.traditional()); // 输出 "defining scope"
console.log(scopeOf.arrow());       // 输出 "lexical scope"

注意

  1. scopeOf.traditional()
    • this 指向调用它的对象 scopeOf
    • 所以返回 scopeOf.scope = "defining scope"
  2. scopeOf.arrow()
    • 箭头函数的 this定义时的外层作用域决定
    • 外层是全局对象(这里的 this.scope = "lexical scope"
    • 所以返回 "lexical scope"

核心:箭头函数的 this 不会被调用环境改变,而是固定在定义时的作用域

特性传统函数箭头函数
语法function(x){}(x) => {}x => x * x
简洁性冗长更短,Concise Body 可省略花括号和 return
this 指向调用对象定义时的外层作用域(lexical)
使用场景普通函数、对象方法回调函数、箭头函数避免 this 混乱

箭头函数非常适合写回调函数:简洁、清晰、可读性高,尤其是逻辑简单时:

javascript
let numbers = [-2, -1, 0, 1, 2];
 
// 传统函数写法
let traditional = numbers.filter(function(num) {
    return num >= 0;
});
 
// 箭头函数写法(Concise Body)
let arrow = numbers.filter(num => num >= 0);
 
console.log(traditional); // [0,1,2]
console.log(arrow);       // [0,1,2]

注意

  • filterArray.prototype 上的方法作用:生成一个新数组,包含所有满足 callback 返回值为 true 的元素

  • 所有数组实例都继承了 filter

创建字符串

模板字符串

javascript
let a = 1;
let b = 2;
let string = `${a} + ${b} = ${a + b}`;
console.log(string); // 输出 "1 + 2 = 3"

Tagged Template Literals(带标签模板字符串)

javascript
function tag(literal, ...values) {
    console.log("literal", literal); // ['What is ', ' plus ', '?']
    console.log("values", values);   // [1, 2]
 
    let result;
    switch (literal[1]) {
        case " plus ":
            result = values[0] + values[1];
            break;
        case " minus ":
            result = values[0] - values[1];
            break;
    }
    return `${values[0]}${literal[1]}${values[1]} is ${result}`;
}
 
let a = 1;
let b = 2;
let output = tag`What is ${a} plus ${b}?`;
console.log(output); // 输出 "1 plus 2 is 3"

注意

literal 数组 = 模板字符串被变量切开的部分

javascript
['What is ', ' plus ', '?']

values 数组 = 模板里变量的值

javascript
[1, 2]

标签函数用 literal + values 生成新的字符串。

可以做更复杂操作(例如计算、格式化、过滤敏感词等)

类型功能用途
Untagged插入变量或表达式普通字符串拼接,多行字符串
Tagged可以在回调函数里处理模板 + 变量,返回任意值构建自定义逻辑、DSL、复杂字符串处理

异步编程(Asynchronous Programming)

注意

JavaScript 是单线程的

  • 单线程 = 一次只能执行一个任务
  • 如果一个任务很耗时(比如读取文件、请求网络),会 阻塞整个程序
  • 用户界面可能无法响应,程序显得“卡住”

解决方法:异步编程(Asynchronous Programming)

  • 异步 = 不阻塞主线程
  • 思路:发起耗时操作 → 等待结果 → 结果回来后再处理
  • 在等待期间,程序可以继续处理其他操作(UI、计算等)

传统异步方式:回调函数(Callback)

  • 回调 = 把函数作为参数传给另一个函数
  • 当耗时操作完成时,这个回调函数会被执行
javascript
const fs = require("fs");
 
const callback = (err, data) => {
    if (err) {
        return console.log("error");
    }
    console.log(`File content: ${data}`);
};
 
fs.readFile("file.txt", callback);

问题:回调地狱(Callback Hell)

  • 当异步操作依赖多个回调时,会产生嵌套层层的函数

  • 代码难读、容易出错

javascript
doSomething(data1, () => {
    doSomethingElse(data2, () => {
        doAnotherThing(data3, () => {
            // 回调层层嵌套
        });
    });
});

使用 Promiseasync / await 避免回调地狱:

javascript
// Promise
fs.promises.readFile("file.txt")
  .then(data => console.log(data))
  .catch(err => console.log("error"));
 
// async/await
async function readFile() {
    try {
        let data = await fs.promises.readFile("file.txt");
        console.log(data);
    } catch (err) {
        console.log("error");
    }
}

注意

异步编程让耗时任务不阻塞 JS 主线程,回调函数是传统方法,但现代 JS 更推荐用 Promise 或 async/await。

注意

fetch 是浏览器和现代 JS 环境提供的内置函数

作用:发送网络请求(HTTP 请求)并获取响应

返回一个 Promise,所以可以用 .then()async/await 来处理异步结果。

使用 Promise 处理 fetch 响应:

注意

Promise = 异步任务的承诺

  • 不会立即返回结果,而是“承诺”以后会返回
  • 可以处理成功或失败的结果

状态(state)

  1. Pending(等待中) → 初始状态,结果未知
  2. Fulfilled(已完成) → 成功,result = 返回值
  3. Rejected(已拒绝) → 失败,result = 错误对象
javascript
function fetchData(url) {
    fetch(url)
        .then((response) => response.json()) // 数据解析
        .then((json) => console.log(json))   // 处理结果
        .catch((error) => console.error(`Error: ${error}`)); // 错误处理
}
 
fetchData("https://www.usemodernfullstack.dev/api/v1/users");

使用 async/await 处理 fetch 响应:

javascript
async function fetchData(url) {
    try {
        const response = await fetch(url);      // 等待 fetch 完成
        const json = await response.json();     // 等待解析 JSON
        console.log(json);
    } catch (error) {
        console.error(`Error: ${error}`);
    }
}
 
fetchData("https://www.usemodernfullstack.dev/api/v1/users");

注意

特性Promiseasync/await
写法链式 .then().then()像同步函数一样写
错误处理.catch()try...catch
可读性多层 then 链可能复杂清晰,易读,逻辑直观
适用场景简单异步链多个顺序异步操作

Promise 用于组织异步操作,链式 then/catch/finally;async/await 让异步代码像同步写法,更清晰易读,但仍需 try...catch 处理错误。

Array.map

注意

map数组的方法

用途:遍历数组的每一项并返回一个新数组

特点:

  1. 不会修改原数组
  2. 返回一个新数组,元素是回调函数处理后的结果
javascript
const newArray = originalArray.map(callback);
  • callback:一个函数,参数是数组的当前元素

  • 返回值:map 会把 callback 的返回值放入新数组

下面的代码让每个元素乘以 10:

javascript
const original = [1, 2, 3, 4];
const multiplied = original.map(item => item * 10);
 
console.log(`original array: ${original}`);    // [1,2,3,4]
console.log(`multiplied array: ${multiplied}`); // [10,20,30,40]
 

扩展运算符(Spread Operator)

  • 写法:三个点 ...
  • 作用*:把数组或对象“展开”,把它们的元素或属性复制到新的变量或新对象中
  • 语义:把集合里的内容拆开,像散开一样分配给新变量

将对象展开到变量:

javascript
let object = { fruit: "apple", color: "green" };
 
// 使用 spread 展开对象属性到变量
let { fruit, color } = { ...object };
 
console.log(`fruit: ${fruit}, color: ${color}`); // fruit: apple, color: green
 
color = "red";
console.log(`object.color: ${object.color}, color: ${color}`); 
// object.color: green, color: red

修改变量 color 不会影响原对象,因为 spread 会分配新的内存,而不是引用原对象。

javascript
let originalArray = [1,2,3];
let clonedArray = [...originalArray];  // 克隆数组
 
clonedArray[0] = "one";
clonedArray[1] = "two";
clonedArray[2] = "three";
 
console.log(`originalArray: ${originalArray}`); // [1,2,3]
console.log(`clonedArray: ${clonedArray}`);     // ["one","two","three"]

Exercise 2 使用现代 JS 扩展 Express.js

在空文件夹中创建 package.json

json
{
    "name": "sample-express",
    "version": "1.0.0",
    "description": "sample express server",
    "license": "ISC",
    "type": "module",
    "dependencies": {
        "express": "^4.18.2",
        "node-fetch": "^3.2.6"
    },
    "devDependencies": {}
}

安装:

shell
npm install

创建 routes.js,从 https://www.usemodernfullstack.dev/api/v1/users 获取 json 信息并转成 html:

javascript
import fetch from "node-fetch";
const routeHello = () => "Hello World!";
const routeAPINames = async () => {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data;
    try {
        const response = await fetch(url);
        data = await response.json();
    } catch (err) {
        return err;
    }
    const names = data
        .map((item) => `id: ${item.id}, name: ${item.name}`).join("<br>");
    return names;
};
export { routeHello, routeAPINames };
webp

创建 index.js,调用 routes.js 导出的模块,并在 /api/names 中将信息提供给客户端:

javascript
import { routeHello, routeAPINames } from "./routes.js";
import express from "express";
const server = express();
const port = 3000;
server.get("/hello", function (req, res) {
    const response = routeHello(req, res);
    res.send(response);
});
server.get("/api/names", async function (req, res) {
    let response;
    try {
        response = await routeAPINames(req, res);
    } catch (err) {
        console.log(err);
    }
    res.send(response);
});
 
server.listen(port, function () {
    console.log(`Server running on port ${port}`);
});

查看 http://localhost:3000/api/names:

webp

JavaScript Tutorial 处学习 JS。

3 TypeScript

注意

TypeScript = JavaScript + 类型系统 + 编译阶段检查

TS 为 JS 添加了静态类型。TS 是 JS 的超集。

类型系统让编译器能够立即发现类型错误。

安装 TS 开发环境

shell
npm install --save-dev typescript

TS 必须经过 TSC 编译成 JS 才可以运行。下面的命令将会创建 tsconfig.json

shell
npx tsc -init

注意

如果在命令行执行 tsc,TypeScript 编译器会:

  1. 当前目录开始
  2. 查找是否存在 tsconfig.json
  3. 如果没有,就往父目录
  4. 一直向上找,直到文件系统根目录

这和很多工具很像,比如:

  • git.git
  • npmpackage.json

这样可以在子目录里执行 tsc,但仍然使用项目根目录的配置

注意

  • tsconfig.jsonMakefile / CMakeLists.txt

  • tsc编译器前端

  • -p指定构建配置文件路径

tsconfig.json

注意

tsconfig.json 是 TypeScript 项目的“编译配置入口文件”

  • 它告诉 tsc
    • 用什么规则编译
    • 编译哪些文件
    • 忽略哪些文件
  • 虽然配置项很多(~100 个),大多数项目只用到少数几个

大多数现代代码编辑器都支持 TypeScript,并且它们会直接在代码中显示 TSC 生成的错误。

类型注解

类型注解不是越多越好,而是要用在“边界和契约”上。

声明变量时

typescript
const x: number = 5;
const y: string = "hello";

不是一个好的写法,会:

  • 增加视觉噪音

  • 没有增加任何安全性

  • 让代码更难读

除非初始化为 null:

typescript
let value: number | null = null;

函数返回值

typescript
function getWeather(): string {
    const weather = "sunny";
    return weather;
}

声明函数形参

typescript
const weather = "sunny";
function getWeather(weather: string): string {
    return weather;
};
getWeather(weather);

注意

位置是否推荐写类型
函数参数✅ 必须
函数返回值⚠️ 视情况
局部变量❌ 通常不需要

类型

JS 中有以下原始类型:

javascript
let stringType: string = "bar";
let booleanType: boolean = true;
let integerType: number = 1;
let floatType: number = 1.5;
let nullType: null = null;
let undefinedType: undefined = undefined;

TypeScript 类型总览

一、基础类型(Primitive Types)

类型含义示例
number数值(整数/浮点数)let n: number = 1;
string字符串let s: string = "hi";
boolean布尔值let b: boolean = true;
bigint大整数let x: bigint = 123n;
symbol唯一标识const id: symbol = Symbol();
null空值let v: null = null;
undefined未定义let u: undefined = undefined;

二、引用类型(Reference Types)

类型含义示例
object非原始类型let o: object = {};
Array<T> / T[]数组let arr: number[] = [1,2];
tuple固定长度数组let t: [number, string] = [1, "a"];
function函数类型(a: number) => number

三、字面量与联合类型(Literal & Union)

类型含义示例
字面量类型固定取值`type Dir = "up"
联合类型 ``多选一
交叉类型 &类型合并type A = B & C;

四、接口与类型别名(Interface & Type)

类型用途示例
interface描述对象结构interface User { id: number }
type任意类型组合type User = { id: number }
可选属性非必需字段age?: number
只读属性不可修改readonly id: number

五、枚举与类似结构(Enum-like)

类型含义示例
enum枚举enum Status { Idle, Busy }
字面量联合枚举替代`"idle"
const enum编译期内联const enum Mode { A, B }

六、泛型(Generics)

类型含义示例
泛型函数类型参数化function id<T>(x: T): T
泛型接口通用结构interface Box<T>
泛型约束限制范围<T extends User>

七、特殊类型(Top / Bottom / Escape)

类型含义说明
any放弃类型检查不推荐
unknown安全的 any需类型缩小
void无返回值常用于函数
never不可能发生用于穷尽检查
this当前上下文类型类/方法中

八、类型工具(Utility Types,常用)

工具类型作用示例
Partial<T>所有属性可选Partial<User>
Required<T>所有属性必选Required<User>
Readonly<T>属性只读Readonly<User>
Pick<T,K>选取字段Pick<User,"id">
Omit<T,K>排除字段Omit<User,"age">
Record<K,T>键值映射Record<string, number>

九、类型推导与收窄(Inference & Narrowing)

特性含义示例
类型推导自动推类型const x = 1;
类型守卫条件收窄typeof x === "string"
可辨识联合带 tag 的 union{ kind: "a" }

自定义类型及接口

type

使用 type 自定义类型:

typescript
type WeatherDetailType = {
  weather: string;
  zipcode: string;
  temp?: number;
};

注意

  • 使用 type 关键字

  • 使用 = 赋值定义

  • 对象结构语法和普通对象几乎一样

  • ? 表示 可选属性

如果不用自定义类型,代码将变得冗长且不可复用:

typescript
const getWeatherDetail = (
  data: {
    weather: string;
    zipcode: string;
    temp?: number;
  }
) => {
  return data;
};

interface

typescript
interface WeatherProps {
    weather: string;
    zipcode: string;
    temp?: number;
}
 
const weatherComponent = (props: WeatherProps): string => props.weather;

注意

typeinterface 的边界并不严格,关键是团队约定和一致性。

TypeScript 官方也承认:

  • 二者能力高度重叠
  • 很多场景可以互换

不是完全一样,强调的是“使用语义上的区别”。

能用 interface 描述对象时,优先用 interface。

场景推荐
对象结构interface
对象的方法定义interface
React 组件 propsinterface
非对象(联合/元组)type

类型声明文件

注意

  • 文件扩展名:.d.ts

  • 不包含任何实现代码,只是“类型描述”

  • 不会被编译成 JS,只用于 TypeScript 类型检查

  • TSC 会读取它们来理解自定义类型或第三方库类型

Exercise 3 使用 TypeScript 扩展 Express.js

在之前的 Exercise 的基础上安装:

shell
npm install --save-dev @types/express

此时 package.json 将类似:

json
{
    "name": "sample-express",
    "version": "1.0.0",
    "description": "sample express server",
    "license": "ISC",
    "type": "module",
    "dependencies": {
        "express": "^4.18.2",
        "node-fetch": "^3.2.6"
    },
    "devDependencies": {
        "@types/express": "^5.0.6",
        "typescript": "^5.9.3"
    }
}
 

创建/编辑 tsconfig.json

json
{
  "compilerOptions": {
    // ------------------------------
    // 输出 / 文件布局
    // ------------------------------
    "rootDir": "./src",            // TS 源码根目录
    "outDir": "./dist",            // 编译后 JS 输出目录
    "sourceMap": true,             // 生成 source map
    "declaration": true,           // 生成 .d.ts 类型声明文件
    "declarationMap": true,        // 生成 .d.ts 的映射文件
 
    // ------------------------------
    // 环境 / 模块设置
    // ------------------------------
    "module": "nodenext",          // Node.js ES 模块
    "target": "esnext",            // 编译目标现代 JS
    "moduleResolution": "node",    // Node 风格模块解析
    "esModuleInterop": true,       // CommonJS 模块兼容 ES6 import
    "verbatimModuleSyntax": true,  // 保留 TS 原生 module 语法
    "isolatedModules": true,       // 每个文件单独编译,防止依赖副作用
    "moduleDetection": "force",    // 强制 TS 当作模块解析
 
    // ------------------------------
    // 类型检查 / 严格模式
    // ------------------------------
    "strict": true,                        // 启用所有严格类型检查
    "noImplicitAny": true,                 // 禁止隐式 any
    "noUncheckedIndexedAccess": true,      // 对索引访问严格检查
    "exactOptionalPropertyTypes": true,    // 可选属性严格匹配
    "skipLibCheck": true,                  // 跳过声明文件类型检查,提高性能
    "noUncheckedSideEffectImports": true,  // 严格检查副作用导入
 
    // ------------------------------
    // JSX / React
    // ------------------------------
    "jsx": "react-jsx",   // 支持 React 17+ JSX 转换
 
    // ------------------------------
    // 类型声明
    // ------------------------------
    "types": ["node"]     // 指定全局类型,如 Node.js
  },
  "include": ["src/**/*"],   // 包含源码文件
  "exclude": ["node_modules", "dist"]  // 排除输出目录和依赖
}
 

注意

配置项作用为什么在 Express 项目里用
esModuleInterop: true允许默认导入 CommonJS 模块 (import express from "express")Express 是 CommonJS 模块,开启后可以用 ES 风格的 import
module: "es6"指定输出的模块系统我们希望编译后的 JS 依然是 ES6 模块
moduleResolution: "node"模块解析方式采用 Node.js 风格确保 import 能找到 node_modules 中的包
target: "es6"编译目标 JavaScript 版本输出 ES6 语法,使 Node.js 直接可运行
noImplicitAny: true禁止隐式 any 类型强制显式类型注解,提高类型安全

创建 custom.d.ts 以自定义一个类型:

typescript
type responseItemType = {
    id: string;
    name: string;
};
 
type WeatherDetailType = {
    zipcode: string;
    weather: string;
    temp?: number;
};
 
interface WeatherQueryInterface {
    zipcode: string;
}

将以前的 routes.js 改为 routes.ts。并修改其内容:

typescript
import fetch from "node-fetch";
 
// 普通同步函数
const routeHello = (): string => "Hello World!";
 
// 异步函数
const routeAPINames = async (): Promise<string> => {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data: responseItemType[];
    try {
        const response = await fetch(url);
        data = (await response.json()) as responseItemType[];
    } catch (err) {
        return "Error";
    }
    const names = data
        .map((item) => `id: ${item.id}, name: ${item.name}`)
        .join("<br>");
    return names;
};
 
// 天气查询
const routeWeather = (query: WeatherQueryInterface): WeatherDetailType =>
    queryWeatherData(query);
 
const queryWeatherData = (query: WeatherQueryInterface): WeatherDetailType => {
    return {
        zipcode: query.zipcode,
        weather: "sunny",
        temp: 35
    };
};
 
export { routeHello, routeAPINames, routeWeather };

将以前的 index.js 改为 index.ts。并修改其内容,这添加了一个查询天气的路由:

typescript
import express from "express";
import type { Request, Response } from "express";
import { routeHello, routeAPINames, routeWeather } from "./routes.js";
 
const server = express();
const port = 3000;
 
server.get("/hello", (_req: Request, res: Response): void => {
  const response = routeHello();
  res.send(response);
});
 
server.get("/api/names", async (_req: Request, res: Response): Promise<void> => {
  const response: string = await routeAPINames();
  res.send(response);
});
 
server.get("/api/weather/:zipcode", (req: Request, res: Response): void => {
  const zipcode = req.params.zipcode;
  if (!zipcode) {
    res.status(400).send({ error: "Missing zipcode" });
    return;
  }
 
  const response = routeWeather({ zipcode });
  res.send(response);
});
 
server.listen(port, (): void => {
  console.log("Listening on " + port);
});

编译并启动:

shell
npx tsc
node index.js

访问 http://localhost:3000/api/weather/4589,得到服务器返回的 json:

json
{"zipcode":"4589","weather":"sunny","temp":35}

可以从 TypeScript Tutorial 获得更多 TS 教程。

4 React

目前 40% 最受欢迎的网站都使用了 React。

  • 学 React 的关键是掌握 JSX 语法,即如何描述界面。

  • 学会将界面元素封装成 可动态更新的组件

  • 掌握这些之后,就能开始用 React 开发完整应用(前端 + 后端)。


  • 现代前端架构倾向于 将应用的界面拆分成小的、独立的、可复用的单元

  • 这些单元就是 组件 的概念,每个组件可以独立管理自己的显示和行为。

webp
  • React 推崇 组件化,把界面拆成小而独立的部分。

  • 不同组件有不同用途,有些单独出现,有些重复出现。

  • React 的语法和设计理念让你可以 高效创建、复用和组合这些组件,从而提高开发效率和可维护性。


  • 声明式编程:只描述界面想要的结果,而不是操作步骤。

  • 响应式组件:组件自管理状态,状态变化自动更新界面。

  • 虚拟 DOM:优化 DOM 更新,减少性能开销。

  • 单页应用(SPA)能力:React + Router 可以在浏览器端模拟多页应用,提高用户体验。

React 的开发环境与项目搭建方式

  • React 与普通 Node.js 项目区别

    • 普通的 Express.js 项目:用标准 JavaScript 写就可以直接运行在 Node.js 上。

    • React 项目:需要更复杂的开发环境和构建工具链。

      • 原因:React 使用 JSX(自定义 JavaScript 语法扩展)和 TypeScript(静态类型)。

      • 这些不能直接运行,需要 编译/转译(transpile) 成普通 JavaScript。

  • 手动搭建 React 非常复杂

    • 如果从零手动配置 webpack、Babel、TypeScript 等工具链,工作量很大。
    • 所以开发者通常使用 工具来自动生成项目结构和配置
  • 使用 create-react-app 脚手架

    • create-react-app(CRA) 是官方推荐的工具,用来快速生成 React SPA 项目的骨架。
    • 功能包括:
      1. 生成初始代码模板(boilerplate)
      2. 配置构建工具链(webpack、Babel、TypeScript 等)
      3. 创建项目文件夹结构
      4. 保证项目布局一致,方便理解其他 React 项目
  • 在线编辑

    • 如果不想创建本地项目,可以使用 在线编辑器
    • 它们提供 和 CRA 相同的文件结构,只需在默认的 App.tsx 文件写代码即可运行。
  • 高级应用:Next.js

    • 对于复杂的全栈应用,通常使用 Next.js
      • 提供 开箱即用的项目搭建和工具链
      • 内部实际上也是基于 CRA 的变体来生成项目
      • 可以用于服务器渲染、路由、API 等功能

JSX

JSX 是什么

  • JSX 是 React 的 语法扩展,用来描述组件的界面。
  • 外表上像 HTML,但本质不是字符串或模板,而是 JavaScript 表达式
  • 可以在 JSX 中使用任意 JavaScript,例如:
    • 条件语句(if / ternary)
    • 循环(array.map)
    • 将 JSX 赋值给变量
    • 从函数返回 JSX

关键点: JSX 最终会被 转译器(transpiler) 转成普通 JavaScript,再渲染成浏览器的 HTML。


在 JSX 中使用 {} 可以嵌入 JavaScript 表达式

例子:

jsx
import React from "react";
 
export default function App() {
    const getElement = (weather: string): JSX.Element => {
        const element = <h1>The weather is {weather}</h1>;
        return element;
    };
    return getElement("sunny");
}

渲染后:

html
<h1>The weather is sunny</h1>

ReactDOM

ReactDOM 的作用

  • ReactDOM 是 React 提供的一个包,用于操作 DOM。
  • 它提供了 API,例如:
    • ReactDOM.createRoot()
    • ReactDOM.render()
  • 通过 ReactDOM,你可以把 React 元素渲染到浏览器的页面中。

React 元素不是浏览器 DOM 元素

  • React 创建的元素 不是直接的 HTML DOM 元素
  • 它们是 普通 JavaScript 对象(虚拟 DOM 节点)。
  • React 通过 render 函数把这些对象渲染到虚拟 DOM,然后再同步到真实 DOM。

React 元素不可变(Immutable)

  • 不可变性:React 元素一旦创建就不能直接修改。

  • 如果你想改变元素(比如改变文本或属性),React 会:

    • 创建一个 新的 React 元素

    • 重新渲染到 虚拟 DOM

    • 对比虚拟 DOM 和真实 DOM

    • 决定是否需要更新浏览器 DOM

这种机制保证了 React 的性能优化和界面的一致性。

JSX 与 ReactDOM

  • JSX 是语法糖,最终会被转换成 React 元素,通过 ReactDOM 渲染到浏览器。

函数组件

元素与组件

  • React 元素(React Elements):普通 JavaScript 对象,可以包含其他元素;渲染后生成 DOM 节点或 DOM 子树。

  • React 组件(React Components):函数或类,用来生成 React 元素并渲染到虚拟 DOM。

  • 核心区别:元素是数据(描述界面),组件是生成元素的逻辑单元。


组件化与逻辑封装

  • 传统前端框架按技术分离:HTML / CSS / JS

  • React 按 逻辑单元分离:一个组件文件里通常包含:

    • JSX(界面结构)

    • 样式(CSS-in-JS 或导入 CSS)

    • 逻辑(事件处理、状态、props)

  • 优点:每个组件自包含,便于重用和维护。


函数组件与 props

  • React 组件通常是 函数,首字母大写
  • props:父组件传入的只读属性,用来传递数据
  • 不可修改:props 在组件内部是 不可变的(immutable),如果需要更新数据,应由父组件或状态管理器处理

TypeScript 接口 + 组件属性

  • 通过 自定义接口(interface) 可以给 props 定义类型,TypeScript 会进行类型检查。

  • 例子:传入天气字符串 weather

tsx
interface WeatherProps {
  weather: string;
}
 
const WeatherComponent = (props: WeatherProps) => {
  return <h1>The weather is {props.weather}</h1>;
};
  • 父组件可以传不同数据,例如 API 返回的天气数据,通过属性传给子组件。

处理用户交互

在 JSX 中处理事件类似 HTML:

  • HTML:<button onclick="doSomething()">Click</button>
  • React:<button onClick={doSomething}>Click</button>

可以在组件内部响应用户操作,例如点击按钮更新状态或调用函数。


完整示例

tsx
import React from "react";
 
export default function App() {
    // 定义接口
    interface WeatherProps {
        weather: string;
	}
 
    // 定义事件处理函数
    const clickHandler = (text: string): void => {
        alert(text);
    };
    
    // 定义组件
    const WeatherComponent = (props: WeatherProps): JSX.Element => {
        const text = `The weather is ${props.weather}`;
        return (<h1 onClick={() => clickHandler(text)}>{text}</h1>);
    };
    
    // 传参,渲染组件
    return (<WeatherComponent weather="sunny" />);
}

注意

概念说明
Props父组件传递给子组件的数据,组件内部不可修改(immutable)
JSX 元素<h1>{text}</h1>,动态显示内容,通过 {} 嵌入 JS 表达式
TypeScript 接口用于约束 props 类型,保证类型安全
事件处理onClick={() => clickHandler(text)},通过箭头函数传参
渲染组件<WeatherComponent weather="sunny" />,父组件传入数据,子组件显示并响应事件

类组件

注意

特性函数组件(Function Component)类组件(Class Component)
编程风格函数式编程,类似纯函数面向对象编程
状态管理通过 useState Hook通过 this.state
生命周期方法使用 Hook(如 useEffect内置生命周期方法(componentDidMountcomponentDidUpdate 等)
this 关键字不用使用 this 引用组件实例
复杂性简单较复杂,需要 constructor、super、render 等
tsx
import React from "react";
 
export default function App() {
    // 定义一个接口
    interface WeatherProps {
        weather: string;
    }
    // 定义一个类型别名
    type WeatherState = {
        count: number;
    };
    // 定义类组件
    class WeatherComponent extends React.Component<WeatherProps, WeatherState> {
        constructor(props: WeatherProps) {
            super(props);
            this.state = {
                count: 0
            };
        }
        // 生命周期方法
        // componentDidMount:组件挂载到 DOM 后调用
        componentDidMount() {
            this.setState({ count: 1 });
        }
        // 点击事件处理
        clickHandler(): void {
            this.setState({ count: this.state.count + 1 });
        }
        // 渲染
        render() {
            return (
                <h1 onClick={() => this.clickHandler()}>
                    The weather is {this.props.weather}, and the counter shows{" "}
                    {this.state.count}
                </h1>
            );
        }
    }
    // 渲染组件到页面
    return (<WeatherComponent weather="sunny" />);
}
  • interface 更面向 组件外部的契约/共享结构type 更面向 内部状态和逻辑复杂类型

  • props 常用 interface

  • state 常用 type

注意

  • 函数组件 = 纯函数 → props 输入 → JSX 输出

  • 类组件 = 对象 → props + state → JSX 输出 + 生命周期方法

  • 点击事件 → 修改 state → 自动 re-render → 页面更新

用 Hooks 在函数组件中实现可复用行为

tsx
import React, { useState,useEffect } from "react";
 
export default function App() {
    interface WeatherProps {
        weather: string;
    }
    const WeatherComponent = (props: WeatherProps): JSX.Element => {
        // useState: 替代 this.state
        const [count, setCount] = useState(0);
        // useEffect: 替代生命周期方法
        useEffect(() => {setCount(1)},[]);
        return (
            // 事件处理
            <h1 onClick={() => setCount(count + 1)}>
                The weather is {props.weather},
                and the counter shows {count}
            </h1>
        );
    };
    return (<WeatherComponent weather="sunny" />);
}
  • useState 是一个 Hook,用于在函数组件中添加状态(state)。

    返回一个 数组

    1. count → 当前状态值
    2. setCount → 修改状态的函数
  • useEffect 可以在函数组件中执行副作用(effect),类似类组件的生命周期方法。

    第二个参数 依赖数组

    • [] → 只在组件挂载时执行(componentDidMount)
    • [count] → 当 count 变化时执行

    例子中,挂载后将计数器初始化为 1。

注意

特性类组件(Listing 4-3)函数组件 + Hooks(Listing 4-4)
状态管理this.state / this.setStateuseState
生命周期方法componentDidMountuseEffect
事件处理单独定义 clickHandler 方法内联箭头函数
语法复杂性constructor + super + render简洁函数式语法
可读性与可维护性较繁琐高,可读性强
多个状态变量需要在 state 对象中维护多个字段每个 useState 独立管理状态
复用逻辑高度依赖 HOC 或类继承可用自定义 Hooks 复用逻辑

函数组件 + Hooks = 更简洁、更可复用、更现代的 React 组件写法,完全可以替代类组件。

React 官方推荐函数组件 + Hooks,类组件不再更新新特性。

Hook功能类组件对应
useState管理局部 statethis.state / this.setState
useEffect处理副作用、生命周期逻辑componentDidMount / componentDidUpdate / componentWillUnmount
useContext跨组件共享数据Context.Consumer 或类组件 static contextType
自定义 Hooks封装可复用逻辑高阶组件 HOC 或 render props

使用 Context 和 useContext 来共享全局数据

注意

  • 函数组件理想情况下是纯函数,只依赖 props

  • 有些情况需要跨多层组件共享状态(如主题、用户 session、语言设置)。

  • 传统做法需要层层传递 props,非常麻烦 → Context 出现来解决这个问题。

tsx
import React, { useState, createContext, useContext } from "react";
 
export default function App() {
    // 初始化 Context 对象 ThemeContext,默认值为空字符串 ""
    const ThemeContext = createContext("");
    // 父组件:提供 Context
    const ContextComponent = (): JSX.Element => {
        const [theme, setTheme] = useState("dark");
        
        return (
            <div>
                <!-- 包裹子组件,提供 Context 值 -->
                <ThemeContext.Provider value={theme}>
                    <!-- button 点击时切换主题 → 更新 theme -->
                    <button onClick={() => setTheme(theme == "dark" ? "light" : "dark")}>
                        Toggle theme
                    </button>
                    <Headline />
                </ThemeContext.Provider>
            </div>
        );
    };
    // 子组件:消费 Context
    const Headline = (): JSX.Element => {
        // 读取父组件提供的主题值
        const theme = useContext(ThemeContext);
        return (<h1 className={theme}>Current theme: {theme}</h1>);
    };
    return (<ContextComponent />);
}

注意

概念类比
ThemeContext.Provider“水塔”
value={theme}“水的高度”
useContext(ThemeContext)“水管”
父组件(Provider)提供水
子组件(useContext)实际用水

性能开销大:useContext 会触发所有消费该 Context 的子组件重新渲染

  • 不适合频繁变化的数据(如滚动位置、输入框内容)

推荐场景

  • 主题、配色、语言
  • 用户 session / 权限信息
  • 跨组件共享的只读配置

Exercise 4 为 Express.js 提供 React 前端(实验性)

如此构建文件结构:

project/
│
├─ package.json
├─ index.js
└─ public/
    └─ weather.html   <-- React 示例文件

package.json

json
{
    "name": "sample-express",
    "version": "1.0.0",
    "description": "sample express server",
    "license": "ISC",
    "type": "module",
    "dependencies": {
        "express": "^4.18.2",
        "node-fetch": "^3.2.6"
    },
    "devDependencies": {
        "@types/express": "^5.0.6",
        "typescript": "^5.9.3"
    }
}

实验性地在 HTML 中引入 React(但是不要在生产环境下这么做):

html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
		<title>Weather Component</title>
        <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
        <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
        <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    </head>
    <body>
        <div id="root"></div>
        <script type="text/babel">
            function App() {
const WeatherComponent = (props) => {
                    const [count, setCount] = React . useState(0);
React . useEffect(() => {
                        setCount(1);
                    }, []);
                    return (
                        <h1 onClick={() => setCount(count + 1)}>
                            The weather is {props.weather}, 
                            and the counter shows {count}
                        </h1>
                    );
                };
                return (<WeatherComponent weather="sunny" />);
            }
            const container = document.getElementById("root");
            const root = ReactDOM.createRoot(container);
            root.render(<App />);
        </script>
    </body>
</html>

index.ts 中定义路由 /components/weather,直接返回 public/weather.html 整个文件:

typescript
import { routeHello, routeAPINames, routeWeather } from "./routes.js";
import express from "express";
import type { Request, Response } from "express";
import path from "path";
 
const server = express();
const port = 3000;
 
server.get("/components/weather", function (req: Request, res: Response): void {
    const filePath = path.join(process.cwd(), "public", "weather.html");
    res.setHeader("Content-Type", "text/html");
    res.sendFile(filePath);
});
 
server.listen(port, function (): void {
    console.log("Listening on " + port);
});

编译并启动服务器:

shell
npx tsc
node index.js

访问 http://localhost:3000/components/weather:

webp

5 Next.js

React 主要负责 “视图层(View)”,它擅长:组件、状态、交互。

React 不负责路由(页面跳转)、服务端接口、构建、部署、SSR / SEO。因此 React 本身并不是“框架”。

Next.js = React + 一整套工程能力

Next.js **简化(streamlines)**了:

  • 前端(frontend)
  • 中间层(middleware)
  • 后端(backend)

Quick Start

创建一个使用 TS,不使用 APP Router 模式的 Next.js 项目:

shell
mkdir sample-next
cd ./sample-next
npx create-next-app@latest --typescript --use-npm --no-app

然后全选 NO,一路回车。

webp

之后:

shell
cd my-app
npm run dev
> my-app@0.1.0 dev
> next dev

▲ Next.js 16.1.1 (Turbopack)
- Local:         http://localhost:3000
- Network:       http://192.168.0.101:3000

✓ Starting...
✓ Ready in 6.6s
○ Compiling / ...
 GET / 200 in 4.1s (compile: 4.0s, render: 64ms

观察目录结构:

my-app/
├─ pages/
│  ├─ index.tsx
│  ├─ _app.tsx
│  └─ api/
├─ styles/
├─ public/

其中:

  • public/:用来放 静态资源
  • styles/:全局 CSS(如 globals.css)和CSS Modules(.module.css,样式只作用于某个组件,不污染全局)
  • pages/:路由
    • _app.tsx:整个应用的入口、所有页面都会经过这里

注意

相关命令:

命令场景是否开发是否需要先 build
npm run dev本地开发
npm run build生成生产构建
npm run start生产运行
npm run export纯静态站点

路由

Express.js 中需要手写路由:

  • URL 写在代码里

  • 行为写在函数里

  • 路由表 = 代码逻辑

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

Next.js 的思想:文件即路由

只要在 pages/ 目录下:

  • 放文件
  • 导出东西

如创建 pages/hello.tsx 然后访问 http://localhost:3000/hello:

tsx
import type { NextPage } from "next";
 
const Hello: NextPage = () => {
    return (<>Hello World!</>);
}
 
export default Hello;
webp

Next.js 中,嵌套路由 = 子目录。

创建 pages/compoents/weather.tsx 并访问 http://localhost:3000/components/weather:

tsx
import type { NextPage } from "next";
import React, { useState, useEffect} from "react";
 
const PageComponentWeather: NextPage = () => {
    interface WeatherProps {
        weather: string;
    }
    const WeatherComponent = (props: WeatherProps) => {
        const [count, setCount] = useState(0);
        useEffect(() => {
            setCount(1);
        }, []);
        return (
            <h1 onClick={() => setCount(count + 1)}>
                The weather is {props.weather},
                and the counter shows {count}
            </h1>
        );
    };
    return (<WeatherComponent weather="sunny" />);
};
 
export default PageComponentWeather;

页面文件必须默认导出一个 NextPage

webp

API Routes

Next.js 在同一个项目中同时做页面和后端 API。

注意

一个全栈应用通常还需要给“程序”用的接口(API),比如:

  • 移动 App
  • 第三方 Widget
  • 其他服务

这就是 machine-readable interface

常见 API 形式:

  • REST(本章用,简单直观)
  • GraphQL(下一章详细讲

Next.js 中的 API Routes 仍然是“文件即路由”。

创建 pages/api/names.ts 并访问 http://localhost:3000/api/names 即可得到后端返回的 json 数据:

typescript
import type { NextApiRequest, NextApiResponse } from "next";
 
type responseItemType = {
    id: string;
    name: string;
};
 
export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
): Promise<NextApiResponse<responseItemType[]> | void> {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data;
    try {
        const response = await fetch(url);
        data = (await response.json()) as responseItemType[];
    } catch (err) {
        return res.status(500);
    }
    const names = data.map((item) => {
        return { id: item.id, name: item.name };
    });
    return res.status(200).json(names);
}
json
[{"id":1,"name":"Olivia Smith"},{"id":2,"name":"Liam Johnson"},{"id":3,"name":"Noah Williams"},{"id":4,"name":"Emma Brown"},{"id":5,"name":"Oliver Jones"},{"id":6,"name":"Charlotte Garcia"},{"id":7,"name":"Elijah Miller"},{"id":8,"name":"Amelia Davis"},{"id":9,"name":"James Rodriguez"},{"id":10,"name":"Ava Martinez"}]
webp

动态 URL

动态 URL = URL 中的一部分是变量

在 Express.js 中,/api/weather/:zipcode 就是一个动态路由。:zipcode 通过 req.params.zipcode 来读取。

创建 pages/api/weather/[zipcode].ts,之后访问 http://localhost:3000/api/weather/1234:

typescript
import type { NextApiRequest, NextApiResponse } from "next";
 
type WeatherDetailType = {
    zipcode: string;
    weather: string;
    temp?: number;
};
 
export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
): Promise<NextApiResponse<WeatherDetailType> | void> {
    return res.status(200).json({
        zipcode: req.query.zipcode,
        weather: "sunny",
        temp: 35
    });
}

样式

注意

特性TSX / JSXVue
JS / TS 逻辑
HTML 模板✅(JSX/TSX 语法)✅(template 标签)
CSS❌ 必须分开 / CSS-in-JS✅ 内置 <style>
语法风格React 风格,HTML 标签在 JS 中写模板 + 指令,模板和逻辑分区明确
数据绑定{} 表达式{{}} 插值 + 指令 v-if, v-for

Next.js 中的样式分为 Global Styles 和 CSS Moudles:

类型作用范围是否会污染全局
Global Styles整个应用✅ 会
CSS Modules单个组件❌ 不会

Global Styles

观察 pages/_app.tsx

tsx
import "@/styles/globals.css";
import type { AppProps } from "next/app";
 
export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}
  • _app.tsx 是整个应用的入口

  • 全局 CSS 只能在入口引入

  • 不能在普通组件里 import "xxx.css"

注意

Global CSSCSS Modules
globals.cssxxx.module.css
全局生效只在当前组件
class 不变class 会被 hash
适合 reset / theme适合组件

Component Styles

创建(覆盖) styles/Home.module.css

css
.container {
    padding: 0 2rem;
    color: red;
}

创建(覆盖)pages/index.tsx

tsx
import type { NextPage } from "next";
import styles from "@/styles/Home.module.css";
 
const Home: NextPage = () => {
    return (
        <div className={styles.container}>Hello World!</div>
    );
};
 
export default Home;

http://localhost:3000/:

webp

内置组件(Built-in Components)

Next.js 提供了一些自带的 React 组件,用来解决常见的需求:

注意

组件主要作用
next/head修改 <head> 标签里的内容,比如 <title><meta>,方便 SEO 优化
next/image图片优化,自动处理大小、延迟加载、WebP 等,提高性能
next/link路由跳转组件,增强前端页面切换体验(客户端路由)

使用 next/headnext/imagenext/link,修改(覆盖)pages/hello.tsx

tsx
import type { NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import Image from "next/image";
 
const Hello: NextPage = () => {
    return (
        <div>
            <Head>
                <title>Hello World Page Title</title>
                <meta property="og:title" content="Hello World" key="title" />
            </Head>
            <div>Hello World!</div>
            <div>
                Use the HTML anchor for an <a href="https://nostarch.com">
                    external link</a> and the Link component for an
                <Link href="/components/weather"> internal page</Link>
                <Image
                    src="/vercel.svg"
                    alt="Vercel Logo"
                    width={72}
                    height={16}
                />
            </div>
        </div>
    );
};
export default Hello;

之后访问 http://localhost:3000/hello。

预渲染(Pre-rendering)

注意

**预渲染(Pre-rendering)**是指 Next.js 在页面被请求之前,先生成 HTML 的过程。Next.js 提供三种方式:

方法生成时机特点使用场景
Server-Side Rendering (SSR)请求时(per request)每次请求生成新的 HTML需要实时数据的页面,如用户仪表板
Static Site Generation (SSG)构建时(build time)HTML 静态生成,所有请求返回同一内容博客、文档、固定内容页面
Incremental Static Regeneration (ISR)构建后 + 定时更新结合 SSG + SSR:先静态生成,后台可增量更新内容大部分不变但偶尔更新的页面

创建 utils/fetch-names.ts 用于后续实验:

typescript
type responseItemType = {
    id: string;
    name: string;
};
 
export const fetchNames = async () => {
    const url = "https://www.usemodernfullstack.dev/api/v1/users";
    let data: responseItemType[] | [] = [];
    let names: responseItemType[] | [];
    try {
        const response = await fetch(url);
        data = (await response.json()) as responseItemType[];
    } catch (err) {
        names = [];
    }
    names = data.map((item) => { return { id: item.id, name: item.name }});
    return names;
};

Server-Side Rendering (SSR)

注意

定义:每次用户请求页面时,Next.js 内置的 Node.js 服务器都会 动态生成 HTML 并返回给客户端。

特点

  1. 页面内容总是最新的,因为每次请求都会重新生成 HTML。
  2. 对需要实时数据的页面非常有用(例如新闻、用户仪表盘、股票行情)。
  3. 缺点:比静态生成(SSG)慢,因为每次请求都要执行服务器端逻辑和 API 调用,HTML 不能轻易缓存。

创建 page/names-sst.tsx

tsx
import type {
    GetServerSideProps,
    GetServerSidePropsContext,
    InferGetServerSidePropsType,
        NextPage,
        PreviewData
} from "next";
import { ParsedUrlQuery } from "querystring";
import { fetchNames } from "../utils/fetch-names";
 
type responseItemType = {
    id: string;
    name: string;
};
 
const NamesSSR: NextPage = (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {
    const output = props.names.map((item: responseItemType, idx: number) => {
        return (
            <li key={`name-${idx}`}>
              {item.id} : {item.name}
            </li>
        );
    });
    return (
        <ul>
            {output}
        </ul>
    );
};
 
export const getServerSideProps: GetServerSideProps = async (
    context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
) => {
    let names: responseItemType[] | [] = [];
    try {
        names = await fetchNames();
    } catch(err) {}
    return {
        props: {
            names
        }
    };
};
 
export default NamesSSR;

注意

在 Next.js 中,启用 SSR 的关键是 导出一个 getServerSideProps 异步函数

typescript
export const getServerSideProps: GetServerSideProps = async (context) => {
    const names = await fetchNames();  // 调用 API 或其他数据源
    return {
        props: { names },  // 这些 props 会传给页面组件
    };
};

Next.js 在每次请求页面时都会调用 getServerSideProps

getServerSideProps 返回的数据会作为 props 传入页面组件。

页面组件使用这些 props 渲染 JSX。

浏览器最终收到的是 完整的 HTML(预渲染),无需额外的客户端渲染才能显示内容。

之后访问 http://localhost:3000/names-ssr:

webp

Static Site Generation (SSG)

注意

定义:在 构建时(build time) 生成 HTML 文件,然后所有用户请求都会 直接返回这些静态 HTML

特点

  1. 快速:因为 HTML 是预生成的,可以直接缓存或通过 CDN 分发。
  2. SEO 优势:页面内容完整且快速呈现,提高 Google SEO 排名。
  3. 低时间指标
    • Time to First Paint (TTFP):用户点击链接到页面内容显示的时间短。
    • Blocking Time:用户能交互的时间短。
  4. 适合静态数据:如果页面内容不依赖实时更新数据,非常适合 SSG。

创建 pages/ames-ssg.tsx

tsx
import type {
GetStaticProps,
    GetStaticPropsContext,
    InferGetStaticPropsType,
    NextPage,
    PreviewData,
} from "next";
import { ParsedUrlQuery } from "querystring";
import { fetchNames } from "../utils/fetch-names";
 
type responseItemType = {
    id: string,
    name: string,
};
 
const NamesSSG: NextPage = (props: InferGetStaticPropsType<typeof getStaticProps>) => {
    const output = props.names.map((item: responseItemType, idx: number) => {
        return (
            <li key={`name-${idx}`}>
                {item.id} : {item.name}
            </li>
        );
    });
    return (
        <ul>
            {output}
        </ul>
    );
};
 
export const getStaticProps: GetStaticProps = async (
    context: GetStaticPropsContext<ParsedUrlQuery, PreviewData>
) => {
    let names: responseItemType[] | [] = [];
    try {
names = await fetchNames();
    } catch (err) {}
    return {
        props: {
            names
        }
    };
};
 
export default NamesSSG;

之后访问 http://localhost:3000/names-ssg

Incremental Static Regeneration(ISR)

ISR 可以理解为 SSG 与 SSR 的混合,是 Next.js 提供的一种让静态生成页面“定期更新”的机制。

注意

特点说明
快速页面依然是静态 HTML,响应快
节省成本不像 SSR 每次请求都生成 HTML
数据更新可以在后台更新页面,保证内容不过时
SEO 友好页面仍然是静态 HTML,搜索引擎能抓取

总结:ISR = “定时刷新的静态页”,兼顾性能和数据时效。

在 SSG 页面里,只需在 getStaticProps 的返回对象里加上 revalidate

tsx
export const getStaticProps: GetStaticProps = async () => {
    const names = await fetchNames();
    return {
        props: { names },
        revalidate: 30 // 页面每 30 秒会在后台更新一次
    };
};
 
  • 第一次访问:直接返回构建时生成的静态页面。

  • 第 31 秒访问:后台开始生成新的页面,但第一次访问者仍看到旧页面。

  • 新页面生成完成后:下一次访问就看到更新后的内容。


  • SSG = 预制便当,一次做好放橱窗里,永远不变。

  • SSR = 动态快餐,每次有人点单现做。

  • ISR = 预制便当 + 自动补货,每隔一段时间厨房会偷偷把便当更新换新,顾客拿到的总是最新的静态便当。

Client-Side Rendering(CSR,客户端渲染)

注意

流程

  1. 先生成一个基础 HTML(可以通过 SSR 或 SSG)。
  2. 浏览器加载 HTML 后,通过 JavaScript 在客户端再去请求数据
  3. 数据返回后,再把页面内容渲染到浏览器 DOM 中。

特点

  • 数据是 实时获取和渲染,适合高度动态的数据(比如股票、货币价格)。
  • 初始加载可能显示一个空白或“骨架屏”,然后填充完整数据。
  • SEO 表现不好,因为搜索引擎抓取的是初始 HTML,没有最终渲染的数据。

创建 pages/names-csr.tsx

tsx
import type {
    NextPage
} from "next";
 
import { useEffect, useState } from "react";
import { fetchNames } from "../utils/fetch-names";
 
type responseItemType = {
    id: string,
    name: string,
};
 
const NamesCSR: NextPage = () => {
    const [data, setData] = useState<responseItemType[] | []>();
    useEffect(() => {
        const fetchData = async () => {
            let names;
            try {
                names = await fetchNames();
            } catch (err) {
                console.log("ERR", err);
            }
            setData(names);
        };
        fetchData();
    });
    const output = data?.map((item: responseItemType, idx: number) => {
        return (
            <li key={`name-${idx}`}>
                {item.id} : {item.name}
            </li>
        );
    });
    return (
        <ul>
            {output}
        </ul>
    );
};
 
export default NamesCSR;

访问 http://localhost:3000/names-csr

注意

页面加载会有 延迟渲染(可能出现白屏或闪烁)。

适合 实时数据展示,而不是 SEO 优先的页面。

对比 SSG/SSR/ISR,CSR 的 首屏渲染速度慢,但前端交互灵活。

静态 HTML 导出(Static HTML Export)

本质上是把你的应用完全打包成 纯静态网页,可以直接部署到任何 Web 服务器(Apache、NGINX、IIS 等),不依赖 Next.js 内置的 Node.js 服务器。

注意

特性描述
命令next export
依赖仅 SSG(getStaticProps)
不支持SSR、ISR、API Routes
输出完全静态 HTML + 资源
部署环境任意 Web 服务器(NGINX、Apache、IIS)

Exercise 5 Express + React → Next.js

把之前 Exercise 的逻辑迁移到 Next.js。

空的文件夹重新创建:

shell
npx create-next-app@latest --typescript --use-npm --no-app

创建 custom.d.ts

typescript
// custom.d.ts
interface WeatherProps {
    weather: string;
}
 
type WeatherDetailType = {
    zipcode: string;
    weather: string;
    temp?: number;
};
 
type responseItemType = {
    id: string;
    name: string;
};

创建 pages/api/names.ts

typescript
import type { NextApiRequest, NextApiResponse } from "next";
import type { responseItemType } from "../../custom";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<responseItemType[] | void>
) {
  const url = "https://www.usemodernfullstack.dev/api/v1/users";
  try {
    const response = await fetch(url);
    const data: responseItemType[] = await response.json();
    const names = data.map((item) => ({ id: item.id, name: item.name }));
    return res.status(200).json(names);
  } catch (err) {
    return res.status(500);
  }
}

创建 pages/api/weather/[zipcode].ts

typescript
import type { NextApiRequest, NextApiResponse } from "next";
import type { WeatherDetailType } from "../../../custom";
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<WeatherDetailType>
) {
  const { zipcode } = req.query;
  return res.status(200).json({
    zipcode: zipcode as string,
    weather: "sunny",
    temp: 35
  });
}
 

创建 pages/hello.tsx

tsx
import type { NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import Image from "next/image";
 
const Hello: NextPage = () => {
  return (
    <div>
      <Head>
        <title>Hello World Page</title>
        <meta property="og:title" content="Hello World" key="title" />
      </Head>
      <div>Hello World!</div>
      <div>
        External: <a href="https://nostarch.com">No Starch</a>
        Internal: <Link href="/components/weather">Weather Page</Link>
      </div>
      <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
    </div>
  );
};
 
export default Hello;

创建 pages/components/weather.tsx

tsx
import type { NextPage } from "next";
import { useState, useEffect } from "react";
import type { WeatherProps } from "../../custom";
 
const WeatherPage: NextPage = () => {
  const WeatherComponent = (props: WeatherProps) => {
    const [count, setCount] = useState(0);
    useEffect(() => setCount(1), []);
    return (
      <h1 onClick={() => setCount(count + 1)}>
        The weather is {props.weather}, counter {count}
      </h1>
    );
  };
  return <WeatherComponent weather="sunny" />;
};
 
export default WeatherPage;
 

运行:

shell
npm run dev

访问:

6 REST AND GRAPHQL APIS

API(Application Programming Interface)本质上是一种“连接规则”,用于让程序和程序之间通信

注意

UIAPI
给人点、看、操作给程序调用
HTML / Button / 页面JSON / HTTP / 参数
用户容错高程序对格式极其敏感

全栈通常会接触到 **Internal API(内部 API / 私有 API)**和 Third-party API(第三方 API)

注意

API Contract = 接口说明书 / 协议

内容举例
请求方式GET / POST
URL/api/user/login
参数username, password
数据格式JSON
返回值成功 / 失败 / 错误码

前后端不需要见面,只要“合同”一致,就能合作

这也是为什么有:

  • Swagger / OpenAPI
  • GraphQL schema
  • TypeScript 类型
  • 接口文档

  • 函数 contract:参数 + 返回值

  • API contract:请求 + 响应

GraphQL、OpenAPI 本质上都是 “强类型接口契约”

REST API

REST 是一套“如何设计 Web API 的规则和习惯”,而不是某种库或协议。

注意

REST API 的核心形态:URL = 资源

URL表示
/users用户集合
/users/123ID=123 的用户
/weather/10001某个地点的天气

REST 思维:URL 是名词,不是动词


REST 不靠 URL 表示动作,而是靠 HTTP Method

Method含义
GET获取资源
POST创建资源
PUT / PATCH修改资源
DELETE删除资源
h
GET /users/123

表示 “获取 ID 为 123 的用户”

REST 特征你的接口
URL 表示资源/api/weather/:zipcode
使用 HTTP MethodGET
参数来自 URLzipcode
返回 JSON
返回状态码200

常见具体状态码

  • 200 OK:请求成功
  • 401 Unauthorized:未登录 / token 无效
  • 403 Forbidden:没权限
  • 404 Not Found:资源不存在
  • 500 Internal Server Error:后端炸了

REST API 有统一的 base URL

  • 通常会带 版本号

  • Endpoint 表示 资源

  • Path 参数定位资源

  • Query 参数修饰结果

所有这些合起来,构成 API Contract 的一部分

The Specification

Specification(规范 / 说明书)就是:API 的“使用手册 + 合同”

注意

Specification 明确写清楚:

  • 有哪些 URL
  • 用什么 HTTP 方法
  • 参数是什么
  • 返回什么
  • 返回什么格式
  • 状态码有哪些

OpenAPI / Swagger 是 Specification 的行业标准

  • OpenAPI:规范本身(标准)

  • Swagger:围绕 OpenAPI 的工具生态

  • Linux Foundation:背书(不是野规范)

Swagger Editor 的价值:

  • JSON / YAML 规范
  • 变成 可读文档 + 可交互 UI
json
{
    "openapi": "3.0.0",
    "info": {
        "title": "Sample Next.js - OpenAPI 3.x",
        "description": "The example APIs from our Next.js application",
        "version": "1.0.0"
    },
    "servers": [
        { "url": "https://www.usemodernfullstack.dev/api/" },
        { "url": "http://localhost:3000/api/ " }
    ],
    "paths": {
        "/v1/weather/{zipcode}": {
            "get": {
                "summary": "Get weather by zip code",
                "parameters": [
                    {
                        "name": "zipcode",
                        "in": "path",
                        "description": "The zip code for the location as string.",
                        "required": true,
                        "schema": {
                            "type": "string",
                            "example": 96815
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Successful operation",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/weatherDetailType"
                                }
                            }
                        }
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "weatherDetailType": {
                "type": "object",
                "properties": {
                    "zipcode": {
                        "type": "string",
                        "example": 96815
                    },
                    "weather": {
                        "type": "string",
                        "example": "sunny"
                    },
                    "temp": {
                        "type": "integer",
                        "format": "int64",
                        "example": 35
                    }
                }
            }
        }
    }
}

可以将这个 json 放在 SwaggerEditor 里导出 API 文档。

webp

注意

整个流程可以理解为:

  1. 定义 API 元信息 + 版本 → 后端实现要对应
  2. 指定根 URL(Servers) → 管理环境
  3. Paths → 定义每个 endpoint
  4. Parameters → 强类型输入
  5. Responses → 强类型输出
  6. Schemas → 数据结构复用
  7. Swagger / Playground → 测试 + 自动生成代码

REST 是无状态的

注意

  • 服务器 不保存客户端的历史信息

  • 客户端每次请求都必须包含 完整的必要信息

  • 支持 负载均衡 / 代理 / 分层系统

  • 更容易扩展,高并发情况下服务器压力小

  • 前后端解耦,客户端负责状态,服务器只处理请求


  • REST API 无状态,但仍可认证:
    • 一般使用 token
    • token 放在:
      • 请求体
      • HTTP Authorization header
  • 服务器不存 session,每次请求都通过 token 判断用户身份
  • 因为是无状态的,所以可以在负载均衡或代理后面依然工作

HTTP 方法(CRUD)

注意

REST 用 标准 HTTP 方法 来对应数据操作:

CRUDHTTP 方法作用特点
CreatePOST新增资源每次 POST 都会创建新资源
ReadGET获取资源最常用,浏览网页就是 GET 请求
UpdatePUT全量更新已有资源多次 PUT 会覆盖资源
Partial UpdatePATCH局部更新资源只更新差异,更高效
DeleteDELETE删除资源删除资源,幂等操作

使用 REST API

默认情况下,curl 是已经被安装的了。

使用命令:

shell
curl -i ^
  -X GET ^
  -H "Accept: application/json" ^
  -H "Authorization: Bearer 83dedad0728baaef3ad3f50bd05ed030" ^
  https://www.usemodernfullstack.dev/api/v2/weather/96815

提示

  • macOS 下多行命令用 \ 续行

  • Windows 下用 ^ 续行

参数用途
-i显示响应头
-X GET指定 HTTP 方法为 GET
-H "Accept: application/json"请求返回 JSON
-H "Authorization: Bearer <token>"认证 token
URL包含资源路径 + path parameter(ZIP code)

收到服务器的回复:

json
HTTP/1.1 200 OK
x-powered-by: Express
access-control-allow-origin: *
content-type: application/json; charset=utf-8
content-length: 73
etag: W/"49-FtAAe5Suh1Fw1QPb1/FgSZ5Y1ws"
set-cookie: connect.sid=s%3A3LE691sfJZ06_pFWOWs3iFueEBi9PUDv.oEzBxvZWdJ%2F8NSeHAEBad3Qu67pLSj8KcTsksK9sJQc; Path=/; Expires=Thu, 15 Jan 2026 11:22:53 GMT; HttpOnly
date: Thu, 15 Jan 2026 11:21:53 GMT
connection: keep-alive
keep-alive: timeout=5
server: Fly/db8019e6c (2026-01-14)
via: 1.1 fly.io, 1.1 fly.io
fly-request-id: 01KF0P7YSWBEJDH9ZCH4029QD0-nrt
 
{"weather":"sunny","tempC":"25","tempF":"77","friends":["96814","96826"]}

由响应头和响应体组成。

使用 PUT 请求获取数据

shell
curl -i ^
    -X PUT ^
    -H "Accept: application/json" ^
    -H "Authorization: Bearer 83dedad0728baaef3ad3f50bd05ed030" ^
    -H "Content-Type: application/json" ^
    -d "{\"weather\":\"sunny\",\"tempC\":\"20\",\"tempF\":\"68\", \"friends\":\"['96815','96826']\" }" ^
    https://www.usemodernfullstack.dev/api/v2/weather/96815

得到回复:

shell
curl -i ^
    -X PUT ^
    -H "Accept: application/json" ^
    -H "Authorization: Bearer 83dedad0728baaef3ad3f50bd05ed030" ^
    -H "Content-Type: application/json" ^
    -d "{\"weather\":\"sunny\",\"tempC\":\"20\",\"tempF\":\"68\", \"friends\":\"['96815','96826']\" }" ^
    https://www.usemodernfullstack.dev/api/v2/weather/96815

注意

对比 GET/PUT

操作方法数据位置返回内容幂等性
读取GETURL + query / path资源数据幂等
更新PUT请求体 JSON状态信息或更新后的对象幂等(多次相同 PUT 结果一样)

GraphQL

REST 是一种架构风格(定义了 URL、HTTP 方法、状态码等规范),而 GraphQL 是 开源的 API 查询和操作语言,可以直接用来描述数据请求和更新操作。

注意

特点RESTGraphQL
端点数量每个资源一个 URL单个端点(通常是 /graphql
操作方式HTTP 方法(GET、POST、PUT、DELETE)POST 请求 + query/mutation 在请求体中
CRUD 表示通过方法区分Queries(读操作) & Mutations(写操作)
状态码用标准 HTTP 状态码(200、404、500 等)几乎总返回 200,除非整个操作失败(返回 500)
响应行为成功/失败由状态码区分即使部分查询失败,HTTP 状态仍可能是 200;错误信息在响应体里返回

也就是说,GraphQL 的错误处理机制与 REST 不同。REST 靠状态码区分成功/失败,而 GraphQL 更依赖 响应体里的 errors 字段

The Schema

GraphQL 的 Schema(模式)相当于 REST 的 specification。

注意

Schema 用于定义可用的 Queries 和 Mutations

  • Query:读取数据
  • Mutation:创建/更新/删除数据

GraphQL 使用 SDL(Schema Definition Language) 来写 schema,也叫 typedef。

typescript
export const typeDefs = gql`
    type LocationWeatherType {
        zip: String!
        weather: String!
        tempC: String!
        tempF: String!
        friends: [String]!
    }
    input LocationWeatherInput {
        zip: String!
        weather: String
        tempC: String
        tempF: String
        friends: [String]
    }
    type Query {
        weather(zip: String): [LocationWeatherType]!
    }
    type Mutation {
        weather(data: LocationWeatherInput): [LocationWeatherType]!
    }
`;

注意

部分名称类型作用说明
对象类型(Type)LocationWeatherTypetype返回给客户端的数据结构,描述一个地点的天气信息
字段zipString!ZIP code,必定存在
weatherString!天气描述(如 sunny)
tempCString!摄氏温度
tempFString!华氏温度
friends[String]!相关地点 ZIP 列表,始终返回数组(可为空)
输入类型(Input)LocationWeatherInputinputMutation 的入参结构,表示客户端提交的数据
字段zipString!要更新的 ZIP code(必须)
weatherString新天气(可选)
tempCString新摄氏温度(可选)
tempFString新华氏温度(可选)
friends[String]关联 ZIP(可选)
查询(Query)weatherQuery读操作
参数zipString要查询的 ZIP code
返回值[LocationWeatherType]!返回天气信息数组(一定有返回值)
变更(Mutation)weatherMutation写操作(新增 / 更新 / 删除)
参数dataLocationWeatherInput客户端提交的更新数据
返回值[LocationWeatherType]!返回更新后的天气数据
符号含义
!非空(一定存在)
[Type]数组
type返回给客户端的数据结构
input客户端传入的数据结构
Query读数据
Mutation改数据

Resolvers

注意

Resolvers 是 GraphQL 中“真正干活”的函数。

  • Schema(typeDefs):只定义“能查什么、长什么样”(接口 / 约定)
  • Resolver:定义“这些数据从哪来、怎么拿”

一句话总结:

  • Schema 是“说明书”,Resolver 是“实现代码”
GraphQL 类型对应操作CRUD
Query查询Read
Mutation新增 / 修改 / 删除Create / Update / Delete
  • 所有查询 → Query resolver

  • 所有写操作 → Mutation resolver

  • 合起来 = 完整 CRUD

一个 GraphQL 查询,本质上是一棵“嵌套调用 resolver 的树”

AST(Abstract Syntax Tree,抽象语法树):

  • GraphQL 会把你的查询解析成一棵树
  • 每个字段 = 一个节点
  • 每个节点 = 对应一个 resolver 函数

Schema

typescript
export const typeDefs = gql`
type FriendsType {
        zip: String!
        weather: String!
}
    type LocationWeatherType {
        zip: String!
        weather: String!
        tempC: String!
        tempF: String!
        friends: [FriendsType]!
    }
    type Query {
        weather(zip: String): [LocationWeatherType]!
    }
`;

注意

  1. 入口点只有一个

    Query.weather(zip)
    
  2. weather 返回的是:

    [LocationWeatherType]
    
  3. LocationWeatherType 里又包含:

    friends: [FriendsType]
    

这就天然形成了嵌套结构

查询

graphql
query GetWeatherWithFriends {
    weather(zip: "96815") {
        weather
        friends {
            weather
        }
    }
}

注意

请求获取 ZIP=96815 这个地点的

  • 天气(weather)
  • 以及它所有邻居(friends)的天气

从服务器角度上看,查询命令会被解析成类似的树:

graphql
Query
 └── weather(zip: "96815")
      ├── weather
      └── friends
           └── weather

注意

GraphQL 会检查:

  • Query.weather 是否存在
  • LocationWeatherType.weather 是否存在
  • LocationWeatherType.friends.weather 是否合法
typescript
export const resolvers = {
    Query: {
        weather: async (_: any, param: WeatherInterface) => {
            return [
                {
                    zip: param.zip,
                    weather: "sunny",
                    tempC: "25C",
                    tempF: "70F",
                    friends: []
                }
            ];
        },
    },
    Mutation: {
        weather: async (_: any, param: { data: WeatherInterface }) => {
            return [
                {
                    zip: param.data.zip,
                    weather: "sunny",
                    tempC: "25C",
                    tempF: "70F",
                    friends: []
                }
            ];
        }
    },
};

注意

Schema 里Resolvers 里
type Query { weather(...) }Query.weather
type Mutation { weather(...) }Mutation.weather

对比 GraphQL 和 REST

注意

维度RESTGraphQL
客户端能否控制返回字段❌ 基本不能✅ 完全可以
默认返回数据整个资源只返回你要的字段
常见问题over-fetching / under-fetching基本避免
接口粒度endpoint 决定query 决定

REST 经常会出现 **Over-fetching(过度获取)**和 Under-fetching(取少了)

GET /api/v2/weather/zip/96815

可能返回:

json
{
  "zip": "96815",
  "weather": "sunny",
  "tempC": "25C",
  "tempF": "70F",
  "friends": [...]
}

如果取多了,浪费流量,取少了,多次获取也浪费流量。

graphql
query Weather {
    weather(zip: "96815") {
        tempC
    }
}

这个语法能清晰地表面需要请求什么。

注意

虽然也可以通过 REST + JSON 调用 API 并告知请求哪些数据。但:

维度RESTGraphQL
能否携带 JSON✅ 可以✅ 可以
是否字段级查询❌ 无统一机制✅ 原生支持
是否支持嵌套查询❌ 需要多接口✅ 一次完成
API 形状由谁决定服务端客户端
类型系统弱 / 文档化强(Schema)

REST 请求当然可以携带 JSON,但 REST 的问题不在“能不能传 JSON”,而在于它缺少一种标准化的、字段级的查询与执行机制;GraphQL 正是为了解决这一点而设计的。

Exercise 6 给 Next.js 添加 GraphQL API

GraphQL ≠ 只是“写几条 query”

GraphQL 是一整套环境,至少包含:

  • GraphQL Server(负责解析、校验、执行 query)

  • GraphQL Schema(强类型 API 合约)

  • Query Language(客户端写的查询语言)

所以在 Next.js 里:

  • 你必须 引入一个 GraphQL Server(Apollo Server)
  • 而不是“像 REST 那样随便写个 API 路由”

安装 Apollo Server:

shell
npm install @apollo/server @as-integrations/next graphql graphql-tag

创建 pages/api/graphql.ts(路由)、graphql/schema.ts(能查什么)、 graphql/data.ts(查询数据) 和 graphql/resolvers.ts(怎么查)。

  • /pages/api:HTTP 接口入口(REST 或 GraphQL)
  • /graphql:GraphQL 内部世界
    • schema
    • resolvers
    • data

GraphQL 的逻辑不和 HTTP 路由混在一起


编辑 graphql/schema.ts

typescript
import gql from "graphql-tag";
 
export const typeDefs = gql`
    type LocationWeatherType {
        zip: String!
        weather: String!
        tempC: String!
        tempF: String!
        friends: [String]!
    }
    input LocationWeatherInput {
        zip: String!
        weather: String
        tempC: String
        tempF: String
        friends: [String]
    }
    type Query {
        weather(zip: String): [LocationWeatherType]!
    }
    type Mutation {
        weather(data: LocationWeatherInput): [LocationWeatherType]!
    }
`;

(往之前的 schema 定义前面加上 import gql from "graphql-tag";

gql 是一个 tagged template literal,它的作用是把 `type Query { ... }` 转换成 GraphQL AST 以供 Apollo Server 使用。


编辑 graphql/data.ts,先用 json 表示所需要的数据:

typescript
export const db = [
    {
        zip: "96815",
        weather: "sunny",
        tempC: "25C",
        tempF: "70F",
        friends: ["96814", "96826"]
    },
    {
        zip: "96826",
        weather: "sunny",
        tempC: "30C",
        tempF: "86F",
        friends: ["96814", "96814"]
    },
    {
        zip: "96814",
        weather: "sunny",
        tempC: "20C",
        tempF: "68F",
        friends: ["96815", "96826"]
    }
];

编辑 graphql/resolvers.ts

typescript
import { db } from "./data";
 
declare interface WeatherInterface {
  zip: string;
  weather: string;
  tempC: string;
  tempF: string;
  friends: string[];
}
 
export const resolvers = {
    Query: {
        weather: async (_: any, param: WeatherInterface) => {
            return [db.find((item) => item.zip === param.zip)];
        }
    },
    Mutation: {
        weather: async (_: any, param: { data: WeatherInterface }) => {
            return [db.find((item) => item.zip === param.data.zip)];
        }
    }
};

编辑 pages/api/graphql.ts

typescript
import { ApolloServer } from "@apollo/server"; // Import Apollo Server for GraphQL
import { startServerAndCreateNextHandler } from "@as-integrations/next"; // Integration helper for Next.js API routes
import { resolvers } from "../../graphql/resolvers"; // Import the GraphQL resolvers
import { typeDefs } from "../../graphql/schema"; // Import the GraphQL schema definitions
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; // Next.js API types
 
// Create Apollo Server instance
//@ts-ignore
const server = new ApolloServer({
    resolvers, // GraphQL resolvers (functions to fetch/modify data)
    typeDefs   // GraphQL schema (type definitions)
});
 
// Create a Next.js API route handler for the Apollo Server
const handler = startServerAndCreateNextHandler(server);
 
// Middleware to allow CORS (Cross-Origin Resource Sharing)
const allowCors =
    (fn: NextApiHandler) => async (req: NextApiRequest, res: NextApiResponse) => {
        // Set CORS headers
        res.setHeader("Allow", "POST"); // Only allow POST requests
        res.setHeader("Access-Control-Allow-Origin", "*"); // Allow requests from any origin
        res.setHeader("Access-Control-Allow-Methods", "POST"); // Allowed HTTP methods
        res.setHeader("Access-Control-Allow-Headers", "*"); // Allowed headers
        res.setHeader("Access-Control-Allow-Credentials", "true"); // Allow credentials like cookies
 
        // Handle preflight OPTIONS request
        if (req.method === "OPTIONS") {
            res.status(200).end(); // Respond immediately to OPTIONS request
        }
 
        // Call the actual handler (Apollo Server)
        return await fn(req, res);
    };
 
// Export the API route wrapped with CORS middleware
export default allowCors(handler);
 

注意

ApolloServer 用于创建 GraphQL 服务。

startServerAndCreateNextHandler 是官方提供的 Next.js 集成工具,可以把 Apollo Server 包装成 Next.js API Route

allowCors 是一个中间件:

  • 设置响应头允许跨域访问。
  • OPTIONS 请求(浏览器的预检请求)直接返回 200。
  • 其他请求则交给 Apollo Server 处理。

export default allowCors(handler)

  • 将 Apollo Server 的 API route 暴露给前端,同时支持跨域。

执行命令:

shell
npm run dev

通过 http://localhost:3000/api/graphql 访问 Apollo Server:

webp

点击左侧的 ArgumentsFields 得到查询语句:

graphql
query Weather($zip: String) {
  weather(zip: $zip) {
    zip
    weather
  }
}

提供 Variables

json
{ "zip": "96826" }

执行查询后得到查询结果:

json
{
  "data": {
    "weather": [
      {
        "zip": "96826",
        "weather": "sunny"
      }
    ]
  }
}

7 MONGODB AND MONGOOSE

大多数应用程序都依赖数据库(Database Management System, 简称 DB)来管理和存储数据集合,并控制对这些数据的访问。

  • MONGODB
    • 一个非关系型数据库,也就是 NoSQL 数据库
    • MongoDB 返回的数据是 JSON 格式,而且数据库查询可以用 JavaScript 来写,所以对于前后端都用 JavaScript 的开发者来说,它非常自然且方便。

注意

特性关系型数据库 (RDB)非关系型数据库 (NoSQL)
数据模型表格,固定Schema键值/文档/列族/图,自由Schema
查询语言SQL各自的API或查询语言
数据一致性强一致性(ACID)弱一致性或最终一致性
扩展性垂直扩展为主水平扩展容易
适用场景金融、ERP、库存管理等社交网络、日志分析、大数据、缓存
事务支持完整支持弱事务或有限支持
SQL / 关系型数据库MongoDB / 文档型数据库
Table(表)Collection(集合)
Row(行)Document(文档)
Column(列)Field(字段)
数据存储格式JSON/BSON
查询语言SQL
  • Mongoose
    • MongoDB 的对象建模工具

使用 ODM(如 Mongoose)

  • 简化数据库操作
  • 提供对象化接口(面向对象操作数据库)
  • 支持 async/await 异步操作
  • 可以对数据类型和结构进行验证,减少错误

安装

为了方便,使用 内存型 MongoDB(in-memory MongoDB),而不是在本地安装和维护真实的 MongoDB 服务器。

特点

  • 适合 测试和练习
  • 数据不会持久化(重启应用后数据会丢失)

提示:真正的应用部署时,需要使用真实的 MongoDB 服务器。

shell
npm install mongodb-memory-server mongoose

定义 Mongoose 模型

为了保证数据的完整性和规范性,需要用 Mongoose schema 创建一个 模型(model)

关键点

  • Schema(模式):定义数据结构、字段类型、约束规则
  • Model(模型):是 Mongoose 与 MongoDB 集合(collection)之间的接口,所有对数据库的操作都通过模型进行。
  • 作用:防止不符合规则的数据进入数据库,保证数据一致性。

在用 TypeScript 编写 Mongoose 模型和模式之前,先声明一个 TypeScript 接口。

创建 mongoose/weather/interface.ts 以定义 Interface

typescript
export declare interface WeatherInterface {
    zip: string;
    weather: string;
    tempC: string;
    tempF: string;
    friends: string[];
};

注意

解析

  • zip, weather, tempC, tempF:字符串
  • friends:字符串数组
  • 这个接口和 GraphQL API 的数据结构一一对应。

创建 mongoose/weather/schema.ts 以用 mongoose 定义 Schema

typescript
import { Schema } from "mongoose";
import { WeatherInterface } from "./interface";
 
export const WeatherSchema = new Schema<WeatherInterface>({
    zip: { type: "String", required: true },
    weather: { type: "String", required: true },
    tempC: { type: "String", required: true },
    tempF: { type: "String", required: true },
    friends: { type: ["String"], required: true },
});

注意

解析

  • Schema<WeatherInterface>:给 Schema 添加类型约束,确保字段类型符合接口
  • type:字段类型
  • required:是否必填
  • 额外类型选项:minlength、maxlength(字符串);min、max(数字)

Mongoose 类型映射

  • 内置类型:String, Number, Boolean, Array, Date
  • 特殊类型:Buffer, ObjectId(MongoDB 文档默认 _id 主键)

类比

  • Schema = 数据蓝图
  • Field = 列(Column)
  • Document = 行(Row)

模型是 Schema 的包装器,通过它可以对 MongoDB 集合进行增删改查(CRUD)操作。

创建 mongoose/weather/model.ts 以定义 Model:

typescript
import mongoose, { model } from "mongoose";
import { WeatherInterface } from "./interface";
import { WeatherSchema } from "./schema";
 
export default mongoose.models.Weather ||
    model<WeatherInterface>("Weather", WeatherSchema);

注意

解析

  1. 导入模块
    • mongoose:核心库
    • model:模型构造器
    • WeatherInterface:类型约束
    • WeatherSchema:字段结构
  2. 创建模型
    • model<WeatherInterface>("Weather", WeatherSchema)
      • 第一个参数 "Weather":模型名 → 对应 MongoDB 中的 collection 名称(自动变复数 Weather → Weathers)
      • 第二个参数:Schema
  3. 检查重复模型
    • mongoose.models.Weather || ...
    • 如果模型已存在,不重复创建,否则会报错
  4. 导出模型:方便其他模块使用

集合与数据库

  • 模型绑定的集合:Weathers
  • 数据库名:Weather(Mongoose 会自动创建)
Mermaid
Loading diagram…

数据库连接中间件(Database-Connection Middleware)

创建 middleware/db-connect.ts

typescript
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
 
async function dbConnect(): Promise<any | String> {
    // 创建只存在于内存的 MongoDB 实例
    const mongoServer = await MongoMemoryServer.create();
    const MONGOIO_URI = mongoServer.getUri();
    await mongoose.disconnect();
    await mongoose.connect(MONGOIO_URI, {
        dbName: "Weather"
    });
}
export default dbConnect;

注意

**db-connect 中间件的职责只有一个:**确保 MongoDB 已经连接,并且 Mongoose 可以正常使用模型进行查询

它负责的事情包括:

  1. 启动一个 内存版 MongoDB
  2. 建立 Mongoose ↔ MongoDB 的连接
  3. 让已定义的 Mongoose Models 自动绑定到数据库集合
  4. 自动处理:
    • 断线重连
    • 操作缓冲(buffering)

数据库 Query

创建 mongoose/weather/services.ts,写如何增删改查数据库:

typescript
import WeatherModel from "./model";
import { WeatherInterface } from "./interface";
 
export async function storeDocument(doc: WeatherInterface): Promise<boolean> {
    try {
        await WeatherModel.create(doc);
    } catch (error) {
        return false;
    }
    return true;
}
 
export async function findByZip(
    paramZip: string
): Promise<Array<WeatherInterface> | null> {
    try {
        return await WeatherModel.findOne({ zip: paramZip });
    } catch (err) {
        console.log(err);
    }
    return [];
}
 
export async function updateByZip(
    paramZip: string,
    newData: WeatherInterface
): Promise<boolean> {
    try {
        await WeatherModel.updateOne({ zip: paramZip }, newData);
        return true;
    } catch (err) {
        console.log(err);
    }
    return false;
}
 
export async function deleteByZip(
        paramZip: string
    ): Promise<boolean> {
    try {
        await WeatherModel.deleteOne({ zip: paramZip });
        return true;
    } catch (err) {
        console.log(err);
    }
    return false;
}

注意

操作推荐检查字段判断成功的依据
Createtry / catch是否抛异常
Read返回值是否为 null
UpdatematchedCount是否找到目标文档
DeletedeletedCount是否真的删除

Service 的定义:

  • 是一个普通函数
  • 专门负责 数据库 CRUD
  • 只和 Mongoose Model 打交道
  • 不关心 GraphQL / HTTP / UI

注意

数据库查询不应该散落在 resolver 里,而应该集中在 service 层;

service 只做一件事:通过 Mongoose Model 执行单一的 CRUD 操作并返回结果。

创建一个端到端 Query

Mermaid
Loading diagram…

创建 pages/api/v1/weather/[zipcode].ts

typescript
import type { NextApiRequest, NextApiResponse } from "next";
import { findByZip } from "./../../../../mongoose/weather/services";
import dbConnect from "./../../../..//middleware/db-connect";
dbConnect();
 
export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
): Promise<NextApiResponse<WeatherDetailType> | void> {
    let data = await findByZip(req.query.zipcode as string);
    return res.status(200).json(data);
}

WeatherDetailTypecustom.d.ts 中定义并在 tsconfig.json 里被引用)


修改 middleware/db-connect.ts 以添加一个初始化的数据集:

typescript
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
import { storeDocument } from "../mongoose/weather/services";
 
async function dbConnect(): Promise<any | String> {
    const mongoServer = await MongoMemoryServer.create();
    const MONGOIO_URI = mongoServer.getUri();
    await mongoose.disconnect();
    
    let db = await mongoose . connect(MONGOIO_URI, {
		dbName: "Weather"
    });
 
    await storeDocument({
		zip: "96815",
		weather: "sunny",
		tempC: "25C",
		tempF: "70F",
		friends: ["96814", "96826"]
    });
    await storeDocument({
        zip: "96814",
        weather: "rainy",
        tempC: "20C",
 		tempF: "68F",
		friends: ["96815", "96826"]
    });
    await storeDocument({
        zip: "96826",
        weather: "rainy",
        tempC: "30C",
		tempF: "86F",
		friends: ["96815", "96814"]
    });
}
export default dbConnect;

编译:

shell
npm run dev

访问 http://localhost:3000/api/v1/weather/96815,得到数据库查询的结果:

json
{"_id":"696e138d01ac54e9470d3543","zip":"96815","weather":"sunny","tempC":"25C","tempF":"70F","friends":["96814","96826"],"__v":0}

Exercise 7 使用 GraphQL API 连接数据库

把之前 Weather 的 GraphQL API 从“读静态 JSON”改成“读 MongoDB 数据库”。

Mermaid
Loading diagram…

注意

之前在 REST API 中是这样:

/api/v1/weather/96815
/api/v1/weather/96814
/api/v1/weather/96826

每个 endpoint 都是一个入口。

而 GraphQL:

POST /graphql

所有请求都走同一个 API 文件

这带来一个巨大好处:数据库连接只需要在一个地方处理一次

修改 api/graphql.ts

typescript
import { ApolloServer } from "@apollo/server";
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { resolvers } from "../../graphql/resolvers"; // GraphQL resolvers:处理具体查询和修改逻辑
import { typeDefs } from "../../graphql/schema"; // GraphQL schema:定义数据结构和查询/修改接口
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; // Next.js API 类型
import dbConnect from "../../middleware/db-connect"; // 数据库连接中间件
 
// 创建 Apollo Server 实例
//@ts-ignore
const server = new ApolloServer({
    resolvers, // 绑定 resolver
    typeDefs,  // 绑定 schema
});
 
// 将 Apollo Server 适配成 Next.js API Handler
const handler = startServerAndCreateNextHandler(server);
 
// CORS 中间件,高阶函数包装 API Handler
const allowCors = (fn: NextApiHandler) =>
    async (req: NextApiRequest, res: NextApiResponse) => {
        // 允许 POST 请求
        res.setHeader("Allow", "POST");
        // 允许所有来源访问(注意:生产环境中 credentials + '*' 会有问题)
        res.setHeader("Access-Control-Allow-Origin", "*");
        // 允许的方法
        res.setHeader("Access-Control-Allow-Methods", "POST");
        // 允许的请求头
        res.setHeader("Access-Control-Allow-Headers", "*");
        // 是否允许携带 cookie
        res.setHeader("Access-Control-Allow-Credentials", "true");
 
        // 处理预检请求 OPTIONS
        if (req.method === "OPTIONS") {
            res.status(200).end();
            return; // 不继续执行 handler
        }
 
        // 调用原始 handler(Apollo Server)
        return await fn(req, res);
    };
 
// MongoDB 连接中间件,高阶函数包装 API Handler
const connectDB = (fn: NextApiHandler) =>
    async (req: NextApiRequest, res: NextApiResponse) => {
        // 确保数据库已连接
        await dbConnect();
        // 调用下一个 handler
        return await fn(req, res);
    };
 
// 导出最终 API Route
// 执行顺序:connectDB -> allowCors -> Apollo Server handler -> resolvers
export default connectDB(allowCors(handler));
 

修改 graphql/resolvers.ts

typescript
import { WeatherInterface } from "../mongoose/weather/interface";
import { findByZip, updateByZip } from "../mongoose/weather/services";
export const resolvers = {
    Query: {
        weather: async (_: any, param: WeatherInterface) => {
            let data = await findByZip(param.zip);
            return [data];
        },
    },
    Mutation: {
        weather: async (_: any, param: { data: WeatherInterface }) => {
await updateByZip(param.data.zip, param.data);
            let data = await findByZip(param.data.zip);
            return [data];
        },
    },
};

访问 http://localhost:3000/api/graphql 即可查询到 middleware/db-connect.ts 中存放的数据:

webp

8 TESTING WITH THE JEST FRAMEWORK

Jest 通过自动化测试来确保修改不会破坏已有功能。

注意

为什么要测试

  • 避免代码修改带来的意外副作用。
  • 保证代码库的稳定性。

两条路线保障代码质量

  • 组件化架构:减少依赖与副作用。
  • 自动化测试:Jest 帮助验证每个单元行为。

Jest 的核心用法

  • 写测试套件(test suites)
  • 检查功能是否符合预期
  • 使用 mock 管理依赖
  • 利用报告发现问题

Test-Driven Development(测试驱动开发) and Unit Testing(单元测试)

注意

TDD(Test-Driven Development):先写测试,再写实现代码。

流程:

  1. 写一个单元测试,针对最小的功能单元(module、function、甚至一行代码)验证预期行为。
  2. 写最少量的代码,使测试通过。

关键概念

  • Unit test(单元测试):测试最小代码单元是否按预期工作。
  • 最小实现原则:只写足够通过测试的代码,避免过度设计。

TDD 的优势:

  • 明确需求
    • 在写代码之前,测试会明确规定功能和边界情况。
    • 可以提前发现需求不清或缺失的地方,而不是等实现完再写测试。
    • 风险:测试可能只是反映你实际实现的行为,而不一定符合真正需求。
  • 控制复杂度
    • 只写必要代码,避免函数过于复杂。
    • 将应用拆分成小、易理解的模块。

测试单元(Unit):模块、函数或代码行。

测试目标:验证单元在隔离环境中是否正确运行。

测试结构

  • Test steps:测试函数内部的一行行操作。
  • Test case(测试用例):完整的测试函数。
  • Test suite(测试套件):把多个测试用例组织成逻辑块。

可重复性:测试每次运行结果必须一致,意味着需要可控环境和固定数据集


Jest 由 Facebook 与 React 一起开发,但可以用于任意 Node.js 项目。

功能

  1. 提供标准化语法写测试。
  2. 内置测试运行器(test runner)。
  3. 自动处理依赖(mock)。
  4. 生成代码覆盖率报告(code coverage report)。

扩展功能

  • 通过额外 npm 包支持 DOM 测试或 React 组件测试。
  • 支持 TypeScript 类型。

安装 Jest

shell
npm install --save-dev jest @types/jest

package.json 中添加 "test": "jest" 以方便使用 npm test 运行 Jest。

json
"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest"
},

继续:

shell
npm install --save-dev ts-jest
shell
npx ts-jest config:init

这将生成 jest.config.js

javascript
const { createDefaultPreset } = require("ts-jest");
 
const tsJestTransformCfg = createDefaultPreset().transform;
 
/** @type {import("jest").Config} **/
module.exports = {
  testEnvironment: "node",
  transform: {
    ...tsJestTransformCfg,
  },
};

创建一个测试用示例模块

创建 ./helpers/sum.test.ts

typescript
import { sum } from "../helpers/sum";
 
describe("the sum function", () => {
    
});

注意

导入函数:虽然 sum.ts 还没写,但我们先在测试文件中导入它。

describe:Jest 的函数,用于创建测试套件(test suite)。

  • 参数 1:套件名称 "the sum function"
  • 参数 2:回调函数,里面写具体的测试用例(test cases)

空套件:目前没有测试用例,Jest 会提醒我们至少要有一个测试。

TDD 核心点:先写测试,再写功能代码。

创建模块文件和占位函数 ./helpers/sum.ts

typescript
const sum = () => {};
export { sum };

注意

占位函数:先创建一个最简单的空函数,保证模块可以被导入。

执行测试试试:

shell
npm test
> my-app@0.1.0 test
> jest

FAIL  helpers/sum.test.ts
  ● Test suite failed to run    
    Your test suite must contain at least one test.
      at onResult (node_modules/@jest/core/build/index.js:1057:18)
      at node_modules/@jest/core/build/index.js:1127:165
      at node_modules/emittery/index.js:363:13
      at Array.map (<anonymous>)
      at Emittery.emit (node_modules/emittery/index.js:361:23)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        0.378 s
Ran all test suites.

这时候 测试仍然会失败,因为:

  1. sum 没有实现功能
  2. 测试套件还没有具体测试用例
步骤说明TDD 阶段
1. 创建测试文件 sum.test.ts先导入函数,创建空套件红灯(Red)
2. 创建模块 sum.ts先写空函数,占位红灯(Red)
3. 运行 npm testJest 提示套件为空或测试失败红灯(Red)
4. 写测试用例添加 test()it() 来描述预期行为红灯→绿灯(Red→Green)
5. 实现函数修改 sum() 实现功能,使测试通过绿灯(Green)
6. 重构优化代码结构,保持测试通过重构(Refactor)

测试用例的结构

有两种单元测试的类型:

注意

  • State-based test(状态型测试)

    typescript
    expect(sum(2, 2)).toBe(4);
    • 检查函数返回值或修改的状态是否符合预期
    • 例子:sum(2, 2) 是否返回 4
    • 目前做的 sum 测试类型
    • 状态型关注结果
  • Interaction-based test(交互型测试)

    typescript
    expect(mockFn).toHaveBeenCalled();
    • 检查函数在运行时是否调用了特定函数或方法
    • 例子:验证某函数是否调用了 console.log() 或数据库接口
    • 交互型关注行为/调用过程

所有的测试用例都遵循三步法(AAA):

  • Arrange:准备测试数据 / 依赖

  • Act:调用函数

  • Assert:验证结果

覆盖 ./helpers/sum.test.ts 以实现 Arrange(准备),用于定义前置条件、测试数据、依赖环境:

typescript
import { sum } from "../helpers/sum";
 
describe("the sum function", () => {
    
    test("two plus two is four", () => {
            let first = 2;
            let second = 2;
            let expectation = 4;
        });
    test("minus eight plus four is minus four", () => {
            let first = -8;
            let second = 4;
            let expectation = -4;
        });
});

继续修改 ./helpers/sum.test.ts 以实现 Act(执行),调用被测函数。

typescript
import { sum } from "../helpers/sum";
 
describe("the sum function", () => {
    
    test("two plus two is four", () => {
        let first = 2;
        let second = 2;
        let expectation = 4;
        let result = sum(first, second);
    });
    
    test("minus eight plus four is minus four", () => {
        let first = -8;
        let second = 4;
        let expectation = -4;
        let result = sum(first, second);
    });
});

继续修改 ./helpers/sum.test.ts 以实现 Assert(断言),验证返回结果是否符合预期。

typescript
import { sum } from "../helpers/sum";
 
describe("the sum function", () => {
    
    test("two plus two is four", () => {
        let first = 2;
        let second = 2;
        let expectation = 4;
        let result = sum(first, second);
        expect(result).toBe(expectation);
    });
    
    test("minus eight plus four is minus four", () => {
        let first = -8;
        let second = 4;
        let expectation = -4;
        let result = sum(first, second);
        expect(result).toBe(expectation);
    });
});

使用 TDD

现在 sum() 是空的,因此执行 npm test 会出现:

> my-app@0.1.0 test
> jest

FAIL  helpers/sum.test.ts
  the sum function
    × two plus two is four (9 ms)
    × minus eight plus four is minus four (2 ms)
    
  ● the sum function › two plus two is four
    expect(received).toBe(expected) // Object.is equality
    Expected: 4
    Received: undefined

       8 |         let expectation = 4;
       9 |         let result = sum(first, second);
    > 10 |         expect(result).toBe(expectation);
         |                        ^
      11 |     });
      12 |     
      13 |     test("minus eight plus four is minus four", () => {

      at Object.<anonymous> (helpers/sum.test.ts:10:24)

  ● the sum function › minus eight plus four is minus four

  ● the sum function › minus eight plus four is minus four

    expect(received).toBe(expected) // Object.is equality

    Expected: -4
    Received: undefined

      16 |         let expectation = -4;
      17 |         let result = sum(first, second);
    > 18 |         expect(result).toBe(expectation);
         |                        ^
      19 |     });
      20 | });

    at Object.<anonymous> (helpers/sum.test.ts:18:24)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 2 total
Snapshots:   0 total
Time:        0.589 s
Ran all test suites.

现在修改 ./helpers/sum.ts 以实现 sum()

typescript
const sum = (a: number, b: number): number => a + b;
export { sum };

如此做,测试成功:

shell
npm test
> my-app@0.1.0 test
> jest

PASS  helpers/sum.test.ts
  the sum function
    √ two plus two is four (3 ms)
    √ minus eight plus four is minus four (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.326 s, estimated 1 s
Ran all test suites.

重构代码(Refactoring Code)

注意

测试先行 → 测试失败 → 再修改实现 → 测试通过 = 安全重构

在 TDD 里的思想里:需求变化 = 测试变化,所以第一步先改测试而不是实现。

因此修改 ./helpers/sum.test.file

typescript
import { sum } from "../helpers/sum";
 
describe("the sum function", () => {
    
    test("two plus two is four", () => {
        expect(sum([2, 2])).toBe(4);
    });
    
    test("minus eight plus four is minus four", () => {
        expect(sum([-8, 4])).toBe(-4);
    });
    
    test("two plus two plus minus four is zero", () => {
        expect(sum([2, 2, -4])).toBe(0);
    });
});

之后修改实现函数 ./helpers/sum.ts

typescript
const sum = (data: number[]): number => {
    return data.reduce((a, b) => a + b);
};
export { sum };

测试的量化指标

通过测试覆盖率(Test Coverage)评价测试套件实际执行到了哪些代码行。一般地,代码覆盖率目标设定为 90% 或以上,并对代码中最关键的部分保持高覆盖率。当然,测试用例应通过测试代码功能来增加价值;仅仅为了提高测试覆盖率而添加测试并不是我们的目标。

修改 package.json 中添加 "test": "jest --coverage" 以在测试中显示测试覆盖率。

json
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "jest --coverage"
},

npm test 会显示代码覆盖率(这里是 100%):

> my-app@0.1.0 test
> jest --coverage

 PASS  helpers/sum.test.ts
  the sum function
    √ two plus two is four (3 ms)
    √ minus eight plus four is minus four (1 ms)
    √ two plus two plus minus four is zero (1 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |                  
 sum.ts   |     100 |      100 |     100 |     100 |                  
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        2.595 s
Ran all test suites.

使用 fakes/stubs/mocks 替换依赖

注意

单元测试要求“隔离”,但真实代码不可避免地依赖其他模块。

引入 Test Doubles(测试替身)

  • 替代真实对象或函数
  • 消除外部依赖
  • 行为是可控、可预测的

这正是“可重复测试”的基础。

类型中文常见叫法主要目的关注点是否关心“被怎么调用”是否返回真实结果典型使用场景
Fake假实现提供可运行的简化实现行为是否近似真实❌ 通常不关心✅ 是(但不完整)内存数据库、简化服务、测试用仓库
Stub返回固定、可预测的数据返回值 / 状态❌ 不关心⚠️ 是(人为设定)隔离外部依赖、控制测试输入
Mock模拟验证交互行为是否发生调用次数 / 参数 / 顺序✅ 非常关心⚠️ 可有可无验证是否调用了某个函数
测试关注点更常用的 Test Double
状态 / 返回值正确性Stub、Fake
行为 / 交互是否发生Mock
复杂依赖的可运行替代Fake
问题对应方案
真实依赖不可控Stub
真实依赖太复杂Fake
需要验证行为而非结果Mock

测试不应该穷举,而是测试策略。

判断边界条件

  • 很多 bug 出现在:

    • 0

    • 空数组

    • null / undefined

  • 边界条件通常:

    • 分支多
    • 容易遗漏

判断核心功能是否成立

  • 选择一个代表性输入

    • 能覆盖循环

    • 能覆盖“依赖前两项”的逻辑

  • 能验证 sum 被正确使用

新建一个 ./helpers/fibonacci.test.ts 用于测试斐波那契的实现:

typescript
import { fibonacci } from "../helpers/fibonacci";
 
describe("the fibonacci sequence", () => {
    
    test("with a length of 0 is ", () => {
        expect(fibonacci(0)).toBe(" ");
    });
    
    test("with a length of 5 is '0, 1, 1, 2, 3' ", () => {
        expect(fibonacci(5)).toBe("0, 1, 1, 2, 3");
    });
});

新建 ./helpers/fibonacci.ts 实现斐波那契,它将依赖 sum()

typescript
import { sum } from "./sum";
 
const fibonacci = (length: number): string => {
    const sequence: number[] = [];
    for (let i = 0; i < length; i++) {
        if (i < 2) {
            sequence.push(sum([0, i]));
        } else {
            sequence.push(sum([sequence[i - 1], sequence[i - 2]]));
        }
    } return sequence.join(", ");
};
export { fibonacci };

重要

如果 sum 出问题,fibonacci 的测试也会失败 —— 即使 Fibonacci 本身是对的

创建 Doubles

修改 fibonacci.test.ts 使用 mock。

typescript
import { fibonacci } from "../helpers/fibonacci";
 
jest.mock("../helpers/sum");
 
describe("the fibonacci sequence", () => {
    test("with a length of 0 is ", () => {
        expect(fibonacci(0)).toBe("");
    });
    
    test("with a length of 5 is '0, 1, 1, 2, 3' ", () => {
        expect(fibonacci(5)).toBe("0, 1, 1, 2, 3");
    });
});

使用 Stub

创建 ./helpers/__mocks__/sum.ts

注意

__mocks__/sum.ts 的作用是:在测试运行期间,把真实的 sum 替换掉,而不是修改生产代码。

typescript
const sum = (data: number[]): number => 999;
 
export { sum };

这个测试替身无论接收到什么数据,总是返回相同的数字 999。

注意

这个 stub:

  • 不关心输入
  • 不模拟真实逻辑
  • 只保证“接口存在”

stub 在这里不是为了“算对”,而是为了证明 Fibonacci 在循环中确实调用了 sum

使用 Fake

修改 ./helpers/__mocks__/sum.ts

typescript
const sum = (data: number[]): number => {
    return data[0] + data[1];
}
 
export { sum };

注意

  • 不破坏测试期望

  • 不引入真实复杂度

  • 不依赖真实实现

使用 Mock

修改 ./helpers/__mocks__/sum.ts

typescript
type resultMap = {
    [key: string]: number;
}
 
const results : resultMap= {
    "0+0": 0,
    "0+1": 1,
    "1+0": 1,
    "1+1": 2,
    "2+1": 3
};
 
const sum = (data: number[]): number => {
    return results[data.join("+")];
}
export { sum };

注意

  • mock 会“看参数”

  • mock 会“按规则返回”

注意

类型在 Fibonacci 例子中的作用
Stub证明“sum 被调用了多少次”
Fake提供足够真实的计算逻辑
Mock精确控制依赖返回值

更多测试类型

注意

1. Functional Tests(功能测试)

目的:验证代码从用户角度是否按预期工作。 特点

  • 关注“输入 → 输出”的正确性
  • 黑盒测试(不关心内部实现、状态或中间过程)
  • 不生成代码覆盖率报告
  • 通常由 QA(质量保证)人员编写
  • 单个模块不独立运行,强调用户功能是否正常

举例:检查按钮点击后是否弹出正确信息,或表单提交后是否返回预期结果。

2. Integration Tests(集成测试)

目的:验证多个模块/子系统之间的集成是否正确。 特点

  • 验证完整子系统或模块组合的行为
  • 不隔离运行,不使用 test doubles(除了外部 API 的特殊情况)
  • 用来发现三类问题:
    1. 模块间通信问题:例如内部 API 不匹配、未清理旧数据等
    2. 环境问题:不同 Node.js 版本、依赖包版本差异导致错误
    3. 网关/API 通信问题:可用 stub 模拟外部 API,如超时或成功请求
  • 通常由 QA 编写,开发者偶尔编写

举例

  • 数据库和业务逻辑模块交互
  • 调用外部支付 API 并验证响应

3. End-to-End Tests(端到端测试 / E2E)

目的:验证整个应用从前端到后端的完整业务流程。 特点

  • 黑盒测试,覆盖完整堆栈
  • 运行在特定环境下,依赖多层系统
  • 测试复杂且容易“flaky”(因为环境或依赖问题导致失败)
  • 速度慢、易超时、无法提供详细内部错误信息
  • 只测试关键业务流程
  • 通常由 QA 编写

举例

  • 用户登录 → 浏览商品 → 下单支付 → 查看订单历史

4. Snapshot Tests(快照测试 / 视觉回归测试)

目的:验证界面或组件的外观是否与上一次版本一致。 特点

  • 也称 视觉回归测试
  • 通过比较当前状态与先前保存的快照来判断变化
  • 避免手动维护 UI 细节的测试
  • Jest 实现方式:
    • React 组件渲染到虚拟 DOM
    • 序列化为文本保存到 __snapshots__ 文件夹
    • 高效、可靠,相比屏幕截图快照更稳定

举例

  • React 组件的渲染结果、DOM 结构是否变化
  • UI 样式或布局是否意外改变

Exercise 8 给项目添加测试用例

警告

原书上没有这一改动,但是改了可以让测试不报错。修改 middleware/db-connect.tsdbConnect() 执行后返回数据库实例:

typescript
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
import { storeDocument } from "../mongoose/weather/services";
 
async function dbConnect(): Promise<any | String> {
    const mongoServer = await MongoMemoryServer.create();
    const MONGOIO_URI = mongoServer.getUri();
    await mongoose.disconnect();
    
    let db = await mongoose . connect(MONGOIO_URI, {
		dbName: "Weather"
    });
 
    await storeDocument({
		zip: "96815",
		weather: "sunny",
		tempC: "25C",
		tempF: "70F",
		friends: ["96814", "96826"]
    });
    await storeDocument({
        zip: "96814",
        weather: "rainy",
        tempC: "20C",
 		tempF: "68F",
		friends: ["96815", "96826"]
    });
    await storeDocument({
        zip: "96826",
        weather: "rainy",
        tempC: "30C",
		tempF: "86F",
		friends: ["96815", "96814"]
    });
    return mongoServer;
}
export default dbConnect;

创建 ./__tests__/middleware/db-connect.test.ts

typescript
/**
 * @jest-environment node
 */
 
import dbConnect from "../../middleware/db-connect";
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
 
describe("dbConnect ", () => {
    
    let connection: any;
    
    afterEach(async () => {
        jest.clearAllMocks();
        await connection.stop();
        await mongoose.disconnect();
    });
    
    afterAll(async () => {
        jest.restoreAllMocks();
    });
    
    test("calls MongoMemoryServer.create()", async () => {
        const spy = jest.spyOn(MongoMemoryServer, "create");
        connection = await dbConnect();
        expect(spy).toHaveBeenCalled();
    });
    
    test("calls mongoose.disconnect()", async () => {
        const spy = jest.spyOn(mongoose, "disconnect");
        connection = await dbConnect();
        expect(spy).toHaveBeenCalled();
    });
    
    test("calls mongoose . connect()", async () => {
        const spy = jest.spyOn(mongoose, "connect");
        connection = await dbConnect();
        const MONGO_URI = connection.getUri();
        expect(spy).toHaveBeenCalledWith(MONGO_URI, {dbName: "Weather"});
    });
});

注意

这个测试文件做了三件事:

  1. 验证内存数据库是否被创建。
  2. 验证 Mongoose 是否连接和断开。
  3. 确保 dbConnect() 正确使用了 MongoMemoryServer 的 URI 和数据库名。
内容说明
测试目标验证 middleware 调用 MongoMemoryServer 和 mongoose API
技术Jest spy (jest.spyOn)
环境Node(@jest-environment node
生命周期管理afterEach 清理 mocks + 停止数据库;afterAll 恢复 mocks
测试策略只验证方法调用次数和参数,不关心内部实现或数据库结果
可测试性dbConnect 函数需返回 mongoServer 实例

提示

mongoose/weather/services.ts 里:

  • service 不直接操作数据库
  • 而是 通过 Mongoose 的 Model(WeatherModel) 来完成 CRUD
  • 例如:
    • WeatherModel.create(...)
    • WeatherModel.findOne(...)
    • WeatherModel.updateOne(...)
    • WeatherModel.deleteOne(...)

问题:这些方法都依赖真实数据库连接,所以创建一个假的 model 避开数据库的调用。

创建 mongoose/weather/__mocks__/model.ts

typescript
import { WeatherInterface } from "../interface";
 
type param = {
    [key: string]: string;
};
 
const WeatherModel = {
    create: jest.fn((newData: WeatherInterface) => Promise.resolve(true)),
    findOne: jest.fn(({ zip: paramZip }: param) => Promise.resolve(true)),
    updateOne: jest.fn(({ zip: paramZip }: param, newData: WeatherInterface) =>
        Promise.resolve(true)
    ),
    deleteOne: jest.fn(({ zip: paramZip }: param) => Promise.resolve(true))
};
export default WeatherModel;

创建测试用例 /__tests__/mongoose/weather/services.test.ts,用于验证 service 层是否把参数正确地传给了 WeatherModel,而不依赖真实 MongoDB。

typescript
/**
 * @jest-environment node
 * Force Jest to run in Node.js environment instead of jsdom
 */
 
import { WeatherInterface } from "../../../mongoose/weather/interface";
import {
    findByZip,
    storeDocument,
    updateByZip,
    deleteByZip,
} from "../../../mongoose/weather/services";
 
import WeatherModel from "../../../mongoose/weather/model";
 
// Mock the entire WeatherModel module so no real database is used
jest.mock("../../../mongoose/weather/model");
 
describe("the weather services", () => {
 
    // Mock weather document used across all tests
    let doc: WeatherInterface = {
        zip: "test",
        weather: "weather",
        tempC: "00",
        tempF: "01",
        friends: []
    };
 
    // Clear mock call history after each test to avoid test interference
    afterEach(async () => {
        jest.clearAllMocks();
    });
 
    // Restore original implementations after all tests complete
    afterAll(async () => {
        jest.restoreAllMocks();
    });
 
    /**
     * storeDocument tests
     * This API is responsible for creating a new weather document
     */
    describe("API storeDocument", () => {
 
        // Ensure the service returns a truthy value on success
        test("returns true", async () => {
            const result = await storeDocument(doc);
            expect(result).toBeTruthy();
        });
 
        // Ensure the document is passed correctly to WeatherModel.create
        test("passes the document to Model.create()", async () => {
            const spy = jest.spyOn(WeatherModel, "create");
            await storeDocument(doc);
            expect(spy).toHaveBeenCalledWith(doc);
        });
    });
 
    /**
     * findByZip tests
     * This API fetches a weather document by zip code
     */
    describe("API findByZip", () => {
 
        // Ensure the service returns a truthy value
        test("returns true", async () => {
            const result = await findByZip(doc.zip);
            expect(result).toBeTruthy();
        });
 
        // Ensure the correct query object is passed to Model.findOne
        test("passes the zip code to Model.findOne()", async () => {
            const spy = jest.spyOn(WeatherModel, "findOne");
            await findByZip(doc.zip);
            expect(spy).toHaveBeenCalledWith({ zip: doc.zip });
        });
    });
 
    /**
     * updateByZip tests
     * This API updates a weather document by zip code
     */
    describe("API updateByZip", () => {
 
        // Ensure the service returns a truthy value
        test("returns true", async () => {
            const result = await updateByZip(doc.zip, doc);
            expect(result).toBeTruthy();
        });
 
        // Ensure both filter and update payload are passed correctly
        test("passes the zip code and the new data to Model.updateOne()", async () => {
            const spy = jest.spyOn(WeatherModel, "updateOne");
            await updateByZip(doc.zip, doc);
            expect(spy).toHaveBeenCalledWith({ zip: doc.zip }, doc);
        });
    });
 
    /**
     * deleteByZip tests
     * This API deletes a weather document by zip code
     */
    describe("API deleteByZip", () => {
 
        // Ensure the service returns a truthy value
        test("returns true", async () => {
            const result = await deleteByZip(doc.zip);
            expect(result).toBeTruthy();
        });
 
        // Ensure the correct delete condition is passed to Model.deleteOne
        test("passes the zip code Model.deleteOne()", async () => {
            const spy = jest.spyOn(WeatherModel, "deleteOne");
            await deleteByZip(doc.zip);
            expect(spy).toHaveBeenCalledWith({ zip: doc.zip });
        });
    });
});

测试结果:

 PASS  __tests__/mongoose/weather/services.test.ts                                                    
 PASS  helpers/sum.test.ts
 PASS  helpers/fibonacci.test.ts                                                            
 PASS  __tests__/middleware/connect.test.ts
------------------|---------|----------|---------|---------|---------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s   
------------------|---------|----------|---------|---------|---------------------
All files         |   81.81 |      100 |      75 |   81.13 |                    
 helpers          |     100 |      100 |     100 |     100 |                    
  fibonacci.ts    |     100 |      100 |     100 |     100 |                    
  sum.ts          |     100 |      100 |     100 |     100 |                    
 middleware       |     100 |      100 |     100 |     100 |                    
  db-connect.ts   |     100 |      100 |     100 |     100 |                    
 mongoose/weather |   65.51 |      100 |    62.5 |   65.51 |                    
  model.ts        |      50 |      100 |      25 |      50 | 7-11               
  services.ts     |   69.56 |      100 |     100 |   69.56 | 8,19-21,32-34,44-46
------------------|---------|----------|---------|---------|---------------------

Test Suites: 4 passed, 4 total
Tests:       16 passed, 16 total
Snapshots:   0 total
Time:        3.549 s, estimated 18 s
Ran all test suites.

一些失败的代码行没有被测试到。

webp

为 REST API 提供端到端测试

注意

E2E(端到端)测试的含义:不 mock、不隔离任何模块,直接从“HTTP 请求 → API → middleware → service → 数据库 → 返回响应”,验证整个系统是否真的能跑通。

测试类型是否隔离是否 mock关注点
单元测试某个函数是否正确
集成测试通常否模块之间是否协作
端到端测试系统是否整体可用

创建 __tests__/pages/api/v1/weather/zipcode.e2e.test.ts

typescript
/**
 * @jest-environment node
 */
 
describe("The API /v1/weather/[zipcode]", () => {
    test("returns the correct data for the zipcode 96815", async () => {
        const zip = "96815";
let response = await fetch(`http://localhost:3000/api/v1/weather/${zip}`);
        let body = await response.json();
        expect(body.zip).toEqual(zip);
    });
});
 
export {};

注意

书里提到:

  • SuperTest
    • 更专业
    • 可断言 HTTP 状态码、header
  • Postman
    • GUI 手动测试工具

这里不用它们,是因为:

  • 教学示例要 简单
  • 重点在 Jest 的 E2E 思路,而不是工具细节

fetch 已经足够表达“端到端”概念

使用快照测试(Snapshot Test)测试 UI

注意

UI 的问题是:

  • 属性多(width / height / class / text / children…)
  • 手写断言成本极高
  • 改一点样式,可能要改几十个断言

Snapshot 的思路是:

  • 第一次运行:UI → 序列化 → 存成快照文件(baseline)

  • 以后运行:UI → 序列化 → 和快照做 diff

如果不同:

  • Jest 报错
  • 明确告诉你哪一行 UI 变了

非常适合 React 组件 / 页面结构测试

继续安装:

shell
npm install --save-dev jest-environment-jsdom
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @types/react-test-renderer react-test-renderer

修改 jest.config.js 以让 Jest 理解 Next.js 项目结构。

javascript
const nextJest = require("next/jest");
const createJestConfig = nextJest({});
 
module.exports = createJestConfig(nextJest({}));

创建 __tests__/pages/components/weather.snapshot.test.tsx

提示

原书代码中 actcreate 已弃用。

tsx
/**
 * @jest-environment jsdom
 */
 
import { render } from "@testing-library/react";
import PageComponentWeather from "../../../pages/components/weather";
 
describe("PageComponentWeather", () => {
    test("renders correctly", () => {
        const { asFragment } = render(<PageComponentWeather />);
        expect(asFragment()).toMatchSnapshot();
    });
});

先在一个终端启动服务器 npm run dev,然后在另一个终端 npm test 执行测试。得到 __tests__/pages/components/__snapshots__/weather.snapshot.test.tsx.snap

tsx
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
 
exports[`PageComponentWeather renders correctly 1`] = `
<DocumentFragment>
  <h1>
    The weather is sunny, counter 1
  </h1>
</DocumentFragment>
`;

根据 snapshot 判断 DOM 结构是否改变。

第二版测试

提示

第一版 snapshot 只测“初始渲染”,第二版通过 act + react-test-renderer 把点击和 useEffect 也纳入 snapshot,从而提高覆盖率。

当心

原书上修改 __tests__/pages/components/weather.snapshot.test.tsx

tsx
/**
 * @jest-environment node
 */
import { act, create } from "react-test-renderer";
import PageComponentWeather from "../../../pages/components/weather";
describe("PageComponentWeather", () => {
    test("renders correctly", async () => {
        let component: any;
        await act(async () => {
            component = await create(<PageComponentWeather></PageComponentWeather>);
        });
        expect(component.toJSON()).toMatchSnapshot();
    });
    test("clicks the h1 element and updates the state", async () => {
        let component: any;
        await act(async () => {
            component = await create(<PageComponentWeather></PageComponentWeather>);
            component.root.findByType("h1").props.onClick();
        });
        expect(component.toJSON()).toMatchSnapshot();
    });
});

在目前已经弃用。

安装:

shell
npm install --save-dev @testing-library/jest-dom

修改 __tests__/pages/components/weather.snapshot.test.tsx

tsx
/**
 * @jest-environment jsdom
 */
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from "@testing-library/react";
import PageComponentWeather from "../../../pages/components/weather";
 
test("renders correctly", () => {
    const { asFragment } = render(<PageComponentWeather />);
    expect(asFragment()).toMatchSnapshot();
});
 
test("click updates counter", () => {
    render(<PageComponentWeather />);
    fireEvent.click(screen.getByRole("heading"));
    expect(screen.getByText(/counter 2/i)).toBeInTheDocument();
});
 

这一用例会在页面上找到标题并模拟用户点击它。

测试后提示:

Snapshot Summary
 › 1 snapshot written from 1 test suite.
 › 1 snapshot obsolete from 1 test suite. To remove it, run `npm test -- -u`.
   ↳ __tests__/pages/components/weather.snapshot.test.tsx
       • PageComponentWeather renders correctly 1

注意

Snapshot 中有一个过时:

  • 说明组件或测试发生变化
  • 可选择更新快照 (npm test -- -u)
  • 或保留旧快照以便对比

9 AUTHORIZATION WITH OAUTH

注意

很多应用不自己做登录,而是“借用”大厂账号(Google / GitHub / Facebook)来登录,这件事最常用、最标准的方案就是 OAuth2。

  • Authentication:你是谁
  • Authorization:你能干什么
  • OAuth:把“你是谁 + 你能干什么”这两件事,委托给第三方来做

OAuth 的 Grant Types(授权模式):

  • Client Credentials Flow(客户端凭证模式)
    • 没有用户参与,客户端用自己的 client_id + client_secret 直接换 access_token
  • Authorization Code Flow(授权码模式)
    • 有用户参与,先登录并授权,再用 授权码换 token(最标准,最常用)
  • Implicit Flow(已废弃)
  • Resource Owner Password Credentials(高风险)

注意

Grant Type是否推荐场景
Client Credentials机器对机器
Authorization Code✅(最重要)用户登录
Implicit已废弃
Password高风险
  • Bearer Token:登录凭据

  • JWT:Bearer Token 的“具体实现形式”

    • OAuth ≠ JWT,但 OAuth 非常常用 JWT 作为 Access Token

Authorization Code Flow

项目的登录流程:

  1. 用户访问你的应用
  2. 你跳转到 OAuth Provider
  3. 用户登录 + 同意授权
  4. Provider 返回 Authorization Code
  5. 你的后端用 Code 换 Access Token
  6. 用 Token 访问用户数据
webp

以 Github 为例:

  1. 注册应用(client_id / secret / redirect_uri)
  2. 用户点击 Login with GitHub
  3. 跳转 GitHub 授权页
  4. 用户同意 scope
  5. GitHub → redirect_uri?code=xxx
  6. 后端用 code + client_secret 换 token
  7. token 存 session
  8. Authorization: Bearer token
  9. 访问用户数据 / 执行业务逻辑

创建一个 JWT Token

JWT 长这样:header.payload.signature

注意

部分干什么
Header说明“这是什么 token,用什么算法签的”
Payload放数据(claims)
Signature防伪、防篡改

Header

js
{
  "typ": "JWT",
  "alg": "HS256"
}
  • typ:这是 JWT
  • alg:使用对称密钥签名,安全性依赖 secret 的保密性

Payload

OAuth 真正有用的信息都在 payload 里。

javascript
const payloadObject = {
  "iss": "https://www.usemodernfullstack.dev/",
  "sub": "THE_CLIENT_ID",
  "aud": "api://endpoint",
  "exp": 234133423,                 // Registered
  "weather_public_zip": "96815",    // Public
  "weather_private_type": "GitHub"  // Private
}

注意

类型字段示例说明
Registerediss"https://www.usemodernfullstack.dev/"token 签发者
Registeredsub"THE_CLIENT_ID"token 属于谁(client 或 user)
Registeredaud"api://endpoint"token 接收方(谁能用)
Registeredexp234133423过期时间
Publicweather_public_zip"96815"对外公开的业务数据
Privateweather_private_type"GitHub"系统内部使用的业务信息

概念上:完整的 payload 示例

生产上:需要:

  1. 调整时间戳
  2. 添加 iatjtinbf 等安全字段
  3. 审查 public/private claim 命名是否安全
Claim作用
iat什么时候签发
nbf(书里写 nfb,概念是 not before)多早之前不能用
jtitoken 唯一 ID(防重放)

Signature

格式如下:

javascript
HMAC_SHA256(
  base64(header) + "." + base64(payload),
  secret
)

注意

可以确保完整性。

  • 改 header → 签名不匹配

  • 改 payload → 签名不匹配

  • 没 secret → 造不出合法 token

在一个空白文件夹下创建 index.ts

typescript
import { createHmac } from "crypto";
 
const base64UrlEncode = (data: string): string => {
    return Buffer.from(data, "utf-8").toString("base64");
};
 
const headerObject = {
    typ: "JWT",
    alg: "HS256"
};
 
const payloadObject = {
    exp: 234133423,
    weather_public_zip: "96815",
    weather_private_type: "GitHub"
};
 
const createJWT = () => {
    const base64Header = base64UrlEncode(JSON.stringify(headerObject));
    const base64Payload = base64UrlEncode(JSON.stringify(payloadObject));
    const secret = "59c4b48eac7e9ac37c046ba88964870d";
    const signature: string = createHmac("sha256", secret)
        .update(`${base64Header}.${base64Payload}`)
        .digest("hex");
        return [base64Header, base64Payload, signature].join(".");
};
 
console.log(createJWT());

可以从 usemodernfullstack.dev/generate-secret 处获得随机的 secret。

命令行:

shell
npm install --save-dev @types/node
npx tsc index.ts --outDir . --module commonjs && node index.js
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjIzNDEzMzQyMywid2VhdGhlcl9wdWJsaWNfemlwIjoiOTY4MTUiLCJ3ZWF0aGVyX3ByaXZhdGVfdHlwZSI6IkdpdEh1YiJ9.f667c81749886ee01831376a38fbdba4d7f59a14c14f3a60e1bbee977c993ac9

Exercise 9 访问受保护的资源

如果没有登录就执行下面的命令:

shell
curl -i -X GET "https://www.usemodernfullstack.dev/protected/resource" -H "Accept: text/html"

会被服务器返回 401。

HTTP/1.1 401 Unauthorized
x-powered-by: Express
access-control-allow-origin: *
content-type: text/html; charset=utf-8
content-length: 7952
etag: W/"1f10-DDf3/XU6iLxte70QtBJcjmif/OM"
set-cookie: connect.sid=s%3AEXzxn4_6_g4liJMUU-cpXVcjLlqDwTYe.ab9qfv5r9vicurCU92052vhn%2BUW6UfvK4GPf0GnFalU; Path=/; Expires=Mon, 26 Jan 2026 10:14:09 GMT; HttpOnly
date: Mon, 26 Jan 2026 10:13:09 GMT
connection: keep-alive
keep-alive: timeout=5
server: Fly/de22bbc0 (2026-01-23)
via: 1.1 fly.io
fly-request-id: 01KFWWNSNGFTMN93JF0PDNHVZZ-sjc

...

设置 OAuth 客户端

进入 https://www.usemodernfullstack.dev/register

填写信息:

webp

Create an Account and move on to the OAuth Client 以得到 Client IDClient Secret

webp

命令行:

shell
curl -i -X POST "https://www.usemodernfullstack.dev/oauth/authenticate" -H "Accept: text/html" -H "Content-Type: application/x-www-form-urlencoded" -d "response_type=code&client_id=client-1769422655842&state=4nBjkh31&scope=read&redirect_uri=http://localhost:3000/oauth/callback&username=promefire&password=i_love_promefire"

得到:

HTTP/1.1 302 Found
x-powered-by: Express
access-control-allow-origin: *
location: http://localhost:3000/oauth/callback?code=ccd70b795084357dbdedb89e42b6620a65d4b939&state=4nBjkh31
vary: Accept
content-type: text/html; charset=utf-8
content-length: 246
set-cookie: connect.sid=s%3AT0Urx23D3kZNiQFw6xaeXNZIExjfSFk8.S1lChsYutbjDgIVJu374gW%2Bd%2FuKr5KaVfmd4kbzXbPM; Path=/; Expires=Mon, 26 Jan 2026 10:26:09 GMT; HttpOnly
date: Mon, 26 Jan 2026 10:25:09 GMT
connection: keep-alive
keep-alive: timeout=5
server: Fly/de22bbc0 (2026-01-23)
via: 1.1 fly.io
fly-request-id: 01KFWXBYGHBAB4762ZKDCB2XX9-sjc

<p>Found. Redirecting to <a href="http://localhost:3000/oauth/callback?code=ccd70b795084357dbdedb89e42b6620a65d4b939&amp;state=4nBjkh31">http://localhost:3000/oauth/callback?code=ccd70b795084357dbdedb89e42b6620a65d4b939&amp;state=4nBjkh31</a></p>

注意

参数含义
response_type=code请求授权码(Authorization Code Flow)
client_idOAuth 客户端 ID,标识你的应用
redirect_uri回调 URL,授权码会被重定向到这里
scope请求的权限范围,例如 read
state随机字符串,防止 CSRF 攻击
username & password用户登录凭证(资源所有者)
  • 302 重定向 → OAuth 服务把用户重定向到回调 URL

  • URL 参数 code → 授权码(Authorization Grant)

  • URL 参数 state → 原样返回,验证请求是否安全(防 CSRF)

  • Authorization Code Flow 中,OAuth 服务器在 /oauth/authorize/oauth/authenticate 返回 302,只是告诉浏览器“去回调 URL”

    • 真正的授权结果在 回调 URL 的参数里
      • 如果成功 → code=<AUTHORIZATION_GRANT>
      • 如果失败 → 可能有 error=access_denied 或其他错误参数

现在拿到了一个授权码(URL 里 code=ccd70b795084357dbdedb89e42b6620a65d4b939,每次调用得到的 code 都不同),之后用这个授权码换取一个访问令牌(access token),才能真正访问用户的受保护资源。

注意

OAuth 标准规定,用授权码换取访问令牌的端点通常是:POST /oauth/access_token

  • 这里是:https://www.usemodernfullstack.dev/oauth/access_token

命令行:

shell
curl -i -X POST "https://www.usemodernfullstack.dev/oauth/access_token" -H "Accept: application/json" -H "Content-Type: application/x-www-form-urlencoded" -d "code=80d4c97300a529a1359c4e766269bd23c255ef99&grant_type=authorization_code&redirect_uri=http://localhost:3000/oauth/callback&client_id=client-1769422655842&client_secret=36d9f3fbf3d53385d3b2560852ad70dd"

得到访问令牌

HTTP/1.1 200 OK
x-powered-by: Express
access-control-allow-origin: *
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 173
etag: W/"ad-nuZnrrIRhswW5y+5PAB9OvNZ/Jk"
set-cookie: connect.sid=s%3ACETIP7pdZUCVa8MYh10Ppfqg9lAM7o4h.ZtWfxRBspz77Z4VBL7jDJ%2FSUJk1tylIejfUE7hkNLds; Path=/; Expires=Mon, 26 Jan 2026 10:49:23 GMT; HttpOnly
date: Mon, 26 Jan 2026 10:48:23 GMT
connection: keep-alive
keep-alive: timeout=5
server: Fly/de22bbc0 (2026-01-23)
via: 1.1 fly.io, 1.1 fly.io
fly-request-id: 01KFWYPFKH30P4W90TXM23ZMRG-lax

{"access_token":"3d182fe4da6ce3b611ac4442814db845d89fd59c","token_type":"Bearer","expires_in":3599,"refresh_token":"d9ec1eb456d9332833361df213e598a19128d2d9","scope":"read"}

使用这个访问令牌去访问受保护的资源:

shell
curl -i -X GET "https://www.usemodernfullstack.dev/protected/resource" -H "Accept: text/html" -H "Authorization: Bearer "3d182fe4da6ce3b611ac4442814db845d89fd59c"
HTTP/1.1 200 OK
x-powered-by: Express
access-control-allow-origin: *
content-type: text/html; charset=utf-8
content-length: 7328
etag: W/"1ca0-eDaS6zqpMccBITBa2xwqx1oH6c4"
set-cookie: connect.sid=s%3AOVyuW-E8_Rw7lSYnBsEEqAE8jTx0Ibvh.%2FDRBrzFT4sBsUgnuqhkXJCx7iHBylNILl7Fu%2Blf8O3o; Path=/; Expires=Mon, 26 Jan 2026 10:52:43 GMT; HttpOnly
date: Mon, 26 Jan 2026 10:51:43 GMT
connection: keep-alive
keep-alive: timeout=5
server: Fly/de22bbc0 (2026-01-23)
via: 1.1 fly.io, 1.1 fly.io
fly-request-id: 01KFWYWK19YG24CX9W5WRC5T5H-lax

<html>

    <head>
        <title></title>
        <link rel="stylesheet" href="/stylesheets/style.css" />
        <link
            href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
            rel="stylesheet"
            integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65"
            crossorigin="anonymous"
        />
        <script
            src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4"
            crossorigin="anonymous"
        ></script>
        <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script>

    </head>

    <body>
        <header class="header-root">
            <div class="layout-grid">
                <a href="/" class="logo-root">
                    <img
                        src="/assets/logo.svg"
                        alt="Logo: Use Modern Fullstack Development - Portal & OAuth Server"
                        sizes="100vw"
                        height="60"
                    />
                </a>

            </div>
        </header>


        <div class="container" style="padding-top: 60px;""">
            <h1>This page is secured.</h1>
<code>{"oauth":{"token":{"_id":"69774677cb66f7029d120408","user":{"_id":"6977406587d32e02a0b4b508","firstName":"mefire","lastName":"pro","username":"promefire","email":"1904817346@qq.com","verificationCode":"3e58bcf2dd0d65a1b59df3ca496b1209","password":"541008216790f59b019c28ff540872b6a1e2c38e97b30338086f82c56da61f9b","createdAt":"2026-01-26T10:22:29.445Z","updatedAt":"2026-01-26T10:22:29.445Z","__v":0},"client":{"redirectUris":["http://localhost:3000/oauth/callback"],"grants":["authorization_code","client_credentials","refresh_token","password"],"_id":"6977406587d32e02a0b4b50b","user":"6977406587d32e02a0b4b508","clientId":"client-1769422655842","clientSecret":"36d9f3fbf3d53385d3b2560852ad70dd","createdAt":"2026-01-26T10:22:29.850Z","updatedAt":"2026-01-26T10:22:29.850Z","__v":0},"accessToken":"3d182fe4da6ce3b611ac4442814db845d89fd59c","accessTokenExpiresAt":"2026-01-26T11:48:23.032Z","refreshToken":"d9ec1eb456d9332833361df213e598a19128d2d9","refreshTokenExpiresAt":"2026-02-09T10:48:23.032Z","scope":"read","createdAt":"2026-01-26T10:48:23.034Z","updatedAt":"2026-01-26T10:48:23.034Z","__v":0}}}</code>
        </div>

    </div>

    <button class="navbar-toggler hamburger" type="button" data-toggle="offcanvas">
        <span class="hamburger-inner"></span>
    </button>
        <div class="navbar-collapse offcanvas-collapse">
            <ul class="navbar-nav nav-root">

                <li class="nav-item nav_item-root">
                    <a class="nav-link" href="/register">
                        <h2 class="nav_item-headline">
                            Register
                            <br /><small class="nav_item-details">
                                A User And An OAuth Client
                            </small>
                        </h2>
                    </a>
                </li>
                <li class="nav-item nav_item-root">
                    <a class="nav-link" href="/oauth/authenticate">
                        <h2 class="nav_item-headline">
                            Login
                            <br /><small class="nav_item-details">
                                And Authorize
                            </small>
                        </h2>
                    </a>
                </li>
                <li class="nav-item nav_item-root">
                    <a class="nav-link" href="/downloads">
                        <h2 class="nav_item-headline">
                            Download
                            <br /><small class="nav_item-details">
                                Listings And Assets
                            </small>
                        </h2>
                    </a>
                </li>
                <li class="nav-item nav_item-root">
                    <a class="nav-link" href="/generate-secret">
                        <h2 class="nav_item-headline">
                            Generate
                            <br /><small class="nav_item-details">
                                A Secret
                            </small>
                        </h2>
                    </a>
                </li><li class="nav-item nav_item-root">
                    <a class="nav-link" href="/privacy-policy">
                        <h2 class="nav_item-headline">
                            Read
                            <br /><small class="nav_item-details">
                                The Privacy Policy
                            </small>
                        </h2>
                    </a>
                </li>
            </ul>
        </div>
        <div class="modal-overlay"></div>

<script>
const offcanvasToggle = document.querySelector('[data-toggle="offcanvas"]');
const offcanvasCollapse = document.querySelector('.offcanvas-collapse');
const hamburger = document.querySelector('.hamburger');
const hamburgerInner = document.querySelector('.hamburger-inner');

offcanvasToggle.addEventListener('click', function () {
  offcanvasCollapse.classList.toggle('show');
  hamburger.classList.toggle('collapsed');
});
        </script>
      <div id="cookie-banner" class="alert alert-dismissible alert-info mb-0" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 999; display: none;">
    <div class="row col-lg-9 col-md-9 offset-lg-2">
    <p class="mb-0">We use cookies to improve your experience on our website. By continuing to use our website, you consent to the use of cookies as described in our <a href="/privacy-policy" class="alert-link">Privacy Policy</a>.</p>
    </div>
    <div class=" container col-lg-9 col-md-9 offset-lg-2">
    <button id="accept-cookies" class="btn btn-primary mt-2 float-right">I Accept</button>
    </div>
  </div>

  <script>
    function setCookie(name, value, days) {
      let expires = "";
      if (days) {
        const date = new Date();
        date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
        expires = "; expires=" + date.toUTCString();
      }
      document.cookie = name + "=" + (value || "")  + expires + "; path=/";
    }

    function getCookie(name) {
      const nameEQ = name + "=";
      const ca = document.cookie.split(';');
      for(let i = 0; i < ca.length; i++) {
        let c = ca[i];
        while (c.charAt(0) == ' ') {
          c = c.substring(1, c.length);
        }
        if (c.indexOf(nameEQ) == 0) {
          return c.substring(nameEQ.length, c.length);
        }
      }
      return null;
    }

    const cookieBanner = document.getElementById('cookie-banner');
    const acceptCookiesBtn = document.getElementById('accept-cookies');

    function hideCookieBanner() {
      cookieBanner.style.display = 'none';
      setCookie('cookieConsent', 'true', 365);
    }
    cookieBanner.style.display = 'none';
    if (!getCookie('cookieConsent')) {
      cookieBanner.style.display = 'block';
      acceptCookiesBtn.addEventListener('click', hideCookieBanner);
    }
    acceptCookiesBtn.addEventListener("click", hideCookieBanner)
  </script>



    </body>

</html>

10 CONTAINERIZATION WITH DOCKER

使用 Docker:

第一个优点:可以为每个项目运行特定版本的软件(比如 Node.js)。

  • 意义:避免不同项目间的依赖冲突。例如,一个项目用 Node.js 18,另一个用 Node.js 20,Docker 可以让它们共存而互不干扰。

第二个优点:开发环境与本地机器解耦,并且创建可重复运行的应用环境。

  • 意义:无论在谁的电脑上运行 Docker 容器,应用表现都是一致的,解决“在我电脑上能运行,但你电脑上不行”的问题。

第三个优点:与传统虚拟机不同,Docker 容器共享主机资源。

  • 结果:容器体积更小、占用内存少、启动快。

安装 Docker 后,检查:

shell
docker -v
Docker version 28.3.3, build 980b856

创建一个 Docker 容器

注意

Docker 的核心组件

Host(主机)

  • Docker 容器运行在一个物理机或虚拟机上,这台机器称为 Host
  • 开发阶段:Host = 你的本地电脑
  • 部署阶段:Host = 服务器

Docker daemon(守护进程)

  • 安装在 Host 上的核心 Docker 服务。

  • 功能:通过 API 提供 Docker 的所有功能。

  • 操作方式:使用命令行 docker 来与 daemon 交互,例如:

    shell
    docker --help

    可以查看所有可能的操作。

Docker 容器

  • 容器是运行中的应用实例。
  • 容器来源于 Docker 镜像(image),镜像是一个包含应用及依赖的可执行包。

在之前 Next.js 项目目录下的 Dockerfile:

dockerfile
FROM node:current
WORKDIR /home/node
COPY package.json package-lock.json /home/node/
EXPOSE 3000

注意

FROM node:current

  • 使用官方 Node.js 镜像,current 表示最新版本。
  • 如果需要锁定特定版本,可以改成 node:18 或其他版本。
  • 可选轻量版:node:current-slim,只包含运行 Node.js 必要的软件包。

WORKDIR /home/node

  • 设置容器内部的工作目录。
  • 后续所有命令都将在该目录下执行。

COPY package.json package-lock.json /home/node/

  • 将项目根目录下的 package.jsonpackage-lock.json 文件复制到容器内的工作目录。
  • Node.js 应用依赖这些文件安装依赖。

EXPOSE 3000

  • 容器对外暴露端口 3000(Node.js 默认端口)。
  • 通过该端口,外部可以访问容器里的应用。

构建 Docker Image

在确保 Docker 服务启动后,使用以下命令构建 Docker 镜像:

shell
docker image build --tag nextjs:latest .
[+] Building 198.7s (8/8) FINISHED                                                                                                                                                                      docker:desktop-linux 
 => [internal] load build definition from dockerfile                                                                                                                                                                    0.3s 
 => => transferring dockerfile: 136B                                                                                                                                                                                    0.2s 
 => [internal] load metadata for docker.io/library/node:current                                                                                                                                                         6.4s 
 => [internal] load .dockerignore                                                                                                                                                                                       0.0s 
 => => transferring context: 2B                                                                                                                                                                                         0.0s 
 => [1/3] FROM docker.io/library/node:current@sha256:6d362f0df70431417ef79c30e47c0515ea9066d8be8011e859c6c3575514a027                                                                                                 190.9s 
 => => resolve docker.io/library/node:current@sha256:6d362f0df70431417ef79c30e47c0515ea9066d8be8011e859c6c3575514a027                                                                                                   0.0s 
 => => sha256:d0685dc4844a986b6e8157c6cbc8b3323c5bb38d7be7785110cb0dbd78045c80 446B / 446B                                                                                                                              1.1s 
 => => sha256:e23bb902ff9eab98ef1bd51e5a730a055d17fbda8618a8db84d38153bb1cf51e 1.25MB / 1.25MB                                                                                                                          4.3s 
 => => sha256:01938a8434c59a276a5e8c8fe28916dbf67847c1ea0d15cc8951376d4788e78b 56.16MB / 56.16MB                                                                                                                      103.8s 
 => => sha256:d0060ea1869cc1dabda25c43283da6795c5cfcbe3d06e94e86632a27b927e893 3.32kB / 3.32kB                                                                                                                          1.6s 
 => => sha256:318d61060ae74f5254974208e92a54f807b028710293f41900249bc6033acf41 211.47MB / 211.47MB                                                                                                                    179.7s 
 => => sha256:a858b7813255a9cb57d05f02b50978e5b5965b0cfc040288fa29905cdc65ad9a 64.40MB / 64.40MB                                                                                                                      111.9s 
 => => sha256:16afb0fdc4694732853f4fbf5125c1dcb35f20cca5bec77a98d73d0d3124f855 24.03MB / 24.03MB                                                                                                                       45.9s 
 => => sha256:32a5bf163bd75109aaa8d446f1570117432475cbb2df3fb6f89dd243bcedd1f3 48.48MB / 48.48MB                                                                                                                       72.2s 
 => => extracting sha256:32a5bf163bd75109aaa8d446f1570117432475cbb2df3fb6f89dd243bcedd1f3                                                                                                                               2.7s 
 => => extracting sha256:16afb0fdc4694732853f4fbf5125c1dcb35f20cca5bec77a98d73d0d3124f855                                                                                                                               1.1s 
 => => extracting sha256:a858b7813255a9cb57d05f02b50978e5b5965b0cfc040288fa29905cdc65ad9a                                                                                                                               5.3s 
 => => extracting sha256:318d61060ae74f5254974208e92a54f807b028710293f41900249bc6033acf41                                                                                                                               6.7s 
 => => extracting sha256:d0060ea1869cc1dabda25c43283da6795c5cfcbe3d06e94e86632a27b927e893                                                                                                                               0.0s 
 => => extracting sha256:01938a8434c59a276a5e8c8fe28916dbf67847c1ea0d15cc8951376d4788e78b                                                                                                                               3.2s
 => => extracting sha256:e23bb902ff9eab98ef1bd51e5a730a055d17fbda8618a8db84d38153bb1cf51e                                                                                                                               0.1s
 => => extracting sha256:d0685dc4844a986b6e8157c6cbc8b3323c5bb38d7be7785110cb0dbd78045c80                                                                                                                               0.0s
 => [internal] load build context                                                                                                                                                                                       0.1s
 => => transferring context: 409.48kB                                                                                                                                                                                   0.1s
 => [2/3] WORKDIR /home/node                                                                                                                                                                                            0.5s 
 => [3/3] COPY package.json package-lock.json /home/node/                                                                                                                                                               0.1s 
 => exporting to image                                                                                                                                                                                                  0.3s 
 => => exporting layers                                                                                                                                                                                                 0.1s 
 => => exporting manifest sha256:f64ebf00d23697fcc33c60075de039dd2902238423bb3d1d3bbfeed6ae674f70                                                                                                                       0.0s 
 => => exporting config sha256:f9ab292bd0076b5ef5dc349b8583f69fd2f38d45167db7d9c1da0d2803658ab3                                                                                                                         0.0s 
 => => exporting attestation manifest sha256:e5ac56043b626d7f074f8acbf1cfff60a22282f49980a639ac318eb53592b5b1                                                                                                           0.0s 
 => => exporting manifest list sha256:b822981104cad83cbcc35ae5c8c98e185b5906269beba6848c4fab24fbd3c910                                                                                                                  0.0s 
 => => naming to docker.io/library/nextjs:latest                                                                                                                                                                        0.0s 
 => => unpacking to docker.io/library/nextjs:latest  

通过以下命令列出本地所有 Docker 镜像:

shell
docker image ls
REPOSITORY                         TAG         IMAGE ID       CREATED              SIZE
nextjs                             latest      b822981104ca   About a minute ago   1.62GB

删除旧的镜像:

shell
docker container rm nextjs_container

启动容器并运行 Next.js:

shell
docker container run `
  --name nextjs_container `
  --volume D:/Users/Documents/Study/sample-next/my-app:/home/node/ `
  --publish-all `
  nextjs:latest npm run dev

注意

标志功能说明
--name nextjs_container容器名称唯一标识容器,方便后续操作
--volume ~/nextjs_refactored/:/home/node/挂载本地目录将本地 Next.js 项目同步到容器内 /home/node/
--publish-all自动端口映射将容器内部 EXPOSE 的端口映射到宿主机的随机端口
nextjs:latest镜像使用刚刚构建的 Next.js 镜像
npm run dev容器内命令启动 Next.js 开发服务器
>>   --name nextjs_container `
>>   --volume D:/Users/Documents/Study/sample-next/my-app:/home/node/ `
>>   --publish-all `
>>   nextjs:latest npm run dev

> my-app@0.1.0 dev
> next dev

  Downloading swc package @next/swc-linux-x64-gnu... to /root/.cache/next-swc
  Downloading swc package @next/swc-linux-x64-musl... to /root/.cache/next-swc
▲ Next.js 16.1.1 (Turbopack)
- Local:         http://localhost:3000
- Network:       http://172.17.0.2:3000

✓ Starting...
Attention: Next.js now collects completely anonymous telemetry regarding usage.
This information is used to shape Next.js' roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://nextjs.org/telemetry

Failed to benchmark file I/O: No such file or directory (os error 2)
✓ Ready in 73.1s

注意

使用 --publish-all 时,Docker 会随机分配宿主机端口映射到容器端口 3000。

浏览器直接访问 http://localhost:3000 可能无法访问,因为宿主机端口不是 3000。

使用以下命令查看实际映射端口:

shell
docker container ls
CONTAINER ID   IMAGE           COMMAND                   CREATED         STATUS         PORTS                     NAMES
37aedb295b7b   nextjs:latest   "docker-entrypoint.s…"   3 minutes ago   Up 3 minutes   0.0.0.0:32769->3000/tcp   nextjs_container

宿主机端口 32769 映射到容器端口 3000,所以访问 Next.js 应用的地址是:http://localhost:32769/

与容器交互

注意

shell
docker container exec -it <container ID or name> /bin/sh
  • exec → 在已运行的容器内执行命令

  • -it → 交互式终端(interactive + tty)

  • /bin/sh → 启动容器内 shell,可以手动查看或调试容器文件

停止容器

注意

shell
docker container kill <container ID or name>
  • 停止正在运行的容器
  • 可以用 容器名容器 ID 指定目标

使用 Docker Compose 做微服务

注意

架构特点
单体应用(Monolith)前端 + 后端 + 数据库全在一个程序里,耦合严重
微服务前端 / 后端 / 测试 / DB 各是独立服务

提示

原书上不是在 Windows 系统下进行的,直接照搬原书的会有问题。

修改 dockerfile

dockerfile
FROM node:current
 
WORKDIR /home/node
 
# 先复制依赖描述文件
COPY package.json package-lock.json ./
 
# 在容器里安装依赖(Linux 平台)
RUN npm ci
 
# 再复制源代码(给非 volume 场景用)
COPY . .
 
EXPOSE 3000
 
CMD ["npm", "run", "dev"]

项目中创建 .dockerignore

text
# Node
node_modules
npm-debug.log
yarn-error.log
 
# Next.js
.next
out
dist
 
# Tests
coverage
 
# Docker
Dockerfile
docker-compose.yml
 
# Git
.git
.gitignore
 
# OS
.DS_Store
Thumbs.db

项目中创建 docker-compose.yml

yaml
services:
  application:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - ./:/home/node/
      - /home/node/node_modules
    command: npm run dev
 
  jest:
    build: .
    volumes:
      - ./:/home/node/
      - /home/node/node_modules
    command: npx jest ./__tests__/mongoose/weather/services.test.ts --watchAll

清理之前的容器:

shell
docker compose down -v
docker compose build --no-cache

启动微服务:

shell
docker compose up
[+] Running 3/3
 ✔ Network my-app_default          Created                                                                                                                                                                              0.1s 
 ✔ Container my-app-application-1  Created                                                                                                                                                                             18.7s 
 ✔ Container my-app-jest-1         Created                                                                                                                                                                             18.7s 
Attaching to application-1, jest-1
application-1  | 
application-1  | > my-app@0.1.0 dev
application-1  | > next dev
application-1  | 
application-1  | ▲ Next.js 16.1.1 (Turbopack)
application-1  | - Local:         http://localhost:3000
application-1  | - Network:       http://172.21.0.2:3000                                                                                                                                                                     
application-1  | 
application-1  | ✓ Starting...
application-1  | ✓ Ready in 6s
jest-1         | (node:69) Warning: `--localstorage-file` was provided without a valid path
jest-1         | (Use `node --trace-warnings ...` to show where the warning was created)
jest-1         | PASS __tests__/mongoose/weather/services.test.ts
jest-1         |   the weather services
jest-1         |     API storeDocument                                                                                                                                                                                       
jest-1         |       ✓ returns true (13 ms)
jest-1         |       ✓ passes the document to Model.create() (3 ms)                                                                                                                                                        
jest-1         |     API findByZip
jest-1         |       ✓ returns true (3 ms)
jest-1         |       ✓ passes the zip code to Model.findOne() (2 ms)                                                                                                                                                       
jest-1         |     API updateByZip
jest-1         |       ✓ returns true (2 ms)
jest-1         |       ✓ passes the zip code and the new data to Model.updateOne() (3 ms)                                                                                                                                    
jest-1         |     API deleteByZip                                                                                                                                                                                         
jest-1         |       ✓ returns true (1 ms)
jest-1         |       ✓ passes the zip code Model.deleteOne() (1 ms)
jest-1         |                                                                                                                                                                                                             
jest-1         | Test Suites: 1 passed, 1 total
jest-1         | Tests:       8 passed, 8 total
jest-1         | Snapshots:   0 total                                                                                                                                                                                        
jest-1         | Time:        1.643 s
jest-1         | Ran all test suites matching ./__tests__/mongoose/weather/services.test.ts.
jest-1         | 

注意

常用命令:

功能命令
看状态docker compose ls
启动docker compose up
正常关docker compose down
强制关docker compose kill
彻底重来docker compose down -v