写在前头
最近在学习 react 的源码,但是内,没有什么头绪,也不知道从何看起,一行一行的阅读源码也不是个办法。偶然间 从掘金上看到一篇 React源码揭秘1 架构设计与首屏渲染,发现 pomb 大神的一篇 build-your-own-react 构建一个你自己的 react,迅速激起了了我的兴趣,他从简单的 createElement 函数讲起,然后一步一步带领大家,很详细通俗的讲解了 Fiber 、并发模式等晦涩的概念,最终实现一个 react,此文章是我读完之后的一个大致总结,作为一个知识搬运工,再将我消化的知识以更简洁易懂的方式分享给大家
Step 1 预备知识 JSX 是如何被解析的 (createElement)
如下是一段 简单的 jsx 语法
const element = <h1 title="foo">Hello</h1>
它会 被 babel 解析为 如下代码
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
接下来我们来实现一个简单的 createElement ,用它来生成 虚拟DOM
function createElement(type, props, ...children) {
return {
// 标记 元素类型
type,
// 元素的属性
props: {
...props,
// 子元素
children: children.map(child =>
// 为了区分 基本类型 和引用类型,我们单独 用 createTextElement 来创造 文本节点
typeof child === "object"
? child
: createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
Step 2 我们需要 render 到真实的 dom 节点上
function render(element, container) {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
// 排除 特殊属性 "children"
const isProperty = key => key !== "children"
// 将元素属性 一一 写入 dom 节点上
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name]
})
// 遍历递归 将 子元素 一个一个 都 附到 真实的 dom 节点上
element.props.children.forEach(child =>
render(child, dom)
)
// 最后挂载到 指定的 dom 节点容器上
container.appendChild(dom)
}
Step 3 并发模式 (Concurrent Mode)
到目前为止,我们好像差不多已经实现另一个 简版的 React 了,可以把 JSX 渲染到 dom 上了,但是有一个问题
就是 我们的 render 函数 是使用 递归 来实现 patch 到 dom 上的,如果我们的 节点层级很大,节点很多的话,可能就会长时间占用浏览器进程,造成阻塞,影响浏览器更高优先级的事务处理(比如说,用户的输入和 ui 交互)
因为 我们需要 把这个大的任务 切割 分为 多个 小的 工作单元,这样的话,如果浏览器有更高优先级的事务处理,我们就可以中断 react 元素的渲染,这我们引入一个概念,称它为 “并发模式”
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 什么是 Fiber ,为何我们需要它
什么是 Fiber (这里引用 React技术揭秘 的解释)
Fiber 的结构
上图是一棵 Fiber 树。为了组织工作单元,我们需要一个 数据结构,每个元素都有 一个 filber 结构,每个 fiber 都是 一个 工作单元
每个工作单元是如何工作的内,下面的函数 performUnitOfWork里 ,主要做了三件事:
- 把元素添加到 dom 中
- 为元素的子元素都创建一个 fiber 结构
- 找到下一个工作单元
function performUnitOfWork(fiber) {
// 创建一个 dom 元素,挂载到 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
// 保存 上一个 sibling fiber 结构
let prevSibling = null
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
// 第一个子元素 作为 child,其余的 子元素 作为 sibling
if (index === 0) {
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
// step1 如果 有 child fiber ,则返回 child
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
// step2 如果 有 sibling fiber ,则返回 sibling
if (nextFiber.sibling) {
return nextFiber.sibling
}
// step3 ,否则 返回 他的 parent fiber
nextFiber = nextFiber.parent
}
}
Step 5 Render 和 Commit 阶段都做了什么
我们在 上面的 performUnitOfWork 里 ,每次都把 元素 直接 添加到 dom 上,这里 会有一个问题,就是 浏览器 随时都有可能中断我们的操作,这样呈现给用户的就是 一个 不完整的 UI,所以 我们需要 做出些改动,就是 所有工作单元执行完后,我们再一并进行 所有 dom 的 添加
function commitRoot() {
// TODO add nodes to dom
}
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
// 所有工作单元都执行完后,我们一并 进行 提交 操作,commitRoot 里进行所有元素 往 dom 树 上添加的动作
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
Step 6 Reconciliation 协调阶段
到目前为止,我们只处理 添加 dom 的情况,那么 update 和 remove dom 的情况 该怎么办内,这个时候,我们就需要在 commit 阶段完成后,用 一个 变量来保存旧的 fiber 树(称为 currentRoot) 来 和 当前(WipRoot: Work in progress )要修改的 fiber 树 进行比较,我们还在 每个 wipRoot 上新增一个 属性 alternate 用来 链接 旧的 fiber 树(上一次 commit 后的 )
function commitRoot() {
commitWork(wipRoot.child)
// commit 阶段完成后,保存当前 fiber 树
currentRoot = wipRoot
wipRoot = null
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
// 和上一次的 commit 阶段的 旧 fiber 树建立连接
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
}
let currentRoot = null
这里的比较规则如下:
- 如果旧的 fiber 元素 和新元素具有相同的类型,那么再进一步进行比较 他们的 属性
- 如果类型不同,并且有一个新元素,则需要创建一个新的DOM节点
- 如果类型不同,并且有一个旧 fiber 元素,则移除旧的节点 这里React也使用 key 进行比较。例如,它检测到子元素在元素数组中的位置发生了变化。
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
reconcileChildren(fiber, elements)
// ignore
}
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",
}
}
// 类型不同,但是 新 fiber 元素存在,则进行 新增(新增新的 fiber)
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
// 类型不同,但是 旧 fiber 树存在,则进行 移除 (先收集起来,在 commit 阶段一并移除)
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
// 下个循环 对 兄弟 fiber 进行比较 (和 下面的 i++ 一个道理)
if (oldFiber) {
oldFiber = oldFiber.sibling
}
// 如果是 第一个 子元素,则把 新的 fiber 挂到 wipFiber 的 child 属性上
if (index === 0) {
wipFiber.child = newFiber
} else if (element) {
// 其他的 子元素 ,挂到 上一个子元素的 sibling 属性上
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
reconcile 协调阶段完成后,我们进入commit阶段
function commitRoot() {
// 移除 刚才收集的 旧节点
deletions.forEach(commitWork)
// commit 当前 wipRoot 的 child 元素
commitWork(wipRoot.child)
// 改变当前 root 指向
currentRoot = wipRoot
wipRoot = null
}
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild(fiber.dom)
} else if (
fiber.effectTag === "UPDATE" &&
fiber.dom != null
) {
// 更新 dom 的 属性(新增新属性和移除旧属性) 及 事件的添加和移除处理
updateDom(
fiber.dom,
fiber.alternate.props,
fiber.props
)
} else if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom)
}
commitWork(fiber.child)
commitWork(fiber.sibling)
}
接下来处理 更新 dom的 操作
// 事件属性
const isEvent = key => key.startsWith("on")
// 除 事件属性 和 特殊属性 children 外的属性
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) {
// 移除旧事件
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]
)
})
// 移除旧属性
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = ""
})
// 添加或更新新属性
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name]
})
// 添加监听事件
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name
.toLowerCase()
.substring(2)
dom.addEventListener(
eventType,
nextProps[name]
)
})
}
Step 7 函数式组件 Function Components
接下来,我们来实现 函数式组件
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)
函数式组件有两点不同,如下:
- 函数式组件没有 dom 节点 ?
- 他的 children 属性 不在 props 上,而是 他的返回值
那么我们需要改动如下
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
}
}
function updateFunctionComponent(fiber) {
// 执行函数式组件获取到 children
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
然后 commitWork 也要改动下:
function commitWork(fiber) {
if (!fiber) {
return
}
let domParentFiber = fiber.parent
// 递归找到 含有 dom 节点的 元素
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild(fiber.dom)
}
// ignore
else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent)
}
// ignore
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
// 删除节点,直到有 dom 节点的元素为止
commitDeletion(fiber.child, domParent)
}
}
Step 8 Hooks 钩子
最后一步,让我们给函数式组件增加 状态(state)
// 保存当前的 fiber
let wipFiber = null
// 保存当前执行 hook 的索引,区分每次执行是哪个 hook
let hookIndex = null
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
接下来实现 useState 钩子
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];
}
小结
至此,我们就实现了一个简易版的 React,虽然 和 React 实际上的代码 有所出入,但是可以更方便的帮助大家理解源码,想进一步研究和阅读源码,推荐大家看看 卡颂 大佬总结的 React技术揭秘 (react.iamkasong.com/)
原文:pomb.us/build-your-…
译文:github.com/Yangfan2016…
本文涉及到的源码:codesandbox.io/s/didact-8-…