React原理解析
阅读对象:了解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方法,不要混淆了。
话是这么说,可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;
}