React.js的Rails开发者指南

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
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
records_2

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

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

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

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

  # 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_1

creating_record_2
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) =>
        @props.handleNewRecord 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
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
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
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: () =>
          @props.handleDeleteRecord @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
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 = @state.records.indexOf 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_1

edit_record_2
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
          @props.handleEditRecord @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 = @state.records.indexOf 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
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工具箱中一个强大的库吧!

推荐阅读更多精彩内容