1. JSX与虚构DOM
咱们从React官网文档结尾最根本的一段Hello World代码动手:
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);
这段代码的意思是通过 ReactDOM.render()
办法将 h1
包裹的JSX元素渲染到id为“root”的HTML元素上. 除了在JS中早已熟知的 document.getElementById()
办法外, 这段代码中还蕴含两个知识点:
- 以
h1
标签包裹的JSX元素 ReactDOM.render()
办法
而这两个知识点则对应着React中要解决的外围问题:
- 为何以及如何应用(JSX示意的)虚构DOM?
- 如何对虚构DOM进行解决, 使其高效地渲染进去?
1.1 虚构DOM是什么? 为何要应用虚构DOM?
虚构DOM其实就是用JavaScript对象示意的一个DOM节点, 外部蕴含了节点的 tag
, props
和 children
.
为何应用虚构DOM? 因为间接操作实在DOM繁琐且低效, 通过虚构DOM, 将一部分低廉的浏览器重绘工作转移到绝对便宜的存储和计算资源上.
1.2 如何将JSX转换成虚构DOM?
通过babel能够将JSX编译为特定的JavaScript对象, 示例代码如下:
// JSX
const e = (
<div id="root">
<h1 className="title">Title</h1>
</div>
);
// babel编译后果(React17之前), 留神子元素的嵌套构造
var e = React.createElement(
"div",
{ id: "root"},
React.createElement(
"h1",
{ className: "title" },
"Title"
)
);
// React17之后编译后果有所区别, 创立节点的办法由react导出, 但基本原理大同小异
1.3 如何将虚构DOM渲染进去?
从上一节babel的编译后果能够看出, 虚构DOM中蕴含了创立DOM所需的各种信息, 对于首次渲染, 间接按照这些信息创立DOM节点即可.
但虚构DOM的真正价值在于“更新”: 当一个list中的某些项产生了变动, 或删除或减少了若干项, 如何通过比照前后的虚构DOM树, 最小化地更新实在DOM? 这就是React的外围指标.
2. React Diffing
“Diffing”即“找不同”, 就是解决上文引出的React的外围指标——如何通过比照新旧虚构DOM树, 以在最小的操作次数下将旧DOM树转换为新DOM树.
在算法畛域中, 两棵树的转换目前最优的算法复杂度为 O(n**3)
, n为节点个数. 这意味着当树上有1000个元素时, 须要10亿次比拟, 显然远远不够高效.
React在基于以下两个假如的根底上, 提出了一套复杂度为 O(n)
的启发式算法
- 不同类型(即标签名、组件名)的元素会产生不同的树;
- 通过设置
key
属性来标识一组同级子元素在渲染前后是否放弃不变.
在实践中, 以上两个假如在绝大多数场景下都成立
2.1 Diffling算法形容
不同类型的元素/组件
当元素的标签或组件名发生变化, 间接卸载并替换以此元素作为根节点的整个子树.
同一类型的元素
当元素的标签雷同时, React保留此DOM节点, 仅比照和更新有扭转的属性, 如className、title等, 而后递归比照其子节点.
对于 style
属性, React会持续深刻比照, 仅更新有扭转的属性, 如color、fontSize等.
参考React实战视频解说:进入学习
同一类型的组件
当组件的props更新时, 组件实例放弃不变, React调用组件的 componentWillReceiveProps()
componentWillUpdate()
和 componentDidUpdate()
生命周期办法, 并执行 render()
办法.
Diffing算法会递归比对新旧 render()
执行的后果.
对子节点的递归
当一组同级子节点(列表)的开端增加了新的子节点时, 上述Diffing算法的开销较小; 但当新元素被插入到列表结尾时, Diffing算法只能按程序顺次比对并重建从新元素开始的后续所有子节点, 造成极大的开销节约.
解决方案是为一组列表项增加 key
属性, 这样React就能够不便地比对出插入或删除项了.
对于 key
属性, 应稳固、可预测且在列表内惟一(无需全局惟一), 如果数据有ID的话间接应用此ID作为 key
, 或者利用数据中的一部分字段哈希出一个key值.
防止应用数组索引值作为 key
, 因为当插入或删除元素后, 之后的元素和索引值的对应关系都会产生错乱, 导致谬误的比对后果.
防止应用不稳固的key(如随机数), 因为每次渲染都会产生扭转, 从而导致列表项被不必要地重建.
2.2 递归的Diffing
在1.2节中的虚构DOM对象中能够得悉: 虚构DOM树的每个节点通过 children
属性形成了一个嵌套的树结构, 这意味着要以递归的模式遍历和比拟新旧虚构DOM树.
2.1节的策略解决了Diffing算法的工夫复杂度的问题, 但咱们还面临着另外一个重大的性能问题——浏览器的渲染线程和JS的执行线程是互斥的, 这意味着DOM节点过多时, 虚构DOM树的构建和解决会长工夫占用主线程, 使得一些须要高优先级解决的操作如用户输出、平滑动画等被阻塞, 重大影响应用体验.
工夫切片(Time Slice)
为了解决浏览器主线程的阻塞问题, 引出 工夫切片 的策略——将整个工作流程分解成小的工作单元, 并在浏览器闲暇时交由浏览器执行这些工作单元, 每个执行单元执行结束后, 浏览器都能够抉择中断渲染并解决其余须要更高优先级解决的工作.
浏览器中提供了 requestIdleCallback
办法实现此性能, 将待调用的函数退出执行队列, 浏览器将在不影响要害事件处理的状况下一一调用.
思考到浏览器的兼容性以及 requestIdleCallback
办法的不稳定性, React本人实现了专用于React的相似 requestIdleCallback
且性能更齐备的 Scheduler
来实现闲暇时触发回调, 并提供了多种优先级供工作设置.
递归与工夫切片
工夫切片策略要求咱们将虚构DOM的更新操作合成为小的工作单元, 同时具备以下个性:
- 可暂停、可复原的更新;
- 可跳过的重复性、覆盖性更新;
- 具备优先级的更新.
对于递归模式的程序来说, 这些是难以实现的. 于是就须要一个处于递归模式的虚构DOM树下层的数据结构, 来辅助实现这些个性.
这就是React16引入的重构后的算法外围——Fiber.
3. Fiber
从概念上来说, Fiber就是重构后的虚构DOM节点, 一个Fiber就是一个JS对象.
Fiber节点之间形成 单向链表 构造, 以实现前文提到的几个个性: 更新可暂停/复原、可跳过、可设优先级.
3.1 Fiber节点
一个Fiber节点就是一个JS对象, 其中的要害属性可分类列举如下:
-
构造信息(形成链表的指针属性)
- return: 父节点
- child: 第一个子节点
- sibling: 右侧第一个兄弟节点
- alternate: 本节点在相邻更新时的状态, 用于比拟节点前后的变动, 3.3节详述
-
组件信息
- tag: 组件创立类型, 如FunctionComponent、ClassComponent、HostComponent等
- key: 即key属性
- type: 组件类型, Function/Class组件的type就是对应的Function/Class自身, Host组件的type就是对应元素的TagName
- stateNode: 对应的实在DOM节点
-
本次更新的props和state相干信息
- pendingProps、memoizedProps
- memoizedState
- dependencies
- updateQueue
-
更新标记
- effectTag: 节点更新类型, 如替换、更新、删除等
- nextEffect、firstEffect、lastEffect
- 优先级相干: lanes、childrenLanes
3.2 Fiber树
前文说到, Fiber节点通过 return
, child
和 slibling
属性形成了单向链表构造, 为了与DOM树对应, 习惯上仍称其为“树”.
如一棵DOM树:
<div>
<h1>Title</h1>
<section>
<h2>Section</h2>
<p>Content</p>
</section>
<footer>Footer</footer>
</div>
其 section
节点的Fiber可示意为:
const sectionFiber = {
key: "SECTION_KEY",
child: h2Fiber,
sibling: footerFiber,
return: divFiber,
alternate: oldSectionFiber,
...otherFiberProps,
}
整体的Fiber构造:
3.3 Fiber架构
基于Fiber形成的虚构DOM树就是Fiber架构.
在3.1节中咱们介绍过, 在Fiber节点中有一个重要属性 alternate
, 单词意为“备用”.
实际上, 在React中最多会同时存在两棵Fiber树:
- 以后显示在屏幕上、曾经构建实现的Fiber树称为“Current Fiber Tree”, 咱们将其中的Fiber节点简写为
currFiber
; - 以后正在构建的Fiber树称为“WorkInProgress Fiber Tree”, 咱们将其Fiber节点节点简写为
wipFiber
.
而这两棵树中节点的 alternate
属性相互指向对方树中的对应节点, 即: currFiber.alternate === wipFiber; wipFiber.alternate === currFber;
他们用于比照更新前后的节点以决定如何更新此节点.
在React中, 整个利用的根节点为 fiberRoot
, 当wipFiber树构建实现后, fiberRoot.current
将从currFiber树的根节点切换为wipFiber的根节点, 以实现更新操作.
3.1 基于Fiber的调度——工夫切片
在2.2节咱们探讨了采纳拆分工作单元并以工夫切片的形式执行, 以防止阻塞主线程. 在Fiber架构下, 每个Fiber节点就是一个工作单元.
在以下示例代码中, 咱们应用浏览器提供的 requestIdleCallback
办法演示这个过程, 它会在浏览器闲暇时执行一个workLoop、解决一个Fiber节点, 而后能够依据理论状况继续执行或暂停期待执行下一个workLoop.
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
// 解决一个Fiber节点, 返回下一个Fiber节点, 详见3.3节
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
// 暂停解决的演示: 当工夫有余时勾销循环处理过程
shouldYield = deadline.timeRemaining() < 1;
}
// 当执行结束(不存在下一个执行单元), 提交整个DOM树
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
3.2 对Fiber节点的解决程序——DFS
由前文咱们可知, Fiber节点通过 return
child
sibling
三个属性相互连接, 整体形成一个单向链表构造,其调度形式就是 深度优先遍历 :
- 以wipFiber树的Root节点作为第一个执行单元;
- 若以后执行单元存在child节点, 则将child节点作为下一个执行单元;
- 反复2, 直至以后执行单元无child;
- 若以后执行单元存在sibling节点, 则将sibling节点作为下一个执行单元, 并回到2;
- 若以后执行单元无child且无sibling, 返回到父节点, 并回到4;
- 反复5; 直至回到Root节点, 执行结束, 将
fiberRoot.current
只为wipFiber树的根节点.
以上步骤阐明, Fiber节点通过 child
→ sibling
→ return
的程序进行深度优先遍历“解决”, 而后更新Fiber树. 那么如何“解决”Fiber节点呢?
3.3 对Fiber节点的处理过程
对Fiber节点的解决就是执行一个 performUnitOfWork
办法, 它接管一个将要解决的Fiber节点, 而后实现以下工作:
-
欠缺构建Fiber节点: 创立DOM并获取
children
- 对于HostComponent和ClassComponent, 依据Fiber中的相干属性, 创立DOM节点并赋给
Fiber.stateNode
属性; - 对于FunctionComponent, 间接通过函数调用获取其children:
Fiber.type(Fiber.props)
// 执行工作单元,并返回下一个工作单元 function performUnitOfWork(fiber) { // 构建以后节点的fiber const isFunctionComponent = fiber.type instanceof Function; if (isFunctionComponent) { updateFunctionComponent(fiber); } else { updateHostComponent(fiber); } // 解决子节点, 构建Fiber树 const elements = fiber.props.children; reconcileChildren(fiber, elements); // TODO: 返回下一个执行单元 // fiber.child || fiber.sibling || fiber.return } // Class/Host组件: 创立DOM function updateHostComponent(fiber) { if (!fiber.dom) { fiber.dom = createDom(fiber); } reconcileChildren(fiber, fiber.props.children); } // 更新Function组件, Function组件须要从返回值获取子组件 // 留神: Function组件无DOM function updateFunctionComponent(fiber) { // 初始化hooks wipFiber = fiber; hookIndex = 0; fiber.hooks = []; const children = [fiber.type(fiber.props)]; // Function组件返回children reconcileChildren(fiber, children); } // TODO: reconcileChildren解决子节点,见第3步
- 对于HostComponent和ClassComponent, 依据Fiber中的相干属性, 创立DOM节点并赋给
-
通过
Fiber.alternate
获取oldFiber
, 即上一次更新后的Fiber值, 而后在下一步中构建和Diff以后Fiber的children
.function reconcileChildren(wipFiber, elements) { let oldFiber = wipFiber.alternate && wipFiber.alternate.child; // ... }
-
构建
children
Fibers, 对于每个子Fiber, 同步地实现以下工作:- 构建Fiber链表: 为每个子元素创立Fiber, 并将父Fiber的
child
属性指向第一个子Fiber, 而后按程序将子Fiber的sibling
属性指向下一个子Fiber; - 比照(Diffing)新旧Fiber节点的
type
props
key
等属性, 确定节点是能够间接复用、替换、更新还是删除, 须要更新的Fiber节点在其effectTag
属性中打上Update
Placement
PlacementAndUpdate
Deletion
等标记, 以在提交更新阶段进行解决.
function reconcileChildren(wipFiber, elements) { let oldFiber = wipFiber.alternate && wipFiber.alternate.child; let index = 0; let prevSibling = null; while (index < elements.length || oldFiber !== null) { const element = elements[index]; let newFiber = null; // Compare oldFiber to element const sameType = oldFiber && element && element.type === oldFiber.type; if (sameType) { // update the node newFiber = { type: oldFiber.type, props: element.props, dom: oldFiber.dom, parent: wipFiber, alternate: oldFiber, effectTag: "UPDATE", }; } if (element && !sameType) { // add this node newFiber = { type: element.type, props: element.props, dom: null, parent: wipFiber, alternate: null, effectTag: "PLACEMENT", }; } if (oldFiber && !sameType) { // delete the oldFiber's node oldFiber.effectTag = "DELETION"; deletions.push(oldFiber); } if (oldFiber) { oldFiber = oldFiber.sibling; } if (index === 0) { wipFiber.child = newFiber; } else { prevSibling = newFiber; } prevSibling = newFiber; index++; }
- 构建Fiber链表: 为每个子元素创立Fiber, 并将父Fiber的
-
按DFS程序返回下一个工作单元, 示例代码如下:
if (fiber.child) { return fiber.child; } let nextFiber = fiber; while (nextFiber) { if (nextFiber.sibling) { return nextFiber.sibling; } nextFiber = nextFiber.parent; }
当DFS过程回到根节点时, 表明本次更新的 wipFiber树 构建实现, 进入下一步的提交更新阶段.
3.4 提交更新阶段
在进入本阶段时, 新的Fiber树已构建实现, 须要进行替换、更新或删除的Fiber节点也在其 effectTag
中进行了标记, 所以本阶段第一个工作就是依据 effectTag
操作实在DOM.
为了防止从头再遍历Fiber树寻找具备 effectTag
属性的Fiber, 在上一步Fiber树的构建过程中保留了一条须要更新的Fiber节点的单向链表 effectList
, 并将此链表的头节点存储在Fiber树根节点的 firstEffect
属性中, 同时这些Fiber节点的 updateQueue
属性中也保留了须要更新的 props
.
除了更新实在DOM外, 在提交更新阶段还须要在特定阶段调用和解决生命周期办法、执行Hooks操作, 本文不再详述.
在此参考了 pomb.us/build-your-… 中提供的 useState
Hook的实现代码, 有助于了解在执行 setState
办法后都产生了什么:
function useState(initial) {
// 判断上一次渲染是否存在此Hook,如果存在就应用上一个state,否则创立新的hook并更新索引
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [], // 每次执行setState时,将action退出此队列,并在下一次渲染时执行
};
// 下一次渲染时, 获取执行队列并逐渐执行, 使得state放弃最新
const actions = oldHook ? oldHook.queue : [];
actions.forEach((action) => {
hook.state = action(hook.state);
});
// setState办法: 将action增加到执行队列并触发渲染, 在下一次渲染时执行此action
const setState = (action) => {
hook.queue.push(action);
// 执行setState后应从新触发渲染
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}