react-server-side-render 最新学习与实践

本贴最后更新于 2543 天前,其中的信息可能已经渤澥桑田

写在前面

server side render(ssr)服务端渲染 ,亦即同构应用。主要有利于 seo 和首屏渲染,这是一篇比较新的可运行的结构设计,基于比较新的 react v16、react-router v5 的同构设计。结合了 redux(Flux 数据流实现)。

项目地址:react-ssr-starter

不务正业(搞前端)差不多已经一年了,学习 react 也是一年前的事情了,但是一直以来对于 react 服务端渲染,兴趣缺缺,毕竟 n 久以前就是服务端渲染(通过模板引擎),随着 ng、react、vue 等框架的兴起,前端渲染越来越火热,没想到发展发展,又绕回了服务端渲染,而且居然还多加了一个 node 中间层,把本来简单的结构一拆再拆,很多时候都是徒然增加复杂度。其实说起来可笑,我觉得服务端渲染在国内的火热百度绝对占有相当大一部分因素(搜索引擎技术万年不更新,广告倒是多了一大堆,233)。

好了,吐槽一段也该进入正题了。我等小菜没法改变现状,也只能适应现状了。在阅读了一大堆乱七八糟的 ssr 相关博文之后,终于找到 Server Side Rendering in React/Redux (JS)React16+Redux+Router4+Koa+Webpack 服务器端渲染(按需加载,热更新)让我暂时明白了其中一部分原理并开始进行自己的构建。

ssr 构想

传统的 react 为什么不利于 seo?其实说白了,就是因为在路由请求到页面时,页面是一个没有任何数据的 html,它的数据必须要运行渲染 dom/更改 <head/> 的 js 代码,而搜索引擎并不会执行这段代码,所以搜索引擎拿不到任何东西(google 已经提出了解决方案,百度仍然沉浸在十年前#雾)。

而 ssr 的结构无外乎就是把这一段需要运行的 js 放到 node 服务端去运行,然后直接向浏览器端输出 html。这样传统引擎就能够去爬取内容了。

为了实现 ssr。我觉得需要考虑的问题应该主要集中在这几点上:

  • 需要写那些方面的代码?

答:需要写两个方面的代码,一个是以前传统的客户端代码,一个是 node 服务端代码。如果你已经写完了一个客户端渲染的代码,那么指需要小小修改一些东西(如果代码组织结构较好,甚至只需要改动一个方法名,也就是说后面提到的 ReactDOM.render 改成 ReactDOM.hydrate),然后加入服务端代码,基本上就能够完成服务端渲染啦。迁移也是非常方便的。

  • node 中间层干什么?怎么干?

答:node 中间层主要任务是 获取用户请求,根据路由(更多是为了应对首屏渲染)准备初始数据(例如:去 api 端请求数据),把初始数据填充到组件中,把整个填充好的组件输出到响应体中。这方面比较好的实现是结合 react-router 匹配路由,结合 redux 的 store 填充数据。刚好与这两个库的思想吻合。请尽量注意,其实在我以前的想法里,服务端渲染应该是每个单页/单页的部分组成组件每次页面跳转都由服务端呈现的。但是在查看了这些博文,以及研究了相关代码之后,才了解,服务端渲染仅仅只针对首屏渲染,首屏渲染完成后,后续的页面跳转,api 请求等还是由前端自己管理,也就是说,其实 node 中间层只管刷新,当然这是因为我们用 react-router 以及 redux 结合所希望达成的最好的效果,实际上依靠 react 官方 api,我们是可以完全实现所有页面都由服务端渲染完成,由客户端去请求的,但是那样其实体验并不好(这也编程了纯服务端渲染,也就是说模板引擎干的事,显然,这不是我们想要的)。服务端渲染主要解决的应该是首屏白屏和 seo 的问题。

  • 浏览器端干什么?怎么干?

答:浏览器端主要是显示服务端渲染过来的 Html(当然,这不用我们管,浏览器干的事)。我们主要是要根据服务端提供的初始 state 和渲染的根节点,把每一层渲染的实际 dom 用 react 组件对应上(因为现在 html 变成了已经渲染成功的样子,但是客户端还什么都没做,客户端的 react 表示一脸懵逼,还不知道自己干了什么)。

而在 react 16 以前 react-dom 只提供了 render 方法,去对应根结点,这个方法会删除掉原来根节点本来已经由服务端渲染成功的子 dom,然后根据初始状态重新渲染,也就出现了渲染两次的问题(服务端渲染一次,把 dom 结构加载到根节点中,客户端拿到 html 页面,再根据初始状态再渲染一次 dom 结构),当然,这其实某种程度已经满足了我们的需求,搜索引擎爬到了初始页面,不执行 js,所有 dom 结构还在,能够获取需要的信息,而用户看到页面中,因为 react-dom 的 render 方法执行效率也还是很可观的,所以也没有什么问题(一般来说其实会有一点闪屏,因为 dom 擦除和重建)。

但是对于复杂的网页,或者追求用户体验的我们来说,这是真的不能忍,react 16 以前,大佬们使用各种方式来避免第二次渲染,但是在 react 16 之后,react 官方提供了一个新的方法来搞定这个问题啦,那就是 ReactDom.hydrate 方法。这个方法和 render 使用是一样的,但是它不会擦除和重建 dom,仅仅只是把 dom 结构和我们的虚拟 dom 结构对应上,简直是大大的方便啊。所以客户端会该 ReactDom.hydrate 方法代替 ReactDom.render 方法,其他写法与以前的客户端渲染一样哦。

  • 开发环境下怎么搞?

答:开发环境下,大致分为两种思路(其实也差不多)

第一种,使用 Webpack 的 devServer 做为开发服务器,当然这就无法完全重现服务端渲染的真实情况,但是问题不大,因为本来差别也不大,只有一个首屏问题。
第二种,完全模拟服务端渲染,使用 koa/express 自行封装,使用 babel 的 register 方法添加 node 对于 import 的支持(这种,去掉开发相关配置,其实完全可以用来直接做服务器)。

  • 生成环境下怎么搞?

答:生产环境其实主要也就是两个方面,一个是客户端代码的编译。另一个是服务端代码,这个可以选择两种,分别是使用 webpack 进行编译使其支持 import/es6/es7/jsx 等代码以及使用 babel.register 使其支持 import/es6/es7/jsx。各有优劣,我的选择是前者,也没什么特别的原因,任性!

  • 架构的基本思想

答:其实基本思想就是,因为服务端需要渲染一部分组件(用于初始化),也就是说服务端需要包含一部分 react 组件,而客户端也(当然)需要包含所有的组件。那么这一部分组件要想办法重用,这方面其实没那么复杂,说白了就是服务端能够在目录中抽取(import/require)到所需的组件,没什么特别的,主要是为了服务端代码,最大程度实现重用。另一方面是路由的匹配(服务端需要相关路由匹配以渲染对应组件),其实 koa/express 等有路由匹配相关的方法,但是同样是为了最大程度重用,我们要想办法能够统一匹配路由,这方面我推荐采用 react-router 搭配 react-router-config 食用。

服务端没有 history,所以需要模拟一个 hisotry,正好,react-router 提供了 staticRouter 静态路由可以模拟,为了更好的食用,我使用 history.js 的 memeryHistory,这样,服务端和客户端又能够重用路由信息啦。

又想一想,还有什么能重用?对啦,是数据处理,包括数据请求,因为服务端需要初始化一部分的数据啊。我们结合 redux,也就成了相关的 action 和 reducer,这一部分也能够重用。在服务端和客户端都需要创建 store,所以把创建 store 的代码提供出来给大家食用,就又重用了一部分代码啦。

经过了以上代码的重用,然后发现,服务端除了监听 request 和渲染 html,其他什么都不用做,因为我们在写客户端代码的时候,就无形中搞定了服务端渲染。所以基本上,来说,脚手架一旦搭建完成,用户还是像以前那样开开心心的写客户端代码,而不用管服务端代码。想想还有点小激动呢 ~

核心代码

通过以上问题的抛出,其实我们心中已经能够有大体的思路,只是在实现上,我们就不得不各种找 api,各种想办法去对应上这些问题了,这是一个枯燥无聊的过程,如果你实在没有继续看下去的欲望,可以直接食用我的脚手架 react-ssr-starter,开箱即用哦。

代码分离方案

食用 react-loadable 组件,也是一个开箱即用的库,结合 webpack 的 import()方法,分分钟实现代码分离。示例

import React from 'react' import Loadable from 'react-loadable' import { homeInit } from './actions' const Loading = () => { return <div>Loading...</div> } const routesConfig = [{ path: '/', component: Loadable({ loader: () => import(/* webpackChunkName: 'AppLayout'*/'./pages/AppLayout'), loading: Loading, }), routes: [{ path: '/', exact:true, component: Loadable({ loader: () => import(/* webpackChunkName: 'Home' */'./pages/Home'), loading: Loading, }), }, { path: '/user', component: Loadable({ loader: () => import(/* webpackChunkName: 'User'*/'./pages/User'), loading: Loading, }) }] }] export default routesConfig

路由重用

我们使用 react-router+react-router-config 的方案实现路由重用,首先时需要导入一个 routesConfig。其实就是上面的代码。接下来我们需要能够在前后端都能加载这个 routesConfig。那么就要分别由前后端代码去读取,客户端需要解析出真正的 Route 节点,而服务端只需要匹配 url 即可

服务端代码:

import { matchRoutes } from 'react-router-config' import Routes from './Routes' let branch = matchRoutes(Routes, ctx.req.url) let promises = branch.map(({ route }) => { return route.init ? (route.init(store)) : Promise.resolve(null) }).map(promise => { if (promise) { return new Promise((resolve) => { promise.then(resolve).catch(resolve) }) } }) await Promise.all(promises).catch(err => console.error(err))

客户端代码:

import { hydrate, render, unmountComponentAtNode } from 'react-dom' import { ConnectedRouter } from 'react-router-redux' import { renderRoutes } from 'react-router-config' const renderApp = (routes) => { const renderMethod = process.env.NODE_ENV === 'development' ? render : hydrate renderMethod( <Provider store={store}> <ConnectedRouter history={history}> {renderRoutes(routes)} </ConnectedRouter> </Provider>, Root) } renderApp(Routes)

store 的重用设计

store 的重用设计非常简单,说白了就是获取初始状态,有就加进去,没有就直接初始化一个 store(服务端没有,客户端需要读取服务端的初始状态,所有有)。

整个 store 初始化方法

import { createStore, applyMiddleware, compose } from 'redux' import thunkMiddleware from 'redux-thunk' import createHistory from 'history/createMemoryHistory' import { routerMiddleware } from 'react-router-redux' import rootReducer from '../reducers' const routerReducers = routerMiddleware(createHistory()) const composeEnhancers = process.env.NODE_ENV == 'development' ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose const middleware = [thunkMiddleware, routerReducers] let configureStore = (initialState) => createStore(rootReducer, initialState, composeEnhancers(applyMiddleware(...middleware))) export default configureStore

客户端使用(注意我们默认 node 服务端渲染的初始状态挂载到 window.__INITIAL_STATE__上面):

const initialState = window && window.\_\_INITIAL_STATE\_\_ import { Provider } from 'react-redux' import configuraStore from './store/configureStore' let store = configuraStore(initialState) //... <Provider store={store}> //...

服务端使用:

import configureStore from './store/configureStore' let store = configureStore()

服务端渲染代码

这才是重中之重,服务端渲染代码,主要使用的时 ReactDom/Server.renderToString 方法。这样可以把组件转换成 string,接下来我们服务端需要做的工作就是继续拼接,把这个 node 装在到根节点下面,然后把整个页面给渲染出去,这里我还使用了一个 react-helmet 库,这是用来做 <head/> 的元素,例如 mata,title 等字段的。我们在服务端要把这些字段进行替换。另外最重要的是,别忘了把初始状态挂载在 html 结点中。

import React from 'react' import { renderToString } from 'react-dom/server' import { StaticRouter, matchPath } from 'react-router-dom' import { Provider } from 'react-redux' import { renderRoutes } from 'react-router-config' import { Helmet } from 'react-helmet' import { getBundles } from 'react-loadable/webpack' import Loadable from 'react-loadable' const createTags = (modules, stats) => { let bundles = getBundles(stats, modules) let scriptfiles = bundles.filter(bundle => bundle.file.endsWith('.js')) let stylefiles = bundles.filter(bundle => bundle.file.endsWith('.css')) let scripts = scriptfiles.map(script => `<script src="/${script.file}"></script>`).join('\n') let styles = stylefiles.map(style => `<link href="/${style.file}" rel="stylesheet"/>`).join('\n') return { scripts, styles } } const prepHtml = (data, { html, head, rootString, scripts, styles, initState }) => { data = data.replace('<html', `<html ${html}`) data = data.replace('</head>', `${head} \n ${styles}</head>`) data = data.replace('<div id="root"></div>', `<div id="root">${rootString}</div>`) data = data.replace('<body>', `<body> \n <script>window.__INITIAL_STATE__ =${JSON.stringify(initState)}</script>`) data = data.replace('</body>', `${scripts}</body>`) return data } export const make = ({ ctx, store, context, template, Routes, stats }) => { let modules = [] const rootString = renderToString( <Loadable.Capture report={moduleName => modules.push(moduleName)}> <Provider store={store}> <StaticRouter location={ctx.req.url} context={context}> {renderRoutes(Routes)} </StaticRouter> </Provider> </Loadable.Capture> ) const initState = store.getState() const { scripts, styles } = createTags(modules, stats) const helmet = Helmet.renderStatic() return prepHtml(template, { html: helmet.htmlAttributes.toString(), head: helmet.title.toString() + helmet.meta.toString() + helmet.link.toString(), rootString, scripts, styles, initState }) } export const getMatch = (routesArray, url) => { return routesArray.some(router => matchPath(url, { path: router.path, exact: router.exact, })) }
import Routes from './Routes' import Loadable from 'react-loadable' import configureStore from './store/configureStore' import { matchRoutes } from 'react-router-config' import { getMatch, make } from './helpers/renderer' import stats from '../dist/react-loadable.json' import Koa from 'koa' const server = new Koa() const port = process.env.port || 3000, staticCache = require('koa-static-cache'), cors = require('koa2-cors') var fs = require('fs') var path = require('path') server.use(cors()) const clientRouter = async (ctx, next) => { let html = fs.readFileSync(path.join(path.resolve(process.cwd(), 'dist'), 'index.html'), 'utf-8') let store = configureStore() let branch = matchRoutes(Routes, ctx.req.url) let promises = branch.map(({ route }) => { return route.init ? (route.init(store)) : Promise.resolve(null) }).map(promise => { if (promise) { return new Promise((resolve) => { promise.then(resolve).catch(resolve) }) } }) await Promise.all(promises).catch(err => console.error(err)) let isMatch = getMatch(Routes, ctx.req.url) const context = {} if (isMatch) { let renderedHtml = await make({ ctx, store, context, template: html, Routes, stats, }) if (context.url) { ctx.status = 301 ctx.redirect(context.url) } else { ctx.body = renderedHtml } } else { ctx.status = 404 ctx.body = '未找到该页面' } await next() } server.use(clientRouter) server.use(staticCache(path.resolve(process.cwd(), 'dist'), { maxAge: 365 * 24 * 60 * 60, gzip: true })) console.log(`\n==> :earth_americas: Listening on port ${port}. Open up http://localhost:${port}/ in your browser.\n`) Loadable.preloadAll().then(() => { server.listen(port) })

上面的代码,就基本把服务端的代码给写完了,没有想象中的那么长,但是也不算端。其中要注意几个点。

  • 我需要读取到 dist 目录的 index.html 目录,这里的目录读取方式有问题。但是大致意思差不多

  • 每个路由都由初始方法,我默认挂载到了 route 的 init 字段中,会把 store 传入进去,可以执行 store.dispatch 方法来改变数据。

客户端代码

客户端代码跟以前的客户端渲染差不多,只是需要根据环境不同切换 render 方法或者 hydrate 方法

import React from 'react' import { hydrate, render, unmountComponentAtNode } from 'react-dom' import { ConnectedRouter } from 'react-router-redux' import Loadable from 'react-loadable' import { renderRoutes } from 'react-router-config' import Routes from './Routes' const initialState = window && window.__INITIAL_STATE__ import { Provider } from 'react-redux' import configuraStore from './store/configureStore' import createHistory from 'history/createBrowserHistory' const history = createHistory() let store = configuraStore(initialState) const Root = document.getElementById('root') const renderApp = (routes) => { const renderMethod = process.env.NODE_ENV === 'development' ? render : hydrate renderMethod( <Provider store={store}> <ConnectedRouter history={history}> {renderRoutes(routes)} </ConnectedRouter> </Provider>, Root) } Loadable.preloadReady().then(renderApp.bind(this, Routes)) if (process.env.NODE_ENV === 'development') { if (module.hot) { module.hot.accept('./reducers/index.js', () => { let newReducer = require('./reducers/index.js').default store.replaceReducer(newReducer) }) module.hot.accept('./Routes.jsx', () => { unmountComponentAtNode(Root) var r = require('./Routes').default renderApp(r) }) } }

开发环境与生产环境

开发环境下还是才用客户端渲染的方式,所以与平常的客户端渲染配置没多大区别,也不再赘述。
主要讲讲生产环境,生产环境下,我们需要变量两个包,分别时 server 和 client。client 包中配置,一定要加入 ReactLoadablePlugin,以提供给服务端读取组件代码。服务端打包,一定要把 target 设置为 node。就这亮点,配置为:

webpack.config.common.js

'use strict' const path = require('path') module.exports = { output:{ filename:'[name].[hash].js', path:path.resolve(__dirname,'dist'), publicPath:'/', chunkFilename:'[name].chunk.[hash:8].js', }, context:path.resolve(__dirname,'src'), resolve: { extensions: ['.js', '.jsx','.json'], modules: [path.resolve(__dirname, 'src'), 'node_modules'] }, }

webpack.config.prod.js:

const path = require('path') const webpack = require('webpack') const CleanWebpackPlugin = require('clean-webpack-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin') const ManifestPlugin = require('webpack-manifest-plugin') const { ReactLoadablePlugin } = require('react-loadable/webpack') const CopyWebpackPlugin = require('copy-webpack-plugin') const ExtractTextPlugin = require('extract-text-webpack-plugin') const common = require('./webpack.config.common') const merge = require('webpack-merge') module.exports = merge(common, { entry: { client: 'client.jsx', }, module: { rules: [{ test: /\.jsx?$/, exclude: /node_modules/, include: path.resolve(__dirname, 'src'), use: { loader: 'babel-loader', options: { cacheDirectory: true } } }, { test: /\.(css|scss|less)$/, exclude: /node_modules/, include: path.resolve(__dirname, 'src'), use: ExtractTextPlugin.extract({ fallback: 'style-loader',//style-loader 将css插入到页面的style标签 use: [{ loader: 'css-loader',//css-loader 是处理css文件中的url(),require()等 options: { sourceMap: true, } }, { loader: 'postcss-loader', options: { sourceMap: true, } }, { loader: 'sass-loader', options: { sourceMap: true, } }, { loader: 'less-loader', options: { sourceMap: true, } }] }), }, { test: /\.(svg|woff2?|ttf|eot|jpe?g|png|gif)(\?.*)?$/i, exclude: /node_modules/, use: { loader: 'url-loader', options: { limit: 1024, name: 'img/[sha512:hash:base64:7].[ext]' } } }], }, plugins: [ new ManifestPlugin(), new webpack.NoEmitOnErrorsPlugin(), new ExtractTextPlugin({ filename: 'css/style.[hash].css', allChunks: true, }), new CopyWebpackPlugin([{ from: 'assets/z.png', to: 'favicon.ico' }]), new CleanWebpackPlugin(['./dist']), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) }), new webpack.optimize.OccurrenceOrderPlugin(), new HtmlWebpackPlugin({ title: 'go-store-client', filename: 'index.html', template: './index.prod.html', }), new webpack.optimize.CommonsChunkPlugin({ name: ['vendors', 'manifest'], minChunks: 2 }), new ReactLoadablePlugin({ filename: path.join('./dist/react-loadable.json'), }), ], externals: { 'react': 'React', 'react-dom': 'ReactDOM', } })

webpack.config.server.js:

const path = require('path') const webpack = require('webpack') const CleanWebpackPlugin = require('clean-webpack-plugin') const webpackNodeExternals = require('webpack-node-externals') const ExtractTextPlugin = require('extract-text-webpack-plugin') module.exports = { entry: './src/server.js', output: { filename: 'server.build.js', path: path.resolve(__dirname, 'build'), }, resolve: { extensions: ['.js', '.jsx','.json'], modules: [path.resolve(__dirname, 'src'), 'node_modules'] }, target: 'node', externals: [webpackNodeExternals()], module: { rules: [{ test: /\.jsx?$/, exclude: /node_modules/, include: path.resolve(__dirname, 'src'), use: { loader: 'babel-loader', options: { cacheDirectory: true } } }, { test: /\.(css|scss|less)$/, exclude: /node_modules/, include: path.resolve(__dirname, 'src'), use: ExtractTextPlugin.extract({ fallback: 'style-loader',//style-loader 将css插入到页面的style标签 use: [{ loader: 'css-loader',//css-loader 是处理css文件中的url(),require()等 options: { sourceMap: true, } }, { loader: 'postcss-loader', options: { sourceMap: true, } }, { loader: 'sass-loader', options: { sourceMap: true, } }, { loader: 'less-loader', options: { sourceMap: true, } }] }), }], }, plugins: [ new webpack.NoEmitOnErrorsPlugin(), new CleanWebpackPlugin(['./build']), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) }), new ExtractTextPlugin({ filename: 'css/style.[hash].css', allChunks: true, }), new webpack.optimize.OccurrenceOrderPlugin(), ], }

写在最后

我的最新代码提交于 github,项目地址为 react-ssr-starter,喜欢的可以拿去直接用 ~

  • JavaScript

    JavaScript 一种动态类型、弱类型、基于原型的直译式脚本语言,内置支持类型。它的解释器被称为 JavaScript 引擎,为浏览器的一部分,广泛用于客户端的脚本语言,最早是在 HTML 网页上使用,用来给 HTML 网页增加动态功能。

    730 引用 • 1280 回帖 • 1 关注
  • webpack

    webpack 是一个用于前端开发的模块加载器和打包工具,它能把各种资源,例如 JS、CSS(less/sass)、图片等都作为模块来使用和处理。

    42 引用 • 130 回帖 • 249 关注
  • React

    React 是 Facebook 开源的一个用于构建 UI 的 JavaScript 库。

    192 引用 • 291 回帖 • 374 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...