jQuery 源码阅读 —— data.js

create: 2013-08-24 22:09


jQuery 中的 data 部分用于将数据与 DOM 节点进行关联。

举个简单的例子,假如我们的 HTML 代码中有如下内容:

    <button id="btn">Test</button>

接下来,在 JavaScript 代码中:

    var btn = $('#btn');
    btn.data('type', 'button');
    btn.on('click', function() {
        console.log(btn.data('type'));
    })

这样,当你点击按钮时,控制台便会输出 button 字符串。

除了用于增加和获取数据的 data() 方法,此外还有用于判断数据是否存在的 jQuery.hasData() 方法,清除数据的 removeData() 方法,整个 data 部分的 API 就这么简单。(data() 与 removeData() 同时为 jQuery 的静态和实例方法)

原理

jQuery 是如何将数据和 DOM 元素关联起来的?说起来可简单了,就是使用了最常见的 JavaScript 对象。

关于 Object 我们太熟悉了,可以说 JavaScript 中一切皆为对象,我们只需

    var o = {};

就创建了一个对象 o,而对象可以拥有属性,属性名为字符串,属性值则是任意类型:

    o.button = {};
    o.button.type = 'button';

我们假设 o.button 就是上面的 button 元素,那么我们是不是就已经把 DOM 元素和数据 type 关联在一起了?实际上 jQuery 内部的处理就是如此,jQuery 有一个 cache 对象就相当于我们定义的对象 o,它的作用就是保存 DOM 元素和数据之间的关联。

区分 DOM 元素

如果我们有两个 DOM 元素 btn1 和 btn2,二者都是 button 类型,我们进行如下处理:

    btn1.data('content', 'Hello');
    btn2.data('content', 'World');

那么 jQuery 内部是如何区分这两个 button 的,如果是你该如何来做?

哈,你肯定会想到,既然是两个不同的元素,那么我给它们分别分配一个独一无二的标识符来区分不就可以了吗?而在 core.js 中,恰好就定义了这么个标识符:jQuery.guid。

jQuery.guid 的初始值为 1,而且不断递增。

我们来写点代码:

    btn1.dataId = jQuery.guid++;
    btn2.dataId = jQuery.guid++;
    jQuery.cache[btn1.dataId] = { content: 'Hello' };
    jQuery.cache[btn2.dataId] = { content: 'World' };

瞧,btn1 和 btn2 的 dataId 不同,通过它就可以找到 cache 上对应的对象。只不过 jQuery 不会用 dataId 这么挫的名字来作为元素的标识符啦~至于是啥,看源码就清楚了!

acceptData()

在知道了原理,了解了如何区分 DOM 对象后,我们便可以阅读源码了。

但是我先提个问题,是不是所有的 DOM 对象都可以关联数据呢?

答案是否定的!

我们先看源码中执行 jQuery.extend 的部分:

    cache: {},

    expando: "jQuery" + (core_version + Math.random()).replace(/\D/g, ""),

    noData: {
        "embed": true,
        "object": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",
        "applet": true
    },

cache 我们已经知道了,是真正存储数据的对象。

expando,看名字就明白啦,它便是上面提到的 dataId 的替代者,至于为什么不用固定的名字,那是因为 jQuery 是可以多版本共存的,如果所有版本都用相同的 expando,那么使用某版本 jQuery 设置的数据就有可能被另一个版本 jQuery 的操作给覆盖掉,实在是够乱。

而 noData 对象则标明了哪些元素是不能关联数据的,包括了:

  1. embed 元素
  2. object 元素
  3. applet 元素

object 后面的内容是 Flash 的 classid,即除了 Flash 外,所有通过 embed、object、applet 标签引入的元素都无法关联数据,原因在 jQuery 的注释中解释了:为这些元素设置 expando 属性会引发无法捕获的异常。

知道了这些内容,acceptData() 方法中的逻辑便清晰了:

    // 不要为任何非元素节点设置数据,因为数据将无法被清除 (#8335)。
    if (elem.nodeType && elem.nodeType !== 1 && elem.nodeType !== 9) {
        return false;
    }

    var noData = elem.nodeName && jQuery.noData[elem.nodeName.toLowerCase()];

    return !noData || noData !== true && elem.getAttribute("classid") === noData;

先排除所有非元素的节点,nodeType 为 1 表示 Element,9 表示 Document。

再排除 noData 中列出的元素,当然对 object 要特殊处理。

internalData()

查看 jQuery.extend 中的其他内容你会发现,这些静态方法基本上都依赖于另外两个内部方法:internalData() 和 internalRemoveData(),仅仅是传入的参数不同。

internalData() 方法用于存储和读取数据。

    function internalData(elem, name, data, pvt /* 仅限内部使用 */ )

看参数列表就能明白,如果传入 data,表示设置数据,如果不传,则是读取数据,但是最后那个仅限内部使用的 pvt 参数是干什么的?

要知道 jQuery 内部同样会使用 data() 这样的方法来对元素进行数据操作,例如后面会讲到的关于事件处理部分。那这样就引发了一个问题,jQuery 如何来区分当前操作的数据是 jQuery 自己设置的,还是用户设置的?

解决办法就是将 jQuery 自己设置的数据和用户设置的数据分开。

    jQuery.cache[btn1.dataId] = {};

这是前面用过的示例代码,实际上这个代码并不完整,最接近真实情况的代码是:

    jQuery.cache[btn1.dataId] = { data: {} };

看,最外层的对象是给 jQuery 自己用的,这个对象的 data 属性则是留给用户的,就是说,当 pvt 为 true,jQuery 就去操作外层对象,如果为 false,就去操作里面的 data 对象,如此一来,就不怕操作数据的时候冲突了。

除了这个内部使用的参数外,jQuery 还定义了直供内部使用的 data() 和 removeData() 两个方法,只有在这两个方法内才会去传入 pvt 参数。

来看 internalData() 的源码:

    if (!jQuery.acceptData(elem)) {
        return;
    }

这句话过滤掉了无法设置数据的元素。

    var thisCache, ret,
        internalKey = jQuery.expando,
        getByName = typeof name === "string",

        // 我们需要对 DOM 节点和 JS 对象分别处理,因为 IE6-7 无法正确回收 DOM 和 JS 互相引用的对象
        isNode = elem.nodeType,

        // 只有 DOM 节点需要全局 jQuery 缓存;
        // JS 对象的数据直接绑定在对象本身,这样垃圾回收能够自动处理
        cache = isNode ? jQuery.cache : elem,

        id = isNode ? elem[internalKey] : elem[internalKey] && internalKey;

上面这段内容也比较简单,但是揭露了一个我没有提到的问题,那就是 data() 方法并非只能操作 DOM 元素,你可以传入 JavaScript 对象:

    var o = {};
    jQuery.data(o, 'type', 'object');

由于 IE 6-7 的问题,如果你在 DOM 节点上直接绑定 JavaScript 对象的话,垃圾回收器很可能会因为两者的循环引用而无法回收对象,因此造成内存泄露,而 JavaScript 对象则没有这方面的担忧,所以 jQuery 就直接将数据绑定到对象本身,而并非 jQuery.cache 中。

    if ((!id || !cache[id] || (!pvt && !cache[id].data)) && getByName && data === undefined) {
        return;
    }

如果元素或对象没有关联过数据,那么就不做操作(这里指读取数据操作)。

    if (!id) {
        if (isNode) {
            elem[internalKey] = id = core_deletedIds.pop() || jQuery.guid++;
        } else {
            id = internalKey;
        }
    }

如果 id 不存在,表明元素或对象没有关联过数据,而只有元素是需要在全局缓存的,因此要使用 guid,而 core_deletedIds 则是在 core.js 中定义的一个数组,用于放置废弃的 guid,这样就可以很有效率的利用 guid。

    if (!cache[id]) {
        cache[id] = {};

        if (!isNode) {
            cache[id].toJSON = jQuery.noop;
        }
    }

这里要提到的是 toJSON,这是保证对 JavaScript 对象进行序列化操作时不会将关联的数据也序列化出来。

    if (typeof name === "object" || typeof name === "function") {
        if (pvt) {
            cache[id] = jQuery.extend(cache[id], name);
        } else {
            cache[id].data = jQuery.extend(cache[id].data, name);
        }
    }

有时候要关联的数据可能是个对象而并非键值对,这时候需要执行浅拷贝。

    if (!pvt) {
        if (!thisCache.data) {
            thisCache.data = {};
        }

        thisCache = thisCache.data;
    }

理解 pvt 的含义。

    if (data !== undefined) {
        thisCache[jQuery.camelCase(name)] = data;
    }

设置数据。你可能注意到了 jQuery.camelCase() 方法,它是用于将类似 "a-b" 转换为 "aB" 形式,为什么需要这么做?后面讲到 data 属性时你就明白了。

    if (getByName) {
        ret = thisCache[name];

        if (ret == null) {

            ret = thisCache[jQuery.camelCase(name)];
        }
    } else {
        ret = thisCache;
    }

    return ret;

这是在获取数据,如果直接通过 name 获取不到的话,会尝试使用驼峰形式的名字再次获取一遍,最终返回结果。

internalData() 函数讲解完毕。

internalRemoveData()

其实这两个方法都不复杂,只要你了解 API 的用法,那么对源码中的逻辑也就会很清楚。

对于 jQuery.removeData(),你需要传入 element,如果传入 name,表示将与该 name 关联的数据删除,如果不传,那么就清除 element 上的所有数据。

来看下 internalRemoveData() 方法。

代码的前部分和 internalData() 类似,不赘述。

// 如果该对象没有缓存条目,那么就没有必要继续了
if ( !cache[ id ] ) {
    return;
}

if ( name ) {

    //获取数据缓存
    thisCache = pvt ? cache[ id ] : cache[ id ].data;

    if ( thisCache ) {

        // 支持数据键名为数组或空格分隔的字符串
        if ( !jQuery.isArray( name ) ) {

            // 在操作之前尝试以字符串作为键名
            if ( name in thisCache ) {
                name = [ name ];
            } else {

                // 如果驼峰格式的键名不存在,那么尝试使用空格来拆分字符串、
                name = jQuery.camelCase( name );
                if ( name in thisCache ) {
                    name = [ name ];
                } else {
                    name = name.split(" ");
                }
            }
        } else {
            // #12786 这里提到了之前的一个 bug,那就是使用 'a-a', 'b-b' 作为键名存储数据
            // 再用 removeData('a-a b-b') 删除,是没有问题的
            // 但使用 removeData([ 'a-a', 'b-b' ]) 却无法删除
            // 于是对于数组参数,将同时删除普通格式和驼峰格式的键名。
            name = name.concat( jQuery.map( name, jQuery.camelCase ) );
        }

        // 删除键名对应的数据
        for ( i = 0, l = name.length; i < l; i++ ) {
            delete thisCache[ name[i] ];
        }

        // 如果缓存中没有数据存在了,那么继续运行,并将缓存对象销毁
        if ( !( pvt ? isEmptyDataObject : jQuery.isEmptyObject )( thisCache ) ) {
            return;
        }
    }
}

在最后一段内容中,判断了缓存对象是否为空,这里用到了两个方法,当然先说说它们的调用形式。

比如说有两个函数:

function a(c) { alert(c); }
function b(c) { alert(c); }

如果我们还有一个变量 d,如果 d 为 true,就调用 a 方法,如果为 false 就调用 b 方法,那么就可以使用三目运算符 ? : 来替代 if-else 操作:

// if-else
if(d) {
    a(c);
} else {
    b(c);
}

// 三目运算符
(d ? a : b)(c);

好,回到源码,在判断对象是否为空处使用了两个函数,其中一个在之前介绍 core.js 的续篇中有讲到,而另一个 isEmptyDataObject 则为内部方法,用于判断内部数据缓存对象是否为空。

function isEmptyDataObject( obj ) {
    var name;
    for ( name in obj ) {

        // 如果公共数据对象为空,那么私有数据对象亦为空
        if ( name === "data" && jQuery.isEmptyObject( obj[name] ) ) {
            continue;
        }
        if ( name !== "toJSON" ) {
            return false;
        }
    }

    return true;
}

这个函数很简单,不说了。继续看 internalRemoveData() 的源码。

// 前面已经提到了,如果缓存对象为空,那么函数继续运行
if ( !pvt ) {
    delete cache[ id ].data;

    // 如果父级缓存对象中只包含内部数据对象,那么将父级缓存对象销毁,否则退出函数
    if ( !isEmptyDataObject( cache[ id ] ) ) {
        return;
    }
}

// 销毁缓存,这个 cleanData() 方法不在当前讨论范围内
if ( isNode ) {
    jQuery.cleanData( [ elem ], true );

// 如果支持 expandos 或 `cache` 不为 window 对象,使用 delete (#10080)
} else if ( jQuery.support.deleteExpando || cache != cache.window ) {
    delete cache[ id ];
} else {
    cache[ id ] = null;
}

这里面提到了 deleteExpando 和 delete 的问题,这属于 IE 的 bug,详细信息参考这篇精彩博客 http://hax.iteye.com/blog/349569

以上我们基本将与 data 有关的静态方法源码看了一遍,接下来开始看和实例方法相关的内容。

data() 和 removeData()

removeData: function( key ) {
    return this.each(function() {
        jQuery.removeData( this, key );
    });
}

removeData() 内部就是循环调用的 jQuery.removeData(),因此略过。

data: function( key, value ) {
    var attrs, name,
        //因为是读取数据,所以只操作元素集合中的第一个
        elem = this[0],
        i = 0,
        data = null;

    // 获取所有数据
    if ( key === undefined ) {
        if ( this.length ) {
            data = jQuery.data( elem );

            // 对于元素对象特殊处理
            if ( elem.nodeType === 1 && !jQuery._data( elem, "parsedAttrs" ) ) {
                attrs = elem.attributes;
                for ( ; i < attrs.length; i++ ) {
                    name = attrs[i].name;

                    if ( !name.indexOf( "data-" ) ) {
                        name = jQuery.camelCase( name.slice(5) );

                        dataAttr( elem, name, data[ name ] );
                    }
                }
                jQuery._data( elem, "parsedAttrs", true );
            }
        }

        return data;
    }

    // 设置多个值
    if ( typeof key === "object" ) {
        return this.each(function() {
            jQuery.data( this, key );
        });
    }

    return arguments.length > 1 ?

     	// 设置一个值
     	this.each(function() {
     		jQuery.data( this, key, value );
     	}) :

     	// 获取一个值
     	elem ? dataAttr( elem, key, jQuery.data( elem, key ) ) : null;
}

你可能会发现代码中出现的 "parsedAttrs","data-",dataAttr() 这样的内容,这里面涉及到了 HTML5 中的新内容:自定义数据特性(data-*)。

所谓自定义数据特性,就是允许你在 HTML 标签上使用 data- 作为前缀的特性,来存储数据。可能你之前也在 HTML 标签上使用过自定义的特性,但 HTML5 为以 data- 为前缀的特性增加了更便捷的存取方法。

http://html5doctor.com/html5-custom-data-attributes/ HTML5 Doctor 对该特性做了讲解,其中提到了获取自定义数据的方法,除了使用通用的 getAttribute() 外,你还可以访问元素对象的 dataset 属性,例如:

<div id='sunflower' data-leaves='47' data-plant-height='2.4m'></div>

对于上面的 HTML 标签,若想获取数据,你可以这么做:

var plant = document.getElementById('sunflower');
var leaves = plant.dataset.leaves; // leaves = 47;
var tallness = plant.dataset.plantHeight;

这比通过特性名来获取值更为方便,不过注意的是,data-plant-height 被转换成了驼峰形式:plantHeight,所以你可能也明白了,为什么上面代码中经常会出现将属性名转为驼峰形式的代码。

在 data() 方法中:

if ( elem.nodeType === 1 && !jQuery._data( elem, "parsedAttrs" ) ) {

这段代码开始,就是为了处理元素节点上以 data- 开头的特性,需要将这些数据存储到 jQuery 的缓存对象中。

而 dataAttr() 函数所做的内容就是将元素以 data- 开头的特性存储到缓存对象中,并返回数据。

因为 HTML 特性只能为字符串,所以 dataAttr() 中所做的大部分内容就是将字符串转为原始格式,包括:boolean、null、number、object。

其中判断 number 是先将字符串转为数字,再转为字符串和原始字符串比较,如果相同,则认为是数字。

如果字符串中包含花括号,可以认为它是 JSON 格式,调用 jQuery.parseJSON() 解析。

最后要注意的是,dataAttr() 不会覆盖已经设置过的数据,就是说如果缓存对象中存在了某个 key,而 HTML 特性上也拥有同样的 key,那么 HTML 特性值不会覆盖缓存对象中的对应值。

啰嗦完了(撒花╮(╯_╰)╭)