要不要考虑手写个React?


  • 微信扫码关注公众号 :前端前端大前端,追求更精致的阅读体验 ,一起来学习啊
  • 关注后发送关键资料,免费获取一整套前端系统学习资料和老男孩python系列课程
    在这里插入图片描述

项目描述

该项目借助snabbdom模拟实现React的主要功能

  • jsx
  • 函数组件
  • 类组件
  • 事件绑定
  • 列表渲染

项目源码

项目脚手架

安装依赖

npm i

启动

npm start

jsx实现

jsx其实就是一个js对象而已,遇到JSX的地方都通过 @babel/plugin-transform-react-jsx 插件转换为 React.createElement函数调用的形式,所以我们需要手动实现这个方法。同时,这也解释了为什么出现jsx的地方即使不显式使用React也要引入。

React.createElement

//src/react.js
const createElement = (type, props = {}, ...children) => {
    console.log({
        type, props, children
    });
};

const React = {
    createElement
};

export default React;

index.js

//src/index.js
import React from "./react";

const App = (
    <div>
        <header className="title">
            我是头部
        </header>
        <main>
            我是内容
        </main>
    </div>
);


  • 值得注意的是,我们在index.js中写了jsx代码段,但是没有手动去调用React.createElement方法,控制台却有预期输出。很正常,这就是上边介绍的插件帮我们做的事。

在这里插入图片描述

  • 到这里我们可以知道React.createElement 的本质是生成一个虚拟dom,所以光打印肯定是不够的。这里就需要引入snabbdom,这个就是虚拟dom相关的库,vue2使用的也是它。

React.createElement完善

//src/react.js
import { h } from 'snabbdom';

const createElement = (type, props = {}, ...children) => {
    return h(type, { props }, children);
};


const React = {
    createElement
};

export default React;
  • 此时代码正常运行,但是渲染不到页面上。这是因为虚拟dom还没有转成真实dom,所以我们需要react-DOM中新增render方法。而这个render方法的具体实现,直接交给snabbdom处理即可

ReactDOM.render

//src/ReactDOM.js
import {init} from 'snabbdom';
import propsModule from 'snabbdom/modules/props';
const reconcile = init([propsModule]);

const render = (el, mountedNode) => {
    reconcile(mountedNode, el);
}

const ReactDOM = {
    render
}
export default ReactDOM;


在index.js中渲染

//src/index.js
import React from "./react";
import ReactDOM from './reactDOM';
const App = (
    <div>
        <header className="title">
            我是头部
        </header>
        <main>
            我是内容
        </main>
    </div>
);


ReactDOM.render(App,document.getElementById("root"))

  • 此时页面上已经可以正常显示我们预期的内容了

在这里插入图片描述

  • 但问题还没有结束,当我们在 index.js 中多次调用 render,会报错。这是因为我们只有在第一次渲染时,可以在真实的DOM节点上调用 reconcile 方法,此后所有的渲染都应该在之前返回的虚拟DOM节点上调用reconcile 。

ReactDOM.render完善

//src/ReactDOM.js
import {init} from 'snabbdom';
import propsModule from 'snabbdom/modules/props';
const reconcile = init([propsModule]);
let vMountedNode = null;
const render = (el, mountedNode) => {
    if (vMountedNode == null) {
        //第一次调用 render 时渲染真实dom节点
        vMountedNode = mountedNode;
    }
    //此后所有渲染在之前返回的虚拟dom上进行
    vMountedNode = reconcile(vMountedNode, el);

}
const ReactDOM = {
    render
}
export default ReactDOM;



  • ok ,到此,第一部分jsx完成

函数组件实现

接下来我们在index.js中添加一个函数组件,看看会怎么个情况

import React from "./react";
import ReactDOM from './reactDOM';
const App = (
    <div>
        <header className="title">
            我是头部
        </header>
        <main>
            我是内容
        </main>
        <Footer/>
    </div>
);

function Footer(){
    return <div>我是尾部</div>
}


ReactDOM.render(App,document.getElementById("root"))


  • 预料之中的报错,sel.indexOf is not a function
  • 问题关键是我们上边写的React.createElement方法接收的第一个参数只能识别原生的html标签,也就是字符串类型。
  • createElement方法张打印type后发现,函数组件的type是一个函数。
  • 解决方法也很简单,做一次类型判断,是字符串的直接创建,是函数就调用一下,将props传入,然后再创建。
//src/react.js
import { h } from 'snabbdom';

const createElement = (type, props = {}, ...children) => {

    //如果是函数组件,那么调用它,并返回执行结果
    if (typeof type === 'function') {
        return type(props);
    }
    return h(type, { props }, children);
};


const React = {
    createElement
};

export default React;
  • 此时我们发现,函数组件可以正常工作了
    在这里插入图片描述
  • 注意上边的type(props),你也许好奇为什么要传递props参数。
  • 如果不传,函数组件的的props哪来呢?
  • 看下函数组件的用法就能理解了
function App(props){
	return <div>{props.title}</div>
}

类组件实现(重点)

类组件的实现比函数组件要难一些,因为它除了状态更新还要考虑生命周期函数,下面开始类组件的逐步实现

添加Component基类

//src/react.js
import { h } from 'snabbdom';

const createElement = (type, props = {}, ...children) => {

    //如果是函数组件,那么调用它,并返回执行结果
    if (typeof type == 'function') {
        return type(props);
    }
    return h(type, { props }, children);
};

class Component {
    constructor() { }

    componentDidMount() { }

    setState(partialState) { }

    render() { }
}


const React = {
    createElement,
    Component
};

export default React;
  • 这里解释了类组件用法中的class Child extends React.Component{}

  • 接下来新建一个类组件counter.js去继承上述react.js中的基类Component并且在index.js中使用

counter.js

//src/counter.js
import React from './react';

export default class Counter extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            count: 0
        }
    }

    componentDidMount() {
        console.log('Component mounted');
    }

    render() {
        return <p>Count: {this.state.count}</p>
    }
}


在index.js中使用类组件

import React from "./react";
import ReactDOM from './reactDOM';
import Counter from "./counter";
const App = (
    <div>
        <header className="title">
            我是头部
        </header>
        <main>
            <Counter/>
        </main>
        <Footer/>
    </div>
);

function Footer(){
    return <div>我是尾部</div>
}


ReactDOM.render(App,document.getElementById("root"))


  • 意料之中的报错Cannot call a class as a function
  • 字面意思,函数可以调用,类自然不能像函数那样调用
  • js基础好的应该清楚es6中的class其实只是语法糖,底层还是function
  • 像函数组件那样排查错误,React.createElement中打印一下类组件的type,底层还是函数
  • 既然都是函数,如何区分?原型添加标识即可
  • 所以,类实例化之后,并调用实例的render方法,之后就和函数组件一毛一样
  • 这个实例化过程,也是类组件不如函数组件性能好的原因之一

添加原型标识

class Component {
    constructor() { }

    componentDidMount() { }

    setState(partialState) { }

    render() { }
}

//这样就能区分类组件和函数组件了
Component.prototype.isReactClassComponent = true;

完善React.createElement方法

const createElement = (type, props = {}, ...children) => {

    /**
      * 如果是类组件,创建实例,调用实例的 render 方法
      * 如果是函数组件,直接调用
      */
    if (type.prototype && type.prototype.isReactClassComponent) {
        const componentInstance = new type(props);
        return componentInstance.render();
    }

    if (typeof type === 'function') {
        return type(props);
    }
    return h(type, { props }, children);

};
  • ok,改完后类组件正常工作

在这里插入图片描述

  • 下边就到了问题的关键,state和setState。如果你有看过react源码,你会发现负责状态更新的是react-dom,而不是react本身。这是为了多平台的代码复用。一样的更新逻辑可以对react用,也可以对reactNative使用。关键是react-dom中在React身上添加的__updater 方法,该方法定义了this.setState更新dom时候的具体逻辑,像这样
//src/react-DOM.js
import React from './react'
import { init } from 'snabbdom';
import propsModule from 'snabbdom/modules/props';
const reconcile = init([propsModule]);

let vMountedNode = null;
const render = (el, mountedNode) => {
    if (vMountedNode == null) {
        //第一次调用 render 时渲染真实dom节点
        vMountedNode = mountedNode;
    }
    //此后所有渲染在之前返回的虚拟dom上进行
    vMountedNode = reconcile(vMountedNode, el);

}

React.__updater = function () {
    //do something
}
const ReactDOM = {
    render
}
export default ReactDOM;

  • 但仅仅如此是不够的,react在进行this.setState的时候会比较旧数据和新数据,是差异更新,也就是常说的diff算法。这里的新旧数据是相对render前和render后而言的。为了进行比较,我们在类组件上添加 __vNode 属性,以维护该组件当前的 VNode 实例。

  • 所以类组件创建那里要细化一下

  if (type.prototype && type.prototype.isReactClassComponent) {
        const componentInstance = new type(props);
        componentInstance.__vNode = componentInstance.render();
        return componentInstance.__vNode;
    }
  • 然后去在基类中完善状态更新的方法
class Component {
    constructor() { }

    componentDidMount() { }

    setState(partialState) { 
        this.state = {
            ...this.state,
            ...partialState
        }
       //这里的this实际就是基类实例化出的实例
        React.__updater(this);
    }

    render() { }
}


  • 此时我们可以完善__updater方法了

React.__updater = (componentInstance) => {
    //当调用 this.setState 的时候更新 DOM 逻辑
    //获取在 __vNode 上存储的 oldVNode
    const oldVNode = componentInstance.__vNode;
    //获取 newVNode
    const newVNode = componentInstance.render();
    //更新 __vNode
    componentInstance.__vNode = reconcile(oldVNode, newVNode);
}

  • 完善更新逻辑后,我们在counter.js中执行一下this.setState的逻辑
//src/counter.js
import React from './react';

export default class Counter extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            count: 0
        }


        setInterval(() => {
            this.setState({
                count: this.state.count + 1
            })
        }, 1000);


    }

  

    render() {
        return <p>Count: {this.state.count}</p>
    }
}

  • 到这里你会发现界面上的计时器确实符合预期的在执行

  • 下面要实现的是生命周期函数,大同小异,这里只介绍挂载时候的钩子函数实现,更多信息可参考这里

     if (type.prototype && type.prototype.isReactClassComponent) {
        const componentInstance = new type(props);
        componentInstance.__vNode = componentInstance.render();
          //增加钩子函数(当虚拟DOM被添加到真实DOM节点上时)
          componentInstance.__vNode.data.hook = {
            create: () => {
                componentInstance.componentDidMount()
            }
        }
        return componentInstance.__vNode;  
    }

  • 接下来在counter.js中使用一下componentDidMount函数
  componentDidMount() {
        console.log('Component mounted');
    }
  • ok,此时控制台会打印Component mounted

在这里插入图片描述

事件绑定

大家都清楚react中事件都是小驼峰形式,而我们上边还没有对事件绑定这块进行处理。借助snabbom中的事件模块,很容易解决这个问题

ReactDOM中引入eventlistenersModule


import eventlistenersModule from 'snabbdom/modules/eventlisteners';
const reconcile = init([propsModule,eventlistenersModule]);

React.createElement对props进行完善

const createElement = (type, props = {}, ...children) => {

    let dataProps = {};
    let eventProps = {};

    for (let propKey in props) {
        // event 属性总是以 `on` 开头
        if (propKey.startsWith('on')) {
            const event = propKey.substring(2).toLowerCase();
            eventProps[event] = props[propKey];
        } else {
            dataProps[propKey] = props[propKey];
        }
    }

    /**
      * 如果是类组件,创建实例,调用实例的 render 方法
      * 如果是函数组件,直接调用
      */
    if (type.prototype && type.prototype.isReactClassComponent) {
        const componentInstance = new type(props);

          //增加钩子函数(当虚拟DOM被添加到真实DOM节点上时)
        componentInstance.__vNode = componentInstance.render();

        componentInstance.__vNode.data.hook = {
            create: () => {
                componentInstance.componentDidMount()
            }
        }
        return componentInstance.__vNode;
        
        
    }

    if (typeof type === 'function') {
        return type(props);
    }
    return h(type, { props: dataProps, on: eventProps }, children);

};

  • 下面我们把counter.js中的定时器去掉,改成点击事件实现
//src/counter.js
import React from './react';

export default class Counter extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            count: 0
        }


    }

    componentDidMount() {
        console.log('Component mounted');
    }

    render() {
        return <p onClick={()=>{
            this.setState({
                count:this.state.count+1
            })
        }}>Count: {this.state.count}</p>
    }
}

  • 此时点击试试,发现符合预期,事件绑定ok

列表渲染

这是处理起来最简单的,涉及一个扁平化处理

  • 先不处理,直接在index.js中拿来用一把
import React from "./react";
import ReactDOM from './reactDOM';
const list = ["vue", "react", "angular"]
import Counter from "./counter";
const App = (
    <div>
        <header className="title">
            我是头部
        </header>
        <main>
            {
                list.map((item, i) => {
                    return <p key={i}>{item}</p>
                })
            }


            <Counter />


        </main>
        <Footer />
    </div>
);

function Footer() {
    return <div>我是尾部</div>
}


ReactDOM.render(App, document.getElementById("root"))


  • 渲染结果是undefined
  • 排查原因,React.createElement中打印一下type,props,children,发现children是嵌套数组

在这里插入图片描述

  • 扁平化处理就能识别了,es6+系列已经在高级浏览器原生支持flat方法

  • React.createElement中对children进行处理

    children=children.flat(Infinity)//Infinity表示无限展开
  • 再看界面,发现列表渲染ok
    在这里插入图片描述

  • 参考文章 https://juejin.im/post/5db0a9876fb9a020794d8c25

发布了396 篇原创文章 · 获赞 786 · 访问量 16万+

猜你喜欢

转载自blog.csdn.net/qq_42813491/article/details/103780553