2017年12月4日月曜日

[Web開發]Node.js 迷航記(express+angular+bootstrap+webpack+webstorm整合開發)

之前寫過的php+mysql的網站,在經過近十年的荒廢之後,對於web開發的認知已成了浦島太郎狀態。加上HTML5+Node.js成了web前後端的熱門顯學,於是想把整個網頁打掉重練。
翻了一陣子相關資料,看到滿滿的套件,還是有一種看不太懂的感覺。

經過一番整理,得到了以下的概念:
web page的運行架構為client/server。
所以定義client(下載到browser顯示的頁面)為「前端」,server為「server端」。

  • Node.js :在「server端」讀取.js檔案並執行,與各個OS介接的執行程式。擁有檔案I/O跟網路I/O(通常拿來做為http server)的功能,可根據不同的uri request執行相對應的回應。
  • npm : node.js package manager. 安裝並管理node.js可利用的各個模組。為node.js初始支援模組。下面提到的所有「server端」使用的模組都需要以npm安裝。
  • express:「server端」以javascript描述server response的模組。還提供了http server設定的template,大幅降低設定的困難度。
    以npm指令:
    npm install -gd express-generator
  • bower:安裝與管理「前端」使用的javascript模組。像是angular / bootstrap... 並把複雜的模組依存架構做成設定檔,讓其他的網頁資料前置處理模組使用。
    但是..........在這個變動快速的世界....bower已經被npm建議改用Yarn了。
  • Yarn:也是一種安裝包的管理軟體。跟npm的功能類似。也是以node.js為執行平台。
  • Karma:執行測試。一些需要做各種條件測試的功能,有測試軟體幫忙跑case會輕鬆很多。
  • AngularJS:「前端」的application framework。有了它,可以輕鬆的架構模組化,資料-顯示-邏輯分離的網頁app。
  • bootstrap:在「前端」幫忙排版網頁的引擎。主要是拿來處理螢幕大小的對應顯示,跟美化各個網頁上的元件。
  • webpack:打包前端的javascript/css/資源(如圖片等等)並加上其他改善處理(例如縮小程式碼/混亂程式碼等等)的模組。在大量應用js模組的時代,在html裡面一個個的用script tag載入是相當麻煩而且沒有效率的事情。而且會導致大量的http request,對server也是負擔。尤其要面對各模組之間的依存順序的時候更會讓你頭痛。因此導入打包模組是必須的。當然,簡單的網頁就不一定要這麼麻煩...


從.頭.開.始。

簡單的了解海量的元件之後,當然是直接要從.頭.開.始。
本文使用OSX為作業環境。
安裝npm: 從官網抓安裝包最安全。 https://nodejs.org/
建立project:在這裡使用express-generator的模板建立流程。
  • 先安裝express-generator:
    sudo npm install -g express-generator
    註:參數「-g」代表此模組是安裝在系統目錄,讓所有目錄都可以使用。也因為是裝在系統目錄,在OSX環境下需要以管理者權限執行,需加上「sudo」。windows環境下就不需要加sudo。
  • 安裝express-generator之後,切換到想要建立project的目錄,執行「express (project名) -ejs」,express模組就會幫忙建立一個新的目錄,裡面包含基本的http server處理的所有檔案。本文之後都以「<project root>」來描述本文所使用的project的根目錄。
    註:「ejs」是javascript的代碼注入模組,後述。
然後來看看建立了哪些東西:

圖上左側的樹狀檔案列表,就是產生出來的檔案們。
東西很多,來一個個的看。

認識package.json

package.json:為npm的進入點。(npm執行沒有帶「-g」參數,也就是對目前的所在目錄處理的指令,都會參照執行當前目錄下的package.json檔案。)所以先看這檔案的內容:

{
  "name": "untitled",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www"
  },
  "dependencies": {
    "body-parser": "~1.18.2",
    "cookie-parser": "~1.4.3",
    "debug": "~2.6.9",
    "ejs": "~2.5.7",
    "express": "~4.15.5",
    "less-middleware": "~2.2.1",
    "morgan": "~1.9.0",
    "serve-favicon": "~2.4.5"
  }
}


在node.js的世界,習慣使用npm start來啟動server端的運行。「start」就是對應到上面的程式碼的"scripts"裡面的"start"。所以下一步就是執行「node ./bin/www」。
當然可以自行加上許多指令。像是test啦,packaging等等...
若是想不經由npm來執行的話,在<project root>執行「node ./bin/www」也可以達到一樣的結果。

"dependencies" 是一大重點。裡面表列的是這個project會用到的所有node.js模組。本文寫到現在,都還沒安裝過模組。上面的模板產生出來的部分,在<project root>執行「npm install」就會全部幫你裝在這個project裡面的「node_modules」目錄下,不會更改到安裝在系統環境下的模組。至於這些模組是不是「必要」,得等到一個個去了解之後才能確定,在此不研究。

要如何增加模組?直接編輯package.json之後再執行「npm install」當然可以。或是執行「npm install <模組名稱> --save 」這樣的加上「--save」參數,這樣再開啟package.json看檔案內容,就會發現安裝過的模組的名字會出現。

至於不使用bower,直接讓前後端所有的模組全部擺在一起會不會有缺點,目前不得而知。先暫時讓它混吧。migrate總是痛苦的。像是模組命名不同,前後端用的版本不同等等瑣碎的問題可能會大量發生。

有了進入點之後,接下來就是依序追蹤。繼續看start參數所指定的執行指令檔:./bin/www

#!/usr/bin/env node

/**
 * Module dependencies.
 */

var app = require('../app');
var debug = require('debug')('untitled:server');
var http = require('http');

/**
 * Get port from environment and store in Express.
 */

var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

/**
 * Create HTTP server.
 */

var server = http.createServer(app);

/**
 * Listen on provided port, on all network interfaces.
 */

server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

/**
 * Normalize a port into a number, string, or false.
 */

function normalizePort(val) {
  var port = parseInt(val, 10);

  if (isNaN(port)) {
    // named pipe
    return val;
  }

  if (port >= 0) {
    // port number
    return port;
  }

  return false;
}

(....  後面的程式碼略過)

可以看出這檔案讀取了「../app」,也就是「./bin/」的前一個目錄的「app.js」。
(沒指定副檔名是因為node有一個查找的機制。有興趣的話請翻閱其他網站的說明)
跟「http」模組(此為node.js內建)。

然後做了啟動http server的動作,跟listen port。這些都是基本的http server啟動流程。
有了基本的http server的流程,在這邊先來試試產生的模板是不是能正常運作吧。
在<project root>執行「npm start」。要是執行「npm start」發生錯誤的話,應該是沒有先執行「npm install」安裝相關模組。
接著用browser 連線「localhost:3000」...

看來是可以運作。safe~~~
這時可以按下ctrl+c停止server運作,繼續迷航...


認識app.js

上面的兩個檔案所提供的資訊,總覺得好像沒做到什麼設定。需要更多的資料來了解。
http.createServer(app) 這個app參數相當可疑。
總之來看看<project root>的app.js:

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var lessMiddleware = require('less-middleware');

var index = require('./routes/index');
var users = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(lessMiddleware(path.join(__dirname, 'public')));
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', index);
app.use('/users', users);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

喔喔。前面留下來的疑問全部都得到了解答。
標記著顏色的require,剛好呼應了dependencies裡面的定義。
大量的express設定就請參考文件。
重點:
  • 「app.set('view engine', 'ejs');」表示express在render網頁的時候,會加上ejs的處理流程。
  • 「module.exports = app;」定義若是此檔案被require的時候,該回傳的是哪個東東。會回傳的是「app」這個參數所代表的物件,前面有個「var app = express();」的動作,所以這個app也就是代表執行「express()」之後所產生的物件。
  • 「app.use('/', index);」表示若得到的url request的路徑是'/',就會執行index這個動作。

而index是由「require('./routes/index');」所定義。所以我們再看./routes/這目錄下面有沒有「index」相關的檔案。有找到index.js。看看內容:

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

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('index', { title: 'Express' });
});

module.exports = router;

看起來似乎是在所謂的router裡面加上url的比對跟安裝callback執行比對之後的動作:回傳一個render過的網頁內容。取得views/index.ejs之後,ejs模組依照傳入的參數跟檔案本身的內容做處理,回傳給browser。

有router.get(),當然也有router.post()。就看使用的需求了。
req是browser傳來的request的內容。應該會經由「bodyparser」處理成object跟一個個的參數。
res對server來說就是對browser輸出的介面。除了回傳網頁之外,發送檔案/url redirect都是做得到的。這部分就請參考文件。


導入IDE:webstorm

到這裡,基本的web server的流程介紹完畢。終於可以開始著手撰寫了。
總覺得一直參考來參考去的很麻煩。很需要好用的整合開發環境。
ios有xcode,android有android studio,windows有visual studio, 那javascript呢?

找了一陣子資料,看來目前最強的是webstorm。有30天試用期。試用之後發現真的是沒有它不行... 因為它整合了debug最重要的功能:中斷點。

其實一開始的檔案列表的圖就是webstorm的介面。
來看看webstorm有多方便吧。
從官網下載,安裝完webstorm之後,執行。

選擇「Create new project」,選擇「Empty Project」,避免webstorm可能會自動產生一些東西來改變之前的成果。填入之前建立的project的根目錄,點選「create」:
之後webstorm會提醒此目錄不是空的,是否要以原有的資料來建立project,選擇「Yes」。

然後就進入了主畫面。


設定webstorm的debug環境

接著就來試試要如何debug吧。要是使用的webstorm,右上角的蟲子icon此時是綠色,可以跳過以下設定debug環境的步驟,直接下斷點試試。
若是右上角的蟲子icon此時是灰色,點選右上角的三角形指向下的方塊,選擇「Edit configurations」。

進入run/debug設定畫面。點選左上角的「+」,在浮現框裡面點選「Node.js」。

然後在圖中的藍色框處填入前面有提到的npm start command:「./bin/www」
再選擇右下角的「ok」。

以上的步驟就是讓webstorm在debug的時候去啟動node.js。這樣我們就可以依照之前的連線的經驗去對server連線。應該會發現右上角的綠色三角形跟蟲的符號成為可選狀態。

為什麼debug config不用npm start不就簡單多了?
經過測試,這樣做的話似乎在npm啟動server之後過個幾秒就會停止server,不知原因...

接著如下圖,在index.js的第6行的數字右側點擊,該行就會出現一個紅點,這就是debug的中斷點。再點選視窗右上角的蟲子符號,使用browser連線「localhost:3000」...

有如下圖般的變成藍色的話,代表中斷點正確運作。這時可以按視窗左下角的綠色三角形讓程式繼續跑。

webstorm簡單介紹到此。已經可以滿足基本的開發需求。


前端打包:webpack

接下來想知道的是:前端的開發/模組安裝/打包要怎麼解決?

這部分其實因為在bower還沒被放棄之前,前端開發的javascript模組的管理都是交給它處理。但是現在就得另找方法解決。就算是跟後端的模組混在一起放,那也是得解決之後打包的問題。

ok。既然決定引進打包功能,就要照打包軟體的建議做法來執行。
本文使用webpack做為打包工具。以下是webpack建議的路徑設定:

.
├── package.json
├── public
│   ├── index.html
│   └── js
│       └── bundle.js
├── src
│   └── js
│       ├── app.js
│       └── modules
│           ├── modulea.js
│           └── moduleb.js
└── webpack.config.js


webpack的開發習慣:<project root>/src目錄做為開發用的程式碼放置目錄,
而<project root>/public則是輸出結果用的。<project root>/public目錄,在express-generator幫忙做出來的檔案裡面已經有了,剛好不需因路徑的更動而改動改太多程式碼。
當然,一鍵做完所有處理之後還可以debug,是必須達成的目標。

總之先把webpack/jquery/angular/bootstrap裝好。
也裝裝webpack在本文裡面會用到的extract-text-webpack-plugin/ css-loader / style-loader / url-loader:
npm install webpack --save
npm install angular --save
npm install bootstrap --save
npm install extract-text-webpack-plugin --save
npm install css-loader --save
npm install style-loader --save
npm install url-loader --save

npm有個package.json為進入點,webpack當然也有個設定檔。就是「webpack.config.js」。
因為不像express有建立模板的工具可用,參考其他網站的設定,在<project root>建立檔案「webpack.config.js」:



// 有套件需要絕對路徑的設定,導入path模組。
var webpack = require('webpack');
var path = require('path');
var ExtractTextPlugin = require('extract-text-webpack-plugin');

var plugins = [ new webpack.ProvidePlugin({
    $: 'jquery',
    jQuery: 'jquery',
    //'window.jQuery': 'jquery'
    })
    , new ExtractTextPlugin('stylesheets/[name].bundle.css') ];
// webpack.ProvidePlugin是為了讓webpack可以包入jquery而使用的。webpack內建支援,不需另行安裝模組。
//ExtractTextPlugin是為了讓webpack把css不內包在.js,另存一個檔案用的。聽起來有點畫蛇添足?因為把css獨立出來的好處是讓html可以同時讀入js跟css。一次只同時讀一個css跟一個js是目前比較推薦的設計。
//預設路徑是output的path。


module.exports = {
    // 進入點。別跟<project root>/app.js搞混。這個是給前端用的。可以有多組。
    entry: {
        app: './src/js/app.js',
    },
    // 檔案輸出設定
    output: {
        // 輸出檔案名。
        filename: 'js/[name].bundle.js',
        // 輸出路徑。webpack v2以後需要指定絕對路徑。
        path: path.join(__dirname, 'public')
    },
    plugins: plugins,
    devtool: 'source-map',  //輸出code map,讓debug tool可以斷點。
    resolve: {
        modules: [ path.join(__dirname, "node_modules") ],
        extensions: ['.js', '.css']
    }, 
    module: {
        loaders: [
            // loads css
            { test: /\.css$/, loader: ExtractTextPlugin.extract({
                fallback: 'style-loader',
                use: 'css-loader'
                })
            },
            { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?mimetype=image/svg+xml' },
            { test: /\.woff(\d+)?(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?mimetype=application/font-woff' },
            { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?mimetype=application/font-woff' },
            { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?mimetype=application/font-woff' }
        ]
    }
};

註:
  • [name]為webpack的專用指令。會自動帶入entry的參數名。
  • plugins:指定需導入的plugin。已定義在前面...
  • resolve:指定「require」或是「import」指令該到哪裡找資料。因為我們沒有把前端跟server端所使用的模組拆開放,目前只要指定npm安裝過的模組的安裝目錄「node_modules」即可。 
  • module:指定讀入該模組之後該做怎樣的處理。test會把讀入的檔名以「正規表示法」的方式比對看看是否符合。符合的話就以此條件的指定loader處理。第一個條件是副檔名為.css,loader是ExtractTextPlugin。ExtractTextPlugin將會以套入的參數設定:先使用css-loader,若是loader報告無法處理的話,再使用style-loader處理。目前這是為了bootstrap必須被含入的「bootstrap/dist/css/bootstrap.css」的設定。有興趣把bootstrap.css從sass層次解譯之後還含入的話就自行多試試吧。
  • 因為bootstrap裡面有不少圖檔資源必須含入,所以下面含入了.svg/.woff/.eof/.ttf檔案的處理。使用「url-loader」做處理。


接著準備src/js/app.js,webpack會幫忙把相關會用到的.js全部包在一起。
包含之後測試angular的controller程式碼。

// style library
require("bootstrap/dist/css/bootstrap.css");
require("bootstrap/dist/css/bootstrap-theme.css");

// javascript library
var $ = require("jquery");
var angular = require("angular");
require("bootstrap/dist/js/bootstrap.js");  //bootstrap的功能

// angular module
require("./angular/controller");





為了方便測試webpack的處理,我們在package.json裡面的script多加一個build的指令去call web pack。到這裡,檔案結構大概是這樣:


然後就可以執行「npm run build」來執行webpack了。(非npm內建的關鍵字,都要加上「run」來告訴npm後面的指令是要到package.json的scripts裡面找。)
webpack執行結果:

看起來是沒問題。(實際上為了測出這樣的參數,已經錯了不下百次)
那就來準備基本的測試頁來試看看嘍。


前端開發

以下的頁面將會有一個輸入框跟顯示區來測試angular的雙向資料綁定,跟bootstrap的著色提示框。別忘了目前的開發環境,html檔是views目錄下的「index.ejs」。(請參考前面的檔案結構列表)

修改views/index.ejs 如下:
<!DOCTYPE html>
<html>
  <head>
    <title><%= title %></title>
    <link rel='stylesheet' href='stylesheets/app.bundle.css' />
    <script type="text/javascript" src="js/app.bundle.js" />
    <!--要含入的資料,會是經過webpack輸出的結果。-->
  </head>
  <body>
    <h1><%= title %></h1>
    <p>Welcome to <%= title %></p>

    <div class="alert alert-warning alert-dismissible show" role="alert">
      This is Bootstrap....
      <button type="button" class="close" data-dismiss="alert" aria-label="Close">
        <span aria-hidden="true">&times;</span>
      </button>
    </div>

    <!-- angular data binding test -->
    <div ng-app="app"  ng-cloak>
      <div ng-controller="helloCtrl" ng-init="init()">
        <input type="text" ng-model="demo">
        <div>{{ demo }} </div>
      </div>
    </div>
    </body>
</html>


修改src/js/angular/module.js 如下:
module.exports.angApp = angular.module("app", []);


修改src/js/angular/controller.js 如下:
let mHello = require('./module');

mHello.angApp.controller("helloCtrl", ['$scope', "$log", function($scope, $log) {
    $scope.init = function() {
        $log.debug('Hello Angular');
        $scope.demo = 'Hello Angular';
    };
}]);


使用browser連線localhost:3000之後的顯示結果:

輸入框裡面的文字修改之後,下方的字會跟著變化,黃色提示框右邊的X點選之後,提示會消失,應該是會動了。



Webpack的殺手應用:動態編譯

最後一步:build兼啟動server。經過一陣子的收集資料,發現webpack可以動態編譯,不過大部分都是靠webpack-dev-server的cli來達成。本文從原本的express的基底,加上webpack-dev-server,靠webpack-dev-server的動態編譯來達到改code免重新啟動debug。

需安裝的套件:
webpack-dev-server / webpack-dev-middleware / webpack-hot-middleware
npm install webpack-dev-server --save
npm install webpack-dev-middleware --save
npm install webpack-hot-middleware --save

修改<project root>/app.js...
把程式碼的20行以後到註解「// catch 404 and forward to error handler」
的部分修改如下:
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(lessMiddleware(path.join(__dirname, 'public')));

app.use('/', index);
app.use('/users', users);
   
if (process.env.NODE_ENV !== 'production') {  //只有開發模式才這樣用。
    const webpack = require('webpack');
    const webpackDevMiddleware = require('webpack-dev-middleware');
    const webpackHotMiddleware = require('webpack-hot-middleware');
    const config = require('./webpack.config.js');
    const compiler = webpack(config);

    app.use(webpackHotMiddleware(compiler));
    app.use(webpackDevMiddleware(compiler, {
        publicPath: "/",
    }));  //publicPath: 指定輸出的路徑是server url的根目錄開始。因為webpackDevMiddleware的所有資料是在記憶體中處理。
}
else  {
    //express-generator做出來的這個設定會導致動態編譯失效,讓它在非開發模式執行。
    app.use(express.static(path.join(__dirname, 'public')));
}

// catch 404 and forward to error handler

啟動webstorm的debug,從webstorm的console確認compile成功之後,使用browser連線「localhost:3000」,看看會不會有網頁出現。
成功的話,可以開始修改controller.js或是index.ejs或是自訂的css,然後reload browser,看看會不會更動,體驗免編譯開發。


以上。