阅读 464

React 还不会用 Hooks 你就赶不上时代了!(一)

一、前言

自从去年 React 16.8 带着它独有的 Hooks 闯入我的世界,我就被这种简洁有趣的语法和实现方案所深深吸引。

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

但是,在网络上的项目实践中,却很少能找到 hooks 比较全面的教程(包括性能优化,最佳实践等等),就像盖一栋楼,大部分教程或者文章都是在提供盖楼的砖块,却没有好好教你如何盖一整栋楼。

React 官方对这方面的文档也是不怎么友好,你或许知道 hooks 的存在,或许知道怎么用 hooks 写出自己想要的页面或者组件,但却不知道怎么使用它们构建一个完整的项目,而且还要能够避免多余的渲染或者延时。

和 Vue 比较不同的是,开发者需要掌握好 JS 的使用才能更好地驾驭 React,如果你是:

  • 没写过 React 但是想用 React 框架写项目的前端开发者;
  • 会写 Class Component 模式的 React,想学习 hooks 用法的前端开发者;

那么这篇文章可以让你轻松使用 hooks 并且用它们来编写项目页面。

文章绝大部分不会涉及到源码分析的内容,对于刚入门的新手也十分友好。

二、Hooks 介绍与使用

介绍

这里的 hooks 使用介绍篇幅稍微有点长,可以暂时不看直接看后篇(虽然现在没有),遇到不理解的使用情况再回来看使用方法。

hooks 是 React 函数式组件编程中最核心的内容,众所周知像数据驱动 mvvm 模式下的前端框架一般都会涉及两个重要的组成部分——状态管理生命周期。而在一个 React 纯函数式组件中,它这样就可以直接运作

  function Component () {
      return (<div>test</div>)
  }
复制代码

而在 Class Component 中你需要在类里面声明定义 constructor 以及各种生命周期等等,这些都是原本纯函数中不具备的东西,而 hooks 的作用就是把这些纯函数不具备的功能进来使用。但也不是哪里都可以使用这些 hooks,React 对 hooks 的使用有严格的限制。

1.只在最顶层使用 Hook
不要在循环,条件或嵌套函数中调用 Hook,确保总是在你的 React 函数的最顶层调用他们。
2.只在 React 函数中调用 Hook
不要在普通的 JavaScript 函数中调用 Hook。你可以:
✅ 在 React 的函数组件中调用 Hook
✅ 在自定义 Hook 中调用其他 Hook
复制代码

虽然官方文档对各个 hook 的说明都已经非常清晰完整,但也还是要先过一遍介绍,也顺便说说使用这些 hooks 时的一些需要注意的地方。

① useState 的使用

useState,就是在函数中使用 state。大多数使用方法如下:

const [state, setState] = useState(defaultState)
1. setState(newState)
2. setState(oldState => ({...oldState, newKey: value}))
3. return (<div>{state}</div>)
复制代码

看起来使用 useState 会比较神奇,但其实它就是一个 ES6 数组解构的语法糖

const [a, b] = [1, 2] 
a == 1 // true 
b == 2 // true
复制代码

useState 方法也就是相当于返回一个状态值跟设置这个状态的方法给前面数组里相同下标定义的变量。

这里要注意第二种使用方法,React 会对多个 setState 进行合并处理以避免调用一次 setState 就触发一次 render 更新,所以在 setState 里传入的方法执行的时间线是在合并之后,它要比直接在外部调用的速度稍晚一些。

② useEffect 的使用

useEffect 相对应的是生命周期,但它没有那么多乱七八糟的生命周期命名,也不需要定义变量赋值,大多数使用场景如下:

1. useEffect(() => { console.log('hooks') }, [dep1, dep2, ...deps])
2. useEffect(() => { console.log('hooks') }, [])
3. useEffect(() => { console.log('hooks') }) // 不传依赖
4. useEffect(() => { return () => { console.log('hooks') }}, [dep1, dep2, ...deps])
5. useEffect(() => { return () => { console.log('hooks') }}, []) 
复制代码

首先 useEffect 是在页面加载完毕之后才执行的内容,也就是说它并不是在调用函数 render 时直接执行,但 React 会按每次的执行顺序记录下来,在页面加载完毕后按顺序执行 effect。

useEffect 第二个参数传入的是 effect 的依赖,当依赖里存储的变量发生变化时就会触发执行 effect 里的代码,它相当于 watch 依赖中的所有变量,当变量发生变化时触发执行。

除此之外,当组件或者页面首次加载时,effect 都会执行一次。如果依赖传入空数组,那 effect 也就只执行一次,而不传值时,每一次函数 render 都会执行 effect。

如果当我们有一些异步数据存在,首次加载时获取不到不想执行 effect 时,你可以这么做:

function Page(props) {
    const { asyncData } = props
    useEffect(() => {
        if (asyncData) { // 当 asyncData 存在时执行
            // your code
        }
    }, [asyncData])
}
复制代码

既然是生命周期,自然是有始有终,而 effect 里 return 的函数,就是当卸载组件或者页面时触发执行的函数,相当于是组件 destroy 时调用的方法。

不过有一点需要注意的是,effect 里所有的变量、参数都是在执行 useEffect 的时候取的定值,所以你可能会踩到下面两个坑点:

function Page1(props) {
    const [state, setState] = useState('abc')
    useEffect(() => {
        console.log(state) // 'abc'
        setState('efg')
        setTimeout(() => { console.log(state) }, 5000) // 5秒之后仍旧是 'abc'
    }, [state])
}
function Page2(props) {
    const [state, setState] = useState('abc')
    useEffect(() => {
        setState('efg')
        return () => { console.log(state) }// 页面各种操作后最后卸载时仍旧是 'abc'
    }, [])
}
复制代码

以及还有这两种问题混合起来的问题

function Page3(props) {
    const [state, setState] = useState('abc')
    // 希望页面卸载时用的是最新的 state,但是首次加载时执行 effect 的代码
    useEffect(() => {
        setState('efg')
        console.log(state) // 'abc'
        return () => { console.log(state) }// 仍旧是 'abc'
    }, [state])
}
复制代码

解决这种场景可以通过把 effect 分开来写,如下:

function Page4(props) {
    const [state, setState] = useState('abc')
    // 希望页面卸载时用的是最新的 state,但是首次加载时执行 effect 的代码
    useEffect(() => { // 首次加载时执行
        setState('efg')
    }, [])
    useEffect(() => { // 卸载时也能拿到最新的 state
        return () => { console.log(state) } // 'efg'
    }, [state])
}
复制代码

③ useMemo 和 useCallback 的使用

useMemouseCallback,为什么要把这两个 hooks 放在一起说呢?因为这两个 hook 其实是同一个。React 官方文档也有说明。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。
复制代码

useMemo 也是写 React 项目时很重要的一个 hook,Memo 全称是 Memorizer,即存储器,它可以把值存储起来,使我们拿到该值的时候不需要再经过相同的计算过程。例如

const memoValue = useMemo(() => {
    return new Array(100000).fill(1).map((item, index) => index)
}, [...deps])
复制代码

useMemo 的第二个参数跟 useEffect 一样都是依赖,也就是当依赖发生变化时,存储的值才会重新调用 useMemo 里的方法重新取值,这样可以避免每次 render 都反复执行相同一个复杂耗时的取值方法也就提高了组件或者页面性能,这在渲染大规模数据的时候非常重要。

有一点区别就是,当依赖传入一个空数组时,useMemo 相当于存储了一个从加载组件到卸载为止都不会发生变化的值。这里注意,useMemo 可以存储任何数据包括页面节点:

const memoNode = useMemo(() => {
    return new Array(100000).fill(1).map((item, index) => (<div>{index}</div>))
}, [])
...
return (
    <div>{memoNode}</div>
)
复制代码

useCallback 也是同理,可以避免因为多次 render 而每次都重新声明函数的过程。这在数据多的时候也有可大可小的影响。

const handleClick = useCallback((e) => {
    // your code
}, [])
复制代码

注意,不应该将 useMemo 或者 useCallback 写在被大量循环的组件当中。

④ useReducer 的使用

useReducer 常用于表单的多数据管理,来看看它的使用:

// 1.声明默认 state
const defaultState = { 
    stateA: 'abc', 
    stateB: 'efg'
}
// 2.声明 reducer 方法
function reducer (state=defaultState, action) {
    const newState = {...state}
    const value = action.value
    switch(action.key) {
      case 'stateA': { // switch 的 case 可以加括号限定作用域
          newState.stateA = value
          return newState
      }
      case 'stateB': {
          newState.stateB = value
          return newState
      }
      default: {
          return newState // reducer 最后结果必须返回一个 state
      }
    }
}
// 3. 在 React 中使用
function App () {
    const [data, dispatch] = useReducer(reducer, defaultState)
    const click = useCallback((e) => {
        dispatch({key: 'stateA', value: 'efg'})
        dispatch({key: 'stateB', value: 'abc'})
    }, [])
    return (
        <>
            <div>{data.stateA}-{data.stateB}</div>
            <button onClick={click}>click</button>
        </>
    )
}
复制代码

你可以看到,使用 useReducer 需要不少代码量,你需要声明一个 reducer 方法用来分发处理数据,然后在 reducer 里面处理每一种 key 的过程。然后你会发现,这好像和直接使用 useState,不都差不多吗?

    const [stateA, setStateA] = useState('abc')
    const [stateB, setStateB] = useState('efg')
    ....
    setStateA('efg')
    setStateB('abc')
复制代码

没错,你的感觉是对的,useState 其实是 useReducer 的一种简单实现

function useState(defaultState) {
    // reducer 方法为 null 时,相当于不对 state 进行处理直接返回 dispatch 的值
    return useReducer(null, defaultState)
}
复制代码

这也说明了其实 reducer 方法并不是一定要按着上面的模板写,它就是个普通的 JS 方法。如果你知道 redux,你可能会以为要像 redux 一样写一堆 constants 常量来定义 action.type,然后在 reducer 里一样的写大量模板性的代码,实际上这不是必要的。比如你也可以这样:

const defaultState = { a: 0, b: 0}
function reducer(state=defaultState, action) {
    let newState = {...state}
    if (action === 'clear') return defaultState
    else if (action.a) newState.a = action.a
    else newState.b = action.b
    return newState
}
function App () {
    const [data, dispatch] = useReducer(reducer, defaultState)
    const click = useCallback((e) => {
        dispatch({a: 1})
        dispatch({b: 2})
        // dispatch('clear')
    }, [])
    return (
        <>
            <div>{data.a}-{data.b}</div>
            <button onClick={click}>click</button>
        </>
    )
}
复制代码

我们也可以直接封装一个便捷的方法来生成我们的 reducer,这样当我们页面有很多表单的时候都可以这样来得到我们的 reducer 方法,就不用逐个写 reducer 的内部逻辑了: codesandbox 例子

// 遍历 action 的 key,对应更改 state[key] 为 action[key]
const getPublicReducer = (defaultState) => (state = defaultState, action) => {
  if (action === "clear") return defaultState;
  const newState = { ...state };
  const keys = Object.keys(action).filter(
    (key) => defaultState[key] !== void 0
  );
  if (!keys.length) return state;
  keys.forEach((ele) => (newState[ele] = action[ele]));
  return newState;
};
// usage
const defaultStateA = { a: 0, b: 0 };
const defaultStateB = { c: 0, d: 0 };
const reducerA = getPublicReducer(defaultStateA);
const reducerB = getPublicReducer(defaultStateB);
export default function App() {
  const [dataA, dispatchA] = useReducer(reducerA, defaultStateA);
  const [dataB, dispatchB] = useReducer(reducerB, defaultStateB);
  const clickA = useCallback((e) => {
    dispatchA({ a: 1 });
    dispatchA({ b: 2 });
    dispatchA({ a: 3, b: 4 });
    // dispatchA('clear')
  }, []);
  const clickB = useCallback((e) => {
    dispatchB({ c: 5 });
    dispatchB({ d: 6 });
    dispatchB({ c: 7, d: 8 });
    // dispatchB('clear')
  }, []);
  return (
    <>
      <div>{dataA.a}-{dataA.b}</div>
      {/* 0-0 click 后为 3-4 */}
      <div>{dataB.c}-{dataB.d}</div>
      {/* 0-0 click 后为 7-8 */}
      <br />
      <button onClick={clickA}>clickA</button>
      <button onClick={clickB}>clickB</button>
    </>
  );
}

复制代码

值得注意的是,在声明 reducer 方法的时候必须要返回一个最后处理 state 的结果值,如果有用过 Array.prototype.reduce 这个方法的同学应该知道,reduce 也需要把最后的累加值 account 返回给下次循环使用,这点 reducer 也保持基本一致,只不过 reducer 是返回给下次调用 dispatch 的时候使用。

⑤ useRef 与 useImperativeHandle 的使用

useRef 多半是用来获取一个实际的页面节点,如:

const div = useRef(null)

useEffect(() => {
    console.log(ref.current) // <div>123</div>
}, [])

return (
    <div ref={div}>123</div>
)
复制代码

useRef 中可以传入一个任意初始值,且之后可以在 ref.current 中访问到这个值。

文章在此之前提过,useEffect 的执行是在页面加载之后,所以在 render 时,react 会把 div 整个节点赋值给了 ref.current,在 effect 里就理所应当的取到了元素节点。

这看起来和 vue 里的 ref 很类似,但 useRef 中的 ref 实际上是可以存储任何数据,并且你可以对 ref.current 进行任意改写,只是它不会触发组件 render:

const data = useRef({ a: 1 })
console.log(data.current) // {a: 1} 再次 render 时为 {b: 1} useRef 不会重新再声明 ref 的值 
data.current = { b: 1 } 
console.log(data.current) // {b: 1}
复制代码

useImperativeHandle 这个 hooks 需要搭配 useRefforwardRef 这两个 api 使用,主要用于暴露组件内部的方法(因为在 ref 中参数的更改不会触发渲染,所以暴露会改变的值就失去了意义),forwardRef可以定义一个自定义组件的 ref 值并暴露给外部组件使用,如下所示:

const Comp = forwardRef((props, ref) => {
    const compMethod = (str) => console.log(str)
    const inputRef = useRef(null)
    
    useImperativeHandle(ref, () => ({
        focus: () => inputRef.current.focus(),
        log: compMethod
    }))
    
    return (
        <input type="text" ref={inputRef} />
    )
})

function App () {
    const ref = useRef(null)
    
    useEffect(() => {
        const { focus, log } = ref.current
        focus()
        log('input')
    }, [])
    
    return (
        <Comp ref={ref} />
    )
    
}
复制代码

ref 的使用在后续组件章节中会在提到有关 ref 透传的使用。

三、React 的更新机制

① 函数式组件什么时候更新

当组件的任一属性"发生改变"或者调用 setState 时,React 的组件就会被更新。你大概可以这么理解,会发生改变的 props 其实都是源于祖先组件里的 state,就是说 props 原本也是 state,React 在调用 useState(还有 useContext) 的时候会收集组件然后在调用 setState 之后再更新被收集的组件们。

在 JS 中,变量分为简单类型和引用类型,而属性的改变指的就是简单类型的值变化,或者是引用类型在栈中的指向发生变化,这意味着像对象或数组一样的引用类型,如果要触发页面渲染,我们需要返回相应的浅拷贝的值。如:

const [a, setA] = useState([1,2,3])
setState(_ => {
    _.push(4)
    return _ 
    // 不会触发渲染 因为_没有被改变
})

const [b, setB] = useState([1,2,3])
setState(_ => {
    _.push(4)
    return [..._]
    // 触发渲染
})
复制代码

控制组件的更新是写 React 最重要的一环之一,特别是在面临着大数据渲染时,避免不必要的重复渲染或者函数调用可能直接影响到整个页面的性能

注意,因为 React 的更新是只要变动,render 的内容就全量更新,这意味着当发生上述组件更新时,该组件或者是当前页面下的所有节点都将逐个重新渲染。

在 Class Component 中,我们可以通过 SCU(Should Component Update) 来控制是否更新当前组件从而达到手动控制子组件是否触发重新渲染的目的,而在 Function Component 中,我们有这样两个选择,一是前面 hooks 介绍讲的 useMemo,二是 React.memo,二者都用了 memo 作为关键字,那我们来看看他们有什么不同。

② React.memo 和 useMemo

React.memo 是直接从 React 库中导出的方法,用 React.memo 包裹的组件在渲染时,会先对组件更新前后的每个属性进行对比, 若有属性发生变化,则调用该组件的 render,在 Function Component 中则是直接执行函数本身。来看这个例子:

import React, {memo} from 'react'
let num = 1

// const ChildComp = (props => { // 没有加 memo
const ChildComp = memo(props => { // 加了 memo
    const { name } = props
    console.log('render child ', name) // 当触发重渲染的时候会打印
    return (
        <div>{name}</div>
    )
})

const FatherComp = props => {
    console.log('render father') // 当触发重渲染的时候会打印
    const [a, setA] = useState('a')
    const [b, setB] = useState('b')
    const hanldePlus = () => {
        setA('a' + num)
        setB('b')
        num++
    }
    
    return (
        <div>
          <button style={{padding: '15px'}}>plus</button>
          <ChildComp name={a} />
          <ChildComp name={b} />
        </div>
    )
}
function App () {
    return <FatherComp />
}
复制代码

我们来对比一下是否使用 memo 两者的区别:codesandbox 例子

点击 plus 按钮可以看到,当使用 memo 的时候,虽然父组件调用了 setB 触发了父组件的更新,但是子组件由于 name 值并没有改变所以没有触发渲染。

而当不使用 memo 时, 子组件即使 name 值没有改变,但仍旧触发了重渲染。

React.memouseMemo 两个名字和功能上基本相同,但在使用上也有一些差别,准确地来说,React.memo 是传入一个 React 组件 之后返回一个经过处理的组件,我们可以在 JSX 中直接使用它,而 useMemo 首先只能在函数式组件或者自定义 hooks 中使用,其次它存储的是一个值,如果要想用 useMemo 去实现上面的例子,那么要这么写:

import React, { useMemo } from 'react'
let num = 1
const ChildComp = props => { // 没有加 memo
    const { name } = props
    console.log('render child ', name) // 当触发重渲染的时候会打印
    return (
        <div>{name}</div>
    )
}
const FatherComp = (props) => {
  console.log("render father"); // 当触发重渲染的时候会打印
  const [a, setA] = useState("a");
  const [b, setB] = useState("b");
  const handlePlus = () => {
    setA("a" + num);
    setB("b");
    num++;
  };
  // 不同点
  const CompA = useMemo(() => ( <ChildComp name={a}/> ), [a]) 
  const CompB = useMemo(() => ( <ChildComp name={b}/> ), [b])

  return (
    <div>
      <button style={{ padding: "15px" }} onClick={handlePlus}>
        plus
      </button>
      {CompA}
      {CompB}
    </div>
  );
};
function App () {
    return <FatherComp />
}
复制代码

最后结果是和上面截图保持一致的。

③ createContext 和 useContext 的使用(不建议)

因为我觉得 useContext 是一个比较“危险”的 hooks,原因在于它可以越过 memo 或者 useMemo 进行组件更新,它脱离了 React 的对 props 和 state 的监听更新规范,而在一般项目中,我们通常有更好的选择如 redux、mobx 等库去管理我们的全局数据和请求,而且写 useContext 也不算简便,先介绍一下它的使用方法: codesandbox 例子

import React, {useState, memo, useContext, createContext} from 'react'
let num = 1
const Context = createContext(defaultData) // defaultData 默认值, 当找不到 Context
const Comp = memo(() => {
    console.log('render')
    const { name } = useContext(Context)
    return ( <div>{name}</div> )
})
function App () {
    const [value, setValue] = useState('abc')
    const click = () => {
        setValue('bcd' + num)
        num ++
    }
    // 子组件使用该 Context 时需要在祖先组件嵌套一层 Provider 并提供数据 value
    // 注意,我们在 createContext 的时候给予了一个 defaultData
    // 当子组件的 useContext 找不到祖先节点的 context 时才会使用 defaultData
    return (
        <Context.Provider value={{name: 'roxz', value}}> 
            <Comp />
            <button onClick={click}>click</button>
        </Context.Provider>
    )
}
复制代码

在上面例子中我们点击 button 会触发 Comp 组件重渲染,即使你从 Context 中拿出来的值没有发生改变,它一样触发了更新,而且这个更新是可以脱离 memo 控制的。

你还看不到这里面其实还有另外一个坑点,就是父组件 App 的 render 重新渲染,会使 Provider 里传的 value 对象也更新了新的地址,而最优使用 Context 除了要注意底下组件的更新同时,最好是把 Context 的内容提取为一层独立组件,如下:

import React, {useState, memo, useContext, createContext} from 'react'
let num = 1
const Context = createContext(defaultData) // defaultData 默认值, 当找不到 Context

const Comp = memo(() => {
    console.log('render')
    const { name } = useContext(Context)
    return ( <div>{name}</div> )
})

const TheProvider = memo((props) => {
    const [value, setValue] = useState('abc')
    return (
        <Context.Provider value={{name: 'roxz', value, setValue}}> 
            {props.children}
        </Context.Provider>
    )
})

function Button () {
    const { setValue } = useContext(Context)
    const click = () => {
        setValue('bcd' + num)
        num ++
    }
    return (<button onClick={click}>click</button>)
}

function App () {
    // 子组件使用该 Context 时需要在祖先组件嵌套一层 Provider 并提供数据 value
    // 注意,我们在 createContext 的时候给予了一个 defaultData
    // 当子组件的 useContext 找不到祖先节点的 context 时才会使用 defaultData
    return (
        <TheProvider>
            <Comp />
            <Button />
        </TheProvider>
    )
}
复制代码

但在某些特殊状况下因为这种更新方式也能达到性能优化的效果,比如在列表渲染的时候,我们可以跳过列表项的更新直接进入列表项里面的组件更新,例如列表中的 checkbox 组件等等。

结语

本文到这里就暂时结束啦,后续我将跟大家介绍如何利用 HOC 来封装一些前端比较常见的组件如 Table 组件、Form 表单等等,还有页面的编写包括但不仅于路由使用、页面组件分拆控制等等。有兴趣的同学可以点赞关注一下下哦。()

这里是公众号: 没有公众号。

这里是作者微信号: 没有微信号。

作者是刚摸索了小半年的 React Hoc 的前端小白,文章哪里有问题的话还请多多海涵,并在评论或者私信告诉我哦(轻点打我,别打头要秃了)