昨天在掘金看到一篇文章, 内容是用原生 JS 写抛物线动画. 看完觉得挺有趣, 很适合用 Rx.JS 来重现, 于是有了这篇文章.
本文默认你已经掌握了 Rx.JS 的基本概念和操作. 若你还没掌握, 推荐先看一些入门资料.
动画的本质就是页面元素随着时间的持续, 在特定时间点改变自身在页面中的坐标位置. 这个很适合用响应式编程中 "流" 的概念来表达. 我们需要将动画的持续时间 (本文只考虑时间限定的情况) 内根据浏览器 requestAnimateFrame API 所允许的时间点映射成一个个节点, 然后在这一个个节点中改变物体的位置. 这个关键一步做好了, 剩下的诸如 easing 曲线和加速度等都好解决了.
来看怎么解决第一个问题. 先上代码:
- // 首先我就把所有 Observable 和操作符导入了, 接下来就省略了
- import { interval, animationFrameScheduler, fromEvent, defer, merge } from "rxjs";
- import { map, takeWhile, tap, flatMap } from "rxjs/operators";
- function duration(ms) {
- return defer(() => {
- const start = Date.now();
- return interval(0, animationFrameScheduler).pipe(
- map(() => (Date.now() - start) / ms),
- takeWhile(n => n <= 1)
- );
- });
- }
defer 的作用是, 只有当被订阅时, 它才会根据提供给它的 Observable 工厂函数, 生成新的 Observable. 这样做的目的是, duration 需要为每一个订阅者提供新的 Observable. 等下会看到它会在不同的地方被订阅.
首先在 defer 里面的 Observable 工厂函数前面记录当前时间戳. 接下来下一行, interval 的作用是相隔指定时间段, 释放一个行为(这个比较抽象, 可以理解成告诉管道的下一个接收者要开始做事了).interval 接受两个参数, 第二个参数是 Scheduler. 默认的 Scheduler 是 async, 这里我们需要提供 animationFrameScheduler. 这样做的意思是, 告诉 interval 每隔 0s 释放一次行为, 这个行为由 animationFrameScheduler 调控. 事实上后者不会真的每 0s 就释放一次, 而是会通过 requestAnimationFrame 来获取浏览器的空闲时间(下一帧渲染之前), 只有当浏览器有空了才会响应 interval 的指令.
然后接下来进入管道, 第一个 map 意思是, 把 interval 的指令映射成一个时间比例, 该时间比例由当前时间, 减去 interval 生成之前的时间, 然后除以总时间, 得到的是当前时间点占总时间长的比率. takeWhile 指定一旦这个时间比例超过 1, 就把 Observable 停掉. 举个例子, 本来指定了 3 秒, 但是时间过了 4 秒, 4/3 就大于 1 了, 超过了动画指定时长.
最重要的部分就处理完了.
接下来计算每个时间点物体应该移动的距离:
const distance = d => t => d * t;
参数 d 指的是总距离, t 指的是时间比率, 就是我们在上一步算出来的. 两者相乘就是每个时间点物体移动的距离了. 注意, 函数式编程里面的函数都要柯里化(回调函数不一定). 这样做的好处等下会看到.
然后取到 DOM 上的目标元素, 对其进行位移:
- const targetDiv = document.querySelector(".target");
- const moveRight$ = duration(2000).pipe(
- map(distance(1000)),
- tap(x => (targetDiv.style.left = x + "px"))
- );
- const moveDown$ = duration(2000).pipe(
- map(distance(700)),
- tap(y => (targetDiv.style.top = y + "px"))
- );
这里写了两个流, 分别是右移和下移, 右移 1000px, 下移 700px. 注意到我们把总距离传给 distance 函数后, 它会返回新的函数, 等着管道上游给它传时间比例 t, 这就是柯里化的作用.
然后我们把两个流合并, 就可以让物体同时右移和下移, 也就是让它走对角线.
merge(moveRight$, moveDown$).subscribe()
动画的第一阶段写完了, 此时目标物体会从左上角到右下角做匀速直线运动. 接下来我们要加上抛物线轨迹和重力加速度效果.
思考一下, 抛物线的轨迹是水平移动和垂直移动速度不一致导致的, 而加速度是由两者的速率变化导致的. 前者可以用两者的函数关系来体现, 后者可以用两者各自的 easing 函数来体现. 我查了一下主流的 easing 函数, 仿写了两个.
第一个是 easeInQuad:
const easeInQuad = t => t * t;
第二个是 easeInQuint:
const easeInQuint = t => t * t * t * t * t * t;
可以看出两者的函数关系是 y = Math.pow(x, 3), 刚好是个抛物线. 若想定制加速度和抛物线轨迹, 也可以自己写.
接下来只用把 interval 里面的时间比例应用于各自的 easing 函数就行了. 然后再加个按钮, 只有点击按钮后, 动画才开始.
一步到位完整代码:
- const targetDiv = document.querySelector(".target");
- const startBtn = document.querySelector("#start");
- const startClick$ = fromEvent(startBtn, "click");
- const easeInQuad = t => t * t;
- const easeInQuint = t => t * t * t * t * t * t;
- function duration(ms) {
- return defer(() => {
- const start = Date.now();
- return interval(0, animationFrameScheduler).pipe(
- map(() => (Date.now() - start) / ms),
- takeWhile(n => n <= 1)
- );
- });
- }
- const distance = d => t => d * t;
- const moveDown$ = duration(1500).pipe(
- map(easeInQuint),
- map(distance(700)),
- tap(y => (targetDiv.style.top = y + "px"))
- );
- const moveRight$ = duration(1500).pipe(
- map(easeInQuad),
- map(distance(1000)),
- tap(x => (targetDiv.style.left = x + "px"))
- );
- startClick$.pipe(
- flatMap(() => merge(moveRight$, moveDown$))
- ).subscribe()
线上效果在这里 https://codepen.io/leihuang/full/OBVBjb/
来源: https://juejin.im/post/5bb2c0b6e51d450e63224272