手写react12-hook

109 阅读7分钟

useState:

//这是一个全局变量,用来记录hook的值
let hookState = [];
//存放当前hook的索引值
let hookIndex = 0;

export function useState(initialState){
    hookState[hookIndex]=hookState[hookIndex]||initialState;//hookState[0]=10
    let currentIndex = hookIndex;
    function setState(newState){
        hookState[currentIndex]=newState;//currentIndex指向hookIndex赋值的时候的那个值 0
        scheduleUpdate();//状态变化后,要执行调度更新任务
    }
    return [hookState[hookIndex++],setState];
}
let scheduleUpdate;
function render(vdom, parentDOM) {
    mount(vdom, parentDOM);    
    //在React里不管在哪里触发的更新,真正的调度都是从根节点开始的
    scheduleUpdate = ()=>{
        hookIndex = 0;//把索引重置为0
        //从根节点执行完整的dom-diff 进行组件的更新
        compareTwoVdom(parentDOM,vdom,vdom);
    }
}

注:

1、第一次render完后,hookState存储了从根节点到各级最底层子节点所有的状态

2、每触发任何一次更新,都会从根节点开始更新,典型的场合是请求成功后、事件回调里setXXX了一堆钩子,然后就会走到scheduleUpdate,重置hookIndex到0,再通过compareTwoVdom对比dom进行更新

3、更新时hookState里已经存了所有索引对应的state了,所以走到组件里时,再调用useState就会拿到最新的值,具体代码体现在:

hookState[hookIndex]=hookState[hookIndex]||initialState;

memo、useMemo、useCallback:

组件的props的值不改变时,我们不希望组件被再次渲染,这种情况下,我们需要用到React.memo,例如下面的例子中:

function Child({data,handleClick}){
  console.log('child render');
  return <button onClick={handleClick}>{data.number}</button>
}
function App(){
  const [name,setName] = React.useState('zhufeng');
  const [number,setNumber] = React.useState(0);
  //缓存对象的 第1个参数是创建对象的工厂函数,第2个参数是依赖变量的数组,如果依赖数组中的任何一个变量发生改变,就会重新调用工厂方法创建新的对象,则否则就会重用上次的对象
  let data = {number};
  //缓存回调函数的
  let handleClick = ()=>setNumber(number+1);
  return (
    <div>
      <input type="text" value={name} onChange={event=>setName(event.target.value)}/>
      <Child data={data} handleClick={handleClick}/>
    </div>
  )
}
ReactDOM.render(<App/>, document.getElementById('root'));

用户在input中输入数字的时候,传给Child组件的number属性和handleClick属性并没有改变,但通过测试我们发现在input中输入内容的过程中,console.log('child render');还是会执行

为了避免这种情况下的重复渲染,用了React.memo包装起来:

function Child({data,handleClick}){
  console.log('child render');
  return <button onClick={handleClick}>{data.number}</button>
}
let MemoChild = React.memo(Child);
function App(){
  const [name,setName] = React.useState('zhufeng');
  const [number,setNumber] = React.useState(0);
  //缓存对象的 第1个参数是创建对象的工厂函数,第2个参数是依赖变量的数组,如果依赖数组中的任何一个变量发生改变,就会重新调用工厂方法创建新的对象,则否则就会重用上次的对象
  let data = {number};
  //缓存回调函数的
  let handleClick = ()=>setNumber(number+1);
  return (
    <div>
      <input type="text" value={name} onChange={event=>setName(event.target.value)}/>
      <MemoChild data={data} handleClick={handleClick}/>
    </div>
  )
}
ReactDOM.render(<App/>, document.getElementById('root'));

但是,经过上述修改之后,可以发现,并没有起到作用,原因是data是对象类型,handleClick是函数,每次重新渲染时,它们都有不同的引用,因此,react还会认为这是两个不一样的对象,还是会重新渲染

这时就需要用useCallback和useMemo来改造:

function Child({data,handleClick}){
	console.log('Child render');
  return <button onClick={handleClick}>{data.number}</button>
}
//可缓存的Child,如果一个组件它的属性没有变化,就不会重新渲染
let MemoChild = React.memo(Child);
function App(){
  console.log('App render');
  const [name,setName] = React.useState('zhufeng');
  const [number,setNumber] = React.useState(0);
  //缓存对象的 第1个参数是创建对象的工厂函数,第2个参数是依赖变量的数组,如果依赖数组中的任何一个变量发生改变,就会重新调用工厂方法创建新的对象,则否则就会重用上次的对象
  let data = React.useMemo(()=>({number}),[number]);
  //缓存回调函数的
  let handleClick = React.useCallback(()=>setNumber(number+1),[number]);
  return (
    <div>
      <input type="text" value={name} onChange={event=>setName(event.target.value)}/>
      <MemoChild data={data} handleClick={handleClick}/>
    </div>
  )
}
ReactDOM.render(<App/>, document.getElementById('root'));

useReducer:

import React from './react';
import ReactDOM from './react-dom';
function reducer(state={number:0}, action) {
  switch (action.type) {
    case 'ADD':
      return {number: state.number + 1};
    case 'MINUS':
      return {number: state.number - 1};
    default:
      return state;
  }
}

function Counter(){
    const [state, dispatch] = React.useReducer(reducer,{number:0});
    return (
        <div>
          Count: {state.number}
          <button onClick={() => dispatch({type: 'ADD'})}>+</button>
          <button onClick={() => dispatch({type: 'MINUS'})}>-</button>
        </div>
    )
}
ReactDOM.render(
  <Counter/>,
  document.getElementById('root')
);
export function useReducer(reducer,initialState){
    hookState[hookIndex]=hookState[hookIndex]||initialState;//hookState[0]=10
    let currentIndex = hookIndex;
    function dispatch(action){
        action = typeof action === 'function'?action(hookState[currentIndex]):action;
        hookState[currentIndex]=reducer?reducer(hookState[currentIndex],action):action;
        scheduleUpdate();//状态变化后,要执行调度更新任务
    }
    return [hookState[hookIndex++],dispatch];
}

useContext:

function useContext(context){
  return context._currentValue;
}

useEffect:

useEffect里的函数会在当前的组件渲染到页而之后执行

useEffect不会阻塞当前页面的渲染

如果没有添加依赖,则每次组件更新时都会执行这个effect

这种情况下虽然可以拿到最新的number值,但会重新再开一个定时器,导致页面效果混乱

import React from 'react';
import ReactDOM from 'react-dom';

function Counter(){
  const [number, setNumber] = React.useState(0);
  React.useEffect(()=>{
    const timer = setInterval(()=>{
      setNumber(number+1);
    },1000);
  // 如果没有添加依赖,则每次组件更新时都会执行这个effect
  });
  return <div>{number}</div>
}

ReactDOM.render(<Counter/>, document.getElementById('root'));

添加空的依赖之后,就可以保证这个effect只执行一次,这样定时器就可以只开一个

但是受到闭包的影响,这里的number每次都是0,所以没法自增

import React from 'react';
import ReactDOM from 'react-dom';

function Counter(){
  const [number, setNumber] = React.useState(0);
  React.useEffect(()=>{
    const timer = setInterval(()=>{
      setNumber(number+1);
    },1000);
  },[]);
  return <div>{number}</div>
}

ReactDOM.render(<Counter/>, document.getElementById('root'));

有一种更改的办法是给setNumber传函数,基于老值计算新值

import React from 'react';
import ReactDOM from 'react-dom';

function Counter(){
  const [number, setNumber] = React.useState(0);
  React.useEffect(()=>{
    const timer = setInterval(()=>{
      // 基于老值计算新值
      setNumber(number=>number+1);
    },1000);
  },[]);
  return <div>{number}</div>
}

ReactDOM.render(<Counter/>, document.getElementById('root'));

也可以在组件销毁时将定时器也取消,下面的写法中,每次更新时effect也都会执行,定时器会重新开启,但会被销毁,下次再开新的定时器

组件销毁时机是在下一次更新的时候销毁,即组件第一次渲染时,console中log的顺序为:

Counter render

开启定时器

执行定时器

下一次渲染时,console中log的顺序为:

Counter render

销毁定时器

开启定时器

执行定时器

function Counter(){
  console.log('Counter render');
  const [number,setNumber] = React.useState(0);
  React.useEffect(()=>{
    console.log('开启定时器');
    const timer = setInterval(()=>{
      console.log('执行定时器');
      setNumber(number=>number+1);
    },1000);
    return ()=>{
      console.log('销毁定时器');
      clearInterval(timer)
    } 
  });
  return <div>{number}</div>
}

ReactDOM.render(<Counter/>, document.getElementById('root'));
export function useEffect(effect,deps){
     //先判断是不是初次渲染
  if(hookState[hookIndex]){
    let [lastDestroy,lastDeps] = hookState[hookIndex];
    let same = deps&&deps.every((item,index)=>item === lastDeps[index]);
    if(same){
        hookIndex++;
    }else{
        //如果有任何一个值不一样,则执行上一个销毁函数
        lastDestroy&&lastDestroy();
        //开启一个新的宏任务
        setTimeout(()=>{
            let destroy = effect();
            hookState[hookIndex++]=[destroy,deps]
        });
    }
  }else{
      //如果是第一次执行执行到此
      setTimeout(()=>{
          let destroy = effect();
          hookState[hookIndex++]=[destroy,deps]
      });
  }
}

useLayoutEffect:

export function useLayoutEffect(effect,deps){
    //先判断是不是初次渲染
 if(hookState[hookIndex]){
   let [lastDestroy,lastDeps] = hookState[hookIndex];
   let same = deps&&deps.every((item,index)=>item === lastDeps[index]);
   if(same){
       hookIndex++;
   }else{
       //如果有任何一个值不一样,则执行上一个销毁函数
       lastDestroy&&lastDestroy();
       //开启一个新的宏任务
       queueMicrotask(()=>{
           let destroy = effect();
           hookState[hookIndex++]=[destroy,deps]
       });
   }
 }else{
     //如果是第一次执行执行到此
     queueMicrotask(()=>{
         let destroy = effect();
         hookState[hookIndex++]=[destroy,deps]
     });
 }
}

通过一个简单的案例,来区分useEffect和useLayoutEffect

作者:buuug
链接:https://zhuanlan.zhihu.com/p/348701319\
来源:知乎

import React, { useEffect, useLayoutEffect, useState } from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
    const [state, setState] = useState("hello world")
    
    useEffect(() => {
        let i = 0;
        while(i <= 100000000) { i++; };
        setState("world hello");
    }, []);
    
    // useLayoutEffect(() => {
    //   let i = 0;
    //   while(i <= 100000000) {
    //     i++;
    //   };
    //   setState("world hello");
    // }, []);
    
    return ( <> <div>{state}</div> </> );
}
export default App;

上面的案例中,如果使用useEffect,页面会先渲染初始的hello world,在useEffect里的while循环卡了一段时间后,再渲染出来world hello,即useEffect是异步执行的

通过测试,发现有一个点需要注意,这个案例必须要用ReactDOM.createRoot(xxx).render(xxx)的方式测试才有效果,即在concurrent模式下才会体现出效果,主要原因是因为在react17中,或者说在非concurrent模式下,react内部虽然也有调度器,但是它是以sync同步的形式工作的,因此在layout阶段后执行的遍历useEffect回调也是以同步方式执行

而如果使用useLayoutEffect,则不会出现先渲染hello world,再渲染world hello的情况,而是直接渲染出来了world hello,即useLayoutEffect是同步执行的

useImperativeHandle:

import React from './react';
import ReactDOM from './react-dom';

function Child(props,forwardRef){
  const inputRef = React.useRef();
  //这个方法可以定制暴露给父组件ref值 forwardRef.current = {focus}
  React.useImperativeHandle(forwardRef,()=>{
    return {
      focus(){
        inputRef.current.focus();
      }
    }
  });
  return <input ref={inputRef}/>
}

let ForwaredChild = React.forwardRef(Child);

function Parent(){
  const inputRef = React.useRef();//{current:undefined}
  const getFocus = ()=>{
    inputRef.current.focus();
    //如果子组件的把自己的内部的真实DOM完整暴露给父组件的,父组件可以对此DOM元素进行任何操作
    //inputRef.current.remove();
    //inputRef.current.value = 'xx';
  }
  return (
    <div>
      <ForwaredChild ref={inputRef}/>
      <button onClick={getFocus}>获得焦点</button>
    </div>
  )
}

ReactDOM.render(<Parent/>, document.getElementById('root'));
export function useImperativeHandle(ref,handler){
    ref.current = handler();
}