React的Hook经验汇总

2021-07-12 fishedee 前端

0 概述

React Hook 经验汇总,Hook已经出来一段时间了,看大众的评论,基本上是毁誉参半。这个机制的确能更容易地复用逻辑,但是也很容易写错误的代码。

我觉得最好的Hook文档还是在官网

1 useState

代码在这里

1.1 基础

import { useState } from 'react';

//Counter的使用,将setCounter传入新值
export default function CounterPage() {
    let [counter, setCounter] = useState(0);
    let incCounter = function () {
        setCounter(counter + 1);
    };
    let decCounter = function () {
        setCounter(counter - 1);
    };
    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <button onClick={incCounter}>加</button>
            <button onClick={decCounter}>减</button>
        </div>
    );
}

使用useState创建一个状态,和一个设置状态的方法,这段简单

1.2 useState的修改参数为闭包

import { useState } from 'react';

//setCounter可以传入一个闭包,获取旧值,然后返回新值
export default function Counter2Page() {
    let [counter, setCounter] = useState(0);
    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount + 1);
                }}
            >
                加
            </button>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount - 1);
                }}
            >
                减
            </button>
        </div>
    );
}

setCounter不仅可以传递一个最新值,还可以传递一个闭包,用来获取最新值以后返回一个修改值。

1.3 useState的初始参数为闭包

import { useState } from 'react';

//useState的初始值可以是一个闭包,用来计算初始值
export default function Couter3Page() {
    let [counter, setCounter] = useState(() => {
        return 1 + 1;
    });
    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount + 1);
                }}
            >
                加
            </button>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount - 1);
                }}
            >
                减
            </button>
        </div>
    );
}

useState的初始化参数可以是一个闭包,用来初始化复杂的初始化操作。一般是依赖于props数据时的初始化过程。

1.4 useReducer

import { useReducer } from 'react';
import { useState } from 'react';

type CounterAction = {
    type: 'inc' | 'dec';
};
export default function CounterPage() {
    let [counter, dispatch] = useReducer(function (
        state: number,
        action: CounterAction,
    ) {
        if (action.type == 'inc') {
            return state + 1;
        } else if (action.type == 'dec') {
            return state - 1;
        }
        return state;
    },
    0);
    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <button
                onClick={() => {
                    dispatch({ type: 'inc' });
                }}
            >
                加
            </button>
            <button
                onClick={() => {
                    dispatch({ type: 'dec' });
                }}
            >
                减
            </button>
        </div>
    );
}

useReducer,就是useState的进阶版了,这样能让修改数据的地方更加聚合在一起,也没啥好说的。用过的redux的都觉得简单

2 useEffect

2.1 基础

代码在这里

import { useEffect } from 'react';
import { useState } from 'react';

export default function EffectPage() {
    let [open, setOpen] = useState(false);
    let [counter, setCounter] = useState(0);

    //useEffect没有参数的时候,代表每次都会在render后重新触发
    //effect的语义,render是一个UI=render(state)的纯函数,那么effect就是纯函数以外的副作用
    //即使触发的状态按钮,也会使得useEffect的运行
    useEffect(() => {
        console.log('reset document title!');
        document.title = '计算器' + counter;
    });
    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount + 1);
                }}
            >
                加
            </button>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount - 1);
                }}
            >
                减
            </button>
            <button
                onClick={() => {
                    setOpen((prevOpen) => !prevOpen);
                }}
            >
                状态:{open ? '打开' : '关闭'}
            </button>
        </div>
    );
}

useEffect的语义就是每一次render以后,都会触发一次的副作用。代码中,就实现了,每次render以后,就重新设置一下title,这个想法还是相当直观的。

2.2 cleanup

import { useEffect } from 'react';
import { useState } from 'react';

export default function EffectPage() {
    let [counter, setCounter] = useState<number>(0);

    //因为effect每次在render都会重新触发
    useEffect(() => {
        console.log('add interval ');
        var i = 0;
        document.title = '计算器' + counter;
        let interval = setInterval(() => {
            document.title = '计算器' + counter + '!'.repeat(i);
            i++;
        }, 500);

        //每次新的effect替换旧的时候,就会调用旧effect的清理函数来清理
        return () => {
            console.log('clean interval');
            clearInterval(interval);
        };
    });
    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount + 1);
                }}
            >
                加
            </button>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount - 1);
                }}
            >
                减
            </button>
        </div>
    );
}

每次render都会触发一次effect的方法,显然,如果我们在effect放入定时器的话,每次render都会产生很多定时器。所以,effect的第一个参数是闭包,这个参数的返回值可以是一个闭包。每次新的副作用执行时,先执行旧的副作用的清理函数,这个清理函数称为cleanup。

2.3 依赖

import { useEffect } from 'react';
import { useState } from 'react';

export default function EffectPage() {
    let [open, setOpen] = useState(false);
    let [counter, setCounter] = useState(0);

    //effect可以带有一个dependence参数,只有参数里面的引用没变时,才会触发副作用
    //这个时候,触发状态按钮,不会再次触发effect了
    //只有在触发加减按钮的时候,才会触发effect
    useEffect(() => {
        console.log('reset document title!');
        document.title = '计算器' + counter;
    }, [counter]);

    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount + 1);
                }}
            >
                加
            </button>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount - 1);
                }}
            >
                减
            </button>
            <button
                onClick={() => {
                    setOpen((prevOpen) => !prevOpen);
                }}
            >
                状态:{open ? '打开' : '关闭'}
            </button>
        </div>
    );
}

effect作为副作用的另外一个问题是,如果effect的闭包需要的内容没变,还需要每次执行一次副作用,似乎就太浪费了。例如,在代码中,副作用就是每次counter变化的时候,更新一下document的标题。但是,如果我们设置open按钮,都需要更新一下document的标题,就会造成浪费了(当副作用是ajax操作的时候就会更加明显)。

注意,React仅仅是对数据进行浅比较而已,如果依赖数据的深层发生变化,但是引用不变的话,副作用依然不会执行

因此,useEffect有第二个参数,手动填写effect执行副作用时,必须是在哪些数据已经变动的情况下。例如,我们填写的依赖是counter,那么就是只有首次render,以及counter变化的时候,才会执行副作用。因此,我们能看到,当我们更改open状态的时候,不会用reset document title的输出。

2.4 空依赖

import { useEffect } from 'react';
import { useState } from 'react';

export default function EffectPage() {
    let [open, setOpen] = useState(false);
    let [counter, setCounter] = useState(0);

    //effect的dependence为空的时候,整个组件只会触发一次
    useEffect(() => {
        console.log('reset document title!');
        document.title = '计算器' + counter;
    }, []);

    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount + 1);
                }}
            >
                加
            </button>
            <button
                onClick={() => {
                    setCounter((prevCount) => prevCount - 1);
                }}
            >
                减
            </button>
            <button
                onClick={() => {
                    setOpen((prevOpen) => !prevOpen);
                }}
            >
                状态:{open ? '打开' : '关闭'}
            </button>
        </div>
    );
}

一种特殊的情况是,useEffect的第二个参数是一个空数组,代表依赖的数据为空。那么副作用仅会在首次render的时候触发一次,之后都不会触发。

2.5 useLayoutEffect

useLayoutEffect,与useEffect是相似的,不过它是与render操作是同步执行而已,这点在官网写得很清楚

3 useCallback

代码在这里

3.1 没有callback缓存

import { memo, useState } from 'react';

type Props = {
    name: string;
    onClick: () => void;
};

//即使用了memo,但是依然是每次两个Button都重绘,因为每次onClick的实例都不同
let ChildButton = memo((props: Props) => {
    console.log('Child Button Render');
    return <button onClick={props.onClick}>{props.name}</button>;
});
export default function CounterPage() {
    let [counter, setCounter] = useState(0);
    let [counter2, setCounter2] = useState(0);
    let inc = function () {
        setCounter((prevState) => {
            return prevState + 1;
        });
    };
    let inc2 = function () {
        setCounter2((prevState) => {
            return prevState + 1;
        });
    };
    console.log('Top Render');
    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <ChildButton onClick={inc} name="计数器1" />
            <div>当前的counter2为:{counter2}</div>
            <ChildButton onClick={inc2} name="计数器2" />
        </div>
    );
}

我们做了一个实验,对子组件加入memo的包装,那么当props不变的时候,组件就不会重新render。显然,这是一种class组件中的componentShouldUpdate的机制而已。

但是,在实验中可以看到,每次看起来inc与inc2都没变,但是ChildButton依然会触发重新的Render。这是不对的,因为inc与inc2是在闭包中创建的,所以它们在每次render都会产生新的引用,它们是新的实例,因此会导致memo失效。

3.2 有callback缓存

import { memo, useCallback, useState } from 'react';

type Props = {
    name: string;
    onClick: () => void;
};

let ChildButton = memo((props: Props) => {
    console.log('Child Button Render');
    return <button onClick={props.onClick}>{props.name}</button>;
});
export default function CounterPage() {
    let [counter, setCounter] = useState(0);
    let [counter2, setCounter2] = useState(0);
    //使用了useCallback以后,仅在首次的时候使用闭包,而后都会缓存这个闭包,从而避免不必要的渲染
    //但是每次render,闭包依然生成,只是不用它而已
    //依赖参数像useEffect一样的用法
    let inc = useCallback(function () {
        setCounter((prevState) => {
            return prevState + 1;
        });
    }, []);
    let inc2 = useCallback(function () {
        setCounter2((prevState) => {
            return prevState + 1;
        });
    }, []);
    console.log('Top Render');
    return (
        <div>
            <div>当前的counter为:{counter}</div>
            <ChildButton onClick={inc} name="计数器1" />
            <div>当前的counter2为:{counter2}</div>
            <ChildButton onClick={inc2} name="计数器2" />
        </div>
    );
}

因此,对于Hook中的函数组件,每次创建闭包都是新实例的问题,Hook提供了useCallback组件来解决这个问题。它就像redux里面的selector的做法,每次先检查一下useCallback的第二个参数,第二个参数依赖没变的时候,才去获取新闭包,否则一直沿用上次的旧闭包。

在每次render的时候依然会创建新闭包,只是这个新闭包没有被useCallback返回出来而已。另外一方面,对于依赖的比较依然是浅拷贝的方式。

4 useMemo

4.1 没有数据缓存

import { memo, useState, useCallback } from 'react';

type Props = {
    name: string;
    onClick: () => void;
};

let ChildButton = memo((props: Props) => {
    console.log('Child Button Render');
    return <button onClick={props.onClick}>{props.name}</button>;
});
export default function CounterPage() {
    let [mode, setMode] = useState('fish');
    let [counter, setCounter] = useState(0);
    let [counter2, setCounter2] = useState(0);
    let inc = useCallback(function () {
        setCounter((prevState) => {
            return prevState + 1;
        });
    }, []);
    let inc2 = useCallback(function () {
        setCounter2((prevState) => {
            return prevState + 1;
        });
    }, []);
    let total = (function () {
        console.log('expensive sum');
        let result = 0;
        for (var i = 0; i != 10; i++) {
            result += counter + counter2;
        }
        return result;
    })();
    console.log('Top Render');
    return (
        <div>
            <div>当前的mode为:{mode}</div>
            <button onClick={() => setMode(mode + '!')}>更新mode</button>
            <div>当前的counter为:{counter}</div>
            <ChildButton onClick={inc} name="计数器1" />
            <div>当前的counter2为:{counter2}</div>
            <ChildButton onClick={inc2} name="计数器2" />
            <div>{'总数为:' + total}</div>
        </div>
    );
}

我们加入一个新功能,每次将所有的Counter值加起来,为了增大消耗,我们让循环执行10次。可以看到,每次render的时候,这个统计操作都会执行,这会造成额外的资源消耗。

4.2 有数据缓存

import { memo, useState, useCallback, useMemo } from 'react';

type Props = {
    name: string;
    onClick: () => void;
};

let ChildButton = memo((props: Props) => {
    console.log('Child Button Render');
    return <button onClick={props.onClick}>{props.name}</button>;
});
export default function CounterPage() {
    let [mode, setMode] = useState('fish');
    let [counter, setCounter] = useState(0);
    let [counter2, setCounter2] = useState(0);
    let inc = useCallback(function () {
        setCounter((prevState) => {
            return prevState + 1;
        });
    }, []);
    let inc2 = useCallback(function () {
        setCounter2((prevState) => {
            return prevState + 1;
        });
    }, []);
    let total = useMemo(
        function () {
            console.log('expensive sum');
            let result = 0;
            for (var i = 0; i != 10; i++) {
                result += counter + counter2;
            }
            return result;
        },
        [counter, counter2],
    );
    console.log('Top Render');
    return (
        <div>
            <div>当前的mode为:{mode}</div>
            <button onClick={() => setMode(mode + '!')}>更新mode</button>
            <div>当前的counter为:{counter}</div>
            <ChildButton onClick={inc} name="计数器1" />
            <div>当前的counter2为:{counter2}</div>
            <ChildButton onClick={inc2} name="计数器2" />
            <div>{'总数为:' + total}</div>
        </div>
    );
}

Hook提供了useMemo来解决这个问题,它就像redux中的selector这样操作。

5 useContext

代码在这里

import { createContext, useContext, useState, memo } from 'react';

const ModeContext = createContext({ mode: 'fish' });

let GrandSon = memo(function () {
    console.log('grand son render');
    let data = useContext(ModeContext);
    return (
        <div>
            <div>我是孙组件</div>
            <div>mode为:{data.mode}</div>
        </div>
    );
});
let Son = memo(function () {
    console.log('son render');
    return (
        <div>
            <div>我是子组件</div>
            <GrandSon />
        </div>
    );
});
export default function () {
    console.log('top render');
    let [mode, setMode] = useState('fish');
    return (
        <div>
            <h3>当前mode为:{mode}</h3>
            <button
                onClick={() => {
                    setMode((mode) => {
                        if (mode == 'fish') {
                            return 'cat';
                        } else {
                            return 'fish';
                        }
                    });
                }}
            >
                切换mode
            </button>
            <ModeContext.Provider value={{ mode: mode }}>
                <Son />
            </ModeContext.Provider>
        </div>
    );
}

Hook提供了更轻松地使用Context的方式,而且当context变化时,会自动通知context的消费者自动render。同时,中间的Son组件不会进行render,性能有更好的提升。

6 useRef

代码在这里

6.1 组件的引用

import { useEffect } from 'react';
import { useRef } from 'react';
import { memo, useState, useCallback } from 'react';

export default function CounterPage() {
    let myRef = useRef<HTMLDivElement>(null);
    useEffect(function () {
        myRef.current?.setAttribute(
            'style',
            'color:red; border: 1px solid blue;',
        );
    }, []);
    return (
        <div ref={myRef}>
            <div>你好</div>
        </div>
    );
}

Hook中提供了useRef来获取组件的引用,从而调用它的DOM操作,这点还是很简单的。要注意的是,引用实例在每次render都是不变的,变化的是引用的current属性而已

6.2 数据的引用

import { useEffect } from 'react';
import { useRef } from 'react';
import { memo, useState, useCallback } from 'react';

export default function CounterPage() {
    //refresh组件用来强行刷新的
    const [refresh, setRefresh] = useState(false);

    let myRef = useRef({ counter: 0 });
    let inc = useCallback(() => {
        myRef.current.counter++;
        setRefresh((prevState) => !prevState);
    }, []);

    let dec = useCallback(() => {
        myRef.current.counter--;
        setRefresh((prevState) => !prevState);
    }, []);
    return (
        <div>
            <div>计数器为:{myRef.current.counter}</div>
            <button onClick={inc}>+</button>
            <button onClick={dec}>-</button>
        </div>
    );
}

我们可以利用useRef在每次render的不变性,来创建一个数据的最新值的存放点,而不是数据的快照值。

7 最佳实践

代码在这里

useEffect与useState,我们可以组合创建自己的Hook。这种方法,给与了我们更好地复用业务代码逻辑的方式。另外一方面,我们也能看到性能的提升。

受限于原来的store对class组件的绑定方法,Redux对Flex的改进之一是,全局只有一个Store。这样view对store的绑定只需要用一个@connect就能实现了,没有繁琐的代码。但是,带来的问题是,每次刷新都会将所有@connect的地方都通知一遍,然后用shouldComponentUpdate来过滤是否需要更新。而且,每次更新都是从父组件一直传递到子组件的更新,而不能实现仅仅的两个没有@connect的子组件的更新。

Hook在这一点上给与我们新的想法。

7.1 不变key的store引用

type Emiter<T> = (data: T) => void;

class EventEmiter<T> {
    private globalEmiterId = 0;
    private handlersMap: Map<number, Emiter<T>> = new Map();

    private data: T;

    constructor(data: T) {
        this.data = data;
    }

    subscribe(handler: Emiter<T>): number {
        let emiterId = this.globalEmiterId;
        this.globalEmiterId++;
        this.handlersMap.set(emiterId, handler);
        return emiterId;
    }

    unsubscribe(emiterId: number) {
        this.handlersMap.delete(emiterId);
    }

    set(data: T) {
        this.data = data;
        this.handlersMap.forEach((e) => {
            e(data);
        });
    }

    get(): T {
        return this.data;
    }
}

export default EventEmiter;

我们首先创建一个EventEmiter,所有store都需要通知view的方式,这个类还是相当简单的

import { useEffect } from 'react';
import { useState } from 'react';
import EventEmiter from './EventEmiter';

class CounterStore extends EventEmiter<number> {
    constructor() {
        super(0);
    }
    public inc = () => {
        this.set(this.get() + 1);
    };

    public dec = () => {
        this.set(this.get() - 1);
    };
}
let store = new CounterStore();

export default function useCounter() {
    let [counter, setCounter] = useState(store.get());

    //加入[]依赖符,仅在首次render的时候进行subscribe操作
    useEffect(function () {
        let emiterId = store.subscribe((data) => {
            //setCounter是稳定的,每次render返回的都是同一个setCounter
            //如果setCounter传入的参数不变,那么就不会触发render,注意这种不变仅仅是浅比较的不变
            setCounter(data);
        });
        return () => {
            store.unsubscribe(emiterId);
        };
    }, []);

    return [counter, store.inc, store.dec] as const;
}

然后我们利用EventEmiter创建了一个自己的CounterStore,然后创建一个自己的Hook组件,它在组件刚实例化时,绑定store。在store变化的时候,更新view的状态。

import { memo } from 'react';
import useCounter from './useCounter';

type Props = {
    name: string;
};

// memo可以使得只有props发生变化的时候才重新render,注意不包括state
export default memo(function (props: Props) {
    let [counter, inc, dec] = useCounter();
    console.log('Child Render');
    return (
        <div>
            <h2>{props.name}</h2>
            <div>{'当前值为:' + counter}</div>
            <button onClick={inc}>加1</button>
            <button onClick={dec}>减1</button>
        </div>
    );
});

然后我们创建一个Button组件

import { useState } from 'react';
import ChildButton from './Button';
export default function Parent() {
    let [open, setOpen] = useState(false);
    let [buttons, setButtons] = useState<number[]>([]);
    console.log('Parent Render');
    return (
        <div>
            <div>
                <button
                    key="add"
                    onClick={() => {
                        setButtons((prevState) => [
                            ...prevState,
                            prevState.length + 1,
                        ]);
                    }}
                >
                    添加一个
                </button>
                <button
                    key="clear"
                    onClick={() => {
                        setButtons([]);
                    }}
                >
                    清除
                </button>
                <button
                    key="other"
                    onClick={() => {
                        setOpen((prevOpen) => !prevOpen);
                    }}
                >
                    状态:{open ? '打开' : '关闭'}
                </button>
            </div>
            {buttons.map((id) => {
                return <ChildButton key={id} name={'按钮' + id} />;
            })}
        </div>
    );
}

最后,我们创建了一个页面

我们在控制台可以看到,每次一个按钮点击的时候,另外一个按钮的组件数据也会自动变化。同时,父组件不需要重新render,这比Redux糟糕的从父组件到子组件渲染到底要好得多。要达到相同的效果,用MobX也能做。

7.2 变化key的store引用

import { useEffect } from 'react';
import { useState } from 'react';
import EventEmiter from '@/pages/myUse/EventEmiter';
import { useCallback } from 'react';

export type CounterEnum = 'fish' | 'cat';
type CounterObject = {
    [key in CounterEnum]: number;
};
class CounterStore extends EventEmiter<CounterObject> {
    constructor() {
        super({
            fish: 0,
            cat: 0,
        });
    }

    public inc = (target: CounterEnum) => {
        let data = this.get();
        //注意要返回新的对象,不能在原来的对象上面改
        data = {
            ...data,
            [target]: data[target] + 1,
        };
        this.set(data);
    };

    public dec = (target: CounterEnum) => {
        let data = this.get();
        data = {
            ...data,
            [target]: data[target] - 1,
        };
        this.set(data);
    };
}
let store = new CounterStore();

export default function useCounter(target: CounterEnum) {
    let [counter, setCounter] = useState(store.get()[target]);

    //加入[]依赖符,仅在首次render的时候进行subscribe操作
    useEffect(
        function () {
            let emiterId = store.subscribe((data) => {
                setCounter(data[target]);
            });
            //切换以后,要set一次
            setCounter(store.get()[target]);
            return () => {
                store.unsubscribe(emiterId);
            };
        },
        [target],
    );

    //useCallback可以缓存每次不同的callback
    let inc = useCallback(
        function () {
            store.inc(target);
        },
        [target],
    );

    let dec = useCallback(
        function () {
            store.dec(target);
        },
        [target],
    );

    return [counter, inc, dec] as const;
}

我们使用另外一种CounterStore,这种CounterStore,允许用户获取不同mode的Counter,也允许用户中途切换另外一种mode的Counter

import { memo, useState } from 'react';
import useCounter, { CounterEnum } from './useCounter';

type Props = {
    name: string;
    mode: CounterEnum;
};

// memo可以使得只有props发生变化的时候才重新render
export default memo(function (props: Props) {
    let [mode, setMode] = useState(props.mode);
    let [counter, inc, dec] = useCounter(mode);
    console.log('Child Render');
    return (
        <div>
            <h2>{props.name}</h2>
            <div>{'当前mode为:' + mode + ',当前值为:' + counter}</div>
            <button onClick={inc}>加1</button>
            <button onClick={dec}>减1</button>
            <button
                onClick={() => {
                    if (mode == 'fish') {
                        setMode('cat');
                    } else {
                        setMode('fish');
                    }
                }}
            >
                {'切换mode'}
            </button>
        </div>
    );
});

我们创建另外一种Button,允许用户切换mode

import { useState } from 'react';
import ChildButton from './Button';
export default function Parent() {
    let [open, setOpen] = useState(false);
    let [buttons, setButtons] = useState<number[]>([]);
    console.log('Parent Render');
    return (
        <div>
            <div>
                <button
                    key="add"
                    onClick={() => {
                        setButtons((prevState) => [
                            ...prevState,
                            prevState.length + 1,
                        ]);
                    }}
                >
                    添加一个
                </button>
                <button
                    key="clear"
                    onClick={() => {
                        setButtons([]);
                    }}
                >
                    清除
                </button>
                <button
                    key="other"
                    onClick={() => {
                        setOpen((prevOpen) => !prevOpen);
                    }}
                >
                    状态:{open ? '打开' : '关闭'}
                </button>
            </div>
            {buttons.map((id) => {
                return (
                    <ChildButton key={id} name={'按钮' + id} mode={'fish'} />
                );
            })}
        </div>
    );
}

最后,创建主页面代码

我们从测试中可以看到,只对mode为cat的计数器自增,那么只会触发2次render,而不是4次render,Hook依然提供了更好的性能。

8 反模式

代码在这里

8.1 不稳定的Hook

import { useState } from 'react';

//所有的Hook都不应该放在条件语句或者for循环中,它们都应该在代码前面首先声明使用
//因为Hook的实现依赖于声明的顺序
//Rendered more hooks than during the previous render.
export default function () {
    let mode = 'nothing';
    let [counter, setCounter] = useState(0);
    if (counter > 0) {
        let [innerMode, setMode] = useState('cat');
        mode = innerMode;
    }

    return (
        <div>
            <div>当前计数为:{counter}</div>
            <button
                onClick={() => {
                    setCounter((prevState) => prevState + 1);
                }}
            >
                +
            </button>
            {counter > 0 ? <div>mode</div> : null}
        </div>
    );
}

切勿在for循环或者if语句中使用Hook,Hook应该是稳定不变的,这样会让React找不到Hook对应的哪个组件。

8.2 使用Snapshot数据的闭包

在闭包里面使用Snapshot数据,这是Class组件的用户切换到Hook组件中最常遇到的问题。

8.2.1 问题

import { useEffect } from 'react';
import { useState } from 'react';

//在任何的callback或者effect中,都不应该使用snapshot的数据,snapshot的数据只能用于渲染
//这不是一个bug,也不是一个设计问题
//因为callback里面的就不能依赖于之前的state来执行业务逻辑
//站在redux的角度,callback只能发送action,在action里面执行业务逻辑,最后由action触发store的变化,引起view的变化
export default function () {
    let [counter, setCounter] = useState(0);

    useEffect(function () {
        //我们期望每秒将计数器递增1,但实际是不行的
        //因为effect是在一个闭包,每次都会捕捉counter这个局部变量,而这个变量仅在组件初始化时捕捉了,初值为0,因此定时器每次都是设置为1
        let interval = setInterval(function () {
            setCounter(counter + 1);
        }, 1000);
        return () => {
            clearInterval(interval);
        };
    }, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
    return (
        <div>
            <div>当前计数为:{counter}</div>
        </div>
    );
}

我们在useEffect中使用一个定时器,我们期望组件启动以后,计数器会自增

但实验证明,计数器在自增到1以后就不变了。这是因为闭包使用了空数组依赖,useEffect仅使用了首次创建的闭包,而这个闭包而是仅仅捕捉首次的counter实例,这个实例的值就是0。因此,在往后的每次interval里面,都是将0递增为1,然后传入到setCounter里面。

在使用Hook组件的时候,一个要转换的思维是,在Hook组件里面,每次render,数据,闭包,每次都是重新生成出来的。如果你用了依赖来限制闭包次数,那么这个闭包只会捕捉到某一次render的数据快照值,而不是数据的最新值。

8.2.2 糟糕的修正

import { useCallback, useRef } from 'react';
import { useEffect } from 'react';
import { useState } from 'react';

//第一种改进方法,这种方法是糟糕的,虽然能用
export default function () {
    let [refresh, setRefresh] = useState(false);
    let counterRef = useRef(0);

    useEffect(function () {
        let interval = setInterval(function () {
            //ref在每次render都是不变的,变化的仅仅是ref.current
            //因此,我们能在ref.current中获取最新值,而不是快照值
            //这种方法相当的Hack,不推荐这样用,也会打破view到store的单向流
            counterRef.current = counterRef.current + 1;
            //强行刷新
            setRefresh((prevState) => !prevState);
        }, 1000);
        return () => {
            clearInterval(interval);
        };
    }, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
    return (
        <div>
            <div>当前计数为:{counterRef.current}</div>
        </div>
    );
}

一个糟糕的修正是,强行使用useRef来存储状态,因为useRef每次返回的数据实例都不是不变的,变化的是ref.current,然后用另外一个状态来强行刷新页面。这种方法依然是企图用数据的最新值的思维来写代码,写出来的代码不优雅,也会破坏state状态的比较。

8.2.3 不太好的修正

import { useEffect } from 'react';
import { useState } from 'react';

//第二种改进方法,勉强过得去
//将业务捕捉在setCounter里面,这样每次都能获取到最新值,但是对于跨状态的业务组件会出问题
export default function () {
    let [counter, setCounter] = useState(0);

    useEffect(function () {
        let interval = setInterval(function () {
            setCounter((prevState) => {
                return prevState + 1;
            });
        }, 1000);
        return () => {
            clearInterval(interval);
        };
    }, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
    return (
        <div>
            <div>当前计数为:{counter}</div>
        </div>
    );
}

一个不太好的修正是,在setCounter里面传入参数,这样每次取得最新值,才执行。但是

  • 将数据的修改逻辑散落在view层的各个位置,维护性并不好
  • 当一次修改是需要多个状态的最新值的时候,这种方法就会无能为力。

8.2.4 好的修正

import { useEffect, useReducer } from 'react';
import { useState } from 'react';

//第三种改进方法,这种不错,对于只是内部使用的状态,不需要跨组件共享的状态最好
//将业务捕捉在setCounter里面,
export default function () {
    let [counter, dispatch] = useReducer((state: number, action: string) => {
        if (action == 'inc') {
            return state + 1;
        }
        return state;
    }, 0);

    useEffect(function () {
        let interval = setInterval(function () {
            dispatch('inc');
        }, 1000);
        return () => {
            clearInterval(interval);
        };
    }, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
    return (
        <div>
            <div>当前计数为:{counter}</div>
        </div>
    );
}

这种方法,用useReducer来封装数据的修改逻辑,这种方法更好,但依然逃不掉一个问题是:

  • 当一次修改是需要多个状态的最新值的时候,这种方法就会无能为力。

8.2.4 极好的修正

import { useEffect, useState } from 'react';
import EventEmiter from './EventEmiter';

class CounterStore extends EventEmiter<number> {
    constructor() {
        super(0);
    }
    public inc = () => {
        this.set(this.get() + 1);
    };

    public dec = () => {
        this.set(this.get() - 1);
    };
}
let store = new CounterStore();

function useCounter() {
    let [counter, setCounter] = useState(store.get());

    useEffect(function () {
        let emiterId = store.subscribe((data) => {
            setCounter(data);
        });
        return () => {
            store.unsubscribe(emiterId);
        };
    }, []);

    return [counter, store.inc, store.dec] as const;
}

//第四种方法,这种方法是最好的,允许在跨组件中共享状态,而且清晰明了,同时避免在view层写业务逻辑
export default function () {
    let [counter, inc, dec] = useCounter();

    useEffect(function () {
        let interval = setInterval(function () {
            inc();
        }, 1000);
        return () => {
            clearInterval(interval);
        };
    }, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
    return (
        <div>
            <div>当前计数为:{counter}</div>
        </div>
    );
}

这种方法是最好的,当数据移到一个固定的store存放,而这个store里面就已经存放好了数据的最新值。注意这个方法里面,store数据与组件数据是分离的,组件数据是通过store的subscribe来同步数据的。因此,我们可以在副作用代码里面,安全地获取store数据,并且能安全地确信store里面的数据都是最新值,而不是快照值,这样我们就能在副作用代码里面用回我们熟悉的命令式的编程方法。

而且,这种方法,对于需要跨store的最新值数据来更新,依然毫无压力。另外,这个方法也提供了跨组件通信的方法。

9 useImperativeHandle

Class Component有实例的说法,它是用类创建出来的,每个实例都有自身的成员变量,和成员方法。但是Function Component没有实例,它仅仅就是一个函数。如果我们获取了一个Fuction Component的ref,我们会得到什么。我们看一下吧。

代码在这里

9.1 forwardRef


   
import { ChangeEventHandler, LegacyRef, useEffect } from 'react';
import { useRef } from 'react';
import { memo, useState, useCallback, forwardRef } from 'react';

type MyInputProps = {
    value: string | undefined;
    onChange: ChangeEventHandler<HTMLInputElement>;
};
const MyInput = forwardRef<HTMLInputElement, MyInputProps>((props, ref) => {
    //将ref直接透传到input组件上面
    return (
        <div>
            <h1>我是Input</h1>
            <input
                ref={ref}
                style={{ border: '1px solid black' }}
                value={props.value}
                onChange={props.onChange}
            />
        </div>
    );
});
export default function Sample1() {
    const [state, setState] = useState('');
    const inputRef = useRef<HTMLInputElement>(null);
    return (
        <div>
            <div>你好</div>
            <MyInput
                ref={inputRef}
                value={state}
                onChange={(e) => {
                    setState(e.target.value);
                }}
            />
            <button
                onClick={() => {
                    inputRef.current?.focus();
                }}
            >
                获取焦点
            </button>
            <input value="测试2" />
        </div>
    );
}

要点如下:

  • 一个函数组件能传入ref的话,需要用forwardRef包装起来。
  • 因为ref是在多次render中都不变的实例,所以,它能直接传入到子组件的ref中,实现透传ref的目的。

9.2 useImperativeHandle

import {
    ChangeEventHandler,
    LegacyRef,
    useEffect,
    useImperativeHandle,
} from 'react';
import { useRef } from 'react';
import { memo, useState, useCallback, forwardRef } from 'react';

type MyInputProps = {
    value: string | undefined;
    onChange: ChangeEventHandler<HTMLInputElement>;
};

type MyInputRef = {
    myFocus: () => void;
};
const MyInput = forwardRef<MyInputRef, MyInputProps>((props, ref) => {
    const myRef = useRef<HTMLInputElement>(null);

    //创建组件自身的ref,赋予更多的灵活性
    useImperativeHandle(ref, () => ({
        myFocus: () => {
            myRef.current?.focus();
        },
    }));
    return (
        <div>
            <h1>我是Input2</h1>
            <input
                ref={myRef}
                style={{ border: '1px solid black' }}
                value={props.value}
                onChange={props.onChange}
            />
        </div>
    );
});
export default function Sample1() {
    const [state, setState] = useState('');
    const inputRef = useRef<MyInputRef>(null);
    return (
        <div>
            <div>你好</div>
            <MyInput
                ref={inputRef}
                value={state}
                onChange={(e) => {
                    setState(e.target.value);
                }}
            />
            <button
                onClick={() => {
                    inputRef.current?.myFocus();
                }}
            >
                获取焦点2
            </button>
            <input value="测试2" />
        </div>
    );
}

要点如下:

  • 如果我们需要将ref赋值自身的组件的方法,我们可以使用useImperativeHandle。默认情况下,它会在每次render的时候,重新计算ref的方法,并且赋值到ref.current中。
  • 在非默认情况下,我们可以使用第三个参数deps,控制首次component挂载的时候,或者控制在某些state改变的时候,重新计算ref的方法,并且赋值到ref.current中。

10 总结

我觉得Hook总体还是不错的,它给与了复用业务逻辑的另外一种方法。可就是容易写错,快照值与最新值经常混淆,对开发者的要求更高了。最后,记住两点:

  • 不要声明不稳定的Hook顺序
  • 不要在闭包里面使用快照数据

参考资料:

相关文章