封裝 DOM 操作


DOM 原本的 API 在撰寫上冗長且操作便,在這邊將 DOM API 做簡單封裝,並建立一個 XD 模組,首先,在 XD-1.0.0.js 中建立一些常數與函式:

// 標準化屬性名稱
const PROPS = new Map([
    ['for', 'htmlFor'],
    ['class', 'className'],
    ['readonly', 'readOnly'],
    ['maxlength', 'maxLength'],
    ['cellspacing', 'cellSpacing'],
    ['rowspan', 'rowSpan'],
    ['colspan', 'colSpan'],
    ['tabindex', 'tabIndex'],
    ['usemap', 'useMap'],
    ['frameborder', 'frameBorder']
]);

// 判斷元素的類型  

function isElementNode(elem) {
    return elem.nodeType === Node.ELEMENT_NODE;
}

function isTextNode(elem) {
    return elem.nodeType === Node.TEXT_NODE;
}

function isCommentNode(elem) {
    return elem.nodeType === Node.COMMENT_NODE;
}

function isInputNode(elem) {
    return elem.nodeName === 'INPUT';
}

雖然要修補 JavaScript 的物件非常容易,除非是為了相容於新的標準,否則不建議在任何原生物件或 DOM 物件上添加特性,以免開發者無法辨別,這些特性是原生的或者來自於程式庫,因此,通常會採取包裹器的形式,將原生物件或 DOM 物件等包裹,開發者建立並操作包裹器,由包裹器來操作原生 API。

因此,接下來在 XD-1.0.0.js 中定義 ElemCollection 類別:

class ElemCollection {
    // 建構時傳入原生 DOM 物件的 Array 清單
    constructor(elems) {
        this.elems = elems;
    }

    // 指定索引取得元素
    get(index = 0) {
        return this.elems[index];
    }

    // 包裹器管理的 DOM 物件個數
    size() {
        return this.elems.length;
    }

    // 包裹器中的 DOM 元素清單是否為空
    isEmpty() {
        return this.elems.length === 0;
    }

    // 逐一操作管理的 DOM 元素
    each(consume) {
        this.elems.forEach(consume);
        return this;
    }

    // 如果 value 為 undefined,傳回第一個 DOM 元素的 innerHTML 
    // 否則用 value 設定全部 DOM 元素之 innerHTML
    html(value) {
        let elems = this.elems;
        if(value === undefined) {
            return elems[0] && isElementNode(elems[0]) ? elems[0].innerHTML : null;
        }
        else {
            elems.filter(isElementNode)
                 .forEach(elem => elem.innerHTML = value);
            return this;
        }
    }

    // 如果 value 為 undefined,傳回第一個 DOM 元素的屬性對應之特性
    // 否則用 value 設定全部 DOM 元素之指定特性       
    attr(name, value) {
        let elems = this.elems;
        let propName = PROPS.has(name) ? PROPS.get(name) : name;
        if(value === undefined) {
            return elems[0] && !isTextNode(elems[0]) && !isCommentNode(elems[0]) ?
                    elems[0][propName] : undefined;
        }
        else {
            elems.filter(elem => !isTextNode(elem) && !isCommentNode(elem))
                 .forEach(elem => elem[propName] = value);
            return this;
        }       
    }

    // 如果 value 為 undefined,傳回第一個 input 元素的 value
    // 否則用 value 設定全部 input 元素的 value           
    val(value) {
        let elems = this.elems;
        // 先只處理 <input> 元素
        if(value === undefined) {
            return elems[0] && isInputNode(elems[0]) ? elems[0].value : null;
        }
        else {
            elems.filter(isInputNode)
                 .forEach(elem => elem.value = value);
            return this;
        }       
    }

    // 如果只有一個父節點,將指定的 elemsCollection 管理之元素附加至該節點
    // 否則用複製 elemsCollection 管理之元素,再附加至各個父節點            
    append(elemsCollection) {
        let parents = this.elems;
        if(parents.length === 1) { // 只有一個父節點
            let parent = parents[0];
            elemsCollection.each(elem => parent.appendChild(elem));
        }
        else if(parents.length > 1){ // 有多個父節點
            parents.forEach(parent => {
                elemsCollection.each(elem => {
                    // 複製子節點
                    var container = document.createElement('div');
                    container.appendChild(elem);
                    container.innerHTML = container.innerHTML;
                    parent.appendChild(container.firstChild);
                });
            });
        }

        return this;
    }

    // 將管理之元素從 DOM 樹上移除     
    remove() {
        this.elems.forEach(elem => {
            elem.parentNode.removeChild(elem);
        });
        return this;
    }
}

接著在選取元素上,基於原生的 getElementByIdgetElementsByTagNamegetElementsByNamequerySelectorAll 等 API 來建立包裹器:

function elemsById(...ids) {
    let container = this || document; 
    let elems = ids.map(id => container.getElementById(id));
    return new ElemCollection(elems);
}

function elemsByTag(...tags) {
    let container = this || document; 
    let elems = tags.map(tag => Array.from(container.getElementsByTagName(tag)))
                    .reduce((acc, arr) => acc.concat(arr), []);

    return new ElemCollection(elems);
}

function elemsByName(...names) {
    let container = this || document; 
    let elems = names.map(name => Array.from(container.getElementsByName(name)))
                     .reduce((acc, arr) => acc.concat(arr), []);

    return new ElemCollection(elems);
}

function elemsBySelector(...selectors) {
    let container = this || document; 
    let elems = selectors.map(selector => Array.from(container.querySelectorAll(selector)))
                         .reduce((acc, arr) => acc.concat(arr), []);

    return new ElemCollection(elems);   
} 

// 指定一或多個標籤名稱,建立 DOM 元素
function create(...tags) {
    return new ElemCollection(tags.map(tag => document.createElement(tag)));
}

這幾個函式的名稱會是模組匯出的名稱:

export {elemsById, elemsByTag, elemsByName, elemsBySelector, create};

除了建立包裹器來管理一組 DOM 元素外,也可以有個包裹器來包裹單一 DOM 元素:

// 包裹單一 DOM 元素
class XD {
    constructor(elem) {
        this.elem = elem;
    }

    elemsById(...ids) {
        return elemsById.apply(this.elem, ids);
    }

    elemsByTag(...tags) {
        return elemsByTag.apply(this.elem, tags);
    }

    elemsByName(...names) {
        return elemsByName.apply(this.elem, names);
    }

    elemsBySelector(...selectors) {
        return elemsBySelector.apply(this.elem, selectors);
    }

    toElemCollection() {
        return new ElemCollection([this.elem]);
    }
}

// 預設匯出的工廠函式,用來建立  X 實例
// 如果傳入字串,會建立新元素
// 否則直接包裹 DOM 元素
export default function(elem) {
    if(typeof elem === 'string') {
        return new XD(document.createElement(elem));
    }
    return new XD(elem);
}

現在,若 XD-1.0.0.js 放在 js 資料夾中,若想使用這個 XD 模組,可以在 HTML 頁面中如下撰寫程式(你的瀏覽器必須支援 ES6 模組):

<script type="module">
    import {elemsById, elemsByTag, elemsByName, elemsBySelector} from './js/XD-1.0.0.js';

    elemsById('console', 'cmd').html('<b>Hello, World</b>');

    console.log(elemsBySelector('#console').html());

    elemsByTag('span', 'div').attr('class', 'red')
                             .html('<i>Red Color</i>');

    elemsByName('name').val('100')
                       .each(elem => console.log(elem.value));
</script>

或者可以使用預設匯入:

<script type="module">
    import x from './js/XD-1.0.0.js';

    let doc = x(document);

    doc.elemsById('console', 'cmd').html('<b>Hello, World</b>');

    console.log(doc.elemsBySelector('#console').html());

    doc.elemsByTag('span', 'div').attr('class', 'red')
                                 .html('<i>Red Color</i>');

    doc.elemsByName('name').val('100')
                           .each(elem => console.log(elem.value));
</script>

來用在先前的範例上,看看改寫之後會長什麼樣,首先改寫一下〈修改文件〉中的第一個範例:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>

    <input id="src" type="text"><button id="add">新增圖片</button>
    <div id="images"></div>

<script type="module">
    import {elemsById, create} from './js/XD-1.0.0.js';

    elemsById('add').get().onclick = function() {
        let img = create('img');

        img.attr('src', elemsById('src').val())
           .get()
           .onclick = function() {
               img.remove();
           };       

        elemsById('images').append(img);              
    };
</script>

</body>
</html>

按此觀看結果

來稍微簡化一下〈修改文件〉中第二個範例:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
</head>
<body>  
    容器一:
    <div id="container1">
        <img id="image" src="https://openhome.cc/Gossip/images/caterpillar_small.jpg"/>
    </div><br>
    容器二:
    <div id="container2"></div>  

<script type="module">
    import {elemsById} from './js/XD-1.0.0.js';

    let image = elemsById('image');

    image.get().onclick = function() {
        let c1 = elemsById('container1');
        let c2 = elemsById('container2');
        if(this.parentNode === c1.get()) {
            c2.append(image);
        } else {
            c1.append(image);
        }
    };

</script>  
</body>
</html>

按此觀看結果

這兩個範例的事件處理,還沒有進一步做適當地封裝,因而看來風格不一致,這會是後續討論事件處理時的主題。

完整的 XD-1.0.0.js 可以按此下載