在日常开发里,几乎每个前端或者 Node.js 开发者都经历过这样一幕:代码明明写得“没毛病”,可一运行,this 突然不是自己想象的那个对象了,甚至直接变成 undefined。尤其是当箭头函数和普通函数混在一起用的时候,那种“上下文丢失”的困惑感,简直让人抓狂。今天,我们就一步步把 this 的四种绑定规则重新在代码里现场还原一遍,再仔细看看箭头函数和普通函数一旦混用,到底会闯出多大的祸。

一、先搞明白 this 到底是谁

很多初学者把 this 理解成“当前函数所在的那个对象”,其实这个说法很不靠谱。this 不是静态绑定的,它是在函数被调用的时候才确定。JavaScript 中,普通函数的 this 遵循四条基本规则,按优先级从低到高分别是:默认绑定、隐式绑定、显式绑定、new 绑定。

1.1 默认绑定

默认绑定是最简单的场景:直接以普通函数调用的方式去执行,this 指向全局对象。在浏览器里是 window,在 Node.js 的模块环境里是 global(如果开启严格模式则会得到 undefined)。

// 技术栈:JavaScript(ES6+)

// 普通函数,直接调用
function sayHi() {
  console.log(this); // 非严格模式下:浏览器 window / Node 全局对象
}

sayHi(); // 直接调用,触发默认绑定

如果在文件顶部写上 'use strict',那默认绑定就会把 this 置为 undefined,这其实是 JavaScript 设计上的一个“安全提醒”,防止你无意中去修改全局对象。

1.2 隐式绑定

当一个函数作为某个对象的方法被调用时,this 就指向这个对象。这叫隐式绑定,因为它是由调用位置“顺带有”地决定的。

// 技术栈:JavaScript(ES6+)

const user = {
  name: '张三',
  greet() {
    console.log(`你好,我是${this.name}`); // this 指向 user
  },
};

user.greet(); // 输出:你好,我是张三

看起来很简单吧?但隐式绑定有个大坑:如果你把方法单独拿出来赋值给一个变量再调用,原来的“对象上下文”就丢了。因为这时调用位置变成了“裸调用”,会掉回默认绑定。

// 技术栈:JavaScript(ES6+)

const user = {
  name: '张三',
  greet() {
    console.log(`你好,我是${this.name}`);
  },
};

const fn = user.greet; // 把方法取出来
fn(); // 输出:你好,我是 undefined(默认绑定,this 不指向 user)

这个坑在回调函数中极其常见,后面我们会看到它的“升级版”。

1.3 显式绑定

显式绑定就是利用 callapplybind 这三个方法,手动指定函数执行时的 this

// 技术栈:JavaScript(ES6+)

function introduce() {
  console.log(`我是${this.name},今年${this.age}岁`);
}

const person = { name: '李四', age: 28 };

introduce.call(person); // 我是李四,今年28岁
introduce.apply(person); // 同上

const boundIntroduce = introduce.bind(person); // bind 返回一个新函数
boundIntroduce(); // 我是李四,今年28岁

callapply 的区别只是传参方式不同:call 用逗号分隔参数,apply 用数组。bind 则不会立刻执行,而是返回一个绑定了 this 的新函数,后面用起来非常灵活。

1.4 new 绑定

当用 new 关键字调用一个函数时,这个函数会被当作构造函数,this 会指向刚刚创建的新对象。

// 技术栈:JavaScript(ES6+)

function Person(name) {
  // 这里的 this 指向新创建的空对象
  this.name = name;
  this.sayName = function () {
    console.log(this.name);
  };
}

const p1 = new Person('王五');
p1.sayName(); // 输出:王五

new 绑定的优先级最高,如果同时出现 callnewnew 说了算(当然实际代码里不会这么写)。

二、箭头函数:特立独行的“不绑定”

箭头函数是 ES6 带来的一个非常受欢迎的新特性。它写起来简洁,而且不绑定自己的 this。更准确地说,箭头函数根本没有自己的 this,它捕获的是定义时所在的外层作用域的 this。这个行为被称为“词法作用域绑定”。

2.1 箭头函数的词法作用域

所谓“词法作用域”,就是看代码写在哪里,而不是看它在哪被调用。对于普通函数,this 在调用时才决定;而箭头函数的 this 在定义时就固定了,永远等于它外层代码里面的 this

// 技术栈:JavaScript(ES6+)

const obj = {
  name: '小红',
  ordinaryFn: function () {
    // 普通函数,this 指向 obj(因为是通过 obj.ordinaryFn() 调用的)
    console.log(this.name); // 小红
  },
  arrowFn: () => {
    // 箭头函数,没有自己的 this
    // 它会在定义处向外层找,这里外层就是整个模块或全局作用域
    console.log(this.name); // undefined(取决于外层)
  },
};

obj.ordinaryFn(); // 小红
obj.arrowFn(); // undefined

注意上面这个例子,obj 对象本身并不会形成一层“作用域”,所以箭头函数里的 this 只能继续向外找,直到全局或模块作用域。很多初学者在这里翻车,以为箭头函数作为对象方法,就理所应当指向对象,其实不然。

2.2 箭头函数与普通函数对比

为了更直观,我们把普通函数和箭头函数放在同一个回调场景里看:

// 技术栈:JavaScript(ES6+)

const team = {
  members: ['小张', '小李'],
  teamName: '前端组',
  showWithOrdinary: function () {
    this.members.forEach(function (member) {
      // 普通函数作为回调,这里的 this 是 undefined(严格模式)或全局对象
      console.log(`${member} 属于 ${this.teamName}`);
    });
  },
  showWithArrow: function () {
    this.members.forEach((member) => {
      // 箭头函数没有自己的 this,会沿作用域链找到 showWithArrow 的 this
      // 而 showWithArrow 是通过 team.showWithArrow() 调用的,this 指向 team
      console.log(`${member} 属于 ${this.teamName}`);
    });
  },
};

team.showWithOrdinary();
// 小张 属于 undefined
// 小李 属于 undefined

team.showWithArrow();
// 小张 属于 前端组
// 小李 属于 前端组

这个对比非常经典。在 forEach 回调里,普通函数的 this 丢失了,而箭头函数完美捕获了外层 this。于是很多人就产生了“箭头函数比普通函数更好用”的错觉,开始到处用箭头函数,结果又踩出新的坑。

三、混用时的经典翻车现场

箭头函数和普通函数混用,并不是“谁替代谁”的问题,而是“彼此容易打架”的问题。下面我们还原三个真实的高频翻车现场。

3.1 场景一:对象方法里的箭头函数

有人为了方便,直接给对象的方法定义成箭头函数,以为这样“this 更稳定”。结果往往适得其反。

// 技术栈:JavaScript(ES6+)

const counter = {
  count: 0,
  // 想用箭头函数让 this 一直指向 counter
  // 但箭头函数没有自己的 this,它会去外层找
  // 此时对象字面量不构成作用域,所以 this 指向全局
  increment: () => {
    this.count++; // 这里 this 不是 counter
    console.log(this.count);
  },
};

counter.increment(); // 会输出 NaN 或者报错,因为全局没有 count

正确的做法是用普通函数方法,或者使用类。一旦用箭头函数定义对象方法,这个对象就彻底失去了“自我指涉”的能力。

3.2 场景二:回调函数里普通函数与箭头函数混用

这是最让人头疼的场景。当一个普通函数内部的回调里,又用了普通函数,然后又想用箭头函数去修正,结果整个 this 链变得错综复杂。

// 技术栈:JavaScript(ES6+)

const app = {
  data: [1, 2, 3],
  multiplier: 2,
  calculate: function () {
    // 这里 this 是 app,没问题
    return this.data.map(function (item) {
      // 普通函数,this 丢失了
      // 于是想用箭头函数修正?
      // 但这里已经丢失了,箭头函数也救不回来,因为箭头函数捕获的是当前的 this(已经是 undefined/全局)
      return item * this.multiplier;
    });
  },
};

app.calculate(); // 报错:Cannot read properties of undefined

这个例子中,map 回调里的普通函数把 this 变成了全局或 undefined,你如果在该普通函数内部再写一个箭头函数去访问 this,它捕获到的就是这个普通函数的 this(已经丢了)。所以“套娃式”的混用只会把混乱放大。

再来看一个“看似修复”实则无效的写法:

// 技术栈:JavaScript(ES6+)

const app = {
  data: [1, 2, 3],
  multiplier: 2,
  calculate: function () {
    const self = this; // 保存外部 this
    return this.data.map(function (item) {
      // 使用 self 而不是 this
      return item * self.multiplier;
    });
  },
};

console.log(app.calculate()); // 正常输出 [2,4,6]

这里用 self 成功保住了上下文,但很多人会问:为什么不用箭头函数呢?因为箭头函数在这里其实也可以直接解决问题,前提是你使用箭头函数,而不是在普通函数内部再套箭头函数。

// 技术栈:JavaScript(ES6+)

const app = {
  data: [1, 2, 3],
  multiplier: 2,
  calculate: function () {
    // 直接使用箭头函数,捕获 calculate 的 this(也就是 app)
    return this.data.map((item) => item * this.multiplier);
  },
};

console.log(app.calculate()); // [2,4,6]

正确的混用姿势应该是:外层普通函数负责接收对象上下文,内层用箭头函数保持这个上下文不丢失。如果内外都用普通函数,就要借助 bindself

3.3 场景三:类中的方法

ES6 的类中,方法默认是普通函数,但如果结合箭头函数定义实例属性,会出现一些微妙的行为。

// 技术栈:JavaScript(ES6+)

class Button {
  constructor(label) {
    this.label = label;
  }

  // 普通方法
  onClick() {
    console.log(`点击了 ${this.label}`);
  }

  // 箭头函数作为实例属性
  onHover = () => {
    console.log(`悬停在 ${this.label}`);
  };
}

const btn = new Button('登录');
btn.onClick(); // 点击了 登录
btn.onHover(); // 悬停在 登录

// 但是!如果将 onClick 解构出来调用
const { onClick, onHover } = btn;

onClick(); // 输出:点击了 undefined(this 丢失)
onHover(); // 输出:悬停在 登录(箭头函数保留了 this)

在类中,箭头函数字段是在构造函数初始化时绑定的,并且会捕获实例的 this。它非常适合做事件回调,因为不管你怎么把方法传来传去,this 都不会丢。而普通方法必须使用 bind(btn) 才能达到同样的效果。

// 技术栈:JavaScript(ES6+)

const btn = new Button('提交');
const onClickBound = btn.onClick.bind(btn);
onClickBound(); // 点击了 提交

这就是混用时最大的差别:普通函数需要手动绑定,箭头函数天生绑定。如果你在同一个类里一个用普通函数、一个用箭头函数,后面维护的人一旦不注意调用方式,就会踩雷。

四、如何救场

了解了这些坑,我们心里大概有数了。救场方案无非是三种:保存 this、显式绑定、统一风格。

4.1 保存 this 到变量

这是最传统也最保险的方法,尤其在嵌套回调比较多的时候,用 self_this 保存上下文。

// 技术栈:JavaScript(ES6+)

const device = {
  name: '打印机',
  showStatus: function () {
    const self = this; // 保存外层 this
    setTimeout(function () {
      // 在 setTimeout 回调里,普通函数的 this 已经变了
      console.log(`设备 ${self.name} 正在工作`); // 用 self 访问正确对象
    }, 100);
  },
};

device.showStatus(); // 设备 打印机 正在工作

这种方式清晰、易读,是“救场”的兜底方案。

4.2 使用 bind / call / apply

显式绑定的最大好处是明确表达“我要把 this 指向谁”。在回调场景中,可以用 bind 直接生成一个绑定后的函数。

// 技术栈:JavaScript(ES6+)

const timer = {
  total: 0,
  add: function (amount) {
    this.total += amount;
    console.log(this.total);
  },
};

// 用 bind 固定 this
const addFive = timer.add.bind(timer, 5);
setTimeout(addFive, 100); // 1秒后输出 5

在 React 类组件时代,这种写法非常常见。现在函数式组件多了,但还是有大量场景需要用到 bind

4.3 统一使用箭头函数或普通函数

最佳策略其实是:在同一个上下文链中,尽量统一使用同一类函数,不要一会儿箭头一会儿普通。

  • 如果你在写一个类,需要把方法当回调传给别处,那就用箭头函数字段。
  • 如果你在写对象方法,而且需要通过 this 访问对象属性,请用普通函数。
  • 如果你在写一个独立函数,不关心 this 是谁,那就用普通函数或者箭头函数都行。

以对象方法为例,统一使用普通函数,配合箭头回调,是最推荐的做法。

// 技术栈:JavaScript(ES6+)

const store = {
  items: [10, 20, 30],
  discount: 0.9,
  applyDiscount() {
    // 外层普通函数:this 指向 store
    return this.items.map((item) => item * this.discount); // 内层箭头函数捕获外层 this
  },
};

console.log(store.applyDiscount()); // [9, 18, 27]

五、应用场景与优缺点

5.1 适合用箭头函数的场景

箭头函数特别适合那些不需要自己的 this、又需要穿透外层的场景,比如:

  • 数组的 mapfilterforEach 等回调。
  • Promise 链中的回调。
  • 类中需要被解构出来的事件处理器(React 类组件的自定义方法)。
  • 需要函数体非常简洁的函数表达式。

优点很明显:

  • 代码简洁,没有 function 关键字和 return 的噪音。
  • 不创建自己的 this,避免了回调中 this 丢失的常见问题。
  • 没有 arguments 对象(也不是好事),但实际用起来更安全。

缺点也很明显:

  • 无法被 callapplybind 改变 this(因为它们绑定的是箭头函数外层的 this)。
  • 不能作为构造函数,不能使用 new
  • 在对象方法中完全没有用处,因为无法指向对象本身。
  • 如果要获取函数参数,需要改用剩余参数 (...args)

5.2 适合用普通函数的场景

普通函数适合一切需要动态 this 的场景,比如:

  • 构造函数(配合 new)。
  • 对象的内部方法,需要访问对象本身。
  • 需要利用 arguments 对象时。
  • 需要通过 callapply 实现“借用方法”时。

优点在于:

  • this 灵活,可以根据调用方式动态变化。
  • 可以作为构造函数。
  • 拥有完整的 arguments
  • 可以被 callapplybind 控制。

缺点是:

  • 在回调中容易丢失 this
  • 嵌套时容易出现“灵异现象”。
  • 需要额外的绑定操作或使用 self 变量。

5.3 混用注意事项

混用不是罪,但你需要遵守几条铁律:

  1. 谁定义,谁负责:箭头函数捕获定义时的 this,所以要注意它定义时所在的作用域是谁。
  2. 普通函数内部的箭头函数,捕获的是普通函数的 this,如果普通函数的 this 已经丢失,箭头函数也救不了。
  3. 不要试图在对象字面量的方法里使用箭头函数,除非你很清楚这个世界在做什么。
  4. 类中混合使用箭头函数和普通方法时,要意识到它们在 this 绑定上的本质区别
  5. bind 与箭头函数不能混用:对箭头函数执行 bind 是没有效果的。

再来看一个正反结合的对比示例:

// 技术栈:JavaScript(ES6+)

const cat = {
  name: '小花',
  // 错误示范:箭头函数做对象方法
  badShout: () => {
    console.log(`${this.name}喵喵叫`);
  },
  // 正确示范:普通函数做对象方法
  goodShout() {
    console.log(`${this.name}喵喵叫`);
  },
  // 混合场景:普通方法内用箭头函数
  lovelyShout() {
    const shout = () => {
      console.log(`${this.name}喵喵叫`);
    };
    shout();
  },
};

cat.badShout(); // undefined喵喵叫
cat.goodShout(); // 小花喵喵叫
cat.lovelyShout(); // 小花喵喵叫

六、总结

this 的四种绑定规则是普通函数世界的基石,箭头函数则带来了一个新的维度:它不参与游戏,而是直接“抄写”外层作用域的答案。两者混用时的困惑,根源在于我们常常忘记箭头函数的“无我”本质。实际开发中,最好的策略不是死记规则,而是在脑海中对每一次函数调用都清晰地问一句:这里的 this 是通过哪种方式决定的?是调用点,还是定义点?当我们把这个问题想透了,再看到箭头函数和普通函数出现在一起,也就不会再发怵了。

最后送你一句实在话:写代码时保持风格统一,能少掉一半的头发。如果你在一个函数链里发现 this 不对劲,先别急着加箭头函数,把整条调用链从头到尾看一遍,通常就能找到那个“偷走 this”的普通函数回调了。