Rails [翻译] React.js 的 Rails 开发者指南

sail_lee · 2016年05月17日 · 最后由 hesongGG 回复于 2017年03月04日 · 18073 次阅读
本帖已被管理员设置为精华贴

React.js 的 Rails 开发者指南

原作者:Fernando Villalobos

原文链接:https://www.airpair.com/reactjs/posts/reactjs-a-guide-for-rails-developers

译者:Sail Lee

目录

React.js 简介

React.js 是一个近似“JavaScript 框架”的流行类库,因其简洁而出众。相对于其他完整实现了 MVC 结构的框架,我们说 React 仅实现了 V(其实有些人用 React 来代替它们框架的 V 部分)。React 应用程序通过两个主要原则来构造:Components 和 States。Components 可以用其他更小的组件来构成,内置或定制;State 驱动了 Facebook 称之为单向响应式数据流的东西,这意味着我们的 UI 将会对每次状态的改变作出反应。

React 的一个优点之一就是它无需任何额外的依赖,这让它几乎能和任何其他的 JS 库插接到一起。利用这个特征,我们将其囊括到我们 Rails 的技术栈中,来构建一个前端强大的应用,也许你会说它是个 Rails 视图层的兴奋剂。

一个模拟的费用跟踪应用

在本指南中,我们正要从零做起,构建一个记录日常花费的小应用。每个记录将包括一个日期、标题和金额。假如一个记录的金额大于零,它将被认为是贷方(译者注:会计术语),相反则计入借方(译者注:会计术语)。这是项目的模型: 项目的模型

总结下,该应用表现如下:

  • 当用户通过横向的表单创建一个新记录时,它将被添加到记录表格中去。
  • 用户可以对任何存在的记录进行行内编辑。
  • 点击任何删除按钮会把相关的记录从表格中删除。
  • 增加、编辑或移除一个存在的记录都将更新位于页面顶部的各项合计项。

在 Rails 项目中初始化 React.js

首先,我们要开始一个全新的 Rails 项目,我们叫它Accounts

rails new accounts

我们将使用 Twitter 的 Bootstrap 做此项目的 UI。安装流程非本文讨论范围,你可以根据官方 github 仓库的指引来安装bootstrap-sass官方 gem。

一旦项目初始化后,我们接下来要把React包含进来。本文中,因为我们打算利用react-rails这个官方 gem 里面的一些很酷的功能,所以要将其包含进项目。其实也有其他方法来完成这项任务,如使用 Rails assets、甚至从官方页面下载源码包并把它们复制到项目的javascripts目录。

如果你曾经开发过 Rails 应用,你会知道安装一个 gem 有多容易:把react-rails添加到你的Gemfile文件中去。

gem 'react-rails', '~> 1.0'

然后,(友好地) 让 Rails 来安装新的 gem 包:

bundle install

react-rails带有一个脚本,会在我们存放 React 组件的app/assets/javascripts目录下创建components.js文件和components目录。

rails g react:install

在跑完安装之后,如果你看看application.js文件中会发现以下三行:

//= require react
//= require react_ujs
//= require components

基本上,它包含了实际上的react库、components组件清单文件和一种以ujs结尾的常见文件。由文件名你可以已经猜到,react-rails包含了一种帮助我们载入 React 组件并且同时也处理Turbolinks事件的非入侵式 JavaScript 驱动。

创建 Resource

我们将要构建一个包含datetitleamount字段的Record资源(resource)。我们要用resource生成器(generator)来代替scaffold,这样我们就不会用到由scaffold创建的所有文件和方法。另一个选择是先运行scaffold生成器,接着删除无用的文件或方法,但是这样会另我们的项目有点乱。进入项目目录后,运行以下命令:

rails g resource Record title date:date amount:float

运行完后,我们最后将得到一个新的Record model、controller 和 routes。我们现在只需要创建我们的数据库并运行之后的数据迁移。

rake db:create db:migrate

作为附加,你可以通过rails console创建两个记录:

Record.create title: 'Record 1', date: Date.today, amount: 500
Record.create title: 'Record 2', date: Date.today, amount: -100

别忘了用rails s来启动你的服务器。 好了!我们要准备写点代码了。

嵌套式组件:记录列表

我们的第一个任务需要在一个表格中展示任何已有的记录。首先,我们需要在RecordController里面创建一个index动作(action)。

# app/controllers/records_controller.rb

class RecordsController < ApplicationController
  def index
    @records = Record.all
  end
end

接着,我们要在app/views/records/目录下创建一个新文件index.html.erb,该文件在我们的 Rails 应用和 React 组件之间扮演着桥梁的作用。要完成该任务,我们将使用 helper 方法react_component,通过它来获取我们要展示的 React 组件的名称连同我们要传递给它的数据。

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

<%= react_component 'Records', { data: @records } %>

需要指出的是,该 helper 是由react-railsgem 包提供的,假如你决定使用其他的集成 React 的方法,就不能用到这个 helper。

你现在能到localhost:3000/records这个路径看看了。显然,因为Records这个 React 组件的缺失,这还未能工作。但是,如果我们看看浏览器中的 HTML 源文件,我们就能发现类似以下的代码:

<div data-react-class="Records" data-react-props="{...}">
</div>

有了这个标记,react_ujs就会检测到我们尝试展示一个 React 组件并实例化它,包括我们通过react_component发送的属性,在本案例中,就是@records的内容。

构建我们第一个 React 组件的时间到了,进入javascripts/components目录,创建一个叫records.js.coffee的新文件来放置我们的Records组件。

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  render: ->
    React.DOM.div
      className: 'records'
      React.DOM.h2
        className: 'title'
        'Records'

每个组件都需要一个render方法,它将负责渲染组件本身。render 方法会返回一个ReactComponent的实例,这样,当 React 执行重新渲染时,它将以最优的方式进行(当 React 检测新节点存在时,会在内存中构建一个虚拟的 DOM)。在上面代码中,我们创建了一个h2实例,内置于ReactComponent中。

注意:实例化 ReactComponent 的另一个方法是在 render 方法中使用JSX语法,以下代码段与前段代码作用相同:

render: ->
  `<div className="records">
    <h2 className="title"> Records </h2>
  </div>`

对我个人而言,当我使用 CoffeeScript 时,我更喜欢使用React.DOM语法而不是 JSX,因为代码可以排列成一个层次结构,类似于 HAML。但是,如果你正尝试集成 React 到一个用 erb 文件建立的现有应用中,你可以选择重用现有 erb 代码并将其转换成 JSX。

你现在可以刷新浏览器了。

records_1

好极了!我们已经画出第一个 React 组件了。现在,是时候显示我们的记录了。

除了render方法以外,React 组件还依靠properties的使用来和其他组件沟通,并且用states来检测是否需要进行重新渲染。我们需要用期望的值来初始化我们的组件状态和属性值:

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  getInitialState: ->
    records: @props.data
  getDefaultProps: ->
    records: []
  render: ->
    ...

getDefaultProps方法将初始化我们组件的属性,以防在初始化时我们忘了发送任何数据。而getInitialState方法则会生成我们组件的初始状态。现在我们还要显示由 Rails 提供的记录。

看起来我们还需要一个格式化amount字符串的 helper 方法,我们可以实现一个简单的字符串格式化工具并使其能让所有其他的coffee文件访问。用下列内容,在javascripts/目录下创建一个新的utils.js.coffee文件:

# app/assets/javascripts/utils.js.coffee

@amountFormat = (amount) ->
  '$ ' + Number(amount).toLocaleString()

我们需要创建一个新的Record组件来显示每个单独的记录,在javascripts/components目录下创建一个record.js.coffee的新文件,并插入以下内容:

# app/assets/javascripts/components/record.js.coffee

@Record = React.createClass
  render: ->
    React.DOM.tr null,
      React.DOM.td null, @props.record.date
      React.DOM.td null, @props.record.title
      React.DOM.td null, amountFormat(@props.record.amount)

Record组件将显示一个包含记录各个属性值单元格的表格行。不用担心那些在React.DOM.*调用中的那些null,那意味着我们不用传送属性值给组件。现在用以下代码更新下Record组件中的render方法:

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  ...
  render: ->
    React.DOM.div
      className: 'records'
      React.DOM.h2
        className: 'title'
        'Records'
      React.DOM.table
        className: 'table table-bordered'
        React.DOM.thead null,
          React.DOM.tr null,
            React.DOM.th null, 'Date'
            React.DOM.th null, 'Title'
            React.DOM.th null, 'Amount'
        React.DOM.tbody null,
          for record in @state.records
            React.createElement Record, key: record.id, record: record

你是否看到刚刚发生了什么?我们创建了一个带标题行的表格,并且在表格体内为每个已有的记录创建了一个Record元素。换句话说,我们正嵌套了内置或定制的 React 组件。相当酷,是不?

当我们处理动态子组件(本案例中为记录)时,我们需要提供一个key属性来动态生成的元素,这样 React 就不会很难刷新 UI,这就是为何我们要在创建 Record 元素时随同实际的记录一起发送key: record.id。如果不是这样做,我们将会在浏览器的 JS 控制台收到一条警告信息(并且在不远的将来产生一些头痛的问题)。

records_2

你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。

父子组件间通信:创建记录

现在我们显示了所有的已有记录,最好能包含一个用于创建记录的表单,让我们增加一个新功能给我们的 React/Rails 应用。

首先,我们吸引加入一个create方法到 Rails 控制器(不要忘了使用_strong*params*):

# app/controllers/records_controller.rb

class RecordsController < ApplicationController
  ...

  def create
    @record = Record.new(record_params)

    if @record.save
      render json: @record
    else
      render json: @record.errors, status: :unprocessable_entity
    end
  end

  private

    def record_params
      params.require(:record).permit(:title, :amount, :date)
    end
end

接着,我们需要构建一个用于处理创建新记录的 React 组件。该组件将拥有自己的state来存放datetitleamount。用下列代码,在目录javascripts/components下创建一个record_form.js.coffee的新文件:

# app/assets/javascripts/components/record_form.js.coffee

@RecordForm = React.createClass
  getInitialState: ->
    title: ''
    date: ''
    amount: ''
  render: ->
    React.DOM.form
      className: 'form-inline'
      React.DOM.div
        className: 'form-group'
        React.DOM.input
          type: 'text'
          className: 'form-control'
          placeholder: 'Date'
          name: 'date'
          value: @state.date
          onChange: @handleChange
      React.DOM.div
        className: 'form-group'
        React.DOM.input
          type: 'text'
          className: 'form-control'
          placeholder: 'Title'
          name: 'title'
          value: @state.title
          onChange: @handleChange
      React.DOM.div
        className: 'form-group'
        React.DOM.input
          type: 'number'
          className: 'form-control'
          placeholder: 'Amount'
          name: 'amount'
          value: @state.amount
          onChange: @handleChange
      React.DOM.button
        type: 'submit'
        className: 'btn btn-primary'
        disabled: !@valid()
        'Create record'

不是太花俏,仅仅是个平常的 Bootstrap 内嵌表单。注意,我们定义了value属性来设置输入的值,并且定义了onChange属性来绑定一个处理器方法,它将会在每次按键时都会被调用。handleChange处理器方法将用name属性来检测那一次输入触发了事件并更新相关的state值:

# app/assets/javascripts/components/record_form.js.coffee

@RecordForm = React.createClass
  ...
  handleChange: (e) ->
    name = e.target.name
    @setState "#{ name }": e.target.value
  ...

我们刚用了字符串插值来动态地定义对象的键值,当name等于title时,与@setState title: e.target.value等值。但为何我们必须使用@setState?为什么我们不能象对待普通的 JS 对象一样,仅对@state设置期望的值呢?因为@setState会产生两个动作:

  1. 更新组件的state
  2. 基于新状态,安排一个 UI 的验证或刷新

当我们每次在我们的组件中使用state时,掌握这个知识是非常重要的。

让我们看看submit按钮,就在render方法最后的地方:

# app/assets/javascripts/components/record_form.js.coffee

@RecordForm = React.createClass
  ...
  render: ->
    ...
    React.DOM.form
      ...
      React.DOM.button
        type: 'submit'
        className: 'btn btn-primary'
        disabled: !@valid()
        'Create record'

我们用!@valid()定义了一个disabled属性,这意味着我们将要实现一个valid方法来判断由用户提供的数据是否是正确的。

# app/assets/javascripts/components/record_form.js.coffee

@RecordForm = React.createClass
  ...
  valid: ->
    @state.title && @state.date && @state.amount
  ...

为了简化,我们仅仅校验@state属性是否为空。这样,每次状态更新后,Create record按钮都根据数据的有效性来决定可用或不可用。

creating_record_1 creating_record_2

现在控制器和表单都已就位,是时候提交新记录给服务器了。我们需要处理表单的submit事件。要完成这项任务,我们需要给表单添加一个onSubmit属性和一个新的handleSubmit方法(如同之前我们处理onChange事件一样):

# app/assets/javascripts/components/record_form.js.coffee

@RecordForm = React.createClass
  ...
  handleSubmit: (e) ->
    e.preventDefault()
    $.post '', { record: @state }, (data) =>
      @user39d data
      @setState @getInitialState()
    , 'JSON'

  render: ->
    React.DOM.form
      className: 'form-inline'
      onSubmit: @handleSubmit
    ...

让我们逐行检阅下这个新方法:

  1. 阻止表单的 HTTP 提交
  2. POST 新的record信息到当前 URL
  3. 提交成功后执行回调函数

success回调函数是这个过程的关键,在成功地创建新记录后,关于这个动作和state恢复到初始值的信息会被通报。还记得之前我曾提到的组件通过属性(或@props) 与其他组件进行沟通吗?对,就是它。当前我们这个组件就是通过@props.handleNewRecord发回数据给父组件,来通知它存在一个新记录。

也许你已经猜到,无论在哪里创建RecordForm元素,我们要传递一个handleNewRecord属性,并用一个方法引用到它,就像React.createElement RecordForm, handleNewRecord: @addRecord。好,父组件Records就是这个“无论在哪里”,由于它拥有一个附带了所有现存记录的state,所有需要我们用新建记录来更新它的 state。

records.js.coffee中添加新的addRecord方法并创建这个新的RecordForm元素,就在h2标题之后(在render方法之中)。

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  ...
  addRecord: (record) ->
    records = @state.records.slice()
    records.push record
    @setState records: records
  render: ->
    React.DOM.div
      className: 'records'
      React.DOM.h2
        className: 'title'
        'Records'
      React.createElement RecordForm, handleNewRecord: @addRecord
      React.DOM.hr null
    ...

刷新浏览器,在表单中填入一个新记录,点击Create record按钮...这次没有悬念,记录几乎立即被添加,而且在提交后表单被清理了,刷新仅仅是为了确认新数据已经被存入了后端服务器。

creating_record_and_records

如果连同 Rails 一起,使用其他的 JS 框架(例如 AngularJS)来构建类似的功能,你或许会遇到问题,因为你的 POST 请求不包括 Rails 所需的CSRFtoken。那么为什么我们没有遇到同样的问题?很简单,因为部门使用jQuery与后端交互,而且 Rails 的jquery_ujs非入侵式驱动器为我们每个AJAX请求都包含了CSRFtoken。酷!

你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。

可重用组件:合计指标

一个应用程序怎能没有一些漂亮的指标呢?让我们拿些有用的信息在窗口顶部添加一些指标框。我们的目的是为了在本章中显示三个值:贷方合计、借方合计和余额。这看起来像是三个组件,或仅仅是一个带属性组件的工作量?

我们能构建一个新的AmountBox组件,它获取三个属性:amounttexttype。在javascripts/components目录下创建一个叫做amount_box.js.coffee的文件,并粘贴以下代码:

# app/assets/javascripts/components/amount_box.js.coffee

@AmountBox = React.createClass
  render: ->
    React.DOM.div
      className: 'col-md-4'
      React.DOM.div
        className: "panel panel-#{ @props.type }"
        React.DOM.div
          className: 'panel-heading'
          @props.text
        React.DOM.div
          className: 'panel-body'
          amountFormat(@props.amount)

我们只用 Bootstrap 的panel元素以“块状”的方式来显示信息,并且通过type属性来设定颜色。我们也包含了一个叫做amountFormatter的相当简单的合计格式化方法,它读取amount属性并以货币格式来显示它。

为了有个完整的解决方案,我们需要在主组件中创建这个元素(三次),依赖我们要显示的数据,传送给所需的属性。让我们首先构建计算器方法,打开Records组件并添加以下代码:

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  ...
  credits: ->
    credits = @state.records.filter (val) -> val.amount >= 0
    credits.reduce ((prev, curr) ->
      prev + parseFloat(curr.amount)
    ), 0
  debits: ->
    debits = @state.records.filter (val) -> val.amount < 0
    debits.reduce ((prev, curr) ->
      prev + parseFloat(curr.amount)
    ), 0
  balance: ->
    @debits() + @credits()
  ...

credits合计所有金额大于 0 的记录,debits合计所有金额小于 0 的记录,而余额就无需多解释了。现在计算器方法已经就位了,我们仅需在render方法中创建AmountBox元素(就像上面的RecordForm组件一样):

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  ...
  render: ->
    React.DOM.div
      className: 'records'
      React.DOM.h2
        className: 'title'
        'Records'
      React.DOM.div
        className: 'row'
        React.createElement AmountBox, type: 'success', amount: @credits(), text: 'Credit'
        React.createElement AmountBox, type: 'danger', amount: @debits(), text: 'Debit'
        React.createElement AmountBox, type: 'info', amount: @balance(), text: 'Balance'
      React.createElement RecordForm, handleNewRecord: @addRecord
  ...

我们已经完成这个功能了!刷新浏览器,你会看到三个框里面显示计算好的金额。但是!这还没完!创建个新记录看看有什么神奇的东西...

amount_indicators

你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。

setState/replaceState:删除记录

我们清单中的下一个功能是删除记录,我们需要在记录表格中增加一个新的Actions列,对于每个记录的该列中都会有一个Delete按钮,相当标准的 UI。和之前的例子一样,我们要在 Rails 控制器中创建一个destroy方法:

# app/controllers/records_controller.rb

class RecordsController < ApplicationController
  ...

  def destroy
    @record = Record.find(params[:id])
    @record.destroy
    head :no_content
  end

  ...
end

那就是我们为此功能所需的全部服务器端代码。现在,打开Records组件并在表头最右边的位置添加Actions列:

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  ...
  render: ->
    ...
    # almost at the bottom of the render method
    React.DOM.table
      React.DOM.thead null,
        React.DOM.tr null,
          React.DOM.th null, 'Date'
          React.DOM.th null, 'Title'
          React.DOM.th null, 'Amount'
          React.DOM.th null, 'Actions'
      React.DOM.tbody null,
        for record in @state.records
          React.createElement Record, key: record.id, record: record

最后,打开Record组件并用Delete链接添加一个额外的列:

# app/assets/javascripts/components/record.js.coffee

@Record = React.createClass
  render: ->
    React.DOM.tr null,
      React.DOM.td null, @props.record.date
      React.DOM.td null, @props.record.title
      React.DOM.td null, amountFormat(@props.record.amount)
      React.DOM.td null,
        React.DOM.a
          className: 'btn btn-danger'
          'Delete'

保存你的文件,刷新浏览器并...我们有的只是没有的按钮,还没把事件附上!

deleting_record_1

让我们添加一些功能给它。和我们从RecordForm组件里学到的一样,方法如下:

  1. 检测在子组件Record中的事件(onClick)
  2. 执行一个动作(在本案例中,发送一个 DELETE 请求到服务器)
  3. 针对该动作,通知父组件Records(通过props来发送或接收一个处理器方法)
  4. 更新Record组件的状态

要实现步骤 1,我们可以为onClick添加一个处理器到Record,就像我们为onSubmit添加一个处理器到RecordForm来创建新记录一样。幸运的是,React 以标准化方式实现了大多数常见浏览器事件,这样我们就无需担心跨浏览器的兼容性(你可以在这里查看到完整的事件清单)。

重新打开Record组件,添加一个新的handleDelete方法和一个onClick属性到“无用”的删除按钮,代码如下:

# app/assets/javascripts/components/record.js.coffee

@Record = React.createClass
  handleDelete: (e) ->
    e.preventDefault()
    # yeah... jQuery doesn't have a $.delete shortcut method
    $.ajax
      method: 'DELETE'
      url: "/records/#{ @props.record.id }"
      dataType: 'JSON'
      success: () =>
        @user74cord @props.record
  render: ->
    React.DOM.tr null,
      React.DOM.td null, @props.record.date
      React.DOM.td null, @props.record.title
      React.DOM.td null, amountFormat(@props.record.amount)
      React.DOM.td null,
        React.DOM.a
          className: 'btn btn-danger'
          onClick: @handleDelete
          'Delete'

当删除按钮被点击时,handleDelete发送一个 AJAX 请求到服务器来删除后端的记录,之后,针对本次动作,它通过handleDeleteRecord处理器可用的props来通知父组件。这意味着我们需要在父组件中调整Record元素的创建来包含额外的属性handleDeleteRecord,而且还要在父组件中实现实际的处理器方法:

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  ...
  deleteRecord: (record) ->
    records = @state.records.slice()
    index = records.indexOf record
    records.splice index, 1
    @replaceState records: records
  render: ->
    ...
    # almost at the bottom of the render method
    React.DOM.table
      React.DOM.thead null,
        React.DOM.tr null,
          React.DOM.th null, 'Date'
          React.DOM.th null, 'Title'
          React.DOM.th null, 'Amount'
          React.DOM.th null, 'Actions'
      React.DOM.tbody null,
        for record in @state.records
          React.createElement Record, key: record.id, record: record, handleDeleteRecord: @deleteRecord

基本上,我们的deleteRecord方法拷贝了当前组建的recordsstate,执行了一个要被删除记录的索引查找,从该数组中拼接好并更新组件的 state,相当标准的 JavaScript 操作。

我们介绍一个和state交互的新办法,replaceStatesetStatereplaceState的主要不同在于前者仅更新state对象的一个键值,而后者会用任何我们发送的新对象来完全覆盖组件的当前 state。

在更新完上面那点代码后,刷新浏览器并尝试删除一个记录,会两个事情发生:

  1. 该记录会从表格中消失
  2. 指标的金额会立即更新,不需要额外的代码了

deleting_record_2

我们几乎完成整个应用程序了,但在实现最后一个功能之前,我们能实施一个小重构,并同时介绍一个新的 React 功能。

你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。

重构:State Helpers

现在为止,我们已经有两种方法让 state 作为我们的数据获取更新,没有任何困难,并不像你所说的那么“复杂”。但设想下一个带有多层次 JSON state 的更复杂的应用程序,你能自己想象下执行深度复制和变换你的 state 数据。React 包含了一些花俏的state helpers来帮助你应对这个重担。无论你的state有多深,这些 helper 都会让你如同使用 MongoDB 的查询语言一样,更自由地操纵它(至少React 的文档是这样说的)。

在使用这些 helper 之前,首先我们需要配置下我们的 Rails 应用程序来包含它们。打开你项目的config/application.rb文件并在 Application 代码块的尾部添加一行config.react.addons = ture

# config/application.rb

...
module Accounts
  class Application < Rails::Application
    ...
    config.react.addons = true
  end
end

为了另其生效,你要重启 Rails 服务器你要重启 Rails 服务器你要重启 Rails 服务器,重要的事情说三遍!现在我们可以通过React.addons.update来访问 state helpers,它们会处理我们的 state 对象(或任何我们发送给它的对象),并且能使用提供的命令。我们将会使用的两个命令是$push$splice(对这些命令,我借用官方 React 文档的解释):

  • {$push: array}array里的所有数据项push()到目标去
  • {$splice: array of arrays}对于在arrays中的每个数组项array,在目标数组中用数据项提供的参数调用splice()

我们打算用这些 helper 来简化Record组件的addRecorddeleteRecord,代码如下:

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  ...
  addRecord: (record) ->
    records = React.addons.update(@state.records, { $push: [record] })
    @setState records: records
  deleteRecord: (record) ->
    index = @user88f record
    records = React.addons.update(@state.records, { $splice: [[index, 1]] })
    @replaceState records: records

同样的结果,更短更优雅的代码,现在你可以随便重载下浏览器并确认有没什么不妥。

你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。

响应式数据流:编辑记录

为了实现最后一个功能,我们现在添加一个额外的Edit按钮,放在我们记录表格中的每个Delete按钮的旁边。当这个Edit按钮被点击时,它将整个数据行从只读状态切换成可编辑状态,展示一个行内表单以便用户可以更新记录的内容。在提交被更新内容或取消该操作后,该记录行将会到它原来的只读状态。

正如你从上文描述中猜到的那样,我们需要处理可变(mutable)数据来切换在Record组件中每个记录的状态。这是一个 React 调用响应式数据流(reactive data flow)的用例。让我们添加一个edit标志和一个handleToggle方法到record.js.coffee

# app/assets/javascripts/components/record.js.coffee

@Record = React.createClass
  getInitialState: ->
    edit: false
  handleToggle: (e) ->
    e.preventDefault()
    @setState edit: !@state.edit
  ...

这个edit标志默认为false,而handleToggleedit由 false 变为 true,亦可反向操作,我们仅需要从一个用户onClick事件中触发handleToggle

现在,我们需要处理两个行记录版本(只读和表单)并且有条件地根据edit标志来显示它们。幸运的是,只要render方法返回一个 React 元素,我们就可以在它里面随意执行任何操作。我们可以定义recordRowrecordForm两个 helper 方法,并在render里面,依赖于@state.edit的内容有条件地调用它们。

我们已经有了一个recordRow的初始化版本,就是我们现在的render方法。让我们把render的内容移到新的recordRow方法里并添加一些额外的代码给它:

# app/assets/javascripts/components/record.js.coffee

@Record = React.createClass
  ...
  recordRow: ->
    React.DOM.tr null,
      React.DOM.td null, @props.record.date
      React.DOM.td null, @props.record.title
      React.DOM.td null, amountFormat(@props.record.amount)
      React.DOM.td null,
        React.DOM.a
          className: 'btn btn-default'
          onClick: @handleToggle
          'Edit'
        React.DOM.a
          className: 'btn btn-danger'
          onClick: @handleDelete
          'Delete'
  ...

我们只加入了一个额外的React.DOM.a元素,用来监听到onClick事件后调用handleToggle

接着,recordForm的实现采用类似结构,只是每个单元格用 input 来代替。我们打算为这些 input 用一个新的ref属性来使其变得可存取。和这个组件不出来state一样,这个新的属性会让我们的组件通过@refs读出由用户提供的数据。

# app/assets/javascripts/components/record.js.coffee

@Record = React.createClass
  ...
  recordForm: ->
    React.DOM.tr null,
      React.DOM.td null,
        React.DOM.input
          className: 'form-control'
          type: 'text'
          defaultValue: @props.record.date
          ref: 'date'
      React.DOM.td null,
        React.DOM.input
          className: 'form-control'
          type: 'text'
          defaultValue: @props.record.title
          ref: 'title'
      React.DOM.td null,
        React.DOM.input
          className: 'form-control'
          type: 'number'
          defaultValue: @props.record.amount
          ref: 'amount'
      React.DOM.td null,
        React.DOM.a
          className: 'btn btn-default'
          onClick: @handleEdit
          'Update'
        React.DOM.a
          className: 'btn btn-danger'
          onClick: @handleToggle
          'Cancel'
  ...

别害怕,这个方法看起来有点大,仅仅是因为我们用了类似 HAML 的语法。注意,当用户点击 Update 按钮时我们调用@handleEdit,我们打算使用与实现删除记录功能类似的流程。

你有否注意到这些React.DOM.input的创建有什么不同吗?我们使用defaultValue代替value来设置初始化 input 的值,这是因为:仅使用value而没有onChange会终止创建只读的 input

最后,render 方法浓缩成下列代码:

# app/assets/javascripts/components/record.js.coffee

@Record = React.createClass
  ...
  render: ->
    if @state.edit
      @recordForm()
    else
      @recordRow()

你可以刷新你的浏览器来看看新的切换效果,但不要提交任何改变,因为我们还没实现实际的 update 功能。

edit_record_1 edit_record_2

要处理记录的更新,我们需要添加update方法到我们的 Rails 控制器:

# app/controllers/records_controller.rb

class RecordsController < ApplicationController
  ...
  def update
    @record = Record.find(params[:id])
    if @record.update(record_params)
      render json: @record
    else
      render json: @record.errors, status: :unprocessable_entity
    end
  end
  ...
end

回到我们的Record组件,我们需要实现handleEdit方法,它将会附带要更新的record信息发送一个 AJAX 请求到服务器,然后由发送更新后版本的记录数据通过handleEditRecord方法通知父组件,这个方法会通过@props被接收到,我们在实现删除记录时用过同样的方法:

# app/assets/javascripts/components/record.js.coffee

@Record = React.createClass
  ...
  handleEdit: (e) ->
    e.preventDefault()
    data =
      title: this.refs.title.value
      date: this.refs.date.value
      amount: this.refs.amount.value
    # jQuery doesn't have a $.put shortcut method either
    $.ajax
      method: 'PUT'
      url: "/records/#{ @props.record.id }"
      dataType: 'JSON'
      data:
        record: data
      success: (data) =>
        @setState edit: false
        @user120rd @props.record, data
  ...

为简单起见,我们不校验用户数据,我们仅仅通过React.findDOMNode(@refs.fieldName).value读取它,并且一字不差的把它发送给后端。在success时更新状态来切换 edit 方式不是强制性的,但用户会因此而明确地感谢我们。

最后但并非最不重要,我们仅需要更新 Records 组件上的 state,用子组件的新版本记录来覆盖之前的旧记录并让 React 发挥它的魔力。实现的代码如下:

# app/assets/javascripts/components/records.js.coffee

@Records = React.createClass
  ...
  updateRecord: (record, data) ->
    index = @user124f record
    records = React.addons.update(@state.records, { $splice: [[index, 1, data]] })
    @replaceState records: records
  ...
  render: ->
    ...
    # almost at the bottom of the render method
    React.DOM.table
      React.DOM.thead null,
        React.DOM.tr null,
          React.DOM.th null, 'Date'
          React.DOM.th null, 'Title'
          React.DOM.th null, 'Amount'
          React.DOM.th null, 'Actions'
      React.DOM.tbody null,
        for record in @state.records
          React.createElement Record, key: record.id, record: record, handleDeleteRecord: @deleteRecord, handleEditRecord: @updateRecord

和我们从上一章学到的一样,使用React.addons.update来改变我们的 state 会产生更稳固的方法。RecordsRecord之间最后的联接是通过handleEditRecord属性来发布方法@updateRecord

最后一次刷新浏览器并尝试更新一些已有的记录,注意页面顶端的金额框如何与你改变的每个记录关联。

edit_record_3

搞定了!我们刚刚一步步地构建了一个小型的 Rails + React 的应用程序!

你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。

结尾的思考:React.js,简洁又灵活

我们已经验证了一些 React 的功能,而且我们还学到了几乎所有它引入的新概念。我听到人们评论这个或那个的 JavaScript 框架因引入新概念而使其学习曲线变得陡峭,但 React 不是这样的。它实现了例如事件处理和绑定等核心 JavaScript 概念,使其易于使用和学习。再次证明,其优势之一就是简洁。

通过实例,我们也学到了如何使其集成到 Rails 的 assets pipeline,而且也能很好的与 CoffeeScript、jQuery、Turbolinks 及 Rails 的其余部分协同工作。但是,这并非是想要获取结果的唯一方式。例如,你不想使用 Turbolinks(因此你不需要react_ujs),你能用Rails Assets来代替react_rails这个 gem,你可以使用Jbuilder来构造更复杂的JSON响应来代替提供的JSON对象,等等。你仍然会得到同样不错的效果。

React 将明显地提升你的前端能力,让它成为你 Rails 工具箱中一个强大的库吧!

编程、翻译双菜,大家轻拍 `B-)

还是要抽点时间把 reactjs 学下,虽然不喜欢写 js 了。。。

赞 不过不是很赞同这种方式集成 react

排版好,👍。

#3 楼 @chucai 请问能否分享下您的经验?

#4 楼 @lana 谢谢,原文排版就不错,我只是把它转成了 markdown 而已

感谢,正想了解下 React 在 rails 里面怎么使用

#7 楼 @dayudodo 我最近也在关注 React.js 刚好碰到这篇文章,所以顺便翻译了共享给大家。

非常感谢!!! 最近正在学习,但是这个好像不需要用到 babel?我看官网使用 react 的时候是用了的,还是已经直接封装进去了?

#1 楼 @sail_lee 没深入用过 react,不过有一点不大明白,麻烦解惑:Rails 的写界面的生产力是如此的高,用 React 后相当于回到原始社会,得手写 HTML 了,对此可有解决方案?

#7 楼 @dayudodo 楼主这个十个旧贴 其实 React 搞但页面感觉比较适合

#10 楼 @qinfanpeng React 的核心是组建,尽量复用 UI 组件。当你有复用的时候,就不会觉得原始了,而且开发好维护(UI 状态)。Rails View 更多的是 html helper,写的还是网页,它没办法写单页面。

#12 楼 @small_fish__ 话虽如此,直接在 jsx 里手写 HTML 还是有点受不了,好在有一些 react 的 UI 组件库可以用。

在 Rails 里面集成 React、Angular 这类的前端框架总是感觉怪怪的。

React 一统江湖。

#9 楼 @millim 我翻看了 react-rails 的源码,还真没有看到 babel 的影子,babel 的作用好像只是用来翻译 ES6 吧,这里没有用到 ES6,应该不用 babel。

#14 楼 @happybai 我是从开发 Desktop APP 过来的,我自己理解的 Rails+JS 的方案中,Rails 负责后台提供数据和 HTML 的初始渲染,而 JS 就是用来接管客户端的控制,根据用户的行为动作来进行事件驱动后的处理。

匿名 #20 2016年05月19日
data =
        title: React.findDOMNode(@refs.title).value
        date: React.findDOMNode(@refs.date).value
        amount: React.findDOMNode(@refs.amount).value

改成

data =
      title: this.refs.title.value
      date: this.refs.date.value
      amount: this.refs.amount.value

在 React 15 里面,上面的写法会报错

楼主翻译的这篇教程是 14 年的,那时候还习惯用 coffee 写 React

其实 jsx 挺好的,可以尝试 react-rails 这个 gem,是一个比较简单无脑的集成方法

coffee 写 react 感觉怪怪的。 react-rails 是支持 es6 的写法的。

多谢楼主翻译,但是个人感觉 es6 写起来还要好点

#19 楼 @weirongxu 谢谢指出我的谬误,的确没有认真看清楚就乱说了

#20 楼 @brookzhang 谢谢提醒!已修改。

#21 楼 @renyuanz 的确,萝卜青菜各有所爱,coffee 和 ES6 某种意义上来说,都是语法糖,看各位喜好了。

#22 楼 @miyazawatomoka 的确是支持 ES6 的

#5 楼 @sail_lee 可供选择的方案至少还有:react_on_rails 或者 前后端完全分离

react-rails 最为 simple,但是也只适用于需求简单的 app..否则迟早还是要用 redux 和 router 或者其他 npm package,那时候 react-rails 的弊端就会显现了,可以参考 react_on_rails 的幻灯片 前后端分离也是一种选择,但是有时候项目可能有几个页面并不需要用到 react,或者 head 要用 rails 去处理,并且你会有更多的配置要做来实现 server-side render 等 react_on_rails 是折中方案,更加遵循约定优于配置的 rails 理念,利用 webpack 打包前端各种资源后塞进 asset pipeline,可以更好地利用 node 生态,同时这个 gem 也会帮你省去很多额外工作,比方在 dev 环境下启用 HMR。

我也不太喜欢 react-rails 这种方式,迟早要遇到瓶颈,比较推荐 react_on_rails。 PS: react_on_rails 目前的 tutorial 是 5.x 版本的,6.x 已经预发布了,等 tutorial 出了,楼主可以考虑翻译下(我是伸手党哈哈)

#18 楼 @sail_lee 我感觉 Rails 最初的设计理念就是 MVC,而且是重 Model 的充血模型的 MVC,当 ViewModel 和 Model 有冲突的时候,传统 Rails 的做法是将 ViewModel 中特有属性,以 Attribute 的形式融入 Model,然后 HTML 用各种 Form Helper 去渲染到前端。这其中充斥着大量的约定。但这些约定都是 React 之类的前端框架所不需要的。以 react-rails 这种方式融入 react,我感觉既浪费了大量的 Rails 的约定(haml、各种 helper、各种 Controller、Route),又使 React 变得臃肿(依赖 Jquery、turbolink),貌似也不好和其他组件集成(例如 router、redux)。如果只想用 Rails 提供数据,我觉得用 rails-api 之类的纯 api,以及以 Node 为核心构建的 react 体系更为合适。

我测试了一下回复,然后就找不到在哪删除

感觉 JS 端比较重啊,最近在 Rails 里面使用 Vue.js,感觉还不错

#29 楼 @nostophilia #30 楼 @happybai 我理解两位建议大致思路都是:假如必须是 rails+react 的话,那么最好是前后端完全分离,而且最彻底的就是 rails 只做 API 服务,而前端完全独立于 rails 来使用 react。

#32 楼 @grant 如果象上面那样使用 react 的话,是比较重。的确,react.js 能做的,Vue.js 也能完成,甚至 vue.js 体量更小

#33 楼 @sail_lee 是的,只不过不是“最好”。我不喜欢 react-rails 可能跟自己接触的项目需求有关,最适合的方案是什么还是视乎你的项目。如果你的界面只有一部分要用 react,而且也用不到多少 npm package,那 react-rails 可能就是最适合的了。

用 react-rails 的话,要么自己找已构建好的版本,比如 redux,然后复制粘贴到 vendor 文件夹下 (react-rails 的作者经常这么建议),然后用 Sprockets require,不过这样管理显然比较低效,也难以维护;至于 rails-assets 和 browserify-rails,rails-assets 包管理用的是 bower,但是因为扁平依赖、基于 github 的安装等问题受到诟病,已经开始被社区抛弃了,redux 的主要作者已经多次声明使用 bower 是 bad idea 而且他们官方不会提供支持;但是 browserify-rails + react-rails 的组合还是有点别扭用你会发现你在一边用 gem 管理包,另一边用 npm,一边非模块化,一边模块化,这可能就是他们所说的不舒服吧。所以还不如彻底点都用 npm,另外据说 browserify-rails 性能不太好。

至于前后端彻底分离,类似阿里中途岛的前后端分离理念有他们特殊的用例,我觉得大多数 Rails 项目都不需要。分离开就得利用 node.js 搭建前端服务器,如果要 server-side render,初始数据要通过 http 请求从 JSON API 获取,反倒多了一层通信,而 node.js 的优势多数时候也没被利用到。react_on_rails 基本上也不会发生什么前后冲突,前端部分大多也是在一个子文件夹下进行的。既然 Rails 能完成所有工作,配合 react_on_rails 也能分离职责,为什么要画蛇添足加一层呢。

请问下做项目的模型的原型图软件是什么?看起来很干净整洁。

#36 楼 @tkin1992 我是直接贴作者的图,并不清楚用的是什么软件。不过,可以推荐一个给你:http://pencil.evolus.vn/

#19 楼 @weirongxu 你好,我在使用你写的 netpayclient 的 GEM,有一些问题想咨询你,可好?

楼主有个地方写法改下就好了了。

@user39d data

应该是

this.props.handleNewRecord data
# 如果把this换成@,就会变成  @user39d data了
需要 登录 后方可回复, 如果你还没有账号请 注册新账号