1. 为什么先啃这三块骨头
React 从 2013 年诞生到现在,生态已经膨胀到令人头皮发麻的程度——路由有 React Router,状态管理有 Redux、Zustand、Jotai,数据请求有 React Query、SWR,样式方案更是多到能打一架。但无论这些工具怎么迭代,底层那几块基石从来没变过:语法、组件、JSX。这三样东西是 React 世界的"母语",不把它们揉碎了嚼烂了,后面学什么都是在沙地上盖楼。
我见过太多人一上来就冲进 Redux 和 Next.js,结果连函数组件和类组件的区别都说不清楚,JSX 里写个className都要犹豫半天是不是拼错了。这种学法最致命的不是"不会",而是"不知道自己不会"——业务代码能跑,但换个场景就懵,性能优化无从下手,bug 排查全凭玄学。
这篇文章我想用最接地气的方式,把 React 的语法底子、组件机制和 JSX 规则摊开来聊一遍。里面不会有花哨的炫技,也不会有"十分钟精通React"这种鬼话,都是我在实际项目里反复踩过的坑和沉淀下来的套路。适合刚接触 React 的前端初学者读,也适合那些用了很久 React 但总觉得根基不稳的同学回头查漏补缺。
2. 语法基础:React 的“方言”到底特殊在哪
React 的语法大框架就是 JavaScript,它没有自创一套语言。但为什么很多人初看 React 代码会觉得"不像 JS"?因为 React 代码里混着两种特殊的东西:JSX 语法和组件专属的调用方式。理解了这两个点,React 代码就不再是"天书",而是一套有规律可循的表达。
2.1 先搞清楚 JSX 不是 HTML,也不是模板字符串
JSX 的全称是 JavaScript XML,它是在 JavaScript 文件里直接写"长得像 HTML"的标记语言。很多新手第一眼看到 JSX,会觉得这就是 HTML 嵌在 JS 里,于是下意识地用 HTML 的规则去理解它,结果处处碰壁。
最典型的差别在三个地方:
第一,HTML 里class是定义样式的,JSX 里必须写成className。因为 JSX 本质是 JavaScript,而class在 JS 里是关键字,直接冲突了,React 就改用 DOM API 里的className这个叫法。我在带着几个刚入门的朋友做项目时,他们几乎都在这上面栽过一跤,控制台报错说 "Invalid DOM propertyclass",然后一脸茫然地来问我。
第二,HTML 标签可以不闭合,比如<img>、<input>、<br>,浏览器会自动容忍。JSX 里一律要求严格闭合,<img />、<input />这种自闭合写法才是合法的。这个规则看着小,实际写起来经常漏,尤其是从 HTML 模板转到 React 项目的前几天,几乎人人都会因为这个编译报错。
第三,HTML 里的注释是<!-- -->,JSX 里注释得写成{/* */}。因为这个大括号是把表达式嵌入 JSX 的语法标记,/* */才是 JS 的注释写法。你要是直接在 JSX 里写 HTML 风格的注释,React 会把它当作文本渲染到页面上,虽然不报错,但页面会莫名多出一段注释文字,这种隐性问题比报错更坑。
2.2 大括号是 JSX 和 JavaScript 之间的“传送门”
JSX 里有一个核心规则:凡是动态的东西,都要用{}包起来。这个{}就是 JSX 世界和 JS 世界之间的传送门,里面可以写任何 JavaScript 表达式。
来看一个实际例子:
function UserCard({ user }) { const greeting = `你好,${user.name}`; return ( <div className="card"> <h2>{greeting}</h2> <p>{user.age >= 18 ? "成年用户" : "未成年用户"}</p> <span>{user.tags.join(" / ")}</span> </div> ); }这段代码里,greeting是变量,三元表达式是条件判断,join是数组方法调用,它们全部都被包在大括号里。这个传送门特别强大,因为它不只是"填充值",还支持任何 JS 表达式,包括函数调用、数组方法链、逻辑运算。
但这里有个新手特别容易混淆的地方:{}里只能放"表达式",不能放"语句"。比如if、for、switch这些都不能直接写在大括号里。原因很简单,表达式有返回值,语句没有。1 + 1是表达式,它会算出 2;if (x > 0) { ... }是语句,它不产生值。JSX 渲染需要的是"值",所以语句在里面毫无意义。你可以在大括号外面用if提前算好结果,再把变量传进去,或者直接用三元表达式、&&短路运算这些"表达式化的逻辑"来搞定条件渲染。
顺便说一个我刚用 React 时踩过的坑:你以为{}里写个console.log('hello')能打印日志,确实能打印,但这行的返回值是undefined,所以 React 会把它当作undefined渲染——实际上 React 对undefined、null、false都是直接忽略不渲染的,所以页面不会出错,但你也什么都看不到。
2.3 一切皆“表达式”的思考方式
学会了{}传送门,下一个关键认知是:用表达式思维代替语句思维。这是从"会写 React"到"写好 React"的分水岭。
比如你有一个数组,要根据长度显示不同文案:
// 不推荐:先在外面算好 function CommentList({ comments }) { let title; if (comments.length === 0) { title = "暂无评论"; } else { title = `共 ${comments.length} 条评论`; } return <h3>{title}</h3>; } // 推荐:用表达式直接搞定 function CommentList({ comments }) { return <h3>{comments.length === 0 ? "暂无评论" : `共 ${comments.length} 条评论`}</h3>; }我也不是让你所有逻辑都硬塞进 JSX,那也会把代码弄得没法看。实际项目里常见的是混合策略:复杂逻辑抽成函数或变量,简单逻辑用表达式。核心原则是:你写的每一行 React 代码,都要时刻意识到它是在"计算一个值",还是在"执行一个动作"。前者是用来渲染的,后者是用来响应事件的。搞混这两者,代码就会越写越拧巴。
3. 组件机制:从函数到 UI 的思维方式转变
组件是 React 的核心抽象,也是我最想提醒初学者认真对待的一个概念。很多人在学习时只是机械地跟着教程写function App() { return <div>...,但完全没意识到,这个平平无奇的函数,背后是一整套"以函数为界、以输入输出为轴"的设计哲学。
3.1 为什么说“组件就是函数”
React 的组件,本质上就是一个函数。它的输入是props(外加内部状态state),输出是一段 JSX(也就是 UI 描述)。
如果你自己写过一些规模稍大的原生 JavaScript 项目,应该能感受到那种痛点:DOM 操作散落各处,代码的"数据逻辑"和"界面逻辑"纠缠在一起,改一个需求要小心翼翼点好几个文件。而 React 的组件化思路,就是把数据到界面的映射关系收拢进一个个独立的函数里。你用这个组件时,只需要关心两件事:传什么进去(props),得到什么出来(UI)。
用生活化的类比来说:React 组件就像一台咖啡机。你往里面放咖啡豆和水(props),按下按钮,它稳定地给你倒出一杯咖啡(UI)。你不用关心机器内部怎么加热、怎么研磨,只要接口不变,内部随便改。组件化拆分的核心价值,就是这个"接口稳定、内部自治"的封装性。
但这里有一个关键认知要纠正:组件内部绝不是"一团死水"。React 组件有自己的生命周期,有内部状态。也就是说,它接收外部输入的同时,还能自己维护一些"随时间变化的东西"。这就是useState这类 Hook 存在的意义。
看这个实际场景:做一个点赞按钮,父组件传过来初始点赞数,按钮内部自己维护"用户是否点赞"和"显示的数字"。点赞数会变,这个"变"是组件内部的状态,不是父组件的东西。这就是函数组件里useState最常见的用法——外部给初始值,内部负责后续的动态演化。
3.2 函数组件 vs 类组件:选哪个,为什么
组件有两种写法,函数组件和类组件:
// 函数组件(现代写法的绝对主力) function Welcome({ name }) { return <h1>你好,{name}</h1>; } // 类组件(老项目里还能见到) class Welcome extends React.Component { render() { return <h1>你好,{this.props.name}</h1>; } }如果你现在开始学 React,我不建议你在类组件上投入太多精力,直接学函数组件 + Hooks 就好。原因有几个方面:
第一,函数组件代码更短,心智负担更小。同样的功能,类组件要写class关键字、extends、constructor、super(props)、this绑定,一套组合拳下来,简单功能也会被写得冗长。函数组件就一个函数,参数进来,返回值出去,清爽利落。
第二,类组件的this是个大坑。方法内部要用this.state、this.props,还得记得绑定事件处理函数,忘了绑就报undefined,这种错误在项目里见得太多了。函数组件靠闭包捕获 props 和 state,天然没这问题。
第三,React 官方对函数组件 + Hooks 的态度很明确,未来所有新功能都在这个方向上推进。类组件虽然还能用,但更像一个历史遗留的兼容层了。
不过话说回来,如果你维护的是老项目,类组件必须能看懂。核心就是知道this.state管内部状态、this.props管外部输入、render()方法返回 UI,在这个基础上改点小需求没什么太大障碍。
3.3 Props:数据从父到子的单向河流
组件之间怎么传数据?答案是props。Props 是父组件传给子组件的数据,在子组件里是只读的。
这个"只读"设计非常关键。React 的数据流是单向的——数据从父组件流向子组件,子组件不能直接修改父组件的 props。如果你在子组件里改了 props,React 在开发模式下会有警告,而且这种改动不会触发父组件更新,最后的结果就是界面表现混乱,数据源失去唯一性。
举个例子:父组件有个用户列表,渲染UserCard子组件。子组件收到的user对象是父组件数据的一份引用,子组件里面不能写user.name = '新名字',正确的做法是:子组件把"用户想改名字"这个意图向上抛(调用父组件传下来的回调函数),让父组件去改数据源头。这套"数据往下流,事件往上走"的模式,是整个 React 组件通信的纲领。
props 不光是数据,也可以是函数。父组件传一个onDelete回调给子组件,子组件在删除按钮的点击事件里调用它。这个模式就是"子组件想要改变父组件数据时,唯一的合法通道"。我第一次带项目的时候就发现,新人最容易犯的错就是跨层直接修改数据,把单向数据流搞成"双向奔赴",代码复杂度瞬间爆炸。
4. 事件处理:React 和原生 DOM 的“翻译层”
React 不是直接给 DOM 元素绑定事件,它有一套自己的事件系统。这套系统的存在,让跨浏览器表现一致成为可能,也让性能优化有了更多下沉空间。
4.1 用驼峰命名替代小写事件名
原生 HTML 里,点击事件是onclick,React 里是onClick,语法上还有更多地方要注意:
// 原生写法 <button onclick="handleClick()">点我</button> // React 写法 <button onClick={handleClick}>点我</button>留意两个区别:事件名是驼峰(onClick而非onclick),事件值是一对大括号包着函数引用(不是字符串调用的形式)。
很多教程都只给这么个对比图就完事了,但我觉得真正值得展开的是:这里的大括号为什么是"函数本身"而不是"函数调用"。如果你写onClick={handleClick()},React 会在渲染时立刻执行这个函数,而不是等点击时才执行。渲染页面时运行一个本应在点击时运行的函数,轻则逻辑错乱,重则死循环报错。正确的写法是onClick={handleClick},就是传引用、不调用。
如果你需要给事件处理函数传参,不能直接onClick={handleClick(id)},得用箭头函数包一层:
<button onClick={() => handleClick(id)}>删除</button>这种写法是每次渲染都新建一个函数,性能上有一点额外开销,但在绝大多数业务场景里微不足道,不用担心。箭头函数的优势是捕获当前渲染轮的 props 和 state,语义清晰、不容易踩坑。
4.2 事件对象和合成事件
React 给每个事件处理函数传入的参数,是一个"合成事件对象"(SyntheticEvent),它不是原生 DOM 事件,但把常用属性和方法都封装进来了。比如event.preventDefault()、event.stopPropagation()、event.target.value,这些原生 API 的常用操作在合成事件里用法一致。
有一个重点必须记住:React 的合成事件是复用的。事件处理函数执行完毕后,合成事件对象会被回收重置。如果你在异步代码里(比如setTimeout、fetch回调)使用这个事件对象,会拿到已经被重置的数据,得到看似莫名其妙的 bug。
假如你写了一个输入框,要在防抖 500ms 后把值提交到后台:
function SearchInput() { const handleChange = (e) => { // 这里存的是值,不是事件对象 const value = e.target.value; setTimeout(() => { // 错误示范:直接用 e.target.value,此时 e 已被重置 submitToAPI(e.target.value); }, 500); }; return <input onChange={handleChange} placeholder="搜索关键词" />; }正确做法是第一行先把e.target.value提取到局部变量里,再在异步代码中用这个值。这不是什么高级技巧,但几乎所有从 jQuery 时代转过来的人都在这个坑里栽过跟头。
4.3 阻止默认行为和冒泡的正确姿势
在表单提交、链接跳转这些场景里,需要阻止浏览器默认行为。React 里的写法和原生 JS 类似,但有一些微妙的差别。
<form onSubmit={(e) => { e.preventDefault(); // 自行处理表单逻辑,比如发送请求 }}> <input type="text" name="keyword" /> <button type="submit">提交</button> </form>这里我特别想提一个很多人忽视的点:e.preventDefault()和e.stopPropagation()的生效范围。React 的合成事件绑定在根容器上,stopPropagation()阻止的是同层级 React 事件之间的冒泡。如果你混用原生事件监听(比如通过ref直接addEventListener),React 的合成事件和原生事件的冒泡顺序会有差异,有点绕。我的建议是:一个项目里尽量统一用 React 的合成事件体系,不要混着来,除非你有非常明确的性能优化目标需要原生监听。
5. 条件渲染与列表渲染:React 中最常用的两大“招式”
组件要干活的日常场景,无非就是"根据条件显示不同内容"和"把数据列表渲染成 UI"。这两块用熟了,基本就可以应付 80% 的业务页面了。
5.1 条件渲染的三板斧
React 没有像 Vue 那样内置v-if指令,它的条件渲染完全靠 JS 表达式实现。常用的有这三种:
第一,三元表达式。
{isLoggedIn ? <UserDashboard /> : <LoginButton />}这是最直白的写法,二选一场景优先推荐。可读性最好,语义清晰。
第二,逻辑与&&短路。
{unreadCount > 0 && <span className="badge">{unreadCount}</span>}适合"满足条件才渲染,不满足就不渲染"的场景。它的原理很简单:unreadCount > 0为真时,&&直接返回后面的 JSX;为假时,返回false,React 忽略它,什么都不渲染。
这里有个经典小坑:如果在&&前面放一个数字,比如{count && <span>...</span>},当count是 0 时,表达式的值是 0,React 会老老实实把 0 渲染到页面上。你本意是"count 大于 0 才显示",结果页面上莫名多个 0。因为 0 在布尔判断里是 false,但 React 渲染时只对false、null、undefined做忽略,0 是会显示出来的。这就是为什么很多代码规范要求&&前面必须写成布尔表达式的原因。
第三,立即执行函数或者提前变量赋值。
function StatusBanner({ status }) { if (status === "success") { return <div className="success">操作成功</div>; } if (status === "error") { return <div className="error">操作失败</div>; } return <div className="info">请稍候</div>; }多个分支条件时,if/else是逃不掉的。这种结构清清爽爽,比一堆嵌套三元表达式容易维护得多。我见过有人在 JSX 里写三层嵌套三元表达式,缩进之后跟个金字塔一样,读代码的人血压直接拉满。遇到这种场景,拆出来写return往往更舒服。
5.2 列表渲染的 key 到底是个什么说法
列表渲染用map方法:
function TaskList({ tasks }) { return ( <ul> {tasks.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> ); }key是 React 用来追踪列表项身份的标识。列表项有增删、排序时,React 靠 key 判断哪些元素是同一项、哪些是新建的、哪些该被移除。如果没有 key 或者用数组索引当 key,React 只能通过位置去猜测,性能会变差,有时还会引发渲染错乱。
我看过太多人直接把key={index}一写就完事,而且完全意识不到有什么不对。我认真说一下:如果列表是不变的、纯展示型数据,用索引当 key 问题不大;但如果列表项可以被增删、移动、或者内部有本地状态,用索引会引发邪门 bug。
用个具体场景举例:一个可勾选的待办列表,你用索引做 key,勾选了第一项,后来又删了第一项,React 会基于"位置"做 diff,第二项的内容被挪到第一项的位置,但它的勾选状态是基于"上一个 key"遗留的,于是界面出现勾选错乱。这件事我多年前第一次遇到时排查了好久,最后发现就是 key 的问题。从那以后,我写代码时 key 只用数据自带的 id、或者专门的标识字段,绝不贪图省事用数组下标。
5.3 渲染大列表时的一个性能思路
如果列表特别长(比如几百上千条),每次状态变化整个列表都重新渲染,性能会明显拉胯。这时候可以考虑把列表项封装成独立组件,配合memo做浅比较优化。
const TaskItem = memo(function TaskItem({ task, onToggle }) { return ( <li> <input type="checkbox" checked={task.done} onChange={() => onToggle(task.id)} /> {task.title} </li> ); }); // 父组件里 const handleToggle = useCallback((id) => { setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) ); }, []); <ul> {tasks.map((task) => ( <TaskItem key={task.id} task={task} onToggle={handleToggle} /> ))} </ul>memo的作用是:如果传入的props和上次渲染时一样,就跳过这次重新渲染。配合useCallback保持回调函数的引用稳定,许多列表项就不需要白白再做一次 diff。这套组合拳是 React 性能优化里最常见也最实用的一招,但初学者可以先有个印象,不用急着全上。需要注意:memo是浅比较,如果 props 里有对象或数组,每次父组件渲染都会产生新引用,memo就失效了。所以真正稳定传下去的 props,尽量用基础类型,或者配合好useMemo。
6. 组件通信与状态提升:别再让数据“无家可归”
组件多了以后,最让人头疼的是数据放哪、怎么流。这里有几个重要的套路,它们是 React 架构设计的地基。
6.1 状态提升:把数据放到最近的“公共祖先”
如果两个组件需要共享同一份数据,正确的做法不是让这两个组件自己各存一份副本,而是把数据放到它们共同的父组件里,通过 props 分发下去。这就是所谓的"状态提升"。
举个例子:一个搜索页面,搜索框组件和搜索结果列表组件都需要关键词这份数据。如果两个组件各存各的,就会出现一个改了另一个不知道的同步问题。把关键词存在父组件里,搜索框通过 props 拿到值、通过回调函数上报修改意图,列表组件通过 props 拿到最新的关键词去过滤,这就是标准的状态提升。
状态提升的核心是:数据归属权越靠上,组件之间的耦合越小。但也不要无脑把所有数据都提到顶层,那样会让顶层组件变得臃肿。判断标准很简单——只有多个子组件真正共享的数据才需要提升,组件私有的 UI 状态(下拉框开合、弹窗是否显示)就该留在组件内部。
6.2 Context:打破层层下达的“行政体系”
props 逐层传递在小项目里没问题,但组件层级深了以后,中间层组件可能完全不关心某个数据,却要帮忙一层层转发。这种"透传"写起来烦人,改起来更烦人。这时候就可以用 Context 来跨层级传递数据。
const ThemeContext = React.createContext("light"); function App() { return ( <ThemeContext.Provider value="dark"> <Toolbar /> </ThemeContext.Provider> ); } function Toolbar() { return ( <div> <ThemedButton /> </div> ); } function ThemedButton() { const theme = useContext(ThemeContext); return <button className={`btn-${theme}`}>Themed Button</button>; }这个例子里,ThemedButton不管身在何层,直接拿到theme值,中间组件完全不碰这份数据。Context 适合放那些"全局性"的数据,比如当前登录用户、主题样式、语言包等。
但这里必须泼盆冷水:Context 不要滥用。它会让组件的复用性变差,因为一旦用了useContext,这个组件就依赖某个 Provider 存在。你把一个"主题感知按钮"放到别的项目里,得连同 ThemeProvider 一起搬。很多团队把 Context 当状态管理工具用,结果一个全局 Context 里塞了几十个字段,任何字段变化都会导致所有消费组件重新渲染,性能问题随之而来。状态管理是该用组件状态、还是 Context、还是第三方库(如 Zustand/Redux),得按场景权衡。
6.3 组合优于继承:组件复用的正确姿势
初学者看了不少组件教程后,容易冒出"组件能用继承来复用吗"的疑问。答案是:不需要,也不能。React 官方明说组件复用推荐用组合,而不是继承。
组合的模式说白了就是嵌套:一个组件内部可以用另一个组件。React 组件天然的树状结构让组合变得极其自然。比如:
function Card({ title, children }) { return ( <div className="card"> <h3>{title}</h3> <div className="card-body">{children}</div> </div> ); } function UserProfile({ user }) { return ( <Card title={`用户:${user.name}`}> <p>邮箱:{user.email}</p> <p>城市:{user.city}</p> </Card> ); }children就是父组件往子组件里传的"插槽位"。这种模式比继承灵活得多,因为它体现的是"拥有"和"包含"的关系,而不是"是"和"等于"的关系。我见过有人为了让一个按钮组件支持不同风格,给它写了一大堆配置项 props,结果每个 props 对应一种渲染分支,代码异常臃肿。其实用组合的思路,把不同风格的按钮直接作为子组件传入,反而更清晰。
7. JSX 进阶规则:那些你逃避不了的细节
基础打完之后,还有几个 JSX 的细节值得单独展开。这些细节看似琐碎,却在项目里长期影响代码质量和排错效率。
7.1 一个根节点与 Fragment
React 组件的返回值必须有一个根节点包裹。早期 React 版本里,你没法直接返回多个并列的 JSX 元素,必须在外面包一层div或span。这导致一个问题:每次为了结构正确,页面 DOM 里多出一层无意义的嵌套。
后来出了 Fragment 的写法,就可以在不增加 DOM 节点的情况下包裹多个元素:
// 错误:并列多个根节点 return ( <h1>标题</h1> <p>段落</p> ); // 正确:用 Fragment 包裹 return ( <React.Fragment> <h1>标题</h1> <p>段落</p> </React.Fragment> ); // 简写形式:Fragment 还有语法糖版本 return ( <> <h1>标题</h1> <p>段落</p> </> );<>和</>是 Fragment 的缩写,写起来比完整写法顺手多了。唯一要注意的是,如果需要给 Fragment 传 key(比如在列表循环中使用的话),就必须写完整的React.Fragment,简写形式不支持 key。
7.2 JSX 中的样式:行内样式不是字符串
原生 HTML 写内联样式是style="color: red; font-size: 14px",JSX 里则必须写成对象:
<div style={{ color: "red", fontSize: 14, marginTop: "8px" }}> 内容 </div>注意最外层的大括号是 JSX 的传送门,里面那个对象字面量才是样式对象。CSS 属性名要遵循驼峰命名:font-size写成fontSize,margin-top写成marginTop。数值不带单位时,React 会自动加上px(某些属性如lineHeight、opacity、zIndex除外,它们本身就是无单位属性)。
行内样式虽然方便,但会带来性能问题,组件反复渲染时每次都要创建新对象。建议把静态样式抽到 CSS 文件里,行内样式只留给动态计算的值(比如根据状态改变的颜色、宽度)。
7.3 受控组件 vs 非受控组件:表单的两种哲学
表单元素在 React 里是个特别的存在。默认情况下,input、textarea、select这些元素用户输入会直接改变 DOM 值,React 不感知。如果我们让 React 的 state 来驱动这些元素的值,就会变成"受控组件"。
function LoginForm() { const [username, setUsername] = useState(""); return ( <div> <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} /> <p>当前输入:{username}</p> </div> ); }这个例子里,value是 state 的值,onChange里更新 state,state 变化触发重新渲染,渲染又回填到value。这个闭环让"用户输入"和"页面显示"永远同步。你是用 state 驱动表单的,所以可以随时对输入值做校验、转换、联动处理。React 官方的推荐是大多数场景都用受控组件,这也是我日常项目里的默认选择。
非受控组件则是让 DOM 自己管理输入值,React 只负责在需要时取个快照:
function SimpleForm() { const inputRef = useRef(null); const handleSubmit = (e) => { e.preventDefault(); alert(`输入内容:${inputRef.current.value}`); }; return ( <form onSubmit={handleSubmit}> <input ref={inputRef} defaultValue="预填内容" /> <button type="submit">提交</button> </form> ); }非受控组件适合极简场景:你不需要实时知道输入值,只要在提交那一刻取一下就行。用ref直接访问 DOM 节点,省心省事。但它放弃了 React 对数据的掌控力,所以功能一复杂起来就会捉襟见肘。我个人的经验是:默认受控,非受控只在"你非常确定这个值的实时变化不重要"时才用。
7.4 转义、 dangerouslySetInnerHTML 与安全问题
JSX 默认会对所有文本内容做转义,这是 React 在安全设计上的一个重要防线。你写{someString},即使这个字符串里包含<script>标签,React 也会把它输出为纯文本,不会执行。
但有些场景确实需要渲染 HTML 字符串,比如后台返回的一段富文本。这时 React 提供了dangerouslySetInnerHTML这个接口:
<div dangerouslySetInnerHTML={{ __html: htmlString }} />这个 API 的名字就自带警告,它是"强行关掉 React 的安全防护"的意思。用之前务必确认这个 HTML 是你自己生成的、或者经过了严格的消毒处理。我处理过一个线上问题,就是后台返回的富文本里有一小块带onerror的<img>攻击代码,前端用dangerouslySetInnerHTML直接渲染,结果脚本在用户浏览器里执行,一顿操作猛如虎。从那以后,凡是用户能编辑、能上传的文本,走这个 API 前都先过一遍白名单过滤。
8. 常见报错与排查技巧:React 开发者的“避坑手册”
没有哪个 React 开发者敢说自己从来没遇到过报错。下面这几个是实践里出现频率最高的,我把排查思路和解决要点整理在了一起。
8.1 “Adjacent JSX elements must be wrapped in an enclosing tag”
这个报错通常在两个并列的 JSX 元素没有用根节点包裹时出现,是初学者的"初次见面礼"。
排查思路很简单,看报错里指出的行号,找到那个 return 下面的并列元素,用 Fragment 或div包裹即可。但我有一次排查这类报错花了半小时,原因是某个组件 return 里用了注释,我把注释写在 JSX 外面了,导致 React 解析时把注释也算成一个"节点",于是出现了并列结构。注释的正确位置是在大括号{/* */}里面,这个问题视觉上很隐蔽,要特别小心。
8.2 “Uncaught TypeError: Cannot read properties of undefined (reading 'map')”
这个报错十个里有八个和异步数据有关:渲染时数据还没到,data.map就执行了,而data还是undefined。
比如刚请求接口时,数据字段是空的:
function ProductList({ products }) { return ( <ul> {products.map((p) => ( <li>{p.name}</li> ))} </ul> ); }如果父组件在接口返回前把products传成undefined,就会翻车。我自己的排查习惯是先确认数据源的初始状态,然后用显式兜底:
{products?.map(...) // 可选链 // 或 {(products || []).map(...)}两种都可以,前一种更简洁,后一种语义上更明确。对于复杂的异步页面,我还会在数据未就绪时渲染一个 loading 占位,用户体验更好。
8.3 “Each child in a list should have a unique key prop”
这个报错 React 几乎是用"把饭喂到嘴边"的方式在调侃你。它说的就是列表渲染缺少 key 或者 key 重复。最常见的成因有两种:忘了写 key;或者 key 用了数组索引,而列表中间发生过删除和插入,导致索引对应关系错乱。
看报错时不要只看是哪一行在 map,要检查 map 出来后每个顶层元素有没有 key。如果你 map 里返回的是一个组件实例,key 要写在这个组件实例上,不是写在它的子元素上。
8.4 状态更新了,页面不变
这是我收到的求助里排名前三的问题。最常见的成因是直接修改了对象或数组,没有返回一个新引用。
// 错误:直接修改原对象,React 感知不到 setUser((prev) => { prev.name = "新的名字"; return prev; }); // 正确:返回一个新对象 setUser((prev) => ({ ...prev, name: "新的名字" }));React 的 state 更新靠的是"引用比较",只要引用没变,它就认为数据没变,不重新渲染。所以凡是涉及到对象或数组更新,一定要记住"创建新副本"这个原则。数组的常见修改方式也需要按这个原则来:新增用[...arr, item],删除用arr.filter(...),修改用arr.map(...)。刚开始不习惯,多踩几次坑就能牢记在心。
8.5 useEffect 里的死循环
新手开始接触useEffect后,会出现一种诡异现象:页面疯狂刷新或接口疯狂请求。常见原因是在useEffect的依赖数组里传入了对象、数组等引用类型,而这些引用每次渲染都会变化,于是 effect 每次渲染都执行。
function Profile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { fetchUser(userId).then(setUser); // 如果依赖项是 user(对象),就会无限循环 }, [userId]); return <div>{user ? user.name : "加载中"}</div>; }排查思路是:看 effect 内读取的所有外部变量,确认依赖数组里列出的是"稳定的、基础类型的值"。如果确实需要在组件里定义一个依赖的对象,可以用useMemo或useCallback稳定它的引用。这里面涉及 React 渲染和副作用时机的关系,理解透了以后,React 的状态逻辑会顺畅很多。
9. 一套拿来即用的 React 项目代码组织建议
学完语法和组件之后,下一个实际问题是:一个真实的 React 项目该怎么组织代码,才能既好读又好改。
9.1 目录结构的一个推荐分层
很多初学者喜欢把所有组件堆在一个文件夹里,文件多了以后就是一场灾难。我长期使用后觉得合理的一种分层思路是:
src/ components/ # 通用可复用组件 Button/ index.jsx style.css Modal/ index.jsx features/ # 业务功能模块 auth/ LoginForm.jsx authApi.js useAuth.js profile/ ProfileCard.jsx hooks/ # 自定义 Hook useDebounce.js useLocalStorage.js utils/ # 纯工具函数 format.js validate.js核心原则是:通用组件和业务组件分开,每个功能模块自己内部闭环(组件、API、逻辑都在一起)。这样要找人改登录功能时,直接进features/auth/,不用满项目找文件。我并不是说这是唯一正确的结构,但它确实帮我避免了多次"文件找不到、不敢动"的尴尬时刻。
9.2 组件编写的一个小清单
写任何一个组件前,先问自己几个问题,可以让代码更干净:
- 这个组件的职责是否单一?如果一个组件既负责展示用户信息,又负责处理复杂的导出逻辑,拆分可能更好。
- props 是否有默认值?用
defaultProps或在解构时赋值默认值,避免 undefined 蔓延。 - 数据获取和业务逻辑是否要和 UI 分离?如果是,自定义 Hook 可能是更合适的容器。
- 有没有多余的中间状态?如果某些状态能用现有 props 派生出来,就不要额外存 state。
比如,一个显示全名的组件,与其存firstName + lastName拼接后的字符串,不如直接由 props 里的两个字段派生出来。React 哲学里很重要的一条就是"最小化状态",凡是能算出来的,就不要存。
9.3 样式方案怎么选
这个真叫"百花齐放":CSS Modules、Tailwind CSS、styled-components、Sass、普通 CSS。对新手来说,我的建议是别在样式方案上纠结太久。无论是公司项目还是个人项目,先选一个团队已经用的或者社区最主流的方案,把精力放在组件和逻辑上。样式方案的迁移成本相对可控,逻辑混乱才是真正的高成本。
Tailwind 现在很火,上手快,样式内聚在类名里,适合快速迭代和原型开发;CSS Modules 更贴近传统 CSS 思维,适合需要细粒度控制的场景;styled-components 把样式写到 JS 里,适合对主题化需求强的项目。就个人体验而言,中小型项目用 Tailwind 或 CSS Modules 都很顺手,大型项目有设计系统的话,styled-components 或类似方案往往更灵活。千万别每天换方案,朝着"写完业务逻辑、样式不失控"这个朴素的指标前进就行。
10. 走向进阶:从“会用”到“会设计”的桥梁
基础语法、组件机制、JSX 规则都过了一遍,接下来不是学更多 API 的时刻,而是需要你建立"设计意识"的时刻。React 的上手曲线前端很平缓,但中后段会突然陡峭起来,这个陡峭感和 API 数量关系不大,主要是心智模型问题。
10.1 从"对的状态管理"到"清晰的状态模型"
同样是写一个购物车,初学者能跑,资深者能撑住后续三个月的新需求。差别在哪?状态建模。
每次需要用useState时,先问:这个状态和别的状态是什么关系?是独立的,还是某个状态的派生值?它能从服务端数据算出来吗?还是说它本来就是一个独立的 UI 交互状态?
我会把项目里的状态分成几个类别:服务端状态(从接口获取的数据)、客户端持久化状态(本地存储的主题选择)、UI 临时状态(弹窗开合)、派生状态(由其他数据计算出来的展示文案)。不同类型有各自的处理方式,混在一起处理必然出问题。
10.2 核心风险中的"Hooks 规则"
在使用useState、useEffect、useContext这些 Hook 时,有一个铁律:只能在组件顶层调用 Hooks,不能在条件语句、循环、嵌套函数里调用。
原因是 React 靠"调用顺序"来记住每个 Hook 对应的 state。如果某次渲染时某个 Hook 因条件判断没被调用,后面所有 Hook 的顺序都会错位,state 就串了。这类 bug 不报错、不警告,但表现诡异,堪称"隐形地雷"。
现代 React 有官方 lint 规则(eslint-plugin-react-hooks)可以自动检查这个约束。新项目建议直接把这个规则加进 ESLint 配置,它会在你写错代码的第一时间高亮提醒,省掉大量排查成本。
10.3 学习路线上的下一站
基础打完之后,我建议按这个顺序往下走:
- React Router:多页面和路由跳转,这也是实际项目绕不开的。
- 状态管理方案:从 useState 到 useReducer,再到你想引入 Zustand 或 Redux Toolkit。
- 网络请求:如何组织 API 请求层,如何处理 loading、error、success 三种异步状态。
- Next.js 或类似框架:服务端渲染和全栈开发,打通前后端协作。
- 性能和测试:React.memo、useMemo、useCallback 的适用场景,以及基础组件的单元测试。
每一步都去真实项目里做点东西。React 这门技术,看看永远学不会,动手踩坑才是最快的成长方式。
回头总结一下我做 React 项目这几年的感受:语法是皮,组件是骨,JSX 是肌肉。三者配合,才能撑起一个完整的应用。刚开始磕磕绊绊很正常,我到现在偶尔还会翻出文档确认某些 API 的细节。别急着追求一步到位,先把手头的一个小页面写好、写稳,再逐步扩大边界。遇到状态错乱、key 出问题这些经典 bug,也不要打击自己的信心,每个 React 开发者都在这条路上摔过无数次,爬起来就真的把这条路走通了。