如今(2018年)react
在前端开发领域已经愈来愈🔥了,我本身也常常在项目中使用react
,可是却老是好奇react
的底层实现原理,屡次尝试阅读react
源代码都没法读下去,确实太难了。前不久在网上看到几篇介绍如何本身动手实现react
的文章,这里基于这些资料,并加入一些本身的想法,从0开始仅用200
行代码实现一个简版react
,相信看完后你们都会对react
的内部实现原理有更多了解。可是在动手以前咱们须要先掌握几个react
相关的重要概念,好比组件(类)
与组件实例
的区别、diff
算法以及生命周期
等,下面依次介绍下,熟悉完这些概念咱们再动手实现。javascript
首先咱们须要弄明白几个容易混淆的概念,最开始学习react
的时候我也有些疑惑他们之间有什么不一样,前几天跟一个新同窗讨论一个问题,发现他居然也分不清组件
和组件实例
,所以颇有必要弄明白这几个概念的区别于联系,本篇后面咱们实现这个简版react
也是基于这些概念。html
Component
就是咱们常常实现的组件,能够是类组件
(class component
)或者函数式组件
(functional component
),而类组件
又能够分为普通类组件(React.Component
)以及纯类组件(React.PureComponent
),总之这两类都属于类组件
,只不过PureComponent
基于shouldComponentUpdate
作了一些优化,这里不展开说。函数式组件
则用来简化一些简单组件的实现,用起来就是写一个函数,入参是组件属性props
,出参与类组件
的render
方法返回值同样,是react element
(注意这里已经出现了接下来要介绍的element
哦)。 下面咱们分别按三种方式实现下Welcome
组件:前端
// Component
class Welcome extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
复制代码
// PureComponent
class Welcome extends React.PureComponent {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
复制代码
// functional component
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
复制代码
熟悉面向对象编程
的人确定知道类
和实例
的关系,这里也是同样的,组件实例
其实就是一个组件类
实例化的结果,概念虽然简单,可是在react
这里却容易弄不明白,为何这么说呢?由于你们在react
的使用过程当中并不会本身去实例化一个组件实例
,这个过程实际上是react
内部帮咱们完成的,所以咱们真正接触组件实例
的机会并很少。咱们更多接触到的是下面要介绍的element
,由于咱们一般写的jsx
其实就是element
的一种表示方式而已(后面详细介绍)。虽然组件实例
用的很少,可是偶尔也会用到,其实就是ref
。ref
能够指向一个dom节点
或者一个类组件(class component)
的实例,可是不能用于函数式组件
,由于函数式组件
不能实例化
。这里简单介绍下ref
,咱们只须要知道ref
能够指向一个组件实例
便可,更加详细的介绍你们能够看react
官方文档Refs and the DOM。java
前面已经提到了element
,即类组件
的render
方法以及函数式组件
的返回值均为element
。那么这里的element
究竟是什么呢?其实很简单,就是一个纯对象(plain object
),并且这个纯对象包含两个属性:type:(string|ReactClass)
和props:Object
,注意element
并非组件实例
,而是一个纯对象。虽然element
不是组件实例
,可是又跟组件实例有关系,element
是对组件实例
或者dom节点
的描述。若是type
是string
类型,则表示dom节点
,若是type
是function
或者class
类型,则表示组件实例
。好比下面两个element
分别描述了一个dom节点
和一个组件实例
:node
// 描述dom节点
{
type: 'button',
props: {
className: 'button button-blue',
children: {
type: 'b',
props: {
children: 'OK!'
}
}
}
}
复制代码
function Button(props){
// ...
}
// 描述组件实例
{
type: Button,
props: {
color: 'blue',
children: 'OK!'
}
}
复制代码
只要弄明白了element
,那么jsx
就不难理解了,jsx
只是换了一种写法,方便咱们来建立element
而已,想一想若是没有jsx
那么咱们开发效率确定会大幅下降,并且代码确定很是不利于维护。好比咱们看下面这个jsx
的例子:react
const foo = <div id="foo">Hello!</div>;
复制代码
其实说白了就是定义了一个dom节点div
,而且该节点的属性集合是{id: 'foo'}
,children
是Hello!
,就这点信息量而已,所以彻底跟下面这种纯对象的表示是等价的:webpack
{
type: 'div',
props: {
id: 'foo',
children: 'Hello!'
}
}
复制代码
那么React
是如何将jsx
语法转换为纯对象的呢?其实就是利用Babel
编译生成的,咱们只要在使用jsx
的代码里加上个编译指示(pragma)
便可,能够参考这里Babel如何编译jsx。好比咱们将编译指示
设置为指向createElement
函数:/** @jsx createElement */
,那么前面那段jsx
代码就会编译为:web
var foo = createElement('div', {id:"foo"}, 'Hello!');
复制代码
能够看出,jsx
的编译过程其实就是从<
、>
这种标签式
写法到函数调用式
写法的一种转化而已。有了这个前提,咱们只须要简单实现下createElement
函数不就能够构造出element
了嘛,咱们后面本身实现简版react
也会用到这个函数:算法
function createElement(type, props, ...children) {
props = Object.assign({}, props);
props.children = [].concat(...children)
.filter(child => child != null && child !== false)
.map(child => child instanceof Object ? child : createTextElement(child));
return {type, props};
}
复制代码
dom咱们这里也简单介绍下,做为一个前端研发人员,想必你们对这个概念应该再熟悉不过了。咱们能够这样建立一个dom节点div
:编程
const divDomNode = window.document.createElement('div');
复制代码
其实全部dom节点都是HTMLElement类
的实例,咱们能够验证下:
window.document.createElement('div') instanceof window.HTMLElement;
// 输出 true
复制代码
关于HTMLElement
API能够参考这里:HTMLElement介绍。所以,dom
节点是HTMLElement类
的实例;一样的,在react
里面,组件实例
是组件类
的实例,而element
又是对组件实例
和dom
节点的描述,如今这些概念之间的关系你们应该都清楚了吧。介绍完了这几个基本概念,咱们画个图来描述下这几个概念之间的关系:
相信使用过react
的同窗都多少了解过这两个概念:虚拟dom
以及diff算法
。这里的虚拟dom
其实就是前面介绍的element
,为何说是虚拟
dom呢,前面我们已经介绍过了,element
只是dom
节点或者组件实例
的一种纯对象描述而已,并非真正的dom
节点,所以是虚拟
dom。react
给咱们提供了声明式
的组件写法,当组件的props
或者state
变化时组件自动更新。整个页面其实能够对应到一棵dom
节点树,每次组件props
或者state
变动首先会反映到虚拟dom
树,而后最终反应到页面dom
节点树的渲染。
那么虚拟dom
跟diff算法
又有什么关系呢?之因此有diff
算法实际上是为了提高渲染
效率,试想下,若是每次组件的state
或者props
变化后都把全部相关dom
节点删掉再从新建立,那效率确定很是低,因此在react
内部存在两棵虚拟dom
树,分别表示现状
以及下一个状态
,setState
调用后就会触发diff
算法的执行,而好的diff
算法确定是尽量复用已有的dom
节点,避免从新建立的开销。我用下图来表示虚拟dom
和diff算法
的关系:
react
组件最初渲染到页面后先生成
第1帧
虚拟dom,这时
current指针
指向该第一帧。
setState
调用后会生成
第2帧
虚拟dom,这时
next指针
指向第二帧,接下来
diff
算法经过比较
第2帧
和
第1帧
的异同来将更新应用到真正的
dom
树以完成页面更新。
这里再次强调一下setState
后具体怎么生成虚拟dom
,由于这点很重要,并且容易忽略。前面刚刚已经介绍过什么是虚拟dom
了,就是element
树而已。那element
树是怎么来的呢?其实就是render
方法返回的嘛,下面的流程图再加深下印象:
react
官方对
diff算法
有另一个称呼,你们确定会在
react
相关资料中看到,叫
Reconciliation
,我我的认为这个词有点晦涩难懂,不事后来又从新翻看了下词典,发现跟
diff算法
一个意思:
reconcile
有
消除分歧
、
核对
的意思,在
react
语境下就是对比
虚拟dom
异同的意思,其实就是说的
diff算法
。这里强调下,咱们后面实现部实现
reconcile
函数,就是实现
diff
算法。
生命周期
与diff算法
又有什么关系呢?这里咱们以componentDidMount
、componentWillUnmount
、ComponentWillUpdate
以及componentDidUpdate
为例说明下两者的关系。咱们知道,setState
调用后会接着调用render
生成新的虚拟dom
树,而这个虚拟dom
树与上一帧可能会产生以下区别:
所以,咱们在实现diff算法
的过程会在相应的时间节点调用这些生命周期
函数。
这里须要重点说明下前面提到的第1帧
,咱们知道每一个react
应用的入口都是:
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);
复制代码
ReactDom.render
也会生成一棵虚拟dom
树,可是这棵虚拟dom
树是开天辟地生成的第一帧
,没有前一帧用来作diff,所以这棵虚拟dom
树对应的全部组件都只会调用挂载期
的生命周期函数,好比componentDidMount
、componentWillUnmount
。
掌握了前面介绍的这些概念,实现一个简版react
也就不难了。这里须要说明下,本节实现部分是基于这篇博客的实现Didact: a DIY guide to build your own React。 如今首先看一下咱们要实现哪些API,咱们最终会以以下方式使用:
// 声明编译指示
/** @jsx DiyReact.createElement */
// 导入咱们下面要实现的API
const DiyReact = importFromBelow();
// 业务代码
const randomLikes = () => Math.ceil(Math.random() * 100);
const stories = [
{name: "React", url: "https://reactjs.org/", likes: randomLikes()},
{name: "Node", url: "https://nodejs.org/en/", likes: randomLikes()},
{name: "Webpack", url: "https://webpack.js.org/", likes: randomLikes()}
];
const ItemRender = props => {
const {name, url} = props;
return (
<a href={url}>{name}</a>
);
};
class App extends DiyReact.Component {
render() {
return (
<div>
<h1>DiyReact Stories</h1>
<ul>
{this.props.stories.map(story => {
return <Story name={story.name} url={story.url} />;
})}
</ul>
</div>
);
}
componentWillMount() {
console.log('execute componentWillMount');
}
componentDidMount() {
console.log('execute componentDidMount');
}
componentWillUnmount() {
console.log('execute componentWillUnmount');
}
}
class Story extends DiyReact.Component {
constructor(props) {
super(props);
this.state = {likes: Math.ceil(Math.random() * 100)};
}
like() {
this.setState({
likes: this.state.likes + 1
});
}
render() {
const {name, url} = this.props;
const {likes} = this.state;
const likesElement = <span />;
return (
<li>
<button onClick={e => this.like()}>{likes}<b>❤️</b></button>
<ItemRender {...itemRenderProps} />
</li>
);
}
// shouldcomponentUpdate() {
// return true;
// }
componentWillUpdate() {
console.log('execute componentWillUpdate');
}
componentDidUpdate() {
console.log('execute componentDidUpdate');
}
}
// 将组件渲染到根dom节点
DiyReact.render(<App stories={stories} />, document.getElementById("root"));
复制代码
咱们在这段业务代码里面使用了render
、createElement
以及Component
三个API,所以后面的任务就是实现这三个API并包装到一个函数importFromBelow
内便可。
createElement
函数的功能跟jsx
是紧密相关的,前面介绍jsx
的部分已经介绍过了,其实就是把相似html
的标签式写法转化为纯对象element
,具体实现以下:
function createElement(type, props, ...children) {
props = Object.assign({}, props);
props.children = [].concat(...children)
.filter(child => child != null && child !== false)
.map(child => child instanceof Object ? child : createTextElement(child));
return {type, props};
}
复制代码
注意这个render
至关于ReactDOM.render
,不是组件
的render
方法,组件
的render
方法在后面Component
实现部分。
// rootInstance用来缓存一帧虚拟dom
let rootInstance = null;
function render(element, parentDom) {
// prevInstance指向前一帧
const prevInstance = rootInstance;
// element参数指向新生成的虚拟dom树
const nextInstance = reconcile(parentDom, prevInstance, element);
// 调用完reconcile算法(即diff算法)后将rooInstance指向最新一帧
rootInstance = nextInstance;
}
复制代码
render
函数实现很简单,只是进行了两帧虚拟dom
的对比(reconcile),而后将rootInstance
指向新的虚拟dom
。细心点会发现,新的虚拟dom
为element
,即最开始介绍的element
,而reconcile
后的虚拟dom
是instance
,不过这个instance
并非组件实例
,这点看后面instantiate
的实现。总之render
方法其实就是调用了reconcile
方法进行了两帧虚拟dom
的对比而已。
那么前面的instance
到底跟element
有什么不一样呢?其实instance
指示简单的是把element
从新包了一层,并把对应的dom
也给包了进来,这也不难理解,毕竟咱们调用reconcile
进行diff
比较的时候须要把跟新应用到真实的dom
上,所以须要跟dom
关联起来,下面实现的instantiate
函数就干这个事的。注意因为element
包括dom
类型和Component
类型(由type
字段判断,不明白的话能够回过头看一下第一节的element
相关介绍),所以须要分状况处理:
dom
类型的element.type
为string
类型,对应的instance
结构为{element, dom, childInstances}
。
Component
类型的element.type
为ReactClass
类型,对应的instance
结构为{dom, element, childInstance, publicInstance}
,注意这里的publicInstance
就是前面介绍的组件实例
。
function instantiate(element) {
const {type, props = {}} = element;
const isDomElement = typeof type === 'string';
if (isDomElement) {
// 建立dom
const isTextElement = type === TEXT_ELEMENT;
const dom = isTextElement ? document.createTextNode('') : document.createElement(type);
// 设置dom的事件、数据属性
updateDomProperties(dom, [], element.props);
const children = props.children || [];
const childInstances = children.map(instantiate);
const childDoms = childInstances.map(childInstance => childInstance.dom);
childDoms.forEach(childDom => dom.appendChild(childDom));
const instance = {element, dom, childInstances};
return instance;
} else {
const instance = {};
const publicInstance = createPublicInstance(element, instance);
const childElement = publicInstance.render();
const childInstance = instantiate(childElement);
Object.assign(instance, {dom: childInstance.dom, element, childInstance, publicInstance});
return instance;
}
}
复制代码
须要注意,因为dom节点
和组件实例
均可能有孩子节点,所以instantiate
函数中有递归实例化的逻辑。
前面咱们提到过,组件包括类组件
(class component
)与函数式组件
(functional component
)。我在平时的业务中常常用到这两类组件,若是一个组件仅用来渲染,我通常会使用函数式组件
,毕竟代码逻辑简单清晰易懂。那么React
内部是如何区分出来这两种组件的呢?这个问题说简单也简单,说复杂也复杂。为何这么说呢,是由于React
内部实现方式确实比较简单,可是这种简单的实现方式倒是通过各类考量后肯定下来的实现方式。蛋总(Dan
)有一篇文章详细分析了下React
内部如何区分两者,强烈推荐你们阅读,这里我直接拿过来用,文章连接见这里How Does React Tell a Class from a Function?。其实很简答,咱们实现类组件
确定须要继承自类React.Component
,所以首先给React.Component
打个标记,而后在实例化组件时判断element.type
的原型链上是否有该标记便可。
// 打标记
Component.prototype.isReactComponent = {};
// 区分组件类型
const type = element.type;
const isDomElement = typeof type === 'string';
const isClassElement = !!(type.prototype && type.prototype.isReactComponent);
复制代码
这里咱们升级下前面的实例化函数instantiate
以区分出函数式组件
与类组件
:
function instantiate(element) {
const {type, props = {}} = element;
const isDomElement = typeof type === 'string';
const isClassElement = !!(type.prototype && type.prototype.isReactComponent);
if (isDomElement) {
// 建立dom
const isTextElement = type === TEXT_ELEMENT;
const dom = isTextElement ? document.createTextNode('') : document.createElement(type);
// 设置dom的事件、数据属性
updateDomProperties(dom, [], element.props);
const children = props.children || [];
const childInstances = children.map(instantiate);
const childDoms = childInstances.map(childInstance => childInstance.dom);
childDoms.forEach(childDom => dom.appendChild(childDom));
const instance = {element, dom, childInstances};
return instance;
} else if (isClassElement) {
const instance = {};
const publicInstance = createPublicInstance(element, instance);
const childElement = publicInstance.render();
const childInstance = instantiate(childElement);
Object.assign(instance, {dom: childInstance.dom, element, childInstance, publicInstance});
return instance;
} else {
const childElement = type(element.props);
const childInstance = instantiate(childElement);
const instance = {
dom: childInstance.dom,
element,
childInstance,
fn: type
};
return instance;
}
}
复制代码
能够看到,若是是函数式组件
,咱们没有实例化该组件,而是直接调用了该函数获取虚拟dom
。
重点来了,reconcile
是react
的核心,显然如何将新设置的state
快速的渲染出来很是重要,所以react
会尽可能复用已有节点,而不是每次都动态建立全部相关节点。可是react
强大的地方还不只限于此,react16
将reconcile
算法由以前的stack
架构升级成了fiber
架构,更近一步作的性能优化。fiber
相关的内容下一节再介绍,这里为了简单易懂,仍然使用相似stack
架构的算法来实现,对于fiber
如今只须要知道其调度
原理便可,固然后面有时间能够再实现一版基于fiber
架构的。
首先看一下整个reconcile
算法的处理流程:
instance
,那么须要实例化一个instance
而且appendChild
;instance
,而是删除instance
,那么须要removeChild
;instance
,那么须要看instance
的type
是否变化,若是有变化,那节点就没法复用了,也须要实例化instance
,而后replaceChild
;type
没变化就能够复用已有节点了,这种状况下要判断是原生dom
节点仍是咱们自定义实现的react
节点,两种状况下处理方式不一样。大流程了解后,咱们只须要在对的时间点执行生命周期
函数便可,下面看具体实现:
function reconcile(parentDom, instance, element) {
if (instance === null) {
const newInstance = instantiate(element);
// componentWillMount
newInstance.publicInstance
&& newInstance.publicInstance.componentWillMount
&& newInstance.publicInstance.componentWillMount();
parentDom.appendChild(newInstance.dom);
// componentDidMount
newInstance.publicInstance
&& newInstance.publicInstance.componentDidMount
&& newInstance.publicInstance.componentDidMount();
return newInstance;
} else if (element === null) {
// componentWillUnmount
instance.publicInstance
&& instance.publicInstance.componentWillUnmount
&& instance.publicInstance.componentWillUnmount();
parentDom.removeChild(instance.dom);
return null;
} else if (instance.element.type !== element.type) {
const newInstance = instantiate(element);
// componentDidMount
newInstance.publicInstance
&& newInstance.publicInstance.componentDidMount
&& newInstance.publicInstance.componentDidMount();
parentDom.replaceChild(newInstance.dom, instance.dom);
return newInstance;
} else if (typeof element.type === 'string') {
updateDomProperties(instance.dom, instance.element.props, element.props);
instance.childInstances = reconcileChildren(instance, element);
instance.element = element;
return instance;
} else {
if (instance.publicInstance
&& instance.publicInstance.shouldcomponentUpdate) {
if (!instance.publicInstance.shouldcomponentUpdate()) {
return;
}
}
// componentWillUpdate
instance.publicInstance
&& instance.publicInstance.componentWillUpdate
&& instance.publicInstance.componentWillUpdate();
instance.publicInstance.props = element.props;
let newChildElement;
if (instance.publicInstance) { // 类组件
instance.publicInstance.props = element.props;
newChildElement = instance.publicInstance.render();
} else { // 函数式组件
newChildElement = instance.fn(element.props);
}
const oldChildInstance = instance.childInstance;
const newChildInstance = reconcile(parentDom, oldChildInstance, newChildElement);
// componentDidUpdate
instance.publicInstance
&& instance.publicInstance.componentDidUpdate
&& instance.publicInstance.componentDidUpdate();
instance.dom = newChildInstance.dom;
instance.childInstance = newChildInstance;
instance.element = element;
return instance;
}
}
function reconcileChildren(instance, element) {
const {dom, childInstances} = instance;
const newChildElements = element.props.children || [];
const count = Math.max(childInstances.length, newChildElements.length);
const newChildInstances = [];
for (let i = 0; i < count; i++) {
newChildInstances[i] = reconcile(dom, childInstances[i], newChildElements[i]);
}
return newChildInstances.filter(instance => instance !== null);
}
复制代码
看完reconcile
算法后确定有人会好奇,为何这种算法叫作stack
算法,这里简单解释一下。从前面的实现能够看到,每次组件的state
更新都会触发reconcile
的执行,而reconcile
的执行也是一个递归过程,并且一开始直到递归执行完全部节点才中止,所以称为stack
算法。因为是个递归过程,所以该diff
算法一旦开始就必须执行完,所以可能会阻塞线程,又因为js是单线程的,所以这时就可能会影响用户的输入或者ui的渲染帧频,下降用户体验。不过react16
中升级为了fiber
架构,这一问题获得了解决。
把前面实现的全部这些代码组合起来就是完整的简版react
,不到200
行代码,so easy~!完整代码见DiyReact。
react16
升级了reconcile
算法架构,从stack
升级为fiber
架构,前面咱们已经提到过stack
架构的缺点,那就是使用递归实现,一旦开始就没法暂停,只能一口气执行完毕,因为js是单线程的,这就有可能阻塞用户输入或者ui渲染,会下降用户体验。
而fiber
架构则不同。底层是基于requestIdleCallback
来调度diff
算法的执行,关于requestIdleCallback
的介绍能够参考我以前写的一篇关于js事件循环
的文章javascript事件循环(浏览器端、node端)。requestIdlecallback
的特色顾名思义就是利用空闲时间来完成任务。注意这里的空闲时间
就是相对于那些优先级更高的任务(好比用户输入、ui渲染)来讲的。
这里再简单介绍一下fiber
这个名称的由来,由于我一开始就很好奇为何叫作fiber
。fiber
实际上是纤程
的意思,并非一个新词汇,你们能够看维基百科的解释Fiber (computer science)。其实就是想表达一种更加精细粒度的调度
的意思,由于基于这种算法react
能够随时暂停diff
算法的执行,然后有空闲时间了接着执行,这是一种更加精细
的调度算法,所以称为fiber
架构。本篇对fiber
就先简单介绍这些,后面有时间再单独总结一篇。
主要参考如下资料: