KO範例19 - 下拉選單連動效果

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

傳統做法就是在兩個下拉選單的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
歡迎推文分享:
Published 04 October 2012 06:52 AM 由 Jeffrey
Filed under:
Views: 48,758



意見

# Mountain said on 04 October, 2012 05:36 AM

Dear 黑暗大:

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

麻煩你

Mountain

# CYT said on 17 October, 2012 12:05 PM

黑暗大您好:

 我照著您的方法做縣市選單,發現沒辦法有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" "www.w3.org/.../xhtml1-transitional.dtd">

<html xmlns="www.w3.org/.../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>

# Jeffrey said on 18 October, 2012 01:50 AM

to CYT, 由程式碼我倒沒看出問題。

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

# CYT said on 18 October, 2012 05:16 AM

我用IE9的除錯器偵錯,發現TaiwanZip.js有錯誤,也說"SCRIPT5009: 'taiwanZipData' 未經定義",指向 var cityNames = [];。

TaiwanZip.js的錯誤是說":"改成";",","改成";",這個部分我改過了,但taiwanZipData' 未經定義,我不大會改,簡單來說, var cityNames = [];

       for (var propName in taiwanZipData)

           cityNames.push(propName);

,這部份有錯誤,請黑暗大給予指導。

# Jeffrey said on 18 October, 2012 05:28 AM

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

# CYT said on 18 October, 2012 09:23 AM

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

# Jeffrey said on 18 October, 2012 09:46 AM

to CYT, 依錯誤訊息來看,疑肇因於TaiwainZip.js未被正確載入執行(原因不明),導致var taiwanZipData = { ... }未宣告成功,因此後續要for (var propName in taiwanZipData)時抱怨taiwanZipData未經定義,所以源頭還是在TaiwanZip.js。

不知你所測試的TaiwanZip.js是以何種方式取得的? 若是由我的範例下載,似乎不需要如前面留言所說做** ":"改成";",","改成";" **的修改,可否再提供進一步該修改的細節?

另外,看你是否能將出錯網頁放在公開網址以便重現問題,應該能很快找出答案。

# CYT said on 01 November, 2012 12:15 PM

TO黑暗大:

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

# Jeffrey said on 01 November, 2012 11:05 PM

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

# CYT said on 02 November, 2012 05:03 AM

http://ppt.cc/3QtM,Test.zip

已打包好了,請指導一下。

# Jeffrey said on 02 November, 2012 06:18 AM

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 ="(可參考:

www.darkthread.net/.../TaiwanZip.js),你的版本遺漏了。

修正以上兩個問題,程式就可以運作了。

# CYT said on 09 November, 2012 09:29 AM

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

# Deven said on 11 May, 2013 11:21 AM

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

# Jeffrey said on 11 May, 2013 07:12 PM

to Deven, addrePrefix除了顯示,亦有輸入縣市決定郵遞區號的功能,你想達成的UI效果是該欄位只需敲郵遞區號,不包含縣市嗎? 若是只需修改self.addrPrefix的ko.computed()讀寫邏輯。

如果想改成"可以輸入縣市但只顯示郵遞區號",跟UI原始設計構想有些出入,需了解更多操作細節,或許應連UI呈現方式都需一起調整。

# angellevis said on 11 September, 2013 11:33 PM

想請問黑暗大~~

如果我需要三欄地址欄位呢??

該如何設定參數能讓js共用?謝謝

# Jeffrey said on 13 September, 2013 03:30 AM

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

# stevenlee said on 29 April, 2015 04:58 AM

黑暗大您好~ 最近工作上要修改前人所寫的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>

# Jeffrey said on 29 April, 2015 05:50 AM

to stevenlee, 關於你遇到的議題,我心中最簡單的解法是引用Kendo UI之類的現成UI元件,可以快速做出夠漂亮又可自訂外觀的下拉選單,用<button><li>徒手造輪子肯定是件累人的事。

如果真要DIY,我慣用的策略是將真的select隱藏起來,options為select專屬,故得用foreach產生每個選項一個<li>模擬,並在<button>click事件切換整個<ul>顯示與否,還要解決項目較多的垂直捲軸問題,當使用者選取某個<li>選項,再將結果同步到<select>上… 細節多如牛毛,決定走這條天堂路前宜三思。

# stevenlee said on 30 April, 2015 05:08 AM

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

# Jack said on 05 November, 2017 01:00 PM

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

# Andy said on 20 July, 2018 06:07 AM

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" "www.w3.org/.../xhtml1-transitional.dtd">

<html xmlns="www.w3.org/.../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='BLOCKED SCRIPTdelField(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>

# Jeffrey said on 25 July, 2018 05:04 AM

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

# Andy said on 09 August, 2018 08:14 PM

Dear 黑暗大

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

# Jeffrey said on 10 August, 2018 05:53 AM

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

你的看法呢?

(必要的) 
(必要的) 
(選擇性的)
(必要的) 
(提醒: 因快取機制,您的留言幾分鐘後才會顯示在網站,請耐心稍候)

5 + 3 =

搜尋

Go

<October 2012>
SunMonTueWedThuFriSat
30123456
78910111213
14151617181920
21222324252627
28293031123
45678910
 
RSS
創用 CC 授權條款
【廣告】
twMVC
最新回應

Tags 分類檢視
關於作者

一個醉心技術又酷愛分享的Coding魔人,十年的IT職場生涯,寫過系統、管過專案, 也帶過團隊,最後還是無怨無悔地選擇了技術鑽研這條路,近年來則以做一個"有為的中年人"自許。

文章典藏
其他功能

這個部落格


Syndication