让中间层承担数据获取职责
之前我们说过浏览器和服务端通信的时候,node作为中间层负责渲染页面,数据从真正的数据服务器中获取。
我们这里来分析一下我们前面的代码是否实现了中间层的概念。我们前面存储的src/public/index.js就是我们客户端要运行的代码,可以发现这里请求服务的接口请求的是java接口,这就违背了中间层的概念,这里请求的接口也应该是中间层的接口,这方便我们排查错误。
我们这里只需要让我们的node-server变成一个代理服务器就可以了,也就是一个proxy的功能,这里我们依赖一个express-http-proxy。
npm install express-http-proxy --save
src/server/index.js修改store获取方式
import express from 'express';
import proxy from 'express-http-proxy';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';
const app = express();
app.use(express.static('public'));
app.use('/api', proxy('xx.xx.xx.xx', {
proxyReqPathResolver: (req) => { // 转发到哪个路径
return req.url;
}
}))
app.get('*', function(req, res) {
const store = getStore();
const matchedRoutes = matchRoute(routes, req,path);
const promises = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
promises.push(item.route.loadData(store));
}
});
Promise.all(promises).then(() => {
res.send(render(store, routes, req));
})
})
var server = app.listen(3000);
src/components/Home/store/actions.js, 删除请求的域名。
import axios from 'axios';
import { CHANGE_LIST } from './constants';
const changeList = (list) => {
type: CHANGE_LIST,
list
}
export const getHomeList = (server) => {
let url = '';
if (server) { // 服务端环境使用真实地址
url = 'xx.xx.xx.xx/api/getlist'
} else { // 浏览器环境使用相对地址,做转发
url = '/api/getlist'
}
return (dispatch) => {
return axios.get(url).then(res => {
const list = res.data.data;
dispatch(changeList(list));
})
}
}
src/components/Home/index.js
import React, { Component } from 'react';
import Header from '../Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
class Home extends Component {
getList() {
const { list } = this.props;
return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
}
render() {
return <div>
<Header>
<div>Home</div>
{this.getList()}
<button onClick={() => { alert('click1'); }>按钮</button>
</div>
}
componentDidMount() {
if (!this.props.list.length) {
this.props.getHomeList();
}
}
}
Home.loadData = (store) => {
// 执行action,扩充store。
return store.dispatch(getHomeList(false));
}
const mapStatetoProps = state => ({
list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList(true));
}
})
export default connect(mapStatetoProps, mapDispatchToProps)(Home);
withExtraArgument
上面的代码我们通过传递布尔值来确定请求路径还是比较麻烦的,我们使用withExtraArgument整理一下。
src/components/Home/index.js
import React, { Component } from 'react';
import Header from '../Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
class Home extends Component {
getList() {
const { list } = this.props;
return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
}
render() {
return <div>
<Header>
<div>Home</div>
{this.getList()}
<button onClick={() => { alert('click1'); }>按钮</button>
</div>
}
componentDidMount() {
if (!this.props.list.length) {
this.props.getHomeList();
}
}
}
Home.loadData = (store) => {
// 执行action,扩充store。
return store.dispatch(getHomeList());
}
const mapStatetoProps = state => ({
list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
})
export default connect(mapStatetoProps, mapDispatchToProps)(Home);
src/components/Home/store/actions.js
import { CHANGE_LIST } from './constants';
const changeList = (list) => {
type: CHANGE_LIST,
list
}
export const getHomeList = (server) => {
return (dispatch, getState, axiosInstance) => {
return axiosInstance.get(url).then(res => {
const list = res.data.data;
dispatch(changeList(list));
})
}
}
src/store/index.js
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer} from '../components/Home/store';
import clientAxios from '../client/request';
import serverAxios from '../server/request';
const reducer = combineReducers({
home: homeReducer
});
export const getStore = () => {
return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)));
}
export const getClientStore = () => {
const defaultState = window.context.state;
// defaultState作为默认值
return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}
src/client/request.js
import axios from 'axios';
const instance = axios.create({
baseURL: '/'
})
src/server/request.js
import axios from 'axios';
const instance = axios.create({
baseURL: 'xx.xx.xx.xx'
})
renderRoutes
src/App.js
import React from 'react';
import Header from './component/Header';
import { renderRoutes } from 'react-router-config';
const App = (props) => {
return (<div>
<Header />
{renderRoutes(props.route.routes)}
</div>)
}
export default App;
我们希望用户无论如何访问都显示App组件。
src/Routes.js
import React from 'react';
import App from './App';
import Home from './components/Home';
import Login from './components/Login';
export default [{
path: '/',
component: App,
routes: [
{
path: '/',
component: Home,
exact: true,
key: 'home',
loadData: Home.loadData
},
{
path: '/login',
component: Login,
key: 'login',
exact: true
}
]
}]
这里我们构建了一个二级路由,当用户访问跟目录的时候我们可以匹配到App组件,当访问/login路径的时候, 我们会匹配到App和Login两个组件。
src/server/utils.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { Provider } from 'react-redux';
export const render = (store, routes, req) => {
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</Provider>
));
return `
<html>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringfiy(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`;
}
src/components/Home/index.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
class Home extends Component {
getList() {
const { list } = this.props;
return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
}
render() {
return <div>
<div>Home</div>
{this.getList()}
<button onClick={() => { alert('click1'); }>按钮</button>
</div>
}
componentDidMount() {
if (!this.props.list.length) {
this.props.getHomeList();
}
}
}
Home.loadData = (store) => {
// 执行action,扩充store。
return store.dispatch(getHomeList());
}
const mapStatetoProps = state => ({
list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
})
export default connect(mapStatetoProps, mapDispatchToProps)(Home);
src/components/Login/index.js
import React from 'react';
const Login = () => {
return <div>Login</div>
}
export default Login;
src/client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import routes from '../Routes';
import { getClientStore } from '../store'; // 使用store
import { Provider } from 'react-redux';
const store = getClientStore();
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<div>
{renderRoutes(routes)}
</div>
</BrowserRouter>
</Provider>
)
}
ReactDOM.hydrate(<App />, document.getElementById('root'));
请求失败处理
如果我们action中的请求失败了,会触发catch而不会触发then,这样会导致网站卡住,不会响应。因为server/index.js中的promise集合会失败,永远也不会返回成功。
Promise.all(promises).then(() => {
res.send(render(store, routes, req));
})
所以我们可以在这里面加一个catch。
Promise.all(promises).then(() => {
res.send(render(store, routes, req));
}).catch(() => {
res.end('sorry');
})
这样页面可以展示出来,但是问题是我们并不知道哪里出了问题,或者说当我们有多个组件渲染时,我们希望接口正常的组件可以正常返回。
我们可以在loadData外层包裹一层新的Promise, 无论loadData成功还是失败,我们都调用resolve,这样就可以确保所有请求都完成。Promise.all就可以正常执行了。
src/server/index.js
import express from 'express';
import proxy from 'express-http-proxy';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';
const app = express();
app.use(express.static('public'));
app.use('/api', proxy('xx.xx.xx.xx', {
proxyReqPathResolver: (req) => { // 转发到哪个路径
return req.url;
}
}))
app.get('*', function(req, res) {
const store = getStore();
const matchedRoutes = matchRoute(routes, req,path);
const promises = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve);
})
promises.push(promise);
}
});
Promise.all(promises).then(() => {
res.send(render(store, routes, req));
})
})
var server = app.listen(3000);
如何支持CSS样式修饰
首先我们需要webpack编译css文件。
webpack.server.js服务端要使用isomorphic-style-loader替代客户端的style-loader。
const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服务端运行webpack需要运行NodeExternals, 他的作用是将express这类node模块不被打包到js里。
const merge = require('webpack-merge');
const config = require('./webpack.base.js');
const serverConfig = {
target: 'node',
mode: 'development',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: Path.resolve(__dirname, 'build')
},
externals: [NodeExternals()],
module: {
rules: [
{
test: /\.css?$/,
use: ['isomorphic-style-loader', {
loader: 'css-loader',
options: {
importLoaders: 1,
modules: true,
localIdentName: '[name]_[local]_[hase:base64:5]'
}
}]
}
]
}
}
module.exports = merge(config, serverConfig);
webpack.client.js客户端使用style-loader加载。
const Path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');
const clientConfig = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: Path.resolve(__dirname, 'public')
},
module: {
rules: [
{
test: /\.css?$/,
use: ['style-loader', {
loader: 'css-loader',
options: {
importLoaders: 1,
modules: true,
localIdentName: '[name]_[local]_[hase:base64:5]'
}
}]
}
]
}
};
module.exports = merge(config, clientConfig);
src/components/Home/style.css
body {
background: green;
}
.test {
background: red;
}
src/components/Home/index.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
import styles from './style.css';
class Home extends Component {
componentWillMount() { // 处理样式
if (this.props.staticContext) { // 服务端运行存在,客户端运行不存在。所以客户端不要执行。将样式存储在context中。
this.props.staticContext.css = styles._getCss();
}
}
getList() {
const { list } = this.props;
return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
}
render() {
return <div className={styles.test}>
<div>Home</div>
{this.getList()}
<button onClick={() => { alert('click1'); }>按钮</button>
</div>
}
componentDidMount() {
if (!this.props.list.length) {
this.props.getHomeList();
}
}
}
Home.loadData = (store) => {
// 执行action,扩充store。
return store.dispatch(getHomeList());
}
const mapStatetoProps = state => ({
list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
})
export default connect(mapStatetoProps, mapDispatchToProps)(Home);
src/server/index.js在render方法里面对样式进行处理。
import express from 'express';
import proxy from 'express-http-proxy';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';
const app = express();
app.use(express.static('public'));
app.use('/api', proxy('xx.xx.xx.xx', {
proxyReqPathResolver: (req) => { // 转发到哪个路径
return req.url;
}
}))
app.get('*', function(req, res) {
const store = getStore();
const matchedRoutes = matchRoute(routes, req,path);
const promises = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve);
})
promises.push(promise);
}
});
Promise.all(promises).then(() => {
const html = render(store, routes, req, context)
res.send(html);
})
})
var server = app.listen(3000);
src/server/utils.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { Provider } from 'react-redux';
export const render = (store, routes, req, context) => {
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</Provider>
));
const cssStr = context.css ? context.css : '';
return `
<html>
<head>
<style>${cssStr}</style>
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringfiy(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`;
}
多个组件的样式如何整合。我们可以使用一个数组来存储css样式。
src/server/utils.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { Provider } from 'react-redux';
export const render = (store, routes, req, context) => {
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</Provider>
));
const cssStr = context.css.length ? context.css.join('\n') : '';
return `
<html>
<head>
<style>${cssStr}</style>
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringfiy(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`;
}
src/components/Home/index.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
import styles from './style.css';
class Home extends Component {
componentWillMount() { // 处理样式
if (this.props.staticContext) { // 服务端运行存在,客户端运行不存在。所以客户端不要执行。将样式存储在context中。
this.props.staticContext.css.push(styles._getCss());
}
}
getList() {
const { list } = this.props;
return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
}
render() {
return <div className={styles.test}>
<div>Home</div>
{this.getList()}
<button onClick={() => { alert('click1'); }>按钮</button>
</div>
}
componentDidMount() {
if (!this.props.list.length) {
this.props.getHomeList();
}
}
}
Home.loadData = (store) => {
// 执行action,扩充store。
return store.dispatch(getHomeList());
}
const mapStatetoProps = state => ({
list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
})
export default connect(mapStatetoProps, mapDispatchToProps)(Home);
src/server/index.js在render方法里面对样式进行处理。
import express from 'express';
import proxy from 'express-http-proxy';
import { matchRoute } from 'react-router-config';
import { render } from './utils';
import { getStore } from '../store'; // 使用store
import routes from '../Routes';
const app = express();
app.use(express.static('public'));
app.use('/api', proxy('xx.xx.xx.xx', {
proxyReqPathResolver: (req) => { // 转发到哪个路径
return req.url;
}
}))
app.get('*', function(req, res) {
const store = getStore();
const matchedRoutes = matchRoute(routes, req,path);
const promises = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve);
})
promises.push(promise);
}
});
Promise.all(promises).then(() => {
const context = { css: [] };
const html = render(store, routes, req, context)
res.send(html);
})
})
var server = app.listen(3000);
其实上面代码还有一个问题。Home组件上我们挂载了一个loadData的方法,但是Home文件我们导出的并不是Home组件,而是connect包装过后的组件,所以导出的是另一个组件。不过幸好connect会分析原组件有哪些属性,并且再挂载到当前输出的内容上,所以后面我们使用Home组件的时候仍旧可以调用loadData方法。不过这样并不太好,最好还是直接声明一下,避免代码使用混乱。
将loadData挂载到ExportHome上。
src/components/Home/index.js
...
// Home.loadData = (store) => {
// // 执行action,扩充store。
// return store.dispatch(getHomeList());
// }
const mapStatetoProps = state => ({
list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
})
const ExportHome = connect(mapStatetoProps, mapDispatchToProps)(Home);
ExportHome.loadData = (store) => {
// 执行action,扩充store。
return store.dispatch(getHomeList());
}
export default ExportHome
使用高阶组件精简代码
上面我们的样式编写太麻烦了,我们首先需要使用componentWillMount生命周期,让后将它的样式注入到context之中。所以每一个组件都需要这样一段代码。这样的设计时并不合理的。我们可以整理一下。使用高阶组件。
src/withStyle.js创建这个高阶组件函数。这个函数返回一个组件。其实这个函数是生成高阶组件的函数,而返回的组件叫做高阶组件,他的工作是渲染前push样式。
我们这个函数要接收样式文件styles,因为组件并不知道styles在哪。还要接收原本要渲染的组件DecoratedComponent,在高阶组件中渲染出来,并且将参数传递进去。
import React, { Component } from 'react';
export default (DecoratedComponent, styles) => {
return class NewComponent extends Component {
componentWillMount() {
if (this.props.staticContext) {
this.props.staticContext.css.push(styles._getCss());
}
}
render() {
return <DecoratedComponent {...this.props}/>
}
}
}
这样高阶组件就写完了,接着我们改造一下Home组件。
src/components/Home/index.js,这里可以删掉自身的componentWillMount了,引入withStyle函数,然后再底部导出的时候使用withStyle包裹住Home组件,再传入styles样式就可以了。withStyle(Home, styles);
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';
import styles from './style.css';
import withStyle from '../../withStyle';
class Home extends Component {
getList() {
const { list } = this.props;
return this.props.list.map(item => <div key={item.id}>{item.title}</div>)
}
render() {
return <div className={styles.test}>
<div>Home</div>
{this.getList()}
<button onClick={() => { alert('click1'); }>按钮</button>
</div>
}
componentDidMount() {
if (!this.props.list.length) {
this.props.getHomeList();
}
}
}
const mapStatetoProps = state => ({
list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
})
const ExportHome = connect(mapStatetoProps, mapDispatchToProps)(withStyle(Home, styles));
ExportHome.loadData = (store) => {
// 执行action,扩充store。
return store.dispatch(getHomeList());
}
export default ExportHome
SEO优化
SEO优化也叫搜索引擎优化。
title和description对搜索引擎优化基本没什么帮助,他们只是网站的描述。百度的搜索会根据网站所有文本的内容进行匹配,给网站进行分类。所以很多时候我们搜索出来的网站和我们需要的内容一致,但是搜索出来的网站titile中并不包含搜索的关键词。
一个网站是由文字,多媒体,链接三部分组成。
在当今的互联网,内容需要原创,原创作品会得到更多的流量,SEO会分析内容的原创性。所以文字我们可以增加原创属性。
链接到的网站内容和当前网站的内容要相关,相关性越强SEO权重越高。
多媒体也需要原创。
React-Helmet
React-Helmet可以定制页面的title和meta
import React, { Component, Fragment } from 'react';
import { Helmet } from 'react-helmet';
class Home extends Component {
render() {
return <Fragment>
<Helmet>
<title>这是Helmet定义的title</title>
<meta name="description" content="这是Helmet定义的description" />
</Helmet>
<div>Home</div>
{this.getList()}
<button onClick={() => { alert('click1'); }>按钮</button>
</Fragment>
}
}
上面的代码只是客户端的渲染,服务器短的渲染有一点不一样,不过也很简单,我们修改一下utils.js
src/server/utils.js
...
import { Helmet } from 'react-helmet';
export const render = (store, routes, req, context) => {
...
const helmet = Helmet.renderStatic();
return `
<html>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
<style>${cssStr}</style>
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringfiy(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>
`;
}
预渲染
url:localhost:8000/render?url=http://localhost:3000/
server.js
const prerender = require('prerender');
const server = prerender({
port: 8000
});
server.start();
运行
node ./server.js
这样访问到的url内容中就存在了页面元素。我们可以在网站外层架设一层nginx,如果访问是个蜘蛛就将请求转发给预渲染服务器,如果是用户就将请求转发给真实的服务器。