細節解析 JavaScript 中 bind 函式的模擬實現

鹿鹿isNotDiefined發表於2024-11-11

大家的閱讀是我發帖的動力,本文首發於我的部落格:deerblog.gu-nami.com/,歡迎大家來玩,轉載請註明出處

💢前言

bind是一個改變函式this指標指向的一個常用函式,經常用在涉及this指標的程式碼中。來看 MDN 的文件

Function例項的bind()方法建立一個新函式,當呼叫該新函式時,它會呼叫原始函式並將其this關鍵字設定為給定的值,同時,還可以傳入一系列指定的引數,這些引數會插入到呼叫新函式時傳入的引數的前面。

最近搞出了一個很難注意得到的 bug,bind函式返回的函式中,原函式的屬性消失了,導致了一個工具函式的失效。

function Message (/*...*/) {/*...*/}
Message.success = function (/*...*/) { return Message('success', /*...*/) }
// ...
xxx.Message = Message.bind(/*...*/)
// ...
xxx.Message.success(/*...*/)
// Uncaught TypeError: xxx.success is not a function

解決方法自然是Object.keys()遍歷一下原函式的屬性,新增到新的函式上面。

來看看文件怎麼說的:

繫結函式還會繼承目標函式的原型鏈。然而,它不會繼承目標函式的其他自有屬性(例如,如果目標函式是一個類,則不會繼承其靜態屬性)。

所以上面Message的屬性就消失了。

後來去翻看Funtion.__proto__.bind的文件發現了一些以前從未注意到的內容,感覺挺有意思...

bind的功能主要有兩點,一個是修改函式的this指標。

例如在老版本的 React Class 元件中使用回撥函式:

export default class TestButton extends Component {
  testClickHandler () {
    console.log(this.state)
  }
  render () {
    return (
      <Button onClick={this.testClickHandler.bind(this)}>test</Button>
    )
  }
}

setTimeout等回撥中訪問當前函式的this指標(當然現在我們都可以用箭頭函式實現):

function test () {
  var that = this
  setTimeout(function () {
    console.log(that.test)
  })
}

bind還可以暫存引數:

const func = (a, b) => a + b
const funcBound = func.bind(null, 1)
funcBound(2) // 3
func(1, 2) // 3

我們還可以用這個特性做到函式柯里化,在複雜的場景中(好像業務開發幾乎用不到的樣子)使得函式呼叫更加靈活:

const curry = (func: Function, ...args: any[]) => {
  let resArgsCount = func.length - args.length
  let tempFunc = func.bind(null, ...args)
  const ans = (...childArgs: any[]) => {
    resArgsCount -= args.length
    return resArgsCount > 0
      ? ((tempFunc = tempFunc.bind(null, ...childArgs)), ans)
      : tempFunc(...childArgs)
  }
  return ans
}

const test = (a, b, c) => a + b + c
const testCurry = curry(test, 1)
testCurry(2)
testCurry(3)
// 6

在 ES6 尚未普及的年代,我們並不能直接使用bind這個新特性,這就需要 polyfill,因此產生了很多相關的技巧(現在即使要相容 IE 也可以直接透過 Bable 相容),在 JS 中模擬實現bind經典面試題了屬於是...

結合文件,這篇部落格將在 JS 中實現一下bind的功能。

🍵修改 this 指標和記錄入參

眾所周知,bind可以修改this的指向,並且記錄入參:

function testFunc (a, b) {
  return [a + b, this]
}
testFunc(1, 2)
// [3, Window]

testFunc.bind({}, 1)(2)
//  [3, {…}]

下面就在 JS 中手寫一下:

Function.prototype.deerBind = function(ctx, ...args) {
    ctx = ctx || window
    const self = this
    return function (...argsNext) {
        return self.apply(ctx, [...args, ...argsNext])
    }
}
testFunc(1, 2)
// [3, Window]

testFunc.deerBind({}, 1)(2)
//  [3, {…}]

☕作為建構函式

繫結函式自動適用於與 new 運算子一起使用,以用於構造目標函式建立的新例項。當使用繫結函式是用來構造一個值時,提供的this會被忽略。

在 JS 中,當你new一個物件時:

  1. 建立一個新物件;
  2. 建構函式this指向這個新物件;
  3. 執行建構函式中的程式碼;
  4. 返回新物件。

當一個函式被作為建構函式new的時候,它的this指向該函式的例項。這裡我們修改一下deerBind函式的返回:

Function.prototype.deerBind = function(ctx, ...args) {
  ctx = ctx || window
  const self = this
  const funcBound = function (...argsNext) {
    return self.apply((this instanceof funcBound ? this : ctx), [...args, ...argsNext])
  }
  funcBound.prototype = self.prototype
  return funcBound
}

function testNew (str) { this.test = 'test ' + str }
new (testNew.bind({}, 'shikinoko nokonoko koshitanntann'))
// testNew {test: 'test shikinoko nokonoko koshitanntann'}
new (testNew.deerBind({}, 'shikinoko nokonoko koshitanntann'))
// testNew {test: 'test shikinoko nokonoko koshitanntann'}

另外,bind返回的函式的例項和原函式是指向同一個原型的,這裡也滿足了:

const ins1 = new (testNew.deerBind({}, 'test')), ins2 = new testNew('test')
ins1.__proto__
// {constructor: ƒ}
ins1.__proto__ === ins2.__proto__
// true

🧉處理箭頭函式的情況

注意到,箭頭函式沒有例項,也不能newthis來自親代作用域,用作建構函式會引起錯誤:

new (() => {})
// Uncaught TypeError: (intermediate value) is not a constructor
new ((() => {}).bind())
// Uncaught TypeError: (intermediate value).bind(...) is not a constructor

const testArrowFunc = (a, b) => {
  return [a + b, this]
}
testArrowFunc(1, 2)
// [3, Window]
testArrowFunc.bind({}, 1)(2)
// [3, Window]

再修改一下這裡的實現:

Function.prototype.deerBind = function(ctx, ...args) {
  ctx = ctx || window
  const self = this
  
  let funcBound
  if (self.prototype) {
    funcBound = function (...argsNext) {
      return self.apply((this instanceof funcBound ? this : ctx), [...args, ...argsNext])
    }
    funcBound.prototype = self.prototype
  } else {
    funcBound = (...argsNext) => {
      return self.apply(ctx, [...args, ...argsNext])
    }
  }
  return funcBound
}

testArrowFunc.deerBind({}, 1)(2)
// [3, Window]
new ((() => {}).deerBind())
// Uncaught TypeError: (intermediate value).deerBind(...) is not a constructor

🍯處理類構造器的情況

你可能會發現,bind可以在類的構造器上面使用,但是我們上面自己寫的似乎存在一點小錯誤:

class base { constructor (a, b) { this.test = a + b } }
new base(1, 2)
// base {test: 3}
new (base.bind({}, 1))(2)
// base {test: 3}
const bind = base.deerBind({}, 1)
new bind(2)
// Uncaught TypeError: Class constructor base cannot be invoked without 'new'

這裡透過prototype上面的constructor來檢查一個函式是否是構造器:

Function.prototype.deerBind = function(ctx, ...args) {
  ctx = ctx || window
  const self = this
  
  let funcBound
  if (self.prototype) {
    funcBound = function (...argsNext) {
      return !self.prototype.constructor
        ? self.apply((this instanceof funcBound ? this : ctx), [...args, ...argsNext])
        : new self(...args, ...argsNext)
    }
    funcBound.prototype = self.prototype
  } else {
    funcBound = (...argsNext) => {
      return self.apply(ctx, [...args, ...argsNext])
    }
  }
  return funcBound
}

const bind = base.deerBind({}, 1)
new bind(2)
// base {test: 3}

我們實現的deerBind也可以用於建構函式了。

🍹結語

到這裡,bind大體就是實現完成了,這裡具體涉及了bind函式改變this指標,記錄引數以及作為建構函式的功能的實現。去看了一下著名 JS polyfill 庫 core-js 的實現,感覺思路大概差不多的樣子,它作為 polyfill 也考慮了相容性的問題,感覺好厲害的樣子。

參考:

  1. https://juejin.cn/post/6844903733013250056
  2. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/bind

相關文章