motainzhang

motainzhang

koa-react-ssr 学习

2019-09-19
koa-react-ssr  学习

koa 创建一个简单的服务器

yarn add koa koa-router

src/server/app.js

const Koa = require('koa')
const Router = require('koa-router')

const app = new Koa()
const router = new Router()

router.get('*', async ctx => {
  ctx.body = `
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <title>koa ssr</title>
    </head>
    <body>
      <h1>简单的 koa http 服务</h1>
      <div id="root"></div>
    </body>
  </html>
  `
})

app.use(router.routes())
app.listen(3000, () => {
  console.log('http://127.0.0.1:3000')
})

上面我们用 koa 简单创建了一个 http 服务,然后返回字符串, node app.js 打开 http://127.0.0.1:3000 可以看到效果了

搞事情,写一个 Home.jsx 组件

src/client/Home.jsx

import React from 'react'
const Home = () => <h2>Hello world</h2>
export default Home

而服务端的 src/server/app.js 文件需要改写为

import Koa from 'koa'
import Router from 'koa-router'
import ReactDOM from 'react-dom'
import Home from '../client/Home'

const app = new Koa()
const router = new Router()

// 原本的在浏览器端的运行方法
// ReactDOM.render(<Home />, document.getElementById('root'))

// 在服务器端运行的代码
const content = ReactDOM.renderToString(<Home />)

router.get('*', async ctx => {
  ctx.body = `
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <title>koa ssr</title>
    </head>
    <body>
      <div id="root">${content}</div>
    </body>
  </html>
  `
})

app.use(router.routes())
app.listen(3000, () => {
  console.log('http://127.0.0.1:3000')
})

上面用到了 import / export 语法,node 不支持,则我们需要使用 babel 对它进行转义。

yarn init -y
yarn add webpack webpack-cli -D
yarn add @babel/core @babel/preset-env @babel/preset-react @babel/runtime @babel/plugin-transform-runtime -D
yarn add webpack-node-externals -D # 我们不希望捆绑 koa koa-router 等模块...
touch webpack.server.js

webpack.server.js

const path = require('path')
const nodeExternals = require('webpack-node-externals')

module.exports = {
  mode: 'production',
  target: 'node',
  externals: [nodeExternals()], // in order to ignore all modules in node_modules folder

  entry: './src/server/app.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'build')
  },

  resolve: {
    extensions: ['.js', '.json', '.jsx'] // import xxx from 'app.jsx' => import xxx from 'app'
  },

  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            // @babel/preset-env 转义 es6+ 的箭头函数、类、async await 等为 ES5 语法
            // @babel/preset-react : 转义 react
            // @babel/plugin-transform-runtime : 自动 polyfill es5不支持的特性
            presets: ['@babel/preset-env', '@babel/preset-react'],
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      }
    ]
  }
}

package.json 中添加

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

打开 http://127.0.0.1:3000/ 就可以看到效果了...

目前为止 我们 webpack 编译了服务端的代码,其中还将 react 转成我们需要的形式。已经初步完成了我们的工作...

实现同构(添加事件绑定)

服务端渲染和客户端渲染的对比:

CSR 步骤:

  1. 浏览器下载 HTML 文档
  2. 浏览器下载 JS 文件
  3. 浏览器运行 React 代码
  4. 页面渲染

SSR 的出现,可以解决这些传统 CSR 的弊端, 且可以优化 SEO 方便 balabala....

重点是同构如何实现:

  1. 服务端运行 React 代码生成 HTML
  2. 发送 HTML 文件给浏览器
  3. 浏览器收到内容显示
  4. 浏览器加载 JS 文件
  5. JS 代码执行并接管页面的操作

在前面的实现过程中,我们只是运行 react 代码生成 string 显示在页面中,也仅此而已,那么我们为 Home.jsx 添加一个事件绑定试试:

import React, { Component } from 'react'

class Home extends Component {
  render() {
    return (
      <div>
        <h2>hello world</h2>
        <button onClick={() => alert('click')}>click</button>
      </div>
    )
  }
}

export default Home
yarn build:server && yarn start

发现无论怎么点也没有反应,原因很简单,react-dom/server 下的 renderToString 并没有做事件相关的处理,因此返回给浏览器的内容不会有事件绑定。

那怎么解决这个问题呢?

这就需要进行同构了。所谓同构,通俗的讲,就是一套 React 代码在服务器上运行一遍,到达浏览器又运行一遍。服务端渲染完成页面结构,浏览器端渲染完成事件绑定。

那如何进行浏览器端的事件绑定呢?

唯一的方式就是让浏览器去拉取 JS 文件执行,让 JS 代码来控制。于是服务端返回的代码变成了这样:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>koa ssr</title>
  </head>
  <body>
    <div id="root">
      <div data-reactroot="">
        <h2>hello world</h2>
        <button>click</button>
      </div>
    </div>
    <script src="/index.js"></script>
  </body>
</html>

有没有发现和之前的区别?区别就是多了一个 script 标签。而它拉取的 JS 代码就是来完成同构的。
那么这个 index.js 我们如何生产出来呢?

在这里,要用到 react-dom。具体做法其实就很简单了:

src/client/index.js

import React from 'react'
import ReactDOM from 'react-dom'
import Home from './Home'

// ReactDOM.render(<Home />, document.getElementById('root')) 这是浏览器端运行的方式

ReactDOM.hydrate(<Home />, document.getElementById('root')) // 服务端渲染用 hydrate

webpack.client.js

const path = require('path')

module.exports = {
  mode: 'production',

  entry: './src/client/index.js',

  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'public')
  },

  resolve: {
    extensions: ['.js', '.json', '.jsx'] // import xxx from 'app.jsx' => import xxx from 'app'
  },

  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            // @babel/preset-env 转义 es6+ 的箭头函数、类、async await 等为 ES5 语法
            // @babel/preset-react : 转义 react
            // @babel/plugin-transform-runtime : 自动 polyfill es5不支持的特性
            presets: ['@babel/preset-env', '@babel/preset-react'],
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      }
    ]
  }
}

src/server/app.js

import Koa from 'koa'
import Router from 'koa-router'
import serve from 'koa-static'
import path from 'path'

// react...
import React from 'react'
import ReactDOM from 'react-dom/server'
import Home from '../client/Home'
const app = new Koa()
const router = new Router()

const content = ReactDOM.renderToString(<Home />)

// 设置静态服务器为根路径的 public
app.use(serve(path.join(process.cwd(), '/public')))

router.get('*', async ctx => {
  ctx.body = `
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <title>koa ssr</title>
    </head>
    <body>
      <div id="root">${content}</div>
      <script src='/index.js'></script>
    </body>
  </html>
  `
})

app.use(router.routes())
app.listen(3000, () => {
  console.log('http://127.0.0.1:3000')
})

package.json

 "scripts": {
    "test": "yarn build:server && yarn build:client && yarn start",
    "start": "node ./build/bundle.js",
    "build:server": "webpack --config webpack.server.js",
    "build:client": "webpack --config webpack.client.js"
  }
yarn add koa-static
yarn test

打开页面点击后就可以发现事件已经绑定成功了!

添加路由

写一个路由的配置文件 src/client/Routes.js

import React, { Component } from 'react'
import { Route } from 'react-router-dom'

import Home from './Home'
import About from './About'

export default (
  <div>
    <Route path="/" exact component={Home} />
    <Route path="/about" exact component={About} />
  </div>
)

src/client/About.jsx

import React, { Component } from 'react'

class About extends Component {
  render() {
    return (
      <div>
        <h2>About page</h2>
      </div>
    )
  }
}

export default About

修改打包 react 的入口文件 src/client/index.js

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from './Routes'

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

ReactDOM.hydrate(<App />, document.getElementById('root')) // 服务端渲染用 hydrate

同时也要修改 src/server/app.js 每次请求根据路径不同生成不同的 content

import Koa from 'koa'
import Router from 'koa-router'
import serve from 'koa-static'
import path from 'path'

// react...
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'

import Routes from '../client/Routes'

const app = new Koa()
const router = new Router()

// 设置静态服务器为根路径的 public
app.use(serve(path.join(process.cwd(), '/public')))

router.get('*', async ctx => {
  //构建服务端的路由
  const content = renderToString(<StaticRouter location={ctx.url}>{Routes}</StaticRouter>)

  ctx.body = `
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <title>koa ssr</title>
    </head>
    <body>
      <div id="root">${content}</div>
      <script src='/index.js'></script>
    </body>
  </html>
  `
})

app.use(router.routes())
app.listen(3000, () => {
  console.log('http://127.0.0.1:3000')
})

现在路由的跳转就没有任何问题啦。 注意,这里仅仅是一级路由的跳转,多级路由的渲染在之后的系列中会用 react-router-configrenderRoutes 来处理。

引入 redux

yarn add redux react-redux redux-thunk axios

新建 src/redux.js 文件 (为了方便 将配置都写在一个文件)

import { combineReducers, compose, createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import axios from 'axios'

// constants
const ADD_COUNT = 'ADD_COUNT'
const GET_TITLE = 'GET_TITLE'

// actions
export const addCount = () => ({
  type: ADD_COUNT
})

export const getTitle = () => {
  return dispatch =>
    axios.get('https://randomuser.me/api/').then(res => {
      dispatch({
        type: GET_TITLE,
        payload: {
          title: res.data.results[0].name.title
        }
      })
    })
}

// default state
let defaultState = {
  count: 1
}

// reducers
export const demoReducer = (state = defaultState, action) => {
  switch (action.type) {
    case ADD_COUNT:
      return { ...state, count: ++state.count }
    case GET_TITLE:
      return { ...state, title: action.payload.title }
    default:
      return state
  }
}

// combineReducers
const reducers = combineReducers({
  demo: demoReducer
})

// generator store
const configureStore = (initialState = {}) => {
  const storeEnhancers = applyMiddleware(thunk)
  const store = createStore(reducers, initialState, storeEnhancers)
  return store
}

// export
export default configureStore()

在客户端中引入 src/client/index.js

import { Provider } from 'react-redux'
import store from '../redux'

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

src/client/Home.jsx 中使用

import React, { Component } from 'react'
import { connect } from 'react-redux'
import { addCount, getTitle } from '../redux'

class Home extends Component {
  componentDidMount() {
    this.props.getTitle()
  }

  render() {
    return (
      <div>
        <h2>
          Count: {this.props.count} title: {this.props.title}
        </h2>
        <button onClick={this.props.addCount}>add count</button>
        <button onClick={this.props.getTitle}>async getTitle</button>
      </div>
    )
  }
}

const mapStateToProps = state => ({
  count: state.demo.count,
  title: state.demo.title
})

export default connect(
  mapStateToProps,
  { addCount, getTitle }
)(Home)

在服务端中引入 src/server/app.js

import { Provider } from 'react-redux'
import store from '../redux'
//...

const content = renderToString(
  <Provider store={store}>
    <StaticRouter location={ctx.url}>{Routes}</StaticRouter>
  </Provider>
)

yarn test 成功编译 说明你成功了

异步数据的服务端渲染方案(数据注水与脱水)

经过上面的改造 我们可以在异步获取到了数据。。当然,这是存在坑点的。。

componentDidMount() {
 this.props.getTitle()
}

我们一般在组件的 componentDidMount 生命周期函数进行异步数据的获取。但是,在服务端渲染中却出现了问题。 启动服务后我们查看网页的源代码 可以发现

源代码里面并没有这些列表数据啊!那这是为什么呢?

让我们来分析一下客户端和服务端的运行流程,当浏览器发送请求时,服务器接受到请求,这时候服务器和客户端的 store 都是空的,紧接着客户端执行 componentDidMount 生命周期中的函数,获取到数据并渲染到页面,然而服务器端始终不会执行 componentDidMount,因此不会拿到数据,这也导致服务器端的 store 始终是空的。

换而言之,关于异步数据的操作始终只是客户端渲染。现在的工作就是让服务端将获得数据的操作执行一遍,以达到真正的服务端渲染的效果。