本文翻译自: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都有详情,修改,删除的按钮,并且也有增加的按钮。下面是样式图:
当点击New quote
按钮时跳转到Quotes#new
页面:
当点击Create quote
时跳转到Quotes#index
页面,显示存储的数据,并按照时间排序。
当你点击Edit按钮时,就会跳转到Quotes#edit
去更新已有的数据
当点击Update quote
,还是跳回Quotes#index
页面
当点击quote的标题时,则跳转到Quotes#show
,现在我们仅仅显示标题,后面增加其他内容。
在开始之前,我们先创建一些测试用例去确保我们构建的内容正常
测试
测试是软件开发的基础部分,如果没有一个强有力的测试,我们将会引入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("← 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("← 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("← 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
方法论,它是简单易懂的,总结为以下三点:
- 每个 component(or block) 应有有独立的名字,比如系统中的
card
,.card
的CSS类应该定义在card.scss
文件中,这就BEM中的B。 - 每个 block 可能有多个 elements,比如 card 举例,每个card都有 title 和 body,在BEM中我们应该写为
.card_title
和.card_body
,这样就可以避免命名冲突,如果另有一个block为.box
也有 title 和 body,那么这个就是.box_title
和.box_body
,这就是BEM中的E - 每个 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
文件,我们将定义一个breakpoints
为media queries
,在我们的项目只有一个叫做tabletAndUp
的breakpoints
// 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
就变得很容易了。
tabletAndUp
比50rem
更容易阅读
并且这可以帮我们避免写重复的代码,比如:
.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预处理器。
我们再写关于.quote
component
// 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
。
Turbolinks是Turbo 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
页面长这个样子
现在我们希望当点击New quote
时,表单可以添加到原来页面的标题下面
当我们点击Create quote
按钮时,创建好的quote应该展示在quotes列表的最上面
当我们点击第二个quote的Edit
按钮时,表格应该直接替换选择的这个quote卡片
当我们点击Update quote
按钮时,又展示回卡片的样式,并且数据更新
而其他的行为,应该不受到影响
- 点击
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
页面长这个样子:
现在我们用同样的ID嵌套Quotes#new
页面片段
为了匹配草图的内容,我们现在改改代码
<%# app/views/quotes/new.html.erb %>
<main class="container">
<%= link_to sanitize("← 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
页面
在Quotes#new
页面中,让我们使用相同的ID去嵌套表单
根据草图,修改我们的源码,我在代码中标记了关键部分
<%# 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("← 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"
像下面这样:
当然每个页面都默认有_top frame
,所以Quotes#new
也有
我们该代码试试:
<%# 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("← 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 Frames
与Turbo_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,看我们画一个草图展示:
假如我们想编辑第二个数据,当我们点击edit时,我们需要Quotes#edit
页面被同样的ID frame嵌套
通过合适的命名,当我们点击Quotes#index
页面的edit按钮时,嵌套表单的frame将会替换嵌套第二条数据的frame。
现在开始代码实现,首先在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("← 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#index
和Quotes#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("← 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
还是上面的问题:
- 查看quote详情没法正常工作了,数据也消失了,控制台也报错
- 删除按钮也在控制台报错了
现在的Quotes#index
页面长下面的样子:
每个数据都被quote_id嵌套,但Quotes#show
页面并没有相同的ID
这里我们就使用"_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时,这时还没有提交,然后删除下面的数据,则页面所有内容被替换,第二条数据的修改框也就没有了。
但我们希望的是删除第三条数据时,不影响其他的数据,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_stream
helper 也可以使用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嵌套住原来的数据集合,然后可以让新的数据放到最前面。
开始写代码:
Quotes#new
页面
<%# app/views/quotes/new.html.erb %>
<main class="container">
<%= link_to sanitize("← 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,让我们写下发生什么事儿:
- 当我们点击
New quote
链接,点击事件会被Turbo拦截 - Turbo知道了该请求将会与id为
new_quote
的frame进行互动,这源于我们在New quote
链接上指定的data-turbo-frame
- 该请求会以ajax形式发送,并且服务端会返回id为
new_quote
的Quotes#new
页面 - 当浏览器接收到了这段HTML,Turbo将会提取
Quotes#new
页面中,id为new_quote
的frame,然后替换Quotes#index
中相同id的frame
现在我们再试试提交空的数据,当点击Create Quote
按钮时,数据是异常的,而异常信息应该出现在页面中,我们再说的细一些
- 当我们点击
Create Quote
按钮时,表单提交将被Turbo拦截 - 表单将被id为
new_quote
的frame嵌套 - 服务端接收到了异常参数,并返回
Quotes#new
页面并带有异常信息 - 浏览器接收到
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做了两件事儿
- 告诉Turbo去把
app/views/quotes/_quote.html.erb
放到id为quotes的frame前面,这里可以看到我们可以直接传参关联。 - 第二行代码告诉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链接时
- 这里的link是嵌套在id为
new_quote
的Frame中,所以Turbo也只会替换这部分内容 - 这里的link导航到了
Quotes#index
页面,其中嵌套了id为new_quote
但是是空的frame - Turbo就用目标页面的空frame替换掉了原来页面的form表单,所以表单消失了
当我们点击 edit quote form 中的Cancel链接时
- 这里的link是嵌套在id为
dom_id(quote)
的frame中,所以也是替换这部分 - 这里的link导航到了
Quotes#index
页面,其中嵌套了id为dom_id(quote)
的frame,内部含有对应quote的html数据 - 然后引用这段html替换,原先的form表单,所以表单消失了
当我们再创建quote并且同时修改多个quote时,我没法发现页面的状态是保留的,这么的各个部分是独立保留的,这就是Turbo带给我们的能力,而不需要写自定义的JS。
Wrap up
这一章节,我们替换了原先传统的CRUD,变成一个响应式应用,而这几乎不需要什么代码,你可以试着创建,修改,删除,查看数据,感受一下我们的应用。学了很多东西,让我们休息一下,然后把所有内容理清楚。
下一章中,我们将使用Active Cable
来给系统加入实时更新的特点。