Таймеры обратного отсчёта без смещения в React: от setTimeout до чистого CSS
Сравните setTimeout, requestAnimationFrame и технику CSS без использования JavaScript для отсчёта времени в React, включая трюк с единым задержкой, который обеспечивает синхронизацию цифр.
Источники: How I Build a Countdown Timer in React
Карточка-купон с датой истечения должна содержать видимые часы: часы, минуты и секунды, отсчитывающие время до исчезновения акции. Этот элемент кажется незначительным, но он влияет на восприятие качества продукта. Таймер, отставающий от реального времени, скачком прыгающий на две секунды или заставляющий вентилятор ноутбука работать бесшумно, подрывает доверие пользователя ко всему окружающему. В этом руководстве рассматриваются три способа создания такого отсчёта времени в React, подробно объясняется, почему первый способ дает ошибки, и приводится техника, использующая только CSS, которая позволяет управлять сотнями таймеров без использования JavaScript для каждого отсчёта.
Проблема в одном предложении
Каждый купон имеет параметр claim_end_time. На экране должно отображаться оставшееся время до этого момента в формате HH:MM:SS, обновляемом каждую секунду, причём отображение должно остановиться при достижении нуля по истечении срока действия купона.
Арифметика здесь проста. Сложная часть — определить как и когда пересчитывать результат, и именно этот выбор влияет на точность, затраты процессора и возможности при исчерпании времени.
Подход 1: цепочка setTimeout и причины её отклонений
Инстинктивная реализация — это функция, которая обновляет отображение, а затем снова запускает себя через одну секунду.
function tick() {
updateDisplay();
setTimeout(tick, 1000);
}
tick();
Код читается легко и, по-видимому, работает при быстрых тестах. Проблема заключается в способе обработки таймеров. JavaScript на странице выполняется в одном основном потоке, и цикл событий передаёт ему по одному заданию за раз. Колбэк таймера — это просто ещё одно задание, которое стоит в очереди после обработчиков кликов, сетевых колбэков, операций отрисовки и всего того, что React синхронизирует. Значение 1000, которое вы передаёте, является минимальным значением, а не гарантией времени выполнения. Если к моменту истечения секунды поток занят, колбэк будет ждать, и его выполнение может произойти через 1050 мс или 1200 мс. Если вам нужно вспомнить, почему основной поток ведёт себя именно так, прочитайте нашу статью О однопоточной архитектуре JavaScript по сравнению с многопроцессным движком браузера.
То, что превращает периодическую задержку в постоянное отставание, — это цепочка вызовов. Каждый тик запланирует следующий тик внутри себя, поэтому любая задержка передается всем последующим тикам. Ничто не может вернуть эту последовательность в ритм обычных часов. Запустите такой цикл на десять минут, сравните его с Date.now(), и вы увидите, что он отстает.
Таймер, который уменьшает счётчик при каждом тике, измеряет количество вызовов функций, а не пройдённое время, а ограничение работы фоновых вкладок делает разницу ещё больше.
Вы можете скорректировать ситуацию, измеряя, насколько поздно выполняется каждый вызов функции, и соответственно уменьшая следующую задержку. Это сработает, но в итоге вам придётся поддерживать логику коррекции, которая будет противостоять платформе вместо того, чтобы использовать её возможности. В любом случае код всё равно выполняется на главном потоке каждую секунду.
Подход 2: requestAnimationFrame и пересчёт на основе времени
requestAnimationFrame (rAF) просит браузер вызвать вашу функцию непосредственно перед следующей перерисовкой, которая на большинстве дисплеев происходит примерно 60 раз в секунду. Однако сама частота здесь не так важна. Важное изменение касается концепции: вы перестаете спрашивать «Прошла ли ещё одна секунда?», а вместо этого спрашиваете «Сколько времени осталось именно в этот момент?»
function updateTime() {
const now = Date.now();
const remaining = endTime - now;
if (remaining <= 0) {
display('00:00:00');
return;
}
display(format(remaining));
requestAnimationFrame(updateTime);
}
requestAnimationFrame(updateTime);
На каждом кадре оставшееся время вычисляется заново с использованием Date.now() в качестве единственного источника информации. Нет счетчика кадров, нет накопленных интервалов. Если один кадр поступает с задержкой, следующий просто снова считывает время и отображает правильное значение. Ошибки не могут накапливаться, поскольку с одного кадра на другой передается только само окончательное время. Тот же принцип работает и в версии с setTimeout: если каждый обратный вызов пересчитывает время с помощью Date.now() вместо уменьшения счетчика, отображение самостоятельно корректируется даже при задержке обратных вызовов.
Разумное возражение: видимое значение меняется всего один раз в секунду, так зачем выполнять какие-либо операции 60 раз в секунду? В React дорогостоящей частью является не само вычитание, а перерисовка, вызываемая методом setState. Поэтому цикл сравнивает значения на каждой кадре и обновляет состояние только тогда, когда значение действительно меняется за целую секунду.
Обновление состояния только при смене секунды
Вот как это реализовано внутри компонента. Обратите внимание, что endTime представляет собой временную метку в формате Unix в секундах, поэтому перед сравнением с Date.now() она умножается на 1000.
const updateTime = (): void => {
const now = Date.now();
const timeTillStop = endTime * 1000 - now;
if (timeTillStop <= 0) {
setTimeLeft({ hours: 0, minutes: 0, seconds: 0 });
onNoCountdownTimeLeft?.();
return;
}
const totalSeconds = Math.floor(timeTillStop / 1000);
if (totalSeconds !== lastSecondsRef.current) {
lastSecondsRef.current = totalSeconds;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
setTimeLeft({ hours, minutes, seconds });
}
animationRef.current = requestAnimationFrame(updateTime);
};
Два рефа выполняют основную работу. lastSecondsRef хранит информацию о последней отрендеренной секунде. Поскольку это реф, запись в него никогда не приводит к перерендерингу; это просто блок памяти, который сохраняется между кадрами. Поэтому цикл выполняется с частотой кадров, в то время как React рендерит не чаще одного раза в секунду. Опциональная функция обратного вызова onNoCountdownTimeLeft вызывается по истечении времени, предоставляя родительскому компоненту возможность скрыть карточку, перезагрузить данные или отобразить состояние «истек срок».
Второй реф, animationRef, хранит ID, возвращаемый функцией requestAnimationFrame. Функция очистки эффекта использует его для отмены запланированного кадра, когда компонент демонтируется или изменяется значение endTime:
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
Без такой очистки цикл будет продолжать работать даже после того, как пользователь уйдет, вызывая метод setState в демонтированном компоненте и тратя ресурсы процессора. С её использованием компонент работает точно, его отрисовка занимает мало ресурсов, и он самостоятельно корректирует свою работу.
Несколько практических замечаний. Браузеры приостанавливают выполнение обратных вызовов rAF в скрытых вкладках, что на самом деле полезно в данном случае: цикл больше не тратит ресурсы на фоне, а первый кадр после того, как вкладка становится видимой, сразу получает правильное значение. Сравнение на каждом кадре незначительно для одного таймера, но суммируется при использовании десятков карточек.
Подход 3: обратный отсчет, реализованный исключительно с помощью CSS
Версия с rAF всё равно выполняет JavaScript на каждом кадре. Третий подход полностью исключает это: JavaScript вычисляет единственное число при монтировании компонента, а после этого движок анимаций браузера самостоятельно управляет каждым кадром.
Отсчёт времени превращается в анимацию CSS, которая пошагово отображает цифры; она запускается с отрицательной задержкой, чтобы начаться в середине последовательности в правильное время.
Прежде чем продолжить, несколько уточнений относительно производительности. Можно прочитать, что анимации CSS всегда выполняются на потоке композитора и поэтому никогда не могут быть заблокированы. Это верно только для небольшого набора свойств, в основном transform, opacity и некоторых фильтров. Анимация зарегистрированных пользовательских свойств и генерируемого content, как это делается в данной технике, обрабатывается движком стилей браузера на основном потоке. Настоящее преимущество заключается в чём-то другом, но также значительном: здесь нет таймеров, нет состояния React и нет перерисовок при каждом кадре, а собственный планировщик браузера, связывающий прогресс анимации с временной шкалой документа, обеспечивает корректность значений.
Чтобы это сработало, необходимо совместное использование четырех функций CSS. Мы рассмотрим их по одной.
Шаг 1: объявление целых чисел, подлежащих анимации, с помощью @property
Очевидной первой попыткой является анимация свойства content напрямую. Однако это не сработает, поскольку content нельзя интерполировать: браузер не понимает, как строка «59» превращается в «58». Вам нужно число, которое браузер может анимировать, и зарегистрированное пользовательское свойство именно это и обеспечивает.
@property --s {
syntax: '<integer>';
inherits: true;
initial-value: 59;
}
@property регистрирует --s вместе с определенным типом. Указание syntax: '<integer>' сообщает браузеру, что значение является настоящим целым числом, а не неким таинственным токеном, что позволяет его использовать в интерполяции и, следовательно, анимировать. Установка параметра inherits: true позволяет потомкам, включая псевдоэлементы ::after, читать анимированное значение у своего родителя. Это означает, что одна анимация контейнера таймера влияет на каждую цифру внутри него. Чтобы узнать больше о зарегистрированных свойствах и о том, чем они отличаются от обычных пользовательских свойств, ознакомьтесь с нашим руководством CSS пользовательские свойства во время выполнения и @property.
Каждая единица времени имеет свое собственное зарегистрированное свойство. Часы начинаются с 99, что и является верхним пределом для отображения времени этим таймером.
@property --s {
syntax: '<integer>';
inherits: true;
initial-value: 59;
} /* seconds */
@property --m {
syntax: '<integer>';
inherits: true;
initial-value: 59;
} /* minutes */
@property --h {
syntax: '<integer>';
inherits: true;
initial-value: 99;
} /* hours */
Шаг 2: отображение целого числа в формате с заполнением с помощью counter()
Целое число всё ещё должно преобразоваться в текст с заполнением нулями (05, а не 5). Функция counter() отображает значение счётчика внутри content, а её встроенный стиль decimal-leading-zero автоматически добавляет двузначное заполнение.
Связь между пользовательским свойством и счётчиком осуществляется через counter-reset: он устанавливает именованный счётчик на текущее значение свойства, после чего counter() считывает его обратно. Этот фрагмент является CSS, хотя в исходном тексте это описано иначе.
.seconds-digit::after {
counter-reset: sc var(--s); /* set the "sc" counter = current --s value */
content: counter(sc, decimal-leading-zero); /* render as "00"–"59" */
}
.minutes-digit::after {
counter-reset: mc var(--m);
content: counter(mc, decimal-leading-zero);
}
.hours-digit::after {
counter-reset: hc var(--h);
content: counter(hc, decimal-leading-zero);
}
Каждый раз, когда --s меняет значение на новое целое число, движок стилей пересчитывает counter-reset, и значение content псевдоэлемента обновляется в соответствии с этим значением. В процессе обновления не участвуют скрипты.
Шаг 3: изменение значений с помощью steps()
По умолчанию анимация плавно переходит между ключевыми кадрами. Часы не должны плавно двигаться; они должны скачком переходить от одной цифры к другой. Функция синхронизации steps(N, end) делит продолжительность на N равных, дискретных скачков.
@keyframes tick-s {
from {
--s: 59;
}
to {
--s: -1;
}
}
animation: tick-s 60s steps(60, end) infinite;
Шестьдесят скачков в течение шестидесяти секунд означают один скачок в секунду: 59 в начале, 58 через одну секунду, 0 после 59 секунд и снова 59 при начале следующей итерации.
Ключевое слово end (эквивалентно jump-end) размещает каждый скачок в конце своего интервала. В момент t = 0 анимация всё ещё показывает свое значение from, поскольку первый скачок ещё не произошел. Этот момент имеет значение, когда появляются отрицательные задержки: задержка, заканчивающаяся ровно в момент t = 0, отображает значение 59 вместо 58.
Завершение ключевых кадров на значении -1 выглядит странно, но это сделано намеренно. Анимация интерполируется в диапазоне из 60 целых чисел, и из-за параметра jump-end она возвращается к значению from до того, как появится окончательное значение. Следовательно, видимая последовательность состоит ровно из 59 шагов от 0 вниз с четкими шагами по одному.
У каждой единицы времени есть свои ключевые кадры и количество шагов. Минуты включают 60 шагов по 60 секунд каждый, а часы — 100 шагов по одному часу каждый:
@keyframes tick-s {
from {
--s: 59;
}
to {
--s: -1;
}
} /* 60 steps × 1s = 60s */
@keyframes tick-m {
from {
--m: 59;
}
to {
--m: -1;
}
} /* 60 steps × 60s = 1hr */
@keyframes tick-h {
from {
--h: 99;
}
to {
--h: -1;
}
} /* 100 steps × 1hr = 100hr */
Шаг 4: начать анимацию посередине последовательности с отрицательной задержкой
Это главный трюк. Когда значение animation-delay отрицательно, браузер считает, что анимация началась на столько же секунд ранее, поэтому она запускается в тот момент, к которому бы достигла его в обычных условиях.
Предположим, что в текущей минуте осталось 23 секунды. Анимация секунд начинается с 59 и должна уже снизиться на 36 единиц, чтобы показать 23; поэтому задержка -36s помещает её в правильное положение. В общем случае:
delay = -(59 - s) where s = remaining seconds within the current minute
Чтобы применить это ко всем трем единицам измерения, сначала необходимо получить общее количество оставшегося времени в секундах, рассчитанное на основе временной метки окончания:
const remaining = endTime - Math.floor(Date.now() / 1000);
Затем это общее время делится на части для каждой единицы измерения. Число часов ограничивается 99, поскольку анимация часов включает всего 100 шагов:
const s = remaining % 60;
const m = Math.floor(remaining / 60) % 60;
const h = Math.min(Math.floor(remaining / 3600), 99);
Учитывайте это ограничение как условие для расчётов. Купон, срок действия которого истекает через пять дней, будет отображать 99:59:59 и казаться «застрявшим», пока не останется менее 100 часов. Если ваши акции могут длиться столько времени, добавьте единицу измерения «дни» или измените формат отображения.
Синхронизация цифр с помощью одной задержки
Очевидным следующим шагом является вычисление отдельной задержки для каждой единицы времени. Это приводит к серьезной ошибке: секунды переходят на значение 59, в то время как минуты остаются прежними слишком долго.
Почему отдельные задержки расходятся
Возьмем значение remaining = 125, что соответствует 0 часам, 2 минутам и 5 секундам. Независимое вычисление задержек для секунд и минут дает следующий результат:
delayS = -(59 - 5) = -54s → seconds virtual start: now - 54s
delayM = -(59 - 2) × 60 = -3420s → minutes virtual start: now - 3420s
Каждая анимация измеряет свои скачки относительно локального виртуального времени начала. События, связанные с секундами, происходят каждую секунду, начиная с момента now - 54s; события, связанные с минутами, происходят каждые 60 секунд, начиная с момента now - 3420s. Поскольку точки отсчета различаются, границы смены значений приходятся на несвязанные моменты по часам. Здесь цифра секунд достигает значения, после которого происходит перезапись:
now - 54 + 60 = now + 6s
Однако цифра минут начинает меняться только тогда, когда:
now - 3420 + (58 × 60) = now - 3420 + 3480 = now + 60s
Таким образом, через шесть секунд счетчик секунд сбрасывается, в то время как минуты меняются через целую минуту. В течение 54 секунд на дисплее отображается 01:59, хотя фактическое значение меньше 01:00. Время ошибочно показывается почти каждую минуту.
Общее начальное значение
Решение заключается в использовании общего начального значения для всех трех анимаций, которое получается путем объединения смещений в одну единицу задержки:
const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));
// = -( 99×3600 + 57×60 + 54 )
// = -(356400 + 3420 + 54)
// = -359874s
Теперь каждая анимация измеряется от момента now - 359874s. Границы их шагов являются кратными длительности собственного шага, начинаясь от этой отправной точки. Посмотрите пример: в окне в 60 секунд анимация секунд находится на позиции 54, поэтому следующая граница наступает через 6 секунд. В окне в 3600 секунд анимация минут находится на позиции 3474, а следующая граница — на 3480, то есть также через 6 секунд. Оба показателя меняются одновременно в момент now + 6s и остаются синхронизированными каждые 60 секунд после этого.
Как одно число кодирует три цифры
Совокупная задержка работает как число смешанной основы, подобно тому как формат HH:MM:SS объединяет три поля в одну строку. Кодирование осуществляется на основе трех компонентов:
|delay| = (99 - h) × 3600 ← hours field, each unit = 3600s
+ (59 - m) × 60 ← minutes field, each unit = 60s
+ (59 - s) ← seconds field, each unit = 1s
Декодирование происходит автоматически для каждой анимации путем учета только её собственного окна:
seconds position = |delay| mod 60 → display 59 - position
minutes position = floor((|delay| mod 3600) / 60) → display 59 - position
hours position = floor(|delay| / 3600) → display 99 - position
Запуск примера с использованием декодера подтверждает это. 359,874 по модулю к 60 равно 54, поэтому секунды отображаются как 59 − 54 = 5. 359,874 по модулю к 3,600 равно 3,474; при делении на 60 с округлением вниз получается 57, так что минуты отображаются как 59 − 57 = 2. 359,874 при делении на 3,600 с округлением вниз дает 99, следовательно часы отображаются как 99 − 99 = 0. Результат отображения — 00:02:05, что соответствует действительности.
Анимации не могут взаимно мешать друг другу, поскольку каждое поле представляет собой точное кратное циклу следующего поля: часть, отвечающая за часы, всегда является кратным числу 3,600, а часть, отвечающая за минуты — кратным числу 60, поэтому ни одна из них не мешает отображению данных ниже.
Чистая остановка в 00:00:00
Анимации тиков используют итерацию типа infinite, поэтому сами по себе они никогда не прекращаются. Когда цикл секунд завершается, он возвращается к значению 59, и таймер, достигший нуля, снова начнет отсчет в обратном направлении.
Решение заключается в ещё одной анимации, которая запускается один раз именно в момент истечения задержки, устанавливая все три свойства в ноль. Этот блок также является CSS, несмотря на своё первоначальное название:
@keyframes freeze {
to {
--s: 0;
--m: 0;
--h: 0;
}
}
.timer {
animation-name: tick-s, tick-m, tick-h, freeze;
animation-duration: 60s, 3600s, 360000s, 1ms;
animation-timing-function: steps(60, end), steps(60, end), steps(100, end),
step-start;
animation-iteration-count: infinite, infinite, infinite, 1;
animation-fill-mode: none, none, none, forwards;
}
JavaScript присваивает общую задержку трем анимациям с интервалами, а оставшееся время в качестве положительной задержки — анимации замораживания:
el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;
Здесь вместе работают три фактора:
step-start(то же, чтоsteps(1, jump-start)) переходит к последнему ключевому кадру сразу по началу анимации, а не в конце её 1-миллисекундного времени действия, так что все три значения становятся 0 в момент истечения задержки.animation-fill-mode: forwardsсохраняет применение последнего ключевого кадра после окончания анимации, поэтому значения ноль остаются неизменными вечно.
animation-name имеет значение: когда несколько анимаций задают одно и то же свойство, побеждает та, что указана позже. Поскольку freeze находится в конце, его значение нуля перезаписывает анимации обновления, которые продолжают работать в фоновом режиме без видимого эффекта.Если купон уже истек при загрузке страницы, оставшееся время равно 0, задержка замораживания составляет 0s, и таймер сразу показывает ноль.
Что стоит вам подход с CSS
Дизайн без использования JavaScript имеет реальные ограничения, и именно они должны влиять на ваше решение:
- Отсутствие механизма истечения срока. Нет обратного вызова, события или изменения состояния при достижении таймером нуля. Если истечение срока должно запустить вызов API, скрыть карточку или отобразить что-то другое, потребуется дополнительный одноразовый
setTimeout, что возвращает частичную простоту. - Ограниченный стиль. Функция
counter()возвращает обычный текст. Шрифт, цвет и макет находятся полностью под вашим контролем, но всё, что зависит от текущего значения в JavaScript — например, смена цвета на красный при остатке менее 60 секунд или добавление класса при достижении определённого порога — требует дополнительных усилий. - Точность до целой секунды. При инициализации оставшееся время округляется до целой секунды, поэтому видимые отметки могут отклоняться на до одной секунды от реальных границ секунд. Это не имеет большого значения для рекламных обратных отсчётов, но это не часы с высокой точностью.
- Поддержка браузерами. Данная техника основана на
@propertyи анимированных зарегистрированных свойствах; прежде чем полагаться на неё, убедитесь в её поддержке для ваших браузеров, используя актуальные данные о совместимости.
В качестве замены — односторонний таймер отсчёта, который должен лишь отсчитывать время и останавливаться, — он крайне эффективен: по одному набору анимаций на карточку, без JavaScript при каждом отсчёте, без смещений и без необходимости очистки при демонтировании. Он особенно хорошо справляется, когда страница отображает сетку из сотен карточек-ваучеров одновременно.
Компонент React для CSS-таймера
Версия на CSS меняет форму компонента. В ней нет состояния, нет цикла анимации и нет необходимости в очистке. Компонент один раз считывает время, задаёт одно свойство стиля и отображает маркировку. Для его работы требуются всего два хука из React:
import { useEffect, useRef } from 'react';
Приведённый ниже компонент вычисляет оставшееся время в секундах, разделяет его на единицы, формирует общую задержку и записывает четыре значения задержки в элемент. Атрибут type лишь меняет визуальный стиль цифр.
interface CSSCountdownTimerProps {
endTime: number; // Unix timestamp in seconds, same as existing prop
type?: 'solid' | 'regular';
}export function CSSCountdownTimer({
endTime,
type = 'solid',
}: CSSCountdownTimerProps) {
const timerRef = useRef<HTMLDivElement>(null); useEffect(() => {
const el = timerRef.current;
if (!el) return; const now = Math.floor(Date.now() / 1000);
const remaining = Math.max(0, endTime - now); const s = remaining % 60;
const m = Math.floor(remaining / 60) % 60;
const h = Math.min(Math.floor(remaining / 3600), 99); const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s)); el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`; // No cleanup needed.
// There are no JS timers, no rAF loops, no subscriptions.
// CSS animations stop automatically when the element is removed from the DOM.
}, [endTime]); return (
<div ref={timerRef} className={`timer ${type}`}>
<span
className={type === 'solid' ? 'digit hours-digit' : 'hours-digit'}
/>
<span className="sep">:</span>
<span
className={type === 'solid' ? 'digit minutes-digit' : 'minutes-digit'}
/>
<span className="sep">:</span>
<span
className={type === 'solid' ? 'digit seconds-digit' : 'seconds-digit'}
/>
</div>
);
}
Как отмечено в комментариях, очистка не требуется: когда элемент покидает DOM, его анимации уходят вместе с ним. Стоит рассмотреть два улучшения. Во-первых, поскольку useEffect выполняется после отрисовки браузером, на первом кадре анимации могут на короткое время отображаться в своих начальных положениях до применения задержки; useLayoutEffect или вычисление задержки во время отрисовки и передача её в виде встроенного стиля позволяют избежать этого момента отображения. Во-вторых, эффект зависит от endTime, поэтому при изменении свойства задержки пересчитываются, и анимации снова правильно позиционируются.
Выбор между тремя подходами
Простое правило принятия решений покрывает большинство ситуаций:
- Если при истечении таймера необходимо выполнить какое-либо действие, такое как обновление состояния, повторный запрос или вызов API, используйте
requestAnimationFrameс пересчётом времени на основе часов. Это даёт полный контроль и подходящее место для обработчика истечения времени. - Если на странице отображается множество таймеров, но ни один из них не запускает логику, обратитесь к CSS-технике и позвольте браузеру выполнять эту работу.
- Если вы используете простую цепочку
setTimeout, которая уменьшает счётчик, замените её. Такой подход неточен и по-прежнему потребляет ресурсы основного потока каждую секунду, поэтому сочетает в себе недостатки обоих альтернативных решений. Эти два подхода также можно объединить: CSS для отображения и один общий таймер для логики истечения времени.
Основные выводы
- Задержки таймеров являются минимальными; для определения оставшегося времени используйте
Date.now()при каждом обновлении, вместо подсчёта тактов, и отклонения исчезнут.
@property, функции counter() для форматирования текста, функции steps() для дискретных скачков и отрицательной задержке для начала работы посреди последовательности.freeze для фиксации нулей.