【前端面试】面试官:我看看你的React Hooks掌握的怎么样?

9,986 阅读18分钟

引言

【前端面试】从小作坊走到大厂,回头看这条路真的好长

【前端面试】披荆斩棘,25道高频React面试题

书接上文,继续分享React Hooks相关面试题

广告时间

程序员和NBA球员一样,黄金时间就这么几年,如果你是一个有进取心的少年,想摆脱如今的工作困境,来找我,我可以跟你分享我是怎么一步步走过来。

作者这两年的面试经历几乎覆盖了上海地区和杭州地区的全部大厂和部分中厂,也可以给你提供帮助:

  • 找工作建议
  • 如何准备面试
  • 如何学习
  • 薪资评估
  • 未来规划和成长

因为作者也在公司做一面面试官,众所周知一面是最难的,可以帮你做

  • 大厂的模拟面试,
  • 前端知识点的探底,帮你差缺补漏。
  • 最粗暴的,你可以简历发我,可以帮你把关。

二维码:

p9-juejin.byteimg.com/tos-cn-i-k3…

只需要两杯咖啡钱~ 欢迎在点击链接扫描添加我的企业微信了解。祝大家在2022期间面试顺利~

面试分享 React hooks

1.Usecallback 和useDemo的区别

useMemo 和 useCallback 接收的参数都是一样,第一个参数为回调 第二个参数为要依赖的数据

共同作用:仅仅 依赖数据 发生变化, 才会重新计算结果,也就是起到缓存的作用。

useCallback和useMemo都可缓存函数的引用或值,但是从更细的使用角度来说useCallback缓存函数的引用,useMemo缓存计算数据的值。

两者区别:

1.useMemo 计算结果是 return 回来的值, 主要用于 缓存计算结果的值 ,应用场景如: 需要计算的状态

2.useCallback 计算结果是 函数, 主要用于 缓存函数,应用场景如: 需要缓存的函数,因为函数式组件每次任何一个 state 的变化 整个组件 都会被重新刷新,一些函数是没有必要被重新刷新的,此时就应该缓存起来,提高性能,和减少资源浪费。

注意: 不要滥用会造成性能浪费,react中减少render就能提高性能,所以这个仅仅只针对缓存能减少重复渲染时使用和缓存计算结果。

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);
// 根据官网文档的介绍我们可理解:在a和b的变量值不变的情况下,memoizedCallback的引用不变。即:useCallback的第一个入参函数会被缓存,从而达到渲染性能优化的目的。
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
// 根据官方文档的介绍我们可理解:在a和b的变量值不变的情况下,memoizedValue的值不变。即:useMemo函数的第一个入参函数不会被执行,从而达到节省计算量的目的。

2.hooks带来了什么

用于在函数组件中引入状态管理和生命周期方法,

取代高阶组件和render props来实现抽象和可重用性

3.hooks的实现原理

修改核心是将useState,useEffect按照调用的顺序放入memoizedState中,每次更新时,按照顺序进行取值和判断逻辑,我们根据调用hook顺序,将hook依次存入数组memoizedState中,每次存入时都是将当前的currentcursor作为数组的下标,将其传入的值作为数组的值,然后在累加currentcursor,所以hook的状态值都被存入数组中memoizedState。先将旧数组memoizedState中对应的值取出来重新复值,从而生成新数组memoizedState。对于是否执行useEffect通过判断其第二个参数是否发生变化而决定的。

这里我们就知道了为啥不要在循环,条件或嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层调用他们。因为我们是根据调用hook的顺序依次将值存入数组中,如果在判断逻辑循环嵌套中,就有可能导致更新时不能获取到对应的值,从而导致取值混乱。同时useEffect第二个参数是数组,也是因为它就是以数组的形式存入的。

4.Hooks 闭包 

useEffect、useMemo、useCallback都是自带闭包的。每一次组件的渲染,它们都会捕获当前组件函数上下文中的状态(state, props),所以每一次这三种hooks的执行,反映的也都是当前的状态,你无法使用它们来捕获上一次的状态。

5.Hooks 体验

每天都在大量使用 hooks 的我也说几句。先说一下我遇到的日常业务一般有如下特点:页面复杂度有限,大部分代码都是在实现页面的某个功能;开发效率优先于运行效率,组件 re-render 5 次还是 2 次无足轻重,不要过早优化,先把功能怼上去,如果页面出现卡顿的话 就再想办法。

在日常业务开发中,hooks 还是非常香的,其带来的收益是明显大于使用成本的。

一是 hooks 对 TypeScript 支持更加友好。例如 useState 对应的状态类型可以被自动推断出来、useEffect 不需要写类型声明(class 写法下几个生命周期函数的参数签名写起来是相当繁琐的),hooks 能帮我们省去大量手动声明类型的操作。同时 hooks 让重构更加简单,使用 hooks 的代码中不会出现 this,组件的 props 或是 state 在组件代码中都是普通的变量,减少了重构的成本,例如原先 重命名 this.state 中某个字段 ,在 hooks 下就成了 重命名某个变量。

二是 hooks 不再要求「状态必须在 this.state 中声明,生命周期写在 componentDidMount/componentDidUpdate 中」。useState/useEffect 的写法更加自由,我们可以按照页面功能/特性来组织 hooks 的书写位置。这在日常业务开发中是相当有用的,因为页面初版完成后的几天内,产品经理会随机、不定时地提出若干新需求。新需求一般是某个新特性,此时按照特性的代码组织方式 能够显著提升新需求的开发效率。

三和二类似,自定义 hooks 提供了更加灵活的逻辑复用机制。自定义 hooks 带来了更多的可能性,例如 swr 是一个异步数据加载的自定义 hooks,提供了 Fast page navigation、Revalidation on focus、Interval polling、Request deduplication 等特性,这个在 class 的写法下几乎是无法实现的。又如像 react-use 这样的类库,若能适当在业务中用起来,可以显著提升开发效率。

当组件/页面变得更为复杂时,hooks 会比 class 写法带来更大的心智负担的:stale closure、过多的 re-render、useEffect 依赖膨胀、缺少 getDerivedStateFromProps …… 这个时候就不能像原来那样自由发挥 hooks 了,还是老老实实用回 class 写法了。

会不会重新触发render?

这个确实是我刚接触时的一个痛点,毕竟写惯了 class-style 的组件,作为一个高工不写两行代码在 shouldComponentUpdate 简直对不起自己。然后问题就来了,在 hook 中就会总想会不会重复渲染啊这类的问题。

后来发现,其实完全没有必要。因为 hook 的 useEffect 本质上就是为了模糊生命周期以及渲染周期的概念的,只要 render 函数中不存在副作用、消耗较小(适当的 memo),多渲染几次其实没什么问题的。这里唯一值得注意的是,render 中千万要小心副作用,如果不用 useEffect 基本上都会产生问题。

useEffect 和useLayoutEffect的区别

useEffect里面的操作需要处理DOM,并且会改变页面的样式,就需要用这个,否则可能会出现出现闪屏问题, useLayoutEffect里面的callback函数会在DOM更新完成后立即执行,但是会在浏览器进行任何绘制之前运行完成,阻塞了浏览器的绘制.

6.谈谈useLayoutEffect和useEffect具体执行时机

useLayoutEffect和平常写的ClassComponent的'componentDidMount'和'componentDidUpdate'同时执行。

useEffect会在本次更新完成后,也就是第1点的方法执行完成后,在开启一次任务调度,在下次任务调度中执行useEffect。

7.用Hooks模拟全部周期

// constructor
function Example() {
	const [count,setCount] = useState(0);
  return null;
}
//getDerivedStateFromProps
// 这里注意到其实这个state并不是真实的state,而是一个跟props相关的对象
const useGetDeriveStateFromProps = (state, props, handle) => {
  const cacheState = useRef(state);
  const newState = handle(cacheState.current, props);
  if (newState) {
    cacheState.current = newState;
  }

  return cacheState.current;
};

// 使用

const Component = props => {
  const state = useGetDeriveStateFromProps({ x: 1 }, props, (state, props) => {
    console.log('new getDerivedStateFromProps')
    if (props.add) {
      state.x += 1;
      return state;
    }
    return null;
  });
  
  return <div>{state.x}</div>;
};

// componentDidMount
function Example() {
	useEffect(()=>console.log('mounted'),[])
}
// shouldComponentUpdate
React.memo 包裹一个组件来对它的 props 进行浅比较,
但这不是一个 hooks,因为它的写法和 hooks 不同,
其实React.memo 等效于 PureComponent,但它只比较 props。
const MyComponent = React.memo(_MyComponent,(prevProps,nextProps) => nextProps.count !== prevProps.count)

// getSnapshotBeforeUpdate
在最近一次渲染输出(提交到 DOM 节点)之前调用。 它使得组件能在发生更改之前从 DOM 中捕获一些信息(例如,滚动位置)。 此生命周期的任何返回值将作为参数传递给 componentDidUpdate
这里有点难解决,可以考虑讲整个生命周期抽象出来

// componentDidUpdate
useEffect(() => console.log('mounted or updated'));
值得注意的是,这里的回调函数会在每次渲染后调用,因此不仅可以访问 componentDidUpdate,还可以访问componentDidMount,如果只想模拟 componentDidUpdate,我们可以这样来实现。
const mounted = useRef();
useEffect(()=>{
	if(!mounted.current) {
  	mounted.current = true;
  } else {
  	console.log('I am didUpdate')
  }
});
useRef 在组件中创建“实例变量”。它作为一个标志来指示组件是否处于挂载或更新阶段。当组件更新完成后在会执行 else 里面的内容,以此来单独模拟 componentDidUpdate。
// componentWillUnmount
useEffect(() => {
  return () => {
    console.log('will unmount');
  }
}, []);

8.useRuduce定义

const [state, dispatch] = useReducer(reducer, initState);
useReducer接收两个参数:
第一个参数:reducer函数,没错就是我们上一篇文章介绍的。
第二个参数:初始化的state。返回值为最新的state和dispatch函数(用来触发reducer函数,计算对应的state)。按照官方的说法:对于复杂的state操作逻辑,嵌套的state的对象,推荐使用useReducer。听起来比较抽象,我们先看一个简单的例子:

// 官方 useReducer Demo
    // 第一个参数:应用的初始化
    const initialState = {count: 0};

    // 第二个参数:state的reducer处理函数
    function reducer(state, action) {
        switch (action.type) {
            case 'increment':
              return {count: state.count + 1};
            case 'decrement':
               return {count: state.count - 1};
            default:
                throw new Error();
        }
    }

    function Counter() {
        // 返回值:最新的state和dispatch函数
        const [state, dispatch] = useReducer(reducer, initialState);
        return (
            <>
                // useReducer会根据dispatch的action,返回最终的state,并触发rerender
                Count: {state.count}
                // dispatch 用来接收一个 action参数「reducer中的action」,用来触发reducer函数,更新最新的状态
                <button onClick={() => dispatch({type: 'increment'})}>+</button>
                <button onClick={() => dispatch({type: 'decrement'})}>-</button>
            </>
        );
    }

最后我们总结一下这篇文章的一些主要内容:使用reducer的场景

  • 如果你的state是一个数组或者对象
  • 如果你的state变化很复杂,经常一个操作需要修改很多state
  • 如果你希望构建自动化测试用例来保证程序的稳定性
  • 如果你需要在深层子组件里面去修改一些状态(关于这点我们下篇文章会详细介绍)
  • 如果你用应用程序比较大,希望UI和业务能够分开维护

9.利用useReducer实现useState

function useState<S>(initialState: S | (()=>S)):
  [S,(value:S | ((prev:S)=>void))=>void]

function useReducer<R extends ReducerWithoutAction<any>, I>(
      reducer: R,
      initializerArg: I,
      initializer: (arg: I) => ReducerStateWithoutAction<R>
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];

function useState<T>(initialState: T | (() => T)) {
  const initState: T =
    // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
    // @ts-ignore
    typeof initialState === 'function' ? initialState() : initialState;

  const [state, dispatch] = React.useReducer(
    // eslint-disable-next-line no-shadow
    (state: T, action: T | ((prev: T) => T)) => {
      // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
      // @ts-ignore
      return typeof action === 'function' ? action(state) : action;
    },
    initState,
  );

  return [
    state,
    (value: T | ((prev: T) => T)) => {
      if (value !== state) {
        dispatch(value);
      }
    },
  ];
}

10.useRef

动机

1.函数组件访问DOM元素;

2.函数组件访问之前渲染变量。

函数组件每次渲染都会被执行,函数内部的局部变量一般会重新创建,利用useRef可以访问上次渲染的变量,类似类组件的实例变量效果。

使用

  1. 每次渲染useRef返回值都不变;
  2. ref.current发生变化并不会造成re-render;
  3. ref.current发生变化应该作为Side Effect(因为它会影响下次渲染),所以不应该在render阶段更新current属性。
  4. 不可以在render里更新ref.current值,在异步渲染里render阶段可能会多次执行。
  5. 可以在render里更新ref.current值,上只要保证每次render不会造成意外效果,都可以在render阶段更新ref.current。但最好别这样,容易造成问题,useRef懒初始化毕竟是个特殊的例外
  6. ref.current 不可以作为其他hooks(useMemo, useCallback, useEffect)依赖项
  7. ref作为其他hooks(useMemo, useCallback, useEffect)依赖项

原理

//本质上是记忆hook,但也可作为data hook,可以简单的用useState模拟useRef:
const useRef = (initialValue) => {
  const [ref] = useState({ current: initialValue});
  return ref
}

11.为什么 React 现在要推行函数式组件,用 class 不好吗?

我认为fb团队花那么大的力气,拽着整个React社区往一个前所未有的方向前进,原因肯定不仅仅是“写法更优雅”。真正的原因如下:

  1. Hooks是比HOCrender props更优雅的逻辑复用方式。这个是很多人喊“真香”的原因。优雅的逻辑复用方式,会促进一个更加蓬勃的生态,这对于原本生态就很强的React来说是如虎添翼。会有更多的人愿意把自己的逻辑抽离成hooks(因为真的太优雅了),发布为library,为react生态添砖加瓦(看看这段时间各种基于hooks的状态管理工具)。我还认为,很快会出现一些“hooks生态圈”的“lodash”。
  2. 函数式组件的心智模型更加“声明式”。hooks(主要是useEffect)取代了生命周期的概念(减少API),让开发者的代码更加“声明化”:
  • 旧的思维:“我在这个生命周期要检查props.A和state.B(props和state),如果改变的话就触发xxx副作用”。这种思维在后续修改逻辑的时候很容易漏掉检查项,造成bug。
  • 新的思维:“我的组件有xxx这个副作用,这个副作用依赖的数据是props.A和state.B”。从过去的命令式转变成了声明式编程。
  • 其实仔细想一想,人们过去使用生命周期不就是为了判断执行副作用的时机吗?现在hooks直接给你一个声明副作用的API,使得生命周期变成了一个“底层概念”,无需开发者考虑。开发者工作在更高的抽象层次上了。
  • 类似的道理,除了声明副作用的API,react还提供了声明“密集计算”的API(useMemo),取代了过去“在生命周期做dirty检查,将计算结果缓存在state里”的做法。React内核帮你维护缓存,你只需要声明数据的计算逻辑以及数据的依赖。
  1. 函数式组件的心智模型更加“函数式”。react团队正在循序渐进地教育社区,为未来的并发模式打下基础。(其实react从一开始就受到了很多函数式编程的影响,现在推行函数式组件算是“回归初心”)。下面我会详细讨论函数式组件的心智模型。

12.React Hooks为什么更容易复用

这点应该是react hooks最大的优点,它通过自定义hooks来复用状态,从而解决了类组件有些时候难以复用逻辑的问题。hooks是怎么解决这个复用的问题呢,具体如下:

  1. 每调用useHook一次都会生成一份独立的状态,这个没有什么黑魔法,函数每次调用都会开辟一份独立的内存空间。
  2. 虽然状态(from useState)和副作用(useEffect)的存在依赖于组件,但它们可以在组件外部进行定义。这点是class component做不到的,你无法在外部声明state和副作用(如componentDidMount)。

上面这两点,高阶组件和renderProps也同样能做到。但hooks实现起来的代码量更少,以及更直观(代码可读性)

13.为什么React Hooks的底层是数组?

function useState(initialValue) {
  var state = initialValue;
  function setState(newState) {
    state = newState;
    render()
  }
  return [state, setState]
}

// useEffect
let _deps;
function useEffect(callback,depArray) {
  const hasNoDeps = !depArray; // 如果 dependencies 不存在
  const hasChangedDeps = _deps
  ? !depArray.every((el, i) => el === _deps[i]) // 两次的 dependencies 是否完全相等
  : true;
  if(hasNoDeps || hasChangedDeps) {
    callback();
    _dep = depArray
  }
}

到现在为止,我们已经实现了可以工作的 useState 和 useEffect。但是有一个很大的问题:它俩都只能使用一次,因为只有一个 _state 和 一个 _deps。比如

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

const [username, setUsername] = useState('fan');

count 和 username 永远是相等的,因为他们共用了一个 _state,并没有地方能分别存储两个值。我们需要可以存储多个 _state 和 _deps。

14.如何清除上一次副作用

副作用函数还可以通过返回一个函数来指定如何清除副作用,为防止内存泄漏,清除函数会在组件卸载前执行。

如果组件多次渲染,则在执行下一个 effect 之前,上一个 effect 就已被清除, 即执行return的函数。

import React, {useState, useEffect} from "react";
export default function UpdateTitleEffectHook() {
    const [number, setNumber] = useState(0)
    useEffect(() => {
        document.title = `${number}`
        console.log(`执行更新:${number}`)
        return ()=>{
            console.log(`清除上一次副作用:${number}`)
        }
    })
    return (
        <>
            <p>{number}</p>
            <button onClick={() => setNumber(number + 1)}>+</button>
        </>
    )
}

点击几次后,你会发现输出如下结果:

执行更新:0
清除上一次副作用:0
执行更新:1
清除上一次副作用:1
执行更新:2
清除上一次副作用:2
执行更新:3

那么为什么在浏览器渲染完后,再执行清理的方法还能找到上次的state呢?原因很简单,我们在useEffect中返回的是一个函数,这形成了一个闭包,这能保证我们上一次执行函数存储的变量不被销毁和污染。

15.React hooks依赖追踪

React 中的 useEffect hook 允许在每次渲染之后运行某些副作用(如请求数据或使用 storage 等 Web APIs),并在下次执行回调之前或当组件卸载时运行一些清理工作

默认情况下,所有用 useEffect 注册的函数都会在每次渲染之后运行,但可以定义真实依赖的状态和属性,以使 React 在相关依赖没有改变的情况下(如由 state 中的其他部分引起的渲染)跳过某些 useEffect hook 执行

// 传递一个依赖项的数组作为 useEffect hook 的第二个参数,只有当 name 改变时才会更新 localStorage
function Form() {
  const [name, setName] = useState('Mary');
  const [surname, setSurname] = useState('Poppins');
  useEffect(function persistForm() {
      localStorage.setItem('formData', name);
  }, [name]);
  // ...
}

显然,使用 React Hooks 时忘记在依赖项数组中详尽地声明所有依赖项很容易发生,会导致 useEffect 回调 "以依赖和引用了上一次渲染的陈旧数据而非最新数据" 从而无法被更新而告终

解决方案:

  • eslint-plugin-react-hooks 包含了一条 lint 提示关于丢失依赖项的规则
  • useCallback 和 useMemo 也使用依赖项数组参数,以分别决定其是否应该返回缓存过的( memoized)与上一次执行相同的版本的回调或值。

16.为什么ReactHooks中不能有条件判断

  1. 初次渲染的时候,按照 useState,useEffect 的顺序,把 state,deps 等按顺序塞到 memoizedState 数组中。
  2. 更新的时候,按照顺序,从 memoizedState 中把上次记录的值拿出来。

Q:为什么只能在函数最外层调用 Hook?为什么不要在循环、条件判断或者子函数中调用。

A:memoizedState 数组是按 hook定义的顺序来放置数据的,如果 hook 顺序变化,memoizedState 并不会感知到。

Q:自定义的 Hook 是如何影响使用它的函数组件的?

A:共享同一个 memoizedState,共享同一个顺序。

Q:“Capture Value” 特性是如何产生的?

A:每一次 ReRender 的时候,都是重新去执行函数组件了,对于之前已经执行过的函数组件,并不会做任何操作

17.在两个组件中使用相同的 Hook 会共享 state 吗?

不会。自定义 Hook 是一种重用_状态逻辑_的机制(例如设置为订阅并存储当前值),所以每次使用自定义 Hook 时,其中的所有 state 和副作用都是完全隔离的。

18.useImperativeHandle

有时候,我们可能不想将整个子组件暴露给父组件,而只是暴露出父组件需要的值或者方法,这样可以让代码更加明确。而useImperativeHandleApi就是帮助我们做这件事的。

语法:

useImperativeHandle(ref, createHandle, [deps])

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。

一个例子🌰:

const TextInput =  forwardRef((props,ref) => {
  const inputRef = useRef();
  // 关键代码
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} />
})
function TextInputWithFocusButton() {
  // 关键代码
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // 关键代码,`current` 指向已挂载到 DOM 上的文本输入元素
    inputEl.current.focus();
  };
  return (
    <>
      // 关键代码
      <TextInput ref={inputEl}></TextInput>
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

这样,我们也可以使用current.focus()来事input聚焦。这里要注意的是,子组件TextInput中的useRef对象,只是用来获取input元素的,大家不要和父组件的useRef混淆了。

如果你发现我的解答有什么问题,欢迎评论和私信我。


企业微信

p9-juejin.byteimg.com/tos-cn-i-k3…