原文地址:pomb.us/build-your-…
最近在学习react源码,在搜集资料的时候,看到这篇由react开发者写的文章,由浅入深的带你实现一个 react,里面提及到的很多概念、比如 fiber,reconsile(diff 算法),commitRoot,这些都是 react 有的理念,看完这篇文章再去学习 react 源码,会更容易理解一些,非常推荐大家去看这篇文章。
因为这篇文章主要还是编码为主,所以我觉得对于这篇文章最好的学习方式是,fork 一下原文章的 codeSandBox 然后把相关代码写一遍(为啥用 codeSandBox ,因为相关的环境都配置好了,拿来即用😊🤣),最后学习完了之后写一篇博客,这种方式可以帮助我们加强理解,同时也方便后续温故而知新。
step 0:回顾
首先,我们来回顾下基础概念,如果你已经很清楚的了解 react、jsx、dom元素是如何工作的,可以跳过这个章节
我们使用如下的react app,它只有三行代码,第一行定义了一个 react 元素,第二行获取文档中的一个 dom 元素,第三行将这个 react 元素渲染到这个 dom 元素里
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
让我们移除react 特性的代码,使用原生的js来实现上面的功能
首先第一行,是一个用 jsx 语法定义的元素,我们使用 babel 把 jsx 转化成 js 代码,转化后的代码如下
// 经过 babel 编译后,jsx代码会变成 React.createElement 函数的调用
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
const container = document.getElementById("root")
ReactDOM.render(element, container)
现在有react特性的代码主要有两处,一处是 React.createElement, 另一处是ReactDom.render
Step 1: 实现 CreateElement
首先我们来实现 React.createElement
function createElement(type, props, ...children) {
return {
// 元素类型
type,
// 属性
props: {
...props,
// 子元素,如果类型是 text 类型,调用 createTextElement 方法
// 原因是因为dom api创建文本类型的元素,与其他元素标签不一样
children: children.map(child =>
typeof child === "object" ? child : createTextElement(child)
)
}
};
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: []
}
};
}
我们来看下调用之后会得到什么?
// 调用 createElement
const element = createElement(
"h1",
{ title: "foo" },
"Hello"
)
// 输出
element = {
type: 'h1',
props: {
title: 'foo',
children: [
{
type: "TEXT_ELEMENT",
props: {
nodeValue: 'Hello',
children: []
}
}
]
}
}
如果我们使用复杂一点的结构呢?
// jsx
const element = (
<div title={'11'}>
<div>22</div>
hello
</div>
)
// babel转译后
const element = React.createElement(
"div",
{title: '11'},
React.createElement("div", null, "22"),
"hello"
);
// 把 React.createElement 替换成 step1 实现的createElement,得到输出
element = {
type:"div",
props: {
title: "11",
children: [
{
type: "div",
props: {
children: [{
type: "TEXT_ELEMENT",
props: {
nodeValue: "22",
children: []
}
}]
}
},
{
type: "TEXT_ELEMENT",
props: {
nodeValue: "hello",
children: []
}
}
]
}
}
step 2:实现 Render 函数
我们尝试用原生js来替换 ReactDom.render
render函数接受两个参数,第一个参数是 虚拟dom元素(一种描述dom的数据结构,根据这种数据结构生成新的dom节点),第二个参数是真实的dom元素,作为element的父节点。
一句话描述当前版本的render函数的作用,将虚拟dom转化成真实dom,并渲染到节点中。
function render(element, container) {
// 根据 element 创建 dom 元素
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
// 遍历属性,添加到dom元素上
const isProperty = key => key !== "children";
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name];
});
// 递归,渲染 children
element.props.children.forEach(child => render(child, dom));
// 将渲染好的元素,追加到 container 元素里
container.appendChild(dom);
}
全部代码及执行结果:codesandbox.io/p/sandbox/d…
这里推荐一个工具,babel 在线转化工具:babeljs.io/ ,可以通过这个工具看到babel转化后的代码。
step 3:并发模式
在我们学习并发模式之前,我们需要重构下代码
目前我们实现的 render 是使用递归的方式,这样会导致,一旦我们启动了 render,直到一整棵dom树渲染完成,这期间是无法中断渲染的,如果这棵树太大,那么会阻塞主线程很长时间,如果这时浏览器有一些更高优先级的事务,比如处理用户的输入,动画流畅执行,那么这些高优先级的事务将会被一直阻塞,直到render完成
所以,我们将把渲染整棵树的工作,拆分成一个个小的单元,执行完每个单元后,如果有更高优先级的事务插入,我们让浏览器中断渲染,去处理更高优先级的工作
我们使用 requestIdleCallback 方法来实现,可以把这个方法理解成 setTimeout, 当浏览器的主线程闲置时,会执行 requestIdleCallback,它接受一个参数 deadline, 通过这个参数来判断我们还有多少时间可以用来渲染,什么时候浏览器会夺回控制权
React 目前不再使用 requestIdleCallback,而是使用 scheduler package,但是概念是一样的。
截至2019年11月,并发模式在 react 中尚不稳定。循环的稳定版本看起来更像是这样:
while (nextUnitOfWork) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
}
在开启这个循环之前,我们需要确定第一个工作单元,我们使用 performUnitOfWork 函数,这个函数会执行当前的工作单元,并返回下一个工作单元。
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) {
// TODO
}
step 4: Fibers
为了组织每个工作单元,我们需要一个新的数据结构,fiber树
每一个元素都有一个 fiber 节点,且每一个 fiber 都会作为一个工作单元
举个🌰,我们想要渲染下面这棵树
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
在渲染过程中,我们需要创建一个root fiber, 并将它设为下一个工作单元,后续的工作会在 performUnitOfWork方法中运行,我们会对每个 fiber 节点做三个事情
- 将这个元素添加到
dom树中 - 为这个节点的
chidren创建fiber节点 - 选出下一个工作单元
设计 fiber树 这种数据结构,其中一个目标是,能够很方便选出下一个工作单元,这也是为啥每一个 fiber 节点都与它的第一个子节点(这里注意,父节点的child只指向第一个子节点)、父节点、下一个兄弟节点有连接。
fiber作为一个工作单元,如何选取下一个工作单元?
- 优先选择当前
fiber节点 的child fiber - 如果当前
fiber节点没有child fiber,那么选择subling fiber(兄弟节点) - 如果当前fiber节点既没有
child fiber也 没有subling fiber,那么下一个工作单元是“uncle”,父fiber节点的subling兄弟节点,如果没有“uncle”,那么会顺着父节前往上找,直到找到subling节点。
fiber树的结构如下图所示:
了解了fiber相关知识后,开始编码实现它
首先,我们把render函数重构下,把创建 dom 元素从 render中剥离,变成一个单独的方法
在render 函数中,我们确定第一个工作单元
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
}
let nextUnitOfWork = null
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
当浏览器空闲的时候,会执行 requestIdleCallback,该方法中会调用 performUnitOfWork 方法,在 performUnitOfWork 方法中我们知道当前的fiber节点信息
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
// 执行到这里,运行 performUnitOfWork
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(fiber) {
// 为当前fiber节点创建 dom
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
// 添加到dom树
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
const elements = fiber.props.children
let index = 0
let prevSibling = null
// 遍历子节点,求出fiber树
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
}
}
step 5: render 阶段 和 commit 阶段
这里有一个问题,就是我们每运行一个工作单元时,我们都添加了一个新节点到dom树中,当浏览器打断我们渲染时,我们就会让用户看到一个不完整的UI,所以我们需要移除这个对dom的突变(mutates)。
我们追踪这个fiber树的根节点,我们将还在构建中的fiber树,叫做 wipRoot (work in progress root)
let nextUnitOfWork = null
let wipRoot = null
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
当我们完成了 wip fiber 树的构建,我们提交整棵fiber树到 dom中
我们新建 一个 commitRoot 方法,在这个方法中将全部节点 追加到dom中,如下
function commitRoot() {
commitWork(wipRoot.child)
wipRoot = null
}
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
step 6: Reconciliation 协调器
到目前为止,我们实现了将节点添加到dom,但是 update(更新) 和 delete(删除) 呢?我们需要去对比render中的fiber树,与最近一次提交到dom中的fiber树,我们将最新一次提交到dom中的fiber树保存到 currentRoot 变量中,当前正在渲染的fiber树我们叫做 wipRoot。对于每一个fiber节点,用 alternate属性连接
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
function commitRoot() {
commitWork(wipRoot.child)
// 保存最新提交到 dom 中的 fiber 树为 currentRoot
currentRoot = wipRoot
wipRoot = null
}
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
// 使用 alternate 属性连接新旧 fiber 树
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
}
改写下 performanceUnitOfWork 方法,在该方法中调用 reconcileChildren 函数,在该函数完成新旧 fiber 节点的对比(reconcile),也就是 diff 算法。
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
// 调用 reconcileChildren 函数
reconcileChildren(fiber, elements)
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
}
}
reconcileChildren 函数中,我们同时遍历新老fiber节点的子节点,老fiber节点通过 wipFiber.alternate 的方式获取。我们通过对比他们的差异,来判断最终渲染到dom上的元素是否需要变更。
对比新旧fiber节点的异同,我们使用下面的规则:
- 如果新旧fiber是相同的类型,保留dom节点,替换属性
- 如果类型不同,且新fiber是一个新元素,那么我们需要创建一个dom节点
- 如果类型不同,且存在一个旧fiber节点,我们需要移除这个旧fiber节点
react 也使用 key 值进行对比,使得该算法的性能更好
function reconcileChildren(wipFiber, elements) {
let index = 0
// 旧fiber
let oldFiber = wipFiber.alternate && wipFiber.alternate.child
let prevSibling = null
// 我们同时遍历新老老fiber节点的子节点
while (index < elements.length || oldFiber != null) {
const element = elements[index]
let newFiber = null
const sameType = oldFiber && element && element.type == oldFiber.type
if (sameType) {
// 如果新旧fiber是相同的类型,保留dom节点,替换属性
newFiber = {
type: oldFiber.type,
props: element.props, // 替换属性
dom: oldFiber.dom, // 保留dom节点
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE", // 添加一个 effectTag 属性,在commit 阶段生效
}
}
if (element && !sameType) {
// 如果类型不同,新建一个fiber 节点
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT", // 添加一个 effectTag 属性,在commit 阶段生效
}
}
if (oldFiber && !sameType) {
// 如果存在旧的 fiber 节点,需要删除旧的fiber 节点
oldFiber.effectTag = "DELETION" // 对旧fiber树添加标签
deletions.push(oldFiber)
}
if (index === 0) {
wipFiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
step 7: 函数组件
我们需要支持下函数组件,下面是一个简单的函数组件的例子,看看它经过babel编译后长啥样?
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)
step 8: Hooks
结语
下面就是全部的代码
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object" ? child : createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
}
}
}
function createDom(fiber) {
const dom = fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type)
updateDom(dom, {}, fiber.props)
return dom
}
const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
//Remove old or changed event listeners
Object.keys(prevProps)
.filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach(name => {
const eventType = name.toLowerCase().substring(2)
dom.removeEventListener(
eventType,
prevProps[name]
)
})
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = ""
})
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name]
})
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name.toLowerCase().substring(2)
dom.addEventListener(
eventType,
nextProps[name]
)
})
}
function commitRoot() {
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
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)
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
deletions = []
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
let deletions = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
}
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
let wipFiber = null
let hookIndex = null
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
const oldHook =
wipFiber.alternate
&& wipFiber.alternate.hooks
&& wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
}
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
hook.state = action(hook.state)
})
const setState = action => {
hook.queue.push(action)
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state, setState]
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
function reconcileChildren(wipFiber, elements) {
let index = 0
let oldFiber = wipFiber.alternate && wipFiber.alternate.child
let prevSibling = null
while (index < elements.length || oldFiber != null) {
const element = elements[index]
let newFiber = null
const sameType = oldFiber && element && element.type == oldFiber.type
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
}
}
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
if (index === 0) {
wipFiber.child = newFiber
} else if (element) {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
const Didact = {
createElement,
render,
useState,
}
/** @jsx Didact.createElement */
function Counter() {
const [state, setState] = Didact.useState(1)
return (
<h1 onClick={() => setState(c => c + 1)}>
Count: {state}
</h1>
)
}
const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container)