封裝樣式處理


樣式處理也許是瀏覽器中最複雜的部份,將所有細節予以封裝一定是個不錯的想法,為此,可以建立一個 Style-1.0.0.js,首先來看看 css 函式,它可以使用物件來一次設定想要的樣式:

// 可透過物件以 key : value(CSS)形式來設定樣式
function css(elem, props) {     
    Object.keys(props)
          .forEach(name => elem.style[name] = props[name]);
}

接著將〈存取元素位置〉中的 offset 放進去:

// 取得元素確實位置
function offset(elem) {
    let x = 0;
    let y = 0;
    for(let e = elem; e; e = e.offsetParent) {
        x += e.offsetLeft;
        y += e.offsetTop;
    }

    //  修正捲軸區域的量
    for(let e = elem.parentNode; e && e != document.body; e = e.parentNode) {
        if(e.scrollLeft) {
            x -= e.scrollLeft;
        }
        if(e.scrollTop) {
            y -= e.scrollTop;
        }
    }

    return { 
        x, 
        y, 
        toString() {
            return `(${this.x}, ${this.y})`;
        }
    };
}

然後準備處理〈顯示、可見度與透明度〉中 hideshow 函式,不過在這之前要想想,原本的 hideshow 函式直接在原生元素上新增了特性,這並不是個建議的方式(除非是為了相容性而修補物件,使之有相容於標準的新功能)。

必須要有個方法,可以為元素儲存相關特性,然而並非在元素本身,這時可以用上〈Set 與 Map〉中談過的 WeakMap

// 儲存元素對應的資料
let elemData = new WeakMap();
function storage(elem, data) {
    if(data === undefined) {
        return elemData.get(elem);
    } else {
        elemData.set(elem, data);
    }
}

// 設定元素的相關屬性,但此實作不是直接儲存在元素上
function prePropOf(elem, prop, value) {
    if(value === undefined) {
        let data = storage(elem);
        return data === undefined ? undefined : data[prop];
    } else {
        let data = storage(elem);
        if(data) {
            data[prop] = value;
        } 
        else {
            data = {[prop] : value};
        }
        storage(elem, data);
    }   
}

使用 WeakMap 的原因在於,如果元素已經不再被程式其他部份參考住,就可以直接 GC,WeakMap 也不會再有該元素,這可以避免記憶體洩漏的問題。

接著,就可以實現 hideshow 函式,以及 fadeOutfadeIn函式:

function computedStyle(elem, name, pseudoClz = null) {
    return window.getComputedStyle(elem, pseudoClz)[name];
}

// 顯示元素
function show(elem, pseudoClz = null) {
    elem.style.display = prePropOf(elem, 'display') || '';
    if(computedStyle(elem, 'display', pseudoClz) === 'none') {
        // 在 DOM 樹上建立元素,取得 display 預設值後移除
        let node = document.createElement(elem.nodeName);
        document.body.appendChild(node);
        elem.style.display = style(node, 'display');
        document.body.removeChild(node);
    }
}

// 隱藏元素
function hide(elem, pseudoClz = null) {
    let display = computedStyle(elem, 'display', pseudoClz);
    prePropOf(elem, 'display', display);
    elem.style.display = 'none';
}

// 取得透明度的數字
function opacity(elem, pseudoClz = null) {
    let opt = computedStyle(elem, 'opacity', pseudoClz);
    return opt === '' ? 1 : parseFloat(opt);
}

//speed 是動畫總時間,step 是動畫數
// 淡出
function fadeOut(elem, speed = 5000, steps = 10, pseudoClz = null) {
    let preOpacity = opacity(elem, pseudoClz);

    prePropOf(elem, 'opacity', preOpacity);

    let timeInterval = speed / steps;
    let valueStep = preOpacity / steps;

    let opt = preOpacity;
    setTimeout(function next() {
        opt -= valueStep;
        if(opt > 0) {
            elem.style.opacity = opt;
            setTimeout(next, timeInterval);
        }
        else {
            elem.style.opacity = 0;
        }
    }, timeInterval);
} 

// 淡入
function fadeIn(elem, speed = 5000, steps = 10, pseudoClz = null) {
    let targetValue = prePropOf(elem, 'opacity') || 1;

    let timeInterval = speed / steps;
    let valueStep = targetValue / steps;

    let opt = 0;
    setTimeout(function next() {
        opt += valueStep;
        if(opt < targetValue) {
            elem.style.opacity = opt;
            setTimeout(next, timeInterval);
        }
        else {
            elem.style.opacity = targetValue;
        }
    }, timeInterval);
}  

接著就是將一些先前文件中看過的其他樣式相關函式放進去了:

// 是否有指定類別
function hasClass(elem, clz) {
    let clzs = elem.className;
    if(!clzs) {
        return false;
    } else if(clzs === clz) {
        return true;
    }
    return clzs.search(`\\b${clz}\\b`) !== -1;
}

// 新增類別
function addClass(elem, clz) {
    if(!hasClass(elem, clz)) {
        if(elem.className) {
            clz = ` ${clz}`;
        }
        elem.className += clz;
    }
}

// 移除類別
function removeClass(elem, clz) {
    elem.className = elem.className.replace(
      new RegExp(`\\b${clz}\\b\\s*`, 'g'), '');
}

function toggleClass(elem, clz1, clz2) {
    if(hasClass(elem, clz1)) {
        removeClass(elem, clz1);
        addClass(elem, clz2);
    }
    else if(hasClass(elem, clz2)) {
        removeClass(elem, clz2);
        addClass(elem, clz1);
    }
}

// 集中取得維度用的方法
class Dimension {
    static screen() {
        return {
            width: screen.width,
            height: screen.height
        };
    }

    static screenAvail() {
        return {
            width: screen.availWidth,
            height: screen.availHeight
        };        
    }

    static browser() {
        return {
            width: window.outerWidth,
            height: window.outerHeight
        };
    }

    static html() {
        return {
            width: window.documentElement.scrollWidth,
            height: window.documentElement.scrollHeight
        };        
    }

    static body() {
        return {
            width: window.body.scrollWidth,
            height: window.body.scrollHeight
        };        
    }

    static viewport() {
        return {
            width: window.innerWidth,
            height: window.innerHeight
        };        
    }
}

// 集中取得座標用的方法
class Coordinate {
    static browser() {
        return {
            x: window.screenX,
            y: window.screenY
        };                
    }

    static scroll() {
        return {
            x: window.pageXOffset,
            y: window.pageYOffset
        };        
    }
}

匯出的名稱有以下這些:

export {css, offset, hide, show, fadeOut, fadeIn};
export {hasClass, addClass, removeClass, toggleClass};
export {Dimension, Coordinate};

再來就是處理 XD-1.2.0.js 了,首先匯入相關名稱:

import {css, offset, hide, show, fadeOut, fadeIn} from './Style-1.0.0.js';
import {hasClass, addClass, removeClass, toggleClass} from './Style-1.0.0.js';

ElemCollection 上添增一些方法:

class ElemCollection {

    ...

    // 如果 value 為 undefined,取得元素 style 特性上對應的樣式
    // 否則在元素的 style 上設定特性           
    style(name, value) { 
        let elems = this.elems;
        let propName = PROPS.has(name) ? PROPS.get(name) : name;

        if(value === undefined) {
            return elems[0] ? elems[0].style[propName] : null;
        } else {
            elems.filter(elem => !isTextNode(elems[0]) && !isCommentNode(elems[0]))
                 .forEach(elem => elem.style[propName] = value);
            return this;
        }
    }

    // 取得計算樣式,不寫在 style() 方法中的理由在於
    // 從計算樣式與 style() 方法傳回值是否為 undefined
    // 可以知道樣式是來自樣式表或者是 style 設定
    // 明確化來源是其目的
    computedStyle(name, pseudoClz = null) {
        let elems = this.elems;
        let propName = PROPS.has(name) ? PROPS.get(name) : name;
        return elems[0] && !isTextNode(elems[0]) && !isCommentNode(elems[0]) ?
                    window.getComputedStyle(elems[0], pseudoClz)[propName] : null;
    }

    // 可透過物件以 key : value(CSS)形式來設定樣式
    css(props) {
        let standardized =
              Object.keys(props)
                    .reduce((acc, name) => {
                         acc[PROPS.has(name) ? PROPS.get(name) : name] = props[name];
                         return acc;
                    }, {});

        this.elems.forEach(elem => css(elem, standardized));
        return this;
    }

    // 取得元素確實位置 
    offset() {
        let elems = this.elems;
        return elems[0] ? offset(elems[0]) : null;
    }

    // 隱藏元素
    hide(pesudoClz = null) {
        this.elems.forEach(elem => hide(elem, pesudoClz));
        return this;
    }

    // 顯示元素
    show(pesudoClz = null) {
        this.elems.forEach(elem => show(elem, pesudoClz));
        return this;
    }

    // 淡出
    fadeOut(speed = 5000, steps = 10, pseudoClz = null) {
        this.elems.forEach(elem => fadeOut(elem, speed, steps, pseudoClz));
        return this;
    }

    // 淡入
    fadeIn(speed = 5000, steps = 10, pseudoClz = null) {
        this.elems.forEach(elem => fadeIn(elem, speed, steps, pseudoClz));
        return this;
    }

    // 第一個元素是否有指定類別
    hasClass(clz) {
        let elems = this.elems;
        return elems[0] ? hasClass(elems[0], clz) : null;
    }

    // 加入類別
    addClass(clz) {
        this.elems.forEach(elem => addClass(elem, clz)); 
        return this;
    }

    // 移除類別
    removeClass() {
        this.elems.forEach(elem => removeClass(elem, clz)); 
        return this;
    }

    // 切換類別
    toggleClass(clz1, clz2) {
        this.elems.forEach(elem => toggleClass(elem, clz1, clz2));
        return this;        
    }
}

XD-1.2.0.js 實際上是作為一個門戶(Facade),對於 Style-1.0.0.js 中的 DimensionCoordinate 直接匯出就可以了:

export {Dimension, Coordinate} from './Style-1.0.0.js';

然而,XD-1.2.0.js 作為一個門戶,也必須考量到的是,ElemCollection 會不會擔負了太多職責了?在未來你可能繼續在上頭添增一些方便的方法,而使得 ElemCollection 成為一個無所不能的超級或上帝類別(God class)?

這是個必須考量的問題,就目前來說,為了簡化範例才這麼做,然而,實際上,應該讓 ElemCollection 只處理一些基礎事務,像 hideshowfadeOutfadeIn 這些是基礎事務嗎?雖然目前都放在 ElemCollection 的話,寫起程式來會很爽,然而,它們應該不太算是基礎事務,而算是特效之類的東西。

因此比較好的作法是,基於 XD-1.2.0.js 上,建構一個 Effect 模組來專門處理特效,將 Style-1.0.0.js 中 hideshowfadeOutfadeIn 函式放到 Effect 模組中,而 ElemCollection 上的 hideshowfadeOutfadeIn 方法,在 Effect 模組中設計一個物件或者是相關函式來處理,在必須用到特效時,可以從 ElemCollection 建構出特效物件,將特效的職責分離出來。

這也有助於 Style-1.0.0.js 瘦身,ES6 並不鼓勵一個模組匯出太多東西,而成為一個超級模組,就目前來說,Style-1.0.0.js 匯出的東西算是比較多一些了,只是為了簡化範例,暫且沒將特效的東西分離出來而已,未來 Style-1.0.0.js 中的東西越來越多時,應避免它成為一個超級模組。

分離職責的東西,就交給你自己來試試了,你可以在 XD-1.2.0.jsStyle-1.0.0.jsEvt-1.0.0.js 下載到目前已封裝之程式庫。

現在先來看看,基於目前的程式庫封裝,可以如何簡化先前看過的範例,首先是〈存取樣式資訊〉中第一個範例的改寫(你的瀏覽器必須支援 ES6 模組):

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>
    <div id="message">這是一段訊息</div>

<script type="module">
    import {elemsById} from './js/XD-1.2.0.js';
    elemsById('message').css({
                    color : '#ffffff',
                    backgroundColor : '#ff0000',
                    width : '300px',
                    height : '200px',
                    paddingLeft : '250px',
                    paddingTop : '150px'
                });

</script>  

</body>
</html>

按我觀看結果

再來是〈存取樣式資訊〉中最後一個範例:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <style type="text/css">
        #message {
            color: #ffffff; 
            background-color: #ff0000; 
            width: 500px; 
            height: 200px; 
            padding-left: 250px; 
            padding-top: 150px;
        }
    </style>
</head>
<body>

    <div id="message">這是一段訊息</div>
    <span id="console"></span>

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

    let color = elemsById('message').computedStyle('backgroundColor');
    elemsById('console').html(color);
</script>  
</body>
</html>

按我觀看結果

以下是〈存取元素位置〉的第三個範例改寫:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <style type="text/css">
         #container {
             color: #ffffff;
             background-color: #ff0000;
             height: 50px;
             position: absolute;
             top: -100px;
             left:-100px;
         }
    </style>
</head>
<body>

    <div id="container">這是一段訊息</div>
    <hr>
    搜尋:<input id="search" type="text">

<script type="module">
    import x from './js/XD-1.2.0.js';
    let doc = x(document);

    let input = doc.elemsById('search');
    let offsetWidth = input.attr('offsetWidth');
    let offsetHeight = input.attr('offsetHeight');
    let search = input.offset();

    doc.elemsById('container')
       .css({
           left  : `${search.x}px`,
           top   : `${search.y + offsetHeight}px`,
           width : `${offsetWidth}px`
       });

</script>

</body>
</html>

按我觀看結果

以下是〈顯示、可見度與透明度〉第一個範例的改寫:

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

    <style type="text/css">
        #message {
            color: #ffffff;
            background-color: #ff0000;
            border-width: 10px;
            border-color: black;
            border-style: solid;
            width: 100px;
            height: 50px;
            padding: 50px;
        }
    </style>  
</head>
<body>

    <button id='toggle'>切換顯示狀態</button>
    <hr>
    這是一些文字!這是一些文字!這是一些文字!這是一些文字!這是一些文字!
    <div id="message">這是訊息一</div>
    這是其他文字!這是其他文字!這是其他文字!這是其他文字!這是其他文字!

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

    elemsById('toggle').addEvt('click', evt => {
        let message = elemsById('message');
        if(message.computedStyle('display') === 'none') {
             message.show();
        } else {
             message.hide();
        }
    });

</script>  

</body>
</html>

按我觀看結果

以下是〈顯示、可見度與透明度〉第二個範例的改寫:

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

    <button id='fadeOut'>淡出</button>
    <button id='fadeIn'>淡入</button><br>
    <img id="image" src="https://openhome.cc/Gossip/images/caterpillar_small.jpg">  

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

    let image = elemsById('image');
    elemsById('fadeOut').addEvt('click', evt => {
        image.fadeOut();
    });

    elemsById('fadeIn').addEvt('click', evt => {
        image.fadeIn();
    });
</script>

  </body>
</html>

按我觀看結果

以下是〈操作 class 屬性〉中的範例改寫:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <style type="text/css">
        .released {
            border-width: 1px;
            border-color: red;
            border-style: dashed;
        }

        .pressed {
            border-width: 5px;
            border-color: black;
            border-style: solid;
        }
  </style>

</head>
<body>

  <img id="logo" class='released' 
       src="https://openhome.cc/Gossip/images/caterpillar_small.jpg">  

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

    let logo = elemsById('logo');

    logo.addEvt('click', evt => {
        logo.toggleClass('released', 'pressed');
    });

</script>  

</body>
</html>

按我觀看結果

以下是〈取得視窗維度資訊〉中的範例改寫:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <style type="text/css">
        #message1 {
            text-align: center;
            vertical-align: middle;
            color: #ffffff;
            background-color: #ff0000;
            width: 100px;
            height: 50px;
            position: absolute;
            top: 0px;
            left: 0px;
        }
    </style>
</head>
<body>

    這些是一些文字<br>這些是一些文字<br>這些是一些文字<br>
    <button>其他元件</button>
    <div id="message1">
        看點廣告吧!<br><br>
        <button id="confirm">確定</button>
    </div>

<script type="module">

    import {elemsById, Dimension} from './js/XD-1.2.0.js';

    let {width, height} = Dimension.viewport();
    let message1 = elemsById('message1');

    message1.css({
        opacity : 0.5,
        width   : `${width}px`,
        height  : `${height / 2}px`,       
        paddingTop : `${height / 2}px`
    });

    elemsById('confirm').addEvt('click', evt => {
        message1.css({
            width   : '0px',
            height  : '0px',       
            paddingTop : '0px',
            display : 'none'
        });        
    });


</script>

</body>
</html>

按我觀看結果