react ssr 服务端渲染入门
前言
前后端同构,作为针对单页应用 SEO 优化乏力、首屏速度瓶颈等问题而产出的解决方案,近来在 react、vue 等前端技术栈中都得到了支持。当我们正打算抛弃传统的纯服务端渲染模式,拥抱前后端分离的最佳实践时,有些人却已经从单页应用的格局里跳出,重新去思考和定义服务端渲染。
为什么要用服务端渲染?
- 加快首屏渲染,减少白屏时间
与传统的web项目直接获取服务端渲染好的HTML不同,单页面应用使用JavaScript在脚本客户端生成HTML来呈现内容,用户需要等待JS解析执行完成后才能看到页面,这就使得白屏加载时间变长,影响用户体验。
- SEO 友好
对于单页面应用,当搜索引擎的爬虫爬取网站HTMl文件时,通常情况下单页面应用中没有任何的内容,仅有<div id="root"> </div>
这么一句话,从而影响排名。
因此,业界借鉴传统的服务端渲染的方案,提出在服务端执行前端框架(React/Vue/Angular)代码生成HTML,然后将渲染好的HTML直接返回给客户端。
图片引自 https://www.jianshu.com/p/a3bce57e7349
技术原理
以React为例,首先我们让React代码在服务端执行一次,使得用户下载的HTML已经包含了所有的页面展示内容(达到新增SEO的目的)。同时,用户不需要等到JavaScript代码全部执行完就可以看到页面效果,增强用户体验。之后,我们让React在客户端再次执行,为HTML页面中的内容添加数据及事件的绑定,页面就具备了React的各种交互能力。
核心API
服务端:使用 ReactDOMServer.renderToString | ReactDOMServer.renderToNodeStream 生成HTML,并且在首次请求下发。
客户端:使用 ReactDOM.hydrate 根据服务端返回的HTML进行 hydrate 操作。React 会尝试在已有标记上绑定事件监听器(从服务端返回的HTMl是不带任何事件的)。
在SSR项目中渲染组件
技术栈: React + Koa2 + Webpack
1. 使用koa搭建服务端环境
新建文件夹,并且初始化项目
mkdir ssr-demo && cd ssr-demo
npm init -y
安装Koa环境
cnpm install --save koa
在项目根目录创建app.js,监听8888端口,当请求根目录时,返回一些HTML
// app.js
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx) => {
ctx.body = `
<html>
<head>
<title>ssr demo</title>
</head>
<body>
<div style="color: red"> Hello World </>
</body>
</html>
`
})
app.listen(8888);
console.log('app is starting at port 8888');
在终端输入命令启动服务 node app.js
访问本地的 http://localhost:8888/,可以看到HTMl返回了。
2.在服务端编写React代码
我们已经启动了一个Node服务器,下一步我们需要在服务器上编写React代码(也可以是Vue或者是其他框架语言),我们创建一个React组件,并且在App中返回。
安装React环境,创建src/components文件夹,新建home.js文件
cnpm install --save-dev React
mkdir src && cd src && mkdir components && cd components && touch home.js
用jsx编写一个最简单的React组件
import React from 'react'
const home = () => {
return <div> This is a React Component</div>
}
export default home;
并且在app.js中引用
const Koa = require('koa');
const { renderToString } = require('react-dom/server');
const Home = require('./src/components/home');
const app = new Koa();
app.use(async (ctx) => {
ctx.body = renderToString(<Home />)
})
app.listen(8888);
console.log('app is starting at port 8888');
然而这段代码并不会成功运行成功。原因如下
-
当前是在Node环境下,Node不能识别import和export。这二者属于ESM语法,而Node遵循的是common.js规范
-
Node不能识别JSX语法
不过,幸运的是Babel可以帮助我们将import和export转化成common.js的规范,同时Babel也提供了插件将JSX语法转化成正常的JavaScript。
为了方便起见,我们直接使用Babel的@babel/preset-env和@babel/preset-react预设。Babel的预设是一系列插件的集合。包括了用来转化成Commonjs的 babel-plugin-transform-modules-commonjs 插件,也包括了转化JSX的 babel-plugin-transform-react-jsx 等一系列插件)
@babel/register
Babel 其中的一种使用方法是通过 require 钩子(也可以结合webpack等其他工具使用,这里为了方便理解暂时用register举例)。require 钩子 将自身绑定到 node 的 require 模块上,并在运行时进行即时编译。 这和 CoffeeScript 的 coffee-script/register 类似。
这是 koa 官方给出的@babel/register使用方法,当我们引入了@babel/register之后,就会在require方法中注入Babel钩子,并且在运行时进行即时编译。
require('babel-core/register');
// require the rest of the app that needs to be transpiled after the hook
const app = require('./app');
所以需要对我们目前的内容进行改造
app.js
require('@babel/register');
const app = require('./server').default;
app.listen(8888);
console.log('app is starting at port 8888');
新建server.js
const Koa = require('koa');
import React from 'react';
const { renderToString } = require('react-dom/server');
const Home = require('./src/components/home').default;
const app = new Koa();
app.use(async (ctx) => {
ctx.body = renderToString(<Home />)
})
app.listen(8001);
console.log('app is starting at port 8888');
export default app;
在项目根目录上创建babel的配置文件 babel.config.js
module.exports = function(api) {
api.cache(true);
return {
presets: [
['@babel/preset-env', {
targets: {
node: true,
},
modules: 'commonjs',
useBuiltIns: 'usage',
corejs: { version: 3, proposals: true },
}],
'@babel/preset-react',
],
}
}
当然,别忘记了安装相关的依赖
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react @babel/register react-dom
大功告成! 运行 node app.js
3.同构的概念
通过上面的例子,我们已经能够将React组件渲染到页面。为了讲明白同构的概念,下面我们为组件绑定一个点击事件。
import React from 'react';
const Home = () => {
return <div> This is a React Component
<button onClick={()=>{alert('click')}}>click</button>
</div>
}
export default Home;
重新运行代码,刷新页面(由于我们的工程里没有集成热更新,所以每次修改还需要重启并且刷新页面)。我们会发现,点击按钮后onClick事件并不会执行。这是因为renderToString()方法只渲染了组件的内容,并不会绑定事件(DOM的宿主是浏览器)。因此我们需要将React代码在服务端执行一遍,在客户端再执行一遍,这种服务端和客户端公用一套代码的方式就称之为同构。
4.在客户端执行React代码
之前我们说过,React代码在服务端执行的时候只能返回HTML页面,但是不具备任何交互。需要我们将React代码在客户端重新再执行一遍,确保页面能响应onClick等事件。React提供了 hydrate 方法。
ReactDOM.hydrate(element, container[, callback])
为了能在客户端执行这段代码,我们需要在模板中手动引入该代码
新建client-ssr.js
import React from 'react'
import {hydrate} from 'react-dom'
import Home from './src/components/home'
hydrate(
<Home />,
document.getElementById('app')
)
新建template.js
export default function template(content = "") {
let page = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="data:;base64,=">
</head>
<body>
<div id="app">
${content}
</div>
<script src="/asset/client-ssr.js"></script>
</body>
`;
return page;
}
修改server.js 将template的内容作为结果返回
const Koa = require('koa');
import React from 'react';
import template from './template';
const { renderToString } = require('react-dom/server');
const Home = require('./src/components/home').default;
const app = new Koa();
app.use(async (ctx) => {
ctx.body = template(renderToString(<Home />))
})
app.listen(8001);
console.log('app is starting at port 8888');
export default app;
重启,发现报错了!
由于我们的client-ssr中是React的JSX语法,直接返回给浏览器是解析不了的,需要用babel解析成浏览器能识别的JavaScript。
webpack打包
安装webpack依赖和命令行工具
cnpm install --save-dev webpack webpack-cli
新建webpack配置文件,另外我们需要安装babel-loader去解析我们的JSX语法
const path = require('path');
module.exports = {
mode: 'development',
entry: {
client: './client-ssr',
},
output: {
path: path.resolve(__dirname, 'asset'),
filename: "[name].js"
},
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }
]
}
}
终端运行 npx webpack,打包client-ssr.js文件。
使用koa-static指定根目录
到目前为止,我们的文件已经准备好了。但是在访问的时候会发现,我们在template.js中的/asset/client.js
文件并没有拿到。所以我们需要告诉当前服务,项目的根目录在哪里,服务端才能正确返回我们需要的文件,这里使用 koa-static 中间件。
修改server.js
import Koa from 'koa';
import serve from 'koa-static';
import path from 'path';
import React from 'react';
import template from './template';
import { renderToString } from 'react-dom/server';
import Home from './src/components/home';
const app = new Koa();
app.use(serve(path.resolve(__dirname)));
app.use(async (ctx) => {
ctx.body = template(renderToString(<Home />))
})
export default app;
重新启动服务器,点击click按钮,成功了!再次在客户端渲染后,我们的页面能正常相应click事件了。
在SSR项目中使用路由 (路由同构)
1. 在客户端中使用路由
同样的,在使用路由时,我们需要在服务端和客户都各配置一遍路由。首先我们安装react-router-dom
为了使得客户端和服务端的路由能够匹配,我们在src文件夹下创建router/index.js来存放公共的路由配置
import React from 'react';
import { Route } from 'react-router-dom'
import Home from '../components/home'
import Login from '../components/Login'
export default (
<div>
<Route path="/" exact component={Home} />
<Route path="/home" exact component={Home} />
<Route path="/login" exact component={Login} />
</div>
)
为了测试路由,在src/components下再创建一个Login组件
import React from "react";
const Login = () => {
return (
<div>
<div>
<span>请输入账号</span>
<input placeholder="请输入密码" />
</div>
<div>
<span>请输入密码</span>
<input placeholder="请输入密码" />
</div>
</div>
);
};
export default Login;
然后在client-ssr.js中引入Route文件,并且采用BrowserRouter
包起来
import React from "react";
import { hydrate } from "react-dom";
import { BrowserRouter } from "react-router-dom";
import Router from './src/router'
hydrate(
<BrowserRouter> {Router} </BrowserRouter>,
document.getElementById("app")
);
别忘了,运行 npx webpack命令 对client-srr.js进行编译
2. 在服务端中使用路由
2.1 用 StaticRouter 替代 BrowserRouter
在服务器端我们需要使用StaticRouter来替代BrowserRouter,StaticRouter 是 React-Router 针对服务器端渲染专门提供的一个路由组件,由于StaticRouter不能像BrowserRouter一样感知页面当前页面的url,所以我们需要给StaticRouter传入location={当前页面url},另外使用 StaticRouter时必须传递一个context参数,用于服务端渲染时的参数传递。
ctx.body = template(
renderToString(
//传入当前path
//context为必填参数,用于服务端渲染参数传递
<StaticRouter location={ctx.url} context={context}>
{Router}
</StaticRouter>
// <Home />
)
);
2.2 使用 koa-router 来控制服务端的请求路径
由于我们不确定用户在访问页面时候的初始路径是什么,所以干脆对所有的路径都进行接收,然后通过location传递到Route中进行匹配。
最终的server.js代码
import Koa from "koa";
import serve from "koa-static"; // 用来指定项目的根目录,根目录之上的文件都不能被访问到
import path from "path";
import React from "react";
import template from "./template";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import KoaRouter from "koa-router";
import Router from "./src/router";
const koaRouter = new KoaRouter();
const app = new Koa();
app.use(serve(path.resolve(__dirname)));
koaRouter.get("/(.*)", async (ctx) => {
const context = {};
console.log('ctx.url', ctx.url);
ctx.body = template(
renderToString(
//传入当前path
//context为必填参数,用于服务端渲染参数传递
<StaticRouter location={ctx.url} context={context}>
{Router}
</StaticRouter>
// <Home />
)
);
});
app.use(koaRouter.routes());
export default app;
运行node app.js, 在浏览器中分别输入http://localhost:8002/home
和http://localhost:8002/login
就能实现React路由功能。
值得注意的是,只有在第一次进入页面时,浏览器请求了页面文件,之后切换路由的操作都不会重新请求页面,因为这时页面的路由跳转已经是客户端React的路由跳转了。
在SSR项目中使用Redux (数据同构)
在目前我们服务端返回的页面中,所有的页面都是不携带任何数据的。而往往我们的页面所要展示的内容,可能需要调接口来拿到,而在服务端的项目中,页面一旦确定内容,就没有办法Rerender了,这就要求组件显示的时候,已经将所有数据都准备好了(在服务端返回HTML以前已经完成了接口的调用),而这些准备好的数据,客户端也不需要重复请求,这就是数据同构。
在ssr项目中,数据同构是非常重要的一环,即用同一套代码请求数据,用同一个数据去渲染。那么就牵扯到三个问题:
- 服务器返回组件的时候,需要完成请求,并且携带数据。
- 浏览器接管页面的时候,需要拿到这个数据,不至于重新请求,或者没有数据。
- 浏览器接管以后,从其他路由跳转到该路由,那么需要浏览器发起请求。
1. 数据脱水与注水
注水:服务端异步获取到数据后将store数据通过window.PRELOADED_STATE = ${JSON.stringify(initialState)}注入到页面中
脱水:浏览器初次加载页面后先从window中同步服务端获取到的store数据,并且在页面中获取数据的代码前加一层判断是否有值再决定获取
2. 在项目中集成Redux
2.1 首先安装redux依赖
cnpm install --save-dev redux react-redux redux-thunk
在src目录下创建store目录
├── src
│ ├── store
│ │ ├── actions.js
│ │ └── reducer.js
└───────└── index.js
reducer.js
const defaultState = {
userList: [],
userInfo: {},
};
export default (state = defaultState, action) => {
console.log("reducers - action", action);
switch (action.type) {
case "CHANGE_USER_LIST":
return {
...state,
userList: action.list,
};
case "CHANGE_USER_INFO":
return {
...state,
userInfo: action.userInfo,
};
default:
return state;
}
};
在服务端使用redux的坑
const store= createStore(reducer,applyMiddleware(thunk))
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
{Router}
</StaticRouter>
</Provider>
));
由于createStore创建的store是单例的store,在服务器端这样的写法将导致所有用户共享一个store,所以我们将创建store这一步封装成一个方法,每次调用都返回一个新的store。
index.js
import {createStore, applyMiddleware} from "redux";
import thunk from "redux-thunk";
const reducer = (state={},action)=>{
return state;
}
const getStore = ()=>{
return createStore(reducer,applyMiddleware(thunk));
}
export default getStore;
2.2 对Home组件进行改造
import React, { useEffect } from "react";
import { connect } from 'react-redux';
import { getUserList } from "../store/actions";
const Home = ( {getUserList, userList }) => {
useEffect(() => {
getUserList();
}, [])
return (
<div>
<span>
This is a React Component
</span>
<button
onClick={() => {
alert('click');
}}
>
<span> click </span>
</button>
</div>
);
};
const mapStateToProps = (state)=>({
userList:state.userList
});
const mapDispatchToProps = (dispatch)=>({
getUserList(){
dispatch(getUserList(dispatch))
}
})
export default connect(mapStateToProps,mapDispatchToProps)(Home);
2.3 将服务端store中的数据 在template中挂载到全局变量中
server.js
import Koa from "koa";
import serve from "koa-static"; // 用来指定项目的根目录,根目录之上的文件都不能被访问到
import path from "path";
import React from "react";
import template from "./template";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import KoaRouter from "koa-router";
import { Provider } from 'react-redux';
import getStore from "./src/store";
import Router from "./src/router";
const koaRouter = new KoaRouter();
const app = new Koa();
app.use(serve(path.resolve(__dirname)));
koaRouter.get("/(.*)", async (ctx) => {
const context = {};
console.log('ctx.url', ctx.url);
const store = getStore();
ctx.body = template(
renderToString(
<Provider store={store}>
<StaticRouter location={ctx.url} context={context}>
{Router}
</StaticRouter>
</Provider>,
store.getState()
),
);
});
app.use(koaRouter.routes());
export default app;
template.js
export default function template(content = "", initialState = {}) {
let page = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="data:;base64,=">
</head>
<body>
<div class="content">
<div id="app" class="wrap-inner">
${content}
</div>
</div>
<script>
window.__STATE__ = ${JSON.stringify(initialState)}
</script>
<script src="/asset/client.js"></script>
</body>
`;
return page;
}
客户端的client-ssr.js也接入redux
import React from "react";
import { hydrate } from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import getStore from "./src/store";
import Router from "./src/router";
const App = () => (
<Provider store={getStore()}>
<BrowserRouter>{Router}</BrowserRouter>
</Provider>
);
hydrate(<App />, document.getElementById("app"));
运行npx webpack重新编译 并且node app.js重启服务
然而,服务端并没有返回数据
需要注意的是useEffect在服务端渲染的时候并不会执行,服务端中只能执行componentDidMount生命周期函数之前的代码。
在服务端获取组件的初始化数据
1. 使用react-route执行初始化组件数据的方法
为了使得返回客户端的HTMl包含异步请求的数据,实际上我们需要在首次渲染的时候,根据把不同的页面,给当前的store填充数据,为了实现这个目的,我们必须满足两个条件
-
代码进入某个页面时能匹配到对应组件里的axios请求(之后用setTimeout替代)
-
被匹配到的组件能够将获取的数据传递到服务端的store中,并且挂载到返回页面的全局中。
对于这个问题,react-router已经为SSR提供了方法,我们需要做的是改造我们的router/index.js并且给组件添加自定义生命周期函数
import React from 'react';
import { Route } from 'react-router-dom';
import Home from '../components/home';
import Login from '../components/Login';
export default [
{
key:"default",
path: "/",
exact: true,
component: Home,
loadData: (store) => { Home.getInitialData(store) }
},
{
key:"home",
path: "/home",
exact: true,
component: Home,
loadData: (store) => { Home.getInitialData(store) }
},
{
key:"login",
path: "/login",
exact: true,
component: Login,
loadData: (store) => { Login.getInitialData(store) }
}
];
2. 修改组件,添加自己的生命周期函数用于获取数据
Home.js
import React, { useEffect } from "react";
import { connect } from 'react-redux';
const getUserList = new Promise((resolved, reject) => {
setTimeout( () => {
console.log('我被执行了!!');
// dispatch(changeUserList([{name: 'zaoren'}], [{name: 'ssr'}]));
resolved([{name: 'zaoren'}, {name: 'ssr'}]);
}, 300)
})
const Home = ( {dispatchUserList, userList }) => {
useEffect(() => {
getUserList.then((list) => {
dispatchUserList(list)
})
}, [])
return (
<div>
<span>
This is a React Component
</span>
<button
onClick={() => {
alert('click');
}}
>
<span> click </span>
<p> {JSON.stringify(userList)} </p>
</button>
</div>
);
};
const mapStateToProps = (state)=>({
userList:state.userList
});
const mapDispatchToProps = (dispatch)=>({
dispatchUserList: (list) => {
dispatch({type:'CHANGE_USER_LIST', list})
}
})
Home.getInitialData = (store) => {
return getUserList.then((list) => {
store.dispatch( {type:'CHANGE_USER_LIST', list } )
})
}
export default connect(mapStateToProps,mapDispatchToProps)(Home);
Login.js
Login.getInitialData = (store) => {
return getUserInfo.then((obj) => {
store.dispatch({ type: "CHANGE_USER_INFO", userInfo: obj });
});
};
3. 修改client-ssr.js中route的渲染方式
import React from "react";
import { hydrate } from "react-dom";
import { BrowserRouter, Route } from "react-router-dom";
import { Provider } from "react-redux";
import getStore from "./src/store";
import Router from "./src/router";
const App = () => (
<Provider store={getStore()}>
<BrowserRouter>
{Router.map((router) => (
<Route {...router} />
))}
</BrowserRouter>
</Provider>
);
hydrate(<App />, document.getElementById("app"));
4. 在server.js中调用组件中的初始化方法
import Koa from "koa";
import serve from "koa-static"; // 用来指定项目的根目录,根目录之上的文件都不能被访问到
import path from "path";
import React from "react";
import template from "./template";
import { renderToString } from "react-dom/server";
import { StaticRouter, Route, matchPath } from "react-router-dom";
import KoaRouter from "koa-router";
import { Provider } from "react-redux";
import getStore from "./src/store";
import Router from "./src/router";
const koaRouter = new KoaRouter();
const app = new Koa();
app.use(serve(path.resolve(__dirname)));
koaRouter.get("/(.*)", async (ctx) => {
const context = {};
const store = getStore();
const matchRoutes = [];
const promises = [];
Router.some((route) => {
matchPath(ctx.url, route) ? matchRoutes.push(route) : "";
});
console.log('matchRoutes', matchRoutes);
matchRoutes.forEach((item) => {
promises.push(item.loadData(store));
});
// 子组件中的 getInitialValue 怎么处理???
console.log('promises', promises);
Promise.all(promises).then(() => {
//可以console一下看到当前的store已经有数据
console.log('store.getState()', store.getState());
ctx.body = template(
renderToString(
<Provider store={store}>
<StaticRouter location={ctx.url} context={context}>
{Router.map((router) => (
<Route {...router} />
))}
</StaticRouter>
</Provider>
),
store.getState()
);
});
});
app.use(koaRouter.routes());
export default app;
重新运行npx webpack,并且启动node服务 node app.js
可以看到,我们已经拿到服务端组件初始化的数据了。
但是!还存在一些问题,之前我们提到脱水的概念。现在的情况是,我们在服务端拿到数据后,到客户端渲染的时候,还会再去请求一次,这无疑是一种浪费!
而且我们从数据也可以看出,页面上的内容会闪一下,因为客户端又初始化了一遍redux中的数据。
思考:当组件中含有子组件的话,初始化数据怎么获取???
在服务端获取过的数据不在客户端重新获取
为了数据重复获取的问题,需要做两件事。
-
判断是服务端渲染,还是正常的页面访问,来决定需不需要发起请求
-
将服务端放回的数据作为Redux的初始值
对于问题1,我们可以在所有组件的 useEffect 中判断 比如Home.js中
useEffect(() => {
userList.length === 0 ? getUserList.then((list) => {
dispatchUserList(list)
}) : ""
}, [])
在Login.js中
useEffect(() => {
Object.keys(userInfo).length === 0 ? getUserInfo.then((list) => {
dispatchUserInfo(list);
}) : '';
}, []);
或许,你也可以通过window._STATE_变量来判断是服务端渲染还是正常页面的访问。
Redux的初始化数据,我们只需要在store创建的时候传进去就可以。
src/store/index.js
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import reducer from "./reducer";
const getStore = (preLoadStore) => {
let store;
preLoadStore
? store = createStore(reducer, preLoadStore, applyMiddleware(thunk))
: store = createStore(reducer, applyMiddleware(thunk));
return store
};
export default getStore;
在client-ssr.js中,调用getStore时候传入初始化的值
import React from "react";
import { hydrate } from "react-dom";
import { BrowserRouter, Route } from "react-router-dom";
import { Provider } from "react-redux";
import getStore from "./src/store";
import Router from "./src/router";
const state = window.__STATE__;
const App = () => (
<Provider store={getStore(state)}>
<BrowserRouter>
{Router.map((router) => (
<Route {...router} />
))}
</BrowserRouter>
</Provider>
);
hydrate(<App />, document.getElementById("app"));
需要进一步解决的问题
-
服务端渲染的按需加载
-
欢迎提问补充
参考链接: