今天分享的内容,是React Hooks第一个api,useState,阅读本文需要有具备最基础的React知识。
单向数据流
和angular双向绑定不同,React采用自上而下单向数据流的方式,管理自身的数据与状态。在单向数据流中,数据只能由父组件触发,向下传递到子组件。
我们可以在父组件中定义state,并通过props的方式传递到子组件。如果子组件想要修改父组件传递而来的状态,则只能给父组件发送消息,由父组件改变,再重新传递给子组件。
在React中,state与props的改变,都会引发组件重新渲染。如果是父组件的变化,则父组件下所有子组件都会重新渲染。
在class组件中,组件重新渲染,是执行render方法。
而在函数式组件中,是整个函数重新执行。
函数式组件
函数式组件与普通的函数几乎完全一样。只不过函数执行完毕时,返回的是一个JSX结构。
function Hello() { return <div>hello world.</div> }
函数式组件非常简单,也正因如此,一些特性常常被忽略,而这些特性,是掌握React Hooks的关键。
1. 函数式组件接收props作为自己的参数
import React from 'react'; interface Props { name: string, age: number } function Demo({ name, age }: Props) { return [ <div>name: {name}</div>, <div>age: {age}</div> ] } export default Demo;
2. props的每次变动,组件都会重新渲染一次,函数重新执行。
3. 没有this。那么也就意味着,之前在class中由于this带来的困扰就自然消失了。
Hooks
Hooks并不是神秘,它就是函数式组件。更准确的概述是:有状态的函数式组件。
useState
每次渲染,函数都会重新执行。我们知道,每当函数执行完毕,所有的内存都会被释放掉。因此想让函数式组件拥有内部状态,并不是一件理所当然的事情。
当然,也不是完全没有办法,useState就是帮助我们做这个事情。
从上一章再谈闭包中我们知道,useState利用闭包,在函数内部创建一个当前函数组件的状态。并提供一个修改该状态的方法。
我们从react中引入useState
import { useState } from 'react';
利用数组解构的方式得到一个状态与修改状态的方法。
// 利用数组解构的方式接收状态名及其设置方法 // 传入0作为状态 counter的初始值 const [counter, setCounter] = useState(0);
每当setCounter执行,就会改变counter的值。
基于这个知识点,我们可以创建一个最简单的,有内部状态的函数式组件。
import React, { useState } from 'react'; export default function Counter() { const [counter, setCounter] = useState(0); return [ <div key="a">{counter}</div>, <button key="b" onClick={() => setCounter(counter + 1)}> 点击+1 </button> ] }
利用useState声明状态,每当点击时,setCounter
执行,counter
递增。
需要注意的是,setCounter
接收的值可以是任意类型,无论是什么类型,每次赋值,counter得到的,都是新传入setCounter中的值。
举个例子,如果counter是一个引用类型。
// counter默认值为 { a: 1, b: 2 } const [counter, setCounter] = useState({ a: 1, b: 2 }); // 此时counter的值被改为了 { b: 4 }, 而不是 { a: 1, b: 4 } setCounter({ b: 4 }); // 如果想要得到 { a: 1, b: 4 }的结果,就必须这样 setCounter({ ...counter, b: 4 });
那么一个思考题:用下面的例子修改状态,会让组件重新渲染吗?
const [counter, setCounter] = useState({ a: 1, b: 2 }); // 修改counter的值 counter.b = 4; setCounter(counter);
useState接收一个值作为当前定义的state的初始值。并且初始操作只有组件首次渲染才会执行。
// 首次执行,counter初始值为10 // 再次执行,因为在后面因为某种操作改变了counter,则获取到的便不再是初始值,而是闭包中的缓存值 const [counter, setCounter] = useState(10); setCounter(20);
如果初始值需要通过较为复杂的计算得出,则可以传入一个函数作为参数,函数返回值为初始值。该函数也只会在组件首次渲染时执行一次。
const a = 10; const b = 20 // 初始值为a、b计算之和 const [counter, setCounter] = useState(() => { return a + b; })
如果是在typescript中使用,我们可以用如下的方式声明状态的类型。
const [counter, setCounter] = useState<number>(0);
但是通常情况下,基础数据类型typescript能够很容易推导出来,因此我们不需要专门设置,只有在相对复杂的场景下才会需要专门声明。
// 能根据 0 推导为number类型 const [counter, setCounter] = useState(0); // 能根据 false 推导为 boolean 类型 const [visible, setVisible] = useState(false); // 能根据 [] 推导为 any[] 类型,因此此时还需要专门声明any为何物 const [arr, setArr] = useState<number[]>([]);
实践
接下来,我们完成一个稍微复杂一点的例子。文章头部的动态图还有印象吗?
多个滑动条控制div元素的不同属性,如果使用useState来实现,应该怎么做?
代码如下:
import React, { useState } from 'react'; import { Slider } from 'antd-mobile'; import './index.scss'; interface Color { r: number, g: number, b: number } export default function Rectangle() { const [height, setHeight] = useState(10); const [width, setWidth] = useState(10); const [color, setColor] = useState<Color>({ r: 0, g: 0, b: 0 }); const [radius, setRadius] = useState<number>(0); const style = { height: `${height}px`, width: `${width}px`, backgroundColor: `rgb(${color.r}, ${color.g}, ${color.b})`, borderRadius: `${radius}px` } return ( <div className="container"> <p>height:</p> <Slider max={300} min={10} onChange={(n) => setHeight(n || 0)} /> <p>width:</p> <Slider max={300} min={10} onChange={(n) => setWidth(n || 0)} /> <p>color: R:</p> <Slider max={255} min={0} onChange={(n = 0) => setColor({ ...color, r: n })} /> <p>color: G:</p> <Slider max={255} min={0} onChange={(n = 0) => setColor({ ...color, g: n })} /> <p>color: B:</p> <Slider max={255} min={0} onChange={(n = 0) => setColor({ ...color, b: n })} /> <p>Radius:</p> <Slider max={150} min={0} onChange={(n = 0) => setRadius(n)} /> <div className="reatangle" style={style} /> </div> ) }
仔细体会一下,代码是不是比想象中更简单?需要注意观察的地方是,当状态被定义为引用数据类型时,例子中是如何修改的。
原则上来说,useState的应用知识差不多都聊完了。不过,还能聊点高级的。
无论是在class中,还是hooks中,state的改变,都是异步的。
如果对事件循环机制了解比较深刻,那么异步状态潜藏的危机就很容易被意识到并解决它。如果不了解,可以翻阅我的JS基础进阶。详解事件循环[1]
状态异步,也就意味着,当你想要在setCounter之后立即去使用它时,你无法拿到状态最新的值,而之后到下一个事件循环周期执行时,状态才是最新的值。
const [counter, setCounter] = useState(10); setCounter(20); console.log(counter); // 此时counter的值,并不是20,而是10
实践中有许多的错误使用,因为异步问题而出现bug。
例如我们想要用一个接口,去请求一堆数据,而这个接口接收多个参数。
当改变各种过滤条件,那么就势必会改变传入的参数,并在参数改变时,立即重新去请求一次数据。
利用hooks,会很自然的想到使用如下的方式。
import React, { useState } from 'react'; interface ListItem { name: string, id: number, thumb: string } // 一堆各种参数 interface Param { current?: number, pageSize?: number, name?: string, id?: number, time?: Date } export default function AsyncDemo() { const [listData, setListData] = useState<ListItem[]>([]); // 定义一个状态缓存参数,确保每次改变后都能缓存完整的参数 const [param, setParam] = useState<Param>({}); function fetchListData() { // @ts-ignore listApi(param).then(res => { setListData(res.data); }) } function searchByName(name: string) { setParam({ ...param, name }); // 改变param之后立即执行请求数据的代码 // 这里的问题是,因为异步的原因,param并不会马上发生变化, // 此时直接发送请求无法拿到最新的参数 fetchListData(); } return [ <div>data list</div>, <button onClick={() => searchByName('Jone')}>search by name</button> ] }
这是一个不完整的示例。需要大家在阅读时结合自身开发经验去意会。
关键的代码在于searchByName
方法。当使用setParam
改变了param之后,立即去请求数据,在当前事件循环周期,param并没有改变。请求的结果,自然无法达到预期。
如何解决呢?
首先我们要考虑的一个问题是,什么样的变量适合使用useState去定义?
当然是能够直接影响DOM的变量,这样我们才会将其称之为状态。
因此param这个变量对于DOM而言没有影响,此时将他定义为一个异步变量并不明智。好的方式是将其定义为一个同步变量。
export default function AsyncDemo() { const [listData, setListData] = useState<ListItem[]>([]); // 定义为同步变量 let param: Param = {} function fetchListData() { // @ts-ignore listApi(param).then(res => { setListData(res.data); }) } function searchByName(name: string) { param = { ...param, name }; fetchListData(); } return [ <div>data list</div>, <button onClick={() => searchByName('Jone')}>search by name</button> ] }
不过,等一下,这样好像也有一点问题
还记得函数式组件的特性吗?每次状态改变,函数都会重新执行一次,那么此时param也就被重置了。状态无法得到缓存。
那么怎么办?
好吧,利用闭包。上一篇文章我们知道,每一个模块,都是一个执行上下文。因此,我们只要在这个模块中定义一个变量,并且在函数组件中访问,那么闭包就有了。
因此,将变量定义到函数的外面。如下
// 定义为同步变量 let param: Param = {} export default function AsyncDemo() { const [listData, setListData] = useState<ListItem[]>([]); function fetchListData() { // @ts-ignore listApi(param).then(res => { setListData(res.data); }) } function searchByName(name: string) { param = { ...param, name }; fetchListData(); } return [ <div>data list</div>, <button onClick={() => searchByName('Jone')}>search by name</button> ] }
这样似乎能够解决一些问题。
但也不是完全没有隐患,因为善后工作还没有做,因为这个闭包中的变量,即使在组件被销毁了,它的值还会存在。当新的组件实例被渲染,param就无法得到初始值了。因此这样的方式,我们必须在每一个组件被销毁时,做好善后工作。
那还有没有更好的方式呢?答案就藏在我们上面的知识点中。
我们知道useState其实也是利用闭包缓存了状态,并且即使函数多次执行,也只会初始化一次。之前的问题在于我们使用了setParam去改变它的值,如果我们换一种思路呢?仔细体会一下代码就知道了。
export default function AsyncDemo() { const [param] = useState<Param>({}); const [listData, setListData] = useState<ListItem[]>([]); function fetchListData() { // @ts-ignore listApi(param).then(res => { setListData(res.data); }) } function searchByName(name: string) { param.name = name; fetchListData(); } return [ <div>data list</div>, <button onClick={() => searchByName('Jone')}>search by name</button> ] }
没有想到吧,useState还能这么用!
OK,useState相关的应用知识就基本分享完了,接下来的文章聊聊useEffect。
今天帮助一位同学优化了hooks实践代码,同样的功能,优化结果代码量减少了40行左右!!快到群里来!