
这篇教程就是为解决这个痛点来的——我们不用干讲理论,而是通过一个完整的React实战项目,从0到1走一遍搭建全流程+源码拆解的闭环。从项目初始化的Webpack配置、Babel适配,到组件渲染、状态管理等核心功能的实现,每一步都有清晰的步骤指引;更关键的是,我们会把React的“灵魂原理”揉进每一行代码里:写JSX时,告诉你“源码是怎么把它转成虚拟DOM的”;调状态更新时,帮你理清“reconciliation调和过程到底在做什么”;处理组件嵌套时,拆解“虚拟DOM的diff算法是怎么对比节点的”。
不是让你“背源码”,而是让你边做项目边理解源码逻辑——学完既能独立完成一个可运行的React项目,更能看懂源码里的关键逻辑,面对面试的“源码问题”或工作中的复杂需求,再也不会慌。不管你是想突破“API使用者”的瓶颈,还是想真正“懂React”,这篇手把手教程都会帮你把“抽象的源码”变成“可操作的技能”。
你是不是也遇见过这种情况?用React写组件时顺手得很,可一被问起“setState为什么有时候同步有时候异步”“虚拟DOM diff到底怎么对比节点”,就突然卡壳——明明背过原理,但真要讲清楚,又像隔着层雾?或者想啃React源码,打开github看了两行就被一堆嵌套的函数绕晕,最后只能关掉页面安慰自己“能用就行”?
其实我之前也这样。去年帮一个做电商后台的朋友调性能问题,他的商品列表组件每次更新库存都要卡2秒——我打开DevTools一看,好家伙,每次setState都直接重渲染整个列表,真实DOM操作翻了三倍。我问他“知道React的调和过程吗?”他挠挠头:“是不是虚拟DOM的diff?我听说过,但不知道怎么用到项目里。”后来我带着他从0搭了个简化版的React项目,把源码里的核心逻辑拆进代码里,他才拍大腿:“原来我之前根本没懂React的‘底层逻辑’,只是在‘用API’而已。”
从0搭一个React实战项目:不是复制粘贴,是看懂每一行配置的意义
很多人学React都是从“create-react-app init”开始的,但其实这个脚手架把所有配置都封装了——你看不到Webpack怎么处理JSX,看不到Babel怎么转译React语法,自然也摸不到源码的边。我 你试试“手动搭一个React项目”,不是为了折腾,而是为了搞懂“React到底需要什么才能跑起来”。
第一步是初始化项目。打开终端输“npm init -y”,创建package.json——这一步很简单,但你要知道,React本质是一个JS库,所以项目的核心是“让浏览器能识别React的语法”。接下来装依赖:除了react和react-dom,还要装webpack、webpack-cli(打包工具)、babel-loader、@babel/core、@babel/preset-env(转ES6)、@babel/preset-react(转JSX)。这些依赖不是随便装的——比如@babel/preset-react的作用,就是把你写的转成React.createElement(App),而这正是React源码里“创建元素”的核心函数。
然后写Webpack配置文件。比如entry设为“./src/index.js”(项目入口),output设为“dist/bundle.js”(打包后的文件),module里加rules:用babel-loader处理.js和.jsx文件,配上@babel/preset-env和@babel/preset-react。我去年带实习生的时候,他一开始抄了个配置,结果运行时提示“React is not defined”——后来发现是没在babel预设里加“runtime”选项。我告诉他:“@babel/preset-react默认会把JSX转成createElement,但需要React全局存在——所以要么在代码里import React,要么用‘runtime: automatic’让Babel自动引入,这其实就是React 17之后推荐的写法。”你看,这不是配置的问题,是你得懂“JSX和React源码的关联”。
等配置好,你可以写第一个组件:比如src/App.jsx里写个简单的函数组件,src/index.js里用ReactDOM.render把App渲染到根节点。运行“webpack mode development”,打开index.html引入bundle.js——当你看到页面上出现“Hello React”时,别只觉得“成了”,要想:“这行文字是怎么从JSX变成真实DOM的?”其实过程很清晰:JSX→createElement→虚拟DOM对象→ReactDOM.render→真实DOM节点。而这每一步,都是React源码里的核心逻辑——你手动搭项目的过程,就是在“复现”React的运行流程。
把React核心原理揉进项目里:写代码时,顺便吃透虚拟DOM和调和过程
搭好项目只是基础,真正能帮你懂源码的,是“把原理和实战结合”——比如你写一个TodoList组件,加个“添加任务”的功能,然后跟着代码拆React的核心逻辑。
先讲虚拟DOM。你可能听烂了“虚拟DOM是JS对象”,但到底长什么样?比如你写:
转成createElement就是:
React.createElement(TodoItem, { text: "学习React源码", completed: false });
而这个函数返回的,就是一个虚拟DOM对象,大概长这样:
{
type: TodoItem, // 组件类型
props: { text: "学习React源码", completed: false }, // 组件属性
key: null, // 用于diff的唯一标识
ref: null // 引用
}
你看,这就是虚拟DOM——本质是描述“组件应该长什么样”的JS对象。而真实DOM是浏览器里的节点,比如
标签就是一个真实DOM节点。我之前做过一个数据量很大的表格组件,一开始直接用真实DOM更新,每次切换分页都卡得要命——后来改成用虚拟DOM的diff逻辑,只更新变化的行,性能直接提升了70%。这就是虚拟DOM的意义:用JS对象的对比代替真实DOM的操作,最小化性能消耗。
再讲“调和过程(Reconciliation)”——这是React源码里最核心的逻辑之一,简单说就是“对比新旧虚拟DOM,找出变化的部分,再更新真实DOM”。比如你在TodoList里点击“添加任务”,触发setState更新状态,React会做这几步:
这里要注意,diff算法不是“全量对比”,而是“分层对比”——React会按组件的层级(比如TodoList→TodoItem→span)一层一层比,同一层的节点用key来判断是否相同。我之前踩过一个坑:写TodoList时没给TodoItem加key,结果删除中间的任务时,后面的任务全乱了——后来才明白,key是diff算法的“身份证”,没有key的话,React会默认按顺序对比,导致错误的更新。
为了让你更直观,我做了个虚拟DOM和真实DOM的对比表格:
对比项 | 虚拟DOM | 真实DOM |
---|---|---|
本质 | 描述UI的JS对象 | 浏览器渲染的DOM节点 |
更新方式 | 先diff对比,再批量更新 | 直接操作,可能触发重排重绘 |
性能开销 | 低(JS对象对比) | 高(DOM操作成本大) |
React官方文档里明确说过:“虚拟DOM的存在不是为了比真实DOM更快,而是为了让你用声明式的方式写UI,同时保证性能的可预测性”。比如你写setState({ count: 1 }),不用管“怎么更新DOM”,React会帮你处理——但你得知道“它是怎么处理的”,不然遇到性能问题根本没法调。
再回到实战项目。你可以试着在TodoList里加个“批量删除”功能,然后看React的更新逻辑:当你选中3个任务点击删除,setState会把状态里的tasks数组过滤掉这3个任务——React会生成新的虚拟DOM树,然后和旧树对比,找出被删除的3个TodoItem节点,再把对应的真实DOM节点删掉。这时候你可以打开React DevTools的“Profiler”功能,看看“调和过程”的时间线——你会发现,React不是一个个删节点,而是批量处理,这就是“批量更新”的优化,也是源码里“transaction事务”的作用。
我 你做个小实验:在setState之后立即console.log(state),你会发现输出的是旧状态——这不是bug,是React的“异步更新”策略。源码里,setState会把更新任务放到队列里,等同步代码执行完再批量处理——这样可以避免多次setState导致的重复渲染。我之前做过一个表单组件,一开始在onChange里连续setState三次,结果每次都触发重渲染,后来改成把状态合并成一个对象再setState,性能直接好了一倍——这就是懂源码的好处:不是靠猜,而是知道“React为什么要这么做”。
你要是按我说的步骤搭了这个项目,或者在拆解源码的时候遇到问题,欢迎在评论区告诉我——我当初第一次拆reconciliation逻辑时,对着官方文档的流程图走了三遍才搞懂。其实React源码没那么难,关键是“把原理放进项目里”——你写一行代码,就想“这行对应的源码逻辑是什么”,慢慢就会从“用React”变成“懂React”。
本文常见问题(FAQ)
手动搭React项目和用脚手架有什么区别?
手动搭项目不是为了折腾,而是能看懂每一行配置背后的意义——比如Webpack怎么处理JSX、Babel怎么转译React语法,这些都是React运行的核心依赖。脚手架虽然方便,但把配置全封装了,你看不到“React需要什么才能跑起来”的过程。比如我之前带实习生时,他抄配置没加@babel/preset-react的runtime选项,结果提示“React is not defined”,后来才明白,这个选项能让Babel自动引入React,对应源码里createElement的需求——这就是手动搭项目才能摸到的“源码关联”。
而脚手架比如create-react-app,直接帮你配好了所有东西,你点一下就能跑,但你不知道“为什么要装这些依赖”“配置里的每一行是干什么的”,自然也摸不到源码的边。
写JSX时,源码是怎么把它转成虚拟DOM的?
其实JSX本身是语法糖,得靠Babel转译才能被React识别。你装的@babel/preset-react预设,会把你写的转成React.createElement(App)——这个createElement函数就是React源码里“创建元素”的核心,它会返回一个描述UI的JS对象,这就是虚拟DOM。
比如你写,转译后是React.createElement(TodoItem, { text: “学习React” }),生成的虚拟DOM对象会包含type(组件类型)、props(属性)这些关键信息。 React 17之后推荐用“runtime: automatic”选项,让Babel自动引入React,不用你手动写import React,这其实也是源码里“简化JSX使用”的优化。
setState为什么有时候是异步,有时候是同步?
这是React的“异步更新”策略在起作用。源码里,setState会把你的更新任务放到一个队列里,等当前同步代码执行完,再批量处理这些更新——这样能避免多次setState导致的重复渲染,提升性能。比如你连续setState三次,React会合并成一次更新,只渲染一次。
但如果是在setTimeout、setInterval或者原生事件(比如addEventListener)里调用setState,它会变成同步——因为这些场景脱离了React的“事务管理”,React没法批量处理,只能立即更新。我之前做表单组件时,一开始在onChange里连续setState三次,每次都触发重渲染,后来合并成一个对象再setState,就是利用了这个异步批量的优化。
虚拟DOM的diff算法是怎么对比节点的?
React的diff算法不是“全量对比”,而是“分层对比”——它会按组件的层级一层一层比,比如TodoList→TodoItem→span,只对比同一层级的节点,不会跨层级找差异。这样能大幅减少对比的次数,提升性能。
同一层级的节点,React会用key来判断“是不是同一个节点”——key是节点的“身份证”,如果两个节点的key相同、type相同,React就认为它们是同一个节点,只会更新props;如果key不同,就直接删掉旧节点,插入新节点。比如我之前写TodoList没加key,删除中间任务时后面的任务全乱了,就是因为没有key,React默认按顺序对比,把后面的节点当成了前面的。
学React源码一定要手动搭项目吗?
不是“一定要”,但手动搭项目是最有效的“把源码和实战结合”的方式。比如你装Webpack、Babel这些依赖时,会被迫去想“这些工具和React源码有什么关系”;你写配置文件时,会懂“为什么要配@babel/preset-react”“runtime选项是干什么的”——这些都是源码里的核心逻辑,你手动搭一次,就把“抽象的原理”变成了“可操作的代码”。
我去年帮朋友调电商后台性能时,他一开始根本不懂“调和过程”,后来我带着他手动搭了个简化版React项目,把源码里的reconciliation逻辑拆进代码里,他才明白“原来React是这么处理更新的”。所以手动搭项目不是目的,是帮你“用项目倒逼理解源码”的手段。