React Hooks秘籍之从入门到入门

Hooks的官方定义

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

也就是React版本 >= 16.8就可以使用Hooks的方式去编写React代码,有一个前提条件,就是必须理解闭包,文中会提到,不理解的话会对组件的行为感到相当疑惑。

useState

在没有Hooks之前,函数组件的问题在于只能“纯粹”的定义,没有内部状态,只能接受外部传入的外部数据,useState解决的就是内部状态。

const App = () => {
  const [num, setNum] = useState(0);
  console.log(num);
  return (
    <div className="App">
      <span>{num}</span>
      <button onClick={() => setNum(num + 1)}>add</button>
    </div>
  );
};
// 页面会渲染出0
// 每次点击按钮时,页面会重新渲染并更新输出num+1后的最新值
复制代码

这段代码,相当于在Class组件中,定义了state,点击按钮后将num+1,重新渲染页面并输出最新的num值。和class组件不同的是,函数组件每次重新渲染会将函数从头执行一遍,class组件只会执行对应的声明钩子和render函数(不了解Class组件的可以先学习下,这里有一篇站内的 React食用一条龙服务,可以不用,但需要理解)

简易实现customUseState

通过一个"不太成熟"的例子,尝试去理解useState的原理

const _states = []; // 用于存储state
let _index = 0; // 用于存初始化时的下标
const useCustomUseState = (initialValue) => {
  const [_, reRender] = useState({}); // 这个不是重点,先不用管,当成能刷新组件的一个工具函数看
  const currentIndex = _index; // 将当前的index保存
  _index += 1; // 将i+1
  _states[currentIndex] = _states[currentIndex] || initialValue;
  // 判断当前下标元素是否有值,用与初始化state和后续函数重新渲染执行时更新_state
  const setState = (newValue) => {
    // 函数用于更新state值
    _states[currentIndex] = newValue;
    _render();
  };
  const _render = () => {
    // 只要更新了值,就把index重置,因为app会重新渲染,再按顺序执行useCustomUseState函数
    // 只有重置了才能按照执行顺序将_state中的值按顺序再次取出
    _index = 0;
    reRender({});
  };
  // 将当前传入的state和更新的state返回给使用者
  return [_states[currentIndex], setState];
};

function App() {
  const [num, setNum] = useCustomUseState(0);
  const [aaa, setAaa] = useCustomUseState(111);
  // 这里我们使用自己实现的useState
  console.log(num);
  console.log(aaa);
  return (
    <div>
      {num}
      <button onClick={() => setNum(num + 1)}>add one </button>
      <hr />
      {aaa}
      <button onClick={() => setAaa(aaa + 1)}>add one </button>
    </div>
  );
}
复制代码

此时点击两个按钮,都会把对应的值给+1,并且能重新渲染出最新的state值。代码中const currentIndex = _index;,这段赋值很关键,将当前执行的下标缓存起来,currentIndex变量和即将return出去的更新函数形成了一个闭包,这样即使_index变化了,currentIndex永远都是运行函数当时得到的_index,而React将例子中对应的_states_index保存在组件节点上。
当理解了这个基础实现后,再去看useState的感觉就通透很多了。

useState使用需要注意的有几点

  1. 不要尝试在当前的业务函数中,去获得更新后的值
  2. useState的执行顺序每次re-render都要保证一样,也就是不能写在if else里,否则React会报错
  3. useState不会像Class组件那样自动合并state,需要手工操作
  4. useState可以接收一个函数去进行更新操作

1. 不要尝试在当前的业务函数中,去获得更新后的值

function App() {
  const [num, setNum] = useCustomUseState(0);
  const addNum = () => {
    setNum(num + 1);
    setTimeout(() => { console.log("num:" + num); }, 1000)
  };
  return <button onClick={addNum}>add one </button>
}
复制代码

这段代码点击按钮1s后,输出的值永远都会是更新前的值。代码是同步执行的,但是setState更新操作会重新执行App这个函数(不用想的太复杂,把App看成普通函数),当在更新前,执行了setTimeout函数,当前组件更新前的num变量和setTimeout传入的箭头函数又形成了一个闭包,相当于在更新后会存在两个"num"变量,旧"num"0,而新"num"1,箭头函数拿到的是旧的"num"变量,所以点击按钮1秒后,输出的num值会是0。(又是闭包又是闭包又是闭包。。。。。。)

2. useState的执行顺序每次render都要保证一样,也就是不能写在if else里,否则React会报错

  const [num, setNum] = useState(0);
  if(num === 0){
    const [aaa, setAaa] = useState(111);
  }
  const [c, setC] = useState(0);
  
// React的报错如下
// React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render
复制代码

用我们自己实现的简易useState来思考,如果执行的顺序不保证,在第一次执行函数时,调用了3useState,返回值都正确,存数据的_states数组此时长度是3。可第二次执行函数调用useState的次数是次,c变量原本应该取_states[2]的,但因为条件不满足少执行了一次useState,导致执行到返回c的函数时,拿到的是_states[1],就会导致刷新后的数据问题。

3.useState不会像Class组件那样自动合并state,需要手工操作

function App() {
  const [person, setPerson] = useState({
    name: "libai",
    age: 89,
  });
  return (
    <div>
      {person.name}
      {person.age}
      <button onClick={() => setPerson({ age: 100 })}>changeAge</button>
    </div>
  );
}
复制代码

当点击按钮后,会发现页面中的libai不见了,因为执行更新传入的对象中,并没有name属性,传入的属性会覆盖掉旧值,不会自动合并,重新渲染后person对象的值是{ age:100 }person.nameundefined,虽然是合法的,但不会输出任何东西。只要利用...展开操作符或者Object.assign,将旧对象的键值全部提取出来,再赋值新的值,新的值含有旧值的一切属性。

function App() {
  const [person, setPerson] = useState({
    name: "libai",
    age: 89,
  });
  const changePerson = ()=>{
      // setPerson({ ...person,age: 100 });
      // 效果等价,则一使用
      const newPerson = Object.assign({},person,{age:100});
      setPerson(newPerson);
  }
  return (
    <div>
      {person.name}
      {person.age}
      <button onClick={changePerson}>changeAge</button>
    </div>
  );
}
复制代码

4. useState可以接收一个函数去进行更新操作

function App() {
  const [person, setPerson] = useState({
    name: "libai",
    age: 89,
  });
  const changePerson = () => {
    setPerson({ ...person, age: person.age + 10 });
    setPerson({ ...person, age: person.age + 10 });
    setPerson({ ...person, age: person.age + 10 });
    setPerson({ ...person, age: person.age + 10 });
  };
  return (
    <div>
      {person.name}
      {person.age}
      <button onClick={changePerson}>changeAge</button>
    </div>
  );
}
复制代码

这段代码中,点击按钮后,代码看似会把89执行4次+10操作得到129,但实际得到的结果是89,导致的原因是闭包(又是tm的闭包)。前面讲过,代码是同步执行,但更新操作是异步的,setPerson执行了4次,传入的参数对象person.age获取到的都是89这个值,person对象和changePerson函数形成了一个闭包,也就是4个函数传入的对象的age都是+10后的99。如果想解决这个问题,可以使用传入函数的方式去更新state。将点击事件进行细微的改动

  const changePerson = () => {
    setPerson((person=> ({ ...person, age: person.age + 10 })));
    setPerson((person=> ({ ...person, age: person.age + 10 })));
    setPerson((person=> ({ ...person, age: person.age + 10 })));
    setPerson((person=> ({ ...person, age: person.age + 10 })));
  };
复制代码

函数的第一个参数是"当前"state的值,这个"当前"并非指组件内state的值(person变量),就是实打实的state最新的值。执行第一次setPerson时,stateage89,更新了对象;第二次setPerson时,stateage99,以此类推最终组件更新后获取的age就是129。理论上,优先使用这种更新方式,脑壳可以少疼点:)

useReducer

其实就是复杂版的useState,通过某些限定操作,来达到“规范更新”的目的,useReducer接受一个函数和一个初始值,得到读和写的api,直接上代码

const initialValue = {
  name: "",
  age: 0,
};

const reducer = (state, { type, payload }) => {
  switch (type) {
    case "changeName":
      return {
        ...state,
        name: payload.name,
      };
    case "changeAge":
      return {
        ...state,
        age: payload.age,
      };
    default:
      throw new Error("你tm传进来的type是什么东西");
  }
};

function App() {
  console.log("app running");
  const [state, dispatch] = useReducer(reducer, initialValue);

  const submitForm = (evt) => {
    evt.preventDefault();
    console.log(state);
  };

  return (
    <form onSubmit={submitForm}>
      <label htmlFor="nage">
        姓名:
        <input
          id="name"
          value={state.name}
          onChange={(evt) =>
            dispatch({
              type: "changeName",
              payload: { name: evt.target.value },
            })
          }
        />
      </label>

      <label htmlFor="age">
        年龄:
        <input
          type={"number"}
          id="age"
          value={state.age}
          onChange={(evt) =>
            dispatch({
              type: "changeAge",
              payload: { age: evt.target.value },
            })
          }
        />
      </label>
      <button onClick={()=>dispatch({type:"have luncher"})}>test Error</button>
      <button type={"submit"}>submit</button>
    </form>
  );
}
复制代码
  • useReducer传入函数和初始值后,返回值和useState类似,数组第一项是初始化好的state,第二项则是用于更新state的函数
  • 在名字输入框和年龄输入框修改时,实际上都触发了一次组件重新渲染,在App函数体中加了一句打印,每次输入时都会输出打印,而更新的操作是通过dispatch函数去触发的(理解为setState),接着就会去执行一开始我们定义好的reducer函数并将state值和dispatch的入参二次传递传入,按照预先定义好的规则去更新当前state
  • 当点击submit按钮时,控制台会输出当前state对象的值
  • 当传入错误的type时,比如代码中的testError按钮的点击事件,就会抛出预先定义好的异常

简而言之,就是可以利用useReducer去更加规范的管理复杂的内部状态变更(复杂版useState

useEffect

这个hooks会在页面渲染后执行相对应的回调,可以实现Class组件中,componentDidMountcomponentDidUpdatecomponentWillUnmout这三个生命周期的作用。
它接受一个函数和一个可选参数(数组)作为参数,一个函数中存在多个useEffect,那么会按照出现的顺序依次去执行。

模拟componentDidMount作用

第二个参数,传入一个空数组表示无任何依赖,那么在App被执行重新渲染时,也不会再次执行函数中的内容

function App() {
  const [num, setNum] = useState(0);
  useEffect(() => {
    console.log("effect running");
    const divEle = document.querySelector("#hook");
    divEle.style.color = "red";
  }, []);
  return (
    <>
      {num}
      <button onClick={() => setNum(num + 1)}>re-render</button>
      <div id="hook">hi,hooks</div>
    </>
  );
}
复制代码
  • 页面渲染后,会获取idhookdiv并将其stylecolor属性改为red,因为能拿到dom元素,也能作证它的执行时机时在页面渲染后,没有渲染的话拿个寂寞么: )
  • 点击re-render按钮后,会更新num的值并再次执行App函数,观察控制台"effect running"只会打印一次,无论点击多少次re-render按钮,都不会再执行useEffect的回调函数

模拟componentDidUpdate作用

第二个参数,传入一个带有依赖值的数组,用来表示当依赖数组中的某一项发生了变更后,再次执行回调函数,实例代码中,传入了num作为依赖项

function App() {
  const [num, setNum] = useState(0);
  useEffect(() => {
    console.log("effect running");
  }, [num]);
  return (
    <>
      {num}
      <button onClick={() => setNum(num + 1)}>re-render</button>
    </>
  );
}
复制代码
  • 页面渲染后,会获取idhookdiv并将其stylecolor属性改为red,因为能拿到dom元素,也能作证它的执行时机时在页面渲染后,没有渲染的话拿个寂寞么: )
  • 除了页面渲染后会打印一次以外,每次点击re-render按钮,会更新num的值,而因为传入了num作为依赖项,所以回调函数被再次执行了
当依赖项不传时,相当于任何state被更新后,都会执行回调函数
  useEffect(() => {
    console.log("effect running");
  });
复制代码

按照当前预期,传入依赖项和不传的表现一致,点击按钮都会再打印一次log

模拟componentWillUnmount作用

function App() {
  const [childVisible, setChildVisible] = useState(true);
  return (
    <>
      <button onClick={() => setChildVisible(!childVisible)}>toggle</button>
      {childVisible && <ChildTest />}
    </>
  );
}

function ChildTest() {
  const [num, setNum] = useState(0);
  useEffect(() => {
    console.log("effect running");
    return () => {
      console.log("component unmount");
    };
  }, [num]);

  return (
    <>
      {num}
      <button onClick={() => setNum(num + 1)}>re-render</button>
    </>
  );
}
复制代码

useEffect回调函数中,return的函数,会在组件被卸载时执行,上面的代码中,每次点击按钮,都会卸载(重新渲染也可以认为是另一种形式的卸载)页面再重新渲染,点击toggle按钮,当childVisiblefalse时不满足条件则不会渲染ChildTest组件,那么卸载也会执行return的函数

useLayoutEffect

这个hooks的执行时机在浏览器渲染前,也就是在浏览器能看到div之前去执行

graph TD
useLayoutEffect --> 浏览器执行渲染 --> useEffect
function App() {
  useEffect(() => {
    console.log("useeffect");
  }, []);

  useLayoutEffect(() => {
    console.log("useLayoutEffect");
    document.querySelector("#text").innerHTML = "一二三四五六七八九十";
  }, []);
  return (
    <>
      <div id="text">123456789</div>
    </>
  );
}
复制代码

这段代码,在App被渲染时,先输出了console.log("useLayoutEffect");,接着把页面idtext的文档内容改为一二三四五六七八九十,此时文档对象(document)已在内存中,只是没有渲染到页面视图上,接着再执行useEffect的输出console.log("useeffect");

推荐始终使用useEffect,除非真的有要在页面渲染前改变layout的需求

useContext

context用于表示上下文,全局变量是全局的上下文,上下文是局部的全局变量

Context我已经写过博客便不重讲了,可以通过这个站内链接食用 React中使用Context的3种方式 安全无毒:)

做个类比来说就是用提供的容器去指定提供范围,为范围内的所有组件提供 食材,餐具,燃气,锅碗瓢盆,九阴真经,易经 等 ,扣门的话可以只提供锅碗瓢盆,食材让消费者自己买,烹饪让消费者自己学。被包裹的任意组件都可以共享使用容器提供的事物(对象)和行为(方法)。

需要注意,如果修改共享的值,不会触发刷新(不是响应式)

Context 可以代替 Redux

useRef

如果需要一个值在组件不断render时,保持唯一不变,可以使用useRef,可以用于存储Dom对象,也可以存储任意对象,这个存储的值不会因组件更新而改变,返回值是{current:x},对象的current属性存放着目标值,为什么需要包一层current?因为只有对象才能保持地址不变来实现永远访问的都是同一个对象。

引用对象的示例

function App() {
  const crf = React.createRef();
  const urf = useRef();
  const showAllRef = () => {
    console.log(crf.current);
    console.log(urf.current);
  };
  return (
    <>
      <div ref={crf}>createRef</div>
      <div ref={urf}>useRef</div>
      <button onClick={showAllRef}>showAllRef</button>
    </>
  );
}
复制代码

代码中用createRefuseRef来分别引用dom元素,点击showAllRef按钮时,都会将对应dom实例打印在控制台,此时他们效果是等价的,但是在函数组件当中使用createRef有个问题,因为每次组件被刷新时,相当与又执行了一遍App函数,而urf变量会被createRef函数的返回值再次赋值,如果用来存储可变对象则不要使用creatRef,这点需要注意。

useRef来维持状态且不触发刷新,示例中会统计点击add按钮的次数,可以当作一个组件存活期间的组件内的全局对象来使用(类比只存在这个组件中的window对象)

function App() {
  let count = useRef(0);
  const [num, setNum] = useState(100);
  useEffect(() => {
    if (num > 100) {
      count.current += 1;
    }
  }, [num]);
  return (
    <>
      {num}
      <button onClick={() => setNum(num + 100)}>add 100</button>
      <button onClick={() => console.log(count.current)}>showCount</button>
    </>
  );
}
复制代码

forwardRef 讲到ref,就得提一下forwardRef

函数组件中,props无法传递ref引用,强行传递会抛出警告

react-dom.development.js:67 Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
复制代码

这时候就需要通过React.forwardRef

const App = () => {
  const buttonRef = useRef(null);

  const clickChild = () => {
    buttonRef.current.click();
  };
  return (
    <>
      <Child ref={buttonRef} />
      <button onClick={clickChild}>点击我会调用子组件的click事件</button>
    </>
  );
};

const Child = React.forwardRef((props, ref) => {
  return (
    <div>
      <button ref={ref} onClick={() => console.log("click")}>
        会被父组件引用的button
      </button>
    </div>
  );
});
复制代码

示例中不管点击App组件中的按钮还是Child组件的按钮,都是相当于点击Child组件的buttonforwardRef会返回一个能接收ref的组件,一般返回组件的函数称为高阶组件。然后进行ref的转发,Child组件就能从第二个参数获得这个ref的引用,并用于button的示例对象,此时buttonRef.current === 子组件的button实例对象

useImperativeHandle

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应当与 forwardRef 一起使用。

意思就是ref返回给父组件是啥玩意,完全自己决定。正常情况下,ref.current是目标dom实例对象,下面使用useImperativeHandle自定义了ref的返回值,父组件需要通过ref.current.amd获取dom的引用,并且还返回了一个额外的msg字段

const App = () => {
  const buttonRef = useRef(null);

  const clickChild = () => {
    console.log(buttonRef.current);
  };
  return (
    <>
      <Child ref={buttonRef} />
      <button onClick={clickChild}>点击我会打印 子组件的ref对象</button>
    </>
  );
};

const Child = React.forwardRef((props, ref) => {
  const buttonRef = createRef(null);
  useImperativeHandle(ref, () => {
    return {
      msg: "你是啥玩意,不允许引用,算了,还是给你吧,用amd字段读吧",
      amd: buttonRef.current,
    };
  });
  return (
    <div>
      <button ref={buttonRef} onClick={() => console.log("click")}>
        会被父组件引用的button
      </button>
    </div>
  );
});
复制代码

点击打印按钮,可以在控制台看到{msg:"xxx",amd:button Instance}

React.memo && React.useMemo && useCallback

要理解useMemouseCallback,就要先理解React.mome

下面实例,点击changeState按钮,虽然传入Childprops没有改变,但依然被触发更新了,如何做到只有传入的依赖的变化才更新来减少不必要的更新呢

function App() {
  const [childProps] = useState(0);
  const [text, setText] = useState("text");
  return (
    <>
      <div>{text}</div>
      <button onClick={() => setText(text + "text")}>changeState</button>
      <hr />
      <Child childProps={childProps} />
    </>
  );
}

const Child = (props) => {
  console.log("我运行了");
  return <div>child:props.m:{props.childProps}</div>;
};
复制代码

React.memo包裹后的高阶组件,默认会对props进行浅层比较,当app进行render时,会对比两次props,如果相同则不会再更新,可以传入第二个参数进行控制对比结果,可以看下自定义对比函数的Typescript定义,接受两个参数,旧的props和新的props,返回boolean决定是否更新组件

propsAreEqual?: (prevProps: Readonly<PropsWithChildren<P>>, nextProps: Readonly<PropsWithChildren<P>>) => boolean
复制代码

函数返回true则不再进行更新,返回false则更新

const Child = (props) => {
  console.log("我运行了");
  return <div>child:props.m:{props.childProps}</div>;
};

function App() {
  const [childProps] = useState(0);
  const [text, setText] = useState("text");
  return (
    <>
      <div>{text}</div>
      <button onClick={() => setText(text + "text")}>changeState</button>
      <hr />
      <Child childProps={childProps} />
    </>
  );
}

const Child = React.memo((props) => {
  console.log("我运行了");
  return <div>child:props.m:{props.childProps}</div>;
});
复制代码

但是这个方案有个Bug,如果props传入了一个函数,就一键破功,原因是父组件更新时,函数又被重新赋值,虽然功能一样,但是两个不同的函数,导致子组件又被重新渲染,这时候就需要useMemo登场,它的使用类似Vue中的计算属性Computer,会把值缓存起来,只有依赖变了才会重新计算,useMemo第一个参数接受一个函数,返回值就是缓存结果,第二个参数是依赖数组,只有依赖项变化才会重新计算。

function App() {
  const [childProps] = useState(0);
  const [text, setText] = useState("text");
  const fn = useMemo(() => {
    return () => {};
  }, [childProps]);
  return (
    <>
      <div>{text}</div>
      <button onClick={() => setText(text + "text")}>changeState</button>
      <hr />
      <Child childProps={childProps} setText={fn} />
    </>
  );
}
复制代码

现在点击按钮父组件更新时,子组件也不会被更新,因为fn被缓存,指向同一个函数地址,useMemo不单单缓存函数,任何数据都可以缓存,如果觉得缓存函数写法有点绕,需要写一个函数再返回一个函数,于是有了语法糖useCallback,不需要在函数里返回函数,只需要传入函数即可,对比useMemo相当于少写了一层

// react实现了一个语法糖
function App() {
  const [childProps] = useState(0);
  const [text, setText] = useState("text");
  const fn = useCallback(() => {}, [childProps]);
  return (
    <>
      <div>{text}</div>
      <button onClick={() => setText(text + "text")}>changeState</button>
      <hr />
      <Child childProps={childProps} setText={fn} />
    </>
  );
}
复制代码

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

自定义Hooks

最后说说自定义Hooks,其实就是和拼积木一样,如何将状态逻辑抽离封装和使用,完全取决是玩家自己,属于Hooks的灵魂。

const useUserList = () => {
  const [list] = useState([]);
  const [loading] = useState(true);
  useEffect(() => {
    // 假设这里发起了一个Http请求一个列表
    setTimeout(() => {
      setList([1, 2, 3]);
      setLoading(false);
    }, 2000);
  }, []);

  return [list, loading, setList, setLoading];
};

const App = () => {
  const [userList, loading] = useUserList();
  return (
    <>
      {userList.length
        ? userList.map((user) => <span key={user}>用户:{user}</span>)
        : null}
      {loading && <div>加载中</div>}
    </>
  );
};
复制代码
  • 这里封装了一个自定义hook取名为useUserListReact有规定hooks必须以use开头命名,这里只是为了展示demo功能,实际上完全可以新建一个hooks的文件夹,针对每个功能的hook进行封装然后导出,在需要使用的组件中,我们只需要引入这个hook函数,并且调用一下就能得到我们想要的数据和行为,和写class组件相比,状态与组件进行了拆分,代码也能得到复用。
  • demo模拟了一个http请求,两秒后返回数据,只所以能获取数据并更新组件,是因为hook中的setState实际是在组件中执行,写在组件外,执行在组件里。
  • 值得一提的是,hook的使用只能在函数组件或者use开头的函数当中,否则React会报错。

看到这里,相信对React Hooks的特性已经"入门"了,感谢收看:)

Guess you like

Origin juejin.im/post/7067530176615153695