普通视图

发现新文章,点击刷新页面。
昨天以前首页

TypeScript 和 JavaScript 的 'use strict' 有啥不同

2025年10月12日 15:52

都叫严格模式,但它们解决的问题完全不在一个层次上

前言

写完 JavaScript 严格模式的文章,突然想到一个问题:"TypeScript 不也有个 strict: true 吗?这俩是一回事吗?开了 TS 的 strict 还要写 'use strict' 吗?"

说实话,我刚学 TypeScript 时也搞混过。看着 tsconfig.json 里的 strict: true,心想这应该和 JS 的 'use strict' 差不多吧,结果配完发现代码里还是满屏标红。

后来花了个周末把 TypeScript 编译选项挨个试了一遍,才明白:这俩虽然名字像,但压根不是一个维度的东西——一个管编译时的类型检查,一个管运行时的语言行为。

先抛几个问题,看看你是不是也有同样的困惑:

  • TypeScript 的 strict 和 JavaScript 的 'use strict' 到底啥区别?
  • 开了 TS 的 strict 模式,还需要写 'use strict' 吗?
  • 它们检查的东西一样吗?(答案是完全不一样)
  • 为啥名字这么像,却是两个东西?(这锅 TypeScript 团队真得背)

这篇文章就来聊聊,同样是"严格",它们到底严在哪里,又有什么本质区别。


目录


一个真实的困惑:我到底该开哪个?

先看一个常见场景。你在写 TypeScript 项目,tsconfig.json 里配了:

{
  "compilerOptions": {
    "strict": true
  }
}

然后在代码里写:

function greet(name) {  // TS 报错:Parameter 'name' implicitly has an 'any' type
  console.log('Hello ' + name);
}

TypeScript 立马给你标红了。你想:行,TypeScript 的严格模式生效了

但是,这时候你在文件顶部加不加 'use strict',会有区别吗?

或者反过来,如果你只写了 'use strict',没开 TypeScript 的 strict: true,又会怎样?

这就是今天要搞清楚的问题


JavaScript 严格模式回顾:运行时的守护者

先快速回顾一下 JavaScript 的严格模式(详细内容可以看上一篇文章)

它是什么?

一个运行时开关,在代码执行时改变 JavaScript 引擎的行为。

'use strict';  // 告诉 JS 引擎:"用严格模式跑这段代码"

x = 10;  // ReferenceError: x is not defined(运行时报错)

它解决什么?

JavaScript 早期设计的语言层面的问题

  • 运行时错误:把静默失败变成抛出异常
  • 危险语法:禁止容易出错的语法(比如 with、八进制字面量)
  • 意外行为:修正反直觉的行为(比如自动创建全局变量)

关键特征

mindmap
  root((JavaScript<br/>严格模式))
    运行时生效
      代码执行时检查
      依赖 JS 引擎
      无法在编译时发现问题
    语言层面
      修改语言行为
      禁止危险语法
      修正历史问题
    向后兼容
      老代码不受影响
      需要主动开启
      只影响声明的作用域

TypeScript 严格模式:编译时的守护者

TypeScript 的 strict: true 是另一个完全不同的东西。

它是什么?

一个编译选项集合,在代码编译(转换为 JS)之前进行类型检查

// tsconfig.json
{
  "compilerOptions": {
    "strict": true  // 这是个"总开关"
  }
}

当你开启 strict: true,实际上是同时开启了这 7 个编译选项:

{
  "compilerOptions": {
    "strict": true,  // 👆 等价于下面 👇

    "noImplicitAny": true,               // 禁止隐式 any 类型
    "noImplicitThis": true,              // 禁止 this 有隐式 any 类型
    "strictNullChecks": true,            // 严格的 null/undefined 检查
    "strictFunctionTypes": true,         // 严格的函数类型检查
    "strictBindCallApply": true,         // 严格检查 bind/call/apply
    "strictPropertyInitialization": true,// 严格的类属性初始化检查
    "alwaysStrict": true,                // 始终以严格模式解析(会加 'use strict')
    "useUnknownInCatchVariables": true   // catch 变量默认为 unknown 类型
  }
}

等等,看到 alwaysStrict 了吗?这就是联系的地方!

它解决什么?

TypeScript 的严格模式解决的是类型安全问题

  • 编译时错误:在代码运行前就发现类型错误
  • 类型推断:强制明确类型,避免隐式 any
  • 空值安全:防止 null/undefined 引起的运行时错误
  • 函数安全:确保函数调用的类型正确性

关键特征

mindmap
  root((TypeScript<br/>严格模式))
    编译时生效
      转译前检查
      IDE 实时提示
      运行前发现问题
    类型系统层面
      强制类型明确
      空值安全检查
      函数类型检查
    配置灵活
      总开关
      可单独开关每个选项
          逐步迁移友好

核心差异:编译时 vs 运行时

现在重点来了,这两者的本质区别

1. 生效时机不同

flowchart LR
    A[编写代码] --> B[TypeScript 编译]
    B --> C[生成 JavaScript]
    C --> D[浏览器/Node.js 执行]

    B -.->|TypeScript strict| E[编译时检查]
    D -.->|JavaScript 'use strict'| F[运行时检查]

    style E fill:#e1f5ff
    style F fill:#fff4e1

TypeScript strict: true

  • ✅ 在编译阶段检查(你还在写代码的时候)
  • ✅ IDE 实时提示,根本不让你编译通过
  • ✅ 问题在开发阶段就被发现

JavaScript 'use strict'

  • ✅ 在运行阶段检查(代码已经在跑了)
  • ✅ 只有执行到那行代码才会报错
  • ✅ 问题可能在生产环境才暴露

2. 检查内容不同

检查项 TypeScript strict JavaScript 'use strict'
未声明变量 ❌ 不检查(这是 JS 运行时的事) ✅ 运行时报错
隐式 any 类型 ✅ 编译错误 ❌ 不检查(JS 没有类型)
null/undefined 安全 ✅ 编译错误 ❌ 不检查(运行时才知道是否为 null)
函数参数类型 ✅ 编译错误 ❌ 不检查
只读属性赋值 ✅ 编译错误(如果用了 readonly ✅ 运行时报错
重复参数名 ✅ 编译错误 ✅ 运行时报错
八进制字面量 ✅ 编译错误 ✅ 运行时报错
with 语句 ✅ 编译错误 ✅ 运行时报错
this 为 undefined ✅ 类型检查会提示 ✅ 运行时行为改变

3. 适用范围不同

TypeScript strict

  • 只在 .ts.tsx 文件中生效
  • 需要 TypeScript 编译器
  • 编译后的 JS 文件里没有类型信息

JavaScript 'use strict'

  • 在所有 JS 文件中都能用(.js.ts 编译后的文件)
  • 不需要任何工具,浏览器原生支持
  • 直接影响 JS 引擎的行为

深入对比:它们分别解决什么问题?

案例 1:未声明的变量

JavaScript 'use strict' 能捕获

'use strict';

function test() {
  myVar = 10;  // ❌ ReferenceError: myVar is not defined(运行时)
}

test();

TypeScript strict 不检查这个

// tsconfig.json: { "strict": true }

function test() {
  myVar = 10;  // ⚠️ TypeScript: Cannot find name 'myVar'
               // 但这是因为 TypeScript 要求先声明变量
               // 不是因为 strict 模式
}

TypeScript 编译后:

"use strict";  // 👈 注意这里!因为 alwaysStrict: true

function test() {
  myVar = 10;  // 运行时还是会被 'use strict' 捕获
}

结论

  • TS 的 strict 本身不处理未声明变量
  • strict 包含 alwaysStrict,会自动加 'use strict'
  • 最终还是靠 JS 的严格模式在运行时捕获

案例 2:隐式 any 类型

TypeScript strict 能捕获

// strict: true

function greet(name) {
  // ❌ 编译错误:Parameter 'name' implicitly has an 'any' type
  console.log('Hello ' + name);
}

JavaScript 'use strict' 完全不管

'use strict';

function greet(name) {
  // ✅ 没问题,JS 本来就是动态类型
  console.log('Hello ' + name);
}

结论

  • TS 的 strict 强制你明确类型
  • JS 的 'use strict' 对类型无能为力(因为 JS 没有静态类型)

案例 3:空值安全

TypeScript strict 的强项

// strict: true(包含 strictNullChecks)

function getLength(str: string) {
  return str.length;
}

const maybeStr: string | null = getSomeString();

getLength(maybeStr);
// 编译错误:Argument of type 'string | null' is not assignable to parameter of type 'string'

JavaScript 'use strict' 无能为力

'use strict';

function getLength(str) {
  return str.length;
}

const maybeStr = getSomeString();

getLength(maybeStr);
// 编译通过
// 运行时如果 maybeStr 是 null,会报错:Cannot read property 'length' of null

结论

  • TS 的 strict 在编译时就发现了潜在的 null 引用问题
  • JS 的 'use strict' 只能等到运行时才崩溃

案例 4:函数 this 类型

两者都有帮助,但方式不同

// TypeScript strict
interface User {
  name: string;
  greet(this: User): void;  // 明确 this 类型
}

const user: User = {
  name: 'Alice',
  greet() {
    console.log(this.name);
  }
};

const greetFn = user.greet;
greetFn();
// ❌ TS 编译错误:The 'this' context of type 'void' is not assignable to method's 'this' of type 'User'
// JavaScript 'use strict'
'use strict';

const user = {
  name: 'Alice',
  greet() {
    console.log(this.name);  // this 是 undefined
  }
};

const greetFn = user.greet;
greetFn();
// ✅ 编译通过
// 运行时报错:Cannot read property 'name' of undefined

结论

  • TS 的 strict 通过类型系统在编译时就警告你
  • JS 的 'use strict'thisundefined,在运行时才报错

实战案例:看看它们如何配合工作

完整示例:两者互补

// tsconfig.json
{
  "compilerOptions": {
    "strict": true  // 包含 alwaysStrict: true
  }
}
// user.ts

// 1️⃣ TypeScript 的 strict 检查类型
function calculateTotal(price: number, quantity: number): number {
  // 2️⃣ TypeScript 确保参数类型正确
  if (price < 0) {
    // 3️⃣ strictNullChecks 确保不返回 undefined
    throw new Error('Price cannot be negative');
  }

  return price * quantity;
}

// 4️⃣ 编译时就发现类型错误
// calculateTotal('100', 5);  // ❌ 编译错误

// 5️⃣ 如果不小心写了未声明变量
function buggyCode() {
  totol = 100;  // ❌ TS: Cannot find name 'totol'
}

编译后的 JavaScript:

// user.js
"use strict";  // 👈 自动加上!来自 alwaysStrict: true

// TypeScript 的类型检查已经完成,这里只剩运行时代码
function calculateTotal(price, quantity) {
  if (price < 0) {
    throw new Error('Price cannot be negative');
  }
  return price * quantity;
}

// 如果 TypeScript 没拦住(比如用了 any),运行时会拦住
function buggyCode() {
  totol = 100;  // 💥 ReferenceError(被 'use strict' 捕获)
}

双重保险

  1. 第一层(编译时) :TypeScript 的 strict 检查类型、空值、函数签名
  2. 第二层(运行时) :JavaScript 的 'use strict' 检查语言层面的问题

深入理解:为什么需要两者?

JavaScript 严格模式的局限

'use strict' 再严格,也只是让错误暴露得早一点,但:

  • ❌ 不能阻止类型错误(比如把字符串传给期望数字的函数)
  • ❌ 不能保证空值安全(比如访问 null 的属性)
  • ❌ 不能检查函数签名(比如参数数量、类型)

TypeScript 严格模式的局限

strict: true 再强大,也只在编译时有效,但:

  • ❌ 不能处理动态引入的第三方库(没有类型定义的)
  • ❌ 不能检查运行时的值(比如从 API 返回的数据)
  • ❌ 如果用了 any 或类型断言,类型检查就被绕过了

两者互补

graph LR
    A[开发阶段] --> B[TypeScript strict<br/>类型检查]
    B --> C[编译]
    C --> D[运行阶段]
    D --> E[JavaScript 'use strict'<br/>语言规则检查]

    B -.-> F[捕获类型错误<br/>空值引用<br/>函数签名问题]
    E -.-> G[捕获未声明变量<br/>静默失败<br/>危险语法]

    style B fill:#e1f5ff
    style E fill:#fff4e1

最佳组合

  • TypeScript strict: true:在开发时就把大部分问题拦住
  • JavaScript 'use strict' (自动加上):作为最后一道防线,拦住 TypeScript 也管不了的运行时问题

✅ 最佳实践:该怎么配置?

1. 新 TypeScript 项目:两个都要

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,  // 👈 已经包含 alwaysStrict: true
    "target": "ES2020",
    "module": "ESNext"
  }
}

这样配置后:

  • ✅ TypeScript 会做编译时检查
  • ✅ 自动为每个文件加上 'use strict'
  • 你不需要手写 'use strict'

2. 老 TypeScript 项目:逐步迁移

如果直接开 strict: true 会导致满屏报错,可以单独开启

{
  "compilerOptions": {
    "strict": false,  // 先不开总开关

    // 逐步开启单个选项
    "noImplicitAny": true,            // 第一步:禁止隐式 any
    "alwaysStrict": true,             // 第二步:加 'use strict'
    "strictNullChecks": true,         // 第三步:空值检查
    // ... 逐步开启其他选项
  }
}

3. 纯 JavaScript 项目:只能用 'use strict'

如果你不用 TypeScript,那就只能用 JavaScript 的严格模式:

// 方式 1:全局开启(文件顶部)
'use strict';

// 你的代码...
// 方式 2:函数级别开启
function myFunction() {
  'use strict';
  // 只在这个函数内严格
}

推荐:配合 ESLint 强制添加:

// .eslintrc.js
module.exports = {
  rules: {
    'strict': ['error', 'global']
  }
};

4. 配合 ESLint/Prettier

TypeScript 的 strict 模式专注类型检查,但代码质量还需要 ESLint:

// .eslintrc.json
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/recommended-requiring-type-checking"
  ],
  "parserOptions": {
    "project": "./tsconfig.json"
  }
}

这样你会得到:

  • TypeScript strict:类型安全
  • ESLint:代码质量、最佳实践
  • Prettier:代码格式

对比总结表

维度 TypeScript strict: true JavaScript 'use strict'
本质 编译选项集合 运行时指令
生效时机 编译时(写代码时) 运行时(代码执行时)
检查内容 类型、空值、函数签名 语言规则、危险语法
错误提示 IDE 实时提示、编译失败 运行时抛出异常
依赖 TypeScript 编译器 JavaScript 引擎
适用文件 .ts.tsx 所有 .js 文件
性能影响 无(编译时) 微小(运行时)
向后兼容 需要 TypeScript 所有现代浏览器
配置方式 tsconfig.json 代码中写 'use strict'
关联关系 alwaysStrict 会自动加 'use strict' 无关 TypeScript
最佳实践 新项目必开 TS 项目自动加上,JS 项目手动加

常见误区澄清

误区 1:"开了 TypeScript strict 就不需要 'use strict' 了"

错误

虽然 strict: true 包含 alwaysStrict: true(会自动加 'use strict'),但:

  • TypeScript 只检查编译时的类型问题
  • 'use strict' 检查运行时的语言问题

正确理解:开了 strict: true 后,编译出的 JS 会自动带 'use strict',所以你不用手写。


误区 2:"'use strict' 能替代 TypeScript"

错误

'use strict' 再严格,也不能做类型检查。比如:

'use strict';

function add(a, b) {
  return a + b;
}

add('1', 2);  // ✅ 运行通过,结果是 '12'(字符串拼接)

TypeScript 会在编译时就发现类型问题:

function add(a: number, b: number) {
  return a + b;
}

add('1', 2);  // ❌ 编译错误:Argument of type 'string' is not assignable to parameter of type 'number'

误区 3:"strict: true 太严格了,影响开发效率"

错误(短期看似如此,长期受益)

刚开始确实会遇到很多类型错误,但:

  • 这些错误本来就存在,只是以前被隐藏了
  • 在编译时发现远比在生产环境崩溃要好
  • 类型提示会让重构和协作更安全

建议:新项目直接开 strict: true,老项目逐步迁移。


写在最后

研究完这两个"严格模式",我的理解是:

它们的关系

  • TypeScript strict:编译时的守护者,拦截类型错误、空值引用、函数签名问题
  • JavaScript 'use strict' :运行时的守护者,拦截语言层面的危险语法和意外行为
  • 它们不是替代关系,而是互补关系

为什么要两者都用

  • TypeScript 再强大,也只在编译时有效
  • 编译后的 JS 代码,依然需要 'use strict' 在运行时提供保护
  • strict: true 里的 alwaysStrict 会自动加上 'use strict',所以你只需要配置 TypeScript,不用手写

使用建议

  1. TypeScript 项目:开启 strict: true(已包含 alwaysStrict
  2. 纯 JavaScript 项目:手动加 'use strict',配合 ESLint 强制
  3. 不要因为名字相似就混淆它们:一个管编译时类型,一个管运行时语言规则

下次有人问你"TypeScript 的 strict 和 JavaScript 的 'use strict' 有啥区别",你可以自信地说:

一个在编译时保护你的类型安全,一个在运行时保护你的代码行为。名字像,但完全不是一回事!

TypeScript 官方文档

  1. Compiler Options: strict - TypeScript 严格模式官方说明
  2. TSConfig Reference - 完整的编译选项参考

JavaScript 官方规范

  1. ECMAScript Strict Mode - 严格模式的官方定义
  2. MDN - Strict mode - 最全面的严格模式文档

相关文档

  1. TypeScript Deep Dive: Strict - 深入理解 TypeScript 严格性
  2. JavaScript: The Good Parts - Douglas Crockford 讲解严格模式的设计哲学

面试官:讲讲这段react代码的输出(踩坑)

2025年10月12日 10:29

从一段看似正常的代码,到深入理解 React Hooks 的闭包陷阱

前言

之前面试,面试官递过来一段代码:"看看这段代码有啥问题?" image.png

我扫了一眼——标准的 React 组件,用了 useStateuseEffect,设置了个定时器每秒打印计数。代码看起来挺规范的,没有明显的语法错误。点击按钮,UI 上的数字也正常更新:1、2、3...

但打开 Console 一看,我愣住了:

Count: 0
Count: 0
Count: 0
Count: 0
...

UI 明明在变,为什么打印的永远是 0?

带着这个困惑,我回来后花了个晚上把 React Hooks 的闭包机制翻了个底朝天。没想到这个看起来简单的 bug,背后藏着的是 JavaScript 闭包和 React 渲染机制的深层交互。

先抛几个问题,看看你能答对几个:

  • 为什么 UI 正常更新,但 console 输出错误?
  • 闭包是怎么"困住"旧值的?
  • useEffect 的空依赖数组 [] 有什么影响?
  • 这个 bug 有几种修复方法?哪种最优?

这篇文章会详细讲解:

  • Bug 的完整复现和分析
  • JavaScript 闭包机制
  • useEffect 的依赖机制
  • 5 种解决方案的完整对比
  • 如何避免类似问题

目录


Bug 演示

完整代码

import { useEffect, useState } from "react";

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

  function handleLog() {
    console.log("Count:", count);
  }

  useEffect(() => {
    const id = setInterval(handleLog, 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

运行效果

UI 显示

Count: 3  ← 点击了3次,显示正常
[Increment 按钮]

Console 输出

Count: 0  ← 一直是0!
Count: 0
Count: 0
Count: 0
...

问题分析:陈旧闭包

什么是陈旧闭包(Stale Closure)?

这个 bug 的根源是 陈旧闭包(Stale Closure) ——函数"记住"了它创建时的环境,但这个环境里的值已经过时了。

为什么会出现?

执行流程详解

  1. 初始渲染(count = 0)

    • 创建 handleLog 函数,捕获 count = 0
    • useEffect 执行,设置 setInterval(handleLog, 1000)
    • 注意:useEffect 的依赖是 [],所以只执行一次
  2. 用户点击按钮

    • setCount(1) → 触发重新渲染
    • 创建新的 handleLog 函数,捕获新的 count = 1
    • 但是! useEffect 不会再执行(依赖是 []
    • setInterval 调用的还是第一次渲染时的旧 handleLog
  3. 结果

    • UI 显示的是最新的 count(React 状态正常更新)
    • setInterval 调用的 handleLog 里的 count 永远是 0(闭包捕获的旧值)

原理深挖:闭包如何困住旧值

闭包基础

先用个简单例子理解闭包:

function createCounter() {
  let count = 0;  // 被"捕获"的变量

  return function() {
    console.log(count);  // 能访问外层的 count
  };
}

const counter1 = createCounter();
const counter2 = createCounter();

counter1();  // 输出: 0
counter2();  // 输出: 0

// 即使外层函数执行完了,内层函数还能访问 count

闭包说穿了就是:函数能"记住"它创建时的环境

React 中的闭包陷阱

// 第一次渲染(count = 0)
function App() {
  const count = 0;  // ← 这个值

  function handleLog() {
    console.log(count);  // ← 被这个函数捕获
  }

  useEffect(() => {
    setInterval(handleLog, 1000);  // ← interval 记住了这个 handleLog
  }, []);  // ← 空数组,只执行一次

  // ...
}

// 第二次渲染(count = 1)
function App() {
  const count = 1;  // ← 新的值

  function handleLog() {
    console.log(count);  // ← 新的函数,捕获新值
  }

  // useEffect 不执行(依赖是空数组)
  // interval 还在调用第一次渲染时的旧 handleLog

  // ...
}

关键点

  • 每次渲染都会创建新的 count 变量和新的 handleLog 函数
  • useEffect 只在首次渲染时执行(依赖是 []
  • setInterval 调用的是第一次渲染时的 handleLog
  • 那个 handleLog 里捕获的 count 永远是 0

解决方案对比

下面介绍 5 种修复方法,每种都有适用场景。

方案对比表

方案
1. 添加 count 依赖
2. 使用 useRef
3. 函数式更新
4. useLatest 自定义 Hook
5. useEffectEvent (React 18+)

方案 1:添加 count 依赖

思路:让 useEffectcount 变化时重新执行。

useEffect(() => {
  const id = setInterval(handleLog, 1000);
  return () => clearInterval(id);
}, [count]);  // ← 添加 count 依赖

优点

  • 简单直接,一行改动

缺点

  • 性能差:每次 count 变化都会:

    1. 清除旧的 interval
    2. 创建新的 interval
  • 对于快速更新的状态,会频繁重建 interval


方案 2:使用 useRef ⭐⭐⭐⭐

思路:用 useRef 保存最新值,interval 读取 ref。

import { useEffect, useState, useRef } from "react";

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

  // 同步 count 到 ref
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  function handleLog() {
    console.log("Count:", countRef.current);  // ← 读取 ref
  }

  useEffect(() => {
    const id = setInterval(handleLog, 1000);
    return () => clearInterval(id);
  }, []);  // ← 空数组,只设置一次

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

为什么有效?

  • ref.current 是可变的,修改它不会触发重新渲染
  • 每次 count 更新时,同步到 countRef.current
  • handleLog 读取 countRef.current,总是最新值

优点

  • 性能好(interval 只创建一次)
  • 总是读取最新值

缺点

  • 需要额外的 useEffect 同步值
  • 代码稍显冗余

方案 3:函数式更新

思路:利用 setState 的函数式更新,不依赖闭包捕获的值。

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount((prevCount) => {
        console.log("Count:", prevCount);  // ← 读取最新值
        return prevCount;  // 不修改,只打印
      });
    }, 1000);

    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

优点

  • 简单,不需要 ref
  • 不需要依赖数组

缺点

  • 只适合简单场景:如果需要访问多个状态,代码会很丑陋
  • 滥用 setState 作为"读取"手段,语义不清晰

方案 4:useLatest 自定义 Hook ⭐⭐⭐⭐⭐

思路:封装方案 2 的 ref 逻辑,提高复用性。

import { useEffect, useState, useRef } from "react";

// 自定义 Hook:保存最新值
function useLatest(value) {
  const ref = useRef(value);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref;
}

export function App() {
  const [count, setCount] = useState(0);
  const countRef = useLatest(count);  // ← 封装成 Hook

  function handleLog() {
    console.log("Count:", countRef.current);
  }

  useEffect(() => {
    const id = setInterval(handleLog, 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

优点

  • 复用性好,可在多个地方使用
  • 语义清晰:useLatest 明确表示"总是最新值"
  • 性能好

缺点

  • 需要额外维护自定义 Hook

方案 5:useEffectEvent (React 18+) ⭐⭐⭐⭐

思路:使用 React 官方的实验性 API。

import { useEffect, useState, experimental_useEffectEvent as useEffectEvent } from "react";

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

  // useEffectEvent:创建一个"总是最新"的事件处理函数
  const handleLog = useEffectEvent(() => {
    console.log("Count:", count);
  });

  useEffect(() => {
    const id = setInterval(handleLog, 1000);
    return () => clearInterval(id);
  }, []);  // ← 不需要添加 handleLog 依赖

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

优点

  • 官方解决方案,专为此设计
  • 语义清晰
  • 不需要手动管理 ref

缺点

  • 实验性 API(React 18 中可用,但可能变动)
  • 需要 React 18+

类似陷阱举例

闭包陷阱不仅出现在 useEffect 中,下面是几个常见场景:

1. 事件监听器

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

  useEffect(() => {
    function handleClick() {
      console.log("Count:", count);  // ← 闭包捕获旧值
    }

    document.addEventListener("click", handleClick);

    return () => {
      document.removeEventListener("click", handleClick);
    };
  }, []);  // ← 空数组,只执行一次

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

修复:添加 count 依赖,或使用 useRef


2. 异步回调

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

  function handleAsync() {
    setTimeout(() => {
      console.log("Count:", count);  // ← 3秒后打印,可能已经变了
    }, 3000);
  }

  return (
    <div>
      <button onClick={handleAsync}>异步打印</button>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

问题

  • 点击"异步打印"时 count = 0
  • 3秒内点击 Increment 多次,count = 5
  • 3秒后 setTimeout 执行,打印 Count: 0(闭包捕获的旧值)

修复:使用 useRefuseLatest


3. 防抖/节流函数

function App() {
  const [searchTerm, setSearchTerm] = useState("");

  const handleSearch = useMemo(
    () =>
      debounce(() => {
        console.log("搜索:", searchTerm);  // ← 闭包捕获旧值
      }, 500),
    []  // ← 空数组,只创建一次
  );

  return <input onChange={(e) => setSearchTerm(e.target.value)} />;
}

修复:添加 searchTerm 依赖,或使用 useLatest


避坑指南

1. 开启 ESLint 规则

// .eslintrc.json
{
  "rules": {
    "react-hooks/exhaustive-deps": "warn"
  }
}

这个规则会检查:

  • useEffectuseCallbackuseMemo 的依赖数组
  • 如果函数内使用了外部变量,但没有加入依赖,会报警告

2. 检查清单

遇到闭包相关的 bug 时,问自己这几个问题:

  • 函数内是否使用了组件的 props 或 state?
  • useEffect 的依赖数组是否完整?
  • 是否有定时器、事件监听器、异步回调?
  • 是否需要总是读取最新值?

3. 快速识别方法

看到这些代码模式,立即警惕

useEffect(() => {
  // 使用了 state/props,但依赖是空数组
  console.log(someState);
}, []);  // ← 🚨 危险!

useEffect(() => {
  setInterval(() => {
    // 使用了 state/props
    console.log(someState);
  }, 1000);
}, []);  // ← 🚨 危险!

useEffect(() => {
  document.addEventListener("click", () => {
    // 使用了 state/props
    console.log(someState);
  });
}, []);  // ← 🚨 危险!

4. 最佳实践

推荐顺序(从简单到复杂):

  1. 首选:使用 ESLint,添加完整依赖
  2. 性能要求高:使用 useLatest 自定义 Hook
  3. React 18+ :使用 useEffectEvent(实验性)
  4. 简单场景:使用函数式更新

总结

这个看起来简单的 bug,背后是 JavaScript 闭包和 React 渲染机制的交互:

核心原理

  • 每次渲染都会创建新的函数和变量
  • 闭包会"记住"函数创建时的环境
  • useEffect 的依赖数组决定何时重新执行
  • 空依赖数组 [] 导致 effect 只执行一次,捕获的是初始值

陈旧闭包的特征

  • UI 正常,但异步操作(定时器、事件监听、回调)读取到旧值
  • useEffect 依赖不完整
  • 函数内使用了外部变量,但没有加入依赖

推荐解决方案

  1. 通用场景:使用 useLatest 自定义 Hook(复用性好)
  2. 简单场景:使用 useRef(手动同步值)
  3. React 18+ :使用 useEffectEvent(官方方案,实验性)

面试启示: 这类题考察的是:

  • 对 JavaScript 闭包的理解
  • 对 React Hooks 机制的掌握
  • 解决实际问题的能力(多种方案对比)

下次写 useEffect 时,多问一句:这个函数用到的变量,是不是总是最新的? 养成这个习惯,就能避开大部分闭包陷阱。


相关资源

❌
❌