React原理

本文主要讲手写React中重要的几个部分,有助于建立对React源码的认知。

1. CreateElement

相信大家一定对jsx不陌生

<div title="box">
	<p>jsx</p>
	<span>hhh</span>
</div>

React中的jsx其实就是一个语法糖,上述jsx经过babel翻译后是

React.createElement('div', {title: 'box'}, 
	React.createElement('p', {}, 'jsx'),
	React.createElement('span', {}, 'hhh')
)

React.createElement: (type, props, ...children) => vDom

也就是说我们在写jsx实际上就是在写一个又一个嵌套的React.createElement。只是这样写太难维护了,所以使用了jsx。

React.createElement是干什么的?产生vDom的。
vDom(Virtual DOM),虚拟Dom节点,也就是自定义的一种数据结构,用来对应页面上真实的Dom节点。我们通过操纵vDom来操作真实的节点。
为什么使用vDom?

  1. vDom比真实Dom轻量太多,真实Dom挂载的属性太多,很多根本用不上
  2. 可进一步支持跨平台,如RN

vDom结构如下

vDom: {
	type,
	props: {
		...props,
		children
	}
}

我们自己写的createElement如下

// 将页面节点分为两类,text和非text
function createElement(type, props, ...children) {
	return {
		typp,
		props: {
			...props,
			children: children.map(child => typeof child === 'object' ? child: createTextNode(child))
		}
	}
}

// 单独定义text vDom
function createTextNode(text) {
	return {
		type: 'TEXT',
		props: {
			nodeValue: text,
			children: []
		}
	}
}

一切都很清楚了。我们写了一堆jsx以为描述了页面上真实dom的排布,实际上,babel将jsx翻译为了一堆的React.createElement,也就是说,最后我们写的jsx变成了一个vDom树
就拿最开始的例子

<div title="box">
	<p>jsx</p>
	<span>hhh</span>
</div>

====={
	type: 'div',
	props: {
		title: 'box',
		children: [
			{
				type: 'p',
				props: {
					children: [{type: 'TEXT', props: {nodeValue: 'jsx', children: []}}]
				}
			},
			{
				type: 'span',
				props: {
					children: [{type: 'TEXT', props: {nodeValue: 'jsx', children: []}}]
				}
			}
		]
	}
}

最后我们得到了上面这个数据结构,它就是我们所描述的页面,下面,就是将这个数据结构渲染成真实dom

2. fiber

根据上面的vDom树,直接渲染出真实页面很简单(递归createElement,appendChild),但是存在一个问题,每次render都会重绘整个页面,而这个过程是同步的,很耗时,会阻塞高优先级的任务,比如用户输入,动画之类。
React的解决办法是:

将长时间的同步任务拆分成多个小任务,从而让浏览器能够抽身去响应其他事件,等他空了再回来继续计算

这个是思路,实现可以使用requestIdleCallbackfiber

requestIdleCallback是一个浏览器实验性API,实现让浏览器空闲的时候来计算(React团队自己实现了这个API)
fiber是一种数据结构,可进行中断和回溯
具体来说,实现如下

nextUnitOfWork和workInProgressRoot是两个全局变量
nextUnitOfWork表示下一个访问的fiber节点
workInProgressRoot也叫wipRoot表示本次渲染的fiber树根节点
performUnitOfWork: (fiber) => fiber,传入要访问(工作)的fiber节点,返回下一个待处理的fiber节点
commitRoot: 提交所有vDom修改,一次性渲染到页面上

function workLoop(deadline) {
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
  if (!nextUnitOfWork && workInProgressRoot) {
    commitRoot();
  }

  requestIdleCallback(workLoop);
}

requestIdleCallback(workLoop);

这段代码的意思是:
如果浏览器空闲,且存在待处理fiber,
就会处理该fiber并返回下一个待处理fiber
如果不存在待处理fiber了,而且本次要执行渲染,
就会将修改提交到页面上。
这个工作由浏览器调度,一直持续着。

那fiber到底长什么样呢?首先,说了fiber就是一种数据结构,不要害怕它
fiber我认为就是对vDom的一个扩展。按面向对象来说,可以认为fiber extends vDom

fiber: {
	// 和vDom相同的属性
	type,
	props,
	//----
	dom, // 对应的真实dom节点
	child, // 子指针,指向第一个儿子
	sibling, // 兄弟指针,指向后一个相邻兄弟
	return, // 父指针,每个儿子都有
	alternate, // 老的fiber节点,用于diff
	effectTag, // 标记,用于向页面提交更改,REPLACEMENT | UPDATE | DELETION
	hooks // 该fiber上挂载的hook 
}

后面三个属性可以先不看,相信你已经知道fiber长什么样了,就是一棵多了几个指向的树
下面分别来看一下提到的几个函数,performUnitOfWork,commitRoot

3. performUnitOfWork

按照先儿子后兄弟的顺序,深度遍历fiber树,每次遍历一个

function performUnitOfWork(fiber) {
  // reconcile(第一次是构建,后面是更新)下一层fiber树
  const isFunctionComponent = fiber.type instanceof Function;
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }

  // 找到fiber树的下一个节点,也即下一个工作单元,按照深度优先遍历child,后sibling的顺序
  if (fiber.child) {
    return fiber.child;
  }

  let nextFiber = fiber;
  while (nextFiber) {
    // 如果有sibling,那么下一个工作单元就是该sibling,直接返回
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }

    // 没有sibling,回到父节点,再去找父节点的sibling
    nextFiber = nextFiber.return;
  }
  // end, default return undefined, fiber tree stop working
}

代码中,虽然有两个函数没有提,但也能看懂,performUnitOfWork函数就是对当前fiber做了一定的处理,然后找到下一个fiber并返回
我们来看一下,对fiber做了什么处理

function updateFunctionComponent(fiber) {
  // 支持useState,初始化变量
  wipFiber = fiber;
  hookIndex = 0;
  wipFiber.hooks = [];        // hooks用来存储具体的state序列

  // 函数组件的type是函数,执行可获得vDom
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}

function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  const elements = fiber.props && fiber.props.children;

  reconcileChildren(fiber, elements);
}

这里需要解释一下,对于函数式组件,babel解析jsx时,也会生成一个vDom(对这个函数,函数式组件是一个函数),这个vDom的type呢就是这个函数,我们知道函数式组件执行的返回值就是jsx写的页面,所以fiber.type(fiber.props)就得到了真正的内容。
reconcileChildren是干嘛的?如果是第一次渲染,就会构建fiber树(只会构建一层,fiber和children之间的关系),后续渲染,就会比对fiber树,实现diff算法。

这里就拿第一次渲染来解释一下。
客户端传入了一个函数式组件,得到了一个vDom树。首先我们为container生成一个vdom/fiber,它的dom设置为container,props.children设置为[vDom树的根节点],将其设置为下一个工作单元(nextUnitOfWork)和workInProgressRoot(正在处理的树的根),浏览器空闲的时候就会自动调用performUnitOfWork。

第一次调用时,传入的fiber是container对应的fiber,进入updateHostComponent,该fiber有dom(即为container),就不挂载了,直接进行reconcileChildren,构建下一层fiber树,重新进入performUnitOfWork,得到下一个处理fiber,即为函数组件对应的fiber

第二次调用时,传入的fiber的type是一个函数,于是进入updateFunctionComponent,执行type函数,得到包裹的vDom,传入reconcileChildren函数中,构建了一层fiber树(包括建立了child,sibling,return指针的关系,以及effectTag的标记,都是REPLACEMENT,这个后续再说)。

然后回到performUnitOfWork中,执行后续代码,根据建立好的一层fiber树找到下一个处理fiber,并返回,此时nextUnitOfWork变为了该fiber。

该fiber就是jsx的根节点,下一次浏览器空闲调用performUnitOfWork时,就先进入updateHostComponent。

updateHostComponent中,先为这个有效vDom挂载真实dom节点(根据type,使用document.createElement,添加除children以外的props,注意对事件特殊处理),再继续构建下一层fiber树。

知到performUnitOfWork返回的下一个处理节点为undefined,处理结束,在workLoop中会进入commitRoot函数,也就是将vDom/fiber到页面上。

3. commitRoot

遍历fiber树,提交修改。修改存在于fiber的effectTag属性上,之前有提到过。
effectTag属性有三个值:REPLACEMENT | UPDATE | DELETION
REPALCEMENT表示添加节点,UPDATE表示更新节点(意思是原dom节点不变,修改上面的props),DLETETION表示删除节点。
第一次渲染时,所有fiber节点的effectTag都为REPALCEMENT

// 统一提交vdom/fiber上的修改,渲染为真实dom到页面上
function commitRoot() {
	// deletions是一个全局数组,每次渲染,将要删除的fiber push进去
	deletions.forEach(commitRootImpl);
	commitRootImpl(workInProgressRoot.child);
	// currentRoot也是一个全局变量,上一次渲染的fiber树的树根
	currentRoot = workInProgressRoot;
	// 将wipRoot置为null,表示本次渲染结束
	workInProgressRoot = null;
}

// 递归遍历fiber树,将修改作用于真实dom
function commitRootImpl(fiber) {
	if (!fiber) {
	  return;
	}
	
	// 找到该fiber的有dom的父节点(即跳过函数fiber那一层)
	let parentFiber = fiber.return;
	while (!parentFiber.dom) {
	  parentFiber = parentFiber.return;
	}
	const parentDom = parentFiber.dom;
	
	if (fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
	  parentDom.appendChild(fiber.dom);
	} else if (fiber.effectTag === 'DELETION') {
	  commitDeletion(fiber);
	} else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
	  updateDom(fiber.dom, fiber.alternate.props, fiber.props);
	}
	
	commitRootImpl(fiber.child);
	commitRootImpl(fiber.sibling);
}

function commitDeletion(fiber, domParent) {
	if (fiber.dom) {
	  // dom存在,是普通节点
	  domParent.removeChild(fiber.dom);
	} else {
	  // dom不存在,是函数组件,向下递归查找真实DOM
	  commitDeletion(fiber.child, domParent);
	}
}

至此,第一次渲染的流程已经很清晰了,我们来仔细看一下reconcileChildren函数的实现

4. reConcileChildren

也就是所谓的diff算法
每次构建/比对一层的fiber树

function reconcileChildren(wipFiber, elements) {
  let prevSibling = null
  let index = 0
  // 找到上一次渲染时与elements对应的fiber
  // 相当于拿到第一个elements的alternate
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child
  // elements没有遍历完,或oldFiber存在(原因见下),就继续循环
  // 因为如果发生了删除,旧fiber树的节点就没有遍历完,没有打上DELETION标签,也就不会从页面上删除掉
  while (index < elements.length || oldFiber) {
    const element = elements[index]
    let newFiber = null

    // 判断oldFiber和element的类型是否相同
    const sameType = oldFiber && element && oldFiber.type === element.type

    // 类型相同,执行update相关操作
    // 也就是更新fiber的props,其它属性沿用oldFiber的
    if (sameType) {
      // update
      newFiber = {
        type: oldFiber.type,
        // 更新props
        props: element.props,
        return: wipFiber,
        dom: oldFiber.dom,
        alternate: oldFiber,
        effectTag: 'UPDATE'
      }
    }

    // 类型不同,但是element存在,执行placement相关操作
    // 生成newFiber
    if (element && !sameType) {
      // add
      newFiber = {
        type: element.type,
        props: element.props,
        return: wipFiber,
        effectTag: 'REPLACEMENT'
      }
    }

    // 类型不同,但是oldFiber存在,执行deletion相关操作
    // 给oldFiber打上DELETION标签,放入待删除的数组
    if (oldFiber && !sameType) {
      // delete
      oldFiber.effectTag = 'DELETION'
      deletions.push(oldFiber)
    }

    // 如果index===0,那么newFiber就是wipFiber的child
    if (index === 0) {
      wipFiber.child = newFiber
    } else {
      // 不是0,当前fiber就是上一次fiber的sibling
      prevSibling.sibling = newFiber
    }

    // 如果oldFiber存在,就让oldFiber指向它的sibling
    // 也就是element和oldFiber一起迭代,实现对应
    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }

    // 保存上一次生成的fiber
    prevSibling = newFiber

    // 迭代
    index++
  }
}

下面我们考虑一下更新,先完成一个useState Hook吧。

5. hook

还记得fiber上定义的hooks属性吗?

// 申明两个全局变量,用来处理useState
// wipFiber是当前的函数组件fiber节点
// hookIndex是当前函数组件内部useState状态计数
let wipFiber = null;
let hookIndex = null;
function useState(initial) {
  // 获得该函数组件中的该hook对应的旧hook
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
  // 初始化当前hook
  const hook = {
    // 旧hook存在的话就延续旧hook的值,否则就是第一次渲染,接收传入的initial初始化值
    state: oldHook?.state || initial,
    // actions,动作队列
    // 为什么要用队列?
    // 因为一次性可能触发多次setState,比如handleClick里调用5次setState,这时queue里就有5个action
    // 并不是说调用一次setState就马上更新页面,这种情况是在handleClick结束后,再去重新渲染
    // 个人理解是:handleClick还没有执行完,浏览器没有空闲时间去执行页面的渲染
    queue: []
  }

  const actions = oldHook?.queue || []
  // 调用action
  actions.forEach(action => {
    // action是函数
    if (typeof action === 'function') {
      hook.state = action(hook.state)
    } else {
      // action是值
      hook.state = action
    }
  })

  const setState = action => {
    // 动作队列中压入action
    hook.queue.push(action)
    // 重新渲染页面,看似是重新遍历整个fiber树,但经过diff算法,只有被修改的部分会作用于真实dom上
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    }
    deletions = []
    nextUnitOfWork = wipRoot
  }

  // 把生成的hook压入hooks中
  wipFiber.hooks.push(hook)
  // 待进入该组件的下一个hook,更新hookIndex
  hookIndex++

  return [hook.state, setState]
}

6. demo

让我们来捋一下整个react执行的过程
这里写一个小demo

export default function App(props) {
	const [count, setCount] = useState(0);
	return (
		<div title={props.title}>
			<div>{count}</div>
			<button onClick={() => setCount(prev => prev + 1)}>+1</button>	
		</div>
	)
}

React.render(<App title="demo"/>, document.getElementById('root'));
  1. <App title="demo"/>被babel翻译为
React.createElement(App, {title: 'demo'})
App()得到div为根的vDom树
App这个vDom和内部的vDom树并没有连接起来,此时vDom结构是这样的:
vDom1:
{
	type: App,
	props: {
		title: 'demo'
	}
}
vDom2:
{
	type: 'div',
	props: {
		title: props.title,
		children: [
			{
				type: 'div',
				props: {
					children: [{type: 'TEXT', props: {nodeValue: count, children: []}}]
				}
			},
			{
				type: 'button',
				props: {
					onClick: () => setCount(prev => prev + 1),
					children: [{type: 'TEXT', props: {nodeValue: '+1', children: []}}]
				}
			}
		]
	}
}
  1. 开始渲染
    将两棵vDom树渲染成一棵fiber树
    2.1 将container和App渲染在一起
初始为container设置一个fiber,dom为container,children为App,
并且设置该fiber为第一个工作单元
经过第一次performUnitOfWork后,fiber树如下
{
	dom: container,
	props: {
		children: [App]
	}child: {
		type: App,
		props: {
			title: 'demo'
		}
		return: *container,
	}
}

2.2 处理App

下一个工作单元是App,会经过updateFunctionComponent,
处理后,将App与内部的组件连接到一起,
并且会更新wipFiber和清空hooks和hookIndex,
直到遇到下一个嵌套的函数组件之前,wipFiber都指向这个函数组件对应的fiber。
调用fiber.type()会执行App函数,同时会执行useState hook,
此时该fiber的hooks属性会推入一个hook,并且hookIndex=1
此时,fiber树如下
{
	dom: container,
	props: {
		children: [App]
	},
	child: {
		type: App,
		props: {
			title: 'demo'
		}
		return: *container,
		effectTag: 'REPLACEMENT',
		hooks: [{state: 0, queue: []}],	// hook
		child: {
			type: 'div',
			props: {
				title: 'demo',
				children: [...]
			}
			return: *App,
			effectTag: 'REPLACEMENT'
		}
	}
}

2.3 最终fiber树

{
	dom: container,
	props: {
		children: [App]
	},
	child: {
		type: App,
		props: {
			title: 'demo'
		}
		return: *container,
		effectTag: 'REPLACEMENT',
		hooks: [{state: 0, queue: []}],	// hook
		child: {
			type: 'div',
			props: {
				title: 'demo',
				children: [...]
			},
			dom,
			return: *App,
			effectTag: 'REPLACEMENT',
			child: {
				type: 'div',
				props: {...},
				dom,
				return: *div,
				effectTag: 'REPLACEMENT',
				child: {
					type: 'TEXT',
					props: {nodeValue: 0, ...},
					dom,
					return: *div,
					effectTag: 'REPALCEMENT'
				},
				sibling: {
					type: 'button',
					props: {onClick...},
					dom,
					return: *div,
					effectTag: 'REPLACEMENT',
					child: {
						type: 'TEXT',
						props: {nodeValue: '+1', ...},
						dom,
						return: *button,
						effectTag: 'REPLACEMENT'
					}
				}
			}
		}
	}
}

2.4 commitRoot
此时fiber树已经渲染好了,nextUnitOfWork也等于undefined了,执行commitRoot提交修改到页面上
commitRoot从container开始遍历fiber树开始渲染,根据fiber节点的effectTag对真实dom进行操作,这里都是REPLACEMENT,所以把所有fiber节点都相应地添加进页面里。
至此,第一次渲染完毕。

  1. 更新
    点击+1 button,调用setCount函数
    执行了hook.queue.push(prev => prev + 1)并重新设置了wipRoot和nextUnitOfWork
const setState = action => {
	hook.queue.push(action)
	workInProgressRoot = {
		dom: currentRoot.dom,
		props: currentRoot.props,
		alternate: currentRoot
	}
	deletetions = [];
	nextUnitOfWork = workInProgressRoot
}

currentRoot其实就是上一次渲染的fiber树的根节点,也就是container。
于是又从container节点开始重新来一遍,fiber树已经构建好了,所以这次遍历fiber树reconcile其实就是去diff,打标签
当nextUnitOfWork是App时,进行updateFunctionComponent,设置wipFiber,hookIndex置0,调用fiber.type(fiber.props),其中又会调用一次useState方法,这次在useState方法中,就存在了oldFiber

function useState(initial) {
  // 获得该函数组件中的该hook对应的旧hook
  const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook?.state || initial,
    queue: []
  }

  const actions = oldHook?.queue || []
  // 调用action
  actions.forEach(action => {
    // action是函数
    if (typeof action === 'function') {
      hook.state = action(hook.state)
    } else {
      // action是值
      hook.state = action
    }
  })

  const setState = action => {
    ...
  }

  // 把生成的hook压入hooks中
  wipFiber.hooks.push(hook)
  // 待进入该组件的下一个hook,更新hookIndex
  hookIndex++

  return [hook.state, setState]
}

所以state还是oldFiber中存的值,此时

hook = {
	state: 0,
	queue: [(prev) => prev + 1]
}
actions = [(prev) => prev + 1]
遍历actions,hook.state = action(hook.state) ---> hook.state = 1
然后返回值count也是1,此时App内部组件count对应的TEXT节点就改变了

将fiber遍历完后,新的fiber树为

{
	dom: container,
	props: {
		children: [App]
	},
	child: {
		type: App,
		props: {
			title: 'demo'
		}
		return: *container,
		effectTag: 'UPDATE',
		hooks: [{state: 1, queue: []}],	// hook
		child: {
			type: 'div',
			props: {
				title: 'demo',
				children: [...]
			},
			dom,
			return: *App,
			effectTag: 'UPDATE',
			child: {
				type: 'div',
				props: {...},
				dom,
				return: *div,
				effectTag: 'UPDATE',
				child: {
					type: 'TEXT',
					// -------------------
					// notify here
					props: {nodeValue: 1, ...},
					dom,
					return: *div,
					effectTag: 'UPDATE'
				},
				sibling: {
					type: 'button',
					props: {onClick...},
					dom,
					return: *div,
					effectTag: 'UPDATE',
					child: {
						type: 'TEXT',
						props: {nodeValue: '+1', ...},
						dom,
						return: *button,
						effectTag: 'UPDATE'
					}
				}
			}
		}
	}
}

可以看到effectTag全都变为了UPDATE,commitRoot中,会将所有的节点原始dom保持不变,而update上面的属性(主要是nodeValue update from 0 to 1)
至此,页面更新结束

7. 结语

React主要涉及这么几个方面:

  1. React.createElement创建vDom
  2. 浏览器闲时调度
  3. fiber
  4. diff
  5. hooks
  • 30
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值