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 tạo, bắt đầu với HTML, sau đó là CSS, rồi đến 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 tính năng nâng cao tăng dần trong khi xây dựng thành phần này.

Thiết lập

  1. Nhấp vào Phối 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 câu chuyện bất kỳ, nên tôi nghĩ việc sử dụng Phần tử <section> cho mỗi người bạn và phần tử <article> cho mỗi câu chuyện. Tuy nhiê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 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> để 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) để hỗ trợ các câu chuyện nguyên mẫu.
  • Thuộc tính style trên mỗi <article> là một phần của quá trình 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 chiếc xương đó thành thứ mà mọi người sẽ muốn tương tác. Hôm nay, chúng tôi 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 ngang. Chúng tôi có thể đạt được điều này bằng cách:

  • Đặt vùng chứa thành Lưới
  • Cài đặt từng thành phần con lấp đầy dữ liệu theo dõi 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 một, cho đến khi thẻ đó được đặt tất cả các phần tử HTML vào mã đánh dấu của bạn.

Chrome và Công cụ cho nhà phát triển mở ra với hình ảnh dạng 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 hiển thị tràn cột lưới, khiến 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 vượt ra ngoài khung nhìn, đã đến lúc nói rằng vùng chứa 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 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 nằm nhẹ nhàng ở câu chuyện tiếp theo, nên chúng ta sẽ dùng scroll-snap-type: x mandatory. Đọc thêm về thông tin này CSS trong CSS Scroll Snap Points (Điểm chụp nhanh của CSS Cuộn) và hành vi cuộn qua trong bài đăng trên blog của tôi.

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

Điều đó sẽ giúp bạn cuộn qua bạn bè của mình, nhưng chúng tôi vẫn có một vấn đề cùng 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 những câu chuyện trẻ em vào đúng vị trí. Chúng ta sẽ 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 đang tạo lưới 1x1 trong đó hàng và cột có cùng một Lưới bí danh của [story] và mỗi mục trong lưới tin bài sẽ thử và xác nhận quyền sở hữu không gian đó, dẫn đến 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, bạn sẽ không còn có thể dùng vị trí tuyệt đối, số thực độ chính xác đơn hoặc các lệnh bố cục khác một phần tử nằm ngoài luồng, chúng ta vẫn đang trong luồng. Ngoài ra, hầu như không có mã nào, hãy nhìn vào đó! 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 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 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ự để người dùng ảnh ở trên cùng và sẽ tự động hiển thị khi tải xong. Người nhận bật tính năng 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 nó trong CSS của chúng tôi để phân lớp với trình giữ chỗ đang 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:

.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 tôi sẽ lấp đầy hình ảnh đó. Xác định 2 hình nền cho phép chúng ta kéo một thủ thuật web CSS gọn gàng có tên là loading tombstone:

  • Hình nền 1 (var(--bg)) là URL 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 dải chuyển màu) hiển thị trong khi URL đang tải

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

Tiếp theo, chúng ta sẽ thêm CSS để loại bỏ 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:

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

Cuối cùng, hãy thêm mộ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 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 thoát. Tôi đã nhận được hàm gia tốc tuỳ chỉnh (cubic-bezier(0.4, 0.0, 1,1)) từ Easing của Material Design hướng dẫn (cuộn đến phần Tăng tốc theo tỷ lệ).

Nếu để mắt quan tâm, có thể bạn đã nhận thấy pointer-events: none khai báo và hiện đang gãi đầu. Tôi muốn nói rằng đây là lựa chọn duy nhất nhược điểm của giải pháp này cho đến thời điểm này. Chúng ta cần hàm này vì phần tử .seen.story sẽ ở trên cùng và sẽ nhận được các lần nhấn, mặc dù tiện ích này không nhìn thấy được. Bằng cách đặt pointer-events đến none, chúng ta biến câu chuyện bằng kính thành một khung cửa sổ và không trộm cắp nhiều tương tác của người dùng hơn. Không tệ, đá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 thấy ổn 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: hãy nhấn vào phải để tiến, nhấn vào bên trái để quay lại. Những điều đơn giản mà người dùng thường sử dụng 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 tôi lấy và lưu trữ tham chiếu đến HTML chính gốc của phần tử. Dòng tiếp theo tính toán vị trí nằm giữa phần tử, vì vậy chúng ta có thể quyết định xem một lần nhấn là 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 nào đó liên quan đến logic của mình. Trong phần này trong trường hợp đó, chúng tôi chỉ quan tâm đến tin bài hiện tại. Trong mã đánh dấu HTML, chúng tôi có thể truy cập bằng cách lấy người bạn đầu tiên và câu chuyện gần đây nhất của họ. Thêm đoạn 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 nghe

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

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ã đượ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à lượt nhấp đó không nằm trong 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ẽ giữ vị trí nằm 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à xác định hướng đi. Nếu vị trí người dùng đó là lớn hơn trung vị, nên chúng ta biết cần chuyển đến next, nếu không 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ẽ 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ã đượ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 những câu chuyện và trải nghiệm người dùng mà chúng tạo ra nổi tiếng. Việc này có vẻ phức tạp và phức tạp, nhưng tôi nghĩ nếu bạn thực hiện bằng cách , bạn sẽ thấy giao diện 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 có cuộn đến kết bạn hoặc giới thiệu/ẩn một câu chuyện. Vì HTML là nơi chúng tôi làm việc, nên chúng tôi sẽ truy vấn câu chuyện về sự hiện diện của bạn bè (người dùng) hoặc câu chuyện (câu chuyện).

Những biến này sẽ giúp chúng ta trả lời các câu hỏi như "đã biết câu chuyện x, thì tiếp theo" nghĩa là chuyển sang câu chuyện khác từ chính người bạn này hay sang một người bạn khác?" Tôi làm điều đó bằng cách sử dụng cái cây cấu trúc mà chúng tôi xây dựng, tiếp cận các bậc cha mẹ và con cái 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 về logic kinh doanh của chúng tôi, 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à câu chuyện cuối cùng/đầu tiên của người bạn đó: hãy giới thiệu một người bạn mới
    • Nếu không có sự việc 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 Toàn màn hì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. Thoả sức phát triển nó, thúc đẩy nó bằng dữ liệu và nói chung làm cho nó của bạn!