5

React SSR的流水账

 2 years ago
source link: https://zhuanlan.zhihu.com/p/419104337
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

React SSR的流水账

这篇文章出自于慕课课程的Step by Step实际亲手操作,记录下来,以便后期回顾

SSR与CSR

取决于DOM结构是从服务端生成还是客户端生成,简单测试可以通过查看源代码能不能看到DOM结构,或者通过禁用JavaScript后能不能正常看到页面

SSR的优势

  1. 减少首页渲染白屏时间
  2. SEO友好

迭代1

新建工程,初始化

 mkdir react-ssr && cd react-ssr
 npm init -y
 npm install @babel/cli @babel/core @babel/preset-env babel-loader express react react-dom webpack webapck-cli webpack-noe-externals

新建目录src/server,新建文件index.js

 const express = require('express')
 const app = express()
 app.get('/', function(req, res) {
     res.send(
 `<html>
     <head>
         <title>React SSR</title>
     </head>
     <body>
         <h1>Hello React SSR</h1>
     </body>
 </html>`
 )
 })
 const server = app.listen(3000)

新建.babelrc

 {
     "presets": ["@babel/preset-env", "@babel/preset-react"]
 }

新建webpack.server.js

 const path = require('path')
 const nodeExternals = require('webpack-node-externals')
 module.exports = {
     mode: 'development',
     target: 'node', // 必须指定
     entry: './src/server/index.js',
     output: {
         filename: 'bundle.js',
         path: path.resolve(__dirname, 'dist')
     },
     externals: [nodeExternals()],
     /*
     没有这个插件就会报
     WARNING in ./node_modules/express/lib/view.js 81:13-25
     Critical dependency: the request of a dependency is an expression
     @ ./node_modules/express/lib/application.js 22:11-28
     @ ./node_modules/express/lib/express.js 18:12-36
     @ ./node_modules/express/index.js 11:0-41
     @ ./src/server/index.js 1:14-32
     */
     module: {
         rules: [{
             test: /\.js?$/,
             loader: 'babel-loader',
             exclude: /node_modules/,
         }]
     }
 }

修改package.json 加上命令

 "scripts": {
     "start": "node ./dist/bundle.js",
     "build": "webpack --config webpack.server.js"
 }

此时执行npm build能看到打包出来的结果文件,执行npm start能启动一个服务器,打开http://localhost:3000能看到网页结果

迭代2

webpack初步配置完成之后,可以使用ES6的模块机制,在src/index.js中把express的引入改为

 import express from 'express'

然后打包重启,一切正常

新建一个组件文件src/containers/Home/index.js

 import React from 'react'
 const Home = () => {
     return <div>Home</div>
 }
 export default Home

src/index.js里修改

 import express from 'express'
 import { renderToString } from 'react-dom/server'
 import Home from '../containers/Home/index'
 import React from 'react'
 const app = express()
 app.get('/', function(req, res) {
     res.send(
 `<html>
     <head>
         <title>React SSR</title>
     </head>
     <body>
         <h1>Hello React SSR</h1>
         ${renderToString(<Home />)}
     </body>
 </html>`
 )
 })
 const server = app.listen(3000)

此时重新构建、启动,能看到

查看源代码能看到

 <html>
     <head>
         <title>React SSR</title>
     </head>
     <body>
         <h1>Hello React SSR</h1>
         <div data-reactroot="">Home</div>
     </body>
 </html>

迭代3

监听自动打包

 "scripts": {
     "start": "node ./dist/bundle.js",
     "build": "webpack --config webpack.server.js --watch"
 },

下面实现服务器的自动重新

 npm install -g nodemon
 "scripts": {
     "start": "nodemon --watch dist --exec node \"./dist/bundle.js\"",
     "build": "webpack --config webpack.server.js --watch"
 },
 npm install -g npm-run-all
 "scripts": {
     "dev": "npm-run-all --parallel dev:**",
     "dev:start": "nodemon --watch dist --exec node \"./dist/bundle.js\"",
     "dev:build": "webpack --config webpack.server.js --watch"
 },

此时只要执行一条命令npm run dev就可以了

只有服务器渲染DOM是不够的,对于事件等机制还是需要代码在客户端再次执行一遍,也就是同一套代码在服务器和浏览器同时执行,这就称之为同构

到此时,如果在Home组件里加上事件是不会响应的

 const Home = () => {
     return <div onClick={() => { alert('Home') }}>Home</div>
 }

index.js 里修改一下HTML模板,加上包裹的带id 的元素,为的是方便客户端渲染时使用,并且加上引入index.js<script>,同时再加上静态文件的路由

 app.use(express.static('public'))
 app.get('/', function(req, res) {
     res.send(
 `<html>
     <head>
         <title>React SSR</title>
     </head>
     <body>
         <h1>Hello React SSR</h1>
         <div id="root">
             ${renderToString(<Home />)}
         </div>
         <script src="index.js"></script>
     </body>
 </html>`
 )
 })

新建src/client/index.js,这是客户端渲染的入口

 import React from 'react'
 import { render } from 'react-dom'
 ​
 import Home from '../containers/Home/index'
 render(<Home />, document.getElementById('root'))

新建webpack.client.js

 const path = require('path')
 module.exports = {
     mode: 'development',
     entry: './src/client/index.js',
     output: {
         filename: 'index.js',
         path: path.resolve(__dirname, 'public')
     },
     module: {
         rules: [{
             test: /\.js?$/,
             loader: 'babel-loader',
             exclude: /node_modules/,
         }]
     }
 }

package.json里修改脚本

 "scripts": {
     "dev": "npm-run-all --parallel dev:**",
     "dev:start": "nodemon --watch dist --exec node \"./dist/bundle.js\"",
     "dev:build:server": "webpack --config webpack.server.js --watch",
     "dev:build:client": "webpack --config webpack.client.js --watch"
 }

现在启动npm run dev,刷新页面,点击,有事件响应了

控制台里报了个警告

 Warning: render(): Target node has markup rendered by React, but there are unrelated nodes as well. This is most commonly caused by white-space inserted around server-rendered markup.

同构的代码应该使用hydrate来渲染,修改src/client/index.js

 import React from 'react'
 import { hydrate } from 'react-dom'
 ​
 import Home from '../containers/Home/index'
 hydrate(<Home />, document.getElementById('root'))

现在又换了一个新的警告

 Did not expect server HTML to contain the text node "
             " in <div>.

此时要在模板里把服务端渲染的内容周围去掉空格和换行

 <div id="root">${renderToString(<Home />)}</div>

现在就没有警告了

用到webpack-merge

 npm install webpack-merge
 ​

新建webpakc.base.js,抽出公共配置

 module.exports = {
     module: {
         rules: [{
             test: /\.js?$/,
             loader: 'babel-loader',
             exclude: /node_modules/,
         }]
     }
 }

修改webpack.client.js

 const path = require('path')
 const merge = require('webpack-merge').default
 const base = require('./webpack.base')
 const client = {
     mode: 'development',
     entry: './src/client/index.js',
     output: {
         filename: 'index.js',
         path: path.resolve(__dirname, 'public')
     },
     
 }
 module.exports = merge(base, client)

修改webpack.server.js

 const path = require('path')
 const nodeExternals = require('webpack-node-externals')
 const merge = require('webpack-merge').default
 const base = require('./webpack.base')
 const server = {
     mode: 'development',
     target: 'node',
     entry: './src/server/index.js',
     output: {
         filename: 'bundle.js',
         path: path.resolve(__dirname, 'dist')
     },
     externals: [nodeExternals()],
     /*
     WARNING in ./node_modules/express/lib/view.js 81:13-25
     Critical dependency: the request of a dependency is an expression
     @ ./node_modules/express/lib/application.js 22:11-28
     @ ./node_modules/express/lib/express.js 18:12-36
     @ ./node_modules/express/index.js 11:0-41
     @ ./src/index.js 1:14-32
     */
 }
 ​
 module.exports = merge(base, server)

路由上最大的区别在于服务端渲染要使用StaticRouter

改造1

 npm install react-router-dom

新建src/routes.js 文件

 import Home from './containers/Home'
 import React from 'react'
 import { Route } from 'react-router-dom'
 const Routes = () => {
     return (
         <div>
             <Route path="/" exact component={Home}></Route>
         </div>
     )
 }
 ​
 export default Routes

修改客户端入口src/client/index.js

 import React from 'react'
 import { hydrate } from 'react-dom'
 import { BrowserRouter } from 'react-router-dom'
 import Routes from '../routes'
 const App = () => {
     return (
         <BrowserRouter>
             <Routes />
         </BrowserRouter>
     )
 }
 hydrate(<App />, document.getElementById('root'))

修改服务端入口src/server/index.js

 import express from 'express'
 import { renderToString } from 'react-dom/server'
 import Routes from '../routes'
 import { StaticRouter } from 'react-router-dom'
 import React from 'react'
 const app = express()
 app.use(express.static('public'))
 ​
 app.get('/', function (req, res) {
     const content = renderToString((
         /* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */
         <StaticRouter location={req.path} context={{}}>
             <Routes />
         </StaticRouter>
     ))
     console.log(content)
     res.send(
         `<html>
     <head>
         <title>React SSR</title>
     </head>
     <body>
         <h1>Hello React SSR</h1>
         <div id="root">${content}</div>
         <script src="index.js"></script>
     </body>
 </html>`
     )
 })
 const server = app.listen(3000)

此时运行,和原来没有差别,当前也只存在一个路由

改造2

这一步增加一个新的路由/login

新建src/container/Login/index.js

 import React from 'react'
 ​
 export default () => (
     <div>Login</div>
 )

修改src/routes.js

 const Routes = () => {
     return (
         <div>
             <Route path="/" exact component={Home}></Route>
             <Route path="/login" exact component={Login}></Route>
         </div>
     )
 }

最后在src/server/index.js里修改服务端的路由

 app.get('*', () => {
   //...
 })

访问localhost:3000/login

改造3

这一步是将<Link> 加入进来做路由跳转

新建src/containers/Header/index.js

 import React from 'react'
 import { Link } from 'react-router-dom'
 export default () => (
     <div>
         <Link to="/">Home</Link>
         <br />
         <Link to="/Login">Login</Link>
     </div>
 )

修改src/routers.js

 const Routes = () => {
     return (
         <div>
             <Header />  
             <Route path="/" exact component={Home}></Route>
             <Route path="/login" exact component={Login}></Route>
         </div>
     )
 }

此时就能路由跳转了,并且服务端渲染只发生在初始加载页面的时候,后续的路由跳转就是客户端的事情

Redux

改造1

 npm install redux react-redux redux-thunk

新建src/store.js

 import { createStore, applyMiddleware } from 'redux'
 import thunk from 'redux-thunk'
 const reducer = (state = { name: 'zhangsan' }, action) => {
     return state
 }
 const store = createStore(reducer, applyMiddleware(thunk))
 export default store

修改src/client/index.js

 const App = () => {
     return (
         <Provider store={store}>
             <BrowserRouter>
                 <Routes />
             </BrowserRouter>
         </Provider>
     )
 }

修改src/server.index.js

 const content = renderToString((
     <Provider store={store}>
         {/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
         <StaticRouter location={req.path} context={{}}>
             <Routes />
         </StaticRouter>
     </Provider>
 ))

修改src/container/Home/index.js 消费store

const Home = () => {
    const name = useSelector(state => state.name)
    return (
        <div onClick={() => { alert('Home') }}>
            Home
            {name}
        </div>)
}

改造2

改造1里有一个坑就是导出的store是同一个实例,这样对于不同用户访问都是访问到了同一个实例

修改scr/store.js,使用方法每次生成一个新的实例,就跟Vuedata配置一样

 import { createStore, applyMiddleware } from 'redux'
 import thunk from 'redux-thunk'
 const reducer = (state = { name: 'zhangsan' }, action) => {
     return state
 }
 const getStore = () => {
     return createStore(reducer, applyMiddleware(thunk))
 }
 export default getStore

修改src/client/index.js

 const App = () => {
     return (
         <Provider store={getStore()}>
             <BrowserRouter>
                 <Routes />
             </BrowserRouter>
         </Provider>
     )
 }

修改src/server/index.js

 const content = renderToString((
     <Provider store={getStore()}>
         {/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
         <StaticRouter location={req.path} context={{}}>
             <Routes />
         </StaticRouter>
     </Provider>
 ))

改造3

这一步的目标是用上reducer,因为仅做试验,所以很多都是硬编码,这一步其实也跟SSR关系不大

新建public/data.json

[
    {
        "name": "zhangsan"
    },
    {
        "name": "lise"
    },
    {
        "name": "wangwu"
    }
]
 npm install axios

修改src/store.js里的reducer

 const reducer = (state = { list: [] }, action) => {
     switch (action.type) {
         case 'UPDATE': 
             return { list: action.payload }
     }
     return state
 }

修改src/container/Home/index.js

 const Home = () => {
     const list = useSelector(state => state.list)
     const dispatch = useDispatch()
     const sendRquest = () => {
         dispatch(dispatch => {
             axios.get('/data.json').then(res => {
                 dispatch({
                     payload: res.data,
                     type: 'UPDATE'
                 })
             })
         })
     }
     return (
         <div>
             Home
             <button onClick={sendRquest}>Send Request</button>
             {
                 list.map(item => (
                     <div key={item.name}>{item.name}</div>
                 ))
             }
         </div>)
 }

这里没有解耦出专门的Action,直接写在了组件里

改造4

这一步解决的是在服务端获取数据的问题,由于componentDidMount方法只会在客户端执行,所以即便初始状态数据的获取代码写在componentDidMount里也是没有用的,这个改造主要参考react-router的Guide里Server Rendering一章

修改src/containers/Home/index.js增加一个数据获取的方法

 Home.loadData = () => {
     // TODO
 }

修改src/routes.js,把导出从组件改为了描述数据

 const routes = [
     {
         path: '/',
         component: Home,
         exact: true,
         loadData: Home.loadData
     },
     {
         path: '/login',
         component: Login,
         exact: true
     }
 ]
 ​
 export default routes

修改src/client/index.js

 const App = () => {
     return (
         <Provider store={getStore()}>
             <BrowserRouter>
                 <Header />
                 {
                     routes.map(route => (
                         <Route key={route.path} {...route} />
                     ))
                 }
             </BrowserRouter>
         </Provider>
     )
 }

修改src/server/index.js

app.get('*', function (req, res) {
    const store = getStore()
    const matchRoutes = []
    routes.some(route => {
        const match = matchPath(req.path, route)
        if (match) {
            matchRoutes.push(route)
        }
    })
    const content = renderToString((
        <Provider store={store}>
            {/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
            <StaticRouter location={req.path} context={{}}>
                <Header />
                {
                    routes.map(route => (
                        <Route key={route.path} {...route} />
                    ))
                }
            </StaticRouter>
        </Provider>
    ))
    res.send(
        `<html>
    <head>
        <title>React SSR</title>
    </head>
    <body>
        <h1>Hello React SSR</h1>
        <div id="root">${content}</div>
        <script src="index.js"></script>
    </body>
</html>`
    )
})

到这一步把基础的架子改造完成

改造5

上一步的改造只支持单层的路由匹配,如果想要匹配多层嵌套路由(每一层可能都要去加载数据),就要使用react-router-config

npm install react-router-config

修改src/routes.js,增加子路由做测试

import Home from './containers/Home'
import Login from './containers/Login'
const ABC = () => (<div>abc</div>)
const XYZ = () => (<div>xyz</div>)
const routes = [
    {
        path: '/',
        component: Home,
        // exact: true,
        loadData: Home.loadData,
        routes: [ // 千万不要写成routers,否则查到怀疑人生
            {
                path: '/abc',
                component: ABC,
                exact: true
            },
            {
                path: '/xyz',
                component: XYZ,
                exact: true
            },
        ]
    },
    {
        path: '/login',
        component: Login,
        exact: true
    }
]

export default routes

修改src/server/index.js

 app.get('*', function (req, res) {
     const store = getStore()
     const matchedRoutes = matchRoutes(routes, req.path)
     console.log(matchedRoutes)
     // ...
 } 

此时访问/xyz可以看到同时匹配到了//xyz

改造6

修改src/container/Home/index.js

 Home.loadData = store => {
     return store.dispatch(dispatch => {
         // 因为要从服务端发起请求,这里不能简写/data.json
         return axios.get('http://localhost:3000/data.json').then(res => { // 返回promise
             dispatch({
                 payload: res.data,
                 type: 'UPDATE'
             })
         })
     })
 }

这里在实际项目中对于请求URL还是要小心一些,可能还涉及代理转发等各种复杂的情况,稳妥一点就判断一下当前是在客户端还是服务端

修改src/server/index.js

 app.get('*', function (req, res) {
     const store = getStore()
     const matchedRoutes = matchRoutes(routes, req.path)
     const promises = []
     matchedRoutes.forEach(item => {
         if (item.route.loadData) {
             promises.push(item.route.loadData(store))
         }
     })
     Promise.all(promises).then(() => {
         console.log(store.getState())
         const content = renderToString((
             <Provider store={store}>
                 {/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
                 <StaticRouter location={req.path} context={{}}>
                     <Header />
                     {
                         routes.map(route => (
                             <Route key={route.path} {...route} />
                         ))
                     }
                 </StaticRouter>
             </Provider>
         ))
         res.send(
 `<html>
     <head>
         <title>React SSR</title>
     </head>
     <body>
         <h1>Hello React SSR</h1>
         <div id="root">${content}</div>
         <script src="index.js"></script>
     </body>
 </html>`
         )
     })
 })

此时刷新网页,会看到数据一闪而过,显示数据的区块为空白,这是因为一开始服务端渲染出了数据

 <html>
     <head>
         <title>React SSR</title>
     </head>
     <body>
         <h1>Hello React SSR</h1>
         <div id="root"><div><a href="/">Home</a><br/><a href="/Login">Login</a></div><div>Home<button>Send Request</button><div>zhangsan</div><div>lise</div><div>wangwu</div></div></div>
         <script src="index.js"></script>
     </body>
 </html>

但随后客户端再次渲染一遍,把数据给刷没了,可以修改Home组件,使得逻辑一致

 useEffect(() => {
     dispatch(dispatch => {
         axios.get('/data.json').then(res => {
             dispatch({
                 payload: res.data,
                 type: 'UPDATE'
             })
         })
     })
 }, [])

此时还存在两个问题

第一个问题是有一个警告

第二个问题是存在一个闪烁过程,这两个问题其实是同一个原因:

一开始服务端获得数据,返回的DOM里是有数据的,随后客户端渲染开始,但一开始默认的store里是空的,客户端按空数组来渲染,此时就变成了客户端渲染出的DOM结果和服务端返回的DOM结果不一致,就报了警告(注意如果使用render而非hydate的话,render并不会在开发模式检查比较DOM的一致性,也就不会报警告),最后在渲染Home组件的时候,发起请求,渲染出数据来

改造7

修改src/store.js

 export const getStore = () => {
     return createStore(reducer, applyMiddleware(thunk))
 }
 export const getClientStore = () => {
     const defaultState = window.__context__.state
     return createStore(reducer, defaultState, applyMiddleware(thunk))
 }

对应的import都要改变,这里不再赘述

src/client/index.js里就要用getClientStore

 <Provider store={getClientStore()}>
 </Provider>

至于这个window.__context__.state 是从服务端来

 `<html>
     <head>
         <title>React SSR</title>
     </head>
     <body>
         <h1>Hello React SSR</h1>
         <div id="root">${content}</div>
         <script>
             window.__context__ = {
                 state: ${JSON.stringify(store.getState())}
             }
         </script>
         <script src="index.js"></script>
     </body>
 </html>`

修改过后,不再有闪烁,警告也消失了

这个过程就称之为数据的注水(注入到window.__context__里)和脱水(从window.__context__里拿出来使用)

客户端在useEffect 或者componentDidMount 里请求数据的代码不能少,因为服务端只在第一个请求的页面进行服务端渲染,加入请求的是/dummy,此时window.__context__里就是空,路由跳转到Home组件的话必须要在客户端发起请求才行

多级嵌套路由改造

之前的嵌套路由没有真正实现渲染,所以还是要借助于react-router-configrenderRouters方法来实现,本质上相当于回到集中式声明路由的写法中去

修改src/routes.js 使之变为嵌套路由声明

 const routes = [
     {
         path: '/',
         component: App,
         routes: [
             {
                 path: '/',
                 component: Home,
                 exact: true,
                 loadData: Home.loadData,
             },
             {
                 path: '/login',
                 component: Login,
                 exact: true,
             }
         ]
     }
 ]

修改src/client/index.js,客户端渲染第一层路由

 hydrate(
     <Provider store={getClientStore()}>
         <BrowserRouter>
             {renderRoutes(routes)}
         </BrowserRouter>
     </Provider>, document.getElementById('root'))

修改src/server/index.js,服务端渲染第一层路由

 const content = renderToString((
     <Provider store={store}>
         {/* 服务器如果要感知当前路径,需要通过req来知道,所以写在这里 */}
         <StaticRouter location={req.path} context={{}}>
             {renderRoutes(routes)}
         </StaticRouter>
     </Provider>
 ))

新建src/App.js,这里用到了props.route.routes注意拼写,有一点不对就要调得怀疑人生

 const App = (props) => {
     return (
         <div>
             <Header />
             {renderRoutes(props.route.routes)}
         </div>
     )
 }
 export default App

这里props.route.routes对应的就是

 [
     {
         path: '/',
         component: Home,
         exact: true,
         loadData: Home.loadData,
     },
     {
         path: '/login',
         component: Login,
         exact: true,
     }
 ]

也就是去渲染第二层的路由

由于style-loader会找寻window并把样式挂载在DOM中,而服务端渲染的时候不存在window对象,所以处理样式的时候要分开

其实判断当前在服务端还是客户端渲染的一个最简便方法就是window存不存在

 npm install isomorphic-style-loader css-loader style-loader

具体用法参考文档

react-helmet

使用react-helmet可以生成、修改DOM的<title><meta>等跟SEO相关的元素,要注意的是真的要做SEO的话react-helmet 是要在服务端运行的,具体做法很简单参考官方文档就行了

预渲染

判断是否爬虫来访问,是爬虫的话返回在预渲染服务器的预渲染结果

可以通过prerender


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK