30 天精通 RxJS (11):实践范例 - 完整拖拉应用

RxJS Logo

有次不小心进到了优酷,发现优酷有个不错的功能,能大大的提升用户体验,就让我们一起来实作这个效果吧!

在第 08 篇的时候,我们已经成功做出简易的拖拉效果,今天要来做一个完整的应用,而且是实务上有机会遇到但不好处理的需求,那就是优酷的影片效果!

当我们在优酷看视频时往下滚动画面,影片会变成一个小窗口在右下角,这个窗口还能够拖拉移动位置。 这个功能可以让用户一边看留言同时又能看影片,且不影响其他的信息显示,真的是很不错的 feature。

就让我们一起来实作这个功能,同时补完拖拉所需要注意的细节吧!

需求分析

首先我们会有一个影片在最上方,原本是位置是静态(static)的,卷轴滚动到低于影片高度后,影片改为相对于窗口的绝对位置(fixed),往回滚会再变回原本的状态。 当影片为 fixed 时,鼠标移至影片上方(hover)会有屏蔽(masker)与鼠标变化(cursor),可以拖拉移动(drag),且移动范围不超过可视区间!

上面可以拆分成以下几个步骤

  • 准备 static 样式与 fixed 样式
  • HTML 要有一个固定位置的锚点(anchor)
  • 当滚动超过锚点,则影片变成 fixed
  • 当往回滚动过锚点上方,则影片变回 static
  • 影片 fixed 时,要能够拖拉
  • 拖拉范围限制在当前可视区间

基本的 HTML 跟 CSS 笔者已经帮大家完成,大家可以直接到下面的链接接着实作:

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width" />
		<title>JS Drag</title>
		<style>
			* {
				-webkit-box-sizing: border-box;
				-moz-box-sizing: border-box;
				box-sizing: border-box;
			}

			html,
			body {
				margin: 0;
				padding: 0;
				height: 2000px;
				background-color: tomato;
			}

			#anchor {
				height: 360px;
				width: 100%;
				background-color: #f0f0f0;
			}

			.video {
				width: 640px;
				height: 360px;
				margin: 0 auto;
				background-color: black;
			}
			.video.video-fixed {
				position: fixed;
				top: 10px;
				left: 10px;
				width: 320px;
				height: 150px;
				cursor: all-scroll;
			}
			.video.video-fixed.masker {
				display: none;
			}
			.video.video-fixed.masker:hover {
				display: block;
				position: absolute;
				width: 100%;
				height: 180px;
				background-color: rgba(0, 0, 0, 0.8);
				z-index: 2;
			}
		</style>
		<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/5.5.8/Rx.js"></script>
	</head>
	<body>
		<div id="anchor">
			<div class="video" id="video">
				<div class="masker"></div>
				<video width="100%" controls>
					<source
						src="http://download.blender.org/peach/bigbuckbunny_movies/big_buck_bunny_480p_stereo.ogg"
						type="video/ogg"
					/>
					Your browser does not support HTML5 video.
				</video>
			</div>
		</div>
	</body>
	<script>

	</script>
</html>

滚动变更样式

先让我们看一下 HTML,首先在 HTML 里有一个 div(#anchor),这个 div(#anchor) 就是待会要做锚点用的,它内部有一个 div(#video),则是滚动后要改变成 fixed 的元件。

CSS 的部分我们只需要知道滚动到下方后,要把 div(#video) 加上video-fixed这个 class。

接着我们就开始实作滚动的效果切换 class 的效果吧!

第一步,取得会用到的 DOM

因为先做滚动切换 class,所以这里用到的 DOM 只有 #video, #anchor。

const video = document.getElementById('video')
const anchor = document.getElementById('anchor')

第二步,建立会用到的 observable

这里做滚动效果,所以只需要监听滚动事件。

const scroll = Rx.Observable.fromEvent(document, 'scroll')

第三步,撰写程序逻辑

这里我们要取得了 scroll 事件的 observable,当滚过 #anchor 最底部时,就改变 #video 的 class。

首先我们会需要滚动事件发生时,去判断是否滚过 #anchor 最底部,所以把原本的滚动事件变成是否滚过最底部的 true or false。

scroll.map((e) => anchor.getBoundingClientRect().bottom < 0)

这里我们用到了getBoundingClientRect这个浏览器原生的 API(moz 链接),他可以取得 DOM 对象的宽高以及上下左右离屏幕可视区间上(左)的距离,如下图

这是图片

当我们可视范围区间滚过 #anchor 底部时,anchor.getBoundingClientRect().bottom就会变成负值,此时我们就改变 #video 的 class。

scroll
	.map((e) => anchor.getBoundingClientRect().bottom < 0)
	.subscribe((bool) => {
		if (bool) {
			video.classList.add('video-fixed')
		} else {
			video.classList.remove('video-fixed')
		}
	})

到这里我们就已经完成滚动变更样式的效果了!

全部的 JS 代码,如下

const video = document.getElementById('video')
const anchor = document.getElementById('anchor')
const scroll = Rx.Observable.fromEvent(document, 'scroll')
scroll
	.map((e) => anchor.getBoundingClientRect().bottom < 0)
	.subscribe((bool) => {
		if (bool) {
			video.classList.add('video-fixed')
		} else {
			video.classList.remove('video-fixed')
		}
	})

当然这段还能在用 debounce/throttle 或 requestAnimationFrame 做优化,这个部分我们日后的文章会在提及。

拖拉的行为

接下来我们就可以接着做拖拉的行为了。

第一步,取得会用到的 DOM

这里我们会用到的 DOM 跟前面是一样的(#video),所以不用多做什么。

第二步,建立会用到的 observable

这里跟上次一样,我们会用到 mousedown, mouseup, mousemove 三个事件。

const mouseDown = Rx.Observable.fromEvent(video, 'mousedown')
const mouseUp = Rx.Observable.fromEvent(document, 'mouseup')
const mouseMove = Rx.Observable.fromEvent(document, 'mousemove')

第三步,撰写程序逻辑

跟上次是差不多的,首先我们会点击 #video 组件,点击(mousedown)后要变成移动事件(mousemove),而移动事件会在鼠标放开(mouseup)时结束(takeUntil)

mouseDown
    .map((e) => mouseMove.takeUntil(mouseUp))
    .concatAll()

因为把 mouseDown observable 发送出来的事件换成了 mouseMove observable,所以变成了 observable(mouseDown) 送出 observable(mouseMove)。 因此最后用 concatAll 把后面送出的元素变成 mouse move 的事件。

这段如果不清楚的可以回去看一下 08 篇的讲解

但这里会有一个问题,就是我们的这段拖拉事件其实只能做用到 video-fixed 的时候,所以我们要加上filter

mouseDown
	.filter((e) => video.classList.contains('video-fixed'))
	.map((e) => mouseMove.takeUntil(mouseUp))
	.concatAll()

这里我们用 filter 如果当下 #video 没有 class video-dragable的话,事件就不会送出。

再来我们就能跟上次一样,把 mousemove 事件变成 { x, y } 的物件,并订阅来改变 #video 元件

mouseDown
	.filter((e) => video.classList.contains('video-fixed'))
	.map((e) => mouseMove.takeUntil(mouseUp))
	.concatAll()
	.map((m) => {
		return {
			x: m.clientX,
			y: m.clientY,
		}
	})
	.subscribe((pos) => {
		video.style.top = pos.y + 'px'
		video.style.left = pos.x + 'px'
	})

到这里我们基本上已经完成了所有功能,其步骤跟 08 篇的方法是一样的,如果不熟悉的人可以回头看一下!

但这里有两个大问题我们还没有解决

  1. 第一次拉动的时候会闪一下,不像优酷那么顺

  2. 拖拉会跑出当前可视区间,跑上出去后就抓不回来了

让我们一个一个解决,首先第一个问题是因为我们的拖拉直接给组件鼠标的位置(clientX, clientY),而非给鼠标相对移动的距离!

所以要解决这个问题很简单,我们只要把点击目标的左上角当作 (0,0),并以此改变元件的样式,就不会有闪动的问题。

这个要怎么做呢? 很简单,我们在昨天讲了一个 operator 叫做 withLatestFrom,我们可以用它来把 mousedown 与 mousemove 两个 Event 的值同时传入 callback。

mouseDown
	.filter((e) => video.classList.contains('video-fixed'))
	.map((e) => mouseMove.takeUntil(mouseUp))
	.concatAll()
	.withLatestFrom(mouseDown, (move, down) => {
		return {
			x: move.clientX - down.offsetX,
			y: move.clientY - down.offsetY,
		}
	})
	.subscribe((pos) => {
		video.style.top = pos.y + 'px'
		video.style.left = pos.x + 'px'
	})

当我们能够同时得到 mousemove 跟 mousedown 的事件,接着就只要把 鼠标相对可视区间的距离(client) 减掉点按下去时 鼠标相对元件边界的距离(offset) 就行了。 这时拖拉就不会先闪动一下罗!

大家只要想一下,其实 client - offset 就是组件相对于可视区间的距离,也就是他一开始没动的位置!

接着让我们解决第二个问题,拖拉会超出可视范围。 这个问题其实只要给最大最小值就行了,因为需求的关系,这里我们的组件是相对可视居间的绝对位置(fixed),也就是说

  • top 最小是 0
  • left 最小是 0
  • top 最大是可视高度扣掉元件本身高度
  • left 最大是可视宽度扣掉元件本身宽度

这里我们先宣告一个 function 来处理这件事

const validValue = (value, max, min) => {
	return Math.min(Math.max(value, min), max)
}

第一个参数给原本要给的位置值,后面给最大跟最小,如果参数大于最大值我们就取最大值,如果参数小于最小值则取最小值。

再来我们就可以直接把这个问题解掉了

mouseDown
	.filter((e) => video.classList.contains('video-fixed'))
	.map((e) => mouseMove.takeUntil(mouseUp))
	.concatAll()
	.withLatestFrom(mouseDown, (move, down) => {
		return {
			x: validValue(
				move.clientX - down.offsetX,
				window.innerWidth - 320,
				0
			),
			y: validValue(
				move.clientY - down.offsetY,
				window.innerHeight - 180,
				0
			),
		}
	})
	.subscribe((pos) => {
		video.style.top = pos.y + 'px'
		video.style.left = pos.x + 'px'
	})

这里我偷懒了一下,直接写死组件的宽高(320, 180),实际上应该用getBoundingClientRect计算是比较好的。

现在我们就完成整个应用啰!

完整代码

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width" />
		<title>JS Drag</title>
		<style>
			* {
				-webkit-box-sizing: border-box;
				-moz-box-sizing: border-box;
				box-sizing: border-box;
			}

			html,
			body {
				margin: 0;
				padding: 0;
				height: 2000px;
				background-color: tomato;
			}

			#anchor {
				height: 360px;
				width: 100%;
				background-color: #f0f0f0;
			}

			.video {
				width: 640px;
				height: 360px;
				margin: 0 auto;
				background-color: black;
			}
			.video.video-fixed {
				position: fixed;
				top: 10px;
				left: 10px;
				width: 320px;
				height: 150px;
				cursor: all-scroll;
			}
			.video.video-fixed.masker {
				display: none;
			}
			.video.video-fixed.masker:hover {
				display: block;
				position: absolute;
				width: 100%;
				height: 180px;
				background-color: rgba(0, 0, 0, 0.8);
				z-index: 2;
			}
		</style>
		<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/5.5.8/Rx.js"></script>
	</head>
	<body>
		<div id="anchor">
			<div class="video" id="video">
				<div class="masker"></div>
				<video width="100%" controls>
					<source
						src="http://download.blender.org/peach/bigbuckbunny_movies/big_buck_bunny_480p_stereo.ogg"
						type="video/ogg"
					/>
					Your browser does not support HTML5 video.
				</video>
			</div>
		</div>
	</body>
	<script>
		const video = document.getElementById('video')
		const anchor = document.getElementById('anchor')
		const scroll = Rx.Observable.fromEvent(document, 'scroll')
		scroll
			.map((e) => anchor.getBoundingClientRect().bottom < 0)
			.subscribe((bool) => {
				if (bool) {
					video.classList.add('video-fixed')
				} else {
					video.classList.remove('video-fixed')
				}
			})

		const mouseDown = Rx.Observable.fromEvent(video, 'mousedown')
		const mouseUp = Rx.Observable.fromEvent(document, 'mouseup')
		const mouseMove = Rx.Observable.fromEvent(document, 'mousemove')
		const validValue = (value, max, min) => {
			return Math.min(Math.max(value, min), max)
		}
		mouseDown
			.filter((e) => video.classList.contains('video-fixed'))
			.map((e) => mouseMove.takeUntil(mouseUp))
			.concatAll()
			.withLatestFrom(mouseDown, (move, down) => {
				return {
					x: validValue(
						move.clientX - down.offsetX,
						window.innerWidth - 320,
						0
					),
					y: validValue(
						move.clientY - down.offsetY,
						window.innerHeight - 180,
						0
					),
				}
			})
			.subscribe((pos) => {
				video.style.top = pos.y + 'px'
				video.style.left = pos.x + 'px'
			})
	</script>
</html>

今日结语

我们简单地用了不到 35 行的代码,完成了一个还算复杂的功能。 更重要的是我们还保持了整支程序的可读性,让我们之后维护更加的轻松。

今天的练习就到这边结束了,不知道读者有没有收获呢? 如果有任何问题欢迎在下方留言给我!

如果你喜欢本篇文章请帮我按个 like 跟星星。

本系列仅作为学习记录所用,摘录自30天精通Rxjs!强烈推荐!膜拜大佬!

posted @   楚小九  阅读(52)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
点击右上角即可分享
微信分享提示