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
未解決問題:
- 驗證,不同的欄位驗證不同,可以在欄位中傳入一個RegExp來驗證,複雜的驗證比如密碼比較,欄位之間有關聯的驗證如何通過欄位來驗證,目前本人沒有想到好辦法
- 複雜欄位,比如檔案上傳,傳入file或者img欄位可以明確表示需要上傳的欄位型別,這種一般是上傳檔案後得到一個連結,返回這個連結並寫入到資料庫中,暫時沒有實現。
1.3 使用同一個搜尋元件
同樣,搜尋也是根據幾個欄位來查詢資訊,這裡我們可以把搜尋分成兩種型別:
- 簡單搜尋,按照更新時間來搜尋,比如昨天,今天,當月,上月,名稱搜尋,其中昨天,今天,當月,上月做成tab的形式,名稱直接輸入框,並且回車搜尋。這個能滿足最普遍的搜尋功能。
- 複雜搜尋,簡單搜尋的基礎上加上要搜尋的欄位。
簡單搜尋
複雜搜尋
複雜搜尋中要搜尋的欄位照樣放在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.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'},見名知義,豈不是很爽?
未解決問題:
- 暫時沒有考慮like,between,>,<等情況。
- 這裡預設介面請求傳入的欄位名字和資料庫中表的欄位名字一致,這是不安全的。
- 使用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