underscore 基礎篇

underscore 基礎篇

這一部分,我們會介紹一些 underscore 中的基礎內容,包括有 underscore 的結構,模塊封裝,以及常用的內部函數等,這一部分是我們之后理解 underscore 的各個 API 的必經之路。
讓我們先從 underscore 的結構入手。

結構
作用域包裹

與其他第三庫一樣,underscore 也通過立即執(zhí)行函數來包裹自己的業(yè)務邏輯。一般而言,這些庫的立即執(zhí)行函數主要有以下目的:

  • 避免全局污染:所有庫的邏輯,庫所定義和使用的變量全部被封裝到了該函數的作用域中。
  • 隱私保護:但凡在立即執(zhí)行函數中聲明的函數、變量等,除非是自己想暴露,否則絕無可能在外部獲得。
(function() {
  //  ...執(zhí)行邏輯
})()

所以,當我們撰寫自己的庫的時候,也可以考慮在最外層包裹上一個立即執(zhí)行函數。既不受外部影響,也不給外部添麻煩。

_對象

underscore 有下劃線的意思,所以 underscore 通過一個下劃線變量 _ 來標識自身,值得注意的是,_ 是一個函數對象,之后,所有的 api 都會被掛載到這個到對象上,如 _.each, _.map 等:

var _ = function(obj) {
  if (obj instanceof _) return obj;
  if (!(this instanceof _)) return new _(obj);
  this._wrapped = obj;
};

那么問題來了, 為什么 _ 會被設計成一個函數對象,而不是普通對象 {} 呢。顯然,這樣的設計意味著之后可能存在這樣的代碼片:

var xxx = _(obj);

這樣做的目的是什么呢? 我會在之后的 underscore 內容拾遺 / 面向對象風格的支持 再進行解釋。

執(zhí)行環(huán)境判斷

underscore 既能夠服務于瀏覽器,又能夠服務于諸如 nodejs 所搭建的服務端,underscore 對象 _ 將會依托于當前的所處環(huán)境,掛載到不同的全局空間當中(瀏覽器的全局對象是 window,node 的全局對象是 global)。下面代碼展示了 underscore 是如何判斷自己所處環(huán)境的,這個判斷邏輯也為我們自己想要撰寫前后端通用的庫的時候提供了幫助:

var root = typeof self == 'object' && self.self === self && self ||
        typeof global == 'object' && global.global === global && global ||
        this;
window or self ?

在 underscore 的判斷所處環(huán)境的代碼中,似乎我們沒有看到 window 對象的引用,其實,在瀏覽器環(huán)境下,self 保存的就是當前 window 對象的引用。那么相比較于使用 window,使用 self 有什么優(yōu)勢呢?我們看到MDN上有這么一句話:
The Window.self read-only property returns the window itself, as a WindowProxy. It can be used with dot notation on a window object (that is, window.self) or standalone (self). The advantage of the standalone notation is that a similar notation exists for non-window contexts, such as in Web Workers.
概括來說,就是 self 還能用于一些不具有窗口的上下文環(huán)境中,比如Web Workers。所以,為了服務于更多場景,underscore 選擇了更加通用的 self 對象。
其次,如果處于 node 環(huán)境,那么underscore的對象 _ 還將被作為模塊導出:

if (typeof exports != 'undefined' && !exports.nodeType) {
  if (typeof module != 'undefined' && !module.nodeType && module.exports) {
    exports = module.exports = _;
  }
  exports._ = _;
} else {
  root._ = _;
}

松弛綁定

默認情況下,underscore 對象 _ 會覆蓋全局對象上同名的 _ 屬性。但是,underscore 也不過于蠻橫,他會保存之前已經存在的 _ 屬性, 因為像是 lodash 這樣的一些庫也喜歡將自己的對象命名為 _:

var previousUnderscore = root._;

當用戶已經在全局對象上綁定了 _ 對象時,可以通過 underscore 提供的 noConflict 函數來重命名 underscore 對象,或者說是手動獲得 underscore 對象,避免與之前的 _ 沖突:

var underscore = _.noConflict();

看到 noConflict 的源碼實現,我們發(fā)現,在其內部,將會恢復原來全局對象上的 _:

/**
 * 返回一個 underscore 對象,把_所有權交還給原來的擁有者(比如 lodash)
 */
_.noConflict = function () {
  //  回復原來的_指代的對象
  root._ = previousUnderscore;
  //  返回 underscore 對象
  return this;
};

局部變量的妙用

underscore 本身也依賴了不少 js 的原生方法,如下代碼所示,underscore 會通過局部變量來保存一些他經常用到的方法或者屬性,這樣做的好處有如下兩點:

  • 在后續(xù)使用到這些方法或者屬性時,避免了冗長的代碼書寫。
  • 減少了對象成員的訪問深度,(Array.prototype.push --> push), 這樣做能帶來一定的性能提升,具體可以參看 《高性能 javascript》
var ArrayProto = Array.prototype, ObjProto = Object.prototype;
var SymbolProto = typeof Symbol !== 'undefined' ? Symbol.prototype : null;

var push = ArrayProto.push.
    slice = ArrayProto.slice,
    toString = ObjProto.toString,
    hasOwnProperty = ObjProto.hasOwnProperty;

undefined 的處理

不可靠的 undefined

在 JavaScript 中,假設我們想判斷一個是否是 undefined,那么我們通常會這樣寫:

if (a === undefined) {}

但是,JavaScript 中的 undefined 并不可靠,我們試著寫這樣一個函數:

function test(a) {
  var undefined = 1;
  console.log(undefined); //  => 1
  if (a === undefined) {
    // ...
  }
}

可以看到,undefined 被輕易地修改為了 1,使得我們之后的對于 undefined 理解引起歧義。所以,在 JavaScript 中,把 undefined 直接解釋為 “未定義” 是有風險的,因為這個標識符可能被篡改。
在 ES5 之前,全局的 undefined 也是可以被修改的,而在 ES5 中,該標識符被設計為了只讀標識符, 假如你現在的瀏覽器不是太老,你可以在控制臺中輸入以下語句測試一下:

undefined = 1;
console.log(undefined); // => undefined
曲線救國

現在我們能夠明確的,標識符 undefined 并不能真正反映 “未定義”,所以我們得通過其他手段獲得這一語義。幸好 JavaScript 還提供了 void 運算符,該運算符會對指定的表達式求值,并返回受信的undefined:

void expression

最常見的用法是通過以下運算來獲得 undefined,表達式為 0 時的運算開銷最小:

void 0;
// or
void(0);

在 underscore 中,所有需要獲得 undefined 地方,都通過 void 0 進行了替代。
當然,曲線救國的方式不只一種,我們看到包裹jquery的立即執(zhí)行函數:

(function(window, undefined) {
  //  ...
})(window)

在這個函數中,我們沒有向其傳遞第二參數(形參名叫 undefined),那么第二個參數的值就會被傳遞上 “未定義”,因此,通過這種方式,在該函數的作用域中所有的 undefined 都為受信的 undefined。

迭代!迭代!迭代

使用迭代,而不是循環(huán)

在函數式編程,更推薦使用迭代

var results = _.map([1,2,3], function(elem) {
  return elem * 2;
}); //  => [2, 4, 6]

而不是循環(huán)

var results = [];
var elems = [1, 2, 3];
for (var i = 0, length = elems.length; i < length; i++) {
  result.push(elems[i]*2);
} //  => [2, 4, 6]
iteratee

對于一個迭代來說,他至少由如下兩個部分構成:

  • 被迭代集合
  • 當前迭代過程
    在 underscore 中,當前迭代過程是一個函數,他被稱為 iteratee(直譯為被迭代者),他將對當前的迭代元素進行處理。我們看到 _.map 的實現:
_.map = _.collect = function (obj, iteratee, context) {
  iteratee = cb(iteratee, context);
  var keys = !isArrayLike(obj) && _.keys(obj),
      length = (keys || obj).length,
      results = Array(length);  //  定長初始化數組
  for (var index = 0; index < length; index++) {
    var currentKey = keys ? keys[index] : index;
    results[index] = iteratee(obj[currentKey], currentKey, obj);
  }
  return results;
};

我們傳遞給的 _.map 的第二個參數就是一個 iteratee,他可能是函數,對象,甚至是字符串,underscore 會將其統(tǒng)一處理為一個函數。這個處理由 underscore 的內置函數 cb 來完成。下面我們看一下 cb 的實現:

var cb = function(value, context, argCount) {
  //  是否用自定義的iteratee
  if (_.iteratee !== builtinIteratee) return _.iteratee(value, context);
  //  針對不同的情況
  if (value == null)  return _.identity;
  if (_.isFunction(value))  return optimizeCb(value, context, argCount);
  if (_.isObject(value)) return _.matcher(value);

  return _.property(value);
};

cb 將根據不同情況來為我們的迭代創(chuàng)建一個迭代過程 iteratee,服務于每輪迭代:

  • value 為 null
    如果傳入的 value 為 null,亦即沒有傳入 iteratee,則 iteratee 的行為只是返回當前迭代元素自身,比如:
var results = _.map([1, 2, 3]); //  => results: [1, 2, 3]
  • value 為一個函數
    如果傳入 value 是一個函數,那么通過內置函數 optimizeCb 對其進行優(yōu)化,optimizeCb 的作用放到之后講,先來看個傳入函數的例子:
// => results:  [
//  "[1,2,3]'s 0 position is 1",
//  "[1,2,3]'s 1 position is 2",
//  "[1,2,3]'s 2 position is 3"
// ]
  • value 為一個對象
    如果 value 傳入的是一個對象,那么返回的 iteratee(_.matcher)的目的是想要知道當前被迭代元素是否匹配給定的這個對象:
var results = _.map([{name: 'yoyoyohamapi'}, {name: 'wxj', age: 13}], {name: 'wxj'});
//  => results: [false, true]
  • value 是字面量,如數字,字符串等
    var results = _.map([{name: 'yoyoyohamapi'}, {name: 'wxj', age: 13}], {name: 'wxj'});
    // => results: [false, true]
var results = _.map([{name: 'yoyoyohamapi'}, {name: 'wxj'}], 'name');
//  results: ['yoyoyohamapi', 'wxj'];

自定義 iteratee

在 cb 函數的代碼中,我們也發(fā)現了 underscore 支持通過覆蓋其提供的 _.iteratee 函數來自定義 iteratee,更確切的說,來自己決定如何產生一個 iteratee:

var cb = function (value, context, argCount) {
  // ...
  if (_.iteratee !== builtinIteratee) return _.iteratee(value, context);
  // ...
}

我們看一下 iteratee 函數的實現:

_.iteratee = builtinIteratee = function (value, context) {
  return cb(value, context, Infinity);
};

默認的 _.iteratee 函數仍然是把生產 iteratee 的工作交給 cb 完成,并且通過變量 buildIteratee 保存了默認產生器的引用,方便之后我們覆蓋了 _.iteratee 后,underscore 能夠通過比較 _.iteratee 與 buildIteratee 來知悉這次覆蓋(也就知悉了用戶想要自定義 iteratee 的生產過程)。
比如當傳入的 value 是對象時,我們不想返回一個 _.matcher 來判斷當前對象是否滿足條件,而是返回當前元素自身(雖然這么做很無聊),就可以這么做:

_.iteratee = function(value, context) {
  //  現在,value為對象時,也是返回自身
  if (value == null || _.isObject(value)) return _.identity;
  if (_.isFunction(value))  return optimizeCb(value, context, argCount);
  return _.property(value);
};

現在運行之前的例子,看一下有什么不同:

var results = _.map([{name: 'yoyoyohamapi'}, {name: 'wxj', age: 13}], {name: 'wxj'});
//  => results: [{name: 'yoyoyohamapi'}, {name: 'wxj', age: 13}];

重置默認的.iteratee改變迭代過程中的行為只在underscore最新的master分支支持, 發(fā)布版的1.8.3并不支持, 我們可以看到發(fā)布版的1.8.3中的cb代碼如下,并沒有判斷.iteratee是否被覆蓋:

var cb = function (value, context, argCount) {
  if (value == null)  return _.identity;
  if (_.isFunction(value))  return optimizeCb(value, context, argCount);
  if (_.isObject(value))  return _.matcher(value);
  return _.property(value);
};

optimizeCb

在上面的分析中,我們知道,當傳入的 value 是一個函數時,value 還要經過一個叫 optimizeCb 的內置函數才能獲得最終的 iteratee:

var cb = function(value, context, argCount) {
  //  ...
  if (_.isFunction(value))  return optimizeCb(value, context, argCount);
  //  ...
};

顧名思義, optimizeCb有優(yōu)化回調的意思,所以他是一個對最終返回的iteratee進行優(yōu)化的過程,我們看到他的源碼:

/** 優(yōu)化回調(特指函數中傳入的回調)
 *
 * @param func 待優(yōu)化回調函數
 * @param context 執(zhí)行上下文
 * @param argCount 參數個數
 * @returns {function}
 */
var optimizeCb = function(func, context, argCount) {
  //  一定要保證回調的執(zhí)行上下文存在
  if (context === void 0) return func;
  switch (argCount == null ? 3 : argCount) {
    case 1: return function(value) {
      return func.call(context, value);
    };
    case 2: return function(value, other) {
      return func.call(context, value, other);
    };
    case 3: return function() {
      return func.call(context, value, index, collection);
    };
    case 4: return function() {
      return func.call(context, accumlator, value, index, collection);
    };
  }
  return function() {
    return func.apply(context, arguments);
  };
};

optimizeCb 的總體思路就是:傳入待優(yōu)化的回調函數 func,以及迭代回調需要的參數個數 argCount,根據參數個數分情況進行優(yōu)化。

  • argCount == 1,即 iteratee 只需要 1 個參數
    在 underscore 的 .times 函數的實現中,.times 的作用是執(zhí)行一個傳入的 iteratee 函數 n 次,并返回由每次執(zhí)行結果組成的數組。它的迭代過程 iteratee 只需要 1 個參數 -- 當前迭代的索引:
//  執(zhí)行iteratee函數n次,返回每次執(zhí)行結果構成的數組
_.times = function(n, iteratee, context) {
  var accum = Array(Math.max(0, n));
  iteratee = optimizeCb(iteratee, context, 1);
  for (var i = 0; i < n; i++) accum[i] = iteratee(i);
  return accum;
};

看一個 _.times 的使用例子:

function getIndex(index) {
  return index;
}
var results = _.times(3, getIndex); // => [0, 1, 2]
  • argCount == 2,即 iteratee 需要 2 個參數
    該情況在 underscore 沒用使用,所以最新的 master 分支已經不再考慮這個參數個數為 2 的情況。
  • argCount == 3(默認),即 iteratee 需要 3 個參數
    這 3 個參數是:
    • value:當前迭代元素的值
    • index:迭代索引
    • collection:被迭代集合
      在 _.map, _.each, _.filter 等函數中,都是給 argCount 賦值了 3:
_.each([1, 2, 3], function() {
  console.log("被迭代的集合:"+collection+"; 迭代索引:"+index+"; 當前迭代的元素值"+value);
});
// =>
// 被迭代的集合:1,2,3; 迭代索引:0; 當前迭代的元素值:1
// 被迭代的集合:1,2,3; 迭代索引:1; 當前迭代的元素值:2
// 被迭代的集合:1,2,3; 迭代索引:2; 當前迭代的元素值:3
  • argCount == 4,即 iteratee 需要 4 個參數
    這 4 個參數分別是:
  • accumulator:累加器
  • value:迭代元素
  • index:迭代索引
  • collection:當前迭代集合
    那么這個累加器是什么意思呢?在 underscore 中的內部函數 createReducer 中,就涉及到了 4 個參數的情況。該函數用來生成 reduce 函數的工廠,underscore 中的 _.reduce 及 _.reduceRight 都是由它創(chuàng)建的:
/**
 * reduce 函數的工廠函數,用于生成一個reducer,通過參數決定reduce的方向
 * @param dir 方向 left or right
 * @returns {function}
 */
var createReduce = function (dir) {
  var reducer = function (obj, iteratee, memo, initial) {
    var keys = !isArrayLike(obj) && _.keys(obj),
        length = (keys || obj).length,
        index = dir > 0 ? 0 : length - 1;
    //  memo用來記錄最新的reduce結果
    //  如果reduce沒有初始化 memo, 則默認為首個元素 (從左開始則為第一個元素, 從右則為最后一個元素)
    if (!initial) {
      memo = obj[keys ? keys[index] : index]; 
      index += dir;
    }
    for (; index >= 0 && index < length; index += dir) {
      var currentKey = keys ? keys[index] : index;
      //  執(zhí)行 reduce 回調, 刷新當前值
      memo = iteratee(memo, obj[currentKey], currentKey, obj);
    } 
    return memo;        
  };

  return function () {
    // 如果參數正常, 則代表已經初始化了 memo
    var initial = arguments.length >= 3;
    //  reducer 因為引入了累加器, 所以優(yōu)化函數的第三個參數傳入了 4,
    //  這樣, 新的迭代回調第一個參數就是當前的累加結果
    return reducer(obj, optimizeCb(iteratee, context, 4), memo, initial);
  };
};

我們可以看到,createReduce 最終創(chuàng)建的 reducer 就是需要一個累加器,該累加器需要被初始化,看一個利用 _.reduce 函數求和的例子:

var sum = _.reduce([1,2,3,4,5], function(accumlator, value, index, collection) {
  return accumlator + value;
}, 0); //  => 15;

rest 參數

什么是 rest 參數,就是自由參數,松散參數,這里的自由和松散都是指的參數個數是隨意的,與之對應的是-- 固定參數。

從一個加法器開始說起

現在,我們完成一個函數,該函數支持對兩個數進行求和,并將結果返回。

function add(a, b) {
  return a + b;
}

但我們想對更多的數求和呢?那我們首先想到用數組來傳遞。

function add(numbers) {
  return _.reduce(numbers, function(accum, current) {
    return accum + current;
  }, 0);
}

或者直接利用 JavaScript 中的 arguments:

function add() {
  var numbers = Array.prototype.slice.call(arguments);
  return _.reduce(numbers, function(accum, current) {
    return accum + current;
  }, 0);
  add(4,3,4,1,1); //  => 13
}

現在,我們獲得了一個更加自由的加法函數。但是,如果現在的需求變?yōu)楸仨殏鬟f至少一個數到加法器呢?

function add(a) {
  var rest = Array.prototype.slice.call(arguments, 1);
  return _.reduce(rest, function(accum, current) {
    return accum + current;
  }, a);
}
add(2, 3, 4, 5);  // => 14

在這個 add 實現中,我們已經開始有了 rest 參數的雛形,除了自由和松散,rest 還有一層意思,就是他的字面意思-- 剩余,所以在許多語言環(huán)境中,rest 參數從最后一個形參開始,表示剩余的參數。

更理想的方式

然而最后一個 add 函數還是把對 rest 參數的獲取耦合到了 add 的執(zhí)行邏輯中,同時,這樣做還會引起歧義,因為在 add 函數的使用者看來,add 函數似乎只需要一個參數 a。 而在 python,java 等語言中,rest 參數是需要顯示聲明的,這種聲明能讓函數調用者知道哪些參數是 rest 參數,比如 python 中通過 * 標識 rest 參數:

def add(a, *numbers):
  sum = a
  for n in numbers:
    sum = sum + n * n
  return sum

所以,更理想的方式是,提供一個更直觀的方式讓開發(fā)者知道哪個參數是 rest 參數,比如,現在有一個函數,其支持 rest 參數,那么我們總是假定這類函數的最后一個參數是 rest 參數, 為此,我們需要創(chuàng)建一個工廠函數,他接受一個現有的函數,包裝該函數,使之支持 rest 參數:

function add(a, rest) {
  return _.reduce(rest, function(accum, currrent) {
    return accum + current;
  }, a);
}

function genResFunc(func) {
  //  新返回的函數支持rest參數
  return function() {
    //  獲得形參個數
    var argLength = func.length;
    //  rest參數的起始位置為最后一個形參位置
    var startIndex = argLength - 1;
    // 最終需要的參數數組
    var args = Array(argLength);
    //  設置rest參數
    var rest = Array.prototype.slice.call(arguments, startIndex);
    //  設置最終調用時需要的參數
    for (var i = 0; i < startIndex; i++) {
      args[i] = arguments[i];
    }
    args[startIndex] = rest;
    //  => args: [a,b,c,d,[rest[0],rest[1],rest[2]]]
    return func.apply(this, args);
  }
}
addWithRest = genRestFunc(add);

addWithRest(1, 2, 3, 4);  //  => 10

記住,在 JavaScript 中,函數也是對象,并且我們能夠通過函數對象的 length 屬性獲得其形參個數
最后,我們來看一下 underscore 的官方實現,他暴露了一個 _.restArgs 函數,通過給該函數傳遞一個 func 參數,能夠使得 func 支持 rest 參數:

/**
 * 一個包裝器,包裝函數func,使之支持rest參數
 * @param func 需要rest參數的函數
 * @param startIndex 從哪里開始標識rest參數, 如果不傳遞, 默認最后一個參數為rest參數
 * @returns {Function} 返回一個具有rest參數的函數
 */
var restArgs = function (func, startIndex) {
  // rest參數從哪里開始,如果沒有,則默認視函數最后一個參數為rest參數
  // 注意, 函數對象的length屬性, 揭示了函數的形參個數
  /*
   ex: function add(a, b) {return a + b;}
   console.log(add.length); //  2
   */
  startIndex = startIndex == null ? func.length - 1 : +startIndex;
  //  返回一個支持rest參數的函數
  return function () {
    //  校正參數, 以免出現負值情況
    var length = Math.max(arguments.length - startIndex, 0);
    //  為rest參數開辟數組存放
    var rest = Array(length);
    //  假設參數從2個開始:func(a, b, *rest)
    //  調用:func(1, 2, 3, 4, 5)
    //  實際的調用是:func.call(this, 1, 2, [3, 4, 5]);
    for (var index = 0; index < length; index++) {
      rest[index] = arguments[index + startIndex];
    }
    //  根據rest參數不同,分情況調用函數,需要注意的是,rest參數總是最后一個參數,否則有歧義
    switch (startIndex) {
      case 0:
           // call的參數一個個傳
           return func.call(this, rest);
      case 1:
           return func.call(this, arguments[0], rest);
      case 2:
           return func.call(this, arguments[0], rest);
    }
    //  如果不是上面三種情況, 而是更通用的(應該是作者寫著寫著發(fā)現這個switch case可能越寫越長, 就用了apply)
    var args = Array(startIndex + 1);
    //  先拿到前面參數
    for (index = 0; index < startIndexl index++) {
      args[index] = arguments[index];
    }
    //  拼接上剩余參數
    args[startIndex] = rest;
    return func.apply(this, args);
  };
};

//  別名
_.restArgs = restArgs;

測試一下:

function add(a, rest) {
  return _.reduce(rest, function(accum, current) {
    return accum + current;
  }, a);
}

var addWithRest = _.restArgs(add);
addWithRest(1, 2, 3, 4);  //  => 10

注意,restArgs 函數也是 underscore 最新的 master 分支上才支持的,1.8.3 版本不具備這個功能。

ES6 中的 rest

現在,最新的ES6標準已經能夠支持 rest 參數,他的用法如下:

function f(x, ...y) {
  //  y is an Array
  return x * y.length;
}
f(3, "hello", true) == 6;

所以如果你的項目能夠用到 ES6 了,就用 ES6 的寫法吧,畢竟他是標準。

創(chuàng)建對象的正確姿勢

無類

對于熟悉面向對象的同學,比如 java 開發(fā)者,一開始接觸到 JavaScript 會非常懊惱,因為在 JavaScript 中,是沒有類的概念的,即便 ES6 引入了 class,extends 等關鍵字,那也只是語法糖(syntax sugar),而不能讓我們真正創(chuàng)建一個類。我們知道,類的作用就在于 繼承 和 派生。作為面向對象的三大特征之一的繼承,其優(yōu)劣在此不再贅述,下面我們看一下如何在缺乏類支持的 JavaScript 實現繼承。

is-a

我們說 A 繼承子 B,實際上可以轉義為 is-a(什么是什么) 關系: A 是 B,比如 Student 繼承自 Person,Student 是一個 Person,只不過比 Person 更加具體。
換言之,繼承描述了一種層次關系,或者說是一種遞進關系,一種更加具體化的遞進過程。所以,繼承也不真正需要 “類” 來支撐,他只需要這個遞進關系。
JavaScript 中雖然沒有類,但是是有對象的概念,我們仍然可以借用對象來描述這個遞進關系,只不過 JavaScript 換了一種描述方式,叫做 原型(prototype)。顧名思義,原型描述了一個對象的來由:
原型 ----> 對象
顯然,二者就構成了上面我們提到的層次遞進關系,在js中,原型和對象間的聯系鏈條是通過對象的 proto 屬性來完成的。舉個更具體的例子,學生對象(student)的原型是人(person),因為學生源于人,在 JavaScript 中我們可以這樣實現二者的遞進關系:

var person = {
  name: '',
  eat: function() {
    console.log("吃飯");
  }
};

var student = {
  name: 'wxj',
  learn: function() {
    console.log("學習");
  }
};
student.__proto__ = person;
//  由于student is a person,所以他也能夠eat
student.eat();

但是上面的代碼片是存在問題的,他描述的是“某個學生是某個人”。你只需要通過上面的代碼片了解如何在 JavaScript 中通過 proto 實現一種層次遞進關系,完成功能的擴展和復用。

原型繼承

上面例子的繼承雖然達到了目的,但是還不是我們熟悉的傳統(tǒng)的面向對象的繼承的寫法,面向對象的繼承的應當是 “Class extends Class”, 而不是上面代碼片體現的 “object extends object”。在 JavaScript 中,借助于構造函數(constructor),new 運算符和構造函數的 prototype 屬性,我們能夠模擬一個類似 “Class extends Class” 的繼承(比如在上例中,我們想要實現 “Student extends Person”),這種方式稱之為原型繼承:

//  聲明一個叫Person的構造函數,為了讓他更像是一個類,我們將其大寫
function Person(name) {
  this.name = name;
}

//  Student '類'
function Student(name) {
  this.name = name;
}

//  通過函數的prototype屬性,我們聲明了Person的原型,并且可以在該原型上掛載我們想要的屬性或者方法
Person.prototype.eat = function() {
  console.log(this.name + "在吃飯");
}

//  現在讓Student來繼承Person
Student.prototype = new Person();

//  擴展Student
Student.prototype.learn = function() {
  console.log(this.name + "在學習");
}

//  實例化一個Student
var student = new Student("wxj");

student.eat();  //  "wxj在吃飯"
student.learn();  //  "wxj在學習"

new Person() 實際上是自動為我們解決了如下幾件事:

  • 創(chuàng)建一個對象,并設置其指向的原型:
var obj = {'__proto__': Person.prototype};
  • 調用 Person() 構造方法,并且將上下文(this)綁定到 obj 上, 即通過 Person 構造 obj:
Person.apply(obj, arguments);
  • 返回創(chuàng)建的對象:
return obj;

所以,Student.prototype = new Person(); //...; var student = new Student("wxj");的等效過程如下:

//  繼承
Student.prototype = {};
Person.apply(Student.prototype, arguments);

//  ...

//  實例化Student
var student = {'__proto__': Student.prototype};
Student.apply(student, "wxj");

那么,我們在調用 student.eat() 時,沿著 proto 提供的線索,最終在 Person.prototype 這個原型上找到該方法。
有了這些知識,我們也不難模擬出一個 new 來實現對象的創(chuàng)建:

function newObj(constructor) {
  var obj = {
    '__proto__': constructor.prototype;
  };

  return function() {
    constructor.apply(obj, arguments);
    return obj;
  };
};

//  測試
function Person(name) {
  this.name = name;
}

//  Student '類'
function Student(name) {
  this.name = name;
}

Person.prototype.eat = function() {
  console.log(this.name + "在吃飯");
}

//  繼承
Student.prototype = newObj(Person)();
//  擴展Student
Student.prototype.learn = function() {
  console.log(this.name + "在學習");
}

//  實例化
var student = newObj(Student)("wxj");
student.eat();  //  =>"sxj在吃飯"
student.learn();  //  =>  "wxj在學習"
Object.create

另外,ES5 更為我們提供了新的對象創(chuàng)建方式:

Object.create(proto, [ propertiesObject ]);

現在,我們可以這樣創(chuàng)建一個繼承自 proto 的對象:

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

Person.prototype.eat = function() {
  console.log(this.name + "在吃飯");
}

var student = Object.create(Person.prototype);
student.name = 'wxj';
student.eat();  // "wxj在吃飯"

在構造對象上,Object.create(proto) 的過程如下:

  • 創(chuàng)建一個臨時的構造函數,并將其原型指向 proto:
var Temp = function() {}; //   一般會通過閉包將Temp常駐內存,避免每次create時都創(chuàng)建空的構造函數
Temp.prototype = proto;
  • 通過 new 新建對象,該對象由這個臨時的構造函數構造,注意,不會像構造函數傳遞任何參數:
var obj = new Temp();
  • 清空臨時構造函數的原型,并返回創(chuàng)建的對象:
Temp.prototype = null;  //  防止內存泄漏
return obj;

完整的 Object.create 參看 MDN。

為什么要用Object.create()

如此看來,Object.create 似乎也只是 new 的一次包裹,并無任何優(yōu)勢可言。但是,正是這次包裹,使我們新建對象更加靈活。使用 new 運算符最大的限制條件是:被 new 運算的只能是一個構造函數,如果你想由一個普通對象構造新的對象,使用 new 就將會報錯:

var person = {
  name: '',
  eat: function() {
    console.log(this.name + "在吃飯");
  }
};

var student = new person;
//  =>"Uncaught TypeError: person is not a constructor(…)"

但是 Object.create 就不依賴構造函數,因為在上面對其工作流程的介紹中,我們知道,Object.create 內部已經維護了一個構造函數,并將該構造函數的 prototype 屬性指向傳入的對象,因此,他比 new 更加靈活:

var student = Object.create(person);
student.name = "wxj";
student.eat();  //  'wxj在吃飯'

另外,Object.create 還能傳遞第二參數,該參數是一個屬性列表,能夠初始化或者添加新對象的屬性,則更加豐富了創(chuàng)建的對象時的靈活性和擴展性,也正是由此功能,Object.create 的內部實現不需要向臨時構造函數傳遞參數:

var student = Object.create(person, {
  name: {value: 'wxj', writable: false}
});
student.name = "yoyoyo";
student.eat();  //  "wxj在吃飯"

更多用例參看 MDN。

underscore 是如何創(chuàng)建對象的

下面我們來看看 underscore 是怎樣創(chuàng)建對象的:

/**
 * 創(chuàng)建一個對象,該對象繼承自prototype
 * 并且保證該對象在其原型上掛載屬性不會影響所繼承的prototype
 * @param {object} prototype
 */
var baseCreate = function (prototype) {
    if (!_.isObject(prototype)) return {};
    // 如果存在原生的創(chuàng)建方法(Object.create),則用原生的進行創(chuàng)建
    if (nativeCreate) return nativeCreate(prototype);
    // 利用Ctor這個空函數,臨時設置對象原型
    Ctor.prototype = prototype;
    // 創(chuàng)建對象,result.__proto__ === prototype
    var result = new Ctor;
    // 還原Ctor原型
    Ctor.prototype = null;
    return result;
};

我們可以看到,underscore 利用 baseCreate 創(chuàng)建對象的時候會先檢查當前環(huán)境是否已經支持了 Object.create,如果不支持,會創(chuàng)建一個簡易的 polyfill:

// 利用Ctor這個空函數,臨時設置對象原型
Ctor.prototype = prototype;
// 創(chuàng)建對象,result.__proto__ === prototype
var result = new Ctor;
// 防止內存泄漏,因為閉包的原因,Ctor常駐內存
Ctor.prototype = null;

而之所以叫 baseCreate,也是因為其只做了原型繼承,而不像 Object.create 那樣還支持傳遞屬性列表。

ES6 中的 class 及 extends 語法糖

在 ES6 中,支持了 class 和 extends 關鍵字,讓我們在撰寫類和繼承的時候更加靠近 java 等語言的寫法:

class Person {
  constructor(name){
    this.name=name;
  }

  eat() {
    console.log(this.name+'在吃飯');
  }
}

class Student extends Person{
  constructor(name){
    super(name);
  }

  learn(){
    console.log(this.name+"在學習");
  }
}

// 測試
var student = new Student("wxj");
student.eat(); // "wxj在吃飯"
student.learn(); // "wxj在學習"

但要注意,這只是語法糖,ES6 并沒有真正實現類的概念。我們看下 Babel(一款流行的 ES6 編譯器)對上面程序的編譯結果,當中我們能看到如下語句:

Object.defineProperty(target, descriptor.key, descriptor);
Object.create();

可見,class 的實現還是依賴于 ES5 提供的 Object.defineProperty 和Object.create 方法。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容