200行代码实现简版react

如今(2018年)react在前端开发领域已经愈来愈🔥了,我本身也常常在项目中使用react,可是却老是好奇react的底层实现原理,屡次尝试阅读react源代码都没法读下去,确实太难了。前不久在网上看到几篇介绍如何本身动手实现react的文章,这里基于这些资料,并加入一些本身的想法,从0开始仅用200行代码实现一个简版react,相信看完后你们都会对react的内部实现原理有更多了解。可是在动手以前咱们须要先掌握几个react相关的重要概念,好比组件(类)组件实例的区别、diff算法以及生命周期等,下面依次介绍下,熟悉完这些概念咱们再动手实现。javascript

1 基本概念:Component(组件)、instance(组件实例)、 element、jsx、dom

首先咱们须要弄明白几个容易混淆的概念,最开始学习react的时候我也有些疑惑他们之间有什么不一样,前几天跟一个新同窗讨论一个问题,发现他居然也分不清组件组件实例,所以颇有必要弄明白这几个概念的区别于联系,本篇后面咱们实现这个简版react也是基于这些概念。html

Component(组件)

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>;
}
复制代码
instance(组件实例)

熟悉面向对象编程的人确定知道实例的关系,这里也是同样的,组件实例其实就是一个组件类实例化的结果,概念虽然简单,可是在react这里却容易弄不明白,为何这么说呢?由于你们在react的使用过程当中并不会本身去实例化一个组件实例,这个过程实际上是react内部帮咱们完成的,所以咱们真正接触组件实例的机会并很少。咱们更多接触到的是下面要介绍的element,由于咱们一般写的jsx其实就是element的一种表示方式而已(后面详细介绍)。虽然组件实例用的很少,可是偶尔也会用到,其实就是refref能够指向一个dom节点或者一个类组件(class component)的实例,可是不能用于函数式组件,由于函数式组件不能实例化。这里简单介绍下ref,咱们只须要知道ref能够指向一个组件实例便可,更加详细的介绍你们能够看react官方文档Refs and the DOMjava

element

前面已经提到了element,即类组件render方法以及函数式组件的返回值均为element。那么这里的element究竟是什么呢?其实很简单,就是一个纯对象(plain object),并且这个纯对象包含两个属性:type:(string|ReactClass)props:Object,注意element并非组件实例,而是一个纯对象。虽然element不是组件实例,可是又跟组件实例有关系,element是对组件实例或者dom节点的描述。若是typestring类型,则表示dom节点,若是typefunction或者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!'
  }
}
复制代码
jsx

只要弄明白了element,那么jsx就不难理解了,jsx只是换了一种写法,方便咱们来建立element而已,想一想若是没有jsx那么咱们开发效率确定会大幅下降,并且代码确定很是不利于维护。好比咱们看下面这个jsx的例子:react

const foo = <div id="foo">Hello!</div>;
复制代码

其实说白了就是定义了一个dom节点div,而且该节点的属性集合是{id: 'foo'}childrenHello!,就这点信息量而已,所以彻底跟下面这种纯对象的表示是等价的: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咱们这里也简单介绍下,做为一个前端研发人员,想必你们对这个概念应该再熟悉不过了。咱们能够这样建立一个dom节点div编程

const divDomNode = window.document.createElement('div');
复制代码

其实全部dom节点都是HTMLElement类的实例,咱们能够验证下:

window.document.createElement('div') instanceof window.HTMLElement;
// 输出 true
复制代码

关于HTMLElementAPI能够参考这里:HTMLElement介绍。所以,dom节点是HTMLElement类的实例;一样的,在react里面,组件实例组件类的实例,而element又是对组件实例dom节点的描述,如今这些概念之间的关系你们应该都清楚了吧。介绍完了这几个基本概念,咱们画个图来描述下这几个概念之间的关系:

component vs instance vs dom vs element

2 虚拟dom与diff算法

相信使用过react的同窗都多少了解过这两个概念:虚拟dom以及diff算法。这里的虚拟dom其实就是前面介绍的element,为何说是虚拟dom呢,前面我们已经介绍过了,element只是dom节点或者组件实例的一种纯对象描述而已,并非真正的dom节点,所以是虚拟dom。react给咱们提供了声明式的组件写法,当组件的props或者state变化时组件自动更新。整个页面其实能够对应到一棵dom节点树,每次组件props或者state变动首先会反映到虚拟dom树,而后最终反应到页面dom节点树的渲染。

那么虚拟domdiff算法又有什么关系呢?之因此有diff算法实际上是为了提高渲染效率,试想下,若是每次组件的state或者props变化后都把全部相关dom节点删掉再从新建立,那效率确定很是低,因此在react内部存在两棵虚拟dom树,分别表示现状以及下一个状态setState调用后就会触发diff算法的执行,而好的diff算法确定是尽量复用已有的dom节点,避免从新建立的开销。我用下图来表示虚拟domdiff算法的关系:

虚拟dom & diff算法
react组件最初渲染到页面后先生成 第1帧虚拟dom,这时 current指针指向该第一帧。 setState调用后会生成 第2帧虚拟dom,这时 next指针指向第二帧,接下来 diff算法经过比较 第2帧第1帧的异同来将更新应用到真正的 dom树以完成页面更新。

这里再次强调一下setState后具体怎么生成虚拟dom,由于这点很重要,并且容易忽略。前面刚刚已经介绍过什么是虚拟dom了,就是element树而已。那element树是怎么来的呢?其实就是render方法返回的嘛,下面的流程图再加深下印象:

element
其实 react官方对 diff算法有另一个称呼,你们确定会在 react相关资料中看到,叫 Reconciliation,我我的认为这个词有点晦涩难懂,不事后来又从新翻看了下词典,发现跟 diff算法一个意思:
reconcile是什么意思
能够看到 reconcile消除分歧核对的意思,在 react语境下就是对比 虚拟dom异同的意思,其实就是说的 diff算法。这里强调下,咱们后面实现部实现 reconcile函数,就是实现 diff算法。

3 生命周期与diff算法

生命周期diff算法又有什么关系呢?这里咱们以componentDidMountcomponentWillUnmountComponentWillUpdate以及componentDidUpdate为例说明下两者的关系。咱们知道,setState调用后会接着调用render生成新的虚拟dom树,而这个虚拟dom树与上一帧可能会产生以下区别:

  1. 新增了某个组件;
  2. 删除了某个组件;
  3. 更新了某个组件的部分属性。

所以,咱们在实现diff算法的过程会在相应的时间节点调用这些生命周期函数。

这里须要重点说明下前面提到的第1帧,咱们知道每一个react应用的入口都是:

ReactDOM.render(
    <h1>Hello, world!</h1>,
    document.getElementById('root')
);
复制代码

ReactDom.render也会生成一棵虚拟dom树,可是这棵虚拟dom树是开天辟地生成的第一帧,没有前一帧用来作diff,所以这棵虚拟dom树对应的全部组件都只会调用挂载期的生命周期函数,好比componentDidMountcomponentWillUnmount

4 实现

掌握了前面介绍的这些概念,实现一个简版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"));
复制代码

咱们在这段业务代码里面使用了rendercreateElement以及Component三个API,所以后面的任务就是实现这三个API并包装到一个函数importFromBelow内便可。

4.1 实现createElement

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};
}
复制代码
4.2 实现render

注意这个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。细心点会发现,新的虚拟domelement,即最开始介绍的element,而reconcile后的虚拟dominstance,不过这个instance并非组件实例,这点看后面instantiate的实现。总之render方法其实就是调用了reconcile方法进行了两帧虚拟dom的对比而已。

4.3 实现instantiate

那么前面的instance到底跟element有什么不一样呢?其实instance指示简单的是把element从新包了一层,并把对应的dom也给包了进来,这也不难理解,毕竟咱们调用reconcile进行diff比较的时候须要把跟新应用到真实的dom上,所以须要跟dom关联起来,下面实现的instantiate函数就干这个事的。注意因为element包括dom类型和Component类型(由type字段判断,不明白的话能够回过头看一下第一节的element相关介绍),所以须要分状况处理:

dom类型的element.typestring类型,对应的instance结构为{element, dom, childInstances}

Component类型的element.typeReactClass类型,对应的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函数中有递归实例化的逻辑。

4.4 区分类组件与函数式组件

前面咱们提到过,组件包括类组件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

4.5 实现reconcile(diff算法)

重点来了,reconcilereact的核心,显然如何将新设置的state快速的渲染出来很是重要,所以react会尽可能复用已有节点,而不是每次都动态建立全部相关节点。可是react强大的地方还不只限于此,react16reconcile算法由以前的stack架构升级成了fiber架构,更近一步作的性能优化。fiber相关的内容下一节再介绍,这里为了简单易懂,仍然使用相似stack架构的算法来实现,对于fiber如今只须要知道其调度原理便可,固然后面有时间能够再实现一版基于fiber架构的。

首先看一下整个reconcile算法的处理流程:

reconcile算法的处理流程
能够看到,咱们会根据不一样的状况作不一样的处理:

  1. 若是是新增instance,那么须要实例化一个instance而且appendChild
  2. 若是是否是新增instance,而是删除instance,那么须要removeChild
  3. 若是既不是新增也不是删除instance,那么须要看instancetype是否变化,若是有变化,那节点就没法复用了,也须要实例化instance,而后replaceChild
  4. 若是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架构,这一问题获得了解决。

4.6 总体代码

把前面实现的全部这些代码组合起来就是完整的简版react,不到200行代码,so easy~!完整代码见DiyReact

5 fiber架构

react16升级了reconcile算法架构,从stack升级为fiber架构,前面咱们已经提到过stack架构的缺点,那就是使用递归实现,一旦开始就没法暂停,只能一口气执行完毕,因为js是单线程的,这就有可能阻塞用户输入或者ui渲染,会下降用户体验。

fiber架构则不同。底层是基于requestIdleCallback来调度diff算法的执行,关于requestIdleCallback的介绍能够参考我以前写的一篇关于js事件循环的文章javascript事件循环(浏览器端、node端)requestIdlecallback的特色顾名思义就是利用空闲时间来完成任务。注意这里的空闲时间就是相对于那些优先级更高的任务(好比用户输入、ui渲染)来讲的。

这里再简单介绍一下fiber这个名称的由来,由于我一开始就很好奇为何叫作fiberfiber实际上是纤程的意思,并非一个新词汇,你们能够看维基百科的解释Fiber (computer science)。其实就是想表达一种更加精细粒度的调度的意思,由于基于这种算法react能够随时暂停diff算法的执行,然后有空闲时间了接着执行,这是一种更加精细的调度算法,所以称为fiber架构。本篇对fiber就先简单介绍这些,后面有时间再单独总结一篇。

6 参考资料

主要参考如下资料:

  1. React Components, Elements, and Instances
  2. Refs and the DOM
  3. HTMLElement介绍
  4. Didact: a DIY guide to build your own React
  5. How Does React Tell a Class from a Function?
  6. Lin Clark - A Cartoon Intro to Fiber - React Conf 2017
  7. Let’s fall in love with React Fiber
相关文章
相关标签/搜索