10-Task-F_-Add-a-Dash-of-Ajax

优质
小牛编辑
133浏览
2023-12-01

我们的客户希望在商店应用中增加对 Ajax 的支持。不过,什么是 Ajax 呢?

在以前(直到 2005 为止),浏览器被认为是愚笨的设备。当编写基于浏览器的应用时,你会向浏览器发送内容然后遗忘当前的会话。有时,用户会填写表单或者点击一个超链接,此时应用将会被输入的请求唤起。应用会向用户返回完整的页面以进行渲染,然后冗长的流程又将再度开启。目前我们的 Depot 应用也是如此。

但浏览器并没有这么愚笨(谁知道呢?)。其实它们可以运行代码。几乎所有的浏览器都可以运行 Javascript。并且 JavaScript 还可以通过浏览器在幕后与服务器端的应用交互,比如更新用户所看到的结果。Jesse James Garrett 命名这种风格的交互为 Ajax(曾经的意思为异步 JavaScript 及 XML,不过现在的意义为让浏览器变得不那么糟糕)。

所以,让我们的购物车 Ajax 化。现在我们要让当前的购物车在分类页的侧边栏显示,而不是通过另一个分离的购物车页面显示。然后,我们向修改后的购物车增加 Ajax 手段,让它不再重新刷新整个页面。

无论你何时使用 Ajax,以无 Ajax 的版本开启一个应用都是不错的选择,并且也可以循序渐进地介绍 Ajax 特性。这也正是我们现在所做的。一开始,我们要将购物车从自己的页面移动至侧边栏。

迭代 F1:迁移购物车

当前我们的购物车是由 CartController 中的 show action 与相应的 .html.erb 模板渲染。我们现在想将其移至侧边栏渲染。实际上,我们是要将购物车在展示所有分类的总局中显示。这种情况使用「partial templates」会更加便捷。

Partial Templates

编辑语言让我们可以定义方法。一个方法就是拥有名称的一块代码,通过名字调用方法,相应的代码将运转。当然,我们也可以向方法传递参数,这使我们可以编写一块代码,并在多种不同情况下使用它。

你可以认为 Rails 的 partial templates(简称 partials)是 view 中的方法。一个 partial 简单说就是整体 view 中分离的一块 view。你可以在其他的模板中或 controller 中调用(渲染)一个 partial。就像方法一样,你也可以向 partial 传递参数,所以相同的 partial 也可以渲染不同的结果。

我们将在迭代中两次使用 partial。首先,让我们看一下购物车的展示:

<!-- app/views/carts/show.html.erb -->
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>

<h2>Your Pragmatic</h2>
<table>
  <% @cart.line_items.each do |item| %>
    <tr>
      <td><%= item.quantity %>&times;</td>
      <td><%= item.product.title %></td>
      <td class="item_price"><%= number_to_currency(item.total_price) %></td>
      <td class="item_remove_button"><%= button_to 'Remove Item', item, method: :delete, data: { confirm: 'Are you sure?' } %></td>
    </tr>
  <% end %>

  <tr class="total_line">
    <td colspan="2">Total</td>
    <td class="total_cell"><%= number_to_currency(@cart.total_price) %></td>
  </tr>
</table>
<%= button_to 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>

模板在表格中创建了一些行,用于展示购物车中的每一个商品。无论你何时看见这样的遍历,你可能都希望停下来并问问自己,模板中是否存在了太多逻辑。其实我们可以用 partial 将循环进行抽象(而且,正如我们所看见的,这个步骤也为我们使用 Ajax 提供了基础)。要完成这项任务,我们要利用你可以通过向方法传递集合以渲染 partial 模板的方式,而且在方法中将为每个集合中的元素自动调用 partial。让我们利用这个特性重写我们的购物车 view。

<!-- app/views/carts/show.html.erb -->
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>

<h2>Your Pragmatic</h2>
<table>
  <%= render(@cart.line_items) %>

  <tr class="total_line">
    <td colspan="2">Total</td>
    <td class="total_cell"><%= number_to_currency(@cart.total_price) %></td>
  </tr>
</table>
<%= button_to 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>

这样就简洁多了。render() 方法会遍历传递给它的集合。partial 模板将作为一个简洁的其他模板文件存在(默认情况下 partial 模板在相同的路径下作为对象被渲染,并且使用表格名称作为名字)。不过为了保持 partials 名字与正式模板名字区分开,当 Rails 查找文件时会自动在 partial 名字前面添加一个下划线。那意味着我们需要命名 partial 为 _line_item.html.erb 并且将它放置在 app/views/line_items 路径下。

<!-- app/views/line_items/_line_item.html.erb -->
<tr>
  <td><%= line_item.quantity %>&times;</td>
  <td><%= line_item.product.title %></td>
  <td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
  <td class="item_remove_button"><%= button_to 'Remove Item', line_item, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>

这段代码还保留了一些细节。在 partial 模板内,我们通过与模板名匹配的变量名指向当前对象。在这个示例中,partial 被命名为 line_item,所以在 partial 中我们预计有个叫做 line_item 的变量。

所以,现在我们已经梳理好了对于购物车的展示,但还没有将它移至侧边栏。我们要再次访问布局文件以完成这个任务。如果我们已经有了展示购物车的 partial 模板,我们便可以在侧边栏中嵌入如下的调用:

render("cart")

但 partial 如何知道哪里可以找到 cart 对象?一种方式是通过赋值。在布局中,我们能够访问到在 controller 中赋值的 @cart 实例变量。这表示在布局的 partial 中也可以调用。不过,这有点像调用一个方法并且向它传递一个全局变量作为参数。尽管能够运行,但代码并不美观,而且也提高了耦合度(高耦合会使程序变得脆弱和难以维护)。

现在我们拥有了一个关于 line item 的 partial,我们也将对购物车作类似的操作。首先,我们将创建 _cart.html.erb 模板。它将基于 carts/show.html.erb 模板,不过是使用 cart 而不是 @cart,而且也不会有提示。(一个 partial 调用另一个 partial 也是可行的)。

<!-- app/views/carts/_cart.html.erb -->
<h2>Your Cart</h2>
<table>
  <%= render(cart.line_items) %>

  <tr class="total_line">
    <td colspan="2">Total</td>
    <td class="total_cell"><%= number_to_currency(cart.total_price) %></td>
  </tr>
</table>
<%= button_to 'Empty cart', cart, method: :delete, data: { confirm: 'Are you sure?' } %>

不重复自己(DRY)是 Rails 的准则之一,我们现在也要按照这个准则工作。现在两个文件将是同步的,因此不会有太多问题,但是使用一套调用 Ajax 的逻辑和一套禁用 Javascript 的逻辑会导致问题。让我们避免这些问题,并且替换原始模板中的内容,使用 partial 进行渲染。

<!-- app/views/carts/show.html.erb -->
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>

<%= render @cart %>

现在我们将修改应用的布局,将新的 partial 放置于侧边栏。

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
  <title>Depot</title>
  <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track' => true %>
  <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
  <%= csrf_meta_tags %>
</head>
<body class='<%= controller.controller_name %>'>
  <div id="banner">
    <%= image_tag("logo.png") %>
    <%= @page_title || "Pragmatic Bookshelf" %>
  </div>

  <div id="columns">
    <div id="side">
      <div id="cart">
        <%= render @cart %>
      </div>
      <ul>
        <li><a href="http://www....">Home</a></li>
        <li><a href="http://www..../faq">Questions</a></li>
        <li><a href="http://www..../news">News</a></li>
        <li><a href="http://www..../contact">Contact</a></li>
      </ul>
      <div id="date_time"><%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %></div>
    </div>
    <div id="main">
      <%= yield %>
    </div>
  </div>
</body>
</html>

接着我们需要对 store controller 作出些小改动。当调用 store 的 index action 时我们调用了布局,不过这里并没有赋值 @cart。不过进行补救也不复杂。

# app/controllers/store_controller.rb

class StoreController < ApplicationController
  include AccessCounter, CurrentCart

  before_action :increase_times, only: [:index]
  before_action :set_start

  def index
    @products = Product.order(:title)
  end
end

最后,我们要修改购物车的样式,之前的样式只是应用在 CartController 的输出内容上,现在当购物车出现在侧边栏时也要应用样式。再提一下,SCSS 使我们只修改一个地方即可完成变化,它会处理所有内嵌定义。

// app/assets/stylesheets/carts.css.scss

// Place all the styles related to the Carts controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/

.carts, #side #cart {
  .item_price, .total_line {
    text-align: right;
  }

  .total_line .total_cell {
    font-weight: bold;
    border-top: 1px solid #595;
  }

  .item_remove_button {
    padding-left: 10px;
  }
}

只要购物车数据是共享的,它无论在哪个位置显示都是一样的,但没有需求说明不同位置的显示需要是一致的。实际上,绿色背景上的黑色字体难以阅读,所以让我们为显示在侧边栏的购物车表格提供额外的样式。

// app/assets/stylesheets/application.css.scss
#side {
  float: left;
  padding: 1em 2em;
  width: 21em;
  background: #141;

  form, div {
    display: inline;
  }

  input {
    font-size: small;
  }

  #cart {
    font-size: smaller;
    color: white;

    table {
      border-top: 1px dotted #595;
      border-bottom: 1px dotted #595;
      margin-bottom: 10px;
    }
  }

  // ...
}

如果在向购物车添加了商品后你应该看到的页面如下图。接着我们就可以等待 Webby 奖的提名了。

the cart is in the sidebar.png

修改流程

现在我们已经在侧边栏显示了购物车内容,接下来我们要修改 「Add to Cart」按钮的运作过程。相比之前展示另一个购物车页面,现在购物车的内容必须通过刷新主页完成。

修改也十分简单。在 create action 结束时,我们让浏览器重定向至主页即可。

# app/controllers/line_items_controller.rb

def create
  product = Product.find(params[:product_id])
  @line_item = @cart.add_product(product.id)

  respond_to do |format|
    if @line_item.save
      format.html { redirect_to store_url }
      format.json { render :show, status: :created, location: @line_item }
    else
      format.html { render :new }
      format.json { render json: @line_item.errors, status: :unprocessable_entity }
    end
  end
end

因此,现在我们拥有了一个商店,并且在侧边栏显示着购物车内容。当我们点击添加一件商品至购物车时,当前页面会刷新显示更新后的购物车内容。然而,如果我们的分类比较多,重新显示会花费比较多的时间。它将占用较多的带宽和服务器资源。所幸,我们还可以通过 Ajax 技术改善它。

迭代 F2:创建基于 Ajax 的购物车

Ajax 让我们可以编写在浏览器中运行的代码与服务器端应用进行交互。在这个事例中,我们希望 Add to Cart 按钮能够在后台运行,调用 LineItems controller 中的 create action。服务端也只用下发购物车的 HTML 内容,然后我们将服务端更新的内容替换侧边栏的购物车。

此时,我们通常通过编写 JavaScript 在浏览器中运行,并在服务端编写代码与 JavaScript 交互(可能会使用一些技术,例如 JavaScript Object Notation [JSON])。好消息是在 Rails 中这一切对我们都是隐藏的。我们可以做到所有我们需要通过 Ruby 处理的工作(以及众多多 Rails 辅助方法的支持)。

在添加 Ajax 至应用中时的注意点是需要小步前进。因此,让我们从基础的开始。让我们修改分类页面,使它能够发送 Ajax 请求给服务端应用,并且服务端响应包含更新后的购物车的 HTML 代码。

在首页中,我们通过 button_to() 创建 create action 的链接。我们希望使用一个 Ajax 请求进行替换。要完成这项任务,我们得先在调用时添加 remote: true 参数。

<!-- app/views/store/index.html.erb -->
<% if notice %>
  <p id="notice"><%= notice %></p>
<% end %>

<h1>
  Your Pragmatic Catalog
  <span>accessed <%= pluralize(@access_times, 'time') %></span>
</h1>

<% cache ['store', Product.latest] do %>
  <% @products.each do |product| %>
    <% cache ['entry', product] do %>
      <div class="entry">
        <%= image_tag(product.image_url) %>
        <h3><%= product.title %></h3>
        <%= sanitize(product.description) %>
        <div class="price_line">
          <span class="price"><%= number_to_currency(product.price, unit: "¥") %></span>
          <%= button_to 'Add to Cart', line_items_path(product_id: product), remote: true %>
        </div>
      </div>
    <% end %>
  <% end %>
<% end %>

到目前为止,我们已经为浏览器向应用发送请求作好了准备。下一步是让应用返回一个响应。现在的计划是创建表现购物车更新后的 HTML 代码片段,并且要将这段 HTML 代码嵌入浏览器的内部表现结构中,还要以相应的文档内容显示,这种形式命名为 Document Object Model(DOM)。通过维护 DOM,我们可以在用户的眼前展示变化的内容。

首先需要修改的是阻止 create action 将 JavaScript 的请求重定向至主页。我们可以通过调用 respond_to() 方法表达我们希望以 .js 格式完成响应。

初看之下这种语法让人惊喜,不过简单来说它就是一个方法的调用,并且还可以选择性地将 block 作为参数传入。我们已经在 44 页详细讲过 block。后面我们将在 318 页详细讲述 respond_to() 方法的细节。

# app/controllers/line_items_controller.rb
def create
  product = Product.find(params[:product_id])
  @line_item = @cart.add_product(product.id)

  respond_to do |format|
    if @line_item.save
      format.html { redirect_to store_url }
      # Here's new line
      format.js
      format.json { render :show, status: :created, location: @line_item }
    else
      format.html { render :new }
      format.json { render json: @line_item.errors, status: :unprocessable_entity }
    end
  end
end

由于这些修改,当 create 完成处理 Ajax 的请求后, Rails 将查找 create 模板进行渲染。

Rails 支持生成 JavaScript 的模板,JS 就表示 JavaScript。一个 .js.erb 模板是在浏览器中通过 JavaScript 完成我们想做的事情的一种方式,所有的一切只需要编写服务器端的 Ruby 代码即可。让我们编写我们的第一个 .js.erb 文件:create.js.erb。它将放置在 app/views/line_items 路径下,就像任何其他的 line items 的 view 一样。

// app/views/line_items/create.js.erb
$('#cart').html("<%= escape_javascript render(@cart) %>")

这个简洁的模板在告知浏览器,让它将 id="cart" 的 HTML 节点替换为当前内容。

我们要分析一下它是怎样做到的。

基于简洁和方便,jQuery 库的别名被写为 「$」,多数情况下都是这样使用 jQuery。

第一次调用 $('#cart') 是让 jQuery 查找 id 为 cart 的 HTML 节点。html() 方法会将第一个参数结果作为当前节点的替换内容。替换内容是通过 @cart 对象调用 render() 方法获取。最后方法的输出是经由 escape_javascript() 辅助方法将 Ruby 字符串转换为可接收的格式输入 JavaScript。

要注意的是这个脚本在浏览器中执行。在服务端执行的部分只是 <%=%> 的分隔符中的部分。

它正常运作吗?这很难在书中展示,不过它确实正常运行的。确认你已经重载了首页,并且获取了远程版本的表单和 JavaScript 库到浏览器中。接着,点击任意 Add to Cart 按钮。你应该可以看见侧边栏的购物车已经更新。而且你并不会看到任何浏览器重新加载页面的迹象。此时我们已经创建了一个 Ajax 应用。

解决问题

尽管 Rails 让 Ajax 变得十分简单,但它并不能让 Ajax 万无一失。并且,由于你正在处理的迭代附有许多技术,当 Ajax 出问题时我很难查出其原因。这也是你应该逐步添加 Ajax 函数的原因之一。

如果你的 Depot 应用没有正常显示 Ajax 的效果,这里有一些提示以供参考:

  • 查看你的浏览器是否有特别的设置强制重新加载页面中的所有内容?有时,浏览器会使用本地页面资源缓存版本,这会导致测试变得混乱。现在是一个不错的时机,你可以进行一次完全的重新加载。

  • 是否出现错误报告?查看一下 logs 路径中的 development.log 文件。也可以查看一下 Rails 的服务端窗口,因为有些错误是显示在控制台中。

  • 查看日志文件时,你是否看到 create action 的输入请求?如果没有,也就说明浏览器没有发起 Ajax 请求。如果 JavaScript 已经被加载过(在浏览器中使用 Source 视图将显示相应的 HTML 内容),或许你的浏览器无法执行 JavaScript?

  • 有些读者报告说,他们需要将应用重启才能使 Ajax 版本的购物车生效

  • 如果你是使用 IE 浏览器,它可能是在兼容模式下运行,这种模式会向下兼容旧版的 IE,不过却是以破碎的方式运行。IE 可以切换至标准模式,如果被下载的页面首行是 DOCTYPE 开头的话会使 Ajax 更好地运行。我们的布局文件就是这样使用的:<!DOCTYPE html>

用户永不满足

我们感到十分开心。我们亲手修改了一些代码,这使我们的 Web 1.0 应用已经支持 Web 2.0 的 Ajax 高速交互。我们迫不急待地邀请客户来观看新应用。一开始我们就直接点击 Add to Cart 按钮并向她展示效果,我们知道自己期望的称赞即将到来。不过,她看起来很惊讶。「你们叫我过来就是看 bug 的吗?」她问道。「你点击了按钮但什么也没有发生」。

我们耐心地向她解释其实这背后已经发生了许多活动。看看侧边栏的购物车。当我们添加商品时,数量从 4 变成了 5。

她说:「哦,我没有注意到」。如果她都没有注意到页面的更新,那其他的用户更不会注意到了。我们是时候强化一下用户界面了。

迭代 F3:改变高亮

Rails 包含了许多 JavaScript 库。其中之一是 jQuery UI 库,它可以让你装饰网站界面,产生相应的视觉效果。其中一种效果是著名的 Yellow Fade Technique。这种技术可以将浏览器中的元素高亮显示,默认情况下它将背景色刷新为黄色,然后再逐渐隐藏回复为白色。我们可以看见下图就是 Yellow Fade Technique 应用在购物车上的效果,图片在最后恢复显示原先的购物车。用户点击 Add to Cart 按钮,商品数量会变化为 2,并且相应的行也会闪烁高亮。几秒钟后它将会回复原来的背景色。

Our cart with the Yellow Fade Technique.png

安装 jQuery UI 库十分简单。首先在 Gemfile 中添加一行代码。

// Gemfile

gem 'jquery-rails'
// Here's new line
gem 'jquery-ui-rails'

在运行 bundle install 命令后就安装完成。

bundle install

在命令运行完成后重启服务。

现在我们已经拥有了可以在应用中使用的 jQuery UI 库,我们要修改一下以展示我们需要的效果。我们通过在 app/asserts/javascripts/application.js 中添加代码实现。

// app/asserts/javascripts/application.js

// This is a manifest file that'll be compiled into application.js, which will include all the files
// listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
// or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
// compiled file.
//
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
// about supported directives.
//
//= require jquery

// Here's new line
//= require jquery-ui/effects/effect-blind

//= require jquery_ujs
//= require turbolinks
//= require_tree .

我们可以看看 97 页的 asserts/stylesheets/application.css 文件。它与上面的 js 文件非常相似,不过它是一个样式表而上面的文件是一个 JavaScript。要注意的是这行代码使用是点符号而不是下划线,要知道并不是所有的插件库作者都使用相同的命名约定。

让我们利用这个三方库向购物车添加上面所描述的高亮效果。只要购物车中的商品被更新(无论是新增的商品还是增加了数量)都改变它的背景色。就算整个页面没有刷新,这样也可以让用户更加清楚哪里发生了变化。

我们要解决的第一个问题是如何区分最近在购物车中更新的是哪个商品。目前,每个商品都只是单纯的 <tr> 元素。我们需要通过一种方式标识最近修改的那个。这个工作需要在 LineItemsController 中处理。并且将当前商品赋值给一个实例变量,然后依据此方式传递给相应的模板。

# app/controllers/line_item_controller.rb

def create
  product = Product.find(params[:product_id])
  @line_item = @cart.add_product(product.id)

  respond_to do |format|
    if @line_item.save
      format.html { redirect_to store_url }

      # Here's new line
      format.js { @current_item = @line_item }
      format.json { render :show, status: :created, location: @line_item }
    else
      format.html { render :new }
      format.json { render json: @line_item.errors, status: :unprocessable_entity }
    end
  end
end

在 _line_item.html.erb partial 中,我们可以校验正在渲染的商品是否就是修改的那个。如果是的话,我们将其 id 标记为 current_item。

<!-- app/views/line_items/_line_item.html.erb -->

<!-- Here's new block -->
<% if line_item == @current_item %>
  <tr id="current_item">
<% else %>
  <tr>
<% end %>
  <td><%= line_item.quantity %>&times;</td>
  <td><%= line_item.product.title %></td>
  <td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
  <td class="item_remove_button"><%= button_to 'Remove Item', line_item, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>

作为两个主要修改的结果,最后更新的 <tr> 元素将被标记为 id="current_item"。现在我们只需要让 JavaScript 改变相应商品的背景色,再逐渐回复原本的背景色,这样便可以抓住别人的眼球。我们在 create.js.erb 模板中处理这些逻辑。

// app/views/line_items/create.js.erb

$('#cart').html("<%= escape_javascript render(@cart) %>");

// Here's new line
$('#current_item').css({'background-color':'#88ff88'}).
  animate({'background-color':'#114411'}, 1000);

可以看到我们通过向 $ 函数传递 '#current_item' 参数区分出我们想应用显示效果的浏览器元素的。接着我们调用 css() 方法设置初始的背景色,接着调用 animate() 方法将背景色在 1000 毫秒过渡回原来的颜色,通常相当于 1 秒钟的时间。

完成修改后,点击任意的 Add to Cart 按钮,你会看见购物车中的商品颜色在回复为原始颜色之前将显示为亮绿色。

迭代 F4:隐藏空购物车

现在还有最后一个来自客户的需求。目前的购物车即使没有任何商品也依然在侧边栏显示。我们可以让购物车有商品时才显示吗?那是当然!

实际上,我们有许多方案方案实现上述功能。最简单的一种方案是在购物车中有商品时才嵌入购物车的 HTML。我们完全可以在 _cart partial 中进行操作。

<!-- app/views/carts/_cart.html.erb -->

<% unless cart.line_items.empty? %>
<h2>Your Cart</h2>
<table>
  <%= render(cart.line_items) %>

  <tr class="total_line">
    <td colspan="2">Total</td>
    <td class="total_cell"><%= number_to_currency(cart.total_price) %></td>
  </tr>
</table>
<%= button_to 'Empty cart', cart, method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>

尽管能够实现功能,但用户界面显得很不友好,当购物车在无商品和有商品状态间切换时侧边栏都需要重画。所以,我们不打算使用这段代码。我们希望购物车显示的切换能够更加顺滑一些。

jQuery UI 库已经提供了界面元素显示的过渡效果。我们可以在 show() 方法中使用 blind 参数,它将让购物车流畅地显现,并将侧边栏剩下的部分向下滑动以腾出空间。

不出意外我们将再次使用 .js.erb 模板启动相应的效果。因为 create 模板只有当我们添加商品至购物车时才会被调用,此时只要在购物车中存在一件商品我们就需要将购物车显示在侧边栏(因为在此时之前的购物车应该是空置并且被隐藏的)。因为在我们高亮显示前购物车应该是可见的,所以我们要编写代码在触发高亮之前就将购物车显示。

现在模板如下:

// app/views/line_items/create.js.erb

// Here's new line
if ($('#cart tr').length == 1) { $('#cart').show('blind', 1000); }

$('#cart').html("<%= escape_javascript render(@cart) %>");

$('#current_item').css({'background-color':'#88ff88'}).
  animate({'background-color':'#114411'}, 1000);

我们还需要在购物车置空时将其隐藏。也有两种方式可以实现。一种是此部分代码渲染前不生成任何 HTML 代码。不幸的是,如果我们这样做,当我们添加商品至购物车中时就需要立即生成购物车的 HTML 代码,当购物车第一次显示时我们会看见浏览器出现一些闪烁现象,接着再进行隐藏,然后再缓慢地通过 blind 效果显示。

还有种更好的方式处理这个问题,就是创建好购物车的 HTML 后如果购物车是空的话通过设置 CSS 样式 display:none 实现。我们需要修改 app/views/layout 中的 application.html.erb 文件实现。我们第一步就是进行如下修改。

<!-- app/views/layout/application.html.erb -->

<div id="cart"
  <!-- Here's new block -->
  <% if @cart.line_items.empty? %>
    style="display:none"
  <% end %>
  >
  <%= render @cart %>
</div>

这段代码在 <div> 标签中添加了 CSS 参数 style=,不过只是在购物车是置空的情况下。效果正常实现了,不过挺难看的。总是晃动位置的 > 字符看起来像缺少匹配符一样(虽然它是有匹配符的),而且在标签中插入逻辑会使模板变得糟糕。让我们想想办法让它不至于将我们的代码丑化。我们会使用一种抽象方式隐藏它,就是通过编写辅助方法。

辅助方法

如果你希望将 view 中的一些流程抽象出来(任意种类的 view),你都应该编写一个辅助方法。

如果查看一下 app 文件夹中,你会找到 6 个子文件夹。

毫无疑问,我们的辅助方法要放置在 helpers 路径中。如果你查找一番此文件夹内,你会发现其中已经存在了一些文件。

Rails 生成器自动地为每个 controller 都创建了相应的辅助方法。Rails 命令本身(初始化应用时的命令)也创建了文件 application_helper.rb。如果你喜欢,你可以将你的方法放置到指定 controller 的辅助文件中,不过由于现在我们编写的方法是应用于应用布局中,我们就将它放置在 application 辅助文件中。

我们要编写一个方法叫做 hidden_div_if()。它需要一个判断条件参数,一组选填的属性,以及一个 block。它将包含由存在 <div> 标签中的 block 生成的内容,并且如果判断条件为 true 就添加 display:none 样式。在布局中如下使用:

<!-- app/views/layouts/application.html.erb -->

<%= hidden_div_if(@cart.line_items.empty?,  id: 'cart') do %>
  <%= render @cart %>
<% end %>

我们要将编写的辅助方法添加至 app/helpers 路径下的 application_helper.rb 文件中。

# app/helpers/application_helper.rb

module ApplicationHelper
  def hidden_div_if(condition, attributes = {}, &block)
    if condition
      attributes["style"] = "display: none"
    end
    content_tag("div", attributes, &block)
  end
end

在代码中使用了 Rails 的辅助方法 content_tag(),它可以将 block 创建的结果包装在一个标签中。通过使用 &block,我们将 hidden_div_if() 传入的 block 传递至 content_tag() 方法中。

最后,当购物车为空时我们需要停止设置通过刷新展示的信息。它不再需要,因为当分类页重画时购物车会从侧边栏消失。不过还有一些其他的原因也可以将其移出。目前我们是通过 Ajax 向购物车添加商品,在客户进行购物发起请求时主页并不会重画。这意味着即使我们在侧边栏显示了购物车,我们依然会显示着刷新信息说购物车是空的。

# app/controllers/carts_controller.rb

def destroy
  @cart.destroy if @cart.id == session[:cart_id]
  session[:cart_id] = nil
  respond_to do |format|

    # Here's new line
    format.html { redirect_to store_url }
    format.json { head :no_content }
  end
end

现在我们已经完美地添加了 Ajax,接着置空购物车并且添加一项商品。

尽管看起来我们进行了许多工作,但我们只进行了两个基本步骤。首先,我们隐藏了购物车,然后在购物车中有商品时通过 CSS 条件样式显示购物车。其次,当购物车从置空切换至有商品状态时我们提供 JavaScript 启用显示 blind 效果。

目前为止,虽然这些改动都很美观但还没有发挥真正的功能。接下来让我们修改页面的功能。如果我们使点击商品图片时也能将商品添加至购物车中,怎么样?使用 JQuery 会使这个功能实现十分简易。

迭代 F5:使图片可点击

到目前为止,我们已经定义过可点击的元素及点击后的响应效果(主要指 button 和 link)。在这个示例中,我们想处理图片的 onClick 事件并执行相应的功能。

换个说法,我们想做的事情是通过一个脚本查找页面加载后的所有图片,并给这些图片添加点击事件触发后的逻辑,触发的逻辑与 Add to Cart 一样。

首先,回忆一下我们要处理的页面的内容。

<!-- app/views/store/index.html.erb -->

<% if notice %>
  <p id="notice"><%= notice %></p>
<% end %>

<h1>
  Your Pragmatic Catalog
  <span>accessed <%= pluralize(@access_times, 'time') %></span>
</h1>

<% cache ['store', Product.latest] do %>
  <% @products.each do |product| %>
    <% cache ['entry', product] do %>
      <div class="entry">
        <%= image_tag(product.image_url) %>
        <h3><%= product.title %></h3>
        <%= sanitize(product.description) %>
        <div class="price_line">
          <span class="price"><%= number_to_currency(product.price, unit: "¥") %></span>
          <%= button_to 'Add to Cart', line_items_path(product_id: product), remote: true %>
        </div>
      </div>
    <% end %>
  <% end %>
<% end %>

通过上述信息,我们需要修改 app/asserts/javascripts/store.js.coffee 文件。

// app/assets/javascripts/store.coffee

# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

$(document).on "ready page:change", ->
  $('.store .entry > img').click ->
    $(this).parent().find(':submit').click()

CoffeeScript 是另一种更易编写的预处理方式。在当前示例中,CoffeeScript 帮助我们使用更简洁的表达式表述 JavaScript 代码。再结合 JQuery 你便可以通过较少的代码产生相同的效果。

在上述示例中,首先我们想在页面加载时执行一个我们定义的函数。这就是上述脚本在第一行完成的,它通过 -> 符定义了一个函数,并将它传递给叫做 on 的函数,on 这个函数被关联了两个事件,分别是 ready 事件和 page:change 事件。 ready 事件是在人们网站外部导航进入此页面时触发,page:change 事件是在网站内部导航至此页面时由 Turbolinks 触发。脚本将两个事件都进行了绑定,也就覆盖了所有情况。

第二行代码将查找 class="entry" 元素中的所有图片,而 class="entry" 的元素需要处于 class="store" 元素中。最后看起来像样式表的部分比较重要。Rails 默认情况下会将 JavaScript 组合为一整个资源。对于每一个被查找到的图片(在其他页面运行时图片数量可能为 0)都被分配了定义的函数为点击事件的响应逻辑。

最后一行就是响应点击事件的逻辑。触发此点击事件的元素被命名为 this。接着会获取它的父元素,也就是 class="entry" 的 div 元素。在这个元素中我们会查找到提交按钮,接着我们将按下按钮。

浏览器完成这个逻辑流程后效果与点击 Add to Cart 并不会有何区别。不过它的过程有所不同。点击图像也会使商品被添加至购物车中。令人惊异的是完成这些步骤我们只用了仅仅 3 行代码。

当然,你也可以直接使用 JavaScript 实现相应的功能,不过可能需要超过 5 组小括号,2 组花括号以及多于 50% 的字符。这里仅仅是简单地演示了下 CoffeeScript 的功能。你可以在《CoffeeScript: Accelerated JavaScript Development[Burl 1]》中了解更多相关知识。

事实上我们完成这些功能时都没有运行测试,不过我们并没有在函数上进行较大的改动,所以应该没有什么问题。但是为了放心我们还是运行一下测试。

哦,不。居然有失败和错误。这并不是件好事。毫无疑问,我们需要重新看看测试方法了。事实上,下一步我们就是要处理这个问题。

测试 Ajax 的修改

我们已经看到测试失败的结果了,而且还看到如下的一些错误:

ActionView::Template::Error: undefined method 'line_items' for nil:NilClass

在错误中一般都会描述主要问题,那先让我们将其定位出来以方便查看余下的信息。根据测试可知,如果我们访问 product 首页将会出现问题,更加确信的是,在我们通过浏览器键入 http://localhost:3000/products/ 后也显示了如下图的错误。

products line item nil error.png

这些信息非常有用。错误信息可以帮助我们定位问题是出现在哪个模板文件(app/views/layouts/application.html.erb),相应文件的哪一行代码,以及模板中出现错误的代码的上下文。通过这些信息,我们可以看到错误是由 @cart.line_items 表达式引发,生成的信息是 undefined method 'line_items' for nil

所以,当我们展示商品首页时 @cart 的值应该是 nil。也就是说其根本原因是只在 store controller 进行了相应赋值。这个问题修复也不复杂,只要我们避免在 @cart 值为空时进行展示即可。

<!-- app/views/layouts/application.html.erb -->

<!-- Here's new line -->
<% if @cart %>
  <%= hidden_div_if(@cart.line_items.empty?,  id: 'cart') do %>
    <%= render @cart %>
  <% end %>
<% end %>

在修复这个问题后,我们继续查看测试的下一个问题。这个错误是由于重定向的值并不是我们期望的导致。此错误发生在创建一个商品条目时。而且,我们在 141 页确实对其进行了修改。与上一个完全意外的改动不同,这个改动是目的明确的,所以我们也要修改相应的测试用例。

# test/controllers/line_items_controller_test.rb

test "should create line_item" do
  assert_difference('LineItem.count') do
    post :create, product_id: products(:ruby).id
  end

  # Here's new line
  assert_redirected_to store_path
  assert_equal 0, session[:access_times]
end

在相应的地方进行修改后,现在我们的测试又都通过了。想想会发生什么吧。为了支持一个新需求而对应用的其中一部分进行的改动可能会破坏之前我们在应用的其他地方的实现。如果你不小心翼翼,这种情况即使是在如同 Depot 这样的小型应用中也会发生。即使你已经足够仔细,在大型的应用中还是会发生这种情况。

不过我们还有任务没有完成。我们还没有测试添加的 Ajax 部分,比如我们点击 Add to Cart 按钮时产生的效果。Rails 将这种测试也进行了简化。

我们已经拥有了「should create line item」的测试用例,所以我们添加另一个叫做「should create line item via ajax」的测试即可。

# test/controllers/line_items_controller_test.rb

test "should create line_item via ajax" do
  assert_difference('LineItem.count') do
    xhr :post, :create, product_id: products(:ruby).id
  end

  assert_response :success
  assert_select_jquery :html, '#cart' do
    assert_select 'tr#current_item td', /Programming Ruby 1.9/
  end
end

这两个测试在名字,请求方式(xhr :postpost,xhr 表示 XML-HttpRequest 的意思)及期望结果上都是不同的。与重定向不同的是,我们期望结果是一次成功的响应,并且其中包含相应的购物车 HTML 内容,在 HTML 内容中我们期望有一行数据 ID 为 current_item 并且元素中的值与 Programming Ruby 1.9 一致。这种效果通过执行 assert_select_jquery() 方法查找相应的 HTML 元素,然后在逻辑中再添加我们想应用的断言即可实现。

最后是关于之前我们介绍的 CoffeeScript 的。关于浏览器中执行代码的测试是这本书外的内容,不过我们应该通过测试确认脚本依赖的内容是没有问题的。而且它也十分简单。

# test/controllers/store_controller_test.rb

test "markup needed for store.coffee in place" do
  get :index
  assert_select '.store .entry > img', 3
  assert_select '.entry input[type=submit]', 3
end

通过这种方式,如果一个热情洋溢的网站设计师修改了页面并且影响了我们的逻辑,我们便可以感知到出现问题,并且在代码进入生产环境前完成修改。要注意,:submit 只是一种 jQuery 的 CSS 扩展,我们只要在测试中使用 input[type=submit] 简洁地描述即可。

保证测试是最新版本是维护应用的重要工作。Rails 使这个工作变得轻松。敏捷开发者会将测试作为他们开发的一部分。并且许多人会在开始编写代码之前先写下相应的测试代码。

总结

在这个迭代中,我们使购物车支持了 Ajax。

  • 我们将购物车移动至侧边栏。然后使 create action 重新展示分类页。

  • 我们通过设置参数 remote:true 使用 Ajax 调用了 LineItemsController.create() action。

  • 我们通过 ERB 模板创建了在客户端执行的 JavaScript 脚本。这个脚本通过使用 jQuery 将购物车的 HTML 内容更新至页面中。

  • 我们通过 jQuery-UI 库添加了高亮效果,方便用户看见看见购物车的变化。

  • 我们编写了一个辅助方法,在购物车为空时将其隐藏,当有商品加入购物车时使用 jQuery 显示它。

  • 我们编写了一个测试以验证不止商品条目被创建,而且也有与请求对应的响应内容返回。

  • 我们通过编写 CoffeeScript 实现了当图像被点击时相应的商品条目会被添加至购物车。

关键点是我们逐步增加了 Ajax 风格的开发。我们逐步完成了一个常见的应用,也加入了 Ajax 特性。Ajax 很难 debug,添加时一定要小步前进,这样做的好处是如果在添加时导致应用停止了也比较容易回溯错误出现的地方。而且就如我们所看见的,以一个传统应用为基础可以更加容易支持 Ajax 操作及非 Ajax 操作。

最后,我们要给你一些建议。第一,如果你计划要进行许多 Ajax 开发,那你需要熟悉浏览器的 JavaScript debug 工具以及 DOM 选择器,比如 Firefox 的 Firebug,IE 的 Developer Tools,Google Chrome 的 Developer Tools,Safari 的 Web Inspector,或者是 Opera 的 Dragonfly。其次,Firefox 的 NoScript 插件会在一次点击操作中验证 JavaScript 或非 JavaScript 的情况。

其他人还发现在两个不同的浏览器中运行程序是有益的,特别是当你开发的 JavaScript 在其中一个能正常运行而另一个中不能正常运行时。而且当添加一个新特性时,最好在所有浏览器中都测试一番相应的 JavaScript 功能。

自习天地

这有些知识需要自己尝试:

  • 当用户置空购物车时界面需要重画将购物车进行隐藏。你可以通过 jQuery UI 的 blind 效果替换吗?
  • 在购物车的每个商品条目后添加一个按钮。当点击按钮时调用相应的 action 减少商品条目数量,如果商品数量减少至零时将其从购物车中删除。先不使用 Ajax 的方式实现,然而再使用 Ajax 的方式。