iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 14
3
Modern Web

Half-Stack Developer 養成計畫系列 第 14

有輪堪用直須用:Express.js

有輪堪用直須用:Express.js

在上一篇裡面我們自己動手打造了一個超級簡單的後端程式,但是你有沒有覺得很麻煩?居然要處理這麼多事情,真是沒有天理,為什麼我寫 Rails 的朋友都跟我說他們只要 30 分鐘就可以寫一個 Blog?為什麼可以那麼快?

你有聽過一種東西叫做 Framework 嗎?框架。用起來跟你在用 library(像 jQuery)有一點像,都是用很多別人提供好的套件,只是框架會是一個超級完整的架構,讓你必須照著它的架構去走。所以好處就是如果框架很完整的話,它會把麻煩的事情全部都做掉;但壞處就是有些地方你想客製化的話可能就比較麻煩一點。

今天我們的主角就是 Node.js 拿來寫 server 的框架:Express.js
(有另外一套號稱自己是:next generation web framework for node.js的框架koa,那為什麼不教這個呢?因為我只會 Express...)

廢話不多說,立刻帶你來看一下這個框架到底可以幹嘛。我們先來建置一下開發環境:

npm install express --save

然後開一個 index.js,裡面寫

var express = require('express');
var app = express();

app.get('/', function (req, res) {
  res.send('hello world');
})

app.get('/hello', function (req, res) {
  res.send('你好');
})

app.get('/hello/:name', function (req, res) {
  res.send('你好, ' + req.params.name);
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})

執行 node index.js 之後會發現輸出一行Example app listening on port 3000!,之後就可以打開http://localhost:3000/hello/peter看一下結果,會發現畫面上寫著你好, peter

上面的程式碼規則滿固定的,你多觀察幾次應該會有一點心得。我們可以針對不同的路徑來寫不同的處理,這就叫做路由( Routing),是後端程式很重要的一個部分。

像是/hello/:name,前面加個冒號表示這個是在網址上會變動的一個參數,你在程式裡面可以用req.params.name把這個參數取出來,/hello/peter就會取到peter/hello/efefef就會取到efefef

為什麼說路由是很重要的一個部分呢?因為如果你只有一個網址的話根本不可能做事嘛!你要怎麼分辨這個 request 到底是做什麼的?(其實你硬要也是可以啦,但是會很麻煩就是了)

我們可以來觀察一下 iT 邦的路由是用什麼規則設計的,例如說我個人的文章頁面網址是這樣:

http://ithelp.ithome.com.tw/users/20091346/articles

很明顯看得出來就是/users/:user_id/articles,代表這個使用者底下的所有文章。如果你爽的話當然也可以用/abc/:user_id/def來當作網址,但是這樣一點都不可讀,後面接手的工程師一定會想殺了你。為了自己的人身安全,強烈建議你不要這樣做。

再來看導覽列的幾個 tab 的網址:

  1. 技術問答:http://ithelp.ithome.com.tw/questions
  2. 技術文章:http://ithelp.ithome.com.tw/articles?tab=tech
  3. iT 徵才:http://ithelp.ithome.com.tw/articles?tab=job
  4. iT 活動:http://ithelp.ithome.com.tw/articles?tab=event

會發現後面三個其實都是到同一個地方,只是透過後面帶的?tab=TAB_NAME來區分。有了以上這些資訊之後,我們可以寫一個非常非常簡單版本的仿 iT 邦的路由出來。

var express = require('express');
var app = express();

app.get('/', function (req, res) {
  res.send('index');
})

app.get('/users/:userId/articles', function (req, res) {
  res.send('這是 user: ' + req.params.userId + ' 的文章');
})

app.get('/questions', function (req, res) {
  res.send('這是問答頁面');
})

app.get('/articles', function (req, res) {
  res.send('這是文章列表,你想看的 tab 是:' + req.query.tab);
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})

你可以試著開開看以下幾個頁面看會輸出什麼(或是你已經猜的到其實也可以不用開)

  1. http://localhost:3000/users/123/articles
  2. http://localhost:3000/questions
  3. http://localhost:3000/articles?tab=job

總之,我們用這麼短短的幾行就把東西都搞定了,可以針對不同的 URL 輸出不同的資訊。可是一直到目前為止,我們都是輸出這些文字而已,有沒有可能輸出 HTML 呢?這樣看起來就更像一個完整的網頁了!我就可以拿去跟我同學炫耀說:嘿你看,我會寫網頁了喔!而且真的可以動!

這個當然可以,而且非常簡單,你看看下面這段程式碼:

var express = require('express');
var app = express();

app.get('/users/:name', function (req, res) {
  res.send(
    '<h2> Hello, ' + req.params.name + '</h2>' 
  );
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})

我們只要把 send 裡面的東西變成 HTML 的格式就行了,就能夠輸出網頁。而且因為瀏覽器很智慧,所以就算你沒有輸出<html>, <body> 這些標籤,它也能夠自動幫你加上。不過這邊只是為了方便還有我懶得打那麼多字,實務上仍然會自己加上這些必要的標籤。

不過,你有沒有覺得有點麻煩?你明明就已經寫好一份 HTML 了,差別只在於傳進來的資料不一樣所以要做一些改動,你就要把 HTML 都加上一堆單引號跟加號變成字串,然後這樣一直拼接起來,有夠累!有沒有更方便的方法?

還記得我以前說過,我做很多事的時候都會先想到:「我最後想要使用的方式」是什麼,先想出結果,再一步步實作出來。我是不知道你想的是怎樣,但我想的差不多是這樣:

app.get('/users/:name', function (req, res) {
  res.send(render('index.html', {
    name: req.params.name
  }));
})

指定你要渲染的檔案跟參數,render這個函式就會自己幫你把index.html跟你想要傳的參數name結合在一起,變成我們想輸出的樣子。那因為要讓程式知道index.html到底要替換哪一個部分,所以你的index.html當然也要做一些修改

<!DOCTYPE html>
<html>
<head>
</head>
<body>
  <h2>{name}</h2>
</body>
</html>

其實你仔細想想,會發現我們要實做的render函式其實也不難,不就是讀檔案然後替換掉固定格式的字串而已嗎?

function render(filename, params) {
  var data = fs.readFileSync(filename, 'utf8');
  for (var key in params) {
    data = data.replace('{' + key + '}', params[key]);
  }
  return data;
}

附上完整程式碼:

var express = require('express');
var app = express();
var fs = require('fs');

function render(filename, params) {
  var data = fs.readFileSync(filename, 'utf8');
  for (var key in params) {
    data = data.replace('{' + key + '}', params[key]);
  }
  return data;
}

app.get('/users/:name', function (req, res) {
  res.send(render('index.html', {
    name: req.params.name
  }));
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})

這樣就達成我們的目的了,不用在那邊自己拼裝字串了,只要簡單的改一下 HTML 就好。

話說,來一段題外話,我剛剛在寫那段讀檔案的時候原本是用了非同步的寫法,就是必須使用 callback 去接收結果

function render(filename, params) {
  fs.readFile(filename, 'utf8', function (err, data) {
    if (err) return console.log(err);
    console.log(data);
    for (var key in params) {
      data = data.replace('{' + key + '}', params[key]);
    }
    return data;
  });
}

然後發現這樣子不行,為什麼呢?因為那個return data;那一句是在 callback function 裡面,所以不是render這個函式的 return,因此render根本不會回傳任何東西。這個是很多新手在寫 JavaScript 的時候會碰到的錯誤,你只要熟記一個原則就好:「非同步的操作,一律用 callback 把結果帶回去」。像以上這種情況,除了要改成用 callback 以外,呼叫 render 的地方也要修改:

function render(filename, params, callback) {
  fs.readFile(filename, 'utf8', function (err, data) {
    if (err) return cabllack(err);
    for (var key in params) {
      data = data.replace('{' + key + '}', params[key]);
    }
    callback(null, data); // 用 callback 傳回結果
  });
}

app.get('/users/:name', function (req, res) {
  render('index.html', {
    name: req.params.name
  }, function (err, data) {
    res.send(data); // 這邊要寫一個 function 才能接收到資料
  });
})

如果上面這段看不懂,那你需要去複習一下 callback function。

好,接著讓我們回歸正題,我們剛剛已經自己寫了一個 render 的 function 對吧,但是現在更難的一個問題來了。假設我是要輸出一個列表怎麼辦?例如說我傳進去的資料是[1, 2, 3],我想輸出的內容是:

<li>1</li>
<li>2</li>
<li>3</li>

這個需求其實超級普遍,例如說部落格的文章列表。你就會從資料庫裡面拿到每一筆文章的資料結合成一個陣列,然後必須用 HTML 顯示出來。每一個 row 的格式都一模一樣,差別就在於資料不同而已。這個又要怎麼辦呢?

很簡單,還記得我們說過框架很完整嗎?其實框架都幫你把這些問題解掉了啦!包含上面那個{name}的範例也是,有現成的解決方案可以用。俗話說:「不要自已動手造輪子」大概就是這樣的意思。如果有現成的解決方案先拿來用用看,真的不行再自己寫一個。

那為什麼我還要讓你手動自已寫render這個函式?我只是想讓你知道我們待會要講的東西的基本原理而已,這樣你用起來才會更有感覺,才會知道為什麼我們需要這個東西。

模板引擎 Template Engine

這台機車引擎壞掉了卻還可以發動,一定是另有隱情(from joke 板某篇笑話)

模板這種東西就跟我們剛剛的 index.html 有八七成像,只是各有各的風格而已,完全看你自己喜歡哪一種。他們的原理就是用自己獨特的格式來寫 HTML,然後傳進資料之後結合在一起,最後 compile 成 HTML。你也可以直接想成是 HTML 版的 SCSS 那種感覺。

Pug

第一個要介紹的叫做 Pug,直接來看一下用這個寫 HTML 會長這樣

doctype html
html(lang="en")
  head
    title= pageTitle
    script(type='text/javascript').
      if (foo) bar(1 + 5)
  body
    h1 Pug - node template engine
    #container.col
      if youAreUsingPug
        p You are amazing
      else
        p Get on it!
      p.
        Pug is a terse and simple templating language with a
        strong focus on performance and powerful features.

嗯,很好,完全不是我喜歡的風格,我們再看下一個

EJS

第二個是 EJS,來看一下簡單的範例:

<ul>
<% for(var i=0; i<supplies.length; i++) {%>
   <li><%= supplies[i] %></li>
<% } %>
</ul>

很好,我喜歡這個!跟 PHP 也有八七成像,把 HTML 跟程式碼混在一起寫,雖然看起來比較雜亂,但是也更直覺一點

Dust.js

最後一個要介紹的是 Dust.js,是 Linkedin 在用的。

<h1>{title}</h1>
<ul>
  {#supplies}
    <li>{.}</li>
  {/supplies}
</ul>

其實我最習慣用的是這個啦,因為我之前公司用的就是這一套,所以用得比較熟悉,使用的感覺也不錯。不過有些限制滿麻煩的,到後來我覺得最快能夠上手的是ejs,因為比較簡單,你想在裡面寫什麼程式碼都可以。但缺點當然就是一不小心就會弄的很雜亂。

接著讓我們把 ejs 跟 express 串起來吧!一樣按照慣例先來裝一下該裝的套件

npm install ejs --save

再來我們新開一個資料夾叫做views,裡面新建一個index.ejs,內容是這樣:

<!DOCTYPE html>
<html>
<head>
</head>
<body>
  <h2><%= name %></h2>
  <ul>
    <% items.forEach(function(item) { %>
        <li><%= item %></li>
    <% }); %>
  </ul>
</body>
</html>

然後在我們的主程式裡加上設定跟輸出的程式碼:

var express = require('express');
var app = express();

app.set('view engine', 'ejs');
app.get('/users/:name', function (req, res) {

  res.render('index', { // 這邊不用寫 views/index 是因為 express 預設 template 就是會放在 views 資料夾裡面
    name: req.params.name,
    items: ['peter', 'nick', 'cake']
  })
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})

打開http://localhost:3000/users/huli之後就可以看到成果了:

http://ithelp.ithome.com.tw/upload/images/20161221/20091346H78Yjo3aDE.png

是不是覺得其實還滿簡單的?這樣練習下來其實就已經大概知道寫網頁的步驟了

  1. 先寫一份 HTML,把資料都寫死
  2. 開始寫後端建立路由規則,並且寫說要接收哪些參數
  3. 把 HTML 改成 template 的形式,並且加上一些程式碼
  4. 把要輸出的資料傳到模板去結合起來變成 HTML
  5. 讓 express 把這個 HTML 回傳給 client 端

這個就叫做動態網頁,因為每一次你都是根據 request 的內容來動態產生 HTML,你每次的 response 幾乎都會長得不太一樣。相比之下,靜態網頁指的就是只有 HTML,你怎麼輸出都是同一份。

最後讓我們來看一個超級完整的範例,先來看一下資料夾結構跟最後完成的樣子:

http://ithelp.ithome.com.tw/upload/images/20161221/20091346u1H1YGtRXn.png

.
├── index.js
├── package.json
├── routes
│   ├── articles.js
│   └── users.js
└── views
    ├── article.ejs
    ├── articles.ejs
    ├── header.ejs
    ├── user.ejs
    └── users.ejs

這樣的結構會更利於之後的開發,因為我們把各個路徑要做的事情切到routes資料夾的檔案底下,這樣會比較好管理。然後views也多了header.ejs,是負責顯示網站的導覽列。

先來看一下最重要的index.js

var express = require('express');
var app = express();
var users = require('./routes/users'); //引入檔案
var articles = require('./routes/articles'); //引入檔案

app.set('view engine', 'ejs');
app.use('/users', users); // 把這個路由的東西都交給 user 處理
app.use('/articles', articles);

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})

再來 users 跟 articles 根本一模一樣的兩個東西,架構完全一樣,只有一些資料不一樣而已。因此我們選一個來看就好,我們看routes/users.js

var express = require('express');
var router = express.Router();

router.get('/', function (req, res) {
  var users = [{ //自己隨便寫一些資料
    id: 1,
    name: 'peter'
  }, {
    id: 2,
    name: 'nick'
  }, {
    id: 3,
    name: '\(o_o)/'
  }];
  res.render('users', { // 輸出
    users: users
  });
});

router.get('/:id', function (req, res) {
  res.render('user', { //輸出
    id: req.params.id
  })
});

module.exports = router;

其實就是分成兩個網址,usersusers/:id,根據要查詢所有使用者還是單一一個使用者 render 不同的檔案。先來看比較簡單的 views/user.ejs

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" />
</head>
<body>
  <% include header %>
  <div class="container">
    <h2>使用者資料</h2>
    <table class="table table-bordered">
      <tr>
        <th>id</th>
      </tr>
        <tr>
          <td><%= id %></td>
        </tr>
    </table>
  </div>
</body>
</html>

一個很簡單的 table 顯示 id 出來而已。這邊的重點是<% include header %>,你可以把每一個部分都獨立成一個檔案,再用 include 來引入。這就跟 Node.js 的 require 很像。我們在views/header.ejs這個檔案主要放的就是一個 bootstrap 的導覽列:

<nav class="navbar navbar-default">
  <div class="container-fluid">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="#">管理系統</a>
    </div>

    <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
      <ul class="nav navbar-nav">
        <li><a href="/articles">文章管理</a></li>
        <li><a href="/users">使用者管理</a></li>
      </ul>
    </div>
  </div>
</nav>

最後來看一下輸出使用者列表的views/users.ejs

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" />
</head>
<body>
  <% include header %>
  <div class="container">
    <h2>使用者列表</h2>
    <table class="table table-bordered">
      <tr>
        <th>id</th>
        <th>姓名</th>
      </tr>
      <% users.forEach(function(user) { %>
          <tr>
            <td><a href="/users/<%= user.id %>"><%= user.id %></a></td>
            <td><%= user.name %></td>
          </tr>
      <% }); %>
    </table>
  </div>
</body>
</html>

跟之前的差不多,寫一個迴圈然後把 table 的每一列都印出來。因為 articles 的就只是把文字跟參數改一改,所以程式碼就不附在這邊了。等這個系列成功寫完之後,我會再整理一下包含範例程式碼全部丟到 github 上,可以等那個時候再來看完整的範例。

總結

這篇好像不小心寫太多,終於來到總結的時候。我決定用一些問題來幫大家做個整理,你可以自己先想想看這些問題:

  1. 為什麼要用 Express.js,而不是用我們上一章的 http.createServer?
  2. 老闆跟我說不准用任何框架,怎麼辦?
  3. 為什麼要用 template engine? 沒有它的話會怎麼樣?
  4. 為什麼要把 headers 獨立出來成一個檔案?寫在每一個 ejs 裡面這樣不好嗎?

因為我把我的回答寫在這篇的話,你就不會思考而是會直接卷下去看答案,所以我決定下一章再來跟大家討論這些問題。
今天的部分就到邊結束囉


上一篇
網頁後端原理:http.createServer
下一篇
MV* 的愛恨情仇
系列文
Half-Stack Developer 養成計畫30

尚未有邦友留言

立即登入留言