本系列的上一篇文章《為什麼ReactJS不適合複雜的前端專案》列舉了前端開發中的種種痛點。本篇文章中將詳細探討其中“複用性”痛點。我們將用原生 DHTML API 、 ReactJS 和 Binding.scala 實現同一個需要複用的標籤編輯器,然後比較三個標籤編輯器哪個實現難度更低,哪個更好用。
標籤編輯器的功能需求
在InfoQ的許多文章都有標籤。比如本文的標籤是“binding.scala”、“data-binding”、“scala.js”。
假如你要開發一個部落格系統,你也希望部落格作者可以新增標籤。所以你可能會提供標籤編輯器供部落格作者使用。
如圖所示,標籤編輯器在視覺上分為兩行。
第一行展示已經新增的所有標籤,每個標籤旁邊有個“x”按鈕可以刪除標籤。第二行是一個文字框和一個“Add”按鈕可以把文字框的內容新增為新標籤。每次點選“Add”按鈕時,標籤編輯器應該檢查標籤是否已經新增過,以免重複新增標籤。而在成功新增標籤後,還應清空文字框,以便使用者輸入新的標籤。
除了使用者介面以外,標籤編輯器還應該提供 API 。標籤編輯器所在的頁面可以用 API 填入初始標籤,也可以呼叫 API 隨時增刪查改標籤。如果使用者增刪了標籤,應該有某種機制通知頁面的其他部分。
原生 DHTML 版
首先,我試著不用任何前端框架,直接呼叫原生的 DHTML API 來實現標籤編輯器,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
<!DOCTYPE html> <html> <head> <script> var tags = []; function hasTag(tag) { for (var i = 0; i < tags.length; i++) { if (tags[i].tag == tag) { return true; } } return false; } function removeTag(tag) { for (var i = 0; i < tags.length; i++) { if (tags[i].tag == tag) { document.getElementById("tags-parent").removeChild(tags[i].element); tags.splice(i, 1); return; } } } function addTag(tag) { var element = document.createElement("q"); element.textContent = tag; var removeButton = document.createElement("button"); removeButton.textContent = "x"; removeButton.onclick = function (event) { removeTag(tag); } element.appendChild(removeButton); document.getElementById("tags-parent").appendChild(element); tags.push({ tag: tag, element: element }); } function addHandler() { var tagInput = document.getElementById("tag-input"); var tag = tagInput.value; if (tag && !hasTag(tag)) { addTag(tag); tagInput.value = ""; } } </script> </head> <body> <div id="tags-parent"></div> <div> <input id="tag-input" type="text"/> <button onclick="addHandler()">Add</button> </div> <script> addTag("initial-tag-1"); addTag("initial-tag-2"); </script> </body> </html> |
為了實現標籤編輯器的功能,我用了 45 行 JavaScript 程式碼來編寫 UI 邏輯,外加若干的 HTML <div>
外加兩行 JavaScript 程式碼填入初始化資料。
HTML 檔案中硬編碼了幾個 <div>
。這些<div>
本身並不是動態建立的,但可以作為容器,放置其他動態建立的元素。
程式碼中的函式來會把網頁內容動態更新到這些 <div>
中。所以,如果要在同一個頁面顯示兩個標籤編輯器,id
就會衝突。因此,以上程式碼沒有複用性。
就算用 jQuery 代替 DHTML API,程式碼複用仍然很難。為了複用 UI ,jQuery 開發者通常必須額外增加程式碼,在 onload
時掃描整個網頁,找出具有特定 class
屬性的元素,然後對這些元素進行修改。對於複雜的網頁,這些 onload
時執行的函式很容易就會衝突,比如一個函式修改了一個 HTML 元素,常常導致另一處程式碼受影響而內部狀態錯亂。
ReactJS 實現的標籤編輯器元件
ReactJS 提供了可以複用的元件,即 React.Component
。如果用 ReactJS 實現標籤編輯器,大概可以這樣寫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
class TagPicker extends React.Component { static defaultProps = { changeHandler: tags => {} } static propTypes = { tags: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, changeHandler: React.PropTypes.func } state = { tags: this.props.tags } addHandler = event => { const tag = this.refs.input.value; if (tag && this.state.tags.indexOf(tag) == -1) { this.refs.input.value = ""; const newTags = this.state.tags.concat(tag); this.setState({ tags: newTags }); this.props.changeHandler(newTags); } } render() { return ( <section> <div>{ this.state.tags.map(tag => <q key={ tag }> { tag } <button onClick={ event => { const newTags = this.state.tags.filter(t => t != tag); this.setState({ tags: newTags }); this.props.changeHandler(newTags); }}>x</button> </q> ) }</div> <div> <input type="text" ref="input"/> <button onClick={ this.addHandler }>Add</button> </div> </section> ); } } |
以上 51 行 ECMAScript 2015 程式碼實現了一個標籤編輯器元件,即TagPicker
。雖然程式碼量比 DHTML 版長了一點點,但複用性大大提升了。
如果你不用 ECMAScript 2015 的話,那麼程式碼還會長一些,而且需要處理一些 JavaScript 的坑,比如在回撥函式中用不了 this
。
ReactJS 開發者可以隨時用 ReactDOM.render
函式把 TagPicker
渲染到任何空白元素內。此外,ReactJS 框架可以在 state
和 props
改變時觸發 render
,從而避免了手動修改現存的 DOM。
如果不考慮冗餘的 key
屬性,單個元件內的互動 ReactJS 還算差強人意。但是,複雜的網頁結構往往需要多個元件層層巢狀,這種父子元件之間的互動,ReactJS 就很費勁了。
比如,假如需要在 TagPicker
之外顯示所有的標籤,每當使用者增刪標籤,這些標籤也要自動更新。要實現這個功能,需要給 TagPicker
傳入 changeHandler
回撥函式,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Page extends React.Component { state = { tags: [ "initial-tag-1", "initial-tag-2" ] }; changeHandler = tags => { this.setState({ tags }); }; render() { return ( <div> <TagPicker tags={ this.state.tags } changeHandler={ this.changeHandler }/> <h3>全部標籤:</h3> <ol>{ this.state.tags.map(tag => <li>{ tag }</li> ) }</ol> </div> ); } } |
為了能觸發頁面其他部分更新,我被迫增加了一個 21 行程式碼的 Page
元件。
Page
元件必須實現 changeHandler
回撥函式。每當回撥函式觸發,呼叫 Page
自己的 setState
來觸發 Page
重繪。
從這個例子,我們可以看出, ReactJS 可以簡單的解決簡單的問題,但碰上層次複雜、互動頻繁的網頁,實現起來就很繁瑣。使用 ReactJS 的前端專案充滿了各種 xxxHandler
用來在元件中傳遞資訊。我參與的某海外客戶專案,平均每個元件大約需要傳入五個回撥函式。如果層次巢狀深,建立網頁時,常常需要把回撥函式從最頂層的元件一層層傳入最底層的元件,而當事件觸發時,又需要一層層把事件資訊往外傳。整個前端專案有超過一半程式碼都在這樣繞圈子。
Binding.scala 的基本用法
在講解 Binding.scala 如何實現標籤編輯器以前,我先介紹一些 Binding.scala 的基礎知識:
Binding.scala 中的最小複用單位是資料繫結表示式,即 @dom
方法。每個 @dom
方法是一段 HTML 模板。比如:
1 2 3 |
// 兩個 HTML 換行符 @dom def twoBr = <br/><br/> |
1 2 3 |
// 一個 HTML 標題 @dom def myHeading(content: String) = <h1>{content}</h1> |
每個模板還可以使用bind
語法包含其他子模板,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@dom def render = { <div> { myHeading("Binding.scala的特點").bind } <p> 程式碼短 { twoBr.bind } 概念少 { twoBr.bind } 功能多 </p> </div> } |
你可以參見附錄:Binding.scala快速上手指南,學習上手Binding.scala開發的具體步驟。
此外,本系列第四篇文章《HTML也可以編譯》還將列出Binding.scala所支援的完整HTML模板特性。
Binding.scala實現的標籤編輯器模板
最後,下文將展示如何用Binding.scala實現標籤編輯器。
標籤編輯器要比剛才介紹的HTML模板複雜,因為它不只是靜態模板,還包含互動。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@dom def tagPicker(tags: Vars[String]) = { val input: Input = <input type="text"/> val addHandler = { event: Event => if (input.value != "" && !tags.get.contains(input.value)) { tags.get += input.value input.value = "" } } <section> <div>{ for (tag <- tags) yield <q> { tag } <button onclick={ event: Event => tags.get -= tag }>x</button> </q> }</div> <div>{ input } <button onclick={ addHandler }>Add</button></div> </section> } |
這個標籤編輯器的 HTML 模板一共用了 18 行程式碼就實現好了。
標籤編輯器中需要顯示當前所有標籤,所以此處用tags: Vars[String]
儲存所有的標籤資料,再用for
/yield
迴圈把tags
中的每個標籤渲染成UI元素。
Vars
是支援資料繫結的列表容器,每當容器中的資料發生改變,UI就會自動改變。所以,在x
按鈕中的onclick
事件中刪除tags
中的資料時,頁面上的標籤就會自動隨之消失。同樣,在Add
按鈕的onclick
中向tags
中新增資料時,頁面上也會自動產生對應的標籤。
Binding.scala不但實現標籤編輯器比 ReactJS 簡單,而且用起來也比 ReactJS 簡單:
1 2 3 4 5 6 7 8 9 |
@dom def render() = { val tags = Vars("initial-tag-1", "initial-tag-2") <div> { tagPicker(tags).bind } <h3>全部標籤:</h3> <ol>{ for (tag <- tags) yield <li>{ tag }</li> }</ol> </div> } |
只要用 9 行程式碼另寫一個 HTML 模板,在模板中呼叫剛才實現好的 tagPicker
就行了。
完整的 DEMO 請訪問 https://thoughtworksinc.github.io/Binding.scala/#4。
在 Binding.scala 不需要像 ReactJS 那樣編寫 changeHandler
之類的回撥函式。每當使用者在 tagPicker
輸入新的標籤時,tags
就會改變,網頁也就會自動隨之改變。
對比 ReactJS 和 Binding.scala 的程式碼,可以發現以下區別:
- Binding.scala 的開發者可以用類似
tagPicker
這樣的@dom
方法表示 HTML 模板,而不需要元件概念。 - Binding.scala 的開發者可以在方法之間傳遞
tags
這樣的引數,而不需要props
概念。 - Binding.scala 的開發者可以在方法內定義區域性變數表示狀態,而不需要
state
概念。
總的來說 Binding.scala 要比 ReactJS 精簡不少。
如果你用過 ASP 、 PHP 、 JSP 之類的服務端網頁模板語言, 你會發現和 Binding.scala 的 HTML 模板很像。
使用 Binding.scala 一點也不需要函數語言程式設計知識,只要把設計工具中生成的 HTML 原型複製到程式碼中,然後把會變的部分用花括號代替、把重複的部分用 for
/ yield
代替,網頁就做好了。
結論
本文對比了不同技術棧中實現和使用可複用的標籤編輯器的難度。
原生 HTML | ReactJS | Binding.scala | |
---|---|---|---|
實現標籤編輯器需要程式碼行數 | 45行 | 51行 | 17行 |
實現標籤編輯器的難點 | 在程式碼中動態更新HTML頁面太繁瑣 | 實現元件的語法很笨重 | 無 |
使用標籤編輯器並顯示標籤列表需要程式碼行數 | 難以複用 | 21行 | 8行 |
阻礙複用的難點 | 靜態HTML元素難以模組化 | 互動元件之間層層傳遞迴調函式過於複雜 | 無 |
Binding.scala 不發明“元件”之類的噱頭,而以更輕巧的“方法”為最小複用單位,讓程式設計體驗更加順暢,獲得了更好的程式碼複用性。
本系列下一篇文章將比較 ReactJS 的虛擬 DOM 機制和 Binding.scala 的精確資料繫結機制,揭開 ReactJS 和 Binding.scala 相似用法背後隱藏的不同演算法。