来试试调试React源码~

603 阅读7分钟

为什么没事要去调试源码呢?因为网上的各种原理教程总觉得有些虚虚实实,心里还是没谱。调试源码虽然很费时间,但也能很有收获。本篇文章会基于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的核心逻辑如下。这里重点关注beginWorkcompleteUnitOfWrok两个函数。它会递归的构造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的核心逻辑如下,它也是执行一个循环逻辑。

  1. 首先处理当前节点completeWork()
  2. 然后判断当前节点的sibling是否存在,存在则跳出循环,后续将执行performUnitOfWork(sibling)
  3. 不存在sibling则设置completedWork为returnFiber,继续执行completeUnitOfWork(returnFiber)
  4. 直到当前节点的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的信息,通过createElementAPI创建出真实的DOM节点,并保存在fiber的stateNode属性上。div的子节点h1与a则通过appendChildAPI被添加到子元素中。
  • 只有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为例:

  1. Continue。前往下一个断点。
  2. Step Over一次执行完一条语句。比如执行一个函数const num = Math.sqrt(2,2),如果你不想进入Math.sqrt里查看细节,只想看到执行完的结果,那么就用Step Over。反之,如果你想看Math.sqrt里是如何实现的,则点击下面的Step Into。
  3. Step Into。将会进入函数,一行一行执行。比如const num = Math.sqrt(2,2),点击Step Into后点会进入到Math.sqrt内部。
  4. Step Out。与Step Into对应。执行后将会跳出当前执行的函数。
  5. Restart。终止当前程序执行并重启调试。
  6. 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”中。