React原理

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

1. CreateElement

相信大家一定对jsx不陌生

js 复制代码
<div title="box">
	<p>jsx</p>
	<span>hhh</span>
</div>

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

js 复制代码
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结构如下

js 复制代码
vDom: {
	type,
	props: {
		...props,
		children
	}
}

我们自己写的createElement如下

js 复制代码
// 将页面节点分为两类,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树

就拿最开始的例子

js 复制代码
<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是一种数据结构,可进行中断和回溯

具体来说,实现如下

js 复制代码
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

js 复制代码
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树,每次遍历一个

js 复制代码
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做了什么处理

js 复制代码
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

js 复制代码
// 统一提交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树

js 复制代码
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属性吗?

js 复制代码
// 申明两个全局变量,用来处理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

js 复制代码
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翻译为
js 复制代码
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渲染在一起
js 复制代码
初始为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

js 复制代码
下一个工作单元是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树

js 复制代码
{
	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
js 复制代码
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

js 复制代码
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中存的值,此时

js 复制代码
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树为

js 复制代码
{
	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
相关推荐
梦想CAD控件1 分钟前
在线CAD开发包结构与功能说明
前端·javascript·vue.js
张拭心6 分钟前
春节后,有些公司明确要求 AI 经验了
android·前端·人工智能
时光不负努力6 分钟前
typescript常用的dom 元素类型
前端·typescript
小怪点点12 分钟前
大文件切片上传
前端
时光不负努力13 分钟前
TS 常用工具类型
前端·javascript·typescript
SuperEugene14 分钟前
Vue状态管理扫盲篇:Vuex 到 Pinia | 为什么大家都在迁移?核心用法对比
前端·vue.js·面试
张拭心16 分钟前
Android 17 来了!新特性介绍与适配建议
android·前端
徐小夕21 分钟前
pxcharts-vue:一款专为 Vue3 打造的开源多维表格解决方案
前端·vue.js·github
Hilaku21 分钟前
我会如何考核一个在简历里大谈 AI 提效的高级前端?
前端·javascript·面试
进击的尘埃34 分钟前
Vue3 中 emit 能 await 吗?事件机制里的异步陷阱
javascript