2.2 組件
2.2.1 組件定義
組件是React的核心概念,是React應用程序的基石。組件將應用的UI拆分成獨立的、可復用的模塊,React應用程序正是由一個一個組件搭建而成的。
定義一個組件有兩種方式,使用ES 6 class(類組件)和使用函數(函數組件)。我們先介紹使用class定義組件的方式,使用函數定義組件的方式稍后介紹。
使用class定義組件需要滿足兩個條件:
(1)class繼承自React.Component。
(2)class內部必須定義render方法,render方法返回代表該組件UI的React元素。
使用create-react-app新建一個簡易BBS項目,在這個項目中定義一個組件PostList,用于展示BBS的帖子列表。
PostList的定義如下:

注意,在定義組件之后,使用ES 6 export將PostList作為默認模塊導出,從而可以在其他JS文件中導入PostList使用。現在頁面上還無法顯示出PostList組件,因為我們還沒有將PostList掛載到頁面的DOM節點上。需要使用ReactDOM.render()完成這一個工作:


圖2-1
注意,使用ReactDOM.render()需要先導入react-dom庫,這個庫會完成組件所代表的虛擬DOM節點到瀏覽器的DOM節點的轉換。此時,頁面展現在瀏覽器中,如圖2-1所示。因為我們并沒有為組件添加任何CSS樣式,所以當前的頁面效果還非常簡陋,后續會逐步進行優化。本節項目源代碼的目錄為/chapter-02/bbs-components。
2.2.2 組件的props
在2.2.1小節中,PostList中的每一個帖子都使用一個標簽直接包裹,但一個帖子不僅包含帖子的標題,還會包含帖子的創建人、帖子創建時間等信息,這時候標簽下的結構就會變得復雜,而且每一個帖子都需要重寫一次這個復雜的結構,PostList的結構將會變成類似這樣的形式:

這樣的結構顯然很冗余,我們完全可以封裝一個PostItem組件負責每一個帖子的展示,然后在PostList中直接使用PostItem組件,這樣在PostList中就不需要為每一個帖子重復寫一堆JSX標簽。但是,帖子列表的數據依然存在于PostList中,如何將數據傳遞給每一個PostItem組件呢?這時候就需要用到組件的props屬性。組件的props用于把父組件中的數據或方法傳遞給子組件,供子組件使用。在2.1節中,我們介紹了JSX標簽的屬性。props是一個簡單結構的對象,它包含的屬性正是由組件作為JSX標簽使用時的屬性組成。例如下面是一個使用User組件作為JSX標簽的聲明:

此時User組件的props結構如下:

現在我們利用props定義PostItem組件:

然后在PostList中使用PostItem:

此時,頁面截圖如圖2-2所示。本節項目源代碼的目錄為/chapter-02/bbs-components-props。

圖2-2
2.2.3 組件的state
組件的state是組件內部的狀態,state的變化最終將反映到組件UI的變化上。我們在組件的構造方法constructor中通過this.state定義組件的初始狀態,并通過調用this.setState方法改變組件狀態(也是改變組件狀態的唯一方式),進而組件UI也會隨之重新渲染。
下面來改造一下BBS項目。我們為每一個帖子增加一個“點贊”按鈕,每點擊一次,該帖子的點贊數增加1。點贊數是會發生變化的,它的變化也會影響到組件UI,因此我們將點贊數vote作為PostItem的一個狀態定義到它的state內。

這里有三個需要注意的地方:
(1)在組件的構造方法constructor內,首先要調用super(props),這一步實際上是調用了React.Component這個class的constructor方法,用來完成React組件的初始化工作。
(2)在constructor中,通過this.state定義了組件的狀態。
(3)在render方法中,我們為標簽定義了處理點擊事件的響應函數,在響應函數內部會調用this.setState更新組件的點贊數。
新頁面的截圖如圖2-3所示。本節項目源代碼的目錄為/chapter-02/bbs-components-state。
通過2.2.2和2.2.3兩個小節的介紹可以發現,組件的props和state都會直接影響組件的UI。事實上,React組件可以看作一個函數,函數的輸入是props和state,函數的輸出是組件的UI。


圖2-3
React組件正是由props和state兩種類型的數據驅動渲染出組件UI。props是組件對外的接口,組件通過props接收外部傳入的數據(包括方法);state是組件對內的接口,組件內部狀態的變化通過state來反映。另外,props是只讀的,你不能在組件內部修改props;state是可變的,組件狀態的變化通過修改state來實現。在第4章中,我們還會對props和state進行詳細比較。
2.2.4 有狀態組件和無狀態組件
是不是每個組件內部都需要定義state呢?當然不是。state用來反映組件內部狀態的變化,如果一個組件的內部狀態是不變的,當然就用不到state,這樣的組件稱之為無狀態組件,例如PostList。反之,一個組件的內部狀態會發生變化,就需要使用state來保存變化,這樣的組件稱之為有狀態組件,例如PostItem。
定義無狀態組件除了使用ES 6 class的方式外,還可以使用函數定義,也就是我們在本節開始時所說的函數組件。一個函數組件接收props作為參數,返回代表這個組件UI的React元素結構。例如,下面是一個簡單的函數組件:

可以看出,函數組件的寫法比類組件的寫法要簡潔很多,在使用無狀態組件時,應該盡量將其定義成函數組件。
在開發React應用時,一定要先認真思考哪些組件應該設計成有狀態組件,哪些組件應該設計成無狀態組件。并且,應該盡可能多地使用無狀態組件,無狀態組件不用關心狀態的變化,只聚焦于UI的展示,因而更容易被復用。React應用組件設計的一般思路是,通過定義少數的有狀態組件管理整個應用的狀態變化,并且將狀態通過props傳遞給其余的無狀態組件,由無狀態組件完成頁面絕大部分UI的渲染工作??傊?,有狀態組件主要關注處理狀態變化的業務邏輯,無狀態組件主要關注組件UI的渲染。
下面讓我們回過頭來看一下BBS項目的組件設計。當前的組件設計并不合適,主要體現在:
(1)帖子列表通過一個常量data保存在組件之外,但帖子列表的數據是會改變的,新帖子的增加或原有帖子的刪除都會導致帖子列表數據的變化。
(2)每一個PostItem都維持一個vote狀態,但除了vote以外,帖子其他的信息(如標題、創建人等)都保存在PostList中,這顯然也是不合理的。
我們對這兩個組件進行重新設計,將PostList設計為有狀態組件,負責帖子列表數據的獲取以及點贊行為的處理,將PostItem設計為無狀態組件,只負責每一個帖子的展示。此時,PostList和PostItem重構如下:




這里主要的修改有:
(1)帖子列表數據定義為PostList組件的一個狀態。
(2)在componentDidMount生命周期方法中(關于組件的生命周期將在2.3節詳細介紹)通過setTimeout設置一個延時,模擬從服務器端獲取數據,然后調用setState更新組件狀態。
(3)將帖子的多個屬性(ID、標題、創建人、創建時間、點贊數)合并成一個post對象,通過props傳遞給PostItem。
(4)在PostList內定義handleVote方法,處理點贊邏輯,并將該方法通過props傳遞給PostItem。
(5)PostItem定義為一個函數組件,根據PostList傳遞的post屬性渲染UI。當發生點贊行為時,調用props.onVote方法將點贊邏輯交給PostList中的handleVote方法處理。
這樣修改后,PostItem只關注如何展示帖子,至于帖子的數據從何而來以及點贊邏輯如何處理,統統交給有狀態組件PostList處理。組件之間解耦更加徹底,PostItem組件更容易被復用。本節項目源代碼的目錄為/chapter-02/bbs-components-stateless。
2.2.5 屬性校驗和默認屬性
我們已經知道,props是一個組件對外暴露的接口,但到目前為止,組件內部并沒有明顯地聲明它暴露出哪些接口,以及這些接口的類型是什么,這不利于組件的復用。幸運的是,React提供了PropTypes這個對象,用于校驗組件屬性的類型。PropTypes包含組件屬性所有可能的類型,我們通過定義一個對象(對象的key是組件的屬性名,value是對應屬性的類型)實現組件屬性類型的校驗。例如:

PropTypes可以校驗的組件屬性類型見表2-1。
表2-1 組件屬性類型和PropTypes屬性的對應關系

當使用PropTypes.object或PropTypes.array校驗屬性類型時,我們只知道這個屬性是一個對象或一個數組,至于對象的結構或數組元素的類型是什么樣的,依然無從得知。這種情況下,更好的做法是使用PropTypes.shape或PropTypes.arrayOf。例如:

表示style是一個對象,對象有color和fontSize兩個屬性,color是字符串類型,fontSize是數字類型;sequence是一個數組,數組的元素是數字。
如果屬性是組件的必需屬性,也就是當使用某個組件時,必須傳入的屬性,就需要在PropTypes的類型屬性上調用isRequired。在BBS項目中,對于PostItem組件,post和onVote都是必需屬性,PostItem的propTypes定義如下:

本節項目源代碼的目錄為/chapter-02/bbs-components-propTypes。
React還提供了為組件屬性指定默認值的特性,這個特性通過組件的defaultProps實現。當組件屬性未被賦值時,組件會使用defaultProps定義的默認屬性。例如:

2.2.6 組件樣式
到目前為止,我們還未對組件添加任何樣式。本節將介紹如何為組件添加樣式。
為組件添加樣式的方法主要有兩種:外部CSS樣式表和內聯樣式。
1.外部CSS樣式表
這種方式和我們平時開發Web應用時使用外部CSS文件相同,CSS樣式表中根據HTML標簽類型、ID、class等選擇器定義元素的樣式。唯一的區別是,React元素要使用className來代替class作為選擇器。例如,為Welcome組件的根節點設置一個className='foo'的屬性:

然后在CSS樣式表中通過class選擇器定義Welcome組件的樣式:

樣式表的引入方式有兩種,一種是在使用組件的HTML頁面中通過標簽引入:

另一種是把樣式表文件當作一個模塊,在使用該樣式表的組件中,像導入其他組件一樣導入樣式表文件:

第一種引入樣式表的方式常用于該樣式表文件作用于整個應用的所有組件(一般是基礎樣式表);第二種引入樣式表的方式常用于該樣式表作用于某個組件(相當于組件的私有樣式),全局的基礎樣式表也可以使用第二種方式引入,一般在應用的入口JS文件中引入。
補充說明:使用CSS樣式表經常遇到的一個問題是class名稱沖突。業內解決這個問題的一個常用方案是使用CSS Modules,CSS Modules會對樣式文件中的class名稱進行重命名從而保證其唯一性,但CSS Modules并不是必需的,create-react-app創建的項目,默認配置也是不支持這一特性的。CSS Modules的使用并不復雜,感興趣的讀者可自行了解(參考地址:https://github.com/css-modules/css-modules)。
2.內聯樣式
內聯樣式實際上是一種CSS in JS的寫法:將CSS樣式寫到JS文件中,用JS對象表示CSS樣式,然后通過DOM類型節點的style屬性引用相應樣式對象。依然使用Welcome組件舉例:

style使用了兩個大括號,這可能會讓你感到迷惑。其實,第一個大括號表示style的值是一個JavaScript表達式,第二個大括號表示這個JavaScript表達式是一個對象。換一種寫法就容易理解了:

當使用內聯樣式時,還有一點需要格外注意:樣式的屬性名必須使用駝峰格式的命名。所以,在Welcome組件中,background-color寫成backgroundColor,font-size寫成fontSize。
下面為BBS項目增加一些樣式。創建style.css、PostList.css和PostItem.css三個樣式文件,三個樣式表分別在index.html、PostList.js、PostItem.js中引入。樣式文件如下:


這里需要提醒一下,style.css放置在public文件夾下,PostList.css和PostItem.css放置在src文件夾下。create-react-app將public下的文件配置成可以在HTML頁面中直接引用,因此我們將style.css放置在public文件夾下。而PostList.css和PostItem.css是以模塊的方式在JS文件中被導入的,因此放置在src文件夾下。
我們還將PostItem中的點贊按鈕換成了圖標,圖標也可以作為一個模塊被JS文件導入,如PostItem.js所示:

增加樣式后的頁面截圖如圖2-4所示。本節項目源代碼的目錄為/chapter-02/bbs-components-style。

圖2-4
2.2.7 組件和元素
在2.1節介紹過React元素的概念。React組件和元素這兩個概念非常容易混淆。React元素是一個普通的JavaScript對象,這個對象通過DOM節點或React組件描述界面是什么樣子的。JSX語法就是用來創建React元素的(不要忘了,JSX語法實際上是調用了React.createElement方法)。例如:

上面的JSX代碼會創建下面的React元素:

React組件是一個class或函數,它接收一些屬性作為輸入,返回一個React元素。React組件是由若干React元素組建而成的。通過下面的例子,可以解釋React組件與React元素間的關系。
