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工具箱中一個強大的庫吧!