Lớp học lập trình: Xây dựng thành phần Stories

Lớp học lập trình này hướng dẫn bạn cách tạo trải nghiệm như Instagram Stories trên web. Chúng ta sẽ xây dựng thành phần này trong quá trình thực hiện, bắt đầu với HTML, sau đó là CSS, rồi đến JavaScript.

Hãy xem bài đăng trên blog của tôi Tạo thành phần Stories để tìm hiểu về các điểm cải tiến liên tục được thực hiện trong quá trình tạo thành phần này.

Thiết lập

  1. Nhấp vào Remix to Edit (Trộn lại để chỉnh sửa) để có thể chỉnh sửa dự án.
  2. Mở app/index.html.

HTML

Tôi luôn hướng đến việc sử dụng HTML có ngữ nghĩa. Vì mỗi người bạn có thể có số lượng tin bài bất kỳ, nên tôi nghĩ rằng việc sử dụng phần tử <section> cho mỗi người bạn và phần tử <article> cho mỗi tin bài là phù hợp. Tuy nhiên, hãy bắt đầu từ đầu. Trước tiên, chúng ta cần một vùng chứa cho thành phần stories.

Thêm phần tử <div> vào <body>:

<div class="stories">

</div>

Thêm một số phần tử <section> để đại diện cho bạn bè:

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

Thêm một số phần tử <article> để đại diện cho các câu chuyện:

<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>
  • Chúng tôi đang sử dụng dịch vụ hình ảnh (picsum.com) để tạo nguyên mẫu cho các tin bài.
  • Thuộc tính style trên mỗi <article> là một phần của kỹ thuật tải phần giữ chỗ mà bạn sẽ tìm hiểu thêm trong phần tiếp theo.

CSS

Nội dung của chúng ta đã sẵn sàng để tạo kiểu. Hãy biến những bộ xương đó thành thứ mà mọi người muốn tương tác. Hôm nay, chúng ta sẽ ưu tiên thiết bị di động.

.stories

Đối với vùng chứa <div class="stories">, chúng ta muốn có một vùng chứa cuộn theo chiều ngang. Chúng ta có thể đạt được điều này bằng cách:

  • Đặt vùng chứa thành Lưới
  • Đặt mỗi thành phần con để lấp đầy kênh hàng
  • Đặt chiều rộng của mỗi thành phần con bằng chiều rộng của khung nhìn trên thiết bị di động

Lưới sẽ tiếp tục đặt các cột mới có chiều rộng 100vw ở bên phải cột trước đó cho đến khi đặt tất cả các phần tử HTML trong mã đánh dấu.

Chrome và DevTools mở ra với hình ảnh lưới hiển thị bố cục toàn chiều rộng
Công cụ của Chrome cho nhà phát triển hiển thị tình trạng tràn cột lưới, tạo một thanh cuộn theo chiều ngang.

Thêm CSS sau vào cuối app/css/index.css:

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

Giờ đây, khi nội dung mở rộng ra ngoài khung nhìn, đã đến lúc cho vùng chứa đó biết cách xử lý nội dung. Thêm các dòng mã được làm nổi bật vào quy tắc .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;
}

Chúng ta muốn cuộn theo chiều ngang, vì vậy, chúng ta sẽ đặt overflow-x thành auto. Khi người dùng cuộn, chúng ta muốn thành phần này nhẹ nhàng nằm trên câu chuyện tiếp theo, vì vậy, chúng ta sẽ sử dụng scroll-snap-type: x mandatory. Đọc thêm về CSS này trong phần CSS Scroll Snap Points (Điểm chụp nhanh cuộn CSS) và overscroll-behavior (hành vi cuộn quá mức) trong bài đăng trên blog của tôi.

Cả vùng chứa mẹ và vùng chứa con đều phải đồng ý với tính năng cuộn chụp nhanh, vì vậy, hãy xử lý vấn đề đó ngay bây giờ. Thêm mã sau vào cuối app/css/index.css:

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

Ứng dụng của bạn chưa hoạt động, nhưng video bên dưới cho thấy điều gì sẽ xảy ra khi bật và tắt scroll-snap-type. Khi được bật, mỗi lần cuộn theo chiều ngang sẽ chuyển sang tin bài tiếp theo. Khi bạn tắt tính năng này, trình duyệt sẽ sử dụng hành vi cuộn mặc định.

Thao tác này sẽ giúp bạn cuộn qua danh sách bạn bè, nhưng chúng ta vẫn còn một vấn đề cần giải quyết với các tin bài.

.user

Hãy tạo một bố cục trong phần .user để sắp xếp các phần tử câu chuyện con đó vào đúng vị trí. Chúng ta sẽ sử dụng một mẹo xếp chồng tiện lợi để giải quyết vấn đề này. Về cơ bản, chúng ta đang tạo một lưới 1x1, trong đó hàng và cột có cùng bí danh Lưới là [story] và mỗi mục trong lưới câu chuyện sẽ cố gắng và xác nhận không gian đó, dẫn đến một ngăn xếp.

Thêm mã được làm nổi bật vào quy tắc .user:

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

Thêm bộ quy tắc sau vào cuối app/css/index.css:

.story {
  grid-area: story;
}

Bây giờ, nếu không có vị trí tuyệt đối, phần nổi hoặc các lệnh bố cục khác đưa một phần tử ra khỏi luồng, chúng ta vẫn ở trong luồng. Ngoài ra, hầu như không có mã nào, hãy xem! Thông tin này được trình bày chi tiết hơn trong video và bài đăng trên blog.

.story

Bây giờ, chúng ta chỉ cần tạo kiểu cho chính mục story.

Trước đó, chúng ta đã đề cập rằng thuộc tính style trên mỗi phần tử <article> là một phần của kỹ thuật tải phần giữ chỗ:

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

Chúng ta sẽ sử dụng thuộc tính background-image của CSS để chỉ định nhiều hình nền. Chúng ta có thể sắp xếp các hình ảnh này theo thứ tự để hình ảnh người dùng nằm ở trên cùng và sẽ tự động xuất hiện khi tải xong. Để bật tính năng này, chúng ta sẽ đặt URL hình ảnh vào một thuộc tính tuỳ chỉnh (--bg) và sử dụng thuộc tính đó trong CSS để xếp lớp với phần giữ chỗ đang tải.

Trước tiên, hãy cập nhật quy tắc .story để thay thế hiệu ứng chuyển màu bằng hình nền sau khi tải xong. Thêm mã được làm nổi bật vào quy tắc .story:

.story {
  grid-area: story;

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

Việc đặt background-size thành cover đảm bảo không có khoảng trống trong khung nhìn vì hình ảnh của chúng ta sẽ lấp đầy khung nhìn đó. Việc xác định 2 hình nền cho phép chúng ta sử dụng một thủ thuật web CSS gọn gàng có tên là loading tombstone (hình ảnh tải):

  • Hình nền 1 (var(--bg)) là URL mà chúng ta đã truyền cùng dòng trong HTML
  • Hình nền 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) là một hiệu ứng chuyển màu để hiển thị trong khi URL đang tải

CSS sẽ tự động thay thế hiệu ứng chuyển màu bằng hình ảnh sau khi hình ảnh tải xuống xong.

Tiếp theo, chúng ta sẽ thêm một số CSS để xoá một số hành vi, giúp trình duyệt hoạt động nhanh hơn. Thêm mã được làm nổi bật vào quy tắc .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 ngăn người dùng vô tình chọn văn bản
  • touch-action: manipulation hướng dẫn trình duyệt rằng các lượt tương tác này nên được coi là sự kiện chạm, giúp trình duyệt không phải cố gắng quyết định xem bạn có đang nhấp vào URL hay không

Cuối cùng, hãy thêm một chút CSS để tạo hiệu ứng chuyển đổi giữa các câu chuyện. Thêm mã được làm nổi bật vào quy tắc .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;
  }
}

Lớp .seen sẽ được thêm vào một câu chuyện cần có lối thoát. Tôi đã lấy hàm làm dịu tuỳ chỉnh (cubic-bezier(0.4, 0.0, 1,1)) từ hướng dẫn Easing (Làm dịu) của Material Design (cuộn đến phần Accerlerated easing (Làm dịu tăng tốc)).

Nếu tinh ý, bạn có thể nhận thấy phần khai báo pointer-events: none và đang vò đầu bức tai. Tôi cho rằng đây là điểm yếu duy nhất của giải pháp này cho đến thời điểm hiện tại. Chúng ta cần điều này vì phần tử .seen.story sẽ ở trên cùng và sẽ nhận được các thao tác nhấn, mặc dù phần tử này không hiển thị. Bằng cách đặt pointer-events thành none, chúng ta sẽ chuyển story dạng kính thành một cửa sổ và không còn đánh cắp lượt tương tác của người dùng nữa. Không phải là một sự đánh đổi quá tệ, cũng không quá khó để quản lý tại đây trong CSS của chúng ta. Chúng ta không đang tung hứng z-index. Tôi vẫn cảm thấy hài lòng về điều này.

JavaScript

Người dùng có thể tương tác với thành phần Stories một cách khá đơn giản: nhấn vào bên phải để chuyển tiếp, nhấn vào bên trái để quay lại. Những việc đơn giản đối với người dùng thường là công việc khó khăn đối với nhà phát triển. Tuy nhiên, chúng tôi sẽ xử lý nhiều vấn đề trong số đó.

Thiết lập

Để bắt đầu, hãy tính toán và lưu trữ nhiều thông tin nhất có thể. Thêm mã sau vào app/js/index.js:

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

Dòng JavaScript đầu tiên của chúng ta sẽ lấy và lưu trữ tệp tham chiếu đến phần tử gốc HTML chính. Dòng tiếp theo tính toán vị trí giữa phần tử của chúng ta, nhờ đó chúng ta có thể quyết định một thao tác nhấn là để chuyển tiếp hay quay lại.

Tiểu bang

Tiếp theo, chúng ta tạo một đối tượng nhỏ có một số trạng thái liên quan đến logic của mình. Trong trường hợp này, chúng ta chỉ quan tâm đến câu chuyện hiện tại. Trong mã đánh dấu HTML, chúng ta có thể truy cập vào thông tin này bằng cách lấy người bạn thứ nhất và tin stories gần đây nhất của họ. Thêm mã được làm nổi bật vào app/js/index.js:

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

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

Trình nghe

Bây giờ, chúng ta đã có đủ logic để bắt đầu nghe và điều hướng các sự kiện của người dùng.

Chuột

Hãy bắt đầu bằng cách nghe sự kiện 'click' trên vùng chứa stories. Thêm mã được đánh dấu vào 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')
})

Nếu một lượt nhấp xảy ra và không phải trên phần tử <article>, chúng ta sẽ thoát và không làm gì cả. Nếu đó là một bài viết, chúng ta sẽ lấy vị trí ngang của chuột hoặc ngón tay bằng clientX. Chúng ta chưa triển khai navigateStories, nhưng đối số mà hàm này nhận được sẽ chỉ định hướng chúng ta cần đi. Nếu vị trí người dùng đó lớn hơn trung bình, chúng ta biết rằng chúng ta cần điều hướng đến next, nếu không thì là prev (trước đó).

Bàn phím

Bây giờ, hãy nghe các thao tác nhấn phím. Nếu nhấn Mũi tên xuống, chúng ta sẽ chuyển đến next. Nếu đó là Mũi tên lên, chúng ta sẽ chuyển đến prev.

Thêm mã được làm nổi bật vào 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')
})

Di chuyển trong phần Câu chuyện

Đã đến lúc giải quyết logic kinh doanh độc đáo của các câu chuyện và trải nghiệm người dùng mà chúng đã trở nên nổi tiếng. Mã này trông khá cồng kềnh và khó hiểu, nhưng tôi nghĩ nếu xem xét từng dòng, bạn sẽ thấy mã này khá dễ hiểu.

Trước tiên, chúng ta sẽ lưu trữ một số bộ chọn giúp quyết định xem có cuộn đến một người bạn hay hiển thị/ẩn một tin bài hay không. Vì HTML là nơi chúng ta đang làm việc, nên chúng ta sẽ truy vấn HTML để biết có bạn bè (người dùng) hoặc tin bài (story) hay không.

Các biến này sẽ giúp chúng ta trả lời các câu hỏi như "trong một story x nhất định, "tiếp theo" có nghĩa là chuyển sang một story khác của chính người bạn này hay của một người bạn khác không?" Tôi đã thực hiện việc này bằng cách sử dụng cấu trúc cây mà chúng ta đã tạo, truy cập vào các phần tử mẹ và phần tử con của chúng.

Thêm mã sau vào cuối 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
}

Dưới đây là mục tiêu logic nghiệp vụ của chúng ta, gần với ngôn ngữ tự nhiên nhất có thể:

  • Quyết định cách xử lý thao tác nhấn
    • Nếu có câu chuyện tiếp theo/trước đó: hiển thị câu chuyện đó
    • Nếu đó là tin stories gần đây nhất/đầu tiên của người bạn: hiển thị một người bạn mới
    • Nếu không có câu chuyện nào theo hướng đó: không làm gì cả
  • Lưu trữ câu chuyện hiện tại mới vào state

Thêm mã được làm nổi bật vào hàm 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
    }
  }
}

Dùng thử

  • Để xem trước trang web, hãy nhấn vào Xem ứng dụng. Sau đó, nhấn vào biểu tượng Màn hình toàn cảnh toàn màn hình.

Kết luận

Đó là phần tóm tắt về nhu cầu của tôi đối với thành phần này. Bạn có thể tuỳ ý xây dựng dựa trên đó, điều khiển bằng dữ liệu và nói chung là biến nó thành của riêng bạn!