用200行代码实现一个超小的react

285 阅读11分钟

完整代码点这里,除过注释和空行外基本200行。当然不可能全覆盖react的所有功能(包括调度),主要实现了渲染这块,帮助大家理解react渲染原理。实际上要投入生产环境的react框架要比这个复杂的多,可以看点这里看我对preact的源码解析

字符型元素渲染

我们首先实现一个能够渲染下面代码的react :

import React from '../../src/react'
import ReactDom from '../../src/react-dom'

const app = <div>
    <span>content</span>
</div>;

ReactDom.render(
    app,
    document.getElementById('root')
)

编译打包

上面的代码是jsx的语法,我们的js是不能够识别这种语法的(

这种),所以我们要把它转为js能够识别的代码,代码的编译打包就是做这件事。在webpack的配置中,需要把jsx这种文件类型的loader配置为babel-loader,而jsx的语法转化是由babel的 babel-preset-react 来完成的。下面是转化后的代码:

const app = React.createElement(
    "div", 
    null,
    React.createElement("span", null, "content")
);

对应jsx的语法会转为 React.createElement 这种形式

  • 第一个参数为节点的类型,

  • 第二个参数为props,

  • 第三个参数为节点的子节点,子节点有多个以此会铺开到第四、五个参数。如果是文本型的直接会是字符串,下面是一些更多转化案例:

    //dome1

    //上面编译后为 React.createElement( "div", { id: "test" }, React.createElement( "span", null, React.createElement("strong", null) ), React.createElement("span", null) );

    //demo2 function App(){ return

    } const app =

    //上面转化后如下 function App() { return React.createElement("div", null); } //元素类型这里的转化规则就是首字母是否是大写,大写就是原名称,小写就是字符串名称 const app = React.createElement(App, null);

虚拟DOM

编译后的代码到浏览器后开始执行。第一步是执行 const app = React.createElement("div",...) ,我们先写关于创建虚拟节点的代码。

//react.js
function createElement(type, props, ...children) {
    const newProps = {
        ...(props || {}),
        children
    }
    delete newProps.key

    return createVNode(type, newProps, props.key)
}

function createVNode(type, props, key) {
    return {
        type,
        props,
        key,
        //组件实例
        _component: null,
        //当前虚拟节点的真实dom
        _currentDom: null,
        //当前虚拟节点的真实dom的父节点
        _parentDom: null
    }
}

在createElement里会把children赋给props,然后调用了 createVNode 来创建一个虚拟节点。现在看下执行后的结果:

const app = React.createElement(
    "div", 
    null,
    React.createElement("span", null, "content")
);
/**
执行后 app = {
    "type":"div", _parentDom: null, _currentDom:null,
    "props":{
        "children":[{
            "type":"span", _parentDom: null, _currentDom:null,
            "props":{
                children:['content']
            }]
        }
    }
}
**/
ReactDom.render(
    app,
    document.getElementById('root')
)

diff渲染

我们首先分析下怎么把执行后的app的内容渲染到真实的dom上。我们从虚拟节点的第一级开始,根据type属性创建不同的element dom,完成后插入到父节点里。然后继续处理该虚拟节点的子节点,以此递归就会完成整个渲染。所以整个渲染主要需要两个函数,一个是处理虚拟节点,一个是处理子节点。

//react-dom.js
function render(element, container) {
    diff({
        ...element,
        _parentDom: container
    })
}
//diff/index.js
function diff(newVNode) {
    const newType = newVNode.type;
    const newProps = newVNode.props;

    if (typeof newType === 'string') {
        newVNode._currentDom = document.createElement(newType);
        newVNode._parentDom.appendChild(newVNode._currentDom)

        diffChildren(newVNode)
    } else if (['string', 'number'].includes(typeof newProps)) {
        newVNode._currentDom = document.createTextNode(newProps)
        newVNode._parentDom.appendChild(newVNode._currentDom)
    }
}
//diff/children.js
function diffChildren(newParentVNode) {
    const newChildren = newParentVNode.props.children = toChildren(newParentVNode.props.children);
    
    newChildren.forEach(children => {
        children._parentDom = newParentVNode._currentDom;

        diff(children)
    });
}

function toChildren(children) {
    const newChildren = children ?? [];
    const childrenArr = Array.isArray(newChildren) ? newChildren : [newChildren]

    return childrenArr.flat().map(children => {
        if (['string', 'number'].includes(typeof children)) {
            return React.createVNode(null, children, null)
        }

        return children
    })
}

上面代码中,diff处理虚拟节点、diffChildren处理子节点、toChildren则是把虚拟节点的children属性转为一个数组(有时不为数组),并且把文本内容(这种children=["content"])也转为虚拟节点方便diff中通用处理(转化后为{type:null,props:"content"})。

这里为什么要用diff这个名称呢?因为此时是首次渲染只有新虚拟节点,而再次渲染时会有老的虚拟节点,会做对比进行渲染。我们分析下整个app的渲染流程:

  1. 调用render方法进行渲染。在render里把要挂载的dom赋给了_parentDom,此时render内为这样:

    diff({ "type":"div", "_parentDom": div#root "props":{...}, )

2. 调用diff函数,在diff函数内走类型为string的处理分支。这里面调用document.createElement 创建一个真实的div dom元素,然后把这个追加到div#root的子节点里面,继续调用diffChildren处理子元素。

//newVnode 为 {"type":"div","_parentDom": div#root,"props":{...}}
function diff(newVNode){
    //newType = "div"
    const newType = newVNode.type;
    if (typeof newType === 'string') {
        newVNode._currentDom = document.createElement(newType);
        newVNode._parentDom.appendChild(newVNode._currentDom)

        diffChildren(newVNode, oldVNode)
    }
}

3. 在diffChildren中调用toChildren方法对传入的虚拟节点children属性进行处理,得到一个数组类型的子元素。然后进行遍历,对每一个子节点设置_parentDom属性方便后面在diff中关联,然后再调用diff对子节点进行渲染。

//此时这里 newParentVNode 为 
{"type":"div","_parentDom": div#root,"_currentDom": div,"props":{
  "children":[{
     "type":"span","props":{
       "children":["content"]
   }]}
}}
function diffChildren(newParentVNode) {
    //这里应该是 [{"type":"span","_parentDom":null,"props":{...}}]
    const newChildren = ...;
    
    newChildren.forEach(children => {
        //children此时为 {"type":"span""_parentDom":null,"props":{...}}
        //而 newParentVNode._currentDom 则是前面创建的div元素
        children._parentDom = newParentVNode._currentDom;

        diff(children)
    });
}

4.再次进入diff时对应的虚拟节点类型为'span',根据前面的分析会创建真实的span元素,并appendChildren到div中。接下来继续调用diffChildren处理span的子元素,由于span的子元素是文本型的数组,在调用toChildren时会为转为正常的虚拟节点。

//此时这里 newParentVNode 为 
{"type":"span","_parentDom": div,"_currentDom": span,"props":{
  "children":["content"]
}}
function diffChildren(newParentVNode) {
    //由于span的children为文本型数组,这里会经过toChildren转化
    //newChildren = [{type:null,props:"content"}]
    const newChildren = newParentVNode.props.children = toChildren(...)
}

5.diffChildren中会继续调用diff进行渲染,这里会进入newProps为string的分支处理。里面调用document.createTextNode创建一个文本节点,然后添加到span的子节点中。至此整个渲染结束,对应的真实dom都渲染挂载完成。

// newVNode 为 {type:null,"_parentDom":span,props:"content"}
// newProps 为 "content"
if (['string', 'number'].includes(typeof newProps)) {
    newVNode._currentDom = document.createTextNode(newProps)
    newVNode._parentDom.appendChild(newVNode._currentDom)
}

整个渲染流程还是有点模糊的同学建议多读几次或者到代码仓库中打断点来观察整个流程。

函数型组件渲染

import React from '../../src/react'
import ReactDom from '../../src/react-dom'

class Children extends React.Component{
    render(){
        return <div>1</div>
    }
}

function App(){
  return <Children/>
}

render(
  <App />,
  document.getElementById("root")
)

//---babel编译后---
class Children extends React.Component {
  render() {
    return React.createElement("div", null, "1");
  }
}
function App() {
  return React.createElement(Children, null);
}
render(
  React.createElement(App, null),
  document.getElementById("root")
)

前面讲了纯字符类型的虚拟节点渲染,这里讲函数性的渲染,以上面代码的渲染为例。

Component组件

上面代码中 Children类继承了React.Component,所以我们首先提供Component类,这个代码很简单:

//react.js
class Component {
    state = {}
}

diff渲染

在执行render后会调用diff,此时type属性值为App函数,我们要在 diff 中增加对这块的处理。

import React from '../../src/react'
//diff/index.js
function diff(newVNode) {
    const newType = newVNode.type;
    const newProps = newVNode.props;

    if (typeof newType === 'function') {
        if (!newVNode._component) {
            if ('prototype' in newType && newType.prototype.render) {
                //类组件
                newVNode._component = new newType(newProps)
            } else {
                //函数组件
                newVNode._component = new React.Component(newProps)
                newVNode._component.render = newType
            }
            newVNode._component.componentWillMount && newVNode._component.componentWillMount();
        }

        newVNode._currentDom = newVNode._parentDom;
        newProps.children = newVNode._component.render(newProps);
        diffChildren(newVNode)
    }
}
  1. 首先diff进入时会进入newType类型为函数的分支,由于没有_component属性会进入实例化组件的流程。由于type为App是函数组件,会先实例一个React.Component,然后把App函数设置到新实例的render属性中。接下来如果设置了componentWillMount生命周期会触发这个生命周期(类组件中才可能设置)。

    newVNode = { type:function App, _parentDom:div#root, props:{} }

2. 接下来会给虚拟节点设置 _currentDom 属性,函数性的虚拟节点并不会有真实的dom操作,但是他的子节点的dom会appendChild这个dom中,所有我们要设置这个属性。接下来执行render并赋值给对应的虚拟节点,完成后整个虚拟节点树会是下面这个样子:

newVNode = {
   type:function App,
   _parentDom:div#root,
   props:{
       children:{
         type:class Children
       }
   }
}

3. 接下来会调用diffChildren来遍历子节点,然后拿子节点继续调用diff。同上面的渲染一样,此时type值是Children,他是一个类会被实例化,然后继续调用render获得子元素。

//newVNode = {type:Children,_parentDom:div#root,props:{}}
function diff(newVNode){
    ...
}

4. 完成后的虚拟节点如下,此时会进入diffChildren继续处理子节点(type="div")。下面的流程跟字符型元素的渲染相同,当到div时创建div元素然后添加到div#root中,继续处理子节点,最终会完成整个渲染。

{
    type:class Children,
    _parentDom:div#root,
    props:{
        children:{
            type:"div",
            props:{
                children:["1"]
            }
        }
    }
}

setState渲染

接下来我们研究setState。对上面的Children组件进行改造,然后我们研究下面这个的渲染。

class Children extends React.Component{
    state = {
        number:1
    }
    onClick = ()=>{
        this.setState({
            number:2
        })
    }
    render(){
        return <div onClick={this.onClick}>{this.state.number}</div>
    }
}

首先我们要给Component增加setState方法。

//react.js
class Component {
    //下次渲染时的状态
    _nextState = null
    //对应的虚拟节点
    _vNode = null

    setState(partialState) {
        this._nextState = {
            ...this.state,
            ...partialState
        }

        enqueueRender(this)
    }
}
function enqueueRender(component) {
    setTimeout(() => {
        diff(component._vNode, {
            ...component._vNode,
            props: {...component._vNode.props}
        })
    }, 0)
}

这里diff会有两个参数,我们要对diff进行改造

//diff/index.js
function diff(newVNode, oldVNode) {
   if (typeof newType === 'function') {
       newVNode._component = oldVNode && oldVNode._component ? oldVNode._component : newVNode._component;
       ...
       if (newVNode._component._nextState !== null) {
            newVNode._component.state = newVNode._component._nextState;
            newVNode._component._nextState = null;
        }

        newVNode._component._vNode = newVNode;
   }
   else if (typeof newType === 'string') {
        if (oldVNode && oldVNode._currentDom) {
            newVNode._currentDom = oldVNode._currentDom;
        } else {
            newVNode._currentDom = document.createElement(newType);
            newVNode._parentDom.appendChild(newVNode._currentDom)
        }
        ...
    } else if (['string', 'number'].includes(typeof newProps)) {
        if (oldVNode && oldVNode._currentDom) {
            newVNode._currentDom = oldVNode._currentDom;
            newVNode._currentDom.data !== String(newProps) && (newVNode._currentDom.data = newProps);
        } else {
            newVNode._currentDom = document.createTextNode(newProps)
            newVNode._parentDom.appendChild(newVNode._currentDom)
        }
    }
}
//diff/children.js
function diffChildren(newParentVNode, oldParentVNode) {
    const newChildren = newParentVNode.props.children = toChildren(newParentVNode.props.children);
    const oldChildren = (oldParentVNode || {props: {}}).props.children = toChildren(oldParentVNode?.props.children);

    newChildren.forEach(children => {
        const oldVNodeIndex = oldChildren.findIndex(child => child.type === children.type && child.key === children.key);
        const oldVNode = oldVNodeIndex !== -1 ? oldChildren.splice(oldVNodeIndex, 1)[0] : null

        children._parentDom = newParentVNode._currentDom;

        diff(children, oldVNode)
    });

    oldChildren.forEach(children => {
        if (typeof children.type !== 'function') {
            children._parentDom.removeChild(children._currentDom);
        }
    })
}

接下来我们分析下整个的渲染:

  1. 调用setState后会把新的状态存在_nextState属性中,然后调用enqueueRender来进行渲染这个组件。

2. enqueueRender中用了setTimeout来异步渲染。在首次渲染时对应的实例里保存了他的虚拟节点,所以直接可以通过component._vNode来拿到对应的虚拟节点,此时老的虚拟节点就是一个虚拟节点的拷贝,然后调用diff进行渲染。

//首次渲染这段代码将对应的虚拟节点保存到实例中
newVNode._component._vNode = newVNode

//此时newVNode和oldVNode的值都是下面内容
{
    type:class Children,
    _component:class Children {state:{number:1}...}
    _parentDom:div#root,
    _currentDom:div#root,
    props:{
        children:[{
            type:"div",
            _parentDom:div#root,
            _currentDom:div
            props:{
                children:[{
                   type:null,
                   props:1,
                   _parentDom:div,
                   _currentDom:text=1
                }]
            }
        }]
    }
}
function diff(newVNode, oldVNode){
  ...
}

3.在执行diff时newVNode._component已经有对应的值所以不会继续实例化。接下来是对nextState的处理,把他设置到新的状态中,然后调用render。此时render中对应的state已经是{number:2},然后把render结果赋给 newProps.children ,而 oldVNode中的props则保持不变。

newVNode = {
    type:class Children,
    _component:class Children {state:{number:2}...}
    _parentDom:div#root,
    _currentDom:div#root,
    props:{
        children:{
            type:"div",
            props:{
                children:[2]
            }
        }
    }
}
//oldVNode保持不变,同传进来一样

4. 接下来会进入diffChildren函数来处理,拿children在老的虚拟节点子元素中查找是否存在。查找规则就是type和key相同,由于key没有设置都是undefined,而类型为div的节点存在老的子节点中,所以会存在oldVNode对象,然后继续调用diff继续渲染。

function diffChildren(newParentVNode, oldParentVNode) {
    // 此时 children = {type:"div",...}
    newChildren.forEach(children => {
        const oldVNodeIndex = oldChildren.findIndex(child => child.type === children.type && child.key === children.key);
        const oldVNode = oldVNodeIndex !== -1 ? oldChildren.splice(oldVNodeIndex, 1)[0] : null


        diff(children, oldVNode)
    })
}

5. diff为div的虚拟节点时,由于存在oldVNode,所以不会执行createElement来创建新的dom,达到复用的目的。如果这里的type是函数则不会创建新的实例。

//此时newVNode 为
{
    type:"div",
    _parentDom:div#root,
    props:{
         children:[2]
    }
}
//oldVNode 为
{
    type:"div",
    _parentDom:div#root,
    _currentDom:div
    props:{
         children:[{
             type:null,
             props:1,
             _parentDom:div,
             _currentDom:text=1
         }]
    }
}
function diff(newVNode, oldVNode){
    if (typeof newType === 'string') {
        if (oldVNode && oldVNode._currentDom) {
            newVNode._currentDom = oldVNode._currentDom;
        } else {
            newVNode._currentDom = document.createElement(newType);
            newVNode._parentDom.appendChild(newVNode._currentDom)
        }
        ...
    }
}

6. 继续处理子节点,然后调用diff再处理{type:null,props:2}的虚拟节点。此时存在oldVNode,所有不会调用createTextNode来创建新的文本节点,而是复用老的文本节点,然后通过data(原生js dom提供的方法)将1改为2,至此整个渲染完成。

if (['string', 'number'].includes(typeof newProps)) {
    if (oldVNode && oldVNode._currentDom) {
        newVNode._currentDom = oldVNode._currentDom;
        newVNode._currentDom.data !== String(newProps) && (newVNode._currentDom.data = newProps);
    } else {
        newVNode._currentDom = document.createTextNode(newProps)
        newVNode._parentDom.appendChild(newVNode._currentDom)
    }
}

props处理

const app = <div id="app" style={{color:"#00f"}} onClick={()=>{
    alert(1)
}}/>
//编译后为
const app = React.createElement(
    'div',
    {
        id:"app",
        style:{color:"#00f"},
        onClick:function(){
            alert(1) 
        }
    }
)
//执行后对应的虚拟节点为
{type:"div",props:{
    id:"app",
    style:{color:"#00f"}
    ...
}}

最后要讲的就是对props的处理,也就是dom的属性。这个只会在type为字符串的虚拟节点有效,所有我们改造下diff方法,然后调用diffProps来处理。

//diff/index.js
function diff(newVNode, oldVNode){
    if (typeof newType === 'string') {
        ...
        diffProps(newVNode, oldVNode)
        diffChildren(newVNode, oldVNode)
    }
}
//diff/props.js
function diffProps(newVNode, oldVNode) {
    const newProps = newVNode.props
    const oldProps = oldVNode?.props ?? {};

    for (let key in oldProps) {
        if (!(key in newProps)) {
            setProperty(newVNode._currentDom, key, null, oldProps[key])
        }
    }
    for (let key in newProps) {
        setProperty(newVNode._currentDom, key, newProps[key], oldProps[key])
    }
}

const eventReg = /on[\w]/;

function setProperty(dom, name, newValue, oldValue) {
    if (['key', 'ref', 'children'].includes(name)) {
        return;
    }
    if (eventReg.test(name)) {
        setEvent(dom, name, newValue, oldValue)
        return
    }
    if (name === 'style') {
        if (oldValue) {
            for (let i in oldValue) {
                if (!(newValue && i in newValue)) {
                    setStyle(dom.style, i, '');
                }
            }
        }
        if (newValue) {
            for (let i in newValue) {
                if (!oldValue || newValue[i] !== oldValue[i]) {
                    setStyle(dom.style, i, newValue[i]);
                }
            }
        }
        return;
    }
    if (name === 'className') {
        dom.className = newValue || ''
    } else if (newValue === null) {
        dom.removeAttribute(name)
    } else {
        dom.setAttribute(name, newValue);
    }

}

function setEvent(dom, name, newValue, oldValue) {
    name = name.toLocaleLowerCase().slice(2);
    if (newValue) {
        if (!oldValue) dom.addEventListener(name, eventProxy);

        (dom._listeners || (dom._listeners = {}))[name] = newValue;
    } else {
        dom.removeEventListener(name, eventProxy);
    }
}

function setStyle(style, name, value) {
    if (typeof value === 'number') {
        style[name] = value + 'px';
    } else if ([undefined,null].includes(value)) {
        style[name] = '';
    } else {
        style[name] = value;
    }
}

function eventProxy(e) {
    this._listeners[e.type](e);
}

在diffProps中对虚拟节点的props进行处理,主要分两种情况:

  • 第一种是对应的属性删除,也就是在新的props中不存在,只在老的props中出现,这种就需要删除对应的属性

  • 第二种是新增或者修改对应的属性

这两种都会遍历所有属性然后调用setProperty来处理具体的属性,这里分四种情况:

  1. 如果属性的健名是 key、ref、children,这种则不需要进行处理。

  2. 如果健名是以on开头的表示是事件类型,则调用setEvent处理。在这个函数里面,如果newValue为空就需要删除对应的事件,不然就添加。添加的时候会把对应的事件函数和事件名称保存到dom的_listeners属性里,然后对应的事件监听函数是固定的函数,触发事件时会从dom的_listeners里取真正的监听函数来执行。

  3. 如果属性健名是style则是样式。样式同样会有删除对应的规则和添加修改对应的规则两种情况,都会调用setStyle来处理。如果属性值是数字则自动加px然后设置到dom.style中,如果属性值为空则删除对应的规则,不然就设置对应的规则。

  4. 如果属性的健名是className则通过dom.className来设置。

  5. 其他的情况通过dom.removeAttribute和dom.setAttribute来设置和删除对应的属性