JavaScript 最容易让人困惑的地方,往往不是语法怎么写,而是表达式按什么规则解释:普通函数的
this看调用方式,闭包保存词法环境,对象相等比较引用身份,null和undefined表达不同种类的“空”。
我追问的链
this到底指向谁,是函数定义时决定的吗?bind和箭头函数都能固定this,区别是什么?- 方法从对象上取出来以后,为什么会丢失
this? - 闭包看起来很像类,它存在的意义是什么?
- 闭包能做的类是不是都能做?闭包如何释放?
===和==最终比较的是什么?- 对象内容完全一样,为什么仍然不相等?
null和undefined为什么要同时存在?
1. 普通函数的 this 由调用方式决定
先看同一个函数:
1 | function showName() { |
只看函数定义,无法确定 this。必须看它怎么被调用。
作为对象方法调用
1 | const player = { |
调用点是:
1 | player.showName() |
所以这次调用中的 this 是 player。
容易产生误解的说法是“函数属于 player”。更准确地说:
这次调用通过
player.showName()发起,所以普通函数的this被设为player。
脱离对象后调用
1 | const callback = player.showName; |
虽然 callback 和 player.showName 指向同一个函数,但调用形式已经变成:
1 | callback() |
它前面没有对象,因此不再自动得到 player。
在严格模式和 ES Module 中,这种普通函数调用的 this 通常是 undefined。旧式非严格脚本中,它可能退回全局对象,但不应该依赖这种行为。
这就是常说的“方法被取出来后丢失 this”。
2. 四种常见绑定规则
普通函数的 this 可以先按四类理解。
默认绑定
1 | function run() { |
严格模式下通常是 undefined。
隐式绑定
1 | const controller = { |
this 是调用点左边的 controller。
显式绑定
1 | run.call(controller); |
调用者主动指定 this。
构造调用
1 | function Player(name: string) { |
new 会创建一个新对象,并让构造函数中的 this 指向它。
可以用这条简化优先级记忆:
1 | new 调用 |
其中 new 调用一个 bound function 时,会忽略 bind 传入的 thisArg,因为构造过程需要使用新创建的实例;预先绑定的参数仍然有效。
3. call、apply、bind 的区别
三者都可以指定普通函数的 this:
1 | function attack(target: string, damage: number) { |
call 立即调用,参数逐个传:
1 | attack.call(player, "boss", 10); |
apply 立即调用,参数以数组传:
1 | attack.apply(player, ["boss", 10]); |
bind 不立即调用,而是创建一个新函数:
1 | const playerAttack = attack.bind(player, "boss"); |
核心区别:
| API | 是否立即调用 | 参数形式 | 返回值 |
|---|---|---|---|
call |
是 | 逐个参数 | 原函数执行结果 |
apply |
是 | 数组或类数组 | 原函数执行结果 |
bind |
否 | 可预置部分参数 | 新函数 |
每次 bind 都会创建不同的函数对象:
1 | attack.bind(player) !== attack.bind(player); // true |
所以事件绑定和解绑必须复用同一个函数引用。Cocos 中更适合直接使用 target 参数:
1 | onEnable() { |
4. 箭头函数没有自己的 this
箭头函数不会根据调用方式重新计算 this。它使用定义位置外层作用域中的 this:
1 | class Panel { |
箭头函数定义在 delayPrint 内部,因此捕获这次 delayPrint 调用中的 this。
换成普通函数:
1 | setTimeout(function () { |
定时器之后只是调用这个普通函数,不会替你保留 Panel 实例。
箭头函数的关键不是“自动指向当前类”,而是:
它不创建自己的
this,而是沿词法作用域向外寻找。
因此 call、apply、bind 无法改变箭头函数的 this:
1 | const arrow = () => console.log(this); |
箭头函数也不能作为构造函数:
1 | const Player = () => {}; |
5. 类方法和箭头函数属性
类里常见两种写法:
1 | class Panel { |
普通方法通常放在原型上,由实例共享:
1 | Panel.prototype.onClick |
箭头函数属性通常在每个实例创建时生成一个新函数:
1 | panelA.onClose !== panelB.onClose |
它不容易丢失 this,但每个实例都会保存自己的函数对象。
工程上可以这样选择:
- 需要原型共享、调用关系清楚:普通方法。
- 回调会被外部直接保存,且确实需要捕获实例:箭头函数属性。
- Cocos 事件支持
target:优先普通方法加第三个target。
6. 闭包不是“函数套函数”
闭包更准确的定义是:
函数与它能够访问的词法环境组合在一起。
例如:
1 | function createCounter() { |
createCounter() 已经执行结束,但返回的 next 仍然引用 count:
1 | counter |
所以 count 继续存在。
闭包的价值不是单纯“保存变量”,而是让函数携带自己的上下文:
- 创建私有状态。
- 生成带配置的函数。
- 实现回调和高阶函数。
- 保存一次流程的局部上下文。
- 避免把所有状态都挂到全局或公开对象上。
7. 闭包与类:能力重叠,表达方式不同
闭包计数器:
1 | function createCounter() { |
类计数器:
1 | class Counter { |
两者都能表达“状态 + 操作”,但组织方式不同:
| 维度 | 闭包 | 类 |
|---|---|---|
| 状态位置 | 词法环境 | 实例属性 |
| 私有性 | 天然无法从外部直接访问 | private 或 #field |
| 方法共享 | 每次工厂调用通常创建函数 | 原型方法可被实例共享 |
| 继承与类型 | 不擅长表达继承体系 | 更适合对象模型和类型关系 |
| 局部流程状态 | 很自然 | 可能显得偏重 |
| 调试观察 | 捕获环境有时较隐蔽 | 实例字段通常更直观 |
闭包能做的事情,很多都能用类重新表达;类能做的事情,也经常能用对象和闭包组合出来。
闭包没有“任何场景都胜过类”的绝对优势。它的优势是:
当状态本来就属于某次函数调用或某段局部流程时,闭包能用很少的结构把状态和行为绑在一起。
类的优势是:
当状态属于一个长期存在、需要多个方法协作、需要类型关系的实体时,类通常更清晰。
8. 闭包如何释放
闭包不需要特殊的“释放函数”。只要外部不再引用闭包函数,它和捕获环境就可能变得不可达:
1 | let counter: (() => number) | null = createCounter(); |
但如果闭包被其他系统保存:
1 | eventBus.on("update", counter); |
只执行 counter = null 不够,因为事件系统还持有它。必须先解除保存关系:
1 | eventBus.off("update", counter); |
所以释放闭包的真实问题是:
1 | 谁还保存着这个函数? |
更完整的引用链与 GC 说明见 内存与生命周期。
9. ===:不做类型转换
=== 对应严格相等语义。它先比较类型,再比较值。
不同类型通常直接不相等:
1 | 1 === "1"; // false |
同类型时:
- 数字比较数值,但
NaN与任何值都不相等,包括自己。 - 字符串比较字符序列。
- 布尔值比较
true或false。 null只严格等于null。undefined只严格等于undefined。- 对象比较引用身份。
1 | const a = { value: 1 }; |
=== 不会递归比较对象内容。它问的是:
两边是不是同一个对象?
10. ==:按规范进行类型转换
== 对应抽象相等语义。类型不同时,它会按规则尝试转换:
1 | 1 == "1"; // true |
它不是简单地“全部转成字符串”或“全部转成数字”。不同类型组合走不同规则。
常见过程包括:
1 | 对象 |
例如:
1 | [1] == 1; |
可以粗略理解为:
1 | [1] |
复杂转换容易产生反直觉结果,所以业务代码通常优先使用 ===。需要接收多种输入类型时,应主动、明确地转换:
1 | Number(input) === expected; |
11. Object.is 和 SameValueZero
=== 有两个特殊数字行为:
1 | NaN === NaN; // false |
Object.is 使用 SameValue 语义:
1 | Object.is(NaN, NaN); // true |
Map、Set、Array.prototype.includes 等常使用 SameValueZero:
1 | new Set([NaN]).has(NaN); // true |
SameValueZero 和 === 接近,但认为 NaN 与自身相等,同时不区分 0 和 -0。
因此 JavaScript 里不只有一种“相等”:
| 操作 | 类型转换 | NaN 等于自身 |
区分 0/-0 |
|---|---|---|---|
== |
是 | 否 | 否 |
=== |
否 | 否 | 否 |
Object.is |
否 | 是 | 是 |
| SameValueZero | 否 | 是 | 否 |
12. undefined:缺失或尚未提供
undefined 常见于:
- 变量声明后尚未赋值。
- 对象不存在某个属性。
- 函数没有显式返回值。
- 实参没有传入。
- 数组越界访问。
1 | let value; |
它更接近:
这个位置没有得到一个值。
13. null:明确表示这里没有对象
null 通常由程序主动写入:
1 | let currentPlayer: Player | null = null; |
它更接近:
这个位置是有定义的,但当前明确为空。
所以工程上常这样区分:
1 | undefined:缺失、未提供、尚未赋值 |
JavaScript 有一条历史兼容规则:
1 | null == undefined; // true |
但它们都不宽松等于这些值:
1 | null == 0; // false |
typeof null 返回 "object" 是 JavaScript 早期留下的历史问题,不代表 null 真的是普通对象:
1 | typeof null; // "object" |
14. ||、?? 和 ?.
|| 按真假值选择:
1 | const count = input || 10; |
如果 input 是 0、""、false,也会使用默认值。
?? 只把 null 和 undefined 当作缺失:
1 | const count = input ?? 10; |
因此:
1 | 0 || 10; // 10 |
可选链 ?. 在左侧为 null 或 undefined 时停止访问:
1 | const city = user.address?.city; |
它不会吞掉所有错误。如果 address 存在,但 getter 或后续代码主动抛错,异常仍然会继续传播。
逻辑闭环 / 锚点
把这一篇压缩成四条规则:
1 | 普通函数的 this |
最实用的工程习惯是:
- 回调前先判断普通函数是否会丢失
this。 - 事件解绑始终保留同一函数引用。
- 用类表达长期实体,用闭包表达局部流程状态。
- 默认使用
===,需要转换时显式转换。 - 用
undefined表达缺失,用null表达明确为空。 - 需要保留
0、false、空字符串时,使用??而不是||。
关联
- JavaScript 运行时:语言语义由 ECMAScript 定义,引擎负责实现。
- 内存与生命周期:闭包和事件回调如何形成真实引用链。
- 下一篇 Promise 与错误流:函数调用跨过异步边界后,返回值和异常如何继续传播。
来源:与 Codex 的对话,2026-07。