如何使用useEffect钩子注册事件?

156

我正在跟随 Udemy 课程学习如何使用 hooks 注册事件,教练提供了以下代码:

  const [userText, setUserText] = useState('');

  const handleUserKeyPress = event => {
    const { key, keyCode } = event;

    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(`${userText}${key}`);
    }
  };

  useEffect(() => {
    window.addEventListener('keydown', handleUserKeyPress);

    return () => {
      window.removeEventListener('keydown', handleUserKeyPress);
    };
  });

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
现在它的功能很好,但我并不确定这是否是正确的方式。原因是,如果我理解正确,在每次重新渲染时,事件将继续注册和注销,我只是认为这不是正确的做法。
所以我对 useEffect 钩子进行了轻微的修改如下:
useEffect(() => {
  window.addEventListener('keydown', handleUserKeyPress);

  return () => {
    window.removeEventListener('keydown', handleUserKeyPress);
  };
}, []);

通过将第二个参数设为空数组来让组件只运行一次效果,模仿componentDidMount。当我尝试结果时,很奇怪的是,每次我输入按键时,它不会附加文本,而是覆盖掉原来的内容。

我期望setUserText(${userText}${key});将新输入的按键附加到当前状态并设置为新状态,但实际上它忘记了旧状态并用新状态重写了它。

我们是否真的应该在每次重新渲染时注册和注销事件呢?

10个回答

177

在这种情况下,最好的方法是查看您在事件处理程序中正在执行的操作。

如果您只是使用先前的state来设置state,则最好使用回调模式,并仅在初始挂载时注册事件侦听器。

如果您不使用回调模式,则事件侦听器使用侦听器引用以及其词法作用域,但在每次渲染时都会创建一个具有更新闭包的新函数;因此,在处理程序中您将无法访问已更新的state。

const [userText, setUserText] = useState("");
const handleUserKeyPress = useCallback(event => {
    const { key, keyCode } = event;
    if(keyCode === 32 || (keyCode >= 65 && keyCode <= 90)){
        setUserText(prevUserText => `${prevUserText}${key}`);
    }
}, []);

useEffect(() => {
    window.addEventListener("keydown", handleUserKeyPress);
    return () => {
        window.removeEventListener("keydown", handleUserKeyPress);
    };
}, [handleUserKeyPress]);

  return (
      <div>
          <h1>Feel free to type!</h1>
          <blockquote>{userText}</blockquote>
      </div>
  );

2
但这将在每次按键时创建一个新的绑定函数。如果您关注性能,那么使用本地状态变量会更好。 - di3
1
如果您使用useCallback来定义一个带有空依赖数组的函数,那么您也不会遇到这个问题。 - Shubham Khatri
12
我认为重要的是使用prevUserText来引用userText之前的状态。如果我需要访问多个状态怎么办?我如何访问所有先前的状态? - Joey Yi Zhao
1
如果我们使用 useReducer 而不是 useState,我们如何获取先前的状态? - Eesa
1
@tractatusviii 我认为这篇帖子会对你有所帮助:https://dev59.com/vVMI5IYBdhLWcg3wlMhZ#55854902 - Shubham Khatri
显示剩余9条评论

76

问题

[...] 在每次重新渲染时,事件将继续注册和注销,我认为这不是正确的做法。

你是对的。在 useEffect 中在每个渲染中重新启动事件处理没有意义。

[...] 空数组作为第二个参数,让组件只运行一次效果 [...] 每次我键入键时,它都被覆盖了,而不是添加。

这是 闭包陈旧值 的问题。

原因:在 useEffect 中使用的函数应该是 依赖项的一部分。你将什么也没有设置为依赖项 ([]),但仍然调用了 handleUserKeyPress,它本身读取 userText 状态。

解决方案

0. useEffectEvent (beta)

更新:React 开发人员提出了一个 RFC,其中包括新的 useEvent Hook(名称和功能略有改变),以解决此类与依赖项相关的事件问题。

在那之前,根据您的用例,有不同的替代方案:

1. 状态更新函数

setUserText(prev => `${prev}${key}`);

✔ 最小侵入式方法
✖ 只能访问自己的先前状态,而不能访问其他状态的Hooks

const App = () => {
  const [userText, setUserText] = useState("");

  useEffect(() => {
    const handleUserKeyPress = event => {
      const { key, keyCode } = event;

      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        setUserText(prev => `${prev}${key}`); // use updater function here
      }
    };

    window.addEventListener("keydown", handleUserKeyPress);
    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, []); // still no dependencies

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/react@16.13.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

2. useRef / 可变引用

const cbRef = useRef(handleUserKeyPress);
useEffect(() => { cbRef.current = handleUserKeyPress; }); // update each render
useEffect(() => {
    const cb = e => cbRef.current(e); // then use most recent cb value
    window.addEventListener("keydown", cb);
    return () => { window.removeEventListener("keydown", cb) };
}, []);

const App = () => {
  const [userText, setUserText] = useState("");

  const handleUserKeyPress = event => {
    const { key, keyCode } = event;

    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(`${userText}${key}`);
    }
  };

  const cbRef = useRef(handleUserKeyPress);

  useEffect(() => {
    cbRef.current = handleUserKeyPress;
  });

  useEffect(() => {
    const cb = e => cbRef.current(e);
    window.addEventListener("keydown", cb);

    return () => {
      window.removeEventListener("keydown", cb);
    };
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/react@16.13.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef, useCallback } = React</script>

✔ 可用于不通过数据流触发重新渲染的回调/事件处理程序
✔ 不需要管理依赖关系
✖ 更加命令式的方法
✖ React 文档只推荐作为最后的选择

请查看以下链接以获取更多信息: 1 2 3

3. useReducer - "作弊模式"

我们可以切换到 useReducer 并访问当前状态/props - 具有与 useState 相似的 API。

变体 2a: reducer 函数内的逻辑

const [userText, handleUserKeyPress] = useReducer((state, event) => {
    const { key, keyCode } = event;
    // isUpperCase is always the most recent state (no stale closure value)
    return `${state}${isUpperCase ? key.toUpperCase() : key}`;  
}, "");

const App = () => {
  const [isUpperCase, setUpperCase] = useState(false);
  const [userText, handleUserKeyPress] = useReducer((state, event) => {
    const { key, keyCode } = event;
    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      // isUpperCase is always the most recent state (no stale closure)
      return `${state}${isUpperCase ? key.toUpperCase() : key}`;
    }
  }, "");

  useEffect(() => {
    window.addEventListener("keydown", handleUserKeyPress);

    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
      <button style={{ width: "150px" }} onClick={() => setUpperCase(b => !b)}>
        {isUpperCase ? "Disable" : "Enable"} Upper Case
      </button>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/react@16.13.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

变体2b:在reducer函数外部处理逻辑 - 类似于useState的更新函数

const [userText, setUserText] = useReducer((state, action) =>
      typeof action === "function" ? action(state, isUpperCase) : action, "");
// ...
setUserText((prevState, isUpper) => `${prevState}${isUpper ? 
  key.toUpperCase() : key}`);

const App = () => {
  const [isUpperCase, setUpperCase] = useState(false);
  const [userText, setUserText] = useReducer(
    (state, action) =>
      typeof action === "function" ? action(state, isUpperCase) : action,
    ""
  );

  useEffect(() => {
    const handleUserKeyPress = event => {
      const { key, keyCode } = event;
      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        setUserText(
          (prevState, isUpper) =>
            `${prevState}${isUpper ? key.toUpperCase() : key}`
        );
      }
    };

    window.addEventListener("keydown", handleUserKeyPress);
    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
      <button style={{ width: "150px" }} onClick={() => setUpperCase(b => !b)}>
        {isUpperCase ? "Disable" : "Enable"} Upper Case
      </button>
    </div>
  );
}


ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/react@16.13.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>

✔ 无需管理依赖项
✔ 访问多个状态和属性
✔ 与useState相同的API
✔ 可扩展到更复杂的情况/减速器
✖ 内联减速器导致性能略低(有点可以忽略
✖ 减速器复杂度稍有增加

不适当的解决方案

useCallback

虽然它可以以各种方式应用,但useCallback不适用于这个特定的问题情况

原因:由于添加了依赖项 - 在这里是userText - 事件监听器将在每次按键时重新启动,在最好的情况下不会表现良好,或者更糟糕地导致不一致性。

const App = () => {
  const [userText, setUserText] = useState("");

  const handleUserKeyPress = useCallback(
    event => {
      const { key, keyCode } = event;

      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        setUserText(`${userText}${key}`);
      }
    },
    [userText]
  );

  useEffect(() => {
    window.addEventListener("keydown", handleUserKeyPress);

    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, [handleUserKeyPress]); // we rely directly on handler, indirectly on userText

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/react@16.13.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef, useCallback } = React</script>

useEffect内部声明处理程序函数

直接在useEffect内部声明事件处理程序函数useCallback存在更多或更少相同的问题,后者只会导致依赖关系略微间接。

换句话说:我们将函数直接放置在useEffect中,而不是通过useCallback添加额外的依赖层 - 但仍然需要设置所有依赖项,导致处理程序经常发生变化。

实际上,如果将handleUserKeyPress移动到useEffect内部,则ESLint exhaustive deps规则将告诉您缺少哪些确切的规范依赖项(userText),如果未指定。

const App =() => {
  const [userText, setUserText] = useState("");

  useEffect(() => {
    const handleUserKeyPress = event => {
      const { key, keyCode } = event;

      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        setUserText(`${userText}${key}`);
      }
    };

    window.addEventListener("keydown", handleUserKeyPress);

    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, [userText]); // ESLint will yell here, if `userText` is missing

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://unpkg.com/react@16.13.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<script>var { useReducer, useEffect, useState, useRef } = React</script>


5
看起来这越来越像是一个被忽视的钩子设计缺陷。 - foxtrotuniform6969
3
@foxtrotuniform6969 你说得没错!有一个新的事件相关挂钩的RFC(更新的答案)。 - ford04
2
谢天谢地,我们需要这个。编辑:你有链接吗?我想瞅一眼。 - foxtrotuniform6969
3
https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md - ford04

20

新答案:

useEffect(() => {
  function handlekeydownEvent(event) {
    const { key, keyCode } = event;
    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(prevUserText => `${prevUserText}${key}`);
    }
  }

  document.addEventListener('keyup', handlekeydownEvent)
  return () => {
    document.removeEventListener('keyup', handlekeydownEvent)
  }
}, [])

在使用setUserText时,将函数作为参数传递而不是对象,prevUserText始终是最新的状态。


旧答案:

尝试这个,它与您的原始代码相同:

useEffect(() => {
  function handlekeydownEvent(event) {
    const { key, keyCode } = event;
    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(`${userText}${key}`);
    }
  }

  document.addEventListener('keyup', handlekeydownEvent)
  return () => {
    document.removeEventListener('keyup', handlekeydownEvent)
  }
}, [userText])

因为在您的useEffect()方法中,它依赖于userText变量,但是您没有将其放入第二个参数中,否则使用参数[]时,userText将始终绑定到初始值''

您不需要像这样做,只是想让您知道为什么您的第二个解决方案不起作用。


嘿 @Isaac,没错,它和没有第二个参数一样,我只是想让你知道为什么你的第二个解决方案不起作用,因为你的第二个解决方案 useEffect() 依赖于 userText 变量,但你没有将其放在第二个参数中。 - Spark.Bao
但是通过添加 [userText],这也意味着在每次重新渲染时注册和注销事件,对吗? - Isaac
你目前的建议是在 componentDidUpdate 中注册事件,这不是我们之前做事情的方式,对吧? - Isaac
1
明白你的意思了,如果你真的想在这个例子中只注册一次,那么你需要使用 useRef,就像 @Maaz Syed Adeeb 的回答一样。 - Spark.Bao
我刚刚给你的答案点了个赞,感谢你和我一起解决这个问题。根据您的理解,useRef相比上面di3的回答,是否更推荐使用呢? - Isaac
显示剩余5条评论

4
针对您的使用场景,useEffect 需要一个依赖数组来跟踪变化,并根据依赖关系确定是否重新渲染。建议始终将依赖数组传递给 useEffect。请参见下面的代码:
我引入了 useCallback 钩子。
const { useCallback, useState, useEffect } = React;

  const [userText, setUserText] = useState("");

  const handleUserKeyPress = useCallback(event => {
    const { key, keyCode } = event;

    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(prevUserText => `${prevUserText}${key}`);
    }
  }, []);

  useEffect(() => {
    window.addEventListener("keydown", handleUserKeyPress);

    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, [handleUserKeyPress]);

  return (
    <div>
      <blockquote>{userText}</blockquote>
    </div>
  );

Edit q98jov5kvq


我希望这是一种预期的行为。我已经更新了我的答案。 - codejockie
不,对于 addEventListener 来说并不是这样的。它不会被多次调用。https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener#Multiple_identical_event_listeners - codejockie
1
但是由于 return () => {window.removeEventListener('keydown', handleUserKeyPress)} 的存在,在每次重新渲染时,组件都会注册和注销。 - Isaac
我们可以在 return () => { console.log('im removing!')} 中再放置另一个控制台日志,这样就可以看到在每次按键时,事件将被注销和重新注册。 - Isaac
1
正是我所期望的行为,但您可以在 https://codesandbox.io/s/n5j7qy051j 上观察到它。 - Isaac
显示剩余4条评论

2
你需要一种方法来跟踪先前的状态。useState只能帮助你跟踪当前状态。从文档中,可以使用另一个hook访问旧状态。
const prevRef = useRef();
useEffect(() => {
  prevRef.current = userText;
});

我已经更新了你的示例来使用这个。它能够正常工作。

const { useState, useEffect, useRef } = React;

const App = () => {
  const [userText, setUserText] = useState("");
  const prevRef = useRef();
  useEffect(() => {
    prevRef.current = userText;
  });

  const handleUserKeyPress = event => {
    const { key, keyCode } = event;

    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      setUserText(`${prevRef.current}${key}`);
    }
  };

  useEffect(() => {
    window.addEventListener("keydown", handleUserKeyPress);

    return () => {
      window.removeEventListener("keydown", handleUserKeyPress);
    };
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"></div>


2

被接受的答案可行,但如果您正在注册BackHandler事件,请确保在handleBackPress函数中返回true,例如:

const handleBackPress= useCallback(() => {
   // do some action and return true or if you do not
   // want the user to go back, return false instead
   return true;
 }, []);

 useEffect(() => {
    BackHandler.addEventListener('hardwareBackPress', handleBackPress);
    return () =>
       BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
  }, [handleBackPress]);

0

这里是基于@ford04答案的useRef解决方案,并移动到自定义钩子中。我最喜欢它,因为它不需要添加任何手动依赖项,并允许在自定义钩子中隐藏所有复杂性。

const useEvent = (eventName, eventHandler) => {
  const cbRef = useRef(eventHandler)

  useEffect(() => {
    cbRef.current = eventHandler
  }) // update after each render

  useEffect(() => {
    console.log("+++ subscribe")
    const cb = (e) => cbRef.current(e) // then use most recent cb value
    window.addEventListener(eventName, cb)
    return () => {
      console.log("--- unsubscribe")
      window.removeEventListener(eventName, cb)
    }
  }, [eventName])
  return
}

App中的使用方式:

function App() {
  const [isUpperCase, setUpperCase] = useState(false)
  const [userText, setUserText] = useState("")

  const handleUserKeyPress = (event) => {
    const { key, keyCode } = event
    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      const displayKey = isUpperCase ? key.toUpperCase() : key
      const newText = userText + displayKey
      setUserText(newText)
    }
  }
  useEvent("keydown", handleUserKeyPress)

  return (
    <div>
      <h1>Feel free to type!</h1>
      <label>
        <input
          type="checkbox"
          defaultChecked={isUpperCase}
          onChange={() => setUpperCase(!isUpperCase)}
        />
        Upper Case
      </label>
      <blockquote>{userText}</blockquote>
    </div>
  )
}

Edit


0
如果你不直接渲染要附加监听器的元素,你确实可以使用`useCallback`或类似的功能。@ford04的回答详细涵盖了这里的情况。
然而,如果你自己渲染元素,直接将事件添加到目标元素不仅更清晰,而且无论你是否使用`useCallback`,它都能正常工作!
const elementRef = useRef(null);

...

return (
  <div ref={elementRef} onKeyDown={handleUserKeyPress}>
  ...

在这种情况下,React会在每次重新渲染时自动重新附加更新后的函数到元素上。

-1
在第二种方法中,useEffect 仅绑定一次,因此 userText 永远不会得到更新。一种方法是维护一个本地变量,在每次按键时与 userText 对象一起更新。
  const [userText, setUserText] = useState('');
  let local_text = userText
  const handleUserKeyPress = event => {
    const { key, keyCode } = event;

    if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
      local_text = `${userText}${key}`;
      setUserText(local_text);
    }
  };

  useEffect(() => {
    window.addEventListener('keydown', handleUserKeyPress);

    return () => {
      window.removeEventListener('keydown', handleUserKeyPress);
    };
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );

个人而言,我不喜欢这个解决方案,感觉有点反React,我认为第一种方法已经足够好了,并且是设计成那样使用的。


你介意加入一些代码来演示如何在第二种方法中实现我的目标吗? - Isaac

-1

你没有访问已更改的 useText 状态的权限。你可以将其与 prevState 进行比较。将状态存储在变量中,例如:state:

const App = () => {
  const [userText, setUserText] = useState('');

  useEffect(() => {
    let state = ''

    const handleUserKeyPress = event => {
      const { key, keyCode } = event;
      if (keyCode === 32 || (keyCode >= 65 && keyCode <= 90)) {
        state += `${key}`
        setUserText(state);
      }   
    };  
    window.addEventListener('keydown', handleUserKeyPress);
    return () => {
      window.removeEventListener('keydown', handleUserKeyPress);
    };  
  }, []);

  return (
    <div>
      <h1>Feel free to type!</h1>
      <blockquote>{userText}</blockquote>
    </div>
  );  
};

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