7 React项目架构 - Router和Store的服务端渲染
Thu, Oct 11, 2018
0x0 概述
加入了router和store,会对服务端渲染有影响:
- 需要控制router的渲染,服务端渲染的内容要根据不同的url进行
- 需要在服务端渲染中集成路由跳转功能
- 使用者可以从任意路由进入网站
- 除了前端做路由跳转外,服务端也需要支持路由跳转,返回给客户端正确的页面
- 需要在服务端渲染中集成路由跳转功能
- store数据同步:
- 服务端渲染的时候会渲染一遍,拿到html,内容扔给客户端
- 客户端js又会渲染一次
- 如果没拿到服务端渲染时,通过API请求到的数据,那么就需要客户端再次通过api请求,去拿数据
- 多次api请求导致浪费
- 需要服务端渲染时请求到的数据,让客户端直接用
0x1 store和router的服务端渲染基础配置
实现
server-entry.js
修改前
import React from 'react' import App from './views/App' export default <App /> |
修改后
import React from 'react' import { StaticRouter } from 'react-router-dom' // #1 import { Provider, useStaticRendering } from 'mobx-react' // #2 import App from './views/App' import { createStoreMap } from './store/store' // 让mobx在服务端渲染的时候不会重复数据变换 useStaticRendering(true) // #3 // 传入对象格式:{appStore: xxx} export default (store, routerContext, url) => ( // #5 <Provider {...store}> {/* #4 */} <StaticRouter context={routerContext} location={url}> {/* #6 */} <App/> </StaticRouter> </Provider> ) export { createStoreMap } // #7 |
说明:
- StaticRouter是专门做服务端渲染提供的组件
- Mobx提供的服务端渲染工具
- mobx会导致每一次数据变化,引起其他方法的调用
- 如computed相关方法
- 服务端渲染的时候,如果使用客户端的代码做,会有bug
- 一次渲染会导致computed执行很多次
- 而且改的变量多的时候,会造成重复引用、重复调用
- 进而导致内存溢出
- 这个方法就是解决这个问题的
- Provider - 需要接受Store的内容
- store需要在服务端渲染的时候生成
- 服务端渲染的时候会有很多请求进来(如列表页的请求、详情页的请求),不可能同一个store在不同的请求里使用
- 因为一个store,在第一次请求的时候可能已经初始化过一些数据了
- 第二次又使用这个store,又初始化另一部分数据,会造成数据的改来改去。所以每次都要重新创建一个store
- 更而且,如果所有请求用同一个store,并发的时候怎么修改呢?
- {...store}:
- 使用解构的方式,指定store们
- 相当于:appState=a对象 bppState=b对象
- 需要让store、routerContext从外界传入进来
传入对象格式:{appStore: xxx}
- context和url
- context - 会在静态渲染的时候,对这个对象进行一些操作,返回一些信息
- 如:需要做redirect的时候,会在context中加一个url,告诉我们需要redirect到哪个地方,我们就在服务端302到那个地方
- location - 就是当前页的url
- context - 会在静态渲染的时候,对这个对象进行一些操作,返回一些信息
为啥import进来又export出去
- 因为server.js那边,只能拿到编译后的server-entry.js包
- 想要调用client下的某个方法,这个方法必须得通过server-entry拿才行
- 所以,这里需要「透传」出去
store.js
import AppStateClass from './app-state' export const AppState = AppStateClass export default { AppState, } export const createStoreMap = () => { return { appState: new AppState() } } |
说明:
- 这个文件中的方法,用于创建「所有」store PS.现在只有appState这一个store,以后会多
修改开发阶段,服务端渲染的相关逻辑
- 之前是直接拿到server-entry.js导出来的<App />做渲染的(见server-entry.js修改前)
const m = new Module();
m._compile(serverBundleString, 'server-entry.js');
serverBundle = m.exports.default;...
const appString = ReactDomServer.renderToString(serverBundle);
- 现在server-entry导出的已经改为一个方法了,需要传参,然后生成一个<App />
dev-static.js
/* 旧方法 module.exports = function (app) { ... app.get('*', function (req, res) { getTemplate().then((index) => { const appString = ReactDomServer.renderToString(serverBundle); console.log('appString:' + appString); res.send(index.replace('<!--app-->', appString)); }) }) }; */ let serverBundle, createStoreMap serverCompiler.watch({}, (err, stats) => { ... const m = new Module() m._compile(serverBundleString, 'server-entry.js') serverBundle = m.exports.default createStoreMap = m.exports.createStoreMap // #1 }); const ReactDomServer = require('react-dom/server') module.exports = function(app) { ... app.get('*', function(req, res) { getTemplate().then((index) => { const routerContext = {} const app = serverBundle(createStoreMap(), routerContext, req.url) const appString = ReactDomServer.renderToString(app) console.log('appString:' + appString) res.send(index.replace('<!--app-->', appString)) }) }) };说明: |
- 注意,想要导到createMapStore方法,需要从编译好的模块里导
- 千万不要直接require('../../client/server-entry.js')这样
- 楼上的,文件里用的是es6的语法,nodejs的server里是识别不了的
- 只能识别编译后的文件的呀!
- 由于导出来的已经是一个方法了,需要传参进去调用,才能生成<App />
- createStoreMap是server-entry.js透传的,创建store的方法
- routerContext,传个空对象就好
结果
执行:
- dev:client
- dev:web:start
0x2 重定向的服务端渲染
问题:如上配置,当我们访问 view-source:localhost:2333/ 查看源码的时候,不会把重定向之后的内容展示出来。
方案:判断「上下文」有木有url - 说明是否重定向,有的话,丢给浏览器302,以便进行重定向
dev-static.js
... module.exports = function(app) { ... app.get('*', function(req, res) { getTemplate().then((index) => { const routerContext = {} const app = serverBundle(createStoreMap(), routerContext, req.url) const appString = ReactDomServer.renderToString(app) if (routerContext.url) { res.status(302).setHeader('Location', routerContext.url) res.end() return } ... }) }) }; |
说明:
- routerContext.url需要在ReactDomServer.renderToString之后,才可能有值
- 我们直接做302跳转,通过设置Location,让浏览器端也做跳转
0x3 服务端异步渲染
概述
场景:实际业务开发中,有许多需要「异步」处理的场景,如「请求服务端数据」等。我们希望,异步数据,可以在异步执行完毕之后,再进行服务端渲染 & 客户端内容生成。
核心工具:
- react-async-bootstrapper
- 安装:`npm i react-async-bootstrapper -S`
- ejs
案例:实现count的延时更改
服务端渲染 & 执行异步任务
client/views/topic-list/index.jsx
class TopicList extends React.Component { ... bootstrap() { return new Promise((resolve) => { setTimeout(() => { this.props.appState.count = 3 resolve(true) }) }) } ... } |
说明:
- 异步操作需要放在bootstrap方法下执行,return一个Promise
- 该方法类似于react-async-bootstrapper声明的一个协议方法,服务端渲染的地方,会等待这个方法的Promise返回true后,再执行渲染逻辑。
- 在dev-static里面,调用asyncBootstrap方法的时候,会去执行组件中的这个方法
- 组件中执行完毕这个方法后,才会继续渲染相关工作
- 这个方法里可以很好的执行异步任务
server/util/dev-static.js
开发环境下,会使用如下逻辑
module.exports = function(app) { ... app.get('*', function(req, res) { getTemplate().then((template) => { ... const asyncBootstrapper = require('react-async-bootstrapper') asyncBootstrapper(app).then(() => { const appString = ReactDomServer.renderToString(app) ... res.send(index.replace('<!--app-->', appString)) }) }) }) }; |
说明:
- get到Template后,执行组件的bootstrap方法,执行完毕,拼接字符串返回响应
结果:查看源码,可以发现,服务端渲染已经把count渲染成3了
问题:页面展示内容中,count仍然为0
解决:使用ejs模板,将服务端渲染后的正确state传给前端展示
将服务端渲染后的state传给前端(ejs)
安装工具:
- 安装:`npm i ejs ejs-compiled-loader -S`
client/server.template.ejs
未来服务端渲染的内容,都以这个文件作为模板渲染。
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content='"width=device-width, user-scalable=no, initial-scale=1.0'> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>由我来组成扁桃体٩(●˙▿˙●)۶…⋆ฺ</title> </head> <body> <div id="root"><%%- appString %></div> <!--#1--> <script> window.__INITIAL__STATE__ = <%%- initialState %> <!--#2--> </script> </body> </html> |
说明
会把appString作为一个变量,在webpack编译的时候,把这个变量放到当前位置
- <%%这种写法:在webpack的HTMLPlagin在编译模板的时候,是可以识别这样的ejs的语法的
- 会自动通过ejs的一个loader,把编译出来的结果中,多出来的%去掉,输出一个完整的模板文件,服务端渲染的时候使用
- initialState变量,用于把服务端渲染后的state传进来
- __INITIAL__STATE__是个添加给window的自定义属性,前端展示时,可以通过这个属性,拿到服务端渲染好的State
- 命名方式是为了防止客户端定义的名称和这里的模板属性名称发生命名冲突,所以定义的比较怪异
- __INITIAL__STATE__是个添加给window的自定义属性,前端展示时,可以通过这个属性,拿到服务端渲染好的State
指定ejs模板文件的loader
build/webpack.config.client.js
const config = webpackMerge(baseConfig,{ ... plugins: [ ... new HTMLPlugin({ template: '!!ejs-compiled-loader!' + path.join(__dirname, '../client/server.template.ejs'), // #1 filename: 'server.ejs' // #2 }) ], ... }); |
说明:
- 指定模板loader的方法
- 指定编译后的文件名,方便其他地方获取这个文件
修改server端渲染template
const getTemplate = () => { return new Promise((resolve, reject) => { axios.get('http://localhost:8787/public/server.ejs') // #1 .then((res) => { resolve(res.data) }) .catch(reject) }) }; ... let serverBundle, createStoreMap serverCompiler.watch({}, (err, stats) => { ... const m = new Module() m._compile(serverBundleString, 'server-entry.js') serverBundle = m.exports.default createStoreMap = m.exports.createStoreMap }); const getStoreState = (stores) => { return Object.keys(stores).reduce((result, storeName) => { result[storeName] = stores[storeName].toJson() return result }, {}) } // #2 const ReactDomServer = require('react-dom/server') module.exports = function(app) { ... app.get('*', function(req, res) { getTemplate().then((template) => { const routerContext = {} const stores = createStoreMap() const app = serverBundle(stores, routerContext, req.url) const asyncBootstrapper = require('react-async-bootstrapper') asyncBootstrapper(app).then(() => { const appString = ReactDomServer.renderToString(app) ... const state = getStoreState(stores) // #3 const ejs = require('ejs') const serialize = require('serialize-javascript') // #4 const html = ejs.render(template, { // #5-1 appString: appString, initialState: serialize(state), // #5-2 }) console.log('appString:' + appString) console.log(stores.appState.count) res.send(html) // #6 }) }) }) }; |
说明:
- 改用编译好的server.ejs模板文件
- 创建将stores转成state的方法
- 获得新的state
- 用于把js对象转为string的库
- 安装`npm i serialize-javascript -S`
- 把ejs模板和要插入其中的变量结合,输出最终的html文件
- 指定ejs模板
- initialState需要是一个字符串才行
client/store/app-state.js
class AppState { ... toJson() { // #1 return { count: this.count, name: this.name, } } } export { AppState as default } |
说明:
- toJson方法:让AppState这个实例,可以以Json的格式获取到
将State展示到前端
client/store/app-state.js
class AppState { constructor({ count, name } = { count: 0, name: 'Azen' }) { // #1 this.count = count this.name = name } @observable count @observable name @computed get msg() { return `${this.name} say count is ${this.count}` } @action add() { this.count += 1 } @action changeName(name) { this.name = name } toJson() { return { count: this.count, name: this.name, } } } export { AppState as default } |
说明:
- 定义state的构造方法,允许通过传入对象构造State
app.js
const initialState = window.__INITIAL__STATE__ || {} // eslint-disable-line // #1 const root = document.getElementById('root') const render = (Component) => { ReactDOM.hydrate( <AppContainer> <Provider appState={new AppState(initialState.appState)}> // #2 <BrowserRouter> <Component /> </BrowserRouter> </Provider> </AppContainer>, root, ) }; render(App) ... |
说明:
- 通过window.__INITIAL__STATE__拿到服务端传来的渲染后的state
- 防止eslint因为方法名而报错
- 使用渲染后的state,生成页面展示需要的state
SEO标签添加
待完善
目前只做到了客户端生效,服务端未能生效...
相关工具:helmet
开发环境代码优化 & 逻辑抽取
生产环境服务端渲染逻辑调整