紀錄工作經驗、相關知識,解決技術相關問題。

React, 新手 Jamp Holes

JSX 是什麼?React JSX 語法規則、與 HTML 的差異和轉譯原理

JSX 是什麼?React JSX 語法規則、與 HTML 的差異和轉譯原理 特色圖片

JSX 是什麼? JSX 是 JavaScript 的語法擴充,讓你在 JavaScript 檔案裡直接寫類似 HTML 的標記,例如 <h1 className="greeting">Hello</h1>。瀏覽器看不懂 JSX,所以要先經過 Babel、TypeScript 之類的編譯器轉成一般 JavaScript 才能執行。本文說明 JSX 的定位、轉譯後長什麼樣子、跟 HTML 的差異、大括號 {} 的用法,最後整理常見錯誤。

JSX 是什麼:JavaScript 裡的類 HTML 標記

React 官方文件提醒,JSX 與 React 是兩個不同的東西,常常搭配使用,但可以各自獨立:JSX 是語法擴充,React 是 JavaScript 函式庫。

JSX 的規格由 facebook/jsx 專案維護。規格寫明 JSX 是 ECMAScript 的類 XML 語法擴充,沒有定義任何語意,也不打算由瀏覽器或 JavaScript 引擎直接實作,而是由各種預處理器(transpiler)轉成標準 ECMAScript。換句話說,<h1> 在 JSX 裡變成什麼,是由使用它的工具決定;在 React 裡,它會變成建立 React 元素的函式呼叫。

React 讓標記與邏輯放在同一個檔案,官方的說法是:渲染邏輯與標記放在一起,每次修改都能保持同步。React 元件是一個 JavaScript 函式,回傳的 JSX 就是畫面上要顯示的內容。

export default function TodoList() {
  return (
    <>
      <h1>Hedy Lamarr's Todos</h1>
      <ul>
        <li>Invent new traffic lights</li>
        <li>Rehearse a movie scene</li>
      </ul>
    </>
  );
}

專案建立與目錄結構見〈React 工具鏈規格解析:基於 Vite 的初始化流程、目錄架構與生產環境建構〉。

JSX 轉譯後長什麼樣子:瀏覽器看不懂 JSX

React 官方部落格說明:瀏覽器本身不認得 JSX,大多數 React 使用者會靠 Babel 或 TypeScript 這類編譯器,把 JSX 轉成一般 JavaScript;Create React App、Next.js 等預先設定好的工具鏈也在內部包含了 JSX 轉換。

轉換方式有兩種,官方稱為舊版與新版 JSX transform:

  • 舊版(classic):把 JSX 轉成 React.createElement(...) 呼叫,所以只要用到 JSX,檔案裡就必須有 import React from 'react'。
  • 新版(automatic):React 17 起提供,編譯器自動從 react/jsx-runtime 匯入 jsx 之類的函式再呼叫,不必手動 import React。官方註明這些函式只給編譯器使用,自己手動建立元素時仍要用 React.createElement;舊版 transform 不會被移除。

用 esbuild 轉換同一段 JSX,兩種輸出如下。這段腳本在 Node.js 執行,jsx: 'transform' 對應 classic,jsx: 'automatic' 對應 automatic:

const esbuild = require('esbuild');
const src = `function App() {
  const name = 'Sean';
  return <h1 className="greeting">Hello, {name}</h1>;
}`;
console.log(esbuild.transformSync(src, { loader: 'jsx', jsx: 'transform' }).code);
console.log(esbuild.transformSync(src, { loader: 'jsx', jsx: 'automatic' }).code);
console.log(esbuild.transformSync('const a = <><p>A</p><p>B</p></>;', { loader: 'jsx', jsx: 'automatic' }).code);
Node.js v22.20.0、esbuild 0.28.2、Windows 11 轉換 JSX 的輸出畫面

從輸出可以看到:

  • classic 的結果是 React.createElement("h1", { className: "greeting" }, "Hello, ", name),標籤名稱成為第一個參數,屬性成為第二個參數,其餘是子節點。
  • automatic 的結果是從 react/jsx-runtime 匯入函式,子節點放進 children 屬性;這個例子的 h1 有兩個子節點(文字與變數),esbuild 產出的是 jsxs。
  • <>...</>(Fragment 短語法)被轉成 jsxs(Fragment, { children: [...] })。Fragment 在 DOM 裡不會留下節點,用它拆元件時 DOM 結構會怎麼變化,可前往〈React JSX 轉譯與 DOM 結構:Fragment 應用、多態元件 as 模式與層級錯位排查〉。

工具怎麼選 classic 或 automatic

各工具的設定名稱不同,但都有這兩種模式:

工具設定說明
Babel(@babel/preset-react)runtime: 'classic' 或 'automatic'官方文件寫明預設為 automatic;automatic 自動匯入 JSX 轉出的函式,classic 不會自動匯入
TypeScripttsconfig 的 jsx 選項react 是 classic runtime,react-jsx 是 automatic runtime,另有 preserve、react-jsxdev、react-native;檔案要用 .tsx 副檔名
esbuildjsx: 'transform'/'automatic'上面範例使用的選項

React 官方部落格另外提到,新版 transform 由 React 17 引入,Babel 7.9.0 以上、TypeScript 4.1 以上支援。

JSX 的結果是一個 JavaScript 物件

JSX 轉譯後呼叫的函式會回傳 React 元素,本質上是描述畫面的物件。用 React.createElement 建立同一個元素,再把 type 與 props 印出來:

const React = require('react');
const el = React.createElement('h1', { className: 'greeting' }, 'Hello, Sean');
console.log(JSON.stringify({ type: el.type, props: el.props }));
{"type":"h1","props":{"className":"greeting","children":"Hello, Sean"}}

這也是官方解釋「為什麼多個 JSX 標籤要包起來」的理由:JSX 底層是純 JavaScript 物件,函式不能同時回傳兩個物件而不包進陣列,所以也不能同時回傳兩個 JSX 標籤而不包進另一個標籤或 Fragment。

JSX 與 HTML 的差異:4 條規則

React 官方把 JSX 比 HTML 嚴格的地方整理成三條規則,加上元件命名規則,一共四件事:

1. 只能回傳一個根元素

元件要回傳多個元素時,要用單一父標籤包起來。不想多加一層 <div>,可以寫 <> 與 </>,這種空標籤稱為 Fragment,能分組又不在瀏覽器的 HTML 樹裡留下痕跡。

2. 標籤一定要關閉

JSX 要求標籤明確關閉:<img> 要寫成 <img />,<li>oranges 要寫成 <li>oranges</li>。HTML 允許的省略寫法在 JSX 裡會直接造成語法錯誤。

3. 屬性用 camelCase

JSX 轉成 JavaScript 後,屬性會變成物件的鍵,而 JavaScript 的名稱不能含連字號,也不能是 class 這類保留字,所以 React 裡許多 HTML 與 SVG 屬性改用 camelCase:stroke-width 寫成 strokeWidth,class 寫成 className。對照如下:

HTMLJSX備註
class="photo"className="photo"依對應的 DOM 屬性命名
for="name"htmlFor="name"React 官方列的標準 DOM 屬性,用於 <label> 與 <output>
tabindex="1"tabIndex={1}舊版官方文件的例子
style="background-color: black"style={{ backgroundColor: 'black' }}行內樣式要傳物件,屬性名稱用 camelCase
aria-label、data-id維持 aria-label、data-id官方說明這兩類基於歷史原因維持 HTML 的連字號寫法

這些屬性在輸出的 HTML 裡會轉回一般寫法。用 react-dom/server 的 renderToStaticMarkup 輸出:

const React = require('react');
const { renderToStaticMarkup } = require('react-dom/server');
const e = React.createElement;
console.log(renderToStaticMarkup(
  e('div', null,
    e('label', { htmlFor: 'n', className: 'lbl' }, '名稱'),
    e('input', { id: 'n', tabIndex: 1 }),
    e('p', { style: { backgroundColor: 'black', color: 'pink' } }, 'x'))
));
Node.js v22.20.0、React 19.3.1、Windows 11 以 renderToStaticMarkup 輸出的 HTML 字串

圖中 == F. HTML output 是這段腳本的結果:htmlFor 輸出成 for、className 輸出成 class、tabIndex 輸出成 tabindex,style 物件變成 background-color:black;color:pink。屬性大小寫與引號規範可前往〈HTML 大小寫與引號規範:別讓細節成為生產環境的隱形炸彈〉。

把 HTML 的 class 原封不動貼進 JSX,React 會在主控台提醒。同一張圖的 == I. class attr console.error 是對 div 傳入 class 時 console.error 收到的內容:格式字串「Invalid DOM property %s. Did you mean %s?」,參數是 class 與 className。

4. 自訂元件用大寫開頭

React 官方的 createElement 文件說明:JSX 中 <Something /> 等同 createElement(Something),<something />(小寫)則等同 createElement('something'),傳入的是字串,會被當成內建 HTML 標籤。元件教學也寫明,React 元件名稱必須以大寫字母開頭,否則無法運作。

同一張圖的 == H. lowercase component:傳函式本身給 createElement 輸出 <b>hi</b>;傳字串 'hello' 則輸出 <hello></hello>,變成不存在的 HTML 標籤,所以小寫元件名稱不會執行元件函式。

在 JSX 裡放 JavaScript:大括號用法

要在標記裡使用變數、函式呼叫等 JavaScript 運算式,就用大括號 {}。React 官方列出大括號只能用在兩個地方:

  1. 標籤內的文字:<h1>{name}'s To Do List</h1> 可以,<{tag}>...</{tag}> 不行。
  2. 緊接在 = 後面的屬性值:src={avatar} 會讀取變數 avatar,src="{avatar}" 則會傳入字串 "{avatar}"。

{{ 和 }} 不是特殊語法,只是大括號裡放了一個 JavaScript 物件,最常見的是行內樣式:

<ul style={{ backgroundColor: 'black', color: 'pink' }}>
  <li>Improve the videophone</li>
</ul>

外層大括號是 JSX 的,內層是物件,屬性名稱用 camelCase。

大括號裡可以放字串、數字或陣列這類 React 能顯示的值,但不能把一般物件直接當子節點。同一張輸出圖的 == J. object child 是把 { name: 'Sean' } 當子節點傳給 div 的結果,拋出的錯誤是:

Objects are not valid as a React child (found: object with keys {name}). If you meant to render a collection of children, use an array instead.

官方文件的挑戰題也是這個錯誤,原因是把整個 person 物件放進標籤內容。

JSX 裡常見的語法錯誤

從 HTML 貼進 JSX 最常遇到標籤沒關閉與多個根元素。用 esbuild 轉換三段有問題的 JSX:

const esbuild = require('esbuild');
for (const code of [
  'const a = <img src="a.png">;',
  'const a = <h1>A</h1><p>B</p>;',
  'const a = <li>one;',
]) {
  try { esbuild.transformSync(code, { loader: 'jsx' }); }
  catch (e) { console.log('INPUT:', code); console.log(e.errors.map(x => 'ERROR: ' + x.text).join('\n')); }
}
Node.js v22.20.0、esbuild 0.28.2、Windows 11 轉換三段有問題的 JSX 的錯誤訊息

三段的輸出:

  • <img src="a.png"> 未關閉:Unexpected end of file before a closing "img" tag,指出 img 沒有關閉。
  • <h1>A</h1><p>B</p> 兩個並排的根元素:Unterminated regular expression。esbuild 的訊息沒有直接說「要用單一元素包起來」,遇到這類看不懂的錯誤時,先檢查 return 後面是不是有多個並排的元素。
  • <li>one; 未關閉:Unexpected end of file before a closing "li" tag。

不同工具的錯誤措辭不同,但原因都在於上面四條規則。

JSX 會跳脫內容,但不是所有情況都安全

React 舊版官方文件說明,React DOM 預設會跳脫(escape)嵌入 JSX 的值再渲染,所以把使用者輸入放進大括號是安全的,這有助於防止 XSS。用 renderToStaticMarkup 輸出 '<script>alert(1)</script>' 當作文字子節點,結果是 <p>&lt;script&gt;alert(1)&lt;/script&gt;</p>(見輸出圖的 == G. escape),尖括號被轉成 &lt; 與 &gt;,不會被當成標籤。

這個保護只涵蓋當成文字放進 JSX 的值。需要把 HTML 字串當標記渲染時要用 dangerouslySetInnerHTML,可前往〈React 如何正確渲染 HTML 字串?dangerouslySetInnerHTML 用法與範例〉。

不用 JSX 也能寫 React,但實務上多半用 JSX

createElement 的官方文件說它是 JSX 的替代方式:專案無法使用 JSX 時,可以直接呼叫 createElement(type, props, ...children)。React 官方也說,多數 React 開發者偏好 JSX 的簡潔,多數程式碼庫也使用 JSX。

檔案副檔名方面,TypeScript 要使用 JSX 必須用 .tsx;Next.js 的 page 檔案,官方文件列的副檔名是 .js、.jsx、.tsx。

相關主題:預設 Props 寫法見〈解決 React 元件 defaultProps 錯誤:TypeScript Props 預設參數、HOC 與重構踩坑排查〉;圖片 alt 檢查警告見〈React Console Warning:images jsx-a11y/alt-text 警告原因、解決方法與 img alt 設定規範〉。

參考資料與延伸閱讀

發佈留言