一、简介
参考资料:
背景
- 第一阶段
很久以前, 一个网站的开发还是前端和服务端在一个项目来维护, 可能是用php+jquery。那时候的页面渲染是放在服务端的, 也就是用户访问一个页面a的时候, 会直接访问服务端路由, 由服务端来渲染页面然后返回给浏览器。也就是说网页的所有内容都会一次性被写在html里, 一起送给浏览器。这时候你右键点击查看网页源代码, 可以看到所有的代码; 或者你去查看html请求, 查看”预览”, 会发现他就是一个完整的网页。
- 第二阶段
但是慢慢的人们觉得上面这种方式前后端协同太麻烦, 耦合太严重, 严重影响开发效率和体验。于是随着vue/react的横空出世, 人们开始习惯了纯客户端渲染的spa.这时候的html中只会写入一些主脚本文件, 没有什么实质性的内容. 等到html在浏览器端解析后, 执行js文件, 才逐步把元素创建在dom上。所以你去查看网页源代码的时候, 发现根本没什么内容, 只有各种脚本的链接。
- 第三阶段
后来人们又慢慢的觉得, 纯spa对SEO非常不友好, 并且白屏时间很长。对于一些活动页, 白屏时间长代表了什么? 代表了用户根本没有耐心去等待页面加载完成.所以人们又想回到服务端渲染, 提高SEO的效果, 尽量缩短白屏时间.所以现在有了新的方案, 新的模式, 叫做同构。
所谓的同构理解为:同种结构的不同表现形态, 同一份react代码, 分别在两端各执行一遍。
1、csr与ssr
- SSR:在服务端,把页面所有的内容都初始化好后,将整个页面的HTML丢到浏览器,给浏览器渲染,浏览器渲染的是整个页面的内容,页面的任何操作都会引起页面的刷新,服务端重新生成页面然后返回整个html文件给客户端
- 优点
- 更好的首屏性能,不需要提前下载一堆css和js后才可看到页面
- 更利于SEO,蜘蛛可以直接抓取已渲染的内容
- 缺点
- 每次页面的改变都需要服务端把所有的代码都初始化,页面需刷新后重新展示
- 优点
- CSR:服务器只返回必要的html(只有一个框架和占位节点)和js资源,js在客户端解析、执行然后渲染生成页面,同时js执行会完成页面事件的绑定,优点是前后端分离,页面交互较好
- SSR + SPA:服务端渲染返回首屏,客户端渲染进行aqs88saa交互。
- 既能满足首屏的性能和SEO,又提高了后续页面操作性能和体验
- 前后端分离开发:前端注重动画等渲染,后端注重数据及性能
1、renderToString
- spa react-dom -> render
- ssr react-dom/server -> renderToString
spa里一定见过react-dom的render方法, 其实react-dom里还有一个renderToString方法。
- server.js
// server.js
const express = require('express');
const app = express();
const React = require('react');
const {renderToString} = require('react-dom/server');
const PORT = 3000;
const App = class extends React.PureComponent{
render(){
return React.createElement("h1",null,"Hello World
");;
}
};
app.get('/',function(req,res){
const content = renderToString(React.createElement(App));
res.send(content);
});
app.listen(PORT, () => {
console.log(`sever listen on ${PORT}`)
});
复制代码
- webpack 配置
// build/webpack-server.config.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
entry:{
index:path.resolve(__dirname,'../server.js')
},
mode:'development',
target:'node',
devtool: 'cheap-module-eval-source-map',
output:{
filename:'[name].js',
path:path.resolve(__dirname,'../dist/server')
},
externals:[nodeExternals()],
resolve:{
alias:{
'@':path.resolve(__dirname,'../src')
},
extensions:['.js']
},
module:{
rules:[{
test:/\.js$/,
use:'babel-loader',
exclude:/node_modules/
}]
},
plugins: [
new CopyWebpackPlugin([{
from:path.resolve(__dirname,'../public'),
to:path.resolve(__dirname,'../dist')
}])
]
}
复制代码
- 打包脚本配置
// package.json添加打包的脚本
"build:server": "webpack --config build/webpack-server.config.js --watch"
// package.json添加运行server.js的脚本
"server": "nodemon dist/server/index.js"
//运行一下
npm run build:server
npm run server
复制代码
- 给h1标签绑定一个click事件
import React from 'react';
import {renderToString} from 'react-dom/server';
const express = require('express');
const app = express();
const App = class extends React.PureComponent{
handleClick=(e)=>{
alert(e.target.innerHTML);
}
render(){
return <h1 onClick={this.handleClick}>Hello World!</h1>;
}
};
app.get('/',function(req,res){
const content = renderToString(<App/>);
console.log(content); //获取到完整的html 打印出<h1>Hello World!</h1>
res.send(content);
});
app.listen(3000);
复制代码
上面一些列代码结束,会遇到一个问题,发现怎么点击都没有反应? renderToString是把元素转成字符串而已, 事件什么的根本没有绑定;所以接下来需要用到同构。
2、同构
一般是指服务端和客户端同构,意思是服务端和客户端运行同一套代码程序
- 服务端执行:让 React 代码在服务器端先执行一次,使得用户下载的 HTML 已经包含了所有的页面展示内容
- 客户端执行:React代码在客户端再次执行,为 HTML 网页中的内容添加数据及事件的绑定,页面就具备了 React 的各种交互能力
3、代码解析
-
ReactDOMServer.renderToString(element)
服务端渲染,将React元素渲染为初始HTML,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。
-
ReactDOM.hydrate(element, container[, callback])
与render()相同,但它用于在ReactDOMServer渲染的容器中对HTML的内容进行hydrate操作,react会尝试在已有标记上绑定事件监听器。
-
ReactDOM.render(element, container[, callback])
在提供的container里渲染一个React元素,并返回对该组件的引用(或者对无状态组件返回null),但避免使用该引用(ref替代)
- 如果React元素之前在container里渲染过,这将会对其执行更新操作,并仅会在必要时改变DOM以映射最新的React元素,会使用React的DOM差分算法进行高效的更新。
- 如果提供了可选的回调函数,该回调将在组件被渲染或更新之后被执行。
// 代码结构
/*
* -- build
* |-- webpack-client.config.js
* |-- webpack-server.config.js
* -- server.js
* -- src
* |-- app.js
* |-- index.js
* |-- pages
* |-- login
* |-- user
* |-- notFound
* |-- router
* |-- index.js
* |-- routerConfig.js
*/
// 1、src/app.js
import React from 'react';
class App extends React.PureComponent{
handleClick=(e)=>{
alert(e.target.innerHTML);
}
render(){
return <h1 onClick={this.handleClick}>Hello World!</h1>;
}
};
export default App;
// 2、src/index.js
// 就跟正常spa应用一样的写法
import React from 'react';
import {render} from 'react-dom';
import App from './app';
render(<App/>,document.getElementById("root"));
// 3、build/webpack-client.config.js
// 处理客户端代码的打包逻辑
const path = require('path');
module.exports = {
entry:{
index:path.resolve(__dirname,'../src/index.js')
},
mode:'development',
devtool: 'cheap-module-eval-source-map',
output:{
filename:'[name].js',
path:path.resolve(__dirname,'../dist/client')
},
resolve:{
alias:{
'@':path.resolve(__dirname,'../src')
},
extensions:['.js']
},
module:{
rules:[{
test:/\.js$/,
use:'babel-loader',
exclude:/node_modules/
}]
}
}
// 4、添加客户端代码的打包脚本
"build:client": "webpack --config build/webpack-client.config.js --watch"
// 运行一下
npm run build:client
// 5、server引用打包好的客户端资源
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import App from './src/app';
const app = express();
app.use(express.static("dist"))
app.get('/',function(req,res){
const content = renderToString(<App/>);
res.send(`
<!doctype html>
<html>
<title>ssr</title>
<body>
<div id="root">${content}</div>
<script src="https://juejin.cn/client/index.js"></script>
</body>
</html>
`);
});
app.listen(3000);
复制代码
经过上面的5步, 看起来没问题了, 但是我们的控制台会输出一些warnning。
ReactDOM.hydrate()和ReactDOM.render()的区别就是:
- ReactDOM.render()会将挂载dom节点的所有子节点全部清空掉,再重新生成子节点。
- ReactDOM.hydrate()则会复用挂载dom节点的子节点,并将其与react的virtualDom关联上。
也就是说ReactDOM.render()会将服务端做的工作全部推翻重做,而ReactDOM.hydrate()在服务端做的工作基础上再进行深入的操作
。
所以我们修改一下客户端的入口文件src/index.js, 将render
修改为hydrate
。
import React from 'react';
import { hydrate } from 'react-dom';
import App from './app';
hydrate(<App/>,document.getElementById("root"));
复制代码
4、同构流程总结
- 服务端根据React代码生成html;
- 客户端发起请求, 收到服务端发送的html, 进行解析和展示;
- 客户端加载js等资源文件;
- 客户端执行js文件, 完成hydrate操作;
- 客户端接管整体应用。
2、路由 (简单使用原理)
在客户端渲染时, React提供了BrowserRouter
和HashRouter
来供我们处理路由, 但是他们都依赖window对象
, 而在服务端是没有window的。
但是react-router提供了StaticRouter
, 为我们的服务端渲染做服务。
1、构建模拟页面
构造Login和User两个页面
// src/pages/login/index.js
import React from 'react';
export default class Login extends React.PureComponent{
render(){
return <div>登陆</div>
}
}
// src/pages/user/index.js
import React from 'react';
export default class User extends React.PureComponent{
render(){
return <div>用户</div>
}
}
复制代码
2、添加路由
- 添加服务端路由
// server.js
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import {StaticRouter,Route} from 'react-router';
import Login from '@/pages/login';
import User from '@/pages/user';
const app = express();
app.use(express.static("dist"))
app.get('*',function(req,res){
const content = renderToString(<div>
<StaticRouter location={req.url}>
<Route exact path="/user" component={User}></Route>
<Route exact path="/login" component={Login}></Route>
</StaticRouter>
</div>);
res.send(`
<!doctype html>
<html>
<title>ssr</title>
<body>
<div id="root">${content}</div>
<script src="https://juejin.cn/client/index.js"></script>
</body>
</html>
`);
});
app.listen(3000);
复制代码
- 添加客户端路由
// src/index.js
import React from 'react';
import { hydrate } from 'react-dom';
import App from './app';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import User from './pages/user';
import Login from './pages/login';
hydrate(
<Router>
<Route path="/" component={App}>
<Route exact path="/user" component={User}></Route>
<Route exact path="/login" component={Login}></Route>
</Route>
</Router>,
document.getElementById("root")
);
复制代码
上面的写法已经发现了一个问题, 就是我们这种写法非常的费劲;既要在客户端写一遍路由, 也要在服务端写一遍路由,于是我们采用路由同构来解决问题。
3、路由同构
两端路由的异同:
- 共同点:路径和组件的映射关系是相同的
- 不同点:路由引用的组件不一样, 或者说实现的方式不一样
路径和组件之间的关系可以用抽象化的语言去描述清楚,也就是我们所说路由配置化。
最后我们提供一个转换器,可以根据我们的需要去转换成服务端或者客户端路由。
// 1、 新建src/pages/notFound/index.js
// 新建404
import React from 'react';
export default ()=> <div>404</div>
// 2、路由配置文件
// src/router/routeConfig.js
import Login from '@/pages/login';
import User from '@/pages/user';
import NotFound from '@/pages/notFound';
export default [{
type:'redirect', //自定义类型
exact:true,
from:'/',
to:'/user'
},{
type:'route',
path:'/user',
exact:true,
component:User
},{
type:'route',
path:'/login',
exact:true,
component:Login
},{
type:'route',
path:'*',
render:({staticContext})=>{
if (staticContext) staticContext.NOT_FOUND = true;
return <NotFound/>
}
}]
// 3、router转换器
import React from 'react';
import { createBrowserHistory } from "history";
import {Route,Router,StaticRouter,Redirect,Switch} from 'react-router';
import routeConfig from './routeConfig';
const routes = routeConfig.map((conf,index)=>{
const {type,...otherConf} = conf;
if(type==='redirect'){
return <Redirect key={index} {...otherConf}/>;
}else if(type ==='route'){
return <Route key={index} {...otherConf}></Route>;
}
});
export const createRouter = (type)=>(params)=>{
if(type==='client'){
const history = createBrowserHistory();
return <Router history={history}>
<Switch>
{routes}
</Switch>
</Router>
}else if(type==='server'){
// const {location} = params;
return <StaticRouter {...params}>
<Switch>
{routes}
</Switch>
</StaticRouter>
}
}
// 4、客户端入口
// src/index.js
import React from 'react';
import { hydrate } from 'react-dom';
import App from './app';
hydrate(
<App />,
document.getElementById("root")
);
// 5、客户端 app.js
// src/app.js
import React from 'react';
import { createRouter } from './router'
class App extends React.PureComponent{
render(){
return createRouter('client')();
}
};
export default App;
// 6、服务端入口
// server.js
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import { createRouter } from './src/router'
const app = express();
app.use(express.static("dist"))
app.get('*',function(req,res){
const content = renderToString(createRouter('server')({location:req.url}) );
res.send(`
<!doctype html>
<html>
<title>ssr</title>
<body>
<div id="root">${content}</div>
<script src="https://juejin.cn/client/index.js"></script>
</body>
</html>
`);
});
app.listen(3000);
复制代码
4、重定向问题
这里我们从/重定向到/user的时候, 可以看到html返回的内容和实现页面渲染的内容是不一样的。
这代表重定向操作是客户端来完成的, 而我们期望的是先访问index.html请求, 返回302, 然后出现一个新的user.html请求
// server.js
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import { createRouter } from './src/router'
const app = express();
app.use(express.static("dist"))
app.get('*',function(req,res){
const context = {};
const content = renderToString(createRouter('server')({location:req.url, context}) );
//当Redirect被使用时,context.url将包含重新向的地址
if(context.url){
//302
return res.redirect(context.url);
}else{
if(context.NOT_FOUND) res.status(404);//判断是否设置状态码为404
res.send(`
<!doctype html>
<html>
<title>ssr</title>
<body>
<div id="root">${content}</div>
<script src="https://juejin.cn/client/index.js"></script>
</body>
</html>
`);
}
});
app.listen(3000);
复制代码
3、升级版
1、服务端代码
相关库
- react-router-config
- renderRoutes
- matchRoutes
- express-http-proxy
- react-helmet:head管理工具
const routes = [
{
component: Root,
routes: [
{
path: "/",
exact: true,
component: Home
},
{
path: "/child/:id",
component: Child,
routes: [
{
path: "/child/:id/grand-child",
component: GrandChild
}
]
}
]
}
];
//1、matchRoutes(routes, pathname)
import { matchRoutes } from "react-router-config";
const branch = matchRoutes(routes, "/child/23");
[
routes[0],
routes[0].routes[1]
]
branch[0].match.url;
branch[0].match.isExact;
// 2、renderRoutes(routes, extraProps = {}, switchProps = {})
import { renderRoutes } from "react-router-config";
import routes from '../Routes';
const App = () => {
return (
<BrowserRouter>
<div>
{renderRoutes(routes)}
</div>
</BrowserRouter>
)
}
复制代码
2、代码分析
- web服务器放置静态资源
- api接口代理
- ssr路由处理:路由匹配、数据获取、ssr
import express from 'express';
const app = express();
var server = app.listen(9002);
// 1. 静态服务器:客户端打包代码index.js放置在public中
app.use(express.static('public'));
// 2. api接口代理
import proxy from 'express-http-proxy';
app.use('/api', proxy('http://127.0.0.1', {
proxyReqPathResolver: function (req) {
console.log('111api', req.path, req.url);
return '/api/' + req.url;
}
}));
// 3. 路由处理:路由匹配、数据获取、ssr
app.get('*', function (req, res) { /* */}
// 3.1 路由匹配:req.path在路由配置中匹配
import routes from '../Routes';
import { matchRoutes } from 'react-router-config'
const matchedRoutes = matchRoutes(routes, req.path);
// 3.2 数据获取
const store = getStore(req);
// 让matchRoutes里面所有的组件,对应的loadData方法执行一次
const promises = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch((e) => {
console.log(`1111${item.route.path}error`, e);
});
})
promises.push(promise);
}
})
// 3.3 数据获取结束后进行ssr渲染
// 使用Promise.all保证所有数据加载完成
Promise.all(promises).then(() => {
const context = { css: [] };
const html = render(store, routes, req, context);
if (context.action === 'REPLACE') {
res.redirect(301, context.url)
} else if (context.NOT_FOUND) {
res.status(404);
res.send(html);
} else {
res.send(html);
}
}).catch((e) => {
console.log('1111error', e);
})
复制代码
1、路由匹配
//react-router-config
//配置对象等价于:<Route>
export default [{
path: '/',
//接收参数component:no render or children
//key:避免重复渲染组件,性能优化
component: App,
loadData: App.loadData,
routes: [
{
path: '/',
component: Home,
exact: true,
loadData: Home.loadData,
key: 'home'
}, {
path: '/translation',
component: Translation,
loadData: Translation.loadData,
exact: true,
key: 'translation'
},
{
component: NotFound
}
]
}];
const matchedRoutes = matchRoutes(routes, req.path);
复制代码
2、数据获取
//2.1、getStore
const reducer = combineReducers({
home: homeReducer,
header: headerReducer,
translation: translationReducer
});
export const getStore = (req) => {
// 改变服务器端store的内容,那么就一定要使用serverAxios
return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios(req))));
}
//2.2、serverAxios读取文件内容
const createInstance = (req) => {
return {
get: (url) => {
const dataPath = path.join(process.cwd(), 'public', url);
const data = fs.readFileSync(`${dataPath}`, 'utf8');
return Promise.resolve({
data: JSON.parse(data)
});
}
}
};
//2.3、使用promise封装数据获取
matchedRoutes.forEach(item => {
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch((e) => {
console.log(`${item.route.path}error`, e);
});
})
promises.push(promise);
}
})
//Home组件代码
ExportHome.loadData = (store) => {
return store.dispatch(getHomeList())
}
//actions.js
export const getHomeList = () => {
return (dispatch, getState, axiosInstance) => {
return axiosInstance.get('/api/news.json')
.then((res) => {
const list = res.data.data;
dispatch(changeList(list))
});
}
}
const changeList = (list) => ({
type: CHANGE_LIST,
list
})
//reducer.js
export default (state = defaultState, action) => {
switch(action.type) {
case CHANGE_LIST:
return {
...state,
newsList: action.list
}
default:
return state;
}
}
复制代码
3、渲染
拼接字符串返回:body、数据、样式
- 使用StaticRouter匹配,使用renderToString渲染字符串
- 数据挂载在window.context下
- 样式放置在style标签中
const context = { css: [] };
const html = render(store, routes, req, context);
//util.js中render
export const render = (store, routes, req, context) => {
const insertCss = (...styles) => {
styles.forEach(style => context.css.push(style._getCss()))
};
//使用StaticRouter路由匹配
const content = renderToString((
<Provider store={store} >
<StaticRouter location={req.path} context={context}>
<Switch>
{renderRoutes(routes)}
</Switch>
</StaticRouter>
</Provider>
));
const helmet = Helmet.renderStatic();
const cssStr = context.css.length ? context.css.join('\n') : '';
return `
<html>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
<style>${cssStr}</style>
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
<script src='https://juejin.cn/index.js'></script>
</body>
</html>
`;
}
复制代码
3、客户端代码
- 获取数据,进行挂载
- 使用BrowserRouter路由
- 使用ReactDom.hydrate渲染,复用模版
const store = getClientStore();
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<div>{renderRoutes(routes)}</div>
</BrowserRouter>
</Provider>
)
}
ReactDom.hydrate(<App />, document.getElementById('root'))
复制代码
1、数据获取
export const getClientStore = () => {
// 使用window.context上的数据设置初始化
const defaultState = window.context.state;
// 改变客户端store的内容,一定要使用clientAxios
return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}
//request.js中clientAxios
const instance = axios.create({
baseURL: '/',
params: {
secret: config.secret
}
});
复制代码
2、组件交互
- 初始数据通过服务端获取直接加载
- 后续交互数据通过客户端发起请求获取
/* 代码结构
-Home
-store
-action.js
-constants.js
-index.js
-reducer.js
-index.js
-style.css
*/
class Home extends Component {
onClickItem(title) {
console.log('111home', title);
}
//该生命周期不会在服务端执行,服务端加载数据使用loadData
componentDidMount() {
//服务端有值,客户端直接使用不会重新获取数据
if (!this.props.list.length) {
this.props.getHomeList();
}
}
getList() {
const { list } = this.props;
return list.map(item => <div className={styles.item} key={item.id} onClick={() => { this.onClickItem(item.title) }} >{item.title}</div>)
}
render() {
return (
<Fragment>
<Helmet>
<title>首页新闻</title>
<meta name="description" content="新闻页面 - 丰富多彩的资讯" />
</Helmet>
<div className={styles.container}>
{this.getList()}
</div>
</Fragment>
)
}
}
const mapStateToProps = state => ({
list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
getHomeList() {
dispatch(getHomeList());
}
});
const ExportHome = connect(mapStateToProps, mapDispatchToProps)(withStyle(Home, styles));
ExportHome.loadData = (store) => {
return store.dispatch(getHomeList())
}
export default ExportHome;
复制代码
4、打包
1、相关包
- npm-run-all:并行运行
- nodemon:node服务器,支持热更新
2、打包命令
"scripts": {
"start": "node ./build/bundle.js", //运行服务器打包代码
"dev": "npm-run-all --parallel dev:**", //并行运行dev: xx
"dev:start": "nodemon --watch build --exec node "./build/bundle.js"",
//--watch选项,配置文件修改,则重新打包
"dev:build:server": "webpack --config webpack.server.js --watch",
"dev:build:client": "webpack --config webpack.client.js --watch"
},
复制代码
3、打包配置
- webpack.base.js
- webpack.client.js
- webpack.server.js
//1. webpack.base.js: babel-loader
//2. webpack.client.js
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
},
loader: style-loader\css-loader\url-loader
module.exports = merge(config, clientConfig);
//3. webpack.server.js
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'build')
},
target: 'node', //不必把node内置模块代码进行打包
//webpack-node-externals,这个库会扫描node_modules文件夹中的所有node_modules名称,并构建一个外部函数,告诉Webpack不要捆绑这些模块或其任何子模块
externals: [nodeExternals()],
loader: isomorphic-style-loader/url-loader
//只在对应的DOM元素上生成class类名,然后返回生成的CSS样式代码,将css文件可以转化为style标签插入到html的
复制代码