前言
经常会看到这样的面试题,让面试者手动实现一个 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');
需要注意的是,很多迭代函数都依赖于 .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 速度更快
这两个问题将会在下一篇中进行详细的分析。