Knockout教學3 - Single Page Applications, SPA

單頁應用程式

許多最現代、靈敏和基於Web的UI已超越傳統 Ajax,已成為單個頁面的應用程式(single page applications):訪客可以在單一的頁面巡覽像本地應用程序的速度。最著名的例子可能是GMail,但這些日子裡,它已經是一種越來越普遍的技術。

hash-based與pushState巡覽

這種應用程式使用hash-based或pushState巡覽以支援前進/後退手勢和書籤。如果讀者不熟悉這種技術的工作原理,請參閱以下簡短的解釋。

hash-based巡覽:訪問者的位置是儲存在URL hash的虛擬巡覽空間裡,hash符號的後面是URL的一部分(例如,/my/app/#category=shoes&page=4),每當URL hash有變動,瀏覽器不會發出HTTP請求來獲取新的一頁。相反的是,它只會把新的URL加入它的前進或後退歷史清單中,並在此網頁運作的腳本中公開並更新新URL hash。該腳本通知新URL hash和動態更新UI並顯示對應的項目內容(例如,”#category=shoes&page=4”代表鞋子目錄第四頁)。

這樣就可以在單個頁面應用程式中支援前進與後退按鈕巡覽(例如,按下”後退鍵”移至前一個URL hash),和有效地使用虛擬位置並可和書籤共同。

pushState是一個提供了另一種不同的方法來更改當前URL的HTML5 API,它將插入新的前進和後退和歷史項目,而不會觸發頁面載入。這不同於URL hash巡覽,因為不限於更新hash片段,可以更新整個URL。

範例:建置一個網頁郵件用戶端

你有一個簡單ViewModel,它們的內容只有保留資料夾清單。你的第一件工作是呈現它們在畫面上,而且讓它們是可選擇的。

你可以使用foreach繫結來呈顯資料夾清單,在View加入以上程式碼:

<!-- Folders -->
<ul data-bind="foreach: folders">
    <li data-bind="text: $data"></li>
</ul>

如果你現在執行應用程式,你應該有一個圓點清單(bullet-pointed)。這就是好的語意,但不是有吸引力。新增folders類別樣式到<ul>元素來改善:

<ul class="folders" data-bind="foreach: folders">

這使它看來起來更好。

讓目錄可選擇

因為這是MVVM,我們將會使用ViewModel屬性來呈現巡覽位置。這將使之後的hash-based巡覽非常容易。新增一個chosenFolderId屬性到ViewModel類別,然後新增一個函式稱goToFolder:

function WebmailViewModel() {
    // 資料
    var self = this;
    self.folders = ['Inbox', 'Archive', 'Sent', 'Spam'];
    self.chosenFolderId = ko.observable();

    // 行為
    self.goToFolder = function(folder) { self.chosenFolderId(folder); };
};

現在,你可以使用css繫結來處理selected類別到合乎比對的資料夾上,和每當有使用者點擊資料夾時執行goToFolder。

<li data-bind="text: $data, 
               css: { selected: $data == $root.chosenFolderId() },
               click: $root.goToFolder"></li>

現在,當點擊資料夾時應該會高亮度提示項目。

呈現郵件

現在訪問者已經可以選擇資料夾,現在呈現資料夾裡的郵件。先定義chosenFolderData屬性在ViewModel:

function WebmailViewModel() {
    // 資料
    var self = this;
    self.folders = ['Inbox', 'Archive', 'Sent', 'Spam'];
    self.chosenFolderId = ko.observable();
    self.chosenFolderData = ko.observable();

    // 行為    
    self.goToFolder = function(folder) { self.chosenFolderId(folder); };
};

下一步,每當使用者巡覽至一個資料夾,chosenFolderData應該執行一個Ajax請求:

self.goToFolder = function(folder) { 
    self.chosenFolderId(folder);
    $.get('/mail', { folder: folder }, self.chosenFolderData);
};

注意,URI資源可修改為”http://learn.knockoutjs.com/mail”來完成實作,但會碰上腳本同源問題。讀者可以先連結至官方獨立範例:http://learn.knockoutjs.com/WebmailExampleStandalone.html來看效果。

查看單一郵件

現在訪問者可以在資料夾之間巡覽郵件。那要如何讓他們打開和讀取特定電子郵件呢?在巡覽的資料夾,我們定義ViewModel屬性來呈現資料的載入與特定電子郵件:

self.chosenMailData = ko.observable();

下一步必須更新繫結內容,這樣訪問者點擊電子郵件時,你的ViewModel才能載入正確的電子郵件。 在<tr>元素使用click繫結:

<tbody data-bind="foreach: mails">
    <tr data-bind="click: $root.goToMail">

下一步在WebmailViewModel實作goToMail方法,通過Ajax請求更新chosenMailData與chosenFolderData:

self.goToFolder = function(folder) { 
    self.chosenFolderId(folder);
    self.chosenMailData(null); // Stop showing a mail
    $.get("/mail", { folder: folder }, self.chosenFolderData);        
};

self.goToMail = function(mail) { 
    self.chosenFolderId(mail.folder);
    self.chosenFolderData(null); // Stop showing a folder
    $.get("/mail", { mailId: mail.id }, self.chosenMailData);
};

最後,您通過將增加標籤到View(HTML)以呈現chosenMailData:

<!-- 選擇mail -->
<div class="viewMail" data-bind="with: chosenMailData">
    <div class="mailInfo">
        <h1 data-bind="text: subject"></h1>
        <p><label>From</label>: <span data-bind="text: from"></span></p>
        <p><label>To</label>: <span data-bind="text: to"></span></p>
        <p><label>Date</label>: <span data-bind="text: date"></span></p>
    </div>
    <p class="message" data-bind="html: messageContent" />
</div>

現在你可以點擊電子郵件,你應該能看到它呈現在畫面上。注意,使用html繫結,它將允許任何換行符號或HTML標籤內容呈現在畫面上(我們要確認伺服器有去除任何惡意內容)。

啟用用戶端巡覽

有許多開源函式庫做的用戶端巡覽(例如,URL hash或pushState),它們任何一個與Knockout一起使用都很合適。本教學會使用sammy.js(http://sammyjs.org/),因為它用簡單的方法來定義用戶端的URL模式。

我們將使用基本技術增加額外間接層。之前,goToFolder和goToMail方法直接觸發Ajax請求和更新ViewModel狀態,但現在,我們要更改goToFolder和goToMail方法,讓它們只觸發用戶端的巡覽。另外,我們將使用Sammy.js檢測用戶端巡覽,然後做Ajax請求並更新ViewModel狀態。這種間接方式意味著,如果使用者通過不同方式觸發用戶端的巡覽(例如,點擊「後退」),對應的ViewModel更新依然會有效。

先加入sammy.js參考至你的View(HTML):

<script src="/scripts/lib/sammy.js" type="text/javascript"></script>

下一步,減少goToFolder和goToMail方法,讓他們只觸發用戶端巡覽:

// 行為    
self.goToFolder = function(folder) { location.hash = folder };
self.goToMail = function(mail) { location.hash = mail.folder + '/' + mail.id };

注意,我們使用的表單用戶端URL通知”#<foldername>”和”#<foldername>/<mailid>”, 現在我們要使用Sammy.js來取得這些類型的URL巡覽,透過Ajax請求執行我們以前的邏輯和載入對應的資料。以下是Sammy.js組態:

// 用戶端路由  
Sammy(function() {
    this.get('#:folder', function() {
        self.chosenFolderId(this.params.folder);
        self.chosenMailData(null);
        $.get("/mail", { folder: this.params.folder }, self.chosenFolderData);
    });

    this.get('#:folder/:mailId', function() {
        self.chosenFolderId(this.params.folder);
        self.chosenFolderData(null);
        $.get("/mail", { mailId: this.params.mailId }, self.chosenMailData);
    });
}).run();

第一個符合表單的URL是#<foldername>,第二個符合表單的URL的#<foldername>/<mailid>,內部邏輯與之前的goToFolder與goToMail方法相同,它們都使用Ajax請求來更新ViewModel。

你已經將View設定準備好去呈現結果,現在測試一下:你應該能夠巡覽和查看URL的更新(請查看畫面最上方Output旁邊的URL hash:…)。如果你是執行Chrome、Firefox或Safari,你還可以使用前進與後退鍵來回溯或重放你的巡覽步驟。

註解:IE使用者

Output視窗執行你的程式碼在iframe,雖然大多數瀏覽器完全支援在iframe內基於hash的巡覽,但各種舊瀏覽器(IE 9之前)都不支援。所以你會不看到前進與後退鍵在Output視窗中有作用。Sammy.js能在IE正常工作,只要不在iframe裡。舊瀏覽器可以使用獨立範例(http://learn.knockoutjs.com/WebmailExampleStandalone.html)來查看程式運作情況。

支援書籤與深層連結

現在的程式碼物幾乎已經可以支援書籤和深層連結。唯一的問題是頁面首次載入時,如果訪問者的用戶端URL是空的,讓它預設呈現Inbox,在Sammy.js新增一條路由組態:

this.get('', function() { this.app.runRoute('get', '#Inbox') });

使用runRoute表示空的用戶端URL會被視同為#Inbox,即,它將載入並呈現Inbox。

現在訪問者不只是巡覽資料夾和電子郵件,還可以使用前進和後退鈕或分享連結,就像他們是透過伺服器來產生巡覽頁面一樣,因為所有UI呈現都是在用戶端,只有原始JSON資料在傳輸資料流上。這比每次點擊後產生新HTML由伺服器載入更吸引人,讓使用者有和本機一樣的體驗與效率。

以下為範例使用的CSS設定:

body { font-family: Helvetica, Arial}
.folders { background-color: #bbb; list-style-type: none; padding: 0; margin: 0; border-radius: 7px; 
background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #d6d6d6), color-stop(0.4, #c0c0c0), color-stop(1,#a4a4a4)); 
margin: 10px 0 16px 0;
font-size: 0px;
}
.folders li:hover { background-color: #ddd; }    
.folders li:first-child { border-left: none; border-radius: 7px 0 0 7px; }
.folders li { font-size: 16px; font-weight: bold; display: inline-block; padding: 0.5em 1.5em; cursor: pointer; color: #444; text-shadow: #f7f7f7 0 1px 1px; border-left: 1px solid #ddd; border-right: 1px solid #888; }
.folders li { *display: inline !important; } /* IE7 only */
.folders .selected { background-color: #444 !important; color: white; text-shadow:none; border-right-color: #aaa; border-left: none; box-shadow:inset 1px 2px 6px #070707; }    

.mails { width: 100%; table-layout:fixed; border-spacing: 0; }
.mails thead { background-color: #bbb; font-weight: bold; color: #444; text-shadow: #f7f7f7 0 1px 1px; }
.mails tbody tr:hover { cursor: pointer; background-color: #68c !important; color: White; }
.mails th, .mails td { text-align:left; padding: 0.4em 0.3em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mails th { border-left: 1px solid #ddd; border-right: 1px solid #888; padding: 0.4em 0 0.3em 0.7em; }    
.mails th:nth-child(1), .mails td:nth-child(1) { width: 20%; }
.mails th:nth-child(2), .mails td:nth-child(2) { width: 15%; }
.mails th:nth-child(3), .mails td:nth-child(3) { width: 45%; }
.mails th:nth-child(4), .mails td:nth-child(4) { width: 15%; }
.mails th:last-child { border-right: none }
.mails tr:nth-child(even) { background-color: #EEE; }
  
.viewMail .mailInfo { background-color: #dae0e8; padding: 1em 1em 0.5em 1.25em; border-radius: 1em; }
.viewMail .mailInfo h1 { margin-top: 0.2em; font-size: 130%; }
.viewMail .mailInfo label { color: #777; font-weight: bold; min-width: 2.75em; text-align:right; display: inline-block; }
.viewMail .message { padding: 0 1.25em; }

沒有留言:

張貼留言

感謝您的留言,如果我的文章你喜歡或對你有幫助,按個「讚」或「分享」它,我會很高興的。