すばらしい質問です。これに対する解決策を思いつくのは本当に楽しかったです。
メニューの状態とチェックボックスの状態の両方に初期状態を指定したかったので、<Menu>
レベル(またはそれ以上)で両方の状態を制御することをお勧めします。これにより、親から初期状態を簡単に定義できるだけでなく、将来さらに複雑なメニューやチェックボックスの動作が必要になった場合に、より柔軟に対応できます。
メニューの構造は再帰的であるため、メニューの状態に再帰的構造を持たせるとかなりうまくいくと思います。コードに入る前に、短い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 (
<div
style={{
display: "flex",
flexDirection: "row",
columnCount: 3,
justifyContent: "space-between"
}}
>
<div style={{ paddingTop: "10px" }}>
<MenuItemContainer
openMenus={openMenus}
menuItems={menuItems}
onMenuToggle={this.handleMenuToggle}
checkedMenus={checkedMenus}
onChecked={this.handleChecked}
/>
</div>
<div style={{ padding: "10px", marginLeft: "auto" }}>
<p>Menu state</p>
<pre>{JSON.stringify(openMenus, null, 2)}</pre>
</div>
<div style={{ padding: "10px", width: "177px" }}>
<p>Checkbox state</p>
<pre>{JSON.stringify(checkedMenus, null, 2)}</pre>
</div>
</div>
);
}
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>
回答
以下のコードウォークスルー。
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機能を使用するようにしたことを言わなければなりません。
状態を作成する
そう。メニュー項目のIDはドットで区切られた数字であるため、状態を構築するときにこれを利用できます。状態は基本的にツリーのような構造で、各サブメニューはその親の子であり、リーフノード(「最後のメニュー」または「最深のメニュー」)は、{}
展開されているか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());
これらの関数を使用して、メニュー状態の特定のメニューを切り替えたり展開したりできます。最初のパラメータはメニューの状態そのもの、2番目はメニューの文字列ID(例:)"3.1.1.1.1"
、3番目は置換を行う関数です。これを、渡した関数のように考えてください.map()
。置換機能は実際の再帰的なツリー反復から分離されているため、後で簡単に追加の機能を実装できます。たとえば、特定のメニューを展開しない場合は、を返す関数を渡すだけ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);
};
この関数は、前の2つの関数で使用され、よりクリーンなインターフェイスを提供します。ここでは、IDがドット(.
)で分割されており、ID部分の配列を示しています。次の関数は、ID文字列の代わりにこの配列を直接操作します。これは、そのようにして.indexOf('.')
不正操作を行う必要がないためです。
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
機能は、問題の肉です。これは、古いメニューツリーから新しいツリーを生成する再帰的な関数であり、古いターゲットノードを提供されたreplacer関数で置き換えます。ツリーの途中からパーツが欠落している場合(たとえば、ツリーは{}
ノード3.1.1.1
であるがノードを置き換えたい場合)は、その間に親ノードを作成します。mkdir -p
コマンドに慣れているようなものです。
これがメニューの状態です。チェックボックスの状態(checkedMenus
)は基本的に単なるインデックスであり、キーはIDであり、true
アイテムがチェックされている場合の値です。この状態は、チェックを外したり再帰的にチェックしたりする必要がないため、再帰的ではありません。このメニュー項目の下にあるものがチェックされていることを示すインジケーターを表示する場合、簡単な解決策は、チェックボックスの状態をメニューの状態のように再帰的に変更することです。
ツリーをレンダリングする
<Menu>
状態ダウンコンポーネントパスの<MenuItemContainer>
レンダリング、<MenuItem>
秒。
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]}
。メニュー状態全体を渡すのではなく、現在のアイテムの子を含む状態ツリーのみを渡します。これにより、コンポーネントは開いたり折りたたんだりする必要があるかどうかを非常に簡単に確認できます。オブジェクトから独自のIDが見つかったかどうかを確認するだけopenMenus ? !!openMenus[id] : false
です()!
また、トグルボタンを変更して、メニューの最も深い項目である場合は、メニューの状態ではなくチェックボックスをトグルするように変更しました。
私はまた、利用することが!!
強要にここ{}
とundefined
にメニュー状態からtrue
かfalse
。これが私が彼らが真実か偽物かどうかだけが重要であると私が言った理由です。reactstrap
コンポーネントが明示したいように見えたtrue
かfalse
、それはありますなぜこれほどのことではなく、truthy / falsyの。
そして最後に:
ReactDOM.render(
<Menu initialOpenMenuId="3.1.1.1" initialCheckedMenuIds={["3.1.1.1.1"]} />,
document.getElementById("root")
);
ここでは、初期状態をに渡し<Menu>
ます。initialOpenMenuId
また、配列にすることができ(またはinitialCheckedMenuIds
単一の文字列かもしれない)が、これは質問のスペックに合わせて。
改善の余地
現在のソリューションでは、onMenuToggle
やonChecked
コールバックなどの多くの状態と、checkedMenus
再帰的ではない状態が渡されます。これらは、Reactのコンテキストを利用できます。