从0开始实现一个简易的React。
- 步骤0: 回顾
- 步骤1: 实现
createElement函数 - 步骤2: 实现
render函数 - 步骤3: 并发模式
- 步骤4: 实现
Fibers - 步骤5: 渲染和提交阶段
- 步骤6: 协调
- 步骤7: 函数组件
- 步骤8: Hooks
第0步:回顾
回顾一下我们怎么使用React的,分为3步,定义一个React元素、从DOM中获取一个节点、将React元素渲染到节点中。
const element = <h1 title="foo">Hello</h1>;
const container = document.getElementById("root");
ReactDOM.render(element, container);删除所有的React代码,用普通的js代码进行替换。
第1行是jsx代码,jsx通过Babel等构建工具转换为js。就是将标签的代码转换为createElement函数调用,参数是标签名、属性对象、子节点
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
);createElement函数返回一个对象:有2个属性,一个type和props(还有其他的属性,现在只关心这2个)
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello"
}
};type是一个字符串,指定想要创建的DOM节点的类型,也就是使用document.createElement创建HTML时传进去的tagName,也可以是函数,将在第7步函数组件中介绍props是一个对象,包含了jsx所有的键值。除此外还有一个特殊属性children,在上面的例子里是一个字符串,通常是一个包含更多元素(element对象)的数组,所以构成了元素树。
接下来替换ReactDOM.render。render是React改变DOM的地方。
首先,使用element中的type创建node节点,本例中是h1
然后,将所有element的props赋值到node节点上(为避免混淆,element指代React元素,node指代DOM元素)
然后创建子节点,只有1个字符串作为子节点,所以创建1个文本节点。
使用textNode而不是设置innerText,为的是以相同方式处理所有元素
注意使用nodeValue就像处理h1的title属性一样,可以认为这个字符串有props: {nodeValue: "hello"}
const node = document.createElement(element.type); // h1
node.title = element.props.title; // title="foo"
const text = docoment.createTextNode("");
text["nodeValue"] = element.props.children; // children="Hello"最后,把textNode加到h1,再把h1加到container中
const container = document.getElementById("root");
node.appendChild(text);
container.appendChild(node);也就是上面的3行React代码,最终会变成下面这些js代码
// 虚拟DOM
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello"
}
};
// 容器
const container = document.getElementById("root");
// render渲染
const node = document.createElement(element.type); // h1
node.title = element.props.title; // title="foo"
const text = document.createTextNode("");
text["nodeValue"] = element.props.children; // children="Hello"
node.appendChild(text);
container.appendChild(node);第1步:实现createElement函数
createElement函数以下面代码为例,实现我们自己的createElement函数,将jsx转换为js
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
const container = document.getElementById("root");
ReactDOM.render(element, container);上一步的时候,我们知道,一个React元素就是一个有type和props属性的对象,我们实现的函数需要做的就是创建这个对象
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
)对props使用扩展运算符号,对children使用剩余参数,这样一来,childen就总是一个数组
// 返回虚拟DOM对象
function createElement(type, props, ...children) {
return {
type,
props: {
...props, // 展开所有属性
// 使用剩余参数,所以children是一个数组
// 可能是对象或者基本类型,如果是基本类型,就是文本节点,调用createTextElement返回文本节点的虚拟DOM对象
// 总之,children是一个虚拟DOM对象构成的数组
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
)
}
};
}
// 创建文本节点虚拟DOM
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: []
}
};
}给我们自己的实现起个名字,比如Didact,我们也想用jsx,怎么告诉babel转译的时候,使用我们自己定义的Didact.createElement函数呢?添加一个注释/** @jsx Didact.createElement */就可以了
const Didact = {
createElement,
};
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)第2步:实现render函数
render函数下一步,需要实现我们自己版本的ReactDOM.render函数
目前,我们只关心向DOM添加内容,稍后处理更新和删除
完整的代码如下:
// 返回虚拟DOM对象
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
)
}
}
}
// 返回文本节点虚拟DOM
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: []
}
}
}
// 渲染函数,根据虚拟DOM节点element生成DOM
// 将生成的DOM塞到container中
function render(element, container) {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
// 判断是不是节点属性,children特殊
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))
// 处理完之后,把生成的dom塞到容器里
container.appendChild(dom)
}
// 自己实现的,起名Didact
const Didact = {
createElement,
render
}
/** @jsx Didact.createElement */
// 这段jsx经过babel,进行类似如下调用
/**
*
* Didact.createElement(
* "div",
* {style: "background: salmon"},
* Didact.createElement(
* "h1",
* null,
* "hello World"
* ),
* Didact.createElement(
* "h2",
* { style: "text-align:right" }
* "from Didact"
* )
* )
*
*/
const element = (
<div style="background: salmon">
<h1>hello World</h1>
<h2 style="text-align:right">from Didact</h2>
</div>
)
const container = document.getElementById("root")
Didact.render(element, container)第3步:并发模式
现在的问题是,render是个递归,一旦开始,就需要跑完,如果元素非常多,渲染就会阻塞浏览器操作。
解决办法就是把工作分为几个小单元,在完成每个单元后,如果还有其他需要完成的工作,就让浏览器中断渲染
这里用到requestIdleCallback,浏览器会在主线程空闲时回调requestIdleCallback
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
}提示
React不再使用requestIdleCallback,用的是调度包,概念上是一样的
第4步:实现Fibers
Fibers为了组织工作单元,需要一种数据结构:Fiber树
每个元素都有一根纤维,每根纤维都是一个工作单位。
假设要渲染一个虚拟DOM树,需要创建根Fiber,作为nextUnitOfWork下一个要进行的单元工作
剩下的,将在performUnitOfWork中完成,对于每一个Fiber,做3件事:
- 将元素添加到DOM
- 为元素的子元素创建
Fiber
- 为元素的子元素创建
- 选择下一个工作单元
每一个Fiber都有一个链接,指向第一个子单元、下一个同级单元和父单元

完成一个fiber的工作时,如果有child,child就是下一个工作单元,图例中,完成<div>,下一个就是<h1>
如果没有child,下一个就是兄弟sibling,比如<p>没有child,下一个就是<a>
如果既没有child,也没有sibling,就去找“叔叔”,比如图例中的<h2>和<a>
如果这个父节点(叔叔)没有子节点,就一直向上找,直到找到有自节点或者根为止,如果到根节点了,说明完成了渲染工作
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
// 浏览器准备之后调用workLoop
requestIdleCallback(workLoop)
function performUnitOfWork(fiber) {
// TODO 添加dom节点
if(!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
// TODO 创建新Fiber
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
}
}
function render(element, container) {
nextUnitofWork = {
dom: container,
props: {
children: [element],
}
}
}第5步:渲染和提交阶段
上一步有一个问题,fiber.parent.dom.appendChild(fiber.dom)这一步,因为浏览器中断渲染,可能看到不完整的页面。移除之,取而代之的是,追踪fiber树的根,称之为工作进度树wipRoot
一旦我们完成所有工作(没有下一个工作单元),我们就会将整个fiber树提交到 DOM
在commitRoot函数中实现这一点。在这里,我们递归地将所有节点追加到 dom 中
let nextUnitOfWork = null
let wipRoot = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
// 浏览器准备之后调用workLoop
requestIdleCallback(workLoop)
function performUnitOfWork(fiber) {
// TODO 添加dom节点
if(!fiber.dom) {
fiber.dom = createDom(fiber)
}
// TODO 创建新Fiber
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
}
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
}
}
nextUnitofWork = wipRoot
}
function commitRoot() {
// TODO
}第6步:协调
删除和更新节点
每次提交,保存上一次提交的fiber树的引用,也就是currentRoot
同时,对每一个fiber,增加alternate属性,指向旧fiber
let currentRoot = null
function commitRoot() {
commitWork(wipRoot.child)
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: currentRoot
}
nextUnitofWork = wipRoot
}