ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
# 12.2 关注用户的网页界面 [12.1 节](#the-relationship-model)用到了很多数据模型技术,可能要花些时间才能完全理解。其实,理解这些关联最好的方式是在网页界面中使用。 在本章的导言中,我们介绍了关注用户的操作流程。本节,我们要实现这些构思的页面,以及关注和取消关注功能。我们还会创建两个页面,分别列出我关注的用户和关注我的用户。在 [12.3 节](#the-status-feed),我们会实现用户的动态流,届时,这个演示应用才算完成。 ## 12.2.1 示例数据 和之前的几章一样,我们要使用 Rake 任务把“关系”相关的种子数据加载到数据库中。有了示例数据,我们就可以先实现网页界面,本节末尾再实现后端功能。 “关系”相关的种子数据如[代码清单 12.14](#listing-sample-relationships) 所示。我们让第一个用户关注第 3-51 个用户,并让第 4-41 个用户关注第一个用户。这样的数据足够用来开发应用的界面了。 ##### 代码清单 12.14:在种子数据中添加“关系”相关的数据 db/seeds.rb ``` # Users User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar", admin: true, activated: true, activated_at: Time.zone.now) 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!(name: name, email: email, password: password, password_confirmation: password, activated: true, activated_at: Time.zone.now) end # Microposts users = User.order(:created_at).take(6) 50.times do content = Faker::Lorem.sentence(5) users.each { |user| user.microposts.create!(content: content) } end # Following relationships users = User.all user = users.first following = users[2..50] followers = users[3..40] following.each { |followed| user.follow(followed) } followers.each { |follower| follower.follow(user) } ``` 然后像之前一样,执行下面的命令,运行[代码清单 12.14](#listing-sample-relationships) 中的代码: ``` $ bundle exec rake db:migrate:reset $ bundle exec rake db:seed ``` ## 12.2.2 数量统计和关注表单 现在示例用户已经关注了其他用户,也被其他用户关注了,我们要更新一下用户资料页面和首页,把这些变动显示出来。首先,我们要创建一个局部视图,在资料页面和首页显示我关注的人和关注我的人的数量。然后再添加关注和取消关注表单,并且在专门的页面中列出我关注的用户和关注我的用户。 [12.1.1 节](#a-problem-with-the-data-model-and-a-solution)说过,我们参照了 Twitter 的叫法,在我关注的用户数量后使用“following”作标记(label),例如“50 following”。[图 12.1](#fig-page-flow-profile-mockup) 中的构思图就使用了这种表述方式,现在把这部分单独摘出来,如[图 12.10](#fig-stats-partial-mockup) 所示。 ![stats partial mockup](https://box.kancloud.cn/2016-05-11_5733307e70b4c.png)图 12.10:数量统计局部视图的构思图 [图 12.10](#fig-stats-partial-mockup) 中显示的数量统计包含当前用户关注的人数和关注当前用户的人数,而且分别链接到专门的用户列表页面。在[第 5 章](chapter5.html#filling-in-the-layout),我们使用 `#` 占位符代替真实的网址,因为那时我们还没怎么接触路由。现在,虽然 [12.2.3 节](#following-and-followers-pages)才会创建所需的页面,不过可以先设置路由,如[代码清单 12.15](#listing-following-followers-actions-routes) 所示。这段代码在 `resources` 块中使用了 `:member` 方法。我们以前没用过这个方法,你可以猜测一下这个方法的作用是什么。 ##### 代码清单 12.15:在用户控制器中添加 `following` 和 `followers` 两个动作 config/routes.rb ``` Rails.application.routes.draw do root 'static_pages#home' get 'help' => 'static_pages#help' get 'about' => 'static_pages#about' get 'contact' => 'static_pages#contact' get 'signup' => 'users#new' get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users do member do get :following, :followers end end resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] resources :microposts, only: [:create, :destroy] end ``` 你可能猜到了,设定上述路由后,得到的 URL 地址类似 /users/1/following 和 /users/1/followers 这种形式。不错,[代码清单 12.15](#listing-following-followers-actions-routes) 的作用确实如此。因为这两个页面都是用来显示数据的,所以我们使用了 `get` 方法,指定这两个地址响应的是 GET 请求。而且,使用 `member` 方法后,这两个动作对应的 URL 地址中都会包含用户的 ID。除此之外,我们还可以使用 `collection` 方法,但 URL 中就没有用户 ID 了。所以,如下的代码 ``` resources :users do collection do get :tigers end end ``` 得到的 URL 是 /users/tigers(或许可以用来显示应用中所有的老虎)。[[7](#fn-7)] [代码清单 12.15](#listing-following-followers-actions-routes) 生成的路由如[表 12.2](#table-following-routes) 所示。留意一下我关注的用户页面和关注我的用户页面的具名路由是什么,稍后会用到。 表 12.2:[代码清单 12.15](#listing-following-followers-actions-routes) 中设置的规则生成的 REST 路由 | HTTP 请求 | URL | 动作 | 具名路由 | | --- | --- | --- | --- | | GET | /users/1/following | `following` | `following_user_path(1)` | | GET | /users/1/followers | `followers` | `followers_user_path(1)` | 设好了路由后,我们来编写数量统计局部视图。我们要在一个 `div` 元素中显示几个链接,如[代码清单 12.16](#listing-stats-partial) 所示。 ##### 代码清单 12.16:显示数量统计的局部视图 app/views/shared/_stats.html.erb ``` <% @user ||= current_user %> <div class="stats"> <a href="<%= following_user_path(@user) %>"> <strong id="following" class="stat"> <%= @user.following.count %> </strong> following </a> <a href="<%= followers_user_path(@user) %>"> <strong id="followers" class="stat"> <%= @user.followers.count %> </strong> followers </a> </div> ``` 因为用户资料页面和首页都要使用这个局部视图,所以在[代码清单 12.16](#listing-stats-partial) 的第一行,我们要获取正确的用户对象: ``` <% @user ||= current_user %> ``` 我们在[旁注 8.1](chapter8.html#aside-or-equals)中介绍过这种用法,如果 `@user` 不是 `nil`(在用户资料页面),这行代码没什么效果;如果是 `nil`(在首页),就会把当前用户赋值给 `@user`。还有一处要注意,我关注的人数和关注我的人数是通过关联获取的,分别使用 `@user.following.count` 和 `@user.followers.count`。 我们可以和[代码清单 11.23](chapter11.html#listing-user-show-microposts) 中获取微博数量的代码对比一下,微博的数量通过 `@user.microposts.count` 获取。为了提高效率,Rails 会直接在数据库层统计数量。 最后还有一个细节需要注意,某些元素指定了 CSS ID,例如: ``` <strong id="following" class="stat"> ... </strong> ``` 这些 ID 是为 [12.2.5 节](#a-working-follow-button-with-ajax)中的 Ajax 准备的,因为 Ajax 要通过独一无二的 ID 获取页面中的元素。 编写好局部视图,把它放入首页就很简单了,如[代码清单 12.17](#listing-home-page-stats) 所示。 ##### 代码清单 12.17:在首页显示数量统计 app/views/static_pages/home.html.erb ``` <% if logged_in? %> <div class="row"> <aside class="col-md-4"> <section class="user_info"> <%= render 'shared/user_info' %> </section> <section class="stats"> <%= render 'shared/stats' %> </section> <section class="micropost_form"> <%= render 'shared/micropost_form' %> </section> </aside> <div class="col-md-8"> <h3>Micropost Feed</h3> <%= render 'shared/feed' %> </div> </div> <% else %> . . . <% end %> ``` 我们要添加一些 SCSS 代码,美化数量统计,如[代码清单 12.18](#listing-stats-css) 所示(包含本章用到的所有样式)。添加样式后,首页如[图 12.11](#fig-home-page-follow-stats) 所示。 ##### 代码清单 12.18:首页侧边栏的 SCSS 样式 app/assets/stylesheets/custom.css.scss ``` . . . /* sidebar */ . . . .gravatar { float: left; margin-right: 10px; } .gravatar_edit { margin-top: 15px; } .stats { overflow: auto; margin-top: 0; padding: 0; a { float: left; padding: 0 10px; border-left: 1px solid $gray-lighter; color: gray; &:first-child { padding-left: 0; border: 0; } &:hover { text-decoration: none; color: blue; } } strong { display: block; } } .user_avatars { overflow: auto; margin-top: 10px; .gravatar { margin: 1px 1px; } a { padding: 0; } } .users.follow { padding: 0; } /* forms */ . . . ``` ![home page follow stats 3rd edition](https://box.kancloud.cn/2016-05-11_5733307e8e1fa.png)图 12.11:显示有数量统计的首页 稍后再把数量统计局部视图添加到用户资料页面中,现在先来编写关注和取消关注按钮的局部视图,如[代码清单 12.19](#listing-follow-form-partial) 所示。 ##### 代码清单 12.19:显示关注或取消关注表单的局部视图 app/views/users/_follow_form.html.erb ``` <% unless current_user?(@user) %> <div id="follow_form"> <% if current_user.following?(@user) %> <%= render 'unfollow' %> <% else %> <%= render 'follow' %> <% end %> </div> <% end %> ``` 这段代码其实也没做什么,只是把具体的工作分配给 `follow` 和 `unfollow` 局部视图了。我们要再次设置路由,加入“关系”资源,如[代码清单 12.20](#listing-relationships-resource) 所示,和微博资源的设置类似([代码清单 11.29](chapter11.html#listing-microposts-resource))。 ##### 代码清单 12.20:添加“关系”资源的路由设置 config/routes.rb ``` Rails.application.routes.draw do root 'static_pages#home' get 'help' => 'static_pages#help' get 'about' => 'static_pages#about' get 'contact' => 'static_pages#contact' get 'signup' => 'users#new' get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users do member do get :following, :followers end end resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] resources :microposts, only: [:create, :destroy] resources :relationships, only: [:create, :destroy] end ``` `follow` 和 `unfollow` 局部视图的代码分别如[代码清单 12.21](#listing-follow-form) 和[代码清单 12.22](#listing-unfollow-form) 所示。 ##### 代码清单 12.21:关注用户的表单 app/views/users/_follow.html.erb ``` <%= form_for(current_user.active_relationships.build) do |f| %> <div><%= hidden_field_tag :followed_id, @user.id %></div> <%= f.submit "Follow", class: "btn btn-primary" %> <% end %> ``` ##### 代码清单 12.22:取消关注用户的表单 app/views/users/_unfollow.html.erb ``` <%= form_for(current_user.active_relationships.find_by(followed_id: @user.id), html: { method: :delete }) do |f| %> <%= f.submit "Unfollow", class: "btn" %> <% end %> ``` 这两个表单都使用 `form_for` 处理“关系”模型对象,二者之间主要的不同点是,[代码清单 12.21](#listing-follow-form) 用来构建一个新“关系”,而[代码清单 12.22](#listing-unfollow-form) 查找现有的“关系”。很显然,第一个表单会向 `RelationshipsController` 发送 `POST` 请求,创建“关系”(`create` 动作);而第二个表单发送的是 `DELETE` 请求,销毁“关系”(`destroy` 动作)。(这两个动作在 [12.2.4 节](#a-working-follow-button-the-standard-way)编写。)你可能还注意到了,关注用户的表单中除了按钮之外什么内容也没有,但是仍然要把 `followed_id` 发送给控制器。在[代码清单 12.21](#listing-follow-form) 中,我们使用 `hidden_field_tag` 方法把 `followed_id` 添加到表单中,生成的 HTML 如下: ``` <input id="followed_id" name="followed_id" type="hidden" value="3" /> ``` [10.2.4 节](chapter10.html#resetting-the-password)说过,隐藏的 `input` 标签会把所需的信息包含在表单中,但在浏览器中不会显示出来。 现在我们可以在资料页面中加入关注表单和数量统计了,如[代码清单 12.23](#listing-user-follow-form-profile-stats) 所示,只需渲染相应的局部视图即可。显示有关注按钮和取消关注按钮的用户资料页面分别如[图 12.12](#fig-profile-follow-button) 和[图 12.13](#fig-profile-unfollow-button) 所示。 ##### 代码清单 12.23:在用户资料页面加入关注表单和数量统计 app/views/users/show.html.erb ``` <% provide(:title, @user.name) %> <div class="row"> <aside class="col-md-4"> <section> <h1> <%= gravatar_for @user %> <%= @user.name %> </h1> </section> <section class="stats"> <%= render 'shared/stats' %> </section> </aside> <div class="col-md-8"> <%= render 'follow_form' if logged_in? %> <% if @user.microposts.any? %> <h3>Microposts (<%= @user.microposts.count %>)</h3> <ol class="microposts"> <%= render @microposts %> </ol> <%= will_paginate @microposts %> <% end %> </div> </div> ``` ![profile follow button 3rd edition](https://box.kancloud.cn/2016-05-11_5733307ea654d.png)图 12.12:某个用户的资料页面([/users/2](http://localhost:3000/users/2)),显示有关注按钮![profile unfollow button 3rd edition](https://box.kancloud.cn/2016-05-11_5733307eca27c.png)图 12.13:某个用户的资料页面([/users/5](http://localhost:3000/users/5)),显示有取消关注按钮 稍后我们会让这些按钮起作用,而且要使用两种方式实现,一种是常规方式([12.2.4 节](#a-working-follow-button-the-standard-way)),另一种使用 Ajax([12.2.5 节](#a-working-follow-button-with-ajax))。不过在此之前,我们要创建剩下的页面——我关注的用户列表页面和关注我的用户列表页面。 ## 12.2.3 我关注的用户列表页面和关注我的用户列表页面 我关注的用户列表页面和关注我的用户列表页面是资料页面和用户列表页面混合体,在侧边栏显示用户的信息(包括数量统计),再列出一系列用户。除此之外,还会在侧边栏中显示一个用户头像列表。构思图如[图 12.14](#fig-following-mockup)(我关注的用户)和[图 12.15](#fig-followers-mockup)(关注我的用户)所示。 ![following mockup bootstrap](https://box.kancloud.cn/2016-05-11_5733307f26047.png)图 12.14:我关注的用户列表页面构思图![followers mockup bootstrap](https://box.kancloud.cn/2016-05-11_5733307f42150.png)图 12.15:关注我的用户列表页面构思图 首先,我们要让这两个页面的地址可访问。按照 Twitter 的方式,访问这两个页面都需要先登录。我们要先编写测试,参照以前的访问限制测试,写出的测试如[代码清单 12.24](#listing-following-followers-authorization-test) 所示。 ##### 代码清单 12.24:我关注的用户列表页面和关注我的用户列表页面的访问限制 test/controllers/users_controller_test.rb ``` require 'test_helper' class UsersControllerTest < ActionController::TestCase def setup @user = users(:michael) @other_user = users(:archer) end . . . test "should redirect following when not logged in" do get :following, id: @user assert_redirected_to login_url end test "should redirect followers when not logged in" do get :followers, id: @user assert_redirected_to login_url end end ``` 在实现这两个页面的过程中,唯一很难想到的是,我们要在用户控制器中添加相应的两个动作。按照[代码清单 12.15](#listing-following-followers-actions-routes) 中的路由设置,这两个动作应该命名为 `following` 和 `followers`。在这两个动作中,需要设置页面的标题、查找用户,获取 `@user.followed_users` 或 `@user.followers`(要分页显示),然后再渲染页面,如[代码清单 12.25](#listing-following-followers-actions) 所示。 ##### 代码清单 12.25:`following` 和 `followers` 动作 RED app/controllers/users_controller.rb ``` class UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy, :following, :followers] . . . def following @title = "Following" @user = User.find(params[:id]) @users = @user.following.paginate(page: params[:page]) render 'show_follow' end def followers @title = "Followers" @user = User.find(params[:id]) @users = @user.followers.paginate(page: params[:page]) render 'show_follow' end private . . . end ``` 读过本书前面的内容我们发现,按照 Rails 的约定,动作最后都会隐式渲染对应的视图,例如 `show` 动作最后会渲染 `show.html.erb`。而[代码清单 12.25](#listing-following-followers-actions) 中的两个动作都显式调用了 `render` 方法,渲染一个名为 `show_follow` 的视图。下面我们就来编写这个视图。这两个动作之所以使用同一个视图,是因为两种情况用到的 ERb 代码差不多,如[代码清单 12.26](#listing-show-follow-view) 所示。 ##### 代码清单 12.26:渲染我关注的用户列表页面和关注我的用户列表页面的 `show_follow` 视图 app/views/users/show_follow.html.erb ``` <% provide(:title, @title) %> <div class="row"> <aside class="col-md-4"> <section class="user_info"> <%= gravatar_for @user %> <h1><%= @user.name %></h1> <span><%= link_to "view my profile", @user %></span> <span><b>Microposts:</b> <%= @user.microposts.count %></span> </section> <section class="stats"> <%= render 'shared/stats' %> <% if @users.any? %> <div class="user_avatars"> <% @users.each do |user| %> <%= link_to gravatar_for(user, size: 30), user %> <% end %> </div> <% end %> </section> </aside> <div class="col-md-8"> <h3><%= @title %></h3> <% if @users.any? %> <ul class="users follow"> <%= render @users %> </ul> <%= will_paginate %> <% end %> </div> </div> ``` [代码清单 12.25](#listing-following-followers-actions) 中的动作会按需渲染[代码清单 12.26](#listing-show-follow-view) 中的视图,分别显式我关注的用户列表和关注我的用户列表,如[图 12.16](#fig-user-following) 和[图 12.17](#fig-user-followers) 所示。注意,上述代码都没有到“当前用户”,所以这两个链接对其他用户也可用,如[图 12.18](#fig-different-user-followers) 所示。 ![user following 3rd edition](https://box.kancloud.cn/2016-05-11_5733307f60bd8.png)图 12.16:显示某个用户关注的人![user followers 3rd edition](https://box.kancloud.cn/2016-05-11_5733307fa22c6.png)图 12.17:显示关注某个用户的人![diferent user followers 3rd edition](https://box.kancloud.cn/2016-05-11_5733307fbf28f.png)图 12.18:显示关注另一个用户的人 现在,这两个页面可以使用了,下面要编写一些简短的集成测试,确认表现正确。这些测试只是健全检查,无需面面俱到。正如 [5.3.4 节](chapter5.html#layout-link-tests)所说的,全面的测试,例如检查 HTML 结构,并不牢靠,而且可能适得其反。对这两个页面来说,我们计划确认显示的数量正确,而且页面中有指向正确的 URL 的链接。 首先,和之前一样,生成一个集成测试文件: ``` $ rails generate integration_test following invoke test_unit create test/integration/following_test.rb ``` 然后,准备测试数据。我们要在“关系”固件中创建一些关注关系。[11.2.3 节](chapter11.html#profile-micropost-tests)使用下面的代码把微博和用户关联起来: ``` orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> user: michael ``` 注意,我们没有用 `user_id: 1`,而是 `user: michael`。 按照这样的方式编写“关系”固件,如[代码清单 12.27](#listing-relationships-fixtures) 所示。 ##### 代码清单 12.27:“关系”固件 test/fixtures/relationships.yml ``` one: follower: michael followed: lana two: follower: michael followed: malory three: follower: lana followed: michael four: follower: archer followed: michael ``` 在这些固件中,Michael 关注了 Lana 和 Malory,Lana 和 Archer 关注了 Michael。为了测试数量,我们可以使用检查资料页面中微博数量的 `assert_match` 方法([代码清单 11.27](chapter11.html#listing-user-profile-test))。然后再检查页面中有没有正确的链接,如[代码清单 12.28](#listing-following-tests) 所示。 ##### 代码清单 12.28:测试我关注的用户列表页面和关注我的用户列表页面 GREEN test/integration/following_test.rb ``` require 'test_helper' class FollowingTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) log_in_as(@user) end test "following page" do get following_user_path(@user) assert_not @user.following.empty? assert_match @user.following.count.to_s, response.body @user.following.each do |user| assert_select "a[href=?]", user_path(user) end end test "followers page" do get followers_user_path(@user) assert_not @user.followers.empty? assert_match @user.followers.count.to_s, response.body @user.followers.each do |user| assert_select "a[href=?]", user_path(user) end end end ``` 注意,在这段测试中有下面这个断言: ``` assert_not @user.following.empty? ``` 如果不加入这个断言,下面这段代码就没有实际意义: ``` @user.following.each do |user| assert_select "a[href=?]", user_path(user) end ``` (对关注我的用户列表页面的测试也是一样。) 测试组件应该可以通过: ##### 代码清单 12.29:**GREEN** ``` $ bundle exec rake test ``` ## 12.2.4 关注按钮的常规实现方式 视图创建好了,下面我们要让关注和取消关注按钮起作用。因为关注和取消关注涉及到创建和销毁“关系”,所以我们需要一个控制器。像之前一样,我们使用下面的命令生成这个控制器: ``` $ rails generate controller Relationships ``` 在[代码清单 12.31](#listing-relationships-controller) 中会看到,限制访问这个控制器中的动作没有太大的意义,但我们还是要加入安全机制。我们要在测试中确认,访问这个控制器中的动作之前要先登录(没登录就重定向到登录页面),而且数据库中的“关系”数量没有变化,如[代码清单 12.30](#listing-relationships-access-control) 所示。 ##### 代码清单 12.30:`RelationshipsController` 基本的访问限制测试 RED test/controllers/relationships_controller_test.rb ``` require 'test_helper' class RelationshipsControllerTest < ActionController::TestCase test "create should require logged-in user" do assert_no_difference 'Relationship.count' do post :create end assert_redirected_to login_url end test "destroy should require logged-in user" do assert_no_difference 'Relationship.count' do delete :destroy, id: relationships(:one) end assert_redirected_to login_url end end ``` 在 `RelationshipsController` 中添加 `logged_in_user` 事前过滤器后,这个测试就能通过,如[代码清单 12.31](#listing-relationships-controller) 所示。 ##### 代码清单 12.31:`RelationshipsController` 的访问限制 GREEN app/controllers/relationships_controller.rb ``` class RelationshipsController < ApplicationController before_action :logged_in_user def create end def destroy end end ``` 为了让关注和取消关注按钮起作用,我们需要找到表单中 `followed_id` 字段(参见[代码清单 12.21](#listing-follow-form) 和[代码清单 12.22](#listing-unfollow-form))对应的用户,然后再调用[代码清单 12.10](#listing-follow-unfollow-following) 中定义的 `follow` 或 `unfollow` 方法。各动作完整的实现如[代码清单 12.32](#listing-relationships-controller-following) 所示。 ##### 代码清单 12.32:`RelationshipsController` 的代码 app/controllers/relationships_controller.rb ``` class RelationshipsController < ApplicationController before_action :logged_in_user def create user = User.find(params[:followed_id]) current_user.follow(user) redirect_to user end def destroy user = Relationship.find(params[:id]).followed current_user.unfollow(user) redirect_to user end end ``` 从这段代码中可以看出为什么前面说“限制访问没有太大意义”:如果未登录的用户直接访问某个动作(例如使用 `curl` 等命令行工具),`current_user` 的值是 `nil`,执行到这两个动作的第二行代码时会抛出异常,即得到一个错误,但对应用和数据来说都没危害。不过完全依赖这样的表现也不好,所以我们添加了一层安全防护措施。 现在,关注和取消关注功能都能正常使用了,任何用户都可以关注或取消关注其他用户。你可以在浏览器中点击相应的按钮验证一下。(我们会在 [12.2.6 节](#following-tests)编写集成测试检查这些操作。)关注第二个用户前后显示的资料页面如[图 12.19](#fig-unfollowed-user) 和[图 12.20](#fig-followed-user) 所示。 ![unfollowed user](https://box.kancloud.cn/2016-05-11_5733307fe46f4.png)图 12.19:关注前的资料页面![followed user](https://box.kancloud.cn/2016-05-11_573330800ef35.png)图 12.20:关注后的资料页面 ## 12.2.5 关注按钮的 Ajax 实现方式 虽然关注用户的功能已经完全实现了,但在实现动态流之前,还有可以增强的地方。你可能已经注意到了,在 [12.2.4 节](#a-working-follow-button-the-standard-way)中,`RelationshipsController` 中的 `create` 和 `destroy` 动作最后都返回了一开始访问的用户资料页面。也就是说,用户 A 先访问用户 B 的资料页面,点击关注按钮关注用户 B,然后页面立即又转回到用户 B 的资料页面。因此,对这样的流程我们有一个疑问:为什么要多一次页面转向呢? Ajax [[8](#fn-8)]可以解决这种问题。Ajax 向服务器发送异步请求,在不刷新页面的情况下更新页面的内容。因为经常要在表单中处理 Ajax 请求,所以 Rails 提供了简单的实现方式。其实,关注和取消关注表单局部视图不用做大的改动,只要把 `form_for` 改成 `form_for…​, remote: true`,Rails 就会自动使用 Ajax 处理表单。这两个局部视图更新后的版本如[代码清单 12.33](#listing-follow-form-ajax) 和[代码清单 12.34](#listing-unfollow-form-ajax) 所示。 ##### 代码清单 12.33:使用 Ajax 处理关注用户的表单 app/views/users/_follow.html.erb ``` <%= form_for(current_user.active_relationships.build, remote: true) do |f| %> <div><%= hidden_field_tag :followed_id, @user.id %></div> <%= f.submit "Follow", class: "btn btn-primary" %> <% end %> ``` ##### 代码清单 12.34:使用 Ajax 处理取消关注用户的表单 app/views/users/_unfollow.html.erb ``` <%= form_for(current_user.active_relationships.find_by(followed_id: @user.id), html: { method: :delete }, remote: true) do |f| %> <%= f.submit "Unfollow", class: "btn" %> <% end %> ``` 上述 ERb 代码生成的 HTML 没什么好说的,如果你好奇的话,可以看一下(细节可能不同): ``` <form action="/relationships/117" class="edit_relationship" data-remote="true" id="edit_relationship_117" method="post"> . . . </form> ``` 可以看出,`form` 标签中设定了 `data-remote="true"`,这个属性告诉 Rails,这个表单可以使用 JavaScript 处理。Rails 遵从了“[非侵入式 JavaScript](http://railscasts.com/episodes/205-unobtrusive-javascript)”原则(unobtrusive JavaScript),没有直接在视图中写入 JavaScript 代码(Rails 之前的版本直接写入了 JavaScript 代码),而是使用了一个简单的 HTML 属性。 修改表单后,我们要让 `RelationshipsController` 响应 Ajax 请求。为此,我们要使用 `respond_to` 方法,根据请求的类型生成合适的响应。例如: ``` respond_to do |format| format.html { redirect_to user } format.js end ``` 这种写法可能会让人困惑,其实只有一行代码会执行。(`respond_to` 块中的代码更像是 `if-else` 语句,而不是代码序列。)为了让 `RelationshipsController` 响应 Ajax 请求,我们要在 `create` 和 `destroy` 动作([代码清单 12.32](#listing-relationships-controller-following))中添加类似上面的 `respond_to` 块,如[代码清单 12.35](#listing-relationships-controller-ajax) 所示。注意,我们把本地变量 `user` 改成了实例变量 `@user`,因为在[代码清单 12.32](#listing-relationships-controller-following) 中无需使用实例变量,而使用 Ajax 处理的表单([代码清单 12.33](#listing-follow-form-ajax) 和[代码清单 12.34](#listing-unfollow-form-ajax))则需要使用。 ##### 代码清单 12.35:在 `RelationshipsController` 中响应 Ajax 请求 app/controllers/relationships_controller.rb ``` class RelationshipsController < ApplicationController before_action :logged_in_user def create @user = User.find(params[:followed_id]) current_user.follow(@user) respond_to do |format| format.html { redirect_to @user } format.js end end def destroy @user = Relationship.find(params[:id]).followed current_user.unfollow(@user) respond_to do |format| format.html { redirect_to @user } format.js end end end ``` [代码清单 12.35](#listing-relationships-controller-ajax) 中的代码会优雅降级(不过要配置一个选项,如[代码清单 12.36](#listing-degrade-gracefully) 所示),如果浏览器不支持 JavaScript,也能正常运行。 ##### 代码清单 12.36:添加优雅降级所需的配置 config/application.rb ``` require File.expand_path('../boot', __FILE__) . . . module SampleApp class Application < Rails::Application . . . # 在处理 Ajax 的表单中添加真伪令牌 config.action_view.embed_authenticity_token_in_remote_forms = true end end ``` 当然,如果支持 JavaScript,也能正确的响应。如果是 Ajax 请求,Rails 会自动调用包含 JavaScript 的嵌入式 Ruby 文件(`.js.erb`),文件名和动作一样,例如 `create.js.erb` 或 `destroy.js.erb`。你可能猜到了,在这种的文件中既可以使用 JavaScript 也可以使用嵌入式 Ruby 处理当前页面。所以,为了更新关注后和取消关注后的页面,我们要创建这种文件。 在 JS-ERb 文件中,Rails 自动提供了 [jQuery](http://jquery.com/) 库的辅助函数,可以通过“[文档对象模型](http://www.w3.org/DOM/)”(Document Object Model,简称 DOM)处理页面中的内容。jQuery 库中有很多处理 DOM 的方法,但现在我们只会用到其中两个。首先,我们要知道通过 ID 获取 DOM 元素的美元符号,例如,要获取 `follow_form` 元素,可以使用如下的代码: ``` $("#follow_form") ``` (参见[代码清单 12.19](#listing-follow-form-partial),这个元素是包含表单的 `div`,而不是表单本身。)上面的句法和 CSS 一样,`#` 符号表示 CSS 中的 ID。由此你可能猜到了,jQuery 和 CSS 一样,使用点号 `.` 表示 CSS 中的类。 我们要使用的第二个方法是 `html`,使用指定的内容修改元素中的 HTML。例如,如果要把整个表单换成字符串 `"foobar"`,可以这么写: ``` $("#follow_form").html("foobar") ``` 和常规的 JavaScript 文件不同,JS-ERb 文件还可以使用嵌入式 Ruby 代码。在 `create.js.erb` 文件中,(成功关注后)我们会把关注用户表单换成取消关注用户表单,并更新关注数量,如[代码清单 12.37](#listing-create-js-erb) 所示。这段代码中用到了 `escape_javascript` 方法,在 JavaScript 中写入 HTML 代码必须使用这个方法对 HTML 进行转义。 ##### 代码清单 12.37:创建“关系”的 JS-ERb 代码 app/views/relationships/create.js.erb ``` $("#follow_form").html("<%= escape_javascript(render('users/unfollow')) %>") $("#followers").html('<%= @user.followers.count %>') ``` `destroy.js.erb` 文件的内容类似,如[代码清单 12.38](#listing-destroy-js-erb) 所示。 ##### 代码清单 12.38:销毁“关系”的 JS-ERb 代码 app/views/relationships/destroy.js.erb ``` $("#follow_form").html("<%= escape_javascript(render('users/follow')) %>") $("#followers").html('<%= @user.followers.count %>') ``` 加入上述代码后,你应该访问用户资料页面,看一下关注或取消关注用户后页面是不是真的没有刷新。 ## 12.2.6 关注功能的测试 关注按钮可以使用了,现在我们要编写一些简单的测试,避免回归。关注用户时,我们要向相应的地址发送 `POST` 请求,确认关注的人数增加了一个: ``` assert_difference '@user.following.count', 1 do post relationships_path, followed_id: @other.id end ``` 这是测试普通请求的方式,测试 Ajax 请求的方式基本类似,把 `post` 换成 `xhr :post` 即可: ``` assert_difference '@user.following.count', 1 do xhr :post, relationships_path, followed_id: @other.id end ``` 我们使用 `xhr` 方法(表示 XmlHttpRequest)发起 Ajax 请求,目的是执行 `respond_to` 块中对应于 JavaScript 的代码([代码清单 12.35](#listing-relationships-controller-ajax))。 取消关注的测试类似,只需把 `post` 换成 `delete`。在下面的代码中,我们检查关注的人数减少了一个,而且指定了“关系”的 ID: 普通请求: ``` assert_difference '@user.following.count', -1 do delete relationship_path(relationship), relationship: relationship.id end ``` Ajax 请求: ``` assert_difference '@user.following.count', -1 do xhr :delete, relationship_path(relationship), relationship: relationship.id end ``` 综上所述,测试如[代码清单 12.39](#listing-follow-button-tests) 所示。 ##### 代码清单 12.39:测试关注和取消关注按钮 GREEN test/integration/following_test.rb ``` require 'test_helper' class FollowingTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) @other = users(:archer) log_in_as(@user) end . . . test "should follow a user the standard way" do assert_difference '@user.following.count', 1 do post relationships_path, followed_id: @other.id end end test "should follow a user with Ajax" do assert_difference '@user.following.count', 1 do xhr :post, relationships_path, followed_id: @other.id end end test "should unfollow a user the standard way" do @user.follow(@other) relationship = @user.active_relationships.find_by(followed_id: @other.id) assert_difference '@user.following.count', -1 do delete relationship_path(relationship) end end test "should unfollow a user with Ajax" do @user.follow(@other) relationship = @user.active_relationships.find_by(followed_id: @other.id) assert_difference '@user.following.count', -1 do xhr :delete, relationship_path(relationship) end end end ``` 测试组件应该能通过: ##### 代码清单 12.40:**GREEN** ``` $ bundle exec rake test ```