Codelab: การสร้างคอมโพเนนต์เรื่องราว

Codelab นี้จะสอนวิธีสร้างประสบการณ์การใช้งานแบบ Instagram Stories บนเว็บ เราจะสร้างคอมโพเนนต์ไปเรื่อยๆ โดยเริ่มจาก HTML, CSS แล้วตามด้วย JavaScript

ดูบล็อกโพสต์การสร้างคอมโพเนนต์เรื่องราวเพื่อดูข้อมูลเกี่ยวกับการเพิ่มประสิทธิภาพแบบต่อเนื่องที่ทำขณะสร้างคอมโพเนนต์นี้

ตั้งค่า

  1. คลิกรีมิกซ์เพื่อแก้ไขเพื่อให้โปรเจ็กต์แก้ไขได้
  2. เปิด app/index.html

HTML

ฉันพยายามใช้ HTML เชิงความหมายเสมอ เนื่องจากเพื่อนแต่ละคนมีเรื่องราวได้หลายเรื่อง ฉันจึงคิดว่าการใช้องค์ประกอบ <section> สำหรับเพื่อนแต่ละคนและองค์ประกอบ <article> สำหรับเรื่องราวแต่ละเรื่องนั้นเหมาะสมดี แต่เรามาเริ่มกันตั้งแต่ต้นเลยดีกว่า ก่อนอื่น เราต้องมีคอนเทนเนอร์สำหรับคอมโพเนนต์เรื่องราว

เพิ่มองค์ประกอบ <div> ลงใน <body> โดยทำดังนี้

<div class="stories">

</div>

เพิ่มองค์ประกอบ <section> เพื่อแสดงถึงเพื่อนๆ ดังนี้

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

เพิ่มองค์ประกอบ <article> บางส่วนเพื่อแสดงเรื่องราว ดังนี้

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • เราใช้บริการรูปภาพ (picsum.com) เพื่อช่วยสร้างต้นแบบเรื่องราว
  • แอตทริบิวต์ style ใน <article> แต่ละรายการเป็นส่วนหนึ่งของเทคนิคการโหลดตัวยึดตำแหน่ง ซึ่งคุณจะดูข้อมูลเพิ่มเติมได้ในส่วนถัดไป

CSS

เนื้อหาของเราพร้อมสำหรับการจัดรูปแบบแล้ว มาเปลี่ยนโครงร่างเหล่านั้นให้เป็นสิ่งที่ผู้คนอยากโต้ตอบด้วยกัน วันนี้เราจะมาพูดถึงการทำงานที่เน้นอุปกรณ์เคลื่อนที่เป็นหลัก

.stories

สำหรับคอนเทนเนอร์ <div class="stories"> เราต้องการคอนเทนเนอร์ที่เลื่อนในแนวนอน เราบรรลุเป้าหมายนี้ได้โดย

  • การทำให้คอนเทนเนอร์เป็นตารางกริด
  • การตั้งค่าองค์ประกอบย่อยแต่ละรายการให้เติมเต็มแทร็กแถว
  • การกำหนดความกว้างขององค์ประกอบย่อยแต่ละรายการให้เท่ากับความกว้างของวิวพอร์ตของอุปกรณ์เคลื่อนที่

ตารางกริดจะวางคอลัมน์ใหม่ที่มีความกว้าง 100vw ไว้ทางด้านขวาของคอลัมน์ก่อนหน้าต่อไปจนกว่าจะวางองค์ประกอบ HTML ทั้งหมดในมาร์กอัป

Chrome และเครื่องมือสำหรับนักพัฒนาเว็บเปิดขึ้นพร้อมภาพตารางกริดที่แสดงเลย์เอาต์แบบเต็มความกว้าง
เครื่องมือสำหรับนักพัฒนาเว็บใน Chrome แสดงคอลัมน์ตารางกริดที่ล้นออกมา ทำให้เกิดแถบเลื่อนแนวนอน

เพิ่ม CSS ต่อไปนี้ที่ด้านล่างของ app/css/index.css

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

เมื่อเรามีเนื้อหาที่ขยายออกไปนอกวิวพอร์ตแล้ว ก็ถึงเวลาบอกคอนเทนเนอร์นั้นว่าจะจัดการกับเนื้อหาอย่างไร เพิ่มบรรทัดโค้ดที่ไฮไลต์ลงในกฎ .stories ดังนี้

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

เราต้องการการเลื่อนในแนวนอน จึงจะตั้งค่า overflow-x เป็น auto เมื่อผู้ใช้เลื่อน เราต้องการให้คอมโพเนนต์หยุดที่เรื่องราวถัดไปอย่างนุ่มนวล ดังนั้นเราจะใช้ scroll-snap-type: x mandatory อ่านเพิ่มเติมเกี่ยวกับ CSS นี้ได้ในส่วน CSS Scroll Snap Points และ overscroll-behavior ของบล็อกโพสต์ของฉัน

ทั้งคอนเทนเนอร์หลักและคอนเทนเนอร์ย่อยต้องยอมรับการสแนปการเลื่อน ดังนั้นเรามาจัดการเรื่องนี้กันเลย เพิ่มโค้ดต่อไปนี้ที่ด้านล่างของ app/css/index.css

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

แอปของคุณยังใช้งานไม่ได้ แต่วิดีโอด้านล่างแสดงให้เห็นว่าจะเกิดอะไรขึ้นเมื่อเปิดใช้และปิดใช้ scroll-snap-type เมื่อเปิดใช้ การเลื่อนแนวนอนแต่ละครั้งจะสแนปไปยังเรื่องราวถัดไป เมื่อปิดใช้ เบราว์เซอร์จะใช้ลักษณะการเลื่อนเริ่มต้น

ซึ่งจะช่วยให้คุณเลื่อนดูเพื่อนๆ ได้ แต่เรายังคงมีปัญหาเกี่ยวกับเรื่องราวที่ต้องแก้ไข

.user

มาสร้างเลย์เอาต์ในส่วน .user ที่จัดระเบียบองค์ประกอบเรื่องราวของบุตรหลานเหล่านั้นให้เข้าที่กัน เราจะใช้เคล็ดลับการซ้อนที่สะดวกเพื่อแก้ปัญหานี้ โดยพื้นฐานแล้ว เรากำลังสร้างตารางกริดขนาด 1x1 ที่แถวและคอลัมน์มีชื่อแทนตารางกริดเดียวกันคือ [story] และรายการตารางกริดของเรื่องราวแต่ละรายการจะพยายามอ้างสิทธิ์พื้นที่นั้น ส่งผลให้เกิดกองซ้อน

เพิ่มโค้ดที่ไฮไลต์ลงในกฎ .user

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

เพิ่มชุดกฎต่อไปนี้ที่ด้านล่างของ app/css/index.css

.story {
  grid-area: story;
}

ตอนนี้ หากไม่มีการวางตำแหน่งแบบสัมบูรณ์ การลอย หรือคำสั่งเลย์เอาต์อื่นๆ ที่นำองค์ประกอบออกจากโฟลว์ เราจะยังคงอยู่ในโฟลว์ แถมยังแทบไม่ต้องเขียนโค้ดเลย ดูสิ เราจะอธิบายเรื่องนี้อย่างละเอียดในวิดีโอและบล็อกโพสต์

.story

ตอนนี้เราแค่ต้องจัดสไตล์รายการเรื่องราวเอง

ก่อนหน้านี้เราได้กล่าวถึงแอตทริบิวต์ style ในองค์ประกอบ <article> แต่ละรายการว่าเป็นส่วนหนึ่งของเทคนิคการโหลดตัวยึดตำแหน่ง

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

เราจะใช้พร็อพเพอร์ตี้ background-image ของ CSS ซึ่งช่วยให้เราระบุภาพพื้นหลังได้มากกว่า 1 ภาพ เราสามารถจัดลำดับให้รูปภาพของผู้ใช้อยู่ด้านบนและจะแสดงโดยอัตโนมัติเมื่อโหลดเสร็จ หากต้องการเปิดใช้ฟีเจอร์นี้ เราจะใส่ URL รูปภาพลงในคุณสมบัติที่กำหนดเอง (--bg) และใช้ภายใน CSS เพื่อวางเลเยอร์กับตัวยึดตำแหน่งการโหลด

ก่อนอื่น มาอัปเดตชุดกฎ .story เพื่อแทนที่การไล่ระดับสีด้วยภาพพื้นหลังเมื่อโหลดเสร็จแล้ว เพิ่มโค้ดที่ไฮไลต์ลงในกฎ .story

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

การตั้งค่า background-size เป็น cover ช่วยให้มั่นใจได้ว่าจะไม่มีพื้นที่ว่างในวิวพอร์ตเนื่องจากรูปภาพของเราจะเติมเต็มพื้นที่ดังกล่าว การกำหนดภาพพื้นหลัง 2 ภาพช่วยให้เราใช้เทคนิค CSS บนเว็บที่เรียกว่าการโหลด Tombstone ได้

  • ภาพพื้นหลัง 1 (var(--bg)) คือ URL ที่เราส่งแบบอินไลน์ใน HTML
  • ภาพพื้นหลัง 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) เป็นการไล่ระดับสีเพื่อแสดงขณะที่ URL กำลังโหลด

CSS จะแทนที่การไล่ระดับสีด้วยรูปภาพโดยอัตโนมัติเมื่อดาวน์โหลดรูปภาพเสร็จแล้ว

ต่อไปเราจะเพิ่ม CSS เพื่อนำลักษณะการทำงานบางอย่างออก ซึ่งจะช่วยให้เบราว์เซอร์ทำงานได้เร็วขึ้น เพิ่มโค้ดที่ไฮไลต์ลงในกฎ .story

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • user-select: none ป้องกันไม่ให้ผู้ใช้เลือกข้อความโดยไม่ตั้งใจ
  • touch-action: manipulation จะบอกเบราว์เซอร์ว่าการโต้ตอบเหล่านี้ควรได้รับการปฏิบัติเหมือนเป็นการโต้ตอบแบบสัมผัส ซึ่งจะช่วยให้เบราว์เซอร์ไม่ต้องพยายามตัดสินใจว่าคุณกำลังคลิก URL หรือไม่

สุดท้าย มาเพิ่ม CSS เล็กน้อยเพื่อทำให้การเปลี่ยนระหว่างเรื่องราวเคลื่อนไหว เพิ่มโค้ดที่ไฮไลต์ลงในกฎ .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

ระบบจะเพิ่มคลาส .seen ลงในเรื่องราวที่ต้องมีทางออก ฉันได้ฟังก์ชันการค่อยๆ เปลี่ยนที่กำหนดเอง (cubic-bezier(0.4, 0.0, 1,1)) จากคู่มือการค่อยๆ เปลี่ยนของ Material Design (เลื่อนไปที่ส่วนการค่อยๆ เปลี่ยนแบบเร่ง)

หากคุณเป็นคนช่างสังเกต คุณอาจสังเกตเห็นการประกาศ pointer-events: none และกำลังสงสัยอยู่ตอนนี้ ฉันคิดว่านี่เป็นข้อเสียเพียงอย่างเดียว ของโซลูชันนี้จนถึงตอนนี้ เราต้องการข้อมูลนี้เนื่องจากองค์ประกอบ .seen.story จะอยู่ด้านบนและได้รับการแตะ แม้ว่าจะมองไม่เห็นก็ตาม การตั้งค่า pointer-events เป็น none จะเปลี่ยนเรื่องราวแบบกระจกให้เป็นหน้าต่าง และไม่ขโมยการโต้ตอบของผู้ใช้อีกต่อไป ซึ่งก็ถือว่าคุ้มค่าและจัดการได้ไม่ยากนักใน CSS ของเราตอนนี้ เราไม่ได้กำลังเล่นกล z-index ฉันยังรู้สึกดีกับเรื่องนี้อยู่

JavaScript

การโต้ตอบของคอมโพเนนต์เรื่องราวนั้นค่อนข้างง่ายสำหรับผู้ใช้ โดยแตะทางขวาเพื่อไปข้างหน้า แตะทางซ้ายเพื่อย้อนกลับ สิ่งที่ดูง่ายๆ สำหรับผู้ใช้ มักเป็นงานยากสำหรับนักพัฒนาซอฟต์แวร์ แต่เราจะจัดการเรื่องต่างๆ ให้คุณเอง

ตั้งค่า

ก่อนอื่น เรามาคำนวณและจัดเก็บข้อมูลให้มากที่สุดเท่าที่จะทำได้ เพิ่มโค้ดต่อไปนี้ลงใน app/js/index.js

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

JavaScript บรรทัดแรกจะดึงและจัดเก็บข้อมูลอ้างอิงไปยังรูทองค์ประกอบ HTML หลัก บรรทัดถัดไปจะคำนวณว่าจุดกึ่งกลางขององค์ประกอบอยู่ที่ใด เพื่อให้เราตัดสินใจได้ว่าการแตะจะไปข้างหน้าหรือย้อนกลับ

รัฐ

ถัดไป เราจะสร้างออบเจ็กต์ขนาดเล็กที่มีสถานะบางอย่างที่เกี่ยวข้องกับตรรกะของเรา ในกรณีนี้ เราสนใจเฉพาะเรื่องราวปัจจุบันเท่านั้น ในมาร์กอัป HTML เราสามารถเข้าถึงข้อมูลนี้ได้โดยการดึงข้อมูลเพื่อนคนแรกและเรื่องราวล่าสุดของเพื่อนคนนั้น เพิ่มโค้ดที่ไฮไลต์ลงใน app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

Listeners

ตอนนี้เรามีตรรกะเพียงพอที่จะเริ่มฟังเหตุการณ์ของผู้ใช้และกำหนดทิศทางแล้ว

เมาส์

มาเริ่มด้วยการฟังเหตุการณ์ 'click' ในคอนเทนเนอร์เรื่องราวของเรา เพิ่มโค้ดที่ไฮไลต์ลงใน app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

หากมีการคลิกเกิดขึ้นและไม่ได้คลิกองค์ประกอบ <article> เราจะยกเลิกและไม่ดำเนินการใดๆ หากเป็นบทความ เราจะจับตำแหน่งแนวนอนของเมาส์หรือนิ้วด้วย clientX เรายังไม่ได้ใช้ navigateStories แต่อาร์กิวเมนต์ที่ใช้จะระบุทิศทางที่เราต้องไป หากตำแหน่งของผู้ใช้มากกว่าค่ามัธยฐาน เราจะรู้ว่าต้องไปยัง next ไม่เช่นนั้นก็ไปยัง prev (ก่อนหน้า)

แป้นพิมพ์

ตอนนี้เรามาฟังการกดแป้นพิมพ์กัน หากกดลูกศรลง เราจะไปยัง next หากเป็นลูกศรขึ้น เราจะไปที่ prev

เพิ่มโค้ดที่ไฮไลต์ลงใน app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

การไปยังส่วนต่างๆ ของเรื่องราว

ถึงเวลาจัดการกับตรรกะทางธุรกิจที่ไม่เหมือนใครของเรื่องราวและ UX ที่ทำให้เรื่องราวเหล่านั้นโด่งดัง โค้ดนี้ดูยาวและซับซ้อน แต่เราคิดว่าหากคุณดูทีละบรรทัด คุณจะพบว่าโค้ดนี้เข้าใจได้ง่ายทีเดียว

เราจะเก็บตัวเลือกบางอย่างไว้ล่วงหน้าเพื่อช่วยตัดสินใจว่าจะเลื่อนไปหาเพื่อนหรือแสดง/ซ่อนเรื่องราว เนื่องจากเรากำลังทำงานใน HTML เราจะค้นหา HTML เพื่อดูว่ามีเพื่อน (ผู้ใช้) หรือเรื่องราว (เรื่องราว) หรือไม่

ตัวแปรเหล่านี้จะช่วยเราตอบคำถามต่างๆ เช่น "เมื่อมีเรื่องราว x คำว่า "ถัดไป" หมายถึงการย้ายไปยังเรื่องราวอื่นจากเพื่อนคนเดียวกันนี้หรือเพื่อนคนอื่น" ฉันทำได้โดยใช้โครงสร้างแบบต้นไม้ที่เราสร้างขึ้น ซึ่งเข้าถึงผู้ปกครองและบุตรหลาน

เพิ่มโค้ดต่อไปนี้ที่ด้านล่างของ app/js/index.js

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

เป้าหมายตรรกะทางธุรกิจของเรามีดังนี้ ซึ่งใกล้เคียงกับภาษาธรรมชาติมากที่สุด

  • ตัดสินใจว่าจะจัดการกับการแตะอย่างไร
    • หากมีเรื่องราวถัดไป/ก่อนหน้า ให้แสดงเรื่องราวนั้น
    • หากเป็นเรื่องราวสุดท้าย/แรกของเพื่อน: แสดงเพื่อนใหม่
    • หากไม่มีเรื่องราวให้ไปต่อในทิศทางนั้น: ไม่ต้องทำอะไร
  • เก็บเรื่องราวปัจจุบันใหม่ไว้ใน state

เพิ่มโค้ดที่ไฮไลต์ลงในฟังก์ชัน navigateStories ดังนี้

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

ลองเลย

  • หากต้องการดูตัวอย่างเว็บไซต์ ให้กดดูแอป แล้วกดเต็มหน้าจอ เต็มหน้าจอ

บทสรุป

สรุปความต้องการที่ฉันมีเกี่ยวกับคอมโพเนนต์ คุณสามารถนำไปต่อยอด ขับเคลื่อนด้วยข้อมูล และปรับให้เหมาะกับคุณได้เลย