资源
正文
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 等
Note
前端 :负责“展示和交互”
中间件 :负责“连接、调度和整合”
后端 :负责“数据和业务逻辑”
三者协同工作,构成一个完整的全栈 Web 应用。
1 Node.js
常用命令
Note
把 Node.js、npm、package.json 和 package-lock.json 当成做菜 :
Node.js 是环境——厨房(能做菜)
npm 是管理包——采购系统
package.json 说需求——菜谱(需要哪些食材)
package-lock.json 锁结果——购物清单(买了哪些具体品牌)
创建一个项目:
1 2 3 mkdir sample-express cd sample-express npm init
这将生成一个 package.json 如下:
1 2 3 4 5 6 7 8 9 10 11 12 { "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 后端框架。
1 npm install express@4.18.2
这将会生成 node_moudles 及 package-lock.json。
安装 Karma 测试工具(只在 开发 / 测试阶段 用,不参与生产环境部署):
1 npm install --save-dev karma@5.0.0
npm 提示这存在漏洞,使用 npm audit 检查是否存在安全漏洞:
1 npm audit --registry=https://registry.npmjs.org/
Important
建议每隔几个月使用一次 npm audit,并结合 npm update,以避免使用过时的依赖项并产生安全风险。
可以修复 之,不过最新的 karma 版本可能会带来破坏性变化。
1 npm audit fix --registry=https://registry.npmjs.org/ --force
如果某些插件没有维护,问题可能解决失败(哪怕是 --force)。
当:
删除了依赖(改了 package.json)
切换分支
合并代码
手动复制过 node_modules
很容易出现:node_modules 里 残留了一堆项目已经不需要的包 。通过以下命令清除 :
更新所有包 :
Note
项目
npm update
npm audit fix --force
主要目的
更新依赖到新版本
消除安全漏洞
触发原因
人为升级
漏洞扫描结果
升级依据
package.json 的版本范围
漏洞数据库
是否允许主版本升级
❌ 否(遵守 semver)
✅ 是
是否可能破坏代码
低
高
是否改 package.json
❌
✅(可能)
是否适合生产项目
常用
⚠️ 谨慎
移除依赖 :
根据配置文件安装 包:
Note
场景
npm install 行为
有 package-lock.json
按 package-lock.json 装
没有 package-lock.json
按 package.json 算
package.json 改了
重算相关部分
CI / 自动化
应使用 lock
npx
Note
npx 是随 Node.js 一起安装的工具
全称:Node Package Execute
功能:不用提前安装包,就能直接运行注册表里的包
npx 的用途
当你只想临时执行某个包的命令 ,而不想把它加到项目依赖里(dependencies 或 devDependencies)
典型场景:脚手架脚本 、一次性工具 、检查工具
示例:
你想检查 package.json 是否有语法错误,可以用 jsonlint
这个包不需要长期安装在项目里,只用一次
用 npx 就可以临时运行,而不用 npm install jsonlint(会将包安装至中央缓存中)
总结:
Exercise 1 搭建一个 Express.js 服务器
构建一个基于 Express.js 的后端的 hello world。空白文件夹下:
1 2 npm init npm install express@4.18.2
创建一个 index.js:
1 2 3 4 5 6 7 8 9 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); })
Note
功能:
启动一个 HTTP 服务器,监听 3000 端口
当访问 /hello 路径时,返回 “Hello World!”
启动成功后在控制台输出提示
运行服务器:
访问 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 。
Note
版本 / 年份
别名
主要特性
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。
Note
以前 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(命名导出)
可以在一个模块里导出 多个函数、变量或类
导入时可以选择重命名,也可以直接用原名
导入语法:
1 2 3 4 5 6 7 8 export function add (a, b ) { return a + b; }export function sub (a, b ) { return a - b; }import { add, sub as subtract } from './utils.js' ;console .log (add (2 ,3 )); console .log (subtract (5 ,2 ));
Default Export(默认导出)
一个模块只能有一个默认导出
导入时可以自定义名字,不受导出名字限制
导出语法:
1 2 3 4 5 6 export default function multiply (a, b ) { return a * b; }import mul from './math.js' ; console .log (mul (2 ,3 ));
Note
特性
Named Export
Default Export
数量限制
可以多个
只能一个
导入时
可重命名,可选择性导入
名字自定义,不可选择性导入
导入方式
{ name }
自定义名字
接口清晰度
高
低(可能被重命名混淆)
TypeScript 建议
多个导出用它
模块只有一个核心功能时用它
Named Export = 多个 明确功能,接口清晰
Default Export = 单一 核心功能,导入时名字可自定义
即使你倾向使用 Named Export ,也必须了解 Default Export 的语法
很多第三方库仍然使用 Default Export
声明变量
虽然很多人说“不要用 var”,它不是完全过时,你仍然需要理解它的行为,才能在不同场景下选择正确的变量声明方式。
Note
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 function testVar ( ) { if (true ) { var x = 10 ; } console .log (x); }function testLetConst ( ) { if (true ) { let y = 20 ; const z = 30 ; } console .log (y); console .log (z); }
声明方式
作用域
是否可重新赋值
建议使用场景
var
函数作用域
可以
了解即可,老代码可能还在用
let
块作用域
可以
常规可变变量
const
块作用域
不可
常量或引用不变的对象
变量提升(Hoisting)
var 会变量提升,let 和 const 不会。
其他语言(Java、C)不能在声明前使用变量,而 JS 因 Hoisting 可以。
1 2 3 4 5 6 7 8 9 10 11 function scope ( ) { foo = 1 ; var foo; }function scope ( ) { var foo; foo = 1 ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 var globalVar = "global" ; function scope ( ) { var foo = "1" ; if (true ) { var bar = "2" ; } console .log (globalVar); console .log (window .globalVar ); console .log (foo); console .log (bar); }scope ();
bar 虽然在 if 块里,但也被提升到函数顶部,可以在函数内任意位置访问
foo 和 bar 都是函数作用域,不受块作用域限制
全局 var 会挂到 window(浏览器)或 global(Node.js)上
如果是 let bar,则会报错。
箭头函数
基本写法:
1 2 3 4 5 6 7 const traditional = function (x ) { return x * x; }const arrow = (x ) => { return x * x; }
简洁写法:
如果只有一个参数和一个返回值 :
1 const conciseBody = x => x * x;
词法作用域(Lexical Scope)
传统函数 vs 箭头函数
传统函数 :
this 指向调用函数的对象
可能被改变(例如用作回调时)
箭头函数 :
不绑定调用对象
this 的值由定义函数时所在的外层作用域决定
所以 this 更直观、少出错
1 2 3 4 5 6 7 8 9 10 11 12 13 14 this .scope = "lexical scope" ;const scopeOf = { scope : "defining scope" , traditional : function ( ) { return this .scope ; }, arrow : () => { return this .scope ; } };console .log (scopeOf.traditional ()); console .log (scopeOf.arrow ());
Note
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 混乱
箭头函数非常适合写回调函数:简洁、清晰、可读性高,尤其是逻辑简单时:
1 2 3 4 5 6 7 8 9 10 11 12 let numbers = [-2 , -1 , 0 , 1 , 2 ];let traditional = numbers.filter (function (num ) { return num >= 0 ; });let arrow = numbers.filter (num => num >= 0 );console .log (traditional); console .log (arrow);
创建字符串
模板字符串
1 2 3 4 let a = 1 ;let b = 2 ;let string = `${a} + ${b} = ${a + b} ` ;console .log (string);
Tagged Template Literals(带标签模板字符串)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function tag (literal, ...values ) { console .log ("literal" , literal); console .log ("values" , values); 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);
Note
literal 数组 = 模板字符串被变量切开的部分
1 ['What is ' , ' plus ' , '?' ]
values 数组 = 模板里变量的值
标签函数用 literal + values 生成新的字符串。
可以做更复杂操作(例如计算、格式化、过滤敏感词等)
类型
功能
用途
Untagged
插入变量或表达式
普通字符串拼接,多行字符串
Tagged
可以在回调函数里处理模板 + 变量,返回任意值
构建自定义逻辑、DSL、复杂字符串处理
异步编程(Asynchronous Programming)
Note
JavaScript 是单线程的
单线程 = 一次只能执行一个任务
如果一个任务很耗时(比如读取文件、请求网络),会 阻塞整个程序
用户界面可能无法响应,程序显得“卡住”
解决方法:异步编程(Asynchronous Programming)
异步 = 不阻塞主线程
思路:发起耗时操作 → 等待结果 → 结果回来后再处理
在等待期间,程序可以继续处理其他操作(UI、计算等)
传统异步方式:回调函数(Callback)
回调 = 把函数作为参数传给另一个函数
当耗时操作完成时,这个回调函数会被执行
1 2 3 4 5 6 7 8 9 10 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)
当异步操作依赖多个回调时,会产生嵌套层层的函数
代码难读、容易出错
1 2 3 4 5 6 7 doSomething (data1, () => { doSomethingElse (data2, () => { doAnotherThing (data3, () => { }); }); });
使用 Promise、async / await 避免回调地狱:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 fs.promises .readFile ("file.txt" ) .then (data => console .log (data)) .catch (err => console .log ("error" ));async function readFile ( ) { try { let data = await fs.promises .readFile ("file.txt" ); console .log (data); } catch (err) { console .log ("error" ); } }
Note
异步编程让耗时任务不阻塞 JS 主线程,回调函数是传统方法,但现代 JS 更推荐用 Promise 或 async/await。
Note
fetch 是浏览器和现代 JS 环境提供的内置函数
作用:发送网络请求(HTTP 请求)并获取响应
它返回一个 Promise ,所以可以用 .then() 或 async/await 来处理异步结果。
使用 Promise 处理 fetch 响应:
Note
Promise = 异步任务的承诺
不会立即返回结果,而是“承诺”以后会返回
可以处理成功或失败的结果
状态(state) :
Pending(等待中) → 初始状态,结果未知
Fulfilled(已完成) → 成功,result = 返回值
Rejected(已拒绝) → 失败,result = 错误对象
1 2 3 4 5 6 7 8 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 响应:
1 2 3 4 5 6 7 8 9 10 11 async function fetchData (url ) { try { const response = await fetch (url); const json = await response.json (); console .log (json); } catch (error) { console .error (`Error: ${error} ` ); } }fetchData ("https://www.usemodernfullstack.dev/api/v1/users" );
Note
特性
Promise
async/await
写法
链式 .then().then()
像同步函数一样写
错误处理
.catch()
try...catch
可读性
多层 then 链可能复杂
清晰,易读,逻辑直观
适用场景
简单异步链
多个顺序异步操作
Promise 用于组织异步操作,链式 then/catch/finally;async/await 让异步代码像同步写法,更清晰易读,但仍需 try…catch 处理错误。
Array.map
Note
map 是 数组的方法
用途:遍历数组的每一项并返回一个新数组
特点:
不会修改原数组
返回一个新数组 ,元素是回调函数处理后的结果
1 const newArray = originalArray.map (callback);
下面的代码让每个元素乘以 10:
1 2 3 4 5 6 const original = [1 , 2 , 3 , 4 ];const multiplied = original.map (item => item * 10 );console .log (`original array: ${original} ` ); console .log (`multiplied array: ${multiplied} ` );
扩展运算符(Spread Operator)
写法:三个点 ...
作用 *:把数组或对象“展开”,把它们的元素或属性复制到新的变量或新对象中
语义:把集合里的内容拆开,像散开一样分配给新变量
将对象展开到变量:
1 2 3 4 5 6 7 8 9 10 let object = { fruit : "apple" , color : "green" };let { fruit, color } = { ...object };console .log (`fruit: ${fruit} , color: ${color} ` ); color = "red" ;console .log (`object.color: ${object.color} , color: ${color} ` );
修改变量 color 不会影响原对象,因为 spread 会分配新的内存 ,而不是引用原对象。
1 2 3 4 5 6 7 8 9 let originalArray = [1 ,2 ,3 ];let clonedArray = [...originalArray]; clonedArray[0 ] = "one" ; clonedArray[1 ] = "two" ; clonedArray[2 ] = "three" ;console .log (`originalArray: ${originalArray} ` ); console .log (`clonedArray: ${clonedArray} ` );
Exercise 2 使用现代 JS 扩展 Express.js
在空文件夹中创建 package.json:
1 2 3 4 5 6 7 8 9 10 11 12 { "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" : { } }
安装:
创建 routes.js,从 https://www.usemodernfullstack.dev/api/v1/users 获取 json 信息并转成 html:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 中将信息提供给客户端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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
Note
TypeScript = JavaScript + 类型系统 + 编译阶段检查
TS 为 JS 添加了静态类型。TS 是 JS 的超集。
类型系统让编译器能够立即发现类型错误。
安装 TS 开发环境
1 npm install --save-dev typescript
TS 必须经过 TSC 编译成 JS 才可以运行。下面的命令将会创建 tsconfig.json
Note
如果在命令行执行 tsc,TypeScript 编译器会:
从当前目录 开始
查找是否存在 tsconfig.json
如果没有,就往父目录
一直向上找,直到文件系统根目录
这和很多工具很像,比如:
git 查 .git
npm 查 package.json
这样可以在子目录里执行 tsc,但仍然使用项目根目录的配置
tsconfig.json
Note
tsconfig.json 是 TypeScript 项目的“编译配置入口文件”
它告诉 tsc:
虽然配置项很多(~100 个),大多数项目只用到少数几个
大多数现代代码编辑器都支持 TypeScript,并且它们会直接在代码中显示 TSC 生成的错误。
类型注解
类型注解不是越多越好,而是要用在“边界和契约”上。
声明变量时
1 2 const x : number = 5 ;const y : string = "hello" ;
不是一个好的写法,会:
除非初始化为 null:
1 let value : number | null = null ;
函数返回值
1 2 3 4 function getWeather ( ): string { const weather = "sunny" ; return weather; }
声明函数形参
1 2 3 4 5 const weather = "sunny" ;function getWeather (weather : string ): string { return weather; };getWeather (weather);
Note
位置
是否推荐写类型
函数参数
✅ 必须
函数返回值
⚠️ 视情况
局部变量
❌ 通常不需要
类型
JS 中有以下原始类型:
1 2 3 4 5 6 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 自定义类型:
1 2 3 4 5 type WeatherDetailType = { weather : string ; zipcode : string ; temp ?: number ; };
Note
使用 type 关键字
使用 = 赋值定义
对象结构语法和普通对象几乎一样
? 表示 可选属性
如果不用自定义类型,代码将变得冗长且不可复用:
1 2 3 4 5 6 7 8 9 const getWeatherDetail = ( data : { weather: string ; zipcode: string ; temp?: number ; } ) => { return data; };
interface
1 2 3 4 5 6 7 interface WeatherProps { weather : string ; zipcode : string ; temp ?: number ; }const weatherComponent = (props : WeatherProps ): string => props.weather ;
Note
type 和 interface 的边界并不严格,关键是团队约定和一致性。
TypeScript 官方也承认:
但不是完全一样 ,强调的是“使用语义上的区别”。
能用 interface 描述对象时,优先用 interface。
场景
推荐
对象结构
interface
对象的方法定义
interface
React 组件 props
interface
非对象(联合/元组)
type
类型声明文件
Exercise 3 使用 TypeScript 扩展 Express.js
在之前的 Exercise 的基础上安装:
1 npm install --save-dev @types/express
此时 package.json 将类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 { "compilerOptions" : { "rootDir" : "./src" , "outDir" : "./dist" , "sourceMap" : true , "declaration" : true , "declarationMap" : true , "module" : "nodenext" , "target" : "esnext" , "moduleResolution" : "node" , "esModuleInterop" : true , "verbatimModuleSyntax" : true , "isolatedModules" : true , "moduleDetection" : "force" , "strict" : true , "noImplicitAny" : true , "noUncheckedIndexedAccess" : true , "exactOptionalPropertyTypes" : true , "skipLibCheck" : true , "noUncheckedSideEffectImports" : true , "jsx" : "react-jsx" , "types" : [ "node" ] } , "include" : [ "src/**/*" ] , "exclude" : [ "node_modules" , "dist" ] }
Note
配置项
作用
为什么在 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 以自定义一个类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 type responseItemType = { id : string ; name : string ; };type WeatherDetailType = { zipcode : string ; weather : string ; temp ?: number ; };interface WeatherQueryInterface { zipcode : string ; }
将以前的 routes.js 改为 routes.ts。并修改其内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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。并修改其内容,这添加了一个查询天气的路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 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); });
编译并启动:
访问 http://localhost:3000/api/weather/4589,得到服务器返回的 json:
1 { "zipcode" : "4589" , "weather" : "sunny" , "temp" : 35 }
可以从 TypeScript Tutorial 获得更多 TS 教程。
4 React
目前 40% 最受欢迎的网站都使用了 React。
声明式编程 :只描述界面想要的结果,而不是操作步骤。
响应式组件 :组件自管理状态,状态变化自动更新界面。
虚拟 DOM :优化 DOM 更新,减少性能开销。
单页应用(SPA)能力 :React + Router 可以在浏览器端模拟多页应用,提高用户体验。
React 的开发环境与项目搭建方式
React 与普通 Node.js 项目区别
手动搭建 React 非常复杂
如果从零手动配置 webpack、Babel、TypeScript 等工具链,工作量很大。
所以开发者通常使用 工具来自动生成项目结构和配置 。
使用 create-react-app 脚手架
create-react-app(CRA) 是官方推荐的工具,用来快速生成 React SPA 项目的骨架。
功能包括:
生成初始代码模板(boilerplate)
配置构建工具链(webpack、Babel、TypeScript 等)
创建项目文件夹结构
保证项目布局一致,方便理解其他 React 项目
在线编辑
如果不想创建本地项目,可以使用 在线编辑器 :
它们提供 和 CRA 相同的文件结构 ,只需在默认的 App.tsx 文件写代码即可运行。
高级应用:Next.js
对于复杂的全栈应用,通常使用 Next.js :
提供 开箱即用的项目搭建和工具链
内部实际上也是基于 CRA 的变体来生成项目
可以用于服务器渲染、路由、API 等功能
JSX
JSX 是什么
JSX 是 React 的 语法扩展 ,用来描述组件的界面。
外表上像 HTML,但本质不是字符串或模板,而是 JavaScript 表达式 。
可以在 JSX 中使用任意 JavaScript,例如:
条件语句(if / ternary)
循环(array.map)
将 JSX 赋值给变量
从函数返回 JSX
关键点: JSX 最终会被 转译器(transpiler) 转成普通 JavaScript,再渲染成浏览器的 HTML。
在 JSX 中使用 {} 可以嵌入 JavaScript 表达式 。
例子:
1 2 3 4 5 6 7 8 9 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" ); }
渲染后:
1 <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 的性能优化和界面的一致性。
JSX 与 ReactDOM
JSX 是语法糖 ,最终会被转换成 React 元素,通过 ReactDOM 渲染到浏览器。
函数组件
元素与组件
React 元素(React Elements) :普通 JavaScript 对象,可以包含其他元素;渲染后生成 DOM 节点或 DOM 子树。
React 组件(React Components) :函数或类,用来生成 React 元素并渲染到虚拟 DOM。
核心区别 :元素是数据(描述界面),组件是生成元素的逻辑单元。
组件化与逻辑封装
函数组件与 props
React 组件通常是 函数 ,首字母大写
props :父组件传入的只读属性,用来传递数据
不可修改 :props 在组件内部是 不可变的(immutable) ,如果需要更新数据,应由父组件或状态管理器处理
TypeScript 接口 + 组件属性
1 2 3 4 5 6 7 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>
可以在组件内部响应用户操作,例如点击按钮更新状态或调用函数。
完整示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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" /> ); }
Note
概念
说明
Props
父组件传递给子组件的数据,组件内部不可修改(immutable)
JSX 元素
<h1>{text}</h1>,动态显示内容,通过 {} 嵌入 JS 表达式
TypeScript 接口
用于约束 props 类型,保证类型安全
事件处理
onClick={() => clickHandler(text)},通过箭头函数传参
渲染组件
<WeatherComponent weather="sunny" />,父组件传入数据,子组件显示并响应事件
类组件
Note
特性
函数组件(Function Component)
类组件(Class Component)
编程风格
函数式编程,类似纯函数
面向对象编程
状态管理
通过 useState Hook
通过 this.state
生命周期方法
使用 Hook(如 useEffect)
内置生命周期方法(componentDidMount、componentDidUpdate 等)
this 关键字
不用
使用 this 引用组件实例
复杂性
简单
较复杂,需要 constructor、super、render 等
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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 ( ) { 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" /> ); }
Note
函数组件 = 纯函数 → props 输入 → JSX 输出
类组件 = 对象 → props + state → JSX 输出 + 生命周期方法
点击事件 → 修改 state → 自动 re-render → 页面更新
用 Hooks 在函数组件中实现可复用行为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import React , { useState,useEffect } from "react" ;export default function App ( ) { interface WeatherProps { weather : string ; } const WeatherComponent = (props : WeatherProps ): JSX .Element => { 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" /> ); }
Note
特性
类组件(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 来共享全局数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import React , { useState, createContext, useContext } from "react" ;export default function App ( ) { const ThemeContext = createContext ("" ); const ContextComponent = (): JSX .Element => { const [theme, setTheme] = useState ("dark" ); return ( <div > <ThemeContext.Provider value ={theme} > <button onClick ={() => setTheme(theme == "dark" ? "light" : "dark")}> Toggle theme </button > <Headline /> </ThemeContext.Provider > </div > ); }; const Headline = (): JSX .Element => { const theme = useContext (ThemeContext ); return (<h1 className ={theme} > Current theme: {theme}</h1 > ); }; return (<ContextComponent /> ); }
Note
概念
类比
ThemeContext.Provider
“水塔”
value={theme}
“水的高度”
useContext(ThemeContext)
“水管”
父组件(Provider)
提供水
子组件(useContext)
实际用水
性能开销大 :useContext 会触发所有消费该 Context 的子组件重新渲染
推荐场景 :
主题、配色、语言
用户 session / 权限信息
跨组件共享的只读配置
Exercise 4 为 Express.js 提供 React 前端(实验性)
如此构建文件结构:
1 2 3 4 5 6 project/ │ ├─ package.json ├─ index.js └─ public/ └─ weather.html <-- React 示例文件
package.json:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "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(但是不要在生产环境下这么做):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 <!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 整个文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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); });
编译并启动服务器:
访问 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 项目:
1 2 3 mkdir sample-next cd ./sample-next npx create-next-app@latest --typescript --use-npm --no-app
然后全选 NO,一路回车。
之后:
1 2 3 4 5 6 7 8 9 10 11 > 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
观察目录结构:
1 2 3 4 5 6 7 my-app/ ├─ pages/ │ ├─ index.tsx │ ├─ _app.tsx │ └─ api/ ├─ styles/ ├─ public/
其中:
public/:用来放 静态资源
styles/:全局 CSS(如 globals.css)和CSS Modules(.module.css,样式只作用于某个组件,不污染全局)
pages/:路由
_app.tsx:整个应用的入口、所有页面都会经过这里
Note
相关命令:
命令
场景
是否开发
是否需要先 build
npm run dev
本地开发
✅
❌
npm run build
生成生产构建
❌
❌
npm run start
生产运行
❌
✅
npm run export
纯静态站点
❌
✅
路由
Express.js 中需要手写路由:
URL 写在代码里
行为写在函数里
路由表 = 代码逻辑
1 2 3 app.get ("/hello" , (req, res ) => { res.send ("Hello World!" ); });
Next.js 的思想:文件即路由
只要在 pages/ 目录下:
如创建 pages/hello.tsx 然后访问 http://localhost:3000/hello:
1 2 3 4 5 6 7 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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。
Note
一个全栈应用通常还需要给“程序”用的接口(API),比如:
这就是 machine-readable interface 。
常见 API 形式:
REST (本章用,简单直观)
GraphQL(下一章详细讲
Next.js 中的 API Routes 仍然是“文件即路由”。
创建 pages/api/names.ts 并访问 http://localhost:3000/api/names 即可得到后端返回的 json 数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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); }
1 [ { "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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 }); }
样式
Note
特性
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:
1 2 3 4 5 6 import "@/styles/globals.css" ;import type { AppProps } from "next/app" ;export default function App ({ Component, pageProps }: AppProps ) { return <Component {...pageProps } /> ; }
Note
Global CSS
CSS Modules
globals.css
xxx.module.css
全局生效
只在当前组件
class 不变
class 会被 hash
适合 reset / theme
适合组件
Component Styles
创建(覆盖) styles/Home.module.css:
1 2 3 4 .container { padding : 0 2rem ; color : red; }
创建(覆盖)pages/index.tsx:
1 2 3 4 5 6 7 8 9 10 import type { NextPage } from "next" ;import styles from "@/styles/Home.module.css" ;const Home : NextPage = () => { return ( <div className ={styles.container} > Hello World!</div > ); };export default Home ;
http://localhost:3000/:
内置组件(Built-in Components)
Next.js 提供了一些自带的 React 组件,用来解决常见的需求:
Note
组件
主要作用
next/head
修改 <head> 标签里的内容,比如 <title>、<meta>,方便 SEO 优化
next/image
图片优化,自动处理大小、延迟加载、WebP 等,提高性能
next/link
路由跳转组件,增强前端页面切换体验(客户端路由)
使用 next/head、next/image 和 next/link,修改(覆盖)pages/hello.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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)
Note
**预渲染(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 用于后续实验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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) :
Note
定义 :每次用户请求页面时,Next.js 内置的 Node.js 服务器都会 动态生成 HTML 并返回给客户端。
特点 :
页面内容总是最新的,因为每次请求都会重新生成 HTML。
对需要实时数据的页面非常有用(例如新闻、用户仪表盘、股票行情)。
缺点:比静态生成(SSG)慢,因为每次请求都要执行服务器端逻辑和 API 调用,HTML 不能轻易缓存。
创建 page/names-sst.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 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 ;
Note
在 Next.js 中,启用 SSR 的关键是 导出一个 getServerSideProps 异步函数 :
1 2 3 4 5 6 export const getServerSideProps : GetServerSideProps = async (context) => { const names = await fetchNames (); return { props : { names }, }; };
Next.js 在每次请求页面时都会调用 getServerSideProps。
getServerSideProps 返回的数据会作为 props 传入页面组件。
页面组件使用这些 props 渲染 JSX。
浏览器最终收到的是 完整的 HTML (预渲染),无需额外的客户端渲染才能显示内容。
之后访问 http://localhost:3000/names-ssr:
Static Site Generation (SSG) :
Note
定义 :在 构建时(build time) 生成 HTML 文件,然后所有用户请求都会 直接返回这些静态 HTML 。
特点 :
快速 :因为 HTML 是预生成的,可以直接缓存或通过 CDN 分发。
SEO 优势 :页面内容完整且快速呈现,提高 Google SEO 排名。
低时间指标 :
Time to First Paint (TTFP) :用户点击链接到页面内容显示的时间短。
Blocking Time :用户能交互的时间短。
适合静态数据 :如果页面内容不依赖实时更新数据,非常适合 SSG。
创建 pages/ames-ssg.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 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 提供的一种让静态生成页面“定期更新”的机制。
Note
特点
说明
快速
页面依然是静态 HTML,响应快
节省成本
不像 SSR 每次请求都生成 HTML
数据更新
可以在后台更新页面,保证内容不过时
SEO 友好
页面仍然是静态 HTML,搜索引擎能抓取
总结:ISR = “定时刷新的静态页”,兼顾性能和数据时效。
在 SSG 页面里,只需在 getStaticProps 的返回对象里加上 revalidate:
1 2 3 4 5 6 7 8 export const getStaticProps : GetStaticProps = async () => { const names = await fetchNames (); return { props : { names }, revalidate : 30 }; };
Client-Side Rendering(CSR,客户端渲染)
Note
流程 :
先生成一个基础 HTML(可以通过 SSR 或 SSG)。
浏览器加载 HTML 后,通过 JavaScript 在客户端再去请求数据 。
数据返回后,再把页面内容渲染到浏览器 DOM 中。
特点 :
数据是 实时获取和渲染 ,适合高度动态的数据(比如股票、货币价格)。
初始加载可能显示一个空白或“骨架屏”,然后填充完整数据。
SEO 表现不好,因为搜索引擎抓取的是初始 HTML,没有最终渲染的数据。
创建 pages/names-csr.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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
Note
页面加载会有 延迟渲染 (可能出现白屏或闪烁)。
适合 实时数据展示 ,而不是 SEO 优先的页面。
对比 SSG/SSR/ISR,CSR 的 首屏渲染速度慢 ,但前端交互灵活。
静态 HTML 导出(Static HTML Export)
本质上是把你的应用完全打包成 纯静态网页 ,可以直接部署到任何 Web 服务器(Apache、NGINX、IIS 等),不依赖 Next.js 内置的 Node.js 服务器。
Note
特性
描述
命令
next export
依赖
仅 SSG(getStaticProps)
不支持
SSR、ISR、API Routes
输出
完全静态 HTML + 资源
部署环境
任意 Web 服务器(NGINX、Apache、IIS)
Exercise 5 Express + React → Next.js
把之前 Exercise 的逻辑迁移到 Next.js。
空的文件夹重新创建:
1 npx create-next-app@latest --typescript --use-npm --no-app
创建 custom.d.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 interface WeatherProps { weather : string ; }type WeatherDetailType = { zipcode : string ; weather : string ; temp ?: number ; };type responseItemType = { id : string ; name : string ; };
创建 pages/api/names.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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 ;
运行:
访问:
6 REST AND GRAPHQL APIS
API(Application Programming Interface)本质上是一种“连接规则” ,用于让程序和程序之间通信 。
Note
UI
API
给人点、看、操作
给程序调用
HTML / Button / 页面
JSON / HTTP / 参数
用户容错高
程序对格式极其敏感
全栈通常会接触到 **Internal API(内部 API / 私有 API)**和 Third-party API(第三方 API)
Note
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 的规则和习惯”,而不是某种库或协议。
Note
REST API 的核心形态:URL = 资源
URL
表示
/users
用户集合
/users/123
ID=123 的用户
/weather/10001
某个地点的天气
REST 思维:URL 是名词,不是动词
REST 不靠 URL 表示动作,而是靠 HTTP Method :
Method
含义
GET
获取资源
POST
创建资源
PUT / PATCH
修改资源
DELETE
删除资源
表示 “获取 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 的“使用手册 + 合同”
Note
Specification 明确写清楚:
有哪些 URL
用什么 HTTP 方法
参数是什么
返回什么
返回什么格式
状态码有哪些
OpenAPI / Swagger 是 Specification 的行业标准
Swagger Editor 的价值:
把 JSON / YAML 规范
变成 可读文档 + 可交互 UI
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 { "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 文档。
Note
整个流程可以理解为:
定义 API 元信息 + 版本 → 后端实现要对应
指定根 URL(Servers) → 管理环境
Paths → 定义每个 endpoint
Parameters → 强类型输入
Responses → 强类型输出
Schemas → 数据结构复用
Swagger / Playground → 测试 + 自动生成代码
REST 是无状态的
Note
服务器 不保存客户端的历史信息 。
客户端每次请求都必须包含 完整的必要信息 。
支持 负载均衡 / 代理 / 分层系统
更容易扩展,高并发情况下服务器压力小
前后端解耦,客户端负责状态,服务器只处理请求
REST API 无状态,但仍可认证:
一般使用 token
token 放在:
请求体
HTTP Authorization header
服务器不存 session,每次请求都通过 token 判断用户身份
因为是无状态的,所以可以在负载均衡或代理后面依然工作
HTTP 方法(CRUD)
Note
REST 用 标准 HTTP 方法 来对应数据操作:
CRUD
HTTP 方法
作用
特点
Create
POST
新增资源
每次 POST 都会创建新资源
Read
GET
获取资源
最常用,浏览网页就是 GET 请求
Update
PUT
全量更新已有资源
多次 PUT 会覆盖资源
Partial Update
PATCH
局部更新资源
只更新差异,更高效
Delete
DELETE
删除资源
删除资源,幂等操作
使用 REST API
默认情况下,curl 是已经被安装的了。
使用命令:
1 2 3 4 5 curl -i ^ -X GET ^ -H "Accept: application/json" ^ -H "Authorization: Bearer 83dedad0728baaef3ad3f50bd05ed030" ^ https://www.usemodernfullstack.dev/api/v2/weather/96815
Tip
macOS 下多行命令用 \ 续行
Windows 下用 ^ 续行
参数
用途
-i
显示响应头
-X GET
指定 HTTP 方法为 GET
-H "Accept: application/json"
请求返回 JSON
-H "Authorization: Bearer <token>"
认证 token
URL
包含资源路径 + path parameter(ZIP code)
收到服务器的回复:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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%3 A3LE691sfJZ06_pFWOWs3iFueEBi9PUDv.oEzBxvZWdJ%2 F8NSeHAEBad3Qu67pLSj8KcTsksK9sJQc; 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: 01 KF0P7YSWBEJDH9ZCH4029QD0-nrt{ "weather" : "sunny" , "tempC" : "25" , "tempF" : "77" , "friends" : [ "96814" , "96826" ] }
由响应头和响应体组成。
使用 PUT 请求获取数据
1 2 3 4 5 6 7 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
得到回复:
1 2 3 4 5 6 7 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
Note
对比 GET/PUT
操作
方法
数据位置
返回内容
幂等性
读取
GET
URL + query / path
资源数据
幂等
更新
PUT
请求体 JSON
状态信息或更新后的对象
幂等(多次相同 PUT 结果一样)
GraphQL
REST 是一种架构风格(定义了 URL、HTTP 方法、状态码等规范),而 GraphQL 是 开源的 API 查询和操作语言 ,可以直接用来描述数据请求和更新操作。
Note
特点
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。
Note
Schema 用于定义可用的 Queries 和 Mutations
Query:读取数据
Mutation:创建/更新/删除数据
GraphQL 使用 SDL(Schema Definition Language) 来写 schema,也叫 typedef。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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] ! } ` ;
Note
部分
名称
类型
作用说明
对象类型(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
Note
Resolvers 是 GraphQL 中“真正干活”的函数。
Schema(typeDefs) :只定义“能查什么、长什么样”(接口 / 约定)
Resolver :定义“这些数据从哪来、怎么拿”
一句话总结:
Schema 是“说明书”,Resolver 是“实现代码”
GraphQL 类型
对应操作
CRUD
Query
查询
Read
Mutation
新增 / 修改 / 删除
Create / Update / Delete
一个 GraphQL 查询,本质上是一棵“嵌套调用 resolver 的树”
AST(Abstract Syntax Tree,抽象语法树):
GraphQL 会把你的查询解析成一棵树
每个字段 = 一个节点
每个节点 = 对应一个 resolver 函数
Schema
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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] ! } ` ;
Note
入口点只有一个
weather 返回的是:
LocationWeatherType 里又包含:
这就天然形成了嵌套结构 。
查询
1 2 3 4 5 6 7 8 query GetWeatherWithFriends { weather( zip : "96815" ) { weather friends { weather } } }
Note
请求获取 ZIP=96815 这个地点的
天气(weather)
以及它所有邻居(friends)的天气
从服务器角度上看,查询命令会被解析成类似的树:
1 2 3 4 5 Query └── weather( zip : "96815" ) ├── weather └── friends └── weather
Note
GraphQL 会检查:
Query.weather 是否存在
LocationWeatherType.weather 是否存在
LocationWeatherType.friends.weather 是否合法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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 : [] } ]; } }, };
Note
Schema 里
Resolvers 里
type Query { weather(...) }
Query.weather
type Mutation { weather(...) }
Mutation.weather
对比 GraphQL 和 REST
Note
维度
REST
GraphQL
客户端能否控制返回字段
❌ 基本不能
✅ 完全可以
默认返回数据
整个资源
只返回你要的字段
常见问题
over-fetching / under-fetching
基本避免
接口粒度
endpoint 决定
query 决定
REST 经常会出现 **Over-fetching(过度获取)**和 Under-fetching(取少了)
1 GET /api/v2/weather/zip/96815
可能返回:
1 2 3 4 5 6 7 { "zip" : "96815" , "weather" : "sunny" , "tempC" : "25C" , "tempF" : "70F" , "friends" : [ ...] }
如果取多了,浪费流量,取少了,多次获取也浪费流量。
1 2 3 4 5 query Weather { weather( zip : "96815" ) { tempC } }
这个语法能清晰地表面需要请求什么。
Note
虽然也可以通过 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:
1 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 内部世界
GraphQL 的逻辑不和 HTTP 路由混在一起
编辑 graphql/schema.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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 表示所需要的数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import { ApolloServer } from "@apollo/server" ; import { startServerAndCreateNextHandler } from "@as-integrations/next" ; import { resolvers } from "../../graphql/resolvers" ; import { typeDefs } from "../../graphql/schema" ; import { NextApiHandler , NextApiRequest , NextApiResponse } from "next" ; const server = new ApolloServer ({ resolvers, typeDefs });const handler = startServerAndCreateNextHandler (server);const allowCors = (fn : NextApiHandler ) => async (req : NextApiRequest , res : NextApiResponse ) => { res.setHeader ("Allow" , "POST" ); res.setHeader ("Access-Control-Allow-Origin" , "*" ); res.setHeader ("Access-Control-Allow-Methods" , "POST" ); res.setHeader ("Access-Control-Allow-Headers" , "*" ); res.setHeader ("Access-Control-Allow-Credentials" , "true" ); if (req.method === "OPTIONS" ) { res.status (200 ).end (); } return await fn (req, res); };export default allowCors (handler);
Note
ApolloServer 用于创建 GraphQL 服务。
startServerAndCreateNextHandler 是官方提供的 Next.js 集成工具,可以把 Apollo Server 包装成 Next.js API Route 。
allowCors 是一个中间件:
设置响应头允许跨域访问。
对 OPTIONS 请求(浏览器的预检请求)直接返回 200。
其他请求则交给 Apollo Server 处理。
export default allowCors(handler):
将 Apollo Server 的 API route 暴露给前端,同时支持跨域。
执行命令:
通过 http://localhost:3000/api/graphql 访问 Apollo Server:
点击左侧的 Arguments 和 Fields 得到查询语句:
1 2 3 4 5 6 query Weather( $zip : String) { weather( zip : $zip ) { zip weather } }
提供 Variables:
执行查询后得到查询结果:
1 2 3 4 5 6 7 8 9 10 { "data" : { "weather" : [ { "zip" : "96826" , "weather" : "sunny" } ] } }
7 MONGODB AND MONGOOSE
大多数应用程序都依赖数据库(Database Management System, 简称 DB)来管理和存储数据集合,并控制对这些数据的访问。
MONGODB
一个非关系型数据库,也就是 NoSQL 数据库
MongoDB 返回的数据是 JSON 格式,而且数据库查询可以用 JavaScript 来写,所以对于前后端都用 JavaScript 的开发者来说,它非常自然且方便。
Note
特性
关系型数据库 (RDB)
非关系型数据库 (NoSQL)
数据模型
表格,固定Schema
键值/文档/列族/图,自由Schema
查询语言
SQL
各自的API或查询语言
数据一致性
强一致性(ACID)
弱一致性或最终一致性
扩展性
垂直扩展为主
水平扩展容易
适用场景
金融、ERP、库存管理等
社交网络、日志分析、大数据、缓存
事务支持
完整支持
弱事务或有限支持
SQL / 关系型数据库
MongoDB / 文档型数据库
Table(表)
Collection(集合)
Row(行)
Document(文档)
Column(列)
Field(字段)
数据存储格式
JSON/BSON
查询语言
SQL
使用 ODM(如 Mongoose) :
简化数据库操作
提供对象化接口(面向对象操作数据库)
支持 async/await 异步操作
可以对数据类型和结构进行验证,减少错误
安装
为了方便,使用 内存型 MongoDB(in-memory MongoDB) ,而不是在本地安装和维护真实的 MongoDB 服务器。
特点 :
适合 测试和练习
数据不会持久化 (重启应用后数据会丢失)
提示 :真正的应用部署时,需要使用真实的 MongoDB 服务器。
1 npm install mongodb-memory-server mongoose
定义 Mongoose 模型
为了保证数据的完整性和规范性,需要用 Mongoose schema 创建一个 模型(model) 。
关键点 :
Schema(模式) :定义数据结构、字段类型、约束规则
Model(模型) :是 Mongoose 与 MongoDB 集合(collection)之间的接口,所有对数据库的操作都通过模型进行。
作用 :防止不符合规则的数据进入数据库,保证数据一致性。
在用 TypeScript 编写 Mongoose 模型和模式之前,先声明一个 TypeScript 接口。
创建 mongoose/weather/interface.ts 以定义 Interface :
1 2 3 4 5 6 7 export declare interface WeatherInterface { zip : string ; weather : string ; tempC : string ; tempF : string ; friends : string []; };
Note
解析 :
zip, weather, tempC, tempF:字符串
friends:字符串数组
这个接口和 GraphQL API 的数据结构一一对应。
创建 mongoose/weather/schema.ts 以用 mongoose 定义 Schema :
1 2 3 4 5 6 7 8 9 10 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 }, });
Note
解析 :
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:
1 2 3 4 5 6 import mongoose, { model } from "mongoose" ;import { WeatherInterface } from "./interface" ;import { WeatherSchema } from "./schema" ;export default mongoose.models .Weather || model<WeatherInterface >("Weather" , WeatherSchema );
Note
解析 :
导入模块 :
mongoose:核心库
model:模型构造器
WeatherInterface:类型约束
WeatherSchema:字段结构
创建模型 :
model<WeatherInterface>("Weather", WeatherSchema)
第一个参数 "Weather":模型名 → 对应 MongoDB 中的 collection 名称(自动变复数 Weather → Weathers)
第二个参数:Schema
检查重复模型 :
mongoose.models.Weather || ...
如果模型已存在,不重复创建,否则会报错
导出模型 :方便其他模块使用
集合与数据库 :
模型绑定的集合:Weathers
数据库名:Weather(Mongoose 会自动创建)
graph TD
A[WeatherInterface TypeScript接口] -->|类型约束| B[WeatherSchema Mongoose Schema]
B -->|定义字段结构| C[Weather Model Mongoose Model]
C -->|CRUD接口| D[(MongoDB Collection weathers)]
A -.->|编译时类型检查| A1[开发阶段]
B -.->|运行时验证| B1[数据验证]
C -.->|操作方法| C1[增删改查]
D -.->|持久化| D1[数据存储]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#e8f5e9
style D fill:#f3e5f5
数据库连接中间件(Database-Connection Middleware)
创建 middleware/db-connect.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 import mongoose from "mongoose" ;import { MongoMemoryServer } from "mongodb-memory-server" ;async function dbConnect ( ): Promise <any | String > { const mongoServer = await MongoMemoryServer .create (); const MONGOIO_URI = mongoServer.getUri (); await mongoose.disconnect (); await mongoose.connect (MONGOIO_URI , { dbName : "Weather" }); }export default dbConnect;
Note
**db-connect 中间件的职责只有一个:**确保 MongoDB 已经连接,并且 Mongoose 可以正常使用模型进行查询
它负责的事情包括:
启动一个 内存版 MongoDB
建立 Mongoose ↔ MongoDB 的连接
让已定义的 Mongoose Models 自动绑定到数据库集合
自动处理:
数据库 Query
创建 mongoose/weather/services.ts,写如何增删改查数据库:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 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 ; }
Note
操作
推荐检查字段
判断成功的依据
Create
try / catch
是否抛异常
Read
返回值
是否为 null
Update
matchedCount
是否找到目标文档
Delete
deletedCount
是否真的删除
Service 的定义:
是一个普通函数
专门负责 数据库 CRUD
只和 Mongoose Model 打交道
不关心 GraphQL / HTTP / UI
Note
数据库查询不应该散落在 resolver 里,而应该集中在 service 层;
service 只做一件事:通过 Mongoose Model 执行单一的 CRUD 操作并返回结果。
创建一个端到端 Query
sequenceDiagram
participant Browser as 浏览器
participant API as Next.js API Route (REST)
participant MW as Middleware (dbConnect)
participant Service as Service (findByZip)
participant Model as Mongoose Model
participant DB as MongoDB (内存数据库)
Browser->>+API: HTTP GET /api/v1/weather/96815
API->>+MW: 调用中间件
MW->>MW: 建立数据库连接
MW->>+Service: findByZip('96815')
Service->>+Model: Weather.findOne({zip: '96815'})
Model->>+DB: 查询数据
DB-->>-Model: 返回文档数据
Model-->>-Service: 返回 Weather 对象
Service-->>-MW: 返回查询结果
MW-->>-API: 返回数据
API-->>-Browser: JSON 响应 {zip, city, temp, ...}
Note over Browser,DB: 完整的请求-响应周期
Note over MW: 确保连接复用
Note over DB: 内存数据库(测试环境)
创建 pages/api/v1/weather/[zipcode].ts:
1 2 3 4 5 6 7 8 9 10 11 12 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 以添加一个初始化的数据集:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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;
编译:
访问 http://localhost:3000/api/v1/weather/96815,得到数据库查询的结果:
1 { "_id" : "696e138d01ac54e9470d3543" , "zip" : "96815" , "weather" : "sunny" , "tempC" : "25C" , "tempF" : "70F" , "friends" : [ "96814" , "96826" ] , "__v" : 0 }
Exercise 7 使用 GraphQL API 连接数据库
把之前 Weather 的 GraphQL API 从“读静态 JSON”改成“读 MongoDB 数据库”。
sequenceDiagram
participant Client as GraphQL 客户端
participant API as Next.js API (/graphql)
participant MW as Middleware (dbConnect)
participant Resolver as GraphQL Resolvers
participant Service as Services (Mongoose CRUD)
participant DB as MongoDB (内存数据库)
Client->>+API: POST /api/graphql query { weather(zip: "96815") {...} }
API->>+MW: 调用中间件
MW->>MW: 建立/复用数据库连接
MW->>+Resolver: 解析 GraphQL 查询
Resolver->>Resolver: 匹配查询字段
Resolver->>+Service: 调用对应服务方法 (findByZip/create/update...)
Service->>+DB: Mongoose 执行 CRUD 操作
DB-->>-Service: 返回数据文档
Service-->>-Resolver: 返回处理结果
Resolver->>Resolver: 字段解析与组装
Resolver-->>-MW: 返回 GraphQL 响应
MW-->>-API: 返回结果
API-->>-Client: JSON 响应 { data: { weather: {...} } }
Note over Client,API: GraphQL 查询/变更
Note over Resolver: 根据 Schema 解析字段
Note over Service: 业务逻辑层
Note over DB: 开发/测试环境
Note
之前在 REST API 中是这样:
1 2 3 /api/v1/weather/96815 /api/v1/weather/96814 /api/v1/weather/96826
每个 endpoint 都是一个入口。
而 GraphQL:
所有请求都走同一个 API 文件
这带来一个巨大好处:数据库连接只需要在一个地方处理一次
修改 api/graphql.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 import { ApolloServer } from "@apollo/server" ;import { startServerAndCreateNextHandler } from "@as-integrations/next" ;import { resolvers } from "../../graphql/resolvers" ; import { typeDefs } from "../../graphql/schema" ; import { NextApiHandler , NextApiRequest , NextApiResponse } from "next" ; import dbConnect from "../../middleware/db-connect" ; const server = new ApolloServer ({ resolvers, typeDefs, });const handler = startServerAndCreateNextHandler (server);const allowCors = (fn : NextApiHandler ) => async (req : NextApiRequest , res : NextApiResponse ) => { res.setHeader ("Allow" , "POST" ); res.setHeader ("Access-Control-Allow-Origin" , "*" ); res.setHeader ("Access-Control-Allow-Methods" , "POST" ); res.setHeader ("Access-Control-Allow-Headers" , "*" ); res.setHeader ("Access-Control-Allow-Credentials" , "true" ); if (req.method === "OPTIONS" ) { res.status (200 ).end (); return ; } return await fn (req, res); };const connectDB = (fn : NextApiHandler ) => async (req : NextApiRequest , res : NextApiResponse ) => { await dbConnect (); return await fn (req, res); };export default connectDB (allowCors (handler));
修改 graphql/resolvers.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 通过自动化测试来确保修改不会破坏已有功能。
Note
为什么要测试
避免代码修改带来的意外副作用。
保证代码库的稳定性。
两条路线保障代码质量
组件化架构 :减少依赖与副作用。
自动化测试 :Jest 帮助验证每个单元行为。
Jest 的核心用法
写测试套件(test suites)
检查功能是否符合预期
使用 mock 管理依赖
利用报告发现问题
Test-Driven Development(测试驱动开发) and Unit Testing(单元测试)
Note
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
1 npm install --save-dev jest @types/jest
在 package.json 中添加 "test": "jest" 以方便使用 npm test 运行 Jest。
1 2 3 4 5 6 7 "scripts" : { "dev" : "next dev" , "build" : "next build" , "start" : "next start" , "lint" : "next lint" , "test" : "jest" } ,
继续:
1 npm install --save-dev ts-jest
这将生成 jest.config.js:
1 2 3 4 5 6 7 8 9 10 11 const { createDefaultPreset } = require ("ts-jest" );const tsJestTransformCfg = createDefaultPreset ().transform ;module .exports = { testEnvironment : "node" , transform : { ...tsJestTransformCfg, }, };
创建一个测试用示例模块
创建 ./helpers/sum.test.ts:
1 2 3 4 5 import { sum } from "../helpers/sum" ;describe ("the sum function" , () => { });
Note
导入函数 :虽然 sum.ts 还没写,但我们先在测试文件中导入它。
describe :Jest 的函数,用于创建测试套件(test suite)。
参数 1:套件名称 "the sum function"
参数 2:回调函数,里面写具体的测试用例(test cases)
空套件 :目前没有测试用例,Jest 会提醒我们至少要有一个测试。
TDD 核心点 :先写测试,再写功能代码。
创建模块文件和占位函数 ./helpers/sum.ts:
1 2 const sum = ( ) => {};export { sum };
Note
占位函数 :先创建一个最简单的空函数,保证模块可以被导入。
执行测试试试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 > 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 test
Jest 提示套件为空或测试失败
红灯(Red)
4. 写测试用例
添加 test() 或 it() 来描述预期行为
红灯→绿灯(Red→Green)
5. 实现函数
修改 sum() 实现功能,使测试通过
绿灯(Green)
6. 重构
优化代码结构,保持测试通过
重构(Refactor)
测试用例的结构
有两种单元测试的类型:
Note
所有的测试用例都遵循三步法(AAA):
Arrange :准备测试数据 / 依赖
Act :调用函数
Assert :验证结果
覆盖 ./helpers/sum.test.ts 以实现 Arrange(准备) ,用于定义前置条件、测试数据、依赖环境:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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(执行) ,调用被测函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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(断言) ,验证返回结果是否符合预期。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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 会出现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 > 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():
1 2 const sum = (a : number , b : number ): number => a + b;export { sum };
如此做,测试成功:
1 2 3 4 5 6 7 8 9 10 11 12 13 > 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)
Note
测试先行 → 测试失败 → 再修改实现 → 测试通过 = 安全重构
在 TDD 里的思想里:需求变化 = 测试变化 ,所以第一步先改测试而不是实现。
因此修改 ./helpers/sum.test.file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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:
1 2 3 4 const sum = (data : number []): number => { return data.reduce ((a, b ) => a + b); };export { sum };
测试的量化指标
通过测试覆盖率(Test Coverage)评价 测试套件实际执行到了哪些代码行 。一般地,代码覆盖率目标设定为 90% 或以上,并对代码中最关键的部分保持高覆盖率。当然,测试用例应通过测试代码功能来增加价值;仅仅为了提高测试覆盖率而添加测试并不是我们的目标。
修改 package.json 中添加 "test": "jest --coverage" 以在测试中显示测试覆盖率。
1 2 3 4 5 6 7 "scripts" : { "dev" : "next dev" , "build" : "next build" , "start" : "next start" , "lint" : "eslint" , "test" : "jest --coverage" } ,
再 npm test 会显示代码覆盖率(这里是 100%):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 > 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 替换依赖
Note
单元测试要求“隔离”,但真实代码不可避免地依赖其他模块。
引入 Test Doubles(测试替身) :
替代真实对象或函数
消除外部依赖
行为是可控、可预测的
这正是“可重复测试”的基础。
类型
中文常见叫法
主要目的
关注点
是否关心“被怎么调用”
是否返回真实结果
典型使用场景
Fake
假实现
提供可运行的简化实现
行为是否近似真实
❌ 通常不关心
✅ 是(但不完整)
内存数据库、简化服务、测试用仓库
Stub
桩
返回固定、可预测的数据
返回值 / 状态
❌ 不关心
⚠️ 是(人为设定)
隔离外部依赖、控制测试输入
Mock
模拟
验证交互行为是否发生
调用次数 / 参数 / 顺序
✅ 非常关心
⚠️ 可有可无
验证是否调用了某个函数
测试关注点
更常用的 Test Double
状态 / 返回值正确性
Stub、Fake
行为 / 交互是否发生
Mock
复杂依赖的可运行替代
Fake
问题
对应方案
真实依赖不可控
Stub
真实依赖太复杂
Fake
需要验证行为而非结果
Mock
测试不应该穷举,而是测试策略。
判断边界条件
判断核心功能是否成立
新建一个 ./helpers/fibonacci.test.ts 用于测试斐波那契的实现:
1 2 3 4 5 6 7 8 9 10 11 12 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():
1 2 3 4 5 6 7 8 9 10 11 12 13 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 };
Important
如果 sum 出问题,fibonacci 的测试也会失败 —— 即使 Fibonacci 本身是对的
创建 Doubles
修改 fibonacci.test.ts 使用 mock。
1 2 3 4 5 6 7 8 9 10 11 12 13 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:
Note
__mocks__/sum.ts 的作用是:在测试运行期间,把真实的 sum 替换掉 ,而不是修改生产代码。
1 2 3 const sum = (data : number []): number => 999 ;export { sum };
这个测试替身无论接收到什么数据,总是返回相同的数字 999。
Note
这个 stub:
stub 在这里不是为了“算对”,而是为了证明 Fibonacci 在循环中确实调用了 sum
使用 Fake
修改 ./helpers/__mocks__/sum.ts:
1 2 3 4 5 const sum = (data : number []): number => { return data[0 ] + data[1 ]; }export { sum };
使用 Mock
修改 ./helpers/__mocks__/sum.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 };
Note
mock 会“看参数”
mock 会“按规则返回”
Note
类型
在 Fibonacci 例子中的作用
Stub
证明“sum 被调用了多少次”
Fake
提供足够真实的计算逻辑
Mock
精确控制依赖返回值
更多测试类型
Note
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 给项目添加测试用例
Warning
原书上没有这一改动,但是改了可以让测试不报错。修改 middleware/db-connect.ts 让 dbConnect() 执行后返回数据库实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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" }); }); });
Note
这个测试文件做了三件事:
验证内存数据库是否被创建。
验证 Mongoose 是否连接和断开。
确保 dbConnect() 正确使用了 MongoMemoryServer 的 URI 和数据库名。
内容
说明
测试目标
验证 middleware 调用 MongoMemoryServer 和 mongoose API
技术
Jest spy (jest.spyOn)
环境
Node(@jest-environment node)
生命周期管理
afterEach 清理 mocks + 停止数据库;afterAll 恢复 mocks
测试策略
只验证方法调用次数和参数,不关心内部实现或数据库结果
可测试性
dbConnect 函数需返回 mongoServer 实例
Tip
在 mongoose/weather/services.ts 里:
service 不直接操作数据库
而是 通过 Mongoose 的 Model(WeatherModel) 来完成 CRUD
例如:
WeatherModel.create(...)
WeatherModel.findOne(...)
WeatherModel.updateOne(...)
WeatherModel.deleteOne(...)
问题 :这些方法都依赖真实数据库连接,所以创建一个假的 model 避开数据库的调用。
创建 mongoose/weather/__mocks__/model.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 import { WeatherInterface } from "../../../mongoose/weather/interface" ;import { findByZip, storeDocument, updateByZip, deleteByZip, } from "../../../mongoose/weather/services" ;import WeatherModel from "../../../mongoose/weather/model" ; jest.mock ("../../../mongoose/weather/model" );describe ("the weather services" , () => { let doc : WeatherInterface = { zip : "test" , weather : "weather" , tempC : "00" , tempF : "01" , friends : [] }; afterEach (async () => { jest.clearAllMocks (); }); afterAll (async () => { jest.restoreAllMocks (); }); describe ("API storeDocument" , () => { test ("returns true" , async () => { const result = await storeDocument (doc); expect (result).toBeTruthy (); }); test ("passes the document to Model.create()" , async () => { const spy = jest.spyOn (WeatherModel , "create" ); await storeDocument (doc); expect (spy).toHaveBeenCalledWith (doc); }); }); describe ("API findByZip" , () => { test ("returns true" , async () => { const result = await findByZip (doc.zip ); expect (result).toBeTruthy (); }); 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 }); }); }); describe ("API updateByZip" , () => { test ("returns true" , async () => { const result = await updateByZip (doc.zip , doc); expect (result).toBeTruthy (); }); 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); }); }); describe ("API deleteByZip" , () => { test ("returns true" , async () => { const result = await deleteByZip (doc.zip ); expect (result).toBeTruthy (); }); test ("passes the zip code Model.deleteOne()" , async () => { const spy = jest.spyOn (WeatherModel , "deleteOne" ); await deleteByZip (doc.zip ); expect (spy).toHaveBeenCalledWith ({ zip : doc.zip }); }); }); });
测试结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 提供端到端测试
Note
E2E(端到端)测试的含义:不 mock、不隔离任何模块,直接从“HTTP 请求 → API → middleware → service → 数据库 → 返回响应”,验证整个系统是否真的能跑通。
测试类型
是否隔离
是否 mock
关注点
单元测试
是
是
某个函数是否正确
集成测试
否
通常否
模块之间是否协作
端到端测试
❌
❌
系统是否整体可用
创建 __tests__/pages/api/v1/weather/zipcode.e2e.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 {};
Note
书里提到:
这里不用它们,是因为:
教学示例要 简单
重点在 Jest 的 E2E 思路,而不是工具细节
fetch 已经足够表达“端到端”概念
使用快照测试(Snapshot Test)测试 UI
Note
UI 的问题是:
属性多(width / height / class / text / children…)
手写断言成本极高
改一点样式,可能要改几十个断言
Snapshot 的思路是:
如果不同:
非常适合 React 组件 / 页面结构测试
继续安装:
1 2 3 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 项目结构。
1 2 3 4 const nextJest = require ("next/jest" );const createJestConfig = nextJest ({});module .exports = createJestConfig (nextJest ({}));
创建 __tests__/pages/components/weather.snapshot.test.tsx:
Tip
原书代码中 act 和 create 已弃用。
1 2 3 4 5 6 7 8 9 10 11 12 13 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:
1 2 3 4 5 6 7 8 9 exports [`PageComponentWeather renders correctly 1` ] = ` <DocumentFragment> <h1> The weather is sunny, counter 1 </h1> </DocumentFragment> ` ;
根据 snapshot 判断 DOM 结构是否改变。
第二版测试
Tip
第一版 snapshot 只测“初始渲染”,第二版通过 act + react-test-renderer 把点击和 useEffect 也纳入 snapshot,从而提高覆盖率。
Caution
原书上修改 __tests__/pages/components/weather.snapshot.test.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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 (); }); });
在目前已经弃用。
安装:
1 npm install --save-dev @testing-library/jest-dom
修改 __tests__/pages/components/weather.snapshot.test.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 (); });
这一用例会在页面上找到标题并模拟用户点击它。
测试后提示:
1 2 3 4 5 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
Note
Snapshot 中有一个过时:
说明组件或测试发生变化
可选择更新快照 (npm test -- -u)
或保留旧快照以便对比
9 AUTHORIZATION WITH OAUTH
Note
很多应用不自己做登录,而是“借用”大厂账号(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(高风险)
Note
Grant Type
是否推荐
场景
Client Credentials
✅
机器对机器
Authorization Code
✅(最重要)
用户登录
Implicit
❌
已废弃
Password
❌
高风险
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
Note
部分
干什么
Header
说明“这是什么 token,用什么算法签的”
Payload
放数据(claims)
Signature
防伪、防篡改
Header
1 2 3 4 { "typ" : "JWT" , "alg" : "HS256" }
typ:这是 JWT
alg:使用对称密钥签名,安全性依赖 secret 的保密性
Payload
OAuth 真正有用的信息都在 payload 里。
1 2 3 4 5 6 7 8 const payloadObject = { "iss" : "https://www.usemodernfullstack.dev/" , "sub" : "THE_CLIENT_ID" , "aud" : "api://endpoint" , "exp" : 234133423 , "weather_public_zip" : "96815" , "weather_private_type" : "GitHub" }
Note
类型
字段
示例
说明
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
格式如下:
1 2 3 4 HMAC_SHA256 ( base64 (header) + "." + base64 (payload), secret )
Note
可以确保完整性。
改 header → 签名不匹配
改 payload → 签名不匹配
没 secret → 造不出合法 token
在一个空白文件夹下创建 index.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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。
命令行:
1 2 npm install --save-dev @types/node npx tsc index.ts --outDir . --module commonjs && node index.js
1 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjIzNDEzMzQyMywid2VhdGhlcl9wdWJsaWNfemlwIjoiOTY4MTUiLCJ3ZWF0aGVyX3ByaXZhdGVfdHlwZSI6IkdpdEh1YiJ9.f667c81749886ee01831376a38fbdba4d7f59a14c14f3a60e1bbee977c993ac9
Exercise 9 访问受保护的资源
如果没有登录就执行下面的命令:
1 curl -i -X GET "https://www.usemodernfullstack.dev/protected/resource" -H "Accept: text/html"
会被服务器返回 401。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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:
命令行:
1 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"
得到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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>
Note
参数
含义
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 里 code=ccd70b795084357dbdedb89e42b6620a65d4b939,每次调用得到的 code 都不同),之后用这个授权码 换取一个访问令牌(access token),才能真正访问用户的受保护资源。
Note
OAuth 标准规定,用授权码换取访问令牌的端点通常是:POST /oauth/access_token。
这里是:https://www.usemodernfullstack.dev/oauth/access_token
命令行:
1 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"
得到访问令牌 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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"}
使用这个访问令牌 去访问受保护的资源:
1 curl -i -X GET "https://www.usemodernfullstack.dev/protected/resource" -H "Accept: text/html" -H "Authorization: Bearer "3d182fe4da6ce3b611ac4442814db845d89fd59c"
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 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 后,检查:
1 Docker version 28.3.3, build 980b856
创建一个 Docker 容器
Note
Docker 的核心组件
Host(主机) :
Docker 容器运行在一个物理机或虚拟机上,这台机器称为 Host 。
开发阶段:Host = 你的本地电脑
部署阶段:Host = 服务器
Docker daemon(守护进程) :
Docker 容器 :
容器是运行中的应用实例。
容器来源于 Docker 镜像(image) ,镜像是一个包含应用及依赖的可执行包。
在之前 Next.js 项目目录下的 Dockerfile:
1 2 3 4 FROM node:currentWORKDIR /home/node COPY package.json package-lock.json /home/node/ EXPOSE 3000
Note
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 镜像:
1 docker image build --tag nextjs:latest .
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 [+] 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 镜像:
1 2 REPOSITORY TAG IMAGE ID CREATED SIZE nextjs latest b822981104ca About a minute ago 1.62GB
删除旧的镜像:
1 docker container rm nextjs_container
启动容器并运行 Next.js:
1 2 3 4 5 docker container run ` --name nextjs_container ` --volume D:/Users/Documents/Study/sample-next/my-app:/home/node/ ` --publish-all ` nextjs:latest npm run dev
Note
标志
功能
说明
--name nextjs_container
容器名称
唯一标识容器,方便后续操作
--volume ~/nextjs_refactored/:/home/node/
挂载本地目录
将本地 Next.js 项目同步到容器内 /home/node/
--publish-all
自动端口映射
将容器内部 EXPOSE 的端口映射到宿主机的随机端口
nextjs:latest
镜像
使用刚刚构建的 Next.js 镜像
npm run dev
容器内命令
启动 Next.js 开发服务器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 >> --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
Note
使用 --publish-all 时,Docker 会随机分配宿主机端口映射到容器端口 3000。
浏览器直接访问 http://localhost:3000 可能无法访问,因为宿主机端口不是 3000。
使用以下命令查看实际映射端口:
1 2 CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 37aedb295b7b nextjs:latest "docker-entrypoint.s…" 3 minutes ago Up 3 minutes 0.0.0.0:32769->3000/tcp nextjs_container
宿主机端口 32769 映射到容器端口 3000,所以访问 Next.js 应用的地址是:http://localhost:32769/
与容器交互
Note
1 docker container exec -it <container ID or name> /bin/sh
停止容器
Note
1 docker container kill <container ID or name>
停止正在运行的容器
可以用 容器名 或 容器 ID 指定目标
使用 Docker Compose 做微服务
Note
架构
特点
单体应用(Monolith)
前端 + 后端 + 数据库全在一个程序里,耦合严重
微服务
前端 / 后端 / 测试 / DB 各是独立服务
Tip
原书上不是在 Windows 系统下进行的,直接照搬原书的会有问题。
修改 dockerfile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 FROM node:currentWORKDIR /home/node COPY package.json package-lock.json ./ RUN npm ci COPY . . EXPOSE 3000 CMD ["npm" , "run" , "dev" ]
项目中创建 .dockerignore:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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
清理之前的容器:
1 2 docker compose down -v docker compose build --no-cache
启动微服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 [+] 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 |
Note
常用命令:
功能
命令
看状态
docker compose ls
启动
docker compose up
正常关
docker compose down
强制关
docker compose kill
彻底重来
docker compose down -v