理解JavaScript中的Function.prototype.bind

jobbole發表於2014-02-07

  函式繫結(Function binding)很有可能是你在開始使用JavaScript時最少關注的一點,但是當你意識到你需要一個解決方案來解決如何在另一個函式中保持this上下文的時候,你真正需要的其實就是 Function.prototype.bind(),只是你有可能仍然沒有意識到這點。

  第一次遇到這個問題的時候,你可能傾向於將this設定到一個變數上,這樣你可以在改變了上下文之後繼續引用到它。很多人選擇使用 self, _this 或者 context 作為變數名稱(也有人使用 that)。這些方式都是有用的,當然也沒有什麼問題。但是其實有更好、更專用的方式。

  Jack Archibald 關於快取 this 的微博(twitter):

  Jake Archibald: “我會為了作用域做任何事情,但是我不會使用 that = this”

  我對這個問題更清晰的認識是在我看到Sindre Sorhus更清楚的描述之後:

  Sindre Sorhus:“在jQuery中使用$this,但是對於純JS我不會,我會使用.bind()”

  而我在一開始的幾個月裡卻忽略了這個明智的建議。

 我們真正需要解決的問題是什麼?

  在下面的例子程式碼中,我們可以名正言順地將上下文快取到一個變數中:

var myObj = {

    specialFunction: function () {

    },

    anotherSpecialFunction: function () {

    },

    getAsyncData: function (cb) {
        cb();
    },

    render: function () {
        var that = this;
        this.getAsyncData(function () {
            that.specialFunction();
            that.anotherSpecialFunction();
        });
    }
};

myObj.render();

  如果我們簡單地使用 this.specialFunction() 來呼叫方法的話,會收到下面的錯誤:

Uncaught TypeError: Object [object global] has no method 'specialFunction'

  我們需要為回撥函式的執行保持對 myObj 物件上下文的引用。 呼叫 that.specialFunction()讓我們能夠維持作用域上下文並且正確執行我們的函式。 然而使用 Function.prototype.bind() 可以有更加簡潔乾淨的方式:

render: function () {

    this.getAsyncData(function () {

        this.specialFunction();

        this.anotherSpecialFunction();

    }.bind(this));

}

 我們剛才做了什麼?

  .bind()建立了一個函式,當這個函式在被呼叫的時候,它的 this 關鍵詞會被設定成被傳入的值(這裡指呼叫bind()時傳入的引數)。因此,我們傳入想要的上下文,this(其實就是 myObj),到.bind()函式中。然後,當回撥函式被執行的時候, this 便指向 myObj 物件。

  如果有興趣想知道 Function.prototype.bind() 內部長什麼樣以及是如何工作的,這裡有個非常簡單的例子:

Function.prototype.bind = function (scope) {
    var fn = this;
    return function () {
        return fn.apply(scope);
    };
}

  還有一個非常簡單的用例:

var foo = {
    x: 3
}

var bar = function(){
    console.log(this.x);
}

bar(); // undefined

var boundFunc = bar.bind(foo);

boundFunc(); // 3

  我們建立了一個新的函式,當它被執行的時候,它的 this 會被設定成 foo —— 而不是像我們呼叫 bar() 時的全域性作用域。

 瀏覽器支援

Browser Version support
Chrome 7
Firefox (Gecko) 4.0 (2)
Internet Explorer 9
Opera 11.60
Safari 5.1.4

  正如你看到的,很不幸,Function.prototype.bind 在IE8及以下的版本中不被支援,所以如果你沒有一個備用方案的話,可能在執行時會出現問題。

  幸運的是,Mozilla Developer Network(很棒的資源庫),為沒有自身實現 .bind() 方法的瀏覽器提供了一個絕對可靠的替代方案

if (!Function.prototype.bind) {
  Function.prototype.bind = function (oThis) {
    if (typeof this !== "function") {
      // closest thing possible to the ECMAScript 5 internal IsCallable function
      throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
    }

    var aArgs = Array.prototype.slice.call(arguments, 1), 
        fToBind = this, 
        fNOP = function () {},
        fBound = function () {
          return fToBind.apply(this instanceof fNOP && oThis
                                 ? this
                                 : oThis,
                               aArgs.concat(Array.prototype.slice.call(arguments)));
        };

    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();

    return fBound;
  };
}

 適用的模式

  在學習技術點的時候,我發現有用的不僅僅在於徹底學習和理解概念,更在於看看在手頭的工作中有沒有適用它的地方,或者比較接近它的的東西。我希望,下面的某些例子能夠適用於你的程式碼或者解決你正在面對的問題。

  CLICK HANDLERS(點選處理函式)

  一個用途是記錄點選事件(或者在點選之後執行一個操作),這可能需要我們在一個物件中存入一些資訊,比如:

var logger = {
    x: 0,       
    updateCount: function(){
        this.x++;
        console.log(this.x);
    }
}

  我們可能會以下面的方式來指定點選處理函式,隨後呼叫 logger 物件中的 updateCount() 方法。

document.querySelector('button').addEventListener('click', function(){
    logger.updateCount();
});

  但是我們必須要建立一個多餘的匿名函式,來確保 updateCount()函式中的 this 關鍵字有正確的值。

  我們可以使用如下更乾淨的方式:

document.querySelector('button').addEventListener('click', logger.updateCount.bind(logger));

  我們巧妙地使用了方便的 .bind() 函式來建立一個新的函式,而將它的作用域繫結為 logger 物件。

  SETTIMEOUT

  如果你使用過模板引擎(比如Handlebars)或者尤其使用過某些MV*框架(從我的經驗我只能談論Backbone.js),那麼你也許知道下面討論的關於在渲染模板之後立即訪問新的DOM節點時會遇到的問題。

  假設我們想要例項化一個jQuery外掛:

var myView = {

    template: '/* 一個包含 <select /> 的模板字串*/',

    $el: $('#content'),

    afterRender: function () {
        this.$el.find('select').myPlugin();
    },

    render: function () {
        this.$el.html(this.template());
        this.afterRender();
    }
}

myView.render();

  你或許發現它能正常工作——但並不是每次都行,因為裡面存在著問題。這是一個競爭的問題:只有先到達的才能獲勝。有時候是渲染先到,而有時候是外掛的例項化先到。【譯者注:如果渲染過程還沒有完成(DOM Node還沒有被新增到DOM樹上),那麼find(‘select’)將無法找到相應的節點來執行例項化。】

  現在,或許並不被很多人知曉,我們可以使用基於 setTimeout() 的 slight hack來解決問題。

  我們稍微改寫一下我們的程式碼,就在DOM節點載入後再安全的例項化我們的jQuery外掛:

//

    afterRender: function () {
        this.$el.find('select').myPlugin();
    },

    render: function () {
        this.$el.html(this.template());
        setTimeout(this.afterRender, 0);        
    }

//

  然而,我們獲得的是 函式 .afterRender() 不能找到 的錯誤資訊。

  我們接下來要做的,就是將.bind()使用到我們的程式碼中:

//

    afterRender: function () {
        this.$el.find('select').myPlugin();
    },

    render: function () {
        this.$el.html(this.template());
        setTimeout(this.afterRender.bind(this), 0);        
    }

//

  現在,我們的 afterRender() 函式就能夠在正確的上下文環境中執行了。

  梳理基於 QUERYSELECTORALL的事件繫結

  如今的DOM API引入了很多非常有用的方法,比如 querySelector, querySelectorAll 和 classList介面,這些方法給DOM API帶來了非常顯著的進步。

  然而,迄今為止並沒有一個真正的原生的為 NodeList 新增事件的方法。於是我們最終從 Array.prototype中剽竊了 forEach 方法來完成遍歷,例如:

Array.prototype.forEach.call(document.querySelectorAll('.klasses'), function(el){
    el.addEventListener('click', someFunction);
});

  仍然,我們可以做的更好,通過使用我們的好朋友 .bind()。

var unboundForEach = Array.prototype.forEach,
    forEach = Function.prototype.call.bind(unboundForEach);

forEach(document.querySelectorAll('.klasses'), function (el) {
    el.addEventListener('click', someFunction);
});

  現在,我們擁有了一個簡潔的遍歷DOM節點的函式。

  結論

  正如你所看到的,.bind() 函式可以巧妙地運用於很多不同的用途,同時可以精簡現有的程式碼。但願這篇概述的內容,能夠在你想在程式碼中使用.bind()(如果需要的話)時派上用場,並且幫助你更好地駕馭改變this值所帶來的好處。

  原文連結: Smashing Magazine   翻譯: 伯樂線上 - 陳鑫偉

相關文章