1. 程式人生 > >node.js+react全棧實踐

node.js+react全棧實踐

利用業餘時間寫了個簡單的專案,使用react+node.js做的一個全棧實踐專案,前端參考了[React-Admin-Starter](https://github.com/veryStarters/react-admin-starter)這個專案,這個專案的自動配置路由,自動頁面骨架的思路很新穎。後端是node.js+express提供介面訪問,最主要的內容是mysql.js的使用和使用nginx反向代理來跨域。

1.前端parttime

前端基於框架React-Admin-Starter基本沒有改動。這是一個後臺管理系統,最常用的功能也就是增刪改查,這裡做了一些自己的調整。

1.1.統一的欄位名

開發PC端這種後臺專案,產品經理經常會提一些臨時需求。比如原型上一個表格欄位“編輯時間”,做到一般快結尾了或者已經快上線了,說要改成“更新時間”。這個時候就比較蛋疼了,當然最直接的辦法就是Ctrl+H全域性查詢,一個一個替換,但是遇到新手連編輯器都不是很熟的小夥伴就要捉急了(我見過一些剛入門的小夥子,用的是vscode,還真不知道全域性查詢,快速跳轉這些快捷鍵)。
前端專案中使用的是ant.design for react,table有兩個地方需要注意,資料來源和顯示列名:

// 資料來源
const dataSource = [
  { key: '1', name: '胡彥斌', age: 32, address: '西湖區湖底公園1號' },
  { key: '2', name: '胡彥祖', age: 42, address: '西湖區湖底公園1號' }
];

// 顯示列
const columns = [
  { title: '姓名', dataIndex: 'name', key: 'name' },
  { title: '年齡', dataIndex: 'age', key: 'age' },
  { title: '住址', dataIndex: 'address', key: 'address' }
]

這裡可以把所有欄位單獨寫在一個檔案裡面,從同一個地方引用這個欄位,這樣只修改這一個欄位所有的名字都改過來了。如下,columns.js 定義欄位:

const id = { title: 'ID', dataIndex: 'id', key: 'id', type: 'input' }
const name = { title: '姓名', dataIndex: 'name', key: 'name', type: 'input' }
const mobile = { title: '手機號', dataIndex: 'mobile', key: 'mobile', type: 'input' }
const email = { title: '郵箱', dataIndex: 'email', key: 'email', type: 'input' }
const thumb = { title: '頭像', dataIndex: 'thumb', key: 'thumb', render: src => <img alt='' src={ src }/> }
const user = [id, name, email, mobile, thumb, createTime, updateTime]
export {
  user
}

user/list/index.js使用欄位:

import { user } from './../../../columns'

<Table
    dataSource={userList}
    pagination={paginationProps}
    columns={user})}
    rowKey='id'
    size="middle"
    bordered/>

問題來了,如果有編輯,刪除欄位怎麼辦呢?這個時候就需要和引用它的地方互動了。這裡可以使用給子元件傳遞函式的方法來實現:

const action = props => {
  let { handleDelete, handleEdit } = props
  return {
    title: '操作',
    key: 'action',
    render: (text, record) => <span>
      <Popconfirm title='確定刪除?' onConfirm={() => handleDelete(record)} okText="確定" cancelText="取消">
        <Icon type="delete" className={style.deleteLink}/>
      </Popconfirm>
      <Divider type="vertical"/>
      <Icon type="edit" onClick={() => handleEdit(record)}/>
    </span>
  }
}
const user = { column: props => [id, name, email, mobile, thumb, createTime, updateTime, action(props)] } 

在使用這個欄位的時候就可以呼叫一個函式:

handleDelete(record) {
    api.user.deleteUser({ id: record.id }).then(res => {
        if (res.success) {
            this.search()
        }
    })
}
    
<Table
    dataSource={userList}
    pagination={paginationProps}
    columns={user.column({ handleDelete: this.handleDelete.bind(this), handleEdit: this.handleEdit.bind(this) })}
    rowKey='id'
    size="middle"
    bordered/> 

這裡給Table的columns屬性賦的是一個函式,函式引數是一個也是一個函式,這樣子元件就可以呼叫到這個函式,有點拗口,你懂就好。columns.js中的action欄位只是一個橋樑作用,根據具體邏輯傳遞進去的函式執行不同的操作,不同場合執行的操作不同,但是操作是類似的,基本都是刪除,和編輯兩個邏輯。

分頁也有類似的問題,比如那天產品經理說:“分頁樣式統一起來,每個地方可選的每頁個數都是20, 30, 50, 100”。我們也可以把這個定義在同一個地方,方便修改。這裡仍然定義在columns.js中

const pageSet = { current: 1, pageSize: 2, total: 0, showQuickJumper: true, showSizeChanger: true, pageSizeOptions: ['20', '30', '50', '100'] }

使用的,如果我們要需要某些場合需要覆蓋掉部分資訊,可以在state中使用...擴充套件運算子,然後後面跟上同名屬性來覆蓋,例如:

import { user, pageSet } from './../../../columns'
  constructor(props) {
    super(props)
    this.state = {
      showAdd: false,
      pageSet: { ...pageSet, pageSizeOptions: ['2', '10'] }
    }
  } 

這樣就不需要在每個業務邏輯裡都去定義列名,只需要在columns.js中去定義,組合,匯出欄位就好了。這樣可能也會有不妥的地方,理論上這裡應該包含這個系統中所有要顯示的列名,大一點的系統如果有成千上萬個欄位,這裡就多起來了。不過話說回來這總比在每個介面自己定義欄位寫的程式碼要少。

1.2 使用同一個新增彈框

新增資料,無非是一個彈出框,一個Form加上兩個按鈕,沒有必要為每一個介面寫一個,如果能給這個彈框傳入屬性,包含要新增的欄位,點選確定的時候呼叫父元件中的新增方法。這樣這個彈出框被公用起來,只起到收集資料,驗證資料的作用。

傳入要新增的欄位,一樣在columns.js這個檔案裡做文章,一般要新增的欄位和顯示在表裡的欄位是類似的,二般不一樣就難辦了,這樣最好還是區分開來,頂多是組合欄位而已。再者,如果新增的欄位時間型別,下拉框選擇,上傳的檔案,圖片怎麼辦呢? 可以在這個欄位里加上一個type欄位,表示控制元件型別,如下:

const email = { title: '郵箱', dataIndex: 'email', key: 'email', type: 'input' }
const createTime = { title: '建立時間', dataIndex: 'createTime', key: 'createTime', type: 'time' }
const user = { column: props => [id, name, email, mobile, thumb, createTime, updateTime, action(props)], field: [name, email, mobile, thumb]} 

引入field,傳遞給新增元件

import { user, pageSet } from './../../../columns'
<AddComp
    field={user.field}
    showAdd={showAdd}
    onAddData={this.addUser.bind(this)}
    title={route.title}/> 

在AddComp元件中使用傳入的欄位:

import React, { Component } from 'react'
import { Form, Modal, Input, message } from 'antd'

class AddDataComp extends Component {
  constructor(props) {
    super(props)
    this.state = {
    }
  }
  componentWillReceiveProps(nextProps, nextContext) {
    this.setState({ showAdd: nextProps.showAdd })
  }
  // 取消,關閉,呼叫父元件關閉彈框
  hideModel() {
    this.props.onClose()
  }
  // 確認,呼叫父元件,新增資料
  confirmForm() {
    this.props.form.validateFields((err, values) => {
      if (err) {
        message.error(err)
      }
      this.props.onAddData(values)
    })
  }
  render() {
    let { showAdd } = this.state
    let { field, title } = this.props
    let { getFieldDecorator } = this.props.form
    const formItemLayout = { labelCol: { span: 6 }, wrapperCol: { span: 18 }}
    return <Modal
      visible={showAdd}
      title={'新增' + title}
      centered
      onCancel={this.hideModel.bind(this)}
      onOk={this.confirmForm.bind(this)}>
      <Form {...formItemLayout}>
        {field.map((f, index) => <Form.Item key={f.key} label={f.title}>
          {getFieldDecorator(f.key, {
            validateTrigger: ['onChange', 'onBlur'],
            rules: [
              { required: true, whitespace: true, message: `${f.title}不能為空` },
            ],
          })(<Input placeholder={'請輸入' + f.title}/>)}
        </Form.Item>)}
      </Form>
    </Modal>
  }
}
const AddComp = Form.create({ name: 'add_comp' })(AddDataComp)
export default AddComp

未解決問題:

  1. 驗證,不同的欄位驗證不同,可以在欄位中傳入一個RegExp來驗證,複雜的驗證比如密碼比較,欄位之間有關聯的驗證如何通過欄位來驗證,目前本人沒有想到好辦法
  2. 複雜欄位,比如檔案上傳,傳入file或者img欄位可以明確表示需要上傳的欄位型別,這種一般是上傳檔案後得到一個連結,返回這個連結並寫入到資料庫中,暫時沒有實現。

1.3 使用同一個搜尋元件

同樣,搜尋也是根據幾個欄位來查詢資訊,這裡我們可以把搜尋分成兩種型別:

  1. 簡單搜尋,按照更新時間來搜尋,比如昨天,今天,當月,上月,名稱搜尋,其中昨天,今天,當月,上月做成tab的形式,名稱直接輸入框,並且回車搜尋。這個能滿足最普遍的搜尋功能。
  2. 複雜搜尋,簡單搜尋的基礎上加上要搜尋的欄位。

簡單搜尋 

複雜搜尋

複雜搜尋中要搜尋的欄位照樣放在common.js中,如下:

const user = { column: props => [id, name, email, mobile, thumb, createTime, updateTime, action(props)], field: [name, email, mobile, thumb], searchField: [name, email, mobile, createTime] } 

引用並使用:

import { user, pageSet } from './../../../columns'
<AddComp
  field={user.field}
  showAdd={showAdd}
  onAddData={this.addUser.bind(this)}
  title={route.title}/> 

SearchComp元件:

import React, { Component } from 'react'
import { Tabs, Input, Button, DatePicker } from 'antd'
const { TabPane } = Tabs
const { Search } = Input
const { RangePicker } = DatePicker
import style from './../static/css/index.pcss'
import { Type } from 'utils'

class SearchComp extends Component {
  constructor(props) {
    super(props)
    this.state = {
      moreSearch: true, // 顯示更多搜尋
      timeSpan: [{ name: 'today', title: '今天' },
        { name: 'yesterday', title: '昨天' },
        { name: 'currentMonth', title: '本月' },
        { name: 'lastMonth', title: '上月' }],
      searchObj: {}
    }
  }
  componentDidMount() {
  }
  // 搜尋條件
  setSearchState(event, column) {
    let { searchObj } = this.state
    if (event.type === 'time') {
      if (column[0]) {
        searchObj[`${event.dataIndex}Start`] = column[0].format('YYYY-MM-DD hh:mm')
      } else {
        delete searchObj[`${event.dataIndex}Start`]
      }
      if (column[1]) {
        searchObj[`${event.dataIndex}End`] = column[1].format('YYYY-MM-DD hh:mm')
      } else {
        delete searchObj[`${event.dataIndex}End`]
      }
    } else {
      if (event.target.value) {
        searchObj[event.target.name] = event.target.value
      } else {
        delete searchObj[event.target.name]
      }
    }
    this.setState(searchObj)
  }
  // 簡單搜尋,預設搜尋第一個欄位
  searchKeyword(value) {
    let searchObj = {}
    let { searchField } = this.props
    if (searchField.length > 0) {
      searchObj[searchField[0].key] = value
      this.onSearch(searchObj)
    }
  }
  // 回車搜尋
  searchEnterKeyword(e) {
    if (e.target.value) {
      let searchObj = {}
      let { searchField } = this.props
      if (searchField.length > 0) {
        searchObj[searchField[0].key] = e.target.value
        this.onSearch(searchObj)
      }
    }
  }
  // 條件搜尋
  searchClick() {
    let { searchObj } = this.state
    this.onSearch(searchObj)
  }
  // 觸發父元件搜尋
  onSearch(searchObj) {
    this.props.onSearch(searchObj)
  }
  // 新增,觸發父元件,彈出新增框
  popUpAdd() {
    this.props.onAdd()
  }
  getSearchItem = () => {
    let { searchField } = this.props
    return (<div className={style.searchItem}>
      {searchField.map((s, index) => {
        if (s.type === 'input') { // 文字框
          return <div key={s.key}>
            <label htmlFor={s.key}>{s.title}</label>
            <Input name={s.key} id={s.key} allowClear placeholder={s.title} onChange={this.setSearchState.bind(this)} className={style.itemInput}/>
          </div>
        } else if (s.type === 'time') { // 時間搜尋
          return <div key={s.key}>
            <label htmlFor={s.key}>{s.title}</label>
            <RangePicker name={s.key} id={s.key} allowClear onChange={ this.setSearchState.bind(this, s) } className={style.itemInput}/>
          </div>
        } else {
          return null
        }
      })}
      <div key='submit-button'>
        <Button>重置</Button>
        <Button type="primary" className={style.commonMarginLeft} onClick={this.searchClick.bind(this)}>搜尋</Button>
      </div>
    </div>)
  }
  render() {
    let { timeSpan, moreSearch } = this.state
    let { onAdd } = this.props
    return (<div>
      <div className={style.search}>
        <Tabs>{ timeSpan.map((t, i) => <TabPane tab={t.title} key={i}/>) }</Tabs>
        <div className={style.searchBox}>
          <Search
            allowClear
            className={style.itemInput}
            placeholder="請輸入關鍵字"
            onPressEnter={this.searchEnterKeyword.bind(this)}
            onSearch={this.searchKeyword.bind(this)}/>
          <Button
            onClick={() => this.setState({ moreSearch: !moreSearch })}
            icon="search"
            className={style.commonMarginLeft}/>
          {Type.isFunction(onAdd) ? <Button
            onClick={this.popUpAdd.bind(this)}
            className={style.commonMarginLeft}
            type="primary"
            icon="plus"/> : null}
        </div>
      </div>
      {moreSearch ? this.getSearchItem() : null}
    </div>)
  }
}
export default SearchComp

這裡使用onChange方法來收集搜尋資料,原理是給Input元件設定name,值是key,也就是欄位名,onChange方法中,使用event.target.name獲取欄位名字,使用event.target.value獲取Input的輸入值,這樣組成搜尋資料searchObj,最後把searchObj返回給父元件。

未解決問題:

  1. 時間搜尋一般是一個時間段,這個暫時沒有實現。
  2. 如果搜尋條件是一個下拉框選擇出來的,這個要給條件渲染成下拉框,這個暫時沒有實現。

1.4 mock資料和代理跨域

原框架提供自動生成mock檔案的功能,專案啟動後使用express啟用了http應用(parttime\scripts\addone\mock-server.js),埠是10086,專門監聽mock請求,在fetch(parttime\src\common\utils\fetch.js),proxyTable(parttime\src\rasConfig.js)中代理。如果不想走mock,就修改代理的target。不過上專案之後很少使用mock,增加了工作量不是?再說已經全棧開發了還要mock個啥呢?

2.後端parttimeApp

後端開發採用的express,mysql.js,pug實現的,注意這裡主要寫介面,pug模板基本上沒有用到。這個子專案基本上是按照官方文件來寫的。
使用express-generator來生成專案骨架,express的模板引擎好多,也不知道那個好,就按照官方文件中的例子給個pug來生成專案。專案中有個www檔案,是啟動檔案,可以直接執行這個檔案啟動。

2.1 資料庫訪問

要訪問介面要新增中介軟體body-parser,因為post,put,patch三種請求中包含請求提,node.js原生的http模組中,請求提是基於流的方式來接受,body-parser可以解析JSON,Raw,文字,URL-encoded格式的請求體。

var bodyParser = require('body-parser');
//解析 application/json
app.use(bodyParser.json());
//解析 application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({ extended: false }));
//轉發api/base請求
app.use('/api/base', indexRouter);
//轉發api/user請求
app.use('/api/user', usersRouter);

在usersRouter就是具體的介面請求了,如下:

var express = require('express');
var router = express.Router();
var config = require('./../conf/index')

/* GET users listing. */
router.get('/', function (req, res, next) {
    res.send('respond with a resource');
});

這裡簡單的分了個層,和java,.net程式碼一樣有router層(相當於業務邏輯層),dao層(資料訪問層)。dao層裡使用mysql.js訪問mysql資料庫。
這個地方說一下分頁的邏輯,分頁查詢使用的是limit offset,pageSize方式,但是有個重要的資訊要返回,就是資料行數,所以需要執行兩次請求,這就意味這要使用回撥嵌套了,這就不是很爽了,程式碼會成一坨。所幸mysql.js生成連線池的時候有個選項multipleStatements,把它設定成true,就可以一次執行兩個sql語句,有點類似儲存過程。

查詢介面一般是select column1,column2 ... from table where column1=value1 and column2=value2 ... order by updateTime desc limit offset, pageSize,這樣的,為了避免每次都拼接sql語句,這裡寫了一個統一處理函式,另外還使用current,pageSize生成offSet。
介面請求中出列current,pageSize,current欄位之外的欄位預設都是需要查詢的欄位,使用for...of方法輪詢查詢物件,生成where字尾。方法如下:

    paging: (sql, param) => {
        // 如果請求中有pageSize,使用current,pageSize生成offSet
        if (param.hasOwnProperty('pageSize')) {
            param.pageSize = parseInt(param.pageSize)
            param.offSet = param.current <= 1 ? 0 : (param.current - 1) * param.pageSize
        }
        for(let key in param) {
            if(!['pageSize', 'current', 'offSet'].includes(key)) {
                sql[0]+= ` AND ${key}=:${key}`
                sql[1]+= ` AND ${key}=:${key}`
            }
        }
        sql[0] += ' ORDER BY updateTime DESC LIMIT :offSet, :pageSize;'
        sql[1] += ' ORDER BY updateTime DESC;'
        return {sql: sql.join(''), param: param}
    } 

2.2 轉義

預設情況下使用?轉義,但是我覺得這種情況有點怪,例如select * from t_user where name=? and age=? and sex=?;這樣要傳入的引數是一個數組,並且要時刻注意陣列的順序和sql語句中?的順序保持一致,這是不是反人類?所幸mysql.js有提供一個配置queryFormat,自定義轉義,程式碼如下:

queryFormat: function (sqlString, values) {
    if (!values) return sqlString;
    return sqlString.replace(/\:(\w+)/g, function (txt, key) {
    if (values.hasOwnProperty(key)) {
        return this.escape(values[key]);
    }
    return txt;
    }.bind(this))
} 

這個函式的原理是使用字串的replace方法將sql語句中的:columnname替換成轉義後的請求值,這樣寫sql語句就方便多了,select * from t_user where name=:name and age=:age and sex=:sex; 還有傳入引數的時候就可以直接傳入一個物件就好,例如{name: '張三', age: 18, sex: 'man'},見名知義,豈不是很爽?

未解決問題:

  1. 暫時沒有考慮like,between,>,<等情況。
  2. 這裡預設介面請求傳入的欄位名字和資料庫中表的欄位名字一致,這是不安全的。
  3. 使用multipleStatements設定一次執行多條語句,也不是很安全,會有sql注入危險。

3. 部署上線

部署上線首先要有域名和空間,這沒啥好說的,就是買買買,不過域名不是必須的。
伺服器我用的是阿里雲的Ubuntu,要在裡面安裝nginx,node.js,npm,mysql,pm2或者forever。
mysql裝好之後命令可以連線,檢視,但是這不是影響工作效率,所有要用客戶端連線,我用的是navicat for mysql。首先要在阿里雲伺服器裡當前例項的安全組裡配置埠訪問規則,mysql使用的是3306,截圖如下:

還要允許root使用者從外網登陸,要修改mysql裡的user表,這裡不再贅述。

使用pm2啟動node.js專案,防止因出錯造成自動退出。pm2工具的使用就不再贅述。

最後前端使用proxyTable代理解決跨域問題的那一套,部署在伺服器上就不管用了,這裡沒有在後端修改伺服器響應頭Access-Control-Allow-Origin,而是使用nginx代理,具體做法是使用vhost,將來自localhost:3332/api/路徑的請求代理到本地127.0.0.1:3333。具體做法是在nginx的vhost目錄下新建一個parttime.conf,內容如下:

server {
        listen 3332;                                    # 埠
        server_name www.hzyayun.net hzyayun.net;        # 域名
        root /usr/local/app/parttime;                   # 站點根目錄
        index index.html;                               # 預設首頁
        location /api/ {
                proxy_pass http://127.0.0.1:3333;       # 請求轉發的地址
                proxy_connect_timeout 6000;             # 連線超時設定
                proxy_read_timeout 6000;
                proxy_redirect off;                     # 不修改請求url
        }
} 

在nginx的配置檔案ngxin.conf內修改http物件,在http配置的最後一行跟上include /etc/nginx/vhost/*.conf; 然後重啟nginx。最後還要開放3332,3333兩個埠。如下:

最後如果想用域名訪問,需要在阿里雲上解析域名,需要備案,太麻煩我就沒有弄,直接使用域名訪問:http://120.27.214.189:3332/

git地址:https://github.com/tylerdong/parttim