useEffect使用指南

本文是阅读A Complete Guide to useEffect之后的个人总结,建议拜读原文

理解hooks工作机制

可以这样说,在使用了useState或是useEffect这样的hooks之后,每次组件在render的时候都生成了一份本次render的state、function、effects,这些与之前或是之后的render里面的内容都是没有关系的。而对于class component来说,state是一种引用的形式。这就造成了二者在一些表现上的不同。

来看下面这样一段代码:

    function Counter() {
        const [count, setCount] = useState(0);

        function handleAlertClick() {
            setTimeout(() => {
            alert('You clicked on: ' + count);
            }, 3000);
        }
        // 多次点击click me按钮,然后点击一下show alert按钮,然后又快速点击多次click me按钮,alert出来的count是点击该按钮时的count还是最新的count??
        // 实验表明,显示的是点击时的按钮,这就意味着handleAlertClick这个函数capture了被点击时的那个count,这也就是说每一轮的count都是不一样的
        return (
            <div>
                <p>You clicked {count} times</p>
                <button onClick={() => setCount(count + 1)}>
                    Click me
                </button>
                <button onClick={handleAlertClick}>
                    Show alert
                </button>
            </div>
        );
    }

再看这样一段代码:

    function Counter() {
        const [count, setCount] = useState(0);

        useEffect(() => {
            setTimeout(() => {
                console.log(count)
            }, 3000)
        })
        // 在3秒内快速点击5次按钮,控制台打出的结果是什么样的?
        // 0 1 2 3 4 5
        return (
            <div>
                <p>You clicked {count} times</p>
                <button onClick={() => setCount(count + 1)}>
                    Click me
                </button>
            </div>
        );
    }

把上述代码改成class component的形式:

    class Example extends React.Component{

        constructor(props) {
            super(props);
                this.state = {
                count: 0,
            }
        }

        componentDidUpdate() {
            setTimeout(() => {
                console.log(this.state.count)
            }, 3000)
        }

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

        // 同样的操作,打印出的结果是 5 5 5 5 5

        render() {
            return (
                <div>
                    <button onClick={this.add}>click me</button>
                </div>
            )
        }
    }

对于class component里面的表现,我们可以通过闭包来改变,之所以如此是因为class component里面的state随着render是发生变化的,而useEffect里面即使使用props.count也不会有问题,因为useEffect里面的所有东西都是每次render独立的

    componentDidUpdate() {
        // 在class component中必须每次把count取出来
        const { count } = this.state;
        setTimeout(() => {
            console.log(count)
        }, 3000)
    }
    function Example(props) {
        useEffect(() => {
            setTimeout(() => {
            console.log(props.counter);
            }, 1000);
        });
        // 在useEffect中不需要先把count从props里面取出来,每次依然是独立的
    }

可以发现,尽管useEffect里面的函数延迟执行了,但是打出的count依然是当时render里面的count,这也说明了其实每次render都是独立的,里面有独立的state、effects、function

    // During first render
    function Counter() {
        const count = 0; // Returned by useState()
        // ...
        <p>You clicked {count} times</p>
        // ...
    }

    // After a click, our function is called again
    function Counter() {
        const count = 1; // Returned by useState()
        // ...
        <p>You clicked {count} times</p>
        // ...
    }

    // After another click, our function is called again
    function Counter() {
        const count = 2; // Returned by useState()
        // ...
        <p>You clicked {count} times</p>
        // ...
    }

下面这段话是精髓:

Inside any particular render, props and state forever stay the same. But if props and state are isolated between renders, so are any values using them (including the event handlers). They also “belong” to a particular render. So even async functions inside an event handler will “see” the same count value.

useEffect的一些注意点

来看官方文档里面关于useEffect清除工作的示例:

    function Example(props) {
        useEffect(() => {
            ChatAPI.subscribeToFriendStatus(props.id,       handleStatusChange);
            return () => {
                ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
            };
        });
    }

如果props从{id: 10}变化为{id: 20}那么react是怎么样来渲染组件、怎么样做清除工作的呢?

按照惯性思维,我们可能觉得应该是先清理之前一次render注册的事件,然后render组件,然后再注册本次render的事件

    React cleans up the effect for {id: 10}.
    React renders UI for {id: 20}.
    React runs the effect for {id: 20}.

但实际上react并不是这样工作的,而是像下面这样,因为react总是在浏览器paint之后再去做effects相关的事情,无论是useEffect还是他返回的函数,而且清理函数也和其他函数一样能够capture当前的props和state,尽管在他执行时已经是新的组件render好了

    React renders UI for {id: 20}.
    The browser paints. We see the UI for {id: 20} on the screen.
    React cleans up the effect for {id: 10}.
    React runs the effect for {id: 20}.

清理函数就像闭包一样直接把他所属的render的props和state消费,然后在需要执行的时候使用这些值

    // First render, props are {id: 10}
    function Example() {
        // ...
        useEffect(
            // Effect from first render
            () => {
                ChatAPI.subscribeToFriendStatus(10, handleStatusChange);
                // Cleanup for effect from first render
                return () => {
                    ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange);
                };
            }
        );
        // ...
        }

    // Next render, props are {id: 20}
    function Example() {
        // ...
        useEffect(
            // Effect from second render
            () => {
                ChatAPI.subscribeToFriendStatus(20, handleStatusChange);
                // Cleanup for effect from second render
                return () => {
                    ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange);
                };
            }
        );
        // ...
    }

忘记lifecycle的观念,拥抱synchronization

在class component里面,lifecycle是我们做一切的基础,但是在使用react-hooks的时候,请忘记lifecycle,尽管useEffect函数很多时候达到了相似的效果

但从根本上来讲,react-hooks的作用是一种同步的作用,同步函数hooks函数内的内容与外部的props以及state,所以才会在每次render之后执行useEffect里面的函数,这时可以获取到当前render结束后的props和state,来保持一种同步

但正是由于useEffect里面的内容在每次render结束后都会执行,可能有时候内部的内容并没有发生变化,这时就会产生冗余的render,这时候就需要引入依赖,由写程序的人来告诉react我这个useEffect依赖了外部的那些参数,只有这些参数发生变化的时候才去执行我里面的函数。

因为react自己不知道什么时候useEffect里面的函数其实没有发生变化。

     useEffect(() => {
        document.title = 'Hello, ' + name;
    }, [name]); // Our deps

上面这段代码相当于告诉react,我这个effect的依赖项是name这个变量,只有当name发生变化的时候才去执行里面的函数

而且这个比较是浅比较,如果state是一个对象,那么对象只要指向不发生变化,那么就不会执行effect里面的函数

譬如:

    function Example() {
        const [count, setCount] = useState({a: 12});

        useEffect(() => {
            console.log('effect');
            return () => {
                console.log('clean')
            }
        }, [count])

        function handleClick() {
            count.a++;
            setCount(count)
        }

        // 点击按钮时发现屏幕显示的值不发生变化,而且effect里面的函数也没有执行,所以进行的是浅比较,这点类似于pureComponent

        return (
            <div>
                <p>You clicked {count.a} times</p>
                <button onClick={handleClick}>
                    Click me
                </button>
            </div>
        );
    }

关于dependency数组

如果强行欺骗react来达到跳过某些渲染之后的effect函数的话,那么可能会出现一些意想不到的后果:

如下代码,我们想模拟一个定时器,在第一次渲染之后挂载,在组件卸载的时候取消这个定时器,那么这好像和把dependency数组设为[]的功能很像,但是如果这样做的话,结果是定时器只加一次。

    function Counter() {
        const [count, setCount] = useState(0);

        useEffect(() => {
            const id = setInterval(() => {
                setCount(count + 1);
            }, 1000);
            // 定时器只加一次的原因在于虽然setInterval函数里面的函数每秒都会执行一次,但是count值始终是初始的0,因为这个函数绑定了第一轮render之后的count值,
            return () => clearInterval(id);
        }, []);

        return <h1>{count}</h1>;
    }

如果写成下面这样的形式的话:

    function Counter() {
        const [count, setCount] = useState(0);

        setInterval(() => {
            setCount(count + 1);
        }, 1000);
        // 造成的后果就是能一直更新count,但是每一轮循环都会执行上面这行代码,定时器越来越多,然后,就卡死啦,而且每个定时器都会执行一遍,那么屏幕上的数字每秒都会在跳,可以试试看
        return <h1>{count}</h1>;
    }

所以通过设置dependency数组来欺骗react达到自己不可告人的目的的话,很容易出现bug,而且还不容易发现,所以还是老老实实的不要骗人

要让计时器正确工作的话,第一种方法是把dependency数组正确设置[count],但这显然不是最好的方法,因为每一轮都会设置计时器,清除计时器。但至少定时器work了。

还有一种方法是利用functional updater,这时候你也可以不用设置dependency

    useEffect(() => {
        const id = setInterval(() => {
            setCount(preCount => preCount + 1);
            // 此时setCount里面的函数的入参是前一次render之后的count值,所以这样的情况下计时器可以work
        }, 1000);
        return () => clearInterval(id);
    }, []);

其他hooks

useContext

使用方法:

    const value = useContext(myContext);

当最近的一个myContext.Provider更新的时候,这个hook就会导致当前组件发生更新

useReducer

    
    function reducer(state, action) {
        switch (action.type) {
            case 'increment':
                return {count: state.count + 1};
            case 'decrement':
                return {count: state.count - 1};
            default:
                throw new Error();
        }
    }

    function Counter() {
        const [state, dispatch] = useReducer(reducer, {count: 100});

        // 如果此处不传入一个initialState: {count: 100}的话,那么默认initialState就是undefined,那么后面的取值就会报错
        return (
            <>
                Count: {state.count}
                <button onClick={() => dispatch({type: 'increment'})}>+</button>
                <button onClick={() => dispatch({type: 'decrement'})}>-</button>
            </>
        );
    }

使用dispatch以后,判断是否重新render是通过Object.is来判断的,每次render之后返回的dispatch其实都是不变的,所以之前定时器的例子最好的解决方案就是利用useReducer来实现:

    function Counter() {
        const [state, dispatch] = useReducer(reducer, initialState);
        const { count, step } = state;

        useEffect(() => {
            const id = setInterval(() => {
            dispatch({ type: 'tick' });
            }, 1000);
            return () => clearInterval(id);
        }, [dispatch]);
        // 现在useEffect不依赖count,依赖的是dispatch,而dispatch在每次render之后都是不变的,所以就不会每次render之后都清除计时器再重新设置计时器
        // 其实这里把dependency数组设为[]也是完全一样的

        return (
            <>
            <h1>{count}</h1>
            <input value={step} onChange={e => {
                dispatch({
                    type: 'step',
                    step: Number(e.target.value)
                });
            }} />
            </>
        );
    }

    const initialState = {
        count: 0,
        step: 1,
    };

    function reducer(state, action) {
        const { count, step } = state;
        if (action.type === 'tick') {
            return { count: count + step, step };
        } else if (action.type === 'step') {
            return { count, step: action.step };
        } else {
            throw new Error();
        }
    }


useCallback

    const memoizedCallback = useCallback(
        () => {
            doSomething(a, b);
        },
        [a, b],
    );
    // 返回的memoizedCallback只有当a、b发生变化时才会变化,可以把这样一个memoizedCallback作为dependency数组的内容给useEffect

我们来看一个useEffect的dependency数组含有函数的情况:

    function Counter() {
        const [count, setCount] = useState(0);
        const [a, setA] = useState(100);

        const fn = useCallback(() => {
            console.log('callback', a)
        }, [a])
        // 可知fn是依赖于a的,只有当a发生变化的时候fn才会变化,否则每轮render的fn都是同一个

        const f1 = () => {
            console.log('f1')
        }
        // 对于f1,每轮循环都有独自的f1,所以相当于一直在变化,如果useEffect依赖于f1的话,每次render之后都会执行

        useEffect(() => {
            console.log('this is effect')
        }, [f1])
        // 当dependency数组里面是f1时,不管更新count还是a,都会执行里面的函数,打印出this is effect
        // 当dependency数组里面是fn时,只有更新a时才会执行该函数
        return (
            <>
                Count: {count}
                <button onClick={() => setCount(count + 1)}>+</button>
                <button onClick={() => setCount(count - 1)}>-</button>
                <br />
                <button onClick={() => setA(a + 1)}>+</button>
                <button onClick={() => setA(a - 1)}>-</button>
            </>
        );
    }

useMemo

    const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

useRef

    const refContainer = useRef(initialValue);

注意:useRef返回相当于一个{current: ...}的plain object,但是和正常这样每轮render之后直接显式创建的区别在于,每轮render之后的useRef返回的plain object都是同一个,只是里面的current发生变化

而且,当里面的current发生变化的时候并不会引起render

补充

dependency数组里面写函数作为dependency的情景:

    function SearchResults() {
        const [query, setQuery] = useState('react');

        // Imagine this function is also long
        function getFetchUrl() {
            return 'https://hn.algolia.com/api/v1/search?query=' + query;
        }
        // 对于这样一个组件,如果我们改变了query,按理来说应该要重新拉取数据,但是这种写法里面就无法实现,除非在useEffect的dependency数组里面添加一个query,但是这样是很不明显的,因为useEffect里面的函数只写了一个fetchData,并没有看到query的身影,所以query很容易被忽略,而一旦忽略就会带来bug,所以简单的解决方法就是把fetchData这个函数作为dependency写进useEffect的dependency数组,但是这样也会带来问题,就是每次render之后,无论这次render是否改变了query,都会导致fetchData这个函数发生变化(因为每次render之后函数都是不同的),都会重新拉取数据,这是我们不想要的结果

        // Imagine this function is also long
        async function fetchData() {
            const result = await axios(getFetchUrl());
            setData(result.data);
        }

        useEffect(() => {
            fetchData();
        }, []);

        // ...
    }

第一次改进,把函数直接写进dependency数组里面:

    function SearchResults() {
        // 🔴 Re-triggers all effects on every render
        function getFetchUrl(query) {
            return 'https://hn.algolia.com/api/v1/search?query=' + query;
        }

        useEffect(() => {
            const url = getFetchUrl('react');
            // ... Fetch data and do something ...
        }, [getFetchUrl]); // 🚧 Deps are correct but they change too often

        useEffect(() => {
            const url = getFetchUrl('redux');
            // ... Fetch data and do something ...
        }, [getFetchUrl]); // 🚧 Deps are correct but they change too often

        // ...
    }

上面这种写法的问题就是useEffect里面的函数调用过于频繁,再次利用useCallback进行改造:

    function SearchResults() {
        const [query, setQuery] = useState('react');

        // ✅ Preserves identity until query changes
        const getFetchUrl = useCallback(() => {
            return 'https://hn.algolia.com/api/v1/search?query=' + query;
        }, [query]);  // ✅ Callback deps are OK
        // 只有当query发生变化的时候getFetchUrl才会变化
        useEffect(() => {
            const url = getFetchUrl();
            // ... Fetch data and do something ...
        }, [getFetchUrl]); // ✅ Effect deps are OK

        // ...
    }

useCallback本质上是添加了一层依赖检查。它以另一种方式解决了问题 - 我们使函数本身只在需要的时候才改变,而不是去掉对函数的依赖

实际上,函数在effect里面也是一种数据流,而在class component中则不是

关于竞态

    function Article({ id }) {
        const [article, setArticle] = useState(null);

        useEffect(() => {
            let didCancel = false;
            // 利用didCancel这个变量来解决竞态问题,如果本次render之后的请求到下次render之后才返回,那么这次render之后的didCancel以及在清理函数里面被设置为true了,就不会继续执行
            async function fetchData() {
                const article = await API.fetchArticle(id);
                if (!didCancel) {
                    setArticle(article);
                }
            }

            fetchData();

            return () => {
                didCancel = true;
            };
        }, [id]);

        // ...
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,295评论 6 512
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,928评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,682评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,209评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,237评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,965评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,586评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,487评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,016评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,136评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,271评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,948评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,619评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,139评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,252评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,598评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,267评论 2 358

推荐阅读更多精彩内容

  • 1. 引言 工具型文章要跳读,而文学经典就要反复研读。如果说 React 0.14 版本带来的各种生命周期可以类比...
    黄子毅阅读 2,082评论 0 10
  • 40、React 什么是React?React 是一个用于构建用户界面的框架(采用的是MVC模式):集中处理VIE...
    萌妹撒阅读 1,020评论 0 1
  • 作为一个合格的开发者,不要只满足于编写了可以运行的代码。而要了解代码背后的工作原理;不要只满足于自己的程序...
    六个周阅读 8,451评论 1 33
  • [译]React函数组件和类组件的差异 原文: https://overreacted.io/how-are-fu...
    JieJess阅读 3,288评论 1 11
  • 起步 安装官方脚手架: npm install -g create-react-app 创建项目: create-...
    Twoold阅读 1,239评论 0 0