
前阵子带实习生周会上他分享学 React 的心得说看了一周文档组件、props、state 这些概念好像都懂了可真要动手写功能模块又不知道从哪下手。这问题我太熟悉了。React 的教程满天飞但要么写得太浅贴几个例子就完事要么一上来就讲虚拟 DOM、Fiber 架构、调度机制直接把新人劝退。所以我整理这篇“精华版”入门教程定位非常明确给刚接触 React、或者学过但没完全想明白的人把主干一次性打通。你不需要提前知道 Redux 是什么不用管 React Router 怎么配只要 JavaScript 基础语法过关跟着这篇文章一步步来就能写出自己的第一个 React 组件理解数据在组件里到底是怎么流动的踩坑的时候也知道该往哪个方向排查。文章里所有的思路都来自我实际写项目和带新人的经验不是照着官方文档念一遍会尽量用大白话把底层逻辑讲透。1. 直击本质React 解决的是 UI 和数据同步的问题1.1 原生 JavaScript 写界面痛点到底在哪在说 React 之前先说说没有框架的时候前端是怎么写页面的。假设现在要做一个聊天列表来新消息了你至少要手动做这么几件事创建一条新的 DOM 节点、把内容插进去、更新右上角的未读数、调整列表滚动条位置、可能还要修改标题栏。这个流程用原生 JavaScript 写每一步都要你亲手操作。const list document.getElementById(message-list); const li document.createElement(li); li.textContent 新消息内容; list.appendChild(li); document.getElementById(unread-count).textContent 3; list.scrollTop list.scrollHeight;页面简单的时候还好一旦业务复杂起来这种“手动同步”的方式会迅速失控。你会发现代码里到处是appendChild、innerHTML、addEventListener数据和界面之间的对应关系乱成一团。更麻烦的是你很难保证每个数据变更都在界面上正确反映出来——漏改一处就是 bug。我早期写原生 JS 项目时最怕听到的一句话就是“这块数据变了你记得把那边几个地方的 DOM 也同步一下。”这种开发模式本质上是把 UI 当作一堆需要你亲手维护的节点数据一变你就要像园林工人一样去修剪每一处对应的树叶。React 的思路完全不同。它不让你去关心“哪一段 DOM 需要改”而是让你把页面看成“状态的函数”。你只需要描述在某个状态下页面应该长什么样。至于状态变了之后具体哪些 DOM 需要增删改React 内部帮你算好、帮你做了。1.2 React 的核心公式UI f(state)UI f(state)这是 React 里最重要的一个公式没有之一。state是数据f是组件函数UI是渲染出来的页面。只要数据变了页面就会重新渲染你不需要手动打电话告诉页面“该更新了”。我经常用一个“表格自动重算”的类比来解释这件事。用原生 JS 写界面就像你拿着一张纸质账本每次数据变了都要自己拿笔蹭掉旧的、写上新的。用 React 写界面就像 Excel 里写好公式你只在单元格里填数据公式自动把结果算出来、展示出来。就算把某个数字改掉所有关联的单元格都会跟着自动更新不用你一个个去改。很多新手学 React 的时候容易陷在语法细节里忽略了这层核心思想。其实你只要抓住了“数据驱动视图”这个主心骨后面学 props、state、Hooks全都能串起来。组件接收数据根据数据渲染界面数据变化界面跟着变化——就这一条主线贯通了整个 React。2. 环境准备5 分钟跑起你的第一个 React 应用2.1 开始前的环境检查老玩家应该还有印象几年前的入门教程基本都是教你用 create-react-app 搭建项目。但这个脚手架现在官方已经不怎么推荐了安装慢、启动慢重项目跑起来能让人等到怀疑人生。我的建议很直接用 Vite。Vite 是目前 React 社区的主流选择启动快、配置简单、开发体验顺畅连 Vue 官方现在也推荐它。动手之前先确认环境。打开终端输入node -v和npm -v看看 Node.js 装没装、版本够不够新。Vite 5 需要 Node.js 18 以上如果你机器上的版本太老先去 Node.js 官网下 LTS 版本安装过程基本就是一路下一步装完再回来验证一遍版本号。node -v # 输出类似 v18.17.0 就说明没问题 npm -v如果你的电脑上从来没装过 Node.js也不用慌官网下载对应系统的安装包双击安装装完打开终端重新敲一次node -v能看到版本号这步就算过了。这一步是整个教程里唯一的“环境门槛”过了之后基本一路畅通。2.2 用 Vite 创建项目环境没问题之后找一个你想放代码的目录在终端里执行下面的命令npm create vitelatest react-demo -- --template react这行命令的意思是让 Vite 帮我们创建一个名为react-demo的新项目使用官方提供的 react 模板。执行过程中如果提示是否安装create-vite输入y回车即可。稍等几秒钟项目文件就生成好了。接下来依次执行三个命令把依赖装上、把开发服务器跑起来cd react-demo npm install npm run dev第一次npm install可能要等一会儿这是正常的别以为卡死了可以去倒杯水。装完之后npm run dev启动开发服务器终端会显示Local: http://localhost:5173/。在浏览器里打开这个地址看到 Vite 加 React 的默认欢迎页说明你的 React 环境已经通了。整个从零到跑起来的过程正常情况不超过 5 分钟。2.3 项目结构怎么看很多新手跑起项目之后面对一堆不认识的文件会有点慌。其实 Vite 生成的 React 项目核心目录就一个src平时你几乎只需要在这里面干活。先看两个关键入口文件react-demo/ ├── index.html # 页面模板浏览器加载的入口 ├── package.json # 依赖清单和脚本命令 └── src/ ├── main.jsx # React 启动入口挂载根组件 ├── App.jsx # 根组件第一个组件示例 └── index.css # 全局样式main.jsx是启动入口里面做了一件事把App这个根组件挂载到页面的某个 DOM 节点上。而App.jsx就是你的第一个 React 组件。我刚带新人时会让他们先把App.jsx里默认生成的 logo 和样式清掉只保留一个简单的h1确认页面上能看到自己的内容。这一步不是为了写什么厉害的功能而是让你建立信心原来这个项目的渲染链路是通的改一个文件页面立刻变。3. 组件的世界JSX、props 与 state3.1 组件就是一坨会变形的 HTMLReact 应用的基本单位是组件。什么叫组件说白了组件就是一个返回“看起来像 HTML 的代码”的函数。比如下面这个function Greeting(props) { return h1Hello, {props.name}!/h1; }这个Greeting函数就是一个 React 组件。它接收一个叫props的参数返回一段 JSX。JSX看名字像是“JavaScript XML”它让你能在 JavaScript 代码里直接写类似 HTML 的标记。有基础的老前端可能会觉得“这不就是把 HTML 写进 JS 里了吗能行吗”能行而且这就是 React 的精髓之一组件就是函数函数的返回值决定了界面长什么样。组件还有一个特性可以嵌套、可以复用。你写了一个Greeting可以在任何地方重复使用它传不同的参数显示不同的内容。这种“搭积木”的思维是 React 开发里最核心的组织方式。一个页面再复杂拆到底全部都是一个一个小组件拼起来的。3.2 JSX 语法规则记住这几条就够用JSX 长得像 HTML但它毕竟不是 HTML有自己的一套语法规则。写过一段时间 React 之后这些规则会变成肌肉记忆但对新手来说有几个点容易踩坑。第一个规则JSX 里只能有一个根元素。多个并列的标签必须用.../包裹这种写法叫 Fragment。第二个规则class要写成className因为class在 JavaScript 里是关键字。第三个规则所有属性名称都用驼峰命名法比如onclick要写成onClicktabindex要写成tabIndex。第四个规则在 JSX 里想要输出变量或表达式要用{}包起来。我把 HTML 和 JSX 的差异整理成一张表你对照着看会很直观场景HTML 写法JSX 写法给元素加 classdiv classboxdiv classNamebox绑定点击事件button onclickfn()button onClick{fn}输出变量span{{ msg }}/span模板语法span{msg}/span写注释!-- 注释 --{/* 注释 */}设置行内样式stylecolor:redstyle{{ color: red }}关于样式那一条新手经常看懵。style{{ color: red }}外面一层大括号是告诉 JSX“这里面是 JavaScript 表达式”里面一层大括号则是说“这是一个对象”。理解了这两层含义就不会被那对花括号绕晕了。3.3 props 是入参state 是内部状态组件要复用就得能接收外部传进来的数据这个数据通道就是props。还是拿Greeting举例function Greeting(props) { return h1Hello, {props.name}!/h1; } // 使用组件时像给 HTML 标签传属性一样传值 Greeting name小明 /父组件通过name小明这种方式把数据传给了子组件。子组件内部通过props.name读取。这里有一个非常关键的规矩props 是只读的。子组件可以读取 props但绝对不能直接修改 props。数据就像两条方向不同的河流父组件的数据往子组件流子组件想改父组件的数据不能动手去抢只能通过回调函数“申请”父组件来修改。这个单向数据流的规矩维持了整个应用的数据可预测性不遵守它项目一大必然出乱子。那如果组件自己有一份想记着、会变化的数据怎么办比如一个计数器点击一次加一这个数字就是组件内部的“状态”用state来管理。在 React 里通过useState这个 Hook 来声明function Counter() { const [count, setCount] useState(0); return ( div p当前计数{count}/p button onClick{() setCount(count 1)}点我加一/button /div ); }useState(0)返回一个数组第一个值是当前状态第二个值是用来更新状态的函数。count是当前的数字setCount是修改它的唯一手段。你不需要写count、不需要手动找p更新文本只要调用setCountReact 就会自动重新渲染组件页面上数字立刻变。这就是前面说的数据变了界面自动跟着变。3.4 state 更新为什么是异步的第一次接触setCount时很多人会习惯性地以为它像普通变量赋值一样调用之后立刻生效。然后写出来的代码经常长这样const [count, setCount] useState(0); function handleClick() { setCount(count 1); console.log(count); // 输出 0不是 1 }为什么setCount之后紧接着打印count拿到的还是旧值因为 React 不是“你改数据我立刻改 DOM”这种简单流程。调用setCount的真正含义是告诉 React“这个组件的状态需要更新了。”React 会把一系列更新合并起来在合适的时机批量处理然后统一渲染。这是为了性能考虑——如果每次setCount都立刻同步渲染一个事件里改五个状态组件就要重渲染五次浪费严重。那如果我真的需要基于最新状态去计算怎么办React 提供了函数式更新的写法setCount((prevCount) prevCount 1);传给setCount的不再是一个具体值而是一个函数这个函数接收“上一次的状态”作为参数返回“新状态”。React 会保证这个函数收到的prevCount永远是最新的。在连续多次更新状态、或者需要读旧值做计算时这个写法比直接传值更可靠。比如连续点击两下按钮想加二直接写setCount(count 1)两次最后只会加一因为两次都基于同一个旧值计算的但用函数式更新写两次每次拿到的都是前一次更新后的结果最终加二。理解这一点React 的思维就入门一半了。4. 用好 HooksuseState 与 useEffect 的实战细节4.1 useState 的完整打开方式上一节的计数器用了useState最基本的用法但在真实项目中还有几个细节值得展开。第一个是惰性初始化。有些状态初始值的计算很耗时比如要从 localStorage 读数据、要做复杂的校验转换。如果你直接写useState(expensiveFunction())这个函数在每次渲染时都会被调用一遍哪怕你根本用不到初始值。正确写法是把函数传进去React 只在首次渲染时执行它const [config, setConfig] useState(() { const saved localStorage.getItem(app-config); return saved ? JSON.parse(saved) : defaultConfig; });第二个是状态更新时要保证是“新引用”。这一点特别容易忽略。看下面这段代码const [user, setUser] useState({ name: 张三, age: 18 }); function updateName() { user.name 李四; // 直接修改了原对象 setUser(user); // 传的还是同一个引用 }这样写界面不会更新。因为 React 比较状态是否变化时比的是“引用是否变化”。你直接改了user.name但user这个对象还是原来那个React 认为状态没变于是跳过渲染。正确做法是创建一个新对象setUser({ ...user, name: 李四 });用展开运算符把原对象拷贝一份再修改目标字段生成一个全新引用React 才会判定“状态变了”。这个“旧数据不能直接改每次要生成新数据”的意识几乎贯穿所有 React 开发场景。第三个是 Hook 的调用规则。useState这类 Hook只能在组件顶层调用不能放在if、for之类条件块里。原因在于 React 靠 Hook 的调用顺序来记忆状态如果某次渲染if不成立、某个 Hook 被跳过了后面所有 Hook 对不上号整个组件就乱了。记住一句话Hooks 必须在组件函数的顶层、无条件地调用。4.2 useEffect副作用这件事组件在渲染时主要是根据 props 和 state 计算界面这算是“纯函数”的流程。但真实应用总得做点别的发请求、存 localStorage、监听键盘事件、设置定时器……这些统统叫副作用。useEffect就是用来处理副作用的标准化入口。基础用法长这样useEffect(() { // 这里是副作用逻辑 console.log(组件挂载或者依赖项变化时执行); return () { // 这里是清理逻辑组件卸载或下次执行前调用 console.log(清理工作); }; }, [依赖项]);那段返回的函数是useEffect最容易被忽略、却极其重要的部分。比如你设置了一个定时器组件卸载时如果不清理定时器还会继续跑轻则报错重则内存泄漏。有了清理函数就能在离开页面时把定时器解除。function Timer() { const [seconds, setSeconds] useState(0); useEffect(() { const timerId setInterval(() { setSeconds((s) s 1); }, 1000); return () clearInterval(timerId); }, []); return p已经计时{seconds} 秒/p; }这个例子里面[]是依赖数组。依赖数组是useEffect的灵魂它的规则有几种情况我整理一下传[]组件挂载时执行一次卸载时执行清理函数。传[dep]当dep值变化时执行。什么都不传每次渲染都执行。实际开发中useEffect里如果用到某个 props 或 state就应该把它写进依赖数组。否则很容易遇到“页面显示的数据不是最新的”这种诡异 bug。不过这里也有一个需要适应的地方写依赖数组时要有意识地思考“哪些外部值在这个副作用里被读取了”而不是图省事全写进去或全部不写。我见过不少新人因为依赖数组写漏在排查问题时浪费了大量时间与其这样不如一开始就养成检查依赖的习惯。4.3 自定义 Hook把逻辑抽出来随着项目变大你会发现有些副作用逻辑会在不同组件里反复出现。比如好几个组件都要监听窗口宽度变化总不能每个组件都写一遍useEffect加addEventListener吧React 允许你自己封装一个 Hook本质就是一个普通函数但内部可以调用其他 Hooks。function useWindowWidth() { const [width, setWidth] useState(window.innerWidth); useEffect(() { const handleResize () setWidth(window.innerWidth); window.addEventListener(resize, handleResize); return () window.removeEventListener(resize, handleResize); }, []); return width; }封装好之后任何组件需要窗口宽度时一行代码就搞定function Layout() { const width useWindowWidth(); return width 768 ? MobileView / : DesktopView /; }命名上有个约定自定义 Hook 必须以use开头这样 React 的 lint 插件才能识别、检查调用规则。自定义 Hook 是 React 组合逻辑的常规手段学会它你的代码抽象能力会上一个台阶。入门阶段不用强求知道有这条路线就行。5. 动手做一个 Todo List把知识串起来5.1 需求拆解学一个东西最忌讳的是“每个语法都认识拼起来就懵”。所以这里咱们做一个非常经典但五脏俱全的 Todo List 应用把前面讲的所有核心概念一次性串起来。需求很简单三个能力在输入框里输入内容点添加或按回车新事项进入列表点击事项文本可以切换“已完成”状态文案上显示删除线每个事项后面有一个删除按钮点击移除该事项这个项目规模不大但它完整覆盖了组件通信父组件和输入框的关系、state 管理列表数据、输入框内容、事件处理、列表渲染四个核心知识点。跟着写一遍你对 React 的“数据驱动”式开发流程会有一个非常直观的体感。5.2 核心代码实现新建一个组件文件TodoList.jsx把下面的代码完整敲一遍。别直接复制粘贴哪怕慢亲手敲一遍的效果完全不一样。import { useState } from react; function TodoList() { const [todos, setTodos] useState([]); const [inputValue, setInputValue] useState(); const addTodo () { const text inputValue.trim(); if (!text) return; setTodos([...todos, { id: Date.now(), text, done: false }]); setInputValue(); }; const toggleTodo (id) { setTodos(todos.map((todo) todo.id id ? { ...todo, done: !todo.done } : todo )); }; const removeTodo (id) { setTodos(todos.filter((todo) todo.id ! id)); }; return ( div classNametodo-container h1待办事项/h1 div classNameinput-row input value{inputValue} placeholder输入要做的事按回车添加 onChange{(e) setInputValue(e.target.value)} onKeyDown{(e) e.key Enter addTodo()} / button onClick{addTodo}添加/button /div ul {todos.map((todo) ( li key{todo.id} className{todo.done ? done : } span onClick{() toggleTodo(todo.id)}{todo.text}/span button onClick{() removeTodo(todo.id)}删除/button /li ))} /ul /div ); } export default TodoList;代码不长但每一段都有讲究。inputValue用来存输入框的实时内容输入框的value直接绑定它onChange里更新它——这种“输入框的值由 state 控制”的写法叫受控组件。一开始可能会有疑惑“为什么我要把输入框的值交给 state 管不嫌麻烦吗”受控组件的好处在于输入框的内容永远是 React 状态的一部分你可以随时读取、修改、重置。你看添加完事项后一句setInputValue()输入框就清空了多干净。todos是核心数据。addTodo里用了[...todos, {...}]展开数组生成新数组而不是todos.push(...)这对应前面讲的“新引用”原则。toggleTodo里用map遍历命中的那条done字段取反其余原样返回最终也是生成一个新数组。removeTodo用filter过滤掉目标项同样不修改原数组。三个操作没有一个是直接改原数据的——这不是强迫症而是 React 的渲染机制要求你必须返回新引用界面才能在数据变化后正确更新。用Date.now()当 id是为了保证 key 的稳定性。列表渲染时 React 会要求每一项有一个唯一的key用于区分、追踪每个元素。后面讲坑的时候会详细说 key 用不对的危害这里先记住id 要稳定、唯一、不随渲染改变。5.3 组件拆分和状态提升写完这个单文件版本后可以进一步思考拆分。比如把单个待办项抽成一个TodoItem组件function TodoItem({ todo, onToggle, onRemove }) { return ( li className{todo.done ? done : } span onClick{onToggle}{todo.text}/span button onClick{onRemove}删除/button /li ); }这时候TodoList负责持有todos状态把它逐个传给TodoItem同时又传了两个回调函数onToggle和onRemove。子组件自己不存列表数据数据在父组件手里子组件想动数据就调用父组件传下来的回调。这个设计模式叫“状态提升”把共享数据放在最近的共同父组件里用 props 分发用回调来更新。这个模式也进一步说明了一个 React 的常见思路数据往哪放、事件往哪传多想一想“父子关系”。一开始觉得麻烦但项目一大这种清晰的数据归属能救你一命。写完这段代码后可以在浏览器里试一下输入几件事回车添加点击文字出现删除线点删除项从列表消失。虽然界面简单但那句“数据变了界面自动更新”已经从概念变成了看得见摸得着的体验。6. 新手最容易踩的坑一次说清楚6.1 状态没更新可能是异步的锅这个坑我几乎在每一个新人身上都见过。具体表现是点击按钮后立刻console.log(state)输出的还是旧值。前面已经讲过原因——setState是异步的更新不会立刻同步到变量上。但实际项目里还有更隐蔽的版本function handleSubmit() { setLoading(true); saveData().then(() { setLoading(false); console.log(loading); // 不一定是你想的值 }); }想要拿到最新状态不要依赖同步读取。要么在useEffect依赖这个状态的地方去处理后续逻辑要么用函数式更新做基于旧值的计算。React 开发里最忌讳的就是“用命令式思维去读状态变量”你要学会接受“状态是异步、最终一致的”这个事实界面展示才是状态的最终投影。6.2 useEffect 陷入死循环useEffect最经典的翻车现场是页面一个劲地重复请求或者 CPU 莫名其妙飙高。罪魁祸首通常有两类一类是在 effect 里调用了setState而这个 state 又写在依赖数组里每次 set 完触发重新渲染重新渲染又跑 effect无限循环。另一类是依赖数组里写了对象或数组字面量useEffect(() { // 做一些事情 }, [user]); // 如果 user 是对象这里每次都是新引用 setUser({ name: 张三 }); // 哪怕值一样引用也变了{ name: 张三 }这种对象每次创建都是一个新引用。React 对比依赖时发现引用变了就会再次触发 effect。解决思路也很明确依赖数组只放稳定值对象从 props 或 state 里取别在依赖数组里临时构造对象。在依赖项条件变化时可以在 effect 内部判断而不是傻傻地把引用写进依赖。6.3 key 到底怎么选列表渲染时用index当 key是另一个高频踩坑点。它的问题在于key 的使命是让 React 能识别“哪个元素是谁”用index意味着元素的唯一标识是它在数组里的位置。一旦列表发生删除、排序、在中间插入等操作元素的 index 就会和组件内部状态错位。举一个实际例子你渲染一个输入框列表每个输入框都用自己的 state 存内容key 用的是 index。当你删除第一项时原本第二项的内容和状态会顶到第一项的位置上界面看起来就像“内容串位了”。因为 React 认为 key 变了这是“新元素”重新创建组件原本在那个位置的 DOM 和 state 都被重置了。解决办法很简单id 用Date.now()或者自增计数都可以只要稳定、唯一、不对位置变化敏感就行。像crypto.randomUUID()生成唯一 id 也是好选择不过那个值每次生成都会变不适合用于关键业务数据主键但作为列表渲染 key 是完全够用的。6.4 class 还是 className事件传参怎么搞JSX 和 HTML 的差异前面列过表了。但有一个坑值得单独拎出来说因为报错信息很迷惑你在 JSX 里写了div classbox页面样式就是死活不生效浏览器控制台还只给你一句 “Warning: Invalid DOM propertyclass. Did you meanclassName?”。第一次遇到你可能会满世界找 CSS 文件的问题结果问题出在 JSX 属性名上。class是 JavaScript 保留字JSX 里必须用className。事件传参是另一个经典困惑点。看下面这个写法有什么问题button onClick{removeTodo(id)}删除/button问题大了。removeTodo(id)会在渲染时被立即执行而不是在点击时执行。你需要用箭头函数包一层把“函数本身”传给 onClick而不是“函数的调用结果”button onClick{() removeTodo(id)}删除/button这样点击时箭头函数才执行里面才调用removeTodo(id)。这个规则也解释了为什么很多组件里会看到满屏的() ...不是闲得慌而是必须这么写才能按需调用。实际写代码时偶尔会发现 React 在控制台给出建议按它的提示改一般没错。但很多新人会被长长的报错堆栈吓住其实大部分时候报错信息前几行已经把问题说得明明白白了与其盯着代码反复看不如先把报错贴到翻译器里看一遍。React 入门到这个程度主干已经全部打通了。我个人带新人的一个体会是不要急着背 API而是先建立几个核心心智模型——“组件是函数”、“数据是单向流动的”、“UI 是状态的映射”、“更新状态必须生成新引用”。这四个模型一旦在脑子里立住后面学状态管理库、路由、服务端渲染这些进阶内容都会觉得顺理成章。这篇教程里如果哪个坑你也踩过或者有什么你见过的奇葩问题我没写进去欢迎评论里聊聊大家一起补全这些“成长路上的垫脚石”。