为什么没事要去调试源码呢?因为网上的各种原理教程总觉得有些虚虚实实,心里还是没谱。调试源码虽然很费时间,但也能很有收获。本篇文章会基于React18,如果大家也想试试调试源码,可以把这当成一个开始~
准备工作
调试前我们需要先准备好调试代码以及调试工具。
调试代码
为了获取一个最简单的React调试环境,可以参考React官方文档:development-workflow。
# 克隆
$ git clone https://github.com/facebook/react.git
# 安装依赖
$ cd react & yarn
# 构建
$ yarn build react/index,react-dom/index --type=UMD
构建完成后,打开fixtures/packaging/babel-standalone/dev.html文件,其中引用的即是刚才构建好的React。
<html>
<body>
<script src="../../../build/oss-experimental/react/umd/react.development.js"></script>
<script src="../../../build/oss-experimental/react-dom/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
<div id="app"></div>
<script type="text/babel">
const container = document.getElementById('app');
const root = ReactDOM.createRoot(container);
root.render(<h1>Hello World!</h1>);
</script>
</body>
</html>;
解释下代码里的"text/babel":babel会在DOM加载完成时检测"text/babel"类型的脚本,并转换代码后执行,从而支持直接编写JSX。
sricpt
标签类型为type="text/babel"
时,其内部代码是不会被浏览器执行的。
如果不想麻烦,也可以直接使用已发布的React包。只需将React链接修改为已发布的链接即可。下面一个单独的html就可以用来直接调试React18源码了。
<html>
<body>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
<div id="app"></div>
<script type="text/babel">
const container = document.getElementById('app');
const root = ReactDOM.createRoot(container);
debugger;
root.render(<h1>Hello World!</h1>);
</script>
</body>
</html>;
调试工具
可以使用浏览器(推荐Chrome)开发者工具,也可以用VSCode,选择个人熟悉的即可,也可以各取所长。
浏览器开发者工具
在调试前,我们可以通过开发者工具的Performance面板查看完整的运行过程。效果如下:
点击函数可以查看源码位置:
下一步我们即可以在Sources面板的源码中打断点调试。
或者直接在代码中添加debugger
:
const container = document.getElementById('app');
const root = ReactDOM.createRoot(container);
debugger; // 程序将停止在这里
root.render(<h1>Hello World!</h1>);
VSCode
VSCode也支持调试网页代码。第一步在左侧调试面板中点击新建launch.json文件,内容如下,将url改为网页地址或文件地址即可。
接着在react-dom.development.js文件中打上断点。
最后一步,点击左侧调试面板的Start Debugging启动调试。
文章的最后附了一些调试小技巧。
开始调试!
接下来就可以正式开始启动调试了。本篇将在VSCode上调试下面一个简单的React程序。调试前需要明确目标,比如这个示例就是为了梳理React渲染的过程,重点看看JSX是怎么转换为fiber,fiber树是如何构建,如何更新为DOM的。
const container = document.getElementById('app');
debugger; // 从这里开始断点调试
const root = ReactDOM.createRoot(container);
root.render(
<div>
<h1>Hello World</h1>
<a href='https://react.dev'>Welcome to React</a>
</div>
);
点击开始调试后,程序代码如下,并且会停在debugger
处。这里可以看到JSX已经转换为了React.createElement
的形式。
'use strict';
var container = document.getElementById('app');
debugger; // 从这里开始断点调试
var root = ReactDOM.createRoot(container);
root.render(React.createElement(
'div',
null,
React.createElement(
'h1',
null,
'Hello World'
),
React.createElement(
'a',
{ href: 'https://react.dev' },
'Welcome to React'
)
));
接下来就是一步步往下调试就行了。首先是ReactDOM.createRoot()
,这个函数我们就简要带过,这里主要是创建Fiber树的根节点,并且与container
这个DOM元素绑定。
接下来就会进入root.render()
。它的参数是一个ReactElement,具体内容如下:
{
$$typeof: Symbol(react.element),
type: "div",
key: null,
ref: null,
props: {
children: [
{
$$typeof: Symbol(react.element),
type: "h1",
key: null,
ref: null,
props: {
children: "Hello World",
},
_owner: null,
_store: {
},
},
{
$$typeof: Symbol(react.element),
type: "a",
key: null,
ref: null,
props: {
href: "https://react.dev",
children: "Welcome to React",
},
_owner: null,
_store: {
},
},
],
},
_owner: null,
_store: {
},
}
ReactElement中记录了元素标签、属性以及子元素等信息,通过这些信息可以还原出真实DOM。
render()
的核心逻辑如下。它将传入的ReactElement保存到update.payload中,然后插入一个异步的更新队列。
function updateContainer(element){
// ...
var update = createUpdate(lane);
update.payload = {
element: element
};
var root = enqueueUpdate(current$1, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, current$1, lane);
entangleTransitions(root, current$1, lane);
}
return lane;
}
这里忽略异步更新逻辑,直接跳到正式的更新入口
performConcurrentWorkOnRoot
(利用performance面板可以看到函数调用过程)。调试时直接搜索找到performConcurrentWorkOnRoot
函数,在该处打断点并“Continue”前往下一个端点即可。
performConcurrentWorkOnRoot
虽然叫performConcurrentWorkOnRoot,但首次渲染会走renderRootSync()
,即同步渲染。
// shouldTimeSlice为false
shouldTimeSlice ? renderRootConcurrent(root, lanes) : renderRootSync(root, lanes);
renderRootSync的核心逻辑是workLoopSync()
,其源码如下:
function workLoopSync() {
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
容易看出来这是一个同步迭代的过程。workInProgress
即当前正在处理的FiberNode。第一个处理的Fiber节点是前面ReactDOM.createRoot()
中初始化出的Fiber树的根节点。对于初次渲染而言,最终目的就是将root.render()
中传入的ReactElement转换为Fiber节点,挂到根节点上,构造出第一个版本的完整的Fiber树,并将其渲染到页面上。
performUnitOfWork
performUnitOfWork的核心逻辑如下。这里重点关注beginWork
与completeUnitOfWrok
两个函数。它会递归的构造fiber节点,并将它们串联起来。
function performUnitOfWork(unitOfWork) {
var current = unitOfWork.alternate;
setCurrentFiber(unitOfWork);
// beginWork处理完后返回下一个待处理的Fiber节点
var next = beginWork(current, unitOfWork);
// 如果next存在,则继续处理该节点,反之则对当前节点执行completeUnitOfWork。
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
beginWork
初次渲染中,beginWork
最后会根据workInProgress.tag
来做不同处理。第一次处理的节点类型是HostRoot,因此将首先执行updateHostRoot。当后续workInProgress更新为div等节点时,则将执行updateHostComponent$1。
switch (workInProgress.tag) {
// ...
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
case HostComponent:
return updateHostComponent$1(current, workInProgress, renderLanes);
// ...
}
这里需要注意下两者的区别:它们最终都会执行reconcileChildren(),并返回workInProgress.child,不同的是children从何处获取。
// updateHostRoot
function updateHostRoot(){
// 对于hostRoot,会从前面提到的update.payload中取出element,作为hostRoot的children
// 并存储在workInProgress.memoizedState.element中
processUpdateQueue(workInProgress, nextProps, null, renderLanes);
var nextState = workInProgress.memoizedState;
var nextChildren = nextState.element; // 即root.render()中传入的ReactElement
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
// updateHostComponent$1
function updateHostComponent$1(){
// 对于hostComponent,直接从workInProgress.pendingProps.children中获取子元素
var nextProps = workInProgress.pendingProps;
var nextChildren = nextProps.children;
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
对于hostRoot,会从前面提到的update.payload中取出element,作为hostRoot的children。而对于普通元素,则可以直接从fiber节点的pendingProps中取出children。后面会提到普通元素的fiber节点是如何构造出来的。
beginWork中的重点就是reconcileChildren,它是构建Fiber树的关键逻辑。
reconcileChildren
reconcileChildren
的源码如下。
function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
if (current === null) {
// If this is a fresh new component that hasn't been rendered yet, we
// won't update its child set by applying minimal side-effects. Instead,
// we will add them all to the child before it gets rendered. That means
// we can optimize this reconciliation pass by not tracking side-effects.
workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
} else {
// If the current child is the same as the work in progress, it means that
// we haven't yet started any work on these children. Therefore, we use
// the clone algorithm to create a copy of all the current children.
// If we had any progressed work already, that is invalid at this point so
// let's throw it out.
workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
}
}
mountChildFibers与reconcileChildFibers仅仅是一个参数的区别,这个参数名为shouldTrackSideEffects
,后面会提到它的作用所在。
var reconcileChildFibers = createChildReconciler(true); // shouldTrackSideEffects为true
var mountChildFibers = createChildReconciler(false); // shouldTrackSideEffects为false
mountChildFibers与reconcileChildFibers最终都会调用reconcileChildFibersImpl
。其中会根据子元素是单个元素还是数组来决定是否调用reconcileChildrenArray
。
function reconcileChildFibersImpl(returnFiber, currentFirstChild, newChild, lanes){
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
// 子节点是一个React Element,比如示例中的div
case REACT_ELEMENT_TYPE:
return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
// ...
}
// 子节点是一个数组,比如示例中的div的子元素[h1, a]
if (isArray(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
}
}
比如hostRoot的子元素为div,则将执行placeSingleChild(reconcileSingleElement())
;而div的子元素为[h1, a],则将执行reconcileChildrenArray()
。
reconcileSingleElement
reconcileSingleElement的核心逻辑如下。它根据element创建一个新的Fiber节点,并将其return属性指向returnFiber。
var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);
_created4.ref = coerceRef(returnFiber, currentFirstChild, element);
_created4.return = returnFiber;
根据示例中的div元素创建的fiber结构如下:
var _created4 = {
type: 'div',
elementType: 'div',
pendingProps: {
children: [h1, a]
},
return: FiberNode, // HostRoot
child: null,
sibling: null,
flags: 0
}
它包含了element的类型与属性,并有一个return属性指向它的父节点。
同时执行的逻辑还有placeSingleChild
,其源码如下。设置fiber.flags属性为Placement意味着这个节点是需要插入的。
function placeSingleChild(newFiber) {
// This is simpler for the single child case. We only need to do a
// placement for inserting new children.
if (shouldTrackSideEffects && newFiber.alternate === null) {
newFiber.flags |= Placement | PlacementDEV;
}
return newFiber;
}
这里出现了shouldTrackSideEffects
,它的作用是什么呢?设想一下,对于<div><h1 /><a /></div>
这样一个结构,是否每个节点都要标记为“插入”呢?显然不是,我们只需要插入div这个根节点到页面上即可。所以在初始化渲染过程中,只有hostRoot会调用reconcileChildFibers(shouldTrackSideEffects为true) ,即div节点会被标记为Placement。而其他子节点只会调用mountChildFibers(shouldTrackSideEffects为false),不会被标记(后面会看到<h1>
与<a>
会被添加为<div>
的的子节点,所以最后只插入<div>
就能显示全部内容)。
reconcileChildrenArray
这个函数里包含了子元素diff的逻辑,但是对于初始化渲染只需执行下面的部分代码。
if (oldFiber === null) {
// 遍历children
for (; newIdx < newChildren.length; newIdx++) {
// 根据element创建fiber,并设置return属性指向父节点
var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (_newFiber === null) {
continue;
}
// 示例中此时shouldTrackSideEffects为true,所以placeChild不会添加flags更新
lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber;
} else {
// 设置sibling为下一个子节点
previousNewFiber.sibling = _newFiber;
}
previousNewFiber = _newFiber;
}
return resultingFirstChild;
}
这里创建子元素的fiber节点时,除了设置return指向父节点,还会设置sibling指向同级的下一个子节点,形成下面的结构。最后将第一个子节点返回作为下一个处理节点。
completeUnitOfWork
当beginWork()
的返回值为null,即当前fiber节点不存在子节点时,将执行completeUnitOfWork
。比如示例中的h1与a元素,它们是不存在子节点的。
function performUnitOfWork(unitOfWork) {
// ...
var next = beginWork(current, unitOfWork, entangledRenderLanes);
// 示例中beginWork总是返回unitOfWork.child,如果不存在则执行completeUnitOfWork
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
completeUnitOfWork的核心逻辑如下,它也是执行一个循环逻辑。
- 首先处理当前节点
completeWork()
。 - 然后判断当前节点的sibling是否存在,存在则跳出循环,后续将执行performUnitOfWork(sibling) 。
- 不存在sibling则设置completedWork为returnFiber,继续执行completeUnitOfWork(returnFiber) 。
- 直到当前节点的returnFiber为空(最终是HostRootFiber,其return为空)。
function completeUnitOfWork(unitOfWork) {
var completedWork = unitOfWork;
var returnFiber = completedWork.return;
do {
next = completeWork(current, completedWork, entangledRenderLanes);
if (next !== null) {
workInProgress = next;
return;
}
var siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// 如果sibling存在则后续将执行performUnitOfWork(sibling)
workInProgress = siblingFiber;
return;
}
// 如果sibling不存在则执行completeUnitOfWork(returnFiber)
completedWork = returnFiber;
workInProgress = completedWork;
}while (completedWork !== null);
}
completeWork()
的处理逻辑如下,其主要作用是根据fiber结构创建出真实DOM。如果存在子节点,则将子节点的DOM添加到当前节点的DOM上。但需要注意,这里虽然构建了真实DOM,但并未插入到页面上,因此此时页面上还不会显示出内容(这里照应了前面所说的shouldTrackSideEffects内容,最后只需将div这个根元素插入到页面上即可)。
function completeWork(current, workInProgress, renderLanes) {
var newProps = workInProgress.pendingProps;
switch (workInProgress.tag) {
case HostComponent:
{
var _type2 = workInProgress.type;
// div#app
var _rootContainerInstance = getRootHostContainer();
// 调用document.createElement(type)创建DOM元素
var _instance3 = createInstance(_type2, newProps);
// 调用domNode.appendChild(child)添加子元素到当前的DOM元素中
appendAllChildren(_instance3, workInProgress);
// 将fiber的stateNode属性指向创建的真实DOM
workInProgress.stateNode = _instance3;
// 将props属性设置到DOM元素上
if (finalizeInitialChildren(_instance3, _type2, newProps)) {
markUpdate(workInProgress);
}
}
case HostRoot:
{
// 处理HostRootFiber
}
}
}
beginWork是从HostRootFiber开始,而最终completeUnitOfWork也是以HostRootFiber结束。至此整个workLoopSync循环就结束了。完整流程示意图如下:
整体而言,renderRootSync(workLoopSync)做了哪些事情呢?
- 针对root.render()传入的ReactElement同步构建了一棵完整的Fiber树,这些fiber节点通过return、child、sibling相连。
- 每个fiber节点都保存了对应的ReactElement的信息,通过
createElement
API创建出真实的DOM节点,并保存在fiber的stateNode属性上。div的子节点h1与a则通过appendChild
API被添加到子元素中。 - 只有div这个根元素的fiber节点被标记了Placement(插入)。
commitRoot
在前面的render过程中已经构建了一棵完整的Fiber树,并且进行了标记(flags)。最后还有一个commit的过程来将这些标记更新到页面上,成为我们可见的内容。
比如示例中只有div元素标记了Placement,最后将执行下面的代码做更新:
appendChildToContainer(parent, stateNode);
// 最终将调用appendChild API将div添加到container元素(div#app)上
parentNode.appendChild(child);
这里对React初始化渲染的过程进行了调试。它只是一个开始,如果大家感兴趣,可以借鉴这个调试经验自己去探索,比如diff是如何做的,hooks的实现原理、调度过程等。但要记住一点,每次调试时都专注一个点,切勿追求大而全,不然很容易把自己绕晕,毕竟React的源码还是挺复杂的。最后附上一些调试小技巧。
一些调试小技巧
一般调试都有这几个按钮,以VSCode为例:
- Continue。前往下一个断点。
- Step Over。一次执行完一条语句。比如执行一个函数
const num = Math.sqrt(2,2)
,如果你不想进入Math.sqrt
里查看细节,只想看到执行完的结果,那么就用Step Over。反之,如果你想看Math.sqrt
里是如何实现的,则点击下面的Step Into。 - Step Into。将会进入函数,一行一行执行。比如
const num = Math.sqrt(2,2)
,点击Step Into后点会进入到Math.sqrt
内部。 - Step Out。与Step Into对应。执行后将会跳出当前执行的函数。
- Restart。终止当前程序执行并重启调试。
- Stop。终止当前程序执行。
下面是一些常用的提高调试效率的小技巧:
善用Step Over
不是每个地方都需要Step Into来一行一行的调试,这时候使用Step Over,省略过程,快速获取结果就行。
遇到回调函数怎么办?
对于回调函数这种异步代码,我们无法通过Step Over或Step Into来跳转到指定处执行。
this.hooks.beforeCompile.callAsync(params, err => {
// 把断点打在回调函数里,点击Continue!
const compilation = this.newCompilation(params);
// ...
});
这时只需直接将断点打在回调函数里,点击Continue(前往下一个断点)即可。
不小心错过了想要调试的那一行?
直接将断点打在前面想要调试的那一行,点击Restart。
如何查看调用栈?
如果一个程序逻辑比较绕,回调比较多,梳理不清楚执行过程,可以查看调用栈来帮助分析。
想要时刻观察某个变量的变化?
将这个变量添加到“WATCH”中。