乾貨滿滿!如何優雅簡潔地實現時鐘翻牌器(支持JS/Vue/React)

雙十一剁手節過去了,你們應該在不少網頁中看到了數字翻牌的效果吧,好比倒計時、 數字增加等。相信不少人都已經本身獨立實現過了,我也在網上看了一些demo,發現HTML結構大多比較複雜,用了4個並列的標籤來放置先後兩個「牌」。本文就來說解下,如何進一步精簡HTML,讓結構簡單,讓JS方法封裝得易使用。先來看看最終效果:html

每一個翻牌的HTML結構(精簡至2個並列標籤):前端

<div class="flip down">
    <div class="digital front number0"></div>
    <div class="digital back number1"></div>
</div>
複製代碼

本次分享含有不少小技巧,靈活使用可以提高技術水平和工做效率,具體包括如下知識點:node

知識點1::before :after僞元素的使用git

知識點2:line-height: 0的妙用github

知識點3:transform-origin和perspective數組

知識點4:backface-visibilitybash

知識點5:時間格式化函數的實現微信

Let's do it!dom

1 翻牌的構建

1.1 基本結構

首先解釋下HTML的結構:函數

<!-- 翻牌的外框 -->
<div class="flip down">
    <!-- 位於前面的紙牌 -->
    <div class="digital front number0"></div>
    <!-- 位於後面的紙牌 -->
    <div class="digital back number1"></div>
</div>
複製代碼

【說明】

flip: 紙牌的外框

down:表示向下翻牌動效,還有對於的up。後面章節會具體講解。

front: 表示位於前面的紙牌

back: 表示位於後面的紙牌

number*: 表示紙牌上的數字

flip的CSS代碼以下:

.flip {
    display: inline-block;
    position: relative;
    width: 60px;
    height: 100px;
    line-height: 100px;
    border: solid 1px #000;
    border-radius: 10px;
    background: #fff;
    font-size: 66px;
    color: #fff;
    box-shadow: 0 0 6px rgba(0, 0, 0, .5);
    text-align: center;
    font-family: "Helvetica Neue"
}
複製代碼

這段代碼很基礎,就再也不詳細解釋了。眼尖的同窗可能發現了,爲何要設置background爲#fff(白色)呢?最終效果明明是黑的。留個疑問,下一小節就會明白了。

基本結構的效果是這樣的:

1.2 構建紙牌並用僞元素拆分上下兩部分

因爲每一個紙牌是上下對摺、翻轉的,因此每一個紙牌要拆分紅上下兩部分。但是HTML中每一個紙牌只有一個標籤,怎麼拆分紅兩個呢?這裏就用到了before和after僞元素。

知識點1: 僞元素的使用

先看代碼:

.flip .digital:before,
.flip .digital:after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    background: #000;
    overflow: hidden;
}

.flip .digital:before {
    top: 0;
    bottom: 50%;
    border-radius: 10px 10px 0 0;
}

.flip .digital:after {
    top: 50%;
    bottom: 0;
    border-radius: 0 0 10px 10px;
}
複製代碼

:before和:after在digital內部生成了兩個僞元素,其中,before用來生成紙牌的「上半張」,after用來生成紙牌的「下半張」。

所以,before「上半張」爲從「頂部(top: 0)」到「距底一半(bottom: 50%)」的部分,頂部兩側爲圓角。

同理,after「下半張」爲「距頂一半(top: 50%)」到「底部(bottom: 0)」的部分,底部兩側爲圓角。

注意代碼中的content: ""不能省略,不然僞元素是不顯示的。

效果以下:

回答上一章節的問題,爲何底層設置background爲白色?

答案很簡單,元素內部的紙片邊角和外層邊角之間會有一點點的縫隙,這個縫隙會露出底部的白色,從視覺效果上看,更加具備立體感。

而後,爲上下部分中間添加一條水平折線。

.flip .digital:before,
    .flip .digital:after {
        content: "";
        position: absolute;
        left: 0;
        right: 0;
        background: #000;
        overflow: hidden;
+       box-sizing: border-box; 
    }
    ...(略)
    .flip .digital:before {
        top: 0;
        bottom: 50%;
        border-radius: 10px 10px 0 0;
+       border-bottom: solid 1px #666;
    }
複製代碼

外層flip添加box-sizing: border-box保證了下邊框不會影響元素的原有高度。

效果以下:

到這裏,咱們能夠認爲是4個小紙片,分別是:

  1. 前上:.digital.front:before
  2. 前下:.digital.front:after
  3. 後上:.digital.back:before
  4. 後下:.digital.back:after

因爲重疊在一塊兒,只能看到一張紙牌。而看到的這個紙牌是後面(back)的紙牌,爲何呢?由於back的HTML寫在了front的後面。不過不要緊,後面咱們會經過z-index來從新調整層疊順序,先不着急。

1.3 爲紙牌添加文字

還記的剛纔的content: ""嗎?紙牌的文字顯示就用到了這個。

先經過CSS定義好0~9的數字:

.flip .number0:before,
.flip .number0:after {
    content: "0";
}

.flip .number1:before,
.flip .number1:after {
    content: "1";
}

.flip .number2:before,
.flip .number2:after {
    content: "2";
}
...(略)
.flip .number9:before,
.flip .number9:after {
    content: "9";
}
複製代碼

如今效果以下:

能夠很明顯的看到兩個問題:

  1. 本應該在後面的back紙牌跑到了前面(z-index問題)
  2. 下半張紙牌的文字應該只顯示下半部分。

先來解決問題2,這裏就涉及到了第二個知識點。

知識點2:line-height: 0的妙用

提到文字的顯示,確定會想到基線(baseline),可能你也曾經看過這個圖:

關於基線(baseline)的計算,確實很麻煩,我也在這裏繞了好久。其實理解line-height:0能夠換個角度,會更容易理解,請看下圖:

當line-height爲200px,每行文字高度爲200px,文字在200px高度的行間區域垂直居中;

當line-height爲100px,每行文字高度爲100px,文字在100px高度的行間區域垂直居中;

當line-height爲0時,行間距爲0,中線的位置也爲0,因此文字只有下半部分留在容器內。

利用line-height:0的特性,就能夠很輕易實現「下半張」紙牌只顯示文字的下半部分,而且與「上半張」紙牌很好的銜接在一塊兒。

在代碼中設置line-height爲0:

.flip .digital:after {
        top: 50%;
        bottom: 0;
        border-radius: 0 0 10px 10px;
+       line-height: 0;
    }
複製代碼

效果以下:

1.4 設置紙牌的層疊關係

首先,先看下「向下翻牌」的視頻演示,直觀感覺下每一個紙片的層級關係:

按照實物圖就能夠肯定每張紙片的z-index:

添加如下CSS代碼:

/*向下翻*/
.flip.down .front:before {
    z-index: 3;
}

.flip.down .back:after {
    z-index: 2;
}

.flip.down .front:after,
.flip.down .back:before {
    z-index: 1;
}
複製代碼

如今效果以下:

咦?怎麼不對?彆着急,這是由於咱們只設置了層級,可是沒有把後面紙牌的下半部翻轉上去。

添加翻轉代碼:

.flip.down .back:after {
        z-index: 2;
+       transform-origin: 50% 0%;
+       transform: perspective(160px) rotateX(180deg);
    }
複製代碼

這裏涉及到了知識點3。

知識點3:transform-origin和perspective

transform-origin是元素旋轉的基本點。

transform-origin: 50% 0%;表示將旋轉基本點設置在橫軸的中點,縱軸的頂點位置,以下圖所示:

perspective(160px)能夠理解爲立體透視圖的景深。在本次分享的效果中,咱們的視角是正對牌面,而且紙牌位於視角中間。因此 transform-origin的第一個值(X軸位置)爲50%。

rotateX(180deg)表示以X軸進行翻轉,對應這裏就是上下翻轉。這裏已經經過transform-origin的第二個參數(Y軸位置:0%)將X軸放在了元素頂部。

基於以上設置,已經能夠正常顯示了,以下圖:

同理,「向上翻」也須要進行設置下。你們能夠本身折兩個紙片,參照上面的方法,應該很容易實現。這裏再也不重複講解,直接放上代碼,你們能夠對比下哪裏不一樣:

/*向上翻*/
.flip.up .front:after {
    z-index: 3;
}

.flip.up .back:before {
    z-index: 2;
    transform-origin: 50% 100%;
    transform: perspective(160px) rotateX(-180deg);
}

.flip.up .front:before,
.flip.up .back:after {
    z-index: 1;
}
複製代碼

2 翻牌動畫的實現

如今紙片都已擺好了,剩下的就是實現CSS3動畫,以及JS交互控制。

2.1 CSS3翻牌動畫

咱們仍是以「向下翻」爲例,再來看下以前的實物翻牌視頻:

能夠看到,「向下翻」主要涉及兩個元素的動畫:

  1. 前面紙牌的上半部向下翻轉180度。
  2. 後面紙牌的下半部(目前已翻轉上去)向下翻轉180度恢復原狀態。

直接上代碼:

.flip.down.go .front:before {
    transform-origin: 50% 100%;
    animation: frontFlipDown 0.6s ease-in-out both;
    box-shadow: 0 -2px 6px rgba(255, 255, 255, 0.3);
}

.flip.down.go .back:after {
    animation: backFlipDown 0.6s ease-in-out both;
}

@keyframes frontFlipDown {
    0% {
        transform: perspective(160px) rotateX(0deg);
    }

    100% {
        transform: perspective(160px) rotateX(-180deg);
    }
}

@keyframes backFlipDown {
    0% {
        transform: perspective(160px) rotateX(180deg);
    }

    100% {
        transform: perspective(160px) rotateX(0deg);
    }
}
複製代碼

以上代碼涉及的知識點和原理沒有新的東西,都已經講解過了,就不詳述了。box-shadow是爲了給紙片的上邊緣加一點白光,視覺效果更好一點。不然在翻轉的時候,跟後面元素都是黑色,融在一塊兒了。看看如今的效果:

顯示不正常!爲何?由於前排上半部紙片的z-index最高,因此它在翻轉到下半部的時候仍然遮擋住了其餘紙片。怎麼優雅的解決?超級簡單,來看看第四個知識點:

知識點4:backface-visibility

backface-visibility表示元素的背面是否可見,默認爲visible(可見)。

這裏的需求是,當前面上半部紙片翻轉到一半的時候(90度)進入不可見狀態。而紙牌翻轉90度之後,正好是顯露元素背面的開始,因此將backface-visibility設置爲hidden便可完美解決!

修改代碼以下:

.flip.down.go .front:before {
        transform-origin: 50% 100%;
        animation: frontFlipDown 0.6s ease-in-out both;
        box-shadow: 0 -2px 6px rgba(255, 255, 255, 0.3);
+       backface-visibility: hidden;
    }
複製代碼

如今效果很完美!

你們能夠試着本身實現向上翻轉效果,代碼直接放出:

.flip.up.go .front:after {
    transform-origin: 50% 0;
    animation: frontFlipUp 0.6s ease-in-out both;
    box-shadow: 0 2px 6px rgba(255, 255, 255, 0.3);
    backface-visibility: hidden;
}

.flip.up.go .back:before {
    animation: backFlipUp 0.6s ease-in-out both;
}
@keyframes frontFlipUp {
    0% {
        transform: perspective(160px) rotateX(0deg);
    }

    100% {
        transform: perspective(160px) rotateX(180deg);
    }
}

@keyframes backFlipUp {
    0% {
        transform: perspective(160px) rotateX(-180deg);
    }

    100% {
        transform: perspective(160px) rotateX(0deg);
    }
}
複製代碼

2.2 JS實現翻牌交互

如今咱們來實現一個簡單的交互。需求是:

  1. 點擊「+」,向下翻牌,數字+1
  2. 點擊「-」,向上翻牌,數字-1

首先,修改下HTML:

+   <div class="single-demo">
M       <div class="flip down" id="flip">
            <div class="digital front number0"></div>
	        <div class="digital back number1"></div>
	    </div>
+	</div>
+   <div class="btn-con">
+       <button id="btn1">向下翻+1</button>
+       <button id="btn2">向上翻-1</button>
+   </div>
複製代碼

配套的CSS以下,僅爲了demo好看,無實際做用:

.single-demo {
    margin: 50px auto;
    padding: 30px;
    width: 600px;
    text-align: center;
    border: solid 1px #999;
}
複製代碼

Javascript代碼以下:

var flip = document.getElementById('flip')
var backNode = document.querySelector('.back')
var frontNode = document.querySelector('.front')
var btn = document.getElementById('btn')
btn1.addEventListener('click', function() {
    flipDown();
})
btn2.addEventListener('click', function() {
    flipUp();
})
// 當前數字
var count = 0
// 是否正在翻轉(防止翻轉未結束就進行下一次翻轉)
var isFlipping = false

// 向下翻轉+1
function flipDown() {
    // 若是處於翻轉中,則不執行
    if (isFlipping) {
        return false
    }
    // 設置前牌的文字
    frontNode.setAttribute('class', 'digital front number' + count)
    // 計算後牌文字(越界判斷)
    var nextCount = count >= 9 ? 0 : (count + 1)
    // 設置後牌的文字
    backNode.setAttribute('class', 'digital back number' + nextCount)
    // 添加go,執行翻轉動畫
    flip.setAttribute('class', 'flip down go')
    // 將翻轉態設置爲true
    isFlipping = true
    // 翻轉結束後,恢復狀態
    setTimeout(function() {
        // 去掉go
        flip.setAttribute('class', 'flip down')
        // 將翻轉態設置爲false
        isFlipping = false
        // 設置前牌文字爲+1後的數字
        frontNode.setAttribute('class', 'digital front number' + nextCount)
        // 更新當前文字
        count = nextCount
    }, 1000)
}
// 向上翻轉-1(同理,註釋略)
function flipUp() {
    if (isFlipping) {
        return false
    }
    frontNode.setAttribute('class', 'digital front number' + count)
    var nextCount = count <= 0 ? 9 : (count - 1)
    backNode.setAttribute('class', 'digital back number' + nextCount)
    flip.setAttribute('class', 'flip up go')
    isFlipping = true
    setTimeout(function() {
        flip.setAttribute('class', 'flip up')
        isFlipping = false
        frontNode.setAttribute('class', 'digital front number' + nextCount)
        count = nextCount
    }, 1000)
}
複製代碼

先看下交互效果:

這段Javascript代碼很冗餘,重複代碼不少。在實際產品中,都是多個數字牌,這種方式顯然沒法應對。下一章節,咱們來講下如何優雅的封裝,以不變應萬變。

3 翻牌時鐘的實現

先看下最終效果:

3.1 HTML構建

HTML代碼以下:

<div class="clock" id="clock">
    <div class="flip down">
        <div class="digital front number0"></div>
        <div class="digital back number1"></div>
    </div>
    <div class="flip down">
        <div class="digital front number0"></div>
        <div class="digital back number1"></div>
    </div>
    <em>:</em>
    <div class="flip down">
        <div class="digital front number0"></div>
        <div class="digital back number1"></div>
    </div>
    <div class="flip down">
        <div class="digital front number0"></div>
        <div class="digital back number1"></div>
    </div>
    <em>:</em>
    <div class="flip down">
        <div class="digital front number0"></div>
        <div class="digital back number1"></div>
    </div>
    <div class="flip down">
        <div class="digital front number0"></div>
        <div class="digital back number1"></div>
    </div>
</div>
複製代碼

CSS代碼以下(以前章節的CSS代碼請保留):

.clock {
    text-align: center;
}

.clock em {
    display: inline-block;
    line-height: 102px;
    font-size: 66px;
    font-style: normal;
    vertical-align: top;
}
複製代碼

效果以下,剩下的就是JS部分了。

3.2 構建Flipper類

將每一個翻牌封裝成類,這樣在應對多個翻牌的時候,能夠方便的經過new Flipper()去獨立控制每一個翻牌對象。

類的實現代碼以下:

function Flipper(config) {
    // 默認配置
    this.config = {
        // 時鐘模塊的節點
        node: null,
        // 初始前牌文字
        frontText: 'number0',
        // 初始後牌文字
        backText: 'number1',
        // 翻轉動畫時間(毫秒,與翻轉動畫CSS 設置的animation-duration時間要一致)
        duration: 600,
    }
    // 節點的本來class,與html對應,方便後面添加/刪除新的class
    this.nodeClass = {
        flip: 'flip',
        front: 'digital front',
        back: 'digital back'
    }
    // 覆蓋默認配置
    Object.assign(this.config, config)
    // 定位先後兩個牌的DOM節點
    this.frontNode = this.config.node.querySelector('.front')
    this.backNode = this.config.node.querySelector('.back')
    // 是否處於翻牌動畫過程當中(防止動畫未完成就進入下一次翻牌)
    this.isFlipping = false
    // 初始化
    this._init()
}
Flipper.prototype = {
    constructor: Flipper,
    // 初始化
    _init: function() {
        // 設置初始牌面字符
        this._setFront(this.config.frontText)
        this._setBack(this.config.backText)
    },
    // 設置前牌文字
    _setFront: function(className) {
        this.frontNode.setAttribute('class', this.nodeClass.front + ' ' + className)
    },
    // 設置後牌文字
    _setBack: function(className) {
        this.backNode.setAttribute('class', this.nodeClass.back + ' ' + className)
    },
    _flip: function(type, front, back) {
        // 若是處於翻轉中,則不執行
        if (this.isFlipping) {
            return false
        }
        // 設置翻轉狀態爲true
        this.isFlipping = true
        // 設置前牌文字
        this._setFront(front)
        // 設置後牌文字
        this._setBack(back)
        // 根據傳遞過來的type設置翻轉方向
        let flipClass = this.nodeClass.flip;
        if (type === 'down') {
            flipClass += ' down'
        } else {
            flipClass += ' up'
        }
        // 添加翻轉方向和執行動畫的class,執行翻轉動畫
        this.config.node.setAttribute('class', flipClass + ' go')
        // 根據設置的動畫時間,在動畫結束後,還原class並更新前牌文字
        setTimeout(() => {
            // 還原class
            this.config.node.setAttribute('class', flipClass)
            // 設置翻轉狀態爲false
            this.isFlipping = false
            // 將前牌文字設置爲當前新的數字,後牌由於被前牌擋住了,就不用設置了。
            this._setFront(back)
        }, this.config.duration)
    },
    // 下翻牌
    flipDown: function(front, back) {
        this._flip('down', front, back)
    },
    // 上翻牌
    flipUp: function(front, back) {
        this._flip('up', front, back)
    }
}
複製代碼

能夠注意到,Flipper的傳參只接受一個對象形式的參數config,使用對象的方式向函數傳參有不少優勢:

  1. 參數語義化,方便理解
  2. 不用在乎參數順序
  3. 傳參的增刪和順序調整不會影響業務代碼的使用

使用Object.assign方法,可將傳遞進來的config參數覆蓋默認參數。傳遞的config中沒有的屬性,則使用默認配置。固然,這種方式只適用於淺拷貝。

關於prototype,以及爲何要設置constructor,請閱讀個人另外一篇文章《一張刮刮卡竟包含這麼多前端知識點》第4.1章節,已經講解得很詳細了。

代碼邏輯請閱讀註釋。

3.3 實現時鐘業務邏輯

接下來的工做就是將js與dom進行綁定。

請看代碼:

這段代碼必定要放在Flipper類代碼的下面,Flipper.prototype必定要在業務邏輯代碼以前執行,不然會報錯找不到Flipper內部方法。

// 定位時鐘模塊
let clock = document.getElementById('clock')
// 定位6個翻板
let flips = clock.querySelectorAll('.flip')
// 獲取當前時間
let now = new Date()
// 格式化當前時間,例如如今是20:30:10,則輸出"203010"字符串
let nowTimeStr = formatDate(now, 'hhiiss')
// 格式化下一秒的時間
let nextTimeStr = formatDate(new Date(now.getTime() + 1000), 'hhiiss')
// 定義牌板數組,用來存儲6個Flipper翻板對象
let flipObjs = []
for (let i = 0; i < flips.length; i++) {
    // 建立6個Flipper實例,初始化並存入flipObjs
    flipObjs.push(new Flipper({
        // 每一個Flipper實例按數組順序與翻板DOM的順序一一對應
        node: flips[i],
        // 按數組順序取時間字符串對應位置的數字
        frontText: 'number' + nowTimeStr[i],
        backText: 'number' + nextTimeStr[i]
    }))
}
複製代碼

代碼邏輯不難,請閱讀註釋。比較值得分享的是其中的時間格式化函數formatDate。

知識點5:時間格式化函數的實現

爲了方便業務使用,實現一個時間格式化方法,這個方法在不少其餘業務中都會使用到,具備很廣泛的實用價值。

需求是經過輸入日期時間格式要求,輸出對應的字符串。

例如:

yyyy-mm-dd hh:ii:ss 輸出:2019-06-02 08:30:37

yy-m-d h:i:s 輸出:19-6-2 8:30:37

先看代碼:

//正則格式化日期
function formatDate(date, dateFormat) {
    /* 單獨格式化年份,根據y的字符數量輸出年份
     * 例如:yyyy => 2019
            yy => 19
            y => 9
     */
    if (/(y+)/.test(dateFormat)) {
        dateFormat = dateFormat.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length));
    }
    // 格式化月、日、時、分、秒
    let o = {
        'm+': date.getMonth() + 1,
        'd+': date.getDate(),
        'h+': date.getHours(),
        'i+': date.getMinutes(),
        's+': date.getSeconds()
    };
    for (let k in o) {
        if (new RegExp(`(${k})`).test(dateFormat)) {
            // 取出對應的值
            let str = o[k] + '';
            /* 根據設置的格式,輸出對應的字符
             * 例如: 早上8時,hh => 08,h => 8
             * 可是,當數字>=10時,不管格式爲一位仍是多位,不作截取,這是與年份格式化不一致的地方
             * 例如: 下午15時,hh => 15, h => 15
             */
            dateFormat = dateFormat.replace(RegExp.$1, (RegExp.$1.length === 1) ? str : padLeftZero(str));
        }
    }
    return dateFormat;
};

//日期時間補零
function padLeftZero(str) {
    return ('00' + str).substr(str.length);
}
複製代碼

代碼邏輯請閱讀註釋,這裏再補充下「日期時間補零padLeftZero」函數的說明。因爲月、日、時、分、秒最多爲2位數,因此這裏只考慮最多補一個0的狀況。

原理是:無論數字是幾位,先在前面補兩個0,再根據原數字的位數進行截取,最終輸出固定爲兩位的補零數字

例如:數字"16"是兩位數,先補兩個0變成"0016",再從該字符串的索引[2]開始截取(2=原數字的位數),因爲字符串索引從[0]開始,因此[2]對應字符串的第3位,輸出結果仍爲"16。

同理,數字"8"是1位數,先補兩個0變成"008",再從該字符串的索引[1]開始截取(1=原數字的位數),即從第2位開始截取,輸出"08"。

這樣就實現了補零的功能。

如今看下效果,已經能夠正確顯示當前時間了。

3.4 運行時鐘

萬事俱備,只差加個定時器讓時鐘翻動起來。

setInterval(function() {
    // 獲取當前時間
    let now = new Date()
    // 格式化當前時間
    let nowTimeStr = formatDate(new Date(now.getTime() - 1000), 'hhiiss')
    // 格式化下一秒時間
    let nextTimeStr = formatDate(now, 'hhiiss')
    // 將當前時間和下一秒時間逐位對比
    for (let i = 0; i < flipObjs.length; i++) {
        // 若是先後數字沒有變化,則直接跳過,不翻牌
        if (nowTimeStr[i] === nextTimeStr[i]) {
            continue
        }
        // 傳遞先後牌的數字,進行向下翻牌動畫
        flipObjs[i].flipDown('number' + nowTimeStr[i], 'number' + nextTimeStr[i])
    }
}, 1000)
複製代碼

這段代碼邏輯很簡單了,主要就是進行先後時間字符串的對比,而後設置紙牌並翻轉。最終效果:

4 Vue & React封裝

因爲篇幅有限,這裏再也不詳述,原理都是同樣的,只是利用Vue和React的API和語法進行封裝。

原生JavaScript、Vue、React三個版本的演示源碼請到個人github下載:

github.com/Yuezi32/fli…

本次分享講解了如何優雅地實現結構簡單的翻牌時鐘,並對JS進行了科學高效的封裝。其中也涉及到了CSS3的一些知識點和技巧。但願能對你們的工做有所幫助。

歡迎關注個人我的微信公衆號,隨時獲取最新文章^_^

相關文章
相關標籤/搜索