0%

跨平台桌面應用開發 NW.js and Electron

Web 應用程式的跨平台運作特性,減低了一些應用開發的負擔。但是隨著多樣化的移動設備應用,Web 應用程式存在一些限制和挑戰,例如,

  • 網路不是一直可用的。當你在火車上或者在隧道裡的時候,就可能沒有網路,你的 Web 應用也會因此中斷,這對於使用者來說可能造成非常多的困擾。
  • Web 流覽器本身也都有一些安全策略,因此對於本機的軟硬體資源也都有限制。

基於這些理由,原生的桌面應用(Desktop)需求也有其存在的優勢,但是開發這些原生應用需要精通不同的技術,進入的門檻複雜。因此過去有類似 Apache Cordova 這類的開發平台,應用現有的 Web 開發技術來開發移動設備的原生應用。最近 的 PWA 架構,也在試圖使用 Web 技術,來取得像原生應用的優點。

JavaScript 生態圈則有 NW.js 與 Electron,試圖將現有的 Web 技術應用在桌面應用系統(Desktop Application)上,寫一份程式碼就可以在 Mac、Windows 及 Linux 上建置及執行。

NW.js 和 Electron

NW.js 是 Node.js 第一個桌面應用開發框架,但是近年來 Electron 的發展比較快速,風頭蓋過了 NW.js。兩者都是來自於同一個創造者,但是兩者在內部架構上採用不一樣的策略。以流行度與發展趨勢來看,Electron 似乎領先 NW.js。不過也有人更喜歡 NW.js,因為相對而言,NW.js 在代碼運行和應用加載方面比較簡單,而且它還支援像 Google 的 Chromebook 平台。

NW.js

簡單來說,NW.js 是一個框架,它支持用 HTML、CSS 和 JavaScript 來構建桌面應用(Desktop Application)。它整合 Node.js 與瀏覽器中的 WebKit 瀏覽器引擎,因此開始的命名為 node-webkit。

通過整合 Node.js 與 WebKit,不僅可以在應用視窗內載入 HTML、CSS 與 JavaScript 文件,還可以通過 JavaScript API 和作業系統進行互動。通過這個 JavaScript API 可以控制視窗的視覺元素,例如,視窗大小
、工具條以及菜單項目,而且還可以訪問本機文件系統與其它資源,這些都是 Web 應用無法做到的。

我們還是由 Hello Tainan 開始。

NW.js Hello Tainan

首先先要安裝 NW.js,開啟一個終端視窗,使用本機管理者身份將 nw 安裝成全局(global)套件,這樣每個專案就可以共享。

install NW.js
1
npm install -g nw

這個安裝會花一些時間!我大該花了 10 分鐘。

新建一個資料夾 hello-tainan-nwjs,我們要在此建立一個 NW.js 專案。開啟 VSCode 選擇專案資料夾,然後開啟一個終端視窗,初始化專案:

init
1
npm init -y

在專案目錄下新建一個子目錄 app,我們會將程式碼放在這個目錄中,在這個 app 目錄下新建一個 index.html 文件。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!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>Hello Tainan</title>
</head>
<body>
<h1>Hello Tainan!</h1>
<button onclick="sayHello()">Say Hello</button>
<script src="javascripts/main.js"></script>
</body>
</html>

在 app 目錄下再開一個子目錄 javascripts 來擺放 JavaScript 程式檔,在 javascripts 目錄下建立 main.js 程式檔,我們會將主要的程式碼放在這個文件中,目前裡面只是一個簡單的 sayHello( ) 函數。

app/javascripts/main.js
1
2
3
function sayHello () {
alert("Hello Tainan, 台南!");
}

修改專案目錄下的 package.json 的 main 屬性,將其值指向 “app/index.html”,這會是我們專案的入口點:

package.json
1
2
3
4
5
6
7
8
{
"name": "hello-tainan-nwjs",
"version": "1.0.0",
"description": "",
"main": "app/index.html",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},

從專案目錄下開啟終端視窗啟動 NW.js

1
nw .

你將會看到如下的樣子。

如果單擊螢幕上的 Say Hello 按鈕,會彈出一個寫著 “Hello Tainan, 台南!” 的警示視窗。

如果從 Chrome 流覽器直接打開 index.html 文件檔,也會看到同樣的界面,單擊按鈕也會看到同樣的結果。

這就是關鍵,代碼不需要修改,你就可以直接將網站的 HTML 頁面轉為 NW.js 開發的桌面應用。

我們來上一點妝扮,在專案目錄下開啟一個終端視窗安裝 bootstrap。

1
npm install bootstrap --save

從 node_modules/bootstrap/dist/css/ 將 bootstrap.min.css 複製到 app/stylesheets 目錄下。並將 bootstrap.min.css 加入 index.html,並在一些 DOM 元素加上一些 bootstrap 的妝扮。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
...
<link rel="stylesheet" href="stylesheets/bootstrap.min.css" />
</head>
<body>
<div class="container text-center">
<h1>Hello Tainan!</h1>
<button class="btn btn-primary" onclick="sayHello()">Say Hello</button>
</div>
<script src="javascripts/main.js"></script>
</body>
</html>

將 Vue.js 也加進來:

1
npm install vue --save

將 node_modules/vue/dist 下的 vue.min.js 複製到 app/javascripts 目錄下, 將 vue.min.js 加入 index.html,再來就需要修改 main.js 產生 Vue 實例,我們也會透過 REST API 從遠端抓取一些資料。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<body>
<div id="app" class="container text-center">
<h1>{{ hello }}</h1>
<button class="btn btn-primary" onclick="sayHello()">Say Hello</button>
<table class="table table-dark mt-3">
<tr v-for="e in employees">
<td>{{ e.empno }}</td>
<td>{{ e.ename }}</td>
<td>{{ formatDate(e.hiredate) }}</td>
<td>{{ e.sal }}</td>
<td>{{ e.comm }}</td>
<td>{{ e.deptno }}</td>
</tr>
</table>
</div>
<script src="javascripts/vue.min.js"></script>
<script src="javascripts/main.js"></script>
</body>

修改 main.js 讓它多作點事情:

app/javascripts/main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function sayHello () {
alert("Hello Tainan, 台南!");
}

const vm = new Vue({
data: {
hello: "Hello Tainan, 台南!",
employees: []
},
methods: {
formatDate(date) {
let newDate = new Date(date);
return newDate.toISOString().slice(0,10);
}
},
created() {
fetch("http://xxxxx.xxx.com.tw:8080/lxxx/xxxxdemo/employees/")
.then(r => r.json())
.then(result => {
this.employees = result.items
});
}
}).$mount("#app");

啟動 NW.js,另外也從瀏覽器直接打開 index.html 檔。

背景是從流覽器執行的畫面,前景則是 NW.js。兩者的程式碼都來自同一份,完全沒甚麼不同!

Electron

Electron 和 NW.js 的區別之一就是整合 Chromium 和 Node.js 的方式不同。NW.js 維護一個共享的 JavaScript 上下文,而 Electron 有多個獨立的 JavaScript 上下文,一個負責啟動運行的視窗 (main),另外一個負責具體的應用視窗 (renderer)。所以在啟動時也有很大的區別,NW.js 通常使用 HTML 作為入口文件,而 Electron 使用的是 JavaScript 文件。

Electron Hello Tainan

一樣要先安裝 Electron,開啟一個終端視窗,使用本機管理者身份:

install Electron
1
npm install -g electron

這也會花一些時間。

新建一個資料夾 hello-tainan-electron,我們要在此建立一個 Electron 專案。開啟 VSCode 選擇專案資料夾,然後開啟一個終端機視窗,初始化專案,除此之外,專案本身也需安裝 electron,這也會花一些時間:

init project
1
2
npm init -y
npm install electron --save

在專案目錄下新建一個子目錄 app,我們會將程式碼放在這個目錄中,在這個 app 目錄下新建一個 index.html 檔與 main.js 程式碼文件。在 app 目錄下另外開兩個子目錄 javascripts 與 stylesheets。

以下則是 app 目錄下的 index.html 與 main.js 文件檔。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!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>Hello Tainan</title>
</head>
<body>
<h1>Hello Tainan!</h1>
<button onclick="sayHello()">Say Hello</button>
<script src="javascripts/app.js"></script>
</body>
</html>
app/main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
const { app, BrowserWindow } = require('electron');

let mainWindow = null;

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

app.on('ready', () => {
mainWindow = new BrowserWindow();
mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.on('closed', () => mainWindow = null);
});

main.js 是 Electron 的入口點,使用 mainWindow.loadURL( ) 將 index.html 加載進來,這個 index.html 與 NW.js 的 index.html 內容是一樣的。

再來要加入我們自己的程式碼,新建一個 JavaScript 文件 app/javascripts/app.js 放我們的應用程式碼,目前只有一個函數 sayHello( )。

app/javascripts/app.js
1
2
3
function sayHello() {
alert("Hello Tainan, 台南!");
}

接下來一樣要修改專案目錄下的 package.json 的 main 屬性,更改專案的入口點,這與 NW.js 完全不同,是 JavaScript 文件:

package.json
1
2
3
4
5
6
7
8
{
"name": "hello-tainan-electron",
"version": "1.0.0",
"description": "",
"main": "app/main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},

從專案目錄下開啟終端視窗啟動 Electron

1
electron .

你將會看到如下的樣子。

如果單擊螢幕上的 Say Hello 按鈕,會彈出一個寫著 “Hello Tainan, 台南!” 的警示視窗。

如果從 Chrome 流覽器直接打開 index.html 文件檔,也會看到同樣的界面,單擊按鈕也會看到同樣的結果。

同樣,把 bootstrap 與 Vue.js 加進來。

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

分別將 node_modules/bootstrap/dist/css/bootstrap.min.css 與 node_modules/vue/dist/vue.min.js 複製到 app/stylesheets 與 app/javascripts 目錄

修改 app/index.html 與 app/javascripts/app.js:

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<body>
<div id="app" class="container text-center">
<h1>{{ hello }}</h1>
<button class="btn btn-primary" onclick="sayHello()">Say Hello</button>
<table class="table table-dark mt-3">
<tr v-for="e in employees">
<td>{{ e.empno }}</td>
<td>{{ e.ename }}</td>
<td>{{ formatDate(e.hiredate) }}</td>
<td>{{ e.sal }}</td>
<td>{{ e.comm }}</td>
<td>{{ e.deptno }}</td>
</tr>
</table>
</div>
<script src="javascripts/vue.min.js"></script>
<script src="javascripts/app.js"></script>
</body>
app/javascripts/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
function sayHello() {
alert("Hello Tainan, 台南!");
}

const vm = new Vue({
data: {
hello: "Hello Tainan, 台南!",
employees: []
},
methods: {
formatDate(date) {
let newDate = new Date(date);
return newDate.toISOString().slice(0, 10);
}
},
created() {
fetch("http://xxxxx.xxx.com.tw:8080/lxxx/xxxxdemo/employees/")
.then(r => r.json())
.then(result => {
this.employees = result.items;
});
}
}).$mount("#app");

啟動 Electron

1
electron .

另外也從瀏覽器直接打開 index.html 檔。

背景是從流覽器執行的畫面,前景則是 Electron。兩者的程式碼也都來自同一份,完全沒甚麼不同!

我們常用的 Visual Studio Code 程式碼編輯器就是使用 Electron 開發的。

微軟 Metro UI

隨著 Window 8 和 Surface 平板的發佈, 微軟對其 UI 做了很大的改變,引入了一套新設計風格的 UI,名為 Metro。帶來的改變不僅是重新設計了元素的樣式,還包括了應用佈局和結構的改變。有人基於這種 Metro 風格的規範,做了一個名為 Metro UI CSS 的 CSS 框架,它可以讓開發者建出符合 Metro 規範的基於 HTML 的應用樣式。

將前面範例使用的 bootstrap 用 Metro 取代,可做出像微軟桌面應用系統風格的畫面:

Offline First 本機存儲

最後我們要建立一個極簡單的備忘便條應用,就像可以完全離線操作的桌面應用系統。我們這裡使用較簡單的 NW.js 框架。

首先建立專案資料夾 let-me-remember,然後切換到專案目錄下,初始化一個 npm 專案。

1
2
cd let-me-remember
npm init -y

我們需要修改 package.json 來啟動應用程式,並定義起始視窗的大小。

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "let-me-remember",
"version": "1.0.0",
"description": "",
"main": "app/index.html",
"window": {
"width": 480,
"height": 320,
"frame": false,
"toolbar": false
},
"scripts": {
"start": "nw .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

這裡我們將應用程式的入口點 main 屬性改為 app/index.html,並新增了 window 屬性,定義了起始視窗的大小,也去除了視窗的邊框,讓它看起來就像螢幕上的便條簽。scripts 新增了 start 屬性,這樣我們可以使用 npm 啟動專案。

在專案目錄下建一子目錄 app 來放我們的程式碼,並在 app 目錄下再建兩個子目錄 stylesheets 與 javascripts 來擺放 CSS 與 JavaScript 文件。首先是 app 目錄下的 index.html 文件。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!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">
<link rel="stylesheet" href="stylesheets/app.css">
<title>備忘便條</title>
</head>
<body>
<div id="close" onclick="process.exit(0)">x</div>
<textarea rows="10" onKeyUp="saveNotes();"></textarea>
<script src="javascripts/app.js"></script>
</body>
</html>

HTML 文件很簡單,使用 textarea 元素來存放用戶輸入的便條內容。還用了一個 id 為 close 的 div 元素,當它被單擊的時候,會觸發退出應用的流程。注意這裡,我們可以從 HTML 中的 JavaScript 上下文中直接調用 Node.js 的 process 全局變數

另外我們要新增 app.css 與 app.js 兩個文件,這分別位於 stylesheets 與 javascripts 目錄下。

"stylesheets/app.css"
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
body {
background: #ffe15f;
color: #694921;
padding: 1em;
}

textarea {
font-family: "Hannotate SC", "Hanzipen SC", "Comic Sans", "Comic Sans MS";
outline: none;
font-size: 14pt;
border: none;
width: 100%;
height: 100%;
background: none;
}

#close {
cursor: pointer;
position: absolute;
top: 8px;
right: 10px;
text-align: center;
font-family: "Helvetica Neue", "Arial";
font-weight: 400;
}
"javascripts/app.js"
1
2
3
4
5
6
7
8
9
10
11
12
function initialize() {
let notes = window.localStorage.getItem('notes');
if (!notes) notes = "不要忘了...";
window.document.querySelector("textarea").value = notes;
}

function saveNotes() {
let notes = window.document.querySelector("textarea").value;
window.localStorage.setItem("notes", notes);
}

window.onload = initialize;

我們使用 HTML5 的 localStorage API,可以在應用中實現數據持久化,並且可以讓保存數據發生在後台,用戶完全感覺不到。

現在可以啟動應用程式了。

1
npm start

畫面看起來就像直接貼在螢幕上的便條籤。隨意打入一些備忘內容,內容就會被保存。當重新打開應用時,此前保存的內容會顯示在便條中。

最後我們需要將此應用直接打包成桌面應用的可執行檔。我們要使用一個 nw-builder 的套件,首先安裝它,如果要安裝成全局套件需要系統管理者權限。

"nw-builder install"
1
npm install nw-builder -g

目前 nw-builder 可以打包的平台有 win32, win64, osx32, osx64, linux32, linux64,這裡我們要打包成
win64 的應用。

1
nwbuild . -o ./build -p win64

第一次執行 nwbuild 會花一些時間,它需要從網路分別下載不同平台的打包工具 nwjs sdk 來執行打包的工作。執行完後會在專案目錄下產生一個 build 目錄,目錄下會有一個專案名稱的子目錄,裡面又會分不同的作業系統目錄。我們可以在 win64 目錄下找到 Windows 版本的 let-me-remember.ext 文件,現在只要直接打開此執行檔,就可以啟動我們的應用程式了。

Electron 版本的備忘便條應用就自己試試了。