0%

桌面應用 NW.js、Vue.js and localStorage

這裡我們會用 NW.js 與 Vue.js 開發一套可離線應用的桌面應用系統,資料將會存在客戶端的 localStorege 中,因此可不需要其它的資料庫,最主要還是在 Vue.js 的運用。你可將此移植到 Web 上,完全不需要修改。要在 NW.js 上運作我們只需要改變一些 NW.js 的啟動設定。

下面圖示將會是完成後的樣子,程式碼都很短,但幾乎包括了 Vue.js 的主要功能,也包括了資料的查詢、新增、修改與刪除。

最上面的輸入區可輸入 “待辦事項”, 按下 Enter 鍵後會將資料存儲到本機端,然後顯示在下方的待辦事項條列區。條列區上方有一個切換複選框(checkbox),可以將所有的待辦事項標記為已完成,或取消所有待辦事項已完成標記。

每一待辦事項也有專屬的切換複選框標記為已完成,或取消已完成標記。雙擊待辦事項的字串,則可修改待辦事項內容,按下 Enter 鍵或離開修改區域會自動存儲,也可以按 Esc 鍵取消修改。最右邊則有刪除鍵,可刪除該筆待辦事項。

到此,新增、修改與刪除都包含了,這些資料的異動存儲與 UI 介面的互動都是使用 Vue.js,其實跟 NW.js 一點關係都沒有。

條列區下方,左邊有尚未完成的筆數訊息,右邊則有三個查詢選項,可選擇查詢所有的待辦事項、尚未完成與已經完成的待辦事項。我們將會用到客戶端路由(Client-Side Routing)的技術。最右邊的 Clear completed 選項則只有在有標記已完成的事項時才會顯示出來,這可以刪除所有已完成的待辦事項。雖然這裡也可以用幾個按鍵(button)解決,但我想傳達的是,甚麼是客戶端路由(Client-Side Routing)的概念,這在 SPA Web 上是很重要的基礎關鍵。Vue.js 有它自己的路由管理器 Vue Router,但在這個範例,我們不需要那麼複雜的功能。

我們要建立一個可離線操作的桌面應用,使用較簡單的 NW.js,因此要確定你已安裝 NW.js。這個範例也可直接移植到 Web Server 上,例如, Node.js Express, 不用修改程式碼。

建立專案

建立一個專案目錄 todo-nwjs,然後使用 npm 初始化專案。

init
1
2
cd todo-nwjs
npm init -y

這個專案除了 NW.js 外,另外會用到 3 個套件,可以使用 npm 安裝。NW.js 則在之前我們已安裝為 global 套件,因此所有的專案都可以共享。

install
1
2
3
npm install vue --save
npm install bootstrap --save
npm install director --save

Vue.js 是 SPA 的框架,Bootstrap 則是 CSS 框架,director 我們會用到它的客戶端路由(Client-Side Routing)。Vue.js 本身也有路由管理器 Vue Router,但我們這裡的路由較簡單,不必用到那麼複雜的路由管理器。

首先我們從修改專案的設定 package.json 開始,這是這個專案唯一與 NW.js 有關的地方,其它的程是碼等都不會看到有 NW.js 的影子。

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "todo-nwjs",
"version": "1.0.0",
"description": "",
"main": "app/index.html",
"window": {
"width": 600,
"height": 450
},
"scripts": {
"start": "nw .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bootstrap": "^4.3.1",
"director": "^1.2.8",
"vue": "^2.6.10"
}
}

main 是專案的入口點,我們會放在專案根目錄下的子目錄 app,index.html 是一般的 HTML 文件,這是此範例的起始文件。window 屬性則設定 NW.js 起始的視窗大小,start 屬性可讓我們在開發專案時使用 npm start 啟動此應用系統,這是唯一與 NW.js 有關的地方,編寫程式碼時,你可以把它忘了。

我們先看 index.html 初始的樣子。

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Todo List</title>
<link rel="stylesheet" href="../node_modules/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="stylesheets/app.css" />
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div id="todoapp" class="container">
<h1 class="text-center">Todos</h1>
</div>
<script src="../node_modules/director/build/director.min.js"></script>
<script src="../node_modules/vue/dist/vue.min.js"></script>
<script src="javascripts/store.js"></script>
<script src="javascripts/app.js"></script>
<script src="javascripts/routes.js"></script>
</body>
</html>

在 NW.js 中可以直接從 node_modules 目錄中引用這些套件,但如果用的是 Web Server 通常都會有限制,你必須把用戶端所需要的資源放在靜態檔案(static files)服務區,而且如果最後使用 nwbuild 打包程式碼時會將整個 node_modules 目錄打包進來,這不是我們要的,我們只需要幾個模組,最好將它們複製到 app 目錄下分類規劃的子目錄。

index.html
1
2
3
4
<link rel="stylesheet" href="stylesheets/bootstrap.min.css" />

<script src="javascripts/director.min.js"></script>
<script src="javascripts/vue.min.js"></script>

index.html 中我們還定義了 [v-cloak] { display: none },這個指令可以隱藏未編譯的 Mustache 標籤直到 Vue.js 的實例準備完畢,這可避免 HTML 繫結 Vue 例項,在頁面載入時會閃爍的狀況。

除了這 3 個套件的程式庫外,我們的程式碼將分別放在 app 目錄下的子目錄 javascripts 中。

  • store.js 專門處理 localStorage 的存儲。
  • app.js 是 Vue.js 的主程式。
  • routes.js 則負責設定客戶端路由(Client-Side Routing)。

這些程式碼是有順序的,在 HTML 檔輸入 javascripts 有些都需注意其前後順序關係。

stylesheets 目錄下的 app.css 在補足 bootstrap CSS 框架不足的地方,最主要的在第三行的 text-decoration 的 line-through,這可讓已完成的待辦事項上有一條刪除橫槓。

app.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
li.completed label {
color: #d9d9d9;
text-decoration: line-through;
}
li a.active {
font-weight: bold;
color: #006699;
}
.info {
margin: 65px auto 0;
color: #bfbfbf;
font-size: 10px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
text-align: center;
}
.info p {
line-height: 1;
}

其它的 javascripts 文件,我們先從 store.js 看起。

Mobile-first 與 offline-first 的 Web 應用最主要的考量應該是網路。在現今多樣化的移動應用裝置,並不像我們在公司內部的應用,網路都是穩定的。所以現今的應用系統首先必須考慮的應該是,當沒有網路的時候,我的應用系統該如何運作? 除了原生的應用與桌面應用,Web 首先當然必須是 PWA 架構,再來就必須考慮資料的存儲問題。沒有網路你無法將資料存儲到遠端的資料庫,所以本機端的存儲是必要的。所有的存儲都必須先存入本機端,再透過訊息佇列(Message Queue)的概念送到遠端的資料庫,這樣應用系統才不會因網路的因素而影響數據的存儲。

近些年來流覽器端的數據存儲可選方案有長足的進展,之前我們在 JavaScript 教育訓練中曾經使用過 PouchDB,它可以在應用程序離線時將數據存儲在本地端,然後在應用程序重新連線時與伺服器端的 CouchDB 或可兼容的服務伺服器同步,無論使用者下次登錄何處,都能保持用戶數據同步。其它可選的本地端存儲數據方案還有很多,例如, IndexedDBLovefieldSQLiteNeDBLevelDBMinimongo,可選的方案很多,致於要使用哪一種? 這取決於你要存儲的數據類型、數據大小以及查詢數據的方式。

這個範例的數據不多,資料類型也不複雜,直接使用瀏覽器的原生功能 localStorage 來實做 API。使用了 JSON.stringify( ) 和 JSON.parse( ) 方法來序列化數據的存儲和讀取數據。

store.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(function(exports) {
"use strict";

const STORAGE_KEY = "todos-vuejs";

exports.todoStorage = {
fetch: function() {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
},
save: function(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
};
})(window);

這裡使用 IIFE 來模組化 JavaScript 程式碼,這段程式碼會產生一個 window.todoStorage 物件,裡面就只包含兩個方法 fetch( ) 與 save( ),因為 localStorage 只能存儲字符串,所以這裡必須使用 JSON.stringify 與 JSON.parse 作序列化。

接下來看 route.js,這是一個簡單的客戶端路由(Client-Side Routing)應用,使用 director 套件。

route.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(function(app, Router) {
"use strict";

const router = new Router();

["all", "active", "completed"].forEach(function(visibility) {
router.on(visibility, function() {
app.visibility = visibility;
});
});

router.configure({
notfound: function() {
window.location.hash = "";
app.visibility = "all";
}
});

router.init();
})(app, Router);

IIFE 中加入兩個引數,app 是 Vue 的實例,Router 則是 director 的建構式函數,所以這段程式碼必須運作在導入 director.js 及產生 Vue 的實例之後,這裡 Vue 實例的變數名稱是 app。所以把它放在 index.html 最後加入的 JavaScripts 文件。

這裡只使用 director.js 簡單的客戶端路由功能,forEach( ) 註冊了 3 個路由監聽器,分別監聽 3 個客戶端路由 all、active 與 completed,客戶端路由與伺服器端路由不同,使用的是 window.location.hash。當在 index.html 中的 <a href=”#/all”>All</a> 錨點與超連結被按下時就會觸發 “all” 監聽器。這 3 個路由的監聽器處理器函數會改變 Vue 實例的資料 visibility,visibility 的變動則會觸發 Vue 實例的 computed 的方法 filteredTodos,這我們馬上會在接下來的 app.js 中看到。

最後就是我們的重點 app.js 程式碼,最主要的是 Vue.js 的操作。重點著重在 Vue.js 的應用與時機,不會講解太多的 Vue.js 基本操作,目前有專業的 Vue.js 教育訓練課程進行中,請踴躍參與。

app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
(function(exports) {
"use strict";

const filters = {
all: function(todos) {
return todos;
},
active: function(todos) {
return todos.filter(function(todo) {
return !todo.completed;
});
},
completed: function(todos) {
return todos.filter(function(todo) {
return todo.completed;
});
}
};

exports.app = new Vue({
data: {
todos: [],
newTodo: "",
editedTodo: null,
visibility: "all"
},
watch: {
todos: {
deep: true,
handler: todoStorage.save
}
},
computed: {
filteredTodos: function() {
return filters[this.visibility](this.todos);
},
remaining: function() {
return filters.active(this.todos).length;
},
allDone: {
get: function() {
return this.remaining === 0;
},
set: function(value) {
this.todos.forEach(function(todo) {
todo.completed = value;
});
}
}
},
methods: {
pluralize: function(word, count) {
return word + (count === 1 ? "" : "s");
},
addTodo: function() {
var value = this.newTodo && this.newTodo.trim();
if (!value) {
return;
}
this.todos.push({
id: this.todos.length + 1,
title: value,
completed: false
});
this.newTodo = "";
},
removeTodo: function(todo) {
var index = this.todos.indexOf(todo);
this.todos.splice(index, 1);
},
editTodo: function(todo) {
this.beforeEditCache = todo.title;
this.editedTodo = todo;
},
doneEdit: function(todo) {
if (!this.editedTodo) {
return;
}
this.editedTodo = null;
todo.title = todo.title.trim();
if (!todo.title) {
this.removeTodo(todo);
}
},
cancelEdit: function(todo) {
this.editedTodo = null;
todo.title = this.beforeEditCache;
},
removeCompleted: function() {
this.todos = filters.active(this.todos);
}
},
directives: {
"todo-focus": function(el, binding) {
if (binding.value) {
el.focus();
}
}
},
created() {
this.todos = todoStorage.fetch();
}
}).$mount("#todoapp");
})(window);

開頭定義了三個客戶端路由對映的處理器函數,會依據不同的路由選擇返回不同條件的數據。

接著是 Vue.js 實例,watch 屬性監聽 Vue 實例的資料屬性(data)的變化,這理監聽的是 todos 屬性,當 todos 有異動就會觸發 watch 處理器(handler) todoStorage.save, 將資料存儲到 localStorage。這裡有一個 deep 屬性,預設值是 false,雖然會監聽 data 中屬性的變化, 但如果是一個物件,它只會監聽物件引用值的變化,當屬性為 true 時它才會監聽物件的內容變動,在此範例,這才是我們所要的。

接下來是幾個 computed 屬性,在 Vue 中,computed 的屬性可以被視為像是 data 一樣,可以讀取和設值,因此在 computed 中可以分成 getter(讀取)和 setter(設值),在沒有寫 setter 的情況下,computed 預設只有 getter ,也就是只能讀取,不能改變設值。filteredTodos 與 remaining 就是只有預設的 getter; allDone 則有 getter 與 setter,當在 HTML Template 中單向綁定 { { allDone } } 時會觸發 getter,使用 v-model=”allDone” 雙向綁定時則會觸發 getter 與 setter。這裡的 getter 會返回一個布爾值,setter 會依據傳入的引數改變所有待辦項目的 todo.completed 屬性。它會用在待辦項目條列區上端的 checkbox 元素上,它的值不是 true 就是 false,當值改變時則會觸發 setter。

methods 屬性則都是 todos 的新增、修改與刪除函數,其中要注意的是資料的修改(Update)分為開始修改 editTodo( ) 與結束修改 doneEdit( )。在 editTodo 函數中我們新增了一個屬性 beforeEditCache 用來存儲修改之前的待辦事項字符串(todo.title),這可以在當使用者捨棄修改時(cancelEdit)時回覆修改前的字符串(rollback),在這裡也就是當使用者在修改中途時按下 Esc 鍵。 而 editedTodo 屬性則可以判斷目前正在修改的待辦項目。

directives 是 Vue.js 提供的一種機制,可以將數據的變化映射為 DOM 行為,這理就是當我們對待辦事項雙擊修改時,將游標聚焦在該要被修改的項目上。指令(Directive)所定義的函數稱為鉤子函數(Hook Function),這裡所傳入的參數 el 是指令所綁定的元素,可用來直接操作 DOM,binding 是一個物件,包含一些屬性,其中 value 屬性绑定指令(Directive)的值,例如在此範例中,v-todo-focus=”todo == editedTodo” 中,binding.value 绑定值將會是 true 或者是 false,所以會將游標聚焦在被修改的項目(editedTodo)上。

created( ) 則是一個 Vue.js 的生命週期鉤子,可以用來在一個 Vue.js 實例被創建之後執行程式碼。這裡只是從存儲中抓取資料。

以下則是 index.html 文件:

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Todo List</title>
<link rel="stylesheet" href="stylesheets/bootstrap.min.css" />
<link rel="stylesheet" href="stylesheets/app.css" />
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div id="todoapp" class="container">
<h1 class="text-center">Todos</h1>
<!-- What needs to be done? -->
<input
type="text"
class="form-control"
style="font-size: 1.5em;"
autofocus
autocomplete="off"
placeholder="What needs to be done?"
v-model="newTodo"
@keyup.enter="addTodo"
/>
<div v-show="todos.length">
<!-- Mark all as completed -->
<ul class="list-group list-group-flush">
<li class="list-group-item mb-0 pb-0 border-bottom border-warning">
<input
id="toggle-all"
type="checkbox"
class="form-check-input"
v-model="allDone"
/>
<label for="toggle-all" class="text-primary mb-0"></label>
</li>
<!-- todos list -->
<li
class="list-group-item pb-0"
v-for="todo in filteredTodos"
:key="todo.id"
:class="{completed: todo.completed}"
>
<div v-show="todo !== editedTodo">
<input
type="checkbox"
class="form-check-input"
v-model="todo.completed"
/>
<label @dblclick="editTodo(todo)">{{ todo.title }}</label>
<button
class="btn text-danger float-right"
@click="removeTodo(todo)"
>
X
</button>
</div>
<!-- edit todo -->
<input
v-show="todo == editedTodo"
type="text"
class="form-control"
v-model="todo.title"
v-todo-focus="todo == editedTodo"
@blur="doneEdit(todo)"
@keyup.enter="doneEdit(todo)"
@keyup.esc="cancelEdit(todo)"
/>
</li>
</ul>
</div>
<!-- routes -->
<div v-show="todos.length" class="bg-light mt-2 pt-2 pb-2">
<div class="d-flex flex-row">
<h6 class="pt-2 mr-3">
<strong v-text="remaining"></strong>
{{ pluralize("item", remaining) }} left
</h6>
<ul class="nav justify-content-center mr-auto">
<li class="nav-item">
<a
href="#/all"
class="nav-link"
:class="{active: visibility == 'all'}"
>All</a
>
</li>
<li class="nav-item">
<a
href="#/active"
class="nav-link"
:class="{active: visibility == 'active'}"
>Active</a
>
</li>
<li class="nav-item">
<a
href="#/completed"
class="nav-link"
:class="{active: visibility == 'completed'}"
>Completed</a
>
</li>
</ul>
<button
class="btn btn-light btn-sm text-primary"
@click="removeCompleted"
v-show="todos.length > remaining"
>
Clear completed
</button>
</div>
</div>
</div>
<footer class="info">
<p>Double-click to edit a todo</p>
</footer>
<script src="javascripts/director.min.js"></script>
<script src="javascripts/vue.min.js"></script>
<script src="javascripts/store.js"></script>
<script src="javascripts/app.js"></script>
<script src="javascripts/routes.js"></script>
</body>
</html>

這裡的 index.html 就不詳細解說了,都是一些 HTML 與 Vue.js 的基本,可以對照 app.js 中的功能。

最後就用 NW.js 啟動應用程式:

1
npm start

試著輸入一些資料,關閉應用程式,再啟動,資料是否是關閉前的資料,這裡沒有使用任何資料庫,只用純 Web 技術建立了一個桌面應用系統。

最後可以將它打包成實際的桌面應用程式,如果我們直接在專案目錄下執行 nwbuild 打包工具,這將會連 node_modules 目錄都打包進去,我們所需要的程式碼與資源都在 app 目錄下,我們只需要打包 app 目錄就可以了,但是 app 目錄下還少一個 package.json 文件,這是 NW.js 啟動時必須的設定檔,得將它一起打包進來,否則無法啟動 NW.js,所以得從專案根目錄複製一份 package.json 到 app 目錄下,我們還需稍做修改:

"app/package.json"
1
2
3
4
5
6
7
8
9
10
{
"name": "todo-nwjs",
"version": "1.0.0",
"description": "",
"main": "index.html",
"window": {
"width": 600,
"height": 450
}
}

屬性 main 的路徑需要修改,因 index.html 目前與 package.json 同目錄,其它則只留下 window 屬性設定一些 NW.js 起始的設定。

現在可以打包程式碼了,從專案目錄下執行 nwbuild。

1
nwbuild app/ -o ./build -p win64

打包完成後你可以在專案目錄下找到 build/todo-nwjs/win64/todo-nwjs.exe,現在就可以直接執行此執行檔了。