项目里最常见的 5 个 this 指向坑:从规则到实战彻底讲透

this 是 JS 面试必考第二题(第一题是闭包),也是业务代码里 bug 率最高的语法之一。

老项目维护中,this 是当之无愧的 bug 高发区。最夸张的一次:点击"删除"按钮,删了隔壁同事的数据,排查 2 小时才发现是事件回调里的 this 指丢了,取错了当前行 ID。

这篇把项目里真实踩过 的 5 个 this 坑 + call/apply/bind/new 的用法与区别 + 手写 5 件套(call/apply/bind/new/instanceof)合在一起讲,看完能直接拿去查老项目的 bug。

一、先搞懂:this 的 4 条核心绑定规则

先把规则说清楚,后面的坑全是规则没遵守的结果。

绑定方式 this 指向 优先级
new 绑定 新创建的实例对象 1(最高)
显式绑定(call/apply/bind) 指定的第一个参数 2
隐式绑定(obj.fn()) 调用时前面的对象 3
默认绑定(直接 fn()) 严格模式 undefined,非严格 window/globalThis 4(最低)

💡口诀:new > 显式(call/bind) > 隐式(点调用) > 默认 。 ⚠️箭头函数不遵循以上规则,它的 this 在定义时就继承外层,永远不会变。

二、5 个 this 坑,你遇到过几个?


坑 1:对象方法赋值给变量,this 直接丢了

这是项目里最高发的坑,没有之一

js 复制代码
// ❌ 老项目真实代码(简化)
const userService = {
  name: '张三',
  login() {
    console.log(this.name + ' 登录了')
  }
}

// 重构时偷懒,把方法抽出来赋值
const btnHandler = userService.login

// 点击按钮触发
document.getElementById('btn').addEventListener('click', btnHandler)
// 控制台:undefined 登录了(非严格模式是 window.name 登录了)

为什么丢了? 隐式绑定要求:调用时必须写成 obj.fn() 这种格式 。你把 fn 赋值给变量再调用,此时调用方式是 btnHandler(),属于默认绑定,this 就没了。

解法

js 复制代码
// 解法 1:bind 锁死 this(事件回调最常用)
const btnHandler = userService.login.bind(userService)

// 解法 2:包一层箭头函数(本质还是利用外层 this)
const btnHandler = () => userService.login()

// 解法 3:在对象里直接把方法改成箭头函数(注意副作用,看坑 5)
const userService = {
  name: '张三',
  login: () => console.log(this.name + ' 登录了')
}

💡我重构老项目的通用动作:把所有抽出来的事件回调全部加一遍 .bind(目标对象),先止血再慢慢优化。


坑 2:数组方法(map/forEach/filter)回调里的 this

js 复制代码
// ❌ 需求:把列表里名字前面加上当前登录人前缀
const app = {
  prefix: '【已复核】',
  users: ['张三', '李四'],

  formatUsers() {
    return this.users.map(function (u) {
      return this.prefix + u  // this 是谁?
    })
  }
}

console.log(app.formatUsers())
// 结果:["undefined张三", "undefined李四"]

为什么错? map 回调是个普通匿名函数,它的 this 走默认绑定 ,不是外层 formatUsers 的 this。这是新手写业务最容易抄错的地方。

解法(挑一个)

js 复制代码
// 解法 1:箭头函数(推荐,ES6 以后最省事)
formatUsers() {
  return this.users.map(u => this.prefix + u)
}

// 解法 2:map 的第二个参数传 this
this.users.map(function (u) {
  return this.prefix + u
}, this)  // ← 这里的 this 就是 app

// 解法 3:老项目兼容写法,在外层存一下 that
const that = this
this.users.map(function (u) {
  return that.prefix + u
})

坑 3:事件监听 + setTimeout 双杀,this 两层全丢

js 复制代码
// ❌ 需求:点击按钮 1 秒后显示当前操作人
const page = {
  user: '张三',
  bindEvent() {
    document.getElementById('btn').addEventListener('click', function () {
      setTimeout(function () {
        console.log(this.user + ' 操作成功')  // this 两次全丢
      }, 1000)
    })
  }
}

page.bindEvent()
// 1 秒后输出:undefined 操作成功

为什么错?

  • 第一层:事件回调是普通函数 → this 指向 DOM 元素(#btn)
  • 第二层:setTimeout 回调是普通函数 → this 指向 window(非严格)

解法:外层一个箭头函数解决两层

js 复制代码
const page = {
  user: '张三',
  bindEvent() {
    // 事件回调改成箭头函数,继承 bindEvent 的 this(即 page)
    document.getElementById('btn').addEventListener('click', () => {
      // setTimeout 再改成箭头函数,继承外层箭头函数的 this(还是 page)
      setTimeout(() => {
        console.log(this.user + ' 操作成功')
      }, 1000)
    })
  }
}

⚠️口诀:回调函数里要用 this?默认全写箭头函数,99% 的情况不会错。 剩下 1% 是坑 5。


坑 4:构造函数里 return 对象,this 被覆盖

js 复制代码
// ❌ 老项目里为了偷懒写的工厂函数
function User(name) {
  this.name = name

  // 想顺手返回一个默认对象
  return {
    type: '普通用户'
  }
}

const u = new User('张三')
console.log(u.name)  // undefined
console.log(u.type)  // '普通用户'

为什么? new 绑定有个隐藏规则:如果构造函数显式 return 了一个对象,那 this 就被这个返回对象覆盖掉了。(基本类型 return 不影响)

修正:要么不 return,要么返回 this。

js 复制代码
function User(name) {
  this.name = name
  // 要么干脆不写 return,要么返回 this
  return this
}

坑 5:对象方法里滥用箭头函数,原型链直接失效

js 复制代码
// ❌ 为了解决坑 1,直接把方法全写成箭头函数,结果踩另一个坑
const obj = {
  name: '张三',
  sayHi: () => {
    console.log(this.name)  // this 是谁?
  }
}
obj.sayHi() // undefined(严格模式)或 window.name

为什么? 箭头函数在定义时 就锁定外层 this。obj 是个对象字面量,它所在的外层作用域不是 obj,而是全局作用域。所以 sayHi 的 this 永远是全局。

更严重的副作用:原型链没法继承

js 复制代码
// ❌ Vue2 组件里如果把 methods 写成箭头函数,this 直接不是 vm 实例
export default {
  data() { return { count: 0 } },
  methods: {
    // 这一写:ESM 模块(export default 必然在 ESM 里)严格模式下 this=undefined;
    // 若是普通脚本非严格模式,this 才是全局对象。总之拿不到 vm 实例,this.count 全崩
    increment: () => this.count++
  }
}

修正:普通函数就用普通函数写法,别全换箭头。

js 复制代码
export default {
  methods: {
    // 老老实实写普通函数,Vue 会自动绑定 this
    increment() { this.count++ }
  }
}

💡this 使用原则:对象/类方法、methods、构造函数 → 普通函数;回调函数 → 箭头函数。别反过来写。

三、call / apply / bind / new:用法 + 区别一网打尽

搞懂了坑,再来看这 4 个跟 this 绑定直接相关的 API------它们既是显式绑定 this 的工具,也是面试手写题的常客。先把用法 讲透,再补一张区别对照表,最后再去手写实现就顺理成章了。


1. Function.prototype.call(thisArg, arg1, arg2, ...)

作用 :立即执行函数,显式指定 this ,参数逐个传(逗号分隔)。

js 复制代码
// 基础用法:把 introduce 的 this 绑到 zhangsan 上
function introduce(age, city) {
  console.log(`我是${this.name},${age}岁,来自${city}`)
}

const zhangsan = { name: '张三' }

// 立即执行,this=zhangsan,参数 28 和 '北京' 逐个传
introduce.call(zhangsan, 28, '北京')
// 输出:我是张三,28岁,来自北京

典型场景

  • 借用其他对象的方法(经典面试题:类数组转数组)
js 复制代码
// 类数组 arguments 本身没有 slice 方法,借用 Array.prototype.slice
function sum() {
  const arr = Array.prototype.slice.call(arguments) // 转成真数组
  return arr.reduce((s, n) => s + n, 0)
}
console.log(sum(1, 2, 3)) // 6

2. Function.prototype.apply(thisArg, [argsArray])

作用 :和 call 一模一样------立即执行 + 指定 this唯一区别 :参数必须是数组(或类数组)

js 复制代码
function introduce(age, city) {
  console.log(`我是${this.name},${age}岁,来自${city}`)
}

const lisi = { name: '李四' }

// this=lisi,参数必须包在数组里传
introduce.apply(lisi, [32, '上海'])
// 输出:我是李四,32岁,来自上海

典型场景:参数本身就是数组时,用 apply 更方便。

js 复制代码
// 场景 1:求数组最大值(Math.max 本身不接数组,只接逐个参数)
const nums = [3, 7, 2, 9, 5]
console.log(Math.max.apply(null, nums)) // 9  (等价于 Math.max(...nums))

// 场景 2:把一个数组 push 进另一个数组(ES6 前的写法)
const arr1 = [1, 2]
const arr2 = [3, 4]
Array.prototype.push.apply(arr1, arr2)
console.log(arr1) // [1, 2, 3, 4]

3. Function.prototype.bind(thisArg, arg1, arg2, ...)

作用不立即执行 ,而是返回一个新函数 ,新函数的 this 被永久锁死为 thisArg,同时可以预设部分参数(函数柯里化)。

js 复制代码
function introduce(age, city) {
  console.log(`我是${this.name},${age}岁,来自${city}`)
}

const wangwu = { name: '王五' }

// bind 不执行,返回一个新函数
const bound = introduce.bind(wangwu, 25) // 预设第一个参数 age=25
bound('深圳')   // 只需传剩下的参数 city
// 输出:我是王五,25岁,来自深圳

// 再 call/apply 也改不回去了------bind 是"焊死"this
bound.call({ name: '赵六' }, 40, '广州')
// 依然输出:我是王五,25岁,来自广州  (this 和 age 没变,只改了 city)

典型场景 :事件回调预先锁 this(坑 1 的解法 1)、函数参数预设。

js 复制代码
const userService = {
  name: '张三',
  login() { console.log(this.name + ' 登录了') }
}

// 抽出来当事件回调时,bind 锁死 this,后面谁调用都不会丢
document.getElementById('btn').addEventListener('click', userService.login.bind(userService))

⚠️ bind 会返回新函数 ,所以多次 bind 只有第一次生效:fn.bind(a).bind(b) 最终 this 还是 a


4. new 关键字

new 不是函数方法,而是 JS 的运算符。它的核心作用是创建实例,并且优先级最高(new 绑定 > 显式绑定)。

js 复制代码
function User(name, age) {
  this.name = name
  this.age = age
}

// new 调用:创建一个新对象,把函数的 this 绑到这个新对象上
const u = new User('张三', 28)
console.log(u.name) // 张三
console.log(u.age)  // 28

new 背后干了 4 件事(手写 new 时要照着实现):

  1. 创建一个空对象 obj
  2. obj.__proto__ 指向构造函数的 prototype(原型链接上)
  3. 执行构造函数,this 绑定到 obj
  4. 如果构造函数没返回对象,就自动返回 obj;如果返回了对象,就用返回的那个(坑 4)

5. 一张表看懂 4 者区别

特性 call apply bind new
是否立即执行 ✅ 立即执行 ✅ 立即执行 ❌ 返回新函数,之后手动调用 ✅ 立即执行构造函数
参数格式 (this, arg1, arg2...) 逐个传 (this, [arr]) 数组传 (this, arg1, ...) 逐个传,可预设 (arg1, arg2...) 不需要传 this
返回值 被调用函数本身的返回值 被调用函数本身的返回值 一个绑定了 this 的新函数 新创建的实例对象(或构造函数 return 的对象)
this 绑定类型 显式绑定 显式绑定 显式绑定(硬绑定,不可再改) new 绑定(优先级最高)
典型使用场景 借用方法、指定 this 调函数 参数是数组时(求 max、数组合并) 事件回调锁 this、预设参数柯里化 创建类的实例对象

一句话速记

  • 立刻执行call(逐个参)或 apply(数组参)
  • 稍后执行 (如回调)+ 锁死 this → bind
  • 创建实例new

四、手写 5 件套(面试必写,查坑必备)


1. 手写 call

js 复制代码
Function.prototype.myCall = function (context, ...args) {
  // 1. 处理 context 传 null/undefined 的情况,默认用 globalThis
  const ctx = context ?? globalThis
  // 2. 把被调用的函数挂到 context 上(用 Symbol 避免键冲突)
  const key = Symbol('fn')
  ctx[key] = this  // this 就是调用 myCall 的那个函数(比如 fn.myCall,this=fn)
  // 3. 执行函数,拿到结果
  const result = ctx[key](...args)
  // 4. 清理,别污染 context
  delete ctx[key]
  return result
}

// 测试
function greet(age) {
  console.log(this.name + ' ' + age + ' 岁')
}
greet.myCall({ name: '张三' }, 30) // 张三 30 岁

2. 手写 apply(和 call 几乎一样,参数是数组)

js 复制代码
Function.prototype.myApply = function (context, args = []) {
  const ctx = context ?? globalThis
  const key = Symbol('fn')
  ctx[key] = this
  const result = ctx[key](...args)  // 只有这里不同:展开数组
  delete ctx[key]
  return result
}

3. 手写 bind(注意 new 调用的优先级更高)

js 复制代码
Function.prototype.myBind = function (context, ...bindArgs) {
  const originFn = this  // 原函数
  const fBound = function (...callArgs) {
    // 关键点:如果 fBound 被 new 调用,new 绑定优先级 > bind
    // 怎么判断?new 调用时 this 是 fBound 的实例
    const ctx = this instanceof fBound ? this : context
    return originFn.apply(ctx, [...bindArgs, ...callArgs])
  }
  // 原型继承:让 new fBound() instanceof originFn 返回 true
  fBound.prototype = Object.create(originFn.prototype)
  return fBound
}

// 测试
function User(name, age) { this.name = name; this.age = age }
const BoundUser = User.myBind(null, '张三')
const u = new BoundUser(30)
console.log(u) // { name: '张三', age: 30 }

4. 手写 new(还原构造过程)

js 复制代码
function myNew(ctor, ...args) {
  // 1. 创建空对象,继承构造函数的原型
  const obj = Object.create(ctor.prototype)
  // 2. 调用构造函数,this 绑定到新对象
  const result = ctor.apply(obj, args)
  // 3. 如果构造函数返回对象,则用返回值;否则返回 obj
  return result instanceof Object && result !== null ? result : obj
}

// 测试
function Dog(name) { this.name = name }
const dog = myNew(Dog, '旺财')
console.log(dog.name)          // 旺财
console.log(dog instanceof Dog) // true

5. 手写 instanceof(原型链查找)

js 复制代码
function myInstanceof(obj, ctor) {
  // 基本类型直接返回 false
  if (obj === null || typeof obj !== 'object' && typeof obj !== 'function') {
    return false
  }
  let proto = Object.getPrototypeOf(obj)
  while (proto !== null) {
    if (proto === ctor.prototype) return true
    proto = Object.getPrototypeOf(proto)
  }
  return false
}

// 测试
console.log(myInstanceof([], Array))       // true
console.log(myInstanceof({}, Object))      // true
console.log(myInstanceof('xxx', String))   // false(字符串基本类型)
console.log(myInstanceof(new String('x'), String)) // true

五、一张表收尾:this 速查

你写的代码 你以为 this 是 实际 this 是 正确做法
const fn = obj.method; fn() obj undefined/window fn = obj.method.bind(obj)
map(function(){ this.x }) 外层 this undefined/window map/forEach/filter 等数组方法第二个参数传 this,或直接用箭头函数
事件回调 + setTimeout 双普通函数 组件实例 DOM/全局 回调全写箭头
new 构造函数() { return {} } 新实例 返回的那个 {} 别 return 对象或 return this
对象方法写箭头函数 对象 全局/外层 老老实实写普通函数

六、回到那个删错数据的 bug

文章开头那个"删了同事数据"的 bug,根因就是坑 1 + 坑 2 叠加:

js 复制代码
// ❌ 原代码,抽方法时 this 丢了,拿到了全局最后一行 ID
const onDelete = rowService.deleteRow
table.on('click', '#delBtn', function () {
  const rowId = $(this).data('id')    // 这里 this 是 DOM,没错
  onDelete(rowId)                     // 这里 onDelete 是默认绑定,this 丢了
  // deleteRow 内部 this.currentScope.rowId 变成了全局值
})

修复只改了一行:

js 复制代码
const onDelete = rowService.deleteRow.bind(rowService)

一行 this 没绑对,同事数据就没了。 这就是为什么要彻底搞懂 this------不是为了面试,是为了少背锅

💡下一步:把 5 个 this 坑案例 + 手写 5 件套全部 commit 到 js-basics-practice 仓库。下次查老项目 bug,直接打开自己的仓库对照着查。

相关推荐
宿6741 小时前
vue3-pinia
前端·vue.js
白泽_hunter1 小时前
搞懂 JS 数据类型:从 typeof 到手写完整类型判断,一张图理清
前端
日光倾2 小时前
TypeScript 随手记 —— 1
前端·javascript·typescript
自学it的攻城狮2 小时前
elpis-core ,koa实现的系统底座, 沉淀80% 的通用能力,剩下20% 用于定制化开发
前端
CappuccinoRose3 小时前
模块化体系
前端·import·export·es modules
AIDANHANG3 小时前
放开靠时间戳对日志前先核请求ID跨服务传播与采样关联
前端·人工智能
半生过往5 小时前
前端学 Java 课程笔记
java·前端·笔记
广州华水科技5 小时前
2026年单北斗GNSS变形监测系统推荐,破解水库安全监测难题
前端
独爱香菜6 小时前
Next.js 15 + Cloudflare Workers 实战:零中间件多语言 SEO 站点架构
前端