React SSR 详解【近 1W 字】+ 2个项目实战

38,029 阅读10分钟

CSR & SSR

客户端渲染(Client Side Rendering)

  • CSR 渲染流程:
    React CSR.png

服务端渲染(Server Side Rendering)

  • 是指将单页应用(SPA)在服务器端渲染成 HTML 片段,发送到浏览器,然后交由浏览器为其绑定状态与事件,成为完全可交互页面的过程。(PS:本文中的 SSR 内容都是围绕同构应用来讲的
  • SSR 渲染流程:

React SSR.jpg

  • 服务端只负责首次“渲染”(真正意义上,只有浏览器才能渲染页面,服务端其实是生成 HTML 内容),然后返回给客户端,客户端接管页面交互(事件绑定等逻辑),之后客户端路由切换时,直接通过 JS 代码来显示对应的内容,不再需要服务端渲染(只有页面刷新时会需要)

为什么要用 SSR

优点:

  • 更快的首屏加载速度:无需等待 JavaScript 完成下载且执行才显示内容,更快速地看到完整渲染的页面,有更好的用户体验。
  • 更友好的 SEO
    • 爬虫可以直接抓取渲染之后的页面,CSR 首次返回的 HTML 文档中,是空节点(root),不包含内容,爬虫就无法分析你的网站有什么内容,所以就无法给你好的排名。而 SSR 返回渲染之后的 HTML 片段,内容完整,所以能更好地被爬虫分析与索引。


  • 基于旧版本的搜索引擎:我们会给 html 加 title 和 description 来做简单的 seo 优化,这两个本质上并不会提高搜索的排名,而是提高网站转化率。给网站提供更多的描述,让用户有点击的欲望,从而提高排名。
<title>首页标题</title>
<meta name="description" content="首页描述"></meta>
  • 基于新版本的搜索引擎(全文搜索):想要光靠上面两个来给网站有个好的排名是不行的,所以需要 SSR 来提供更多的网站内容。

缺点:

  • 对服务器性能消耗较高
  • 项目复杂度变高,出问题需要在前端、node、后端三者之间找
  • 需要考虑 SSR 机器的运维、申请、扩容,增加了运维成本(可以通过 Serverless 解决)

什么是同构应用

  • 一套代码既可以在服务端运行又可以在客户端运行,这就是同构应用。
  • 在服务器上生成渲染内容,让用户尽早看到有信息的页面。一个完整的应用除包括纯粹的静态内容以外,还包括各种事件响应、用户交互等。这就意味着在浏览器端一定还要执行 JavaScript 脚本,以完成绑定事件、处理异步交互等工作。
  • 从性能及用户体验上来看,服务端渲染应该表达出页面最主要、最核心、最基本的信息;而浏览器端则需要针对交互完成进一步的页面渲染、事件绑定等增强功能。所谓同构,就是指前后端共用一套代码或逻辑,而在这套代码或逻辑中,理想的状况是在浏览器端进一步渲染的过程中,判断已有的 DOM 结构和即将渲染出的结构是否相同,若相同,则不重新渲染 DOM 结构,只需要进行事件绑定即可。
  • 从这个维度上讲,同构和服务端渲染又有所区别,同构更像是服务端渲染和浏览器端渲染的交集,它弥补了服务端和浏览器端的差异,从而使得同一套代码或逻辑得以统一运行。同构的核心是“同一套代码”,这是脱离于两端角度的另一个维度。

手动搭建一个 SSR 框架

使用 Next.js(成熟的 SSR 框架)

  • 这里只是展示了一些值得注意的知识点以及自己的心得,更多细节请看 官方文档 中文文档

安装

npx create-next-app project-name

查看 package.json

{
  "name": "next-demo-one",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    // 默认端口 3000,想要修改端口用 -p  
    "dev": "next dev -p 4000",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "9.1.4",
    "react": "16.12.0",
    "react-dom": "16.12.0"
  }
}

Head

  • next/head 的作用就是给每个页面设置 <head> 标签的内容,相当于 react-helmet
import Head from 'next/head'

export default () =>
  <div>
    <Head>
      <title>My page title</title>
      <meta name="viewport" content="initial-scale=1.0, width=device-width" />
    </Head>
    <p>Hello world!</p>
  </div>

getInitialProps

  • Next.js 有一套自己的获取数据的规范,数据请求需要放在 getInitialProps 内部,而不是放在组件的生命周期里,需要遵循它的规范。
  • getInitialProps 入参对象的属性如下:
    • pathname - URL 的 path 部分
    • query - URL 的 query 部分,并被解析成对象
    • asPath - 显示在浏览器中的实际路径(包含查询部分),为 String 类型
    • req - HTTP 请求对象 (只有服务器端有)
    • res - HTTP 返回对象 (只有服务器端有)
    • jsonPageRes - 获取数据响应对象 (只有客户端有)
    • err - 渲染过程中的任何错误
  • 当页面初始化加载时,getInitialProps 只会在服务端被调用。只有当路由跳转(Link 组件跳转或 API 方法跳转)时,客户端才会执行 getInitialProps在线demo
  • 只有放在 pages 目录下的组件,它的 getInitialProps 才会被调用,子组件使用 getInitialProps 是无效的
    • 因为 pages 目录下的组件都默认是一个路由组件,只有路由组件才会被处理。Next.js 会先调用路由组件上的 getInitialProps 方法,获取返回的数据作为 props 传入到该路由组件中,最后渲染该路由组件。在线demo
    • 子组件想要获取数据,最直接的方法如下:
function PageA(props){
    const {childOneData,childTwoData} = props;
    return <div>
        <ChildOne childOneData/>
        <ChildTwo childTwoData/>
    </div>;
}
PageA.getInitialProps = async ()=>{
    // 在父组件中的 getInitialProps 方法里,调用接口获取子组件所需要的数据
    const childOneData = await getPageAChildOneData();
    const childTwoData = await getPageAChildTwoData();
    return {childOneData, childTwoData}
};
  • 当一个页面结构复杂,多个子组件需要同时请求数据或者子组件需要动态加载时,以上的方案可能就不太适合了。千万不要想着在子组件的生命周期中去请求数据,要遵守 Next.js 的规范。比较好的方法是:将这些子组件拆分一个个子路由,作为路由组件就能调用 getInitialProps 方法获取数据

路由

  • 约定式路由
    • 默认在 pages 目录下的 .js 文件都是一级路由
    • 如果要使用二级路由,就在 pages 目录新建一个文件夹

image.png

  • Next.js 中的 Link 组件,默认不会渲染出任何内容(如 a 标签),需要指定渲染内容,并且内部必须有一个顶层元素,不能同时出现两个兄弟元素。它只是监听了我们指定内容的 click 事件,然后跳转到指定的路径
import Link from 'next/link'
const Index = () => {
  return (
    <>
        <Link href="/a?id=1">
            <div>
                <Button>AAA</Button>
                <Button>BBB</Button>
            </div>
        </Link>
    </>
  )
};
  • Next.js 中的路由是通过约定文件目录结构来生成的,所以无法定义 params动态路由只能通过 query 实现
import Router from 'next/router'
import Link from 'next/link'

const Index = () => {
  // 通过 API 跳转
  function gotoTestB() {
    Router.push(
      {
        pathname: '/test/b',
        query: {
          id: 2,
        },
      }
    )
  }
  return (
    <>
        <Link href="/test/b?id=1" >
                <Button>BBB</Button>
        </Link>
    </>
  )
};
  • 如果想要浏览器中的路由更好看些(如:/test/id,而不是 /test?id=123456),可以用路由映射
import Router from 'next/router'
import Link from 'next/link'

const Index = () => {
  // 通过 API 跳转
  function gotoTestB() {
    Router.push(
      {
        pathname: '/test/b',
        query: {
          id: 2,
        },
      },
      '/test/b/2',
    )
  }
  return (
    <>
        <Link href="/test/b?id=1" as="/test/b/1" >
            <div>
                <Button>BBB</Button>
            </div>
        </Link>
    </>
  )
};
  • 但是以上页面刷新的时候,页面会 404 ,因为是 SPA 应用,前端改变浏览器路由可以不刷新页面,但是在刷新页面,重新请求该路由对应的文件时,服务端找不到该路径对应的文件。所以需要借助 Node 框架(如:Koa2 )来替代 Next.js 默认自带的 server
const Koa = require('koa');
const Router = require('koa-router');
const next = require('next');
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const server = new Koa();
  const router = new Router();
  
  router.get('/a/:id', async ctx => {
    const id = ctx.params.id;
    await handle(ctx.req, ctx.res, {
      pathname: '/a',
      query: { id },
    });
  });
  
  server.listen(3000, () => {
    console.log('koa server listening on 3000')
  });
}

  • 路由拦截器
import Router from 'next/router'

Router.beforePopState(({ url, as, options }) => {
  // I only want to allow these two routes!
  if (as !== "/" || as !== "/other") {
    // Have SSR render bad routes as a 404.
    window.location.href = as
    // 返回 false,Router 将不会执行 popstate 事件
    return false
  }

  return true
});

  • 路由事件
    • routeChangeStart(url) - 路由开始切换时触发
    • routeChangeComplete(url) - 完成路由切换时触发
    • routeChangeError(err, url) - 路由切换报错时触发
    • beforeHistoryChange(url) - 浏览器 history 模式开始切换时触发
    • hashChangeStart(url) - 开始切换 hash 值但是没有切换页面路由时触发
    • hashChangeComplete(url) - 完成切换 hash 值但是没有切换页面路由时触发
    • 这里的 url 是指显示在浏览器中的 url。如果你使用了路由映射,那浏览器中的 url 将会显示 as 的值
import React from 'react';
import Router from 'next/router'

class User extends React.Component {

    handleRouteChange = url => {
        console.log('url=> ', url);
    };

    componentDidMount() {
        Router.events.on('routeChangeStart', (res) => {
            console.log(res);
        });
        Router.events.on('routeChangeComplete', (res) => {
            console.log(res);
        });
        Router.events.on('routeChangeError', (res) => {
            console.log(res);
        });
    }

    componentWillUnmount() {
        Router.events.off('routeChangeStart', (res) => {
            console.log(res);
        });
        Router.events.off('routeChangeComplete', (res) => {
            console.log(res);
        });
        Router.events.off('routeChangeError', (res) => {
            console.log(res);
        });
    }

    render() {
        return <div>User </div>;
    }
}

style jsx

  • Next.js 中有各种 CSS 解决方案,默认集成了 styled-jsx
const A = ({ router, name}) => {
  return (
    <>
      <Link href="#aaa">
        <a className="link">
          A {router.query.id} {name} 
        </a>
      </Link>
      <style jsx>{`
        a {
          color: blue;
        }
        .link {
          color: ${color};
        }
      `}</style>
    </>
  )
};

动态加载资源 & 组件

import { withRouter } from 'next/router'
import dynamic from 'next/dynamic'
import Link from 'next/link'

const LazyComp = dynamic(import('../components/lazy-comp'));

const A = ({time }) => {

  return (
    <>
      <div>Time:{time}</div>
      <LazyComp />
    </>
  )
};

A.getInitialProps = async ctx => {
  // 动态加载 moment,只有到了当前页面的时候才去加载它,而不是在页面初始化的时候去加载
  const moment = await import('moment');

  const promise = new Promise(resolve => {
    setTimeout(() => {
      resolve({
        name: 'jokcy',
        // 默认加载的是 ES6 模块
        time: moment.default(Date.now() - 60 * 1000).fromNow(),
      })
    }, 1000)
  });

  return await promise
};

export default A;

_app.js

  • 新建 ./pages/_app.js 文件,自定义 App 模块
  • 自定义 Next.js 中的 ,可以有如下好处:
    • 实现各个页面通用的布局 —— Layout
    • 当路由变化时,保持一些公用的状态(使用 redux)
    • 给页面传入一些自定义的数据
    • 使用 componentDidCatch 自定义处理错误
// lib/my-context
import React from 'react'
export default React.createContext('')

// components/Layout
// 固定布局
    xxx
    xxx
    xxx

// _app.js
import 'antd/dist/antd.css';
import App, { Container } from 'next/app';
import Layout from '../components/Layout'
import MyContext from '../lib/my-context'
import {Provider} from 'react-redux'

class MyApp extends App {
  state = {
    context: 'value',
  };

  /**
   * 重写 getInitialProps 方法
   */
  static async getInitialProps(ctx) {
    const {Component} = ctx;
    // 每次页面切换的时候,这个方法都会被执行!!!
    console.log('app init');
    let pageProps = {};
    // 因为如果不加 _app.js,默认情况下,Next.js 会执行 App.getInitialProps
    // 所以重写 getInitialProps 方法时,路由组件的 getInitialProps 必须要执行
    if (Component.getInitialProps) {
      pageProps = await Component.getInitialProps(ctx)
    }
    return {
      pageProps
    }
  }

  render() {
    const { Component, pageProps, reduxStore } = this.props;

    return (
      // 在最新的 Next.js 版本中,Container 被移除了,不再需要 Container 包裹组件
      // https://github.com/zeit/next.js/blob/master/errors/app-container-deprecated.md
      <Container>
        <Layout>
            <MyContext.Provider value={this.state.context}>
              <Component {...pageProps} />
            </MyContext.Provider>
        </Layout>
      </Container>
    )
  }
}
export default MyApp;

_document.js

  • 只有在服务端渲染的时候才会被调用,客户端是不会执行的
  • 用来修改服务端渲染的文档内容
  • 一般配合第三方 css-in-js 方案使用,如 styled-components
import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
    // 重写 getInitialProps 方法
    static async getInitialProps(ctx) {
        // 因为如果不加 _document.js,默认情况下,Next.js 会执行 Document.getInitialProps
        // 所以自定义的时候,必须执行 Document.getInitialProps
        const props = await Document.getInitialProps(ctx);
        return {
            ...props
        }
    }

    // render 要么不重写,重写的话,以下的内容都必须加上
    // render() {
    //   return (
    //     <Html>
    //       <Head>
    //           <style>{`body { background:red;} /* custom! */`}</style>
    //       </Head>
    //       <body className="custom_class">
    //         <Main />
    //         <NextScript />
    //       </body>
    //     </Html>
    //   )
    // }
}

export default MyDocument

内部集成 Webpack

  • Next.js 内部集成了 Webpack,开箱即用
  • 生成环境下默认会分割代码和 tree-shaking

集成 Redux

在线demo

渲染流程

Next.js 渲染流程 (1).jpg

服务端执行顺序

在线demo

  1. _app getInitialProps()
  2. page getInitialProps()
  3. _document getInitialProps()
  4. _app constructor()
  5. _app render()
  6. page constructor()
  7. page render()
  8. _document constructor()
  9. _document render()

page 表示路由组件

客户端执行顺序(首次打开页面)

  1. _app constructor()
  2. _app render()
  3. page constructor()
  4. page render()

注意: 当页面初始化加载时,getInitialProps 只会在服务端被调用。只有当路由跳转( Link 组件跳转或 API 方法跳转)时,客户端才会执行 getInitialProps

路由跳转执行顺序

  1. _app getInitialProps()
  2. page getInitialProps()
  3. _app render()
  4. page constructor()
  5. page render()

使用 Next.js 的优缺点

优点:

  • 轻量易用,学习成本低,开箱即用(如:内部集成 Webpack、约定式路由等),不需要自己去折腾搭建项目。个人看法:是一个用自由度来换取易用性的框架。
  • 自带数据同步策略,解决服务端渲染最大难点。把服务端渲染好的数据,拿到客户端重用,这个在没有框架的时候,是非常麻烦的。
  • 拥有丰富的插件,让我们可以在使用的时候按需使用。
  • 配置灵活:可以根据项目要求的不同快速灵活的进行配置。

缺点: 必须遵循它的规范(如:必须在 getInitialProps 中获取数据),写法固定,不利于拓展。

展望 Serverless

  • Serverless —— 无服务架构
  • Serverless 不代表再也不需要服务器了,而是说:开发者再也不用过多考虑服务器的问题,计算资源作为服务而不是服务器的概念出现
  • Serverless 肯定会火,前端可以不考虑部署、运维、环境等场景,直接编写函数来实现后端逻辑,对生产力上有着显著的提升
  • 有了 Serverless ,之后的 SSR 可以称为 Serverless Side Rendering
  • 因为对 Serverless 不是很了解,只知道它的概念以及带来的影响是什么,所以不敢过多妄言,有兴趣的同学可以自行了解

看懂 Serverless,这一篇就够了
理解serverless无服务架构原理(一)
什么是Serverless无服务器架构?

常见问题

客户端需要使用 ReactDOM.hydrate 代替 ReactDOM.render ,完成 SSR 未完成的事情(如:事件绑定)

  • 在 React v15 版本里,ReactDOM.render 方法会根据 data-react-checksum 的标记,复用 ReactDOMServer 的渲染结果,不重复渲染。根据 data-reactid 属性,找到需要绑定的事件元素,进行事件绑定的处理。
  • 在 React v16 版本里,ReactDOMServer 渲染的内容不再带有 data-react 属性,ReactDOM.render 可以使用但是会报警告。
  • 在 React v17 版本里,ReactDOM.render 将不再具有复用 SSR 内容的功能,统一用 hydrate() 来进行服务端渲染。
  • 因为服务端返回的 HTML 是字符串,虽然有内容,但是各个组件没有事件,客户端的仓库中也没有数据,可以看做是干瘪的字符串。客户端会根据这些字符串完成 React 的初始化工作,比如创建组件实例、绑定事件、初始化仓库数据等。hydrate 在这个过程中起到了非常重要的作用,俗称“注水”,可以理解为给干瘪的种子注入水分,使其更具生机。
  • 在使用 Next.js 时, 打开浏览器控制台 => 找到 network => 找到当前路由的请求并查看 response => 可以看到服务端返回的 html 里包含着当前页面需要的数据,这样客户端就不会重新发起请求了,靠的就是 ReactDOM.hydrate

image.png

SSR 需要使用 StaticRouter(静态路由容器),而非 BrowserRouterHashRouter

客户端和服务端都需要配置 store 仓库,但是两个仓库会不大一样

componentDidMount 在服务器端是不执行的,而 componentWillMount 在客户端和服务端都会执行,所以这就是为什么不建议在 componentWillMount 发送请求的原因

注册事件必须要放在 componentDidMount 中,不能放在 componentWillMount 中,因为 服务端是不会执行 componentWillUnmount 的,如果放在 componentWillMount 中,会导致事件重复注册,发生内存泄漏

如果不想使用 SSR,但是又想要优化 SEO ,可以使用 prerender 或者 prerender-spa-plugin 来替代 SSR

在手动搭建 SSR 框架时:使用 npm-run-all & nodemon 来提高开发 Node 项目的效率

  • nodemon 监听代码文件的变动,当代码改变之后,自动重启
  • npm-run-all 用于并行或者顺序运行多个 npm 脚本的 cli 工具
npm install npm-run-all nodemon --save-dev
 "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:start": "nodemon build/server.js",
    "dev:build:client": "webpack --config webpack.client.js --watch",
    "dev:build:server": "webpack --config webpack.server.js --watch"
 }

在 Next.js 中:默认会引入 import React from "react",但是如果不引入,在写组件时,编辑器会发出警告,所以还是引入下较好

在 Next.js 中:会对 pages 目录下的每个路由组件分开打包,所以当点击按钮进行路由跳转时,并不会马上跳转到对应的路由页面,而是要先加载好目标路由的资源文件,然后再跳转过去。这个可以用预加载优化。

在 Next.js 中:内部集成了 Webpack,生成环境下默认会分割代码和 tree-shaking

Next.js 适用于任何 node 框架,但是这些框架的对于 requestresponse 的封装方式肯定有不同之处,它是如何保证 Next.js 导出的 handle 方法能兼容这些框架尼?

  • 保证 handle 方法接收到的是 NodeJS 原生的requset 对象以及 response 对象,不是框架基于原生封装的 requestresponse 对象。所以这就是为什么在使用 koa 时,handle 接收的是 ctx.reqctx.res ,而不是 ctx.requestctx.response 的原因。

在 Next.js 中:如何集成 styled-components

  • 需要在 _document.js 中集成
  • 利用 AOP 面向切面编程思想
cnpm i styled-components babel-plugin-styled-components -D

// .babelrc
{
  "presets": ["next/babel"],
  "plugins": [
    [
      "import",
      {
        "libraryName": "antd"
      }
    ],
    ["styled-components", { "ssr": true }]
  ]
}

// _document.js
import Docuemnt, { Html, Head, Main, NextScript } from 'next/document'
import { ServerStyleSheet } from 'styled-components'

function withLog(Comp) {
  return props => {
    console.log(props);
    return <Comp {...props} />
  }
}

class MyDocument extends Docuemnt {
  
  static async getInitialProps(ctx) {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          // 增强 APP 功能
          enhanceApp: App => props => sheet.collectStyles(<App {...props} />),
          // 增强组件功能
          // enhanceComponent: Component => withLog(Component)
        });

      const props = await Docuemnt.getInitialProps(ctx);

      return {
        ...props,
        styles: (
          <>
            {props.styles}
            {sheet.getStyleElement()}
          </>
        ),
      }
    } finally {
      sheet.seal()
    }
  }

  render() {
    return (
      <Html>
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

// pages/a.js
import { withRouter } from 'next/router'
import Link from 'next/link'
import styled from 'styled-components'

const Title = styled.h1`
  color: yellow;
  font-size: 40px;
`;

const color = '#113366';

const A = ({ router, name}) => {
  return (
    <>
      <Title>This is Title</Title>
      <Comp />
      <Link href="#aaa">
        <a className="link">
          A {router.query.id} {name} 
        </a>
      </Link>
      <style jsx>{`
        a {
          color: blue;
        }
        .link {
          color: ${color};
        }
      `}</style>
    </>
  )
};
export default withRouter(A)

在 Next.js 中:如何集成 CSS / Sass / Less / Stylus

支持用 .css.scss.less.styl,需要配置默认文件 next.config.js,具体可查看下面链接

在 Next.js 中:打包的时候无法按需加载 Antd 样式

www.cnblogs.com/1wen/p/1079…

www.jianshu.com/p/2f9f3e41c…

在 Next.js 中:不要自定义静态文件夹的名字

在根目录下新建文件夹叫 static,代码可以通过 /static/ 来引入相关的静态资源。但只能叫static ,因为只有这个名字 Next.js 才会把它当作静态资源。

在 Next.js 中:为什么打开应用的速度会很慢

  • 可能将只有服务端用到的模块放到了 getInitialProps 中,然后 Webpack 把该模块也打包了。可参考 import them properly

Next.js 常见错误列表

后语

  • 本文只是基于我的理解写的,如有错误的理解还请指正或者更好的方案还请提出
  • 为了写的尽量详细点,前前后后花了两个月的时间才整理出了这篇文章,看到这里,如果觉得这篇文章还不错,还请点个赞~~

项目地址

手动搭建简易版 SSR 框架

React16.8 + Next.js + Koa2 开发 Github 全栈项目

参考

淘宝前后端分离实践 !!!!!!

UmiJS SSR

为什么将 react 项目做成 ssr 同构应用

揭秘 React 同构应用

打造高可靠与高性能的 React 同构解决方案

慕课网 Next.js 教程

推荐阅读

你真的了解 React 生命周期吗

React Hooks 详解 【近 1W 字】+ 项目实战

Webpack 设置环境变量的误区

从 0 到 1 实现一款简易版 Webpack