React源码解析-虚拟DOM

1,414

JSX是什么

弄清JSX对理解虚拟DOM有很重要的作用JSX只是看起来像是HTML,但它却是JavaScript,在React代码执行之前,Babel会将JSX编译为React API。

// 编译前
<div className="content">
    <h3>Hello React</h3>
    <p>React is great</p>
</div>
// 编译后
React.createElement(
    'div',
    {
        className: 'content'
    },
    React.createElement('h3', null, 'Hello World'),
    React.createElement('p', null, 'React is greate')
)

React.createElement代表一个节点元素,第一个参数是节点的名称,第二个是节点的属性,后面的参数都是子节点。我们可以自己在babeljs.is网站试验。React.createElement就是用来创建虚拟DOM的,返回的就是一个虚拟DOM对象。React再将虚拟DOM转换为真实DOM显示到页面中。

jsx在运行时会被Babel转换为React.createElement对象,React.createElement会被React转换成虚拟DOM对象,虚拟DOM对象会被React转换成真实DOM对象。

JSX语法的出现就是为了让React开发人员编写用户界面代码更加轻松。

什么是虚拟DOM

在React中,每个DOM对象都有一个对应的虚拟DOM对象,他是DOM对象的JavaScript表现形式,其实就是使用JavaScript对象来描述DOM对象信息,比如DOM对象的类型是什么,它身上有哪些属性,它拥有哪些子元素。

可以把虚拟DOM对象理解为DOM对象的一个副本,不过虚拟DOM不能直接显示在屏幕上。虚拟DOM就是为了解决React操作DOM的性能问题。

// 编译前
<div className="content">
    <h3>Hello React</h3>
    <p>React is great</p>
</div>
// 编译后
{
    type: "div",
    props: { className: "content"},
    children: [
        {
            type: "h3",
            props: null,
            children: [
                {
                    type: "text",
                    props: {
                        textContent: "Hello React"
                    }
                }
            ]
        },
        {
            type: "p",
            props: null,
            children: [
                {
                    type: "text",
                    props: {
                        textContent: "React is greate"
                    }
                }
            ]
        }
    ]
}

React采用最小化的DOM操作来提升DOM操作的优势,只更新需要更新的,在React第一次创建DOM对象的时候会为每一个DOM对象创建虚拟的DOM对象,在DOM对象发生更新之前React会更新所有的虚拟DOM对象, 然后将更新前的虚拟DOM和更新后的虚拟DOM进行对比,找到变更的DOM对象,只将发生变化的DOM更新到页面中从而提升了js操作DOM的性能。

虽然在操作真实DOM之前进行的虚拟DOM更新和对比的操作,但是由于JS操作自有对象效率是很高的,成本几乎可以忽略不计的。

在React代码执行前,JSX会被Babel转换为React.createElement方法的调用,在调用createElement方法时会传入元素的类型,元素的属性,以及元素的子元素,createElement方法的返回值为构建好的虚拟DOM对象。这里我们自己来实现一个createElement方法。

createElement方法接收type, props, childrens三个参数。分别表示标签类型,标签属性和标签子元素。在这个方法中要返回一个虚拟DOM对象,在这个对象中有个type属性其实就是参数传入的值,接着是props和children。

function createElement(type, props, ...children) {
    return {
        type,
        props,
        children
    }
}

我们这里使用TinyReact来分析React代码。首先要配置babel将jsx编译为Tiny的createElement方法,这样方便我们调试

.babelrc

{
    "presets": [
        "@babel/preset-env",
        [
            "@babel/preset-react",
            {
                "pragma": "TinyReact.createElement"
            }
        ]
    ]
}

脚手架仓库自取地址 链接

src/index.js

import TinyReact from "./TinyReact"

const virtualDOM = (
  <div className="container">
    <h1>你好 我是虚拟DOM</h1>
  </div>
)

console.log(virtualDOM);

控制台打印结果。

{
    "type": "div",
    "props": {
        "className": "container"
    },
    "children": [
        {
            "type":"h1",
             "props":null,
            "children": [
                "你好 我是虚拟DOM"
            ]
        }
    ]
}

这里我们就打印出来一个简单的虚拟DOM,不过也有一个问题,这里的文本节点"你好 我是虚拟DOM"直接以字符串添加到了children数组中,这是不对的,正确的做法应该是文本节点也应该是一个虚拟DOM对象。

我们只需要循环children数组,判断如果不是一个对象就认为他是一个文本节点,我们将它替换成一个对象,

function createElement(type, props, ...children) {
    // 遍历children对象
    const childElements = [].concat(...children).map(child => {
        if(child instanceof Object) {
        return child; // 是对象直接返回
        } else {
        // 不是对象 调用createElement方法生成一个对象
        return createElement('text', { textContent: child });
        }
    })
    return {
    type,
    props,
    children: childElements
    }
}

文本节点变成了一个对象。

{
    "type": "div",
    "props": {
        "className": "container"
    },
    "children": [
        {
            "type":"h1",
             "props":null,
            "children": [
                {
                    "type":"text",
                    "props": {
                        "textContent": "你好 我是虚拟DOM"
                    },
                    "children": []
                }
            ]
        }
    ]
}

我们都知道在组件模板中如果是布尔值或者null值,节点是不显示的。我们这里需要处理一下。

<div className="container">
    <h1>你好 我是虚拟DOM</h1>
    {
        1 === 2 && <h1>布尔值节点</h1>
    }
</div>
function createElement(type, props, ...children) {
  // 遍历children对象
  const childElements = [].concat(...children).reduce((result, child) => {
    // 判断child不能是布尔也不能是null
    // 因为使用reduce,所以result是前一次循环的返回值,最终返回result就可以
    if (child !== false && child !== true && child !== null) {
      if (child instanceof Object) {
        result.push(child); // 是对象直接返回
      } else {
        // 不是对象 调用createElement方法生成一个对象
        result.push(createElement('text', {
          textContent: child
        }));
      }
    }
    return result;
  }, [])
  return {
    type,
    props,
    children: childElements
  }
}

我们还需要将children放入到props中,只需要使用Object.assign将props和children合并返回就可以了。

return {
    type,
    props: Object.assign({ children: childElements}, props),
    children: childElements
}

将虚拟DOM转换为真实DOM

我们要定义一个render方法,。

src/tinyReact/render.js

这个方法要接收三个参数,第一个参数是虚拟DOM,第二个参数是要渲染到的页面元素,第三个参数是旧的虚拟DOM用于进行对比。render方法的主要作用就是将虚拟DOM转换为真实DOM并且渲染到页面中。

import diff from './diff'

function render(virtualDOM, container, oldDOM) {
    diff(virtualDOM, container, oldDOM);
}

需要在diff方法中进行一次处理,如果旧的虚拟DOM存在就进行对比,如果不存在就直接将当前的虚拟DOM放置在container中。

src/tinyReact/diff.js

import mountElement from './mountElement';

function diff (virtualDOM, container, oldDOM) {
    // 判断oldDOM是否在巡
    if (!oldDOM) {
        return mountElement(virtualDOM, container);
    }
}

要判断需要转换的虚拟DOM是组件还是普通的标签。需要分别进行处理, 这里我们先默认只有原生jsx标签,写死调用mountNativeElement方法。

src/tinyReact/mountElement.js

import mountNativeElement from './mountNativeElement';

function mountElement(virtualDOM, container) {
    // 处理原生的jsx和组件的jsx
    mountNativeElement(virtualDOM, container);
}

mountNativeElement文件用于将原生的虚拟DOM转换成真实的DOM,这里调用createDOMElement方法来实现。

src/tinyReact/mountNativeElement.js

import createDOMElement from './createDOMElement';

function mountNativeElement(virtualDOM, container) {
    // 将虚拟dom转换成真实的对象
    let newElement = createDOMElement(virtualDOM);
    // 将转换之后的DOM对象放在页面中
    container.appendChild(newElement);
}

创建真实DOM的方法单独定义文件,方便复用。需要判断如果是元素节点就创建相应的元素,如果是文本节点就创建对应的文本。然后通过递归的方式创建子节点。最后将我们创建的这个节点放在指定的容器container中就可以了。

src/tinyReact/createDOMElement.js

import mountElement from "./mountElement";

function createDOMElement(virtualDOM) {
    let newElement = null;
    if (virtualDOM.type === 'text') {
        // 文本节点 使用createTextNode创建
        newElement = document.createTextNode(virtualDOM.props.textContent);
    } else {
        // 元素节点 使用 createElement 创建
        newElement = document.createElement(virtualDOM.type);
    }
    // 递归创建子节点
    virtualDOM.children.forEach(child => {
        mountElement(child, newElement);
    })
    return newElement;
}

为真实的DOM对象添加属性

我们知道属性是存储在虚拟DOM的props中的,我们只需要在创建元素的时候循环这个属性,将这些属性放在真实的元素中就可以了。

在添加属性的时候需要考虑不同的情况,比如说事件和静态属性都是不同的,而且添加属性的方法也是不同的,布尔属性和值属性的设置方式有所不同。还需要判断属性是不是children,因为children并不是属性,是我们自己定义的子元素,属性如果是className还需要转换成class进行添加。

src/tinyReact/createDOMElement.js

我们单独定一个方法来为元素添加属性,在创建元素之后调用这个方法,这里叫做updateNodeElement

import mountElement from "./mountElement";
import updateNodeElement from "./updateNodeElement";

function createDOMElement(virtualDOM) {
    let newElement = null;
    if (virtualDOM.type === 'text') {
        // 文本节点 使用createTextNode创建
        newElement = document.createTextNode(virtualDOM.props.textContent);
    } else {
        // 元素节点 使用 createElement 创建
        newElement = document.createElement(virtualDOM.type);
        // 调用添加属性的方法
        updateNodeElement(newElement, virtualDOM)
    }
    // 递归创建子节点
    virtualDOM.children.forEach(child => {
        mountElement(child, newElement);
    })
    return newElement;
}

首先需要获取节点对象的属性列表,使用Object.keys来获得属性名,然后使用forEach来遍历。

src/tinyReact/updateNodeElement.js

如果属性名以on开头我们就认为他是一个事件, 然后我们截取出事件名称也就是去掉首部的on并且将字符串小写,使用addEventListener来绑定事件。

如果属性名是value或者checked是不能使用setAttribute来设置的,直接属性名等于属性值即可。

最后判断属性名如果是className就转换成class,如果不为children则其它属性全部可以使用setAttribute来设置。

function updateNodeElement(newElement, virtualDOM) {
    // 获取节点对应的属性对象
    const newProps = virtualDOM.props;
    Object.keys(newProps).forEach(propName => {
        const newPropsValue = newProps[propName];
        // 判断是否是事件属性
        if (propName.startsWith('on')) {
            // 截取出事件名称
            const eventName = propName.toLowerCase().slice(2);
            // 为元素添加事件
            newElement.addEventListener(eventName, newPropsValue);
        } else if (propName === 'value' || propName === 'checked') {
            // 如果属性名是value或者checked不能使用setAttribute来设置,直接以属性方式设置即可
            newElement[propName] = newPropsValue;
        } else if (propName !== 'children') {
            // 排除children
            if (propName === 'className') {
                newElement.setAttribute('class', newPropsValue)
            } else {
                newElement.setAttribute(propName, newPropsValue)
            }
        }
    })
}

组件渲染 - 区分函数组件还是类组件

在渲染组件之前首先我们要明确地是,组件的虚拟DOM类型值为函数,函数组件和类组件都是如此。

const Head = () => <span>head</span>

组件的虚拟DOM

{
    type: function(){},
    props: {},
    children: []
}

在渲染组件时,要先将Component与Native Element区分开,如果是Native Element可以直接进行渲染,这个我们之前已经处理过了,如果是组件需要特别处理。

我们可以在入口文件src/index.js中渲染一个组件。

import TinyReact from "./TinyReact"

const root = document.getElementById('root');

function Demo () {
    return <div>hello</div>
}
function Head () {
  return <div><Demo /></div>
}

TinyReact.render(<Head />, root);

然后就需要在mountElement方法中区分原生标签和组件。

src/tinyReact/isFunction.js

function isFunction(virtualDOM) {
    return virtualDOM && typeof virtualDOM.type === 'function';
}

我们在mountComponent方法中处理组件。首先我们要考虑这个组件是类组件还是函数组件,因为他们的处理方式是不同的,可以使用原型上是否存在render函数。我们可以借助isFunctionComponent函数来判断

src/tinyReact/mountComponent.js

如果type存在,并且对象是一个函数,并且对象上不存在render方法,那就是一个函数组件 src/tinyReact/isFunctionComponent.js

import isFunctionComponent from './isFunctionComponent';

function mountComponent(virtualDOM, container) {
    // 判断组件是类组件还是函数组件
    if (isFunctionComponent(virtualDOM)) {
        
    }
}

src/tinyReact/isFunctionComponent.js

import isFunction from "./isFunction";

function isFunctionComponent(virtualDOM) {
    const type = virtualDOM.type;
    return type && isFunction(virtualDOM) && !(type.prototype && type.prototype.render)
}

处理函数组件

我们先来处理函数组件, 函数组件其实很简单,只需要调用type函数就可以了,就可以获取返回的虚拟dom。获取之后我们需要判断新获取的虚拟DOM是否是一个组件,如果是继续调用mountComponent,如果不是则为原生DOM元素直接调用mountNativeElement方法将虚拟DOM渲染到页面中。

src/tinyReact/mountComponent.js

import isFunction from './isFunction';
import isFunctionComponent from './isFunctionComponent';
import mountNativeElement from './mountNativeElement';

function mountComponent(virtualDOM, container) {
    //存储得到的虚拟DOM
    let nextVirtualDOM = null;
    // 判断组件是类组件还是函数组件
    if (isFunctionComponent(virtualDOM)) {
        // 处理函数组件
        nextVirtualDOM = buildFunctionComponent(virtualDOM);
    }
    // 判断是否仍是一个函数组件
    if (isFunction(nextVirtualDOM)) {
        mountComponent(nextVirtualDOM, container);
    }
    // 渲染nextVirtualDOM
    mountNativeElement(nextVirtualDOM, container);
}

function buildFunctionComponent (virtualDOM) {
    return virtualDOM.type();
}