上午培训内容:react相关知识点

React 简介与环境搭建

  • 核心特点:介绍了 React 的组件化、响应式数据更新及单向数据流特性。
  • 脚手架搭建:演示了使用 Vite 创建 React + TypeScript 项目的全过程,包括依赖安装与目录结构解析。 TypeScript类型检查,当类型报错时,代码实际上仍然能通过编译。

模块化与组件基础

  • 导入导出:讲解了 ES Module 的默认导出与具名导出的区别及使用规范。
  • JSX 语法:介绍了 JSX 的基本用法,包括表达式嵌入、条件渲染、循环渲染以及与 HTML 的差异(如 className、事件命名)。

组件通信与状态管理

  • Props 传递:演示了父组件向子组件传递数据,以及默认值处理和类型定义。
  • Hooks 使用:重点讲解了 useState 钩子函数, use开头的即为钩子函数,类如useState、useEffect等,说明其如何实现响应式状态更新及避免/闭包陷阱/的正确写法。

知识点:React State 的闭包陷阱(Stale Closure Trap)是 React Hooks 中最常见、也最容易被误解的问题之一。它的核心本质是:在异步操作或回调函数中,捕获了旧渲染周期的状态值(State),导致获取到的始终是“过期”的数据

要彻底理解并解决这个问题,我们需要从原理、典型场景和解决方案三个维度来剖析。

一、 为什么会发生闭包陷阱?(核心原理)

要理解闭包陷阱,首先要明白 React 函数组件的渲染机制:

  1. 渲染即快照:React 的函数组件并不是一个长期存在的实体,而是 React 反复调用的函数。每次组件渲染,都会产生一个包含当前 State 和 Props 的不可变快照
  2. 闭包捕获旧值:JavaScript 的闭包会“记住”它被创建时的词法环境。当你在 useEffect 或事件处理函数中创建定时器、异步请求时,这些回调函数会捕获创建那一刻的 State 值。
  3. 陷阱产生:如果后续 State 更新了,组件触发了重新渲染,但那个异步回调函数依然持有旧渲染周期的闭包。它完全不知道 State 已经发生变化,从而表现出“状态没有更新”的错觉。

二、 典型的闭包陷阱场景

最经典的场景是在 useEffect 中使用定时器或异步操作,且依赖数组为空 []

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      // ❌ 闭包陷阱:这里的 count 永远是 0
      console.log('Current count:', count); 
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖数组,导致 effect 只在首次渲染时执行

  return <button onClick={() => setCount(count + 1)}>+1</button>;
}

现象:无论你怎么点击按钮,页面显示的 count 在增加,但控制台每秒打印的永远是初始值 0。因为 setInterval 的回调在首次渲染时被创建,闭包锁死了当时的 count = 0

三、 如何优雅地解决闭包陷阱?

针对不同的业务需求,React 提供了多种破局方案:

方案 1:正确声明依赖数组(刷新闭包)

如果你希望回调函数能拿到最新的 State,最正规的做法是将该 State 加入 useEffect 的依赖数组。

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', count); // ✅ 每次都能拿到最新值
  }, 1000);
  return () => clearInterval(timer); // 清理上一次的定时器
}, [count]); // ✅ 将 count 加入依赖
  • 注意:当 count 变化时,React 会执行清理函数(清除旧定时器),然后重新执行 Effect(创建新定时器)。这能确保拿到最新值,但频繁重建定时器可能会带来性能开销。

方案 2:函数式更新(Functional Updates)

如果你的目的仅仅是基于旧状态来更新状态(例如 count + 1),完全不需要在闭包中读取 count,可以直接使用 setState 的函数形式。

useEffect(() => {
  const timer = setInterval(() => {
    // ✅ 传入函数,React 会保证传入最新的 state,无需依赖 count
    setCount(prevCount => prevCount + 1); 
  }, 1000);
  return () => clearInterval(timer);
}, []); // 保持空数组即可!
  • 优势:这是最推荐的更新模式,既避免了闭包陷阱,又避免了不必要的 Effect 重新执行。

方案 3:使用 useRef 作为可变容器

如果你需要在定时器或异步回调中读取最新的状态,但又不想频繁重建 Effect,可以使用 useRefuseRef 返回的对象在组件整个生命周期中保持不变,修改它的 .current 属性不会触发重渲染。

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 每次渲染同步最新值到 ref
  useEffect(() => {
    countRef.current = count; 
  }, [count]);

  useEffect(() => {
    const timer = setInterval(() => {
      // ✅ 通过 ref 获取最新值,完美避开闭包陷阱
      console.log('Latest count:', countRef.current); 
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 保持空数组

  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
  • 优势:兼顾了“只执行一次”和“获取最新值”的需求,是处理复杂异步逻辑(如 WebSocket、Canvas 动画)的利器。

方案 4:使用 useReducer

当状态逻辑非常复杂,或者多个状态相互依赖时,可以使用 useReducer。因为 dispatch 函数的引用是永远稳定的,不需要加入任何依赖数组,从根本上杜绝了闭包陷阱。

总结

React 的闭包陷阱并不是 Bug,而是 React 坚持“每次渲染都有独立 Props 和 State”这一设计哲学的必然结果。理解“渲染即快照”的概念后,根据场景选择函数式更新补全依赖数组useRef,就能游刃有余地应对各种异步状态管理问题。


下午培训内容:jdbc与mysql相关

核心知识点概览

模块关键内容最佳实践/注意事项
SQL 规范DDL(定义表结构)与 DML(增删改查);禁止硬删除,必须使用软删除(状态字段变更)生产环境严禁直接执行 DELETE,需准备回滚 SQL
字段类型ID 推荐使用 BIGINT 或自定义字符串规则;字符串优先选 VARCHAR;避免使用 TEXT 占用过大空间字符集必须使用 `utf8mb4以支持特殊字符
索引优化基于 B+ 树结构,支持等值、范围查询;联合索引遵循“最左匹配”原则索引并非越多越好,需权衡更新性能;避免在索引列上使用函数或隐式转换导致失效
JDBC 编程包含加载驱动、获取连接、创建 Statement、执行 SQL、处理结果、释放资源六步必须使用 PreparedStatement 防止 SQL 注入;资源释放需在 finally 块中倒序关闭
连接池引入 Druid 等连接池管理数据库连接,减少 TCP 握手开销需合理配置初始连接数、最大连接数及超时时间

SQL 开发与规范

在业务开发中,数据操作需严格区分 DDL 和 DML。DDL 涉及表结构修改(如新增字段),风险较高,需评估对历史数据的影响 。DML 操作中,严禁使用物理删除(Hard Delete),应通过增加“是否删除”状态字段实现软删除,以便数据追溯和恢复 。此外,ID 生成不建议仅依赖数据库自增,大型项目常采用程序生成的字符串 ID 以具备业务含义 。

Q:为什么企业通常不支持使用外键?

Claude:好问题。你在前面的建表脚本里我写了 FOREIGN KEY ... ON DELETE CASCADE——这在演示/小项目里可以,但企业生产环境几乎不用。


为什么不推荐?原因分三层

第一层:性能代价,每次写入都在加锁

外键不是一个”声明”,而是一个运行时的检查动作。当你往 grades 表插入一条成绩:

INSERT INTO grades (student_id, ...) VALUES (5, ...);

MySQL 内部实际执行了:
1. 在 grades 表上加意向锁
2. 去 students 表对 id=5 那行加共享锁(S锁)
3. 检查 id=5 是否存在
4. 存在 → 释放锁,插入成功
5. 不存在 → 回滚

问题在第 2 步:子表的写操作,会锁住父表的行。高并发下,大量成绩插入会竞争 students 表的行锁,产生阻塞链:

线程A: INSERT grades(student_id=5) → 锁住 students.id=5(等A提交才释放)
线程B: UPDATE students SET name='新名' WHERE id=5 → 被阻塞!
线程C: INSERT grades(student_id=5) → 也被阻塞!

如果线程 A 的事务很慢(比如批量导入几百条),后面全在排队。

第二层:级联操作是”原子弹”

-- 你现在的定义
FOREIGN KEY (student_id) REFERENCES students(id)
    ON DELETE CASCADE

一句话读懂这一行:删一个学生,他的所有成绩瞬间消失,不可恢复。

-- 有人不小心写了:
DELETE FROM students WHERE id = 1;
-- 学生没了,4条成绩也没了。没有警告,没有确认。

真实场景更可怕:

-- 有人写错条件:
DELETE FROM students WHERE enrollment_date > '2000-01-01';
-- 整张 grades 表可能被级联清空

企业里数据是命根子。CASCADE DELETE 意味着一个初级开发的失误可以让数据直接蒸发。所以大多数公司用软删除is_deleted = 1),而不是物理级联删除。

第三层:架构上,数据校验归应用层管

现代企业架构的趋势:

传统: 数据库负责一切约束(外键、触发器、存储过程)
      → 数据库成了"大黑盒",逻辑散落在 SQL 和代码两处

现代: 数据库只管存储,所有业务规则在应用层
      → 校验逻辑有版本控制、能写单元测试、能 code review

一个典型的学生插入成绩的场景,业务校验都比外键做得多:

// 应用层校验 — 逻辑清晰,可测试,可打日志
public void addGrade(Long studentId, String subject, Double score) {
    Student student = studentMapper.findById(studentId);
    if (student == null) {
        throw new BusinessException("学生不存在: " + studentId);  // 业务异常,有上下文
    }
    if (student.getDeleted() == 1) {
        throw new BusinessException("学生已注销,无法录入成绩");
    }
    if (score < 0 || score > 100) {
        throw new BusinessException("成绩超出范围");
    }
    // ... 再插入
}

外键只能告诉你”插入失败(违反约束)“,不能告诉你为什么。应用层校验可以返回精确的错误信息、记录审计日志、触发补偿流程。


额外:分库分表后,外键直接不成立

当单表数据量过亿,企业会做水平分库

DB1: students_1 ~ 100000    DB2: students_100001 ~ 200000
     grades 对应                       grades 对应

外键跨不了数据库。这时候”数据完整性”只能靠应用层保证。从一开始就不用外键,未来分库时就不用重构。


那你们建表脚本里的外键应该去掉吗?

对,生产环境改成这样:

-- 生产环境写法:只留索引,不加外键
CREATE TABLE grades (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    student_id  BIGINT NOT NULL,
    ...
    -- 保留索引,保证 JOIN 查询性能
    INDEX idx_student_id (student_id)
    -- 不加 FOREIGN KEY 约束
);

索引保留(否则查某个学生的成绩就是全表扫描),外键去掉(校验交给应用层)。

一句话总结:

外键本质是用数据库的性能和运维灵活性,去换一个应用层也能做的事。小项目、内部工具用它省心,企业高并发场景下得不偿失。


索引原理与失效场景

索引底层采用 B+ 树结构,通过多叉平衡降低磁盘 I/O 次数 。使用联合索引时,查询条件必须符合“最左前缀”匹配原则,否则索引可能失效 。常见失效场景包括:对索引列进行函数运算、类型隐式转换(如字符串与数字比较)、前缀模糊查询以及不满足最左匹配 。可通过 EXPLAIN 命令分析 SQL 执行计划,关注 type 和 key 字段判断索引使用情况 。

Q:使用索引能够提升查询性能的底层逻辑是? Claude:

你的表还需要在哪些列加索引?

先看现状 — 打开 sql/schema.sql 已有的索引:

students:                          grades:
  PK id            ✅                PK id           ✅
  UK student_no    ✅                UK (student_id, subject, semester) ✅
  idx_name         ✅                idx_subject     ✅
  idx_major_class  ✅                idx_semester    ✅

students 表建议加 1 个:

enrollment_date —— 必加。成绩管理系统几乎一定会有”查 2024 级学生”、“按入学年份统计”这类需求。目前这个列没有任何索引,每次按入学时间过滤都是全表扫描。

性别、年龄不加——性别只有 3 种值,选择性太低,建了也用不上(优化器会直接选全表扫描);年龄同理,值太集中。

grades 表建议加 1 个复合索引:

(semester, subject, score) —— 这是成绩表最重要的索引。原因是成绩查询几乎都围绕”学期 + 学科”展开:

-- 这三种查询是最常见的,一个复合索引全部覆盖:
WHERE semester = '2024-2025-2' AND subject = 'java' AND score < 60   -- 查不及格
WHERE semester = '2024-2025-2' AND subject = 'java' ORDER BY score    -- 排名
WHERE semester = '2024-2025-2' GROUP BY subject                        -- 各科平均

复合索引”最左前缀”规则让这三个查询都能命中。

可考虑删除的冗余索引:

  • idx_semesteridx_subject —— 如果你建了 (semester, subject, score),单独查 WHERE semester = 能用最左前缀命中,单独查 WHERE subject = 反而不能。所以 idx_subject 保留(应付不带学期的单科查询),idx_semester 可以删(被复合索引最左列覆盖)。

索引为什么能加速查询?本质是减少磁盘 IO

关键认知:CPU 和内存都很快,查询的瓶颈在磁盘 IO。

内存访问:    ~100 ns
SSD 随机读:  ~100,000 ns   (内存的 1000 倍)
机械硬盘:    ~10,000,000 ns (内存的 10 万倍)

没有索引 → 全表扫描,O(n)

SELECT * FROM students WHERE student_no = '2024001';

数据库一行一行从磁盘读到内存,逐一比对。100 万行的表,平均读 50 万次,每次都是一次随机 IO。就像一本没有目录的书,你要找”第五章”,只能从第 1 页开始逐页翻。

有索引 → B+ 树查找,O(log n)

索引是一棵 B+ 树,结构像多级目录:

              ["2024001" ~ "2024400"]          ← 根节点(常驻内存)
              /          |          \
    ["001"~"133"]   ["134"~"266"]  ["267"~"400"]  ← 中间节点
       /    \           /    \         /    \
    叶子↔叶子↔        叶子↔叶子↔      叶子↔叶子↔     ← 存主键值

student_no = '2024001'

  1. 读根节点 → 确定去左侧分支(1 次 IO
  2. 读中间节点 → 确定去最左叶子(1 次 IO
  3. 读叶子节点 → 拿到 id = 11 次 IO
  4. id = 1 去主键索引拿完整行(1 次 IO,这叫回表)

100 万行 → 4 次 IO 搞定。100 万次 vs 4 次,差距就在这里。

B+ 树为什么这么”矮”?

InnoDB 一个节点 16KB,每个索引条目大约十几个字节。一个节点能存 上千个 key

树高 1 层 → 1,000 行
树高 2 层 → 1,000 × 1,000 = 100 万行
树高 3 层 → 10 亿行

你的表存 100 万条成绩,树高不超过 3 层,查找任何一条最多 3 次磁盘 IO

叶子节点的链表 — 范围查询的杀手锏

B+ 树相比普通 B 树多了一个关键设计:叶子节点是双向链表

[叶子1] ↔ [叶子2] ↔ [叶子3] ↔ [叶子4] ↔ ...

WHERE score BETWEEN 60 AND 80:B+ 树定位到 60,然后顺着链表往右走,读到 80 就停。中间的读取是顺序 IO,比随机 IO 快几十倍。

覆盖索引 — 连回表都省了

二级索引叶子只存主键。如果查询的列全在索引里,不用回表,又省一次 IO:

-- 索引: (semester, subject, score)
-- ✅ 覆盖索引,不回表
SELECT semester, subject, score FROM grades WHERE semester = '2024-2025-2';

-- ❌ 需要回表(id, student_id, exam_date 不在索引里)
SELECT * FROM grades WHERE semester = '2024-2025-2';

排序也被加速

SELECT * FROM grades WHERE subject = 'java' ORDER BY score DESC;

B+ 树叶子节点本身就是有序的(subject, score) 索引里,subject='java' 区间的数据已经按 score 排好了,直接读就行——不需要额外排序(filesort)。100 万行的排序从几秒变几十毫秒。


一句话总结

索引快就快在:把 O(n) 的”逐行翻”变成 O(log n) 的”翻目录”,把大量随机 IO 变成少量定位 + 顺序读取。你的表最需要加的是 enrollment_date 索引(semester, subject, score) 复合索引

JDBC 编程与安全防护

JDBC 是 Java 连接数据库的标准 API,核心步骤包括加载驱动、建立连接、创建 Statement、执行 SQL、处理 ResultSet 及释放资源 。为防止 SQL 注入,必须使用 PreparedStatement 进行预编译,避免直接拼接字符串 。资源释放必须在 finally 块中执行,且需按创建顺序的逆序关闭(ResultSet → Statement → Connection),并在使用前判空 。实际项目中通常引入 Druid 等连接池来统一管理连接,提升性能 。

关于池化思想(来源Claude)

一句话理解池化

池化 = 提前造好一批资源放着,用完归还,而不是用完就扔、用的时候再建。

用餐厅类比:

没有池化(每次 new + destroy):      池化(连接池):
客人来 → 买锅 → 做菜 → 扔锅          后厨常备 10 口锅
客人来 → 买锅 → 做菜 → 扔锅          客人来 → 拿锅 → 做菜 → 放回
客人来 → 买锅 → 做菜 → 扔锅          客人来 → 拿锅 → 做菜 → 放回
       ↑ 买锅 + 扔锅 的开销巨大               ↑ 造一次,复用无数次

核心矛盾:创建和销毁资源的成本远高于复用的成本。


为什么”创建”这么贵?

以数据库连接为例,建立一次 TCP 连接 + MySQL 握手:

1. TCP 三次握手                   ~1.5 RTT
2. MySQL 认证握手(密码校验)       ~1 RTT
3. MySQL 返回握手确认              ~1 RTT
4. 字符集/时区协商                 ~1 RTT
─────────────────────────────────────────
   总计: 4-5 次网络往返 ≈ 50-200ms

而连接建立后,执行一条 SELECT * FROM students WHERE id = 1 只需要 几毫秒。如果不复用连接,每次查询的实际耗时是:

建连接(100ms) + 执行SQL(2ms) + 关连接(10ms) = 112ms

1000 次查询就是 112 秒。连接池把 100ms 的建连开销一次性支付,后续只花 2ms:

预热建10个连接(一次性1000ms) + 执行1000次SQL(2ms × 1000) = 3秒

112 秒 → 3 秒,提升 37 倍。


池化的通用模式(四个要素)

所有池化技术都是同一套逻辑:

┌──────────────────────────────────────────┐
│                  资源池                    │
│  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐        │
│  │ 空闲 │ │ 空闲 │ │ 使用中│ │ 空闲 │ ...   │
│  └─────┘ └─────┘ └─────┘ └─────┘        │
│     ↑ 借                 ↓ 还             │
│  ┌─────────────────────────────┐          │
│  │         调用方               │          │
│  └─────────────────────────────┘          │
└──────────────────────────────────────────┘

四个核心参数:
  • 核心数 (core size)    — 池里最少保持几个(不回收)
  • 最大数 (max size)     — 池里最多允许几个(超出排队等待)
  • 空闲超时 (idle timeout) — 空闲多久后回收多余连接
  • 等待超时 (wait timeout) — 借不到时最多等多久(超时报错)

六大应用领域

1. 数据库连接池(最典型)

实现所属生态
HikariCPSpring Boot 默认,当前性能最优
Druid阿里出品,带 SQL 监控墙
DBCP2Apache,老牌
# Spring Boot 中你就在用池化,只是没感知
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5

每一条 jdbc:mysql://... 的背后都不是裸连接,而是从池里拿的。

2. 线程池

线程创建代价极高——每个线程在操作系统层面要分配独立的栈空间(默认 1MB),创建和销毁涉及内核态切换。

// 不用池: 每来一个任务 new 一个线程
new Thread(() -> doWork()).start();  // 创建 ~1ms,栈占 1MB,任务完就扔

// 用池: 固定 10 个 worker 线程反复用
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> doWork());  // 复用已有线程,0 创建开销

Java 里最常用的 ThreadPoolExecutor 参数设计和连接池完全一致:

corePoolSize = 5    → 常驻 5 个线程
maxPoolSize  = 10   → 忙时最多扩到 10 个
keepAliveTime = 60s → 多余线程空闲 60s 后回收
workQueue 容量 = 100 → 任务排队队列

3. HTTP 连接池

微服务时代,服务 A 调服务 B 走 HTTP。每次请求如果重新建 TCP 连接,开销同样巨大。

// OkHttp / Apache HttpClient 内部都有连接池
// 同一个 host:port 的连接被复用
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200);           // 总共最多 200 个连接
cm.setDefaultMaxPerRoute(20);  // 每个目标服务最多 20 个

浏览器也是同理——Chrome 对同一域名默认保持 6 个 TCP 连接复用(HTTP/1.1 的 keep-alive)。

4. 对象池

有些对象创建成本极高,用完可以重置再给下一个人:

// StringBuilder 可以 reset 后复用
// Apache Commons Pool2 就是通用对象池框架
GenericObjectPool<StringBuilder> pool = new GenericObjectPool<>(new StringBuilderFactory());
pool.setMaxTotal(50);

StringBuilder sb = pool.borrowObject();  // 借
sb.append("hello");
pool.returnObject(sb);                   // 还(自动 reset)

Jedis(Redis 客户端)内部就用 Commons Pool2 管理 Redis 连接。 你写 jedis.set("key", "val"),背后是从池里拿连接、用完归还。

5. 内存池

malloc / free 的系统调用频繁了也会成为瓶颈。内存池预先向 OS 申请一大块,自己管理分配和回收:

C++ STL allocator、Netty 的 PooledByteBuf、JVM 的 TLAB 全是这个思路。

Netty 中:
PooledByteBufAllocator  → 堆外内存池,零拷贝 + 复用
UnpooledByteBufAllocator → 每次 new,用完等 GC

这就是为什么 Netty 做网络编程性能碾压传统 BIO——内存都不需要频繁申请释放。

6. 常量池 / 字符串池

JVM 内部就在用池化:

String s1 = "hello";      // 从字符串常量池拿
String s2 = "hello";      // 同一个引用,不会重复创建
String s3 = new String("hello");  // 强行 new,绕开池

System.out.println(s1 == s2);  // true  — 池里同一个对象
System.out.println(s1 == s3);  // false — 堆上新对象

Integer 也一样,-128 ~ 127 范围的整数被 JVM 缓存在常量池中。


一张表总结

领域池化的资源创建成本典型实现
数据库连接池TCP 连接 + DB 会话100ms + 握手HikariCP, Druid
线程池OS 线程 + 栈空间1ms + 1MB 内存ThreadPoolExecutor
HTTP 连接池TCP 连接50ms + 握手HttpClient, OkHttp
对象池重量级 Java 对象new + initCommons Pool2, Jedis
内存池堆外/堆内内存块malloc/freeNetty PooledByteBuf
字符串池String 对象new + GC 回收JVM String Pool

池化的本质不是”让你更快”,而是”避免重复做慢的事情”。 任何创建和销毁成本远高于使用成本的资源,都值得被池化。反过来,轻量级对象(比如一个 DTO)就别池化了——JVM 的年轻代 GC 创建和回收它们几乎零代价,加个池反而把代码搞复杂了。

每天前进一点点