1. 程式人生 > >underscore.js原始碼解析之函式繫結

underscore.js原始碼解析之函式繫結

1. 引言

  underscore.js是一個1500行左右的Javascript函式式工具庫,裡面提供了很多實用的、耦合度極低的函式,用來方便的操作Javascript中的陣列、物件和函式,它支援函式式和麵向物件鏈式的程式設計風格,還提供了一個精巧的模板引擎。理解underscore.js的原始碼和思想,不管是新手,還是工作了一段時間的人,都會上升一個巨大的臺階。雖然我不搞前端,但是在一個星期的閱讀分析過程中,仍然受益匪淺,決定把一些自認為很有意義的部分記錄下來。

2.建構函式的本質

  在開始分析函式繫結之前,有必要深入理解Javascript中的建構函式,因為這是在函式繫結中會碰到的一個問題。
  下面是我們都很清楚的知識:
  
  1. 使用new呼叫的函式即為建構函式,無論函式名是否首字母大寫。
  2. 使用new呼叫建構函式時,建構函式中的執行上下文this指向的是此建構函式將要生成的例項物件,用呼叫普通函式的方式呼叫建構函式時,this和呼叫的上下文環境有關。
  3. 建構函式有一個prototype屬性,這和它的例項物件的__proto__屬性都是指向同一個物件,即原型物件,其原型物件有一個constructor屬性,指向建構函式。
  
  但是如果出現下面這些情況,結果又會如何:

建構函式中出現return

function A() {
  this.name = 'a';
  return 22;
}
var obj = new A();
console.log(obj); //結果為 A {name: "a"}

說明return 22;被忽略了,最終還是return this;

但是,上面return了一個非object型別,如果return一個非null的object型別,那麼結果又不同了:

function A() {
  this.name = 'a';
  return {age: 22}; //return任何typeof 為object的型別,null除外
} var obj = new A(); console.log(obj); //結果為 A {age: 22}

可以看到這時return {age: 22}產生了作用。

所以可以得出一個結論:用new呼叫建構函式,如果顯式return了一個非object型別,則會被忽略;如果return了一個非null的object型別的變數或值,它將會成為新生成的例項。

下面的程式碼模擬了js引擎對建構函式的處理過程:

function A() {
  this.name = 'a';
  if(!A.prototype.speak) {
    A.prototype.speak = function
() {
alert('Hello'); } } return {age: 22}; } function handleConstructor(Ctor) { var obj = {}; //最終生成的例項物件 obj.__proto__ = Ctor.prototype; //保證原型鏈的正確 var result = Ctor.apply(obj); //Ctor中的this此時為obj,執行Ctor建構函式,可能有return值 if(typeof result === 'object' && result !== null) //非null的object型別才返回 return result; return obj; } var a = handleConstructor(A); console.log(a); //{age: 22} 和new A()是一樣的

3.原生bind

  Function.prototype.bind作用是指定一個執行上下文,生成一個新函式,但是原函式的執行上下文並沒有發生改變。bind第二個引數開始是呼叫函式的引數。

function A(name, age) {
  this.name = name;
  this.age = age;
  if(!A.prototype.greet) {
    A.prototype.greet = function() {
      console.log(this.name + ' ' + this.age);
    }
  }
}
var p = new A('a', 22);
var p2 = new A('b', 23);
var greet = p.greet;
greet();//this現在指向window,name和age為undefined

將p的greet繫結到p2上:

var greet = p.greet.bind(p2);
greet();  //b 23
p.greet();//a 22 原函式的this並沒有改變

如果呼叫bind的函式是一個建構函式,則bind的第一個引數也就是函式內部this的指向,會被忽略,通過上面對建構函式的分析,這點很好理解,建構函式的this指向的是將要生成的例項物件,如果能夠修改this將引發混亂。

4. _.bind

underscore的bind強化了原生的bind,能夠處理建構函式的情況,以及建構函式中出現return的情況。

//第一個引數是待處理函式,第二個引數是新指定的執行上下文,後面的引數都是待處理函式的引數
_.bind = function(func, context) {
  //有原生,就用原生的。
  if (nativeBind && nativeBind === func.bind) return nativeBind.apply(func, slice.call(arguments, 1));
  if (!_.isFunction(func)) throw new TypeError('Bind must be called on a function');
  //待處理函式的引數
  var args = slice.call(arguments, 2);
  var bound = function() {                          //這裡有兩批引數,外圍函式的加上閉包的
    return executeBound(func, bound, context, this, args.concat(slice.call(arguments)));
  }; 
  return bound;
};

最後那個bound可能會燒得腦袋痛,但是我們已經知道需要判斷待處理的函式是否是用作建構函式,所以executeBound就是幹這件事的。

//sourceFunc 就是待處理函式,它的執行上下文(context)等待被指定,它的引數args即將被填入其中
//boundFunc是上面的_.bind函式中return出來的函式bound,bound函式有可能通過bound()方式呼叫,也可能通過new bound()呼叫,它的執行上下文為callingContext
var executeBound = function(sourceFunc, boundFunc, context, callingContext, args) {
  //instanceof判斷的是 是否在原型鏈上,new出來的例項肯定在建構函式的原型鏈上
  //如果boundFunc不是new的方式,則sourceFunc填入引數執行完事了
  if (!(callingContext instanceof boundFunc)) return sourceFunc.apply(context, args);

  //如果boundFunc是new方式呼叫,
  //這行可以理解為self就是sourceFunc構造出來的例項,因為它們在一條原型鏈上
  var self = baseCreate(sourceFunc.prototype); //baseCreate當成Object.create就好了
  //把sourceFunc綁在例項self上執行,self就是最終構造的例項物件
  var result = sourceFunc.apply(self, args);  //建構函式一般沒有顯式的return,如果有的話,兩種情況處理
  //如果建構函式返回了一個非null物件,則就返回這個物件
  if (_.isObject(result)) return result;
  //否則應該返回之前的例項self
  return self;
};

4 用_.bindAll固定this

_.bindAll = function(obj) {//obj, methodName1, methodName2, methodName3...
  var i, length = arguments.length, key;
  if (length <= 1) throw new Error('bindAll must be passed function names');
  for (i = 1; i < length; i++) {
    key = arguments[i];
    obj[key] = _.bind(obj[key], obj);
  }
  return obj;
};

看過了_.bind,那麼這個_.bindAll(obj, func1,func2…)就非常好理解了,簡單來說,就是把obj上的一堆方法綁死在obj上,以後不管把obj上的這些方法給誰引用,執行上下文都不會改變,都是obj,也就是說this被固定住 了,不再是 誰呼叫此函式,誰就是this。
obj[key] = _.bind(obj[key], obj); 這一行實現了綁死的效果,受它的啟發,如果沒有underscore,原生也可以實現這個效果:

function A(name, age) {
  this.name = name;
  this.age = age;
  if(!A.prototype.greet) {
    A.prototype.greet = function() {
      alert(this.name + ' ' + this.age);
    }
  }
}
var p = new A('a', 22);
var p2 = new A('b', 23);
p.greet = p.greet.bind(p);//將greet的this綁死在p上。
var greet = p.greet; //將p.greet給一個新變數引用,this還是p
p2.greet = p.greet; //
greet(); // a 22
p2.greet(); //a 22

雖然在呼叫bind之前,greet中的this已經是指向p的,但是此時的this是會變動的,將p.greet方法中的this強制指向p,再覆蓋原來的p.greet,也就是將this綁死在了p上,以後無論怎樣去改變greet的呼叫者,this都不會變化,都指向p。

分析一下文件上的例子,是一個按鈕的view:

<button id="btn">button</button>
<script>
var buttonView = {
  label  : 'underscore',
  onClick: function(){ alert('clicked: ' + this.label); },
};
var btn = document.getElementById('btn');
btn.addEventListener('click', buttonView.onClick);
</script>

此時點選按鈕,出現 clicked: undefined,這是一個this的使用陷阱,雖然看不到addEventListener的原始碼,但是buttonView.onClick在傳入addEventListener之後,執行上下文肯定不是buttonView了。

//使用原生的bind將this綁死在buttonView上。
buttonView.onClick = buttonView.onClick.bind(buttonView);

現在就不會出現上面的問題了。

5._partial不完全呼叫

  前面在_.bind的原始碼中,可以看到裡面引數有個連線的過程concat,即外圍的引數和內部閉包的引數連線到了一起,那麼完全可以在呼叫外圍函式的時候傳入一部分引數佔個位子,呼叫內部函式的時候傳入剩下一部分引數,從而實現一次FP不完全呼叫(或者叫做偏函式)的過程,用原生的舉個例子:
  

function add(a, b, c) {
    return a + b + c;
}
var addOne = add.bind(null, 1);
var addOneAndTwo = addOne.bind(null, 2);
console.log(add(1,2,3));
console.log(addOne(2,3));
console.log(addOneAndTwo(3));

可以看到,這樣子只能靠左邊佔位,在某些情況下並不能滿足需求,underscore封裝了一個更加通用的偏函式可以使用 _ 實現任意位置的佔位:

_.partial = function(func) {
    //呼叫外圍函式傳入的佔位引數,可能會有下劃線表示跳過不填,比如 _, 'arg1', _, 'arg3'
    var boundArgs = slice.call(arguments, 1);
    var bound = function() { 
        var position = 0,
        length = boundArgs.length;
        //arguments為呼叫內部閉包傳入的引數 args為最終引數
        var args = Array(length); //args先預設為佔位引數的數量,後面可能還會變長
        for (var i = 0; i < length; i++) {
            //如果佔位引數是下劃線佔位符,則用arguments中的引數填補
            args[i] = boundArgs[i] === _ ? arguments[position++] : boundArgs[i];
        }
        //如果arguemnts引數還沒填完,接著填
        while (position < arguments.length) args.push(arguments[position++]);
        //還是要小心建構函式的情況
        return executeBound(func, bound, this, this, args);
    };
    return bound;
};
function sub(a, b) {
    return a - b;
}
var subOne = _.partial(sub, _, 1);
console.log(subOne(10)); //9

3.總結

  1. 用new呼叫建構函式,如果顯式return了一個非object型別,則會被忽略;如果return了一個非null的object型別的變數或值,它將會成為新生成的例項。
  2. 將this固定住的技巧,obj.method = obj.method.bind(obj);
  3. 用bind可以實現不完全呼叫或者偏函式。