Turbo Rails 指导

本文翻译自:https://www.hotrails.dev/turbo-rails ,使用Rails7开发一个响应式单页面系统,分享turbo-rails的使用技巧与知识点,如果有翻译不清楚的地方,请指出,方便我后期更正,也可以对照着原文去学习。目前仅翻译了前四章内容,后续应该会继续完善。

我们将学习使用,Rails7默认引入的tubo-rails库,并创建一个响应式的单页面应用,且不用写一句自定义JS。

简介

这一章我们将解释要学习的内容,看下成品的样子,并且开始我们的项目。

线上试用:https://www.hotrails.dev/quotes,类似于一个账单管理器,你可以试着添加数据,并进行编辑。

为什么学习Turbo?

伴随着2021年12月7日发布的Rails7,Hotwire整合了Stimulus和Turbo。成为了默认的前端框架。下面我们看看Turbo与Rails整合带来的新的特点:

  • 第一:所有的链接和表单提交转为Ajax请求,通过Turbo Drive可以加速我们的系统,我们只需要简单的引入,而不需要做其他操作,即可获得好处。
  • 第二:通过Turbo Frames几行代码,我们就可以很容易的将页面切割为小的组件,可以替换或延迟加载页面的独立部分。
  • 第三:通过Turbo Stream就可以增加实时更新的特点。你可以做类似的多用户游戏或者实时监控系统。

通过本次教程的学习,你将知道所有的细节关于上面三点。

本教程适用范围

本教程中你将:

  • 创建一个增删改查项目
  • 创建我们的CSS样式系统
  • 通过Devise gem进行身份校验
  • 学习:Turbo Drive, Turbo Frames, Turbo Streams

如果你已经很熟悉1-3点,并打算学习第四点,那本教程就适用于你。

项目开始

我们将开始创建新的Rails项目,其中使用Sass作为css预处理器,Esbuild来构建Js。数据库你随意。这里默认你是Rails7版本

  • 开始创建:
rails new quote-editor --css=sass --javascript=esbuild 
  • 检查你的Gemfile看是否有该依赖
# Gemfile
gem "turbo-rails", "~> 1.0"
  • 使用:bundle install下载正确版本的gem
  • 使用:bin/setup来下载依赖,并创建数据库
  • 使用:bin/dev来预编译css和js,并启动服务

查看:http://localhost:3000,你应该能看到Rails启动页面。


注意:**bin/setup** **bin/dev**

**bin/setup** 可以下载gem,javascript依赖,create,migrate,seed 数据库。这一点不论是在团队中,还是小项目中,都可以快速的初始化环境。

**bin/dev**基于Procfile.dev文件,当运行这个命令时,我们是在同时运行它们:

web: bin/rails server -p 3000
js: yarn build --watch
css: yarn build:css --watch

第一个命令就是启动项目,第二,三个就是预编译,--watch是确保我们每次css,js文件被保存时,被观测到

这些命令都在创建项目的/bin目录中。

让我们继续构建项目吧


实现简单的增删改查

这一章,我们将要创建quote模型,并关联Controller通过Rails的约定

我们先通过Excalidraw来画一些草图,来描述我们要做的事儿

Quotes#index页面中,我们将展示quotes集合,并且每个quote都有详情,修改,删除的按钮,并且也有增加的按钮。下面是样式图:

img

当点击New quote按钮时跳转到Quotes#new页面:

img

当点击Create quote时跳转到Quotes#index页面,显示存储的数据,并按照时间排序。

当你点击Edit按钮时,就会跳转到Quotes#edit去更新已有的数据

img

当点击Update quote,还是跳回Quotes#index页面

当点击quote的标题时,则跳转到Quotes#show,现在我们仅仅显示标题,后面增加其他内容。

img

在开始之前,我们先创建一些测试用例去确保我们构建的内容正常

测试

测试是软件开发的基础部分,如果没有一个强有力的测试,我们将会引入bug,并无意间影响以往的功能。

  • 创建测试文件
bin/rails g system_test quotes
  • 编写测试用例
# test/system/quotes_test.rb
require "application_system_test_case"

class QuotesTest < ApplicationSystemTestCase
  test "Creating a new quote" do
    # When we visit the Quotes#index page
    # we expect to see a title with the text "Quotes"
    visit quotes_path
    assert_selector "h1", text: "Quotes"

    # When we click on the link with the text "New quote"
    # we expect to land on a page with the title "New quote"
    click_on "New quote"
    assert_selector "h1", text: "New quote"

    # When we fill in the name input with "Capybara quote"
    # and we click on "Create Quote"
    fill_in "Name", with: "Capybara quote"
    click_on "Create quote"

    # We expect to be back on the page with the title "Quotes"
    # and to see our "Capybara quote" added to the list
    assert_selector "h1", text: "Quotes"
    assert_text "Capybara quote"
  end
end

这里就是简单的增删改查测试用例,但开始之前,我们需要一些测试数据

使用 fixtures 可以创建假数据,在测试执行前,默认会将数据加载到测试数据库中。

  • 创建quotes的fixtures文件
touch test/fixtures/quotes.yml
  • 创建数据
# test/fixtures/quotes.yml

first:
  name: First quote

second:
  name: Second quote

third:
  name: Third quote
  • 我们再添加两个测试用例
# test/system/quotes_test.rb

require "application_system_test_case"

class QuotesTest < ApplicationSystemTestCase
  setup do
    @quote = quotes(:first) # Reference to the first fixture quote
  end

  # ...
  # 我们之前写的测试用例
  # ...

  test "Showing a quote" do
    visit quotes_path
    click_link @quote.name

    assert_selector "h1", text: @quote.name
  end

  test "Updating a quote" do
    visit quotes_path
    assert_selector "h1", text: "Quotes"

    click_on "Edit", match: :first
    assert_selector "h1", text: "Edit quote"

    fill_in "Name", with: "Updated quote"
    click_on "Update quote"

    assert_selector "h1", text: "Quotes"
    assert_text "Updated quote"
  end

  test "Destroying a quote" do
    visit quotes_path
    assert_text @quote.name

    click_on "Delete", match: :first
    assert_no_text @quote.name
  end
end

现在我们执行:bin/rails test:system,发现都失败,因为缺少Quote模型,路由,控制器。我们现在添加需要的部分。

  • 创建模型和迁移文件
rails generate model Quote name:string
  • 模型增加非空校验
# app/models/quote.rb

class Quote < ApplicationRecord
  validates :name, presence: true
end
  • 修改迁移文件,保证字段非空,预防我们在控制台内出错
# db/migrate/XXXXXXXXXXXXXX_create_quotes.rb

class CreateQuotes < ActiveRecord::Migration[7.0]
  def change
    create_table :quotes do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end
  • 执行迁移文件,这时候表就创建好了
bin/rails db:migrate

添加路由和控制器

  • 生成Quote的Controller控制器
bin/rails generate controller Quotes
  • 增加Quote的增删改查路由
# config/routes.rb

Rails.application.routes.draw do
  resources :quotes
end
  • 编写Controller And Action
# app/controllers/quotes_controller.rb

class QuotesController < ApplicationController
  before_action :set_quote, only: [:show, :edit, :update, :destroy]

  def index
    @quotes = Quote.all
  end

  def show
  end

  def new
    @quote = Quote.new
  end

  def create
    @quote = Quote.new(quote_params)

    if @quote.save
      redirect_to quotes_path, notice: "Quote was successfully created."
    else
      render :new
    end
  end

  def edit
  end

  def update
    if @quote.update(quote_params)
      redirect_to quotes_path, notice: "Quote was successfully updated."
    else
      render :edit
    end
  end

  def destroy
    @quote.destroy
    redirect_to quotes_path, notice: "Quote was successfully destroyed."
  end

  private

  def set_quote
    @quote = Quote.find(params[:id])
  end

  def quote_params
    params.require(:quote).permit(:name)
  end
end

添加视图

注意:这里我们会添加一些css类名,下一章我们将构建自己的css文件系统。所以你只需要简单的复制即可

  • Quotes#index页面
<%# app/views/quotes/index.html.erb %>

<main class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary" %>
  </div>

  <%= render @quotes %>
</main>
  • 抽取局部共用文件_quote.html.erb,遍历@quotes,并渲染视图
<%# app/views/quotes/_quote.html.erb %>

<div class="quote">
  <%= link_to quote.name, quote_path(quote) %>
  <div class="quote__actions">
    <%= button_to "Delete",
                  quote_path(quote),
                  method: :delete,
                  class: "btn btn--light" %>
    <%= link_to "Edit",
                edit_quote_path(quote),
                class: "btn btn--light" %>
  </div>
</div>
  • new.html.erb 和 edit.html.erb是很类似的
<%# app/views/quotes/new.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

  <%= render "form", quote: @quote %>
</main>
<%# app/views/quotes/edit.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quote"), quote_path(@quote) %>

  <div class="header">
    <h1>Edit quote</h1>
  </div>

  <%= render "form", quote: @quote %>
</main>
  • 抽取局部共用文件_form.html.erb,也就修改与新增的表单
<%# app/views/quotes/_form.html.erb %>

<%= simple_form_for quote, html: { class: "quote form" } do |f| %>
  <% if quote.errors.any? %>
    <div class="error-message">
      <%= quote.errors.full_messages.to_sentence.capitalize %>
    </div>
  <% end %>

  <%= f.input :name, input_html: { autofocus: true } %>
  <%= f.submit class: "btn btn--secondary" %>
<% end %>

可以看到我们的表单标签很简易,并且这里的表单可以不需要我们鼠标操作,直接键盘操作数据,因为使用到simple_form gem,所以添加到Gemfile中,并下载

# Gemfile

gem "simple_form", "~> 5.1.0"
  • gem添加后,执行:
bundle install
bin/rails generate simple_form:install

simple_form可以让表单更容易操作,并使用统一的css样式,现在我们修改配置。

# config/initializers/simple_form.rb

SimpleForm.setup do |config|
  # Wrappers configration
  config.wrappers :default, class: "form__group" do |b|
    b.use :html5
    b.use :placeholder
    b.use :label, class: "visually-hidden"
    b.use :input, class: "form__input", error_class: "form__input--invalid"
  end

  # Default configuration
  config.generate_additional_classes_for = []
  config.default_wrapper                 = :default
  config.button_class                    = "btn"
  config.label_text                      = lambda { |label, _, _| label }
  config.error_notification_tag          = :div
  config.error_notification_class        = "error_notification"
  config.browser_validations             = false
  config.boolean_style                   = :nested
  config.boolean_label_class             = "form__checkbox-label"
end

上面的:config.default_wrapper = :default可以保证我们系统中的表单使用统一的样式。

当我们使用f.input :name构建Quote时,默认包装器将会生成以下的HTML:

<div class="form__group">
  <label class="visually-hidden" for="quote_name">
    Name
  </label>
  <input class="form__input" type="text" name="quote[name]" id="quote_name">
</div>

Simple form也帮助我们定义了标签文本提示,在另一个配置文件中。

# config/locales/simple_form.en.yml

en:
  simple_form:
    placeholders: # 文本提示
      quote:
        name: Name of your quote
    labels:  # 标签提示
      quote:
        name: Name

  helpers:
    submit: # 按钮文本
      quote:
        create: Create quote # 如果为新的数据,显示创建
        update: Update quote # 如果为老的数据,显示修改
  • 最后就是Quotes#show页面
<%# app/views/quotes/show.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>
  <div class="header">
    <h1>
      <%= @quote.name %>
    </h1>
  </div>
</main>

现在我们再跑:bin/rails test:system 就可以通过了,也可以页面中测试。

你会发现测试时,浏览器会自动打开,并执行测试流程,这个比较费时,你可以配置以下内容,来关闭浏览器打开设置

# test/application_system_test_case.rb

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  # Change :chrome with :headless_chrome
  driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end

需要注意我们安装了新的gem并且修改了配置文件,所以应该项目重新启动,保证内容加载完毕。

Turbo Drive: Form responses must redirect to another location

现在还有一个问题,当我们提交时,打开浏览器的控制台,我们将会在Console中看到:Form responses must redirect to another location

这是由于Rails7中的Turbo Drive带来的变化,我们将在Turbo Drive中讨论这一话题,如果你遇到这个问题,只需要增加:status: :unprocessable_entity

# app/controllers/quotes_controller.rb

class QuotesController < ApplicationController
  # ...

  def create
    @quote = Quote.new(quote_params)

    if @quote.save
      redirect_to quotes_path, notice: "Quote was successfully created."
    else
      # Add `status: :unprocessable_entity` here
      render :new, status: :unprocessable_entity
    end
  end

  # ...

  def update
    if @quote.update(quote_params)
      redirect_to quotes_path, notice: "Quote was successfully updated."
    else
      # Add `status: :unprocessable_entity` here
      render :edit, status: :unprocessable_entity
    end
  end

  # ...
end

现在,我们增删改查就都OK了,就是页面太丑了,下一章我们将添加CSS样式。

Seeding

当我们第一次打开项目时,也没有数据,每次手动添加又太麻烦,我们知道可以通过db/seeds.rb来初始化数据,不过既然我们已经在fixtures中定义了数据了,就可以利用这一部分。

  • 使用seed数据:bin/rails db:seed
  • 使用fixture数据:bin/rails db:fixtures:load

不过我们也可以修改seeds.rb文件:

puts "\n== Seeding the database with fixtures =="
system("bin/rails db:fixtures:load")

这样再执行:bin/rails db:seed时,就自动加载fixtures文件内容


组织CSS文件

这一章我们将使用BEM方法论,去设计项目的CSS样式,这里我们将不会使用Bootstrap或者Tailwind,因为作者觉得不好看,并打算展示一些技巧

如果你喜欢CSS,你可以学习到一些技巧,如果你不喜欢,也可直接复制代码,进入下一章的Turbo学习

我们的CSS风格

CSS是一个比较难掌握的话题,像其他的编程一样,它需要一些风格和约定才能更好的使用,而学习写CSS最好的办法就是写一个小项目去设计样式

BEM方法论

对于命名约定,我们使用BEM方法论,它是简单易懂的,总结为以下三点:

  1. 每个 component(or block) 应有有独立的名字,比如系统中的card.card的CSS类应该定义在card.scss文件中,这就BEM中的B。
  2. 每个 block 可能有多个 elements,比如 card 举例,每个card都有 title 和 body,在BEM中我们应该写为 .card_title.card_body,这样就可以避免命名冲突,如果另有一个block为 .box也有 title 和 body,那么这个就是.box_title.box_body,这就是BEM中的E
  3. 每个 block 可能有多个 modifiers,再用 card 的例子,每个card可能有不同的颜色,那这个命名就该是:.card--primary and .card--secondary这就是BEM中的M

这样就可以避免命名冲突了

组织CSS文件

现在我们有了健壮的命名约定,是时候讨论文件组织了,这个项目很简单,我们也会有一个简单的架构

我们的app/assets/stylesheets/文件夹将会包含4个 elements

  • application.sass.scss导入所有的样式
  • A mixins/ folder where we'll add Sass mixins
  • A config/ folder where we'll add our variables and global styles
  • A components/ folder where we'll add our components
  • A layouts/ folder where we'll add our layouts
components 和 layouts 有什么区别?

components是页面中独立的部分,它不应该关心它会被放到哪里,而是只关心样式,一个好的例子是:按钮,按钮不知道它会被放哪里

layout相反的,不添加样式,只关注与定位,好的例子是:container转载页面内容,如果你好奇样式相关内容,可以看这里:

一旦我们建立了我们的设计系统,我们将能够创建新的页面在没有时间编写额外的CSS组成组件和布局。


注意 components 和 margins

理论上,components不应该有外边距,当我们设计一个独立的components时,我们也不知道他被放到页面哪里。比如按钮,不管是垂直还是水平放置,都没道理增加外边距,出现几个空格的距离。

我们不能提前预知独立的components在哪里被使用,这是layouts的职责,随着 design system 的壮大,如果components更容易与其他组件合作将会更容易的复用。

虽然那么说,但是在本教程中,我们将打破这些规则,我会直接在components上加入margin。因为这个项目不会再扩展,我不希望事情变的太复杂,不过如果你做真实项目时,应该记住上面的规则


足够的理论了,现在我们将要写SASS代码了,让我们开始吧

使用我们自己的CSS在quote编辑器上

The mixins folder

这个文件夹是最小的,只有一个_media.scss文件,我们将定义一个breakpointsmedia queries,在我们的项目只有一个叫做tabletAndUpbreakpoints

// app/assets/stylesheets/mixins/_media.scss

@mixin media($query) {
  @if $query == tabletAndUp {
    @media (min-width: 50rem) { @content; }
  }
}

当写css时,我们先按照移动端写css,例如:

.my-component {
  // The CSS for mobile
}

当需要为大尺寸overrides时,使用我们的 media query 可以让事情变得更简单

.my-component {
  // The CSS for mobile

  @include media(tabletAndUp) {
    // The CSS for screens bigger than tablets
  }
}

这就是 modile first approach,我们先定义小尺寸,再添加override的大尺寸,这是一个使用 mixin 的好的实践,如果后面想增加更多的breakpoints,例如:laptopAndUp or desktopAndUp 就变得很容易了。

tabletAndUp50rem更容易阅读

并且这可以帮我们避免写重复的代码,比如:

.component-1 {
  // The CSS for mobile

  @media (min-width: 50rem) {
    // The CSS for screens bigger than 50rem
  }
}

.component-2 {
  // The CSS for mobile

  @media (min-width: 50rem) {
    // The CSS for screens bigger than 50rem
  }
}

想象一下我们要把50rem改为55rem在一些points中,那会是一场维护噩梦。

最后在一个地方有一个精心策划的断点列表可以帮助我们选择最相关的断点,从而限制我们的选择!

这就是我们第一个css文件要说的内容,但很有用,我们的quote编辑器,必须是响应迅速的,并且这是一个简单强大的breakpoints实现

The configuration folder

其中一个重要的文件夹是 variables files,去构建一个强壮的设计系统,这里就是我们将选择好看的颜色,可读的字体和保证一直的间距性。

首先:让我们开始设计文本,比如字体,颜色,大小和行高

// app/assets/stylesheets/config/_variables.scss

:root {
  // Simple fonts
  --font-family-sans: 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;

  // Classical line heights
  --line-height-headers: 1.1;
  --line-height-body:    1.5;

  // Classical and robust font sizes system
  --font-size-xs: 0.75rem;   // 12px
  --font-size-s: 0.875rem;   // 14px
  --font-size-m: 1rem;       // 16px
  --font-size-l: 1.125rem;   // 18px
  --font-size-xl: 1.25rem;   // 20px
  --font-size-xxl: 1.5rem;   // 24px
  --font-size-xxxl: 2rem;    // 32px
  --font-size-xxxxl: 2.5rem; // 40px

  // Three different text colors
  --color-text-header: hsl(0, 1%, 16%);
  --color-text-body:   hsl(0, 5%, 25%);
  --color-text-muted:  hsl(0, 1%, 44%);
}

第一个Variables设置帮助我们确认我们的文本设计在整个系统中是一致的。

保持一致的spacing,padding,margins在系统中也是必要的,让我们开始构建简单的标尺。

// app/assets/stylesheets/config/_variables.scss

:root {
  // All the previous variables

  // Classical and robust spacing system
  --space-xxxs: 0.25rem; // 4px
  --space-xxs: 0.375rem; // 6px
  --space-xs: 0.5rem;    // 8px
  --space-s: 0.75rem;    // 12px
  --space-m: 1rem;       // 16px
  --space-l: 1.5rem;     // 24px
  --space-xl: 2rem;      // 32px
  --space-xxl: 2.5rem;   // 40px
  --space-xxxl: 3rem;    // 48px
  --space-xxxxl: 4rem;   // 64px
}

关于颜色:

// app/assets/stylesheets/config/_variables.scss

:root {
  // All the previous variables

  // Application colors
  --color-primary:          hsl(350, 67%, 50%);
  --color-primary-rotate:   hsl(10, 73%, 54%);
  --color-primary-bg:       hsl(0, 85%, 96%);
  --color-secondary:        hsl(101, 45%, 56%);
  --color-secondary-rotate: hsl(120, 45%, 56%);
  --color-tertiary:         hsl(49, 89%, 64%);
  --color-glint:            hsl(210, 100%, 82%);

  // Neutral colors
  --color-white:      hsl(0, 0%, 100%);
  --color-background: hsl(30, 50%, 98%);
  --color-light:      hsl(0, 6%, 93%);
  --color-dark:       var(--color-text-header);
}

The last part of our variables file will contain various user interface styles such as border radiuses and box shadows. Once again, the goal is to ensure consistency in our application.

// app/assets/stylesheets/config/_variables.scss

:root {
  // All the previous variables

  // Border radius
  --border-radius: 0.375rem;

  // Border
  --border: solid 2px var(--color-light);

  // Shadows
  --shadow-large:  2px 4px 10px hsl(0 0% 0% / 0.1);
  --shadow-small:  1px 3px 6px hsl(0 0% 0% / 0.1);
}

这就是所有了,我们的系统使用统一的样式。现在 apply those variables to global styles i.e。

// app/assets/stylesheets/config/_reset.scss

*,
*::before,
*::after {
  box-sizing: border-box;
}

* {
  margin: 0;
  padding: 0;
}

html {
  overflow-y: scroll;
  height: 100%;
}

body {
  display: flex;
  flex-direction: column;
  min-height: 100%;

  background-color: var(--color-background);
  color: var(--color-text-body);
  line-height: var(--line-height-body);
  font-family: var(--font-family-sans);
}

img,
picture,
svg {
  display: block;
  max-width: 100%;
}

input,
button,
textarea,
select {
  font: inherit;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  color: var(--color-text-header);
  line-height: var(--line-height-headers);
}

h1 {
  font-size: var(--font-size-xxxl);
}

h2 {
  font-size: var(--font-size-xxl);
}

h3 {
  font-size: var(--font-size-xl);
}

h4 {
  font-size: var(--font-size-l);
}

a {
  color: var(--color-primary);
  text-decoration: none;
  transition: color 200ms;

  &:hover,
  &:focus,
  &:active {
    color: var(--color-primary-rotate);
  }
}

现在我们可以设计独立的components

The components folder

这个文件夹将会包含我们独立的components样式、

Let’s start by, believe it or not, our most complex components: buttons. We will start with the base .btn class and then add four modifiers for the different styles:

// app/assets/stylesheets/components/_btn.scss

.btn {
  display: inline-block;
  padding: var(--space-xxs) var(--space-m);
  border-radius: var(--border-radius);
  background-origin: border-box; // Invisible borders with linear gradients
  background-color: transparent;
  border: solid 2px transparent;
  font-weight: bold;
  text-decoration: none;
  cursor: pointer;
  outline: none;
  transition: filter 400ms, color 200ms;

  &:hover,
  &:focus,
  &:focus-within,
  &:active {
    transition: filter 250ms, color 200ms;
  }

  // Modifiers will go there
}

btn类是一个内联块元素,我们为其添加了默认样式,如padding, border-radius和transition。注意,在Sass中,&号对应于直接嵌套&号的选择器。在我们的例子中&:hover,将被Sass转换为CSS中的:btn:hover。

现在我们为其添加四个样式:

// app/assets/stylesheets/components/_btn.scss

.btn {
  // All the previous code

  &--primary {
    color: var(--color-white);
    background-image: linear-gradient(to right, var(--color-primary), var(--color-primary-rotate));

    &:hover,
    &:focus,
    &:focus-within,
    &:active {
      color: var(--color-white);
      filter: saturate(1.4) brightness(115%);
    }
  }

  &--secondary {
    color: var(--color-white);
    background-image: linear-gradient(to right, var(--color-secondary), var(--color-secondary-rotate));

    &:hover,
    &:focus,
    &:focus-within,
    &:active {
      color: var(--color-white);
      filter: saturate(1.2) brightness(110%);
    }
  }

  &--light {
    color: var(--color-dark);
    background-color: var(--color-light);

    &:hover,
    &:focus,
    &:focus-within,
    &:active {
      color: var(--color-dark);
      filter: brightness(92%);
    }
  }

  &--dark {
    color: var(--color-white);
    border-color: var(--color-dark);
    background-color: var(--color-dark);

    &:hover,
    &:focus,
    &:focus-within,
    &:active {
      color: var(--color-white);
    }
  }
}

还是上面的规则:&--primary 会被转为:.btn--primary通过Sass预处理器。

我们再写关于.quotecomponent

// app/assets/stylesheets/components/_quote.scss

.quote {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-s);

  background-color: var(--color-white);
  border-radius: var(--border-radius);
  box-shadow: var(--shadow-small);
  margin-bottom: var(--space-m);
  padding: var(--space-xs);

  @include media(tabletAndUp) {
    padding: var(--space-xs) var(--space-m);
  }

  &__actions {
    display: flex;
    flex: 0 0 auto;
    align-self: flex-start;
    gap: var(--space-xs);
  }
}

下来是.form 和 .visually-hidden,之前我们使用了simple form,其中定义了:

config.wrappers :default, class: "form__group" do |b|
  b.use :html5
  b.use :placeholder
  b.use :label, class: "visually-hidden"
  b.use :input, class: "form__input", error_class: "form__input--invalid"
end

这里我们开始定义.form

// app/assets/stylesheets/components/_form.scss

.form {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);

  &__group {
    flex: 1;
  }

  &__input {
    display: block;
    width: 100%;
    max-width: 100%;
    padding: var(--space-xxs) var(--space-xs);
    border: var(--border);
    border-radius: var(--border-radius);
    outline: none;
    transition: box-shadow 250ms;

    &:focus {
      box-shadow: 0 0 0 2px var(--color-glint);
    }

    &--invalid {
      border-color: var(--color-primary);
    }
  }
}

下来是.visually-hidden component

// app/assets/stylesheets/components/_visually_hidden.scss

// Shamelessly stolen from Bootstrap

.visually-hidden {
  position: absolute !important;
  width: 1px !important;
  height: 1px !important;
  padding: 0 !important;
  margin: -1px !important;
  overflow: hidden !important;
  clip: rect(0, 0, 0, 0) !important;
  white-space: nowrap !important;
  border: 0 !important;
}

展示异常信息

// app/assets/stylesheets/components/_error_message.scss

.error-message {
  width: 100%;
  color: var(--color-primary);
  background-color: var(--color-primary-bg);
  padding: var(--space-xs);
  border-radius: var(--border-radius);
}

That’s it; we have all the components we need to complete the CRUD on our Quote model. We just need two layouts now, and we will be done with the CSS!

The layouts folder

// app/assets/stylesheets/layouts/_container.scss

.container {
  width: 100%;
  padding-right: var(--space-xs);
  padding-left: var(--space-xs);
  margin-left: auto;
  margin-right: auto;

  @include media(tabletAndUp) {
    padding-right: var(--space-m);
    padding-left: var(--space-m);
    max-width: 60rem;
  }
}
// app/assets/stylesheets/layouts/_header.scss

.header {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-s);
  justify-content: space-between;
  margin-top: var(--space-m);
  margin-bottom: var(--space-l);

  @include media(tabletAndUp) {
    margin-bottom: var(--space-xl);
  }
}

好了这些就是所有内容,现在我们导入到 manifest file 为了 Sass 去处理他们

The manifest file

// app/assets/stylesheets/application.sass.scss

// Mixins
@import "mixins/media";

// Configuration
@import "config/variables";
@import "config/reset";

// Components
@import "components/btn";
@import "components/error_message";
@import "components/form";
@import "components/visually_hidden";
@import "components/quote";

// Layouts
@import "layouts/container";
@import "layouts/header";

赶紧结束吧,我们已经写了太多让人头晕的css代码了。

Turbo Drive

这一章我们将解释Turbo Drive是什么?它是怎么加速Rails系统通过转化Link clicks和form submissions为Ajax请求。

是什么?

Turbo Drive`是`Turbo`的第一部分,已经被Rails7默认引入了,可以看一下Gemfile和`application.js
# Gemfile

# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
// app/javascript/application.js

// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"

默认的,Turbo Drive加速了Rails应用,通过转化Ajax请求,所以从一开始,我们的增删改查项目就已经是一个单页面应用,并且没有写一行Js代码。

通过Turbo Drive,HTML 页面不会被完全重新渲染,当我们的Turbo Drive拦截一个link clikc or a form submission时,这个Ajax请求的响应体只会替换HTML也页面中的部分,其中大多数请求部分不会修改,这就使性能得到优化,因为不需要重复下载相同的CSS和JS,当你第一次访问网页时,就下载了所有的CSS和JS文件,后面就不需要再加载了。

但隐藏的问题是:如果服务端CSS和JS代码更新了,但用户之前访问过网页,CSS和JS不再更新怎么办?我们将在后面回答这个问题、

Turbo Drive是如何工作的?

Turbo Drive拦截了 links 的单击事件和 forms 的提交事件。

每次一个 link is clicked ,Turbo Drive 就会拦截该事件,重写默认的行为转为Ajax请求,并把结果中的部分与原页面做替换。

这也是为什么Rails7项目默认为单页面应用,页面第一次访问不会完全从新渲染。只有部分

每当有links被click时,伪代码实现例如:

// Select all links on the page
const links = document.querySelectorAll("a");

// Add a "click" event listener on each link to intercept the click
// and override the default behavior
links.forEach((link) => {
  link.addEventListener("click", (event) => {
    // Override default behavior
    event.preventDefault()
    // Convert the click on the link into an AJAX request
    // Replace the current page's <body> with the <body> of the response
    // and leave the <head> unchanged
  }
)});

对于表单提交也是类似的,不再展示伪代码。


在前面我们讨论过,Rails7的一大变化,Invalid form submissions have to return a 422 status code for Turbo Drive to replace the of the page and display the form errors.

422状态码就是指:Rails中的:unprocessable_entity,如果你看scaffold generator生成的代码,就会发现默认带有status: :unprocessable_entity to #create and #update


TurbolinksTurbo Drive的前身,只是前者只能拦截 clicks on links不包含表单提交,现在Turbo Drive包含处理表单提交,所以作者重命名TurboLinks为Turbo Drive。


禁用Turbo Drive

在某些情况中,我们需要禁用Turbo Drive,比如某些Gem不支持Turbo Drive,一个好的应对方式就是在特定的地方禁用,例如登陆和退出的表单。下面我们示例禁用特定的links and forms

只需要添加:data-turbo="false"

比如在Quotes#index页面,让我们禁用新建功能中的Turbo Drive

<main class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo: false } %>
  </div>

  <%= render @quotes %>
</main>

如果你去看控制台中的网络包,你会发现每次请求时有四个请求。

  • One HTML request to load the Quotes#new HTML page
  • One request to load the CSS bundle
  • One request to load the JavaScript bundle
  • One request to load the favicon of the page

这里是用谷歌浏览器,如果你是其他浏览器,可能不太一样


但如果我们恢复刚才的代码,再次请求,就会发现只有两个请求:

  • One AJAX request to get the HTML for the Quotes#new page
  • One request to load the favicon of the page

同样的我们也可以测试表单提交:

<%= simple_form_for quote,
                    html: {
                      class: "quote form",
                      data: { turbo: false }
                    } do |f| %>

  <% if quote.errors.any? %>
    <div class="error-message">
      <%= quote.errors.full_messages.to_sentence.capitalize %>
    </div>
  <% end %>

  <%= f.input :name, input_html: { autofocus: true } %>
  <%= f.submit class: "btn btn--secondary" %>
<% end %>

请求时,发现有五个请求包:

  • One HTML request to submit the form
  • The HTML redirection to the Quotes#index page
  • One request to load the CSS bundle
  • One request to load the JavaScript bundle
  • One request to load the favicon of the page

当我们恢复时,再看只有三个请求包

  • One AJAX request to submit the form
  • The AJAX redirection to the Quotes#index page
  • One request to load the favicon of the page

我们已经证明了Turbo Drive为我们做什么了。

  • 它转化了所有的 Link clicks 和 form submissions 为AJAX请求,并加速应用
  • 它阻拦了加载CSS和JS文件的重复加载

我们发现,我们也没有写一句JS代码


如果你想全局禁用Turbo Drive,虽然我们不推荐,你可以以下操作:

// app/javascript/application.js

import { Turbo } from "@hotwired/turbo-rails"
Turbo.session.drive = false

Reloading the page with data-turbo-track=“reload”

之前我们说过,ajax请求仅仅替换中的内容,而没有,导致用户无法使用最新的JS和CSS文件。

为了解决这个问题,每次新的请求,Turbo Drive比较DOM节点中的data-turbo-track="reload"和响应中的,如果不同则重新加载整个页面。

如果你看一下application.html.erb,就会发现:

<%# app/views/layouts/application.html.erb %>

<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>

现在我们知道他的作用了。

这里你可以打开控制台去看一下请求,然后修改你的CSS或JS文件,再发一个请求,看一下和之前有什么区别?


**注意:**做一些小的测试体验,像上面那样,会让我们更清晰的明白发生了什么事儿,在下面的章节中我们还会做一些小的测试。


Changing the style of the Turbo Drive progress bar

随着Turbo Drive重写浏览器的默认行为,浏览器的进度条会受到一些影响。

Turbo已经为浏览器默认加载条构建了替代品,并且我们可以引入自己的样式,让我们先来写样式:

// app/assets/stylesheets/components/_turbo_progress_bar.scss

.turbo-progress-bar {
  background: linear-gradient(to right, var(--color-primary), var(--color-primary-rotate));
}

别忘了引入Sass文件

// app/assets/stylesheets/application.sass.scss

// All the previous code
@import "components/turbo_progress_bar";

想要查看到我们的样式是否成功的加载,一个好办法就是让我们程序休眠几秒,我们可以试试:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  # Add this line to see the progress bar long enough
  # and remove it when it has the expected styles
  before_action -> { sleep 3 }
end

现在你就能更方便的看到效果了,让我们删除这段临时代码吧

结论

可以看到这一章很简单,我们基本没写什么自定义JS代码,就能获得实质性的性能提升。

下一章,我们将使用Turbo Freames去转化一些复合页面为小的片段页面。

Turbo Frames and Turbo Stream templates

这一章节,我们将学习如何切割页面为小的独立部分,读完这一章节后,增删改查行为就会统一在index页面展示。

要干嘛?

在我们现在的quotes编辑器中,当进行#new and #edit时,会跳转到其他的页面,而本章希望通过Turbo Frames and Turbo Streams做到当操作时,均在Quotes#index页面进行。可以看看线上的效果:https://www.hotrails.dev/quotes

在我们练习Turbo Frame技巧前,先画一些草图来说明我们要干的事儿,然后更新我们的系统测试。

现在我们的Quotes#index页面长这个样子

img

现在我们希望当点击New quote时,表单可以添加到原来页面的标题下面

img

当我们点击Create quote按钮时,创建好的quote应该展示在quotes列表的最上面

img

当我们点击第二个quote的Edit按钮时,表格应该直接替换选择的这个quote卡片

img

当我们点击Update quote按钮时,又展示回卡片的样式,并且数据更新

img

而其他的行为,应该不受到影响

  • 点击Delete按钮时,该条quote应该就被删除掉来了
  • 点击quote的标题时,应该跳转到Quotes#show页面

让我们现在更新测试代码来匹配对应的设想行为

# test/system/quotes_test.rb

require "application_system_test_case"

class QuotesTest < ApplicationSystemTestCase
  setup do
    @quote = quotes(:first)
  end

  test "Showing a quote" do
    visit quotes_path
    click_link @quote.name

    assert_selector "h1", text: @quote.name
  end

  test "Creating a new quote" do
    visit quotes_path
    assert_selector "h1", text: "Quotes"

    click_on "New quote"
    fill_in "Name", with: "Capybara quote"

    assert_selector "h1", text: "Quotes"
    click_on "Create quote"

    assert_selector "h1", text: "Quotes"
    assert_text "Capybara quote"
  end

  test "Updating a quote" do
    visit quotes_path
    assert_selector "h1", text: "Quotes"

    click_on "Edit", match: :first
    fill_in "Name", with: "Updated quote"

    assert_selector "h1", text: "Quotes"
    click_on "Update quote"

    assert_selector "h1", text: "Quotes"
    assert_text "Updated quote"
  end

  test "Destroying a quote" do
    visit quotes_path
    assert_text @quote.name

    click_on "Delete", match: :first
    assert_no_text @quote.name
  end
end

什么是Turbo Frames?

Turbo Frames are independent pieces of a web page that can be appended, prepended, replaced, or removed without a complete page refresh and writing a single line of JavaScript!

也就是可以添加,删除,替换独立的网页片段,而不需要刷新和写自定义的JS。

这一部分中,我们将使用几个小的案例来学习Turbo Frames,然后再实现我们上面的预想。

让我们创建第一个Turbo Frame,这里我们使用turbo_frame_tag helper,我们把Quotes#index页面中header部分嵌套到 id 为"first_turbo_frame"的Turbo Frame中

<%# app/views/quotes/index.html.erb %>

<main class="container">
  <%= turbo_frame_tag "first_turbo_frame" do %>
    <div class="header">
      <h1>Quotes</h1>
      <%= link_to "New quote", new_quote_path, class: "btn btn--primary" %>
    </div>
  <% end %>

  <%= render @quotes %>
</main>

如果你看一下DOM,Turbo Frame生成的HTML就长下面的样子

<turbo-frame id="first_turbo_frame">
  <div class="header">
    <h1>Quotes</h1>
    <a class="btn btn--primary" href="/quotes/new">New quote</a>
  </div>
</turbo-frame>

如我们看到的,turbo_frame_tag helper帮助我们创建了自定义标签,其中id就是我们传递给helper的第一个参数

标签并不存在于HTML官方定义中,这是Turbo JavaScript Library中定义的元素。它拦截的 form submissions and clicks on links,让这些frames成为你网页的独立部分!

现在点击New quote按钮,页面上的这部分消失了,并且浏览器控制台显示:Response has no matching <turbo-frame id="first_turbo_frame"> element,让我们来解释这奇怪的现象。

Turbo Frames cheat sheet

这一部分我们将探索一些应用于Turbo Frames的规则

尽管我们的例子都是 Links , 不过同样适用于 Forms


**规则1:**当你点击一个嵌套Turbo Frame的超链接,Turbo期待在目标页面中有一个相同ID的frame,然后目标页面的 frame内容将会替换原页面中的frame内容。

单纯去说,会比较难懂,我们来画一些草图,来更好的理解

现在的Quotes#index页面长这个样子:

img

现在我们用同样的ID嵌套Quotes#new页面片段

为了匹配草图的内容,我们现在改改代码

<%# app/views/quotes/new.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

  <%= turbo_frame_tag "first_turbo_frame" do %>
    <%= render "form", quote: @quote %>
  <% end %>
</main>

现在我们在浏览器中试试,当我们刷新Quotes#index页面,并且点击New quote按钮时,你会发现Quotes#new页面中被嵌套的内容,替换了Quotes#index页面中相同id的内容。


**规则2:**当你点击一个嵌套了Turbo Frame的超链接,但目标页面没有相同ID的frame,这个 frame消失,并且响应异常信息将会以日志的形式,显示在控制台中。

还记得我们上面遇到的奇怪现象吗?这就是因为规则二导致的,如果你在Quotes#new的form上,再写其他ID的frame,刷新页面,并点击New quote,就会看到异常日志再次出现。


**规则3:**link 可以指定另外一个frame id去串联内容

这个规则很有用,但需要更多的草图去理解它,我们现在使用id为second_frame的Turbo Frame去嵌套quote列表,在Quotes#index页面

img

Quotes#new页面中,让我们使用相同的ID去嵌套表单

img

根据草图,修改我们的源码,我在代码中标记了关键部分

<%# app/views/quotes/index.html.erb %>

<main class="container">
  <%= turbo_frame_tag "first_turbo_frame" do %>
    <div class="header">
      <h1>Quotes</h1>
      <%= link_to "New quote",
                  new_quote_path,
                  <% # 这里是关键,虽然被first_turbo_frame的frame嵌套,
                    但是指定了second_frame的frame %>
                  data: { turbo_frame: "second_frame" },
                  class: "btn btn--primary" %>
    </div>
  <% end %>

  <%= turbo_frame_tag "second_frame" do %>
    <%= render @quotes %>
  <% end %>
</main>
<%# app/views/quotes/new.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

  <%= turbo_frame_tag "second_frame" do %>
    <%= render "form", quote: @quote %>
  <% end %>
</main>

现在我们再试试,我们访问Quotes#index页面,刷新,点击New quoto按钮,可以看到我们的quotes列表被表单替换掉了。这个就是我们传递的 data: { turbo_frame: "second_frame" }发挥的作用。


注意:

有一个特殊的frame代表整个页面,但它并不是一个真是的Turbo Frame,但是又很像,所以你基本可以认为是一致的。

比如:如果你想让New quote按钮去替换整个页面,我们可以使用data-turbo-frame="_top"像下面这样:

img

当然每个页面都默认有_top frame,所以Quotes#new也有

img

我们该代码试试:

<%# app/views/quotes/index.html.erb %>

<main class="container">
  <%= turbo_frame_tag "first_turbo_frame" do %>
    <div class="header">
      <h1>Quotes</h1>
      <%= link_to "New quote",
                  new_quote_path,
                  data: { turbo_frame: "_top" },
                  class: "btn btn--primary" %>
    </div>
  <% end %>

  <%= render @quotes %>
</main>

我们可以在Quotes#new页面中添加任意内容,因为浏览器会替换整个页面,例如:我们把Quotes#new页面恢复为最初的样子

<%# app/views/quotes/new.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

  <%= render "form", quote: @quote %>
</main>

现在我们再访问Quotes#indexd页面,并进行添加,可以看到整个页面都被Quotes#new页面替换了。

当使用**_top**关键词时,页面的URL会改变为目标页面的URL,这与使用常规Turbo Frame时的不同之处。


可以看到,Turbo Frames算是Rails开发人员的新增利器,它可以帮助我们切割独立的页面而不需要写什么JS代码。

上面的内容针对我们的项目基本够用了,但我们仍要学习两个东西:

  • 怎么将Turbo FramesTurbo_stream fomat结合使用?
  • 如何对Frames命名,通过良好的约定

让我们开始练习,并通过测试,在此之前记得把Quotes#index页面恢复为最初状态

<%# app/views/quotes/index.html.erb %>

<main class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote", new_quote_path, class: "btn btn--primary" %>
  </div>

  <%= render @quotes %>
</main>

Editing quotes with Turbo Frames

现在我们先做quotes修改部分使用Turbo Frames,目标就是当点击修改时,edit表单将会替换选中的quote数据。通过上面的学习,这一点很容易做到,但如何给这个Frame起一个好名字呢?

Quotes#index页面中,每个被Turbo Frame嵌套的quote数据都应该有一个唯一ID,一个好的约定就是:模型单数形式_模型的ID,看我们画一个草图展示:

img

假如我们想编辑第二个数据,当我们点击edit时,我们需要Quotes#edit页面被同样的ID frame嵌套

img

通过合适的命名,当我们点击Quotes#index页面的edit按钮时,嵌套表单的frame将会替换嵌套第二条数据的frame。

img

现在开始代码实现,首先在Quotes#index页面,使用quote_#{quote_id}的frame嵌套住每条数据,而index页面的数据都来源于_quote.html.erb部分,所以我们只需要操作这部分:

<%# app/views/quotes/_quote.html.erb %>

<%= turbo_frame_tag "quote_#{quote.id}" do %>
  <div class="quote">
    <%= link_to quote.name, quote_path(quote) %>
    <div class="quote__actions">
      <%= button_to "Delete",
                    quote_path(quote),
                    method: :delete,
                    class: "btn btn--light" %>
      <%= link_to "Edit",
                  edit_quote_path(quote),
                  class: "btn btn--light" %>
    </div>
  </div>
<% end %>

下来是Quotes#edit页面

<%# app/views/quotes/edit.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quote"), quote_path(@quote) %>

  <div class="header">
    <h1>Edit quote</h1>
  </div>

  <%= turbo_frame_tag "quote_#{@quote.id}" do %>
    <%= render "form", quote: @quote %>
  <% end %>
</main>

通过简单的几行代码,我们就实现了功能,你可以在浏览器中去查看,测试一下提交正常数据,异常数据是否都能正常工作。

不过你可能已经注意到当我们想查看quote详情时,不再起作用,并且当删除时,控制台中会爆出异常。这和上面的规则2有关,我们很快去解决,不过在此之前,我们聊聊dom_id helper帮助我们写出就简洁的Turbo Frame ids。

Turbo Frames and the dom_id helper

你可以传递一个字符串或者对象,它能帮助我们转为一个dom_id,比如:

# If the quote is persisted and its id is 1:
dom_id(@quote) # => "quote_1"

# If the quote is a new record:
dom_id(Quote.new) # => "new_quote"

# Note that the dom_id can also take an optional prefix argument
# We will use this later in the tutorial
dom_id(Quote.new, "prefix") # "prefix_new_quote"

turbo_frame_tag helper 自动将对象,转为dom_id,所以我们可以改改之前在Quotes#indexQuotes#edit页面,传递对象即可,下面的代码是等价的:

<%= turbo_frame_tag "quote_#{@quote.id}" do %>
...
<% end %>

  <%= turbo_frame_tag dom_id(@quote) do %>
...
<% end %>

  <%= turbo_frame_tag @quote %>
...
<% end %>

所以我们利用语法糖来改改:

<%# app/views/quotes/_quote.html.erb %>

<%= turbo_frame_tag quote do %>
  <div class="quote">
    <%= link_to quote.name, quote_path(quote) %>
    <div class="quote__actions">
      <%= button_to "Delete",
                    quote_path(quote),
                    method: :delete,
                    class: "btn btn--light" %>
      <%= link_to "Edit",
                  edit_quote_path(quote),
                  class: "btn btn--light" %>
    </div>
  </div>
<% end %>
<%# app/views/quotes/edit.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quote"), quote_path(@quote) %>

  <div class="header">
    <h1>Edit quote</h1>
  </div>

  <%= turbo_frame_tag @quote do %>
    <%= render "form", quote: @quote %>
  <% end %>
</main>

一切工作正常,我们继续。

Showing and deleting quotes

还是上面的问题:

  1. 查看quote详情没法正常工作了,数据也消失了,控制台也报错
  2. 删除按钮也在控制台报错了

现在的Quotes#index页面长下面的样子:

img

每个数据都被quote_id嵌套,但Quotes#show页面并没有相同的ID

img

这里我们就使用"_top"来替换整个页面

<%# app/views/quotes/_quote.html.erb %>

<%= turbo_frame_tag quote do %>
  <div class="quote">
    <%= link_to quote.name,
                quote_path(quote),
                data: { turbo_frame: "_top" } %>
    <div class="quote__actions">
      <%= button_to "Delete",
                    quote_path(quote),
                    method: :delete,
                    class: "btn btn--light" %>
      <%= link_to "Edit",
                  edit_quote_path(quote),
                  class: "btn btn--light" %>
    </div>
  </div>
<% end %>

再在浏览器中测试,发现第一个问题就解决啦

我们也可以用同样的方式,解决第二个问题:

<%# app/views/quotes/_quote.html.erb %>

<%= turbo_frame_tag quote do %>
  <div class="quote">
    <%= link_to quote.name,
                quote_path(quote),
                data: { turbo_frame: "_top" } %>
    <div class="quote__actions">
      <%= button_to "Delete",
                    quote_path(quote),
                    method: :delete,
                    form: { data: { turbo_frame: "_top" } },
                    class: "btn btn--light" %>
      <%= link_to "Edit",
                  edit_quote_path(quote),
                  class: "btn btn--light" %>
    </div>
  </div>
<% end %>

一切工作良好,但出现了个副作用,比如当我们给第二个数据点击Edit时,这时还没有提交,然后删除下面的数据,则页面所有内容被替换,第二条数据的修改框也就没有了。

img

但我们希望的是删除第三条数据时,不影响其他的数据,Turbo and Rails再次帮助了我们,让我们先删除对Delete按钮做出的修改

<%# app/views/quotes/_quote.html.erb %>

<%= turbo_frame_tag quote do %>
  <div class="quote">
    <%= link_to quote.name,
                quote_path(quote),
                data: { turbo_frame: "_top" } %>
    <div class="quote__actions">
      <%= button_to "Delete",
                    quote_path(quote),
                    method: :delete,
                    class: "btn btn--light" %>
      <%= link_to "Edit",
                  edit_quote_path(quote),
                  class: "btn btn--light" %>
    </div>
  </div>
<% end %>

是时候介绍:Turbo_Stream format

The Turbo Stream format

在Rails7中,表单提交会附带Turbo_Stream format,我们删除一个quote,并看看Rails的日志

Started DELETE "/quotes/908005781" for 127.0.0.1 at 2022-01-27 15:30:13 +0100
Processing by QuotesController#destroy as TURBO_STREAM

可以看到#destroy带有Turbo_Stream,让我们看看怎么利用它去解决上面的问题

在Controller中,我们需要通过respond_to支持HTML和Turbo_Stream

# app/controllers/quotes_controller.rb

def destroy
  @quote.destroy

  respond_to do |format|
    format.html { redirect_to quotes_path, notice: "Quote was successfully destroyed." }
    format.turbo_stream
  end
end

我们创建对应的视图

<%# app/views/quotes/destroy.turbo_stream.erb %>

<%= turbo_stream.remove "quote_#{@quote.id}" %>

我们在浏览器中再次删除quote,并查看响应的HTML数据:

<turbo-stream action="remove" target="quote_908005780">
</turbo-stream>

这个就是turbo_stream helper接收了remove方法和"quote_#{@quote.id},然后又转化为了自定义标签

**当浏览器接收到了这段HTML代码,Turbo知道怎么拦截它,**它表现对应的行为,并找到对应的参数ID,我们的例子中,就是删除掉 删除了的数据frame,而其他的页面不变,这正是我们想要的。


注意:当我写下本章内容时,turbo_stream helper帮助我们响应下面的方法:

# Remove a Turbo Frame
turbo_stream.remove

# Insert a Turbo Frame at the beginning/end of a list
turbo_stream.append
turbo_stream.prepend

# Insert a Turbo Frame before/after another Turbo Frame
turbo_stream.before
turbo_stream.after

# Replace or update the content of a Turbo Frame
turbo_stream.update
turbo_stream.replace

当然除了,需要一个对应的方法,turbo_stream helper也需要一个 partial and locals 作为参数,来知道需要操作哪个HTML使用对应方法。下面我们将展开学习。


有了Turbo Frame 和**TURBO_STREAM** format的结合,我们将能够在我们的网页上执行精确的操作,而不必写一行JavaScript,因此保留我们的网页的状态。

还有就是,turbo_streamhelper 也可以使用dom_id,因此我们再修改:

<%# app/views/quotes/destroy.turbo_stream.erb %>

<%= turbo_stream.remove @quote %>

Creating a new quote with Turbo Frames

也就是我们的quote编辑器新增功能,在深处实现之前,我们先画几个草图说明。

我们希望当点击New quote时,表单可以出现在页面头部的下面,当创建好后,把新加的数据放到列表的最上面,并删除原来的表单。为此我们需要多两个Turbo Frames:

  • 一个空的Turbo Frame,帮助我们接收新增表单
  • 一个Turbo Frame嵌套住原来的数据集合,然后可以让新的数据放到最前面。

img

img

img

开始写代码:

Quotes#new页面

<%# app/views/quotes/new.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

  <%= turbo_frame_tag @quote do %>
    <%= render "form", quote: @quote %>
  <% end %>
</main>

因为这里的@quote是一个新的记录,所以下面三个表达式是等价的:

turbo_frame_tag "new_quote"
turbo_frame_tag Quote.new
turbo_frame_tag @quote

现在我们给Quotes#index页面中加入一个空Turbo Frame使用相同的ID,为了接收 new quote form。

<%# app/views/quotes/index.html.erb %>

<main class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo_frame: dom_id(Quote.new) } %>
  </div>

  <%= turbo_frame_tag Quote.new %>
  <%= render @quotes %>
</main>

可以看到我们添加了一个空的Turbo Frame,并且在"New quote"中指定:data: { turbo_frame: dom_id(Quote.new) }用来与连接这个空的Turbo Frame,让我们写下发生什么事儿:

  1. 当我们点击New quote链接,点击事件会被Turbo拦截
  2. Turbo知道了该请求将会与id为new_quote的frame进行互动,这源于我们在New quote链接上指定的data-turbo-frame
  3. 该请求会以ajax形式发送,并且服务端会返回id为new_quoteQuotes#new页面
  4. 当浏览器接收到了这段HTML,Turbo将会提取Quotes#new页面中,id为new_quote的frame,然后替换Quotes#index中相同id的frame

现在我们再试试提交空的数据,当点击Create Quote按钮时,数据是异常的,而异常信息应该出现在页面中,我们再说的细一些

  1. 当我们点击Create Quote按钮时,表单提交将被Turbo拦截
  2. 表单将被id为new_quote的frame嵌套
  3. 服务端接收到了异常参数,并返回Quotes#new页面并带有异常信息
  4. 浏览器接收到status: :unprocessable_entity的响应时,就会将新的new_quote的frame替换原先的,并带有异常信息

现在我们已经完成了草图上的设计,还有一件事儿就是将正常添加的数据,放在数据列表的上面。

如果你在浏览器中去试试,发现虽然数据已经创建到数据库了,但数据并没有放到数据列表的最上面。这是为啥?

当我们用一个正常数据提交表单时,QuotesController#create将会返回Quotes#index页面,其中嵌套一个id为new_quote的空frame,Turbo将会用这个替换我们之前的表单,但是Turbo并不知道怎么处理这个新加的数据,它应该被放到哪里?所以我们将使用Turbo Stream view来处理。

先声明QuotesController需要同时支持HTML和Turbo_Stream formats:

# app/controllers/quotes_controller.rb

def create
  @quote = Quote.new(quote_params)

  if @quote.save
    respond_to do |format|
      format.html { redirect_to quotes_path, notice: "Quote was successfully created." }
      format.turbo_stream
    end
  else
    render :new, status: :unprocessable_entity
  end
end

然后再创建关联的视图

<%# app/views/quotes/create.turbo_stream.erb %>

<%= turbo_stream.prepend "quotes", partial: "quotes/quote", locals: { quote: @quote } %>
<%= turbo_stream.update Quote.new, "" %>

在这个视图中,Turbo做了两件事儿

  1. 告诉Turbo去把app/views/quotes/_quote.html.erb放到id为quotes的frame前面,这里可以看到我们可以直接传参关联。
  2. 第二行代码告诉Turbo去更新id为new_quote的frame,变成空内容。

现在我们就修改Quotes#index页面,将quotes集合使用id为quotes的frame进行嵌套

<%# app/views/quotes/index.html.erb %>

<div class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo_frame: dom_id(Quote.new) } %>
  </div>

  <%= turbo_frame_tag Quote.new %>

  <%= turbo_frame_tag "quotes" do %>
    <%= render @quotes %>
  <% end %>
</div>

现在我们再在浏览器中看看,如果你打开控制台的网络,你将看到表单提交的响应内容将会长下面的样子:

<turbo-stream action="prepend" target="quotes">
  <template>
    <turbo-frame id="quote_123">
      <!-- The HTML for the quote partial -->
    <turbo-frame>
  </template>
</turbo-stream>

<turbo-stream action="update" target="new_quote">
  <template>
    <!-- An empty template! -->
  </template>
</turbo-stream>

我们传递的参数,可以被Turbo理解,然后Turbo执行对应的行为(append, prepend, replace, remove)在目标Fream中


现在我们讲讲一些技巧,关于Turbo Stream views中,不同的写法代表的意思是一样的。

  • 这是我们之前写的
<%# app/views/quotes/create.turbo_stream.erb %>

<%= turbo_stream.prepend "quotes", partial: "quotes/quote", locals: { quote: @quote } %>
<%= turbo_stream.update Quote.new, "" %>
  • 当一行过长时,我们可以修改一下
<%# app/views/quotes/create.turbo_stream.erb %>

<%= turbo_stream.prepend "quotes" do %>
  <%= render partial: "quotes/quote", locals: { quote: @quote } %>
<% end %>

<%= turbo_stream.update Quote.new, "" %>
  • 而在Rails中下面的表述是一致的
render partial: "quotes/quote", locals: { quote: @quote }
render @quote
  • 所以我们将我们写的进行简化
<%# app/views/quotes/create.turbo_stream.erb %>

<%= turbo_stream.prepend "quotes" do %>
  <%= render @quote %>
<% end %>

<%= turbo_stream.update Quote.new, "" %>
  • 而这里的行并不长,所以不需要使用块儿语法
<%# app/views/quotes/create.turbo_stream.erb %>

<%= turbo_stream.prepend "quotes", @quote %>
<%= turbo_stream.update Quote.new, "" %>

够优雅吧?这就是一些写Turbo Stream views的不同方式

Ordering our quotes

还有一个细节问题,当我们觉得把新加的quote加入到quotes列表的最前面时,但页面刷新时,顺序又乱了,我们应该保证quotes始终按照时间进行倒序展示,最新的放在最上面,让我修改一下Quote model

# app/models/quote.rb

class Quote < ApplicationRecord
  validates :name, presence: true

  scope :ordered, -> { order(id: :desc) }
end

然后再在Controller#index中使用

# app/controllers/quotes_controller.rb

def index
  @quotes = Quote.ordered
end

现在即使页面刷新,顺序也是不变的。

记得修改对应的测试代码,来让测试通过

# test/system/quotes_test.rb

setup do
  # We need to order quote as well in the system tests
  @quote = Quote.ordered.first
end

测试已经可以全部通过了,我们学习了很多技巧,而几乎只写了几行代码

Adding a cancel button

现在一切工作良好,但是我们希望可以加一个Cancel按钮在quotes/_form.html.erb视图中。

<%# app/views/quotes/_form.html.erb %>

<%= simple_form_for quote, html: { class: "quote form" } do |f| %>
  <% if quote.errors.any? %>
    <div class="error-message">
      <%= quote.errors.full_messages.to_sentence.capitalize %>
    </div>
  <% end %>

  <%= f.input :name, input_html: { autofocus: true } %>
  <%= link_to "Cancel", quotes_path, class: "btn btn--light" %>
  <%= f.submit class: "btn btn--secondary" %>
<% end %>

我们在浏览器中试试看,发现一切正常,我们看看到底发生了什么

当我们点击 new quote form 中的Cancel链接时

  1. 这里的link是嵌套在id为new_quote的Frame中,所以Turbo也只会替换这部分内容
  2. 这里的link导航到了Quotes#index页面,其中嵌套了id为new_quote但是是空的frame
  3. Turbo就用目标页面的空frame替换掉了原来页面的form表单,所以表单消失了

当我们点击 edit quote form 中的Cancel链接时

  1. 这里的link是嵌套在id为dom_id(quote)的frame中,所以也是替换这部分
  2. 这里的link导航到了Quotes#index页面,其中嵌套了id为dom_id(quote)的frame,内部含有对应quote的html数据
  3. 然后引用这段html替换,原先的form表单,所以表单消失了

当我们再创建quote并且同时修改多个quote时,我没法发现页面的状态是保留的,这么的各个部分是独立保留的,这就是Turbo带给我们的能力,而不需要写自定义的JS。

Wrap up

这一章节,我们替换了原先传统的CRUD,变成一个响应式应用,而这几乎不需要什么代码,你可以试着创建,修改,删除,查看数据,感受一下我们的应用。学了很多东西,让我们休息一下,然后把所有内容理清楚。

下一章中,我们将使用Active Cable来给系统加入实时更新的特点。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值