Thật là một câu hỏi tuyệt vời! Tôi thực sự rất thích đến với một giải pháp cho cái này.
Vì bạn muốn đưa ra trạng thái ban đầu cho cả trạng thái menu và trạng thái hộp kiểm, tôi nghĩ rằng việc kiểm soát trạng thái của cả hai ở <Menu>
cấp độ (hoặc thậm chí cao hơn!) Là một ý tưởng tốt. Điều này không chỉ giúp bạn dễ dàng xác định trạng thái ban đầu từ cha mẹ mà còn cho phép bạn linh hoạt hơn nếu bạn cần một số hành vi hoặc hộp kiểm phức tạp hơn trong tương lai.
Vì cấu trúc của các menu là đệ quy, tôi nghĩ rằng có một cấu trúc đệ quy cho trạng thái menu hoạt động khá tốt. Trước khi tôi đi vào mã, đây là một GIF ngắn, tôi hy vọng, sẽ giúp giải thích trạng thái trông như thế nào:
Bản giới thiệu
Đây là đoạn trích sân chơi:
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>
Câu trả lời
Mã hướng dẫn dưới đây.
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>
Hướng dẫn
Trước khi bắt đầu, tôi phải nói rằng tôi đã tự do thay đổi một số mã để sử dụng các tính năng JavaScript hiện đại như phá hủy đối tượng ,
phá hủy mảng , phần còn lại và các giá trị mặc định .
Tạo trạng thái
Vì thế. Vì ID của các mục menu là các số được phân tách bằng dấu chấm, nên chúng ta có thể tận dụng lợi thế này khi xây dựng trạng thái. Trạng thái về cơ bản là một cấu trúc giống như cây, với mỗi menu phụ là con của cha mẹ và nút lá ("menu cuối cùng" hoặc "menu sâu nhất") có giá trị của {}
nó nếu được mở rộng hoặc undefined
nếu không. Đây là cách trạng thái ban đầu của menu được xây dựng:
<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)
};
};
Chúng ta hãy tách rời từng chút một.
const expandedNode = () => ({});
const unexpandedNode = () => undefined;
Đây chỉ là các hàm tiện lợi mà chúng tôi xác định để chúng tôi có thể dễ dàng thay đổi giá trị chúng tôi sử dụng để thể hiện một nút mở rộng và chưa được mở rộng. Nó cũng làm cho mã dễ đọc hơn một chút so với việc chỉ sử dụng bằng chữ {}
hoặc undefined
trong mã. Các giá trị mở rộng và chưa được mở rộng cũng có thể là như vậy , true
và false
điều quan trọng là nút mở rộng là trung thực và nút chưa được mở rộng là sai lệch . Thêm về điều đó sau.
const toggleNodeById = (node, id) =>
replaceNodeById(node, id, oldNode =>
oldNode ? unexpandedNode() : expandedNode()
);
const expandNodeById = (node, id) =>
replaceNodeById(node, id, oldNode => expandedNode());
Các chức năng này cho phép chúng tôi chuyển đổi hoặc mở rộng một menu cụ thể trong trạng thái menu. Tham số đầu tiên là chính trạng thái menu, thứ hai là ID chuỗi của menu (ví dụ "3.1.1.1.1"
) và thứ ba là chức năng thay thế. Hãy nghĩ về điều này giống như chức năng bạn chuyển đến .map()
. Chức năng thay thế được tách ra khỏi lần lặp cây đệ quy thực tế để bạn có thể dễ dàng thực hiện nhiều chức năng hơn sau này - ví dụ: nếu bạn muốn một số menu cụ thể được mở rộng, bạn có thể chuyển vào một hàm trả về 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);
};
Chức năng này được sử dụng bởi hai cái trước để cung cấp giao diện sạch hơn. ID được phân chia ở đây bởi các dấu chấm ( .
) cung cấp cho chúng ta một mảng các phần ID. Hàm tiếp theo hoạt động trên mảng này thay vì chuỗi ID trực tiếp, bởi vì theo cách đó chúng ta không cần phải thực hiện .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)
};
};
Các replaceNode
chức năng là thịt của vấn đề. Đây là một hàm đệ quy tạo ra một cây mới từ cây menu cũ, thay thế nút đích cũ bằng hàm thay thế được cung cấp. Nếu cây bị thiếu các phần ở giữa, ví dụ như khi cây {}
nhưng chúng ta muốn thay thế nút 3.1.1.1
, nó sẽ tạo các nút cha ở giữa. Kiểu như mkdir -p
nếu bạn quen thuộc với lệnh.
Vì vậy, đó là trạng thái menu. Trạng thái hộp kiểm ( checkedMenus
) về cơ bản chỉ là một chỉ mục, với khóa là ID và giá trị true
nếu một mục được chọn. Trạng thái này không được đệ quy, vì chúng không cần phải được kiểm tra hoặc kiểm tra đệ quy. Nếu bạn quyết định rằng bạn muốn hiển thị một chỉ báo rằng một cái gì đó trong mục menu này được chọn, một giải pháp dễ dàng là thay đổi trạng thái hộp kiểm để được đệ quy như trạng thái menu.
Kết xuất cây
Thành <Menu>
phần này truyền xuống các trạng thái <MenuItemContainer>
, làm cho <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>;
}
Thành <MenuItemContainer>
phần này không khác lắm so với thành phần ban đầu. Các <MenuItem>
thành phần trông hơi khác nhau, mặc dù.
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);
}
}
Ở đây phần quan trọng là đây : openMenus={openMenus && openMenus[id]}
. Thay vì chuyển xuống toàn bộ trạng thái menu, chúng tôi chỉ chuyển xuống cây trạng thái chứa con của mục hiện tại. Điều này cho phép thành phần rất dễ dàng kiểm tra xem nó nên được mở hay thu gọn - chỉ cần kiểm tra xem ID của chính nó có được tìm thấy từ đối tượng không ( openMenus ? !!openMenus[id] : false
)!
Tôi cũng đã thay đổi nút chuyển đổi để chuyển đổi hộp kiểm thay vì trạng thái menu nếu đó là mục sâu nhất trong menu - nếu đây không phải là thứ bạn đang tìm kiếm, thì khá nhanh để thay đổi lại.
Tôi cũng sử dụng !!
ở đây để ép buộc {}
và undefined
từ trạng thái menu vào true
hoặc false
. Đây là lý do tại sao tôi nói nó chỉ quan trọng cho dù chúng là sự thật hay giả. Các reactstrap
thành phần dường như muốn rõ ràng true
hoặc false
thay vì trung thực / giả dối, vì vậy đó là lý do tại sao nó ở đó.
Và cuối cùng:
ReactDOM.render(
<Menu initialOpenMenuId="3.1.1.1" initialCheckedMenuIds={["3.1.1.1.1"]} />,
document.getElementById("root")
);
Ở đây chúng tôi vượt qua trạng thái ban đầu để <Menu>
. Đây initialOpenMenuId
cũng có thể là một mảng (hoặc initialCheckedMenuIds
có thể là một chuỗi đơn), nhưng điều này phù hợp với thông số kỹ thuật của câu hỏi.
Phòng cải tiến
Giải pháp ngay bây giờ chuyển xuống rất nhiều trạng thái, chẳng hạn như onMenuToggle
và onChecked
gọi lại, và checkedMenus
trạng thái không đệ quy. Chúng có thể sử dụng Bối cảnh của React .