關於我為什麼要寫這篇文章,我有幾句話要講。本身作為一個前端開發人員,我一直有在用Vue.js Framework
,鑑於其快速構建大型Web App的能力,我對其非常喜歡。然而近期我打算進軍Multi-Platform App的領域了,這樣其實作為一個Web開發人員來講,就沒有多少可以做的選擇了。Vue.js在跨裝置方面的應用尚且薄弱,而React家族的React Native和Google家的Flutter是這方面的絕對主力。
然而,“得益於”Flutter噁心的樣式互套機制,對開發人員極度不友好,我毫無猶豫地放棄了它。所以,React Native就成了我唯一的選擇。
所以讀者可以將這篇文章看作是React Native的前置知識。
本文的參考來自React的官方網站(https://react.dev/),感謝社區成員所作出的努力。
React 元件
React最重要的概念就是“元件”(Component),一個React元件就是一個返回UI組合的JSX函式。例如:
1 | function Profile() { |
可以將React元件抽離成一個檔案,即所謂的single-file component
。例如我們可以在Profile.js檔案中匯出元件:
1 | // Profile.js |
然後我們就可以在Main.js元件中使用這個元件:
1 | import Profile from './Profile.js' |
React元件在使用的時候需以大寫字母開頭,以區別普通的DOM元素。例如<Profile></Profile>
。
JSX
React使用的是JS的語法擴充套件JSX,這個套件可以允許在JS檔案中書寫HTML類似的標記。
JSX有一套嚴格的規則,不同於HTML,使用時要特別注意:
- 每一個React元件的返回值必須是單根的。即所有元素必須被包含在一個根元素中。React推薦使用
<></>
。
1 | function SingleRoot() { |
- 所有的標籤必須關閉:使用自閉標籤例如
<img/>
或關閉的標籤<li></li>
。這一點對於有良好開發習慣的開發人員來講不成問題。
使用大括弧{}
來開啟JavaScript程式碼窗
類似於Vue.js中的JavaScript Template Reflect機制,在JSX中,我們也可以在返回的JSX DOM Tree中嵌入我們的JS程式碼。這時候我們需要使用{}
來開啟JS程式碼窗。
1 | export default function Avatar() { |
JS程式碼窗可以用在標籤之間的HTML Text上,也可以用在標籤的屬性取值上,就如同上面的例子展示的一樣。
我們還可以在大括弧中傳遞JS物件:
1 | export default function TodoList() { |
我們分析一下<ul></ul>
這個標籤。其style
屬性中,第一個大括弧表示開啟JS程式碼窗,第二個大括弧則是JS物件的大括弧。
透過向標籤屬性中傳遞JS物件,我們可以實現CSS的動態傳遞。
向元件中傳遞props
Vue.js中也有props傳遞的概念,JSX類比與那個概念。由於React元件屬於JS函式,因此,props可以作為函式的引數來傳遞,這一點確實比Vue.js要來得方便。
1 | // Profile.js |
然後在main.js中:
1 | // main.js |
還可以為props設定預設值:
1 | function profile({avatar, name = "Gregorio Y. Zara"}) { |
props透傳
如果一個元件希望將自己的所有props傳遞給子元件,那麼我們有一個props透傳的簡潔語法:
1 | function Profile(props) { |
這將把Profile元件的所有props全部透傳到Avatar元件。
props是不可改變的。當我們要props改變的時候,我們其實是需要傳遞一個新的props,而不是改變舊的。這一點將會在下面講到。
條件渲染
類似於Vue.js中的v-if
和v-else
。由於JSX使用函式渲染的模式,因此我們可以很方便地使用JS的if-else
來進行條件渲染。
1 | function Item({ name, isPacked }) { |
可以使用null
來什麼也渲染空:
1 | if (isPacked) { |
列表渲染
類似於Vue.js中的v-for
。官方提供的步驟如下:
- 創建陣列:
1 | const people = [ |
- 把陣列map到JSX node中:
1 | const listItems = people.map(person => <li>{person}</li>); |
- 在元件中返回map的結果:
1 | return <ul>{listItems}</ul>; |
可以使用key
屬性在陣列中唯一標示該元素,類似於Vue.js中的:key
。
1 | return <li key={person.id}>...</li> |
這個key應該是獨一無二的。
事件處理
透過在元件中定義事件處理函式的方法可以實現事件處理:
1 | export default function Button() { |
事件處理函式有幾個注意事項:
- 必須定義在元件中。
- 名稱通常使用
handle
開頭,後接事件名稱。
自然,你也可以直接將函式定義在JSX node中:
1 | <button onClick={function handleClick() { |
由於事件處理函式是定義在元件內部的,因此該函式也可以直接使用元件的props。
客製化事件
在Vue.js中,我們可以很方便地客製化事件。使用defineEmits()
函式配合emit()
函式,我們可以向父元件丟出一個事件。
在React中,這個過程大差不差。defineEmits()
中的定義被轉成了函式類型的props,而後直接執行之即可。
1 | function Button({ onClick, children }) { |
事件傳播
我們有這樣一個元件:
1 | export default function Toolbar() { |
我們發現,當我們點按任意一個Button,不僅會觸發Button本身的onClick()
事件,也觸發了其父div的onClick()
。
要停止事件向其父傳播,我們需要呼叫其預設引數:
1 | export default function Toolbar() { |
與之類似,e.preventDefault()
函式可以避免預設的元素行為。
狀態管理
由於互動的結果,元件通常需要更改螢幕上的內容。在表單中鍵入應更新輸入欄位,單擊影象列上的“Next”應更改顯示的影象,單擊“購買”應將產品放入購物車。元件需要“記住”事情:當前輸入值、當前影象、購物車。在React中,這種特定於元件的記憶體被稱為狀態(state)。
在Vue.js中,也有類似的概念。例如,當我們需要將某個變數動態渲染到template上的時候,我們就需要使用ref()
或reactive()
,否則,對變數的任何修改都不會被渲染器意識到。
比如在下面的程式中,點按Change Index按鈕就不會發生任何渲染改變:
1 | export default function Test() { |
在React中也是如此,不同的是,在React中,我們使用的是useState()
函式。它的使用過程如下:
1 | import { useState } from 'react'; |
我們可以看到,原本的變數被變成了一個變數陣列,其中index
是原來的變數,而配套的setIndex
則是變數的setter。
在React中,useState以及以“use”開頭的任何其他函式都被稱為Hook。Hook是特殊函式,僅在React渲染時可用。
與Vue.js類似,React中狀態也是元件依賴的。也就是說,如果你把一個元件渲染兩次,這兩個元件將會有完全不互通的各自狀態。即狀態是“私有的”。
狀態批次處理
我們來看這樣一個例子:
1 | export default function Counter() { |
你可能會期望,當我點按“+3”按鈕的時候,number能夠連續相加3次,畢竟我使用了三次setNumber()
函式。
然而你會發現,每點按一次,number僅+1。這是因為,在每一輪渲染之前,number的值都是固定的,因此無論在本輪渲染中呼叫了多少次setNumber()
函式,其都是一樣的效果。
React的狀態批次處理機制是:每一輪渲染之前,都要將所有的狀態更新乃至於所有的事件處理函式全部執行完成,才會開始這一輪渲染。所以會出現上面的那個問題。
儘管像上面那樣子寫程式不是一個好主意,上面的需求也算是相當小眾,然而React仍舊給我們提供了解決的方案,只需要將setNumber(number + 1)
替換成setNumber(n => n + 1)
即可。這告訴React,用上一輪更新的狀態結果來做事,而不是使用預設固定值。
這裡的n => n + 1
函式稱為“更新函式”,通常情況下,使用被更新變數的第一個字母作為該函式的引數。
物件狀態的更新
遵循一個原則:不要修改已經持有的物件(儘管它們是mutable的),而是創造一個新的物件去替換原有的物件。
1 | setPosition({ |
以上的更新適用於物件中的每一個鍵的值都發生變化的情況。如果物件中只有少數的鍵發生變化,每一次都要更新整個物件,這顯然太不合理了。解決這個問題,我們可以使用...
來拷貝原來的物件變數值。
1 | setPerson({ |
好,問題來了,假如我們有一個複雜的Nested物件,我們應該如何去更新它?比如這個物件:
1 | const [person, setPerson] = useState({ |
如果我想要更新artwork
中的city
一欄,按照上面說的,我們自然可以這樣子去更新它:
1 | setPerson({ |
我們不得不使用了兩遍...
。這個還算好,如果遇到更加複雜的nested情況,這樣做顯然來得效率低。
隆重介紹我們寫React Native第一個必備的外部套件——use-immer
。
使用use-immer
套件,我們可以反直覺地無痛更新物件。
首先我們安裝use-immer
套件:
1 | npm install use-immer --save |
然後我們就可以把useState()
換成useImmer()
了:
1 | import { useImmer } from 'use-immer'; |
其中的更新語句:
1 | updatePerson(draft => { |
允許我們直接更新物件中的鍵。這很酷,對吧?
陣列狀態的更新
陣列可以被看作特殊的物件,因此對於物件的限制也同樣適用於陣列。我們也依然需要將陣列看作immutable,儘管其依然是mutable的。
對於陣列,我們可以使用一些更加簡單的方式去生成新的陣列。
向陣列中新增元素
push()
方法是mutable方法,要實現這個內容,需要:
1 | setArtists([ |
刪除陣列中的元素
可以曲線救國,使用filter()
函式篩選出非刪除元素組成新陣列。
1 | setArtists( |
修改陣列
使用map()
函式來客製化修改規則,生成新的陣列。
1 | setArtists(shapes.map(shape => { |
陣列中插入元素
配合slice()
函式來使用:
1 | setArticles([ |
其他修改
其實,對於陣列的修改還有一個最簡單不過的方式:直接拷貝原來的陣列到一個新陣列,然後修改新陣列即可。
1 | const newArticles = [...Articles] |
然而這樣做對於陣列中的物件來說是一個問題。因為這樣的拷貝很淺,無法將物件的邏輯拷貝到新陣列。因為陣列中的物件實際上是一個指向,而不是一個元素。所以當我們修改新陣列中的物件內容,事實上是在修改原陣列,這是應當被避免的。
可以這樣做:
1 | setMyList(myList.map(artwork => { |
或者你會更喜歡使用immer:
1 | updateMyList(draft => { |
狀態管理的高級玩法
對於React狀態管理和宣告式程式設計的思考
初學者對於React的狀態管理可能會感到非常混亂和迷惑,但是如果習慣起來,會發現React的狀態管理功能十分強大。
按照React官方的說法,其狀態管理屬於“宣告式程式設計”的典型應用。
說到宣告式,我上次接觸的以它為特點的東西還叫做NixOS,那個Linux發行版給我留下了極其深刻的心理陰影。雖然我承認其一些設計理念確實是超前的且無可比擬的,比如說宣告式設計。然而由於其軟體的缺失和pack的過程之複雜,實在是讓我記憶深刻,並且終生不想再碰它。
跑遠了~回到宣告式程式設計這件事上來。什麼叫宣告式呢?React官方給了一個十分形象的例子:
假如你叫了一輛計程車,想要去到某個地方,使用傳統的命令式程式設計,你就需要一步一步指導司機走哪條路,最後到達你想要到達的地方。如果其中一步錯誤,可能最後就無法到達正確的目的地。
但是宣告式程式設計則不一樣。其相當於你直接告訴司機你要去到哪個地方,然後司機會幫你完成路線規劃,最後去到那裏。
相當於,你只需要宣告結果,過程不需要去管。這在一些大型的程式系統中會顯著提高開發和維護的效率。
回到React上來,你只需要宣告頁面的“狀態”,React就能夠幫助你達到那個狀態,而不需要你親自去操縱頁面上的元件之類的。
事實上,Vue.js所基於的template系統也是一樣。template預先聲明了頁面的元素,然後我們可以修改template的狀態來實現頁面的更新。
在元件之間共享狀態
有的時候我們希望兩個元件的狀態可以同步改變,這時候我們需要進行“提升狀態”作業。
提升狀態,是指將狀態從元件中刪除掉,轉而將其放到共同的最近父元件中的過程。
我們來看這樣一個例子:
1 | import { useState } from 'react'; |
顯然,這兩個Panel的isActive
狀態是獨立的。
如果我希望讓它們兩個能夠同時改變,即要展示就共同展示,要隱藏就共同隱藏。這該如何做呢?
- 從子元件中刪除狀態。
- 從父元件中向子元件傳遞硬編碼資料。
- 將狀態給到父元件。
例如上述例子就可以如下修改:
1 | import { useState } from 'react'; |
提升狀態的過程本質上是父元件統一管理子元件的過程。
保留和重置狀態
只要元件在UI tree的相同地方被渲染,React就會保留其狀態,而當其被移除,或者其他元件代替了它在UI tree中的位置,它的狀態就會被丟棄。
相同位置的相同類型元件保留狀態
有如下一個例子:
1 | export default function App() { |
你會發現當你按下Click me.按鈕的時候,即使Children1變成了Children2,元件裡面的狀態score也不會改變。這是因為兩個Test元件是UI tree相同位置的相同類型元件,因此會被React當作相同元件,狀態得到保留,
同一位置的不同類型元件重置狀態
根據上面的例子,我們應該十分容易理解它,比如同一位置,把Test元件換成其他的什麼元件,再換回來,Test元件的score狀態自然就被重置了。
在同一位置重置狀態
上面我們講到,元件遵循的是在同一位置的相同類型元件將會保留狀態。然而如果我們希望在同一位置的相同元件強制重置狀態,我們該如何做呢?
我們有兩種方法:
- 在不同的位置渲染。
- 使用key。
我們來看第一種。還是用上面的那個例子。將:
1 | { show ? (<Test>Children1</Test>) : (<Test>Children2</Test>)} |
修改成:
1 | { show && <Test>Children1</Test> } |
即可。
另一種方式是使用key。key可以讓React區分不同的元件。只需要給到兩個<Test></Test>
元件兩個不同的key即可。
1 | { show ? (<Test key="Test1">Children1</Test>) : (<Test key="Test2">Children2</Test>)} |
使用Reducer
當我們的狀態處理邏輯越來越多的時候,難免會顯得非常混亂。這個時候,我們可以將狀態處理邏輯統一提取到元件外面的reducer函式中,將針對某一個狀態變數的全部處理邏輯都放到這裡面。然後在元件內部,只需要使用這個reducer函式來分發相應的處理邏輯就好。
首先我們需要一個reducer函式,它接受兩個引數:state和action,state是要監控的狀態變數,action是執行的作業。該函式回傳更新後的狀態。
比如我們編寫這樣一個函式:
1 | function tasksReducer(tasks, action) { |
在元件中使用reducer:
1 | const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); |
useReducer()
函式接受兩個引數:taskReducer
是在元件外定義的reducer函式,initialTasks是預設的狀態值。
當需要更新元件的狀態時,我們只需要使用dispatch()
函式:
1 | function handleAddTask(text) { |
傳送給dispatch()
函式的引數是一個物件,這個物件最後會變成taskReducer()
函式的第二個引數action
。
我們還可以使用immer來提升我們使用Reducer的體驗。(immer真是個偉大的發明)
使用immer,我們就可以使用push
或array[index] =
這種形式來修改我們的state。
只需要將useReducer()
換成userImmerReducer()
即可,然後你就可以在你的reducer函式中大膽使用上面提到的修改方法了。
使用context深入傳遞資料
這個場景在客製化元件庫的時候特別有用。在之前使用Vue.js開發元件庫的時候,我總會遇到這樣一個問題:客製化的radio-box元件需要監聽其父級radio-box-group元件的屬性,來確定自己是不是已經被選中了。
context就可以做到這一點。通俗講,context可以讓父級(不管多遙遠)的元件可以跨越千山萬水,為子級元件提供一些資料。
我們就用這個例子來做。現在我有一個RadioBoxGroup元件,其有一個屬性是check,類型是integer,代表選中的RadioBox元件的id。其children為RadioBox元件。要求RadioBox元件能夠根據其父級的check,自動獲悉自己是否被選中。
我們首先建立這兩個元件,我分為兩個檔案:
1 | // RadioBox.js |
1 | // RadioBoxGroup.js |
然後再建立一個測試元件:
1 | // App.js |
接下來我們就要開始實現上述目的了。
首先我們需要創建一個context,我們重新建立一個新的檔案用於專門存放context:
1 | // CheckContext.js |
createContext()
函式唯一的引數,表示預設的context值。
然後,我們在RadioBoxGroup元件中提供這個context:
1 | // RadioBoxGroup.js |
這一步是告訴React:如果我的子元件索要context,請將這個context提供給它。React將會把距離子元件最近的context提供給它。
而後,子元件就可以向父元件索要context了:
1 | import { useContext } from 'react'; |
完成了。接下來,就只要在App
元件中為RadioBoxGroup元件設定check屬性即可。
結語
暫時先寫這麼多吧,也很多了。應當注意的是,這些內容僅僅在React官方檔案中屬於“中級”,而更高級的功能我暫時用不上,於是就暫時先寫到這裡。以後用到了,以後再說。
另外要再次提醒讀者,React是一個函式庫,而並非是一個框架。基於React的框架,請移步目前最流行的Next.js。