单击两次以显示反应 bootstrap 弹出窗口

Takes two clicks for react bootstrap popover to show up

我 运行 在尝试构建允许用户单击单词并在 bootstrap 弹出窗口中获取其定义的页面时遇到了问题。这是通过发送 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 初始化和 <Popover.Body> 组件。这并没有解决问题。

    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>

问题

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

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

选择另一个词将一遍又一遍地重复此过程。

要解决此问题,您需要先使用 show 属性 在渲染后显示弹出窗口(如果它与 selected 词匹配)。但是如果这个词出现多次怎么办?如果您对“她”一词执行此操作,它会在多个位置显示弹出窗口。因此,您不必为每个词分配一个唯一的 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 => 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
  }
}

接下来,我们需要设置当前selected 的单词。定义可用后,将显示该词的弹出窗口。

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。我们还需要 select 被点击的词 - 即使有多个相同的词,我们只想要被点击的词。为此,我们也会检查它的 Id。如果我们点击一​​个特定的词,我们需要确保我们点击的是 selected。我们还需要用标点符号渲染出原始单词。这一切都在这个块中完成:

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 调用的结果来改进这一点。