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 (Tin trên Instagram) trên web. Chúng ta sẽ tạo thành phần trong quá trình tạo, bắt đầu bằng HTML, sau đó đến CSS, sau đó là JavaScript.

Hãy xem bài đăng Xây dựng thành phần Stories trên blog của tôi để tìm hiểu về các điểm cải tiến tiến bộ được thực hiện khi xây dựng thành phần này.

Thiết lập

  1. Nhấp vào Remix để chỉnh sửa (Remix) để 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 câu chuyện bất kỳ, nên tôi nghĩ sẽ có ý nghĩa khi sử dụng phần tử <section> cho mỗi người bạn và một phần tử <article> cho mỗi câu chuyện. Hãy bắt đầu lại từ đầu. Trước tiên, chúng ta cần một vùng chứa cho thành phần câu chuyện.

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 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) để giúp tạo câu chuyện mẫu.
  • 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ỗ. Bạn sẽ tìm hiểu thêm về kỹ thuật này 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. Hãy biến những xương đó thành nội dung mà mọi người sẽ muốn tương tác. Hôm nay, chúng ta sẽ ưu tiên làm việc trên thiết bị di động.

.stories

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

  • Tạo vùng chứa thành Lưới
  • Thiết lập để từng trẻ điền vào theo dõi 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 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ục đánh dấu.

Chrome và Công cụ cho nhà phát triển mở với hình ảnh lưới cho thấy bố cục toàn bộ 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 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 chúng ta đã có nội dung vượt ra khỏ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 đánh dấu vào bộ 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 nghỉ ngơi trong 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 các phần CSS Scroll Snap Pointshành vi cuộn qua trong bài đăng trên blog của tôi.

Cả vùng chứa mẹ và thành phần con đều phải đồng ý với thao tác cuộn chụp nhanh, vì vậy hãy xử lý việc đó 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 dưới đây cho thấy những gì sẽ xảy ra khi scroll-snap-type được bật và tắt. Khi được bật, mỗi thanh cuộn theo chiều ngang sẽ chuyển đến câu chuyện tiếp theo. Khi bạn tắt, trình duyệt sẽ sử dụng hành vi cuộn mặc định.

Như vậy, bạn sẽ có thể lướt xem bạn bè, nhưng vẫn còn một vấn đề với những câu chuyện cần giải quyết.

.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 con đó. Chúng ta sẽ dùng một thủ thuật xếp chồng hữu ích để giải quyết vấn đề này. Về cơ bản, chúng ta sẽ 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ẽ thử và xác nhận không gian đó, dẫn đến một ngăn xếp.

Thêm mã được đánh dấu vào bộ quy tắc .user của bạn:

.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;
}

Giờ đây, dù không có vị trí tuyệt đối, số thực có độ chính xác đơn hoặc các lệnh bố cục khác làm mất một phần tử ngoài luồng, chúng ta vẫn đang hoạt động. Thêm vào đó, gần như không có mã nào, hãy nhìn vào đó đi! Điều này được phân tích 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 mục trong câu chuyện.

Trước đó, chúng tôi đã đề 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 tôi sẽ sử dụng thuộc tính background-image của CSS, cho phép chúng ta chỉ định nhiều hình nền. Chúng ta có thể sắp xếp các biểu tượng này theo thứ tự sao cho ảnh người dùng ở 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 URL đó trong CSS để tạo lớp cùng với phần giữ chỗ tải.

Trước tiên, hãy cập nhật bộ 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 của bạn:

.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ó không gian trống trong khung nhìn vì hình ảnh của chúng ta sẽ lấp đầy hình ảnh. Khi xác định 2 hình nền, chúng ta có thể kéo một thủ thuật web CSS gọn gàng có tên là tải tombstone:

  • Hình nền 1 (var(--bg)) là URL chúng tôi đã chuyể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à hiệu ứng chuyển màu để 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 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 đánh dấu vào bộ quy tắc .story của bạn:

.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 giúp 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 hoạt động tương tác này phải được coi là sự kiện chạm, việc này sẽ giúp trình duyệt không phải quyết định xem bạn có đang nhấp vào một 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 đá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));

  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 Gia tốc của Material Design (cuộn đến phần Tăng tốc độ tăng tốc).

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

JavaScript

Cách tương tác của một thành phần Stories khá đơn giản với người dùng: nhấn vào bên phải để chuyển đến, nhấn vào bên trái để quay lại. Những thứ đơ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. Dù vậy, chúng tôi sẽ xử lý rất nhiều phần việc này.

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 tôi lấy và lưu trữ tệp tham chiếu đến gốc của phần tử HTML chính. Dòng tiếp theo tính toán vị trí giữa của phần tử, vì vậy, chúng ta có thể quyết định xem một lần nhấn sẽ tiến hay lùi.

Tiểu bang

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

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

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

Người nghe

Hiện chúng ta có đủ logic để bắt đầu theo dõi các sự kiện của người dùng và hướng dẫn họ.

Chuột

Hãy bắt đầu bằng cách theo dõi sự kiện 'click' trên vùng chứa story của chúng ta. Thêm mã đã đá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 nhưng lượt nhấp đó không nằm trên phần tử <article>, thì chúng tôi sẽ bảo lãnh và không làm gì cả. Nếu đó là một bài viết, chúng ta sẽ lấy vị trí theo chiều ngang của chuột hoặc ngón tay bằng clientX. Chúng tôi chưa triển khai navigateStories, nhưng đối số mà đối số này xác định hướng chúng ta cần đi. Nếu vị trí người dùng đó lớn hơn trung vị, chúng ta sẽ biết mình cần chuyển đến next, nếu không thì prev (trước đó).

Bàn phím

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

Thêm mã đã đá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')
})

Điều hướng câu chuyện

Đã đến lúc xử lý logic kinh doanh độc đáo của những câu chuyện và trải nghiệm người dùng mà những câu chuyện này đã trở nên nổi tiếng. Câu này có vẻ rườm rà và phức tạp, nhưng tôi cho rằng nếu bạn đọc từng dòng một thì bạn sẽ thấy nội dung này khá dễ hiểu.

Ở phía trước, chúng tôi lưu trữ một số bộ chọn giúp chúng tôi quyết định cuộn đến một người bạn hay hiển thị/ẩn tin bài. Vì HTML là nơi chúng ta đang xử lý, nên chúng ta sẽ truy vấn HTML này xem có sự hiện diện của bạn bè (người dùng) hoặc những câu chuyện (câu chuyện) hay không.

Những biến này sẽ giúp chúng ta trả lời những câu hỏi như "cho câu chuyện x, "tiếp theo" có nghĩa là chuyển sang một câu chuyện khác từ người bạn này hay người bạn khác?" Tôi đã làm điều này bằng cách sử dụng cấu trúc cây chúng tôi đã xây dựng, tiếp cận các bậc cha mẹ và con của họ.

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 kinh doanh của chúng ta, càng gần với ngôn ngữ tự nhiên càng tốt:

  • 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à câu chuyện cuối cùng/câu chuyện đầu tiên của người bạn đó: hãy cho một người bạn mới xem
    • Nếu không có câu chuyện nào để đi theo hướng đó: không làm gì cả
  • Lưu trữ truyện mới hiện tạ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 View App (Xem ứng dụng), sau đó nhấn vào Fullscreen toàn màn hình (Toàn màn hình).

Kết luận

Đó là thông tin tóm tắt về những nhu cầu tôi có đối với thành phần này. Hãy thoải mái xây dựng dựa trên nó, lái xe với dữ liệu và nhìn chung, biến nó thành của bạn!