从这里进入第二阶段的学习,会创建一个新的财务软件项目。
客户端路由
传统的页面导航基于服务器,每个页面独立请求和由服务器提供。
新的方法是基于客户端,页面变化由路由表对应到组件和 JS 方法。
使用组件模拟页面导航的应用程序其实只有一个页面,运行更快。
根据用户的动作,JS 选择组件提供给页面,只刷新必要的部分。
重用代码
为了重用一些基本的代码和项目结构文件,复制 Todo App。
新的程序用来记录个人账户变化,可命名为 Budget App。
需要清空的文件夹包括:
• src/components
• src/playground
• src/styles/components
需要更新以下文件:
从src/styles/base/_base.scss
的body
中删除以下定义:
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
}
};
具体参考代码内的行注释,输出都在控制台。