1. 先说清楚:React 到底是干嘛的
我第一次接触 React 的时候,最困惑的事情不是“它怎么用”,而是“它到底解决了什么问题”。当时还在用 jQuery 写页面,手动操作 DOM,改一个数据要写好几行document.getElementById,还经常因为忘记更新某个节点导致页面状态和实际数据不一致。
React 给出的答案是:你只需要关注数据长什么样,页面长什么样React 帮你算。它把整个界面拆成一个个组件,每个组件接收数据、返回界面描述,当数据变化时,React 自动对比前后差异,只更新需要变化的部分。这种“数据驱动视图”的思路,就是 React 的核心,也是后来 Vue、Svelte 等框架的共同方向。
这套教程适合谁?如果你是前端新手,只学过 HTML CSS JavaScript 基础,想学框架又怕一上来被各种术语劝退,那这篇精华版能帮你直接把 React 最核心的骨架搭起来。如果你有后端经验想转前端,这篇也能让你快速理解组件化开发的思维方式。
我尽量把每个概念都用大白话讲,配上完整的运行示例,核心代码直接在本地跑起来看到效果,比看十篇概念文章都管用。
2. 组件:页面就是乐高积木
2.1 函数组件的本质就是“会返回 JSX 的函数”
React 里最常用的组件形式就是函数组件。说白了,它就是一个返回 JSX 的 JavaScript 函数。JSX 是 React 专门设计的语法,长得像 HTML,但实际上是 JavaScript 的语法糖。浏览器不认识 JSX,所以需要编译工具把它转换成React.createElement调用。
function Greeting() { return <h1>你好,React!</h1>; }这段代码里,Greeting就是一个组件。它不接收任何输入,返回一段 JSX。要在页面上使用它,只需要在别的地方写成<Greeting />就行。组件的名字必须是大写字母开头,如果是小写,React 会当成普通的 HTML 标签处理,自定义组件就失效了。这个细节我见过很多新手踩坑,包括我自己。
组件的好处在于复用。比如一个博客首页,文章卡片、侧边栏、导航栏,每个区域都可以独立成组件。改卡片样式只动卡片组件,不会影响其他地方。这种模块化能力是组件化开发最大的收益,也是 React 能撑起大项目的基础。
2.2 JSX 不是模板语言:大括号里写 JavaScript
JSX 跟传统的模板语言(比如 Vue 的{{ }}插值)最大的区别是,它直接嵌在 JavaScript 里,写起来非常自由。任何 JavaScript 表达式都可以放进{}里:
const user = { name: '小明', age: 18 }; function UserCard() { return ( <div> <p>姓名:{user.name}</p> <p>年龄:{user.age + 1}</p> <p>{user.age > 18 ? '成年' : '未成年'}</p> </div> ); }注意,{}里只能放表达式,不能放语句(比如if)。如果你想写条件逻辑,用三元表达式代替,或者先在外面算好变量再放进 JSX。
另外还有一个细节:JSX 里给元素加 class,不能写class,要写className。因为 JSX 最终会被转成 JS 对象,class在 JavaScript 里是保留字,所以 React 用className替代。同理,for属性要写成htmlFor。刚写 React 经常会犯这个错,尤其以前写过 HTML 的人特别容易顺手就写成class。
如果返回的 JSX 有多行,组件外面必须套一个根标签,或者用<>...</>碎片(Fragment)包裹。多行 JSX 必须有一个共同的父级容器,否则会报错。
3. 跑一个能看的项目:环境准备其实很简单
3.1 用 Vite 而不是 Create React App
现在官方推荐的脚手架已经偏向 Vite 了,老牌的 Create React App 虽然还在维护,但构建速度明显不如 Vite,而且配置起来比较重。Vite 是基于原生 ES Module 的,开发时启动快得惊人,几秒钟就能把页面跑起来。
先确认电脑上装了 Node.js,建议 16 以上的版本。打开终端,执行:
npm create vite@latest my-react-app -- --template react cd my-react-app npm install npm run devnpm create vite@latest会拉取 Vite 的创建工具,-- --template react指定用 React 模板。装完依赖后,npm run dev会启动一个本地开发服务器,终端会打印出访问地址,一般是http://localhost:5173,浏览器打开就能看到默认页面。
如果不想自己敲命令,也可以直接在 Vite 官网的初始化页面里选择 React 模板,下载 zip 解压后跟着 README 跑起来。本质上是一样的,只是少了命令行操作。
3.2 看一眼脚手架目录结构
Vite 的 React 模板目录不长,核心要关注几个文件和文件夹:
my-react-app/ ├── node_modules/ // 依赖包 ├── public/ // 静态资源 ├── src/ │ ├── App.jsx // 根组件 │ ├── main.jsx // 入口文件,React 从这里启动 │ ├── index.css // 全局样式 │ └── ... ├── index.html // 唯一的 HTML 页面 └── package.json // 依赖和脚本main.jsx是入口,内容通常长这样:
import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.jsx'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> </React.StrictMode> );ReactDOM.createRoot(...).render(...)是 React 18 的标准写法。createRoot创建一个根节点,render方法把<App />挂载到页面上。StrictMode是严格模式,开发阶段会帮我们检测组件里写得不对的地方,比如副作用放在渲染函数里,它会在开发环境重复执行渲染函数来暴露问题,这条我是真实验证过的,生产环境它不会生效,所以不用担心影响性能。
4. props:组件之间传数据就像传参数
4.1 props 都是单向的:父组件传给子组件
组件之间共享数据靠props。父组件在子组件标签上写属性,子组件通过函数参数接收。
// 父组件 function App() { return ( <UserCard name="小红" age={20} /> ); } // 子组件 function UserCard({ name, age }) { return ( <div> <h3>{name}</h3> <p>{age} 岁</p> </div> ); }{ name, age }是解构赋值的写法,直接从 props 对象里取出对应字段。如果没有解构,也可以写成props.name和props.age,但解构让代码更简洁。
一个重要的原则是:props 是只读的。子组件不能修改传进来的 props,一旦你试图去给它赋值,React 会直接在控制台给你报错。如果子组件想修改数据,正确的做法是发送通知给父组件,让父组件去改,这也对应了 React 的“单向数据流”设计。
我刚学的时候总觉得这个限制很麻烦:为什么不让我在子组件里直接改?后来才明白,单向数据流让数据来源变得清晰。页面上任何数据变化,你都能顺着组件层级往上追溯,排查问题快得多。如果数据可以随便改,最后很可能变成一团乱麻,改一处崩一处。
4.2 动态传值:在标签属性里用大括号
props 不光能传字符串字面量,任何 JavaScript 表达式都能传。
function App() { const user = { name: '阿强', age: 22 }; const showAge = true; return ( <Card name={user.name} age={user.age} hobbies={['篮球', '吉他']} showAge={showAge} onClick={() => alert('点击了卡片')} /> ); }hobbies直接传了一个数组,onClick传了一个函数。React 里这种自由传值方式让组件可以非常灵活地组合。有人可能觉得“组件里传函数”很别扭,但这恰恰是 React 数据交互的核心:父组件把自己定义的处理函数传给子组件,子组件在合适的时机调用它,等于是子组件在“请求”父组件做事情。
给组件写 props 时,我习惯把跟事件相关的都以on开头命名,比如onClick、onChange、onSubmit。这个命名习惯不是硬性要求,但多人协作时能快速分清哪些是数据、哪些是回调。
5. state:让组件记住自己的状态
5.1 useState:组件里的“记忆盒子”
props 是父组件塞给子组件的,那子组件自己内部的数据变化怎么办?用useState。
useState是 React 自带的一个 Hook,它返回两个东西:当前的状态值,和一个更新这个状态的函数。
import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); return ( <div> <p>当前计数:{count}</p> <button onClick={() => setCount(count + 1)}>加一</button> </div> ); }useState(0)给count定的初始值是 0。setCount(newValue)会更新状态,并且自动触发组件重新渲染。也就是说,你只要更新状态,页面上对应的部分就会自动跟着变化,不需要手动操作 DOM。这是 React 最核心的体验:把 UI 同步的脏活累活交给框架。
这里要特别强调:不能用count = count + 1这样直接赋值的方式去修改状态。组件里直接修改普通变量不会有任何响应式效果,必须要调用setCount。React 只有通过状态更新函数去改数据,才会感知到变化并重新渲染。
5.2 状态更新的异步特性与函数式更新
React 的状态更新是异步的。比如:
const [count, setCount] = useState(0); function handleClick() { setCount(count + 1); console.log(count); // 这里打印出来的还是旧值 0 }这段代码里,console.log(count)打印出来的依然是0,因为setCount只是安排了这次更新,并没有立刻改变count的值。如果连续点击按钮三次希望 count 变成 3,只调三次setCount(count + 1)很可能得不到预期结果,因为每次拿到的count可能都是旧的 0。
正确的做法是用函数式更新:
setCount(prevCount => prevCount + 1);prevCount是 React 传入的最新状态值,基于这个值做递增才是准确的。这个写法在多个更新连在一起、或者自己在循环里更新状态时尤其重要。我第一次写计数器就踩了这个坑:连点三次按钮,count 只变成了 2。后来查了文档才知道异步更新的细节。
5.3 状态该放在哪个组件?
这是新手最容易拿不准的问题。常见的经验法则是:哪个组件用到这个状态,或者状态影响哪些组件的显示,就往它们的共同父组件放。
举个例子,用户输入框和提交按钮都在同一个表单里,那输入内容这个 state 就放在表单这个父组件里。如果两个兄弟组件需要展示同一个数据,那就把 state 放到它们共同的父组件,通过 props 分别传给它们。
如果项目大了,状态共享变复杂,再考虑引入全局状态管理工具(比如 Redux、Zustand)。但初学阶段千万别急着上状态管理库,先把 useState 用熟练,再用自定义 Hook 去抽你重复的状态逻辑,这往往比引入外部库更简单直接。
6. 事件处理:让页面有反应
6.1 在 JSX 里监听事件
React 监听事件的方式跟原生 DOM 很像,但有几处不同。原生 HTML 里写onclick="func()",React 里写onClick={handleClick}。注意两点:一是事件名用驼峰写法(onClick而不是onclick),二是传的函数引用,不是字符串。
function ClickButton() { function handleClick() { alert('你点了按钮'); } return <button onClick={handleClick}>点我</button>; }如果需要往事件处理器里传参数,有几种写法:
function ClickButton() { function handleClick(msg) { alert(msg); } return <button onClick={() => handleClick('来自按钮的消息')}>点我</button>; }用箭头函数包一层是最常见的做法,简洁明了。注意事项是:不要直接写onClick={handleClick('消息')},那会在渲染阶段就直接执行这个函数,而不是等到点击的时候。
6.2 事件对象与合成事件
React 的事件处理器会接收一个合成事件对象(SyntheticEvent),它包装了浏览器的原生事件,兼容了不同浏览器的差异。常规操作原生的e.preventDefault()或e.stopPropagation()在这里同样可用。
function SubmitForm() { function handleSubmit(e) { e.preventDefault(); // 阻止表单默认刷新 const formData = new FormData(e.target); console.log(formData.get('username')); } return ( <form onSubmit={handleSubmit}> <input name="username" /> <button type="submit">提交</button> </form> ); }e.preventDefault()是表单场景里最常见的需求——不刷新页面,而是在前端处理后端数据。理解了合成事件,后面做表单的受控组件会顺手很多。
7. 条件渲染与列表渲染
7.1 条件渲染的三种姿势
React 没有 Vue 里那种v-if指令,一切都是写 JS 逻辑:
- 三元表达式:最简单直观的写法。
- 短路逻辑:如果条件为真就渲染后面部分,否则什么都不渲染。
- 立即执行函数:适合条件复杂的情况,但不推荐在 JSX 里写复杂的 IIFE,逻辑复杂时应提前抽离。
function LoginStatus({ isLogin }) { return ( <div> {isLogin ? <p>欢迎回来</p> : <button>登录</button>} </div> ); } function Notice({ count }) { return ( <div> {count > 0 && <p>你有 {count} 条未读消息</p>} </div> ); }第一种写法适合简单的二中选一,第二种适合“条件成立才显示”的场景。两种都很常见。用短路逻辑时注意:别让count是 0 这样容易引起整型显示为 0 的坑。如果count是0,表达式会渲染出数字 0。比如{count > 0 && <p>...</p>}在 count 是 1 时返回<p>,在 count 是 0 时直接返回false,React 会自动忽略false不渲染。但如果你写{count && <p>...</p>},count 为 0 时 React 会当真渲染出数字 0,界面出现一个孤零零的 0,这类细节需要自己体感踩过才知道有多尴尬。
7.2 列表渲染:key 的作用与选取
渲染一个数组,最常见的就是用.map():
const fruitList = ['苹果', '香蕉', '橘子']; function FruitList() { return ( <ul> {fruitList.map((fruit, index) => ( <li key={index}>{fruit}</li> ))} </ul> ); }.map()返回的数组里,每个元素必须有一个key属性。key是 React 用来识别“这个元素是哪一个”的线索。如果列表可能增删、排序,那key就不能用数组下标,否则 React 在更新时可能把顺序搞错,导致状态错乱。最简单可靠的key是数据自带的唯一 ID,比如用户 ID、商品 ID 等。
key只需要在兄弟节点间唯一就行,不需要全局唯一。还有一点:key不会作为 prop 传进组件,你在组件内部props.key是拿不到的。
列表渲染里还有个常见需求:过滤后的列表。不要直接改原始数组,而是用.filter()生成新数组渲染。React 的理念是“不修改原数据,每次生成新数据”,这跟函数式编程的思想一脉相承。
8. useEffect:处理副作用的地方
8.1 useEffect 到底管什么?
组件里的“副作用”指的是:跟纯渲染无关的外部操作。比如发请求、操作 localStorage、订阅事件、修改页面标题等。React 推荐把副作用放在useEffect里执行。
useEffect接收两个参数:一个函数和一个依赖数组。
import { useEffect, useState } from 'react'; function UserPage({ userId }) { const [user, setUser] = useState(null); useEffect(() => { fetch(`/api/user/${userId}`) .then(res => res.json()) .then(data => setUser(data)); }, [userId]); if (!user) return <p>加载中...</p>; return <h1>{user.name}</h1>; }这段代码表示:当userId变化时,useEffect 里的函数会重新执行,去请求新的用户数据。如果依赖数组是空数组[],则只在组件挂载时执行一次。如果完全不传第二个参数,那就每次渲染都执行——这个行为往往会导致无限循环,需要谨慎。
8.2 清理副作用:防止内存泄漏
如果你的副作用是订阅事件、开启定时器或建立 WebSocket 连接,那么组件卸载时要清理这些资源,否则会浪费内存甚至产生 bug。
useEffect的函数可以返回一个“清理函数”,React 会在组件卸载时调用它:
useEffect(() => { const timer = setInterval(() => { console.log('定时器触发'); }, 1000); return () => { clearInterval(timer); }; }, []);这里返回的() => { clearInterval(timer); }就是清理函数。React 在清理旧副作用、卸载组件时都会调用它。我做过一个长列表滚动加载的页面,忘了清理scroll事件监听,页面退出了监听还在,导致滚动时疯狂触发请求,排查了大半天才发现是清理函数没写。
经验法则:凡是setInterval、addEventListener、new WebSocket这类全局性的东西,都要在 useEffect 里配对清理。
8.3 依赖数组的取舍:别在依赖里写变了但没用到的变量
依赖数组的作用是告诉 React:“只有这些值变了,才需要重新执行这个副作用。”但我见过最多的坑是——开发调试时,为了“省事”,把依赖数组省略不写,结果组件每次渲染都触发副作用,卡成幻灯片。
另一个极端是:每次渲染都调用useEffect,但函数内部的变量其实在依赖数组中没写全,React 只会对比依赖数组里的变量是否变化,如果里面没写但实际用到的外部变量变了,副作用执行的时机就不对。此时 React 编译器会提示你补充依赖,或者你需要重新审视副作用的设计。
我个人的习惯是:不要让 useEffect 里用太多外层变量,尽量把需要的数据集中在依赖数组里明确列出。如果逻辑复杂到依赖太多,就先在 effect 外部把变量整理成一个对象或数组再依赖它,这样逻辑边界清楚,后续维护也更轻松。
9. 新手最容易踩的 8 个坑
9.1 在 JSX 里写循环或 if 出错
JSX 里写for循环、if语句会直接报错。因为{}里只接受表达式。有循环先在外面算好结果,有 if 用三元表达式代替。
// 错误写法 return <div>{if (a) { b }}</div>; // 正确写法 return <div>{a ? b : null}</div>;9.2 setState 之后马上读 state 读不到新值
这是异步更新导致的。如果需要基于最新 state 做后续逻辑,要么放在useEffect里根据 state 变化去处理,要么用函数式更新计算。
9.3 忘记 key 或使用 index 当 key
列表渲染时控制台会警告 “Each child in a list should have a unique key”。如果列表有增加、删除或重排,用 index 会有隐藏的 bug。尽量用唯一 id。
9.4 改了状态页面没变:直接给 state 对象做属性修改
const [obj, setObj] = useState({ count: 0 }); // 错误 obj.count += 1; setObj(obj); // 正确 setObj({ ...obj, count: obj.count + 1 });React 判断状态更新靠的是引用变化。直接修改对象属性,引用没变,React 认为没更新,不会重新渲染。必须生成一个新对象来替换旧对象。
9.5 组件内部大量逻辑堆在 return 里
JSX 里塞太多三元表达式和函数调用会让可读性崩掉。把关键计算移到 return 前面,分成变量或函数,保持 JSX 干净。
9.6 useEffect 造成无限循环
最常见的写法是:
useEffect(() => { setCount(count + 1); });没传依赖数组,副作用在渲染后执行,执行后更新 state,触发重渲染,副作用又执行,死循环。解决办法就是给依赖数组明确的依赖项。
9.7 组件命名忘记大写
自定义组件用小写开头时,React 会把它当成 HTML 标签,像<userCard />会被当成一个不认识的原生标签,最终渲染出一个空的自定义元素,页面上什么都看不到。这个问题新手经常碰见,控制台还会警告。
9.8 事件处理函数里使用了箭头函数但没绑 this
这在类组件里非常常见,但现在大家基本写函数组件,不存在这个问题。不过我还见到有人会不小心在一个函数组件里给另一个函数组件传递方法时,忘记用箭头函数包装,导致方法在渲染期间直接被调用。原则就是:传的是函数引用,不是函数调用的结果。
10. 一个完整的入门示例:待办事项
把上面讲到的知识点组合起来,写一个经典的小项目——待办事项列表。麻雀虽小,五脏俱全:props、state、事件、条件渲染、列表渲染、useEffect 全都有了。
先创建组件结构,但这个示例可以全部写在App.jsx里:
import { useState, useEffect } from 'react'; function TodoApp() { const [todos, setTodos] = useState(() => { const saved = localStorage.getItem('todos'); return saved ? JSON.parse(saved) : []; }); const [inputValue, setInputValue] = useState(''); useEffect(() => { localStorage.setItem('todos', JSON.stringify(todos)); }, [todos]); function addTodo() { if (!inputValue.trim()) return; const newTodo = { id: Date.now(), text: inputValue, done: false }; setTodos([...todos, newTodo]); setInputValue(''); } function toggleTodo(id) { setTodos(todos.map(todo => todo.id === id ? { ...todo, done: !todo.done } : todo )); } function deleteTodo(id) { setTodos(todos.filter(todo => todo.id !== id)); } return ( <div> <h1>待办事项</h1> <input value={inputValue} onChange={e => setInputValue(e.target.value)} placeholder="输入新任务" /> <button onClick={addTodo}>添加</button> <ul> {todos.map(todo => ( <li key={todo.id}> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} /> <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}> {todo.text} </span> <button onClick={() => deleteTodo(todo.id)}>删除</button> </li> ))} </ul> {todos.length === 0 && <p>还没有任务,先添加一个吧。</p>} </div> ); } export default TodoApp;在这个例子里:
todos初始值是从 localStorage 读取的,注意useState里传的是函数() => {...},这样初始化时只会执行一次。useEffect监听todos变化,自动写入 localStorage,刷新页面数据不丢。- 添加、切换、删除都用不可变更新方式,生成新数组,不修改原数组。
- 列表渲染中用了
todo.id作为 key。 - 条件渲染处理了空列表的提示。
这段代码可以直接替换脚手架里的App.jsx,跑起来试试。本地打开页面,添加几条任务,刷新浏览器,数据还在,这会让你真切感受到 state 和 useEffect 的威力。
11. 我对 React 学习路径的实际体会
写了几年 React,我把入门阶段的经验总结成一句话:先用起来,再理解原理,最后才是优化。
初学者最容易犯的错误是想先把原理全部搞懂再动手写代码。React 的 useState 异步更新、虚拟 DOM 对比、fiber 架构,这些原理确实重要,但如果你还没写过 50 个组件的项目,上来啃源码只会挫败感很强。先照着例子写几个小项目,等遇到状态不同步、性能卡顿的问题再去翻原理,那时你会更容易理解它为什么这么设计。
第二个建议是:不要太早引入 Redux。很多人刚学完 useState 就听说“状态多了要用 Redux”,结果 Redux 那一套 action、reducer、dispatch 让新手直接晕倒。实际上很多场景用useState加 props 传递就能解决。等你真的遇到跨层级组件共享状态的痛点,再学 Zustand 或者 Redux Toolkit,会轻松得多。我见过太多新手卡在 Redux 的学习曲线里,最后放弃了整个 React。
第三个建议是:写组件的时候把“这个组件负责什么”想清楚。一个组件只做一件事,props 不要传太多,超过五六个就该停下来想想是不是该拆组件了。这样写出来的代码自己能看懂,别人也能维护。
如果你自己动手把上面的待办事项跑通,再改改样式、加个编辑功能,React 入门的门槛就已经跨过去了。后面的路,无非是遇到问题、查文档、解决问题,循环往复,水平自然就起来了。