React.js的Rails开发者指南
原作者:Fernando Villalobos
原文链接:https://www.airpair.com/reactjs/posts/reactjs-a-guide-for-rails-developers
译者:Sail Lee
目录
- React.js简介
- 一个模拟的费用跟踪应用
- 在Rails项目中初始化React.js
- 创建Resource
- 嵌套式组件:记录列表
- 父子组件间通信:创建记录
- 可重用组件:合计指标
- setState/replaceState:删除记录
- 重构:State Helpers
- 响应式数据流:编辑记录
- 结尾的思考:React.js,简洁又灵活
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
我们将要构建一个包含date
、title
和amount
字段的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-rails
gem包提供的,假如你决定使用其他的集成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。
你现在可以刷新浏览器了。
好极了!我们已经画出第一个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控制台收到一条警告信息(并且在不远的将来产生一些头痛的问题)。
你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。
父子组件间通信:创建记录
现在我们显示了所有的已有记录,最好能包含一个用于创建记录的表单,让我们增加一个新功能给我们的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来存放date
、title
和amount
。用下列代码,在目录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
会产生两个动作:
- 更新组件的state
- 基于新状态,安排一个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按钮都根据数据的有效性来决定可用或不可用。
现在控制器和表单都已就位,是时候提交新记录给服务器了。我们需要处理表单的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
...
让我们逐行检阅下这个新方法:
- 阻止表单的HTTP提交
- POST新的
record
信息到当前URL - 提交成功后执行回调函数
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按钮...这次没有悬念,记录几乎立即被添加,而且在提交后表单被清理了,刷新仅仅是为了确认新数据已经被存入了后端服务器。
如果连同Rails一起,使用其他的JS框架(例如AngularJS)来构建类似的功能,你或许会遇到问题,因为你的POST请求不包括Rails所需的CSRF
token。那么为什么我们没有遇到同样的问题?很简单,因为部门使用jQuery
与后端交互,而且Rails的jquery_ujs
非入侵式驱动器为我们每个AJAX请求都包含了CSRF
token。酷!
你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。
可重用组件:合计指标
一个应用程序怎能没有一些漂亮的指标呢?让我们拿些有用的信息在窗口顶部添加一些指标框。我们的目的是为了在本章中显示三个值:贷方合计、借方合计和余额。这看起来像是三个组件,或仅仅是一个带属性组件的工作量?
我们能构建一个新的AmountBox
组件,它获取三个属性:amount
、text
和type
。在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
...
我们已经完成这个功能了!刷新浏览器,你会看到三个框里面显示计算好的金额。但是!这还没完!创建个新记录看看有什么神奇的东西...
你可以到这里去看看本章的结果代码,或者仅仅到这里看看由本章引入的改变。
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'
保存你的文件,刷新浏览器并...我们有的只是没有的按钮,还没把事件附上!
让我们添加一些功能给它。和我们从RecordForm
组件里学到的一样,方法如下:
- 检测在子组件
Record
中的事件(onClick) - 执行一个动作(在本案例中,发送一个DELETE请求到服务器)
- 针对该动作,通知父组件
Records
(通过props来发送或接收一个处理器方法) - 更新
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
方法拷贝了当前组建的records
state,执行了一个要被删除记录的索引查找,从该数组中拼接好并更新组件的state,相当标准的JavaScript操作。
我们介绍一个和state交互的新办法,replaceState
。setState
和replaceState
的主要不同在于前者仅更新state对象的一个键值,而后者会用任何我们发送的新对象来完全覆盖组件的当前state。
在更新完上面那点代码后,刷新浏览器并尝试删除一个记录,会两个事情发生:
- 该记录会从表格中消失
- 指标的金额会立即更新,不需要额外的代码了
我们几乎完成整个应用程序了,但在实现最后一个功能之前,我们能实施一个小重构,并同时介绍一个新的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
组件的addRecord
和deleteRecord
,代码如下:
# 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
,而handleToggle
将edit
由false变为true,亦可反向操作,我们仅需要从一个用户onClick
事件中触发handleToggle
。
现在,我们需要处理两个行记录版本(只读和表单)并且有条件地根据edit
标志来显示它们。幸运的是,只要render
方法返回一个React元素,我们就可以在它里面随意执行任何操作。我们可以定义recordRow
和recordForm
两个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 功能。
要处理记录的更新,我们需要添加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 会产生更稳固的方法。Records
和Record
之间最后的联接是通过handleEditRecord
属性来发布方法@updateRecord。
最后一次刷新浏览器并尝试更新一些已有的记录,注意页面顶端的金额框如何与你改变的每个记录关联。
搞定了!我们刚刚一步步地构建了一个小型的 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工具箱中一个强大的库吧!