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 một trải nghiệm như Câu chuyện trên Instagram trên web. Chúng ta sẽ tạo thành phần này trong quá trình thực hiện, bắt đầu bằng 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ề những điểm cải tiến gia tăng đượ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 Trộn để 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 có ngữ nghĩa. Vì mỗi người bạn có thể có nhiều tin, nên tôi nghĩ việc sử dụng một phần tử <section> cho mỗi người bạn và một phần tử <article> cho mỗi tin 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 tin của mình.

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

<div class="stories">

</div>

Thêm một số phần tử <section> để biểu thị 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> để biểu thị 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 một dịch vụ hình ảnh (picsum.com) để giúp tạo nguyên mẫu cho các câu chuyện.
  • 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 để 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ẽ làm việc theo hướng ư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:

  • Biến vùng chứa thành Lưới
  • Đặt mỗi thành phần con để lấp đầy hàng theo dõi
  • Đặt chiều rộng của mỗi thành phần con thành chiều rộng của khung hiển thị 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 của bạn.

Chrome và Công cụ cho nhà phát triển mở ra với một hình ảnh lưới cho thấy bố cục có chiều rộng đầy đủ
Công cụ của Chrome cho nhà phát triển cho thấy cột lưới bị tràn, tạo ra một thanh 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 vượt 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 đá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 di chuyển, chúng ta muốn thành phần này nằm nhẹ nhàng 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 các phần CSS Scroll Snap Points (Điểm chụp 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 căn chỉnh khi cuộn, vì vậy, hãy xử lý việc này 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 scroll-snap-type được bật và tắt. Khi bạn bật chế độ này, mỗi lần vuốt theo chiều ngang sẽ chuyển nhanh đến tin bài tiếp theo. Khi bạn tắt chế độ này, trình duyệt sẽ sử dụng hành vi cuộn mặc định.

Thao tác đó sẽ giúp bạn di chuyể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 phần tin.

.user

Hãy tạo một bố cục trong phần .user để sắp xếp các phần tử con của câu chuyện đó vào đúng vị trí. Chúng ta 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 lưới câu chuyện sẽ cố gắng yêu cầu 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:

.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, không cần định vị tuyệt đối, các thành phần nổi hoặc chỉ thị 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! Điều này sẽ được phân tích chi tiết hơn trong video và bài đăng trên blog.

.story

Giờ đây, chúng ta chỉ cần tạo kiểu cho chính mục 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 ta sẽ sử dụng thuộc tính background-image của CSS. Thuộc tính này cho phép chúng ta chỉ định nhiều hình nền. Chúng ta có thể sắp xếp chúng theo thứ tự sao cho ảnh người dùng ở trên cùng và sẽ tự động xuất hiện khi quá trình tải hoàn tất. Để 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ỗ tải.

Trước tiên, hãy cập nhật bộ quy tắc .story để thay thế một chuyển màu bằng hình nền sau khi quá trình tải hoàn tất. 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 đảm bảo không có khoảng trống trong khung hiển thị vì hình ảnh của chúng ta sẽ lấp đầy khoảng trống đó. Việc xác định 2 hình nền cho phép chúng ta sử dụng một thủ thuật CSS gọn gàng trên web, được gọi là bia mộ tải:

  • Hình nền 1 (var(--bg)) là URL mà chúng tôi đã truyền nội tuyến 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 sẽ xuất hiện trong khi URL đang tải

CSS sẽ tự động thay thế hình ảnh chuyển màu bằng hình ảnh sau khi quá trình tải xuống hình ảnh hoàn tất.

Tiếp theo, chúng ta sẽ thêm một số CSS để xoá một số hành vi, giải phóng trình duyệt để di chuyển nhanh hơ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;
}
  • 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 những lượt tương tác này phải đượ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 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 tin. 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;

  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 chậm tuỳ chỉnh (cubic-bezier(0.4, 0.0, 1,1)) từ hướng dẫn Làm chậm của Material Design (hãy di chuyển đến phần Làm chậm tăng tốc).

Nếu có con mắt tinh tường, có lẽ bạn đã nhận thấy khai báo pointer-events: none và đang gãi đầu ngay bây giờ. Tôi cho 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 thao tác nhấn, ngay cả khi phần tử đó không hiển thị. Bằng cách đặt pointer-events thành none, chúng ta sẽ biến câu chuyện trên kính thành một cửa sổ và không còn lấy đi các lượt tương tác của người dùng nữa. Không quá tệ, không quá khó để quản lý ở đây trong CSS của chúng tôi ngay bây giờ. Chúng tôi không 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 điều đơ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ữ một tham chiếu đến gốc phần tử HTML chính. Dòng tiếp theo tính toán vị trí ở giữa phần tử, để chúng ta có thể quyết định xem 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ó 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 phần tử này bằng cách lấy người bạn thứ nhất và câu chuyện gần đây nhất của họ. Thêm mã được đánh dấu 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
}

Trình xử lý

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

Chuột

Hãy bắt đầu bằng cách lắng nghe sự kiện 'click' trên vùng chứa tin của chúng ta. 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 một 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à nó nhận sẽ chỉ định hướng đi cần thiết. Nếu vị trí của người dùng đó lớn hơn giá trị trung vị, chúng ta biết rằng mình cần chuyển đến next, nếu không thì chuyển đến prev (trước đó).

Bàn phím

Bây giờ, hãy theo dõi các lần 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')
})

Cách di chuyển trong phần Câu chuyện

Đã đến lúc giải quyết logic kinh doanh riêng biệt của tin và trải nghiệm người dùng mà chúng đã trở nên nổi tiếng. Đoạn mã này có vẻ dài và phức tạp, nhưng tôi nghĩ nếu xem từng dòng, bạn sẽ thấy nó khá dễ hiểu.

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

Những biến số này sẽ giúp chúng tôi trả lời các câu hỏi như "với tin x, liệu "tiếp theo" có nghĩa là chuyển sang một tin khác của cùng một người bạn này hay chuyển sang một người bạn khác?" Tôi đã làm được điều đó bằng cách sử dụng cấu trúc cây mà chúng tôi đã xây dựng, tiếp cận các bậc cha mẹ và con cái 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
}

Sau đây là mục tiêu logic nghiệp vụ 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à tin/câu chuyện cuối cùng/đầu tiên của người bạn: cho thấy một người bạn mới
    • Nếu không có tin nào để chuyển đến theo hướng đó: không làm gì cả
  • Lưu 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, rồi nhấn vào Toàn màn hình toàn màn hình.

Kết luận

Đó là phần kết thúc cho những nhu cầu của tôi đối với thành phần này. Bạn có thể tuỳ ý phát triển, điều chỉnh theo dữ liệu và nói chung là biến nó thành của riêng bạn!