iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 1
0
Modern Web

JS30 錄系列 第 1

Day 1 - JS Drum Kit

任務內容

利用JS做出一組爵士鼓.
HTML code如下, 類別keys內部包著九個類別為key, 但持有不同data-key屬性值的div標籤. 在外頭也有九個分別與之對應的audio標籤. 當與kbd標籤內部對應的按鍵被按下時, 會發出相對應的聲響.

<div class="keys">
  <!--keys裡面包著key-->
  <div data-key="65" class="key">
    <kbd>A</kbd>
    <span class="sound">clap</span>
  </div>
</div>

<!--與key對應的audio標籤-->
<audio data-key="65" src="sounds/clap.wav"></audio>

作法

首先在window物件底下設置監聽keydown事件的監聽器, window物件為包覆整個DOM的物件, 在該處設置監聽器可以監聽DOM內觸發的所有事件. keydown事件只要瀏覽器偵測到鍵盤被按下的瞬間就會觸發. 被觸發的瞬間, 執行自訂函式playSound來回應.

window.addEventListener('keydown', playSound);

playSound函式就是用來播放聲音的! 要發出指定的聲音需要幾個步驟, 以按鍵'A'被按下的過程來舉例:

  1. 鍵盤的'A'慘遭按下
  2. 存取data-key屬性中帶有'A'的keyCode的audio元素, 如果沒有找到目標, 直接停止動作並返回
  3. 如果有找到, 播放該audio元素連結的聲音檔

按照以上的邏輯, 寫出來的程式碼大致如下:

function playSound(e) {
  const audio = document.querySelector(`audio[data-key="${e.keyCode}"]`);
  if (!audio) return;

  audio.play();
}

看起來有點複雜, 其實也還行! 拆開來個別理解一下...

playSound(e)

addEventListener會把被監聽的事件物件當成值, 傳入回應的自訂函式之中. 要讓自訂函式收到該值, 可以在宣告自訂函式時提供一個代表該事件的參數, 通常會用e (或是event)命名. 在這裡playSound(e)裡面的e代表keydown事件的物件

e.keyCode

e.keyCode屬性記錄著"按下的按鍵"的keyCode. 鍵盤上的每個按鍵都有一個相對應的keyCode, 有興趣可以在 http://keycode.info/ 看看各個按鍵的keyCode. 以'A'舉例, 就是65.

document.querySelector

document.querySelector() 是用來選取DOM的方法, 括號內部填入代表CSS選擇器的字串.

CSS Selector

CSS Selector 是一組用來選擇特定元素的CSS 符號. `audio[data-key="65"]` 就是一串CSS selector. 在CSS Selector的意思為具有屬性data-key="65"的audio標籤.

Template Literal

Template Literial是在字串前後以` `代替" ", 通常內部放的東西會被當成字串, 若要插入變數只要以${變數}隔開就好. 使用 Temlate Literial讓"在字串中插入變數"的動作更加容易.

我們希望 data-key 的值是每次按下去的按鍵的keyCode, 而不是固定的65, 因此需要在data-key的值放進代表按鍵輸入keyCode的變數. 結合CSS Selector 和 Template Literal, 可以寫成 `audio[data-key="${e.keyCode}"]`.

data-* 屬性

data-key屬性是自訂的 data-* 標頭屬性, 這種屬性通常用來儲存與該元素標籤相關的小型資料, 一個標籤可以有好幾個 data-* 屬性.

所以下面這段程式碼的意思就是, 找到data-key屬性中存著按下按鍵keyCode的audio標籤, 並指定給audio這個變數.

const audio = document.querySelector(`audio[data-key="${e.keyCode}"]`);

根據原始檔提供的HTML標籤, 只有九個按鍵會有相對應的audio標籤. if(!audio) return 的意思為: 如果沒有找到對應的audio, 就返回.
if() 括號內的東西會自動被轉成Boolean, 結果只有true或false, 除了falsy以外的東西, 放進去的結果都是true. , 如果按下去的按鍵沒找到相對應的HTML標籤, audio變數的值就會是 undefined , 是falsy的一種, !audio就是非audio.
如果沒有被返回, 表示有該標籤, 就可以在下一行用audio.play()播放.

大致上完成了! 但有些不流暢的小地方!
實際打鼓後會發現, 有些比較長的聲音檔在播放時, 當新按鍵按下去, 新的聲音檔並不會被立即執行! 所以我們的鼓會有點lag, 沒辦法咚咚咚一直敲, 這簡直侮辱了鼓手的尊嚴!
所以我們得加點料...

function playSound(e) {
  const audio = document.querySelector(`audio[data-key="${e.keyCode}"]`);
  if (!audio) return;
  // 在 play 之前加入這行
  audio.currentTime = 0;
  audio.play();
}

在audio.play()上方加了一行audio.currentTime = 0. currentTime特性代表目前播放的進度.每次播放聲音前, 將播放進度設定回原點, 然後再播放, 這麼一來就可以連續敲打了!!

但還是有個美中不足之處, 我們希望敲打時, 被敲打的按鍵會發光並放大, 讓我們知道自己正在敲打哪個樂器, 沒錯, 這就是身為鼓的使命.
所以可以...

  1. 選取所有具有key類別的標籤
  2. 在選取的標籤上設下監聽器: 如果該標籤被按到了, 就發光變大!
  3. 然後再變回去

因此得加入一些程式碼!

function playSound(e) {
  const audio = document.querySelector(`audio[data-key="${e.keyCode}"]`);
  // 用一樣的方式找尋具有相同data-key的div元素
  const key = document.querySelector(`div[data-key="${e.keyCode}"]`);
  if (!audio) return;

  // 在代表按鍵圖示的標籤上直接加上 playing 類別
  key.classList.add('playing');
  audio.currentTime = 0;
  audio.play();
}

用相同的方式選取具有對應data-key的div元素, 如果具有該HTML元素, 就為它加上'playing`這個CSS類別. playing這個類別記錄了放大和邊緣發黃光的CSS! 加上的瞬間, 它就會發光!

.playing {
  transform: scale(1.1);
  border-color: #ffc600;
  box-shadow: 0 0 1rem #ffc600;
}

但總不能一直讓它發著光, 所以必須設定發完光就光芒退散.
此時先看一下key類別的CSS.

.key {
  /* 前略... */
  transition: all .07s ease;
  /* 後略... */
}

transition 是CSS轉場, 第一個參數代表變化時會使用到轉場的屬性, all就是全部, 表示.key內可以支援轉場效果的任何屬性, 只要發生變化, 都會以動畫的方式漸變到新的屬性去. 第二個參數代表在多少時間內要完成轉場,第三個參數代表轉場過程與時間相依的函數, 簡單來說轉場速度不會是固定的, 可以依照設定在一開始轉變得很快, 後來變很慢...之類的.
會特別提到轉場, 是因為轉場結束後會觸發一個transitionend事件, 是我們要監聽的對象! 是的長官, 發現目標了!
在keydown監聽器上方加上這兩行!

const keys = document.querySelectorAll('.key');
keys.forEach(key => key.addEventListener('transitionend', removeTransition));

document.querySelectorAll('.key')把所有帶有key類別的元素都選起來, 並指定給key變數. key變數內所存的值會是一串清單(DOM List), 內容是所有帶有key類別的div元素. 注意這個清單本身並不是一個陣列(Array), 只是長得很像. 我們叫它類陣列(Array-Like). 類陣列跟陣列的差別在於, 它少了陣列本身所具有的一些原型屬性(properties) 與方法(methods), 好險Array.forEach()是支援DOM List的!

forEach()方法顧名思義, 能把陣列內的每個元素用自訂的函式迭代執行一次. 舉例如下:

/* 這是 forEach 的舉例 */
var exampleArray = [1, 2, 3];
exampleArray.forEach(number => number += 2); // 結果為 [3, 4, 5]

因此上述程式碼的第二行意思是: 將所有帶有key類別的div元素加上監聽transitionend的監聽器, 只要任何key類別元素轉變完畢, 就執行removeTransition自訂函式, 將放大和發光的效果移除! removeTransition函式的內部長這樣:

  function removeTransition(e) {
    if (e.propertyName !== 'transform') return;
    e.target.classList.remove('playing');
  }

為什麼會有 if(e.propertyName... 什麼的?
因為CSS轉場的第一個參數是all, 當一個有對應按鈕的按鍵被按下時, 該按鍵的transform, border-color, box-shadow都被新增的 playing 類別影響, 都改變了, 都有轉場, 都會有轉場結束的時候, 因此會觸發好幾個transitionend! 可是我們只需要一個啊!
所以我們只需要留下一個屬性, 並將其它屬性觸發事件的自訂函數返回. 這裡用傳入的事件物件e裡面的propertyName屬性, 獨留transform的回呼函式. 事件物件的target屬性存有觸發事件的HTML元素本身, 在這裡即為剛轉場結束, 帶有key類別的div元素. 利用e.target.classList.remove('playing') 把放大發光效果移除.

整個串起來如下面的code, 好!

function removeTransition(e) {
  if (e.propertyName !== 'transform') return;
  e.target.classList.remove('playing');
}

function playSound(e) {
  const audio = document.querySelector(`audio[data-key="${e.keyCode}"]`);
  const key = document.querySelector(`div[data-key="${e.keyCode}"]`);
  if (!audio) return;
  key.classList.add('playing');
  audio.currentTime = 0;
  audio.play();
}

const keys = document.querySelectorAll('.key');
keys.forEach(key =\> key.addEventListener('transitionend', removeTransition));
window.addEventListener('keydown', playSound);

以上就是JS 30 第一篇的心得分享! 嗯打太細了...


下一篇
Day 2 - JS & CSS Clock
系列文
JS30 錄30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言