從零開始使用JavaScript編寫資料表格控制元件(一)
資料表格控制元件的基礎功能
資料表格是一個很常用的控制元件,用於把多列資料展示成表格的形狀,通常有表頭,表頭可固定,表格內容可滾動。本文以一個資料表格控制元件為例,說明從構思到實現控制元件的整個過程。
為了使初學者更容易理解其中的原理,我們不使用任何額外的庫,比如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控制元件。
相關文章
- 從零開始使用JavaScript編寫資料表格控制元件(二)JavaScript控制元件
- 從零開始編寫自己的JavaScript框架(一)JavaScript框架
- 從零開始寫JavaScript框架(一)JavaScript框架
- 從零開始編寫自己的JavaScript框架(二)JavaScript框架
- Re從零開始的UI庫編寫生活之表格元件UI元件
- 從零開始寫JavaScript框架(二)JavaScript框架
- 從零開始寫一個Javascript解析器JavaScript
- 從零開始編寫一個babel外掛Babel
- 從零開始編寫指令碼引擎指令碼
- 從零開始寫一個ExporterExport
- 從零開始仿寫一個抖音App——開始APP
- 從零開始寫一個網頁網頁
- 從零開始寫一個微前端框架-資料通訊篇前端框架
- 從零開始編寫一個 Python 非同步 ASGI WEB 框架Python非同步Web框架
- 從零開始用golang編寫一個分散式測試工具Golang分散式
- 從零開始寫一個node爬蟲(上)—— 資料採集篇爬蟲
- 從零開始寫一個node爬蟲(一)爬蟲
- 如何從零開始寫一個網站網站
- 從零開始:用REACT寫一個格鬥遊戲(一)React遊戲
- 19. 從零開始編寫一個類nginx工具, 配置資料的熱更新原理及實現Nginx
- Re從零開始的UI庫編寫生活之表單UI
- Re從零開始的UI庫編寫生活之按鈕UI
- 從零開始-打造一個JavaScript完整線上教程文件JavaScript
- 從零開始的堆疊卡片控制元件控制元件
- 從零到一編寫MVVMMVVM
- 從零開始:用REACT寫一個格鬥遊戲(二)React遊戲
- 從零開始寫一個微前端框架-沙箱篇前端框架
- 資料分析從零開始實戰 | 基礎篇(一)
- Excel 開始支援使用 JavaScript 編寫自定義函式ExcelJavaScript函式
- 從零開始仿寫一個BiliBili客戶端之編譯ijkplayer客戶端編譯
- Re從零開始的UI庫編寫生活之規範制定UI
- 用PyTorch從零開始編寫DeepSeek-V2PyTorch
- 從零開始系列-Laravel編寫api服務介面:6.資料庫查詢(未完待續)LaravelAPI資料庫
- 從零開始JAVA資料結構學習筆記(一)Java資料結構筆記
- 從零開始手寫一個微前端框架-渲染篇前端框架
- 從零開始再學 JavaScript 定時器JavaScript定時器
- 5-使用協程-從零開始寫一個武俠冒險遊戲遊戲
- 從零開始資料分析:一個資料分析師的資料分析流程