菜鸡是怎么手写 React 之 vdom 到 dom 的转换

1,154 阅读9分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

前言

这篇文章是我看了光神的文章后写出来的,实现的思路以及代码都是一模一样,但是大佬们对于一些代码会觉得理所当然的事,对于我等菜鸡却理解不了,所以这篇文章更多的是以新手的角度去解读光神的文章,很啰嗦,但绝对通俗易懂。

vdom

  • 首先看下面这个对象,用原生的dom操作方法,我们怎么把这个对象变成我们页面上的dom结构
    const vdom = {
        type: 'ul',
        props: {
            className: 'list',
            children: [
                {
                    type: 'li',
                    props: {
                        className: 'item',
                        style: {
                            background: 'blue',
                            color: '#fff'
                        },
                        onClick: function() {
                            alert(1);
                        },
                        children: [
                            'aaaa'
                        ]
                    }
                },
                {
                    type: 'li',
                    props: {
                        className: 'item',
                        children: [
                            'bbbbddd'
                        ]
                    }
                },
                {
                    type: 'li',
                    props: {
                        className: 'item',
                        children: [
                            'cccc'
                        ]
                    }
                }
            ]
        }
    };
    
  • 我们先写一个render方法,这个方法接受两个参数,第一个参数需要转换成真实dom节点的vdom,第二个参数是被挂载的父节点
  • render(vdom,document.documentElement)//这里我们挂载到根结点上
    
  • 在render方法中,vdom参数有两种情况
    • 如果是一个数字或者字符串,那么我们就直接创建一个文本节点,然后挂载到父节点上去
    • 如果是一个对象,那么就创建对应的元素节点,再挂载到父节点上面去
    • 所以现在代码是这样的
      const isTextNode = (vdom) => { return typeof vdom === 'string' || typeof vdom === 'number' }
      
      const isElementNode = (vdom) => { return typeof vdom === 'object' && typeof vdom.type === 'string' }
      
      const render = (vdom,parent) => {
          if(isTextNode(vdom)){//当前是文本节点
              parent.appendChild(document.createTextNode(vdom))
          }else if(isElementNode(vdom)){//当前是元素节点
              const newDom = document.createElement(vdom.type)
              parent.appendChild(newDom)
          }
      }
      
  • 对于文本节点,我们直接挂载到父节点上就可以,但对于元素节点,我们还需要设置节点的属性,比如className属性、style属性、id属性等等,以及这个节点对应的响应事件
    const isEventListenerAttr = (key,value) => { return typeof value === 'function' && key.startsWith('on')}
    
    const isStyleAttr = (key,value) => { return key === 'style' && typeof value === 'object'}
    
    const isPlainAttr = (value) => { return typeof value !== 'object' && typeof value !== 'function'}
    
    const setAttribute = (dom,key,value) => {
        if(isEventListenerAttr(key,value)){//这里要设置的是元素的响应事件
            const eventType = key.slice(2).toLowerCase()
            dom.addEventListener(eventType,value)
        }else if(key === 'className'){//class属性需要特殊设置,设置后变成<div class='xxx'></div>,而用dom.setAttribute会变成<div className='xxx'></div>
            dom[key] = value
        }else if(isStyleAttr(key,value)){//style属性是对象,需要特别设置
            Object.assign(dom.style,value)
        }else if(isPlainAttr(value)){//其他常规的属性在这里设置,比如id属性、title属性等
            dom.setAttribute(key,value)
        }
    }
    
    const render = (vdom,parent) => {
        if(isTextNode(vdom)){//当前是文本节点
            parent.appendChild(document.createTextNode(vdom))
        }else if(isElementNode(vdom)){//当前是元素节点
            const newDom = document.createElement(vdom.type)	
            for(let props in vdom.props){//开始设置节点的属性
                if(props !== 'children'){//需要将children过滤掉
                    setAttribute(newDom,props,vdom.props[props])
                }
            }
            parent.appendChild(newDom)
        }
    }
    
  • 在对元素节点的属性设置完后,如果该元素节点存在子节点,那么我们用递归的方式,对子节点调用render方法
    const render = (vdom,parent) => {
        if(isTextNode(vdom)){
            parent.appendChild(document.createTextNode(vdom))
        }else if(isElementNode(vdom)){
            const newDom = document.createElement(vdom.type)
            for(let props in vdom.props){
                if(props !== 'children'){
                    setAttribute(newDom,props,vdom.props[props])
                }
            }
            //对元素的子节点进行递归
            for (const child of vdom.props.children) {
                render(child, newDom);
            }
            parent.appendChild(newDom)
        }
    }
    
  • 现在js中的代码是这样的
    const vdom = {
        type: 'ul',
        props: {
            className: 'list',
            children: [
                {
                    type: 'li',
                    props: {
                        className: 'item',
                        style: {
                            background: 'blue',
                            color: '#fff'
                        },
                        onClick: function() {
                            alert(1);
                        },
                        children: [
                            'aaaa'
                        ]
                    }
                },
                {
                    type: 'li',
                    props: {
                        className: 'item',
                        children: [
                            'bbbbddd'
                        ]
                    }
                },
                {
                    type: 'li',
                    props: {
                        className: 'item',
                        children: [
                            'cccc'
                        ]
                    }
                }
            ]
        }
    };
    
    const isTextNode = (vdom) => { return typeof vdom === 'string' || typeof vdom === 'number' }
    
    const isElementNode = (vdom) => { return typeof vdom === 'object' && typeof vdom.type === 'string' }
    
    const isEventListenerAttr = (key,value) => { return typeof value === 'function' && key.startsWith('on')}
    
    const isStyleAttr = (key,value) => { return key === 'style' && typeof value === 'object'}
    
    const isPlainAttr = (value) => { return typeof value !== 'object' && typeof value !== 'function'}
    
    const setAttribute = (dom,key,value) => {
        if(isEventListenerAttr(key,value)){//这里要设置的是元素的响应事件
            const eventType = key.slice(2).toLowerCase()
            dom.addEventListener(eventType,value)
        }else if(key === 'className'){
            //className属性需要特殊设置,设置后变成<div class='xxx'></div>,而用dom.setAttribute会变成<div className='xxx'></div>
            dom[key] = value
        }else if(isStyleAttr(key,value)){//style属性是对象,需要特别设置
            Object.assign(dom.style,value)
        }else if(isPlainAttr(value)){//其他常规的属性在这里设置,比如id属性、title属性等
            dom.setAttribute(key,value)
        }
    }
    
    const render = (vdom,parent) => {
        if(isTextNode(vdom)){//当前是文本节点
            parent.appendChild(document.createTextNode(vdom))
        }else if(isElementNode(vdom)){//当前是元素节点
            const newDom = document.createElement(vdom.type)
            //开始设置节点的属性
            for(let props in vdom.props){
                if(props !== 'children'){//需要将children过滤掉
                    setAttribute(newDom,props,vdom.props[props])
                }
            }
            //对元素的子节点进行递归
            for (const child of vdom.props.children) {
                render(child, newDom);
            }
            parent.appendChild(newDom)
        }
    }
    render(vdom,document.documentElement)
    
  • 页面的效果是这样的

1677839521461.jpg

jsx

  • 在日常的开发中,我们并不会去写vdom,写的是jsx,它的形式是这样的

    //jsx的形式
    const jsx = <ul className="list">
        <li className="item" style={{ background: 'blue', color: 'pink' }} onClick={() => alert(2)}>aaa</li>
        <li className="item">bbbb</li>
        <li className="item">cccc</li>
    </ul>
    
  • jsx经过babel编译过后,会变成一个函数,这个函数通过react执行之后,就会变成一个对象,这个对象的形式就和我们最初写的vdom形式一模一样,下面是babel编译后的产物

    //babel编译的产物
    const jsx = /*#__PURE__*/React.createElement("ul", {
    
            className: "list"
    
        }, /*#__PURE__*/React.createElement("li", {
    
            className: "item",
    
            style: {
    
                background: 'blue',
    
                color: 'pink'
    
            },
    
            onClick: () => alert(2)
    
            }, "aaa"), /*#__PURE__*/React.createElement("li", {
    
                className: "item"
    
            }, "bbbb"), /*#__PURE__*/React.createElement("li", {
    
                className: "item"
    
    }, "cccc"));
    
  • 在项目的根目录下,先执行npm init,执行后项目目录是这样的

    directory.png

  • 下载babel,执行下面的代码

    npm install --save-dev @babel/core @babel/cli @babel/preset-env
    
    npm install --save-dev @babel/preset-react
    
  • 在根目录下新建babel.config.js文件,导入下面的代码

    module.exports = {
    
        presets:[
    
            [
    
            '@babel/preset-react'
    
            ]
    
        ]
    
    }
    
  • 执行babel编译命令

    //该命令会把当前目录下的main.js编译,并输出到当前目录下的main文件夹中
    ./node_modules/.bin/babel main.js --out-dir main
    
  • 将html文件中的script引入更改成编译后的脚本,

  • 最后我们在html文件中引入react库,刷新浏览器就可以看到页面正常显示

<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>

<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>

函数组件

对于函数组件,我们会先去执行这个函数,执行完后就会生成vdom

  • 函数组件在react中的编写形式是这样的

    function Item(props) {
        return <li className="item" style={props.style} onClick={props.onClick}>{props.children}</li>;
    }
    
    function List(props) {
        return <ul className="list">
            {props.list.map((item, index) => {
                return <Item style={{ background: item.color }} onClick={() => alert(item.text)}>{item.text}</Item>
            })}
        </ul>;
    }
    
  • 函数组件经过babel编译后是这样的,对比于jsx编译出来的产物,函数组件编译的产物多了一层函数包裹着

    function Item(props) {
      return /*#__PURE__*/React.createElement("li", {
        className: "item",
        style: props.style,
        onClick: props.onClick
      }, props.children);
    }
    function List(props) {
      return /*#__PURE__*/React.createElement("ul", {
        className: "list"
      }, props.list.map((item, index) => {
        return /*#__PURE__*/React.createElement(Item, {
          style: {
            background: item.color
          },
          onClick: () => alert(item.text)
        }, item.text);
      }));
    }
    
  • 而在React执行这个函数过后,我们在render函数中拿到的vdom是这样的

    vdomCompare.jpg

  • 我们对比一下jsx编译执行后传入render的vdom和函数组件编译执行后传入render的vdom可以发现

    • 编译执行后的产物都是一个对象
    • 但是对象的type属性不同,jsx编译后的type属性是个字符串,而函数组件编译后的type属性是个函数
  • 所以对于函数组件,在经过编译后,我们还需要去执行一次type属性对应的函数,才可以生成文章开头展示的vdom对象

    function isComponentVdom(vdom) { return typeof vdom.type == 'function' }
    if (isComponentVdom(vdom)) {//当我们判断到传入的vdom是由函数组件编译而来的时候
            console.log('this is function component vdom',vdom)
    
            //执行type属性上的函数,并将vdom.props属性传入
            const componentVdom = vdom.type(vdom.props);
            console.log('componentVdom',componentVdom)
    
            //最后执行render函数
            render(componentVdom, parent);
    }
    
  • 现在的render方法是这样的

    const render = (vdom,parent) => {
        if(isTextNode(vdom)){//当前是文本节点
            parent.appendChild(document.createTextNode(vdom))
        }else if(isElementNode(vdom)){//当前是元素节点
            console.log('this is element vdom',vdom)
            const newDom = document.createElement(vdom.type)
    
            //开始设置节点的属性
            for(let props in vdom.props){
                if(props !== 'children'){//需要将children过滤掉
                    setAttribute(newDom,props,vdom.props[props])
                }
            }
            
            //对元素的子节点进行递归
            for (const child of vdom.props.children) {
                render(child, newDom);
            }
    
            parent.appendChild(newDom)
        }else if (isComponentVdom(vdom)) {//当我们判断到传入的vdom是由函数组件编译而来的时候
            console.log('this is function component vdom',vdom)
    
            //执行type属性上的函数,并将vdom.props属性传入
            const componentVdom = vdom.type(vdom.props);
    
            console.log('componentVdom',componentVdom)
    
            //最后执行render函数
            render(componentVdom, parent);
        }
    }
    

类组件

对于函数组件,我们需要执行这个函数,才可以得到我们想要的vdom;而对于类组件,我们就去生成这个类的实例,再调用这个实例的render方法(实例的render方法和全局的render方法是两个不同的方法),这样就可以得到我们想要的vdom

  • 我们写的类组件都是通过继承的方法去获取到一些特有的属性,所以我们要先创建一个父类

    class Component {
        constructor(props) {
            this.props = props || {};
            this.state = null;
        }
    
        setState(nextState) {
    
        }
    }
    
  • 声明一个类组件去继承这个父类,以及修改一下传入render函数的参数

    function Item(props) {
        return <li className="item" style={props.style} onClick={props.onClick}>{props.children}</li>;
    }
    
    //类组件
    class List extends Component {
        constructor(props) {
            super();
            this.state = {
                list: [
                    {
                        text: 'aaa',
                        color: 'blue'
                    },
                    {
                        text: 'bbb',
                        color: 'orange'
                    },
                    {
                        text: 'ccc',
                        color: 'red'
                    }
                ],
                textColor: props.textColor
            }
        }
    
        render() {
            return <ul className="list">
                {this.state.list.map((item, index) => {
                    return <Item style={{ background: item.color, color: this.state.textColor}} onClick={() => alert(item.text)}>{item.text}</Item>
                })}
            </ul>;
        }
    }
    
    //修改参数
    render(<List textColor={'pink'}/>,document.documentElement)
    
  • 类组件以及render函数经过编译后是这样的

    class List extends Component {
      constructor(props) {
        super();
        this.state = {
          list: [{
            text: 'aaa',
            color: 'blue'
          }, {
            text: 'bbb',
            color: 'orange'
          }, {
            text: 'ccc',
            color: 'red'
          }],
          textColor: props.textColor
        };
      }
      render() {
        return /*#__PURE__*/React.createElement("ul", {
          className: "list"
        }, this.state.list.map((item, index) => {
          return /*#__PURE__*/React.createElement(Item, {
            style: {
              background: item.color,
              color: this.state.textColor
            },
            onClick: () => alert(item.text)
          }, item.text);
        }));
      }
    }
    
    render( /*#__PURE__*/React.createElement(List, {
      textColor: 'pink'
    }), document.documentElement);
    
  • 类组件编译后仍然是一个类,但是调用全局的render函数的时候,参数却变成了一个函数,而最后进入到全局render函数的vdom是这样的

    classVdom.jpg

  • 类组件编译后的对象,他的type属性是一个class,对于class,我们用typeof得到的结果仍然是'function',所以我们还需要判断一下,当前的组件是类组件还是函数组件

    if (isComponentVdom(vdom)) {//如果vdom的type属性是一个函数或者是class
        if (Component.isPrototypeOf(vdom.type)) {//判断这个class是否是继承我们的总类而来
    
        } else {
    
    }
    
  • 对于类组件,我们先new一个实例,再调用这个实例的render方法,所以针对类组件的处理是这样的

    if (isComponentVdom(vdom)) {//如果vdom的type属性是一个函数或者是class
            if (Component.isPrototypeOf(vdom.type)) {//判断这个class是否是继承我们的总类而来
                const instance = new vdom.type(vdom.props);
                const componentVdom = instance.render();
                render(componentVdom, parent);
            } else {
            
            }
        }
    
  • 完整的代码如下

    class Component {
        constructor(props) {
            this.props = props || {};
            this.state = null;
        }
    
        setState(nextState) {
            this.state = nextState;
        }
    }
    
    function Item(props) {
        return <li className="item" style={props.style} onClick={props.onClick}>{props.children}</li>;
    }
    
    //类组件
    class List extends Component {
        constructor(props) {
            super();
            this.state = {
                list: [
                    {
                        text: 'aaa',
                        color: 'blue'
                    },
                    {
                        text: 'bbb',
                        color: 'orange'
                    },
                    {
                        text: 'ccc',
                        color: 'red'
                    }
                ],
                textColor: props.textColor
            }
        }
    
     render() {
            return <ul className="list">
                {this.state.list.map((item, index) => {
                    return <Item key={`item${index}`} style={{ background: item.color, color: this.state.textColor}} onClick={() => alert(item.text)}>{item.text}</Item>
                })}
            </ul>;
        }
    }
    
    const isTextNode = (vdom) => { return typeof vdom === 'string' || typeof vdom === 'number' }
    
    const isElementNode = (vdom) => { return typeof vdom === 'object' && typeof vdom.type === 'string' }
    
    const isEventListenerAttr = (key,value) => { return typeof value === 'function' && key.startsWith('on')}
    
    const isStyleAttr = (key,value) => { return key === 'style' && typeof value === 'object'}
    
    const isPlainAttr = (value) => { return typeof value !== 'object' && typeof value !== 'function'}
    
    function isComponentVdom(vdom) { return typeof vdom.type == 'function' }
    
    const setAttribute = (dom,key,value) => {
        if(isEventListenerAttr(key,value)){//这里要设置的是元素的响应事件
            const eventType = key.slice(2).toLowerCase()
            dom.addEventListener(eventType,value)
        }else if(key === 'className'){
            //className属性需要特殊设置,设置后变成<div class='xxx'></div>,而用dom.setAttribute会变成<div className='xxx'></div>
            dom[key] = value
        }else if(isStyleAttr(key,value)){//style属性是对象,需要特别设置
            Object.assign(dom.style,value)
        }else if(isPlainAttr(value)){//其他常规的属性在这里设置,比如id属性、title属性等
            dom.setAttribute(key,value)
        }
    }
    
    const render = (vdom,parent) => {
        if(isTextNode(vdom)){
            parent.appendChild(document.createTextNode(vdom))
        }else if(isElementNode(vdom)){
            console.log('this is element vdom',vdom)
            const newDom = document.createElement(vdom.type)
            //开始设置节点的属性
            for(let props in vdom.props){
                if(props !== 'children'){//需要将children过滤掉
                    setAttribute(newDom,props,vdom.props[props])
                }
            }
            console.log(vdom.props.children)
            //对元素的子节点进行递归
            for (const child of vdom.props.children) {
                render(child, newDom);
            }
    
            parent.appendChild(newDom)
        }else if (isComponentVdom(vdom)) {//当我们判断到传入的vdom是由函数组件编译而来的时候
    
            if (Component.isPrototypeOf(vdom.type)) {
                console.log('this is class component vdom',vdom)
                const instance = new vdom.type(vdom.props);
                const componentVdom = instance.render();
                render(componentVdom, parent);
            } else {
                console.log('this is function component vdom',vdom)
                //执行type属性上的函数,并将vdom.props属性传入
                const componentVdom = vdom.type(vdom.props);
                console.log('componentVdom',componentVdom)
                //最后执行render函数
                render(componentVdom, parent);
            }
        }
    }
    
    render(<List textColor={'pink'}/>,document.documentElement)
    
  • 页面的效果是这样的

    1677840966292.jpg

总结

光神的手写react系列中,前两篇是比较简单的,在阅读的过程中,卡住的点可能会是babel的配置,第二篇解读争取下周写出来(ง๑ •̀_•́)ง。