【问题标题】:How to recursively construct an JSON hierarchy from a NodeList?如何从 NodeList 递归构造 JSON 层次结构?
【发布时间】:2020-12-31 13:32:53
【问题描述】:

给定以下输入:

<dl>
  <dt>
    <h3>Title A</h3>
    <dl>
      <dt>
        <h3>Title A- A</h3>
        <dl>
          <dt><a href="#">Item</a></dt>
          <dt><a href="#">Item</a></dt>
        </dl>
      </dt>
      <dt><a href="#">Item</a></dt>
      <dt><a href="#">Item</a></dt>
      <dt><a href="#">Item</a></dt>
      <dt><a href="#">Item</a></dt>
      <dt>
        <h3>Title B- A</h3>
        <dl>
          <dt><a href="#">Item</a></dt>
          <dt><a href="#">Item</a></dt>
        </dl>
      </dt>
      <dt><a href="#">Item</a></dt>
    </dl>
  </dt>
</dl>

我想根据上面的输入构建一个 JSON 对象:

{
  "title": "Title A",
  "children": [
    {
      "title": "Title A- A",
      "children": [
        {"title": "Item"},
        {"title": "Item"}
      ]
    },
    {"title": "Item"},
    {"title": "Item"},
    {"title": "Item"},
    {"title": "Item"},
    {
      "title": "Title B- A",
      "children": [
        {"title": "Item"},
        {"title": "Item"}
      ]
    },
    {"title": "Item"}
  ]
}

这是我迄今为止尝试过的:

function buildTree(node) {
    if (!node) return [];
    const h3 = node.querySelector('h3') || node.querySelector('a');
    let result = {
        title: h3.innerText,
        children: []
    };
    const array = [...node.querySelectorAll('dl')];
    if (array) {
        result.children = array.map(el => buildTree(el.querySelector('dt')));
    }
    return result;
}

我得到的结果与我的预期不同,这是我得到的结果:

{
  "title": "Title A",
  "children": [
    {
      "title": "Title A",
      "children": [
        {
          "title": "Title A- A",
          "children": [
            {
              "title": "Item A- A 1",
              "children": []
            }
          ]
        },
        {
          "title": "Item A- A 1",
          "children": []
        },
        {
          "title": "Title B- A 1",
          "children": []
        }
      ]
    },
    {
      "title": "Title A- A",
      "children": [
        {
          "title": "Item A- A 1",
          "children": []
        }
      ]
    },
    {
      "title": "Item A- A 1",
      "children": []
    },
    {
      "title": "Title B- A 1",
      "children": []
    }
  ]
}

似乎有些数据不存在,知道我可能遗漏了什么吗?

【问题讨论】:

  • 请注意您在滥用dl。术语dt 应与其定义dd 相邻。 dl 可以嵌套,但只能嵌套在另一个 dd 中。如果绝对必要,div 之类的元素可以包装 dt/dd 对。 read the docs here.
  • @Thankyou 输入由 Microsoft Edge 生成,它基本上是导出收藏夹(书签)为 HTML 的结构
  • Sirwan,这并不奇怪 ????????‍♀️ 微软以not complying with standards...

标签: javascript recursion


【解决方案1】:

修复 html

首先我要说的是您误用了dl。来自MDN docs -

HTML &lt;dl&gt; 元素代表一个描述列表。该元素包含一组术语(使用&lt;dt&gt; 元素指定)和描述(由&lt;dd&gt; 元素提供)...

dldtdd 的正确用法如下所示 -

<dl>
  <dt>Title 1</dt>
  <dd>  
    <dl>
      <dt>Title 1.1</dt>
      <dd><a href="#">Item 1.1.1</a></dd>
      <dd><a href="#">Item 1.1.2</a></dd>
    </dl>
  </dd>
  <dd><a href="#">Item 1.2</a></dd>
  <dd><a href="#">Item 1.3</a></dd>
  <dd><a href="#">Item 1.4</a></dd>
  <dd><a href="#">Item 1.5</a></dd>
  <dd>
    <dl>
      <dt>Title 1.6</dt>    
      <dd><a href="#">Item 1.6.1</a></dd>
      <dd><a href="#">Item 1.6.2</a></dd>
    </dl>
  </dd>
  <dd><a href="#">Item 1.7</a></dd>
</dl>

注意它与您输出的预期形状相匹配 -

{
  "title": "Title 1",
  "children": [
    {
      "title": "Title 1.1",
      "children": [
        {"title": "Item 1.1.1"},
        {"title": "Item 1.1.2"}
      ]
    },
    {"title": "Item 1.2"},
    {"title": "Item 1.3"},
    {"title": "Item 1.4"},
    {"title": "Item 1.5"},
    {
      "title": "Title 1.6",
      "children": [
        {"title": "Item 1.6.1"},
        {"title": "Item 1.6.2"}
      ]
    },
    {"title": "Item 1.7"}
  ]
}

来自Html

如果您不愿意(或不能)如上所述更改输入 html,请参阅 Scott 的精彩回答。要为提议的 html 编写程序,我会将其分为两部分。首先我们用一个简单的递归形式写fromHtml -

function fromHtml (e)
{ switch (e?.tagName)
  { case "DL":
      return Array.from(e.childNodes, fromHtml).flat()
    case "DD":
      return [ Array.from(e.childNodes, fromHtml).flat() ]
    case "DT":
    case "A":
      return e.textContent
    default:
      return []
   }
}

fromHtml(document.querySelector('dl'))

这为我们提供了这种中间格式 -

[
  "Title 1",
  [
    "Title 1.1",
    [ "Item 1.1.1" ],
    [ "Item 1.1.2" ]
  ],
  [ "Item 1.2" ],
  [ "Item 1.3" ],
  [ "Item 1.4" ],
  [ "Item 1.5" ],
  [
    "Title 1.6",
    [ "Item 1.6.1" ],
    [ "Item 1.6.2" ]
  ],
  [ "Item 1.7" ]
]

应用标签

之后,我会编写一个单独的 applyLabels 函数,它会添加您需要的 titlechildren 标签 -

const applyLabels = ([ title, ...children ]) =>
  children.length
    ? { title, children: children.map(applyLabels) }
    : { title }
  
const result =
  applyLabels(fromHtml(document.querySelector('dl')))
{
  "title": "Title 1",
  "children": [
    {
      "title": "Title 1.1",
      "children": [
        {"title": "Item 1.1.1"},
        {"title": "Item 1.1.2"}
      ]
    },
    {"title": "Item 1.2"},
    {"title": "Item 1.3"},
    {"title": "Item 1.4"},
    {"title": "Item 1.5"},
    {
      "title": "Title 1.6",
      "children": [
        {"title": "Item 1.6.1"},
        {"title": "Item 1.6.2"}
      ]
    },
    {"title": "Item 1.7"}
  ]
}

我可能会建议进行最后一项更改,以确保输出中的所有节点都具有统一的形状,{ title, children }。这是一个值得注意的变化,因为在这种情况下applyLabels 更容易编写并且它表现得更好 -

const applyLabels = ([ title, ...children ]) =>
  ({ title, children: children.map(applyLabels) })

是的,这意味着最深的后代将有一个空的 children: [] 属性,但它使使用数据变得更加容易,因为我们不必对某些属性进行空值检查。


演示

展开下面的sn-p,在自己的浏览器中验证fromHtmlapplyLabels的结果-

function fromHtml (e)
{ switch (e?.tagName)
  { case "DL":
      return Array.from(e.childNodes, fromHtml).flat()
    case "DD":
      return [ Array.from(e.childNodes, fromHtml).flat() ]
    case "DT":
    case "A":
      return e.textContent
    default:
      return []
   }
}

const applyLabels = ([ title, ...children ]) =>
  children.length
    ? { title, children: children.map(applyLabels) }
    : { title }
  
const result =
  applyLabels(fromHtml(document.querySelector('dl')))
  
console.log(result)
<dl>
  <dt>Title 1</dt>
  <dd>  
    <dl>
      <dt>Title 1.1</dt>
      <dd><a href="#">Item 1.1.1</a></dd>
      <dd><a href="#">Item 1.1.2</a></dd>
    </dl>
  </dd>
  <dd><a href="#">Item 1.2</a></dd>
  <dd><a href="#">Item 1.3</a></dd>
  <dd><a href="#">Item 1.4</a></dd>
  <dd><a href="#">Item 1.5</a></dd>
  <dd>
    <dl>
      <dt>Title 1.6</dt>    
      <dd><a href="#">Item 1.6.1</a></dd>
      <dd><a href="#">Item 1.6.2</a></dd>
    </dl>
  </dd>
  <dd><a href="#">Item 1.7</a></dd>
</dl>

备注

我已经写了数百个关于递归和数据转换主题的答案,但这是我认为我第一次以基本的方式使用.flat。我以为我在this Q&A 中有一个用例,但斯科特的评论从我这里拿走了!这个答案不同,因为domNode.childNodes 不是一个真正的数组,所以Array.prototype.flatMap 不能使用。感谢您提出有趣的问题。

【讨论】:

  • 标记清理 (:-) ) 使代码结构更好!美丽的代码,一如既往。使用.children 而不是.childNodes 应该会让一些事情变得更容易。
  • 我怎么从来不知道.children?!我没有看到它记录在 MDN: NodeMDN: HTMLElement 上。这是什么魔法?!
  • 我认为它实际上是一种旧格式,保留在 MDN:ParentNode 中。
【解决方案2】:

你最好在递归之前做出决定:

function buildTree(node) {
  const result = {};
  for (const el of node.children) {
    switch(el.nodeName) {
      case 'H3':
      case 'A':
        result.title = el.innerText;
        result.children = [];
        break;
      case 'DL':
        result.children = buildTree(el);
        break;
      case 'DT':
        result.children.push(buildTree(el));
        break;
      default:
        console.warn(`Unknown node type '${el.nodeName}'`, el);
    }

  }
  return result;
}

通过这个示例,我可以看到您尝试解析几乎相同的 DTs 和 DLs。

【讨论】:

    【解决方案3】:

    这是相互递归的一个明确案例。如果我们区分如何处理DL 和如何处理DT,这很容易处理。 (正如其他人所指出的,没有任何 DD 的这是一个奇怪的结构。)

    我在最初的DL 中添加了一个id,以便于获取。但是,无论您选择获取此根元素,您都应该能够将其传递给 handleDl 以取回您的结构。

    const handleDl = (dl) => 
      [...dl.children]
        .filter (({nodeName}) => nodeName == 'DT')
        .map (handleDt)
    
    const handleDt = (dt) => {
      const kids = [...dt.children]
      const h3 = kids .find (({nodeName}) => nodeName == 'H3')
      const dl = kids .find (({nodeName}) => nodeName == 'DL')
      return h3
        ? {title: h3.textContent, children: handleDl (dl)}
        : {title: dt.textContent}
    }
    
    const node = document.getElementById('foo')
    
    console .log (handleDl (node))
    .as-console-wrapper {max-height: 70% !important; top: 30%}
    &lt;dl id = "foo"&gt;&lt;dt&gt;&lt;h3&gt;Title A&lt;/h3&gt;&lt;dl&gt;&lt;dt&gt;&lt;h3&gt;Title A- A&lt;/h3&gt;&lt;dl&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;/dl&gt;&lt;/dt&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;dt&gt;&lt;h3&gt;Title B- A&lt;/h3&gt;&lt;dl&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;/dl&gt;&lt;/dt&gt;&lt;dt&gt;&lt;a href="#"&gt;Item&lt;/a&gt;&lt;/dt&gt;&lt;/dl&gt;&lt;/dt&gt;&lt;/dl&gt;

    handleDl 只是将handleDt 映射到所提供节点的所有DT 子节点上。

    handleDt 稍微复杂一些,因为有两种不同的样式。我们在节点的子节点中找到第一个H3 和第一个DL。如果找到H3,我们从中选择标题,并使用handleDlDL 处理为子节点。如果没有找到H3,我们只是根据当前节点的文本内容报告标题。假设这是对您的实际问题的简化,您可能必须更复杂地推导出此文本。但这应该不难。

    更新

    使用来自@Thankyou 的逻辑结构更清晰,我们可以更简单地编写相同的样式。 (请注意,有许多错误路径没有被检查。这可能与?-nullish 运算符有关……读者练习。)这与上面的故障相似,但代码更简单。它相当依赖于特定的结构,其中每个DD 有一个DL 或一个简单的HTML 包装器围绕我们的标题,每个DL 有一个DT,后跟一个或多个DDs。不过既然是DL的指定结构,这也不是什么难事。

    const handleDl = (dl) => ({
      title: dl .children [0] .textContent,
      children: [...dl .children] .slice (1) .map (handleDd)
    })
    
    const handleDd = (dd) => 
      dd .children [0] .nodeName == "DL"
        ? handleDl (dd .children [0])
        : {title: dd .textContent}
    
    const node = document .querySelector ('dl')
    
    console .log (handleDl (node))
    .as-console-wrapper {max-height: 70% !important; top: 30%}
    &lt;dl&gt;&lt;dt&gt;Title 1&lt;/dt&gt;&lt;dd&gt;&lt;dl&gt;&lt;dt&gt;Title 1.1&lt;/dt&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.1.1&lt;/a&gt;&lt;/dd&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.1.2&lt;/a&gt;&lt;/dd&gt;&lt;/dl&gt;&lt;/dd&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.2&lt;/a&gt;&lt;/dd&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.3&lt;/a&gt;&lt;/dd&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.4&lt;/a&gt;&lt;/dd&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.5&lt;/a&gt;&lt;/dd&gt;&lt;dd&gt;&lt;dl&gt;&lt;dt&gt;Title 1.6&lt;/dt&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.6.1&lt;/a&gt;&lt;/dd&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.6.2&lt;/a&gt;&lt;/dd&gt;&lt;/dl&gt;&lt;/dd&gt;&lt;dd&gt;&lt;a href="#"&gt;Item 1.7&lt;/a&gt;&lt;/dd&gt;&lt;/dl&gt;

    【讨论】:

    • answer 看起来很适合建议的输入。然而,这是对dldt 的滥用。 dl 可以嵌套,但前提是它出现在 dd 中。如果我要尝试这个,我想我会先修复输入。
    • 哇,这个问题比我预想的要难得多!尤其是如果我们不假设h3dl 有孩子在场。讨厌的(空)文本节点是真正的熊。任何使用相互递归的答案都会得到我的支持:D
    • @Thankyou:是的,但是您的标记简化提供了编写更好代码的能力。 (看看我原来的和我的更新之间的区别!)
    • 喜悦的泪水从我脸上流下来。 that 更像是我想象的可能但无法完全破解它。你真的想通了!新年快乐,斯科特。
    • Scott,一个interesting post 刚刚出现。我不确定他们到底想要什么,也许你会对这个问题有不同的解释。无论哪种方式,它看起来都很有趣。
    【解决方案4】:

    我认为一个重要的部分是 querySelector 和 querySelectorAll 是递归的。是不是你混淆了 dl 和 dt,因为一个 dl 中有多个 dt?以下内容对您有用吗?

    function buildTree(node) {
        if (!node) return [];
        const h3 = node.querySelector(':scope > h3') || node.querySelector(':scope > a');
        let result = {
            title: h3.innerText,
            children: []
        };
        const array = [...node.querySelectorAll(':scope > dl > dt')];
        result.children = array.map(el => buildTree(el));
        return result;
    }
    

    您必须首先传递一个 dt 节点才能使其工作。 (https://jsfiddle.net/52ups6fL/)

    【讨论】:

    • 虽然连续的多个&lt;dt&gt;s 在语义上是有效的,但OP 的代码仍然不是,因为根本没有&lt;dd&gt;s。
    • @user4642212 确实如此,但我不确定 OP 是否对 HTML 的结构有任何影响。
    • 它似乎工作了!谢谢。顺便说一句,我不知道:scope@likle
    猜你喜欢
    • 2013-11-22
    • 2017-02-04
    • 2021-07-04
    • 1970-01-01
    • 1970-01-01
    • 2020-11-19
    • 2021-12-31
    • 2023-03-24
    • 2015-11-23
    相关资源
    最近更新 更多