Рекурсия и js-фреймворки

Как насчёт динамического меню когда глубина вложенности неизвестна? Пример из жизни сайдбар. Под катом реализация для React и Vue.

В качестве мока используем данные из trees.js.

// tree.js
const tree = [
  { name: 'Level 1.1' },
  {
    name: 'Level 1.2',
    children: [
      { name: 'Level 2.1' },
      { name: 'Level 2.2' }
    ]
  },
  {
    name: 'Level 1.3',
    children: [
      { name: 'Level 2.3' },
      { name: 'Level 2.4' },
      {
        name: 'Level 2.5',
        children: [
          { name: 'Level 3.1' },
          {
            name: 'Level 3.2',
            children: [
              { name: 'Level 4.1' },
              { name: 'Level 4.2' }
            ]
          },
          { name: 'Level 3.3' }
        ]
      }
    ]
  }
]

export default tree

React

Создать новый проект.

$ npx create-react-app new-project
$ cd new-project
$ npm run start

Структура проекта:

.
├── src
│   ├── App.js
│   ├── components
│   │   ├── Sidebar.js
│   │   └── SidebarItem.js
│   ├── index.js
│   └── tree.js
└── public
Точка входа. Здесь будеть жить сайдбар и функция handleClick, которая сможет принять данные при клике на любой элемент из списка.
import React from 'react'
import tree from './tree'
import Sidebar from './components/Sidebar'

function handleClick (e) {
  e.preventDefault();
  // При клике на любой пункт вывести название пункта
  console.log(e.target.textContent)
}

export default function App() {
  return <Sidebar items={tree} handleClick={handleClick} />
}
Обойти список. Передать элементы первого уровня в качестве props для SidebarItem.
import React from 'react'
import SidebarItem from './SidebarItem'

export default function Sidebar({ items, handleClick }) {
  return (
    <div className="sidebar">
      <ul className="sidebar-list">
        {items.map((sidebarItem, index) => (
          <SidebarItem
            key={`${sidebarItem.name}-${index}`}
            handleClick={handleClick}
            {...sidebarItem}
          />
        ))}
      </ul>
    </div>
  )
}
Отобразить имена элементов первого уровня. Проверить наличие вложенных уровней и, если они есть, вернуть SidebarItem снова, передав ему новый список (рекурсия в действии).
import React from 'react'

const styles = {
  btn: {
    border: 0,
    outline: 'none'
  }
}

export default function SidebarItem({ name, children, handleClick, ...rest }) {
  return (
    <>
      <li {...rest}>
        <button style={{ ...styles.btn }} onClick={handleClick}>
          <span>{name}</span>
        </button>
      </li>
      {Array.isArray(children) ? (
        <ul>
          {children.map((subItem) => (
            <SidebarItem
              key={subItem.name}
              handleClick={handleClick}
              {...subItem}
            />
          ))}
        </ul>
      ) : null}
    </>
  )
}

Vue

Vue отличается от React своей магией. Не всегда очевидно то, что фреймворк хранит «под капотом», но порой эта магия может упростить жизнь. Но не в этом случае. Логика аналогична той, что мы писали выше.

Создадим новый проект. Для простоты сделаем это с vue-cli.

$ npx @vue/cli create new-project
$ cd new-project
$ npm run serve

Структура проекта:

.
├── public
└── src
    ├── App.vue
    ├── components
    │   ├── Sidebar.vue
    │   └── SidebarItem.vue
    ├── main.js
    └── tree.js
Точка входа. Подключим компоненты и прокинем данные.
<template>
  <div id="app">
    <sidebar :data="tree" @handleClick="handleClick" />
  </div>
</template>

<script>
import tree from './tree'
import Sidebar from './components/Sidebar'

export default {
  name: 'app',
  data () {
    return {
      tree
    }
  },
  components: {
    Sidebar
  },
  methods: {
    handleClick (node) {
      console.log('Clicked: ', node.name)
    }
  }
}
</script>
Обойти список. Передать элементы первого уровня в качестве props для SidebarItem.
<template>
  <ul>
    <li v-for="node in data" :key="`${node.name}`">
      <sidebar-item :node="node" :handleClick="handleClick" />
    </li>
  </ul>
</template>

<script>
import SidebarItem from './SidebarItem'

export default {
  name: 'sidebar',
  props: {
    data: {
      type: Array,
      required: true
    }
  },
  components: {
    SidebarItem
  },
  methods: {
    handleClick (node) {
      this.$emit('handleClick', node)
    }
  }
}
</script>
Отобразить имена элементов первого уровня (node.name). Проверить наличие вложенных уровней (node.children) и, если они есть, вернуть этот же компонент снова, передав ему новый список.

В отличие от React, где есть такая сущность как фрагмент, для Vue 2.x пока нельзя обойтись без обёртки div. Ходят слухи, что в третьей версии фрагменты тоже появятся.

<template>
  <div>
    <button class="label" @click="handleClick(node)">{{ node.name }}</button>

    <ul v-if="node.children && node.children.length">
      <sidebar-item
        v-for="(child, index) in node.children"
        :key="index"
        :node="child"
        :handleClick="handleClick"
      />
    </ul>
  </div>
</template>

<script>
export default {
  name: 'sidebar-item',
  props: {
    node: { type: Object, required: true },
    handleClick: { type: Function, default: () => {} }
  }
}
</script>

<style scoped>
.label {
  border: 0;
  outline: 'none';
}
</style>

Recursion