无需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的实现。