安橙的博客

👋 欢迎来到我的博客

个人用 SKILLS

绘图 SKILL 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 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 # Paper-tone Diagram 学术克制 × 手工纸感 × 编辑设计。回避现代 SaaS 风(饱和蓝、纯白底、阴影泛滥、圆角滥用),把"信息"当作"印刷品"来呈现。适用流程图 / 架构图 / pipeline 示意图,中文友好,可输出 HTML、Excalidraw、SVG。 气质关键词:**安静、考究、可读、不喧哗**。 --- ## 1 · Color Tokens(固定不可改) ``` --ivory: #FAF9F5 主背景(代替纯白) --paper: #FFFFFF 卡片背景 --slate: #141413 主文字(几乎黑但偏暖) --clay: #D97757 唯一强调色 — 关键路径 / 序号 / 斜体 --clay-d: #B85C3E 强调暗色,备用 --oat: #E3DACC 暖灰 — hover / 下划线 --olive: #788C5D 第二色,用量 ≈ clay 的 1/5 --g100: #F0EEE6 最浅灰 — 徽章背景 --g200: #E6E3DA 边框 / 分隔 --g300: #D1CFC5 标准描边 --g500: #87867F 次要文字、Mono 标签 --g700: #3D3D3A 正文次级 ``` 调色法则:**没有冷灰**(所有灰带土黄底);全页只有 clay 一种亮色,占比 < 20%;不用纯黑纯白;olive 仅作分类区分,用量约 clay 的 1/5。 --- ## 2 · Typography(固定) ``` --serif: ui-serif, Georgia, "Times New Roman", Times, serif; --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace; ``` | 用途 | 字体 | 字号 | 字重 | 字距 | |---|---|---|---|---| | H1 | serif | clamp(38, 5.4vw, 62)px | **500** | -0.018em | | H2 章节 | serif | 27px | 500 | -0.012em | | 卡片标题 | serif | 19px | 500 | -0.008em | | 正文 lede | sans | 16.5px | 400 | — | | 卡片描述 | sans | 13.5px | 400 | — | | Mono 标签 | mono | 10–13px | 400/600 | 0.08–0.12em | 铁律:serif 标题字重统一 **500**(不要 600/700);H1 用负字距 -0.018em + `max-width:17ch`;斜体词用 `<em>` + clay 色,是页面唯一的"装饰修辞"。 --- ## 3 · Layout ``` .wrap { max-width:1120px; margin:0 auto; padding:0 32px 140px; } ``` - 章节间距 **72px**;header 顶部 80px / 底部 56px;footer 顶部 100px - **50px 悬挂缩进** — 章节标题不缩,介绍与卡片网格 `margin-left:50px` - 卡片网格 `grid-template-columns: repeat(auto-fill, minmax(316px, 1fr)); gap:20px` --- ## 4 · Borders / Radius / Motion - 描边 **1.5px**(HTML) / **1px**(Excalidraw,无 1.5 选项) - 圆角:5px(SVG 内)、6px(tag)、10–14px(卡片)、999px(胶囊) - 动效时长 **120–150ms**(纸面回弹感) - 只用 transform + 颜色 + 阴影,**不缩放、不旋转、不淡入** - 默认 ease,不用 cubic-bezier --- ## 5 · 通用结构 — Vertical Pipeline 流程图 每张图按这个骨架组织: 1. **masthead** — eyebrow(▬ + Mono 大写)+ H1(serif 500,带 `<em>` 斜体)+ lede(sans 16.5)+ meta strip(Mono 横条罗列关键技术栈) 2. **section header** — 序号(Mono / clay / 34px 宽)+ 标题(serif 27)+ count(灰胶囊) 3. **节点垂直流** — 每节点 = mono tag + serif title + sans sub 4. **节点间 vertical arrow** — strokeWidth=1, color=g500, 长度 ~42px 5. **右侧 mono 注释** — 前置 24×1.5px clay 短横线 + Mono 11px 灰字 6. **大型节点用 panel** — slate border,内嵌 4 列子模块网格,关键子模块用 clay border;子模块再下沉到深层硬件栈(深色块用 slate 背景 + ivory 文字) 7. **底部 legend** — 色彩与符号说明 8. **footer** — 衬线斜体格言 + 仓库链接 节点状态 mapping: - `entry` — bg=g100, border=slate - 普通 service — bg=transparent, border=slate - `accent`(关键路径) — border=clay - `exit` — bg=oat, border=clay - `dark`(硬件层) — bg=slate, 文字=ivory, 副=oat --- ## 6 · Variant A — HTML 单文件 CSS 起手必备: ```css :root{ --ivory:#FAF9F5; --paper:#FFFFFF; --slate:#141413; --clay:#D97757; --clay-d:#B85C3E; --oat:#E3DACC; --olive:#788C5D; --g100:#F0EEE6; --g200:#E6E3DA; --g300:#D1CFC5; --g500:#87867F; --g700:#3D3D3A; --serif: ui-serif, Georgia, serif; --sans: system-ui, -apple-system, sans-serif; --mono: ui-monospace, "SF Mono", Menlo, monospace; } body{ background:var(--ivory); color:var(--slate); font-family:var(--sans); line-height:1.55; } .wrap{ max-width:1120px; margin:0 auto; padding:0 32px 140px; } .eyebrow{ font-family:var(--mono); font-size:12px; letter-spacing:.12em; text-transform:uppercase; color:var(--g500); } .eyebrow::before{ content:""; display:inline-block; width:24px; height:1.5px; background:var(--clay); margin-right:12px; vertical-align:middle; } h1{ font-family:var(--serif); font-weight:500; font-size:clamp(38px, 5.4vw, 62px); letter-spacing:-0.018em; line-height:1.06; max-width:17ch; } h1 em{ font-style:italic; color:var(--clay); } ``` 节点卡片: ```css .node{ background:var(--paper); border:1.5px solid var(--g300); border-radius:14px; padding:18px 22px; transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease; } .node:hover{ transform:translateY(-3px); box-shadow:0 10px 30px rgba(20,20,19,.10); border-color:var(--slate); } .node.accent{ border-color:var(--clay); } .node.entry { background:var(--g100); } .node.exit { background:var(--oat); border-color:var(--clay); } .annot{ font-family:var(--mono); font-size:11.5px; color:var(--g500); display:flex; align-items:center; } .annot::before{ content:""; width:32px; height:1.5px; background:var(--clay); margin-right:12px; } ``` 垂直箭头(SVG): ```html <svg width="14" height="46" viewBox="0 0 14 46"> <line x1="7" y1="2" x2="7" y2="34" stroke="#87867F" stroke-width="2.5" stroke-linecap="round"/> <polyline points="2,32 7,42 12,32" fill="none" stroke="#87867F" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/> </svg> ``` --- ## 7 · Variant B — Excalidraw (.excalidraw) JSON 文件骨架: ```json { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [...], "appState": { "gridSize": null, "viewBackgroundColor": "#FAF9F5" }, "files": {} } ``` 每个 element 必备字段:`id, type, x, y, width, height, angle, strokeColor, backgroundColor, fillStyle, strokeWidth, strokeStyle, roughness, opacity, groupIds, frameId, roundness, seed, version, versionNonce, isDeleted, boundElements, updatedAt, link, locked` 铁律: - **`roughness: 0`** 全画布禁用手绘抖动(学术风核心) - `strokeWidth: 1`(Excalidraw 没有 1.5) - 圆角矩形:`"roundness": {"type": 3}` - `fillStyle: "solid"`(不用 hachure 斜线填充) - 字体: 统一使用 Comic Shanns 字体,font-family: 8 推荐用 Python 脚本生成,因为 JSON 重复字段多。最小工厂: ```python import json, time, random random.seed(42) elements = [] def base(): return { "id": f"id-{random.randint(10**8, 10**12)}", "angle": 0, "strokeColor": "#141413", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "groupIds": [], "frameId": None, "roundness": None, "seed": random.randint(1, 10**9), "version": 1, "versionNonce": random.randint(1, 10**9), "isDeleted": False, "boundElements": None, "updatedAt": int(time.time() * 1000), "link": None, "locked": False, } def rect(x, y, w, h, *, stroke="#141413", bg="transparent", sw=1, rounded=True): el = base(); el.update({"type":"rectangle", "x":x, "y":y, "width":w, "height":h, "strokeColor":stroke, "backgroundColor":bg, "strokeWidth":sw, "roundness": {"type":3} if rounded else None}) elements.append(el); return el def text(x, y, content, *, size=14, font=2, color="#141413", w=None, align="left"): if w is None: cn = sum(1 for c in content if ord(c) > 127) w = int(cn*size + (len(content)-cn)*size*0.55) + 6 el = base(); el.update({"type":"text", "x":x, "y":y, "width":w, "height":int(size*1.25), "text":content, "fontSize":size, "fontFamily":font, "textAlign":align, "verticalAlign":"top", "containerId":None, "originalText":content, "lineHeight":1.25, "baseline":int(size*0.9), "strokeColor":color}) elements.append(el); return el def arrow(x1, y1, x2, y2, *, color="#87867F", sw=1): dx, dy = x2-x1, y2-y1 el = base(); el.update({"type":"arrow", "x":x1, "y":y1, "width":max(abs(dx),1), "height":max(abs(dy),1), "points":[[0,0],[dx,dy]], "lastCommittedPoint":None, "startBinding":None, "endBinding":None, "startArrowhead":None, "endArrowhead":"arrow", "strokeColor":color, "strokeWidth":sw}) elements.append(el); return el def line(x1, y1, x2, y2, *, color="#87867F", sw=1, dashed=False): dx, dy = x2-x1, y2-y1 el = base(); el.update({"type":"line", "x":x1, "y":y1, "width":max(abs(dx),1), "height":max(abs(dy),1), "points":[[0,0],[dx,dy]], "lastCommittedPoint":None, "startBinding":None, "endBinding":None, "startArrowhead":None, "endArrowhead":None, "strokeColor":color, "strokeWidth":sw, "strokeStyle": "dashed" if dashed else "solid"}) elements.append(el); return el # ... 调用 rect/text/arrow 组装画布,最后: data = {"type":"excalidraw", "version":2, "source":"https://excalidraw.com", "elements":elements, "appState":{"gridSize":None, "viewBackgroundColor":"#FAF9F5"}, "files":{}} json.dump(data, open("out.excalidraw","w"), ensure_ascii=False, indent=2) ``` 布局参考(垂直流程图): - 主轴中心 X = 700,卡片宽 380,普通节点高 78 - 节点之间 arrow 长度 42 - 大面板宽 540~560,内嵌 4 列子模块(每个 ~120 宽,gap 12) - 大面板的"04 · ENGINE"标签:在 border 上浮一个 IVORY 背景的小矩形 + clay Mono 文字 - 右侧详细注释面板 X = 主面板右边缘 + 60,宽 320 - 注释条目格式:Mono clay 小标题(`§ NN · 主题`)+ sans g700 多行正文 --- ## 8 · Variant C — SVG 缩略图 放在卡片缩略图区(.thumb,132px 高,灰底)。 约定: - `stroke-width: 2.5` - `stroke-linecap: round` - 圆角 `rx="4"` 或 `5` - 一张图只用 3–5 个色阶,clay 占比 < 20% - 几何元素:`<rect>`、`<circle>`、`<line>`,**不画曲线** class 命名: - `.st` 描边 (g500) / `.fl` 填充 (g300) / `.cl` clay 填充 / `.ol` olive 填充 - `.oa` oat 填充 + g500 描边 / `.sl` slate 填充 / `.wh` 白填充 + g500 描边 - `.ln` 灰线 / `.lc` clay 线 / `.da` 虚线 (dasharray 4 4) --- ## 9 · 输出前自检 Checklist 每张图都必须满足: 1. ☐ 背景 `#FAF9F5` 米白,**不用纯白** 2. ☐ 强调色只有 `#D97757` 粘土橙,占比 < 20%,无第二亮色 3. ☐ 大标题用 serif(Excalidraw 例外:Helvetica 大字号 字重 500) 4. ☐ Mono 标签全部大写 + 宽字距(eyebrow / 序号 / 文件名 / 注释) 5. ☐ 章节缩进 50px(标题不缩,正文与卡片缩) 6. ☐ 描边 1.5px(HTML)/ 1px(Excalidraw),圆角 10–14px 7. ☐ 没有阴影泛滥(只在 hover 时柔和阴影) 8. ☐ 没有冷灰(灰一律带土黄底) 9. ☐ Hover 只动 150ms(translateY(-3px) + 颜色 + 阴影) 10. ☐ 大量留白(下边距 ≥ 100px,章节间距 72px) 11. ☐ Excalidraw 必须 `roughness: 0` 12. ☐ 大量中文标注时,字号略放大,行高 ≥ 1.5,留白略多 --- ## 10 · 命名与产物建议 - HTML:`{topic}-pipeline.html` / `{topic}-architecture.html` - Excalidraw:`{topic}.excalidraw`(中文文件名也支持) - 生成脚本:`gen_{topic}.py`,参数化坐标 / 文字 / 配色,便于反复调整 > 这套设计的精神:把"信息"当作"印刷品",而不是"界面"。少即是多,但少不是空 — 是经过裁剪的丰富。

五月 10, 2026

AI 数学基础

线性代数 向量与向量空间 - 向量与向量空间 - 什么是向量 - 向量是一个有方向的量 - 几何视角:空间中从原点出发的一支箭头,方向和长度都有意义 - 代数视角:一组有序的数字 - 示例 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505104026041.png,96,87) - 这是一个三维向量,每个数字对应一个坐标轴上的分量。 - 词嵌入 - 一个词(token)会被映射成一个几百甚至几千维的向量 - 比如 "猫" 这个词可能被表示成一个 768 维的向量——你可以把它想象成 768 个坐标轴上的一个点 - 语义相近的词在这个空间里位置接近 - 向量的基本运算 - 向量加法 - 两个向量相加,对应分量分别相加 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505104141640.png,287,77) - 几何意义: 把 $\vec{b}$ 的起点接到 $\vec{a}$ 的终点,结果是合向量。 - 在 LLM 里的例子:有研究发现,词向量之间存在近似的语义关系 - vec("国王")−vec("男人")+vec("女人")≈vec("女王") - 标量乘法 - 用一个数(标量)乘以向量,每个分量都乘以这个数 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505111258217.png,172,77) - 几何意义:把向量拉伸或压缩 - 点积 - 定义 - 两个同维度向量的点积 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505114508393.png,360,73) - 示例 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505114528207.png,483,99) - 几何意义 - 点积有另一种等价写法 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505114553768.png,165,50) - 其中 $\theta$ 是两向量之间的夹角 - 关键洞察 - 点积可以衡量两个向量"有多相似"。 - 这正是 Attention 机制里 Query 和 Key 做点积的本质——算两个向量的相关程度 - 向量范数(Norm) - 范数衡量向量的"长度"或"大小" - L2 范数(最常用) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505115921185.png,251,61) - 就是我们熟悉的欧几里得距离 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505115946653.png,276,63) - L1 范数 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505120034974.png,255,43) - 各分量绝对值之和,在正则化(防止过拟合)中常用。 - 余弦相似度 - 把点积和范数组合起来,就得到余弦相似度——LLM 里衡量语义相似性的核心工具 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505120438471.png,383,76) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/05/20260505120456734.png,389,164) - 余弦相似度只关心方向,不关心长度 - 两个词的向量可能长度不同,但如果方向相同,说明它们在语义上是一致的 - 向量空间 - 满足以下条件的集合叫向量空间 - 元素(向量)之间可以相加,可以被标量乘 - 在这个集合里做加法和乘法,结果还在这个集合里 - 最关键的概念是维度(Dimension):向量空间需要多少个基向量来描述其中所有的点 - GPT-2 用 768 维的向量空间来表示词义,GPT-3 用 12288 维。维度越高,理论上能编码的信息越丰富 - Numpy 代码 - ```python import numpy as np # ─── 1. 创建向量 ─────────────────────────────────────── a = np.array([1, 2, 3], dtype=float) b = np.array([4, -1, 2], dtype=float) print("向量 a:", a) print("向量 b:", b) # ─── 2. 向量加法与标量乘法 ───────────────────────────── print("\n向量加法 a + b:", a + b) print("标量乘法 2 * a:", 2 * a) # ─── 3. 点积 ────────────────────────────────────────── dot_product = np.dot(a, b) print("\n点积 a · b:", dot_product) # 手动验证 manual_dot = sum(a[i] * b[i] for i in range(len(a))) print("手动计算点积:", manual_dot) # ─── 4. L2 范数 ──────────────────────────────────────── norm_a = np.linalg.norm(a) norm_b = np.linalg.norm(b) print("\n‖a‖₂ =", norm_a) print("‖b‖₂ =", norm_b) # ─── 5. 余弦相似度 ───────────────────────────────────── cos_sim = dot_product / (norm_a * norm_b) print("\n余弦相似度(a, b):", cos_sim) # ─── 6. 模拟词嵌入:哪个词和"猫"最相似? ────────────── # 用随机向量模拟(实际中是模型学习出来的) np.random.seed(42) embedding_dim = 8 # 简化为 8 维演示 word_vectors = { "猫": np.random.randn(embedding_dim), "狗": np.random.randn(embedding_dim), "汽车": np.random.randn(embedding_dim), "小猫": np.random.randn(embedding_dim), } # 手动让"猫"和"小猫"更相似 word_vectors["小猫"] = word_vectors["猫"] + np.random.randn(embedding_dim) * 0.3 def cosine_similarity(v1, v2): return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) query = word_vectors["猫"] print("\n与"猫"的余弦相似度:") for word, vec in word_vectors.items(): if word != "猫": sim = cosine_similarity(query, vec) print(f" {word}: {sim:.4f}") ``` 矩阵基本运算 - 矩阵基本运算 - 矩阵是什么 - 是数字排列成的二维表格,用行数 x 列数描述它的形状(称为"维度"或"shape") - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/07/20260507220522540.png,142,68) - 这是一个 2×3 矩阵(2 行 3 列)。用 $A_{ij}$​ 表示第 i 行第 j 列的元素,比如 $A_{12} = 2$ - 矩阵的本质是线性变换。 - 描述的是"把一个向量变成另一个向量"的规则。 - 矩阵与向量的乘法 - 计算规则 - 矩阵 $A(m \times n)$ 乘以向量 $\vec{x}$(n 维),结果是一个 m 维向量 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/07/20260507221328789.png,353,92) - 结果的每一行,就是矩阵那一行与向量做点积。 - 具体例子 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/07/20260507221813500.png,388,92) - 一个 3×2 的矩阵,把一个 2 维向量变成了 3 维向量。 - 维度变了——这正是"变换"的含义。 - 几何直觉 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/07/20260507221947557.png,126,66) - 该矩阵可以把任意 2D 向量逆时针旋转 90 度 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/07/20260507222314744.png,114,66) - 原来朝右的向量,变成了朝上的向量。矩阵就是这样编码变换规则的。 - 矩阵 A 乘以向量 $\vec{x}$ 等于把 $\vec{x}$ 所在的空间拉伸、旋转、投影到另一个空间。 - LLM 中的应用 - Transformer 里,每个 token 的向量 $\vec{x}$ 乘以权重矩阵 $W_Q$​,得到 Query 向量 - $\vec{q} = W_Q\vec{x}$ - 这就是一次线性变换——把输入向量投影到"Query 空间" - 矩阵乘法 - 计算规则 - 两个矩阵相乘:$A(m\times k) \times B (k\times n) = C(m\times n)$ - 关键约束 - A 的列数必须等于 B 的行数(都是 k)。 - 结果矩阵 C 的第 i 行第 j 列 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/07/20260507224234717.png,165,72) - 即 A 的第 i 行与 B 的第 j 列做点积。 - 具体例子 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/07/20260507224403814.png,121,212) - 矩阵乘法的本质:变换的复合 - 单独看计算规则很枯燥。真正重要的是它的意义 - 矩阵 AB 表示"先做变换 B,再做变换 A"。 - 就像函数复合 $f(g(x))$,矩阵乘法是在组合两个线性变换。 - 神经网络的多层结构,本质上就是多个矩阵变换串联在一起,每一层都对数据做一次变换。 - 矩阵乘法的重要性质 - 不满足交换律 - $AB \neq BA$(大多数情况下) - 先旋转再缩放,和先缩放再旋转,结果不同——顺序很重要。 - 满足结合律 - $(AB)C=A(BC)$ - 这让我们可以灵活选择计算顺序(影响效率,不影响结果) - 转置 - 把矩阵的行和列互换,记作 $A^T$ - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509003910492.png,283,81) - $A_{ij}^T=A_{ji}$ - 转置的重要性质 - $(AB)^T=B^TA^T$ - $(\vec{a}^T\vec{b}) = \vec{a}\cdot \vec{b}$ - 转置把向量点积统一进了矩阵符号 - LLM 的直接应用 - Attention 公式里的 $QK^T$ 就是对 K 做转置后再和 Q 相乘,目的是让每个 Query 向量和每个 Key 向量都算一次点积,得到注意力分数矩阵。 - 逆矩阵 - 对于方阵(行数=列数) A,如果存在矩阵 $A^{-1}$ 使得 $AA^{-1}=A{-1}A=I$ - 其中 I 是单位矩阵,对角线全是 1,其余是 0,那么 $A^{-1}$ 叫做 A 的逆矩阵 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509004417750.png,115,77) - 单位矩阵就像数字里的 1,乘以任何矩阵都不改变它:$AI=IA=A$ - 几何直觉:A 把空间做了某种变换,$A^{-1}$ 就是吧这个变换撤销,还原回去 - 注意:不是所有方阵都有逆矩阵。如果矩阵把空间"压扁"了(比如把三维压成二维),就无法恢复,这样的矩阵不可逆 - 在 LLM 里,矩阵是怎么用的 - Transformer 输入是一个序列,比如 4 个 token,每个 token 用 8 维向量表示,整体表示成矩阵 X,形状(4x8) - 要生成 Query、Key、Value,分别乘以三个权重矩阵 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509004625263.png,279,31) - 这三个矩阵乘法,就是在把输入向量投影到三个不同的子空间,分别用于"我在找什么"(Q)、"我有什么"(K)、"我的内容是什么"(V)。 - 注意力分数:$score=QK^T$ - Q 是 $4\times d_k$,$K^T$ 是 $d_k\times 4$,结果是 $4\times 4$ 的矩阵 - 结果是每个 token 和其他每个 token 的相关程度 - 整条公式 $QK^T$ 的每一个元素,就是一对 Query 和 Key 向量的点积 - Numpy 实战 - ```python import numpy as np # ─── 1. 矩阵与向量的乘法 ────────────────────────────── A = np.array([[1, 2], [3, 4], [5, 6]]) # 3×2 矩阵 x = np.array([1, -1]) # 2 维向量 result = A @ x # @ 是矩阵乘法运算符 print("A @ x =", result) # 应得 [-1, -1, -1] # ─── 2. 矩阵乘法 ────────────────────────────────────── B = np.array([[1, 2], [3, 4]]) C = np.array([[5, 6], [7, 8]]) print("\nB @ C =\n", B @ C) print("C @ B =\n", C @ B) print("交换律不成立:", np.allclose(B @ C, C @ B)) # False # ─── 3. 转置 ────────────────────────────────────────── M = np.array([[1, 2, 3], [4, 5, 6]]) print("\n原矩阵 shape:", M.shape) # (2, 3) print("转置后 shape:", M.T.shape) # (3, 2) print("转置:\n", M.T) # ─── 4. 模拟 Attention 里的 QK^T ────────────────────── np.random.seed(0) seq_len = 4 # 4 个 token d_model = 8 # 每个 token 8 维 d_k = 4 # Query/Key 的维度 # 输入序列矩阵:4 个 token,每个 8 维 X = np.random.randn(seq_len, d_model) # 权重矩阵(实际中是训练得到的) W_Q = np.random.randn(d_model, d_k) W_K = np.random.randn(d_model, d_k) # 计算 Q 和 K Q = X @ W_Q # shape: (4, 4) K = X @ W_K # shape: (4, 4) # 计算注意力分数矩阵 scores = Q @ K.T # shape: (4, 4) print("\nQ shape:", Q.shape) print("K^T shape:", K.T.shape) print("注意力分数矩阵 shape:", scores.shape) print("注意力分数矩阵:\n", scores.round(2)) # 每个元素 scores[i][j] 就是第 i 个 token 对第 j 个 token 的注意力分数 print("\ntoken 0 对所有 token 的注意力分数:", scores[0].round(2)) ``` 线性变换的几何直觉 - 线性变换的几何直觉 - 核心洞察:矩阵的列=基向量的去向 - 什么是基向量 - 二维平面最自然的一组基向量 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509005936990.png,141,61) - 任何 2D 向量都可以写成基向量的组合 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509005955004.png,108,54) - 矩阵的读法 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509010125456.png,111,61) - $\hat{i}$ 经过 A 变换后,落在 $\begin{bmatrix} 2 \\ 1 \end{bmatrix}$ - $\hat{j}$ 经过 A 变换后,落在 $\begin{bmatrix} -1 \\ 3 \end{bmatrix}$ - 线性变换有一个神奇的性质:它保留了向量加法和数乘。 - 只要你知道基向量被变到哪里,整个空间所有向量的去向就都确定了 - 这就是矩阵能用一组数字描述整个变换的原因 - 矩阵×向量,本质是用向量的分量作为系数,对矩阵的列做线性组合 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509010714977.png,244,59) - 几种常见的线性变换 - 缩放 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509152812843.png,61,60) - $\hat{i}$ 拉到 [2,0], $\hat{j}$ 拉到 [0,2],整个空间被均匀放大 2 倍 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509152901112.png,57,59) - 不均匀缩放,横向拉伸 3 倍,纵向不变。 - 旋转 - 逆时针旋转角度 $\theta$ - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509153055957.png,164,55) - 错切 (Shear) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509153129655.png,59,55) - $\hat{i}$ 不动,$\hat{j}$ 从 [0,1] 移动到 [1,1]。整个空间像被协推的扑克牌 - 投影 (Projection) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509153234697.png,58,60) - $\hat{i}$ 不动,$\hat{j}$ 压扁到原点,整个 2D 平面被投影到 $x$ 轴,降了一维 - 这个变换是不可逆的,一旦把平面压扁成一条线,无法恢复 - 反射 (Reflection) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509153344443.png,74,57) - $\hat{i}$ 翻到 [-1,0],整个空间沿 y 轴镜像翻转 - 行列式:变换的缩放因子 - 行列式 $det(A)$ 是一个数 - 这个变换把单位面积或体积放大缩小了多少倍 - 几何意义 - $\hat{i}$ 和 $\hat{j}$ 围成的单位正方形,面积是 1 - 变换后,这两个向量会围成一个平行四边形,这个平行四边形的面积就是 $|det(A)|$ - 二阶行列式公式 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509153655269.png,164,60) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509153803344.png,408,167) - det(A) = 0 -> 矩阵不可逆 -> 变换降低了维度 - 秩 (Rank):变换后的输出维度 - 秩 (rank(A)) 是另外一个数 - 回答:变换后,所有向量落到的空间有几维 - 直观理解 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509154254876.png,93,55) - [2,4] = 2x[1,2],两列共线,无论 $\hat{i}$ 和 $\hat{j}$ 落到哪里,它们都在一条直线上 - 整个 2d 平面被压成了一条 1D 直线,rank(A) = 1 - 如果两列线性无关(不共线),秩为 2,变换后仍然是 2D 平面,满秩 - 满秩 vs 不满秩 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509155152785.png,531,126) - 秩的另一种表述 - rank(A) = A 的列向量中线性无关的最大个数 - 也等于行向量中线性无关的最大个数,行秩=列秩 - 线性相关与无关 - 一组向量是线性相关的,意味着其中某个向量可以由其他向量的线性组合得到 - n 个线性无关的向量,才能"撑起"一个 n 维空间 - 如果你有 100 个向量,但它们都共线(线性相关),实际上只描述了一条 1D 直线 - 连接到 LLM - 为什么 LLM 用高维向量 - GPT-3 用 12288 维的词向量 - 维度越高,能容纳的"线性无关方向"越多,理论上能编码越丰富的语义特征 - 情感、词性、领域、时态、语气……每个特征可以是一个独立的方向。 - 低秩的危险 - 如果训练出的某个权重矩阵秩很低,意味着它实际上只在少数几个方向上有效,浪费了表达能力 - 这是"模型坍缩"的一种数学表现 - LoRA 的精妙之处 - LoRA(Low-Rank Adaptation)微调的核心思想:微调时新增的参数变化矩阵 $\delta W$ 通常是低秩的 - 如果 $\delta W$ 是 1024x1024 的矩阵,参数量是约 100 万。但如果它的秩只有 8,可以分解成:$\delta W = A\cdot B$ - 其中 A 是 1024 x 8,B 是 8 x 1024,参数量降到约 1.6 万 - Attention 的几何意义 - $Q=XW_Q$,这是在用矩阵 $W_Q$ 把输入向量变换到 Query 空间 - $W_Q$ 的秩决定了 Query 空间的自由度—能从输入里提取多少个独立的"问题"。 - 可视化代码 - ```python import numpy as np import matplotlib.pyplot as plt def plot_transformation(matrix, title): """可视化一个 2x2 矩阵的变换效果""" # 原始基向量 i_hat = np.array([1, 0]) j_hat = np.array([0, 1]) # 变换后 i_new = matrix @ i_hat j_new = matrix @ j_hat # 画一个网格点 x = np.linspace(-2, 2, 9) y = np.linspace(-2, 2, 9) X, Y = np.meshgrid(x, y) points = np.stack([X.ravel(), Y.ravel()]) transformed = matrix @ points fig, axes = plt.subplots(1, 2, figsize=(10, 5)) # 原空间 axes[0].scatter(points[0], points[1], c='lightblue', s=10) axes[0].arrow(0, 0, *i_hat, head_width=0.1, color='red', label='i_hat') axes[0].arrow(0, 0, *j_hat, head_width=0.1, color='green', label='j_hat') axes[0].set_title("变换前") axes[0].set_xlim(-3, 3); axes[0].set_ylim(-3, 3) axes[0].grid(True); axes[0].axhline(0); axes[0].axvline(0) axes[0].set_aspect('equal') # 变换后空间 axes[1].scatter(transformed[0], transformed[1], c='lightcoral', s=10) axes[1].arrow(0, 0, *i_new, head_width=0.1, color='red') axes[1].arrow(0, 0, *j_new, head_width=0.1, color='green') axes[1].set_title(f"变换后\ndet={np.linalg.det(matrix):.2f}, " f"rank={np.linalg.matrix_rank(matrix)}") axes[1].set_xlim(-5, 5); axes[1].set_ylim(-5, 5) axes[1].grid(True); axes[1].axhline(0); axes[1].axvline(0) axes[1].set_aspect('equal') plt.suptitle(title); plt.tight_layout(); plt.show() # ─── 试试不同的变换 ─────────────────────────────── # 1. 缩放 plot_transformation(np.array([[2, 0], [0, 1.5]]), "缩放:x 方向 2 倍,y 方向 1.5 倍") # 2. 旋转 45 度 theta = np.pi / 4 plot_transformation( np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]), "旋转 45 度" ) # 3. 错切 plot_transformation(np.array([[1, 1], [0, 1]]), "错切") # 4. 投影到 x 轴(注意 det=0, rank=1) plot_transformation(np.array([[1, 0], [0, 0]]), "投影到 x 轴(降维!)") # 5. 反射 plot_transformation(np.array([[-1, 0], [0, 1]]), "沿 y 轴反射") ``` 特征值与特征向量 - 特征值与特征向量 - 变换中不变的方向 - 一个矩阵作用于空间,所有向量都被旋转、拉伸、扭曲。绝大多数向量变换后,方向都改变了 - 但有些特殊的向量——它们经过变换后,方向不变,只是被拉长或缩短 - 这种"在变换中保持方向"的向量,就叫做特征向量(eigenvector)。它被拉伸的倍数,叫做特征值(eigenvalue) - 形式化定义 - 特征值方程 - $A\vec{v}=\lambda\vec{v}$ - 矩阵 A 作用在向量 $\vec{v}$ 上,结果等于把 $\vec{v}$ 缩放 $\lamda$ 倍 - $\lamda$ 是特征值(一个标量,可以是正、负、零,甚至复数) - 几何含义 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509211904890.png,438,253) - 关键洞察:特征向量给出的是矩阵变换的"主轴"。沿着这些主轴,复杂的矩阵变换简化成了简单的"拉伸" - 怎么求特征值 - 推导 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509212329174.png,460,246) - 行列式为 0 意味着矩阵把空间压扁了,存在一些非零向量被压成了零向量——那些被压扁的方向就是特征方向 - 实际计算 - 求特征值 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509213017060.png,589,239) - 求特征向量 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509213047640.png,502,350) - 特征分解 - 如果 $n\times n$ 矩阵 A 有 n 个线性无关的特征向量,可以写成 $A=PDP^{-1}$ - 公式解析 - $P$ 是把特征向量按列排起来的矩阵 - $D$ 是对角矩阵,对角线上是对应的特征值 - $P^{-1}$ 是 $P$ 的逆矩阵 - 几何含义 - 任何复杂的线性变换 A,都可以分解成三步简单操作: - $P^{-1}$: 把空间转一下,让特征方向对齐到坐标轴 - $D$: 沿坐标轴各方向独立缩放(因为 D 是对角矩阵) - $P$: 把空间转回来 - 整个复杂变换,本质上就是沿着特征方向的简单缩放 - 矩阵的 n 次幂 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/09/20260509213619982.png,250,40) - 计算量从 100 次矩阵乘法降到 n 次标量幂运算 - 这是 PageRank、马尔可夫链稳态分析的核心计算技巧 - PCA:特征值的经典应用 - PCA(主成分分析,Principal Component Analysis)是降维最常用的算法。它的数学本质就是特征分解 - 问题背景 - 假设你有 1000 个数据点,每个是 768 维向量 - 可视化、分析都很困难 - 能不能找到 2 个最重要的"方向",把数据投影到这两个方向上,保留尽可能多的信息 - PCA 的答案:找数据协方差矩阵的特征向量,按特征值大小排序,取前 k 个 - 为什么有效 - 协方差矩阵 C 描述了数据在各维度的"散布情况" - 它的特征向量给出了数据散布最大的方向——这些就是"主成分" - 特征值越大,说明数据沿那个方向变化越剧烈,包含的信息越多 - 如果你的 1000 个数据点几乎都集中在一条直线附近 - 那条直线就是第一主成分(最大特征值对应的特征向量) - 用它一个维度就能描述大部分信息 - PCA 的步骤 - 把数据矩阵 X 中心化:每一列减去该列均值 - 计算协方差矩阵:$C=\frac 1{n-1}X^TX$ - 对 C 做特征分解:$C=PDP^{-1}$ - 选 D 中最大的 k 个特征值对应的特征向量,组成投影矩阵 W - 降维后的数据: $X_{new}=XW$ - 连接到 LLM - 词嵌入的可视化 - GPT 的 768 维词向量没法直接看。 - 用 PCA 把它降到 2D 或 3D,就能在散点图上观察 - "国王""女王""公主"这些词是否聚在一起? - 这是分析模型语义的常用手段。 - 模型权重分析 - Transformer 的注意力矩阵分析中,研究者会看注意力权重矩阵的特征值分布 - 如果特征值过于集中(少数几个特征值很大,其他都很小) - 说明模型在过度关注某些方向,可能存在退化现象。 - RoPE 位置编码的内在结构 - 旋转位置编码(RoPE)本质上是用一组旋转矩阵作用在 Query 和 Key 上 - 这些旋转矩阵的特征值(在复数域)正是 $e^{i\theta}$,对应频率的位置信息 - 模型压缩的根基 - 特征分解的更一般形式 SVD是各种模型压缩、剪枝、低秩近似的数学基础 - Numpy 代码实现 - ```python import numpy as np import matplotlib.pyplot as plt # ─── 1. 求特征值和特征向量 ──────────────────────────── A = np.array([[3, 1], [0, 2]], dtype=float) eigenvalues, eigenvectors = np.linalg.eig(A) print("特征值:", eigenvalues) # [3., 2.] print("特征向量(按列):\n", eigenvectors) # 验证 A v = λ v v0 = eigenvectors[:, 0] # 第一个特征向量 λ0 = eigenvalues[0] print("\nA @ v0 =", A @ v0) print("λ0 * v0 =", λ0 * v0) print("两者相等:", np.allclose(A @ v0, λ0 * v0)) # ─── 2. 验证特征分解 A = P D P^(-1) ──────────────── P = eigenvectors D = np.diag(eigenvalues) A_reconstructed = P @ D @ np.linalg.inv(P) print("\n重构 A:\n", A_reconstructed) print("与原 A 相等:", np.allclose(A, A_reconstructed)) # ─── 3. 用特征分解快速算 A^10 ────────────────────── A10_naive = np.linalg.matrix_power(A, 10) A10_eigen = P @ np.diag(eigenvalues**10) @ np.linalg.inv(P) print("\n两种方法算 A^10 是否一致:", np.allclose(A10_naive, A10_eigen)) # ─── 4. 手写 PCA 并可视化 ─────────────────────────── np.random.seed(42) # 生成一些 2D 数据:长椭圆形分布 n = 200 mean = [0, 0] cov = [[3, 2], [2, 2]] data = np.random.multivariate_normal(mean, cov, n) # Step 1: 中心化 data_centered = data - data.mean(axis=0) # Step 2: 协方差矩阵 C = (data_centered.T @ data_centered) / (n - 1) # Step 3: 特征分解 eigvals, eigvecs = np.linalg.eigh(C) # eigh 用于对称矩阵,更稳定 # Step 4: 按特征值从大到小排序 idx = np.argsort(eigvals)[::-1] eigvals = eigvals[idx] eigvecs = eigvecs[:, idx] print("\n协方差矩阵的特征值:", eigvals) print("第一主成分(最大特征值方向):", eigvecs[:, 0]) print("第二主成分:", eigvecs[:, 1]) # 可视化:原数据 + 两个主成分方向 plt.figure(figsize=(8, 8)) plt.scatter(data[:, 0], data[:, 1], alpha=0.5, label='数据') # 画两个主成分方向(用特征值缩放表示重要程度) origin = data.mean(axis=0) for i, (val, vec) in enumerate(zip(eigvals, eigvecs.T)): plt.arrow(*origin, *(vec * np.sqrt(val) * 2), head_width=0.15, color=['red', 'green'][i], label=f'PC{i+1} (λ={val:.2f})', linewidth=2) plt.axis('equal') plt.grid(True) plt.legend() plt.title("PCA:特征向量指出数据散布最大的方向") plt.show() # ─── 5. 降维:把 2D 数据投影到 1D ───────────────── # 只用第一主成分 W = eigvecs[:, 0:1] # shape (2, 1) data_1d = data_centered @ W print(f"\n降维前 shape: {data.shape}") print(f"降维后 shape: {data_1d.shape}") # 计算保留的"信息比例" info_kept = eigvals[0] / eigvals.sum() print(f"用 1 维保留了 {info_kept:.1%} 的信息") ``` SVD (奇异值分解) - SVD (奇异值分解) - 为什么需要 SVD - 特征分解 $A = PDP^{-1}$ 有两个限制 - 只能用于方阵(行数=列数) - 不是所有的方阵都能分解(要求有 n 个线性无关的特征向量) - 任何 $m\times n$ 的矩阵都能做 SVD 分解,没有任何限制 - SVD 公式 - 对任意矩阵 $A$ ($m\times n$) $A=U\Sigma V^T$ - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/10/20260510154340941.png,429,164) - 奇异值通常按从大到小排列:$\sigma_1 \ge \sigma_2 \ge ... \ge \sigma _r \ge 0$, r 是矩阵的秩 - 几何解释 - 矩阵代表线性变换。SVD 说的是——任何线性变换,都可以分解成"旋转 → 缩放 → 旋转"三步 - 把向量 $\vec{x}$ 应用矩阵 $A$: $A\vec{x} = U\Sigma V^T\vec{x}$ - $V^T\vec{x}$: 对 $\vec{x}$ 做 一次旋转/反射 - $\Sigma(\cdot)$: 沿坐标轴各方向独立缩放(缩放倍数=奇异值) - $U(\cdot)$: 再做一次旋转/反射 - 与特征分解的对比 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/10/20260510154940144.png,461,207) - 奇异值的含义 - 衡量矩阵的重要方向 - 最大的奇异值 $\sigma_1$ 对应矩阵最"主要"的变换方向;后面的奇异值一次描述次要方向。 - 决定矩阵的秩 - 非零奇异值的个数 = 矩阵的秩 - 衡量矩阵的"信息含量" - 如果一个矩阵的奇异值快速衰减,说明矩阵的信息几乎全在前两个方向上,后面的方向可以安全丢弃 - SVD 与 LoRA - 问题背景 - 微调一个 7B 参数的 LLM,要更新所有参数代价极大 - LoRA 的观察:微调时的参数变化 $\Delta W$ 通常是低秩的——也就是说,虽然 $\Delta W$ 是个大矩阵,它真正有效的"主方向"很少 - LoRA 的做法 - 不直接学习 $\Delta W$,而是学习两个小矩阵 A 和 B $\Delta W \approx BA$ - 如果 $\Delta W$ 是 4096x4096,参数量是 1700 万 - 如果用 LoRA 设秩为 8,则 A 是 8x4096,B 是 4096x8,参数量约 6.5 万,减少 250 倍 - SVD 在 LLM 中的其他应用 - 词嵌入分析 - 对一个 $50000\times 768$ 的词嵌入矩阵做 SVD,看奇异值衰减情况,可以判断词嵌入空间的有效维度 - Attention 矩阵分析 - 研究 Transformer 注意力权重矩阵的奇异值分布,可以诊断模型是否出现"注意力坍缩"——所有注意力都集中在少数几个 token 上 - 模型压缩 - 把训练好的权重矩阵做 SVD,丢弃小奇异值,用低秩矩阵替代,可以压缩模型大小 - 推荐系统的根基 - 经典的协同过滤推荐算法,本质就是对"用户-物品评分矩阵"做 SVD 低秩近似 - NumPy 代码实战 - ```python import numpy as np import matplotlib.pyplot as plt # ─── 1. 基本 SVD 分解 ───────────────────────────────── np.random.seed(42) A = np.random.randn(5, 3) # 一个 5x3 的非方阵 U, sigma, VT = np.linalg.svd(A, full_matrices=False) print("A shape:", A.shape) print("U shape:", U.shape) # (5, 3) print("奇异值:", sigma) # 长度为 3 的数组 print("V^T shape:", VT.shape) # (3, 3) # 验证 A = U Σ V^T A_reconstructed = U @ np.diag(sigma) @ VT print("\n重构误差:", np.linalg.norm(A - A_reconstructed)) # ─── 2. 验证 U 和 V 是正交矩阵 ──────────────────────── print("\nU^T @ U =\n", (U.T @ U).round(4)) # 应近似为单位矩阵 print("\nV @ V^T =\n", (VT @ VT.T).round(4)) # 应近似为单位矩阵 # ─── 3. 低秩近似:用 SVD 压缩图像 ───────────────────── # 创建一个简单的"图像"(一个矩阵) np.random.seed(0) image = np.zeros((50, 50)) # 加几个明显的结构 image[10:20, 10:40] = 1 image[25:35, 15:35] = 0.7 image[40:48, 5:45] = 0.5 image += np.random.randn(50, 50) * 0.1 # 对图像做 SVD U, sigma, VT = np.linalg.svd(image) # 用不同 k 值重构 fig, axes = plt.subplots(1, 5, figsize=(15, 3)) axes[0].imshow(image, cmap='gray') axes[0].set_title(f"原图\n所有 50 个奇异值") axes[0].axis('off') for i, k in enumerate([1, 3, 10, 30]): image_k = U[:, :k] @ np.diag(sigma[:k]) @ VT[:k, :] axes[i+1].imshow(image_k, cmap='gray') info_kept = (sigma[:k]**2).sum() / (sigma**2).sum() axes[i+1].set_title(f"k={k}\n信息保留 {info_kept:.1%}") axes[i+1].axis('off') plt.suptitle("SVD 低秩近似:用前 k 个奇异值还原矩阵") plt.tight_layout() plt.show() # ─── 4. 奇异值衰减图 ────────────────────────────────── plt.figure(figsize=(8, 4)) plt.plot(sigma, 'o-') plt.yscale('log') plt.xlabel('奇异值序号') plt.ylabel('奇异值大小(对数尺度)') plt.title('奇异值衰减情况') plt.grid(True) plt.show() # ─── 5. 模拟 LoRA:用低秩分解近似一个权重矩阵 ─────── # 假设原始权重矩阵 W = np.random.randn(1024, 1024) * 0.01 # 假设这是"微调后的变化"——故意构造成低秩的 np.random.seed(1) B_true = np.random.randn(1024, 8) A_true = np.random.randn(8, 1024) delta_W_true = B_true @ A_true # 真实秩为 8 的变化 # 用 SVD 提取最佳秩-8 近似 U, sigma, VT = np.linalg.svd(delta_W_true) k = 8 delta_W_approx = U[:, :k] @ np.diag(sigma[:k]) @ VT[:k, :] print("\n--- LoRA 模拟 ---") print(f"原始 ΔW 参数量: {1024 * 1024:,}") print(f"LoRA 形式 (B + A) 参数量: {1024 * k + k * 1024:,}") print(f"压缩比: {1024 * 1024 / (1024 * k + k * 1024):.1f}x") print(f"近似误差: {np.linalg.norm(delta_W_true - delta_W_approx):.6f}") ``` - SVD vs 特征分解 vs PCA 总结 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/10/20260510161623378.png,546,158) - 有趣的关系 - 对对称正定矩阵,特征分解和 SVD 等价 - PCA 可以通过对中心化数据矩阵 X 直接做 SVD 实现,比先算协方差矩阵更稳定

五月 5, 2026

AIInfra 学习

GPU 体系结构 + CUDA 入门 + Profiling AIInfra 全景 + 学习内容 - AIInfra 全景 & 学习内容 - 理论部分 - AIInfra 是什么 - AI Infra 不是一个单一的东西,而是一整套支撑「让模型在合适的硬件上、以合适的成本、稳定地训练和服务用户」的技术栈 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/10/20260510163128065.png,726,300) - 一条 prompt 的完整生命周期 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/10/Clipboard_Screenshot_1778404995.png,500,400) - 训练 vs 推理的关键差异 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/05/10/20260510180216416.png,655,286)

五月 5, 2026

福建旅游规划

福建旅游规划 第一站:泉州 (5 月 2 号) 关键地点: 15:13 到泉州 【两大雷点,差评较多,不去或者快速过】 西街和浔埔村,确实商业化过重,但西街是去开元寺必经之路,不想逛就快速走过好了,浔埔村不簪花真的没必要去,而且在古城里簪花可以取景的地方更多人更少 ...

四月 30, 2026

Go 复习

基础 Go 环境搭建与工具链 - Go 环境搭建与工具链 - 安装 Go - 官网直接安装 - ```bash # macOS(用 Homebrew) brew install go # Linux(手动安装) wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin # Windows # 直接下载 .msi 安装包:https://go.dev/dl/ # 验证安装 go version # 输出:go version go1.23.0 darwin/arm64 ``` - goenv 环境管理 - ```bash # Linux(手动安装) wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin # Windows # 直接下载 .msi 安装包:https://go.dev/dl/ # 验证安装 go version # 输出:go version go1.23.0 darwin/arm64 ``` - 理解环境变量 - 常用环境变量 - ```bash # 查看所有 Go 环境变量 go env # 最重要的几个: go env GOROOT # Go 的安装路径(一般不需要改) go env GOPATH # 工作目录,默认 ~/go go env GOMODULE # 模块模式(应该是 "on") ``` - GOPATH vs Go Modules - 早期 Go 用 GOPATH 管理所有的代码,所有项目必须放在 $GOPATH/src 下 - Go 1.11 引入了 Go Modules,可以在任意目录创建项目 - 现在 Go Modules 是默认模式,不需要关注 GOPATH 了 - 只需要确认 GO111MODULE=on - 设置常用模块 - ```bash # 确保模块模式开启 go env -w GO111MODULE=on # 设置国内代理(如果你在中国大陆) go env -w GOPROXY=https://goproxy.cn,direct ``` - VsCode 插件 - Go 官方插件 - Go Test Exploerer 可视化运行测试 - Error Lens 行内显示错误信息 - 核心命令 - go mod init <模块名> 初始化一个新模块 - go run main.go 编译并直接运行(不生成二进制) - go build 编译生成可执行文件 - go fmt ./... 格式化代码 - go test ./... 运行所有测试 - go get <包名> 添加依赖到当前模块 - go mod tidy 清理未使用的依赖 - go vet ./... 静态分析,检查常用错误 - go doc <包名> 类型系统与变量 - 类型系统与变量 - 基本类型总览 - Go 是强类型语言,每个类型都有明确的类型 - 整数 - int/uint 平台相关(32 位或 64 位) 最常用 - int8/int16/int32/int64 指定位数的有符号整数 - uint8(byte)/uint16/uint32/uint64 无符号整数 - 浮点数 - float32 单进度浮点数 (7 位有效数字) - float64 双进度浮点数 (15 位有效数字) - 其他 - bool true/false - string UTF-8 编码的不可变字节序列 - byte uint8 的别名,表示一个字节 - rune int32 的别名,表示一个 Unicode 码点 - byte vs rune - byte 是一个字节,用于处理原始字节数据 - rune 是一个 Unicode 码点,用于处理字符 - 一个中文字符占 3 个 byte,但只是一个 1 rune - 遍历字符串,for range 遍历的是 rune,for i 遍历的是 byte - ```go s := "Hello你好" // byte 视角:看到的是字节 fmt.Println(len(s)) // 11(5个ASCII + 6个中文字节) // rune 视角:看到的是字符 fmt.Println(len([]rune(s))) // 7(5个英文字符 + 2个中文字符) // for range 按 rune 遍历 for i, ch := range s { fmt.Printf("索引=%d 字符=%c\n", i, ch) } // 索引=0 字符=H // 索引=1 字符=e // ... // 索引=5 字符=你 ← 注意索引跳了 // 索引=8 字符=好 ``` - 变量声明的四种方式 - 核心原则:函数内用 :=,函数外用 var - ```go // ① var + 类型(最完整的写法) var name string = "Gopher" // ② var + 类型推断(省略类型) var age = 25 // Go 推断为 int // ③ 短变量声明(最常用,只能在函数内) city := "Tokyo" // 等价于 var city = "Tokyo" // ④ 批量声明(常用于包级别变量) var ( host = "localhost" port = 8080 debug = false ) ``` - 选用参考 - 函数内:几乎总是使用 := 短声明,简洁明了 - 函数外:只能用 var,因为 := 只能在函数内使用 - 需要显式指定类型时,用 var name Type - 声明但是暂不赋值,用var name Type - 常见陷阱 - 短声明的遮蔽 - ```go x := 1 fmt.Println(x) // 1 if true { x := 2 // 注意:这是一个新的 x,遮蔽了外层的 x! fmt.Println(x) // 2 } fmt.Println(x) // 1 ← 外层的 x 没有变! // 正确做法:用 = 而不是 := if true { x = 2 // 修改的是外层的 x } fmt.Println(x) // 2 ``` - 零值机制 - Go 没有 undefined、null、None 的概念 - 每个类型都有一个确定的零值,声明变量时不赋值就是零值 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418230943782.png,200,180) - ```go // 零值是安全的,可以直接使用 var count int // 0,可以直接 count++ var name string // "",可以直接 name += "Go" var ok bool // false // 但 nil 类型需要初始化后才能使用! var m map[string]int // nil,直接 m["key"] = 1 会 panic! m = make(map[string]int) // 先 make 初始化 var s []int // nil,但 append 是安全的 s = append(s, 1) // ✅ nil 切片可以直接 append ``` - 常量与 iota 枚举 - Go 用 const 定义常量 - Go 没有 enum 关键字,但是提供了 iota 自增器 - 基本常量 ```go const Pi = 3.14159 const AppName = "MyApp" // 批量声明 const ( StatusOK = 200 StatusNotFound = 404 StatusError = 500 ) ``` - iota Go 的枚举利器 ```Go // iota 在 const 块中从 0 开始,每行自增 1 type Weekday int const ( Sunday Weekday = iota // 0 Monday // 1(自动继承 iota 表达式) Tuesday // 2 Wednesday // 3 Thursday // 4 Friday // 5 Saturday // 6 ) ``` - iota 高级用法 ```Go // 用法一:跳过某个值 type Color int const ( _ Color = iota // 0,用 _ 跳过 Red // 1 Green // 2 Blue // 3 ) // 用法二:位掩码(权限系统常用) type Permission uint const ( Read Permission = 1 << iota // 1 (001) Write // 2 (010) Execute // 4 (100) ) // 组合权限 userPerm := Read | Write // 3 (011) fmt.Println(userPerm & Read != 0) // true,有读权限 fmt.Println(userPerm & Execute != 0) // false,无执行权限 // 用法三:文件大小单位 const ( _ = iota KB = 1 << (10 * iota) // 1 << 10 = 1024 MB // 1 << 20 GB // 1 << 30 TB // 1 << 40 ) ``` - iota 的本质 - 不是一个值,是 const 块的行索引计数器 - 每次遇到一个新的 const 就重置为 0 - 同一行的多个 iota 值相同 - 类型转换 - ```Go // ❌ 隐式转换在 Go 中不存在 var a int = 42 var b float64 = a // 编译错误! var c int64 = a // 编译错误!即使都是整数 // ✅ 必须显式转换 var b float64 = float64(a) // 42.0 var c int64 = int64(a) // 42 // 字符串 ↔ 数字(用 strconv 包) import "strconv" s := strconv.Itoa(42) // int → string: "42" n, err := strconv.Atoi("42") // string → int: 42 f, err := strconv.ParseFloat("3.14", 64) // string → float64 // 字符串 ↔ 字节切片 bytes := []byte("Hello") // string → []byte str := string(bytes) // []byte → string // ⚠️ 精度丢失要注意 big := int64(1<<62) small := int32(big) // 溢出!结果不可预测 ``` - fmt.Sprintf 万能转字符串 - 性能不如 strconv,高频调用优先用 strconv 流程控制 - 流程控制 - if/else 条件判断 - 条件不需要括号 - 允许在条件前加入一个初始化语句 - 基本语法 - ```Go score := 85 if score >= 90 { fmt.Println("优秀") } else if score >= 60 { fmt.Println("及格") } else { fmt.Println("不及格") } // 注意: // 1. 条件不需要括号 (score >= 90) 写成 score >= 90 // 2. 大括号必须有,即使只有一行 // 3. else 必须和右大括号同一行 ``` - 前置初始化语句 (GO 特色) - ```Go // 在条件中声明并使用变量 if err := doSomething(); err != nil { fmt.Println("出错了:", err) return } // err 的作用域仅限于 if-else 块内,块外不可见 // 对比:不用前置初始化的写法 err := doSomething() if err != nil { fmt.Println("出错了:", err) return } // 这里 err 仍在作用域内,可能造成后续变量名冲突 ``` - 前置初始化的好处 - 限制了变量的作用域 - 避免污染外部命名空间 - 在错误处理中常见 - for 循环 - Go 只有 for 一个循环关键字 - for 有多种形式,能覆盖其他语言的各种循环要求 - 经典三段式 - ```Go for i := 0; i < 10; i++ { fmt.Println(i) } ``` - 类 while 循环 (只有条件) - ```Go n := 10 for n > 0 { fmt.Println(n) n-- } ``` - 无限循环 - ```Go for { // 死循环,用 break 退出 if someCondition() { break } } // 常用于服务器主循环 for { conn, err := listener.Accept() if err != nil { continue } go handleConn(conn) } ``` - for-range 遍历 - ```Go // 遍历切片/数组 nums := []int{10, 20, 30} for i, v := range nums { fmt.Printf("索引=%d 值=%d\n", i, v) } // 只要索引 for i := range nums { fmt.Println(i) } // 只要值(用 _ 忽略索引) for _, v := range nums { fmt.Println(v) } // 遍历 map(顺序随机!) m := map[string]int{"a": 1, "b": 2} for k, v := range m { fmt.Printf("%s=%d\n", k, v) } // 遍历字符串(按 rune) for i, ch := range "Hello你好" { fmt.Printf("%d: %c\n", i, ch) } // 遍历 channel(Day 9 会讲) // for msg := range ch { ... } ``` - 循环复用坑 - 在 Go 1.22 之前,for-range 的循环变量是复用的,在 go routine 中捕获会出现问题 - ```Go // Go 1.22+ 这样写是安全的 for _, v := range []int{1, 2, 3} { go func() { fmt.Println(v) // 1.22 前可能全打印 3 }() } // Go 1.22 之前的兼容写法: for _, v := range []int{1, 2, 3} { v := v // 显式创建副本 go func() { fmt.Println(v) }() } ``` - switch 分支 - 支持默认 break、多值匹配、任意表达式 - 基本用法:默认不穿透 - ```go day := 3 switch day { case 1: fmt.Println("周一") case 2: fmt.Println("周二") case 3: fmt.Println("周三") // 默认自动 break,不会掉到 case 4 case 4, 5: // ✨ 多值匹配 fmt.Println("周四或周五") case 6, 7: fmt.Println("周末") default: fmt.Println("无效") } ``` - 无条件 switch (if-else 链的优雅写法) - ```go score := 85 // 省略 switch 后的条件,相当于 switch true switch { case score >= 90: fmt.Println("A") case score >= 80: fmt.Println("B") case score >= 60: fmt.Println("C") default: fmt.Println("F") } // 比 if-else 链更清晰 // 等价于: // if score >= 90 { ... } // else if score >= 80 { ... } // ... ``` - 前置初始化语句 - ```go switch os := runtime.GOOS; os { case "linux": fmt.Println("Linux") case "darwin": fmt.Println("macOS") case "windows": fmt.Println("Windows") default: fmt.Printf("未知系统: %s\n", os) } ``` - fallthrough:显式穿透 - ```go // 如果真的需要穿透到下一个 case,用 fallthrough switch n := 1; n { case 1: fmt.Println("一") fallthrough // 继续执行下一个 case case 2: fmt.Println("二") // 会被打印 case 3: fmt.Println("三") // 不会打印,fallthrough 只穿透一层 } // 输出:一、二 ``` - break、continue、goto - 基础 break 和 continue - ```go for i := 0; i < 10; i++ { if i == 3 { continue // 跳过本次迭代 } if i == 7 { break // 退出循环 } fmt.Println(i) } // 输出:0 1 2 4 5 6 ``` - 标签:跳出嵌套循环 - ```go // 问题:break 只能跳出一层循环 // 在嵌套循环中如何跳出外层? OuterLoop: for i := 0; i < 5; i++ { for j := 0; j < 5; j++ { if i*j > 6 { break OuterLoop // 直接跳出外层循环 } fmt.Printf("%d*%d ", i, j) } } // continue 也支持标签:continue OuterLoop 会回到外层循环的下一次迭代 ``` - goto 存在,但是不建议用 - defer 延迟执行 - defer 是 Go 的标志性特性,用来注册「函数退出前必须执行」的逻辑 - 它彻底解决了资源清理、错误恢复等场景 - 基本行为:函数返回前执行 - ```go func main() { fmt.Println("1. 开始") defer fmt.Println("3. 延迟执行") fmt.Println("2. 中间") } // 输出: // 1. 开始 // 2. 中间 // 3. 延迟执行 ← 在函数返回前执行 ``` - 典型用法:资源清理 - ```go // 文件操作 func readFile(name string) error { f, err := os.Open(name) if err != nil { return err } defer f.Close() // ✨ 立刻注册关闭,无论函数怎么退出都会执行 // 读取文件内容... // 即使这里 return 或 panic,f.Close() 都会被执行 return nil } // 锁 func updateCounter() { mu.Lock() defer mu.Unlock() // ✨ 保证解锁,不会死锁 counter++ // 即使后续代码 panic,锁也能释放 } ``` - 多个 defer: LIFO 栈顺序 - ```go func main() { defer fmt.Println("1") defer fmt.Println("2") defer fmt.Println("3") fmt.Println("main") } // 输出: // main // 3 ← 后进先出(栈) // 2 // 1 ``` - defer 语句参数 - defer 语句的参数在注册是就计算好,不是执行时 - ```go func main() { x := 10 defer fmt.Println("defer:", x) // x 的值 10 被立即捕获 x = 20 fmt.Println("main:", x) } // 输出: // main: 20 // defer: 10 ← 不是 20! // 如果想用最终值,用闭包: func main() { x := 10 defer func() { fmt.Println("defer:", x) // 闭包捕获变量引用 }() x = 20 } // 输出 defer: 20 ``` 函数 - 函数深入 - 函数基础 - Go 的函数定义和 C 系列语言不一样:关键字 func 在前,返回值类型在后 - ```go // 基本语法:func 函数名(参数列表) 返回值类型 { 函数体 } func add(a int, b int) int { return a + b } // 参数同类型可以合并声明 func add2(a, b int) int { return a + b } // 无返回值 func greet(name string) { fmt.Println("Hello,", name) } // 无参数也无返回值 func printVersion() { fmt.Println("v1.0.0") } ``` - 函数是一等公民 - Go 的函数可以赋值给变量、作为参数传递、作为返回值返回 - 这让 Go 支持函数式编程风格,比如高阶函数、回调、中间件模式等 - ```go // 函数赋值给变量 var operation func(int, int) int = add result := operation(1, 2) // 3 // 函数作为参数 func apply(nums []int, fn func(int) int) []int { result := make([]int, len(nums)) for i, n := range nums { result[i] = fn(n) } return result } doubled := apply([]int{1, 2, 3}, func(x int) int { return x * 2 }) // [2, 4, 6] ``` - 多返回值 - go 原生支持多返回值 - ```go // 返回商和余数 func divmod(a, b int) (int, int) { return a / b, a % b } q, r := divmod(10, 3) // q=3, r=1 // 只想要其中一个,用 _ 忽略 q, _ := divmod(10, 3) // 只要商 _, r = divmod(10, 3) // 只要余数 ``` - 几乎所有主流库都用这个特性返回 (result, error) - go 的惯用法:(result,error) - ```go // 几乎所有可能失败的操作都返回 (结果, error) func readFile(name string) (string, error) { data, err := os.ReadFile(name) if err != nil { return "", err // 失败时返回零值 + 错误 } return string(data), nil // 成功时返回结果 + nil } // 调用方的标准模式 content, err := readFile("hello.txt") if err != nil { log.Fatal(err) } fmt.Println(content) ``` - 命名返回值 - go 允许给返回值命名,在返回值多,复杂逻辑时有用 - ```go // 普通多返回值 func divide1(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("除数不能为零") } return a / b, nil } // 命名返回值版本 func divide2(a, b float64) (result float64, err error) { if b == 0 { err = errors.New("除数不能为零") return // 裸 return,自动返回 result=0, err=上面的值 } result = a / b return // 自动返回 result, err } ``` - 命名返回值 + defer - ```go // 在 defer 中修改返回值 func doWork() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("恢复自 panic: %v", r) // ✨ 这里能修改返回值 err! } }() // 可能 panic 的代码 panic("something bad") } result := doWork() // result 不是 nil,而是包装后的 error ``` - 什么时候用命名返回值 - 返回值很多且含义复杂时,命名可以当文档 - 需要在 defer 中修改返回值时(recover 模式) - 可变参数函数 - 用 ...T 表示可变参数,函数内部会收到一个 []T 类型的切片 - fmt.Println 就是典型的可变参数函数。 - 示例 - ```go // 求任意个数字的和 func sum(nums ...int) int { total := 0 for _, n := range nums { total += n } return total } sum() // 0(传递空切片) sum(1, 2, 3) // 6 sum(1, 2, 3, 4, 5) // 15 // 把切片展开传递,用 ... 展开 nums := []int{10, 20, 30} sum(nums...) // 60,注意是 nums... 而不是 nums ``` - 混合使用固定参数和可变参数 - ```go // 可变参数必须是最后一个参数 func greet(greeting string, names ...string) { for _, name := range names { fmt.Printf("%s, %s!\n", greeting, name) } } greet("Hello", "Alice", "Bob", "Charlie") // Hello, Alice! // Hello, Bob! // Hello, Charlie! ``` - 闭包与匿名函数 - 闭包是能访问外部作用域变量的函数 - Go 的闭包语法简洁,是实现迭代器、中间件、回调等模式的基础 - 匿名函数 - ```go // 定义后立即调用(IIFE) func() { fmt.Println("我是匿名函数") }() // 赋值给变量 add := func(a, b int) int { return a + b } fmt.Println(add(1, 2)) // 3 ``` - 闭包:捕获外部变量 - ```go // 计数器生成器 func makeCounter() func() int { count := 0 return func() int { count++ // ✨ 闭包捕获了外部的 count return count } } c1 := makeCounter() fmt.Println(c1()) // 1 fmt.Println(c1()) // 2 fmt.Println(c1()) // 3 // 每个闭包有自己独立的状态 c2 := makeCounter() fmt.Println(c2()) // 1(c2 的 count 是独立的) fmt.Println(c1()) // 4(c1 继续累加) ``` - 闭包实战:装饰器模式 - ```go // 为函数添加日志功能 func withLogging(name string, fn func(int) int) func(int) int { return func(x int) int { fmt.Printf("调用 %s(%d)\n", name, x) result := fn(x) fmt.Printf("%s(%d) = %d\n", name, x, result) return result } } double := func(x int) int { return x * 2 } loggedDouble := withLogging("double", double) loggedDouble(5) // 调用 double(5) // double(5) = 10 // 这是 HTTP 中间件的基础思想! ``` - 闭包的捕获规则 - 闭包捕获的是引用,不是值 - 修改外部变量会反映到闭包内 - 闭包修改也会反映到外部 - error 作为 返回值 - Go 把 error 当作普通值处理,而不是特殊的异常机制 - error 的本质:一个接口 - ```go // error 其实就是标准库定义的一个接口 // 任何实现了 Error() string 方法的类型都是 error type error interface { Error() string } // 创建 error 的几种方式: // 方式一:errors.New import "errors" err := errors.New("文件不存在") // 方式二:fmt.Errorf(支持格式化) err = fmt.Errorf("文件 %s 不存在", filename) // 方式三:自定义类型(后续再深入) ``` - 标准返回模式 - ```go // ✅ Go 惯用写法:err 作为最后一个返回值 func findUser(id int) (*User, error) { if id < 0 { return nil, fmt.Errorf("无效 ID: %d", id) } user, ok := userCache[id] if !ok { return nil, errors.New("用户不存在") } return user, nil } // 调用方必须检查 err user, err := findUser(42) if err != nil { return err } // 这里 user 保证不是 nil ``` - 提前返回:降低嵌套 - ```go // ❌ 过度嵌套(来自 Java/Python 习惯) func processDataBad(path string) error { data, err := readFile(path) if err == nil { parsed, err := parse(data) if err == nil { result, err := transform(parsed) if err == nil { save(result) return nil } return err } return err } return err } // ✅ Go 风格:早期返回,扁平化 func processData(path string) error { data, err := readFile(path) if err != nil { return err } parsed, err := parse(data) if err != nil { return err } result, err := transform(parsed) if err != nil { return err } return save(result) } ``` 数组、切片与 Map - 数组、切片与 Map - 数组:固定长度的值类型 - Go 的数组和 C/Java 的不一样,它是值类型,长度是类型的一部分 - 实际开发很少使用数组,几乎都使用切片 - 示例 - ```go // 声明数组:类型 [N]T var a [3]int // [0, 0, 0],零值初始化 b := [3]int{1, 2, 3} // 字面量初始化 c := [...]int{1, 2, 3} // ... 让编译器推断长度 // 长度是类型的一部分! var x [3]int var y [4]int x = y // ❌ 编译错误:[3]int 和 [4]int 是不同类型 // 访问和修改 fmt.Println(b[0]) // 1 b[0] = 100 fmt.Println(len(b)) // 3 // 遍历 for i, v := range b { fmt.Printf("b[%d] = %d\n", i, v) } ``` - 数组是值类型 - 把数组赋值给另一个变量,或者传入函数,都会完整拷贝一份 - 大数组这样性能会很差,实际开发总是使用切片 - ```go a := [3]int{1, 2, 3} b := a // ✨ 完全拷贝!b 和 a 是独立的 b[0] = 100 fmt.Println(a) // [1 2 3] ← a 没变 fmt.Println(b) // [100 2 3] // 传入函数也是拷贝 func modify(arr [3]int) { arr[0] = 999 } modify(a) fmt.Println(a) // [1 2 3] ← 依然没变! ``` - 切片:Go 的主力军 - 切片是 Go 中最常用的数据结构 - 看起来像动态数组,但底层是对数组的视图 - 切片的三要素 - ```go // 切片在内存中是一个结构体(概念上): // type slice struct { // ptr *T // 指向底层数组的指针 // len int // 当前长度 // cap int // 容量(底层数组剩余空间) // } s := []int{10, 20, 30} fmt.Println(len(s)) // 3(长度) fmt.Println(cap(s)) // 3(容量) ``` - 创建切片的五种方式 - ```go // ① 字面量 s1 := []int{1, 2, 3} // ② make:指定长度(和容量) s2 := make([]int, 5) // len=5, cap=5,值全是 0 s3 := make([]int, 3, 10) // len=3, cap=10 // ③ nil 切片 var s4 []int // nil,len=0, cap=0 fmt.Println(s4 == nil) // true // 但 nil 切片可以直接 append,不会 panic // ④ 空切片(和 nil 切片 len/cap 相同但底层不同) s5 := []int{} fmt.Println(s5 == nil) // false // ⑤ 切割已有数组/切片(下一节详讲) arr := [5]int{1, 2, 3, 4, 5} s6 := arr[1:4] // [2, 3, 4] ``` - ni 切片 vs 空切片 - 两者几乎可以互换 - 都可以 append,都可以 range - JSON 序列化时 nil 变为 null,空切片变为 【】 - 推荐总是优先使用 nil 切片 - 切片操作:切割与 append - 切割语法 s[low:high:max] - ```go s := []int{0, 1, 2, 3, 4, 5} // 基本切割 [low:high] s[1:4] // [1, 2, 3] ← 左闭右开 s[:3] // [0, 1, 2] 省略 low 默认 0 s[3:] // [3, 4, 5] 省略 high 默认 len s[:] // [0, 1, 2, 3, 4, 5] 完整拷贝引用 // 三参数切割 [low:high:max] —— 限制容量 s2 := s[1:4:4] fmt.Println(len(s2), cap(s2)) // 3, 3(容量被限制) // 不加 :max 的话,cap(s2) 会是 5(底层数组剩余容量) ``` - append: 扩容机制 - ```go s := []int{1, 2, 3} s = append(s, 4) // [1 2 3 4] s = append(s, 5, 6, 7) // 添加多个 // 合并两个切片 a := []int{1, 2} b := []int{3, 4} c := append(a, b...) // 注意 ...,展开 b // c = [1 2 3 4] ``` - 扩容规则 - ```go // 当 append 超过 cap 时,Go 会分配新的底层数组 // 扩容规则(Go 1.18+ 简化): // cap < 256:翻倍(1 → 2 → 4 → 8 ...) // cap >= 256:每次增加约 25% s := make([]int, 0, 2) fmt.Println(cap(s)) // 2 s = append(s, 1, 2, 3) fmt.Println(cap(s)) // 4(翻倍) // 扩容是昂贵的!如果知道最终大小,一开始就 make 好 s := make([]int, 0, 1000) // ✅ 预分配 for i := 0; i < 1000; i++ { s = append(s, i) // 不会触发扩容 } ``` - 切片陷阱 - 切割共享底层数组 - ```go original := []int{1, 2, 3, 4, 5} sub := original[1:3] // [2, 3],但底层数组是共享的! sub[0] = 999 fmt.Println(original) // [1 999 3 4 5] ← 原数组被改了! fmt.Println(sub) // [999 3] // 安全做法:显式拷贝 sub := make([]int, 2) copy(sub, original[1:3]) // sub 是独立的 sub[0] = 999 fmt.Println(original) // [1 2 3 4 5] ← 不受影响 ``` - append 可能修改原切片 - ```go original := []int{1, 2, 3, 4, 5} sub := original[:3] // [1 2 3],cap=5 sub = append(sub, 999) // append 时 cap 够用,复用底层数组! fmt.Println(original) // [1 2 3 999 5] ← 原数组第 4 位被改了! // 用三参数切割限制容量,强制 append 时分配新数组 sub := original[:3:3] // cap=3 sub = append(sub, 999) // 容量不够,分配新数组 fmt.Println(original) // [1 2 3 4 5] ← 原数组安全 ``` - 大切片导致内存泄露 - ```go // 读一个 1GB 大文件,只保留前 100 字节 func readSmallPart(filename string) []byte { big, _ := os.ReadFile(filename) // 1GB return big[:100] // ⚠️ 返回的切片引用着整个 1GB 数组! // 只要返回值存活,1GB 内存就不会被 GC } // 正确做法:主动拷贝脱离引用 func readSmallPart(filename string) []byte { big, _ := os.ReadFile(filename) small := make([]byte, 100) copy(small, big) return small // ✅ 只引用 100 字节,big 可以被 GC } ``` - 切片常见操作 - 删除元素 - ```go // Go 没有内置的删除函数,用切片拼接 s := []int{1, 2, 3, 4, 5} i := 2 // 要删除的索引 // 删除索引 i 的元素 s = append(s[:i], s[i+1:]...) // s = [1 2 4 5] // Go 1.21+ 用 slices.Delete import "slices" s = slices.Delete(s, i, i+1) ``` - 插入元素 - ```go s := []int{1, 2, 4, 5} i := 2 // 插入位置 val := 3 // 要插入的值 // 在索引 i 处插入 val s = append(s[:i], append([]int{val}, s[i:]...)...) // s = [1 2 3 4 5] // Go 1.21+ 用 slices.Insert(推荐) s = slices.Insert(s, i, val) ``` - 复制、反转、排序 - ```go // 复制 src := []int{1, 2, 3} dst := make([]int, len(src)) n := copy(dst, src) // n 是实际复制的元素数 // Go 1.21+ 最简洁 dst := slices.Clone(src) // 排序 import "sort" nums := []int{3, 1, 4, 1, 5, 9, 2, 6} sort.Ints(nums) // [1 1 2 3 4 5 6 9] sort.Sort(sort.Reverse(sort.IntSlice(nums))) // 降序 // Go 1.21+ 更简洁 slices.Sort(nums) slices.Reverse(nums) // 自定义排序 sort.Slice(nums, func(i, j int) bool { return nums[i] > nums[j] // 降序 }) ``` - map: 键值对容器 - Go 的 map 是哈希表实现,支持任何可比较类型作为 key - 使用前必须初始化,nil map 不能写入 - 声明与初始化 - ```go // ❌ 错误:只声明,没初始化 var m1 map[string]int m1["a"] = 1 // panic: assignment to entry in nil map // ✅ 方式一:make m2 := make(map[string]int) m2["a"] = 1 // ✅ 方式二:字面量 m3 := map[string]int{ "one": 1, "two": 2, "three": 3, } // ✅ 方式三:make 指定初始容量(性能优化) m4 := make(map[string]int, 1000) // 提前分配空间 ``` - 增删改查 - ```go m := map[string]int{"a": 1, "b": 2} // 增/改(语法相同) m["c"] = 3 // 新增 m["a"] = 100 // 修改 // 查 v := m["a"] // 100 missing := m["x"] // 0(不存在时返回零值!) // ✨ 判断 key 是否存在:逗号 ok 惯用法 v, ok := m["a"] // v=100, ok=true v, ok := m["x"] // v=0, ok=false if ok { fmt.Println("存在") } // 删除(不存在也不会报错) delete(m, "a") // 长度 fmt.Println(len(m)) ``` - 遍历(顺序随机) - ```go m := map[string]int{"a": 1, "b": 2, "c": 3} // 每次遍历顺序都可能不同! for k, v := range m { fmt.Printf("%s=%d\n", k, v) } // 需要有序遍历:把 key 取出来排序 keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { fmt.Printf("%s=%d\n", k, m[k]) } ``` - map 进阶:零值技巧与并发 - 零值技巧:计数器模式 - ```go // map 访问不存在的 key 返回零值,这特性很好用 words := []string{"go", "rust", "go", "python", "go", "rust"} count := make(map[string]int) for _, w := range words { count[w]++ // ✨ 不存在时 count[w] 是 0,+1 变成 1 } // count = {"go": 3, "rust": 2, "python": 1} // 分组:map + 切片 type User struct { Name string City string } users := []User{ {"Alice", "Tokyo"}, {"Bob", "Tokyo"}, {"Charlie", "Osaka"}, } byCity := make(map[string][]User) for _, u := range users { byCity[u.City] = append(byCity[u.City], u) } // byCity["Tokyo"] = [{Alice Tokyo} {Bob Tokyo}] ``` - map 作为集合 - ```go // Go 没有内置 Set,用 map[T]struct{} 模拟 set := map[string]struct{}{} // 添加 set["apple"] = struct{}{} set["banana"] = struct{}{} // 判断存在 _, ok := set["apple"] // true // 删除 delete(set, "apple") // 为什么用 struct{} 而不是 bool? // struct{} 不占内存(0 字节),比 bool(1字节)更省空间 ``` - map 不是并发安全的 - ```go // ❌ 多个 goroutine 同时读写 map 会 panic! m := make(map[string]int) go func() { m["a"] = 1 }() go func() { m["b"] = 2 }() // 运行时报错:fatal error: concurrent map writes // ✅ 方式一:sync.Mutex 加锁 var mu sync.Mutex m := make(map[string]int) go func() { mu.Lock() defer mu.Unlock() m["a"] = 1 }() // ✅ 方式二:sync.Map(适合读多写少场景) var sm sync.Map sm.Store("a", 1) v, ok := sm.Load("a") sm.Delete("a") sm.Range(func(k, v any) bool { fmt.Println(k, v) return true // 返回 false 停止遍历 }) ``` 结构体与方法 - 结构体与方法 - 结构体基础 - 结构体是 Go 中组织数据的核心方式 - Go 没有 class,但 struct + 方法已经能表达几乎所有面向对象的特性,而且更简单直接 - 定义与初始化 - ```go // 定义结构体 type User struct { ID int Name string Email string Active bool } // 初始化方式一:字段名(推荐,最清晰) u1 := User{ ID: 1, Name: "Alice", Email: "alice@example.com", Active: true, } // 初始化方式二:按字段顺序(脆弱,字段增减会出问题) u2 := User{2, "Bob", "bob@example.com", false} // 初始化方式三:零值 var u3 User // 所有字段都是零值:{0, "", "", false} u3.Name = "Charlie" // 初始化方式四:new(返回指针) u4 := new(User) // 等价于 &User{} u4.Name = "David" ``` - 访问和修改字段 - ```go u := User{Name: "Alice"} // 访问字段 fmt.Println(u.Name) // 修改字段 u.Name = "Alicia" // 指针也用 . 访问(Go 自动解引用) p := &u p.Name = "Alice 2" // 等价于 (*p).Name,Go 帮你省了* fmt.Println(u.Name) // Alice 2 ``` - 字段名大小写 - 首字母大写的字段是导出的(public),可以被包外访问 - 小写的是未导出的(private),只能包内使用 - 值类型 vs 指针 - struct 是值类型。赋值、传参、返回都是完整拷贝 - ```go u1 := User{Name: "Alice"} u2 := u1 // ✨ 完全拷贝!u1 和 u2 是两个独立对象 u2.Name = "Bob" fmt.Println(u1.Name) // Alice(没变) fmt.Println(u2.Name) // Bob // 传入函数也是拷贝 func modify(u User) { u.Name = "Changed" // 只修改了拷贝 } modify(u1) fmt.Println(u1.Name) // Alice(没变!) // 想修改原对象?传指针 func modifyPtr(u *User) { u.Name = "Changed" } modifyPtr(&u1) fmt.Println(u1.Name) // Changed ``` - 什么时候用指针 - ```go // ✅ 使用指针的场景: // 1. 需要修改原对象 func (u *User) SetName(name string) { u.Name = name } // 2. 结构体很大,拷贝开销大 type Config struct { // 假设有 50 个字段 } func process(c *Config) { ... } // 传指针避免拷贝 // 3. 想表达「可能为空」的语义 func findUser(id int) *User { if !found { return nil // 用 nil 表示未找到 } return &user } // ❌ 不用指针的场景: // - 小结构体(几个字段的) // - 不修改的只读操作 // - 代表不可变概念(如 time.Time) ``` - 经验法则 - 拿不准就用指针 - Go 代码中 *User 比 User 更常见 - 但像 time.Time、image.Point 这种小且不可变的类型,用值类型更自然 - 方法:给类型加行为 - 方法是绑定到特定类型的函数 - Go 的方法语法有点特别——接收者写在函数名前面 - 方法定义语法 - ```go type Rectangle struct { Width, Height float64 } // 方法定义:func (接收者 类型) 方法名(参数) 返回值 func (r Rectangle) Area() float64 { return r.Width * r.Height } func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) } // 调用 rect := Rectangle{Width: 3, Height: 4} fmt.Println(rect.Area()) // 12 fmt.Println(rect.Perimeter()) // 14 ``` - 值接收者 vs 指针接收者 - ```go type Counter struct { count int } // ❌ 值接收者:修改不会生效! func (c Counter) IncrementWrong() { c.count++ // 只修改了副本 } // ✅ 指针接收者:修改原对象 func (c *Counter) Increment() { c.count++ } // ✅ 只读方法用值接收者 func (c Counter) Get() int { return c.count } c := Counter{} c.IncrementWrong() fmt.Println(c.Get()) // 0 ← 没变! c.Increment() fmt.Println(c.Get()) // 1 ← 生效了 c.Increment() fmt.Println(c.Get()) // 2 ``` - 选择接受者类型的原则 - 需要修改对象 -> 必须用指针接收者 - 结构体很大 -> 用指针避免拷贝 - 包含 sync.Mutex 等不可拷贝字段 -> 必须用指针 - 其他情况两者都行,但是一个类型的方法应该统一 - 要么全值接收者,要么全指针接收者 - go 的自动转换 - ```go // Go 会在值和指针之间自动转换,调用方法时不用纠结 c := Counter{} // 值 c.Increment() // ✨ Go 自动取地址 (&c).Increment() c.Get() // 值调用值方法,直接 p := &Counter{} // 指针 p.Increment() // 指针调用指针方法,直接 p.Get() // ✨ Go 自动解引用 (*p).Get() // 唯一限制:不能在「不可寻址」的值上调用指针方法 Counter{}.Increment() // ❌ 编译错误,字面量不可寻址 // 但可以: c := Counter{} c.Increment() // ✅ 变量可寻址 ``` - 给任意类型定义方法 - 方法不只能定义在 struct 上,任何在当前包定义的类型都可以有方法 - ```go // 给 int 起别名并加方法 type Celsius float64 func (c Celsius) ToFahrenheit() float64 { return float64(c)*9/5 + 32 } func (c Celsius) String() string { return fmt.Sprintf("%.1f°C", float64(c)) } // 给切片起别名并加方法 type IntSlice []int func (s IntSlice) Sum() int { total := 0 for _, n := range s { total += n } return total } func (s IntSlice) Max() int { if len(s) == 0 { return 0 } m := s[0] for _, n := range s[1:] { if n > m { m = n } } return m } func main() { temp := Celsius(25) fmt.Println(temp) // 25.0°C(调用了 String 方法) fmt.Println(temp.ToFahrenheit()) // 77 nums := IntSlice{1, 5, 3, 8, 2} fmt.Println(nums.Sum()) // 19 fmt.Println(nums.Max()) // 8 } ``` - 只能给本包类型加方法 - 不能直接给 int、string 等内置类型加方法,也不能给其他包的类型加方法 - 需要的话就 type MyInt int 定义一个新类型。这个限制防止混乱——每个类型的方法归属清晰 - 结构体嵌入 - Go 没有继承,但有「嵌入」 - 把一个 struct 嵌入到另一个里面,外层可以直接访问内层的字段和方法 - 这就是 Go 面向对象的核心——组合优于继承 - 基本嵌入 - ```go type Animal struct { Name string Age int } func (a Animal) Describe() string { return fmt.Sprintf("%s, %d岁", a.Name, a.Age) } // Dog 嵌入 Animal type Dog struct { Animal // ✨ 嵌入字段(没有字段名) Breed string } d := Dog{ Animal: Animal{Name: "旺财", Age: 3}, Breed: "柴犬", } // 直接访问嵌入字段的属性和方法(提升) fmt.Println(d.Name) // "旺财"(不用 d.Animal.Name) fmt.Println(d.Describe()) // "旺财, 3岁"(继承了 Animal 的方法) fmt.Println(d.Breed) // "柴犬" // 也可以显式访问 fmt.Println(d.Animal.Name) // 同上 ``` - 方法覆盖 - ```go // Dog 可以定义同名方法「覆盖」Animal 的 func (d Dog) Describe() string { // 可以调用被覆盖的方法 base := d.Animal.Describe() return fmt.Sprintf("%s,品种:%s", base, d.Breed) } d := Dog{Animal: Animal{Name: "旺财", Age: 3}, Breed: "柴犬"} fmt.Println(d.Describe()) // "旺财, 3岁,品种:柴犬" ``` - 嵌入接口 - ```go // 也可以嵌入接口,常见于标准库 type ReadCloser interface { io.Reader // 嵌入接口 io.Closer // 嵌入接口 } // 等价于: // type ReadCloser interface { // Read(p []byte) (n int, err error) // Close() error // } // 实战场景:给一个已有类型「增强」 type LoggedDB struct { *sql.DB // 嵌入指针,继承所有方法 logger *log.Logger } // 只需要定义想增强的方法 func (db *LoggedDB) Query(query string, args ...any) (*sql.Rows, error) { db.logger.Printf("SQL: %s", query) return db.DB.Query(query, args...) } // 其他方法(Ping、Exec 等)自动继承自 *sql.DB ``` - 嵌入 vs 继承 - 嵌入不是继承 - 嵌入字段是一个真实存在的字段,外层 struct has-a 内层,不是 is-a - 你可以嵌入多个类型(多继承的感觉),但本质是组合:Dog 不是 Animal 的子类,而是「包含了一个 Animal」 - 构造函数模式 - Go 没有 constructor 关键字 - 惯例是定义 NewXxx 函数返回初始化好的实例,这样可以封装验证、默认值、依赖注入等逻辑。 - 示例 - ```go type User struct { ID int Name string Email string CreatedAt time.Time } // 标准构造函数:返回值 func NewUser(name, email string) User { return User{ Name: name, Email: email, CreatedAt: time.Now(), } } // 更常见:返回指针 + error func NewUser(name, email string) (*User, error) { if name == "" { return nil, errors.New("name 不能为空") } if !strings.Contains(email, "@") { return nil, fmt.Errorf("无效的 email: %s", email) } return &User{ Name: name, Email: email, CreatedAt: time.Now(), }, nil } // 使用 u, err := NewUser("Alice", "alice@example.com") if err != nil { log.Fatal(err) } fmt.Println(u.Name) ``` - 函数选项模式 - ```go // 问题:构造函数参数太多怎么办? // func NewServer(host string, port int, timeout time.Duration, tls bool, ...) // 解决:函数选项模式(Functional Options) type Server struct { host string port int timeout time.Duration tls bool } // Option 是一个修改 Server 的函数 type Option func(*Server) func WithPort(p int) Option { return func(s *Server) { s.port = p } } func WithTimeout(t time.Duration) Option { return func(s *Server) { s.timeout = t } } func WithTLS() Option { return func(s *Server) { s.tls = true } } // 构造函数接受任意个 Option func NewServer(host string, opts ...Option) *Server { // 默认值 s := &Server{ host: host, port: 80, timeout: 30 * time.Second, tls: false, } // 应用选项 for _, opt := range opts { opt(s) } return s } // 使用:非常灵活 s1 := NewServer("localhost") s2 := NewServer("example.com", WithPort(443), WithTLS()) s3 := NewServer("api.com", WithTimeout(10*time.Second)) ``` - 函数选项模式的威力 - 这个模式在 Go 生态广泛使用(gRPC、Kubernetes、各种库的配置都是这个模式) - 优势:可选参数不用硬编码、扩展性好(加新选项不破坏现有代码)、可读性强(调用方清楚写出每个选项) - 结构体标签 - Struct Tag 是字段后面的反引号字符串,为字段附加元数据 - JSON 序列化、数据库映射、表单校验都用它 - 示例 - ```go type User struct { ID int `json:"id"` Name string `json:"name" validate:"required,min=2"` Email string `json:"email" validate:"required,email"` Password string `json:"-"` // - 表示不参与 JSON 序列化 Age int `json:"age,omitempty"` // omitempty:零值时忽略 Role string `json:"role" db:"user_role"` // 同一字段可有多个 tag } // JSON 序列化 u := User{ ID: 1, Name: "Alice", Email: "alice@example.com", Password: "secret", // Age 是 0,会被忽略 } data, _ := json.Marshal(u) fmt.Println(string(data)) // {"id":1,"name":"Alice","email":"alice@example.com","role":""} // 注意:password 不在输出中,age 也被忽略 ``` - 常见 Tag 用法一览 - ```go type Article struct { // encoding/json ID int `json:"id"` Title string `json:"title"` Content string `json:"content,omitempty"` // 空时忽略 Internal string `json:"-"` // 从不序列化 // GORM(Day 17-18) CreatedAt time.Time `gorm:"autoCreateTime"` Slug string `gorm:"uniqueIndex;size:100"` // validator(参数校验) Author string `validate:"required,min=2,max=50"` Views int `validate:"gte=0"` // 组合使用 Email string `json:"email" validate:"required,email" db:"user_email"` } ``` - tag 只是字符串 - Tag 本身只是给字段附加的字符串元数据,语言本身不处理 - 是各个库(encoding/json、gorm、validator...)通过反射读取 tag 并做相应处理 - tag 的语法完全由使用它的库定义 接口与多态 - 接口与多态 - 接口是什么 - 接口是 Go 最优雅的特性。 - 它定义了一组方法签名,任何实现了这些方法的类型都「自动」满足这个接口——不需要显式声明 implements。 - 这叫做「隐式接口实现」,也叫鸭子类型。 - 接口示例 - ```go // 定义接口:只有方法签名,没有实现 type Animal interface { Sound() string Name() string } // Dog 实现了 Animal 接口 // 注意:不需要写 "implements Animal"! type Dog struct{ name string } func (d Dog) Sound() string { return "汪汪" } func (d Dog) Name() string { return d.name } // Cat 也实现了 Animal 接口 type Cat struct{ name string } func (c Cat) Sound() string { return "喵喵" } func (c Cat) Name() string { return c.name } // 函数接受接口类型 func Describe(a Animal) { fmt.Printf("%s 说:%s\n", a.Name(), a.Sound()) } func main() { dog := Dog{name: "旺财"} cat := Cat{name: "咪咪"} Describe(dog) // 旺财 说:汪汪 Describe(cat) // 咪咪 说:喵喵 // 接口变量可以持有任意满足条件的类型 var a Animal = dog a = cat // 随时切换 } ``` - 隐式实现的威力 - Java/C# 要写 implements Animal,Go 不需要 - 只要你的类型有对应的方法,它就是那个接口 - 你可以让一个你不能修改的第三方类型满足你的接口 - 也可以在不改动现有代码的情况下新定义接口来约束它们 - 这是 Go 解耦的核心武器 - 接口值的内部结构 - 接口值在底层由两部分组成:动态类型(type)和动态值(value) - 示例 - ```go // 接口值 = (type, value) var a Animal // (nil, nil),零值 a = Dog{name: "旺财"} // a 现在是 (Dog, {name:"旺财"}) // 打印类型和值 fmt.Printf("类型: %T\n", a) // main.Dog fmt.Printf("值: %v\n", a) // {旺财} ``` - nil 接口 vs 含 nil 的接口 - ```go // 这是 Go 最反直觉的陷阱之一! var d *Dog = nil // d 是 nil 指针 var a Animal = d // a 的 type=*Dog, value=nil // a 不是 nil!因为 type 部分有值 fmt.Println(a == nil) // false ← 让人意外! fmt.Println(d == nil) // true // 正确判断接口是否有效 func process(a Animal) { // ❌ 错误:a != nil 不代表 a 里的值不是 nil if a != nil { // 如果 a = (*Dog)(nil),这里调用方法会 panic } // ✅ 正确:用 reflect 或 type switch 检查 if a == nil { return } } // 最佳实践:函数返回接口时,失败直接返回 nil func findAnimal() Animal { var d *Dog = nil return d // ❌ 坑!返回的接口不是 nil return nil // ✅ 这才是真正的 nil 接口 } ``` - 「含有 nil 指针的接口,不等于 nil 接口。」 - 一个接口值只有当 type 和 value 都是 nil 时,它才等于 nil - 函数返回接口类型时,失败直接 return nil,而不是 return (*ConcreteType)(nil) - 类型断言与 type switch - 有时候你需要从接口里把具体类型「取出来」 - Go 提供了两种方式:类型断言和 type switch - 类型断言 - ```go var a Animal = Dog{name: "旺财"} // 方式一:直接断言(不安全,失败会 panic) d := a.(Dog) fmt.Println(d.name) // 旺财 // 方式二:逗号 ok(推荐,安全) d, ok := a.(Dog) if ok { fmt.Println("是 Dog:", d.name) } else { fmt.Println("不是 Dog") } // 断言为接口(检查是否实现了另一个接口) type Swimmer interface { Swim() string } if s, ok := a.(Swimmer); ok { fmt.Println(s.Swim()) } else { fmt.Println("不会游泳") } ``` - type switch - ```go func describe(i interface{}) { switch v := i.(type) { case int: fmt.Printf("整数: %d,翻倍是 %d\n", v, v*2) case string: fmt.Printf("字符串: %q,长度 %d\n", v, len(v)) case bool: fmt.Printf("布尔值: %t\n", v) case []int: fmt.Printf("整数切片,长度 %d\n", len(v)) case nil: fmt.Println("是 nil") default: fmt.Printf("未知类型: %T\n", v) } } describe(42) // 整数: 42,翻倍是 84 describe("hello") // 字符串: "hello",长度 5 describe(true) // 布尔值: true describe([]int{1,2,3}) // 整数切片,长度 3 describe(nil) // 是 nil ``` - 类型断言 vs 类型转换 - 类型断言 a.(Dog) 是从接口里取出具体类型 - 运行时才知道结果,失败会 panic - 类型转换 int(3.14) 是编译时已知的类型转换,不会失败(但可能丢精度) - 标准库核心接口 - Go 标准库定义了很多小接口,每个接口只有 1-3 个方法 - fmt.Stringer:自定义打印格式 - ```go // 标准库定义: // type Stringer interface { // String() string // } type Point struct{ X, Y int } // 实现 Stringer 接口 func (p Point) String() string { return fmt.Sprintf("(%d, %d)", p.X, p.Y) } p := Point{3, 4} fmt.Println(p) // (3, 4) ← 自动调用 String() fmt.Printf("%v\n", p) // (3, 4) fmt.Printf("%s\n", p) // (3, 4) ``` - error:自定义错误类型 - ```go // error 接口只有一个方法: // type error interface { // Error() string // } // 自定义错误类型(携带更多信息) type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return fmt.Sprintf("字段 %s 验证失败: %s", e.Field, e.Message) } // 使用 func validateAge(age int) error { if age < 0 || age > 150 { return &ValidationError{ Field: "age", Message: fmt.Sprintf("值 %d 超出合法范围 [0, 150]", age), } } return nil } err := validateAge(-1) if err != nil { fmt.Println(err) // 字段 age 验证失败: 值 -1 超出合法范围 [0, 150] // 用类型断言获取详细信息 if ve, ok := err.(*ValidationError); ok { fmt.Println("出问题的字段:", ve.Field) } } ``` - io.Reader 和 io.Writer:I/O 的基石 - ```go // io.Reader:能被读取的任何东西 // type Reader interface { // Read(p []byte) (n int, err error) // } // io.Writer:能被写入的任何东西 // type Writer interface { // Write(p []byte) (n int, err error) // } // 这两个接口让函数极其通用: func copyData(dst io.Writer, src io.Reader) (int64, error) { return io.Copy(dst, src) } // 文件、网络连接、内存 buffer 都实现了这两个接口: file, _ := os.Open("input.txt") buf := &bytes.Buffer{} copyData(buf, file) // 文件 → 内存 copyData(os.Stdout, buf) // 内存 → 标准输出 copyData(os.Stdout, strings.NewReader("hello")) // 字符串 → 标准输出 // 自己实现 io.Writer(比如日志收集器) type LogWriter struct { prefix string } func (w *LogWriter) Write(p []byte) (int, error) { fmt.Printf("[%s] %s", w.prefix, p) return len(p), nil } lw := &LogWriter{prefix: "INFO"} fmt.Fprintf(lw, "服务器启动在端口 %d\n", 8080) // [INFO] 服务器启动在端口 8080 ``` - 接口组合 - 和结构体嵌入一样,接口也可以嵌入其他接口 - Go 鼓励定义小接口,再通过组合构建大接口 - 示例 - ```go // 小接口:单一职责 type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } type Closer interface { Close() error } // 通过组合构建复合接口 type ReadWriter interface { Reader Writer } type ReadWriteCloser interface { Reader Writer Closer } // 实践中的例子 type Shape interface { Area() float64 Perimeter() float64 } type Drawable interface { Draw() string } // 组合成「可绘制的形状」 type DrawableShape interface { Shape Drawable } ``` - 接口应该越小越好 - ```go // ❌ 大接口:实现困难,测试困难,耦合高 type UserService interface { CreateUser(name, email string) (*User, error) GetUser(id int) (*User, error) UpdateUser(id int, name string) error DeleteUser(id int) error ListUsers() ([]*User, error) AuthenticateUser(email, password string) (string, error) ResetPassword(email string) error // ... 还有 20 个方法 } // ✅ 小接口:精准,易测试,低耦合 type UserCreator interface { CreateUser(name, email string) (*User, error) } type UserFinder interface { GetUser(id int) (*User, error) } type Authenticator interface { Authenticate(email, password string) (string, error) } // 函数只声明它真正需要的 func registerHandler(creator UserCreator) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // 只需要 CreateUser,不依赖整个 UserService } } ``` - 空接口与 any - 空接口 interface{} 没有任何方法要求,所以所有类型都满足它 - Go 1.18 引入了 any 作为 interface{} 的别名,更简洁 - 示例 - ```go // interface{} 和 any 完全等价 var x any = 42 x = "hello" x = []int{1, 2, 3} x = struct{ Name string }{"Alice"} // 常见于需要处理任意类型的场景 func printAll(values ...any) { for _, v := range values { fmt.Printf("%T: %v\n", v, v) } } printAll(1, "hello", true, 3.14) // int: 1 // string: hello // bool: true // float64: 3.14 // map 存储任意类型(类似 JSON 对象) config := map[string]any{ "host": "localhost", "port": 8080, "debug": true, "timeout": 30.5, } ``` - 使用 any 的代价 - ```go // any 丢失了类型信息,使用时必须断言 var v any = 42 // ❌ 不能直接运算 // fmt.Println(v + 1) // 编译错误 // ✅ 先断言再使用 if n, ok := v.(int); ok { fmt.Println(n + 1) // 43 } // any 的性能也比具体类型差(有装箱开销) // 能用泛型(Day 14)就用泛型,不要滥用 any // 合理使用场景: // 1. JSON 解析(结构未知时) // 2. 通用容器(在泛型之前的历史代码) // 3. fmt.Println 这种需要接受任意值的工具函数 ``` - any 不是银弹 - any 让你绕过了类型系统,失去了编译器的保护 - 能用具体类型就用具体类型,能用泛型就用泛型(Go 1.18+),any 是最后的选择 - 接口定义最佳实践 - 在使用方定义接口,不在实现方 - ```go // ❌ 在实现包里定义接口(Java 风格) // package userservice // type UserService interface { ... } // type UserServiceImpl struct { ... } // ✅ Go 风格:在需要的地方定义接口 // package handler type userStore interface { // 小写,包私有 GetUser(id int) (*User, error) CreateUser(name, email string) (*User, error) } type Handler struct { store userStore // 依赖接口,不依赖具体类型 } // 任何实现了 GetUser 和 CreateUser 的类型都可以注入 func NewHandler(store userStore) *Handler { return &Handler{store: store} } ``` - 接口让测试更容易 - ```go // 生产代码 type DBStore struct { db *sql.DB } func (s *DBStore) GetUser(id int) (*User, error) { /* 查数据库 */ } // 测试用 Mock(不需要数据库!) type MockStore struct { users map[int]*User } func (m *MockStore) GetUser(id int) (*User, error) { u, ok := m.users[id] if !ok { return nil, errors.New("not found") } return u, nil } func (m *MockStore) CreateUser(name, email string) (*User, error) { u := &User{ID: len(m.users) + 1, Name: name, Email: email} m.users[u.ID] = u return u, nil } // 测试 func TestHandler(t *testing.T) { mock := &MockStore{users: map[int]*User{ 1: {ID: 1, Name: "Alice"}, }} h := NewHandler(mock) // 注入 Mock // ... 测试 handler 逻辑,完全不依赖数据库 } ``` - 接口是 Go 依赖注入的基础 - Go 不需要 Spring 这样的 DI 框架 - 接口 + 构造函数注入就够了 - 生产环境注入真实实现,测试时注入 Mock - 这让代码既解耦又易测试 - 这是 Go 后端项目的标准架构模式 并发与工程化 - 并发与工程化 - Goroutine 入门 - Goroutine 是什么 - Goroutine 是 Go 并发的核心——比线程轻量得多的「协程」 - 启动一个 Goroutine 只需要在函数调用前加 go 关键字 - 示例 - ```go package main import ( "fmt" "time" ) func say(s string) { for i := 0; i < 3; i++ { fmt.Println(s) time.Sleep(100 * time.Millisecond) } } func main() { go say("世界") // 新 goroutine 中运行 say("你好") // 当前 goroutine 中运行 } // 输出(顺序不确定): // 你好 // 世界 // 你好 // 世界 // 你好 // 世界 ``` - Goroutine vs 线程 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/19/20260419160732615.png,310,100) - GMP 调度模型(概览) - Go 运行时用 GMP 模型来调度 Goroutine - 示例 - ```go // G = Goroutine(协程) // M = Machine(OS 线程) // P = Processor(逻辑处理器,调度上下文) // P 的数量默认等于 CPU 核数,可以设置 import "runtime" runtime.GOMAXPROCS(4) // 设置使用 4 个 P fmt.Println(runtime.GOMAXPROCS(0)) // 查看当前 P 数量 fmt.Println(runtime.NumCPU()) // CPU 核数 // Go 1.5+ 默认 GOMAXPROCS = NumCPU // 大多数时候不需要手动设置 ``` - 调度模型示意 - ``` P1 [G1→G2→G3] P2 [G4→G5→G6] ↓ ↓ M1 M2 ↓ ↓ OS Thread OS Thread 当 G 执行系统调用阻塞时,P 会把 M 换掉继续跑其他 G 这叫"work stealing"(工作窃取)——空闲的 P 会从别的 P 偷 G 来跑 ``` - sync.WaitGroup 等待协程完成 - 启动 Goroutine 后,main 函数不会自动等待它们结束 - 如果 main 退出,所有 Goroutine 都会被强制终止 - WaitGroup 是等待一组 Goroutine 完成的标准方式 - 示例 - ```go // ❌ 错误:main 退出,goroutine 还没跑完 func main() { go fmt.Println("可能跑不到") // main 立即结束,goroutine 被杀死 } // ✅ 用 WaitGroup 等待 import "sync" func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) // 计数器 +1 go func(id int) { defer wg.Done() // 函数结束时计数器 -1 fmt.Printf("Worker %d 完成\n", id) }(i) } wg.Wait() // 阻塞,直到计数器归零 fmt.Println("所有 Worker 完成") } ``` - WaitGroup 的使用规范 - ```go // ✅ 正确:在 goroutine 启动前 Add wg.Add(1) go func() { defer wg.Done() // ... }() // ❌ 错误:在 goroutine 内部 Add(可能来不及) go func() { wg.Add(1) // 可能 Wait() 已经过了,竞态! defer wg.Done() // ... }() // ✅ WaitGroup 不能拷贝,传指针 func process(wg *sync.WaitGroup) { defer wg.Done() // ... } var wg sync.WaitGroup wg.Add(1) go process(&wg) wg.Wait() ``` - 固定节奏 - 启动 goroutine 前 wg.Add(1) - goroutine 内第一行 defer wg.Done() - 主流程 wg.Wait() - Goroutine 泄露 - Goroutine 泄漏是 Go 并发最常见的 bug:启动了 Goroutine,但它永远不会退出,一直占用内存和资源 - 泄露的常见原因 - ```go // ❌ 泄漏一:等待永远不会发送的 channel func leak1() { ch := make(chan int) go func() { val := <-ch // 永远阻塞!没人往 ch 发数据 fmt.Println(val) }() // 函数返回,但 goroutine 永远卡在这 } // ❌ 泄漏二:没有退出机制的无限循环 func leak2() { go func() { for { doWork() // 没有 break/return/cancel 条件 } }() } // ❌ 泄漏三:阻塞的 HTTP 请求没有超时 func leak3() { go func() { resp, _ := http.Get("http://slow-server.com") // 如果服务器不响应,goroutine 永远等着 _ = resp }() } ``` - 正确的退出机制 - ```go // ✅ 用 done channel 通知退出 func worker(done <-chan struct{}) { for { select { case <-done: fmt.Println("收到退出信号,正在退出") return default: doWork() } } } done := make(chan struct{}) go worker(done) time.Sleep(5 * time.Second) close(done) // 通知所有监听 done 的 goroutine 退出 // ✅ 用 context.Context(更推荐,Day 10 详讲) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() go func() { select { case <-ctx.Done(): fmt.Println("超时退出:", ctx.Err()) return case result := <-doAsyncWork(): fmt.Println("完成:", result) } }() ``` - 检测泄露工具 goleak - ```go // 在测试中检测 goroutine 泄漏 import "go.uber.org/goleak" func TestNoLeak(t *testing.T) { defer goleak.VerifyNone(t) // 测试结束时检查是否有泄漏的 goroutine // 你的业务代码 runSomeTask() } // 也可以用运行时查看当前 goroutine 数量 import "runtime" fmt.Println("goroutine 数量:", runtime.NumGoroutine()) ``` - Goroutine 的黄金原则 - 启动 Goroutine 的人负责结束它 - 每次 go func() 之前,问自己:这个 Goroutine 什么时候、在什么条件下会退出? - 数据竞争与 Race Detector - 多个 Goroutine 同时读写同一变量时,会产生数据竞争(Data Race) - Go 自带了竞态检测器 - ```go // ❌ 数据竞争:多个 goroutine 同时写 counter var counter int func main() { var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() counter++ // ❌ 非原子操作,存在竞争! }() } wg.Wait() fmt.Println(counter) // 结果不确定,可能不是 1000 } // 运行竞态检测器 // go run -race main.go // 输出:WARNING: DATA RACE // 清晰显示哪些行在竞争 ``` - 三种解决方案 - ```go // ✅ 方案一:sync.Mutex 加锁 var ( mu sync.Mutex counter int ) go func() { mu.Lock() defer mu.Unlock() counter++ }() // ✅ 方案二:atomic 原子操作(更快,适合简单计数) import "sync/atomic" var counter int64 go func() { atomic.AddInt64(&counter, 1) }() result := atomic.LoadInt64(&counter) // ✅ 方案三:用 channel 传递数据(不共享状态) // 这是最地道的 Go 风格(Day 9 详讲) ch := make(chan int, 1000) for i := 0; i < 1000; i++ { go func() { ch <- 1 }() } total := 0 for i := 0; i < 1000; i++ { total += <-ch } fmt.Println(total) // 1000,保证正确 ``` - -race 标志 - 开发阶段始终用 go test -race 和 go run -race 运行代码 - Race Detector 会在运行时检测数据竞争并打印详细报告(文件名、行号、goroutine 栈) - 生产环境不用开(有约 20% 性能损耗),但 CI/CD 流水线里一定要跑 - sync 包核心原语 - sync 包提供了并发编程的基础工具 - 除了 WaitGroup,还有互斥锁、读写锁和 Once - sync.Mutex 互斥锁 - ```go type SafeCounter struct { mu sync.Mutex count int } func (c *SafeCounter) Inc() { c.mu.Lock() defer c.mu.Unlock() // 保证解锁 c.count++ } func (c *SafeCounter) Value() int { c.mu.Lock() defer c.mu.Unlock() return c.count } // 使用 counter := &SafeCounter{} var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() counter.Inc() }() } wg.Wait() fmt.Println(counter.Value()) // 1000,保证正确 ``` - sync.RWMutex 读写锁 (读多写少场景) - ```go type Cache struct { mu sync.RWMutex data map[string]string } // 写操作:独占锁 func (c *Cache) Set(key, val string) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = val } // 读操作:共享锁(多个 goroutine 可同时读) func (c *Cache) Get(key string) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() v, ok := c.data[key] return v, ok } // 读写锁的性能优势: // 纯读的场景下,多个 goroutine 可以并发读,不阻塞 // 只有写时才独占,适合缓存、配置这类读多写少的场景 ``` - sync.Once 单例初始化 - ```go // 保证函数只执行一次,线程安全 var ( instance *DB once sync.Once ) func GetDB() *DB { once.Do(func() { instance = &DB{ conn: openConnection(), } }) return instance } // 无论多少 goroutine 并发调用 GetDB() // openConnection() 只会执行一次 // 这是 Go 实现单例模式的标准方式 ``` - Channel 通信 - Channel 基础 - Channel 是 Go 并发的通信机制 - Go 的并发哲学是:「不要通过共享内存来通信,要通过通信来共享内存。」 - Channel 就是这个哲学的具体实现——Goroutine 之间通过 channel 传递数据,而不是共享变量。 - 示例 - ```go // 创建 channel:make(chan 类型) ch := make(chan int) // 无缓冲 channel ch := make(chan string, 5) // 有缓冲 channel,容量 5 // 发送数据:ch <- value ch <- 42 // 接收数据:value := <-ch v := <-ch // 关闭 channel close(ch) // 判断 channel 是否关闭 v, ok := <-ch // ok == false 说明 channel 已关闭且已空 // channel 的零值是 nil var ch chan int // nil channel // 向 nil channel 发送/接收会永远阻塞! ``` - 两个 Goroutine 通信 - ```go func main() { ch := make(chan string) // 发送方 goroutine go func() { ch <- "Hello from goroutine!" }() // 接收方(主 goroutine) msg := <-ch fmt.Println(msg) // Hello from goroutine! } // ✨ 不需要 WaitGroup! // <-ch 会阻塞,直到有数据,天然同步 ``` - Channel 的方向是通信,不是存储 - Channel 不是消息队列,不是用来存数据的 - 它是两个 Goroutine 之间的「传送带」,一端发送,另一端接收,数据在两者之间流动 - 无缓冲 vs 有缓冲 - 无缓冲和有缓冲 channel 的行为差异很大,选错会导致死锁或意外的并发行为。 - 无缓冲 Channel (同步) - ```go ch := make(chan int) // 无缓冲 // 发送方和接收方必须同时就绪,否则阻塞 // 就像打电话:双方必须同时在线 // ❌ 死锁:同一个 goroutine 又发又收 ch <- 1 // 阻塞等待接收方 v := <-ch // 永远不会执行到这里 // fatal error: all goroutines are asleep - deadlock! // ✅ 正确:发送和接收在不同 goroutine go func() { ch <- 42 }() // 另一个 goroutine 发送 v := <-ch // 主 goroutine 接收(会等待) fmt.Println(v) // 42 ``` - 有缓冲 Channel (异步) - ```go ch := make(chan int, 3) // 缓冲容量 3 // 就像发短信:发完不用等对方读 // 只要缓冲区没满,发送不阻塞 ch <- 1 // 不阻塞 ch <- 2 // 不阻塞 ch <- 3 // 不阻塞 ch <- 4 // 阻塞!缓冲区已满 // 缓冲区为空时,接收阻塞 v := <-ch // 1 // 查看状态 fmt.Println(len(ch)) // 2(当前数据量) fmt.Println(cap(ch)) // 3(总容量) ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/20/20260420213936856.png,310,90) - 用有缓冲的场景 - 发送方和接收方速度不匹配,例如任务队列、批量处理 - 缓冲大小要谨慎,太大会掩盖背压问题(下游处理慢时上游应该被限速) - Channel 方向限定 - 在函数参数中,可以限制 channel 只能发送或只能接收 - ```go // chan<- T:只能发送(send-only) // <-chan T:只能接收(receive-only) // chan T:双向(可以转换为单向) func producer(out chan<- int) { // 只能往 out 发送 for i := 0; i < 5; i++ { out <- i } close(out) // 生产完毕,关闭 channel } func consumer(in <-chan int) { // 只能从 in 接收 for v := range in { // for range 自动处理 close fmt.Println("收到:", v) } } func main() { ch := make(chan int, 5) // 双向 channel go producer(ch) // 自动转换为 chan<- int consumer(ch) // 自动转换为 <-chan int } ``` - for range 遍历 Channel - ```go // chan<- T:只能发送(send-only) // <-chan T:只能接收(receive-only) // chan T:双向(可以转换为单向) func producer(out chan<- int) { // 只能往 out 发送 for i := 0; i < 5; i++ { out <- i } close(out) // 生产完毕,关闭 channel } func consumer(in <-chan int) { // 只能从 in 接收 for v := range in { // for range 自动处理 close fmt.Println("收到:", v) } } func main() { ch := make(chan int, 5) // 双向 channel go producer(ch) // 自动转换为 chan<- int consumer(ch) // 自动转换为 <-chan int } ``` - 关于 close 的规则 - 只有发送方应该关闭 channel,接收方不应该关闭 - 关闭已关闭的 channel 会 panic - 向已关闭的 channel 发送会 panic - 从已关闭的 channel 接收会立即返回零值 - 简单原则:谁生产,谁关闭 - select 多路复用 - select 是 channel 的多路复用工具,当需要同时监听多个 channel 时,select 是标准答案 - ```go ch1 := make(chan string) ch2 := make(chan string) go func() { time.Sleep(1 * time.Second) ch1 <- "来自 ch1" }() go func() { time.Sleep(2 * time.Second) ch2 <- "来自 ch2" }() // select 等待第一个就绪的 case select { case msg := <-ch1: fmt.Println("ch1:", msg) case msg := <-ch2: fmt.Println("ch2:", msg) } // 输出:ch1: 来自 ch1(先到先得) ``` - 带 default 的非阻塞操作 - ```go ch := make(chan int, 1) // 非阻塞发送 select { case ch <- 42: fmt.Println("发送成功") default: fmt.Println("channel 已满,丢弃") } // 非阻塞接收 select { case v := <-ch: fmt.Println("接收到:", v) default: fmt.Println("channel 为空,跳过") } ``` - 超时控制 (实战必备) - ```go func fetchWithTimeout(url string) (string, error) { resultCh := make(chan string, 1) go func() { // 模拟 HTTP 请求 time.Sleep(2 * time.Second) resultCh <- "响应内容" }() select { case result := <-resultCh: return result, nil case <-time.After(1 * time.Second): // 1 秒超时 return "", errors.New("请求超时") } } ``` - 循环中使用 select - ```go func worker(jobs <-chan int, done <-chan struct{}) { for { select { case job, ok := <-jobs: if !ok { fmt.Println("jobs channel 已关闭") return } fmt.Println("处理任务:", job) case <-done: fmt.Println("收到退出信号") return } } } // 多 case 同时就绪时,select 随机选一个 // 这是刻意设计的,避免某个 case 被饿死 ``` - select 的随机性 - 当多个 case 同时就绪时,select 随机选择一个,不保证顺序 - 这是 Go 刻意的设计——防止某个 case 总被忽略(饥饿) - Done Channel 模式 - Done channel 是 Go 并发里用来广播退出信号的经典模式 - 通过 close(done) 可以同时通知所有监听它的 Goroutine 退出 - 示例 - ```go func generator(done <-chan struct{}, nums ...int) <-chan int { out := make(chan int) go func() { defer close(out) for _, n := range nums { select { case out <- n: // 正常发送 case <-done: // 收到退出信号 return } } }() return out } func main() { done := make(chan struct{}) defer close(done) // 函数退出时自动广播退出信号 out := generator(done, 1, 2, 3, 4, 5) // 只消费 2 个,然后退出 // done 关闭会通知 generator goroutine 退出 fmt.Println(<-out) // 1 fmt.Println(<-out) // 2 // defer close(done) 执行,generator goroutine 收到信号退出 } ``` - close 广播的原理 - ```go done := make(chan struct{}) // 启动多个监听 done 的 goroutine for i := 0; i < 3; i++ { go func(id int) { <-done // 阻塞等待 fmt.Printf("Worker %d 退出\n", id) }(i) } time.Sleep(1 * time.Second) close(done) // ✨ 关闭会「广播」到所有接收方 // 所有 3 个 goroutine 同时收到通知并退出 // 原理:从已关闭的 channel 接收立即返回零值 // 所以 close 一次,所有 <-done 都会解除阻塞 ``` - Done channel vs context.Context - Done channel 是手动实现退出信号,简单直接 - 新代码优先用 context - 生产者-消费者模式 - 基础版:单生产者、单消费者 - ```go func producer(out chan<- int, count int) { defer close(out) for i := 0; i < count; i++ { out <- i time.Sleep(50 * time.Millisecond) } } func consumer(in <-chan int, results chan<- int) { defer close(results) for v := range in { results <- v * v // 计算平方 } } func main() { jobs := make(chan int, 10) results := make(chan int, 10) go producer(jobs, 5) go consumer(jobs, results) for r := range results { fmt.Println(r) // 0, 1, 4, 9, 16 } } ``` - Worker Pool: 多消费者并发处理 - ```go func workerPool(jobs <-chan int, results chan<- int, workerCount int) { var wg sync.WaitGroup for i := 0; i < workerCount; i++ { wg.Add(1) go func(id int) { defer wg.Done() for job := range jobs { // 模拟处理耗时 time.Sleep(100 * time.Millisecond) result := job * job results <- result fmt.Printf("Worker %d 处理任务 %d -> %d\n", id, job, result) } }(i) } // 所有 worker 完成后关闭 results go func() { wg.Wait() close(results) }() } func main() { jobs := make(chan int, 100) results := make(chan int, 100) // 启动 3 个 worker workerPool(jobs, results, 3) // 发送 9 个任务 for i := 1; i <= 9; i++ { jobs <- i } close(jobs) // 任务发送完毕 // 收集结果 for r := range results { fmt.Println("结果:", r) } } // 3 个 worker 并发处理 9 个任务 // 总耗时 ≈ 3 * 100ms = 300ms(串行需 900ms) ``` - 并发模式进阶:context.Context - 为什么需要 Context - 在 Go 的并发程序里,经常需要跨多个 Goroutine 传递:截止时间、取消信号、请求相关值 - 没有 context 前,这些靠 done channel 手动实现 - context 包是 go 官方对这些需求的统一实现 - context 三大能力 - 取消信号:主动取消或超时自动取消,自动传播给所有子 context。 - 截止时间:知道还剩多少时间,让每个环节合理分配时间。 - 携带值:在调用链上传递请求级别的元数据(traceID、userID 等)。这三点让 context 成为 Go 服务端编程的基础设施。 - Context 树结构 - Context 是一棵树:从根节点出发,不断派生子节点 - 父节点取消时,所有子节点自动取消。这个设计让取消信号天然地从上往下传播 - 示例 - ```go import "context" // 根节点(两种) ctx := context.Background() // 最常用的根,永不取消 ctx := context.TODO() // 占位用,表示「还没想好用哪个」 // 从根派生子节点 ctx1, cancel1 := context.WithCancel(context.Background()) ctx2, cancel2 := context.WithTimeout(ctx1, 5*time.Second) ctx3, cancel3 := context.WithDeadline(ctx2, time.Now().Add(10*time.Second)) ctx4 := context.WithValue(ctx3, "traceID", "abc-123") // 取消任意一个节点,它的所有子节点都会取消 cancel1() // ctx1、ctx2、ctx3、ctx4 全部取消! // 每个 WithXxx 返回的 cancel 函数必须调用 // 否则 context 相关资源永远不会释放(泄漏) defer cancel1() defer cancel2() ``` - 流程 - ``` Background() │ WithCancel() ← cancel() 取消这里 │ WithTimeout() ← 5秒后自动取消(或父被取消) │ WithDeadline() ← 到达截止时间取消(或父被取消) │ WithValue() ← 携带 traceID(不产生取消) 父节点取消 → 所有子孙节点自动取消 ✨ ``` - WithCancel 主动取消 - WithCancel 是最基础的 context 派生方式,返回一个可手动取消的 context - 适用于需要主动控制 goroutine 生命周期的场景 - 示例 - ```go func longRunningTask(ctx context.Context) error { for { select { case <-ctx.Done(): // ctx.Err() 返回取消原因 // context.Canceled:被主动取消 // context.DeadlineExceeded:超时 return ctx.Err() default: // 执行任务的一步 doWork() time.Sleep(100 * time.Millisecond) } } } func main() { ctx, cancel := context.WithCancel(context.Background()) // 启动任务 go func() { err := longRunningTask(ctx) fmt.Println("任务结束:", err) // context canceled }() time.Sleep(1 * time.Second) cancel() // 主动取消,goroutine 收到信号退出 time.Sleep(100 * time.Millisecond) fmt.Println("主程序退出") } ``` - 一个父取消,多个子都取消 - ```go func worker(ctx context.Context, id int) { for { select { case <-ctx.Done(): fmt.Printf("Worker %d 退出: %v\n", id, ctx.Err()) return default: fmt.Printf("Worker %d 工作中\n", id) time.Sleep(200 * time.Millisecond) } } } func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 启动多个 worker,共享同一个 ctx for i := 1; i <= 3; i++ { go worker(ctx, i) } time.Sleep(1 * time.Second) cancel() // 一次取消,所有 worker 同时退出 ✨ time.Sleep(100 * time.Millisecond) } ``` - WithTimeout & WithDeadline - 超时控制是服务端最常见的需求。 - WithTimeout 设置相对时间,WithDeadline 设置绝对时间。 - 两者本质相同,WithTimeout 是对 WithDeadline 的封装。 - WithTimeout 最常用 - ```go // HTTP 请求超时 func callExternalAPI(url string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // 无论成功失败都要调用 req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { // 超时时 err 会包含 "context deadline exceeded" return nil, fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() return io.ReadAll(resp.Body) } // 数据库查询超时 func getUser(db *sql.DB, id int) (*User, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() var u User err := db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = ?", id, ).Scan(&u.ID, &u.Name, &u.Email) return &u, err } ``` - 检查剩余时间 - ```go ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // 检查截止时间 deadline, ok := ctx.Deadline() if ok { remaining := time.Until(deadline) fmt.Printf("还剩 %.1f 秒\n", remaining.Seconds()) } // 合理分配时间 // 总共 5 秒,给第一步 2 秒,给第二步 3 秒 step1Ctx, cancel1 := context.WithTimeout(ctx, 2*time.Second) defer cancel1() doStep1(step1Ctx) // step2 用剩余时间(最多 3 秒,但如果父 ctx 已超时则立即取消) doStep2(ctx) ``` - WithDeadline 绝对时间点 - ```go // 设定必须在某个时间点前完成 deadline := time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC) ctx, cancel := context.WithDeadline(context.Background(), deadline) defer cancel() // 检查是否已超过截止时间 if ctx.Err() == context.DeadlineExceeded { return errors.New("已超过截止时间") } ``` - 超时值怎么定 - 通常根据 SLA(服务等级协议)来定 - 数据库查询:100ms-2s。内部 RPC:200ms-1s - 外部 HTTP:1s-10s - 给每一层调用留足缓冲——如果你的总超时是 5s,不要给每个子操作也设 5s,要合理分配 - 记录 p99 延迟来校准超时值 - HTTP 服务中的 Context - HTTP 服务是 context 最常见的使用场景 - 每个请求都有一个 context,当客户端断开连接时,context 自动取消,让你能及时释放资源 - 示例 - ```go // net/http 标准库:每个 Request 自带 context func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // 获取请求的 context // 客户端断开时,ctx.Done() 会收到信号 result, err := slowDBQuery(ctx) if err != nil { if ctx.Err() != nil { // 客户端已断开,不需要返回错误 return } http.Error(w, err.Error(), 500) return } json.NewEncoder(w).Encode(result) } // 慢查询感知取消 func slowDBQuery(ctx context.Context) ([]Row, error) { rows, err := db.QueryContext(ctx, "SELECT * FROM large_table WHERE complex_condition", ) if err != nil { return nil, err // 包含超时/取消信息 } defer rows.Close() var result []Row for rows.Next() { // 检查 context,避免在取消后继续处理 if ctx.Err() != nil { return nil, ctx.Err() } var row Row rows.Scan(&row.ID, &row.Name) result = append(result, row) } return result, nil } ``` - 完整的中间件链 - ```go func main() { mux := http.NewServeMux() mux.HandleFunc("/api/user", userHandler) // 中间件链:每层都可以增强/修改 context handler := timeoutMiddleware( authMiddleware( traceMiddleware(mux), ), ) http.ListenAndServe(":8080", handler) } // 超时中间件:为每个请求加上统一超时 func timeoutMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() next.ServeHTTP(w, r.WithContext(ctx)) }) } // 认证中间件:注入用户信息 func authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") user, err := validateToken(token) if err != nil { http.Error(w, "未授权", 401) return } ctx := context.WithValue(r.Context(), UserIDKey, user.ID) next.ServeHTTP(w, r.WithContext(ctx)) }) } ``` - context 是第一个参数,不是字段 - Go 官方规范:context 始终作为函数的第一个参数,命名为 ctx - 不要把 context 存在结构体字段里——这会让生命周期管理变得混乱 - 每次函数调用传入,每次可以传入不同的 context,这才是正确的使用姿势 - context 最佳实践 - 规范汇总 - ```go // ✅ 1. ctx 永远是第一个参数 func doSomething(ctx context.Context, param string) error // ✅ 2. 函数入口处检查 ctx func process(ctx context.Context) error { if ctx.Err() != nil { return ctx.Err() } // ... } // ✅ 3. defer cancel() 紧跟在 WithXxx 后面 ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second) defer cancel() // ✅ 4. 传递 ctx,不要存在结构体字段里 type Service struct { db *sql.DB // ❌ ctx context.Context // 不要这样 } func (s *Service) Query(ctx context.Context) error { ... } // ✅ 5. 长循环中定期检查 ctx func processBatch(ctx context.Context, items []Item) error { for i, item := range items { if i%100 == 0 { // 每 100 条检查一次 if err := ctx.Err(); err != nil { return fmt.Errorf("在第 %d 条时被取消: %w", i, err) } } process(item) } return nil } // ✅ 6. 区分 Background 和 TODO // Background:程序入口、main、测试、HTTP 根 handler // TODO:占位用,表示「这里应该有 ctx 但还没传进来」 ``` - 常见错误模式 - ```go // ❌ 1. 忘记调用 cancel ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // 忘写 defer cancel(),定时器资源永远不释放 // ❌ 2. 在 goroutine 里使用已取消的 ctx ctx, cancel := context.WithTimeout(parent, 1*time.Second) defer cancel() go func() { time.Sleep(2 * time.Second) doWork(ctx) // ❌ ctx 已超时,doWork 立即失败 }() // ✅ 应该为新 goroutine 派生新 ctx 或用父 ctx go func() { newCtx, cancel := context.WithTimeout(parent, 5*time.Second) defer cancel() doWork(newCtx) }() // ❌ 3. 用 context.Background() 代替传入的 ctx func handler(ctx context.Context) error { // 丢失了超时/取消信息! return db.QueryContext(context.Background(), "SELECT ...") } // ✅ 始终传递原始 ctx func handler(ctx context.Context) error { return db.QueryContext(ctx, "SELECT ...") } ``` - Context 的使用场景 - 请求进来 → 用 WithTimeout 加超时 → 中间件用 WithValue 注入元数据 → 传给 handler - handler 传给数据库/RPC/外部 HTTP → 客户端断开/超时 → ctx.Done() 触发 → 所有下游自动取消 - 整条链路只用一个 ctx,层层传递,层层受控 - 错误处理哲学 - 错误处理回顾与痛点 - 示例 - ```go // 基础版:丢失了太多信息 func readConfig(path string) error { _, err := os.ReadFile(path) if err != nil { return err // ❌ 调用方不知道是什么操作失败了 } return nil } // 稍好:加了上下文,但还有问题 func readConfig(path string) error { _, err := os.ReadFile(path) if err != nil { return fmt.Errorf("读取配置失败: %v", err) // ❌ 用 %v,丢失了原始 err 类型! } return nil } // ✅ Go 1.13+ 正确做法:用 %w 包装,保留原始 error func readConfig(path string) error { _, err := os.ReadFile(path) if err != nil { return fmt.Errorf("读取配置 %s 失败: %w", path, err) // ✅ %w 包装 } return nil } // 调用方可以拆包检查原始错误 err := readConfig("/etc/app.yaml") if errors.Is(err, os.ErrNotExist) { fmt.Println("配置文件不存在,使用默认配置") } ``` - %v 和 %w 的本质区别 - %v 把 err 转成字符串,原始错误信息被「压扁」成文字,调用方无法再用 errors.Is/As 检查原始类型 - %w 把原始 err 包裹在新错误里,形成错误链,调用方可以用 errors.Is/As 沿链查找 - 只要你想让调用方能检查原始错误,就用 %w - 错误包装与错误链 - 错误链就像俄罗斯套娃:最外层是最高级的上下文描述,最内层是根本原因。 - 每一层都用 %w 包装,形成一条可以追溯的链路。 - 示例 - ```go // 错误链示例:从底层到顶层 // 层级:DB驱动 → DB层 → 业务层 → API层 // ① 最底层:数据库驱动报错 var ErrNotFound = errors.New("记录不存在") // ② DB 层包装 func (r *UserRepo) Get(id int) (*User, error) { // ... return nil, fmt.Errorf("UserRepo.Get id=%d: %w", id, ErrNotFound) } // ③ 业务层包装 func (s *UserService) GetUser(id int) (*User, error) { u, err := s.repo.Get(id) if err != nil { return nil, fmt.Errorf("UserService.GetUser: %w", err) } return u, nil } // ④ API 层包装 func handleGetUser(id int) { _, err := userService.GetUser(id) if err != nil { // err.Error() 输出完整链路: // "UserService.GetUser: UserRepo.Get id=42: 记录不存在" fmt.Println(err) } } ``` - 手动实现 Unwrap (支持自定义类型) - ```go // 自定义错误类型也可以支持错误链 type DBError struct { Op string // 操作名 Err error // 原始错误 } func (e *DBError) Error() string { return fmt.Sprintf("数据库操作 %s 失败: %v", e.Op, e.Err) } // ✨ 实现 Unwrap 方法,支持 errors.Is/As 穿透 func (e *DBError) Unwrap() error { return e.Err } // 使用 err := &DBError{Op: "INSERT", Err: ErrDuplicate} fmt.Println(errors.Is(err, ErrDuplicate)) // true,因为实现了 Unwrap ``` - 错误链的价值 - 一条好的错误链就像一份事故报告:「API层在做什么 → 业务层在做什么 → 数据层在做什么 → 根本原因是什么」 - 出了问题直接看错误信息就能定位,不用加断点。 - 这在微服务日志里尤其有价值。 - errors.Is: 判断错误类型 - errors.Is 用来判断错误链中是否包含某个特定的「哨兵错误」(Sentinel Error)。 - 它会递归 Unwrap 整条链,不像 == 只比较最外层。 - 示例 - ```go // 定义哨兵错误(包级别的可比较 error 值) var ( ErrNotFound = errors.New("未找到") ErrUnauthorized = errors.New("未授权") ErrTimeout = errors.New("超时") ) func doSomething() error { return fmt.Errorf("操作失败: %w", ErrNotFound) // 包装了 ErrNotFound } err := doSomething() // ❌ == 只比较最外层,找不到 fmt.Println(err == ErrNotFound) // false! // ✅ errors.Is 会沿链查找 fmt.Println(errors.Is(err, ErrNotFound)) // true ✨ // 实际使用场景 if errors.Is(err, ErrNotFound) { http.Error(w, "资源不存在", http.StatusNotFound) } else if errors.Is(err, ErrUnauthorized) { http.Error(w, "请先登录", http.StatusUnauthorized) } else if err != nil { http.Error(w, "内部错误", http.StatusInternalServerError) } ``` - 自定义 Is 方法 - ```go // 有时候两个错误「相等」不是值相等,而是逻辑相等 type StatusError struct { Code int } func (e *StatusError) Error() string { return fmt.Sprintf("HTTP %d", e.Code) } // 自定义 Is:只要 Code 相同就认为相等 func (e *StatusError) Is(target error) bool { t, ok := target.(*StatusError) if !ok { return false } return e.Code == t.Code } err := fmt.Errorf("请求失败: %w", &StatusError{Code: 404}) target := &StatusError{Code: 404} fmt.Println(errors.Is(err, target)) // true,通过自定义 Is 比较 ``` - errors.As: 提取错误详情 - errors.Is 只判断「是不是」,errors.As 则「取出来」——把错误链中某个具体类型的错误提取出来,访问它的字段 - 示例 - ```go // 自定义错误类型,携带更多信息 type ValidationError struct { Field string Value any Message string } func (e *ValidationError) Error() string { return fmt.Sprintf("字段 %s(值=%v)验证失败: %s", e.Field, e.Value, e.Message) } func validateAge(age int) error { if age < 0 { return &ValidationError{ Field: "age", Value: age, Message: "不能为负数", } } return nil } func processUser(age int) error { if err := validateAge(age); err != nil { return fmt.Errorf("processUser: %w", err) // 包装了 ValidationError } return nil } // 调用方提取详情 err := processUser(-1) if err != nil { var ve *ValidationError if errors.As(err, &ve) { // ✅ 会沿链查找 *ValidationError 类型 fmt.Printf("字段 %q 的值 %v 有误: %s\n", ve.Field, ve.Value, ve.Message) // 字段 "age" 的值 -1 有误: 不能为负数 } else { fmt.Println("未知错误:", err) } } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/20/20260420233148929.png,310,100) - 包管理与项目结构 - Go Modules 完整工作流 - Go Modules 是 Go 官方的依赖管理系统,从 Go 1.16 起默认开启 - 它解决了早期 GOPATH 时代「所有代码必须在同一目录」的问题,让项目可以放在任意位置,依赖版本清晰可控 - 核心命令 - go mod init <模块名> 初始化模块,创建 go.mod - go get <包>@<版本> 添加或更新依赖 - go mod tidy 清理多余依赖,补全缺失依赖 - go mod download 下载依赖到本地缓存 - go mod vendor 把依赖复制到 vendor 目录 - go list -m all 列出所有直接和间接依赖 - go mod graph 查看依赖关系图 - go mod verify 验证依赖是否被篡改 - 完整操作流程 - ```go # 1. 创建项目 mkdir myapp && cd myapp go mod init github.com/yourname/myapp # 2. 添加依赖 go get github.com/gin-gonic/gin@v1.9.1 go get gorm.io/gorm@latest # 3. 升级依赖 go get github.com/gin-gonic/gin@latest # 升级到最新 go get github.com/gin-gonic/gin@v1.9.0 # 降级到指定版本 # 4. 移除不再使用的依赖(先从代码中删除 import,再运行) go mod tidy # 5. 查看当前所有依赖 go list -m all # 6. 检查可以升级的依赖 go list -m -u all ``` - 模块名应该是什么 - 模块名通常是代码仓库的路径,如 github.com/yourname/myapp - 如果不打算发布为公共包,用任何路径都行,比如 mycompany.com/myapp 或者就叫 myapp - 模块名是唯一标识,同一模块内的包用它作为 import 前缀 - go.mod 和 go.sum - go.mod 和 go.sum 是 Go modules 的两个核心文件 - go.mod 详解 - ```go // go.mod 示例 module github.com/yourname/myapp // ① 模块名 go 1.23 // ② 最低 Go 版本要求 require ( github.com/gin-gonic/gin v1.9.1 // 直接依赖 gorm.io/gorm v1.25.0 // 直接依赖 gorm.io/driver/postgres v1.5.0 // 直接依赖 // indirect 表示间接依赖(你的依赖的依赖) github.com/bytedance/sonic v1.9.1 // indirect golang.org/x/net v0.15.0 // indirect ) // replace:替换依赖源(用本地路径或 fork) replace github.com/some/lib => ../local-lib replace github.com/some/lib => github.com/your-fork/lib v1.0.0 // exclude:排除特定版本 exclude github.com/problematic/pkg v1.2.3 ``` - go.sum 的作用 - ```go # go.sum 存储每个依赖的哈希值,确保安全性 # 格式:模块@版本 算法:哈希值 github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= # 每个依赖有两行: # 1. 源码的哈希(用于验证下载的代码没有被篡改) # 2. go.mod 文件本身的哈希 # ✅ 两个文件都要提交到版本控制 # go.sum 保证了构建的可重现性(reproducibility) ``` - 语义化版本 (SemVer) - ```go # v主版本.次版本.补丁版本 # v1.2.3 # ↑ ↑ ↑ # │ │ └── 补丁:向后兼容的 bug 修复 # │ └───── 次版本:向后兼容的新功能 # └──────── 主版本:不兼容的 API 变更 # Go Modules 的主版本规则: # v0.x.x 和 v1.x.x:模块名不需要版本后缀 # v2.x.x 及以上:模块名必须加 /v2 后缀! # ✅ 正确导入 v2 模块 import "github.com/some/lib/v2" # 特殊版本格式 go get github.com/pkg@latest # 最新稳定版 go get github.com/pkg@main # main 分支的最新 commit go get github.com/pkg@abc1234 # 特定 commit(用伪版本) ``` - MVS: 最小版本选择 - Go Modules 用 MVS(最小版本选择)算法决定使用哪个版本:在所有依赖要求中,选满足条件的最小版本 - 这和 npm/pip 的「最新版本」策略不同,MVS 保证了构建的可重现性——同样的 go.mod 在任何地方都产生相同的构建结果 - 包的可见性规则 - Go 的可见性只有两种:导出(首字母大写)和未导出(首字母小写)。 - 这个规则适用于所有标识符:函数、类型、变量、常量、接口、结构体字段。 - 示例 - ```go // package user // ✅ 导出:包外可见 type User struct { ID int // 导出字段 Name string // 导出字段 email string // ❌ 未导出字段,包外不可见(但 json 可以通过 tag 控制) } func NewUser(name string) *User { ... } // 导出函数 func (u *User) GetEmail() string { ... } // 导出方法 // ❌ 未导出:只能在 user 包内使用 type internalState struct { ... } func validate(u *User) error { ... } var defaultTimeout = 30 * time.Second ``` - internal 包:包级别的访问控制 - ```go myapp/ ├── main.go ├── internal/ # ✨ internal 目录下的包只能被父目录导入 │ ├── auth/ │ │ └── auth.go # 只有 myapp 模块内部可以导入 │ └── config/ │ └── config.go ├── pkg/ # 可以被外部导入的公共包 │ └── middleware/ └── api/ # 规则: # myapp/internal/auth 只能被 myapp/ 下的包导入 # github.com/other/app 无法导入 myapp/internal/auth # 编译时强制执行,违反会报错 ``` - ```go // 实战场景:把不想暴露的实现细节放进 internal // 对外暴露干净的 API,隐藏内部实现 // myapp/internal/db/connection.go package db // 内部数据库实现 func newConnection(dsn string) (*sql.DB, error) { ... } // myapp/service/user.go import "github.com/yourname/myapp/internal/db" // ✅ 同模块内,可以导入 // github.com/other/app/main.go import "github.com/yourname/myapp/internal/db" // ❌ 编译错误!外部无法导入 ``` - internal 的最佳实践 - internal 是 Go 推荐的封装方式 - 把不稳定的、实验性的、或者只供内部使用的代码放进 internal,防止外部依赖你的内部实现 - 这让你能自由重构内部代码而不破坏 API - 大型项目里几乎每个模块都有 internal 目录 - 标准项目结构 - Go 官方没有强制规定项目结构,但社区形成了 Standard Go Project Layout 的约定。 - 了解这个结构能让你快速读懂任何 Go 项目。 - Web 服务项目结构 - ```go │ ├── internal/ # 私有应用代码(不对外暴露) │ ├── handler/ # HTTP handler 层 │ │ ├── user.go │ │ └── auth.go │ ├── service/ # 业务逻辑层 │ │ ├── user.go │ │ └── auth.go │ ├── repository/ # 数据访问层 │ │ ├── user.go │ │ └── user_test.go │ ├── model/ # 数据模型 │ │ └── user.go │ └── config/ # 配置加载 │ └── config.go │ ├── pkg/ # 可以被外部项目使用的公共库 │ ├── middleware/ │ └── utils/ │ ├── api/ # API 定义(OpenAPI/Protobuf) │ └── openapi.yaml │ ├── configs/ # 配置文件模板 │ ├── app.yaml │ └── app.prod.yaml │ ├── migrations/ # 数据库迁移文件 │ ├── 001_create_users.sql │ └── 002_add_email_index.sql │ ├── scripts/ # 构建/部署脚本 │ └── build.sh │ ├── go.mod ├── go.sum ├── Makefile # 常用命令 └── README.md ``` - 关键原则:分层架构 - ```go // 依赖方向:handler → service → repository → model // 每层只能依赖下层,不能反向依赖 // model 层:只有数据结构,零依赖 // internal/model/user.go type User struct { ID int Name string Email string CreatedAt time.Time } // repository 层:只管数据存取,依赖 model // internal/repository/user.go type UserRepository interface { FindByID(ctx context.Context, id int) (*model.User, error) Create(ctx context.Context, u *model.User) error } // service 层:业务逻辑,依赖 repository 接口 // internal/service/user.go type UserService struct { repo repository.UserRepository // 依赖接口,不依赖具体实现 } // handler 层:处理 HTTP 请求,依赖 service 接口 // internal/handler/user.go type UserHandler struct { svc *service.UserService } ``` - import 规范 - Go 对 import 有明确的格式规范,goimports 工具会自动帮你管理。 - 了解规范能让代码更易读,也是 code review 的基本要求。 - Import 分组规范 - ```go package main import ( // 第一组:标准库 "context" "fmt" "net/http" // 第二组:第三方依赖(空行分隔) "github.com/gin-gonic/gin" "gorm.io/gorm" // 第三组:本项目内部包(空行分隔) "github.com/yourname/myapp/internal/config" "github.com/yourname/myapp/internal/handler" ) // goimports 工具会自动排列和分组 // 安装:go install golang.org/x/tools/cmd/goimports@latest // VS Code 保存时自动运行 ``` - 常见 import 技巧 - ```go // 别名:解决包名冲突 import ( "crypto/rand" mrand "math/rand" // 别名,避免和 crypto/rand 冲突 ) // 只执行 init 函数(数据库驱动常用) import ( _ "github.com/lib/pq" // PostgreSQL 驱动,只要 init _ "github.com/mattn/go-sqlite3" // SQLite 驱动 ) // 点 import(不推荐,污染命名空间) import . "fmt" Println("hello") // 不需要 fmt. 前缀了,但容易混淆 // 循环 import 会编译失败: // a 包 import b 包,b 包 import a 包 → 编译错误 // 解决:提取公共代码到第三个包,或重新设计接口 ``` - 循环 import 怎么解决 - 循环 import 通常说明包的职责划分有问题。 - 解决方法 - 把相互依赖的代码提取到新的公共包; - 用接口解耦(A 包定义接口,B 包实现,两者都不直接依赖对方); - 合并两个包(如果它们本来就属于同一个领域)。 - Go Workspace 多模块开发 - Go 1.18 引入了 Workspace 模式,让你可以同时开发多个相互依赖的模块,不需要修改 go.mod 里的 replace 指令。 - ```go # 场景:同时开发 myapp 和它依赖的 mylib workspace/ ├── myapp/ │ ├── go.mod │ └── main.go # 依赖 mylib ├── mylib/ │ ├── go.mod │ └── lib.go # 被 myapp 依赖 └── go.work # Workspace 文件 # 创建 workspace cd workspace go work init ./myapp ./mylib # 添加新模块到 workspace go work use ./another-module # go.work 文件内容: # go 1.23 # # use ( # ./myapp # ./mylib # ) # 现在 myapp 直接使用本地的 mylib, # 不需要 replace 指令,改动立即生效! # 注意:go.work 不要提交到版本控制(加入 .gitignore) ``` - ```go // myapp/main.go import "github.com/yourname/mylib" // 自动使用本地的 mylib func main() { mylib.DoSomething() // 改 mylib 的代码,这里立即反映 } // 优势: // - 不用修改 go.mod 的 replace 指令 // - go.sum 不受影响 // - CI/CD 时不用 workspace(直接用发布的版本) ``` - Workspace vs replace - replace 指令需要修改 go.mod,容易被意外提交到仓库影响其他人。 - Workspace 的 go.work 放在工作区根目录,不进入版本控制,各个模块的 go.mod 保持干净。 - 新项目推荐用 Workspace,老项目的 replace 也可以逐步迁移过来。 - 构建与工具链 - go build 常用选项 - ```bash # 基础构建 go build ./... # 构建所有包(检查编译错误) go build -o bin/server ./cmd/server # 指定输出文件 # 交叉编译(为其他平台构建) GOOS=linux GOARCH=amd64 go build -o server-linux ./cmd/server GOOS=darwin GOARCH=arm64 go build -o server-mac ./cmd/server GOOS=windows GOARCH=amd64 go build -o server.exe ./cmd/server # 常用 GOOS:linux, darwin, windows # 常用 GOARCH:amd64 (x86-64), arm64 (M1/M2 Mac, AWS Graviton) # 注入版本信息(生产必备) go build -ldflags="-X main.Version=v1.2.3 -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" ./cmd/server # 减小二进制体积 go build -ldflags="-s -w" ./cmd/server # 去掉调试信息,体积减少约 30% # 启用竞态检测(开发阶段) go build -race ./... ``` - Build Tags (条件编译) - ```go // 文件只在 Linux 下编译 //go:build linux package main // 文件只在测试时编译 //go:build integration // 多条件 //go:build linux && amd64 // 自定义 tag(在 go build -tags 指定) //go:build production // 运行:go build -tags production ./... // 实战场景:不同环境的配置 // config_dev.go //go:build !production // config_prod.go //go:build production ``` - Makefile: 常用命令封装 - ```go # Makefile 示例 .PHONY: build run test lint clean # 变量 APP=myapp VERSION=$(shell git describe --tags --always --dirty) BUILD_TIME=$(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -s -w" build: go build $(LDFLAGS) -o bin/$(APP) ./cmd/server run: go run ./cmd/server test: go test -race -cover ./... test-integration: go test -tags integration -race ./... lint: golangci-lint run ./... clean: rm -rf bin/ docker: docker build -t $(APP):$(VERSION) . # 使用:make build / make test / make lint ``` - 代理与私有模块 - GOPROXY:模块代理 - ```go # 查看当前代理设置 go env GOPROXY # 默认值:https://proxy.golang.org,direct # 中国大陆推荐: go env -w GOPROXY=https://goproxy.cn,direct # 格式:代理1,代理2,...,direct # direct 表示直连源仓库(代理失败时兜底) # off 表示完全禁止下载 # GONOSUMCHECK:跳过校验和检查 go env -w GONOSUMCHECK="*.internal.company.com" # GONOSUMDB:跳过 sum 数据库 go env -w GONOSUMDB="*.internal.company.com" ``` - 私有模块配置 - ```go # GOPRIVATE:私有模块不走代理也不走 sum 数据库 go env -w GOPRIVATE="*.corp.example.com,github.com/mycompany/*" # 配置 git 认证(访问私有仓库) git config --global url."https://token:PASSWORD@github.com/mycompany/".insteadOf "https://github.com/mycompany/" # 或者用 SSH git config --global url."git@github.com:mycompany/".insteadOf "https://github.com/mycompany/" # 公司内部代理服务器(Athens/goproxy.io) go env -w GOPROXY="https://goproxy.company.com,https://proxy.golang.org,direct" go env -w GOPRIVATE="*.company.com" ``` - 测试与基准测试 - Go Test 框架基础 - Go 内置了完整的测试框架,不需要任何第三方库。 - 测试文件以 _test.go 结尾,测试函数以 Test 开头,接受 *testing.T 参数。这三条规则,就是 Go 测试的全部入场门票。 - 示例 - ```go // math/add.go package math func Add(a, b int) int { return a + b } func Divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("除数不能为零") } return a / b, nil } // math/add_test.go(同一个包,_test.go 结尾) package math import "testing" func TestAdd(t *testing.T) { result := Add(1, 2) if result != 3 { t.Errorf("Add(1, 2) = %d,期望 3", result) } } func TestDivide(t *testing.T) { result, err := Divide(10, 2) if err != nil { t.Fatalf("意外错误: %v", err) } if result != 5 { t.Errorf("Divide(10, 2) = %f,期望 5", result) } } ``` - 运行测试的常用命令 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/21/20260421000815190.png,310,160) - testing.T 的核心方法 - ```go // 测试失败但继续执行 t.Errorf("期望 %d,实际 %d", expected, actual) t.Error("发现问题") // 测试失败并立即停止(Fatal = Error + runtime.Goexit) t.Fatalf("致命错误: %v", err) t.Fatal("不可继续") // 仅打印日志(不影响测试结果) t.Logf("调试信息: %v", value) t.Log("这里到了") // 跳过测试 t.Skip("跳过原因:需要数据库") t.Skipf("跳过:CI 环境不支持 %s", feature) // 标记为并行测试 t.Parallel() // 子测试(下一节讲) t.Run("子测试名", func(t *testing.T) { ... }) ``` - 表驱动测试 - 表驱动测试是 Go 最推荐的测试写法。 - 把所有测试用例放进一个切片,用循环执行,消除重复代码,添加新用例只需加一行。 - 这是 Go 测试的标准惯用法。 - 示例 - ```go func TestAdd(t *testing.T) { // 定义测试用例表 tests := []struct { name string // 用例名(出错时显示) a, b int expected int }{ {"正数相加", 1, 2, 3}, {"负数相加", -1, -2, -3}, {"正负相加", 5, -3, 2}, {"零值", 0, 0, 0}, {"大数", 1000000, 2000000, 3000000}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // ✨ 子测试 result := Add(tt.a, tt.b) if result != tt.expected { t.Errorf("Add(%d, %d) = %d,期望 %d", tt.a, tt.b, result, tt.expected) } }) } } ``` - 测试有 error 返回的函数 - ```go func TestDivide(t *testing.T) { tests := []struct { name string a, b float64 expected float64 wantErr bool // 是否期望出错 errMsg string // 期望的错误信息(可选) }{ {"正常除法", 10, 2, 5, false, ""}, {"除以1", 9, 1, 9, false, ""}, {"除以零", 10, 0, 0, true, "除数不能为零"}, {"负数除法", -10, 2, -5, false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := Divide(tt.a, tt.b) // 检查是否符合预期的错误行为 if tt.wantErr { if err == nil { t.Error("期望有错误,但没有") } if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("错误信息 %q 不包含 %q", err.Error(), tt.errMsg) } return // 有预期错误,不检查结果 } if err != nil { t.Fatalf("意外错误: %v", err) } if math.Abs(result-tt.expected) > 1e-9 { t.Errorf("Divide(%.2f, %.2f) = %.2f,期望 %.2f", tt.a, tt.b, result, tt.expected) } }) } } ``` - t.Run 子测试的好处 - t.Run 为每个用例创建独立的子测试 - 出错时精确定位是哪个用例失败(如 TestDivide/除以零) - 可以单独运行某个子测试 go test -run TestDivide/除以零 - 支持 t.Parallel() 并行运行各个子测试 - 测试前后的准备与清理 - 很多测试需要初始化环境(启动数据库、创建临时文件)和清理(关闭连接、删除文件)。Go 提供了几种机制来处理这些需求。 - TestMain: 包级别的 setup/teardown - ```go // 如果定义了 TestMain,go test 会优先调用它 // 可以在这里做全局初始化 func TestMain(m *testing.M) { // ─── Setup ─── fmt.Println("全局初始化:启动测试数据库...") db, err := setupTestDB() if err != nil { log.Fatalf("数据库初始化失败: %v", err) } // ─── 运行所有测试 ─── exitCode := m.Run() // ─── Teardown ─── fmt.Println("全局清理:关闭测试数据库...") db.Close() cleanupTestData() // 必须调用 os.Exit,否则 defer 不会执行 os.Exit(exitCode) } ``` - t.Cleanup: 函数级别的清理 - ```go func TestWriteFile(t *testing.T) { // 创建临时目录 tmpDir := t.TempDir() // ✨ 测试结束自动删除! // t.Cleanup 注册清理函数(类似 defer,但属于测试框架) tmpFile := filepath.Join(tmpDir, "test.txt") t.Cleanup(func() { os.Remove(tmpFile) t.Log("清理临时文件") }) // 测试逻辑 err := os.WriteFile(tmpFile, []byte("hello"), 0644) if err != nil { t.Fatal(err) } content, err := os.ReadFile(tmpFile) if err != nil { t.Fatal(err) } if string(content) != "hello" { t.Errorf("内容不匹配") } } // t.TempDir() 创建临时目录,测试结束(包括失败)后自动删除 // 比手写 defer os.RemoveAll 更安全 ``` - 辅助函数: Helper - ```go // 当多个测试有重复逻辑时,提取为 helper 函数 // ✨ 注意 t.Helper():让错误报告指向调用 helper 的地方,而不是 helper 内部 func assertNoError(t *testing.T, err error) { t.Helper() // 声明这是 helper 函数 if err != nil { t.Fatalf("意外错误: %v", err) } } func assertEqual(t *testing.T, got, want interface{}) { t.Helper() if got != want { t.Errorf("期望 %v,实际 %v", want, got) } } // 使用 func TestSomething(t *testing.T) { result, err := doWork() assertNoError(t, err) // 出错时报告这一行,不是 assertNoError 内部 assertEqual(t, result, 42) // 同上 } ``` - testify 更好的断言库 - 标准库的 testing 功能够用,但写法有点啰嗦。 - testify 是 Go 生态最流行的测试辅助库,提供更简洁的断言、Mock 和测试套件支持。 - 安装:go get github.com/stretchr/testify - assert vs require - ```go import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWithTestify(t *testing.T) { // assert:失败后继续执行(类似 t.Errorf) assert.Equal(t, 3, Add(1, 2)) assert.NotNil(t, someValue) assert.NoError(t, err) assert.Contains(t, "hello world", "world") assert.True(t, isValid) assert.Len(t, slice, 3) // require:失败后立即停止(类似 t.Fatalf) require.NoError(t, err) // err 不为 nil 就立即停止 require.NotNil(t, result) // 后续代码依赖 result 不为 nil // 带自定义消息 assert.Equal(t, want, got, "用户列表长度应该相等") // 错误断言 assert.Error(t, err) // 期望有 error assert.ErrorIs(t, err, ErrNotFound) // 等价于 errors.Is assert.ErrorAs(t, err, &ValidationError{}) // 等价于 errors.As } ``` - 常用断言 - ```go // 相等性 assert.Equal(t, expected, actual) // == assert.NotEqual(t, expected, actual) // != assert.EqualValues(t, expected, actual) // 类型不同但值相等 // nil 检查 assert.Nil(t, val) assert.NotNil(t, val) // 布尔 assert.True(t, condition) assert.False(t, condition) // 错误 assert.NoError(t, err) assert.Error(t, err) assert.ErrorIs(t, err, target) // 字符串/切片 assert.Contains(t, "hello", "ell") assert.Contains(t, []int{1,2,3}, 2) assert.Len(t, slice, 3) assert.Empty(t, slice) assert.NotEmpty(t, slice) // 数值比较 assert.Greater(t, 5, 3) assert.GreaterOrEqual(t, 5, 5) assert.InDelta(t, 1.5, 1.500001, 0.001) // 浮点近似相等 // 结构体(深度相等) assert.Equal(t, expected, actual) // 自动深度比较 // 或用 ObjectsAreEqual: require.True(t, reflect.DeepEqual(want, got)) ``` - assert 和 require 的选择原则 - 当后续的测试逻辑依赖当前断言成立时(比如 err == nil 才能用 result),用 require。 - 否则用 assert,收集更多失败信息。一个简单口诀:「之后还要用这个值,就 require;不用就 assert」。 - Mock 测试 - Mock 是测试中最重要的技术之一。当你的代码依赖外部服务(数据库、HTTP API、邮件服务),测试时用 Mock 替换这些依赖,让测试快速、可靠、独立。 - 手写 Mock - ```go // 被测代码依赖接口(Day 12 项目结构的价值体现) type UserRepository interface { FindByID(ctx context.Context, id int) (*User, error) Create(ctx context.Context, u *User) error } type UserService struct { repo UserRepository } // 手写 Mock:直接实现接口 type MockUserRepo struct { users map[int]*User nextID int // 可以记录调用次数、参数等 CreateCalled int FindCalled int } func (m *MockUserRepo) FindByID(_ context.Context, id int) (*User, error) { m.FindCalled++ u, ok := m.users[id] if !ok { return nil, ErrNotFound } return u, nil } func (m *MockUserRepo) Create(_ context.Context, u *User) error { m.CreateCalled++ u.ID = m.nextID m.users[u.ID] = u m.nextID++ return nil } // 测试时注入 Mock func TestUserService_Register(t *testing.T) { mockRepo := &MockUserRepo{ users: make(map[int]*User), nextID: 1, } svc := &UserService{repo: mockRepo} ctx := context.Background() u, err := svc.Register(ctx, "Alice", "alice@example.com") require.NoError(t, err) assert.Equal(t, "Alice", u.Name) assert.Equal(t, 1, mockRepo.CreateCalled) // 验证调用次数 } ``` - testify/mock:功能更强的 mock - ```go import "github.com/stretchr/testify/mock" // 用 testify/mock 生成 Mock type MockUserRepo struct { mock.Mock // 嵌入 mock.Mock } func (m *MockUserRepo) FindByID(ctx context.Context, id int) (*User, error) { args := m.Called(ctx, id) // 记录调用,返回预设值 if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*User), args.Error(1) } func (m *MockUserRepo) Create(ctx context.Context, u *User) error { args := m.Called(ctx, u) return args.Error(0) } // 使用:设置期望 func TestWithMock(t *testing.T) { mockRepo := new(MockUserRepo) expectedUser := &User{ID: 1, Name: "Alice"} // 设置期望:当 FindByID 被用 id=1 调用时,返回 expectedUser mockRepo.On("FindByID", mock.Anything, 1).Return(expectedUser, nil) // 设置期望:Create 被调用时成功 mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*User")).Return(nil) svc := &UserService{repo: mockRepo} u, err := svc.GetUser(context.Background(), 1) require.NoError(t, err) assert.Equal(t, "Alice", u.Name) // 验证所有期望都被满足 mockRepo.AssertExpectations(t) } ``` - Mock 的正确姿势 - Mock 应该只 Mock 你控制不了的东西:数据库、HTTP 接口、时间、随机数。 - 不要 Mock 你自己写的业务逻辑——那会让测试失去意义。 - 最好的测试策略是:单元测试 Mock 外部依赖,集成测试用真实的基础设施(用 testcontainers)。 - 基准测试 - 基准测试用来测量代码的性能。函数以 Benchmark 开头,接受 *testing.B 参数。Go 会自动调整循环次数,让结果更准确。 - 示例 - ```go func BenchmarkAdd(b *testing.B) { // b.N 由 go test 自动决定(通常是几百万次) for i := 0; i < b.N; i++ { Add(1, 2) } } // 运行基准测试 // go test -bench=. -benchmem ./... // -bench=. 运行所有 Benchmark // -benchmem 同时显示内存分配 // -benchtime=5s 指定运行时间 // -count=3 运行 3 次取平均 // 示例输出: // BenchmarkAdd-8 1000000000 0.2891 ns/op 0 B/op 0 allocs/op // ↑执行次数 ↑每次耗时 ↑内存 ↑分配次数 ``` - 比较两种实现的性能 - ```go // 对比字符串拼接:+ vs strings.Builder func BenchmarkStringConcat(b *testing.B) { words := []string{"hello", "world", "go", "test"} b.Run("用+拼接", func(b *testing.B) { for i := 0; i < b.N; i++ { result := "" for _, w := range words { result += w // 每次拼接都分配新内存 } _ = result } }) b.Run("用Builder", func(b *testing.B) { for i := 0; i < b.N; i++ { var sb strings.Builder for _, w := range words { sb.WriteString(w) // 预分配,减少内存分配 } _ = sb.String() } }) } // 输出示例: // BenchmarkStringConcat/用+拼接-8 3000000 450 ns/op 128 B/op 4 allocs/op // BenchmarkStringConcat/用Builder-8 10000000 120 ns/op 64 B/op 1 allocs/op // Builder 快 3.7 倍,内存分配少 4 倍 ``` - b.ResetTimer 和 b.StopTimer - ```go func BenchmarkWithSetup(b *testing.B) { // 做一些耗时的初始化(不计入基准) data := generateLargeTestData() b.ResetTimer() // 重置计时器,排除初始化时间 for i := 0; i < b.N; i++ { b.StopTimer() // 暂停计时 input := prepareInput(data) // 每次迭代的准备工作 b.StartTimer() // 恢复计时 processData(input) // 只测量这部分 } } ``` - 示例测试 - Example 函数有双重作用:既是可运行的测试,也是文档。 - go doc 命令会显示 Example 函数,godoc 网站也会展示它们。 - 示例 - ```go // 函数名:Example + 函数名 func ExampleAdd() { result := Add(1, 2) fmt.Println(result) // Output: 3 ← go test 会验证输出与这里一致 } func ExampleDivide() { result, err := Divide(10, 2) fmt.Println(result, err) // Output: 5 <nil> } // 无法预测输出顺序时用 Unordered output func ExamplePrintMap() { m := map[string]int{"a": 1, "b": 2} for k, v := range m { fmt.Printf("%s: %d\n", k, v) } // Unordered output: // a: 1 // b: 2 } // 类型/方法的 Example func ExampleUser_GetName() { u := User{Name: "Alice"} fmt.Println(u.GetName()) // Output: Alice } ``` - Example 是活的文档 - Example 函数最大的优点是:它会被 go test 执行,如果输出和注释不匹配就会失败。 - 这意味着你的文档示例代码永远是正确的——代码改了但没更新文档,测试会提醒你。 - 这是 Go 「文档即测试」哲学的体现。 - 测试覆盖率 - 覆盖率不是越高越好,但低覆盖率通常意味着关键路径没有被测试。 - Go 内置了覆盖率工具,不需要额外安装。 - 示例 - ```go # 查看覆盖率(百分比) go test -cover ./... # ok myapp/internal/service coverage: 78.5% of statements # 生成覆盖率文件 go test -coverprofile=coverage.out ./... # 用浏览器查看哪些行被覆盖(绿色),哪些没有(红色) go tool cover -html=coverage.out # 按函数查看覆盖率 go tool cover -func=coverage.out # myapp/internal/service/user.go:Register 100.0% # myapp/internal/service/user.go:GetUser 75.0% # total: 82.3% # 生成覆盖率徽章(常用于 README) # 需要第三方工具,如 goveralls、codecov ``` - 覆盖率的合理目标 - ```go // 什么应该测? // ✅ 核心业务逻辑(service 层) // ✅ 复杂算法 // ✅ 错误处理路径 // ✅ 边界条件(空值、极值) // 什么可以不测/难测? // ❌ main 函数 // ❌ 简单的 getter/setter // ❌ 第三方库(已有自己的测试) // ❌ 纯配置代码 // 行业参考值: // 核心业务代码:80% 以上 // 整体项目:60-70% 是健康的 // 追求 100% 反而可能浪费时间在无价值的测试上 // 一个常见的 .golangci.yml 配置 // testpackage: 是否要求测试在独立的 _test 包 // 建议:核心包用 package xxx_test,确保测试只用公开 API ``` - 测试覆盖率的正确理解 - 覆盖率是工具,不是目标。 - 80% 覆盖率但测试都是走正常路径,不如 60% 但每个边界条件都测到。 - 真正重要的是:关键业务路径有测试、错误路径有测试、修改代码后测试能告诉你哪里坏了。 - 泛型 - Go 1.18 引入泛型之前,想写「适用于任意类型」的函数有两种选择:为每种类型写重复代码,或者用 any 丢失类型安全。 - 泛型解决了这个两难困境——既通用,又类型安全。 - 没有泛型的问题 - ```go // ❌ 方案一:为每种类型写重复代码 func SumInt(nums []int) int { total := 0 for _, n := range nums { total += n } return total } func SumFloat64(nums []float64) float64 { total := 0.0 for _, n := range nums { total += n } return total } // 如果要支持 int32、int64、float32... 噩梦 // ❌ 方案二:用 any,丢失类型安全 func Sum(nums []any) any { // 怎么相加?any 不支持 + 运算符 // 必须类型断言,运行时才发现错误 } // ✅ 泛型:一次编写,类型安全,适用所有数字类型 func Sum[T int | int64 | float64](nums []T) T { var total T for _, n := range nums { total += n } return total } Sum([]int{1, 2, 3}) // 6 Sum([]float64{1.1, 2.2}) // 3.3 Sum([]string{"a", "b"}) // ❌ 编译错误:string 不满足约束 ``` - 泛型 vs any 的本质区别 - any 在运行时丢失类型信息,错误只能在运行时发现。 - 泛型在编译时确定类型,错误在编译时发现,且不需要类型断言,性能也更好(零运行时开销)。 - 泛型是编译器的能力,any 是程序员的妥协。 - 类型参数基础语法 - 泛型通过「类型参数」实现。 - 类型参数放在方括号 [] 里,紧跟在函数名或类型名后面。 - 每个类型参数都有一个「约束」,限制它能接受哪些类型。 - 泛型函数 - ```go // func 函数名[类型参数 约束](普通参数) 返回值 func Map[T, R any](slice []T, fn func(T) R) []R { result := make([]R, len(slice)) for i, v := range slice { result[i] = fn(v) } return result } // 使用:Go 能自动推断类型参数,通常不用显式写 nums := Map([]int{1, 2, 3}, func(n int) int { return n * 2 }) // nums = [2, 4, 6] strs := Map([]int{1, 2, 3}, func(n int) string { return fmt.Sprintf("No.%d", n) }) // strs = ["No.1", "No.2", "No.3"] // 显式指定类型参数(推断失败时) result := Map[int, string]([]int{1, 2, 3}, func(n int) string { return strconv.Itoa(n) }) ``` - 泛型类型 (结构体) - ```go // type 类型名[类型参数 约束] struct { ... } type Stack[T any] struct { items []T } func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } func (s *Stack[T]) Pop() (T, bool) { if len(s.items) == 0 { var zero T // ✨ 零值:泛型类型的默认值 return zero, false } top := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return top, true } func (s *Stack[T]) Peek() (T, bool) { if len(s.items) == 0 { var zero T return zero, false } return s.items[len(s.items)-1], true } func (s *Stack[T]) Len() int { return len(s.items) } // 使用 intStack := Stack[int]{} // 整数栈 intStack.Push(1) intStack.Push(2) v, ok := intStack.Pop() // v=2, ok=true strStack := Stack[string]{} // 字符串栈 strStack.Push("hello") ``` - 类型泛型 - 约束定义了类型参数能接受哪些类型。 - 约束本质上是接口——只不过这个接口里可以包含「类型集合」(type set),不只是方法列表。 - 内置约束 - ```go // any:没有限制(等价于 interface{}) func Identity[T any](v T) T { return v } // comparable:可以用 == 和 != 比较的类型 // int, string, bool, 指针, channel, 不含 slice/map/func 的 struct 等 func Contains[T comparable](slice []T, target T) bool { for _, v := range slice { if v == target { return true } } return false } Contains([]int{1, 2, 3}, 2) // true Contains([]string{"a", "b"}, "c") // false // 注意:slice、map、func 不是 comparable // Contains([][]int{{1,2}}, []int{1,2}) // ❌ 编译错误 ``` - contraints 包的内置约束 - ```go import "golang.org/x/exp/constraints" // 或 Go 1.21+ 用标准库 cmp 包 // constraints.Ordered:支持 < > <= >= 的类型 // int, int8, int16, int32, int64 // uint, uint8, ..., uintptr // float32, float64 // string func Min[T constraints.Ordered](a, b T) T { if a < b { return a } return b } func Max[T constraints.Ordered](a, b T) T { if a > b { return a } return b } func Clamp[T constraints.Ordered](val, lo, hi T) T { return Max(lo, Min(val, hi)) } Min(3, 5) // 3 Min("apple", "banana") // "apple"(字符串也可以比较) Clamp(15, 0, 10) // 10(限制在 [0, 10] 范围内) ``` - 自定义约束 - ```go // 约束是接口,可以包含: // 1. 方法列表 // 2. 类型集合(~ 表示底层类型) // 类型集合:只允许这些具体类型 type Integer interface { int | int8 | int16 | int32 | int64 } // ~ 表示底层类型(包含所有基于这些类型的自定义类型) type Number interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 } func Sum[T Number](nums []T) T { var total T for _, n := range nums { total += n } return total } // ~ 的重要性: type MyInt int // 基于 int 的自定义类型 Sum([]MyInt{1, 2, 3}) // ✅ ~int 包含 MyInt // 不加 ~ 的话,Sum[int | float64] 就不接受 MyInt // 方法 + 类型混合约束 type Stringer interface { ~string | ~[]byte // 类型集合 String() string // 方法要求 } ``` - ~ 操作符的含义 - ~T 表示「底层类型为 T 的所有类型」 - type MyInt int 的底层类型是 int,所以 ~int 包含 MyInt - 不加 ~ 则只接受 int 本身,不接受基于 int 的自定义类型 - 实际项目中,数值约束通常都要加 ~ - 实用泛型函数库 - 掌握泛型最好的方式是实现那些经典的高阶函数。 - Go 1.21 的 slices 和 maps 标准包已经内置了很多。 - 切片操作三件套 - ```go // Map:对每个元素应用函数 func Map[T, R any](s []T, fn func(T) R) []R { result := make([]R, len(s)) for i, v := range s { result[i] = fn(v) } return result } // Filter:保留满足条件的元素 func Filter[T any](s []T, keep func(T) bool) []T { result := make([]T, 0, len(s)) for _, v := range s { if keep(v) { result = append(result, v) } } return result } // Reduce:累积计算 func Reduce[T, R any](s []T, init R, fn func(R, T) R) R { result := init for _, v := range s { result = fn(result, v) } return result } // 使用:组合成管道 nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} result := Reduce( Map( Filter(nums, func(n int) bool { return n%2 == 0 }), func(n int) int { return n * n }, ), 0, func(acc, n int) int { return acc + n }, ) // 偶数的平方和:4+16+36+64+100 = 220 ``` - 通用工具函数 - ```go // Contains:检查元素是否在切片中 func Contains[T comparable](s []T, target T) bool { for _, v := range s { if v == target { return true } } return false } // Index:查找元素的索引,-1 表示不存在 func Index[T comparable](s []T, target T) int { for i, v := range s { if v == target { return i } } return -1 } // Unique:去重,保持顺序 func Unique[T comparable](s []T) []T { seen := make(map[T]struct{}) result := make([]T, 0, len(s)) for _, v := range s { if _, ok := seen[v]; !ok { seen[v] = struct{}{} result = append(result, v) } } return result } // Keys:取出 map 的所有 key func Keys[K comparable, V any](m map[K]V) []K { keys := make([]K, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // Values:取出 map 的所有 value func Values[K comparable, V any](m map[K]V) []V { vals := make([]V, 0, len(m)) for _, v := range m { vals = append(vals, v) } return vals } // GroupBy:按 key 函数分组 func GroupBy[T any, K comparable](s []T, key func(T) K) map[K][]T { groups := make(map[K][]T) for _, v := range s { k := key(v) groups[k] = append(groups[k], v) } return groups } // 使用 GroupBy type Person struct{ Name, City string } people := []Person{ {"Alice", "Tokyo"}, {"Bob", "Tokyo"}, {"Charlie", "Osaka"}, } byCity := GroupBy(people, func(p Person) string { return p.City }) // byCity["Tokyo"] = [{Alice Tokyo} {Bob Tokyo}] ``` - 泛型数据结构 - 泛型最大的价值之一是可以写真正通用的数据结构。下面实现几个常用的容器。 - 泛型 Set (集合) - ```go type Set[T comparable] struct { items map[T]struct{} } func NewSet[T comparable](items ...T) *Set[T] { s := &Set[T]{items: make(map[T]struct{})} for _, item := range items { s.Add(item) } return s } func (s *Set[T]) Add(item T) { s.items[item] = struct{}{} } func (s *Set[T]) Remove(item T) { delete(s.items, item) } func (s *Set[T]) Contains(item T) bool { _, ok := s.items[item] return ok } func (s *Set[T]) Len() int { return len(s.items) } // 集合运算 func (s *Set[T]) Union(other *Set[T]) *Set[T] { result := NewSet[T]() for k := range s.items { result.Add(k) } for k := range other.items { result.Add(k) } return result } func (s *Set[T]) Intersection(other *Set[T]) *Set[T] { result := NewSet[T]() for k := range s.items { if other.Contains(k) { result.Add(k) } } return result } // 使用 a := NewSet(1, 2, 3, 4) b := NewSet(3, 4, 5, 6) union := a.Union(b) // {1,2,3,4,5,6} inter := a.Intersection(b) // {3,4} ``` - 泛型 Optional (可选值) - ```go // 类似 Rust 的 Option<T>,比返回 (T, bool) 更语义化 type Optional[T any] struct { value T hasValue bool } func Some[T any](v T) Optional[T] { return Optional[T]{value: v, hasValue: true} } func None[T any]() Optional[T] { return Optional[T]{} } func (o Optional[T]) IsPresent() bool { return o.hasValue } func (o Optional[T]) Get() (T, bool) { return o.value, o.hasValue } func (o Optional[T]) OrElse(defaultVal T) T { if o.hasValue { return o.value } return defaultVal } func (o Optional[T]) Map(fn func(T) T) Optional[T] { if !o.hasValue { return None[T]() } return Some(fn(o.value)) } // 使用 func findUser(id int) Optional[User] { user, ok := db[id] if !ok { return None[User]() } return Some(user) } result := findUser(42). Map(func(u User) User { u.Name = strings.ToUpper(u.Name); return u }). OrElse(User{Name: "anonymous"}) ``` - 标准库的泛型包 - Go 1.21 起标准库加入了几个泛型包,直接使用它们比自己实现更好。 - slice - ```go import "slices" nums := []int{3, 1, 4, 1, 5, 9, 2, 6} // 排序 slices.Sort(nums) // [1 1 2 3 4 5 6 9] slices.SortFunc(nums, func(a, b int) int { return b - a // 降序 }) // 查找 idx := slices.Index(nums, 5) // 找到返回索引,否则 -1 ok := slices.Contains(nums, 5) // true // 修改 slices.Reverse(nums) // 原地反转 unique := slices.Compact(nums) // 去除连续重复 nums = slices.Delete(nums, 2, 4) // 删除 [2,4) 区间 nums = slices.Insert(nums, 1, 99, 88) // 在索引 1 处插入 // 复制(独立副本) clone := slices.Clone(nums) // 比较 equal := slices.Equal([]int{1,2}, []int{1,2}) // true ``` - maps 包 - ```go import "maps" m := map[string]int{"a": 1, "b": 2, "c": 3} // 复制 clone := maps.Clone(m) // 删除满足条件的 key maps.DeleteFunc(m, func(k string, v int) bool { return v < 2 // 删除 value < 2 的条目 }) // 判断是否相等 eq := maps.Equal(m, map[string]int{"b": 2, "c": 3}) // 遍历(Go 1.23+ range over func) for k, v := range maps.All(m) { fmt.Printf("%s: %d\n", k, v) } ``` - cmp 包 - ```go import "cmp" // cmp.Ordered:可排序类型的约束(等价于 constraints.Ordered) func Min[T cmp.Ordered](a, b T) T { return min(a, b) // Go 1.21+ 内置了 min/max 函数 } // cmp.Compare:三路比较(-1, 0, 1) cmp.Compare(1, 2) // -1 cmp.Compare(2, 2) // 0 cmp.Compare(3, 2) // 1 cmp.Compare("a", "b") // -1(字符串也支持) // 配合 slices.SortFunc 使用 type User struct{ Name string; Age int } users := []User{{"Alice", 30}, {"Bob", 25}, {"Charlie", 35}} slices.SortFunc(users, func(a, b User) int { return cmp.Compare(a.Age, b.Age) // 按年龄排序 }) // Go 1.21+ 内置 min/max(泛型版本) fmt.Println(min(3, 5)) // 3 fmt.Println(max("a", "b")) // b fmt.Println(min(1, 2, 3, 4)) // 1(支持多参数) ``` - 泛型的限制与最佳实践 - 当前的限制 - ```go // ❌ 限制一:不能在方法上定义新的类型参数 type Foo struct{} func (f Foo) Bar[T any](v T) {} // ❌ 编译错误!方法不能有类型参数 // ✅ 解决:把类型参数放到结构体上 type Foo[T any] struct{} func (f Foo[T]) Bar(v T) {} // ❌ 限制二:类型参数不能用作类型断言的目标 func wrong[T any](v any) T { return v.(T) // ❌ 编译错误 } // ✅ 解决:用 switch 或 reflect func convert[T any](v any) (T, bool) { result, ok := v.(T) return result, ok } // ❌ 限制三:泛型类型不能直接比较(如果类型参数不是 comparable) func Equal[T any](a, b T) bool { return a == b // ❌ 如果 T 是 any,不能用 == } // ✅ 解决:约束为 comparable func Equal[T comparable](a, b T) bool { return a == b } // ❌ 限制四:不支持特化(不能为特定类型提供不同实现) // Go 泛型目前没有 C++ 那样的模板特化 ``` - 什么时候用泛型,什么时候不用 - ```go // ✅ 适合用泛型: // 1. 通用容器(Stack, Queue, Set, Tree) // 2. 算法函数(Sort, Map, Filter, Reduce) // 3. 工具函数(Min, Max, Contains, GroupBy) // 4. 类型安全的包装(Optional, Result) // ❌ 不适合用泛型: // 1. 单一类型的业务逻辑(用具体类型更清晰) // 2. 接口能解决的多态问题(优先接口) // 3. 能用 any 且不需要类型安全的场景 // 判断口诀: // 「如果写了两个只有类型不同的函数,考虑泛型」 // 「如果行为因类型不同而不同,用接口」 // 反面教材:泛型滥用 type GenericService[T any] struct { // ❌ 过度泛型 repo Repository[T] } // 如果 T 只有一种可能,直接用具体类型 // 正确:当真正需要通用性时才用 type Cache[K comparable, V any] struct { // ✅ 合理 mu sync.RWMutex data map[K]V ttl time.Duration } ``` Web 开发实战 - Web 开发实战 - net/http 标准库 - net/http 核心概念 - Go 的 net/http 标准库是同类中最强大的之一——不需要任何框架就能构建生产级 HTTP 服务器。 - 理解标准库是学好 Gin/Echo 等框架的基础,因为框架只是在它之上加了一层糖衣。 - HTTP 服务器示例 - ```go package main import ( "fmt" "net/http" ) func main() { // 注册路由处理器 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, Go Web!") }) http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "pong") }) // 启动服务器(阻塞) fmt.Println("服务器启动在 :8080") if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) } } // curl http://localhost:8080/ // → Hello, Go Web! // curl http://localhost:8080/ping // → pong ``` - http.Handler 接口:一切方法的核心 - ```go // Handler 接口只有一个方法 // type Handler interface { // ServeHTTP(ResponseWriter, *Request) // } // ResponseWriter:写响应 // *Request:读请求 // 实现 Handler 接口的三种方式: // 方式一:函数(用 http.HandlerFunc 适配) func myHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "Hello") } http.Handle("/", http.HandlerFunc(myHandler)) // 方式二:http.HandleFunc(语法糖,最常用) http.HandleFunc("/", myHandler) // 方式三:实现 ServeHTTP 方法的结构体 type MyHandler struct{ greeting string } func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, h.greeting) } http.Handle("/", &MyHandler{greeting: "Hello from struct!"}) ``` - nil 是默认 ServeMux - http.HandleFunc 和 http.Handle 注册到默认的 http.DefaultServeMux 上。 - ListenAndServe 传 nil 就用这个默认路由器。 - 生产代码通常自己创建 http.NewServeMux(),避免全局状态污染——特别是在写库的时候。 - 解析请求 - *http.Request 包含请求的所有信息。掌握如何读取 URL 参数、Header、Body 是写 API 的基础。 - URL 参数与路径 - ```go func handler(w http.ResponseWriter, r *http.Request) { // 请求方法 method := r.Method // "GET", "POST", "PUT", "DELETE" // URL 路径 path := r.URL.Path // "/api/users/42" // Query 参数(?name=Alice&age=30) name := r.URL.Query().Get("name") // "Alice" age := r.URL.Query().Get("age") // "30"(字符串,需手动转换) all := r.URL.Query() // url.Values(map[string][]string) // ⚠️ 标准库没有路径参数(:id 这种) // 需要自己解析或用 Go 1.22 的新路由器 // 例如:从 /api/users/42 提取 42 parts := strings.Split(r.URL.Path, "/") // parts = ["", "api", "users", "42"] fmt.Fprintf(w, "method=%s path=%s name=%s", method, path, name) } ``` - 读取 Header 和 Body - ```go func handler(w http.ResponseWriter, r *http.Request) { // Headers contentType := r.Header.Get("Content-Type") token := r.Header.Get("Authorization") userAgent := r.Header.Get("User-Agent") // Body(只能读一次!) defer r.Body.Close() body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "读取 body 失败", http.StatusBadRequest) return } // JSON 解析 var payload struct { Name string `json:"name"` Email string `json:"email"` } if err := json.Unmarshal(body, &payload); err != nil { http.Error(w, "JSON 解析失败", http.StatusBadRequest) return } // 或者用 Decoder(更高效,不需要先读入 []byte) var data map[string]any if err := json.NewDecoder(r.Body).Decode(&data); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } _ = contentType; _ = token; _ = userAgent fmt.Fprintf(w, "name=%s email=%s", payload.Name, payload.Email) } ``` - 表单与文件上传 - ```go func handler(w http.ResponseWriter, r *http.Request) { // 解析表单(必须先调用) if err := r.ParseForm(); err != nil { http.Error(w, "解析表单失败", http.StatusBadRequest) return } name := r.FormValue("name") // 同时读取 form 和 query 参数 // 文件上传 r.ParseMultipartForm(10 << 20) // 最大 10MB file, header, err := r.FormFile("avatar") if err != nil { http.Error(w, "读取文件失败", http.StatusBadRequest) return } defer file.Close() fmt.Printf("文件名: %s, 大小: %d\n", header.Filename, header.Size) // 保存文件 dst, _ := os.Create("upload/" + header.Filename) defer dst.Close() io.Copy(dst, file) _ = name fmt.Fprintln(w, "上传成功") } ``` - 构建响应 - http.ResponseWriter 用于写响应。 - 理解写入顺序很重要:必须先设置 Header,再 WriteHeader,最后写 Body。 - ```go func handler(w http.ResponseWriter, r *http.Request) { // ⚠️ 顺序很重要:Header → WriteHeader → Body // 1. 设置响应头(必须在 WriteHeader 之前) w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Request-ID", "abc-123") // 2. 设置状态码(只能调用一次,之后会 superfluous) w.WriteHeader(http.StatusCreated) // 201 // 3. 写 Body fmt.Fprintln(w, `{"message": "created"}`) } // 常用状态码常量 http.StatusOK // 200 http.StatusCreated // 201 http.StatusNoContent // 204 http.StatusBadRequest // 400 http.StatusUnauthorized // 401 http.StatusForbidden // 403 http.StatusNotFound // 404 http.StatusInternalServerError // 500 ``` - JSON 响应的标准写法 - ```go // 封装一个通用的 JSON 响应函数 func writeJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(data); err != nil { // 已经 WriteHeader 了,只能 log,不能再改状态码 log.Printf("写入 JSON 响应失败: %v", err) } } // 封装错误响应 type ErrorResponse struct { Code int `json:"code"` Message string `json:"message"` } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, ErrorResponse{Code: status, Message: msg}) } // 使用 func getUserHandler(w http.ResponseWriter, r *http.Request) { user, err := findUser(1) if err != nil { writeError(w, http.StatusNotFound, "用户不存在") return } writeJSON(w, http.StatusOK, user) } ``` - 重定向与文件服务 - ```go // 重定向 http.Redirect(w, r, "/new-path", http.StatusMovedPermanently) // 301 http.Redirect(w, r, "/login", http.StatusFound) // 302 // 简单错误响应 http.Error(w, "资源不存在", http.StatusNotFound) // 等价于: // w.Header().Set("Content-Type", "text/plain; charset=utf-8") // w.WriteHeader(http.StatusNotFound) // fmt.Fprintln(w, "资源不存在") // 静态文件服务 http.Handle("/static/", http.StripPrefix( "/static/", http.FileServer(http.Dir("./public")), )) // ServeFile:返回单个文件 http.ServeFile(w, r, "./public/index.html") // ServeContent:带 Range 支持的文件(视频流) http.ServeContent(w, r, "video.mp4", time.Now(), file) ``` - ServerMux 路由与 Go 1.22 新路由 - 标准库的 ServeMux 在 Go 1.22 之前功能很有限——不支持路径参数和方法限制。 - Go 1.22 大幅增强了路由能力,现在可以直接用标准库写出接近框架的路由。 - 传统 ServerMux - ```go mux := http.NewServeMux() // 精确匹配 mux.HandleFunc("/api/users", usersHandler) // 前缀匹配(以 / 结尾) mux.HandleFunc("/static/", staticHandler) // ⚠️ 传统 ServeMux 的限制: // 1. 不支持路径参数(/users/:id) // 2. 不支持 HTTP 方法区分(GET vs POST) // 需要在 handler 内手动判断 func usersHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listUsers(w, r) case http.MethodPost: createUser(w, r) default: http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) } } ``` - Go 1.22+ 增强路由 - ```go // Go 1.22 的 ServeMux 支持: // 1. HTTP 方法前缀 // 2. 路径参数 {name} // 3. 通配符 {path...} mux := http.NewServeMux() // 方法 + 路径 mux.HandleFunc("GET /api/users", listUsers) mux.HandleFunc("POST /api/users", createUser) // 路径参数 {id} mux.HandleFunc("GET /api/users/{id}", getUser) mux.HandleFunc("PUT /api/users/{id}", updateUser) mux.HandleFunc("DELETE /api/users/{id}", deleteUser) // 通配符(匹配任意后续路径) mux.HandleFunc("GET /static/{path...}", staticHandler) // 在 handler 中读取路径参数 func getUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") // ✨ Go 1.22 新增! fmt.Fprintf(w, "获取用户 ID: %s", id) } // 精确匹配(末尾加 {$} 防止前缀匹配) mux.HandleFunc("GET /{$}", homeHandler) // 只匹配根路径 ``` - 路由组织:分文件 - ```go // 把路由注册拆分到各模块 // cmd/server/main.go func main() { mux := http.NewServeMux() // 注册各模块的路由 registerUserRoutes(mux) registerAuthRoutes(mux) registerHealthRoutes(mux) srv := &http.Server{ Addr: ":8080", Handler: mux, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } log.Fatal(srv.ListenAndServe()) } // internal/handler/user.go func registerUserRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/users", listUsers) mux.HandleFunc("POST /api/users", createUser) mux.HandleFunc("GET /api/users/{id}", getUser) mux.HandleFunc("PUT /api/users/{id}", updateUser) mux.HandleFunc("DELETE /api/users/{id}", deleteUser) } ``` - 用 http.Server 而不是 http.ListenrAndServe - http.ListenAndServe 是快速启动用的,没办法设置超时。 - 生产环境必须用 http.Server 并设置 ReadTimeout、WriteTimeout、IdleTimeout。 - 没有这些超时,慢速客户端可以让你的服务器资源耗尽(slowloris 攻击)。 - 中间件模式 - 中间件是包裹 Handler 的函数,在请求前后添加通用逻辑(日志、认证、CORS、限流)。 - 标准库的中间件模式非常简洁,用函数包装函数实现。 - 中间件的定义与使用 - ```go // 中间件的签名:接受 Handler,返回 Handler type Middleware func(http.Handler) http.Handler // 日志中间件 func Logger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) // 调用下一个 handler log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start)) }) } // Recovery 中间件(防 panic 崩溃) func Recovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { log.Printf("panic 恢复: %v\n%s", err, debug.Stack()) http.Error(w, "内部服务错误", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } // 链式组合多个中间件 func Chain(h http.Handler, middlewares ...Middleware) http.Handler { // 反序应用,保证执行顺序正确 for i := len(middlewares) - 1; i >= 0; i-- { h = middlewares[i](h) } return h } // 使用 mux := http.NewServeMux() mux.HandleFunc("GET /api/users", listUsers) // 应用中间件:Recovery → Logger → mux(从外到内) handler := Chain(mux, Recovery, Logger) http.ListenAndServe(":8080", handler) ``` - 认证中间件(JWT 示意) - ```go type contextKey string const userIDKey contextKey = "userID" func Auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "" { http.Error(w, "未授权", http.StatusUnauthorized) return } // 验证 token,提取 userID userID, err := validateToken(strings.TrimPrefix(token, "Bearer ")) if err != nil { http.Error(w, "无效令牌", http.StatusUnauthorized) return } // 把 userID 注入 context(Day 10 学的技能) ctx := context.WithValue(r.Context(), userIDKey, userID) next.ServeHTTP(w, r.WithContext(ctx)) }) } // 在 handler 中读取 func profileHandler(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(userIDKey).(int) if !ok { http.Error(w, "未认证", http.StatusUnauthorized) return } fmt.Fprintf(w, "当前用户 ID: %d", userID) } // 只对特定路由加认证 mux.Handle("GET /api/profile", Auth(http.HandlerFunc(profileHandler))) ``` - CORS 中间件 - ```go func CORS(allowOrigin string) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", allowOrigin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") // OPTIONS 预检请求直接返回 if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } } // 使用 handler := Chain(mux, Recovery, Logger, CORS("https://myapp.com"), ) ``` - 构建完整 REST API - 完整用户 CRUD REST API - ```go package main import ( "encoding/json" "errors" "fmt" "log" "net/http" "strconv" "sync" "time" ) // ───── 模型 ───── type User struct { ID int `json:"id"` Name string `json:"name"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"` } // ───── 存储(内存)───── type Store struct { mu sync.RWMutex users map[int]*User nextID int } func NewStore() *Store { return &Store{users: make(map[int]*User), nextID: 1} } var ErrNotFound = errors.New("not found") func (s *Store) List() []*User { s.mu.RLock(); defer s.mu.RUnlock() list := make([]*User, 0, len(s.users)) for _, u := range s.users { list = append(list, u) } return list } func (s *Store) Get(id int) (*User, error) { s.mu.RLock(); defer s.mu.RUnlock() u, ok := s.users[id] if !ok { return nil, ErrNotFound } return u, nil } func (s *Store) Create(u *User) { s.mu.Lock(); defer s.mu.Unlock() u.ID = s.nextID; u.CreatedAt = time.Now() s.users[u.ID] = u; s.nextID++ } func (s *Store) Update(id int, name, email string) (*User, error) { s.mu.Lock(); defer s.mu.Unlock() u, ok := s.users[id] if !ok { return nil, ErrNotFound } if name != "" { u.Name = name } if email != "" { u.Email = email } return u, nil } func (s *Store) Delete(id int) error { s.mu.Lock(); defer s.mu.Unlock() if _, ok := s.users[id]; !ok { return ErrNotFound } delete(s.users, id); return nil } // ───── Handler ───── type UserHandler struct{ store *Store } func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 路由分发(Go 1.22 前的方式) id, err := strconv.Atoi(r.PathValue("id")) if err != nil && r.PathValue("id") != "" { writeError(w, 400, "无效的 ID") return } switch { case r.Method == "GET" && id == 0: h.list(w, r) case r.Method == "POST" && id == 0: h.create(w, r) case r.Method == "GET" && id > 0: h.get(w, r, id) case r.Method == "PUT" && id > 0: h.update(w, r, id) case r.Method == "DELETE" && id > 0: h.delete(w, r, id) default: http.Error(w, "Not Found", 404) } } func (h *UserHandler) list(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, h.store.List()) } func (h *UserHandler) create(w http.ResponseWriter, r *http.Request) { var u User if err := json.NewDecoder(r.Body).Decode(&u); err != nil { writeError(w, 400, "无效的请求体") return } if u.Name == "" || u.Email == "" { writeError(w, 400, "name 和 email 不能为空") return } h.store.Create(&u) writeJSON(w, 201, u) } func (h *UserHandler) get(w http.ResponseWriter, r *http.Request, id int) { u, err := h.store.Get(id) if errors.Is(err, ErrNotFound) { writeError(w, 404, "用户不存在") return } writeJSON(w, 200, u) } func (h *UserHandler) update(w http.ResponseWriter, r *http.Request, id int) { var req struct { Name string `json:"name"` Email string `json:"email"` } json.NewDecoder(r.Body).Decode(&req) u, err := h.store.Update(id, req.Name, req.Email) if errors.Is(err, ErrNotFound) { writeError(w, 404, "用户不存在") return } writeJSON(w, 200, u) } func (h *UserHandler) delete(w http.ResponseWriter, r *http.Request, id int) { if err := h.store.Delete(id); errors.Is(err, ErrNotFound) { writeError(w, 404, "用户不存在") return } w.WriteHeader(204) } // ───── 工具函数 ───── func writeJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // ───── main ───── func main() { store := NewStore() h := &UserHandler{store: store} mux := http.NewServeMux() // Go 1.22+ 路由 mux.HandleFunc("GET /api/users", h.list) mux.HandleFunc("POST /api/users", h.create) mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.PathValue("id")) h.get(w, r, id) }) mux.HandleFunc("PUT /api/users/{id}", func(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.PathValue("id")) h.update(w, r, id) }) mux.HandleFunc("DELETE /api/users/{id}", func(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.PathValue("id")) h.delete(w, r, id) }) handler := Chain(mux, Recovery, Logger) srv := &http.Server{ Addr: ":8080", Handler: handler, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, } fmt.Println("🚀 服务器启动: http://localhost:8080") log.Fatal(srv.ListenAndServe()) } ``` - 用 curl 测试 - ```go # 创建用户 curl -X POST http://localhost:8080/api/users \ -H "Content-Type: application/json" \ -d '{"name":"Alice","email":"alice@example.com"}' # → {"id":1,"name":"Alice","email":"alice@example.com","created_at":"..."} # 获取所有用户 curl http://localhost:8080/api/users # 获取单个用户 curl http://localhost:8080/api/users/1 # 更新用户 curl -X PUT http://localhost:8080/api/users/1 \ -H "Content-Type: application/json" \ -d '{"name":"Alicia"}' # 删除用户 curl -X DELETE http://localhost:8080/api/users/1 ``` - 优雅关闭 - 生产环境的服务器不能强制关闭——要等正在处理的请求完成后再退出。 - Go 的 http.Server.Shutdown 方法实现了这个功能,配合系统信号使用。 - ```go package main import ( "context" "log" "net/http" "os" "os/signal" "syscall" "time" ) func main() { mux := http.NewServeMux() mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { // 模拟慢请求 time.Sleep(2 * time.Second) fmt.Fprintln(w, "OK") }) srv := &http.Server{ Addr: ":8080", Handler: mux, } // 在独立 goroutine 中启动服务器 go func() { log.Println("🚀 服务器启动: :8080") if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatalf("服务器错误: %v", err) } }() // 监听系统信号(Ctrl+C 或 kill) quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit // 阻塞,等待信号 log.Println("🛑 收到关闭信号,正在优雅关闭...") // 给正在处理的请求 30 秒完成 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Printf("强制关闭: %v", err) } else { log.Println("✅ 服务器已优雅关闭") } } // 测试优雅关闭: // 1. 启动服务器 // 2. 发送慢请求:curl http://localhost:8080/ // 3. 在请求处理中按 Ctrl+C // 4. 观察:服务器等待请求完成后才退出 ``` - Gin 框架入门 - 为什么用 Gin - Gin 是在标准库之上的增强层,保留了所有 handler 逻辑,但是减少了重复代码 - 提供了更强的路由、更方便的参数绑定、更完善的错误处理 - 标准库 vs Gin 对比 - 标准库写法 - ```go // ─── 标准库写法 ─── func getUser(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.Atoi(idStr) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) json.NewEncoder(w).Encode(map[string]string{"error": "无效的 ID"}) return } user, err := findUser(id) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) json.NewEncoder(w).Encode(map[string]string{"error": "用户不存在"}) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) json.NewEncoder(w).Encode(user) } // ─── Gin 写法 ─── func getUser(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(400, gin.H{"error": "无效的 ID"}) return } user, err := findUser(id) if err != nil { c.JSON(404, gin.H{"error": "用户不存在"}) return } c.JSON(200, user) } ``` - 安装与快速上手 - ```go go get github.com/gin-gonic/gin ``` - ```go package main import "github.com/gin-gonic/gin" func main() { r := gin.Default() // 默认包含 Logger + Recovery 中间件 r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{"message": "pong"}) }) r.Run(":8080") // 等价于 http.ListenAndServe(":8080", r) } // gin.H 是 map[string]any 的别名,专为 JSON 响应设计 // curl http://localhost:8080/ping // → {"message":"pong"} ``` - Gin 的性能 - Gin 实用 httprouter 为底层路由,比标准库的 ServeMux 更快 - 官方 benchmark 显示比标准库快 40 倍以上 - 路由与路由分组 - Gin 的路由系统非常强大,支持路径参数、通配符、路由分组,还能给分组统一加中间件。 - 基本路由 - ```go r := gin.Default() // HTTP 方法 r.GET("/users", listUsers) r.POST("/users", createUser) r.PUT("/users/:id", updateUser) r.DELETE("/users/:id", deleteUser) r.PATCH("/users/:id/status", updateStatus) // 路径参数 r.GET("/users/:id", func(c *gin.Context) { id := c.Param("id") // 精确参数:/users/42 → "42" fmt.Println(id) }) r.GET("/files/*filepath", func(c *gin.Context) { path := c.Param("filepath") // 通配符:/files/a/b/c → "/a/b/c" fmt.Println(path) }) // Query 参数 r.GET("/search", func(c *gin.Context) { keyword := c.Query("q") // ?q=golang page := c.DefaultQuery("page", "1") // 带默认值 fmt.Println(keyword, page) }) // 任意方法 r.Any("/webhook", webhookHandler) // 静态文件 r.Static("/static", "./public") r.StaticFile("/favicon.ico", "./favicon.ico") ``` - 路由分组 Group - ```go r := gin.Default() // 路由分组:共享前缀 v1 := r.Group("/api/v1") { v1.GET("/users", listUsers) v1.POST("/users", createUser) v1.GET("/users/:id", getUser) } v2 := r.Group("/api/v2") { v2.GET("/users", listUsersV2) // 新版接口 } // 嵌套分组 + 中间件 admin := r.Group("/admin", AuthMiddleware(), AdminOnlyMiddleware()) { admin.GET("/dashboard", dashboard) admin.GET("/users", adminListUsers) // 嵌套分组 settings := admin.Group("/settings") { settings.GET("/", getSettings) settings.PUT("/", updateSettings) } } // 只对部分路由加中间件 auth := r.Group("/api") auth.Use(AuthMiddleware()) // 这组路由都需要认证 { auth.GET("/profile", profile) auth.PUT("/profile", updateProfile) } ``` - NoRoute 和 NoMethod - ```go // 自定义 404 r.NoRoute(func(c *gin.Context) { c.JSON(404, gin.H{"error": "接口不存在"}) }) // 自定义 405(方法不允许) r.NoMethod(func(c *gin.Context) { c.JSON(405, gin.H{"error": "方法不允许"}) }) ``` - gin.Context: 一切的核心 - *gin.Context 封装了请求和响应,是 Gin handler 的唯一参数。掌握它的所有方法,你就掌握了 Gin 的核心。 - 读取请求参数 - ```go func handler(c *gin.Context) { // ─── 路径参数 ─── id := c.Param("id") // /users/:id // ─── Query 参数 ─── q := c.Query("keyword") // ?keyword=go page := c.DefaultQuery("page", "1") // 带默认值 all := c.QueryMap("tags") // ?tags[a]=1&tags[b]=2 // ─── Form 参数 ─── name := c.PostForm("name") nick := c.DefaultPostForm("nick", "anonymous") // ─── Header ─── token := c.GetHeader("Authorization") ua := c.GetHeader("User-Agent") // ─── 请求信息 ─── ip := c.ClientIP() // 客户端 IP(处理了代理) method := c.Request.Method // 原始 *http.Request 仍可访问 _ = id; _ = q; _ = page; _ = all _ = name; _ = nick; _ = token; _ = ua; _ = ip; _ = method } ``` - Context 的键值存储(在中间件传数据) - ```go // 中间件设置值 func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("Authorization") userID, err := validateToken(token) if err != nil { c.AbortWithStatusJSON(401, gin.H{"error": "未授权"}) return // Abort 后必须 return! } c.Set("userID", userID) // 存入 context c.Set("isAdmin", false) c.Next() // 继续执行后续 handler/中间件 } } // Handler 取值 func profileHandler(c *gin.Context) { userID, exists := c.Get("userID") if !exists { c.JSON(401, gin.H{"error": "未认证"}) return } // 类型断言 id := userID.(int) // 或者用类型安全的方法 id2, _ := c.GetInt("userID") isAdmin, _ := c.GetBool("isAdmin") c.JSON(200, gin.H{"userID": id, "id2": id2, "isAdmin": isAdmin}) } ``` - 响应方法 - ```go func handler(c *gin.Context) { // JSON 响应(最常用) c.JSON(200, gin.H{"message": "ok"}) c.JSON(200, user) // 结构体自动序列化 // 其他格式 c.String(200, "Hello, %s!", "World") c.HTML(200, "index.html", gin.H{"title": "首页"}) c.XML(200, user) c.YAML(200, user) // 文件响应 c.File("./public/file.pdf") c.FileAttachment("./public/file.pdf", "download.pdf") c.Data(200, "image/png", imageBytes) // 重定向 c.Redirect(302, "https://example.com") // 状态码快捷方式 c.Status(204) // 只有状态码,无 body // 中止后续中间件(不中止当前 handler) c.Abort() c.AbortWithStatus(403) c.AbortWithStatusJSON(403, gin.H{"error": "禁止访问"}) } ``` - 参数绑定与校验 - Gin 集成了 validator 库,可以在解析请求参数的同时做校验,大大减少了手动 if err 的代码量。 - ShouldBind:绑定 + 校验 - ```go // 定义请求结构体,用 binding tag 指定校验规则 type CreateUserRequest struct { Name string `json:"name" binding:"required,min=2,max=50"` Email string `json:"email" binding:"required,email"` Age int `json:"age" binding:"gte=0,lte=150"` Password string `json:"password" binding:"required,min=8"` } func createUser(c *gin.Context) { var req CreateUserRequest // ShouldBindJSON:解析 JSON body + 校验 if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } // req 现在已经被填充且通过了校验 user, err := userService.Create(c.Request.Context(), req.Name, req.Email) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(201, user) } ``` - 各种 Bind 方法 - ```go // ShouldBindJSON:绑定 JSON body c.ShouldBindJSON(&req) // ShouldBindQuery:绑定 query 参数 type SearchRequest struct { Keyword string `form:"q" binding:"required"` Page int `form:"page" binding:"gte=1"` Size int `form:"size" binding:"gte=1,lte=100"` } c.ShouldBindQuery(&req) // ShouldBindUri:绑定路径参数 type UriRequest struct { ID int `uri:"id" binding:"required,gt=0"` } c.ShouldBindUri(&req) // ShouldBind:自动根据 Content-Type 选择 c.ShouldBind(&req) // Bind 和 ShouldBind 的区别: // ShouldBind:失败只返回 error,状态码由你决定(推荐) // Bind:失败自动设置 400,并写入响应(不灵活) ``` - 常用校验规则 - ```go type Example struct { // 必填 Name string `binding:"required"` // 长度限制 Bio string `binding:"max=500"` Code string `binding:"len=6"` Tag string `binding:"min=1,max=20"` // 数值范围 Age int `binding:"gte=0,lte=150"` Score float64 `binding:"gt=0"` // 格式校验 Email string `binding:"required,email"` URL string `binding:"url"` Phone string `binding:"e164"` // 国际电话格式 // 枚举值 Status string `binding:"oneof=active inactive deleted"` Role string `binding:"oneof=admin editor viewer"` // 条件校验 Password string `binding:"required_with=Email"` // Email 填了就必填 // 自定义:omitempty + 条件 Nick string `binding:"omitempty,min=2"` // 有值才校验 } // 自定义校验器 import "github.com/go-playground/validator/v10" func validateChinesePhone(fl validator.FieldLevel) bool { phone := fl.Field().String() matched, _ := regexp.MatchString(`^1[3-9]\d{9}$`, phone) return matched } // 注册到 Gin if v, ok := binding.Validator.Engine().(*validator.Validate); ok { v.RegisterValidation("cnphone", validateChinesePhone) } type Req struct { Phone string `binding:"required,cnphone"` } ``` - 统一处理校验错误 - validator 返回的错误是 validator.ValidationErrors 类型 - 可以提取字段名和校验规则,返回更友好的错误信息,而不是直接把 err.Error() 给用户 - 生产代码应该封装一个 parseValidationError 函数。 - Gin 中间件 - Gin 的中间件是 gin.HandlerFunc(等价于 func(*gin.Context)),通过 c.Next() 和 c.Abort() 控制执行流。 - 中间件的执行模型 - ```go // 中间件的结构:前置逻辑 → Next() → 后置逻辑 func LoggerMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path // ─── 前置:请求进来时执行 ─── fmt.Printf("→ %s %s\n", c.Request.Method, path) c.Next() // 执行下一个 handler/中间件 // ─── 后置:响应返回后执行 ─── duration := time.Since(start) status := c.Writer.Status() fmt.Printf("← %s %s %d %v\n", c.Request.Method, path, status, duration) } } // 全局中间件 r := gin.New() // 不用 gin.Default()(它自带 Logger 和 Recovery) r.Use(LoggerMiddleware()) r.Use(RecoveryMiddleware()) r.Use(CORSMiddleware()) ``` - 认证/限流中间件(完整实现) - ```go func JWTAuth() gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { c.AbortWithStatusJSON(401, gin.H{"error": "缺少认证令牌"}) return } // "Bearer <token>" parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { c.AbortWithStatusJSON(401, gin.H{"error": "令牌格式错误"}) return } claims, err := parseJWT(parts[1]) if err != nil { c.AbortWithStatusJSON(401, gin.H{"error": "无效的令牌"}) return } // 把用户信息注入 context c.Set("userID", claims.UserID) c.Set("userEmail", claims.Email) c.Next() } } // 限流中间件 func RateLimit(rpm int) gin.HandlerFunc { // 每个 IP 限制每分钟 rpm 次请求 limiter := rate.NewLimiter(rate.Every(time.Minute/time.Duration(rpm)), rpm) return func(c *gin.Context) { ip := c.ClientIP() if !getLimiter(ip, limiter).Allow() { c.AbortWithStatusJSON(429, gin.H{ "error": "请求过于频繁,请稍后再试", }) return } c.Next() } } ``` - Abort vs Return - ```go // ⚠️ 重要:Abort 不会停止当前函数执行! // 必须同时 return,否则后续代码还会执行 func badMiddleware(c *gin.Context) { c.AbortWithStatusJSON(401, gin.H{"error": "未授权"}) // ❌ 没有 return,后续代码还会执行! doSomethingDangerous() // 会被执行 } func goodMiddleware(c *gin.Context) { c.AbortWithStatusJSON(401, gin.H{"error": "未授权"}) return // ✅ 明确 return,停止执行 } // Abort 的作用是:阻止后续的中间件和 handler 执行 // 但当前函数中 Abort 之后的代码仍然会执行 ``` - 统一错误处理 - 在每个 handler 里写重复的错误处理代码很烦。 - Gin 提供了统一错误处理的机制——handler 只负责「报告」错误,中间件统一「处理」错误。 - ```go // ─── 定义 AppError ─── type AppError struct { Status int `json:"-"` Code string `json:"code"` Message string `json:"message"` Err error `json:"-"` } func (e *AppError) Error() string { return e.Message } // 快捷构造 func NotFound(msg string) *AppError { return &AppError{Status: 404, Code: "NOT_FOUND", Message: msg} } func BadRequest(msg string) *AppError { return &AppError{Status: 400, Code: "BAD_REQUEST", Message: msg} } func Internal(err error) *AppError { return &AppError{Status: 500, Code: "INTERNAL_ERROR", Message: "内部服务错误", Err: err} } // ─── 错误处理中间件 ─── func ErrorHandler() gin.HandlerFunc { return func(c *gin.Context) { c.Next() // 收集所有 handler 添加的错误 if len(c.Errors) == 0 { return } err := c.Errors.Last().Err var appErr *AppError if errors.As(err, &appErr) { c.JSON(appErr.Status, appErr) } else { // 记录未预期的错误 log.Printf("未处理的错误: %v", err) c.JSON(500, gin.H{"code": "INTERNAL_ERROR", "message": "内部服务错误"}) } } } // ─── Handler 里只需要 c.Error(err) ─── func getUser(c *gin.Context) { id := c.Param("id") user, err := userService.GetUser(c.Request.Context(), id) if err != nil { // 只需添加错误,不需要写响应 _ = c.Error(NotFound("用户不存在")) return } c.JSON(200, user) } // ─── 注册 ─── r := gin.New() r.Use(ErrorHandler()) // 必须注册(会在所有 handler 之后执行) r.Use(LoggerMiddleware()) ``` - Gin 项目完整结构 - 项目结构 - ```go myapp/ ├── cmd/server/main.go # 启动入口 ├── internal/ │ ├── handler/ # HTTP 层(Gin handlers) │ │ ├── router.go # 路由注册 │ │ ├── user.go # 用户相关 handler │ │ ├── auth.go # 认证 handler │ │ └── middleware/ │ │ ├── auth.go # JWT 认证中间件 │ │ ├── logger.go # 日志中间件 │ │ ├── recovery.go # panic 恢复 │ │ └── cors.go # CORS 中间件 │ ├── service/ # 业务逻辑层 │ │ ├── user.go │ │ └── auth.go │ ├── repository/ # 数据访问层 │ │ └── user.go │ ├── model/ # 数据模型 │ │ └── user.go │ └── config/ # 配置 │ └── config.go └── go.mod ``` - 经典文件 - ```go // internal/handler/router.go package handler import ( "github.com/gin-gonic/gin" "github.com/yourname/myapp/internal/handler/middleware" "github.com/yourname/myapp/internal/service" ) func SetupRouter( userSvc *service.UserService, authSvc *service.AuthService, ) *gin.Engine { // 生产环境关闭调试模式 gin.SetMode(gin.ReleaseMode) r := gin.New() // 不用 Default,手动注册中间件 // 全局中间件 r.Use(middleware.Recovery()) r.Use(middleware.Logger()) r.Use(middleware.CORS()) r.Use(middleware.RequestID()) // 健康检查(不需要认证) r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) // API 路由 api := r.Group("/api/v1") { // 认证路由(不需要 JWT) auth := NewAuthHandler(authSvc) api.POST("/register", auth.Register) api.POST("/login", auth.Login) api.POST("/refresh", auth.RefreshToken) // 需要认证的路由 authed := api.Group("") authed.Use(middleware.JWTAuth()) { users := NewUserHandler(userSvc) authed.GET("/users", users.List) authed.GET("/users/:id", users.Get) authed.PUT("/users/:id", users.Update) authed.DELETE("/users/:id", users.Delete) authed.GET("/profile", users.Profile) } } return r } // cmd/server/main.go func main() { cfg, err := config.Load() if err != nil { log.Fatal(err) } // 依赖注入 repo := repository.NewUserRepo(db) userSvc := service.NewUserService(repo) authSvc := service.NewAuthService(repo, cfg.JWTSecret) r := handler.SetupRouter(userSvc, authSvc) srv := &http.Server{ Addr: ":" + cfg.Port, Handler: r, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, } // 优雅关闭(Day 15 学的) go gracefulShutdown(srv) log.Fatal(srv.ListenAndServe()) } ``` - 数据库操作 GORM - GORM 简介与安装 - GORM 是 Go 生态最流行的 ORM 库。它让你用 Go 结构体操作数据库,不需要手写 SQL(当然也支持原生 SQL)。 - 支持 MySQL、PostgreSQL、SQLite、SQL Server 等主流数据库。 - 安装 - ```go # GORM 核心库 go get gorm.io/gorm # 数据库驱动(选择你用的数据库) go get gorm.io/driver/postgres # PostgreSQL(推荐) go get gorm.io/driver/mysql # MySQL go get gorm.io/driver/sqlite # SQLite(开发/测试用) ``` - 连接数据库 - ```go package db import ( "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) // PostgreSQL(生产推荐) func NewPostgresDB(dsn string) (*gorm.DB, error) { db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), // 打印 SQL }) if err != nil { return nil, err } // 连接池配置 sqlDB, _ := db.DB() sqlDB.SetMaxIdleConns(10) // 最大空闲连接 sqlDB.SetMaxOpenConns(100) // 最大打开连接 sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大生命周期 return db, nil } // DSN 格式 // PostgreSQL: "host=localhost user=postgres password=secret dbname=myapp port=5432 sslmode=disable" // MySQL: "user:password@tcp(127.0.0.1:3306)/myapp?charset=utf8mb4&parseTime=True&loc=Local" // SQLite(快速测试/开发用) func NewSQLiteDB(path string) (*gorm.DB, error) { return gorm.Open(sqlite.Open(path), &gorm.Config{}) } // 内存 SQLite(单元测试专用) func NewTestDB() (*gorm.DB, error) { return gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) } ``` - GORM 和 database/sql 的关系 - GORM 底层仍然使用 database/sql,只是在它之上封装了 ORM 的能力。 - 通过 db.DB() 可以拿到底层的 *sql.DB,设置连接池、执行原生 SQL。两者不是替代关系,而是协作关系。 - 模型定义 - GORM 模型是普通的 Go 结构体,通过标签(tag)控制数据库映射行为。 - 嵌入 gorm.Model 可以自动获得 ID、CreatedAt、UpdatedAt、DeletedAt 四个字段。 - 基础模型定义 - ```go package model import ( "time" "gorm.io/gorm" ) // 嵌入 gorm.Model:自动拥有 id, created_at, updated_at, deleted_at type User struct { gorm.Model // 嵌入基础字段 Name string `gorm:"size:100;not null"` Email string `gorm:"uniqueIndex;size:255;not null"` Password string `gorm:"size:255;not null"` Age int `gorm:"default:0"` Active bool `gorm:"default:true"` } // gorm.Model 展开后等价于: // ID uint `gorm:"primarykey"` // CreatedAt time.Time // UpdatedAt time.Time // DeletedAt gorm.DeletedAt `gorm:"index"` ← 软删除 // 不想用 gorm.Model?自定义主键 type Article struct { ID int `gorm:"primaryKey;autoIncrement"` Title string `gorm:"size:200;not null;index"` Content string `gorm:"type:text"` AuthorID uint `gorm:"not null"` Published bool `gorm:"default:false"` CreatedAt time.Time UpdatedAt time.Time } ``` - 常用 GORM TAG - ```go type Example struct { // 主键 ID uint `gorm:"primaryKey"` UUID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` // 列属性 Name string `gorm:"column:full_name;size:100;not null"` Price float64 `gorm:"type:decimal(10,2);default:0"` Bio string `gorm:"type:text"` // 索引 Email string `gorm:"uniqueIndex"` // 唯一索引 City string `gorm:"index"` // 普通索引 Code string `gorm:"index:idx_code_type"` // 联合索引(和 Type 同名) Type string `gorm:"index:idx_code_type"` // 联合索引 // 忽略字段(不映射到数据库) Computed string `gorm:"-"` // 创建时只写、更新时只写 CreatedBy string `gorm:"->:false;<-:create"` // 只在创建时写 // 嵌入结构体 Address `gorm:"embedded;embeddedPrefix:addr_"` // 前缀 addr_ } type Address struct { Street string City string } // 数据库列:addr_street, addr_city ``` - AutoMigrate:自动建表 - ```go // 自动创建/更新表结构(只加列,不删列,生产用专业迁移工具) err := db.AutoMigrate( &model.User{}, &model.Article{}, &model.Comment{}, ) if err != nil { log.Fatal("数据库迁移失败:", err) } // AutoMigrate 的行为: // ✅ 表不存在 → 创建表 // ✅ 字段不存在 → 添加字段 // ✅ 索引不存在 → 创建索引 // ❌ 字段类型变了 → 不会修改(需要手动迁移) // ❌ 删掉字段 → 不会删除数据库列(安全策略) ``` - CRUD 基础操作 - Create (创建) - ```go // 创建单条记录 user := model.User{Name: "Alice", Email: "alice@example.com"} result := db.Create(&user) // ✨ 创建后 user.ID 会被填充 if result.Error != nil { return result.Error } fmt.Println("新用户 ID:", user.ID) // 批量创建 users := []model.User{ {Name: "Bob", Email: "bob@example.com"}, {Name: "Charlie", Email: "charlie@example.com"}, } db.Create(&users) // 单次 SQL 插入多行,比循环创建高效 // 只创建指定字段 db.Select("Name", "Email").Create(&user) // 忽略某些字段 db.Omit("Age", "Active").Create(&user) ``` - Read (查询) - ```go // 按主键查询 var user model.User db.First(&user, 1) // SELECT * FROM users WHERE id = 1 LIMIT 1 db.First(&user, "id = ?", 1) // 等价写法 // 查询单条(不按主键排序) db.Take(&user, 1) // SELECT * FROM users WHERE id = 1 LIMIT 1 db.Last(&user) // 最后一条(按主键 DESC) // 条件查询 db.Where("name = ?", "Alice").First(&user) db.Where("age > ? AND active = ?", 18, true).Find(&users) // IN 查询 db.Where("id IN ?", []int{1, 2, 3}).Find(&users) // LIKE 查询 db.Where("name LIKE ?", "%Alice%").Find(&users) // 查询多条 var users []model.User result := db.Find(&users) // SELECT * FROM users(自动过滤 deleted_at IS NULL) fmt.Println("查到", result.RowsAffected, "条") // 查询所有(含软删除的) db.Unscoped().Find(&users) // 只查指定字段 db.Select("id", "name", "email").Find(&users) // 检查记录是否存在 var count int64 db.Model(&model.User{}).Where("email = ?", email).Count(&count) exists := count > 0 ``` - Update (更新) - ```go // 更新单个字段 db.Model(&user).Update("name", "Alicia") // UPDATE users SET name='Alicia', updated_at=NOW() WHERE id=1 // 更新多个字段(用 map) db.Model(&user).Updates(map[string]any{ "name": "Alicia", "active": false, }) // 更新多个字段(用结构体,⚠️ 零值字段会被忽略!) db.Model(&user).Updates(model.User{Name: "Alicia", Age: 0}) // ⚠️ Age=0 是零值,不会被更新! // 解决零值问题:用 Select 明确指定要更新的字段 db.Model(&user).Select("Name", "Age").Updates(model.User{Name: "Alicia", Age: 0}) // Age=0 会被更新 // 带条件的更新 db.Model(&model.User{}).Where("active = ?", false).Update("deleted", true) // 不触发 Hook 和 updated_at 的原生更新 db.Model(&user).UpdateColumn("login_count", gorm.Expr("login_count + 1")) ``` - Delete (删除) - ```go // 软删除(如果模型有 DeletedAt 字段) // 只设置 deleted_at,不真正删除 db.Delete(&user, 1) // UPDATE users SET deleted_at=NOW() WHERE id=1 // 查询时自动过滤软删除的记录 db.Find(&users) // WHERE deleted_at IS NULL // 硬删除(真正删除) db.Unscoped().Delete(&user, 1) // DELETE FROM users WHERE id=1 // 批量删除(必须有条件,防止误删全表) db.Where("active = ?", false).Delete(&model.User{}) // 如果不加条件会报错(安全机制) ``` - 结构体 Updates 的零值陷阱 - 用结构体更新时,GORM 会忽略零值字段(0、""、false、nil),只更新非零值。 - 这个设计防止了意外覆盖,但也导致你没法把一个字段更新为零值。 - 解决方案:用 map[string]any 替代结构体,或者用 Select 明确列出要更新的字段。这是 GORM 面试高频考点! - 查询进阶 - 链式调用构建复杂查询 - ```go // GORM 的链式 API:每个方法返回 *gorm.DB,可以任意组合 var users []model.User db. Where("age > ?", 18). Where("active = ?", true). Order("created_at DESC"). Limit(10). Offset(20). // 跳过 20 条(第 3 页,每页 10 条) Find(&users) // 分页查询(通用封装) type Pagination struct { Page int Size int } func Paginate(p Pagination) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { offset := (p.Page - 1) * p.Size return db.Offset(offset).Limit(p.Size) } } // 使用 Scopes 复用查询逻辑 db.Scopes(Paginate(Pagination{Page: 2, Size: 10})). Where("active = ?", true). Order("name ASC"). Find(&users) ``` - 聚合与分组 - ```go // Count var count int64 db.Model(&model.User{}).Where("active = ?", true).Count(&count) // Sum, Avg, Max, Min var totalAge float64 db.Model(&model.User{}).Select("COALESCE(SUM(age), 0)").Scan(&totalAge) // Group By + Having type Result struct { City string Count int64 } var results []Result db.Model(&model.User{}). Select("city, COUNT(*) as count"). Group("city"). Having("COUNT(*) > ?", 10). Scan(&results) // Distinct db.Distinct("name", "email").Find(&users) ``` - 原生 SQL - ```go // Raw:执行原生 SQL 并扫描结果 var users []model.User db.Raw("SELECT * FROM users WHERE age > ? AND active = ?", 18, true).Scan(&users) // Exec:执行不返回行的 SQL db.Exec("UPDATE users SET login_count = login_count + 1 WHERE id = ?", userID) // 扫描到自定义结构体(不一定是模型) type UserSummary struct { Name string Email string Posts int } var summaries []UserSummary db.Raw(` SELECT u.name, u.email, COUNT(p.id) as posts FROM users u LEFT JOIN posts p ON p.author_id = u.id GROUP BY u.id `).Scan(&summaries) // Named 参数(更安全) db.Where("name = @name AND email = @email", sql.Named("name", "Alice"), sql.Named("email", "alice@example.com"), ).Find(&users) ``` - 关联关系 - GORM 支持所有常见的关联关系:一对一、一对多、多对多。理解关联关系是写复杂查询的基础。 - 一对多 - ```go // 用户有多篇文章 type User struct { gorm.Model Name string Articles []Article // Has Many(一对多) } // 文章属于一个用户 type Article struct { gorm.Model Title string Content string UserID uint // 外键(约定:类型名+ID) User User // Belongs To Comments []Comment } // 创建关联数据 user := User{ Name: "Alice", Articles: []Article{ {Title: "Go 入门", Content: "..."}, {Title: "Gin 框架", Content: "..."}, }, } db.Create(&user) // 同时创建用户和文章 // 预加载关联(避免 N+1 问题) var users []User db.Preload("Articles").Find(&users) // SELECT * FROM users // SELECT * FROM articles WHERE user_id IN (1,2,3,...) // 嵌套预加载 db.Preload("Articles.Comments").Find(&users) // 条件预加载 db.Preload("Articles", "published = ?", true).Find(&users) ``` - 多对多 - ```go // 文章有多个标签,标签也属于多篇文章 type Article struct { gorm.Model Title string Tags []Tag `gorm:"many2many:article_tags"` // 中间表名 } type Tag struct { gorm.Model Name string `gorm:"uniqueIndex"` Articles []Article `gorm:"many2many:article_tags"` } // GORM 自动管理中间表 article_tags (article_id, tag_id) // 创建时关联标签 tags := []Tag{{Name: "Go"}, {Name: "Backend"}} article := Article{Title: "Go Web 开发", Tags: tags} db.Create(&article) // 追加关联 db.Model(&article).Association("Tags").Append([]Tag{{Name: "Tutorial"}}) // 替换关联 db.Model(&article).Association("Tags").Replace(newTags) // 删除关联(只删关系,不删记录) db.Model(&article).Association("Tags").Delete(tag) // 清空关联 db.Model(&article).Association("Tags").Clear() // 计数 count := db.Model(&article).Association("Tags").Count() ``` - 一对一 - ```go // 用户有一个个人资料 type User struct { gorm.Model Name string Profile Profile // Has One } type Profile struct { gorm.Model Bio string Avatar string UserID uint // 外键 } // 查询时预加载 var user User db.Preload("Profile").First(&user, 1) fmt.Println(user.Profile.Bio) ``` - 事务处理 - 事务保证一组操作要么全部成功,要么全部回滚。GORM 提供了简洁的事务 API,支持自动和手动两种模式。 - 自动事务 - ```go // Transaction 函数:返回 error 时自动回滚,nil 时自动提交 err := db.Transaction(func(tx *gorm.DB) error { // 注意:事务内所有操作必须用 tx,不能用 db! // 创建订单 order := Order{UserID: userID, Total: 100.0} if err := tx.Create(&order).Error; err != nil { return err // 返回 error → 自动回滚 } // 扣减库存 result := tx.Model(&Product{}). Where("id = ? AND stock > 0", productID). UpdateColumn("stock", gorm.Expr("stock - 1")) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return errors.New("库存不足") // 返回 error → 回滚 } // 记录流水 if err := tx.Create(&Transaction{OrderID: order.ID}).Error; err != nil { return err } return nil // 返回 nil → 自动提交 }) if err != nil { log.Printf("下单失败: %v", err) } ``` - 手动事务 (需要跨函数使用时) - ```go // 手动控制 tx := db.Begin() // 开启事务 defer func() { if r := recover(); r != nil { tx.Rollback() // panic 时回滚 } }() if err := tx.Error; err != nil { return err } if err := tx.Create(&user).Error; err != nil { tx.Rollback() return err } if err := tx.Create(&profile).Error; err != nil { tx.Rollback() return err } tx.Commit() // 全部成功才提交 return nil // 保存点(嵌套事务) tx.SavePoint("step1") // ... 一些操作 tx.RollbackTo("step1") // 只回滚到保存点,不全部回滚 ``` - 事务内必须用 tx,不能用 db - 这是事务最常见的错误! - 在 Transaction 回调里,如果你误用了外部的 db 而不是回调参数 tx,那些操作不属于这个事务,不会被一起回滚。 - 养成习惯:看到 Transaction 回调,里面所有 db 操作都换成 tx。 - Hooks 钩子 - GORM 的 Hook 允许在数据库操作前后自动执行逻辑,比如密码加密、数据校验、审计日志。 - ```go // Hook 方法列表: // BeforeCreate, AfterCreate // BeforeUpdate, AfterUpdate // BeforeDelete, AfterDelete // BeforeFind, AfterFind type User struct { gorm.Model Name string Email string Password string } // 创建前:自动加密密码 func (u *User) BeforeCreate(tx *gorm.DB) error { if u.Password != "" { hashed, err := bcrypt.GenerateFromPassword( []byte(u.Password), bcrypt.DefaultCost, ) if err != nil { return err } u.Password = string(hashed) } return nil } // 查询后:脱敏处理(不把密码带出去) func (u *User) AfterFind(tx *gorm.DB) error { u.Password = "" // 查出来后清空密码字段 return nil } // 更新前:校验必填字段 func (u *User) BeforeUpdate(tx *gorm.DB) error { if u.Email == "" { return errors.New("email 不能为空") } return nil } // 删除前:记录审计日志 func (u *User) BeforeDelete(tx *gorm.DB) error { return tx.Create(&AuditLog{ Action: "DELETE", Resource: "users", ResourceID: u.ID, }).Error } ``` - Repository 模式整合 - 把 GORM 封装到 Repository 层,让 Service 层不依赖具体的数据库实现。 - ```go // internal/repository/user.go package repository import ( "context" "errors" "gorm.io/gorm" "github.com/yourname/myapp/internal/model" ) var ErrNotFound = errors.New("记录不存在") // 接口(让 service 层只依赖接口) type UserRepository interface { FindByID(ctx context.Context, id uint) (*model.User, error) FindByEmail(ctx context.Context, email string) (*model.User, error) List(ctx context.Context, page, size int) ([]*model.User, int64, error) Create(ctx context.Context, u *model.User) error Update(ctx context.Context, u *model.User) error Delete(ctx context.Context, id uint) error } // GORM 实现 type gormUserRepo struct { db *gorm.DB } func NewUserRepository(db *gorm.DB) UserRepository { return &gormUserRepo{db: db} } func (r *gormUserRepo) FindByID(ctx context.Context, id uint) (*model.User, error) { var u model.User err := r.db.WithContext(ctx).First(&u, id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return &u, err } func (r *gormUserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) { var u model.User err := r.db.WithContext(ctx).Where("email = ?", email).First(&u).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return &u, err } func (r *gormUserRepo) List(ctx context.Context, page, size int) ([]*model.User, int64, error) { var users []*model.User var count int64 offset := (page - 1) * size db := r.db.WithContext(ctx).Model(&model.User{}) if err := db.Count(&count).Error; err != nil { return nil, 0, err } if err := db.Offset(offset).Limit(size). Order("created_at DESC").Find(&users).Error; err != nil { return nil, 0, err } return users, count, nil } func (r *gormUserRepo) Create(ctx context.Context, u *model.User) error { return r.db.WithContext(ctx).Create(u).Error } func (r *gormUserRepo) Update(ctx context.Context, u *model.User) error { return r.db.WithContext(ctx).Save(u).Error } func (r *gormUserRepo) Delete(ctx context.Context, id uint) error { result := r.db.WithContext(ctx).Delete(&model.User{}, id) if result.RowsAffected == 0 { return ErrNotFound } return result.Error } ``` - WithContext 的重要性 - ```go // ✅ 每个数据库操作都带上 context // 当 HTTP 请求超时或客户端断开时,数据库查询会自动取消 func (r *gormUserRepo) FindByID(ctx context.Context, id uint) (*model.User, error) { var u model.User err := r.db.WithContext(ctx).First(&u, id).Error // ↑ 把 ctx 传给 GORM,超时自动取消 SQL return &u, err } // ❌ 没有 WithContext:HTTP 超时后 SQL 还在跑,浪费数据库资源 func (r *gormUserRepo) FindByID(id uint) (*model.User, error) { var u model.User err := r.db.First(&u, id).Error // 没有超时控制 return &u, err } ``` - GORM 高级查询 - scrope 复用查询逻辑 - ```go // Scope 是返回 func(db *gorm.DB) *gorm.DB 的函数 // 让你把常用的查询条件封装成可复用的"查询片段" // 1. 分页 Scope func Paginate(page, size int) func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } return db.Offset((page-1)*size).Limit(size) } } // 2. 只查活跃用户 func ActiveOnly(db *gorm.DB) *gorm.DB { return db.Where("active = ?", true) } // 3. 按时间倒序 func LatestFirst(db *gorm.DB) *gorm.DB { return db.Order("created_at DESC") } // 组合使用:业务代码非常清晰 var users []User db.Scopes(ActiveOnly, LatestFirst, Paginate(2, 20)).Find(&users) // 等价于: // db.Where("active = ?", true). // Order("created_at DESC"). // Offset(20).Limit(20). // Find(&users) ``` - 预加载,解决 N+1 问题 - ```go // ❌ N+1 问题:1 次查用户 + N 次查每个用户的文章 var users []User db.Find(&users) for _, u := range users { var articles []Article db.Where("user_id = ?", u.ID).Find(&articles) // 每个用户都查一次! } // ✅ Preload:2 次 SQL 解决(无论多少用户) db.Preload("Articles").Find(&users) // SELECT * FROM users // SELECT * FROM articles WHERE user_id IN (1,2,3,...) // 嵌套预加载(用户 → 文章 → 评论) db.Preload("Articles.Comments").Find(&users) // 条件预加载:只加载已发布的文章 db.Preload("Articles", "published = ?", true).Find(&users) // 自定义预加载查询:函数式 db.Preload("Articles", func(db *gorm.DB) *gorm.DB { return db.Where("published = ?", true). Order("created_at DESC"). Limit(5) }).Find(&users) // 预加载所有关联(懒人写法,慎用) db.Preload(clause.Associations).Find(&users) ``` - JOIN:按关联条件过滤 - ```go // Preload 适合"加载关联数据" // Joins 适合"按关联表字段过滤/排序" // 查询「至少有 1 篇已发布文章」的用户 var users []User db.Joins("JOIN articles ON articles.user_id = users.id"). Where("articles.published = ?", true). Distinct(). Find(&users) // Joins + Preload 组合:用 Joins 过滤,用 Preload 加载完整关联 db.Joins("JOIN articles ON articles.user_id = users.id AND articles.published = ?", true). Preload("Articles"). Distinct(). Find(&users) ``` - 分页:完整封装 - ```go type PageResult[T any] struct { Items []T `json:"items"` Total int64 `json:"total"` Page int `json:"page"` Size int `json:"size"` HasNext bool `json:"has_next"` } func Page[T any](db *gorm.DB, page, size int) (*PageResult[T], error) { var items []T var total int64 if err := db.Model(new(T)).Count(&total).Error; err != nil { return nil, err } if err := db.Scopes(Paginate(page, size)).Find(&items).Error; err != nil { return nil, err } return &PageResult[T]{ Items: items, Total: total, Page: page, Size: size, HasNext: int64(page*size) < total, }, nil } // 使用 result, _ := Page[User](db.Where("active = ?", true), 1, 20) ``` - Preload vs Joins - 需要把关联数据填到结构体里 → Preload; - 只是按关联字段过滤、不需要关联数据 → Joins; - 既要过滤又要加载完整关联 → Joins + Preload。 - Preload 是 N 次表查询(用 IN),Joins 是 1 次 JOIN 查询。 - Has Many 关联尤其推荐 Preload,因为 JOIN 后会出现笛卡尔积。 - 数据库迁移 - AutoMigrate 适合开发期,但生产环境需要专业的迁移工具:版本化、可回滚、团队协作友好。 - golang-migrate 是 Go 生态最常用的方案。 - 为什么不用 AutoMigrate? - ```go // AutoMigrate 的问题: // ❌ 不会删除字段(改名 = 加字段,旧字段一直在) // ❌ 不会修改字段类型(VARCHAR(50) → VARCHAR(100) 不生效) // ❌ 没有版本记录(不知道线上跑到哪个版本了) // ❌ 不能回滚(出问题只能手写 SQL 修复) // ❌ 多人协作冲突难发现 // 生产环境正确做法: // 1. 用 SQL 文件描述每次变更(向上 + 向下) // 2. 工具记录已执行的版本 // 3. 部署前检查、出错可回滚 ``` - 安装使用 golang-migrate - ```go # 安装 CLI brew install golang-migrate # macOS # 或 go install -tags 'postgres' \ github.com/golang-migrate/migrate/v4/cmd/migrate@latest # 创建迁移文件(自动生成时间戳前缀) migrate create -ext sql -dir db/migrations -seq create_users_table # 会生成两个文件: # db/migrations/000001_create_users_table.up.sql # db/migrations/000001_create_users_table.down.sql # 执行所有未执行的迁移 migrate -path db/migrations \ -database "postgres://postgres:secret@localhost:5432/myapp?sslmode=disable" \ up # 回滚最后 1 个版本 migrate -path db/migrations -database "..." down 1 # 强制设置版本(脏数据修复用) migrate -path db/migrations -database "..." force 1 ``` - 迁移文件示例 - ```go -- 000001_create_users_table.up.sql CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), deleted_at TIMESTAMP ); CREATE INDEX idx_users_deleted_at ON users(deleted_at); CREATE INDEX idx_users_email ON users(email); -- 000001_create_users_table.down.sql DROP TABLE IF EXISTS users; ``` - go 程序中嵌入迁移 - ```go // 程序启动时自动执行迁移(用 embed 把 SQL 文件打包进二进制) package db import ( "embed" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" _ "github.com/lib/pq" "database/sql" ) //go:embed migrations/*.sql var migrationsFS embed.FS func RunMigrations(dsn string) error { sqlDB, err := sql.Open("postgres", dsn) if err != nil { return err } defer sqlDB.Close() driver, err := postgres.WithInstance(sqlDB, &postgres.Config{}) if err != nil { return err } src, err := iofs.New(migrationsFS, "migrations") if err != nil { return err } m, err := migrate.NewWithInstance("iofs", src, "postgres", driver) if err != nil { return err } if err := m.Up(); err != nil && err != migrate.ErrNoChange { return err } return nil } ``` - Redis 操作 - 基础入门 - go-redis 客户端 - ```go go get github.com/redis/go-redis/v9 ``` - 示例程序 - ```go package cache import ( "context" "time" "github.com/redis/go-redis/v9" ) func NewRedis(addr, password string, db int) (*redis.Client, error) { rdb := redis.NewClient(&redis.Options{ Addr: addr, // "localhost:6379" Password: password, DB: db, // 默认 0 PoolSize: 50, // 连接池大小 MinIdleConns: 10, DialTimeout: 5 * time.Second, ReadTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second, }) // 启动时 Ping 验证连接 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := rdb.Ping(ctx).Err(); err != nil { return nil, err } return rdb, nil } ``` - Redis 常用缓存操作 - String: 缓存值、计数器 - ```go ctx := context.Background() // 设置 + 过期时间 err := rdb.Set(ctx, "user:1", `{"name":"Alice"}`, 10*time.Minute).Err() // 读取 val, err := rdb.Get(ctx, "user:1").Result() if err == redis.Nil { // key 不存在 } else if err != nil { // 出错 } // SetNX:只在不存在时设置(分布式锁的基础) ok, _ := rdb.SetNX(ctx, "lock:order:42", "1", 30*time.Second).Result() if !ok { return errors.New("已有人在处理") } // 计数器 rdb.Incr(ctx, "page_view:home") rdb.IncrBy(ctx, "score:user:1", 10) // 批量获取 vals, _ := rdb.MGet(ctx, "user:1", "user:2", "user:3").Result() ``` - Hash: 对象字段缓存 - ```go // 把一个 User 拆成多个字段,比 JSON 字符串更省内存、可单字段更新 rdb.HSet(ctx, "user:1", "name", "Alice", "email", "alice@example.com", "age", 30) name, _ := rdb.HGet(ctx, "user:1", "name").Result() // 一次取所有字段 all, _ := rdb.HGetAll(ctx, "user:1").Result() // map[string]string{"name":"Alice", ...} // 单字段自增 rdb.HIncrBy(ctx, "user:1", "login_count", 1) ``` - List/Set/ZSet - ```go // List:消息队列、最近浏览 rdb.LPush(ctx, "queue:emails", "task1", "task2") val, _ := rdb.RPop(ctx, "queue:emails").Result() // 队尾出队 // 阻塞式弹出(适合 worker 消费) result, _ := rdb.BLPop(ctx, 5*time.Second, "queue:emails").Result() // Set:去重集合、标签、好友关系 rdb.SAdd(ctx, "tags:article:1", "go", "redis", "backend") exists, _ := rdb.SIsMember(ctx, "tags:article:1", "go").Result() all, _ := rdb.SMembers(ctx, "tags:article:1").Result() // ZSet:排行榜、按分数排序 rdb.ZAdd(ctx, "leaderboard", redis.Z{Score: 100, Member: "alice"}) rdb.ZAdd(ctx, "leaderboard", redis.Z{Score: 85, Member: "bob"}) // 取前 10 名(分数从高到低) top, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result() for _, z := range top { fmt.Printf("%v: %v\n", z.Member, z.Score) } ``` - Pipelines:批量操作 - ```go // 一次往返发送多个命令,大幅减少网络开销 pipe := rdb.Pipeline() incr := pipe.Incr(ctx, "page_view:home") pipe.Expire(ctx, "page_view:home", 24*time.Hour) _, err := pipe.Exec(ctx) if err != nil { return err } fmt.Println("当前 PV:", incr.Val()) ``` - Cache-Aside 缓存策略 - Cache-Aside(旁路缓存)是最经典的缓存模式 - 应用先查缓存,缓存没有再查数据库并回填缓存。简单、可靠、易于理解。 - 读流程 - ```go func GetUser(ctx context.Context, id uint) (*User, error) { key := fmt.Sprintf("user:%d", id) // 1. 查缓存 val, err := rdb.Get(ctx, key).Result() if err == nil { var u User if err := json.Unmarshal([]byte(val), &u); err == nil { return &u, nil // 缓存命中 } } else if err != redis.Nil { // Redis 出错(不是 key 不存在),降级直接查 DB log.Warn("redis error:", err) } // 2. 缓存未命中 → 查数据库 var u User if err := db.WithContext(ctx).First(&u, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // 防穿透:把"不存在"也缓存(短 TTL) rdb.Set(ctx, key, "", 1*time.Minute) return nil, ErrNotFound } return nil, err } // 3. 回填缓存 if data, err := json.Marshal(u); err == nil { rdb.Set(ctx, key, data, 10*time.Minute) } return &u, nil } ``` - 写流程 - ```go // ✅ 推荐:先写 DB,再删缓存 func UpdateUser(ctx context.Context, u *User) error { if err := db.WithContext(ctx).Save(u).Error; err != nil { return err } // 删除缓存(下次读会从 DB 重新加载) rdb.Del(ctx, fmt.Sprintf("user:%d", u.ID)) return nil } // ❌ 不推荐:先写 DB,再更新缓存 // 问题:两个并发更新可能让旧值覆盖新值(A 写 DB → B 写 DB → B 写缓存 → A 写缓存) // 删除策略选 "delete" 而不是 "update" 的核心原因: // 1. 删除幂等,更新可能写入旧数据 // 2. 下次读自然回填,避免缓存写入热点 // 3. 简单(不用维护缓存的写一致性) ``` - 封装通用缓存层 - ```go // 用泛型封装一个通用的"查缓存 → 回源 → 回填"模式 func GetOrLoad[T any]( ctx context.Context, rdb *redis.Client, key string, ttl time.Duration, loader func(ctx context.Context) (*T, error), ) (*T, error) { // 1. 查缓存 if val, err := rdb.Get(ctx, key).Bytes(); err == nil { var v T if err := json.Unmarshal(val, &v); err == nil { return &v, nil } } // 2. 回源 v, err := loader(ctx) if err != nil { return nil, err } // 3. 回填(异步、不阻塞返回) go func() { if data, err := json.Marshal(v); err == nil { rdb.Set(context.Background(), key, data, ttl) } }() return v, nil } // 使用 user, err := GetOrLoad(ctx, rdb, fmt.Sprintf("user:%d", id), 10*time.Minute, func(ctx context.Context) (*User, error) { var u User return &u, db.WithContext(ctx).First(&u, id).Error }, ) ``` - 缓存三类问题 - 缓存穿透 - ```go // 问题:恶意请求大量不存在的 ID // → 缓存永远没有 → 每次都打到 DB → DB 被打挂 // 方案 1:缓存空值(短 TTL) if errors.Is(err, gorm.ErrRecordNotFound) { rdb.Set(ctx, key, "NULL_PLACEHOLDER", 1*time.Minute) return nil, ErrNotFound } // 读取时识别空值 val, _ := rdb.Get(ctx, key).Result() if val == "NULL_PLACEHOLDER" { return nil, ErrNotFound } // 方案 2:布隆过滤器 // 启动时把所有有效 ID 加入布隆过滤器 // 查询前先问布隆过滤器:这个 ID 可能存在吗? // "肯定不存在" → 直接拒绝,不查 DB ``` - 缓存击穿 - ```go // 问题:1 个超热点 key(比如首页配置)刚好过期 // → 大量请求同时穿透到 DB → DB 瞬间被打挂 // 方案:用 SetNX 实现单飞(singleflight) // 只让 1 个请求去查 DB,其他请求等待结果 import "golang.org/x/sync/singleflight" var sf singleflight.Group func GetHotConfig(ctx context.Context) (*Config, error) { if val, err := rdb.Get(ctx, "config:home").Bytes(); err == nil { var c Config json.Unmarshal(val, &c) return &c, nil } // singleflight:同一 key 同时只有 1 次回源 v, err, _ := sf.Do("config:home", func() (any, error) { var c Config if err := db.First(&c).Error; err != nil { return nil, err } if data, err := json.Marshal(c); err == nil { rdb.Set(ctx, "config:home", data, 10*time.Minute) } return &c, nil }) if err != nil { return nil, err } return v.(*Config), nil } ``` - 缓存雪崩 - ```go // 问题:缓存预热时大批 key 设了相同的 TTL // → 同一时刻全部过期 → 请求全打到 DB // 方案:TTL 加随机抖动 func RandomTTL(base time.Duration) time.Duration { jitter := time.Duration(rand.Int63n(int64(base / 5))) // ±20% return base + jitter } rdb.Set(ctx, key, val, RandomTTL(10*time.Minute)) // 实际过期时间分布在 10~12 分钟之间,自然错峰 // 方案 2:多级缓存(本地 + Redis),Redis 挂了还有本地兜底 // 方案 3:限流降级,DB 压力过大时直接返回旧数据或默认值 ``` - 分布式锁 - ```go // 简化版分布式锁 type RedisLock struct { rdb *redis.Client key string value string // 锁的"持有者"标识,释放时校验 ttl time.Duration } func NewLock(rdb *redis.Client, key string, ttl time.Duration) *RedisLock { return &RedisLock{ rdb: rdb, key: key, value: uuid.New().String(), // 唯一 ID ttl: ttl, } } // 获取锁(非阻塞) func (l *RedisLock) TryLock(ctx context.Context) (bool, error) { return l.rdb.SetNX(ctx, l.key, l.value, l.ttl).Result() } // 释放锁(用 Lua 脚本保证原子性:「校验是我加的锁」+「删除」) var unlockScript = redis.NewScript(` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end `) func (l *RedisLock) Unlock(ctx context.Context) error { return unlockScript.Run(ctx, l.rdb, []string{l.key}, l.value).Err() } // 使用 lock := NewLock(rdb, "lock:order:42", 30*time.Second) ok, err := lock.TryLock(ctx) if err != nil || !ok { return errors.New("已有人在处理") } defer lock.Unlock(ctx) // 临界区:处理订单 ``` - 认证与授权 (JWT+RBAC) - 一个 Web 服务的安全模块通常包含两层:先认证(Authentication,你是谁),再授权(Authorization,你能做什么) - 两个核心概念 - ```go Authentication(认证 / AuthN) → 你是谁? → 例如:用户名密码登录、Token 校验、SSO Authorization(授权 / AuthZ) → 你被允许做什么? → 例如:管理员可以删用户,普通用户不行;A 用户只能编辑自己的文章 实际请求的处理顺序: HTTP Request → 中间件 1:解析 Token,确认身份(认证) → 中间件 2:检查权限,判断操作(授权) → 业务 Handler ``` - 无状态认证 vs 有状态 - ```go 传统 Session 方案: 登录后服务器内存(或 Redis)保存 sessionID → 用户信息 浏览器用 Cookie 携带 sessionID ✅ 服务端可主动失效 ❌ 服务端要存状态,多实例需要共享存储;跨域 Cookie 麻烦 JWT 方案(Stateless): 登录后服务器签发一个自包含 Token,里面带用户信息和过期时间 客户端把 Token 放在 Authorization Header 里 ✅ 服务端无状态,水平扩展友好;天然跨域 ❌ 签发后无法主动失效(需要黑名单或短 TTL + 刷新) 现代后端通常选 JWT,配合 Refresh Token 机制弥补失效问题。 ``` - golang-jwt 实战 - Go 生态最常用的库是 golang-jwt/jwt。我们封装一个 TokenService,统一管理签发和校验。 - 安装 - ```go go get github.com/golang-jwt/jwt/v5 ``` - 封装 TokenService - ```go package auth import ( "errors" "time" "github.com/golang-jwt/jwt/v5" ) // 自定义 Claims:内嵌标准字段 + 业务字段 type Claims struct { UserID uint `json:"uid"` Email string `json:"email"` Role string `json:"role"` jwt.RegisteredClaims // 提供 ExpiresAt / IssuedAt / Issuer 等 } type TokenService struct { secret []byte issuer string accessTTL time.Duration refreshTTL time.Duration } func NewTokenService(secret string) *TokenService { return &TokenService{ secret: []byte(secret), issuer: "myapp", accessTTL: 15 * time.Minute, // Access Token 短 refreshTTL: 7 * 24 * time.Hour, // Refresh Token 长 } } // 签发 Access Token func (s *TokenService) IssueAccess(uid uint, email, role string) (string, error) { claims := Claims{ UserID: uid, Email: email, Role: role, RegisteredClaims: jwt.RegisteredClaims{ Issuer: s.issuer, Subject: fmt.Sprint(uid), IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTTL)), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(s.secret) } // 校验 Token func (s *TokenService) Parse(tokenStr string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { // ⚠️ 必须校验签名算法,防止 alg=none 攻击 if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("unexpected signing method") } return s.secret, nil }, jwt.WithIssuer(s.issuer), jwt.WithExpirationRequired(), ) if err != nil { return nil, err } claims, ok := token.Claims.(*Claims) if !ok || !token.Valid { return nil, errors.New("invalid token") } return claims, nil } ``` - 认证中间件 - 中间件从 Authorization Header 取 Token,校验通过后把用户信息塞进 context,后续 Handler 直接读取。 - 示例 - ```go package middleware import ( "context" "net/http" "strings" "github.com/gin-gonic/gin" ) type ctxKey string const ClaimsKey ctxKey = "claims" func JWTAuth(svc *auth.TokenService) gin.HandlerFunc { return func(c *gin.Context) { h := c.GetHeader("Authorization") if !strings.HasPrefix(h, "Bearer ") { c.AbortWithStatusJSON(401, gin.H{"error":"missing or invalid Authorization header"}) return } tokenStr := strings.TrimPrefix(h, "Bearer ") claims, err := svc.Parse(tokenStr) if err != nil { c.AbortWithStatusJSON(401, gin.H{"error":"invalid token: " + err.Error()}) return } // 把 claims 写入 context ctx := context.WithValue(c.Request.Context(), ClaimsKey, claims) c.Request = c.Request.WithContext(ctx) c.Set("claims", claims) // Gin 也提供了内部 Set/Get c.Next() } } // Handler 里取出当前用户 func ClaimsFrom(c *gin.Context) *auth.Claims { if v, ok := c.Get("claims"); ok { return v.(*auth.Claims) } return nil } // 使用 r := gin.Default() r.POST("/api/v1/auth/login", loginHandler) // 公开 authed := r.Group("/api/v1", JWTAuth(tokenSvc)) // 需要登录 { authed.GET("/me", meHandler) authed.POST("/articles", createArticleHandler) } ``` - 密码加密:bcypt - 绝对禁止把密码明文存数据库。bcrypt 是密码哈希的事实标准:自带盐、计算慢(抗暴力破解)、参数可调。 - 安装 ```go go get golang.org/x/crypto/bcrypt ``` - 示例 - ```go package auth import "golang.org/x/crypto/bcrypt" // Cost 推荐 12,越高越慢但越安全(2025 年合理范围 12~14) // 每 +1 计算时间翻倍:cost=10 ≈ 100ms, cost=12 ≈ 250ms const PasswordCost = 12 func HashPassword(plain string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(plain), PasswordCost) if err != nil { return "", err } return string(h), nil } func VerifyPassword(plain, hashed string) bool { err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plain)) return err == nil } // 数据库里存的 hash 形如: // $2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy // ↑ ↑ ↑ // 算法 cost salt + hash // // 不需要单独存 salt——它已经嵌在 hash 字符串里了! ``` - 集成到 User 模型(GORM Hook) - ```go type User struct { gorm.Model Email string `gorm:"uniqueIndex"` Password string `gorm:"size:255;not null" json:"-"` // json:"-" 防序列化 Role string `gorm:"default:user"` // user / admin } // 创建/更新前自动加密(已经是哈希就跳过) func (u *User) BeforeSave(tx *gorm.DB) error { // 检测是否已经是 bcrypt 哈希 if strings.HasPrefix(u.Password, "$2") { return nil } if u.Password == "" { return nil } h, err := HashPassword(u.Password) if err != nil { return err } u.Password = h return nil } ```

四月 18, 2026

claude code plugin 实验

🎯 实验前:假设与目标 (Plan) 开发一个 Plugin 给 Claude Code 使用。 ...

四月 10, 2026

Grafana 复习

基础入门 - 基础入门 - 可观测性与 Grafana 概览 - 理解可观测性 - 什么是可观测性 - 指通过系统对外输出的数据,来推断系统内部状态的能力 - 和监控的区别:监控是预先定义好要看什么,可观测性让你能回答事先没想到的问题 - 三大支柱 - Metrics(指标): - 带事件戳的数值数据,例如 QPS、响应事件 P99、内存使用率 - 指标的特点是体积小、聚合性强,适合用来回答系统整体表现如何 - Logs (日志): - 是离散的事件记录,比如一条错误堆栈、一次用户登录、一个数据库慢查询 - 日志的特点是信息丰富但体积大,适合用来回答到底发生了什么 - 指标告诉你系统有问题,需要通过日志定位具体原因 - Traces(链路追踪): - 记录一个请求在分布式系统中经过的所有服务和耗时 - 一个 API 请求经过了网关 -> 用户服务 -> 订单服务 -> 数据库 - Trace 记录每一跳的耗时 - 适合回答请求慢在哪个环节 - 认知 Grafana 生态 - Grafana 是什么 - 是 Grafana Labs 开发的开源可视化与监控平台 - Grafana 本身不存储数据,是一个统一的查询和可视化前端 - LGTM 技术栈 - LGTM 代表四个核心组件-Loki、Grafana、Tempo 和 Mimir,每个负责可观测性的一个关键方面 - L - Loki:日志聚合系统,类似 ELK 中的 Elasticsearch 但是更轻量 - G - Grafana:可视化中心,所有数据汇聚于此 - T - Tempo:分布式追踪后端,存储和查询 Trace 数据 - M - Mimir:长期指标存储,可以理解为 Prometheus 的增强版 - 了解 Prometheus 在生态中的角色 - Prometheus 是什么 - 是一个开源的指标监控和告警系统 - 在 Grafana 中扮演数据采集和存储的角色 - 关键特征 - 拉取模型(Pull Model):Prometheus 主动去拉取目标服务的指标,而不是目标服务推送数据给它,服务只需暴露 /metrics HTTP 端点 - 时序数据库:所有指标都按时间戳存储,形成时间序列 - PromQL:Prometheus 自带的查询语言 - 环境搭建与初识 Grafana 界面 - 用 Docker Compose 搭建环境 - 创建项目目录 - ``` grafana-learning/ ├── docker-compose.yml └── prometheus/ └── prometheus.yml ``` - 启动服务 - 在 grafana-learning 目录打开终端,运行 ```bash docker-compose up -d ``` - 验证 prometheus - 打开浏览器访问 localhost:9090 - 在查询框输入 up 点击 execute - 结果 up{job="prometheus"} 的值为 1 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163113390.png,440,150) - 点击 Status -> Targets,看到 prometheus 的 target,状态为绿色的 UP - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163148074.png,400,150) - 验证 Grafana - 打开 localhost:3000,使用 admin/admin123 登录 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163255350.png,309,338) - 熟悉 Grafana 界面 - Home:首页,包含最近访问的仪表盘和快捷入口 - Dashboard:管理和浏览所有仪表盘,核心功能区 - Explore:临时查询和调试数据的工作台 - Alerting:配置告警规则,通知渠道和静默策略 - Connections:管理数据源连接,在这里添加 Prometheus - Administration:系统管理(用户、组织、插件、服务器设置等) - 连接 Prometheus 并创建第一个面板 - 添加 Prometheus 数据源 - 在 Grafana -> Connections -> Data source -> Add data source (Prometheus) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163355890.png,400,300) - 配置 Connection URL:http://prometheus:9090 (容器名) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163429596.png,300,80) - 出现 queried the Prometheus API 提示,说明连接成功 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163506913.png,410,60) - 创建第一个 Dashboard - Dashboard -> New -> New dashboard -> Add visualization - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163625329.png,430,290) - Metric ,输入 up,点击 Run queries,看到值为 1 的直线 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163741421.png,260,160) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163813561.png,260,190) - Save Dashboard 保存面板 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410163933122.png,260,210) - 添加第二个面板 - Add -> Visualization - 输入 prometheus_target_interval_length_seconds - 记录了 Prometheus 实时抓取目标的时间间隔,会看到多条线,因为有不同的 quantile 标签 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410164235654.png,260,200) - 面板设置,Title -> 抓取间隔分布,Legend 模式改为 Custom 输入 quantile - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410164840562.png,260,170) - 调整时间范围 - 调整时间选择器 Last 6 hours - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410165639451.png,340,320) - 理解 Prometheus 的基本概念 - Prometheus 的工作原理 - 拉取模型 - 传统的方式是应用主动把数据推给监控服务器 - Prometheus 按照固定的事件间隔,主动去拉取每个目标暴露的指标数据 - Prometheus 每隔 15s (scape_interval) 向目标服务发起一个 HTTP GET 请求,访问 /metrics 端点 - 解析返回的文本并存入本地的时序数据库 - 优点:被监控的服务不需要知道 Prometheus 地址,只需暴露一个 HTTP 端点 - Prometheus 可以集中管理所有采集目标的配置,如果某个目标挂了,Prometheus 能立刻发现 - metric 数据结构 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410175651692.png,260,150) - 格式:指标名称{标签1="值1", 标签2="值2"} 数值 时间戳 - 四种指标类型 - Counter 计数器 - 只增不减,每次事件发生,值就加 1 - 典型用例是请求总数、错误总数、发送的字节数 - 几乎不会直接看 Counter 的原始值,而是用 rate() 函数计算它的增长速率 - 例如 rate(http_requests_total[5m]) 告诉过去 5m 平均每秒处理了多少请求 - Counter 在服务重启时会归零,但 rate() 函数能自动处理这种重置 - Gauge 仪表盘 - Gauge 可以上升也可以下降,代表某个瞬时状态的快照 - 典型用例是当前内存的使用率、CPU 温度、队列中任务量 - 与 Counter 不同,Gauge 的原始值本身就有意义,不需要 rate() - Histogram 直方图 - Histogram 把观测值按照预定义的区间分类计数 - 每次请求的响应时间会被分到不同的时间区间中 - Histogram 在 Prometheus 中实际会产生多个时间序列:_bucket(每个区间的累计计数)、_count(总观测次数)、_sum(所有观测值的总和) - Histogram 的核心价值在于它可以在服务端灵活计算任意分位数 - 可以用 histogram_quantile(0.95,...) 在查询时计算 P95 - Summary 摘要 - Summary 和 Histogram 解决类似的问题,但是它在客户端直接计算分位数 - Summary 的缺点是分位数在客户端计算后就固定了,无法跨实例聚合 - 实际项目中,Histogram 比 Summary 用的更多 - 理解标签 - 一个指标名称加上一组标签,唯一确定一条时间序列 - 什么是标签 - 查询 prometheus_http_requests_total: ``` prometheus_http_requests_total{code="200", handler="/api/v1/query", instance="prometheus:9090", job="prometheus"} → 42 prometheus_http_requests_total{code="200", handler="/metrics", instance="prometheus:9090", job="prometheus"} → 1580 prometheus_http_requests_total{code="302", handler="/", instance="prometheus:9090", job="prometheus"} → 3 ``` - code、handler、instance、job 都是标签,虽然指标名称是 prometheus_http_requests_total,但是每组不同的标签组合构成了独立的时间序列 - 标签可以做维度分析:按 HTTP 状态码筛选,按接口路径分组,按实例聚合 - 需要注意标签的基数,如果用用户 ID 作为标签,会导致存储爆炸 - 查询 - prometheus_http_requests_total{code="200"} 只看成功的请求 - 在 Grafana 中连接数据源与创建面板 - Explore 查询实验台 - 查询 Counter 指标 - prometheus_http_requests_total - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410191834327.png,400,400) - 只看 /metrics 端点的请求 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410191950185.png,400,400) - 使用 rate() 函数把累计值转换为速率 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410192131676.png,400,400) - [5m] 是范围选择器,表示过去 5min 内每秒的平均请求数,指定了计算速率的时间窗口 - 查询 Gauge 指标 - go_goroutines - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410192303151.png,400,400) - go_memstats_alloc_bytes/1024 - 是 Prometheus 进程当前分配的内存字节数,/1024 转换为 KB - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410192453732.png,400,400) - 观察 Histogram 指标的结构 - prometheus_http_request_duration_seconds_bucket - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410192606038.png,400,375) - 每个时间序列都带有 le 标签,表示 less than or equal - 计算 P90 - histogram_quantile(0.9, rate(prometheus_http_request_duration_seconds_bucket[5m])) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410193139016.png,400,375) - 构建完整的 Dashboard - 创建 Dashboard - Dashboard -> New -> New dashboard,save dashboard 命名为 "Day 4-Prometheus 自监控" - 面板 1:Prometheus 运行状态 (Stat 面板) - Add -> Visualization,面板类型从 Time series 切换成 Stat - 查询输入 up{job="prometheus"} - Title 填运行状态 - Value mapping 部分,点击 Add Value mapping,添加 1->正常运行/绿色,0->已宕机/红色 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410194252635.png,300,300) - 面板 2:每秒请求数 (Time Series 面板) - 查询输入 rate(prometheus_http_requests_total[5m]) - Title "HTTP 请求速率 (按 handler)" - Legend 输入 {{handler}} - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/10/20260410194559215.png,300,300) - 面板 3:内存使用量 (Time Series 面板) - go_memstats_alloc_bytes / 1024 / 1024 - Title 填内存使用 (MB),右侧设置找到 Standard options -> Unit,搜索并选择 Megabytes (SI),设置 Decimals 为 1,让数值显示一位小数 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412093035837.png,440,340) - 面板 4: Goroutine 数量 (Gauge 面板) - 这里的 Gauge 是 Grafana 的面板类型,不是 Prometheus 的指标类型,同名但是概念不同 - go_goroutines - Title 填 Goroutines,在 Standard options 中设置 Min 为 0,Max 为 100 - 找到 Threshholds 部分,设置阈值:绿色为基础色,50 以上为黄色,80 以上为红色 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412093435497.png,420,340) - 面板 5: 请求耗时 P90 (Stat 面板) - 新建 Stat - histogram_quantile(0.9, rate(prometheus_http_request_duration_seconds_bucket[5m])) - Title 请求耗时 P90,在 Standard Options -> Unit 中选择 seconds - 布局建议 - 第一行放小面板-运行状态和请求耗时 P90 这种单值面板适合窄小的尺寸 - 第二行放 HTTP 请求速率这种需要横向展开的时序图 - 第三行并排放内存使用和 Goroutines - 面板探索设置 - Standard options 包含最常用的显示设置:Unit 单位、Min/Max 范围、Decimals 小数位数、Color Schema 配色方案 - Threshholds 用颜色编码数值区间,帮助快速判断好坏 - Overrides 允许为特定的时间序列覆盖全局设置 核心技能 - 核心技能 - PromQL - 两种向量 - PromQL 的基石 - 即时向量 - 即时向量返回每条时间序列在某一时刻的采样值,之前的大部分查询都是即时向量 - up/go_goroutines/prometheus_http_requests_total{handler="/metrics"} - 每条查询返回的是当前时刻每条匹配的事件序列的最新值 - 切换到 Table 视图看的更清晰 - 范围向量 - 返回每条时间序列在一个时间窗口内的所有采样值,语法是在指标名后加 [时间窗口] - prometheus_http_requests_total{handler="/metrics"}[5m] - 返回的是过去 5 分钟内的所有采样点 - 范围向量不能直接绘图,只能作为函数的输入转化为即时向量,最常见的就是 rate() - 时间窗口的写法 - 支持这些时间单位 s(秒)、m(分钟)、h(小时)、d(天)、w(周)、y(年) - 可以组合使用 [1h30m] 表示 1小时 30 分钟 - 窗口越短,曲线越尖锐,能捕捉到更细微的波动,窗口越长,曲线越平滑,反映的是更长期的趋势 - 5m 是最常用的窗口 - 核心函数 - rate() - 计算 Counter 在指定时间窗口内的平均增长率,会自动处理 Counter 重置 - rate(prometheus_http_requests_total[5m]) - irate() - irate() 只用时间窗口内最后两个数据点计算瞬时速率,对突刺更敏感 - irate(prometheus_http_requests_total[5m]) - 因为只看两个点,结果不够稳定,一般不用于告警规则 - increase() - 返回 Counter 在时间窗口内的总增长量 - increase(prometheus_http_requests_total{handler="/metrics"}[1h]) - 告诉你过去 1 h /metrics 端点一共被请求了多少次 - sum() - sum() 把多条时间序列的值加在一起 - sum(rate(prometheus_http_requests_total[5m])) - 把所有 handler、所有 code 的请求速率加总,得到一个总 QPS - sum by (code) (rate(prometheus_http_requests_total[5m])) - 可以用 by 子句按某个标签维度分组 - avg() - 有多个实例时,avg() 能给出平均值 - 也支持 by 分组 - count() - count(prometheus_http_requests_total) - 返回的是有多少条时间序列匹配这个查询 - 数学运算与常用模式 - 算术运算 - 支持标准的数学运算符 - go_memstats_alloc_bytes / 1024 / 1024 - 两个指标之间也能做运算 HTTP 请求的错误率 - sum(rate(prometheus_http_requests_total{code=~"5.."}[5m]))/sum(rate(prometheus_http_requests_total[5m])) - 比较运算 - go_goroutines > 30 - 聚合函数 - min(prometheus_http_request_duration_seconds_sum) - max(prometheus_http_request_duration_seconds_sum) - topk(3, prometheus_http_requests_total) - bottomk(2, prometheus_http_requests_total) - 标签匹配的四种方式 - 精确匹配 = - prometheus_http_requests_total{handler="/metrics"} - 精确排除 != - prometheus_http_requests_total{handler!="/metrics"} - 正则匹配 - prometheus_http_requests_total{handler=~"/api/v1/.*"} - 正则排除 - prometheus_http_requests_total{handler!~"/api/v1/.*"} - 聚合-by 与 without - by 保留指定标签 - by 告诉聚合函数"按这些标签分组,其余标签全部丢弃" - sum by (code) (rate(prometheus_http_requests_total[5m])) - 可以按多个标签分组 - sum by (code, handler) (rate(prometheus_http_requests_total[5m])) - without 排除指定标签 - 按除了这些标签之外的所有标签分组 - sum without (instance) (rate(prometheus_http_requests_total[5m])) - 会移除 instance 标签维度,保留其他所有标签。效果是把同一个 job 下不同实例的数据合并。 - 什么时候用 by/without - 关心的维度少,想丢弃的维度多,用 by - 时间偏移与数据对比 - offset 查看历史数据 - go_goroutines offset 1h - 返回的是 1 小时前的 goroutine 数量。单独看这个值意义不大,但它可以用来做环比对比。 - 计算同比/环比变化 - 用当前值减去历史值,就能得到变化量 - ``` rate(prometheus_http_requests_total{handler="/metrics"}[5m]) - rate(prometheus_http_requests_total{handler="/metrics"}[5m] offset 1h) ``` - 深入 Grafana 面板类型 - 面板选择的思考方式 - 单个关键数值 - Stat 或 Gauge - Stat 适合展示当前值,Gauge 适合展示有范围的百分比 - 随时间变化的趋势 - TimeSeries - 最常用的面板类型,适合 QPS、延迟、内存使用等需要观察时间趋势的指标 - 多项的大小对比 - Bar chart - 适合按维度比较大小,例如各服务的请求量排行、各状态码的占比 - 数据的分布密度 - Heatmap - 用颜色深浅表示值的密集程度,非常适合展示请求延迟分布随时间的变化,Histogram 数据的最佳搭档 - 结构化明细数据 - Table - 展示多列明细数据,适合需要精确数值和排序的场景 - TimeSeries 进阶 - 面板 1:多线对比 - 查询:rate(prometheus_http_requests_total[5m]) - Legend 填写 {{handler}} {{code}} - 右侧设置 Graph Styles,Line Width:2, Fill opacity:10(线条下方增加淡填充色) - Tooltip,设置为 All,Sort order: Descending - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412104830826.png,600,220) - 面板 2:堆叠面积图 - 查询:sum by (code) (rate(prometheus_http_requests_total[5m])) - Title: 请求量堆叠(按状态码) - Legend {{code}}、Graph styles,找到 Stack series 设置为 Normal - Fill opacity 调整到 50 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412105336324.png,540,300) - Stat 面板的丰富配置 - 面板 3:带 sparkline 的 Stat - sum(rate(prometheus_http_requests_total[5m])) - Title:总 QPS,Stat Stype: Grap mode -> Area (在大数字下面显示迷你趋势线-sparkline) - Standard options -> Unit requests/sec (ps),Decimals=2 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412105937729.png,551,342) - 面板 4:多值 Stat - 查询 A:sum(rate(prometheus_http_requests_total{code="200"}[5m])) - 查询 B:sum(rate(prometheus_http_requests_total{code!="200"}[5m])) - Legend:A -> 成功请求,B -> 非 200 请求 - Orientation -> Horizontal 两个值会并排显示 - Thresholds B 设置 0 为绿色,0.1 以上为红色 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412110948641.png,541,320) - BarChart 维度对比 - 面板 5:按 handler 排行的柱状图 - sort_desc(sum by (handler) (increase(prometheus_http_requests_total[1h]))) - 这条查询计算过去 1 h内每个 handler 的请求总量,按降序排列 - Bar chart 显示效果不理想,PromQL 返回的是时间序列数据,Bar chart 更适合展示某一时刻的快照 - 在查询编辑器的 Options 区域,把 Format 改为 Table,把 Type 改为 Instant - 在右侧面板设置中,"Orientation" 改为 "Horizontal"(横向柱状图) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412122419997.png,600,170) - Table 结构化明细 - 面板 6:指标明细表 - sort_desc(sum by (handler, code) (increase(prometheus_http_requests_total[1h]))) - 在 Option 中设置 Format 为 Table,Type 为 Instant - Overrides -> Add field override -> Fields with name -> 选择 value - 点击 "Add override property" → 在搜索框中输入 "cell" → 选择 "Cell type",然后设为 "Colored background" - 颜色效果依赖 Threshold 的配置 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412151854303.png,422,282) - Heatmap 分布密度 - Heatmap 是 Histogram 数据的最佳搭档 - 用颜色深浅表示数据密度,X 轴是时间,Y 轴是数值区间 - 面板 7:请求延迟分布热力图 - sum(increase(prometheus_http_request_duration_seconds_bucket[5m])) by (le) - 把所有 handler 的延迟 bucket 数据汇总,le 标签表示 bucket 的上界 - Format -> Heatmap - Yaxis,Unit -> seconds,Colors -> Scheme 选择不同的配色方案 - Title:请求延迟分布 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412152205566.png,530,310) - Dashboard 布局优化 - 第一行放概览指标(Stat 面板) - 第二行放趋势图,Time Series 面板(多线对比,堆叠面积图) - 第三方放分析图,Bar Chart 和 Heatmap 并排放在第三排 - 第四行放明细表,Table 面板放在最底部 - Dashboard 变量与交互设计 - 理解变量的作用 - 变量允许用户通过下拉框切换要查看的数据维度,而不用为每个维度创建单独的面板 - 创建一个变量 - 进入变量设置 - Dashboard 页面 -> Settings -> Variables -> Add variable - 创建 handler 变量 - Name 填 handler,这是变量的标识符,在查询中用 $handler 引用它 - Label 填接口,这是下拉框旁边显示的中文标签 - Type 选 Query,这表示变量的候选值从数据源查询获得 - Data source 选 prometheus - Query Type 选 Lable Values - Label 选 handler - Metric 选 prometheus_http_requests_total - 告诉 Grafana 从 prometheus_http_requests_total 中提取所有不同的 handler 标签值作为下拉选项 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153052768.png,280,300) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153132293.png,400,150) - 在面板使用变量 - 添加 Time Series 面板 - 查询:rate(prometheus_http_requests_total{handler="$handler"}[5m]) - Legend 填 {{code}},Title 填 $handler 请求速率,变量在 title 中也能用 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153254711.png,530,340) - 多选与全选 - Dashboard Settings -> Variables -> handler 变量进行编辑 - Selection options 部分,勾选 Multi-value,允许用户同时勾选多个 handler - 勾选 Include All option ,在下拉框增加 ALL 选项 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153554336.png,350,190) - 修改面板查询为 rate(prometheus_http_requests_total{handler=~"$handler"}[5m]) - Grafana 会将多选处理为正则表达式 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412153751767.png,430,350) - 内置变量 - Grafana 提供了一些内置变量,不需要手动创建,可以直接在查询中使用 - $__interval 和 $__rate_interval - 是最重要的内置变量 - 会根据 Dashboard 的时间范围和面板宽度,自动计算一个合理的时间步长 - $__rate_interval 的智能之处 - 当查看过去一小时的数据,可能是 1 分钟 - 查看过去 7 天的数据,可能是 15 分钟 - 无论时间范围怎么变,都能返回合理密度的数据点 - $__rate_interval 和 $interval 的区别 - $__rate_interval 至少覆盖 4 个采样周期,更适合搭配 rate() 使用 - $__interval 纯根据面板像素密度计算步长 - $__range - 表示当前 Dashboard 选择的完整时间范围,在 increase 中有用 - increase(prometheus_http_requests_total{handler=~"$handler"}[$__range]) - $__dashboard 和 $__name - $__dashboard 是当前 Dashboard 的名称,$__name 是面板的名称 - 一般用在告警通知模板中,不常用在查询里 - 用 Go 构建可观测 HTTP 服务 - 编写带 Prometheus 指标的 Go 服务 - 构建一个模拟的订单 API 服务,会暴露:请求计数 (Counter)、请求延迟 (Histogram)、当前处理中的请求数 (Gauge)。 - 指标设计 - myapp_http_requests_total(Counter) - 按 method、endpoint、status 三个维度记录请求总数 - Counter 是因为请求数只增不减 - 三个标签维度可以让你从不同角度分析:按接口看、按状态码看,按 HTTP 方法看 - myapp_http_request_duration_seconds(Histogram) - 记录请求延迟分布 - 用 Histogram 而不是 Summary,是因为要计算不同分位 - myapp_http_requets_in_flight(Guage) - 当前正在处理的请求数 - Guage 是由于这个值可增可减 - myapp_orders_created_total(Counter) - 业务指标,按商品类型记录订单数 - myapp_order_queue_size(Guage) - 模拟的业务指标,队列深度 - 启动服务并验证 - 启动服务 - ```bash docker-compose down docker-compose up -d --build ``` - --build 参数会重新构建 myapp 镜像。首次构建需要下载 Go 依赖 - 验证 myapp - 访问 http://localhost:8080/health,看到 {"status": "healthy"} - 访问 http://localhost:8080/metrics,看到自定义指标和 Go 运行时指标 - 验证 prometheus 抓取 - 访问 http://localhost:9090/targets,看到两个 target:prometheus 和 myapp,状态是 up - 查询 myapp_http_requests_total,确认有数据返回 - 构建应用监控 Dashboard - 创建新 Dashboard "My GoApp-监控面板" - 创建变量 - Settings -> Variables - 添加 endpoint 变量: - Type -> Query,Data source -> Prometheus - query type -> label values,Label -> myapp,metric->myapp_http_requests_total - Multi-value 和 Include All option 勾选 - 面板 1:应用状态 - 查询 up{job="myapp"} - 类型 Stat,配置 Value mappings: 1->在线(绿色),0->离线(红色),Title->应用状态 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412170743853.png,250,140) - 面板 2:总 QPS (state + sparkline) - 查询:sum(rate(myapp_http_requests_total{endpoint=~"$endpoint"}[$__rate_interval])) - 类型选 Stat,Graph mode 设置为 Area,Unit 设置为 reqps,Title 填写 QPS - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412170957566.png,250,140) - 面板 3:错误率 (Stat) - 查询: ``` sum(rate(myapp_http_requests_total{endpoint=~"$endpoint", status=~"4..|5.."}[$__rate_interval])) / sum(rate(myapp_http_requests_total{endpoint=~"$endpoint"}[$__rate_interval])) * 100 ``` - Unit 设为 "percent (0-100)" - Thresholds 设置:绿色为基础,5 以上黄色,10 以上红色。Title 填 "错误率"。 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412171243965.png,250,150) - 面板 4:请求速率按状态码(Time Series 堆叠) - 查询:sum by (status) (rate(myapp_http_requests_total{endpoint=~"$endpoint"}[$__rate_interval])) - Legend 填 {{status}}。Graph styles 中 Stack series 设为 "Normal",Fill opacity 设为 40。 - Overide: 添加 "Fields with name" 为 "200",Override 属性选 "Color",设为绿色。重复操作,给 "500" 设红色,"400" 设黄色,"201" 设浅绿色。 - Title 填 "请求速率(按状态码)" - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412171817077.png,250,150) - 面板 5:请求延迟分位数 (Time Series) - 同时展示 P50,P90,P99 三条线 - 查询 A: - Legend P50 - histogram_quantile(0.5, sum by (le) (rate(myapp_http_request_duration_seconds_bucket{endpoint=~"$endpoint"}[$__rate_interval]))) - 查询 B: - Legend P90 - histogram_quantile(0.9, sum by (le) (rate(myapp_http_request_duration_seconds_bucket{endpoint=~"$endpoint"}[$__rate_interval]))) - 查询 C - Legent P99 - histogram_quantile(0.99, sum by (le) (rate(myapp_http_request_duration_seconds_bucket{endpoint=~"$endpoint"}[$__rate_interval]))) - Unit: seconds,Title:请求延迟分位数 - 通过 Overrides 给三条线设置不同的视觉效果:P50 绿色实线,P90 用黄色实线,P99 用红色实线 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/12/20260412225759292.png,310,150) 告警与日志 - 告警与日志 - Grafana 的告警系统 - 理解 Grafana 告警架构 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418104323716.png,618,330) - 配置 Contact Point - 左侧导航栏 Alerting -> Contact points -> Create contact points - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418104545088.png,277,414) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418104623733.png,400,150) - Name 填学习用 Webhook,Integration 选 Webhook,URL 填 http://myapp:8080/health - 用 Go 应用的健康检查端点作为 Webhook 接收地址,不会真正处理报警,但能验证通知是否发出 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418104854183.png,400,250) - 内置了一个默认的 grafana-default-email Contact Point,生产环境会配置 Email、Slack、DingTalk、PagerDuty 等 - 创建第一条告警规则(监控 Go 应用的错误率) - 创建告警规则 - Alerting -> Alert Rules -> New alert rule - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418105203977.png,400,350) - 命名规则 - Rule name 填入应用错误率过高 - 定义查询和条件 - 在 Define query and alert condition 部分,会看到一个查询编辑器 - 查询 A: 选择数据源 Prometheus,切换到 Code 模式,输入 - sum(rate(myapp_http_requests_total{status=~"4..|5.."}[5m])) / sum(rate(myapp_http_requests_total[5m])) * 100 - 计算的是过去 5 分钟内的错误率百分比 - 下方 Set Alert Condition,设置 WHERE QUERY IS ABOVE 5 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418105855803.png,400,125) - 通过 preview alert rule condition 可以预览报警状态,如果已经满足条件,会显示为 firing - 配置评估行为 - 在 Add foler and labels 下选择或创建一个文件夹 (学习告警) - Evaluation 创建一个新的,叫做默认评估组,评估间隔 1m,每分钟评估一次 - Pending period 填写 2,这个参数很重要,表示条件必须持续满足 2 分钟后才会真正触发告警 - 避免了短暂的尖峰导致的误报,告警会先进入 Pending 状态,2 分钟后才会变为 Firing - 添加标签和注释 - 在 Add folder and labels 部分 - 添加一个 Label: key 填 severity,value 填 warning。标签用于告警路由 - Notification Policy 可以决定把报警发给谁 - 在 Configure notification message 中:Summary 填应用错误率超过 5% - Description 填当前错误率为 {{$value.B.value}},已超过 5% 的告警阈值 - {{ $value.B.value }} 是模板变量,会在告警触发时被替换为实际的错误率数值。 - 理解告警状态与查看告警 - 在 Alerting -> Alert Rules 可以查看所有规则的状态 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418174616492.png,300,100) - 在 Dashboard 中添加一个 Alert list 面板,会展示当前活跃的所有告警 - Loki 与日志监控 - Loki 的设计哲学 - 传统日志系统(ElasticSearch)会对日志内容做全文索引,每个词都被索引,搜索速度快但是存储和计算成本高 - Loki 只索引日志的元数据标签,不索引日志内容本身,查询先通过标签快速定位到相关的日志流,再在这些流中做文本搜索 - 部署 Loki 和 Promtail - Loki 是日志存储和查询引擎,Promtail 是日志采集代理,类似 Node Exporter 采集指标,Promtail 采集日志 - 更新 docker-compose.yml - 在底部的 volumns 部分添加 loki_data: - 创建 Promtail 配置文件 - 放在 grafana-learning/promtail 目录下 - 理解 Promtail 配置 - clients 指定了日志推送的目标地址 - Loki 的 api 端点 - 和 Prometheus 的拉取模型不同,Loki 使用推送模型:Promtail 主动把采集到的日志推送给 Loki - scrape_configs 定义了日志采集源 - 本次使用了 docker_sd_configs,通过 Docker Socket 自动发现所有运行中的容器并采集它们的标准输出日志 - relabel_configs 对标签做转换 - __meta_docker_container_name 是 Docker 自动提供的元数据,映射到 container 标签,就能按容器名筛选日志 - 验证 - 访问 http://localhost:3100/ready,返回 ready 说明 Loki 已启动 - docker-compose logs promtail --tail 20 - 能看到 Successfully connected to Loki 的日志 - 在 Grafana 中添加 Loki 数据源 - 在 Grafana 中点击 Connections -> Data sources -> Add data source,搜索 Loki - Connection URL 填写 http://loki:3100 - 用 Explore 查看日志 - 点击左侧 Explore,在顶部数据源下拉框中切换到 Loki - 第一次查询 - Loki 查询语言是 LogQL,结构和 PromQL 非常相似 - 最基本的查询是用花括号指定标签筛选:{container="myapp"} - 输入后点击 Run query - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418210037672.png,430,360) - 按容器筛选 - {container="prometheus} - 查看所有容器的日志 - {container=~".+"} - =~".+" 是正则匹配 - LogQL 管道操作 - 可以在标签筛选的基础上,用 | 符号串联多个处理步骤 - 文本过滤 - 只看包含 error 的日志 - {container="myapp"} |= "error" - |= 表示包含,!= 表示不包含 - {container="myapp"} != "health" - 正则过滤 - {container="myapp"} |~ "status.*500" - |~ 是正则匹配,!~ 是反向正则过滤 - 管道串联 - 多个过滤条件可以串联,形成管道 - {container="myapp"} |= "error" != "health" - 管道中的每一步都在上一步的结果基础上继续过滤 - JSON 解析 - 如果 Go 应用输出的是 JSON 格式的日志,可以用 | json 进行自动解析 - {container="myapp"} | json - 解析后,json 中的每个字段都变成了可筛选的标签 - {"level":"error"} - {container="myapp"} | json | level="error" - 行格式化 - | line_format 可以重新格式化日志的显示方式 - {container="myapp"} | line_format "{{.container}}: {{.__line__}}" - 从日志提取指标 - Metrics from Logs 能让你不需要在代码埋点就能从日志中生成监控指标 - 计算日志量速率 - rate({container="myapp"}[5m]) - 按容器分组统计 - sum by (container) (rate({container=~".+"}[5m])) - count_over_time - 计算时间窗口内的日志总行数 - count_over_time({container="myapp"} |= "error" [1h]) - 在 Dashboard 中整合日志面板 - 实时日志流 - 右侧面板类型选择 Logs,数据源选 Loki - 查询: {container="myapp"} != "metrics",排除 /metrics 端点的请求日志 - 在右侧面板设置中,找到 Log details 区域,确保 Show time 和 Wrap lines 都开启 - Tilte 填应用日志,这个面板会实时滚动 Go 应用的最新日志 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/18/20260418220304056.png,260,150) - 错误日志速率 (Time Series 面板) - 新建 Time Series 类型,数据源选择 Loki - rate({container="myapp"} |= "error" [5m]) - Title 填写错误日志速率,Unit 设置为 logs/sec - LogQL 进阶与指标日志联动 - 更多日志解析器 - logfmt 解析器 - logfmt 是 Go 生态常见的日志格式,形如 level=info msg="request handled" method="GET" - 如果日志是这种键值对格式,可以用:{container="myapp"} | logfmt - 解析后每个键值对都变成可以筛选的标签 - 当前 Go 应用用的是标准 log.Println 输出,不是 logfmt 格式 - pattern 解析器 - pattern 是最灵活的解析器,允许用模板来描述日志的结构,提取出其中的字段 - 语法是使用 <field_name> 来做占位符 - {container="myapp"} | pattern `<_> "<method> <path> <_>" <status> <_>` - 这条查询假设日志中包含类似 "GET /api/orders HTTP/1.1" 200 ... 的内容 - 会把 HTTP 方法提取为 method,路径提取为 path,状态码提取为 status - <_> 是丢弃占位符,匹配但是不保存 - regxp 解析器 - {container="myapp"} | regexp `(?P<method>GET|POST|PUT|DELETE) (?P<path>/\S+)` - (?P<name>...) 是命名捕获组,提取的值会变成同名标签,正则解析器功能最强但是性能最差 - 标签提取与 label_format - line_format 重新格式化日志显示 - line_format 用 Go 模板语法重新定义每行日志的显示内容 - {container=~"myapp|prometheus"} | line_format "[{{.container}}] {{.__line__}}" - 这会在每行日志前加上方括号包裹的容器名 - line_format 转换标签 - label_format 可以基于现有标签生成新标签或修改标签名 - {container="myapp"} | label_format short_id="{{.container_id | trunc 12}}" - 这条查询把容器 ID 截取前 12 位,存为 short_id 标签,trunc 12 是 Go 模板的字符串截取函数 - drop 和 keep 标签 - 当解析器提取了太多标签导致查询杂乱,可以用 drop 丢弃不需要的标签,或用 keep 只保留需要的 - {container="myapp"} | json | keep container, level, msg 附录 环境配置 docker-compose.yml ...

四月 10, 2026

js 复习

语言核心基础 - 语言核心基础 - 变量与数据类型 - 变量声明 - JavaScript 有三种声明变量的关键字,它们的核心区别在于作用域和可变性 - var - var 是 ES5 时代的声明方式,它的作用域是函数级别的,而不是块级的。 - 这意味着在 if 或 for 的大括号里用 var 声明的变量,在外面依然可以访问 - 另外 var 存在"变量提升"(hoisting)——声明会被提升到函数顶部,但赋值不会,所以在声明前访问会得到 undefined 而不是报错 - ```js function example() { console.log(a); // undefined(不会报错,因为声明被提升了) var a = 10; if (true) { var b = 20; } console.log(b); // 20(var 没有块级作用域) } ``` - let - let 是 ES6 引入的,拥有块级作用域,不会被提升到可访问状态(存在"暂时性死区" TDZ),在声明前访问会直接报 ReferenceError。 - ```js function example() { // console.log(a); // ReferenceError! let a = 10; if (true) { let b = 20; } // console.log(b); // ReferenceError! b 只存在于 if 块内 } ``` - const - const 和 let 一样有块级作用域和 TDZ,但它要求声明时必须赋值,且绑定不可重新赋值。 - 注意"不可重新赋值"不等于"不可变"——如果 const 指向一个对象或数组,对象内部的属性是可以修改的。 - ```js const PI = 3.14159; // PI = 3; // TypeError: Assignment to constant variable const user = { name: 'An' }; user.name = 'Bob'; // ✅ 没问题,修改的是对象属性,不是重新赋值 // user = {}; // ❌ TypeError,不能重新绑定 ``` - 七种原始类型 - JavaScript 的数据类型分为原始类型和引用类型。 - 原始类型有 7 种,它们的值是不可变的,按值传递。 - string 字符串 - ```js const s1 = 'hello'; // 单引号 const s2 = "world"; // 双引号 const s3 = `你好 ${s1}`; // 模板字符串,支持插值和换行 ``` - 字符串是不可变的。s1[0] = 'H' 不会报错但也不会生效。所有字符串方法都返回新字符串。 - number 数字 - JavaScript 只有一种数字类型,使用 IEEE 754 双精度浮点数。这带来了经典问题 - ```js console.log(0.1 + 0.2); // 0.30000000000000004 console.log(0.1 + 0.2 === 0.3); // false // 特殊值 console.log(Infinity); // 正无穷 console.log(-Infinity); // 负无穷 console.log(NaN); // Not a Number console.log(NaN === NaN); // false!NaN 不等于自身 console.log(Number.isNaN(NaN)); // true,用这个判断 ``` - bigint 大整数 - 当数字超过 Number.MAX_SAFE_INTEGER(2⁵³ - 1)时使用 - ```js const big = 123456789012345678901234567890n; // 末尾加 n console.log(typeof big); // "bigint" // big + 1; // ❌ TypeError,bigint 不能和 number 混合运算 big + 1n; // ✅ ``` - boolean 布尔值 - 只有 true 和 false。但在条件判断中,其他类型会被隐式转换为布尔值。 - 记住这些 falsy 值(转换为 false 的值): - ```js // 以下全部是 falsy false, 0, -0, 0n, "", null, undefined, NaN // 其他一切都是 truthy,包括: // "0", " ", [], {}, function(){} 都是 truthy! ``` - null 空值 - 表示"有意为空",通常用于显式地标记一个变量"目前没有值"。 - ```js let user = null; // 明确表示:当前没有用户 ``` - undefined 未定义 - 表示变量已声明但未赋值,或者函数没有返回值 - ```js let x; console.log(x); // undefined function foo() {} console.log(foo()); // undefined ``` - symbol 符号 - ES6 引入,每个 Symbol 都是唯一的,主要用作对象属性的唯一标识符 - ```js const id1 = Symbol('id'); const id2 = Symbol('id'); console.log(id1 === id2); // false,每次调用都创建新的 const obj = { [id1]: '这个属性名是唯一的' }; ``` - 引用类型(Reference Types) - 除了原始类型,剩下的都是引用类型(对象)。 - 包括普通对象 {}、数组 []、函数 function、Date、RegExp 等等。 - 引用类型按引用传递,变量存的是内存地址而不是值本身 - ```js const a = { name: 'An' }; const b = a; // b 和 a 指向同一个对象 b.name = 'Bob'; console.log(a.name); // "Bob" —— a 也被改了! // 数组同理 const arr1 = [1, 2, 3]; const arr2 = arr1; arr2.push(4); console.log(arr1); // [1, 2, 3, 4] ``` - typeof 操作符 - typeof 返回一个字符串,表示值的类型 - ```js typeof 42; // "number" typeof 'hello'; // "string" typeof true; // "boolean" typeof undefined; // "undefined" typeof Symbol(); // "symbol" typeof 10n; // "bigint" // ⚠️ 两个著名的"坑" typeof null; // "object" —— 历史遗留 bug,null 其实是原始类型 typeof function(){};// "function" —— 虽然函数也是对象 typeof []; // "object" typeof {}; // "object" ``` - 判断数组应该用 Array.isArray(),判断 null 用 === null - 类型转换 - 显式转换 - ```js // 转字符串 String(123); // "123" (123).toString();// "123" // 转数字 Number("42"); // 42 Number(""); // 0 Number("hello"); // NaN Number(true); // 1 Number(null); // 0 Number(undefined);// NaN parseInt("42px");// 42 —— 会尽量解析 parseFloat("3.14abc"); // 3.14 // 转布尔 Boolean(0); // false Boolean(""); // false Boolean("0"); // true ← 注意!非空字符串都是 true Boolean([]); // true ← 注意!空数组也是 true ``` - 隐式转换 - ```js // + 号:只要有一边是字符串,就做字符串拼接 '5' + 3; // "53" 5 + '3'; // "53" // 其他算术运算符会把字符串转为数字 '5' - 3; // 2 '5' * 2; // 10 '5' / '2'; // 2.5 // == 的隐式转换(不推荐使用) 0 == false; // true "" == false; // true null == undefined; // true null == 0; // false ← 特殊规则 // === 严格相等,不做类型转换(推荐始终使用) 0 === false; // false "" === false; // false ``` - 运算符与流程控制 - 算术运算符 - 基本的 +、-、*、/ 之外,还有几个需要注意的 ```js // 取余(求模) console.log(10 % 3); // 1 console.log(-10 % 3); // -1(结果符号跟被除数一致) // 幂运算(ES2016) console.log(2 ** 10); // 1024,等价于 Math.pow(2, 10) // 自增/自减 let a = 5; console.log(a++); // 5(先返回再加) console.log(a); // 6 console.log(++a); // 7(先加再返回) ``` - 复合赋值运算符就是简写:+=、-=、*=、/=、%=、**=、??=、||=、&&= ```js let count = 10; count += 5; // 等价于 count = count + 5 count **= 2; // 等价于 count = count ** 2 // 逻辑赋值(ES2021) let name = null; name ??= 'Anonymous'; // 如果 name 是 null/undefined,才赋值 console.log(name); // "Anonymous" let config = ''; config ||= 'default'; // 如果 config 是 falsy,就赋值 console.log(config); // "default" ``` - 比较运算符 - ==(宽松相等) 会在比较前做隐式类型转换,规则非常复杂: - ```js 0 == false; // true(false 转为 0) '' == false; // true(两边都转为 0) '1' == 1; // true(字符串转为数字) null == undefined; // true(特殊规则,它俩只跟对方相等) null == 0; // false(null 不会被转为数字) NaN == NaN; // false(NaN 不等于任何东西包括自己) [] == false; // true([] → '' → 0,false → 0) [] == ![]; // true(这个最离谱,但确实是 true) ``` - ===(严格相等) 不做类型转换,类型不同直接返回 false: - ```js 0 === false; // false(number vs boolean) '' === false; // false '1' === 1; // false null === undefined; // false ``` - 实际开发原则:永远使用 === 和 !==。 - 唯一可能用 == 的场景是 x == null 来同时判断 null 和 undefined - 更推荐写成 x === null || x === undefined,或者用 x ?? defaultValue - 逻辑运算符与短路求值 - 逻辑运算符在 JS 中的行为和很多语言不同——它们返回的不是布尔值,而是操作数本身。 - &&(逻辑与):如果左侧是 falsy,返回左侧;否则返回右侧。 - ```js console.log(0 && 'hello'); // 0(左侧 falsy,直接返回左侧) console.log(1 && 'hello'); // "hello"(左侧 truthy,返回右侧) console.log('a' && 'b' && 0); // 0(逐个检查,遇到第一个 falsy 就返回) console.log('a' && 'b' && 'c'); // "c"(全 truthy,返回最后一个) // 实际用法:条件执行 user && user.login(); // 如果 user 存在才调用 login ``` - ||(逻辑或):如果左侧是 truthy,返回左侧;否则返回右侧。 - ```js console.log(0 || 'default'); // "default" console.log('hello' || 'default'); // "hello" // 经典用法:提供默认值 const port = config.port || 3000; // ⚠️ 陷阱:如果 config.port 是 0,也会被跳过! ``` - ??(空值合并,ES2020):只有左侧是 null 或 undefined 时才返回右侧。这解决了 || 的陷阱。 - ```js const port = config.port ?? 3000; // config.port 为 0 → 返回 0 ✅ // config.port 为 '' → 返回 '' ✅ // config.port 为 null/undefined → 返回 3000 // 对比 0 || 3000; // 3000(0 是 falsy) 0 ?? 3000; // 0(0 不是 null/undefined) '' || 'default'; // "default" '' ?? 'default'; // "" ``` - ! (逻辑非) - ```js console.log(!0); // true console.log(!''); // true console.log(!null); // true // 双重取反 !! 可以将任意值转为布尔值 console.log(!!0); // false console.log(!!'hello'); // true console.log(!!null); // false ``` - 可选链与其他实用运算符 - ?.(可选链,Optional Chaining):安全地访问嵌套属性,遇到 null/undefined 时短路返回 undefined 而不是报错。 - ```js const user = { name: 'An', address: { city: 'Tokyo' } }; // 不用可选链 const zip = user && user.address && user.address.zip; // 用可选链 const zip2 = user?.address?.zip; // undefined,不报错 const nothing = null?.foo?.bar; // undefined // 也可以用在方法调用和下标访问上 user.greet?.(); // 如果 greet 方法存在才调用 const first = arr?.[0]; // 如果 arr 存在才取下标 ``` - 逗号运算符:依次执行所有表达式,返回最后一个。用得不多,偶尔在 for 循环中见到 - ```js const result = (1, 2, 3); // result === 3 for (let i = 0, j = 10; i < j; i++, j--) { /* ... */ } ``` - 条件语句 - if/else if/else - ```js const score = 85; if (score >= 90) { console.log('优秀'); } else if (score >= 70) { console.log('良好'); } else if (score >= 60) { console.log('及格'); } else { console.log('不及格'); } ``` - 三元表达式:适合简单的二选一,不要嵌套太深 - ```js const status = score >= 60 ? '及格' : '不及格'; // ❌ 不推荐:嵌套三元,可读性很差 const grade = score >= 90 ? 'A' : score >= 70 ? 'B' : score >= 60 ? 'C' : 'D'; // ✅ 这种情况用 if/else 或函数更清晰 ``` - switch:适合对同一个值做多重精确匹配,注意 break 不能忘 - ```js const day = 'Monday'; switch (day) { case 'Monday': case 'Tuesday': case 'Wednesday': case 'Thursday': case 'Friday': console.log('工作日'); break; // ← 不写 break 会"穿透"到下一个 case case 'Saturday': case 'Sunday': console.log('周末'); break; default: console.log('无效日期'); } ``` - switch 使用 === 进行比较,不会做隐式类型转换。 - 循环语句 - for 循环:最经典的循环,适合需要精确控制索引的场景: ```js for (let i = 0; i < 5; i++) { console.log(i); // 0, 1, 2, 3, 4 } // 倒序遍历 for (let i = arr.length - 1; i >= 0; i--) { console.log(arr[i]); } ``` - while 与 do...while: ```js // while:先判断再执行 let n = 5; while (n > 0) { console.log(n); n--; } // do...while:先执行一次再判断,至少执行一次 let input; do { input = prompt('请输入密码'); } while (input !== 'secret'); ``` - for...of:遍历可迭代对象(数组、字符串、Map、Set 等)的值: ```js const fruits = ['苹果', '香蕉', '橘子']; for (const fruit of fruits) { console.log(fruit); // "苹果", "香蕉", "橘子" } // 遍历字符串的每个字符(对中文友好) for (const char of '你好世界') { console.log(char); // "你", "好", "世", "界" } // 如果同时需要索引,用 entries() for (const [index, fruit] of fruits.entries()) { console.log(`${index}: ${fruit}`); } ``` - for...in:遍历对象的可枚举属性名(键名): ```js const user = { name: 'An', age: 25, city: 'Tokyo' }; for (const key in user) { console.log(`${key}: ${user[key]}`); } // ⚠️ 不推荐用 for...in 遍历数组! // 它会遍历原型链上的属性,顺序也不保证 // 遍历数组请用 for...of 或数组方法 ``` - break、continue 与标签 - ```js // break:立即退出整个循环 for (let i = 0; i < 10; i++) { if (i === 5) break; console.log(i); // 0, 1, 2, 3, 4 } // continue:跳过本次迭代,进入下一次 for (let i = 0; i < 10; i++) { if (i % 2 === 0) continue; // 跳过偶数 console.log(i); // 1, 3, 5, 7, 9 } // 标签(label):用于跳出嵌套循环,实际开发很少用 outer: for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i === 1 && j === 1) break outer; // 直接跳出外层循环 console.log(i, j); } } ``` - 函数基础 - 函数声明 vs 函数表达式 - JavaScript 中定义函数有两种基本方式,它们最大的区别在于提升(hoisting)行为。 - 函数声明(Function Declaration):整个函数会被提升到作用域顶部,因此可以在声明之前调用。 - ```js // ✅ 可以在声明之前调用 greet('An'); function greet(name) { console.log(`你好,${name}!`); } ``` - 函数表达式(Function Expression):把一个匿名或具名函数赋值给变量。变量声明会提升,但赋值不会,所以在赋值之前调用会报错。 - ```js // ❌ TypeError: sayHi is not a function // sayHi('An'); const sayHi = function(name) { console.log(`Hi, ${name}!`); }; sayHi('An'); // ✅ // 具名函数表达式:函数名只在函数内部可见,主要用于递归和调试 const factorial = function fact(n) { return n <= 1 ? 1 : n * fact(n - 1); }; // fact(5); // ❌ ReferenceError,外部不可见 factorial(5); // ✅ 120 ``` - 箭头函数 - ES6 引入的简洁写法,但它不仅仅是语法糖,和普通函数有几个本质区别。 - 基本语法 ```js // 完整写法 const add = (a, b) => { return a + b; }; // 单个表达式可以省略 {} 和 return(隐式返回) const add2 = (a, b) => a + b; // 只有一个参数可以省略括号 const double = x => x * 2; // 没有参数必须写空括号 const getRandom = () => Math.random(); // ⚠️ 返回对象字面量必须加括号,否则 {} 会被当成函数体 const makeUser = (name) => ({ name, active: true }); ``` - 箭头函数与普通函数的区别 ```js // 1. 没有自己的 this,继承外层作用域的 this const team = { name: 'Frontend', members: ['An', 'Bob'], // ❌ 普通函数:this 指向调用者,forEach 的回调中 this 不是 team showBad() { this.members.forEach(function(member) { console.log(`${member} belongs to ${this.name}`); // this.name 是 undefined }); }, // ✅ 箭头函数:继承 showGood 的 this,即 team showGood() { this.members.forEach(member => { console.log(`${member} belongs to ${this.name}`); // "Frontend" }); } }; // 2. 没有 arguments 对象 function normalFn() { console.log(arguments); // ✅ [1, 2, 3] } const arrowFn = () => { // console.log(arguments); // ❌ ReferenceError }; normalFn(1, 2, 3); // 3. 不能用作构造函数 const Person = (name) => { this.name = name; }; // new Person('An'); // ❌ TypeError: Person is not a constructor // 4. 没有 prototype 属性 ``` - 回调函数、数组方法的参数优先用箭头函数 - 对象方法用普通函数(需要自己的 this) - 永远不要用箭头函数定义对象方法或构造函数。 - 参数处理 - 默认参数(Default Parameters): - ```js function createUser(name, role = 'viewer', active = true) { return { name, role, active }; } createUser('An'); // { name: 'An', role: 'viewer', active: true } createUser('An', 'admin'); // { name: 'An', role: 'admin', active: true } createUser('An', undefined, false); // { name: 'An', role: 'viewer', active: false } // 注意:传 undefined 会触发默认值,传 null 不会 createUser('An', null); // { name: 'An', role: null, active: true } // 默认值可以是表达式,甚至引用前面的参数 function createId(prefix, timestamp = Date.now(), id = `${prefix}-${timestamp}`) { return id; } ``` - 剩余参数(Rest Parameters): - 用 ... 收集剩余的参数到一个真正的数组中(不同于 arguments 类数组对象): - ```js function sum(first, ...rest) { console.log(first); // 1 console.log(rest); // [2, 3, 4, 5](真正的数组) return rest.reduce((acc, val) => acc + val, first); } sum(1, 2, 3, 4, 5); // 15 // 剩余参数必须是最后一个参数 // function bad(a, ...b, c) {} // ❌ SyntaxError // 搭配解构使用 function processConfig({ host, port, ...otherOptions }) { console.log(host); // "localhost" console.log(port); // 3000 console.log(otherOptions); // { debug: true, timeout: 5000 } } processConfig({ host: 'localhost', port: 3000, debug: true, timeout: 5000 }); ``` - 返回值 - 函数只能返回一个值。如果需要返回多个值,用对象或数组: ```js // 不写 return 或 return 后没有值,返回 undefined function noReturn() { console.log('hello'); } console.log(noReturn()); // undefined // 返回多个值的模式 function getMinMax(arr) { return { min: Math.min(...arr), max: Math.max(...arr) }; } const { min, max } = getMinMax([3, 1, 4, 1, 5, 9]); // 用数组返回,配合解构 function divide(a, b) { return [Math.floor(a / b), a % b]; // [商, 余数] } const [quotient, remainder] = divide(17, 5); // 3, 2 ``` - return 换行陷阱 ```js function gotcha() { return // ← JS 在这里自动插入分号,函数返回 undefined { name: 'An' }; } console.log(gotcha()); // undefined! // 正确写法:左大括号跟 return 同一行 function correct() { return { name: 'An' }; } ``` - 函数是一等公民 - 在 JavaScript 中,函数和其他值(数字、字符串)没有区别,可以被赋值、传递、返回。 - 赋值给变量: ```js const greet = function(name) { return `Hello, ${name}`; }; const sayHi = greet; // 函数可以像值一样赋给另一个变量 sayHi('An'); // "Hello, An" ``` - 作为参数传递(回调函数): ```js function repeat(n, action) { for (let i = 0; i < n; i++) { action(i); } } repeat(3, console.log); // 0, 1, 2 repeat(3, i => console.log(i * i)); // 0, 1, 4 // 数组方法中大量使用回调 const nums = [1, 2, 3, 4, 5]; const evens = nums.filter(n => n % 2 === 0); // [2, 4] const doubled = nums.map(n => n * 2); // [2, 4, 6, 8, 10] const sum = nums.reduce((acc, n) => acc + n, 0); // 15 ``` - 作为返回值 ```js function multiplier(factor) { return (number) => number * factor; // 返回一个新函数 } const double = multiplier(2); const triple = multiplier(3); double(5); // 10 triple(5); // 15 // 这就是闭包的雏形——返回的函数"记住"了 factor 的值 // Day 4 会深入讲解 ``` - 存储在数据结构中: ```js const strategies = { add: (a, b) => a + b, subtract: (a, b) => a - b, multiply: (a, b) => a * b, }; function calculate(strategy, a, b) { return strategies[strategy](a, b); } calculate('add', 10, 5); // 15 calculate('multiply', 10, 5); // 50 ``` - IIFE(立即调用函数表达式) - IIFE 是一种定义后立即执行的函数模式。在 ES6 模块化之前,它是避免全局变量污染的主要手段。 - ```js // 经典写法 (function() { const secret = '只在这里可见'; console.log(secret); })(); // console.log(secret); // ❌ ReferenceError // 带参数 (function(global) { global.myLib = { version: '1.0' }; })(window); // 箭头函数版 (() => { console.log('立即执行'); })(); // 带返回值 const result = (() => { const a = 10; const b = 20; return a + b; })(); console.log(result); // 30 ``` - 现在有了 ES Modules 和块级作用域(let/const),IIFE 用得少了,但在一些老代码库和特定场景(如隔离作用域执行异步代码)中仍然常见。 - 作用域与闭包 - 作用域的三种层级 - 作用域决定了变量在哪里可以被访问。JavaScript 有三种作用域层级。 - 全局作用域:在所有函数和块之外声明的变量,任何地方都能访问。在浏览器中,全局变量会挂载到 window 对象上。 - ```js const APP_NAME = 'MyApp'; // 全局作用域 function foo() { console.log(APP_NAME); // ✅ 可以访问 } // 浏览器环境下 var x = 10; console.log(window.x); // 10(var 声明的全局变量挂到 window) // let/const 声明的不会挂到 window let y = 20; console.log(window.y); // undefined ``` - 函数作用域:在函数内部声明的变量,外部不可见。var、let、const 在函数内都有这个特性。 - ```js function createSecret() { var secret1 = 'var secret'; let secret2 = 'let secret'; const secret3 = 'const secret'; return 'done'; } createSecret(); // console.log(secret1); // ❌ ReferenceError // console.log(secret2); // ❌ ReferenceError // console.log(secret3); // ❌ ReferenceError ``` - 块级作用域:let 和 const 在 {}(if、for、while 或单独的块)中声明时,只在该块内可见。var 没有块级作用域。 - ```js { let a = 1; const b = 2; var c = 3; } // console.log(a); // ❌ ReferenceError // console.log(b); // ❌ ReferenceError console.log(c); // 3 ✅ var 穿透了块 // 经典面试题:for 循环中的 var vs let for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // 输出:3, 3, 3(var 没有块级作用域,循环结束后 i 是 3) for (let j = 0; j < 3; j++) { setTimeout(() => console.log(j), 100); } // 输出:0, 1, 2(let 每次迭代创建新的块级作用域) ``` - 作用域链 - 当代码访问一个变量时,JS 引擎会沿着作用域链由内向外逐层查找,直到找到或到达全局作用域。 - ```js const global = 'global'; function outer() { const outerVar = 'outer'; function middle() { const middleVar = 'middle'; function inner() { const innerVar = 'inner'; // inner 可以访问所有外层变量 console.log(innerVar); // "inner" ← 自身作用域 console.log(middleVar); // "middle" ← 父级作用域 console.log(outerVar); // "outer" ← 祖父级作用域 console.log(global); // "global" ← 全局作用域 } inner(); // console.log(innerVar); // ❌ 外层不能访问内层 } middle(); } outer(); ``` - 词法作用域 - JavaScript 使用词法作用域(也叫静态作用域),意思是函数的作用域在定义时就确定了,而不是在调用时。 - ```js const x = 'global'; function printX() { console.log(x); // 这里的 x 在定义时就绑定了全局的 x } function wrapper() { const x = 'wrapper'; printX(); // 虽然在 wrapper 内部调用,但打印的是 "global" } wrapper(); // "global",不是 "wrapper" ``` - 这个特性是闭包的基础。函数"记住"的是它被创建时的环境,而不是被调用时的环境。 - ```js function createPrinter() { const message = '来自 createPrinter'; return function() { console.log(message); // 词法作用域:绑定的是 createPrinter 内的 message }; } const printer = createPrinter(); function someOtherPlace() { const message = '来自 someOtherPlace'; printer(); // "来自 createPrinter",不受调用位置影响 } someOtherPlace(); ``` - 闭包 - 闭包是指一个函数能够访问其词法作用域中的变量,即使这个函数在其词法作用域之外执行。 - ```js function createCounter() { let count = 0; // 这个变量被"封闭"在闭包中 return function() { count++; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2 console.log(counter()); // 3 // count 变量从外部完全无法访问 // console.log(count); // ❌ ReferenceError // 每次调用 createCounter 创建独立的闭包 const counter2 = createCounter(); console.log(counter2()); // 1(独立的 count) ``` - 闭包的本质:createCounter 执行完后,正常来说内部变量 count 应该被垃圾回收。但因为返回的函数仍然引用着 count,JS 引擎就保留了这个变量。返回的函数 + 它引用的外部变量 = 闭包。 - 闭包应用场景 - 数据私有化 / 模块模式 ```js function createWallet(initialBalance) { let balance = initialBalance; // 私有变量,外部无法直接修改 return { deposit(amount) { if (amount <= 0) throw new Error('金额必须大于 0'); balance += amount; return balance; }, withdraw(amount) { if (amount > balance) throw new Error('余额不足'); balance -= amount; return balance; }, getBalance() { return balance; } }; } const wallet = createWallet(100); wallet.deposit(50); // 150 wallet.withdraw(30); // 120 wallet.getBalance(); // 120 // wallet.balance; // undefined,无法直接访问 // balance = 999999; // 不可能,balance 被封闭在闭包中 ``` - 工厂函数 / 函数生成器 ```js function createGreeter(greeting) { return function(name) { return `${greeting}, ${name}!`; }; } const hello = createGreeter('Hello'); const nihao = createGreeter('你好'); hello('An'); // "Hello, An!" nihao('An'); // "你好, An!" // 更实用的例子:创建带预设配置的函数 function createLogger(prefix) { return function(message) { console.log(`[${prefix}] ${new Date().toISOString()}: ${message}`); }; } const dbLog = createLogger('DB'); const apiLog = createLogger('API'); dbLog('连接成功'); // [DB] 2026-04-05T...: 连接成功 apiLog('请求收到'); // [API] 2026-04-05T...: 请求收到 ``` - 柯里化(Currying):把接受多个参数的函数,变成一系列只接受一个参数的函数 ```js // 普通函数 function add(a, b, c) { return a + b + c; } add(1, 2, 3); // 6 // 柯里化版本(手动) function curriedAdd(a) { return function(b) { return function(c) { return a + b + c; // 三层闭包,每层记住一个参数 }; }; } curriedAdd(1)(2)(3); // 6 // 拆开用 const addOne = curriedAdd(1); const addOneAndTwo = addOne(2); addOneAndTwo(3); // 6 // 箭头函数简写 const curriedAdd2 = a => b => c => a + b + c; // 实际应用:事件处理器工厂 const handleClick = (action) => (id) => (event) => { console.log(`Action: ${action}, ID: ${id}, Target: ${event.target}`); }; // button.addEventListener('click', handleClick('delete')(42)); ``` - 防抖(Debounce) ```js function debounce(fn, delay) { let timerId = null; // 闭包保存定时器 ID return function(...args) { clearTimeout(timerId); // 每次调用都清除上一次的定时器 timerId = setTimeout(() => { fn.apply(this, args); // delay 毫秒后才真正执行 }, delay); }; } // 使用:搜索框输入时,停止输入 300ms 后才发请求 const search = debounce((query) => { console.log(`搜索: ${query}`); }, 300); search('h'); // 被取消 search('he'); // 被取消 search('hel'); // 被取消 search('hello'); // 300ms 后执行 → "搜索: hello" ``` - 节流(Throttle) ```js function throttle(fn, interval) { let lastTime = 0; // 闭包保存上次执行时间 return function(...args) { const now = Date.now(); if (now - lastTime >= interval) { lastTime = now; fn.apply(this, args); } }; } // 使用:滚动事件每 200ms 最多触发一次 // window.addEventListener('scroll', throttle(handleScroll, 200)); ``` - 对象与数组 - 对象字面量与属性 - 对象是 JavaScript 中最基础的数据结构,用来存储键值对。 - 创建与属性访问: ```js // 对象字面量 const user = { name: 'An', age: 25, 'favorite-color': 'blue', // 包含特殊字符的键名需要引号 1: 'one' // 数字键会被转为字符串 }; // 点语法访问 console.log(user.name); // "An" // 方括号访问(适用于动态键名或特殊字符) console.log(user['favorite-color']); // "blue" console.log(user[1]); // "one" const key = 'age'; console.log(user[key]); // 25(动态访问) ``` - 属性简写与计算属性名(ES6): ```js const name = 'An'; const age = 25; // 属性简写:变量名和属性名相同时可以简写 const user = { name, age }; // 等价于 { name: name, age: age } // 方法简写 const calculator = { // ES6 之前:add: function(a, b) { return a + b; } add(a, b) { return a + b; }, subtract(a, b) { return a - b; } }; // 计算属性名:用表达式作为键名 const field = 'email'; const obj = { [field]: 'an@example.com', // { email: 'an@example.com' } [`${field}Verified`]: true, // { emailVerified: true } ['get' + 'Name']() { return 'An'; } // { getName() { return 'An'; } } }; ``` - 属性的增删改查: ```js const user = { name: 'An' }; // 增 user.age = 25; user['city'] = 'Tokyo'; // 改 user.name = 'Bob'; // 删 delete user.city; console.log(user.city); // undefined // 查(判断属性是否存在) console.log('name' in user); // true(包括原型链) console.log(user.hasOwnProperty('name')); // true(只查自身) console.log(user.toString !== undefined); // true(原型链上的) console.log(user.hasOwnProperty('toString')); // false ``` - 对象的遍历与常用静态方法 - ```js const user = { name: 'An', age: 25, city: 'Tokyo' }; // Object.keys() — 返回键名数组 Object.keys(user); // ['name', 'age', 'city'] // Object.values() — 返回值数组 Object.values(user); // ['An', 25, 'Tokyo'] // Object.entries() — 返回 [key, value] 二维数组 Object.entries(user); // [['name','An'], ['age',25], ['city','Tokyo']] // 用 for...of 配合 entries 遍历 for (const [key, value] of Object.entries(user)) { console.log(`${key}: ${value}`); } // Object.fromEntries() — entries 的反操作 const entries = [['a', 1], ['b', 2]]; const obj = Object.fromEntries(entries); // { a: 1, b: 2 } // 实用场景:过滤对象属性 const filtered = Object.fromEntries( Object.entries(user).filter(([key]) => key !== 'age') ); // { name: 'An', city: 'Tokyo' } ``` - Object.assign() 与冻结: ```js // Object.assign() — 合并对象(浅拷贝) const defaults = { theme: 'light', lang: 'zh', debug: false }; const userPrefs = { theme: 'dark', debug: true }; const config = Object.assign({}, defaults, userPrefs); // { theme: 'dark', lang: 'zh', debug: true } // 后面的属性覆盖前面的 // Object.freeze() — 冻结对象,不能增删改 const frozen = Object.freeze({ name: 'An', age: 25 }); frozen.name = 'Bob'; // 静默失败(严格模式下报错) frozen.city = 'NYC'; // 静默失败 console.log(frozen); // { name: 'An', age: 25 } // ⚠️ freeze 是浅冻结 const obj = Object.freeze({ inner: { value: 1 } }); obj.inner.value = 999; // ✅ 生效了!内层对象没有被冻结 // Object.isFrozen() console.log(Object.isFrozen(frozen)); // true ``` - 解构赋值 — 对象 - 解构让你用简洁的语法从对象中提取值。 ```js const user = { name: 'An', age: 25, address: { city: 'Tokyo', zip: '100-0001' }, hobbies: ['coding', 'reading'] }; // 基本解构 const { name, age } = user; console.log(name); // "An" console.log(age); // 25 // 重命名 const { name: userName, age: userAge } = user; console.log(userName); // "An" // 默认值 const { name: n, role = 'viewer' } = user; console.log(role); // "viewer"(user 中没有 role) // 嵌套解构 const { address: { city, zip } } = user; console.log(city); // "Tokyo" // 剩余属性 const { name: nm, ...rest } = user; console.log(rest); // { age: 25, address: {...}, hobbies: [...] } ``` - 函数参数中的解构(非常常用): ```js // ❌ 不解构:参数含义不清晰 function createUser(name, age, role, active) { // name 是第几个参数?容易搞混 } // ✅ 解构:清晰且支持默认值、可选参数 function createUser({ name, age, role = 'viewer', active = true } = {}) { return { name, age, role, active }; } // 调用时不需要关心参数顺序 createUser({ age: 25, name: 'An' }); createUser({}); // 全部使用默认值 createUser(); // 因为有 = {},不传也不报错 ``` - 数组基础与常用方法 - 创建数组: ```js const arr1 = [1, 2, 3]; const arr2 = new Array(5); // [empty × 5](5个空位,不推荐) const arr3 = Array.from({ length: 5 }, (_, i) => i); // [0, 1, 2, 3, 4] const arr4 = Array.of(1, 2, 3); // [1, 2, 3] ``` - 增删元素(会修改原数组): ```js const arr = [1, 2, 3]; // 尾部操作 arr.push(4); // [1, 2, 3, 4],返回新长度 4 arr.pop(); // [1, 2, 3],返回被删除的元素 4 // 头部操作 arr.unshift(0); // [0, 1, 2, 3],返回新长度 4 arr.shift(); // [1, 2, 3],返回被删除的元素 0 // splice — 万能的增删改 const items = ['a', 'b', 'c', 'd', 'e']; // splice(起始索引, 删除个数, ...插入的元素) items.splice(2, 1); // 删除:items = ['a','b','d','e'],返回 ['c'] items.splice(1, 0, 'x'); // 插入:items = ['a','x','b','d','e'] items.splice(1, 2, 'y','z');// 替换:items = ['a','y','z','d','e'] ``` - 不修改原数组的方法: ```js const arr = [1, 2, 3, 4, 5]; // slice(start, end) — 切片,左闭右开 arr.slice(1, 3); // [2, 3] arr.slice(-2); // [4, 5](最后两个) arr.slice(); // [1, 2, 3, 4, 5](浅拷贝) // concat — 拼接 arr.concat([6, 7]); // [1, 2, 3, 4, 5, 6, 7] // indexOf / lastIndexOf — 查找索引 [1, 2, 3, 2].indexOf(2); // 1(第一个) [1, 2, 3, 2].lastIndexOf(2); // 3(最后一个) [1, 2, 3].indexOf(99); // -1(未找到) // includes — 是否包含 [1, 2, 3].includes(2); // true [1, 2, 3].includes(99); // false // ⚠️ indexOf 和 includes 对引用类型无效 const obj = { id: 1 }; [{ id: 1 }].includes(obj); // false(不同的引用) // join — 数组转字符串 ['a', 'b', 'c'].join('-'); // "a-b-c" ['a', 'b', 'c'].join(''); // "abc" // flat — 扁平化 [1, [2, [3, [4]]]].flat(); // [1, 2, [3, [4]]](默认一层) [1, [2, [3, [4]]]].flat(2); // [1, 2, 3, [4]] [1, [2, [3, [4]]]].flat(Infinity); // [1, 2, 3, 4](完全扁平) // at — 支持负索引(ES2022) const letters = ['a', 'b', 'c', 'd']; letters.at(0); // "a" letters.at(-1); // "d"(最后一个) letters.at(-2); // "c" ``` - 解构赋值-数组 - ```js const rgb = [255, 128, 0]; // 基本解构 const [r, g, b] = rgb; console.log(r, g, b); // 255 128 0 // 跳过元素 const [first, , third] = [1, 2, 3]; console.log(first, third); // 1 3 // 默认值 const [a, b, c, d = 0] = [1, 2, 3]; console.log(d); // 0 // 剩余元素 const [head, ...tail] = [1, 2, 3, 4, 5]; console.log(head); // 1 console.log(tail); // [2, 3, 4, 5] // 交换变量(不需要临时变量) let x = 1, y = 2; [x, y] = [y, x]; console.log(x, y); // 2 1 // 从函数返回值解构 function getCoords() { return [35.6762, 139.6503]; } const [lat, lng] = getCoords(); // 嵌套解构 const matrix = [[1, 2], [3, 4]]; const [[a1, a2], [b1, b2]] = matrix; ``` - 扩展运算符(Spread Operator) - ... 在不同上下文中扮演不同角色。用在函数参数中是"收集"(rest),用在数组/对象字面量中是"展开"(spread) - 数组展开 ```js const arr1 = [1, 2, 3]; const arr2 = [4, 5, 6]; // 合并数组 const merged = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6] // 浅拷贝 const copy = [...arr1]; // [1, 2, 3](新数组,不同引用) // 在特定位置插入 const withInsert = [...arr1.slice(0, 1), 99, ...arr1.slice(1)]; // [1, 99, 2, 3] // 展开字符串 const chars = [..."hello"]; // ['h', 'e', 'l', 'l', 'o'] // 配合 Math 使用 const nums = [3, 1, 4, 1, 5, 9]; Math.max(...nums); // 9 ``` - 对象展开 ```js const base = { a: 1, b: 2 }; const extra = { b: 3, c: 4 }; // 合并对象(后面覆盖前面) const merged = { ...base, ...extra }; // { a: 1, b: 3, c: 4 } // 浅拷贝 + 修改部分属性(React 中常用) const user = { name: 'An', age: 25, city: 'Tokyo' }; const updated = { ...user, age: 26 }; // { name: 'An', age: 26, city: 'Tokyo' } // 条件展开 const isAdmin = true; const config = { theme: 'dark', ...(isAdmin && { adminPanel: true, debugMode: true }) }; // isAdmin 为 true 时:{ theme: 'dark', adminPanel: true, debugMode: true } // isAdmin 为 false 时:{ theme: 'dark' }(false 被展开为空) ``` - 浅拷贝与深拷贝 - JavaScript 中引用类型赋值只是复制地址,理解拷贝层级非常重要。 - 浅拷贝(Shallow Copy):只拷贝第一层,嵌套的对象/数组仍然是共享引用。 ```js const original = { name: 'An', scores: [90, 85, 95], address: { city: 'Tokyo' } }; // 三种浅拷贝方式 const copy1 = { ...original }; const copy2 = Object.assign({}, original); const copy3 = structuredClone ? null : null; // 这个是深拷贝,后面说 // 浅拷贝的问题:嵌套对象是共享的 copy1.name = 'Bob'; // ✅ 不影响 original copy1.scores.push(100); // ❌ original.scores 也变了! copy1.address.city = 'Osaka'; // ❌ original.address.city 也变了! console.log(original.scores); // [90, 85, 95, 100] console.log(original.address.city); // "Osaka" ``` - 深拷贝(Deep Copy):递归拷贝所有层级,完全独立。 ```js // 方法1:structuredClone(现代浏览器和 Node 17+ 原生支持,推荐) const deep1 = structuredClone(original); deep1.scores.push(100); deep1.address.city = 'Osaka'; console.log(original.scores); // [90, 85, 95] 不受影响 ✅ console.log(original.address.city); // "Tokyo" 不受影响 ✅ // 方法2:JSON 序列化(简单但有限制) const deep2 = JSON.parse(JSON.stringify(original)); // ⚠️ 限制: // - undefined、function、Symbol 会丢失 // - Date 会变成字符串 // - RegExp 会变成空对象 // - 不支持循环引用 // 示例:JSON 方法的数据丢失 const problematic = { fn: () => 'hello', // 函数 date: new Date(), // Date undef: undefined, // undefined regex: /abc/g // 正则 }; const jsonCopy = JSON.parse(JSON.stringify(problematic)); console.log(jsonCopy); // { date: "2026-04-05T...", regex: {} } // fn 和 undef 直接丢了,date 变成了字符串,regex 变成了空对象 ``` - 数组高阶方法 - 什么是高阶方法 - 高阶方法就是接受函数作为参数的方法 - 每个方法的回调函数都接收三个参数:(当前元素, 索引, 原数组),大多数情况只用第一个。 - map - 映射转换 - 对数组每个元素执行回调,将返回值收集成一个等长的新数组。不修改原数组。 - ```js const nums = [1, 2, 3, 4, 5]; const doubled = nums.map(n => n * 2); // [2, 4, 6, 8, 10] const strings = nums.map(n => `第${n}项`); // ['第1项', '第2项', '第3项', '第4项', '第5项'] // 实际场景:从 API 数据中提取需要的字段 const users = [ { id: 1, name: 'An', email: 'an@test.com', age: 25 }, { id: 2, name: 'Bob', email: 'bob@test.com', age: 30 }, { id: 3, name: 'Cat', email: 'cat@test.com', age: 22 } ]; const names = users.map(u => u.name); // ['An', 'Bob', 'Cat'] const cards = users.map(({ id, name, age }) => ({ id, label: `${name} (${age}岁)` })); // [{ id: 1, label: 'An (25岁)' }, ...] // 使用索引参数 const indexed = ['a', 'b', 'c'].map((item, i) => `${i}: ${item}`); // ['0: a', '1: b', '2: c'] ``` - 常见错误 ```js // ❌ map 中不 return(箭头函数加了 {} 就必须写 return) nums.map(n => { n * 2 }); // [undefined, undefined, ...] // ❌ 用 map 做副作用操作(不关心返回值时应该用 forEach) users.map(u => console.log(u.name)); // 能跑,但语义错误 // ❌ parseInt 的经典陷阱 ['1', '2', '3'].map(parseInt); // [1, NaN, NaN] // 原因:map 传了 (元素, 索引, 数组) 三个参数给 parseInt // parseInt('1', 0) → 1 // parseInt('2', 1) → NaN(1进制无效) // parseInt('3', 2) → NaN(2进制没有3) // ✅ 正确写法 ['1', '2', '3'].map(s => parseInt(s, 10)); // [1, 2, 3] ['1', '2', '3'].map(Number); // [1, 2, 3] ``` - filter-过滤筛选 - 对数组每个元素执行回调,保留返回值为 truthy 的元素,返回一个新数组(长度 ≤ 原数组)。 - ```js const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const evens = nums.filter(n => n % 2 === 0); // [2, 4, 6, 8, 10] const bigNums = nums.filter(n => n > 5); // [6, 7, 8, 9, 10] // 过滤对象数组 const users = [ { name: 'An', age: 25, active: true }, { name: 'Bob', age: 17, active: true }, { name: 'Cat', age: 30, active: false }, { name: 'Dan', age: 22, active: true } ]; const activeAdults = users.filter(u => u.active && u.age >= 18); // [{ name: 'An', ... }, { name: 'Dan', ... }] // 移除 falsy 值 const messy = [0, 'hello', '', null, 42, undefined, false, 'world']; const clean = messy.filter(Boolean); // ['hello', 42, 'world'] // 去重(Day 5 用的 Set 更简洁,这里展示 filter 做法) const arr = [1, 2, 2, 3, 3, 3]; const unique = arr.filter((item, index) => arr.indexOf(item) === index); // [1, 2, 3] ``` - reduce — 归约累积 - reduce 是最强大也最灵活的数组方法,它将数组"归约"为一个值(可以是任何类型)。 - 语法:array.reduce((accumulator, currentValue, index, array) => {...}, initialValue) - ```js // 最基本:求和 const sum = [1, 2, 3, 4, 5].reduce((acc, cur) => acc + cur, 0); // 执行过程: // acc=0, cur=1 → 1 // acc=1, cur=2 → 3 // acc=3, cur=3 → 6 // acc=6, cur=4 → 10 // acc=10, cur=5 → 15 // 结果:15 // 求最大值 const max = [3, 1, 4, 1, 5, 9].reduce((a, b) => a > b ? a : b); // 没有初始值时,第一个元素作为初始值 // 结果:9 ``` - find 与 findIndex - 查找 - find 返回第一个满足条件的元素本身,找不到返回 undefined。 - findIndex 返回第一个满足条件的元素索引,找不到返回 -1。 - ```js const users = [ { id: 1, name: 'An', role: 'dev' }, { id: 2, name: 'Bob', role: 'admin' }, { id: 3, name: 'Cat', role: 'dev' } ]; const admin = users.find(u => u.role === 'admin'); // { id: 2, name: 'Bob', role: 'admin' } const notFound = users.find(u => u.role === 'ceo'); // undefined const adminIndex = users.findIndex(u => u.role === 'admin'); // 1 // findLast / findLastIndex(ES2023)从后往前找 const lastDev = users.findLast(u => u.role === 'dev'); // { id: 3, name: 'Cat', role: 'dev' } ``` - some 与 every — 条件判断 - some 只要有一个元素满足条件就返回 true(类似逻辑或)。 - every 要求所有元素都满足条件才返回 true(类似逻辑与)。 - ```js const nums = [1, 2, 3, 4, 5]; nums.some(n => n > 4); // true(5 > 4) nums.some(n => n > 10); // false nums.every(n => n > 0); // true(全部大于0) nums.every(n => n > 3); // false(1, 2, 3 不满足) // 空数组的行为 [].some(() => true); // false(没有元素满足) [].every(() => false); // true(没有元素不满足 —— 空真) // 实际场景 const cart = [ { name: '键盘', inStock: true }, { name: '鼠标', inStock: true }, { name: '显示器', inStock: false } ]; const allInStock = cart.every(item => item.inStock); // false const anyOutOfStock = cart.some(item => !item.inStock); // true // 表单验证 const fields = [ { name: 'email', valid: true }, { name: 'password', valid: true }, { name: 'phone', valid: false } ]; const canSubmit = fields.every(f => f.valid); // false ``` - sort 排序 - sort 会修改原数组,并且默认按字符串的 Unicode 码点排序,这对数字来说通常不是你想要的。 - ```js // ❌ 默认排序的坑 [10, 9, 2, 21, 3].sort(); // [10, 2, 21, 3, 9] // 因为 "10" < "2"(按字符串比较,'1' 的码点 < '2' 的码点) // ✅ 数字排序:提供比较函数 // 返回负数 → a 排前面 // 返回正数 → b 排前面 // 返回 0 → 保持原序 [10, 9, 2, 21, 3].sort((a, b) => a - b); // [2, 3, 9, 10, 21](升序) [10, 9, 2, 21, 3].sort((a, b) => b - a); // [21, 10, 9, 3, 2](降序) // 对象数组排序 const users = [ { name: 'Cat', age: 30 }, { name: 'An', age: 25 }, { name: 'Bob', age: 22 } ]; // 按年龄升序 users.sort((a, b) => a.age - b.age); // 按名字字母序 users.sort((a, b) => a.name.localeCompare(b.name)); // localeCompare 支持中文拼音排序: // ['张三','李四','王五'].sort((a,b) => a.localeCompare(b, 'zh-CN')) ``` - 不想修改原数组? 用 toSorted()(ES2023)或先拷贝: ```js const original = [3, 1, 2]; // ES2023 const sorted = original.toSorted((a, b) => a - b); // [1, 2, 3] console.log(original); // [3, 1, 2](不变) // 兼容写法 const sorted2 = [...original].sort((a, b) => a - b); ``` - flatMap — 映射 + 扁平化 - flatMap 等价于先 map 再 flat(1),但只扁平一层。它特别适合一对多的映射场景。 - ```js // 普通 map 产生嵌套数组 const sentences = ['hello world', 'goodbye moon']; sentences.map(s => s.split(' ')); // [['hello', 'world'], ['goodbye', 'moon']] // flatMap 自动扁平一层 sentences.flatMap(s => s.split(' ')); // ['hello', 'world', 'goodbye', 'moon'] // 一对多:每个用户有多个标签 const users = [ { name: 'An', tags: ['dev', 'js'] }, { name: 'Bob', tags: ['design', 'css'] } ]; const allTags = users.flatMap(u => u.tags); // ['dev', 'js', 'design', 'css'] // 条件性映射:过滤 + 转换一步完成 const nums = [1, 2, 3, 4, 5, 6]; const doubledEvens = nums.flatMap(n => n % 2 === 0 ? [n * 2] : []); // [4, 8, 12] // 偶数返回 [n*2](包含一个元素的数组),奇数返回 [](空数组被扁平掉了) ``` - forEach — 遍历执行 - forEach 对每个元素执行回调,没有返回值(返回 undefined)。它是纯粹用于副作用的方法。 - ```js const users = ['An', 'Bob', 'Cat']; users.forEach((name, index) => { console.log(`${index + 1}. ${name}`); }); // 1. An // 2. Bob // 3. Cat ``` 面向对象与核心机制 - 面向对象与核心机制 - this 指向 - 为什么 this 这么重要 - 和大多数语言不同,JS 中 this 的值不取决于函数在哪里定义,而取决于函数怎么被调用(箭头函数除外) - 规则一:默认绑定 - 当函数作为独立函数调用(不通过对象、不用 call/apply/bind、不用 new),this 指向全局对象(浏览器中是 window,Node 中是 global)。严格模式下 this 是 undefined。 - ```js function showThis() { console.log(this); } showThis(); // 浏览器:window,Node:global // 严格模式 function strictShow() { 'use strict'; console.log(this); } strictShow(); // undefined ``` - 规则二:隐式绑定 - 当函数作为对象的方法调用时(obj.method()),this 指向调用它的那个对象。 - ```js const user = { name: 'An', greet() { console.log(this.name); } }; user.greet(); // "An",this === user // 链式对象:this 指向最近的调用者 const company = { name: 'MiniMax', department: { name: 'Agent Team', getName() { return this.name; } } }; company.department.getName(); // "Agent Team",不是 "MiniMax" // this 指向 department,因为 department 是最近的调用者 ``` - 规则三:显式绑定 — call / apply / bind - 当你想手动指定 this 时,使用这三个方法 - call — 立即调用,逐个传参 ```js function introduce(greeting, punctuation) { console.log(`${greeting}, I'm ${this.name}${punctuation}`); } const user = { name: 'An' }; introduce.call(user, 'Hello', '!'); // "Hello, I'm An!" // 第一个参数是 this 的值,后面依次是函数参数 ``` - apply — 立即调用,参数以数组形式传入 ```js introduce.apply(user, ['Hi', '~']); // "Hi, I'm An~" // call 和 apply 的唯一区别就是参数传递方式 // 助记:Apply 用 Array // 经典用法:借用方法 const arrayLike = { 0: 'a', 1: 'b', 2: 'c', length: 3 }; const realArray = Array.prototype.slice.call(arrayLike); // ['a', 'b', 'c'] // 求数组最大值(ES5 时代) Math.max.apply(null, [3, 1, 4, 1, 5]); // 5 // 现在用展开运算符更好:Math.max(...[3, 1, 4, 1, 5]) ``` - bind — 返回新函数,永久绑定 this(不立即调用): ```js const user = { name: 'An' }; function greet(greeting) { console.log(`${greeting}, ${this.name}`); } const greetAn = greet.bind(user); greetAn('Hello'); // "Hello, An" greetAn('Hey'); // "Hey, An" // bind 也可以预设参数(偏函数/Partial Application) const helloAn = greet.bind(user, 'Hello'); helloAn(); // "Hello, An"(greeting 已经被绑定为 'Hello') // 解决回调中 this 丢失的问题 setTimeout(user.greet.bind(user), 100); // ✅ 不会丢失 this // bind 返回的函数,this 不可被再次修改 const bound = greet.bind(user); bound.call({ name: 'Bob' }, 'Hi'); // "Hi, An"(仍然是 An,bind 的优先级更高) ``` - 规则四:new 绑定 - 当用 new 调用函数时,JS 会自动创建一个新对象,将 this 绑定到这个新对象上。 - ```js function User(name, age) { // new 调用时,this 自动指向新创建的空对象 {} this.name = name; this.age = age; // 不需要 return,自动返回 this } const user = new User('An', 25); console.log(user); // User { name: 'An', age: 25 } // new 到底做了什么?等价于: function myNew(Constructor, ...args) { // 1. 创建空对象,原型指向构造函数的 prototype const obj = Object.create(Constructor.prototype); // 2. 执行构造函数,this 绑定到新对象 const result = Constructor.apply(obj, args); // 3. 如果构造函数返回了对象,就用那个对象;否则返回新创建的对象 return result instanceof Object ? result : obj; } const user2 = myNew(User, 'Bob', 30); console.log(user2); // User { name: 'Bob', age: 30 } ``` - 箭头函数中的 this - 箭头函数没有自己的 this,它的 this 继承自定义时所在的外层作用域(词法 this),且永远不可改变。 - ```js const user = { name: 'An', // 普通方法 greetNormal() { console.log(this.name); // this 取决于调用方式 }, // 箭头函数作为方法 ❌ greetArrow: () => { console.log(this.name); // this 是外层作用域的 this,不是 user } }; user.greetNormal(); // "An" ✅ user.greetArrow(); // undefined ❌(箭头函数的 this 是全局/模块作用域) ``` - 绑定优先级 - new 绑定 > 显式绑定(call/apply/bind) > 隐式绑定(obj.fn()) > 默认绑定(fn()) - 原型与原型链 - 为什么需要原型 - 在没有原型机制的情况下,每次用构造函数创建实例,方法都会被重复创建 ```js function User(name) { this.name = name; // 每个实例都创建一个新的 greet 函数,浪费内存 this.greet = function() { return `Hi, I'm ${this.name}`; }; } const u1 = new User('An'); const u2 = new User('Bob'); console.log(u1.greet === u2.greet); // false —— 两个不同的函数对象 // 如果创建 10000 个用户,就有 10000 份完全相同的 greet 函数 // 原型就是用来解决这个问题的 ``` - prototype 与 __proto__ - prototype:只有函数才拥有的属性,它是一个对象,当用 new 调用该函数时,新创建的实例的原型会指向这个对象。 - __proto__(或者说 [[Prototype]]):每个对象都有的内部属性,指向它的原型对象。__proto__ 是非标准但被广泛支持的访问方式,标准方式是 Object.getPrototypeOf()。 ```js function User(name) { this.name = name; } // 在 prototype 上定义方法,所有实例共享 User.prototype.greet = function() { return `Hi, I'm ${this.name}`; }; const u1 = new User('An'); const u2 = new User('Bob'); // 实例的 __proto__ 指向构造函数的 prototype console.log(u1.__proto__ === User.prototype); // true console.log(Object.getPrototypeOf(u1) === User.prototype); // true(标准写法) // 所有实例共享 prototype 上的方法 console.log(u1.greet === u2.greet); // true ✅ 同一个函数 u1.greet(); // "Hi, I'm An" u2.greet(); // "Hi, I'm Bob" ``` - constructor 属性 - 每个函数的 prototype 对象默认有一个 constructor 属性,指回函数本身 - ```js function User(name) { this.name = name; } console.log(User.prototype.constructor === User); // true const u = new User('An'); console.log(u.constructor === User); // true(通过原型链找到的) // 可以用 constructor 判断实例是由哪个构造函数创建的 // 但不如 instanceof 可靠,因为 constructor 可以被覆盖 ``` - 原型链(Prototype Chain) - 当你访问一个对象的属性时,JS 引擎会沿着原型链逐层查找,直到找到该属性或到达链的顶端 null - ```js function Animal(name) { this.name = name; } Animal.prototype.eat = function() { return `${this.name} is eating`; }; function Dog(name, breed) { Animal.call(this, name); // 调用父构造函数 this.breed = breed; } // 建立原型链:Dog.prototype 的原型指向 Animal.prototype Dog.prototype = Object.create(Animal.prototype); Dog.prototype.constructor = Dog; // 修复 constructor Dog.prototype.bark = function() { return `${this.name} says woof!`; }; const dog = new Dog('Buddy', 'Golden'); // 属性查找过程 dog.name; // "Buddy" ← 在 dog 自身找到 dog.bark(); // "Buddy says woof!" ← 在 Dog.prototype 上找到 dog.eat(); // "Buddy is eating" ← 在 Animal.prototype 上找到 dog.toString(); // "[object Object]" ← 在 Object.prototype 上找到 dog.whatever; // undefined ← 一路找到 null,没找到 ``` - Object.create() - Object.create(proto) 创建一个新对象,将它的 __proto__ 设为指定的对象。这是建立原型链最干净的方式。 - Class 语法与继承 - Class 的本质 - ES6 的 class 是原型继承的语法糖。 - 它不引入新的对象模型,底层仍然是原型机制,但写法更清晰、更接近传统面向对象语言。 - ```js // ES5 写法 function User(name, age) { this.name = name; this.age = age; } User.prototype.greet = function() { return `Hi, I'm ${this.name}`; }; // ES6 class 写法(完全等价) class User { constructor(name, age) { this.name = name; this.age = age; } greet() { return `Hi, I'm ${this.name}`; } } // 验证底层仍然是原型 console.log(typeof User); // "function" console.log(User.prototype.greet); // function console.log(new User('An', 25).__proto__ === User.prototype); // true ``` - class 和构造函数的区别: - ```js // 1. class 必须用 new 调用 class Foo {} // Foo(); // ❌ TypeError: Class constructor Foo cannot be invoked without 'new' // 构造函数不强制 function Bar() {} Bar(); // ✅ 不报错(但通常是 bug) // 2. class 内部默认严格模式 class StrictExample { method() { // 这里自动处于严格模式 // 未声明的变量赋值会报错等 } } // 3. class 声明不会提升(存在 TDZ) // const u = new User(); // ❌ ReferenceError // class User {} // 4. class 中的方法不可枚举 class MyClass { method() {} } console.log(Object.keys(MyClass.prototype)); // [](空,method 不可枚举) // 对比 ES5 function MyFunc() {} MyFunc.prototype.method = function() {}; console.log(Object.keys(MyFunc.prototype)); // ['method'](可枚举) ``` - constructor 构造函数 - constructor 是 class 中的特殊方法,在 new 时自动调用。每个 class 只能有一个。 - ```js class User { constructor(name, age) { // this 指向新创建的实例 this.name = name; this.age = age; this.createdAt = new Date(); } } const u = new User('An', 25); console.log(u.name); // "An" console.log(u.createdAt); // 当前时间 // 如果不写 constructor,会有一个默认的空 constructor class Empty {} // 等价于 class Empty { constructor() {} } // constructor 中可以做参数验证 class PositiveNumber { constructor(value) { if (typeof value !== 'number' || value <= 0) { throw new Error('必须是正数'); } this.value = value; } } new PositiveNumber(5); // ✅ // new PositiveNumber(-1); // ❌ Error: 必须是正数 ``` - 实例方法、访问器与类字段 - ```js class User { // --- 类字段(Class Fields,ES2022)--- // 直接在 class body 中声明,会成为实例自身的属性 role = 'viewer'; loginCount = 0; constructor(name, email) { this.name = name; this.email = email; } // --- 实例方法 --- // 定义在 User.prototype 上,所有实例共享 greet() { return `Hi, I'm ${this.name}`; } login() { this.loginCount++; return `${this.name} logged in (${this.loginCount} times)`; } // --- getter / setter --- // 像属性一样访问,但实际上是方法调用 get displayName() { return `${this.name} <${this.email}>`; } get isFrequent() { return this.loginCount > 10; } set nickname(value) { if (value.length < 2) { throw new Error('昵称至少2个字符'); } this._nickname = value; } get nickname() { return this._nickname || this.name; } } const u = new User('An', 'an@test.com'); // 类字段 console.log(u.role); // "viewer" console.log(u.loginCount); // 0 // 实例方法 u.login(); // "An logged in (1 times)" // getter(不加括号) console.log(u.displayName); // "An <an@test.com>" console.log(u.isFrequent); // false // setter(像赋值一样使用) u.nickname = 'AnDev'; console.log(u.nickname); // "AnDev" // u.nickname = 'A'; // ❌ Error: 昵称至少2个字符 ``` - 静态方法与静态字段 - static 定义在类本身上,不是在实例上。通过类名调用,不能通过实例调用。 - ```js class MathUtils { // 静态方法 static add(a, b) { return a + b; } static multiply(a, b) { return a * b; } // 静态字段 static PI = 3.14159; // 静态方法可以互相调用 static circleArea(radius) { return MathUtils.PI * radius ** 2; // 或者用 this(this 在静态方法中指向类本身) // return this.PI * radius ** 2; } } MathUtils.add(1, 2); // 3 MathUtils.PI; // 3.14159 MathUtils.circleArea(5); // 78.53975 // 不能通过实例调用 // const m = new MathUtils(); // m.add(1, 2); // ❌ TypeError: m.add is not a function ``` - 私有字段(Private Fields) - ES2022 引入 # 前缀表示真正的私有属性和方法,只能在类内部访问。 ```js class BankAccount { // 私有字段 #balance; #owner; #transactionHistory = []; constructor(owner, initialBalance) { this.#owner = owner; this.#balance = initialBalance; } // 私有方法 #recordTransaction(type, amount) { this.#transactionHistory.push({ type, amount, date: new Date(), balance: this.#balance }); } // 公开方法(通过它们间接访问私有数据) deposit(amount) { if (amount <= 0) throw new Error('金额必须大于0'); this.#balance += amount; this.#recordTransaction('deposit', amount); return this; } withdraw(amount) { if (amount > this.#balance) throw new Error('余额不足'); this.#balance -= amount; this.#recordTransaction('withdraw', amount); return this; } get balance() { return this.#balance; } get history() { // 返回副本,防止外部修改 return [...this.#transactionHistory]; } // 静态私有方法 static #validateAmount(amount) { return typeof amount === 'number' && amount > 0; } } const account = new BankAccount('An', 1000); account.deposit(500).withdraw(200); // 链式调用 console.log(account.balance); // 1300 console.log(account.history); // [{...}, {...}] // ❌ 私有字段外部完全不可访问 // account.#balance; // SyntaxError // account.#recordTransaction; // SyntaxError ``` - extends 与 super — 类继承 - extends 建立继承关系,super 用于调用父类。 - ```js class Animal { constructor(name) { this.name = name; this.energy = 100; } eat(amount) { this.energy += amount; return `${this.name} ate, energy: ${this.energy}`; } sleep(hours) { this.energy += hours * 10; return `${this.name} slept ${hours}h, energy: ${this.energy}`; } info() { return `${this.name} (energy: ${this.energy})`; } } class Dog extends Animal { // 子类的 constructor 必须在使用 this 之前调用 super() constructor(name, breed) { super(name); // 调用 Animal 的 constructor this.breed = breed; } // 新方法 bark() { this.energy -= 5; return `${this.name} says Woof!`; } // 重写父类方法 eat(amount) { // 调用父类的 eat 方法 super.eat(amount * 2); // 狗吃东西恢复双倍能量 return `${this.name} gobbled food! energy: ${this.energy}`; } // 扩展父类方法 info() { return `${super.info()} [${this.breed}]`; } } const dog = new Dog('Buddy', 'Golden'); dog.eat(10); // "Buddy gobbled food! energy: 120"(10*2=20,100+20=120) dog.bark(); // "Buddy says Woof!" dog.sleep(2); // "Buddy slept 2h, energy: 135"(继承自 Animal) dog.info(); // "Buddy (energy: 135) [Golden]" // 原型链验证 console.log(dog instanceof Dog); // true console.log(dog instanceof Animal); // true ``` - 字符串与正则表达式 - 模板字符串深入 - ```js // 基本插值 const name = 'An'; const greeting = `Hello, ${name}!`; // 插值中可以放任意表达式 const price = 99; const qty = 3; console.log(`总价: ¥${price * qty}`); // "总价: ¥297" console.log(`状态: ${qty > 0 ? '有货' : '缺货'}`); // "状态: 有货" // 多行字符串(保留换行和缩进) const html = ` <div class="card"> <h2>${name}</h2> <p>Price: ¥${price}</p> </div> `; // 嵌套模板 const items = ['JS', 'CSS', 'HTML']; const list = ` <ul> ${items.map(item => `<li>${item}</li>`).join('\n ')} </ul> `; ``` - 模板标签 - 标签模板允许你用一个函数来处理模板字符串,这是一个强大但不太常见的特性: - ```js // 标签函数接收两个参数: // strings — 模板中的静态文本部分(数组) // values — 插值表达式的值(rest 参数) function highlight(strings, ...values) { return strings.reduce((result, str, i) => { const value = values[i] !== undefined ? `【${values[i]}】` : ''; return result + str + value; }, ''); } const lang = 'JavaScript'; const exp = 5; console.log(highlight`我学了 ${lang} ${exp} 年`); // "我学了 【JavaScript】 【5】 年" // 实际应用1:SQL 防注入 function sql(strings, ...values) { return { text: strings.join('$'), // 用占位符替代 values // 参数化查询 }; } const userId = "1; DROP TABLE users; --"; const query = sql`SELECT * FROM users WHERE id = ${userId}`; // { text: "SELECT * FROM users WHERE id = $", values: ["1; DROP TABLE users; --"] } // 实际应用2:国际化 // 实际应用3:CSS-in-JS(styled-components 就是用标签模板实现的) ``` - 常用字符串方法 - ```js const str = ' Hello, World! Hello, JavaScript! '; // --- 查找类 --- str.indexOf('Hello'); // 2(第一次出现的位置) str.lastIndexOf('Hello'); // 17(最后一次出现的位置) str.indexOf('Python'); // -1(未找到) str.includes('World'); // true str.startsWith(' Hello'); // true str.endsWith('! '); // true str.search(/hello/i); // 2(用正则查找,返回索引) // --- 提取类 --- str.slice(2, 7); // "Hello"(起始, 结束索引,左闭右开) str.slice(-5); // "t! "(从倒数第5个到末尾) str.substring(2, 7); // "Hello"(类似 slice,但不支持负数) str.at(2); // "H"(ES2022,支持负索引) str.at(-3); // "! " → 不对,at 只取单个字符 → "!" // 更正:str.at(-3) 返回 '!' // --- 修改类(返回新字符串,原字符串不变)--- str.trim(); // "Hello, World! Hello, JavaScript!" str.trimStart(); // "Hello, World! Hello, JavaScript! " str.trimEnd(); // " Hello, World! Hello, JavaScript!" str.toUpperCase(); // " HELLO, WORLD! HELLO, JAVASCRIPT! " str.toLowerCase(); // " hello, world! hello, javascript! " 'hello'.repeat(3); // "hellohellohello" // --- 填充类 --- '42'.padStart(5, '0'); // "00042"(左填充到5位) '42'.padEnd(5, '.'); // "42..."(右填充到5位) '7'.padStart(2, '0'); // "07"(常用于格式化月份/日期) // --- 分割类 --- 'a,b,c,d'.split(','); // ['a', 'b', 'c', 'd'] 'hello'.split(''); // ['h', 'e', 'l', 'l', 'o'] 'a-b-c'.split('-', 2); // ['a', 'b'](limit 参数) // --- 替换类 --- 'hello world'.replace('world', 'JS'); // "hello JS"(只替换第一个) 'aabbcc'.replace('b', 'X'); // "aaXbcc" 'aabbcc'.replaceAll('b', 'X'); // "aaXXcc"(ES2021,替换全部) ``` - replace 高级用法 - ```js // 函数替换:每次匹配时调用函数,返回值作为替换内容 const result = 'hello-world-foo'.replace(/\w+/g, (match) => { return match.charAt(0).toUpperCase() + match.slice(1); }); // "Hello-World-Foo" // 替换函数的参数:(match, ...groups, offset, fullString) '2026-04-07'.replace( /(\d{4})-(\d{2})-(\d{2})/, (match, year, month, day) => `${day}/${month}/${year}` ); // "07/04/2026" // 实际场景:模板引擎 function template(str, data) { return str.replace(/\{\{(\w+)\}\}/g, (match, key) => { return data[key] ?? match; // 找不到就保留原文 }); } template('Hello, {{name}}! You have {{count}} messages.', { name: 'An', count: 5 }); // "Hello, An! You have 5 messages." // 驼峰转换 function camelToKebab(str) { return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); } camelToKebab('backgroundColor'); // "background-color" camelToKebab('fontSize'); // "font-size" function kebabToCamel(str) { return str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase()); } kebabToCamel('background-color'); // "backgroundColor" ``` - 正则表达式基础语法 - 正则表达式用于模式匹配,由模式和标志组成。 - 创建方式 ```js // 字面量(推荐,不需要转义反斜杠) const re1 = /hello/i; // 构造函数(适用于动态构建正则) const re2 = new RegExp('hello', 'i'); const keyword = 'world'; const re3 = new RegExp(keyword, 'gi'); // 动态模式 ``` - 常用标志 ```js /pattern/g // g — global,匹配所有,不只第一个 /pattern/i // i — case insensitive,忽略大小写 /pattern/m // m — multiline,^ 和 $ 匹配每行的开头结尾 /pattern/s // s — dotAll,让 . 也匹配换行符 /pattern/u // u — unicode,正确处理 Unicode(如 emoji、中文) /pattern/d // d — indices,返回匹配的位置信息(ES2022) // 可以组合 /pattern/giu ``` - 正则核心语法 - 字符类 ```js // 普通字符:匹配自身 /hello/ // 匹配 "hello" // 元字符 . // 任意单个字符(除换行符,除非用 s 标志) \d // 数字 [0-9] \D // 非数字 [^0-9] \w // 单词字符 [a-zA-Z0-9_] \W // 非单词字符 \s // 空白字符(空格、tab、换行等) \S // 非空白字符 \b // 单词边界 \B // 非单词边界 // 字符集合 [] [abc] // 匹配 a、b 或 c 中的任意一个 [a-z] // a 到 z [A-Z0-9] // 大写字母或数字 [^abc] // 不是 a、b、c(^ 在 [] 内表示取反) // 示例 /\d{3}-\d{4}/.test('123-4567'); // true /[aeiou]/i.test('Hello'); // true(有元音字母) /^[a-zA-Z_]\w*$/.test('myVar_1'); // true(合法的变量名) ``` - 量词 ```js * // 0 次或多次 + // 1 次或多次 ? // 0 次或 1 次 {n} // 恰好 n 次 {n,} // 至少 n 次 {n,m} // n 到 m 次 // 示例 /colou?r/ // "color" 或 "colour"(u 出现 0 或 1 次) /\d{3,4}/ // 3 位或 4 位数字 /ha+/ // "ha", "haa", "haaa"... // 贪婪 vs 非贪婪 // 默认贪婪:尽可能多地匹配 '<div>hello</div>'.match(/<.+>/); // ["<div>hello</div>"](匹配了整个) // 加 ? 变成非贪婪(懒惰):尽可能少地匹配 '<div>hello</div>'.match(/<.+?>/); // ["<div>"](匹配第一个完整标签就停) ``` - 位置锚点 ```js ^ // 字符串开头(m 标志下匹配行开头) $ // 字符串结尾(m 标志下匹配行结尾) \b // 单词边界 /^hello/.test('hello world'); // true(以 hello 开头) /world$/.test('hello world'); // true(以 world 结尾) /^hello$/.test('hello'); // true(整个字符串就是 hello) // \b 单词边界 'cat concatenate'.match(/\bcat\b/g); // ["cat"](只匹配独立的 cat) 'cat concatenate'.match(/cat/g); // ["cat", "cat"](两个都匹配) ``` - 分组与或 ```js // 分组 () /(ab)+/.test('ababab'); // true(ab 重复多次) /(foo|bar)/.test('foobar'); // true // 或 | /cat|dog/.test('I have a cat'); // true /^(http|https):\/\//.test('https://example.com'); // true // 非捕获分组 (?:) — 分组但不捕获 /(?:http|https):\/\/(\w+)/.exec('https://example.com'); // 结果中不包含 http/https 的捕获组 ``` - 捕获组与反向引用 - ```js // 编号捕获组 const dateRe = /(\d{4})-(\d{2})-(\d{2})/; const match = dateRe.exec('2026-04-07'); console.log(match[0]); // "2026-04-07"(完整匹配) console.log(match[1]); // "2026"(第1个捕获组) console.log(match[2]); // "04"(第2个捕获组) console.log(match[3]); // "07"(第3个捕获组) // 命名捕获组(ES2018,强烈推荐) const namedRe = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/; const m = namedRe.exec('2026-04-07'); console.log(m.groups.year); // "2026" console.log(m.groups.month); // "04" console.log(m.groups.day); // "07" // 解构命名捕获组 const { groups: { year, month, day } } = namedRe.exec('2026-04-07'); // 在 replace 中使用捕获组 '2026-04-07'.replace( /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, '$<d>/$<m>/$<y>' ); // "07/04/2026" // 编号引用 '2026-04-07'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1'); // "07/04/2026" // 反向引用:在正则内部引用前面的捕获组 // 匹配重复的单词 /\b(\w+)\s+\1\b/.test('the the'); // true(\1 引用第一个捕获组) /\b(\w+)\s+\1\b/.test('the cat'); // false ``` - Map、Set 与迭代器 - Map — 键值映射 - Map 是 ES6 引入的键值对集合,和普通对象的核心区别在于:任何类型都可以作为键。 - ```js const map = new Map(); // 设置键值对 map.set('name', 'An'); map.set(42, 'number key'); map.set(true, 'boolean key'); const objKey = { id: 1 }; const fnKey = () => {}; map.set(objKey, '对象作为键'); map.set(fnKey, '函数作为键'); // 获取 map.get('name'); // "An" map.get(42); // "number key" map.get(objKey); // "对象作为键" map.get({ id: 1 }); // undefined!不是同一个引用 // 检查与删除 map.has('name'); // true map.delete(42); // true(删除成功) map.size; // 4 // 清空 map.clear(); map.size; // 0 ``` - 用可迭代对象初始化: ```js // 用二维数组初始化 const map = new Map([ ['name', 'An'], ['age', 25], ['city', 'Tokyo'] ]); // 从对象转换 const obj = { a: 1, b: 2, c: 3 }; const mapFromObj = new Map(Object.entries(obj)); // 从 Map 转回对象 const backToObj = Object.fromEntries(mapFromObj); // { a: 1, b: 2, c: 3 } ``` - Map vs 普通对象 ```js // 1. 键的类型 // 对象的键只能是 string 或 Symbol const obj = {}; obj[1] = 'one'; obj['1'] = 'string one'; console.log(obj[1]); // "string one"(数字键被转成了字符串,覆盖了) // Map 的键保持原始类型 const map = new Map(); map.set(1, 'number one'); map.set('1', 'string one'); map.get(1); // "number one" map.get('1'); // "string one"(两个不同的键) // 2. 顺序保证 // Map 始终按插入顺序遍历 // 对象的键顺序在现代引擎中也基本有序,但规范上数字键会排在前面 // 3. 性能 // Map 在频繁增删键值对时性能更好 // Map 有原生的 size 属性(O(1)),对象需要 Object.keys(obj).length // 4. 原型污染 // 对象会继承 Object.prototype 上的属性 const plain = {}; console.log('toString' in plain); // true(原型上的) // Map 没有这个问题 const safeMap = new Map(); safeMap.has('toString'); // false ``` - Set - 唯一值集合 - ```js const set = new Set(); // 添加 set.add(1); set.add(2); set.add(3); set.add(2); // 重复,被忽略 set.add('2'); // 字符串 "2" 和数字 2 是不同的值 console.log(set.size); // 4 // 检查与删除 set.has(2); // true set.delete(2); // true set.has(2); // false // 初始化 const set2 = new Set([1, 2, 3, 2, 1]); // Set { 1, 2, 3 } // 清空 set.clear(); ``` - 可迭代协议 - JavaScript 中 for...of、展开运算符 ...、解构赋值等特性,都依赖可迭代协议。 - 一个对象如果实现了 Symbol.iterator 方法,并且该方法返回一个迭代器,这个对象就是可迭代的。 - ```js // 内置可迭代对象 // Array, String, Map, Set, TypedArray, arguments, NodeList // 验证是否可迭代 function isIterable(obj) { return obj != null && typeof obj[Symbol.iterator] === 'function'; } isIterable([1, 2, 3]); // true isIterable('hello'); // true isIterable(new Map()); // true isIterable(new Set()); // true isIterable({ a: 1 }); // false(普通对象不可迭代) isIterable(42); // false ``` - 迭代器协议: - 迭代器是一个对象,它有一个 next() 方法,每次调用返回 { value, done }。 - ```js // 手动使用迭代器 const arr = [10, 20, 30]; const iterator = arr[Symbol.iterator](); iterator.next(); // { value: 10, done: false } iterator.next(); // { value: 20, done: false } iterator.next(); // { value: 30, done: false } iterator.next(); // { value: undefined, done: true } // for...of 本质就是自动调用迭代器 // 等价于: const iter = arr[Symbol.iterator](); let result = iter.next(); while (!result.done) { console.log(result.value); result = iter.next(); } ``` - 自定义可迭代对象 - ```js // 实现一个范围对象 class Range { constructor(start, end, step = 1) { this.start = start; this.end = end; this.step = step; } [Symbol.iterator]() { let current = this.start; const end = this.end; const step = this.step; return { next() { if (current <= end) { const value = current; current += step; return { value, done: false }; } return { value: undefined, done: true }; } }; } } const range = new Range(1, 5); for (const n of range) { console.log(n); // 1, 2, 3, 4, 5 } // 因为实现了迭代协议,所有相关语法都自动支持 console.log([...range]); // [1, 2, 3, 4, 5] const [a, b, c] = range; // a=1, b=2, c=3 const nums = Array.from(range); // [1, 2, 3, 4, 5] const set = new Set(range); // Set { 1, 2, 3, 4, 5 } // 带步长 const evens = new Range(0, 10, 2); console.log([...evens]); // [0, 2, 4, 6, 8, 10] ``` - 生成器函数 - 生成器用 function* 声明,yield 暂停并产出值。它自动实现了迭代器协议。 - ```js // 基本语法 function* numberGenerator() { yield 1; yield 2; yield 3; } const gen = numberGenerator(); gen.next(); // { value: 1, done: false } gen.next(); // { value: 2, done: false } gen.next(); // { value: 3, done: false } gen.next(); // { value: undefined, done: true } // 生成器自动可迭代 for (const n of numberGenerator()) { console.log(n); // 1, 2, 3 } console.log([...numberGenerator()]); // [1, 2, 3] ``` - 错误处理 - 错误类型 - ```js // 1. SyntaxError — 语法错误,代码根本无法解析 // const x = ; // SyntaxError: Unexpected token ';' // 这种错误在代码运行前就会被发现 // 2. ReferenceError — 引用了不存在的变量 // console.log(notDefined); // ReferenceError: notDefined is not defined // 3. TypeError — 类型错误,对值进行了不支持的操作 // null.toString(); // TypeError: Cannot read properties of null // (42)(); // TypeError: 42 is not a function // const x = 1; x = 2; // TypeError: Assignment to constant variable // 4. RangeError — 值超出有效范围 // new Array(-1); // RangeError: Invalid array length // (1.5).toFixed(200); // RangeError: toFixed() digits argument must be between 0 and 100 // 5. URIError — URI 编解码错误 // decodeURIComponent('%'); // URIError: URI malformed // 6. EvalError — eval 相关(现代 JS 中很少见) // 所有错误都继承自 Error const err = new TypeError('出错了'); console.log(err instanceof TypeError); // true console.log(err instanceof Error); // true console.log(err.message); // "出错了" console.log(err.name); // "TypeError" console.log(err.stack); // 完整的调用栈信息 ``` - try/catch/finally - ```js // 基本结构 try { // 可能出错的代码 const data = JSON.parse('invalid json'); } catch (error) { // 出错时执行 console.log(error.message); // "Unexpected token i in JSON at position 0" console.log(error.name); // "SyntaxError" } finally { // 无论是否出错都执行 console.log('清理工作'); } // catch 可以省略参数(ES2019) try { JSON.parse('{}'); } catch { // 不需要 error 对象时可以省略 console.log('解析失败'); } // finally 即使有 return 也会执行 function example() { try { return '来自 try'; } finally { console.log('finally 仍然执行'); // ⚠️ 如果 finally 里也有 return,会覆盖 try 的 return // return '来自 finally'; // 不推荐这样做 } } example(); // 打印 "finally 仍然执行",返回 "来自 try" ``` - throw 与自定义 Error - ```js // 基本自定义错误 class AppError extends Error { constructor(message, code) { super(message); this.name = 'AppError'; this.code = code; } } // 特定业务错误 class ValidationError extends AppError { constructor(field, message) { super(message, 'VALIDATION_ERROR'); this.name = 'ValidationError'; this.field = field; } } class NotFoundError extends AppError { constructor(resource, id) { super(`${resource} with id ${id} not found`, 'NOT_FOUND'); this.name = 'NotFoundError'; this.resource = resource; this.id = id; } } class AuthenticationError extends AppError { constructor(message = '未认证') { super(message, 'UNAUTHORIZED'); this.name = 'AuthenticationError'; } } // 使用 function getUser(id) { if (typeof id !== 'number') { throw new ValidationError('id', 'ID 必须是数字'); } const user = database.find(u => u.id === id); if (!user) { throw new NotFoundError('User', id); } return user; } ``` - 模块化 - ES Modules — import / export - ES Modules 是 JavaScript 的官方模块系统,每个文件就是一个模块,有自己的作用域。 - 命名导出(Named Exports): - ```js // utils.js // 声明时导出 export const PI = 3.14159; export function add(a, b) { return a + b; } export class Calculator { multiply(a, b) { return a * b; } } // 也可以统一导出 const subtract = (a, b) => a - b; const divide = (a, b) => a / b; export { subtract, divide }; ``` - 命名导入 - ```js // main.js // 导入指定的导出 import { add, PI, Calculator } from './utils.js'; console.log(add(1, 2)); // 3 console.log(PI); // 3.14159 // 重命名导入 import { add as sum, subtract as sub } from './utils.js'; sum(1, 2); // 3 // 导入全部到一个命名空间 import * as Utils from './utils.js'; Utils.add(1, 2); Utils.PI; ``` - 默认导出 - 每个模块只能有一个默认导出。 - ```js // logger.js export default class Logger { log(msg) { console.log(`[LOG] ${msg}`); } error(msg) { console.error(`[ERR] ${msg}`); } } // 也可以导出函数或值 // export default function() { ... } // export default 42; ``` - ```js // 导入默认导出(不需要花括号,名字可以随意取) import Logger from './logger.js'; import MyLogger from './logger.js'; // 不同名字也行 const logger = new Logger(); logger.log('hello'); ``` - 混合使用 ```js // api.js export default class API { constructor(baseUrl) { this.baseUrl = baseUrl; } } export const VERSION = '2.0'; export function createAPI(url) { return new API(url); } // 同时导入默认和命名导出 import API, { VERSION, createAPI } from './api.js'; ``` - 高级模块用法 - 重新导出 ```js // models/user.js export class User { /* ... */ } // models/post.js export class Post { /* ... */ } // models/comment.js export class Comment { /* ... */ } // models/index.js — 统一入口 export { User } from './user.js'; export { Post } from './post.js'; export { Comment } from './comment.js'; // 重新导出全部 export * from './user.js'; // 重新导出并重命名 export { User as UserModel } from './user.js'; // 重新导出默认导出 export { default as User } from './user.js'; ``` - 动态导入 ```js // 按需加载 async function loadChart() { const { Chart } = await import('./chart.js'); const chart = new Chart('#container'); chart.render(data); } // 条件加载 async function loadLocale(lang) { const messages = await import(`./locales/${lang}.js`); return messages.default; } // 搭配 UI 事件 button.addEventListener('click', async () => { const { openModal } = await import('./modal.js'); openModal(); }); // 多个模块并行加载 const [moduleA, moduleB] = await Promise.all([ import('./a.js'), import('./b.js') ]); ``` - CommonJS 对比 - CommonJS 是 Node.js 传统的模块系统,和 ES Modules 有重要区别。 - ```js // CommonJS 语法 // 导出 module.exports = { add, subtract }; // 或 exports.add = add; // 导入 const { add, subtract } = require('./utils'); const utils = require('./utils'); // 主要区别: // 1. CJS 是同步加载,ESM 是异步的 // 2. CJS 的 require 可以在任何地方调用,ESM 的 import 必须在顶层 // 3. CJS 导出的是值的拷贝,ESM 导出的是活绑定 // 4. CJS 用 .js 后缀,ESM 在 Node 中用 .mjs 或在 package.json 中设 "type": "module" // Node.js 中使用 ESM 的两种方式: // 方式1:文件后缀用 .mjs // 方式2:package.json 中添加 "type": "module" ``` 异步编程与浏览器 API - 异步编程与浏览器 API - 异步基础与回调 - 同步 vs 异步 - JavaScript 是单线程语言——同一时刻只能执行一段代码 - 现实中很多操作是耗时的(网络请求、文件读写、定时器),如果同步等待,整个程序就会卡住 - 事件循环 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/07/20260407204918171.png,531,461) - 宏任务与微任务 - ```js console.log('1. 同步'); setTimeout(() => { console.log('2. 宏任务 - setTimeout'); }, 0); Promise.resolve().then(() => { console.log('3. 微任务 - Promise.then'); }); queueMicrotask(() => { console.log('4. 微任务 - queueMicrotask'); }); console.log('5. 同步'); // 输出顺序: // 1. 同步 // 5. 同步 // 3. 微任务 - Promise.then // 4. 微任务 - queueMicrotask // 2. 宏任务 - setTimeout // 解释: // 第一轮:执行所有同步代码 → 1, 5 // 清空微任务队列 → 3, 4 // 取一个宏任务 → 2 ``` - setTimeout 与 setInterval - setTimeout — 延迟执行: - ```js // 基本用法 const timerId = setTimeout(() => { console.log('3 秒后执行'); }, 3000); // 取消定时器 clearTimeout(timerId); // 传递参数给回调 setTimeout((name, age) => { console.log(`${name}, ${age}`); }, 1000, 'An', 25); // 1 秒后打印 "An, 25" // ⚠️ 延迟时间不是精确的 // setTimeout(fn, 100) 意思是"至少 100ms 后执行" // 实际可能更长(调用栈忙、页面后台时浏览器会节流等) // 最小延迟:嵌套 setTimeout 超过 5 层后,最小延迟被限制为 4ms function nested(depth = 0) { const start = performance.now(); setTimeout(() => { console.log(`depth ${depth}: ${(performance.now() - start).toFixed(1)}ms`); if (depth < 10) nested(depth + 1); }, 0); } // 前几层接近 0ms,深层后会变成 4ms+ ``` - 回调函数模式 - 回调是 JavaScript 最早的异步处理方式——把"完成后要做的事"作为函数传进去。 - ```js // 最简单的回调 function fetchData(callback) { setTimeout(() => { const data = { name: 'An', age: 25 }; callback(data); }, 1000); } fetchData((data) => { console.log('收到数据:', data); }); // Node.js 风格的回调:错误优先(Error-first Callback) function readFile(path, callback) { setTimeout(() => { if (path === '') { callback(new Error('路径不能为空'), null); } else { callback(null, `${path} 的内容`); // 第一个参数是错误,第二个是结果 } }, 500); } readFile('data.txt', (error, data) => { if (error) { console.error('读取失败:', error.message); return; } console.log('读取成功:', data); }); ``` - Promise - Promise 是什么 - Promise 是一个代表异步操作最终结果的对象。它解决了回调地狱的问题,让异步代码像同步一样线性书写。 - 一个 Promise 有三种状态,一旦从 pending 变为 fulfilled 或 rejected,就不可再改变: - ```js // pending(进行中)→ fulfilled(已成功) // pending(进行中)→ rejected(已失败) // 创建 Promise const promise = new Promise((resolve, reject) => { // 这个函数(executor)立即同步执行 console.log('executor 执行了'); // 异步操作 setTimeout(() => { const success = true; if (success) { resolve('成功的数据'); // pending → fulfilled } else { reject(new Error('失败了')); // pending → rejected } }, 1000); }); console.log('Promise 创建后'); // 输出顺序: // "executor 执行了"(同步) // "Promise 创建后"(同步) // (1 秒后)promise 被 resolve ``` - then/catch/finally - then — 处理成功和失败: - ```js const promise = new Promise((resolve) => { setTimeout(() => resolve(42), 1000); }); // then 接受两个回调:onFulfilled 和 onRejected promise.then( (value) => console.log('成功:', value), // 42 (error) => console.log('失败:', error) ); // 通常只传第一个参数,用 catch 处理错误 promise .then(value => console.log('成功:', value)) .catch(error => console.log('失败:', error)); ``` - then 的返回值——链式调用的基础: - then 始终返回一个新的 Promise。返回值决定了新 Promise 的状态: - ```js // 1. 返回普通值 → 新 Promise 以该值 resolve Promise.resolve(1) .then(v => v + 1) // 返回 2 .then(v => v * 3) // 返回 6 .then(v => console.log(v)); // 6 // 2. 返回 Promise → 新 Promise 跟随返回的 Promise Promise.resolve(1) .then(v => { return new Promise(resolve => { setTimeout(() => resolve(v + 10), 500); }); }) .then(v => console.log(v)); // 11(等待 500ms 后) // 3. 抛出异常 → 新 Promise 被 reject Promise.resolve(1) .then(v => { throw new Error('出错了'); }) .then(v => console.log('不会执行')) .catch(err => console.log(err.message)); // "出错了" // 4. 不写 return → 返回 undefined Promise.resolve(1) .then(v => { v + 1; }) // 忘了 return! .then(v => console.log(v)); // undefined ``` - catch 错误处理 - ```js // catch 是 .then(null, onRejected) 的语法糖 // 它能捕获前面所有 then 中抛出的错误 Promise.resolve(1) .then(v => { throw new Error('then 中的错误'); }) .then(v => { console.log('跳过'); // 不执行 }) .catch(err => { console.log('捕获:', err.message); // "then 中的错误" return '已恢复'; // catch 也可以返回值,让链继续 }) .then(v => { console.log(v); // "已恢复"(链恢复了) }); // ⚠️ then 的第二个参数 vs catch 的区别 Promise.reject(new Error('初始错误')) .then( v => console.log('成功'), err => { console.log('then 的 onRejected:', err.message); throw new Error('新错误'); } ); // "新错误" 无法被捕获!因为同一个 then 的 onRejected 不能捕获 onFulfilled 的错误 // ✅ 用 catch 更安全 Promise.reject(new Error('初始错误')) .then(v => console.log('成功')) .catch(err => console.log('catch 捕获:', err.message)); ``` - finally — 无论成败都执行: - ```js function fetchData() { showLoading(); return fetch('/api/data') .then(res => res.json()) .then(data => { displayData(data); return data; }) .catch(err => { showError(err.message); }) .finally(() => { hideLoading(); // 无论成功失败都要隐藏 loading }); } // finally 的特殊行为: // 1. 不接收参数(不知道是成功还是失败) // 2. 默认"透传"前一步的值 Promise.resolve(42) .finally(() => { console.log('清理'); // 执行 return 999; // 返回值被忽略(除非抛错) }) .then(v => console.log(v)); // 42(透传,不是 999) // 3. 如果 finally 中抛出错误,会覆盖之前的结果 Promise.resolve(42) .finally(() => { throw new Error('finally 出错'); }) .catch(err => console.log(err.message)); // "finally 出错" ``` - Promise 静态方法 - Promise.resolve / Promise.reject — 快速创建: - ```js // 创建一个立即 resolve 的 Promise const p1 = Promise.resolve(42); p1.then(v => console.log(v)); // 42 // 如果传入 Promise,原样返回 const p2 = Promise.resolve(Promise.resolve('hello')); p2.then(v => console.log(v)); // "hello"(不会嵌套) // 如果传入 thenable(有 then 方法的对象),会展开 const thenable = { then(resolve) { resolve('from thenable'); } }; Promise.resolve(thenable).then(v => console.log(v)); // "from thenable" // 创建一个立即 reject 的 Promise const p3 = Promise.reject(new Error('失败')); p3.catch(err => console.log(err.message)); // "失败" ``` - Promise.all — 全部成功才成功: - ```js const p1 = fetch('/api/users').then(r => r.json()); const p2 = fetch('/api/posts').then(r => r.json()); const p3 = fetch('/api/comments').then(r => r.json()); // 并行执行,全部完成后返回结果数组(顺序与传入一致) Promise.all([p1, p2, p3]) .then(([users, posts, comments]) => { console.log(users, posts, comments); }) .catch(err => { // ⚠️ 任何一个失败,整个 all 就失败 // 其他 Promise 仍然会执行完,但结果被丢弃 console.error('有一个请求失败:', err); }); // 传入空数组立即 resolve Promise.all([]).then(v => console.log(v)); // [] // 非 Promise 值会被自动包装 Promise.all([1, 'hello', Promise.resolve(42)]) .then(v => console.log(v)); // [1, 'hello', 42] // 实际应用:批量操作 const userIds = [1, 2, 3, 4, 5]; const users = await Promise.all( userIds.map(id => getUser(id)) ); ``` - async/await - async 函数基础 - async/await 是 Promise 的语法糖,让异步代码看起来像同步代码一样。 - ```js // async 函数始终返回 Promise async function greet() { return 'Hello'; // 自动包装为 Promise.resolve('Hello') } greet().then(v => console.log(v)); // "Hello" // 等价于 function greet() { return Promise.resolve('Hello'); } // 即使返回普通值,也是 Promise async function getNumber() { return 42; } console.log(getNumber() instanceof Promise); // true // 抛出的错误变成 rejected Promise async function fail() { throw new Error('出错了'); } fail().catch(err => console.log(err.message)); // "出错了" ``` - await 表达式 - await 只能在 async 函数(或模块顶层)内部使用。它暂停函数执行,等待 Promise resolve,然后返回 resolve 的值。 - ```js async function example() { console.log('1. 开始'); // await 暂停,等 Promise resolve const result = await new Promise(resolve => { setTimeout(() => resolve('数据'), 1000); }); console.log('2. 收到:', result); // 1 秒后 console.log('3. 继续'); } example(); console.log('4. async 函数外部'); // 输出: // 1. 开始 // 4. async 函数外部(不等待 async 函数) // (1 秒后) // 2. 收到: 数据 // 3. 继续 ``` - await 的本质——对比 then 链: ```js // Promise 链 function getData() { return getUser(1) .then(user => getOrders(user.id)) .then(orders => getOrderDetail(orders[0].id)) .then(detail => { console.log(detail); return detail; }); } // async/await(完全等价,但可读性好得多) async function getData() { const user = await getUser(1); const orders = await getOrders(user.id); const detail = await getOrderDetail(orders[0].id); console.log(detail); return detail; } // 最大的优势:中间变量全部可用 // 不需要像 Promise 链那样用嵌套或外部变量传递 ``` - 错误处理 - try/catch ```js async function fetchUserData(userId) { try { const user = await getUser(userId); const orders = await getOrders(user.id); const detail = await getOrderDetail(orders[0].id); return { user, orders, detail }; } catch (error) { // 任何一步的 reject 或 throw 都会被捕获 console.error('获取数据失败:', error.message); throw error; // 重新抛出或返回兜底数据 } } // 精细化错误处理:每步单独 try/catch async function fetchWithFallback(userId) { let user; try { user = await getUser(userId); } catch (error) { console.warn('用户服务异常,使用默认数据'); user = { id: userId, name: 'Unknown' }; } let orders; try { orders = await getOrders(user.id); } catch (error) { console.warn('订单服务异常'); orders = []; } return { user, orders }; } ```

四月 3, 2026

OpenSpec 实验

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:OpenSpec 实验 ...

三月 31, 2026

CSS 复习

基础核心 - 基础核心 - CSS 基础与选择器 - CSS 的三种引入方式 - ```html <!-- 1. 行内样式:直接写在标签上,优先级最高但不推荐大量使用 --> <p style="color: red; font-size: 16px;">这是行内样式</p> <!-- 2. 内嵌样式:写在 <head> 的 <style> 标签里 --> <head> <style> p { color: blue; } </style> </head> <!-- 3. 外链样式(推荐!):单独的 .css 文件 --> <link rel="stylesheet" href="style.css"> ``` - 一般使用外链样式,它能让 HTML 和 CSS 分离,方便维护和缓存 - 基础选择器 - ```css /* 元素选择器 —— 选中所有该标签 */ p { color: #333; } h1 { font-size: 24px; } /* 类选择器 —— 最常用,可复用 */ .card { background: #fff; padding: 16px; } .highlight { color: orange; } /* ID 选择器 —— 唯一的,一个页面同一个 ID 只用一次 */ #header { height: 60px; } /* 通配符选择器 —— 选中所有元素,常用于重置 */ * { margin: 0; padding: 0; box-sizing: border-box; /* 第 3 天会详细讲 */ } ``` - 优先使用类选择器,ID 选择器优先级太高不好覆盖,元素选择器范围太大容易误伤 - 组合选择器 - ```css /* 后代选择器(空格)—— 选中所有后代,不管嵌套多深 */ .article p { line-height: 1.8; } /* 子代选择器(>)—— 只选直接子元素 */ .nav > li { display: inline-block; } /* 相邻兄弟选择器(+)—— 紧挨着的下一个兄弟 */ h2 + p { font-size: 18px; /* 紧跟在 h2 后面的第一个 p */ } /* 通用兄弟选择器(~)—— 后面所有的兄弟 */ h2 ~ p { color: #666; /* h2 后面的所有 p */ } ``` - ```html <div class="nav"> <li>直接子元素 ✅ 被 .nav > li 选中</li> <li>直接子元素 ✅ <li>孙子元素 ❌ 不会被 .nav > li 选中</li> <li>孙子元素 ❌ 但会被 .nav li 选中</li> </li> </div> ``` - 多选择器组合技巧 - ```css /* 并集选择器(逗号)—— 同时给多个选择器设置相同样式 */ h1, h2, h3 { font-weight: bold; color: #222; } /* 交集选择器(紧挨着写)—— 同时满足多个条件 */ p.highlight { /* 既是 p 标签,又有 highlight 类 */ background: yellow; } /* 链式类选择器 —— 同时拥有多个类 */ .btn.primary.large { /* class="btn primary large" 的元素 */ padding: 12px 24px; } ``` - 伪类选择器 - 伪类用 : 开头,表示元素的某种状态或位置 - 交互状态伪类 ```css /* 鼠标悬停 */ .btn:hover { background: #2563eb; color: white; } /* 获得焦点(键盘 Tab 到或点击输入框时) */ input:focus { border-color: #2563eb; outline: none; } /* 点击瞬间 */ .btn:active { transform: scale(0.98); } /* 已访问的链接 */ a:visited { color: #666; } ``` - 结构位置伪类 ```css /* 第一个 / 最后一个子元素 */ li:first-child { font-weight: bold; } li:last-child { border-bottom: none; } /* 第 n 个子元素 —— 最灵活的选择器 */ tr:nth-child(2n) { background: #f9f9f9; /* 偶数行加背景,实现斑马纹 */ } tr:nth-child(odd) { background: #fff; /* odd = 奇数,even = 偶数 */ } /* 前 3 个 */ li:nth-child(-n+3) { color: red; } /* 每隔 3 个 */ li:nth-child(3n) { font-weight: bold; } /* 同类型的第一个(区分标签类型) */ p:first-of-type { font-size: 18px; } /* 唯一的子元素 */ p:only-child { text-align: center; } ``` - 其他实用伪类 ```css /* 否定伪类 —— 排除某些元素 */ li:not(:last-child) { border-bottom: 1px solid #eee; /* 除了最后一个都加下划线 */ } input:not([type="submit"]) { border: 1px solid #ddd; /* 除了提交按钮外的 input */ } /* 空元素 */ div:empty { display: none; /* 隐藏没有内容的 div */ } ``` - 伪元素选择器 - 用 :: 开头,会创建一个虚拟元素插入到页面中 - ```css /* ::before 在元素内容前面插入 */ .required::before { content: "* "; color: red; } /* ::after 在元素内容后面插入 */ a.external::after { content: " ↗"; font-size: 12px; } /* 经典用法:清除浮动(了解即可) */ .clearfix::after { content: ""; display: block; clear: both; } /* 装饰性下划线 */ .fancy-title::after { content: ""; display: block; width: 60px; height: 3px; background: #2563eb; margin-top: 8px; } /* 选中文本的样式 */ ::selection { background: #2563eb; color: white; } /* 输入框占位符 */ input::placeholder { color: #aaa; font-style: italic; } ``` - ::before 和 ::after 必须有 content 属性,哪怕是空字符串 "",否则不会显示。 - 属性选择器 - 根据 HTML 属性来选择元素,在表单样式中特别好用: - ```css /* 精确匹配 */ input[type="text"] { border: 1px solid #ddd; } input[type="password"] { letter-spacing: 4px; } /* 开头匹配 ^= */ a[href^="https"] { color: green; /* https 开头的链接标绿 */ } /* 结尾匹配 $= */ a[href$=".pdf"] { color: red; /* PDF 链接标红 */ } /* 包含匹配 *= */ img[src*="avatar"] { border-radius: 50%; /* src 中含 avatar 的图片变圆 */ } /* 有这个属性就选中 */ [disabled] { opacity: 0.5; cursor: not-allowed; } ``` - 优先级(权重)计算 - 当多条规则冲突时,浏览器按优先级决定谁生效 - ```css !important → 10000(尽量不用) 行内样式 style="" → 1000 ID 选择器 #id → 100 类/伪类/属性选择器 → 10 元素/伪元素选择器 → 1 通配符 * → 0 ``` - ```css /* 权重:0-0-1(一个元素) */ p { color: black; } /* 权重:0-1-0(一个类) */ .text { color: blue; } /* 权重:0-1-1(一个类 + 一个元素) */ p.text { color: green; } /* 权重:1-0-0(一个 ID)→ 这条赢 */ #intro { color: red; } /* 权重:0-2-1(两个类 + 一个元素) */ .card .content p { color: gray; } ``` - 盒模型深入 - 盒模型四层结构 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/01/20260401100513772.png,450,380) - ```css .box { /* 内容区 */ width: 200px; height: 100px; /* 内边距 —— 内容到边框的呼吸空间 */ padding: 16px; /* 四边相同 */ padding: 16px 24px; /* 上下 16 左右 24 */ padding: 8px 16px 24px; /* 上 8 左右 16 下 24 */ padding: 8px 16px 24px 32px;/* 上 右 下 左(顺时针) */ /* 边框 */ border: 1px solid #ddd; border-radius: 8px; /* 圆角 */ /* 外边距 —— 盒子与其他元素的间距 */ margin: 20px; margin: 0 auto; /* 水平居中的经典写法 */ } ``` - padding 和 margin 的简写顺序是顺时针:上 → 右 → 下 → 左。两个值时是"上下"和"左右",三个值是"上""左右""下"。 - box-sizing:最重要的一个属性 - ```css /* 默认值:width 只算内容区 */ .content-box { box-sizing: content-box; width: 200px; padding: 20px; border: 2px solid #000; /* 实际占据宽度 = 200 + 20*2 + 2*2 = 244px 😰 */ } /* 推荐值:width 包含 padding 和 border */ .border-box { box-sizing: border-box; width: 200px; padding: 20px; border: 2px solid #000; /* 实际占据宽度 = 200px 🎉 内容区自动缩小为 156px */ } ``` - content-box 的计算方式违反直觉,设了 width: 200px 结果实际更宽。 - 所以现代开发第一行永远是: ```css /* 全局重置 —— 背下来,每个项目都加 */ *, *::before, *::after { box-sizing: border-box; } ``` - margin 合并 - 相邻兄弟的 margin 合并 ```css .box-a { margin-bottom: 30px; } .box-b { margin-top: 20px; } /* 两者之间的间距不是 50px,而是 30px(取较大值) */ ``` - 父子元素的 margin 穿透 ```html <div class="parent"> <div class="child">子元素</div> </div> <style> .parent { background: #f0f0f0; } .child { margin-top: 30px; } /* 这个 margin 会"穿透"到父元素外面!父元素整体下移 30px */ </style> ``` - 解决办法 ```css /* 方法 1:给父元素加 overflow */ .parent { overflow: hidden; } /* 方法 2:给父元素加 padding 代替子元素的 margin */ .parent { padding-top: 30px; } /* 方法 3:给父元素加 border(哪怕是透明的) */ .parent { border-top: 1px solid transparent; } /* 方法 4(最推荐):用 Flexbox,自动避免合并 */ .parent { display: flex; flex-direction: column; } ``` - 为了避免 margin 合并带来的混乱,养成一个好习惯:统一只用一个方向的 margin。推荐只用 margin-bottom,不要上下混用 ```css /* ✅ 好习惯:统一用 margin-bottom */ h2 { margin-bottom: 12px; } p { margin-bottom: 16px; } /* ❌ 坏习惯:上下混用 */ h2 { margin-bottom: 12px; } p { margin-top: 20px; } /* 和 h2 的 bottom 合并了,不直观 */ ``` - display 属性 ```css /* block —— 独占一行,可以设宽高 */ /* 默认就是 block 的标签:div, p, h1~h6, ul, li, section... */ .block-el { display: block; width: 300px; /* ✅ 生效 */ height: 100px; /* ✅ 生效 */ margin: 20px; /* ✅ 四个方向都生效 */ } /* inline —— 和文字一起排列,不能设宽高 */ /* 默认就是 inline 的标签:span, a, strong, em, img... */ .inline-el { display: inline; width: 300px; /* ❌ 无效 */ height: 100px; /* ❌ 无效 */ margin-top: 20px; /* ❌ 上下 margin 无效 */ padding: 10px; /* ⚠️ 左右有效,上下视觉上有但不影响布局 */ } /* inline-block —— 既能和文字一行排列,又能设宽高 */ /* 两全其美,适合按钮、标签等小组件 */ .inline-block-el { display: inline-block; width: 120px; /* ✅ 生效 */ height: 40px; /* ✅ 生效 */ margin: 10px; /* ✅ 四个方向都生效 */ } ``` - 常用技巧:把 <a> 或 <span> 改成 inline-block,就可以给它们设置宽高和完整的 padding/margin 了。 ```css /* 经典用例:inline-block 做按钮 */ .tag { display: inline-block; padding: 4px 12px; background: #e8f4fd; color: #1a73e8; border-radius: 4px; font-size: 13px; } ``` - border 的用法 - ```css /* 基础语法:宽度 样式 颜色 */ .box { border: 1px solid #ddd; } /* 单侧边框 */ .section { border-left: 3px solid #2563eb; padding-left: 12px; } /* 圆角 */ .card { border-radius: 8px; } /* 四角相同 */ .avatar { border-radius: 50%; } /* 正圆(需要宽高相等) */ .pill { border-radius: 9999px; } /* 胶囊形状 */ /* 只圆上面两个角 */ .tab { border-radius: 8px 8px 0 0; } /* 虚线 / 点线 */ .dashed { border: 2px dashed #aaa; } .dotted { border: 1px dotted #aaa; } /* outline —— 不占空间的"边框",常用于焦点指示 */ button:focus { outline: 2px solid #2563eb; outline-offset: 2px; } ``` - 文档流与定位 - 正常文档流 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/01/20260401154807166.png,524,382) - 正常文档流中的元素互相"知道"彼此的存在,会自动避让 - 接下来要学的定位方式,有些会脱离文档流,让元素"飘起来",不再影响其他元素的排列 - position 五种定位方式 - static 默认值 - ```css .box { position: static; /* 默认就是这个,不用写 */ /* top/left/right/bottom 无效 */ /* z-index 无效 */ } ``` - relative 相对定位 - ```css .box { position: relative; top: 20px; /* 相对原位向下移 20px */ left: 30px; /* 相对原位向右移 30px */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/01/20260401155249189.png,311,255) - relative 单独用的场景不多,它最大的价值是给 absolute 子元素提供定位参考点。 - absolute 绝对定位(重点) - 脱离文档流,相对于最近的有定位属性的祖先元素来定位 - ```css /* 经典搭配:父 relative + 子 absolute */ .parent { position: relative; /* 成为定位参考点 */ width: 400px; height: 300px; } .child { position: absolute; top: 0; right: 0; /* 贴在父元素的右上角 */ /* 这个子元素已经脱离文档流,不占原来的空间 */ } ``` - 如果找不到有定位属性的祖先,就相对于整个页面(<html>)定位。 - 常见用法 ```css /* 右上角的徽标 / 角标 */ .avatar-wrap { position: relative; display: inline-block; } .badge { position: absolute; top: -4px; right: -4px; width: 18px; height: 18px; background: red; color: white; font-size: 12px; text-align: center; line-height: 18px; border-radius: 50%; } /* 绝对定位实现水平垂直居中 */ .centered { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } /* 铺满父元素(做遮罩层常用) */ .overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; /* 等价于简写:inset: 0; */ background: rgba(0, 0, 0, 0.5); } ``` - fixed - 脱离文档流,相对于浏览器视口定位,滚动页面也不动: - ```css /* 固定顶部导航 */ .navbar { position: fixed; top: 0; left: 0; right: 0; /* left + right = 0 让它撑满整个宽度 */ height: 60px; background: white; z-index: 100; /* 确保在最上层 */ } /* 别忘了给 body 加上 padding,防止内容被导航遮住 */ body { padding-top: 60px; } /* 右下角的回到顶部按钮 */ .back-to-top { position: fixed; bottom: 30px; right: 30px; width: 44px; height: 44px; } ``` - sticky 粘性定位(现代常用) - 结合了 relative 和 fixed 的效果——正常情况下跟着滚动,滚到指定位置后就"粘"住 - 父元素不能设 overflow: hidden 或 overflow: auto,否则 sticky 会失效。 - ```css /* 表格的表头粘在顶部 */ thead th { position: sticky; top: 0; background: white; } /* 侧边栏跟着滚到一定位置后固定 */ .sidebar { position: sticky; top: 80px; /* 距顶部 80px 时粘住 */ } /* 分组列表的标题吸顶 */ .group-title { position: sticky; top: 0; background: #f5f5f5; padding: 8px 16px; font-weight: bold; } ``` - z-index 层叠上下文 - 当元素重叠时,z-index 控制谁在上面 - ```css /* z-index 只对有定位属性的元素生效(非 static) */ .layer-back { position: relative; z-index: 1; } .layer-middle { position: relative; z-index: 10; } .layer-front { position: relative; z-index: 100; } ``` - 分层规范 ```css /* 建议给项目定一套层级规范,避免数字乱飞 */ :root { --z-dropdown: 100; /* 下拉菜单 */ --z-sticky: 200; /* 粘性元素 */ --z-navbar: 300; /* 导航栏 */ --z-overlay: 400; /* 遮罩层 */ --z-modal: 500; /* 弹窗 */ --z-toast: 600; /* 提示消息 */ } .modal { z-index: var(--z-modal); } .overlay { z-index: var(--z-overlay); } .navbar { z-index: var(--z-navbar); } ``` - z-index 不是全局比较的。每个设置了定位 + z-index 的元素会创建一个层叠上下文,子元素的 z-index 只在父元素内部比较: - 浮动 float (了解即可) - 浮动是 CSS2 时代的布局方式,现在已经被 Flexbox 和 Grid 替代 - ```css /* 文字环绕图片 —— float 唯一还在用的场景 */ .article img { float: left; margin-right: 16px; margin-bottom: 8px; } ``` - 如果遇到浮动导致父元素高度塌陷的问题,用学过的 ::after 清除浮动或者直接改用 Flexbox 就好 - 颜色与背景 - 颜色表示法 - HEX(十六进制) ```css /* 6 位写法:#RRGGBB */ .box { color: #2563eb; } /* 3 位简写:当每组两位相同时 */ .box { color: #fff; } /* 等价于 #ffffff */ .box { color: #333; } /* 等价于 #333333 */ /* 8 位写法:带透明度 #RRGGBBAA */ .box { background: #2563eb80; } /* 50% 透明度 */ ``` - HEX 是最常见的写法,设计稿给的颜色值通常就是 HEX。缺点是不直观,看不出颜色长什么样。 - RGB/RGBA - ```css /* rgb(红, 绿, 蓝) 每个值 0~255 */ .box { color: rgb(37, 99, 235); } /* rgba 加一个透明度参数 0~1 */ .box { background: rgba(37, 99, 235, 0.5); } /* 50% 透明 */ /* 现代写法:直接在 rgb 里加透明度,用 / 分隔 */ .box { background: rgb(37 99 235 / 0.5); } ``` - HSL / HSLA(推荐学会) - ```css /* hsl(色相, 饱和度, 亮度) */ /* 色相 H:0~360 的色环角度 0/360 = 红,120 = 绿,240 = 蓝 饱和度 S:0% = 灰色,100% = 纯色 亮度 L:0% = 黑色,50% = 正常,100% = 白色 */ .box { color: hsl(220, 85%, 53%); } /* 蓝色 */ .box { color: hsl(220, 85%, 53%, 0.5); } /* 半透明蓝 */ ``` - ```css /* 同一个蓝色,只改亮度就能得到一组深浅色 */ .blue-50 { background: hsl(220, 85%, 97%); } /* 极浅蓝背景 */ .blue-100 { background: hsl(220, 85%, 90%); } /* 浅蓝 */ .blue-500 { background: hsl(220, 85%, 53%); } /* 主色 */ .blue-700 { background: hsl(220, 85%, 35%); } /* 深蓝 */ .blue-900 { background: hsl(220, 85%, 20%); } /* 极深蓝文字 */ /* 只改色相就能切换颜色 */ .red { color: hsl(0, 85%, 53%); } .green { color: hsl(120, 85%, 35%); } .purple { color: hsl(270, 85%, 53%); } ``` - 命名颜色 - ```css .box { color: red; } .box { background: transparent; } /* 透明,这个经常用 */ .box { color: currentColor; } /* 继承当前元素的 color 值 */ ``` - opacity 与 RGBA 透明的区别 ```css /* opacity:整个元素(包括文字和子元素)都变透明 */ .card { background: #2563eb; color: white; opacity: 0.5; /* 背景、文字、子元素全部变成 50% 透明 😰 */ } /* rgba / hsla:只让颜色本身透明 */ .card { background: rgba(37, 99, 235, 0.5); color: white; /* 只有背景半透明,文字完全清晰 ✅ */ } ``` - 背景属性 - background-color ```css .box { background-color: #f5f5f5; } ``` - 渐变背景(重点) ```css /* 线性渐变 */ .banner { background: linear-gradient(to right, #667eea, #764ba2); } /* 指定角度 */ .banner { background: linear-gradient(135deg, #667eea, #764ba2); } /* 多色渐变 */ .rainbow { background: linear-gradient( 90deg, #ff6b6b, #feca57, #48dbfb, #ff9ff3 ); } /* 控制颜色位置 */ .sharp { background: linear-gradient( 90deg, #2563eb 0%, #2563eb 50%, /* 蓝色占前 50% */ #f59e0b 50%, /* 黄色从 50% 开始,形成硬分界 */ #f59e0b 100% ); } /* 径向渐变(从中心向外) */ .spotlight { background: radial-gradient(circle, #fff, #e2e8f0); } /* 椭圆渐变 + 偏移中心 */ .glow { background: radial-gradient( ellipse at top left, rgba(37, 99, 235, 0.2), transparent 60% ); } ``` - 背景图片 ```css .hero { /* 基础用法 */ background-image: url('hero.jpg'); /* 不重复 */ background-repeat: no-repeat; /* 覆盖整个容器(可能裁切) */ background-size: cover; /* 完全显示(可能留白) */ background-size: contain; /* 居中显示 */ background-position: center; /* 滚动时背景固定(视差效果) */ background-attachment: fixed; /* 简写(推荐记住这个万能组合) */ background: url('hero.jpg') no-repeat center / cover; } ``` - 多重背景叠加 ```css /* 渐变叠加在图片上 —— 做图文卡片的经典手法 */ .card-with-image { background: linear-gradient( to bottom, transparent 40%, rgba(0, 0, 0, 0.7) 100% ), url('photo.jpg') no-repeat center / cover; color: white; } /* 装饰性渐变叠加 */ .fancy-bg { background: radial-gradient(ellipse at top left, rgba(37, 99, 235, 0.15), transparent 50%), radial-gradient(ellipse at bottom right, rgba(245, 158, 11, 0.15), transparent 50%), #fafafa; } ``` - 配色技巧 ```css /* 一个页面通常只需要这几种颜色 */ :root { --color-primary: hsl(220, 85%, 53%); /* 主色:按钮、链接 */ --color-primary-light: hsl(220, 85%, 95%);/* 主色浅版:背景 */ --color-primary-dark: hsl(220, 85%, 40%); /* 主色深版:悬停 */ --color-gray-50: hsl(220, 10%, 98%); /* 页面背景 */ --color-gray-200: hsl(220, 10%, 88%); /* 边框、分隔线 */ --color-gray-500: hsl(220, 10%, 50%); /* 次要文字 */ --color-gray-900: hsl(220, 10%, 12%); /* 主文字 */ --color-success: hsl(145, 65%, 42%); /* 成功 */ --color-warning: hsl(40, 95%, 55%); /* 警告 */ --color-danger: hsl(0, 75%, 55%); /* 错误 */ } /* 使用变量 */ .btn-primary { background: var(--color-primary); color: white; } .btn-primary:hover { background: var(--color-primary-dark); } ``` - 字体与文字排版 - font-family 字体栈 - ```css /* 字体栈:从左到右依次尝试,找到第一个可用的就使用 */ body { font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; } ``` - ```css font-family: "PingFang SC", /* macOS 上的中文字体 */ "Microsoft YaHei", /* Windows 上的中文字体 */ "Helvetica Neue", /* macOS 上的英文字体 */ Arial, /* Windows 上的英文字体 */ sans-serif; /* 兜底:让系统选一个无衬线字体 */ ``` - 引入 google fonts - ```html <!-- 在 HTML 的 <head> 中引入 --> <link href="https://fonts.googleapis.cn/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet"> ``` - ```css body { font-family: "Noto Sans SC", sans-serif; } ``` -系统字体栈(零加载延迟) ```css /* 直接使用用户系统里的字体,不需要下载 */ body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans SC", sans-serif; } /* 代码用等宽字体 */ code, pre { font-family: "Fira Code", "JetBrains Mono", "Source Code Pro", Consolas, monospace; } ``` - font-size 与单位 - ```css /* px —— 绝对单位,固定大小 */ h1 { font-size: 32px; } /* em —— 相对于父元素的 font-size */ .parent { font-size: 16px; } .child { font-size: 1.5em; } /* = 16 × 1.5 = 24px */ /* em 的问题:层层嵌套时会复利式叠加 */ .child .grandchild { font-size: 1.5em; } /* = 24 × 1.5 = 36px 😰 */ /* rem —— 相对于根元素(html)的 font-size ⭐ 推荐 */ html { font-size: 16px; } /* 浏览器默认就是 16px */ h1 { font-size: 2rem; } /* = 16 × 2 = 32px */ h2 { font-size: 1.5rem; } /* = 16 × 1.5 = 24px */ p { font-size: 1rem; } /* = 16px */ ``` - ```css /* px —— 绝对单位,固定大小 */ h1 { font-size: 32px; } /* em —— 相对于父元素的 font-size */ .parent { font-size: 16px; } .child { font-size: 1.5em; } /* = 16 × 1.5 = 24px */ /* em 的问题:层层嵌套时会复利式叠加 */ .child .grandchild { font-size: 1.5em; } /* = 24 × 1.5 = 36px 😰 */ /* rem —— 相对于根元素(html)的 font-size ⭐ 推荐 */ html { font-size: 16px; } /* 浏览器默认就是 16px */ h1 { font-size: 2rem; } /* = 16 × 2 = 32px */ h2 { font-size: 1.5rem; } /* = 16 × 1.5 = 24px */ p { font-size: 1rem; } /* = 16px */ ``` - 记住一个原则:font-size 用 rem,padding/margin 等间距可以用 rem 或 px。 - rem 的好处是后面做响应式时,只需要改 html 的 font-size 就能整体缩放。 - font 相关属性大全 - ```css .text { /* 字号 */ font-size: 16px; /* 字重:100~900,或 normal(400) / bold(700) */ font-weight: 400; /* 正常 */ font-weight: 700; /* 加粗 */ /* 风格 */ font-style: normal; /* 正常 */ font-style: italic; /* 斜体 */ /* 简写(顺序固定:style weight size/line-height family) */ font: italic 700 18px/1.6 "Noto Sans SC", sans-serif; } ``` - 一般只用 400(正文)和 700(标题/强调)两种就够了,最多加一个 500(中等) - 行高与文本间距 - ```css .article { /* line-height:行高,控制行与行之间的距离 */ line-height: 1.8; /* 无单位写法(推荐)= font-size × 1.8 */ line-height: 28px; /* 固定值写法 */ /* letter-spacing:字间距 */ letter-spacing: 0.5px; /* 中文正文加一点字间距更透气 */ letter-spacing: 2px; /* 大标题可以拉大 */ letter-spacing: -0.5px; /* 紧凑排列 */ /* word-spacing:词间距(英文用) */ word-spacing: 2px; } ``` - ```css /* 正文:1.6 ~ 1.8,中文偏大更舒适 */ .body-text { line-height: 1.7; } /* 标题:1.2 ~ 1.4,紧凑一些更有力 */ h1, h2, h3 { line-height: 1.3; } /* 按钮/标签:等于 height 实现垂直居中 */ .btn { height: 40px; line-height: 40px; /* 文字垂直居中 */ } /* UI 组件:1.4 ~ 1.5 */ .card p { line-height: 1.5; } ``` - 文本对齐与装饰 - ```css /* 水平对齐 */ .left { text-align: left; } .center { text-align: center; } .right { text-align: right; } .justify { text-align: justify; } /* 两端对齐,中文排版常用 */ /* 文本装饰 */ a { text-decoration: none; } /* 去掉链接下划线 */ .del { text-decoration: line-through; } /* 删除线 */ .underline { text-decoration: underline; text-decoration-color: #2563eb; /* 下划线颜色 */ text-underline-offset: 4px; /* 下划线和文字的距离 */ text-decoration-thickness: 2px; /* 下划线粗细 */ } /* 文本转换 */ .upper { text-transform: uppercase; } /* 全大写 */ .lower { text-transform: lowercase; } /* 全小写 */ .cap { text-transform: capitalize; } /* 首字母大写 */ /* 文本缩进(中文段落首行缩进) */ .cn-paragraph { text-indent: 2em; } /* 两个字的宽度 */ ``` - 文本溢出管理 - ```css /* 单行文本溢出显示省略号 —— 三件套必须一起用 */ .ellipsis { white-space: nowrap; /* 不换行 */ overflow: hidden; /* 超出隐藏 */ text-overflow: ellipsis; /* 显示省略号 */ } /* 多行文本溢出(限制 3 行) */ .line-clamp { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; /* 最多显示 3 行 */ overflow: hidden; } /* white-space 的几个值 */ .nowrap { white-space: nowrap; } /* 强制不换行 */ .pre { white-space: pre; } /* 保留空格和换行(像 <pre>) */ .prewrap { white-space: pre-wrap; } /* 保留空格换行,但允许自动折行 */ /* overflow-wrap:长单词/URL 强制断行 */ .break-word { overflow-wrap: break-word; /* 旧写法:word-break: break-all; 也能用但更暴力 */ } ``` 布局体系 - 布局体系 - Flex 基础与主轴 - Flex 容器与项目 - ```html <!-- 容器(父元素):加 display: flex 的那个 --> <div class="container"> <!-- 项目(子元素):容器的直接子元素自动成为 flex 项目 --> <div class="item">A</div> <div class="item">B</div> <div class="item">C</div> </div> ``` - ```css .container { display: flex; /* 这一行就开启了 Flexbox */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402104212118.png,568,241) - inline-flex,和 flex 唯一的区别是容器本身变成行内元素 - ```css /* flex:容器是块级元素,独占一行 */ .container { display: flex; } /* inline-flex:容器是行内元素,可以和其他内容并排 */ .badge-group { display: inline-flex; } ``` - 主轴与交叉轴 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402104703467.png,338,443) - justify-content 控制主轴,align-items 控制交叉轴。主轴方向由 flex-direction 决定。 - flex-direction 主轴方向 - ```css .container { display: flex; /* 四个方向 */ flex-direction: row; /* 默认:从左到右 → */ flex-direction: row-reverse; /* 从右到左 ← */ flex-direction: column; /* 从上到下 ↓ */ flex-direction: column-reverse; /* 从下到上 ↑ */ } ``` - ```css /* 水平导航 */ .nav { display: flex; flex-direction: row; } /* 垂直堆叠的表单 */ .form { display: flex; flex-direction: column; } /* 移动端常见:图标在上文字在下 */ .tab-item { display: flex; flex-direction: column; align-items: center; } ``` - justify-content 主轴对齐(重点) - ```css .container { display: flex; justify-content: flex-start; /* 默认:靠起始端 */ justify-content: flex-end; /* 靠末端 */ justify-content: center; /* 居中 */ justify-content: space-between; /* 两端对齐,中间等分 */ justify-content: space-around; /* 每个项目两侧等间距 */ justify-content: space-evenly; /* 所有间距完全相等 */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402105720219.png,615,368) - 常用场景 ```css /* 导航栏:logo 在左,链接在右 */ .navbar { display: flex; justify-content: space-between; } /* 按钮组居中 */ .button-group { display: flex; justify-content: center; } /* 底部 Tab 栏等分 */ .tab-bar { display: flex; justify-content: space-evenly; } /* 表单按钮靠右 */ .form-actions { display: flex; justify-content: flex-end; } ``` - flex-wrap - 默认情况下所有项目挤在一行,即使溢出也不换行 - ```css .container { display: flex; flex-wrap: nowrap; /* 默认:不换行,项目可能被压缩 */ flex-wrap: wrap; /* 放不下就换行 */ flex-wrap: wrap-reverse; /* 换行但方向反转(新行在上面) */ } ``` - flex-wrap: wrap 配合固定宽度的项目可以实现简单的网格效果 ```css /* 简易卡片网格 */ .card-list { display: flex; flex-wrap: wrap; gap: 16px; } .card { width: 300px; /* 固定宽度,放不下自动换行 */ } ``` - flex-flow 简写 - ```css /* 简写:flex-flow: <direction> <wrap> */ .container { flex-flow: row wrap; /* 水平排列 + 允许换行 */ flex-flow: column nowrap; /* 垂直排列 + 不换行 */ } /* 等价于分开写 */ .container { flex-direction: row; flex-wrap: wrap; } ``` - gap 间距 - gap 是控制 Flex 项目之间间距的最佳方式,比给每个项目加 margin 干净得多 - ```css .container { display: flex; gap: 16px; /* 行列间距都是 16px */ gap: 16px 24px; /* 行间距 16px,列间距 24px */ row-gap: 16px; /* 只设行间距 */ column-gap: 24px; /* 只设列间距 */ } ``` - gap 相比 margin 的优势 ```css /* ❌ 用 margin 的痛苦:最后一个元素多出右边距 */ .item { margin-right: 16px; } .item:last-child { margin-right: 0; } /* 还得手动去掉 */ /* ✅ 用 gap:自动只在项目之间加间距,边缘没有 */ .container { display: flex; gap: 16px; } ``` - Flexbox 交叉轴与项目属性 - align-items 交叉轴对齐(重点) - justify-content 管主轴,align-items 管交叉轴。当主轴是水平方向时,交叉轴就是垂直方向 - ```css .container { display: flex; height: 200px; /* 容器要有高度才能看出交叉轴效果 */ align-items: stretch; /* 默认:项目拉伸撑满容器高度 */ align-items: flex-start; /* 顶部对齐 */ align-items: flex-end; /* 底部对齐 */ align-items: center; /* 垂直居中 */ align-items: baseline; /* 按文字基线对齐 */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402112324221.png,389,563) - align-self 单个项目的对齐 - align-items 是容器属性,控制所有项目。align-self 写在项目上,让单个项目有不同的对齐方式 - ```css .container { display: flex; align-items: flex-start; /* 所有项目顶部对齐 */ height: 200px; } /* 只让第三个项目底部对齐 */ .item-c { align-self: flex-end; } /* 只让第二个项目居中 */ .item-b { align-self: center; } ``` - align-content 多行对齐 - align-content 只在 flex-wrap: wrap 产生多行时才有效果,它控制的是多行整体在交叉轴上的分布 - ```css .container { display: flex; flex-wrap: wrap; height: 400px; align-content: flex-start; /* 所有行挤在顶部 */ align-content: flex-end; /* 所有行挤在底部 */ align-content: center; /* 所有行居中 */ align-content: space-between; /* 首行贴顶末行贴底,中间等分 */ align-content: space-around; /* 每行上下等间距 */ align-content: stretch; /* 默认:行高拉伸填满容器 */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402224434719.png,633,199) - Flex 项目属性(核心) - 写在子元素上的三个属性,控制项目如何分配空间 - flex-basis 基础尺寸 - ```css .item { /* flex-basis 定义项目在分配多余空间之前的初始大小 */ flex-basis: auto; /* 默认:使用 width 或内容宽度 */ flex-basis: 200px; /* 固定初始宽度 200px */ flex-basis: 30%; /* 初始占 30% */ flex-basis: 0; /* 初始宽度为 0,完全由 flex-grow 决定 */ } ``` - flex-basis 和 width 的关系:当两者同时存在时,flex-basis 优先级更高(主轴为水平时) - 用了 Flexbox 后建议统一用 flex-basis 代替 width。 - flex-grow 放大比例 - 当容器有剩余空间时,flex-grow 决定每个项目分到多少: - ```css /* 默认 flex-grow: 0,不放大 */ /* 三个项目各自宽 100px,容器宽 600px,剩余 300px */ .item-a { flex-grow: 1; } /* 分到 300 × 1/3 = 100px → 总宽 200px */ .item-b { flex-grow: 1; } /* 分到 300 × 1/3 = 100px → 总宽 200px */ .item-c { flex-grow: 1; } /* 分到 300 × 1/3 = 100px → 总宽 200px */ ``` - 常见用法 ```css /* 让某个项目占满剩余空间 */ .sidebar { width: 250px; } /* 固定宽度 */ .main { flex-grow: 1; } /* 占满剩余 */ /* 搜索框:按钮固定宽,输入框自适应 */ .search-bar { display: flex; } .search-input { flex-grow: 1; } /* 输入框占满剩余空间 */ .search-btn { width: 80px; } /* 按钮固定宽 */ ``` - flex-shrink 缩小比例 - 当容器空间不足时,flex-shrink 决定每个项目被压缩多少 - ```css /* 默认 flex-shrink: 1,所有项目等比缩小 */ /* 不想被压缩(比如侧边栏固定宽度) */ .sidebar { flex-shrink: 0; /* 我不缩! */ width: 250px; } .main { flex-shrink: 1; /* 空间不够时我来缩 */ } ``` - flex 简写 ⭐ 必须掌握 - ```css /* flex: grow shrink basis */ flex: 0 1 auto; /* 默认值:不放大,可缩小,尺寸由内容决定 */ flex: 1; /* 等价于 flex: 1 1 0% → 等分剩余空间 */ flex: auto; /* 等价于 flex: 1 1 auto → 基于内容的弹性 */ flex: none; /* 等价于 flex: 0 0 auto → 完全不伸缩 */ flex: 0 0 200px; /* 固定 200px,不伸不缩 */ ``` - flex: 1 vs flex: auto 的区别 - ```css /* flex: 1 → flex: 1 1 0% */ /* basis 为 0,忽略内容宽度,纯按 grow 比例分配 */ /* flex: auto → flex: 1 1 auto */ /* basis 为 auto,先考虑内容宽度,再按 grow 比例分配剩余空间 */ ``` - 大多数情况用 flex: 1 就对了,想要项目完全等宽就用它。 - 经典布局模式 - ```css /* 模式 1:水平垂直居中(最经典的 Flex 用法) */ .center-box { display: flex; justify-content: center; align-items: center; } /* 模式 2:左右两端对齐 + 垂直居中(导航栏) */ .navbar { display: flex; justify-content: space-between; align-items: center; } /* 模式 3:固定侧边栏 + 自适应主内容 */ .layout { display: flex; } .sidebar { flex: 0 0 250px; } /* 固定 250px */ .main { flex: 1; } /* 占满剩余 */ /* 模式 4:底部固定 Footer(页面不够高时 Footer 也贴底) */ .page { display: flex; flex-direction: column; min-height: 100vh; } .page-header { /* 自然高度 */ } .page-main { flex: 1; } /* 撑满中间空间 */ .page-footer { /* 自然高度 */ } /* 模式 5:左图右文(或左文右图) */ .media { display: flex; align-items: flex-start; /* 顶部对齐,不拉伸 */ gap: 16px; } .media-image { flex: 0 0 120px; } /* 图片固定宽 */ .media-body { flex: 1; } /* 文字占满 */ ``` - Grid 基础与轨道定义 - Grid 容器与基本概念 - ```css .container { display: grid; /* 开启 Grid 布局 */ } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402225938244.png,349,399) - grid-template-columns 定义列 - ```css /* 固定宽度 */ .grid { display: grid; grid-template-columns: 200px 300px 200px; /* 三列:200 + 300 + 200 */ } /* 百分比 */ .grid { grid-template-columns: 25% 50% 25%; } /* fr 单位 —— 按比例分配剩余空间 ⭐ */ .grid { grid-template-columns: 1fr 2fr 1fr; /* 总共 4fr,第一列 1/4,第二列 2/4,第三列 1/4 */ } /* 混合使用 */ .grid { grid-template-columns: 250px 1fr 200px; /* 两侧固定,中间自适应 —— 比 Flex 更简洁! */ } ``` - `fr` 是 Grid 独有的单位,表示 fraction(份)。它会在固定尺寸分配完之后,按比例分配剩余空间 - grid-template-rows 定义行 - ```css .grid { display: grid; grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 80px 1fr 60px; /* 三行:头 80px + 中间自适应 + 底 60px */ } /* 行通常不需要显式定义,让内容撑开即可 */ .grid { grid-template-columns: 1fr 1fr 1fr; /* 不写 grid-template-rows,行高由内容决定 */ } ``` - repeat() 简化重复定义 - ```css /* 写 12 列等宽 */ /* ❌ 繁琐写法 */ grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; /* ✅ repeat 写法 */ grid-template-columns: repeat(12, 1fr); /* 重复模式 */ grid-template-columns: repeat(3, 1fr 2fr); /* 等价于:1fr 2fr 1fr 2fr 1fr 2fr(6 列) */ /* 和固定列混合 */ grid-template-columns: 200px repeat(3, 1fr) 200px; /* 等价于:200px 1fr 1fr 1fr 200px(5 列) */ ``` - minmax() 弹性范围 - minmax(最小值, 最大值) 让列宽在一个范围内弹性变化: - ```css .grid { grid-template-columns: minmax(200px, 300px) 1fr 1fr; /* 第一列:最小 200px,最大 300px */ /* 后两列等分剩余空间 */ } /* 行高最小 100px,内容多时自动撑高 */ .grid { grid-template-rows: minmax(100px, auto); } ``` - gap 行列间距 - 和 Flexbox 的 gap 一样,但 Grid 里可以分别控制行间距和列间距 - ```css .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; /* 行列间距都是 20px */ gap: 16px 24px; /* 行间距 16px,列间距 24px */ row-gap: 16px; /* 只设行间距 */ column-gap: 24px; /* 只设列间距 */ } ``` - 隐式网格与 grid-auto-rows - 你定义了 2 行 3 列 = 6 个格子,但如果有 9 个项目,多出来的 3 个怎么办? - Grid 会自动创建新行来容纳它们,这就是隐式网格: - ```css .grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: 150px 150px; /* 只定义了 2 行 */ /* 隐式创建的行默认高度由内容决定 */ /* 用 grid-auto-rows 控制隐式行的高度 */ grid-auto-rows: 150px; /* 隐式行也是 150px */ grid-auto-rows: minmax(100px, auto); /* 最少 100px,内容多就撑高 */ } ``` - Grid 项目放置与区域 - grid-column / grid-row 放置项目 - 用网格线编号来精确控制项目的位置和大小 - ```css .item { /* 完整写法 */ grid-column-start: 1; grid-column-end: 3; /* 从列线 1 到列线 3 → 跨 2 列 */ grid-row-start: 1; grid-row-end: 2; /* 从行线 1 到行线 2 → 占 1 行 */ /* 简写(推荐) */ grid-column: 1 / 3; /* 起始线 / 结束线 */ grid-row: 1 / 2; } ``` - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/04/02/20260402230753263.png,333,233) - span 关键字 - 用 span 表示跨几格 - ```css /* 以下三种写法效果相同 */ .item { grid-column: 1 / 3; } /* 从线 1 到线 3 */ .item { grid-column: 1 / span 2; } /* 从线 1 起,跨 2 列 */ .item { grid-column: span 2; } /* 从自然位置起,跨 2 列 */ ``` - span 在实际开发中更常用,因为你不需要关心具体的线编号 - ```css /* 图片画廊:让某张特色图跨 2 列 2 行 */ .photo-featured { grid-column: span 2; grid-row: span 2; } /* 跨满整行 */ .full-width { grid-column: 1 / -1; /* 从第一条线到最后一条线 */ } ``` - 实现各种跨列跨行布局 - ```css .grid { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 120px; gap: 12px; } /* 横幅:跨满一整行 */ .banner { grid-column: 1 / -1; /* 第 1 条线到最后一条线 */ } /* 大卡片:跨 2 列 2 行 */ .large-card { grid-column: span 2; grid-row: span 2; } /* 侧边栏:固定在右侧,跨 3 行 */ .sidebar { grid-column: 4; /* 只写一个值 = 起始线,占 1 列 */ grid-row: 1 / span 3; /* 从第 1 行起跨 3 行 */ } /* 自由放置到任意位置 */ .special { grid-column: 2 / 4; /* 第 2~3 列 */ grid-row: 2 / 4; /* 第 2~3 行 */ } /* ┌──────────────────────────────┐ │ banner (跨 4 列) │ ├───────────┬────────┬─────────┤ │ │ C │ │ │ large ├────────┤ sidebar │ │ (2×2) │ D │ (1×3) │ ├─────┬─────┼────────┤ │ │ E │ F │ G │ │ └─────┴─────┴────────┴─────────┘ */ ``` - grid-template-areas 命名区域 ⭐ 重点 - 这是 Grid 最直观的布局方式——用 ASCII 字符画的方式"画"出你的布局: - ```css .page { display: grid; grid-template-columns: 220px 1fr; grid-template-rows: 60px 1fr 50px; grid-template-areas: "header header" "sidebar main" "footer footer"; min-height: 100vh; } /* 每个子元素用 grid-area 对号入座 */ .page-header { grid-area: header; } .page-sidebar { grid-area: sidebar; } .page-main { grid-area: main; } .page-footer { grid-area: footer; } ``` - 规则 ```css grid-template-areas: "header header header" /* 同名区域必须是矩形 */ "sidebar main main" /* 每行的区域数 = 列数 */ "sidebar footer footer"; /* 用 . 表示空白格 */ /* ❌ 非矩形会报错 */ grid-template-areas: "header sidebar" "main main" "footer sidebar"; /* sidebar 呈 L 形,不合法! */ /* ✅ 用 . 留空 */ grid-template-areas: "header header header" "sidebar main ." /* 右下角留空 */ "footer footer footer"; ``` - Grid 中的对齐 - 容器级对齐(控制所有项目) - ```css .grid { display: grid; grid-template-columns: repeat(3, 100px); /* 网格总宽 300px,可能比容器窄 */ height: 400px; /* justify-items:项目在单元格内的水平对齐 */ justify-items: stretch; /* 默认:撑满单元格宽度 */ justify-items: start; /* 靠左 */ justify-items: end; /* 靠右 */ justify-items: center; /* 水平居中 */ /* align-items:项目在单元格内的垂直对齐 */ align-items: stretch; /* 默认:撑满单元格高度 */ align-items: start; /* 靠上 */ align-items: end; /* 靠下 */ align-items: center; /* 垂直居中 */ /* place-items:简写(垂直 水平) */ place-items: center; /* 水平垂直都居中 */ place-items: start end; /* 垂直靠上,水平靠右 */ } /* start + start center + center end + end ┌────────────┐ ┌────────────┐ ┌────────────┐ │[item] │ │ │ │ │ │ │ │ [item] │ │ │ │ │ │ │ │ [item]│ └────────────┘ └────────────┘ └────────────┘ */ ``` - 整个网格在容器内的对齐 - 当网格的总尺寸小于容器时,可以控制整个网格的位置: - ```css .grid { display: grid; grid-template-columns: repeat(3, 100px); /* 总宽 300px */ width: 600px; /* 容器宽 600px,有 300px 空余 */ /* justify-content:整个网格水平方向的对齐 */ justify-content: center; /* 网格整体水平居中 */ justify-content: space-between; /* 列之间等分空间 */ /* align-content:整个网格垂直方向的对齐 */ align-content: center; /* place-content 简写 */ place-content: center; } ``` - 单个项目对齐 - ```css /* 某个项目单独设置 */ .special-item { justify-self: center; /* 这个项目在格子里水平居中 */ align-self: end; /* 这个项目在格子里垂直靠底 */ /* 简写 */ place-self: end center; } ``` - Grid 自适应与 Flex vs Grid 选型 - auto-fill 与 auto-fit - auto-fill:能塞多少列就塞多少列 - ```css .grid { display: grid; grid-template-columns: repeat(auto-fill, 200px); gap: 16px; } ``` - auto-fit:同上,但空轨道会被折叠 - ```css .grid { display: grid; grid-template-columns: repeat(auto-fit, 200px); gap: 16px; } ``` - auto-fit + minmax 万能自适应 - ```css .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; } ``` - 这一行做了什么:每列最小 280px,有多余空间时等分放大到 1fr。容器变宽就自动多放一列,变窄就自动减少,每列永远在 280px ~ 等分之间弹性变化。 - auto-fill vs auto-fit + minmax 的区别 - 当项目数量少于能放下的列数时,差别才显现 - ```css /* 容器 1200px,3 个项目 */ /* auto-fill:空轨道保留,项目不会撑到 1fr */ repeat(auto-fill, minmax(280px, 1fr)) | [280px] [280px] [280px] [空280px] | 项目大小固定在 280px,因为还有空轨道分 fr /* auto-fit:空轨道折叠,项目撑满 */ repeat(auto-fit, minmax(280px, 1fr)) | [ 400px ] [ 400px ] [ 400px ] | 空轨道折叠了,3 个项目等分 1200px ``` 响应式与进阶 - 响应式与进阶 - 响应式基础与媒体查询 - viewport meta 标签 - ```html <meta name="viewport" content="width=device-width, initial-scale=1.0"> ``` - 手机浏览器会把页面当做 980px 宽的桌面页面来渲染,然后整体缩小 - 媒体查询 @media 基础用法 - ```css /* 基础语法 */ @media (条件) { /* 条件满足时才生效的样式 */ } /* 最大宽度:屏幕 ≤ 768px 时生效 */ @media (max-width: 768px) { .sidebar { display: none; } } /* 最小宽度:屏幕 ≥ 1024px 时生效 */ @media (min-width: 1024px) { .container { max-width: 1200px; } } /* 范围:屏幕在 768px ~ 1024px 之间 */ @media (min-width: 768px) and (max-width: 1024px) { .card-grid { grid-template-columns: repeat(2, 1fr); } } ``` - 现代范围语法 ```css /* 新语法:用比较运算符,更易读 */ @media (width <= 768px) { .sidebar { display: none; } } @media (width >= 1024px) { .container { max-width: 1200px; } } @media (768px <= width <= 1024px) { .card-grid { grid-template-columns: repeat(2, 1fr); } } ``` - 移动优先 vs 桌面优先 - 桌面优先 - ```css /* 默认样式 = 桌面 */ .grid { grid-template-columns: repeat(3, 1fr); } /* 平板:≤ 1024px */ @media (max-width: 1024px) { .grid { grid-template-columns: repeat(2, 1fr); } } /* 手机:≤ 768px */ @media (max-width: 768px) { .grid { grid-template-columns: 1fr; } } ``` - 移动优先 - ```css /* 默认样式 = 手机 */ .grid { grid-template-columns: 1fr; } /* 平板:≥ 768px */ @media (min-width: 768px) { .grid { grid-template-columns: repeat(2, 1fr); } } /* 桌面:≥ 1024px */ @media (min-width: 768px) { .grid { grid-template-columns: repeat(3, 1fr); } } ``` - 断点设计 - 断点就是布局发生变化的屏幕宽度 - 需要按布局需要记 - ```css /* 推荐的断点体系 */ /* 小手机 */ /* 默认样式覆盖,不需要媒体查询 */ /* 大手机 / 小平板 */ @media (min-width: 640px) { } /* 平板 */ @media (min-width: 768px) { } /* 小桌面 / 大平板横屏 */ @media (min-width: 1024px) { } /* 桌面 */ @media (min-width: 1280px) { } /* 大桌面 */ @media (min-width: 1536px) { } ``` - ```css /* 大多数项目只需要这三个 */ /* 手机:默认 */ /* 平板:768px */ /* 桌面:1024px */ ``` - 媒体查询的组织方式 - 集中在底部 ```css /* 所有默认样式 */ .header { ... } .hero { ... } .card { ... } /* 所有平板适配 */ @media (min-width: 768px) { .header { ... } .hero { ... } .card { ... } } /* 所有桌面适配 */ @media (min-width: 1024px) { .header { ... } .hero { ... } .card { ... } } ``` - 跟在组件后面(按组件分组) ```css /* 一个组件的所有状态都在一起,好维护。这也是大多数 CSS 框架和组件库的做法。*/ /* Header */ .header { ... } @media (min-width: 768px) { .header { ... } } @media (min-width: 1024px) { .header { ... } } /* Hero */ .hero { ... } @media (min-width: 768px) { .hero { ... } } @media (min-width: 1024px) { .hero { ... } } /* Card */ .card { ... } @media (min-width: 768px) { .card { ... } } ``` - 常见响应式模式 - 导航栏折叠 ```css /* 移动端:隐藏链接,显示菜单按钮 */ .nav { display: none; } .menu-toggle { display: block; } /* 桌面端:显示链接,隐藏按钮 */ @media (min-width: 768px) { .nav { display: flex; } .menu-toggle { display: none; } } ``` - 侧边栏变底部 ```css /* 移动端:单列,侧边栏在下面 */ .layout { display: grid; grid-template-columns: 1fr; } /* 桌面端:双列 */ @media (min-width: 1024px) { .layout { grid-template-columns: 1fr 280px; } } ``` - Grid areas 重排 ```css /* 移动端布局 */ .page { display: grid; grid-template-areas: "header" "main" "sidebar" "footer"; grid-template-columns: 1fr; } /* 桌面端布局 */ @media (min-width: 1024px) { .page { grid-template-areas: "header header" "sidebar main" "footer footer"; grid-template-columns: 240px 1fr; } } ``` - 表格在移动端变卡片 ```css /* 移动端:隐藏表头,每行变成卡片 */ @media (max-width: 768px) { table thead { display: none; } table tr { display: block; margin-bottom: 16px; border: 1px solid #eee; border-radius: 8px; padding: 12px; } table td { display: flex; justify-content: space-between; padding: 6px 0; } /* 用 data 属性模拟表头 */ table td::before { content: attr(data-label); font-weight: 700; color: #333; } } ``` - 间距随屏幕缩放 ```css /* 用 CSS 变量集中管理间距 */ :root { --page-padding: 16px; --section-gap: 24px; } @media (min-width: 768px) { :root { --page-padding: 32px; --section-gap: 40px; } } @media (min-width: 1024px) { :root { --page-padding: 48px; --section-gap: 56px; } } /* 全局使用变量,不用每个组件单独写媒体查询 */ .header { padding: 0 var(--page-padding); } .hero { padding: var(--section-gap) var(--page-padding); } .content { padding: var(--section-gap) var(--page-padding); } ``` - 响应式进阶内容 - 图片是响应式布局中最容易出问题的元素——要么溢出容器,要么被拉伸变形。 - max-width 防溢出 - ```css /* 所有项目都应该全局加上这条 */ img { max-width: 100%; /* 永远不超过容器宽度 */ height: auto; /* 保持原始比例 */ display: block; /* 去掉图片底部的空隙 */ } ``` - object-fit:控制图片填充方式 - 当图片容器有固定的宽高比时,object-fit 控制图片如何适应 - ```css /* 原图 800×400,容器 300×300 */ .img-cover { width: 300px; height: 300px; object-fit: cover; /* 填满容器,多余裁掉(保持比例)✅ 最常用 */ } .img-contain { width: 300px; height: 300px; object-fit: contain; /* 完整显示,可能留白(保持比例) */ } .img-fill { width: 300px; height: 300px; object-fit: fill; /* 拉伸填满(变形)❌ 默认值 */ } .img-none { width: 300px; height: 300px; object-fit: none; /* 不缩放,只显示原图中心部分 */ } ``` - 配合 object-position 控制裁切焦点: ```css .avatar { width: 100px; height: 100px; border-radius: 50%; object-fit: cover; object-position: center top; /* 头像聚焦在上半部分 */ } /* 常用值 */ object-position: center; /* 默认:居中裁切 */ object-position: top; /* 从顶部裁切 */ object-position: left top; /* 从左上角裁切 */ object-position: 20% 30%; /* 自定义焦点 */ ``` - aspect-ratio:固定宽高比 ```css /* 不用 padding hack 了,直接声明宽高比 */ .card-cover { width: 100%; aspect-ratio: 16 / 9; /* 16:9 视频比例 */ object-fit: cover; } .avatar { width: 80px; aspect-ratio: 1; /* 正方形 = 1/1 */ border-radius: 50%; object-fit: cover; } .photo-portrait { aspect-ratio: 3 / 4; /* 竖版照片 */ } /* 配合 Grid 让所有卡片封面等高 */ .article-grid .card-cover { aspect-ratio: 16 / 10; object-fit: cover; } ``` - 响应式字体 - vw 单位:随视口缩放 - ```css /* vw = viewport width 的百分比 */ /* 1vw = 视口宽度的 1% */ h1 { font-size: 5vw; /* 手机 375px → 18.75px */ /* 平板 768px → 38.4px */ /* 桌面 1440px → 72px */ } ``` - 问题很明显:手机上太小,桌面上太大,没有上下限。 - clamp() 有上下限的响应式值 - clamp(最小值, 首选值, 最大值) 完美解决了这个问题 - ```css h1 { font-size: clamp(1.75rem, 4vw, 3rem); /* 最小 1.75rem (28px) 首选 4vw(随视口变化) 最大 3rem (48px) 三者取中间值 */ } ``` - 常用 clamp 配方 ```css /* 标题 */ h1 { font-size: clamp(1.75rem, 4vw, 3rem); } h2 { font-size: clamp(1.25rem, 3vw, 2rem); } h3 { font-size: clamp(1.1rem, 2.5vw, 1.5rem); } /* 正文 */ body { font-size: clamp(0.875rem, 1.5vw, 1.125rem); } /* 间距也可以用 clamp */ .section { padding: clamp(24px, 5vw, 64px) clamp(16px, 4vw, 48px); } /* 容器最大宽度 */ .container { width: clamp(320px, 90vw, 1200px); margin: 0 auto; } ``` - 用了 clamp 后,很多字号和间距就不需要写媒体查询了——它自动在范围内平滑缩放。 - 其他响应式单位 - ```css /* vh = viewport height */ .hero { min-height: 100vh; } /* 占满整个视口高度 */ /* svh / dvh = 小/动态视口高度(解决手机地址栏问题) */ .hero { min-height: 100dvh; } /* 推荐用 dvh */ /* vmin / vmax = 视口较小/较大边 */ .square { width: 50vmin; height: 50vmin; } /* 始终是正方形 */ ``` - 手机浏览器的地址栏会收起/展开,导致 100vh 的高度有时候会偏大。dvh(dynamic viewport height)会自动适应地址栏的变化 - ```css .full-screen { min-height: 100vh; /* 兜底 */ min-height: 100dvh; /* 现代浏览器用这个 */ } ``` - 容器查询 - 媒体查询根据视口宽度变化,但一个组件可能在不同位置出现 - 主内容区很宽,侧边栏很窄 - 容器查询根据父容器的宽度变化,让组件真正做到"放在哪里就适配哪里" - ```css /* 第一步:声明容器 */ .card-wrapper { container-type: inline-size; /* 启用容器查询 */ container-name: card; /* 可选:给容器命名 */ } /* 第二步:根据容器宽度写样式 */ @container card (min-width: 400px) { .card { display: flex; /* 容器宽时横排 */ gap: 16px; } .card-cover { flex: 0 0 200px; } } @container card (max-width: 399px) { .card { display: block; /* 容器窄时竖排 */ } .card-cover { width: 100%; aspect-ratio: 16 / 9; } } ``` - 容器查询单位 ```css .card-wrapper { container-type: inline-size; } .card-title { font-size: clamp(14px, 3cqi, 20px); /* cqi = 容器 inline-size 的 1% */ /* 类似于 vw,但基于容器宽度而不是视口 */ } ``` - 常用容器单位:cqi(容器宽度%)、cqb(容器高度%)、cqmin(较小边%)。 - 不需要媒体查询的响应式技巧 - 很多响应式效果可以用纯 CSS 实现,不写一行 @media - ```css /* 技巧 1:Grid auto-fit + minmax(Day 13 学过) */ .grid { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); /* 自动调整列数,完全不需要媒体查询 */ } /* 技巧 2:clamp 响应式字号 */ h1 { font-size: clamp(1.5rem, 4vw, 3rem); } /* 技巧 3:clamp 响应式间距 */ .section { padding: clamp(24px, 5vw, 64px); } /* 技巧 4:flex-wrap 自动换行 */ .button-group { display: flex; flex-wrap: wrap; gap: 12px; } /* 技巧 5:min() / max() 控制宽度 */ .container { width: min(90vw, 1200px); /* 取小值:窄屏时 90vw,宽屏时 1200px */ margin: 0 auto; } .card { width: max(280px, 30%); /* 取大值:至少 280px */ } /* 技巧 6:auto-fit 让不够时换行 */ .stats { display: flex; flex-wrap: wrap; gap: 16px; } .stat-item { flex: 1 1 150px; /* 至少 150px,不够就换行 */ } ``` - translation 过渡动画 - translation 基础语法 - ```css /* 没有 transition:悬停时背景色瞬间变化 */ .btn { background: #2563eb; } .btn:hover { background: #1d4ed8; } /* 加上 transition:背景色用 0.3 秒平滑过渡 */ .btn { background: #2563eb; transition: background 0.3s; } .btn:hover { background: #1d4ed8; } ``` - 完整语法 ```css transition: 属性 时长 缓动函数 延迟; /* 示例 */ transition: background 0.3s ease 0s; transition: transform 0.2s ease-in-out; transition: opacity 0.5s linear 0.1s; ``` - 分开写的写法 ```css .box { transition-property: background; /* 哪个属性要过渡 */ transition-duration: 0.3s; /* 过渡多长时间 */ transition-timing-function: ease; /* 缓动曲线 */ transition-delay: 0s; /* 延迟多久开始 */ } ``` - transition-property 过渡哪些属性 - ```css /* 指定单个属性 */ transition: background 0.3s; /* 指定多个属性 */ transition: background 0.3s, color 0.3s, transform 0.2s; /* 所有可过渡属性一起 */ transition: all 0.3s; ``` - all 方便但有两个问题 ```css /* ❌ all 的隐患 */ .card { transition: all 0.3s; /* 问题 1:可能过渡你不想过渡的属性(比如 width、height 导致布局抖动)*/ /* 问题 2:性能比指定属性差 */ } /* ✅ 推荐:明确列出要过渡的属性 */ .card { transition: transform 0.3s, box-shadow 0.3s; } ``` - 哪些属性可以过渡 ```css /* 能过渡的属性必须有中间值(浏览器知道怎么从 A 插值到 B)*/ /* ✅ 可以过渡 */ color, background-color /* 颜色有中间值 */ opacity /* 0 到 1 之间连续 */ transform /* 位移、缩放、旋转都有中间值 */ width, height, padding, margin /* 数值类都可以 */ border-color, border-radius box-shadow font-size, letter-spacing, line-height /* ❌ 不能过渡 */ display /* none → block 没有中间状态 */ font-family /* 字体之间没法渐变 */ background-image /* 图片之间不能插值 */ position /* static → absolute 没有中间值 */ ``` - display: none 不能过渡是一个经典痛点。要做显隐动画,用 opacity + visibility 代替 ```css /* 用 opacity + visibility 替代 display 的显隐过渡 */ .dropdown { opacity: 0; visibility: hidden; transform: translateY(-8px); transition: opacity 0.2s, visibility 0.2s, transform 0.2s; } .trigger:hover .dropdown { opacity: 1; visibility: visible; transform: translateY(0); } ``` - transition-duration 时长 - ```css transition-duration: 0.3s; /* 300 毫秒 */ transition-duration: 300ms; /* 同上,用 ms 写法 */ transition-duration: 0.15s; /* 150 毫秒,很快 */ transition-duration: 0.5s; /* 500 毫秒,中速 */ transition-duration: 1s; /* 1 秒,比较慢 */ ``` - 不同场景的推荐时长 ```css /* 微交互(按钮、链接、图标)→ 快速 */ .btn { transition: background 0.15s, transform 0.15s; } /* 悬停效果(卡片、图片)→ 中速 */ .card { transition: transform 0.3s, box-shadow 0.3s; } /* 展开/收起(下拉菜单、折叠面板)→ 稍慢 */ .panel { transition: max-height 0.35s, opacity 0.35s; } /* 页面级动画(模态框、页面切换)→ 慢 */ .modal { transition: opacity 0.4s, transform 0.4s; } ``` - 越小的元素过渡越快,越大的元素过渡可以稍慢。超过 0.5s 用户会觉得卡顿。 - transition-timing-function 缓动函数(重点) - 缓动函数决定过渡的"节奏感"——匀速、先快后慢、先慢后快 - ```css /* 预设曲线 */ transition-timing-function: linear; /* 匀速 */ transition-timing-function: ease; /* 默认:慢→快→慢,最常用 */ transition-timing-function: ease-in; /* 慢→快(加速) */ transition-timing-function: ease-out; /* 快→慢(减速)*/ transition-timing-function: ease-in-out; /* 慢→快→慢,比 ease 对称 */ ``` - 适合的场景 ```css /* ease-out(减速)→ 元素进入画面 */ .card-enter { transition: transform 0.3s ease-out; } /* ease-in(加速)→ 元素离开画面 */ .card-leave { transition: transform 0.3s ease-in; } /* ease-in-out → 来回移动、展开收起 */ .accordion { transition: max-height 0.35s ease-in-out; } /* linear → 颜色渐变、进度条 */ .progress-bar { transition: width 0.5s linear; } ``` - cubic-bezier 自定义曲线 - 所有预设曲线都是 cubic-bezier 的简写 ```css /* 预设值的真身 */ ease: cubic-bezier(0.25, 0.1, 0.25, 1.0) ease-in: cubic-bezier(0.42, 0, 1.0, 1.0) ease-out: cubic-bezier(0, 0, 0.58, 1.0) ease-in-out: cubic-bezier(0.42, 0, 0.58, 1.0) /* 自定义:弹性回弹效果 */ .bounce { transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); /* y 值超过 1 就会产生"过冲"回弹效果 */ } ``` - 推荐网站 cubic-bezier.com 可以预览和对比各种曲线 - transition-delay 延迟 - ```css /* 鼠标悬停 0.1s 后才开始变化 */ .tooltip { transition: opacity 0.3s ease 0.1s; } /* 多个属性不同延迟 → 顺序动画 */ .card:hover .card-title { transform: translateY(0); transition: transform 0.3s ease 0s; /* 标题先动 */ } .card:hover .card-desc { transform: translateY(0); transition: transform 0.3s ease 0.1s; /* 描述延迟 0.1s */ } .card:hover .card-btn { transform: translateY(0); transition: transform 0.3s ease 0.2s; /* 按钮延迟 0.2s */ } ``` - transform 变化 - 2D 变化基础 - transform 不会影响文档流——元素在页面中的原始位置仍然保留,只是视觉上发生了变化 - translate 位移 - ```css /* 水平和垂直移动 */ transform: translateX(50px); /* 右移 50px */ transform: translateY(-20px); /* 上移 20px */ transform: translate(50px, -20px); /* 合写:右移 50 + 上移 20 */ /* 百分比相对于元素自身尺寸 */ transform: translateX(100%); /* 右移自身宽度的距离 */ transform: translateY(-50%); /* 上移自身高度的一半 */ ``` - translate 最经典的用途——绝对定位居中 ```css .centered { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); /* top/left 把左上角移到容器中心 */ /* translate 再把元素自身偏移回去 */ } ``` - scale 缩放 - ```css transform: scale(1.5); /* 等比放大 1.5 倍 */ transform: scale(0.8); /* 等比缩小到 80% */ transform: scale(1); /* 原始大小 */ transform: scale(0); /* 缩小到消失 */ transform: scaleX(1.5); /* 只水平拉宽 */ transform: scaleY(0.5); /* 只垂直压扁 */ transform: scale(1.5, 0.8); /* 水平 1.5 倍,垂直 0.8 倍 */ ``` - 常见用法 ```css /* 按钮点击缩小反馈 */ .btn:active { transform: scale(0.96); } /* 图片悬停放大 */ .img-wrapper { overflow: hidden; } .img-wrapper img { transition: transform 0.4s ease; } .img-wrapper:hover img { transform: scale(1.1); } /* 从 0 到 1 的弹出效果 */ .popup { transform: scale(0); transition: transform 0.3s ease; } .popup.active { transform: scale(1); } ``` - rotate 旋转 - ```css transform: rotate(45deg); /* 顺时针旋转 45 度 */ transform: rotate(-90deg); /* 逆时针旋转 90 度 */ transform: rotate(180deg); /* 翻转 */ transform: rotate(360deg); /* 转一圈(用于 loading 动画) */ transform: rotate(0.5turn); /* 半圈 = 180deg */ ``` - ```css transform: rotate(45deg); /* 顺时针旋转 45 度 */ transform: rotate(-90deg); /* 逆时针旋转 90 度 */ transform: rotate(180deg); /* 翻转 */ transform: rotate(360deg); /* 转一圈(用于 loading 动画) */ transform: rotate(0.5turn); /* 半圈 = 180deg */ ``` - ```css transform: rotate(45deg); /* 顺时针旋转 45 度 */ transform: rotate(-90deg); /* 逆时针旋转 90 度 */ transform: rotate(180deg); /* 翻转 */ transform: rotate(360deg); /* 转一圈(用于 loading 动画) */ transform: rotate(0.5turn); /* 半圈 = 180deg */ ``` - ```css /* 箭头图标旋转表示展开/收起 */ .arrow { display: inline-block; transition: transform 0.3s ease; } .expanded .arrow { transform: rotate(180deg); } /* 悬停微旋转 */ .icon:hover { transform: rotate(15deg); } ``` - skew 倾斜 - ```css transform: skewX(10deg); /* 水平方向倾斜 */ transform: skewY(5deg); /* 垂直方向倾斜 */ transform: skew(10deg, 5deg); /* 合写 */ ``` - ```css /* 平行四边形按钮 */ .skew-btn { transform: skewX(-10deg); } /* 文字反向 skew 保持正常 */ .skew-btn span { display: inline-block; transform: skewX(10deg); } /* 斜切背景装饰 */ .section-divider { height: 80px; background: hsl(220, 85%, 55%); transform: skewY(-3deg); } ``` - 组合变化 ```css /* 先旋转 45 度,再右移 100px */ transform: translateX(100px) rotate(45deg); /* 实际执行顺序:先 rotate → 再 translate */ /* 注意:旋转后坐标轴也跟着转了,所以 translate 方向会变 */ /* 先移动再旋转 vs 先旋转再移动,效果完全不同! */ ``` - ```css transform: translateX(100px) rotate(45deg); 原始 → 先旋转 45° → 再沿旋转后的 X 轴移动 100px 结果:元素在右上方 transform: rotate(45deg) translateX(100px); 原始 → 先沿原始 X 轴移动 100px → 再旋转 45° 结果:元素在正右方但旋转了 ``` - 先写 translate,再写其他变化 ```css /* ✅ 推荐顺序:translate → scale → rotate */ .card:hover { transform: translateY(-8px) scale(1.02) rotate(1deg); } ``` - translate-origin 变换原点 - 默认情况下所有变换都以元素中心为原点。transform-origin 可以改变这个原点: - ```css /* 默认:中心 */ transform-origin: center; /* 50% 50% */ /* 关键字 */ transform-origin: top left; /* 左上角 */ transform-origin: bottom right; /* 右下角 */ transform-origin: center top; /* 顶部中间 */ /* 百分比 */ transform-origin: 0% 0%; /* 左上角 */ transform-origin: 100% 100%; /* 右下角 */ /* 像素值 */ transform-origin: 20px 30px; ``` - 改变原点最直观的效果在旋转和缩放上: - ```css /* 旋转:绕中心转 vs 绕左上角转 */ .spin-center { transform-origin: center; transform: rotate(45deg); } .spin-corner { transform-origin: top left; transform: rotate(45deg); } /* spin-center: spin-corner: ╱╲ ┌╲ ╱ ╲ │ ╲ ╱ ◉ ╲ ←原点在中心 │ ╲ ← 原点在左上角 ╲ ╱ │ ╱ ╲╱ │╱ */ ``` - 实用场景 ```css /* 下拉菜单从顶部展开 */ .dropdown { transform-origin: top center; transform: scaleY(0); /* 从顶部收起 */ transition: transform 0.2s ease; } .trigger:hover .dropdown { transform: scaleY(1); /* 从顶部展开 */ } /* 导航下划线从左侧展开 */ .nav-link::after { transform-origin: left; transform: scaleX(0); transition: transform 0.3s ease; } .nav-link:hover::after { transform: scaleX(1); } /* 图片从左上角放大 */ .zoom-corner { transform-origin: top left; } .zoom-corner:hover { transform: scale(1.5); } ``` - 3D 变化 - ```css /* 给父元素设透视 */ .scene { perspective: 800px; /* 观察者距离屏幕 800px */ /* 值越小 → 透视感越强(像凑近看)*/ /* 值越大 → 透视感越弱(像远处看)*/ } ``` - @keyframes 关键帧动画 - 基础语法 - 动画分两步:先用 @keyframes 定义动画,再用 animation 应用到元素上 - ```css /* 第一步:定义关键帧 */ @keyframes fadeIn { from { opacity: 0; /* 起始状态 */ } to { opacity: 1; /* 结束状态 */ } } /* 第二步:应用动画 */ .element { animation: fadeIn 0.5s ease; } ``` - 用百分比定义关键帧 - ```css @keyframes bounce { 0% { transform: translateY(0); } 30% { transform: translateY(-30px); } 50% { transform: translateY(0); } 70% { transform: translateY(-15px); } 100% { transform: translateY(0); } } .ball { animation: bounce 1s ease; } ``` - animation 属性详解 - animation 的完整简写 - ```css animation: 名称 时长 缓动 延迟 次数 方向 填充 播放状态; /* 示例 */ animation: fadeIn 0.5s ease 0s 1 normal forwards running; ``` - animation-name 动画名称 - ```css animation-name: fadeIn; /* 对应 @keyframes fadeIn */ animation-name: none; /* 不应用任何动画 */ ``` - animation-duration 持续时长 - ```css animation-duration: 0.5s; animation-duration: 300ms; animation-duration: 2s; ``` - animation-timing-function 缓动 - ```css animation-timing-function: ease; animation-timing-function: linear; animation-timing-function: ease-in-out; animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* steps() —— 逐帧动画,不做插值 */ animation-timing-function: steps(4); /* 分 4 步跳变 */ animation-timing-function: steps(1, end); /* 打字机效果 */ ``` - ```css /* 精灵图逐帧动画 */ .sprite { width: 64px; height: 64px; background: url('sprite.png'); animation: walk 0.6s steps(8) infinite; } @keyframes walk { to { background-position: -512px 0; } /* 8 帧 × 64px */ } /* 打字机效果 */ .typing { width: 0; overflow: hidden; white-space: nowrap; border-right: 2px solid; animation: typing 2s steps(15) forwards, /* 15 个字逐个显示 */ blink 0.8s step-end infinite; /* 光标闪烁 */ } @keyframes typing { to { width: 15ch; } /* ch = 一个字符的宽度 */ } @keyframes blink { 50% { border-color: transparent; } } ``` - animation-delay 延迟 - ```css animation-delay: 0.3s; /* 等 0.3 秒后开始 */ animation-delay: -0.5s; /* 负值:动画从 0.5s 处开始播放(跳过开头) */ ``` - ```css /* loading 动画:3 个点交错起步 */ .dot:nth-child(1) { animation-delay: 0s; } .dot:nth-child(2) { animation-delay: 0.15s; } .dot:nth-child(3) { animation-delay: 0.3s; } ``` - animation-iteration-count 播放次数 - ```css animation-iteration-count: 1; /* 默认:播放 1 次 */ animation-iteration-count: 3; /* 播放 3 次 */ animation-iteration-count: infinite; /* 无限循环 */ ``` - animation-direction 方向 - ```css animation-direction: normal; /* 默认:0% → 100% */ animation-direction: reverse; /* 反向:100% → 0% */ animation-direction: alternate; /* 交替:去 → 回 → 去 → 回 */ animation-direction: alternate-reverse; /* 反向交替:回 → 去 → 回 → 去 */ ``` - animation-fill-mode 填充模式 (重要) - ```css animation-fill-mode: none; /* 默认:动画结束后回到初始状态 */ animation-fill-mode: forwards; /* 保持在最后一帧 */ animation-fill-mode: backwards; /* 延迟期间就应用第一帧 */ animation-fill-mode: both; /* forwards + backwards */ ``` - ```css @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } none: 延迟中透明度=1 → 动画0→1 → 结束后回到1(跳了一下再回来) forwards: 延迟中透明度=1 → 动画0→1 → 结束后保持1 ✅ backwards: 延迟中透明度=0 → 动画0→1 → 结束后回到1 both: 延迟中透明度=0 → 动画0→1 → 结束后保持1 ✅✅ 最完整 ``` - animation-play-state 播放状态 - ```css animation-play-state: running; /* 默认:播放中 */ animation-play-state: paused; /* 暂停 */ /* 悬停暂停动画 */ .marquee:hover { animation-play-state: paused; } ``` - animation 简写 - 记住简写的顺序规则:第一个时间值是 duration,第二个是 delay - ```css /* animation: name duration timing delay count direction fill state */ animation: fadeIn 0.5s ease 0s 1 normal forwards running; /* 常用的简写 */ animation: fadeIn 0.5s ease forwards; /* 渐入并保持 */ animation: spin 1s linear infinite; /* 无限匀速旋转 */ animation: bounce 0.6s ease-in-out 3; /* 弹跳 3 次 */ animation: float 3s ease-in-out infinite alternate; /* 无限来回浮动 */ ``` - 多动画同时应用 - ```css .element { animation: fadeIn 0.5s ease forwards, slideUp 0.5s ease forwards; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { transform: translateY(30px); } to { transform: translateY(0); } } ``` 工程化与实战 - 标题

三月 30, 2026