查看原文
其他

【第2232期】深入理解React Router:Context、Hooks、Refs、Memo特性讲解

李杨韬 前端早读课 2021-04-20

前言

本文来自《深入理解React Router》节选。今日前端早读课文章由@李杨韬分享。

正文从这开始~~

鉴于读者对React有一定的认识,且本书所有案例均使用React Hooks编写,以及在React Router源码中使用了Context等React特性,因此本章仅对React的Context、Hooks等部分特性进行介绍。对于其他React相关特性,读者可查阅相关资料进行学习。

Context

在React中,父组件通常将数据作为props传递给子组件。如果需要跨层级传递数据,那么使用props逐层传递数据将会使开发变得复杂。同时,在实际开发中,许多组件需要一些相同的东西,如国际化语言配置、应用的主题色等,在开发组件的过程中也不希望逐级传递这些配置信息。

在这种情况下,可以使用React的Context特性。Context被翻译为上下文,如同字面意思,其包含了跨越当前层级的信息。

Context在许多组件或者开发库中有着广泛的应用,如react-redux使用Context作为Provider,提供全局的store,以及React Router通过Context提供路由状态。掌握Context将会对理解React Router起到极大的帮助作用。这里以图3-1来说明Context如何跨组件传递数据。

在图3-1中,左侧组件树使用了逐层传递props的方式来传递数据,即使组件B、组件C不需要关心某个数据项,也被迫需要将该数据项作为props传递给子组件。而使用Context来实现组件间跨层级的数据传递,数据可直接从组件A传递到组件D中。

在React v16.3及以上版本中,可使用React.createContext接口创建Context容器。基于生产者-消费者模式,创建容器后可使用容器提供方(一般称为Provider)提供某跨层级数据,同时使用容器消费方(一般称为Consumer)消费容器提供方所提供的数据。示例如下:

  1. // 传入defaultValue

  2. // 如果Consumer没有对应的Provider,则Consumer所获得的值为传入的1

  3. const CountContext = React.createContext(1);

  4. class App extends React.Component {

  5. state = {

  6. count: 0

  7. };

  8. render() {

  9. console.log("app render");

  10. return (

  11. <CountContext.Provider value={this.state.count}>

  12. <Toolbar />

  13. <button

  14. onClick={() =>

  15. this.setState(state => ({

  16. count: state.count + 1

  17. }))

  18. }

  19. >

  20. 更新

  21. </button>

  22. </CountContext.Provider>

  23. );

  24. }

  25. }

通过setState改变count的值,触发render渲染,Context.Provider会将最新的value值传递给所有的Context.Consumer。

  1. class Toolbar extends React.Component {

  2. render() {

  3. console.log("Toolbar render");

  4. return (

  5. <div>

  6. <Button />

  7. </div>

  8. );

  9. }

  10. }

  11. class Button extends React.Component {

  12. render() {

  13. console.log("Button outer render");

  14. return (

  15. // 使用Consumer跨组件消费数据

  16. <CountContext.Consumer>

  17. {count => {

  18. // 在Consumer中,受到Provider提供数据的影响

  19. console.log("Button render");

  20. return <div>{count}</div>;

  21. }}

  22. </CountContext.Consumer>

  23. );

  24. }

  25. }

在上例中,顶层组件App使用 CountContext.Provider将this.state.count的值提供给后代组件。App的子组件Toolbar不消费Provider所提供的数据,Toolbar的子组件Button使用CountContext.Consumer获得App所提供的数据count。中间层的Toolbar组件对数据跨层级传递没有任何感知。在单击“更新”按钮触发数据传递时,Toolbar中的“Toolbar render”信息不会被打印。每次单击“更新”按钮时,仅会打印“app render”与“Button render”,这是因为在Provider所提供的值改变时,仅Consumer会渲染,所以Toolbar中的“Toolbar render”不会被打印。

如果在Toolbar中也使用Provider提供数据,如提供的value为500:

  1. class Toolbar extends React.Component {

  2. render() {

  3. console.log("Toolbar render");

  4. return (

  5. <CountContext.Provider value={500}>

  6. <Button />

  7. </CountContext.Provider>

  8. );

  9. }

  10. }

则Button中的Consumer得到的值将为500。原因在于当有多个Provider时,Consumer将消费组件树中最近一级的Provider所提供的值。这作为React的一个重要特性,在React Router源码中被大量应用。

注意,如果不设置Context.Provider的value,或者传入undefined,则Consumer并不会获得创建Context时的defaultValue数据。创建Context时的defaultValue数据主要提供给没有匹配到Provider的Consumer,如果去掉App中的Provider,则Consumer所获得的值为1。

如果希望使用this.context方式获取Provider所提供的值,则可声明类的静态属性contextType (React v16.6.0)。contextType的值为创建的Context,如:

  1. const MyContext = React.createContext();

  2. class MyClass extends React.Component {

  3. static contextType = MyContext;

  4. render() {

  5. // 获取Context的值

  6. let value = this.context;

  7. }

  8. }

在React v16.3以前,不支持通过createContext的方式创建上下文,可使用社区的polyfill方案,如create-react-context等。

注意,组件的优化方法如shouldComponentUpdate或者React.memo不能影响Context值的传递。若在Button中引入shouldComponentUpdate,则会阻止Button更新:

  1. shouldComponentUpdate() {

  2. // 返回false 阻止了Button组件的渲染,但是Provider提供的数据依然会提供到

  3. //Consumer中

  4. // 不受此影响

  5. return false;

  6. }

改变Provider所提供的值后,依然会触发Consumer的重新渲染,结果与未引入shouldComponentUpdate时一致。

Hooks

React Hooks是React v16.8正式引入的特性,旨在解决与状态有关的逻辑重用和共享等问题。

在React Hooks诞生前,随着业务的迭代,在组件的生命周期函数中,充斥着各种互不相关的逻辑。通常的解决办法是使用Render Props动态渲染所需的部分,或者使用高阶组件提供公共逻辑以解耦各组件间的逻辑关联。但是,无论是哪一种方法,都会造成组件数量增多、组件树结构修改或者组件嵌套层数过多的问题。在Hooks诞生后,它将原本分散在各个生命周期函数中处理同一业务的逻辑封装到了一起,使其更具移植性和可复用性。使用Hooks不仅使得在组件之间复用状态逻辑更加容易,也让复杂组件更易于阅读和理解;并且由于没有类组件的大量polyfill代码,仅需要函数组件就可运行,Hooks将用更少的代码实现同样的效果。

React提供了大量的Hooks函数支持,如提供组件状态支持的useState、提供副作用支持的useEffect,以及提供上下文支持的useContext等。

在使用React Hooks时,需要遵守以下准则及特性要求。

  • 只在顶层使用Hooks。不要在循环、条件或嵌套函数中调用Hooks,确保总是在React函数组件的顶层调用它们。

  • 不要在普通的JavaScript函数中调用Hooks。仅在React的函数组件中调用Hooks,以及在自定义Hook中调用其他Hooks。

useState

useState类似于React类组件中的state和setState,可维护和修改当前组件的状态。

useState是React自带的一个Hook函数,使用useState可声明内部状态变量。useState接收的参数为状态初始值或状态初始化方法,它返回一个数组。数组的第一项是当前状态值,每次渲染其状态值可能都会不同;第二项是可改变对应状态值的set函数,在useState初始化后该函数不会变化。

useState的类型为:

  1. function useState<S>(initialState: S | (() => S)): [S, Dispatch <SetStateAction <S>>];

initialState仅在组件初始化时生效,后续的渲染将忽略initialState:

  1. const [inputValue, setValue] = useState("react");

  2. const [react, setReact] = useState(inputValue);

如上例中的inputValue,当初始值传入另一个状态并初始化后,另一个状态函数将不再依赖inputValue的值。

使用Hooks的方式非常简单,引入后在函数组件中使用:

  1. import { useState } from 'react';

  2. function Example() {

  3. const [count, setCount] = useState(0);

  4. return (

  5. <div>

  6. <p>您点击了 {count} 次</p>

  7. <button onClick={() => setCount(count + 1)}>

  8. // 单击触发更新

  9. </button>

  10. </div>

  11. );

  12. }

类似于setState,单击按钮时调用setCount更新了状态值count。当调用setCount后,组件会重新渲染,count的值会得到更新。

当传入初始状态为函数时,其仅执行一次,类似于类组件中的构造函数:

  1. const [count, setCount] = useState(()=>{

  2. // 可执行初始化逻辑

  3. return 0

  4. });

此外,useState返回的更新函数也可使用函数式更新:

  1. setCount(preCount => preCount + 1)

如果新的state需要依赖先前的 state 计算得出,那么可以将回调函数当作参数传递给setState。该回调函数将接收先前的state,并将返回的值作为新的state进行更新。

注意,React规定Hooks需写在函数的最外层,不能写在if…else等条件语句中,以此来确保Hooks的执行顺序一致。

useEffect
副作用

在计算机科学中,如果某些操作、函数或表达式在其局部环境之外修改了一些状态变量值,则称其具有副作用(side effect)。副作用可以是一个与第三方通信的网络请求,或者是外部变量的修改,或者是调用具有副作用的任何其他函数。副作用并无好坏之分,其存在可能影响其他环境的使用,开发者需要做的是正确处理副作用,使得副作用操作与程序的其余部分隔离,这将使得整个软件系统易于扩展、重构、调试、测试和维护。在大多数前端框架中,也鼓励开发者在单独的、松耦合的模块中管理副作用和组件渲染。

对于函数来说,无副作用执行的函数称为纯函数,它们接收参数,并返回值。纯函数是确定性的,意味着在给定输入的情况下,它们总是返回相同的输出。但这并不意味着所有非纯函数都具有副作用,如在函数内生成随机值会使纯函数变为非纯函数,但不具有副作用。

React是关于纯函数的,它要求render纯净。若render不纯净,则会影响其他组件,影响渲染。但在浏览器中,副作用无处不在,如果希望在React中处理副作用,则可使用 useEffect。 useEffect,顾名思义,就是执行有副作用的操作,其声明如下:

  1. useEffect(effect: React.EffectCallback, inputs?: ReadonlyArray<any> | undefined)

函数的第一个参数为副作用函数,第二个参数为执行副作用的依赖数组,这将在下面的内容中介绍。 示例如下:

  1. const App = () => {

  2. const [value, setValue] = React.useState(0);

  3. // 引入useEffect

  4. React.useEffect(function useEffectCallBack() {

  5. // 可执行副作用

  6. // 在此进行数据请求、订阅事件或手动更改 DOM等操作

  7. const nvDom = document.getElementById("content");

  8. console.log("color effect", nvDom.style.color);

  9. });

  10. console.log("render");

  11. return (

  12. <div

  13. id="content"

  14. style={{ color: value === 1 ? "red" : "" }}

  15. onClick={() => setValue(c => c + 1)}

  16. >

  17. value: {value}

  18. </div>

  19. );

  20. };

当上述组件初始化后,在打印render后会打印一次color effect,表明组件渲染之后,执行了传入的effect。而在单击ID为content的元素后,将更新value状态,触发一次渲染,打印render之后会打印color effect red。这一流程表明React的DOM已经更新完毕,并将控制权交给开发者的副作用函数,副作用函数成功地获取到了DOM更新后的值。事实上,上述流程与React的componentDidMount、componentDidUpdate生命周期类似,React首次渲染和之后的每次渲染都会调用一遍传给useEffect的函数,这也是useEffect与传统类组件可以类比的地方。一般来说,useEffect可类比为componentDidMount、componentDidUpdate、componentWillUnmount三者的集合,但要注意它们不完全等同,主要区别在于componentDidMount或componentDidUpdate中的代码是“同步”执行的。这里的“同步”指的是副作用的执行将阻碍浏览器自身的渲染,如有时候需要先根据DOM计算出某个元素的尺寸再重新渲染,这时候生命周期方法会在浏览器真正绘制前发生。

而useEffect中定义的副作用函数的执行不会阻碍浏览器更新视图,也就是说这些函数是异步执行的。所谓异步执行,指的是传入useEffect的回调函数是在浏览器的“绘制”阶段之后触发的,不“同步”阻碍浏览器的绘制。在通常情况下,这是比较合理的,因为大多数的副作用都没有必要阻碍浏览器的绘制。对于useEffect,React使用了一种特殊手段保证effect函数在“绘制”阶段后触发:

  1. const channel = new MessageChannel();


  2. channel.port1.onmessage = function() {

  3. // 此时绘制结束,触发effect函数

  4. console.log("after repaint");

  5. };


  6. requestAnimationFrame(function () {

  7. console.log("before repaint");

  8. channel.port2.postMessage(undefined);

  9. });

requestAnimationFrame与postMessage结合使用以达到这一类目的。

简而言之,useEffect会在浏览器执行完reflow/repaint流程之后触发,effect函数适合执行无DOM依赖、不阻碍主线程渲染的副作用,如数据网络请求、外部事件绑定等。

清除副作用

当副作用对外界产生某些影响时,在再次执行副作用前,应先清除之前的副作用,再重新更新副作用,这种情况可以在effect中返回一个函数,即cleanup(清除)函数。

每个effect都可以返回一个清除函数。作为useEffect可选的清除机制,其可以将监听和取消监听的逻辑放在一个effect中。

那么,React何时清除effect?effect的清除函数将会在组件重新渲染之后,并先于副作用函数执行。以一个例子来说明:

  1. const App = () => {

  2. const [value, setValue] = useState(0);

  3. useEffect(function useEffectCallBack() {

  4. expensive();

  5. console.log("effect fire and value is", value);

  6. return function useEffectCleanup() {

  7. console.log("effect cleanup and value is ", value);

  8. };

  9. });

  10. return <div onClick={() => setValue(c => c + 1)}>value: {value}</div>;

  11. };

每次单击div元素,都会打印:

  1. // 第一次单击

  2. effect cleanup and value is 0

  3. effect fire and value is 1

  4. // 第二次单击

  5. effect cleanup and value is 1

  6. effect fire and value is 2

  7. // 第三次单击

  8. effect cleanup and value is 2

  9. effect fire and value is 3

  10. // ……

如上例所示,React会在执行当前 effect 之前对上一个 effect 进行清除。清除函数作用域中的变量值都为上一次渲染时的变量值,这与Hooks的Caputure Value特性有关,将在下面的内容中介绍。

除了每次更新会执行清除函数,React还会在组件卸载的时候执行清除函数。

减少不必要的effect

如上面内容所说,在每次组件渲染后,都会运行effect中的清除函数及对应的副作用函数。若每次重新渲染都执行一遍这些函数,则显然不够经济,在某些情况下甚至会造成副作用的死循环。这时,可利用useEffect参数列表中的第二个参数解决。useEffect参数列表中的第二个参数也称为依赖列表,其作用是告诉React只有当这个列表中的参数值发生改变时,才执行传入的副作用函数:

  1. useEffect(() => {

  2. document.title = `You clicked ${count} times`;

  3. }, [count]); // 只有当count的值发生变化时,才会重新执行document.title这一行

那么,React是如何判断依赖列表中的值发生了变化的呢?事实上,React对依赖列表中的每个值,将通过Object.is进行元素前后之间的比较,以确定是否有任何更改。如果在当前渲染过程中,依赖列表中的某一个元素与该元素在上一个渲染周期的不同,则将执行effect副作用。

注意,如果元素之一是对象或数组,那么由于Object.is将比较对象或数组的引用,因此可能会造成一些疑惑:

  1. function App({config}) {

  2. React.useEffect(

  3. () => {},

  4. [config],

  5. )

  6. return <div>{/* UI */}</div>

  7. }

  8. // 每次渲染都传入config新对象

  9. <App config={a:1}/>

如果config每次都由外部传入,那么尽管config对象的字段值都不变,但由于新传入的对象与之前config对象的引用不相等,因此effect副作用将被执行。要解决此种问题,可以依赖一些社区的解决方案,如use-deep-compare-effect。

在通常情况下,若useEffect的第二个参数传入一个空数组[](这并不属于特殊情况,它依然遵循依赖列表的工作方式),则React将认为其依赖元素为空,每次渲染比对,空数组与空数组都没有任何变化。React认为effect不依赖于props或state中的任何值,所以effect副作用永远都不需要重复执行,可理解为componentDidUpdate永远不会执行。这相当于只在首次渲染的时候执行effect,以及在销毁组件的时候执行cleanup函数。要注意,这仅是便于理解的类比,对于第二个参数传入一个空数组[]与这类生命周期的区别,可查看下面的注意事项。

注意事项

1)Capture Value特性

注意,React Hooks有着Capture Value的特性,每一次渲染都有它自己的props和state:

  1. function Counter() {

  2. const [count, setCount] = useState(0);

  3. useEffect(() => {

  4. const id = setInterval(() => {

  5. console.log("count is", count);

  6. setCount(count + 1);

  7. }, 1000);

  8. return () => clearInterval(id);

  9. }, []);

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

  11. }

在useEffect中,获得的永远是初始值0,将永远打印“count is 0”;h1中的值也将永远为setCount(0+1)的值,即“1”。若希望count能依次增加,则可使用useRef保存count,useRef将在3.2.4节介绍。

2)async函数

useEffect不允许传入async函数,如:

  1. useEffect(async () => {

  2. // return函数将不会被调用

  3. }, []);

原因在于async函数返回了promise,这与useEffect的cleanup函数容易混淆。在async函数中返回cleanup函数将不起作用,若要使用async函数,则可进行如下改写:

  1. useEffect(() => {

  2. (async () => {

  3. // 一些逻辑

  4. })();

  5. // 可返回cleanup函数

  6. }, []);

3)空数组依赖

注意,useEffect传递空数组依赖容易产生一些问题,这些问题通常容易被忽视,如以下示例:

  1. function ChildComponent({ count }) {

  2. useEffect(() => {

  3. console.log("componentDidMount", count);

  4. return () => {

  5. // 永远为0,由Capture Value特性所导致

  6. alert("componentWillUnmount and count is " + count);

  7. };

  8. }, []);

  9. console.log("count", count);

  10. return <>count:{count}</>;

  11. }

  12. const App = () => {

  13. const [count, setCount] = useState(0);

  14. const [childShow, setChild] = useState(true);

  15. return (

  16. <div onClick={() => setCount(c => c + 1)}>

  17. <button onClick={() => setChild(false)}>销毁Child组件</button>

  18. {childShow && <ChildComponent count={count} />}

  19. </div>

  20. );

  21. };

单击“销毁Child组件”按钮,浏览器将弹出“componentWillUnmount and count is 0”提示框,无论setCount被调用多少次,都将如此,这是由Capture Value特性所导致的。而类组件的componentWillUnmount生命周期可从this.props.count中获取到最新的count值。

在使用useEffect时,注意其不完全与componentDidUpdate、componentWillUnmount等生命周期等同,应该以“副作用”或状态同步的方式去思考useEffect。但这也不代表不建议使用空数组依赖,需要结合上下文场景决定。与其将useEffect视为一个功能来经历3个单独的生命周期,不如将其简单地视为一种在渲染后运行副作用的方式,可能会更有帮助。

useEffect的设计意图是关注数据流的改变,然后决定effect该如何执行,与生命周期的思考模型需要区分开。

useLayoutEffect

React还提供了与useEffect同等地位的useLayoutEffect。useEffect和useLayoutEffect在副作用中都可获得DOM变更后的属性:

  1. const App = () => {

  2. const [value, setValue] = useState(0);

  3. useEffect(function useEffectCallBack() {

  4. const nvDom = document.getElementById("content");

  5. console.log("color effect", nvDom.style.color);

  6. });

  7. useLayoutEffect(function useLayoutEffectCallback() {

  8. const nvDom = document.getElementById("content");

  9. console.log("color layout effect", nvDom.style.color);

  10. });


  11. return (

  12. <div

  13. id="content"

  14. style={{ color: value === 1 ? "red" : "" }}

  15. onClick={() => setValue(c => c + 1)}

  16. >

  17. value: {value}

  18. </div>

  19. );

  20. };

单击按钮后会打印“color layout effect red”“color effect red”。可见useEffect与useLayoutEffect都可从DOM中获得其变更后的属性。

从表面上看,useEffect与useLayoutEffect并无区别,但事实上厘清它们的区别需要从副作用的“同步”“异步”入手。3.2.2节曾介绍过useEffect的运行过程是异步进行的,即useEffect不阻碍浏览器的渲染;useLayoutEffect与useEffect的区别是useLayoutEffect的运行过程是“同步”的,其阻碍浏览器的渲染。

简而言之,useEffect发生在浏览器reflow/repaint操作之后,如果某些effect是从DOM中获得值的,如获取clientHeight、clientWidth,并需要对DOM进行变更,则可以改用useLayoutEffect,使得这些操作在reflow/repaint操作之前完成,这样有机会避免浏览器花费大量成本,多次进行reflow/repaint操作。以一个例子来说明:

  1. const App = () => {

  2. const [value, setValue] = useState(0);

  3. useEffect(function useEffectCallBack() {

  4. console.log("effect");

  5. });

  6. // 在下一帧渲染前执行

  7. window.requestAnimationFrame(() => {

  8. console.log("requestAnimationFrame");

  9. });

  10. useLayoutEffect(function useLayoutEffectCallback() {

  11. console.log("layoutEffect");

  12. });

  13. console.log("render");

  14. return <div onClick={() => setValue(c => c + 1)}>value: {value}</div>;

  15. };

分别在useEffect、requestAnimationFrame、useLayoutEffect和render过程中进行调试打印,以观察它们的时序。可以看到,当渲染App后将按如下顺序打印:render、layoutEffect、requestAnimationFrame、effect。由此可知,useLayoutEffect的副作用都在“绘制”阶段前,useEffect的副作用都在“绘制”阶段后。通过浏览器调试工具观察task的执行,如图3-2所示。 在图3-2中,①执行了useLayoutEffectCallback,为useLayoutEffect的副作用;②为浏览器的Paint流程;在Paint流程后,③的执行函数为useEffectCallBack,执行了useEffect的副作用。

useRef

在使用class类组件时,通常需要声明属性,用以保存DOM节点。借助useRef,同样可以在函数组件中保存DOM节点的引用:

  1. import { useRef } from "React"

  2. function App() {

  3. const inputRef = useRef(null);

  4. return <div>

  5. <input type="text" ref={inputRef}/>

  6. {/* 通过inputRef.current获取节点 */}

  7. <button onClick={() => inputRef.current.focus()}>focus</button>

  8. </div>

  9. }

  10. // useRef的签名为:

  11. interface MutableRefObject<T> {

  12. current: T;

  13. }

  14. function useRef<T>(initialValue: T): MutableRefObject<T>;

useRef返回一个可变的Ref对象,其 current 属性被初始化为传递的参数(initialValue)。useRef返回的可变对象就像一个“盒子”,这个“盒子”存在于组件的整个生命周期中,其current属性保存了一个可变的值。

useRef不仅适用于DOM节点的引用,类似于类上的实例属性,useRef还可用来存放一些与UI无关的信息。useRef返回的可变对象,其current属性可以保存任何值,如对象、基本类型或函数等。所以,函数组件虽然没有类的实例,没有“this”,但是通过useRef依然可以解决数据的存储问题。如在2.1节,曾使用过useRef:

  1. function Example(props){

  2. const { history } = props;

  3. // 使用useRef保存注销函数

  4. const historyUnBlockCb = React.useRef<UnregisterCallback>(()=>{});

  5. React.useEffect(()=>{

  6. return ()=>{

  7. // 在销毁组件时调用,注销history.block

  8. historyUnBlockCb.current()

  9. }

  10. },[]);

  11. function block() {

  12. // 解除之前的阻止

  13. historyUnBlockCb.current();

  14. // 重新设置弹框确认,更新注销函数,单击“确定”按钮,正常跳转;单击“取消”

  15. // 按钮,跳转不生效

  16. historyUnBlockCb.current = history.block('是否继续?')

  17. }

  18. return <>

  19. <button onClick={block}>阻止跳转</button>

  20. <button onClick={()=>{

  21. historyUnBlockCb.current();

  22. }}>解除阻止</button>

  23. </>

  24. }

上例使用useRef返回了可变对象historyUnBlockCb,通过historyUnBlockCb.current保存了history.block的返回值。

注意,更改refObject.current的值不会导致重新渲染。如果希望重新渲染组件,则可使用useState,或者使用某种forceUpdate方法。

useMemo

作为React内置的Hooks,useMemo用于缓存某些函数的返回值。useMemo使用了缓存,可避免每次渲染都重新执行相关函数。useMemo接收一个函数及对应的依赖数组,当依赖数组中的一个依赖项发生变化时,将重新计算耗时函数。

  1. function App() {

  2. const [count, setCount] = React.useState(0);

  3. const forceUpdate = useForceUpdate();

  4. const expensiveCalcCount = count => {

  5. console.log("expensive calc");

  6. let i = 0;

  7. while (i < 9999999) i++;

  8. return count;

  9. };

  10. // 使用useMemo记录高开销的操作

  11. const letterCount = React.useMemo(() => expensiveCalcCount(count), [count]);

  12. console.log("component render");

  13. return (

  14. <div style={{ padding: "15px" }}>

  15. <div>{letterCount}</div>

  16. <button onClick={() => setCount(c => c + 1)}>改变count</button>

  17. <button onClick={forceUpdate}>更新</button>

  18. </div>

  19. );

  20. }

在上面的示例中,除了使用了React.useState,还使用了一个自定义Hook——useForceUpdate,其返回了forceUpdate函数,与类组件中的forceUpdate函数功能一致。关于自定义Hook,将在3.2.7节介绍。

在初始渲染App时,React.useMemo中的函数会被计算一次,对应的count值与函数返回的结果都会被useMemo记录下来。

若单击“改变count”按钮,由于count改变,当App再次渲染时,React.useMemo发现count有变化,将重新调用expensiveCalcCount并计算其返回值。因此,控制台会打印“expensive calc”“component render”。

而若单击“更新”按钮,则调用forceUpdate函数再次渲染。由于在再次渲染过程中React.useMemo发现count值没有改变,因此将返回上一次React.useMemo中函数计算得到的结果,渲染App控制台仅打印“component render”。

同时,React也提供了useCallback用以缓存函数:

  1. useCallback(fn, deps)

在实现上,useCallback等价于useMemo(() => fn, deps),因此这里不再赘述。

useContext

若希望在函数组件中使用3.1节中所述的Context,除使用Context.Consumer消费外,还可使用useContext:

  1. const contextValue = useContext(Context);

useContext接收一个Context对象(React.createContext 的返回值)并返回该Context的当前值。与3.1节中的Consumer类似,当前的Context值由上层组件中距离最近的Context.Provider提供。当更新上层组件中距离最近的Context.Provider时,使用useContext的函数组件会触发重新渲染,并获得最新传递给Context.Provider的value值。

调用了useContext的组件总会在Context值变化时重新渲染,这个特性将会经常使用到。

在函数组件中,使用useContext获取上下文内容,有效地解决了之前Provider、Consumer需要额外包装组件的问题,且由于其替代了Context.Consumer的render props写法,这将使得组件树更加简洁。

自定义Hook

自定义Hook是一个函数,其名称约定以use开头,以便可以看出这是一个Hooks方法。如果某函数的名称以use开头,并且调用了其他Hooks,就称其为一个自定义Hook。自定义Hook就像普通函数一样,可以定义任意的入参与出参,唯一要注意的是自定义Hook需要遵循Hooks的基本准则,如不能在条件循环中使用、不能在普通函数中使用。

自定义Hook解决了之前React组件中的共享逻辑问题。通过自定义Hook,可将如表单处理、动画、声明订阅等逻辑抽象到函数中。自定义Hook是重用逻辑的一种方式,不受内部调用情况的约束。事实上,每次调用Hooks都会有一个完全隔离的状态。因此,可以在一个组件中使用两次相同的自定义Hook。下面是两个常用自定义Hook的示例:

  1. // 获取forceUpdate函数的自定义Hook

  2. export default function useForceUpdate() {

  3. const [, dispatch] = useState(Object.create(null));

  4. const memoizedDispatch = useCallback(() => {

  5. // 引用变化

  6. dispatch(Object.create(null));

  7. }, [dispatch]);

  8. return memoizedDispatch;

  9. }

获取某个变量上一次渲染的值:

  1. // 获取上一次渲染的值

  2. function usePrevious(value) {

  3. const ref = useRef();

  4. useEffect(() => {

  5. ref.current = value;

  6. }, [value]);

  7. return ref.current;

  8. }

可基于基础的React Hooks定义许多自定义Hook,如useLocalStorage、useLocation、useHistory (将在第5章中进行介绍)等。将逻辑抽象到自定义Hook中后,代码将更具有可维护性。

Refs

createRef

前文曾介绍过useRef用以保存DOM节点,事实上也可以通过createRef创建Ref对象:

  1. class MyComponent extends React.Component {

  2. constructor(props) {

  3. super(props);

  4. this.myRef = React.createRef();

  5. }

  6. render() {

  7. return <div ref={this.myRef} />;

  8. }

  9. }

当this.myRef被传递给div元素时,可通过以下方式获取div原生节点:

  1. const node = this.myRef.current;

Ref不仅可以作用于DOM节点上,也可以作用于类组件上。在类组件上使用该属性时,Ref对象的current属性将获得类组件的实例,因而也可以调用该组件实例的公共方法。

forwardRef

引用传递(Ref forwading)是一种通过组件向子组件自动传递引用Ref的技术。例如,某些input组件需要控制其focus,本来是可以使用Ref来控制的,但是因为该input已被包裹在组件中,所以这时就需要使用forwardRef来透过组件获得该input的引用。

  1. import React,{Component} from 'react';

  2. import ReactDOM,{render} from 'react-dom';

  3. const ChildOrigin = (props,ref)=>{

  4. return (

  5. <div ref={ref}>{props.txt}</div>

  6. )

  7. };

  8. const Child = React.forwardRef(ChildOrigin);

  9. class Parent extends Component{

  10. constructor(){

  11. super();

  12. this.myChild = React.createRef();

  13. }

  14. componentDidMount(){

  15. console.log(this.myChild.current);// 获取的是Child组件中的div元素

  16. }

  17. render(){

  18. return <Child ref={this.myChild} txt="parent props txt"/>

  19. }

  20. }

当对原ChildOrigin组件使用forwardRef获得了新的Child组件后,新Child组件的Ref将传递到ChildOrigin组件内部。在上面的示例中,可通过新Child组件的Ref值this.myChild. current获取到ChildOrigin组件内部div元素的引用。

Memo

为了提高React的运行性能,React v16.6.0提供了一个高阶组件——React.memo。当React.memo包装一个函数组件时,React会缓存输出的渲染结果,之后当遇到相同的渲染条件时,会跳过此次渲染。与React的PureComponent组件类似,React.memo默认使用了浅比较的缓存策略,但React.memo对应的是函数组件,而React.PureComponent对应的是类组件。React.memo的签名如下:

  1. function memo<P extends object>(

  2. Component: SFC<P>,

  3. propsAreEqual?: (prevProps: Readonly<PropsWithChildren<P>>, nextProps: Readonly<PropsWithChildren<P>>) => boolean): NamedExoticComponent<P>;

React.memo参数列表中的第一个参数接收一个函数组件,第二个参数表示可选的props比对函数。React.memo包装函数组件后,会返回一个新的记忆化组件。以一个示例来说明,若有一个子组件ChildComponent,没有通过React.memo记忆化:

  1. function ChildComponent({ count }) {

  2. console.log("childComponent render", count);

  3. return <>count:{count}</>;

  4. }

  5. const App = () => {

  6. const [count] = useState(0);

  7. const [childShow, setChild] = useState(true);

  8. return (

  9. <div>

  10. <button onClick={() => setChild(c => !c)}>隐藏/展示内容</button>

  11. {childShow && <div>内容</div>}

  12. <ChildComponent count={count} />

  13. </div>

  14. );

  15. };

当重复单击按钮时,由于触发了重新渲染,ChildComponent将得到更新,将多次打印“childComponent render”。若引入React.memo(ChildComponent)缓存组件,则在渲染组件时,React将进行检查。如果该组件渲染的props与先前渲染的props不同,则React将触发渲染;反之,如果props前后没有变化,则React不执行渲染,更不会执行虚拟DOM差异检查,其将使用上一次的渲染结果。

  1. function ChildComponent({ count }) {

  2. console.log("childComponent render");

  3. return <>count:{count}</>;

  4. }

  5. const MemoChildComponent = React.memo(ChildComponent);

  6. const App = () => {

  7. const [count] = useState(0);

  8. const [childShow, setChild] = useState(true);

  9. return (

  10. <div>

  11. <button onClick={() => setChild(c => !c)}> 隐藏/展示内容</button>

  12. {childShow && <div>内容</div>}

  13. <MemoChildComponent count={count} />

  14. </div>

  15. );

  16. };

当单击“隐藏/展示内容”按钮时,会导致重新渲染,但由于原组件通过React.memo包装过,使用了包装后的组件MemoChildComponent,在多次渲染时props没有变化,因此这时不会多次打印“childComponent render”。

同时,React.memo可以使用第二个参数propsAreEqual来自定义渲染与否的逻辑:

  1. const MemoChildComponent = React.memo(ChildComponent, function propsAreEqual(

  2. prevProps,

  3. nextProps

  4. ) {

  5. return prevProps.count === nextProps.count;

  6. });

propsAreEqual接收上一次的prevProps与即将渲染的nextProps,函数返回的boolean值表明前后的props是否相等。若返回“true”,则认为前后props相等;反之,则认为不相等,React将根据函数的返回值决定组件的渲染情况(与shouldComponentUpdate类似)。因此,可认为函数返回“true”,props相等,不进行渲染;函数返回“false”则认为props有变化,React会执行渲染。 注意,不能把React.memo放在组件渲染过程中。

  1. const App = () => {

  2. // 每次都获得新的记忆化组件

  3. const MemoChildComponent = React.memo(ChildComponent);

  4. const [count] = useState(0);

  5. const [childShow, setChild] = useState(true);

  6. return (

  7. <div>

  8. <button onClick={() => setChild(c => !c)}>隐藏/展示内容</button>

  9. {childShow && <div>内容</div>}

  10. <MemoChildComponent count={count} />

  11. </div>

  12. );

  13. };

这相当于每次渲染都开辟一块新的缓存,原缓存无法得到利用,React.memo的记忆化将失效,开发者需要特别注意。

小结

本章介绍了Context、Hooks、Refs、Memo等React特性,在React Router源码及相关第三方库实现中,都涉及以上特性。掌握以上特性,对理解React Router及使用React Router进行实战都有非常大的帮助。

相比props和state,React的Context特性可以实现跨层级的组件通信。我们可以在很多框架设计中找到使用Context的例子,React Router也是其一。学习使用Context对理解React Router十分重要。同时,本章介绍了React Hooks,作为React v16.8的新特性,以及考虑到React Router今后的演进趋势,学习使用React Hooks进行函数式组件开发将对读者有极大的帮助。

参考文献

  • https://zh-hans.reactjs.org/docs/context.html.

  • https://en.wikipedia.org/wiki/Sideeffect(computer_science).

  • https://zh-hans.reactjs.org/docs/hooks-reference.html#usecontext.

  • https://github.com/facebook/react/blob/v16.8.6/packages/shared/shallowEqual.js.

  • https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is#Description.


关于本书更详细内容,欢迎通过本文左下角“阅读原文”了解。


为你推荐


【第2214期】前端测试心法 + React 组件测试实践


【第2113期】使用 React Hooks 声明 setInterval


欢迎自荐投稿,前端早读课等你来

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存