1. 程式人生 > >輪子篇:基於Node和React的全棧式架構

輪子篇:基於Node和React的全棧式架構

寫在前面

花幾天時間做了個小東西,不得不提,麻雀雖小,但五臟俱全,充分體現出一個全棧工程師在小專案上高效的產出能力 (^-^)。簡單介紹下:

架構適宜

如果你是一個前端開發工程師,並且懂一點Node和資料庫。有一天,你的老闆逼你快速開發一個移動端的商城加一個管理後臺,請不要慌張,裝上我的輪子跑跑看。

快速搭建

本打算弄個腳手架工具,但是出於教學的目的,還是一步步地告訴大家怎麼搭這個全棧式的框架。

express生成服務端雛形

會點Node的應該對express不陌生,模版引擎我習慣使用ejs,所以執行下面命令:

$ express -e myapp && cd
myapp && npm install

這樣服務端的雛形就有了:

|----myapp
   |----bin/
   |----node_modules/
   |----public/
   |----routes/
   |----views/
   |----app.js
   |----package.json

設計和部署資料庫

Mac 上推薦使用 MySQLWorkBench 設計和管理資料庫,當然要是你夠牛逼,不用GUI工具也行,直接敲命令也可以玩。設計好資料庫表關係之後,匯出.sql檔案並生成資料庫。

node連線mysql

node連線mysql

需要用的三方的mysql庫,先安裝:

$ npm install mysql -save

不妨搞個配置檔案:config.db.js

/**
 * @desc mysql資料庫配置檔案
 **/

var config = {
    host: 'localhost',
    port: 3306,
    user: 'root',
    password: '你的資料庫密碼',
    database: '你的資料庫名稱',
};

module.exports = config;

連連看

var mysql = require('mysql'),
    config = require
('./config.db'); var con = mysql.createConnection(config);

也來耍耍MVC

雖然後端不是強項,但也不能太失水準,設計模式上怎麼也搞個MVC,看新的目錄結構:

|----myapp
   ....
   |----database/          /*管理資料模型(即資料模型)*/
      |----config.db.js    /*連線配置*/
      |----user.db.js      /*使用者模型,以這個為例*/
   |----route/             /*路由+業務邏輯處理*/
      |----services/       /*業務邏輯處理(即控制器)*/
        |----user.ctrl.js  /*使用者控制器,以這個為例*/
        |----index.js      /*預設路由*/
        |----api.js        /*API入口*/
      |----helper.js       /*後端使用的工具方法*/
   |----views/             /*模版檔案(即檢視)*/
      |----index.ejs       /*前臺入口*/
      |----admin.ejs       /*後臺入口*/

user.db.js舉例

/**
 * @desc 使用者 資料模型
 * @author Jafeney <692270687@qq.com>
 **/

var mysql = require('mysql'),
    helper = require('../routes/helper'),
    config = require('./config.db');

var con = mysql.createConnection(config);

/*使用者模組 構造方法*/
var User = function(user) {
    this.props = user.props  //引數集合,借鑑react設計思想
};

/*獲取全部資料,測試介面使用,正式上線時請關閉*/
User.prototype.getUserAllItems = function(callback) {
    var _sql = "select * from user where u_del=0";
    helper.db_query({
        connect: con,
        sql: _sql,
        name: 'getUserAllItems',
        callback: callback
    })
}

module.exports = User

helper.js放什麼

其實後端開發過程是用到的工具方法都可以放進去,這裡先舉例3個常用的(當然有些方法前端也能使用,建議分開存放,方便以後的歸併)

/**
 * @desc 工具模組
 * @author Jafeney <[email protected]>
 **/
var crypto = require('crypto');
module.exports = {
    // 獲取本地時間字串
    getTimeString: function(date) {
        return date.getFullYear() + '-' + (date.getMonth() + 1) + '-' +
            date.getDate() + ' ' + date.getHours() + ':' + date.getMinutes() +
            ':' + date.getSeconds();
    },
    // MD5加密
    getMD5: function(str) {
        var md5 = crypto.createHash('md5');
        md5.update(str);
        return md5.digest('hex');
    },
    // 執行sql語句
    db_query(opt) {
        opt.connect.query(opt.sql, function(err, res) {
            if (err) {
                console.log(`${opt.name} err: + ${err}`);
            } else {
                console.log(`${opt.name} success!`);
                if (typeof(opt.callback) === 'function') {
                    opt.callback(err, res);
                }
            }
        });
    }
}

user.ctrl.js舉例

/**
 * @desc 使用者 控制器
 * @author Jafeney <692270687@qq.com>
 **/

var User = require('../../database/user.db');

module.exports = {
    // 模組初始化
    init: function(app) {
        app.get('/user', this.doGetUserAllItems)
    },
    // 獲取所有使用者資訊
    doGetUserAllItems: function(req, res) {
        var props = {};  //預設引數為空
        var user = new User({props: props});
        user.getUserAllItems(function(err, data) {
            if (data.length) {
                return res.send({
                    code: 200,
                    data: data
                })
            } else {
                console.log(err)
                return res.send({
                    code: 500,
                    message: '出錯了'
                })
            }
        })
    }
}

還是前後端分離吧

做前端的時候,最希望看到的就前後端分離和解耦,好吧入鄉隨俗,也來體驗下後端怎麼寫restful介面

配置一層單獨的路由

為了區分檢視路由和API路由,我們給API提供一層單獨的路由,在app.js里加這兩行:

var api = require('./routes/api');
app.use('/api', api);

api.js長啥樣

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

var FS_PATH_SERVICES = './routes/services/';
var REQUIRE_PATH_SERVICES = './services/';

router.options('*', function (req, res, next) {
    next();
});

try {
    var list = fs.readdirSync(FS_PATH_SERVICES);
    for (var e; list.length && (e = list.shift());) {
        var service = require(REQUIRE_PATH_SERVICES + e);
        service.init && service.init(router);
    }
} catch(e) {
    console.log(e);
}

module.exports = router;

好了,到這裡後端算是佈置好了,重啟node服務,可以測試一下api介面,比如: http://localhost/api/user 去測試使用者介面是否正常

配置前端工程

前端,是時候表演真正的技術了。抄上咱們的武器:React 、 React-Router 、 Redux 、 ES2015 、Less、Webpack…,向著硝煙奮起!

React環境搭建

目前國內react和vuex的PK正搞得火熱,在我看來同為JS框架,兩者的優勢其實類似,只要能得心應手地解決實際問題,也無需你死我活。而我React用得比較順手,這裡就以React為例吧。

依賴的node_modules

"dependencies": {
  "babel-polyfill": "^6.16.0",   
  "immutable": "^3.8.1",          
  "isomorphic-fetch": "^2.2.1",
  "react": "^15.4.1",
  "react-dom": "^15.4.1",
  "react-redux": "^4.4.6",
  "react-redux-spinner": "^0.4.0",
  "react-router": "^3.0.0",
  "react-router-redux": "^4.0.7",
  "redux": "^3.6.0",
  "redux-immutablejs": "0.0.8",
  "redux-logger": "^2.7.4",
  "redux-thunk": "^2.1.0",
},
"devDependencies": {
  "babel-core": "^6.18.2",
  "babel-loader": "^6.2.8",
  "babel-preset-es2015": "^6.18.0",
  "babel-preset-react": "^6.16.0",
  "babel-preset-stage-0": "^6.16.0",
  "css-loader": "^0.26.0",
  "file-loader": "^0.9.0",
  "img-loader": "^1.3.1",
  "less": "^2.7.1",
  "less-loader": "^2.2.3",
  "style-loader": "^0.13.1",
  "url-loader": "^0.5.7",
  "webpack": "^1.13.3"
}

玩玩babel

react使用babel除了安裝依賴,.babelrc的配置還有個注意點,為了支援JSX和ES2015的最新提案,presets需要這麼寫:

{ "presets": ["es2015","react","stage-0"] }

耍耍webpack

webpackgulp要好用不少,下面是這個架構下的webpack.config.js寫法:

/**
 * @desc 專案webpack配置檔案
 * @author Jafeney <692270687@qq.com>
 **/

var webpack = require('webpack');
var path = require('path');
var nodeModulesPath = path.join(__dirname, '/node_modules');

module.exports = {
    entry: {
        admin: './src/entries/admin',
        front: './src/entries/front',
        // 作為外部模組,不打包到webpack的主檔案
        vendor: ['react', 'react-dom', 'redux'],
    },
    output: {
        path: path.join(__dirname, '/public/build'),
        publicPath: '/assets/',
        filename: '[name].bundle.js'
    },
    module: {
        noParse: [
            path.join(nodeModulesPath, '/react/dist/react.min'),
            path.join(nodeModulesPath, '/react-dom/dist/react-dom.min'),
            path.join(nodeModulesPath, '/redux/dist/redux.min'),
        ],
        loaders: [
            { test: /\.less$/, loader: 'style!css!less' },
            { test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader' },
            { test: /\.(gif|jpg|png)$/, loader: 'url?limit=8192&name=images/[name].[hash].[ext]' },
            { test: /\.(woff|svg|eot|ttf)$/, loader: 'url?limit=50000&name=fonts/[name].[hash].[ext]' }
        ]
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env': {
                NODE_ENV: JSON.stringify('production')
            }
        }),
        // new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } }), // 版本上線時開啟
        new webpack.optimize.CommonsChunkPlugin('common.js'),  // 抽取公共部分
        new webpack.optimize.OccurenceOrderPlugin(),
        new webpack.NoErrorsPlugin()
    ]
}

注意入口檔案有三個:admin、front和vendor。admin是管理後臺的入口、front是前臺商城的入口、vender則是把react、react-dom、redux這三個大的依賴模組單獨抽離成一個檔案,這樣可以大大減小webpack打包後文件的大小。還有一個技巧是 commonsChunkPlugin() 這個外掛,它可以再次抽取輸入檔案的公共部分,再次減小這三個檔案的大小,然後利用瀏覽器的並行載入能力,稍稍加快整個專案的載入速度。

打包後的模組怎麼引?

前面也說到在後端的Views目錄裡商城主頁管理後臺對應的模版檢視分別是 index.ejsadmin.ejs,而webpack打包好的檔案會作為靜態資源放在public的build目錄下:

商城檢視入口 index.ejs(移動端)

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title id="page_title">你的網站名稱</title>
        <meta name="description" content="你的網站名稱" />
        <meta name="keywords" content="商城,福利" />
        <meta content="yes" name="apple-mobile-web-app-capable" />
        <meta content="telephone=no" name="format-detection" />
        <meta content="email=no" name="format-detection" />
        <meta content="black" name="apple-mobile-web-app-status-bar-style">
        <link rel="shortcut icon" type="image/x-icon" href="http://xiangke.da56.com/static/img/xiangke.ico" media="screen" />
        <link href="http://xiangke.da56.com/static/img/xiangke.ico" rel="apple-touch-icon">
        <link rel="stylesheet" href="http://www.da56.com/static/css/loader.css">
        <script type="text/javascript">
            !function(j){function i(){j.rem=m.getBoundingClientRect().width/16,m.style.fontSize=j.rem+"px"}var p,o=j.navigator.appVersion.match(/iphone/gi)?j.devicePixelRatio:1,n=1/o,m=document.documentElement,l=document.createElement("meta");if(j.dpr=o,j.addEventListener("resize",function(){clearTimeout(p),p=setTimeout(i,300)},!1),j.addEventListener("pageshow",function(b){b.persisted&&(clearTimeout(p),p=setTimeout(i,300))},!1),m.setAttribute("data-dpr",o),l.setAttribute("name","viewport"),l.setAttribute("content","initial-scale="+n+", maximum-scale="+n+", minimum-scale="+n+", user-scalable=no"),m.firstElementChild){m.firstElementChild.appendChild(l)}else{var k=document.createElement("div");k.appendChild(l),document.write(k.innerHTML)}i()}(window);
        </script>
    </head>
    <body>
        <div id="root">
            <div id="floatBarsG">
                <div id="floatBarsG_1" class="floatBarsG"></div>
                <div id="floatBarsG_2" class="floatBarsG"></div>
                <div id="floatBarsG_3" class="floatBarsG"></div>
                <div id="floatBarsG_4" class="floatBarsG"></div>
                <div id="floatBarsG_5" class="floatBarsG"></div>
                <div id="floatBarsG_6" class="floatBarsG"></div>
                <div id="floatBarsG_7" class="floatBarsG"></div>
                <div id="floatBarsG_8" class="floatBarsG"></div>
            </div>
        </div>
        <script src="/build/common.js"></script>
        <script src="/build/vendor.bundle.js"></script>
        <script src="/build/front.bundle.js"></script>
    </body>
</html>

這裡我簡要說明一下,上面的 head 部分把移動端適配(包括rem佈局)的工作都做了,有了它,移動端你直接就可以用rem進行佈局了,具體怎麼玩我下面會介紹。

可能有人對 floatBarsG 這一層有疑問。這其實是為了解決單頁應用載入時的白屏做得CSS3載入動畫,配合head的loader.css可以有一個不錯的載入效果(你可以自己定製一套)。

後臺不需要做移動適配,head部分就簡單多了:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>管理後臺</title>
        <link rel="stylesheet" href="http://www.da56.com/static/css/loader.css">
        <link rel="shortcut icon" href="http://www.da56.com/src/images/icon.ico" />
    </head>
    <body>
        <div id="root">
            <div id="floatBarsG">
                <div id="floatBarsG_1" class="floatBarsG"></div>
                <div id="floatBarsG_2" class="floatBarsG"></div>
                <div id="floatBarsG_3" class="floatBarsG"></div>
                <div id="floatBarsG_4" class="floatBarsG"></div>
                <div id="floatBarsG_5" class="floatBarsG"></div>
                <div id="floatBarsG_6" class="floatBarsG"></div>
                <div id="floatBarsG_7" class="floatBarsG"></div>
                <div id="floatBarsG_8" class="floatBarsG"></div>
            </div>
        </div>
        <script src="/build/vendor.bundle.js"></script>
        <script src="/build/admin.bundle.js"></script>
    </body>
</html>

我的Redux玩法

redux也不是什麼神祕的東西啦,不過相比 flux 確實好用不少,尤其是處理業務邏輯的能力和對store的管理都比較好用。

前端目錄結構

|----src/                   /*前端程式碼盡在此目錄下*/    
  |----components/          /*專案用到的元件*/
  |----containers/          /*頁面容器*/
    |----admin/             /*管理後臺的頁面容器*/
      |----login.js         /*登入頁面容器,以這個為例*/
      |----style.less       /*管理後臺樣式,統一寫在這個less裡*/
    |----front/             /*前臺商城的頁面容器*/
      |----basic/           /*基礎樣式*/
        |----global.less    /*全域性通用樣式以及變數*/
        |----reset.less     /*頁面初始化的樣式*/
        |----size.less      /*字型已經rem配置*/
      |----home.js          /*商城主頁容器,以這個為例*/
      |----style.less       /*前臺商城的樣式,統一寫在這個less裡*/
  |----entries/             /*入口*/
    |----admin.entry.js     /*後臺入口*/
    |----front.entry.js     /*前臺入口*/
  |----mixins/              /*混入方法*/
    |----helper.js          /*前端使用的工具方法*/
    |----pure-render.js     /*載入優化*/
  |----redux/               /*redux*/
    |----actions/           /*actions*/
    |----reducers/          /*reducers*/
    |----configStore.js     /*store配置*/
    |----types.js           /*store定義*/
  |----routes/              /*前端路由*/
    |----admin.route.js     /*管理後臺路由*/
    |----front.route.js     /*前臺商城路由*/
  |----config.js            /*前端配置檔案*/

關於佈局

PC端隨意些,可以用畫素佈局。這裡說說移動端,正好結合 rem 說說這套佈局的玩法:

前文在 head 部分已經給頁面的 html標籤定義了 data-dprfont-size作為基準單位。 再結合下面這套less版的尺寸方案:

// @desc    提供 750px尺寸的 尺寸 (包括字型大小)的一些常用方法
// 為什麼不使用rem 設定字型?
// 參見 https://github.com/imweb/mobile/issues/3
@g-base: 46.875rem;
@g-font-base: 40rem;
.px2px(@name, @px){
    @{name}: round(@px / 2) * 1px;
    [data-dpr="2"] & {
        @{name}: @px * 1px;
    }
    // for mx3
    [data-dpr="2.5"] & {
        @{name}: round(@px * 2.5 / 2) * 1px;
    }
    // for 小米note
    [data-dpr="2.75"] & {
        @{name}: round(@px * 2.75 / 2) * 1px;
    }
    [data-dpr="3"] & {
        @{name}: round(@px / 2 * 3) * 1px
    }
    // for 三星note4
    [data-dpr="4"] & {
        @{name}: @px * 2px;
    }
}
.px2rem(@name, @px) {
    @{name}: (@px / 46.875) * 1rem;
}
//margin,padding, border可以使用這個設定兩個值
.mpb(@name, @px, @py) {
    @{name}: (@px / 46.875) * 1rem (@py / 46.875) * 1rem;
}
.fontSize(@px) {
    .px2px(font-size, @px);
}

.size(@thesize) {
    width: @thesize;
    height: @thesize;
}

.size(@width, @height) {
    width: @width;
    height: @height;
}

大家知道UI給出的移動端設計稿一般是 2x 規格的,以 Iphone6的375寬度為例,設計給出的一般是750,那麼我們在用rem佈局時,寬度就是:

   750rem/@g-base

並且它會自動適配Iphone各個尺寸和常用的Android螢幕,省時省心。

React-Router怎麼玩

React-Router也不神祕,其實就是前端路由的一層封裝,配置也很簡單。這裡因為結合redux來使用,所以稍稍有點不同,拿前臺商城為例吧:

front.entry.js

/**
 * @desc 商城入口
 * @author Jafeney <692270687@qq.com>
 **/
import React from 'react'
import { render } from 'react-dom'
// redux
import { Provider } from 'react-redux'
// router
import { Router, hashHistory } from 'react-router'
import { syncHistoryWithStore } from 'react-router-redux'
import routes from '../routes/front'
import configureStore from '../redux/configureStore'

const store = configureStore(hashHistory)
const history = syncHistoryWithStore(hashHistory, store)

render(
    (
        <Provider store={store}>
            <Router history={history} routes={routes} />
        </Provider>
    ), document.getElementById('root')
)

front.route.js

/**
 * @desc 專案路由設定
 * @author Jafeney <692270687@qq.com>
 **/

import React from 'react'
import { Route } from 'react-router'

import Door from '../containers/front/door'
import Home from '../containers/front/home'

const routes = (
    <Route>
        <Route path="/" component={Door} />
        <Route path="/home" component={Home} />
    </Route>
);

export default routes

Immutable管理你的reducers

Immutable之前也有單獨介紹過,可以提高物件的取值效率,這裡主要是和 reducer 結合使用,舉個例子:

/**
 * @desc 輪播 reducer
 **/

import Immutable from 'immutable';
import * as TYPES from '../types'
import { createReducer } from 'redux-immutablejs'

export const carousel = createReducer(Immutable.fromJS({preload: false}), {
    [TYPES.CAROUSEL_UPDATE]: (state, action) => {
        return state.set('preload', true).merge(Immutable.fromJS(action.result))
    },
    [TYPES.CAROUSEL_CLEAN]: (state, action) => {
        return state.clear().set('preload', false)
    }
})

然後我們在頁面裡可以用 .get('@name') 來獲取物件的屬性。

注意:如果Immutable物件是個List,必須先map()一下,然後再用get()方法取值。

有個得心應手的元件庫

React搞得快一年了,前段時間也自己寫了個元件庫 Royal,不過一直疲於新業務開發,沒有很好地整理文件和維護,挺可惜的,不過我開發新專案還是把Royal運用起來,對於有問題的元件進行修改和優化。唉,也是力不從心,期待有人能幫我打理打理吧 ^o^。在此推薦幾個時尚的元件庫吧:

Antd

螞蟻金服開發一個比較全面的React元件庫,我以前也推薦過,確實蠻不錯,唯一的痛點應該是它的原始碼,學習起來比較費勁。
文件地址: https://ant.design/docs/react/introduce

Material-UI

Grommet

用git進行託管

三方託管程式碼是個好習慣,有效防止程式碼丟失或者出錯後回滾。


/*Git 全域性設定*/

$ git config --global user.name "Jafeney"
$ git config --global user.email "[email protected]"

/*建立新版本庫*/

$ git clone [email protected].aliyun.com:b2b/test.git
$ cd test
$ touch README.md
$ git add README.md
$ git commit -m "add README"
$ git push -u origin master

/*已存在的資料夾或 Git 倉庫*/

$ cd existing_folder
$ git init
$ git remote add origin [email protected].aliyun.com:b2b/test.git
$ git add .
$ git commit
$ git push -u origin master

新增.gitignore 阻止node_modules或編譯後的檔案等進入版本庫

node_modules
.DS_Store
build