Rails Rails 用 RJS 简单有效的实现页面局部刷新

huacnlee · 2016年02月22日 · 最后由 miserytan 回复于 2017年07月25日 · 17377 次阅读
本帖已被管理员设置为精华贴

我们时常在做项目的时候会遇到这样的需求,页面上有个基本数据的下拉框,用于选择一些数据,如:项目版本号、联系人、收件地址、银行账号列表什么的。

而需求可能会要求说可以在这个页面随时增加那些下拉框的选择值,而不是另开窗口。

于是我们可能需要实现一个浮动窗口,里面包含新增表单,实现表单 AJAX 提交,最后在刷新主窗口的下拉菜单控件。

这里介绍一下我最近实现的一个简单方式:

主表单页面:

link_to + remote 的方式实现一个 Ajax 请求的点击

<div class="form-field-user-address">
  <%= f.select :address, collection: current_user.addresses %>
</div>
<%= link_to '新增联系地址', new_address_path, remote: true %>

AddressesController 保持标准的 Rails new 函数结构

class AddressesController < ApplicationController
  def new
    @address = Address.new
  end

  def create
    @address = Address.new(address_params)
    respond_to do |format|
      if @address.save
        format.html { redirect_to addresses_path, notice: '地址新增成功。' }
        format.js
      else
        format.html { render :new }
        format.js
      end
    end
  end
end

新增 new.js.erb (以后会大量用 js.erb 的做法),由于前面是 remote 请求,HTTP Header 里面带的信息会让 Rails 渲染 js.erb 的模板 这里渲染 _form.html.erb 生成地址表单的 HTML:

var html = "<%= j(render('form', remote: true)) %>";
// bootbox 弹窗插件 http://bootboxjs.com/
bootbox.dialog({
  title: "新增联系地址",
  message: html
});

然后我们就得到了这样的界面:

然后正题来了,我们怎样才能在地址新增成功以后,以无刷新的方式更新主表单上面那个下拉框呢?

这就是本帖要将的技巧,非常简单,而且适用于任何地方!

我们新增一个 create.js.erb,前面说过 remote 的请求,Rails 会选择 js.erb 的模板来渲染

<% if @address.errors.blank? %>
  // 用 Ajax 载入当前页面,找到下拉框的 Dom 的新 HTML,再替换目前页面的下拉框 Dom
  var selector = '.form-field-user-address';
  $.get(location.href, function(html) {
    var doc = $(html).find(selector);
    $(selector).replaceWith(doc);
  });
  bootbox.hideAll();
<% else %>
  var html = "<%= j(render('form', remote: true)) %>";
  // 保存失败,直接替换浮动窗口上面的 HTML 为新的 form render 结果
  // 因为 @address 包含验证错误信息,所以 render 出来的 HTML 也包含验证信息
  $('form.new_address').replaceWith(html);
<% end %>

为了很多地方都能用到,于是我把上面的变成一个公共函数:

app.coffee

window.App =
  refreshDom: (selector) ->
    $.get location.href, (html) ->
        doc = $(html).find(selector)
        $(selector).replaceWith(doc)

create.js.erb 就可以简化了:

<% if @address.errors.blank? %>
  App.refreshDom('.form-field-user-address');
  bootbox.hideAll();
<% else %>
  var html = "<%= j(render('form', remote: true)) %>";
  $('form.new_address').replaceWith(html);
<% end %>

扩充阅读

只是简单提一下,最近在看 React,感觉 React 也可以做类似的事情。

很实用,赞。

Jquery ujs 里是不是已经定义了页面收到 js 代码后自动执行的过程了?

其实一开始我是在找 Turbolinks 里面是否有类似的方法,尝试了

Turbolinks.visit(location.href, { change: ['form-field-user-address'] });

但结果失败了,首先浏览器会滚动到顶部页面动了...

#2 楼 @adamshen 对,以上的动作和 jquery_ujs 有非常大的关系

很实用啊

#3 楼 @huacnlee 以前项目就是这么干的,但是只是有时有些 js 需要重新 bind 到新的 dom 上。

现在已经不支持手动点赞了嘛

@huacnlee 以前这样用过,有点小问题是若 xxx.js.erb 有 js 错误,在浏览器里相当不好定位(报错很奇怪,位置也不好定义);所以感觉这比较适合 xxx.js.erb 里 js 逻辑不复杂的情况。

#8 楼 @qinfanpeng 复杂的都要放到 JS 函数里面,避免服务端返回过多多余代码

确实,有次这样弄,把我坑惨了,后面就把复杂 js 逻辑放到纯 js 里去了。 感觉 RailsRequest Header 设置成 script,然后浏览器加载完后就自动执行这段逻辑了。

@huacnlee js.erb 里的脚本是直接 eval 跑的,所以里面声明的变量会变成 全局变量 ,所以任何 js.erb 最好加上闭包。CoffeeScript 貌似不用管这个问题,因为默认生成的 JavaScript 就是有闭包的。

@darkbaby123 你说的闭包指的是命名空间吗

@optionsource 也许没表达清楚,就是建一个匿名函数表达式去封装一下:

(function() {
// Your code
})();

这样写新 dom 要重新绑定事件,有时候会很麻烦。

RJS 对于独立的小功能点 非常适用,谁用谁知道。

当做到局部刷新页面之后,华顺你有没有尝试做过“页面后退”操作。之前我这样实现过然后想返回前一个页面,但是在浏览器中显示了你所说的 .js.erb 中 的 js 代码。
补充:项目禁用了 Turbolinks

虽然具有通用性,但要重新获取一次当前页面,然后获取里面的最新 dom 来更新旧页面的数据,显得有点浪费带宽:),可以用通用的 url 获取 JSON 数据来更新;而且用 replace 的方式,还会造成事件丢失。

不喜欢这种到处都是碎片的感觉,还有怎么测试。

使用 angular 会不会更简单一些?

#19 楼 @alvin2ye 单元测试就只能测 js 函数,功能测试用 capybara 测一下

最近因为交接同事的项目,有用到 vue.js,感觉用 vue.js 处理这些会更简单

refresh UI 的时候其实会有 DOM 上绑定的 JS 事件需要重新绑定的问题…… 我和华顺的场景一样: 新建地址后,增加新的地址,并可以点击编辑再次弹出模态框保存地址,但由于是新出现的 DOM,所以需要重新绑定模态框弹出的事件。我也试过直接用 Turbolink 带的 API,没有合适的,问题也很多,也试过楼主这样抽出来替换方法然后用的,但会出现上述问题,最后都是手写……不知道 @huacnlee 有没有解决类似的问题……

感觉现在技术栈都落伍了,我还停留在 ajax 的时代,看来要被抛弃了

这种做法实际上是 SJR-Server-generated JavaScript Responses(by DHH) ;Basecamp 中的大部分 Ajax 操作都是这样玩的;RJS was a "ruby-to-js" template system,这玩意只能算是 coffee 的一种粗糙形式。

另外我一直有个疑惑是,返回的 javascript 在哪里由谁执行的,看了 jquery-ujs 源码并没有执行的语句,jquery-ujs 中 ajax 的回调是这样:

success: function(data, status, xhr) {
  element.trigger('ajax:success', [data, status, xhr]);
},
complete: function(xhr, status) {
  element.trigger('ajax:complete', [xhr, status]);
},

#23 楼 @cassiuschen 事件绑定的处理,你用 Backbone Events 就简单了。这也是我为什么会在 Ruby China 用 Backbone 的原因

27 楼 已删除

#26 楼 @huacnlee 没用 Backbone。。。现在 DOM 没法重新绑定事件了。还没解决思路

#26 楼 @huacnlee 上个厕所,想到个解决方法,解决了。。。

果然没有什么是上一次厕所不能解决的,如果不行,那就两次。

用的 Bootstrap v4?

#25 楼 @holysoros jQuery.ajax() dataType 设为“script” , 就会把 response 当成 js 来执行,严格来说这是 jQuery 实现的,rails 的 jquery-ujs 只是定义了一些全局事件和事件代理 detail: http://api.jquery.com/jquery.ajax/ @cassiuschen 你应该用事件代理,参考 jQuery 的 on(http://api.jquery.com/on/) 方法,把事件代理到父级元素上,不要把事件直接绑到操作的元素。 backbone 也是这种原理。

$( "body" ).on( "click", "p", function() {
  alert( $( this ).text() );
});

这种写法在 slim 模板下能够实现吗?我在一个级联选择框的样例代码中看到这种做法,但是我的项目里面用的是 slim 模板,没找到正确编写 xxx.js.slim 的方法,最后只能用了最原始的 ajax 实现的。

@huacnlee

谢谢你的分享,很好的想法!关于下面的代码有个问题:

window.App =
  refreshDom: (selector) ->
    $.get location.href, (html) ->
        doc = $(html).find(selector)
        $(selector).replaceWith(doc)

如果 location.href 的页面本来就非常复杂,数据量大的话,那么不是很慢呢?有没有更好的方法呢?

@huacnlee 能看下您的 github 源码吗?同样的需求,尝试着照你说的写着,没写出来

需要 登录 后方可回复, 如果你还没有账号请 注册新账号