Последовательная нумерация сносок с помощью React

Я пытаюсь создать компонент сноски для моей новой версии моего блога на React, но пока не могу сделать правильную нумерацию.

Я применяю в основном ту же логику, что и для своей версии Веб-компоненты того же компонента блога:

  1. Рассчитайте текущий индекс сносок, исходя из того, сколько сносок вы можете document.querySelectorAll(".footnote").
  2. Используйте этот индекс для тега <sup>, где должна появиться сноска.
  3. Добавьте указатель и содержимое сноски в контейнер сносок в конце сообщения.

Я думаю, что это не сработает из-за последовательности рендеринга, которую использует React. Есть ли обходной путь? Может быть, есть метод выбора запроса, позволяющий брать только элементы перед определенным элементом?

Вот пример того, что у меня есть на данный момент:

function Article() {
  return (
    <article>
      <p>The<FootNote html = "Footnote 1" /> article content here<FootNote html = "Footnote 2" />.</p>
      <FootNotesContainer />
    </article>
  )
}

function FootNotesContainer() {
  return (
    <div id = "footnotes-container">
      <h2>Footnotes</h2>
    </div>
  )
}

function FootNote({ html }) {
  const [footNoteLink, setFootNoteLink] = React.useState("")
   
  React.useEffect(() => {
    const previousFootnotes =
      document.querySelectorAll(".footnote")
    const nextFootNoteNumber = previousFootnotes.length
         setFootNoteLink(nextFootNoteNumber.toString())

    const footNoteContent = document.createElement("div")
    footNoteContent.innerHTML = /* html */ `
      <a 
        href = "#footnote-base-${nextFootNoteNumber}"
        id = "footnote-${nextFootNoteNumber}"
        className = "no-underline"
      >${nextFootNoteNumber}</a>:
      ${html}
    `

    const footNoteContainer = document.querySelector(
      "#footnotes-container"
    )
    footNoteContainer.appendChild(footNoteContent)
  }, [html])


  return (
    <sup className = "footnote">
      <a
        href = {`#footnote-${footNoteLink}`}
        id = {`footnote-base-${footNoteLink}`}
        className = "text-orange-400 no-underline"
      >
        {footNoteLink}
      </a>
    </sup>
  )
}

ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <Article />
)
<script src = "https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src = "https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

<div id = "root"></div>

Я также пытался сделать это более реактивным способом, но я не знаю, почему список, предоставляемый контекстом, не копируется/изменяется последовательно:

const FootnotesContext = React.createContext(null)

function FootnotesProvider({ children }) {
  const [footnotes, setFootnotes] = React.useState([])
  
  function addFootnote(html) {
    setFootnotes([...footnotes, html])
  }
  
  function totalFootnotes() {
    return footnotes.length
  }

  return (
    <FootnotesContext.Provider value = {{ footnotes, addFootnote, totalFootnotes }}>
      {children}
    </FootnotesContext.Provider>
  )
}

function useFootnotes() {
  const context = React.useContext(FootnotesContext)
  
  if (!context) throw new Error("`useFootnotes` must be used within a `FootnotesProvider`.")
  
  return context
}

function Article() {
  return (
    <FootnotesProvider>
      <article>
        <p>The
          <FootNote html = "Footnote 1" />{" "}
          article content here
          <FootNote html = "Footnote 2" />.
        </p>

        <hr/>

        <FootNotesContainer />
      </article>
    </FootnotesProvider>
  )
}

function FootNotesContainer() {
  const { footnotes } = useFootnotes()
  
  console.info(footnotes)
  
  return (
    <div id = "footnotes-container">
      <h2>Footnotes</h2>
      <h3>Total Footnotes: {footnotes.length}</h3>
      
      {
        footnotes.map((f, i) => (
          <div className = "footnote" key = {i}>
            <a 
              href = {`#footnote-sup-${i}`}
              id = {`footnote-${i}`}
            >{i + 1}</a>:
            <p>{f}</p>
          </div>
        ))
      }
    </div>
  )
}

function FootNote({ html }) {
  const { footnotes, addFootnote, totalFootnotes } = useFootnotes()
  
  const i = totalFootnotes() + 1

  React.useEffect(() => {
    addFootnote(html)
  }, [])

  return (
    <sup className = "footnote-sup">
      <a
        href = {`#footnote-${i}`}
        id = {`footnote-sup-${i}`}
      >
        {i}
      </a>
    </sup>
  )
}

ReactDOM.createRoot(document.getElementById("root")).render(<Article />)
.footnote {
  display: flex;
  gap: 4px;
  align-items: center;
}
<script src = "https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src = "https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

<div id = "root"></div>

🤔 А знаете ли вы, что...
Синтаксис JavaScript схож с синтаксисом языка программирования Java, но они не связаны.


176
2

Ответы:

Решено

с контекстом, без состояния

Вот моя первая попытка. Я выбрал React useRef, потому что мы должны быть в состоянии достичь цели с помощью одного рендеринга. Всякий раз, когда поставщик выполняет рендеринг, он сбрасывает свою локальную ссылку. По мере рендеринга дочерних элементов провайдера ref обновляется.

function App() {
  return <div>
    <p>
      Hello <Footnote children = "hello" />.
      <Child />
      World <Footnote children = "world" />.
    </p>
    <Footnotes />
  </div>
}

function Child() {
  return <span>More text from the child
    <Footnote children = "more" />
    and a little more <Footnote children = "and more"/>.
  </span>
}

Как видите, это работает для компонентов Footnote, которые появляются на разных уровнях дерева компонентов. Одним из предостережений этого подхода является то, что список сносок должен отображаться после всех отдельных компонентов Footnote

Hello [1].More text from the child[2]and a little more [3].World [4].

[1]: hello
[2]: more
[3]: and more
[4]: world

Мы можем реализовать Footnote и Footnotes как -

function Footnote(props) {
  const context = React.useContext(Context)
  const id = context.add(props.children)
  return <sup>[{id}]</sup>
}

function Footnotes() {
  const context = React.useContext(Context)
  return <ul>
    {context.get().map((children, index) => (
      <li key = {index}>
        [{index + 1}]: {children}
      </li>
    ))}
  </ul>
}

А Context и Provider -

const defaultContext = {
  get: () => [],
  add: () => {
    throw Error(`add called outside of context`)
  }
}

const Context = React.createContext(defaultContext)

function Provider(props) {
  const footnotes = React.useRef([])
  footnotes.current = []
  const add = children => footnotes.current.push(children)
  const get = () => footnotes.current
  return <Context.Provider
    children = {props.children}
    value = {{ add, get }}
  />
}

Запустите приведенный ниже фрагмент, чтобы проверить результат в своем браузере:

const defaultContext = {
  get: () => [],
  add: () => {
    throw Error(`add called outside of context`)
  }
}

const Context = React.createContext(defaultContext)

function Provider(props) {
  const footnotes = React.useRef([])
  footnotes.current = []
  const add = children => footnotes.current.push(children)
  const get = () => footnotes.current
  return <Context.Provider
    children = {props.children}
    value = {{ add, get }}
  />
}

function Footnote(props) {
  const context = React.useContext(Context)
  const id = context.add(props.children)
  return <sup>[{id}]</sup>
}

function Footnotes() {
  const context = React.useContext(Context)
  return <ul>
    {context.get().map((children, index) => (
      <li key = {index}>
        [{index + 1}]: {children}
      </li>
    ))}
  </ul>
}

function App() {
  return <div>
    <p>
      Hello <Footnote children = "hello" />.
      <Child />
      World <Footnote children = "world" />.
    </p>
    <Footnotes />
  </div>
}

function Child() {
  return <span>More text from the child
    <Footnote children = "more" />
    and a little more <Footnote children = "and more"/>.
  </span>
}

ReactDOM.createRoot(document.querySelector("#app")).render(
  <Provider>
    <App />
  </Provider>
)
<script crossorigin src = "https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src = "https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div id = "app"></div>

Я оставлю вам возможность создавать настоящие a элементы и соответствующие href. Чтобы URL-адреса оставались неизменными, я бы посоветовал не использовать номер сноски как часть URL-адреса.

Улучшением может быть возврат компонента из функции контекста add, чтобы компонент Footnote не брал на себя эту проблему -

function Provider(props) {
  const add = children => {
    const id = footnotes.current.push(children)
    const fragment = children.split(" ").join("-")
    return <a href=`#${encodeURIComponent(fragment)}`>${id}</a>
  return // ...
}

function Footnote(props) {
  const context = React.useContext(Context)
  const link = context.add(props.children) // <a href=...>...</a>
  return <sup>[{link}]</sup>
}

Если вы обнаружите недостатки этого подхода, поделитесь, и я постараюсь их устранить ^-^


Я не знаю, почему @NephiBalinski удалил свой ответ, но здесь он просто для справки, так как я подумал, что он полезен. По сути, он использует смесь моего второго фрагмента и того, что в итоге придумал @Mulan.


1. Способ React Context

Один из способов решить эту проблему — использовать React Context. Вот исправленный и обновленный код с использованием React Context:

const FootnotesContext = React.createContext(null);

function FootnotesProvider({ children }) {
  const [footnotes, setFootnotes] = React.useState([]);
  const footnotesRef = React.useRef([]);

  function addFootnote(html) {
    footnotesRef.current = [...footnotesRef.current, html];
    setFootnotes([...footnotesRef.current, ]);
  }

  function totalFootnotes() {
    return footnotes.length;
  }

  return (
    <FootnotesContext.Provider value = {{ footnotes, addFootnote, totalFootnotes,  }}>
      {children}
    </FootnotesContext.Provider>
  );
}

function useFootnotes() {
  const context = React.useContext(FootnotesContext);

  if (!context) throw new Error('`useFootnotes` must be used within a `FootnotesProvider`.');

  return context;
}

function Article() {
  return (
    <FootnotesProvider>
      <article>
        <p>
          The <FootNote html = "Footnote 1" /> article content here <FootNote html = "Footnote 2" />.
        </p>

        <hr />

        <FootNotesContainer />
      </article>
    </FootnotesProvider>
  );
}

function FootNotesContainer() {
  const { footnotes } = useFootnotes();

  return (
    <div id = "footnotes-container">
      <h2>Footnotes</h2>
      <h3>Total Footnotes: {footnotes.length}</h3>

      {footnotes.map((f, i) => (
        <div className = "footnote" key = {i}>
          <a href = {`#footnote-sup-${i + 1}`} id = {`footnote-${i}`}>
            {i + 1}
          </a>:
          <p>{f}</p>
        </div>
      ))}
    </div>
  );
}

function FootNote({ html }) {
  const { footnotes, addFootnote, totalFootnotes } = useFootnotes();
  // const footnoteRef = React.useRef(null);
  const [index, setIndex] = React.useState(0)

  React.useEffect(() => {
    addFootnote(html);
  }, []);

  React.useEffect(() => {
    setIndex(footnotes.indexOf(html) + 1)
  }, [footnotes])

  return (
    <sup 
      className = "footnote-sup" 
      // ref = {ref => footnoteRef.current = ref}
    >
      <a
        href = {`#footnote-${index}`}
        id = {`footnote-sup-${index}`}
      >
        {index}
      </a>
    </sup>
  )
}

ReactDOM.createRoot(document.getElementById("root")).render(<Article />);
.footnote {
  display: flex;
  gap: 4px;
  align-items: center;
}
<script src = "https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src = "https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

<div id = "root"></div>

Ключевые изменения

  • Мы добавили сноскиRef, чтобы гарантировать, что сноски добавляются последовательно.
  • Мы изменили структуру компонента FootNote, чтобы исправить нумерацию сносок.
  • Мы исправили ссылки в FootNotesContainer.
  • Мы не использовали манипуляции с DOM!

Это должно решить проблему со сносками.

2. Генераторный способ (не рекомендуется)

Другой подход — использование функций генератора ES6. Вот пример базовой функции генератора:

function* genId() {
    var index = 0;
    while (true)
        yield index++;
    }
}

let gen = genId();

console.info(gen.next().value); // 0
console.info(gen.next().value); // 1
console.info(gen.next().value); // 2

Вот исправленный и обновленный код:

function Article() {
  return (
    <article>
      <p>The<FootNote html = "Footnote 1" /> article content here<FootNote html = "Footnote 2" />.</p>
      <FootNotesContainer />
    </article>
  );
}

function FootNotesContainer() {
  return (
    <div id='footnotes-container'>
      <h2>Footnotes</h2>
    </div>
  );
}

function* generateId() {
  let idCounter = 1;
  while (true) {
    yield idCounter++;
  }
}

const gen = generateId();

function useFootnoteId() {
  const [id, setId] = React.useState(0);

  React.useEffect(() => {
    setId(gen.next().value);
  }, [gen]);

  return id;
}

function FootNote({ html }) {
  const footNoteLink = useFootnoteId();
<script src = "https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src = "https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

<script src = "https://cdn.jsdelivr.net/npm/[email protected]/runtime.min.js"></script>

<div id = "root"></div>

Ключевые изменения

  • Мы больше не используем previousFootnotes.length для вычисления значения footNoteLink. Вместо этого мы используем функции useFootnoteId иgenerId.
    • Примечание. Мы удалили чтение DOM, поэтому такие действия, как удаление сносок, могут потребовать дополнительных действий.
  • Мы реализовали функцию очистки в FootNotesContainer, чтобы избежать создания ненужных сносок из-за чрезмерного повторного рендеринга.

Теперь сноски должны работать так, как вы хотите.