早期的SSR(Server Side Rendering) : 服务端渲染,在最早期的网页开发时代,就是采用这种形式,由服务端渲染出页面结构,直接返回给客户端,首屏页面直出,SEO也较友好,但页面路由跳转会导致整个页面重新加载;
**CSR(Client Side Rendering):**随着前后端分离、提高开发效率的思想逐渐流行,react、vue等前端框架的默认支持,前端路由的无刷新切换页面,逐渐成为目前前端开发的主流形式。服务端返回的只是一个空页面,通过客户端加载js,填充生成整个页面展现给客户,减小了服务端的压力,但首屏等待时间较长,而且由于服务端返回空页面,导致对SEO并不友好。
**新时代的SSR:**为了解决CSR的痛点,开发者们重新把目光投向了SSR,结合CSR, 采用同构的模式,刷新SSR直出页面结构,之后客户端接管页面,前端路由无刷新切页,兼具了SSR和CSR的优点。目前结合react和vue也有了对应的SSR框架,next.js和nuxt.js.
本文通过实现简单的demo, 理解React SSR 服务端渲染的过程。
**同构:**同构这个概念存在于 Vue,React 这些新型的前端框架中,同构实际上是客户端渲染和服务器端渲染的一个整合。我们把页面的展示内容和交互写在一起,让代码执行两次。在服务器端执行一次,用于实现服务器端渲染,在客户端再执行一次,用于接管页面交互。
SSR 之所以能够实现,本质上是因为虚拟 DOM 的存在,dom的操作在服务端是无法实现的,而虚拟 DOM 是真实 DOM 的一个 JavaScript 对象映射,React 在做页面操作时,实际上不是直接操作 DOM,而是操作虚拟 DOM,也就是操作普通的 JavaScript 对象,这就使得 SSR 成为了可能。在服务端将虚拟dom映射成字符串返回,在客户端将虚拟dom映射为真实dom挂载到页面上。
SSR一般都需要一个node服务器作为中间层,由node处理服务端渲染,以及转发客户端到数据服务器的请求。
1. 配置webpack
既然需要node中间层, 那么就必须有node服务代码和客户端代码的入口,配置两份webpack配置
客户端 webpack.client.js:
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
}
}
resolve: {
extensions: [".js", ".jsx"], //引入文件时支持省略后缀,配置越多性能消耗越多
alias: {
"@": path.resolve(__dirname, "../src"), //引用文件时可以用“@”代表“src”的绝对路径,样式文件中为“~@”
}
}
}
服务端 webpack.server.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
target: 'node',
mode: 'development',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'build')
},
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
}
}
resolve: {
extensions: [".js", ".jsx"], //引入文件时支持省略后缀,配置越多性能消耗越多
alias: {
"@": path.resolve(__dirname, "../src"), //引用文件时可以用“@”代表“src”的绝对路径,样式文件中为“~@”
}
}
}
webpack-node-externals插件是用于在node环境下三方模块不被打包到最终的源码中,因为node环境下的npm已经安装了这些依赖;target: node 是让node 的核心模块不被webpack打包。
2. 配置路由,前后端同构 --- react-router-config;
对于页面代码我们使用的同一套,只是前后端使用的路由并不同,客户端使用BrowserRouter, 而react-router-dom为客户端渲染提供了StaticRouter, 对于路由的渲染管理建议使用react-router-config
路由配置文件:
import App from "./containers/App"
import Home from "./containers/Home";
import Login from "./containers/Login";
import Personal from "./containers/Personal";
import NotFound from "./containers/NotFound";
const routes = [
{
path: "/",
component: App,
loadData: App.loadData,
routes:[
{
path: "/",
component: Home,
exact: true,
// 每个路由组件的静态方法就是为在服务端的store灌入初始数据
loadData: Home.loadData,
},
{
path: "/login",
exact: true,
component: Login,
},
{
path: "/personal",
exact: true,
component: Personal
},
{
component: NotFound,
}
]
}
]
export default routes;
client端入口路由:
import { renderRoutes } from "react-router-config";
import routes from '../Router';
const App = () => {
return <Provider store={getClientStore()}>
<BrowserRouter>
{renderRoutes(routes)}
</BrowserRouter>
</Provider>
}
// 挂载到页面
ReactDom.render(<App/>, document.querySelector('#root'))
server端入口路由:
import { renderRoutes } from "react-router-config";
import routes from '../Router';
const App = () => {
return <Provider store={getClientStore()}>
<StaticRouter location={url} context={{}}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
}
// 转换为字符串返回
return ReactDom.renderToString(<App/>)
StaticRouter的匹配需要手动传入匹配的路由地址 location={url}。
3. 结合Redux实现首页的数据直出
node转发请求, node端我采用了koa, 使用koa-server-http-proxy做代理请求
import proxy from 'koa-server-http-proxy';
...
app.use(proxy('/api', {
target: 'http://xxx.com',
changeOrigin: true
}))
...
store的创建:
// 服务端store
// 服务器端的 Store 是所有用户都要用的,每个用户访问的时候,这个函数重新执行,为每个用户提供一个独立的 Store, 而不是提前创建好的一个单例:
export const getServerStore = (ctx) => createStore(reducer, applyMiddleware(logger, thunk.withExtraArgument(serverHttp)));
// 客户端store
export const getClientStore = () => {
const initState = window._content.state;
return createStore(reducer, initState, applyMiddleware(logger, thunk.withExtraArgument(clientHttp)));
}
同构的存在服务端的初始页面数据请求不需要代理,而客户端需要代理,解决方案:
axios构建两个实例clientHttp 和 serverHttp,设置不同的baseURL,在createStore应用redux-thunk中间件时 thunk.withExtraArgument(api)传入,在异步dispatch的第三个参数获取到axios实例,通过该实例派发请求。
首屏数据的获取, 通过redux和dispatch去获取
....server端解析页面需要的数据
import routes from '../Router';
// 获取匹配到的路由
const matchedRoutes = matchRoutes(routes, ctx.url);
// 得到数据请求数组 --- 一组promise
const promiseDatas = [];
matchedRoutes.forEach(({route}) => {
if(route.loadData) {
promiseDatas.push(route.loadData(store));
}
})
// 执行数据请求,为store灌入初始数据
Promise.all(promises).then(() => {
// 生成要返回的页面
})
................................
...组件中
import {getNewsList} from './store/actions';
import {useSelector, useDispatch} from 'react-redux';
import styles from './index.css';
const Home = () => {
const name = useSelector(({root}) => root.name);
const list = useSelector(({home}) => home.list);
const dispatch = useDispatch();
useEffect(() => {
if(!list.length) {
dispatch(getNewsList());
}
}, [])
return <div>
<h1 className={styles.title}>Home Page !!!</h1>
<h2>name: {name}</h2>
<ul>
{
list.map(({title, content}) => <li key={title}>
<h4>{title}</h4>
<p>{content}</p>
</li>)
}
</ul>
<button onClick={() => console.log('click button')}>click</button>
</div>
}
// 此静态方法为服务端用来做数据直出
Home.loadData = (store) => {
return store.dispatch(getNewsList());
}
export default Home;
数据的脱水和注水
服务端渲染之后,拿到了首页数据,但客户端会再次渲染,store是空的。解决办法:在服务端渲染的时候将获取到的数据赋值一个全局变量(注水),客户端创建的store以这个变量的值作为初始值(脱水),这样就做到的首屏的数据直出。
// server端注水,再返回的模板字符串中注入数据
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/antd/4.8.2/antd.min.css" integrity="sha512-CPolmBEaYWn1PClN5taQQ0ucEhAt+9j7+Tiog/SblkFjZ5k6M3TioqmlpcHKwUhIcsu1s7lgnX4Plsb6T8Kq5A==" crossorigin="anonymous" />
<title>React-SSR</title>
</head>
<body>
<div id="root">${contents}</div>
<script>
window._content = {
state: ${JSON.stringify(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>`
// 客户端脱水
export const getClientStore = () => {
const initState = window._content.state;
return createStore(reducer, initState, applyMiddleware(logger, thunk.withExtraArgument(clientHttp)));
}
4. 首屏样式的直出
webpack配置css解析
// webpack.client.js --- 客户端正常配置css-loader和style-loader
.....
module: {
rules: [
{
test: /\.css$/i,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
esModule: false,
modules: {
compileType: 'module',
localIdentName: '[name]_[local]_[hash:base64:5]'
},
}
}
]
},
]
}
.....
// webpack.server.js --- server端使用isomorphic-style-loader代替style-loader, 因为style-loader是生成style标签挂载到页面的,服务端明显不合适
module: {
rules: [
{
test: /\.css$/,
use: ['isomorphic-style-loader', {
loader: 'css-loader',
options: {
esModule: false,
importLoaders: 1,
modules: {
compileType: 'module',
localIdentName: '[name]_[local]_[hash:base64:5]'
},
}
}]
},
]
}
服务端改造
import React from 'React';
import {renderToString} from 'react-dom/server';
import { renderRoutes } from "react-router-config";
import StyleContext from 'isomorphic-style-loader/StyleContext';
// react服务端渲染路由需要使用StaticRouter
import {StaticRouter} from 'react-router-dom';
import {Provider} from 'react-redux';
export const render = (store, routes, url, context) => {
const css = new Set();
const insertCss = (...styles) => {
styles.forEach(style => {
css.add(style._getCss());
})
};
const contents = renderToString(
<StyleContext.Provider value={{ insertCss }}>
<Provider store={store}>
// context可以在服务端渲染时在组件的props.staticContext中获取到,以区分两端环境
<StaticRouter location={url} context={{}}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
</StyleContext.Provider>
);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<style id="ssr-style">${[...css].join('\n')}</style>
<title>React-SSR</title>
</head>
<body>
<div id="root">${contents}</div>
<script>
window._content = {
state: ${JSON.stringify(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>`;
}
客户端使用
import useStyles from 'isomorphic-style-loader/useStyles';
const Home = () => {
...
// 区分server端和client端
if(props.staticContext) {
useStyles(styles);
}
return <div>
....
</div>
}
最后贴一下依赖版本
"dependencies": {
"@babel/core": "^7.12.3",
"@babel/plugin-proposal-function-bind": "^7.12.1",
"@babel/plugin-transform-runtime": "^7.12.1",
"@babel/preset-env": "^7.12.1",
"@babel/preset-react": "^7.12.1",
"@babel/preset-stage-0": "^7.8.3",
"@babel/runtime": "^7.12.1",
"axios": "^0.21.0",
"babel-loader": "^8.1.0",
"css-loader": "^5.0.1",
"isomorphic-style-loader": "^5.1.0",
"koa": "^2.13.0",
"koa-router": "^9.4.0",
"koa-server-http-proxy": "^0.1.0",
"koa-static": "^5.0.0",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "^7.2.2",
"react-router-config": "^5.1.1",
"react-router-dom": "^5.2.0",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"style-loader": "^2.0.0",
"webpack": "5.4.0",
"webpack-cli": "^4.1.0",
"webpack-node-externals": "^2.5.2"
},
"devDependencies": {
"redux-logger": "^3.0.6",
"webpack-merge": "^5.3.0"
}