MERN Boilerplate


上學期學 Web Programming 的過程中,對於不斷湧上來的新技術總是感到期待,畢竟學得愈多能做的事情就愈多,把工具一個個串接在一起做想要做的東西也很有成就感。不過我一直覺得要重複那些實作上必經的例行步驟相當乏味,於是便把自己寫的 Blog App 改了一下,打造成一個以 Node 和 webpack 為基礎,React 做前端、Express 做 Server 並接上 Mongo database 的開發模板。

直到前陣子合作開發 Beact 的時候,Vibert Thio 學長所做的環境配置讓我有點嚇到(所謂的被技術霸凌),才發現自己根本沒有真的使用到 webpack 強大的地方。於是當時便下定決心暑假要找時間將 webpack 的細節研究的更深一點,改良之前那個陽春的模板,並用這些新技術回頭去修正開發 Beact 時因時間因素尚未解掉的 issue。

經過將近三天的折騰,總算完成了一個能看的新模板(回頭看舊版覺得好丟臉啊Orz)。聽說寫程式定期 review 和紀錄是提升自己的好習慣,所以我決定在這邊把自己這幾天開發時的一些想法和技術內容給記下來。模板的詳細內容可以參考底下連結,也歡迎善心/熱心人士發 pr~

Github:MERN-BoilerPlate

檔案結構

主要的 folder 有以下幾個:掌管後端的 /server、掌管前端原始碼的 /src,以及實際在做 production 時前端所生成、用來與 /server 端溝通的 /public(這個 folder 在開發時可以刪掉,並不影響)。webpack config 有三個版本,dev 是在開發階段所使用的,prod 和 server 則是做 production 用的。

/server:
包含了一個 express server 和對應的 config 檔案,/api 裡頭則是一個 mongo model 基本 CRUD controllers。

/src:
前端的邏輯全部都在這裡,/components 負責裝 React Components,並 export 到 index.js 讓 webpack compile。/scss 裝 Sass 程式碼,/utils 用來存非 React 的 Javascript Class & Function,/assets 存圖片、音訊、字體等資源,/testData 存開發階段測試用的資料。

.
├── .babelrc
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── README.md
├── assets
│   └── images
│       └── boilerplate-image.png
├── package.json
├── public
│   ├── bundle.js
│   ├── bundle.js.map
│   ├── index.html
│   ├── server.js
│   └── server.js.map
├── server
│   ├── api
│   │   ├── controllers
│   │   │   └── posts_controller.js
│   │   ├── models
│   │   │   └── Post.js
│   │   └── routes.js
│   ├── config.js
│   └── server.js
├── src
│   ├── assets
│   ├── components
│   │   └── App.js
│   ├── index.js
│   ├── scss
│   │   ├── style.scss
│   │   └── title.scss
│   ├── template.html
│   ├── testData
│   │   └── testData.json
│   └── utils
│       └── ExampleUtil.js
├── webpack.dev.config.js
├── webpack.prod.config.js
└── webpack.server.config.js

基本配置

由於 /src 和 /server 的配置很簡單,只是參考普通的 starter 模板,這裡就不多做介紹,而會將重點擺在我做 webpack 配置時用到的 loader 和 plugins 們。完整的 webpack 設定和 package.json 放在文章最後供參考,也可以直接進 github 看。

安裝的部分,如果要嘗試加裝在自己的專案,記得要將這些 loader 和 plugins 事先用 npm install。

babel-loader

這是最基本的,要將 React 和 ES6 compile 成正常的 Javascript,需要先加一個 .babelrc 寫自己想要的 presets(當然也可以直接寫在 webpack config 裡頭,只不過我習慣隔離出來),然後把 loader 寫到 webpack 。

// .babelrc
{
  "presets": [
    [
      "env",
      {
        "targets": {
          "browsers": ["last 2 versions", "safari >= 7"]
        }
      }
    ],
    "react",
    "stage-2"
  ]
}

// webpack.prod.config.js
const path = require('path');
module.exports = {
  entry: {
    bundle: [
      './src/index.js',
    ],
  },
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'public'),
    publicPath: '',
  },
  devtool: 'source-map',
  module: {
    loaders: [
      {
        test: /\.js$/,
        loaders: ['babel'],
        exclude: /node_modules/,
      },
    ],
  },
};

json-loader

在實作 React 的時候,有時候會需要一些測試用的 data,而這類 data 通常會用 json 的格式在 local 存起來,待需要用的時候送進 React 的程式裡面。加裝 json-loader 的好處是可以單純透過 import 就把原本的 json 資料轉換成 javascript 的 object,免去再多做一層轉換的操作。

style css sass loader

因為我希望把環境設成可以直接用 Sass 來寫 style,所以這邊除了標準的 style-loader 和 css-loader 配置外多加了一層 sass-loader。在開發過程中需要把用到的 .scss 檔 import 進對應到的 React Component,此時 loader 會由右到左執行:sass-loader 先把 Sass 程式碼轉成 css,css-loader 負責載入轉換後的 css 至 React Component,style-loader 再對這些 css 包上 style tag 嵌入,就可以不需要再手動額外加上 script file。

在引入 webpack loader 前,我原先的做法是在 server 端使用 node-sass-middleware 來製作 script file,只不過這對於使用多個 .scss 檔時感覺比較綁手綁腳,所以後來就統一轉到 webpack 了。

lodash-webpack-plugin

我喜歡在做開發時使用一些 lodash 的函數來處理 array 轉換,但 lodash 本身一載都是一大包,沒用到的功能往往會造成空間的浪費。所以這裡加裝了 lodash-webpack-plugin,可以讓實際生成 bundle 時只使用有被用到的 lodash 工具函數。

以上這幾個 loader 都很好裝,除了 lodash 的 plugin 以外都只要在 webpack config 裡寫上 loader 資訊就好了。lodash 的部分則因為跟 babel config 有關,我沒有改 webpack config,而是直接去 .babelrc 補上 plugins。

// .babelrc
{
  "presets": ["react", "es2015", "es2016", "stage-2"],
  "plugins": ["lodash"]
}

進階配置

專案開發的愈來愈大後往往會碰上一個問題,那就是要怎麼同時追求開發效率和產品效能?若要提升開發效率,會需要裝更多的 loader 和 plugins 來輔助,但這樣可能會造成因為太多外加的東西導致生成的 bundle 變大好幾倍,進一步讓產品的效能下降。

同時,某些提升開發效率的方法,整體的 workflow 原理和做 production 完全不同,這樣會造成即使開發的很順利,但真的要 build production 版本時又要從頭改 code。

這裡我使用的解決方法是把 development 用的 webpack config 和 production 用的分別寫在兩個檔案,也就是一開始檔案結構所列出的 webpack.dev.config.js 和 webpack.prod.config.js。基本配置到目前為止對兩者皆適用,但針對 webpack.dev.config.js 我們可以新增一些額外的套件來做開發效率上的優化。

webpack-dev-middleware & webpack-hot-middleware

平時在做 production 時,webpack 會直接 build 一個新的 bundle 到 file system 裡頭。但是如果使用 webpack-dev-middleware 這個套件並串接 server,bundle 就會被寫進並暫存在記憶體裡面,而不會真的被 build 出來。這時只要設定好 publicPath 讓 server 去 call 記憶體裡的 bundle,效果跟直接 build 在 file system 是一樣的,但速度上會快上許多。

設定好後,再補上 webpack-hot-middleware,甚至還可以實現開發過程中熱替換的功能。

不過就如同前述,這樣的開發原理和 production 並不相同,所以我們還需要在 package.json 裡頭的 scripts 補上 NODE_ENV 的設定。當 server 接收到的 NODE_ENV=prod 時,就去執行 production 的 bundle;當 NODE_ENV=dev 時,則執行 webpack-dev-middleware 和 webpack-hot-middleware。

// server/server.js
import config from './config';
const path = require('path');
const server = express();

if (process.env.NODE_ENV === 'dev') {
  const webpackMiddleware = require('webpack-dev-middleware');
  const webpackHotMiddleware = require('webpack-hot-middleware');
  const webpack = require('webpack');
  const webpackDevConfig = require('../webpack.dev.config.js');
  const compiler = webpack(webpackDevConfig);
  const middleware = webpackMiddleware(compiler, {
    publicPath: webpackDevConfig.output.publicPath,
    stats: { colors: true },
  });
  server.use(middleware);
  server.use(webpackHotMiddleware(compiler));
  server.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, '../public/index.html'));
  });
} else {
  server.use(express.static('public'));
  server.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, 'public/index.html'));
  });
}

server.listen(config.port, config.host, () => {
  console.info('Express listening on port', config.port);
  console.log(process.env.NODE_ENV);
});

至於 webpack config 的部分也要做些調整,好引入 webpack-hot-middleware。

// webpack.dev.config.js
const path = require('path');
const webpack = require('webpack');
const hotMiddlewareScript = 'webpack-hot-middleware/client';

module.exports = {
  entry: {
    bundle: [
      hotMiddlewareScript,
      './src/index.js',
    ],
  },
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'public'),
    publicPath: '/',
  },
  ...
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin(),
  ],
};

html-webpack-plugin

如果使用 webpack-dev-middleware,不免會發現一個問題,那就是 index.html 裡所接受的 bundle script 在 dev 和 prod 兩種不同的模式下會對應到不同的路徑。這個問題可以透過使用 html-webpack-plugin 來解決。

只要在 /src 寫一個沒有 script 的 template.html 檔,然後在 webpack 調好設定,它會在 build 後生成正式的 index.html 並在裡面插進需要的 script 和對應的路徑,這使得我們不再需要擔心路徑不一樣的問題。

寫法上只需要在 webpack config 把 plugin require 進來,然後送到 plugins 調一下設定即可。

// webpack.dev.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  ...
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin(),
    new HtmlWebpackPlugin({
      template: './src/template.html',
      filename: 'index.html',
    }),
  ],
};

react-hot-loader v3

雖然有了 webpack-hot-middleware,但它只提供了 server HMR 的功能,要在 React Component 實現熱替換還需要加裝 react-hot-loader v3 才行。據專題助教 chentsulin 所說:「要對 React Component 這麼複雜的 module 做 Hot Replacement 相當不容易,這個技術變革很多次,從 react-hot-loader v1 -> react-transfrom-hmr -> react-hot-loader v3(目前已經相當久都是使用這個)」

針對 react-hot-loader,webpack 的部分只要補上 entry 和 loaders 的資訊就可以了,但 React 的 root Component 要改成處於 hot mode 時會刷新的版本。

// webpack.dev.config.js
const reactHotLoaderScript = 'react-hot-loader/patch';
module.exports = {
  entry: {
    bundle: [
      hotMiddlewareScript,
      reactHotLoaderScript,
      './src/index.js',
    ],
  },
  ...
  module: {
    loaders: [
      ...
      {
        test: /\.js$/,
        loaders: ['react-hot-loader/webpack', 'babel'],
        exclude: /node_modules/,
      },
      ...
    ],
  },
  ...
};

React 端的調整(以下為了能在 blogger 顯示,特意在 jsx 裡頭多加了空格):

// src/index.js
import React from 'react';
import { render } from 'react-dom';
import App from './components/App';
import './scss/style.scss';

const rootElement = document.getElementById('root');

let app;
if (module.hot) {
  const { AppContainer } = require('react-hot-loader');
  app = (
    < AppContainer >
      < App />
    < /AppContainer >
  );
  module.hot.accept('./components/App', () => {
    const NewApp = require('./components/App').default;
    render(
      < AppContainer >
        < NewApp />
      < /AppContainer >,
      rootElement,
    );
  });
} else {
  app = < App />;
}

render(app, rootElement);

webpack-dashboard


這是一個可以優化開發介面的插件,比起看著 terminal 生一堆不明所以的 bundle 檔更能掌握現況。裝的方式跟其他 plugins 都大同小異,只要在 config 新增就行了。

// webpack.dev.config.js
const DashboardPlugin = require('webpack-dashboard/plugin');
module.exports = {
  ...
  plugins: [
    ...
    new DashboardPlugin(),
  ],
};

webpack-bundle-analyser



這個 plugin 的功能是將 bundle 成分做視覺化,在做系統優化時用來檢視哪些 package 太重相當好用。

// webpack.dev.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
  ...
  plugins: [
    ...
    new BundleAnalyzerPlugin(),
  ],
};

以上就是我在這個模板上所用到的配置,server 端的 webpack 設定跟 prod 版大同小異,主要就是要把 compile 的環境調成 node 的模式,這裡就不多做介紹。整體的設定上除了 webpack-dev-middleware、webpack-hot-middleware 和 react-hot-loader 需要動到 /server 和 /src 的內容以外,其餘都可以在 webpack config 和 package.json 實現。實際在開發時,只需要 npm run dev 即可;做 production 時則是 npm run build 編譯再接 npm start 開 server,兩個過程生成相同內容但又互相獨立,算是有達到一開始立下的目標。

最後附上 package.json 和完整的 webpack 設定碼。

// package.json
{
  "name": "mernboilerplate",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node public/server.js",
    "prebuild": "rimraf public",
    "build:client": "webpack --config webpack.prod.config.js -p",
    "build:server": "webpack --config webpack.server.config.js -p",
    "build": "npm-run-all build:*",
    "dev": "NODE_ENV=dev webpack-dashboard -- nodemon --exec babel-node server/server.js --ignore public/ --ignore src/"
  },
  "author": "Chan Yu An",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.16.1",
    "body-parser": "^1.17.2",
    "ejs": "^2.5.6",
    "express": "^4.15.3",
    "mongodb": "^2.2.27",
    "mongoose": "^4.10.3",
    "prop-types": "^15.5.10",
    "react": "^15.5.4",
    "react-dom": "^15.5.4",
    "react-router": "^4.1.1",
    "react-router-dom": "^4.1.1"
  },
  "devDependencies": {
    "babel-cli": "^6.24.1",
    "babel-core": "^6.25.0",
    "babel-eslint": "^7.2.3",
    "babel-loader": "^7.0.0",
    "babel-preset-env": "^1.6.0",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-2": "^6.24.1",
    "babel-plugin-lodash": "^3.2.11",
    "css-loader": "^0.23.1",
    "eslint": "^3.19.0",
    "eslint-config-airbnb": "^14.1.0",
    "eslint-plugin-import": "^2.2.0",
    "eslint-plugin-jsx-a11y": "^4.0.0",
    "eslint-plugin-react": "^7.0.1",
    "html-webpack-plugin": "^2.29.0",
    "json-loader": "^0.5.4",
    "lodash": "^4.17.4",
    "lodash-webpack-plugin": "^0.11.4",
    "node-sass": "^4.5.3",
    "nodemon": "^1.11.0",
    "npm-run-all": "2.3.0",
    "react-hot-loader": "^3.0.0-beta.6",
    "rimraf": "2.5.4",
    "sass-loader": "^6.0.6",
    "style-loader": "^0.13.1",
    "webpack": "2.1.0-beta.20",
    "webpack-dashboard": "^0.4.0",
    "webpack-dev-middleware": "^1.11.0",
    "webpack-hot-middleware": "^2.13.2"
  }
}

// webpack.prod.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    bundle: [
      './src/index.js',
    ],
  },
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'public'),
    publicPath: '',
  },
  devtool: 'source-map',
  module: {
    loaders: [
      {
        test: /\.json$/,
        loader: 'json-loader',
      },
      {
        test: /\.js$/,
        loaders: ['babel'],
        exclude: /node_modules/,
      },
      {
        test: /\.scss$/,
        loader: 'style!css!sass',
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/template.html',
      filename: 'index.html',
    }),
  ],
};

// webpack.dev.config.js
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const DashboardPlugin = require('webpack-dashboard/plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

const hotMiddlewareScript = 'webpack-hot-middleware/client';
const reactHotLoaderScript = 'react-hot-loader/patch';


module.exports = {
  entry: {
    bundle: [
      hotMiddlewareScript,
      reactHotLoaderScript,
      './src/index.js',
    ],
  },
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'public'),
    publicPath: '/',
  },
  devtool: 'eval',
  module: {
    loaders: [
      {
        test: /\.json$/,
        loader: 'json-loader',
      },
      {
        test: /\.js$/,
        loaders: ['react-hot-loader/webpack', 'babel'],
        exclude: /node_modules/,
      },
      {
        test: /\.scss$/,
        loader: 'style!css!sass',
      },
    ],
  },
  plugins: [
    new BundleAnalyzerPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin(),
    new HtmlWebpackPlugin({
      template: './src/template.html',
      filename: 'index.html',
    }),
    new DashboardPlugin(),
  ],
};

// webpack.server.config.js
const path = require('path');
const fs = require('fs');

module.exports = {
  entry: './server/server.js',
  output: {
    filename: 'server.js',
    path: path.join(__dirname, 'public'),
  },
  target: 'node',
  externals: fs.readdirSync('node_modules').reduce((acc, mod) => {
    if (mod === '.bin') {
      return acc;
    }
    acc[mod] = `commonjs ${mod}`;
    return acc;
  }, {}),
  node: {
    console: false,
    global: false,
    process: false,
    Buffer: false,
    __filename: false,
    __dirname: false,
  },
  resolve: {
    extensions: ['.js', '.json'],
  },
  devtool: 'source-map',
  module: {
    loaders: [
      {
        test: /\.json$/,
        loader: 'json-loader',
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        query: {
          presets: [
            [
              'env',
              {
                targets: { node: 7 },
                useBuiltIns: true,
              },
            ],
            'react',
            'stage-2',
          ],
          plugins: ['lodash'],
        },
      },
    ],
  },
};

參考資料

[1] webpack - Development
[3]  手把手深入理解 webpack dev middleware 原理與相關 plugins
[4] 設定開發React的環境 - React Hot Loader
[5] Setting Up Webpack Dev Middleware in Express
[6] React Hot Loader - Getting Started
[11] github - webpack-dev-middleware-boilerplate

特別感謝專題的助教林承澤 chentsulin 以及ReactJS.tw 版上的陳愷奕幫忙解惑!

Comments

Popular posts from this blog

物理與奧林匹亞大小事

怎麼準備科學班甄試

柯南主線漫畫列表