Hướng dẫn tạo menu dạng cây phân cấp bằng Javascript và CSS
1. Mô tả menu dạng cây phân cấp
Menu dạng cây phân cấp hiện nay được sử dụng khá nhiều ở các trang web. Điểm đặc biệt của nó là chủ yếu được viết bằng Javascript nên có thể chạy trên các trang web tĩnh. Nó có khá nhiều lợi ích như tiết kiệm diện tích khi cần thiết, tăng tính tương tác với người dùng. Menu dạng cây phân cấp có dạng như hình sau:
Ở đó mỗi khi chúng ta nhấn vào 1 đề mục thì nó sẽ được mở ra để hiện các mục nhỏ hơn phía dưới. Menu dạng này giống như menu trong các chương trình duyệt file (Windows Explorer chẳng hạn).
Mỗi phần tử của menu có thể là 1 điểm cuối, hoặc cũng có thể chính là 1 menu, nghĩa là bên trong nó còn có thể chứa 1 hoặc nhiều phần tử khác, bản thân các phần tử này lại có thể là điểm cuối hay 1 menu khác. Điểm cuối ở đây hiểu là 1 phần tử mà trong nó không có phần tử nào. Cấu trúc này cho phép chúng ta xây dựng menu dạng cây phân cấp có số cấp vô hạn. Trong hình vẽ trên điểm cuối được biểu thị bằng hình file, còn menu được biểu thị bằng hình thư mục.
Khi nhấn lên 1 menu thì các mục trong nó sẽ được mở ra, nhấn thêm 1 lần nữa thì menu đó sẽ được đóng lại. Còn nhấn lên 1 điểm cuối thì chúng ta sẽ thực hiện 1 tác vụ nào đó cần thiết.
2. Mô tả cấu trúc dữ liệu biểu diễn menu dạng cây phân cấp
Mỗi 1 phần tử của menu trên các trang web thông thường có các thuộc tính sau:
- name: dùng để ghi tên của phần tử đó và cũng chính là tên khi hiển thị phần tử đó trên trình duyệt.
- url: thường được dùng trong các điểm cuối khi muốn hiển thị url ở 1 vị trí của trang web hay chuyển đến trang khác.
- desc: là phần mô tả 1 phần tử của menu. Trong hình trên phần này không có, tuy nhiên khi thiết kế những menu mà mỗi 1 phần tử chứa trong nó những dữ liệu phức tạp như form đăng kí, hình ảnh, ... thì phần này rất cần thiết.
- list: nếu phần tử đó là 1 menu thì cần phải có 1 danh sách các phần tử con của nó.
Trong các thuộc tính trên, thuộc tính name và list gần như là bắt buộc, url và desc là tuy chọn, có thể có hoặc không. Ngoài các thuộc tính này, các bạn có thể thêm vào menu của mình 1 số thuộc tính khác như ID chẳng hạn, tùy vào hoàn cảnh và mục đích sử dụng.
Như vậy thì mô hình chung của 1 phần tử menu có dạng sau:
menu
- name: 'Menu Element Name';
- url: 'Menu Element Link';
- desc: 'Something about this element';
- list: menu, menu, ...; // nếu có
Dữ liệu dạng phân cấp này thông thường có thể biểu diễn theo 2 cách: XML và JSON (JavaScript Object Notation). Cách biểu diễn dạng XML uyển chuyển hơn trong việc xóa, bổ xung dữ liệu và khá đơn giản với những người không phải là coder. Còn cách biểu diễn JSON quen thuộc với những coder hơn, dễ xảy ra những lỗi cú pháp khi biểu diễn nhưng lại dễ dàng thao tác với dữ liệu hơn. Với 1 đối tượng viết ở dạng JSON thì chúng ta có thể thao tác trực tiếp với các phần tử trong đó mà không gặp rắc rối, còn với XML các bạn có thể sẽ nghĩ đến ngay Ajax để lấy dữ liệu và thao tác trên từng DOM element của nó để trích xuất dữ liệu ra.
Ở đây chúng ta dùng JavaScript là chủ yếu nên sẽ dùng JSON để tiện thao tác. Như thế thì dữ liệu của menu chúng ta có thể mô tả dạng như sau:
HTML Code:
var menuData = {
name: 'Main menu',
list: [
{
name: 'Sub menu 1',
url: 'http://linksubmenu1.com',
list: [
{name: 'Sub sub menu 1', url: 'http://linksubsubmenu1'},
{name: 'Sub sub menu 2', url: 'http://linksubsubmenu2'}
]
},
{
name: 'Sub menu 2',
url: 'http://linksubmenu2.com',
list: [
{name: 'Sub sub menu 1', url: 'http://linksubsubmenu1'},
{name: 'Sub sub menu 2', url: 'http://linksubsubmenu2'}
]
}
]
};
Đó là mô phỏng 1 menu dạng cây đa cấp, bạn có thể bổ xung bao nhiêu phần tử vào cũng được miễn là phải tôn trọng quy tắc JSON. Quy tắc này thực ra cũng không có gì phức tạp cả, chỉ cần chú ý biểu diễn 1 đối tượng chúng ta dùng dấu ngoặc nhọn {}, còn khi liệt kê 1 mảng chúng ta dùng dấu ngoặc vuông [], mỗi thuộc tính thì biểu diễn bằng cặp name: value.
Như vậy phần biểu diễn dữ liệu cho menu đã xong. Phần này khá quan trọng, vì dữ liệu sẽ quyết định cách chúng ta làm việc sau này. Nếu biểu diễn không thống nhất sẽ dẫn đến các phần sau không biết nên làm thế nào và có nguy cơ phải biểu diễn lại.
3. Biểu diễn các phần tử của menu
Ở phần trên chúng ta đã biểu diễn dữ liệu của menu. Phần đó cần được tách riêng biệt để dễ bổ xung, sửa đổi khi cần thiết. Nhiệm vụ của chúng ta là từ dữ liệu đó tạo ra các đối tượng có các phương thức biểu diễn cần thiết để thao tác với chúng.
Vì thế, chúng ta xây dựng 1 đối tượng gọi là menuTree dùng để mô phỏng menu của chúng ta dựa trên dữ liệu được cho bởi 1 đối tượng menuData được viết theo cú pháp JSON. Hàm tạo của đối tượng menuTree sẽ như sau:
HTML Code:
// Create a menu from an object written in JSON format
function menuTree(menuData) {
this.name = menuData.name;
this.url = (menuData.url) ? menuData.url : '' ;
this.desc = (menuData.desc) ? menuData.desc : '';
// If it's a cursive menu, create a list of submenu and add it
if (menuData.list) {
var list = new Array();
for (var i = 0; i < menuData.list.length; i++) {
list[i] = new menuTree(menuData.list[i]);
list[i].parent = this;
}
this.list = new Array();
this.addList(list);
}
// Create DOM node for menu
this.render();
}
3 dòng đầu dùng để gán các thuộc tính name, url, desc từ menuData sang đối tượng của chúng ta. Phần tiếp theo để xử lí khi menuData chứa 1 danh sách các phần tử con. Chú ý là ở phần này chúng ta gọi đệ quy để sinh ra chính các phần tử của menu tương ứng với các nút của menuData, tức là sẽ sinh thêm 1 phần tử ứng với 1 nút, rồi bên trong phần tử đó lại tiếp tục sinh ra 1 phần tử con khác nếu có. Chỗ này khá quan trọng vì nó sẽ đảm bảo cho bạn mô phỏng được cây phân cấp với số cấp bất kì. Chúng ta cũng tạo 1 liên kết ngược đến cha của nó qua lệnh list[i].parent = this;, dùng để dự trữ cho các trường hợp cần thiết.
Sau khi sinh ra hết các phần tử rồi, chúng ta đưa nó vào 1 mảng tên list và bổ xung nó vào đối tượng của chúng ta qua hàm addList(). Hàm addList() đuwọc viết như sau:
HTML Code:
// Add a list of items to menu
menuTree.prototype.addList = function(list) {
if (this.isMenu() && list.length > 0) {
var n = this.list.length;
for (var i = 0; i < list.length; i++, n++) {
this.list[n] = list[i];
}
}
}
Không có gì khó hiểu cả, chúng ta chèn phần tử list[i] vào cuối mảng list của đối tượng thôi.
Như vậy toàn bộ dữ liệu từ menuData đã được 'sao chép' qua đối tượng của chúng ta. Có thể bạn tự hỏi tại sao phải sao chép như vậy mà không dùng menuData làm 1 đối tượng của chúng ta luôn và thao tác trên đó? Câu trả lời là chúng ta có thể làm như thế, nhưng với mỗi dữ liệu khác nhau chúng ta lại phải có các hàm xử lí tương ứng, mà không thể nhóm chúng lại thành 1 đối tượng chung để dùng, sẽ rất phức tạp và rối nếu như bạn muốn làm 1 lúc 3-4 menu chẳng hạn. Hơn nữa việc biểu diễn dữ liệu độc lập sẽ giúp chúng ta tùy biến dữ liệu hơn, chỉnh sửa, xóa mà không ảnh hưởng đến các chức năng phía sau và cũng không ảnh hưởng đến các đối tượng menu khác.
4. Thao tác với các DOM node
Muốn hiển thị được menu của chúng ta trên trình duyệt, chúng ta phải 'viết' nội dung của nó ra. Có khá nhiều cách để đưa nội dung vào trang web như dùng hàm document.write(), bổ xung các DOM node bằng appendChild(), hay qua thuộc tính innerHTML, ... Chúng ta sẽ dùng cách thao tác với DOM node, bởi chúng khá giống với các node trong menu của chúng ta, đồng thời nó cũng tránh được các lỗi hiển thị khi sử dụng các phương thức kia (vì viết code HTML trực tiếp sẽ làm rối và dễ có lỗi cú pháp cũng như khó sửa chữa).
Để sinh ra các DOM node tương ứng với các phần tử trong 1 menu, chúng ta dùng hàm render() như sau:
HTML Code:
// Create DOM node for all menu elements
menuTree.prototype.render = function() {
this.html = document.createElement('div');
this.html.className = 'menuElement';
// Create node for body
this.body = document.createElement('div');
this.body.className = 'menuBody';
this.body.appendChild(document.createTextNode(this.desc));
// Append header and body
this.html.appendChild(this.header);
this.html.appendChild(this.body);
// If it has submenus, create DOM node for each item recusively
if (!this.isMenu()) {
return;
}
for (var i = 0; i < this.list.length; i++) {
this.list[i].render();
this.body.appendChild(this.list[i].html);
}
}
Ở đây chúng ta bổ xung các thuộc tính của 1 phần tử menu là html (dùng để chứa toàn bộ menu), header (chứa tiêu đề name của menu), body (chứa phần mô tả desc của menu và các phần tử con nếu có).
2 dòng đầu tiên chúng ta sinh ra DOM node div tương ứng với html, và gán cho nó thuộc tính lớp CSS là menuElement.
Đoạn tiếp theo chúng ta sinh ra DOM node a tương ứng với header. Đây chính là tên của menu khi hiển thị trên trình duyệt. Sở dĩ ta không dùng div như trước là vì phần header của menu có thể click được, dùng a là tiện nhất. Tất nhiên bạn có thể dùng CSS để tùy biến, tuy nhiên có 1 vài vấn đề không tương thích giữa các trình duyệt như việc đổi màu sắc tiêu đề khi di chuột lên chẳng hạn (Firefox và IE không hoạt động, Opera thì hoạt động). Chúng ta gán cho nó thuộc tính href = javascript(0) để nó không nhảy đi đâu cả. Dòng code:
this.header.onclick = (this.isMenu()) ? showHide : clickHandler;
rất quan trọng, nó sẽ nhận biết sự kiện khi chúng ta click lên tiêu đề của menu và đưa ra các tác vụ tương ứng. Dễ thấy là nếu như phần tử đang thao tác là 1 menu (tức là có các phần tử con) thì công việc của chúng ta là đóng / mở menu đó, tức là hiển thị hay không hiển thị các phần tử con đó thông qua hàm showHide(). Còn nếu đây không phải là menu, tức là 1 điểm cuối, thì chúng ta truyền quyền kiểm soát cho hàm clickHandler(). Hàm showHide() là chung cho mọi menu nên chúng ta sẽ code cho nó, còn hàm clickHandler() thì tùy vào trường hợp mà bạn code cho nó thế nào:
HTML Code:
// Handler when click on menu items
function clickHandler() {
var obj = this.me;
// Do something here
}
Ở đây chúng ta dùng hàm isMenu() để kiểm tra xem phần tử hiện thời có phải là menu không, đoạn code để kiểm tra rất đơn giản như sau:
HTML Code:
menuTree.prototype.isMenu = function() {
return (this.list) ? true : false;
}
Sau khi đã gán các sự kiện tương ứng cho phần tiêu đề, chúng ta chỉ đơn giản gán tên cho nó thông qua câu lệnh:
this.header.appendChild(document.createTextNode(th is.name));
Ở đoạn code tạo header này còn có câu lệnh:
this.header.me = this;
Lệnh này chúng ta sử dụng để tham chiếu ngược đến đối tượng hiện thời (bạn sẽ thấy nó hữu dụng ở hàm showHide() phía sau).
Đoạn tiếp theo là tạo ra 1 DOM node div tương ứng với body, phần thân của menu. Chúng ta cũng gán cho nó thuộc tính lớp CSS là menuBody và bổ xung phần desc vào đó.
Như vậy ta đã cơ bản tạo các DOM node cho 1 phần tử của menu. Nhưng còn các phần tử con (nếu có) thì sao? Đó là điều mà đoạn code tiếp theo đã làm: HTML Code:
// If it has submenus, create DOM node for each item recusively
if (!this.isMenu()) {
return;
}
for (var i = 0; i < this.list.length; i++) {
this.list[i].render();
this.body.appendChild(this.list[i].html);
}
Cũng dễ hiểu thôi phải không? Chúng ta dùng đệ quy để cho các phần tử con tự sinh ra các DOM node tương ứng, sau đó bổ xung nó vào phần body của phần tử hiện thời thông qua hàm appendChild().
Hàm render() này nên được chạy ngay khi khởi tạo đối tượng, vì thế chúng ta đưa nó vào thân của hàm tạo, chúng ta có hàm tạo cuối cùng như sau:
HTML Code:
// Create DOM node for all menu elements
menuTree.prototype.render = function() {
this.html = document.createElement('div');
this.html.className = 'menuElement';
// Create node for body
this.body = document.createElement('div');
this.body.className = 'menuBody';
this.body.appendChild(document.createTextNode(this.desc));
// Append header and body
this.html.appendChild(this.header);
this.html.appendChild(this.body);
// If it has submenus, create DOM node for each item recusively
if (!this.isMenu()) {
return;
}
for (var i = 0; i < this.list.length; i++) {
this.list[i].render();
this.body.appendChild(this.list[i].html);
}
}
Bây giờ chúng ta sẽ xây dựng hàm showHide(), dùng để ẩn / hiện menu khi chúng ta click lên tên của nó. Dạng chung của hàm này như sau:
HTML Code:
// Toogle menu on/off
function showHide() {
var obj = this.me;
if (!obj.isMenu()) {
return;
}
if (obj.isOpened()) {
obj.hideBody();
} else {
obj.showBody();
}
}
Ở đây chúng ta dùng lại tham chiếu obj = this.me; mà ta đã gán ở phần tạo header. Tại sao phải dùng tham chiếu ngược như thế này mà không dùng ngay đối tượng this? Nguyên nhân là vì khi bạn nhấn lên tiêu đề của menu, thì đối tượng this được gửi tới hàm sẽ là phần header chứ không phải là đối tượng menu tổng thể (bao gồm cả header, body, ...). Nói cách khác là đối tượng được gửi tới (context object) không phải là đối tượng chúng ta cần thao tác. Vì vậy để lấy đối tượng cần thao tác, chúng ta dùng tham chiếu ngược obj = this.me;.
Đoạn lệnh sau cũng đơn giản: nếu phần tử đó không phải là menu thì thoát ra, nếu là menu thì kiểm tra xem nó mở hay không để đóng / mở nó tương ứng. Các hàm isOpened(), hideBody(), showBody() được viết như sau:
HTML Code:
menuTree.prototype.isOpened = function() {
return (this.body.style.display == '');
}
menuTree.prototype.hideBody = function() {
this.body.style.display = 'none';
this.header.className = 'menuHeaderClosed';
}
menuTree.prototype.showBody = function() {
this.body.style.display = '';
this.header.className = 'menuHeaderOpened';
}
Ở đây ta dùng thuộc tính CSS display để quyết định menu có được hiển thị hay không ('' là có hiển thị, 'none' là không hiển thị). Ngoài ra ta cũng gán thuộc tính lớp CSS cho phần header (bạn có thể thấy trên hình VD ở đầu là khi menu đóng thì có biểu tượng thư mục đóng, menu mở tương ứng với biểu tượng thư mục mở).
Như vậy về cơ bản, menu của chúng ta đã xây dựng xong. Bây giờ chúng ta xây dựng hàm để hiển thị menu đó ra trình duyệt. Để hiển thị menu, chúng ta cần 1 vị trí được định danh id="..." để hiển thị ở đó. Ngoài ra, ta cũng xây dựng hàm cho phép menu ở trạng thái đóng hoặc mở khi hiển thị. Hàm của chúng ta có dạng sau:
HTML Code:
// Show menu at place indentified by containerID
// showAll is defaulted by false
menuTree.prototype.show = function(containerID, showAll) {
this.prepare(showAll);
var container = $(containerID);
container.innerHTML = '';
container.appendChild(this.html);
}
Trong đó hàm prepare() chúng ta dùng để chuẩn bị menu trước khi hiển thị (như việc đóng / mở chẳng hạn, chúng ta tách ra riêng biệt để dễ tùy biến). Hàm $() dùng để tham chiếu đến vị trí được định danh bởi containerID. Hàm này viết đơn giản như sau:
HTML Code:
// Reference to DOM node
function $(id) {
return document.getElementById(id);
}
Ngoài ra, chúng ta 'làm sạch' chỗ sẽ hiển thị menu bằng lệnh container.innerHTML = ''; (bạn có thể không muốn cũng được nếu như muốn bổ xung menu vào 1 phần có sẵn nào đó). Sau đó chúng ta thêm menu vào phần đó bằng lệnh container.appendChild(this.html); là xong.
Bây giờ chúng ta sẽ xây dựng hàm prepare(), nó có dạng như sau:
HTML Code:
// Prepare menu before show it
// showAll is defaulted by false
menuTree.prototype.prepare = function(showAll) {
if (!this.isMenu()) {
this.header.className = 'itemHeader';
return;
}
showAll = (typeof(showAll) == 'undefined') ? false : true;
this.header.className = (showAll) ? 'menuHeaderOpened' : 'menuHeaderClosed';
this.body.style.display = (showAll) ? '' : 'none';
for (var i = 0; i < this.list.length; i++) {
this.list[i].prepare();
}
}
Bạn có thể thấy là chúng ta sẽ gán thuộc tính lớp CSS cho phần tiêu đề ở đây, tương ứng phần tử đó là 1 điểm cuối (itemHeader), hay là menu đang mở (menuHeaderOpened) hoặc đóng (menuHeaderClosed). Sau đó tương ứng với tham biến truyền vào showAll mà chúng ta cho phần body hiển thị hay không qua thuộc tính CSS display. Ở đây mặc định showAll là false, tức là menu sẽ đóng. Cuối cùng là việc duyệt qua các phần tử con trong danh sách của menu (nếu có) và gọi đệ quy để chúng tự 'chuẩn bị' cho mình.
Việc xây dựng menu dạng cây phân cấp như vậy đã hoàn tất. Để tạo 1 menu cây phân cấp dạng này, bạn chỉ đơn thuần thực hiện các bước sau:
- Tạo phần dữ liệu cho biến menuData (hoặc tên khác, tùy ý), nhớ là viết theo quy ước JSON:
var menuData = {}
- Tạo 1 đối tượng menuTree dựa trên dữ liệu của menuData như sau:
var myMenu = new menuTree(menuData);
- Và hiển thị nó ở nơi cần thiết:
myMenu.show('menu'); // menu là id của nới được hiển thị
5. Tùy biến CSS
Nhưng như vậy chưa đủ, bởi menu của bạn vẫn chưa có 'hình dáng', chúng chỉ đơn thuần là text mà thôi. Nguyên nhân là vì chúng ta chưa thiết kế các lớp CSS cho từng phần của menu. Hãy chú ý tên các lớp mà ta đã tạo cho từng phần của menu, khi gom lại, ta có 1 nhóm các lớp CSS cần xử lí như sau:
HTML Code:
.menuElement {
}
.menuElement a {
}
.menuElement a:hover {
}
.menuHeaderClosed {
}
.menuHeaderOpened {
}
.itemHeader {
}
.menuBody {
}
Ở phần này, bạn có thể tùy biến CSS 1 cách tối đa để tạo ra 1 menu độc đáo và đẹp, VD như với menu dạng thư mục ở đầu tiên, mình dùng CSS như sau:
HTML Code:
.menuElement {
margin-left: 10px;
display: block;
}
.menuElement a {
text-decoration: none;
}
.menuElement a:hover {
font-weight: bold;
}
.menuHeaderClosed {
background: url(images/folderClosed.gif) no-repeat;
padding: 2px 0 2px 20px;
background-position: 0 0;
line-height: 18px;
}
.menuHeaderOpened {
background: url(images/folderOpened.gif) no-repeat;
padding: 2px 0 2px 20px;
background-position: 0 0;
line-height: 18px;
}
.itemHeader {
background: url(images/file.gif) no-repeat;
padding: 2px 0 2px 20px;
background-position: 0 0;
line-height: 18px;
}
.menuBody {
}
Ngoài dạng thư mục này ra, bạn có thể tạo ra nhiều dạng menu khác, chủ yếu là dựa vào các hình mà bạn có. Dưới đây là 1 vài loại khác mà mình tạo thêm, các bạn tham khảo:
Các file CSS tương ứng cho từng loại, cùng với hình ảnh và code của file Javascript các bạn có thể download tại đây. Muốn dùng loại menu nào, bạn hãy thay đổi tên file CSS tương ứng. Trong đó cũng có sẵn file demo, bạn có thể chỉnh sửa lại để thành menu của mình.
Demo các bạn có thể xem tại trang Tin tức tổng hợp
6. Lời kết
Bài viết này cố gắng trình bày cho các bạn cách làm menu dạng cây phân cấp, loại menu rất hay được sử dụng trong các trang web để tăng tính tướng tác của người dùng. Vì bài viết được viết vội vàng nên không khỏi những thiếu sót, rất mong nhận được sự góp ý của các bạn để phần code và bài viết được tốt hơn. Xin cảm ơn.