僅100行的JavaScript DOM操作類庫

顏海鏡發表於2014-04-11

如果你構建過Web引用程式,你可能處理過很多DOM操作。訪問和操作DOM元素幾乎是每一個Web應用程式的通用需求。我們我們經常從不同的控制元件收集資訊,我們需要設定value值,修改div或span標籤的內容。當然有許多庫能幫助處理這些行為,其中最流行的當屬jQuery,已經成為事實上的標準。有事你並不需要jQuery提供每一樣東西,所以在這篇文章中,我們將看看如何建立自己的類庫來操作DOM元素。

API

身為開發者的我們每天都要做決定。我相信在測試驅動開發中,我真的非常喜歡的一個事實是它迫使你在開始實際編碼之前必須做出設計決定。沿著這些思路,我想我想要的DOM操作類庫的API最終看起來可能像這樣:

//返回 DOM 元素
dom('.selector').el
//返回元素的值/內容
dom('.selector').val() 
//設定元素的值/內容
dom('.selector').val('value') 

這應該包括了大多數可能用到的操作。然而如何我們可以一次操作多個物件會顯得個更好。如果能生成一個JavaScript物件,那將是偉大之舉。

//生成包裝DOM元素的物件
dom({
    structure: {
        propA: '.selector',
        propB: '.selector'
    },
    propC: '.selector'
}) 

一旦我們將元素存下來,我們能很容易對它們執行val方法。

//檢索DOM元素的值
dom({
    structure: {
        propA: '.selector',
        propB: '.selector'
    },
    propC: '.selector'
}).val()

這將是將資料直接從DOM轉換為JavaScript物件的有效方法。

現在我們心理已經清楚我們的API看起來的樣子,我們類庫程式碼看起來像下面這樣:

var dom = function(el) {
    var api = { el: null }
    api.val = function(value) {
        // ...
    }
    return api;
}

作用域

很明顯,我們打算使用類似getElementById,querySelector或querySelectorAll這樣的方法。通常情況下,你可以像下面這樣訪問DOM:

var header = document.querySelector('.header');

querySeletor是非常有趣的,例如,它不僅僅是document物件的方法,同時也是其他DOM元素的方法。這意味著,我們可以在特定上下文中執行查詢。比如:

<header>
    <p>Big</p>
</header>
<footer>
    <p>Small</p>
</footer>

var header = document.querySelector('header');
var footer = document.querySelector('footer');
console.log(header.querySelector('p').textContent); // Big
console.log(footer.querySelector('p').textContent); // Small

我們能在特定的DOM樹上操作,並且我們的類庫應該支援傳遞作用域。所以,如果它接受一個父元素選擇符是非常棒的。

var dom = function(el, parent) {
    var api = { el: null }
    api.val = function(value) {
        // ...
    }
    return api;
}

查詢DOM元素

按照我們上面所說的,我們將使用querySelector和querySelectorAll查詢DOM元素。讓我們為這些函式建立兩個快捷方式。

var qs = function(selector, parent) {
    parent = parent || document;
    return parent.querySelector(selector);
};
var qsa = function(selector, parent) {
    parent = parent || document;
    return parent.querySelectorAll(selector);
};

在那之後我們應該傳遞el引數。通常情況下將是一個(選擇符)字串,但我們也應該支援:

  • DOM元素——類庫的val方法會非常方便,所以我們可能需要使用已經引用的元素;
  • JavaScript物件——為了建立包含多個DOM元素的JavaScript物件。

下面的switch包括這兩種情況:

switch(typeof el) {
    case 'string':
        parent = parent && typeof parent === 'string' ? qs(parent) : parent;
        api.el = qs(el, parent);
    break;
    case 'object': 
        if(typeof el.nodeName != 'undefined') {
            api.el = el;
        } else {
            var loop = function(value, obj) {
                obj = obj || this;
                for(var prop in obj) {
                    if(typeof obj[prop].el != 'undefined') {
                        obj[prop] = obj[prop].val(value);
                    } else if(typeof obj[prop] == 'object') {
                        obj[prop] = loop(value, obj[prop]);
                    }
                }
                delete obj.val;
                return obj;
            }
            var res = { val: loop };
            for(var key in el) {
                res[key] = dom.apply(this, [el[key], parent]);
            }
            return res;
        }
    break;
}

如果開發者傳遞字串將執行第一個case。我們轉換parent並且呼叫querySelector的快捷方式。第二個case將會被執行如果我們傳遞一個DOM元素或JavaScript物件。我們檢查物件是否有nodeName屬性,如果有這個屬性,我們直接將它的值作為api.el的值。如果沒有,那麼我們遍歷物件的所有屬性並且為每個屬性初始化為類庫例項。這裡有一些測試用例:

<p>text</p>
<header>
    <p>Big</p>
</header>
<footer>
    <p>Small</p>
</footer>

訪問第一個段落:

dom('p').el

訪問header節點裡的段落:

dom('p', 'header').el

傳遞一個DOM元素:

dom(document.querySelector('header')).el

傳遞一個JavaScript物件:

var els = dom({
    footer: 'footer',
    paragraphs: {
        header: 'header p',
        footer: 'footer p'
    }
}))
// 最後我們在此得到JavaScript物件。
// 它的屬性是實際的結果
// 執行dom函式。例如,獲取值
// footer是paragraphs的屬性
els.paragraphs.footer.el

獲取或設定元素的值

表單元素的值如input或select可以被很容易的檢索到——我們可以使用元素的value屬性。我們我們已經有一個能訪問的DOM元素了——儲存在api.el。然而,當我們碰到單選框或核取方塊是有些棘手。對於其他HTML節點像div,section或span我們獲取元素的值實際上是獲取textContent屬性。如果textContent是undefined那麼可以用innerHTML代替(相似)。讓我們寫出另一個switch語句:

api.val = function(value) {
    if(!this.el) return null;
    var set = !!value;
    var useValueProperty = function(value) {
        if(set) { this.el.value = value; return api; }
        else { return this.el.value; }
    }
    switch(this.el.nodeName.toLowerCase()) {
        case 'input':
        break;
        case 'textarea':
        break;
        case 'select':              
        break;
        default:
    }
    return set ? api : null;
}

首先我們需要確保api.el屬性存在。set是布林型別變數告訴我們是獲取還是設定元素的value屬性。有.value屬性的元素包括一個輔助方法。switch語句將包含方法的實際邏輯。最後我們返回api本身,為了保持鏈式操作。當然我們這樣做僅當我們使用設定器函式時。

讓我們看看如何處理不能同型別的元素。例如input節點:

case 'input':
    var type = this.el.getAttribute('type');
    if(type == 'radio' || type == 'checkbox') {
        var els = qsa('[name="' + this.el.getAttribute('name') + '"]', parent);
        var values = [];
        for(var i=0; i<els.length; i++) {
            if(set && els[i].checked && els[i].value !== value) {
                els[i].removeAttribute('checked');
            } else if(set && els[i].value === value) {
                els[i].setAttribute('checked', 'checked');
                els[i].checked = 'checked';
            } else if(els[i].checked) {
                values.push(els[i].value);
            }
        }
        if(!set) { return type == 'radio' ? values[0] : values; }
    } else {
        return useValueProperty.apply(this, [value]);
    }
break;

這可能是最有趣的例子了。有兩種型別的元素需要不同的處理——單選框和核取方塊。這些元素實際上是一組,我們要牢記這點。這就是為什麼我們使用querySelectorAll獲取整組並找出哪個是被選擇/選中的。更復雜的是,核取方塊可能不止被選中一個。上面的方法完美處理所有這些情況。 處理textarea元素非常簡單,這要得益於我們上面寫的輔助函式。

case 'textarea': 
    return useValueProperty.apply(this, [value]); 
break;

下面看我們如何處理下拉選單(select):

case 'select':
    if(set) {
        var options = qsa('option', this.el);
        for(var i=0; i<options.length; i++) {
            if(options[i].getAttribute('value') === value) {
                this.el.selectedIndex = i;
            } else {
                options[i].removeAttribute('selected');
            }
        }
    } else {
        return this.el.value;
    }
break;

最後是預設操作:

default: 
    if(set) {
        this.el.innerHTML = value;
    } else {
        if(typeof this.el.textContent != 'undefined') {
            return this.el.textContent;
        } else if(typeof this.el.innerText != 'undefined') {
            return typeof this.el.innerText;
        } else {
            return this.el.innerHTML;
        }
    }
break;

上面這些程式碼我們完成了我們的val方法。這裡有一個簡單的HTML表單和相應的測試:

<form>
    <input type="text" value="sample text" />
    <input type="radio" name="options" value="A">
    <input type="radio" name="options" checked value="B">
    <select>
        <option value="10"></option>
        <option value="20"></option>
        <option value="30" selected></option>
    </select>
    <footer>version: 0.3</footer>
</form>

如果我們寫下面的:

dom({
    name: '[type="text"]',
    data: {
        options: '[type="radio"]',
        count: 'select'
    },
    version: 'footer'
}, 'form').val();

我們會得到:

{
    data: {
        count: "30",
        options: "B"
    },
    name: "sample text",
    version: "version: 0.3"
}

這方法對於把資料衝HTML導成JavaScript物件非常有幫助。這正是我們很多人每天都很常見的任務。

最後結果

最後完成的類庫程式碼僅有100行程式碼,但它仍然滿足我們所需的訪問 DOM元素並且獲取和設定value值/內容。

var dom = function(el, parent) {
    var api = { el: null }
    var qs = function(selector, parent) {
        parent = parent || document;
        return parent.querySelector(selector);
    };
    var qsa = function(selector, parent) {
        parent = parent || document;
        return parent.querySelectorAll(selector);
    };
    switch(typeof el) {
        case 'string':
            parent = parent && typeof parent === 'string' ? qs(parent) : parent;
            api.el = qs(el, parent);
        break;
        case 'object': 
            if(typeof el.nodeName != 'undefined') {
                api.el = el;
            } else {
                var loop = function(value, obj) {
                    obj = obj || this;
                    for(var prop in obj) {
                        if(typeof obj[prop].el != 'undefined') {
                            obj[prop] = obj[prop].val(value);
                        } else if(typeof obj[prop] == 'object') {
                            obj[prop] = loop(value, obj[prop]);
                        }
                    }
                    delete obj.val;
                    return obj;
                }
                var res = { val: loop };
                for(var key in el) {
                    res[key] = dom.apply(this, [el[key], parent]);
                }
                return res;
            }
        break;
    }
    api.val = function(value) {
        if(!this.el) return null;
        var set = !!value;
        var useValueProperty = function(value) {
            if(set) { this.el.value = value; return api; }
            else { return this.el.value; }
        }
        switch(this.el.nodeName.toLowerCase()) {
            case 'input':
                var type = this.el.getAttribute('type');
                if(type == 'radio' || type == 'checkbox') {
                    var els = qsa('[name="' + this.el.getAttribute('name') + '"]', parent);
                    var values = [];
                    for(var i=0; i<els.length; i++) {
                        if(set && els[i].checked && els[i].value !== value) {
                            els[i].removeAttribute('checked');
                        } else if(set && els[i].value === value) {
                            els[i].setAttribute('checked', 'checked');
                            els[i].checked = 'checked';
                        } else if(els[i].checked) {
                            values.push(els[i].value);
                        }
                    }
                    if(!set) { return type == 'radio' ? values[0] : values; }
                } else {
                    return useValueProperty.apply(this, [value]);
                }
            break;
            case 'textarea': 
                return useValueProperty.apply(this, [value]); 
            break;
            case 'select':
                if(set) {
                    var options = qsa('option', this.el);
                    for(var i=0; i<options.length; i++) {
                        if(options[i].getAttribute('value') === value) {
                            this.el.selectedIndex = i;
                        } else {
                            options[i].removeAttribute('selected');
                        }
                    }
                } else {
                    return this.el.value;
                }
            break;
            default: 
                if(set) {
                    this.el.innerHTML = value;
                } else {
                    if(typeof this.el.textContent != 'undefined') {
                        return this.el.textContent;
                    } else if(typeof this.el.innerText != 'undefined') {
                        return typeof this.el.innerText;
                    } else {
                        return this.el.innerHTML;
                    }
                }
            break;
        }
        return set ? api : null;
    }
    return api;
}

我建立了一個jsbin的例子,你可以看看類作品。

總結

我上面討論的類庫是AbsurdJS客戶端元件的一部分。該模組的完成文件可以在這裡找到。這程式碼的目的並非要取代jQuery或其他可以訪問DOM的流行類庫。函式的思想是自成一體,一個函式只做一件事並把它做好。這是AbsurdJS背後的主要思想,它也是基於模組化建設的,如routerAjax模組。

原文 http://flippinawesome.org/2014/03/10/a-dom-manipulation-class-in-100-lines-of-javascript/

Q群推薦

  • CSS家園188275051,CSS開發者的天堂,歡迎有興趣的同學加入
  • GitHub家園225932282,GitHub愛好者的天堂,歡迎有興趣的同學加入
  • 碼農之家203145707,碼農的天堂,歡迎有興趣的同學加入

相關文章