サンプル
playground
See the Pen interval example. by juner clarinet (@juner) on CodePen.
ソース
<div id="ui">
<div id="inner-ui">
<label>count down start:<input type="number" value="100" min="1" id="countdownStartInput" /></label>
<label>interval:<input type="number" value="1000" min="1" id="intervalInput" /></label>
<label>mode:<select id="modeSelect"></select></label>
<label>count downs:<select id="countDownSelect"></select></label>
<label><input type="checkbox" checked id="autoStart" />auto start</label>
</div>
<button id="startButton">start</button>
<button id="button"></button>
</div>
<ul id="target">
<template id="template">
<li><span class="counter"></span><button class="delete">🗑️</button></li>
</template>
</ul>
<style>
:root {
display: flex;
flex-flow: column nowrap;
height: calc(100% - 2px);
--gap: 0.5ex;
gap: var(--gap);
padding: 0.1ex;
& > body {
display: contents;
& > :where(#ui) {
display: flex;
flex-flow: row nowrap;
gap: var(--gap);
& > :where(#inner-ui) {
display: flex;
flex: 1 1 auto;
flex-flow: row wrap;
gap: var(--gap);
height: min-content;
& > :not(#button) {
height: min-content;
}
}
}
:where(#button)::before {
display: inline;
content: "set count down";
}
& :where(#inner-ui):has(#autoStart:checked) {
& ~ :where(#button)::before {
content: "start count down";
}
}
:where(#startButton.empty) {
visibility: hidden;
}
}
input:not([type="checkbox"], [type="radio"]) {
width: 5em;
}
label {
display: flex;
min-height: 1em;
flex-flow: row nowrap;
select option[disabled] {
pointer-events: none;
display: none;
}
}
ul {
display: flex;
flex-flow: row wrap;
padding: 0;
margin: 0;
gap: var(--gap);
align-items: flex-start;
justify-content: flex-start;
li {
text-align: end;
--base: 0;
&.mode-setTimeout {
--base: -0.5;
--name: "setTimeout";
}
&.mode-setInterval {
--base: -0.45;
--name: "setInterval";
}
&.mode-requestAnimationFrame {
--base: -0.4;
--name: "requestAnimationFrame";
}
&.mode-queueMicrotask {
--base: -0.35;
--name: "queueMicrotask";
}
&.mode-requestIdleCallback {
--base: -0.3;
--name: "requestIdleCallback";
}
&.mode-Worker\:setInterval {
--base: -0.25;
--name: "Worker:setInterval";
}
&.mode-Worker\:setTimeout {
--base: -0.2;
--name: "Worker:setTimeout";
}
&.mode-Worker\:requestAnimationFrame {
--base: -0.15;
--name: "Worker:requestAnimationFrame";
}
&.mode-postTask\:user-visible {
--base: 0.2;
--name: "postTaask:user-visible";
}
&.mode-postTask\:user-blocking {
--base: 0.3;
--name: "postTask:user-blocking";
}
&.mode-postTask\:background {
--base: 0.5;
--name: "postTask:background";
}
&.counter-time {
--border-color: black;
}
&.counter-number {
--border-color: gray;
}
list-style-type: none;
border: 1px solid var(--border-color);
padding: 0.5ex;
background-color: oklab(
100% var(--base) calc(1 - (var(--value) / var(--max)))
);
&::before {
display: block;
content: var(--name);
font-size: 0.9ex;
background-color: var(--border-color);
color: white;
padding-inline: 0.5ex;
position: relative;
margin-block-start: -0.7ex;
margin-inline-start: -0.7ex;
width: min-content;
white-space: nowrap;
}
.counter {
margin-inline-end: 0.5ex;
}
}
}
}
</style>
<script type="module">
// #region constant
const constants = Object.freeze({
countStart: 100,
start: 1000
});
// #endregion
// #region autoStart
let startWaiter = [];
// #endregion
// #region startButton
startButton.addEventListener("click", startButtonClick);
function startButtonClick() {
for (const start of [...startWaiter]) start();
startWaiter = [];
refleshStartButtonStatus();
}
refleshStartButtonStatus();
function refleshStartButtonStatus() {
const hasWaiter = startWaiter.length > 0;
const list = startButton.classList;
if (hasWaiter) list.remove("empty");
else list.add("empty");
}
// #endregion
// #region button event
button.addEventListener("click", buttonClick);
function buttonClick() {
const controller = new AbortController();
const signal = controller.signal;
const countStart = inputToNum(countdownStartInput, constants.countStart);
const start = inputToNum(intervalInput, constants.start);
const [mode, iterator] = modeToInterval(modeSelect);
const [countDown, counter] = countDownToActor(countDownSelect);
const enableAutoStart = autoStart.checked;
const $template = template.content.cloneNode(true);
const $li = $template.querySelector("li");
const c = counter(countStart, start);
const i = iterator.bind(null, start, { signal });
$li.classList.add(`mode-${mode}`);
$li.classList.add(`counter-${countDown}`);
$li.querySelector(".counter").innerText = `${countStart}/${countStart}`;
$li.querySelector(".delete").addEventListener("click", (e) => {
e.preventDefault();
if (!controller.signal.aborted) controller.abort();
$li.remove();
});
$li.style.setProperty("--value", countStart);
$li.style.setProperty("--max", countStart);
target.insertAdjacentElement("afterbegin", $li);
if (enableAutoStart) {
startFunction();
return;
}
startWaiter.push(startFunction);
refleshStartButtonStatus();
function startFunction() {
{
const {
value: [i, cs],
done
} = c.next();
$li.querySelector(".counter").innerText = `${i}/${cs}`;
}
(async ({ $li, iterator, counter, signal }) => {
for await (const _ of iterator()) {
if (signal.aborted) break;
const { value: v, done } = counter.next();
if (done) break;
const [i, c] = v;
$li.querySelector(".counter").innerText = `${i}/${c}`;
$li.style.setProperty("--value", i);
}
$li.remove();
})({ $li, iterator: i, counter: c, signal });
}
}
// #endregion
/**
* input element to number value.
* @param {HTMLInputElement} elem
* @param {number} init
* @return {number}
*/
function inputToNum(elem, init) {
const num = elem.valueAsNumber;
if (!isNaN(num)) return num;
return init;
}
const modeToInterval = (() => {
/** interval mode */
const intervals = new Map([
["setInterval", setInterval],
[
"setTimeout",
(delay, option) => toIterater(setTimeout.bind(null, delay, option))
],
[
"requestAnimationFrame",
(delay, option) =>
toIterater(requestAnimationFrame.bind(null, delay, option))
],
[
"queueMicrotask",
(delay, option) => toIterater(queueMicrotask.bind(null, delay, option))
],
["Worker:setInterval", workeredSetInterval],
[
"Worker:setTimeout",
(delay, option) =>
toIterater(workeredSetTimeout.bind(null, delay, option))
],
[
"Worker:requestAnimationFrame",
(delay, option) =>
toIterater(workeredRequestAnimationFrame.bind(null, delay, option))
]
]);
if (typeof globalThis?.requestIdleCallback === "function") {
intervals.set("requestIdleCallback", (delay, option) =>
toIterater(requestIdleCallback.bind(null, delay, option))
);
}
if (typeof globalThis?.scheduler?.postTask === "function") {
intervals.set("postTask:user-blocking", (delay, option) =>
toIterater(postTask.bind(null, "user-blocking", delay, option))
);
intervals.set("postTask:user-visible", (delay, option) =>
toIterater(postTask.bind(null, "user-visible", delay, option))
);
intervals.set("postTask:background", (delay, option) =>
toIterater(postTask.bind(null, "background", delay, option))
);
}
for (const key of intervals.keys())
modeSelect.insertAdjacentHTML(
"beforeend",
`<option value="${key}">${key}</option>`
);
return modeToInterval;
/**
* @param {HTMLSelectElement} modeElement
* @return {[string, (start: number, option:{signal: AbortSignal} = {}) => AsyncIterator<void>]}
*/
function modeToInterval(modeElement) {
const value = modeElement.value;
if (intervals.has(value)) return [value, intervals.get(value)];
throw new Error(`not found intervals '${value}' type.`);
}
/**
* shorthand scheduler.postTask(callback, {delay, priority}) to Promise<void>
* @param {string} priority
* @param {null|number} delay
* @return {Promise<void>}
*/
function postTask(priority, delay, { signal } = {}) {
if (typeof globalThis?.scheduler?.postTask !== "function")
throw new Error("not support. scheduler.postTask");
return scheduler
.postTask(() => undefined, {
delay,
priority,
signal
})
.catch(() => undefined);
}
/**
* setTimeout's Promise<void> version.
* @param {number|null} delay = null - not work
* @return {Promise<void>}
*/
function setTimeout(delay = null, { signal } = {}) {
const d = Promise.withResolvers();
const clear = globalThis.setTimeout(d.resolve, delay);
if (signal) {
signal.addEventListener("abort", abort);
d.promise.finally(() => signal.removeEventListener("abort", abort));
}
return d.promise;
function abort() {
clearTimeout(clear);
d.resolve();
}
}
/**
* setTimeout's in Web Worker version.
* @param {number|null} delay = null - not work
* @return {Promise<void>}
*/
function workeredSetTimeout(delay = null, { signal } = {}) {
const d = Promise.withResolvers();
if (signal) {
signal.addEventListener("abort", abort);
d.promise.finally(() => signal.removeEventListener("abort", abort));
}
const source = `
globalThis.addEventListener('message', msg =>
globalThis.setTimeout(() => globalThis.postMessage(null), msg.data)
)`;
const url = `data:text/javascript;base64,${btoa(source)}`;
const worker = new Worker(url);
worker.addEventListener("message", () => {
d.resolve();
});
worker.postMessage(delay);
return d.promise;
function abort() {
worker?.terminate();
worker = null;
d.resolve();
}
}
/**
* requestAnimationFrame's Promise<void> version.
* @param {number|null} delay = null
* @return {Promise<void>}
*/
async function requestAnimationFrame(delay = null, { signal } = {}) {
const raf = globalThis.requestAnimationFrame;
if (signal?.aborted) return Promise.resolve();
const d = Promise.withResolvers();
const clear = raf(d.resolve);
try {
if (signal) {
signal.addEventListener("abort", abort);
}
await d.promise;
} finally {
if (signal) {
signal.removeEventListener("abort", abort);
}
}
function abort() {
cancelAnimationFrame(clear);
d.resolve();
}
}
/**
* requestAnimationFrame's in Web Worker version.
* @param {number|null} delay = null - not work
* @return {Promise<void>}
*/
function workeredRequestAnimationFrame(delay = null, { signal } = {}) {
const d = Promise.withResolvers();
if (signal) {
signal.addEventListener("abort", abort);
d.promise.finally(() => signal.removeEventListener("abort", abort));
}
const source = `
globalThis.addEventListener('message', () =>
globalThis.requestAnimationFrame(() => globalThis.postMessage(null))
)`;
const url = `data:text/javascript;base64,${btoa(source)}`;
const worker = new Worker(url);
worker.addEventListener("message", () => {
d.resolve();
});
worker.postMessage(null);
return d.promise;
function abort() {
worker?.terminate();
worker = null;
d.resolve();
}
}
/**
* requestIdleCallback's Promise<void> version.
* @param {number|null} delay = null
* @return {Promise<void>}
*/
async function requestIdleCallback(delay = null, { signal } = {}) {
let timeout = delay;
const ric = globalThis.requestIdleCallback;
if (signal?.aborted) return Promise.resolve();
const d = Promise.withResolvers();
const clear = ric(d.resolve, { timeout });
try {
if (signal) {
signal.addEventListener("abort", abort);
}
await d.promise;
} finally {
if (signal) {
signal.removeEventListener("abort", abort);
}
}
function abort() {
d.resolve();
cancelIdleCallback(clear);
}
}
/**
* queueMicrotask's Promise<void> version.
* @param {number|null} delay = null - not work
* @return {Promise<void>}
*/
async function queueMicrotask(delay = null, { signal } = {}) {
const raf = globalThis.queueMicrotask;
const d = Promise.withResolvers();
raf(d.resolve);
try {
if (signal) {
signal.addEventListener("abort", abort);
}
await d.promise;
} finally {
if (signal) {
signal.removeEventListener("abort", abort);
}
}
if (signal?.aborted) {
return;
}
function abort() {
d.resolve();
}
}
/**
* setInterval's AsyncIterator<void> version.
* @param delay = null
* @return {AsyncIterator<void>}
*/
async function* setInterval(delay = null, { signal } = {}) {
const intervals = [Promise.withResolvers()];
const clear = globalThis.setInterval(() => {
let last = intervals[intervals.length - 1];
last.resolve();
intervals.push(Promise.withResolvers());
}, delay);
if (signal) signal.addEventListener("abort", abort);
try {
do {
const first = intervals[0];
yield await first.promise;
intervals.shift();
} while (intervals.length);
} finally {
abort();
if (signal) signal.removeEventListener("abort", abort);
}
function abort() {
clearInterval(clear);
if (intervals.length) {
do {
const v = intervals.pop();
if (v) v.resolve();
} while (intervals.length);
}
}
}
/**
* setInterval's in Web Worker version.
* @param delay = null
* @return {AsyncIterator<void>}
*/
async function* workeredSetInterval(delay = null, { signal } = {}) {
const intervals = [Promise.withResolvers()];
if (signal) signal.addEventListener("abort", abort);
const source = `
globalThis.addEventListener('message', msg =>
globalThis.setInterval(() => globalThis.postMessage(null), msg.data)
)`;
const url = `data:text/javascript;base64,${btoa(source)}`;
let worker = null;
try {
worker = new Worker(url);
worker.addEventListener("message", () => {
let last = intervals[intervals.length - 1];
last.resolve();
intervals.push(Promise.withResolvers());
});
worker.postMessage(delay);
do {
const first = intervals[0];
yield await first.promise;
intervals.shift();
} while (intervals.length);
} finally {
abort();
}
function abort() {
worker?.terminate();
worker = null;
if (intervals.length) {
do {
const v = intervals.pop();
if (v) v.resolve();
} while (intervals.length);
}
}
}
/**
* return Promise<T> method to AsyncIterator<T>
* @return {AsyncIterator<T>}
*/
async function* toIterater(callback) {
while (true) {
yield await callback();
}
}
})();
const countDownToActor = (() => {
/** count down pattern */
const countDowns = new Map([
[
"time",
function* (countStart, milliseconds) {
const startTime = new Date().getTime();
let currentTime = startTime;
let count = toCount();
do {
currentTime = new Date().getTime();
count = toCount();
yield [count, countStart];
} while (count > 0);
function toCount() {
return Math.max(
0,
Math.ceil(
(countStart * milliseconds - (currentTime - startTime)) /
milliseconds
)
);
}
}
],
[
"number",
function* (startCount, millisecondss) {
let count = startCount;
while (count >= 0) {
yield [count--, startCount];
}
}
]
]);
for (const key of countDowns.keys())
countDownSelect.insertAdjacentHTML(
"beforeend",
`<option value="${key}">${key}</option>`
);
return countDownToActor;
/**
* get counter
* @param {HTMLSelectElement} countDownElement
* @return {[string,(startCount:number, millisecondss:number) => Iterator<[number, number]>]}
*/
function countDownToActor(countDownElement) {
const value = countDownElement.value;
if (countDowns.has(value)) return [value, countDowns.get(value)];
throw new Error(`not found countDowns '${value}' type.`);
}
})();
</script>
タスク系関数
setInterval
指定した コールバックを 指定 ミリ秒毎にキュー登録する。 clearInterval でキャンセルが可能。
async iterator に対応させるなら次のような感じ
/**
* setInterval's return AsyncIterator<void> version.
*
* @return {AsyncIterator<void>}
*/
async function* startInterval({ signal, delay, args } = {}) {
if (signal) {
signal.throwIfAborted();
signal.addEventListener("abort", abort);
}
const intervals = [Promise.withResolvers()];
const clear = globalThis.setInterval((...args) => {
let last = intervals[intervals.length - 1];
last.resolve(args);
intervals.push(Promise.withResolvers());
}, delay, ...(args ?? []));
try {
do {
const first = intervals[0];
yield await first.promise;
intervals.shift();
} while (intervals.length);
} finally {
abort();
if (signal) signal.removeEventListener("abort", abort);
}
function abort() {
globalThis.clearInterval(clear);
if (intervals.length) {
do {
const v = intervals.pop();
if (signal.reason) v.reject(signal.reason)
else v.resolve();
} while (intervals.length);
}
}
}
使うときはこんな感じ
// #region キャンセルされる前提の場合
{
const signal = AbortSignal.timeout(10000);
try {
for await (const [v] of startInterval({delay:1000, signal, args:['called']}))
console.log(v);
} catch (e) {
if (signal.reason !== e) throw e;
}
}
// #endregion
// #region break される場合
{
let i = 0;
for await (const _ of startInterval({delay:1000})) {
console.log('called: %o', i);
if (i++ > 3) break;
}
}
// #endregion
setTimeout
指定コールバックを 指定ミリ秒後にキューに登録する。 clearTimeout でキャンセルが可能。
戻り値 Promise<void>
対応するならこんな感じ
/**
* setTimeout's return Promise<void> version.
*/
function startTimeout({signal, delay, args} = {})
{
if (signal) {
if (signal.reason) return Promise.reject(signal.reason);
signal.addEventListener("abort", abort);
}
const d = Promise.withResolvers();
if (signal) {
d.promise.finally(() => signal.removeEventListener("abort", abort));
}
const clear = globalThis.setTimeout((...args) => d.resolve(args), delay, ...(args ?? []));
return d.promise;
function abort() {
globalThis.clearInterval(clear);
d.reject(signal.reason);
}
}
使うときはこんな感じ
// #region 通常利用
{
await startTimeout({delay:1000});
}
// #endregion
// #region 正常完了 / キャンセルで値を変える
{
const signal = AbortSignal.timeout(1000);
const result = await startTimeout({delay: 10000, signal})
.then(() => 'ok')
.catch(() => 'abort');
console.log({result});
// -> abort
}
// #endregion
// #region キャンセルを無視して続行する
{
const signal = AbortSignal.timeout(1000);
await startTimeout({delay:10000, signal}).catch(() => undefined);
}
// #endregion
window.requestAnimationFrame
指定コールバックを 次の再描画アニメーションを更新する際に 呼び出す。 cancelAnimationFrame でキャンセルが可能
戻り値 Promise<void>
対応するならこんな感じ
/**
* requestAnimationFrame's return Promise<void> version.
*/
function waitAnimationFrame({signal} = {})
{
if (signal) {
if (signal.reason) return Promise.reject(signal.reason);
signal.addEventListener("abort", abort);
}
const d = Promise.withResolvers();
if (signal) {
d.promise.finally(() => signal.removeEventListener("abort", abort));
}
const clear = globalThis.requestAnimationFrame(d.resolve);
return d.promise;
function abort() {
globalThis.cancelAnimationFrame(clear);
d.reject(signal.reason);
}
}
使うときはこんな感じ
// #region 通常利用
{
await waitAnimationFrame();
}
// #endregion
// #region 正常完了 / キャンセルで値を変える
{
const signal = AbortSignal.timeout(1000);
const result = await waitAnimationFrame({signal})
.then(() => 'ok')
.catch(() => 'abort');
console.log({result});
// -> ok
}
// #endregion
// #region キャンセルを無視して続行する
{
const signal = AbortSignal.timeout(1000);
await waitAnimationFrame({signal}).catch(() => undefined);
}
// #endregion
window.requestIdleCallback
指定時間内に アイドル状態になったら もしくは 指定タイムアウト時間が経過したなら 呼び出す。 cancelIdleCallback でキャンセルが可能
2024/08/22 時点では Safari 以外は実装されており、 Safari の TP で実装されている との caniuse 情報がある
戻り値 Promise<IdleDeadline>
対応させるのであれば次の様な感じ
function waitIdle({signal, ...options} = {})
{
if (signal) {
if (signal.reason) return Promise.reject(signal.reason);
signal.addEventListener("abort", abort);
}
const d = Promise.withResolvers();
if (signal) {
d.promise.finally(() => signal.removeEventListener("abort", abort));
}
const clear = globalThis.requestIdleCallback(d.resolve, options);
return d.promise;
function abort() {
globalThis.cancelIdleCallback(clear);
d.reject(signal.reason);
}
}
使うときはこんな感じ
// #region 通常利用 (アイドルになる OR 1秒経過で完了する)
{
const deadline = await waitIdle({timeout: 1000});
// timeout かどうか
console.log(deadline.didTimeout);
// 残りの timeout までの時間 (didTimeout が true なら 0固定)
console.log(deadline.timeRemaining());
}
// #endregion
// #region AbortSignal 経由で timeout
{
const signal = AbortSignal.timeout(10000);
const deadline = await waitIdle().catch(() => ({didTimeout:true}));
// timeout かどうか
console.log(deadline.didTimeout);
// 残りの timeout までの時間 (didTimeout が true なら 0固定)
console.log(deadline?.timeRemaining() ?? 0);
}
// #endregion
queueMicrotask
指定したコールバックを マイクロタスクキューとして登録する(これは連続して登録しても UIキューは実行できない為、注意が必要。間を開けたくないコールバック登録の際に利用する
キャンセルは不可
戻り値を Promise<void>
にして書くなら次の様になる。
function queueMicrotask({signal} = {})
{
if (signal) {
if (signal.reason) return Promise.reject(signal.reason);
signal.addEventListener("abort", abort);
}
const d = Promise.withResolvers();
if (signal) {
d.promise.finally(() => signal.removeEventListener("abort", abort));
}
const clear = globalThis.queueMicrotask(d.resolve);
return d.promise;
function abort() {
d.reject(signal.reason);
}
}
ただ、queueMicrotask() は promise.then()
の登録の際の内部実装なので .then()
で繋げればいいというのはそう。
つまりこれと変わらないとも言える。
{
await Promise.resolve();
}
scheduler.postTask
指定した 優先度 で 指定された 遅延後 コールバックを実行する 戻り値は Promise<T>
(コールバックの戻り値に影響される為)
firefox と safari 以外実装されている。 firefox は実装途中(フラグをつけることで有効化される)、safari はまだである。
優先度は requestAnimationFrame から requestIdleCallback の間で user-blocking
user-visible
background
が 優先度高い順で定義されている。
この関数は Promise<T>
の戻り値fを持つ為、 そのまま使います。
使うときはこんな感じ
// #region 通常利用 (優先度を user-visible で 1秒後 に完了)
{
await scheduler.postTask(() => undefined, {delay:1000});
}
// #region
// #region 通常利用 (優先度を background で 2秒後 に完了)
{
await scheduler.postTask(() => undefined, {
priority: 'background',
delay: 2000}
);
}
// #region
v