TodoApp程式+編輯項目

今天的主題是要加入項目的編輯功能,以及更多的CSS樣式。至於重點部份有兩個,第一個是CSS樣式的加入,第二個是如何切換項目的編輯。

這個程式最後的呈現結果,會是在網頁上出現一個文字輸入框,當你輸入文字後按下Enter鍵,就會把文字加到下面的列表中,在項目中改用勾選盒來更改狀態,用滑鼠左鍵雙點按(DoubleClick)可以進行項目的再編輯。就像下面的動態圖片這樣:

註: 本文章同步放置於Github庫的這裡,所有的程式碼也在裡面。

當程式愈來愈複雜的時候,實作經驗會愈來愈重要,學到了很多基礎知識雖然重要,但更重要的是實作經驗,以及解決問題的能力。有時候,一個小問題就有可能困住很久才能解決,這個範例程式愈來愈複雜,代表它裡面用到的功能愈來愈多,你可能要仔細地看,因為很多部份都是彼此關聯的,牽一髮而動全身。

編輯項目功能程式碼說明

首先,因為我們要能讓TodoItem可以在被新增後,還能進行重新的編輯,而且位置就在那個TodoItem上,這種功能要怎麼作?

過去使用jQuery或直接用JavaScript來寫,就是查詢到要編輯的那個DOM元素實體,然後在該節點替換成可輸入的文字輸入框,很直覺地作法,但現在這支程式並不是這樣作的,雖然也有替換的過程,但它有幾個關鍵步驟,建議你看過之後思考一下。

現在用在React上也是類似的,不過因為使用的是元件的概念,所以你需要用下面的步驟來思考:

  1. 使用者觸發要重新編輯某個事件 - 看是要用按鈕點按還是其它的事件,這個範例中是用滑鼠左鍵雙點按(DoubleClick)

  2. 觸發重新渲染(re-render) - React裡面只有state(狀態)改變,用了setState方法才會觸發重新渲染,所以上面觸發的事件一定是要改變到state值

  3. 編輯後儲存再重新渲染 - 觸發儲存新的輸入值,再觸發重新渲染,回復原先的項目狀態

整個流程就是這樣。直接看程式碼會比較快。

首先我們新增了一個叫TodoEditForm的新元件,它就是在雙點按(DoubleClick)某個項目時,要用來編輯這個項目上的標題文字用的。它有點像是TodoItemTodoAddForm元件的組合。這也是個無狀態的元件,也就是用函式的語法來寫的元件。

函式的傳入參數類型如下,我們只需要要進行編輯的標題值,以及一個用於更新最後新的值的函式即可,這個onItemUpdate函式,依然是來自上層元件(App)的指定值:

export type TodoEditFormProps = {
  title: string,
  onItemUpdate: (x: string) => void,
}

TodoEditForm元件中的回傳值部份,對文字輸入框用了兩個事件,一個是onBlur,另一個是onKeyPress,而且一開始就有一個autoFocus的屬性,之前已經有說過了JSX在指定props屬性時,不指定任何值時相當於預設值true,下面是這個文字輸入框的程式碼:

<input
  type="text"
  defaultValue={title}
  ref={el => { titleField = el }}
  autoFocus
  onBlur={(e) => {
    if (titleField.value.trim()
        && e.target instanceof HTMLInputElement) {  

      //更新某個索引值的標題    
      onItemUpdate(titleField.value)
    }
    }
  }
  onKeyPress={(e) => {
    if (titleField.value.trim()
        && e.target instanceof HTMLInputElement
        && e.key === 'Enter') {  

      //更新某個索引值的標題    
      onItemUpdate(titleField.value)
    }
    }
  }
/>

這裡面還有一個要注意的地方,就是defaultValue這個屬性,這屬性是React中人造的這種HTML的DOM元素才有的,它是用來指定給像這種文字輸入框值用的,等會還會看到另一個類似的屬性。你不能直接使用value這個屬性,除非你有提供onChange方法在這個元素上,不然React會把這個表單輸入元件當作是只能讀不能寫的,這是使用表單元素在這種無狀態函式中的一個要特別注意的地方。

你如果把這個defaultValue改為value,會出現下面的警告:

warning.js:36 Warning: Failed form propType: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`. Check the render method of `TodoEditForm`.

你可能會好奇,為何在更新某一筆項目資料時,不需要知道這筆資料的索引值?onItemUpdate只有一個傳入參數,就是被改過的新字串而已。

因為就像之前在點按某個項目,然後改變這個項目的樣式的作法一樣,它在上層元件指定這個函式值的時候,就已經把索引值固定住了。下面是在App元件中的TodoEditForm元件的使用程式碼,有看到handleEditItemUpdate其實需要兩個傳入參數,只是其中一個先給它了,後面那個title再由TodoEditForm中觸發事件時再給:

<TodoEditForm
  key={item.id}
  title={item.title}
  onItemUpdate={(title) => { this.handleEditItemUpdate(index, title) }}
/>

好的,接著來看App元件的程式碼變化,當然最主要的是像之前項目中的isCompleted屬性,這次也新增了一個isEditing,用於分辦哪一個項目正在進行編輯中,也是個布林值。所以新的Item類型的定義會像下面這樣:

export type Item = {
  id: number,
  title: string,
  isCompleted: boolean,
  isEditing: boolean,
}

雖然isEditing有點像是個臨時才會用到的值,但這是因為整個運作流程,一定需要改變到state值(用setState方法),才能觸發重新渲染,這個機制你一定要記住,如果功能得要觸發重渲染,也就是畫面上要更動到DOM元素,只有這條路可走。

App元件新增了兩個新的方法,一個是handleEditItem,這是讓目前的TodoItem被雙點按後,將被點按的那個項目的isEditing屬性,從false變為true的一個方法。這個方法幾乎與之前的handleStylingItem一樣,程式碼如下:

//處理其中一個陣列中成員變為編輯中的方法
handleEditItem = (index: number) => {
  //拷貝一個新陣列
  const newItems = [...this.state.items]

  //切換isEditing的布林值
  newItems[index].isEditing = !newItems[index].isEditing

  //整個陣列重新更新
  this.setState({
    items: newItems,
  })
}

另一個方法是要給TodoEditForm編輯完成後儲存變動後的標題文字值用的,叫作handleEditItemUpdate,它需要兩個傳入參數,一個是要變動的項目的索引值,另一個是要變成哪一個文字值,程式碼如下:

//處理其中一個陣列中成員編輯完後更新的方法
handleEditItemUpdate = (index: number, title: string) => {
  //拷貝一個新陣列
  const newItems = [...this.state.items]

  //切換isEditing的布林值
  newItems[index].title = title

  //切換isEditing的布林值
  newItems[index].isEditing = !newItems[index].isEditing

  //整個陣列重新更新
  this.setState({
    items: newItems,
  })
}

這兩個方法的程式碼都很簡單,沒什麼特別要說的,儲存完記得把isEditing改回false值就行了。

再來是因為Item的物件結構變了,所以原先的TodoAddForm也要變動一下,其一是在新增項目時,要多一個isEditing值。其二是需要加上一個雙點按的事件。但還有一個最好要改的,因為原來我們是利用點按一下項目來變動項目的樣式,現在又有一個雙點按的事件,這兩個會影響到,所以把原先的點按事件,改用在一個勾選盒(checkbox)上。程式碼如下:

const TodoItem = ({ title, isCompleted, onItemClick, onItemDoubleClick }: TodoItemProps) => (
   <li
    onDoubleClick={onItemDoubleClick}
      className={isCompleted
        ? 'list-group-item list-group-item-danger animated fadeIn'
        : 'list-group-item list-group-item-success animated bounce'
      }
      >
      <input
        type="checkbox"
        defaultChecked={isCompleted}
        onClick={onItemClick}
        />
        {' '}
        {title}
    </li>
)

這裡的defaultChecked情況與上面說的defaultValue類似,這也是React中的人造元素才有的屬性,相當於DOM元素上的checked,但這裡沒用到onChange事件,所以要改為defaultChecked

註: 程式碼中的className是套用了css樣式檔,等會再說明。

最後的關鍵在於Apprender方法中的回傳值部份,在TodoList標記之間的JSX表達式中,這裡本來是輸出TodoItem元件的地方,但在這裡需要更動程式碼,如果項目中的isEditingtrue值時,就改為使用TodoEditForm元件,如果是false才會用TodoItem元件。為了更簡化語法,用了三元運算符(?:)來取代if...else,程式碼如下:

<TodoList>
{
  this.state.items.map((item, index) => (
      (item.isEditing)
      ? <TodoEditForm
          key={item.id}
          title={item.title}
          onItemUpdate={(title) => { this.handleEditItemUpdate(index, title) }}
        />
      : <TodoItem
          key={item.id}
          isCompleted={item.isCompleted}
          title={item.title}
          onItemDoubleClick={() => { this.handleEditItem(index) }}
          onItemClick={() => { this.handleStylingItem(index) }}
        />
    )
  )
}
</TodoList>

這邊的在props(屬性)指定值的三個方法,全部都先給定index值(項目在陣列中的索引值),寫法會有些特別要注意一下。

以上是有關於加入編輯項目文字框的說明。

樣式化程式碼說明

你應該有看到,我加入了一些樣式在程式碼之中,目的讓原本看起來醜醜的TodoApp美觀些。

這都是只使用CSS定義的部份而已,主要是用了BootstrapAnimate.css。也有一些動畫的特效,不過都是純粹的CSS動畫而已。

Bootstrap的檔案是另外到它提供的線上自訂服務,去除掉與JavaScript有關的元件與Glyphicons。

要套用現成的CSS在React應用中,之前有說過如果Webpack有設定好,直接匯入到程式碼中就可以使用,你可以看到App.js的最上面有這幾句:

//匯入css檔
import '../style/bootstrap.css'
import '../style/animate.css'

這是在create-react-app所建立的專案中已經幫你都設定好了,而且只要最上層的App.js有匯入,裡面有用到的元件檔案就不需要再作匯入的動作,這一點我已經有試過,最後打包會自動幫你把所有的CSS檔合併壓縮,而且會產生Source Map(原始碼地圖),實在有夠方便。

這些樣式都是使用CSS類別名稱來指定就可以了,例如TodoList元件中的ul標記,你可以看到像這樣套上CSS類別的樣式:

<ul className="list-group">{children}</ul>

所有的標記與元件我都加上了CSS的類別定義,在TodoItem元件中額外用了Animate的特效,它這裡面的特效是純粹的CSS特效,所以可以使用,不過檔案有點大,如果確定好要用什麼特效,應該只需要用到的部份就行了,Animate.css也有自訂編譯的方式。

所以你在網路上看到什麼好看的CSS樣式範例,直接拿來套用就行了,主要記得要把原來範例中的<div class="xxxx">改為React中的<div className="xxxx">。如果有JavaScript相關的特效記得不能用要先拿掉。

結論

本章的目的是提供不同的思考方式,如果你已經是熟悉JavaScript或jQuery的開發者,這個功能絕對是難不倒你的。但換到React上來思考,就不是那樣的直覺式的查詢DOM,替換DOM元素節點的作法,你如果親自動手作看看,應該能體會其中的差異。這一個思考方向的不同,我認為在寫React應用時是很重要。

另一個重點是在於展示如何使用CSS樣式於React應用中,以單純的CSS類別套用來說,並沒有什麼不同,CSS特效同樣也可以套用。之前有說過React官方的文件是用內嵌樣式的語法,同樣你也可以直接用現成的CSS樣式。至於它們各自有什麼樣的一些細節,之前的章節內容中有稍微分析過了,你可以再仔細看看然後再決定你想要的作法,這個範例只是其中一種解決方案,不一定要這樣作。

最後因為文章篇幅的關係,可能無法介紹到React中的動畫要怎麼作,這其實也是另一個大議題,目前來說除了官方的內建附加套件外,文件說明在這裡,也有幾種解決的方式,有興趣的話你可以再研究看看。

Last updated