How to build your own React: Didact解析
背景
作为前端开发工程师,学习自己平时使用的前端框架的源码是非常有帮助的,但是深入一个框架源码的学习,往往会遇到如下等诸多问题:
-
源码的包和代码量太多,无从下手;
-
源码太复杂,学习成本太高;
-
找一些文章来导学,发现文章用的框架版本落后。
简介
Didact就可以作为这样的一块敲门砖,在Didact的官网,作者Rodrigo Pombo这样介绍Didact:
We are going to rewrite React from scratch. Step by step. Following the architecture from the real React code but without all the optimizations and non-essential features.
翻译:
我们将一步步地从头开始重写React。我们会遵循真正的React源码的架构,但不会实现所有的优化和非必要的功能。
所以简单来说,Didact就是实现了一个简易的React框架。
因此,我们以Didact作为引导,将来学习React源码的时候,可以更好地解决上述提到的的问题:
-
引导我们逐步理解Didact,且这个过程与理解React是一致的;
-
代码清晰,理解难度小,便于入门和上手;
-
沿用了React的设计理念与思想,React从16.8以后,代码在变但思想没变,学习Didact可以帮助我们了解React的理念与思想。
核心能力
原理与源码解析
从一个简单的例子开始
// 一个jsx element
const element = <h1 title="foo">Hello</h1>;
// 一个根结点容器
const container = document.getElementById("root");
// 将element渲染到容器里
ReactDOM.render(element, container);
这段代码用React实现了一个最简单的应用:将一个h1渲染到root容器里。
之所以我们可以在.jsx文件里写类似html标签的JSX,是因为babel会在打包时将JSX转义为React.createElement(args)或者_jsx(args),本质上JSX写法就是这两个方法的语法糖,本文中都将以createElement为例:
const element = <h1 title="foo">Hello</h1>;
// 会被babel转义为如下
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
);
所以,要实现一个简单的React,就必须实现createElement与render两个方法,我们这里可以统一到Didact里,也就是:
function createElement() {/** do sth */}
function render() {/** do sth */}
const Didact = {
createElement,
render
};
实现createElement方法
举一个上面例子的变形:
const element = (
<h1 title="foo">
<div>123</div>
Hello
</h1>
);
对于此element,需要三个字段来对它进行描述:
-
类型 —— h1
-
属性 —— title="foo"
-
孩子 —— <div>123</div>与Hello
所以,createElement的入参可以设定为type、props、children,其中children参数可以有多个,返回类型为DidactElement,可以将children合并到props中管理,事实上在React中我们也是通过
const { children } = props; 来获取孩子elements,这里我们对照Didact源码来看:
type DidactElement = {
type: string | function; // 本篇文章只实现原生组件和函数式组件
props: { children: DidactElement[]; [props: string]: any; };
};
/**
* @param type
元素类型,可能是原生的div、h1等,也可能是Function Component等
* @param props
元素属性,原生节点中对应DOM properties,FC中对应组件的传值
@param ...children
孩子elements,数目不确定
@return DidactElement
*/
function createElement(
type: string,
props: Record<string, any>,
...children: DidactElement[]
): DidactElement {
return {
type,
props: {
...props,
// 此处要判断传入的是jsx element还是文本,文本另作处理
children: children.map(child =>
(typeof child === 'object' ? child : createTextElement(child))
)
},
};
}
// 文本节点处理的方法,相比jsx element,其type和props都比较固定,且无children
function createTextElement(text: string): DidactElement {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: []
}
};
}
现在有了createElement方法,接下来可以用babel来做transform,只需要加一行注释,构建时babel就会将JSX转义为Didact.createElement():
/** @jsx Didact.createElement */
const element = (
<h1 title="foo">
<div>123</div>
Hello
</h1>
);
实现一个原始的render方法
实现createElement方法后,可以开始着手render方法。通过上面的例子可知,render的入参有两个,渲染的元素element和容器container。我们可以通过DOM操作基本实现一个原始的render方法:
function render(element: DidactElement, container: HTMLElement) {
// 根据element创建真实的节点
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
// 将DOM Properties加入到创建的节点上
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);
}
由此,一个静态的JSX --> DOM的转化成型了,在接下来的章节中,我们会实现Didact赖以工作的核心机制 —— Fiber架构,所以原始的render方法也会随着后续的讲解来重写、完善
前置知识:Concurrent模式
在讲述Fiber架构之前,我们需要了解一个前置的知识 —— Concurrent模式。
上述render函数中,会递归处理孩子elements直到生成完整DOM,JS线程释放,GUI线程才会进行渲染工作。这个递归的过程是不可中断的,如果Element Tree结构非常复杂,则会导致JS执行时间过长,GUI线程工作阻塞,界面会掉帧卡顿,用户使用体验会大打折扣。
所以,想要提升用户的体验,有一种思路就是在每一帧的时间内,预留一定时间给JS线程,让他处理JS,没有处理完成的工作留到下一帧中继续进行,而剩下的时间给GUI线程来进行渲染,保证用户看到的画面是流畅的,这也是time slice(时间切片)的思想。
浏览器有一个实验性的API —— requestIdleCallback,这个API可以使我们在浏览器当前帧有空闲时间时执行JS,通过此方法我们可以拿到当前帧的剩余可用时间,一般用法如下:
type Deadline = {
timeRemaining: () => number; // 当前剩余的可用时间,即该帧剩余时间
didTimeout: boolean; // 是否超时
}
// 该方法执行callback时会传入该参数
function work(deadline: Deadline) {
// 如果剩余可用时间>1ms或者没有超时,即说明当前帧还有剩余时间,可以执行JS
if (deadline.timeRemaining() > 1 || deadline.didTimeout) {
// do sth
}
// 如果没有剩余时间,则等待下一帧;或者完成工作后继续等待下一轮执行
requestIdleCallback(work);
}
requestIdleCallback(work);
但此用法还是有问题,第9行的do something一旦开始依旧不会停止,所以如果把上面提到的递归的过程放到这里,依旧会一直执行无法中断。
造成这种情况的本质原因是所有的递归工作都耦合在一起,因此我们第8行只判断了一次。如果可以把这个工作分割成一个一个的单元,这样每个单元超时的概率会远远小于总工作超时的概率。
因此,我们将递归的形式转变为循环的形式,对一个个单元进行处理,而在循环中我们每一次都可以进行第8行的判断,就可以解决这个问题了,如下所示:
function work(deadline: Deadline) {
// 循环地、一个一个单元地进行工作
while (deadline.timeRemaining() > 1 || deadline.didTimeout) {
// 只做一个单元的工作
}
// 退出循环后,等待下一轮的空闲时间可以继续上面的工作
requestIdleCallback(work);
}
requestIdleCallback(work);
同时,这也是Fiber架构的核心思想之一,接下来我们将开始介绍并实现Fiber架构。
Fiber架构与两个阶段
在主流的Vue、React框架中,都沿用了虚拟DOM这一思想,即用JavaScript对象来描述浏览器的DOM,用一个JS的树形结构来描述一个真实的DOM树。
在React中用Fiber这种数据结构来实现虚拟DOM这一思想 —— 先根据Element Tree生成Fiber Tree,再根据Fiber Tree增删改真实的DOM Tree。
因此,综合上述知识,我们可以引出自v16之后React采取的新的全新的架构 —— Fiber架构,这个架构由三个部分组成:
-
Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
-
Reconciler(协调器)—— 负责找出变化的组件,可以被中断,对应Render阶段
-
Renderer(渲染器)—— 负责将变化的组件渲染到页面上,不被中断,对应Commit阶段
其中Scheduler由上述提到的requestIdleCallback来实现(实际上React受限于浏览器的兼容性和功能上的微小差异选择了自己实现)。
Fiber架构的核心概念就是Fiber节点及其构成的Fiber Tree:
如图,Fiber Tree是一个树形结构,与传统树形结构相比,Fiber Tree的每一个父节点的child指针只指向长子,长子与兄弟之间再通过sibling连接,而且所有的孩子都有一个parent指针指向父节点。
在Didact中会有两棵Fiber Tree,一棵是Current Fiber Tree,即当前正在呈现的DOM对应的Fiber Tree,还有一棵是WorkInProgress Fiber Tree,是正在进行构建的树,两棵树中对应的节点通过Fiber节点上的alternate指针连接。
至此我们可以得出Fiber节点的数据结构即:
type Fiber = {
type?: string | function; // Fiber Root 不需要此类型,详见
props: { children: DidactElement[]; [props: string]: any; };
dom: HTMLElement | null;
alternate: Fiber | null;
parent?: Fiber;
child?: Fiber;
sibling?: Fiber;
effectTag?: string;
hooks?: hook[];
};
// 下面将会用到
type hook = <T>(
initialValue?: T | (() => T)
) => [T | undefined, (value: T) => T];
接下来会具体介绍Didact工作两个阶段,工作流程如下:
整个工作流程中的入口就是workloop函数,可以对照图和源码看一下workloop做了什么事情:
let nextUnitOfWork: Fiber | null = null;
let currentRoot: Fiber | null = null;
let wipRoot: Fiber | null = null;
let deletions: Fiber[] | null = null;
function workLoop(deadline: Deadline) {
let shouldYield = false;
// 每进行一个单元的工作,就会进行判断,可随时中断,此时是Scheduler和Reconciler在工作
// 中断后继续执行时,nextUnitOfWork是全局变量,因此可以取到上一次的中断前的nextUnitOfWork继续进行工作
// 这就是递归变循环的好处,真正的践行了time slice的思想
while (nextUnitOfWork && !shouldYield) {
// dfs遍历
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
);
shouldYield = deadline.timeRemaining() < 1;
}
// 当wip fiber构建完成,就可以进入commit阶段了,此时是Renderer在工作
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
Render阶段
Render阶段做的事情就是将Element --> Fiber。
其核心方法就是performUnitOfWork,接下来直接对照源码进行解析:
/**
* 此方法的作用:
* 1. 将当前fiber的props.children中的elements生成fiber节点并连接起来
* 2. 返回长子节点,即fiber.child;若没有孩子则返回兄弟节点或叔辈节点
*/
function performUnitOfWork(fiber: Fiber) {
// 这两个update函数实现功能1
const isFunctionComponent =
fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
// 实现功能2
if (fiber.child) {
return fiber.child;
}
// 如果没有孩子节点了,则返回兄弟节点或者叔辈节点,即dfs的过程
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
function updateHostComponent(fiber: Fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 上述功能1的具体实现
reconcileChildren(fiber, fiber.props.children);
}
function updateFunctionComponent(fiber: Fiber) {
// do sth
}
// 当标记为"PLACEMENT"时,即新增DOM,需要执行此函数创建一个DOM
function createDom(fiber: Fiber) {
const dom =
fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type)
// 同时将属性注入
updateDom(dom, {}, fiber.props);
return dom;
}
// 接收fiber节点及其孩子elements作为参数,将孩子elements生成孩子fiber并与fiber节点连接
function reconcileChildren(wipFiber: Fiber, elements: DidactElement[]) {
let index = 0;
// 要进行工作的节点对应的old fiber节点,初始值为长子节点
let oldFiber =
wipFiber.alternate && wipFiber.alternate.child;
// 前面完成的wip fiber的孩子节点,用来做兄弟之间的连接
let prevSibling: Fiber | null = null;
// 循环将孩子们生成fiber,并与current fiber节点进行对比打上标记
while (
index < elements.length ||
oldFiber !== null
) {
const element = elements[index];
let newFiber: Fiber | null = 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"
};
}
/**
删除的时候比较特殊,因为删除后新的树中就不应该有该Fiber节点了,所以只能在
老的树中的Fiber节点上存储标记,并用一个全局的数组deletions存储这些节点
*/
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
// 结束当前element建fiber的工作,将oldFiber更新为下一轮的oldFiber
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
// 如果是长子节点则将连接父节点与长子节点,不是则连成前一轮的节点的兄弟节点
if (index === 0) {
wipFiber.child = newFiber;
} else if (element) {
prevSibling.sibling = newFiber;
}
// 更新prevSibling节点,并进入下一轮
prevSibling = newFiber;
index++;
}
}
至此,从DidactElement --> Fiber的工作完成,我们找到了变化的组件,并打上了相应的标记,后续在commit阶段,会根据不同的标记进行相应的DOM操作。
因此,原始render方法可以进行重写了:
function render(element: DidactElement, container: HTMLElement) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
deletions = [];
/**
我们只需要将nextUnitOfWork置为根Fiber节点,nextUnitOfWork && !shouldYield
的判断条件就会满足,workloop方法就会开始构建Fiber Tree并最终生成DOM树
*/
nextUnitOfWork = wipRoot;
}
Commit阶段
Fiber Tree构建完成后,nextUnitOfWork会变为null,Render阶段结束,开始执行commitRoot方法:用WIP Fiber Tree及其对应的DOM Tree来替换掉Current Fiber Tree及其对应的DOM Tree。
具体的commitRoot方法直接对应源码进行解析:
function commitRoot() {
// 执行删操作
deletions.forEach(commitWork);
// 执行增和改操作
commitWork(wipRoot.child);
// 操作完DOM后,用WIP Fiber Tree替换掉Current Fiber Tree
currentRoot = wipRoot;
// 将WIP Fiber Tree置空
wipRoot = null;
}
// 进行具体的增删改操作
function commitWork(fiber: Fiber) {
// 递归出口
if (!fiber) {
return;
}
let domParentFiber = fiber.parent;
// 如果父Fiber没有dom,则找到第一个有dom的祖先Fiber节点,参考图
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom !== null
) {
// 这一步的dom是在Render阶段就建好了,所以只需插入
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") {
commitDeletion(fiber, domParent);
}
// dfs模式进行递归
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(fiber: Fiber, domParent: HTMLElement) {
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
// 与上面相似的逻辑,没有dom就对第一个有dom的子Fiber进行删除操作
commitDeletion(fiber.child, domParent);
}
}
const isEvent = (key: string) => key.startsWith('on');
const isProperty = (key: string) => key !== 'children' && !isEvent(key);
const isNew =
(prev: Record<string, any>, next: Record<string, any>) => (key: string) =>
prev[key] !== next[key];
const isGone =
(prev: Record<string, any>, next: Record<string, any>) => (key: string) =>
!(key in next);
function updateDom(
dom: HTMLElement,
prevProps: Record<string, any>,
nextProps: Record<string, any>
) {
// 移除旧的监听事件
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]);
});
// 移除旧的DOM properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = '';
});
// 增加新的的DOM properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name];
});
}
至此,所有的DOM的增、删、改操作都已完成,但是还待完善:
-
只支持Host Component。Didact最终的目标是支持Host Component和Function Component;
-
只有render函数执行第一次渲染 —— mount,而没有提供update的入口。接下来一小节中,实现State Hook功能,将会为我们提供setState函数作为update的入口。
Function Component 与 State Hook
无状态的Function Component
由简入繁,我们可以先实现一个无状态的Function Component。
Function Component实质上就是一个函数,它的Fiber节点的type就是该函数,我们通过执行这个函数来拿到它返回的elements,也就是它的子elements,要实现Function Component,只需要在这里做工作,我们可以直接看源码:
let wipFiber: Fiber | null = null;
let hookIndex: number | null = null;
function updateFunctionComponent(fiber: Fiber) {
wipFiber = fiber;
// 用数组来存储hooks,在React中用链表,下一小节会用到
hookIndex = 0;
wipFiber.hooks = [];
// 与Host Componenet区别之一就是children需要通过执行函数获得
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
实现useState
useState实现原理其实很简单:
-
执行setState导致重渲染;
-
重渲染的时候,会再次执行Function Component函数;
-
执行函数时,会执行useState,拿到最新的state,return的JSX中使用了最新的state;
-
state的流转过程:JSX —> Element —> Fiber —> DOM,最终视图刷新。
我们直接看Didact源码:
// 此hooks在执行updateFunctionComponent的第10行执行FC的函数时执行
function useState<T>(initial?: T) {
// 取之前旧树上的对应hook(通过hookIndex保证顺序)
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
// state初值为上一次的state或者初值(第一次调用时)
// queue用来存储所有的setState动作以便进行批处理
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
// 更新state
const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
hook.state = action(hook.state);
});
// 执行此函数后,会触发重新构建Fiber树
const setState = (action: (value: T) => T) => {
// 上一步中执行的就是action方法,此处会将其推入queue
hook.queue.push(action);
// 执行更新,做的工作和render方法相似,因此setState是update的入口
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
// 执行后将该hook推入新树的hooks数组
wipFiber.hooks.push(hook);
// 为处理下一个hook作准备
hookIndex++;
// 注意此时返回了一个函数setState
// setState用到了函数中的局部变量hook,因此形成了一个闭包
return [hook.state, setState];
}
-
初次挂载时,第15行开始没有任何action执行;
-
一切就绪后,初次挂载时的WIP Fiber Tree变成了Current Fiber Tree,而且hooks都已经已经挂载到了对应的Function Component Fiber节点上;
-
触发setState,就会触发构建一棵新的WIP Fiber Tree,此时便会:
至此,Didact成型了:
const Didact = {
createElement,
render,
useState
};
总结
至此,我们实现了一个简易版React框架 —— Didact,它可以支持我们书写JSX并最终将它渲染到页面上,还可以实现状态驱动视图。这里是完整的代码和一个demo:
Didact功能较简单,而在React中做了大量的优化工作,如顶层事件代理机制、Fiber的bailout机制,此框架只是沿用了React中的设计理念和思想,希望可以为大家学习React源码做一点铺路的工作。
参考文献
转载自:https://juejin.cn/post/7268540338452037632