赞
踩
本文主要讲手写React中重要的几个部分,有助于建立对React源码的认知。
相信大家一定对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结构如下
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
根据上面的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节点,用于diff
effectTag, // 标记,用于向页面提交更改,REPLACEMENT | UPDATE | DELETION
hooks // 该fiber上挂载的hook
}
后面三个属性可以先不看,相信你已经知道fiber长什么样了,就是一棵多了几个指向的树
下面分别来看一下提到的几个函数,performUnitOfWork,commitRoot
按照先儿子后兄弟的顺序,深度遍历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到页面上。
遍历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函数的实现
也就是所谓的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吧。
还记得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] }
让我们来捋一下整个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: []}}] } } ] } }
初始为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节点都相应地添加进页面里。
至此,第一次渲染完毕。
hook.queue.push(prev => prev + 1)
并重新设置了wipRoot和nextUnitOfWorkconst 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)
至此,页面更新结束
React主要涉及这么几个方面:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。