虚拟dom、fiber、渲染dom、dom-diff

116 阅读13分钟

虚拟dom、fiber、渲染dom、dom-diff

  • 三者之间相互关联,且按照先后顺序进行依赖
  • 在虚拟dom与渲染dom之间还存在一个步骤,创建fiber
  • react的整体渲染流程:
    • 1.拿到虚拟dom(一个object)
    • 2.根据虚拟dom创建fiber结构
    • 3.通过fiber来进行渲染dom
    • 4.再次更新的时候,已存在旧dom和旧fiber,重新执行1,在2阶段查找可复用fiber,然后在3阶段渲染

虚拟dom

  • 将jsx转换成createElement或jsx包裹的函数,函数会返回一个包含标签特征的object
  • jsx转换完成的时候是一整个大的object,前面文章有写,但不详细,补充说明下
  • 示例:
// jsx
let element = (
    <h1 className="a" key={"1"}>
        <p>hello<span>word</span></p>
    </h1>
)

// 转换后
import { jsx as _jsx } from "react/jsx-runtime";
import { jsxs as _jsxs } from "react/jsx-runtime";
let element = /*#__PURE__*/_jsx("h1", {
  className: "a",
  children: /*#__PURE__*/_jsxs("p", {
    children: ["hello", /*#__PURE__*/_jsx("span", {
      children: "word"
    })]
  })
}, "1");

// jsx函数执行后的虚拟dom
let element = {
    $$typeof: Symbol(react.element),
    key: "1",
    props: {
        className: 'a', 
        children: {
            $$typeof: Symbol(react.element),
            key: null,
            props: {
                children: [
                    'hello',
                    {
                        $$typeof: Symbol(react.element), 
                        type: 'span', 
                        key: null, 
                        ref: null, 
                        props: {children: 'word'}
                    }
                ]
            },
            ref: null,
            type: "p"
        }
    ref: null,
    type: "h1"
    }
}

  • 可以看到这个大的object,是层层嵌套的
    • 单个子节点,children为object
    • 多个子节点,children为array
    • children放在props里,className、id等属性也在里面

fiber

  • 那fiber是什么,为什么要有fiber,它起什么作用

产生原因

  • 浏览器的渲染与js执行是互斥的,那么当js执行时间过长,会导致浏览器长时间无法渲染,造成页面上的效果就是ui交互的卡顿
  • 屏幕刷新频率一般在60hz,那么将1秒分为60份,就是16.6毫秒,那么我们可以做一个策略,将一个大的js任务,处理成多个小任务,在浏览器空闲的时候执行小任务,执行完成后,查看当前帧剩余空闲时间,如果剩余时间大于某个值,我们继续执行下一个任务,否则将控制权给到浏览器渲染
    • 好处:会减少浏览器绘制的阻塞
    • 补充:虽然可以判断空闲时间,但是无法在任务执行前判断执行耗时,所以当任务耗时过旧,依然会阻塞,但相比一次执行完毕会好很多
  • 函数: requestIdleCallback
    • 告知浏览器有待执行的任务,有空的时候会执行
    • 会将剩余耗时实例(deadline)传递给函数,可以通过(deadline.timeRemaining())判断剩余的毫秒值

fiber结构

  • 也是object,且是通过虚拟dom生成
  • fiber的结构都一致,但一些属性在不同的类型上作用不同
  • 看一下fiber的所有属性
/**
 *
 * @param {*} tag fiber的类型 函数组件0  类组件1 原生组件5 根元素3
 * @param {*} pendingProps 新属性,等待处理或者说生效的属性
 * @param {*} key 唯一标识
 */
export function FiberNode(tag, pendingProps, key) {
  this.tag = tag;
  this.key = key;
  this.type = null; //fiber类型,来自于 虚拟DOM节点的type  span div p
  //每个虚拟DOM=>Fiber节点=>真实DOM
  this.stateNode = null; //此fiber对应的真实DOM节点  h1=>真实的h1DOM

  this.return = null; //指向父节点
  this.child = null; //指向第一个子节点
  this.sibling = null; //指向弟弟

  //fiber哪来的?通过虚拟DOM节点创建,虚拟DOM会提供pendingProps用来创建fiber节点的属性
  this.pendingProps = pendingProps; //等待生效的属性
  this.memoizedProps = null; //已经生效的属性

  //每个fiber还会有自己的状态,每一种fiber 状态存的类型是不一样的
  //类组件对应的fiber 存的就是类的实例的状态,HostRoot存的就是要渲染的元素
  this.memoizedState = null;
  //每个fiber身上可能还有更新队列
  this.updateQueue = null;
  //副作用的标识,表示要针对此fiber节点进行何种操作
  this.flags = NoFlags; //自己的副作用
  //子节点对应的副使用标识
  this.subtreeFlags = NoFlags;
  //替身,轮替 在后面讲DOM-DIFF的时候会用到
  this.alternate = null;
  this.index = 0;
  // 待删除的子   
  this.deletions = null;
}
  • 有几个需要注意的点
    • stateNode始终指向的是自己的真实dom,但根fiber的state指向{current: 当前根fiber}
    • alternate指向老fiber,可能直接理解老fiber不太合适,也可以理解为新旧两个fiber通过alternate互相引用
    • 函数fiber的memoizedState指向hooks链表

创建fiber

初始化

  • createRoot传递进来了一个根dom,通常为div#root
  • 初始化FiberRoot实例,{containerInfo:div#root},存储下根dom
  • 初始化根fiber实例, 可以理解为 new FiberNode,根fiber的tag指向HostRoot(3),后续会根据这个值判断是否为根fiber
  • 根fiber的stateNode指向FiberRoot,FiberRoot的current指向当前根fiber
  • 初始化fiber的更新队列,里面啥也没有
  • 初始化root实例并返回,{_internalRoot: FiberRoot实例}
更新队列
  • 简述更新队列
    • fiber的updateQueue指向更新队列
    • shared里面的pending始终指向最后一个更新
    • 更新之间形成一个单向循环链表,那么我们就可以通过最后一个更细,拿到所有的更新
      • 链表结构简述:3-1-2-3
      • 拿到3的next,然后将3的next置为null,可以断开链表,避免遍历死循环
function initialUpdateQueue(fiber) {
  //创建一个新的更新队列
  //pending其实是一个循环链接
  const queue = {
    shared: {
      pending: null
    }
  }
  fiber.updateQueue = queue;
}
function createUpdate() {
  return {};
}
function enqueueUpdate(fiber, update) {
  const updateQueue = fiber.updateQueue;
  const shared = updateQueue.shared;
  const pending = shared.pending;
  if (pending === null) {
    update.next = update;
  } else {
    //如果更新队列不为空的话,取出第一个更新
    update.next = pending.next;
    //然后让原来队列的最后一个的next指向新的next
    pending.next = update;
  }
  updateQueue.shared.pending = update;
}
function processUpdateQueue(fiber) {
  const queue = fiber.updateQueue;
  const pending = queue.shared.pending;
  if (pending !== null) {
    queue.shared.pending = null;
    //最后一个更新
    const lastPendingUpdate = pending;
    const firstPendingUpdate = lastPendingUpdate.next;
    //把环状链接剪开
    lastPendingUpdate.next = null;
    let newState = fiber.memoizedState;
    let update = firstPendingUpdate;
    while (update) {
      newState = getStateFromUpdate(update, newState);
      update = update.next;
    }
    fiber.memoizedState = newState;
  }
}
function getStateFromUpdate(update, newState) {
  return Object.assign({}, newState, update.payload);
}
let fiber = { memoizedState: { id: 1 } };
initialUpdateQueue(fiber);
let update1 = createUpdate();
update1.payload = { name: 'zhufeng' }
enqueueUpdate(fiber, update1)

let update2 = createUpdate();
update2.payload = { age: 14 }
enqueueUpdate(fiber, update2)

//基于老状态,计算新状态
processUpdateQueue(fiber);
console.log(fiber.memoizedState);

渲染dom

  • 渲染dom分为两部分:生成fiber树,渲染
  • 这部分可以理解为是在requestIdleCallback中执行

render

  • 在生成root实例后,我们会调用render
  • 在页面上的表现为dom直接更新到页面上了,其实在插入dom前还有一步,创建fiber树
  • 创建fiber树依赖于虚拟dom,先将虚拟dom入队列,存到根fiber的updateQueue里

构建fiber树

  • 开始渲染dom的第一步,构建fiber树
  • 在构建前,有一个小步骤
    • 判断当前根fiber是否存在对应的老fiber,没有就创建一个,有就拷贝点旧值,然后使用alternate互相指向
    • 将这个新(或拷贝,后边统一称为新)的根fiber,放到全局中进行while循环
    • 变量为:workInProgress
let workInProgress = null;

function prepareFreshStack(root) {
  workInProgress = createWorkInProgress(root.current, null);
}
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}
// 执行构建
function renderRootSync(root) {
  // 创建个替身,来更新fiber
  prepareFreshStack(root);
  // 在替身上处理本次的fiber树 
  workLoopSync();
}
  • 看一下便利和创建的操作,主要涉及
/**
 * 执行一个工作单元
 * @param {*} unitOfWork
 */
function performUnitOfWork(unitOfWork) {
  //获取新的fiber对应的老fiber
  const current = unitOfWork.alternate;
  //完成当前fiber的子fiber链表构建后,返回子
  const next = beginWork(current, unitOfWork);
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    //如果没有子节点表示当前的fiber已经完成了
    completeUnitOfWork(unitOfWork);
  } else {
    //如果有子节点,就让子节点成为下一个工作单元
    workInProgress = next;
  }
}

function completeUnitOfWork(unitOfWork) {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;
    //执行此fiber 的完成工作,如果是原生组件的话就是创建真实的DOM节点
    completeWork(current, completedWork);
    //如果有弟弟,就构建弟弟对应的fiber子链表
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;
      return;
    }
    //如果没有弟弟,说明这当前完成的就是父fiber的最后一个节点
    //也就是说一个父fiber,所有的子fiber全部完成了
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}
  • 以真实dom进行举例,走一下它的执行顺序
  • beginWork的作用为获取fiber的子节点,将它处理成fiber后与父级关联,然后返回首个子fiber
  • 示例:
()=>(
<div>
    hello
    <p>world</p>
</div>
)
/**
 * 完成一个fiber节点
 * @param {*} current 老fiber
 * @param {*} workInProgress 新的构建的fiber
 */
export function completeWork(current, workInProgress) {
  // indent.number -= 2;
  // logger(" ".repeat(indent.number) + "completeWork", workInProgress);
  const newProps = workInProgress.pendingProps;
  switch (workInProgress.tag) {
    case HostRoot:
      bubbleProperties(workInProgress);
      break;
    //如果完成的是原生节点的话
    case HostComponent:
      ///现在只是在处理创建或者说挂载新节点的逻辑,后面此处分进行区分是初次挂载还是更新
      //创建真实的DOM节点
      const { type } = workInProgress;
      //如果老fiber存在,并且老fiber上真实DOM节点,要走节点更新的逻辑
      if (current !== null && workInProgress.stateNode !== null) {
        updateHostComponent(current, workInProgress, type, newProps);
      } else {
        const instance = createInstance(type, newProps, workInProgress);
        //把自己所有的儿子都添加到自己的身上
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
        finalizeInitialChildren(instance, type, newProps);
      }
      bubbleProperties(workInProgress);
      break;
    case FunctionComponent:
      bubbleProperties(workInProgress);
      break;
    case HostText:
      //如果完成的fiber是文本节点,那就创建真实的文本节点
      const newText = newProps;
      //创建真实的DOM节点并传入stateNode
      workInProgress.stateNode = createTextInstance(newText);
      //向上冒泡属性
      bubbleProperties(workInProgress);
      break;
  }
}

function bubbleProperties(completedWork) {
  let subtreeFlags = NoFlags;
  //遍历当前fiber的所有子节点,把所有的子节的副作用,以及子节点的子节点的副作用全部合并
  let child = completedWork.child;
  while (child !== null) {
    subtreeFlags |= child.subtreeFlags;
    subtreeFlags |= child.flags;
    child = child.sibling;
  }
  completedWork.subtreeFlags = subtreeFlags;
}
  • 首先新根fiber是存在老fiber的,虽然老fiber里没有什么内容
  • 走进beginWork,会按照tag类型进行处理
  • 因为是HostRoot,所以它的子节点一定不存在,但是在更新队列中存在虚拟dom
  • 将队列更新,从memoizedState中拿到虚拟dom
  • 将虚拟dom生成fiber节点,return指向新根fiber,新根fiber的child指向这个节点
  • 因为有父级有老fiber,但子级别没有,所以这是一个准备插入的节点,那么给fiber节点的flags累加副作用Placement,然后返回fiber节点
  • 然后将fiber当做新的workInProgress,会再次触发performUnitOfWork
  • Functionfiber没有老fiber,且没有子,那么需要将函数执行,拿到子,将首个子节点处理成fiber,关联下关系返回
  • divFiber继续触发performUnitOfWork
  • 没有老fiber,没有子,在pendingProps里有多个子节点,遍历数组,将子节点以sibling关联,且加上return,divFiber的child指向首个childFiber,也就是hello文本fiber,然后返回
  • TextFiber继续触发performUnitOfWork
  • 没子节点了,走到completeUnitOfWork函数
  • 一样是按照tag类型分别处理
  • 创建文本节点真实dom,将真实dom给到TextFiber的stateNode,然后将子的副作用累加到自身的subtreeFlags
  • 有sibling,将sibling给到workInProgress,再次触发performUnitOfWork
  • 只有一个子,且是文本,可以理解为没有儿子,那么又走到completeUnitOfWork
  • 创建P的真实dom,是原生标签,那么将所有儿子真实dom插入到自身,把所有儿子的副作用累加到自身的subtreeFlags
  • 没sibling了,把父级给到workInProgress,自身也有个while,没return走自身
  • 创建dom,添加儿子dom到自身,累加副作用
  • 没sibling,找父级,自身while,是Function,只累加副作用
  • 没sibling,找父级,自身while,到根Fiber了,也只累加副作用,
  • 没sibling,也没父了,结束while,这时候workInProgress也为null了,后续再次处理fiber树也不会受到影响
  • fiber树创建完毕

首次渲染

  • 拿到根fiber的current.alternate,也就是刚才创建的fiber树
  • 给到finishedWork,开始渲染
  const finishedWork = root.current.alternate;
  root.finishedWork = finishedWork;
  commitRoot(root);
  • 根节点上就是个树尖,没有dom,所以我们先通过subtreeFlags来判断是否有要增删改查的副作用
  • 刚才在创建阶段,我们给FunctionFiber加上了插入副作用,所以要插入
  • 先处理要删除的节点,但现在的deletions没有,所以不需要删除
  • 然后递归处理后代节点的副作用
  • 首次只有FunctionFiber有副作用,但是它是个函数,不许要插入,我们要插入的是它的子节点div
  • 然后减去自身副作用,渲染完成
  • 将root.current变为本次构建fiber树的根fiber,以前的根下次更新使用,形成轮替效果

domdiff

  • diff的前提是已经渲染过一次dom了
  • 触发条件,dom要重新渲染,比如以下情况
 // 点击div,触发视图刷新
 () => {
  const [count, setCount] = React.useState(0);
  return (
    <div
      onClick={() => {
        setCount(count + 1);
      }}
    >
      hello
      <p>{count === 0 ? "word" : count}</p>
    </div>
  );
}
  • 当前的根fiber存在alternate,那么可以复用这个fiber根的老节点,但需要改些值
    • flags、subtreeFlags、deletions给初始化,会重新统计
    • child拷贝一下
  • 将更新{setCount(count+1)}入到自身的队列

开始构建fiber树

  • 还是workLoopSync函数,依然是从根fiber开始
  • 区别:这次Function有老节点,但是没老fiber(这块逻辑跟首次创建根一样)
/**
 * 根据新的虚拟DOM生成新的Fiber链表
 * @param {*} current 老的父Fiber
 * @param {*} workInProgress 新的你Fiber
 * @param {*} nextChildren 新的子虚拟DOM
 */
function reconcileChildren(current, workInProgress, nextChildren) {
  //如果此新fiber没有老fiber,说明此新fiber是新创建的
  //如果此fiber没能对应的老fiber,说明此fiber是新创建的,如果这个父fiber是新的创建的,它的儿子们也肯定都是新创建的
  if (current === null) {
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren);
  } else {
    //如果说有老Fiber的话,做DOM-DIFF 拿老的子fiber链表和新的子虚拟DOM进行比较 ,进行最小化的更新
    // 首次的current.child为空,这次可不为空了
    workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren);
  }
}
  • 判断新老虚拟dom的type和key是否一致
  • 一致就复用当前节点,然后将老fiber中的的sibling放入新fiber的deletions中,表示要删除,但Function没有sibling,所以不放
  • 只有节点不行啊,我们要创建fiber,那么拿到旧的fiber,创建新的fiber,然后alternate相互指向
    • 然后初始化sibling和index,因为要重新赋值
    • 然后return指向父fiber
    • 父fiber的child指向当前fiber
    • 因为本次function没改动,且老fiber存在,所以不是插入,不添加副作用
  • 执行FunctionFiber的performUnitOfWork
    • 刚才给它关联了老fiber,
    • 而且新的fiber是复用了老fiber部分值的,所以新fiber存在memoizedState,这里面存的是hook的链表
    • 那么执行函数,函数内部执行hook,hook根据链表拿到对应的更新队列,依次执行,得到新的值,根据新的值生成虚拟dom,然后返回
    • 现在要把子节点变成fiber节点,还是走reconcileChildFibers函数
    • 老节点存在,且key和type一样,且没有sibling,所以不删除sibling,依据老divFiber创建新的divFiber,两者关联,加上return索引
    • div的老fiber也有,所以也不累加副作用,返回新divFiber
  • 执行divFiber的的performUnitOfWork
    • 有多个子节点,那么依次处理
    • 有三轮循环:
      • 第一轮:从第一个节点开始遍历,遇到不能复用的break,记录当前索引
      • 第2轮:如果老节点遍历完了,新节点还没有,证明需要插入节点,依次插入
      • 第3轮:将老节点存入map结构中,遍历新节点,有key或index相同的就复用,并删除map中对应的值,如果没有相同的就插入新节点,遍历完成后,还留在map中的就是要删除的
  • 后边的fiber创建重复以上步骤,最终直到创建完成
  • 可以看到是老节点的type和key来判断是否复用的
  • 而且在fiber树创建阶段:复用的内容是虚拟dom,和老fiber,并不是真实dom,真实dom还是会重新创建一遍

提交阶段

  • 与初次执行步骤一致,但区别在于这次只有一个更新,那么只更新p即可