underscore 基础篇

underscore 基础篇

这一部分,我们会介绍一些 underscore 中的基础内容,包括有 underscore 的结构,模块封装,以及常用的内部函数等,这一部分是我们之后理解 underscore 的各个 API 的必经之路。
让我们先从 underscore 的结构入手。

结构
作用域包裹

与其他第三库一样,underscore 也通过立即执行函数来包裹自己的业务逻辑。一般而言,这些库的立即执行函数主要有以下目的:

  • 避免全局污染:所有库的逻辑,库所定义和使用的变量全部被封装到了该函数的作用域中。
  • 隐私保护:但凡在立即执行函数中声明的函数、变量等,除非是自己想暴露,否则绝无可能在外部获得。
(function() {
  //  ...执行逻辑
})()

所以,当我们撰写自己的库的时候,也可以考虑在最外层包裹上一个立即执行函数。既不受外部影响,也不给外部添麻烦。

_对象

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 内容拾遗 / 面向对象风格的支持 再进行解释。

执行环境判断

underscore 既能够服务于浏览器,又能够服务于诸如 nodejs 所搭建的服务端,underscore 对象 _ 将会依托于当前的所处环境,挂载到不同的全局空间当中(浏览器的全局对象是 window,node 的全局对象是 global)。下面代码展示了 underscore 是如何判断自己所处环境的,这个判断逻辑也为我们自己想要撰写前后端通用的库的时候提供了帮助:

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

在 underscore 的判断所处环境的代码中,似乎我们没有看到 window 对象的引用,其实,在浏览器环境下,self 保存的就是当前 window 对象的引用。那么相比较于使用 window,使用 self 有什么优势呢?我们看到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 还能用于一些不具有窗口的上下文环境中,比如Web Workers。所以,为了服务于更多场景,underscore 选择了更加通用的 self 对象。
其次,如果处于 node 环境,那么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 的源码实现,我们发现,在其内部,将会恢复原来全局对象上的 _:

/**
 * 返回一个 underscore 对象,把_所有权交还给原来的拥有者(比如 lodash)
 */
_.noConflict = function () {
  //  回复原来的_指代的对象
  root._ = previousUnderscore;
  //  返回 underscore 对象
  return this;
};

局部变量的妙用

underscore 本身也依赖了不少 js 的原生方法,如下代码所示,underscore 会通过局部变量来保存一些他经常用到的方法或者属性,这样做的好处有如下两点:

  • 在后续使用到这些方法或者属性时,避免了冗长的代码书写。
  • 减少了对象成员的访问深度,(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的立即执行函数:

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

在这个函数中,我们没有向其传递第二参数(形参名叫 undefined),那么第二个参数的值就会被传递上 “未定义”,因此,通过这种方式,在该函数的作用域中所有的 undefined 都为受信的 undefined。

迭代!迭代!迭代

使用迭代,而不是循环

在函数式编程,更推荐使用迭代

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

而不是循环

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 会将其统一处理为一个函数。这个处理由 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 将根据不同情况来为我们的迭代创建一个迭代过程 iteratee,服务于每轮迭代:

  • value 为 null
    如果传入的 value 为 null,亦即没有传入 iteratee,则 iteratee 的行为只是返回当前迭代元素自身,比如:
var results = _.map([1, 2, 3]); //  => results: [1, 2, 3]
  • value 为一个函数
    如果传入 value 是一个函数,那么通过内置函数 optimizeCb 对其进行优化,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 函数的代码中,我们也发现了 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分支支持, 发布版的1.8.3并不支持, 我们可以看到发布版的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有优化回调的意思,所以他是一个对最终返回的iteratee进行优化的过程,我们看到他的源码:

/** 优化回调(特指函数中传入的回调)
 *
 * @param func 待优化回调函数
 * @param context 执行上下文
 * @param argCount 参数个数
 * @returns {function}
 */
var optimizeCb = function(func, context, argCount) {
  //  一定要保证回调的执行上下文存在
  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 的总体思路就是:传入待优化的回调函数 func,以及迭代回调需要的参数个数 argCount,根据参数个数分情况进行优化。

  • argCount == 1,即 iteratee 只需要 1 个参数
    在 underscore 的 .times 函数的实现中,.times 的作用是执行一个传入的 iteratee 函数 n 次,并返回由每次执行结果组成的数组。它的迭代过程 iteratee 只需要 1 个参数 -- 当前迭代的索引:
//  执行iteratee函数n次,返回每次执行结果构成的数组
_.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 都是由它创建的:
/**
 * 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;
      //  执行 reduce 回调, 刷新当前值
      memo = iteratee(memo, obj[currentKey], currentKey, obj);
    } 
    return memo;        
  };

  return function () {
    // 如果参数正常, 则代表已经初始化了 memo
    var initial = arguments.length >= 3;
    //  reducer 因为引入了累加器, 所以优化函数的第三个参数传入了 4,
    //  这样, 新的迭代回调第一个参数就是当前的累加结果
    return reducer(obj, optimizeCb(iteratee, context, 4), memo, initial);
  };
};

我们可以看到,createReduce 最终创建的 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
}

现在,我们获得了一个更加自由的加法函数。但是,如果现在的需求变为必须传递至少一个数到加法器呢?

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 还有一层意思,就是他的字面意思-- 剩余,所以在许多语言环境中,rest 参数从最后一个形参开始,表示剩余的参数。

更理想的方式

然而最后一个 add 函数还是把对 rest 参数的获取耦合到了 add 的执行逻辑中,同时,这样做还会引起歧义,因为在 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

所以,更理想的方式是,提供一个更直观的方式让开发者知道哪个参数是 rest 参数,比如,现在有一个函数,其支持 rest 参数,那么我们总是假定这类函数的最后一个参数是 rest 参数, 为此,我们需要创建一个工厂函数,他接受一个现有的函数,包装该函数,使之支持 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);
    }
    //  如果不是上面三种情况, 而是更通用的(应该是作者写着写着发现这个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 的写法吧,毕竟他是标准。

创建对象的正确姿势

无类

对于熟悉面向对象的同学,比如 java 开发者,一开始接触到 JavaScript 会非常懊恼,因为在 JavaScript 中,是没有类的概念的,即便 ES6 引入了 class,extends 等关键字,那也只是语法糖(syntax sugar),而不能让我们真正创建一个类。我们知道,类的作用就在于 继承 和 派生。作为面向对象的三大特征之一的继承,其优劣在此不再赘述,下面我们看一下如何在缺乏类支持的 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 实现一种层次递进关系,完成功能的扩展和复用。

原型继承

上面例子的继承虽然达到了目的,但是还不是我们熟悉的传统的面向对象的继承的写法,面向对象的继承的应当是 “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() 实际上是自动为我们解决了如下几件事:

  • 创建一个对象,并设置其指向的原型:
var obj = {'__proto__': Person.prototype};
  • 调用 Person() 构造方法,并且将上下文(this)绑定到 obj 上, 即通过 Person 构造 obj:
Person.apply(obj, arguments);
  • 返回创建的对象:
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 来实现对象的创建:

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 更为我们提供了新的对象创建方式:

Object.create(proto, [ propertiesObject ]);

现在,我们可以这样创建一个继承自 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) 的过程如下:

  • 创建一个临时的构造函数,并将其原型指向 proto:
var Temp = function() {}; //   一般会通过闭包将Temp常驻内存,避免每次create时都创建空的构造函数
Temp.prototype = proto;
  • 通过 new 新建对象,该对象由这个临时的构造函数构造,注意,不会像构造函数传递任何参数:
var obj = new Temp();
  • 清空临时构造函数的原型,并返回创建的对象:
Temp.prototype = null;  //  防止内存泄漏
return obj;

完整的 Object.create 参看 MDN。

为什么要用Object.create()

如此看来,Object.create 似乎也只是 new 的一次包裹,并无任何优势可言。但是,正是这次包裹,使我们新建对象更加灵活。使用 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 还能传递第二参数,该参数是一个属性列表,能够初始化或者添加新对象的属性,则更加丰富了创建的对象时的灵活性和扩展性,也正是由此功能,Object.create 的内部实现不需要向临时构造函数传递参数:

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

更多用例参看 MDN。

underscore 是如何创建对象的

下面我们来看看 underscore 是怎样创建对象的:

/**
 * 创建一个对象,该对象继承自prototype
 * 并且保证该对象在其原型上挂载属性不会影响所继承的prototype
 * @param {object} prototype
 */
var baseCreate = function (prototype) {
    if (!_.isObject(prototype)) return {};
    // 如果存在原生的创建方法(Object.create),则用原生的进行创建
    if (nativeCreate) return nativeCreate(prototype);
    // 利用Ctor这个空函数,临时设置对象原型
    Ctor.prototype = prototype;
    // 创建对象,result.__proto__ === prototype
    var result = new Ctor;
    // 还原Ctor原型
    Ctor.prototype = null;
    return result;
};

我们可以看到,underscore 利用 baseCreate 创建对象的时候会先检查当前环境是否已经支持了 Object.create,如果不支持,会创建一个简易的 polyfill:

// 利用Ctor这个空函数,临时设置对象原型
Ctor.prototype = prototype;
// 创建对象,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 方法。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,468评论 5 473
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,620评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,427评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,160评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,197评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,334评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,775评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,444评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,628评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,459评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,508评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,210评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,767评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,850评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,076评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,627评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,196评论 2 341