从0到1实现 React Fiber
背景
- React支持JSX语法,我们可以直接将HTML代码写到JS中间,然后渲染到页面上,我们写的HTML如果有更新的话,React还有虚拟DOM的对比,只更新变化的部分,而不重新渲染整个页面,大大提高渲染效率;
- 到了V16.x,React更是使用了一个被称为Fiber的架构,提升了用户体验,同时还引入了hooks等特性。接下来从JSX入手,手写简单的React,了解其中原理;
JSX和CreateElement
在实现React要支持JSX还需要一个库叫JSXTransformer.js,后来JSX的转换工作都集成到了babel里面了,babel还提供了在线预览的功能,可以看到转换后的效果:
const App =
(
<div>
<h1 id="title">Title</h1>
<a href="xxx">Jump</a>
<section>
<p>
Article
</p>
</section>
</div>
);
转换后:
"use strict";
const App = /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("h1", {
id: "title"
}, "Title"), /*#__PURE__*/React.createElement("a", {
href: "xxx"
}, "Jump"), /*#__PURE__*/React.createElement("section", null, /*#__PURE__*/React.createElement("p", null, "Article")));
格式化后:
var App = React.createElement(
'div',
null,
React.createElement(
'h1',
{
id: 'title',
},
'Title',
),
React.createElement(
'a',
{
href: 'xxx',
},
'Jump',
),
React.createElement(
'section',
null,
React.createElement('p', null, 'Article'),
),
);
所以从转换后的代码,可以看到React.createElement支持一下以下几个参数:
- type,也就是节点类型;
- config, 这是节点上的属性,比如id和href;
- children, 从第三个参数开始就全部是children也就是子元素了,子元素可以有多个,类型可以是简单的文本,也可以还是React.createElement,如果是React.createElement,其实就是子节点了,子节点下面还可以有子节点。这样就用React.createElement的嵌套关系实现了HTML节点的树形结构;
一个正常的React应用,最基础的除了createElement外,还有ReactDom.render,像下面:
import React from 'react';
import ReactDOM from 'react-dom';
const App = (
<div>
<h1 id="title">Title</h1>
<section>
<p>Hello xianzao</p>
</section>
</div>
);
ReactDOM.render(App, document.getElementById('root'));
手写CreateElement
对于<h1 id="title">Title</h1>
这样一个简单的节点,原生DOM也会附加一大堆属性和方法在上面,所以我们在createElement的时候最好能将它转换为一种比较简单的数据结构,只包含我们需要的元素:
{
type: 'h1',
props: {
id: 'title',
children: 'Title'
}
}
有了这个数据结构后,我们对于DOM的操作其实可以转化为对这个数据结构的操作,新老DOM的对比其实也可以转化为这个数据结构的对比,这样我们就不需要每次操作都去渲染页面,而是等到需要渲染的时候才将这个数据结构渲染到页面上。这其实就是虚拟DOM!而我们createElement就是负责来构建这个虚拟DOM的方法:
function createElement(type, props, ...children) {
// 核心逻辑不复杂,将参数都塞到一个对象上返回就行
// children也要放到props里面去,这样我们在组件里面就能通过this.props.children拿到子元素
return {
type,
props: {
...props,
children
}
}
}
手写Render
我们用createElement
将JSX代码转换成了虚拟DOM,那真正将它渲染到页面的函数是render,所以我们还需要实现下这个方法,通过我们一般的用法ReactDOM.render( <App />,document.getElementById('root'))
:
- 根组件,其实是一个JSX组件,也就是一个createElement返回的虚拟DOM;
- 父节点,也就是我们要将这个虚拟DOM渲染的位置;
有了这两个参数,我们来实现下render方法:
function render(vDom, container) {
let dom;
// 检查当前节点是文本还是对象
if(typeof vDom !== 'object') {
dom = document.createTextNode(vDom)
} else {
dom = document.createElement(vDom.type);
}
// 将vDom上除了children外的属性都挂载到真正的DOM上去
if(vDom.props) {
Object.keys(vDom.props)
.filter(key => key != 'children')
.forEach(item => {
dom[item] = vDom.props[item];
})
}
// 如果还有子元素,递归调用
if(vDom.props && vDom.props.children && vDom.props.children.length) {
vDom.props.children.forEach(child => render(child, dom));
}
container.appendChild(dom);
}
import React from './myReact';
const ReactDOM = React;
const App = (
<div>
<h1 id="title">Title</h1>
<section>
<p>Hello xianzao</p>
</section>
</div>
);
ReactDOM.render(App, document.getElementById('root'));
为什么需要Fiber
- 上述简单的实现了虚拟DOM渲染到页面上的代码,这部分工作被React官方称为
renderer
; renderer
是第三方可以自己实现的一个模块,还有个核心模块叫做reconciler
,reconciler
的一大功能就是大家熟知的diff
,他会计算出应该更新哪些页面节点,然后将需要更新的节点虚拟DOM传递给renderer
,renderer
负责将这些节点渲染到页面上;- 虽然React的
diff
算法是经过优化的,但是他却是同步的,renderer
负责操作DOM的appendChild
等API也是同步的,也就是说如果有大量节点需要更新,JS线程的运行时间可能会比较长,在这段时间浏览器是不会响应其他事件的,因为JS线程和GUI线程是互斥的,JS运行时页面就不会响应,这个时间太长了,用户就可能看到卡顿,特别是动画的卡顿会很明显。在React的官方演讲中有个例子,可以很明显的看到这种同步计算造成的卡顿;
而Fiber就是用来解决这个问题的,Fiber可以将长时间的同步任务拆分成多个小任务,从而让浏览器能够抽身去响应其他事件,等他空了再回来继续计算,这样整个计算流程就显得平滑很多。
现在的问题:
- 上面我们自己实现的
render
方法直接递归遍历了整个vDom树,如果我们在中途某一步停下来,下次再调用时其实并不知道上次在哪里停下来的,不知道从哪里开始,所以vDom
的树形结构并不满足中途暂停,下次继续的需求,需要改造数据结构; - 拆分下来的小任务什么时候执行?我们的目的是让用户有更流畅的体验,所以我们最好不要阻塞高优先级的任务,比如用户输入,动画之类,等他们执行完了我们再计算。那我怎么知道现在有没有高优先级任务,浏览器是不是空闲呢?
总结下来,Fiber
要想达到目的,需要解决两个问题:
- 新的任务调度,有高优先级任务的时候将浏览器让出来,等浏览器空了再继续执行;
- 新的数据结构,可以随时中断,下次进来可以接着执行;
requestIdleCallback
requestIdleCallback
是一个实验中的新API,这个API调用方式如下:
- caniuse查看适配程度
// 开启调用
var handle = window.requestIdleCallback(callback[, options])
// 结束调用
Window.cancelIdleCallback(handle)
requestIdleCallback
接收一个回调,这个回调会在浏览器空闲时调用,每次调用会传入一个IdleDeadline,可以拿到当前还空余多久,options可以传入参数最多等多久,等到了时间浏览器还不空就强制执行了,使用这个API可以解决任务调度的问题,让浏览器在空闲时才计算diff并渲染;- 但是这个API还在实验中,兼容性不好,所以React官方自己实现了一套。(基于时间原因,本文会继续使用
requestIdleCallback
来进行任务调度)我们进行任务调度的思想是将任务拆分成多个小任务,requestIdleCallback
里面不断的把小任务拿出来执行,当所有任务都执行完或者超时了就结束本次执行,同时要注册下次执行,代码架子就是这样:
function workLoop(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
// 这个while循环会在任务执行完或者时间到了的时候结束
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
// 如果任务还没完,但是时间到了,我们需要继续注册requestIdleCallback
requestIdleCallback(workLoop);
}
// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {}
// 使用requestIdleCallback开启workLoop
requestIdleCallback(workLoop);
Fiber的可中断数据结构
接下来实现performUnitOfWork
,从上面的结构可以看出来,他接收的参数是一个小任务,同时通过这个小任务还可以找到他的下一个小任务,Fiber构建的就是这样一个数据结构。
Fiber的数据结构是一棵树,包含3部分
- child: 父节点指向第一个子元素的指针;
- sibling:从第一个子元素往后,指向下一个兄弟元素;
- return:所有子元素都有的指向父元素的指针;
有了这几个指针后,我们可以在任意一个元素中断遍历并恢复,比如在上图List
处中断了,恢复的时候可以通过child
找到他的子元素,也可以通过return
找到他的父元素,如果他还有兄弟节点也可以用sibling
找到。Fiber这个结构外形看着还是棵树,但是没有了指向所有子元素的指针,父节点只指向第一个子节点,然后子节点有指向其他子节点的指针,这其实是个链表。
实现Fiber
- 将之前的
vDom
结构转换为Fiber
的数据结构,同时需要能够通过其中任意一个节点返回下一个节点,其实就是遍历这个链表; - 遍历的时候从根节点出发,先找子元素,如果子元素存在,直接返回,如果没有子元素了就找兄弟元素,找完所有的兄弟元素后再返回父元素,然后再找这个父元素的兄弟元素。整个遍历过程其实是个深度优先遍历(DFS),从上到下,然后最后一行开始从左到右遍历;
- 比如下图从div1开始遍历的话,遍历的顺序就应该是
div1 -> div2 -> h1 -> a -> div2 -> p -> div1
。可以看到这个序列中,当我们return父节点时,这些父节点会被第二次遍历,所以我们写代码时,return的父节点不会作为下一个任务返回,只有sibling
和child
才会作为下一个任务返回;
// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {
// 根节点的dom就是container,如果没有这个属性,说明当前fiber不是根节点
if(!fiber.dom) {
fiber.dom = createDom(fiber); // 创建一个DOM挂载上去
}
// 如果有父节点,将当前节点挂载到父节点上
if(fiber.return) {
fiber.return.dom.appendChild(fiber.dom);
}
// 将我们前面的vDom结构转换为fiber结构
const elements = fiber.children;
let prevSibling = null;
if(elements && elements.length) {
for(let i = 0; i < elements.length; i++) {
const element = elements[i];
const newFiber = {
type: element.type,
props: element.props,
return: fiber,
dom: null
}
// 父级的child指向第一个子元素
if(i === 0) {
fiber.child = newFiber;
} else {
// 每个子元素拥有指向下一个子元素的指针
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
}
}
// 这个函数的返回值是下一个任务,这其实是一个深度优先遍历
// 先找子元素,没有子元素了就找兄弟元素
// 兄弟元素也没有了就返回父元素
// 然后再找这个父元素的兄弟元素
// 最后到根节点结束
// 这个遍历的顺序其实就是从上到下,从左到右
if(fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while(nextFiber) {
if(nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.return;
}
}
统一commit DOM操作
performUnitOfWork
一边构建Fiber结构一边操作DOMappendChild
,这样如果某次更新好几个节点,操作了第一个节点之后就中断了,那我们可能只看到第一个节点渲染到了页面,后续几个节点等浏览器空了才陆续渲染。为了避免这种情况,我们应该将DOM操作都搜集起来,最后统一执行,这就是commit
。为了能够记录位置,我们还需要一个全局变量workInProgressRoot
来记录根节点,然后在workLoop
检测如果任务执行完了,就commit
let workInProgressRoot = null; // 指向Fiber的根节点
function workLoop(deadline) {
while(nextUnitOfWork && deadline.timeRemaining() > 1) {
// 这个while循环会在任务执行完或者时间到了的时候结束
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
// 任务做完后统一渲染
if(!nextUnitOfWork && workInProgressRoot) {
commitRoot();
}
// 如果任务还没完,但是时间到了,我们需要继续注册requestIdleCallback
requestIdleCallback(workLoop);
}
因为我们是在Fiber树完全构建后再执行的commit
,而且有一个变量workInProgressRoot
指向了Fiber的根节点,所以我们可以直接把workInProgressRoot
拿过来递归渲染就行了:
// 统一操作DOM
function commitRoot() {
commitRootImpl(workInProgressRoot.child); // 开启递归
workInProgressRoot = null; // 操作完后将workInProgressRoot重置
}
function commitRootImpl(fiber) {
if(!fiber) {
return;
}
const parentDom = fiber.return.dom;
parentDom.appendChild(fiber.dom);
// 递归操作子元素和兄弟元素
commitRootImpl(fiber.child);
commitRootImpl(fiber.sibling);
}
reconcile
reconcile
其实就是虚拟DOM树的diff
操作:
- 删除不需要的节点;
- 更新修改过的节点;
- 添加新的节点;
为了在中断后能回到工作位置,我们还需要一个变量currentRoot
,然后在fiber节点里面添加一个属性alternate
,这个属性指向上一次运行的根节点,也就是currentRoot
。currentRoot
会在第一次render
后的commit
阶段赋值,也就是每次计算完后都会把当次状态记录在alternate
上,后面更新了就可以把alternate
拿出来跟新的状态做diff。然后performUnitOfWork
里面需要添加调和子元素的代码,可以新增一个函数reconcileChildren
。这个函数要将老节点跟新节点拿来对比,对比逻辑如下:
- 如果新老节点类型一样,复用老节点DOM,更新props;
- 如果类型不一样,而且新的节点存在,创建新节点替换老节点;
- 如果类型不一样,没有新节点,有老节点,删除老节点;
注意删除老节点的操作是直接将oldFiber
加上一个删除标记就行,同时用一个全局变量deletions记录所有需要删除的节点:
// 对比oldFiber和当前element
const sameType = oldFiber && element && oldFiber.type === element.type; //检测类型是不是一样
// 先比较元素类型
if(sameType) {
// 如果类型一样,复用节点,更新props
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
return: workInProgressFiber,
alternate: oldFiber, // 记录下上次状态
effectTag: 'UPDATE' // 添加一个操作标记
}
} else if(!sameType && element) {
// 如果类型不一样,有新的节点,创建新节点替换老节点
newFiber = {
type: element.type,
props: element.props,
dom: null, // 构建fiber时没有dom,下次perform这个节点是才创建dom
return: workInProgressFiber,
alternate: null, // 新增的没有老状态
effectTag: 'REPLACEMENT' // 添加一个操作标记
}
} else if(!sameType && oldFiber) {
// 如果类型不一样,没有新节点,有老节点,删除老节点
oldFiber.effectTag = 'DELETION'; // 添加删除标记
deletions.push(oldFiber); // 一个数组收集所有需要删除的节点
}
然后就是在commit
阶段处理真正的DOM操作,具体的操作是根据我们的effectTag
来判断的:
function commitRootImpl(fiber) {
if(!fiber) {
return;
}
const parentDom = fiber.return.dom;
if(fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
parentDom.appendChild(fiber.dom);
} else if(fiber.effectTag === 'DELETION') {
parentDom.removeChild(fiber.dom);
} else if(fiber.effectTag === 'UPDATE' && fiber.dom) {
// 更新DOM属性
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
}
// 递归操作子元素和兄弟元素
commitRootImpl(fiber.child);
commitRootImpl(fiber.sibling);
}
替换和删除的DOM操作都比较简单,更新属性的会稍微麻烦点,我们用一个辅助函数updateDom
来实现:
// 更新DOM的操作
function updateDom(dom, prevProps, nextProps) {
// 1. 过滤children属性
// 2. 老的存在,新的没了,取消
// 3. 新的存在,老的没有,新增
Object.keys(prevProps)
.filter(name => name !== 'children')
.filter(name => !(name in nextProps))
.forEach(name => {
if(name.indexOf('on') === 0) {
dom.removeEventListener(name.substr(2).toLowerCase(), prevProps[name], false);
} else {
dom[name] = '';
}
});
Object.keys(nextProps)
.filter(name => name !== 'children')
.forEach(name => {
if(name.indexOf('on') === 0) {
dom.addEventListener(name.substr(2).toLowerCase(), nextProps[name], false);
} else {
dom[name] = nextProps[name];
}
});
}
总结
React V15 在渲染时,会递归比对 VirtualDOM 树,找出需要变动的节点,然后同步更新它们,过程期间,React 会占据浏览器资源,这会导致用户触发的事件得不到响应,并且会导致掉帧.导致用户感觉到卡顿。
为了给用户制造一种应用很快的“假象”,不能让一个任务长期霸占着资源。可以将浏览器的渲染、布局、绘制、资源加载(例如 HTML 解析)、事件响应、脚本执行视作操作系统的“进程”,需要通过某些调度策略合理地分配 CPU 资源,从而提高浏览器的用户响应速率,同时兼顾任务执行效率。
所以 React 通过Fiber 架构,让这个执行过程变成可被中断。“适时”地让出 CPU 执行权,除了可以让浏览器及时地响应用户的交互,还有其他好处:
-
分批延时对DOM进行操作,避免一次性操作大量 DOM 节点,可以得到更好的用户体验;
-
给浏览器一点喘息的机会,它会对代码进行编译优化(JIT)及进行热代码优化,或者对 reflow 进行修正;
核心思想: Fiber 也称协程或者纤程,它和线程并不一样,协程本身是没有并发或者并行能力的(需要配合线程),它只是一种控制流程的让出机制。让出 CPU 的执行权,让 CPU 能在这段时间执行其他的操作。渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。
转载自:https://juejin.cn/post/7254853744918216762