用网络动画API进行精确计时的方法

315 阅读10分钟

我以前认为动画是一种好玩的东西。为界面增加模糊性的东西。除此以外,在好的方面,动画可以使界面更清晰。网络上的动画有一个特性,我没怎么听说过,就是它的精确性。Web Animations API允许我们放弃与JavaScript定时问题有关的变通方法。在这篇文章中,你将看到如何不做动画以及如何协调几个元素的动画。

当你从事一个需要精确的视觉呈现的工作时,你很快就会发现,你花了太多的时间来解决JavaScript无法精确地确定代码何时会实际执行的问题。试着去实现一些依赖于节奏或共享时间的东西,你就会明白我的意思。在某些情况下,你可能会接近,但它永远不会完美。

我发现在处理复杂的问题时,有一件事很有帮助,那就是把它们分解成更小、更简单的问题。碰巧的是,较小的部分--即使是很多--有一些东西将它们统一起来。有些东西可以让你统一对待它们。在动画的情况下,现在你有更多的元素要处理,你需要一些东西来保证计时的精确性,以排除漂移的可能性,即元素 "不合拍 "的可能性。

首先,让我们看看JavaScript所提供的典型工具会出什么问题。

JavaScript的时间问题。按部就班,但不合拍

在JavaScript中,每个任务都要经过一个队列。你的代码、用户互动、网络事件。每个任务都在等待轮到它被一个事件循环执行。这样一来,就保证了事情的发生顺序:当你调用一个函数时,你可以确定没有突然的鼠标移动会在中间注入自己。当你需要事情稍后发生时,你可以注册一个事件监听器或一个定时器。

当事件发生或定时器到期时,你在回调中定义的任务会进入队列。一旦事件循环到了它,你的代码就会被执行。这是一个强大的概念,它允许我们在很大程度上忽略了并发性。它运作良好,但你最好了解它是如何运作的。

我们将在动画的背景下研究它的后果。我鼓励你更深入地学习这个话题。了解JavaScript工作的本质,将为你节省时间,并使你的头发保持颜色。Jake Archibald在他的 "Tasks, Microtasks, Queues and Schedules"一文中,以及最近在JSConf的 "In The Loop"演讲中,对这一切做了很好的分解。

一旦你决定用setTimeoutsetInteval 来做动画,等待你的是什么?

低精度

我们可以准确地定义在将我们的任务放入队列之前应该等待多长时间的超时。我们无法预测的是,此刻队列中会出现什么。我们可以通过检查计划的tick长度和实际执行代码的时刻之间的差异来实现自我调整的定时器。这个差异被应用于下一个tick超时。

它大多是有效的,但如果所需的刻度线之间的距离是以两位数的毫秒或更少的时间来衡量的,它就很少能在正确的时刻击中。另外,它的性质(它在执行时进行调整)使它很难将有节奏的东西可视化。它将显示它被调用时的精确状态,但不会显示状态改变的确切时刻。

这是因为setTimeout ,保证了一件事情被放入队列之前的最小延迟。但是没有办法知道什么东西会已经在队列中了。

堆积起来

如果低精度对你来说偶尔也可以,你会得到一个堆积。如果事件循环有很多任务要处理,你本来想在时间上隔开的事情可能一下子就被执行了--或者全部被暂停。

电池寿命的进步来自于更好的硬件和高效的软件。浏览器标签可能会被暂停,以减少不使用时的电力消耗。当标签再次成为焦点时,事件循环可能会发现自己有很多回调--其中一些是由定时器发出的--在队列中要处理。

有一次,我不得不为一个网站实现随机翻转的瓷砖,其中一个bug是由沉睡的标签引起的。因为每块瓷砖都有自己的定时器,当标签进入活动状态时,它们都会同时启动。

请注意最上面一排的块是如何被延迟的,然后一次翻转三个。(参见Kirill Myshkin的PenCodePen Home Timeouts vs DocumentTimeline)

拥挤的队列

很可能,你的代码已经受到库和框架的限制。这意味着你的定时器的回调更有可能在一个不幸的时刻被放入队列中。你可能没有太多的机会进行优化,因为已经有很多代码在运行。

上面的缺点在某些情况下可能会被解决。你自己决定在每个特定的项目中什么是更有价值的。如果你的所有元素都可以由一个定时器管理,你也许可以使它发挥作用。

不过,我还是会看看requestAnimationFrame ,而不是用定时器来管理动画。我上面链接的Jake的演讲很好地说明了这一点。它给你的是节奏感。你可以确定你的代码会在用户能够看到任何东西之前就被执行。因为你有一个函数被调用的时间戳,你可以用它来计算你需要的确切状态。

什么是值得你花时间去处理的,这取决于你。很可能某个特定的解决方法是好的,而你可以继续进行你试图实现的任何事情。你是一个更好的判断者,知道什么在你的情况下是可行的。

如果你想实现的东西适合于动画领域,你会从队列中移开它而受益。让我们看看我们如何到达一个时间为王的地方。

网络动画API。事物同步的地方

在我之前的文章中,"用Web Animations API协调复杂性",我们研究了如何使几个动画可以被控制,就好像它们是一个整体。现在我们来看看如何确保你所有的动画都在正确的时刻开始。

时间轴

Web Animations API引入了一个时间线的概念。默认情况下,所有的动画都与 document 的时间线联系在一起。这意味着动画共享同一个 "内部时钟"--一个从页面加载开始的时钟。

这个共享的时钟是让我们协调动画的原因。无论是某种节奏还是某种模式,你都不需要担心某个东西会拖后腿或走在自己前面。

开始时间

要使一个动画在某一时刻开始,你可以使用startTime 属性。startTime 的值是以从页面加载开始的毫秒为单位。动画的开始时间设置为1000.5 ,当文档时间线的currentTime 属性等于1000.5 时,动画将准确地开始播放。

注意到开始时间值中的点了吗?是的,你可以使用几分之一的毫秒,它就是这么精确。然而,确切的精度取决于浏览器的设置

另一个有用的东西是,开始时间也可以是负数。你可以自由地把它设置为未来的某个时刻或过去的某个时刻。将该值设置为-1000 ,那么你的动画状态就会像在页面加载时已经播放了一秒钟一样。对于用户来说,就好像在他们还没有想到要访问你的页面时,动画就已经开始播放了。

注意请注意,timelinestartTime 仍然是实验性的技术。

演示

为了演示你如何使用它,我设置了一个演示。我实现了一个指标,它比其他任何指标都更依赖于时间精度--一个时钟。好吧,我做了两个,那样的话,一个可以揭示另一个的伟大之处。这个演示中的某些东西很简单,足以证明基本原理。也有一些棘手的部分,让你看到这种方法的不足之处。

数字和模拟时钟,都是用数字实现的。(见Kirill Myshkin笔钟)

模拟时钟的运动是非常简单的。三根指针做同样的单圈旋转--相当乐观地--无限次的旋转。因为时钟是一个精确的工具,所以我让秒针和分针在其相应数值变化的确切时刻改变它们的位置。这有助于说明它们的变化与下面数字钟上的表兄弟一样,都是在准确的时刻变化的。

const clock = document.getElementById("analog-clock");
const second = clock.querySelector(".second");
const minute = clock.querySelector(".minute");
const hour = clock.querySelector(".hour");

const s = 1000;
const m = s * 60;
const h = m * 60;
const d = h * 24;

const hands = [second, minute, hour];
const hand_durations = [m, h, d];
const steps = [60, 60, 120];

const movement = hands.map(function (hand, index) {
    return hand.animate(
        [
            {transform: "rotate(0turn)"},
            {transform: "rotate(1turn)"}
        ],
        {
            duration: hand_durations[index],
            iterations: Infinity,
            easing: `steps(${steps[index]}, end)`
        }
    );
});

movement.forEach(function (move) {
    move.startTime = start_time;
});

三根指针的动画在旋转的时间和步数上都有所不同。秒针在六万毫秒内做一次旋转。分针比它慢60倍。时针--因为它是一个二十四小时的时钟--在分针转动二十四圈的同等时间内做一圈。

为了将时钟指针的操作与相同的时间概念联系起来(以确保分针在秒针完成旋转的时候准确地更新其位置),我使用了startTime 属性。这个演示中的所有动画都被设置为相同的开始时间。而这就是你所需要的一切。不要担心队列、暂停的标签或成堆的超时。定义一次就可以了。

另一方面,数字时钟则有点反直觉。每个数字都是一个容器,有overflow: hidden; 。里面有一排从0到1的数字,坐在等宽的单元格中。每个数字都是通过水平平移该行的单元格宽度乘以数字值来显示的。就像模拟时钟上的指针一样,这是一个为每个数字设置正确时间的问题。虽然从毫秒到分钟的所有数字都很容易做到,但小时则需要一些技巧--我将在下文中介绍这些技巧。

让我们来看看start_time 变量的值。

const start_time = (function () {
    const time = new Date();
    const hour_diff = time.getHours() - time.getUTCHours();
    const my_current_time = (Number(time) % d) + (hour_diff * h);

    return document.timeline.currentTime - my_current_time;
}());

为了计算出所有元素必须开始的确切时刻(已经过了午夜),我取了Date.now() (自1970年1月1日以来的毫秒数)的值,从中剥离了整整一天,并通过与UTC时间的差异进行调整。这给我留下了从今天开始已经过去的毫秒数。这是我的时钟所需的唯一数据,以显示它注定要显示的内容:小时分钟

为了将该值转换为文件的境界,我需要根据从这个演示的页面加载到Date.now() ,已经过去了多少时间来调整它。要做到这一点,我从文档的currentTime 中减去了它。将这个结果应用于一个动画,意味着这个特定的动画从午夜开始就一直在播放。把这个结果应用到所有的动画上,你就得到了一个从午夜开始播放的演示。

理论上,我们可以有一个从1970年1月1日开始运行的时钟(到现在为止已经52年了),但是有些浏览器对动画的持续时间有无记录的限制。应用一些CSS来人为地使这种时钟变老也是正确的--因为从昨晚开始运行的时钟不会有任何其他区别。这两个时钟将完全同步。

这种方法的不足之处

能够在不进行任何复杂计算的情况下实现如此精确的东西是很有力量的。但它只适用于你试图实现的东西可以用关键帧定义的情况。你应该根据你的特殊情况,决定在什么情况下会有好处,在什么情况下处理缺点会变得更加麻烦和昂贵。

Web Animations API的另一个选择是使用requestAnimationFrameperformance.now() 。有了这些,你就需要自己计算插值。

如果你选择依靠Web Animations API,你将不得不面对这样一个事实,即事情以不同的方式适合关键帧的定义。有些东西可能几乎不需要定义,因为它们自然适合。其他的则需要变通。这些变通方法是否为你试图实现的东西增加了很多成本,应该决定了你的方法。

钟的演示有两种情况的例子。指针本身是最容易做的事情。它是一个一圈旋转的关键帧,用steps 缓和功能来使它们滴答作响。最后,演示时钟的主要运动几乎不费吹灰之力就完成了。我希望我可以说,数字钟也是一样容易实现的。但那是由于我自己的缺点造成的,我会说。

这里有三个我不得不恢复的变通方法的例子。我希望它们能给你一个想法,如果你采用动画的方法,你可能需要做什么。它们并不能很好地代表Web Animations API的限制,它们只是显示了我所选择的一个特定的实现是如何被改变以适应的。让我们看看我在哪些方面做了额外的工作。

一些属性不会像你想要的那样动画化

如果你仔细观察,模拟时钟上的每个指针都有一个阴影。它们增加了一些深度,使时钟看起来更漂亮。阴影很容易使用box-shadowfilter CSS属性来应用。它在一定程度上是可动画的,但它的不足之处在于阴影方向的动画。你不能用角度值来设置它,而是用坐标来设置。

我找不到一种方法来实现使用两个坐标的阴影的圆形运动。相反,我把每只手分解成三个元素,分别做动画(关于这个技术,请看我以前的文章《用网络动画API协调复杂度》)。第一个是一个包装器,包含了手的另外两个部分:身体和阴影。包装器是主要旋转所适用的元素。身体定义了手的形状、大小和颜色,而阴影则复制了身体的属性(颜色除外)。另外,它有自己的动画定义--它围绕手的中心旋转。

要处理的元素数量成倍增加,似乎更难做到。不过在阴影的情况下,它最终与手分离的事实给了它更多的灵活性。你可以使用CSS对它进行样式设计。而且因为时间已经处理好了,有更多的元素并不会使它变得更难。

除法并不总是导致份额相等

第二个解决方法是数字时钟的小时部分需要的。该时钟是用个位数元素实现的。三个代表毫秒,两个代表秒,两个代表分钟。小时的数字不适合循环关键帧的逻辑。

循环不是有规律的,因为在二十年代只有四个小时。我不得不引入一个 "宽 "位数来解决这个问题。这个宽位数的逻辑与普通位数相同,只是它支持从零到九十九的数字--这对这种情况来说已经足够了。最后,数字钟的小时指示器重新使用了与模拟钟的时针相同的计时选项。

不看日历,你永远不知道下个月会有多长

第三个解决方法是针对日期复杂功能的。现在我有了 "宽 "数字元素,我重新使用它来显示日期,只是将持续时间从小时增加到天。

这种方法的问题是,月份的长度与演示中使用的相同长度的动画并不完全对应。你看,我们今天使用的日历有一个混乱的历史,很难适应一个简单的循环。人们必须在一个关键帧中定义公历的所有例外情况。我就不做这个了。我在这里向你展示一个变通办法。

我选择依靠Date ,而不是定义另一个有缺陷的日历。谁知道未来的月份会有多少天,对吗?

function month() {
    const now = new Date();
    return digits(
        (new Date(now.getFullYear(), now.getMonth() + 1, 0)).getDate(),
        false
    );
}

function create_animation(digit) {
    const nr_digits = digit.firstElementChild.children.length;
    const duration = d * nr_digits;
    return digit.firstElementChild.animate(
        [
            {transform: "translateX(0)"},
            {transform: "translateX(calc(var(--len) * -2ch)"}
        ],
        {
            duration,
            easing: `steps(${nr_digits}, end)`,
            iterationStart: (d * ((new Date()).getDate() - 1)) / duration
        }
    );
}

(function set_up_date_complication() {
    const day = document.querySelector(".day");

    const new_day = day.cloneNode();
    new_day.append(month());
    day.replaceWith(new_day);

    Array.from(new_day.children).forEach(function (digit) {
        const complication = create_animation(digit);
        complication.startTime = start_time;
        complication.finished.then(set_up_date_complication);
    });
}());

为了使日期复杂化无懈可击,我将其持续时间定义为当前月份的长度。为了保持使用相同的开始时间并避免对duration 值的限制,我们使用iterationStart 属性将动画 "倒退 "到正确的日期。

当这个月结束时,我们需要为下个月重建日期复杂化。这样做的正确时刻是在复杂化动画完成后。与本演示中的其他动画不同,日期并不迭代,所以我们将使用当前月份动画的finished 承诺创建一个新的日期。

这种方法存在着本文开头所述的缺点。但是在月的情况下,我们可能会对轻微的不精确性视而不见。

你将不得不相信我的话,它是有效的。否则,请在一个月的任何一个接近午夜的最后一天回到这篇文章中来。谁知道呢,你会发现我在同一页上,眼睛里充满了希望,手指交叉着。

总结

动画共享相同的时间参考,通过调整它们的startTime 属性,你可以将它们调整到你需要的任何模式。你可以确信它们不会漂移。

Web Animations API带有强大的工具,使你能够大大减少你的应用程序和你必须做的工作。它还带有一种精确性,为实现新型的应用程序提供了可能性。

这种力量就包含在动画领域。它是否适合你的情况,你要根据你的需要来决定。我希望我在这篇文章中提供的例子能让你更好地了解应该选择什么途径。