Taro 微信小程序实时倒计时跳秒与节点累积排查

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

这次故障最初看起来像两个问题:不同客户端的倒计时对不上,中央数字和动画还会越叠越多。继续运行一段时间后,页面开始变卡,开发者工具里出现大量带 display: nonefixed 节点。

我一开始也把注意力放在定时器清理、组件状态和热更新上。后来发现,这两组现象分别来自时间模型和宿主节点挂载方式。继续给原有代码补条件,只会让链路更难解释。

每秒广播剩余秒数不是可靠的倒计时

最直接的实现是让服务端每秒计算一次剩余时间,再通过 WebSocket 发给客户端:

const remainingSeconds = Math.floor((deadlineMs - Date.now()) / 1000)
send({ remainingSeconds })

客户端收到多少就显示多少。问题是,这条消息只是服务端某一时刻的快照,不是时钟。

数据库查询、任务调度、网络传输和小程序渲染都会消耗时间。如果一条消息晚到了 700ms,下一条又按正常时间到达,客户端看到的序列可能直接从 8 跳到 6。应用切到后台或者主线程短暂繁忙时,情况更明显。

可靠的做法是发送绝对截止时间:

send({ deadlineMs })

客户端自行计算:

const remainingMs = deadlineMs - correctedNowMs
const remainingSeconds = Math.max(0, Math.ceil(remainingMs / 1000))

本地定时器只负责刷新显示。即使某次 tick 晚了,下一次也会回到正确时间,而不是继续沿用已经过期的剩余秒数。

设备时间不能直接当作服务端时间

只有截止时间还不够。不同手机的系统时间可能有偏差,直接使用 Date.now() 仍会让多个客户端显示不同结果。

可以在 WebSocket 的 ping/pong 中做轻量校时。这不是 WebSocket 协议自带的控制帧,而是应用层自己定义的两种 Protobuf 消息。关于 .proto、Buf 生成代码以及二进制消息的发送和接收,可先看 Protobuf 与 Buf 实战:TypeScript WebSocket 服务端发送与客户端接收。这里重点说明时间是怎样在这两种消息之间流动的。

先分清两个时间字段

协议只需要两个消息:

message PingRequest {
  int64 timestamp = 1;
}

message PongResponse {
  int64 timestamp = 1;
  int64 server_now_ms = 2;
}

字段虽然都表示毫秒时间戳,用途却完全不同:

  • timestamp 是客户端发出 ping 时的本机时间。服务端不能修改它,必须原样放进 pong,客户端靠它找到本次请求的起点;
  • server_now_ms 是服务端处理 ping 时的当前时间,用来估算服务端时钟与客户端时钟的差值。

如果把 pong 中的 timestamp 也改成服务端时间,客户端就失去了 sentAt,既算不出本次 RTT,也无法把响应和发送时刻对应起来。

客户端建连后立即发 ping

客户端在 WebSocket 连接成功后立即采样一次,随后每 5 秒重新采样。立即发送很重要,否则页面刚打开时要先使用未校准的本机时间等待一个周期。

const sendPing = () => {
  if (!socketTask || !isConnected) return

  const message = WsMessage.create({
    pingRequest: { timestamp: Date.now() },
  })
  const encoded = WsMessage.encode(message).finish()

  socketTask.send({
    data: encoded.buffer.slice(
      encoded.byteOffset,
      encoded.byteOffset + encoded.byteLength,
    ),
  })
}

sendPing()
const pingTimer = setInterval(sendPing, 5000)

这里的 Date.now() 只是记录“客户端什么时候发出了这次请求”,还没有被当作可信的服务端当前时间。

服务端回传原始时间和服务端时间

服务端解码消息后,把客户端时间戳原样复制到 pong,同时写入处理消息这一刻的服务端时间:

if (message.pingRequest) {
  const pong = WsMessage.create({
    pongResponse: {
      timestamp: message.pingRequest.timestamp,
      serverNowMs: Date.now(),
    },
  })

  ws.send(WsMessage.encode(pong).finish())
}

这一步不需要保存每个连接的 ping 状态。客户端发出的时间戳随响应返回,服务端只负责盖上自己的时间。

客户端收到 pong 后计算偏移量

客户端收到二进制消息并解码后,再读取一次本机时间:

if (message.pongResponse) {
  const receivedAt = Date.now()
  const sentAt = Number(message.pongResponse.timestamp)
  const serverNowMs = Number(message.pongResponse.serverNowMs)
  const rtt = Math.max(1, receivedAt - sentAt)

  if (serverNowMs > 0 && rtt <= bestClockRtt) {
    const clockOffsetMs = serverNowMs - (sentAt + receivedAt) / 2
    bestClockRtt = rtt
    setClockOffsetMs(clockOffsetMs)
  }
}

假设一次请求的数值如下:

sentAt     = 100000
receivedAt = 100080
serverNowMs = 102040

本次往返耗时是 80ms。在无法分别测量上行和下行耗时的情况下,先假设两者大致相等,那么服务端记录时间时,对应的客户端时间约为往返中点:

(100000 + 100080) / 2 = 100040

服务端返回的是 102040,因此服务端时钟比客户端快约 2000ms

clockOffsetMs = 102040 - 100040 = 2000

之后页面需要服务端当前时间时,不再直接使用 Date.now(),而是统一使用:

const correctedNowMs = Date.now() + clockOffsetMs

偏移量为正,表示服务端时钟更快,所以客户端当前时间应该加上偏移量,方向不能反过来。

中点算法依赖“上下行耗时大致对称”这个近似条件,因此它不是高精度授时。网络排队越严重,上下行差异越可能放大。周期采样时只保留当前连接中 RTT 最低的样本,可以减少排队延迟造成的误差;重连后则把最低 RTT 重置,重新评估新连接。若最低 RTT 只有几十毫秒,而同版本客户端仍长期相差 2 到 3 秒,就不应再归因于普通网络延迟。

这时需要直接比较:

deadlineMs
clockOffsetMs
Date.now() + clockOffsetMs

deadlineMs 不同,说明某个客户端漏掉了延期或状态更新;校准后的当前时间不同,则应检查 pong 数据、服务端节点时钟,或者负责生成截止时间的后台任务是否与 WebSocket 服务使用同一时间基准。

一个页面只能有一条时间轴

顶部倒计时、中央 3、2、1、0 和阶段动画不应该分别监听不同消息。只要一部分界面根据截止时间计算,另一部分等待 WebSocket 事件,它们迟早会因为消息传输延迟而错开。

页面时间轴只保留两个输入:

  • 服务端维护的绝对截止时间;
  • ping/pong 得出的时钟偏移。

页面上的秒数、阶段名称和动画触发点都从这两个值推导。WebSocket 只在截止时间改变时更新事实,不再承担逐秒播放动画的职责。

const remainingMs = deadlineMs - (Date.now() + clockOffsetMs)
const remainingSeconds = Math.max(0, Math.ceil(remainingMs / 1000))

setRemainingSeconds(remainingSeconds)
setStageAlert(getStageAlert(remainingMs))

顶部数字和阶段动画必须共用同一个 remainingMs。如果顶部倒计时使用校准时间,而动画仍直接读取 Date.now(),即使它们在同一个页面,也仍然是两条会逐渐错开的时间轴。

如果流程允许延期,还要防止旧消息覆盖新截止时间。同一流程的截止时间只会向后延长时,可以拒绝小于已知值的更新:

if (knownDeadlineMs === undefined || incomingDeadlineMs >= knownDeadlineMs) {
  knownDeadlineMs = incomingDeadlineMs
}

断线重连也不能只等待下一条广播。连接恢复后应主动获取当前阶段和截止时间,并用实体 ID 或同步版本阻止旧请求覆盖新状态。

0 应该是一秒,不是一个瞬间

另一个容易被忽略的问题是 0 的含义。常见实现使用 Math.ceil 显示 3、2、1,到达边界后立刻进入下一阶段,于是上一阶段的 0 和下一阶段的第一秒发生在同一时刻。

界面可能同时显示中央的 0 和顶部的下一阶段 15s。这不是样式问题,而是时间模型没有给 0 留位置。

若产品定义的序列是:

15 → 14 → ... → 2 → 1 → 0

那么 0 就要占用完整的 1000ms。假设一个流程包含一个准备阶段和三个确认阶段,总截止时间需要增加四个零秒区间:

const ZERO_TICK_MS = 1000
const totalDurationMs =
  prepareDurationMs +
  phaseDurationMs * 3 +
  ZERO_TICK_MS * 4

阶段判断、延期后的新截止时间、预计结束时间和客户端显示公式必须一起调整。只在界面上临时补一个 0,下一阶段其实已经开始,多个位置仍然会显示不同状态。

时间轴语义发生变化后,客户端与服务端也要按同一版本发布。旧客户端仍按没有零秒区间的公式解释截止时间,阶段显示自然会相差 1 到 4 秒。这种差异和校时公式无关。

为什么覆盖层节点会一直增加

倒计时稳定后,页面卡顿仍然存在。微信开发者工具的 WXML 面板给出了更直接的证据:每次更新都会多出一对结构相同的节点,一个用于数字层,另一个用于动画层。旧节点被设为 display: none,但没有从宿主树中移除。

最初的覆盖层使用了条件挂载的 RootPortal。数字每秒变化一次,相当于反复创建和销毁 portal。移除 RootPortal 后,重复节点有所变化,却没有消失。

当时组件仍然返回两个 Fragment 顶层的 fixed 节点:

<>
  <View className="fixed ..." />
  <View className="fixed ..." />
</>

数字状态、动画帧和显隐状态都会让组件重新渲染。在本次使用的 Taro 4.2.1 与微信开发者工具环境中,这组跨组件的 Fragment 顶层节点没有被稳定复用,而是以成对的新 data-sid 追加到页面中。

这是根据运行时节点变化得到的判断,并未进一步确认是否能在所有 Taro 版本和基础库版本中复现。对当前页面而言,继续保留这层组件边界没有收益,所以我把专用的覆盖层逻辑放回页面,并使用一个真实宿主节点包住固定层:

<View className="h-0 w-0 pointer-events-none">
  <View className={showNumber ? 'fixed flex' : 'fixed hidden'}>
    {/* 数字 */}
  </View>

  <View className={showAnimation ? 'fixed flex' : 'fixed hidden'}>
    {/* 动画 */}
  </View>
</View>

这两个子节点始终存在,状态变化只修改文本、图片帧和 class。调试器里应该始终只有一个数字层和一个动画层,而不是每秒出现新的 data-sid

几种没有解决根因的修补

这次排查中,有几种改动看似合理,却只能短暂掩盖问题:

  • 给动画增加更多“只触发一次”的 ref 和阶段判断;
  • 将更新频率从 250ms 降到 1 秒;
  • 在每次阶段变化时主动把显示状态设为 false
  • 清理缓存、重新构建,或者把问题归因于热更新。

这些处理既没有统一时间源,也没有改变宿主节点的挂载结构。节点仍在追加,只是速度慢了一些;倒计时仍由不同消息驱动,只是错位不一定马上出现。

我现在如何验证实时倒计时

静态检查和成功构建只能说明代码能够编译。涉及 WebSocket、设备时钟和小程序宿主树时,还需要在运行环境里检查这些事实:

  1. 两个同版本客户端收到的 deadlineMs 是否一致。
  2. 校准后的当前时间差是否接近最低 RTT 的量级。
  3. 延期、断线重连和前后台切换后,页面是否重新获得权威截止时间。
  4. 3、2、1、0 与顶部阶段秒数是否来自同一次计算。
  5. 运行多个阶段后,数字层和动画层的节点数量是否保持不变。
  6. 新旧时间轴不能混合发布;协议字段弃用要先经历一个兼容版本。

我更看重第五项。倒计时能消失并不代表问题已经解决,节点数量不增长才说明渲染链路真正稳定下来。

结论

实时倒计时传输的应该是截止时间,不是每秒变化的显示文本。客户端用经过校准的当前时间计算所有视觉状态,服务端负责截止时间、延期和重连后的权威快照。

在 Taro 小程序里,覆盖层还要检查最终生成的宿主节点。React 组件看起来已经卸载,不代表微信运行时一定回收了对应节点。遇到页面越来越黑、越来越卡时,WXML 中持续增长的 data-sid 往往比 React 状态日志更接近问题本身。