React
的虚拟DOM
和Diff
算法是React
的很是重要的核心特性,这部分源码也很是复杂,理解这部分知识的原理对更深刻的掌握React
是很是必要的。html
原本想将虚拟DOM
和Diff
算法放到一篇文章,写完虚拟DOM
发现文章已经很长了,因此本篇只分析虚拟DOM
。node
本篇文章从源码出发,分析虚拟DOM
的核心渲染原理(首次渲染),以及React
对它作的性能优化点。react
说实话React
源码真的很难读😅,若是本篇文章帮助到了你,那么请给个赞👍支持一下吧。git
React
React
组件为什么必须大写React
如何防止XSS
React
的Diff
算法和其余的Diff
算法有何区别key
在React
中的做用React
组件若是你对上面几个问题还存在疑问,说明你对React
的虚拟DOM
以及Diff
算法实现原理还有所欠缺,那么请好好阅读本篇文章吧。github
首先咱们来看看到底什么是虚拟DOM
:算法
在原生的JavaScript
程序中,咱们直接对DOM
进行建立和更改,而DOM
元素经过咱们监听的事件和咱们的应用程序进行通信。express
而React
会先将你的代码转换成一个JavaScript
对象,而后这个JavaScript
对象再转换成真实DOM
。这个JavaScript
对象就是所谓的虚拟DOM
。segmentfault
好比下面一段html
代码:api
<div class="title"> <span>Hello ConardLi</span> <ul> <li>苹果</li> <li>橘子</li> </ul> </div>
在React
可能存储为这样的JS
代码:数组
const VitrualDom = { type: 'div', props: { class: 'title' }, children: [ { type: 'span', children: 'Hello ConardLi' }, { type: 'ul', children: [ { type: 'li', children: '苹果' }, { type: 'li', children: '橘子' } ] } ] }
当咱们须要建立或更新元素时,React
首先会让这个VitrualDom
对象进行建立和更改,而后再将VitrualDom
对象渲染成真实DOM
;
当咱们须要对DOM
进行事件监听时,首先对VitrualDom
进行事件监听,VitrualDom
会代理原生的DOM
事件从而作出响应。
React
为什么采用VitrualDom
这种方案呢?
使用JavaScript
,咱们在编写应用程序时的关注点在于如何更新DOM
。
使用React
,你只须要告诉React
你想让视图处于什么状态,React
则经过VitrualDom
确保DOM
与该状态相匹配。你没必要本身去完成属性操做、事件处理、DOM
更新,React
会替你完成这一切。
这让咱们更关注咱们的业务逻辑而非DOM
操做,这一点便可大大提高咱们的开发效率。
不少文章说VitrualDom
能够提高性能,这一说法其实是很片面的。
直接操做DOM
是很是耗费性能的,这一点毋庸置疑。可是React
使用VitrualDom
也是没法避免操做DOM
的。
若是是首次渲染,VitrualDom
不具备任何优点,甚至它要进行更多的计算,消耗更多的内存。
VitrualDom
的优点在于React
的Diff
算法和批处理策略,React
在页面更新以前,提早计算好了如何进行更新和渲染DOM
。实际上,这个计算过程咱们在直接操做DOM
时,也是能够本身判断和实现的,可是必定会耗费很是多的精力和时间,并且每每咱们本身作的是不如React
好的。因此,在这个过程当中React
帮助咱们"提高了性能"。
因此,我更倾向于说,VitrualDom
帮助咱们提升了开发效率,在重复渲染时它帮助咱们计算如何更高效的更新,而不是它比DOM
操做更快。
若是您对本部分的分析有什么不一样看法,欢迎在评论区拍砖。
React
基于VitrualDom
本身实现了一套本身的事件机制,本身模拟了事件冒泡和捕获的过程,采用了事件代理,批量更新等方法,抹平了各个浏览器的事件兼容性问题。
VitrualDom
为React
带来了跨平台渲染的能力。以React Native
为例子。React
根据VitrualDom
画出相应平台的ui
层,只不过不一样平台画的姿式不一样而已。
若是你不想看繁杂的源码,或者如今没有足够时间,能够跳过这一章,直接👇虚拟DOM原理总结
在上面的图上咱们继续进行扩展,按照图中的流程,咱们依次来分析虚拟DOM
的实现原理。
咱们在实现一个React
组件时能够选择两种编码方式,第一种是使用JSX
编写:
class Hello extends Component { render() { return <div>Hello ConardLi</div>; } }
第二种是直接使用React.createElement
编写:
class Hello extends Component { render() { return React.createElement('div', null, `Hello ConardLi`); } }
实际上,上面两种写法是等价的,JSX
只是为 React.createElement(component, props, ...children)
方法提供的语法糖。也就是说全部的JSX
代码最后都会转换成React.createElement(...)
,Babel
帮助咱们完成了这个转换的过程。
以下面的JSX
<div> <img src="avatar.png" className="profile" /> <Hello /> </div>;
将会被Babel
转换为
React.createElement("div", null, React.createElement("img", { src: "avatar.png", className: "profile" }), React.createElement(Hello, null));
注意,babel
在编译时会判断JSX
中组件的首字母,当首字母为小写时,其被认定为原生DOM
标签,createElement
的第一个变量被编译为字符串;当首字母为大写时,其被认定为自定义组件,createElement
的第一个变量被编译为对象;
另外,因为JSX
提早要被Babel
编译,因此JSX
是不能在运行时动态选择类型的,好比下面的代码:
function Story(props) { // Wrong! JSX type can't be an expression. return <components[props.storyType] story={props.story} />; }
须要变成下面的写法:
function Story(props) { // Correct! JSX type can be a capitalized variable. const SpecificStory = components[props.storyType]; return <SpecificStory story={props.story} />; }
因此,使用JSX
你须要安装Babel
插件babel-plugin-transform-react-jsx
{ "plugins": [ ["transform-react-jsx", { "pragma": "React.createElement" }] ] }
下面咱们来看看虚拟DOM
的真实模样,将下面的JSX
代码在控制台打印出来:
<div className="title"> <span>Hello ConardLi</span> <ul> <li>苹果</li> <li>橘子</li> </ul> </div>
这个结构和咱们上面本身描绘的结构很像,那么React
是如何将咱们的代码转换成这个结构的呢,下面咱们来看看createElement
函数的具体实现(文中的源码通过精简)。
createElement
函数内部作的操做很简单,将props
和子元素进行处理后返回一个ReactElement
对象,下面咱们来逐一分析:
(1).处理props:
ref
、key
从config
中取出并赋值self
、source
从config
中取出并赋值props
后面的文章会详细介绍这些特殊属性的做用。
(2).获取子元素
props.children
props.children
(3).处理默认props
defaultProps
定义的默认props
进行赋值ReactElement
ReactElement
将传入的几个属性进行组合,并返回。
type
:元素的类型,能够是原生html类型(字符串),或者自定义组件(函数或class
)key
:组件的惟一标识,用于Diff
算法,下面会详细介绍ref
:用于访问原生dom
节点props
:传入组件的props
owner
:当前正在构建的Component
所属的Component
$$typeof
:一个咱们不常见到的属性,它被赋值为REACT_ELEMENT_TYPE
:
var REACT_ELEMENT_TYPE = (typeof Symbol === 'function' && Symbol.for && Symbol.for('react.element')) || 0xeac7;
可见,$$typeof
是一个Symbol
类型的变量,这个变量能够防止XSS
。
若是你的服务器有一个漏洞,容许用户存储任意JSON
对象, 而客户端代码须要一个字符串,这可能会成为一个问题:
// JSON let expectedTextButGotJSON = { type: 'div', props: { dangerouslySetInnerHTML: { __html: '/* put your exploit here */' }, }, }; let message = { text: expectedTextButGotJSON }; <p> {message.text} </p>
JSON
中不能存储Symbol
类型的变量。
ReactElement.isValidElement
函数用来判断一个React
组件是不是有效的,下面是它的具体实现。
ReactElement.isValidElement = function (object) { return typeof object === 'object' && object !== null && object.$$typeof === REACT_ELEMENT_TYPE; };
可见React
渲染时会把没有$$typeof
标识,以及规则校验不经过的组件过滤掉。
当你的环境不支持Symbol
时,$$typeof
被赋值为0xeac7
,至于为何,React
开发者给出了答案:
0xeac7
看起来有点像React
。
self
、source
只有在非生产环境才会被加入对象中。
self
指定当前位于哪一个组件实例。_source
指定调试代码来自的文件(fileName
)和代码行数(lineNumber
)。上面咱们分析了代码转换成了虚拟DOM
的过程,下面来看一下React
如何将虚拟DOM
转换成真实DOM
。
本部分逻辑较复杂,咱们先用流程图梳理一下整个过程,整个过程大概可分为四步:
过程1:初始参数处理
在编写好咱们的React
组件后,咱们须要调用ReactDOM.render(element, container[, callback])
将组件进行渲染。
render
函数内部实际调用了_renderSubtreeIntoContainer
,咱们来看看它的具体实现:
render: function (nextElement, container, callback) { return ReactMount._renderSubtreeIntoContainer(null, nextElement, container, callback); },
TopLevelWrapper
进行包裹TopLevelWrapper
只一个空壳,它为你须要挂载的组件提供了一个rootID
属性,并在render
函数中返回该组件。
TopLevelWrapper.prototype.render = function () { return this.props.child; };
ReactDOM.render
函数的第一个参数能够是原生DOM
也能够是React
组件,包裹一层TopLevelWrapper
能够在后面的渲染中将它们进行统一处理,而不用关心是否原生。
shouldReuseMarkup
变量,该变量表示是否须要从新标记元素_renderNewRootComponent
,渲染完成后调用callback
。在_renderNewRootComponent
中调用instantiateReactComponent
对咱们传入的组件进行分类包装:
根据组件的类型,React
根据原组件建立了下面四大类组件,对组件进行分类渲染:
ReactDOMEmptyComponent
:空组件ReactDOMTextComponent
:文本ReactDOMComponent
:原生DOM
ReactCompositeComponent
:自定义React
组件他们都具有如下三个方法:
construct
:用来接收ReactElement
进行初始化。mountComponent
:用来生成ReactElement
对应的真实DOM
或DOMLazyTree
。unmountComponent
:卸载DOM
节点,解绑事件。具体是如何渲染咱们在过程3中进行分析。
过程2:批处理、事务调用
在_renderNewRootComponent
中使用ReactUpdates.batchedUpdates
调用batchedMountComponentIntoNode
进行批处理。
ReactUpdates.batchedUpdates(batchedMountComponentIntoNode, componentInstance, container, shouldReuseMarkup, context);
在batchedMountComponentIntoNode
中,使用transaction.perform
调用mountComponentIntoNode
让其基于事务机制进行调用。
transaction.perform(mountComponentIntoNode, null, componentInstance, container, transaction, shouldReuseMarkup, context);
关于批处理事务,在我前面的分析setState执行机制中有更多介绍。
过程3:生成html
在mountComponentIntoNode
函数中调用ReactReconciler.mountComponent
生成原生DOM
节点。
mountComponent
内部其实是调用了过程1生成的四种对象的mountComponent
方法。首先来看一下ReactDOMComponent
:
DOM
标签、props
进行处理。DOM
节点。_updateDOMProperties
将props
插入到DOM
节点,_updateDOMProperties
也可用于props Diff
,第一个参数为上次渲染的props
,第二个参数为当前props
,若第一个参数为空,则为首次建立。DOMLazyTree
对象并调用_createInitialChildren
将孩子节点渲染到上面。那么为何不直接生成一个DOM
节点而是要建立一个DOMLazyTree
呢?咱们先来看看_createInitialChildren
作了什么:
判断当前节点的dangerouslySetInnerHTML
属性、孩子节点是否为文本和其余节点分别调用DOMLazyTree
的queueHTML
、queueText
、queueChild
。
能够发现:DOMLazyTree
其实是一个包裹对象,node
属性中存储了真实的DOM
节点,children
、html
、text
分别存储孩子、html节点和文本节点。
它提供了几个方法用于插入孩子、html
以及文本节点,这些插入都是有条件限制的,当enableLazy
属性为true
时,这些孩子、html
以及文本节点会被插入到DOMLazyTree
对象中,当其为false
时会插入到真实DOM
节点中。
var enableLazy = typeof document !== 'undefined' && typeof document.documentMode === 'number' || typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string' && /\bEdge\/\d/.test(navigator.userAgent);
可见:enableLazy
是一个变量,当前浏览器是IE
或Edge
时为true
。
在IE(8-11)
和Edge
浏览器中,一个一个插入无子孙的节点,效率要远高于插入一整个序列化完整的节点树。
因此lazyTree
主要解决的是在IE(8-11)
和Edge
浏览器中插入节点的效率问题,在后面的过程4咱们会分析到:若当前是IE
或Edge
,则须要递归插入DOMLazyTree
中缓存的子节点,其余浏览器只须要插入一次当前节点,由于他们的孩子已经被渲染好了,而不用担忧效率问题。
下面来看一下ReactCompositeComponent
,因为代码很是多这里就再也不贴这个模块的代码,其内部主要作了如下几步:
props
、contex
等变量,调用构造函数建立组件实例state
performInitialMount
生命周期,处理子节点,获取markup
。componentDidMount
生命周期在performInitialMount
函数中,首先调用了componentWillMount
生命周期,因为自定义的React
组件并非一个真实的DOM,因此在函数中又调用了孩子节点的mountComponent
。这也是一个递归的过程,当全部孩子节点渲染完成后,返回markup
并调用componentDidMount
。
过程4:渲染html
在mountComponentIntoNode
函数中调用将上一步生成的markup
插入container
容器。
在首次渲染时,_mountImageIntoNode
会清空container
的子节点后调用DOMLazyTree.insertTreeBefore
:
判断是否为fragment
节点或者<object>
插件:
insertTreeChildren
将此节点的孩子节点渲染到当前节点上,再将渲染完的节点插入到html
html
,再调用insertTreeChildren
将孩子节点插入到html
。IE
或Edge
,则不须要再递归插入子节点,只须要插入一次当前节点。IE
或bEdge
时return
children
不为空,递归insertTreeBefore
进行插入有关虚拟DOM
的事件机制,我曾专门写过一篇文章,有兴趣能够👇【React深刻】React事件机制
React.createElement
或JSX
编写React
组件,实际上全部的JSX
代码最后都会转换成React.createElement(...)
,Babel
帮助咱们完成了这个转换的过程。createElement
函数对key
和ref
等特殊的props
进行处理,并获取defaultProps
对默认props
进行赋值,而且对传入的孩子节点进行处理,最终构形成一个ReactElement
对象(所谓的虚拟DOM
)。ReactDOM.render
将生成好的虚拟DOM
渲染到指定容器上,其中采用了批处理、事务等机制而且对特定浏览器进行了性能优化,最终转换为真实DOM
。即ReactElement
element对象,咱们的组件最终会被渲染成下面的结构:
type
:元素的类型,能够是原生html类型(字符串),或者自定义组件(函数或class
)key
:组件的惟一标识,用于Diff
算法,下面会详细介绍ref
:用于访问原生dom
节点props
:传入组件的props
,chidren
是props
中的一个属性,它存储了当前组件的孩子节点,能够是数组(多个孩子节点)或对象(只有一个孩子节点)owner
:当前正在构建的Component
所属的Component
self
:(非生产环境)指定当前位于哪一个组件实例_source
:(非生产环境)指定调试代码来自的文件(fileName
)和代码行数(lineNumber
)ReactElement
对象还有一个$$typeof`属性,它是一个`Symbol`类型的变量`Symbol.for('react.element')`,当环境不支持`Symbol`时,`$$typeof
被赋值为0xeac7
。
这个变量能够防止XSS
。若是你的服务器有一个漏洞,容许用户存储任意JSON
对象, 而客户端代码须要一个字符串,这可能为你的应用程序带来风险。JSON
中不能存储Symbol
类型的变量,而React
渲染时会把没有$$typeof
标识的组件过滤掉。
React
在渲染虚拟DOM
时应用了批处理以及事务机制,以提升渲染性能。
关于批处理以及事务机制,在我以前的文章【React深刻】setState的执行机制中有详细介绍。
在IE(8-11)
和Edge
浏览器中,一个一个插入无子孙的节点,效率要远高于插入一整个序列化完整的节点树。
React
经过lazyTree
,在IE(8-11)
和Edge
中进行单个节点依次渲染节点,而在其余浏览器中则首先将整个大的DOM
结构构建好,而后再总体插入容器。
而且,在单独渲染节点时,React
还考虑了fragment
等特殊节点,这些节点则不会一个一个插入渲染。
React
本身实现了一套事件机制,其将全部绑定在虚拟DOM
上的事件映射到真正的DOM
事件,并将全部的事件都代理到document
上,本身模拟了事件冒泡和捕获的过程,而且进行统一的事件分发。
React
本身构造了合成事件对象SyntheticEvent
,这是一个跨浏览器原生事件包装器。 它具备与浏览器原生事件相同的接口,包括stopPropagation()
和 preventDefault()
等等,在全部浏览器中他们工做方式都相同。这抹平了各个浏览器的事件兼容性问题。
上面只分析虚拟DOM
首次渲染的原理和过程,固然这并不包括虚拟 DOM
进行 Diff
的过程,下一篇文章咱们再来详细探讨。
关于开篇提的几个问题,咱们在下篇文章中进行统一回答。
本文源码中的版本为React
15版本,相对16
版本会有一些出入,关于16
版本的改动,后面的文章会单独分析。
文中若有错误,欢迎在评论区指正,或者您对文章的排版,阅读体验有什么好的建议,欢迎在评论区指出,谢谢阅读。
想阅读更多优质文章、下载文章中思惟导图源文件、阅读文中demo
源码、可关注个人github博客,你的star✨、点赞和关注是我持续创做的动力!
推荐关注个人微信公众号【code秘密花园】,天天推送高质量文章,咱们一块儿交流成长。