1. JSX不是HTML:先弄清楚React的渲染本质
React的核心语法,说来说去都绕不开JSX。很多人刚接触React时很容易把它当作一种"写在JavaScript里的HTML",结果一写就踩坑——标签属性名写错、样式对象写错、注释写法不对、条件渲染渲染出了false或0。这些问题本质上都因为没理解JSX到底是什么。
JSX不是模板引擎,也不是HTML超集。它的真实身份是语法糖:Babel会把JSX代码编译成React.createElement(type, props, children)调用。比如你写:
<div className="card"> <h1>标题</h1> </div>编译结果大致是:
React.createElement( 'div', { className: 'card' }, React.createElement('h1', null, '标题') )React.createElement最终返回的是一个普通的JavaScript对象,这个对象有type、props、children等字段。React运行时拿到这个对象树,去对比之前的对象树(也就是diff),然后才真正操作DOM。换句话说,JSX本质上在描述"组件结构的数据",而不是直接写标签。
理解了这一点,很多问题就好解释了。
1.1 为什么外层必须有且只有一个根节点
既然React.createElement是嵌套结构,返回的必然是一棵树,那自然只能有一个根。React 16之前想返回多个平级元素必须包一层div,React 16.2之后提供了Fragment语法:
<> <li>第一项</li> <li>第二项</li> </>这里要注意:空标签<>并不是特殊组件,它一样会被编译,但不会在DOM里产生任何节点。所以以后如果你的页面莫名其妙多了一堆空div,检查一下是不是没用Fragment。
1.2 属性名的差异不是React故意折腾你
JSX属性名和原生HTML属性有几个显著差异:
class必须写成className,因为class在JavaScript里是保留字for必须写成htmlFor,原生label标签会踩这个坑tabindex要写成tabIndex,readonly要写成readOnly- 自定义属性必须用
>const style = { backgroundColor: '#fff', fontSize: '14px', marginTop: 12 };CSS属性名一律用小驼峰。数字类型的值,React会默认给你加
px(某些属性除外,比如lineHeight、flex、opacity等)。这一套规则第一次用确实容易记混,但用熟了之后会发现比写字符串拼接舒服很多。另一个常见误解是注释。JSX里的注释写法比较反直觉:
<div> {/* 这是注释 */} {/* 多行注释 这行也是注释 */} </div>因为花括号里是表达式上下文,注释必须用
/* */包裹。新手经常写// 注释然后报错,就是因为没理解"花括号里是JS表达式"这一层。2. 组件与props:单向数据流的设计哲学
组件是React的第二个核心概念。函数组件和Class组件在React 16.8之前差异较大,如今hooks普及后,函数组件已经是绝对主流。但不管是哪种,组件的本质都是一个"输入props、输出JSX"的函数:
function Greeting({ name, age }) { return <p>你好,{name},年龄{age}</p>; }这个函数每次被调用,都对应一次"渲染"的过程。React不知道也不关心你怎么实现的,它只关心你返回的JSX长什么样。
2.1 props是只读的:为什么不能改
props是父组件传给子组件的数据。在React的心智模型里,数据是单向流动的:从父组件流向子组件,子组件只能读,不能改。如果子组件改了props里的对象属性,虽然JS层面不报错,但会造成不可预测的UI状态——因为父组件根本不知道这个变化,下一次重新渲染时数据又被覆盖回去了。
实践中,如果子组件确实需要修改某个值,有两条路:
- 把"修改的方法"作为props传给子组件,子组件调用父组件传下来的回调,由父组件去改状态
- 子组件把传入的数据复制到自己的
useState或useRef里再管理
第一条是React官方推荐的做法,它保证了"唯一数据源"在父组件手里,UI永远和状态保持一致。这也是为什么React的单向数据流能带来清晰的可预测性——任何时刻,UI就是当前props和state的一个快照。
2.2 props.children:组合能力的基础
props.children是React组合模型的核心。你写:<Card> <p>我是卡片内容</p> </Card>这个
<p>标签就会作为Card组件的children传入。这不是什么魔法,它就是一个普通对象属性。利用这个机制可以做flexible的布局组件:
function Layout({ header, sidebar, children }) { return ( <div> <div>{header}</div> <aside>{sidebar}</aside> <main>{children}</main> </div> ); }调用时这样传:
<Layout header={<Header />} sidebar={<aside>导航</aside>} > <Content /> </Layout>你会发现组件之间"搭积木"的能力,靠的就是props.children。这也是React前端生态里高阶组件、render props等模式的底层来源。
3. useState与useEffect:hooks生态下的状态管理
React 16.8引入了hooks,这是React核心语法的重要转折。
useState和useEffect是日常开发中使用频率最高的两个hooks,但也是最容易出问题的两个。3.1 useState的反模式
很多人刚用
useState时会有这些疑惑:为什么state更新了但界面没变?为什么setState后立刻读值还是旧值?useState返回的set函数,触发更新后会触发一次重新渲染,但当前这一次函数运行环境中,变量值仍然是旧值。因为每次渲染都是"重新执行一次组件函数",新状态只属于下一次渲染:function Counter() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); console.log(count); // 仍然是0 }; return <button onClick={handleClick}>点击</button>; }这不是bug,这是React的渲染模型决定的。如果你要基于最新state做后续操作,可以用函数式更新,或者在
useEffect里监听变化:setCount(prev => prev + 1)。另一个常见反模式是把对象直接塞进state然后修改属性:
const [user, setUser] = useState({ name: '', age: 0 }); // 错误 user.name = '张三'; setUser(user); // React会认为state没变,UI不更新必须返回一个新的对象引用:
setUser({ ...user, name: '张三' });React比较state变没用值比较或者深比较,而是用的
Object.is级别的浅比较。只要你没有产生新引用,React就认为没有变化。记住:永远不要直接修改state,永远用set函数生成新值。3.2 useEffect的心智模型
很多新手把
useEffect理解为"生命周期钩子",其实这个说法有误导。useEffect更接近"在渲染提交到屏幕之后执行副作用"的机制。依赖数组是重点中的重点:
useEffect(() => { // 每次渲染都执行 }); useEffect(() => { // 只有在挂载时执行一次(严格模式下会执行两次) }, []); useEffect(() => { // 当count变化时执行 }, [count]);依赖数组不是"参数组成的数组",而是"这个effect依赖哪些值"的声明。如果你在effect内部引用了某个变量,却没把它写进依赖数组,React会给你warning,但不会帮你修复——这个bug往往很隐蔽。
举个例子,一个常见的轮询需求:
useEffect(() => { const timer = setInterval(() => { fetchData(id); }, 1000); return () => clearInterval(timer); }, [id]);id变化后会重新创建定时器,同时也清理了旧的定时器。这里如果不把id放进去,fetchData(id)拿到的永远是初次渲染的那个id,这就是闭包过期的经典问题。在异步请求、事件监听、定时器这些场景里特别容易出现。另一个值得注意的点是effect的清理函数。在使用事件监听、订阅、定时器时,一定要在清理函数里解除绑定,否则组件卸载后还会继续执行副作用,轻则内存泄漏,重则报错。React 18之后开发环境下effect会在Mount后执行一次并立即Cleanup再重新执行,这不是性能问题,这是为了帮你发现漏清理的Bug。
4. 事件处理与表单:受控组件与非受控组件的取舍
React事件体系有个显著特点:事件是合成事件,也就是说React在顶层统一挂了代理监听,而不是把事件直接绑定在DOM元素上。这主要是为了跨浏览器一致性,也顺便贡献了自动清理和性能层面的优势。
事件处理函数里需要注意this指向,Class组件时代这是个重点坑,函数组件时代已经不存在了,但事件对象的池化机制仍然是常见话题。React 17之前,事件对象是会被复用的,异步访问事件对象属性会得到空值,需要调用
event.persist()。React 17之后池化机制被移除了,现在可以放心在异步里使用event,但为了兼容旧项目还是建议先确认一下版本。表单是React中的一个高频难点,核心在于受控组件和非受控组件的选择。
4.1 受控组件:数据驱动UI的正统方案
所谓受控,就是表单
input的值由React state控制:function Form() { const [value, setValue] = useState(''); const handleChange = (e) => { setValue(e.target.value); }; return <input value={value} onChange={handleChange} />; }用户输入 -> onChange事件 -> setState -> 重新渲染 -> 输入框值变为state值,这就是受控的完整闭环。好处是:input的值不会再"自行变化",所有数据流都有迹可循,可以随时对用户输入做过滤、格式化、限制长度等。
比如移动端项目里常见的语音输入功能,把语音识别结果直接
setValue(transcript),输入框的值立刻同步更新,这就是受控组件的天然优势。你想剪贴板粘贴一组数据、想用微信扫码回填内容、想批量替换文本,所有"从外部注入输入框"的场景,受控组件都是最简单的解法。4.2 非受控组件:适合哪些场景
非受控组件用
ref直接访问DOM:function Uncontrolled() { const inputRef = useRef(null); const submit = () => { console.log(inputRef.current.value); }; return <input type="text" ref={inputRef} />; }没有value和onChange的绑定,值由DOM自己管理。这类组件适合:表单提交时才取值、不关心内容的输入场景、第三方组件封装、文件上传等不需要实时校验的字段。
一个不够成熟的建议是:能用受控尽量用受控,非受控适合少数明确不需要React"掺和"内部状态的场景。但反过来,也别把所有表单都硬写成受控——比如一个用户要输入一长串文章内容的
textarea,每次键盘敲击都触发一次state更新和重渲染,在大文本场景下确实有性能压力。合理做法是让子表单组件独立收货,父组件提交时再统一取值。5. 条件渲染与列表渲染:最容易出错的两个高频写法
条件渲染和列表渲染是React JSX里最日常的写法,却也是新手栽跟头最多的地方。
5.1 逻辑与运算符的坑:0和''会被渲染出来
很多教程会写
{condition && <Component />}这种模式,简洁高效。但一旦condition不是布尔值,而是数字或字符串,就会翻车:// 假设 count = 0 {count && <p>当前有{count}条消息</p>} // 页面上会渲染出 0原因在于
0 && something在JavaScript里返回的是0,React渲染这种数字时会直接把它显示出来。而false && something返回false,React什么都不渲染。所以安全写法是:{count > 0 && <p>当前有{count}条消息</p>}或者先转成布尔值:
{!!count && ...}。这个坑在从后端拿数量数据时特别容易踩——接口返回0,页面莫名其妙显示了一个0。5.2 key的作用:别看它小,写错会出灵异bug
列表渲染必须加
key,这个应该已是共识。但key怎么选很多人还是懵。key的真实作用是帮助React识别"哪个元素发生了什么变化"——是新增、删除还是移动。React的diff算法会尽量复用,如果key选错,可能发生:明明只是插入了一条数据,结果其他项的状态全丢了、输入框内容串了、滚动位置乱了。
key选择的三条经验:
- 优先用数据里唯一的ID,比如业务ID、创建时间戳等
- 没有ID时可以用index,但必须保证列表是静态的、不发生排序和中间增删
- 绝对不要用随机数当key,这会让React每次渲染都认为所有项都变了,性能急剧下降
一个典型反例是分页列表:用户在第一页输入表格内容,翻到第二页再翻回来,之前输入的内容全没了。很可能就是因为用了index当key,React不知道"这条数据还是原来那条",直接重渲染了。
更深层的理解是:key属于"同层兄弟节点间"的比较规则。不同父节点下的key即使相同也没关系;key只在同一个数组里需要唯一。
6. 样式方案:内联、CSS Modules与CSS-in-JS的取舍
样式这块React官方没给标准答案,社区方案五花八门。核心语法层面能说的主要有三类。
6.1 内联样式:适合动态样式和简单变量
内联样式本质是对象,好处是可以直接绑定JS变量:
function ProgressBar({ percent }) { return ( <div style={{ width: `${percent}%`, background: percent > 80 ? 'red' : 'green' }}> 进度 </div> ); }但内联样式不支持伪类、媒体查询、动画,也不支持样式复用。组件稍微复杂一点就不好维护。所以它的定位是:局部动态样式、组件内联主题变量、小型工具组件。
6.2 CSS Modules和CSS-in-JS
CSS Modules是工程化方案,通过构建工具把类名编译成带hash的唯一类名,天然不用操心类名冲突:
// Button.module.css 里 .btn 会被编译为 .Button_btn__3xY7a import styles from './Button.module.css'; function Button() { return <button className={styles.btn}>按钮</button>; }好处是零学习成本、静态CSS的性能优势、易于抽公共变量。坏处是动态样式穿插起来麻烦一点,和JS变量沟通需要通过行内样式或CSS变量。
CSS-in-JS(styled-components等)则是用JS模板字符串写CSS:
const Button = styled.button` background: ${props => props.primary ? 'blue' : 'gray'}; padding: 8px 16px; `;它把样式和组件绑在一个文件里,动态样式顺手,想做主题切换也方便。代价是运行时开销——每次渲染都要解析样式字符串或者注入style标签,对性能敏感的大型列表、图表页面会有压力。
我自己的经验是:组件库项目适合CSS Modules或者直接纯CSS;业务页面小中型项目用CSS-in-JS体验很爽;动态样式多的场景可以用内联配合CSS变量。核心思路不是"选一个最强的",而是"别跨方案乱混"。一个项目里三种方案都上,那才是真正的灾难。
7. 状态管理进阶:useReducer、Context与全局状态的边界
当state不再只是"一个组件自己用",而是多个兄弟组件、跨层级组件都要读写时,就需要考虑状态管理方案了。
7.1 useReducer:逻辑复杂的局部状态
useReducer是useState的升级版,适合状态变化逻辑多的场景:function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: 0 }; default: throw new Error(); } } function Counter() { const [state, dispatch] = useReducer(reducer, { count: 0 }); return ( <button onClick={() => dispatch({ type: 'increment' })}> {state.count} </button> ); }它比
useState强的地方在于:把"状态怎么变"的逻辑从组件里抽出去,变成纯函数。action是描述意图的对象,reducer根据action返回新状态。规律是:如果你的一个组件里有三四个state变量互相影响、一个操作要改多个字段,那就该换useReducer了。7.2 Context的合理使用范围
React Context用于跨越层级传递数据,避免prop drilling。但它不是性能化的全局状态工具——Context值变化时,所有消费该Context的组件都会重新渲染,即使它们只关心某一部分数据。
如果有人用Context管理一整个大型全局state,那性能往往撑不住。比较合理的用法是把若干个Context按领域拆小,比如
UserContext、ThemeContext、SettingsContext,并且消费端组件尽量拆分细粒度。const ThemeContext = React.createContext('light'); function App() { const [theme, setTheme] = useState('light'); return ( <ThemeContext.Provider value={{ theme, setTheme }}> <Layout /> </ThemeContext.Provider> ); } function Toolbar() { const { theme } = useContext(ThemeContext); return <div style={{ color: theme === 'dark' ? '#fff' : '#000' }}>工具栏</div>; }你不需要一上来就上Redux或Zustand。很多中小项目里,"useReducer + 若干Context"足够用。只有真正出现跨页面跨模块的复杂共享状态、需要devtools时间旅行调试、需要持久化中间件时,再引入外部状态库。
8. 性能优化:memo、useCallback和useMemo的实战判断
React渲染本身很快,但遇到大列表、图表、复杂表单时仍然需要注意性能。React默认行为是"父组件重新渲染,子组件也跟着渲染"——这不是bug,这是React保证UI一致性的最简单方式。但很多场景里子组件并没有必要跟着重新跑一遍render。
8.1 React.memo:阻止不必要的子组件重新渲染
React.memo对组件做浅比较,props不变时就跳过重新渲染:const MemoChild = React.memo(function Child({ data }) { return <div>{data.name}</div>; });等等,有一个关键前提:如果父组件每次渲染时都创建一个新数组或对象作为props,那浅比较永远会失败:
function Parent() { const data = { name: '张三' }; // 每次渲染都是新对象 return <MemoChild data={data} />; }要让memo生效,就得配合
useMemo:function Parent() { const data = useMemo(() => ({ name: '张三' }), []); return <MemoChild data={data} />; }所以
React.memo和useMemo是两兄弟,很难分开用。8.2 useMemo和useCallback的正确姿势
useMemo缓存计算结果,useCallback缓存函数引用:const total = useMemo(() => list.reduce((acc, i) => acc + i.price, 0), [list]); const handleClick = useCallback(() => { console.log(total); }, [total]);滥用也无必要:
useMemo本身有hash比较的开销,缓存一个简单运算可能比直接计算还慢。合理的场景是——list变化频率低、计算本身昂贵、结果被多个子组件使用、或者结果作为依赖数组的值。经验门槛:如果运行它的代价小于运行多个组件重新渲染的代价,就值得用;否则别用。另外补充一个细节:
useCallback和useMemo的依赖数组写好后基本不会再从外部修改,但如果内部调用了ref或外部状态,依赖数组漏写会造成闭包过期,这和useEffect一个道理。8.3 大列表和图表场景的优化思路
热词里提到了React图表,这种场景特别考验性能。渲染数千条数据时不加优化,每帧都可能卡顿。常用手段:
React.memo包裹每个图表项useMemo缓存格式化后的数据,不在渲染中重复计算- 虚拟列表(实际渲染可视窗口内条目的
react-window等方案) - 图表库尽量选SVG或Canvas渲染可控的,数据量特别大时直接换WebGL方案
还有一个容易被忽略的点:减少state放在大列表项内部。每个列表项自身的state变化,只有自己会重新渲染,这没问题;但如果列表项中的某个指标值是从全局state读的,那全局state一变化,所有项都要重渲染。合理做法是让全局state只保留"最小必要集合",派生数据用
useMemo在组件内部计算。9. React 18与未来语法:并发渲染、SSR数据预获取
React 18把并发渲染(Concurrent Mode)推向了稳定版本,这给核心语法带来了几个不可回避的变化。
9.1 自动批处理
React 18之前,
setState在事件处理里是自动批处理的,但在Promise、setTimeout、原生事件里不会,会导致多次渲染。React 18开始,所有场景都会自动批处理:function handleClick() { setCount(c => c + 1); setFlag(f => !f); // 只重新渲染一次 }这带来的心智变化是:记住状态更新可能是异步、延迟的,不要在setState之后立刻依赖DOM或state。必要时可以用
flushSync强制同步刷新,但日常场景很少需要。9.2 useTransition与useDeferredValue
并发特性引入的两个新hooks值得了解。
useTransition用于标记低优先级更新,让高优先级的交互不被阻塞:function App() { const [isPending, startTransition] = useTransition(); const [list, setList] = useState([]); const handleSearch = (keyword) => { // 输入框能保持流畅,搜索结果的更新可以延迟 startTransition(() => { setList(filterData(keyword)); }); }; }useDeferredValue则是让某个值延迟更新,常用于输入框联动列表搜索场景。这两个API解决的都是"交互优先级"问题,核心语法层面了解一下即可,实际项目中可以显著优化体验。9.3 React SSR数据预获取的新方向
热词里提到"react ssr 数据预获取方案",React 18的
Suspense正在改变服务端渲染的数据获取方式。传统SSR要等所有数据就绪才返回HTML,Suspense方案则允许组件"挂起"直到数据准备完成,可以把首屏时间分散到最需要的部分。用框架层面做这类事情(比如Next.js的RSC、Remix的loader)会更方便,核心语法层面只需要知道:Suspense可以指定fallback,让"等待异步数据"成为声明式而非命令式。<Suspense fallback={<Spinner />}> <AsyncProfile userId={userId} /> </Suspense>这是React并发能力在数据获取领域的重要延伸,也是面试里"React新特性"的高频问题方向。
10. 写在最后:怎么真正掌握React核心语法
最后分享一点我自己的体会。React核心语法不难,难的是心智模型转变。很多人写了好几个月React,仍然在"用React写jQuery"——总想直接操作DOM、总想从一个全局变量读数据、总对"重新渲染"感到手足无措。
我的建议是:不要急着上脚手架,先用Create React App或Vite搭一个最小的项目,把本篇文章里的每个示例都亲手敲一遍。特别是useEffect依赖数组、useReducer的action设计、Context的拆分,这些只靠看永远学不会,踩一次坑比看十遍文档都有用。
面试方面,React核心语法的考点其实也很集中:JSX编译原理、key的作用、受控与非受控、hooks依赖数组的闭包陷阱、memo和useMemo的适用边界、React 18批处理与并发特性。把这些问题每一个都能从"为什么会这样"讲清楚,而不是背答案,基本就没问题了。
学React的过程中你会发现,它逼着你用更结构的眼光看待视图层的复杂性和状态变化。前面讲的所有语法点,本质上都是围绕"UI是状态的函数"这个核心思想展开的。抓住这条主线,后面再看Redux、React Router、Next.js这些生态,你会豁然开朗。