返回介绍

七、服务器端渲染

发布于 2024-03-27 21:02:24 字数 10783 浏览 0 评论 0 收藏 0

7.1 基本套路

为什么要服务器端渲染

最近几年浏览器端框架很繁荣,以至于很多新入行的开发者只知道浏览器端渲染框架,都不知道存在服务器端渲染这回事,其实,网站应用最初全都是服务器端渲染,由服务器端用 PHP、Java 或者 Python 等其他语言产生 HTML 来给浏览器端解析。

相比于浏览器端渲染,服务器端渲染的好处是:

1、可以缩短第一有意义渲染时间(First-Meaningful-Paint-Time)。

如果完全依赖于浏览器端渲染,那么服务器端返回的 HTML 就是一个空荡荡的框架和对 JavaScript 的应用,然后浏览器下载 JavaScript,再根据 JavaScript 中的 AJAX 调用获取服务器端数据,再渲染出 DOM 来填充网页内容,总共需要三个 HTTP 或 HTTPS 请求。

如果使用服务器端渲染,第一个 HTTP/HTTPS 请求返回的 HTML 里就包含可以渲染的内容了,这样用户第一时间就会感觉到有东西画出来了,这样的感知性能更好。

2、更好的搜索引擎优化(Search-Engine-Optimization,SEO)

大部分网站都希望自己能够出现在搜索引擎的搜索页前列,这个前提就是网页内容要能够被搜索引擎的爬虫正确抓取到。虽然 Google 这样的搜索引擎已经可以检索浏览器端渲染的网页,但毕竟不是全部搜索引擎都能做到,如果搜索引擎的爬虫只能拿到服务器端渲染的内容,完全浏览器端渲染就行不通了。

即使对于 Google,网页性能也是搜索排名的重要指标,如果通过服务器端渲染提高网页性能,网页的排名更可能靠前。

上面两点,就是服务器端渲染的主要意义。

React 对服务器端渲染的支持

因为 React 是声明式框架,所以,在渲染上对服务器端渲染非常友好。

假设我们我们要渲染一个以 App 为最根节点的组件树,浏览器端渲染的代码如下:

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<App />, document.getElementById('root'));

现在我们想要在服务器端渲染 App,如果使用 React v16 之前的版本,代码是这样:

import React from 'react';
import ReactDOMServer from 'react-dom/server';

// 把产生 html 返回给浏览器端
const html = ReactDOMServer.renderToString(<Hello />);

从 React v16 开始,上面的服务器端代码依然可以使用,但是也可以把 renderToString 替换为 renderToNodeStream,代码如下:

import React from 'react';
import ReactDOMServer from 'react-dom/server';

// 把渲染内容以流的形式塞给 response
ReactDOMServer.renderToNodeStream(<Hello />).pipe(response);

此外,浏览器端代码也有一点变化,ReactDOM.render 依然可以使用,但是官方建议替换为 ReactDOM.hydrate,原来的 ReactDOM.render 将来会被废弃掉。

renderToString 的功能是一口气同步产生最终 HTML,如果 React 组件树很庞大,这样一个同步过程可能比较耗时。假设渲染完整 HTML 需要 500 毫秒,那么一个 HTTP/HTTPS 请求过来,500 毫秒之后才返回 HTML,显得不大合适,这也是为什么 v16 提供了 renderToNodeStream 这个新 API 的原因。

renderToNodeStream 把渲染结果以的形式塞给 response 对象(这里的 response 是 express 或者 koa 的概念),这意味着不用等到所有 HTML 都渲染出来了才给浏览器端返回结果,也许 10 毫秒内就渲染出来了网页头部,那就没必要等到 500 毫秒全部网页都出来了才推给浏览器,的作用就是有多少内容给多少内容,这样用户只需要 10 毫秒多一点的延迟就可以看到网页内容,进一步改进了第一有意义渲染时间

服务器端渲染的难点

看到这里,你可能觉得服务器端渲染也太简单了,的确,因为 React 组件可以不必关心自己是在哪个端渲染,可以做到代码一次编写,到处都可以执行。但是,真的这么简单吗?

为了简化问题,上面的代码示例有意忽略了一个事实,那就是,应用往往需要外部服务器获取数据啊!

除非你的网页应用根本没有动态内容,不然你必须要考虑在服务器端怎么给 React 组件获取数据。

比如,你现在看到的掘金小册,为了渲染你所看到的页面,需要调用掘金小册的服务器 API 来获取这篇文章的内容。对于浏览器端渲染,在 componentDidMount 里调用 AJAX 就好了;对于服务器端渲染,要想产生 HTML 的包含内容,必须事先把数据准备好,也就是说,代码要是这样才行:

import React from 'react';
import ReactDOMServer from 'react-dom/server';

callAPI().then(result => {
  const props = result;
  ReactDOMServer.renderToNodeStream(<Hello {...props}/>).pipe(response);
});

最大的问题来了,如何给组件获取和提供数据呢?

解决了这个问题,才算真的解决了服务器端渲染的问题。

脱水注水8

React 有一个特点,就是把内容展示和动态功能集中在一个组件中。比如,一个 Counter 组件既负责怎么画出内容,也要负责怎么响应按键点击,这当然符合软件高内聚性的原则,但是也给服务器端渲染带来更多的工作。

设想一下,如果只使用服务器端渲染,那么产生的只有 HTML,虽然能够让浏览器端画出内容,但是,没有 JavaScript 的辅助是无法响应用户交互事件的。对应 Counter 的例子,一个 Counter 组件在浏览器中也就渲染出一个数字两个按钮,用户点击 + 按钮或者 - 按钮,什么都不会发生。

很显然我们必须要在浏览器端赋予 Counter 组件一些神力,让它能够响应事件。那么怎么赋予 Counter 组件神力呢?其实我们已经做过这件事了,Counter 组件里面已经有对按钮事件的处理,我们所要做的只是让 Counter 组件在浏览器端重新执行一遍,也就是 mount 一遍就可以了。

也就是说,如果想要动态交互效果,使用 React 服务器端渲染,必须也配合使用浏览器端渲染。

现在问题变得更加有趣了,在服务器端我们给 Counter 一个初始值(这个值可以不是缺省的 0),让 Counter 渲染产生 HTML,这些 HTML 要传递给浏览器端,为了让 Counter 的 HTML起来点击相应事件,必须要在浏览器端重新渲染一遍 Counter 组件。在浏览器端渲染 Counter 之前,用户就可以看见 Counter 组件的内容,但是无法点击交互,要想点击交互,就必须要等到浏览器端也渲染一次 Counter 之后。

接下来的一个问题,如果服务器端塞给 Counter 的数据和浏览器端塞给 Counter 的数据不一样呢?

在 React v16 之前,React 在浏览器端渲染之后,会把内容和服务器端给的 HTML 做一个比对。如果完全一样,那最好,接着用服务器端 HTML 就好了;如果有一丁点不一样,就会立刻丢掉服务器端的 HTML,重新渲染浏览器端产生的内容,结果就是用户可以看到界面闪烁。因为 React 抛弃的是整个服务器端渲染内容,组件树越大,这个闪烁效果越明显。

React 在 v16 之后,做了一些改进,不再要求整个组件树两端渲染结果分毫不差,但是如果发生不一致,依然会抛弃局部服务器端渲染结果。

总之,如果用服务器端渲染,一定要让服务器端塞给 React 组件的数据和浏览器端一致。

为了达到这一目的,必须把传给 React 组件的数据给保留住,随着 HTML 一起传递给浏览器网页,这个过程,叫做脱水(Dehydrate);在浏览器端,就直接拿这个脱水数据来初始化 React 组件,这个过程叫注水(Hydrate)。

前面提到过 React v16 之后用 React.hydrate 替换 React.render,这个 hydrate 就是注水

总之,为了实现 React 的服务器端渲染,必须要处理好这两个问题:

  • 脱水
  • 注水

Facebook 未使用服务器端渲染

值得一提的是,虽然 React 从最初版本就支持服务器端渲染,并且 React 的创建者 Facebook 也全力在自己的网站产品中使用 React,但他们自己却没有使用 React 的服务器端渲染功能。理由是,Facebook 已经在 PHP 上投入了很多资源,不打算放弃这些投入。

这里我当然不是批评 Facebook,实际上,Facebook 对 React 的支持是真心的,它在自己的网站上大范围使用 React,而不只是做出来后让外部使用者当小白鼠,这种全力投入也给了 React 使用者很大信心。但另一方面,因为 Facebook 自己不用 React 的服务器端渲染,如何利用这个功能,就缺乏一个官方参考标准了。

也许就是因为缺乏 Facebook 的官方标准,业界对服务器端渲染的解决方法层出不穷,不过,到目前看来,next.js 还是最佳方案

7.2 理解 Next.js

我们已经知道了服务器端渲染的原理,你只需要搭建一个 Express 服务器,在服务器端手工打造『脱水』,在浏览器端做『注水』,完成某个页面的服务器端渲染并不难。

不过,服务器端渲染的问题并不这么简单,一个最直接的问题,就是怎么处理多个页面的『单页应用』(Single-Page-Application)?

所以单页应用,就是虽然用户感觉有多个页面,但是实现上只有一个页面,用户感觉到页面可以来回切换,但其实只是一个页面并没有完全刷新,只是局部界面更新而已。

假设一个单页应用有三个页面 Home、Prodcut 和 About,分别对应的的路径是 /home、/product 和 /about,而且三个页面都依赖于 API 调用来获取外部数据。

现在我们要做服务器端渲染,如果只考虑用户直接在地址栏输入 /home、/product 和 /about 的场景,很容易满足,按照上面说的套路做就是了。但是,这是一个单页应用,用户可以在 Home 页面点击链接无缝切换到 Product,这时候 Product 要做完全的浏览器端渲染。换句话说,每个页面都需要既支持服务器端渲染,又支持完全的浏览器端渲染,更重要的是,对于开发者来说,肯定不希望为了这个页面实现两套程序,所以必须有同时满足服务器端渲染和浏览器端渲染的代码表示方式。

读者可以思考一下什么样的代码表示合适,也可以直接往下,看看业界公认最科学的实现方式 Next.js 是如何做的。

快速创建 Next.js 项目

在说明 Next.js 的工作原理之前,我们先看怎么快速创建 Next.js 项目,这个问题用代码来说明会更顺畅。

我们也可以手工创建 Next.js 项目,不过更简单的方式是用自动化工具 create-next-app,这个 create-next-app 类似于 create-react-app,一个命令就创建一个可以运行的应用。

首先安装 create-next-app。

npm install -g create-next-app

然后,就可以在你专门存放项目的目录下执行 create-next-app,产生一个使用 Next.js 的 React 应用,下面的命令创建一个叫 next_demo 的应用:

create-next-app next_demo

进入新生成的项目目录 next_demo 里检查一下,可以看到文件结构非常简洁,pages 目录下是页面文件,package.json 中差不是下面这样,没有繁冗的 webpack 和 babel 依赖包,因为一切都被 Next.js 封装起来了

{
  "name": "create-next-example-app",
  "scripts": {
  "dev": "next",
  "build": "next build",
  "start": "next start"
  },
  "dependencies": {
  "next": "^6.0.3",
  "react": "^16.5.2",
  "react-dom": "^16.5.2"
  }
}

虽然有不少框架都表示自己的功能很强大,但其中有很多框架的设计并不中立,用这些框架去开发某些特定应用或许还行,如果放到一个更大范围的应用类型中,就会发现无法满足要求,这样的框架通用性不足,开发者一定要谨慎使用。

讲良心话,Next.js 真的是一个通用性非常高的框架,因为 Next.js 完全遵从了 React 的技术哲学:一切皆为组件。

在 Next.js 中,创造一个页面,其实就是创造一个 React 组件,接下来我们看看如何创建一个页面。

编写页面

使用下面的命令启动 Next.js 应用,进入的是开发者模式,这时候对代码的改变,会立刻体现在网页上。

npm run dev

请注意,这一点上 Next.js 的习惯用法和 create-react-app 产生的应用不一样。在 create-react-app 产生的应用中, npm run start 启动是开发者模式,但在 Next.js 应用中,习惯上 npm run start 以产品模式启动,所以要先运行 npm run build 然后才能运行 npm run start。

Next.js 遵从『协定优于配置』(convention over configuration)的设计原则,根据『协定』,在 pages 中每个文件对应一个网页文件,文件名对应的就是网页的路径名,比如 pages/home.js 文件对应的就是 /home 路径的页面,当然 pages/index.js 比较特殊,对应的是默认根路径 / 的页面。

我们修改 pages/index.js,让它更简单一些,如下:

import React from 'react'

const Home = (props) => (
  <h1>
  Hello World
  </h1>
)

export default Home

这样会在页面上显示出一个 Hello World,而这个页面代码就是一个普通的 React 组件而已。

页面都是 React 组件,这就是 Next.js 的哲学。

getInitialProps

我们还是要回到本来的话题,如何优雅地实现服务器端渲染,上面的 Home 页面虽然能够渲染出完整包含 Hello World 的 HTML,但是并没有调用任何外部 API 资源,所以也没有异步操作,并不能体现服务器端渲染的难度。

我们用一个函数来实现异步操作,以此模拟调用 API 的延迟效果,如下:

const timeout = (ms, result) => {
  return new Promise(resolve => setTimeout(() => resolve(result), ms));
};

然后,我们利用这个 timeout 来获得展示网页所需的数据。比如说,获取用户名,那么我们的 Home 组件就要换一个写法,像下面那样,增加 getInitialProps 的定义:

const Home = (props) => (
  <h1>
  Hello {props.userName}
  </h1>
)

Home.getInitialProps = async () => {
  return await timeout(200, {userName: 'Morgan'});
};

这个 getiInitialProps 是 Next.js 最伟大的发明,它确定了一个规范,一个页面组件只要把访问 API 外部资源的代码放在 getInitialProps 中就足够,其余的不用管,Next.js 自然会在服务器端或者浏览器端调用 getInitialProps 来获取外部资源,并把外部资源以 props 的方式传递给页面组件。

注意 getInitialProps 是页面组件的静态成员函数,可以用下面的方法定义:

Home.getInitialProps = async () = {...};

也可以在组件类中加上 static 关键字定义:

class Home extends React.Component {
  static async getInitialProps() {
   ...
  }
}

通过上面的代码,我么也可以注意到,getInitialProps 是一个 async 函数,所以,在 getInitialProps 函数中可以使用 await 关键字,用同步的方式编写异步逻辑。

我们可以这样来看待 getInitialProps,它就是 Next.js 对代表页面的 React 组件生命周期的扩充。React 组件的生命周期函数缺乏对异步操作的支持,所以 Next.js 干脆定义出一个新的生命周期函数 getInitialProps,在调用 React 原生的所有生命周期函数之前,Next.js 会调用 getInitialProps 来获取数据,然后把获得数据作为 props 来启动 React 组件的原生生命周期过程。

这个生命周期函数的扩充十分巧妙,因为:

  • 没有侵入 React 原生生命周期函数,以前的 React 组件该怎么写还是怎么写;
  • getInitialProps 只负责获取数据的过程,开发者不用操心什么时候调用 getInitialProps,依然是 React 哲学的声明式编程方式;
  • getInitialProps 是 async 函数,可以利用 JavaScript 语言的新特性,用同步的方式实现异步功能。

Next.js 的脱水注水

我们说过服务器端渲染的关键是如何脱水注水,如果你对 Next.js 如何实现这两个关键点好奇(实际上你确实应该感到好奇),那么在浏览器中使用显示网页源代码就可以让你一目了然。

在网页的 HTML 中,可以看到类似下面的内容:

<script>
  __NEXT_DATA__ = {
  "props":{
    "pageProps": {"userName":"Morgan"}},
    "page":"/","pathname":"/","query":{},"buildId":"-","assetPrefix":"","nextExport":false,"err":null,"chunks":[]}
</script>

Next.js 在做服务器端渲染的时候,页面对应的 React 组件的 getInitialProps 函数被调用,异步结果就是脱水数据的重要部分,除了传给页面 React 组件完成渲染,还放在内嵌 script 的 NEXT_DATA 中,这样,在浏览器端渲染的时候,是不会去调用 getInitialProps 的,直接通过 NEXT_DATA 中的脱水数据来启动页面 React 组件的渲染。

这样一来,如果 getInitialProps 中有调用 API 的异步操作,只在服务器端做一次,浏览器端就不用做了。

那么,getInitialProps 什么时候会在浏览器端调用呢?

当在单页应用中做页面切换的时候,比如从 Home 页切换到 Product 页,这时候完全和服务器端没关系,只能靠浏览器端自己了,Product 页面的 getInitialProps 函数就会在浏览器端被调用,得到的数据用来开启页面的 React 原生生命周期过程。

关键点是,浏览器可能会直接访问 /home 或者 /product,也可能通过网页切换访问这两个页面,也就是说 Home 或者 Product 都可能被服务器端渲染,也可能完全只有浏览器端渲染,不过,这对应用开发者来说无所谓,应用开发者只要写好 getInitialProps,至于调用 getInitialProps 的时机,交给 Next.js 处理就好了。

你可以发明自己的服务器端框架,但很可能最后你发现,如果要做得通用性好,最后都会做到和 Next.js 一样的模式上来。

值得一提的是,getInitialProps 返回的应该是纯数据,也就是不要返回一个定制类的实例。比如,有一个类 Foo 有一个成员函数 bar,不要在 getInitialProps 返回一个 Foo 实例。不然,经过脱水注水过程,网页组件获得的那个Foo 实例不再是你想的那个 Foo 实例了,它变成了一个纯粹的数据,不会包含成员函数 bar 的。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文