React原理解析

45 分钟读完

阅读对象:了解React基本功能但不明白其原理的人。

参考资料:

React做了什么?

在谈论react原理之前,我们要先明白React做了哪些事情。

  • 组件复用
  • 视图数据在一定程度上解耦了
  • 接管了DOM操作,避免不必要的渲染

让我们来分析一下这些功能:

组件复用

所谓组件,指有一定功能的模块。大家在开发中是否写过自定义的下拉列表,如果没有可以尝试一下。或许写一个原生列表并不是很难,但是一个网站中可能要用到很多相似的下拉列表,他们之间只是列表项不一样,然而你却得每个地方都要写一个,很麻烦。

这时候比较好的解决方案是什么:

一个预先定义好的模块,我只需要告诉他下拉列表项的内容 ,他就能在任何地方工作。

这就是组件做的事情,它带来了这些好处:

  • 一个可以复用的模块
  • 模块和模块是按照逻辑划分的,易于使用和管理。

MV*(解耦)

M(Model):数据模型,包括数据的各种操作逻辑

V(View):视图

在原生JS中,我们想要改变网页中的数据,要进行一大堆DOM操作,而这些操作都混杂在描述页面视图的代码中,这样就很容易造成混乱。而React的MV架构,带来的是这样的:

  • 数据只能被当作组件的属性(props)传入组件
  • 组件内部只能使用属性中的数据
  • 数据只能由父组件传递给子组件

乍一看似乎看不出来有什么好处,但是应用中的数据却变成了这样:

从上往下,一条清晰无比的数据流。之所以清晰,归功于数据只能通过组件的props传递。在组件中,你需要考虑的只是props中的数据,因为其他数据你是根本拿不到的。这样就不会被其他的庞大数据所困扰。

接管DOM操作

你可能听说过DOM操作是十分“昂贵”的,你知道为什么吗?我们可以在浏览器的控制台中打印出一个HTML元素,会得一个HTML对象,你可以展开看看这个对象有多数属性和方法。大概200多个!随便一个HTML标签就是一个这么庞大的对象,操作起来岂能不昂贵?这200多个属性对应着我们可以从HTML元素中取到的所有属性(位置,样式,事件,名称等等)。

于是乎就诞生了一种思路,当我们在操作DOM(增删改)的时候,没有必要在DOM上操作,因为大多数时候我们根本用不到一个元素的200多个属性,而是以其他形式操作,操作完之后,再反映在DOM上。这种思路对应的技术,也就是Virtual DOM,虚拟DOM,简称VDOM。

VDOM也是用对象描述元素,但是VDOM对象只拥有很少的,必要的属性。

VDOM的逻辑,我画了一张图:

初始化时,VDOM生成一个初始的DOM。(这一步图中没画)

然后之后的每次DOM操作,都由VDOM来完成,因为VDOM对象要轻量很多,所以操作效率很快。

VDOM进行操作后不会立即更改DOM,而是拿到一个操作后的结果。

然后VDOM会用这个操作后的结果和操作前的比较,通过算法得出需要变化的节点。然后对DOM做出最小的改变。


React做的三件事情解释完了,下面就来讲讲React是如何实现这些功能的。(具体的实现比较复杂,恕我没有能力讲的很清楚。)

React的工作流程

使用React.createElement()创建一组React元素(一个React元素可能包含其他React元素),这样就创建了一个层层嵌套的React元素”树“,然后我们再使用ReactDOM.render()将这个“元素树“的 ”根元素“ 挂载到真实DOM节点上。

注 :更多时候我们使用的是JSX创建React元素,其实不过是cerateElement的语法糖,本质上是一样的。

创建React元素

createElement,顾名思义,创建了一个Element(元素),但是这个元素可不是DOM元素,而是React元素。换句话说,这可不是那个有200多个属性的对象,而是只有很少属性的一个对象。也就是说,React元素本质上是个对象。

我们来看看createElement创建一个React元素需要哪些东西:

React.createElement(
	type, //元素类型
    [props],  //元素相关的属性
    [...children]  //元素的子元素
)

注:这里的type参数,既可以是html标签名,也可以是我们定义的Component类(这个后面会讲)。

使用这个方法后,生成了类似这样一个对象(即一个React元素):

{
    type: 'ElementType',  //可能是'h1','div',或其他自定义Component类名
    props: {
    	className: 'anyClass',
        id: 'anyId',
        onclick: function{...},
        children: [...children], //子元素
        ...
    },
    key: '#Hd&naFGswd', //该元素的key,这个后面会讲
}

嗯,这个对象有类型type,携带的属性props,以及键值key等等,当然在react源码中可能还会有其他属性,这里简化了。

组件(Component)

组件的概念不是哪个框架所独有的,而成为了一种共识,那么我们来想想组件到底是干什么的?

网页的基本单位是HTML标签,但是这些标签功能有限。然而有时我们想实现一个更复杂的“标签”,并且想要如同使用原生标签一般可以在任何地方使用它,即我们只需要给它设置一系列props,它就能如我们所预料般地工作。这时我们想要自定义地标签就是组件。

所以组件是这样发挥功能的:

  • 由原生的HTML元素或其他组件组成,
  • 接收一堆属性(用于指示组件如何工作)

前面我们挖了一个坑,说React元素的type属性也可以是一个Component类(class),下面我们就来看看这个类是如何工作的:

  • 这个类接受一堆属性(props)
  • 这个类的render函数返回一堆React元素
  • 这个类在被渲染时会调用render函数,渲染render函数返回的元素集合
  • 这个类在”装载”,”更新”,”卸载”的过程中会调用一些生命周期函数(如果有的话)

在React中,组件也是一个React元素。其唯一不同就在于组件元素的type属性不是一个字符串,而是一个类。这个类哪来的?

注:“类”是ES6加入JavaScript的概念,其本质只是一个构造函数,在浏览器控制台打印一个类得到的为function类型,而类的实例则是构造函数生成的一个对象。

想想我们定义组件的时候是怎么定义的?

import React from 'react';

class MyComponent extends React.Component{
    constructor(props){
        ...
    }
    ...
    render(){
        ...
    }
}

一般都是上面这种,当然也有这种函数组件:

function MyComponent(props) {
    return (...)
}

也就是说,我们定义组件的时候,只是定义了一个类(或函数)。

而这个类名,在将来createElement的时候,就会赋给React元素中的type属性。

让我们来想想组件类中可能会有哪些信息:

  • 构造函数:指示”生成“当前组件要用到哪些数据
  • 自定义函数:这些函数一般都在render函数中被调用,当作工具处理一些功能
  • render函数:指示这个组件“生成”后会得到怎样一组React元素
  • 生命周期函数:指示该组件在“生成”,”更新“过程(生成、更新过程在哪里发生我们后面会讲)中的特定阶段会进行哪些操作。

嗯,当我们有一个组件类时,我们已经有了生成一个组件所需要的全部准备了。

ReactDOM.render阶段

React工作的最后一步,就是我们拿到一个已经创建好的React元素(通常是根元素),通过ReactDOM.render()方法,渲染成真正的DOM元素,然后挂载到指定的DOM节点上。像这样:

render工作方式

注:此render方法不是组件中的render方法,不要混淆了。

话是这么说,可ReactDOM.render()做的事情可不是那么简单的:

render函数并不会每次被调用的时候都将DOM树重新渲染出来,挂载到DOM节点上。

因为一个DOM树是从VDOM(即我们的React元素)渲染出来的,而React持有当前的VDOM,所以当新的VDOM(即新传给render的React元素)到来时,render函数内部会将两个VDOM进行比较,得出一种在已存在的DOM上进行更新的最佳方案。只需要操作最少量的DOM节点,就能得到想要的改变。

tips:这里有一个官方的例子,虽然每次都调用了render函数,但你可以在浏览器的开发者工具栏中看到,只有最少的DOM元素被改变了(而不是完全重新渲染)。

React工作流程背后的原理

React的流程已经整理清楚了,就让我们来看看React到底是如何实现这样的流程的吧。

元素类型声明阶段

上文已提到,React元素类型(type)有几种:原生HTML标签对应的字符串,自定义组件类的类名。字符串type没有什么多说的,直接拿来用就可以了。而组件类却要提前定义。

我们定义的组件继承了React.Component类,这个类是个抽象类,其中应该定义了一些特殊函数:

  • constructor,render,以及其他一些生命周期钩子函数
  • setState(),forceUpdate()这些触发组件更新的函数
  • 一些类属性
TODO#1-定义React对象的Component类
React = {
    Component: Component,
};
    
function Component(props, context, updater) {
	this.props = props;
    this.state = null; //由开发者自己定义
    //context和updater先忽略
}
Component.prototype.render = function() {
    //这里只需要供子类继承,不需要实现
    //而是由开发者在定义组件的时候自己确定返回的React元素
}
Component.prototype.setState = function(partialState) {
    //这个后面会实现
}

创建元素功能

注:下面不特别说明的话,所有“元素”指的都是React元素。

即调用React.createElement()后得到一个元素对象。

TODO#2-定义一个React元素生成函数
function ReactElement(type, props, key) {
    var element = {
        type,
        props,
        key,
    }
    //react还会添加其他属性,如ref,并且会冻结生成的element对象
    return element;
}
TODO#3-定义一个React对象的createElement函数
/**
 * DO:创建一个React元素
 *
 * @params:[string|function] type => 元素的类型
 * @params:[Array] props => 元素携带的属性
 * @params:[Array] children => 元素的子元素
 * @return:[Object] element => 创建的一个React元素实例
 */

//react其实会检验一下type是否时有效的
//如果无效则显示对应的警告信息
//如果有效,再跳转到真正的createElement函数
//这里省略了
React.createElement = function (type, props, ...children) {
    //将所有children放进一个数组
    //react中,children长度为一时不会设置为数组,这里为了方便省略了
    var all_children = [];
    for (var child in children) {
        all_children.push(child);
    }
    //将children挂到props上
    var props.children = all_children;
    //从props中取出key
    var key = props.key || null;
    
    return ReactElement(type, props, key);
}

render准备阶段

我们已经有了创建出来的元素了,但是现在还不好直接去render。为什么呢?

因为首先,我们的元素有很多不同的类型,而每种不同的类型的渲染方式又不一样:一个文本类型,我们只需要加入属性,简单地将元素的children添加到span节点中就可以了;而一个自定义组件类,我们则需要处理原生属性,事件,而且还要处理元素的children中的其他元素。

如果将这些操作全都放在render中是不合理的:

  • 逻辑上render只是负责传入的element的渲染,element的子元素的渲染应该由元素本身完成
  • 如果将所有不同类型的element的处理逻辑都放在render函数中完成,render就会变得很复杂,逻辑混乱

所以我们应该让每个元素能够自己处理自己的渲染,render只负责调起传入的那一个元素的渲染过程。

然而在我们渲染元素时,我们拿到一个元素,需要判断元素的类型,然后再生成对应的实例。

TODO#4-定义一个根据元素类型创建实例的“调解站”
//reactDOM中的调解站并不只是一个函数,而是多个
//而且传入的并不是调解后直接生成实例,
//而是先生成“Fiber”对象(带有类型,属性等信息,并添加了一些内部实现时用到的属性)

//调解站仅负责返回元素实例,而不负责触发渲染。
function createInstance(element) {
    //文本元素类
    if(typeof element === "string" || typeof element === "number") {
        return new ReactTextComponent(element);
    }
    //HTML元素类
    if(typeof element === "object" && typeof element.type === "string") {
        return new ReactHTMLComponent(element);
    }
    //组件类,包括函数组件和类组件
    if(typeof element === "object" && typeof element.type === "function") {
        return new ReactCustomerComponent(element);
    }
}
TODO#5-为每类元素定义一个类
//我们先来看看reactDOM定义了哪些元素类型(注:reactDOM都把他们称为Component,而这里的Component指得其实是React元素)。
var IndeterminateComponent = 0; //不确定是函数组件还是类组件
var FunctionalComponent = 1; //函数组件
var ClassComponent = 2; //类组件
var HostRoot = 3; // React的根元素(即最后要挂载到DOM上的那个根元素)
var HostPortal = 4; // React子元素树,可以挂到其他元素上
var HostComponent = 5; //HTML元素对应的React元素
var HostText = 6; //文本节点对应的React元素

//然后我们去实现其中的几个类型的类。

//文本节点类
//注:传入文本节点的不是element,而是textContent
function ReactTextComponent(textContent) {
    this._currentElement = "" + textContent;
    this._parent = null;
}
ReactTextComponent.mountComponent = function(parent) {
    this._parent = parent;
    return "<span data-reactid='" + parent + "'>" + this._currentElement + '</span>';
}

//HTML元素类
function ReactHTMLComponent(element) {
	this._currentElement = "" + textContent;
    this._parent = null;
}
ReactHTMLComponent.mountComponent = function(parent) {
    this._parent = parent;
    var props = this._currentElement.props;
    var tagName = this._currentElement.type; //当前HTML标签名
    var propsString = ""; //当前元素的属性
    propsString += " data-reactid ='" + this._parent + "'";
    
    //开始从props中读取属性
    for(var propKey in props) {
        //注意:children也在props中,我们不读取children
    	//事件属性我们都通过事件委托交给document元素代理处理,故也不加到属性中
        if(props[propKey] && props != 'children' && !/^on[A-Za-z]/.test(propKey)) {
            propsString += " " + propKey + "=" + props[propKey];
        }
        
        if(/^on[A-Za-z]/.test(propKey)){
            //如果是事件属性,在这里我们就对document元素设置事件代理。
            //...
        }
    }
        
    //开始获取children渲染出来的DOM
    var contentMarkup = "";
    var children = props.children || [];
    var childrenInstance = []; //存放所有children实例,用于以后更新用
    for(var child in children) {
        //创建子元素的实例
        var childComponentInstance = createInstance(child);
        //存储实例
        childrenInstance.push(childComponentInstance);
        //当前子元素的id是parent加上子元素的key。
        var nowId = parent + "." + child.key;
        var childMarkup = childComponentInstance.mountComponent(nowId);
        contentMarkup += " " + childMarkup;
    }
    
    //存到当前实例的一个属性中,以后更新时取出
    this._childrenInstance = childrenInstance;
    
    //拼出DOM字符串
    return "<" + tagName + propsString + ">" + contentMarkup + "</" + tagName + ">";
}

//组件类,这里只处理了类组件,忽略了函数组件
function ReactCustomerComponent(element) {
	this._currentElement = element;
    this._parent = null; //触发当前组件渲染的元素,即它的父元素
    this._instance = null;
}
ReactCustomerComponent.prototype.mountComponent = function(parent) {
    this._parent = parent;
    var props = this._currentElement.props;
    var componentClass = this._currentElment.type;
    //用当前元素的属性,创建一个当前元素类型的实例
    var inst = new componentClass(props); 
    //保存当前元素的创建出来的实例
    this._instance = inst;
    //保存对当前组件的引用,以后更新时用
    inst._reactInternalInstance = this;
    
    //至此,准备已经完成,开始装载(即获取渲染后的DOM)
    inst.componentWillMount && inst.componentWillMount();
	
    //在这里调用组件实例中的render函数
    //得到的是一个React元素(这个元素中包含渲染出来的一颗子树),或者文本节点
    var renderedSubTree = this._instance.render();
	//创建当前渲染出来的子树的实例
    var renderedSubTreeInstance = createInstance(renderedSubTree);
    this._renderedSubTreeInstance = renderedSubTreeInstance;
    //获取子树渲染后得到的DOM节点
    //注意:因为组件并不是真正的HTML节点,所以不会真正的被渲染出来
    //而是渲染组件内部的元素,因此parent不需要改变就交给子树就可以了
    var markup = renderedSubTreeInstance.mountComponent(this._parent);
    
    //如果根节点
    if(mountReady) {
        inst.componentDidMount && inst.componentDidMount();
    }
    
    return markup;
}


render阶段

有了创建每个类型元素的实例的功能,我们就可以拿到元素实例去render了。

TODO#6-定义一个render函数
var mountReady = false,  //当前根的渲染是否完成的标志
var rootIndex = 0, //

ReactDom = {
    render: render,
}

/**
 * DO:将element生成对应的DOM,并挂载到container中
 *
 * @params:[Object] element => React元素
 * @params:[Array] container => 一个DOM节点,用于挂载渲染完成后得到的真实DOM。
 * @return:[null] 无返回值
 */

//我们省略了callback参数的处理
function render(rootElement, container) {
    //reactDOM中会先检验container是否合法,并给出提示信息
    //还会清空container节点中的其他元素
    //这里省略这些操作
    
    //render检验当前的container是否已经由一个reactRootContainer属性
    //即检查是否已经在当前container上存在一个挂载上去的react了。
    if(container._reactRootContainer){
    	//如果没有该属性,说明是第一次挂载
        //先进行初始化一颗树的操作,创建一个root类,添加相关属性,这里省略。
        
        //reactDOM内部对container进行了多层处理,
        //并且最后是将element和处理后的container一起加入了更新队列
    } else {
        //否则则说明已经存在reactDOM,就不用初始化,而是进行更新。
        
        //加入更新队列
    }
    
    //reactDOM将元素加入更新队列后就交由其他函数进行了多层处理
    //大致为生成根元素实例->调用实例的生成DOM的函数->函数中递归子元素(生成实例,调用渲染函数)
    //这里就都写在render函数中。
    var RootInstance = createInstance(rootElement); //根据根元素创建该React元素的实例
    var html = RootInstance.mountComponent(rootIndex++); //调用该实例生成DOM的函数
    
    //将生成的html填入到container中
    container.innerHTML = html;
    //设置全局挂载完成的标志
    mountReady = true;
}

更新阶段

上一步我们已经可以将React元素(VDOM)渲染成真实DOM,并且挂到某个DOM节点上去了。

在这之后,数据的改变会引起DOM的更新,想要处理这些更新,先来看看React的更新机制是怎样的。

在React中,数据以两种形式存在:state和props。

state保存的是属于当前组件的数据(可以自己更改),props保存的是从外部传递到当前组件的数据(不可自己更改)。因此数据改变的根源,还是在于state的改变,即在组件中调用setState()

但是要知道,setState只是负责传递新state的,更新的逻辑不应该放在其中——而应该交给元素实例自己去处理,即在各种元素类上定一个一个负责更新的方法(就像mountComponent一样)

TODO#7-定义在组件的实例中触发更新的方法
/**
 * DO:更新组件的state
 *
 * @params:[Object] newState => 新属性
 * @return:[null] 无返回值
 */
Component.prototype.setState = function(newState) {
    //在这里我们拿到了新state
    //我们在自定义组件类创建实例的时候,定义了_renderedSubTreeInstance字段
    //因此在此我们可以取到当前组件的实例。
    
    //调用更新函数
	this._renderedSubTreeInstance.receiveComponent(null, newState);
}

至于组件更新的方法,我们可能在这两种情况下调用:

  • 用setState设置了一个新的state
  • 旧的实例接收到一个新的元素,所以应该根据这个新的元素更新当前的实例。
TODO#8-定义各种类实现更新的方法
ReactTextComponent.prototype.receiveComponent(){
    
}

ReactHTMLComponent.prototype.receiveComponent(){
    
}

ReactCustomerComponent.prototype.receiveComponent(nextElement, nextState) {
    //如果传入了新的元素,则更新当前的实例
    this._currentElement = nextElement || this._currentElement;
    
    var inst = this._instance;
    //合并state
    var nextState = Object.assign(inst.state, newState);
    //改写state
    inst.state = nextState;
    //新的props不需要改写,因为只要我们重新调用组件实例的render
    //新的props就会被新render出来的元素所使用
    var nextProps = this._currentElement.props;
    
    //如果设置了生命周期钩子函数且返回false,则仅更新state和props,不重新render。
    if(inst.shouldComponentUpdate && (inst.shouldComponentUpdate(nextProps, nextState) === false )) {
        return;
    }
    //这时开始准备重新render当前组件
    if(inst.componentWillUpdate) {
        inst.componentWillUpdate(nextProps, nextState);
    }
    
    //先获取之前渲染的子树的实例
    var prevSubTreeInstance = this._renderedSubTreeInstance;
    //获取之前渲染的元素
    var prevRenderedElement = prevSubTreeInstance._currentElement;
    //重新render,并且获取新渲染出的元素
    var nextRenderedElement = this._instance.render();
    
    //按理说拿到子树的新元素就应该调用该子树的更新函数了。
    //但是React在这里实现了一层优化,即检测新元素是否和旧元素是同类元素
    //如果不是同类元素,则直接重新渲染,而不继续更新了。
    
    //这里我们要使用一个全局的函数,接受新旧两个元素,判断是否有必要更新
    //这个函数我们在后面实现。
    if(_shouldUpdateReactComponent(prevRenderedElement, nextRenderedElement)) {
        //调用子元素的更新函数
        prevSubTreeInstance.receiveComponent(nextRenderedElement);
        //更新完成,调用生命周期函数
        inst.componentDidMount && inst.componentDidMount();
    } else {
        //直接重新渲染
        var parent = this._parent;
        //重新生成实例,并生成新的DOM字符串
        this._renderedSubTreeInstance = createInstance(nextRenderedElement);
        var nextMarkup = this._renderedSubTreeInstance.mountComponent(parent);
        
        //使用现在的旧的parent Id在DOM中查找原来的DOM树
        //用nextMarkup替换掉那颗DOM树
        ... //代码略
    }
}
TODO#9-定义一个根据新旧元素判断是否有必要对该元素更新的方法
//接受一个新元素nextElement,一个旧元素prevElement
//比较这两个元素,判断是否有必要进行更新
function _shouldUpdateReactComponent(prevElement, nextElement) {
    if(prevElement != null && nextElement != null){
        var prevType = typeof prevElement;
        var nextType = typeof nextElement;
        if(prevType === "string" || prevType === "number"){
            //如果两个都是文本元素,返回true
            return nextType === "string" || nextType === "number";
        } else {
            //如果是HTML元素或自定义组件
            //返回true的情况:两个元素都是object类型,并且type和key属性都一样
            return nextType === "object" && prevElement.type === nextElement.type && prevElement.key === nextElement.key;
        }
    }
    return false;
}

更新时间: