以動態網頁方式排序網頁的表格內容
前一陣子根據老闆要求,寫了一個重點商品銷售統計報表的程式,每天統計各門市到昨天為止的重點商品銷售統計,將結果輸出到網頁讓每個門市都可以瀏覽查看。這程式運作了一段時間後,老闆覺得還不錯,就要我改成暢銷商品銷售統計,納入上千項商品為暢銷商品。因為上千項商品的報表很長,老闆就要我加上排序功能。
要排序當然OK啊。只是我很懶,不想為了視覺效果去修改統計程式,更不想為了更新排序結果而要伺服端再回傳一次頁面。於是我打算以動態網頁的方式,直接用 JavaScript 對網頁上的統計表格排序。
首先,我們先看一份示範用的統計表格,如下所示。點擊表格標題即可依此欄位內容排序。
XX銷售報表
項次 | 條碼 | 商品名稱 | 銷售量 | 銷售額 |
1 | 4203803 | 輕鬆錠 | ||
2 | 4203810 | 骨錠 | 10 | 2800 |
3 | 4203811 | 玻尿酸膠囊 | 5 | 2500 |
4 | 4203812 | 膠原葡萄籽錠 | 4 | 1200 |
5 | 4203817 | 葉黃素膠囊 | 9 | 891 |
6 | 4203818 | 綜合維他命 | 12 | 6000 |
7 | 4203865 | 異黃酮 | 8 | 1640 |
8 | 4203866 | 甲殼素膠囊 | 3 | 450 |
9 | 4203868 | 柚兒茶素 | 3 | 210 |
10 | 4203876 | 魚油 | 11 | 990 |
合計 |
設計概要
一開始,這是一張很單純的統計表格,標題只是文字而不是連結。顯示的統計結果是伺服端統計程式按項次排列之順序。現在我要加上排序功能,而且我不想要發生向伺服器要求更新頁面的動作,亦即我要以動態網頁的方式直接變動頁面上的內容排列順序。
第一步要想的是,排序資料如何得手?所幸網頁上的報表內容係基於「樣式與內容互離」之概念產生,因此報表中的內容就是純粹的資料,沒有夾雜其他樣式文字。故僅需取得報表中每一格的內容便可進行排序。排序程式僅需知道欄位名稱(或欄號),就可以直接從網頁上的報表中取得資料加以排序。
第二步則要思考如何依排序結果更新報表的每列順序。所幸網頁上的報表內容係基於良好的 HTML 格式嚴謹地產生,如下所示:
<table>
<thead>
<tr>
<td>
</td>
</tr>
</thead>
<tbody>
<tr>
<td>
</td>
<td>
</td>
</tr>
</tbody>
</table>
由於其文件結構良好,只需要透過 HTML DOM 取得 tr
元素陣列,再重新安插其 DOM 節點的位置即可。
連續兩個「所幸」並非偶然,因為我早知遵循這兩個原則的好處,一開始就決定如此輸出資料。不了解為何要將「樣式與內容分離」者,往往在報表內容中夾雜樣式文字,例如: <td><span style="color:red">-123</span></td>
。一但這麼寫, JavaScript 就不能簡便地自報表中取得排序資料,要多做許多節點存取動作。不了解為何要按 HTML 的良好格式輸出者,欄位標題列、合計列、分項列等等內容就會混在一起。如此就必須多出判斷更新範圍的動作,而不能簡便地更新局部內容。
實踐概念
在實作過程中,我先以程序導向的方式設計,定義一個變數 sorter ,將所有用得到的變數、函數都塞進裡面。我需要一組設定值告知第幾欄是什麼資料,如此一來僅需傳一個代表資料欄位的字串, JavaScript 就知道該從第幾欄位取得排序資料。我將這組設定值定義於陣列 sortConfig 。
接著定義喚起排序動作的起始函數 sortBy ,只需要傳遞欄位名稱。遞減的排序函數 funcSort_desc、遞增的排序函數 funcSort_asc、自 DOM 中取得排序資料與資料列節點的函數 getTbody, getRows 。最後定義實際進行排序及更新頁面內容的函數 doSort 。下列為程式碼:
var sorter = {
"currentType" : "item",
"currentOrder" : "asc",
"sortConfig" : {
"item": {
"columnIndex" : 0
},
"ean": {
"columnIndex" : 1
},
"pname": {
"columnIndex" : 2
},
"qty": {
"columnIndex" : 3
},
"amt": {
"columnIndex" : 4
}
},
"sortBy" : function(t) {
if (this.currentType == t) {
this.currentOrder = (this.currentOrder == "asc"
? "desc"
: "asc"
);
}
else {
this.currentType = t;
}
var keyCellIndex = this.sortConfig[this.currentType].columnIndex;
var funcSort = this["funcSort_" + this.currentOrder];
this.doSort(keyCellIndex, funcSort);
},
"funcSort_desc" : function(a,b) {
return a.key - b.key;
},
"funcSort_asc" : function(a,b) {
return b.key - a.key;
},
"getRows" : function() {
var tbody = this.getTbody();
var rows = tbody.getElementsByTagName('tr');
return rows;
},
"getTbody" : function() {
return document.getElementsByTagName('table')[0].getElementsByTagName('tbody')[0];
},
"doSort" : function(keyCellIndex, sortFunc) {
var rows = this.getRows();
var keys = [];
for (var i = 0, cell, row = rows[0]; row; row = rows[++i]) {
cell = row.getElementsByTagName('td')[keyCellIndex].firstChild.nodeValue;
keys[i] = {
"key": cell,
"row": row
}
}
keys.sort(sortFunc);
var tbody = this.getTbody();
for (var i = 0, r = keys[i]; r; r = keys[++i]) {
tbody.appendChild(r.row);
}
}
}
底下是報表的原始內容 (簡化版) 。報表是由伺服端統計程式產生,所以修改伺服端統計程式的輸出動作,加上一行載入 JavaScript 的動作,並修改標題欄位為呼叫 JavaScript 排序動作的連結。
<script type="text/javascript" src="sorter.js"></script>
<h3>XX銷售報表</h3>
<table border="1" cellspacing="0">
<thead>
<tr>
<td class="item"><a href="javascript:sorter.sortBy('item');">項次</a></td>
<td class="ean"><a href="javascript:sorter.sortBy('ean');">條碼</a></td>
<td class="pname"><a href="javascript:sorter.sortBy('pname');">商品名稱</a></td>
<td class="qty"><a href="javascript:sorter.sortBy('qty');">銷售量</a></td>
<td class="amt"><a href="javascript:sorter.sortBy('amt');">銷售額</a></td>
</tr>
</thead>
<tbody id="reportTbody">
<tr>
<td>1</td>
<td>4203803</td>
<td>輕鬆錠</td>
<td> </td>
<td> </td>
</tr>
</tbody>
</table>
Refactoring
透過上述快速的實作過程,我已經驗證了程式碼可行性。最後免不了要重整一下,最好是把上面的程式碼重整成一個可再用的 class 。重整重點有二:一、將原本的變數改寫成可生成新個體的函數型態,即 JavaScript 的類別。二、設定值與排序資料區域可作為引數傳遞。下列為重整後的程式碼,重整過程非常簡單而直覺,各位可以自行比較與思考。
function Sorter(config) {
var requiredArgs = ['table', 'currentType', 'currentOrder', 'sortConfig'];
for (var i = 0, k = requiredArgs[i]; k; k = requiredArgs[++i]) {
this[k] = config[k];
}
var funcSortMap = {
'desc': function(a,b) {
return a.key - b.key;
},
'asc': function(a,b) {
return b.key - a.key;
}
}
this.sortBy = function(t) {
if (this.currentType == t) {
this.currentOrder = (this.currentOrder == 'asc'
? 'desc'
: 'asc'
);
}
else {
this.currentType = t;
}
var keyCellIndex = this.sortConfig[this.currentType].columnIndex;
var funcSort = (this.sortConfig[this.currentType].funcSortMap
? this.sortConfig[this.currentType].funcSortMap
: funcSortMap
)[this.currentOrder];
doSort.apply(this, [keyCellIndex, funcSort]);
}
var tbody;
function getTbody() {
if ( !tbody ) {
//pass by id or DOM node?
tbody = (typeof this.table == 'string' //pass by id
? document.getElementById(this.table)
: this.table //pass by DOM node
);
}
return tbody;
}
function getRows() {
var tbody = getTbody.call(this);
var rows = tbody.getElementsByTagName('tr');
return rows;
}
function doSort(keyCellIndex, sortFunc) {
var rows = getRows.call(this);
var keys = [];
for (var i = 0, cell, row = rows[0]; row; row = rows[++i]) {
cell = row.getElementsByTagName('td')[keyCellIndex].firstChild.nodeValue;
keys[i] = {
'key': cell,
'row': row
}
}
keys.sort(sortFunc);
var tbody = getTbody.call(this);
for (var i = 0, r = keys[i]; r; r = keys[++i]) {
tbody.appendChild(r.row);
}
}
}
下列是使用案例,配置一個sorter,引數中指示排序資料區域的 ID 、預設排序型態、順序,以及各資料型態的欄位位置。 HTML 部份則不需改變。
var sorter = new Sorter({
"table" : "reportTbody",
"currentType" : "item",
"currentOrder" : "asc",
"sortConfig" : {
"item": {
"columnIndex" : 0
},
"ean": {
"columnIndex" : 1
},
"pname": {
"columnIndex" : 2,
"funcSortMap" : {
"desc": function(a,b) {
if (a.key == b.key) return 0;
else if (a.key > b.key) return -1;
else return 1;
},
"asc": function(a,b) {
if (a.key == b.key) return 0;
else if (a.key > b.key) return 1;
else return -1;
}
}
},
"qty": {
"columnIndex" : 3
},
"amt": {
"columnIndex" : 4
}
}
});
結語
整個修改動作很快,而且對原有程式的改動幅度意外地小。原有統計程式只改了 View 的部份,且只改了兩行。加上一行 <script type="text/javascript" src="sorter.js"></script>
,再將表格欄位標題的輸出內容改成連結。
現在有很多 JavaScript 的套件提供這些 GUI 視覺元件內容。不過像這種簡單的功能倒也不見得要用那些套件來做。更重要的是,不論用什麼套件,都應該遵循程式、資料、樣式等內容分離的原則,也不要配合套件來決定程式如何寫。本文能夠如此簡便地在最小改動幅度下實踐排序功能,便歸功於分離原則。
附帶一提,當排序資料很多時,瀏覽器會陷入不回應狀態。而底下的程式碼則是另一種版本的 DOM 節點更新程式碼。上述版本直接操作顯示中的節點,而下列版本則是先配置一個 tbody 節點,於背景排放資料列內容後,再替換整個 tbody 節點。理論上較快,但我實際使用時... 測不出效能差異 (IE, Firefox and Opera)。
function doSort(keyCellIndex, sortFunc) {
var rows = getRows.call(this);
var keys = [];
for (var i = 0, cell, row = rows[0]; row; row = rows[++i]) {
cell = row.getElementsByTagName('td')[keyCellIndex].firstChild.nodeValue;
keys[i] = {
'key': cell,
'row': row
}
}
keys.sort(sortFunc);
var tbody = getTbody.call(this);
for (var i = 0, fr, r = keys[i]; r; r = keys[++i]) {
tbody.appendChild(r.row);
}
var newTbody = document.createElement('tbody');
for (var i = 0, newRow, r = keys[i]; r; r = keys[++i]) {
newRow = r.row.cloneNode(true);
newTbody.appendChild(newRow);
}
var tbody = getTbody.call(this);
tbody.parentNode.replaceChild(newTbody, tbody);
}
樂多舊回應