1
头图

准备工作

需要用到的模板文件的仓库地址

1. JSX

先看看jsx语法,做了什么事情 babel.js
同时react的文档也写明了

实际上,JSX 仅仅只是 React.createElement(component, props, ...children) 函数的语法糖。

image.png

可以看到,这些jsx语法,经过babel转译后,最终调用了React.createElement,其需要三个参数type, props, children。其返回值就是virtual DOM对象。也就是说,我们可以使用babel将我们的jsx代码,转换成React.createElement的调用,最后由该方法,将jsx转换为虚拟dom对象. 也就是说,jsx实际就是React.createElement的语法糖。 现在我们就需要实现一个自己的createElement方法

2. 项目配置

查看仓库地址,可以直接获取到模板文件。这里主要介绍一下我们的.babelrc中如何配置,帮助我们解析jsx代码,并自动的调用我们自己写的createElement方法
可以看看babel官网 是如何配置react的。
image.png
presets中配置@babel/prset-react,我们将使用他来转换我们代码中的jsx代码。想想上面的代码,我们写的函数式组件,或者jsx代码,都被转换成了React.createElement代码,所以我们借助babel就可以实现我们自定义的createElement功能

{
  "presets": [
    "@babel/preset-env",
    [
      "@babel/preset-react",
      {
        "pragma": "MyReact.createElement" //默认的pragma就是 React.createElement,也就是说,我们要实现一个我们的MyReact.createElement, 那么就需要在这里写成MyReact.createElement (only in classic runtime)
      }
    ]
  ]

}

3. Virtual DOM

3.1 什么是Virtual DOM

使用javascript对象来描述真实的dom对象,其结构一般就是这样

vdom = {
    type: '',
    props: {} // 属性对象
    children: [] // 子元素,子组件
}

3.2 创建Virtual DOM

3.2.1 实现一个createElement方法

在我们的模板文件中, 已经使用webpack配置好了代码的入口文件,安装好依赖,然后将项目运行起来,这时候浏览器啥都没有发生。解析jsx的工作也都有babel帮我们完成了
如果你出现了这种情况,那么请自行更改webpack中devserver的端口号
image.png

这是我们的项目目录结构:

image.png
同时,也可以看看我们项目的目录结构, 这里我已经添加了一个createElement.js的文件,我们将在这个文件中,实现将jsx代码,转换为virtual DOM对象。

上面我们提到过,React.createElement会接收三个参数type, props, children,然后会自动的将jsx代码转换成下面这个类型,因此我们需要做的就是提供这么一个方法,接收这三个参数,然后在将其组装成我们想要的对象。

vdom = {
    type: '',
    props: {} // 属性对象
    children: [] // 子元素,子组件
}
  1. 首先在MyReact文件夹下创建createDOMElement.js,他的结构我们上面提到过,接收三个参数,并且返回一个vdom的对象

    export default function createElement(type, props, ...children) {
      return {
         type,
         props,
         children
      }
    }
  2. 创建好了createElement方法,那么我们需要往外暴露,因此在MyReact/index.js中,我们将其暴露出来

    // MyReact/index.js
    import createElement from './createElement'
    export default {
      createElement,
    }
  3. 然后我们在入口文件, 引入我们的MyReact,同时写一段jsx的代码,看看能不能符合我们的预期

    // index.js
    
    import MyReact from "./MyReact"
    // 按照react的使用方法,这里我们先引入我们自定义的MyReact 此处的MyReact会将jsx语法 通过调用MyReact.createElement(),然后返回我们所需要的VDOM
    // 这个是在.babelrc配置的
    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)

    看看打印结果,是不是我们的预期
    image.png
    bingo,确实是我们想要的。这里大家可以看到

  • children中,有些节点是一个boolean,还有就是我们可能节点就是个null, 不需要转换
  • children中,有些节点直接就是文本,需要转换文文本节点
  • props中,需要可以访问children节点
    上述两个特殊情况都没有被正确的转换成vDOM,因此我们接下来需要做的就是,对children节点,在进行一次createElement的操作。

    3.2.2 改进createElement方法

    上面我们说到,我们需要递归的调用createElement方法去生成vDOM。根据上面三个问题,我们可以作如下改进

    export default function createElement(type, props, ...children) {
    // 1.循环children对象进行对象转换,如果是对象,那么在调用一次createElement方法,将其转换为虚拟dom,否则直接返回,因为他就是普通节点. 
    const childrenElements = [].concat(...children).reduce((result, child) => {
      // 2.节点中还有表达式, 不能渲染 null节点 boolean类型的节点, 因此我们这里用的reduce,而不是用map
      if (child !== true && child !== false && child !== null) {
        // child已经是对象了,那么直接放进result中
        if (child instanceof Object) {
          result.push(child)
        } else {
          // 如果他是文本节点,则直接转换为为本节点
          result.push(createElement("text", { textContent: child }))
        }
      }
      return result
    }, [])
    // 3. props 可以访问children节点,
    return {
      type,
      props: Object.assign({ children: childrenElements }, props), // hebing Props
      children: childrenElements
    }
    }

现在再看看我们的输出, 可以看到,之前我们children中有false的,以及纯文本的节点,都被正确的处理了,到这里,我们的createElement就结束了
image.png

3.3 实现render方法

3.3.1 render方法

首先在MyReact文件夹下创建render.js
在render中,我们还要一个diff方法,diff算法就是保证视图只更新变动的部分,需要将新旧dom进行对比(vDOM, oldDOM),然后更新更改部分的dom(container)。我们先写一个diff方法,实际的算法,我们留在后面来补充。

// MyReact/render.js

import diff from "./diff"
export default function render(vDOM, container, oldDOM) {
  // diff算法
  diff(vDOM, container, oldDOM)
}

然后,我们在MyReact/index.js将render方法进行导出。

import createElement from './createElement'
import render from './render'
export default {
  createElement,
  render
}
3.3.2 diff方法

刚刚的分析,我们可以知晓,这个diff算法是需要三个参数的,newDom, container, oldDom, 在这里,我们需要做的是就是对比新旧dom,这里,我们需要一个方法,用来创建元素,于是我们现在又需要一个 mountElement方法,于是创建文件mountElement.js,用于创建元素。


// MyReact/diff.js
import mountElement from "./mountElement"

export default function diff(vDOM, container, oldDOM) {
  // 判断oldDOM是否存在
  if (!oldDOM) {
    // 创建元素
    mountElement(vDOM, container)
  }
}
3.3.3 mountElement方法

我们的元素,需要区分原生dom元素还是组件。组件分为class组件,以及函数组件。在这我们先把原生dom进行渲染。

  • mountElement

    • mountNativeElement
    • mountComponentElement

      • class组件
      • 函数组件
// MyReact/mountElement.js
    
export default function mountElement(vDOM, container) {
  // 此处需要区分原生dom元素还是组件,如何区分? 这个逻辑我们后面再补充
  mountNativeElement(vDOM, container)
}
3.3.4 mountNativeElement方法

在这个方法中,我们需要将virtual DOM转成真正的DOM节点,在这里,我们借助一个方法,来创建真实DOM元素,然后再将其append到容器中。


// MyReact/mountNativeElement.js
import createDOMElement from "./createDOMElement"
/**
 * 渲染vdom到指定节点
 * @param {*} vDOM
 * @param {*} container
 */
export default function mountNativeElement(vDOM, container) {
  let newElement= createDOMElement(vDOM)
  container.appendChild(newElement)
}

下面我们来实现这个createDOMElement方法,因为后续我们也会用到,所以把它作为一个公共的函数,方便其他地方使用。
这个方法,我们需要做下面的几件事情。

  1. 将传进来的vDOM创建成html元素
  2. 创建html元素 又分为两种情况, 纯文本节点,还是元素节点
  3. 递归创建子节点的html元素

    // MyReact/createDOMElement.js
    import mountElement from "./mountElement"
    /**
     * 创建虚拟dom
     * @param {*} vDOM
     * @returns
     */
    export default function createDOMElement(vDOM) {
      let newElement = null
    
      // 1. 渲染文本节点, 根据我们之前处理的,纯文本节点,通过text去标记, 值就是props中的textContent
      if (vDOM.type === 'text') {
     newElement = document.createTextNode(vDOM.props.textContent)
      } else {
     // 2.渲染元素节点
     newElement = document.createElement(vDOM.type) // type 就是html元素类型 div input p这些标签等等
     // 注意,这里我们只渲染了节点,并没有将props的属性,放在html标签上,这个我们后面在进行
      }
      // 以上步骤仅仅只是创建了根节点,还需要递归创建子节点
      vDOM.children.forEach(child => {
     // 将其放置在父节点上, 由于不确定当前子节点是组件还是普通vDOM,因此我们再次调用mountElement方法,当前的节点容器,就是newElement
     mountElement(child, newElement)
      })
      return newElement
    }
    

    代码已经就绪,我们去浏览器看看有没有什么变化, 这个时候,你的浏览器应该长这样了。然后我们再来分析下,我们还缺什么?
    image.png

3.3.5 更新节点属性的方法(updateNodeElement)

我们现在已经实现了将jsx的代码,渲染到了页面上。但是现在看看我们的虚拟DOM的结构。跟我们的预期还缺少了下面的东西

  1. className没有被渲染为 class
  2. data-test type value等这些原生属性没有被添加到对应的标签上
  3. button的响应事件

接下来,我们就去实现这个updateNodeElement方法。
还是先创建MyReact/updateNodeElement.js这个文件。思考一个问题,我们什么时候调用这个方法来更新node的属性呢?
在上面 3.3.4中,我们在进行更新节点的步骤,因此更新node节点的属性,也需要在那里进行
然后可以肯定的是,我们需要两个参数,一个是容器container,一个是我们的虚拟DOM,这样才能确定一个完整的element.
接下来的工作就是要把props属性,依次的赋值给html。回忆一下,如何设置html的属性?我们使用 element.setAttribute('prop', value)来实现.
明确了如何更新html上面的属性,接下来来分析下,我们要处理哪些属性,和事件
首先我们需要遍历当前vDOM的props属性,根据键值确定使用何种设置属性的方式

  1. 事件绑定:我们绑定事件都是以on开头,类似这样 onClick;
  2. value checked这样的属性值,就不能使用setAttribute方法了,想想我们使用原生dom的时候,对于输入框这样的value值,我们是直接用的input.value设置输入框的值;
  3. children属性,这是我们之前手动添加的节点属性,因此,我们要把children给他剔除;
  4. className属性,这个需要我们将其改为 class,剩下的属性,就可以直接使用键来设置了
    代码实现如下:

    // MyReact/updateNodeElement.js
    export default function updateNodeElement (newElement, vDOM) {
      const { props } = vDOM
      // 遍历vdom上的key,获取每个prop的值,
      Object.keys(props).forEach(key => {
     const currentProp = props[key]
     // 如果是以on开头的,那么就认为是事件属性,因此我们需要给他注册一个事件 onClick -> click
     if (key.startsWith('on')) {
       // 由于事件都是驼峰命名的,因此,我们需要将其转换为小写,然后取最后事件名称
       const eventName = key.toLowerCase().slice(2)
       // 为当前元素添加事件处理函数
       newElement.addEventListener(eventName, currentProp)
     } else if (key === 'value' || key === 'checked') {
       // input 中的属性值
       newElement[key] = currentProp
     } else if (key !== 'children') {
       // 抛开children属性, 因为这个是他的子节点, 这里需要区分className和其他属性
       newElement.setAttribute(key === 'className' ? 'class' : key, currentProp)
     }
      })
    }

    接下来,我们找到createDOMElement.js文件,我们需要在渲染元素节点后,更新他的属性值

    // MyReact/createDOMElement.js
    import mountElement from "./mountElement"
    /**
     * 创建虚拟dom
     * @param {*} vDOM
     * @returns
     */
    export default function createDOMElement(vDOM) {
      let newElement = null
    
      // 1. 渲染文本节点, 根据我们之前处理的,纯文本节点,通过text去标记, 值就是props中的textContent
      if (vDOM.type === 'text') {
     newElement = document.createTextNode(vDOM.props.textContent)
      } else {
     // 2.渲染元素节点
     newElement = document.createElement(vDOM.type) // type 就是html元素类型 div input p这些标签等等
     // 更新dom元素的属性,事件等等
     updateNodeElement(newElement, vDOM)
      }
      // 以上步骤仅仅只是创建了根节点,还需要递归创建子节点
      vDOM.children.forEach(child => {
     // 将其放置在父节点上, 由于不确定当前子节点是组件还是普通vDOM,因此我们再次调用mountElement方法,当前的节点容器,就是newElement
     mountElement(child, newElement)
      })
      return newElement
    }
    

    至此,我们已经完成了属性设置,现在回到浏览器看看,我们的结果,class属性被正确的加载了元素上面,其他属性,以及事件响应,也都做好了。
    image.png
    image.png

阶段一完成!

阶段一小结

  1. jsx语法在babel加持下,可以转换成vDOM,我们使用 babel-preset-react,并配置.babelrc,可以让我们实现自定义的createElement方法。然后将jsx转换成虚拟DOM.
  2. 我们通过createElement方法生成的虚拟DOM对象,通过diff算法(本文未实现),然后进行dom的更新
  3. 虚拟dom对象需要我们对所有的节点进行真实dom的转换。
  4. 创建节点我们需要使用 element.createElement(type)创建文本节点element.createElement(type), 设置属性,我们需要用到 element.setAttribute(key, value)这个方法。
  5. 节点更新需要区分html节点,组件节点。组件节点又需要区分 class组件以及函数组件

未完待续
可以看到,我们仅仅实现了jsx代码能够被正确的渲染到页面中,我们还有很多工作未做,比如下面的这些,后续的代码更新都放在这里了。源码

  • 组件渲染函数组件、类组件
  • 组件渲染中 props的处理
  • dom元素更新时的vDOM对比,删除节点
  • setState方法
  • 实现ref属性获取dom对象数组
  • key属性的节点标记与对比

2021-12-31更新

4.组件渲染

上面我们说到,我们只进行了html节点的渲染,接下来我们要做的就是渲染组件。
react中,组件分为两种,一种是函数组件,一种是类组件。那么我们如何来进行区分呢?
想想我们在使用的时候是怎么使用的


// 函数组件
function Hello = function() {
    return <div>Hello </div>
}
// 类组件
class World extends React.Component {
    constructor(props) {
        super(props)
    }
    render(){
        return <div>Hello react</div>
    }

}

看到上面的方法,我们考虑函数组件与类组件的区别。函数组件实际上就是个函数,那么我们在生成virtual DOM的时候,那他的type应该就是function, class组件是一个类,当然他的类型也会是函数,于是我们就可以知道,class组件可以通过prototype找到render方法,而函数组件则不存在这个方法,因此,我们需要一个方法来判断当前组件是函数组件还是类组件。
创建MyReact/isFunction.js以及MyReact/isFunctionComponet.js

// isFunction.js
export default function isFunction(vDOM) {
  return typeof vDOM?.type === 'function'
}
// isFunctionComponet.js
export default function isFunctionComponent(vDOM) {
  const type = vDOM.type
  return type && isFunction(vDOM) && !(type.prototype && type.prototype.render)
}

我们现在需要做的就是在mountElement.js中进行组件的区分,此时我们需要新建一个方法,叫做mountComponentElement,在这里面去区分函数组件以及类组件

// mountElement.js
import mountNativeElement from "./mountNativeElement"
import mountComponentElement from "./mountComponentElement"
import isFunction from "./isFunction"
/**
 * 挂载元素
 * @param {*} vDOM
 * @param {*} container
 */
export default function mountElement(vDOM, container) {
  // 此处需要区分原生dom元素还是组件,如何区分?
  if (isFunction(vDOM)) {
    mountComponentElement(vDOM, container)
  } else {
    mountNativeElement(vDOM, container)
  }
}

4.1 函数组件的渲染

上面分析到我们需要创建一个mountComponentElement方法,用于渲染组件元素. 我们该如何去渲染这个函数式组件呢?我们先看看调用render方法后,传入的组件vDOM是个什么情况
我们在入口index.js中写上这么一段测试代码,然后在下面看看输出

function Hello() {
  return <div>Hello 123!</div>
}
function Heart() {
  return (<div>
    &hearts;
    <Hello />
  </div>)
}
MyReact.render(<Heart />, root)

image.png
控制台可以看到,现在传入的函数组件,他的type就是一个function, 就是我们写的那个Heart,那既然是函数,我们就可以直接执行,然后就能获取到他返回的virtual DOM了。
这里,我们需要引入之前创建的isFunctionComponet函数,用于区分函数组件还是类组件.然后我们在分析下,如果这个传入的组件,他还嵌入的其他的组件呢?因此,我们在调用了这个函数组件的方法后,还需要去判断,此时生成的组件是什么类型的html functional class.因此,在mountComponentElement我们这个方法中,需要递归的去调用这个方法,同时我们还要调用渲染html组件的方法。我们看来如何实现

import isFunction from "./isFunction"; // 判断是否为function
import isFunctionComponent from "./isFunctionComponent"; // 区分函数组件 类组件
import mountNativeElement from "./mountNativeElement"; // mount Native元素
// 
export default function mountComponentElement (vDOM, container) {
  console.log(vDOM, 'vDOM')
  let nextElementVDOM = null // 申明一个变量用于保存即将生成的vDOM
  // 区分class组件, 以及函数组件,如果vDOM中 有render方法,那么就是class组件,否则就是函数组件
  if (isFunctionComponent(vDOM)) {
    // 这里生成的可能是一个包含组件的函数式组件
    nextElementVDOM =  buildFunctionComponent(vDOM) // 借用一个方法,去生成函数组件的vDOM
  } else {
    // TODO 生成类组件vDOM
  }
  // 如果这个创建的elemnt还是个函数,那么就继续处理
  if (isFunction(nextElementVDOM)) {
    mountComponentElement(nextElementVDOM, container)
  } else {
    // 如果不是函数,那么就是普通的Dom元素了,直接进行渲染
    mountNativeElement(nextElementVDOM, container)
  }

}

/**
 * build函数式组件,函数式组件直接执行,执行过后就是生成的vdom
 * @param {*} vDOM
 * @returns
 */
function buildFunctionComponent(vDOM) {
  return vDOM.type() // 上面的分析可知,这个vDOM的type就是个函数 执行过后,返回return中的内容
}

写到这里,我们的页面上应该已经出现结果了
image.png

4.2 类组件的渲染

接下来 我们来实现类组件的渲染,同样我们也需要借助一个方法去实现这个功能.但是在这之前,我们还需要一个关键的东西。那就是Component。因为我们使用class组件的时候,都需要 class Com extends React.Component,因此我们还需要实现这个Component类, 然后我们的类组件才能去继承这个类实现其他方法
新建MyReact/Component.js,现在这里我们什么都不做,只是将这个类暴露出去

// Component.js
export default class Component {
}

在index.js中,将这个Component类导出

// MyReact/index.js
import createElement from './createElement'
import render from './render'
import Component from './Component'
export default {
  createElement,
  render,
  Component
}

更新下我们的测试代码,index.js文件中我们来使用Component声明一个类组件

// index.js
class MyClass extends MyReact.Component {
  constructor(props) {
    super(props)
  }
  render() {
    return <div>我是类组件-----</div>
  }
}
MyReact.render(<MyClass />, root)

然后在4.1的基础上,增加buildClassComponent方法。按照我们之前做函数组件的分析,类组件生成vDOM需要执行一次实例化,然后手动的去调用render放发

import isFunction from "./isFunction"; // 判断是否为function
import isFunctionComponent from "./isFunctionComponent"; // 区分函数组件 类组件
import mountNativeElement from "./mountNativeElement"; // mount Native元素
// 
export default function mountComponentElement (vDOM, container) {
  console.log(vDOM, 'vDOM')
  let nextElementVDOM = null // 申明一个变量用于保存即将生成的vDOM
  // 区分class组件, 以及函数组件,如果vDOM中 有render方法,那么就是class组件,否则就是函数组件
  if (isFunctionComponent(vDOM)) {
    // 这里生成的可能是一个包含组件的函数式组件
    nextElementVDOM =  buildFunctionComponent(vDOM) // 借用一个方法,去生成函数组件的vDOM
  } else {
    // 类组件的渲染
    nextElementVDOM =  buildClassComponent(vDOM)
  }
  // 如果这个创建的elemnt还是个函数,那么就继续处理
  if (isFunction(nextElementVDOM)) {
    mountComponentElement(nextElementVDOM, container)
  } else {
    // 如果不是函数,那么就是普通的Dom元素了,直接进行渲染
    mountNativeElement(nextElementVDOM, container)
  }

}

/**
 * build函数式组件,函数式组件直接执行,执行过后就是生成的vdom
 * @param {*} vDOM
 * @returns
 */
function buildFunctionComponent(vDOM) {
  return vDOM.type() // 上面的分析可知,这个vDOM的type就是个函数 执行过后,返回return中的内容
}

// 新增,build类组件方法
/**
 * build类组件, 需要对其进行实例化,然后手动的调用render方法
 * @param {*} vDOM
 * @returns
 */
function buildClassComponent(vDOM) {
  const component = new vDOM.type()
  return component.render()
}

到这里,我们的类组件渲染方法已经结束了,可以到浏览器中看看执行结果
ef3c6c38702cc6aadea894b44666ed92.png

4.3 组件的props渲染

4.1 4.2中,我们完成了组件的渲染,但是没有把props放入进去。我们回忆一下,props在react中是如何进行传递的.

// 类组件
class MyComponent extends React.Component {
    constructor(props) {
        super(props)
    }
    render() {
        return <div>
        <p>{this.props.age}</p>
        <p>{this.props.name}</p>
        <p>{this.props.gender}</p>
        </div>
    }
}
// 函数组件
function Heart(props) {
  return (<div>
    &hearts;
    {props.title}
    <Hello />
  </div>)
}
  • 类组件
    通过继承自父类的props,然后在子类中使用,因此我们需要在Component这个类中,实现props的保存。如何去做呢,很简单,那就是将子类传递过来的props,在构造函数中保存起来,这样子类便可以访问到父类的props了。于是我们加入下面的代码到Component类中。
    接下来的问题是,我们如何将props属性,传递到类组件的构造函数中呢?也很简单,那就是在我们buildClassComponent这个方法中,实例化了类组件,因此我们只需要在这里将props属性在类组件被实例化的时候传递过去就可以了
// Component.js
export default class Component {
  constructor(props) {
    this.props = props
  }
}
// mountComponentElement.js
function buildClassComponent(vDOM) {
  const component = new vDOM.type(vDOM.props) // 将props作为实例化是的参数传递过去
  return component.render()
}

image.png

  • 函数组件
    函数组件相对更简单,只需要在调用render方法的时候,将props传递过去就可以了,因为他就是个普通函数。

    
    // mountComponentElement.js
    function buildFunctionComponent(vDOM) {
      return vDOM.type(vDOM.props) // 上面的分析可知,这个vDOM的type就是个函数 执行过后,返回return中的内容, 我们把props传递过去,在组件内部就可以获取到props的值了。
    }

    image.png

至此,函数组件的渲染就已经完成了。


接下来要做的就是:

  • dom元素更新时的vDOM对比,删除节点
  • setState方法
  • 实现ref属性获取dom对象数组
  • key属性的节点标记与对比

路飞的笑
119 声望3 粉丝

人活着就要做有意义的事情。