前言
客户端渲染(CSR)
- 客户端渲染,页面初始加载的HTML页面中无网页展示内容,需要加载执行JavaScript文件中的代码,通过JavaScript渲染生成页面,同时JavaScript代码会完成页面的交汇事件的绑定。
服务端渲染(SSR)
- 用户请求服务器,服务器上直接生成HTML内容并返回给浏览器。服务器端渲染来,页面的内容是由Server端生成的。一般来说,服务器渲染的页面交互能力有限,如果要实现复杂交互,还是要通过引入JavaScript文件来辅助实现。服务单渲染这个概念,适用于任何后端语言。
同构
同构这个概念存在于Vue,React这些新型的前端框架中,同构实际上是客户端渲染和服务端渲染的一个整合。我们把页面的展示内容和交互写在一起,让代码执行两次。在服务器端执行一次,用于实现服务端渲染,在客户端再执行一次,用于接管页面交互。
同构模型图
实现
客户端和服务端的同构
创建提供server打包的webpack配置
const path = require('path') const nodeExternals = require('webpack-node-externals') // webpack-node-externals 所有节点模块将不再经过webpack转化,而是保留为require module.exports = { target: 'node', mode: 'development', entry: './server/index.js', externals: [nodeExternals()], output: { filename: 'bundle.js', path: path.resolve(__dirname, 'build') }, module: { rules: [ { test: /.js$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: [ '@babel/preset-react', ['@babel/preset-env'] ] } } ] } } 复制代码
创建server/index.js目录,使用express启动服务。
import React from "react"; import { renderToString } from "react-dom/server"; import express from "express"; import App from "../src/App"; const app = express(); // 设置静态资源地址 app.use(express.static("public")); // 匹配/路由 app.get("/", (req, res) => { const Page = <App />; // 由于node不支持jsx,使用react-dom/server自带的renderToString进行转换 const content = renderToString(Page); // 返回一个html结构,并添加需要渲染的组件,并引入client中的JS进行数据操作。同构触发。 res.send(` <html> <head> <meta charset="utf-8" /> <title>react ssr</title> </head> <body> <div id="root">${content}</div> <script src="https://juejin.cn/bundle.js"></script> </body> </html> `); }); app.listen(9093, () => { console.log("监听完毕"); }); 复制代码
创建提供给client打包的webpack配置
const path = require('path') module.exports = { mode: 'development', entry: './client/index.js', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'public') }, module: { rules: [ { test: /.js$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: [ '@babel/preset-react', ['@babel/preset-env'] ] } } ] } } 复制代码
client/index.js中给SSR的组件进行注水操作
import React from "react"; import ReactDom from "react-dom"; import App from "../src/App"; // 进行注水 ReactDom.hydrate(<App />, document.getElementById("root")); 复制代码
这样初步的React SSR应用基本搭建完成。
问题:React开发大部分都是单页面开发,路由不可能只是一个,那么怎么匹配路由进行页面的渲染呢?
支持路由模式
改造server/index.js,使用react-router-dom提供的StaticRouter包裹入口组件。并将/改为*进行全路由匹配。
import React from "react"; import { renderToString } from "react-dom/server"; import express from "express"; import { StaticRouter } from "react-router-dom"; import App from "../src/App"; const app = express(); // 设置静态资源地址 app.use(express.static("public")); // 匹配所有路由 app.get("*", (req, res) => { // 由于node不支持jsx,使用react-dom/server自带的renderToString进行转换 const content = renderToString(<StaticRouter location={req.url}>{App}</StaticRouter>); // 返回一个html结构,并添加需要渲染的组件,并引入client中的JS进行数据操作。同构触发。 res.send(` <html> <head> <meta charset="utf-8" /> <title>react ssr</title> </head> <body> <div id="root">${content}</div> <script src="https://juejin.cn/bundle.js"></script> </body> </html> `); }); // 启动服务 app.listen(9093, () => { console.log("监听完毕"); }); 复制代码
src目录下新增文件夹container目录,新建Index.js 和 About.js两个组件。并改造src/App.js。
Index.js
import React, { useState } from "react"; function Index() { const [count, setCount] = useState(0); return ( <div> <h1>first react ssr {count}</h1> <button onClick={() => setCount(count + 1)}>累加</button> </div> ); } export default Index; 复制代码
About.js
import React from "react"; function About() { return <div>关于页面</div>; } export default About; 复制代码
App.js
import React from "react"; import { Route } from 'react-router-dom'; import Index from './container/Index'; import About from './container/About'; export default ( <div> <Route path='/' exact component={Index}></Route> <Route path='/about' exact component={About}></Route> </div> ) 复制代码
client/index.js进行改造,使用react-router-dom中BrowserRouter包裹入口组件。
import React from "react"; import ReactDom from "react-dom"; import { BrowserRouter } from "react-router-dom"; import App from "../src/App"; const Page = <BrowserRouter>{App}</BrowserRouter>; // 进行注水 ReactDom.hydrate(Page, document.getElementById("root")); 复制代码
这样就实现路由模式了。
redux支持
注册redux,并创建indexReducer 和 userReducer对应Index和User组件。
import { createStore, applyMiddleware, combineReducers } from 'redux' import thunk from 'redux-thunk' import indexReducer from './index' import userReducer from './user' const reducer = combineReducers({ index: indexReducer, user: userReducer }) const store = createStore(reducer, applyMiddleware(thunk)) export default store 复制代码
indexReducer中redux内容,发送/api/course/list获取数据。
const GET_LIST = 'INDEX/GET_LIST' const changeList = list => ({ type: GET_LIST, list }) export const getIndexList = server => { // $axios由于CSR和SSR都会发送请求,所以利用了redux中间件的第二个参数,具体内容看下面的axios代理实现 return (dispatch, getState, $axios) => { return $axios.get('/api/course/list').then(res => { const { list } = res.data dispatch(changeList(list)) }) } } const defaultState = { list: [] } export default (state = defaultState, action) => { switch (action.type) { case GET_LIST: const newState = { ...state, list: action.list } return newState default: return state } } 复制代码
改造Index组件,加入loadData方法供服务端调用。
import React, { useState, useEffect } from "react"; import { connect } from "react-redux"; import { getIndexList } from "../store/index"; import styles from "./Index.css"; function Index(props) { const [count, setCount] = useState(0); useEffect(() => { // 如果服务端没返回数据,客户端发送接口请求 if (!props.list.length) { props.getIndexList(); } }, []); return ( <div className={styles.container}> <h1 className={styles.title}>first react ssr {count}</h1> <button onClick={() => setCount(count + 1)}>累加</button> <hr /> <ul> {props.list.map((item) => { return <li key={item.id}>{item.name}</li>; })} </ul> </div> ); } Index.loadData = (store) => { return store.dispatch(getIndexList()); }; export default connect((state) => ({ list: state.index.list }), { getIndexList, })(Index); 复制代码
服务端改造,根据路由渲染出的组件,并且拿到loadData方法,获取数据。
import React from "react"; import { renderToString } from "react-dom/server"; import express from "express"; import { StaticRouter, matchPath, Route, Switch } from "react-router-dom"; import routes from "../src/App"; import { Provider } from "react-redux"; import { getServerStore } from "../src/store/store"; const app = express(); // 设置静态资源地址 app.use(express.static("public")); const store = getServerStore(); // 匹配所有路由 app.get("*", (req, res) => { // 根据路由渲染出的组件,并且拿到loadData方法,获取数据 // 存储网络请求 const promises = []; routes.map((route) => { const match = matchPath(req.path, route); if (match) { const { loadData } = route.component; if (loadData) { const promise = new Promise((resolve, reject) => { loadData(store).then(resolve).catch(resolve); }); promises.push(promise); // promises.push(loadData(store)); } } }); // allSettled Promise.all(promises) .then(() => { const context = { css: [] }; // 由于node不支持jsx,使用react-dom/server自带的renderToString进行转换 const content = renderToString( <Provider store={store}> <StaticRouter location={req.url} context={context}> <Header></Header> <Switch> {routes.map((route) => ( <Route {...route}></Route> ))} </Switch> </StaticRouter> </Provider> ); if (context.statusCode) { res.status(context.statusCode) } if (context.action === 'REPLACE') { res.redirect(301, context.url) } const css = context.css.join('\n') // 返回一个html结构,并添加需要渲染的组件,并引入client中的JS进行数据操作。同构触发。 res.send(` <html> <head> <meta charset="utf-8" /> <title>react ssr</title> <style> ${css} </style> </head> <body> <div id="root">${content}</div> <script> window.__context = ${JSON.stringify(store.getState())} </script> <script src="https://juejin.cn/bundle.js"></script> </body> </html> `); }) .catch(() => { res.send("报错了500"); }); }); // 启动服务 app.listen(9093, () => { console.log("监听完毕"); }); 复制代码
这样基本实现了SSR中Redux的数据渲染,但是前面提到的axios发送数据请求,CSR和SSR接口是不一样,所以我们接下来改造axios。
axios代理实现
redux-thunk支持接受参数并在使用的时候放入到第三个参数。
由于axios在CSR和SSR发送请求的地址不一样,可以使用redux-thunk第三个参数进行修改。拆分store入口函数,氛围Client调用和Server调用的函数
import axios from 'axios' import { createStore, applyMiddleware, combineReducers } from 'redux' import thunk from 'redux-thunk' import indexReducer from './index' import userReducer from './user' const reducer = combineReducers({ index: indexReducer, user: userReducer }) const serverAxios = axios.create({ baseURL: 'http://localhost:9090' }) const clientAxios = axios.create({ baseURL: '/' }) // const store = createStore(reducer, applyMiddleware(thunk)) // export default store // 服务端使用 export const getServerStore = () => { return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios))) } // 客户端使用 export const getClientStore = () => { const defaultStore = window.__context ? window.__context : {} return createStore(reducer, defaultStore, applyMiddleware(thunk.withExtraArgument(clientAxios))) } 复制代码
使用的时候修改indexReducer修改。
const GET_LIST = 'INDEX/GET_LIST' const changeList = list => ({ type: GET_LIST, list }) export const getIndexList = server => { // 不使用全局的axios,使用第三个参数$axios return (dispatch, getState, $axios) => { return $axios.get('/api/course/list').then(res => { const { list } = res.data dispatch(changeList(list)) }) } } const defaultState = { list: [] } export default (state = defaultState, action) => { switch (action.type) { case GET_LIST: const newState = { ...state, list: action.list } return newState default: return state } } 复制代码
Client改造,调用store中的getClientStore实例化store。
import React from "react"; import ReactDom from "react-dom"; import { BrowserRouter, Route, Switch } from "react-router-dom"; import routes from "../src/App"; import { Provider } from "react-redux"; import { getClientStore } from "../src/store/store"; import Header from "../src/component/Header"; const Page = ( <Provider store={getClientStore()}> <BrowserRouter> <Header></Header> <Switch> {routes.map((route) => ( <Route {...route}></Route> ))} </Switch> </BrowserRouter> </Provider> ); if (window.__context) { // 进行注水 ReactDom.hydrate(Page, document.getElementById("root")); } else { // 进行render ReactDom.render(Page, document.getElementById("root")); } 复制代码
Server改造,调用store中的getServerStore实例化store。
import { getServerStore } from "../src/store/store"; const store = getServerStore(); const content = renderToString( <Provider store={store}> <StaticRouter location={req.url} context={context}> <Header></Header> <Switch> {routes.map((route) => ( <Route {...route}></Route> ))} </Switch> </StaticRouter> </Provider> ); 复制代码
这样的axios就可以在CSR和SSR调用不同地址的接口了。
css支持
CSS支持需要开启css module进行开发。webpack配置css-loader中的options对象的modules: true开启css module。
rules: [ { test: /.css$/, use: [ "style-loader", { loader: "css-loader", options: { modules: true, }, }, ], }, ], 复制代码
React中的props有staticContext字段,可用与SSR的数据传递。
使用css modules的_getCss拿到css对象并进行序列化。并放入到SSR中进行数据的渲染并放入到模版中。
About.js
import React from "react"; import styles from './About.css' function About(props) { if (props.staticContext) { props.staticContext.css.push(styles._getCss()) } return <div className={styles.title}>关于页面</div>; } export default About; 复制代码
Server/index.js
import React from "react"; import { renderToString } from "react-dom/server"; import express from "express"; import proxy from "http-proxy-middleware"; import { StaticRouter, matchPath, Route, Switch } from "react-router-dom"; import routes from "../src/App"; import { Provider } from "react-redux"; import { getServerStore } from "../src/store/store"; import Header from "../src/component/Header"; import path from 'path' import fs from 'fs' const app = express(); // 设置静态资源地址 app.use(express.static("public")); const store = getServerStore(); app.use( "/api", proxy({ target: "http://localhost:9090", changOrigin: true, }) ); function csrRender(res) { // 读取文件返回 const filename = path.resolve(process.cwd(), 'public/src/index.csr.html') const html = fs.readFileSync(filename, 'utf-8') res.send(html) } // 匹配所有路由 app.get("*", (req, res) => { if (req.query._mode == 'csr') { console.log('url参数开启csr降级'); return csrRender(res) } // 转发 // if (req.url.startsWith('/api/')) { // // 不渲染页面,使用axios进行转发 // } // 根据路由渲染出的组件,并且拿到loadData方法,获取数据 // 存储网络请求 const promises = []; routes.map((route) => { const match = matchPath(req.path, route); if (match) { const { loadData } = route.component; if (loadData) { const promise = new Promise((resolve, reject) => { loadData(store).then(resolve).catch(resolve); }); promises.push(promise); // promises.push(loadData(store)); } } }); // allSettled Promise.all(promises) .then(() => { const context = { css: [] }; // 由于node不支持jsx,使用react-dom/server自带的renderToString进行转换 const content = renderToString( <Provider store={store}> <StaticRouter location={req.url} context={context}> <Header></Header> <Switch> {routes.map((route) => ( <Route {...route}></Route> ))} </Switch> </StaticRouter> </Provider> ); if (context.statusCode) { res.status(context.statusCode) } if (context.action === 'REPLACE') { res.redirect(301, context.url) } const css = context.css.join('\n') // 返回一个html结构,并添加需要渲染的组件,并引入client中的JS进行数据操作。同构触发。 res.send(` <html> <head> <meta charset="utf-8" /> <title>react ssr</title> <style> ${css} </style> </head> <body> <div id="root">${content}</div> <script> window.__context = ${JSON.stringify(store.getState())} </script> <script src="https://juejin.cn/bundle.js"></script> </body> </html> `); }) .catch(() => { res.send("报错了500"); }); }); 复制代码
这样的话在SSR渲染的时候就支持CSS了。
性能优化
错误页面状态码支持
设计Notfound组件,如果找不到进入Notfound组件。
路由改造
export default [ { path: '/', component: Index, // loadData: Index.loadData, exact: true, key: 'index' }, { path: '/userinfo', component: User, exact: true, key: 'userinfo' }, { path: '/about', component: About, exact: true, key: 'about' }, { component: Notfound, // 找不见对应的路由进入 key: 'notfound' } ] 复制代码
Notfound.js
import React from 'react' import { Route } from 'react-router-dom' function Notfound(props) { return <h1>error Notfound</h1> } export default Notfound 复制代码
Notfound进行改造,指定错误页面的状态码。
import React from 'react' import { Route } from 'react-router-dom' function Status({ code, children }) { return <Route render={({ staticContext }) => { if (staticContext) { staticContext.statusCode = code } return children }}></Route> } function Notfound(props) { return <Status code={404}> <h1>error Notfound</h1> </Status> } export default Notfound 复制代码
使用staticContext进行SSR通信,并返回状态。
if (context.statusCode) { res.status(context.statusCode) } 复制代码
放弃SEO的降级渲染实现优化,创建CSR的模版文件,如果是服务端压力过大等一些问题,不经过SSR之后返货CSR模版。
创建index.csr.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>react csr</title> </head> <body> <div id="root"></div> </body> </html> 复制代码
webpack.client.js使用html-webpack-plugin进行改造。
const HtmlWebpackPlugin = require("html-webpack-plugin"); ... // 添加plugins plugins: [ new HtmlWebpackPlugin({ filename: "src/index.csr.html", template: "src/index.csr.html", inject: true, }), ], 复制代码
Server改造。完成一个切换CSR和SSR工程。
// 创建渲染CSR模版函数 function csrRender(res) { // 读取文件返回 const filename = path.resolve(process.cwd(), 'public/src/index.csr.html') const html = fs.readFileSync(filename, 'utf-8') res.send(html) } app.get("*", (req, res) => { // 这个_mode的判断可切换任何,比较服务端压力大开启,前端手动开启等 if (req.query._mode == 'csr') { console.log('url参数开启csr降级'); return csrRender(res) } } 复制代码
高阶组件优化css
由于css不能在每一个组件都加入判断去判断SSR还是CSR,使用高阶组件进行封装。
import React from "react"; import styles from './About.css' function About(props) { // 每个组件进行判断过于繁琐 if (props.staticContext) { props.staticContext.css.push(styles._getCss()) } return <div className={styles.title}>关于页面</div>; } export default About; 复制代码
withStyle
import React from "react"; // 将非反应特定的静态数据从子组件复制到父组件。类似于Object.assign,但将 React 静态关键字列入黑名单,以免被覆盖。 import hoistNonReactStatic from 'hoist-non-react-statics'; function whitStyle(Comp, styles) { function NewComp (props) { if (props.staticContext) { props.staticContext.css.push(styles._getCss()); } return <Comp {...props} />; }; hoistNonReactStatic(NewComp, Comp) return NewComp } export default whitStyle; 复制代码
改造使用组件
import React from "react"; import styles from './About.css' import withStyle from "../withStyle"; function About(props) { return <div className={styles.title}>关于页面</div>; } export default withStyle(About, styles); 复制代码
完整代码地址:github.com/lowKeyXiaoD…
结语
服务端渲染在React和Vue中都有完整的框架实现,比如Vue的nuxt和React的next都是比较成熟的服务端渲染的框架,现在的实现只是为了大家更好的认识的什么SSR,什么是同构,更好的掌握SSR框架。
其实SSR的实现还有其他的思路,如果项目工程过大,SSR后期加入,只是为了方便SEO查找可以使用puppeteer进行SSR改造。监听请求地址,抓取对应的html进行渲染。这样就会造成一种SSR的假象也是可以进行SEO的。
const express = require("express"); const puppeteer = require("puppeteer"); const app = express(); app.get("*", async (req, res) => { if (req.url === "/favicon.ico") { return res.send({ code: 0, }); } const url = 'http://localhost:9093' + req.url const brower = await puppeteer.launch() const page = await brower.newPage() await page.goto(url, { waitUntil: ['networkidle0'] }) const html = await page.content() res.send(html); }); app.listen(8081, () => { console.log("ssr server"); }); 复制代码