此 Codelab 将教您如何在 Web 上构建类似 Instagram 快拍的体验。我们将逐步构建该组件,首先是 HTML,然后是 CSS,最后是 JavaScript。
请查看我的博文构建故事组件,了解在构建此组件时所做的渐进式增强。
设置
- 点击 Remix to Edit 使项目可供修改。
- 打开
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
列右侧放置新的 100vw
列,直到将标记中的所有 HTML 元素都放置完毕。

将以下 CSS 添加到 app/css/index.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
对于用户而言,故事组件的互动非常简单:点按右侧可前进,点按左侧可后退。对用户来说简单的事情,对开发者来说往往是艰巨的任务。不过,我们会处理其中的大部分工作。
设置
首先,我们来计算并存储尽可能多的信息。
将以下代码添加到 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 中进行操作,因此我们将查询 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
}
}
}
试试看
- 如需预览网站,请按查看应用。然后按全屏图标
。
总结
以上就是我对组件的需求。您可以随意在此基础上进行构建,利用数据驱动它,并将其打造成您自己的应用!