用 Node.js 製作簡易的留言版

檔案結構

1
2
3
4
5
6
7
8
— app.js
— views
  ∟ index.html
  ∟ post.html
  ∟ 404.html
— public
∟ lib
∟ bootstrap.css

app.js

  • 後端邏輯程式碼

views

  • HTML 檔案, 分別是首頁、發表留言頁、404警告頁

public

  • 靜態資源資料夾, 包含所有第三方套件、圖片、前端js檔、css檔, 這些在 HTML 標籤中, 如 <link> <script> <img> 所請求的資源

views

使用 bootstrap 來製作簡易的介面

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div class="header container">
<div class="page-header">
<h1 class="my-4">留言板 <small>...</small></h1>
<a class="btn btn-success mb-4" href="./post.html">發表留言</a>
</div>
</div>
<div class="comments container">
<ul class="list-group">
<li class="list-group-item">
<div class="d-flex flex-column">
<div class="mb-3">
<span class="font-weight-bold mr-2">留言者姓名</span>
<small class="text-right">留言時間</small>
</div>
<span class="mb-3">留言內容</span>
</div>
</li>
</ul>
</div>

post.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div class="header container">
<div class="page-header">
<h1><a href="/">首頁</a> <small>發表評論</small></h1>
</div>
</div>
<div class="comments container">
<form>
<div class="form-group">
<label for="input_name">你的大名</label>
<input type="text" class="form-control" id="input_name" name="name" placeholder="請寫入你的姓名">
</div>
<div class="form-group">
<label for="textarea_message">留言內容</label>
<textarea class="form-control" name="message" id="textarea_message" cols="30" rows="10"></textarea>
</div>
<button type="submit" class="btn btn-primary">發表</button>
</form>
</div>

app.js

起手式

1
2
3
const http = require('http')
const fs = require('fs')
const url = require('url)

用 Node.js 實作一個 Apache HTTP Server中, 是這樣建立服務的

1
2
3
4
const server = http.createServer()
server.on('request', function (req, res) {
// ...
})

現在我們可以簡化成以下寫法, 結果是一樣的

1
2
3
http.createServer(function (req res) {
// ...
})

取得請求路徑

當瀏覽器發送請求到伺服器端時, 需要根據請求過來的網址, 回傳對應的 html 頁面回去, 首先我們需要先得到網址的字串資料, 最簡易的方式是像這樣的

1
2
3
http.createServer(function (req, res) {
let url = req.url
})

如果請求的網址是 127.0.0.1:3000/comment, url 的輸出結果則為 /comment。 但是, 當使用者在留言時所傳送的網址可能會副帶 query string 參數, 像是這樣

1
127.0.0.1:3000/comment?name=dylan&message=今天天氣真好

這時候 url 的值為 /comment?name=dylan&message=今天天氣真好, 這些傳送的參數是不可能每次都一樣的, 這個情況下, 如果使用 if (url === '/comment') 這樣的判斷方式是會有問題的。
因此我們需要確保請求網址傳過來時, 能夠將請求路徑區與夾帶的 query string 區分開。

使用 Node.js 的 url 核心模組, 使用方法請參考此篇:使用 JS 取得 Query String 資料

1
2
3
4
http.createServer(function (req, res) {
let urlObj = url.parse(req.url, true)
let pathname = urlObj.pathname
})

現在, 如果像這樣 127.0.0.1:3000/comment?name=dylan&message=今天天氣真好 的請求傳來時, url 模組的 parse 方法可以幫助我們將網址進行處理。

輸出 urlObj.pathname 可以得到請求路徑

1
/comment

urlObj.query 幫助我們將 query 處理成物件

1
{name: 'dylan', message: '今天天氣真好'}

接著可以把判斷方式改為這樣, 就能避免問題了

1
2
3
if (pathname === '/comment') {
// do something
}

公開的靜態資源

這個留言板中只有一個靜態資源 bootstrap, 位置在 public → lib → bootstrap.css

1
2
3
— public
∟ lib
∟ bootstrap.css

以往我們在做前端開發的經驗中, 通常使用相對路徑來引入 JS、CSS 或圖片檔案, 像是:

1
<link rel="stylesheet" href="../node_modules/bootstrap/dist/css/bootstrap.css">

現在可以改成使用絕對路徑, 如果請求路徑是以 /public/ 開頭, 則伺服器認定需要往 public 資料夾中的某個資源, 所以我們就直接可以把請求路徑當作文件路徑來直接進行讀取

1
<link rel="stylesheet" href="/public/lib/bootstrap.css">
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
http.createServer(function (req res) {
let urlObj = url.parse(req.url, true)
let pathname = urlObj.pathname
// 統一處理 404 頁面
function toErrorPage () {
fs.readFile('./views/404.html', (err, data) => {
if (err) return res.end('404 not Found!')
res.end(data)
})
}
// ============== START ================
if (pathname.indexOf('/public/') === 0) {
fs.readFile(`.${pathname}`, (err, data) => {
if (err) return toErrorPage()
res.end(data)
})
}
// ================ END ================
})

若有其他的靜態資源也可以如法炮製

1
2
3
4
5
6
7
— public
∟ js
∟ main.js
∟ css
∟ main.css
∟ img
∟ A.jpg
1
2
3
<script src="/public/js/main.js"></script>
<link rel="stylesheet" href="/public/css/main.css">
<img src="/public/css/A.jpg" alt="">

處理 index.html

撰寫 / 請求的伺服器回應

1
2
3
4
5
6
7
8
9
10
if (pathname.indexOf('/public/') === 0) {
// ...
// ============== START ================
} else if (pathname === '/') {
fs.readFile('./views/index.html', (err, data) => {
if (err) return toErrorPage()
res.end(data)
})
}
// ================ END ================

模板引擎動態渲染留言資料

目前的程式碼只會回傳靜態的頁面, 因此還需要使用模板引擎動態渲染留言的資料到 index.html 上。
這邊和上次製作 Apache Server 一樣, 使用 art-template 套件。

使用方法可以參考在 Node.js 中使用 art-template 模板引擎

宣告一個處理留言資料的物件, 先寫死兩個留言資料, 屬性有

  • name 留言者
  • message 留言內容
  • dateTime 留言時間
1
2
3
4
5
6
7
8
9
10
11
12
let comments = [
{
name: '王曉明',
message: '今天天氣不錯',
dateTime: '2019-08-09 13:00:00'
},
{
name: '王曉華',
message: '對阿',
dateTime: '2019-08-09 12:00:00'
}
]

使用 art-template 模板引擎

app.js

1
const template = require('art-template')
1
2
3
4
5
6
7
8
9
10
11
12
13
if (pathname.indexOf('/public/') === 0) {
// ...
} else if (pathname === '/') {
fs.readFile('./views/index.html', (err, data) => {
if (err) return toErrorPage()
// ============== START ================
let afterRenderData = template.render(data.toString(), {
comments: comments
})
res.end(afterRenderData)
// ============== END ================
})
}

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<ul class="list-group">
{{ each comments }}
<li class="list-group-item">
<div class="d-flex flex-column">
<div class="mb-3">
<span class="font-weight-bold mr-2">{{ $value.name }}</span>
<small class="text-right">{{ $value.dateTime }}</small>
</div>
<span class="mb-3">{{ $value.message }}</span>
</div>
</li>
{{ /each }}
</ul>

效果

處理 post.html

撰寫 /post 請求的伺服器回應

當點擊首頁中的發表留言按鈕時, 瀏覽器會向伺服器發起 127.0.0.1:3000/post 請求, 隨即跳轉至 post.html 頁面

1
2
<!-- index.html -->
<a class="btn btn-success mb-4" href="./post.html">發表留言</a>

改為

1
<a class="btn btn-success mb-4" href="/post">發表留言</a>

1
2
3
4
5
6
7
8
9
10
11
// ..
} else if (pathname === '/') {
// ...
// ============== START ================
} else if (pathname === '/post') {
fs.readFile('./views/post.html', (err, data) => {
if (err) return toErrorPage()
res.end(data)
})
}
// ============== END ================

使用 HTML 表單發送資料

當使用者點擊發送按鈕時, 將表單資料送往後端

1
2
3
4
5
6
7
8
9
10
11
12
<!-- post.html -->
<form action="/comment" method="get">
<div class="form-group">
<label for="input_name">你的大名</label>
<input type="text" class="form-control" id="input_name" name="name" placeholder="請寫入你的姓名">
</div>
<div class="form-group">
<label for="textarea_message">留言內容</label>
<textarea class="form-control" name="message" id="textarea_message" cols="30" rows="10"></textarea>
</div>
<button type="submit" class="btn btn-primary">發表</button>
</form>

action="/comment" 在發送時, 瀏覽器會透過 127.0.0.1:3000/comment 向伺服器發起請求, get 方法則會將表單內具有 name 屬性的表單元素的值, 透過 query string 的方式發送資料。

簡單的原生 HTML 表單驗證

1
<input type="text" class="form-control" required minlength="2" maxlength="10" id="input_name" name="name" placeholder="請寫入你的姓名">
1
<textarea class="form-control" name="message" id="textarea_message" cols="30" rows="10" required minlength="5" maxlength="20"></textarea>

required 為必填項目, minlengthmaxlength 分別代表最低字數與最多字數限制


撰寫 /comment 請求的伺服器回應

1
2
3
4
5
6
7
8
9
10
} else if (pathname === '/post') {
// ...
} else if (pathname === '/comment') {
let commentData = urlObj.query
commentData.dateTime = new Date().toLocaleString()
comments.unshift(commentData)
res.statusCode = 302
res.setHeader('Location', '/')
res.end()
}

如文章一開始所提, 在使用者送出留言後, 會使用像是 127.0.0.1:3000/comment?name=dylan&message=今天天氣真好 的網址傳送到伺服器, 然而現在我們已經可以透過 url.parse 方法取得物件格式的 query string 資料, 當收到此請求時將該 query string 物件推入 comments 留言物件中, 就完成了資料的更新。
資料雖然已經更新, 但使用者還是停留在 post 頁面, 我們需要透過伺服器重新導向到首頁。

  1. 狀態碼設定為 302 臨時重新導向
  2. 在 Response Header 中通過 Location 告訴瀏覽器導向到哪一頁

如果瀏覽器發現收到伺服器的狀態碼是 302, 它會自動去 Response Header 中找 Location, 然後對伺服器重新發請求, 接著你就能看到瀏覽器自動跳轉了。
當使用者被重新導向到首頁時, 新的資料已經透過模板引擎重新渲染成新的首頁 HTML 發送給瀏覽器了, 如此一來, 使用者就能順利看到新的留言呈現在首頁上。

DEMO

Github