从零实现一个简化版React Fiber

3,268 阅读7分钟

image.png

既然提到了Fiber,首先需要知道的是,为什么会产生Fiber这个东西,出现的原因是什么。

Fiber 之前的 React

在React 16引入Fiber之前, React采用的是递归虚拟DOM树,找出需要变动的节点。

我们可以用一段代码来简易的模拟下。

const element = (
    <div id='A1'>
        <div id='B1' className='B11'>
            <div id='C1'></div>
            <div id='C11'></div>
        </div>
        <div id='B2' className='B11'>
            <div id='C2'></div>
        </div>
    </div>
);

function render(element, parentDom) {
    const dom = document.createElement(element.type);

    Object.keys(element.props)
        .filter((ele) => ele !== "children")
        .forEach((item) => {
            dom[item] = element.props[item];
        });

    if (Array.isArray(element.props.children)) {
        element.props.children.forEach((item) => render(item, dom));
    } else if (
        element.props.children &&
        Object.keys(element.props.children).length !== 0
    ) {
        render(element.props.children, dom);
    }

    parentDom.appendChild(dom);
}

render(element, document.getElementById("root"));

由于采用的是递归方法, 执行栈会越来越深, 而且存在不能中断的问题。随着项目越来越大, 这种问题也会越来越严重, 递归越来越深, 就会十分卡顿。

比方说现在有100个组件,每个组件渲染需要耗费1s, 全部渲染完就是100s, 如果这100s中用户输入了一段内容,这段内容只会在100s以后显示, 这会给用户一种卡顿的印象。

前置内容

实现Fiber之前, 我们需要先学习几个知识点。

屏幕刷新率

目前大部分设备的刷新率为60次/秒, 这说明每秒绘制的帧数达到60时, 整体的页面效果是流畅的, 低于这个数值, 用户会感觉到卡顿。

由于每秒绘制的帧数是60, 所以很明显可以得出每帧的时间大概是16.6ms (1s/60)。

每帧需要执行的内容如下所示:

走进React Fiber的世界

  1. 首先需要处理输入事件,能够让用户得到最早的反馈;
  2. 接下来是处理定时器,需要检查定时器是否到时间,并执行对应的回调;
  3. 接下来处理 Begin Frame(开始帧),即每一帧的事件,包括 window.resize、scroll、media query change 等;
  4. 接下来执行请求动画帧 requestAnimationFrame(rAF),即在每次绘制之前,会执行 rAF 回调;
  5. 紧接着进行 Layout 操作,包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示;
  6. 接着进行 Paint 操作,得到树中每个节点的尺寸与位置等信息,浏览器针对每个元素进行内容填充;
  7. 到这时以上的六个阶段都已经完成了,接下来处于空闲阶段,可以在这时执行 requestIdleCallback 里注册的任务;

需要注意的是, 每一帧里面不是百分百会有空闲时间, 如果某个任务执行时间过长, 浏览器就会推迟渲染。

Fiber概念

Fiber可以理解成两个概念, 第一个是数据结构,第二个是执行单元。

数据结构

React Fiber采用链表实现, 每个节点都是一个fiber, 每个fiber包括了child、sibling、return等属性。

child: 第一个子节点
sibling: 兄弟节点
return: 父节点

如下图所示:

具体的Fiber信息可以通过以下方法查看

对应的DOM元素右键 store as global variable, 出现如下

输入temp1.__reactInternalInstance$3qy8wj4jygh ( temp1. 即可, 后面有智能提示 )

tag: 当前Fiber节点的类型
stateNode: 只想当前节点的真实DOM
return: 当前节点的父节点
sibling: 当前节点的兄弟节点
child: 当前节点的第一个子节点
effectTag: 当前节点的副作用类型

执行单元

Fiber可以理解成一个执行单元, 我们暂且不管这个执行单元的结构是什么样的, 将他视为一个执行单元, 每次执行完一个单元, React就会检查是否还有剩余时间。

流程如下图所示

走进React Fiber的世界

  1. React向浏览器请求调度
  2. 浏览器空闲时间把控制权交给React
  3. React 判断是否存在未执行任务, 是否还有空闲时间, 都满足时执行任务, 否则把控制权返还给浏览器

API

requestAnimationFrame

浏览器提供的绘制动画的 api 。它要求浏览器在下一帧之前调用指定的回调函数更新动画

具体看以下的例子

<body>
    <div
        id="div"
        style="width: 0; height: 50px; background-color: #40a9ff"
    ></div>
    <button id="button">开始</button>
</body>
<script>
    let button = document.getElementById("button");
    let div = document.getElementById("div");

    let start = 0;
    let intervalTime = [];

    const progress = () => {
        div.style.width = div.offsetWidth + 1 + "px";
        div.innerHTML = div.offsetWidth + "%";
        if (div.offsetWidth < 100) {
            let current = Date.now();
            intervalTime.push(current - start);
            start = current;
            requestAnimationFrame(progress);
        } else {
            console.log(intervalTime); // 打印时间间隔
        }
    };

    button.onclick = () => {
        div.style.width = 0;
        let currrent = Date.now();
        start = currrent;
        requestAnimationFrame(progress);
    };
</script>

requestIdleCallback

requestIdleCallback可以使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。

详情参考MDN

具体执行流程如下

走进React Fiber的世界

requestIdleCallback(callback) 中的callback有两个属性

timeRemaining: 还剩余多少闲置时间可以用来执行耗时任务
didTimeout: 判断当前的回调函数是否被执行

const works = [
    () => {
        console.log("A1开始");
        console.log("A1结束");
    },
    () => {
        console.log("B1开始");
        console.log("B1结束");
    },
    () => {
        console.log("C1开始");
        console.log("C1结束");
    },
    () => {
        console.log("C2开始");
        console.log("C2结束");
    },
    () => {
        console.log("B2开始");
        console.log("B2结束");
    },
];

requestIdleCallback(woorkLoop);

function woorkLoop(deadline) {
    console.log("本帧的剩余时间:", deadline.timeRemaining());

    // 如果还有剩余时间,并且还有没有完成的任务
    while (deadline.timeRemaining() > 0 && works.length > 0) {
        performUnitOfWork();
    }
}

function performUnitOfWork() {
    let work = works.shift(); // 取出第一个任务,执行
    work();
}

假如中间某一个任务执行时间特别长,超过了一帧的空闲时间。

const works = [
    () => {
        console.log("A1开始");
        sleep(20);
        console.log("A1结束");
    },
    () => {
        console.log("B1开始");
        sleep(20);
        console.log("B1结束");
    },
    () => {
        console.log("C1开始");
        sleep(20);
        console.log("C1结束");
    },
    () => {
        console.log("C2开始");
        sleep(20);
        console.log("C2结束");
    },
    () => {
        console.log("B2开始");
        sleep(20);
        console.log("B2结束");
    },
];

requestIdleCallback(woorkLoop);

function woorkLoop(deadline) {
    console.log("本帧的剩余时间:", deadline.timeRemaining());

    // 如果还有剩余时间,并且还有没有完成的任务
    while (deadline.timeRemaining() > 0 && works.length > 0) {
        performUnitOfWork();
    }

    // 时间用完,还有没完成的任务
    if (works.length > 0) {
        console.log(
            `只剩${deadline.timeRemaining()},本帧时间已经用完,请等待下次调度`
        );
        // 重新请求调度
        requestIdleCallback(woorkLoop);
    }
}

function performUnitOfWork() {
    let work = works.shift(); // 取出第一个任务,执行
    work();
}

// 模拟等待时间
function sleep(duration) {
    let start = Date.now();
    while (start + duration > Date.now()) {}
}

可以发现任务分成了多帧执行,但是会发现一个问题C1,C2,B2在一起执行了,空闲时间也很明显超过了一帧正常的时间。

这是因为浏览器如果长时间处于空闲状态,会把requestIdleCallback的执行时间适当的拉长,最大可以达到50ms。

可以用requestAnimationFrame来验证。

requestAnimationFrame(progress);

function progress() {
    console.log("progress");
    requestAnimationFrame(progress);
}

MessageChannel

上面我们提到了请求浏览器调度的方法用的是requestIdleCallback,但是这个方法有个问题,我们可以看一下这个api的兼容性。

可以发现有部分浏览器不支持该方法,所以React模拟了一个requestIdleCallback, 基于MessageChannel

大概介绍下用法,不做详细解释。

var channel = new MessageChannel();
var port1 = channel.port1;
var port2 = channel.port2;
port1.onmessage = function (event) {
    console.log("port1收到来自port2的数据:" + event.data);
};
port2.onmessage = function (event) {
    console.log("port2收到来自port1的数据:" + event.data);
};

port1.postMessage("发送给port2");
port2.postMessage("发送给port1");

MessageChannel 通过调用 requestAnimationFrame 来模拟 requestIdleCallback,具体如下。

let channel = new MessageChannel();
let activeTime = 1000 / 16; // 每帧的时间
let deadLineTime; // 一帧截止时间
let pendingCallback; 
let timeRemaining = () => deadLineTime - performance.now(); // 剩余时间

channel.port2.onmessage = () => {
    let currentTime = performance.now();
    // 帧的截止时间是否小于当前时间,小于时,当前帧已过期
    let didTimeout = deadLineTime <= currentTime;

    if ((didTimeout || timeRemaining() > 0) && pendingCallback) {
        pendingCallback({ didTimeout, timeRemaining });
    }
};

window.requestIdleCallback = (callback) => {
    requestAnimationFrame((rafTime) => {
        console.log(rafTime);
        // 每一帧开始时间加上16.6就是截止时间
        deadLineTime = rafTime + activeTime;
        pendingCallback = callback;
        // 添加一个宏任务,绘制结束以后执行
        channel.port1.postMessage("hello");
    });
};

以上就是Fiber的前置知识。

实现React Fiber

Fiber分成两个阶段

协调阶段 ( Reconcilation ) : 可中断, 找出所有的变更, 例如节点新增, 删除, 属性变更等等 (以下生命周期会在这个阶段调用)

  1. componentWillMount
  2. componentWillReceiveProps
  3. static getDerivedStateFromProps
  4. shouldComponentUpdate
  5. componentWillUpdate

提交阶段 ( commit 阶段 ) : 不可中断, 执行变更 (以下生命周期会在这个阶段调用)

  1. componentDidMount
  2. componentDidUpdate
  3. componentWillUnmount

Reconcilation

1、先遍历当前节点的子节点,后弟弟节点, 最后叔叔节点
2、自己的所有的子节点完成后, 自己完成

这个阶段需要找出所有的节点变更, 这些变更在React中被称为副作用 (Effect), 遍历规则如图所示

// 根节点
let workInProgressRoot = {
    stateNode: container, // 此fiber对应的dom节点
    props: {
        children: [element],
    },
};

// 下一个工作单元
let nextUnitOfWork = workInProgressRoot;

function workLoop() {
    // 存在下一个工作单元时执行,并返回下一个工作单元
    while (nextUnitOfWork) {
        nextUnitOfWork = performanceUnitOfWork(nextUnitOfWork);
    }

    if (!nextUnitOfWork) {
        commitRoot();
    }
}

function performanceUnitOfWork(workInProgressFiber) {
    // 1. 创建真实DOM,并没有挂载  2. 创建fiber树
    beginWork(workInProgressFiber);

    // 首先遍历子节点
    if (workInProgressFiber.child) {
        return workInProgressFiber.child;
    }

    while (workInProgressFiber) {
        // 如果没有儿子,当前节点其实已经结束了
        completeUnitOfWork(workInProgressFiber);
        
        // 遍历兄弟节点
        if (workInProgressFiber.sibling) {
            return workInProgressFiber.sibling;
        }

        // 遍历父节点
        workInProgressFiber = workInProgressFiber.return;
    }
}

function beginWork(workInProgressFiber) {
    console.log("beginWork", workInProgressFiber.props.id);

    // 只创建元素,不挂载
    if (!workInProgressFiber.stateNode) {
        workInProgressFiber.stateNode = document.createElement(
            workInProgressFiber.type
        );

        for (let key in workInProgressFiber.props) {
            if (key !== "children") {
                workInProgressFiber.stateNode[key] =
                    workInProgressFiber.props[key];
            }
        }
    }

    // 创建子fiber
    let prevFiber;
    if (Array.isArray(workInProgressFiber.props.children)) {
        workInProgressFiber.props.children.forEach((item, index) => {
            let childFiber = {
                type: item.type,
                props: item.props,
                return: workInProgressFiber, // 当前节点的父节点
                effectTag: "PLACEMENT", // 标记,表示对应DOM需要被插入到页面
            };

            // 把第一个子节点挂载到当前节点的child上,其余的子节点,挂载到第一个子节点的sibling
            if (index === 0) {
                workInProgressFiber.child = childFiber;
            } else {
                prevFiber.sibling = childFiber;
            }

            prevFiber = childFiber;
        });
    }
}

副作用的收集

遍历Fiber树,将有副作用的节点收集起来,形成一个单向链表。

每个节点向上归并effect list, firstEffect表示第一个有副作用的子Fiber, lastEffect指最后一个有副作用的子Fiber, 中间通过nextEffect来形成单向链表。

effect list 顺序与fiber节点遍历的完成顺序一致。

function completeUnitOfWork(workInProgressFiber) {
    console.log("completeUnitOfWork", workInProgressFiber.props.id);
    // 构建副作用
    let returnFiber = workInProgressFiber.return; // 父节点

    if (returnFiber) {
        // 把当前fiber的有副作用子链表挂载到父节点
        if (!returnFiber.firstEffect) {
            returnFiber.firstEffect = workInProgressFiber.firstEffect;
        }

        if (workInProgressFiber.lastEffect) {
            if (returnFiber.lastEffect) {
                returnFiber.lastEffect.nextEffect =
                    workInProgressFiber.firstEffect;
            }

            returnFiber.lastEffect = workInProgressFiber.lastEffect;
        }

        // 挂载自己
        if (workInProgressFiber.effectTag) {
            if (returnFiber.lastEffect) {
                returnFiber.lastEffect.nextEffect = workInProgressFiber;
            } else {
                returnFiber.firstEffect = workInProgressFiber;
            }
            returnFiber.lastEffect = workInProgressFiber;
        }
    }
}

commit

根据effect list 更新视图。


function workLoop() {
    // 存在下一个工作单元时执行,并返回下一个工作单元
    while (nextUnitOfWork) {
        nextUnitOfWork = performanceUnitOfWork(nextUnitOfWork);
    }

    if (!nextUnitOfWork) {
        commitRoot();
    }
}


// 插入页面
function commitRoot() {
    let currentFiber = workInProgressRoot.firstEffect;

    while (currentFiber) {
        if (currentFiber.effectTag === "PLACEMENT") {
            currentFiber.return.stateNode.appendChild(currentFiber.stateNode);
        }

        currentFiber = currentFiber.nextEffect;
    }

    workInProgressRoot = null;
}

完整的代码可以查看:github地址

总结

本文只是简易的实现一个React Fiber,简单的介绍下了React Fiber的原理, 引入Fiber 的原因, 但是还是有很多的东西没有介绍, 比如任务调度的优先级, 任务中断与恢复等, 感兴趣的可以自己去查看下源码

站在巨人的肩膀上

本文只是简单的介绍, 实际上React的实现比本文要复杂的多, 如果想深入了解, 可以阅读以下的文章


南京三百云信息科技有限公司(车300)成立于2014年3月27日,是一家扎根于南京的移动互联网企业,目前坐落于南京、北京。经过7年积累,累计估值次数已达52亿次,获得了国内外多家优质投资机构青睐如红杉资本、上汽产业基金等。
三百云是国内优秀的以人工智能为依托、以汽车交易定价和汽车金融风控的标准化为核心产品的独立第三方的汽车交易与金融SaaS服务提供商。

欢迎加入三百云,一起见证汽车行业蓬勃发展,期待与您携手同行!
Java开发、Java实习、PHP实习、测试、测开、产品经理、大数据、算法实习,热招中...
官网:www.sanbaiyun.com/
投递简历:hr@che300.com,请注明来自掘金😁