從零開始使用JavaScript編寫資料表格控制元件(一)

民工精髓發表於2013-06-11

資料表格控制元件的基礎功能

資料表格是一個很常用的控制元件,用於把多列資料展示成表格的形狀,通常有表頭,表頭可固定,表格內容可滾動。本文以一個資料表格控制元件為例,說明從構思到實現控制元件的整個過程。

為了使初學者更容易理解其中的原理,我們不使用任何額外的庫,比如jQuery之類,僅僅使用bootstrap來控制樣式。

1. 功能分析

DataGrid控制元件主要有以下幾個功能: - 載入資料並展示成表格的形狀 - 新增一行 - 刪除一行 - 點選某行選中 - 修改行資料並重新整理

DataGrid控制元件主要需要響應這樣幾個事件: - 載入完成 - 選中行變更

2. 實現原理

想要實現DataGrid控制元件,我們有三個步驟要做:

  • 用什麼樣的DOM結構來展現
  • 用什麼樣的結構來定義資料
  • 資料跟DOM結構如何關聯起來

下面我們來考慮如何分別實現這三個步驟。

2.1. DOM結構

做一個控制元件之前,我們首先要把DOM結構確定下來,也就是用HTML能夠展現控制元件的形態。什麼結構適合展現資料表格呢?毫無疑問,是HTML中的table,語義上非常符合,為了省事,我們不考慮樣式,直接用bootstrap中的表格樣式。

<table class="table table-bordered table-striped">
    <thead>
        <tr>
            <th>#</th>
            <th>Name</th>
            <th>Gender</th>
            <th>Age</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>1</td>
            <td>Tom</td>
            <td>Male</td>
            <td>5</td>
        </tr>
        <tr>
            <td>2</td>
            <td>Jerry</td>
            <td>Female</td>
            <td>2</td>
        </tr>
        <tr>
            <td>3</td>
            <td>Sun Wukong</td>
            <td>Male</td>
            <td>1024</td>
        </tr>
    </tbody>
</table>

這個結構足夠表達DataGrid了,樣子也還可以,所以我們很滿意,開始考慮資料了。單使用這個結構,是難以做到表頭固定,表格體可滾動的,但為了簡單,我們先用這個結構來做。

2.2. 資料結構

在資料表格中,有兩個資料是需要傳入的,一是標題的列頭資料,二是表格的內容,它們都很適合用陣列來描述。

列頭最少需要描述這些內容: 標題 資料欄位

表格行需要有這些資訊: 每個key的值

所以,對照上面的表格,我們可以把資料描述起來:

var columns = [{
    label: "#",
    field: "index"
}, {
    label: "Name",
    field: "name"
}, {
    label: "Gender",
    field: "gender"
}, {
    label: "Age",
    field: "age"
}];

var data = [{
    index: 1,
    name: "Tom",
    gender: "Male",
    age: 5
}, {
    index: 2,
    name: "Jerry",
    gender: "Female",
    age: 2
}, {
    index: 3,
    name: "Sun Wukong",
    gender: "Male",
    age: 1024
}];

2.3. DOM和資料的關聯

這部分聽起來有些複雜,我先打個比方吧。

有一個勤勞的媽媽,她有三個寶寶,每個寶寶都有不少衣物,媽媽的職責是管理這些衣物,並且用它們來裝扮寶寶們。這些衣物可以分為上衣,褲子,鞋子,襪子,帽子等等。

想象一下她是怎麼做的: 把第一個寶寶抱過來,選幾件衣服,給他穿上,放他出去玩。 對第二個寶寶做同樣的操作。 對第三個寶寶做同樣的操作。

我們甚至還可以推而廣之,不管她有多少寶寶,都必定是按照這個方式做的。

那麼,對比我們的控制元件,每條資料都是一個寶寶,把資料渲染到DOM的過程就好比給寶寶穿衣服。我們的列資訊就好比衣物的分類。

我們有另外的問題: 寶寶們都出去玩了,我們問媽媽:最大的寶寶穿的是些什麼衣服?綠色有大嘴猴的那件T恤穿在哪個寶寶身上?

這就要求媽媽對衣服和寶寶的關聯關係有所記錄。拿我們這個控制元件來看,寶寶就相當於每行的資料,衣服相當於用來展示的DOM節點,在這兩者之間是需要一些關聯的。

3. 程式設計

3.1. 實體劃分

在我們這個控制元件中,存在兩個實體:資料表格,表格行。

資料表格的職責很清楚,表格行的存在又是為了什麼呢?我們完全可以把職責全部掌控在資料表格裡,不下放給行。

設想我們要取選中行的name屬性,我們的寫法是這樣的:

var row = grid.selectedRow();
var name = grid.get(row, "name");

希望這麼寫:

var row = grid.selectedRow();
var name = row.get("name");

甚至我們可以連著寫:

var name = grid.selectedRow().get("name");

這裡的問題是,selectedRow究竟要返回什麼結構,才能讓它有get方法?

這裡有兩種選擇,一種是返回行的DOM結構,get方法附加在行上,行的資料也作為屬性附加在DOM上,這樣我們是沒有單獨的表格行實體的。另一種是建立表格行的實體,在其中管理DOM和資料的關聯關係。這兩種方式,我們應當如何選擇呢?

把過多資料附加到DOM上並不是一個好的選擇,尤其我們不能確定使用者給控制元件傳哪些欄位,萬一跟DOM自身的衝突了,會很糟糕。所以我們選擇自己來管理這個關係。

3.2. 表格行的職責

確定了這樣的原則之後,我們來考慮表格行的職責。

表格行,它應當可以被選中,也可以被取消選中(這個操作不是通過點選選中狀態的自身來完成,而是點選其他行,由表格控制元件來取消自己的選中),可以讀寫資料。

我們考慮一下表格行的選中要幹些什麼。首先,如果已經有選中的行了,要把那個的樣式去掉,然後把選中的行指向當前的行,再把當前行的樣式變成選中的顏色。

這些職責,我們來考慮一下,哪些屬於表格,那些資料表格行。

表格行是否應當知道所在表格當前選中的行是誰?不應該,因為這跟你無關。你只要知道自己是不是被選中就行了,不要管閒事。所以,管理選中行這個事情應當給表格做,某行被點選了,他不該擅自作決定,比如先把自己顏色變掉之類的,而是應當先請示彙報:“老大,有人翻我牌子,你把我選起來吧。”

嚴格來說,小弟不該干涉老大的工作,比如老大這時候就應該扇他一巴掌:“撲街仔,翻你牌幾啦?不把老大放眼裡啦?機不機到德墨忒爾法則啦?”然後還是把他選中。問題出在哪裡呢?你多嘴了。你告訴老大,有人點我就可以了,你管老大後面幹什麼?那是他的事,雖然你知道老大要這麼幹,但你這個屬於知道得太多,該打。

好了,現在老大知道有人翻你牌子了,拿了個本子翻了翻,把今天的頭牌改成了你,然後分別對新老兩個頭牌大喝一聲:“浩南把你的表拿下來,山雞戴上!”看到沒有,小弟聽到老大指示之後才能改變外觀。

山雞去臺灣作出一番事業,從前人家叫他山雞,回來之後,有人還想這麼叫,浩南哥語重心長地糾正:“叫雞哥!”從此大家都叫他雞哥了。

綜上所述,表格行有這幾個職責:

  • 建立,做一些初始化的事情
  • 銷燬,主要是行唄刪除的時候把DOM和資料的引用去掉,這樣瀏覽器可以做記憶體回收
  • 選中,改變樣式為選中狀態,比如山雞戴上了三個表,從此成為了代表
  • 取消選中,改變樣式為非選中狀態,比如浩南把自己的表給了山雞,自己就不是代表了
  • 設定屬性,比如浩南把山雞的稱呼改成雞哥
  • 獲取屬性,比如別人看到山雞,打聽一下就知道他是雞哥

3.3. 表格的職責

在上面所有功能裡去掉表格行的職責,就得到了表格的職責

  • 載入列頭資料
  • 根據資料載入列表
  • 新增行
  • 刪除行
  • 選中行

3.4. 如何實現自定義事件

什麼是事件呢?本質上是一種非同步的機制,打個比方說,你委託我做飯,說,做完飯給你打個電話,你先出去玩了。為什麼是非同步呢,因為你不在這裡等我做完就走了,你也不關心我什麼時候做得完,反正做好告訴你就是了。你在我這裡監聽了做飯完成事件,我做完之後,把這個事件派發一下,派發到你了,我的職責就完成了。

這麼一看,我們的事情其實不復雜。我要對你提供什麼呢:

  • 新增事件的監聽,注意這裡可能不止一個,有可能多個人同時來等著吃飯。
  • 移除事件的監聽,這是為何?因為可能我沒做完,你先給我打了電話,說別人約你吃飯,你不需要知道我是否做完了。
  • 當事情做完,通知所有監聽方。

這些分析完,我們的程式碼就好寫了:

//事件派發機制的實現
var EventDispatcher = {
    addEventListener: function(eventType, handler) {
        //事件的儲存
        if (!this.eventMap) {
            this.eventMap = {};
        }

        //對每個事件,允許新增多個監聽
        if (!this.eventMap[eventType]) {
            this.eventMap[eventType] = [];
        }

        //把回撥函式放入事件的執行陣列
        this.eventMap[eventType].push(handler);
    },

    removeEventListener: function(eventType, handler) {
        for (var i=0; i<this.eventMap[eventType].length; i++) {
            if (this.eventMap[eventType][i] === handler) {
                this.eventMap[eventType].splice(i, 1);
                break;
            }
        }
    },

    dispatchEvent: function(event) {
        var eventType = event.type;
        for (var i=0; i<this.eventMap[eventType].length; i++) {
            //把對當前事件新增的處理函式拿出來挨個執行
            this.eventMap[eventType][i](event);
        }
    }
};

除此之外,我們還需要寫一個輔助方法,用於把事件機制附加到表格上:

//簡單的物件屬性複製,把源物件上的屬性複製到自己身上,只複製一層
Object.prototype.extend = function(base) {
    for (var key in base) {
        if (base.hasOwnProperty(key)) {
            this[key] = base[key];
        }
    }
    return this;
};

4. 上述功能的實現

我們把前面這兩段程式碼放置在一個util.js中,因為這些功能不僅僅在DataGrid中會用到,然後,再建立一個datagrid.js,內容如下:

//作為一個控制元件,它的容器必須傳入
function DataGrid(element) {
    this.columns = [];
    this.rows = [];

    element.innerHTML = '<table class="table table-bordered table-striped"><thead><tr></tr></thead><tbody></tbody><table>';

    this.header = element.firstChild.tHead;
    this.tbody = element.firstChild.tBodies[0];

    this.selectedRow = null;
}

DataGrid.prototype = {
    loadColumns: function (columns) {
        if (this.header.rows.length > 0) {
            this.header.removeChild(this.header.rows[0]);
        }
        var tr = this.header.insertRow(0);

        for (var i = 0; i < columns.length; i++) {
            var th = tr.insertCell(i);
            th.innerHTML = columns[i].label;
        }
        this.columns = columns;
    },

    loadData: function (data) {
        for (var i = 0; i < data.length; i++) {
            this.insertRow(data[i]);
        }

        //跟外面說一聲,資料載入好了
        var event = {
            type: "loadCompleted",
            target: this
        };
        this.dispatchEvent(event);
    },

    insertRow: function (data) {
        var row = new DataRow(data, this);
        this.tbody.appendChild(row.dom);

        this.rows.push(row);

        var that = this;
        row.addEventListener("selected", function (event) {
            that.select(event.row);
        });

        //已經成功新增了新行
        var event = {
            type: "rowInserted",
            newRow: row,
            target: this
        };
        this.dispatchEvent(event);
    },

    removeRow: function (row) {
        if (row === this.selectedRow) {
            this.selectedRow = null;
        }

        this.tbody.removeChild(row.dom);
        row.destroy();

        for (var i = 0; i < this.rows.length; i++) {
            if (this.rows[i] == row) {
                this.rows.splice(i, 1);
                break;
            }
        }

        //已經移除
        var event = {
            type: "rowRemoved",
            target: this
        };
        this.dispatchEvent(event);
    },

    select: function (row) {
        var event = {
            type: "changed",
            target: this,
            oldRow: this.selectedRow,
            newRow: row
        };

        if (this.selectedRow) {
            this.selectedRow.select(false);
        }

        if (row) {
            row.select(true);
        }

        this.selectedRow = row;

        this.dispatchEvent(event);
    }
}.extend(EventDispatcher);


function DataRow(data, grid) {
    this.data = data;
    this.grid = grid;

    this.create();
}

DataRow.prototype = {
    create: function () {
        var row = document.createElement("tr");
        for (var i = 0; i < this.grid.columns.length; i++) {
            var cell = document.createElement("td");
            cell.innerHTML = this.data[this.grid.columns[i].field] || "";
            row.appendChild(cell);
        }
        this.dom = row;

        var that = this;
        row.onclick = function (event) {
            //通知上級,我被點了
            var newEvent = {
                type: "selected",
                target: that,
                row: that
            };
            that.dispatchEvent(newEvent);
        }
    },

    destroy: function () {
        this.dom = null;
        this.data = null;
        this.grid = null;
    },

    select: function (flag) {
        if (flag) {
            this.dom.className = "info";
        }
        else {
            this.dom.className = "";
        }
    },

    set: function (key, value) {
        this.data[key] = value;

        for (var i = 0; i < this.grid.columns.length; i++) {
            if (this.grid.columns[i].field === key) {
                this.dom.childNodes[i].innerHTML = value;
                break;
            }
        }
    },

    get: function (key) {
        return this.data[key];
    },

    refresh: function (data) {
        this.data = data;

        for (var i = 0; i < this.grid.columns.length; i++) {
            this.dom.childNodes[i].innerHTML = data[this.grid.columns[i].field] || "";
        }
    }
}.extend(EventDispatcher);

然後,我們為它建立一個測試頁面,叫做datagrid.html,內容如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>DataGrid</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="DataGrid">
    <meta name="author" content="xu.fei@outlook.com">

    <link href="http://twitter.github.io/bootstrap/assets/css/bootstrap.css" rel="stylesheet"/>
    <script type="text/javascript" src="js/utils.js"></script>
    <script type="text/javascript" src="js/datagrid.js"></script>
</head>
<body>
    <div class="span12">
        <div class="header">
            <h3>Staff Management</h3>
        </div>
        <div>
            <div id="grid1" class="row"></div>
            <div class="row">
                <div class="header">
                    <h4>Detail</h4>
                </div>
                <hr/>
                <form class="form-horizontal span6">
                    <div class="control-group">
                        <label class="control-label" for="inputIndex">Index</label>
                        <div class="controls">
                            <input type="text" id="inputIndex" placeholder="Index">
                        </div>
                    </div>
                    <div class="control-group">
                        <label class="control-label" for="inputGender">Gender</label>
                        <div class="controls">
                            <input type="text" id="inputGender" placeholder="Gender">
                        </div>
                    </div>
                </form>
                <form class="form-horizontal span6">
                    <div class="control-group">
                        <label class="control-label" for="inputName">Name</label>
                        <div class="controls">
                            <input type="text" id="inputName" placeholder="Name">
                        </div>
                    </div>
                    <div class="control-group">
                        <label class="control-label" for="inputAge">Age</label>
                        <div class="controls">
                            <input type="text" id="inputAge" placeholder="age">
                        </div>
                    </div>
                </form>
            </div>
        </div>
        <div class="modal-footer">
            <div id="operateBtns">
                <button class="btn btn-primary" onclick="newClicked()">New</button>
                <button class="btn btn-primary" onclick="modifyClicked()"><i class="icon-edit icon-white"></i>Modify</button>
                <button class="btn btn-primary" onclick="deleteClicked()"><i class="icon-remove icon-white"></i>Delete</button>
            </div>
            <div id="confirmBtns">
                <button class="btn btn-primary" onclick="okClicked()"><i class="icon-ok icon-white"></i>OK</button>
                <button class="btn btn-primary" onclick="cancelClicked()">Cancel</button>
            </div>
        </div>
    </div>

    <script type="text/javascript">
    var state = "View";

    var grid = new DataGrid(document.getElementById("grid1"));

    grid.addEventListener("loadCompleted", function(event) {
        if (event.target.rows.length > 0) {
            event.target.select(event.target.rows[0]);
        }
    });

    grid.addEventListener("changed", function(event) {
        var data;
        if (event.newRow) {
            data = event.newRow.data;
        }
        else {
            data = {};
        }

        setFormData(data);
    });

    grid.addEventListener("rowInserted", function(event) {
        event.target.select(event.newRow);
    });

    grid.addEventListener("rowRemoved", function(event) {
        if (event.target.rows.length > 0) {
            event.target.select(event.target.rows[0]);
        }
    });

    init();

    function init() {
        enableForm(false);
        switchButtons("Operate");

        var columns = [{
            label: "#",
            field: "index"
        }, {
            label: "Name",
            field: "name"
        }, {
            label: "Gender",
            field: "gender"
        }, {
            label: "Age",
            field: "age"
        }];

        var data = [{
            index: 1,
            name: "Tom",
            gender: "Male",
            age: 5
        }, {
            index: 2,
            name: "Jerry",
            gender: "Female",
            age: 2
        }, {
            index: 3,
            name: "Sun Wukong",
            gender: "Male",
            age: 1024
        }];

        grid.loadColumns(columns);
        grid.loadData(data);
    }

    function newClicked() {
        state = "New";
        switchButtons("Confirm");
        enableForm(true);

        setFormData({});
    }

    function modifyClicked() {
        state = "Modify";
        switchButtons("Confirm");
        enableForm(true);
    }

    function deleteClicked() {
        if (confirm("Sure?")) {
            grid.removeRow(grid.selectedRow);
        }
    }

    function okClicked() {
        var data = {
            index: document.getElementById("inputIndex").value,
            name: document.getElementById("inputName").value,
            gender: document.getElementById("inputGender").value,
            age: document.getElementById("inputAge").value
        };

        if (state === "New") {
            grid.insertRow(data);
        }
        else if (state === "Modify") {
            grid.selectedRow.refresh(data);
        }
        state = "View";
        switchButtons("Operate");
        enableForm(false);
    }

    function cancelClicked() {
        state = "View";
        switchButtons("Operate");
        enableForm(false);

        setFormData(grid.selectedRow.data);
    }

    function enableForm(flag) {
        document.getElementById("inputIndex").disabled = !flag;
        document.getElementById("inputName").disabled = !flag;
        document.getElementById("inputGender").disabled = !flag;
        document.getElementById("inputAge").disabled = !flag;
    }

    function switchButtons(group) {
        if (group === "Operate") {
            document.getElementById("operateBtns").style.display = "";
            document.getElementById("confirmBtns").style.display = "none";
        }
        else if (group === "Confirm") {
            document.getElementById("operateBtns").style.display = "none";
            document.getElementById("confirmBtns").style.display = "";
        }
    }

    function getFormData() {
        return {
            index: document.getElementById("inputIndex").value,
            name: document.getElementById("inputName").value,
            gender: document.getElementById("inputGender").value,
            age: document.getElementById("inputAge").value
        };
    }

    function setFormData(data) {
        document.getElementById("inputIndex").value = data.index || "";
        document.getElementById("inputName").value = data.name || "";
        document.getElementById("inputGender").value = data.gender || "";
        document.getElementById("inputAge").value = data.age || "";
    }
    </script>
</body>
</html>

我們可以看到,這已經可以跑一個簡單的維護介面了,但我們的功能還是有限的,在後續篇幅中,我們會講述如何實現表格的渲染器、改變列的寬度,固定列頭,表格體滾動,排序等高階功能。我們的最終目標是:一個很正式的DataGrid控制元件。

線上演示地址

相關文章