React渲染过程源码分析

2019-06-04  本文已影响0人  A长安

什么是虚拟DOM(Virtual DOM)

在传统的开发模式中,每次需要进行页面更新的时候都需要我们手动的更新DOM:

image

在前端开发中,最应该避免的就是DOM的更新,因为DOM更新是极其耗费性能的,有过操作DOM经历的都应该知道,修改DOM的代码也非常冗长,也会导致项目代码阅读困难。在React中,把真是得DOM转换成JavaScript对象树,这就是我们说的虚拟DOM,它并不是真正的DOM,只是存有渲染真实DOM需要的属性的对象。

image

虚拟DOM的好处

虽然虚拟DOM会提升一定得性能但是并不明显,因为每次需要更新的时候Virtual DOM需要比较两次的DOM有什么不同,然后批量更新,这也是需要资源的。

Virtual真实的好处其实是,他可以实现跨平台,我们所熟知的react-native就是基于VirtualDOM来实现的。

Virtual DOM实现

现在我们根据源码来分析一下Virtual DOM的构建过程。

JSX和React.createElement

在看源码之前,现在回顾一下React中创建组件的两种方式。

1.JSX

function App() {
  return (
    <div>Hello React</div>
  );
}

2.React.createElement

const App = React.createElement('div', null, 'Hello React');

这里多说一句其实JSX只不过是React.createElement的语法糖,在编译的时候babel会将JSX转换成为使用React.createElement的形式,因为JSX语法更加符合我们日常开发的习惯,所以我们在写React的时候更多的是使用JSX语法进行编写。

React.createElement都做了什么

下面粘贴一段React.createElement的源码来分析:

ReactElement.createElement = function(type, config, children) {
  //初始化参数
  var propName;
  var props = {};
  var key = null;
  var ref = null;
  var self = null;
  var source = null;

  if (config != null) {
    // 如果存在config,则提取里面的内容
    if (hasValidRef(config)) {
      ref = config.ref;
    }
    if (hasValidKey(config)) {
      key = '' + config.key;
    }
    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;
    // 将新添加的元素更新到新的props中
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

    //如果只有一个children参数,那么指直接赋值给children
    //否则合并处理children
  var childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    var childArray = Array(childrenLength);
    for (var i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    props.children = childArray;
  }

  // 如果某个prop为空,且存在默认的prop,则将默认的prop赋值给props
  if (type && type.defaultProps) {
    var defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }
  
 //返回一个ReactElement实例对象,这个可以理解就是我们说的虚拟DOM
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props,
  );
};

ReactElement与其中的安全机制

看到这里我们不禁好奇上述代码中返回的ReactElement到底是个什么东西呢?其实ReactElement就只是我们常说的虚拟DOM,ReactElement主要包含了这个DOM节点的类型(type)、属性(props)和子节点(children)。ReactElement只是包含了DOM节点的数据,还没有注入对应的一些方法来完成React框架的功能。

现在来看一下ReactElement的源码部分

var ReactElement = function (type, key, ref, self, source, owner, props) {
  var element = {
    // react中防止XSS注入的变量,也是标志这个是react元素的变量,稍后会讲
    $$typeof: REACT_ELEMENT_TYPE,

    // 构建属于这个元素的属性值
    type: type,
    key: key,
    ref: ref,
    props: props,

    // 记录一下创建这个元素的组件
    _owner: owner,
  };

  return element;
};

上述代码可以看出来,ReactElement其实就是装有各种属性的一个大对象而已。

$$typeof

首先我们现在控制台打印一下react.createElement的结果:

image

WHAT???这个变量是什么???

其实$$typeof是为了安全问题引入的变量,什么安全问题呢?那就是XSS

我们都知道React.createElement方法的第三个参数是允许用户输入自定义组件的,那么设想一下,如果前端允许用户输入下面一段代码:

var input = "{"type": "div",  "props": {"dangerouslySetInnerHTML": {"__html": "<script>alert('hey')</script>"}}}""

//然后我们开始用输入的值创建ReactElement,就变成了下面这个样子

React.createElement('div', null, input);

至此XSS注入就达成目的啦。

那么$$typeof这个变量是怎么做到安全认证的呢???

var REACT_ELEMENT_TYPE =
  (typeof Symbol === 'function' && Symbol.for && Symbol.for('react.element')) ||
  0xeac7;
  
  ReactElement.isValidElement = function (object) {
  return (
    typeof object === 'object' &&
    object !== null &&
    object.$$typeof === REACT_ELEMENT_TYPE
  );
};

首先typeof是Symbol类型的变量,是无法通过json对象转成字符串,所以就如果只是简单的json拷贝,是没有办法通过ReactElement.isValidElement的验证的,ReactElement.isValidElement会将不带有typeof变量的元素全部丢掉不用。

React的render过程

现在通过源码来看一下react中从定义完组件之后render到页面的过程。

1.ReactDOM.render

当我们想要将一个组件渲染到页面上需要调用ReactDOM.render(element,container,[callback])方法,现在我们就从这个方法入手一步一步来看源码:

var ReactDOM = {
  findDOMNode: findDOMNode,
  render: ReactMount.render,
  unmountComponentAtNode: ReactMount.unmountComponentAtNode,
  version: ReactVersion
};

从上面代码我们可以看到,我们经常调用的ReactDOM.render,其实是在调用ReactMount的render方法。所以我们现在来看ReactMount中的render方法都做了些什么。

/src/renderers/dom/client/ReactMount.js

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

2._renderSubtreeIntoContainer

现在我们终于找到了源头,那就是_renderSubtreeIntoContainer方法,我们在来看一下它是怎么样定义的,可以根据下面代码中的注释一步一步的来看:

  _renderSubtreeIntoContainer: function (
    parentComponent,
    nextElement,
    container,
    callback,
  ) {
    // 检验传入的callback是否符合标准,如果不符合,validateCallback会throw出
    //一个错误(内部调用了node_modules/fbjs/lib/invariant有invariant方法)
    ReactUpdateQueue.validateCallback(callback, 'ReactDOM.render');

    // 此处的TopLevelWrapper,只不过是将你传进来的type,进行一层包裹,并赋值ID,并会在TopLevelWrapper.render方法中返回你传入的值
    // 具体看源码,,所以个这东西只是一个包裹层
    var nextWrappedElement = React.createElement(TopLevelWrapper, {
      child: nextElement,
    });

    //判断之前是否渲染过此元素,如果有返回此元素,如果没有返回null
    var prevComponent = getTopLevelWrapperInContainer(container);

    if (prevComponent) {
      var prevWrappedElement = prevComponent._currentElement;
      var prevElement = prevWrappedElement.props.child;
      // 判断是否需要更新组件
      if (shouldUpdateReactComponent(prevElement, nextElement)) {
        var publicInst = prevComponent._renderedComponent.getPublicInstance();
        var updatedCallback =
          callback &&
          function () {
            callback.call(publicInst);
          };
        // 如果需要更新则调用组件更新方法,直接返回更新后的组件
        ReactMount._updateRootComponent(
          prevComponent,
          nextWrappedElement,
          nextContext,
          container,
          updatedCallback,
        );
        return publicInst;
      } else {
        // 不需要更新组件,那就把之前的组件卸载掉
        ReactMount.unmountComponentAtNode(container);
      }
    }

    // 返回当前容器的DOM节点,如果没有container返回null
    var reactRootElement = getReactRootElementInContainer(container);
    // 返回上面reactRootElement的data-reactid
    var containerHasReactMarkup =reactRootElement && !!internalGetID(reactRootElement);
    // 判断当前容器是不是有身为react元素的子元素
    var containerHasNonRootReactChild = hasNonRootReactChild(container);
    // 得到是否应该重复使用的标记变量
    var shouldReuseMarkup = containerHasReactMarkup && !prevComponent && !containerHasNonRootReactChild;



    // 将一个新的组件渲染到真是得DOM上
    var component = ReactMount._renderNewRootComponent(
      nextWrappedElement,
      container,
      shouldReuseMarkup,
      nextContext,
    )._renderedComponent.getPublicInstance();

    // 如果有callback函数那就执行这个回调函数,并且将其this只想component
    if (callback) {
      callback.call(component);
    }

    // 返回组件
    return component;
  },

根据上面的注释可以很容易理解上面的代码,现在我们总结一下_renderSubtreeIntoContainer方法的执行过程:

1.校验传入callback的格式是否符合规范
2.用TopLevelWrapper包裹层(带有reactID)包裹传入的type,这里说明一下,react.createElement这个方法的type值可以有三种分别是,原生标签的标签名字符串('div'、'span')、react component 、react fragment
3.判断是否渲染过此次准备渲染的元素,如果渲染过,则判断是否需要更新。
    3.1 如果需要更新则调用更新方法,并且直接将更新后的组件返回
    3.2 如果不需要更新,则卸载老组件
4.如果没渲染过,则处理shouldReuseMarkup变量
5.调用ReactMount._renderNewRootComponent将组将更新到DOM(此函数后面会分析)
6.返回组件

ReactMount._renderNewRootComponent(渲染组件,批次装载)

上面说到其实在_renderSubtreeIntoContainer方法中,最后使用了ReactMount._renderNewRootComponent进行进行组件的渲染,接下来我们看一下该方法的源码:

  _renderNewRootComponent: function (
    nextElement,
    container,
    shouldReuseMarkup,
    context,
  ) {
    // 监听window上面的滚动事件,缓存滚动变量,保证在滚动的时候页面不会触发重排
    ReactBrowserEventEmitter.ensureScrollValueMonitoring();
    //获取组件实例
    var componentInstance = instantiateReactComponent(nextElement, false);

    // 批处理,初始化render的过程是异步的,但是在render的时候componentWillMount或者componentDidMount生命中其中
    // 可能会执行更新变量的操作,这是react会将这些操作通过当前批次策略,统一处理。
    ReactUpdates.batchedUpdates(
      batchedMountComponentIntoNode, // *
      componentInstance,
      container,
      shouldReuseMarkup,
      context,
    );

    var wrapperID = componentInstance._instance.rootID;
    instancesByReactRootID[wrapperID] = componentInstance;
    // 返回实例
    return componentInstance;
  }

还是先来总结一下上面代码的过程:

1.监听滚动事件,缓存变量,避免滚动带来的重排
2.初始化组件实例
3.批量执行更新操作
react四大类组件

在上面代码执行过程的2中调用instantiateReactComponent创建了,组件的实例,其实组件类型有四种,具体看下图:

image

在这里我们还是看一下它的具体实现,然后分析一下过程:

function instantiateReactComponent(node, shouldHaveDebugID) {
  var instance;

  if (node === null || node === false) {
    // 空组件
    instance = ReactEmptyComponent.create(instantiateReactComponent);
  } else if (typeof node === 'object') {
    var element = node;
    if (typeof element.type === 'string') {
      // 原生DOM
      instance = ReactHostComponent.createInternalComponent(element);
    } else if (isInternalComponentType(element.type)) {
      instance = new element.type(element);
    } else {
      // react组件
      instance = new ReactCompositeComponentWrapper(element);
    }
  } else if (typeof node === 'string' || typeof node === 'number') {
    // 文本字符串
    instance = ReactHostComponent.createInstanceForText(node);
  } else {
    
  }
  return instance;
}

1.node为空时初始化空组件ReactEmptyComponent.create(instantiateReactComponent)
2.node类型是对象时,即是DOM标签或者自定义组件,那么如果element的类型是字符串,则初始化DOM标签组件ReactNativeComponent.createInternalComponent,否则初始化自定义组件ReactCompositeComponentWrapper
3.当node是字符串或者数字时,初始化文本组件ReactNativeComponent.createInstanceForText
4.其他情况不处理
批次装载

在_renderNewRootComponent代码中有一个方法后面我是打了星号的,batchedUpdate方法的第一个参数其实是个callback,这里也就是batchedMountComponentIntoNode,从方法名就可以很容易看出来他是一个批次装载组件的方法,他是定义在ReactMount上面的,来看一下他的具体实现吧。

function batchedMountComponentIntoNode(
  componentInstance,
  container,
  shouldReuseMarkup,
  context,
) {

  // 在batchedMountComponentIntoNode中,使用transaction.perform调用mountComponentIntoNode让其基于事务机制进行调用
  var transaction = ReactUpdates.ReactReconcileTransaction.getPooled(
    !shouldReuseMarkup && ReactDOMFeatureFlags.useCreateElement,
  );
  transaction.perform(
    mountComponentIntoNode,
    null,
    componentInstance,
    container,
    transaction,
    shouldReuseMarkup,
    context,
  );
  ReactUpdates.ReactReconcileTransaction.release(transaction);
}

事务机制以后再进行分析,这里就直接来看mountComponentIntoNode是如何将组件渲染成DOM节点的吧。

mountComponentIntoNode(生成DOM)

mountComponentIntoNode这个函数主要就是装载组件,并且将其插入到DOM中,话不多说,直接上源码,然后根据源码一步步的分析:

/**
 * Mounts this component and inserts it into the DOM.
 *
 * @param {ReactComponent} componentInstance The instance to mount.
 * @param {DOMElement} container DOM element to mount into.
 * @param {ReactReconcileTransaction} transaction
 * @param {boolean} shouldReuseMarkup If true, do not insert markup
 */

function mountComponentIntoNode(
  wrapperInstance,
  container,
  transaction,
  shouldReuseMarkup,
  context,
) {
  var markup = ReactReconciler.mountComponent(
    wrapperInstance,
    transaction,
    null,
    ReactDOMContainerInfo(wrapperInstance, container),
    context,
  );
  wrapperInstance._renderedComponent._topLevelWrapper = wrapperInstance;
  ReactMount._mountImageIntoNode(
    markup,
    container,
    wrapperInstance,
    shouldReuseMarkup,
    transaction,
  );
}

可以看到mountComponentIntoNode方法首先调用了ReactReconciler.mountComponent方法,而在ReactReconciler.mountComponent方法中其实是调用了上面四种react组件的mountComponent方法,前面的就不说了,我们直接来看一下四种组件中的mountComponent方法都干了什么吧。

 /src/renderers/dom/shared/ReactDOMComponent.js
  mountComponent: function (
    transaction,
    hostParent,
    hostContainerInfo,
    context,
  ) {
    var props = this._currentElement.props;
    switch (this._tag) {
      case 'audio':
      case 'form':
      case 'iframe':
      case 'img':
      case 'link':
      case 'object':
      case 'source':
      case 'video':

      ....

    // 创建容器
    var mountImage;
      var ownerDocument = hostContainerInfo._ownerDocument;
      var el;
      if (this._tag === 'script') {
          var div = ownerDocument.createElement('div');
          var type = this._currentElement.type;
          div.innerHTML = `<${type}></${type}>`;
          el = div.removeChild(div.firstChild);
        } else if (props.is) {
          el = ownerDocument.createElement(this._currentElement.type, props.is);
        } else {
          el = ownerDocument.createElement(this._currentElement.type);
        }
      }

      // 更新props,第一个参数是上次的props,第二个参数是最新的props,如果上一次的props为空那么就是新建状态
      this._updateDOMProperties(null, props, transaction);
      // 生成DOMLazyTree对象
      var lazyTree = DOMLazyTree(el);
      // 处理孩子节点
      this._createInitialChildren(transaction, props, context, lazyTree);
      mountImage = lazyTree;
    
    // 返回容器
    return mountImage;
  }

总结一下上述代码的执行过程,在这里我只截取了初次渲染时候执行的代码:
1.对特殊的标签进行处理,并且调用方法给出相应警告
2.创建DOM节点
3.调用_updateDOMProperties方法来处理props
4.生成DOMLazyTree
5.通过DOMLazyTree调用_createInitialChildren处理孩子节点。然后返回DOM节点

下面我们来看一下这个DOMLazyTree方法都干了些什么,还是上源码:

function queueChild(parentTree, childTree) {
  if (enableLazy) {
    parentTree.children.push(childTree);
  } else {
    parentTree.node.appendChild(childTree.node);
  }
}

function queueHTML(tree, html) {
  if (enableLazy) {
    tree.html = html;
  } else {
    setInnerHTML(tree.node, html);
  }
}

function queueText(tree, text) {
  if (enableLazy) {
    tree.text = text;
  } else {
    setTextContent(tree.node, text);
  }
}

function toString() {
  return this.node.nodeName;
}

function DOMLazyTree(node) {
  return {
    node: node,
    children: [],
    html: null,
    text: null,
    toString,
  };
}

DOMLazyTree.queueChild = queueChild;
DOMLazyTree.queueHTML = queueHTML;
DOMLazyTree.queueText = queueText;

从上述代码可以看到DOMLazyTree其实就是一个用来包裹节点信息的对象,里面有孩子节点,html节点,文本节点,并且提供了将这些节点插入到真是DOM中的方法,现在我们来看一下在_createInitialChildren方法中它是如何来使用这个lazyTree对象的:

  _createInitialChildren: function (transaction, props, context, lazyTree) {
    var innerHTML = props.dangerouslySetInnerHTML;
    if (innerHTML != null) {
      if (innerHTML.__html != null) {
        DOMLazyTree.queueHTML(lazyTree, innerHTML.__html);
      }
    } else {
      var contentToUse = CONTENT_TYPES[typeof props.children]
        ? props.children
        : null;
      var childrenToUse = contentToUse != null ? null : props.children;
      if (contentToUse != null) {
        if (contentToUse !== '') {
          DOMLazyTree.queueText(lazyTree, contentToUse);
        }
      } else if (childrenToUse != null) {
        var mountImages = this.mountChildren(
          childrenToUse,
          transaction,
          context,
        );
        for (var i = 0; i < mountImages.length; i++) {
          DOMLazyTree.queueChild(lazyTree, mountImages[i]);
        }
      }
    }
  }

判断当前节点的dangerouslySetInnerHTML属性、孩子节点是否为文本和其他节点分别调用DOMLazyTree的queueHTML、queueText、queueChild.

ReactCompositeComponent

在实例调用mountComponent时,在这里额外的说一下这个函数的执行过程,ReactCompositeComponent也就是我们说的react自定义组件,起主要的执行过程如下:

1.处理props、contex等变量,调用构造函数创建组件实例
2.判断是否为无状态组件,处理state
3.调用performInitialMount生命周期,处理子节点,获取markup。
4.调用componentDidMount生命周期

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

渲染DOM

在上述mountComponentIntoNode中最后一步是执行_mountImageIntoNode方法,在该方法中核心的渲染方法就是insertTreeBefore,我们直接来看这个方法的源码,然后进行分析:


var insertTreeBefore = function(
  parentNode,
  tree,
  referenceNode,
) {

  if (
    tree.node.nodeType === DOCUMENT_FRAGMENT_NODE_TYPE ||
    (tree.node.nodeType === ELEMENT_NODE_TYPE &&
      tree.node.nodeName.toLowerCase() === 'object' &&
      (tree.node.namespaceURI == null ||
        tree.node.namespaceURI === DOMNamespaces.html))
  ) {
    insertTreeChildren(tree);
    parentNode.insertBefore(tree.node, referenceNode);
  } else {
    parentNode.insertBefore(tree.node, referenceNode);
    insertTreeChildren(tree);
  }
}

function insertTreeChildren(tree) {
  if (!enableLazy) {
    return;
  }
  var node = tree.node;
  var children = tree.children;
  if (children.length) {
    for (var i = 0; i < children.length; i++) {
      insertTreeBefore(node, children[i], null);
    }
  } else if (tree.html != null) {
    setInnerHTML(node, tree.html);
  } else if (tree.text != null) {
    setTextContent(node, tree.text);
  }
}

1.该方法首先就是判断当前节点是不是fragment节点或者Object插件
2.如果满足条件1,首先调用insertTreeChildren将此节点的孩子节点渲染到当前节点上,再将渲染完的节点插入到html
3.如果不满足1,是其他节点,先将节点插入到插入到html,再调用insertTreeChildren将孩子节点插入到html

在此过程中已经一次调用了setInnerHTML或setTextContent来分别渲染html节点和文本节点。

结尾

上述文章就是react的初次渲染过程分析,如果有哪些地方写的不对,欢迎在评论中讨论。本文代码采用的react15中的代码,和react最新版本代码会有一些的出入

上一篇下一篇

猜你喜欢

热点阅读