React:函数组件内外的事件处理程序哪里更好?

5
考虑这个简单的React文本框组件:
```jsx ```
其中,`label`是标签名,`value`是文本框中的值,`onChange`是当文本框的值发生变化时触发的回调函数。
import React, { useState } from "react";
import { TextField, Grid } from "@mui/material";

const handleEnter = (event) => {
    console.log("In handleEnter");
    if (event.key == "Enter" && event.shiftKey) {
        console.log("Detected Shift+Enter key");
    } else if (event.key == "Enter") {
        console.log("Detected Enter key");
    }
};

export default function Example() {
    const [value, setValue] = React.useState("");

    const handleChatBoxChange = (event) => {
        setValue(event.target.value);
    };

    return (
        <Grid container>
            <TextField
                id='chatBox'
                maxRows={90}
                onKeyDown={handleEnter}
                value={value}
                onChange={handleChatBoxChange}
                variant='filled'
            ></TextField>
        </Grid>
    );
}

这是一个非常简单的组件,使用handleEnter来检测用户按下Enter或Shift+Enter组合键。我意识到,handleEnter的实现可以写在函数Example()之外,也可以写在Example()内部。但是,handleChatBoxChange()不能置于外部,因为它依赖于通过useState()创建的setValue(而useState()必须在函数组件中使用)。那么如何最好地实践呢?

2个回答

3
简洁回答:“尽可能在室外”
继续阅读了解原因...
在这个答案中,我忽略了代码风格和源代码模块管理(这些是观点)。
阅读这个答案时需要理解的一些重要概念是 纯函数闭包"不纯"函数(所有不属于前两种类别的函数)。这已经在 Stack Overflow 上广泛讨论过,所以我在这里不再赘述。
除了你对闭包本质的观察之外,它对于引用相等性(对象标识)的影响是非常不同的。考虑以下情况:
在下面的代码中(你可以想象它是一个名为component.jsx的模块文件),回调函数是在组件外部的模块顶层创建的。它在程序运行时初始化一次,并且在JS内存的生命周期内其值永远不会改变:
const handleClick = ev => {/*...*/};

const Component = () => {
  return <div onClick={handleClick}></div>;
};

相反,下面的代码展示了回调函数被创建在组件内部。因为组件本身只是函数,这意味着每次组件被 React 调用时,回调函数都会被重新创建(完全是一个新的函数,而不是等同于前一次渲染中的“自身版本”)。
const Component = () => {
  const handleClick = ev => {/*...*/};
  return <div onClick={handleClick}></div>;
};

在上述情况下,当该函数直接用作子ReactElement的事件处理程序回调时,它不会产生可观察的差异。但是,当将函数传递给子组件或在回调函数中将其用于useEffect钩子时,情况变得更加复杂:
import {useEffect} from 'react';

const handleClick = ev => {/*...*/};

const Component = () => {
  useEffect(() => {
    const message = `The name of the function is ${
      handleClick.name
    }, and this message will only appear in the console when this component first mounts`;
    console.log(message);
  }, []);

  return <div onClick={handleClick}></div>;
};

在上面的例子中,该函数不需要包含在效果的依赖列表中,因为其值不能且永远不会改变。它没有在组件内创建,也没有从props中接收到,因此引用将是稳定的。
相反,考虑下面的例子:
import {useEffect} from 'react';

const Component = () => {
  const handleClick = ev => {/*...*/};

  useEffect(() => {
    const message = `The name of the function is ${
      handleClick.name
    }, and this message will appear in the console EVERY time this component renders`;
    console.log(message);
  }, [handleClick]);

  return <div onClick={handleClick}></div>;
};

在上面的代码中,每次组件在渲染期间被调用时都会重新创建函数,导致效果检测到依赖数组中的不同值并再次运行效果。因此,出于这个原因,必须将函数包含在依赖数组中。
当将函数作为prop传递给子组件时,相同的概念也适用。当组件接收到一个函数作为prop值(prop值只是函数组件对象参数中属性的值)时,它无法知道函数的引用稳定性,因此该函数必须始终包含在使用它的依赖列表中。
这导致了记忆化的概念和相关的内置hooks(例如useMemouseCallback)。 (类似于函数类型,记忆化是一个更大的主题,已经在SO和许多其他地方进行了涵盖。)
我将给出一个实际的例子,说明如何在结束本答案之前为闭包函数创建稳定的对象标识。
import {useCallback, useState} from 'react';

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

  const adjustCount = useCallback(
    (amount) => setCount(count => count + amount),
    [setCount],
  // ^^^^^^^^
  ); // It's optional to include the "setState" function provided by `useState`,
  // in the dependency array here (it's a special case exception because React
  // already guarantees that the "setState" function it gives you will remain
  // stable). However, it doesn't hurt to include it (although you might
  // choose to omit it if you're working on a refactor toward
  // performance micro-optimizations).

  // Now, when you pass that function to a child component as a prop value,
  // the object identity won't change on subsequent renders:
  //                                      vvvvvvvvvvv
  return <SomeChildComponent adjustCount={adjustCount} />;
};

感谢您提供深入的答案。在您的最后一个示例中,如果我们不使用"useCallback",那么当组件重新渲染时,SomeChildComponent是否也会重新渲染?而使用记忆化将不会重新渲染子组件,因为adjustCount将通过新的重新渲染而“持久化”而不改变身份。但是,如果父组件重新渲染,难道它不会自动重新渲染子组件吗? - Yui
@Yui 它并不能直接防止子组件重新渲染(这就是 React.memo API 的作用)——但是,如果不使用它,则使用 React.memo 创建的子组件始终会重新渲染,因为函数 prop 值会发生变化。 - jsejcksn

1
在我看来,“外部”或“内部”取决于您是否希望其他组件使用它。
如果handleEnter仅由Example()使用,则“内部”就可以了。
但是对于我来说,我会使用自定义钩子来封装它们:
function useTextInput() {
    const [value, setValue] = useState('');
    const handleChatBoxChange = (event) => {
        setValue(event.target.value);
    };

    const handleEnter = (event) => {
        console.log("In handleEnter");
        if (event.key == "Enter" && event.shiftKey) {
            console.log("Detected Shift+Enter key");
        } else if (event.key == "Enter") {
            console.log("Detected Enter key");
        }
    }

    return {
      value,
      setValue,
      onChange: handleChatBoxChange,
      onEnter: handleEnter   
    }
}

export default function Example() {
    const { value, setValue, onEnter, onChange } = useTextInput();

    return (
        <Grid container>
            <TextField
                id='chatBox'
                maxRows={90}
                onKeyDown={onEnter}
                value={value}
                onChange={onChange}
                variant='filled'
            ></TextField>
        </Grid>
    );
}

更新:顺便说一下,大多数情况下,我们不需要像@jsejcksn所说的那样过于考虑(但他是正确的)。当它发生时,我们可以考虑这个问题。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接