上次帮朋友游戏公会搭活动页,需求一句话:等级加速活动页上要显示双倍经验剩余2天,最好跟游戏里一样会自己倒计时。用HTML写倒计时不难,难点是把“剩余2天”做成不会出错的活动信息展示——到期秒切文案、切后台不跳秒、不出现负数。这篇我直接把我常用的做法拆开来讲:用原生HTML搭结构,CSS做视觉,JavaScript负责倒计时刷新,不依赖任何框架,最后你复制改时间就能用。适合刚接触HTML/CSS/JS的同学,也适合要快速交付游戏活动页、运营H5、个人小站活动横幅的人。
1. 先把需求想透:倒计时到底在倒什么
1.1 “双倍经验剩余2天”这句文案背后是什么逻辑
“双倍经验剩余2天”不是从你打开网页那一刻开始计时两天,而是指向一个固定的活动截止时间点。比如运营说“活动2月28日23:59:59结束”,页面要做的就是把当前时刻和这个截止点相减,然后把差值渲染成剩余几天几时几分几秒。反过来说,如果把这个需求当成“用户打开后倒计时48小时”,每个用户看到的结束点都不一样,整个活动的公平性和统一性就没了,运营也没法在预定时间点切下一档活动。
所以第一件事不是写代码,是确认结束时间。结束时间往往来自游戏后台的活动配置、运营后台的排期表、或者接口返回的时间戳。拿到这个绝对结束时间之后,页面才谈得上显示准确性。
1.2 为什么不能盲目相信浏览器本地时间
新手最常犯的错是直接用 new Date().getTime() 做基准,再去读一个写死在代码里的目标时间。单机演示没问题,但真实活动页里有几个隐患:用户电脑系统时间可能被手动改过,可能时区设置不对,也可能系统时间同步服务没开,导致本地时间比真实时间慢或者快几分钟。如果活动卡在整点开抢,这种误差就会让页面的“剩余时间”和真实剩余时间对不上。
稳妥做法是把“服务器给的结束时间”和“服务器当前时间”一起下发。前端只做一件事:计算两个时间点之间的差。即使本机时间不准,只要服务器时间偏移量能通过接口拿到,页面也可以把偏差校准回来。若只是静态页面演示,至少把结束时间做成页面顶部一个明显的配置项,别把时间戳埋在一大坨逻辑里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把倒计时卡片的HTML结构和CSS视觉先搭明白
2.1 搭建标准HTML结构:一张活动卡片就够了
很多活动页喜欢堆一堆嵌套的 div,最后维护的时候连自己都晕。我建议先把页面语义拆清楚:这是一张活动卡片,卡片上有活动标签、活动标题、活动说明、倒计时区域。用合理的标签写出来,后续接样式、接脚本都会省心。
看下面这段结构,直接对应“等级加速活动”的页面卡片:
html复制<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>等级加速活动 - 双倍经验倒计时</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<section class="activity-card">
<span class="activity-tag">等级加速活动</span>
<h1>双倍经验</h1>
<p class="activity-desc">活动期间角色战斗经验提升至原来的200%</p>
<p class="countdown-label">距活动结束还有</p>
<div class="countdown-box">
<span class="time-card" id="days">00</span>
<span class="unit">天</span>
<span class="time-card" id="hours">00</span>
<span class="unit">时</span>
<span class="time-card" id="minutes">00</span>
<span class="unit">分</span>
<span class="time-card" id="seconds">00</span>
<span class="unit">秒</span>
</div>
</section>
</body>
</html>
这里有几个细节是我实际工作中比较在意的:第一,lang="zh-cn" 和 <meta charset="utf-8"> 别省,尤其是页面里要显示中文单位“天时分秒”,编码错了全是乱码;第二,倒计时数字不要拆成四个独立 id 之外再加一层复杂结构,JS只需要这四个元素;第三,脚本放到页面底部,或者用 defer,避免JS执行时DOM还没解析完。
2.2 用CSS把“剩余2天”变成页面C位
活动倒计时光有数字不行,得让人一眼扫过来就知道“现在还在活动期”,所以视觉上通常要让数字尽量突出,单位弱化,背景有活动氛围。下面这段CSS是比较常见的活动卡片风格:深色渐变背景、白色圆角卡片、每个时间块用深色底衬托数字。
css复制* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a237e, #4a148c);
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 16px;
}
.activity-card {
width: 100%;
max-width: 680px;
background: #ffffff;
border-radius: 18px;
padding: 40px 30px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.activity-tag {
display: inline-block;
padding: 6px 14px;
border-radius: 999px;
background: #ff6f00;
color: #ffffff;
font-size: 14px;
letter-spacing: 2px;
}
h1 {
margin-top: 18px;
font-size: 34px;
color: #212121;
}
.activity-desc {
margin-top: 8px;
color: #666666;
font-size: 15px;
}
.countdown-label {
margin: 30px 0 14px;
color: #9e9e9e;
font-size: 14px;
}
.countdown-box {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.time-card {
background: #263238;
color: #ffffff;
font-size: 32px;
line-height: 1;
padding: 14px 10px;
border-radius: 10px;
min-width: 64px;
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
}
.unit {
color: #888888;
font-size: 14px;
margin: 0 2px;
}
.ended-tip {
font-size: 22px;
color: #e53935;
}
有一个很容易被忽略的小坑:数字从9变成10,或者从59变成60的瞬间,如果字体不是等宽数字,整个倒计时区域会左右晃动,特别掉价。所以在 .time-card 上我加了 font-variant-numeric: tabular-nums,让数字以等宽方式渲染。这个属性在大多数现代浏览器都支持,老项目里也可以用固定宽度或者 letter-spacing 缓解晃动。
3. 倒计时核心逻辑:稳定到点归零的原生JS写法
3.1 可以直接保存运行的单文件倒计时完整代码
下面是一份完整的单文件实现,把HTML、CSS、JS都放在一个 .html 文件里,保存后浏览器打开就能看到效果。我特意把演示目标时间写成“打开页面后的48小时”,方便你第一眼就看到接近“双倍经验剩余2天”的显示效果;正式接活动时,把 END_TIME 改成服务端下发的固定结束时间戳即可。
html复制<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>等级加速活动 - 双倍经验倒计时</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a237e, #4a148c);
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 16px;
}
.activity-card {
width: 100%;
max-width: 680px;
background: #ffffff;
border-radius: 18px;
padding: 40px 30px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.activity-tag {
display: inline-block;
padding: 6px 14px;
border-radius: 999px;
background: #ff6f00;
color: #ffffff;
font-size: 14px;
letter-spacing: 2px;
}
h1 {
margin-top: 18px;
font-size: 34px;
color: #212121;
}
.activity-desc {
margin-top: 8px;
color: #666666;
font-size: 15px;
}
.countdown-label {
margin: 30px 0 14px;
color: #9e9e9e;
font-size: 14px;
}
.countdown-box {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.time-card {
background: #263238;
color: #ffffff;
font-size: 32px;
line-height: 1;
padding: 14px 10px;
border-radius: 10px;
min-width: 64px;
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
}
.unit {
color: #888888;
font-size: 14px;
margin: 0 2px;
}
.ended-tip {
font-size: 22px;
color: #e53935;
}
</style>
</head>
<body>
<section class="activity-card">
<span class="activity-tag">等级加速活动</span>
<h1>双倍经验</h1>
<p class="activity-desc">活动期间角色战斗经验提升至原来的200%</p>
<p class="countdown-label" id="countdownLabel">距活动结束还有</p>
<div class="countdown-box" id="countdownBox">
<span class="time-card" id="days">00</span>
<span class="unit">天</span>
<span class="time-card" id="hours">00</span>
<span class="unit">时</span>
<span class="time-card" id="minutes">00</span>
<span class="unit">分</span>
<span class="time-card" id="seconds">00</span>
<span class="unit">秒</span>
</div>
</section>
<script>
(function () {
// 演示环境:从打开页面起算48小时
// 正式环境请改成服务端下发的固定结束时间,例如:
// var END_TIME = 1767225600000;
var END_TIME = Date.now() + 2 * 24 * 60 * 60 * 1000;
var daysEl = document.getElementById('days');
var hoursEl = document.getElementById('hours');
var minutesEl = document.getElementById('minutes');
var secondsEl = document.getElementById('seconds');
var labelEl = document.getElementById('countdownLabel');
var boxEl = document.getElementById('countdownBox');
var timerId = null;
var isEnd = false;
function pad(num) {
return num < 10 ? '0' + num : '' + num;
}
function calc(remain) {
// 向上取整,保证页面打开瞬间停留在一整秒上,更符合玩家对“剩余2天”的感知
var totalSeconds = Math.ceil(remain / 1000);
var days = Math.floor(totalSeconds / 86400);
var hours = Math.floor((totalSeconds % 86400) / 3600);
var minutes = Math.floor((totalSeconds % 3600) / 60);
var seconds = totalSeconds % 60;
return {
days: days,
hours: hours,
minutes: minutes,
seconds: seconds
};
}
function render() {
if (isEnd) {
return;
}
var remain = END_TIME - Date.now();
if (remain <= 0) {
isEnd = true;
if (timerId) {
clearInterval(timerId);
timerId = null;
}
labelEl.textContent = '活动动态';
boxEl.innerHTML = '<p class="ended-tip">本次活动已结束,感谢参与</p>';
return;
}
var t = calc(remain);
daysEl.textContent = pad(t.days);
hoursEl.textContent = pad(t.hours);
minutesEl.textContent = pad(t.minutes);
secondsEl.textContent = pad(t.seconds);
}
function start() {
render();
if (!isEnd) {
timerId = setInterval(render, 1000);
}
}
start();
})();
</script>
</body>
</html>
直接复制到本地打开,你应该能看到类似“距活动结束还有 01天23小时59分59秒”的显示,下一秒会自动减掉一秒。这样处理有个好处,即使打开页面的瞬间不是整点,也不会出现“活动还有48小时,可页面上却写着47小时59分59秒”的观感偏差。
3.2 核心逻辑拆解:时间差、格式化、边界处理
倒计时函数最核心的是 END_TIME - Date.now() 这一句减法。END_TIME 是活动结束那一刻的时间戳,Date.now() 是当前时间戳,两者相减得到剩余毫秒数。接下来把毫秒换算成天、时、分、秒:
24小时对应毫秒数是 24 * 60 * 60 * 1000,所以剩余天数可以用总毫秒除以这个值后向下取整。剩余小时数不是简单拿毫秒数去除以小时毫秒数,而是要先取“去掉整天之后剩下的毫秒”,再除以小时对应的毫秒。分钟和秒同理。这种层层取余的算法,是倒计时类需求最常见的标准写法,比一次性除到底要直观得多。
结束边界同样值得单独处理。当 remain <= 0 时,要把倒计时停在00天00时00分00秒,同时把文案切换成“活动已结束”,而不是让数字继续变负数。很多线上活动页出问题都出在这一步:活动已经截止,页面还在显示“剩余-1天-2小时”,玩家截图发群里,运营面子挂不住。所以页面里必须有一个显式的结束分支,同时 clearInterval 停掉定时器,避免无意义的每秒刷新继续跑。
3.3 为什么每次刷新都从目标时间倒推,而不是按秒累减
这是我踩过几次坑后总结出的关键点。新手经常这样写:先算一个总的剩余秒数,然后 setInterval 每秒执行一次 totalSeconds--,再把值渲染上去。页面一直停在前台时这样写没问题,但只要用户把浏览器切到后台再切回来,问题就会暴露。
浏览器为了省电,会对后台页面的定时器做节流,setInterval 可能从1秒1次变成1分钟1次甚至更久。切回页面的瞬间,定时器又突然密集执行几次。如果用的是“每秒减1”的累减逻辑,数字可能一瞬间跳好几秒,或者和真实时间对不上。而上面代码里每次渲染都重新执行 END_TIME - Date.now(),从目标时间倒推当前还剩多少,那么不管定时器被后台节流了多久,下一次执行拿到的始终是真实剩余时间,不会累积错误。
4. 真实项目进阶:接接口、变文案、处理页面生命周期
4.1 活动结束时间不要写死在代码深处
真正上线页面的结束时间,通常由运营后台配置。前端有三种比较稳妥的接法,看项目环境选一种就好。
第一种,后端模板注入。页面渲染时,后端直接把活动配置写进页面里的全局变量,比如:
html复制<script>
window.__ACTIVITY_CONFIG__ = {
endTime: 1767225600000,
serverTime: 1767000000000
};
</script>
页面里的JS读取这两个字段,再用 serverTime - Date.now() 计算时间偏移量,校准本地时钟。
第二种,前端通过接口拉取活动状态。
javascript复制fetch('/api/activity/current')
.then(function (res) {
return res.json();
})
.then(function (data) {
// data.endTime 是活动截止时间戳
// data.serverTime 是服务器当前时间戳
serverOffset = data.serverTime - Date.now();
END_TIME = data.endTime;
start();
})
.catch(function () {
// 接口挂了也要给一个兜底展示,避免活动页空白
});
第三种,纯静态页面但运营会自己改HTML。这种情况下,至少把结束时间放在脚本最前面,用清晰注释标注“运营改这里”,不要埋在函数内部。
我比较推荐第一种或第二种:服务端负责最终的活动开关和奖励发放,前端只负责把服务端下发的时间展示出来。倒计时做得再花哨,也只是一种“提示”,不能作为活动真正开始或结束的依据。
4.2 从“剩余2天”到“还有1小时”:公告文案的自动切换
不是所有场景都要摆出四个数字块。导航栏、横幅、邮件提醒里,经常只需要一句“双倍经验剩余2天”。这时候需要一个把毫秒数转成友好文字的格式化函数:
javascript复制function formatRemainToText(remainMs) {
if (remainMs <= 0) {
return '活动已结束';
}
var totalMinutes = Math.ceil(remainMs / 60000);
var days = Math.floor(totalMinutes / 1440);
var hours = Math.floor((totalMinutes % 1440) / 60);
var minutes = totalMinutes % 60;
if (days > 0 && hours > 0) {
return '剩余' + days + '天' + hours + '小时';
}
if (days > 0) {
return '剩余' + days + '天';
}
if (hours > 0 && minutes > 0) {
return '剩余' + hours + '小时' + minutes + '分钟';
}
if (hours > 0) {
return '剩余' + hours + '小时';
}
if (minutes > 0) {
return '剩余' + minutes + '分钟';
}
return '即将结束';
}
这里有个容易犯的低级错误:明明活动还剩23小时,如果机械地套“天时分秒”,会输出“剩余0天
