实现 render 方法
这里要实现的是和 ReactDOM.render
同样的功能,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| // ...
+ 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 每渲染完一个单元节点,就让出主线程,丢给浏览器去做其他工作,然后再回来继续渲染,依次往复,直至比较完成,最后一次性的更新到视图上。
下面用一段伪代码来理解这个拆分过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| let nextUnitOfWork = null
function workLoop(deadline) { let shouldYield = false while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork) shouldYield = deadline.timeRemaining() < 1 } requestIdleCallback(workLoop) }
requestIdleCallback(workLoop)
function performUnitOfWork(nextUnitOfWork) { }
|
不熟悉 requestIdleCallback 可以点这里查看,这个方法很简单:它需要传入一个 callback,浏览器会在空闲时去调用这个 callback,然后给这个callback 传入一个 IdleDeadline,IdleDeadline
会预估一个剩余闲置时间,我们可以通过还剩多少闲置时间去判断,是否足够去执行下一个单元任务
。
fiber 的数据结构
为了能拆分成上面的单元任务
,我们需要一种新的数据结构——fiber链表
,例如我们要渲染如下元素:
1 2 3 4 5 6 7 8 9 10
| 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
,也不断的遍历全部节点,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| function createDom(fiber) { const dom = fiber.type == "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(fiber.type)
const isProperty = key => key !== "children"
Object.keys(fiber.props) .filter(isProperty) .forEach(name => dom[name] = fiber.props[name]) return dom }
function render(element, container) { nextUnitOfWork = { dom: container, props: { children: [element], }, } }
|
- 修改
render
方法:设置待执行的初始fiber
- 新增
createDom
方法:将原 render
方法里的主要逻辑移到 createDom
中,即根据 fiber
的属性,创建 dom节点
实现 performUnitOfWork
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
|
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
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++ }
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 完整不展示。
1 2 3 4 5 6 7 8 9
| function performUnitOfWork(fiber) { // ...
- if (fiber.parent) { - fiber.parent.dom.appendChild(fiber.dom) - }
//... }
|
所以要删除上面的实现,转而通过判断 root 节点是否全部渲染完成,若全部完成,再将整个root fiber
插入 dom,实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| 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 出现不完整展示的问题。
本章源码
参考:
- build your own react
- Fibre-递增对比
- 有 React fiber,为什么不需要 Vue fiber?