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 xây dựng một trải nghiệm như Instagram Stories trên web. Chúng ta sẽ tạo thành phần trong quá trình bắt đầu, 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 cố gắng sử dụng HTML 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à hợp lý. 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> để thể hiện tin bài:

<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 tôi đã sẵn sàng để thể hiện phong cách riêng. 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
  • Làm cho chiều rộng của mỗi trẻ làm 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 rộng 100vw ở bên phải cột trước cho đến khi đặt tất cả các phần tử HTML vào mã đánh dấu của bạn.

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ị tràn cột lưới, tạo thành một trình cuộn 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 chúng ta có 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ý. 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 thao tác cuộn ngang sẽ chuyển đến 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 thành phần của câu chuyện trẻ em 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! Phần này sẽ đượ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, cho phép chỉ định nhiều hình nền. Chúng ta có thể sắp xếp chúng theo thứ tự để hình ảnh người dùng ở trên cùng và sẽ tự động xuất hiện khi tải xong. Để làm điều này, chúng tôi sẽ đặt URL hình ảnh vào thuộc tính tùy chỉnh (--bg) và sử dụng URL đó trong CSS để phân lớp với phần giữ chỗ 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 đánh dấu vào bộ 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 sẽ đảm bảo không có khoảng trống nào 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 dải chuyển màu sẽ hiển thị 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 thoát. Tôi đã nhận được hàm gia tốc tuỳ chỉnh (cubic-bezier(0.4, 0.0, 1,1)) trong hướng dẫn Easing của Material Design (cuộn đến phần Tăng tốc theo tỷ lệ).

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à nhược điểm duy nhất của giải pháp từ trước đến nay. 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 tôi không kết hợp z-index. Tôi vẫn cảm thấy hài lòng về điều này.

JavaScript

Tính năng tương tác của thành phần Stories khá đơn giản đối với người dùng: nhấn vào bên phải để chuyển sang phần tiếp theo, 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 lại thường là công việc khó khăn đối với nhà phát triển. Nhưng chúng tôi sẽ xử lý rất nhiều việc.

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ữ một 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 chúng ta. 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 bài 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 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 mình 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 thao tác nhấn bà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 đá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')
})

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

Khám phá câu chuyện

Đã đến lúc xử lý 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 tôi lưu trữ một số bộ chọn giúp chúng tôi quyết định xem sẽ cuộn đến một người bạn hay hiển thị/ẩn một tin bài. 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ư "với story x, "tiếp theo" có nghĩa là chuyển sang story khác của cùng một người bạn hay chuyển sang story khác của một người bạn khác?" 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ó tin bài tiếp theo/trước: hiển thị tin bài đó
    • 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 đánh dấu 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à bản tóm tắt cho nhu cầu của tô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!