关于React hooks的一些故事

概要

为什么需要hooks

class 组件的不足

  • 难以复用组件间状态逻辑:组件状态逻辑的复用,需要 props render高阶组件等解决方案,但是此类解决方案的抽象封装将会导致层级冗余,形成“嵌套地狱”

什么是状态逻辑?

在React中,我们可以将"状态逻辑"理解为与组件的数据、状态和行为相关的代码。这些代码用于控制组件如何工作,包括如何存储和更新数据、如何响应用户的操作以及如何处理外部事件。

比如,假设你有一个计数器组件。这个组件的状态逻辑包括:

  • 存储计数值的数据。
  • 响应用户点击按钮的操作,以增加或减少计数值。
  • 更新UI以显示最新的计数值。

这些操作和代码都可以被称为"状态逻辑",因为它们管理着组件的状态和行为。

props render是怎么实现的?

这是一种通过将函数作为props传递给子组件的方式来实现状态逻辑的复用。父组件将状态逻辑封装在一个函数中,然后将该函数作为props传递给子组件,子组件可以调用这个函数以获取状态数据和处理逻辑。这样可以实现状态逻辑在多个组件之间的共享。

class ParentComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  increment = () => {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return (
      <ChildComponent
        count={this.state.count}
        increment={this.increment}
      />
    );
  }
}

class ChildComponent extends React.Component {
  render() {
    const { count, increment } = this.props;
    return (
      <div>
        <p>Count: {count}</p>
        <button onClick={increment}>Increment</button>
      </div>
    );
  }
}

高阶组件是怎么实现的?

高阶组件是一个函数,接受一个组件作为参数,并返回一个新的组件,该新组件具有额外的状态逻辑。高阶组件可以用于包装多个组件,使它们共享相同的状态逻辑。

// 高阶组件,用于添加计数器逻辑
function withCounter(WrappedComponent) {
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.state = { count: 0 };
    }

    increment = () => {
      this.setState({ count: this.state.count + 1 });
    }

    render() {
      return (
        <WrappedComponent
          count={this.state.count}
          increment={this.increment}
          {...this.props}
        />
      );
    }
  }
}

class Counter extends React.Component {
  render() {
    const { count, increment } = this.props;
    return (
      <div>
        <p>Count: {count}</p>
        <button onClick={increment}>Increment</button>
      </div>
    );
  }
}

const CounterWithState = withCounter(Counter);
  • 难以维护复杂组件

    • 许多不相干的逻辑代码被混杂在同一个生命周期中,相关联的逻辑代码被拆分到不同声明周期当中,容易遗忘导致产生bug
    • 组件常常充斥着状态逻辑的访问和处理,不能拆分为更小的粒度,可通过状态管理库集中管理状态,但耦合了状态管理库又会导致组件复用性降低
  • this 指向问题:在 JavaScript 中,class 的方法默认不会绑定 this,当调用 class 的方法时 this 的值为 undefined,为了在方法中访问 this 则必须在构造器中绑定使用 class fields 语法(实验性语法)

import React, { Component } from 'react';

class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    // 在构造器中手动绑定方法
    this.increment = this.increment.bind(this);
  }

  increment() {
    // 此处的 this 为组件实例
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        {/* 注意:必须调用绑定后的方法 */}
        <button onClick={this.increment}>Increment</button>
      </div>
    );
  }
}

export default Counter;
  • 难以对 class 进行编译优化:由于 JavaScript 历史设计原因,使用 class 组件会让组件预编译过程中变得难以进行优化,如 class 不能很好的压缩,并且会使热重载出现不稳定的情况

Hook 的优势

先看行代码,就是跟上面一样的功能,点击按钮,实现计数增加。

代码量少、简介,还不用担心this的问题

import React, { useState } from 'react';

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

  const increment = () => {
    // 在函数组件中,不需要担心 this,直接使用 state 和 setState
    setCount(count + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

export default Counter;
  • Hook 使你在无需改变组件结构的情况下复用状态逻辑(自定义 Hook)
  • Hook 将组件中互相关联的部分拆分成更小的函数(比如设置订阅或请求数据)
  • Hook 使你在非 class 的情况下可以使用更多的 React 特性

Hook 使用规则

Hook 就是 Javascript 函数,使用它们时有两个额外的规则:

  • 只能在函数外层调用 Hook,不要在循环、条件判断或者子函数中调用
  • 只能在 React 的函数组件自定义 Hook 中调用 Hook。不要在其他 JavaScript 函数中调用

在组件中 React 是通过判断 Hook 调用的顺序来判断某个 state 对应的 useState的,所以必须保证 Hook 的调用顺序在多次渲染之间保持一致,React 才能正确地将内部 state 和对应的 Hook 进行关联

我的疑问:只能在 React 的函数组件和自定义 Hook 中调用 Hook。不要在其他 JavaScript 函数中调用。为什么不能在非组件外的文件调用?

React的Hooks(包括useState、useEffect、useContext等)是为React组件设计的,它们依赖于React的渲染周期和组件实例来工作。这就是为什么官方文档建议只在React函数组件和自定义Hooks中调用Hooks的原因。让我解释为什么不应该在非组件外的文件中调用Hooks:

  • Hooks依赖React的内部机制:Hooks依赖React内部的机制来跟踪状态和副作用。React会在组件渲染时自动管理Hooks的状态。如果在非组件外的文件中调用Hooks,React不会知道何时应该进行状态更新和副作用的处理,这可能导致不一致的行为。
  • Hooks与组件生命周期相关:Hooks的行为和效果是与组件的生命周期密切相关的。例如,useEffect Hook用于处理副作用,它会在组件挂载、更新或卸载时执行不同的操作。如果在非组件外的文件中调用useEffect,就没有明确的组件生命周期可供其依赖,因此它可能无法按预期工作。
  • Hooks与组件状态关联:Hooks通常用于管理组件的状态,而组件状态是组件内部的概念。在非组件外的文件中调用Hooks可能无法访问组件的状态,因此可能无法执行相关操作。

总之,Hooks是为React组件设计的,它们的工作方式和行为与React组件的生命周期和状态管理紧密相关。在非组件外的文件中调用Hooks可能会导致不一致的行为和错误,因此官方建议只在React函数组件和自定义Hooks中使用它们,以确保它们按照预期工作。如果你想在非组件外的地方共享状态和逻辑,可以考虑使用其他JavaScript模块化和状态管理方案,如Redux或Mobx。

我的疑问:hooks跟哪些生命周期有关?

React Hooks 是为了在函数组件中实现与组件生命周期相关的功能而引入的。它们使得函数组件能够具备类似于Class Components中的生命周期方法的能力,以处理副作用、订阅数据、管理状态等。

细说常用hooks

useState

useState Hook 用于在函数组件中管理局部状态。它相当于Class Component中的this.state和this.setState。您可以在函数组件的渲染过程中调用它,以创建和更新组件的状态。

使用方法:

基本结构
const [age, setAge] = useState(28);

按照惯例,状态变量的命名方式是使用数组解构,类似于 [something, setSomething]。

组件添加状态时
import { useState } from 'react';

function MyComponent() {
  const [age, setAge] = useState(42);
  const [name, setName] = useState('Taylor');
  // ...
function handleClick() {
  setName('Robin');
}

age是初始化的值,setAge是用来更新的

基于先前的状态更新状态
function handleClick() {
  setAge(age + 1); // setAge(42 + 1)
  setAge(age + 1); // setAge(42 + 1)
  setAge(age + 1); // setAge(42 + 1)
}

这是因为在已经运行的代码中调用set函数不会更新年龄(age)状态变量。因此,每次调用setAge(age + 1)都变成了setAge(43)

为了解决这个问题,可以使用更新函数

function handleClick() {
  setAge(a => a + 1); // setAge(42 => 43)
  setAge(a => a + 1); // setAge(43 => 44)
  setAge(a => a + 1); // setAge(44 => 45)
}

在这里,a => a + 1 是您的更新函数。它接受待处理的状态并从中计算下一个状态。

React将您的更新函数放入队列中。然后,在下一次渲染期间,它将按照相同的顺序调用它们:

  • a => a + 1 将接收到42作为待处理状态,并返回43作为下一个状态。
  • a => a + 1 将接收到43作为待处理状态,并返回44作为下一个状态。
  • a => a + 1 将接收到44作为待处理状态,并返回45作为下一个状态。

没有其他排队的更新,所以最终React将45存储为当前状态。

按照惯例,通常会将待处理状态的参数命名为状态变量名称的首字母,比如使用a表示age。但是,您也可以根据自己的喜好将其命名为prevAge或其他您认为更清晰的名称。

更新状态中的对象和数组

不要直接修改状态的值,状态不可变性,即使那些复杂数据结构确实可以直接被修改

// 🚩 Don't mutate an object in state like this:
form.firstName = 'Taylor';
// ✅ Replace state with a new object
setForm({
  ...form,
  firstName: 'Taylor'
});
避免重新创建初始状态
function TodoList() {
  const [todos, setTodos] = useState(createInitialTodos());
  // ...

虽然createInitialTodos()的结果仅在初始渲染时使用,但您仍然在每次渲染时调用此函数。如果它正在创建大型数组或执行昂贵的计算,这可能是浪费的。

为了解决这个问题,您可以将它作为初始化函数传递给useState

function TodoList() {
  const [todos, setTodos] = useState(createInitialTodos);
  // ...

使用key来重置状态

在渲染列表时,您经常会遇到key属性。然而,它还有另一个作用。

您可以通过向组件传递不同的key来重置组件的状态。在这个示例中,"重置"按钮会更改version状态变量,我们将其作为一个key传递给Form组件。当key发生变化时,React会从头开始重新创建Form组件(以及其所有子组件),因此它的状态会被重置。

import { useState } from 'react';

export default function App() {
  const [version, setVersion] = useState(0);

  function handleReset() {
    setVersion(version + 1);
  }

  return (
    <>
      <button onClick={handleReset}>Reset</button>
      <Form key={version} />
    </>
  );
}

function Form() {
  const [name, setName] = useState('Taylor');

  return (
    <>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <p>Hello, {name}.</p>
    </>
  );
}

我的疑问:为啥不直接用唯一值,用状态来定义不会很麻烦吗?

chat-gpt说:

使用useState来定义key值并不是React中常见的做法,而且通常是没有必要的。key属性通常用于唯一标识列表中的元素,以便React可以有效地管理它们的渲染和更新。在实际应用中,key通常与渲染的数据相关,而不是与组件的状态相关。

在列表中,key的主要作用是帮助React识别元素是否发生了变化,以便在更新时执行最小的DOM更改,提高性能。key的值应该是稳定的,不应该在组件的渲染中频繁变化。

如果您使用useState来定义key值,那么key将与组件的状态相关,这可能会导致不稳定的key值,从而可能引发问题。此外,key的目的是标识元素,而不是用于状态管理。

总之,通常情况下,不建议使用useState来定义key值,而是将key与渲染的数据相关联,以确保稳定性和性能。如果您需要在组件内部管理状态,可以使用其他变量或状态来实现,而不是与key相关的内容。

常见问题:

我更新了状态,为什么日志显示的是旧值 ?

调用 set 函数不会改变运行代码中的状态:

function handleClick() {
  console.log(count);  // 0

  setCount(count + 1); // Request a re-render with 1
  console.log(count);  // Still 0!

  setTimeout(() => {
    console.log(count); // Also 0!
  }, 5000);
}

这是因为状态类似于快照(snapshot截屏)。如果想获取到最新状态需要重新渲染组件

如果需要使用下一个状态,可以先将其保存在变量中,然后再传递给 set 函数

const nextCount = count + 1;
setCount(nextCount);

console.log(count);     // 0
console.log(nextCount); // 1
 我更新了状态,但我的screen没有更新

通过 Object.is 比较下一个状态是否等于上一个状态,如果等于,React 将忽略您的更新。这种情况通常发生在直接更改状态中的对象或数组时

obj.x = 10;  // 🚩 Wrong: mutating existing object
setObj(obj); // 🚩 Doesn't do anything

您更改了现有的 obj 对象并将其传回给 setObj,因此 React 忽略了更新。

要解决这个问题,你需要确保始终替换状态中的对象和数组,而不是更改它们:

// ✅ Correct: creating a new object
setObj({
  ...obj,
  x: 10
});
 Object.is怎么比较的呢?它不是比较是值吗?

Object.is 确实是用来比较值的,但React使用它来比较两个状态对象或值的引用,以判断它们是否相同。在JavaScript中,当两个对象或值引用相同的内存地址时,它们被认为是相同的。这是因为两个引用相同的对象意味着它们代表相同的数据。

React之所以使用 Object.is 进行引用比较,是因为在JavaScript中,相等性比较(equality comparison)通常是基于引用而不是值的。例如,两个相等但不同的对象会被认为不相等,因为它们引用不同的内存地址。

所以,当您直接修改旧状态的值时,虽然新状态的值与旧状态的值不同,但它们引用相同的对象,因此React认为状态没有真正变化,从而不会触发重新渲染。

为了确保React可以检测到状态的变化,应该创建一个新的对象或值,并将其作为新状态传递给状态更新函数,以确保新状态引用不同的内存地址。这样React会正确地认为状态发生了变化并触发重新渲染。

我有报错,多次渲染!

您可能会收到这样的错误提示: 重新渲染次数过多。React 会限制render次数,以防止出现无限循环。通常,这意味着您在render过程中无条件地设置了状态,因此您的组件进入了一个循环:render、设置状态(导致一次render)、render、设置状态(导致一次render),以此类推。这种情况通常是由于指定事件处理程序时出错造成的:

// 🚩 Wrong: calls the handler during render
// 在这个示例中,handleClick()会在渲染期间立即执行,并且不会等到用户点击按钮时才执行。
return <button onClick={handleClick()}>Click me</button>

// ✅ Correct: passes down the event handler
return <button onClick={handleClick}>Click me</button>

// ✅ Correct: passes down an inline function
return <button onClick={(e) => handleClick(e)}>Click me</button>
为什么set state会导致一次render?

在React中,组件的渲染是指React将组件的UI呈现到屏幕上的过程。当组件的状态发生变化或接收到新的props时,React会重新渲染组件以反映这些变化。这是React的核心工作原理之一。

当您调用setState或使用Hooks的状态更新函数时,React会将组件标记为“需要重新渲染”。这意味着React将在适当的时间重新运行组件的render方法,以生成新的UI以反映最新的状态。然后,新的UI将被呈现到屏幕上

初始化函数和更新函数运行了两次?

在严格模式下,React 会调用某些函数两次而不是一次:

function TodoList() {
  // This component function will run twice for every render.

  const [todos, setTodos] = useState(() => {
    // This initializer function will run twice during initialization.
    return createTodos();
  });

  function handleClick() {
    setTodos(prevTodos => {
      // This updater function will run twice for every click.
      return [...prevTodos, createTodo()];
    });
  }
  // ...

这种开发专用行为有助于保持组件的纯净。React 使用其中一个调用的结果,而忽略另一个调用的结果。只要您的组件、初始化器和更新器函数是纯粹的。但是,如果它们不小心不纯,这就会帮助你注意到错误。

setTodos(prevTodos => {
  // 🚩 Mistake: mutating state
  prevTodos.push(createTodo());
});

由于 React 会调用两次更新器函数,因此您会看到 todo 被添加了两次,这样您就会知道其中有一个错误。在本示例中,您可以通过替换数组而不是更改数组来修复错误:

setTodos(prevTodos => {
  // ✅ Correct: replacing with new state
  return [...prevTodos, createTodo()];
});

既然这个更新器函数是纯粹的,再多调用一次也不会对行为造成影响。这就是为什么 React 调用两次会帮助你发现错误。只有组件、初始化器和更新器函数需要是纯函数。事件处理程序不需要是纯函数,因此 React 绝不会调用您的事件处理程序两次。 

我试图将状态设置为一个函数,但它却被调用了
const [fn, setFn] = useState(someFunction);

function handleClick() {
  setFn(someOtherFunction);
}

由于您传递的是函数,React 会假定 someFunction 是初始化函数,而 someOtherFunction 是更新函数,因此会尝试调用它们并存储结果。要真正存储一个函数,您必须在两种情况下都在它们前面加上 () =>。然后,React 就会存储您传递的函数。

const [fn, setFn] = useState(() => someFunction);

function handleClick() {
  setFn(() => someOtherFunction);
}

 useContext

useContext 是一个 React 挂钩,可让您从组件中读取和订阅上下文。

使用方法:

在组件的顶层调用 useContext 来读取和订阅上下文。

import { useContext } from 'react';

function MyComponent() {
  const theme = useContext(ThemeContext);
  // ...
可以传数据到树最深一层 
import { useContext } from 'react';

function Button() {
  const theme = useContext(ThemeContext);
  // ...

useContext 会返回所传递上下文的上下文值。要确定上下文值,React 会搜索组件树,并找到与该特定上下文最接近的上下文provider。

function MyPage() {
  return (
    <ThemeContext.Provider value="dark">
      <Form />
    </ThemeContext.Provider>
  );
}

function Form() {
  // ... renders buttons inside ...
}

 将上下文传递给 Button,只需将它或它的一个父组件包装到相应的上下文提供程序中即可:提供程序和 Button 之间有多少层组件并不重要。当窗体中任意位置的 Button 调用 useContext(ThemeContext) 时,它将收到 "dark "作为值。

可以通过上下文来更新数据

通常,您会希望上下文随时间而变化。要更新上下文,可将其与state相结合。在父组件中声明一个状态变量,并将当前state作为context传递给provider。

function MyPage() {
  const [theme, setTheme] = useState('dark');
  return (
    <ThemeContext.Provider value={theme}>
      <Form />
      <Button onClick={() => {
        setTheme('light');
      }}>
        Switch to light theme
      </Button>
    </ThemeContext.Provider>
  );
}

设置默认值 
如果 React 在父树上找不到任何特定上下文的提供者,则 useContext() 返回的上下文值将等于您在创建该上下文时指定的默认值:

const ThemeContext = createContext(null);

默认值永不更改。如果要更新上下文,可按上述方法与状态一起使用。

通常情况下,可以使用一些更有意义的值代替 null 作为默认值,例如:

const ThemeContext = createContext('light');

这样,如果您不小心在没有相应provider的情况下渲染了某个组件,它也不会损坏。这也有助于你的组件在测试环境中良好运行,而无需在测试中设置大量的provider。

所以useContext是使用上下文,createContext是创建上下文,里面可以设置初始值。

覆盖树的一部分上下文 


您可以通过在具有不同值的提供者中包装树的一部分来覆盖上下文

<ThemeContext.Provider value="dark">
  ...
  <ThemeContext.Provider value="light">
    <Footer />
  </ThemeContext.Provider>
  ...
</ThemeContext.Provider>
  • 外部的 <ThemeContext.Provider value="dark"> 设置了整个树的主题为 "dark",这是树的默认主题。
  • 内部的 <ThemeContext.Provider value="light"> 包含了一个名为 <Footer /> 的组件。在这个内部提供者中,主题被覆盖为 "light",这意味着 <Footer /> 及其子组件将使用 "light" 主题,而不受外部提供者的影响
当传递对象和函数时,优化re-renders

您可以通过上下文传递任何值,包括对象和函数。

function MyApp() {
  const [currentUser, setCurrentUser] = useState(null);

  function login(response) {
    storeCredentials(response.credentials);
    setCurrentUser(response.user);
  }

  return (
    <AuthContext.Provider value={{ currentUser, login }}>
      <Page />
    </AuthContext.Provider>
  );
}

在React中,当上下文(Context)的值发生变化时,所有依赖该上下文的组件都会重新渲染。这是React的正常行为。

在您的示例中,MyApp 组件提供了一个名为 AuthContext 的上下文,其中包含了 currentUser 和 login。每当 MyApp 重新渲染时,它会创建一个新的上下文值对象,即使 currentUser 没有变化。

问题在于,即使 currentUser 没有变化,所有依赖于 AuthContext 的组件也会重新渲染,这可能会降低应用程序的性能,尤其在大型应用程序中。

为了解决这个性能问题,作者建议以下两种优化:

  • 使用 useCallback 包装 login 函数:这意味着 login 函数只在 setCurrentUser 改变时才会重新创建,而不是在每次 MyApp 重新渲染时都重新创建。
  • 使用 useMemo 包装 contextValue 对象:这样可以确保只有在 currentUser 或 login 发生变化时,contextValue 对象才会重新创建。

通过这两个优化,您可以避免不必要的重新渲染,从而提高应用程序的性能,尤其是在具有复杂上下文结构的大型应用程序中。这些优化的关键点是,只有在相关数据发生变化时,才会重新创建相关对象,而不是在每次渲染时都重新创建。这有助于减少不必要的渲染和提高性能。

import { useCallback, useMemo } from 'react';

function MyApp() {
  const [currentUser, setCurrentUser] = useState(null);

  const login = useCallback((response) => {
    storeCredentials(response.credentials);
    setCurrentUser(response.user);
  }, []);

  const contextValue = useMemo(() => ({
    currentUser,
    login
  }), [currentUser, login]);

  return (
    <AuthContext.Provider value={contextValue}>
      <Page />
    </AuthContext.Provider>
  );
}

常见问题:

总是从上下文中得到未定义的值 

可能是没有写value传值

// 🚩 Doesn't work: no value prop
<ThemeContext.Provider>
   <Button />
</ThemeContext.Provider>

使用错误的传值属性 

// 🚩 Doesn't work: prop should be called "value"
<ThemeContext.Provider theme={theme}>
   <Button />
</ThemeContext.Provider>

正确的方法 

// ✅ Passing the value prop
<ThemeContext.Provider value={theme}>
   <Button />
</ThemeContext.Provider>

useEffect

useEffect 是一个 React 挂钩,可让您将组件与外部系统同步。

useEffect Hook 用于处理副作用操作,例如数据获取、订阅、手动DOM操作等。它在组件渲染后执行,相当于Class Component的componentDidMount、componentDidUpdate 和 componentWillUnmount生命周期方法的组合。您可以使用它来管理与组件生命周期相关的操作。

使用方法:

useEffect(setup, dependencies?)

需要在组件的顶端使用useEffect
import { useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);
  // ...
}
与外部系统相连接

有些组件在页面上显示时需要与网络、浏览器 API 或第三方库保持连接。这些系统不受 React 控制,因此称为外部系统。

要将组件连接到外部系统,请在组件的顶层调用 useEffect:

import { useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useEffect(() => {
  	const connection = createConnection(serverUrl, roomId);
    connection.connect();
  	return () => {
      connection.disconnect();
  	};
  }, [serverUrl, roomId]);
  // ...
}

您需要向 useEffect 传递两个参数:

一个包含连接到该系统的setup代码的setup函数。

const connection = createConnection(serverUrl, roomId);
connection.connect();

它应返回一个包含clean up代码的clean up函数,用于断开与该系统的连接。

 connection.disconnect();

一个依赖关系列表,包括在这些函数中使用的组件的每个值。

[serverUrl, roomId]
  1. 设置函数和清理函数:在useEffect中,您应该提供两个函数。第一个是设置函数(setup function),用于执行与外部资源的初始化和连接操作。第二个是清理函数(cleanup function),用于在组件卸载或依赖项发生更改时执行资源清理和断开连接的操作。

  2. 指定依赖项:您应该明确指定依赖项数组,以告诉React什么情况下应该运行设置和清理函数。这个依赖项数组通常包含与设置和清理函数相关的变量,以便在这些变量发生变化时触发副作用操作。

  3. 正确处理多次调用useEffect的一个重要特性是它可能会在组件挂载、更新和卸载时多次调用。这意味着您需要编写设置和清理函数,以确保在每个调用时都能正确处理。通常情况下,您需要在清理函数中撤销设置函数的操作。

  4. 开发模式下的额外调用:在开发模式下,React可能会在实际挂载和更新之前调用清理函数,以验证代码的正确性。这是一种用于捕获潜在问题的机制,因此确保您的设置和清理函数逻辑正确是很重要的。

让我们以上面的例子来说明这个顺序。

当上面的 ChatRoom 组件添加到页面时,它会使用初始的 serverUrl 和 roomId 连接到聊天室。如果服务器地址或房间 ID 因重新渲染而发生变化(例如,用户在下拉菜单中选择了不同的聊天室),Effect 就会断开与前一个房间的连接,并连接到下一个房间。当 ChatRoom 组件从页面中移除时,您的 "效果 "将最后一次断开连接。

为什么需要cleanup 函数?

这个函数会在组件卸载或依赖项发生变化时执行。通常,这个清理函数用于撤销或清理在设置函数(setup function)中创建的副作用操作,以确保资源被正确释放。

如果清理函数没有需要执行的操作,或者不需要清理任何资源,您可以简单地省略return语句,或者返回undefined。这是合法的,而且非常常见。

用自定义钩子包装effect
function useChatRoom({ serverUrl, roomId }) {
  useEffect(() => {
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]);
}

它展示了如何创建一个名为useChatRoom的自定义Hook,该Hook封装了与聊天室相关的副作用逻辑。这个自定义Hook接受一个包含serverUrlroomId的对象作为参数,然后在内部使用useEffect来执行相应的操作,包括创建连接和处理清理逻辑。通过这种方式,它将副作用逻辑隐藏在一个更容易理解和使用的API背后。 

可以使用自定义钩子 

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl
  });
  // ...
为什么我要使用自定义钩子包裹useeffect?
  1. 抽象副作用逻辑:将useEffect包装在自定义钩子中可以将复杂的副作用逻辑抽象出来,使组件的代码更加清晰和可读。这使得组件可以专注于其主要功能,而不必关心底层副作用的实现细节。

  2. 提供更具声明性的API:通过使用自定义钩子,您可以为副作用逻辑提供一个更具声明性的API。这使得在组件中使用副作用逻辑变得更加简单和直观,而不必处理useEffect的各种参数和依赖项。

  3. 提高可重用性:将副作用逻辑封装在自定义钩子中使得它可以在多个组件中共享和重复使用。这有助于减少代码重复,并确保在应用程序的不同部分中使用相同的副作用逻辑,从而提高了代码的可维护性。

  4. 测试性:通过将副作用逻辑封装在自定义钩子中,您可以更容易地进行单元测试,因为您可以专门测试自定义钩子的行为,而不必关心组件中的副作用逻辑。

  5. 提高可维护性:自定义钩子的存在可以使代码更加模块化和可维护。如果需要更改副作用逻辑,只需在自定义钩子中进行修改,而不必在多个组件中进行修改。

如何在React组件中控制一个非React小部件

代码在此~

clever-bird-4gvnqk - CodeSandbox

示例中介绍了一个名为Map的React组件,它接受一个名为zoomLevel的属性。这个组件的目标是在zoomLevel属性更改时,调用第三方的MapWidget类的方法来保持地图的缩放级别与React组件的状态同步。

使用useEffect:在示例中,使用了useEffect钩子。它的作用是监视zoomLevel属性的变化,并在每次变化时执行一段代码。这段代码负责创建或获取MapWidget类的实例,并调用setZoom()方法以将缩放级别设置为与zoomLevel属性相同。

清理函数:在这个示例中,没有必要编写清理函数。因为MapWidget类只管理了传递给它的DOM节点,当Map React组件从树中移除后,浏览器的JavaScript引擎会自动对这个DOM节点和MapWidget类实例进行垃圾回收,因此不需要手动清理。

使用useEffect来获取数据
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';

export default function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);

  useEffect(() => {
    let ignore = false;
    setBio(null);
    fetchBio(person).then(result => {
      if (!ignore) {
        setBio(result);
      }
    });
    return () => {
      ignore = true;
    };
  }, [person]);

  // ...
  • 手动数据获取:在某些情况下,您可能需要在React组件中手动获取数据,而不是使用React框架提供的数据获取机制(例如useEffect和fetch或axios库等)。在这个示例中,代码展示了如何使用useEffect来执行数据获取操作。
  • 避免竞态条件:在网络请求中,数据的返回时间可能与请求的发送顺序不一致。这就是所谓的“竞态条件”(race conditions)。为了避免竞态条件,代码中引入了一个名为ignore的变量,初始值设置为false,并在清理函数中将其设置为true。
  • ignore变量的作用:ignore变量用于标记是否应该忽略网络请求的结果。当ignore为true时,表示应该忽略任何网络响应,因为组件可能已经卸载或在发送新请求之前已经发生了其他重要的状态更改。当ignore为false时,表示可以安全地处理网络响应并将数据设置到bio状态中。(比如说,还没请求完数据,这时候我换了个状态了,这时候不需要继续请求了)
  • 清理函数:useEffect返回一个清理函数,用于在组件卸载或依赖项变化时执行。在这个示例中,清理函数的主要作用是将ignore标志设置为true,以停止处理来自之前发出的未完成的网络请求的结果。
指定响应式依赖
function ChatRoom({ roomId }) { // This is a reactive value
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // This is a reactive value too

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // This Effect reads these reactive values
    connection.connect();
    return () => connection.disconnect();
  }, [serverUrl, roomId]); // ✅ So you must specify them as dependencies of your Effect
  // ...
}
  1. 响应式值:在这个上下文中,响应式值包括props、状态变量(例如,使用useState声明的变量)以及直接在组件内部声明的函数。这些值是可以改变的,可以触发组件的重新渲染。

  2. 指定依赖性:当在组件中定义useEffect时,应该将其依赖性指定为第二个参数,这是一个值数组。这些值在useEffect内部使用,并在它们发生变化时触发它的执行。

  3. 示例:提供的示例是一个ChatRoom组件。在useEffect内部,它使用serverUrlroomId连接到服务器。这两个值都在组件内部声明,因此它们被视为响应式值,必须包含在依赖数组中。

  4. Linter警告:如果在依赖性列表中省略了任何响应式值,您的linter(假设它已正确配置为React)将生成警告,指示您应该通过包括那些缺失的依赖性来修复它。

  5. 证明非响应性:要删除一个依赖性,您需要向linter证明它不需要成为依赖性。在示例中,serverUrl移动到组件外部,使其不再是响应式的,因为它在重新渲染期间不会更改。因此,它不需要在依赖数组中。

  6. 空依赖数组如果useEffect代码块不使用任何响应式值,您可以指定一个空的依赖数组([])。这意味着效果将只在需要时运行,不会在任何props或状态值更改时重新运行,类似于类组件中的componentDidMount

在effect从过去的state来更新state

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(count + 1); // You want to increment the counter every second...
    }, 1000)
    return () => clearInterval(intervalId);
  }, [count]); // 🚩 ... but specifying `count` as a dependency always resets the interval.
  // ...
}

由于 count 是一个reactive value,因此必须在依赖项列表中指定。但是,每次计数发生变化时,Effect 都要重新清理和设置。这并不理想。

要解决这个问题,可以将 c => c + 1 状态更新器传递给 setCount:(这个在usestate中强调过这个问题)

import { useState, useEffect } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(c => c + 1); // ✅ Pass a state updater
    }, 1000);
    return () => clearInterval(intervalId);
  }, []); // ✅ Now count is not a dependency

  return <h1>{count}</h1>;
}
 移除没必要的对象依赖项

如果您的效果依赖于渲染过程中创建的对象或函数,那么它可能会过于频繁地运行。例如,由于每次渲染的选项对象都不同,因此每次渲染后该特效都会重新连接:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const options = { // 🚩 This object is created from scratch on every re-render
    serverUrl: serverUrl,
    roomId: roomId
  };

  useEffect(() => {
    const connection = createConnection(options); // It's used inside the Effect
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // 🚩 As a result, these dependencies are always different on a re-render
  // ...

避免使用在渲染过程中创建的对象作为依赖项。应在 "effect "中创建对象:

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
移除没有必要的function 依赖项

如果您的效果依赖于渲染过程中创建的对象或函数,那么它可能会过于频繁地运行。例如,该效果在每次呈现后都会重新连接,因为每次呈现的 createOptions 函数都不同:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  function createOptions() { // 🚩 This function is created from scratch on every re-render
    return {
      serverUrl: serverUrl,
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions(); // It's used inside the Effect
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // 🚩 As a result, these dependencies are always different on a re-render
  // ...

每次重新渲染时从头开始创建一个函数本身并不是问题。您不需要对此进行优化。但是,如果将其作为 "effect "的依赖项,就会导致 "effect "在每次重新渲染后重新运行。

请避免将渲染过程中创建的函数用作依赖项。取而代之的是,在 "effect "中声明它:

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() {
      return {
        serverUrl: serverUrl,
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
从effect中读取最新的props和state

默认情况下,当您从一个 "效果 "中读取一个反应值时,必须将其添加为依赖关系。这将确保您的 "effect "对该值的每次变化都做出 "反应"。

不过,有时您会希望读取某个 "effect "的最新道具和状态,而不对它们做出 "反应"。例如,假设您想记录每次访问页面时购物车中物品的数量:

function Page({ url, shoppingCart }) {
  useEffect(() => {
    logVisit(url, shoppingCart.length);
  }, [url, shoppingCart]); // ✅ All dependencies declared
  // ...
}

如果您想在每次更改 url 后都记录新页面的访问,但只在购物车更改时不记录新页面的访问,该怎么办?

在不违反反应规则的情况下,您无法将 shoppingCart 从依赖关系中排除。不过,您可以表示不希望一段代码对变化做出 "反应",即使它是在一个 "效果 "中被调用的。使用 useEffectEvent 钩子声明一个 "effect事件",并将读取 shoppingCart 的代码移入其中:

function Page({ url, shoppingCart }) {
  const onVisit = useEffectEvent(visitedUrl => {
    logVisit(visitedUrl, shoppingCart.length)
  });

  useEffect(() => {
    onVisit(url);
  }, [url]); // ✅ All dependencies declared
  // ...
}
 在服务器和客户端显示不同内容

这段内容在讨论使用服务器渲染时,React组件可能在服务器和客户端两个不同的地方被渲染,并提到了一个特殊情况:有时候你希望在客户端显示不同的东西。

通常情况下,React组件在服务器上渲染生成网页的初始HTML,然后在客户端上继续工作,使网页具有交互性。为了让这个过程正常工作,初始渲染的输出在服务器和客户端上必须一模一样。

但是,有些情况下,你可能需要在客户端上显示不同的内容。比如,如果你的应用程序需要读取浏览器本地存储(localStorage)中的数据,这是在服务器上无法做的。在这种情况下,你可以使用一种特殊的技巧来实现这个目标。

function MyComponent() {
  const [didMount, setDidMount] = useState(false);

  useEffect(() => {
    setDidMount(true);
  }, []);

  if (didMount) {
    // ... return client-only JSX ...
  }  else {
    // ... return initial JSX ...
  }
}

在示例中,我们使用了一个叫做`didMount`的变量。在组件加载后,通过`useEffect`,我们将`didMount`的值设置为`true`,这时可以切换到客户端渲染的内容。在组件加载期间,用户将看到初始的渲染内容,然后当客户端加载和渲染时,会显示不同的内容。

需要注意的是,这种技巧应该谨慎使用,因为用户在加载过程中可能会看到初始内容,这可能需要一些时间。所以最好不要让组件在外观上出现剧烈变化。在很多情况下,可以通过使用CSS来有条件地显示不同的内容来避免使用这种技巧。

常见问题:

组件挂载时, effect会运行两次 

当开启严格模式时,React 会在setup之前额外运行一次setup和clean up。

这是一个压力测试,用于验证您的 Effect 的逻辑实现是否正确。如果这会导致明显的问题,则说明您的clean up函数缺少某些逻辑。清理函数应停止或撤销设置函数正在执行的任何操作。经验法则是,用户不应该区分调用一次设置(如在生产过程中)和设置 → 清理 → 设置序列(如在开发过程中)。

Effect在每次重新渲染后都会运行 


首先,检查您是否忘记指定依赖关系数组:

useEffect(() => {
  // ...
}); // 🚩 No dependency array: re-runs after every render!

如果您已经指定了依赖关系数组,但 Effect 仍在循环中重新运行,那是因为每次重新渲染时,您的依赖关系之一都不相同。

您可以通过在控制台手动记录依赖关系来调试这个问题:

  useEffect(() => {
    // ..
  }, [serverUrl, roomId]);

  console.log([serverUrl, roomId]);
Effect一直在无限循环中重复运行 


如果Effect在无限循环中运行,那么有两件事必须为真:

Effect正在更新某些状态。
该状态会导致重新渲染,从而使Effect的依赖关系发生变化。

即使组件没有卸载,我的clean up仍在运行 


清理功能不仅会在卸载时运行,而且会在每次重新渲染依赖关系发生变化之前运行。
此外,在开发过程中,React 会在组件挂载后立即额外运行一次 setup+cleanup。

如果有清理代码却没有相应的设置代码,通常就是代码有问题:

useEffect(() => {
  // 🔴 Avoid: Cleanup logic without corresponding setup logic
  return () => {
    doSomething();
  };
}, []);

clean up应与setup相对称,并应停止或撤销setup逻辑所做的任何操作:

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);

useRef

使用方法

在组件的顶层调用 useRef 来声明 ref。

import { useRef } from 'react';

function MyComponent() {
  const intervalRef = useRef(0);
  const inputRef = useRef(null);
  // ...
使用 ref 引用值 


在组件的顶层调用 useRef 来声明一个或多个 ref。

import { useRef } from 'react';

function Stopwatch() {
  const intervalRef = useRef(0);
  // ...

useRef 返回一个 ref 对象,该对象的单个当前属性最初设置为您提供的初始值。

下次渲染时,useRef 将返回相同的对象。你可以更改它的 current 属性来存储信息,并在以后读取。这可能会让你想起状态,但两者之间有一个重要的区别。

更改 ref 不会触发重新渲染。这意味着 refs 非常适合用于存储不会影响组件视觉输出的信息。例如,如果您需要存储一个区间 ID 并在以后检索它,您可以将它放在一个 ref 中。要更新 ref 中的值,需要手动更改其当前属性:

function handleStartClick() {
  const intervalId = setInterval(() => {
    // ...
  }, 1000);
  intervalRef.current = intervalId;
}

之后,您可以从 ref 中读取该区间 ID,以便调用清除该区间: 

function handleStopClick() {
  const intervalId = intervalRef.current;
  clearInterval(intervalId);
}

使用 ref 可以确保

  • 可以在两次重新渲染之间存储信息(不像普通变量,每次重新渲染都会重置)。
  • 更改信息不会触发重新渲染(不像状态变量会触发重新渲染)。
  • 信息是每个组件副本的本地信息(与外部变量不同,外部变量是共享的)。
  • 更改 ref 不会触发重新渲染,因此 refs 不适合用于存储要在屏幕上显示的信息。为此,请使用状态。了解更多有关在 useRef 和 useState 之间做出选择的信息。
使用 ref 操作 DOM 


使用 ref 操作 DOM 尤为常见。React 为此提供了内置支持。

首先,声明一个初始值为 null 的 ref 对象:

import { useRef } from 'react';

function MyComponent() {
  const inputRef = useRef(null);
  // ...

然后将 ref 对象作为 ref 属性传递给要操作的 DOM 节点的 JSX: 

 // ...
  return <input ref={inputRef} />;

在 React 创建DOM节点并将其显示在屏幕上之后,React会将您的ref对象的current属性设置为该DOM节点。现在,您可以访问<input>的DOM节点并调用focus()等方法:

  function handleClick() {
    inputRef.current.focus();
  }

当节点从屏幕上移除时,React 会将 current 属性设置为空。

避免重新创建 ref 内容 


React 会保存一次初始 ref 值,并在下次渲染时忽略它。

function Video() {
  const playerRef = useRef(new VideoPlayer());
  // ...

虽然 new VideoPlayer() 的结果仅用于初始呈现,但每次呈现时仍会调用该函数。如果要创建昂贵的对象,这可能会造成浪费。

为了解决这个问题,你可以这样初始化 ref:

function Video() {
  const playerRef = useRef(null);
  if (playerRef.current === null) {
    playerRef.current = new VideoPlayer();
  }
  // ...

常见问题

无法为自定义组件获取引用 


如果您尝试像下面这样向自己的组件传递一个 ref:

const inputRef = useRef(null);

return <MyInput ref={inputRef} />;

可能会报错

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

默认情况下,您自己的组件不会向其中的 DOM 节点公开引用。

要解决这个问题,请找到您想获取引用的组件:

export default function MyInput({ value, onChange }) {
  return (
    <input
      value={value}
      onChange={onChange}
    />
  );
}

然后像这样用 forwardRef 包起来:

import { forwardRef } from 'react';

const MyInput = forwardRef(({ value, onChange }, ref) => {
  return (
    <input
      value={value}
      onChange={onChange}
      ref={ref}
    />
  );
});

export default MyInput;

然后,父组件就可以获得指向它的引用。

参考文章:

作者:杰凌
链接:https://juejin.cn/post/7041551402048421901
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

React Hooks 完全使用指南 - 掘金 (juejin.cn)

react 官方文档

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值