从零实现一个React&ReactDOM(附代码)

时间:2020-7-1 作者:admin

前言

作为一个react刚入门的小白,趁放假看了珠峰架构的从零实现react视频,跟着敲了一下,为了加深记忆和理解,写了这篇博客。

文中代码来自于视频,我做的就是根据自己的理解梳理了一遍整个逻辑,所以本文严格意义上不算一篇博客,应该是算学习笔记。

如果说的有不正确的地方欢迎评论指出!

一个最简单的实现

调用

// index.js
import React from './react';

React.render('hello world', document.getElementById('root'));

实现

上面两行代码的结果是在一个id是root的容器里,塞了一个hello world字符串,因此我们可以简单的定义render方法

// react.js
function render(el, container) {
    $(container).html(el); // jQuery API
}

export default React {
  render,
}

函数组件和类组件

但是我们很快会发现,我们平时并不是这么简单的定义一个组件,常用的我们说react定义组件的方式有函数两种。

但是这两种方式的el要怎么塞入container呢,我们可以先简单的看看他们的调用和babel转译后的结果。

函数组件

function App() {
  return (
      <div style="color: red" onClick={function() {aler(1)}}>
        Hello
      <span>world</span>
    </div>
  )
}

React.render(App(), document.getElementById('root'));

经过bebel转译后,发现他调用了React的ceateElement的方法,了解虚拟DOM的小伙伴可能对他可能不会陌生,不熟悉也没事,我们往后看:

React.render(
  React.createElement(
    'div',
    {
      style: "color: red",
      onClick: function () { alert(1); }
    },
    'hello',
    React.createElement('span', {}, 'world')
  ),
  document.getElementById('root')
);

类组件

class App extends React.Component {
  render () {
    return (
      <div style="color: red" onClick={function() {aler(1)}}>
        Hello
        <span>world</span>
      </div>
      )
  }
}

React.render(App, document.getElementById('root'));

经过bebel转译后,我们发现他和函数组件非常类似,但是第一个参数从字符串变成了一个App类(function):

React.render(
  React.createElement(App),
  document.getElementById('root')
);

React.createElement

我们发现,这两种调用方式,经过转移之后都调用了React.createElement这个方法。

我们可以看看他的简单实现,第一个参数是Tag的元素类型,当我们用class声明组件的时候,jsx语法就是<App></App>,相应的,type就是APP,此时的App是一种方法。

// 方便判断对象是否是虚拟DOM,el instanceof DOM
class Element {
  constructor(type, props) {
    this.type = type;
    this.props = props;
  }
}

/**
 * 返回虚拟DOM
 * @param {string|function} type 节点类型
 * @param {object} props 节点属性
 * @param {array} children 子节点
 */
export default function createElement(type, props = {}, ...children) {
  props.children = children; // array
  return new Element(type, props);
}

React.createElement的返回是一个Element类,用类去构造他的好处是我们可以通过instanceof判断对象是否是虚拟DOM。

虚拟DOM

虚拟DOM说白了就是用一个对象表示一个DOM节点。

例如: <div id="app">hello</div>我们可以这么描述他,这是一个标签类型为div,属性是id,属性值是app,具有一个string类型子节点的节点。

把高亮部分提取出来,我们可以用对象描述这个节点,这个对象就是虚拟DOM。

const el = {
  type: 'div',
  props: {
    id: 'app',
    children: [
      'hello'
    ]
  },
}

使用虚拟DOM描述节点有什么好处呢?

我们知道操作真实DOM节点的代价是非常高的,当数据频繁发生更新的时候,直接操作真实DOM节点就会造成频繁的重绘和回流。

虽然一次重绘和回流只耗时几十ms,但是可能因为重绘,造成一个频繁的闪烁,用户体验非常不好。

因此,我们可以操作虚拟DOM,然后将虚拟DOM一次性绘制到页面上,当然,这个时候我们不需要将整个页面重新渲染,React和Vue都有diff算法,通过打补丁的方式更新改变的DOM节点,提升了更新的效率。

不过这个暂时不属于本文范围,有兴趣的小伙伴可以自行了解一下,说这个的目的就是解释一下为什么我们不直接把真实DOM节点传入render方法中,而是绕了一个弯传入了虚拟DOM这样一个js对象

使用虚拟DOM的问题

第一个问题是,我们发现,React.createElement返回值是作为render方法的el入参传入的。

function render(el, container) {
    $(container).html(el); // jQuery API
}

按照我们之前的简单实现,el应该是一个HTML字符串才能被渲染到容器中。

所以接下来我们要做的事情就是把虚拟DOM形式的对象,转换为我们的HTML字符串。

第二个问题是,既然使用了虚拟DOM,我们需要一个唯一标识去对应虚拟DOM和真实DOM节点。为了解决这个问题,我们可以给每个节点增加rootId,当然在新版中使用fiber代替,已经看不到它了。

对render函数的改造

现在我们发现,render方法的el参数有三种形式的入参数

  • string类型或者number类型的简单符号
  • Element类的虚拟DOM
    • type是string类型的函数式组件
    • type是function类型的类组件

这时候我们可以简单的构造一个工厂函数createReactUnit,根据不同的传入类型,用相应的方法返回一个HTML字符串。

createReactUnit这里return的是一个类的实例,通过实例的getMarkUp方法去获取HTML字符串,其实是为了代码的可扩展性,如果组件还有其他的操作,就可以通过类的不同方法实现。

// unit.js
class Unit {
  constructor(el) {
    this.curremtEl = el;
  }
}

class ReactTextUnit extends Unit {
  // 每个不同的子类分别重写getMarkUp方法,返回HTML字符串
  // rootId的作用在后面解释,可以理解为区分节点设置的id值
  getMarkUp(rootId) { ... }
}

class ReactNativeUnit extends Unit {
  getMarkUp(rootId) { ... }
}

class ReactComponentUnit extends Unit {
  getMarkUp(rootId) { ... }
}

// 对不同的el,根据我们刚刚三种形式入参的特征进行判断。
const creatReactUnit = function (el) {
  // 如果是简单的字符串或者数字类型
  if (typeof el === 'string' || typeof el === 'number') {
    return new ReactTextUnit(el);
  }
  // 如果是函数式的组件
  if (typeof el === 'object' && typeof el.type === 'string') {
    return new ReactNativeUnit(el);
  }
  // 如果是class形式的
  if (typeof el === 'object' && typeof el.type === 'function') {
    return new ReactComponentUnit(el);
  }
}


// react.js
function render(el, container) {
  // 获取相应的单元实例
  let creatReactUnitInstance = creatReactUnit(el);
  // 调用实例的getMarkUp方法,获取相应的HTML字符串
  // 这里有一个nextRootIndex参数,就是rootId
  let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
  // 将HTML字符串塞入容器中进行渲染
  $(container).html(mark);
}

export default const React = {
  render,
  createElement,
  Component,
  nextRootIndex: 0,
}

编写getMarkUp方法

现在,我们就可以重写每个子类的getMarkUp方法,去生成HTML字符串。

rootId

为了区分不同的节点,方便对虚拟DOM进行操作,我们给每个节点添加一个data-rootid属性,形如:

<div data-rootid="0">
  <div data-rootid="0.0">
    <span data-rootid="0.0.0">hello</span>
    <span data-rootid="0.0.1">world</span>
  </div>
</div>

我们可以通过[data-rootid=”0.0.0″]方便的选择到文本是hello的这个span。

String | number

其实string类型的字符串可以直接塞入到容器中,但是为了区分不同的节点,我们还是要给他们外面套一层span,用rootId去标识他们。

class ReactTextUnit extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;

    return `<div data-rootid="${rootId}">
      ${this.curremtEl}
    </div>`;
  }
}

函数式组件

这时候我们拿到的el是Element类,有type和prop两个属性,形如:

el = {
  type: "div",
  props:{
    style: "color: red",
    children: [
      "app",
      { props: {…}, type: ƒ }
    ]
  }
}

要将这个结构转化成HTML字符串,很容易想到递归,递归的出口就是当children数组的元素类型是基本类型。

例如<div>hello</div>可以拆解为一个elementNodediv和一个textNodehello,textNode没有children,因此递归结束,此时creatReactUnit方法返回的就是ReactTextUnit类的实例,调用getMarkUp方法就能获得相应的HTML字符串。

class ` extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;
    let { type, props } = this.curremtEl;
    let children;
    // 起始标签,属性需要通过遍历props向后添加
    let startTag = `<${type} data-rootid="${rootId}"`
    let endTag = `</${type}>`;

    // 遍历props,给Tag拼属性 style="color: red"
    for (let key in props) {
      if (key === 'children') {
        /** 
         * 处理是子节点的情况
         * 递归遍历children数组,join方法将数组转为HTML字符串
        */
        children = props[key].map((el, index) => {
          // 递归调用createReactUnit,将虚拟DOM转化为HTML字符串
          let childInstance = creatReactUnit(el);
          return childInstance.getMarkUp(`${rootId}.${index}`)
        }).join('');
      } else {
        // 如果是节点的属性,直接向startTag后面拼
        startTag += `${key}="${props[key]}"`
      }
    }

    // 返回html字符串
    return `${startTag}>${children}${endTag}`
  }
}

类声明式组件

类声明式组件el形式和函数式类似,但是节点类型是一个类,所以我们需要调用这个类的render方法,才能获取类的结构(也就是let reactRendered = componentInstance.render();这一句调用)。

获取到了结构之后,就和函数式组件一样,递归调用creatReactUnit就可以了。

class ReactComponentUnit extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;
    let { type: Component, props } = this.curremtEl;
    // 方便 this.props 调用
    let componentInstance = new Component(props);
    // 获取组件render方法返回的结构,形如<App><span>123</span></App>
    let reactRendered = componentInstance.render();
    // 递归调用createReactUnit方法,渲染组件中的tag
    let reactComponentUnitInstance = creatReactUnit(reactRendered);
    return reactComponentUnitInstance.getMarkUp(rootId);
  }
}

实现事件的绑定

到这里为止,我们已经可以简单的把组件渲染到页面上了,但是,当我们想给组件绑定一些事件的时候我们会发现,我们不能给HTML字符串绑定事件。

这时候我们就可以想到事件委托机制,那么怎么知道事件要绑定到哪一个节点上呢,我们定义的rootId就派上了用场。

$(document).on('click', '[data-rootid=0.0.0]', function() {alert(1)}) // 这里用了jQuery的API,也可以用原生的

这一部分判断应该在ReactNativeUnit类中,只有这个类,才涉及props的拼接,所以我们添加一个判断,如果匹配到了on开头的属性,作为事件绑定它。

完整代码如下:

class ReactNativeUnit extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;
    let { type, props } = this.curremtEl;
    console.log(this.curremtEl)
    let children;
    let startTag = `<${type} data-rootid="${rootId}"`
    let endTag = `</${type}>`;

    for (let key in props) {
      if (key === 'children') {
        children = props[key].map((el, index) => {
          let childInstance = creatReactUnit(el);
          return childInstance.getMarkUp(`${rootId}.${index}`)
        }).join('');
      } else if (/on[a-z]/i.test(key)) {
        /** 
         * 处理有事件的情况
         * 给元素绑定事件
        */
        let eventType = key.slice(2).toLocaleLowerCase(); // onClick -> click
        // 通过事件委托绑定事件,参数分别是类型,子选择器,要绑定的方法
        $(document).on(eventType, `[data-rootid=${rootId}]`, props[key])
      }
      else {
        startTag += `${key}="${props[key]}"`
      }
    }

    return `${startTag}>${children}${endTag}`
  }
}

实现简单的生命周期

react里和组件挂载相关的两个生命周期是componentWillMountcomponentDidMount,当有组件嵌套的时候,他们的调用顺序是。

  1. Parent will mount
  2. Children will mount
  3. Children did mount
  4. Parent did mount

怎么样才能使生命周期呈现这个顺序呢,我们可以先看componentWillMount

componentWillMount

这个顺序很简单,是按照递归的顺序调用的,层级越外面,越先调用到。

和组件相关的递归调用,就只有ReactComponentUnit类的getMarkUp方法,因此只需要在这个方法里加一行componentWillMount方法的调用就可以了,完整代码如下。

class ReactComponentUnit extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;
    let { type: Component, props } = this.curremtEl;
    let componentInstance = new Component(props);
    // 调用生命周期钩子函数
    componentInstance.componentWillMount && componentInstance.componentWillMount();
    let reactRendered = componentInstance.render();
    let reactComponentUnitInstance = creatReactUnit(reactRendered);
    return reactComponentUnitInstance.getMarkUp(rootId);
  }
}

componentDidMount

这个钩子的调用顺序和递归的解析顺序是相反的,后挂载先执行,因此他应该在return方法之前调用。

其次,DidMount是在组件挂载成功后执行的钩子,关于组件挂载,应该在React.render方法中执行,所以我们在render方法中先trigger一个‘mounted’事件,作为一个发布者。

// react.js
function render(el, container) {
  let creatReactUnitInstance = creatReactUnit(el);
  let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
  $(container).html(mark);
  // 挂载组件完成的方法
  $(document).trigger('mounted');
}

通过$(document).on('mounted',()=> {})方法订阅‘mounted’事件,执行生命周期钩子,生命周期钩子执行的顺序就够订阅的顺序。

因此,这句订阅应该在return的前面进行。

完整代码如下:

class ReactComponentUnit extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;
    let { type: Component, props } = this.curremtEl;
    let componentInstance = new Component(props);
    componentInstance.componentWillMount && componentInstance.componentWillMount();
    let reactRendered = componentInstance.render();
    let reactComponentUnitInstance = creatReactUnit(reactRendered);
    let markUp = reactComponentUnitInstance.getMarkUp(rootId);
    // 子组件开始挂载,先于父组件订阅这个方法
    $(document).on('mounted', () => {
      componentInstance.componentDidMount && componentInstance.componentDidMount();
    })
    return markUp;
  }
}

完整代码

看了这么多,我们其实发现,整个逻辑的核心就是把jsx语法先转成虚拟DOM,再递归遍历虚拟DOM将它转成HTML字符串,按照这个思路就非常容易理解整个逻辑。

以下是本文提到的所有代码:

// react.js
import $ from 'jquery';
import creatReactUnit from './unit'
import createElement from './element'
import Component from './component'

let React = {
  render,
  createElement,
  Component,
  nextRootIndex: 0,
}

/**
 * 将虚拟DOM渲染到页面上
 * @param {DOM} el 要渲染的元素,jsx语法
 * @param {*} container 容器
 */
function render(el, container) {
  // 通过标记获取el中的元素
  let creatReactUnitInstance = creatReactUnit(el);
  let mark = creatReactUnitInstance.getMarkUp(React.nextRootIndex);
  $(container).html(mark);
  // 挂载组件完成的方法
  $(document).trigger('mounted');
}

export default React;
// unit.js

import $ from 'jquery';

// 父类,通过父类保存参数
class Unit {
  constructor(el) {
    this.curremtEl = el;
  }
}

class ReactTextUnit extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;

    return `<div data-rootid="${rootId}">
      ${this.curremtEl}
    </div>`;
  }
}

class ReactComponentUnit extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;
    let { type: Component, props } = this.curremtEl;
    // 给方便this.props调用
    let componentInstance = new Component(props);
    componentInstance.componentWillMount && componentInstance.componentWillMount();
    // 组件返回的
    let reactRendered = componentInstance.render();
    // 递归渲染组件中的tag,<App><span>123</span></App>
    let reactComponentUnitInstance = creatReactUnit(reactRendered);
    let markUp = reactComponentUnitInstance.getMarkUp(rootId);
    // 子组件开始挂载,先于子组件订阅这个方法
    $(document).on('mounted', () => {
      componentInstance.componentDidMount && componentInstance.componentDidMount();
    })
    return markUp;
  }
}

class ReactNativeUnit extends Unit {
  getMarkUp(rootId) {
    this._rootId = rootId;
    let { type, props } = this.curremtEl;
    console.log(this.curremtEl)
    let children;
    let startTag = `<${type} data-rootid="${rootId}"`
    let endTag = `</${type}>`;

    // 给Tag拼属性 style="color: red"
    for (let key in props) {

      if (key === 'children') {
        /** 
         * 处理是子节点的情况
         * 递归添加子节点,成为字符串
        */
        children = props[key].map((el, index) => {
          let childInstance = creatReactUnit(el);
          return childInstance.getMarkUp(`${rootId}.${index}`)
        }).join('');
      } else if (/on[a-z]/i.test(key)) {
        /** 
         * 处理有事件的情况
         * 给元素绑定事件
        */
        let eventType = key.slice(2).toLocaleLowerCase();
        // 元素,选择器,方法
        $(document).on(eventType, `[data-rootid=${rootId}]`, props[key])
      }
      else {
        startTag += `${key}="${props[key]}"`
      }
    }

    return `${startTag}>${children}${endTag}`
  }
}

const creatReactUnit = function (el) {
  if (typeof el === 'string' || typeof el === 'number') {
    return new ReactTextUnit(el);
  }
  // 是否是 react 的 虚拟DOM
  if (typeof el === 'object' && typeof el.type === 'string') {
    return new ReactNativeUnit(el);
  }
  // 如果是class形式的
  if (typeof el === 'object' && typeof el.type === 'function') {
    return new ReactComponentUnit(el);
  }
}

export default creatReactUnit;
// element.js
class Element {
  constructor(type, props) {
    this.type = type;
    this.props = props;
  }
}

/**
 * 返回虚拟DOM
 * @param {string|function} type 节点类型
 * @param {object} props 节点属性
 * @param {array} children 子节点
 */
export default function createElement(type, props = {}, ...children) {
  props.children = children; // array
  return new Element(type, props);
}
// component.js
export default class Component {
  constructor(props) {
    this.props = props;
  }

  /**
   * 更新状态
   */
  setState() {
    ...
  }
}
声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:87074139@qq.com 进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。