下拉選單間的連動在網頁設計十分普遍,例如: 主分類與次分類兩個下拉選單,選好主分類後,次分類的下拉選項要立即變成該主分類底下的次分類項目;另一個經典的例子則是---縣市鄉鎮與郵遞區號選取介面,幾乎是網頁開發的必修學分,網路上有不少範例及現成套件。

傳統做法就是在兩個下拉選單的onchange事件上做文章,說難不難,但寫起來挺囉索的。這回我們來看看用Knockout如何實現縣市鄉鎮區下拉選單連動。線上展示

開始之前,我們必須先取得縣市鄉鎮與郵遞區號資料來源,由於時間的關係,我已經預先烤好準備了一份(邊說邊從桌下端出半成品)台灣三碼郵遞區號的JSON檔,接著依照慣例,我們只需專心把ViewModel做好,其餘要跟DOM元素及事件打架的雞毛蒜皮瑣事就交給Knockout搞定!

我們在ViewModel中宣告5個屬性:

  1. cities
    所有縣市名稱組成的observableArray,作為縣市下拉選單的options來源
  2. city
    用以儲存所選取的縣市名稱,為縣市下拉選單的value繫結對象
  3. areas
    為ko.computed,傳回city縣市所屬的鄉鎮市區資料物件陣列,作為鄉鎮市區下拉選單的options來源。這裡運用KO可以相依性追蹤特性,city一旦改變,areas就會立即重算,鄉鎮市區下拉選單的選項也馬上跟著變動,不知不覺間"連動"的功能就寫完了!
  4. areaZip
    儲存目前所選取的鄉鎮市區加郵遞區號資料,為鄉鎮市區下拉選單的value繫結對象
  5. addrPrefix
    可寫入式ko.computed,將city與areaZip組成如"台北市大安區106"格式字串傳回,寫入資料時解析成縣市與鄉鎮市區郵遞區號兩部分,並同步至city及areaZip

以下是ViewModel的完整程式碼:

        function MyViewModel() {
            var self = this;
            //cities為所有縣市名稱組成的observableArray
            self.cities = ko.observableArray(cityNames);
            //city用來儲存目前挑選的縣市名稱
            self.city = ko.observable();
            //areas為一computed,會傳回city縣市所屬鄉鎮市區資料物件陣列
            self.areas = ko.computed(function () {
                var areaData = taiwanZipData[self.city()];
                var options = [];
                if (areaData) {
                    for (var propName in areaData) {
                        options.push({
                            value: propName + areaData[propName],
                            text: propName
                        });
                    }
                }
                return options;
            });
            //areaZip用來儲存鄉鎮市區加郵遞區號資料
            self.areaZip = ko.observable();
            //可寫式computed
            //用以傳回"台北市大安區106"格式之city + areaZip資料
            //變更內容時,會將"台北市大安區106"格式解析並更新至city與areaZip
            self.addrPrefix = ko.computed({
                read: function () {
                    return (self.city() || "") + (self.areaZip() || "");
                },
                write: function (value) {
                    if (value.length >= 3) {
                        self.city(value.substr(0, 3));
                    }
                    if (value.length > 3) {
                        self.areaZip(value.substr(3));
                    }
                }
            });
        }

處理完ViewModel,網頁元素部分相對單純: 兩個下拉選單,value分別繫結到city及areaZip,options則繫結至cities及areas,另外再加一個input繫結到areaPrefix顯示輸入結果。

<select data-bind="options: cities, optionsCaption: '選擇縣市', value: city"></select>
<select data-bind="options: areas, optionsCaption: '選擇區域', optionsText: 'text', optionsValue: 'value', value: areaZip"></select>
<input data-bind="value: addrPrefix" />

就這樣,程式寫完了,真的!! 有趣的是,若在<input>中填入"台北市大安區106",下拉選單還會自動切到"台北市"及"大安區",代表修改文字內容或透過程式變更其值,下拉選單也會自動切換到對應位置,連<input>到<select>的反向連動也有了。

再一次證明"搞定ViewModel,介面就會自動自發地運作起來",這就是MVVM的魔力所在。(線上展示)

我不知道大家做何感覺,身為曾DIY打造過類似下拉選單的老鳥,在使用Knockout寫好範例的那一刻,我感動到想起立鼓掌! 啊~ 福氣啦~~~

[KO系列]

http://www.darkthread.net/kolab/labs/default.aspx?m=post

Comments

# by Mountain

Dear 黑暗大: 我們是作教育訓練的單位, 看你專長在網頁程是部份, 不知是否有興趣分享你的專長, 我的連絡方式是mountain@fitpi.com 麻煩你 Mountain

# by CYT

黑暗大您好: 我照著您的方法做縣市選單,發現沒辦法有KO範例19的效果,也試了很久,找不出問題所在,因此PO上程式碼,盼黑暗大給予指導。 PS.我個人認為,<select data-bind="options: cities, optionsCaption: '選擇縣市', value: city"></select> <select data-bind="options: areas, optionsCaption: '選擇區域', optionsText: 'text', optionsValue: 'value', value: areaZip"></select> <input data-bind="value: addrPrefix" /> ,這段程式碼似乎有問題,我在試的時候,開始的時候,兩個選單都空白。 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>無標題文件</title> <script src="../Scripts/jquery-1.7.2.js"></script> <script src="../Scripts/knockout-2.1.0.debug.js"></script> <script src="../Scripts/TaiwanZip.js"></script> <script> //由taiwanZips取出程式清單 var cityNames = []; for (var propName in taiwanZipData) cityNames.push(propName); function MyViewModel() { var self = this; //cities為所有縣市名稱組成的observableArray self.cities = ko.observableArray(cityNames); //city用來儲存目前挑選的縣市名稱 self.city = ko.observable(); //areas為一computed,會傳回city縣市所屬鄉鎮市區資料物件陣列 self.areas = ko.computed(function () { var areaData = taiwanZipData[self.city()]; var options = []; if (areaData) { for (var propName in areaData) { options.push({ value: propName + areaData[propName], text: propName }); } } return options; }); //areaZip用來儲存鄉鎮市區加郵遞區號資料 self.areaZip = ko.observable(); //可寫式computed //用以傳回"台北市大安區106"格式之city + areaZip資料 //變更內容時,會將"台北市大安區106"格式解析並更新至city與areaZip self.addrPrefix = ko.computed({ read: function () { return (self.city() || "") + (self.areaZip() || ""); }, write: function (value) { if (value.length >= 3) { self.city(value.substr(0, 3)); } if (value.length > 3) { self.areaZip(value.substr(3)); } } }); } $(function () { ko.applyBindings(new MyViewModel()); }); </script> </head> <body> <select data-bind="options: cities, optionsCaption: '選擇縣市', value: city"></select> <select data-bind="options: areas, optionsCaption: '選擇區域', optionsText: 'text', optionsValue: 'value', value: areaZip"></select> <input data-bind="value: addrPrefix" /> </body> </html>

# by Jeffrey

to CYT, 由程式碼我倒沒看出問題。 網頁有沒有丟出任何錯誤訊息?(也可能有出錯但瀏覽器沒明確提示,最好用偵錯工具檢查一下) 有點懷疑是相關js載入或執行問題,導致程式中斷造成不正確的結果。

# by CYT

我用IE9的除錯器偵錯,發現TaiwanZip.js有錯誤,也說"SCRIPT5009: 'taiwanZipData' 未經定義",指向 var cityNames = [];。 TaiwanZip.js的錯誤是說":"改成";",","改成";",這個部分我改過了,但taiwanZipData' 未經定義,我不大會改,簡單來說, var cityNames = []; for (var propName in taiwanZipData) cityNames.push(propName); ,這部份有錯誤,請黑暗大給予指導。

# by Jeffrey

to CYT, 在IE9 Dev Tools裡可以查到TaiwanZip.js的內容嗎? 直覺有可能是編碼問題,確認一下TaiwanZip.js的檔案編碼是UTF-8,而不是ANSI(BIG-5)。

# by CYT

我確定了,TaiwanZip.js編碼是UTF-8。

# by Jeffrey

to CYT, 依錯誤訊息來看,疑肇因於TaiwainZip.js未被正確載入執行(原因不明),導致var taiwanZipData = { ... }未宣告成功,因此後續要for (var propName in taiwanZipData)時抱怨taiwanZipData未經定義,所以源頭還是在TaiwanZip.js。 不知你所測試的TaiwanZip.js是以何種方式取得的? 若是由我的範例下載,似乎不需要如前面留言所說做** ":"改成";",","改成";" **的修改,可否再提供進一步該修改的細節? 另外,看你是否能將出錯網頁放在公開網址以便重現問題,應該能很快找出答案。

# by CYT

TO黑暗大: 我想用公開的網頁來顯現間題,但似乎找不到合適的空間來顯示網頁(我adsl不是中華電信的),懇請黑暗大指點提供,另外,我是想用這個功能來加強我的php作品的功能,除了程式碼,是否要裝其他的軟體?

# by Jeffrey

to CYT,這個範例只動用到HTML、JS,不需要任何後端支援就可以執行,故把必要的檔案放在本機資料夾也可測試。由於依您的描述難以推敲問題所在,建議你在本機資料夾放入可重現錯誤的HTML與JS(指直接用瀏覽器開本機HTML就能看到錯誤,若有必要請先移除敏感資料及PHP後端程式部分),打包成ZIP透過SkyDrive、DropBox等方式分享,大家就能幫忙射茶包。

# by CYT

http://ppt.cc/3QtM,Test.zip 已打包好了,請指導一下。

# by Jeffrey

to CYT, 發現兩個問題: 1) 路徑有誤,檔案結構為test/test.php, test/scripts/TaiwanZip.js,故<script src="../Scripts/TaiwanZip.js"></script>應改成<script src="Scripts/TaiwanZip.js"></script>才對。(jQuery, knockout.js的URL都要改) 2) TaiwanZip.js的第一行應為"var taiwanZipData ="(可參考: http://www.darkthread.net/kolab/Scripts/TaiwanZip.js),你的版本遺漏了。 修正以上兩個問題,程式就可以運作了。

# by CYT

謝謝黑暗大的指導,間題解決了,讓我受益良多。

# by Deven

黑暗大您好,如果想在addrPrefix顯示郵遞區號就好,我該如何修改,麻煩指導,謝謝。

# by Jeffrey

to Deven, addrePrefix除了顯示,亦有輸入縣市決定郵遞區號的功能,你想達成的UI效果是該欄位只需敲郵遞區號,不包含縣市嗎? 若是只需修改self.addrPrefix的ko.computed()讀寫邏輯。 如果想改成"可以輸入縣市但只顯示郵遞區號",跟UI原始設計構想有些出入,需了解更多操作細節,或許應連UI呈現方式都需一起調整。

# by angellevis

想請問黑暗大~~ 如果我需要三欄地址欄位呢?? 該如何設定參數能讓js共用?謝謝

# by Jeffrey

to angellevis, 不是很明白三欄地址的定義,另外,設定參數共用JS的情境也得再詳細解釋一下,大家才好腦力激盪。

# by stevenlee

黑暗大您好~ 最近工作上要修改前人所寫的code,剛好需看到是要處理ko 下拉選單的樣式更改。 不過因為select option 是系統原生樣式,無法直接修改。 想說直接從ko 下手,將原本select 的架構,改變為 <button><ul><li> 來模擬select。 因為資料與邏輯都已寫好,不想為了改變select的樣式,而去將原先舊有的寫法整個改變。想請問是否有方式能實現? 想請問黑暗大,select 綁定的資料,該如何改為綁在button 上,select 的option 的資料,又該如何綁在 <li> 上。 html 上的寫法: <select name="child" class="validate[funcCall[recheck]] personselect" data-bind="options: childOptions(), value: searchModel.journey.child" data-prompt-target="checkPassengerMsg"></select>

# by Jeffrey

to stevenlee, 關於你遇到的議題,我心中最簡單的解法是引用Kendo UI之類的現成UI元件,可以快速做出夠漂亮又可自訂外觀的下拉選單,用<button><li>徒手造輪子肯定是件累人的事。 如果真要DIY,我慣用的策略是將真的select隱藏起來,options為select專屬,故得用foreach產生每個選項一個<li>模擬,並在<button>click事件切換整個<ul>顯示與否,還要解決項目較多的垂直捲軸問題,當使用者選取某個<li>選項,再將結果同步到<select>上… 細節多如牛毛,決定走這條天堂路前宜三思。

# by stevenlee

謝謝黑暗大,已找到解決方式了,非常感謝你的建議

# by Jack

如果是不確定階層的實在不曉得如何寫..

# by Andy

Dear 黑暗大 不好意思,我有個問題想請教一下 如何在"陣列元素的新增/移除事件"中,加上"動態新增下拉選單選項"的功能,以下是我的程式碼:(欄位新增後,分析項目會依照所選的分析儀器來連動,例如:分析儀器選1,分析項目只會有A可選;分析儀器選2,分析項目只會有B及C可選;分析儀器選3,分析項目只會有A、D及E可選) 還請黑暗大給予指導,謝謝 <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Step1.aspx.cs" Inherits="Chemical_Analysis_System.ChemApply.Step1" %> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head id="Head1" runat="server"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <title></title> <style type="text/css"> .auto-style1 { font-family: 標楷體; font-size: xx-large; color: #000066; } .auto-style2 { font-family: 標楷體; } .auto-style3 { color: #FFFFFF; height: 22px; font-family: 標楷體; background-color: #000066; } .auto-style4 { font-family: Calibri; } .auto-style5 { color: #0000FF; } .auto-style8 { color: #FFFFFF; height: 22px; font-family: 標楷體; background-color: #FFFFFF; } .auto-style9 { font-family: 標楷體; width: 100px; height: 51px; } .auto-style10 { font-family: 標楷體; width: 200px; height: 51px; } .auto-style11 { width: 200px; height: 51px; } .auto-style12 { font-family: 標楷體; width: 200px; } .auto-style13 { color: #FF0000; } .auto-style14 { font-family: 標楷體; color: #FF0000; } .auto-style15 { border-style: solid; border-width: 1px; padding: 1px 4px; background-color: #99CCFF; text-align: center; } .auto-style16 { margin-left: 0px; } .style22 { width: 906px; } </style> </head> <body style="background-color: #FFFFFF"> <form id="form1" name="myform" runat="server"> <hr style="color: #000000; background-color: #000000; width:auto"/> <hr style="color: #000000; background-color: #000000; width:auto " class="style22" /> <p class="auto-style3"> 樣品說明<span class="auto-style4">(Sample Info.)</span></p> <div style="display:none"> <select id="temp1" name="temp1" onchange="SelZero(this.options[this.options.selectedIndex].value);"> <option value="1">1</option> <option value="2">2</option> <option value="3">3</option> <option value="4">4</option> </select> <select id="Zero" name="Zero" onchange="document.FormCode.Zip.value=this.options[this.options.selectedIndex].value;"> <option value="">分析項目</option> </select> <select id="temp3"> <option value="酸">酸</option> <option value="鹼">鹼</option> <option value="有機類">有機類</option> </select> <select id="temp4"> <option value="1.爆炸性">1.爆炸性</option> <option value="2.易燃性">2.易燃性</option> <option value="3.氧化性">3.氧化性</option> <option value="4.腐蝕性">4.腐蝕性</option> <option value="5.毒性">5.毒性</option> <option value="6.驚嘆號">6.驚嘆號</option> <option value="7.健康危害">7.健康危害</option> <option value="8.水環境危害">8.水環境危害</option> <option value="9.無危害圖示">9.無危害圖示</option> </select> <select id="temp5"> <option value="危險">危險</option> <option value="警告">警告</option> <option value="其他危害:內含有害物質,如TMAH、HF......">其他危害:內含有害物質,如TMAH、HF......</option> </select> </div> <div> <input type="button" value="新增欄位(Add)" onclick="addField()" ID="Button1" runat="server" text="Add" /> <table id="addtable" border="1" cellpadding="5" style="border:1px #000000 solid;text-align:center"> <tr> <td width="42" bgcolor="#cccccc">No.</td> <td width="86" bgcolor="#cccccc">樣品名稱*</td> <td width="125" bgcolor="#cccccc">分析儀器</td> <td width="125" bgcolor="#cccccc">分析項目</td> <td width="125" bgcolor="#cccccc">主要成分</td> <td width="125" bgcolor="#cccccc">樣品說明</td> <td width="125" bgcolor="#cccccc">化學品特性</td> <td width="125" bgcolor="#cccccc">GHS危險圖示</td> <td width="125" bgcolor="#cccccc">危害警告訊息*</td> <td width="42" bgcolor="#cccccc">刪除</td> </tr> </table> <script type="text/javascript"> var countMin = 1; var countMax = 100; //最多幾列 var count = countMin; function addField() { var table = document.getElementById("addtable"); var tr = table.insertRow(-1); //新增第1欄 var td = tr.insertCell(-1); td.innerHTML = "<span class=red>" + tr.rowIndex + "</span>"; //新增第2欄 td = tr.insertCell(tr.cells.length); td.innerHTML = '<input name="samplename" type="text" size="12">'; //新增第3欄 var select = document.createElement("select"); select.setAttribute("name", "num"); select.options.add(new Option("", "")) for (var i = 0; i < document.getElementById("temp1").length; i++) { select.options.add(new Option(document.getElementById("temp1").options[i].value, document.getElementById("temp1").options[i].innerHTML)); } var td = tr.insertCell(-1); td.appendChild(select); //新增第4欄 var select = document.createElement("select"); select.setAttribute("name", "num"); select.options.add(new Option("", "")) for (var i = 0; i < document.getElementById("Zero").length; i++) { select.options.add(new Option(document.getElementById("Zero").options[i].value, document.getElementById("Zero").options[i].innerHTML)); } var td = tr.insertCell(-1); td.appendChild(select); //新增第5欄 td = tr.insertCell(tr.cells.length); td.innerHTML = '<input name="contents" type="text" size="12">'; //新增第6欄 td = tr.insertCell(tr.cells.length); td.innerHTML = '<input name="instructions" type="text" size="12">'; //新增第7欄 var select = document.createElement("select"); select.setAttribute("name", "cname"); select.options.add(new Option("", "")) for (var i = 0; i < document.getElementById("temp3").length; i++) { select.options.add(new Option(document.getElementById("temp3").options[i].value, document.getElementById("temp3").options[i].innerHTML)); } var td = tr.insertCell(-1); td.appendChild(select); //新增第8欄 var select = document.createElement("select"); select.setAttribute("name", "cname"); select.options.add(new Option("", "")) for (var i = 0; i < document.getElementById("temp4").length; i++) { select.options.add(new Option(document.getElementById("temp4").options[i].value, document.getElementById("temp4").options[i].innerHTML)); } var td = tr.insertCell(-1); td.appendChild(select); //新增第9欄 var select = document.createElement("select"); select.setAttribute("name", "cname"); select.options.add(new Option("", "")) for (var i = 0; i < document.getElementById("temp5").length; i++) { select.options.add(new Option(document.getElementById("temp5").options[i].value, document.getElementById("temp5").options[i].innerHTML)); } var td = tr.insertCell(-1); td.appendChild(select); //新增第10欄 var td = tr.insertCell(-1); td.innerHTML = "<input type='button' value='刪除' onclick='javascript:delField(this);'>"; document.getElementById("add").style.display = table.rows.length == countMax + 1 ? "none" : ""; } function delField(obj) { var table = document.getElementById("addtable"); table.deleteRow(obj.parentNode.parentNode.rowIndex); document.getElementById("add").style.display = ""; //更新序號 if (table.rows.length > 1) { for (i = 1; i < table.rows.length; i++) { table.rows(i).cells(0).innerHTML = "<span class=red>" + i.toString() + "</span>"; } } } function batchAddField(j) { var i, k; //清空 var table = document.getElementById("table1"); if (table.rows.length > 1) { k = table.rows.length; for (i = 1; i < k; i++) { table.deleteRow(table.rows(1).rowIndex); } } if (!isNaN(j)) { for (i = 0; i < j; i++) { addField(); } } else { alert("請輸入正確數值"); } } </script> </div> </form> </body> </html>

# by Jeffrey

to Andy, KO的核心精神MVVM,寫好Model屬性互動邏輯,不需要自己操弄HTML或DOM,也用掛一堆onchange事件,與你現行的程式做法很不一樣。如果你想套用這篇文章所提的做法,可參考這一系列文章,先從導入MVVM觀念開始。

# by Andy

Dear 黑暗大 多謝您的提點,參考您一系列的文章,終於把"陣列元素的新增/移除事件"及"下拉選單連動效果"的功能合併成功了

# by Jeffrey

to Andy, 讚! 有感受到你在網路另一端的喜悅,很開心有幫上忙。

# by Andy

Dear 黑暗大 關於ASP.NET專案中體驗knockout.js之問題請教,問題如下: 我照著黑暗大的指導,透過NuGet下載程式庫並安裝了knockoutjs,在WebForm.aspx用"在瀏覽器中檢視",網頁開啟後===>"陣列元素的新增/移除事件"及"下拉選單連動效果"的功能都不能使用;相同的程式碼,我複製貼到.txt檔後,將附檔名改成.html,開啟網頁後,所有功能都是正常的,想請教黑暗大是否知道我的問題出在哪兒? 還請黑暗大給予指導,謝謝

# by Jeffrey

to Andy, 建議開啟瀏覽器F12開發者工具追查,看看是否出現JavaScript錯誤訊息。

# by Andy

Dear 黑暗大 又再次的感謝您 ^^ 已成功可透過ASP.NET使用了 雖然F12 我常常按,但查看JavaScript錯誤訊息→這個我還真的是經由您提點後才第一次查看呢 XD。

# by Andy

Dear 黑暗大 不好意思,又來跟您請教了 如同這頁所分享的寫法(二階:主分類、次分類),三階(主分類、次分類、次次分類)下拉選單連動效果是否也行的通呢?自己試了3天,目前還試不出來。 還請黑暗大給予指導,謝謝

# by Jeffrey

to Andy,要不要把你有問題的程式抽取成範例放上 JSBin 或 JSFiddler 讓大家幫忙把脈?

# by Andy

Dear 黑暗大 我自己亂試了2~3個版本都無法達到"次次分類"能依"次分類"選擇而連動, 先用下面這個版本來詢問好了,if判斷式是否不能在這使用,或我的用法不對, 範例如右===> https://jsbin.com/yuyurafima/edit?html,output SimulationSoftware.js 檔案內容如下 var SimulationSoftwareData = { "語言": { "國語": "100", "英語": "101" }, "其它": { "數學": "200" } } ;

# by Jeffrey

to Andy, 依照你的程式我做了小調整讓次次分類可以連動,https://jsbin.com/rakohip/edit?html,output 原理是用 ko.computed() 追蹤次分類選取值,在其異動時改變 icons 集合內容。

# by Andy

Dear 黑暗大 原來是這樣的寫法,非常感謝您的幫忙與指導。

Post a comment