“005-006 jsx语法规则、jsx小练习”——看到这个编号,基本就能猜到这是一套前端入门课程里的某个节点。我自己带新人时,一般在第5、第6次课响应讲JSX,前面刚讲完React.createElement的基础,后面马上要进入组件开发,这个位置很关键:学得好,后续写组件就像写HTML一样顺手;学得不好,后面到处都是“花括号包裹”“驼峰命名”这类小坑。
这篇文章,我会把课程里讲的JSX语法规则重新梳理一轮,再带上4个我实际给新人用的小练习,每个练习都给出能直接跑起来的代码和我在旁标注的“为什么这么写”。JSX这套东西,资料很多,但多数讲得太散,要么只讲规则不给场景,要么狂堆代码不解释背后编译逻辑。我尽量用带项目的口吻讲清楚:JSX不是模板引擎,它是JavaScript的语法糖,理解了这一层,规则就不需要死记。
1. 内容整体设计与思路拆解
1.1 先搞清楚:JSX到底是个什么东西
我第一次接触JSX时最大的困惑是:这不就是HTML吗,为什么还非得起个新名字?后来看编译产物才反应过来,JSX既不是HTML也不是模板字符串,它是React.createElement这个调用的语法糖。
举个最简单的例子。你写:
const element = <h1 className="title">你好</h1>;Babel会把这段转译成:
const element = React.createElement('h1', { className: 'title' }, '你好');注意看,标签名变成了第一个参数,属性集合变成了第二个参数,子节点变成了第三个及之后的参数。也就是说,JSX树在编译阶段已经被拍扁成一棵JavaScript对象树了,而不是等到运行时才去解析标签字符串。
理解了这一点,很多规则就说得通了。比如“标签必须闭合”——因为createElement需要完整描述一个节点树,一个没闭合的标签会造成结构歧义,编译器没法确定子节点的边界。再比如“只能有一个根节点”——因为函数只能返回一个值,如果你写了并列的两个根,编译出来的createElement不知道该把哪个作为返回值。
这个认知是整篇文章的基石。后面所有规则我都会尽量往这条主线上去靠,而不是孤立地罗列知识点。
1.2 为什么要设计成嵌入在JavaScript里的结构
早几年前端圈争论过一个话题:模板应该在HTML标签里写逻辑,还是应该在JavaScript里写模板?React选的是后者,Vue最初选了前者,到了Vue3也开始提供JSX或h函数的能力。这里面的逻辑值得多想一步。
JSX最实在的优势是“组件一切皆JS”。模板语言里你需要学习v-if、v-for、ngIf这类指令,但在JSX里,条件判断直接写if或三元表达式,列表循环直接调用map,复用逻辑就是定义一个函数变量。学JSX,你不需要再学一套指令系统,只需要把JavaScript基础打好。
另一个容易被忽略的点是类型检查。既然JSX是JavaScript语法的一部分,TypeScript就可以对JSX里的属性名、事件参数做静态类型校验。比如说<button onClick={handleClick}>,如果handleClick函数签名不匹配,编辑器里就会直接标红。这一点在大型团队协作里价值极大——模板引擎时代,这类问题只能在运行时暴露。
当然,JSX也不是没有代价。它要求构建工具链支持编译,不能直接扔到浏览器里跑。但今天Vite、Next.js这类脚手架已经把这层复杂度封装好了,代价对这个时代来说实际可以忽略。
1.3 从模板语法切换到JSX的思维转变
我接触过不少从Vue转React或者同时用两者的同事,他们最不适应的点往往不是JSX本身,而是思路上的切换。
Vue模板是这么写条件渲染的:
<div v-if="visible"> <p>显示这段文字</p> </div>JSX里则是:
<div> { visible && <p>显示这段文字</p> } </div>前者读起来像“对这段HTML做一个指令”,后者读起来像“在JavaScript表达式里嵌入一段结构”。同样的事,前者偏向声明式指令,后者偏向计算式表达式。说不上谁绝对更好,但你必须清楚自己当前在用什么范式。
一旦建立了“JSX是JavaScript表达式”这个认知,写列表渲染时会下意识想到map,写复杂逻辑时会想到先算出一个变量再放进{},而不是到处找指令。这条思维转换,是我在教学设计里排在语法规则之前的。
2. 核心语法规则详解:一条一条说清楚
2.1 根节点:为什么只能有一个最外层标签
JSX表达式必须有一个根级容器,否则编译会直接报错:Adjacent JSX elements must be wrapped in an enclosing tag。
原因是前面说的,一个createElement调用只能生成一个节点。你写了两个并列的<div>,编译后就成了两个函数调用结果,函数却没规定怎么把它们拼起来。
解决办法有三种:
- 包一层普通
<div> - 用React Fragment缩写
<>...</> - 返回一个数组(不推荐,因为需要加key)
实际开发中,我推荐优先用Fragment。多包一层<div>会无端增加DOM层级,很多样式问题就是这么来的。Fragment不会在DOM树上新增任何节点。
function CardList() { return ( <> <Card title="一" /> <Card title="二" /> </> ); }顺便说一个细节:<></>不能设置key,如果是动态循环场景,需要用完整的<Fragment key={item.id}>。这个坑很容易被忽略。
2.2 标签必须闭合,自闭合标签不能省
JSX沿用了XML的严格闭合规则:<div>必须有</div>,<img>必须写成<img />,否则编译会失败。
很多人在HTML里习惯不写<img>的闭合斜杠,搬到JSX里就容易报错。这里给一个排查建议:如果你复制了一段现成的HTML片段到JSX里,第一件事就是检查img、input、br、hr这几个自闭标签有没有补上斜杠。
还有一个大小写问题:JSX里小写标签名会被当成HTML内置元素,大写开头的会被当成组件。所以如果你自定义了一个组件但忘了大写,比如写了<userCard>,编译会把它当作'usercard'这个HTML标签去处理,运行时告诉你这个元素不认识。同样,把<div>写成<Div>,React也会尝试把它当成组件渲染,然后失败。
2.3 花括号里只能放表达式,放不了语句
这是JSX最容易踩出问题的地方。{}里可以放变量、函数调用、三元表达式、数组map的结果,但不能放if、for、switch、变量声明。
我见过不少新手的尝试:
{ if (condition) { return <p>对</p> } }直接编译报错。原因还是那句话:{}最终会作为参数传给createElement(..., children),参数位置只能是一个表达式,不能是语句。
遇到这种情况,常规解法是在JSX外面先把逻辑算好:
let content; if (condition) { content = <p>对</p>; } else { content = <p>错</p>; } return <div>{content}</div>;或者直接用三元表达式和短路运算。要建立一个习惯:JSX内部尽量只做简单判断,复杂逻辑往函数体里挪,这样代码可读性反而更好。
2.4 class要写成className,内联样式是对象
这是初学JSX时出现频次最高的小错误。在JSX里写<div class="box">虽然浏览器能渲染出来,控制台却会有一个warning,而且React官方明确不推荐这么做。
原因很直接:class是JavaScript的保留字,你在JavaScript的表达式上下文里写class,解析器会感到困惑。所以JSX把HTML的class属性改名为className。
内联样式就更有意思了,它要求传一个对象,且属性名是驼峰:
<div style={{ backgroundColor: '#f0f0f0', fontSize: '14px', padding: '8px' }}> 内容 </div>注意这里有两层花括号:外层是JSX表达式语法“我要进入JavaScript空间”,内层是对象字面量。很多新人问我为什么CSS属性要写成驼峰——因为CSS属性名里的连字符在JavaScript对象里不好写,所以React把它们统一处理成了驼峰风格。
实际开发时,动态样式经常配三元表达式:
<div style={{ color: isActive ? '#fff' : '#333' }}> 状态文字 </div>要注意到style对象里的数值属性,如width: 100,React会默认加上px单位。但像opacity、lineHeight这类不会加单位,这个规则不必硬记,遇到特殊场景查一下文档就行。
2.5 属性值:静态字符串和动态表达式
JSX属性的写法分两种场景:
静态场景,直接写字符串:
<img src="/logo.png" alt="站点Logo" />动态场景,用花括号包表达式:
<img src={logoUrl} alt={logoAltText} />还有一个容易忽略的细节:布尔值属性。HTML里有disabled、readonly这类属性,只要标签上出现就生效。在JSX里要遵守“传值就要显式传”的风格:
<button disabled={true}>不可用</button> <button disabled={false}>可用</button>如果你漏写属性值,比如<button disabled>,JSX会把它默认当成{true}。这个行为在组件传参时有时会引发意想不到的Bug,所以要养成显式写布尔值的习惯。
2.6 列表渲染:map方法代替v-for
JSX里渲染列表的思路和Vue模板截然不同。Vue模板用v-for指令,JSX直接用JavaScript原生数组方法:
const users = [ { id: 1, name: '张三' }, { id: 2, name: '李四' } ]; function UserList() { return ( <ul> {users.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ); }map返回的是一个JSX元素数组,React会把数组依次渲染到对应位置。这再次印证了那句“JSX是JavaScript表达式”——数组是表达式,map返回数组,所以一切顺理成章。
key属性是列表渲染必须关心的点。它给React的Diff算法提供身份标识,帮助框架判断哪些元素变化了、哪些可以复用。我见过有人用数组下标当key,短期内没出问题,但一旦列表发生了插入、删除、排序,就会出现状态错乱、渲染位置不对这类诡异Bug。
正确做法是使用业务里真正唯一的ID。如果没有现成ID,可以生成一次稳定ID再存下来,而不是每次渲染都Math.random()。
2.7 条件渲染:短路与三元的细节
JSX的条件渲染一般有三种写法,按场景选择:
- 二元短路:
{visible && <p>显示</p>} - 三元表达式:
{visible ? <p>显示</p> : <p>隐藏</p>} - 提前return:
if (!visible) return null;
用&&时要特别小心一个经典坑:当左边是数字0时,React会渲染出0这个字符。比如:
{count && <p>有数据</p>}如果count是0,页面会莫名其妙显示一个“0”。原因很简单,0 && 表达式的结果是0,而React认为0是有效渲染内容。解决办法是把判断写成布尔值:
{count > 0 && <p>有数据</p>}提前return通常用于权限控制、空状态拦截这类较复杂的逻辑。它不算JSX规则,而是把控制流放在函数体里,JSX内部保持干净,我建议在业务代码里优先用这种。
2.8 注释与转义:JSX区域的边界问题
在JSX里写注释也有自己的一套约定。
写在子节点的注释,需要包在花括号里:
<div> {/* 这是一个注释 */} <p>文字内容</p> </div>如果写在属性附近,那你实际上仍在JSX表达式区块内,按JavaScript注释来写:
const el = /* 注释 */ <div>内容</div>;很多新人想在标签内部直接写<!-- 注释 -->,这在JSX里是行不通的。它会原样输出成HTML注释,虽然浏览器不报错,但不符合React组件的精神。
转义方面,JSX默认会对文本内容里的HTML实体和标签进行转义处理,防止注入攻击。你写{'<p>'},页面会显示字面的<p>,而不是创建一个p标签。这是React的安全机制,不要为了“方便”用dangerouslySetInnerHTML去绕过它,除非你对传入内容有完全的信任。
2.9 核心规则速查表
为了方便备查,我把上面讲到的规则整理成一张表:
| 规则类别 | 正确写法 | 错误写法 | 原因 |
|---|---|---|---|
| 根节点 | 用<>...</>包裹 | 并列多个根元素 | 单个表达式只能返回一个节点 |
| 标签闭合 | <img />、<input /> | <img> | 编译需要确定节点边界 |
| 组件命名 | UserCard | userCard | 小写被视为HTML标签 |
| CSS类名 | className | class | class是JS保留字 |
| 内联样式 | style={{ color: 'red' }} | style="color: red" | 样式是JavaScript对象 |
| 条件渲染 | {count > 0 && <p>x</p>} | {count && <p>x</p>} | 避免渲染数字0 |
| 列表key | 用业务唯一ID | 数组下标 | 帮助Diff识别节点 |
| 注释 | {/* 注释 */} | <!-- 注释 --> | 处于JS表达式区域 |
这张表是我做团队内训时整理出来的,前端基础参差不齐的情况下,大家照着表过一遍,JSX踩坑率明显下降。
3. JSX小练习逐题拆解:照着做就能上手
3.1 练习一:在JSX里渲染变量与简单表达式
目标:理解{}不是装饰,它代表进入JavaScript运行空间。
新建一个组件,定义几个基础变量,然后把它们按不同方式渲染出来。
function BasicRender() { const userName = '小明'; const age = 18; const tags = ['前端', 'React', '篮球']; return ( <div> <h1>用户信息</h1> <p>姓名:{userName}</p> <p>年龄:{age >= 18 ? '成年' : '未成年'}</p> <p>标签数:{tags.length} 个</p> <p> 标签: {tags.join('、')} </p> </div> ); }这个练习的核心价值在于体会“表达式”的边界。userName是变量,age >= 18 ? ... : ...是三元表达式,tags.length是属性访问,tags.join(...)是方法调用。它们在{}里都合法,因为它们都会立刻产生一个值。
你可以试着在{}里放一句const i = 1;,页面马上会报语法错误。看见报错不要慌,这恰好能帮你建立“哪些能放、哪些不能放”的直觉。
运行这个组件,页面上会正确显示用户信息。如果出现“对象不能作为React子元素”的报错,多半是你在{}里直接塞了一个普通对象,比如{ { name: '张三' } }。React允许函数、数组、字符串、数字作为子节点,但普通对象不行。
3.2 练习二:根据登录状态展示不同内容
目标:掌握条件渲染的两种主流写法,并理解它们在可读性上的差异。
定义一个isLoggedIn状态,根据它切换欢迎语和登录按钮。
function LoginStatus() { const isLoggedIn = true; return ( <div> {isLoggedIn ? ( <p>欢迎回来,用户!</p> ) : ( <button>请先登录</button> )} {isLoggedIn && <span>当前在线</span>} </div> ); }三元表达式适合“二选一”的场景——要么显示A,要么显示B。&&适合“单条件展示”的场景——满足条件就显示,不满足就什么都不显示。
我给出的代码里两个都用到了,这样你能直观对比。很多人觉得&&比三元“简洁”,但简洁的前提是语义清晰。如果你的条件里包含数字运算,务必先处理布尔转换,比如{isLoggedIn === true && ...}。
这个练习还有一个扩展建议:把isLoggedIn改成可以从props传入,这样组件就变成受控的,跟实际业务接轨。
3.3 练习三:渲染待办列表,并高亮已完成项
目标:掌握map列表渲染与key的正确用法,同时练习通过动态className和style控制视觉状态。
准备一份待办数据,用map渲染成列表,已完成项加删除线标记。
const todos = [ { id: 't-001', text: '学习JSX语法', done: true }, { id: 't-002', text: '完成小练习', done: false }, { id: 't-003', text: '整理笔记', done: false } ]; function TodoList() { return ( <ul> {todos.map(todo => ( <li key={todo.id} style={{ textDecoration: todo.done ? 'line-through' : 'none', color: todo.done ? '#999' : '#333' }} > {todo.text} {todo.done && '(已完成)'} </li> ))} </ul> ); }key务必放在map直接产生的那个元素上。这里key={todo.id}放在<li>上,是正确的。如果放到了<li>内部的某个子元素上,React依然会警告,而且Diff优化完全失效。
另一个细节:同样一条列表项,textDecoration和color都根据done动态变化。这是实际项目里最常见的动态样式写法。你也可以改用三元拼className:
<li className={todo.done ? 'todo-item done' : 'todo-item'}>两种写法各有适用场景。style适合动态性极强的内联视觉属性,className适合样式较多、需要配合CSS文件的场景。我个人的习惯是:超过两个动态属性时优先考虑className,降低JSX里的噪音。
试着往todos里插入一条新数据,观察key稳定时的渲染表现。如果换成下标key={index},在插入第一条时,视觉可能看不出区别,但如果你加入了复选框交互,就会看到状态错乱。
3.4 练习四:事件绑定与受控输入
目标:理解JSX事件系统的写法,并组装一个小而完整的交互组件。
做一个简单的输入框,点击按钮把内容追加到列表。
import { useState } from 'react'; function AddItemBox() { const [inputValue, setInputValue] = useState(''); const [items, setItems] = useState(['默认项目']); const handleSubmit = () => { if (inputValue.trim() === '') return; setItems([...items, inputValue]); setInputValue(''); }; return ( <div> <input value={inputValue} onChange={event => setInputValue(event.target.value)} placeholder="输入新项目" /> <button onClick={handleSubmit}>添加</button> <ul> {items.map((item, index) => ( <li key={item + '-' + index}>{item}</li> ))} </ul> </div> ); }这个练习涉及内容超出了JSX语法本身,包含状态管理和事件绑定。但它恰好是JSX最常用到的完整场景,所以我把它放在这里当作综合练习。
事件绑定的写法要注意:onClick={handleSubmit}传的是函数引用,而不是调用结果handleSubmit()。如果写成后者,函数会在渲染执行时立刻执行一次,按钮点击反而没反应。这是JSX事件处理高频Bug。
受控输入是指输入框的value和onChange合在一起,让React完全接管输入值。很多初学者困惑“为什么我在框里打字没反应”,多半是写了value={inputValue}却漏了onChange处理。
注意这个练习我在key上借用了item + '-' + index,严格来说这不算稳定ID,在插入和排序场景会有问题。练习里它是可接受的,但到了真实业务请务必用唯一ID。我会更推荐把练习数据改成带ID的对象数组,这样更贴近实际。
4. 常见问题与排查技巧实录
4.1 报错信息逐个拆:从翻译到解法
JSX报错通常比较直接,但新手容易对着英文报错蒙圈。我挑几个高频的记录下来。
Adjacent JSX elements must be wrapped in an enclosing tag:并列的JSX元素没有被包在同一个根节点里。检查return里是否有两个根级<div>,用Fragment或外层容器包起来。
Unexpected token:通常是某处花括号、括号不配对,或是在{}里写了语句,比如if、for。先看代码里JSX区块和JavaScript区块的边界是不是乱了。
'div' is not defined/'yourComponent' is not defined:见到这个报错第一反应是组件名拼写或导入问题。再确认一下是否以大写字母开头,是否从正确路径导入。
Each child in a list should have a unique key prop:列表渲染没有设置key,或key重复。找出map返回的JSX元素,给它补一个稳定唯一标识。
Objects are not valid as a React child:在{}里直接放了普通对象,需要用数组、字符串、数字或JSX元素替换。
Cannot read properties of undefined (reading 'map'):多半是数据还没加载完成就尝试map,此时数组是undefined。解法是在map前加一层空值兜底,比如(data || []).map(...)。
4.2 编译链观察法:把Babel当作调试工具
遇到拿不准的JSX语法时,与其猜测,不如直接看编译结果。浏览器打开 Babel官网的在线编译工具 ,在左侧输入JSX代码,右侧就会实时显示编译后的JavaScript函数调用。
这个方法最适合验证前面讲到的“JSX本质是createElement调用”这句话。举个例子:
<div className="box" onClick={handleClick}> 内容 {name} </div>编译后,你会在右侧看到React.createElement("div", { className: "box", onClick: handleClick }, "内容 ", name)。
这样一来,每个属性如何映射、子节点如何排列就一目了然了。遇到奇怪的报错,我按照这个思路多跑几遍,通常很快能定位。
前端工程里最终负责这个编译转化的是各构建工具内置的@babel/preset-react(或者Vite与SWC的相应插件)。理解了产物形态,你在排查“为什么页面不支持JSX”时会心里有底。
4.3 排错三板斧:JSX运行不正常的通用排查思路
分享一套我在实际排查中反复用到的固定流程。
先看编译期提示。终端或浏览器控制台出现的语法错误,优先处理。这类提示通常精确到行和列,定位能力最强。
再看控制台运行时警告。比如key警告、class名称警告。它们不影响渲染,但是隐患,应该逐一处理而不是忽略。我见过太多团队Warning堆成山,最后真正出Bug时根本找不到有效线索。
最后看渲染结果。如果页面渲染出来但不符合预期,就在JSX表达式内部插入console.log,确认数据形态对不对。重点检查数组是否为空、对象属性名是否拼错、truam运算结果是否符合预期。
排查时不要东一榔头西一棒槌。我曾经遇到一个同事排查半小时,结果是className写成了class,控制台其实早就告警了。所以顺序很重要,先看提示,再打日志,最后上编辑器调试,能省大量无效时间。
4.4 一个被低估的语法陷阱:JSX里的空行与分号
很多新人写JSX时喜欢在标签之间敲很多空行,以为可以随便排版。实际上JSX的空格处理有自己的规则。
在JSX里,写在标签之间的换行和缩进默认会被忽略,但文本与标签紧挨着时,空格可能不被合并。比如:
<div> 前 <span>中</span> 后 </div>这里“前”和“后”之间实际渲染出的效果,可能和你预期的不太一样。解决办法是显式拼接:{'前 '}{'后 '},或者使用CSS的white-space控制。
还有一个现象,JSX里你写了分号也经常会被忽略,因为编译后它本来就在JavaScript环境里,分号有无不影响逻辑。不过为了团队代码风格统一,建议遵循项目里的lint规则,不要混用。
4.5 团队实战里最容易出现的三个JSX代码坏味道
最后补充几个评审代码时经常遇到的坏味道,纯属经验之谈。
第一个是过长的JSX树。一个render里塞了七八层嵌套结构,读起来极其痛苦。解决办法是拆组件或拆局部变量,让JSX树尽量扁平。推荐把某块子结构抽成一个变量再放进{}:
const header = ( <div className="card-header"> <h3>{title}</h3> <span>{subtitle}</span> </div> ); return ( <div className="card">{header}</div> );第二个是滥用&&短路,前面提过的数字0问题就是典型。代码评审时我会要求统一用布尔比较,或者显式转成布尔值。
第三个是内联样式堆成山。JSX里写style对象本身没错,但如果style对象里有十几行属性,就该考虑改成CSS类或者抽一个样式变量。否则组件逻辑和视觉混在一起,维护成本呈指数上升。
最后再分享一个我压箱底的习惯:每当遇到JSX相关的报错或诡异行为,我会先默念一遍“花括号里就是另一个世界,标签里是静态结构”。这句话从我带新人到现在一直有效。JSX说穿了就是“JavaScript为主、标签为辅”的组合,你越早接受它是代码而不是HTML,越不容易被那些看起来很像、其实不兼容HTML的写法绊住。
这几个小练习如果你能动手跑一遍,再顺手把文末检查清单用在手头项目里,JSX语法这块基本就稳了。后续写组件、写Hook、上TypeScript,会发现很多所谓的“新知识”,其实都是从这棵名为JSX的树上长出来的枝叶。