前不久我们才学习了Vue3的源码,并且手写了mini-vue。既然开了学习源码的头,一不做二不休,顺便来看看React吧。
笔者也是一边学习一边记录,这篇博客是为了个人复习而写的,纰漏之处请多包涵。
JSX
在项目中使用的jsx,最终会转换成一个类型为ReactElement
的对象。
该对象的接口如下,其中$$typeof
是Symbol类型,用于安全,详情:为什么React元素有一个$$typeof属性?(TODO)
export interface ReactElementType {
$$typeof: symbol | number;
type: ElementType; // 标签类型
key: Key;
props: Props;
ref: Ref;
}
const supportSymbol = typeof Symbol === 'function' && Symbol.for;
export const REACT_ELEMENT_TYPE = supportSymbol
? Symbol.for('react.element')
: 0xeac7;
jsx转为对象由编译时和运行时两步完成。编译是由babel完成的,在playground中可见,jsx会转化成对jsx
方法的调用。
jsx方法会区分生产环境和开发环境,而且有时候调用的是React.createElement方法,此处不探讨这些细节。
于是实现jsx方法,逻辑比较简单,从上面的图也可以看出,它接受type和config,然后从config中获取key、ref、children等信息。最后调用ReactElement
方法,生成的children在props中。
export const jsxDEV = (type: ElementType, config: any) => {
let key: Key = null;
const props: Props = {};
let ref: Ref = null;
for (const prop in config) {
const val = config[prop];
if (prop === 'key') {
if (val !== undefined) {
key = '' + val;
}
continue;
}
if (prop === 'ref') {
if (val !== undefined) {
ref = val;
}
continue;
}
if ({}.hasOwnProperty.call(config, prop)) {
props[prop] = val;
}
}
return ReactElement(type, key, ref, props);
};
function ReactElement(
type: Type,
key: Key,
ref: Ref,
props: Props
): ReactElementType {
const element = {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
ref,
props
};
return element;
}
Reconciler
Reconciler是“协调器”的意思,其作用和Vue中的Renderer类似,都属于运行时核心模块。它接受编译好的jsx,调用浏览器的API,最终把虚拟DOM转换为真实DOM。
ReactElement
如果作为核心模块操作的数据结构,存在的问题:
- 无法表达结点之间的关系。
- 字段有限,不好拓展(比如:无法表达状态)。
因此,在ReactElement和DOM之间,需要添加一种新的数据结构,要能够表达结点之间的关系,方便拓展,它就是FiberNode
。FiberNode是虚拟DOM在React中的实现。
fiber的属性基本分为三个部分:
- 基本的属性:因为fiber(一般)是由element创建而来的,所以要保存type、tag、key、ref等属性。props保存在pendingProps中,作为初始的props,与之对应的是更新结束后的memoizedProps。
- 作为工作单元的属性:
- updateQueue属性是更新的核心机制,会从队列中取出Update对象,用baseState进行计算,得到memoizedState,然后此时的memoizedState会作为下一次更新时的baseState。Update中保存了action属性,它可以是值或函数。 最常见的更新操作就是setState或者是useState的dispatch方法,它们的传参就可以是值或函数,和这里对应起来。
- flags用于标记副作用,比如某个fiber结点需要进行Placement(插入、移动)或是Delete children...就会在commit阶段完成这些副作用。
- 构建fiber tree的属性:return指向父亲fiber,child指向孩子fiber,sibling指向兄弟fiber。
export class FiberNode {
type: any;
// tag: 结点类型
tag: WorkTag;
// pendingProps: 接下来有哪些props需要改变 / 刚开始工作时的props
pendingProps: Props;
key: Key;
ref: Ref;
stateNode: any;
return: FiberNode | null;
sibling: FiberNode | null;
child: FiberNode | null;
index: number;
// 工作完成后 / 确定下来的props
memoizedProps: Props | null;
memoizedState: any;
// 双缓冲技术,current和wip的切换
alternate: FiberNode | null;
// 保存标记,副作用
flags: Flags;
updateQueue: unknown;
constructor(tag: WorkTag, pendingProps: Props, key: Key) {
this.tag = tag;
this.ref = null;
this.key = key;
// HostComponent.stateNode => div DOM
this.stateNode = null;
// FunctionComponent.type => Function
this.type = null;
this.return = null;
this.sibling = null;
this.child = null;
// 同级的fiberNode有好几个,比如一个ul下有多个li,它们的index分别是0、1、2
this.index = 0;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.memoizedState = null;
this.updateQueue = null;
this.alternate = null;
this.flags = NoFlags;
}
}
更新机制
刚才已经说过,应该在fiber上保存updateQueue,其中保存了Update对象用于更新。
除此之外,需要考虑的事情:
- 更新可能发生于任意组件,而更新流程是从根节点递归的。
- 需要一个统一的根节点保存通用信息。
引入FiberRootNode,保存了容器、fiber和finishedWork。
export class FiberRootNode {
container: Container;
current: FiberNode;
// 指向整个更新完成后的HostFiber
finishedWork: FiberNode | null;
constructor(container: Container, hostRootFiber: FiberNode) {
this.container = container;
this.current = hostRootFiber;
hostRootFiber.stateNode = this;
this.finishedWork = null;
}
}
之后每次发生更新时,都通过fiber的return指针,向上遍历到hostRootFiber,通过stateNode属性获取fiberRootNode。
mount流程
render阶段
使用双缓存技术,共有两棵fiber tree,每个fiber的alternate指向另一棵树中和它对应的结点。
全局变量workInProgress,顾名思义,指向当前遍历到的fiber结点,初始时指向hostRootFiber.alternate。当然此时wip为null,需要创建fiberNode。
然后,存在一个workLoop函数,来完成“递”和“归”的流程。
beginWork
接受wip作为参数,然后判断wip.tag。
假设是hostComponent类型,则从wip的pendingProps中取出children element,然后根据element生成fiber,设置父亲.child = fiber,将fiber返回。
在reconcileChildren的过程中,还会设置fiber的flags。
总结:生成孩子结点的fiber,设置父fiber的child指针,设置fiber的flags,不断向下。
completeWork
开始completeWork时,wip指向fiber tree中的叶子结点,因为是“归”的步骤,completeWork是按从下往上的顺序执行的。
complete完成的工作,一是构建dom(并且把dom赋值给wip.stateNode),二是将DOM插入到dom树中,三是处理flags。
- 构建dom很简单,document.createElement就可以。
- 插入dom结点时,要找到wip下最近的HostComponent或是HostText结点(要做到这一点,又要向下递归),最后把符合条件结点的stateNode插入父dom。
- flags分布在不同的fiberWork中,为了快速找到它们,可以利用归的流程,把子fiber的flags冒泡到父。父结点保存subtreeFlags,即子树中的flags。
另外,在首屏渲染时,只有根fiber上会有“Placement”的flags,前面的所有dom插入操作都是离屏的,最后把整棵dom树插入浏览器。
总结:从最底下出发,设置子fiber的return指针,sibling的return指针,依次生成dom和完成dom插入操作,并且设置flags,不断向上。
commit阶段
commit阶段有三个子阶段:beforeMutation、mutation、layout。其中Placement等操作是在mutation阶段完成的。
在render阶段结束后,会更新fiberRootNode上的finishedWork。而commit阶段,就是拿到finishedWork,然后根据flags,完成相应的操作,过程是自上而下的。
用MutationMask来判断是否需要进行mutation阶段,假如需要进行Placement:
- 获取hostParent:往上找,直到找到HostComponent或HostRoot类型的结点,得到其stateNode。
- 获取要插入的结点的dom:往下找,直到找到HostComponent或HostText类型的结点,得到其stateNode。
这样的操作在前面completeWork的时候也出现了一次,可以想一下函数组件或是类组件,它们是不对应任何dom的,它们的孩子才是HostComponent或HostText。
实现了首屏渲染,取得阶段性胜利。