React :从 jsx 到 fiber 树

693 阅读3分钟
开篇前,先聊下几个概念:
1. jsx 是什么?

jsx 其实就是js的语法拓展,写代码时用js描述UI更加方便;最终它会经过babel等转码器编译成可运行的js; 例如

const redDivDesc = <div style={{color: isRed ? 'red' : ''}}> red div</div>

会转码为如下代码:

const redDivDesc = _jsxRuntime.jsx("div", {
  style: {
    color: isRed ? 'red' : ''
  },
  children: " red div"
});

通过以上也可以看出来,在js语法上扩展jsx来描述UI 是很灵活的;

2. react element是什么?

上例中jsx函数的返回结果,例如redDivDesc就是一个react element; 它有以下几个属性:

$$typeof: REACT_ELEMENT_TYPE, // 标识是否是ReactElement
type: 'div', // 'div' () => xxx 等,jsx 的第一个入参;
key: key, // 设置的key
ref: ref,
props: props,
// Record the component responsible for creating this element.
_owner: owner
3. fiber是什么?

fiber 是vDom在react中的实现,也是react新架构的基础;

它既承担了存储数据的职责,也作为react的工作单元,保存了更新变化的数据和接下来要执行的工作;一个fiber的结构如下:

 // Instance
  this.tag = tag; // HostComponent:5 HostText:6 FunctinComponent: 0 ClassComponent: 1 ...
  this.key = key;
  this.elementType = null;
  this.type = null; // dom元素指向对应的元素类型,组件只想其函数或者类
  this.stateNode = null; // 指向真实DOM元素,类组件指向其实例

  // Fiber
  this.return = null; // 指向父级节点
  this.child = null; 
  this.sibling = null; // 兄弟节点 (注意子节点是一个链表,不是数组)
  this.index = 0;

  this.ref = null;
  this.refCleanup = null;

  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode; //描述fiber树的模式,比如 ConcurrentMode 模式

  // Effects
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null; // 要删除的子节点

  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  this.alternate = null; // 指向上一次可复用的fiber

通过 react Element的信息是可以创建fiber的,这是我们写的jsx到生成fiber的基础;例如可以这样

class FiberNode {
    constructor (tag, props, key) {
        // ... 一堆属性的初始化
        this.tag = tag;
        this.props= props;
        this.key = key;
    }
}
export function createFiberFromElement(
  element: ReactElement
): Fiber {
  const {type, key, props} = element;  
  const tag = getTagFromType(type);
  reurn new FiberNode(tag, props, key);
}
从jsx代码到fiber树的生成

考虑如下代码

const list1 = [
  { text: "a", color: "red" },
  { text: "b", color: "green" },
  { text: "c", color: "yellow" },
  { text: "d", color: "blue" },
];
const list2 = [
  { text: "d", color: "blue" },
  { text: "a", color: "red" },
  { text: "b", color: "green" },
  { text: "c", color: "yellow" },
];
function List() {
  const [list, setList] = useState(list1);
  return (
    <ul onClick={() => setList(list2)}>
      {list.map((item, index) => (
        <li key={item.text} style={{ color: item.color }}>
          {item.text}
        </li>
      ))}
    </ul>
  );
}
function App() {
  return (
    <div>
      <List />
      oh,hei
    </div>
  );
}
const root = document.getElementById('root');
createRoot(root).render(<App/>);

代码执行的入口是从createRoot(root).render()开始的; 首先createRoot 执行完会创建 fiberRootNode, 和hostRootFiber, 如下图:

image.png

到执行render的时候,会从hostRootFiber克隆一个fiber,生成如下结构,同时产生一个更新(这个更新最终就是把<App/> 存储到hostRootFiber里,注意这时准确的是记录要干啥,实际做的时候是在beginwork中,总之就是后面能拿到 就是了),开始调度和生成fiber树;此时会将一个指针workInprogressFiber指向它

image.png

下面就开始了beginWork流程:

总的就是通过当前节点的current.child(fiber 也就是当前节点alternate指向的fiber的子fiber) 与 React Element(下面的child element)比对,然后生成当前节点的child fiber;

首先来对比HostRoot的 current.child 与 child element;这里需要注意的是,每个不同tag获取child element 的方式是不同的,比如HostRoot 经过更新后可以从它的属性中获取它的child element,即App;

此时通过比对, 发现current.child是null, 那么会通过element来生成fiber(current.child 不是null,有可能会走复用,后面再说);

// 转码后的ReactElement,createFiberFromElement前面已经提到了;
newFiber = createFiberFromElement({
    type: App,
    key: null,
    ref: null,
    props: {}
});

因为我们现在知道workInprogress是它的父fiber,所以可以建立如下关系

newFiber.return = workInprogress;
workInProgress.child = newFiber;
workInProgress = newFiber;

image.png

现在开始比对App fiber 的current.child 与 它的child element; 针对function Component的fiber,它的child是它运行的结果即App()的返回值;同样他没有current.child,后面的流程也是如此,不再特意说明,所以也是通过createFiberFromElement来创建fiber;执行完后如图(后面通过wip fiber来指定workInproressFiber)

image.png

进入div的current.child 和 child element; 对于HostCompoennt(原始dom节点的fiber)它的child element 在它的属性props.children中,jsx 编译后会放里面;这个地方比上一步相对复杂一些,要建立起子节点的链表;大概可以这样实现

let firstNewFiber = null,lastNewFiber = null;
for (let i = 0; i< childElements.length; i++) {
    const newFiber = createFiberFromElement(childElement[i]);
    newFiber.index = i;
    newFiber.return = workInprogressFiber;
    if (firstNewFiber === null) {
        firstNewFiber = newFiber;
        lastNewFiber = newFiber;
    } else {
        lastNewFiber.sibling = newFiber;
        lastNewFiber = lastNewFiber.sibling;
    }
}
workInprogressFiber = firstNewChild;

现在的fiber树如图:

image.png

  • 接着对比List组件的current.child和它的child element;
  • 接着对比Ul的current.child 和它的child.element;
  • 接着对比第一个li的current.child 和它的child.element;

最终fiber树如图:

image.png

可以发现,现在到了text 类型的fiber节点,这其实也就是fiber树的叶子节点(文本不可能有子节点了);但是还有几个li的子节点没有生成,那么后续我们要从当前寻找sibling,对它的slibling执行上述过程;然后逐级往上寻找sibling;用代码概括一下这个流程:我们将它标记为流程1;

// workInProgressFiber => wipFiber
let wipFiber = hostRootFiber;
do {
    down: while (wipFiber) {
        let childFiber = beginWork(wipFiber);
        if (childFiber === null) {
            break down;
        }
        wipFiber = childFiber;
    }
    
    up: while (wipFiber !== null) {
        completeWork(wipFiber);
        if (wipFiber.sibling === null) {
            wipFiber = wipFiber.return;
        } else {
            wipFiber.sibling.return = wipFiber.return;
            wipFiber = wipFiber.sibling;
            // 回归到beginwork流程;
            break up;
        }
    }
   
  
    if (wipFiber === null) {
        break;
    }
   
} while (true)

在beginWork 中,如果遇到text, 返回null; 开始从叶子节点往上;直到HostRootFiber(return 为null);这个过程会遍历到树中的每个节点,并调用completeWork;我们还是先生成代码声明的整颗fiber树后再说它; 根据上面流程,我们后面会进行如下步骤:

  1. 没有sibling,向上
  2. 有sibling,开始 slibling 的 beginwork;

生成fiber树如图

image.png

现在遇到了a 节点的情况,继续同样的流程,我们就得到了完整的fiber树

image.png

我们在绿色步骤处可以做一下DOM生成;然后放在fiber中存储; 在 叶子 fiber中我们可以创建Dom 节点,存储在stateNode中, 然后在任意HostComponent节点往下寻找有stateNode的节点,然后appendChild;依次执行,就会得到一颗离屏DOM;(序号代表Dom 生成的顺序);

image.png

最终会查找标记了Placement的节点的第一个有stateNode的节点,append到父节点上;图中就是将离屏div 挂载到root div上;最终浏览器进行渲染即得到了我们代码要得到的结果;

最终执行完后,FiberRootNode 的current指针指向当前fiber树的HostRootFiber;

更新后fiber树的变化

当我们点击了ul触发了setState,又会触发一个更新,react 开始新一轮的流程1;

总的来说,这个流程也是生成fiber树,但是跟首屏渲染的不同是,我们要找出变化的节点,只去对变化的节点进行操作,提高性能;这里面比对时大部分节点都有current.child,根据某种规则,我们会复用current fiber tree 中的fiber,这个规则实现就是diff算法;下面每个步骤都会涉及;

diff分为单节点diff和多节点diff(单节点和多节点diff指的是child element)

单节点diff:

涉及到的情况:

  • li(key=1) li(key=2) li(key=3) -> li(key=3); 删除多个节点;
  • li(key=1) li(key=2) li(key=3) -> li(key=4); 删除;新增
  • li(key=1) -> p(key=1); Component(key=1) -> li(key=1); key相同,type不同; 删除新增
  • null -> div; 新增

逻辑如下:

function reconcileSingleElement(wipFiber, childFiber, childElement) {
    let sameLevelFiber = childFiber;
    // 挨个寻找上次更新的同级节点是否有相同的key的节点
    while (sameLevelFiber !== null) {
        if (sameLevelFiber.key === childElement.key) { // key相同
            if (childElement.$$typeof === REACT_ELEMENT_TYPE) {
                if (childElement.type === sameLevelFiber.type) { // type 相同 div -> div p->p Component->Component
                    const fiber = reUseFiber(sameLeveFiber);
                    fiber.return = wipFiber;
                    // 因为key是唯一的,所以剩下的肯定是要删掉的;
                    deleteRemainingChildren(wipFiber, sameLevelFiber.sibling);
                    return fiber;
                } else {
                    // 既然相同的key,type不同,那么以前的节点都要删除;将sameLevelFiber及其后面的兄弟节点放到wipFiber的deletions中
                    deleteRemainingChildren(wipFiber, sameLevelFiber);
                    break;
                }
            } else {
                break;
            }
        } else {
            deleteChild(wipFiber, sameLevelFiber);
            // 这一个key 不同,换下一个
            sameLevelFiber = sameLevelFiber.sibling;
        }
    }
    // 没有找到相同的key节点;新建一个;
    const fiber = createFiberFromElement(childElement);
    fiber.return = wipFiber;
    return fiber;
}

此处需要注意一下,reUseFiber就是从一个fiber的信息来创建另一个fiber, 并且相互之间建立一个alternate的联系;

image.png

从element新建的fiber里面很多信息都是初始值,例如alternate指向的是null;

多节点diff

  • 预处理同级fiber节点为map(key->fiber)结构;
  • 遍历child element,根据上一步生成的map信息,通过重用(map中存在key相同的且type相同,此时删除map中的key)或者从element创建来生成新的fiber;同时填充index,sibling数据;打flag标记(标记是否移动);
  • 将map中剩余节点标记删除

可以看出,不论是多节点还是单节点,都是同级的对比,这样遍历一遍每个节点比较一次,是O(n)的复杂度;而不是两个树的对比,跨层级的移动是不会重用节点的,所以开发中我们也应该减少跨层级的移动;

后面通过demo来看这个过程;

初始状态 image.png

wip Fiber 是HostRootFiber,现在生成它的child fiber;它的child element 是 App element, current.child 是App fiber, 这是一个单节点diff, 它们的key相同,type也相同(都是App function的引用), 所以会根据App fiber来生成 child fiber,生成后如下图:

image.png

  • 对比App的current.child fiber与App child element;
  • 对比Div的current.child fiber与Div child element;
  • 对比Ul 的current.child fiber与Ul 的child element;

下面依次进行以上步骤,以下对比的子fiber与子element key 相同,type也相同;fiber树结构如下:

image.png

生成ul的child fiber是一个多节点diff的例子:

  1. 先用current.child 生成 map;
let node = current.child;
const map = new Map();
while (node !== null) {
    const usedKey = node.key === null ? node.index : node.key;
    map.set(usedKey,node);
    node = node.sibling;
}
  1. 循环child element, 逻辑如下:
let lastFixedIndex = 0;
for (let i = 0; i < childElements.length; i++) {
    const el = childElements[i];
    const fiber = getFiberFromMap(wipFiber, el, index, map);
    if (fiber === null) {
        continue;
    }
    fiber.index = i;
    fiber.return = wipFiber;
    // ...生成链表代码省略
    if (!shouldTrackEffects) { // 不需要跟踪副作用,继续,此种情况是wip fiber没有current的情形;
        continue;
    }
    const current = fiber.alternate;
    if (current !== null) {
        const oldIndex = current.index;
        // 正在遍历到的节点应该比以前遍历的节点的位置要靠右,否则就是向右移动了;
        if (oldIndex < maxPlacedIndex) {
            newFiber.flags |= Placement;
            continue;
        } else {
            // 不移动
            lastFixedIndex = oldIndex
        }
    } else {
        fiber.flags |= Placement;
    }
}
function getFiberFromMap (parent, el, index, map) {
    const usedKey = node.key === null ? index: node.key;
    const match = map.get(usedKey);
    if (match) {
        // 文本节点需要判断tag,此处省略;
        if (el.type === element.type) {
            map.delete(usedKey);
            return reUseFiber(match, el.props);
        }
    } else {
        return createFiberFromElement(el);
    }
}
  1. 删除map中剩余的fiber;
map.forEach(fiber => deleteChild(parent, fiber);

demo中就是a,b,c 这三个li右移,d 原地不变;

image.png

  1. 返回链表头节点,并把wipFiber指向它

生成的fiber树如图

image.png

接着对比li(d)的current child fiber和child element, 文本节点只要fiber.tag 是HostText,就可以重用,demo中的情况是可以重用的;

根据流程1,到文本节点后向上查找sibling,进行fiber生成,最终fiber树结构:

image.png