前端技能树,面试复习第 26 天—— React Hook 的实现原理 | useState | 生命周期

Posted 前端修罗场

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端技能树,面试复习第 26 天—— React Hook 的实现原理 | useState | 生命周期相关的知识,希望对你有一定的参考价值。

  • ⭐️ 本文首发自 前端修罗场(点击加入社区,参与学习打卡,获取奖励)是一个由资深开发者独立运行的专业技术社区,我专注 Web 技术、答疑解惑、面试辅导以及职业发展。
  • 🏆 目前就职于全球前100强知名外企,曾就职于头部互联网企业 | 清华大学出版社签约作者 | CSDN 银牌讲师 | 蓝桥云课2021年度人气作者Top2 | CSDN 博客专家 | 阿里云专家博主 | 华为云享专家
    出品著作
    《ElementUI 详解与实战》| 《ThreeJS 在网页中创建动画》|《PWA 渐进式Web应用开发》
  • 🔥 本文已收录至前端面试题库专栏 《前端面试宝典》(点击订阅)
  • 💯 此专栏文章针对准备找工作的应届生、初中高级前端工程师设计,以及想要巩固前端基础知识的开发者文章中包含 90% 的面试考点和实际开发中需要掌握的知识,内容按点分类,环环相扣重要的是,形成了前端知识技能树,多数同学已经通过面试拿到 offer,并提升了自己的前端技术水平。
    作者对重点考题做了详细解析和重点标注(建议在 PC 上阅读),并通过图解、思维导图的方式帮你降低了学习成本,节省备考时间,尽可能快地提升。可以说目前市面上没有像这样完善的面试备考指南!
  • ❤️ 现在订阅专栏,私聊博主,即可享受一次免费的模拟面试、简历修改、答疑服务拉你进前端答疑互助交流群,享受博主答疑服务和备考服务,优质文章分享。【私聊备注:前端修罗场】
  • 🚀 加入前端修罗场,从此快人一步,和一群人一起更进一步
  • 👉🏻 目前 ¥19.9 优惠中,即将恢复至 ¥99.9 原价 ~

1. 对 React Hook 的理解,它的实现原理是什么

React-Hooks 是 React 团队在 React 组件开发实践中,逐渐认知到的一个改进点,这背后其实涉及对类组件函数组件两种组件形式的思考和侧重。

(1)类组件:所谓类组件,就是基于 ES6 Class 这种写法通过继承 React.Component 得来的 React 组件。以下是一个类组件:

class DemoClass extends React.Component 
  state = 
    text: ""
  ;
  componentDidMount() 
    //...
  
  changeText = (newText) => 
    this.setState(
      text: newText
    );
  ;

  render() 
    return (
      <div className="demoClass">
        <p>this.state.text</p>
        <button onClick=this.changeText>修改</button>
      </div>
    );
  

可以看出,React 类组件内部预置了相当多的“现成的东西”等着我们去调度/定制,state 和生命周期就是这些“现成东西”中的典型。要想得到这些东西,难度也不大,只需要继承一个 React.Component 即可。

当然,这也是类组件的一个不便,它太繁杂了,对于解决许多问题来说,编写一个类组件实在是一个过于复杂的姿势。复杂的姿势必然带来高昂的理解成本,这也是我们所不想看到的。除此之外,由于开发者编写的逻辑在封装后是和组件粘在一起的,这就使得类组件内部的逻辑难以实现拆分和复用。

(2)函数组件:函数组件就是以函数的形态存在的 React 组件。早期并没有 React-Hooks,函数组件内部无法定义和维护 state,因此它还有一个别名叫“无状态组件”。以下是一个函数组件:

function DemoFunction(props) 
  const  text  = props
  return (
    <div className="demoFunction">
      <p>`函数组件接收的内容:[$text]`</p>
    </div>
  );

相比于类组件,函数组件肉眼可见的特质自然包括轻量、灵活、易于组织和维护、较低的学习成本等。

通过对比,从形态上可以对两种组件做区分,它们之间的区别如下:

  • 类组件需要继承 class,函数组件不需要;
  • 类组件可以访问生命周期方法,函数组件不能;
  • 类组件中可以获取到实例化后的 this,并基于这个 this 做各种各样的事情,而函数组件不可以;
  • 类组件中可以定义并维护 state(状态),而函数组件不可以;

除此之外,还有一些其他的不同。通过上面的区别,我们不能说谁好谁坏,它们各有自己的优势。在 React-Hooks 出现之前,类组件的能力边界明显强于函数组件。

实际上,类组件和函数组件之间,是面向对象和函数式编程这两套不同的设计思想之间的差异。而函数组件更加契合 React 框架的设计理念:

React 组件本身的定位就是函数,一个输入数据、输出 UI 的函数。作为开发者,我们编写的是声明式的代码,而 React 框架的主要工作,就是及时地把声明式的代码转换为命令式的 DOM 操作,把数据层面的描述映射到用户可见的 UI 变化中去。这就意味着从原则上来讲,React 的数据应该总是紧紧地和渲染绑定在一起的,而类组件做不到这一点。函数组件就真正地将数据和渲染绑定到了一起。函数组件是一个更加匹配其设计理念、也更有利于逻辑拆分与重用的组件表达形式。

为了能让开发者更好的的去编写函数式组件。于是,React-Hooks 便应运而生。

React-Hooks 是一套能够使函数组件更强大、更灵活的“钩子”。

函数组件比起类组件少了很多东西,比如生命周期、对 state 的管理等。这就给函数组件的使用带来了非常多的局限性,导致我们并不能使用函数这种形式,写出一个真正的全功能的组件。而 React-Hooks 的出现,就是为了帮助函数组件补齐这些(相对于类组件来说)缺失的能力。

如果说函数组件是一台轻巧的快艇,那么 React-Hooks 就是一个内容丰富的零部件箱。“重装战舰”所预置的那些设备,这个箱子里基本全都有,同时它还不强制你全都要,而是允许你自由地选择和使用你需要的那些能力,然后将这些能力以 Hook(钩子)的形式“钩”进你的组件里,从而定制出一个最适合你的“专属战舰”。

2. 为什么 useState 要使用数组而不是对象

useState 的用法:

const [count, setCount] = useState(0)

可以看到 useState 返回的是一个数组,那么为什么是返回数组而不是返回对象呢?

这里用到了解构赋值,所以先来看一下ES6 的解构赋值:

数组的解构赋值
const foo = [1, 2, 3];
const [one, two, three] = foo;
console.log(one);   // 1
console.log(two);   // 2
console.log(three); // 3
对象的解构赋值
const user = 
  id: 888,
  name: "xiaoxin"
;
const  id, name  = user;
console.log(id);    // 888
console.log(name);  // "xiaoxin"

看完这两个例子,答案应该就出来了:

  • 如果 useState 返回的是数组,那么使用者可以对数组中的元素命名,代码看起来也比较干净
  • 如果 useState 返回的是对象,在解构对象的时候必须要和 useState 内部实现返回的对象同名,想要使用多次的话,必须得设置别名才能使用返回值

下面来看看如果 useState 返回对象的情况:

// 第一次使用
const  state, setState  = useState(false);
// 第二次使用
const  state: counter, setState: setCounter  = useState(0)

这里可以看到,返回对象的使用方式还是挺麻烦的,更何况实际项目中会使用的更频繁。

总结:useState 返回的是 array 而不是 object 的原因就是为了降低使用的复杂度,返回数组的话可以直接根据顺序解构,而返回对象的话要想使用多次就需要定义别名了。

3. React Hooks 解决了哪些问题?

React Hooks 主要解决了以下问题:

(1)在组件之间复用状态逻辑很难

React 没有提供将可复用性行为“附加”到组件的途径(例如,把组件连接到 store)解决此类问题可以使用 render props 和 高阶组件。但是这类方案需要重新组织组件结构,这可能会很麻烦,并且会使代码难以理解。由 providers,consumers,高阶组件,render props 等其他抽象层组成的组件会形成“嵌套地狱”。 尽管可以在 DevTools 过滤掉它们,但这说明了一个更深层次的问题:React 需要为共享状态逻辑提供更好的原生途径。

可以使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使我们在无需修改组件结构的情况下复用状态逻辑。 这使得在组件间或社区内共享 Hook 变得更便捷。

(2)复杂组件变得难以理解

在组件中,每个生命周期常常包含一些不相关的逻辑。例如,组件常常在 componentDidMountcomponentDidUpdate 中获取数据。但是,同一个 componentDidMount 中可能也包含很多其它的逻辑,如设置事件监听,而之后需在 componentWillUnmount 中清除。相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起。如此很容易产生 bug,并且导致逻辑不一致。

在多数情况下,不可能将组件拆分为更小的粒度,因为状态逻辑无处不在。这也给测试带来了一定挑战。同时,这也是很多人将 React 与状态管理库结合使用的原因之一。但是,这往往会引入了很多抽象概念,需要你在不同的文件之间来回切换,使得复用变得更加困难。

为了解决这个问题,Hook 将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据),而并非强制按照生命周期划分。你还可以使用 reducer 来管理组件的内部状态,使其更加可预测

(3)难以理解的 class

除了代码复用和代码管理会遇到困难外,class 是学习 React 的一大屏障。我们必须去理解 javascriptthis 的工作方式,这与其他语言存在巨大差异。还不能忘记绑定事件处理器。没有稳定的语法提案,这些代码非常冗余。大家可以很好地理解 props,state 和自顶向下的数据流,但对 class 却一筹莫展。即便在有经验的 React 开发者之间,对于函数组件与 class 组件的差异也存在分歧,甚至还要区分两种组件的使用场景。

为了解决这些问题,Hook 使你在非 class 的情况下可以使用更多的 React 特性。 从概念上讲,React 组件一直更像是函数。而 Hook 则拥抱了函数,同时也没有牺牲 React 的精神原则。Hook 提供了问题的解决方案,无需学习复杂的函数式或响应式编程技术。

4. React Hook 的使用限制有哪些?

React Hooks 的限制主要有两条:

  • 不要在循环、条件或嵌套函数中调用 Hook;
  • 需要在 React 的函数组件中调用 Hook。

那为什么会有这样的限制呢?Hooks 的设计初衷是为了改进 React 组件的开发模式。在旧有的开发模式下遇到了三个问题。

  • 组件之间难以复用状态逻辑。过去常见的解决方案是高阶组件、render props 及状态管理框架。
  • 复杂的组件变得难以理解。 生命周期函数与业务逻辑耦合太深,导致关联部分难以拆分。
  • 人和机器都很容易混淆类。常见的有 this 的问题,但在 React 团队中还有类难以优化的问题,希望在编译优化层面做出一些改进。

这三个问题在一定程度上阻碍了 React 的后续发展,所以为了解决这三个问题,Hooks 基于函数组件开始设计。然而第三个问题决定了 Hooks 只支持函数组件。

那为什么不要在循环、条件或嵌套函数中调用 Hook 呢?因为 Hooks 的设计是基于数组(链表)实现。在调用时按顺序加入数组中,如果使用循环、条件或嵌套函数很有可能导致数组取值错位,执行错误的 Hook。当然,实质上 React 的源码里不是数组,是链表。

这些限制会在编码上造成一定程度的心智负担,新手可能会写错,为了避免这样的情况,可以引入 ESLint 的 Hooks 检查插件进行预防。

5. useEffect 与 useLayoutEffect 的区别

  • useEffect 可以实现我们在类组件中的 componentDidMount、ComponentDidUpdate和componentWillUnmount 的功能呢,只不过被合并成为一个API
  • 与 componentDidMount 或 componentDidUpdate 不同的是,使用 useEffect 不会阻塞浏览器更新屏幕,这让你的应用看起来响应更快。大多数情况下,effect 不需要同步地执行。在


(1)共同点

  • 运用效果:useEffect 与 useLayoutEffect 两者都是用于处理副作用,这些副作用包括改变 DOM、设置订阅、操作定时器等。在函数组件内部操作副作用是不被允许的,所以需要使用这两个函数去处理。
  • 使用方式: useEffect 与 useLayoutEffect 两者底层的函数签名是完全一致的,都是调用的 mountEffectImpl 方法,在使用上也没什么差异,基本可以直接替换。

(2)不同点

  • 使用场景:useEffect 在 React 的渲染过程中是被异步调用的,用于绝大多数场景;而 useLayoutEffect 会在所有的 DOM 变更之后同步调用,主要用于处理 DOM 操作、调整样式、避免页面闪烁等问题。也正因为是同步处理,所以需要避免在 useLayoutEffect 做计算量较大的耗时任务从而造成阻塞。
  • 使用效果:useEffect 是按照顺序执行代码的,改变屏幕像素之后执行(先渲染,后改变DOM),当改变屏幕内容时可能会产生闪烁;useLayoutEffect 是 改变屏幕像素之前就执行了(会推迟页面显示的事件,先改变DOM后渲染),不会产生闪烁。useLayoutEffect总是比useEffect先执行

在未来的趋势上,两个 API 是会长期共存的,暂时没有删减合并的计划,需要开发者根据场景去自行选择。React 团队的建议非常实用,如果实在分不清,先用 useEffect,一般问题不大;如果页面有异常,再直接替换为 useLayoutEffect 即可。

6. React Hooks 在平时开发中需要注意的问题和原因

(1)不要在循环,条件或嵌套函数中调用 Hook,必须始终在 React 函数的顶层使用 Hook

这是因为 React 需要利用调用顺序来正确更新相应的状态,以及调用相应的钩子函数。一旦在循环或条件分支语句中调用 Hook,就容易导致调用顺序的不一致性,从而产生难以预料到的后果。

(2)使用 useState 时候,使用 push,pop,splice 等直接更改数组对象的坑

使用 push 直接更改数组无法获取到新值,原因是 push,pop,splice 是直接修改原数组,react 会认为 state 并没有发生变化,无法更新。
应该采用析构方式,但是在 class 里面不会有这个问题

  • 因为 useState 的更新函数会直接替换老的 state,所以我们在对对象或者数组的 state 做增删的时候不能像以前直接对数组使用 push,pop,splice 等直接改变数组的方法。正确的方法应该是使用数组解构生成一个新数组。

代码示例:

function Indicatorfilter() 
  let [num,setNums] = useState([0,1,2,3])
  const test = () => 
    // 这里坑是直接采用push去更新num
    // setNums(num)是无法更新num的
    // 必须使用num = [...num ,1]
    num.push(1)
    // num = [...num ,1]
    setNums(num)
  
return (
    <div className='filter'>
      <div onClick=test>测试</div>
        <div>
          num.map((item,index) => (
              <div key=index>item</div>
          ))
      </div>
    </div>
  )


class Indicatorfilter extends React.Component<any,any>
  constructor(props:any)
      super(props)
      this.state = 
          nums:[1,2,3]
      
      this.test = this.test.bind(this)
  

  test()
      // class采用同样的方式是没有问题的
      this.state.nums.push(1)
      this.setState(
          nums: this.state.nums
      )
  

  render()
      let nums = this.state
      return(
          <div>
              <div onClick=this.test>测试</div>
                  <div>
                      nums.map((item:any,index:number) => (
                          <div key=index>item</div>
                      ))
                  </div>
          </div>

      )
  

(3)useState 设置状态的时候,只有第一次生效,后期需要更新状态,必须通过useEffect

TableDeail 是一个公共组件,在调用它的父组件里面,我们通过 set 改变 columns 的值,以为传递给 TableDeail 的 columns 是最新的值,所以 tabColumn 每次也是最新的值,但是实际tabColumn 是最开始的值,不会随着 columns 的更新而更新:

const TableDeail = (
    columns,
:TableData) => 
    const [tabColumn, setTabColumn] = useState(columns) 


// 正确的做法是通过useEffect改变这个值
const TableDeail = (
    columns,
:TableData) => 
    const [tabColumn, setTabColumn] = useState(columns) 
    useEffect(() =>setTabColumn(columns),[columns])

(4)善用 useCallback (缓存函数)

父组件传递给子组件事件句柄时,如果我们没有任何参数变动可能会选用 useMemo (缓存值)。但是每一次父组件渲染子组件即使没变化也会跟着渲染一次

  • useCallback 是缓存的函数,父组件给子组件传递参数为普通函数时,父组件每次更新子组件都会更新,但是大部分情况子组件更新是没必要的。
  • 这时候我们用 useCallback 来定义函数,并把这个函数传递给子组件,子组件就会根据依赖项再更新了.
import React,  useState, useCallback, useEffect  from 'react';
function Parent() 
    const [count, setCount] = useState(1);
    const [val, setVal] = useState('');
 
    const callback = useCallback(() => 
        return count;
    , [count]);
    return <div>
        <h4>count</h4>
        <Child callback=callback/>
        <div>
            <button onClick=() => setCount(count + 1)>+</button>
            <input value=val onChange=event => setVal(event.target.value)/>
        </div>
    </div>;

 
function Child( callback ) 
    const [count, setCount] = useState(() => callback());
    useEffect(() => 
      console.log(123);
        setCount(callback());
    , [callback]);
    return <div>
        count
    </div>


export default Parent

uesMemo
useMemo 可以初略理解为Vue中的计算属性,在依赖的某一属性改变的时候自动执行里面的计算并返回最终的值(并缓存,依赖性改变时才重新计算),对于性能消耗比较大的一定要使用useMemo, 不然每次更新都会重新计算。

import React,  useState, useMemo  from 'react'

function Parent() 
  const [count, setCount] = useState(0)
  const [price, setPrice] = useState(1)
  const handleCountAdd = () => setCount(count + 1)
  const handlePriceAdd = () => setPrice(price + 1)
  // 使用useMemo在count和price改变时自动计算总价
  const all = useMemo(() => count * price, [count, price])
  return (
    <div>
      parent, count
      <button onClick=handleCountAdd>增加数量</button>
      <button onClick=handlePriceAdd>增加价格</button>
      <p>count: count, price: price all: all</p>
    </div>
  )


export default Parent

(5)不要滥用 useContext
useContext 可以实现类似 react-redux 插件的功能,上层组件使用 createContext 创建一个context,并使用 <MyContext.Provider> 来传递context,下层组件使用 useContext来接收context

可以使用基于 useContext 封装的状态管理工具。
https://segmentfault.com/a/1190000019679398

(6)useReducer
useReducer 和 redux 中 reducer 类似,useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法

import React,  useReducer  from 'react'

function Parent() 
  const reducer = (state, action) => 
    switch (action.type) 
      case 'add':
        return count: state.count + 1
      case 'reduce':
        return count: state.count - 1
      default:
        throw new Error()
    
  
  let initialState = 0
  const init = (initialState) => (
    count: initialState
  )

const [state, dispatch] = useReducer(reducer, initialState, init)
//第三个参数为惰性初始化函数,可以用来进行复杂计算返回最终的initialState,如果initialState较简单可以忽略此参数
return (
    <div>
      <p>state.count</p>
      <button onClick=() => dispatch(type: 'add')>add</button>
      <button onClick=() => dispatch(type: 'reduce')>reduce</button>
    </div>
)

7. React Hooks 和生命周期的关系?

函数组件 的本质是函数,没有 state 的概念的,因此不存在生命周期一说,仅仅是一个 render 函数而已。

但是引入 Hooks 之后就变得不同了,它能让组件在不使用 class 的情况下拥有 state,所以就有了生命周期的概念,所谓的生命周期其实就是 useStateuseEffect()useLayoutEffect()

即:

  • Hooks 组件(使用了Hooks的函数组件)有生命周期,

  • 函数组件(未使用Hooks的函数组件)是没有生命周期的

下面是具体的 class 与 Hooks 的生命周期对应关系

  • constructor函数组件不需要构造函数,可以通过调用 useState 来初始化 state。如果计算的代价比较昂贵,也可以传一个函数给 useState
const [num, UpdateNum] = useState(0)
  • getDerivedStateFromProps:一般情况下,我们不需要使用它,可以在渲染过程中更新 state,以达到实现 getDerivedStateFromProps 的目的。
function ScrollView(row) 
  let [isScrollingDown前端技能树,面试复习第 24 天—— React-Router 了解多少,说一说它的实现原理是什么

前端技能树,面试复习第 28 天—— React 综合问题解析 | 实现一个全局 Dialog | React 的设计理念 | SSR | JSX 的原理 | HOC 高阶组件

前端技能树,面试复习第 22 天—— React 的生命周期,前后发生了哪些变化,为什么有些生命周期废弃了

前端技能树,面试复习第 27 天—— React Diff 算法的原理,和 Vue 有什么区别 | 虚拟 DOM | key 的原理,为什么要用

前端技能树,面试复习第 48 天—— Vuex 原理

前端技能树,面试复习第 54 天—— 手写代码:情景题