Codelab:构建故事组件

此 Codelab 将教您如何在 Web 上构建类似于 Instagram 快拍的体验。我们将逐步构建该组件,首先是 HTML,然后是 CSS,最后是 JavaScript。

请查看我的博文构建 Stories 组件,了解在构建此组件时所做的渐进增强。

设置

  1. 点击 Remix to Edit(混剪以编辑)使项目可编辑。
  2. 打开 app/index.html。

HTML

我始终致力于使用语义 HTML。 由于每个好友都可以有任意数量的故事,因此我认为为每个好友使用一个 <section> 元素,为每个故事使用一个 <article> 元素是有意义的。 不过,我们还是从头开始吧。首先,我们需要一个容器来存放故事组件。

向 <body> 添加 <div> 元素:

<div class="stories">

</div>

添加一些 <section> 元素来表示好友:

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

添加一些 <article> 元素来表示故事:

<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>
  • 我们正在使用图片服务 (picsum.com) 来帮助制作故事原型。
  • 每个 <article> 上的 style 属性是占位符加载技术的一部分,您将在下一部分中详细了解该技术。

CSS

我们的内容已准备好进行样式设置。让我们把这些骨架变成人们想要互动的内容。今天,我们将采用移动优先的方式。

.stories

对于 <div class="stories"> 容器,我们希望使用水平滚动容器。 为此,我们可以:

  • 将容器设为网格
  • 将每个子项设置为填充行轨道
  • 将每个子视图的宽度设置为移动设备视口的宽度

网格将继续在上一列的右侧放置新的 100vw 宽列,直到它将所有 HTML 元素放置在标记中。

Chrome 和开发者工具已打开,其中显示了全宽布局的网格视觉效果
Chrome 开发者工具显示网格列溢出,从而创建水平滚动条。

在 app/css/index.css 底部添加以下 CSS:

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

现在,我们已经有了超出视口的内容,接下来需要告诉该容器如何处理这些内容。将以下突出显示的代码行添加到 .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;
}

我们希望实现横向滚动,因此将 overflow-x 设置为 auto。当用户滚动时,我们希望组件轻轻停在下一个故事上,因此我们将使用 scroll-snap-type: x mandatory。如需详细了解此 CSS,请参阅我的博文中的 CSS 滚动贴靠点和 overscroll-behavior 部分。

父级容器和子级容器都需要同意滚动贴靠,因此我们现在来处理这个问题。将以下代码添加到 app/css/index.css 的底部:

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

您的应用尚无法运行,但以下视频展示了启用和停用 scroll-snap-type 时会发生什么。启用后,每次水平滚动都会贴靠到下一个故事。如果停用此政策,浏览器会使用其默认滚动行为。

这样您就可以滚动浏览好友,但我们仍有一个与故事相关的问题需要解决。

.user

我们在 .user 部分中创建一个布局,将这些子故事元素整理到位。我们将使用一个便捷的堆叠技巧来解决这个问题。 我们实际上是在创建一个 1x1 的网格,其中行和列具有相同的网格别名 [story],并且每个故事网格项都会尝试占用该空间,从而形成堆叠。

将突出显示的代码添加到 .user 规则集中:

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

将以下规则集添加到 app/css/index.css 的底部:

.story {
  grid-area: story;
}

现在,如果没有绝对定位、浮动或其他会将元素移出流的布局指令,我们仍然处于流中。而且几乎不需要编写代码,看看吧!视频和博文中对此进行了更详细的剖析。

.story

现在,我们只需要为故事项本身设置样式。

前面我们提到,每个 <article> 元素上的 style 属性是占位符加载技术的一部分:

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

我们将使用 CSS 的 background-image 属性,该属性允许我们指定多张背景图片。我们可以对它们进行排序,让用户图片位于顶部,并在加载完成后自动显示。为此,我们将图片网址放入自定义属性 (--bg) 中,并在 CSS 中使用该属性与加载占位符分层。

首先,我们来更新 .story 规则集,以便在背景图片加载完成后用其替换渐变效果。将突出显示的代码添加到 .story 规则集中:

.story {
  grid-area: story;

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

将 background-size 设置为 cover 可确保视口中没有空白空间,因为我们的图片会填满视口。定义 2 张背景图片后,我们就可以使用一个巧妙的 CSS 网页技巧,称为加载墓碑:

  • 背景图片 1 (var(--bg)) 是我们在 HTML 中以内嵌方式传递的网址
  • 背景图片 2(linear-gradient(to top, lch(98 0 0), lch(90 0 0)) 是一个渐变,在网址加载时显示

图片下载完成后,CSS 会自动将渐变替换为图片。

接下来,我们将添加一些 CSS 来移除一些行为,从而让浏览器运行得更快。 将突出显示的代码添加到 .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 可防止用户意外选择文本
  • touch-action: manipulation 会指示浏览器将这些互动视为触摸事件,从而使浏览器无需尝试确定您是否在点击网址

最后,我们添加一些 CSS,为故事之间的切换添加动画效果。将突出显示的代码添加到 .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;
  }
}

.seen 类将添加到需要退出的故事中。 我从 Material Design 的加/减速指南(滚动到加速部分)中获取了自定义加/减速函数 (cubic-bezier(0.4, 0.0, 1,1))。

如果您观察敏锐,可能已经注意到了 pointer-events: none 声明,并且现在正在为此感到困惑。我认为这是该解决方案 目前唯一的缺点。我们需要这样做,因为 .seen.story 元素将位于顶部并会接收点按,即使它是不可见的。通过将 pointer-events 设置为 none,我们将玻璃故事变成了一个窗口,不再窃取用户互动。这种取舍还不错,目前在我们的 CSS 中管理起来并不太难。我们不会玩杂耍z-index。我仍然对这个项目充满信心

JavaScript

对用户而言,Stories 组件的互动非常简单:点按右侧可前进,点按左侧可后退。对用户来说简单的事情,对开发者来说往往需要付出艰苦的努力。不过,我们会处理好大部分工作。

设置

首先,我们来计算并存储尽可能多的信息。 将以下代码添加到 app/js/index.js:

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

我们的第一行 JavaScript 会抓取并存储对主要 HTML 元素根的引用。下一行计算元素的中点,以便我们确定点按是前进还是后退。

州

接下来,我们创建一个包含一些与逻辑相关的状态的小对象。在本例中,我们只对当前故事感兴趣。在 HTML 标记中,我们可以通过抓取第 1 位好友及其最新动态来访问它。将突出显示的代码添加到 app/js/index.js:

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

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

监听器

我们现在有足够的逻辑来开始监听用户事件并引导用户。

鼠标

首先,让我们在故事容器上监听 'click' 事件。 将突出显示的代码添加到 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')
})

如果发生点击,但点击的不是 <article> 元素,我们会退出,不执行任何操作。 如果是一篇文章,我们会使用 clientX 抓取鼠标或手指的水平位置。我们尚未实现 navigateStories,但它接受的实参指定了我们需要前往的方向。如果该用户位置大于中位数,我们就知道需要导航到 next,否则导航到 prev(上一个)。

键盘

现在,我们来监听键盘按键。如果按下向下箭头,则会导航到 next。如果是向上键,则转到 prev。

将突出显示的代码添加到 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')
})

故事导航

现在,我们要处理故事的独特业务逻辑,以及让故事名声大噪的用户体验。这看起来很繁琐,但如果您逐行阅读,就会发现它其实很容易理解。

首先,我们存储了一些选择器,这些选择器可帮助我们决定是滚动到好友还是显示/隐藏故事。由于我们是在 HTML 中工作,因此我们将查询其中是否存在好友(用户)或故事(故事)。

这些变量将帮助我们回答诸如“对于故事 x,‘下一个’是指移至同一位朋友的另一个故事,还是移至另一位朋友的故事?”之类的问题。我通过使用我们构建的树结构,深入到父级和子级,实现了这一目标。

将以下代码添加到 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
}

以下是我们的业务逻辑目标,尽可能接近自然语言:

  • 决定如何处理点按操作
    • 如果有下一个/上一个故事:显示该故事
    • 如果这是好友的最后一个/第一个故事:显示新好友
    • 如果该方向没有可跳转到的故事:不执行任何操作
  • 将新的当前故事暂存到 state 中

将突出显示的代码添加到 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
    }
  }
}

试试看

  • 如需预览网站,请按查看应用。然后按全屏 全屏。

总结

以上就是我对该组件的需求。您可以随意在此基础上进行构建,用数据驱动它,并将其打造为您的专属应用!