手写一个react-mini
【react-mini】 最近想手写一下react-mini 于是便搜集了一些资料 加上对于这段时间学习react的经验 ,开始手写一下,只有通过手写才能更加深入的去了解内部运行机制。也是给自己的查缺补漏和技术分享。
笔者文章集合详见:
createElement
接下来我们从最简单的createElement开始实现: 我们在使用react时,只有三行代码。第一个定义了一个 React 元素,下一个从 DOM 获得一个节点,最后一个函数将 React 元素呈现到容器中。
javascript
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
在第一行,我们用 JSX 定义元素。它甚至不是有效的 JavaScript,因此为了用普通的 JS 替换它,首先我们需要用有效的 JS 替换它。通过诸如 Babel 之类的构建工具,JSX 被转换为 JS。 我们可以通过babel在线网站查看被转换过后的代码:
php
React.createElement("h1", {
title: "foo"
}, "Hello");
React.createElement
除了做了一些校验本质上就是返回了一个对象,我们可以很安全的将他的返回结果-一个处理过后的对象作为最终的输出:
go
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
//转换为:
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
}
那如何转换呢?我们一会聊。。 这就是一个元素,一个有两个属性的对象: (它有更多的属性,但是我们只关心这两个属性)。 于是我么可以通过element去创建元素并将props赋值给创建的元素:
scss
onst 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转换为基本的保存节点信息的对象,再通过该对象去创建节点的过程,接下来回答如何转换的问题:
javascript
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: [],
},
}
}
如果我们有一个像这样的注释,当 babel 传递 JSX 时,它将使用我们定义的函数:
javascript
const Didact = {
createElement,
}
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)
接下来完成render函数:
现在,我们只关心向 DOM 添加内容,稍后我们将处理更新和删除。 我们首先使用元素类型创建 DOM 节点,然后将新节点附加到容器中。
我们递归地对每个孩子执行相同的操作。
我们还需要处理文本元素,如果元素类型是我们创建的文本节点,而不是常规的节点,
这里我们需要做的最后一件事是将元素分配给节点。
ini
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)
}
实现Concurrent Mode
在上方render
中element.props.children.forEach
这个递归调用有一个问题。
一旦我们开始呈现,我们将不会停止,直到我们呈现完整的元素树。如果元素树很大,它可能会阻塞主线程太长时间。如果浏览器需要处理用户输入或保持动画流畅等高优先级的事情,它将不得不等待,直到渲染完成。
所以我们要把工作分成几个小单元,每个单元完成后,如果还有其他需要做的事情,我们会让浏览器中断渲染。
我们用来做一个循环,浏览器不会告诉它何时运行,而是在主线程空闲时运行回调。
React 不再使用 requestIdleCallback。现在它使用调度程序包。但是对于这个用例,它在概念上是相同的。
RequestIdleCallback 还为我们提供了一个截止日期参数。我们可以用它来检查我们有多少时间,直到浏览器需要再次控制。
要开始使用循环,我们需要设置第一个工作单元,然后编写一个函数,它不仅执行工作,而且还返回下一个工作单元。
scss
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
Fibers
为了组织工作单元,我们需要一个数据结构: fiber tree。
每个元素有一个fiber ,每个fiber是一个工作单元。
我们将会对每一个fiber做三件事情:
- 将元素添加到 DOM
- 为元素的子元素创造fiber
- 选择下一个工作单元
这种数据结构的目标之一是便于查找下一个工作单元。这就是为什么每一个fiber都与它的第一个孩子、下一个兄弟姐妹和它的父母有联系。

当我们完成对fiber的工作时,如果它有一个孩子,那么fiber将是下一个工作单元。
在我们的示例中,当我们完成对 div fiber的处理时,下一个工作单元将是 h1 fiber。
如果fiber没有孩子,我们使用兄弟姐妹作为下一个工作单元。
例如,p fiber没有孩子,所以我们在完成后移动到 a fiber。
如果fiber没有孩子也没有兄弟姐妹,我们就去找"叔叔": 父母的兄弟姐妹。比如样本中的 a 和 h2 fiber。
此外,如果父母没有兄弟姐妹,我们继续通过父母,直到我们找到一个与兄弟姐妹或直到我们达到根。如果我们已经到达了根,这意味着我们已经完成了这个渲染的所有工作。 重写render方法:
ini
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],
},
}
}
let nextUnitOfWork = null
在workLoop
函数中,当浏览器准备好时,它将调用我们的 workLoop,我们将开始处理 root。
首先,我们创建一个新节点并将其附加到 DOM。
我们在 fiber.DOM 属性中跟踪 DOM 节点。
然后我们为每个孩子创造一种新的fiber。
我们把它添加到fiber tree中,根据它是否是第一个孩子,把它设置为一个孩子或者一个兄弟姐妹。
最后,我们寻找下一个工作单元。我们首先尝试与孩子,然后与兄弟姐妹,然后与叔叔,等等。
ini
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
}
}
Render and Commit
我们还有一个问题。
每次处理一个元素时,我们都要向 DOM 添加一个新节点。还有,浏览器可能会在我们绘制完整棵树之前中断我们的工作,在这种情况下,用户将看到一个不完整的 UI。
因此,我们需要从这里删除改变 DOM 的部分。 删除:
scss
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
相反,我们将跟踪fiber tree的根。我们将其称为"正在进行的工作"root 或 wipRoot。
一旦我们完成了所有的工作(我们知道这一点,因为没有下一个工作单元) ,我们将整个fiber tree提交给 DOM。
scss
function commitRoot() {
// TODO add nodes to dom
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
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)
}
我们在 committee Root 函数中执行,在这里我们递归地将所有节点附加到 dom。
Reconciliation
到目前为止,我们只向 DOM 添加了一些东西,但是更新或删除节点会怎么样呢?
这就是我们现在要做的,我们需要比较我们在渲染函数上接收到的元素和我们提交给 DOM 的最后一个fiber tree。
因此,在完成提交之后,我们需要保存对"我们提交到 DOM 的最后一个fiber tree"的引用。我们称之为 currentRoot。
我们还将替代属性添加到每个fiber。这个属性是一个到旧fiber的链接,旧fiber是我们在前一个提交阶段提交给 DOM 的fiber。
现在让我们从 PerformUnitOfWork 中提取代码来创建新的fiber..
在这里我们将调和旧的fiber与新的元素。
我们同时迭代旧fiber(wipFiber.Alternate)的子元素和我们想要调和的元素数组。
如果我们忽略同时迭代一个数组和一个链表所需的所有样板,那么在这段时间里我们只剩下最重要的东西: oldFiberandelement。元素是我们想要呈现给 DOM 的东西,而 old fiber是我们上次呈现的东西。
我们需要比较它们,看看是否需要对 DOM 应用任何更改。
为了比较它们,我们使用类型:
-
如果旧的fiber和新的元素有相同的类型,我们可以保留 DOM 节点,只是更新它与新的porps
-
如果类型不同并且有一个新元素,则意味着我们需要创建一个新的 DOM 节点
-
如果类型不同,有一个旧的fiber,我们需要删除旧的节点
在这里react也使用key,这是一个更好的协调。例如,它检测子元素在元素数组中的位置何时发生更改。
当旧fiber和单元具有相同类型时,我们创建一个新的fiber,保持旧fiber中的 DOM 节点和单元中的props。
我们还在fiber中添加了一个新属性: effectTag。
然后,对于元素需要一个新的 DOM 节点的情况,我们使用 PLACEMENT 效果标记来标记新的fiber。
对于需要删除节点的情况,我们没有新的fiber,所以我们将effectTag添加到旧fiber。
但是当我们将fiber tree提交到 DOM 时,我们是从正在进行的工作根中提交的,根中没有旧的fiber。
所以我们需要一个数组来跟踪我们想要删除的节点。
然后,当我们提交对 DOM 的更改时,我们也使用来自该数组的fiber。
现在,让我们更改 committee Work 函数来处理新的 effectTags。
如果光纤有一个 PLACEMENT 效果标记,我们将执行与前面相同的操作,将 DOM 节点从父fiber追加到该节点。
如果是删除,删除孩子。
ini
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) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
reconcileChildren(fiber, elements)
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
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++;
}
}
如果它是一个 UPDATE,我们需要用更改过的props更新现有的 DOM 节点。
我们将在 updateDom 函数中执行此操作。
我们将旧fiber与新fiber的props进行比较,去掉不见的props,设置新的或更换的props。
我们需要更新的一种特殊的props是事件侦听器,因此如果道具名称以" on"前缀开头,我们将对它们进行不同的处理。
如果事件处理程序发生更改,我们将其从节点中删除。
然后我们添加新的处理程序。
scss
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 Components
Function Components在两个方面有所不同:
来自函数组件的fiber没有 DOM 节点
孩子们来自运行函数,而不是直接从props得到他们.
我们检查fiber类型是否是一个函数,并根据这一点,我们去一个不同的更新函数。
在 updateHostComponent 中,我们执行与前面相同的操作。
在 updateFunctionComponent 中,我们运行函数来获取子元素。
对于我们的示例,这里 fiber.type 是 App 函数,当我们运行它时,它返回 h1元素。
然后,一旦我们有了孩子,和解也会以同样的方式进行,我们不需要改变那里的任何东西。我们需要改变的是 committee Work 函数。
现在我们有了没有 DOM 节点的fiber,我们需要改变两件事情。
首先,要找到 DOM 节点的父节点,我们需要沿着fiber向上查找,直到找到具有 DOM 节点的fiber。
在删除一个节点时,我们还需要继续操作,直到找到一个具有 DOM 节点的子节点。