yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

React 入门:生命周期与组件通信

yinguangyao opened this issue · comments

commented

1. React 生命周期

在 React 类组件中提供了丰富的生命周期,允许你在组件渲染、更新和卸载的时候执行某些操作。

1.1 为什么要有生命周期

其实不仅是 React,在其他语言和框架中也有类似的概念,比如 Swift 的 ViewController、Spring 等等。
不妨换个角度来思考,组件这些都算是对象,每个对象都有从诞生到消亡的过程,就和人的生命一样,在每个阶段做该做的事情,这就是生命周期。
从技术的角度来说,由于 React 中的渲染是:view = f(data),而有些网络请求之类的副作用操作,这些超出了原有的能力,只能开放接口给开发者,这些接口就是生命周期。生命周期本质是提供 AOP(面向切面编程)的 hook。

1.2 React15 的生命周期

这张图是 React16 之前的生命周期图,一共是三个阶段,分别是首次 mounting 阶段、updation 更新阶段、unmouting 卸载阶段。

image_1dkqrtcjp2hc10t51u0v10fn19j59.png-205.6kB

1.3 constructor

constructor 函数接收 props 和 context 两个参数并在组件中进行初始化。如果你想要在构造函数中使用 props 和 context,那么必须调用 super 并传入 props 和 context。
由于 React 组件也是个类,所以在渲染阶段会使用 new 操作符来实例化,这一步是在 React 中做的,我们不需要关心。

class App extends React.Component {
    constructor(props, context) {
        super(props, context);
        console.log('props', this.props);
        console.log('context', this.context);
    }
}

那有时候我们调用 super,什么都不传,在组件中依然能够拿到 this.props,这是为什么呢?
这是因为 React 在实例化组件的时候会重新设置一遍 props。

const instance = new App(props);
instance.props = props;

1.4 componentWillMount

在组件第一次渲染之前调用,如果在此时调用setState,将不会引发多次渲染。

1.5 componentDidMount

在组件渲染成功(插入到dom树中)调用,不是在组件 render 后就调用,而是当所有子组件都触发 render 之后才会被调用。
依赖 DOM 的操作都应该放在这里,如果需要通过网络请求获取数据,也应当放到这里。

componentDidMount() {
    fetch('/getUserList').then(function(res) {
        return res.json();
    })
}

1.6 componentWillReceiveProps

componentWillReceiveProps 会在组件接收新的 props 之前被调用(一般是更新阶段),参数中返回了新的 props。如果这个时候你有需要比较前后两次 props 后再决定更新 state 的操作,那么就可以在这里。

componentWillReceiveProps(nextProps) {
    if (nextProps.count !== this.props.count) {
        this.setState({
            count: nextProps.count
        })
    }
}

1.7 shouldComponentUpdate

这个函数是在组件更新之前调用的,接收新的 props 和新的 state,最终需要返回一个布尔类型的值,会根据返回的值来判断当前组件是否需要更新。
如果需要进行性能优化(防止无关组件渲染),那么就可以在此处进行处理。
如果你修改了组件中的 state,但 shouldComponentUpdate 却返回了 false,那么新的 state 依然会挂载到组件中,只是不会重新渲染,直到下次更新的时候一起渲染上去。

shouldComponentUpdate(nextProps, nextState) {
    // 如果返回true,那就是需要更新。如果返回了false,则组件不会进行更新。
}

1.8 componentWillUpdate

这个函数是在组件将要更新之前调用,此时 shouldComponentUpdate 已经返回了true。
切记,在这里不能调用 setState,因为 setState 会造成组件更新,最终将造成死循环。

1.9 componentDidUpdate

这个函数是在组件更新之后调用,这个时候组件已经执行过了render 方法。
切记,在这里不能调用 setState,因为 setState 会造成组件更新,最终将造成死循环。

1.10 componentWillUnmount

componentWillUnmount 是在组件将要卸载时执行的,如果在 componentDidMount 中绑定了原生事件,那么就需要在这里进行解绑。

componentDidMount() {
    window.addEventListener('scroll', this.scroll)
}
componentWillUnmount() {
    window.removeEventListener('scroll', this.scroll)
}
scroll() {}

在React 16之后,由于现有的 fiber 架构带来的异步渲染,导致了原有的部分生命周期不再适用,componentWillReceiveProps、componentWillMount、componentWillUpdate 三个生命周期将在 React17 移除。

1.13 新的生命周期钩子

React v16.3 之后增加了 getDerivedStateFromProps 和 getSnapshotBeforeUpdate 两个新的生命周期来代替上文中说的 componentWillReceiveProps、componentWillMount、componentWillUpdate 三个生命周期。

image_1dqf1im83181amhr4r9r2u1419p.png-135.1kB

1.14 static getDerivedStateFromProps(props, state)

getDerivedStateFromProps 会在每次组件重新渲染的时候被调用,包括组件初始化的时候。
在每次获取新的 props 或 state 之后,可以是从父组件那里获取到新的 props,也可以是组件自身的 state 变化,都会触发这个方法。
这个方法需要返回一个对象,这个对象就是新的 state;也可以返回一个 null,意思是组件不需要更新state。

class App extends React.Component {
    state = {
        count: 0
    }
    // 每次点击都会对 count 增加1
    handleClick = () => {
        this.setState({
            count: this.state.count + 1
        })
    }
    render() {
        return (
            <>
                <span onClick={this.handleClick}>+</span>
                <Counter count={this.state.count}/>
            </>
        );
    }
}
class Counter extends React.Component {
    state = {
        count: 0
    }
    // 每次 App 执行 handleClick 的时候都会触发
    static getDerivedStateFromProps(props, state) {
        return {
            count: props.count
        }
    }
    render() {
        return <h1>{this.props.count}</h1>
    }
}

网上很多文章有些误导,似乎 getDerivedStateFromProps 和 componentWillReceiveProps 有什么 py 交易,实际上两者触发机制完全不同。
也不能将 getDerivedStateFromProps 单纯看做是 componentWillReceiveProps,毕竟组件自身修改 state 也会触发 getDerivedStateFromProps,这样 getDerivedStateFromProps 的第一个参数 props 就依然是原来的 props,实际上返回值没变。
将上面的例子改造后可以看得比较明白:

class Counter extends React.Component {
    state = {
        count: undefined
    }
    handleClick = () => {
        this.setState({
            count: this.state.count - 1
        })
    }
    static getDerivedStateFromProps(props, state) {
        return {
            count: props.count
        }
    }
    render() {
        return (
            <>
                <h1>{this.state.count}</h1>
                <span onClick={this.handleClick}>-</span>
            </>
        )
    }
}

在点击 Counter 里面的 - 之后,只是触发了自身的 state 变化,因此 getDerivedStateFromProps 返回的 props 依旧是原来的值,最终 state 没变化,组件也没有重新渲染。
由于 componentWillReceiveProps 在 17版本会被移除,处理这种情况最好的方式还是将 Counter 设置为受控组件,只负责展示,不应该有自己的状态。
所以,这个减号应当从 Counter 组件中移出,放到 App 组件中。

class App extends React.Component {
    state = {
        count: 0
    }
    // 每次点击都会对 count 增加1
    handlePlus = () => {
        this.setState({
            count: this.state.count + 1
        })
    }
    handleMinus = () => {
        this.setState({
            count: this.state.count -1 1
        })
    }
    render() {
        return (
            <>
                <span onClick={this.handlePlus}>+</span>
                <Counter count={this.state.count}/>
                <span onClick={this.handleMinus}>-</span>
            </>
        );
    }
}
function Counter(props) {
    return <h1>{this.props.count}</h1>
}

1.15 getSnapshotBeforeUpdate(prevProps, prevState)

getSnapshotBeforeUpdate 会在组件更新之后,执行完 render 方法,但是 DOM 尚未渲染之时被调用。它将会返回一个值或者 null,这个值作为 componentDidUpdate 的第三个参数。
因此 getSnapshotBeforeUpdate 可以覆盖原有的 componentWillUpdate 的所有用法。

class Counter extends React.Component {
    state = {
        count: undefined
    }

    static getDerivedStateFromProps(props, state) {
        return {
            count: props.count
        }
    }

    getSnapshotBeforeUpdate(prevProps, prevState) {
        console.log('will Update');
        return null;
    }

    componentDidUpdate(prevProps, prevState, snapValue) {
        console.log(snapValue)
        console.log('did Update')
    }
    render() {
        console.log('render')
        return <h1 onClick={this.handleClick}>{this.state.count}</h1>
    }
}

上述例子打印的结果为:render、will Update、null、did Update。

2. PureComponent

除了 Component 之外,React 还提供了 PureComponent 这个方法,可以在一定程度上优化性能。PureComponent 只能用于类组件,如果想要在函数组件中使用,那么请用 memo 来代替。

class App extends React.PureComponent {
    render() {
        return <h1>{this.props.count}</h1>
    }
}
const App = React.memo((props) => {
    return <h1>{props.count}</h1>
})

PureComponent 的作用主要是对组件更新前后的 props 和 state 进行浅比较,如果不相等,那么就会继续渲染组件,否则组件将不会渲染。
实际上,PureComponent 只是对 shouldComponentUpdate 进行了一次封装。基于 shouldComponentUpdate 也可以实现类似的功能。

const size = (obj = {}) => Object.keys(obj).length;
const shadowEqual = (objA = {}, objB = {}) => {
    if (objA === objB) return false;
    
    if (size(objA) !== size(objB)) return true;
    
    let isEqual = true;
    (Object.keys(objA) || []).forEach(key => {
        if (objA[key] !== objB[key]) {
            isEqual = false;
        }
    })
    return isEqual;
}
class App extends React.Component {
    shouldComponentUpdate(nextProps, nextState) {
        if (
            shadowEqual(nextProps, this.props) &&
            shadowEqual(nextState, this.state)
        ) {
            return false;
        }
        return true;
    }
    render() {
        return <h1>{this.props.count}</h1>
    }
}

3. ref

React 提供了 ref 这个属性,用于获取到组件的引用。不能在函数组件上使用 ref 属性,因为它们没有实例。
ref 可以挂载到组件上也可以挂载到 DOM 元素上。当挂载到组件上的时候,ref 表示对组件实例的引用;当挂载到 DOM 元素上时表示具体的 DOM 元素节点。
由于 ref 是在 render 里面挂载的,因此一般需要在 render 执行之后才能调用。

class Counter extends React.Component {
    render() {
        return <h1>{this.props.count}</h1>
    }
}
class App extends React.Component {
    componentDidMount() {
        console.log(this.refs.plus);
        console.log(this.refs.counter);
    }
    render() {
        return (
            <>
                <h1 ref="plus">+</h1>
                <Counter ref="counter" count={0} />
                <h1>-</h1>
            </>
        )
    }
}

打印出来的结果如下:

image_1dqf3tvudfn01f5m1qrvp7a16am9.png-58.2kB

ref 除了可以接收一个字符串,还可以接收一个函数,这个函数会返回当前组件的引用,和字符串效果一致。

class App extends React.Component {
    componentDidMount() {
        console.log(this.plus);
        console.log(this.counter);
    }
    render() {
        return (
            <>
                <h1 ref={plus => this.plus = plus}>+</h1>
                <Counter ref={counter => this.counter = counter} count={0} />
                <h1>-</h1>
            </>
        )
    }
}

如果想要在组件渲染完成之后,通过 ref 获取到真实的 DOM 该怎么办?
react-dom 提供了一个 findDOMNode 的方法,接收一个组件的引用作为参数,返回真实的 DOM。

class App extends React.Component {
    componentDidMount() {
        console.log(this.plus);
        console.log(ReactDOM.findDOMNode(this.counter));
    }
    render() {
        return (
            <>
                <h1 ref={plus => this.plus = plus}>+</h1>
                <Counter ref={counter => this.counter = counter} count={0} />
                <h1>-</h1>
            </>
        )
    }
}

渲染的结果如下,可以看到打印出来的是 Counter 渲染的真实 DOM。

image_1dqf47a8hl912o41l3j1od7852m.png-3.6kB

4. 受控组件和非受控组件

前面在讲 getDerivedStateFromProps 的时候有提到过,尽量将子组件设置为受控组件,以减少依赖 getDerivedStateFromProps 的可能性。
那么什么是受控组件呢?
从字面意思上来理解,就是受控制的组件。与之相对的,则是不受控制的组件,即非受控组件。
受控组件的概念常用于表单组件中,一般是指使用真实 DOM 来保存表单的数据。

class NameForm extends React.Component {
  constructor(props) {
    super(props);
  }

  handleSubmit = (event) => {
    alert('A name was submitted: ' + this.input.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" ref={(input) => this.input = input} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

上述例子中,通过访问 DOM 的形式来获取到了 input 输入框的值,由于这个组件自身没有状态,input 的值都是输入的时候挂载到 DOM 上面的,因此称之为受控组件。
非受控组件则是指使用 state 来保存 input 的值,通过 onChange 来修改 state 的值,从而改变 input 的值。

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
        value: ''
    }
  }

  handleChange = (event) => {
      this.setState({
          value: event.target.value
      })
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" value={this.state.value} onChange={this.handleChange} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

在 Vue 和 Angular 等其他前端框架当中,它们提供了双向绑定的特性,其实这个特性在 input 当中利用 state + onChange 就已经实现了。

组件通信

前面我们介绍了 React 的一些用法,可以看到组件化让代码复用变得更加方便,但也带来了一个问题,那就是组件之间有数据共享该怎么做?
比如现在有个筛选组件和列表组件,当在筛选组件中选择一些筛选信息之后,列表组件需要根据这些筛选信息展示筛选过后的数据。
这样就需要把筛选组件中选择的筛选信息传给列表组件,也就涉及到了组件通信。
尤其是在组件嵌套深入、业务复杂的应用中,组件之间通信比较多。这引发了一个令 React 开发者头疼的问题,就是在 React 中如何做状态管理?
接下来这三篇文章将会详细介绍 React 中的状态管理,这篇起到抛砖引玉的作用,后面两篇将会深入到 Redux 和 Mobx 的原理来带领大家搞定 React 状态管理。

在我们开发中,涉及到的组件通信一般有下面这几种:

  1. 父组件向子(孙)组件传值
  2. 子(孙)组件向父组件传值
  3. 兄弟组件之间传值

父子组件通信

父子组件之间传值是最简单的一种。父组件向子组件传值,只需要把这个值通过 props 传给子组件就行了。这个值可以是任何类型的,原始类型、引用类型都可以当 props 传给子组件。

function Parent() {
    return <Child name="child" />
}

function Child(props) {
    return <h1>{props.name}</h1>
}

除了父组件向子组件传值,还有子组件向父组件传值,那么该怎么传呢?常见的场景是在 Modal 或者 Switch 组件中,父组件需要知道这些组件内部的开关状态。
这里涉及到两种方法,一种是通过一个 发布-订阅 模式来传值。在父组件做订阅,在子组件中合适的时机发布,这样就可以进行传值。

// 一个简单的发布订阅
let PubSub = {
    callbackLists: {},
    trigger(eventName, data) {
        let callbackList = this.callbackLists[eventName]
        if (!this.callbackList) {
            return
        }
        for (let i = 0; i < this.callbackList.length; i++) {
            this.callbackList[i](data)
        }
    },
    on(eventName, callback) {
        if (!this.callbackLists[eventName]) {
            this.callbackLists[eventName] = []
        }
        this.callbackLists[eventName].push(callback)
    }
}
class Parent extends Component {
    state = {
        status: 'close'
    }
    componentDidMount() {
        PubSub.on('onOpenChange', (status) => {
            this.setState({
                status: status ? "open" : "close"
            })
        })
    }
    render() {
        return (
            <>
                <Child />
                <span>{this.state.status}</span>
            </>
        )
    }
}

class Child extends Component {
    state = {
        open: false
    }
    
    changeStatus = () => {
        this.setState({
            open: !this.state.open
        }, () => {
            PubSub.trigger('onOpenChange', this.state.open);
        });
    }
    
    render() {
        <div>
            <button onClick={this.changeStatus}></button>
        </div>
    }
}

当然这只是一种非官方的做法,这种方法很容易造成组件通信混乱,因为没办法很清楚地看出来组件依赖的订阅方和发布方在哪里。
除了这种方法,还有一种更常用的做法,即把函数当做 props 传给子组件,子组件在适当的时候调用这个函数,将父组件需要的值当做参数传给这个函数。

class Parent extends Component {
    constructor() {
        super();
        this.state = {
            status: 'close'
        }
    }
    onOpenChange = (status) => {
        this.setState({
            status: status ? "open" : "close"
        })
    }
    render() {
        return (
            <>
                <Child onOpenChange={this.onOpenChange} />
                <span>{this.state.status}</span>
            </>
        )
    }
}

class Child extends Component {
    constructor(props) {
        super(props);
        this.state = {
            open: false
        }
    }
    
    changeStatus = () => {
        this.setState({
            open: !this.state.open
        }, () => {
            this.props.onOpenChange(this.state.open);
        });
    }
    
    render() {
        return (
            <div>
                <button onClick={this.changeStatus}>点击切换状态</button>
            </div>
        )
    }
}

在点击按钮的时候,会执行父组件传来的 onOpenChange 函数,父组件从 onOpenChange 拿到状态之后更新到 state 中,进而触发重新渲染。

切换.gif-81.5kB

父孙组件通信

看到这里,相信你也知道父组件怎么和孙组件通信了吧?父组件向孙组件传值,可以将 props 一层层传下去。

function Parent() {
    return <Child name="parent" />
}
function Child(props) {
    return <GrandSon name={props.name} />
}
function GrandSon(props) {
    return <h1>{props.name}</h1>
}

而孙组件向父组件传值也是同样的道理,将需要执行的函数一层层传下去。在孙组件中执行这个函数,将需要的数据当做参数传过去。

function Parent() {
    const onChange = (e) => {
        console.log(e.target.value);
    }
    return <Child onChange={onChange} />
}
function Child(props) {
    return <GrandSon onChange={props.onChange} />
}
function GrandSon(props) {
    return <input onChange={props.onChange} />
}

上面的例子只是嵌套两层就已经比较麻烦了,但是如果组件嵌套过深,就要一层层传给目标子组件,这样会非常麻烦。
因此,为了解决这个问题,React 还提供了一个 Context API,可以实现跨组件通信。
通过 createContext 创建一个 Context 对象,这个对象提供了 Provider 和 Consumer 两个属性。
每一个 Context 对象都会返回一个 Provider 组件,它允许消费组件来订阅 context 的变化。
Provider 会提供一个 value 属性,将这个 value 传给消费组件。
每一个 Context 对象也会返回一个 Consumer 组件,它用来响应 context 的变化,这是一个 render props 的模式。

const context = createContext('');
function Parent() {
    return (
        <context.Provider value="parent">
            <Child />
        </context.Provider>
    )
}
function Child(props) {
    return <GrandSon />
}
function GrandSon(props) {
    return (
        <context.Consumer>
            {
                (value) => {
                     return <h1>{value}</h1>
                }
            }
        </context.Consumer>
    )
}

关于 Context 更详细的用法,在后面的《详解 React16 新特性》一文中会进行介绍。

兄弟组件通信

兄弟组件通信稍微麻烦一点儿,除了上面的 发布-订阅 模式,如果想通信,就必须借助共同的父组件这个“中间桥梁”。
以一开始说的筛选表单和列表的通信为例,列表需要获取到筛选的信息,这里就要借助到两者的共同父组件。
首先要了解一件事,那就是组件之间的通信,一般都是伴随着响应某种用户的操作。比如用户选择了所有的筛选项后点击查询按钮,或者在用户每次修改选项的时候去主动查询。
我们就以用户点击查询后,将筛选信息传给列表,列表根据筛选信息调用接口进行查询为例:

class Filters extends Component {
    constructor(props) {
        super(props);
        this.state = {
            filters: {}
        }
    }
    // 点击查询
    onSearch() {
        this.props.onSearch && this.props.onSearch(this.state.filter);
    }
    // 用户选择新的筛选项
    onfilterChange(filter) {
        this.setState({
            filters: {
                ...this.state.filters,
                ...filter
            }
        })
    }
    render() {}
}
class List extends Component {
    constructor(props) {
        super(props);
        this.state = {
            list: []
        }
    }
    // 组件接收新的 filter 的时候更新 state
    componentWillReceiveProps(nextProps) {
        if (nextProps.filters !== this.props.filters) {  
            this.search(nextProps.filters);
        }
    }
    // 查询新的列表
    search(filters) {
        fetch('/getList', {
            filters
        })
        .then((data) => data.json())
        .then((data) => this.setState({
            list: data.list
        }))
    }
    render() {
        return this.state.list.map(item => {
            return <div>{item.content}</div>
        })
    }
}

首先创建两个组件,组件 Filters 对外暴露方法 onSearch,当用户点击查询的时候就会调用外部传来的 onSearch 方法。
组件 List 接收一个 filters 对象作为 props,当传来新的 filters 的时候就会去调用接口去查询新的列表数据,然后渲染出来。
所以兄弟组件之间的通信关键在于父组件,父组件就像粘合的胶水一样,会将两个组件粘合起来。

class App extends Component {
    constructor(props) {
        super(props)
        this.state = {
            filters: {}
        }
    }
    onSearch = (filters) => {
        this.setState({
            filters
        })
    }
    render() {
        return (
            <div className="main">
                <Filters onSearch={onSearch} />
                <List filters={filters} />
            </div>
        )
    }
}

当然,这个组件的设计并不好,比如 List 组件应该是纯展示组件,只需要接收需要展示的数据就够了,调用接口筛选应该放到 App 组件中去做。这个例子只是为了让大家比较清晰地知道兄弟组件之间是如何传值的。
如果组件嵌套比较多,需要通信的组件也比较多的话,你会发现最终的组件数据流动变成了这样:

image_1duacmd2h1dl014ei13778ue1na7m.png-39kB

上面的数据流动方向是从最顶层组件向下面的组件,形成了一个“单向数据流”。

6. 推荐阅读

  1. 图解 React
  2. 精益 React 学习指南 (Lean React)
  3. 我们为什么需要 React?