笔者将编写"React源码解析"系列文章三到四篇,阐述React内部的机制。欢迎大家关注我的掘金账号,以便能及时看到最新的文章更新推送。

当我们能够熟练运用react进行前端开发时,不免会对react内部机制产生浓厚的兴趣,组件是什么?是真的DOM吗?生命周期函数的执行依据又是什么呢?

本篇,我们先来研究React组件的实现与挂载。

1.组件是什么

首先编写一个最简单的组件,同时观察React是如何运作的:

上述代码写完后,我们就得到了<A />这个组件。首先我们要弄清楚一个问题,<A />是什么。用console.log打印出来:

可以看出,<A />其实是js对象而不是真实的DOM。注意此时props是空对象。接下来,我们打印<A><div>这是A组件</div></A>,看看控制台会输出什么:


我们看到,props发生了变化,由于<A />组件中嵌套了一个divdiv中又嵌套了文字,所以在描述<A />对象的props中增加了children属性,其值为描述div的js对象。同理,如果我们进行多层的组件嵌套,其实就是在父对象的props中增加children字段及对应的描述值,也就是js对象的多层嵌套。

以上描述的是基于ES6的React开发模式,其实在ES5中通过React.createClass({})方法创建的组件,与ES6中是完全一样的,同样可以通过控制台打印输出组件结果进行验证,此处不再赘述。

那么形如HTML标签实际上却是对象的React组件是如何构成的呢?

因为我们的组件声明基于ReactComponent,所以首先我们打开node_modules/react/lib/React.js,可以看到如下代码:

我们在import React from 'react'时,引入的就是源码中暴露出的React对象。在extends Component时,继承了Component类。这里需要多说两点:

  • 源码中明明使用的module.exports而不是export default,为什么还能够成功引入呢?其实这是babel解析器的功劳。它令(ES6)import === (CommonJS)module.export。而在typescript中,需要严格的export default声明,故在typescript下就不能使用import React from 'react'了,有兴趣的读者可以尝试一下。
  • 我们可以写extends Component也可以写extends React.Component,这两者是否存在区别呢?答案是否定的。因为ComponentReact.Component的引用。也就是说Component === React.Component,在实际项目中写哪个都可以。

沿着ReactComponent的线索,我们打开node_modules/react/lib/ReactComponent.js:

上述代码真是再熟悉不过的构造函数,想必大家已经滚瓜烂熟了。同时我们也注意到setState是定义在原型上具有两个参数的方法,具体原理我们将在React更新机制的篇章讲解。

上述代码表明,我们在最开始声明的组件A,其实是继承ReactComponent类的子类,它的原型具有setState等方法。这样组件A已经有了最基本的雏形。

2.组件的初始化

声明A后,我们可以在其内部自定义方法,也可以使用生命周期的方法,如ComponentDidMount等等,这些和我们在写"类"的时候是完全一样的。唯一不同的是组件类必须拥有render方法输出类似<div>这是A组件</div>的结构并挂载到真实DOM上,才能触发组件的生命周期并成为DOM树的一部分。首先我们观察ES6的"类"是如何初始化一个react组件的。

将最初的示例代码放入babel中:

其中_Component是对象ReactComponent_inherit方法是extends关键字的函数实现,这些都是ES6相关内容,我们暂时不管。关键在于我们发现render方法实际上是调用了React.createElement方法。然后我们打开node_modules/react/lib/ReactElement.js:

看到这里我们发现,其实每一个组件对象都是通过React.createElement方法创建出来的ReactElement类型的对象。换句话说,ReactElment是一种内部记录组件特征并告诉React你想在屏幕上看到什么的对象。
React.createElement方法传入三个参数:

参数 功能
type babel在解析jsx时会判断标签首字母大小写,大写为自定义组件,小写为原生HTML标签(string/ReactClass)
config 组件的配置项,如refkey等属性都记录在config
children 如果render的是单个组件,那么children参数为空,props为空;如果render的是嵌套组件,那么内部组件的相关信息将作为children参数传入并记录在props

React.createElement处理参数后返回ReactElement并创建出组件对象。在ReactElement中:

参数 功能
$$typeof 组件的标识信息
key DOM结构标识,提升update性能
props 子结构相关信息(有则增加children字段;没有为空)和组件属性(如style)
ref 真实DOM的引用
_owner _owner === ReactCurrentOwner.current(ReactCurrentOwner.js),值为创建当前组件的对象,默认值为null。

看完上述内容相信大家已经对React组件的实质有了一定的了解。通过执行React.createElement创建出的ReactElement类型的js对象,就是组件。进一步说,如果我们通过class关键字声明两个组件<A /><B />,那么他们在真正被挂载之前一直是ReactElement类型的js对象。

3.组件的挂载

我们知道可以通过ReactDOM.render(component,mountNode)的形式对自定义组件/原生DOM/字符串进行挂载,例如

ReactDOM.render('helloWorld',document.getElementById('root')

那么挂载的过程又是如何实现的呢?

我们打开node_modules/react/lib/ReactDOM.js:

由此在node_modules/react/lib/ReactMount.js:

var TopLevelWrapper = function () {
  this.rootID = topLevelRootCounter++;
};

TopLevelWrapper.prototype.render = function () {
  // this.props is actually a ReactElement
  return this.props;
};

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

    //若组件未挂载,那么将react组件挂载到DOM上,若组件已被挂载,那么将执行组件更新机制
    _renderSubtreeIntoContainer:function (parentComponent, nextElement, container, callback) {
        ReactUpdateQueue.validateCallback(callback, 'ReactDOM.render');

        //将组件添加到前一级wrapper的props属性下(前文已说明组件中props属性的作用)
        var nextWrappedElement = ReactElement(TopLevelWrapper, null, null, null, null, null, nextElement);
        var nextContext;

        //如果父组件存在,则存储在nextContext中,否则存储空对象
        if (parentComponent) {
            var parentInst = ReactInstanceMap.get(parentComponent);
            nextContext = parentInst._processChildContext(parentInst._context);
        }  else {
            nextContext = emptyObject;
        }

        //判断容器下是否已经存在组件,对于ReactDOM.render()来说为空
        var prevComponent = getTopLevelWrapperInContainer(container);

        //假设该容器已存在组件且类型和索引相同时,依据Diff算法只对当前组件进行更新,否则进行卸载
        if (prevComponent) {
            var prevWrappedElement = prevComponent._currentElement;
            var prevElement = prevWrappedElement.props;
            //组件更新机制在生命周期部分进行解析
            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);
        }
    }

        var reactRootElement = getReactRootElementInContainer(container);
        var containerHasReactMarkup = reactRootElement && !!internalGetID(reactRootElement);
        var containerHasNonRootReactChild = hasNonRootReactChild(container);

        //经过上述流程,确认容器为空或容器内的组件已卸载,那么调用_renderNewRootComponent插入DOM
        var component = ReactMount._renderNewRootComponent(nextWrappedElement, container, shouldReuseMarkup, nextContext)._renderedComponent.getPublicInstance();

        //此处说明ReactDOM.render可以传三个参数,包括回调函数
        if (callback) {
            callback.call(component);
        }

        return component;
}

先梳理一下流程:

ReactDOM.render => ReactMount.render => ReactMount._renderSubtreeIntoContainer

故我们来分析一下_renderSubtreeIntoContainer即可,细节流程已经注释在源码中:

参数 功能
parentComponent 当前组件的父组件,第一次渲染时为null
nextElement 要插入DOM中的组件,如helloWorld
container 要插入的容器,如document.getElementById('root')
callback 完成后的回调函数

由上面的分析,我们看到最后调用了._renderNewRootComponent,看下源码:

代码中batchedMountComponentIntoNode以事务的形式调用mountComponentIntoNode(事务将专门拿出一篇文章来解析),而mountComponentIntoNode最终调用的是_mountImageIntoNode,看下源码:

核心代码就是最后两行。setInnerHTML是一个方法,将markup设置为containerinnerHTML属性,这样就完成了DOM的插入。recacheNode方法是将处理好的组件对象存储在缓存中,提高结构更新的速度。

React组件初始化和挂载的流程到这里基本明朗了。另外在ReactDOM.render()的方法使用中,我们会注意到有以下几种情况:

这其中存在哪些区别呢?

在组件挂载的倒数第二步,也就是执行_renderNewRootComponent方法时,我们看到有一个名为instantiateReactComponent的方法返回一个经过加工的对象。我们看下instantiateReactComponent的源码:

我们在进行挂载时,传入的参数为nextElement(node参数)false,输入和输出可以总结如下表:

nextElement 实际参数 结果
null/false 创建ReactEmptyComponent组件
object && type === string 虚拟DOM 创建ReactDOMComponent组件
object && type !== string React组件 创建ReactCompositeComponent组件
string 字符串 创建ReactTextComponent组件
number 数字 创建ReactTextComponent组件

由上表我们看出,根据ReactDOM.render()传入不同的参数,React内部会创建四大类组件。这四大类组件我们最常用的就是ReactCompositeComponent组件,其内部具有完整的生命周期,最终返回HTML,也是React最关键的组件特性。关于生命周期的部分,我们在下一篇文章讲解。

总结一下我们在梳理源码之后所得到的结论吧:

  • 使用classcreateClass方法声明的React组件,在挂载之前是ReactElment类型的js对象;
  • 执行挂载流程,babel会解析出每个ReactElment类型的对象所包含的信息,转化为HTML标签插入给定的容器中。

最后我们用一张图来梳理React组件从声明到初始化再到挂载的流程:
(点击可查看大图)

回顾: