Zepto中数据缓存原理与实现

前言

以前我们使用Zepto进行开发的时候,会把一些自定义的数据存到dom节点上,好处是非常直观和便捷,但是也带来了例如直接将数据暴露出来会出现安全问题,数据以html自定义属性标签存在,对于浏览器本身来说是没有多大意义的,最后要获取数据的时候还得操作dom。Zepto有一个data模块,专门用来做数据缓存,允许我们存放任何与dom相关的数据。

原文链接

源码仓库

data
data

原理

在开始学习和阅读Zepto中的data模块前,我们先大致了解一下dom元素和要缓存的数据是如何联系起来的。

原理
原理

看一下上面那张图。简单地理解就是

  • dom元素身上有一exp(Zepto1507010934916)属性,其对应的值是1,2,3整数数字,
  • data是一个存储着与dom元素相关联的自定义数据的大对象类似下面这样

{
  1: {
    name: 'qianlongo'
  },
  2: {
    sex: 'boy'
  }
}
  • dom元素就是通过1,2,3数字索引和大对象data关联起来

  • 对于DOM自定义数据的增删改查就是在对数字索引对应的对象进行操作。

$.fn.data

在匹配元素上存储任意相关数据或返回匹配的元素集合中的第一个元素的给定名称的数据存储的值。

例子

<div class="box" data-name="qianlongo" data-sex="boy"></div>

let $box = $('.box')

// setData
$box.data("foo", 52)
$box.data("bar", { myType: "test", count: 40 })
$box.data({ baz: [ 1, 2, 3 ] })

// getData
$box.data("foo") // 52
$box.data("name") // qianlongo
$box.data() // { name: "qianlongo", sex: "boy", foo: 52, bar: { myType: "test", count: 40 }, baz: [ 1, 2, 3 ] }

基本用法大家肯定很熟悉,需要注意的地方是,我们也可以直接获取定义在html标签上以data-为前缀的属性。接下来我们就直接看源码实现啦

源码


$.fn.data = function(name, value) {
  return value === undefined ?
    // set multiple values via object
    $.isPlainObject(name) ?
      this.each(function(i, node){
        $.each(name, function(key, value){ setData(node, key, value) })
      }) :
      // get value from first element
      (0 in this ? getData(this[0], name) : undefined) :
    // set value on all elements
    this.each(function(){ setData(this, name, value) })
}

通过上面的例子我们知道,设置数据的时候可以单个属性设置,也可以多个属性(传递一个对象)一起设置。大量使用三目运算是Zepto一贯的风格。我们来拆解一下这段代码。

  1. 当value传递了值并且不是undefined的时候可以认为是设置单个数据属性。于是走这段代码
this.each(function(){ setData(this, name, value) })

通过遍历匹配元素,并调用setData方法传入元素,要设置的数据的key和value。

  1. 当没有传递value进来,并且name是个纯粹的对象时候。也就是类似这样使用
$box.data({ baz: [ 1, 2, 3 ] })

此时走的是这段代码

this.each(function(i, node){
  $.each(name, function(key, value){ setData(node, key, value) })
})

还是遍历当前匹配元素,并且遍历传进的对象name,到底层还是调用setData方法一个个属性进行设置。

  1. 当name不是一个对象的时候,认为是对数据的读取操作。走的是这段代码
(0 in this ? getData(this[0], name) : undefined)

通过判断当前是否有匹配的元素,如果有则是调用getData方法,并传入匹配元素集合中的第一个元素,以及要获取的数据name属性。如果没有匹配元素,就直接返回undefined了。

总体逻辑还是挺清晰的。接下来我们主要需要弄清楚上面用到的几个函数setData,getData。以及解释一下data模块初始定义的几个变量

var data = {}, 
    dataAttr = $.fn.data, 
    camelize = $.camelCase,
    exp = $.expando = 'Zepto' + (+new Date())

各变量解释如下

/**
   * data 存储于dom相映射的数据数据结构如同下
   * {
   *   1: {
   *      name: 'qianlongo',
   *      sex: 'boy'
   *    },
   *   2: {
   *      age: 100
   *    }
   * }
   * 
   * dataAttr $原型上的data方法,通过getAttribute和setAttribute设置或读取元素属性
   * camelize 中划线转小驼峰函数
   * exp => Zepto1507004986420 设置在dom上的属性,value是data中的key 1, 2,3等
   */

setData

function setData(node, name, value) {
  var id = node[exp] || (node[exp] = ++$.uuid),
    store = data[id] || (data[id] = attributeData(node))
  if (name !== undefined) store[camelize(name)] = value
  return store
}

exp是类似Zepto1507004986420的字符串,$.uuid初始值是0,首先会尝试去读取元素身上的exp属性,元素没有该属性就为该元素设置exp属性。

并去data大对象中读取id(1, 2, 3...)属性,当然了如果data对象中没有读取到,就通过调用attributeData函数先获取
node节点所有以data-为前缀的自定义属性,并将其赋值。

现在自定义属性的集合已经有了,先判断name是否是个undefined,不是就往store上添加name属性。

最后函数调用之后会返回整个数据对象store。

attributeData

获取元素以data-为前缀的自定义属性的集合

// Read all "data-*" attributes from a node
function attributeData(node) {
  var store = {}
  $.each(node.attributes || emptyArray, function(i, attr){
    if (attr.name.indexOf('data-') == 0)
      store[camelize(attr.name.replace('data-', ''))] =
        $.zepto.deserializeValue(attr.value)
  })
  return store
}

我们先来看一下node.attributes mdn是个啥

Element.attributes 属性返回该元素所有属性节点的一个实时集合。该集合是一个 NamedNodeMap 对象,不是一个数组,所以它没有 数组 的方法,其包含的 属性 节点的索引顺序随浏览器不同而不同。更确切地说,attributes 是字符串形式的名/值对,每一对名/值对对应一个属性节点。

例子


<div class="box" data-name="qianlongo" data-sex="boy" foo="foo" title="标题"></div>
let $box = document.querySelector('.box')
    $box.dataset.age = 100
    console.log($box.attributes)

attributes
attributes

得到的数据如上图所示,接下来我们再回到attributeData函数的源码分析

if (attr.name.indexOf('data-') == 0)
    store[camelize(attr.name.replace('data-', ''))] =
      $.zepto.deserializeValue(attr.value)

通过判断ele.attributes拿到的集合中,是否是以data-开头的属性,如果是就往store对象中添加驼峰化后的该属性,并且序列化之后的attr.value作为该属性的值。最后将store对象返回。

getData

获取存储在data中与DOM元素关联的对象name属性。当name属性不存在的时候直接返回整个对象。

function getData(node, name) {
  var id = node[exp], store = id && data[id]
  if (name === undefined) return store || setData(node)
  else {
    if (store) {
      if (name in store) return store[name]
      var camelName = camelize(name)
      if (camelName in store) return store[camelName]
    }
    return dataAttr.call($(node), name)
  }
}

实现思路还是首先去读取setData时候添加在node节点上的id,然后以该id为key去data中查找。如果name没有传,此时直接返回整个store,当然如果store也没有找到,就返回调用setData后返回的该元素的自定义属性的集合。

当store存在时,先判断name属性在store中存在与否,存在便直接返回相应的属性,否则对传入的name进行驼峰化之后再判断在store中是否存在,存在即返回对应的属性。也就是说你传入的name为min-age或者minAge得到的是一样的值。

最后如果在数据缓存中还没有找到属性name,就调用dataAttr函数,去直接查找元素身上的相关属性。

removeData

在元素上移除绑定的数据

可以添加或者更新数据自然也就可以移除数据了,先看下例子

例子


<div class="box"></div>
let $box = $('.box')

$box.data("foo", 52)
$box.data("bar", { myType: "test", count: 40 })
$box.data({ baz: [ 1, 2, 3 ] })

// $box.removeData('foo')
// $box.removeData('foo bar baz')
// $box.removeData(['foo', 'bar', 'baz'])
// $box.removeData()

我们可以指定删除单个属性,也可以通过空格隔开删除多个属性,也可以传入一个要删除的属性数组,甚至当你什么都不传的时候,原先设置在该元素身上的data会被全部清空

源码

$.fn.removeData = function(names) {
  if (typeof names == 'string') names = names.split(/\s+/)
  return this.each(function(){
    var id = this[exp], store = id && data[id]
    if (store) $.each(names || store, function(key){
      delete store[names ? camelize(this) : key]
    })
  })
}

首先传进来的names是字符串的情况下,先转化成数组,接着就是对当前匹配的元素集合进行遍历,逐个删除元素对应的缓存的数据。

当查找到store的时候对转化后的names或者store进行遍历,如果是自己指定要删除的属性,先驼峰化一下,再用delete删除,否则全部清空则直接delete store中的key

$.data

存储任意数据到指定的元素并且/或者返回设置的值

$.data = function(elem, name, value) {
  return $(elem).data(name, value)
}

定义在$函数身上的静态方法,底层还是调用的实例方法.data。

$.hasData

确定元素是否有与之相关的Zepto数据。

$.hasData = function(elem) {
  var id = elem[exp], store = id && data[id]
  return store ? !$.isEmptyObject(store) : false
}

同样定义在$函数身上的静态方法,原理就是拿着elem身上的id,去data中查找是否有与之关联的数据对象,如果找到了并且不是一个空对象,便返回true,否则没有找到或者是空对象都是返回false

remove, empty

生成扩展的remove和empty方法,未扩展之前的remove和empty功能依旧还在,增添了删除选中的元素缓存的数据功能。

;['remove', 'empty'].forEach(function(methodName){
  // 缓存原型上之前对应的remove和empty方法
  var origFn = $.fn[methodName]
  // 重写两个方法
  $.fn[methodName] = function() {
    // 获取当前选中元素的所有内部包含元素
    var elements = this.find('*')
    // 如果是remove方法,则在获取的elements元素基础上把本身也添加进去
    if (methodName === 'remove') elements = elements.add(this)
    // 调用removeData删除与dom关联的data中的数据
    elements.removeData()
    // 最后还是调用对应的方法删除dom,或者清除dom的内容
    return origFn.call(this)
  }
})

结尾

以上是Zepto种data模块所有源码分析,欢迎大家指正其中有问题的地方。

文章记录

data模块

  1. Zepto中数据缓存原理与实现(2017-10-03)

form模块

  1. zepto源码分析之form模块(2017-10-01)

zepto模块

  1. 这些Zepto中实用的方法集(2017-08-26)
  2. Zepto核心模块之工具方法拾遗 (2017-08-30)
  3. 看zepto如何实现增删改查DOM (2017-10-2)

event模块

  1. mouseenter与mouseover为何这般纠缠不清?(2017-06-05)
  2. 向zepto.js学习如何手动触发DOM事件(2017-06-07)
  3. 谁说你只是"会用"jQuery?(2017-06-08)

ajax模块

  1. 原来你是这样的jsonp(原理与具体实现细节)(2017-06-11)