了解react是如何渲染,阅读源码可能不是一件容易的事情,但是这篇文章作者用了不到400行代码构建mini React
用来介绍react
的渲染过程,虽然是基于react16.8
,但和现在渲染中心思想没有太大变化,学习完对react
渲染整体流程会有一个比较清晰认识,为后续深入研究某一项过程有一个良好开端。
React JSX 转为原生写法
React JSX
是一种声明式写法,无需命令式的方式(即直接操作DOM来更新UI)来构建UI。
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container) //V18之前写法
注:V18使用
ReactDOM.createRoot(root).render(<App />)
来渲染root节点以支持并发模式。
对于上面jsx 如何写法 通过babel
调用React
内置的createElement
等方法,实际上会被转义一个对象记录节点的type类型
,props
等基本信息 ,而ReactDOM.render
可以理解为事件根据传入React.element 对象生成实际dom挂载到container 容器节点上(当前真是react肯定会做很多优化和处理,比如root节点事件委托等内容,但这些都暂时不考虑)。
// jsx 对应js对象
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
}
},
const container = document.getElementById("root")
// ReactDOM.render对应做的事情
const node = document.createElement(element.type)
node["title"] = element.props.title
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
node.appendChild(text)
container.appendChild(node)
写一个createElement和Render
在编译时让babel
调用我们实现createElement
转为对象,对着react.createElement
实现看看参数列表,基本都是包含节点的type类型
,props
和children
信息。
React.createElement('img',{src:'xxxx'})
这个就是最初到React.Element
结构(需要渲染的结构),后续需要靠它的信息生成对应Fiber
节点,再通过Fiber
节点生成实际dom
节点。
自定义的createElement
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
function createTextElement(text) {
return {
// 这里将文本类型,定义TEXT_ELEMENT
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<span>图片描述</span>
<img src='xxxx'/>
</div>)
// 上面jsx会转义成下面的babel调用
const elementObj = Didact.createElement(
"div",
{ id:"foo"},
Didact.createElement("span", null, "图片描述"),
Didact.createElement("img")
)
不完整的render方法
拿到需要确认渲染结构elements
对象,我们只需要传入render
函数调用相应的document.createElement
等原生相关方法好像就可以了?
const container = document.getElementById("root")
render(elementObj,container)
function render(element, container) {
const dom = element.type == "TEXT_ELEMENT"
? document.createTextNode("") : document.createElement(element.type)
const isProperty = key => key !== "children"
Object.keys(element.props).filter(isProperty)
.forEach(name => {
dom[name] = element.props[name]
})
element.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
走到这里用自定义的createElement
方法和不完整render
也可以实现jsx
到生成dom
节点。但是render
由于是递归调用的,这意味着一旦开始渲染,就不会停止执行,如果元素树很大,可能会阻塞主线程太长时间。如果浏览器需要执行高优先级的操作,例如处理用户输入或保持动画流畅,则必须等到渲染完成。 所以还需要进行优化实现并发模式渲染。
工作单元和Fiber Tree
render
使用了递归调用的方式,一旦开始渲染,无法中断,React考虑将整体渲染工作划分成更小的工作单元,在完成工作单元后,允许去中断渲染来支持其它优先级更高的任务,这里作者使用了requestIdleCallback函数,该函数允许传入回调函数让浏览器中空闲状态下去执行回调函数,保障渲染过程不会阻塞浏览器其他响应,在react
中使用是scheduler(调度包)来控制。
定义一个performUnitOfWork
函数表示工作单元需要做的事情,渲染工作就像下面伪代码一样。
/**
@param deadline: 用于获取当前空闲时间对象
*/
function workLoop(deadline){
// 是否应该阻塞渲染
let shouldYield = false
// 这里没有下一个工作单元,渲染执行完,递归就结束
while(nextUnitOfWork&& !shouldYield){
nextUnitOfWork = performUnitOfWork(nextUnitOfWork) // 返回下一个需要执行工作
shouldYield = deadline.timeRemaining()<1
}
// 尝试下一次空闲时去执行
requestIdleCallback(workLoop)
}
// 开启渲染Loop
requestIdleCallback(workLoop)
这种渲染模式,比最开始写的render相比就好像将一幅图像,拆成一块块拼图🧩(生成fiber),按卡槽位置一些策略拼接(比较fiber 节点优化策略)拼的过程比较费时,过程中你可以去喝水,上厕所(一些更高优先级事情),最后拼出完整图像发朋友圈🐶(挂载在root上)。
React.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
按照root->child-〉sibling-〉parent...->root
(顺序遍历完)
遍历过程中,需要对每一个element对象转为fiber对象,还需要找到下一个元素。需要注意的是用不能在遍历中去修改对应dom元素,因为render过程是可以被中断的,所以中断后渲染内容是不可预料的内容(不知道你拼出个啥玩意),所以react在这里分开成两个阶段render
,**commit
过程。
render过程
:协调过程(reconcileChildren),计算好需要更新的fiber Tree。 (耗时长,可中断)commit过程
:提交实际操作dom结构 (不可中断,保证页面渲染一致性)
render渲染过程中,我们反复比较element Tree
(需要渲染的内容)与最近一次提交的fiber Tree
进行比较来复用原来内容,进行添加,更新和删除等操作这个过程就是协调,原文中作者介绍很简单,后面可以深入了解,先看看原文内容。
- 如果旧的 Fiber 和新的元素具有相同的类型,我们可以保留 DOM 节点并用新的 props 更新它
- 如果类型不同并且有新元素,则意味着我们需要创建一个新的 DOM 节点
- 如果类型不同并且存在旧fiber,我们需要删除旧节点
const reconcileFuntion = (elementTree,lasteFiberTree)=>{
...
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom, //
parent: wipFiber,
alternate: oldFiber, //旧节点
effectTag: "UPDATE", //commit阶段映射做对应操作
};
}
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION";
// 删除没有新fiber,只能在旧fiber打effectTag。
deletions.push(oldFiber);
// commit 删除时是没有旧fiber,额外使用deletions数组记录信息。
}
...
}
Commit 提交浏览器去渲染
原文也比较简单,通过effectTag
去执行相应计算,根据effectTag
,按照child->sibling...
,递归的遍历直到没有节点为止。
// 递归的将所有节点追加到dom中
function commitWork(fiber) {
if (!fiber) {
return;
}
let domParentFiber = fiber.parent;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
总结:
文章过程清晰易懂,并且和完整demo可以调试学习,但是因为简练,所以没有深入介绍具体过程。并且由于内容比较久远,需要结合其他最新的文章一起看比较好,总的来说,还是可以了解阅读,对初学者友好,像讲故事一样展开。