10

前言

在上一篇文章JSX和虚拟DOM中,我们实现了基础的JSX渲染功能,但是React的意义在于组件化。在这篇文章中,我们就要实现React的组件功能。

React定义组件的方式可以分为两种:函数和类,我们姑且将两种不同方式定义的组件称之为函数定义组件类定义组件

函数定义组件

函数定义组件相对简单,只需要用组件名称声明一个函数,并返回一段JSX即可。
例如我们定义一个Welcome组件:

function Welcome( props ) {
    return <h1>Hello, {props.name}</h1>;
}
注意组件名称要以大写字母开头

函数组件接受一个props参数,它是给组件传入的数据。

我们可以这样来使用它:

const element = <Welcome name="Sara" />;
ReactDOM.render(
    element,
    document.getElementById( 'root' )
);

让createElemen支持函数定义组件

回顾一下上一篇文章中我们对React.createElement的实现:

function createElement( tag, attrs, ...children ) {
    return {
        tag,
        attrs,
        children
    }
}

这种实现只能渲染原生DOM元素,而对于组件,createElement得到的参数略有不同:
如果JSX片段中的某个元素是组件,那么createElement的第一个参数tag将会是一个方法,而不是字符串。

区分组件和原生DOM的工作,是babel-plugin-transform-react-jsx帮我们做的

例如在处理<Welcome name="Sara" />时,createElement方法的第一个参数tag,实际上就是我们定义Welcome的方法:

function Welcome( props ) {
    return <h1>Hello, {props.name}</h1>;
}

所以我们需要修改一下createElement,让它能够渲染组件。

function createElement( tag, attrs, ...children ) {
    
    // 如果tag是一个方法,那么它是一个组件
    if ( typeof tag === 'function' ) {
        return tag( attrs || {} );
    }

    return {
        tag,
        attrs,
        children
    }
}

渲染函数定义组件

在简单的修改了createElement方法后,我们就可以用来渲染函数定义组件了。
渲染上文定义的Welcome组件:

const element = <Welcome name="Sara" />;
ReactDOM.render(
    element,
    document.getElementById( 'root' )
);

在浏览器中可以看到结果:

图片描述

试试更复杂的例子,将多个组件组合起来:

function App() {
    return (
        <div>
            <Welcome name="Sara" />
            <Welcome name="Cahal" />
            <Welcome name="Edite" />
        </div>
    );
}
ReactDOM.render(
    <App />,
    document.getElementById( 'root' )
);

在浏览器中可以看到结果:
图片描述

类定义组件

类定义组件相对麻烦一点,我们通过继承React.Component来定义一个组件:

class Welcome extends React.Component {
    render() {
        return <h1>Hello, {this.props.name}</h1>;
    }
}

Componet

为了实现类定义组件,我们需要定义一个Component类:

class Component {}

state & props

通过继承React.Component定义的组件有自己的私有状态state,可以通过this.state获取到。同时也能通过this.props来获取传入的数据。
所以在构造函数中,我们需要初始化stateprops

// React.Component
class Component {
    constructor( props = {} ) {
        this.isReactComponent = true;
        this.state = {};
        this.props = props;
    }
}

这里多了一个isReactComponent属性,我们后面会用到。

setState

组件内部的state和渲染结果相关,当state改变时通常会触发渲染,为了让React知道我们改变了state,我们只能通过setState方法去修改它。我们可以通过Object.assign来做一个简单的实现。
在每次更新state后,我们需要使用ReactDOM.render重新渲染。

import ReactDOM from '../react-dom'
class Component {
    constructor( props = {} ) {
        // ...
    }

    setState( stateChange ) {
        // 将修改合并到state
        Object.assign( this.state, stateChange );
        if ( this._container ) {
            ReactDOM.render( this, this._container );
        }
    }
}

你可能听说过React的setState是异步的,同时它有很多优化手段,这里我们暂时不去管它,在以后会有一篇文章专门来讲setState方法。

让createElemen支持类定义组件

在js中,class只是语法糖,它的本质仍然是一个函数。
所以第一步,我们需要在createElemen方法中区分当前的节点是函数定义还是类定义。
类定义组件必须有render方法,而通过class定义的类,它的方法都附加在prototype上。
所以只需要判断tag的prototype中是否有render方法,就能知道这个组件是函数定义还是类定义。
现在我们可以进一步修改React.createElement

function createElement( tag, attrs, ...children ) {

    // 类定义组件
    if ( tag.prototype &&  tag.prototype.render ) {
        return new tag( attrs );
    // 函数定义组件
    } else if ( typeof tag === 'function' ) {
        return tag( attrs || {} );
    }

    return {
        tag,
        attrs,
        children
    }
}

render

函数定义组件返回的是jsx,我们不需要做额外处理。但是类定义组件不同,它并不直接返回jsx。而是通过render方法来得到渲染结果。

所以我们需要修改ReactDOM.render方法。
修改之前我们先来回顾一下上一篇文章中我们对ReactDOM.render的实现:

function render( vnode, container ) {

    if ( vnode === undefined ) return;
    
    // 当vnode为字符串时,渲染结果是一段文本
    if ( typeof vnode === 'string' ) {
        const textNode = document.createTextNode( vnode );
        return container.appendChild( textNode );
    }

    const dom = document.createElement( vnode.tag );

    if ( vnode.attrs ) {
        Object.keys( vnode.attrs ).forEach( key => {
            if ( key === 'className' ) key = 'class';            // 当属性名为className时,改回class
            dom.setAttribute( key, vnode.attrs[ key ] )
        } );
    }

    vnode.children.forEach( child => render( child, dom ) );    // 递归渲染子节点

    return container.appendChild( dom );    // 将渲染结果挂载到真正的DOM上
}

在上文定义Component时,我们添加了一个isReactComponent属性,在这里我们需要用它来判断当前渲染的是否是一个组件:

function render( vnode, container ) {

    if ( vnode.isReactComponent ) {
        const component = vnode;
        component._container = container;   // 保存父容器信息,用于更新
        vnode = component.render();            //  render()返回的结果才是需要渲染的vnode
    }
    
    // 后面的代码不变...
}

现在我们的render方法就可以用来渲染组件了。

生命周期

上面的实现还差一个关键的部分:生命周期。

在React的组件中,我们可以通过定义生命周期方法在某个时间做一些事情,例如定义componentDidMount方法,在组件挂载时会执行它。

但是现在我们的实现非常简单,还没有对比虚拟DOM的变化,很多生命周期的状态没办法区分,所以我们暂时只添加componentWillMountcomponentWillUpdate两个方法,它们会在组件挂载之前和更新之前执行。

function render( vnode, container ) {

    if ( vnode.isReactComponent ) {
        const component = vnode;

        if ( component._container ) {
            if ( component.componentWillUpdate ) {
                component.componentWillUpdate();    // 更新
            }
        } else if ( component.componentWillMount ) {
            component.componentWillMount();          // 挂载
        }

        component._container = container;   // 保存父容器信息,用于更新

        vnode = component.render();
    }
    
    // 后面的代码不变...
}

渲染类定义组件

现在大部分工作已经完成,我们可以用它来渲染类定义组件了。
我们来试一试将刚才函数定义组件改成类定义:

class Welcome extends React.Component {
    render() {
        return <h1>Hello, {this.props.name}</h1>;
    }
}

class App extends React.Component {
    render() {
        return (
            <div>
                <Welcome name="Sara" />
                <Welcome name="Cahal" />
                <Welcome name="Edite" />
            </div>
        );
    }
}
ReactDOM.render(
    <App />,
    document.getElementById( 'root' )
);

运行起来结果和函数定义组件完全一致:
图片描述

再来尝试一个能体现出类定义组件区别的例子,实现一个计数器Counter,每点击一次就会加1。
并且组件中还增加了两个生命周期函数:

class Counter extends React.Component {
    constructor( props ) {
        super( props );
        this.state = {
            num: 0
        }
    }

    componentWillUpdate() {
        console.log( 'update' );
    }

    componentWillMount() {
        console.log( 'mount' );
    }

    onClick() {
        this.setState( { num: this.state.num + 1 } );
    }

    render() {
        return (
            <div onClick={ () => this.onClick() }>
                <h1>number: {this.state.num}</h1>
                <button>add</button>
            </div>
        );
    }
}

ReactDOM.render(
    <Counter />,
    document.getElementById( 'root' )
);

可以看到结果:
图片描述

mount只在挂载时输出了一次,后面每次更新时会输出update

后话

至此我们已经从API层面实现了React的核心功能。但是我们目前的做法是每次更新都重新渲染整个组件甚至是整个应用,这样的做法在页面复杂时将会暴露出性能上的问题,DOM操作非常昂贵,而为了减少DOM操作,React又做了哪些事?这就是我们下一篇文章的内容了。

这篇文章的代码:https://github.com/hujiulong/...

从零开始实现React系列

React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

整个系列大概会有六篇左右,我每周会更新一到两篇,我会第一时间在github上更新,有问题需要探讨也请在github上回复我~

博客地址: https://github.com/hujiulong/blog
关注点star,订阅点watch

上一篇文章

从零开始实现React(一):JSX和虚拟DOM


hujiulong
2.5k 声望98 粉丝

js/vue/react/webGL