深入剖析JavaScript中的this:从底层逻辑到易错陷阱

93 阅读11分钟

深入剖析JavaScript中的this:从底层逻辑到易错陷阱

大家好!今天,我们来聊聊JavaScript中那个让人又爱又恨的“this”。如果你是JS新手,可能觉得this像个神秘的指针,总是在关键时刻指向“错误”的地方;如果你是老手,或许已经踩过无数坑,但每次遇到复杂场景还是要停下来想想。为什么this这么“任性”?它的设计逻辑是什么?如何避开那些隐形的陷阱?

a8a0956310fbc3378caa4831a0918c2b.jpg

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,全局变量污染!

c67c198443515ff10bfe9640e69b50b9.jpg 为什么说“不完美”?因为在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):

    1. 对象环境记录(Object Environment Record) :绑定了全局对象(Window)。
    2. 声明环境记录(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由规则决定。调用栈决定上下文顺序。

3c33319a557e14b245a201a37aebda17.png 扩展: 你打开 Chrome DevTools,按 F11 进入函数,看左侧 Call Stack:

  • 最下面一层是谁调用了当前函数 → 那个人就是 this!
  • 比如栈是:anonymous → setTimeout → obj.method → 你在 method 里看到的 this 就是 window(因为 setTimeout 是普通调用)

这就是为什么很多人说:“看调用栈就能算出 this”,因为 this 就是执行上下文在创建时根据调用者算出来的!

最佳实践:避坑指南

  1. 用严格模式:避免默认全局。
  2. 优先箭头函数:在回调中固定this。
  3. bind一切不确定:尤其是事件和定时器。
  4. ES6 class:内置new绑定,少出错。

结语:this的“双刃剑”

this是JS灵活性的体现,但也因动态绑定而复杂。理解底层(作用域链 vs 调用方式),多练例子,你就能驾驭它。记住:this不是变量,是指针,由“谁调用”决定。