Blog icon indicating copy to clipboard operation
Blog copied to clipboard

JavaScript专题之从零实现jQuery的extend

Open mqyqingfeng opened this issue 7 years ago • 43 comments

前言

jQuery 的 extend 是 jQuery 中应用非常多的一个函数,今天我们一边看 jQuery 的 extend 的特性,一边实现一个 extend!

extend 基本用法

先来看看 extend 的功能,引用 jQuery 官网:

Merge the contents of two or more objects together into the first object.

翻译过来就是,合并两个或者更多的对象的内容到第一个对象中。

让我们看看 extend 的用法:

jQuery.extend( target [, object1 ] [, objectN ] )

第一个参数 target,表示要拓展的目标,我们就称它为目标对象吧。

后面的参数,都传入对象,内容都会复制到目标对象中,我们就称它们为待复制对象吧。

举个例子:

var obj1 = {
    a: 1,
    b: { b1: 1, b2: 2 }
};

var obj2 = {
    b: { b1: 3, b3: 4 },
    c: 3
};

var obj3 = {
    d: 4
}

console.log($.extend(obj1, obj2, obj3));

// {
//    a: 1,
//    b: { b1: 3, b3: 4 },
//    c: 3,
//    d: 4
// }

当两个对象出现相同字段的时候,后者会覆盖前者,而不会进行深层次的覆盖。

extend 第一版

结合着上篇写得 《JavaScript专题之深浅拷贝》,我们尝试着自己写一个 extend 函数:

// 第一版
function extend() {
    var name, options, copy;
    var length = arguments.length;
    var i = 1;
    var target = arguments[0];

    for (; i < length; i++) {
        options = arguments[i];
        if (options != null) {
            for (name in options) {
                copy = options[name];
                if (copy !== undefined){
                    target[name] = copy;
                }
            }
        }
    }

    return target;
};

extend 深拷贝

那如何进行深层次的复制呢?jQuery v1.1.4 加入了一个新的用法:

jQuery.extend( [deep], target, object1 [, objectN ] )

也就是说,函数的第一个参数可以传一个布尔值,如果为 true,我们就会进行深拷贝,false 依然当做浅拷贝,这个时候,target 就往后移动到第二个参数。

还是举这个例子:

var obj1 = {
    a: 1,
    b: { b1: 1, b2: 2 }
};

var obj2 = {
    b: { b1: 3, b3: 4 },
    c: 3
};

var obj3 = {
    d: 4
}

console.log($.extend(true, obj1, obj2, obj3));

// {
//    a: 1,
//    b: { b1: 3, b2: 2, b3: 4 },
//    c: 3,
//    d: 4
// }

因为采用了深拷贝,会遍历到更深的层次进行添加和覆盖。

extend 第二版

我们来实现深拷贝的功能,值得注意的是:

  1. 需要根据第一个参数的类型,确定 target 和要合并的对象的下标起始值。
  2. 如果是深拷贝,根据 copy 的类型递归 extend。
// 第二版
function extend() {
    // 默认不进行深拷贝
    var deep = false;
    var name, options, src, copy;
    var length = arguments.length;
    // 记录要复制的对象的下标
    var i = 1;
    // 第一个参数不传布尔值的情况下,target默认是第一个参数
    var target = arguments[0] || {};
    // 如果第一个参数是布尔值,第二个参数是才是target
    if (typeof target == 'boolean') {
        deep = target;
        target = arguments[i] || {};
        i++;
    }
    // 如果target不是对象,我们是无法进行复制的,所以设为{}
    if (typeof target !== 'object') {
        target = {}
    }

    // 循环遍历要复制的对象们
    for (; i < length; i++) {
        // 获取当前对象
        options = arguments[i];
        // 要求不能为空 避免extend(a,,b)这种情况
        if (options != null) {
            for (name in options) {
                // 目标属性值
                src = target[name];
                // 要复制的对象的属性值
                copy = options[name];

                if (deep && copy && typeof copy == 'object') {
                    // 递归调用
                    target[name] = extend(deep, src, copy);
                }
                else if (copy !== undefined){
                    target[name] = copy;
                }
            }
        }
    }

    return target;
};

在实现上,核心的部分还是跟上篇实现的深浅拷贝函数一致,如果要复制的对象的属性值是一个对象,就递归调用 extend。不过 extend 的实现中,多了很多细节上的判断,比如第一个参数是否是布尔值,target 是否是一个对象,不传参数时的默认值等。

接下来,我们看几个 jQuery 的 extend 使用效果:

target 是函数

在我们的实现中,typeof target 必须等于 object,我们才会在这个 target 基础上进行拓展,然而我们用 typeof 判断一个函数时,会返回function,也就是说,我们无法在一个函数上进行拓展!

什么,我们还能在一个函数上进行拓展!!

当然啦,毕竟函数也是一种对象嘛,让我们看个例子:

function a() {}

a.target = 'b';

console.log(a.target); // b

实际上,在 underscore 的实现中,underscore 的各种方法便是挂在了函数上!

所以在这里我们还要判断是不是函数,这时候我们便可以使用《JavaScript专题之类型判断(上)》中写得 isFunction 函数

我们这样修改:

if (typeof target !== "object" && !isFunction(target)) {
    target = {};
}

类型不一致

其实我们实现的方法有个小 bug ,不信我们写个 demo:

var obj1 = {
    a: 1,
    b: {
        c: 2
    }
}

var obj2 = {
    b: {
        c: [5],

    }
}

var d = extend(true, obj1, obj2)
console.log(d);

我们预期会返回这样一个对象:

{
    a: 1,
    b: {
        c: [5]
    }
}

然而返回了这样一个对象:

{
    a: 1,
    b: {
        c: {
            0: 5
        }
    }
}

让我们细细分析为什么会导致这种情况:

首先我们在函数的开始写一个 console 函数比如:console.log(1),然后以上面这个 demo 为例,执行一下,我们会发现 1 打印了三次,这就是说 extend 函数执行了三遍,让我们捋一捋这三遍传入的参数:

第一遍执行到递归调用时:

var src = { c: 2 };
var copy = { c: [5]};

target[name] = extend(true, src, copy);

第二遍执行到递归调用时:

var src = 2;
var copy = [5];

target[name] = extend(true, src, copy);

第三遍进行最终的赋值,因为 src 是一个基本类型,我们默认使用一个空对象作为目标值,所以最终的结果就变成了对象的属性!

为了解决这个问题,我们需要对目标属性值和待复制对象的属性值进行判断:

判断目标属性值跟要复制的对象的属性值类型是否一致:

  • 如果待复制对象属性值类型为数组,目标属性值类型不为数组的话,目标属性值就设为 []

  • 如果待复制对象属性值类型为对象,目标属性值类型不为对象的话,目标属性值就设为 {}

结合着《JavaScript专题之类型判断(下)》中的 isPlainObject 函数,我们可以对类型进行更细致的划分:


var clone, copyIsArray;

...

if (deep && copy && (isPlainObject(copy) ||
        (copyIsArray = Array.isArray(copy)))) {

    if (copyIsArray) {
        copyIsArray = false;
        clone = src && Array.isArray(src) ? src : [];

    } else {
        clone = src && isPlainObject(src) ? src : {};
    }

    target[name] = extend(deep, clone, copy);

} else if (copy !== undefined) {
    target[name] = copy;
}

循环引用

实际上,我们还可能遇到一个循环引用的问题,举个例子:

var a = {name : b};
var b = {name : a}
var c = extend(a, b);
console.log(c);

我们会得到一个可以无限展开的对象,类似于这样:

循环引用对象

为了避免这个问题,我们需要判断要复制的对象属性是否等于 target,如果等于,我们就跳过:

...
src = target[name];
copy = options[name];

if (target === copy) {
    continue;
}
...

如果加上这句,结果就会是:

{name: undefined}

最终代码


// isPlainObject 函数来自于  [JavaScript专题之类型判断(下) ](https://github.com/mqyqingfeng/Blog/issues/30)
var class2type = {};
var toString = class2type.toString;
var hasOwn = class2type.hasOwnProperty;

function isPlainObject(obj) {
    var proto, Ctor;
    if (!obj || toString.call(obj) !== "[object Object]") {
        return false;
    }
    proto = Object.getPrototypeOf(obj);
    if (!proto) {
        return true;
    }
    Ctor = hasOwn.call(proto, "constructor") && proto.constructor;
    return typeof Ctor === "function" && hasOwn.toString.call(Ctor) === hasOwn.toString.call(Object);
}


function extend() {
    // 默认不进行深拷贝
    var deep = false;
    var name, options, src, copy, clone, copyIsArray;
    var length = arguments.length;
    // 记录要复制的对象的下标
    var i = 1;
    // 第一个参数不传布尔值的情况下,target 默认是第一个参数
    var target = arguments[0] || {};
    // 如果第一个参数是布尔值,第二个参数是 target
    if (typeof target == 'boolean') {
        deep = target;
        target = arguments[i] || {};
        i++;
    }
    // 如果target不是对象,我们是无法进行复制的,所以设为 {}
    if (typeof target !== "object" && !isFunction(target)) {
        target = {};
    }

    // 循环遍历要复制的对象们
    for (; i < length; i++) {
        // 获取当前对象
        options = arguments[i];
        // 要求不能为空 避免 extend(a,,b) 这种情况
        if (options != null) {
            for (name in options) {
                // 目标属性值
                src = target[name];
                // 要复制的对象的属性值
                copy = options[name];

                // 解决循环引用
                if (target === copy) {
                    continue;
                }

                // 要递归的对象必须是 plainObject 或者数组
                if (deep && copy && (isPlainObject(copy) ||
                        (copyIsArray = Array.isArray(copy)))) {
                    // 要复制的对象属性值类型需要与目标属性值相同
                    if (copyIsArray) {
                        copyIsArray = false;
                        clone = src && Array.isArray(src) ? src : [];

                    } else {
                        clone = src && isPlainObject(src) ? src : {};
                    }

                    target[name] = extend(deep, clone, copy);

                } else if (copy !== undefined) {
                    target[name] = copy;
                }
            }
        }
    }

    return target;
};

思考题

如果觉得看明白了上面的代码,想想下面两个 demo 的结果:

var a = extend(true, [4, 5, 6, 7, 8, 9], [1, 2, 3]);
console.log(a) // ???
var obj1 = {
    value: {
        3: 1
    }
}

var obj2 = {
    value: [5, 6, 7],

}

var b = extend(true, obj1, obj2) // ???
var c = extend(true, obj2, obj1) // ???

专题系列

JavaScript专题系列目录地址:https://github.com/mqyqingfeng/Blog

JavaScript专题系列预计写二十篇左右,主要研究日常开发中一些功能点的实现,比如防抖、节流、去重、类型判断、拷贝、最值、扁平、柯里、递归、乱序、排序等,特点是研(chao)究(xi) underscore 和 jQuery 的实现方式。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

mqyqingfeng avatar Jul 14 '17 02:07 mqyqingfeng

var isObject = function (data) {
  return Object.prototype.toString.call(data) === '[object Object]'
}

var extend = function(deep) {
  var sources = typeof deep === 'boolean' && deep ? Array.prototype.slice.call(arguments, 1) : Array.prototype.slice.call(arguments)
  var i = 0;
  var obj = {};
  for (; i < sources.length; i++) {
    if (!isObject(sources[i])) {
      console.error("Function[extend] parmas must be Object")
      return false
    }
    for (var key in sources[i]) {
      if (deep === true && isObject(sources[i][key]) && obj[key]) {
        obj[key] = extend(deep, obj[key], sources[i][key])
        continue
      }
      if (sources[i].hasOwnProperty(key)) {
        obj[key] = sources[i][key]
      }

    }
  }
  return obj;
}

我没有考虑数组对象的情况,只是一个简单的对象的复制。我只是觉得没必要写的你那么繁琐,比如target,i,我觉得这些变量必要性都不大的,可以像我这样简写,不知道你对这种写法有啥看法,或者改进的建议

TaurusWood avatar Jul 16 '17 13:07 TaurusWood

@TaurusWood 我觉得写得很好呀,文章中的写法是抽自 jQuery 的 extend 的写法,考虑到了很多情况,比如不同类型之间的拷贝、循环引用,甚至是性能上的考虑,比如 for 循环高于 for in ,直接遍历 arguments 对象而非先转换成数组再遍历等,你的写法相对简化,但相对的,实现的功能也少了一点。我觉得这个看需求吧,如果现在的可以满足你的需求,那就是很好的~

mqyqingfeng avatar Jul 19 '17 04:07 mqyqingfeng

楼主的深复制方法只支持json对象,并不完美啊。可以参考http://jerryzou.com/posts/dive-into-deep-clone-in-javascript/

zhanba avatar Aug 31 '17 03:08 zhanba

为什么 var c = extend(true, obj2, obj1) 答案会是

c = {
    value: [5, 6, 7]
}

感觉应该是

c = {
     value: {
         3: 1
    }
}

fondadam avatar Sep 01 '17 05:09 fondadam

@fondadam 单独看这个结果,当然是 c = { value: { 3: 1 }},然而这其实是个陷阱,因为当执行前一句 var b = extend(true, obj1, obj2)的时候, obj1 的值已经发生了改变~

mqyqingfeng avatar Sep 01 '17 06:09 mqyqingfeng

@mqyqingfeng 哎呀 掉坑里了

话说你的blog都讲的好详细,谢谢分享。

fondadam avatar Sep 01 '17 06:09 fondadam

extend 第一版中的 src 变量好像赋值后没用到哦

cobish avatar Sep 04 '17 05:09 cobish

@cobish 哈哈,确实如此,感谢指出~

mqyqingfeng avatar Sep 05 '17 04:09 mqyqingfeng

function extend() {
    // 默认不进行深拷贝
    var deep = false;
    var name, options, src, copy;
    var length = arguments.length;
    // 记录要复制的对象的下标
    var i = 1;
    // 第一个参数不传布尔值的情况下,target默认是第一个参数
    var target = arguments[0] || {};
    // 如果第一个参数是布尔值,第二个参数才是target
    if (typeof target == 'boolean') {
        deep = target;
        target = arguments[i] || {};
        i++;
    }
    // 如果target不是对象,我们无法进行复制的所以设为{}
    if (typeof target !== 'object') {
        target = {}
    }
    for (; i < length; i++) { // 从i 开始 没有deep i = 1 有deep i = 2
        options = arguments[i]
        if (options !== null) { //不为null和undefined
            for (name in options) {

                src = target[name]
                copy = options[name]

                if (deep && copy && typeof copy == 'object') {
                	console.log(deep, src, copy)
                    src = extend(deep, src, copy)
                    //  target[name] = extend(deep,src,copy)
                } else if (copy !== undefined) {
                    target[name] = copy
                }
            }
        }
    }
    return target

}

var obj1 = {
    a: 1,
    b: {
        c: 2
    }
}

var obj2 = {
    b: {
        c: [5],

    }

}

var d = extend(true, obj1, obj2)
console.log(d); // {a:1,b:{c:2}}

只是在深拷贝的时候把 target[name]换成了 src 怎么会这样?

1391020381 avatar Oct 25 '17 08:10 1391020381

@1391020381 我搞错了,第二版是没有这个问题的,这是因为:

if (deep && copy && typeof copy == 'object') {
     console.log(deep, src, copy)
    // wrong 这样并不会修改 target[name]的值
     src = extend(deep, src, copy)
    // right
    target[name] = extend(deep,src,copy)
 } else if (copy !== undefined) {
      target[name] = copy
}

mqyqingfeng avatar Oct 25 '17 09:10 mqyqingfeng

@mqyqingfeng 是不是当 target[name]是对象的时候,src保存的是target[name]的指针副本, 当再次给它赋值时,只是改变了src的指向,而target[name]没有变化。即前面参数传递的意思是一样的。

1391020381 avatar Oct 25 '17 09:10 1391020381

@1391020381 正是如此,不过无论 target[name] 是不是对象,修改 src 的值都不会改变 target[name] 的值,举个例子:

// 不是对象
var value = {
	a: 1
}
var src = value.a;

src = 2;

console.log(value.a); // 1

// 是对象
var value = {
	a: {
		num: 1
	}
}
var src = value.a;

src = 2;

console.log(value.a); // {num: 1}

mqyqingfeng avatar Oct 25 '17 10:10 mqyqingfeng

对的不是对象的时候,也是重新给src赋值,不会影响value.a。大神回复,我受宠若惊,我会一直关注大神的 @mqyqingfeng

1391020381 avatar Oct 25 '17 10:10 1391020381

@1391020381 过奖了,大家一起相互交流讨论,有的时候,我可能因为某些原因没能及时回复,还请不要介意~

mqyqingfeng avatar Oct 25 '17 10:10 mqyqingfeng

// 要求不能为空 避免 extend(a,,b) 这种情况 if (options != null) { ........ } 参数如果是空的,按道理获取值是undefined,这个不应该判断是options!=undefined么,怎么是null,这个没看明白哈,求指教

zzzzzyb avatar Nov 02 '17 01:11 zzzzzyb

@zzzzzyb 因为 null == nullundefined == null 的结果都为 true,所以 options != null ,如果 options 为 null 或者 undefine,结果都为 false,所以无论是 extend(a,,b) 或者你传 extend(a, null, b)都是可以跳过这个参数的

mqyqingfeng avatar Nov 02 '17 03:11 mqyqingfeng

不严格的话,undefined==undefined,也是true,就结果来说用undefined来判断也是一样的吧@mqyqingfeng

zzzzzyb avatar Nov 02 '17 03:11 zzzzzyb

@zzzzzyb 所以也可以啦~ 不过 null 更短呀~ <( ̄︶ ̄)> 除此之外,值得一提的是,在低版本的浏览器中,undefined 的值是可以被更改的,举个例子:

window.undefined = null;
console.log(window.abc === undefined); // false

当然大多时候,不会出现这样的问题,但是有可能曲折的出现:

var n = window.abc; // 实际上 abc 并没有被设置,n 为 undefined
window[n]='text'; // 不小心更改了 undefined 的值
console.log(window.abc === undefined); // false

所以有些开发者可能更倾向于使用 null 进行判断

mqyqingfeng avatar Nov 02 '17 04:11 mqyqingfeng

@mqyqingfeng 确实更严谨点,谢谢大佬指点

zzzzzyb avatar Nov 02 '17 04:11 zzzzzyb

感觉ES6的Object.assign函数和extend很相似,不过assign使用的也是浅拷贝

liujuntao123 avatar Nov 14 '17 12:11 liujuntao123

@liujuntao123 确实是这样的,我觉得正是 extend 这种函数的广泛应用才导致了 assign 函数的诞生~

mqyqingfeng avatar Nov 15 '17 10:11 mqyqingfeng

非常感谢作者的讲解,一开始看有点不懂,后来带着敲,自己理解懂了点,然后自己仿着写了一遍,也弄了个深拷贝,只支持数组和普通对象,循环引用没过滤,技术比较菜写这个都写了很久,害羞的丢个链接让前辈们帮我看看不足之处,我感觉我写的 i f判断太多。 https://github.com/lizhongzhen11/myStudy/blob/master/jq/extend.js

lizhongzhen11 avatar Jan 19 '18 08:01 lizhongzhen11

@lizhongzhen11 写得很不错哈~ 直接看确实会有点难度啦,最好还是自己敲一遍,边敲边理解,效果会更好。写代码的时候注意哪些东西是重复书写的,就可以将其提取出来,也算是一个优化,比如 Object.prototype.toString.call(source) === '[object Object]' 和 Object.prototype.toString.call(source) === '[object Array]' 被重复的用到,就可以提取成 isObject 和 isArray 函数,这样代码的语义会更好~

mqyqingfeng avatar Jan 20 '18 11:01 mqyqingfeng

哈哈哈哈哈,好开心能得到前辈的点评,我会继续努力的!!!

lizhongzhen11 avatar Jan 20 '18 11:01 lizhongzhen11

jQuery中$.extend没有办法深拷贝存在自身引用的对象,一旦出现自身引用会爆栈的。$.extend对于循环引用的处理并不彻底。 这是我自己的实现方式,比$.extend简单,但是加入了对象自环的处理。

/**
 * obj: 待拷贝的对象
 * target: 目标对象
 * parent: target的各个父级
 * _target: 用来分解target,如果有值,必有_target.target[_target.key] === target,主要用来while赋值用。(出现自引用)
 **/
function deepCopy(obj, target, parent = null, _target = null) {
    target = target || {};

    let _parent = parent;
    while (_parent) {
        if (_parent.parentObj === obj) {
            _target.target[_target.key] = obj;
            // 不能直接给target赋值,否则引用的指针丢失,只能给target的属性赋值
            // target = obj (错!!!)
            // 所以才要把target[key]拆分成target和key
            return;
        }
        _parent = _parent.parent;
    }

    Object.keys(obj).forEach(function(key) {
        let currentCopy = obj[key];
        if (typeof currentCopy === 'object' && currentCopy !== null) {
            target[key] = currentCopy.constructor === Array ? [] : {};
            deepCopy(
                currentCopy,
                target[key],
                {
                    parentObj: obj, // 当前target的直接父级
                    parent: parent // 保存target的所有非直接父级
                },
                {
                    // 把target[key]拆分成target和key,供while赋值,否则对传入的target直接赋值会导致指针丢失
                    target: target,
                    key: key
                }
            );
        } else {
            target[key] = currentCopy;
        }
    });

    return target;
}

xunan007 avatar Feb 25 '18 05:02 xunan007

@Devinnn dalao的思路非常赞~不过这种实现的话,每次只能从一个对象继承属性吧?需要继承多个对象的时候需要多次执行该方法。有个简单的修改:既然已经用了es6的默认参数功能,那么for (let key in obj) { if (obj.hasOwnProperty(key)) {xxx},这一段在没有else分支的情况下,可以用Object.keys(obj).forEach(key => {xxx})来代替吧(不过不确定会不会性能较差)。

Tan90Qian avatar Apr 16 '18 08:04 Tan90Qian

@Tan90Qian 大佬谈不上,你写的文章质量都很高😄 代码是参考了知乎上一篇深拷贝“找爸爸”的思路。写的不那么仔细,感谢提出不足。 这个代码确实是只考虑拷贝一个对象的属性。

xunan007 avatar Apr 16 '18 10:04 xunan007

@Devinnn 认错人了吧233 我只是一个读者,不是作者,还没毕业的萌新一枚

Tan90Qian avatar Apr 16 '18 10:04 Tan90Qian

@Tan90Qian 同,大三哈~

xunan007 avatar Apr 16 '18 10:04 xunan007

@Tan90Qian @StevenXN 后生可畏呀,两位同学!(๑•̀ㅂ•́)و✧

mqyqingfeng avatar Apr 24 '18 14:04 mqyqingfeng