TaskTracker 移动端离线 Web 应用,使用 HTML5/jQuery/KnockoutJS
Task Tracker 是一个使用 HTML5、CSS3 和 JavaScript 开发的离线 Web 应用程序。
引言
您在火车上通勤时,正在 iPhone 上观看一个有趣的 YouTube 视频,这时网络中断了,您无法继续观看视频。这让您感到沮丧。这就是典型的基于 Web 的应用程序的局限性,因为它们需要互联网连接才能无缝地提供应用程序。但是,有了 HTML5,现在可以开发离线 Web 应用程序,在互联网连接丢失的情况下仍能无缝运行。这是因为 HTML5 具有应用程序缓存和 Web 存储功能。通过 Web 存储,我们现在可以在客户端存储多达 5 MB 的数据。这是一个相当不错的大小,可以在互联网丢失的情况下缓存客户端数据,用户将能够继续工作,并在互联网连接恢复后与服务器同步。本文的目的是演示这些功能。TaskTracker 是一个针对移动平台开发的离线 Web 应用程序,可以帮助用户跟踪他们待办的任务。此外,他们还可以维护和管理他们的联系人。我还将解释如何利用 HTML5 和 jQuery/jQuery-UI/jQuery-Validation 和 Knockoutjs 等开源框架的强大功能来构建此应用程序。
本应用程序使用了 HTML5 的应用程序缓存和 Web 存储功能。
通过 jQuery,我们可以轻松地操作 DOM 元素,例如在用户界面中动态地附加事件、添加/删除或切换 CSS 类,并且代码简洁易懂,最重要的是它兼容所有浏览器!因此,我们可以专注于应用程序的业务逻辑,而不必担心这些问题。
jQuery-UI 插件能够通过几行代码将标准的 HTML 标记元素转换为优雅的 GUI 小部件。此外,它还提供了各种 主题,因此我们不必过多担心构建自己的 CSS 样式。
jQuery-Validation 插件提供了所有功能来处理客户端验证。
最后,Knockoutjs 是一个出色的框架,它使我们能够使用 Model-View-ViewModel (MVVM) 设计模式来构建应用程序。这是一种非常强大的设计模式,在 WPF 和 Silverlight 开发的应用程序中非常流行。Knockoutjs 提供了一个强大的数据绑定框架,用于 JavaScript 编程。有一个优秀的 教程 可以帮助您熟悉这个框架。
背景
几个月前,我观看了一个关于 HTML5 功能的有趣 视频,对其强大的功能印象深刻。HTML5 的功能,结合 CSS3 和 JavaScript 的强大功能,可以用于构建移动平台的 Web 应用程序,然后可以使用 PhoneGap 等开源框架将其转换为不同移动平台的原生移动应用程序。这样我们就可以编写一次,部署到多个平台。我决定开发一个 TaskTracker 应用程序来探索这些功能。我将在下面的章节中解释此应用程序中使用的不同框架的各种功能。您可以在此处查看正在运行的 演示。
设计概述
该应用程序采用流行的 MVVM 设计模式构建。MVVM 是一种与旧的 MVC 设计模式非常相似的设计模式,并且解决了关注点分离的问题。像 MVC 一样,它有一个模型(Model)、视图(View),但控制器(Controller)的角色由 ViewModel 承担。ViewModel 负责处理用户交互,并与模型交互以更新视图。它除了模型之外还可以拥有 View 特有的附加属性。此外,在由多个视图组成的复杂 Web 应用程序中,每个视图都可以有一个相应的 ViewModel。在我们的应用程序中,只有一个视图和一个 ViewModel。应用程序的关键文件是 *main.html*、*taskcontroller.js* 和 *storageController.js*。应用程序的类图如图 1 所示。
从图 1 可以看出,`TaskControllerUI` 类是一个概念类,代表应用程序的用户界面,并以纯 HTML 实现。`TaskViewModel` 是一个关键类,负责处理所有用户交互并更新视图,以及与模型进行交互。`Customer` 和 `Task` 是两个实体类,它们充当此应用程序的模型。`StorageController` 是一个包装类,负责从 WebStorage 保存和检索数据。
由于 JavaScript 不是像 C++、C# 或 Java 这样的传统面向对象编程语言,它没有 `Class` 构造。所以所有类都以不同的方式实现。我将首先解释业务层实现,然后是数据层,最后是 UI。
业务层
业务层包括两个关键实体:`Task` 和 `Customer`。另一个重要的类是 `TaskViewModel`。下面的部分显示了 `Task` 和 `Customer` 的实现细节。
// Declare Task class
function Task() {
var self = this;
self.name= ko.observable("");
self.id = 1,
self.description = ko.observable(""),
self.startDate = ko.observable($.datepicker.formatDate('yy-mm-dd',new Date())),
self.visitDate = ko.observable($.datepicker.formatDate('yy-mm-dd',new Date())),
self.visitTime = ko.observable("9:00 am"),
self.price = ko.observable(0),
self.status = ko.observable("Pending"),
self.custid = ko.observable();
self.notes = ko.observable("");
}
// Declare Customer Class
function Customer() {
var self = this;
self.custid = ko.observable(101);
self.firstname = ko.observable("");
self.lastname = ko.observable("");
self.address1 = ko.observable("");
self.address2 = ko.observable("");
self.city = ko.observable("");
self.country = ko.observable("");
self.zip = ko.observable("");
self.phone = ko.observable("");
self.mobile = ko.observable("");
self.email = ko.observable("");
}
// example of creating new instance of these classes
var task = new Task();
var cust = new Customer();
// Important point to remember knockout observables are methods.
var fname= cust.firstname(); // see we access the property as function.
// set the property.
cust.firstname('Joe'); // setting the observable property.
如果您查看上面的代码,您可能会注意到这两个类都只包含属性,因为它们是实体类,并且不暴露任何方法。但您也可能注意到这些属性是用 Knockout 构造初始化的。这些属性被声明为 `observables`。这将使这些属性能够绑定到 UI 并提供双向绑定。因此,在 UI 中对这些属性所做的任何更改都将自动更新这些属性。或者,如果您以编程方式更改它们的值,它将反映这些更改到 UI 中。需要记住的一点是,Knockout observables 是函数,您不能将它们作为普通属性访问或初始化。请参见上面显示设置和访问 Knockout observable 属性的示例代码。
现在让我们看看 `TaskViewModel` 的实现细节。
function TaskViewModel() {
var self =this;
self.lastID = 1000;
self.custID = 100;
self.taskCustomer = ko.observable();
self.currentCustomer = ko.observable(new Customer());
self.customerSelected = -1;
self.customers = ko.observable([]);
self.normalCustomers = [];
self.selected = ko.observable();
self.taskMenu = ko.observable("Add Task");
self.customerMenu = ko.observable("Add Customer");
self.editFlag = false;
self.editCustFlag = false;
self.tasks = ko.observableArray([]);
self.normalTasks = [];
self.currentTask = ko.observable(new Task());
self.taskOptions = ['Pending', 'In Progress', 'Complete'],
self.filterCustomer = ko.observable("");
self.filterDate = ko.observable("");
//Filter tasks by visit date
self.ftasks = ko.dependentObservable(function () {
var filter = self.filterDate();
if (!filter) {
return self.tasks();
}
return ko.utils.arrayFilter(self.tasks(), function (item) {
if ($.isFunction(item.visitDate)) {
var val1 = item.visitDate();
var val2 = self.filterDate();
if (item.status() === 'Complete')
return false;
return (val1 === val2);
} else {
return self.tasks();
}
});
}, self);
// Methods for managing the tasks.
// Init tasks
self.init = function () {
var sLastID = tsk_ns.storage.getItem("lastTaskID");
if (sLastID !== null) {
self.lastID = parseInt(sLastID);
}
// Read tasks.
var sTasks = tsk_ns.storage.getItem("tasks");
// alert(sTasks);
if (sTasks !== null) {
self.normalTasks = JSON.parse(sTasks);
self.updateTaskArray();
self.currentTask(new Task());
// this.tasks(ko.observableArray(ntasks));
//this.tasks(ntasks);
} else {
alert("No Tasks in storage");
}
},
// Add task
self.addTask = function () {
$('#taskForm').validate().form();
var isvalid = $('#taskForm').valid();
if (isvalid) {
if (!self.editFlag) {
self.lastID += 1;
self.currentTask().id = self.lastID;
self.tasks.push(self.currentTask);
tsk_ns.storage.saveItem("lastTaskID", self.lastID.toString());
} else {
self.currentTask().custid = self.taskCustomer().custid;
//self.selected(self.currentTask);
}
// Save last task ID
//self.tasks(self.normalTasks);
// save tasks
var mTasks = ko.toJSON(self.tasks);
console.log(mTasks);
tsk_ns.storage.saveItem("tasks", mTasks);
self.normalTasks = JSON.parse(mTasks);
self.updateTaskArray();
// current taks
self.taskMenu("Add Task");
self.currentTask(new Task());
self.editFlag = false;
console.log("No of tasks are :" + self.tasks().length);
}
};
// Remove tasks.
self.removeTask = function (itask) {
self.tasks.remove(itask);
var mTasks = ko.toJSON(self.tasks);
self.normalTasks = JSON.parse(mTasks);
tsk_ns.storage.saveItem("tasks", mTasks);
self.updateTaskArray();
};
// Edit task
self.editTask = function (itask) {
self.selected(itask);
// var index=ko.utils.arrayIndexOf(self.tasks, self.selected);
// Get Current customer
var curCust = self.getCurrentCustomer(itask.custid);
if (curCust !== null) {
self.taskCustomer(curCust);
}
self.currentTask(itask);
self.taskMenu("Edit Task");
$("#tabs").tabs("option", "selected", 2);
self.editFlag = true;
//$("#taskNew").button("option", "disabled", true);
//$("#taskUpdate").button("option", "disabled", false);
};
self.addNotes = function (itask) {
var curCust = self.getCurrentCustomer(itask.custid);
if (curCust !== null) {
self.taskCustomer(curCust);
}
self.currentTask(itask);
$("#dlgNotes").dialog("open");
self.editFlag = true;
};
// Update task
self.updateTask = function () {
console.log("Select task index is " + this.selected);
console.log(self.taskCustomer().custid);
// self.currentTask().custid = self.taskCustomer.custid;
var normalTsk = ko.toJSON(self.currentTask());
// self.currentTask().visitDate(normalTsk.visitDate);
var tskObject = JSON.parse(normalTsk);
// var stdate = $("#taskStartDate").datepicker(
tskObject.custid = self.taskCustomer().custid;
console.log(tskObject.custid);
if (this.selected > -1)
this.normalTasks[this.selected] = tskObject;
};
self.getCurrentCustomer = function (cid) {
for (var i = 0; i < self.normalCustomers.length; i++) {
var c1 = self.normalCustomers[i];
if (c1.custid === cid)
return c1;
}
return null;
}
// copy tasks.
self.updateTaskArray = function () {
self.tasks.removeAll();
// self.tasks(self.normalTasks);
for (var i = 0; i < self.normalTasks.length; i++) {
var ctask = self.normalTasks[i];
// var ctask = JSON.parse(self.normalTasks[i]);
var t = new Task();
t.id = ctask.id;
t.name(ctask.name);
t.description(ctask.description);
t.startDate(ctask.startDate);
t.visitDate(ctask.visitDate);
t.visitTime(ctask.visitTime);
t.price(ctask.price);
t.status(ctask.status);
t.custid = ctask.custid;
t.notes(ctask.notes);
self.tasks.push(t);
// console.log("Task name is " + ctask.name);
};
console.log("No of tasks are :" + self.tasks().length);
};
self.toggleScroll= function() {
$("#taskcontent").toggleClass("scrollingon");
};
}
`TaskViewModel` 是此应用程序的核心类,负责处理用户交互,并与模型交互以更新视图。上面的代码不是完整的源代码,只是突出显示了关键方法。ViewModel 拥有两个用于 Tasks 和 Customers 的*observable arrays*。另外请注意使用*dependent observable*按访问日期过滤待办任务。有关完整的实现细节,请参阅源代码。
数据层
数据层由 `StorageController` 类实现。其实现细节如下:
// Store Manager Singleton class
// Implemented using revealing module design pattern
var tsk_ns = tsk_ns || {};
tsk_ns.storage = function () {
// create instance of local storage
var local = window.localStorage,
// Save to local storage
saveItem = function (key, item) {
local.setItem(key, item);
},
getItem = function (key) {
return local.getItem(key);
},
hasLocalStorage = function () {
return ('localStorage' in window && window['localStorage'] != null);
};
// public members
return {
saveItem: saveItem,
getItem: getItem,
hasLocalStorage: hasLocalStorage
};
} ();
上述类只是 `window.localStorage` 的一个包装器,并提供了一个包装方法来从本地存储中保存和获取数据。该类有一个 `hasLocalStorage` 方法,用于检查浏览器是否支持 localStorage。
UI 层
UI 层以纯 HTML 文件实现。
所有文档加载完成后,它将执行以下初始化代码。该代码定义了验证规则、jQueryUI 初始化代码以及 ViewModel 与 UI 的绑定。
// Document ready
$(function () {
// Define Validation rules for customer form
$('#customerForm').validate({
rules: {
firstname: "required",
lastname: "required",
//compound rule
email: {
email: true
}
}
});
// Define validation options for taskform
$('#taskForm').validate({
rules: {
taskname: "required",
startdate: {
dateITA: true
},
visitdate: {
dateITA: true
},
visittime: {
time12h: true
},
price: {
number: true,
min: 0
}
},
messages: {
startdate: {
dateITA: "Invalid Date! Enter date in (dd/mm/yyyyy) format."
},
visitdate:{
dateITA: "Invalid Date! Enter date in (dd/mm/yyyyy) format."
},
}
});
// Initialise GUI widgets.
$("#tabs").tabs();
$("#taskNew,#taskSave,#taskUpdate").button();
$("#taskNew").button("option", "disabled", false);
$("#taskUpdate").button("option", "disabled", true);
//Create instance of view model and bind to UI.
var viewModel = new TaskViewModel();
viewModel.init();
viewModel.initCustomers();
ko.applyBindings(viewModel);
// Initialise Dialog.
$("#dlgNotes").dialog({
autoOpen: false,
modal: true,
buttons: {
Ok: function () {
$(this).dialog("close");
viewModel.addTask();
}
}
});
});
我使用了 KnockoutJS 的不同类型的绑定,例如 `for-each`、`with`、`form` 和 `custom` 绑定功能。有关完整详细信息,请参阅源代码。下面是 `Customer` 自定义绑定的代码。
ko.bindingHandlers.customerFromID = {
init: function (element, valueAccessor, allBindingsAccessor) {
var options = allBindingsAccessor().viewmodelOptions || {};
var custid = valueAccessor();
var vm = options.ViewModel;
var sfield = options.showField;
var curCust = vm.getCurrentCustomer(custid);
if (curCust != null) {
if(sfield===1)
$(element).text(curCust.firstname + '_' + curCust.lastname);
else if(sfield===2)
$(element).text(curCust.phone);
else if(sfield==3)
$(element).text(curCust.mobile);
}
else
$(element).text('');
}
}
在待办任务列表表中,我希望显示 `Customer` 的详细信息,例如客户姓名、电话和手机。由于每一行都绑定到一个 `Task` 对象,该对象有一个 `customerID` 作为其属性之一,因此我必须使用 KnockoutJS 的自定义绑定功能来从 `customerID` 中提取客户详细信息。有关详细信息,请参阅上面的代码。
配置离线工作
使用 HTML5,我们现在可以配置应用程序应该离线提供的页面、资产和资源。这可以通过定义应用程序清单文件来实现,该文件是一个纯文本文件。此外,在 `html` 标签中,您需要定义 `manifest` 属性来指定清单文件的名称。`<html manifest="manfiest.appcache">` 我添加了扩展名为 `.appcache` 的清单文件。但您可以使用任何扩展名。我们需要配置 Web 服务器以包含此新扩展名,并将其 MIME 类型指定为 `text/cache-manifest`。请参阅下面的代码查看清单文件的内容。
请记住,清单文件中的第一行应该是 `CACHE MANIFEST`。不要在此行之前添加任何注释。清单文件的结构很简单。它有三个部分:`CACHE`、`NETWORK` 和 `FALLBACK`。所有离线使用的页面和资源都需要在 `CACHE` 部分指定。仅在线需要的页面/资源在 `NETWORK` 部分。在 `FALLBACK` 部分,您可以指定回退时的备用页面。就是这样!
支持应用程序缓存的浏览器将首先在客户端保存所有离线资源,然后无论您是在线还是离线,它将始终从应用程序缓存中提供这些资源。因此,对这些页面所做的任何更改仅在修改清单文件后才会生效。这一点很重要,需要记住。修改清单文件并更新版本以告知浏览器存在某些更改,以便它从服务器获取最新更改并再次将其保存在客户端。
//contentmanifest.appcache
CACHE MANIFEST
## version 1.6
CACHE:
themes/le-frog/jquery-ui.css
themes/le-frog/jquery-ui-1.8.17.custom.css
css/tasktracker.css
scripts/jquery-1.7.1.min.js
scripts/json2.js
scripts/jquery-ui-1.8.17.custom.min.js
scripts/jquery-ui-timepicker-addon.js
scripts/knockout-latest.js
scripts/ko-protected-observable.js
scripts/StorageManager.js
scripts/TaskController.js
scripts/jquery.validate.min.js
scripts/additional-methods.js
images/app-note-icon.png
images/Actions-document-edit-icon.png
images/edit-trash-icon.png
NETWORK:
# Other pages and resources only to be served online will come here
FALLBACK:
# Any alternate pages in case of fallback will come here.
移动端样式
我无需进行大量更改即可为此应用程序配置移动端。您需要做的就是在我 HTML 文件的 `head` 部分指定 `viewport` 设置。我不得不为 `textarea` 标签定义一个额外的样式,因为它的宽度超出了其父容器。有关详细信息,请参阅下面的代码。
// <meta name="viewport" content="width=device-width, initial-scale=1">
// styling for textarea
.fixwidth
{
width:80%;
}
关注点
最初,我将 `Task` 类中的 `startdate` 和 `visitdate` 属性声明为 `Date` 类型。但在保存和检索数据时遇到了问题。在 DatePicker 控件中显示了不正确的日期。因此,我修改了代码,将其保存为字符串。
由于该应用程序没有服务器端处理,因此在保存 `Task` 或 `Customer` 详细信息之前,使用以下代码触发客户端验证。
// Validation for Task Entry form
$('#taskForm').validate().form();
var isvalid = $('#taskForm').valid();
// Validation for customer entry form.
$("#customerForm").validate().form();
var custValid= $("#customerForm").valid();
此外,在 iPhone 或 Android 手机上,由于屏幕宽度限制,我无法在待办任务列表表中显示所有列,因为它超出了其容器。我尝试使用 CSS 样式来显示滚动条。在桌面浏览器中效果很好,但在智能手机上不行。因此,我在待办任务列表表的 `tr` 标签的 `click` 事件上使用了以下代码。用户可以通过点击标题来折叠或展开行。这是使用 jQuery 的 `toggleClass` 方法实现的。
// toggleScroll method of TaskController class
self.toggleScroll= function() {
$("#taskcontent").toggleClass("scrollingon");
};
Android 手机/iPhone/iPad 用户
Android 用户可以在此处下载原生应用。iPhone 和 iPad 用户可以此处访问演示应用程序。他们可以将其添加书签。这样无论是在线还是离线,它都能正常工作。
历史
这是 TaskTracker 的第一个版本。在未来的版本中,我考虑将任务和客户详细信息保存在服务器端数据库中。这样,当连接到互联网时,应用程序就可以与数据库同步。这样,所有数据都可以从任何地方访问,无论是移动设备还是桌面浏览器。而此版本则不然,数据存储在客户端浏览器中。
结论
由于 HTML5 的新功能,现在可以轻松开发离线 Web 应用程序。该应用程序探讨了 HTML5、jQuery、jQuery-UI、jQuery-Validation 和 KnockoutJs 的关键功能。此外,我们还可以使用 PhoneGap 等开源框架将其转换为不同移动平台的原生应用程序。如果您对实现有任何疑问或需要澄清,请告诉我您的评论和建议。您可以通过 电子邮件 与我联系。