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中只能使用我们所规定的方法。