环境准备
- git clone https://github.com/easy-team/egg-react-webpack-boilerplate.git
yarn install
ornpm install
编写前端代码逻辑
编写 React Router
创建 React Router 实例
添加
${root}/app/web/page/blog/router/index.jsx
import { Route } from 'react-router-dom';
import Home from './home';
import Detail from './detail';
export default function createRouter() {
return [
{
path: '/detail/:id',
component: Detail
},
{
path: '/',
component: Home
},
{
path: '*',
component: Home
}
];
}
创建 React Router 路由页面
- 添加
${root}/app/web/page/blog/router/home.jsx
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { hot } from 'react-hot-loader/root'
import request from 'framework/request';
import './home.css'
class Home extends Component {
static async asyncData(context, route) {
return request.get('/api/blog/list', context);
}
render() {
const { list = [] } = this.props;
return <div className="easy-article-list">
<ul>
{list.map(function (item) {
return <li key={item.id} className="easy-article-item">
<h2 className="easy-article-title"><Link to={'/detail/' + item.id}>{item.title}</Link></h2>
<div className="easy-article-summary">{item.summary}</div>
<div className="easy-article-meta">
<span>11Word Count:{item.wordCount} </span>
<span>Create Time: {item.createTime}</span>
</div>
</li>;
})}
</ul>
</div>;
}
}
const mapStateToProps = state => {
return {
list: state.list
};
};
const mapDispatchToProps = dispatch => {
return {};
};
export default connect(mapStateToProps, mapDispatchToProps)(EASY_ENV_IS_DEV ? hot(Home) : Home);
- 添加
${root}/app/web/page/blog/router/detail.jsx
'use strict';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader/root'
import request from 'framework/request';
class Detail extends Component {
static async asyncData(locals, route) {
const id = route.match.params.id;
return request.get(`/api/blog/${id}`, locals);
}
render() {
const { article } = this.props;
return article ? <div>
<h2 className="easy-article-detail-title">{article.title}</h2>
<div className="easy-article-info">
<iframe src={article.url} frameBorder="0" width="100%" style={{minHeight: '800px'}}></iframe>
</div>
</div> : null;
}
}
const mapStateToProps = state => {
return {
article: state.article
};
};
export default connect(mapStateToProps)(EASY_ENV_IS_DEV ? hot(Detail) : Detail)
编写 React Router 聚合页面
${root}/app/web/page/blog/view/main.jsx
import React, { Component } from 'react';
import { Link, Switch } from 'react-router-dom';
import { hot } from 'react-hot-loader/root'
import { ARTICLE_LIST, ARTICLE_DETAIL } from '../store/constant';
import Layout from 'component/layout'
import Header from 'component/header'
import Route from '../router/route';
import Home from '../router/home';
import Detail from '../router/detail';
import './main.css';
class Main extends Component {
constructor(props) {
super(props);
this.state = { current: props.url };
}
tabClick(e) {
console.log('click', e.target);
}
render() {
return <Layout {...this.props}>
<Header></Header>
<ul className="menu-tab">
<li onClick={this.tabClick.bind(this)}><Link to="/">Home</Link></li>
</ul>
<Switch>
<Route type={ARTICLE_DETAIL} path="/detail/:id" component={Detail} />
<Route type={ARTICLE_LIST} path="/" component={Home}/>
</Switch>
</Layout>;
}
}
export default EASY_ENV_IS_DEV ? hot(Main) : Main;
编写 React Router 路由切换 Hook 高阶组件
${root}/app/web/page/blog/router/route.jsx
'use strict';
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import { connect } from 'react-redux';
import { update } from '../store/actions';
class WrapperRoute extends Component {
async componentWillReceiveProps(nextProps) {
const { type, locals, component, computedMatch, updateState } = nextProps;
if (computedMatch.url !== this.props.computedMatch.url) {
const { asyncData } = component;
if (asyncData) {
const data = await asyncData(locals, { match: computedMatch });
updateState(type, data);
}
}
}
render() {
return <Route {...this.props} />
}
}
const mapStateToProps = state => {
return {
locals: state
}
};
const mapDispatchToProps = dispatch => {
return {
updateState: (type, data) => dispatch(update(type, data))
};
};
export default connect(mapStateToProps, mapDispatchToProps)(WrapperRoute);
编写 React Store
创建 React Store 实例
// ${root}/app/web/page/blog/store/constant.js
export const ARTICLE_LIST = 'ARTICLE_LIST';
export const ARTICLE_DETAIL = 'ARTICLE_DETAIL';
export const ADD = 'ADD';
export const DEL = 'DEL';
// ${root}/app/web/page/blog/store/actions.js
export const update = (type, data) => {
return {
type,
data
};
}
// ${root}/app/web/page/blog/store/reducers.js
import { ARTICLE_DETAIL, ARTICLE_LIST, ADD, DEL } from './constant';
export default function update(state, action) {
const newState = Object.assign({}, state);
if (action.type === ADD) {
const list = Array.isArray(action.data) ? action.data : [action.data];
newState.list = [...newState.list, ...list];
} else if (action.type === DEL) {
newState.list = newState.list.filter(data => {
return data.id !== action.id;
});
} else if (action.type === ARTICLE_LIST) {
newState.list = action.data.list;
newState.total = action.data.total;
} else if (action.type === ARTICLE_DETAIL) {
newState.article = action.data.article;
}
return newState;
}
// ${root}/app/web/page/blog/store/index.js
import { createStore } from 'redux';
import reducers from './reducers';
export default function(initalState){
return createStore(reducers, initalState);
}
编写 React SPA 入口代码
${root}/app/web/page/blog/index.jsx webpack.config.js
的 entry 指向该文件。
import React, { Component } from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter, StaticRouter } from 'react-router-dom';
import { matchRoutes } from 'react-router-config';
import createStore from './store'
import createRouter from './router'
import Main from './view/main'
import '../../asset/css/blog.css'
// 直接渲染 React Component 组件,JSX 文件结尾的 Webpack entry 自动使用 react-entry-template-loader
export default class Entry extends Component {
static async asyncData(locals) {
const router = createRouter();
const url = locals.url;
const matchRouteList = matchRoutes(router, url);
const promises = matchRouteList.map(matchRoute=> {
const componentAsyncData = matchRoute.route.component.asyncData;
return componentAsyncData instanceof Function ? componentAsyncData(locals, matchRoute) : null;
});
const list = await Promise.all(promises);
return list.reduce((item, result) => {
return { ...result, ...item}
}, {});
}
render() {
if (EASY_ENV_IS_BROWSER) {
const store = createStore(window.__INITIAL_STATE__);
const { url } = store.getState();
return <Provider store={store}>
<BrowserRouter location={url}>
<Main></Main>
</BrowserRouter>
</Provider>;
}
const store = createStore(this.props);
const { url } = store.getState();
return <Provider store={store}>
<StaticRouter location={url} context={{}}>
<Main></Main>
</StaticRouter>
</Provider>;
}
}
编写 Node 代码
添加
${root}/app/controller/blog/index.js
Node 代码
'usestrict';
const egg = require('egg');
module.exports = class IndexController extends egg.Controller {
async ssr(ctx) {
// 1. ctx.render 方法是 egg-view-react-ssr 插件提供实现
// 2. ctx.render 第一个参数 blog.js 是 webpack.config.js entry 配置的 blog 构建后 JSBundle 文件
// 3. ctx.render 第二个参数 { url, title ...} 是渲染数据,会与公共 ctx.locals 合并
await ctx.render('blog.js', {
url: ctx.url,
title: 'Egg React ',
keywords: 'Egg,React,Egg React,Egg React SSR, Egg React CSR, Server Side Render, Client Side Render',
description: 'Egg + React + Webpack 服务端渲染 SSR (Server Side Render) 和 前端渲染 CSR (Client Side Render) 工程骨架项目'
});
}
async list() {
this.ctx.body = this.service.article.getArtilceList();
}
async detail(ctx) {
const id = ctx.params.id;
const article = this.service.article.getArticle(Number(id));
ctx.body = { article };
}
};
Egg 路由配置
添加
${root}/app/router.js
Egg 路由配置
module.exports = app => {
const { router, controller } = app;
router.get('/api/blog/list', controller.blog.index.list);
router.get('/api/blog/:id', controller.blog.index.detail);
router.get('/(.*?)', controller.blog.index.ssr);
};
Webpack 构建配置
添加
${root}/webpack.config.js
新增页面 entry 配置
module.exports = {
entry: {
blog: 'app/web/page/blog/index.jsx'
},
}
相关辅助代码
${root}/app/web/framework/request.js
'use strict';
import axios from 'axios';
// axios.defaults.baseURL = 'http://127.0.0.1:7001';
axios.defaults.timeout = 15000;
axios.defaults.xsrfHeaderName = 'x-csrf-token';
axios.defaults.xsrfCookieName = 'csrfToken';
export default {
async post(url, json, locals = {}) {
const headers = {};
if (EASY_ENV_IS_NODE) {
headers['x-csrf-token'] = locals.csrf;
headers.Cookie = `csrfToken=${locals.csrf}`;
}
const res = await axios.post(`${locals.origin}${url}`, json, { headers });
return res.data;
},
async get(url, locals = {}) {
const res = await axios.get(`${locals.origin}${url}`);
return res.data;
}
};
${root}/app/web/component/layout/index.jsx
import React, { Component } from 'react';
export default class Layout extends Component {
render() {
if(EASY_ENV_IS_NODE) {
return <html>
<head>
<title>{this.props.title}</title>
<meta charSet="utf-8"></meta>
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"></meta>
<meta name="keywords" content={this.props.keywords}></meta>
<meta name="description" content={this.props.description}></meta>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon"></link>
</head>
<body><div id="app">{this.props.children}</div></body>
</html>;
}
return this.props.children;
}
}
- Egg 中间件设置公共 locals, 该 locals 会自动与 Egg 的 render 的第二个参数合并传递给 JSX 模板
// ${root}/app/middleware/locals.js
module.exports = () => {
return async function locale(ctx, next) {
ctx.locals.locale = ctx.query.locale || 'cn';
ctx.locals.origin = ctx.request.origin;
await next();
};
};
// Egg 启用 Middleware locals
const path = require('path');
const fs = require('fs');
module.exports = app => {
const exports = {};
exports.middleware = [
'locals'
];
return exports;
};
项目代码结构
代码见:https://github.com/easy-team/egg-react-webpack-boilerplate.git