React Hooks 学习笔记 | useEffect Hook(二)

大家好,上一篇文章我们学习了 State Hook 的基础用法,还没看的同学们,小编建议你先看下《 React Hooks 学习笔记 | State Hook(一)》这篇文章,今天我们一起来了解 useEffect Hook 的基础用法。
一、开篇
一般大多数的组件都需要特殊的操作,比如获取数据、监听数据变化或更改DOM的相关操作,这些操作被称作 “side effects(副作用)”。
在类组件中,我们通常会在 componentDidMount 和 componentDidUpdate 这两个常用的生命钩子函数进行操作,这些生命周期的相关方法便于我们在合适的时机更加精确的控制组件的行为。
这有一个简单的代码示例,页面加载完成后,更改页面的标题
componentDidMount() {
  document.title = this.state.name + " from " + this.state.location;
}
当你尝试更改标题对应的状态值时,页面的标题不会发生任何变化,你还需要添加另一个生命周期的方法 componentDidUpdate() ,监听状态值的变化重新re-render,示例代码如下:
componentDidUpdate() {
  document.title = this.state.name + " from " + this.state.location;
}
从上述代码我们可以看出,要实现动态更改页面标题的方法,我们需要调用两个生命钩子函数,同样的方法写两遍。但是我们使用 useEffect Hook 函数,就能解决代码重复的问题,示例代码如下:
import React, { useState, useEffect } from "react";
//...
useEffect(() => {
  document.title = name + " from " + location;
});
可以看出,使用 useEffect Hook ,我们就实现了两个生命周期函数等效的目的,节省了代码量。

二、添加清除功能
还有一个类组件的例子,在某些情况下,你需要在组件卸载(unmounted)或销毁(destroyed)之前,做一些有必要的清除的操作,比如timers、interval,或者取消网络请求,或者清理任何在componentDidMount()中创建的DOM元素(elements),你可能会想到类组件中的 componentWillUnmount()这个钩子函数,示例代码如下:
import React from "react";
export default class ClassDemo extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      resolution: {
        width: window.innerWidth,
        height: window.innerHeight
      }
    };
    this.handleResize = this.handleResize.bind(this);
  }
  componentDidMount() {
    window.addEventListener("resize", this.handleResize);
  }
  componentDidUpdate() {
    window.addEventListener("resize", this.handleResize);
  }
  componentWillUnmount() {
    window.removeEventListener('resize', this.handleResize);
  }
  handleResize() {
    this.setState({
      resolution: {
        width: window.innerWidth,
        height: window.innerHeight
      }
    });
  }
  render() {
    return (
      <section>
        <h3>
          {this.state.resolution.width} x {this.state.resolution.height}
        </h3>
      </section>
    )
  }
}
上面的代码将显示浏览器窗口的当前分辨率。当你调整窗口大小,您应该会看到自动更新窗口的宽和高的值,同时我们又添加了组件销毁时,在 componentWillUnmount() 函数中定义清除监听窗口大小的逻辑。
如果我们使用 Hook 的方式改写上述代码,看起来更加简洁,示例代码如下:
import React, { useState, useEffect } from "react";
export default function HookDemo(props) {
  ...
  const [resolution, setResolution] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  useEffect(() => {
    const handleResize = () => {
      setResolution({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    window.addEventListener("resize", handleResize);
    // return clean-up function
    return () => {
      document.title = 'React Hooks Demo';
      window.removeEventListener("resize", handleResize);
    };
  });
  ...
  return (
    <section>
      ...
      <h3>
        {resolution.width} x {resolution.height}
      </h3>
    </section>
  );
}
运行后的效果如下所示:

三、关于 [ ] 依赖数组参数的说明
在开篇的时候,我们使用 useEffect Hook 实现了 componentDidMount ,componentDidUpdate 两个生命钩子函数的一致的效果,这就意味着 DOM 加载完成后,状态发生变化造成的 re-render 都会执行 useEffect Hook 中的逻辑,在一些场景下,我们没必要在状态发生变化时,调用此函数的逻辑,比如我们在这里定义数据接口更改数据状态,数据状态发生变化,会重新调用 useEffect Hook 中的请求逻辑,这样岂不是进入了无限循环,数据量大的话,说不定就把接口请求死了。
但是还好, useEffect Hook 提供了依赖使用参数,第一个参数是定义方法,第二个参数是依赖数组,用于自定义依赖的参数,是否触发再次执行,接下来我们来看几个示例效果:
3.1、after every render
useEffect(() => { 
// run after every rendering 
console.log('render') 
})

如上图所示,我们每次更改状态值导致组件重新渲染时,我们在 useEffect 中定义的输出将会反复的被执行。
3.2、Once(执行一次)
接下来我们可以在第二个参数上定义一个空数组,解决上述问题,告诉 Hook 组件只执行一次(及时状态发生改变导致的 re-render ),示例代码如下:
useEffect(() => {
    // Just run the first time
    console.log('render')
  }, [])

如上图运行效果所示,你会发现 Hook 函数中定义的输出,无论我们怎么更改状态值,其只输出一次。
3.3、依赖 state/props 的改变再执行
如果你想依赖特定的状态值、属性,如果其发生变化时导致的 re-render ,再次执行 Hook 函数中定义的逻辑,你可以将其写在数组内,示例代码如下:
useEffect(() => {
    // When title or name changed will render
    console.log('render')
}, [title, name])
四、用一张图总结下
说了这么多,我们做一下总结,说白了就是整合了 componentDidMount,componentDidUpdate,与 componentWillUnmount 这三个生命钩子函数,变成了一个API,其用法可以用如下一张图进行精简概括

五、继续完善购物清单
在上一篇系列文章里《 React Hooks 学习笔记 | State Hook(一)》,我们通过做一个简单的购物清单实践了 State Hook,本篇文章我们通过继续完善这个实例,加深我们对 useEffect Hook 的理解,学习之前大家可以先提前下载上一篇文章的源码。
本节案例,为了更加接近实际应用场景,这里我使用了 Firebase 快速构建后端的数据库和其自身的接口服务。(谷歌的产品,目前需要科学上网才能使用,Firebase 是 Google Cloud Platform 为应用开发者们推出的应用后台服务。借助Firebase,应用开发者们可以快速搭建应用后台,集中注意力在开发 client 上,并且可以享受到 Google Cloud 的稳定性和 scalability )。

5.1、创建Firebase
1、在 https://firebase.google.com/(科学上网才能访问),使用谷歌账户登录 ,进入控制台创建项目。


5.2、添加状态加载、错误提示UI组件
接下来我们添加进度加载组件和错误提示对话框组件,分别用于状态加载中状态提示和系统错误状态提示,代码比较简单,这里就是贴下相关代码。
LoadingIndicator 数据加载状态提示组件
import React from 'react';
import './LoadingIndicator.css';
const LoadingIndicator = () => (
    <div className="lds-ring">
        <div />
        <div />
        <div />
        <div />
    </div>
);
export default LoadingIndicator;
// components/UI/LoadingIndicator.js
.lds-ring {
  display: inline-block;
  position: relative;
  width: 54px;
  height: 54px;
}
.lds-ring div {
  box-sizing: border-box;
  display: block;
  position: absolute;
  width: 44px;
  height: 44px;
  margin: 6px;
  border: 6px solid #ff2058;
  border-radius: 50%;
  animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
  border-color: #ff2058 transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
  animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
  animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
  animation-delay: -0.15s;
}
@keyframes lds-ring {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
/*
 components/UI/LoadingIndicator.css
*/
ErrorModal 系统错误组件
import React from "react";
import './ErrorModal.css'
const ErrorModal = React.memo(props => {
    return (
        <React.Fragment>
            <div className="backdrop" onClick={props.onClose} />
            <div className="error-modal">
                <h2>An Error Occurred!</h2>
                <p>{props.children}</p>
                <div className="error-modal__actions">
                    <button type="button" onClick={props.onClose}>
                        Okay
                    </button>
                </div>
            </div>
        </React.Fragment>
    );
});
export default ErrorModal;
// components/UI/ErrorModal.js
.error-modal {
  position: fixed;
  top: 30vh;
  left: calc(50% - 15rem);
  width: 30rem;
  background: white;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.26);
  z-index: 100;
  border-radius: 7px;
}
.error-modal h2 {
  margin: 0;
  padding: 1rem;
  background: #ff2058;
  color: white;
  border-radius: 7px 7px 0 0;
}
.error-modal p {
    padding: 1rem;
}
.error-modal__actions {
    display: flex;
    justify-content: flex-end;
    padding: 0 0.5rem;
}
.backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh;
  background: rgba(0, 0, 0, 0.75);
  z-index: 50;
}
/*
 components/UI/ErrorModal.css
*/
5.3、增加接口显示购物清单列表
接下来,我们在购物清单页 Ingredients 组件里,我们使用今天所学的知识,在 useEffect() 里添加历史购物清单的列表接口,用于显示过往的清单信息,这里我们使用 firebase 的提供的API, 请求 https://react-hook-update-350d4-default-rtdb.firebaseio.com/ingredients.json 这个地址,就会默认给你创建 ingredients 的集合,并返回一个 JSON 形式的数据集合,示例代码如下:
useEffect(() => {
        fetch('https://react-hook-update-350d4-default-rtdb.firebaseio.com/ingredients.json')
            .then(response => response.json())
            .then(responseData => {
                const loadedIngredients = [];
                for (const key in responseData) {
                    loadedIngredients.push({
                        id: key,
                        title: responseData[key].title,
                        amount: responseData[key].amount
                    });
                }
                setUserIngredients(loadedIngredients);
            })
    }, []);
// components/Ingredients/Ingredients.js
上述代码我们可以看出,我们使用 fetch 函数请求接口,请求完成后我们更新 UserIngredients 数据状态,最后别忘记了,同时在 useEffect 函数中,依赖参数为空数组[ ],表示只加载一次,数据状态更新时导致的 re-render,就不会发生无限循环的请求接口了,这个很重要、很重要、很重要!
5.4 、更新删除清单的方法
这里我们要改写删除清单的方法,将删除的数据更新到云端数据库 Firebase ,为了显示更新状态和系统的错误信息,这里我们引入 ErrorModal ,添加数据加载状态和错误状态,示例如下:
import ErrorModal from '../UI/ErrorModal';
const Ingredients = () => {
...
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState();
...
}
// components/Ingredients/Ingredients.js
接下来我们来改写删除方法 removeIngredientHandler
const removeIngredientHandler = ingredientId => {
        setIsLoading(true);
        fetch(`https://react-hook-update-350d4-default-rtdb.firebaseio.com/ingredients/${ingredientId}.json`,
            {
                method: 'DELETE'
            }
        ).then(response => {
            setIsLoading(false);
            setUserIngredients(prevIngredients =>
                prevIngredients.filter(ingredient => ingredient.id !== ingredientId)
            );
        }).catch(error => {
            setError('系统开了个小差,请稍后再试!');
            setIsLoading(false);
        })
    };
// components/Ingredients/Ingredients.js
从上述代码我们可以看出,首先我们先将加载状态默认为true,接下来请求删除接口,这里请注意接口地址 ${ingredientId} 这个变量的使用(当前数据的 ID 主键),删除成功后,更新加载状态为 false 。如果删除过程中发生错误,我们在catch 代码块里捕捉错误并调用错误提示对话框(更新错误状态和加载状态)。
5.5、更新添加清单的方法
接着我们改写添加清单的方式,通过接口请求的方式,将添加的数据添加至 Firebase 数据库,代码比较简单,就不多解释了,示例代码如下:
const addIngredientHandler = ingredient => {
        setIsLoading(true);
        fetch('https://react-hook-update-350d4-default-rtdb.firebaseio.com/ingredients.json',
            {
            method: 'post',
            body: JSON.stringify(ingredient),
            headers: {'Content-Type': 'application/json'}
        })
            .then(response => {
            setIsLoading(false);
            return response.json();
        })
            .then(responseData => {
            setUserIngredients(prevIngredients => [
                ...prevIngredients,
                {id: responseData.name, ...ingredient}
            ]);
        });
    };
// components/Ingredients/Ingredients.js
5.5、添加搜索组件功能
我们继续完善购物清单的功能,为购物清单添加新功能-搜索功能(通过请求接口),方便我们搜索清单的内容,界面效果如下图所示,在中间添加一个搜索框。

import React,{useState,useEffect,useRef} from "react";
import Card from "../UI/Card";
import './Search.css';
const Search = React.memo(props=>{
    const { onLoadIngredients } = props;
    const [enteredFilter,setEnterFilter]=useState('');
    const inputRef = useRef();
    useEffect(() => {
        const timer = setTimeout(() => {
            if (enteredFilter === inputRef.current.value) {
                const query =
                    enteredFilter.length === 0
                        ? ''
                        : `?orderBy="title"&equalTo="${enteredFilter}"`;
                fetch(
                    'https://react-hook-update-350d4-default-rtdb.firebaseio.com/ingredients.json' + query
                )
                    .then(response => response.json())
                    .then(responseData => {
                        const loadedIngredients = [];
                        for (const key in responseData) {
                            loadedIngredients.push({
                                id: key,
                                title: responseData[key].title,
                                amount: responseData[key].amount
                            });
                        }
                        onLoadIngredients(loadedIngredients);
                    });
            }
        }, 500);
        return () => {
            clearTimeout(timer);
        };
    }, [enteredFilter, onLoadIngredients, inputRef]);
    return(
        <section className="search">
            <Card>
                <div className="search-input">
                    <label>搜索商品名称</label>
                    <input
                        ref={inputRef}
                        type="text"
                        value={enteredFilter}
                        onChange={event=>setEnterFilter(event.target.value)}
                    />
                </div>
            </Card>
        </section>
    )
});
export  default Search;
// components/Ingredients/Search.js
上述代码,我们定义为了避免频繁触发接口,定义了一个定时器,在用户输入500毫秒后在请求接口。你可以看到 useEffect() 里,我们使用了 return 方法,用于清理定时器,要不会有很多的定时器。同时依赖参数有三个 [enteredFilter, onLoadIngredients,inputRef],只有用户的输入内容和事件属性发生变化时,才会再次触发 useEffect() 中的逻辑。这里我们用到了useRef 方法获取输入框的值,关于其详细的介绍,会在稍后的文章介绍。
接下来贴上 Search.css 的相关代码,由于内容比较简单,这里就不过多解释了。
.search {
  width: 30rem;
  margin: 2rem auto;
  max-width: 80%;
}
.search-input {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-direction: column;
}
.search-input input {
  font: inherit;
  border: 1px solid #ccc;
  border-radius: 5px;
  padding: 0.15rem 0.25rem;
}
.search-input input:focus {
  outline: none;
  border-color: #ff2058;
}
@media (min-width: 768px) {
  .search-input {
    flex-direction: row;
  }
}
/* 
components/Ingredients/Search.css
*/
最后我们将 Search 组件添加至清单页面,在这个页面里定义了一个 useCallback 的方法,类似 Vue 的 computed 缓存的特性,避免重复计算,这个方法主要用来接收 Search 子组件传输数据,用于更新 UserIngredients 数据中的状态,在稍后的文章里我会详细介绍,这里只是简单的贴下代码,示例代码如下:
const filteredIngredientsHandler = useCallback(filteredIngredients => {
setUserIngredients(filteredIngredients)
}, []);// components/Ingredients/Ingredients.js
接下来在 return 里添加 Search 组件和 ErrorModal 组件,在 Search 组件的 ingredients 属性里添加上述定义的 filteredIngredientsHandler 方法,用于接收组件搜索接口请求返回的数据内容,用于更新 UserIngredients 的数据状态,示例代码如下:
<div className="App">
{error && <ErrorModal onClose={clearError}>{error}</ErrorModal>}
<IngredientForm onAddIngredient={addIngredientHandler} loading={isLoading}/>
<section>
<Search onLoadIngredients={filteredIngredientsHandler}/>
<IngredientList ingredients={userIngredients} onRemoveItem={removeIngredientHandler}/>
</section>
</div>// components/Ingredients/Ingredients.js
到这里,本节的实践练习就完了,基本上是一个基于后端接口的,基础的增删改查案例,稍微完善下就可以运用到你的实际案例中。你可以点击阅读原文进行体验(主要本案例采用了Firebase ,科学上网才能在线体验)。
六、结束语
好了,本篇关于 useEffect() 的介绍就结束了,希望你已经理解了 useEffect 的基本用法,感谢你的阅读,你可以点击阅读原文体验本文的案例部分,如果你想获取源码请回复"r2",小编建议亲自动手做一下,这样才能加深对 useEffect Hook 的认知,下一篇本系列文章将会继续介绍 useRef,敬请期待。
