HTML/CSS Web 后端框架支持 Turbo

piecehealth · February 09, 2021 · Last by quietcool replied at September 09, 2022 · 1414 hits

dhh

老凡尔赛 dhh 发布了他的 40kb js 做出 hey.com 的new magic,Turbo 则是"new magic"的核心。

Turbo仅仅是一个 JavaScript 库,所以理论上任何后端框架都可以配合 Turbo,下面介绍一下 Turbo 是怎么工作的以及后端框架如何实现对 Turbo 的支持。

本文重点不是介绍 Turbo,看过 dhh 的视频以及 Turbo 文档后再来看本文效果更佳

Turbo Drive

Turbo Drive 是浏览器导航(navigation)加速方案,作为 Rails 程序员都很熟悉,就是 turbolinks 的马甲,是一个纯前端的方案,所有服务端渲染的框架不需要做任何改变就可以直接使用。

Turbo Frames

Turbo Frames 可以使页面局部更新(url 不会改变),比如在 dhh 视频中rooms#show页面有三部分(蓝框的是 turbo frame):房间信息(frame id 是room),此房间的聊天记录,发送聊天记录的 form。

rooms#show

同时,我们也有rooms#edit页面,这个页面只有一个编辑 room 的 form(frame id 也是room)跟一些提示信息。

rooms#edit

使用 frame 之后,在房间信息里点“edit”(url 是/rooms/:id/edit),页面并没有跳转,只是同名 frame 做了替换(rooms#edit页面 frame 之外的内容被丢弃)

rooms#show#edit

实现原理也很简单:在 frame 里的 link 被点击后,会向服务器发送一个 get 请求(这里的请求是/rooms/:id/edit),服务端会返回完整的 html,然后 turbo 只需要把返回的 html 中同名 frame 与当前 frame 替换即可。

后端框架理论上也不需要做任何事就能支持,只需要在 html 模版中加入turbo-frame标签并加上对应的 id 即可。后端可以优化的是,turbo frame 里发出请求的 response 不需要返回模版的 layout(例如 Rails 的 application.html.erb)

后端需要:

  1. 通过看 headers 里的Turbo-Frame来判断是否是来自 turbo-frame 里的请求。
  2. 如果是来自 turbo-frame 的请求,response 里不带通用的 layout
# 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 Streams 是 Turbo 最灵性的地方,通过服务端返回一小段 html 来更新当前页面。原理简单,容易理解,效果也不错,值得拥有!

dhh 的教程举例:

rooms#show

发送一条新 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

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

通过 websocket 推过来 Turbo Stream HTML

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)
  1. 通过自定义标签的connectedCallback,会自动订阅相应的 topic。
  2. 开发者只需要向 topic 推对应的 turbo stream 的 html 片段,TurboCableStreamSourceElement会把这个 html 片段通过 dispatchEvent 传递给 Turbo,Turbo 最终完成页面的局部更新。

支持 websocket 的后端框架可以复制这一个过程:

  • 首先要自定一个类似turbo-cable-stream-source的 html 标签,配合 JavaScript 代码使标签出现后自动订阅一个 topic(suscribe topic 的代码根据不同的后端框架需要有调整)。
  • 服务端有一个 channel 来 handle 这些 topic,例如在 turbo-rails 里是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
  • 这时候只需要向 topic 发 turob stream html 就好了。为了方便,可以把发送 html 做成 helper 方法方便调用。

有了这些背景知识,很容易就能看懂turbo-rails,并且了解 turbo 是怎么工作的。

我也自己写了一个 phoinex 版本的phoenix_turbo

还是不太清楚。 能再讲下 Turbo-rails 如何使用的吗?

刚听完真人线下分享的来实名点赞了!

You need to Sign in before reply, if you don't have an account, please Sign up first.