资源
- The Complete Developer: Master the Full Stack With TypeScript, React, Next.js, MongoDB, and Docker | Martin Krause | download on Z-Library
- Downloads
正文
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 架构方式包括 REST 和 GraphQL。
中间件可以使用多种语言实现,但现代全栈开发中最常见的是 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 锁结果——购物清单(买了哪些具体品牌)
创建一个项目:
mkdir sample-express
cd sample-express
npm init
这将生成一个 package.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 后端框架。
npm install express@4.18.2这将会生成 node_moudles 及 package-lock.json。
安装 Karma 测试工具(只在 开发 / 测试阶段 用,不参与生产环境部署):
npm install --save-dev karma@5.0.0
npm 提示这存在漏洞,使用 npm audit 检查是否存在安全漏洞:
npm audit --registry=https://registry.npmjs.org/重要
建议每隔几个月使用一次 npm audit,并结合 npm update,以避免使用过时的依赖项并产生安全风险。
可以修复之,不过最新的 karma 版本可能会带来破坏性变化。
npm audit fix --registry=https://registry.npmjs.org/ --force如果某些插件没有维护,问题可能解决失败(哪怕是 --force)。
当:
- 删除了依赖(改了
package.json) - 切换分支
- 合并代码
- 手动复制过
node_modules
很容易出现:node_modules 里 残留了一堆项目已经不需要的包。通过以下命令清除:
npm prune更新所有包:
npm update注意
| 项目 | npm update | npm audit fix --force |
|---|---|---|
| 主要目的 | 更新依赖到新版本 | 消除安全漏洞 |
| 触发原因 | 人为升级 | 漏洞扫描结果 |
| 升级依据 | package.json 的版本范围 | 漏洞数据库 |
| 是否允许主版本升级 | ❌ 否(遵守 semver) | ✅ 是 |
| 是否可能破坏代码 | 低 | 高 |
是否改 package.json | ❌ | ✅(可能) |
| 是否适合生产项目 | 常用 | ⚠️ 谨慎 |
移除依赖:
npm uninstall karma根据配置文件安装包:
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。空白文件夹下:
npm init
npm install express@4.18.2创建一个 index.js:
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);
})注意
功能:
- 启动一个 HTTP 服务器,监听 3000 端口
- 当访问
/hello路径时,返回 “Hello World!” - 启动成功后在控制台输出提示
运行服务器:
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) | ES2015 | let/const,模板字符串,箭头函数,解构赋值,默认参数/剩余参数,模块 (import/export),class 和继承,Map/Set/WeakMap/WeakSet,Promise,迭代器/生成器 (for...of) |
| ES7 (2016) | ES2016 | 指数运算符 **,Array.prototype.includes |
| ES8 (2017) | ES2017 | async/await,Object.values / Object.entries,字符串填充方法 (padStart/padEnd) |
| ES9 (2018) | ES2018 | 异步迭代器 (for await...of),正则增强,对象扩展语法 (...rest) |
| ES10 (2019) | ES2019 | Array.flat / flatMap,Object.fromEntries,可选逗号,trimStart / trimEnd |
| ES11 (2020) | ES2020 | BigInt,动态 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 Export | Default Export |
|---|---|---|
| 数量限制 | 可以多个 | 只能一个 |
| 导入时 | 可重命名,可选择性导入 | 名字自定义,不可选择性导入 |
| 导入方式 | { name } | 自定义名字 |
| 接口清晰度 | 高 | 低(可能被重命名混淆) |
| TypeScript 建议 | 多个导出用它 | 模块只有一个核心功能时用它 |
- Named Export = 多个明确功能,接口清晰
- Default Export = 单一核心功能,导入时名字可自定义
- 即使你倾向使用 Named Export,也必须了解 Default Export 的语法
- 很多第三方库仍然使用 Default Export
声明变量
虽然很多人说“不要用 var”,它不是完全过时,你仍然需要理解它的行为,才能在不同场景下选择正确的变量声明方式。
注意
// 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 会变量提升,let 和 const 不会。
其他语言(Java、C)不能在声明前使用变量,而 JS 因 Hoisting 可以。
function scope() {
foo = 1; // 赋值
var foo; // 声明
}
// 等价于
function scope() {
var foo; // 声明被提升
foo = 1; // 赋值
}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 块里,但也被提升到函数顶部,可以在函数内任意位置访问 -
foo和bar都是函数作用域,不受块作用域限制 -
全局
var会挂到window(浏览器)或global(Node.js)上 -
如果是
let bar,则会报错。
箭头函数
基本写法:
const traditional = function(x) {
return x * x;
}
const arrow = (x) => {
return x * x;
}简洁写法:
如果只有一个参数和一个返回值:
- 可以省略圆括号、花括号和
return
const conciseBody = x => x * x;词法作用域(Lexical Scope)
传统函数 vs 箭头函数
- 传统函数:
this指向调用函数的对象- 可能被改变(例如用作回调时)
- 箭头函数:
- 不绑定调用对象
this的值由定义函数时所在的外层作用域决定- 所以
this更直观、少出错
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"注意
scopeOf.traditional():this指向调用它的对象scopeOf- 所以返回
scopeOf.scope = "defining scope"
scopeOf.arrow():- 箭头函数的
this由定义时的外层作用域决定 - 外层是全局对象(这里的
this.scope = "lexical scope") - 所以返回
"lexical scope"
- 箭头函数的
核心:箭头函数的 this 不会被调用环境改变,而是固定在定义时的作用域
| 特性 | 传统函数 | 箭头函数 |
|---|---|---|
| 语法 | function(x){} | (x) => {} 或 x => x * x |
| 简洁性 | 冗长 | 更短,Concise Body 可省略花括号和 return |
| this 指向 | 调用对象 | 定义时的外层作用域(lexical) |
| 使用场景 | 普通函数、对象方法 | 回调函数、箭头函数避免 this 混乱 |
箭头函数非常适合写回调函数:简洁、清晰、可读性高,尤其是逻辑简单时:
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]注意
-
filter是 Array.prototype 上的方法。作用:生成一个新数组,包含所有满足callback返回值为true的元素 -
所有数组实例都继承了
filter。
创建字符串
模板字符串
let a = 1;
let b = 2;
let string = `${a} + ${b} = ${a + b}`;
console.log(string); // 输出 "1 + 2 = 3"Tagged Template Literals(带标签模板字符串)
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 数组 = 模板字符串被变量切开的部分
['What is ', ' plus ', '?']values 数组 = 模板里变量的值
[1, 2]标签函数用 literal + values 生成新的字符串。
可以做更复杂操作(例如计算、格式化、过滤敏感词等)
| 类型 | 功能 | 用途 |
|---|---|---|
| Untagged | 插入变量或表达式 | 普通字符串拼接,多行字符串 |
| Tagged | 可以在回调函数里处理模板 + 变量,返回任意值 | 构建自定义逻辑、DSL、复杂字符串处理 |
异步编程(Asynchronous Programming)
注意
JavaScript 是单线程的
- 单线程 = 一次只能执行一个任务
- 如果一个任务很耗时(比如读取文件、请求网络),会 阻塞整个程序
- 用户界面可能无法响应,程序显得“卡住”
解决方法:异步编程(Asynchronous Programming)
- 异步 = 不阻塞主线程
- 思路:发起耗时操作 → 等待结果 → 结果回来后再处理
- 在等待期间,程序可以继续处理其他操作(UI、计算等)
传统异步方式:回调函数(Callback)
- 回调 = 把函数作为参数传给另一个函数
- 当耗时操作完成时,这个回调函数会被执行
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)
-
当异步操作依赖多个回调时,会产生嵌套层层的函数
-
代码难读、容易出错
doSomething(data1, () => {
doSomethingElse(data2, () => {
doAnotherThing(data3, () => {
// 回调层层嵌套
});
});
});使用 Promise、async / await 避免回调地狱:
// 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):
- Pending(等待中) → 初始状态,结果未知
- Fulfilled(已完成) → 成功,
result= 返回值 - Rejected(已拒绝) → 失败,
result= 错误对象
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 响应:
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");注意
| 特性 | Promise | async/await |
|---|---|---|
| 写法 | 链式 .then().then() | 像同步函数一样写 |
| 错误处理 | .catch() | try...catch |
| 可读性 | 多层 then 链可能复杂 | 清晰,易读,逻辑直观 |
| 适用场景 | 简单异步链 | 多个顺序异步操作 |
Promise 用于组织异步操作,链式 then/catch/finally;async/await 让异步代码像同步写法,更清晰易读,但仍需 try...catch 处理错误。
Array.map
注意
map 是 数组的方法
用途:遍历数组的每一项并返回一个新数组
特点:
- 不会修改原数组
- 返回一个新数组,元素是回调函数处理后的结果
const newArray = originalArray.map(callback);-
callback:一个函数,参数是数组的当前元素
-
返回值:map 会把 callback 的返回值放入新数组
下面的代码让每个元素乘以 10:
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)
- 写法:三个点
... - 作用*:把数组或对象“展开”,把它们的元素或属性复制到新的变量或新对象中
- 语义:把集合里的内容拆开,像散开一样分配给新变量
将对象展开到变量:
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 会分配新的内存,而不是引用原对象。
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:
{
"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": {}
}安装:
npm install创建 routes.js,从 https://www.usemodernfullstack.dev/api/v1/users 获取 json 信息并转成 html:
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 };
创建 index.js,调用 routes.js 导出的模块,并在 /api/names 中将信息提供给客户端:
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:
从 JavaScript Tutorial 处学习 JS。
3 TypeScript
注意
TypeScript = JavaScript + 类型系统 + 编译阶段检查
TS 为 JS 添加了静态类型。TS 是 JS 的超集。
类型系统让编译器能够立即发现类型错误。
安装 TS 开发环境
npm install --save-dev typescriptTS 必须经过 TSC 编译成 JS 才可以运行。下面的命令将会创建 tsconfig.json
npx tsc -init注意
如果在命令行执行 tsc,TypeScript 编译器会:
- 从当前目录开始
- 查找是否存在
tsconfig.json - 如果没有,就往父目录
- 一直向上找,直到文件系统根目录
这和很多工具很像,比如:
git查.gitnpm查package.json
这样可以在子目录里执行 tsc,但仍然使用项目根目录的配置
注意
-
tsconfig.json≈ Makefile / CMakeLists.txt -
tsc≈ 编译器前端 -
-p≈ 指定构建配置文件路径
tsconfig.json
注意
tsconfig.json 是 TypeScript 项目的“编译配置入口文件”
- 它告诉
tsc:- 用什么规则编译
- 编译哪些文件
- 忽略哪些文件
- 虽然配置项很多(~100 个),大多数项目只用到少数几个
大多数现代代码编辑器都支持 TypeScript,并且它们会直接在代码中显示 TSC 生成的错误。
类型注解
类型注解不是越多越好,而是要用在“边界和契约”上。
声明变量时
const x: number = 5;
const y: string = "hello";不是一个好的写法,会:
-
增加视觉噪音
-
没有增加任何安全性
-
让代码更难读
除非初始化为 null:
let value: number | null = null;函数返回值
function getWeather(): string {
const weather = "sunny";
return weather;
}声明函数形参
const weather = "sunny";
function getWeather(weather: string): string {
return weather;
};
getWeather(weather);注意
| 位置 | 是否推荐写类型 |
|---|---|
| 函数参数 | ✅ 必须 |
| 函数返回值 | ⚠️ 视情况 |
| 局部变量 | ❌ 通常不需要 |
类型
JS 中有以下原始类型:
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 自定义类型:
type WeatherDetailType = {
weather: string;
zipcode: string;
temp?: number;
};注意
-
使用
type关键字 -
使用
=赋值定义 -
对象结构语法和普通对象几乎一样
-
?表示 可选属性
如果不用自定义类型,代码将变得冗长且不可复用:
const getWeatherDetail = (
data: {
weather: string;
zipcode: string;
temp?: number;
}
) => {
return data;
};interface
interface WeatherProps {
weather: string;
zipcode: string;
temp?: number;
}
const weatherComponent = (props: WeatherProps): string => props.weather;注意
type 和 interface 的边界并不严格,关键是团队约定和一致性。
TypeScript 官方也承认:
- 二者能力高度重叠
- 很多场景可以互换
但不是完全一样,强调的是“使用语义上的区别”。
能用 interface 描述对象时,优先用 interface。
| 场景 | 推荐 |
|---|---|
| 对象结构 | interface |
| 对象的方法定义 | interface |
| React 组件 props | interface |
| 非对象(联合/元组) | type |
类型声明文件
注意
-
文件扩展名:
.d.ts -
不包含任何实现代码,只是“类型描述”
-
不会被编译成 JS,只用于 TypeScript 类型检查
-
TSC 会读取它们来理解自定义类型或第三方库类型
Exercise 3 使用 TypeScript 扩展 Express.js
在之前的 Exercise 的基础上安装:
npm install --save-dev @types/express此时 package.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:
{
"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 以自定义一个类型:
type responseItemType = {
id: string;
name: string;
};
type WeatherDetailType = {
zipcode: string;
weather: string;
temp?: number;
};
interface WeatherQueryInterface {
zipcode: string;
}将以前的 routes.js 改为 routes.ts。并修改其内容:
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。并修改其内容,这添加了一个查询天气的路由:
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);
});编译并启动:
npx tsc
node index.js访问 http://localhost:3000/api/weather/4589,得到服务器返回的 json:
{"zipcode":"4589","weather":"sunny","temp":35}可以从 TypeScript Tutorial 获得更多 TS 教程。
4 React
目前 40% 最受欢迎的网站都使用了 React。
-
学 React 的关键是掌握 JSX 语法,即如何描述界面。
-
学会将界面元素封装成 可动态更新的组件。
-
掌握这些之后,就能开始用 React 开发完整应用(前端 + 后端)。
-
现代前端架构倾向于 将应用的界面拆分成小的、独立的、可复用的单元。
-
这些单元就是 组件 的概念,每个组件可以独立管理自己的显示和行为。
-
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 项目的骨架。
- 功能包括:
- 生成初始代码模板(boilerplate)
- 配置构建工具链(webpack、Babel、TypeScript 等)
- 创建项目文件夹结构
- 保证项目布局一致,方便理解其他 React 项目
-
在线编辑
- 如果不想创建本地项目,可以使用 在线编辑器:
- CodeSandbox:https://codesandbox.io
- StackBlitz:https://stackblitz.com
- 它们提供 和 CRA 相同的文件结构,只需在默认的
App.tsx文件写代码即可运行。
- 如果不想创建本地项目,可以使用 在线编辑器:
-
高级应用:Next.js
- 对于复杂的全栈应用,通常使用 Next.js:
- 提供 开箱即用的项目搭建和工具链
- 内部实际上也是基于 CRA 的变体来生成项目
- 可以用于服务器渲染、路由、API 等功能
- 对于复杂的全栈应用,通常使用 Next.js:
JSX
JSX 是什么
- JSX 是 React 的 语法扩展,用来描述组件的界面。
- 外表上像 HTML,但本质不是字符串或模板,而是 JavaScript 表达式。
- 可以在 JSX 中使用任意 JavaScript,例如:
- 条件语句(if / ternary)
- 循环(array.map)
- 将 JSX 赋值给变量
- 从函数返回 JSX
关键点: JSX 最终会被 转译器(transpiler) 转成普通 JavaScript,再渲染成浏览器的 HTML。
在 JSX 中使用 {} 可以嵌入 JavaScript 表达式。
例子:
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");
}渲染后:
<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
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>
可以在组件内部响应用户操作,例如点击按钮更新状态或调用函数。
完整示例
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) | 内置生命周期方法(componentDidMount、componentDidUpdate 等) |
| this 关键字 | 不用 | 使用 this 引用组件实例 |
| 复杂性 | 简单 | 较复杂,需要 constructor、super、render 等 |
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 在函数组件中实现可复用行为
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)。返回一个 数组:
count→ 当前状态值setCount→ 修改状态的函数
-
useEffect可以在函数组件中执行副作用(effect),类似类组件的生命周期方法。第二个参数 依赖数组:
[]→ 只在组件挂载时执行(componentDidMount)[count]→ 当count变化时执行
例子中,挂载后将计数器初始化为 1。
注意
| 特性 | 类组件(Listing 4-3) | 函数组件 + Hooks(Listing 4-4) |
|---|---|---|
| 状态管理 | this.state / this.setState | useState |
| 生命周期方法 | componentDidMount | useEffect |
| 事件处理 | 单独定义 clickHandler 方法 | 内联箭头函数 |
| 语法复杂性 | constructor + super + render | 简洁函数式语法 |
| 可读性与可维护性 | 较繁琐 | 高,可读性强 |
| 多个状态变量 | 需要在 state 对象中维护多个字段 | 每个 useState 独立管理状态 |
| 复用逻辑 | 高度依赖 HOC 或类继承 | 可用自定义 Hooks 复用逻辑 |
函数组件 + Hooks = 更简洁、更可复用、更现代的 React 组件写法,完全可以替代类组件。
React 官方推荐函数组件 + Hooks,类组件不再更新新特性。
| Hook | 功能 | 类组件对应 |
|---|---|---|
| useState | 管理局部 state | this.state / this.setState |
| useEffect | 处理副作用、生命周期逻辑 | componentDidMount / componentDidUpdate / componentWillUnmount |
| useContext | 跨组件共享数据 | Context.Consumer 或类组件 static contextType |
| 自定义 Hooks | 封装可复用逻辑 | 高阶组件 HOC 或 render props |
使用 Context 和 useContext 来共享全局数据
注意
-
函数组件理想情况下是纯函数,只依赖 props。
-
有些情况需要跨多层组件共享状态(如主题、用户 session、语言设置)。
-
传统做法需要层层传递 props,非常麻烦 → Context 出现来解决这个问题。
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:
{
"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(但是不要在生产环境下这么做):
<!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 整个文件:
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);
});编译并启动服务器:
npx tsc
node index.js访问 http://localhost:3000/components/weather:
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 项目:
mkdir sample-next
cd ./sample-next
npx create-next-app@latest --typescript --use-npm --no-app然后全选 NO,一路回车。
之后:
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 写在代码里
-
行为写在函数里
-
路由表 = 代码逻辑
app.get("/hello", (req, res) => {
res.send("Hello World!");
});Next.js 的思想:文件即路由
只要在 pages/ 目录下:
- 放文件
- 导出东西
如创建 pages/hello.tsx 然后访问 http://localhost:3000/hello:
import type { NextPage } from "next";
const Hello: NextPage = () => {
return (<>Hello World!</>);
}
export default Hello;
Next.js 中,嵌套路由 = 子目录。
创建 pages/compoents/weather.tsx 并访问 http://localhost:3000/components/weather:
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。
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 数据:
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);
}[{"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"}]
动态 URL
动态 URL = URL 中的一部分是变量
在 Express.js 中,/api/weather/:zipcode 就是一个动态路由。:zipcode 通过 req.params.zipcode 来读取。
创建 pages/api/weather/[zipcode].ts,之后访问 http://localhost:3000/api/weather/1234:
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 / JSX | Vue |
|---|---|---|
| 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:
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 CSS | CSS Modules |
|---|---|
globals.css | xxx.module.css |
| 全局生效 | 只在当前组件 |
| class 不变 | class 会被 hash |
| 适合 reset / theme | 适合组件 |
Component Styles
创建(覆盖) styles/Home.module.css:
.container {
padding: 0 2rem;
color: red;
}创建(覆盖)pages/index.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;
内置组件(Built-in Components)
Next.js 提供了一些自带的 React 组件,用来解决常见的需求:
注意
| 组件 | 主要作用 |
|---|---|
next/head | 修改 <head> 标签里的内容,比如 <title>、<meta>,方便 SEO 优化 |
next/image | 图片优化,自动处理大小、延迟加载、WebP 等,提高性能 |
next/link | 路由跳转组件,增强前端页面切换体验(客户端路由) |
使用 next/head、next/image 和 next/link,修改(覆盖)pages/hello.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 用于后续实验:
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 并返回给客户端。
特点:
- 页面内容总是最新的,因为每次请求都会重新生成 HTML。
- 对需要实时数据的页面非常有用(例如新闻、用户仪表盘、股票行情)。
- 缺点:比静态生成(SSG)慢,因为每次请求都要执行服务器端逻辑和 API 调用,HTML 不能轻易缓存。
创建 page/names-sst.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 异步函数:
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:
Static Site Generation (SSG):
注意
定义:在 构建时(build time) 生成 HTML 文件,然后所有用户请求都会 直接返回这些静态 HTML。
特点:
- 快速:因为 HTML 是预生成的,可以直接缓存或通过 CDN 分发。
- SEO 优势:页面内容完整且快速呈现,提高 Google SEO 排名。
- 低时间指标:
- Time to First Paint (TTFP):用户点击链接到页面内容显示的时间短。
- Blocking Time:用户能交互的时间短。
- 适合静态数据:如果页面内容不依赖实时更新数据,非常适合 SSG。
创建 pages/ames-ssg.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:
export const getStaticProps: GetStaticProps = async () => {
const names = await fetchNames();
return {
props: { names },
revalidate: 30 // 页面每 30 秒会在后台更新一次
};
};
-
第一次访问:直接返回构建时生成的静态页面。
-
第 31 秒访问:后台开始生成新的页面,但第一次访问者仍看到旧页面。
-
新页面生成完成后:下一次访问就看到更新后的内容。
-
SSG = 预制便当,一次做好放橱窗里,永远不变。
-
SSR = 动态快餐,每次有人点单现做。
-
ISR = 预制便当 + 自动补货,每隔一段时间厨房会偷偷把便当更新换新,顾客拿到的总是最新的静态便当。
Client-Side Rendering(CSR,客户端渲染)
注意
流程:
- 先生成一个基础 HTML(可以通过 SSR 或 SSG)。
- 浏览器加载 HTML 后,通过 JavaScript 在客户端再去请求数据。
- 数据返回后,再把页面内容渲染到浏览器 DOM 中。
特点:
- 数据是 实时获取和渲染,适合高度动态的数据(比如股票、货币价格)。
- 初始加载可能显示一个空白或“骨架屏”,然后填充完整数据。
- SEO 表现不好,因为搜索引擎抓取的是初始 HTML,没有最终渲染的数据。
创建 pages/names-csr.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。
空的文件夹重新创建:
npx create-next-app@latest --typescript --use-npm --no-app创建 custom.d.ts:
// 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:
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:
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:
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:
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;
运行:
npm run dev访问:
- http://localhost:3000/hello
- http://localhost:3000/components/weather
- http://localhost:3000/api/names
- http://localhost:3000/api/weather/12345
6 REST AND GRAPHQL APIS
API(Application Programming Interface)本质上是一种“连接规则”,用于让程序和程序之间通信。
注意
| UI | API |
|---|---|
| 给人点、看、操作 | 给程序调用 |
| 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/123 | ID=123 的用户 |
/weather/10001 | 某个地点的天气 |
REST 思维:URL 是名词,不是动词
REST 不靠 URL 表示动作,而是靠 HTTP Method:
| Method | 含义 |
|---|---|
| GET | 获取资源 |
| POST | 创建资源 |
| PUT / PATCH | 修改资源 |
| DELETE | 删除资源 |
GET /users/123表示 “获取 ID 为 123 的用户”
| REST 特征 | 你的接口 |
|---|---|
| URL 表示资源 | /api/weather/:zipcode |
| 使用 HTTP Method | GET |
| 参数来自 URL | zipcode |
| 返回 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
{
"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 文档。
注意
整个流程可以理解为:
- 定义 API 元信息 + 版本 → 后端实现要对应
- 指定根 URL(Servers) → 管理环境
- Paths → 定义每个 endpoint
- Parameters → 强类型输入
- Responses → 强类型输出
- Schemas → 数据结构复用
- Swagger / Playground → 测试 + 自动生成代码
REST 是无状态的
注意
-
服务器 不保存客户端的历史信息。
-
客户端每次请求都必须包含 完整的必要信息。
-
支持 负载均衡 / 代理 / 分层系统
-
更容易扩展,高并发情况下服务器压力小
-
前后端解耦,客户端负责状态,服务器只处理请求
- REST API 无状态,但仍可认证:
- 一般使用 token
- token 放在:
- 请求体
- HTTP
Authorizationheader
- 服务器不存 session,每次请求都通过 token 判断用户身份
- 因为是无状态的,所以可以在负载均衡或代理后面依然工作
HTTP 方法(CRUD)
注意
REST 用 标准 HTTP 方法 来对应数据操作:
| CRUD | HTTP 方法 | 作用 | 特点 |
|---|---|---|---|
| Create | POST | 新增资源 | 每次 POST 都会创建新资源 |
| Read | GET | 获取资源 | 最常用,浏览网页就是 GET 请求 |
| Update | PUT | 全量更新已有资源 | 多次 PUT 会覆盖资源 |
| Partial Update | PATCH | 局部更新资源 | 只更新差异,更高效 |
| Delete | DELETE | 删除资源 | 删除资源,幂等操作 |
使用 REST API
默认情况下,curl 是已经被安装的了。
使用命令:
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) |
收到服务器的回复:
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 请求获取数据
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得到回复:
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
| 操作 | 方法 | 数据位置 | 返回内容 | 幂等性 |
|---|---|---|---|---|
| 读取 | GET | URL + query / path | 资源数据 | 幂等 |
| 更新 | PUT | 请求体 JSON | 状态信息或更新后的对象 | 幂等(多次相同 PUT 结果一样) |
GraphQL
REST 是一种架构风格(定义了 URL、HTTP 方法、状态码等规范),而 GraphQL 是 开源的 API 查询和操作语言,可以直接用来描述数据请求和更新操作。
注意
| 特点 | REST | GraphQL |
|---|---|---|
| 端点数量 | 每个资源一个 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。
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) | LocationWeatherType | type | 返回给客户端的数据结构,描述一个地点的天气信息 |
| 字段 | zip | String! | ZIP code,必定存在 |
weather | String! | 天气描述(如 sunny) | |
tempC | String! | 摄氏温度 | |
tempF | String! | 华氏温度 | |
friends | [String]! | 相关地点 ZIP 列表,始终返回数组(可为空) | |
| 输入类型(Input) | LocationWeatherInput | input | Mutation 的入参结构,表示客户端提交的数据 |
| 字段 | zip | String! | 要更新的 ZIP code(必须) |
weather | String | 新天气(可选) | |
tempC | String | 新摄氏温度(可选) | |
tempF | String | 新华氏温度(可选) | |
friends | [String] | 关联 ZIP(可选) | |
| 查询(Query) | weather | Query | 读操作 |
| 参数 | zip | String | 要查询的 ZIP code |
| 返回值 | — | [LocationWeatherType]! | 返回天气信息数组(一定有返回值) |
| 变更(Mutation) | weather | Mutation | 写操作(新增 / 更新 / 删除) |
| 参数 | data | LocationWeatherInput | 客户端提交的更新数据 |
| 返回值 | — | [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
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]!
}
`;注意
-
入口点只有一个
Query.weather(zip) -
weather返回的是:[LocationWeatherType] -
LocationWeatherType里又包含:friends: [FriendsType]
这就天然形成了嵌套结构。
查询
query GetWeatherWithFriends {
weather(zip: "96815") {
weather
friends {
weather
}
}
}注意
请求获取 ZIP=96815 这个地点的
- 天气(weather)
- 以及它所有邻居(friends)的天气
从服务器角度上看,查询命令会被解析成类似的树:
Query
└── weather(zip: "96815")
├── weather
└── friends
└── weather注意
GraphQL 会检查:
Query.weather是否存在LocationWeatherType.weather是否存在LocationWeatherType.friends.weather是否合法
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
注意
| 维度 | REST | GraphQL |
|---|---|---|
| 客户端能否控制返回字段 | ❌ 基本不能 | ✅ 完全可以 |
| 默认返回数据 | 整个资源 | 只返回你要的字段 |
| 常见问题 | over-fetching / under-fetching | 基本避免 |
| 接口粒度 | endpoint 决定 | query 决定 |
REST 经常会出现 **Over-fetching(过度获取)**和 Under-fetching(取少了)
GET /api/v2/weather/zip/96815
可能返回:
{
"zip": "96815",
"weather": "sunny",
"tempC": "25C",
"tempF": "70F",
"friends": [...]
}如果取多了,浪费流量,取少了,多次获取也浪费流量。
query Weather {
weather(zip: "96815") {
tempC
}
}这个语法能清晰地表面需要请求什么。
注意
虽然也可以通过 REST + JSON 调用 API 并告知请求哪些数据。但:
| 维度 | REST | GraphQL |
|---|---|---|
| 能否携带 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:
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:
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 表示所需要的数据:
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:
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:
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 暴露给前端,同时支持跨域。
执行命令:
npm run dev通过 http://localhost:3000/api/graphql 访问 Apollo Server:
点击左侧的 Arguments 和 Fields 得到查询语句:
query Weather($zip: String) {
weather(zip: $zip) {
zip
weather
}
}提供 Variables:
{ "zip": "96826" }执行查询后得到查询结果:
{
"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 服务器。
npm install mongodb-memory-server mongoose定义 Mongoose 模型
为了保证数据的完整性和规范性,需要用 Mongoose schema 创建一个 模型(model)。
关键点:
- Schema(模式):定义数据结构、字段类型、约束规则
- Model(模型):是 Mongoose 与 MongoDB 集合(collection)之间的接口,所有对数据库的操作都通过模型进行。
- 作用:防止不符合规则的数据进入数据库,保证数据一致性。
在用 TypeScript 编写 Mongoose 模型和模式之前,先声明一个 TypeScript 接口。
创建 mongoose/weather/interface.ts 以定义 Interface:
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:
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:
import mongoose, { model } from "mongoose";
import { WeatherInterface } from "./interface";
import { WeatherSchema } from "./schema";
export default mongoose.models.Weather ||
model<WeatherInterface>("Weather", WeatherSchema);注意
解析:
- 导入模块:
mongoose:核心库model:模型构造器WeatherInterface:类型约束WeatherSchema:字段结构
- 创建模型:
model<WeatherInterface>("Weather", WeatherSchema)- 第一个参数
"Weather":模型名 → 对应 MongoDB 中的 collection 名称(自动变复数 Weather → Weathers) - 第二个参数:Schema
- 第一个参数
- 检查重复模型:
mongoose.models.Weather || ...- 如果模型已存在,不重复创建,否则会报错
- 导出模型:方便其他模块使用
集合与数据库:
- 模型绑定的集合:
Weathers - 数据库名:
Weather(Mongoose 会自动创建)
数据库连接中间件(Database-Connection Middleware)
创建 middleware/db-connect.ts:
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 可以正常使用模型进行查询
它负责的事情包括:
- 启动一个 内存版 MongoDB
- 建立 Mongoose ↔ MongoDB 的连接
- 让已定义的 Mongoose Models 自动绑定到数据库集合
- 自动处理:
- 断线重连
- 操作缓冲(buffering)
数据库 Query
创建 mongoose/weather/services.ts,写如何增删改查数据库:
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;
}注意
| 操作 | 推荐检查字段 | 判断成功的依据 |
|---|---|---|
| Create | try / catch | 是否抛异常 |
| Read | 返回值 | 是否为 null |
| Update | matchedCount | 是否找到目标文档 |
| Delete | deletedCount | 是否真的删除 |
Service 的定义:
- 是一个普通函数
- 专门负责 数据库 CRUD
- 只和 Mongoose Model 打交道
- 不关心 GraphQL / HTTP / UI
注意
数据库查询不应该散落在 resolver 里,而应该集中在 service 层;
service 只做一件事:通过 Mongoose Model 执行单一的 CRUD 操作并返回结果。
创建一个端到端 Query
创建 pages/api/v1/weather/[zipcode].ts:
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);
}(WeatherDetailType 在 custom.d.ts 中定义并在 tsconfig.json 里被引用)
修改 middleware/db-connect.ts 以添加一个初始化的数据集:
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;编译:
npm run dev访问 http://localhost:3000/api/v1/weather/96815,得到数据库查询的结果:
{"_id":"696e138d01ac54e9470d3543","zip":"96815","weather":"sunny","tempC":"25C","tempF":"70F","friends":["96814","96826"],"__v":0}Exercise 7 使用 GraphQL API 连接数据库
把之前 Weather 的 GraphQL API 从“读静态 JSON”改成“读 MongoDB 数据库”。
注意
之前在 REST API 中是这样:
/api/v1/weather/96815
/api/v1/weather/96814
/api/v1/weather/96826
每个 endpoint 都是一个入口。
而 GraphQL:
POST /graphql
所有请求都走同一个 API 文件
这带来一个巨大好处:数据库连接只需要在一个地方处理一次
修改 api/graphql.ts:
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:
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 中存放的数据:
8 TESTING WITH THE JEST FRAMEWORK
Jest 通过自动化测试来确保修改不会破坏已有功能。
注意
为什么要测试
- 避免代码修改带来的意外副作用。
- 保证代码库的稳定性。
两条路线保障代码质量
- 组件化架构:减少依赖与副作用。
- 自动化测试:Jest 帮助验证每个单元行为。
Jest 的核心用法
- 写测试套件(test suites)
- 检查功能是否符合预期
- 使用 mock 管理依赖
- 利用报告发现问题
Test-Driven Development(测试驱动开发) and Unit Testing(单元测试)
注意
TDD(Test-Driven Development):先写测试,再写实现代码。
流程:
- 写一个单元测试,针对最小的功能单元(module、function、甚至一行代码)验证预期行为。
- 写最少量的代码,使测试通过。
关键概念:
- Unit test(单元测试):测试最小代码单元是否按预期工作。
- 最小实现原则:只写足够通过测试的代码,避免过度设计。
TDD 的优势:
- 明确需求
- 在写代码之前,测试会明确规定功能和边界情况。
- 可以提前发现需求不清或缺失的地方,而不是等实现完再写测试。
- 风险:测试可能只是反映你实际实现的行为,而不一定符合真正需求。
- 控制复杂度
- 只写必要代码,避免函数过于复杂。
- 将应用拆分成小、易理解的模块。
测试单元(Unit):模块、函数或代码行。
测试目标:验证单元在隔离环境中是否正确运行。
测试结构:
- Test steps:测试函数内部的一行行操作。
- Test case(测试用例):完整的测试函数。
- Test suite(测试套件):把多个测试用例组织成逻辑块。
可重复性:测试每次运行结果必须一致,意味着需要可控环境和固定数据集。
Jest 由 Facebook 与 React 一起开发,但可以用于任意 Node.js 项目。
功能:
- 提供标准化语法写测试。
- 内置测试运行器(test runner)。
- 自动处理依赖(mock)。
- 生成代码覆盖率报告(code coverage report)。
扩展功能:
- 通过额外 npm 包支持 DOM 测试或 React 组件测试。
- 支持 TypeScript 类型。
安装 Jest
npm install --save-dev jest @types/jest在 package.json 中添加 "test": "jest" 以方便使用 npm test 运行 Jest。
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest"
},继续:
npm install --save-dev ts-jestnpx ts-jest config:init这将生成 jest.config.js:
const { createDefaultPreset } = require("ts-jest");
const tsJestTransformCfg = createDefaultPreset().transform;
/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: "node",
transform: {
...tsJestTransformCfg,
},
};创建一个测试用示例模块
创建 ./helpers/sum.test.ts:
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:
const sum = () => {};
export { sum };注意
占位函数:先创建一个最简单的空函数,保证模块可以被导入。
执行测试试试:
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.
这时候 测试仍然会失败,因为:
sum没有实现功能- 测试套件还没有具体测试用例
步骤 说明 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(准备),用于定义前置条件、测试数据、依赖环境:
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(执行),调用被测函数。
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(断言),验证返回结果是否符合预期。
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():
const sum = (a: number, b: number): number => a + b;
export { sum };如此做,测试成功:
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:
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:
const sum = (data: number[]): number => {
return data.reduce((a, b) => a + b);
};
export { sum };测试的量化指标
通过测试覆盖率(Test Coverage)评价测试套件实际执行到了哪些代码行。一般地,代码覆盖率目标设定为 90% 或以上,并对代码中最关键的部分保持高覆盖率。当然,测试用例应通过测试代码功能来增加价值;仅仅为了提高测试覆盖率而添加测试并不是我们的目标。
修改 package.json 中添加 "test": "jest --coverage" 以在测试中显示测试覆盖率。
"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 用于测试斐波那契的实现:
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():
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。
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 替换掉,而不是修改生产代码。
const sum = (data: number[]): number => 999;
export { sum };这个测试替身无论接收到什么数据,总是返回相同的数字 999。
注意
这个 stub:
- 不关心输入
- 不模拟真实逻辑
- 只保证“接口存在”
stub 在这里不是为了“算对”,而是为了证明 Fibonacci 在循环中确实调用了 sum
使用 Fake
修改 ./helpers/__mocks__/sum.ts:
const sum = (data: number[]): number => {
return data[0] + data[1];
}
export { sum };注意
-
不破坏测试期望
-
不引入真实复杂度
-
不依赖真实实现
使用 Mock
修改 ./helpers/__mocks__/sum.ts:
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 的特殊情况)
- 用来发现三类问题:
- 模块间通信问题:例如内部 API 不匹配、未清理旧数据等
- 环境问题:不同 Node.js 版本、依赖包版本差异导致错误
- 网关/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.ts 让 dbConnect() 执行后返回数据库实例:
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。
/**
* @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"});
});
});注意
这个测试文件做了三件事:
- 验证内存数据库是否被创建。
- 验证 Mongoose 是否连接和断开。
- 确保
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:
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。
/**
* @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.
一些失败的代码行没有被测试到。
为 REST API 提供端到端测试
注意
E2E(端到端)测试的含义:不 mock、不隔离任何模块,直接从“HTTP 请求 → API → middleware → service → 数据库 → 返回响应”,验证整个系统是否真的能跑通。
| 测试类型 | 是否隔离 | 是否 mock | 关注点 |
|---|---|---|---|
| 单元测试 | 是 | 是 | 某个函数是否正确 |
| 集成测试 | 否 | 通常否 | 模块之间是否协作 |
| 端到端测试 | ❌ | ❌ | 系统是否整体可用 |
创建 __tests__/pages/api/v1/weather/zipcode.e2e.test.ts
/**
* @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 组件 / 页面结构测试
继续安装:
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 项目结构。
const nextJest = require("next/jest");
const createJestConfig = nextJest({});
module.exports = createJestConfig(nextJest({}));创建 __tests__/pages/components/weather.snapshot.test.tsx:
提示
原书代码中 act 和 create 已弃用。
/**
* @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:
// 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:
/**
* @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();
});
});在目前已经弃用。
安装:
npm install --save-dev @testing-library/jest-dom修改 __tests__/pages/components/weather.snapshot.test.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
项目的登录流程:
- 用户访问你的应用
- 你跳转到 OAuth Provider
- 用户登录 + 同意授权
- Provider 返回 Authorization Code
- 你的后端用 Code 换 Access Token
- 用 Token 访问用户数据
以 Github 为例:
- 注册应用(client_id / secret / redirect_uri)
- 用户点击 Login with GitHub
- 跳转 GitHub 授权页
- 用户同意 scope
- GitHub → redirect_uri?code=xxx
- 后端用 code + client_secret 换 token
- token 存 session
- Authorization: Bearer token
- 访问用户数据 / 执行业务逻辑
创建一个 JWT Token
JWT 长这样:header.payload.signature
注意
| 部分 | 干什么 |
|---|---|
| Header | 说明“这是什么 token,用什么算法签的” |
| Payload | 放数据(claims) |
| Signature | 防伪、防篡改 |
Header
{
"typ": "JWT",
"alg": "HS256"
}typ:这是 JWTalg:使用对称密钥签名,安全性依赖 secret 的保密性
Payload
OAuth 真正有用的信息都在 payload 里。
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
}注意
| 类型 | 字段 | 示例 | 说明 |
|---|---|---|---|
| Registered | iss | "https://www.usemodernfullstack.dev/" | token 签发者 |
| Registered | sub | "THE_CLIENT_ID" | token 属于谁(client 或 user) |
| Registered | aud | "api://endpoint" | token 接收方(谁能用) |
| Registered | exp | 234133423 | 过期时间 |
| Public | weather_public_zip | "96815" | 对外公开的业务数据 |
| Private | weather_private_type | "GitHub" | 系统内部使用的业务信息 |
概念上:完整的 payload 示例
生产上:需要:
- 调整时间戳
- 添加
iat、jti、nbf等安全字段 - 审查 public/private claim 命名是否安全
| Claim | 作用 |
|---|---|
iat | 什么时候签发 |
nbf(书里写 nfb,概念是 not before) | 多早之前不能用 |
jti | token 唯一 ID(防重放) |
Signature
格式如下:
HMAC_SHA256(
base64(header) + "." + base64(payload),
secret
)注意
可以确保完整性。
-
改 header → 签名不匹配
-
改 payload → 签名不匹配
-
没 secret → 造不出合法 token
在一个空白文件夹下创建 index.ts:
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。
命令行:
npm install --save-dev @types/node
npx tsc index.ts --outDir . --module commonjs && node index.jseyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjIzNDEzMzQyMywid2VhdGhlcl9wdWJsaWNfemlwIjoiOTY4MTUiLCJ3ZWF0aGVyX3ByaXZhdGVfdHlwZSI6IkdpdEh1YiJ9.f667c81749886ee01831376a38fbdba4d7f59a14c14f3a60e1bbee977c993ac9
Exercise 9 访问受保护的资源
如果没有登录就执行下面的命令:
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
填写信息:
Create an Account and move on to the OAuth Client 以得到 Client ID 和 Client Secret:
命令行:
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&state=4nBjkh31">http://localhost:3000/oauth/callback?code=ccd70b795084357dbdedb89e42b6620a65d4b939&state=4nBjkh31</a></p>
注意
| 参数 | 含义 |
|---|---|
response_type=code | 请求授权码(Authorization Code Flow) |
client_id | OAuth 客户端 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 的参数里:
现在拿到了一个授权码(URL 里 code=ccd70b795084357dbdedb89e42b6620a65d4b939,每次调用得到的 code 都不同),之后用这个授权码换取一个访问令牌(access token),才能真正访问用户的受保护资源。
注意
OAuth 标准规定,用授权码换取访问令牌的端点通常是:POST /oauth/access_token。
- 这里是:
https://www.usemodernfullstack.dev/oauth/access_token
命令行:
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"}
使用这个访问令牌去访问受保护的资源:
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 后,检查:
docker -vDocker 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:
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.json和package-lock.json文件复制到容器内的工作目录。 - Node.js 应用依赖这些文件安装依赖。
EXPOSE 3000
- 容器对外暴露端口 3000(Node.js 默认端口)。
- 通过该端口,外部可以访问容器里的应用。
构建 Docker Image
在确保 Docker 服务启动后,使用以下命令构建 Docker 镜像:
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 镜像:
docker image lsREPOSITORY TAG IMAGE ID CREATED SIZE
nextjs latest b822981104ca About a minute ago 1.62GB
删除旧的镜像:
docker container rm nextjs_container启动容器并运行 Next.js:
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。
使用以下命令查看实际映射端口:
docker container lsCONTAINER 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/
与容器交互
注意
docker container exec -it <container ID or name> /bin/sh-
exec→ 在已运行的容器内执行命令 -
-it→ 交互式终端(interactive + tty) -
/bin/sh→ 启动容器内 shell,可以手动查看或调试容器文件
停止容器
注意
docker container kill <container ID or name>- 停止正在运行的容器
- 可以用 容器名 或 容器 ID 指定目标
使用 Docker Compose 做微服务
注意
| 架构 | 特点 |
|---|---|
| 单体应用(Monolith) | 前端 + 后端 + 数据库全在一个程序里,耦合严重 |
| 微服务 | 前端 / 后端 / 测试 / DB 各是独立服务 |
提示
原书上不是在 Windows 系统下进行的,直接照搬原书的会有问题。
修改 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:
# 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:
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清理之前的容器:
docker compose down -v
docker compose build --no-cache启动微服务:
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 |