MyPrototypeWhat/take-down

React原理相关

MyPrototypeWhat opened this issue · 0 comments

React原理相关知识点

[TOC]

React-diff

参考:

React技术揭秘-Diff算法

一个DOM节点在某一时刻最多会有4个节点和他相关。

  1. current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点
  2. workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点
  3. DOM节点本身。
  4. JSX对象。即ClassComponentrender方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。

Diff算法的本质是对比1和4,生成2。

  • 为了降低算法复杂度,Reactdiff会预设三个限制:

    • 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他

    • 两个不同类型的元素会产生出不同的树。如果元素由div变为pReact会销毁div及其子孙节点,并新建p及其子孙节点。

    • 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:

      // 更新前
      <div>
        <p key="ka">ka</p>
        <h3 key="song">song</h3>
      </div>
      
      // 更新后
      <div>
        <h3 key="song">song</h3>
        <p key="ka">ka</p>
      </div>

      如果没有keyReact会认为div的第一个子节点由p变为h3,第二个子节点由h3变为p。这符合限制2的设定,会销毁并新建。

      但是当我们用key指明了节点前后对应关系后,React知道key === "ka"p在更新后还存在,所以DOM节点可以复用,只是需要交换下顺序。

  • 实现:

    1. newChild类型为objectnumberstring,代表同级只有一个节点
    2. newChild类型为Array,同级有多个节点
    • 我们从Diff的入口函数reconcileChildFibers出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数。

      // 根据newChild类型选择不同diff函数处理
      function reconcileChildFibers(
        returnFiber: Fiber,
        currentFirstChild: Fiber | null,
        newChild: any,
      ): Fiber | null {
      
        const isObject = typeof newChild === 'object' && newChild !== null;
      
        if (isObject) {
          // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
          switch (newChild.$$typeof) {
            case REACT_ELEMENT_TYPE:
              // 调用 reconcileSingleElement 处理
            // // ...省略其他case
          }
        }
      
        if (typeof newChild === 'string' || typeof newChild === 'number') {
          // 调用 reconcileSingleTextNode 处理
          // ...省略
        }
      
        if (isArray(newChild)) {
          // 调用 reconcileChildrenArray 处理
          // ...省略
        }
      
        // 一些其他情况调用处理函数
        // ...省略
      
        // 以上都没有命中,删除节点
        return deleteRemainingChildren(returnFiber, currentFirstChild);
      }
    • 单节点Diff:

      • 不论当前节点个数,此次更新的节点为单个的情况

      • object类型为例,会进入reconcileSingleElement函数

        const isObject = typeof newChild === 'object' && newChild !== null;
        
          if (isObject) {
            // 对象类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
            switch (newChild.$$typeof) {
              case REACT_ELEMENT_TYPE:
                // 调用 reconcileSingleElement 处理
              // ...其他case
            }
          }
      • reconcileSingleElement:

        function reconcileSingleElement(
          returnFiber: Fiber,
          currentFirstChild: Fiber | null,
          element: ReactElement
        ): Fiber {
          const key = element.key;
          let child = currentFirstChild;
          
          // 首先判断是否存在对应DOM节点
          while (child !== null) {
            // 上一次更新存在DOM节点,接下来判断是否可复用
        
            // 首先比较key是否相同
            if (child.key === key) {
        
              // key相同,接下来比较type是否相同
        
              switch (child.tag) {
                // ...省略case
                
                default: {
                  if (child.elementType === element.type) {
                    // type相同则表示可以复用
                    // 返回复用的fiber
                    return existing;
                  }
                  
                  // type不同则跳出switch
                  break;
                }
              }
              // 代码执行到这里代表:key相同但是type不同
              // 将该fiber及其兄弟fiber标记为删除
              deleteRemainingChildren(returnFiber, child);
              break;
            } else {
              // key不同,将该fiber标记为删除
              deleteChild(returnFiber, child);
            }
            child = child.sibling;
          }
        
          // 创建新Fiber,并返回 ...省略
        }
      • 总结:

        • 先判断keykey相同,判断type,相同则复用,不同则删除当前fiber和兄弟fiber
          • 为什么要删除兄弟fiber
            • 因为走到这个地方的前提是key相同,type不同。既然唯一的可能性(key代表唯一标识)都不能复用,那么剩下的fiber都没有机会了,自然被删除。
        • key不同则删除当前fiber
    • 多节点Diff:

      • 一个JSX对象,children属性为数组,就会走到reconcileChildrenArray函数

        {
          $$typeof: Symbol(react.element),
          key: null,
          props: {
            children: [
              {$$typeof: Symbol(react.element), type: "li", key: "0", ref: null, props: {},}
              {$$typeof: Symbol(react.element), type: "li", key: "1", ref: null, props: {},}
              {$$typeof: Symbol(react.element), type: "li", key: "2", ref: null, props: {},}
              {$$typeof: Symbol(react.element), type: "li", key: "3", ref: null, props: {},}
            ]
          },
          ref: null,
          type: "ul"
        }
      • 几种情况:

        • 节点更新:

          // 之前
          <ul>
            <li key="0" className="before">0<li>
            <li key="1">1<li>
          </ul>
          
          // 之后 情况1 —— 节点属性变化
          <ul>
            <li key="0" className="after">0<li>
            <li key="1">1<li>
          </ul>
          
          // 之后 情况2 —— 节点类型更新
          <ul>
            <div key="0">0</div>
            <li key="1">1<li>
          </ul>
        • 节点新增或减少

        • 节点位置变化

        • 同级多个节点的Diff一定属于以上三种情况中的一种或多种。

      • 实现:

        • React团队发现,在日常开发中,更新组件相较于新增删除,发生的频率更高。所以Diff会优先判断当前节点是否属于更新

          ​ 注意

          在我们做数组相关的算法题时,经常使用双指针从数组头和尾同时遍历以提高效率,但是这里却不行。

          虽然本次更新的JSX对象 newChildren为数组形式,但是和newChildren中每个组件进行比较的是current fiber,同级的Fiber节点是由sibling指针链接形成的单链表,即不支持双指针遍历。

          newChildren[0]fiber比较,newChildren[1]fiber.sibling比较。

          所以无法使用双指针优化。

        • 基于以上原因,Diff算法的整体逻辑会经历两轮遍历:

        • 第一轮遍历:处理更新的节点。

          • let i = 0,遍历newChildren,将newChildren[i]oldFiber比较,判断DOM节点是否可复用。

          • 如果可复用,i++,继续比较newChildren[i]oldFiber.sibling,可以复用则继续遍历。

          • 如果不可复用,分两种情况:

            • key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。

              // 之前
              <li key="0">0</li>
              <li key="1">1</li>
              <li key="2">2</li>
                          
              // 之后
              <li key="0">0</li>
              <li key="2">1</li>
              <li key="1">2</li>
              • 第一个节点可复用,遍历到key === 2的节点发现key改变,不可复用,跳出遍历,等待第二轮遍历处理。
              • 此时oldFiber剩下key === 1key === 2未遍历,newChildren剩下key === 2key === 1未遍历
            • key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历

              // 之前
              <li key="0" className="a">0</li>
              <li key="1" className="b">1</li>
                          
              // 之后 情况1 —— newChildren与oldFiber都遍历完
              <li key="0" className="aa">0</li>
              <li key="1" className="bb">1</li>
                          
              // 之后 情况2 —— newChildren没遍历完,oldFiber遍历完
              // newChildren剩下 key==="2" 未遍历
              <li key="0" className="aa">0</li>
              <li key="1" className="bb">1</li>
              <li key="2" className="cc">2</li>
                          
              // 之后 情况3 —— newChildren遍历完,oldFiber没遍历完
              // oldFiber剩下 key==="1" 未遍历
              <li key="0" className="aa">0</li>
          • 如果newChildren遍历完(即i === newChildren.length - 1)或者oldFiber遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束。

        • 第二轮遍历:处理剩下的不属于更新的节点。

          • newChildrenoldFiber同时遍历完

            • 最理想的情况:只需在第一轮遍历进行组件更新。此时Diff结束。
          • newChildren没遍历完,oldFiber遍历完

            • 已有的DOM节点都复用了,这时还有新加入的节点意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement
            • 你可以在这里看到这段源码逻辑
          • newChildren遍历完,oldFiber没遍历完

            • 意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion
            • 你可以在这里看到这段源码逻辑
          • newChildrenoldFiber都没遍历完

            • 这意味着有节点在这次更新中改变了位置。重点!!,下文详细讲解
            • 你可以在这里看到这段源码逻辑
          • 处理移动的节点

            • 由于有节点改变了位置,所以不能再用位置索引i对比前后的节点,那么我们需要使用key

            • 为了快速的找到key对应的oldFiber,我们将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。(这地方有些像vue2的diff)

              const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
            • 接下来遍历剩余的newChildren,通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber

          • 标记节点是否移动

            • 我们需要明确:节点是否移动是以什么为参照物?

            • 参照物是:最后一个可复用的节点在oldFiber中的位置索引(用变量lastPlacedIndex表示)。

            • 方便理解,两个例子:

              • 剩余oldFiber生成map(key:index),遍历剩余newChildren,在oldFiber中匹配key找到对应老节点index

              • 在Demo中我们简化下书写,每个字母代表一个节点,字母的值代表节点的key

                // 之前
                abcd
                
                // 之后
                acdb
                
                ===第一轮遍历开始===
                a(之后)vs a(之前)  
                key不变,可复用
                此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
                所以 lastPlacedIndex = 0;
                
                继续第一轮遍历...
                
                c(之后)vs b(之前)  
                key改变,不能复用,跳出第一轮遍历
                此时 lastPlacedIndex === 0;
                ===第一轮遍历结束===
                
                ===第二轮遍历开始===
                newChildren === cdb,没用完,不需要执行删除旧节点
                oldFiber === bcd,没用完,不需要执行插入新节点
                
                将剩余oldFiber(bcd)保存为map
                
                // 当前oldFiber:bcd
                // 当前newChildren:cdb
                
                继续遍历剩余newChildren(在map中找相同key)
                
                key === c  oldFiber中存在
                const oldIndex = c(之前).index;
                此时 oldIndex === 2;  // 之前节点为 abcd,所以c.index === 2
                比较 oldIndex  lastPlacedIndex;
                
                如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
                并将 lastPlacedIndex = oldIndex;
                如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动
                
                在例子中,oldIndex 2 > lastPlacedIndex 0
                 lastPlacedIndex = 2;
                c节点位置不变
                
                继续遍历剩余newChildren
                
                // 当前oldFiber:bd
                // 当前newChildren:db
                
                key === d  oldFiber中存在
                const oldIndex = d(之前).index;
                oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3
                 lastPlacedIndex = 3;
                d节点位置不变
                
                继续遍历剩余newChildren
                
                // 当前oldFiber:b
                // 当前newChildren:b
                
                key === b  oldFiber中存在
                const oldIndex = b(之前).index;
                oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
                 b节点需要向右移动
                ===第二轮遍历结束===
                // 之前
                abcd
                // 之后
                dabc
                
                ===第一轮遍历开始===
                d(之后)vs a(之前)  
                key改变,不能复用,跳出遍历
                ===第一轮遍历结束===
                
                ===第二轮遍历开始===
                newChildren === dabc,没用完,不需要执行删除旧节点
                oldFiber === abcd,没用完,不需要执行插入新节点
                
                将剩余oldFiber(abcd)保存为map
                
                继续遍历剩余newChildren
                
                // 当前oldFiber:abcd
                // 当前newChildren dabc
                
                key === d  oldFiber中存在
                const oldIndex = d(之前).index;
                此时 oldIndex === 3; // 之前节点为 abcd,所以d.index === 3
                比较 oldIndex  lastPlacedIndex;
                oldIndex 3 > lastPlacedIndex 0
                 lastPlacedIndex = 3;
                d节点位置不变
                
                继续遍历剩余newChildren
                
                // 当前oldFiber:abc
                // 当前newChildren abc
                
                key === a  oldFiber中存在
                const oldIndex = a(之前).index; // 之前节点为 abcd,所以a.index === 0
                此时 oldIndex === 0;
                比较 oldIndex  lastPlacedIndex;
                oldIndex 0 < lastPlacedIndex 3
                 a节点需要向右移动
                
                继续遍历剩余newChildren
                
                // 当前oldFiber:bc
                // 当前newChildren bc
                
                key === b  oldFiber中存在
                const oldIndex = b(之前).index; // 之前节点为 abcd,所以b.index === 1
                此时 oldIndex === 1;
                比较 oldIndex  lastPlacedIndex;
                oldIndex 1 < lastPlacedIndex 3
                 b节点需要向右移动
                
                继续遍历剩余newChildren
                
                // 当前oldFiber:c
                // 当前newChildren c
                
                key === c  oldFiber中存在
                const oldIndex = c(之前).index; // 之前节点为 abcd,所以c.index === 2
                此时 oldIndex === 2;
                比较 oldIndex  lastPlacedIndex;
                oldIndex 2 < lastPlacedIndex 3
                 c节点需要向右移动
                
                ===第二轮遍历结束===
                • 考虑性能,我们要尽量减少将节点从后面移动到前面的操作

React-Router

  • BrowserRouter
    • React-router-dom v6
      • BrowserRouter,通过createBrowserHistory创建history实例(history库提供),执行history.listen监听路由变化,返回Router组件
    • React-router
      • 提供RouterRoutes以及多个路由相关hooks
      • Router注入两个context,并格式化props中相关参数。
      • Routeschildren转化为routes数组,并作为参数放在useRoutes中执行
      • useRoutes内部读取context中数据(这也说明为什么useRoutes要放在<Router>下使用),并会通过分数规则判断路由匹配,交由_renderMatches渲染
      • _renderMatches注入一个context,并渲染出对应组件

React事件机制

参考

React 事件系统工作原理

  • 事件原理,react中事件是合成事件,统一绑定在document,事件回调为dispatchEvent
  • 当用户点击时触发document上的对应事件,从原生事件找到对应的合成事件,并从事件池中取出该合成事件的实例对象,并覆盖属性,作为事件对象,如果没有就创建一个(React17取消了事件对象的复用
  • 从原生事件中找到点击对应的dom节点,从dom节点找到最近的React组件实例,从而找到了一条由这个实例父节点不断向上组成的链, 这个链就是我们要触发合成事件的链,
  • 反向触发这条链,父-> 子,模拟捕获阶段,触发所有props中含有onClickCaptures的实例
  • 正向触发这条链,子-> 父,模拟冒泡阶段,触发所有 props 中含有 onClick 的实例。
  • 总结:
    • React 会在派发事件时打开批量更新, 此时所有的 setState 都会变成异步。
    • React onClick/onClickCapture, 实际上都发生在原生事件的冒泡阶段。(React17支持原生捕获事件)

Fiber

参考:

Fiber架构的实现原理

  • 虚拟DOMReact中有个正式的称呼——Fiber,用Fiber来取代React16虚拟DOM这一称呼

  • 起源:

    • React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。
    • 为了解决这个问题,React16递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。
  • 含义:

    • 作为架构来说,之前React15Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack ReconcilerReact16Reconciler基于Fiber节点实现,被称为Fiber Reconciler
    • 作为静态的数据结构来说,每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的DOM节点等信息。
    • 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)
  • 结构:

    function FiberNode(
      tag: WorkTag,
      pendingProps: mixed,
      key: null | string,
      mode: TypeOfMode,
    ) {
      // 作为静态数据结构的属性
      this.tag = tag; // Fiber对应组件的类型 Function/Class/Host...
      this.key = key; // key属性
      this.elementType = null; // 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
      this.type = null; // 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent(原生html标签),指DOM节点tagName
      this.stateNode = null; // Fiber对应的真实DOM节点
    
      // 用于连接其他Fiber节点形成Fiber树
      this.return = null; // 指向父级Fiber节点
      this.child = null; // 指向子Fiber节点
      this.sibling = null; // 指向右边第一个兄弟Fiber节点
      this.index = 0;
    
      this.ref = null;
    
      // 作为动态的工作单元的属性
      // 保存本次更新造成的状态改变相关信息
      this.pendingProps = pendingProps;
      this.memorizedProps = null;
      this.updateQueue = null; //fiber 上的更新队列执行一次 setState 就会往这个属性上挂一个新的更新, 每条更新最终会形成一个链表结构,最后做批量更新
      this.memorizedState = null;
      this.dependencies = null;
    
      this.mode = mode;
    	// Effect相关
      // 保存本次更新会造成的DOM操作
      this.effectTag = NoEffect; // 表示当前 fiber 要进行何种更新(更新、删除等)
      this.nextEffect = null; // 指向下个需要更新的fiber
      this.firstEffect = null; // 指向所有子节点里,需要更新的 fiber 里的第一个
      this.lastEffect = null; // 指向所有子节点中需要更新的 fiber 的最后一个
    
      // 调度优先级相关
      this.lanes = NoLanes;
      this.childLanes = NoLanes;
    
      // 指向该fiber在另一次更新时对应的fiber(workInProgress fiber树)
      this.alternate = null;
    }

Fiber的工作原理

参考:

FIber架构的工作原理

  • React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树

  • current Fiber树中的Fiber节点被称为current fiberworkInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。

    • currentFiber.alternate === workInProgressFiber;
      workInProgressFiber.alternate === currentFiber;
  • 即当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树

  • 流程梳理——mount

    function App() {
      const [num, add] = useState(0);
      return (
        <p onClick={() => add(num + 1)}>{num}</p>
      )
    }
    
    ReactDOM.render(<App/>, document.getElementById('root'));
    • 首次执行ReactDOM.render会创建fiberRootNode(源码中叫fiberRoot)和rootFiber。其中fiberRootNode整个应用的根节点rootFiber<App/>所在组件树的根节点

      • 之所以要区分fiberRootNoderootFiber,是因为在应用中我们可以多次调用ReactDOM.render渲染不同的组件树,他们会拥有不同的rootFiber。但是整个应用的根节点只有一个fiberRootNodefiberRootNodecurrent会指向当前页面上已渲染内容对应Fiber树,即current Fiber树

        fiberRootNode.current = rootFiber;
      • 页面中还没有挂载任何DOMfiberRootNode.current指向的rootFiber没有任何子Fiber节点(即current Fiber树为空)。

    • 进入render阶段,根据组件返回的JSX在内存中依次创建Fiber节点并连接在一起构建Fiber树,被称为workInProgress Fiber树(下图中右侧的树)

      • 在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,在首屏渲染时只有rootFiber存在对应的current fiber(即rootFiber.alternate)。

        workInProgressFiber
    • 图中右侧已构建完的workInProgress Fiber树commit阶段渲染到页面后,fiberRootNodecurrent指针指向workInProgress Fiber树使其变为current Fiber 树

  • 流程梳理——update

    • 点击p节点触发状态改变,这会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树

      wipTreeUpdate
    • mount时一样,workInProgress fiber的创建可以复用current Fiber树对应的节点数据。(这个决定是否复用的过程就是Diff算法

    • workInProgress Fiber 树render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树

JSX

参考:

深入理解JSX

  • 编译成React.createElement

    export function createElement(type, config, children) {
      let propName;
    
      const props = {};
    
      let key = null;
      let ref = null;
      let self = null;
      let source = null;
    
      if (config != null) {
        // 将 config 处理后赋值给 props
        // ...省略
      }
    
      const childrenLength = arguments.length - 2;
      // 处理 children,会被赋值给props.children
      // ...省略
    
      // 处理 defaultProps
      // ...省略
    
      return ReactElement(
        type,
        key,
        ref,
        self,
        source,
        ReactCurrentOwner.current,
        props,
      );
    }
    
    const ReactElement = function(type, key, ref, self, source, owner, props) {
      const element = {
        // 标记这是个 React Element
        $$typeof: REACT_ELEMENT_TYPE,
    
        type: type,// 执行组件自身,类组件指向类,函数组件指向函数
        key: key,
        ref: ref,
        props: props,
        _owner: owner,
      };
    
      return element;
    };

    JSX被ReactElement生成为一个element对象。JSX是一种描述当前组件内容的数据结构

  • element对象不包含他不包含组件schedulereconcilerender所需的相关信息。比如如下信息就不包括在JSX中:

    • 组件在更新中的优先级
    • 组件的state
    • 组件被打上的用于Renderer标记

    这些内容都包含在Fiber节点中。

  • 所以,在组件mount时,Reconciler根据JSX描述的组件内容生成组件对应的Fiber节点

    update时,ReconcilerJSXFiber节点保存的数据对比,生成组件对应的Fiber节点,并根据对比结果为Fiber节点打上标记

Render阶段流程

参考:

流程概览

beginWork负责从父到子遍历,当子为null的时候,执行completeWork,如果当前有sibling,就返回sibling继续执行beginWork

  • render阶段开始于performSyncWorkOnRootperformConcurrentWorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新。

    // performSyncWorkOnRoot会调用该方法
    function workLoopSync() {
      while (workInProgress !== null) {
        performUnitOfWork(workInProgress);
      }
    }
    
    // performConcurrentWorkOnRoot会调用该方法
    function workLoopConcurrent() {
      while (workInProgress !== null && !shouldYield()) {
        performUnitOfWork(workInProgress);
      }
    }
    • 区别在于是否调用shouldYield。如果当前浏览器帧没有剩余时间,shouldYield会中止循环,直到浏览器有空闲时间后再继续遍历。
    • workInProgress代表当前已创建的workInProgress fiber
    • performUnitOfWork方法会创建下一个Fiber节点并赋值给workInProgress,并将workInProgress与已创建的Fiber节点连接起来构成Fiber树
  • performUnitOfWork的工作可以分为两部分:“递”和“归”。通过遍历的方式实现可中断的递归

    • 阶段

      • 首先从rootFiber开始向下深度优先遍历。为遍历到的每个Fiber节点调用beginWork方法

        该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。

        当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。

    • 阶段

      • 在“归”阶段会调用completeWork 处理Fiber节点

        当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。

        如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。

        “递”和“归”阶段会交错执行直到“归”到rootFiber。至此,render阶段的工作就结束了

  • 例子:

    function App() {
      return (
        <div>
          i am
          <span>KaSong</span>
        </div>
      )
    }
    
    ReactDOM.render(<App />, document.getElementById("root"));

    对应Fiber树结构:

    Fiber架构
    1. rootFiber beginWork
    2. App Fiber beginWork
    3. div Fiber beginWork
    4. "i am" Fiber beginWork
    5. "i am" Fiber completeWork
    6. span Fiber beginWork
    7. span Fiber completeWork
    8. div Fiber completeWork
    9. App Fiber completeWork
    10. rootFiber completeWork
    // 之所以没有 “KaSong” Fiber 的 beginWork/completeWork
    // 是因为作为一种性能优化手段,针对只有单一文本子节点的Fiber,React会特殊处理。
    • beginWork:

      function beginWork(
        current: Fiber | null, // 当前组件对应的Fiber节点
        workInProgress: Fiber, // 当前组件对应的Fiber节点
        renderLanes: Lanes, // 优先级相关
      ): Fiber | null {
        // ...省略函数体
      }
      • 组件mount时,由于是首次渲染,是不存在当前组件对应的Fiber节点在上一次更新时的Fiber节点,即mountcurrent === null。所以我们可以通过current === null ?来区分组件是处于mount还是update

        // update时:如果current存在可能存在优化路径,可以复用current(即上一次更新的Fiber节点)
          if (current !== null) {
            // ...省略
        
            // 复用current
            return bailoutOnAlreadyFinishedWork(
              current,
              workInProgress,
              renderLanes,
            );
          } else {
            didReceiveUpdate = false;
          }
        
          // mount时:根据tag不同,创建不同的子Fiber节点
          switch (workInProgress.tag) {
            case IndeterminateComponent: 
              // ...省略
            case LazyComponent: 
              // ...省略
            case FunctionComponent: 
              // ...省略
            case ClassComponent: 
              // ...省略
            case HostRoot:
              // ...省略
            case HostComponent:
              // ...省略
            case HostText:
              // ...省略
            // ...省略其他类型
          }
        }
      • 因此,beginWork的工作可以分为两部分:

        • update时:如果current存在,在满足一定条件时可以复用current节点,这样就能克隆current.child作为workInProgress.child,而不需要新建workInProgress.child

          • 我们可以看到,满足如下情况时就可以直接复用前一次更新的子Fiber,不需要新建子Fiber:(didReceiveUpdate=true)

            • oldProps === newProps && workInProgress.type === current.type,即propsfiber.type不变
            • !includesSomeLane(renderLanes, updateLanes),即当前Fiber节点优先级不够,会在讲解Scheduler时介绍
            if (current !== null) {
                const oldProps = current.memoizedProps;
                const newProps = workInProgress.pendingProps;
            
                if (
                  oldProps !== newProps ||
                  hasLegacyContextChanged() ||
                  (__DEV__ ? workInProgress.type !== current.type : false)
                ) {
                  didReceiveUpdate = true;
                } else if (!includesSomeLane(renderLanes, updateLanes)) {
                  didReceiveUpdate = false;
                  switch (workInProgress.tag) {
                    // 省略处理
                  }
                  return bailoutOnAlreadyFinishedWork(
                    current,
                    workInProgress,
                    renderLanes,
                  );
                } else {
                  didReceiveUpdate = false;
                }
              } else {
                didReceiveUpdate = false;
              }
        • mount时:除fiberRootNode以外,current === null。会根据fiber.tag不同,创建不同类型的子Fiber节点

          • 根据fiber.tag不同,进入不同类型Fiber的创建逻辑。
        • 对于我们常见的组件类型,如(FunctionComponent/ClassComponent/HostComponent),最终会进入reconcileChildren方法。

    • reconcileChildren

      • Reconciler模块的核心部分

      • 对于mount的组件,他会创建新的子Fiber节点

      • 对于update的组件,他会将当前组件与该组件在上次更新时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点

        export function reconcileChildren(
          current: Fiber | null,
          workInProgress: Fiber,
          nextChildren: any,
          renderLanes: Lanes
        ) {
          if (current === null) {
            // 对于mount的组件
            workInProgress.child = mountChildFibers(
              workInProgress,
              null,
              nextChildren,
              renderLanes,
            );
          } else {
            // 对于update的组件
            workInProgress.child = reconcileChildFibers(
              workInProgress,
              current.child,
              nextChildren,
              renderLanes,
            );
          }
        }
      • 最终他会生成新的子Fiber节点并赋值给workInProgress.child,作为本次beginWork返回值 ,并作为下次performUnitOfWork执行时workInProgress传参

      • mountChildFibersreconcileChildFibers这两个方法的逻辑基本一致。唯一的区别是:reconcileChildFibers会为生成的Fiber节点带上effectTag属性,而mountChildFibers不会。

    • effectTag

      • render阶段的工作是在内存中进行,当工作结束后会通知Renderer需要执行的DOM操作。要执行DOM操作的具体类型就保存在fiber.effectTag中。你可以从这里 看到effectTag对应的DOM操作

        // DOM需要插入到页面中
        export const Placement = /*                */ 0b00000000000010;
        // DOM需要更新
        export const Update = /*                   */ 0b00000000000100;
        // DOM需要插入到页面中并更新
        export const PlacementAndUpdate = /*       */ 0b00000000000110;
        // DOM需要删除
        export const Deletion = /*                 */ 0b00000000001000;
      • 通过二进制表示effectTag,可以方便的使用位操作为fiber.effectTag赋值多个effect

      • 那么,如果要通知RendererFiber节点对应的DOM节点插入页面中,需要满足两个条件:

        • fiber.stateNode存在,即Fiber节点中保存了对应的DOM节点
        • (fiber.effectTag & Placement) !== 0,即Fiber节点存在Placement effectTag
      • 我们知道,mount时,fiber.stateNode === null,且在reconcileChildren中调用的mountChildFibers不会为Fiber节点赋值effectTag。那么首屏渲染如何完成呢?

        • fiber.stateNode会在completeWork中创建
        • 假设mountChildFibers也会赋值effectTag,那么可以预见mount时整棵Fiber树所有节点都会有Placement effectTag。那么commit阶段在执行DOM操作时每个节点都会执行一次插入操作,这样大量的DOM操作是极低效的。
        • 为了解决这个问题,在mount时只有rootFiber会赋值Placement effectTag,在commit阶段只会执行一次插入操作。
    • beginWork流程图

      img

    • completeWork:

      function completeWork(
        current: Fiber | null,
        workInProgress: Fiber,
        renderLanes: Lanes,
      ): Fiber | null {
        const newProps = workInProgress.pendingProps;
      
        switch (workInProgress.tag) {
          case IndeterminateComponent:
          case LazyComponent:
          case SimpleMemoComponent:
          case FunctionComponent:
          case ForwardRef:
          case Fragment:
          case Mode:
          case Profiler:
          case ContextConsumer:
          case MemoComponent:
            return null;
          case ClassComponent: {
            // ...省略
            return null;
          }
          case HostRoot: {
            // ...省略
            updateHostContainer(workInProgress);
            return null;
          }
          case HostComponent: {
            // ...省略
            return null;
          }
      • 类似beginWorkcompleteWork也是针对不同fiber.tag调用不同的处理逻辑。

      • 处理HostComponent

        • beginWork一样,我们根据current === null ?判断是mount还是update

        • 同时针对HostComponent,判断update时我们还需要考虑workInProgress.stateNode != null ?(即该Fiber节点是否存在对应的DOM节点

          case HostComponent: {
            popHostContext(workInProgress);
            const rootContainerInstance = getRootHostContainer();
            const type = workInProgress.type;
          
            if (current !== null && workInProgress.stateNode != null) {
              // update的情况
              // ...省略
            } else {
              // mount的情况
              // ...省略
            }
            return null;
          }
        • mount的情况:

          • Fiber节点生成对应的DOM节点

          • 将子孙DOM节点插入刚生成的DOM节点

          • update逻辑中的updateHostComponent类似的处理props的过程

            // mount的情况
            
            // ...省略服务端渲染相关逻辑
            
            const currentHostContext = getHostContext();
            // 为fiber创建对应DOM节点
            const instance = createInstance(
                type,
                newProps,
                rootContainerInstance,
                currentHostContext,
                workInProgress,
              );
            // 将子孙DOM节点插入刚生成的DOM节点中
            appendAllChildren(instance, workInProgress, false, false);
            // DOM节点赋值给fiber.stateNode
            workInProgress.stateNode = instance;
            
            // 与update逻辑中的updateHostComponent类似的处理props的过程
            if (
              finalizeInitialChildren(
                instance,
                type,
                newProps,
                rootContainerInstance,
                currentHostContext,
              )
            ) {
              markUpdate(workInProgress);
            }
          • mount时只会在rootFiber存在Placement effectTag。那么commit阶段是如何通过一次插入DOM操作(对应一个Placement effectTag)将整棵DOM树插入页面的呢?

          • 原因就在于completeWork中的appendAllChildren方法,每次调用时都会将已生成的子孙DOM节点插入当前生成的DOM节点下,当completeWork函数执行到rootfiber时,appendAllChildren已经构建好了一个完整dom的(未渲染)

        • update的情况:

          • update时,Fiber节点已经存在对应DOM节点,所以不需要生成DOM节点。需要做的主要是处理props,比如:

            • onClickonChange等回调函数的注册
            • 处理style prop
            • 处理DANGEROUSLY_SET_INNER_HTML prop
            • 处理children prop
          • 我们去掉一些当前不需要关注的功能(比如ref)。可以看到最主要的逻辑是调用updateHostComponent方法。

            if (current !== null && workInProgress.stateNode != null) {
              // update的情况
              updateHostComponent(
                current,
                workInProgress,
                type,
                newProps,
                rootContainerInstance,
              );
            }
          • updateHostComponent内部,被处理完的props会被赋值给workInProgress.updateQueue,并最终会在commit阶段被渲染在页面上。

            workInProgress.updateQueue = (updatePayload: any);
            // updatePayload=[props[key],props[value]]
            // 偶数索引的值为变化的prop key,奇数索引的值为变化的prop value。
          • 你可以从这里看到updateHostComponent方法定义。

      • effectList

        • 作为DOM操作的依据,commit阶段需要找到所有有effectTagFiber节点并依次执行effectTag对应操作。难道需要在commit阶段再遍历一次Fiber树寻找effectTag !== nullFiber节点么?

        • 为了解决这个问题,在completeWork的上层函数completeUnitOfWork中,每个执行完completeWork且存在effectTagFiber节点会被保存在一条被称为effectList的单向链表中。

        • effectList中第一个Fiber节点保存在fiber.firstEffect,最后一个元素保存在fiber.lastEffect

        • 类似appendAllChildren,在“归”阶段,所有有effectTagFiber节点都会被追加在effectList中,最终形成一条以rootFiber.firstEffect为起点的单向链表。

                                 nextEffect         nextEffect
          rootFiber.firstEffect -----------> fiber -----------> fiber
      • completeWork流程图

        completeWork流程图

  • 结尾:

    • 至此,render阶段全部工作完成。在performSyncWorkOnRoot函数中fiberRootNode被传递给commitRoot方法,开启commit阶段工作流程。

      commitRoot(root);

Commit阶段

参考:

Commit阶段

  • rootFiber.firstEffect为开始保存了一条需要执行副作用的链表effectList,在commit阶段执行

  • 除此之外,一些生命周期钩子(比如componentDidXXX)、hook(比如useEffect)需要在commit阶段执行。

  • commit阶段的主要工作(即Renderer的工作流程)分为三部分:

    • before mutation阶段(执行DOM操作前)
    • mutation阶段(执行DOM操作)
    • layout阶段(执行DOM操作后)
    • (你可以从这里 看到commit阶段的完整代码)
  • before mutation阶段之前和layout阶段之后还有一些额外工作,涉及到比如useEffect的触发、优先级相关的重置、ref的绑定/解绑。

  • before mutation阶段之前

    • before mutation之前主要做一些变量赋值,状态重置的工作。

    • 这一长串代码我们只需要关注最后赋值的firstEffect,在commit的三个子阶段都会用到他。

      do {
          // 触发useEffect回调与其他同步任务。由于这些任务可能触发新的渲染,所以这里要一直遍历执行直到没有任务
          flushPassiveEffects();
        } while (rootWithPendingPassiveEffects !== null);
      
        // root指 fiberRootNode
        // root.finishedWork指当前应用的rootFiber
        const finishedWork = root.finishedWork;
      
        // 凡是变量名带lane的都是优先级相关
        const lanes = root.finishedLanes;
        if (finishedWork === null) {
          return null;
        }
        root.finishedWork = null;
        root.finishedLanes = NoLanes;
      
        // 重置Scheduler绑定的回调函数
        root.callbackNode = null;
        root.callbackId = NoLanes;
      
        let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
        // 重置优先级相关变量
        markRootFinished(root, remainingLanes);
      
        // 清除已完成的discrete updates,例如:用户鼠标点击触发的更新。
        if (rootsWithPendingDiscreteUpdates !== null) {
          if (
            !hasDiscreteLanes(remainingLanes) &&
            rootsWithPendingDiscreteUpdates.has(root)
          ) {
            rootsWithPendingDiscreteUpdates.delete(root);
          }
        }
      
        // 重置全局变量
        if (root === workInProgressRoot) {
          workInProgressRoot = null;
          workInProgress = null;
          workInProgressRootRenderLanes = NoLanes;
        } else {
        }
      
        // 将effectList赋值给firstEffect
        // 由于每个fiber的effectList只包含他的子孙节点
        // 所以根节点如果有effectTag则不会被包含进来
        // 所以这里将有effectTag的根节点插入到effectList尾部
        // 这样才能保证有effect的fiber都在effectList中
        let firstEffect;
        if (finishedWork.effectTag > PerformedWork) {
          if (finishedWork.lastEffect !== null) {
            finishedWork.lastEffect.nextEffect = finishedWork;
            firstEffect = finishedWork.firstEffect;
          } else {
            firstEffect = finishedWork;
          }
        } else {
          // 根节点没有effectTag
          firstEffect = finishedWork.firstEffect;
        }