无需JavaScript的实时倒计时:Turbo Streams + relative_time_in_words

大多数倒计时器需要JavaScript库。但是使用Rails 8的新relative_time_in_words助手和Turbo Streams,你可以完全在服务端构建实时更新的倒计时。无需npm包,无需客户端日期计算,而且在JavaScript禁用时也能正常降级。

这是一个有点刻意的例子——一次有趣的探索,看看纯服务端渲染能做到什么程度。在生产环境中,你可能会将客户端JavaScript倒计时与服务端验证结合使用,这样可以减少服务器资源使用,同时在重要的地方(实际的截止时间执行)保持准确性。但让我们看看服务端方法能走多远。

新助手

Rails 8添加了relative_time_in_words,它可以处理过去和未来的时间:

relative_time_in_words(3.hours.from_now)
# => "in about 3 hours"

relative_time_in_words(2.days.ago)
# => "2 days ago"

这与只处理过去时间的time_ago_in_words不同。有一个同时处理两个方向的助手使倒计时UI变得更加简单。

构建实时拍卖计时器

让我们构建一个每分钟更新的拍卖倒计时,完全不需要编写JavaScript。

模型

class Auction < ApplicationRecord
  def ended?
    ends_at <= Time.current
  end

  def time_remaining
    ends_at - Time.current
  end
end

视图

<%= turbo_stream_from "auction_#{@auction.id}" %>

<div class="auction-timer">
  <span id="countdown_<%= @auction.id %>"
        class="<%= urgency_class(@auction.ends_at) %>">
    <%= relative_time_in_words(@auction.ends_at) %>
  </span>
</div>

基于紧急程度的样式

添加一个根据剩余时间返回CSS类的助手:

# app/helpers/countdowns_helper.rb
module CountdownsHelper
  def urgency_class(deadline)
    return "ended" if deadline <= Time.current

    remaining = deadline - Time.current
    case remaining
    when 0..1.hour then "critical"
    when 1.hour..1.day then "warning"
    else "normal"
    end
  end
end
.critical { color: #dc2626; font-weight: bold; }
.warning { color: #d97706; }
.normal { color: #059669; }
.ended { color: #6b7280; }

广播任务

这就是Turbo Streams发挥作用的地方。一个循环任务广播倒计时更新:

# app/jobs/countdown_broadcast_job.rb
class CountdownBroadcastJob < ApplicationJob
  def perform(auction)
    return if auction.ended?

    Turbo::StreamsChannel.broadcast_update_to(
      "auction_#{auction.id}",
      target: "countdown_#{auction.id}",
      html: render_countdown(auction)
    )

    # 调度下次更新
    self.class.set(wait: update_interval(auction)).perform_later(auction)
  end

  private

  def render_countdown(auction)
    ApplicationController.render(
      partial: "auctions/countdown",
      locals: { auction: auction }
    )
  end

  def update_interval(auction)
    remaining = auction.time_remaining
    case remaining
    when 0..5.minutes then 10.seconds
    when 5.minutes..1.hour then 1.minute
    else 5.minutes
    end
  end
end

任务以动态间隔调度自己——当截止时间临近时每10秒更新一次,但当时间还远时只每5分钟更新一次。这使你的任务队列保持可管理。

局部视图

<%# app/views/auctions/_countdown.html.erb %>
<span class="<%= urgency_class(auction.ends_at) %>">
  <% if auction.ended? %>
    已结束
  <% else %>
    <%= relative_time_in_words(auction.ends_at) %>
  <% end %>
</span>

启动倒计时

当查看拍卖页面时启动广播任务:

class AuctionsController < ApplicationController
  def show
    @auction = Auction.find(params[:id])
    CountdownBroadcastJob.perform_later(@auction) unless @auction.ended?
  end
end

其他用例

这个模式适用于任何时间敏感的显示:

限时抢购

<div id="sale_timer">
  促销结束 <%= relative_time_in_words(@sale.ends_at) %>
</div>

支持工单SLA

def sla_status(ticket)
  deadline = ticket.created_at + ticket.sla_hours.hours
  if deadline.future?
    "响应截止 #{relative_time_in_words(deadline)}"
  else
    "SLA已违反 #{relative_time_in_words(deadline)}"
  end
end

活动日程

<% if @event.starts_at.future? %>
  开始于 <%= relative_time_in_words(@event.starts_at) %>
<% else %>
  已开始 <%= relative_time_in_words(@event.starts_at) %>
<% end %>

注意事项

任务队列开销

每个活动的倒计时都会产生循环任务。对于高流量页面,请考虑:

  • 批量处理多个拍卖的更新
  • 使用Action Cable进行真正的实时更新
  • 限制并发倒计时任务的数量

时区

relative_time_in_words使用Time.current,它尊重Time.zone。确保你的应用程序时区处理是一致的。

优雅降级

倒计时在没有JavaScript的情况下也能工作——用户只会看到页面加载时准确的静态时间。Turbo Streams作为增强功能添加实时更新。

I18n

助手使用Rails内置的翻译。在你的locale文件中自定义它们:

zh:
  datetime:
    relative:
      past: "%{time}前"
      future: "%{time}后"

总结

这种方法以最小的复杂性为你提供实时倒计时。服务器是时间的真实来源,消除了客户端时钟偏差问题。而且因为它只是流式传输的HTML,所以在Turbo工作的任何地方都能工作。

对于真正的实时精度(秒级),你仍然需要JavaScript。但对于大多数用例——拍卖、促销、SLA、活动——分钟级更新就足够了,而这种服务端方法更容易构建和维护。

查看PR #55405了解relative_time_in_words的实现。