React.js 下的 $.data() “踩坑”實錄

愚安發表於2015-04-21

引子

最近在做得一個專案,我是基於reactjs來寫的。專案不大不小,就帶了個童鞋一起寫,為了不讓react寫起來那麼吃力,我還是引入了jquery (1.11.1)。就這樣整個專案開展的還算順利,期間踩到了一些坑,但都是react的,直到…

一切都源於這樣的一個寫法

_edit:(e)->
    $ele = $(e.currentTarget).parents(`td`)
    _name = $ele.data(`name`)
    _filterArr = @props.items.filter (item)->
        item.activityName is _name
    if _filterArr.length
        @props.onEditCallBack(_filterArr[0]) if @props.onEditCallBack
    e.preventDefault()
    return

很簡單地一段coffee,獲取綁在td上的data-name,然後在items裡找到name為_name的item,執行callback

可釋出到線上之後,出了問題,有得item就是無法編輯,線上程式碼又uglify過,不好除錯,這位童鞋看了半天程式碼也沒有發現什麼問題。愚安我這時候在睡午覺,迷糊中被他叫醒。

點了下頁面發現,頁面上一個data-name="111"的item無法刪除,看了下程式碼之後,拽拽的對他說:“不要亂用jquery的data,這裡有快取,大小寫,型別轉換三大坑,看原始碼去!”。然後將原來的data改為getAttribute之後,果然跑通了。為什麼跑通,且看下文。事後,我也不知道當時為什麼突然來了這句三大坑。既然說了,那總要跟別人講下三個坑吧,不能打臉,不能不講道理是吧。

先說data屬性

貼一段MDN上關於data屬性的介紹,連結

HTML5是具有擴充套件性的設計,它初衷是資料應與特定的元素相關聯,但不需要任何定義。data-* 屬性允許我們在標準內於HTML元素中儲存額外的資訊,而不許需要使用類似於 classList,標準外屬性,DOM額外屬性或是 setUserData之類的伎倆。

一股濃濃的谷歌翻譯味兒,英語好的童鞋還是去看原文,或者幫忙去翻譯下,就在愚安我寫這篇部落格的時候,順便提交了下翻譯,連我這種大學英語考試總共有幾級都不知道的人都敢翻譯,何況你呢。

在外部使用JavaScript去訪問這些屬性的值同樣非常簡單。你可以使用getAttribute()配合它們完整的HTML名稱去讀取它們,但標準定義了一個更簡單的方法:DOMStringMap你可以使用dataset讀取到資料。

文件裡寫到無論是通過getAttribute()還是dataset都可以輕鬆訪問節點上得data-*屬性的值,但二者是有區別的。

getAttribute()與dataset的區別

這裡補充一點兒關於DOM的小知識,直接訪問節點屬性和通過getAttribute訪問節點屬性返回的結果不一定是一樣的,但getAttribute和attributes[`索引`]訪問節點屬性的結果一定是不同的(即使都訪問都不存在的屬性,前者返回null,後者返回undefined),舉個例子

<div name="div" id="test"></div>
var div = document.getElementById(`test`);
div.name 
//undefined
div.id 
//"test"
div.getAttribute("name") 
//"div"
div.attributes[`name`] 
//name="div"
Object.prototype.toString.call(div.attributes[`name`])
//"[object Attr]"

事實上,對於DOM節點而言,id與attributes是同樣等級的屬性。DOM不熟的同學,可以去看看這方面的資料,這裡我就不跑題了。

繼續看區別。

Object.prototype.toString.call(div.dataset)
//"[object DOMStringMap]"
Object.prototype.toString.call(div.attributes)
//"[object NamedNodeMap]"

很顯然,二者是不同型別的map。

div[`data-a`] = 1
//1
div.getAttribute(`data-a`)
//null
div.attributes["data-a"]
//undefined
div.dataset["a"]
//undefined
//--------------------
div.setAttribute("data-foo", "bar")
//undefined
div.getAttribute("data-foo")
"bar"
div.attributes["data-foo"]
//data-foo="bar"
div.dataset["foo"]
//"bar"
//--------------------
div.dataset[`foo2`] = "123"
//"123"
div.getAttribute("data-foo2")
//"123"
div.attributes["data-foo2"]
//data-foo2="123"
div[`data-foo2`]
//undefined

通過以上三種方式,大家應該大致知道節點欄位,節點屬性,節點dataset之間的小關係與區別
再來貼一段文件

為了使用dataset物件去獲取到資料屬性,需要獲取屬性名中data-之後的部分(要注意的是破折號連線的名稱需要轉換為駝峰樣式的名稱)。

測試

div.setAttribute(`data-foo-bar`,123)
//undefined
div.dataset["fooBar"]
//"123",仍為字元型
div.dataset[`bar-foo`] = 123
//Uncaught DOMException: Failed to set the `bar-foo` property on `DOMStringMap`: `bar-foo` is not a valid property name.
div.dataset["barFoo"] = 123
//123
div.getAttribute(`data-bar-foo`)
//"123"
div.dataset["barFoo"]
//"123",仍為字元型

可見這裡確實存在喜聞樂見的camelCase轉換。

再說jquery.data的”坑”

開始翻jquery-1.11.1的原始碼中得data函式。
注:jquery2放棄了對一些對低版本瀏覽器的支援,“坑”不全,我們還是看1.X的。

jQuery.extend({
    cache: {},
    //當設定下面這三種元素的expando屬性時會丟擲異常
    //具體方法參見jquery的src/data/accepts下的jQuery.acceptData方法
    noData: {
        "applet ": true,
        "embed ": true,
        // ...但是 Flash物件 (擁有classid)可以處理expando
        "object ": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"
    },

    hasData: function( elem ) {
        elem = elem.nodeType ? jQuery.cache[ elem[jQuery.expando] ] : elem[ jQuery.expando ];
        return !!elem && !isEmptyDataObject( elem );
    },

    data: function( elem, name, data ) {
        return internalData( elem, name, data );
    },

    removeData: function( elem, name ) {
        return internalRemoveData( elem, name );
    },

    // For internal use only.
    _data: function( elem, name, data ) {
        return internalData( elem, name, data, true );
    },

    _removeData: function( elem, name ) {
        return internalRemoveData( elem, name, true );
    }
});

這個就是jquery.data的大致結構,比較清晰。接下來,我們來聊聊前面說的三大坑。

型別轉換坑

首先回到最開始的事故程式碼裡,熟悉coffee的童鞋都知道,is關鍵字,在編譯到javascript時,會變成===號(強等於),而儲存在item裡的name時字元型,通過$("selector").data()函式獲取文件節點的data-*屬性上的值時,呼叫得是jquery.fn.data方法,這裡就不貼完整程式碼了,貼下造成這個型別轉換的部分dataAttr()

if ( data === undefined && elem.nodeType === 1 ) {

    var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase();

    data = elem.getAttribute( name );

    if ( typeof data === "string" ) {
        try {
                //布林型轉換
            data = data === "true" ? true :
                data === "false" ? false :
                //null型轉換
                data === "null" ? null :
                // 僅當將其轉換成數字時,其字元值相對原字元值不變時,進行number型轉換
                +data + "" === data ? +data :
                //json字串到object的轉換,rbrace = /^(?:{[wW]*}|[[wW]*])$/
                rbrace.test( data ) ? jQuery.parseJSON( data ) :
                data;
            } catch( e ) {}

            // Make sure we set the data so it isn`t changed later
            jQuery.data( elem, key, data );

        } else {
            data = undefined;
        }
    }

通過我註釋的部分可以很容易看出,jquery在呼叫jquery.data()前,會對傳入的data值進行型別轉換,其中轉換為number的部分就是造成引子中提到到bug的原因。當然,jQuery這裡完全是為了方便大家使用,我這裡說採坑,純屬強行甩鍋給jquery。

當然,我們上面測試過原生的javascript通過dataset或者getAttribute都不會做這種型別轉換。
舉個例子

$(div).data("foo-bar")
//123,number型
$(div).data("fooBar")
//123
div.dataset["fooBar"]
//"123",字元型

大小寫轉換坑

在上面程式碼中,我們注意到這麼一段

//rmultiDash = /([A-Z])/g;
var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase();
    data = elem.getAttribute( name );

這裡現將key中所有的大寫字母前加“-”,然後統一轉換為小寫。
再舉個例子

var div = document.createElement(`div`),
    key = "ID",
    id = 123;
div.setAttribute("data-"+key, id);
$(div).data(key);
//undefined

當然前面也已經講過,即使使用dataset這種結果。把這個“坑”,算在jquery的頭上實在是不講道理。不過這裡,也是給像我這樣比較粗心的前端童鞋,提個醒,直接寫在html裡的data-*中記得要用小寫,避免不必要的bug。

快取坑

在jquery.data中核心的internalData函式裡,進行了主要的cache讀寫操作。我們呼叫$(selector).data(key,value)的時候,進行的流程大致如下

  • key,value格式化處理
  • 檢查elem是否有elem[internalKey],若有則作為cache中對應得id,若無則做如下處理
id = elem[ internalKey ] = deletedIds.pop() || jQuery.guid++;
//deletedIds預設為[]記錄被剷除的id的陣列
//guid是預設為1的計數器
//這樣可以保證被刪除的元素的id能夠被放到deletedIds再利用,而不是無線遞增guid造成枯竭
  • 拿到id之後,檢查jQuery.cache[id]是否存在,若不存在則jQuery.cache[id] = {}
  • 將key為傳入key的camelCase形式,value為做相應處理的value的鍵值對放入jQuery.cache[id]中
  • 返回jQuery.cache[id]

注:internalKey = jQuery.expando = “jQuery” + ( jQuery.fn.jquery + Math.random() ).replace( /D/g, “” )

同理,呼叫$(selector).data(key)時,也是現做key處理,id處理,去jQuery.cache[id]這個Object中拿到對應key的value,或返回undefined。

由於jquery這種cache機制,導致如果一個DOM節點上存在internalKey,且其剛好對應一個可以命中的cacheID,則無法通過jQuery.data()方法拿到data-*對應的值,而是cache對應的值。

這種情形最容易在類似reactjs這種virtual-DOM在對一組元素做部分刪除操作時出現。因為virtual-DOM是做增量更新,刪除的virtual-DOM並不一定是將我們主觀視覺上看到的那個DOM節點,而是將相鄰DOM節點進行增量更新,此時雖然data-*屬性仍是原來的值,但整個DOM卻是那個本來已經被刪除的元素,所以如果那個被刪除的DOM元素曾經呼叫過data方法,保留了iternalKey的話,那麼恭喜你,你碰到我說的快取坑了。

當然上面這種情況,也很容易通過getAttribute(“data-*”)處理解決掉,不是上面大問題,無須擔心。

後記

這篇blog寫在愚安我離職的第二天,在星巴克坐了一下午,無聊寫的,延續了我以往寫東西狂貼程式碼湊字數的原則。可以作為jQuery.data()的一個小解讀,也可以算是對我前段時間專案中遇到的一些小問題的記錄。感謝大家閱讀,如有錯誤,歡迎指出。


作者部落格原文地址

參考資料:

  1. https://developer.mozilla.org/zh-CN/docs/Web/Guide/HTML/Using_data_att…
  2. https://github.com/jquery/jquery/blob/1.11-stable/src/data.js
  3. http://blog.rx836.tw/blog/jquery-data-cache/

相關文章