react hooks 指北

463 阅读15分钟

useState

useState是react官方提供的第一个hook,我们通过官方文档提供的计算器demo来看一下它的使用方法:

11.gif

通过调用setCount即可更新count和UI。

定义

useState接收一个参数并返回一对值:

  • 当前状态值(count)
  • 它的初始值等于接收的参数值(0)
  • 它的值由更新当前状态值的函数(setCount)更新
  • 更新当前状态值的函数(setCount)
  • 接收一个参数,可在事件处理函数中或其他一些地方(如useEffect)调用这个函数
  • 也可以接收一个函数,setState(count => count + 1),该函数的参数为上一次的count值
  • 每次调用都会重新渲染UI并更新当前状态值

注意事项

  • 不可局部更新
    • 如果state是一个对象,setState能否更新部分属性?答案是不行,因为setState不会帮我们合并属性,详情请看代码
  • 对象地址变化才会触发更新
    • setState(obj),如果接收的参数obj是个对象且跟之前接收的参数是同一个对象(地址相同),react就认为数据没有变化,不会触发更新,例如:

qq.gif

这时候点击onClick的时候,user的name属性不会被更新成小张,因为接收到的user跟第一次渲染的时候相比是同一个对象,若想更新name需要传入一个新的对象:

qq.gif


性能优化

这里我们假设count的值是通过一些列复杂的运算后得到的:

const [state, setState] = useState({count: 555555555 * 9999999999 / 77777777 })

虽然该参数只在第一次初始化的时候生效,但是每次更新UI,js解析到useState的时候还是会把里面的 ({count: 555555555 * 9999999999 / 77777777}) 重新计算一遍,而这些开销是不必要的。

好在useState除了能接收一个参数,还可以接收一个函数,并且该函数只会运行一次(函数的返回值会被缓存),像上述情况,我们就可以通过传入函数来避免多余的性能开销:

const initialState= {
   count: 555555555 * 9999999999 / 77777777  // 假设是复杂的运算
}
const [state, setState] = useState(()=>{
    return initialState // 只会运行一次
})


实现一个简易的useState

知道了useState的用法后,我们趁热打铁,动手实现一个简易的useState加深对hook的理解。

// 状态值
let _state 

// 定义一个重渲染函数(简化了react的重新渲染)
const rerender = () => {
  ReactDOM.render(
    <App />,
    document.getElementById('root')
  );
};

// 自定义 _useState
const _useState = initialState => {
  _state = _state === undefined ? initialState : _state; // 判断是否第一次调用
  const setState = newState => {
    _state = newState;
    rerender();
  };
  return [_state, setState];
};

在上面的代码中,我们定义了 _useState ,它接收一个参数,同时返回一个数组,数组内容为[_state, setState],每次调用_setState的时候,会把_state的值更新为 newState ,同时更新UI,我们看下效果:


22.gif


在这里,setState其实就是_useState内部返回的一个闭包。


上述代码已经可以满足一个useState的基本功能了,但是如果项目中需要使用多个useState,就无法满足了。多个useState,就意味着要存储多个状态值(state),考虑用数组的形式来存储,把代码做一下改造:

// 状态值
let _state = [];

// 索引
let index = 0;

// 自定义 _useState
const _useState = initialState => {
    const curIndex = index;
  index++;
  _state[curIndex] = _state[curIndex] === undefined ? initialState : _state[curIndex];
  const setState = newState => {
    index = 0 // 重置index,保证App更新重新声明_useState的时候,_state数组还是从第0位开始存储状态值。
    _state[curIndex] = newState;
    rerender();
  };
  return [_state[curIndex], setState];
};

export default function App() {
  const [a, setA] = _useState(0);
  const [b, setB] = _useState(0);
  return (
    <div className="App">
      <div>
        <span>{a}</span>
        <button onClick={() => setA(a + 1)}>+</button>
      </div>
      <div>
        <span>{b}</span>
        <button onClick={() => setB(b + 1)}>+</button>
      </div>
    </div>
  );
}


上述的代码中,我们用一个 _state[] 数组来存储状态值state,每声明一个 _useState ,就按顺序往里添加一个状态值;另外每次调用_setState的时候都要把索引 index 重置为0,这样保证了UI更新重新声明_useState的时候,状态值_state不会错乱。看下效果:

image.png


33.gif

到此为止就实现了一个简单的useState。其实在react内部,_state和index都会挂载到fiberNode上的对应属性上并由react进行维护,即链表形式的一种数据结构。由于其实现机制需要记录次序,也就是为什么不可以在条件判断语句中(if)使用hook的原因了。


总结

  • 每个函数组件会对应一个React节点
  • 每个节点保存着state和index
  • useState会读取state[index]
  • index由useState声明的顺序决定
  • useState接收的参数只在第一次初始化的时候生效
  • setState会修改state,并触发UI更新


useReducer

用来践行redux的思想,可以看作是useState的复杂版,在某些场景,比如表单需要归类一些数据,useReducer 会比 useState 更适用。

  • 使用步骤
    • 创建初始值initialState
    • 创建所有操作reducer(state,action)
    • 传给useReducer得到读和写API
    • 调用写({type: 操作类型})
const initFormData = {
  name: "",
  age: 18,
  nationality: "汉族"
};

function reducer(state, action) {
  switch (action.type) {
    case "patch":
      return { ...state, ...action.formData };
    case "reset":
      return initFormData;
    default:
      throw new Error();
  }
}

function App() {
  const [formData, dispatch] = useReducer(reducer, initFormData);
  const onSubmit = () => {};
  const onReset = () => {
    dispatch({ type: "reset" });
  };
  return (
    <form onSubmit={onSubmit} onReset={onReset}>
      <div>
        <label>
          姓名
          <input
            value={formData.name}
            onChange={e =>
              dispatch({ type: "patch", formData: { name: e.target.value } })
            }
          />
        </label>
      </div>
      <div>
        <label>
          年龄
          <input
            value={formData.age}
            onChange={e =>
              dispatch({ type: "patch", formData: { age: e.target.value } })
            }
          />
        </label>
      </div>
      <div>
        <label>
          民族
          <input
            value={formData.nationality}
            onChange={e =>
              dispatch({
                type: "patch",
                formData: { nationality: e.target.value }
              })
            }
          />
        </label>
      </div>
      <div>
        <button type="submit">提交</button>
        <button type="reset">重置</button>
      </div>
      <hr />
      {JSON.stringify(formData)}
    </form>
  );
}


qq.gif

useContext

通过 createContext 创建出来的上下文,在子组件中可以通过 useContext 这个 Hook 获取 Provider 提供的内容。

  • 上下文
    • 全局变量是全局的上下文
    • 上下文是局部的全局变量
  • 使用步骤
    • 使用C=createContext(initialState)创建上下文
    • 使用<C.provider>圈定作用域
    • 在作用域内使用useContext(C)来使用上下文
const C = createContext(null);

function App() {
  console.log("App 执行了");
  const [n, setN] = useState(0);
  return (
    <C.Provider value={{ n, setN }}>
      <div className="App">
        <Baba />
      </div>
    </C.Provider>
  );
}

function Baba() {
  const { n, setN } = useContext(C);
  return (
    <div>
      我是爸爸 n: {n}
      <Child />
    </div>
  );
}

function Child() {
  const { n, setN } = useContext(C);
  const onClick = () => {
    setN(i => i + 1);
  };
  return (
    <div>
      我是儿子 我得到的 n: {n}
      <button onClick={onClick}>+1</button>
    </div>
  );
}

qq.gif

只要是被<C.provider>包裹(作用域)的组件,不管有多少层嵌套,都可以获取到<C.provider>提供的value值。

实现一个简易的redux

知道了useReducer和useContext的使用方法,我们利用这两个hook的特性,来实现一个简易的redux。

  • 实现思路
    • 将数据集中在一个store对象
    • 将所有操作集中在reducer
    • 创建一个Context
    • 创建对数据的读写API
    • 将创建出来的读写API放到Context里
    • 用Context.provider将Context的内容提供给所有组件
    • 各个组件用useContext来获取读写API
  • 代码实现
const store = {
  user: null,
  books: null,
  movies: null
};

function reducer(state, action) {
  switch (action.type) {
    case "setUser":
      return { ...state, user: action.user };
    case "setBooks":
      return { ...state, books: action.books };
    case "setMovies":
      return { ...state, movies: action.movies };
    default:
      throw new Error();
  }
}

const Context = React.createContext(null);

function App() {
  const [state, dispatch] = useReducer(reducer, store);

  const api = { state, dispatch };
  return (
    <Context.Provider value={api}>
      <User />
      <hr />
      <Books />
      <Movies />
    </Context.Provider>
  );
}

function User() {
  const { state, dispatch } = useContext(Context);
  useEffect(() => {
    ajax("/user").then(user => {
      dispatch({ type: "setUser", user: user });
    });
  }, []);
  return (
    <div>
      <h1>个人信息</h1>
      <div>name: {state.user ? state.user.name : ""}</div>
    </div>
  );
}

function Books() {
  const { state, dispatch } = useContext(Context);
  useEffect(() => {
    ajax("/books").then(books => {
      dispatch({ type: "setBooks", books: books });
    });
  }, []);
  return (
    <div>
      <h1>我的书籍</h1>
      <ol>
        {state.books ? state.books.map(book => <li key={book.id}>{book.name}</li>) : "加载中"}
      </ol>
    </div>
  );
}

function Movies() {
  const { state, dispatch } = useContext(Context);
  useEffect(() => {
    ajax("/movies").then(movies => {
      dispatch({ type: "setMovies", movies: movies });
    });
  }, []);
  return (
    <div>
      <h1>我的电影</h1>
      <ol>
        {state.movies
          ? state.movies.map(movie => <li key={movie.id}>{movie.name}</li>)
          : "加载中"}
      </ol>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

// 假 ajax 两秒钟后,根据 path 返回一个对象,必定成功不会失败
function ajax(path) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (path === "/user") {
        resolve({
          id: 1,
          name: "Frank"
        });
      } else if (path === "/books") {
        resolve([
          {
            id: 1,
            name: "JavaScript 高级程序设计"
          },
          {
            id: 2,
            name: "JavaScript 精粹"
          }
        ]);
      } else if (path === "/movies") {
        resolve([
          {
            id: 1,
            name: "爱在黎明破晓前"
          },
          {
            id: 2,
            name: "恋恋笔记本"
          }
        ]);
      }
    }, 2000);
  });
}

image.png

另外,也可以对上述的代码进行模块化,代码参考这里


useEffect

官方定义:该 Hook 接收一个包含命令式、且可能有副作用代码的函数。

  • 什么是副作用?
    • 对环境的改变即为副作用,如修改document.title,当然不一定非要把副作用放在useEffect里。

使用场景

  • 作为componentDidMount使用,[]作第二个参数

image.png

  • 作为componentDidUpdate使用,需指定依赖[n]

qq.gif

  • 作为componentWillMount使用,通过return调用

qq.gif

  • 以上三种用途可同时存在


特点

  • 如果同时存在多个useEffect, 会按照出现的顺序执行。

useLayoutEffect

特点

  • useLayoutEffect总是比useEffect先执行
  • 最好是一些会影响布局layout的代码才放到useLayoutEffect里执行

image.png

经验

  • 为了用户体验,优先使用useEffect(优先渲染)

useMemo

在学习useMemo之前,先回顾一下React.meno,我们一般会用它来做一些性能优化。

memo优化

function App() {
  const [n, setN] = React.useState(0);
  const [m, setM] = React.useState(0);
  const onClick = () => {
    setN(n + 1);
  };
  return (
    <div className="App">
      <div>
        <button onClick={onClick}>update n {n}</button>
      </div>
      <Child1 data={m}/>
      <Child2 data={m}/>
    </div>
  );
}
function Child1(props) {
  console.log("child1 执行了");
  return <div>child: {props.data}</div>;
}
function Child2(props) {
  console.log("child2 执行了");
  return <div>child: {props.data}</div>;
}

qq.gif

在上面的例子中,Child1和Child2的只接收m的值,但是当我们只改了n的值,这两个组件也跟着重新渲染了,这显然不是我们想要的效果,对上述代码做一下更改:

return (
    <div className="App">
      <div>
        <button onClick={onClick}>update n {n}</button>
      </div>
      <Child1 data={m} />
      <MemoChild2 data={m} />
    </div>
  );
}

const MemoChild2 = React.memo(Child2);

qq.gif

当我们用React.memo对Child2组件进行包装后,当n的值发生改变,Child2不会再重新渲染,作为对比,Child1会被重新渲染,React.memo使得只有组件接收的props发生了改变,组件才会重新渲染。


memo的问题

我们在对上述代码做一下改造,给Child2传一个空函数:

function App() {
  ...
  const onChildClick = () => {
    console.log(m)
  }
  return (
    <div className="App">
      <div>
        <button onClick={onClick}>update n {n}</button>
      </div>
      <Child1 data={m} />
      <MemoChild2 data={m} onClick={onChildClick}/>
    </div>
  );
}

const MemoChild2 = React.memo(Child2);

qq.gif

当改变n值的时候,会发现Child2也被重新渲染了。

梳理一下过程:当调用setN的时候,会导致App()重新被调用,这时候会生成一个新的函数 onChildClick ,虽然这个函数的功能和之前的一样,但是由于函数是对象,新生成的函数地址和原先函数地址是不一样的,所以memo认为props的onChildClick发生了变化,就会重新渲染组件。


提醒一下,这里m值也是新生成的,但是m是基本类型,前后值是一样的,所以React.memo认为props的m没有发生变化


为了解决这个问题,我们需要告诉告诉react,下一次重新渲染的时候不要再生成一个新的onChildClick 而是复用上一次的,这时候就需要用到useMemo,对代码做一下改造,用useMemo对onChildClick做一下包装:

...
   const onChildClick = useMemo(() => {
    const fn = () => {
      console.log(m)
    }
    return fn
  }, [m])
...


qq.gif

这样Child2就不会因为接受onChildClick而进行不必要的更新了。

特点

  • 接收2个参数,第一个参数是 () => value , 返回值value 即被缓存的值;第二个参数是依赖 [m,n]
  • 只有当依赖发生变化时,才会计算出新的value
  • 如果依赖不变,就复用之前的value,起到缓存的效果


useCallback

在之前用useMemo的缓存函数的时候我们这样写的:

 const onChildClick = useMemo(() => {
    const fn = () => {
      console.log(m)
    }
    return fn
  }, [m])
 
// 等价于
  const onChildClick = useMemo(() => () => {
    console.log(m)
  }, [m])

上述写法显得稍微麻烦了一点,需要自己return函数,所以react提供了一个更加便捷的useCallback给我们用:

const onChildClick = useCallback(() => {
    console.log(m)
  }, [m])

特点

  • 接收2个参数,第一个参数是 fn ,第二个参数是依赖 [m,n]
  • 只有当依赖发生变化时,才会计算出新的 fn
  • 如果依赖不变,就复用之前的 fn ,起到缓存的效果


从上述例子可以得出:

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

总结

  • useMemo既可以缓存函数也可以缓存普通值钱,是可以取代useCallback的
  • 用useCallback缓存函数,写法上更加简洁

useRef

qq.gif

先点击 show alert ,再点击 Click me 改变count的值为6,等过了3秒发现弹窗显示的值为0,而不是6。

这是为什么呢?

当我们每次调用setCount更新状态的时候,React 会重新渲染组件, 每一次渲染都会拿到独立的count, 并重新渲染生成新的 handleAlertClick 函数,每一个handleAlertClick 里面都有它自己的 count 。这个过程可以简化成下面的代码来理解:

function handleAlertClick() {
    setTimeout(() => {
       alert('You clicked on:' + count)
    }, 3000)
}

let count = 0
handleAlertClick(count) // 这时候调用handleAlertClick, count为0

count = 1
handleAlertClick(count) // 这时候调用handleAlertClick, count为1

count = 2
handleAlertClick(count) // 这时候调用handleAlertClick, count为2

// 这其实就是闭包引起的,每次重新渲染生成的handleAlertClick就是一个闭包
// 而其内部引用的count值取决与handleAlertClick被调用时count的取值
// 虽然在handleAlertClick内部延迟了三秒才打印count,但count的值早在闭包形成的时候就被固定下来了

因为我们是在 count = 0 的时候调用了 handleAlertClick ,所以弹窗显示的值为0。

如果我们想要弹窗显示实时的count,就可以通过useRef来实现,对代码做一下改造:

qq.gif

因为 useRef 每次都会返回同一个引用, 所以在 useEffect 中修改的时候 ,在 alert 中也会同时被修改,这样子,点击的时候就可以弹出实时的 count 了。

总结

  • 何时使用
    • 当你需要一个值,在组件不断render时报保持不变(指向同一个引用地址)
    • useRef既可以引用Dom也可以引用普通对象
  • 如何使用
    • 初始化:const count = useRef(0)
    • 读取:count.current
  • 为什么需要current来存值?
    • 为了保证多次更新后useRef还是返回的同一个值(只有引用能做到)

注意事项

  • ref.current能做到值变化时自动更新render更新UI吗?
    • 不能。
const count = useRef(0)
count.current += 1

这时候虽然count.current从0变成1,但是UI并不会重新渲染。

如果我们想做到ref.current变化时自动更新render更新UI也很简单:

const [,setUpdate] = useState(0)
const count = useRef(0)
count.current += 1
setUpdate(n => n + 1)

自己申明一个useState,用到返回的更新函数,每次更改ref.current时,主动调用一下这个更新函数就可以了。


forwardRef

由于props无法传递ref属性,所以需要借助forwardRef来传递。

function App() {
  const buttonRef = useRef(null);
  return (
    <div className="App">
      <Button1 ref={buttonRef}>按钮</Button3>
    </div>
  );
}

const Button1 = React.forwardRef((props, ref) => {
  return <button className="red" ref={ref} {...props} />;
});


useImperativeHandle

定义

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值,需配合 forwardRef 一起使用。

使用

当父组件需要调用子组件某个方法时

// 子组件
function _FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
const FancyInput = forwardRef(_FancyInput);

// 父组件
function App() {
  const handleClick = () => {
        inputRef.current.focus()  // 调用子组件<FancyInput/> 里的focus方法。
  }
  return (
    const inputRef = useRef();
    <FancyInput ref={inputRef} />
    <button onClick={handleClick}>click</button>
  )
}

自定义hook

hooks的出现使得我们从组件中提取状态逻辑更加容易,方便对其进行复用。

当我们使用class组件,通常在componentDidMount里请求数据,然后state中需要有一个变量记录当前是否正在请求接口,在请求的前后需要手动去改变这些状态,大概代码如下:

class App extends Component {
  state = {
    loading: false,
  }

  componentDidMount() {
    this.setState({
      data: null,
      loading: true,
    });
    axios.get('/api/data').then((data) => {
      this.setState({
        data,
        loading: false,
      });
    });
  }

  render() {
    return this.state.loading ? '正在加载中...' : (
      <Page data={data} />
    );
  }
}

下面我们就自定义一个hook对里面的请求数据逻辑进行提取封装。

useRequest

// 封装
const useRequest = (fn, dependencies = []) => {
  const [data, setData] = useState(defaultValue);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fn()
      .then(res => {
        setData(res);
      })
      .finally(() => {
        setLoading(false);
      });
  }, dependencies);

  return { data, setData, loading };
};

// 使用
function App() {
  const { loading, data } = useRequest(() => axios.get('/api/test'));
  return loading ? '正在加载中...' : (
    <Page data={data} />
  );
}

闭包陷阱

image.png

先上一张尤大对hooks的吐槽,相关文章链接

在刚开始使用hooks的时候,很多人都会觉得不适应,因为hooks所表现出来的一些特性跟我们的常规思维是有冲突的,就比如刚才我们上文讲useRef 提到那个例子,当你第一次用hooks去实现一个简单的定时器的时候也会发现并没有那么容易,这一切的缘由都要从闭包说起。

先看下面这个例子:

过时的闭包

function createIncrement(i) {
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    const message = `Current value is ${value}`;
    return function logValue() {
      console.log(message);
    };
  }
  return increment;
}

const inc = createIncrement(1);
const log = inc(); // value值为 1
inc();             // value值为 2
inc();             // value值为 3
log();             // 注意,这里打印出来的是 Current value is 1 而不是 Current value is 3

最后调用的 log() 是一个过时的闭包。

第一次调用时inc(),返回了闭包 log() 并且捕获了变量 message ,其值为 Current value is 1 ,在多次调用 inc() 后, value 已经变成了3,而此时 log() 内部捕获的 message 的值还是一开始的 Current value is 1 ,所以我们称 log() 为过时的闭包,而其内部捕获的message 为过时的变量。

过时的闭包
捕获具有过时值的变量

修复过时的闭包

  • 找到捕获了最新变量的闭包
const inc = createIncrement(1);
inc();  // value值为 1
inc();  // value值为 2
const latestLog = inc(); // value值为 3
latestLog(); // 打印出了 "Current value is 3"
  • 调用闭包的时候重新获取变量
// 将 const message = `Current value is ${value}` 移到logValue()函数主体中

function createIncrementFixed(i) {
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    return function logValue() {
      const message = `Current value is ${value}`;
      console.log(message);
    };
  }
  
  return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // value值为 1
inc();             // value值为 2
inc();             // value值为 3
// Works!
log();             //  打印出了 "Current value is 3"

useEffect值不更新

qq.gif

单击几次增加按钮后,控制台每隔2秒钟还是打印出了 count is: 0 ,这也是上文提到的过时的闭包引起的。

第一次渲染时,闭包log()count变量捕获为0。后来,即使count增加,log()仍然使用第一次捕获的 count 0 变量。

解决useEffect值不更新

当count的值发生变化时,要告诉useEffect丢弃过时的闭包去获取最新的闭包,即上文提到到修复过时闭包的第一种方法,同时这里因为用到了定时器,所以要清除老的定时器。

function WatchCount() {
  const [count, setCount] = useState(0);

  useEffect(function() {
    const id = setInterval(function log() {
      console.log(`Count is: ${count}`);
    }, 2000);
    return function() {
      clearInterval(id);
    }
  }, [count]);

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1) }>
        Increase
      </button>
    </div>
  );
}

useState值更新异常

qq.gif

先点击 Increase async ,再点击 Increase sync 把count的值改成4,但是过了1秒钟后,count的值不是变成5而是变成了1,这同样是闭包引起的。点击 Increase async 这时候内部定时器捕获到count的变量是0,所以过了1秒钟,在0的基础上加1,count就变成了1。

解决setState值更新异常

这次我们用上文修复过时闭包提到的第二种方案调用闭包的时候重新获取变量,在定时器到时间后,重新获取一下count变量即可。

 function handleClickAsync() {
    setTimeout(function delay() {
      setCount(count => count + 1);
    }, 1000);
  }