New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从零开始实现一个React(二):组件和生命周期 #5

Open
hujiulong opened this Issue Mar 26, 2018 · 32 comments

Comments

@hujiulong
Owner

hujiulong commented Mar 26, 2018

前言

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

组件

React定义组件的方式可以分为两种:函数和类,函数定义可以看做是类定义的一种简单形式。

createElement的变化

回顾一下上一篇文章中我们对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做修改,只需要知道如果渲染的是组件,tag的值将是一个函数

组件基类React.Component

通过类的方式定义组件,我们需要继承React.Component

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

所以我们就需要先来实现React.Component这个类:

Component

React.Component包含了一些预先定义好的变量和方法,我们来一步一步地实现它:
先定义一个Component类:

class Component {}

state & props

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

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

setState

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

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

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

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

render

上一篇文章中实现的render方法只支持渲染原生DOM元素,我们需要修改ReactDOM.render方法,让其支持渲染组件。
修改之前我们先来回顾一下上一篇文章中我们对ReactDOM.render的实现:

function render( vnode, container ) {
    return container.appendChild( _render( vnode ) );
}

function _render( vnode ) {

    if ( vnode === undefined || vnode === null || typeof vnode === 'boolean' ) vnode = '';

    if ( typeof vnode === 'number' ) vnode = String( vnode );

    if ( typeof vnode === 'string' ) {
        let textNode = document.createTextNode( vnode );
        return textNode;
    }

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

    if ( vnode.attrs ) {
        Object.keys( vnode.attrs ).forEach( key => {
            const value = vnode.attrs[ key ];
            setAttribute( dom, key, value );
        } );
    }

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

    return dom; 

我们需要在其中加一段用来渲染组件的代码:

function _render( vnode, container ) {

    // ...

    if ( typeof vnode.tag === 'function' ) {

        const component = createComponent( vnode.tag, vnode.attrs );

        setComponentProps( component, vnode.attrs );

        return component.base;
    }
    
    // ...
}

组件渲染和生命周期

在上面的方法中用到了createComponentsetComponentProps两个方法,组件的生命周期方法也会在这里面实现。

生命周期方法是一些在特殊时机执行的函数,例如componentDidMount方法会在组件挂载后执行

createComponent方法用来创建组件实例,并且将函数定义组件扩展为类定义组件进行处理,以免其他地方需要区分不同定义方式。

// 创建组件
function createComponent( component, props ) {

    let inst;
    // 如果是类定义组件,则直接返回实例
    if ( component.prototype && component.prototype.render ) {
        inst = new component( props );
    // 如果是函数定义组件,则将其扩展为类定义组件
    } else {
        inst = new Component( props );
        inst.constructor = component;
        inst.render = function() {
            return this.constructor( props );
        }
    }

    return inst;
}

setComponentProps方法用来更新props,在其中可以实现componentWillMountcomponentWillReceiveProps两个生命周期方法

// set props
function setComponentProps( component, props ) {

    if ( !component.base ) {
        if ( component.componentWillMount ) component.componentWillMount();
    } else if ( component.componentWillReceiveProps ) {
        component.componentWillReceiveProps( props );
    }

    component.props = props;

    renderComponent( component );

}

renderComponent方法用来渲染组件,setState方法中会直接调用这个方法进行重新渲染,在这个方法里可以实现componentWillUpdatecomponentDidUpdatecomponentDidMount几个生命周期方法。

export function renderComponent( component ) {

    let base;

    const renderer = component.render();

    if ( component.base && component.componentWillUpdate ) {
        component.componentWillUpdate();
    }

    base = _render( renderer );

    if ( component.base ) {
        if ( component.componentDidUpdate ) component.componentDidUpdate();
    } else if ( component.componentDidMount ) {
        component.componentDidMount();
    }

    if ( component.base && component.base.parentNode ) {
        component.base.parentNode.replaceChild( base, component.base );
    }

    component.base = base;
    base._component = component;

}

渲染组件

现在大部分工作已经完成,我们可以用它来渲染组件了。

渲染函数定义组件

渲染前文提到的Welcome组件:

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

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

1

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

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

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

渲染类定义组件

我们来试一试将刚才函数定义组件改成类定义:

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' )
);

运行起来结果和函数定义组件完全一致:
1

再来尝试一个能体现出类定义组件区别的例子,实现一个计数器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' )
);

可以看到结果:
2

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

后话

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

这篇文章的代码:https://github.com/hujiulong/simple-react/tree/chapter-2

从零开始实现React系列

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

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

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

上一篇文章

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

下一篇文章

从零开始实现一个React(三):diff算法

@Sunshine168

This comment has been minimized.

Sunshine168 commented Mar 26, 2018

板凳 期待详细讲 setState 的地方 尤其是对于setState 异步原因,以及事务机制

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Mar 26, 2018

@Sunshine168 嗯,后面会有一篇文章来讲setState

@Sunshine168

This comment has been minimized.

Sunshine168 commented Mar 27, 2018

另外感觉这里举例的生命周期不是很对~ 用WillMount 和 WillUpdate 会好点~ 这里用didMount和didUpdate 不是很准确。 这两个周期都应该是子组件都render完 父组件才会执行的。 这个版本 父子组件执行生命周期的顺序就会不对了~

@dabaoabc

This comment has been minimized.

dabaoabc commented Mar 27, 2018

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Mar 27, 2018

@Sunshine168 有道理,修改了~

@qinkangwu

This comment has been minimized.

qinkangwu commented Mar 27, 2018

// 当vnode为字符串时,渲染结果是一段文本
if ( typeof vnode === 'string' ) {
const textNode = document.createTextNode( vnode );
return container.appendChild( textNode );
}
改成 if ( typeof vnode === 'string' || typeof vnode === 'number')
不然会报TypeError

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Mar 27, 2018

@qinkangwu 是的,示例代码仓库里我是这样写的,文章中忘记改了

@hujiulong hujiulong changed the title from 从零开始实现React(二):实现组件功能 to 从零开始实现一个React(二):实现组件功能 Apr 8, 2018

@hujiulong hujiulong changed the title from 从零开始实现一个React(二):实现组件功能 to 从零开始实现一个React(二):组件和生命周期 Apr 8, 2018

@hujiulong hujiulong added the React label Apr 8, 2018

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Apr 8, 2018

在写第三篇时,发现这篇文章中很多实现都有些问题,而且实现方式和第三篇文章割裂开了,所以重写了这篇文章的部分内容。

@Sunshine168

This comment has been minimized.

Sunshine168 commented Apr 9, 2018

正在重新细读,感觉重写的内容跨度有点大~

@xwchris

This comment has been minimized.

xwchris commented Apr 14, 2018

发现一保存diff.js文件 parcel的热更新会无限执行 这是parcel hmr的bug吗

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Apr 14, 2018

@xwchris 我也发现这个问题了,还没仔细找原因

@austin880625

This comment has been minimized.

austin880625 commented May 22, 2018

不是很明白component.base的作用(以及base._component),能不能在文章中額外解釋一下?

@hujiulong

This comment has been minimized.

Owner

hujiulong commented May 23, 2018

@austin880625 component.base保存的是组件的dom对象,反过来base._component保存的是dom对象所对应的组件,这个就是为了把他们关联起来

@losefish

This comment has been minimized.

losefish commented Jun 10, 2018

chapter-2 这个分支中, ./src/react-dom/render.js 文件中第1行 "Componet" 是不是拼写错误?应该是 Component。

而如果修改为Component,第11行创建依然使用了new Component 创建实例,
使用pure component的方式创建组件

//  ./src/react-dom/render.js
import { Component } from '../react'
import { setAttribute } from './dom'
function createComponent( component, props ) {
    let inst;
    if ( component.prototype && component.prototype.render ) {
		inst = new component( props );
	} else {
		inst = new Component( props );
		inst.constructor = component;
		inst.render = function() {
            return this.constructor( props );
        }
	}
    return inst;
}
//  ./index.js
import React from './react';
import ReactDOM from './react-dom';
const Welcome = function(props) {
    return <h1>hello {props.name}</h1>;
};
const el = <Welcome name={'world'} />;
ReactDOM.render(el, document.getElementById('root'));

控制台报错
image

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Jun 10, 2018

@losefish 感谢指出,已经修复了

@crazylxr

This comment has been minimized.

crazylxr commented Jun 23, 2018

createElemen的变化那个标题,Element 少了个 t

@MeCKodo

This comment has been minimized.

MeCKodo commented Jul 30, 2018

你这生命周期实现的不对啊老铁。DidMount应该是DOM已经append到document里了,至少在这里是可以获取到真实DOM的,看了源码仅仅只是把vnode 转换为了dom,但是没有append进去。

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Jul 31, 2018

@MeCKodo 嗯,这是做了简单的处理,如果要严格实现正确的生命周期需要额外加一个队列保存组件挂载的顺序。我后面考虑改进一下

@dreamsline

This comment has been minimized.

dreamsline commented Aug 11, 2018

看不懂,到这一步调试不出来了

@daweilv

This comment has been minimized.

daweilv commented Aug 12, 2018

typeof vnode.tag === 'function' 的时候没有处理 props.children,会导致下面这样的结构渲染出错吧:

class Root extends React.Component {
  render() {
    return (
      <Parent>
        <Child />
        <Child />
      </Parent>
    );
  }
}
@132yse

This comment has been minimized.

132yse commented Aug 25, 2018

那个为什么 在 new Component() 的时候,会自带一个 base 并保存真实 dom 结构的?
是 jax 自带的功能吗?求教这个 base 是怎么加上去的

@jiachaosun

This comment has been minimized.

jiachaosun commented Aug 29, 2018

@132yse base是在这里赋值的,但是我也没理解为什么要这么做一次。
https://github.com/hujiulong/simple-react/blob/218fa694635bdd2c15de2ca1dd4903398662fd92/src/react-dom/render.js#L63

@hujiulong

This comment has been minimized.

Owner

hujiulong commented Sep 3, 2018

@jiachaosun 这个上面已经回答了呀,就是用来保存组件实例最终渲染出来的DOM,至于为什么要这么做,你自己看哪些地方用到了component.base

@calpa

This comment has been minimized.

calpa commented Oct 30, 2018

组件下面的 Component 章节,标题应该是 Component 而不是 Componet

@hawaiiey

This comment has been minimized.

hawaiiey commented Oct 30, 2018

有一个问题:ReactDOM.render方法中

function (vnode, container) {
    container.innerHTML = '' // 这段代码只执行一次
    return render(vnode, container) // 这段代码会根据vnode的层级递归执行
}
@hujiulong

This comment has been minimized.

Owner

hujiulong commented Oct 30, 2018

@calpa 感谢指出,已经改了
@hawaiiey 你的问题是啥...

@hawaiiey

This comment has been minimized.

hawaiiey commented Oct 30, 2018

@hawaiiey 你的问题是啥...

为什么函数内第一行代码只执行一次,而第二行会执行多次?

@dsaco

This comment has been minimized.

dsaco commented Oct 31, 2018

@hawaiiey 因为ReactDOM.render只调了一次阿, 下面的render是别的地方调用的

@dsaco

This comment has been minimized.

dsaco commented Oct 31, 2018

@hujiulong 看了挺多文章,你这个能看下去, 只是这一章代码太多,描述太少 看着有点懵逼啊。。。

@yinguangyao

This comment has been minimized.

yinguangyao commented Nov 12, 2018

一直想看react源码,但是看不进去,正好看到了这几篇文章,感觉写的非常好,希望会对自己之后解读react源码有帮助。

@bigggge

This comment has been minimized.

bigggge commented Nov 28, 2018

为啥要写
inst.constructor = component;
这一句?

@ruiqingzheng

This comment has been minimized.

ruiqingzheng commented Nov 30, 2018

为啥要写
inst.constructor = component;
这一句?

因为用了new Component(props) 这句后, 得到的对象的constructor属性就成了Component , 而这个对象实际上的构造方法应该是函数名 , 也就是component. 当然了不改也可以, 但是如果需要通过对象的constructor属性来判断它的父类就可能出错

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment