React源码解析系列:实现一个包含时间切片、fiber、Hooks的简易React
遵循真实的React代码架构,实现一个包含时间切片、fiber、Hooks的简易React,舍弃部分优化代码和非必要的功能,命名为HuaMu
目录
CreateElement函数Render函数- 并发模式
FibersRender和Commit阶段ReconciliationFunction ComponentsHooks
一、JSX转换为JS
React通过 Babel 将JSX转换为JS,以tagName、props和children作为参数,调用CreateElement函数,将标签转化为对象输出
// 转换前
const el = <h1 title="el_title">HuaMu<h1>;
// 转换后
const el = {
type:"h1",
props:{
title:"el_title",
children:"HuaMu",
}
}
使用原生js实现render功能
// 创建容器
const container = document.getElementById("root")
// 创建 h1 节点
const node = document.createElement(el.type);
node["title"]= el.props.title;
// 创建 孩子 节点
const text = document.createTextNode("");
text["nodeValue"] = el.props.children;
// 追加`textNode`到`h1` 后,再将`h1`追加到`container`
node.appendChild(text);
container.appendChild(node);
** 注意:文本节点使用textNode而不是innerText,保证以相同的方式对待所有的元素 **
二、CreateElement函数
正如上面演示的,元素是具有type和props属性的对象,因此,CreateElement函数唯一需要做的就是创建该对象
/**
* @param {string} type HTML元素
* @param {object} props 具有JSX属性中的所有键和值
* @param {string | array} children 元素树
*/
function CreateElement(type, props, ...children) {
return {
type,
props:{
...props,
children,
}
}
}
将剩余参数赋予children,扩展运算符用于构造字面量对象props将对象表达式按照key-value的方式展开,保证props.children始终是一个数组。
// 例子
CreateElement("div")
CreateElement("div", null, a)
CreateElement("div", null, a, b)
// 例子3 返回
{
"type": "div",
"props": { "children": [a, b] }
}
** 注意:当...children为空或为原始值时,React不会创建props.children,但为了简化代码,暂不考虑性能,为原始值创建特殊的类型TEXT_EL**
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_EL",
props: {
nodeValue: text,
children: []
}
}
}
二、Render函数
1、添加内容
- 创建对应的DOM节点,然后将新节点附加到容器中,并递归每个孩子节点做同样的操作
- 将元素的props属性分配给节点
function Render(el,container) {
// 创建节点
const dom = el.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(el.type);
el.props.children.forEach(child => Render(child, dom))
// 为节点分配props属性
const isProperty = key => key !== 'children';
const setProperty = name => dom[name] = el.props[name];
Object.keys(el.props).filter(isProperty).forEach(setProperty)
container.appendChild(dom);
}
执行一下demo,看看效果呐!
2、更新内容
3、删除内容
三、并发模式
1、递归存在的问题
一旦开始渲染,便不会停止,直到渲染了整棵元素树,如果元素树很大,可能会阻塞主线程,且妨碍浏览器执行高优作业(必须等到它渲染完成)
2、解决方案
将任务分解为多个工作单元,每完成一个工作单元,判断是否有高优作业,若有,则让浏览器中断渲染
-
window.requestIdleCallback(cb[, options]):浏览器将在主线程空闲时运行回调。函数会接收到一个IdleDeadline的参数,这个参数可以获取当前空闲时间(timeRemaining)以及回调是否在超时前已经执行的状态(didTimeout)。 -
React已不再使用requestIdleCallback,目前使用scheduler package。但在概念上是相同的
let nextUnitOfWork = null;
function PerformUnitOfWork(nextUnitOfWork) {
// TODO:执行工作并返回下一个执行的工作单元
}
function WorkLoop(deadline) {
// 当前线程的闲置时间是否可以在结束前执行更多的任务
let shouldYield = false;
while(nextUnitOfWork && !shouldYield) {
nextUnitOfWork = PerformUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1; // 如果idle period已经结束,则它的值是0
}
requestIdleCallback(WorkLoop)
}
// 当浏览器准备就绪时,它将调用WorkLoop
requestIdleCallback(WorkLoop)
四、Fibers
为了组织工作单元,即方便查找下一个工作单元,需引入fiber tree的数据结构。每个元素都有一个fiber,链接到其第一个子节点,下一个兄弟姐妹节点和父节点,且每个fiber都将成为一个工作单元。
// 假设我们要渲染的元素树如下
const el = (
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>
)
其对应的fiber tree如下:
在Render中,设置为nextUnitOfWork为root fiber,剩余的fiber将在performUnitOfWork函数上执行以下三件事:
- 为元素创建节点并添加到
dom - 为元素的子代创建
fiber - 选择下一个执行工作单元
<span id="code1"> 👇
// 创建节点部分独立出来
function CreateDom(fiber) {
const dom = fiber.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(fiber.type);
// 为节点分配props属性
const isProperty = key => key !== 'children';
const setProperty = name => dom[name] = fiber.props[name];
Object.keys(fiber.props).filter(isProperty).forEach(setProperty)
return dom
}
function Render(el,container) {
// 设置 nextUnitOfWork 为 root fiber
nextUnitOfWork = {
dom: container,
props:{
children:[el],
}
}
}
function PerformUnitOfWork(fiber) {
// 为元素创建节点并添加到dom
if(!fiber.dom) {
fiber.dom = CreateDom(fiber)
}
if(fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
// 为元素的子代创建fiber
const els = fiber.props.children;
let index = 0;
let prevSibling = null;
while(index < els.length) {
const el = els[index];
const newFiber = {
type: el.type,
props: el.props,
parent: fiber,
dom: null
}
// 子代在fiber树中的位置是child还是sibling,取决于它是否第一个
if(index === 0){
fiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
// 选择下一个执行工作单元,优先级是 child -> sibling -> parent
if(fiber.child){
return fiber.child;
}
let nextFiber = fiber;
while(nextFiber) {
if(nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
五、Render和Commit阶段
<span id="code2">
1、存在问题
- 在
performUnitOfWork函数里,每次为元素创建节点之后,都向dom添加一个新节点 - 在渲染完整棵树之前,浏览器可能会中断,导致用户看不到完整的UI
2、解决方案
- 将创建一个节点就向
dom进行的添加处理更改为跟踪fiber root,也被称为progress root或者wipRoot - 一旦完成所有的工作,即没有下一个工作单元时,才将
fiber提交给dom
// 跟踪根节点
let wipRoot = null;
function Render(el,container) {
wipRoot = {
dom: container,
props:{
children:[el],
}
}
nextUnitOfWork = wipRoot;
}
// 一旦完成所有的工作,将整个fiber提交给dom
function WorkLoop(deadline) {
...
if(!nextUnitOfWork && wipRoot) {
CommitRoot()
}
requestIdleCallback(WorkLoop)
}
// 将完整的fiber提交给dom
function CommitRoot() {
CommitWork(wipRoot.child)
wipRoot = null
}
// 递归将每个节点添加进去
function CommitWork(fiber) {
if(!fiber) return;
const parentDom = fiber.parent.dom;
parentDom.appendChild(fiber.dom);
CommitWork(fiber.child);
CommitWork(fiber.sibling);
}
六、Reconciliation
- 实现更新、删除功能,需要将
render函数中收到的元素与提交给dom的最后的fiber tree进行比较 - 因此,需要保存最后一次提交给
fiber tree的引用currentRoot - 同时,为每个
fiber添加alternate属性,记录上一阶段提交的old fiber
let currentRoot = null;
function Render(el,container) {
wipRoot = {
...
alternate: currentRoot
}
...
}
function CommitRoot() {
...
currentRoot = wipRoot;
wipRoot = null
}
- 为元素的子代创建
fiber的同时,将old fiber与new fiber进行reconcile - 通过以下三个维度进行比较 1、如果
old fiber与new fiber具有相同的type,保留dom节点并更新其props,并设置标签effectTag为UPDATE2、type不同,且为new fiber,意味着要创建新的dom节点,设置标签effectTag为PLACEMENT;若为old fiber,则需要删除节点,设置标签effectTag为DELETION
** 注意:为了更好的Reconciliation,React还使用了key,比如更快速的检测到子元素何时更改了在元素数组中的位置 **
let deletions = null;
function PerformUnitOfWork(fiber) {
...
const els = fiber.props.children;
// 提取 为元素的子代创建fiber 的代码
ReconcileChildren(fiber, els);
}
function ReconcileChildren(wipFiber, els) {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
// 为元素的子代创建fiber 的同时 遍历旧的fiber的子级
// undefined != null; // false
// undefined !== null; // true
while(index < els.length || oldFiber != null) {
const el = els[index];
const sameType = oldFiber && el && el.type === oldFiber.type;
let newFiber = null;
// 更新节点
if(sameType) {
newFiber = {
type: el.type,
props: el.props,
parent: wipFiber,
dom: oldFiber.dom, // 使用 oldFiber
alternate: oldFiber,
effectTag: "UPDATE",
}
}
// 新增节点
if(!sameType && el){
newFiber = {
type: el.type,
props: el.props,
parent: wipFiber,
dom: null, // dom 设置为null
alternate: null,
effectTag: "PLACEMENT",
}
}
// 删除节点
if(!sameType && oldFiber) {
// 删除节点没有新的fiber,因此将标签设置在旧的fiber上,并加入删除队列 [commit阶段提交时,执行deletions队列,render阶段执行完清空deletions队列]
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber)
}
if(oldFiber) {
oldFiber = oldFiber.sibling;
}
if(index === 0) {
wipFiber.child = newFiber;
} else if(el) {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
- 在
CommitWork函数里,根据effectTags进行节点处理 1、PLACEMENT - 跟之前一样,将dom节点添加进父节点 2、DELETION - 删除节点 3、UPDATE - 更新dom节点的props
function CommitWork(fiber) {
if (!fiber) return;
const parentDom = fiber.parent.dom;
if (fiber.effectTags === 'PLACEMENT' && fiber.dom !== null){
parentDom.appendChild(fiber.dom);
} else if (fiber.effectTags === 'DELETION') {
parentDom.removeChild(fiber.dom)
} else if(fiber.effectTags === 'UPDATE' && fiber.dom !== null) {
UpdateDom(
fiber.dom,
fiber.alternate.props,
fiber.props
)
}
CommitWork(fiber.child);
CommitWork(fiber.sibling);
}
重点分析一下UpdateDom函数:
- 普通属性 1、删除旧的属性 2、设置新的或更改的属性
- 特殊处理以
on为前缀的事件属性 1、删除旧的或更改的事件属性 2、添加新的事件属性
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);
/**
* 更新dom节点的props
* @param {object} dom
* @param {object} prevProps 之前的属性
* @param {object} nextProps 当前的属性
*/
function UpdateDom(dom, prevProps, nextProps) {
// 删除旧的属性
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(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(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name.toLowerCase().substring(2)
dom.addEventListener(
eventType,
nextProps[name]
)
})
}
执行一下demo,看看效果呐!
七、Function Components
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
等价于
function App(props) {
return Huamu.CreateElement("h1",null,"Hi ",props.name)
}
const element = Huamu.CreateElement(App, {name:"foo"})
1、Function Components相对于其他type的不同点:
Function Components的fiber没有dom节点children来自于函数的运行而不是props
2、实现
- 检测
fiber类型,并依据此划分UpdateFunctionComponent和UpdateHostComponent进行处理
function PerformUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
if(isFunctionComponent) {
UpdateFunctionComponent(fiber)
} else {
UpdateHostComponent(fiber)
}
// 选择下一个执行工作单元,优先级是 child -> sibling -> parent
...
}
function UpdateFunctionComponent(fiber) {
// TODO
}
function UpdateHostComponent(fiber) {
if (!fiber.dom) = fiber.dom = CreateDom(fiber);
const els = fiber.props.children;
ReconcileChildren(fiber, els);
}
children来自于函数的运行而不是props,即运行函数获取children
function UpdateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)];
ReconcileChildren(fiber,children);
}
- 没有
dom节点的fiber1、在添加节点时,得沿着fiber树向上移动,直到找到带有dom节点的父级fiber2、在删除节点时,得继续向下移动,直到找到带有dom节点的子级fiber
function CommitWork(fiber) {
if (!fiber) return;
// 优化:const domParent = fiber.parent.dom;
let domParentFiber = fiber.parent;
while(!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;
if (fiber.effectTags === 'PLACEMENT' && fiber.dom!=null){
domParent.appendChild(fiber.dom);
} else if (fiber.effectTags === 'DELETION') {
// 优化: domParent.removeChild(fiber.dom)
CommitDeletion(fiber, domParent)
} else if(fiber.effectTags === 'UPDATE' && fiber.dom!=null) {
UpdateDom(
fiber.dom,
fiber.alternate.props,
fiber.props
)
}
CommitWork(fiber.child);
CommitWork(fiber.sibling);
}
function CommitDeletion(fiber,domParent){
if(fiber.dom){
domParent.removeChild(fiber.dom)
} else {
CommitDeletion(fiber.child, domParent)
}
}
八、Hooks
- 为
Function Components添加状态 1、向fiber添加一个hooks数组,以支持useState在同一组件中多次调用,且跟踪当前的hooks索引
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 Components组件调用UseState时,通过alternate属性检测fiber是否有old hook -
若有
old hook,将状态从old hook复制到new hook,否则,初始化状态 -
将
new hook添加fiber,hook index递增,返回状态
function UseState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state]
}
UseState还需返回一个可更新状态的函数,因此,需要定义一个接收action的setState函数- 将
action添加到队列中,再将队列添加到fiber - 在下一次渲染时,获取
old hook的action队列,并代入new state逐一执行,以保证返回的状态是已更新的 - 在
setState函数中,执行跟Render函数类似的操作,将currentRoot设置为下一个工作单元,以便开始新的渲染
function UseState(initial) {
...
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]
}
结:Babel使用自定义库
const HuaMu = {
CreateElement,
Render,
UseState
}
/** @jsx HuaMu.CreateElement */
function Counter() {
const [state, setState] = HuaMu.UseState(1)
return (
<h1 onClick={() => setState(c => c + 1)}>
Count: {state}
</h1>
)
}
const element = <Counter />
const container = document.getElementById("root")
HuaMu.Render(element, container)
执行一下demo,看看效果呐!
不足
- 在渲染阶段,遍历整棵树。
React源码会跳过没有任何更改的子树 - 在提交阶段,遍历整棵树。
React源码仅保留有更新的fiber链表
