本文内容是阅读 Build Your Own React 时的一个记录(大部分是原文内容的直译,少部分自己的想法),体验建立一个自己的
micro-react
,通过 ThecreateElement
Function、Therender
Function、Concurrent Mode、Fibers、Render and Commit Phases、Reconciliation、Function Components、Hooks 这八个步骤实现react
的一些核心内容。实验地址:
The createElement Function
现在,将从 createElement
开始,react element
是一个带有 type
和 props
的对象。createElement
函数需要创建出一个这样的对象。
const createElement = (
type,
props,
...children
) => {
return {
type,
props: {
...props,
// child 有可能是 string 或者 number
children: children.map((child) =>
typeof child === "object"
? child
: createTextElement(child)
),
},
};
};
children
数组中可能也有 string
、number
这样的基础值,因此需要对不是对象的值创建出一种特殊的类型 TEXT_ELEMENT
。
const createTextElement = (text) => {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
};
React
对于一个基本值的子元素,不会创建空数组也不会包一层TEXT_ELEMENT
,但是为了简化代码,这里的实现和React
有差异,毕竟micro-react
只需要实现核心的功能。
对于 createElement
的参数,需要通过 Babel
将 JSX
转换成 JS
,会将下面的代码生成 react element
。
const element = React.createElement(
"div",
{ style: "background: salmon" },
React.createElement("h1", null, "HelloWorld"),
React.createElement(
"h2",
{ style: "text-align:right" },
"from tauysi"
)
);
The render Function
当创建出 react element
之后,就需要将 element
渲染出来,因此需要用到 ReactDOM.render
函数,在这里只需要关注如何在 DOM
上添加东西,之后再考虑更新和删除的问题。
const render = (element, container) => {
const dom = document.createElement(
element.type
);
container.appendChild(dom);
};
根据 element
中 type
的属性创建出 DOM
节点,再将新节点添加到 container
中。除此之外,还需要对每一个子节点进行递归做相同处理,最后把 react element
的属性值赋值给 DOM
节点。
const render = (element, container) => {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
Object.keys(element.props)
.filter((key) => key !== "children")
.forEach((name) => {
dom[name] = element.props[name];
});
element.props.children.forEach((child) =>
render(child, dom)
);
container.appendChild(dom);
};
这样既可渲染出来元素。
Concurrent Mode
通过上文,可以发现目前递归调用 render
渲染 element
树一旦开始,就无法暂停。当这棵树很大时,会对主线程进行阻塞,因此意味着浏览器的一些高优先级任务会一直等待渲染,如:用户输入。
因此需要将整个任务分成一些小块,每当我们完成其中一块之后就把主线程控制权交给浏览器,让浏览器看看是否有更高优先级的任务需要完成。
这里使用 requestIdleCallback 作为一个循环。可以把 requestIdleCallback
类比成 setTimeout
,只不过这次是浏览器来决定什么时候运行回调函数,而不是 setTimeout
里通过我们指定的一个时间。浏览器会在主线程有空闲的时候运行回调函数。
// 下一个需要执行的单元
let nextUnitOfWork = null;
const workLoop = (deadline) => {
// shouldYield 表示线程繁忙,应该中断渲染
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
);
// timeRemaining 返回当前空闲期的估计剩余毫秒数 检测线程是否繁忙
shouldYield = deadline.timeRemaining() < 1;
}
// 重复请求
requestIdleCallback(workLoop);
};
// 空闲时执行渲染
requestIdleCallback(workLoop);
/**
* currentUnitOfWork 当前执行的任务单元
* 还需要返回下一个执行的任务单元
*/
const performUnitOfWork = (
currentUnitOfWork
) => {};
Fibers
对于 Fiber
,算是第一次接触,它是把所有任务单元组织在一起的数据结构。每一个 element
都是一个 fiber
,每一个 fiber
都是一个任务单元。在 render
中创建了一个根 fiber
,并且将其设置为 nextUnitOfWork
作为第一个任务单元,剩下的任务会通过 performUnitOfWork
返回,每个 fiber
节点完成了下述三件事情:
- 把
element
添加到DOM
上。 - 为该
fiber
节点的子节点新建fiber
。 - 选出下一个任务单元。
fiber
会指向它第一个子节点、下一个兄弟节点和父节点,为了更加方便的找到下一个任务单元。处理完当前的 fiber
节点之后,会处理它的 child
,如果没有 child
,就处理它的 sibling
;若既没有 child
也没有 sibling
,它的 uncle
将作为下一个任务单元。类似于 DFS
进行处理 fibers
。
在 render
函数中,将 nextUnitOfWork
设置为 fiber
的根节点。此时的 children
就是我们通过 render
传进来的 element
。
const render = (element, container) => {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
};
};
做好这些内容之后,等待浏览器空闲时,workLoop
会开始遍历这棵树。遍历时通过 performUnitOfWork
去获取下一个工作单元。在 performUnitOfWork
中,需要做三步内容:
- 添加
dom
节点。 - 为当前
fiber
的children
创建出新的fiber
。 - 返回下一个需要执行的
fiber
。
const elements = currentUnitOfWork.props.children;
let prevSibling = null;
elements.forEach((element, index) => {
const nextFiber = {
type: element.type,
props: element.props,
parent: currentUnitOfWork,
dom: null,
};
if (index === 0) {
currentUnitOfWork.child = nextFiber;
} else {
prevSibling.sibling = nextFiber;
}
prevSibling = nextFiber;
});
这里的
element
是通过React.createElement
创建出来的,dom
是对应最终的dom
节点,fiber
是从element
到dom
的中间产物,主要方便用于时间切片处理。
Render and Commit Phases
在 performUnitOfWork
中,一边遍历 element
,一边生成新的 DOM
节点并且添加到父节点上,在完成整棵树的渲染之前,浏览器中途可能会中断这个过程,那么用户可能会看到渲染未完成的 ui
。
因此把修改 DOM
的内容记录在 fiber tree
上,通过追踪这棵树来收集所有 DOM
节点的修改,这棵树叫 wipRoot(work in progress Root)
一旦完成了 wipRoot
这棵树的所有任务(nextUnitOfWork
为 undefined
)就把这棵树的变更提交(commit
)到实际的 DOM
上。
// 将 fiber 提交到实际的 dom 上
const commitRoot = () => {
// add nodes to dom
commitWork(wipRoot.child);
wipRoot = null;
};
// 递归添加 dom
const commitWork = (fiber) => {
if (!fiber) return;
const domParent = fiber.parent.dom;
domParent.appendChild(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
};
Reconciliation
到此为止,已经可以将新的节点添加到 DOM
上,但是对于更新和删除节点暂时还不可以。并且还需要比较 render
中新接收的 element
生成的 fiber
树和上次提交到 DOM
的 fiber
树。
我们的 micro-react
已经可以进行相关事件的绑定。如下图所示。
对上次提交到 DOM 节点的 fiber 树进行保存到额外参数上 currentRoot
在每一个 fiber
节点上添加 alertnate
属性用于记录旧 fiber
(上次 commit
阶段使用的 fiber
)的引用。
注意区分这里定义的
currentRoot
和wipRoot
,wipRoot
是指正在渲染的fiber
树,currentRoot
是上次渲染的fiber
树,在commit
阶段需要对wipRoot
进行提交,提交后将wipRoot
保存到currentRoot
再对wipRoot
进行清空。
对原来的 performUnitOfWork
中创建新 fiber
节点抽离出来放在新的 reconcileChildren
函数中
const reconcileChildren = (
wipFiber,
elements
) => {
// 如果有alternate,就返回它的child,没有,就返回undefined
let oldFiber =
wipFiber.alternate &&
wipFiber.alternate.child;
let prevSibling = null;
let index = 0;
while (index < elements.length || oldFiber) {
const element = elements[index];
let newFiber = null;
// 如果新旧节点上类型相同可以直接复用旧 DOM 修改上面的属性。
const sameType =
oldFiber &&
element &&
element.type === oldFiber.type;
/** 这里需要补充对 sameType 判断来进行 oldFiber 与 element 的比较 */
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
wipFiber.child = newFiber;
} else if (element) {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
};
通过对 sameType
判断可以知道当前的 wipRoot
和 react element
区别,从而判断需要对 dom
节点的操作,并且在新的 fiber
节点上新增 effectTag
属性(用于在 commit
阶段,提交到真实 dom
节点上时的操作)。
如果是 update
可以复用 oldFiber.dom
,如果是 add
和 oldFiber
无关,如果是 delete
则不需要去遍历 oldFiber
,只需要将需要删除的节点放在一个数组中,在 commit
阶段直接将数组中需要删除的 dom
移除。
const 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
) {
updateDOM(
fiber.dom,
fiber.alternate.props,
fiber.props
);
} else if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
};
对于上面 updateDOM
函数,内部主要是比较新老 fiber
节点的属性。这部分内容没什么需要细说的没有的属性就删除,如果是事件就 removeEventListener
。
Function Components
对于 micro-react
当然也要支持函数组件,以最简单的 HelloWorld
为例,进行实验。
const App = (props) => {
return React.createElement(
"div",
{ className: "App" },
React.createElement(
"header",
{ className: "App-header" },
React.createElement(
"h2",
null,
"Hello " + props.name
)
)
);
};
const element = React.createElement(App, {
name: "World",
});
ReactDOM.render(element, container);
对于函数组件的 fiber
而言,没有 DOM
节点,并且子节点是由函数运行而来的不是直接从 props
属性中获取。在 performUnitOfWork
函数中需要对 fiber
类型进行判断,看看是否是函数组件。
const updateFunctionComponent = (fiber) => {
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
};
updateFunctionComponent
用于从函数组件中生成子组件,运行 fiber.type
(App
函数),返回子数组,剩下的调和(reconciliation
)工作和之前一致。
原来在 commit
中,所有 fiber
都由真实存在的 DOM
。
const domParent = fiber.parent.dom;
但是在函数组件没有 DOM
节点,因此函数组件里面返回的子组件需要挂载在函数组件(App
)的父节点,所以当我们的 fiber
没有 DOM
的时候,需要修改两个地方。首先找 DOM
节点的时候需要向上遍历 fiber
节点,直到找到有 DOM
节点的 fiber
节点。
let domParentFiber = fiber.parent;
// 向上找到存在的 DOM
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;
其次在移除节点的同时也需要找到该 fiber
下第一个有 DOM
节点的 fiber
节点。
const commitDeletion = (fiber, domParent) => {
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
};
通过递归来找到存在的 DOM
节点,并且 remove
掉。
Hooks
这小结主要为函数组件添加状态,以最简单的计数器为例子。首先在函数组件中调用 useState
,创建出状态。
const Counter = () => {
const [count, setCount] = useState(0);
const [value, setValue] = useState(7);
return React.createElement(
"div",
{ className: "App" },
React.createElement(
"header",
{ className: "App-header" },
React.createElement(
"h1",
null,
"Counter: ",
count
),
React.createElement("input", {
value: value,
oninput: (e) =>
setValue(Number(e.target.value)),
className: "mt",
}),
React.createElement(
"div",
{ className: "operator mt" },
React.createElement(
"button",
{
onclick: () =>
setCount((pre) => pre + value),
},
"ADD Input (+)"
),
React.createElement(
"button",
{
onclick: () =>
setCount(count - value),
},
"SUB Input (-)"
)
)
)
);
};
为此,需要在调用函数组件时初始化一些全局变量,我们需要在 useState
中使用到这些全局变量。当函数组件调用 useState
时,校验 fiber
对应的 alternate
字段下的旧 fiber
是否存在旧 hook
。如果存在旧 hook
,将旧 hook
值拷贝到新 hook
;如果不存在,将 state
初始化,然后在 fiber
上添加新的 hook
,返回状态。
useState
还需要一个可以更新状态的函数,定义为 setState
,它接收一个 action
参数(可能为函数,也有可能直接是状态值)。将 action
推入到队列中,之后和之前在 render
函数中一样,将 wipRoot
设置为当前 fiber
,之后调度器会帮我们进行新一轮的渲染。
const setState = (action) => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
然后在 useState
中进行调度 hook
队列中的 action
,用来更新 state
。
actions.forEach((action) => {
if (action instanceof Function) {
hook.state = action(hook.state);
} else {
hook.state = action;
}
});
最终完成了 micro-react
,可以实现如下效果。
Summary
阅读完这篇文章跟着做完实验下来,理解了 React
工作原理,为之后读懂 React
源码建立基础。但是这只实现了一些简单的功能,没有涉及到 React
复杂功能和相关优化,并且和真正的 React
有一定区别,比如:micro-react
在 render
阶段遍历了整棵 fiber
树,React
通过一些算法跳过没有变更发生的子树;micro-react
在 commit
阶段遍历了整棵 fiber
树,React
维护了一个列表用于记录变化的 fiber
并且只访问这些 fiber
;micro-react
新建一个 wipTree
,都对每个 fiber
新建了一个对象,React
会尽可能复用之前 fiber
树的对象,等等…
后续可以尝试为 micro-react
添加上这些功能:
- 用一个对象记录
style prop
- 展平
children
数组 - 实现
useEffect hook
- 使用
key
来调和(reconciliation
)