蒼時弦也
蒼時弦也
資深軟體工程師
發表於

React.js + Parse 實做簡易留言板

前一陣子 SITCON 文創組冬季訓練最後一天,我安排了這個課程給我們的新成員。 雖然 SITCON 文創組看似是個需要「技術」的團隊,不過現實上我們倒是花很多時間在思考跟設計上,沒辦法找到設計相關科系的新成員稍稍遺憾。

不過因為有製作網站的需求,因此安排了這個課程,透過學習 React.js 以及結合 Parse 去熟悉一些基本的前端技巧。

注意事項:

  1. 文中的範例全部都以 CoffeeScript 撰寫
  2. 本文不會提及 Browserify 的配置與應用(當天有介紹過,練習時是使用我配置好的 gulp task)
  3. 這是在不考慮 UI/UX 以及美術的前提下製作的
  4. 文中不會解釋太多 React.js / Flux 的基本概念(請上官網 or ReactJS.tw 社團學習)

那麼,就開始吧!

拆分元件

React.js 的 Component(元件)的概念,某種程度上是需要重新定義大家腦中 HTML / JS / CSS 配合的概念,而這個應用方式在很多時候其實能夠幫我們解決不少問題。 (個人認為很像 Shadow DOM 的感覺)

而 Component 該怎麼拆分呢?簡單來說最小單位就像是一個 <button> 都可以視為一個 Component 只是要看需求。

我們可以利用 Component 重新去定義一個 HTML 元素的效果 Ex. <a> 的 onClick 事件重新定義,但是又可以重複利用

建構一個留言板我們會需要幾個元件:

  • 留言板主體(通常會叫做 Application 或者 App
    • 留言顯示區域
      • 單篇留言
    • 留言表單

以一個最低限度結構的留言板來說,至少需要這幾種元件才能夠構成。

其實仔細看,會發現基本上也就跟常見的 MVC 框架在拆分 View 的技巧感覺很類似。

不過就如同前面所說的,有時候 Component 也用於「重新定義」某個 HTML 的元素效果。 像是 react-bootstrap 就利用重新定制的 <Nav> <MenuItem> 等來表現 Navbar(像是實際上都只是反映單個 HTML 元素而已)

架構

以 React.js 本身來說,實際上是不足以製作一個完整的 WebApp 的,因此才加入了 Flux 這套理論(我想不適合視為 Library / Framework 而是跟 MVC 類似的理論比較恰當)

原本的 React.js 其實只有定義了 Component 以及來自外部的「屬性」表現內部的「狀態」而已,但是若要跟 API 溝通並且進行讀取與寫入資料該如何表現呢?

這時候透過 Flux 所定義的架構就可以很輕鬆的實踐。

Flux 架構是一個單向的流程,不管如何一定會從 Actions 開始出發(有時候也會回到 Actions)但是絕不會有返回的狀況

  • Actions/
    • 通常是處理 API 的部分,會透過 Dispatcher 對 Store 做出操作
  • Constants/
    • 定義 Action 類型的輔助套件(可以不實作,但是缺點會是很容易因為輸入錯誤的字串而無法正常運作)
  • Components/
    • React.js 所定義的元件,會監聽 Store 的變動更新自身
  • Store/
    • 主要儲存資料的地方,透過從 Dispatcher 收到的變更進行處理
  • Dispatcher
    • 負責指派工作(其中包含了 waitFor() 可以等待一連串的動作完成)

在開發 React.js/Flux 的 WebApp 時,只要注意自己的控制流程是否依循著 Action -> Dispatcher -> Store -> View (Component) 就可以知道自己是否在做正確的設計。

建構元件

我個人的習慣是以 React.js 的元件開始做起,因為可以直接看到最終的成果(即使還沒有跟 API 連接上,但也能看到不少基本效果)

主體(Application)

因為是留言板,所以這邊我用了 GuestBook.cjsx 而非 Application.cjsx 作為檔名。

大家可能會預設情況是「只能有一個 Application 存在頁面」但對於 React.js 來說只是把不同的 Virtual DOM 更新到實際的 DOM 上面,因此是可以在一個頁面做多次 render 動作混合 React.js 跟傳統網頁的

 1###
 2# GuestBook
 3#
 4# @cjsx React.DOM
 5###
 6
 7React = require 'react'
 8
 9Comments = require './Comments.cjsx' # 這之後會實作
10CommentForm = require './CommentForm.cjsx' # 同上
11
12module.exports = React.createClass {
13  render: ->
14    (
15      <div>
16        <CommentForm />
17        <Comments />
18      </div>
19    )
20}

大多數時候主體框架只是用於把各種子元件讀取進來而已,因此看起來非常的簡單。

要注意的是,因為 React.js 把一個原件視為一個 DOM 物件,因此回傳時務必不能同時傳回兩個元件(這邊就用 div 包起來)

留言表單(CommentForm)

表單的實作上比較簡單,留言部份還包含了另一個子元件「單篇留言」因此就到下一階段再進行處理。

 1###
 2# Comment Form
 3#
 4# @cjsx React.DOM
 5###
 6
 7React = require 'react'
 8
 9CommentAction = require '../actions/CommentAction.coffee' # 後面會實作 Comment Action
10
11module.exports = React.createClass {
12  getInitialState: ->
13  	# React.js 的 State 都需要「事先定義」才能夠使用
14    {
15      content: ""
16    }
17
18  _onSubmit: (e) -> # 處理 Submit 的方法(收到的 e 就跟原生 JavaScript 拿到的 Event 是相同的)
19    CommentAction.create(@state.content) # 呼叫 Action 執行某個任務(新增留言)
20    @setState { content: "" }
21
22    e.preventDefault() # 取消原有的表單送出動作
23
24  _onChange: (e) ->
25    # 這算是一種小技巧,我們會發現在 _onSubmit 方法要取得 textarea 的內容是很困難的
26    # 因此就隨時將表單內容存到狀態中(再次時做表單處理)用於送出時使用
27    # 不過要注意的是後面的 textarea 設定了 value={@state.content} 可以確保使用者輸入的跟送出的結果是相同的
28    @setState { content: e.target.value }
29
30  render: ->
31    (
32      <div>
33        <form onSubmit={@_onSubmit}>
34          <textarea onChange={@_onChange} placeholder="Messages..." value={@state.content}></textarea>
35          <button type="submit">Submit</button>
36        </form>
37      </div>
38    )
39}

實際上,在 React.js 中的元件設計都是很簡單的,從目前的兩個例子就可以觀察到。

留言區(Comments)

前面的段落看到了 State (內部狀態)的使用,這邊則會看到 Props (外部傳入)的應用,以及動態產生元件時的運用。

 1###
 2# Comments
 3#
 4# @cjsx React.DOM
 5###
 6
 7React = require 'react'
 8
 9CommentStore = require '../stores/CommentStore.coffee' # 後面也會實作 Store 的部分
10CommentAction = require '../actions/CommentAction.coffee'
11
12CommentItem = require './CommentItem.cjsx'
13
14module.exports = React.createClass {
15  getInitialState: ->
16    {
17      comments: CommentStore.getAll() # 初始化的時候從 Store 拿出目前儲存的資料
18    }
19
20  _onChange: ->
21    @setState { comments: CommentStore.getAll() } # 更新目前儲存的資料
22
23  componentDidMount: ->
24    CommentAction.load() # 在元件被加入到頁面上時呼叫「讀取」動作進行 API 查詢
25    CommentStore.addChangeListener @_onChange # 向 Store 登記「變更」事件以了解資料更新
26
27  componentWillUnmount: ->
28    CommentStore.removeChangeListner @_onChange # 當元件即將被移除時也解除事件監聽
29
30  generateCommentsItem: ->
31    # React.js 動態產生元件可以透過陣列的方式呈現,而每個元素則要給予一個 key 屬性
32    # 這邊將狀態中儲存的留言資訊依序取出,然後放入對應的屬性
33    # 這邊的 data.get() 用法是 Parse API 存取屬性的方式(一般情況使用 data.content 就可以了)
34    @state.comments.map (data) -> 
35      <CommentItem key={data.id} content={data.get('content')} createTime={data.get('time').toString()} />
36
37  render: ->
38    (
39      <div>
40        {@generateCommentsItem()}
41      </div>
42    )
43}

留言內容(CommentItem)

這部分就沒有什麼好討論的,單純就是呈現上的應用(資料來自於屬性)

 1###
 2# Comment Item
 3#
 4# @cjsx React.DOM
 5###
 6
 7React = require 'react'
 8
 9module.exports = React.createClass {
10  render: ->
11    (
12      <div>
13        <p>{@props.content}</p>
14        <footer>
15          <span>{@props.createTime}</span>
16        </footer>
17      </div>
18    )
19}

這邊可以這樣想像「State」是一個私有屬性,只有元件自己可以操作。而「Props」則是 State 的變化體,只接受外部傳入的數值(父元件才有編輯的權限)這樣就比較能區分出使用上的時機

Dispatcher

這邊會先解釋 Dispatcher 是因為後面的 Action 與 Store 都會需要使用到,因此必須先完成才能夠繼續進行。

其實在實作元件之前先製作 Dispatcher 也沒有關係

 1
 2###
 3# Dispatcher
 4###
 5
 6Dispatcher = require('flux').Dispatcher
 7
 8# 簡單說就是做物件的繼承,在 Facebook 的範例會看到使用 Object.assign 去輔助
 9# 不過 Dispatcher 也可以利用 CoffeeScript 提供的 extends 來做擴充
10# 後面的 Store 會因為要從 EventEmitter 的 prototype 繼承而無法做這件事情
11# 關於 Object.assign 的用途,可以參考 ES6 相關文章的介紹
12
13class AppDispatcher extends Dispatcher
14  handleViewAction: (action) -> # 擴充一個 handleViewAction 用來處理跟 View 相關的動作(大多數時候也只會有這一個)
15    @dispatch { # 觸發某些動作
16      source: 'VIEW_ACTION' # 看到用全部大寫的物件,請合理猜測是使用 Constant 的時機(這邊文章都會用一般字串帶過)
17      action: action # 將收到的動作物件一併傳給 Store
18    }
19
20module.exports = new AppDispatcher

Store

接下來我們要實作一個 Store 來儲存資料,至於該怎麼實作基本上是沒有限制的 Ex. Hash, Array 都可以

這裏會是這篇文章程式碼最多的一個部分,不過並不是什麼複雜的程式,大多數都是在描述「處理」事件上。

 1
 2###
 3# CommentStore
 4###
 5
 6# 利用 browserify 的功能,讓我們可以將 EventEmitter 在瀏覽器上實作(這個非常好用)
 7EventEmitter = require('events').EventEmitter 
 8# 如果是使用 ES6 就不需要這個輔助套件
 9assign = require 'react/lib/Object.assign' 
10
11AppDispatcher = require '../Dispatcher.coffee'
12
13_comments = [] # 這邊用一個 Array 實作陣列的儲存(大多數時候從 API 撈出來也都是陣列,排序上可以依靠 API 而不需要自己處理,反而是專注在容易呈現會更好)
14
15dispather = (payload) -> # 向 Dispatcher 登記需要一個處理程序,這部分就是在定義當 Dispatcher 被觸發時應該做什麼動作
16	# 取出 Action 物件
17  # Action 物件包含什麼都是看開發者高興的,只是我們通常會給一個 actionType 屬性
18  action = payload.action 
19
20  switch action.actionType # 透過 actionType 屬性判斷該做什麼動作
21    when 'COMMENT_CREATE' # 這邊就是該用 Constant 的時候(這個範例會略過這個步驟)
22      _comments.unshift action.comment # 在留言資料的最前面插入一筆(預設是逆序排序)
23      CommentStore.emitChange() # 讓 Store 觸發 Change 事件通知 View 重新讀取
24
25    when 'COMMENT_LOAD'
26      _comments = action.comments # 跟上面的 CREATE 不同,這次 action 包的是 comments (所有留言)
27      CommentStore.emitChange() # 一樣要觸發 Change 事件讓 View 重新讀取
28      
29  return true # 這是非常重要的一行,如果沒有傳回 true 的話 Dispatcher 會判定任務失敗(Ex. 使用 waitFor 時造成中斷)
30
31# 這邊是將 EventEmitter.prototype 複製到一個空物件(並且作為我們後來的 Store)另一部分複製的就是我們目前正準備定義的方法
32CommentStore = assign {}, EventEmitter.prototype, {
33  getAll: ->
34    _comments
35
36  emitChange: ->
37    @emit 'CHANGE'
38
39  addChangeListener: (callback) ->
40    @on 'CHANGE', callback
41
42  removeChangeListener: (callback) ->
43    @removeListener 'CHANGE', callback
44
45  dispatherIndex: AppDispatcher.register( dispather ) # 向 Dispatcher 登記(會拿到一個 ID 之後可以用於 watiFor 的應用)
46}
47
48module.exports = CommentStore

表面上看起來似乎會覺得很複雜,不過稍微釐清思路之後其實也就是當 Dispatcher 送出一個 Callback 後做某些事情,再利用 Event 機制呼叫 View 上面的 Callback 而已。

有沒有發現其實就是從 Action 開始呼叫 Dispatcher 然後再呼叫 Store 接著呼叫 View 呢?(而某些情況則會從 View 呼叫 Action 然後循環下去⋯⋯)

Action

其實我一直在思考「Ajax 的非同步 Callback 該在哪處理呢?」這個問題,最後得出的得答案是在 Action 裡面。 當收到 Response 之後,再決定對 Dispatcher 送些什麼 View Action 讓 Store 處理,最後反應在 View 上面。

 1###
 2# Comment Action
 3###
 4
 5AppDispatcher = require '../Dispatcher.coffee'
 6
 7Comment = Parse.Object.extend("Comment")
 8
 9module.exports = {
10  load: ->
11    query = new Parse.Query(Comment)
12
13    query.descending('time').find({ # 排序上利用 Parse API 處理,而不是在 Store 中人工解決
14      success: (results)->
15        AppDispatcher.handleViewAction { # 讓 Dispatcher 送出一個任務
16          actionType: 'COMMENT_LOAD'
17          comments: results
18        }
19    })
20
21  create: (content) ->
22    comment = new Comment
23    comment.save {
24      content: content,
25      time: new Date()
26    }
27    .then (object) ->
28      AppDispatcher.handleViewAction {
29        actionType: 'COMMENT_CREATE'
30        comment: object
31      }
32}

基本上就是 Parse API 的操作,官方文件都寫得很清楚,我就不多做討論了!

Render

最後就是呈現在網頁上,其實不難。

 1###
 2# Application
 3#
 4# @cjsx React.DOM
 5###
 6
 7React = require 'react'
 8
 9Guestbook = require './components/GuestBook.cjsx'
10
11Parse.initialize("Application ID", "JavaScript Key");
12
13window.onload = ->
14  React.render <Guestbook />, document.body

React.render 接收兩個參數,第一個是要 render 的元件,第二個是要放置的 DOM 元素。

簡單說,其實也可以這樣使用:

1React.render <Header />, document.getElementById("header")
2React.render <Main />, document.getElementById("main")
3React.render <Footer />, document.getElementById("footer")

至於該如何運用,就取決於實際上的情況。

我認為上面這種運用可以在網站從傳統的 Server-Client 轉變為 WebApp + API 架構的過渡期去應用 不用完整寫完 WebApp 就能開始套用,聽起來其實蠻不錯的。

小結

這是冬季訓練大約花三小時講解的內容,做法是 Step by Step 並且在每個步驟講解。 如果是熟練的開發者,我想大概一個小時內就能夠做完或者更多的任務,至少就我的經驗來說,還沒有一個 WebApp Framework 可以讓我如此有彈性的開發(Ex. 可以用 @getDOMNode() 獲取原生的 DOM 做操作,在做整合 Library 時就很好用)

不過這幾年技術每天都在變化,像是當我熟悉 Flux 之後沒多久,又多了一個叫做 Riot.js 的套件,說比 React.js 更輕量化(不過實際看過文件覺得沒有 React.js 這麼討喜)

雖然 React.js 在某些應用上非常的方便,不過我還是建議多學幾種應對不同的情況(不要都學到同一種情境的就好 XD)