老凡尔赛 dhh 发布了他的 40kb js 做出 hey.com 的new magic,Turbo 则是"new magic"的核心。
Turbo仅仅是一个 JavaScript 库,所以理论上任何后端框架都可以配合 Turbo,下面介绍一下 Turbo 是怎么工作的以及后端框架如何实现对 Turbo 的支持。
本文重点不是介绍 Turbo,看过 dhh 的视频以及 Turbo 文档后再来看本文效果更佳
Turbo Drive 是浏览器导航(navigation)加速方案,作为 Rails 程序员都很熟悉,就是 turbolinks 的马甲,是一个纯前端的方案,所有服务端渲染的框架不需要做任何改变就可以直接使用。
Turbo Frames 可以使页面局部更新(url 不会改变),比如在 dhh 视频中rooms#show
页面有三部分(蓝框的是 turbo frame):房间信息(frame id 是room
),此房间的聊天记录,发送聊天记录的 form。
同时,我们也有rooms#edit
页面,这个页面只有一个编辑 room 的 form(frame id 也是room
)跟一些提示信息。
使用 frame 之后,在房间信息里点“edit”(url 是/rooms/:id/edit
),页面并没有跳转,只是同名 frame 做了替换(rooms#edit
页面 frame 之外的内容被丢弃)
实现原理也很简单:在 frame 里的 link 被点击后,会向服务器发送一个 get 请求(这里的请求是/rooms/:id/edit
),服务端会返回完整的 html,然后 turbo 只需要把返回的 html 中同名 frame 与当前 frame 替换即可。
后端框架理论上也不需要做任何事就能支持,只需要在 html 模版中加入turbo-frame
标签并加上对应的 id 即可。后端可以优化的是,turbo frame 里发出请求的 response 不需要返回模版的 layout(例如 Rails 的 application.html.erb)
后端需要:
Turbo-Frame
来判断是否是来自 turbo-frame 里的请求。# turbo-rails 实现
module Turbo::Frames::FrameRequest
extend ActiveSupport::Concern
included do
layout -> { false if turbo_frame_request? } # <- 来自turbo frame的请求layout设为false
etag { :frame if turbo_frame_request? }
end
private
def turbo_frame_request? # <- 判断请求是否来自turbo frame
request.headers["Turbo-Frame"].present?
end
end
Turbo Streams 是 Turbo 最灵性的地方,通过服务端返回一小段 html 来更新当前页面。原理简单,容易理解,效果也不错,值得拥有!
拿dhh 的教程举例:
发送一条新 message(下方蓝框内的 turbo-frame 里的 form submit),服务端将返回
<turbo-stream target="messages" action="append">
<template>
<p id="message_4">08 Feb 15:24: spam4</p>
</template>
</turbo-stream>
Turbo 理解了这段 html 的意思:往 id 是messages
的 dom 元素里append
template 里面的 html,即<p id="message_4">08 Feb 15:24: spam4</p>
。
实际应用中,<p id="message_4">08 Feb 15:24: spam4</p>
往往来自一个局部模版。如
<%= turbo_stream.append "messages", @message %> # 根据@message类型推断使用`messages/_message.html.erb`模版。
等价于
<turbo-stream action="append" target="messages">
<template>
<%= render @message %>
</template>
</turbo-stream>
所以说 Turbo 即复用了服务端渲染的模版,又可以在不写 JavaScript 的情况下完成页面的更新。
服务端一般两个地方返回 Turbo Stream HTML:Turbo Frame 里提交的 form 可以返回 Turbo Stream HTML,也可以通过 websocket 推过来 Turbo Stream HTML。
Turbo Frame 里提交的 Form 的请求头的 accpet type 是text/vnd.turbo-stream.html
,遇到这个请求头就可以返回 Turbo Stream HTML,并且把 response 的 content type 也设置成text/vnd.turbo-stream.html
# turbo-rails注册turbo mimetype
module Turbo
class Engine < Rails::Engine
initializer "turbo.mimetype" do
Mime::Type.register "text/vnd.turbo-stream.html", :turbo_stream
end
end
end
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
before_action :set_room, only: %i[ new create ]
def create
@message = @room.messages.create!(message_params)
respond_to do |format|
format.turbo_stream # 自动找 messages/create.turbo_stream.erb
format.html { redirect_to @room }
end
end
private
def set_room
@room = Room.find(params[:room_id])
end
def message_params
params.require(:message).permit(:content)
end
end
turbo-rails 的做法是自定义一个 html 标签turbo-cable-stream-source
。标签中有一个加密过的 topic,例如
<%= turbo_stream_from @room %>
会生成
<turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="IloybGtPaTh2WT">
</turbo-cable-stream-source>
标签对应的 javascript 代码:
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"
import { subscribeTo } from "./cable"
class TurboCableStreamSourceElement extends HTMLElement {
async connectedCallback() {
connectStreamSource(this)
this.subscription = await subscribeTo(this.channel, { received: this.dispatchMessageEvent.bind(this) })
}
disconnectedCallback() {
disconnectStreamSource(this)
if (this.subscription) this.subscription.unsubscribe()
}
dispatchMessageEvent(data) {
const event = new MessageEvent("message", { data })
return this.dispatchEvent(event)
}
get channel() {
const channel = this.getAttribute("channel")
const signed_stream_name = this.getAttribute("signed-stream-name")
return { channel, signed_stream_name }
}
}
customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement)
TurboCableStreamSourceElement
会把这个 html 片段通过 dispatchEvent 传递给 Turbo,Turbo 最终完成页面的局部更新。支持 websocket 的后端框架可以复制这一个过程:
turbo-cable-stream-source
的 html 标签,配合 JavaScript 代码使标签出现后自动订阅一个 topic(suscribe topic 的代码根据不同的后端框架需要有调整)。Turbo::StreamsChannel
。class Turbo::StreamsChannel < ActionCable::Channel::Base
extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
def subscribed
if verified_stream_name = self.class.verified_stream_name(params[:signed_stream_name])
stream_from verified_stream_name
else
reject
end
end
end
有了这些背景知识,很容易就能看懂turbo-rails,并且了解 turbo 是怎么工作的。
我也自己写了一个 phoinex 版本的phoenix_turbo