本文主要讲手写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?
- vDom比真实Dom轻量太多,真实Dom挂载的属性太多,很多根本用不上
- 可进一步支持跨平台,如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的解决办法是:
将长时间的同步任务拆分成多个小任务,从而让浏览器能够抽身去响应其他事件,等他空了再回来继续计算
这个是思路,实现可以使用requestIdleCallback和fiber
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节点,用于diffeffectTag, // 标记,用于向页面提交更改,REPLACEMENT | UPDATE | DELETIONhooks // 该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,回到父节点,再去找父节点的siblingnextFiber = 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是函数,执行可获得vDomconst 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不存在,是函数组件,向下递归查找真实DOMcommitDeletion(fiber.child, domParent);}
}
至此,第一次渲染的流程已经很清晰了,我们来仔细看一下reconcileChildren函数的实现
4. reConcileChildren
也就是所谓的diff算法
每次构建/比对一层的fiber树
function reconcileChildren(wipFiber, elements) {let prevSibling = nulllet index = 0// 找到上一次渲染时与elements对应的fiber// 相当于拿到第一个elements的alternatelet 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) {// updatenewFiber = {type: oldFiber.type,// 更新propsprops: element.props,return: wipFiber,dom: oldFiber.dom,alternate: oldFiber,effectTag: 'UPDATE'}}// 类型不同,但是element存在,执行placement相关操作// 生成newFiberif (element && !sameType) {// addnewFiber = {type: element.type,props: element.props,return: wipFiber,effectTag: 'REPLACEMENT'}}// 类型不同,但是oldFiber存在,执行deletion相关操作// 给oldFiber打上DELETION标签,放入待删除的数组if (oldFiber && !sameType) {// deleteoldFiber.effectTag = 'DELETION'deletions.push(oldFiber)}// 如果index===0,那么newFiber就是wipFiber的childif (index === 0) {wipFiber.child = newFiber} else {// 不是0,当前fiber就是上一次fiber的siblingprevSibling.sibling = newFiber}// 如果oldFiber存在,就让oldFiber指向它的sibling// 也就是element和oldFiber一起迭代,实现对应if (oldFiber) {oldFiber = oldFiber.sibling}// 保存上一次生成的fiberprevSibling = newFiber// 迭代index++}
}
下面我们考虑一下更新,先完成一个useState Hook吧。
5. hook
还记得fiber上定义的hooks属性吗?
// 申明两个全局变量,用来处理useState
// wipFiber是当前的函数组件fiber节点
// hookIndex是当前函数组件内部useState状态计数
let wipFiber = null;
let hookIndex = null;
function useState(initial) {// 获得该函数组件中的该hook对应的旧hookconst oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]// 初始化当前hookconst hook = {// 旧hook存在的话就延续旧hook的值,否则就是第一次渲染,接收传入的initial初始化值state: oldHook?.state || initial,// actions,动作队列// 为什么要用队列?// 因为一次性可能触发多次setState,比如handleClick里调用5次setState,这时queue里就有5个action// 并不是说调用一次setState就马上更新页面,这种情况是在handleClick结束后,再去重新渲染// 个人理解是:handleClick还没有执行完,浏览器没有空闲时间去执行页面的渲染queue: []}const actions = oldHook?.queue || []// 调用actionactions.forEach(action => {// action是函数if (typeof action === 'function') {hook.state = action(hook.state)} else {// action是值hook.state = action}})const setState = action => {// 动作队列中压入actionhook.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,更新hookIndexhookIndex++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'));
<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: []}}]}}]}
}
- 开始渲染
将两棵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: []}], // hookchild: {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: []}], // hookchild: {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 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对应的旧hookconst oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]const hook = {state: oldHook?.state || initial,queue: []}const actions = oldHook?.queue || []// 调用actionactions.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,更新hookIndexhookIndex++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: []}], // hookchild: {type: 'div',props: {title: 'demo',children: [...]},dom,return: *App,effectTag: 'UPDATE',child: {type: 'div',props: {...},dom,return: *div,effectTag: 'UPDATE',child: {type: 'TEXT',// -------------------// notify hereprops: {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主要涉及这么几个方面:
- React.createElement创建vDom
- 浏览器闲时调度
- fiber
- diff
- hooks