文章目录
引文
如果你是前端从业者,在使用react、vue的过程中,一定避不开“虚拟dom”这个话题。
那么本次就由博主来带大家探索一下react中的“虚拟dom”
通过本文,你将了解到如下几点:
- 什么是虚拟dom?
- 虚拟dom做了什么?他的优点是什么?他的出现带来了什么改变、解决了什么问题?
- 虚拟dom的性能如何?是否比原生的dom操作更快?
- react的虚拟dom是如何实现的?react是如何将虚拟dom转变为真实dom?
一、前端技术的发展历史
想要深入了解虚拟dom,我们先回顾一下在虚拟dom之前,我们是如何更新页面的,通过技术历程的了解,希望你可以理解虚拟dom是在什么情况下诞生的,以及他的出现到底解决了什么问题?
1.1 前后端不分离
1.1.2 静态网站
19世纪90年代第一个Web浏览器诞生,随后www
的出现标志着前端技术的开始。
互联网发展的早期,WWW(World Wide Web)、浏览器、JavaScript相继诞生,最开始大多是HTML静态网页。
此时由于技术的有限,网页的浏览毫无体验可言。
1.1.3 动态网站
比较有代表性的技术有JSP 、PHP、ASP、ASP.NET 等语言,它们都类似,是运行在服务端的语言。
那时候没有专业的前端,前后端开发是一体的,前端代码是后端代码的一部分,前端写静态模板,后端加数据套模板。
1.2 前后端分离
1.2.1 jQuery时代
1999年微软发布的IE5.0允许JS脚本像服务器发起请求,直到05年左右,这个功能才引起重视,被命名为AJAX
,并完善了他的使用方法,AJAX成为脚本发起HTTP通信的代名词。
06年jQuery发布,当时并不突出,直到2009年,Sizzle选择器引擎研发成功,jQuery才取得压倒性的优势。jQuery在处理DOM兼容上真是知微见著, 发掘出大量的DOM/BOM兼容方案,至此JQuery
一骑绝尘,其次,开发者们已开始注重前后端分离。
但是随着互联网行业的迅速发展,JQuery也暴露出了他的问题,当Ajax出现依赖时,就不可避免就出现回调地狱。因此针对这方面的讨论,诞生Deffered与Promise。
同时,为了提高JQuery的性能,涉及到性能优化问题,往往都会提到尽可能少的操作DOM这一点,为什么?
在当时,我们一般都会在JS文件中直接操作DOM元素,JQuery的出现极大的降低了开发者操作DOM的使用成本,前面提到了他的兼容方案十分完善,几乎抹平了当时不同浏览器操作DOM的API差异。
为什么性能优化需要降低DOM操作的频率?
这里就涉及到重绘与回流两个概念,比如单纯修改颜色就会引发重绘,删除或新增一个DOM节点就会引发回流和重绘,用户虽然无法感知这个过程,但对于浏览器而言也存在消耗性能。当然,后面针对回流出了一个DOM API——DocumentFragment
DocumentFragment
是一种特殊的文档对象,它表示一个没有父元素的文档片段。DocumentFragment 可以被用来存储多个文档节点,并且这些节点的变化不会影响到文档本身。例如,如果你想在文档中插入一组元素,你可以将这些元素添加到一个 DocumentFragment 中,然后将整个 DocumentFragment 一次性插入到文档中。这样做可以避免频繁地操作 DOM,从而提高性能。
1.2.2 angularjs时代
2010年angularjs(这里指angularjs1而非angular)横空出世,一招双向绑定在当时更是惊为天人!
AngularJS 是一种 JavaScript 框架,用于构建动态、响应式的网页应用程序。
angularjs
完成了一个惊艳操作,他将view
视图层与Model
数据层进行挂钩,只要 Model 层数据发生变化,view 层便自动更新。angularjs 的这种做法,彻底将开发者从操作 DOM 上解放了出来,同时也为日后JQuery的没落埋下伏笔。
但是angularjs
的数据同步机制性能比较差,后面他们的团队转而放弃了维护angularjs
转而退出了angular
1.2.3 react与vue
早期vue
的模板语法、指令,双向绑定等很多灵感其实都借鉴了angularjs
,但是他们的更新机制毫无关联,vue中的数据同步是需要更新的组件独立更新,react
与 vue
一样相对 angularjs 也是局部更新,只是 react
中的局部是以当前组件为根以及之下的所有子组件,这是由于他们渲染机制的原因。
react 与 vue的出现, 都引入了虚拟 DOM 的概念。
react 虚拟 dom 是 react 框架内部使用的一种数据结构,它能够高效地计算出真实 DOM 的变化,并且能够快速地将变化应用到真实 DOM 中。
1.3 总结
js
和 jq
:研发在专注业务的同时,还要亲自操作 dom。
angularjs 1
:将研发从操作 dom 中解脱了出来,更新 dom 交由 angularjs 底层实现,这一套机制由脏检测机制所支撑。
react/vue
:同样是由框架底层实现更新 dom,只是在此之前多了虚拟dom的对比,先对比再更新,以此达到最小更新目的,所以相当于传统js实现手动更新,框架底层就需要先帮我们找到需要更新的虚拟dom,然后再更新到真实dom之上。
至此,我们从宏观角度解释了前端技术的发展历程,想必你对虚拟dom也有了一丝感悟,那么虚拟dom到底是怎么实现的呢?他又解决了什么问题?接下来就进入正题。
二、什么是虚拟dom
2.1 概念
我理解为虚拟DOM为一种数据结构,框架通过他在内存中模拟真实的DOM结构,当页面需要变化的时候通过一种算法来对比更新前后的虚拟DOM结构找到需要更改的地方,然后再操作真实DOM,就可以更新页面。
这是因为手动查找并更新真实 DOM 的速度很慢,而更新虚拟 DOM 的速度很快。当我们想要更新页面时,我们可以更新虚拟 DOM,然后用虚拟 DOM 的变化来更新真实 DOM,这样就可以节省大量的时间。
2.2 react中的虚拟dom
熟悉react
的同学想必对React.createElement
不会陌生,他是react中创建虚拟dom的方式,语法如下:
/*
* component 组件名,一个标签也可以理解成一个最基础的组件
* props 当前组件的属性,比如class,或者其它属性
* children 组件的子组件,就像标签套标签
*/
React.createElement(component, props, ...children)
现在有一个简单的HTML标签
<span className='span'>hello echo</span>
那么在react创建虚拟dom的时候,他是这么创建的:
React.createElement('div', {className:'span'}, 'hello echo');
这样看起来很简单嘛,但是若dom存在多级的嵌套的话,假设有下面这种情况:
<span className='span'>
<span>
hello echo
</span>
</span>
这个时候,就需要在React.createElement
中不断嵌套
React.createElement('span', {className:'span'}, React.createElement("span", null, "hello echo"));
但是在实际的开发中,我们的页面结构往往比这些复杂的多,那肯定是没办法直接用这个方法创建虚拟dom的,所以我们常常使用jsx
语法在react中创建我们的html页面。
你可以认为jsx
模版语法是React.createElement
的语法糖,他方便我们用熟悉的html习惯去定义reeactNode
结构,而在编译之后,他还是会变成React.createElement
所创建的对象,大概过程如下:
为方便理解,我们可以将React.createElement创建对象结构抽象为:
const VitrualDom = {
type: 'span',
props: {
className: 'span'
},
children: [{
type: 'span',
props: {},
children: 'hello World!'
}]
}
这个数据只是传给React.cerateElement
的结构,可以理解为通俗意义上的虚拟DOM,但他并不是真正的ReactNode
,只有经过React.cerateElement
处理之后的数据,才是真正意义上的React虚拟DOM
简单写一个例子,我们把虚拟dom打印出来看看他是什么样子
import React from "react";
class Father extends React.PureComponent {
render() {
console.log(this.props.children);
return <div>{this.props.children}</div>;
}
}
class P extends React.Component {
render() {
return (
<Father>
<span className="span">
<span>hello World!</span>
</span>
</Father>
);
}
}
export default P;
打印结果如下,
看看他里面的props
,再对照一下上面写的react代码,现在就很清晰的看出来真实的虚拟dom是什么了
React中的虚拟dom不仅仅是我们上面写的简单的js
数据,它经过React.creatElement
的处理之后,添加了许多额外的属性,这些属性在后续对虚拟Dom结构进行查找更新过程中发挥作用,比如在React16之后更新的Fiber机制。
到目前,我们搞清楚了虚拟DOM到底是什么,也见识了React
中实现的虚拟DOM。所谓虚拟DOM其实只是一个包含了标签类型type,属性props以及它包含子元素children的对象。
虚拟DOM和真正地DOM是不一样的,Virtual DOM最主要的还是保留了Element之间的层次关系和一些基本属性。因为真实DOM实在是太复杂,即使是一个空元素,内部也存在非常多的属性,你可以理解为虚拟DOM是从真实DOM中摘出来的一部分,专门用来更新DOM的一种数据结构。
三、react虚拟DOM的优势是什么
3.1 局部更新DOM的机制
在说这一点之前,我们先回忆一下前端使用原生JS
和JQuery
的时代,回想一下当时是通过什么方式把数据和视图连接到了一起。
虽然博主的技术历程中并没有那些经验,但在查询了相关的资料后发现在使用JQuery
开发时,数据和视图是强耦合的,我们要先更新视图中的数据,就需要使用onload
进行监听,发起ajax
请求接收到数据之后,在回调中创造出包含新数据的DOM片段
,然后将其替换到需要更新的地方。
就像下面这样:
<ul id='userList'></ul>
const userList = [
'第一',
'第二',
'第三'
]
const ulDom = document.querySelector('#userList');
// 生成代码片段
const fragment = document.createDocumentFragment();
for (let i = 0; i < userList.length; i++) {
const liDom = document.createElement("li");
liDom.innerHTML = userList[i];
// 依次生成li,并加入到代码片段
fragment.appendChild(liDom);
}
// 最终将代码片段塞入到ul
ulDom.appendChild(fragment);
是不是觉得特别的生硬。。。但在当时的技术环境下,大部分开发者都是这样操作,技艺高超的开发者会想办法将上面生成新标签的逻辑封装成一个通用的方法,再考虑用上createDocumentFragment
减少dom操作的次数。
所以在之前原生JS
开发时代,根本就没有所谓的dom
对比,因为所有需要更新的dom
都是重新生成的,在写代码之前,我们就知道了页面中的哪些地方需要动态变化,哪些是固定不变,提前将需要动态生成dom
的逻辑封装为一个方法,然后在需要更新的时候去调这个方法。
下面看这样一个场景:
在一个展示资料页面中,点击选择不同的用户,contain
区域会显示不同的信息列表,那么在我们点击用户的时候JS
会去请求这个用户的信息数据,然后JQery
通过上面的方式去重新渲染contain
展示区域的信息。所以,我们每次点击用户,contain
区域其实都等于完全重建。
现在我们不希望contain
区域全部重新渲染,而是希望通过比对dom
节点,找到有变化的dom
,然后在变化的dom
中看是哪个属性发生了变化,只有不同的部分我们才需要更新。但仅仅通过下面这段代码,你就能预想到这个做法的性能有多糟糕了:
const liDom = document.createElement("li");
let num = 0;
for (let key in liDom) {
num += 1;
}
console.log(num); // 307
遍历li
节点光属性就有307个,这仅仅是一个节点,当页面足够复杂的时候,光是比较dom
就足以劝退浏览者。
在前面我们也提到过,不管是jq
封装,还是react vue
的模板语法,它的前提一定是研发自己提前知道了哪部分内容未来是可变的,所以我们才要动态封装,才需要使用{}
进行包裹,那既然如此,我们就对比未来可能会变的部分不是更好吗?
而回到上文我们对于虚拟结构的抽象,对于react
而言,props
是可变的,child
是可变的,state
也是可变的,而这些属性恰好都在虚拟dom
中均有呈现。
所以到这里,我们解释了虚拟dom的第一个优势,站在对比更新的角度,虚拟dom
能聚焦于需要对比什么,相对原生dom
它提供更高效的对比可行性。
3.2 浏览器兼容性最佳
React中的虚拟DOM具有强大的兼容性。我们现在知道,在react编译过程中,react
和babel
将JSX转为JS对象
也就是虚拟DOM,之后通过render函数生成真实DOM。在编译虚拟DOM的过程中react还做了许多处理,比如diff
对比和兼容处理
就是在这个阶段。
在JQuery
中操作DOM十分便捷,比如原生的input
就有onchange
事件,但这都是在浏览器底层对DOM的处理,不同的DOM只能触发关联给他的一些事件,比如div
标签本身没有onchange
事件,但是虚拟dom就不同了,虚拟dom一方面模仿了原生dom的行为,其次在事件方面也做了合成事件与原生事件的映射关系,比如:
{
onClick: ['click'],
onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']
}
react暴露给我们的合成事件,其实在底层会关联到多个原生事件,通过这种做法抹平了不同浏览器之间的api差异,也带来了更强大的事件系统。
至于 react 合成事件底层是如何实现的,博主也还没有做相关的研究。
3.3 渲染机制的优化
我们知道react是state数据驱动视图,只要state发生了改变,那么render就会重新触发,以达到更新UI层的效果。
但是在某些处理逻辑中,某个state的值会多次发生变化,比如在某个事件中短时间内多次setState
,这时react会进行多次渲染吗?
答案是不会。
在 React 中,state 的更新是异步的,React 会在更新页面之前进行优化,因此可能会把多次 setState() 调用合并为一次更新,以提高性能。
当然如果我们是直接操作dom,那还有哪门子的异步和渲染等待,当你append完一个子节点,页面早渲染完了。所以虚拟dom的对比提前,以及setState的异步处理,本质上也是在像尽可能少的操作dom靠近。
3.4 跨平台能力
因为 React 只是在 JavaScript 层面上操作虚拟 DOM,所以可以在不同平台上使用相同的代码来渲染用户界面。
之所以加入虚拟dom这个中间层,除了解决部分性能问题,加强兼容性之外,还有个目的是将dom的更新抽离成一个公共层,别忘了react除了做页面引用外,react还支持使用React Native做原生app。
四、虚拟DOM的性能
虚拟DOM真的比直接操作原生DOM快吗?
并不是,目前为止,没有任何一个框架可以比直接操作原生DOM更快。
虚拟DOM虽然比真实DOM的属性少了很多,但是在react
中,需要对虚拟DOM进行递归对比,找到需要更新的节点然后再去操作真实DOM,而手动操作DOM是直接操作的真实DOM,即便在react
经历了许多查找对比虚拟DOM过程的优化,比如diff
、fiber
等,react
到头来还是要操作原生dom,只是对于研发来讲不用关注这一步罢了。
但是,作为一个开发者,许多情况下我们不能仅仅关注性能如何,还要对项目的可维护性、迭代问题等一些列成本进行预估,react
中,虚拟DOM的使命就是把业务逻辑和页面DOM的操作区分开来,那些繁琐的DOM操作由react
帮你实现,虽然有时性能称不上多好,但是开发过程的体验是原生JS无法获得的。
总结来说,单论修改一个dom节点的性能,不管react
还是vue
亦或是angular
,一定是原生最快,但虚拟dom有原生dom比不了的价值,起码react这些框架能让研发更专注业务以及数据处理,而不是陷入繁琐的dom增删改查中。
五、react中的虚拟DOM的实现原理
OK,家人们!现在来到了最重要的部分,也是最令人头痛的源码部分,能看到这里的,个个都是人才~
下面以react17.0.2
源码为准,看一下react的底层如何实现虚拟DOM。
不知你还有没有印象,上文有提到,react
中创建虚拟DOM的方法React.createElement
,下面这段代码摘除了dev环境的逻辑。
/**
* 创建并返回给定类型的新ReactElement。
* See https://reactjs.org/docs/react-api.html#createelement
*/
function createElement(type, config, children) {
let propName;
// 创建一个全新的props对象
const props = {};
let key = null;
let ref = null;
let self = null;
let source = null;
// 有传递自定义属性进来吗?有的话就尝试获取ref与key
if (config != null) {
if (hasValidRef(config)) {
ref = config.ref;
}
if (hasValidKey(config)) {
key = '' + config.key;
}
// 保存self和source
self = config.__self === undefined ? null : config.__self;
source = config.__source === undefined ? null : config.__source;
// 剩下的属性都添加到一个新的props属性中。注意是config自身的属性
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
!RESERVED_PROPS.hasOwnProperty(propName)
) {
props[propName] = config[propName];
}
}
}
// 处理子元素,默认参数第二个之后都是子元素
const childrenLength = arguments.length - 2;
// 如果子元素只有一个,直接赋值
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
// 如果是多个,转成数组再赋予给props
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
props.children = childArray;
}
// 处理默认props,不一定有,有才会遍历赋值
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
// 默认值只处理值不是undefined的属性
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
// 调用真正的React元素创建方法
return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
}
代码看似很多,其实逻辑非常清晰:
- 处理参数,对传进来的数据进行加工处理,比如提取config参数,处理props等
- 调用真正的创建虚拟DOM的API
ReactElement
创建ReactNode
数据加工部分分为三步:
- 第一步,判断config有没有传,不为null就做处理
- 判断ref、key,__self、__source这些是否存在或者有效,满足条件就分别赋值给前面新建的变量。
- 遍历config,并将config自身的属性依次赋值给前面新建props。
- 第二步,处理子元素。默认从第三个参数开始都是子元素。
- 如果子元素只有一个,直接赋值给props.children。
- 如果子元素有多个,转成数组后再赋值给props.children。
- 第三步,处理默认属性defaultProps
- 一个纯粹的标签也可以理解成一个最最最基础的组件,而组件支持 defaultProps,所以这一步判断有没有defaultProps,如果有同样遍历,并将值不为undefined的部分都拷贝到props对象上。
逻辑抽离出来,看起来并不难~
我们在看一下ReactElement
,同样是删除了dev环境逻辑:
const ReactElement = function (type, key, ref, self, source, owner, props) {
const element = {
// 这个标签允许我们将其标识为唯一的React Element
$$typeof: REACT_ELEMENT_TYPE,
// 元素的内置属性
type: type,
key: key,
ref: ref,
props: props,
// 记录负责创建此元素的组件。
_owner: owner,
};
return element;
};
这里主要是将前面的一些数据,生成一个element
对象,也就是虚拟DOM。
这里提一下REACT_ELEMENT_TYPE
,他的实现是:
export const REACT_ELEMENT_TYPE = Symbol.for('react.element');
如果你自己实现了上面打印虚拟DOM的场景,或许你有点印象。
在这里
$$typeof
定义为Symbol(react.element)
,而Symbol
一大特性就是标识唯一性,即便两个看着一模一样的Symbol,它们也不会相等。而react
之所以这样做,本质也是为了防止xss攻击
,防止外部伪造虚拟dom结构。
至此,我们又了解到了react底层是如何实现虚拟DOM,这里留下一个问题,我看到别人的文章中有提到,但是我自己还没去复现。
React中虚拟dom的是否允许修改,或者添加新的属性?
六、react中的虚拟DOM如何转变为真实DOM?
一般来说,使用React编写应用,ReactDOM.render
是我们触发的第一个函数。那么我们先从ReactDOM.render
这个入口函数开始分析render的整个流程。
下面贴出来的源码也会把dev环境下的代码删掉,我们只关注具体的逻辑
虚拟DOM会通过ReactDOM.render
进行渲染成真实DOM
class P extends React.Component {
render() {
return (
<span className="span">
<span>hello World!</span>
</span>
);
}
}
6.1 React render
export function render(element: React$Element<any>,container: Container,callback: ?Function) {
// 校验container是否合法
invariant(
isValidContainer(container),
'Target container is not a DOM element.',
);
// 调用 legacyRenderSubtreeIntoContainer 方法
return legacyRenderSubtreeIntoContainer(
null,
element,
container,
false,
callback,
);
}
可以看出 render方法实际上只对container进行节点类型的校验,如果不是一个合法的dom节点就会抛出错误,我们只需要关注核心逻辑legacyRenderSubtreeIntoContainer()
6.2 legacyRenderSubtreeIntoContainer
legacyRenderSubtreeIntoContainer
函数名的意思就是“组件子树继承渲染到容器中”,其实就是把虚拟的dom树渲染到真实的dom容器中,但是你别被他的名字
function legacyRenderSubtreeIntoContainer(
parentComponent: ?React$Component<any, any>,
children: ReactNodeList,
container: Container,
forceHydrate: boolean,
callback: ?Function,
) {
// root:FiberRootNode 是整个应用的根结点
// 绑定在真实DOM节点的_reactRootContainer属性上
let root: RootType = (container._reactRootContainer: any);
let fiberRoot;
// 判断 根节点是否存在
if (!root) {
//如果不存在,则说明是Initial mount 阶段,调用函数生成rootNode
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
container,
forceHydrate,
);
// 取出root内部的_internalRoot属性
fiberRoot = root._internalRoot;
if (typeof callback === 'function') {
const originalCallback = callback;
// 封装 callback 回调
callback = function() {
//通过fiberRoot找到当前对应的rootFiber
//将rootFiber.child.stateNode作为callback中的this指向
const instance = getPublicRootInstance(fiberRoot);
originalCallback.call(instance);
};
}
// 初始化不允许批量处理,使用 unbatchedUpdates 调用 updateContainer()同步生成
unbatchedUpdates(() => {
updateContainer(children, fiberRoot, parentComponent, callback);
});
} else {
fiberRoot = root._internalRoot;
if (typeof callback === 'function') {
const originalCallback = callback;
callback = function() {
const instance = getPublicRootInstance(fiberRoot);
originalCallback.call(instance);
};
}
// batchedUpdates 调用 updateContainer();
updateContainer(children, fiberRoot, parentComponent, callback);
}
return getPublicRootInstance(fiberRoot);
}
继续看一下getPublicRootInstance
函数
export function getPublicRootInstance(
container: OpaqueRoot,
): React$Component<any, any> | PublicInstance | null {
// 取出当前Fiber节点,通过一些判断寻找FiberRoot
const containerFiber = container.current;
// 是否存在子Fiber节点
if (!containerFiber.child) {
return null;
}
// 判断子Fiber节点tag类型
switch (containerFiber.child.tag) {
// 这里 HostComponent = 5,
case HostComponent:
return getPublicInstance(containerFiber.child.stateNode);
default:
return containerFiber.child.stateNode;
}
}
再梳理一下这个过程:
- root = container._reactRootContainer:如不存在,表示是 Initial mount 阶段,调用 legacyCreateRootFromDOMContainer 生成;如存在,表示是 update 阶段;
- fiberRoot = root._internalRoot:从 root 上拿到内部 _internalRoot 属性;
- 封装 callback 回调(通过 fiberRoot 找到其对应的 rootFiber,然后将 rootFiber.child.stateNode 作为 callback 中的 this 指向,调用 callback);
- mount 阶段,需要尽快完成,不允许批量更新(使用的是legacy渲染模式),使用 unbatchedUpdates 调用 updateContainer();update 阶段,直接调用 updateContainer() 执行更新;
- 返回getPublicRootInstance(fiberRoot):返回公开的 Root 实例对象。
上面看似新建、更新两种情况都用的一个函数,react
故意将他们命名为一样,其实new
的时候还是走的new
生成逻辑,update
走的update
逻辑。
就像这里
export const updateContainer = enableNewReconciler
? updateContainer_new
: updateContainer_old;
FiberRoot和rootFiber的区别:
- 首次执行ReactDOM.render会创建fiberRootNode(源码中叫fiberRoot)和rootFiber。
- fiberRootNode是整个应用的根节点,绑定在真实DOM节点的_reactRootContainer属性上
- rootFiber是当前所在组件树的根节点,rootFiber在每次重新渲染的时候会重新构建。
6.3 updateContainer
这里必须关注一下updateContainer()
,走到这里就进入了一个非常关键的处理
来看一下updateContainer
的源码
function createContainer(
containerInfo: Container,
tag: RootTag,
hydrate: boolean,
hydrationCallbacks: null | SuspenseHydrationCallbacks,
): OpaqueRoot {
return createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks);
}
export function updateContainer(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
callback: ?Function,
): Lane {
const current = container.current;
// 获取更新任务的触发时间,可以理解为返回的时间越小,则执行的优先级越高
const eventTime = requestEventTime();
// lane 中文含义是车道, 在 React 中,lane 是一个number值,使用 32 个比特位表示32个“车道”。
// 通过一系列处理确定任务真正的优先级,并申请相关的车道
const lane = requestUpdateLane(current);
if (enableSchedulingProfiler) {
markRenderScheduled(lane);
}
const context = getContextForSubtree(parentComponent);
if (container.context === null) {
container.context = context;
} else {
container.pendingContext = context;
}
//返回一个包装好的任务 update ,存放一些数据
// const update: Update<*> = {
// eventTime: number,
// lane: Lane,
// tag: 0 | 1 | 2 | 3,
// payload: any,
// callback: (() => mixed) | null,
// next: Update<State> | null,
// };
const update = createUpdate(eventTime, lane);
update.payload = {element};
callback = callback === undefined ? null : callback;
// 将需要更新的任务对象关联进 Fiber 任务队列,形成环状链表
enqueueUpdate(current, update);
// 进入Fiber的协调、调度
scheduleUpdateOnFiber(current, lane, eventTime);
return lane;
}
这一部分react
做了什么?
他为传进来的Fiber申请了lane
,确定了优先级,生成一个更新的任务update
类型的数据,将需要更新的任务关联进了Fiber的任务队列并且形成了环状链表,进入Fiber的协调调度函数scheduleUpdateOnFiber
,安排Fiber节点挂载。
在后续scheduleUpdateOnFiber
的处理中,会调用checkForNestedUpdates()
,他处理任务更新的嵌套层数,如果嵌套层数过大( >50 ),就会认为是无效更新,则会抛出异常。
之后便根据markUpdateLaneFromFiberToRoot
对当前的fiber树,自底向上的递归fiber的lane,根据lane做二进制比较或者位运算处理,ensureRootIsScheduled
里确定调度更新的模式,有performSyncWorkOnRoot
或performConcurrentWorkOnRoot
方法。不同的调用取决于本次更新是同步更新还是异步更新。。
这里需要提一下react
的渲染模式
react 的三种渲染模式
1、legacy 模式:ReactDOM.render(<App />, rootNode)
。
2、blocking 模式:ReactDOM.createBlockingRoot(rootNode).render(<App />)
。
3、concurrent 模式:ReactDOM.createRoot(rootNode).render(<App />)
。创建的更新具有不同的优先级,同时也是可以打断的
updateContainer
不管什么模式都会走 performSyncWorkOnRoot
,这个函数的核心功能分别是:
这个函数,而这个函数三个核心功能分别是:
- beginWork
- completeUnitOfWork
- commitRoot
在
render
阶段会通过遍历的方式向下调和向上归并,从而创建一颗完整的Fiber Tree
,调和的过程也就是beginWork
,归并就是completeWork
过程
6.4 performSyncWorkOnRoot 函数的三大核心处理
(1) beginWork
我们直接跳过一些逻辑看一个比较重要的方法,beginWork
这段代码太长,我们只看部分逻辑
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// 删除部分无影响的代码
workInProgress.lanes = NoLanes;
//这里有一些判断FiberProps是否可以复用的逻辑,然后做一些处理
switch (workInProgress.tag) {
// 模糊定义的组件
case IndeterminateComponent:
{
return mountIndeterminateComponent(current, workInProgress, workInProgress.type, renderLanes);
}
// 函数组件
case FunctionComponent:
{
var _Component = workInProgress.type;
var unresolvedProps = workInProgress.pendingProps;
var resolvedProps = workInProgress.elementType === _Component ? unresolvedProps : resolveDefaultProps(_Component, unresolvedProps);
return updateFunctionComponent(current, workInProgress, _Component, resolvedProps, renderLanes);
}
// class组件
case ClassComponent:
{
var _Component2 = workInProgress.type;
var _unresolvedProps = workInProgress.pendingProps;
var _resolvedProps = workInProgress.elementType === _Component2 ? _unresolvedProps : resolveDefaultProps(_Component2, _unresolvedProps);
return updateClassComponent(current, workInProgress, _Component2, _resolvedProps, renderLanes);
}
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
case ...:
return ...
}
}
这里beginWork
做的很重要的一步,就是根据render
传进来组件类型的不同来选择不同的组件更新的方法。
我们可以根据 current
是否为 null 来判断当前组件是处于 update
阶段还是 mount
阶段
因此,beginWork
的工作其实可以分成两部分
mount
时:会根据 Fiber.tag 的不同,执行不同类型的创建子 Fiber 节点的程序update
时:会根据一定的条件复用 current 节点,这样可以通过clone current.child
来作为workInProgress.child
,而不需要重新创建
比如我们最初定义了一个Class
组件P
,这里就会进入updateClassComponent()
来更新组件
function updateClassComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps: any,
renderLanes: Lanes,
) {
// 删除了添加context部分的逻辑
// 获取组件实例
var instance = workInProgress.stateNode;
var shouldUpdate;
// 如果没有实例,那就得创建实例
if (instance === null) {
if (current !== null) {
current.alternate = null;
workInProgress.alternate = null;
workInProgress.flags |= Placement;
}
// 这里new Class创建组件实例
constructClassInstance(workInProgress, Component, nextProps);
// 挂载组件实例
mountClassInstance(workInProgress, Component, nextProps, renderLanes);
shouldUpdate = true;
} else if (current === null) {
shouldUpdate = resumeMountClassInstance(workInProgress, Component, nextProps, renderLanes);
} else {
shouldUpdate = updateClassInstance(current, workInProgress, Component, nextProps, renderLanes);
}
// Class组件的收尾工作
var nextUnitOfWork = finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes);
}
在看这段代码前,我们自己也可以提前想象下这个过程,比如Class
组件你一定是得new
才能得到一个实例,只有拿到实例后才能调用其render
方法,拿到其虚拟dom
结构,之后再根据结构创建真实dom
,添加属性,最后加入到页面。
所以在updateClassComponent
中,首先会对组件做context
相关的处理,这部分代码我删掉了,其余,判断当前组件是否有实例,如果有就去更新实例,如果没有那就创建实例,所以我们聚焦到constructClassInstance
与mountClassInstance
、finishClassComponent
三个方法,看命名就能猜到,前者一定是创造实例,后者是应该是挂载实例前的一些处理,先看第一个方法:
function constructClassInstance(workInProgress, ctor, props) {
// 删除了对组件context进一步加工的逻辑
// ....
// 这里创建了组件实例
// 验证了前面的推测,这里new了我们的组件,并且传递了当前组件的props以及前面代码加工的context
var instance = new ctor(props, context);
var state = workInProgress.memoizedState = instance.state !== null && instance.state !== undefined ? instance.state : null;
adoptClassInstance(workInProgress, instance);
// 删除了对于组件生命周期钩子函数的处理,比如很多即将被废弃的钩子,在这里都会被添加 UNSAFE_ 前缀
//.....
return instance;
}
果然,这里通过new ctor(props, context)
创建了组件实例。
下面看mountClassInstance()
function mountClassInstance(workInProgress, ctor, newProps, renderLanes) {
// 此方法主要是对constructClassInstance创建的实例进行数据组装,为其赋予props,state等一系列属性
var instance = workInProgress.stateNode;
instance.props = newProps;
instance.state = workInProgress.memoizedState;
instance.refs = emptyRefsObject;
initializeUpdateQueue(workInProgress);
// 删除了部分特殊情况下,对于instance的特殊处理逻辑
}
虽然命名是挂载,但其实离真正的挂载还远得很,本方法其实是为constructClassInstance
创建的组件实例做数据加工,为其赋予props state
等一系列属性。
在上文代码中,其实还有个finishClassComponent
方法,此方法在组件自身都准备完善后调用,我们期待已久的render方法处理就在里面:
function finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes) {
var instance = workInProgress.stateNode;
ReactCurrentOwner$1.current = workInProgress;
var nextChildren;
if (didCaptureError && typeof Component.getDerivedStateFromError !== 'function') {
// ...
} else {
{
setIsRendering(true);
// 关注点在这,通过调用组件实例的render方法,得到内部的元素
nextChildren = instance.render();
setIsRendering(false);
}
}
if (current !== null && didCaptureError) {
forceUnmountCurrentAndReconcile(current,workInProgress,nextChildren,renderLanes,
);
} else {
//reconcileChildren 做的事情就是 react 的另一核心之一 —— diff 过程
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
}
workInProgress.memoizedState = instance.state;
return workInProgress.child;
}
(2) completeWork
当 workInProgress
为 null 时,也就是当前任务的 fiber
树遍历完之后,就进入到了 completeUnitOfWork
函数。
// packages/react-reconciler/src/ReactFiberWorkLoop.old.js
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
// ...
// 对节点进行completeWork,生成DOM,更新props,绑定事件
next = completeWork(current, completedWork, subtreeRenderLanes);
if (
returnFiber !== null &&
(returnFiber.flags & Incomplete) === NoFlags
) {
// 将当前节点的 effectList 并入到父节点的 effectList
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
}
returnFiber.lastEffect = completedWork.lastEffect;
}
// 将自身添加到 effectList 链,添加时跳过 NoWork 和 PerformedWork的 flags,因为真正的 commit 时用不到
const flags = completedWork.flags;
if (flags > PerformedWork) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
}
} while (completedWork !== null);
// ...
}
经过了 beginWork
操作,workInProgress
节点已经被打上了 flags
副作用标签。completeUnitOfWork
方法中主要是逐层收集 effects
链,最终收集到root
上,供接下来的 commit
阶段使用。
到这里,我们可以理解例子P组件虚拟dom都准备完毕,现在要做的是对于虚拟dom这种最基础的组件做转成真实dom的操作,见如下代码:
function completeWork(current, workInProgress, renderLanes) {
var newProps = workInProgress.pendingProps;
// 根据tag类型做不同的处理
switch (workInProgress.tag) {
// 标签类的基础组件走这条路
case HostComponent:
{
popHostContext(workInProgress);
var rootContainerInstance = getRootHostContainer();
var type = workInProgress.type;
if (current !== null && workInProgress.stateNode != null) {
// ...
} else {
// ...
} else {
// 关注点1:创建虚拟dom的实例
var instance = createInstance(type, newProps, rootContainerInstance, currentHostContext, workInProgress);
appendAllChildren(instance, workInProgress, false, false);
workInProgress.stateNode = instance; // Certain renderers require commit-time effects for initial mount.
// 关注点2:初始化实例的子元素
if (finalizeInitialChildren(instance, type, newProps, rootContainerInstance)) {
markUpdate(workInProgress);
}
}
}
}
}
}
createInstance
调用createElement
方法创建真正的DOM实例,react
会根据你的标签类型来决定怎么创建dom。比如我们的span
很显然就是通过ownerDocument.createElement(type)
创建,如下图:
创建完成后,此时的span节点还是一个啥都没有的空span,所以后续来到finalizeInitialChildren
方法,这里开始对创建的span
节点的children
子元素进一步加工,再通过里面的一些函数做一些对节点的加工处理,比如设置节点的标签样式等等。
那么到这里,其实我们的组件P已经准备完毕,包括真实dom也都创建好了,就等插入到页面了,那这些dom什么时候插入到页面的呢?
(3) commit
completeUnitOfWork
结束后,render
阶段便结束了,后面就到了 commit
阶段。
其实到这里可以算是render
阶段的完成,这里在内存中构建 workInProgress Fiber
树的所有工作都已经完成,这其中包括了对 Fiber
节点的 update
、diff
、flags
标记、subtreeFlags(effectList)
的收集等一系列操作,在 completeWork
阶段形成了 effectList
链表,连接所有需要被更新的节点。
下面,为了将这些需要更新的节点应用到真实 DOM 上却不需要遍历整棵树,在 commit 阶段,会通过遍历这条 EffectList 链表,执行对应的操作,来完成对真实 DOM 的更新。
这个阶段我们直接看他是怎么把真实DOM节点插入到容器中的,直接定位到insertOrAppendPlacementNodeIntoContainer
方法,直译过来就是将节点插入或者追加到容器节点中:
function insertOrAppendPlacementNodeIntoContainer(node, before, parent) {
var tag = node.tag;
var isHost = tag === HostComponent || tag === HostText;
if (isHost || enableFundamentalAPI ) {
var stateNode = isHost ? node.stateNode : node.stateNode.instance;
if (before) {
// 在容器节点前插入
insertInContainerBefore(parent, stateNode, before);
} else {
// 在容器节点后追加
appendChildToContainer(parent, stateNode);
}
} else if (tag === HostPortal) ; else {
var child = node.child;
// 只要子节点不为null,继续递归调用
if (child !== null) {
insertOrAppendPlacementNodeIntoContainer(child, before, parent);
var sibling = child.sibling;
// 只要兄弟节点不为null,继续递归调用
while (sibling !== null) {
insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
sibling = sibling.sibling;
}
}
}
}
这里react
主要做了两件事情:
- 如果是原生 DOM 节点,调用
insertInContainerBefore
或appendChildToContainer
来在相应的位置插入 DOM 节点 - 如果不是原生 DOM 节点,会对当前 Fiber 节点的所有子 Fiber 节点调用
insertOrAppendPlacementNodeIntoContainer
对自身进行遍历,直到找到 DOM 节点,然后插入
我们再看一看appendChildToContainer的实现:
function appendChildToContainer(container, child) {
var parentNode;
if (container.nodeType === COMMENT_NODE) {
parentNode = container.parentNode;
parentNode.insertBefore(child, container);
} else {
parentNode = container;
// 将子节点插入到父节点中
parentNode.appendChild(child);
var reactRootContainer = container._reactRootContainer;
if ((reactRootContainer === null || reactRootContainer === undefined) && parentNode.onclick === null) {
// TODO: This cast may not be sound for SVG, MathML or custom elements.
trapClickOnNonInteractiveElement(parentNode);
}
}
结合我们前面自己写的例子
class P extends React.Component {
render() {
return (
<span className="span">
<span>hello World!</span>
</span>
);
}
}
由于我们定义的组件非常简单,P组件只有一个span标签,所以这里的parentNode其实就是容器根节点,当执行完parentNode.appendChild(child)
,可以看到页面就出现了hello World!
了。
6.5 小结
至此,组件的虚拟dom生成,真实dom的创建,加工以及渲染全部执行完毕。
上面有多次提到fiber
节点,其实我们在创建完真实dom后,它还是会被加工成一个fiber节点,而此节点中通过child可以访问到自己的子节点,通过sibling获取自己的兄弟节点,最后通过return属性获取自己的父节点,通过这些属性为构建dom树提供了支撑,当然fiber我会另开一篇文章来解释,
好了,到这里,虽然我省略了很多内容,没想到还是写了有一万多字,关于react中的虚拟dom如何转变成真实dom也介绍完毕了。
回顾整个学习的过程,我对react
的理解还是比较生硬,许多内容看似理解但我却无法表述出来,不少地方都需要借鉴大佬的分析
作者水平有限,文章仅作为一篇个人的学习总结,如需借鉴还请谨慎。