1. 从一个“看起来像HTML”的语法说起
第一次看到JSX代码的人,十有八九会愣一下:这玩意儿到底是JavaScript还是HTML?比如下面这段:
const element = <h1 className="title">Hello, world!</h1>;一个合法的JavaScript文件里,突然冒出一段尖括号标签,赋值给一个变量,末尾还带分号。如果你之前只写过普通JS,第一反应大概是“这语法不合法吧”。但如果你在React项目里写过组件,这段代码你每天都要敲几十遍。
JSX的全称是JavaScript XML,它是React生态里用来描述UI结构的一种语法扩展。注意我的用词——语法扩展,不是模板语言,也不是HTML的变体。它最终会被编译工具转换成普通的JavaScript函数调用。上面那行代码,经过编译后大致等价于:
const element = React.createElement("h1", { className: "title" }, "Hello, world!");所以JSX的本质,是给React.createElement这类的函数调用套了一层更直观的“外壳”。你写的是标签,跑的是函数。理解这一点,后面所有的疑惑——为什么className不叫class、为什么只能返回一个根节点、为什么表达式要包在花括号里——全都能顺藤摸瓜找到答案。
这篇文章我打算把JSX从里到外拆一遍。不管你是刚接触React的新手,还是写了几年组件但没深究过编译原理的老手,我都会从语法规则、编译过程、表达式机制、条件渲染、列表渲染、样式处理、常见报错排查这几个角度,把这块内容讲透。中间会穿插大量我在实际项目里踩过的坑和总结出来的写法习惯,尽量让你看完就能直接上手改代码,而不是停留在“大概懂了”的层面。
2. JSX到底是什么:本质、由来与它解决的问题
2.1 它不是一个新语言,只是函数调用的语法糖
很多人把JSX当成一门独立语言来学,这是个误区。JSX没有自己的运行时,没有自己的类型系统,也没有独立的执行引擎。它只是一套语法约定,由Babel、TypeScript、SWC这类编译工具识别并转换。转换的目标就是普通的JavaScript函数调用。
React官方文档里有一句话说得挺直白:JSX只是React.createElement(component, props, ...children)的语法糖。这句话是理解一切的地基。你写<Button color="red">点击</Button>,编译器看到的是一个类型为Button的元素、一个color属性、一个文本子节点,然后生成对应的函数调用。
那为什么React不直接让你写React.createElement呢?因为UI结构天然是树形的,用函数调用描述树形结构,嵌套三层以上就没法看了。JSX用标签的嵌套形式,把这种树形结构直接映射到代码的视觉结构上,可读性提升是数量级的。
2.2 为什么是“XML”而不是“HTML”
JSX名字里的XML不是随便起的。它要求标签必须闭合,属性必须用引号包裹,标签名大小写敏感。这些规则比HTML严格得多。HTML里你可以写<br>、<img src="x">这种自闭合标签不闭合的写法,JSX里必须写成<br />、<img src="x" />。
这个严格性是有意为之的。因为JSX最终要转换成函数调用,标签不闭合编译器就没法确定子节点的边界。而HTML的容错解析是浏览器专门为网页场景做的妥协,JSX不需要背这个包袱。
另一个关键差异是大小写。在HTML里<div>和<DIV>是一样的,但在JSX里,小写开头的标签被当作原生DOM标签,大写开头的标签被当作组件引用。这个规则极其重要,后面讲组件渲染时会详细展开。
2.3 它解决了什么问题:UI与逻辑的耦合
在JSX出现之前,前端界的主流做法是“模板与逻辑分离”。HTML模板里写结构,JavaScript里写交互逻辑,两者通过选择器或者模板语法绑定。这种模式在小规模页面里没问题,但组件化开发普及之后,问题就暴露了:一个按钮的渲染逻辑、状态变化、事件处理,被拆散在模板文件和JS文件里,改一个交互要来回跳。
React团队当年的判断是:渲染逻辑和UI逻辑本质上就是耦合的,强行分离反而增加了认知负担。与其用模板语法发明一套新的指令系统(比如v-if、v-for),不如直接用JavaScript的表达能力来描述UI。JSX就是这个思路的产物——它让你在描述结构的同时,随时可以插入JavaScript表达式。
这个选择带来的直接好处是:条件判断用&&和三元表达式,列表用map,这些都是JavaScript原生能力,不需要额外学一套模板指令。代价是你得对JavaScript本身足够熟悉。
3. JSX的核心语法规则与编译机制
3.1 编译过程:从标签到函数调用
理解编译过程,很多“为什么这么写”的问题就自动有答案了。我用Babel的在线编译器实测过,一段典型的JSX:
const el = ( <div className="box" id="main"> <span>文本</span> {count} </div> );经过转换后大致是:
const el = React.createElement( "div", { className: "box", id: "main" }, React.createElement("span", null, "文本"), count );可以看到几个规律:第一个参数是标签名或组件引用,第二个参数是属性对象(没有属性时为null),后面的参数全是子节点,按顺序排列。子节点如果是文本,直接作为字符串传入;如果是表达式,传入表达式的值;如果是嵌套标签,递归调用React.createElement。
新版React(17之后)引入了自动运行时,编译产物不再依赖全局的React变量,而是从react/jsx-runtime导入jsx函数。转换结果变成:
import { jsx as _jsx } from "react/jsx-runtime"; const el = _jsx("div", { className: "box", id: "main", children: [ _jsx("span", { children: "文本" }), count ] });这个变化的意义在于:你不再需要在每个JSX文件顶部写import React from 'react'。以前忘了写这行,运行时会报React is not defined,这是新手最常见的报错之一。自动运行时之后这个问题基本消失了。
3.2 属性书写:为什么是className和htmlFor
JSX属性名用的是DOM属性名而不是HTML属性名,最典型的就是className和htmlFor。原因是JSX属性最终会作为对象的key传给React.createElement,而对象的key不能和JavaScript保留字冲突。class是JS的保留字,for也是,所以React用了DOM API里的对应名称className和htmlFor。
这个规则延伸出一批属性写法:
| HTML写法 | JSX写法 | 说明 |
|---|---|---|
| class | className | class是保留字 |
| for | htmlFor | for是保留字 |
| tabindex | tabIndex | 驼峰命名 |
| onclick | onClick | 事件用驼峰 |
| onchange | onChange | 事件用驼峰 |
| style="color:red" | style={{color:'red'}} | 样式传对象 |
事件属性用驼峰命名,值传函数而不是字符串。这一点和原生HTML的onclick="doSomething()"完全不同。JSX里写onClick={doSomething},传的是函数引用,不是调用结果。如果你写成onClick={doSomething()},那就在渲染时立即执行了,这是新手高频错误。
3.3 表达式插值:花括号里能放什么
JSX里用一对花括号{}来插入JavaScript表达式。能放的东西包括:变量、函数调用、算术运算、三元表达式、逻辑运算、数组的map结果、对象属性访问。不能放的东西包括:语句(if、for、switch)、对象字面量(会被当成块级作用域)、注释(要用{/* */}形式)。
// 可以 <p>{user.name}</p> <p>{1 + 2}</p> <p>{isLogin ? "已登录" : "未登录"}</p> <p>{list.map(item => <li key={item.id}>{item.name}</li>)}</p> // 不可以 <p>{if (x) { return 1 }}</p> // 语句不能放 <p>{{ color: 'red' }}</p> // 对象字面量会被误解析最后那个对象字面量的坑我踩过。你想传一个对象给某个属性,写成style={{color:'red'}},外层花括号是“进入表达式模式”,内层花括号是对象字面量,两个花括号连在一起看起来像模板语法,其实是两层含义。理解这一点,就不会觉得style={{}}这种写法奇怪了。
3.4 注释的写法
JSX里的注释必须包在花括号里,写成{/* 注释内容 */}。直接写//或者/* */在标签之间会被当成文本渲染出来。这个细节在调试时经常用到,比如临时注释掉一段JSX:
<div> {/* <OldComponent /> */} <NewComponent /> </div>4. 条件渲染、列表渲染与样式处理实战
4.1 条件渲染的四种写法与取舍
JSX里没有v-if这种指令,条件渲染全靠JavaScript表达式。常用的有四种写法,各有适用场景。
第一种是三元表达式,适合二选一的场景:
{isLoading ? <Spinner /> : <Content />}第二种是逻辑与&&,适合“满足条件才渲染,否则什么都不显示”的场景:
{hasError && <ErrorTip />}这里有个经典坑:如果hasError是数字0,0 && <ErrorTip />的结果是0,React会把0渲染到页面上。所以用&&时,左侧一定要确保是布尔值,写成{!!hasError && <ErrorTip />}或者{hasError > 0 && <ErrorTip />}更稳妥。
第三种是提前返回,适合整个组件级别的条件:
function UserPanel({ user }) { if (!user) return <LoginTip />; return <div>{user.name}</div>; }第四种是变量提取,适合条件复杂、嵌套深的场景:
let content; if (status === 'loading') content = <Spinner />; else if (status === 'error') content = <ErrorTip />; else content = <DataList />; return <div>{content}</div>;我的经验是:简单二选一用三元,单条件显示用&&,组件级判断用提前返回,多分支用变量提取。别硬套一种写法,可读性优先。
4.2 列表渲染与key的真正作用
列表渲染用数组的map方法,这个大家都知道。但key的作用很多人理解得不对。key不是给开发者看的,是给React的diff算法用的。React通过key来判断新旧列表里哪些元素是同一个,从而决定是复用DOM还是销毁重建。
{list.map(item => ( <li key={item.id}>{item.name}</li> ))}用数组下标当key是常见的偷懒做法,但在列表会增删排序的场景下会出问题。比如一个可删除的列表,用下标当key,删掉第一项后,原来第二项的下标变成0,React会认为下标0的元素还在,只是内容变了,于是复用DOM但更新内容。如果这个元素内部有输入框或者动画状态,就会出现状态错位。所以能用唯一id就用唯一id,实在没有再用下标。
key只需要在map的直接父级元素上写,不需要往子元素里传。而且key不会作为props传给组件,组件内部拿不到key的值,这是React的特殊处理。
4.3 样式处理的三种方式
JSX里写样式有三种主流方式,各有取舍。
内联样式用对象,属性名用驼峰:
<div style={{ backgroundColor: 'red', fontSize: 14 }}>内容</div>注意fontSize的值14会被自动加上px,但如果你写'14px'也行。内联样式适合动态计算的样式,不适合大量静态样式。
CSS类名用className,配合外部样式表:
<div className={`box ${isActive ? 'active' : ''}`}>内容</div>模板字符串拼接类名是最常见的做法。类名多了之后可以用classnames这个库,写法更清爽。
CSS Modules或者CSS-in-JS方案,适合组件级样式隔离。CSS Modules的用法是import styles from './Button.module.css',然后className={styles.button}。这种方式的好处是类名自动加哈希,不会全局污染。
我个人的习惯是:布局和静态样式用CSS Modules,动态样式用内联对象,条件类名用模板字符串或classnames。三种混用没问题,关键是团队内保持一致。
5. 常见报错与排查技巧实录
5.1 高频报错速查表
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
| Adjacent JSX elements must be wrapped | 返回了多个并列根节点 | 用<>...</>或<div>包裹 |
| React is not defined | 旧版运行时忘了import React | 顶部加import React from 'react' |
| Cannot read property 'map' of undefined | 列表数据未初始化 | 初始值设为[]或用可选链 |
| Objects are not valid as a React child | 直接渲染了对象 | 渲染对象的某个属性 |
| Each child in a list should have a unique key | 列表缺key | 给map元素加唯一key |
| Invalid hook call | Hook在条件或循环里调用 | 移到组件顶层 |
| Unexpected token '<' | 编译工具没配置JSX | 检查Babel/TS配置 |
5.2 几个我踩过的坑
第一个坑是&&渲染数字0。前面提过,{count && <Tip />}在count为0时会渲染出0。这个bug很隐蔽,因为页面上多一个0不容易被发现,但会影响布局。解决办法是确保左侧是布尔值。
第二个坑是JSX里的空格处理。HTML里多个空格会折叠成一个,JSX里标签之间的换行和缩进会被去掉,但同一行内的空格会保留。如果你需要显式空格,用{' '}。这个在拼接文本时经常遇到,比如<span>你好</span> <span>世界</span>中间的空格,换行写就没了。
第三个坑是组件名必须大写。<myComponent />会被当成原生标签mycomponent,React会警告“未知标签”。必须写成<MyComponent />。这个规则没有例外,动态组件可以用变量:const Tag = condition ? A : B; return <Tag />;,变量名首字母大写即可。
第四个坑是dangerouslySetInnerHTML。有时候需要渲染富文本,用这个属性:
<div dangerouslySetInnerHTML={{ __html: htmlString }} />名字里的“dangerously”不是吓唬人,如果htmlString来自用户输入且没做过滤,就是XSS漏洞。用之前一定要确保内容可信或者已经过净化处理。
5.3 调试JSX的实用技巧
React DevTools是排查JSX结构问题的首选工具。它能显示组件树、每个组件的props和state,还能高亮对应的DOM节点。遇到“为什么这个组件没渲染”的问题,先在DevTools里看组件树里有没有它,再看它的props是否符合预期。
另一个技巧是在JSX里临时插入{JSON.stringify(props)}来打印props,比console.log更直观,因为它在页面上直接显示。调试完记得删掉。
如果遇到编译层面的问题,比如“Unexpected token”,先确认文件扩展名是.jsx或.tsx,再确认构建工具的配置里包含了JSX转换。Vite、Next.js这类现代脚手架默认支持,老项目用Webpack的话要检查babel-loader的presets里有没有@babel/preset-react。
6. JSX在Vue和其他框架里的延伸
6.1 Vue3里的JSX用法
Vue3也支持JSX,但和React的JSX有区别。Vue的JSX通过@vue/babel-plugin-jsx转换,指令系统用不了,得用等价写法。比如v-if用三元或&&,v-for用map,v-model用modelValue和onUpdate:modelValue。
// Vue3 JSX const Comp = { setup() { const count = ref(0); return () => ( <div onClick={() => count.value++}> {count.value} </div> ); } };Vue3的JSX里,事件用onClick,但修饰符要用函数包装,比如.stop写成onClick={e => { e.stopPropagation(); handler(); }}。插槽用v-slots对象传递。整体体验和React接近,但细节规则不同,跨框架迁移时要注意。
6.2 为什么Vue默认用模板而React用JSX
这是个设计哲学问题。Vue的模板是声明式的,编译器能做静态分析优化,比如标记静态节点、提升常量,运行时性能有优势。React的JSX是命令式的JavaScript,灵活度更高,但优化靠开发者手动做(比如memo、useMemo)。
两者没有绝对优劣。模板上手门槛低,适合团队里前端水平参差不齐的情况;JSX灵活度高,适合逻辑复杂、需要大量动态结构的场景。选哪个更多是团队技术栈和项目需求的权衡,不是技术先进性的问题。
6.3 其他场景里的JSX变体
除了React和Vue,还有一些工具用到了类似JSX的语法。比如某些设计工具的脚本扩展用.jsx后缀,但那是完全不同的东西,和React的JSX没有关系。搜索“图层排列器.jsx下载”这类关键词时要注意区分,别把设计工具的脚本和前端框架的JSX搞混了。
React Native里也用JSX,但标签不是DOM元素,而是原生组件,比如<View>、<Text>。写法规则和Web端一致,只是可用的标签集合不同。从Web转React Native时,JSX语法不用重新学,但组件库要重新熟悉。
7. 写在最后:我个人的几条实操建议
JSX这东西,语法规则半天就能学完,但真正用好需要时间。我自己的体会是,别把它当成模板语言来用,把它当成JavaScript的一部分。遇到“这里能不能写表达式”的问题,先问自己“这个表达式在JS里合法吗”,合法基本就能用。
写组件时,条件渲染和列表渲染尽量保持扁平,嵌套超过三层就提取子组件或者变量。JSX的可读性优势在扁平结构里最明显,嵌套深了反而比模板更难读。
key的问题别偷懒,能用id就用id。我见过太多因为用下标当key导致的诡异bug,排查起来很费时间,而避免的成本几乎为零。
最后,遇到报错先看控制台的第一条错误,React的报错信息通常很具体,会直接告诉你哪个文件哪一行、哪个属性有问题。别被一屏红色吓到,从第一条开始读,大部分问题五分钟内能定位。