本篇为基础篇,简单谈论下
16.8
引入的一些原生Hook
,useEffect
的注意事项以及useCallback
的闭包陷阱问题,如何使用useCallback + React.memo + useRef
解决性能优化问题。如内容存在争议,欢迎指正。
Why
Hook
作为 React16.8
新增特性,它出现的目的是为了可以让我们在不编写 class
的情况下使用 state
以及其他的 React
特性。首先可以思考一下在没有引入 Hook
之前,React
是如何定义一个组件?
通常是定义一个类组件,在类组件中,通过 constructor
初始化一些 state
,在不同的生命周期中处理自己的逻辑,最后通过 render
来渲染。最初写的类组件中,逻辑往往比较简单,但随着业务的增多,类组件会变得越来越复杂,各种生命周期中包含着大量的逻辑代码,并且对于类组件很难拆分一些逻辑,状态的复用则需要通过高阶组件实现(类似于在 redux
中的 connect
)。其次对于类组件而言,class
作为声明的关键字并且其是 ES6
中新增的特性,需要具有前导知识,还必须去搞清楚 this
的各种指向,对于初学者而言并不友好。
再来看看函数组件,在函数组件中,每次调用都会产生临时的变量不利于记录组件内部的状态,并且重新渲染时,函数都会被执行,里面的一些发送网络请求等逻辑都会被再次执行。其没有生命周期以及 this
作为技术壁垒是函数组件最显著的特点。
因此在没有 Hook
概念之前我们通常都是编写 class
组件,因为函数无法记录组件内部的状态。引入了 Hook
的概念,目的就是为了将两者的优点集合起来,在**不编写 class 组件的情况下也可以使用 state
以及其他 React 特性。**有了 Hook
之后基本上可以代替之前只用 class
组件的所有地方,这里也不再例举两者的区别,总而言之,Hook
可以让我们的代码更加精简,不在考虑 this
等问题,可以在函数组件中使用 React
特性。
What
下面将分别梳理下官方常见的 Hook
。
useState
useState 应该算是最常用的 Hook
之一,前面提到了,函数每次 render
都会重新调用一次函数,对应的内部的状态将会被重置,因此需要 Hook
来记录其内部的状态值。
useState
作为一个 hook
函数,需要传入一个参数,并且会返回一个数组,一般是通过数组解构获取里面的数据。需要传入的参数是作为初始化 state
的值,返回的数组中第一个参数包含当前状态的值以及设置状态值的函数。
由于 useState
使用简单,这里便不做举例说明(想了解 useState
的实现原理可以看之前的 mirco-react hook)。
由于是第一个
Hook
函数,简单说下对于Hook
有三个规则:
- 只能在函数的最外层使用
Hook
,不能在循环、条件判断、子函数中调用。- 只能在
React
的函数组件中调用Hook
,不要在其他JS
函数中调用(除非自定义Hook
的时候)。- 规定
Hook
函数需要以use
作为函数开头命名,这样才能被React
识别。
useEffect
首先从一个简单的 class
组件入手。
export class App extends PureComponent {
constructor() {
super();
this.state = {
counter: 100,
};
}
componentDidMount() {
$axios.get("localhost:7777").then(() => {});
}
render() {
const { counter } = this.state;
return (
<div>
<h2>{counter}</h2>
<button
onClick={(e) =>
this.setState({
counter: counter + 1,
})
}
>
Add
</button>
</div>
);
}
}
上面的代码中,可以看到在类组件中,对于网络请求的操作可以放在对应的生命周期中进行。反观函数组件,如果直接把一些副作用的代码放在了函数体内部,会导致在每次重新 render
的时候都会去执行这些副作用。
const App = memo(() => {
const [count, setCount] = useState(200);
$axios.get("localhost:7777").then(() => {});
return (
<div>
<h2>{count}</h2>
<button
onClick={(e) => setCount(count + 1)}
>
Add
</button>
</div>
);
});
在函数组件主体内(React
的渲染阶段),对于改变 DOM
、添加订阅、设置定时器和执行包含副作用的操作都是不被允许的,因为会产生一些奇怪的 Bug
并且破坏 UI
的一致性。因此有了 useEffect 的 Hook
,来接收一个包含命令式且可能有副作用的函数,通过这个 Hook
函数,可以告诉 React
需要在渲染后执行哪些操作。
useEffect
参数需要传入一个 callback
,在 React
执行完 DOM
操作后,就会回调这个函数。无论是第一次渲染之后,还是每次更新之后都会调用这个 callback
。上述情况会导致出现问题:某些代码只需要执行一次,类似于 componentDidMount 和 componentWillUnmount 中完成的事情(网络请求、订阅或者取消订阅等)。
针对这问题,需要注意 useEffect
的第二个参数,一个数组,在哪些 state
发生变化时,才会重新执行。
useEffect(() => {
console.log(count);
}, [count]);
useEffect(() => {
// do somethings.
}, []);
如上代码,当 count
发现变化时,才会重新执行 callback
,并且引入了一个特殊的用法,第二个参数传入一个空数组,此时不会依赖于其他的 state
,只有在第一次调用函数组件,渲染完成之后执行,因此类似于类组件的 componentDidMount
。主要注意的是如果传入了空数组,effect
内部的 props
和 state
会一直持有其初始值。官方给出了一些更好的方式去避免频繁的重复调用 effect
。
- Is it safe to omit functions from the list of dependencies?
- What can I do if my effect dependencies change too often?
const Example = ({ someProp }) => {
function doSomething() {
console.log(someProp);
}
useEffect(() => {
doSomething();
}, []);
};
在 effect
外部的函数使用了 someProp
,但是没有依赖于 someProp
这个状态,这便对应了一个难点:需要记住 effect 外部函数使用了哪些 props 和 state。
修改之后代码,如下:
const Example = ({ someProp }) => {
useEffect(() => {
function doSomething() {
console.log(someProp);
}
doSomething();
}, [someProp]);
};
注意: 官方提到了一些注意的东西,可以去 useEffect 的文档中查看详情
对于 useEffect
还有一点需要注意的,在 class
组件中,某些副作用的代码,我们需要在 componentWillUnmount
中进行清楚,useEffect
中也可以模拟 componentWillUnmount
操作,回到之前 useEffect
的第一个回调函数,其本身可以有一个返回值,这个返回值是另一个回调函数,可以看下其函数签名.
type EffectCallback = () =>
| void
| (() => void | undefined);
这是 effect
可选的清除机制,每个 effect
都可以返回一个清除函数,这样就可以把订阅和取消订阅的逻辑放在一起。
useEffect(() => {
const unsubscribe = store.subscribe(() => {});
return () => {
unsubscribe();
};
});
为防止内存泄漏,清除函数会在组件卸载前执行。另外,如果组件多次渲染(通常如此),则在执行下一个 effect
之前,对上一个 effect
进行清除。
useContext
在之前的开发中(没有使用 Hook
之前),要在组件中使用共享的 Context
有两种方式:
- 类组件通过
类名.contextType = MyContext
方式,在类中获取context
。 - 多个
Context
或者在函数式组件中通过MyContext.Consumer
方式共享context
。
引入了 useContext
之后,只需要 const value = useContext(MyContext);
既可以获取到 context
。函数需要接受一个 context
对象(React.createContext 的返回值)。
当组件上层最近的
<MyContext.Provider>
更新时,该Hook
会触发重新渲染,并使用最新传递给MyContext provider
的context value
值。
const MyContext = React.createContext(defaultValue);
只有当组件所处的树中没有匹配到 Provider
时,其 defaultValue
参数才会生效。
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee",
},
dark: {
foreground: "#ffffff",
background: "#222222",
},
};
const ThemeContext = React.createContext(
themes.light
);
function App() {
return (
<ThemeContext.Provider value={themes.dark}>
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const theme = useContext(ThemeContext);
return (
<button
style={{
background: theme.background,
color: theme.foreground,
}}
>
Context
</button>
);
}
useReducer
useReducer
是 useState
的一种替代方案,在某些场景下,如果 state
处理逻辑比较复杂,可以通过 useReducer
来对其进行拆分,或者这次修改的 state
需要依赖之前的 state
。不过写法和 redux
十分的相似。
const initialState = { count: 0 };
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,
initialState
);
return (
<>
Count: {state.count}
<button
onClick={() =>
dispatch({ type: "decrement" })
}
>
-
</button>
<button
onClick={() =>
dispatch({ type: "increment" })
}
>
+
</button>
</>
);
}
useCallback
对于 useCallback
钩子,官方文档说明的很简洁。只是说明把内联回调函数以及依赖项数组作为参数传入 useCallback
,它会返回该回调函数的 memoized
版本,该回调函数仅在某个依赖项改变时才会更新,useCallback(fn, deps)
相当于 useMemo(() => fn, deps)
。
从官方的定义上来看,useCallback
的目的是不希望子组件多次渲染,并不是为了函数进行缓存,从而达到性能的优化,那么如何进行性能优化呢?
当需要将一个函数传递给子组件时,最好使用 useCallback
进行优化,将优化后的函数传递给子函数。在先思考 useCallback
之前,先认识下 React.memo 它仅检查 props
的变更,简单来说就是变更了就重新渲染,但是遇到 useState
,useContext
这类似的 hook
,变更了 state
或 context
也会重新渲染。
const Home = memo(({ increment }) => {
console.log("Home render");
return (
<div>
<button className="mt" onClick={increment}>
Home Component Add One (+)
</button>
{/* 100 child components */}
</div>
);
});
对 Home
组件引入 memo
,当 increment
不变时,它不会重新渲染。
const App = memo(() => {
const [count, setCount] = useState(0);
const [random, setRandom] = useState(
Math.random()
);
// TODO increment
return (
<div className="App">
<header className="App-header">
<h2>count: {count}</h2>
<button onClick={increment}>
App Add One (+)
</button>
<Home increment={increment} />
<h2>random: {random}</h2>
<button
onClick={(e) =>
setRandom(Math.random())
}
>
Change Random Number
</button>
</header>
</div>
);
});
此时可以思考一下 increment
函数该如何写。
// 1. 普通函数
const increment = () => {
console.log("increment");
setCount(count + 1);
};
应该在没有认识 useCallback
之前能想到的就是这样了,但是这样的话,会有一个问题,每次点击 Add
调用 increment
之后,state
发生变化,App
组件重新渲染,increment
函数也会发生变化,从而传入到子组件中的 props
发生变化,子组件中的所有组件会被重新 render
,如果子组件只有很少的内容影响不明显,当子组件是一整个页面,没点击一次 Add
相当于都会重新去渲染整个页面,很影响性能,因此,想到了第二个方法,使用 useCallback
。
const increment = useCallback(() => {
console.log("increment");
setCount(count + 1);
}, []);
const increment = useCallback(() => {
console.log("increment");
setCount(count + 1);
}, [count]);
此时这两个都会存在点问题,第一个会出现闭包陷阱,第二个每次 count
发生变化会导致重新调用 useCallback
去生成新的 increment
让子组件的 props
发生变化,因此子组件会重新渲染。
闭包陷阱:这里是因为没有依赖项,因此
count
记录的是最初的0
,点击Add
之后,会调用increment
让count
变为1
,由于没有依赖项,那么它将返回该回调函数的memoized
版本(点击前的increament === 点击后的increament
)。深层次是因为在堆中没有重新开辟一块空间放置回调函数内容,用的是之前的。⚠️ 注意:useEffect、useMemo 也存在同样的问题!
最终正对种种问题,可以使用 useRef
这个 Hook
实现 useCallback + memo
的性能优化。
const countRef = useRef();
// 这里可以把 useState 删掉,直接用 current 的值
countRef.current = count;
const increment = useCallback(function foo() {
console.log("increment");
setCount(countRef.current + 1);
}, []);
主要是因为 countRef
没有发生变化,因此也不会重新去生成新的 increment
,并且其内部 current
会在每次更新 count
重新渲染 App
组件之后获取最新值。
useMemo
区分于 useCallback
,useMemo
返回的是 memoized
值,而不是一个 memoized
回调,从另外一个角度来讲,useCallback(fn, deps)
和 useMemo(() => fn, deps)
的效果是相同的。
因此这里对 useMemo
不做过多的赘述,其和 useCallback
有相似之处。
const App = memo(() => {
const [count, setCount] = useState(0);
const info = { name: "why", age: 18 };
return (
<div>
<span>{count}</span>
<button
onClick={(e) => setCount(count + 1)}
>
Add
</button>
<HelloWorld info={info} />
</div>
);
});
在点击 Add
的时候,count
发生变化,re-render
,info
每次都会拿到一个新的对象,导致了传入到子组件 props
也会发生变化,因此每次都会重新渲染子组件。如果此时使用 useMemo
来返回 info
,const info = useMemo(() => ({ name: "why", age: 18 }), [])
则每次 re-render
的时候,info
对象不会改变,子组件不会重新渲染。
useRef
这里的 useRef
返回的值类似于在 Vue3
中 Composition API
的 ref
。useRef(init)
会返回 ref object
,其中 current
的属性会设置为 init
。在下次的 render
中,useRef
会返回相同的对象,因此可用 current
来存储一些信息,类似于 state
,但有一个重要的区别,更改 ref
不会触发 re-render
。
因此对于使用 ref
:
- 可以在
re-render
之间存储信息,不像常规的变量发生变化。 - 改变它不会触发
re-render
,改变state
会触发re-render
。 - 该信息对于组件的每一个副本都是本地的。
function MyComponent() {
// 🚩 Don't write a ref during rendering
myRef.current = 123;
// 🚩 Don't read a ref during rendering
return <h1>{myOtherRef.current}</h1>;
}
注意:在
render
期间不要对ref.current
进行操作。这是因为之前所提到的React
希望自己的组件是一个纯函数,对于相同的输入(props
,state
,context
),都会返回相同的JSX
,上述的操作已经破坏了这个期望。
为此,正对这个保存状态的特征,可以解释在 useCallback
中所提到的可以使用 useRef 绑定值来解决闭包陷阱的问题。查看详情
useRef
除了用来保存状态之外,还可以用来获取 DOM
元素(fiber
信息)。
const App = memo(() => {
const titleRef = useRef(null);
const inputRef = useRef(null);
function showTitleDom() {
console.log(titleRef.current);
inputRef.current.focus();
}
return (
<>
<h2 ref={titleRef}>Hello World</h2>
<input type="text" ref={inputRef} />
<button onClick={showTitleDom}>
Check
</button>
</>
);
});
但是当使用 ref
去获取自定义组件时,会出现错误,例如下代码:
const HelloWorld = memo(() => {
return <div>HelloWorld</div>;
});
const App = memo(() => {
const compRef = useRef();
function showRef() {
console.log(compRef.current);
}
return (
<>
<HelloWorld ref={compRef}></HelloWorld>
<button onClick={showRef}>
Show Own Component Ref
</button>
</>
);
});
控制台报错信息提示需要使用 React.forwardRef
,修改之后代码如下:
const HelloWorld = memo(
forwardRef((props, ref) => {
return <div ref={ref}>HelloWorld</div>;
})
);
const App = memo(() => {
const compRef = useRef();
function showRef() {
console.log(compRef.current);
}
return (
<>
<HelloWorld ref={compRef}></HelloWorld>
<button onClick={showRef}>
Show Own Component Ref
</button>
</>
);
});
useImperativeHandle
首先可以看下如下代码:
const HelloWorld = memo(
forwardRef((props, ref) => {
return <input type="text" ref={ref} />;
})
);
const App = memo(() => {
const inputRef = useRef();
function handleDOM() {
inputRef.current.focus();
inputRef.current.value = "";
}
return (
<div>
<HelloWorld ref={inputRef} />
<button onClick={handleDOM}>Change</button>
</div>
);
});
以上代码,父组件能够拿到子组件的 DOM
信息,因此可以随意操作,避免以为未知的错误,可以使用 useImperativeHandle
来约束暴露出去的属性。
由于涉及到了组件暴露 ref
,因此 useImperativeHandle
应当与 forwardRef
一起使用。
const HelloWorld = memo(
forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => {
return {
focus() {
inputRef.current.focus();
},
};
});
return <input type="text" ref={inputRef} />;
})
);
将 HelloWolrd
组件的内容修改为使用 useImperativeHandle
,此时便无法改变 value
。
useLayoutEffect
useLayoutEffect
和 useEffect
非常的相似,事实上他们也只有一点区别而已:
useEffect
会再渲染的内容更新到DOM
后执行,不会阻塞DOM
更新。useLayoutEffect
会在渲染的内容更新到DOM
前执行,会阻塞DOM
更新。
也就是说对于 useEffect
而言,当 state
发生时候,此时组件会触发重新渲染,当渲染的内容更新到 DOM
上可以在屏幕上看到更新内容之后在触发 useEffect
里面的副作用内容;相反,当渲染的内容更新到 DOM
之前,会先执行 useLayoutEffect
中副作用的内容,如果其修改了 DOM
内容,此时在屏幕上不会渲染之前的内容,而是直接展示修改之后的内容。
这里给了一个例子:useEffect & useLayoutEffect 从例子可以看出,点击 useEffect
之后屏幕会闪动一次,这就是因为先渲染出来之后在重新执行 useEffect
中的副作用,而下面的 useLayoutEffect
则不会出现闪烁的问题,因为会把 DOM
修改之后再渲染出来。
Summary
到此为止,把常见的一些 Hook
都熟悉了一遍,其实还有其他的 Hook
,比如 React18
提出的 useId
、Redux
中的 useSeletor
、useDispatch
等等,这些内容都需要了解。准备开始做一些 React
的项目,应该是先从最简单的开始。