深入执行上下文:JavaScript 中 this 的底层绑定机制

深入理解 JavaScript 中的 this:设计初衷、绑定规则与常见陷阱

在 JavaScript 开发中,this 是一个既基础又容易让人困惑的概念。它看似简单,却因绑定规则依赖于函数调用方式 而非声明位置 ,常常导致意料之外的行为。本文将从 this 的本质出发,结合语言设计背景、执行机制以及实际代码示例,系统性地梳理 this 的行为规律,并为后续讨论"绑定丢失"问题预留空间。


从自由变量说起:为什么需要 this

在深入 this 之前,不妨先回顾一个更基础的概念:自由变量(free variable)

考虑如下代码:

js 复制代码
var name = "全局";

function greet() {
  console.log("你好," + name);
}

greet(); // 你好,全局

函数 greet 内部使用了变量 name,但它并未在函数内部声明。这个 name 就是一个自由变量 。JavaScript 引擎会沿着词法作用域链(Lexical Scope Chain) 向外查找,最终在全局作用域中找到 name 的定义。

这种机制是静态的------变量的查找路径在代码书写时就已确定,与函数如何被调用无关。这也是 JavaScript 中绝大多数变量访问的行为模式。

然而,面向对象编程带来了一个新需求:同一个函数可能属于多个对象,希望在运行时动态地知道"当前是哪个对象在调用我"

例如:

js 复制代码
var person1 = { name: "Alice", sayHi: greet };
var person2 = { name: "Bob",   sayHi: greet };

person1.sayHi(); // 期望输出:你好,Alice
person2.sayHi(); // 期望输出:你好,Bob

如果 greet 依然依赖词法作用域中的 name,它永远只能访问到全局的 "全局",而无法感知调用者是谁。词法作用域在此失效了

于是,JavaScript 引入了 this ------ 一个不依赖词法作用域、而由调用方式决定的特殊关键字。它让函数能够在运行时动态获取"调用上下文",从而实现对所属对象的自引用。

换句话说:自由变量靠"写在哪"决定值,this 靠"怎么调"决定值

正是这种设计,使得 this 成为了 JavaScript 执行模型中一个独特而关键的存在------它打破了静态作用域的规则,引入了动态上下文的能力,但也因此带来了理解上的挑战。

this 是什么?

在 JavaScript 中,this 是一个运行时绑定的上下文对象引用 。它不是一个变量,而是一个关键字,其值在函数被调用时动态确定,取决于函数是如何被调用的,而不是在哪里定义的。

这与 JavaScript 中其他变量(如自由变量)的查找机制截然不同------后者遵循词法作用域(Lexical Scope) ,由函数声明的位置决定;而 this 则完全由调用方式 决定,属于动态作用域的一种体现。


this 的设计初衷

JavaScript 最初被设计为一种轻量级脚本语言,用于在浏览器中操作 DOM。为了支持面向对象编程(OOP),即使在没有 class 的早期版本中,也需要一种机制让函数能够访问所属对象的属性和方法。

于是,this 被引入:当一个函数作为对象的方法被调用时,this 自动指向该对象 。这样,开发者就可以在方法内部通过 this.xxx 访问对象自身的数据。

然而,由于 JavaScript 函数是一等公民(first-class citizens),可以被赋值、传递、独立调用,这就导致同一个函数在不同调用场景下 this 指向可能完全不同------这种灵活性也带来了复杂性。


varlet 声明对全局对象的影响

这一点与 this 的默认绑定密切相关:

  • 使用 var 在全局作用域声明的变量,会自动挂载到全局对象 上(如 window.myVar = ...)。
  • 使用 letconst 声明的变量则不会挂载到全局对象
js 复制代码
var a = 1;
let b = 2;

console.log(window.a); // 1
console.log(window.b); // undefined

因此,在非严格模式下,若 this 指向 window,通过 this.a 可以访问到 var a,但无法访问 let b。这也解释了为什么在某些代码中 this.xxx 能"神奇地"访问到全局变量------其实是访问了挂载在 window 上的属性。

使用var声明挂载变量到window对象上并不是一件好的事情,他会污染全局环境


、JavaScript 执行机制与 this 的"例外性"

JavaScript 引擎在执行代码前会经历编译阶段 (包括词法分析、作用域构建等)。变量和函数的作用域链 在编译阶段就已确定,这就是词法作用域的基础。

然而,this 是一个例外 :它的值无法在编译阶段确定 ,必须等到运行时 根据调用栈和调用方式动态计算。这意味着:

  • 即使两个完全相同的函数体,只要调用方式不同,this 就可能指向完全不同的对象。
  • this 与作用域链无关,它属于执行上下文(Execution Context) 的一部分,而非词法环境。

this 指向的几种典型情况

根据调用方式,this 的绑定可分为以下几类:

1. 作为对象的方法调用

js 复制代码
var myObj = {
    name:"极客时间",
    showThis:function(){
        console.log(this);//this->myObj
    }
}
    myObj.showThis();

作为对象的方法调用时,它指向调用该函数的对象

2. 作为普通函数调用

js 复制代码
function print() {
  console.log(this); // 非严格模式:window;严格模式:undefined
}
print();

作为普通函数调用时,它指向全局对象window(非严格模式)/undefined(严格模式)

3. 构造函数调用

js 复制代码
function CreateObj(){
            // var tempObj = {};
            //CreateObj.call(tempObj);
            //tempObj.__proto__ = CreateObj.prototype;
            //return tempObj;
            console.log(this);
            this.name="极客时间";

        }
        var myObj = new CreateObj();
        console.log(myObj);

作为构造函数调用时,它指向当前的构造函数的实例化对象

4. 使用 call / apply 绑定this

js 复制代码
 let bar ={
            myName:"极客邦",
            test:1
        }
        function foo(){
            this.myName="极客时间";
        }
        // 接受指定this为第一个参数,并运行
        foo.call(bar);// this 被指定为bar 
        // 和call 一样
        foo.apply(bar);// this 被指定为bar 
        console.log(bar);

call和apply都能够改变this的指向,他们接受指定this为第一个参数,我的理解:你可以认为指定一个对象来调用这个函数。值得注意的是,在这段代码中二者似乎是等价的,但实际上二者在参数上有差异,在这里就不深入讨论

5. 事件处理函数中的 this

在 DOM 事件监听器中,this 默认指向触发事件的元素

js 复制代码
<a href="#" id="link">点击我</a>
    <script>
        document.getElementById("link").addEventListener("click",function(){
            console.log(this);
        })

触发事件后,可以看到,this指向的是当前触发改事件的DOM元素


、this的绑定规则

默认绑定

一般存在于最常用的函数调用类型;独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则

考虑这样一段代码:

js 复制代码
function foo(){
console.log(this.a)
}
var a =2;
foo();//2

我们能看到,当我们调用foo()函数时,this.a被解析成了全局变量a 。这是为什么?是的,这就是我们说的默认绑定,this指向全局对象,当然这必须是在非严格模式下,严格模式下则会绑定到undefined。

一句话总结默认绑定:非严格模式下,当函数独立调用时,this自动绑定到全局对象上

⚠️ 这种设计其实暴露了早期 JavaScript 的一个"历史包袱":在非严格模式下,意外的全局 this绑定可能导致隐式创建全局变量,污染全局命名空间。

隐式绑定

当函数调用的位置存在上下文对象,或者说该函数被调用时被某个对象"拥有"或"包含",隐式绑定会把函数调用中的this绑定到这个上下文对象

考虑这样一段代码:

js 复制代码
function foo ()
{
console.log(this.a);
}
var obj ={
a:2,
foo:foo// 实际上是对foo的引用

}
obj.foo()//2

我们能看到 foo()函数调用时,能够访问到obj的内部属性a,这是因为它由obj调用,所以它被obj所包含。
值得注意的是,对象引用链中只有上一层或者说最后一层在调用位置起作用

js 复制代码
function foo ()
{
console.log(this.a);
}
var obj2 ={
a:42,
foo:foo
}
var obj1 ={
a:2,
obj2:obj2
}
obj1.obj2.foo();//42

因为最后调用foo的是obj2,所以 foo 的 this.a 指向 obj 2中的 a

显式绑定

JS中绝大多数函数以及你自己创建的函数,都可以使用call()和apply()方法,你可以使用他们来直接指定this的绑定对象,因此我们称为显示绑定

考虑这样一段代码

js 复制代码
function foo ()
{
console.log(this.a);
}
var obj ={
a:2
}
foo.call(obj);//2

按照前面的理解:foo()在全局中被调用,那么this应该默认被绑定到全局,但是这里却能够访问到obj中的a,这就是 call() 的作用 -->我们可以在调用foo时强制把它的this绑定到obj上

从this绑定的角度出发,call()和apply()是一样的,都用来强制绑定this到指定对象,他们的区别体现在其他参数上,我们这里不考虑

如果你传入了一个原始值(字符串,布尔值,数字)来当作this的绑定对象,这个值会被转换为它的对象形式(new String(),new Boolean(),new Number())。这个过程被称为"装箱"

new绑定

使用new来调用foo()时,我们会构造一个新对象并把它绑定到foo()调用中的this上。我们称为new绑定

首先我们需要重新定义JS中的构造函数-->构造函数只是一些使用new操作符时被调用的普通函数。

所以实际上,并不存在所谓的构造函数,只有对于函数的构造调用

接下来考虑这样一段代码:

js 复制代码
function foo (a)
{
  this.a = a;
}
var bar =new foo(2);
console.log(bar.a);//2

当我们使用new时,就会自动执行这样一些操作:

  1. 创建一个新对象{}
  2. 新对象被执行与[[prototype]]连接
  3. 将函数调用的this绑定到该对象
  4. 如果没有返回其他对象,则自动返回这个新对象

所以上述代码的实际底层是:

js 复制代码
function foo (a)
{
 var bar ={};
 bar.__proto__ = foo().prototype
 foo.call(bar);
 return bar;
  this.a = a;
}

绑定丢失

隐式丢失

一个常见的绑定问题就是被隐式绑定的函数会丢失绑定对象,会重新应用为默认绑定,从而使得this绑定到全局会undefined

js 复制代码
function foo(){
console.log(this.a)
}
var obj = {
  a:2,
  foo:foo
  
};


var bar = obj.foo;//函数别名
var a = "global";
bar();//global

这里导致绑定丢失的原因是bar实际上也是对foo的引用,而当bar()调用时,它其实是一个函数的独立调用,所以执行了默认绑定

再看另外一种情况,在传入回调函数时:

js 复制代码
function foo(){
console.log(this.a)
}
function doFoo(fn)
{
    fn();
}
var obj = {
  a:2,
  foo:foo
  
};

// 把方法赋值给一个变量 ------ 绑定丢失!
var bar = obj.foo;//函数别名
var a = "global";
doFoo(obj.foo);//global

这样同样导致了绑定丢失,那么造成这种情况的原因是?

foo()函数的执行实际上转交由doFoo来执行了,而在它的执行上下文中没有a这个变量,所以沿着作用域链查找到全局中的a

这样的绑定丢失的核心是:回调函数的执行权被移交到了其他函数手中

即使是显示绑定也无法避免绑定丢失

js 复制代码
function foo() {
  console.log("this.a =", this.a); // 期望this指向obj,输出2
}

var obj = { a: 2 };

// 定义一个接收回调的函数
function doCallback(callback) {
  callback(); // 这里执行回调,call的绑定丢失
}

// 用call显式绑定foo的this到obj,作为回调传递
doCallback(function() {
  foo.call(obj); // 看似绑定了obj
});

// 改造:故意制造绑定丢失(更直观)
function doLostBind(callback) {
  // 模拟实际场景中对回调的二次调用,绑定丢失
  const temp = callback;
  temp(); // 执行时丢失原call绑定
}

// 传递用call绑定的函数,最终绑定丢失
doLostBind(foo.call.bind(foo, obj)); // 非严格模式下输出this.a = undefined(指向window)

doLostBind(foo.call.bind(foo, obj))这种写法看似传入时做了绑定,但其实这只是生成了一个"准备绑定的函数",并没有真正的执行绑定逻辑


怎么解决绑定丢失的问题?

  • 硬绑定
javascript 复制代码
function foo(){
console.log(this.a)
}
var obj = {
  a:2,
};

var bar = function () {
    foo.call(obj);
    
}
bar();//2
setTimeout(bar,2);//2

bar.call(window);//2
  1. 在bar函数的内部,我们把foo的this强制绑定在了obj上,无论之后怎么调用bar,他都会手动在obj上调用foo
  2. 硬绑定不可能再修改它的this ,我们想要通过bar.call(window); 修改绑定对象,但无论你怎么修改,最后都会执行 foo.call(obj);把this重新绑定到obj上

由于硬绑定是一种很常用的模式,所以ES5提供了它的内置方法bind(),用法如下

js 复制代码
function foo(temp){
console.log(this.a,temp);
return this.a+ temp
}
var obj = {
  a:2,
};


var bar = foo.bind(obj)
var b = bar(3);//2 3
console.log(b);// 5

bind()会返回一个硬编码的新函数,他会把你指定的参数设置为this的上下文并调用原始函数

结语

this 是 JavaScript 中一个强大但需要谨慎使用的机制。理解它的设计初衷、绑定规则以及与作用域系统的差异,是写出健壮、可维护代码的关键。掌握 this,不仅有助于避免常见 bug,也能更深入地理解 JavaScript 的执行模型。

在下一篇文章中,我们将了解更多关于this的底层

相关推荐
DsirNg1 小时前
Vue 3:我在真实项目中如何用事件委托
前端·javascript·vue.js
克喵的水银蛇1 小时前
Flutter 适配实战:屏幕适配 + 暗黑模式 + 多语言
前端·javascript·flutter
冬男zdn1 小时前
Next.js 16 + next-intl App Router 国际化实现指南
javascript·typescript·reactjs
有意义1 小时前
this 不是你想的 this:从作用域迷失到调用栈掌控
javascript·面试·ecmascript 6
风止何安啊2 小时前
别被 JS 骗了!终极指南:JS 类型转换真相大揭秘
前端·javascript·面试
拉不动的猪2 小时前
深入理解 Vue keep-alive:缓存本质、触发条件与生命周期对比
前端·javascript·vue.js
over6972 小时前
深入理解 JavaScript 原型链与继承机制:从 instanceof 到多种继承模式
前端·javascript·面试
烂不烂问厨房2 小时前
前端实现docx与pdf预览
前端·javascript·pdf
GDAL2 小时前
Vue3 Computed 深入讲解(聚焦 Vue3 特性)
前端·javascript·vue.js