Blog Logo

从零实现一个简易版react

写于2021-04-22 10:04 阅读耗时39分钟 阅读量


最近在学习react底层的相关知识点,想把react再深入一步学习,于是试着自己写个react出来。当然这篇文章也是从其他大牛身上学习到的,拿出来整理一下,文章最后附有该篇文章具体实现逻辑的源码,已放在github地址。

  • 1.JSX是什么?
  • 2.Virtual DOM出现的目的?
  • 3.什么是Virtual DOM?
  • 4.Virtual DOM 如何提升效率?
  • 5.实现一个简易版react
    • 5.1.创建Virtual DOM
    • 5.2.渲染Virtual DOM
    • 5.3.渲染组件
    • 5.5.更新Virtual DOM
    • 5.6.setState
    • 5.7.更新组件
    • 5.8.ref属性

react的核心功能点:如何创建、渲染、更新Virtual DOM?如何渲染组件、更新组件? 自己实现这些,也就明白react的大部分核心了。 首先看下思维导图,看不懂没关系,后面看完,会有更清晰的认识:

react


1.JSX是什么?

JSX是一种JavaScript语法的扩展,React使用它来描述用户界面长成什么样子。虽然它看起来非常像HTML,但它确实是JavaScript。 在React代码执行之前,Babel会将JSX编译为React API

<div className='container'>
    <p>Hello</p>
</div>)

会编译成:

React.createElement("div", {
    className: "container"
  }, React.createElement("p", null, "Hello"));
}

可通过babeljs官网repl查看: https://babeljs.io/repl

view


2.Virtual DOM出现的目的?

Virtual DOM 出现的目的是为了提高 JavaScript 操作 DOM 对象的效率


3.什么是Virtual DOM?

Virtual DOM其实是JavaScript对象,是描述真实DOM的一种方式。

<div className="container">
  <h3>Hello React</h3>
  <p>React is great </p>
</div>

Virtual DOM对象:

{
  type: "div",
  props: { className: "container" },
  children: [
    {
      type: "h3",
      props: null,
      children: [
        {
          type: "text",
          props: {
            textContent: "Hello React"
          }
        }
      ]
    },
    {
      type: "p",
      props: null,
      children: [
        {
          type: "text",
          props: {
            textContent: "React is great"
          }
        }
      ]
    }
  ]
}

4.Virtual DOM 如何提升效率?

对比Virtual DOM对象,从中找出差异,最终只更新对象中存在差异的部分,从而提高JavaScript操作DOM对象的效率。

<div id="container">
    <p>Hello React</p>
</div>

更改成:

<div id="container">
    <p>Hello Angular</p>
</div>

Virtual DOM:

const before = {
  type: "div",
  props: { id: "container" },
  children: [
    {
      type: "p",
      props: null,
      children: [
        { type: "text", props: { textContent: "Hello React" } }
      ]
    }
  ]
}

更改后:

const after = {
  type: "div",
  props: { id: "container" },
  children: [
    {
      type: "p",
      props: null,
      children: [
        { type: "text", props: { textContent: "Hello Angular" } }
      ]
    }
  ]
}

5.实现一个简易版react

5.1.创建Virtual DOM

在 React 代码执行前,JSX 会被 Babel 转换为 React.createElement 方法的调用,在调用 createElement 方法时会传入元素的类型,元素的属性,以及元素的子元素,createElement 方法的返回值为构建好的 Virtual DOM 对象。

自己实现React第一步: 把Babel默认的React.createElement,换成自己的TinyReact.createElement: 在.babelrc中配置:

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

在index.js中写JSX:

const virtualDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test="test">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <span>这是一段内容</span>
      <button onClick={() => alert("你好")}>点击我</button>
      <h3>这个将会被删除</h3>
      2, 3
      <input type="text" value="13"/>
    </div>
)
console.log(virtualDOM)

tinyreact

会报TinyReact,未定义。


第二步,引入TinyReact:

import TinyReact from './TinyReact'
const virtualDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test="test">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <span>这是一段内容</span>
      <button onClick={() => alert("你好")}>点击我</button>
      <h3>这个将会被删除</h3>
      2, 3
      <input type="text" value="13"/>
    </div>
)
console.log(virtualDOM)

第三步,定义TinyReact.createElement方法:

export default function createElement(type, props, ...children) {
    console.log('createElement------')
}

会发现自动执行了createElement方法多次,返回的virtualDOM值为undefined。

createElement


第四步,TinyReact.createElement方法的具体实现: 返回含元素的类型type、属性props、子元素children的对象:

export default function createElement(type, props, ...children) {
    console.log('createElement------')
    const childrenElement = [...children].reduce((rst, child) => {
        // 判断不是boolean或null,才创建DOM
        if (child !== true && child !== false && child !== null) {
            if( child instanceof Object ) {
                // 是节点
                rst.push(child)
            } else {
                // 是文本,创建节点
                rst.push(createElement('text', { textContent: child }))
            }
        }
        return rst
    }, []) // 第二参数,结果的初始值
    return {
        type,
        props: Object.assign({children: childrenElement}, props), // 把children放进props里
        children: childrenElement,
    }
}

最终成功返回VirtualDOM对象。

createElementOK

创建Virtual DOM的实现机制:JSX通过Babel自动调用React.createElement,返回Virtual DOM对象。 核心API:React.createElement


5.2.渲染Virtual DOM

渲染Virtual DOM,实际是将虚拟Virtual DOM 对象转成真实 DOM 对象的过程。 通过调用 render 方法可以将 Virtual DOM 对象更新为真实 DOM 对象。 render里面可以是原生DOM元素,也可以是组件。

nativeDOM

目前先实现原生DOM元素的渲染:

实现render函数:

import mountNativeElement from './mountNativeElement'

export default function render(virtualDOM, container) {
    mountNativeElement(virtualDOM, container)
}

实现mountNativeElement函数:

export default function mountNativeElement(virtualDOM, container) {
    let newElement = null
    if(virtualDOM.type === 'text') {
        // 文本节点
        newElement = document.createTextNode(virtualDOM.props.textContent)
    } else {
        // 元素节点
        newElement = document.createElement(virtualDOM.type)
        // 递归创建子节点
        virtualDOM.children.forEach(child => {
            mountNativeElement(child, newElement)
        })
    }
    // 转化的DOM放置到页面中
    container.appendChild(newElement)
}

页面调用render:

import TinyReact from './TinyReact'
const root = document.getElementById('root')
const virtualDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test="test">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <span>这是一段内容</span>
      <button onClick={() => alert("你好")}>点击我</button>
      <h3>这个将会被删除</h3>
      2, 3
      <input type="text" value="13"/>
    </div>
)
TinyReact.render(virtualDOM, root)
console.log(virtualDOM)

渲染成功:

react-view

render函数的实现机制:通过递归创建所有节点后,最后添加到root节点里完成渲染。 节点包括:文本节点和元素节点。 核心API有:createTextNodecreateElementappendChild


渲染Virtual DOM后,我们还需要为元素节点添加属性。 需要解析所有元素节点的属性对象,比如:事件、value值、class样式、自定义属性等。 在render创建元素节点的逻辑后面,新增一个解析属性的方法updateNodeElement:

export default function mountNativeElement(virtualDOM, container) {
    let newElement = null
    if(virtualDOM.type === 'text') {
        // 文本节点
        newElement = document.createTextNode(virtualDOM.props.textContent)
    } else {
        // 元素节点
        newElement = document.createElement(virtualDOM.type)
        // 解析元素节点属性
        updateNodeElement(newElement, virtualDOM)
        ...
    }
    ...
}

实现updateNodeElement函数:

export default function updateNodeElement(newElement, virtualDOM) { 
    const { props } = virtualDOM
    Reflect.ownKeys(props).forEach(propName => {
        // 如果是children,则排除
        const propsValue = props[propName]
        if (propName !== 'children') {
            if (propName.slice(0, 2) === 'on') {
                // 如果是事件属性,各种事件,比如:on
                // 把onClick -> click
                const event = propName.toLocaleLowerCase().slice(2)
                newElement.addEventListener(event, propsValue)
            } else if (propName === 'value' || propName === 'checked') {
                // 如果是名称属性,比如:value或checked
                newElement[propName] = propsValue
            } else if (propName === 'className') {
                // 如果是样式属性,className
                // 把className -> class
                newElement.setAttribute('class', propsValue)
            } else {
                // 如果是自定义属性,比如:data-test
                newElement.setAttribute(propName, propsValue)
            }
        }
    })
}

渲染成功: 无属性前: react-view

有属性后: react_view


说下具体的判断逻辑,首先排除props里面的children属性,因为该属性是父节点下的所有子节映射,所以暂时不需要做什么处理。排除children属性后,依次按照属性类型属于事件属性名称属性样式属性自定义属性,对该节点做不同的处理逻辑。

最后简单总结:为元素节点添加属性的核心是依据不同的属性类型做不同的处理逻辑。 属性包括:事件属性、名称属性、样式属性、自定义属性。 核心API有:addEventListenersetAttribute


5.4.渲染组件

在前面,我们都是在渲染Virtual DOM,即JXS定义的DOM对象。

const virtualDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test="test">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <span>这是一段内容</span>
      <button onClick={() => alert("你好")}>点击我</button>
      <h3>这个将会被删除</h3>
      2, 3
      <input type="text" value="13"/>
    </div>
)
TinyReact.render(virtualDOM, root)

除此之外,我们还可以渲染组件,组件包括函数组件类组件。 函数组件:

const FunComp = (props) => {
  return (
    <div>
      <div>Hello, {props.name}!</div>
    </div>
  )
}
TinyReact.render(<FunComp name="FunComp"/>, root)

类组件:

class Comp extends TinyReact.Component {
  constructor(props) {
    super(props)
  }
  render () {
    return (
      <div>
        <div>Hello, {this.props.name}!</div>
      </div>
    )
  }
}
TinyReact.render(<Comp name="Comp"/>, root)

第一步:在之前的render函数中,除了mountNativeElement函数,去渲染JSX外,还需新增一个渲染组件的方法mountComponent 之前:

import mountNativeElement from './mountNativeElement'

export default function render(virtualDOM, container) {
    mountNativeElement(virtualDOM, container)
}

之后:

import mountComponent from './mountComponent'
import mountNativeElement from './mountNativeElement'

export default function mountElement(virtualDOM, container) {
    if (typeof virtualDOM.type === 'function') {
        // Component
        mountComponent(virtualDOM, container)
    } else {
        // Native Element
        mountNativeElement(virtualDOM, container)
    }
}

第二步,实现mountComponent函数:

import mountElement from "./mountElement"

export default function mountComponent(virtualDOM, container) {
    const { type, props } = virtualDOM
    if (type.prototype && type.prototype.render) {
        // 类组件extends Component
        mountElement(new type(props || {}).render(), container)
    } else {
        // 函数组件Function
        mountElement(type(props || {}), container)
    }
}

第三步,实现自定义TinyReact.Component类:

export default class Component {
    constructor(props) {
        this.props = props
    }
}

最终效果: 渲染函数组件: TinyReact.render(<FunComp name="FunComp"/>, root) funComp

渲染类组件: TinyReact.render(<Comp name="Comp"/>, root) comp

说下具体的判断逻辑,渲染Virtual DOM在之前就实现好了(mountNativeElement函数),于是后面的渲染组件,递归到最后都会去执行渲染Virtual DOM(mountNativeElement函数)的逻辑。

用大白话讲,之前我们封装的mountNativeElement函数, 主要功能是将jsx(<div>xxx</div>)格式的对象解析并渲染。 而像函数组件fun(){return <div>xxx</div>}; 类组件class comp(){render(){return <div>xxx</div>}}。 它俩不是直接返回成jsx的,而是需要把它俩弄成jsx后,执行之前的mountNativeElement函数就行了。

函数组件的逻辑是执行fun(),则返回jsx, 类组件的逻辑是先const c = new comp(),再执行c.render()函数,最后返回jsx


值的注意的是: 1.如何判断是Virtual DOM对象还是组件? 答:通过typeof virtualDOM.type === 'function'去实现,即判断virtualDOM的type属性类型是不是函数,如果是则为组件,否则为Virtual DOM对象。

2.如何判断组件是类组件还是函数组件? 答:通过type.prototype && type.prototype.render去实现,即判断virtualDOM的type是函数,且该函数的原型链上是否有render方法,如果有,则为类组件,否则为函数组件。

3.如何实现类组件和函数组件props传值? 答: a.类组件通过extends TinyReact.Component后,函数组件通过从constructor中获取(super(props))父类(TinyReact.Component)的props值,实现类组件之间的传递的,父类的props值是通过new comp(xxx)传入的,传值执行顺序:new (props) => Component props => FunComp props

b.函数组件通过fun(props)进行传值。


最后简单总结:渲染组件的核心是将组件返回出jsx,最后走原生DOM元素的渲染逻辑。 组件包括:类组件和函数组件。 核心函数实现:类组件new type(props || {}).render()、函数组件type(props || {})


5.5.更新Virtual DOM

5.5.1.Virtual DOM 类型相同

当我们把之前渲染出来的virtualDOM,延迟两秒更新其中里面的部分内容,来模拟Virtual DOM的更新逻辑。 更新前的virtualDOM:

const virtualDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test="test">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <span>这是一段内容</span>
      <button onClick={() => alert("你好")}>点击我</button>
      <h3>这个将会被删除</h3>
      2, 3
      <input type="text" value="13"/>
    </div>
  )

更新后的modifyDOM:

const modifyDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2>(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <span>这是更改后的内容</span>
      <button onClick={() => alert("你好!!!")}>点击我</button>
      <h3>这个将会被删除</h3>
      2, 3
      <input type="text" value="13"/>
    </div>
)

TinyReact.render(virtualDOM, root)
setTimeout(() => {
  TinyReact.render(modifyDOM, root)
}, 2000)

更新的内容有:h2的data-test属性删除了、span的内容改变了、事件里的alert输出改变了。


第一步,将最初的创建文本节点和元素节点的时候,给该节点添加一个_virtualDOM,存放最初的virtualDOM:

import mountElement from './mountElement'
import updateNodeElement from './updateNodeElement'
export default function createDOMElement(virtualDOM) { 
    let newElement = null
    if(virtualDOM.type === 'text') {
        // 文本节点
        newElement = document.createTextNode(virtualDOM.props.textContent)
    } else {
        // 元素节点
        newElement = document.createElement(virtualDOM.type)
        // 解析元素节点属性
        updateNodeElement(newElement, virtualDOM)
        // 递归创建子节点
        virtualDOM.children.forEach(child => {
            mountElement(child, newElement)
        })
    }
    // 为每个DOM元素设置_virtualDOM属性,用于对比节点是否发生变化
    newElement._virtualDOM = virtualDOM
    return newElement
}

第二步,更改render函数逻辑:

import mountNativeElement from './mountNativeElement'

export default function render(virtualDOM, container) {
    diff(virtualDOM, container, container.firstChild)
}

第三步,实现diff函数:

import render from './mountElement'
import updateNodeElement from './updateNodeElement'

export default function diff(virtualDOM, container, oldDOM) {
    // 判断oldDOM是否存在
    if (!oldDOM) {
        mountElement(virtualDOM, container)
    } else {
        // 更新对比逻辑
        const { _virtualDOM } = oldDOM
        // 第一种情况,Virtual DOM 类型相同
        if(virtualDOM.type === _virtualDOM.type) {
            if(virtualDOM.type === 'text') {
                // 文本节点 对比文本内容是否发生变化
                const { textContent: newText } = virtualDOM.props
                const { textContent: oldText } = _virtualDOM.props
                if (newText !== oldText) {
                    oldDOM.textContent = newText
                    oldDOM._virtualDOM = virtualDOM
                }
            } else {
                // 元素节点 对比属性内容是否发生变化
                updateNodeElement(oldDOM, virtualDOM, _virtualDOM)
            }
            // 递归virtualDOM的子元素
            virtualDOM.children.forEach((child, i) => {
                diff(child, oldDOM, oldDOM.childNodes[i])
            })
        }
    }
    // ...
}

第四步:更改updateNodeElement函数逻辑:

export default function updateNodeElement(newElement, virtualDOM, _virtualDOM = {}) {
    // 没有_virtualDOM,则是第一次渲染,新增属性
    if (Reflect.ownKeys(_virtualDOM).length === 0) {
        console.log('新增属性----')
        // 新增属性
        const { props } = virtualDOM
        Reflect.ownKeys(props).forEach(propName => {
            // 如果是children,则排除
            const propsValue = props[propName]
            if (propName !== 'children') {
                if (propName.slice(0, 2) === 'on') {
                    // 如果是事件属性,各种事件,比如:on
                    // 把onClick -> click
                    const event = propName.toLocaleLowerCase().slice(2)
                    newElement.addEventListener(event, propsValue)
                } else if (propName === 'value' || propName === 'checked') {
                    // 如果是名称属性,比如:value或checked
                    newElement[propName] = propsValue
                } else if (propName === 'className') {
                    // 如果是样式属性,className
                    // 把className -> class
                    newElement.setAttribute('class', propsValue)
                } else {
                    // 如果是自定义属性,比如:data-test
                    newElement.setAttribute(propName, propsValue)
                }
            }
        })
    } else {
        // 更新属性
        console.log('更新属性----')
        const { props: newProps } = virtualDOM
        const { props: oldProps } = _virtualDOM
        Reflect.ownKeys(newProps).forEach(propName => {
            if (propName !== 'children') {
                const newPropsValue = newProps[propName]
                const oldPropsValue = oldProps[propName]
                if (newPropsValue !== oldPropsValue) {
                    console.log('属性值不同----------需要更新')
                    // 属性值不同
                    if (propName.slice(0, 2) === 'on') {
                        // 如果是事件属性,各种事件,比如:on
                        // 把onClick -> click
                        const event = propName.toLocaleLowerCase().slice(2)
                        newElement.removeEventListener(event, oldPropsValue)
                        newElement.addEventListener(event, newPropsValue)
                    } else if (propName === 'value' || propName === 'checked') {
                        // 如果是名称属性,比如:value或checked
                        newElement[propName] = newPropsValue
                    } else if (propName === 'className') {
                        // 如果是样式属性,className
                        // 把className -> class
                        newElement.setAttribute('class', newPropsValue)
                    } else {
                        // 如果是自定义属性,比如:data-test
                        newElement.setAttribute(propName, newPropsValue)
                    }
                }
            }
        })
        // 判断属性被删除的情况
        Reflect.ownKeys(oldProps).forEach(propName => {
            if (propName !== 'children') {
                const newPropsValue = newProps[propName]
                const oldPropsValue = oldProps[propName]
                if(!newPropsValue) {
                    // 属性值被删除了
                    console.log('属性需要删除----', propName)
                    if (propName.slice(0, 2) === 'on') {
                        // 如果是事件属性,各种事件,比如:on
                        // 把onClick -> click
                        const event = propName.toLocaleLowerCase().slice(2)
                        newElement.removeEventListener(event, oldPropsValue)
                    } else {
                        // 如果是非事件属性
                        newElement.removeAttribute(propName)
                    }
                }
            }
        })
    }
}

最终效果: 第一次渲染: react_view2

2s更改后渲染: react_view3


说下具体的判断逻辑,首先在创建文本和元素节点的地方,新增一个_virtualDOM属性,用于对比节点是否发生变化。 然后判断oldDOM是否存在,第一次的时候container.firstChild是没有的,因为<div id="root"></div>下面没有子节点,所以oldDOM肯定不存在,走原生DOM元素渲染逻辑。 在更新的时候,因为页面已经渲染出了DOM元素,且该元素必须只有一个根节点,所以可以通过firstChild取得oldDOM,给oldDOM赋值后,oldDOM存在。

接着当oldDOM存在后,执行更新对比逻辑。在做更新对比前,需要判断Virtual DOM 类型是否相同。因为相同,意味着只是属性发生了变化,不同,意味着有增删节点的变化,直接将老节点替换成新节点,因此实现逻辑是不同的。

接着先执行第一种简单的情况,Virtual DOM 类型完全相同,只是属性值发生变化的情况。 属性值发生变化的方式有两种:属性值发生了更改属性值被删除了

最后通过_virtualDOM是否存在,去做对应的逻辑处理,如果_virtualDOM存在,则执行更新属性逻辑,如果_virtualDOM不存在,则是第一次渲染,新增属性。 在更新属性逻辑里,属性对比后,如果属性值不同,说明值更改了,需要更新新值;如果在老的_virtualDOM里没有发现新的virtualDOM里的属性值,说明原来的属性被删除了。

重复上面的逻辑,进行递归,一个一个的进行对比。


最后简单总结:实现Virtual DOM类型相同的对比,核心是在创建文本和元素节点的时候,新增_virtualDOM属性,用于更新前后属性对比,最后通过递归对DOM元素一个一个进行对比,有变化的进行属性值更改或属性删除。

核心API有:addEventListenerremoveEventListenersetAttributeremoveAttribute


5.5.2.Virtual DOM 类型不同

更新前的virtualDOM:

const virtualDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test="test">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <span>这是一段内容</span>
      <button onClick={() => alert("你好")}>点击我</button>
      <h3>这个将会被删除</h3>
      2, 3
      <input type="text" value="13"/>
    </div>
)

更新后的modifyDOM:

const modifyDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test2="test123">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <div>这是更改后的内容</div>
      <button onClick={() => alert("你好!!!")}>点击我</button>
      <input type="text" value="13"/>
    </div>
)
TinyReact.render(virtualDOM, root)
setTimeout(() => {
  TinyReact.render(modifyDOM, root)
}, 2000)

下面是,diff函数实现:

import createDOMElement from './createDOMElement'
import mountElement from './mountElement'
import updateNodeElement from './updateNodeElement'

export default function diff(virtualDOM, container, oldDOM) {
    // 判断oldDOM是否存在
    if (!oldDOM) {
        mountElement(virtualDOM, container)
    } else {
        // 更新对比逻辑
        const { _virtualDOM } = oldDOM
        // 第一种情况,Virtual DOM 类型相同
        if(virtualDOM.type === _virtualDOM.type) {
            ...
            // 判断新节点和老节点的数量,如果老节点的数量大于新节点的数量,则有删除节点
            const nLen = virtualDOM.children.length
            const oLen = oldDOM.childNodes.length
            if (oLen > nLen) {
                for(let i = oLen - 1; i > nLen - 1; i-- ) {
                    oldDOM.removeChild(oldDOM.childNodes[i])
                }
            }
        } else {
            // 第二种情况,Virtual DOM 类型不相同
            // 将老的元素节点替换
            const newNode = createDOMElement(virtualDOM)
            oldDOM.parentNode.replaceChild(newNode, oldDOM)
        }
    }
}

最终效果: 第一次渲染: react_view2

2s更改后渲染: react_view4


说下具体的判断逻辑,在Virtual DOM 类型不相同的时候,只需要将老的元素节点替换成新的元素节点。有种情况是当老的元素节点被删除的时候,删除节点发生在节点更新以后并且发生在同一个父节点下的所有子节点身上。 在节点更新完成以后,如果旧节点对象的数量多于新 VirtualDOM 节点的数量,就说明有节点需要被删除。

最后简单总结:实现Virtual DOM类型不同的对比,核心是将老的元素节点替换成新的元素节点。如果是删除节点的情况,需走删除节点逻辑。

核心API有:replaceChildremoveChild


5.6.setState

定义一个Comp组件:

class Comp extends TinyReact.Component {
  constructor(props) {
    super(props)
    this.state = {
      title: 'Defualt Title'
    }
    this.changeTitle = this.changeTitle.bind(this)
  }
  changeTitle() {
    this.setState({title:'Changed Title'})
  }
  render () {
    return (
      <div>
        <div>
          <div>{this.state.title}</div>
          <button onClick={this.changeTitle}>改变title</button>
        </div>
        <div>Hello, {this.props.name}!</div>
      </div>
    )
  }
}
TinyReact.render(<Comp name="张三"/>, root)

当点击改变title按钮后,通过setState实现title的更新。

第一步,在Componet类中新增setState、setDOM存放老的DOM对象、getDOM获取老的DOM对象逻辑:

import diff from "./diff"

export default class Component {
    constructor(props) {
        this.props = props
    }
    setState(state) {
        this.state = Object.assign({} , this.state, state)
        const newVirtualDOM = this.render()
        const oldDOM = this.getDOM()
        diff(newVirtualDOM, oldDOM.parentNode, oldDOM)
    }
    setDOM(dom) {
        this._dom = dom
    }
    getDOM() {
        return this._dom
    }
}

第二步,在mountComponent函数中,为 Virtual DOM 对象添加 component 属性, 值为类组件的实例对象。

import mountElement from "./mountElement"

export default function mountComponent(virtualDOM, container) {
    const { type, props } = virtualDOM
    if (type.prototype && type.prototype.render) {
        // 类组件extends Component
        const component = new type(props || {})
        const newVirtualDOM = component.render()
        newVirtualDOM.component = component
        mountElement(newVirtualDOM, container)
    } else {
        // 函数组件Function
        mountElement(type(props || {}), container)
    }
}

第三步,在mountNativeElement函数中,从类组件的实例对象中调用setDOM,用于存放之前老的DOM元素:

import createDOMElement from "./createDOMElement"

export default function mountNativeElement(virtualDOM, container) {
    let newElement = createDOMElement(virtualDOM)
    // 转化的DOM放置到页面中
    container.appendChild(newElement)
    // 类组件
    let component = virtualDOM.component
    if (component) {
        component.setDOM(newElement)
    }
}

最终效果: 未点击: cliick_before

点击后: click_after

说下具体的实现逻辑,首先我们要明白setState的调用逻辑和TinyReact.render是差不多的。在render里面的调用的是diff函数,因此setState的核心调用函数也是它。

setState函数只是在调用diff函数之前,需要把继承Component组件的子组件Comp中定义的state进行更新合并,同时需要把新的virtualDOM、root dom、old dom获取到即可。

更新的virtualDOM,也就是子组件Comp的render函数返回值,这很容易获取。


old dom的父节点一定是root dom,所以难的是如何获取old dom?

获取old dom稍微有点绕,首先一定要明白的是setState方法,只能在类组件中使用。直接渲染virtualDOM对象和函数组件,这两种情况是没有setState的。

因此我们首先在mountComponent函数中,如果是类组件,则将component实例对象赋值给newVirtualDOM对象,component实例对象就是new Comp()后的实例对象,Comp类不是继承Componet类吗,因此该component实例对象可以调用Componet里定义的方法setDOM、render等。

然后在mountNativeElement函数中,通过newVirtualDOM.component.setDOM将old DOM元素存进component实例对象中即可。

最后在setState中调用getDOM,即可获取老的oldDOM。

最后简单总结:实现setState类组件状态更新的核心是执行diff函数,将新的virtualDOM 对象再次转成真实 DOM 对象。

核心API有:setDOM、getDOM、diff


5.7.更新组件

在5.5中,我们实现了Virtual DOM对象的更新,本节我们来实现组件的更新。 这是Virtual DOM对象的更新:

const virtualDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test="test">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <span>这是一段内容</span>
      <button onClick={() => alert("你好")}>点击我</button>
      <h3>这个将会被删除</h3>
      2, 3
      <input type="text" value="13"/>
    </div>
)
const modifyDOM = (
    <div className="container">
      <h1>你好 Tiny React</h1>
      <h2 data-test2="test123">(编码必杀技)</h2>
      <div>
        嵌套1 <div>嵌套 1.1</div>
      </div>
      <h3>(观察: 这个将会被改变)</h3>
      {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
      {2 == 2 && <div>2</div>}
      <div>这是更改后的内容</div>
      <button onClick={() => alert("你好!!!")}>点击我</button>
      <input type="text" value="13"/>
    </div>
)
TinyReact.render(virtualDOM, root)
setTimeout(() => {
  TinyReact.render(modifyDOM, root)
}, 2000)

这是组件的更新:

class Comp extends TinyReact.Component {
  constructor(props) {
    super(props)
    this.state = {
      title: 'Defualt Title'
    }
    this.changeTitle = this.changeTitle.bind(this)
  }
  changeTitle() {
    this.setState({title:'Changed Title'})
  }
  render () {
    return (
      <div>
        <div>
          <div>{this.state.title}</div>
          <button onClick={this.changeTitle}>改变title</button>
        </div>
        <div>Hello, {this.props.name}!</div>
      </div>
    )
  }
}
TinyReact.render(<Comp name="张三"/>, root)
setTimeout(() => {
  TinyReact.render(<Comp name="李四"/>, root)
}, 2000)

注意两者的区别哈!

在实现组件更新前,我们需要断要更新的组件和未更新前的组件是否是同一个组件,如果不是同一个组件就不需要做组件更新操作,直接调用 mountElement 方法将组件返回的 Virtual DOM 添加到页面中,如果是同一个组件,只需要更新原来的props即可。

第一步,Componet类中,新增updateProps函数:

import diff from "./diff"

export default class Component {
    constructor(props) {
        this.props = props
    }
    setState(state) {
        this.state = Object.assign({} , this.state, state)
        const newVirtualDOM = this.render()
        const oldDOM = this.getDOM()
        diff(newVirtualDOM, oldDOM.parentNode, oldDOM)
    }
    setDOM(dom) {
        this._dom = dom
    }
    getDOM() {
        return this._dom
    }
    updateProps(props) {
        this.props = props
    }
}

第二步,diff函数,新增组件更新逻辑:

import createDOMElement from './createDOMElement'
import mountElement from './mountElement'
import updateNodeElement from './updateNodeElement'

export default function diff(virtualDOM, container, oldDOM) {
    // 判断oldDOM是否存在
    if (!oldDOM) {
        mountElement(virtualDOM, container)
    } else {
        // 更新对比逻辑
        const { _virtualDOM } = oldDOM
        if (typeof virtualDOM.type === 'function') {
            // Component,component为oldComponent
            const {component} = _virtualDOM
            // 判断是否是同一个组件
            if (component && virtualDOM.type === component.constructor) {
                console.log('同一个组件')
                // 更新props
                component.updateProps(virtualDOM.props)
                // 获取新的virtualDOM
                const newVirtualDOM = component.render()
                // 执行diff
                diff(newVirtualDOM, container, oldDOM)
            } else {
                console.log('不同的组件', oldDOM)
                // 删除老DOM
                oldDOM.remove()
                // 新增新DOM
                diff(virtualDOM, container)
            }
            return
        }
        // 第一种情况,Virtual DOM 类型相同
        if(virtualDOM.type === _virtualDOM.type) {
            ...
        } else {
            // 第二种情况,Virtual DOM 类型不相同
            // 将老的元素节点替换
            ...
        }
    }
}

最终效果: 第一次渲染: cliick_before

如果是同一个组件,2s后渲染:

TinyReact.render(<Comp name="张三"/>, root)
setTimeout(() => {
  TinyReact.render(<CompSecond name="李四"/>, root)
}, 2000)

compUpdate1

如果是不同组件,2s后渲染:

TinyReact.render(<Comp name="张三"/>, root)
setTimeout(() => {
  TinyReact.render(<CompSecond name="李四"/>, root)
}, 2000)

compUpdate2

说下具体的实现逻辑,组件更新前需要判断该组件是新组件还是老组件,如果是新组件,则直接替换原来的Virtual DOM对象(先删除老的oldDOM,再增加新的newDOM)。如果是老组件,则更新下props即可。

如何判断该组件是新组件还是老组件呢? 通过新的virtualDOM的type和老的实例对象component的constructor,是否是来自同一个类组件,是则为老组件。(virtualDOM.type === component.constructor)

核心API有:updateProps、diff


5.8.ref属性

ref属性有两种情况,一个是在节点上添加,一个是在类组件上添加。 为节点添加 ref 属性可以获取到这个节点的 DOM 对象,为类组件添加 ref 属性可以获取到组件的实例对象。 节点:

<input type="text" ref={input => (this.input = input)} />

类组件:

<Comp name="张三" ref={comp => this.comp = comp}/>

第一步,在createDOMElement函数中,新增实现节点ref的实现:

import mountElement from './mountElement'
import updateNodeElement from './updateNodeElement'
export default function createDOMElement(virtualDOM) { 
    let newElement = null
    if(virtualDOM.type === 'text') {
        // 文本节点
        newElement = document.createTextNode(virtualDOM.props.textContent)
    } else {
        // 元素节点
        newElement = document.createElement(virtualDOM.type)
        // 解析元素节点属性
        updateNodeElement(newElement, virtualDOM)
        // 递归创建子节点
        virtualDOM.children.forEach(child => {
            mountElement(child, newElement)
        })
        // 添加支持ref属性,获取当前DOM实例对象
        if(virtualDOM.props && virtualDOM.props.ref) {
            virtualDOM.props.ref(newElement)
        }
    }
    // 为每个DOM元素设置_virtualDOM属性,用于对比节点是否发生变化
    newElement._virtualDOM = virtualDOM
    return newElement
}

第二步,在mountComponent函数中,新增实现类组件ref的实现:

import mountElement from "./mountElement"

export default function mountComponent(virtualDOM, container) {
    const { type, props } = virtualDOM
    if (type.prototype && type.prototype.render) {
        // 类组件extends Component
        const component = new type(props || {})
        const newVirtualDOM = component.render()
        newVirtualDOM.component = component
        mountElement(newVirtualDOM, container)
        // 添加类组件ref属性
        if (component) {
            if (component.props && component.props.ref) {
                component.props.ref(component)
            }
        }
    } else {
        // 函数组件Function
        mountElement(type(props || {}), container)
    }
}

测试一下:

class Comp extends TinyReact.Component {
  constructor(props) {
    super(props)
    this.state = {
      title: 'Defualt Title'
    }
    this.changeTitle = this.changeTitle.bind(this)
  }
  changeTitle() {
    this.setState({title:'Changed Title'})
  }
  render () {
    return (
      <div>
        <div>
          <div>{this.state.title}</div>
          <button onClick={this.changeTitle}>改变title</button>
        </div>
        <div>Hello, {this.props.name}!</div>
      </div>
    )
  }
}
class RefDemo extends TinyReact.Component {
  handle() {
    let value = this.input.value
    console.log(value)
    console.log(this.comp)
  }
  render() {
    return (
      <div>
        <input type="text" ref={input => (this.input = input)} />
        <button onClick={this.handle.bind(this)}>按钮</button>
        <Comp name="张三" ref={comp => this.comp = comp}/>
      </div>
    )
  }
}
TinyReact.render(<RefDemo/>, root)

ref

说下具体的实现逻辑,为节点添加ref属性,判断virtualDOM的props中是否存在ref属性,如果存在,则执行ref函数,将当前的dom元素存入进去;为类组件添加ref属性,判断类组件实例对象component的props中是否存在ref属性,如果存在,则执行ref函数,将当前的实例对象component存入进去。


再回顾下前面的思维导图,逻辑就显得特别清晰了。

react

用大白话理解react对页面做了哪些事情? 页面初始化渲染时: 1.jsx会被解析成n个React.createElement执行; 2.React.createElement递归创建所有节点(createTextNode、createElement、appendChild)后,最后添加(appendChild)到root节点里完成渲染; 3.如果是元素节点,则为该节点添加相应属性(事件、名称、样式、自定义属性); 4.如果是组件(类组件或函数组件),都会想方设法先得到jsx,然后再执行前面的第1、2、3步操作实现渲染;

页面更新渲染时: 1.jsx更新会根据type去判断是否为同一个类型的dom,如果不同,则直接替换该dom(replaceChild);如果相同,则判断该dom属性是否发生改变,有的话则替换对应属性,同时去看该dom的属性是否存在删除情况,有的话需要remove掉。 2.如果是函数组件更新或类组件更新不是同一个组件的时候,直接删除老的dom,将新的jsx获取后,然后再执行前面的第1步。 3.如果是类组件更新且是同一个组件的时候,首先更新props,将新的jsx获取后,然后再执行前面的第1步。


页面初始化渲染:jsx解析出virtual dom,将virtual dom对象替换成真实dom,最后添加到root节点完成渲染。 jsx示意图:jsx -> virtual dom -> n个createElement和createTextNode -> 合成1个root=createElement('div') -> applendChild(root) -> 渲染完毕

组件示意图:组件(类或函数)-> jsx -> virtual dom -> n个createElement和createTextNode -> 合成1个root=createElement('div') -> applendChild(root) -> 渲染完毕


页面更新渲染:dom不同,则直接替换成新dom;dom相同,则按照属性的更改做对应逻辑。 jsx示意图(diff): dom不同->replaceChild(newNode, oldNode)dom相同->文本节点:oldDOM.textContent = newText;元素节点:setAttribute、removeAttribute、[propName] = newPropsValue、removeChild、addEventListener、removeEventListener

函数组件示意图:删除老的dom,获取新的jsx后,执行jsx示意图(diff)。 oldDOM.remove -> new jsx -> diff

类组件示意图: 不是同一个类组件的更新:删除老的dom,获取新的jsx后,执行jsx示意图(diff)。 非同一个类组件示意图:oldDOM.remove -> new jsx -> diff

同一个类组件的更新:更新该类的props属性,然后获取新的jsx后,执行jsx示意图(diff) 同一个类组件示意图:updateProps -> new jsx -> diff


最后附上该篇文章的源码,github源码地址:https://github.com/ww930912/tiny-react

Headshot of Maxi Ferreira

怀着敬畏之心,做好每一件事。