【React深刻】深刻分析虚拟DOM的渲染原理和特性

image

导读

React的虚拟DOMDiff算法是React的很是重要的核心特性,这部分源码也很是复杂,理解这部分知识的原理对更深刻的掌握React是很是必要的。html

原本想将虚拟DOMDiff算法放到一篇文章,写完虚拟DOM发现文章已经很长了,因此本篇只分析虚拟DOMnode

本篇文章从源码出发,分析虚拟DOM的核心渲染原理(首次渲染),以及React对它作的性能优化点。react

说实话React源码真的很难读😅,若是本篇文章帮助到了你,那么请给个赞👍支持一下吧。git

开发中的常见问题

  • 为什么必须引用React
  • 自定义的React组件为什么必须大写
  • React如何防止XSS
  • ReactDiff算法和其余的Diff算法有何区别
  • keyReact中的做用
  • 如何写出高性能的React组件

若是你对上面几个问题还存在疑问,说明你对React的虚拟DOM以及Diff算法实现原理还有所欠缺,那么请好好阅读本篇文章吧。github

首先咱们来看看到底什么是虚拟DOM:算法

虚拟DOM

image

在原生的JavaScript程序中,咱们直接对DOM进行建立和更改,而DOM元素经过咱们监听的事件和咱们的应用程序进行通信。express

React会先将你的代码转换成一个JavaScript对象,而后这个JavaScript对象再转换成真实DOM。这个JavaScript对象就是所谓的虚拟DOMsegmentfault

好比下面一段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事件从而作出响应。

为什么使用虚拟DOM

React为什么采用VitrualDom这种方案呢?

提升开发效率

使用JavaScript,咱们在编写应用程序时的关注点在于如何更新DOM

使用React,你只须要告诉React你想让视图处于什么状态,React则经过VitrualDom确保DOM与该状态相匹配。你没必要本身去完成属性操做、事件处理、DOM更新,React会替你完成这一切。

这让咱们更关注咱们的业务逻辑而非DOM操做,这一点便可大大提高咱们的开发效率。

关于提高性能

不少文章说VitrualDom能够提高性能,这一说法其实是很片面的。

直接操做DOM是很是耗费性能的,这一点毋庸置疑。可是React使用VitrualDom也是没法避免操做DOM的。

若是是首次渲染,VitrualDom不具备任何优点,甚至它要进行更多的计算,消耗更多的内存。

VitrualDom的优点在于ReactDiff算法和批处理策略,React在页面更新以前,提早计算好了如何进行更新和渲染DOM。实际上,这个计算过程咱们在直接操做DOM时,也是能够本身判断和实现的,可是必定会耗费很是多的精力和时间,并且每每咱们本身作的是不如React好的。因此,在这个过程当中React帮助咱们"提高了性能"。

因此,我更倾向于说,VitrualDom帮助咱们提升了开发效率,在重复渲染时它帮助咱们计算如何更高效的更新,而不是它比DOM操做更快。

若是您对本部分的分析有什么不一样看法,欢迎在评论区拍砖。

跨浏览器兼容

image

React基于VitrualDom本身实现了一套本身的事件机制,本身模拟了事件冒泡和捕获的过程,采用了事件代理,批量更新等方法,抹平了各个浏览器的事件兼容性问题。

跨平台兼容

image

VitrualDomReact带来了跨平台渲染的能力。以React Native为例子。React根据VitrualDom画出相应平台的ui层,只不过不一样平台画的姿式不一样而已。

虚拟DOM实现原理

若是你不想看繁杂的源码,或者如今没有足够时间,能够跳过这一章,直接👇虚拟DOM原理总结

image

在上面的图上咱们继续进行扩展,按照图中的流程,咱们依次来分析虚拟DOM的实现原理。

JSX和createElement

咱们在实现一个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

下面咱们来看看虚拟DOM的真实模样,将下面的JSX代码在控制台打印出来:

<div className="title">
      <span>Hello ConardLi</span>
      <ul>
        <li>苹果</li>
        <li>橘子</li>
      </ul>
</div>

image

这个结构和咱们上面本身描绘的结构很像,那么React是如何将咱们的代码转换成这个结构的呢,下面咱们来看看createElement函数的具体实现(文中的源码通过精简)。

image

createElement函数内部作的操做很简单,将props和子元素进行处理后返回一个ReactElement对象,下面咱们来逐一分析:

(1).处理props:

image

  • 1.将特殊属性refkeyconfig中取出并赋值
  • 2.将特殊属性selfsourceconfig中取出并赋值
  • 3.将除特殊属性的其余属性取出并赋值给props

后面的文章会详细介绍这些特殊属性的做用。

(2).获取子元素

image

  • 1.获取子元素的个数 —— 第二个参数后面的全部参数
  • 2.若只有一个子元素,赋值给props.children
  • 3.如有多个子元素,将子元素填充为一个数组赋值给props.children

(3).处理默认props

image

  • 将组件的静态属性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

selfsource只有在非生产环境才会被加入对象中。

  • self指定当前位于哪一个组件实例。
  • _source指定调试代码来自的文件(fileName)和代码行数(lineNumber)。

虚拟DOM转换为真实DOM

上面咱们分析了代码转换成了虚拟DOM的过程,下面来看一下React如何将虚拟DOM转换成真实DOM

本部分逻辑较复杂,咱们先用流程图梳理一下整个过程,整个过程大概可分为四步:

image

过程1:初始参数处理

在编写好咱们的React组件后,咱们须要调用ReactDOM.render(element, container[, callback])将组件进行渲染。

render函数内部实际调用了_renderSubtreeIntoContainer,咱们来看看它的具体实现:

render: function (nextElement, container, callback) {
    return ReactMount._renderSubtreeIntoContainer(null, nextElement, container, callback);
  },

image

  • 1.将当前组件使用TopLevelWrapper进行包裹

TopLevelWrapper只一个空壳,它为你须要挂载的组件提供了一个rootID属性,并在render函数中返回该组件。

TopLevelWrapper.prototype.render = function () {
  return this.props.child;
};

ReactDOM.render函数的第一个参数能够是原生DOM也能够是React组件,包裹一层TopLevelWrapper能够在后面的渲染中将它们进行统一处理,而不用关心是否原生。

  • 2.判断根结点下是否已经渲染过元素,若是已经渲染过,判断执行更新或者卸载操做
  • 3.处理shouldReuseMarkup变量,该变量表示是否须要从新标记元素
  • 4.调用将上面处理好的参数传入_renderNewRootComponent,渲染完成后调用callback

_renderNewRootComponent中调用instantiateReactComponent对咱们传入的组件进行分类包装:

image

根据组件的类型,React根据原组件建立了下面四大类组件,对组件进行分类渲染:

  • ReactDOMEmptyComponent:空组件
  • ReactDOMTextComponent:文本
  • ReactDOMComponent:原生DOM
  • ReactCompositeComponent:自定义React组件

他们都具有如下三个方法:

  • construct:用来接收ReactElement进行初始化。
  • mountComponent:用来生成ReactElement对应的真实DOMDOMLazyTree
  • 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

image

  • 1.对特殊DOM标签、props进行处理。
  • 2.根据标签类型建立DOM节点。
  • 3.调用_updateDOMPropertiesprops插入到DOM节点,_updateDOMProperties也可用于props Diff,第一个参数为上次渲染的props,第二个参数为当前props,若第一个参数为空,则为首次建立。
  • 4.生成一个DOMLazyTree对象并调用_createInitialChildren将孩子节点渲染到上面。

那么为何不直接生成一个DOM节点而是要建立一个DOMLazyTree呢?咱们先来看看_createInitialChildren作了什么:

image

判断当前节点的dangerouslySetInnerHTML属性、孩子节点是否为文本和其余节点分别调用DOMLazyTreequeueHTMLqueueTextqueueChild

image

能够发现:DOMLazyTree其实是一个包裹对象,node属性中存储了真实的DOM节点,childrenhtmltext分别存储孩子、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是一个变量,当前浏览器是IEEdge时为true

IE(8-11)Edge浏览器中,一个一个插入无子孙的节点,效率要远高于插入一整个序列化完整的节点树。

因此lazyTree主要解决的是在IE(8-11)Edge浏览器中插入节点的效率问题,在后面的过程4咱们会分析到:若当前是IEEdge,则须要递归插入DOMLazyTree中缓存的子节点,其余浏览器只须要插入一次当前节点,由于他们的孩子已经被渲染好了,而不用担忧效率问题。

下面来看一下ReactCompositeComponent,因为代码很是多这里就再也不贴这个模块的代码,其内部主要作了如下几步:

  • 处理propscontex等变量,调用构造函数建立组件实例
  • 判断是否为无状态组件,处理state
  • 调用performInitialMount生命周期,处理子节点,获取markup
  • 调用componentDidMount生命周期

performInitialMount函数中,首先调用了componentWillMount生命周期,因为自定义的React组件并非一个真实的DOM,因此在函数中又调用了孩子节点的mountComponent。这也是一个递归的过程,当全部孩子节点渲染完成后,返回markup并调用componentDidMount

过程4:渲染html

mountComponentIntoNode函数中调用将上一步生成的markup插入container容器。

在首次渲染时,_mountImageIntoNode会清空container的子节点后调用DOMLazyTree.insertTreeBefore

image

判断是否为fragment节点或者<object>插件:

  • 若是是以上两种,首先调用insertTreeChildren将此节点的孩子节点渲染到当前节点上,再将渲染完的节点插入到html
  • 若是是其余节点,先将节点插入到插入到html,再调用insertTreeChildren将孩子节点插入到html
  • 若当前不是IEEdge,则不须要再递归插入子节点,只须要插入一次当前节点。

image

  • 判断不是IEbEdgereturn
  • children不为空,递归insertTreeBefore进行插入
  • 渲染html节点
  • 渲染文本节点

原生DOM事件代理

有关虚拟DOM的事件机制,我曾专门写过一篇文章,有兴趣能够👇【React深刻】React事件机制

虚拟DOM原理、特性总结

React组件的渲染流程

  • 使用React.createElementJSX编写React组件,实际上全部的JSX 代码最后都会转换成React.createElement(...) Babel帮助咱们完成了这个转换的过程。
  • createElement函数对keyref等特殊的props进行处理,并获取defaultProps对默认props进行赋值,而且对传入的孩子节点进行处理,最终构形成一个ReactElement对象(所谓的虚拟DOM)。
  • ReactDOM.render将生成好的虚拟DOM渲染到指定容器上,其中采用了批处理、事务等机制而且对特定浏览器进行了性能优化,最终转换为真实DOM

虚拟DOM的组成

ReactElementelement对象,咱们的组件最终会被渲染成下面的结构:

  • type:元素的类型,能够是原生html类型(字符串),或者自定义组件(函数或class
  • key:组件的惟一标识,用于Diff算法,下面会详细介绍
  • ref:用于访问原生dom节点
  • props:传入组件的propschidrenprops中的一个属性,它存储了当前组件的孩子节点,能够是数组(多个孩子节点)或对象(只有一个孩子节点)
  • owner:当前正在构建的Component所属的Component
  • self:(非生产环境)指定当前位于哪一个组件实例
  • _source:(非生产环境)指定调试代码来自的文件(fileName)和代码行数(lineNumber)

防止XSS

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等特殊节点,这些节点则不会一个一个插入渲染。

虚拟DOM事件机制

React本身实现了一套事件机制,其将全部绑定在虚拟DOM上的事件映射到真正的DOM事件,并将全部的事件都代理到document上,本身模拟了事件冒泡和捕获的过程,而且进行统一的事件分发。

React本身构造了合成事件对象SyntheticEvent,这是一个跨浏览器原生事件包装器。 它具备与浏览器原生事件相同的接口,包括stopPropagation() preventDefault() 等等,在全部浏览器中他们工做方式都相同。这抹平了各个浏览器的事件兼容性问题。

上面只分析虚拟DOM首次渲染的原理和过程,固然这并不包括虚拟 DOM进行 Diff的过程,下一篇文章咱们再来详细探讨。

关于开篇提的几个问题,咱们在下篇文章中进行统一回答。

推荐阅读

末尾

本文源码中的版本为React15版本,相对16版本会有一些出入,关于16版本的改动,后面的文章会单独分析。

文中若有错误,欢迎在评论区指正,或者您对文章的排版,阅读体验有什么好的建议,欢迎在评论区指出,谢谢阅读。

想阅读更多优质文章、下载文章中思惟导图源文件、阅读文中demo源码、可关注个人github博客,你的star✨、点赞和关注是我持续创做的动力!

推荐关注个人微信公众号【code秘密花园】,天天推送高质量文章,咱们一块儿交流成长。

图片描述

相关文章
相关标签/搜索