tsc-space/salons

看完就能学会的React同构

Opened this issue · 0 comments

传送门

同构这名词大家应该都已经很熟悉了, 具体是怎么一回事我就不多做解释了, 万一说的不对怕被大佬喷.

本文旨在给大家介绍一下如何使用Node+react+react-router4实现服务端渲染.

首先我们来新建工程, 确定目录结构, 由于同构项目包括服务端渲染和客户端渲染两部分, 并且它俩渲染时用的是同一套代码, 所以目录结构如下图所示:
table

  1. browser/index.js
    该文件是客户端渲染的入口, 可以理解为我们平时开发单页面应用的/src/index.js, 代码如下:
import React from 'react';
import { hydrate } from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import App from '../shared/App';

hydrate(
  <Router>
    <App />
  </Router>,
  document.getElementById('root')
);

其实, 只需关注不同点, 我们平时使用是ReactDOM.render, 而这里使用ReactDOM.hydrate, 官方解释是该api和我们平时使用是ReactDOM.render是一样一样的, 但是当container的HTML内容是由ReactDOMServer渲染, 那么我们需要调用hydrate, 它会尝试将事件绑定在已渲染的dom上.

  1. server/index.js
    该文件是服务端渲染的入口, 服务端接收到浏览器的请求后, 根据请求的路由req.url, 调用ReactDOM/Server.renderToString生成html字符串返回给客户端.
import express from "express"
import cors from "cors"
import React from "react"
import { renderToString } from "react-dom/server"
import { StaticRouter, matchPath } from "react-router-dom"
import serialize from "serialize-javascript"
import App from '../shared/App'
import routes from '../shared/routes'
const fs = require('fs');
const path = require('path');

// 读取html模板
const template = fs.readFileSync(path.resolve(process.cwd(), 'public/index.html'), 'utf-8');

const app = express()

app.use(cors())
app.use(express.static("public"))

app.get("*", (req, res, next) => {
  // 找到当前请求的url对应的route配置项
  const activeRoute = routes.find((route) => matchPath(req.url, route)) || {}

  // 如果路由配置项有fetchInitialData, 那就请求数据
  const promise = activeRoute.fetchInitialData
    ? activeRoute.fetchInitialData(req.path)
    : Promise.resolve()

  promise.then((data) => {
    // 在ssr中, 子路由可通过访问this.props.staticContext拿到数据
    const context = { data }

    // 在server中, 需要使用StaticRouter
    const markup = renderToString(
      <StaticRouter location={req.url} context={context}>
        <App />
      </StaticRouter>
    )

    // window.__INITIAL_DATA__, 便是客户端的初始数据
    // 读取html模板 + 占位符替换的方式更优雅
    res.send(
      template
        .replace('<!-- SCRIPT_PLACEHOLDER -->', `<script>window.__INITIAL_DATA__ = ${serialize(data)}</script>`)
        .replace('<!-- HTML_PLACEHOLDER -->', markup)
      );

  }).catch(next)
})

app.listen(3000, () => {
  console.log(`Server is listening on port: 3000`)
})

接下来讲的划重点, 要考哦:

  • 服务端渲染调用的是StaticRouter
    不同于客户端渲染调用BrowserRouter.官方解释大概是一个永远不会改变location的router, 原因是因为只有当我们第一次在浏览器中输入地址按下回车键访问页面时发起的请求才会经过服务端, 从第二次开始, 每次浏览器地址发生改变, 由于客户端使用BrowserRouter, 所以以后的每次请求都只是通过history.pushState/placeState记录, 并不会向服务端发起请求.
    StaticRouter接受2个参数, location只需传req.url, context属性用来向子组件传递数据, 在StaticRouter下声明的每个子route都会接收到props.staticContext, 就像我们平时常用的props.match, props.location.

  • 声明式路由, 根据页面获取数据并生成相应html字符串
    在实际场景中, 用户可能会访问不同的页面, 也就是对应不同的route, 而有些页面需要初始化数据, 而有些页面不需要初始化数据. 在客户端渲染中, 大家都已经很熟悉, 只需在componentDidMount中发起请求获取数据即可, 但是在服务端渲染中, 我们需要做的是, 先根据页面去获取数据, 然后使用这些数据去渲染html字符串并返回给客户端, 所以这里我们需要用到声明式路由.
    shared/routes.js

import Home from './Home';
import Grid from './Grid';

import { fetchPopularRepos } from '../shared/api';

const routes = [
  {
    path: '/',
    exact: true,
    component: Home,
  },
  {
    path: '/popular/:id',
    exact: true,
    component: Grid,
    fetchInitialData: (path = '') => fetchPopularRepos(path.split('/').pop()),
  }
]

export default routes;

shared/App.js

import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
import routes from './routes';
import NoMatch from './NoMatch';
import NavBar from './NavBar';

export default class App extends Component {
  render() {
    return (
      <div>
        <NavBar />

        <Switch>
          { // render(props) {} props中有staticContext属性
            routes.map(({ path, exact, component: Component, ...rest }) => (
              <Route key={path} path={path} exact={exact} render={(props) => (
                <Component {...props} {...rest} />
              )} />
            ))
          }
          <Route render={(props) => <NoMatch {...props} /> } />
        </Switch>
        
      </div>
    );
  }
}

在routes.js中, 我在数组中写入每个页面对应的配置, 仔细观察我们会发现有的配置中有fetchInitialData, 这个字段就是用来声明我们获取初始化数据的方法. 当然字段名大家可根据自己命名习惯随意声明.
接着让我们来看看App.js, 在这页面中会根据routes配置来生成, 但是这里有一个很关键的点, 与前文的StaticRouter的context呼应, 在Route中渲染我特意使用了render函数, 它接收的props的属性中就包含staticContext, match, location等重要数据.

  • 服务端根据页面请求数据, 生成html字符串
    在这一步我们会用到React-Router的另一个api, macthPath, 我们通过调用routes.find((route) => matchPath(req.url, route))获取到当前请求对应的route配置, 随之便可判断是否有fetchInitialData方法来判断是否需要获取初始化数据, 然后通过context将数据传递给StaticRouter下的子组件. 相信大家一定都看到函数最后调用了res.send, 它就是用来给客户端返回html字符串的.

  • 在返回的html内容中通过script给客户端写入全局数据
    先简单介绍一下, 在服务端中页面可以通过StaticRouter对应的staticContext, 是拿到数据, 那么客户端又该怎么拿到数据呢, 答案便是window.INITIAL_DATA
    这一段代码中我使用了一个库'serialize-javascript', 它可以防止xss攻击, 当然重点是window.INITIAL_DATA, 这里我们先买个伏笔哈, 一会去客户端渲染部分讲解.

<script>window.__INITIAL_DATA__ = ${serialize(data)}</script>, 
  1. shared: 服务端和客户端公用的组件
    在这里我们拿shared/Grid.js来做说明, 该页面是根据路由参数来获取github上不同语言的开源库. 首先看代码:
import React, { Component } from 'react';
import './Popular.less';

class Popular extends Component {
  constructor(props) {
    super(props);
    let repos;

    if (__isBrowser__) {
      // 如果是客户端, 则读取window.__INITIAL_DATA__
      repos = window.__INITIAL_DATA__;
      delete window.__INITIAL_DATA__;
    } else {
      // 如果是服务端, 则读取staticContext.data
      repos = this.props.staticContext.data;
    }
    this.state = {
      repos,
      loading: !Array.isArray(repos) || !repos.length,
    };
  }

  componentDidMount() {
    if (!Array.isArray(this.state.repos) || !this.state.repos.length) {
      this.fetchRepos(this.props.match.params.id);
    }
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.match.params.id !== this.props.match.params.id ) {
      this.fetchRepos(this.props.match.params.id);
    }
  }

  fetchRepos = (lang) => {
    this.setState({loading: true});
    this.props
      .fetchInitialData(lang)
      .then(data => {
        this.setState({ loading: false, repos: data });
      }).catch(() => {
        this.setState({loading: false});
      })
  }

  render() {
    const { repos, loading } = this.state

    if (loading) {
      return <div>LOADING</div>;
    }

    return (
      <ul style={{display: 'flex', flexWrap: 'wrap'}}>
        {repos.map(({ name, owner, stargazers_count, html_url }) => (
          <li key={name} style={{margin: 30}}>
            <ul>
              <li><a href={html_url}>{name}</a></li>
              <li>@{owner.login}</li>
              <li className="star">{stargazers_count} stars</li>
            </ul>
          </li>
        ))}
      </ul>
    )
  }
}

export default Popular;
  • 服务器渲染和浏览器渲染怎么保持一致
    首先当我们在浏览器中输入地址访问该页面时, 请求会先通过服务端, 由服务端获取初始化数据并生成html返回给浏览器, 然后浏览器解析html, 渲染页面, 并加载bundle.js, 当加载并解析完js后, 浏览器会执行ReactDOM.hydrate来渲染页面, 在这里我们访问的是Popular页面, 让我来给大家分析一下.
    第一步: 我们先是ReactDOM/Server.renderToString -> StaticRouter访问Popular.js, 在这一步中我们通过this.props.staticContext.data获取初始数据, 然后通过html模板占位符替换的方式返回给客户端html
    第二步: 客户端解析html, 渲染首屏, 并去加载html中的script并解析. 这时候问题来了, 当客户端执行Popular.js时, 由于它的执行环境已经是浏览器, 并且路由是BrowserRouter, 所以通过this.props.staticContext.data时页面会报错, 原因是客户端中props.staticContext为undefined. 所以这时候, 就用到了前文所讲的window.INITIAL_DATA 我们通过script将服务端获取的初始数据注入到全局变量中, 然后根据__isBrowser__字段来判断当前代码的执行环境, 来生成初始state.需要强调的是, 在这里我们用完即删, 不能污染全局变量
    ps: 我们可通过webpack.DefinePlugin注入__isBrowser__变量
    第三步: 加工componentDidMount, 因为访问该页面分为2中情况, 一种是用户直接在浏览器中输入该页面地址, 这时候会经过服务端渲染+浏览器加载解析的整个过程, 所以页面能拿到初始数据, 那么我们自然不需要重复请求数据. 第二种情况是我们通过React-Router api跳转到该页面, 这时候就没有初始数据里, 需要客户端重新请求数据.

  • 服务端渲染需要注意的一些细节:
    1. 怎么处理css/less/scss
    由于Node环境并不支持解析样式文件, 所以打包时会报错. 这里有2种解决方案
    1️⃣ 用isomorphic-style-loader替代style-loader, 大家可以去了解一下写法, 和css-module差不多, 可能会与部分团队的规范产生冲突, 所以不建议
    2️⃣ 在webpack配置文件中, 对serverConfig添加rules, 针对node环境忽略样式解析

{
  test: /\.(less|css|scss)$/,
  use: 'ignore-loader'
}

2. 在服务端返回的html字符串不建议手动拼写, 而是采用html模板+占位符, 在server/index.js中去读取html文件, 然后替换占位符的方式. 因为实际开发中通常会做分包处理, 提取出带有hash的vendors等文件, 我们会用到'html-webpack-plugin'来帮我们自动引入, 所以我推荐html模板, 而且使用''占位符即语义化, 又符合规范.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>SSR</title>
</head>
<body>
  <div id="root">
    <!-- HTML_PLACEHOLDER -->
    <!-- SCRIPT_PLACEHOLDER -->
  </div>
</body>
</html>

3. 在serverConfig中添加externals配置, 为了不把node_modules下的第三方模块打包进包里, 这里用到了webpack-node-externals插件

4. 因为在服务端渲染时, 会执行componentDidMount之前的生命周期, 所以也难免会用到window, document等浏览器中才有的全局变量, 所以这里我们需要加点hack

if (typeof global.window === 'undefined') {
  global.window = {};
}

5. 将fetch或者ajax发送请求改成isomorphic-fetch或者axios