列表中的倒计时组件

前景:在uniapp项目列表中用到了uv-ui中的uv-count-down组件,出现了一个问题。每个卡片都有倒计时直接卡死问题。干脆自己写一个

server-count-down 是一个适合循环列表使用的服务器时间倒计时组件。调用方只传结束时间,组件使用服务器校准时间计算剩余时长,并让所有组件实例共用一个定时器。

完成本教程后,你将理解以下内容:

  • 为什么大量 uv-count-down 实例会造成页面卡顿。
  • 如何使用服务器时间计算可靠的剩余时长。
  • 如何用单调时钟避免设备时间变化影响倒计时。
  • 如何让任意数量的倒计时实例共用一个定时器。
  • 如何拆分格式化、调度器、状态机和 Vue 组件。

运行前提

本教程基于以下环境:

  • uni-app Vue 3。
  • Composition API 和 <script setup lang="ts">
  • 项目已经通过 getServerMoment()完成服务器时间校准。
  • time 表示倒计时结束时间,不表示剩余时长。

组件源码位于以下目录:

text 复制代码
src/components/server-count-down/
├── server-count-down.vue
├── count-down-engine.ts
├── count-down-utils.ts
└── shared-ticker.ts

先看使用结果

传入毫秒时间戳字符串:

vue 复制代码
<server-count-down time="1818993600000" format="HH:mm:ss" />

传入响应式时间戳:

vue 复制代码
<server-count-down :time="order.auto_pause_time" format="HH:mm:ss" />

组件执行以下计算:

text 复制代码
剩余时长 = 结束时间 - 当前服务器时间
本地截止锚点 = 单调时钟当前值 + 剩余时长
每次刷新后的剩余时长 = 本地截止锚点 - 单调时钟当前值

调用方不需要创建定时器,也不需要每秒修改 time

为什么原组件在列表中卡顿

uv-count-down 的普通模式为每个组件实例递归创建一个约 30ms 的定时任务。一个实例每秒执行约 33 次回调,100 个实例每秒可能执行约 3300 次回调。

大部分回调不会更新页面,因为秒数通常没有变化,但 JavaScript 仍需要唤醒并执行这些回调。列表越长,主线程调度、时间计算和组件更新的开销越明显。

server-count-down 使用模块级共享调度器解决这个问题:

text 复制代码
多个组件实例
    ↓ 订阅
一个 shared-ticker
    ↓ 分发当前单调时间
各自计算剩余时长
    ↓
秒值变化时更新视图

架构设计

四个文件分别承担一种职责:

文件 职责
count-down-utils.ts 拆分毫秒时长并按照 format 生成显示文本。
shared-ticker.ts 管理全局订阅者和唯一的共享定时器。
count-down-engine.ts 管理开始、暂停、重置、结束等倒计时状态。
server-count-down.vue 定义组件 API,接入服务器时间并渲染结果。

这种拆分让 Vue 组件只负责数据绑定。计时算法和格式化逻辑不会与模板、样式混在一起。

1. 拆分时间数据

change 事件需要保持与 uv-count-down 相同的数据结构:

ts 复制代码
export interface CountDownTimeData {
    days: number;
    hours: number;
    minutes: number;
    seconds: number;
    milliseconds: number;
}

定义常用时间单位,然后使用除法和取模拆分剩余毫秒:

ts 复制代码
const SECOND = 1_000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;

export const parseTimeData = (time: number): CountDownTimeData => ({
    days: Math.floor(time / DAY),
    hours: Math.floor((time % DAY) / HOUR),
    minutes: Math.floor((time % HOUR) / MINUTE),
    seconds: Math.floor((time % MINUTE) / SECOND),
    milliseconds: Math.floor(time % SECOND)
});

假设剩余时间是 90_061_007ms,拆分结果如下:

ts 复制代码
{
    days: 1,
    hours: 1,
    minutes: 1,
    seconds: 1,
    milliseconds: 7
}

格式化时间

parseFormat()替换 DDHHmmssSSS。格式中缺少高位单位时,函数把高位时间累计到下一级。

例如,剩余时间是 1 天 1 小时:

text 复制代码
DD:HH:mm:ss → 01:01:00:00
HH:mm:ss    → 25:00:00

这项规则与 uv-count-down 保持一致,因此只使用 HH:mm:ss 时,小时数可以超过 23。

2. 创建共享调度器

共享调度器的核心是模块级 Map。ES 模块只初始化一次,因此每个组件导入的都是同一组订阅者和同一个定时器。

ts 复制代码
interface TickerSubscriber {
    interval: number;
    lastTick: number;
    listener: (now: number) => void;
}

const subscribers = new Map<symbol, TickerSubscriber>();

let timer: ReturnType<typeof setInterval> | null = null;
let timerInterval = 0;

每个订阅使用独立的 Symbol 作为标识。取消订阅时,只删除当前组件对应的数据。

使用单调时钟

倒计时推进优先使用 performance.now()

ts 复制代码
export const getMonotonicTime = (): number => {
    if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
        return performance.now();
    }

    return Date.now();
};

performance.now()表示当前运行环境启动后的相对时间。用户修改系统日期或时区不会改变这个值,因此它比 Date.now()更适合推进已经校准的剩余时长。

不支持 performance.now()的运行环境会回退到 Date.now()。服务器时间仍用于建立初始剩余时长,但运行期间修改设备时间可能影响回退模式。

动态调整共享频率

普通倒计时每 200ms 检查一次,毫秒倒计时每 50ms 检查一次:

ts 复制代码
const SECOND_TICK_INTERVAL = 200;
const MILLISECOND_TICK_INTERVAL = 50;

调度器根据当前订阅者选择最小间隔:

ts 复制代码
const updateTimer = (): void => {
    const nextInterval = Math.min(...Array.from(subscribers.values(), ({ interval }) => interval));

    if (timer && timerInterval === nextInterval) return;
    if (timer) clearInterval(timer);

    timer = subscribers.size ? setInterval(runTicker, nextInterval) : null;
    timerInterval = subscribers.size ? nextInterval : 0;
};

只存在普通倒计时时,共享定时器使用 200ms。加入毫秒倒计时后,唯一的定时器切换为 50ms;最后一个毫秒订阅取消后,频率自动降回 200ms

订阅和清理

ts 复制代码
export const subscribeSharedTicker = (listener, millisecond) => {
    const id = Symbol('count-down-subscriber');
    const interval = millisecond ? MILLISECOND_TICK_INTERVAL : SECOND_TICK_INTERVAL;

    subscribers.set(id, {
        interval,
        lastTick: getMonotonicTime(),
        listener
    });
    updateTimer();

    return () => {
        if (!subscribers.delete(id)) return;
        updateTimer();
    };
};

最后一个订阅者离开时,subscribers.size 变为 0,调度器清理定时器。页面反复进入和退出不会留下后台任务。

3. 创建倒计时状态机

count-down-engine.ts 不依赖 Vue。它只保存以下状态:

ts 复制代码
let deadline = 0;
let remaining = 0;
let running = false;
let finished = false;
let unsubscribe: (() => void) | null = null;

各字段的含义如下:

字段 说明
deadline 基于单调时钟建立的本地截止锚点。
remaining 当前剩余毫秒。
running 当前是否正在倒计时。
finished 是否已经触发结束事件。
unsubscribe 当前实例取消共享时钟订阅的方法。

重置

reset()先停止当前订阅,再从服务器时间重新计算剩余时长:

ts 复制代码
const reset = (autoStart: boolean): void => {
    pause();
    finished = false;
    setRemaining(normalizeDuration(options.getDuration()), true);
    if (autoStart) start();
};

组件首次挂载、time 改变或外部主动调用 reset()时都会执行这段逻辑。

开始

start()使用当前剩余时长建立单调时钟截止锚点:

ts 复制代码
const start = (): void => {
    if (running || remaining <= 0) return;

    running = true;
    deadline = now() + remaining;
    unsubscribe = subscribe(handleTick, options.isMillisecond());
};

这里不再使用设备当前日期。服务器时间只负责确定初始剩余时长,后续通过单调时钟计算经过了多久。

刷新

共享调度器把当前单调时间传给 handleTick()

ts 复制代码
const handleTick = (currentTime: number): void => {
    if (!running) return;
    setRemaining(deadline - currentTime);
};

倒计时不执行 remaining -= 1000。即使定时器回调延迟,下一次刷新仍然直接使用截止锚点计算正确的剩余时长。

控制视图更新频率

普通模式只在秒值变化时触发 change 事件:

ts 复制代码
const shouldNotify =
    force ||
    options.isMillisecond() ||
    !isSameSecond(normalizedRemaining, remaining) ||
    normalizedRemaining === 0;

共享调度器可以每 200ms 检查一次,但 Vue 视图通常每秒只更新一次。这个判断避免无意义的响应式更新。

暂停和恢复

暂停时需要保存精确剩余时间:

ts 复制代码
const pause = (): void => {
    if (running) remaining = Math.max(deadline - now(), 0);
    stopSubscription();
};

再次调用 start()时,状态机使用暂停时保存的 remaining 建立新的截止锚点。暂停期间经过的时间不会计入倒计时。

4. 接入服务器时间

Vue 组件的 time 表示结束时间,支持以下类型:

ts 复制代码
interface Props {
    time?: string | number | Date;
    format?: string;
    autoStart?: boolean;
    millisecond?: boolean;
    customStyle?: string | Record<string, string | number>;
}

推荐传毫秒时间戳,因为它不受日期字符串格式和跨端解析差异影响。

解析结束时间

静态属性 time="1818993600000" 传入的是字符串,而不是数字。直接调用 moment('1818993600000') 不能保证按毫秒时间戳解析,因此组件先识别纯数字字符串:

ts 复制代码
const resolveEndTime = (): number => {
    if (typeof props.time === 'number') return props.time;
    if (props.time instanceof Date) return props.time.getTime();

    const rawTime = props.time.trim();
    const timestamp = Number(rawTime);
    return rawTime && Number.isFinite(timestamp)
        ? timestamp
        : moment(rawTime).valueOf();
};

以下写法最终得到相同的毫秒时间戳:

vue 复制代码
<server-count-down time="1818993600000" />
<server-count-down :time="1818993600000" />

计算服务器剩余时长

ts 复制代码
const getServerRemainingDuration = (): number => {
    const targetTime = resolveEndTime();
    if (!Number.isFinite(targetTime)) return 0;

    return Math.max(targetTime - getServerMoment().valueOf(), 0);
};

getServerMoment()来自项目请求层。请求层读取响应头中的服务器时间,并保存服务器时间与本地时间之间的偏移量。

组件不会自己请求服务器。使用倒计时前,应用必须已经完成至少一次带服务器时间响应头的接口请求,否则 getServerMoment()会暂时使用本地时间作为兜底。

5. 连接状态机和 Vue

组件把 Props、事件和响应式状态传给倒计时引擎:

ts 复制代码
const engine = createCountDownEngine({
    getDuration: getServerRemainingDuration,
    isMillisecond: () => props.millisecond,
    onChange: (nextTimeData) => {
        timeData.value = nextTimeData;
        formattedTime.value = parseFormat(props.format, nextTimeData);
        emit('change', nextTimeData);
    },
    onFinish: () => emit('finish')
});

引擎不直接修改 Vue 状态。它通过 onChangeonFinish 把结果交回组件,这种依赖注入方式降低了计时逻辑与框架的耦合。

监听 Props

ts 复制代码
watch(() => props.time, reset);

watch(
    () => props.format,
    (format) => {
        formattedTime.value = parseFormat(format, timeData.value);
    }
);

time 变化时重新按服务器时间计算剩余时长。format 变化时只重新生成显示文本,不重启倒计时。

管理生命周期

ts 复制代码
onMounted(reset);
onUnmounted(engine.dispose);

组件挂载后根据 autoStart 决定是否开始。组件卸载时取消共享时钟订阅,最后一个实例卸载后,全局定时器也会停止。

暴露实例方法

ts 复制代码
defineExpose({ start, pause, reset });

调用方可以继续使用与 uv-count-down 相同的手动控制方式。

6. 渲染组件

模板保留 uv-count-down 的 CSS 类名,已有样式可以继续生效:

vue 复制代码
<template>
    <view class="uv-count-down" :style="customStyle">
        <slot>
            <text class="uv-count-down__text">{{ formattedTime }}</text>
        </slot>
    </view>
</template>

默认插槽没有内容时显示 formattedTime。传入自定义插槽后,调用方可以通过 change 事件接收结构化时间数据并自行渲染。

使用示例

基础倒计时

vue 复制代码
<server-count-down
    :time="order.end_time"
    format="HH:mm:ss"
/>

显示天数

vue 复制代码
<server-count-down
    :time="order.end_time"
    format="DD天 HH:mm:ss"
/>

监听变化和结束

vue 复制代码
<script setup lang="ts">
import type { CountDownTimeData } from '@/components/server-count-down/count-down-utils';

const handleChange = (timeData: CountDownTimeData): void => {
    console.log('剩余时间', timeData);
};

const handleFinish = (): void => {
    console.log('倒计时结束');
};
</script>

<template>
    <server-count-down
        :time="order.end_time"
        format="HH:mm:ss"
        @change="handleChange"
        @finish="handleFinish"
    />
</template>

手动控制

vue 复制代码
<script setup lang="ts">
import { ref } from 'vue';
import ServerCountDown from '@/components/server-count-down/server-count-down.vue';

const countDownRef = ref<InstanceType<typeof ServerCountDown> | null>(null);
</script>

<template>
    <server-count-down
        ref="countDownRef"
        :time="order.end_time"
        :auto-start="false"
    />

    <button @click="countDownRef?.start()">开始</button>
    <button @click="countDownRef?.pause()">暂停</button>
    <button @click="countDownRef?.reset()">重置</button>
</template>

reset()会重新读取当前服务器时间,而 pause()start()只暂停和恢复当前剩余时长。

循环列表

vue 复制代码
<template>
    <view v-for="item in orderList" :key="item.id">
        <text>{{ item.name }}</text>
        <server-count-down
            :time="item.end_time"
            format="HH:mm:ss"
        />
    </view>
</template>

列表中的组件会自动订阅同一个共享调度器。不要在列表项中额外创建 setInterval,也不要每秒重新计算并修改 time

毫秒倒计时

vue 复制代码
<server-count-down
    :time="order.end_time"
    format="ss:SSS"
    millisecond
/>

毫秒模式会提高共享调度器频率,并让当前实例更频繁地更新视图。长列表不要开启毫秒模式,否则大量文本更新本身仍会带来性能压力。

API 参考

Props

参数 类型 默认值 说明
time `String Number Date`
format String HH:mm:ss 支持 DDHHmmssSSS
autoStart Boolean true 是否在挂载或重置后自动开始。
millisecond Boolean false 是否按毫秒频率更新。
customStyle `String Object` {}

Events

事件 参数 说明
change CountDownTimeData 剩余时间发生可见变化时触发。
finish 剩余时间归零时触发一次。

Methods

方法 说明
start() 从当前剩余时长继续倒计时。
pause() 暂停并保存精确剩余时长。
reset() 按最新结束时间和服务器时间重新计算。

常见问题

传入时间戳后不动

按以下顺序检查:

  1. 确认 time 是未来的结束时间,不是剩余秒数。
  2. 确认秒级时间戳已经乘以 1000,组件要求毫秒时间戳。
  3. 确认 autoStart 没有设置为 false
  4. 确认应用已经通过接口响应头校准服务器时间。

秒级时间戳通常是 10 位,毫秒时间戳通常是 13 位:

text 复制代码
秒级:1818993600
毫秒:1818993600000

小时数超过 23

format="HH:mm:ss" 不包含 DD,因此组件会把天数累计到小时。需要按天显示时改用:

vue 复制代码
<server-count-down :time="endTime" format="DD天 HH:mm:ss" />

页面切到后台后是否会不准确

组件不依赖"每次减一秒"。页面恢复后,下一次共享时钟回调会根据截止锚点重新计算剩余时长,因此定时器被系统节流不会产生累计误差。

为什么普通模式使用 200ms 定时器

每秒执行一次的定时器可能因为启动时刻与秒边界不一致而出现明显延迟。200ms 检查可以更快捕获秒值变化,而 isSameSecond()保证视图通常每秒只更新一次。

总结

server-count-down 把倒计时拆成服务器时间校准、单调时钟推进、共享任务调度、状态管理和格式化五个部分。这个结构同时解决了客户端时间不可靠、后台定时器节流和列表大量定时器卡顿的问题。

阅读源码时,按以下顺序理解最容易:

  1. server-count-down.vue 查看组件输入和输出。
  2. count-down-engine.ts 查看开始、暂停、重置和结束流程。
  3. shared-ticker.ts 查看多个实例如何共享一个定时器。
  4. count-down-utils.ts 查看时间拆分和格式化规则。
相关推荐
LVZ1 小时前
用了半年 AI 写代码,最烦的不是代码,是环境
前端·后端·开源
polaris_tl1 小时前
一行 `compress: false`,为什么让 SSE 首包恢复实时返回
前端
海边的云1 小时前
别再让 AI 瞎改代码:一套让大模型「收敛」的前端专家 Skill》
前端
kisshyshy1 小时前
给端侧大模型装上“发动机”:React 合成事件 + 进度条组件全解
前端·react.js·node.js
hunterandroid1 小时前
DataStore 工程化实践:迁移、并发更新与异常恢复
android·前端
晓说前端1 小时前
TypeScript 核心语法进阶 —— 字面量类型与类型推论
前端·typescript
不简说2 小时前
JS 代码技巧 vol.8 — 20 个函数式编程实战,把 if/else 拍扁的骚操作
前端·javascript·面试
头茬韭菜2 小时前
4.9 SSRF 防护 — Web 工具的出站安全与私有 IP 拦截
前端·tcp/ip·安全
Cobyte2 小时前
模板 DSL 解析器中的状态机设计
前端·javascript·vue.js