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 คอลัมน์ใหม่ไว้ทางด้านขวาของคอลัมน์ก่อนหน้า 1 จนกว่าจะวางองค์ประกอบ 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 และ 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 เจ๋งๆ ที่เรียกว่า loading 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
}

Listener

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

หนู

มาเริ่มด้วยการฟังเหตุการณ์ '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 เป็นตำแหน่งที่เรากำลังใช้งาน เราจะ การสอบถามหาเพื่อน (ผู้ใช้) หรือเรื่องราว (เรื่องราว)

ตัวแปรเหล่านี้จะช่วยเราในการตอบคำถามต่างๆ เช่น "หากเรื่อง 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
    }
  }
}

ลองเลย

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

บทสรุป

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