iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 25
0
Modern Web

Half-Stack Developer 養成計畫系列 第 25

如果有一天我變得更複雜:Redux

如果有一天我變得更複雜:Redux

有鑒於前面幾篇文章其實都講得有點簡略,怕大家不知道 SPA 的全貌到底是怎樣,我決定在這一篇裡面寫一個完整的專案出來,當作一個簡單的小範例。我最喜歡的範例就是超簡單版 blog,只要可以發文刪文就好了,十分容易。

我們就先從後端的部分開始吧,希望你沒有忘掉後端要怎麼寫。

mkdir blog-api
cd blog-api
npm init
npm install body-parser express mongodb --save

接著先新建一個 db.js,來處理跟資料庫有關的東西,程式碼都跟我們之前寫過的簡易留言板差不多:

var MongoClient = require('mongodb').MongoClient;
var ObjectId = require('mongodb').ObjectId;

// 要連接的網址
var url = 'mongodb://localhost:27017/blog';
var db = null;

var DB = {
  connect: function (cb) {

    // 連接到 DB
    MongoClient.connect(url, function(err, mongo) {
      console.log("Connected successfully to server");
      db = mongo;
      cb(err);
    });
  },

  addPost: function (post, cb) {
    var collection = db.collection('documents');

    // 寫入資料
    collection.insert(post, function(err, result) {
      cb(err, result);
    });
  },

  deletePost: function (id, cb) {
    var collection = db.collection('documents');

    collection.deleteOne({ _id : ObjectId(id) }, function(err, result) {
      console.log(err, result);
      cb(err, result);
    }); 
  },

  getPosts: function (cb) {
    var collection = db.collection('documents');

    collection.find({}).toArray(function(err, docs) {
      cb(err, docs);
    });
  }
}

module.exports = DB;

再來是重要的 index.js,核心程式碼都在裡面,主要就是處理各個 API 的路由:

var express = require('express');
var bodyParser = require('body-parser')
var db = require('./db');

var app = express();

// 有了這個才能透過 req.body 取東西
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())

// 有了這個才能讓不同網址也拿到資料
app.use(function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header("Access-Control-Allow-Headers", "Content-Type, Access-Control-Allow-Headers");
  next();
})

// 直接輸出所有留言
app.get('/posts', function (req, res) {

  // 拿出所有的留言
  db.getPosts(function (err, posts) {
    if (err) {
      res.send(err);
    } else {
      res.send(posts.reverse());
    }
  })
});

// 刪除文章
app.get('/posts/delete/:id', function (req, res) {
  var id = req.params.id;
  db.deletePost(id, function (err) {
    if (err) {
      res.send({
        status: 'FAILURE',
        err: err
      });
    } else {

      // 成功後輸出成功
      res.send({
        status: 'SUCCESS'
      });
    }
  })
})

// 新增文章
app.post('/posts', function (req, res) {
  var title = req.body.title;
  var content = req.body.content;

  console.log(req.body);

  db.addPost({
    title: title,
    content: content,
    createTime: new Date(),
  }, function (err, data) {
    if(err) {
      res.send({
        status: 'FAILURE',
        err: err
      });
    } else {
      res.send({
        status: 'SUCCESS'
      });
    }
  })
})

db.connect(function (err) {
  if(!err) {
    app.listen(5566, function () {
      console.log('Example app listening on port 5566!')
    })
  }
})

我們一共有三個 API:

  1. GET /posts 取得所有文章
  2. GET /posts/delete/:id 刪除文章
  3. POST posts/ 新增文章

後端的部分輕鬆解決了,再來就是前端的部分。其實簡單也不難,就分成兩個頁面就好,一個是發文的表單,另外一個是顯示所有文章的頁面。這次為了讓大家看看專案結構應該要怎麼切,不再像之前一樣把所有組件都集合在一起。

一樣先從index.js開始:

import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route, hashHistory, IndexRoute } from 'react-router'

import App from './App';
import AllPost from './AllPost';
import NewPost from './NewPost';

import './index.css';


ReactDOM.render(
  (
    <Router history={hashHistory}>
      <Route path="/" component={App}>
        <IndexRoute component={AllPost} />
        <Route path="posts" component={AllPost}/>
        <Route path="new_post" component={NewPost} />
      </Route>
    </Router>
  ),
  document.getElementById('root')
);

這邊就是簡單的設置一下路由規則,引入其他檔案的 component,IndexRoute就是預設路徑,你連到/的時候會渲染AllPost這個 component。先來看比較簡單的NewPost.js,發表文章的頁面:

import React, { Component } from 'react';
import {hashHistory} from 'react-router';
import axios from 'axios';

export default class NewPost extends Component {

  constructor (props) {
    super(props);

    this.onChange = this.onChange.bind(this);
    this.onClick = this.onClick.bind(this);
    this.onContentInput = this.onContentInput.bind(this);

    this.state = {
      title: '',
      content: ''
    }
  }

  onChange (e) {
    this.setState({
      title: e.target.value
    })
  }

  onContentInput (e) {
    this.setState({
      content: e.target.value
    })
  }

  // 發文
  onClick () {
    const {title, content} = this.state;

    // send request
    axios.post('http://localhost:5566/posts', {
      title,
      content
    }).then(function (response) {
      console.log(response);
      hashHistory.push('/posts');
    }).catch(function (error) {
      console.log(error);
    });
    
  }

  render () {
    const {title, content} = this.state;
    return (
      <form>
        <div className="form-group">
          <label>標題</label>
          <input name="title" className="form-control" placeholder="title" value={title} onChange={this.onChange}/>
        </div>
        <div className="form-group">
          <label>內容</label>
          <textarea onChange={this.onContentInput} value={content}></textarea>
        </div>
        <button type="button" className="btn btn-default" onClick={this.onClick}>送出</button>
      </form>
    );
  }
}

這邊出現了以前沒看過的新東西,第一個叫做axios,這是一個讓你可以方便發 request 的 library,詳細使用說明可以到 Github 上面尋找。第二個是.then, .catch 這種用法,這個叫做promise,某種程度上是拿來取代 callback 用的,程式碼的可讀性會高一些些。有興趣的可以用js promise這組關鍵字去搜尋相關文章。

再來是hashHistory.push('/posts');,這其實就是換頁的意思。發表文章成功之後就自己跳回首頁。

這部分應該沒什麼太困難的,就是一個表單讓你可以輸入資訊,按下送出的時候發 request,成功跳轉回首頁。接下來看AllPost.js

import React, { Component } from 'react';
import axios from 'axios';
import Post from './Post';

export default class AllPost extends Component {

  constructor (props) {
    super(props);

    this.onRemove = this.onRemove.bind(this);

    this.state = {
      posts: []
    }

    const self = this;

    axios.get('http://localhost:5566/posts')
    .then(function (response) {
      const data = response.data;
      console.log(data);
      self.setState({
        posts: data
      })
    })
    .catch(function (error) {
      console.log(error);
    }); 
  }

  onRemove (id) {
    const self = this;
    axios.get('http://localhost:5566/posts/delete/' + id)
    .then(function (response) {
      console.log(response.data);
      self.setState({
        posts: self.state.posts.filter((post) => post._id !== id)
      })
    }).catch(function (error) {
      console.log(error);
    }); 
  }

  render () {
    const {posts} = this.state;
    return (
      <table className="table table-bordered">
        <thead>
          <tr>
            <th>標題</th>
            <th>內容</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
        {
          posts.map((post) => {
            return (<Post id={post._id} title={post.title} content={post.content} remove={this.onRemove} />);
          })
        }
        </tbody>
      </table>
    );
  }
}

這邊要跟Post.js合著一起看,因為每一個 Post 都是一個小的組件。

import React, { Component } from 'react';

export default class Post extends Component {

  constructor (props) {
    super(props);

    this.onRemove = this.onRemove.bind(this);
  }

  onRemove () {
    const {remove, id} = this.props;
    remove(id);
  }

  render () {
    const {title, content} = this.props;
    return (
      <tr>
        <td>{title}</td>
        <td>{content}</td>
        <td>
          <a className="btn btn-danger" onClick={this.onRemove}>刪除</a>
        </td>
      </tr>
    );
  }
}

Post 很簡單,就是顯示內容然後按鈕點了之後會呼叫 props.remove。

所以 AllPost 裡面在做的事情就是拿所有的文章、設成 state,然後負責處理刪除之後的 request 以及 state 的狀態等等。最後來看一下寫完之後跑起來會長怎樣:

看起來體驗不錯,還滿順暢的。好了,可是現在問題來了,假如說我們想要在發表文章的頁面新增一個文字:現在一共有 n 篇文章,該怎麼辦呢?我們要怎麼拿到這個資訊?

想拿到這個資訊,在 AllPost 這個組件裡面的 posts 這個 state 有,可是我們要怎麼把這邊的資訊在 NewPost 這個組件裡面同步,或者是說,兩個一起共用這個 state?有一種方式你可以參考看看,還記得在 App 這個組件裡面,我們用 this.props.children來 render 嗎?我們可以改由把 posts 這個 state 放到這個組件底下,再用 props 傳下去,這樣就可以讓 NewPost 跟 AllPost 這兩個組件都透過 props 讀到 posts。

整體的思路大概是這樣。但我們今天要用的不是自己實作這個方法,而是用一套概念滿類似的框架,叫做 Redux。其實這不只是一套框架,而是連你先前認知的整個流程都會被改變。你還記得你剛接觸 React 的時候嗎?我說他是一套:新的思考方式,Redux 也是,如果可以的話,你最好把你以前學的東西全部忘掉,是一片空白來學 Redux,我反而會覺得比較容易。

我之前有分享過一個 slide:React and Flux 心得分享,Redux 也是以 Flux 的概念為基礎,再改良一些東西以後演變出來的一個框架,所以在底層的觀念其實一模一樣。

先來講講 Redux(或者其實說是 Flux) 要解決的問題是什麼,主要就是希望整個程式內部的運作可以變得透明單一,發生問題的時候可以很快的定位到錯誤在哪裡。Redux 跟 React 不一定要綁在一起用,你喜歡的話也可以在 Android 或是 iOS 用 Redux 的概念實作出一樣的流程。

就像 React 裡面的 component 會有 State 一樣,Redux 裡面有個東西叫做「Store」,是專門拿來存放 global 的 state 用的。例如說 posts 就可以存在這邊。那你要怎麼改變資訊呢?你不能直接像設定 state 那樣改變,而是要透過一個叫做 Action 的東西。

你要 dispatch 一個改變文章的 Action,這樣 Store 在接收到這個 Action 之後,會把它丟進 Reducer,Reducer 只是一個改變 state 的 function,你丟一個東西進去然後它丟一個東西出去。舉例來說,

const addCountReducer(state) {
  return {
    ...state,
    count: state.count + 1
  }
}

addCountReducer({
  count: 2
})
/*
=> {
  count: 3
}
*/

最後 Store 就會把上面那個 Object 設成新的 State。

總之你只要記住兩個概念就好:

  1. Store 其實就是全局的 state,你可以存任何資訊在這邊
  2. 要改變 Store,只能透過 dispatch action

首先呢,舉我們剛剛的「刪除文章」這個動作為例子(先假設不發 request 到 API),Redux 實作的流程會是這樣:

  1. 按下按鈕,dispatch 一個 DELETE_POST 的 Action
  2. Store 接收到這個 Action,把現在的 store state 丟給 reducer
  3. reducer 返回新的 state
  4. Store 變更成新的 state

那 Redux 的 store 到底可以幹嘛呢?你的 component 可以利用 connect,把 redux 的 state 注入到 component 的 props 裡面,你在組件裡面就可以用 this.props.posts 取到你想要的 Store state 了。

因為 Redux 這整套要講起來可以很花時間,因此建議大家直接參考Flux 基礎概念與實戰入門,或是官方教學

Talk is cheap, show me the code! 我們直接試著把 redux 加上去,或許看程式碼你就更瞭解了。

npm install redux react-redux --save 

先建立一個 actions.js

/*
 * action types
 */

export const SET_POSTS = 'SET_POSTS'
export const REMOVE_POST = 'REMOVE_POST'

/*
 * action creators
 */

export function setPosts(posts) {
  return { type: SET_POSTS, posts }
}

export function removePost(id) {
  return { type: REMOVE_POST, id }
}

這邊就是簡單的設定 action 的類型還有幫助你建立 action 的 helper function 而已。接著建立一個reducers.js

import { combineReducers } from 'redux'
import { SET_POSTS, REMOVE_POST } from './actions'

// 設定預設 state
const defaultState = {
  posts: []
}

// 底下每一個就是一個 reducer
function posts(state = defaultState, action) {
  switch (action.type) {

    // 回傳設定好 posts 的 state
    case SET_POSTS:
      return {
        ...state,
        posts: action.posts
      }

    // 回傳刪除後的 state
    case REMOVE_POST:
      return {
        ...state,
        posts: state.posts.filter((post) => post._id !== action.id)
      }
    default:
      return state
  }
}

// 其實有多個 reducer 才需要用這個
const App = combineReducers({
  posts
})

export default App

這邊就是寫好我們剛剛講的 reducer,丟入現在的狀態以及 Action,回傳新的狀態。接著修改index.js,用 react-redux 提供的 Provider,把整個 App 包起來:

import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route, hashHistory, IndexRoute } from 'react-router'

import { Provider } from 'react-redux'
import { createStore } from 'redux'
import AppReducer from './reducers'

import App from './App';
import AllPost from './AllPost';
import NewPost from './NewPost';

import './index.css';

// 建立 store,把 reducer 傳進去
let store = createStore(AppReducer);

ReactDOM.render(
  (
    <Provider store={store}>
      <Router history={hashHistory}>
        <Route path="/" component={App}>
          <IndexRoute component={AllPost} />
          <Route path="posts" component={AllPost}/>
          <Route path="new_post" component={NewPost} />
        </Route>
      </Router>
    </Provider>
  ),
  document.getElementById('root')
);

萬事俱備,只欠東西。現在只剩下把組件也改一改了。先來看比較簡單的 NewPost.js

import React, { Component } from 'react';
import {hashHistory} from 'react-router';
import {connect} from 'react-redux'
import axios from 'axios';

class NewPost extends Component {

  constructor (props) {
    super(props);

    this.onChange = this.onChange.bind(this);
    this.onClick = this.onClick.bind(this);
    this.onContentInput = this.onContentInput.bind(this);

    this.state = {
      title: '',
      content: ''
    }
  }

  onChange (e) {
    this.setState({
      title: e.target.value
    })
  }

  onContentInput (e) {
    this.setState({
      content: e.target.value
    })
  }

  // 發文
  onClick () {
    const {title, content} = this.state;

    // send request
    axios.post('http://localhost:5566/posts', {
      title,
      content
    }).then(function (response) {
      console.log(response);
      hashHistory.push('/posts');
    }).catch(function (error) {
      console.log(error);
    });
    
  }

  render () {
    const {title, content} = this.state;
    const {posts} = this.props;

    return (
      <form>
        <h3>一共有{posts.length}篇文章</h3>
        <div className="form-group">
          <label>標題</label>
          <input name="title" className="form-control" placeholder="title" value={title} onChange={this.onChange}/>
        </div>
        <div className="form-group">
          <label>內容</label>
          <textarea onChange={this.onContentInput} value={content}></textarea>
        </div>
        <button type="button" className="btn btn-default" onClick={this.onClick}>送出</button>
      </form>
    );
  }
}

const mapStateToProps = (state) => {
  return {
    posts: state.posts.posts
  }
}

// 利用 connect 把 Redux 的 state 變成 NewPost 裡面的 props
export default connect(mapStateToProps)(NewPost)

connect你可以帶進去一些參數,他就會幫你把 redux 的 store 對映到組件裡的 props,所以我們在這裡面就可以用this.props.posts取到 Redux Store 裡面的 posts。

當然,AllPost也是同理,但要記得把原本設定跟刪除文章的動作都改成用 dispatch action,這邊一樣可以用 connect 提供的參數來做設置:

import React, { Component } from 'react';
import {connect} from 'react-redux'
import axios from 'axios';
import Post from './Post';
import {setPosts, removePost} from './actions';

class AllPost extends Component {

  constructor (props) {
    super(props);

    this.onRemove = this.onRemove.bind(this);
    
    const {setPosts} = props;

    axios.get('http://localhost:5566/posts')
    .then(function (response) {
      const data = response.data;
      console.log(data);
      setPosts(data);
    })
    .catch(function (error) {
      console.log(error);
    }); 
  }

  onRemove (id) {
    const {removePost} = this.props;
    axios.get('http://localhost:5566/posts/delete/' + id)
    .then(function (response) {
     removePost(id);
    }).catch(function (error) {
      console.log(error);
    }); 
  }

  render () {
    const {posts} = this.props;
    return (
      <table className="table table-bordered">
        <thead>
          <tr>
            <th>標題</th>
            <th>內容</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
        {
          posts.map((post) => {
            return (<Post id={post._id} title={post.title} content={post.content} remove={this.onRemove} />);
          })
        }
        </tbody>
      </table>
    );
  }
}

const mapStateToProps = (state) => {
  return {
    posts: state.posts.posts
  }
}

const mapDispatchToProps = (dispatch) => {
  return { 
    setPosts: (posts) => {
      dispatch(setPosts(posts))
    },

    removePost: (id) => {
      dispatch(removePost(id))
    }
  }
}

// 利用 connect 把 Redux 的 state 變成 AllPost 裡面的 props
export default connect(mapStateToProps, mapDispatchToProps)(AllPost)

就這樣,一切都大功告成了!成功地把 posts 這個 state 改到 Redux 那邊去,這樣只要有任何一個 component 想要用,都可以自己拿去用。

結論

其實這一章比我想像中的要難講很多,因為我覺得要講 Redux 需要先講 Flux,講了 Flux 你又要先提 Flux 解決的問題,例如說 two way binding 或是資料流向太複雜等等的,可是對初學者來說,這些東西他們都毫無感觸,怎麼又會懂 Flux 好用在哪?

所以這一篇我能講的很有限,我能預期滿多初學者看完之後可能還是一頭霧水,之後有時間我會再來想一下怎麼講會更好。你們也可以先試著上網搜尋看看其他教學,或者是參考作者本人自己的線上影片教學:Getting Started with Redux


上一篇
你要去哪裡:React Router
下一篇
如果你很懶,那你更應該寫測試:jest
系列文
Half-Stack Developer 養成計畫30

1 則留言

0
imakou
iT邦新手 5 級 ‧ 2017-01-04 14:49:42

這篇文章好猛啊!!
大推!

想請教Huli版大,
我跟著很多React的範例,從Es5到6,從沒有導入Redux到有
都沒有看過關於【分頁】(pagination)的作法
不曉得您實務應用上是怎麼做的呢?謝謝?

huli iT邦新手 5 級‧ 2017-01-04 21:35:15 檢舉

分頁嗎?
我最直覺能想到的就是用 state 去管理現在是哪一個分頁,然後去 render 那個分頁。

{this.state.tabId === 1 && <Tab1 />}
{this.state.tabId === 2 && <Tab2 />}

你是問這種分頁嗎?

另外一種分頁的話應該是指假設你有 100 筆資料,然後每頁顯示十筆
這樣的話就是選頁數的點了之後改變 state,你再根據 state 去改變組件。
例如說

onClick (page) {
  this.setState({
    pageStart: page
  })
}
<DataTable from={this.state.pageStart} />
imakou iT邦新手 5 級‧ 2017-01-05 12:06:25 檢舉

先謝謝Huli版大的回應,

看完您的想法,我想說
是不是應該先去管理好 data的array?

譬如:100筆 除以 10

所以會有10頁,pageStart 去紀錄目前那一頁
同時也利用pageStart來作為切割出想要的陣列裡面的資料
譬如,pageStart:3, 就用 3 這個數字去切除第 30 ~39 筆
然後render到頁面上~

所以好像重點比較在操作陣列上,不知道這樣的思路是正確的嗎?

huli iT邦新手 5 級‧ 2017-01-05 21:25:53 檢舉

沒錯沒錯,差不多就是這樣,操作陣列也很簡單

const dataPerPage = 10;
const pageStart = 3;

const start = datapPerPage * pageStart;
render(<DataTable data={pages.slice(start, start+ dataPerPage )}>)

我要留言

立即登入留言