React App – Router and Redux

从这里进入第二阶段的学习,会创建一个新的财务软件项目。

客户端路由
传统的页面导航基于服务器,每个页面独立请求和由服务器提供。
新的方法是基于客户端,页面变化由路由表对应到组件和 JS 方法。
使用组件模拟页面导航的应用程序其实只有一个页面,运行更快。
根据用户的动作,JS 选择组件提供给页面,只刷新必要的部分。

重用代码
为了重用一些基本的代码和项目结构文件,复制 Todo App。
新的程序用来记录个人账户变化,可命名为 Budget App。
需要清空的文件夹包括:
src/components
src/playground
src/styles/components
需要更新以下文件:
src/styles/base/_base.scssbody中删除以下定义:

    background: $dark-blue;

src/styles/base/_settings.scss中保留以下定义:

$s-size: 1.2rem;
$m-size: 1.6rem;
$l-size: 3.2rem;
$xl-size: 4.8rem;
$screen-width: 45rem;

src/styles/styles.scss中保留以下定义:

@import './base/settings';
@import './base/base';

更新public/index.html文件中如下语句:

        <title>Budget App</title>

更新package.json文件中如下语句:

  "name": "budget-app",

更新src/app.js如下:

import React from 'react';
import ReactDOM from 'react-dom';
import 'normalize.css/normalize.css';
import './styles/styles.scss';

ReactDOM.render(<p>From Budget App</p>, 
    document.getElementById('app'));

重启服务器$ npm run dev-server
确认程序主页信息,控制台没有错误提示。
以上,清理工作完成,开始添加本项目内容。

安装 React Router 运行:
$ cnpm install react-router-dom@4.2.2 --save

为 Webpack 配置使用客户端路由。
配置webpack.config.js文件的devServer部分:

    devServer: {
        contentBase: path.join(__dirname,'public'),
        historyApiFallback: true
    }

以上增加了语句historyApiFallback: true
设置找不到指定页面时默认打开程序主页。

以下代码过程不符合正常或推荐开发流程。
应该是从框架到内容伴随测试的迭代开发。
这里只是记录下第一阶段最终的结果。

创建组件
创建src/components/DashboardPage.js作为主页:

import React from 'react';

const DashboardPage = () => (
    <div>
        Reserved for Dashboard
    </div>
);

export default DashboardPage;

以上,没有惊喜。

类似的,创建AddBudgetPage.js用于添加预算:

import React from 'react';

const AddBudgetPage = () => (
    <div>
        Reserved for Adding Budget Page
    </div>
);

export default AddBudgetPage;

创建HelpPage.js用于提供帮助信息:

import React from 'react';

const HelpPage = () => (
    <div>
        Reserved for Help Page
    </div>
);

export default HelpPage;

创建NotFoundPage.js用于404错误:

import React from 'react';
import { Link } from 'react-router-dom';

const NotFoundPage = () => (
    <div>
        404! - <Link to="/">Go Home</Link>
    </div>
);

export default NotFoundPage;

这里用了来自 React Router 的 Link 容器。
类似 HTML 的 可以创建超链接。
只是 Link 依托 React 框架,效率更高。

创建EditBudgetPage.js用于编辑个别预算:

import React from 'react';

const EditBudgetPage = (props) => {
    console.log(props);
    return (
        <div>
            Reserved for Editing Budget with ID: 
            {props.match.params.id}
        </div>
    );
};

export default EditBudgetPage;

注意以上代码在控制台记录了读入的属性。
并在返回文字中加入读入属性的一个变量。
该变量在后面的路由设置中会做定义。

最后创建Header.js用于插入到所有页面头部:

import React from 'react';
import { NavLink } from 'react-router-dom';

const Header = () => (
    <header>
        <h1>Budget App</h1>
        <NavLink to="/" activeClassName="is-active" exact={true}>Home</NavLink>
        <NavLink to="/create" activeClassName="is-active">Add</NavLink>
        <NavLink to="/help" activeClassName="is-active">Help</NavLink>
    </header>
);

export default Header;

注意, 是 HTML5 的容器,不是来自 React。
使用NavLink创建导航,后面的类会凸显当前页面链接。

以上所有组件会统一由路由文件链接和组合。
创建src/routers/AppRouter.js路由文件:

import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import DashboardPage from '../components/DashboardPage';
import AddBudgetPage from '../components/AddBudgetPage';
import EditBudgetPage from '../components/EditBudgetPage';
import HelpPage from '../components/HelpPage';
import NotFoundPage from '../components/NotFoundPage';
import Header from '../components/Header';

const AppRouter = () => (
    <BrowserRouter>
        <div>
            <Header />
            <Switch>
                <Route path="/" component={DashboardPage} exact={true} />
                <Route path="/create" component={AddBudgetPage} />
                <Route path="/edit/:id" component={EditBudgetPage} />
                <Route path="/help" component={HelpPage} />
                <Route component={NotFoundPage} />
            </Switch>
        </div>
    </BrowserRouter>
);

export default AppRouter;

以上导入组件,注意诸如 的用法。
会添加到所有可能的路由。
会根据路径设置匹配需要提供的组件。
exact参数保证路径匹配方式为绝对匹配。
其中:id的参数传入方式以及在属性中的结果。

最后将以上创建的路由对象提供给app.js

import React from 'react';
import ReactDOM from 'react-dom';
import AppRouter from './routers/AppRouter';
import 'normalize.css/normalize.css';
import './styles/styles.scss';

ReactDOM.render(<AppRouter />,
    document.getElementById('app'));

综上,组件通过路由配置来集合,并提供给程序。
这一部分了解路由的结构,组件和参数传导用法。

一个错误
这里记录一个奇怪的错误,关于 404 错误。
在设置/edit/:id的路由时,可能发现错误:
GET http://localhost:8080/edit/bundle.js net::ERR_ABORTED 404 (Not Found)
其实错误信息很明显,找bundle.js的位置不对。
但直到对比官方代码才发现原因在index.html里。
正确的代码是:

<script src="/bundle.js"></script>

而我的代码是:

<script src="bundle.js"></script>

没有明确bundle.js在根目录,程序找错了位置。

Redux
简单的说,Redux方便在不同组件之间传递状态。
根据经典模型,状态和属性需要依赖母子关系传递。
而Redux方便了不相关的组件之间相互传递信息。

为了初步了解Redux创建练习文件:
src/playground/PlayRedux.js
修改webpack.config.js配置提供以上文件:
entry: './src/playground/PlayRedux.js',
注意这里替代的是原程序入口app.js
可见Redux会从更底层控制React程序。

更新PlayRedux.js文件如下:

console.log('hello');

运行开发服务器:
$ npm run dev-server
到程序主页http://localhost:8080/
打开开发者工具控制台,确认提示信息。

安装Redux
$ cnpm install redux@3.7.2 --save

Redux的组成* Action是程序发给Store的信息,用dispatch方法发送

  • Reducer定义某个Action发生时State如何相应的变化
  • Store保存程序状态并提供访问和更新接口,注册监听

根据 Action 判断如何更新State,具有如下特点:

  • Reducer 是纯函数,即输入和输出都与外界无关。
  • Reducer 不会修改 State 或 Action

安装两个包,分别用于生成唯一 ID 和转换对象。
cnpm install uuid@3.1.0 --save
cnpm install babel-plugin-transform-object-rest-spread@6.23.0 --save
更新.babelrc配置文件,添加对以上插件的引用:

{
    "presets": ["env", "react"],
    "plugins": [
        "transform-class-properties",
        "transform-object-rest-spread"
    ]
}

生成唯一 ID 的包提供uuid()方法,很简单。
转换对象是指增加对ES6规范 … obj的支持。

用两个小程序示例,更新PlayRedux.js文件如下:

// *********************************
// 以下是只包含一个 Reducer 的简单例子
// *********************************

// 从 redux 导入创建库和整合 Reducer 的模块
import { createStore, combineReducers } from 'redux';
import uuid from 'uuid';

// 定义 Action,默认参数为空对象,默认对象属性值为 1
const increment = ({ incrementBy = 1 } = {}) => ({
    type: 'INCREMENT',
    incrementBy
});

const decrement = ({ decrementBy = 1 } = {}) => ({
    type: 'DECREMENT',
    decrementBy
});

const set = ({ count } = {}) => ({
    type: 'SET',
    count
});

const reset = () => ({
    type: 'RESET'
})

// 定义 reducer,为不同 Action 定义处理方法
const countReducer = (state = { count: 0 }, action) => {
    switch (action.type) {
        case 'INCREMENT':
            return {
                count: state.count + action.incrementBy
            };
        case 'DECREMENT':
            return {
                count: state.count - action.decrementBy
            };
        case 'RESET':
            return {
                count: 0
            };
        case 'SET':
            return {
                count: action.count
            };
        default:
            return state;
    }
};

// 创建 store 用来保存 state
// 注意创建方法需要使用参数 reducer
const store = createStore(countReducer);
console.log(store.getState());

// store 注册方法,每次 state 变化时触发控制台日志
// 返回的注销方法,在触发时注销对 state 变化的监控
const unsubscribe = store.subscribe(() => {
    console.log(store.getState());
});

// 使用默认值 1 和指定值 5 增加计数器
store.dispatch(increment());
store.dispatch(increment({incrementBy: 5}));

store.dispatch(decrement());
store.dispatch(decrement({decrementBy: 10}));

// 重置和设置计数器
store.dispatch(reset());
store.dispatch(set({count: 101}));

// 注销监控后运行减法,没有触发控制台日志
unsubscribe();
store.dispatch(decrement());

// *******************************
// 以下是包含两个 Reducer 的复杂例子
// *******************************

// 定义 Expsense 的 Action

// ADD_EXPENSE
// 新建支出,设置默认值,直接返回对象
const addExpense = ({
    description = '',
    note = '',
    amount = 0,
    createdAt = 0
} = {}) => ({
    type: 'ADD_EXPENSE',
    expense: {
        id: uuid(),
        description,
        note,
        amount,
        createdAt
    }
});

// REMOVE_EXPENSE
const removeExpense = ({ id } = {}) => ({
    type: 'REMOVE_EXPENSE',
    id
});

// EDIT_EXPENSE
const editExpense = (id, updates) => ({
    type: 'EDIT_EXPENSE',
    id,
    updates
});

// 定义 Filter 的 Action

// SET_TEXT-FILTER
const setTextFilter = (text = '') => ({
    type: 'SET_TEXT_FILTER',
    text
});

// SORT_BY_DATE
const sortByDate = () => ({
    type: 'SORT_BY_DATE'
});

// SORT_BY_AMOUNT
const sortByAmount = () => ({
    type: 'SORT_BY_AMOUNT'
});

// SET_START_DATE
const setStartDate = (date) => ({
    type: 'SET_START_DATE',
    date
});

// SET_END_DATE
const setEndDate = (date) => ({
    type: 'SET_END_DATE',
    date
});

// 定义 Expense 的默认状态
const eDefaultState = [];

const expensesReducer = ( state = eDefaultState, action) => {
    switch (action.type) {
        case 'ADD_EXPENSE':
            return [
                // 注意以下引用 state 对象的方法
                ...state,
                action.expense
            ];
        case 'REMOVE_EXPENSE':
            // 返回过滤掉指定 ID 的数组实现删除功能
            return state.filter(({ id }) => id !== action.id);
        case 'EDIT_EXPENSE':
            return state.map((expense) => {
                if (expense.id === action.id) {
                    return {
                        // 后面的属性值会覆盖前面同名的
                        ...expense,
                        ...action.updates
                    };
                } else {
                    return expense;
                }
            });
        default:
            return state;
    }
};

// 定义 Filter 的默认状态
const fDefaultState = {
    text: '',
    sortBy: 'date',
    startDate: undefined,
    endDate: undefined
};

const filtersReducer = ( state = fDefaultState, action) => {
    switch (action.type) {
        case 'SET_TEXT_FILTER':
            return {
                ...state,
                text: action.text
            };
        case 'SORT_BY_DATE':
            return {
                ...state,
                sortBy: 'date'
            };
        case 'SORT_BY_AMOUNT':
            return {
                ...state,
                sortBy: 'amount'
            }
        case 'SET_START_DATE':
            return {
                ...state,
                startDate: action.date
            };
        case 'SET_END_DATE':
            return {
                ...state,
                endDate: action.date
            };
        default:
            return state;
    }
};

// 返回经过过滤的 Expense,注意过滤和排序方法的定义
const getFilteredExpenses = (expenses, {text, sortBy, startDate, endDate}) => {
    return expenses.filter((expense) => {
        const startDateMatch = typeof startDate !== 'number' ||
            expense.createdAt >= startDate;
        const endDateMatch = typeof endDate !== 'number' ||
            expense.createdAt <= endDate;
        const textMatch = 
            expense.description.toLowerCase().includes(text.toLowerCase());
        return startDateMatch && endDateMatch && textMatch;
    }).sort((a, b) => {
        if (sortBy === 'date') {
            return a.createdAt > b.createdAt ? 1 : -1;
        } else if (sortBy === 'amount') {
            return a.amount > b.amount ? 1 : -1;
        } else {
            console.log('not sortable');
        };
    });
};

// 通过整合 Reducer 创建库
const expenseStore = createStore(
    combineReducers({
        expenses: expensesReducer,
        filters: filtersReducer,
    })
);

// 在每次状态有更新时控制台输出过滤后的 Expense
expenseStore.subscribe(() =>{
    const state = expenseStore.getState();
    const filteredExpenses = 
        getFilteredExpenses(state.expenses, state.filters);
    console.log(filteredExpenses);
});

// 创建三个 Expense
const one = expenseStore.dispatch(
    addExpense({description: 'rent', amount: 100}));
const two = expenseStore.dispatch(
    addExpense({description: 'coffee', amount: 200, createdAt: -1000}));
const three = expenseStore.dispatch(
    addExpense({description: 'rent', amount: 200, createdAt: 1000}));

// 删除和更新 Expense 的方式很简洁,具体可查看 Reducer 中的代码
expenseStore.dispatch(removeExpense({id: one.expense.id}));
expenseStore.dispatch(editExpense(two.expense.id, {amount: 500}));

// 定义并应用文字过滤器,之后清空过滤器对比结果
expenseStore.dispatch(setTextFilter('rent'));
expenseStore.dispatch(setTextFilter());

// 根据 Amount 和 Date 分别做排序,对比结果
expenseStore.dispatch(sortByAmount());
expenseStore.dispatch(sortByDate());

// 设置过滤器开始时间再清空,对比结果
expenseStore.dispatch(setStartDate(125));
expenseStore.dispatch(setStartDate());

// 设置过滤器结束时间再清空,对比结果
expenseStore.dispatch(setEndDate(125));
expenseStore.dispatch(setEndDate());

// 示例库的结构,包括一个 Expense 数组和一个过滤器对象
const demoState = {
    expense: [{
        id: 'anything',
        description: 'January Rent',
        note: 'Final payment',
        amount: 54500,
        createAt: 0
    }],
    filters: {
        text: 'rent',
        sortBy: 'amount', // date or amount
        startDate: undefined,
        endDate: undefined
    }
};

具体参考代码内的行注释,输出都在控制台。

Leave a comment