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

显示模式

登录
ARCHIVE DOCUMENTJS

面试官问:能否模拟实现 JS 的 bind 方法

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/133-面试官问:能否模拟实现JS的bind方法
本文目录12 个章节
  1. 前言
  2. bind 是什么
  3. 第一版:只绑定 this 并合并参数
  4. bind 返回的函数可以被 new 调用
  5. 第二版:使用 Reflect.construct 支持 new
  6. instanceof 检查的局限
  7. 第三版:ES5 风格的构造转发
  8. 多次绑定
  9. bind 与箭头函数
  10. 现代实践
  11. 最后总结
  12. 参考资料

面试官问:能否模拟实现 JS 的 bind 方法

Category(分类): JavaScript Status: 未知

配图来自原文页面,已下载并转换为本地 WebP;原始授权未核实,发布前请人工确认。

前言

你好,我是若川。这是“面试官问”系列的第二篇,旨在帮助读者提升 JavaScript 基础知识,涉及 newcallapplythis、原型链和函数对象。

用过 React class component 的同学可能写过下面的代码:

import React, { Component } from 'react'

class TodoItem extends Component {
  constructor(props) {
    super(props)
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() {
    console.log('handleClick')
  }

  render() {
    return <div onClick={this.handleClick}>点击</div>
  }
}

export default TodoItem

class field 箭头函数、函数组件和事件处理器闭包也能解决一部分 this 绑定问题,但理解 bind 仍然有助于掌握函数调用和构造调用。

bind 是什么

Function.prototype.bind 会创建一个新的绑定函数(bound function),把目标函数和预设的 this、前置参数保存起来。调用绑定函数时,绑定参数会排在新调用传入的参数前面。

const obj = {}

console.log(typeof Function.prototype.bind) // function
console.log(typeof Function.prototype.bind()) // function
console.log(Function.prototype.bind.name) // bind
console.log(Function.prototype.bind().name) // 'bound '(名称末尾有一个空格)
console.log(Function.prototype.bind.length) // 1

最后一个名称是 'bound ',后面有一个空格:目标函数 Function.prototype 本身没有名字,所以原生绑定函数的名称是 bound

绑定 this 和参数

const obj = {
  name: '若川'
}

function original(a, b) {
  console.log(this.name)
  console.log([a, b])
  return false
}

const bound = original.bind(obj, 1)
const boundResult = bound(2)

console.log(boundResult) // false
console.log(original.bind.name) // bind
console.log(original.bind.length) // 1
console.log(original.bind().length) // 2
console.log(bound.name) // bound original
console.log((function () {}).bind().name) // 'bound '(名称末尾有一个空格)
console.log(bound.length) // 1,2 个形参减去 1 个预置参数

可以得到几个结论:

  1. bindFunction.prototype 上的方法,每个函数通常都可以调用它。
  2. bind(thisArg, ...args) 返回一个新函数,不会立即执行目标函数。
  3. 绑定参数和调用绑定函数时传入的参数会按顺序合并。
  4. 绑定函数的 name 通常是目标函数名称前加 bound ;匿名函数的名称通常是 bound
  5. 绑定函数的 length 通常是 max(0, target.length - boundArgs.length)thisArg 不计入参数数量。
  6. 绑定函数执行时会返回目标函数的返回值。

namelength 是原生绑定函数的可观察属性,但实现自定义版本时还会遇到属性描述符、原型和内部槽等差异。

原文配图 133-01

第一版:只绑定 this 并合并参数

下面是一个适合入门的简版。它只处理普通函数调用,不处理 new

function bindSimple(thisArg, ...boundArgs) {
  const target = this

  if (typeof target !== 'function') {
    throw new TypeError('bindSimple must be called on a function')
  }

  return function bound(...callArgs) {
    return Reflect.apply(target, thisArg, [...boundArgs, ...callArgs])
  }
}

const obj = { name: '若川' }

function original(a, b) {
  console.log(this.name)
  console.log([a, b])
}

const bound = bindSimple.call(original, obj, 1)
bound(2) // 若川;[1, 2]

原文使用 [].slice.call(arguments)self.apply,在 ES5 时代是常见写法。现代代码可以使用剩余参数和 Reflect.apply,但二者表达的核心仍然是:保存目标函数、保存前置参数,再以指定的 this 调用目标函数。

这个版本有明显限制:

  • new bound() 的行为没有实现;
  • namelength 不符合原生 bind;
  • 绑定函数自身的原型、instanceof 和错误栈也不能完全模拟;
  • 不应在生产环境无条件给 Function.prototype 添加 bindFn,因为会污染全局原型。

如果为了面试演示确实需要添加方法,至少使用不可枚举属性,并在测试结束后清理;生产代码直接使用原生 bind

bind 返回的函数可以被 new 调用

如果目标函数可构造,原生绑定函数也可以使用 new

const obj = {
  name: '若川'
}

function Original(a, b) {
  console.log('this 是新对象:', this instanceof Original)
  this.name = b
  this.args = [a, b]
}

const bound = Original.bind(obj, 1)
const value = new bound(2)

console.log(value.name) // 2
console.log(value.args) // [1, 2]
console.log(value instanceof Original) // true
console.log(value instanceof bound) // true

构造调用时,绑定的 thisArg 会被忽略,因为 new 会准备一个新的 this;绑定的前置参数仍然有效。原生绑定函数会把构造行为转发给目标函数,并正确处理目标函数返回的对象。

function ReturnObject(value) {
  this.value = 'ignored'
  return { value }
}

const BoundReturnObject = ReturnObject.bind(null, 'returned')
const result = new BoundReturnObject()
console.log(result.value) // returned

如果目标函数不是可构造的,例如箭头函数,原生绑定函数也不能被 new 调用:

const arrow = () => {}
const boundArrow = arrow.bind(null)

try {
  new boundArrow()
} catch (error) {
  console.log(error instanceof TypeError) // true
}

第二版:使用 Reflect.construct 支持 new

下面的实现使用标准 API 处理构造调用,适合作为现代教学版本:

function bindPolyfill(thisArg, ...boundArgs) {
  const target = this

  if (typeof target !== 'function') {
    throw new TypeError('bindPolyfill must be called on a function')
  }

  function bound(...callArgs) {
    const finalArgs = [...boundArgs, ...callArgs]

    if (new.target !== undefined) {
      // 直接 new bound() 时,让目标函数看到 target 作为 new.target。
      const newTarget = new.target === bound ? target : new.target
      return Reflect.construct(target, finalArgs, newTarget)
    }

    return Reflect.apply(target, thisArg, finalArgs)
  }

  const length = Math.max(0, target.length - boundArgs.length)
  Object.defineProperty(bound, 'length', {
    value: length,
    configurable: true
  })
  Object.defineProperty(bound, 'name', {
    value: `bound ${target.name}`,
    configurable: true
  })

  return bound
}

function Point(x, y) {
  this.x = x
  this.y = y
}

Point.prototype.toString = function () {
  return `${this.x},${this.y}`
}

const obj = {}
const BoundPoint = bindPolyfill.call(Point, obj, 0)
const point = new BoundPoint(5)

console.log(point.toString()) // 0,5
console.log(point instanceof Point) // true
console.log(point.x) // 0
console.log(point.y) // 5

这个实现比 this instanceof bound 更接近现代语义,但仍不是原生 bind 的完全复制:

  • 原生 bound function 是规范定义的特殊函数对象,通常没有自己的 prototype 自有属性;上面的普通函数 bound 会带有默认 prototype
  • 原生绑定函数的 instanceofnew.target、静态属性继承和跨 realm 行为由引擎内部方法决定。
  • Reflect.construct 能处理可构造函数,但不能把不可构造的箭头函数变成构造函数。
  • Function.prototype.bind 还涉及 this 的严格/非严格模式转换、错误信息和属性描述符细节。

因此,面试中应主动说明“这是教学实现,不声称与原生 bind 完全等价”。

instanceof 检查的局限

历史上的 ES5 模拟实现常使用 this instanceof bound 判断绑定函数是否通过 new 调用。这个方案在没有 new.target 的旧环境中有价值,但它可能被调用者通过原型关系影响;并且绑定函数的目标函数不一定是普通构造器。

function Student(name) {
  if (this instanceof Student) {
    this.name = name
    return
  }

  throw new Error('必须通过 new 关键字来调用 Student')
}

const student = new Student('若')
// student 已经是 Student 实例,因此普通调用也能通过 instanceof 检查。
const result = Student.call(student, '川')
console.log(result) // undefined
console.log(student.name) // 川

ES2015 提供了 new.target

function Student2(name) {
  if (new.target !== undefined) {
    this.name = name
    return
  }

  throw new Error('必须通过 new 关键字来调用 Student2')
}

const student2 = new Student2('若')
console.log(student2.name) // 若

try {
  Student2.call(student2, '川')
} catch (error) {
  console.log(error.message) // 必须通过 new 关键字来调用 Student2
}

new.target 更适合区分普通调用与构造调用,但 Reflect.construct 可以显式指定 newTarget,所以在框架或元编程代码中仍需按实际 API 契约设计。

第三版:ES5 风格的构造转发

在没有 new.targetReflect.construct 的旧环境中,可以使用一个空构造函数把目标函数的原型接到绑定函数上,再通过 this instanceof bound 判断构造调用:

function bindLegacy(thisArg) {
  const target = this

  if (typeof target !== 'function') {
    throw new TypeError('Function.prototype.bind called on incompatible value')
  }

  const boundArgs = Array.prototype.slice.call(arguments, 1)
  const bound = function () {
    const callArgs = Array.prototype.slice.call(arguments)
    const finalArgs = boundArgs.concat(callArgs)

    if (this instanceof bound) {
      const result = target.apply(this, finalArgs)
      const isObject = result !== null &&
        (typeof result === 'object' || typeof result === 'function')
      return isObject ? result : this
    }

    return target.apply(thisArg, finalArgs)
  }

  if (target.prototype) {
    function Empty() {}
    Empty.prototype = target.prototype
    bound.prototype = new Empty()
  }

  Object.defineProperties(bound, {
    length: {
      value: Math.max(0, target.length - boundArgs.length),
      configurable: true
    },
    name: {
      value: `bound ${target.name}`,
      configurable: true
    }
  })

  return bound
}

function Person(name) {
  this.name = name
}

const BoundPerson = bindLegacy.call(Person, null, 'Ada')
const person = new BoundPerson()
console.log(person.name) // Ada
console.log(person instanceof Person) // true

这里的 Empty 技巧是旧版 polyfill 中常见的原型转接方式。es5-shim 的实现还会处理可调用性检查、绑定参数数量和更多兼容性细节;阅读源码很适合了解 ES5 时代的兼容策略,但不应把它当成现代引擎内部实现。

过去为了让返回函数的 length 看起来像原生 bind,有些 polyfill 会用 Function 构造器动态生成 $0, $1 等形参。现代环境可以直接使用 Object.defineProperty 设置 length,而动态 Function 还可能受到 CSP 限制,因此没有必要在新代码中使用。

多次绑定

一个绑定函数还可以再次调用 bind。后一次绑定会继续追加参数,但不能覆盖第一次绑定的 this

function list(...args) {
  return [this.label, ...args]
}

const first = list.bind({ label: 'first' }, 1)
const second = first.bind({ label: 'second' }, 2)

console.log(second(3)) // ['first', 1, 2, 3]

bind 与箭头函数

箭头函数没有自己的动态 this,它会捕获定义位置的词法 this,因此对箭头函数调用 bind 只能固定前置参数,不能改变其 this

const arrow = () => this
const object = {}

console.log(arrow.bind(object)() === arrow()) // true(非模块顶层示例)

在 ES module 中顶层 thisundefined;在 CommonJS 中顶层 this 通常是 module.exports。因此不要依赖上面示例的具体对象身份,重点是:箭头函数的 this 不由调用点决定。

现代实践

  • 生产代码直接使用原生 bind,不需要手写 polyfill。
  • React 新代码更常使用函数组件;class component 仍可在构造器中绑定方法,或使用 class field 箭头函数。
  • 事件监听器需要移除时,必须保存同一个绑定函数引用,不能每次 removeEventListener 都重新调用 bind
  • 绑定函数会创建新函数,频繁在渲染或循环中重复绑定可能增加分配和比较成本;可以在初始化阶段缓存。
  • 只有普通函数目标可构造时,绑定函数才可使用 newthisArg 在构造调用中会被忽略。
class ButtonController {
  constructor(element) {
    this.element = element
    this.handleClick = this.handleClick.bind(this)
    element.addEventListener('click', this.handleClick)
  }

  handleClick() {
    console.log(this.element.tagName)
  }

  destroy() {
    this.element.removeEventListener('click', this.handleClick)
  }
}

最后总结

  1. bindFunction.prototype 上的方法,会返回新的绑定函数。
  2. 普通调用时,绑定函数使用固定的 this,并把绑定参数和调用参数合并后传给目标函数。
  3. 构造调用时,绑定的 thisArg 被忽略,目标函数会使用 new 准备的新对象;绑定参数仍然有效。
  4. 原生绑定函数通常拥有 bound 前缀的 name,以及减去前置参数后的 length
  5. ES5 时代可以用空构造函数和 instanceof 模拟构造转发;现代实现优先使用 Reflect.applyReflect.construct
  6. 手写实现用于理解原理和面试,不应声称与原生 bind 在所有内部语义上等价,也不建议污染 Function.prototype

参考资料

  1. MDN:Function.prototype.bind()
  2. MDN:Function.prototype.apply()
  3. MDN:Reflect.construct()
  4. ECMAScript:Function.prototype.bind
  5. es-shims:es5-shim
  6. 若川:JavaScript 深入之 bind 的模拟实现

作者:若川

链接:https://juejin.cn/post/6844903718089916429

来源:稀土掘金。著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS