1. 程式人生 > >我的第一個 react redux demo

我的第一個 react redux demo

最近學習react redux,先前看過了幾本書和一些部落格之類的,感覺還不錯,比如《深入淺出react和redux》,《React全棧++Redux+Flux+webpack+Babel整合開發》,《React與Redux開發例項精解》, 個人覺得《深入淺出react和redux》這本說講的面比較全, 但是 很多都是蜻蜓點水, 不怎麼深入。這裡簡單記錄一個redux 的demo, 主要方便以後自己檢視,首先執行介面如下:

專案結構如下:

這裡我們一共有2個元件,都在components資料夾下面,Picker.js實現如下:

import React from 'react';
import PropTypes from 'prop-types';
function Picker({value, onChange, options}) {
    return(
        <span>
            <h1>{value}</h1>
            <select onChange={e=>onChange(e.target.value)} value={value}>
                {options.map(o=>
                    <option value={o} key={o}>{o}</option>
                    )}
            </select>
        </span>
    );
}

Picker.propTypes = {
    options:PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
    value:PropTypes.string.isRequired,
    onChange:PropTypes.func.isRequired,
};

export default Picker;

這裡的onChange事件是傳遞進來的,最終就是要觸發一個action,Posts.js的實現如下:

import React from 'react';
import PropTypes from 'prop-types';
function Posts ({posts}){
    return(
        <ul>
            {
                posts.map((p,i)=>
                    <li key={i}>{p.title}</li>
                )
            }
        </ul>
    );
}

Posts.propTypes = {
    posts:PropTypes.array.isRequired,
};

export default Posts;

現在來看看actions/index.js的實現:

import fetch from 'isomorphic-fetch';

export const REQUEST_POSTS = 'REQUEST_POSTS';
export const RECEIVE_POSTS = 'RECEIVE_POSTS';
export const SELECT_REDDIT = 'SELECT_REDDIT';
export const INVALIDATE_REDDIT = 'INVALIDATE_REDDIT';

export function selectReddit(reddit){
    return{
        type:SELECT_REDDIT,
        reddit,
    };
}

export function invalidateReddit(reddit){
    return {
        type:INVALIDATE_REDDIT,
        reddit,
    };
}

export function requestPosts(reddit){
    return {
        type:REQUEST_POSTS,
        reddit,
    };
}

export function receivePosts(reddit,json){
    return{
        type:RECEIVE_POSTS,
        reddit,
        posts:json.data.children.map(x=>x.data),
        receivedAt:Date.now(),
    };
}

function fetchPosts(reddit){
    return dispatch=>{
        dispatch(requestPosts);
        return fetch(`https://www.reddit.com/r/${reddit}.json`)
        .then(r=>r.json())
        .then(j=>dispatch(receivePosts(reddit,j)));
    }
}

function shouldFetchPosts(state,reddit){
    const posts = state.postsByReddit[reddit];
    if(!posts){
        return true;
    }
    if(posts.isFetching){
        return false;
    }
    return posts.didInvalidate;
}

export function fetchPostsIfNeeded(reddit){
    return (dispatch,getState)=>{
        if(shouldFetchPosts(getState(),reddit)){
            return dispatch(fetchPosts(reddit));
        }
        return null;
    };
}

主要是暴露出selectReddit,invalidateReddit,requestPosts,receivePosts和fetchPostsIfNeeded幾個action,而fetchPostsIfNeeded才是主要的,首先呼叫shouldFetchPosts方法來檢查是否需要獲取資料, 如果是的話就呼叫fetchPosts方法,而fetchPosts方法返回的是一個function,這裡我的專案使用了redux-thunk, 看看redux-thunk的實現如下:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

所以在fetchPostsIfNeeded中的dispatch(fetchPosts(reddit)) 最終會進入到redux-thunk裡面,fetchPosts(reddit)返回的是一個function如下,所以這裡會進入這個action裡面,也就是 return action(dispatch, getState, extraArgument);

function fetchPosts(reddit){
    return dispatch=>{
        dispatch(requestPosts);
        return fetch(`https://www.reddit.com/r/${reddit}.json`)
        .then(r=>r.json())
        .then(j=>dispatch(receivePosts(reddit,j)));
    }
}

所以在fetchPosts裡面的dispatch引數就是redux-thunk裡面return action(dispatch, getState, extraArgument) 的dispatch。

在這裡的function裡面, 一般發起http請求前有一個 狀態改變(dispatch(requestPosts);), http請求成功後有一個 狀態改變(dispatch(receivePosts(reddit,j))),失敗也會有狀態改變(這裡忽律失敗的case)

接下來看看containers\App.js

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { selectReddit, fetchPostsIfNeeded, invalidateReddit } from '../actions';
import Picker from '../components/Picker';
import Posts from '../components/Posts';

class App extends Component{
    constructor(props){
        super(props);
        this.handleChange=this.handleChange.bind(this);
        this.handleRefreshClick=this.handleRefreshClick.bind(this);
    }

    componentDidMount(){
        console.log('執行componentDidMount');
        const { dispatch, selectedReddit } = this.props;
        dispatch(fetchPostsIfNeeded(selectedReddit));
    }

    componentWillReceiveProps(nextProps){
        console.log('執行componentWillReceiveProps', nextProps);
        if(nextProps.selectedReddit !==this.props.selectedReddit)
        {
            const { dispatch, selectedReddit } = this.props;
            dispatch(fetchPostsIfNeeded(selectedReddit));
        }
    }

    handleChange(nextReddit){
        this.props.dispatch(selectReddit(nextReddit));
    }

    handleRefreshClick(e){
        e.preventDefault();
        const {dispatch, selectedReddit } = this.props;
        dispatch(invalidateReddit(selectedReddit));
        dispatch(fetchPostsIfNeeded(selectedReddit));
    }

    render(){
        const { selectedReddit, posts, isFetching, lastUpdated } = this.props;
        const isEmpty = posts.length === 0;
        const message = isFetching ? <h2>Loading...</h2> : <h2>Empty.</h2>;
        return(
            <div>
                <Picker value={selectedReddit} onChange={this.handleChange} options={['reactjs', 'frontend']} />
                <p>
                    { lastUpdated && <span>Last updated at {new Date(lastUpdated).toLocaleDateString()}.</span>}
                    {!isFetching && <a href="#" onClick={this.handleRefreshClick}>Refresh</a>}
                </p>
                {isEmpty?message:<div style={{opacity:isFetching?0.5:1}}><Posts posts={posts}/></div>}
            </div>
        );
    }
}
App.propTypes = {
    selectedReddit:PropTypes.string.isRequired,
    posts:PropTypes.array.isRequired,
    isFetching:PropTypes.bool.isRequired,
    lastUpdated:PropTypes.number,
    dispatch:PropTypes.func.isRequired
};

function mapStateToProps(state){
    const { selectedReddit, postsByReddit } = state; 
    const { isFetching, lastUpdated, items: posts,} = postsByReddit[selectedReddit] || { isFetching: true, items: [], };
    return {
        selectedReddit,
        posts,
        isFetching,
        lastUpdated,
    };
}
export default connect(mapStateToProps)(App);

只是要注意一下componentDidMount方法裡面是呼叫dispatch(fetchPostsIfNeeded(selectedReddit));的,頁面載入後就傳送預設的http請求。

在來看看reducers\index.js:

import {combineReducers} from 'redux';
import {SELECT_REDDIT,INVALIDATE_REDDIT,REQUEST_POSTS,RECEIVE_POSTS} from '../actions';

function selectedReddit (state='reactjs',action) {
    switch(action.type){
        case SELECT_REDDIT:
            return action.reddit;
        default:
        return state === undefined ? "" : state;
    }
}

function posts(state= { isFetching: false, didInvalidate: false,items: [],}, action){
    switch(action.type){
        case INVALIDATE_REDDIT:
            return Object.assign({},state,{didInvalidate:true});
        case REQUEST_POSTS:
            return Object.assign({},state,{isFetching:true,didInvalidate:false});
        case RECEIVE_POSTS:
            return Object.assign({},state,{isFetching:false,didInvalidate:false,items:action.posts,lastUpdated:action.receivedAt});
        default:
            return state;
    }
}

function postsByReddit(state={},action){
    switch(action.type){
        case INVALIDATE_REDDIT:
        case RECEIVE_POSTS:
        case REQUEST_POSTS:
            return Object.assign({},state,{[action.reddit]:posts(state[action.reddit],action)});
        default:
            return state === undefined ? {} : state;
    }
}

const rootReducer =combineReducers({postsByReddit,selectedReddit});
export default rootReducer;

這裡用combineReducers來合併了postsByReddit和selectedReddit兩個Reducers,所以每個action都會進入這2個Reducers(也不知道我的理解是否正確),比如action type 是INVALIDATE_REDDIT,selectedReddit 什麼都不處理,直接返回state,然而postsByReddit會返回我們需要的state。 還有就是經過combineReducers合併後的資料,原先postsByReddit需要的state現在就只能通過state.postsByReddit來獲取了。

還有大家主要到了沒有, 這裡有return state === undefined ? "" : state; 這樣的寫法, 那是combineReducers在初始化的時候會傳遞undefined ,combineReducers->assertReducerShape的實現如下:

所以預設的state傳遞的是undefined,而我們的reducer也是沒有處理ActionTypes.INIT的

現在來看看store/configureStore.js

import {createStore,applyMiddleware,compose} from 'redux';
import thunkMiddleware from 'redux-thunk';
import  logger from 'redux-logger';
import rootReducer from '../reducers';

export default function configureStore(initialState){
    const store=createStore(rootReducer,initialState,compose(
        applyMiddleware(thunkMiddleware,logger),
        window.devToolsExtension? window.devToolsExtension():f=>f
    ));
    if(module.hot){
        module.hot.accept('../reducers',()=>{
            const nextRootReducer=require('../reducers').default;
            store.replaceReducer(nextRootReducer);
        });
    }
    return store;
}

module.hot實在啟用了熱跟新後才可以訪問的。

index.js實現:

import 'babel-polyfill';
import React from 'react';
import {render} from 'react-dom';
import {Provider} from 'react-redux';
import App from './containers/App';
import configureStore from './store/configureStore';

const store=configureStore();
render(
    <Provider store={store}>
    <App />
  </Provider>
    ,document.getElementById('root')
);

server.js實現:

var webpack = require('webpack');
var webpackDevMiddleware = require('webpack-dev-middleware');
var webpackHotMiddleware = require('webpack-hot-middleware');
var config = require('./webpack.config');

var app= new (require('express'))();
var port= 3000;

var compiler = webpack(config);
app.use(webpackDevMiddleware(compiler, {noInfo:true, publicPath:config.output.publicPath}));
app.use(webpackHotMiddleware(compiler));

app.get("/",function(req,res){
    res.sendFile(__dirname+'/index.html');
});

app.listen(port,function(error){
    if(error){
        console.error(error);
    }
    else{
        console.info("==> ?  Listening on port %s. Open up http://localhost:%s/ in your browser.", port, port)
    }
});

package.json檔案如下:

{
  "name": "react-demo",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start":"node server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-loader": "^7.1.5",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-react-hmre": "^1.1.1",
    "expect": "^23.6.0",
    "express": "^4.16.3",
    "node-libs-browser": "^2.1.0",
    "webpack": "^4.20.2",
    "webpack-dev-middleware": "^3.4.0",
    "webpack-hot-middleware": "^2.24.2"
  },
  "dependencies": {
    "babel-polyfill": "^6.26.0",
    "isomorphic-fetch": "^2.2.1",
    "react": "^16.5.2",
    "react-dom": "^16.5.2",
    "react-redux": "^5.0.7",
    "redux": "^4.0.0",
    "redux-logger": "^3.0.6",
    "redux-thunk": "^2.3.0"
  }
}

webpack.config.js:

var path = require("path");
var webpack= require('webpack');

module.exports = {
    devtool:'cheap-module-eval-source-map',
    entry: ['webpack-hot-middleware/client','./index.js'],
    output : {
        path:path.join(__dirname,'dist'),
        filename:"bundle.js",
        publicPath:'/static/'
    },
    module:{
        rules:[
            {
                test:/\.js$/,
                loaders:['babel-loader'] ,
                exclude:'/node_modules/',
                include:__dirname                      
            }
        ]
    },
    plugins:[
        new webpack.optimize.OccurrenceOrderPlugin(),
        new webpack.HotModuleReplacementPlugin()
    ]
}

index.html:

<!DOCTYPE html>
<html>
  <head>
    <title>Redux async example</title>
  </head>
  <body>
    <div id="root">
    </div>
    <script src="/static/bundle.js"></script>
  </body>
</html>

.babelrc:

{
    "presets": [
        "es2015","react"
    ],
    "env":{
        "development":{
            "presets":["react-hmre"]
        }
    }
}