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 时要照着实现):
- 创建一个空对象
obj - 把
obj.__proto__指向构造函数的prototype(原型链接上) - 执行构造函数,
this绑定到obj - 如果构造函数没返回对象,就自动返回
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,直接打开自己的仓库对照着查。