实现 render 方法
这里要实现的是和 ReactDOM.render
同样的功能,代码如下:
// ...
+ function render(element, container) {
+ const dom = element.type == "TEXT_ELEMENT"
+ ? document.createTextNode("")
+ : document.createElement(element.type)
+
+ // children 被放到了 props 属性里,这里过滤掉 children
+ const isProperty = key => key !== "children"
+
+ Object.keys(element.props)
+ .filter(isProperty)
+ // 设置 dom 元素的属性,这里是简化版意思一下,直接赋值
+ .forEach(name => dom[name] = element.props[name])
+
+ // 递归子元素
+ element.props.children.forEach(child =>render(child, dom))
+
+ container.appendChild(dom)
+ }
const profile = (
<div className="profile">
<span className="profile-title">title</span>
<h3 className="profile-content">content</h3>
</div>
);
console.log('成功启动', profile);
+ const container = document.getElementById("root")
+ Didact.render(profile, container)
- 创建节点时,不同类型的节点用不同方法创建,文本节点用
createTextNode
,其他节点用createElement
- 我们创建jsx数据结构时,将
children
统一放到了props
属性里,所以给dom添加props
前,遍历props
时,需过滤掉props
里的children
- 这里给dom添加
props
属性的实现非常简单,只有一个赋值表达式dom[name] = element.props[name]
,其实是想用一行代码来代表此处还有着冗杂的属性处理,但写太复杂对理解整体react源码没有帮助,但感兴趣可以阅读。
这样大家就可以看到页面已经被渲染出来了,如下图:
截止到此处的源码
为什么要引入fiber
我们的render
方法是用递归
实现的,那么问题就来了,一旦开始递归,就不会停止,直至渲染完整个dom树。
那如果dom树很大,js就会占据着主线程,而无法做其他工作,比如用户的交互得不到响应
、动画不能保持流畅
,因为它们必须等待渲染完成。为了展示这个问题,下面有个小演示:
为了保持行星的旋转,主线程需要在每16ms左右就要运行一次。如果主线程被其他东西阻塞, 比如设置了主线程占用200毫秒, 大家就会发现动画开始丢失帧的现象——行星会发生冻结、卡顿,直到主线程再次被释放。
正是因为react的渲染会阻塞主线程太久,所以出现了react fiber
。
fiber是什么
react fiber
没法缩短
整颗树的渲染时间,但它使得渲染过程被分成一小段、一小段的,相当于有了 “保存工作进度” 的能力,js每渲染完一个单元节点,就让出主线程,丢给浏览器去做其他工作,然后再回来继续渲染,依次往复,直至比较完成,最后一次性的更新到视图上。
下面用一段伪代码来理解这个拆分过程:
// 被拆分成的一个一个单元的小任务
let nextUnitOfWork = null
function workLoop(deadline) {
// requestIdleCallback 给 shouldYield 赋值,告诉我们浏览器是否空闲
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
// 循环调用 workLoop
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
// 每次执行完一个单元任务,会返回下一个单元任务
function performUnitOfWork(nextUnitOfWork) {
// TODO
}
不熟悉 requestIdleCallback 可以点这里查看, 这个方法很简单:它需要传入一个 callback,浏览器会在空闲时去调用这个 callback, 然后给这个callback 传入一个 IdleDeadline,IdleDeadline
会预估一个剩余闲置时间,我们可以通过还剩多少闲置时间去判断,是否足够去执行下一个单元任务
。
fiber的数据结构
为了能拆分成上面的单元任务
,我们需要一种新的数据结构——fiber链表
,例如我们要渲染如下元素:
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
它被转化成的fiber 链表
的结构如下:
- 我们用
fiber
来代指一个要处理的单元任务
,如:上面的一个h1
就是一个fiber
- 几乎每一个
fiber
都有3个指针,所以每个fiber
都可以找到它的父、子(第一个子元素)、兄弟元素(这也是渲染可以中断的原因) - 每当渲染完一个
fiber
,performUnitOfWork
都会返回下一个待处理的fiber
,浏览器闲时就会去处理下一个fiber
,以此循环 - 优先返回
child fiber
做为下一个待处理的fiber
;若child fiber
不存在,则返回兄弟 fiber
;若兄弟 fiber
不存在,则往上递归,找父元素的兄弟 fiber
;以此循环...
例如:
- 当前渲染了
div
,那么下一个要处理的就是h1 fiber
- 如果
child fiber
不存在,如p fiber
,则下一个要处理的是兄弟a fiber
- 如果
child fiber
和兄弟 fiber
都不存在,如:a fiber
,则往上找叔叔 fiber
,即h2 fiber
实现 fiber
在render
方法里为nextUnitOfWork
赋值第一个fiber
,待浏览器闲时检测到了nextUnitOfWork
有值,就会启动loop循环,不断地设置下一个fiber
,也不断的遍历全部节点,代码如下:
function createDom(fiber) {
const dom =
fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type)
// children 被放到了 props 属性里,这里过滤掉 children
const isProperty = key => key !== "children"
Object.keys(fiber.props)
.filter(isProperty)
// 设置 dom 元素的属性,这里是简化版意思一下,直接赋值
.forEach(name => dom[name] = fiber.props[name])
return dom
}
function render(element, container) {
// 虽然后面会给这个对象添加更多属性,但这里是第一个 fiber
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
- 修改
render
方法:设置待执行的初始fiber
- 新增
createDom
方法: 将原render
方法里的主要逻辑移到createDom
中,即根据fiber
的属性,创建dom节点
实现 performUnitOfWork
方法:
// 每次执行完一个单元任务(做了以下3件事),会返回下一个单元任务
// 1. 给fiber添加dom,并插入父元素
// 2. 给当前fiber的每一个子元素生成fiber节点
// 3. 找到要返回的下一个 unitOfWork
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
const elements = fiber.props.children
let index = 0
let prevSibling = null
// 1. 遍历当前fiber的children
// 2. 给children里的每个child指定3个指针,分别指向其 父、子、兄弟三个节点
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
if (index === 0) {
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
// 下面的操作是返回下一个单元——nextUnitOfWork
// 1. 优先找child
// 2. 没有child找兄弟
// 3. 没有兄弟,找叔叔,也就是递归到父元素的兄弟
// 4. 没有叔叔就一直往上递归...
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
里面的注释很详尽,就不再讲述 performUnitOfWork
的实现了。
UI展示不完整问题
从下面代码可以看出,每个fiber
都会执行一次插入dom,但因渲染是会被打断的,所以就会出现只插入部分dom的情况,使某一刻的UI完整不展示。
function performUnitOfWork(fiber) {
// ...
- if (fiber.parent) {
- fiber.parent.dom.appendChild(fiber.dom)
- }
//...
}
所以要删除上面的实现,转而通过判断root节点是否全部渲染完成,若全部完成,再将整个root fiber
插入dom,实现如下:
function render(element, container) {
- nextUnitOfWork = {
+ wipRoot = {
dom: container,
props: {
children: [element],
},
}
+ nextUnitOfWork = wipRoot
}
+ function commitRoot() {
+ commitWork(wipRoot.child)
+ wipRoot = null
+ }
+ // 递归插入所有dom
+ function commitWork(fiber) {
+ if (!fiber) return
+
+ const domParent = fiber.parent.dom
+ domParent.appendChild(fiber.dom)
+ commitWork(fiber.child)
+ commitWork(fiber.sibling)
+ }
// 被拆分成的一个一个单元的小任务
let nextUnitOfWork = null
+ let wipRoot = null
function workLoop(deadline) {
// requestIdleCallback 给 shouldYield 赋值,告诉我们浏览器是否空闲
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
+ // 没有下一个待渲染的fiber,表示所有dom渲染完成,commit到root
+ if (!nextUnitOfWork && wipRoot) {
+ commitRoot()
+ }
// 循环调用 workLoop
requestIdleCallback(workLoop)
}
通过上面最后的 commitRoot
方法,将完整的 root fiber
里的所有 dom
通过递归插入到了页面,就修复了UI出现不完整展示的问题。
参考: