不用 call 和 apply 方法模拟实现 ES5 的 bind 方法
Category(分类): JavaScript Status: 已整理
本文保留原文“用临时属性借用方法”的 ES5 思路,并补充原生
Function.prototype.bind的当前语义。标题中的“ES5”指 ES5 规定的 bind 语义,不代表下面代码只使用 ES5 语法;示例使用现代语法便于阅读,生产代码应优先使用原生bind。
一、bind 解决什么问题
普通函数的 this 由调用形式决定。把方法单独取出来作为回调时,原来的对象信息不会自动跟着函数一起走:
const user = {
name: 'Ada',
say(greeting) {
return `${greeting}, ${this.name}`
},
}
const say = user.say
console.log(user.say('Hello')) // Hello, Ada
console.log(say('Hello')) // Hello, undefined(非严格函数环境下)
Function.prototype.bind(thisArg, ...boundArgs) 会返回一个新函数:调用这个新函数时,原函数的 this 固定为 thisArg,并且可以预先填充参数。
const boundSay = user.say.bind(user, 'Hello')
console.log(boundSay()) // Hello, Ada
绑定的是函数调用规则,不是把函数复制到另一个对象上。绑定函数之后,原函数仍然是原函数,返回值和异常也会继续传递。
二、ES5 时代的临时属性实现
在没有 Function.prototype.bind、Reflect.apply 和展开语法的旧环境中,可以把函数临时挂到接收者对象上,再用“对象.临时属性”的形式调用。因为这种调用形式会把接收者作为 this,所以不需要 call 或 apply:
function invokeWithoutCallApply(fn, thisArg, args) {
const receiver = Object(thisArg)
const key = typeof Symbol === 'function'
? Symbol('temporary method')
: `__bind_${Date.now()}_${Math.random()}`
Object.defineProperty(receiver, key, {
configurable: true,
value: fn,
})
try {
return receiver[key](...args)
} finally {
delete receiver[key]
}
}
function bindWithoutCallApply(fn, thisArg, ...boundArgs) {
if (typeof fn !== 'function') {
throw new TypeError('bind target must be callable')
}
function bound(...callArgs) {
const args = boundArgs.concat(callArgs)
const calledWithNew = this instanceof bound
const receiver = calledWithNew ? this : thisArg
const result = invokeWithoutCallApply(fn, receiver, args)
if (calledWithNew) {
return isObject(result) ? result : this
}
return result
}
// 让 new bound() 创建的对象也能通过 fn.prototype 访问原型方法。
if (fn.prototype && typeof fn.prototype === 'object') {
bound.prototype = Object.create(fn.prototype)
Object.defineProperty(bound.prototype, 'constructor', {
configurable: true,
value: bound,
writable: true,
})
}
return bound
}
function isObject(value) {
return (typeof value === 'object' && value !== null) || typeof value === 'function'
}
const user = {
name: 'Ada',
say(greeting, punctuation) {
return `${greeting}, ${this.name}${punctuation}`
},
}
const boundSay = bindWithoutCallApply(user.say, user, 'Hello')
console.log(boundSay('!')) // Hello, Ada!
这个实现展示了原理,但不能完全复刻现代规范,也不能直接称为纯 ES5 polyfill:
Object(thisArg)会把null、undefined和原始值包装成对象,严格模式函数接收null/undefined的精确语义无法仅靠临时属性调用完整模拟;非严格函数的 this substitution 也不等同于简单创建一个新包装对象。- 不能可靠判断所有可构造对象,箭头函数、代理、类构造器和跨 realm 函数还涉及更多内部操作。
- 原生绑定函数的
length、name、构造行为、instanceof和错误边界更复杂。 - 临时属性可能和对象原有属性冲突;这里使用
Symbol时风险较低,但旧环境的字符串回退仍不是绝对安全。
三、绑定参数与构造调用
bind 的参数分两部分:创建绑定函数时提供的参数在前,调用绑定函数时提供的参数在后。
function list(first, second, third) {
return [this.prefix, first, second, third]
}
const bound = list.bind({ prefix: 'item' }, 1)
console.log(bound(2, 3)) // ['item', 1, 2, 3]
原生 bind 被 new 调用时有一个重要例外:绑定的 thisArg 会被忽略,新建的实例作为 this 传给原函数;预填参数仍然保留。如果原函数返回对象,构造结果仍会遵循 new 的返回值规则。
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.describe = function () {
return `${this.name}:${this.age}`
}
const BoundPerson = Person.bind({ name: 'ignored' }, 'Ada')
const person = new BoundPerson(18)
console.log(person.describe()) // Ada:18
console.log(person instanceof Person) // true
console.log(person instanceof BoundPerson) // true(原生 bind 的构造关系)
上面的教学实现为普通函数创建了一个代理 prototype,因此可以覆盖常见场景;但它不应被当作完整 polyfill。现代环境直接使用:
const NativeBoundPerson = Person.bind(null, 'Ada')
const nativePerson = new NativeBoundPerson(18)
console.log(nativePerson.describe()) // Ada:18
四、this、箭头函数与绑定的边界
箭头函数没有自己的 this,bind 不能改变已经由外层词法环境决定的 this:
const arrow = () => this
const another = arrow.bind({ value: 1 })
console.log(another() === arrow()) // true
这里顶层 this 会因脚本、模块、CommonJS 或宿主环境而不同,重点是 bind 不会重新绑定箭头函数的 this。普通函数才具有动态 this 绑定行为。
绑定函数也不能通过再次 bind 改变已经绑定的 this,但后续绑定参数仍会继续拼接:
function describe(a, b) {
return [this.value, a, b]
}
const first = describe.bind({ value: 1 }, 'a')
const second = first.bind({ value: 2 }, 'b')
console.log(second()) // [1, 'a', 'b']
五、为什么不建议自己重写原生 bind
现代 JavaScript 引擎已经提供了原生实现,通常应直接写:
const handler = user.say.bind(user)
button.addEventListener('click', handler)
// 不再需要监听时,必须移除同一个绑定函数引用
button.removeEventListener('click', handler)
不要在 Function.prototype 上随意覆盖原生方法。若只是教学或兼容一个非常受限的旧环境,可以把实现命名为 bindWithoutCallApply 之类的独立函数,并在测试中覆盖:普通调用、预填参数、new、返回对象、严格模式、箭头函数和异常传播。
参考资料:
原文参考:jawil/blog#16。