JavaScript 中的 `this` 与变量查找:一场关于“身份”与“作用域”的深度博弈
JavaScript 中的 this 与变量查找:一场关于“身份”与“作用域”的深度博弈
在 JavaScript 的浩瀚宇宙中,有两个概念让无数开发者爱恨交织:一个是像变色龙一样的 this,另一个是像迷宫一样的 作用域链(Scope Chain)。
很多初学者容易混淆这两者:以为 this 也是沿着作用域链查找的,或者以为变量查找会受 this 影响。事实恰恰相反:
- 变量查找:遵循词法作用域(Lexical Scope),由代码写在哪里决定(静态的)。
-
this指向:遵循动态绑定(Dynamic Binding),由代码怎么被调用决定(动态的)。
就像一个人的社会身份(this)取决于他此刻站在哪个舞台上,而他的记忆(变量查找)取决于他出生和成长的地方(代码声明的位置)。
本文将基于深度对话中的四个经典场景,从变量查找陷阱到构造函数迷局,再到 DOM 事件与调用方式的终极对比,带你彻底看透 JavaScript 的核心机制。
第一幕:错位的记忆 —— 变量查找 vs this 指向
让我们从一个极具迷惑性的代码片段开始。这段代码完美展示了**“变量去哪找”和this 指向谁**是完全平行的两条线。
var bar = {
myName: "time.geekbang.com",
printName: function() {
// 【变量查找】:沿着作用域链向上找
// 1. 函数内部有没有 myName? 没有。
// 2. 外层作用域(全局)有没有 myName? 有!值是 '极客邦'
console.log(myName); // 输出:极客邦
// 【对象属性访问】:直接访问 bar 对象的属性
console.log(bar.myName); // 输出:time.geekbang.com
// 【this 指向】:取决于调用方式
console.log(this);
console.log(this.myName);
}
}
function foo() {
let myName = '极客时间'; // 注意:这是 foo 内部的局部变量
return bar.printName; // 返回的是函数引用,带走了吗?没有!
}
// 全局变量
var myName = '极客邦';
// 获取函数引用
var _printName = foo();
// 【关键调用】:独立函数调用
_printName();
🕵️♂️ 深度剖析:当 _printName() 执行时
假设我们在浏览器环境(非严格模式)下运行 _printName(),结果如下:
-
console.log(myName)-> 输出'极客邦'- 原因:这是自由变量查找。
-
路径:函数内部找不到 -> 沿着词法作用域链向外找 -> 找到全局作用域下的
var myName = '极客邦'。 -
误区:很多人以为它会找到
foo里的'极客时间'。错!printName函数是在bar对象里定义的(全局作用域),它的“出生地”决定了它只能看到全局变量,根本看不见foo内部的let myName。哪怕它是通过foo返回的,它的作用域链依然在定义时就固定了。
-
console.log(bar.myName)-> 输出'time.geekbang.com'-
原因:这是显式的对象属性访问,与
this无关,直接读取bar对象上的值。
-
原因:这是显式的对象属性访问,与
-
console.log(this)&this.myName-> 输出Window和undefined(或全局 myName)-
原因:
_printName()是独立函数调用(前面没有点号)。 -
规则:在非严格模式下,独立调用的
this指向全局对象window。 -
结果:
this是window。window.myName的值正是全局变量'极客邦'(因为var声明的全局变量会自动挂载到window上)。
-
原因:
⚖️ 变量修改实验:let vs var 的蝴蝶效应
现在,我们来玩两个“如果”,看看世界如何改变。
实验 A:把 foo() 里的 let 换成 var
function foo() {
var myName = '极客时间'; // 换成 var
return bar.printName;
}
- 结果:毫无变化。
-
解析:无论
foo内部用let还是var,myName依然是foo的局部变量。printName函数的作用域链依然只包含它自己、全局作用域,不包含foo的执行上下文。变量查找依然跳过foo,直接找到全局的'极客邦'。
实验 B:把全局的 var myName 改为 let myName
// 全局
let myName = '极客邦'; // 换成 let
-
结果:
-
console.log(myName)-> 报错!ReferenceError: myName is not defined(如果在某些模块环境) 或者依然能访问到? -
修正解析:在全局作用域用
let声明的变量不会挂载到window对象上,但它依然在全局词法环境中。 -
console.log(myName)(第一行) -> 依然输出'极客邦'。因为变量查找是沿着词法作用域链,能找到全局let变量。 -
console.log(this.myName)(最后一行) -> 输出undefined。 -
核心差异:
this指向window,而window对象上没有myName属性(因为let不挂载到 window)。 -
结论:变量查找找到了值,但
this查找失败了。这再次证明了变量查找路径和this指向是两套完全独立的系统。
-
💡 核心洞察: 函数带走的是“代码”,不是“环境”。
printName被返回后,它依然坚守着它出生时的作用域链(全局),对foo内部的秘密(局部变量)一无所知。而this则像个墙头草,谁调用它,它就指向谁。
第二幕:身份的切换 —— 两种调用方式的终极对决
紧接着上面的代码,如果我们换一种调用方式,世界瞬间反转:
// 方式一:独立调用
_printName();
// 方式二:对象方法调用
bar.printName();
🥊 巅峰对决
| 特性 |
独立调用 (_printName()) |
对象方法调用 (bar.printName()) |
|---|---|---|
| 语法形式 | 函数名直接加括号,前面无归属 |
对象.函数名(),前面有点号 |
this 指向 |
window (非严格模式) |
bar 对象 |
this.myName |
window.myName ('极客邦') |
bar.myName ('time.geekbang.com') |
变量 myName |
依然找全局 ('极客邦') | 依然找全局 ('极客邦') |
| 本质逻辑 | 函数失去了上下文,回归默认 | 函数明确了所有者,指向调用者 |
-
_printName():就像把一个员工从公司(bar)开除,让他去大街上(全局)流浪。此时他代表的是“路人甲”(window)。 -
bar.printName():员工在公司打卡上班。此时他明确代表“极客时间官网”(bar)。
💡 核心洞察: 点号(
.)是this的开关。只要有obj.func()的形式,this就是obj。一旦把函数赋值给变量再调用(var f = obj.func; f()),点号消失,this也就迷失了。
第三幕:错位的时空 —— 构造函数中的递归迷局
除了对象方法,new 操作符是 this 的另一个重要舞台。但这里同样藏着陷阱。
function CreateObj() {
var temObj = {};
CreateObj.call(temObj); // ⚠️ 致命递归
temObj.__proto__ = CreateObj.prototype;
return temObj;
console.log(this); // 死代码
this.name = '极客时间';
}
var myObj = new CreateObj();
🚨 崩溃现场
这段代码试图在构造函数内部手动模拟 new,却导致了 栈溢出(RangeError)。
-
new的隐式魔法:执行new CreateObj()时,引擎已经创建了实例instance并绑定了this。 -
致命的递归:
CreateObj.call(temObj)并不是改变当前的this,而是开启了一次全新的函数调用。- 新调用 -> 创建新
temObj-> 再次call-> 无限循环。
- 新调用 -> 创建新
-
死代码:
return temObj导致后面的this.name永远无法执行。且因为显式返回了对象,new原本创建的instance被丢弃。
✅ 正确的“手动 New”姿势
要在外部模拟 new,必须在函数外控制:
function CreateObj() {
this.name = '极客时间'; // 这里的 this 由外部 call 决定
}
var temObj = {};
temObj.__proto__ = CreateObj.prototype;
CreateObj.call(temObj); // 只调用一次,绑定 temObj
var myObj = temObj;
💡 核心洞察:
this在函数执行瞬间即被定格。你无法在函数内部通过call篡改当前执行的this,那只会开启新的轮回。
第四幕:舞台的主角 —— DOM 事件中的本能反应
最后,来到浏览器前端。
<a href="#" id="link">点击我</a>
<script>
document.getElementById('link').addEventListener("click", function(){
console.log(this); // <a href="#" id="link">点击我</a>
});
</script>
🎭 舞台规则
在 addEventListener 的普通函数回调中:
this自动指向触发事件的 DOM 元素。
-
谁被点了?
<a>标签。 -
this是谁?<a>标签。
⚠️ 陷阱:若改用箭头函数 () => {},this 将不再指向 <a>,而是继承外层(通常是 window)。所以在处理 DOM 事件时,普通函数是首选。
🏁 终极总结:掌握 JavaScript 的双核驱动
通过这四幕大戏,我们理清了 JavaScript 中最容易混淆的两个核心机制:
1. 变量查找(静态的·出身的烙印)
- 规则:沿着词法作用域链向上查找。
- 决定因素:函数写在哪里(声明位置)。
-
特点:一旦函数定义完成,它能访问哪些变量就永久固定了,不受调用方式影响。
-
案例:
printName无论在哪儿调用,它永远只能找到全局的myName,找不到foo内部的myName。
-
案例:
2. this 指向(动态的·舞台的身份)
- 规则:看调用方式(Call Site)。
- 决定因素:函数怎么被调用。
-
四大场景:
-
独立调用 (
func()) ->window(非严格模式)。 -
方法调用 (
obj.func()) ->obj。 -
构造调用 (
new Func()) -> 新实例。 -
事件回调 (
element.addEventListener(..., function)) -> DOM 元素。 -
显式绑定 (
call/apply/bind) -> 指定的对象(开启新调用)。
-
独立调用 (
🗝️ 钥匙在手
- 如果你想访问外层变量,请关心作用域链(代码写在哪)。
- 如果你想操作当前对象,请关心
this(代码怎么调)。 -
切记:不要试图在函数内部用
call改变当前的this,那是徒劳的;也不要以为函数被传递后能带走它的局部变量环境,那也是错觉。
JavaScript 的灵活性赋予了它强大的能力,也带来了复杂性。但只要分清**“静态的作用域”与“动态的 this”**,你就能在代码的迷宫中游刃有余,写出既精准又优雅的逻辑!