技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

不用 call 和 apply 方法模拟实现 ES5 的 bind 方法

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/76-不用call和apply方法模拟实现ES5的bind方法
本文目录5 个章节
  1. 一、bind 解决什么问题
  2. 二、ES5 时代的临时属性实现
  3. 三、绑定参数与构造调用
  4. 四、this、箭头函数与绑定的边界
  5. 五、为什么不建议自己重写原生 bind

不用 callapply 方法模拟实现 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.bindReflect.apply 和展开语法的旧环境中,可以把函数临时挂到接收者对象上,再用“对象.临时属性”的形式调用。因为这种调用形式会把接收者作为 this,所以不需要 callapply

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) 会把 nullundefined 和原始值包装成对象,严格模式函数接收 null/undefined 的精确语义无法仅靠临时属性调用完整模拟;非严格函数的 this substitution 也不等同于简单创建一个新包装对象。
  • 不能可靠判断所有可构造对象,箭头函数、代理、类构造器和跨 realm 函数还涉及更多内部操作。
  • 原生绑定函数的 lengthname、构造行为、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]

原生 bindnew 调用时有一个重要例外:绑定的 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、箭头函数与绑定的边界

箭头函数没有自己的 thisbind 不能改变已经由外层词法环境决定的 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

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS