React骚操作之技巧篇

4,444 阅读6分钟

想了半小时,实在不知道开头应该怎么写,就随便写点内容填充一下,以显得不那么空虚。

话说回来,react是很多前端切图仔的吃饭家伙,更深入的了解其特性有助于我们更好的吃饭。前端时间我在重新学习react的时候,发现了一些比较有趣的行为,总结一波看看有没有引起大家的共鸣。

(全文仅为笔者个人理解和总结,不喜勿喷)

setState

中间状态

有些时候,我们需要在组件更新之后(componentDidUpdate钩子)才能确定要不要更新状态!那么如果在componentDidUpdate钩子里面更新状态,那么中间状态会被显示出来吗,也就是页面会闪一下吗?

    class State extends PureComponent {
        state = { count: 0 };

        componentDidMount() {
            this.setState({
                count: 1
            });
        }

        componentDidUpdate() {
            console.log(document.getElementById('count').innerHTML);
            if (this.state.count < 50) {
                this.setState({ state: this.state.count + 1 });
            }
        }

        render() {
            return (
                <span id="count">{ this.state.count }</span>
            );
        }
    }

运行之后可以看到结果,页面直接显示最终状态50,中间状态0-49并没有闪现页面(页面没有出现闪的情况),但是控制台里面按顺序打印了1-49。

得出结论:在componentDidUpdate、componentDidMount等commit阶段的钩子里面进行setState的操作,会改变dom树,浏览器只渲染最终状态,中间状态不会被展示

猜想原因是js引擎和渲染引擎共用同一线程,中间的一波setState操作实际上是js引擎占据着线程,渲染引擎无法渲染,所以中间状态并未显出来(类似js大计算量任务造成浏览器假死状态)。

同步还是异步setState

看下面例子

    class State extends PureComponent {
        state = { count: 0 }

        componentDidMount() {
            this.setState({ count: this.state.count + 1 });
            console.log(this.state.count);        // 0
            setTimeout(() => {
                this.setState({ count: this.state.count + 1 });
                console.log(this.state.count);    // 2
            }, 0);
        }

        render() {
            return (
                <span id="count">{ this.state.count }</span>
            );
        }
    }

看到最终输出结果发现,第一个setState并没有立即执行,而第二个setState却立即执行了。

首先,第一个setState表现为异步,是React内部做的一个批量更新的优化。当一个react交互事件(如onClick等react合成事件)触发时,内部会将一个flag:isBatchingUpdates置为true,此时React会将收集到的setState存起来,然后统一更新。

其次,根据以上说法,第二个setState是在setTimeout里面调用的,并没有触发react交互事件,所以并不会进行批量更新,也就表现为同步的形式。

当然,如果要在setTimeout等异步回调中进行批量更新的操作,可以使用ReactDom.unstable_batchedUpdates方法:

    class State extends PureComponent {
        state = { count: 0 }

        componentDidMount() {
            setTimeout(() => {
                ReactDom.unstable_batchedUpdates(() => {
                    this.setState({ count: this.state.count + 1 });
                    console.log(this.state.count);        // 0
                    this.setState({ count: this.state.count + 1 });
                    console.log(this.state.count);    // 0
                });
                console.log(this.state.count);        // 2
            }, 0);
        }

        render() {
            return (
                <span id="count">{ this.state.count }</span>
            );
        }
    }

连续更新

我们现在知道交互事件回调函数中的setState是异步的,那么在连续更新的时候就会产生一些问题:

    class State extends PureComponent {
        state = { count: 0 }

        handleClick = () => {
            this.setState({ count: this.state.count + 1 });
            this.updateSomething();
        }

        updateSomething = () => {
            this.setState({ count: this.state.count + 1 });
        }

        render() {
            return (
                <React.Fragment>
                    <span id="count">{ this.state.count }</span>
                    { /* 实际上点击按钮一次,count += 1 */ }
                    <button onClick={ this.handleClick }>click me</button>
                </React.Fragment>
            );
        }
    }

我们预期的是:点击click me按钮,count += 2。

实际结果:count += 1.

很不幸的是我们setState的时候,setState是异步的,多个setState会被合并掉,最终相当于只发起了一次setState。

像这种需要依赖于上一次setState值的操作,可以使用另一种方法的setState方式:

    class State extends PureComponent {
        state = { count: 0 }

        handleClick = () => {
            this.setState(state => ({ count: state.count + 1 }));
            this.updateSomething();
        }

        updateSomething = () => {
            this.setState(state => ({ count: state.count + 1 }));
        }

        render() {
            return (
                <React.Fragment>
                    <span id="count">{ this.state.count }</span>
                    { /* 实际上点击按钮一次,count += 2 */ }
                    <button onClick={ this.handleClick }>click me</button>
                </React.Fragment>
            );
        }
    }

key

key是帮助React判断哪个元素发生了改变、新增和移除的辅助标识,但是我发现我们基本只在list渲染中使用key,如下:

    render() {
        return (
            <Fragment>{
                list.map(item => (
                    <ListItem key={ item.id } { ...item } />
                ))
            }</Fragment>
        );
    }

实际上,在处理组件派生状态的时候,使用key可以减少我们状态重置操作:

父组件

    class Key extends Component {
        state = {
            activeItem: {
                id: 0,
                name: 'listItem0'
            }
        }

        render() {
            const { activeItem = {} } = this.state;
            
            return (
                <div>
                    <ItemDetail
                        key={ activeItem.id }
                        itemData={ activeItem }
                    />
                </div>
            );
        }
    }

    export default Key;

子组件

    class ItemDetail extends PureComponent {

        constructor(props) {
            super(props);

            this.state = {
                name: `${ props.itemData.name }_child` // 派生状态,这是不好的行为,应避免
            }
        }

        fetchItemDetail = () => {
            // ajax request
        }

        componentDidMount() {
            this.fetchItemDetail();
        }

        render() {
            return (
                <div>
                    { /* item info */ }
                </div>
            );
        }
    }

上面的例子中,每当activeItem变化时,父组件给ItemDetail子组件传入的key会变化,则ItemDetail组件会被React删掉,然后重新创建,故子组件内部会自动生成新的派生状态和获取新的item详情。

此方法的优点是不必在子组件内部监听props的变化以获取新的数据和生成新的派生状态,缺点是key每次渲染都需要删除原有的组件,再生成新的组件

假如子组件内部的状态比较复杂,且diff成本较高,可以考虑使用以上方法重置状态。

Ref

一直以来我们都被教导:使用ref来调用组件实例的方法会破坏组建的生命周期,我们应该避免使用这个特性,但是某些情况下是非获取不可的。这里总结几种情况下获取组件实例的方法:

获取高阶组件的实例

我们可以使用React提供的forwardRed方法将ref当成一个普通的props属性进行透传

    import React, { forwardRef } from 'react';
    import Child from './Child';

    const ForwardRefChild = forwardRef((props, ref) => {
        return (
            <Child
                { ...props }
                forwardedRef={ ref }
            />
        );
    });

父组件

    class Ref extends PureComponent {
        getRef = instance => {
            console.log(instance);
        }

        render() {
            return (
                <ForwardRefChild ref={ this.getRef }></ForwardRefChild>
            );
        }
    }

子组件

    class Child extends PureComponent {
        render() {
            const { forwardedRef } = this.props;

            return (
                <div ref={ forwardedRef }></div>
            );
        }
    }

获取react-redux的connect装饰器装饰过的组件实例

connect函数有四个入参,其中第四个入参为可选参数,类型为对象,对象属性包括

- pure: boolean

- withRef: boolean,标识是否保存一个对被被包含的组件实例的引用,改引用通过getWrappedInstance方法获得

没错,只要通过设置withRef为true即可通过getWrappedInstance方法获得实例

    class Ref extends PureComponent {

        getRef = decoratedInstance => {
            console.log(decoratedInstance.getWrappedInstance()); // real instance
        }

        render() {
            retrun (
                <ChildDecoratedByConnect ref={ this.getRef }/>
            );
        }
    }

获取props.children的ref

以上两种场景都是中规中矩的操作(it means that 不够骚),假如我们想要在组件中获取props.children的属性,那么就有点麻烦了,直接贴代码:

    class Child extends PureComponent {
        getRef = node => {
            console.log(node); // 开心又快乐
        }

        getChild = () => {
            const { children } = this.props;
            const element = React.Children.toArray(children)[0]; // 获取children第一个子元素

            // clone这个子元素
            return React.cloneElement(element, {
                ref: this.getRef // 重写ref
            });
        }

        render() {
            return (
                <div>{ this.getChild() }</div>
            );
        }
    }

我们没有直接渲染props.children,而是通过React.clone复制了一个副本,并且修改其ref的值,达到我们的目的(这里只渲染了一个子元素,多个子元素请自己实现)。

上面的例子中,存在一个问题,由于Child组件复写了props.children的ref,会导致props.children原有的ref无法使用。为了解决这个问题,我们可以结合上文提到的forwardRef,将实例传给原有的ref

    class Child extends PureComponent {

        getRef = node => {
            const { forwardedRef } = this.props;

            // function类型ref
            if ((typeof forwardedRef).toUpperCase() === 'FUNCTION') {
                forwardedRef(node);
            } else {
                // 使用React.createRef生成的ref对象
                forwardedRef.current = node;
            }

            console.log(node); // 开心又快乐
        }

        getChild = () => {
            const { children } = this.props;
            const element = React.Children.toArray(children)[0];

            return React.cloneElement(element, {
                ref: this.getRef
            });
        }

        render() {
            return (
                <div>{ this.getChild() }</div>
            );
        }
    }

在子组件的getRef函数中,将实例赋值给原ref对象或者作为参数调用ref函数,即可使得外层的函数也可以获得此实例。

结尾

以上内容,如有错漏,欢迎指正。

好了,到点下班!!!

@Author: PaperCrane