useState
useState原理
如果你不了解class组件,也没有关系,我们无需通过与class比较而引入它。
useState是组件中最基础的工具,使用它来帮助我们创建一个“状态变量”。如果你不理解什么是“状态”,也没有关系。
在数据驱动思想的开发模式下,我们应该将每一个动作、UI与数据相关联,通过操作数据去操作视图。一个简单的例子,我们想要使一个一个div去想右移动10px如何实现?这很简单,我们只需要获取到这个div元素,然后将它style的值减少10px
const div = document.getElementById("app")
div.style.transform = 'translateX(10px)'
在做这件事情的时候,我们要考虑的第一主体是div这个元素,通过控制这个dom节点的属性来移动它,不论我们要操作它多少次,都将是以它为主。
而数据驱动意在让我们抛离这些旁事,将重心放在数据上。如果在最开始,有一个变量叫做distance,这个变量在以后的操作中,将始终代表这个元素的平移的距离。每当我想要改变div元素时,例如我想让他向右平移10px,只需要改变distance的值即可,视图会自动更新。这样一来,我无需再关心如何去获取dom这些琐事,我只需要关注distance这个变量,因为它代表了dom节点现在的平移状态。把重心放在数据上。ok,那么,我们来实现它。
let distance = 0
const div = document.getElementById("app")
function setDistance(state) {
distance = state
render()
}
function render() {
div.style.transform = `translateX(${distance}px)`
}
在做了一些简单的准备后,现在我想要让div进行平移,那么,我只需要使用setDistance(10)
方法。对于使用者,我可以不去思考setDistance内部是如何实现的,我只需要知道,通过setDistance改变distance的值后,视图会随之更新。这便是数据驱动的一些观点,将我们的思考重心放在数据上。在UI中的dom逐渐变得复杂时,我们可以不用担心复杂的dom关系带来的额外工作。
而在与react中,useState正式如此。再通过useState初始化得到的一个变量与一个函数正是对应着上面的distance与setDistance。将变量与dom绑定后,即可通过变量来操作dom。
他的使用法非常简单
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>Click</button>
</div>
);
}
通过点击按钮触发setCount方法来进行count的变化,count的变化从而触发视图的重新渲染。
在之前的例子中,我们使用全局创建的distance来存储我们的状态变量,可在react中,会出现众多的state,如果都要定义变量,则难以管理。所以这里,我们可以使用数组来存储。
我们将所有定义的状态变量放在一个数组stateList中,然后定义一个下标来找到指定的状态变量。
let stateList = [];
let index = 0;
在使用useState定义状态的时候,我们只需要将当前定义的变量插入数组中。
function useState(initialValue) {
stateList[index] = initialValue;
const currentindex = index;
const setState = newState => {
stateList[currentindex] = newState;
render();
};
index = index + 1
return [stateList[currentindex], setState];
}
现在,我们在组件中使用它
function App() {
const [count, setCount] = useState(3);
// count 即 stateList[0]
// setCount 为 (newState) => { stateList[0] = newState; render() }
const [num, setNum] = useState(0);
// num 即 stateList[1]
// setNum 为 (newState) => { stateList[1] = newState; render() }
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>
click
</button>
<span>{num}</span>
<button onClick={() => setNum(num + 1)}>
click
</button>
</div>
);
}
这样,通过下标记录,来将所有state存放在一个数组中存储。现在,我们将它渲染到页面中
function render() {
index = 0;
ReactDOM.render(<App />, rootElement);
}
render()
现在我们已经可以看到页面中的两个span了,一个内容为3
一个为0
。当时当我们点击时,数值并不会增加。当我们点击第一个按钮,触发了setCount,此时stateList[0]的值增加了1,然后执行render方法。render方法将index置为0后,重新渲染函数组件,组件会依次渲染,当在此执行到App组件中的useState时,相当于重新把初始值付给了stateList[0]。
所以,我们需要在非首次更新视图的时候,不去进行重新赋值。
function useState(initialValue) {
stateList[index] = stateList[index] || initialValue; // 如果当前值存在,即证明之前赋值过,此时将不再使用传入的初始值
const currentindex = index;
const setState = newState => {
stateList[currentindex] = newState;
render();
};
index = index + 1
return [stateList[currentindex], setState];
}
此时,我们便完成了这个useState的实现。因为每次渲染组件,执行每一个useState的顺序是不变的,所以他们可以通过注册setState函数时存储的下标找到自己存储值的位置。这也就要求我们在写函数组件时,不能将hook包在判断语句中,例如
function App() {
const [count, setCount] = useState(3);
if(count > 3) {
const [num, setNum] = useState(0);
}
const [page, setPage] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>
click
</button>
<span>{num}</span>
<button onClick={() => setNum(num + 1)}>
click
</button>
</div>
);
}
第一次赋值时,count、num、page分别的下标是0、1、2。当count增加1的时候,在此渲染组件时,将不会在调用const [num, setNum] = useState(0)
而直接调用const [page, setPage] = useState(0)
,这时,stateList[1]就被付给了page从而导致错误。所以,在使用hook时要严格遵守这个规范。
useState简单原理代码如下:
const rootElement = document.getElementById("root");
let stateList = [];
let index = 0;
function useState(initialValue) {
stateList[index] = stateList[index] || initialValue;
const currentindex = index;
const setState = newState => {
stateList[currentindex] = newState;
render();
};
return [stateList[index++], setState];
}
function App() {
const [count, setCount] = useState(3);
const [num, setNum] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>
click
</button>
<span>{num}</span>
<button onClick={() => setNum(num + 1)}>
click
</button>
</div>
);
}
function render() {
index = 0;
ReactDOM.render(<App />, rootElement);
}
render();
在调用useData去改变状态变量的值时,我们会以data本身作为参考。在上例中,我们在点击时为num增加一遍是通过setNum(num + 1)来实现,他是在num的基础上进行的运算。因为js是静态作用域,所以变量会在函数声明时被确定。下例
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(count + 1)
}, 1000)
}
return (
<div>
<span>{count}</span>
<button onClick={handleClick}>
click
</button>
</div>
);
}
我们的想法是,在点击完成1s以后去进行数值的变化。可在实际操作时,如果我们在1s中点击多次,他的值永远增加的是1,count在1s后变为1之后,再无变化。
这是因为,我们在快速点击按钮后,迅速定义出了三个等待执行事件回调,此时,count并无变化,它的值一直是0,所以生成的三个函数全部为
() => {
setCount(0 + 1)
}
所以,react中setState在定义setCount方法的时候,为我们提供了定外一种写法
setCount(count => count + 1)
通过函数的方式,将上一次的count值传递过来,使用这种方式拿到count而无需我们自行的去设法拿到它执行时的值。因为我们可能会在任何地方去使用setCount。
setState的多次调用时的渲染情况
当我们在一个方法中先后调用了两个setState的方法
function App() {
const [count, setCount] = useState(3);
const [num, setNum] = useState(0);
console.log(1) // 用于监测App组件渲染次数
const handleClick = () => {
setCount(count + 1)
setNum(num + 1)
}
return (
<div>
<span>{count}</span>
<span>{num}</span>
<button onClick={handleClick}>
click
</button>
</div>
);
}
点击按钮后会发现控制台只输出了一次
> 1
尽管我们调用了两次setState方法,相对应了App中的输出却只输出一次,这是因为React在渲染时为我们进行了合并,这样会减少更多的无用渲染。在父子组件中也是这样
function Child({ num, setNum }) {
const [data, setData] = useState(0);
console.log(2);
const handle = () => {
setData(data + 1)
setNum(num + 1)
}
return (
<div>
<h2>{num}</h2>
<h2>{data}</h2>
<button onClick={handle}>click</button>
</div>
);
}
function App() {
const [num, setNum] = useState(0);
console.log(1);
return (
<div className="App">
<Child num={num} setNum={setNum} />
</div>
);
}
在点击按钮后,在控制台的输出并不是先输出一个2,在输出1、2,而是直接输出1、2
> 1
> 2
React会帮我们在渲染方面尽量减少无用的工作。这也证明了setState的更新时异步的。
但是,如果我们将此方做放入setTimeout中
function App() {
const [count, setCount] = useState(3);
const [num, setNum] = useState(0);
console.log(1)
const handleClick = () => {
setTimeout(() => {
setCount(count + 1)
setNum(num + 1)
}, 0)
}
return (
<div>
<span>{count}</span>
<span>{num}</span>
<button onClick={handleClick}>
click
</button>
</div>
);
}
此时会发现,控制台的输出为
> 1
> 1
此外,如果两次setState之间有异步操作改开的话,也会进行多次渲染
function App() {
const [count, setCount] = useState(3);
const [num, setNum] = useState(0);
console.log(1)
const handleClick = async () => {
setCount(count + 1)
await sleep()
setNum(num + 1)
}
return (
<div>
<span>{count}</span>
<span>{num}</span>
<button onClick={handleClick}>
click
</button>
</div>
);
}
输出也为两次。这也证明,React的合并渲染能力是有限的。而渲染是否会被合并取决于他是否处于React的事件处理程序中,例如react的类组件的生命周期,又或者是事件函数。但像setTimeout、Promise这类方法的回调中,便不属于React的可控范围。在被await函数间隔之后,以后的代码也将不在属于可控范围。例如
console.log('组件渲染')
const handleClick = async () => {
setCount(count => count + 1)
await sleep()
setCount(count => count + 1)
setCount(count => count + 1)
}
将会在控制台输出三次组件渲染
Eg:此处的事件函数必须为React事件,如果是原生事件的绑定,则无法进行合并
Eg:React使用事务机制为可控范围内的函数方法进行延迟执行。
对于处于这些环境中的setState方法不会立即执行渲染,而是会被加入缓存数组。而后经过计算、处理后,统一执行渲染。但不在这些环境的setState方法便会在触发时,立刻执行。setState的立即执行便会引起视图的立刻渲染。对于
function App() {
const [count, setCount] = useState(0);
console.log('组件渲染')
const handleClick = () => {
setCount(count + 1)
console.log(count)
}
return (
<div>
<span>{count}</span>
<button onClick={handleClick}>
click
</button>
</div>
);
}
此时控制台的输出为
> 0
> 组件渲染
而将setState改为立即执行后
function App() {
const [count, setCount] = useState(0);
console.log('组件渲染')
const handleClick = async () => {
setTimeout(() => {
setCount(count + 1)
console.log(count)
}, 0)
}
return (
<div>
<span>{count}</span>
<button onClick={handleClick}>
click
</button>
</div>
);
}
此时控制台输出
> 组件渲染
> 0
在React 18中,将在更多环境中实现合并渲染
useRef
在React 应用中,变量和UI通过函数绑定,这类变量被叫做状态变量,而状态变量的变更会引起视图的重新渲染。
function App() {
const [state, setState] = useState(0)
return (<div>
<button onClick={() => setState(state + 1)}>{state}</button>
</div>)
}
在点击按钮后,会改变变量的值,从而触发视图重新渲染。但有时,我们不希望变量变化后会对视图进行更新,我们只是希望这是一个单纯的用于存储数据的变量,并且不随组件每次重渲染而重新赋值。这时,我们需要的可能并不是一个“状态”,而是一个“ref”。
ref与创造它的组件一起存在于其整个生命周期,但它的改变将不会引起视图更新。在使用useRef创造了一个ref后,他的值被存在了ref对象的current属性下。
相比一般变量,在重新渲染组件后,ref仍能保持上一次的值,一般变量则会重新变为初始值。相比状态变量,ref的改变将不会触发视图渲染。
export default function App() {
const [count, setCount] = useState(0);
const ref = useRef(count);
const prevCount = ref.current;
console.log('组件渲染')
useEffect(() => {
console.log(`上一次的值:${prevCount}, 本次的值:${count}`)
ref.current = count
}, [visible]);
return <button onClick={() => setCount(count + 1)}>{String(count)}</button>
}
在点击一次按钮后,在控制台可见
> 组件渲染
> 上一次的值:0, 本次的值:1
在点击触发状态变量改变时,组件将重新执行渲染。当第二次执行const prevVisible = ref.current
时,此时的ref.current的值仍然是第一次的值并未改变,而此时的count已经变为+1后的值,故在执行useEffect中的输出如上。而在输出完成后,对ref.current进行赋值,此时,ref.current的值才出现了变更,并且变更后不会引起视图渲染。
对于useRef的实现原理,最简单的实现如下
function useRef(initialValue) {
return useMemo(() => ({ current: initialValue }), []);
}
创建ref的方式
对于函数组件,我们即可使用useRef的方式去创建,而在类组件中可使用顶层方法React.createRef来创建
// 函数组件
function App() {
const ref = useRef()
return <div></div>
}
// 类组件
class App extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
}
}
如果我们创建ref只是想获取dom元素,我们还可以通过一下方式来获取
export default function App(){ let myRef; useEffect(() => { console.log(myRef) }, [myRef]) return <ReactClassComponent ref={(el) => {myRef = el}} /> }
使用ref绑定元素
ref的出现更多情况下是用来解决dom问题的。在react编程中,它很好的为我们创建了虚拟dom并让我们无需关心dom操作。但有时,我们仍需要去主动的获取并操作dom,这时,存储dom的变量必须不会随组件的每次渲染而重新赋值,故此时,我们定义一个ref与dom绑定实是天作之合。
将我们定义出来的ref变量赋给dom元素的ref属性,即可通过ref来调用原生dom元素的方法。
function App(){
const ref = useRef();
useEffect(() => {
ref.current.focus()
}, [])
return <input ref={ref}/>
}
ref变量在接收原生dom对象时,将会把该原生对象作为其current属性。在接收react类组件时,会将会接收组件的挂载实例作为其 current 属性。另外,ref并不接收、绑定函数组件,因为它没有实例。
class ReactClassComponent extends React.Component {
render() {
return <div>ReactClassComponent</div>
}
}
function App(){
const ref1 = useRef();
const ref2 = useRef();
useEffect(() => {
console.log(ref1.current) // <input />
console.log(ref2.current) // ReactClassComponent实例
}, [])
return (<div>
<input ref={ref1}/>
<ReactClassComponent ref={ref2}/>
</div>)
}
当我们给类组件ref属性赋值时,ref不会作为参数进入子组件的props中,因为它代表的是子组件本身。
class ReactClassComponent extends React.Component {
console.log(this.props.text) // hello world
console.log(this.props.ref) // null
render() {
return <div>ReactClassComponent</div>
}
}
function App(){
const myRef = useRef();
return <ReactClassComponent ref={myRef} text="hello world"/>
}
将ref向下级传递
类组件在接收到从ref属性传来的myRef对象时,不会将它作为普通的属性处理,并且不会将ref对象传到组件那。可如果我们这时的目的是传递而不是绑定呢?在写出<ReactClassComponent ref={myRef} text="hello world"/>
的时候,我们可能是想把组件实例与myref对象绑定,也可能只是单纯的想将myRef对象传递到组件中使用。这时,普通的组件将无法做到,我们需要使用React提供给我们的生成器去生成一个组件
const Child = React.forwardRef((props, ref) => {
console.log(ref.current); // <button>click</button>
return <button ref={ref}>click</button>;
});
export default function App(){
const myRef = useRef();
return <Child ref={myRef}/>
}
通过React.forwardRef生成的组件可以接受ref方法,并且将它传入生成的组件中,而非直接与自身绑定。上面的方式,就成功的将子组件中的button元素与父级定义的ref对象所绑定上了。
自定义与ref绑定的对象
到目前为止,能绑定到ref对象上的有类组件、原生dom元素。其实,我们可以自定义能被ref绑定的对象
function App(){
const myRef = useRef();
useImperativeHandle(myRef, () => ({
click: () => {
console.log('click')
}
}));
console.log(myRef)
return <div>app</div>
}
此时ref对象在控制台的输出如下
{
current: {
click: () => { console.log('click') }
}
}
useImperativeHandle接受一个ref对象和一个生成函数,函数的返回即为与ref对象绑定的对象。通过这个方法,我们无需将整个dom对外暴露,而只暴露我们所规定的一些方法。
在不使用useImperativeHandle的时候,我们想将组件中的input对父级暴露
const FancyInput = React.forwardRef((props, ref) => {
return <input ref={ref} ... />;
})
function App(){
const myRef = useRef();
return <FancyInput ref={myRef}/>
}
此时,在父级可以直接拿到input的dom元素去做任何操作,而有时,我们可能只是想暴露该dom元素的一些原生方法,例如focus。所以我们大可不必将整个dom元素暴露
const FancyInput = React.forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} ... />;
})
function App(){
const myRef = useRef();
return <FancyInput ref={myRef}/>
}
此时,在App中只能使用我们所规定的方法。