1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 07:25:04 +02:00

perf(frontend): タブがバックグラウンドな間のsetIntervalを停止するように (#17769)

This commit is contained in:
かっこかり
2026-07-22 22:24:14 +09:00
committed by GitHub
parent c9ac4f9b41
commit 170ee2bfeb
12 changed files with 120 additions and 38 deletions

View File

@@ -30,6 +30,7 @@
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: タイムラインの読み込みパフォーマンスを改善
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正

View File

@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** ドキュメントがアクティブになっていない間は実行を止めるsetInterval。戻り値の関数でdisposeする */
export function createVisibilityAwareInterval(fn: () => void, interval: number, options: {
immediate?: boolean;
} = {}): () => void {
let intervalId: number | null = null;
let lastCalledAt: number | null = null;
const tick = () => {
lastCalledAt = Date.now();
fn();
};
const start = () => {
if (intervalId != null) return;
if (lastCalledAt == null) {
if (options.immediate) {
tick();
} else {
lastCalledAt = Date.now();
}
} else if (Date.now() - lastCalledAt >= interval) {
// もし前回の呼び出しからinterval以上の時間が経過していたら、即座に呼び出す
// (非アクティブ→アクティブに戻った場合など)
tick();
}
intervalId = window.setInterval(tick, interval);
};
const stop = () => {
if (intervalId != null) {
window.clearInterval(intervalId);
intervalId = null;
}
};
const onVisibilityChange = () => {
if (window.document.visibilityState === 'visible') {
start();
} else {
stop();
}
};
window.document.addEventListener('visibilitychange', onVisibilityChange);
if (window.document.visibilityState === 'visible') {
start();
}
return () => {
window.document.removeEventListener('visibilitychange', onVisibilityChange);
stop();
};
}

View File

@@ -4,34 +4,47 @@
*/
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
import { createVisibilityAwareInterval } from './interval.js';
export function useInterval(fn: () => void, interval: number, options: {
immediate: boolean;
afterMounted: boolean;
keepRunningWhenHidden?: boolean;
}): (() => void) | undefined {
if (Number.isNaN(interval)) return;
let intervalId: number | null = null;
let disposer: (() => void) | null = null;
const start = () => {
if (options.keepRunningWhenHidden) {
if (options.immediate) fn();
const intervalId = window.setInterval(fn, interval);
disposer = () => {
window.clearInterval(intervalId);
};
} else {
disposer = createVisibilityAwareInterval(fn, interval, { immediate: options.immediate });
}
};
const clear = () => {
if (disposer) {
disposer();
disposer = null;
}
};
if (options.afterMounted) {
onMounted(() => {
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
start();
});
} else {
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
start();
}
const clear = () => {
if (intervalId) window.clearInterval(intervalId);
intervalId = null;
};
onActivated(() => {
if (intervalId) return;
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
if (disposer) return;
start();
});
onDeactivated(() => {

View File

@@ -87,7 +87,7 @@ onDeactivated(() => {
useInterval(() => {
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
if (!window.document.hidden && isActivated) {
if (isActivated) {
fetchHistory();
}
}, 1000 * 10, {

View File

@@ -4,6 +4,7 @@
*/
import { ref, readonly, computed } from 'vue';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
const time = ref(Date.now());
@@ -29,6 +30,6 @@ export function useLowresTime() {
return computed(() => Math.max(time.value, now));
}
window.setInterval(() => {
createVisibilityAwareInterval(() => {
time.value = Date.now();
}, TIME_UPDATE_INTERVAL);

View File

@@ -6,6 +6,7 @@
import { onUnmounted, reactive } from 'vue';
import * as Misskey from 'misskey-js';
import { EventEmitter } from 'eventemitter3';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
import type { Reactive } from 'vue';
import type { NoteUpdatedEvent } from 'misskey-js/streaming.types.js';
import { useStream } from '@/stream.js';
@@ -68,7 +69,8 @@ const POLLING_INTERVAL =
prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
MIN_POLLING_INTERVAL;
window.setInterval(() => {
// documentが非表示の間はポーリングを停止する
createVisibilityAwareInterval(() => {
const ids = [...pollingQueue.entries()]
.filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く
.map(([k, v]) => k)
@@ -76,7 +78,6 @@ window.setInterval(() => {
.slice(0, CAPTURE_MAX);
if (ids.length === 0) return;
if (window.document.hidden) return;
// まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない
misskeyApi('notes/show-partial-bulk', {

View File

@@ -303,7 +303,11 @@ if (!props.game.isEnded) {
props.connection!.send('claimTimeIsUp', {});
}
}
}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
}, TIMER_INTERVAL_SEC * 1000, {
immediate: false,
afterMounted: true,
keepRunningWhenHidden: true, // 対局の制限時間管理のため、バックグラウンドでも止めない
});
}
async function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {

View File

@@ -248,7 +248,11 @@ async function accept(user: Misskey.entities.UserLite) {
}
}
useInterval(matchHeatbeat, 1000 * 5, { immediate: false, afterMounted: true });
useInterval(matchHeatbeat, 1000 * 5, {
immediate: false,
afterMounted: true,
keepRunningWhenHidden: true, // バックグラウンドタブでもマッチング待機を維持する必要がある
});
onMounted(() => {
misskeyApi('reversi/invitations').then(_invitations => {

View File

@@ -4,6 +4,7 @@
*/
import { BroadcastChannel } from 'broadcast-channel';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
import type { StorageProvider } from '@/preferences/manager.js';
import { cloudBackup } from '@/preferences/utility.js';
import { miLocalStorage } from '@/local-storage.js';
@@ -143,10 +144,10 @@ preferencesChannel.addEventListener('message', (msg) => {
//#region 定期クラウドバックアップ
let latestBackupAt = 0;
window.setInterval(() => {
// documentが非表示の間は実行されない (同期されていない古い値がバックアップされるのを防ぐ意味もある)
createVisibilityAwareInterval(() => {
if ($i == null) return;
if (!store.s.enablePreferencesAutoCloudBackup) return;
if (window.document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ
if (prefer.profile.modifiedAt <= latestBackupAt) return;
cloudBackup().then(() => {

View File

@@ -75,11 +75,9 @@ const fetchEndpoint = computed(() => {
url.searchParams.set('url', widgetProps.url);
return url.toString();
});
const intervalClear = ref<(() => void) | undefined>();
let intervalClear: (() => void) | null | undefined = null;
const tick = () => {
if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return;
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => {
@@ -90,10 +88,10 @@ const tick = () => {
watch(fetchEndpoint, tick);
watch(() => widgetProps.refreshIntervalSec, () => {
if (intervalClear.value) {
intervalClear.value();
if (intervalClear != null) {
intervalClear();
}
intervalClear.value = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
intervalClear = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
immediate: true,
afterMounted: true,
});

View File

@@ -113,13 +113,11 @@ const fetchEndpoint = computed(() => {
url.searchParams.set('url', widgetProps.url);
return url;
});
const intervalClear = ref<(() => void) | undefined>();
let intervalClear: (() => void) | null | undefined = null;
const key = ref(0);
const tick = () => {
if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return;
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => {
@@ -131,10 +129,10 @@ const tick = () => {
watch(fetchEndpoint, tick);
watch(() => widgetProps.refreshIntervalSec, () => {
if (intervalClear.value) {
intervalClear.value();
if (intervalClear != null) {
intervalClear();
}
intervalClear.value = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
intervalClear = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
immediate: true,
afterMounted: true,
});

View File

@@ -17,6 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
import { useWidgetPropsManager } from './widget.js';
import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
@@ -59,7 +60,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
emit,
);
let intervalId: number | null = null;
let disposeInterval: (() => void) | null = null;
let rafRequestId: number | null = null;
const ss = ref('');
const ms = ref('');
@@ -83,9 +84,9 @@ const tick = () => {
};
const clearTimers = () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = null;
if (disposeInterval) {
disposeInterval();
disposeInterval = null;
}
if (rafRequestId) {
window.cancelAnimationFrame(rafRequestId);
@@ -99,12 +100,13 @@ watch(() => widgetProps.showMs, (to) => {
clearTimers();
if (to) {
// rafはdocumentが非表示の間はブラウザによって自動的に停止される
rafRequestId = window.requestAnimationFrame(function loop() {
tick();
rafRequestId = window.requestAnimationFrame(loop);
});
} else {
intervalId = window.setInterval(tick, 1000);
disposeInterval = createVisibilityAwareInterval(tick, 1000);
}
}, { immediate: true });