服务器端渲染(SSR)的实现与应用
在现代Web开发中,服务器端渲染(Server-Side Rendering,简称SSR)是一项非常重要的技术,它可以显著提升网站的SEO性能,使网站更容易被主流搜索引擎(如Google、Yahoo和Bing)索引。本文将详细介绍如何实现SSR,并结合实际案例展示如何使用Promise来连接组件与Redux,通过API获取数据并进行服务器端渲染。
1. 服务器端渲染的重要性
服务器端渲染对于提升网站的SEO至关重要。虽然Googlebot目前支持客户端渲染(CSR),可以在Google上对网站进行索引,但对于其他搜索引擎(如Yahoo、Bing或DuckDuckGo),使用SSR可以更好地提高网站的SEO效果。如果您不太关心SEO,那么可能不需要担心SSR;但如果您希望网站在多个搜索引擎上都有良好的表现,那么SSR是一个不错的选择。
2. 实现服务器端渲染的准备工作
在开始实现SSR之前,需要安装一些必要的依赖项。可以使用以下命令进行安装:
npm install --save-dev webpack-node-externals webpack-dev-middleware webpack-hot-middleware webpack-hot-server-middleware webpack-merge babel-cli babel-preset-es2015
3. 具体实现步骤
以下是实现服务器端渲染的详细步骤:
1.
添加npm脚本
:在
package.json
文件中添加以下脚本:
"scripts": {
"clean": "rm -rf dist/ && rm -rf public/app",
"start": "npm run clean & NODE_ENV=development BABEL_ENV=development nodemon src/server --watch src/server --watch src/shared --exec babel-node --presets es2015",
"start-analyzer": "npm run clean && NODE_ENV=development BABEL_ENV=development ANALYZER=true babel-node src/server"
}
-
修改
webpack.config.js文件 :由于要实现SSR,需要将Webpack配置分为客户端配置和服务器配置,并以数组形式返回。文件内容如下:
// Webpack Configuration (Client & Server)
import clientConfig from './webpack/webpack.config.client';
import serverConfig from './webpack/webpack.config.server';
export default [
clientConfig,
serverConfig
];
-
创建客户端配置文件
webpack.config.client.js:
// Dependencies
import webpackMerge from 'webpack-merge';
// Webpack Configuration
import commonConfig from './webpack.config.common';
import {
context,
devtool,
entry,
name,
output,
optimization,
plugins,
target
} from './configuration';
// Type of Configuration
const type = 'client';
export default webpackMerge(commonConfig(type), {
context: context(type),
devtool,
entry: entry(type),
name: name(type),
output: output(type),
optimization,
plugins: plugins(type),
target: target(type)
});
-
创建服务器配置文件
webpack.config.server.js:
// Dependencies
import webpackMerge from 'webpack-merge';
// Webpack Configuration
import commonConfig from './webpack.config.common';
// Configuration
import {
context,
entry,
externals,
name,
output,
plugins,
target
} from './configuration';
// Type of Configuration
const type = 'server';
export default webpackMerge(commonConfig(type), {
context: context(type),
entry: entry(type),
externals: externals(type),
name: name(type),
output: output(type),
plugins: plugins(type),
target: target(type)
});
-
创建通用配置文件
webpack.config.common.js:
// Configuration
import { module, resolve, mode } from './configuration';
export default type => ({
module: module(type),
resolve,
mode
});
-
创建
context.js文件 :
// Dependencies
import path from 'path';
export default type => type === 'server'
? path.resolve(__dirname, '../../src/server')
: path.resolve(__dirname, '../../src/client');
-
创建
entry.js文件 :
// Environment
const isDevelopment = process.env.NODE_ENV !== 'production';
export default type => {
if (type === 'server') {
return './render/serverRender.js';
}
const entry = [];
if (isDevelopment) {
entry.push(
'webpack-hot-middleware/client',
'react-hot-loader/patch'
);
}
entry.push('./index.jsx');
return entry;
};
-
创建
externals.js文件 :
// Dependencies
import nodeExternals from 'webpack-node-externals';
export default () => [
nodeExternals({
whitelist: [/^redux\/(store|modules)/]
})
];
-
修改
module.js文件 :
// Dependencies
import ExtractTextPlugin from 'extract-text-webpack-plugin';
// Environment
const isDevelopment = process.env.NODE_ENV !== 'production';
export default type => {
const rules = [
{
test: /\.(js|jsx)$/,
use: 'babel-loader',
exclude: /node_modules/
}
];
if (!isDevelopment || type === 'server') {
rules.push({
test: /\.scss$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [
'css-loader?minimize=true&modules=true&localIdentName=[name]__[local]_[hash:base64]',
'sass-loader'
]
})
});
} else {
rules.push({
test: /\.scss$/,
use: [
{
loader: 'style-loader'
},
{
loader: 'css-loader',
options: {
modules: true,
importLoaders: 1,
localIdentName: '[name]__[local]_[hash:base64]',
sourceMap: true,
minimize: true
}
},
{
loader: 'sass-loader'
}
]
});
}
return {
rules
};
};
-
创建
name.js文件 :
export default type => type;
-
创建
output.js文件 :
// Dependencies
import path from 'path';
export default type => {
if (type === 'server') {
return {
filename: 'server.js',
path: path.resolve(__dirname, '../../dist'),
libraryTarget: 'commonjs2'
};
}
return {
filename: '[name].bundle.js',
path: path.resolve(__dirname, '../../public/app'),
publicPath: '/'
};
};
-
创建
plugins.js文件 :
// Dependencies
import CompressionPlugin from 'compression-webpack-plugin';
import ExtractTextPlugin from 'extract-text-webpack-plugin';
import webpack from 'webpack';
import WebpackNotifierPlugin from 'webpack-notifier';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
// Environment
const isDevelopment = process.env.NODE_ENV !== 'production';
// Analyzer
const isAnalyzer = process.env.ANALYZER === 'true';
export default type => {
const plugins = [
new ExtractTextPlugin({
filename: '../../public/css/style.css'
})
];
if (isAnalyzer) {
plugins.push(
new BundleAnalyzerPlugin()
);
}
if (isDevelopment) {
plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
new WebpackNotifierPlugin({
title: 'CodeJobs'
})
);
} else {
plugins.push(
new CompressionPlugin({
asset: '[path].gz[query]',
algorithm: 'gzip',
test: /\.js$|\.css$|\.html$/,
threshold: 10240,
minRatio: 0.8
})
);
}
return plugins;
};
-
创建
resolve.js文件 :
// Dependencies
import path from 'path';
export default {
extensions: ['.js', '.jsx'],
modules: [
'node_modules',
path.resolve(__dirname, '../../src/client'),
path.resolve(__dirname, '../../src/server')
]
};
-
创建
target.js文件 :
export default type => type === 'server' ? 'node' : 'web';
-
修改
App.jsx文件 :
// Dependencies
import React from 'react';
import {
BrowserRouter,
StaticRouter,
Switch,
Route
} from 'react-router-dom';
// Components
import About from '@components/About';
import Home from '@components/Home';
export default ({ server, location, context = {} }) => {
const routes = (
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/about" component={About} />
</Switch>
);
// Client Router
let router = (
<BrowserRouter>
{routes}
</BrowserRouter>
);
// Server Router
if (server) {
router = (
<StaticRouter location={location} context={context}>
{routes}
</StaticRouter>
);
}
return router;
};
-
修改
server/index.js文件 :
// Dependencies
import express from 'express';
import path from 'path';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackHotMiddleware from 'webpack-hot-middleware';
import webpackHotServerMiddleware from 'webpack-hot-server-middleware';
import webpack from 'webpack';
// Utils
import { isMobile, isBot } from '@utils/device';
// Client Render
import clientRender from './render/clientRender';
// Webpack Configuration
import webpackConfig from '@webpack';
// Environment
const isProduction = process.env.NODE_ENV === 'production';
// Express Application
const app = express();
// Webpack Compiler
const compiler = webpack(webpackConfig);
// Public directory
app.use(express.static(path.join(__dirname, '../../public')));
// Device Detection
app.use((req, res, next) => {
req.isMobile = isMobile(req.headers['user-agent']);
// We detect if a search bot is accessing...
req.isBot = isBot(req.headers['user-agent']);
next();
});
// Webpack Middleware
if (!isProduction) {
// Hot Module Replacement
app.use(webpackDevMiddleware(compiler));
app.use(webpackHotMiddleware(
compiler.compilers.find(compiler => compiler.name === 'client')
));
} else {
// GZip Compression just for Production
app.get('*.js', (req, res, next) => {
req.url = `${req.url}.gz`;
res.set('Content-Encoding', 'gzip');
next();
});
}
// Client Side Rendering
app.use(clientRender());
if (isProduction) {
try {
// eslint-disable-next-line
const serverRender = require('../../dist/server.js').default;
app.use(serverRender());
} catch (e) {
throw e;
}
}
// For Server Side Rendering on Development Mode
app.use(webpackHotServerMiddleware(compiler));
// Disabling x-powered-by
app.disable('x-powered-by');
// Listen Port...
app.listen(3000);
-
修改
clientRender.js文件 :
// HTML
import html from './html';
// Initial State
import initialState from './initialState';
export default function clientRender() {
return (req, res, next) => {
if (req.isBot) {
return next();
}
res.send(html({
title: 'Codejobs',
initialState: initialState(req)
}));
};
}
-
创建
serverRender.js文件 :
// Dependencies
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
// Redux Store
import configureStore from '@configureStore';
// Components
import App from '../../client/App';
import html from './html';
// Initial State
import initialState from './initialState';
export default function serverRender() {
return (req, res, next) => {
// Configuring Redux Store
const store = configureStore(initialState(req));
const markup = renderToString(
<Provider store={store}>
<App
server
location={req.url}
/>
</Provider>
);
res.send(html({
title: 'Codejobs',
markup,
initialState: initialState(req)
}));
};
}
4. 服务器端渲染的工作原理
可以使用
npm start
命令启动应用程序。在浏览器(如Chrome)中打开
http://localhost:3000
,右键单击并选择“查看页面源代码”,可能会发现没有使用SSR。这是因为我们只对搜索机器人使用SSR。
isBot
函数可以检测所有搜索机器人,为了测试SSR,将
curl
作为机器人添加到测试中,其代码如下:
export function isBot(ua) {
const b = /curl|bot|googlebot|google|baidu|bing|msn|duckduckgo|teoma|slurp|yandex|crawler|spider|robot|crawling/i;
return b.test(ua);
}
在应用程序运行时,打开一个新的终端,执行以下命令:
curl http://localhost:3000
可以看到
#root
div内的HTML代码是使用SSR渲染的。如果想测试
/about
路由,也可以使用
curl
命令:
curl http://localhost:3000/about
此外,Chrome有一个名为
User-Agent Switcher for Chrome
的扩展程序,可以在浏览器中指定要使用的用户代理。例如,可以为Googlebot添加一个特殊的用户代理,然后在
User-Agent Switcher
中选择
Chrome | Bot
,当查看页面源代码时,可以看到HTML代码以SSR方式渲染。
5. 处理
window
对象的问题
在使用SSR时,直接使用
window
对象会导致
ReferenceError
,例如:
ReferenceError: window is not defined
为了解决这个问题,可以创建一个函数来验证是否在浏览器环境中使用
window
对象:
export function isBrowser() {
return typeof window !== 'undefined';
}
每次需要使用
window
对象时,可以这样做:
const store = isBrowser() ? configureStore(window.initialState) : {};
6. 使用Promise实现服务器端渲染
前面的示例展示了如何使用简单组件进行SSR,但在实际应用中,可能需要使用Promise来连接组件与Redux,通过API获取数据并进行服务器端渲染。以下是具体步骤:
6.1 准备工作
需要安装以下包:
npm install axios babel-preset-stage-0 react-router-dom redux-devtools-extension redux-thunk
6.2 具体实现步骤
-
添加简单API
:创建一个简单的API来显示待办事项列表,在
src/server/controllers/api.js文件中添加以下代码:
import express from 'express';
const router = express.Router();
// Mock data, this should come from a database....
const todo = [
{
id: 1,
title: 'Go to the Gym'
},
{
id: 2,
title: 'Dentist Appointment'
},
{
id: 3,
title: 'Finish homework'
}
];
router.get('/todo/list', (req, res, next) => {
res.json({
response: todo
});
});
export default router;
-
导入API控制器
:在
src/server/index.js文件中导入API控制器,并将其作为中间件添加到/api路由中:
// Controllers
import apiController from './controllers/api';
// Express Application
const app = express();
// Webpack Compiler
const compiler = webpack(webpackConfig);
// Routes
app.use('/api', apiController);
-
修改
serverRender.js文件 :之前在serverRender.js文件中直接渲染App组件,现在需要从具有initialAction静态方法的组件中获取Promise,将它们保存到promises数组中,解析这些Promise,然后渲染App组件:
// Dependencies
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import { matchPath } from 'react-router-dom';
// Redux Store
import configureStore from '@configureStore';
// Components
import App from '../../client/App';
// HTML
import html from './html';
// Initial State
import initialState from './initialState';
// Routes
import routes from '@shared/routes';
export default function serverRender() {
return (req, res, next) => {
// Configuring Redux Store
const store = configureStore(initialState(req));
// Getting the promises from the components which has initialAction.
const promises = routes.paths.reduce((promises, route) => {
if (matchPath(req.url, route) && route.component && route.component.initialAction) {
promises.push(Promise.resolve(store.dispatch(route.component.initialAction())));
}
return promises;
}, []);
// Resolving our promises
Promise.all(promises)
.then(() => {
// Getting Redux Initial State
const initialState = store.getState();
// Rendering with SSR
const markup = renderToString(
<Provider store={store}>
<App
server
location={req.url}
/>
</Provider>
);
// Sending our HTML code.
res.send(html({
title: 'Codejobs',
markup,
initialState
}));
})
.catch(e => {
// eslint-disable-line no-console
console.log('Promise Error: ', e);
});
};
}
-
修改客户端目录结构
:将组件封装为小型应用程序,在其中创建
actions、API、components、containers和reducers文件夹。新的结构如下:
client
└── todo
├── actions
│ ├── actionTypes.js
│ └── index.js
├── api
│ ├── constants.js
│ └── index.js
├── components
│ ├── Layout.jsx
│ └── Todo.jsx
├── containers
│ └── index.js
└── reducer
└── index.js
-
创建
actionTypes.js文件 :在src/client/todo/actions/actionTypes.js文件中添加FETCH_TODO动作:
// Actions
export const FETCH_TODO = {
request: () => 'FETCH_TODO_REQUEST',
success: () => 'FETCH_TODO_SUCCESS'
};
-
创建
index.js文件 :在src/client/todo/actions/index.js文件中创建fetchTodo动作,用于从API中获取待办事项列表:
// Base Actions
import { request, received } from '@baseActions';
// Api
import api from '../api';
// Action Types
import { FETCH_TODO } from './actionTypes';
export const fetchTodo = () => dispatch => {
const action = FETCH_TODO;
const { fetchTodo } = api;
dispatch(request(action));
return fetchTodo()
.then(response => dispatch(received(action, response.data)));
};
-
创建
baseActions.js文件 :在src/shared/redux/baseActions.js文件中创建request和received函数,用于简化动作的分发:
// Base Actions
export const request = ACTION => ({
type: ACTION.request()
});
export const received = (ACTION, data) => ({
type: ACTION.success(),
payload: data
});
-
创建
constants.js文件 :在src/client/todo/api/constants.js文件中定义API常量:
export const API = Object.freeze({
TODO: 'api/todo/list'
});
-
创建
index.js文件 :在src/client/todo/api/index.js文件中创建Api类,并添加一个静态方法fetchTodo:
// Dependencies
import axios from 'axios';
// Configuration
import config from '@configuration';
// Utils
import { isBrowser } from '@utils/frontend';
// Constants
import { API } from './constants';
class Api {
static fetchTodo() {
// For Node (SSR) we have to specify our base domain (http://localhost:3000/api/todo/list)
// For Client Side Render just /api/todo/list.
const url = isBrowser()
? API.TODO
: `${config.baseUrl}/${API.TODO}`;
return axios(url);
}
}
export default Api;
-
创建
Todo容器 :在src/client/todo/container/index.js文件中,将待办事项列表映射到Redux,并将fetchTodo动作添加到Redux中:
// Dependencies
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
// Components
import Layout from '../components/Layout';
// Actions
import { fetchTodo } from '../actions';
export default connect(({ todo }) => ({
todo: todo.list
}), dispatch => bindActionCreators(
{
fetchTodo
},
dispatch
))(Layout);
-
创建
Layout组件 :在src/client/todo/components/Layout.jsx文件中创建Layout组件:
// Dependencies
import React from 'react';
// Shared Components
import Header from '@layout/Header';
import Content from '@layout/Content';
import Footer from '@layout/Footer';
// Componenets
import Todo from '../components/Todo';
const Layout = props => (
<main>
<Header {...props} />
<Content>
<Todo {...props} />
</Content>
<Footer {...props} />
</main>
);
export default Layout;
-
创建
reducer文件 :在src/client/todo/reducer/index.js文件中创建todoReducer:
// Utils
import { getNewState } from '@utils/frontend';
// Action Types
import { FETCH_TODO } from '../actions/actionTypes';
// Initial State
const initialState = {
list: []
};
export default function todoReducer(state = initialState, action) {
switch (action.type) {
case FETCH_TODO.success(): {
const { payload: { response = [] } } = action;
return getNewState(state, {
list: response
});
}
default:
return state;
}
}
-
创建
Todo组件 :在src/client/todo/components/Todo.jsx文件中创建Todo组件,在componentDidMount方法中执行fetchTodo动作,并将待办事项列表渲染为HTML列表:
// Dependencies
import React, { Component } from 'react';
// Utils
import { isFirstRender } from '@utils/frontend';
// Styles
import styles from './Todo.scss';
class Todo extends Component {
componentDidMount() {
const { fetchTodo } = this.props;
fetchTodo();
}
render() {
const {
todo
} = this.props;
if (isFirstRender(todo)) {
return null;
}
return (
<div>
<div className={styles.Todo}>
<ol>
{todo.map((item, key) =>
<li key={key}>{item.title}</li>)}
</ol>
</div>
</div>
);
}
}
export default Todo;
总结
通过以上步骤,我们详细介绍了如何实现服务器端渲染,并结合实际案例展示了如何使用Promise来连接组件与Redux,通过API获取数据并进行服务器端渲染。服务器端渲染可以显著提升网站的SEO性能,同时解决了在SSR中使用
window
对象的问题。希望本文对您理解和实现服务器端渲染有所帮助。
流程图
graph LR
A[开始] --> B[安装依赖]
B --> C[添加npm脚本]
C --> D[修改webpack配置文件]
D --> E[创建客户端和服务器配置文件]
E --> F[创建通用配置文件]
F --> G[创建其他配置文件]
G --> H[修改App.jsx文件]
H --> I[修改server/index.js文件]
I --> J[修改clientRender.js文件]
J --> K[创建serverRender.js文件]
K --> L[启动应用程序]
L --> M[测试SSR]
M --> N[处理window对象问题]
N --> O[使用Promise实现SSR]
O --> P[结束]
表格
| 步骤 | 描述 |
|---|---|
| 1 | 安装必要的依赖项 |
| 2 |
添加npm脚本到
package.json
文件
|
| 3 |
修改
webpack.config.js
文件
|
| 4 | 创建客户端和服务器配置文件 |
| 5 | 创建通用配置文件 |
| 6 |
创建其他配置文件(如
context.js
、
entry.js
等)
|
| 7 |
修改
App.jsx
文件
|
| 8 |
修改
server/index.js
文件
|
| 9 |
修改
clientRender.js
文件
|
| 10 |
创建
serverRender.js
文件
|
| 11 | 启动应用程序并测试SSR |
| 12 |
处理
window
对象的问题
|
| 13 | 使用Promise实现服务器端渲染 |
6.3 使用Promise实现服务器端渲染的工作原理
在使用Promise实现服务器端渲染时,核心在于处理组件的异步数据获取。通过
serverRender.js
文件中的逻辑,我们可以看到整个过程的详细步骤:
1.
配置Redux Store
:首先,根据请求的初始状态配置Redux Store。
const store = configureStore(initialState(req));
-
获取组件的Promise
:遍历路由配置,找出具有
initialAction静态方法的组件,并将其返回的Promise添加到promises数组中。
const promises = routes.paths.reduce((promises, route) => {
if (matchPath(req.url, route) && route.component && route.component.initialAction) {
promises.push(Promise.resolve(store.dispatch(route.component.initialAction())));
}
return promises;
}, []);
-
解析Promise
:使用
Promise.all方法解析所有Promise,确保所有异步数据都已获取。
Promise.all(promises)
.then(() => {
// 获取Redux初始状态
const initialState = store.getState();
// 使用SSR渲染组件
const markup = renderToString(
<Provider store={store}>
<App
server
location={req.url}
/>
</Provider>
);
// 发送HTML代码
res.send(html({
title: 'Codejobs',
markup,
initialState
}));
})
.catch(e => {
console.log('Promise Error: ', e);
});
7. 常见问题及解决方案
7.1
window
对象问题
在服务器端渲染中,直接使用
window
对象会导致
ReferenceError
。为了解决这个问题,我们创建了
isBrowser
函数来验证是否在浏览器环境中使用
window
对象。
export function isBrowser() {
return typeof window !== 'undefined';
}
在需要使用
window
对象的地方,可以这样使用:
const store = isBrowser() ? configureStore(window.initialState) : {};
7.2 异步数据获取问题
在使用Promise实现服务器端渲染时,确保所有异步数据都已获取是关键。通过在
serverRender.js
文件中使用
Promise.all
方法,我们可以等待所有Promise解析完成后再进行渲染。
7.3 性能问题
服务器端渲染可能会对服务器性能产生一定影响。为了优化性能,可以考虑以下几点:
-
缓存数据
:对于一些不经常变化的数据,可以使用缓存来减少重复的API请求。
-
代码分割
:使用Webpack的代码分割功能,将应用程序拆分为多个小块,减少初始加载时间。
-
压缩代码
:在生产环境中,使用压缩插件(如
CompressionPlugin
)对代码进行压缩,减少传输数据量。
8. 最佳实践
8.1 代码结构
将组件封装为小型应用程序,每个应用程序包含
actions
、
API
、
components
、
containers
和
reducers
文件夹,这样可以提高代码的可维护性和可扩展性。
8.2 错误处理
在
serverRender.js
文件中,使用
try...catch
块来捕获Promise解析过程中的错误,并进行适当的处理。
Promise.all(promises)
.then(() => {
// 渲染逻辑
})
.catch(e => {
console.log('Promise Error: ', e);
});
8.3 性能优化
除了前面提到的缓存数据、代码分割和压缩代码外,还可以使用CDN来加速静态资源的加载。
9. 总结
通过本文的介绍,我们详细了解了服务器端渲染(SSR)的实现方法和使用Promise实现服务器端渲染的具体步骤。服务器端渲染可以显著提升网站的SEO性能,同时解决了在SSR中使用
window
对象的问题。在实际应用中,我们需要注意处理异步数据获取、性能优化和错误处理等问题,遵循最佳实践来提高代码的可维护性和可扩展性。
流程图
graph LR
A[开始使用Promise实现SSR] --> B[配置Redux Store]
B --> C[获取组件的Promise]
C --> D[解析Promise]
D --> E[获取Redux初始状态]
E --> F[使用SSR渲染组件]
F --> G[发送HTML代码]
G --> H[结束]
表格
| 步骤 | 描述 |
|---|---|
| 1 | 配置Redux Store |
| 2 | 获取组件的Promise |
| 3 | 解析Promise |
| 4 | 获取Redux初始状态 |
| 5 | 使用SSR渲染组件 |
| 6 | 发送HTML代码 |
希望本文对您理解和实现服务器端渲染有所帮助,让您能够在实际项目中更好地应用这一技术。
超级会员免费看
888

被折叠的 条评论
为什么被折叠?



