深入剖析JavaScript中的this:从底层逻辑到易错陷阱
大家好!今天,我们来聊聊JavaScript中那个让人又爱又恨的“this”。如果你是JS新手,可能觉得this像个神秘的指针,总是在关键时刻指向“错误”的地方;如果你是老手,或许已经踩过无数坑,但每次遇到复杂场景还是要停下来想想。为什么this这么“任性”?它的设计逻辑是什么?如何避开那些隐形的陷阱?
this的起源:一个“不完美”的设计决定
先来点历史背景。JavaScript诞生于1995年,那时候的Brendan Eich(JS之父)只用了10天就设计出了原型。JS最初是为了浏览器脚本而生,借鉴了Java的语法,但它更像Scheme(一种Lisp方言)的简化版。this的概念其实是从面向对象编程(OOP)中借来的:在Java或C++里,this通常指向当前对象实例,帮助你访问对象的属性和方法。
但JS不同,它没有严格的“类”概念(直到ES6引入class)。早期的JS函数特别灵活,可以作为普通函数、对象方法、构造函数等各种角色使用。这就导致了一个问题:函数内部怎么知道“自己”属于哪个对象?于是,this被引入作为“上下文指针”。
问题是,JS作者做了一个“不完美”的设计:this不是在编译阶段静态绑定的(不像词法作用域),而是在运行时动态决定的,由函数的调用方式决定。这听起来很酷,但也埋下了无数坑。想象一下,你写了一个函数,本以为this指向对象A,结果因为调用方式变了,它指向了全局window对象——boom,全局变量污染!
为什么说“不完美”?因为在OOP中,this“应该”总是指向对象本身。但JS函数太“自由”了,如果作为普通函数调用,this就“无家可归”。作者偷懒,让它默认指向全局对象(非严格模式下)。这在浏览器环境中尤其危险,因为全局对象是window,var声明的变量会直接挂在window上,容易造成命名冲突。let和const就好多了,不会污染window。
易错提醒:新手常忽略严格模式('use strict')。在严格模式下,普通函数的this是undefined,而不是window。这能避免意外的全局修改,但如果你忘了检查模式,代码在不同环境下行为不一致——测试环境OK,生产环境炸锅!
自由变量与作用域链:this的前传
要懂this,先得搞清楚变量查找机制。JS是词法作用域(Lexical Scope),意思是变量的作用域在代码编写时就决定了,而不是运行时。编译阶段,JS引擎会创建作用域链(Scope Chain),用于查找变量。
什么是自由变量?简单说,在函数内部使用,但既不是参数也不是局部变量的变量,就叫自由变量。它必须从外层作用域链中找。举个例子(基于提供的1.html代码优化):
'use strict'; // 严格模式,避免全局污染
var bar = {
myName: 'time.geekbang.com',
printName: function() {
console.log(myName); // 自由变量myName,从作用域链找
console.log(bar.myName); // 直接访问对象属性
console.log(this); // this指向调用者
console.log(this.myName); // 通过this访问
}
};
function foo() {
let myName = '极客时间'; // 局部变量
return bar.printName;
}
let myName = '极客邦'; // 全局变量
let _printName = foo(); // 返回printName函数引用
_printName(); // 普通调用,this指向undefined(严格模式),myName是全局'极客邦'
bar.printName(); // 对象方法调用,this指向bar,myName仍是全局'极客邦'
这里,printName里的myName是自由变量。查找路径:先看函数自身(无),再看外层作用域(全局),找到'极客邦'。注意,bar.myName是' time.geekbang.com',但自由变量不走this路径。
知识点扩展:作用域链是单向的,从内到外。JS引擎在编译时构建变量环境(Variable Environment)和词法环境(Lexical Environment)。自由变量的解析依赖于函数定义的位置,不是调用位置。这叫静态作用域,与this的动态绑定形成鲜明对比。
易错提醒:很多人混淆this和自由变量。this不是变量,而是关键字。它不参与作用域链查找!如果你想通过this访问属性,必须确保this指向正确对象。否则,像上面例子,console.log(myName)找的是全局,而不是bar里的属性。
执行上下文:this的“舞台”
JS执行代码时,会创建执行上下文(Execution Context),包括变量对象、作用域链和this。每个函数调用都会推入调用栈(Call Stack),生成新的上下文。
this就是在进入执行上下文时确定的,一旦确定,就不变了。这解释了为什么箭头函数的this是“继承”外层的——箭头函数没有自己的this,它用外层上下文的this。
底层逻辑:执行上下文分全局、函数和eval三种。全局上下文的this是window(浏览器)。函数上下文的this取决于调用方式。这就是动态绑定的核心。
this的四大绑定规则:逐一拆解
JS中this的指向有四种规则(优先级:new > 显式 > 隐式 > 默认)。我们用生动例子扩展每个规则,并结合提供的代码细化。
1. 默认绑定:普通函数的“无家可归”
当函数作为普通函数调用(不属于任何对象),this默认指向全局对象(非严格模式)或undefined(严格模式)。
var myObj = {
name: '极客时间',
showThis: function() {
this.name = '极客邦'; // 修改this指向的对象
console.log(this); // 取决于调用方式
}
};
var foo = myObj.showThis; // foo是函数引用,不是方法
foo(); // 普通调用,this指向window(非严格),全局name变'极客邦'
这里,foo()等同于window.foo()。结果:全局污染!
扩展:为什么这样设计?因为JS函数是“一等公民”,可以随意赋值。作者没预料到这种“脱对象”场景,就让this“默认”全局。
易错提醒:回调函数常中招。比如setTimeout(myObj.showThis, 1000),this会是window。解决:用bind或箭头函数。
2. 隐式绑定:对象方法的“自然指向”
当函数作为对象方法调用,this隐式指向该对象。
var myObj = {
name: '极客时间',
showThis: function() {
console.log(this); // {name: '极客时间', showThis: [Function]}
}
};
myObj.showThis(); // this指向myObj
简单吧?但如果多层嵌套呢?this只看最近的对象。
扩展:这是OOP的预期行为。JS用“调用栈”追踪:引擎问“谁调用了这个函数?”答案是myObj。
易错提醒:方法被解构或赋值后,隐式绑定失效,转默认绑定。像上面foo例子。
3. 显式绑定:call/apply/bind的“强制干预”
用call、apply或bind显式指定this。
function foo() {
console.log(this); // 取决于绑定
}
let bar = { myName: '极客邦' };
foo.call(bar); // this指向bar
foo.apply(bar); // 同上,apply传数组参数
let boundFoo = foo.bind(bar); // 返回新函数,永久绑定
boundFoo(); // this仍是bar
扩展:call/apply立即调用,bind返回函数。底层:引擎在执行上下文创建时,用指定对象替换this。优先级高于隐式。
易错提醒:bind后的函数不能再bind(ES5规范)。箭头函数忽略显式绑定,因为它没自己的this。
4. new绑定:构造函数的“新生儿”
用new调用函数,this指向新实例。
function Createobj(){
console.log(this)
this.name='极客时间'
}
}
var myObj = new CreateObj(); // this指向myObj
// 这里可以看做new的执行过程
// var temObj={}
// Createobj.call(temObj)
// temObj.__proto__=Createobj.prototype
// return temObj
底层逻辑:new做了四件事:1. 创建空对象;2. 设置__proto__指向构造函数prototype;3. 执行函数,this指向新对象;4. 返回对象(若函数返回非对象)。
扩展:这是模拟class的机制。ES6 class内部就是new绑定。
易错提醒:忘了new,变普通函数,this指向全局!总有开发者写Person()而不new Person(),结果全局属性乱改。
特殊场景:事件处理和箭头函数
事件处理函数的this指向绑定元素:
document.getElementById('link').addEventListener('click', function() {
console.log(this); // <a id="link">点击我</a>
});
这是浏览器DOM的约定,类似隐式绑定。
箭头函数:没有自己的this,继承外层。
例子:
let obj = {
name: '极客时间',
arrow: () => console.log(this.name), // this是外层(window)
normal: function() { console.log(this.name); }
};
obj.arrow(); // undefined(或全局)
obj.normal(); // '极客时间'
易错提醒:箭头函数在回调中超实用,但别在需要动态this的地方用(如对象方法)。
灵魂追问环节
1. 到底什么是自由变量?
自由变量:在当前作用域被使用,却既不是参数也不是本地声明的变量,它必须通过“外层词法环境一层层往上借”才能拿到。
JavaScript
function foo() {
console.log(a) // a 就是自由变量
}
let a = 100
foo()
查找路径:沿着定义时的词法环境一层层往外找(outer 指针),直到全局。
2. 自由变量这么好用,为什么还要有 this?
因为自由变量解决的是拿外层变量 的问题,而 this 解决的是在对象方法里访问当前对象自身属性/方法的问题。
想象一下如果没有 this:
JavaScript
const user = {
name: '小明',
getName: function() {
return name // 只能通过自由变量?那就乱套了!
}
}
没有 this,对象就无法“自我认知”,OOP 直接崩塌。
总的来说:
-
自由变量(词法作用域) 是为了封闭和静态数据访问。它让函数记住了定义时的环境(闭包的基础)。
-
this(动态作用域) 是为了复用和动态上下文。
- 如果没有 this,对象的方法就很难复用。比如 personA.sayHi 和 personB.sayHi,如果没有 this 指向当前实例,你需要为每个对象硬编码方法,或者显式传递对象参数。
- this 让函数可以像一个通用的工具,被不同的对象“借用”。
- 一句话总结:自由变量解决了“我在哪出生”的问题,this 解决了“谁在调用我”的问题。
3. 为什么 var 会挂到 window 上,let / const 不会?
-
机制:全局执行上下文包含两个环境记录(Environment Record):
- 对象环境记录(Object Environment Record) :绑定了全局对象(Window)。
- 声明环境记录(Declarative Environment Record) :属于新的块级作用域机制。
-
var 和 function:出于兼容性历史包袱,它们被放在了对象环境记录里。所以 var a = 1 等同于 window.a = 1。
-
let 和 const:它们被放在了声明环境记录里。它们依然是全局变量,可以在全局访问,但不会变成 window 的属性。所以 window.a 是 undefined。
-
挂载时机:只有在**全局作用域(非模块、非函数内)**使用 var 或 function 声明时,才会挂载到 window。
| 声明方式 | 是否成为全局对象(window)的属性 | 原因 |
|---|---|---|
| var | 是 | 在全局执行上下文中,var 声明会做“变量提升 + 属性挂载” |
| let/const | 否 | 位于全局的“声明死区”(Temporal Dead Zone),只存在于词法环境中,不挂到 window |
| 函数声明 | 是 | 函数声明也会挂到 window 上 |
4. 执行上下文中,outer 走什么路线?this 走什么路线?
| 项目 | 自由变量(outer 路线) | this(调用者路线) |
|---|---|---|
| 口号 | 静如处子 | 动如脱兔 |
| 决定时机 | 编译阶段(函数出生那一刻就定了) | 运行阶段(谁在调用我才知道) |
| 查找路径 | 沿着函数定义时的外层词法环境一层层往上爬 | 完全看调用方式,按优先级判断 |
| 具体路线 | 当前作用域 → outer → outer → outer → 全局 | 1. 有没有 new? 2. 有没有 call/apply/bind? 3. 有没有 obj.fn() 的点/方括号? 4. 都没有 → 非严格 window / 严格 undefined |
| 核心比喻 | 找爹:爹是天生的,改不了 | 找老板:谁发工资我跟谁 |
| 关键区别 | 出生决定命运(定义时决定) | 谁喊我我跟谁走(调用时决定) |
| 是否可被强制改变 | 不能(连 bind 都改不了) | 可以(call、apply、bind、new 都能强行改) |
| 经典口诀 | “爹是外层函数,生下来就认好了” | “谁最后点我/呼我,我就是谁的人” |
这就是为什么有人说:“作用域链是静态的,this 是动态的”。
this与执行上下文的深层联系
执行上下文是this的“容器”。全局上下文this是window。函数上下文this由规则决定。调用栈决定上下文顺序。
扩展:
你打开 Chrome DevTools,按 F11 进入函数,看左侧 Call Stack:
- 最下面一层是谁调用了当前函数 → 那个人就是 this!
- 比如栈是:anonymous → setTimeout → obj.method → 你在 method 里看到的 this 就是 window(因为 setTimeout 是普通调用)
这就是为什么很多人说:“看调用栈就能算出 this”,因为 this 就是执行上下文在创建时根据调用者算出来的!
最佳实践:避坑指南
- 用严格模式:避免默认全局。
- 优先箭头函数:在回调中固定this。
- bind一切不确定:尤其是事件和定时器。
- ES6 class:内置new绑定,少出错。
结语:this的“双刃剑”
this是JS灵活性的体现,但也因动态绑定而复杂。理解底层(作用域链 vs 调用方式),多练例子,你就能驾驭它。记住:this不是变量,是指针,由“谁调用”决定。