200行代码实现简版react🔥

综合技术 2018-12-08 阅读原文

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

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

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

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 DOM

element

前面已经提到了 element ,即 类组件render 方法以及 函数式组件 的返回值均为 element 。那么这里的 element 到底是什么呢?其实很简单,就是一个纯对象( plain object ),而且这个纯对象包含两个属性: type:(string|ReactClass)props:Object ,注意 element 并不是 组件实例 ,而是一个纯对象。 虽然 element 不是 组件实例 ,但是又跟组件实例有关系, element 是对 组件实例 或者 dom节点 的描述 。如果 typestring 类型,则表示 dom节点 ,如果 typefunction 或者 class 类型,则表示 组件实例 。比如下面两个 element 分别描述了一个 dom节点 和一个 组件实例

// 描述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 的例子:

const foo = <div id="foo">Hello!</div>;

其实说白了就是定义了一个dom节点 div ,并且该节点的属性集合是 {id: 'foo'}childrenHello! ,就这点信息量而已,因此完全跟下面这种纯对象的表示是等价的:

{
  type: 'div',
  props: {
    id: 'foo',
    children: 'Hello!'
  }
}

那么 React 是如何将 jsx 语法转换为纯对象的呢?其实就是利用 Babel 编译生成的,我们只要在使用 jsx 的代码里加上个 编译指示(pragma) 即可,可以参考这里 Babel如何编译jsx 。比如我们将 编译指示 设置为指向 createElement 函数: /** @jsx createElement */ ,那么前面那段 jsx 代码就会编译为:

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

关于 HTMLElement API可以参考这里: 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 也就不难了。首先看一下我们要实现哪些API,我们最终会以如下方式使用:

// 声明编译指示
/** @jsx DiyReact.createElement */

// 导入我们下面要实现的API
const DiyReact = importFromBelow();

// 业务代码
const randomLikes = () => Math.ceil(Math.random() * 100);
const stories = [
    {name: "DiyReact介绍", url: "http://google.com", likes: randomLikes()},
    {name: "Rendering DOM elements ", url: "http://google.com", likes: randomLikes()},
    {name: "Element creation and JSX", url: "http://google.com", likes: randomLikes()},
    {name: "Instances and reconciliation", url: "http://google.com", likes: randomLikes()},
    {name: "Components and state", url: "http://google.com", likes: randomLikes()}
];

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>:heart:</b></button>
                <a href={url}>{name}</a>
            </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 实现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;
        const newChildElement = instance.publicInstance.render();
        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.5 整体代码

把前面实现的所有这些代码组合起来就是完整的 简版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. Lin Clark - A Cartoon Intro to Fiber - React Conf 2017
  6. Let’s fall in love with React Fiber
简书

责编内容by:简书阅读原文】。感谢您的支持!

您可能感兴趣的

React组件:为什么调用顺序是constructor -> willMount -> ... 虽然常用React、redux编写SPA,但是这一块是如何运作,应该如何优化,还是比较困扰,最近开始阅读程墨的《深入浅出React和Redux》,结合之前读过的React源码和相关源码的文章后,打算从源码的角度,解释下书中的一些内容。 ...
React + PubsubJS 组件之间的不一样的通信方式... PubsubJS的发布订阅模式为组件之间的通信提供了更方便快捷的方式;语法的话非常简单,可以来这里学习 https://www.npmjs.com/package/pubsub-js 首先,在项目中初始化 npm install p...
Create React App 2.0: What’s New? TL;DR:- In this article, we are going to go over the new updates and features added in the new major release of the cre...
Preact —— React 的 3kb 轻量化替代方案 Preact 是 React 的轻量化替代方案,仅有 3KB。并且提供了相同的 ES6 API,还具有组件和 Virtual DOM。 具有以下特性: 更接近于实质:Preact 在 DOM 上实现一个可能是最...
React Apollo入门 入门Apollo Client最简单的方法是使用Apollo Boost,它会给客户端配置推荐的设置。使用Apollo Boost创建app的内容包括缓存,本地状态管理以及错误处理。 安装 首先安装以下的软件包: npm ins...