从underscore源码看如何实现map函数

前言

经常会看到这样的面试题,让面试者手动实现一个 map 函数之类的,嗯,貌似并没有什么实际意义。但是对于知识探索的步伐不能停止,现在就来分析下如何实现 map 函数。

PS: 关于 underscore 源码解读注释,详见:underscore 源码解读。

Array.prototype.map

先来了解下原生 map 函数。

map 函数用于对数组元素进行迭代遍历,返回一个新函数并不影响原函数的值。map 函数接受一个 callback 函数以及执行上下文参数,callback 函数带有三个参数,分别是迭代的当前值,迭代当前值的索引下标以及迭代数组自身。map 函数会给数组中的每一个元素按照顺序执行一次 callback 函数。

var arr = [1,2,3];
var newArr = arr.map(function(item, index){
    if(index == 1) return item * 3;
    return item;
})
console.log(newArr); // [1, 6, 3]

实现

for 循环

实现思路其实挺简单,使用 for 循环对原数组进行遍历,每个元素都执行一遍回调函数,同时将值赋值给一个新数组,遍历结束将新数组返回。

将自定义的 _map 函数依附在 Array 的原型上,省去了对迭代数组类型的检查等步骤。

Array.prototype._map = function(iteratee, context) {
    var arr = this;
    var newArr = [];
    for(var i=0; i<arr.length; i++) {
        newArr[i] = iteratee.call(context, arr[i], i, arr);
    }
    return newArr;
}

测试如下:

var arr = [1,2,3];
var newArr = arr._map(function(item, index){
    if(index == 1) return item * 3;
    return item;
})
console.log(newArr); // [1, 6, 3]

好吧,其实重点不在于自己如何实现 map 函数,而是解读 underscore 中是如何实现 map 函数的。

underscore 中的 map 函数

.map 相对于 Array.prototype.map 来说,功能更加完善和健壮。 .map 源码:

  /**
   * @param obj 对象
   * @param iteratee 迭代回调
   * @param context 执行上下文
   * _.map 的强大之处在于 iteratee 迭代回调的参数可以是函数,对象,字符串,甚至不传参
   * _.map 会根据不同类型的 iteratee 参数进行不同的处理
   * _.map([1,2,3], function(num){ return num * 3; }); // [3, 6, 9]
   * _.map([{name: 'Kevin'}, {name: 'Daisy'}], 'name'); // ["Kevin", "Daisy"]
   */
  _.map = _.collect = function(obj, iteratee, context) {
    // 针对不同类型的 iteratee 进行处理
    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 接受 3 个参数,分别是迭代对象,迭代回调和执行上下文。iteratee 迭代回调在函数内部进行了特殊处理,为什么要这么做,原因是因为iteratee 迭代回调的参数可以是函数,对象,字符串,甚至不传参。

// 传入一个函数
_.map([1,2,3], function(num){ return num * 3; }); // [3, 6, 9]

// 什么也不传
_.map([1,2,3]); // [1, 2, 3]

// 传入一个对象
_.map([{name:'Kevin'}, {name: 'Daisy', age: 18}], {name: 'Daisy'}); // [false, true]

// 传入一个字符串
_.map([{name:'Kevin'}, {name: 'Daisy', age: 18}], 'name'); // ["Kevin", "Daisy"]

先来分析下 _.map 函数内部是如何针对不同类型的 iteratee 进行处理的。

cb

cb 函数源码如下(PS: 所有的注释都是个人见解):

var cb = function(value, context, argCount) {
    // 是否使用自定义的 iteratee 迭代器,外部可以自定义 iteratee 迭代器
    if (_.iteratee !== builtinIteratee) return _.iteratee(value, context);
    // 处理不传入 iteratee 迭代器的情况,直接返回迭代集合
    // _.map([1,2,3]); // [1,2,3]
    if (value == null) return _.identity;
    // 优化 iteratee 迭代器是函数的情况
    if (_.isFunction(value)) return optimizeCb(value, context, argCount);
    // 处理 iteratee 迭代器是对象的情况
    if (_.isObject(value) && !_.isArray(value)) return _.matcher(value);
    // 其他情况的处理,数组或者基本数据类型的情况
    return _.property(value);
};

cb 函数内部针对 value 类型(也就是 iteratee 迭代器)的不同做了相应的处理。

underscore 中允许我们自定义 _.iteratee 函数的,也就是可以自定义迭代回调。

if (_.iteratee !== builtinIteratee) return _.iteratee(value, context);

正常情况下,这个判断语句应该为 false,因为在 underscore 内部中已经定义了 _.iteratee 就是与 builtinIteratee 相等。

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

这样做的目的是为了区分是否有自定义 .iteratee 函数,如果有重写了 .iteratee 函数,就使用自定义的函数。

那么为什么会允许我们去修改 .iteratee 函数呢?试想如果场景中只是需要 .map 函数的 iteratee 参数是函数的话,就用该函数处理数组元素,如果不是函数,就直接返回当前元素,而不是将 iteratee 进行针对性处理。

_.iteratee = function(value, context) {
    if(typeof value === 'function') {
        return function(...rest) {
            return value.call(context, ...rest)
        };
    }
    return function(value) {
        return value;
    }
}

测试如下:

_.map([{name:'Kevin'}, {name: 'Daisy', age: 18}], 'name');

image

需要注意的是,很多迭代函数都依赖于 .iteratee 函数,所以要谨慎使用自定义 .iteratee。

当然了,如果没有 iteratee 迭代器的情况下,也是直接返回迭代集合。

正常使用情况下,传入的 iteratee 迭代器应该都会是函数的,为了提升性能,在 cb 函数内部针对 iteratee 迭代器是函数的情况做了性能处理,也就是 optimizeCb 函数。

optimizeCb

optimizeCb 函数源码如下:

  /**
   * 优化迭代器回调
   * @param func 迭代器回调 
   * @param context 执行上下文
   * @param argCount 指定迭代器回调接受参数个数
   */
  var optimizeCb = function(func, context, argCount) {
    // 如果没有传入上下文,直接返回
    if (context === void 0) return func;
    // 根据指定接受参数进行处理
    switch (argCount) {
      case 1: return function(value) {
        // value: 当前迭代元素
        return func.call(context, value);
      };
      // The 2-parameter case has been omitted only because no current consumers
      // made use of it.
      case null:
      case 3: return function(value, index, collection) {
        // value: 当前迭代元素,index: 迭代元素索引,collection: 迭代集合
        return func.call(context, value, index, collection);
      };
      case 4: return function(accumulator, value, index, collection) {
        // accumulator: 累加器,value: 当前迭代元素,index: 迭代元素索引,collection: 迭代集合
        return func.call(context, accumulator, value, index, collection);
      };
    }
    // 当指定迭代器回调接受参数的个数超过4个,就用 arguments 代替
    // 为什么不直接使用这段代码而是在上面根据 argCount 处理接受的参数
    // 1. arguments 存在性能问题
    // 2. call 比 apply 速度更快
    return function() {
      return func.apply(context, arguments);
    };
  };

optimizeCb 函数内部主要是针对 iteratee 迭代器接受的参数进行性能优化。当指定迭代器回调接受参数的个数超过4个,就用 arguments 代替。为什么要这样处理?原因是因为 arguments 存在性能问题,且 call 比 apply 速度更快。具体分析会在下一篇给出解释,这里不做过多的分析。

_.matcher

回到前面对 iteratee 迭代器类型做处理的话题,如果 iteratee 迭代器是对象的情况,又该如何处理?也就是这样:

_.map([{name:'Kevin'}, {name: 'Daisy', age: 18}], {name: 'Daisy'}); // [false, true]

在 cb 函数内部使用了 .matcher 函数处理这种情况,来分析下 .matcher 函数都做了哪些事情。 _.matcher 源码如下:

  /**
   * 传入一个属性对象,返回一个属性检测函数,检测对象是否具有指定属性
   * var matcher = _.matcher({name: '白展堂'});
    var obj = {name: '白展堂', age: 25};
    matcher(obj); // true
   */
  _.matcher = _.matches = function(attrs) {
    // 合并复制对象,attrs 必须是 Objdect 类型
    // arrts 的值为空或者其他数据类型,都能保证 attrs 是 Object 类型
    attrs = _.extendOwn({}, attrs);
    // 返回属性检测函数
    return function(obj) {
      // 检测 obj 对象是否具有指定属性 attrs
      return _.isMatch(obj, attrs);
    };
  };

_.matcher 的主要作用就是检测 obj 对象是否具有指定属性 attrs,例如:

var matcher = _.matcher({name: '白展堂'});
var obj = {name: '白展堂', age: 25};
var obj2 = {name: '吕秀才', age: 25};

matcher(obj); // true
matcher(obj2); // false

具体的检测是使用了 .isMatch 函数, .isMatch 源码如下:

  /**
   * 检测对象中是否包含指定属性
   * var obj = {name: '白展堂', age: 25}; 
   * var attrs = {name: '白展堂'}; 
   * _.isMatch(obj, attrs); // true
   */
  _.isMatch = function(object, attrs) {
    var keys = _.keys(attrs), length = keys.length;
    if (object == null) return !length;
    var obj = Object(object);
    for (var i = 0; i < length; i++) {
      var key = keys[i];
      if (attrs[key] !== obj[key] || !(key in obj)) return false;
    }
    return true;
  };

核心部分就梳理清楚了,回到 .map 函数,可以看到,也是使用了 for 循环来实现 map 功能,和我们自己实现了思路一致,有一点不同的是, .map 函数的第一个参数,不仅限于数组,还可以是对象和字符串。

_.map('name'); // ["n", "a", "m", "e"]

_.map({name: '白展堂', age: 25}); // ["白展堂", 25]

在 _.map 函数内部,对类数组的对象也进行了处理。

遗留问题

到这里就梳理清楚了在 underscore 中是如何实现 map 函数的,以及优化性能方案。可以说在 underscore 中每行代码都很精炼,值得反复揣摩。

同时在梳理过程中,遗留了两个问题:

  • arguments 存在性能问题
  • call 比 apply 速度更快

这两个问题将会在下一篇中进行详细的分析。

参考

  • https://blog.fundebug.com/2017/07/26/master_map_filter_by_hand_written/
  • https://github.com/mqyqingfeng/Blog/issues/58

今天这篇文章是读者@小兴投的稿,欢迎大家投稿,让你的文章可以分享给更多的小伙伴,他的 github 上有一个上千 star 的前端导航,点击原文可以访问他的 github

原文发布于微信公众号 - 前端桃园(betaoyuan)

原文发表时间:2018-12-11

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券