Какой замечательный вопрос! Мне очень понравилось придумывать решение для этого.
Поскольку вы хотели задать начальное состояние как для состояния меню, так и для состояния флажка, я думаю, что управление состоянием обоих на уровне <Menu>
(или даже выше!) хорошая идея. Это не только облегчает определение начального состояния от родителя, но также предоставляет вам большую гибкость, если вам понадобится более сложное поведение меню или флажков в будущем.
Поскольку структура меню рекурсивна Я думаю, что рекурсивная структура состояния меню работает довольно хорошо. Прежде чем я go в коде, вот небольшой GIF, который, я надеюсь, поможет объяснить, как выглядит состояние:
Демо
Вот фрагмент игровой площадки:
const loadMenu = () =>
Promise.resolve([
{
id: "1",
name: "One",
children: [
{
id: "1.1",
name: "One - one",
children: [
{ id: "1.1.1", name: "One - one - one" },
{ id: "1.1.2", name: "One - one - two" },
{ id: "1.1.3", name: "One - one - three" }
]
}
]
},
{ id: "2", name: "Two", children: [{ id: "2.1", name: "Two - one" }] },
{
id: "3",
name: "Three",
children: [
{
id: "3.1",
name: "Three - one",
children: [
{
id: "3.1.1",
name: "Three - one - one",
children: [
{
id: "3.1.1.1",
name: "Three - one - one - one",
children: [
{ id: "3.1.1.1.1", name: "Three - one - one - one - one" }
]
}
]
}
]
}
]
},
{ id: "4", name: "Four" },
{
id: "5",
name: "Five",
children: [
{ id: "5.1", name: "Five - one" },
{ id: "5.2", name: "Five - two" },
{ id: "5.3", name: "Five - three" },
{ id: "5.4", name: "Five - four" }
]
},
{ id: "6", name: "Six" }
]);
const { Component, Fragment } = React;
const { Button, Collapse, Input } = Reactstrap;
const replaceNode = (replacer, node, idPath, i) => {
if (i <= idPath.length && !node) {
// Not at target node yet, create nodes in between
node = {};
}
if (i > idPath.length) {
// Reached target node
return replacer(node);
}
// Construct ID that matches this depth - depth meaning
// the amount of dots in between the ID
const id = idPath.slice(0, i).join(".");
return {
...node,
// Recurse
[id]: replaceNode(replacer, node[id], idPath, i + 1)
};
};
const replaceNodeById = (node, id, visitor) => {
// Pass array of the id's parts instead of working on the string
// directly - easy way to handle multi-number ID parts e.g. 3.1.15.32
return replaceNode(visitor, node, id.split("."), 1);
};
const expandedNode = () => ({});
const unexpandedNode = () => undefined;
const toggleNodeById = (node, id) =>
replaceNodeById(node, id, oldNode =>
oldNode ? unexpandedNode() : expandedNode()
);
const expandNodeById = (node, id) =>
replaceNodeById(node, id, oldNode => expandedNode());
class Menu extends Component {
constructor(props) {
super(props);
this.state = {
menuItems: [],
openMenus: {},
checkedMenus: {}
};
this.handleMenuToggle = this.handleMenuToggle.bind(this);
this.handleChecked = this.handleChecked.bind(this);
}
render() {
const { menuItems, openMenus, checkedMenus } = this.state;
return (
Menu state
{JSON.stringify(openMenus, null, 2)}
Флажок состояния
{JSON.stringify(checkedMenus, null, 2)}
); } componentDidMount () {const {initialOpenMenuId, initialCheckedMenuIds} = this.props; loadMenu (). then (menuItems => {const initialMenuState = {}; this.setState ({menuItems, openMenus: expandNodeById (initialMenuState, initialOpenMenuId), checkedMenus: initialCheckedMenuIds.reduce ((a cc) (val = ... a cc, [val]: true}), {})});}); } handleMenuToggle (toggledId) {this.setState (({openMenus}) => ({openMenus: toggleNodeById (openMenus, toggledId)}))); } handleChecked (toggledId) {this.setState (({checkedMenus}) => ({checkedMenus: {... checkedMenus, [toggledId]: checkedMenus [toggledId] ?expandedNode (): extendedNode ()}})); }} function MenuItemContainer ({openMenus, onMenuToggle, checkedMenus, onChecked, menuItems = []}) {if (! menuItems.length) return null; const renderMenuItem = menuItem => ( ); return
; } класс MenuItem extends Component {constructor (props) {super (props); this.handleToggle = this.handleToggle.bind (this); this.handleChecked = this.handleChecked.bind (this); } render () {const {children, name, id, openMenus, onMenuToggle, checkedMenus, onChecked} = this.props; const isLastChild =! children; return ( {name} {isLastChild && ( )} ); } handleToggle () {this.props.onMenuToggle (this.props.id); } handleChecked () {this.props.onChecked (this.props.id); }} ReactDOM.render ( , document.getElementById ("root"));
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reactstrap/8.4.1/reactstrap.min.js"></script>
<div id="root"></div>
Ответ
Кодовое пошаговое руководство ниже.
const loadMenu = () =>
Promise.resolve([
{
id: "1",
name: "One",
children: [
{
id: "1.1",
name: "One - one",
children: [
{ id: "1.1.1", name: "One - one - one" },
{ id: "1.1.2", name: "One - one - two" },
{ id: "1.1.3", name: "One - one - three" }
]
}
]
},
{ id: "2", name: "Two", children: [{ id: "2.1", name: "Two - one" }] },
{
id: "3",
name: "Three",
children: [
{
id: "3.1",
name: "Three - one",
children: [
{
id: "3.1.1",
name: "Three - one - one",
children: [
{
id: "3.1.1.1",
name: "Three - one - one - one",
children: [
{ id: "3.1.1.1.1", name: "Three - one - one - one - one" }
]
}
]
}
]
}
]
},
{ id: "4", name: "Four" },
{
id: "5",
name: "Five",
children: [
{ id: "5.1", name: "Five - one" },
{ id: "5.2", name: "Five - two" },
{ id: "5.3", name: "Five - three" },
{ id: "5.4", name: "Five - four" }
]
},
{ id: "6", name: "Six" }
]);
const { Component, Fragment } = React;
const { Button, Collapse, Input } = Reactstrap;
const replaceNode = (replacer, node, idPath, i) => {
if (i <= idPath.length && !node) {
// Not at target node yet, create nodes in between
node = {};
}
if (i > idPath.length) {
// Reached target node
return replacer(node);
}
// Construct ID that matches this depth - depth meaning
// the amount of dots in between the ID
const id = idPath.slice(0, i).join(".");
return {
...node,
// Recurse
[id]: replaceNode(replacer, node[id], idPath, i + 1)
};
};
const replaceNodeById = (node, id, visitor) => {
// Pass array of the id's parts instead of working on the string
// directly - easy way to handle multi-number ID parts e.g. 3.1.15.32
return replaceNode(visitor, node, id.split("."), 1);
};
const expandedNode = () => ({});
const unexpandedNode = () => undefined;
const toggleNodeById = (node, id) =>
replaceNodeById(node, id, oldNode =>
oldNode ? unexpandedNode() : expandedNode()
);
const expandNodeById = (node, id) =>
replaceNodeById(node, id, oldNode => expandedNode());
class Menu extends Component {
constructor(props) {
super(props);
this.state = {
menuItems: [],
openMenus: {},
checkedMenus: {}
};
this.handleMenuToggle = this.handleMenuToggle.bind(this);
this.handleChecked = this.handleChecked.bind(this);
}
render() {
const { menuItems, openMenus, checkedMenus } = this.state;
return (
<MenuItemContainer
openMenus={openMenus}
menuItems={menuItems}
onMenuToggle={this.handleMenuToggle}
checkedMenus={checkedMenus}
onChecked={this.handleChecked}
/>
);
}
componentDidMount() {
const { initialOpenMenuId, initialCheckedMenuIds } = this.props;
loadMenu().then(menuItems => {
const initialMenuState = {};
this.setState({
menuItems,
openMenus: expandNodeById(initialMenuState, initialOpenMenuId),
checkedMenus: initialCheckedMenuIds.reduce(
(acc, val) => ({ ...acc, [val]: true }),
{}
)
});
});
}
handleMenuToggle(toggledId) {
this.setState(({ openMenus }) => ({
openMenus: toggleNodeById(openMenus, toggledId)
}));
}
handleChecked(toggledId) {
this.setState(({ checkedMenus }) => ({
checkedMenus: {
...checkedMenus,
[toggledId]: checkedMenus[toggledId] ? unexpandedNode() : expandedNode()
}
}));
}
}
function MenuItemContainer({
openMenus,
onMenuToggle,
checkedMenus,
onChecked,
menuItems = []
}) {
if (!menuItems.length) return null;
const renderMenuItem = menuItem => (
<li key={menuItem.id}>
<MenuItem
openMenus={openMenus}
onMenuToggle={onMenuToggle}
checkedMenus={checkedMenus}
onChecked={onChecked}
{...menuItem}
/>
</li>
);
return <ul>{menuItems.map(renderMenuItem)}</ul>;
}
class MenuItem extends Component {
constructor(props) {
super(props);
this.handleToggle = this.handleToggle.bind(this);
this.handleChecked = this.handleChecked.bind(this);
}
render() {
const {
children,
name,
id,
openMenus,
onMenuToggle,
checkedMenus,
onChecked
} = this.props;
const isLastChild = !children;
return (
<Fragment>
<Button onClick={isLastChild ? this.handleChecked : this.handleToggle}>
{name}
</Button>
{isLastChild && (
<Input
addon
type="checkbox"
onChange={this.handleChecked}
checked={!!checkedMenus[id]}
value={id}
/>
)}
<Collapse isOpen={openMenus ? !!openMenus[id] : false}>
<MenuItemContainer
menuItems={children}
// Pass down child menus' state
openMenus={openMenus && openMenus[id]}
onMenuToggle={onMenuToggle}
checkedMenus={checkedMenus}
onChecked={onChecked}
/>
</Collapse>
</Fragment>
);
}
handleToggle() {
this.props.onMenuToggle(this.props.id);
}
handleChecked() {
this.props.onChecked(this.props.id);
}
}
ReactDOM.render(
<Menu initialOpenMenuId="3.1.1.1" initialCheckedMenuIds={["3.1.1.1.1"]} />,
document.getElementById("root")
);
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reactstrap/8.4.1/reactstrap.min.js"></script>
<div id="root"></div>
Пошаговое руководство
Прежде чем начать, я должен сказать, что позволил себе сменить код для использования современного JavaScript такие функции, как деструктуризация объекта , деструктуризация массива , остальные и значения по умолчанию .
Создание состояния
Итак. Поскольку идентификаторы пунктов меню - это числа, разделенные точкой, мы можем воспользоваться этим при построении состояния. Состояние по сути является древовидной структурой, где каждое подменю является дочерним по отношению к его родительскому элементу, а конечный узел («последнее меню» или «самое глубокое меню») имеет значение либо {}
, если оно развернуто, либо undefined
если нет. Вот как создается начальное состояние меню:
<Menu initialOpenMenuId="3.1.1.1" initialCheckedMenuIds={["3.1.1.1.1"]} />
// ...
loadMenu().then(menuItems => {
const initialMenuState = {};
this.setState({
menuItems,
openMenus: expandNodeById(initialMenuState, initialOpenMenuId),
checkedMenus: initialCheckedMenuIds.reduce(
(acc, val) => ({ ...acc, [val]: true }),
{}
)
});
});
// ...
const expandedNode = () => ({});
const unexpandedNode = () => undefined;
const toggleNodeById = (node, id) =>
replaceNodeById(node, id, oldNode =>
oldNode ? unexpandedNode() : expandedNode()
);
const expandNodeById = (node, id) =>
replaceNodeById(node, id, oldNode => expandedNode());
const replaceNodeById = (node, id, visitor) => {
// Pass array of the id's parts instead of working on the string
// directly - easy way to handle multi-number ID parts e.g. 3.1.15.32
return replaceNode(visitor, node, id.split("."), 1);
};
const replaceNode = (replacer, node, idPath, i) => {
if (i <= idPath.length && !node) {
// Not at target node yet, create nodes in between
node = {};
}
if (i > idPath.length) {
// Reached target node
return replacer(node);
}
// Construct ID that matches this depth - depth meaning
// the amount of dots in between the ID
const id = idPath.slice(0, i).join(".");
return {
...node,
// Recurse
[id]: replaceNode(replacer, node[id], idPath, i + 1)
};
};
Давайте разберем это по частям.
const expandedNode = () => ({});
const unexpandedNode = () => undefined;
Это просто удобные функции, которые мы определяем, чтобы мы могли легко изменить значение, которое мы используем для представления расширенного и нерасширенного узла. Это также делает код немного более читабельным по сравнению с простым использованием в коде литерала {}
или undefined
. Расширенные и нерасширенные значения также могут быть true
и false
, что имеет значение, так как расширенный узел истинен , а нерасширенный узел ложный. Подробнее об этом позже.
const toggleNodeById = (node, id) =>
replaceNodeById(node, id, oldNode =>
oldNode ? unexpandedNode() : expandedNode()
);
const expandNodeById = (node, id) =>
replaceNodeById(node, id, oldNode => expandedNode());
Эти функции позволяют переключать или расширять конкретное меню c в состоянии меню. Первый параметр - это само состояние меню, второй - идентификатор строки меню (например, "3.1.1.1.1"
), а третий - функция, выполняющая замену. Думайте об этом как о функции, которую вы передаете .map()
. Функциональность заменителя отделена от фактической итерации рекурсивного дерева, так что вы можете легко реализовать больше функциональности позже - например, если вы хотите, чтобы какое-то конкретное меню c не расширялось, вы можете просто передать функцию, которая возвращает unexpandedNode()
.
const replaceNodeById = (node, id, visitor) => {
// Pass array of the id's parts instead of working on the string
// directly - easy way to handle multi-number ID parts e.g. 3.1.15.32
return replaceNode(visitor, node, id.split("."), 1);
};
Эта функция используется двумя предыдущими для обеспечения более чистого интерфейса. Идентификатор разделен здесь точками (.
), которые дают нам массив частей идентификатора. Следующая функция работает с этим массивом, а не со строкой ID напрямую, потому что таким образом нам не нужно делать .indexOf('.')
shenanigans.
const replaceNode = (replacer, node, idPath, i) => {
if (i <= idPath.length && !node) {
// Not at target node yet, create nodes in between
node = {};
}
if (i > idPath.length) {
// Reached target node
return replacer(node);
}
// Construct ID that matches this depth - depth meaning
// the amount of dots in between the ID
const id = idPath.slice(0, i).join(".");
return {
...node,
// Recurse
[id]: replaceNode(replacer, node[id], idPath, i + 1)
};
};
Функция replaceNode
является основным вопросом. Это рекурсивная функция, которая создает новое дерево из старого дерева меню, заменяя старый целевой узел предоставленной функцией заменителя. Если в дереве отсутствуют промежуточные части, например, когда дерево {}
, но мы хотим заменить узел 3.1.1.1
, оно создает родительские узлы между ними. Вроде как mkdir -p
, если вы знакомы с командой.
Так что это состояние меню. Состояние флажка (checkedMenus
) - это просто индекс, ключом является идентификатор и значение true
, если элемент отмечен. Это состояние не является рекурсивным, поскольку их не нужно проверять или проверять рекурсивно. Если вы решите, что хотите отобразить индикатор того, что что-то в этом пункте меню отмечено, простым решением было бы изменить состояние флажка на рекурсивное, как состояние меню.
Рендеринг дерева
Компонент <Menu>
передает состояния до <MenuItemContainer>
, что делает <MenuItem>
s.
function MenuItemContainer({
openMenus,
onMenuToggle,
checkedMenus,
onChecked,
menuItems = []
}) {
if (!menuItems.length) return null;
const renderMenuItem = menuItem => (
<li key={menuItem.id}>
<MenuItem
openMenus={openMenus}
onMenuToggle={onMenuToggle}
checkedMenus={checkedMenus}
onChecked={onChecked}
{...menuItem}
/>
</li>
);
return <ul>{menuItems.map(renderMenuItem)}</ul>;
}
Компонент <MenuItemContainer>
не очень отличается от исходного компонента. Однако компонент <MenuItem>
выглядит немного по-другому.
class MenuItem extends Component {
constructor(props) {
super(props);
this.handleToggle = this.handleToggle.bind(this);
this.handleChecked = this.handleChecked.bind(this);
}
render() {
const {
children,
name,
id,
openMenus,
onMenuToggle,
checkedMenus,
onChecked
} = this.props;
const isLastChild = !children;
return (
<Fragment>
<Button onClick={isLastChild ? this.handleChecked : this.handleToggle}>
{name}
</Button>
{isLastChild && (
<Input
addon
type="checkbox"
onChange={this.handleChecked}
checked={!!checkedMenus[id]}
value={id}
/>
)}
<Collapse isOpen={openMenus ? !!openMenus[id] : false}>
<MenuItemContainer
menuItems={children}
// Pass down child menus' state
openMenus={openMenus && openMenus[id]}
onMenuToggle={onMenuToggle}
checkedMenus={checkedMenus}
onChecked={onChecked}
/>
</Collapse>
</Fragment>
);
}
handleToggle() {
this.props.onMenuToggle(this.props.id);
}
handleChecked() {
this.props.onChecked(this.props.id);
}
}
Здесь важная часть: openMenus={openMenus && openMenus[id]}
. Вместо передачи всего состояния меню мы передаем только дерево состояний, которое содержит дочерние элементы текущего элемента. Это позволяет компоненту очень легко проверить, должен ли он быть открыт или свернут - просто проверьте, найден ли у него его собственный идентификатор (openMenus ? !!openMenus[id] : false
)!
Я также изменил кнопку-переключатель, чтобы переключать флажок вместо состояния меню, если это самый глубокий элемент в меню - если это не то, что вам нужно, его довольно быстро изменить обратно.
Я также использую !!
здесь для приведения {}
и undefined
из состояния меню в true
или false
. Вот почему я сказал, что важно только, правдивы они или нет. * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *} * * * * * * * * * * * * * * * * * * * * * * * * * * * *} Теперь, когда он здесь: исходное состояние до <Menu>
. initialOpenMenuId
также может быть массивом (или initialCheckedMenuIds
может быть единственной строкой), но это соответствует теме вопроса c.
Возможности для улучшения
Решение сейчас проходит через множество состояний, например, обратные вызовы onMenuToggle
и onChecked
и состояние checkedMenus
, которое не является рекурсивным. В них можно использовать контекст React .
.