Tái cấu trúc một đoạn mã có sẵn (Phần 1)

Sau đây là những chia sẻ kinh nghiệm từ tôi về một số phương pháp giúp tái cấu trúc đoạn mã của bạn. Bài viết này sẽ không đi trình bày về cấu trúc ngôn ngữ, điều kiện, vòng lặp..., nhưng hy vọng bài viết sẽ cung cấp cho bạn một cái nhìn sâu sắc như làm thế nào để đoạn mã của bạn có thể dễ đọc, quan trọng hơn là đoạn mã đó sẽ dễ dàng tái sử dụng và bảo trì.

Refactoring

Trước khi tiếp tục chủ đề, tôi nghĩ rằng điều quan trọng là phải làm rõ chính xác refactoring là gì? Có rất nhiều định nghĩa về refactoring, nhưng định nghĩa mà tôi thích nhất đó là định nghĩa của Martin Fowler trong cuốn sách refactoring (xin phép được trích dẫn nguyên bản định nghĩa bằng tiếng anh để tránh mọi sự nhầm lẫn trong khi truyền tải nội dung):

Refactoring is a controlled technique for improving the design of an existing code base. Its essence is applying a series of small behaviour-preserving transformations, each of which too small to be worth doing

Chúng ta áp dụng những thay đổi nhỏ cho đoạn mã của mình, và làm như vậy nhiều lần cho tới khi đoạn mã đó trở nên dễ đọc và dễ dàng tái sử dụng. Nhưng chú ý rằng, những thay đổi đó sẽ không được làm thay đổi hành vi của đoạn mã, nó chỉ đơn thuần là cải tiến mã nguồn.

Chính vì chúng ta chỉ cải tiến mã nguồn chứ không thay đổi hành vi của đoạn mã, vậy nên sau mỗi công đoạn tái cấu trúc thì chúng ta luôn phải trải qua một bước là kiểm tra và chắc chắn rằng hành vi của đoạn mã không hề bị thay đổi.

1. Hello World ! JavaScript

Một trong những vấn đề cơ bản khi một lập trình viên học và sử dụng jQuery, đó là cố gắng xây dựng một trang HTML với cấu trúc tabs. Nó giống như việc bắt đầu một ngôn ngữ, framework mới với chương trình Hello World, nó sẽ là một ví dụ tốt để chúng ta bắt đầu tái cấu trúc đoạn mã.

Sau đây là một đoạn javascript với chức năng tạo tabs HTML như chúng ta mong đợi:

var tabularize = function() {
var active = location.hash;
if(active) {
    $(".tabs").children("div").hide();
    $(active).show();
    $(".active").removeClass("active");
    $(".tab-link").each(function() {
        if($(this).attr("href") === active) {
            $(this).parent().addClass("active");
        }
    });
}
$(".tabs").find(".tab-link").click(function() {
    $(".tabs").children("div").hide();
    $($(this).attr("href")).show();
    $(".active").removeClass("active");
    $(this).parent().addClass("active");
    return false;
});
};

Đoạn mã trên có trường hợp được đề cập tới, trường hợp thứ nhất là khi người dùng truy cập vào địa chỉ example.com/#tab2 thì tab2 sẽ được kích hoạt khi họ tải trang. Trường hợp thứ hai là khi người dùng nhấp chuột vào một tab thì ngay lập tức tab đó sẽ được kích hoạt.

Đây là đoạn mã HTML để có thể chạy chức năng trên:

<div class="tabs">
  <ul>
    <li class="active"><a href="#tab1" class="tab-link">Tab 1</a></li>
    <li><a href="#tab2" class="tab-link">Tab 2</a></li>
    <li><a href="#tab3" class="tab-link">Tab 3</a></li>
  </ul>
  <div id="tab1">
    <h3>Tab 1</h3>
    <p>Lorem ipsum dolor sit amet</p>
  </div>
  <div id="tab2">
    <h3>Tab 2</h3>
    <p>Lorem ipsum dolor sit amet</p>
  </div>
  <div id="tab3">
    <h3>Tab 3</h3>
    <p>Lorem ipsum dolor sit amet</p>
  </div>
</div>
<script>
  $(tabularize);
</script>

Đoạn JavaScript trên không phải là một đoạn mã thực sự tốt nhưng nó cũng không phải một đoạn mã xấu. Nó vẫn thực hiện đúng chức năng mà chúng ta mong muốn. Và mục tiêu của chúng ra hiện giờ là đi cấu trúc lại đoạn mã trên chứ không phải thay đổi hành vi của nó.

Tái sử dụng các selector

Chìa khóa đầu tiên để chúng ta có thể tái cấu trúc một đoạn mã, đó chính là tái sử dụng các selector. Cùng nhìn lại đoạn mã JavaScript một lần nữa, chúng ta có thể thấy những đoạn selector sau được sử dụng nhiều lần:

  • $(".tabs")
  • $(".tabs").children("div")
  • $(".tab-link")

Chúng ta bắt đầu thay đổi, lưu ý bạn phải thay đổi mọi thứ cùng một thời điểm, và luôn luôn kiểm tra lại hành vi của đoạn mã sau khi đã thay đổi toàn bộ.

Lúc này, đoạn mã của chúng ta sẽ trông như thế này:

var tabsWrapper = $(".tabs");
var tabs = tabsWrapper.children("div");
var tabLinks = tabsWrapper.find(".tab-link");
var active = location.hash;
if(active) {
  tabs.hide();
  $(active).show();
  $(".active").removeClass("active");
  tabLinks.each(function() {
    if($(this).attr("href") === active) {
      $(this).parent().addClass("active");
    }
  });
}
tabLinks.click(function() {
  tabs.hide();
  $($(this).attr("href")).show();
  $(".active").removeClass("active");
  $(this).parent().addClass("active");
  return false;
});

Và bây giờ, chỉ với một chút thay đổi nhỏ, đoạn mã của chúng ta đã được cải thiện một cách đáng kể. Chúng ta đã loại bỏ những đọan mã trùng lặp, theo tôi thì ở đây chúng ta đã có hai cải tiến lớn:

  • Nếu bất kì một selector nào được thay đổi, hoặc thay đổi mã HTML, thì chúng ta cũng chỉ phải thay đổi đoạn mã ở một nơi.
  • Đoạn mã trông có vẻ dễ hiểu hơn trước, bạn không cần viết ra nó nhưng bạn có thể đọc lướt qua và nắm được chức năng của đoạn mã đó là gì.

Tái sử dụng hành động

Nếu bạn xem xét lại đoạn mã, bạn có thể thấy chuỗi "active" được sử dụng lại khá nhiều lần. Bạn hãy làm điều tương tự với chuỗi này giống như khi chúng ta thay đổi các selector. Nhưng điều đó sẽ hữu ích khi chuỗi được tái sử dụng trên hai lần. Còn trong trường hợp nó chỉ được gọi một lần trong đoạn mã, việc thay đổi là không cần thiết.

Chúng ta sẽ khởi tạo một biến activeClass như sau:

var tabsWrapper = $(".tabs");
var tabs = tabsWrapper.children("div");
var tabLinks = tabsWrapper.find(".tab-link");
var activeClass = "active";

var active = location.hash;
if(active) {
  tabs.hide();
  $(active).show();
  $("." + activeClass).removeClass(activeClass);
  tabLinks.each(function() {
    if($(this).attr("href") === active) {
      $(this).parent().addClass(activeClass);
    }
  });
}
tabLinks.click(function() {
  tabs.hide();
  $($(this).attr("href")).show();
  $("." + activeClass).removeClass(activeClass);
  $(this).parent().addClass(activeClass);
  return false;
});

Chúng ta vừa tiến một bước mới trong việc tái tổ chức lại đoạn mã. Nhưng có vẻ vẫn còn một số khối mã bị lặp lại. Bạn có thể so sánh hai khối mã này:

$("." + activeClass).removeClass(activeClass);
tabLinks.each(function() {
  if($(this).attr("href") === active) {
    $(this).parent().addClass(activeClass);
  }
});
$("." + activeClass).removeClass(activeClass);
$(this).parent().addClass(activeClass);

Nhìn qua có vẻ hai khối mã là khác nhau vì khối mã trên có lặp qua một vòng các phần tử. Song cả hai khối mã đều thực hiện một khối công việc như nhau:

  • Tìm kiếm phần tử có lớp "active" và xóa class đó.
  • Thêm class "active" cho phần tử cha của phần tử hiện tại.

Khi có nhiều hơn một khối mã thực hiện cùng một công việc, thì chúng ta nên nhóm chức năng đó lại. Chúng ta sẽ thực hiện nó như sau:

var activateLink = function(elem) {
  $("." + activeClass).removeClass(activeClass);
  $(elem).addClass(activeClass);
};

Ở đây, hàm activateLink sẽ đóng vai trò nhận tham số là một phần tử và thêm lớp "active" cho phần tử đó. Đồng thời xóa bỏ các phần tử khác có chứa lớp "active". Và công việc của chúng ta bây giờ là sử dụng hàm này áp dụng vào đoạn mã của chúng ta. Kết quả nhận được như sau:

var tabsWrapper = $(".tabs");
var tabs = tabsWrapper.children("div");
var tabLinks = tabsWrapper.find(".tab-link");
var activeClass = "active";
var activateLink = function(elem) {
  $("." + activeClass).removeClass(activeClass);
  $(elem).addClass(activeClass);
};

var active = location.hash;
if(active) {
  tabs.hide();
  $(active).show();
  tabLinks.each(function() {
    if($(this).attr("href") === active) {
      activateLink($(this).parent());
    }
  });
}
tabLinks.click(function() {
  tabs.hide();
  $($(this).attr("href")).show();
  activateLink($(this).parent());
  return false;
});

Cách trừu tượng hóa đoạn mã thành các hàm là cách dễ dàng nhất giúp chúng ta dễ dàng nâng cấp, bảo trì cũng như tái sử dụng đoạn mã. Và các bạn nên chú ý khi viết các hàm, đầu tiên về cách đặt tên, tên hàm phải nói lên được mục đích hàm đó viết ra để làm gì. Điều này giúp những nhà phát triển khác có thể hiểu nhanh chóng được ý nghĩa của hàm bạn viết. Ngòai ra khi các bạn viết một hàm, các bạn nên suy nghĩ tới hàm đó có thể được sử dụng ở trong những trường hợp nào, sao cho chúng ta có thể sử dụng hàm đó ở càng nhiều nơi càng tốt. Cuối cùng thì hàm đó càng đơn giản càng tốt. (ok)

Từ một đoạn mã ban đầu, chúng ta đã có những thay đổi nhỏ, loại bỏ những đoạn mã trùng lặp, xây dựng hàm và tái sử dụng nó, và đoạn mã bây giờ đã được cải thiện rất nhiều. Nhưng nếu đây là một dự án thực tế, và đột nhiên bạn được chuyển sang một dự án khác để xây dựng một chức năng y hệt. Khi đó bạn phải chắc chắn rằng đoạn mã trước đó của bạn sẽ được cải thiện và sử dụng trong dự án mới. Và bạn sẽ không thấy mình bị luẩn quẩn trong mớ logic của chức năng đó. Chìa khóa quan trọng giúp việc tái cấu trúc một đoạn mã thành công, đó là luôn giữ chúng nhỏ và kín.

Mức độ trùng lặp cao hơn

Khi nhìn lại đoạn mã, mặc dù chúng đã được cải thiện rất nhiều nhưng vẫn còn một số chức năng bị trùng lặp mà chúng ta có thể cải thiện chúng tốt hơn. Tôi sẽ không đi vào giải thích tường tận về sự trùng lặp này nữa. Hãy nhìn vào đoạn mã sau đây khi chúng đã được tái cấu trúc một lần nữa, bạn hãy so sánh và tìm ra sự trùng lặp:

var tabsWrapper = $(".tabs");
var tabs = tabsWrapper.children("div");
var tabLinks = tabsWrapper.find(".tab-link");
var activeClass = "active";

var activateLink = function(elem) {
  $("." + activeClass).removeClass(activeClass);
  $(elem).addClass(activeClass);
};

var activateTab = function(tabSelector) {
  tabs.hide();
  $(tabSelector).show();
};

var active = location.hash;
if(active) {
  activateTab(active);
  tabLinks.each(function() {
    if($(this).attr("href") === active) {
      activateLink($(this).parent());
    }
  });
}
tabLinks.click(function() {
  activateTab($(this).attr("href"));
  activateLink($(this).parent());
  return false;
});

Chúng ta đã tạo ra một hàm activateTab để thực hiện hai nhiệm vụ là ẩn và hiện các tabs. Nhưng đến đây thì chúng ta lại đối mặt với việc trùng lặp mã khi cả hai khối mã đều thực hiện hai hàm là activateTab và activateLink. Câu hỏi đặt ra là làm thế nào để viết một hàm chung cho hai nhiệm vụ này vì hiện tại ở khối mã thứ nhất, việc gọi hàm activateLink đang được thực hiện thông qua việc duyệt qua các phần tử và kiểm tra điều kiện. Vậy nên trước khi chúng ta có thể xử lý sự trùng lặp này, hãy tìm cách đưa đoạn mã về dạng thích hợp hơn để có thể dễ dàng xử lý sự trùng lặp đó.

Việc tìm ra cách xử lý cho trường hợp nay đôi khi sẽ đưa bạn vào một vòng luẩn quẩn và có thể bạn cảm thấy bế tắc. Đến đây thì kỹ năng tìm kiếm, hoặc có thể là kỹ năng giao tiếp bằng cách hỏi những nguời có kinh nghiệm có lẽ sẽ giúp bạn. (like)

Tạm phân tích một chút, vấn đề chúng ta hiện gặp phải là gì? Đó là làm thế nào để có thể sử dụng hàm activateLink bên ngoài vòng lặp each. Đến đây thì chúng ta có thể sử dụng method filter của jQuery. Cụ thể cách làm như sau:

var tabsWrapper = $(".tabs");
var tabs = tabsWrapper.children("div");
var tabLinks = tabsWrapper.find(".tab-link");
var activeClass = "active";

var activateLink = function(elem) {
  $("." + activeClass).removeClass(activeClass);
  $(elem).addClass(activeClass);
};

var activateTab = function(tabSelector) {
  tabs.hide();
  $(tabSelector).show();
};

var active = location.hash;
if(active) {
  activateTab(active);
  var link = tabLinks.filter(function() {
    return $(this).attr("href") === active;
  }).parent();
  activateLink(link);
}
tabLinks.click(function() {
  activateTab($(this).attr("href"));
  activateLink($(this).parent());
  return false;
});

Tới đây hãy xem xét thật cẩn thận về hai hàm activateTab và activateLink. Lý tưởng nhất đó là chúng ta sẽ đóng gói hai hàm này vào một hàm khác, để làm được điều này thì chúng ta cần phải thay đổi tham số của hai hàm. Nếu bạn chú ý cẩn thận, bạn có thể thấy mối quan hệ giữa tham số của hai hàm. Điều này cho phép bạn có thể viết lại mã cho hàm activateLink như sau:

var activateLink = function(selector) {
  $("." + activeClass).removeClass(activeClass);
  var elem = tabLinks.filter(function() {
    return $(this).attr("href") === selector;
  }).parent();
  $(elem).addClass(activeClass);
};

Với thay đổi đó, bạn có thể viết lại mã cho hai trường hợp:

if(active) {
  activateTab(active);
  activateLink(active);
}
tabLinks.click(function() {
  activateTab($(this).attr("href"));
  activateLink($(this).attr("href"));
  return false;
});

Việc thay đổi để các đoạn mã không bị trùng lặp giờ đây đã trở nên đơn giản hơn rất nhiều. Đoạn mã giờ đây trông đơn giản và dễ hiểu hơn rất nhiều:

var tabsWrapper = $(".tabs");
var tabs = tabsWrapper.children("div");
var tabLinks = tabsWrapper.find(".tab-link");
var activeClass = "active";

var activateLink = function(selector) {
  $("." + activeClass).removeClass(activeClass);
  var elem = tabLinks.filter(function() {
    return $(this).attr("href") === selector;
  }).parent();
  $(elem).addClass(activeClass);
};

var activateTab = function(tabSelector) {
  tabs.hide();
  $(tabSelector).show();
};

var transition = function(selector) {
  activateTab(selector);
  activateLink(selector);
};

var active = location.hash;
if(active) {
  transition(active);
}
tabLinks.click(function() {
  transition($(this).attr("href"));
  return false;
});

Hãy so sánh với đoạn mã ban đầu, thực sự chỉ với những thay đổi nhỏ, chúng ta đã có một kết qủa mong đợi. (like)

Bài viết này chủ yếu đi vào tìm hiểu những phương pháp cơ bản để tái cấu trúc một đoạn mã có sẵn. Ở những bài viết sau, tôi sẽ cùng bạn tìm hiểu về những kỹ năng cao hơn. Cảm ơn bạn đã theo dõi bài viết. (thanks)

Tài liệu tham khảo

https://robots.thoughtbot.com/sandi-metz-rules-for-developers

http://refactoring.com/

https://sourcemaking.com/refactoring

http://javascriptplayground.com/the-refactoring-tales/refactoring-tales.html