计时器的选择:Animations 精确计时,Timeout 虚假计时

1,737 阅读6分钟

前言

当你处理需要精确的可视化时间表示时,你会发现你无法准确确定代码何时实际执行。

在某些情况下获取的时间还挺准,但它不精确

动画举例来说,你不仅有很多的元素要处理,你需要来保证一定程度的时间精度,从而排除漂移的可能性,元素不合拍的可能性。

JavaScript 时序问题:有序但不合拍

JavaScript 中,每个任务都经过一个队列。您的代码、用户交互、网络事件。每个任务都等待轮到事件循环执行。这样,它保证事情按顺序发生。

当事件触发或计时器到期时,您在回调中定义的任务将进入队列。一旦事件循环到达它,您的代码就会被执行。

我们将在动画的背景下来聊聊它是如何运作的。

setTimeout 或 setInteval

一旦使用 setTimeout 或 setInteval 制作动画,下面就是需要考虑的三个点:

低精度

我们可以准确定义在将任务放入队列之前超时应该等待多长时间。但是我们无法预测的是此刻队列中的内容。通过计算所需时间和代码执行的实际时间的差异,可以在下一个计时器时自行调整计时器等待时间。

它大部分都有效,但如果刻度之间所需的是两位数毫秒或更短的时间,则它很少会正确。此外,它的性质(它会根据执行进行调整)使得很难计算

它将精确显示调用时的状态,但不会精确显示状态更改的时刻

那是因为 setTimeout 保证在将事物放入队列之前的最小延迟。但是没有办法知道队列中已经有什么。

堆积

如果低精度适合,还要注意堆积。如果有很多任务要处理的定时任务,那么你原本打算在时间上间隔开的任务可能会一次全部执行——全部挂起

例如:当网站实现随机翻转图块,会出现一个错误,是由未激活标签引起的。因为每个图块都保持着自己的计时器,所以当标签变成激活时,它们都同时运行。

注意顶行块是如何延迟的,然后一次翻转三个。

拥挤的队列

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

所以我推荐使用requestAnimationFrame,他将解决这些问题。

 requestAnimationFrame 来管理动画

TIMELINE 时间轴

Animations API 引入了时间轴的概念。默认情况下,所有动画都绑定到文档 document 的时间轴。这意味着动画共享相同的内部时钟——一个在页面加载时开始的时钟。

共享时钟使我们能够协调动画。无论是特定的节奏还是模式,您都无需担心某些东西会拖延或超前。

START TIME 开始时间 

要使动画在特定时刻开始,您可以使用 startTime  属性。 startTime 的值是从页面加载开始计算的,以毫秒为单位。开始时间设置为 1000.5 的动画将在文档时间轴的 currentTime 属性等于 1000.5 时开始播放。

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

另一个有用的事情是开始时间也可以是负数。您可以自由地将其设置为未来的某个时刻或过去的某个时刻。

将该值设置为 -1000 ,您的动画状态就像在页面加载时已经播放了一秒钟一样。对于用户来说,好像动画在他们想到访问您的页面之前就已经开始播放了。

演示 

我设置了一个演示。一个模拟时钟,一个数字时钟,它比任何其他指标都更依赖于时间精度。此演示中的某些内容非常简单,足以演示基础知识。还有一些棘手的部分向您展示了这种方法的不足之处。

模拟时钟的运动非常简单。三个指针做同样的单圈旋转。因为时钟是一种精确的仪器,所以我让秒针和分针在它们相应的值发生变化的那一刻准确地改变它们的位置。这有助于说明它们的变化与下方数字时钟上的模拟时钟一样。

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;
});

每个指针的动画在它们旋转的时间长短和划分的步数上各不相同。秒针在六万毫秒内转一圈。分针比这慢六十倍。时针——因为它是一个二十四小时制的时钟——在与分针转二十四圈的时间相同的时间内走一圈。

为了将时钟指针的操作与相同的时间概念联系起来(以确保分针在秒针完成旋转的那一刻准确地更新其位置),我使用了 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 日以来的毫秒数),从中剥离了整天,并根据与世界标准时间。这给我留下了自今天开始以来已经过去的毫秒数。这是我的时钟唯一需要显示它注定要显示的数据:小时分钟

要将该值转换为文档的范围,我需要根据从加载此演示页面到 Date.now() 调用所经过的时间来调整它。为此,我从文档的 currentTime 中减去它。将结果应用于动画意味着这个特定的动画从午夜开始就一直在播放。将其应用于所有动画,您将获得一个从下午开始播放的演示。

这种方法的缺点

能够在没有任何复杂计算的情况下实现这种精度的东西是无异议的。它只适用于你试图实现的东西可以用关键帧定义的情况。

Animations API 的替代方法是使用 requestAnimationFrame 或 performance.now() 。有了这些,您需要自己计算插值。

如果您选择使用 Animations API,您将不得不面对这样一个事实,即关键帧定义中的内容有所不同。有些东西可能几乎不需要定义,因为它们天生适合。其他人需要解决方法。这些解决方法是否会增加您尝试实施的成本,应该决定您的方法。

某些属性不会像您希望的那样进行动画

如果你仔细观察,模拟时钟上的每根指针都有一个影子。它们增加了一些深度并使时钟看起来更好。使用 box-shadow 或 filter CSS 属性可以轻松应用阴影。它在一定程度上是可动画的,但不足之处在于阴影方向的动画。您不用角度值设置它,而是通过坐标设置它。

分裂并不总是平等分

时钟是用单个数字元素实现的。三个代表毫秒,两个代表秒,两个代表分钟。小时数字不符合循环关键帧的逻辑。

循环不规则:因为二十开头只有四个小时。我不得不引入一个数字来解决这个问题。宽数字与普通数字具有相同的逻辑,只是它支持从0-99的数字——这对于这种情况来说已经足够了。最后,数字时钟的小时指示器重新使用了与模拟时钟上的时针相同的计时选项。

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

现在我有了数字元素,我重新使用它来显示日期,只是将持续时间从几小时增加到几天。

这种方法的问题在于,月份的长度与演示中使用的相同长度的动画不能完美匹配。你看,我们今天使用的日历有一段混乱的历史,很难适应一个简单的循环。必须在单个关键帧中定义公历的所有例外情况。我会跳过这样做。我在这里向您展示一个解决方法。

我选择依赖 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 promise 创建一个新日期。

该方法存在本文开头所述的缺点。但在几个月的情况下,我们可能会因为轻微的不精确而闭上眼睛。

结论

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

  • Animations API 附带强大的工具,可让您减少应用程序和您必须做的工作量。它还具有为实现新型应用程序打开可能性的精度。

  • 不仅在动画领域。它是否适合您的情况是您根据自己的需要决定的。我希望我在本文中提供的示例能让您更好地了解选择什么路径。

全文完。

谢谢!

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 4 天

点击查看活动详情