【问题标题】:Takes two clicks for react bootstrap popover to show up需要两次点击才能显示反应引导弹出窗口
【发布时间】:2021-11-22 01:31:32
【问题描述】:

我在尝试构建允许用户单击单词并在引导弹出窗口中获取其定义的页面时遇到了问题。这是通过发送 API 请求并使用接收到的数据更新状态来实现的。

问题是弹出框仅在第二次单击该单词后出现。 useEffect() 中的 console.log() 表明每次单击一个新单词时都会发出一个 API 请求。要使弹出框出现相同的单词,必须单击两次。如果只需要点击一下就更好了。

    import React, { useState, useRef, useEffect } from "react";
    import axios from "axios";
    import { Alert, Popover, OverlayTrigger } from "react-bootstrap";
    
    export default function App() {
      const [text, setText] = useState(
        "He looked at her and saw her eyes luminous with pity."
      );
      const [selectedWord, setSelectedWord] = useState("luminous");
      const [apiData, setApiData] = useState([
        {
          word: "",
          phonetics: [{ text: "" }],
          meanings: [{ definitions: [{ definition: "", example: "" }] }]
        }
      ]);
    
      const words = text.split(/ /g);
    
      useEffect(() => {
        var url = "https://api.dictionaryapi.dev/api/v2/entries/en/" + selectedWord;
        axios
          .get(url)
          .then(response => {
            setApiData(response.data)
            console.log("api call")
           })
          .catch(function (error) {
            if (error) {
              console.log("Error", error.message);
            }
          });
      }, [selectedWord]);
    
      function clickCallback(w) {
        var word = w.split(/[.!?,]/g)[0];
        setSelectedWord(word);
      }
    
      const popover = (
        <Popover id="popover-basic">
          <Popover.Body>
            <h1>{apiData[0].word}</h1>
            <h6>{apiData[0].meanings[0].definitions[0].definition}</h6>
          </Popover.Body>
        </Popover>
      );
    
      return (
        <Alert>
          {words.map((w) => (
            <OverlayTrigger
              key={uuid()}
              trigger="click"
              placement="bottom"
              overlay={popover}
            >
              <span onClick={() => clickCallback(w)}> {w}</span>
            </OverlayTrigger>
          ))}
        </Alert>
      );
    }

更新: 更改了 apiData 初始化和 &lt;Popover.Body&gt; 组件。这并没有解决问题。

    const [apiData, setApiData] = useState(null)
    <Popover.Body>
            {
              apiData ?
                <div>
                  <h1>{apiData[0].word}</h1>
                  <h6>{apiData[0].meanings[0].definitions[0].definition}</h6>
                </div> :
                <div>Loading...</div>
            }
          </Popover.Body>

【问题讨论】:

  • 我的猜测是这个的根本问题是 React 的状态。您正在为 apiData 使用嵌套对象,因此该组件的生命周期从第一眼开始的行为方式是调用 api 并将新数据设置为 apiData 但由于该对象是嵌套的,因此不会被拾取立即并且组件不会重新渲染,因此您需要第二次单击,然后更新 selectedWord 进而重新渲染您的应用程序。
  • 您没有在 then 块中调用 response.json()。你应该在设置apiData状态之前先调用它。
  • @moodseller 有没有办法绕过它。我也不确定用嵌套对象初始化状态是正确的方法。但是,如果我将一个空数组传递给 apiData 的useState,则会在&lt;Popover.Body&gt; 中引发错误,因为它无法访问状态字段。 @IshanBassi 我试过这样做,但没有奏效。我认为是因为axios默认将响应转换为json。

标签: reactjs react-bootstrap popover


【解决方案1】:

问题

这是我认为正在发生的事情:

  1. 组件渲染
  2. 开始获取“发光”的定义。
  3. “发光”的定义已完成获取。它调用setApiData(data)
  4. 组件重新呈现
  5. 如果单击“luminous”,popper 会立即显示,这是因为 popper 的数据已准备好使用,而 setSelectedWord("luminous") 什么都不做。
  6. 如果您单击另一个词,例如“pity”,弹出器会尝试显示,但 setSelectedWord("pity") 会导致组件开始重新渲染。
  7. 组件重新呈现
  8. 开始获取“pity”的定义。
  9. “怜悯”的定义已完成获取。它调用setApiData(data)
  10. 组件重新呈现
  11. 如果单击“pity”,popper 会立即显示,这是因为 popper 的数据已准备好使用,而 setSelectedWord("pity") 什么也不做。

选择另一个单词会一遍又一遍地重复这个过程。

要解决此问题,您需要首先使用show 属性在渲染后显示弹出框(如果它与所选单词匹配)。但是如果这个词出现多次怎么办?如果您对“她”一词执行此操作,则会在多个位置显示弹出框。因此,您不必对每个单词进行比较,而是必须为每个单词分配一个唯一的 ID 并与之进行比较。

修复组件

要为单词分配一个在渲染之间不会改变的 ID,我们需要在组件顶部为它们分配 ID,并将它们存储在一个数组中。为了让这个“更简单”,我们可以将该逻辑抽象为组件之外的可重用函数:

// Use this function snippet in demos only, use a more robust package
// https://gist.github.com/jed/982883 [DWTFYWTPL]
const uuid = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}

// Splits the text argument into words, removes excess formatting characters and assigns each word a UUID.
// Returns an array with the shape: { [index: number]: { word: string, original: string, uuid: string }, text: string }
function identifyWords(text) {
  // split input text into words with unique Ids
  const words = text
    .split(/ +/)
    .map(word => {
      const cleanedWord = word
        .replace(/^["]+/, "")     // remove leading punctuation
        .replace(/[.,!?"]+$/, "") // remove trailing punctuation
      
      return { word: cleanedWord, original: word, uuid: uuid() }
    });
  
  // attach the source text to the array of words
  // we can use this to prevent unnecessary rerenders
  words.text = text;
  
  // return the array-object
  return words;
}

在组件中,我们需要设置状态变量来保存单词数组。通过将回调传递给useState,React 将仅在第一次渲染时执行它,而在重新渲染时跳过调用它。

// set up state array of words that have their own UUIDs
// note: we don't want to call _setWords directly
const [words, _setWords] = useState(() => identifyWords("He looked at her and saw her eyes luminous with pity."));

现在我们有了words_setWords,我们可以从中提取text 值:

// extract text from words array for convenience
// probably not needed
const text = words.text;

接下来,我们可以创建自己的setText 回调。这可能更简单,但我想确保我们支持 React 的变异更新语法 (setText(oldValue =&gt; newValue)):

// mimic a setText callback that actually updates words as needed
const setText = (newTextOrCallback) => {
  if (typeof newTextOrCallback === "function") {
    // React mutating callback mode
    _setWords((words) => {
      const newText = newTextOrCallback(words.text);
      return newText === words.text
        ? words // unchanged
        : identifyWords(newText); // new value
    });
  } else {
    // New value mode
    return newTextOrCallback === words.text
      ? words // unchanged
      : identifyWords(newTextOrCallback); // new value
  }
}

接下来,我们需要设置当前选中的单词。一旦定义可用,就会显示该单词的弹出框。

const [selectedWordObj, setSelectedWordObj] = useState(() => words.find(({word}) => word === "luminous"));

如果您不想默认显示单词,请使用:

const [selectedWordObj, setSelectedWordObj] = useState(); // nothing selected by default

要修复 API 调用,我们需要利用“使用异步效果”模式(有一些库可以简化这一点):

const [apiData, setApiData] = useState({ status: "loading" });

useEffect(() => {
  if (!selectedWordObj) return; // do nothing.

  // TODO: check cache here

  // clear out the previous definition
  setApiData({ status: "loading" });
  
  let unsubscribed = false;
  axios
    .get(`https://api.dictionaryapi.dev/api/v2/entries/en/${selectedWordObj.word}`)
    .then(response => {
      if (unsubscribed) return; // do nothing. out of date response
      
      const body = response.data;
      
      // unwrap relevant bits
      setApiData({
        status: "completed",
        word: body.word,
        definition: body.meanings[0].definitions[0].definition
      });
    })
    .catch(error => {
      if (unsubscribed) return; // do nothing. out of date response
      
      console.error("Failed to get definition: ", error);
      
      setApiData({
        status: "error",
        word: selectedWordObj.word,
        error
      });
    });
    
  return () => unsubscribed = true;
}, [selectedWord]);

上面的代码块确保在不再需要 setApiData 方法时避免调用它们。它还使用status 属性来跟踪它的进度,以便您可以正确呈现结果。

现在定义一个显示加载消息的弹出框:

const loadingPopover = (
  <Popover id="popover-basic">
    <Popover.Body>
      <span>Loading...</span>
    </Popover.Body>
  </Popover>
);

我们可以将加载弹出框与apiData 混合使用,以获得显示定义的弹出框。如果我们仍在加载定义,请使用加载定义。如果我们有错误,请显示错误。如果正确完成,则渲染定义。为了使这更容易,我们可以将这个逻辑放在组件之外的函数中,如下所示:


function getPopover(apiData, loadingPopover) {
  switch (apiData.status) {
    case "loading":
      return loadingPopover;
    case "error":
      return (
        <Popover id="popover-basic">
          <Popover.Body>
            <h1>{apiData.word}</h1>
            <h6>Couldn't find definition for {apiData.word}: {apiData.error.message}</h6>
          </Popover.Body>
        </Popover>
      );
    case "completed":
      return (
        <Popover id="popover-basic">
          <Popover.Body>
            <h1>{apiData.word}</h1>
            <h6>{apiData.definition}</h6>
          </Popover.Body>
        </Popover>
      );
  }
}

我们在组件中调用这个函数:

const selectedWordPopover = getPopover(apiData, loadingPopover);

最后,我们渲染出单词。因为我们正在渲染一个数组,所以我们需要使用一个 key 属性,我们将其设置为每个单词的 Id。我们还需要选择被点击的词——即使有多个相同的词,我们也只想要被点击的那个。为此,我们也会检查它的 ID。如果我们点击一​​个特定的词,我们需要确保我们点击的那个被选中。我们还需要渲染出带有标点符号的原始单词。这一切都在这个块中完成:

return (
  <Alert>
    {words.map((wordObj) => {
      const isSelectedWord = selectedWordObj && selectedWordObj.uuid = wordObj.uuid;
      return (
        <OverlayTrigger
          key={wordObj.uuid}
          show={isSelectedWord}
          trigger="click"
          placement="bottom"
          overlay={isSelectedWord ? selectedWordPopover : loadingPopover}
        >
          <span onClick={() => setSelectedWordObj(wordObj)}> {wordObj.original}</span>
        </OverlayTrigger>
      )})}
  </Alert>
);

完整代码

将所有这些放在一起给出:

import React, { useState, useRef, useEffect } from "react";
import axios from "axios";
import { Alert, Popover, OverlayTrigger } from "react-bootstrap";

// Use this function snippet in demos only, use a more robust package
// https://gist.github.com/jed/982883 [DWTFYWTPL]
const uuid = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}

// Splits the text argument into words, removes excess formatting characters and assigns each word a UUID.
// Returns an array with the shape: { [index: number]: { word: string, original: string, uuid: string }, text: string }
function identifyWords(text) {
  // split input text into words with unique Ids
  const words = text
    .split(/ +/)
    .map(word => {
      const cleanedWord = word
        .replace(/^["]+/, "")     // remove leading characters
        .replace(/[.,!?"]+$/, "") // remove trailing characters
      
      return { word: cleanedWord, original: word, uuid: uuid() }
    });
  
  // attach the source text to the array of words
  words.text = text;
  
  // return the array
  return words;
}

function getPopover(apiData, loadingPopover) {
  switch (apiData.status) {
    case "loading":
      return loadingPopover;
    case "error":
      return (
        <Popover id="popover-basic">
          <Popover.Body>
            <h1>{apiData.word}</h1>
            <h6>Couldn't find definition for {apiData.word}: {apiData.error.message}</h6>
          </Popover.Body>
        </Popover>
      );
    case "completed":
      return (
        <Popover id="popover-basic">
          <Popover.Body>
            <h1>{apiData.word}</h1>
            <h6>{apiData.definition}</h6>
          </Popover.Body>
        </Popover>
      );
  }
}

export default function App() {
  // set up state array of words that have their own UUIDs
  // note: don't call _setWords directly
  const [words, _setWords] = useState(() => identifyWords("He looked at her and saw her eyes luminous with pity."));
  
  // extract text from words array for convenience
  const text = words.text;
  
  // mimic a setText callback that actually updates words as needed
  const setText = (newTextOrCallback) => {
    if (typeof newTextOrCallback === "function") {
      // React mutating callback mode
      _setWords((words) => {
        const newText = newTextOrCallback(words.text);
        return newText === words.text
          ? words // unchanged
          : identifyWords(newText); // new value
      });
    } else {
      // New value mode
      return newTextOrCallback === words.text
        ? words // unchanged
        : identifyWords(newTextOrCallback); // new value
    }
  }

  const [selectedWordObj, setSelectedWordObj] = useState(() => words.find(({word}) => word === "luminous"));
  
  const [apiData, setApiData] = useState({ status: "loading" });

  useEffect(() => {
    if (!selectedWordObj) return; // do nothing.

    // TODO: check cache here

    // clear out the previous definition
    setApiData({ status: "loading" });
    
    let unsubscribed = false;
    axios
      .get(`https://api.dictionaryapi.dev/api/v2/entries/en/${selectedWordObj.word}`)
      .then(response => {
        if (unsubscribed) return; // do nothing. out of date response
        
        const body = response.data;
        
        // unwrap relevant bits
        setApiData({
          status: "completed",
          word: body.word,
          definition: body.meanings[0].definitions[0].definition
        });
       })
      .catch(error => {
        if (unsubscribed) return; // do nothing. out of date response
        
        console.error("Failed to get definition: ", error);
        
        setApiData({
          status: "error",
          word: selectedWordObj.word,
          error
        });
      });
      
    return () => unsubscribed = true;
  }, [selectedWord]);

  function clickCallback(w) {
    var word = w.split(/[.!?,]/g)[0];
    setSelectedWord(word);
  }
  
  const loadingPopover = (
    <Popover id="popover-basic">
      <Popover.Body>
        <span>Loading...</span>
      </Popover.Body>
    </Popover>
  );

  const selectedWordPopover = getPopover(apiData, loadingPopover);

  return (
    <Alert>
      {words.map((wordObj) => {
        const isSelectedWord = selectedWordObj && selectedWordObj.uuid = wordObj.uuid;
        return (
          <OverlayTrigger
            key={wordObj.uuid}
            show={isSelectedWord}
            trigger="click"
            placement="bottom"
            overlay={isSelectedWord ? selectedWordPopover : loadingPopover}
          >
            <span onClick={() => setSelectedWordObj(wordObj)}> {wordObj.original}</span>
          </OverlayTrigger>
        )})}
    </Alert>
  );
}

注意:您可以通过缓存 API 调用的结果来改进这一点。

【讨论】:

  • 感谢您的回复!当我开始应用您帖子中列出的更改时,我不得不删除我从“uuidv4”库中导入的uuid() 函数,并将其分配给主体中的&lt;OverlayTrigger&gt; 组件。令我高兴的是,仅此一项就解决了延迟问题,并且在第一次单击时就开始出现弹出窗口。仍然存在一个小滞后的问题,并且一个新的弹出框在一瞬间显示了前一个单词的定义。
  • 所以我尝试运行您的代码,但它遇到了同样的问题。要解决我将setApiData({ status: "loading" }) 添加到useEffect() 的问题,现在它可以按预期工作。再次感谢。
  • @cruise134 RE:UUID:很烦人,这很有意义。在每次渲染时,您都会为元素生成一个新的 UUID。 React 会看到这一点,完全删除旧组件并在其位置创建一个新元素。在我上面的代码中,用作键的 UUID 在渲染之间保持不变,这意味着 React 只会更新它需要的内容。
  • @cruise134 RE:加载状态:我知道我错过了一些东西。很高兴看到你把它整理好了。我现在就解决这个问题。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-01-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多