Skip to content

[React] 了解 JSX 與其語法、畫面渲染技巧

· 27 min

《React 思維進化》 筆記系列
1. [React] DOM, Virtual DOM 與 React element
2. [React] 了解 JSX 與其語法、畫面渲染技巧
3. [React] 單向資料流介紹以及 component 初探
4. [React] 認識狀態管理機制 state 與畫面更新機制 reconciliation
5. [React] 在子 component 觸發父 component 資料更新、深入理解 batch update 與 updater function
6. [React] 了解 immutable state 與 immutable update 方法
7. [React] 認識 component 的生命週期、了解每次 render 都有自己版本的資料
8. [React] React 中的副作用處理、初探 useEffect
9. [React] 對 hooks 的 dependencies 誠實,以維護資料流的連動
10. [React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境
11. [React] 認識 useCallback、useMemo,了解 hooks 運作原理

前言#

承接上篇 [React] DOM, Virtual DOM 與 React element,此篇主要敘述的是《React 思維進化》 2–4 ~ 2–5 章節的筆記,若有錯誤歡迎大家回覆告訴我~

什麼是 JSX#

在上篇文章有提到,我們可透過 React.createElement 方法來建立 React element,但實際專案中卻很少看到有人用這種方式來建立 React element,而是用 JSX 語法,JSX 語法長得很像 HTML 語法,但其實 JSX 並不是在 JavaScript 中寫 HTML,接下來就要好好介紹到底什麼是 JSX ~

JSX 是 React 提供的一種語法糖,因為每次都要透過 React.createElement 來建立 React element 太麻煩啦,用 JSX 語法糖可讓開發者有類似撰寫 HTML 語法的體驗,再藉由專門的工具轉譯成 React.createElement 的呼叫語法,因此 JSX 語法本質上就是在呼叫 React.createElement 方法!只是外層用了漂亮的糖衣包裹,來提升開發者體驗。

什麼是語法糖? 語法糖是程式語言為了某些已存在的功能或語法,額外添加的便捷替代語法,但語法糖本質仍然是背後原本的功能,語法糖並沒有創造新功能,JSX 語法糖示意圖如下。

撰寫 JSX 其實就是在寫 React.createElement 方法的呼叫

也就是說,撰寫 JSX 語法並不是在寫 HTML 語法,他就是在呼叫 React.createElement 方法而已,只是 JSX 被刻意設計成模仿 HTML 語法的撰寫與開發體驗,讓程式碼更易於閱讀和編寫;而一段 JSX 語法其實是表達 React.createElement 方法回傳的值(也就是一個 React element)。

寫 JSX 一樣要注意 React element 屬性命名與 HTML 語法的差異(例如 class 要改寫為 className);有的學習資源會說「JSX 語法中的 class 屬性要改寫為 className」,需注意要改成 className 的原因是因為 React element 對屬性命名的規定,而不是 JSX 語法本身的要求,因為 JSX 會被轉譯成 React.createElement 的語法,因此 JSX 也要遵守對 React element 屬性的要求;也就是說,就算不寫 JSX 而是直接用 React.createElement 來建立 React element,也要遵守屬性命名轉換的規定。

以下分別用 React.createElement 語法和 JSX 語法來建立 React element,其實都是表達一樣的意思,也會得到一樣的 React element 結構。

const reactElement = React.createElement(
"div",
{ id: "wrapper", className: "foo" },
React.createElement(
"ul",
{ id: "list-01" },
React.createElement("li", { className: "list-item" }, "item 1"),
React.createElement("li", { className: "list-item" }, "item 2"),
React.createElement("li", { className: "list-item" }, "item 3")
),
React.createElement("button", { id: "button1" }, "I am a buton")
);
const reactElement = (
<div id='wrapper' className='foo'>
<ul id='list-01'>
<li className='list-item'>item 1</li>
<li className='list-item'>item 2</li>
<li className='list-item'>item 3</li>
</ul>
<button id='button1'>I am a buton</button>
</div>
);

以 Babel 進行 JSX 語法的轉譯#

在沒有任何處理的情況,JSX 語法在普通 JavaScript 執行環境是不合法的,會跳出如下錯誤:

因此需要專門工具來轉譯 JSX 語法,替換成真正可執行的 React.createElement 呼叫語法,而 Babel 就是可協助進行轉譯的工具。

Babel 是 JavaScript 社群中最主流的 source code transpiler,可將 JavaScript 原始碼轉譯成另一種模樣的 JavaScript 原始碼,並可透過各種 plugin 來定義轉譯範圍與效果。

補充: compiler 與 transpiler
• compiler :一種編譯工具,能將人類編寫的高階語言程式碼,轉為電腦能解讀並執行的低階機器語言執行檔。
• transpiler :一種轉譯工具,能將高階程式語言原始碼轉換成另一種模樣的高階程式語言原始碼,又稱為「source-to-source compiler」,將 TypeScript 轉譯成普通 JavaScript 程式碼,也是這種轉譯概念的應用。

負責轉譯 JSX 語法的工具稱作「JSX transformer」,JavaScript 社群中各種主流的 transpiler 幾乎都有實踐自己的 JSX transformer,而 Babel 是其中較主流也是 React 官方所推薦的,Babel 透過設置 @babel/plugin-transform-react-jsx 這個 plugin 作為 JSX transformer,就可將 JSX 語法轉為 React.createElement

以 Babel 搭建 React 開發環境時,通常會在 Babel 引入整個 @babel/preset-react 組合包,因為 @babel/plugin-transform-react-jsx 已經被包含在 @babel/preset-react 這個組合包中,因此不需再另外引入 @babel/plugin-transform-react-jsx。若開發者使用 Create React App 或 Next.js 這種已整合好的開發環境,通常都已內建好這些設定,開發者不用再自己處理這些 plugin 的引入。

將包含 JSX 語法的檔案交給 JSX transformer 的 Babel 轉譯後,就能產出另一支 JSX 被替換成 React.createElement 的 .js 檔案,而我們在 React 專案的 HTML 所引入的 JS 檔案則是 Babel 轉譯輸出後的檔案,以確保瀏覽器要執行的程式碼不包含 JSX 語法。

需注意的是,JSX 轉譯行為是發生在開發環境的建置階段(build time),而非執行階段(runtime),照理來說當我們在開發環境修改原始碼並存檔,就該讓 Babel 重新進行一次轉譯(可用其他工具來監聽程式碼變化,一有變就自動跑轉譯),此轉譯過程稱為程式碼的靜態分析與處理,所謂「靜態」是指程式碼被實際執行之前,先以純文字形式對程式碼語意、結構等進行分析,並進行轉譯或優化等行為。

額外補充一點, Babel 是如何讀懂我們的程式碼並轉譯成 React.createElement 的呢? Babel 實際上是透過 AST 的方式來實現對程式碼的修改,AST 全名是 Abstract Syntax Tree (抽象語法樹),是一種樹狀結構,用來表示程式碼語法結構。Babel 會先將我們的程式碼轉為 AST,並透過修改 AST 來將程式碼改造成我們想要的樣子,最後再將 AST 轉為一般的程式碼輸出。關於 Babel 與 AST 可參考 透過製作 Babel-plugin 初訪 AST

新版 JSX transformer 與 jsx-runtime#

React 17 以前,如果在使用 JSX 語法的檔案沒有寫 import React from 'react',會在瀏覽器 runtime 遇到 React is not defined 的錯誤,因為 React 17 以前,Babel JSX transformer 會將 JSX 轉為 React.createElement() ,它預期實際執行的作用域中,已經有 React 這變數且其中也有 createElement 這方法可呼叫;如果開發者沒事先 import React from 'react',就會導致實際執行時找不到 React 這變數而產生錯誤。

React 17 以前,如果在使用 JSX 語法的檔案沒有寫 import React from 'react',會在執行時噴錯

而從 React 17 開始,React 官方與 Babel 合作並支援新 JSX transformer,透過新的 JSX transformer 與 React 17 開始支援的 jsx-runtime,就不需要再為了 JSX 語法而 import React。新的 JSX transformer 不再把 JSX 語法轉換成 React.createElement() ,而改成 jsx-runtime_jsx 方法。

React 17 開始,新的 _jsx 方法可取代 React.createElement 的執行效果,即使不寫 import React,也能直接使用 JSX 語法,達到自動 import 的效果

_jsx()React.createElement 有何不同? 其實兩者大致相同,都是用來建立 React element 的方法,只是_jsx 方法有額外多一些優化。另外提醒,若開發者想用 JSX 以外的方法建立 React element,還是只能使用 React.createElement 語法,不應直接在原始碼中呼叫 _jsx()

📍 因_jsx_ 方法與React.createElement方法的主要用途完全互通,接下來仍以「JSX 語法會轉譯成 React.createElement() 語法」這說法來描述、展示範例程式碼

截至目前章節,我們瞭解了 DOM、Virtual DOM、React element 與 JSX 的概念,可用以下這張圖來總結這些概念的綜合關係。

JSX 語法規則#

JSX 語法會透過工具轉譯成React.createElement方法呼叫,為支援更多 JavaScript 的邏輯及各種資料型別的表達,JSX 會有更多的語法規則,接著就來看看有哪些語法規則吧!

嚴格標籤閉合#

在 HTML 中,有些標籤元素不會包含內容或子元素,因此不需閉合標籤,這些標籤被稱為「空元素」,如:<br><img><input>。而另外一類本來就需要閉合的標籤,如:<p><div><span>,即使遺漏閉合標籤,HTML 解析器也不會出錯,因為 HTML 有容錯性,瀏覽器會根據其他內容推斷可能的結構,以建立 DOM 元素。

然而,JSX 語法是嚴格標籤閉合,即使是只寫開標籤的空元素,在 JSX 中也需寫閉合標籤,如果沒有正確將 JSX 標籤閉合,Babel JSX transformer 就會產生轉譯失敗的錯誤。

// 即使子元素為空,也一定要閉合,否則 JSX 語法會轉譯失敗
const img = <img src='./image.jpg'></img>;
const input = <input type='text'></input>;
// 更推薦用自我閉合的簡寫寫法
const img = <img src='./image.jpg'/>;
const input = <input type='text'/>;
// 沒有子元素的標籤在轉譯後會自動忽略不填第三個參數
const img = React.createElement('img', {src: './image.jpg'});
const input = React.createElement('input', {type: 'text'});

JSX 語法的資料表達#

HTML 和 JSX 在本質上是完全不同的東西,以下說明兩者差異:

因此,若要在 JSX 表達靜態字串以外的資料型別或邏輯,就無法以 HTML 語法實現,要呈現字面值或表達式,就要選其他適當的語法。

補充:字面值與表達式
• 字面值(literal):表示固定值的表示法,不需進行額外計算。如:數字 123、字串 'Helo, world!'
• 表達式(expression):計算產生值的表示法,當 JavaScript 引擎看到一個表達式,他會嘗試運算並產生一個值。表達式可包含變數、運算子、函式呼叫或另一個表達式。如:2+3 是一個表達式,當 JavaScript 引擎實際執行到它,會計算出結果為 5
• 字面值和表達式的關係:兩者都是用來表示和產生值,差異在於字面值是固定的值本身,表達式是產生值的計算過程,可根據變數或函式的值變化。

在 JSX 語法中表達一段固定的字串字面值#

若想在 JSX 表達內容固定的字串字面值,因為是靜態內容,可使用和 HTML 相同的語法。

input.js
const div = (
<div id='foo' className='bar'> //以 className='bar'這種用引號把值包起來的寫法,來表達一個屬性的值是內容固定的字串字面值
這是一段字串 //子元素中若有固定的字串字面值內容,可直接寫在標籤之間
</div>
)

轉譯出來的內容會變成一段字串:

output.js
const div = React.createElement(
'div',
{
id: 'foo',
className: 'bar'
},
'這是一段字串'
);

可整理成兩種使用情況:

在 JSX 語法中表達一段表達式#

想表達任何「固定的字串字面值」以外的表達式時,就需要用 JSX 指定的語法 {} 來包住表達式

input.js
const number = 100;
function handleButtonClick(){
alert('clicked!');
}
const buttonElement = (
<button onClick={handleButtonClick}>
數字變數:{number},表達式:{number * 99}
</button>
)

以大括號{}包起來的表達式程式碼,轉譯後會放置到對應位置:

output.js
const number = 100;
function handleButtonClick() {
alert("clicked!");
}
const buttonElement = React.createElement(
"button",
{ onClick: handleButtonClick },
"數字變數:",
number,
',表達式:',
number * 99
);

可整理成兩種使用情況:

其中,如果子元素是一段連續字串字面值,則不論字串多長、是否換行,一整段字串會被視為同一個子元素,如:'數字變數:'',表達式:',會被視為單獨的子元素。

而如果子元素內包含表達式,則每個表達式都會被視為一個獨立的子元素,且會切分前面的字串,讓前面的字串變成獨立子元素,如:表達式 numbernumber * 99 都被視為獨立的子元素,且會切分前面的字串,如上面的範例程式碼,轉譯後的 button 元素總共有 4 個子元素,分別為 '數字變數:'number',表達式:'number * 99

將表達式視為獨立子元素的好處在於,表達式的值可能隨應用程式的互動行為而被更新,當資料連動畫面更新,進行新舊 React element 比較並只操作最小範圍 DOM element 時,獨立的子元素可更縮小操作的範圍,更降低效能成本。

在 JSX 語法中表達另一段 JSX 語法作為子元素#

「在 JSX 語法中表達另一段 JSX 語法作為子元素」意思就是在React.createElement方法中,包含另一段React.createElement方法的呼叫來作為子元素,而React.createElement 的呼叫屬於表達式,照理應該用 {} 包起來,如下:

input.js
const list = (
<ul>
{/* React.createElement是一種表達式,所以用{}包起來 */}
{<li>item 1</li>}
字串字面值 1
{<li>item 2</li>}
字串字面值 2
{<li>item 3</li>}
</ul>
)

然而,HTML 語法除了固定內容字串外,另一種能表達的概念就是開與閉標籤的位置;因此 JSX 語法也支援直接在父元素的開標籤與閉標籤之間寫子元素的標籤,不須另外包 {},如下:

input.js
const list = (
<ul>
{/* 直接寫在開標籤與閉標籤之間的子元素,會被 JSX transformer 自動辨識是在呼叫 React.createElement 的方法 */}
<li>item 1</li>
字串字面值 1
<li>item 2</li>
字串字面值 2
<li>item 3</li>
</ul>
)

React element 的子元素支援型別#

定義一個對應 DOM element 類型的 React element(如: divspan)時,會因為元素類型不同而有不一樣的處理方式來轉換到實際 DOM element,以下列出各種資料型別作為 React element 子元素是如何轉換到 DOM element 的:

<div>{[1,2,3]}</div> 轉換成實際 DOM element 的結果

而如果是 <div>{[ { id: 1, itemName: "apple" }, { id: 2, itemName: "orange" }, { id: 3, itemName: "banana" } ]}</div> 則會轉換失敗,因為子元素是物件,沒辦法轉換到實際 DOM element,會出現錯誤:

陣列的子元素若無法轉換成實際 DOM element,就會處理失敗

畫面渲染邏輯#

畫面渲染過程中,有時會遇到需因應不同資料或狀態而變化的渲染邏輯,「動態列表渲染」和「條件式判斷渲染」是最常見情境。

動態列表渲染#

可根據陣列資料動態產生列表的畫面,React 會攤開處理陣列型別的子元素,依序渲染每個元素。

input.js
const items = \["foo", "bar"\];
const element = (
<ul>
<li>固定的 item</li>
{/* 以原始資料的陣列產生對應的 React element 陣列 */}
{/* 這段表達式會產生一個陣列,陣列中每個項目都是一個 <li> React element */}
{items.map((item) => (
<li>我是 {item}</li>
))}
<li>另一個固定的 item</li>
</ul>
);
// output.js
const items = ["foo", "bar"];
const element = React.createElement(
"ul",
null,
React.createElement("li", null, "固定的 item"),
//陣列裡的項目被依序提出,放到原本陣列的位置上,與陣列前後元素同層並排
items.map((item) => React.createElement("li", null, "我是 ", item)),
React.createElement("li", null, "另一個固定的 item")
);

在印出 React element 陣列作為子元素時,可能會看到 React 在 console 印出一段警告:Warning: Each child in a list should have a unique 'key' prop.,因為 React 需要對陣列中的 React element 做 Virtual DOM 的效能優化處理,React 會透過 key屬性來辨別每個 React element,因此開發者需對陣列的每個 element 都填上唯一、不重複的 key 屬性。

條件式判斷渲染#

可根據資料或狀態做為條件式,來判斷是否繪製特定畫面區塊,因為 React element 只是 JavaScript 中的一種普通物件資料,可直接以 JavaScript 內建邏輯來條件式建立 React element。

input.js
const items = ["a", "b", "c"];
let childElement;
if (items.length >= 1) {
childElement = <img src="./image.jpg" />;
} else {
childElement = <input type="text" name="email" />;
}
const appElement = (
<div>
{items.map((item) => (
<span>{item}</span>
))}
{childElement}
</div>
);
output.js
// 只是普通的 JS 邏輯來操作普通的 JS 物件資料
const items = ["a", "b", "c"];
let childElement;
if (items.length >= 1) {
childElement = React.createElement("img", {
src: "./image.jpg",
});
} else {
childElement = React.createElement("input", {
type: "text",
name: "email",
});
}
const appElement = React.createElement(
"div",
null,
items.map((item) => React.createElement("span", null, item)),
childElement
);

透過 && 運算子來條件式渲染#

&& 適合用在「符合條件時就渲染特定畫面,不符合時則什麼都不印」的情境,&&運算子本身運算邏輯為:當運算子左邊的值為 truthy,回傳運算子右邊的值;當運算子左邊的值為 falsy,回傳運算子左邊的值。

&&運算子應用在 JSX 語法中的範例如下:

const element = (
<div>
{/* 當 isVIP 為 true 時,此表達式的值就是<h1>Hello VIP!</h1>,
當 isVIP 為 false 時,此表達式的值就是 false,不會印出任何東西 */}
{isVIP && <h1>Hello VIP!</h1>}
</div>
)

透過三元運算子來條件式渲染#

三元運算子適合用在「當條件符合時印出 A,不符合時則印出 B」的情境,三元運算子本身運算邏輯為:在條件式的值後面加一個問號(?),如果條件式為 truthy,回傳冒號(:)左邊的值,如果條件式為 falsy,回傳冒號右邊的值。

將三元運算子應用在 JSX 語法中的範例如下:

const element = (
<div>
{/* 當 isLoggedIn 為 true 時,此表達式的值是 <h1>Member</h1>,
當 isLoggedIn 為 false 時,此表達式的值是 <h1>Guest</h1> */}
{isLoggedIn ? <h1>Member</h1> : <h1>Guest</h1>}
</div>
)

JSX 語法的第一層只能有一個節點#

因為一段 JSX 就是在呼叫一次 React.createElement 語法,只會回傳「一個 React element」,因此無法在 JSX 的第一層結構表達多個 React element 節點:

const element = (
//⛔這段JSX語法不合法,無法只用一個值來表達兩個 React element,transpiler 無法解析
<button>foo</button>
<div>bar</div>
);

樹狀資料結構只能有一個根節點,若想要表達多個同層的 React element,解決辦法就是把想要放在同層的多個 React element 用一個共同的父元素包起來:

const element = (
<div>
<button>foo</button>
<div>bar</div>
</div>
);

但多包一層無意義的<div> 可能導致產生多餘的層級結構而降低可讀性,因此 React 提供內建的特殊元素類型 Fragment 來建立父元素。

Fragment#

Fragment 不是對應實際 DOM element 的元素類型,也不是一種 component,而是 React 內建的特殊 React element 類型,專門處理上述情境,以 Fragment 建立 React element,實際 DOM 結構不會產生多餘元素,可將其想成一個能作為容器用途的 React element。

import { Fragment } from "react";
const element = (
<Fragment>
<button>foo</button>
<div>bar</div>
</Fragment>
);

另外,也可直接用空標籤來表示 Fragment 元素類型,就不用另外 import Fragment

const element = (
<>
<button>foo</button>
<div>bar</div>
</>
);

Reference:#

如有任何問題歡迎聯絡、不吝指教✍️