F_JS30 11-20

11 Custom HTML5 Video Player

一開始長這樣,什麼也沒有

image

先取得元素

const player = document.querySelector("video");
const video = player.querySelector(".viewer");
const progress = player.querySelector(".progress");
const progressBar = player.querySelector(".progress__filled");
const toggle = player.querySelector(".toggle");

const ranges = player.querySelectorAll(".player__slider");
const skipButtons = player.querySelectorAll("[data-skip]");

play() method

video.paused 是一個屬性,是判斷影片是否正在暫停狀態
影片暫停, video.paused 會顯示 true ,反之顯示 false。作者透過這個來做一些判斷。

console.dir(video) 可以查看到此屬性:

image

先監聽video有click事件時,觸發影片切換播放或暫停

// 切換播放或暫停
function togglePlayer() {
  const method = video.paused ? "play" : "pause";
  video[method]();
}

video.addEventListener("click", togglePlayer);

再來監聽video是play事件、pause事件,都觸發按鈕的圖示,以及監聽按鈕是click事件,觸發切換影片播放或暫停

// 設定播放鈕圖示
function updateButton() {
  const icon = this.paused ? "►" : "❚❚";
  toggle.textContent = icon;
}

video.addEventListener("play", updateButton);
video.addEventListener("pause", updateButton);

toggle.addEventListener("click", togglePlayer);

再來製作skip button,一個是退後10秒,一個是往前25秒
this.dataset.skip 是字串,所以要轉成數字讓 video.currentTime 可以加。

// 設定skip按鈕
function skip() {
  console.log(this.dataset.skip);
  video.currentTime += parseFloat(this.dataset.skip);
}

skipButtons.forEach((button) => button.addEventListener("click", skip));

Q:為什麼用parseFloat轉成數字?是因為影片秒數是小數點關係嗎?

image

設定音量和播放速率

<input
  type="range"
  name="volume"
  class="player__slider"
  min="0"
  max="1"
  step="0.05"
  value="1"
/>
<input
  type="range"
  name="playbackRate"
  class="player__slider"
  min="0.5"
  max="2"
  step="0.1"
  value="1"
/>

作者特別將這兩個 range 的 name 設定跟 video 裡面的屬性一樣名字:name="volume"name="playbackRate"

image
image

// 設定音量和播放速率
function handleRange() {
  video[this.name] = this.value;
}

ranges.forEach((range) => range.addEventListener("change", handleRange));
ranges.forEach((range) => range.addEventListener("mousemove", handleRange));

最後是進度條了!
因為是用 flex-basis 來顯示播放進度位置,所以設計 flex-basis 的百分比對應影片時間

.progress__filled {
  width: 50%;
  background: #ffc600;
  flex: 0;
  flex-basis: 50%;
}
// 設定進度條顏色位置
function handleProgress() {
  const percent = (video.currentTime / video.duration) * 100;
  progressBar.style.flexBasis = `${percent}%`;
}

video.addEventListener("timeupdate", handleProgress);

作者說用 timeupdate 事件來觸發

根據影片播放不斷更新百分比

image


進度條也需要有點擊的方式
所以去找整個進度條的 offset
可以看到progress的是640px,如點擊一半就是 offetX 屬性為320的位置

image

透過console.log(e)來查看我click progress中的offsetX為多少

image

// 設定進度條可以點擊要的位置
function scrub(e) {
  const scrubTime = (e.offsetX / progress.offsetWidth) * video.duration;
  video.currentTime = scrubTime;
}
progress.addEventListener("click", scrub);
progress.addEventListener("mousemove", scrub);

但影片跟不上mousemove位置,所以需要再寫條件判斷

let mousedown = false;
progress.addEventListener("click", scrub);
progress.addEventListener("mousemove", (e) => mousedown && scrub(e));
progress.addEventListener("mousedown", () => (mousedown = true));
progress.addEventListener("mouseup", () => (mousedown = false));

(e) => mousedown && scrub(e) 這寫法特別??

Q:offsetWidth是蝦咪?元素的完整可見寬度
Q:progress.offsetWidth為什麼不是this.offsetWidth?

完整程式碼:

// 取得元素
const player = document.querySelector(".player");
const video = player.querySelector(".viewer");
const progress = player.querySelector(".progress");
const progressBar = player.querySelector(".progress__filled");
const toggle = player.querySelector(".toggle");

const ranges = player.querySelectorAll(".player__slider");
const skipButtons = player.querySelectorAll("[data-skip]");

// 切換播放或暫停
function togglePlayer() {
  const method = video.paused ? "play" : "pause";
  video[method]();
}

// 設定播放鈕圖示
function updateButton() {
  const icon = this.paused ? "►" : "❚❚";
  toggle.textContent = icon;
}

// 設定skip按鈕
function skip() {
  video.currentTime += parseFloat(this.dataset.skip);
}

// 設定音量和播放速率
function handleRange() {
  video[this.name] = this.value;
}

// 設定進度條顏色位置
function handleProgress() {
  const percent = (video.currentTime / video.duration) * 100;
  progressBar.style.flexBasis = `${percent}%`;
}
// 設定進度條可以點擊要的位置
function scrub(e) {
  const scrubTime = (e.offsetX / progress.offsetWidth) * video.duration;
  video.currentTime = scrubTime;
}

// 連接監聽
video.addEventListener("click", togglePlayer);
video.addEventListener("play", updateButton);
video.addEventListener("pause", updateButton);
video.addEventListener("timeupdate", handleProgress);

toggle.addEventListener("click", togglePlayer);

skipButtons.forEach((button) => button.addEventListener("click", skip));

ranges.forEach((range) => range.addEventListener("change", handleRange));
ranges.forEach((range) => range.addEventListener("mousemove", handleRange));

let mousedown = false;
progress.addEventListener("click", scrub);
progress.addEventListener("mousemove", (e) => mousedown && scrub(e));
progress.addEventListener("mousedown", () => (mousedown = true));
progress.addEventListener("mouseup", () => (mousedown = false));

12 JavaScript KONAMI CODE!

輸入一段特定字串之後出現特定的畫面,稱為 key senquence。

把輸入的key,push到 pressed 陣列

window.addEventListener("keyup", (e) => {
    pressed.push(e.key);
    // 用來設計pressed的陣列長度都不會超過secretCode的長度
    console.log(pressed);

image

Array.prototype.splice()

作者為了要讓 pressed 陣列長度都不會超過secretCode的長度

pressed.splice(
      -secretCode.length - 1,
      pressed.length - secretCode.length
    );
  • -secretCode.length - 1 :意思是從 pressed 陣列最後面往前數到-5的位置,因為secretCode.length長度為4,只要長度超過4,就刪除
  • pressed.length - secretCode.length:這個位置是要刪除元素數量。所以長度超過 secretCode.length 的元素都是多餘元素,讓 pressed 的長度都保持不超過 secretCode.length
  • 結果會是splice(-5,1),表示從陣列的倒數第5個元素開始,刪除1個元素

那既然都是刪除最前面一個字,為什麼不直接寫0就好?
測試這樣寫也沒問題XD

pressed.splice(0, pressed.length - secretCode.length);

可以看到第5個開始,就開始刪除最前面的元素,維持陣列內是4個元素,直到符合secretCode字串就執行之後的程式碼。

image

完整程式碼:

  const pressed = [];
  const secretCode = "fang";

  window.addEventListener("keyup", (e) => {
    pressed.push(e.key);
    // 用來設計pressed的陣列長度都不會超過secretCode的長度
    pressed.splice(
      -secretCode.length - 1,
      pressed.length - secretCode.length
    );
    if (pressed.join("").includes(secretCode)) cornify_add();
  });
// cornify.js檔案是作者另外引入的,會執行裡面的事情

image

13 Vanilla JavaScript Slide In on Scroll

使用 scroll 事件,會發現他不斷觸發
例如將此網頁從頭滾動到底部,就觸發了 scroll 事件179次

image
透過 debounce 函式,讓整個事件觸發幾次就好
作者也是上網找 debounce 函式套用而已

  function debounce(func, wait = 20, immediate = true) {
    var timeout;
    return function () {
      var context = this,
        args = arguments;
      var later = function () {
        timeout = null;
        if (!immediate) func.apply(context, args);
      };
      var callNow = immediate && !timeout;
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
      if (callNow) func.apply(context, args);
    };
  }

  function checkSlide(e) {
    console.count(e);
  }
  const slideImages = document.querySelectorAll(".slide-in");
  window.addEventListener("scroll", debounce(checkSlide)); // 透過debounce減少checkSlide觸發次數

可以看到同樣都是從頭滾動到底部,觸發次數只有13次

image

debounce 防抖函式

再來設計當滾動到此圖片的高度50%,就增加圖片的動畫滑入

window.innerHeight:是視窗高度

window.scrollY:是瀏覽器頂部向下滾動多少

好難懂作者的計算方式。。。。先照抄

完整程式碼:

function debounce(func, wait = 20, immediate = true) {
    var timeout;
    return function () {
      var context = this,
        args = arguments;
      var later = function () {
        timeout = null;
        if (!immediate) func.apply(context, args);
      };
      var callNow = immediate && !timeout;
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
      if (callNow) func.apply(context, args);
    };
  }
const slideImages = document.querySelectorAll(".slide-in");

  function checkSlide() {
    slideImages.forEach((slideImage) => {
      // 判斷視窗底部到達圖片中線
      const slideInAt =
        window.scrollY + window.innerHeight - slideImage.height / 2;
      // 用來判斷視窗是否滾動過圖片的底部
      const imageBottom = slideImage.offsetTop + slideImage.height;
      // 判斷圖片的中線是否到視窗
      const isHalfShown = slideInAt > slideImage.offsetTop;
      // 判斷圖片是否還未滾出視窗
      const isNotScrolledPast = window.scrollY < imageBottom;
      if (isHalfShown && isNotScrolledPast) {
        slideImage.classList.add("active");
      } else {
        slideImage.classList.remove("active");
      }
    });
  }

  window.addEventListener("scroll", debounce(checkSlide));

14 JavaScript Fundamentals: Reference VS Copy

這邊講「傳值」概念

  let age = 100;
  let age2 = age;
  console.log(age, age2); // 100 100
  age = 200;
  console.log(age, age2); // 200 100

  let name = "fang";
  let name2 = name;
  console.log(name, name2); // fang fang

  name = "Vicky";
  console.log(name, name2); // Vicky fang

這邊講「傳址」概念

const players = ["Wes", "Sarah", "Ryan", "Poppy"];

const team = players;
team[3] = "Peter";
console.log(team); // ["Wes","Sarah","Ryan","Peter"]
console.log(players); // ["Wes","Sarah","Ryan","Peter"]

複製array的方法(不會有傳參考)

  1. slice方法,shallow copy一個陣列,這樣就不會改到原陣列
  const players = ["Wes", "Sarah", "Ryan", "Poppy"];
  const result = players.slice();
  console.log(result); // ["Wes", "Sarah", "Ryan", "Poppy"]

  result[3] = "Vicky";
  console.log(result); // ["Wes", "Sarah", "Ryan", "Vicky"] 索引值3的值改成"Vicky"
  console.log(players); // ["Wes", "Sarah", "Ryan", "Poppy"] 值沒有因為result變動
  1. concat()
const players = ["Wes", "Sarah", "Ryan", "Poppy"];

const copyArray = [].concat(players);
console.log(copyArray); // ["Wes", "Sarah", "Ryan", "Poppy"]
  1. 展開運算子
const players = ["Wes", "Sarah", "Ryan", "Poppy"];

const result = [...players];
console.log(result); // ["Wes", "Sarah", "Ryan", "Poppy"]
  1. Array.from()
const players = ["Wes", "Sarah", "Ryan", "Poppy"];

const result3 = Array.from(players);
console.log(result); // ["Wes", "Sarah", "Ryan", "Poppy"]

物件也有傳址

  const person = {
    name: "Wes Bos",
    age: 80,
  };

  const answer = Object.assign({}, person, { address: "Tainan" });
  console.log(answer); // {name: 'Wes Bos', age: 80, address: 'Tainan'}
  console.log(person); // {name: 'Wes Bos', age: 80}

Object.assign 只能對第一層屬性shallow copy ,第二層還是會被修改

  const family = {
    parents: {
      dad: "David",
      mom: "Esther",
    },
    country: "Israel",
    familyNumber: 2,
  };

  // shallow copy
  const dev = Object.assign({}, family);
  dev.parents.mom = "Maria";
  console.log(family);
  console.log(dev);

image
如需要deep copy,須使用JSON.stringify先轉成字串,再用JSON.parse轉回物件,這樣修改第二層之後的內容都不會動到原物件

  const dev2 = JSON.parse(JSON.stringify(family));
  dev2.parents.dad = "Peter";
  console.log(family);
  console.log(dev2);
  • 使用JSON.stringify(family),回傳物件的字串
'{"parents":{"dad":"David","mom":"Maria"},"country":"Israel","familyNumber":2}'
  • 再使用JSON.parse(),把物件的字串解析,轉回成物件
    image

修改物件第二層資料:

dev2.parents.dad = "Peter";
console.log(family);
console.log(dev2);

family的資料不會被改動

image

15 How LocalStorage and Event Delegation work

<div class="wrapper">
      <h2>LOCAL TAPAS</h2>
      <p></p>
      <ul class="plates">
        <li>Loading Tapas...</li>
      </ul>
      <form class="add-items">
        <input type="text" name="item" placeholder="Item Name" required />
        <input type="submit" value="+ Add Item" />
      </form>
    </div>
  const addItems = document.querySelector(".add-items");
  const plates = document.querySelector(".plates");
  const itemList = JSON.parse(localStorage.getItem("food")) || [];
  populateList(itemList, plates);

 // 新增品項
  function addItem(event) {
    event.preventDefault();
    const inputText = this.querySelector("[name=item]").value;
    const item = {
      inputText,
      done: false,
    };
    itemList.push(item);
    populateList(itemList, plates);
    localStorage.setItem("food", JSON.stringify(itemList));
    this.reset(); // 表單回初始狀態
  }

Q1: document.querySelectordocument 是什麼?
document 是window物件之一,代表整個HTML文件(DOM)。

const addItems = document.querySelector(".add-items");

所以這段程式碼意思是,JS會從HTML文件中找尋class為 add-items 的元素並存入變數 addItems 。如有符合就回傳第一個找到的元素,如沒有則回傳null

當縮小到特定範圍了,就可以直接在該元素使用querySelector,不需要再用document.querySelector(),因為JS只會在該元素內尋找,而不是整個document,會更有效率。

例如:

const addItems = document.querySelector(".add-items");
const text = addItems.querySelector("input[name=item]"); // 確保抓取到<input>,可加上標籤名稱

// 寫法等同
document.querySelector(".add-items").querySelector("input[name=item]")

Q2: 為什麼不是監聽 input[type="text"] 按鈕就好?而是監聽整個 <form> 表單?

  • 監聽 <form>submit事件,確保觸發表單提交的方式都被攔截並處理,例如:輸入後可以直接按enter鍵提交。
  • 監聽 <form> 會比針對單一 input[type="submit"] 更靈活,例如:表單新增其他元素提交,就不需修改程式碼。

event delegation 事件委派

因為事件傳遞機制是先捕捉後冒泡,所以不管點擊任何li都會回到ul身上,所以把listener放在ul,透過父節點統一處理子節點的事件就是事件委派。

Huli - DOM 的事件傳遞機制:捕獲與冒泡

Q3: 這裡的this指向誰?

addItems.addEventListener("submit", function(e){
  console.log(this); // ?
  const text = this.querySelector("[name=item]").value; 
});
  • 當使用普通函式function時,this指向「觸發事件的元素」,就是addItems,也就是<form>,當提交表單時,addItem函式才會被執行。

測試輸入abc,並提交,this印出<form>內容

image

  • 使用箭頭函式,因為父層this是什麼,箭頭函式的this就是什麼
addItems.addEventListener("submit",(e)=>{
  console.log(this); // 這裡的`this`是指向`window`
});

Q4: 事件處理的參數(e / event)?
event物件(事件物件),當事件發生時,瀏覽器自動把event物件傳進事件處理函式,如不需使用到event物件,則可以省略不寫。

event.preventDefault()

為了阻止瀏覽器的預設行為。
瀏覽器默認情況下,當表單提交時會刷新頁面,或將數據發送到server端 ; 不是所有事件都有預設行為。

Event: preventDefault() method

function addItem(e){
  console.log(e);
}

查找 event 物件中的 preventDefault 方法,從SubmitEvent物件沿著原型在Event物件找到了~

SubmitEvent
Event

image
image

EventTarget

Element, and its children, as well as Document and Window, are the most common event targets, but other objects can be event targets, too.
最常見的eventTarget,例如:element和其子元素、document、window

EventTarget 的實例方法:

  • EventTarget.addEventListener()
  • EventTarget.removeEventListener()
  • EventTarget.dispatchEvent()
const addItems = document.querySelector(".add-items");

addItems.addEventListener("submit", function(e){
    console.log(this);
});

因為 document.querySelector(".add-items") 會回傳一個 HTMLElement,HTMLElement 繼承自 Element,Element 又繼承自 Node,Node 最終繼承自 EventTarget。所以平常使用document.querySelector(".add-items") 才可以使用 EventTarget的方法!
(addEventListener/removeEventListener/dispatchEvent)

reset()

將表單欄位初始狀態

物件縮寫

  • 屬性縮寫:屬性與變數名稱相同,可直接寫變數名稱
let name = "David";
let age = 18;

// 一般寫法
const person = {
    name:name,
    age:age
}

// 屬性縮寫
const person = {name,age}; // {name: 'David', age: 18}

試著輸入noodles和pizza並按新增,印出資料如下:

image

function addItem(){
  // ...  
  populateList(itemList, plates); 
}

// 渲染HTML
function populateList(itemList, plates) {
    plates.innerHTML = itemList
      .map((plate, index) => {
        return `
      <li>
        <input type="checkbox" id="item${index}" data-index=${index} ${
          plate.done ? "checked" : ""
        }/>
        <label for="item${index}">${plate.inputText}</label>
      </li>
      `;
      })
      .join("");
  }

物件done:false 是什麼作用?A:之後用來判斷checkbox是否checked

<input>裡面寫是否checked的判斷

${plate.done ? "checked" : ""}

Q5: 為什麼要寫三元判斷?A:用來判斷每個品項的狀態是否打勾,因為當重新渲染頁面時,有打勾的就維持。

渲染HTML出來後長這樣:

image

因為刷新頁面後,剛剛輸入的內容都不會保留,所以需要出動 localStorage 本地儲存。

localStorage

可將文本儲存在瀏覽器的儲存空間
MDN

  1. window裡面的物件

    image

  2. 在devTools的 application 可以查看

    image

  3. 只能儲存String,如果不是字串,會自己轉型,例如Object轉成"[Object Object]"。使用JSON.stringify把物件轉字串

The keys and the values stored with localStorage are always in the UTF-16 string format, which uses two bytes per character. As with objects, integer keys are automatically converted to strings.

  1. 語法
// key和value分別對應local Storage的key和value,可自訂

localStorage.setItem("key","value"); // 新增資料

localStorage.getItem("key"); // 讀取資料

localStorage.removeItem("key"); // 移除資料

const items = JSON.parse(localStorage.getItem("items")) || [];

function addItem(){
    // ...
localStorage.setItem("items", JSON.stringify(items));
}

populateList(items, itemsList);

image

刷新頁面,資料都不會消失(但會發現原本打勾的又沒打勾了)

image

Q6:JSON.parse(localStorage.getItem("food"))拿到不是物件嗎?這樣怎麼運作array methods?
A: JSON.parse(localStorage.getItem("food")) 解析出來的值會是一個 array !! (自己看錯XD)

截圖確認

image
image

頁面加載的時候,檢查const items = JSON.parse(localStorage.getItem("items")) || [];是否有東西,沒東西就給空陣列


最後因為頁面每次加載後,有 checked 的會變成沒打勾狀態,所以新增 toggleDone 函式處理。

event.target

因為每次點擊勾選時,都會同時兩個 pointerEvent 出現,印出來看是 <label><input type="checkbox">

image

image

所以用 event.target 方式,假如不是 "input" 的就跳過。所以確保選到<input>標籤。

運用加在 <input type="checkbox">data-index=${index},將itemList陣列的每一個元素的done做反向(true->false或 false->true)
完成後把結果儲存在localStorage,並將結果渲染至頁面。

// 切換勾選狀態
  function toggleDone(event) {
    if (!event.target.matches("input")) return;
    const el = event.target;
    const index = el.dataset.index;
    itemList[index].done = !itemList[index].done;
    localStorage.setItem("food", JSON.stringify(itemList));
    populateList(itemList, plates);
  }

target property

完整程式碼:

const addItems = document.querySelector(".add-items");
  const plates = document.querySelector(".plates");
  const itemList = JSON.parse(localStorage.getItem("food")) || [];
  populateList(itemList, plates);

  // 新增品項
  function addItem(event) {
    event.preventDefault();
    const inputText = this.querySelector("[name=item]").value;
    const item = {
      inputText,
      done: false,
    };
    itemList.push(item);
    populateList(itemList, plates);
    localStorage.setItem("food", JSON.stringify(itemList));
    this.reset();
  }

  // 渲染畫面
  function populateList(itemList, plates) {
    plates.innerHTML = itemList
      .map((plate, index) => {
        return `
      <li>
        <input type="checkbox" id="item${index}" data-index=${index} ${
          plate.done ? "checked" : ""
        }/>
        <label for="item${index}">${plate.inputText}</label>
      </li>
      `;
      })
      .join("");
  }

  // 切換勾選狀態
  function toggleDone(event) {
    if (!event.target.matches("input")) return;
    const el = event.target;
    const index = el.dataset.index;
    itemList[index].done = !itemList[index].done;
    localStorage.setItem("food", JSON.stringify(itemList));
    populateList(itemList, plates);
  }

  addItems.addEventListener("submit", addItem);
  plates.addEventListener("click", toggleDone);

16 CSS Text Shadow on Mouse Move Effect

offsetWidth / offsetHeight是元素實際顯示的寬度/高度
包含:width、padding、border,但不包含margin

  const hero = document.querySelector(".hero");
  const text = hero.querySelector("h1");

  function addShadow(e) {
    // div的實際顯示寬高
    const { offsetWidth: width, offsetHeight: height } = hero;

    // mousemove事件的offset
    let { offsetX: x, offsetY: y } = e;

    // 計算滑鼠在div的相對位置
    const xWalk = x / width;
    const yWalk = y / height;
    console.log(xWalk, yWalk);
  }

  hero.addEventListener("mousemove", addShadow);

橘色框是div,紅色框是h1

image

可以看到,當滑鼠滑到h1時,因為h1是div裡面的元素,offset會變成h1去計算,為了讓滑鼠移動不會因為裡面有元素而重新計算,判斷如下:

    if (this !== e.target) {
      x +=  e.target.offsetLeft;
      y +=  e.target.offsetTop;
    }

offsetLeft與offsetTop的理解圖:

image

補充:
clientX / clientY就可以避免元素不同的offset問題!不用再寫if判斷(灑花~~~)

function addShadow(e) {
 // div的實際顯示寬高
 const { offsetWidth: width, offsetHeight: height } = hero;

 const { clientX: currentX, clientY: currentY } = e;
  }

設定陰影的偏移量,作者設定100px,表示偏移量為 -50px~50px ,左上角為 (-50,-50),右下角為 (50,50)
用Math.round取四捨五入

 const xWalk = Math.round((currentX / width) * walk - walk / 2);
 const yWalk = Math.round((currentY / height) * walk - walk / 2);

最後加上text也就是h1的陰影樣式,可以加上多個,設定不同方向的陰影

text.style.textShadow = `
  ${xWalk}px ${yWalk}px 0 pink,
  ${xWalk * -1}px ${yWalk}px 0 aqua,
  ${xWalk}px ${yWalk * -1}px 0 gray
`;

image

17 Sorting Band Names without articles