概述

技术方案的存在都是为了解决问题,JSP 方案因为一些问题被前后端分离的整站 Ajax 方案代替,整站 Ajax 又因为一些问题可能要被 SSR 代替。

关于前后端分离的话题这里不展开,这一趴重点谈谈整站 Ajax 有什么问题,SSR 又是怎么解决的。

为了更好地理解,建议先用脚手架创建项目:

vue init nuxt-community/starter-template <project-name>

SEO

“搜索引擎优化” 简称 “SEO(Search Engine Optimization)”,互联网中信息比较分散,让潜在客户快速找到自己是每个公司必然要面对的问题,这其中通过搜索引擎导流是一个非常重要的手段。但是整站 Ajax 对搜索引擎非常不友好,所以在一些不关心搜索引擎友好性的场景下整站 Ajax 方案使用较为广泛,比如企业的后台系统。在尝过了前后端分离的整站Ajax 方案后,从管理到一线开发被其开发效率的提升和清晰的结构所吸引,那有没有什么方案能够享受前后端分离的优点又兼顾SEO呢?

【SSR 前传】

经过一段时间的探索 SSR 方案脱颖而出,SSR 之前的方案这里简单的提几个:

开发分离发布合并,在淘宝用的比较多,将商品详情页生成为静态文件,这种方案能满足需求,但缺点是需要做一些其他工作,比如价格更新库存更新以及购买人数的更新都需要另外一套代码逻辑来处理。

为搜索引擎和真实用户准备两套呈现逻辑,通过 HTML5 History 实现资源的统一,通过 noscript 分流机器和人,缺点是页面不能有复杂的交互,因为缺少数据和页面事件的处理机制。

【SSR 同构】

SSR 通过同构的方法解决了上面问题。我们先说一下 SSR 的具体表现,比如我们现在有一个列表页,列表中每一行对应一个详情页,那么如果直接用浏览器访问列表页时,服务器返回数据和 html 融合后的页面,浏览器拿到页面直接渲染,这就省去了先请求 js 再由 js 发起数据请求的过程,页面渲染的同时请求js,js加载完成后绑定事件;从列表页中点击某一条到详情页的时候,和普通的全栈 Ajax 一样,先请求 js 再由 js 发起数据请求,然后填充数据渲染页面。如果将详情页的链接复制出来,直接在新浏览中访问,那么详情页会直接返回数据和 html 融合后的页面,渲染的同时请求详情页 js,最后再绑定事件。这个“服务器端拼接 html 和 html 是由同样的页面和组件完成的,这种前后端采用同样的结构在不同的环境中产出同样的 html 的方案称之为“同构”。

下面分两个层面说说怎么实现的:

第一个是路由层面,那么路由配置在前端还是后端呢?Vue 的 SSR 框架 nuxt 使用了 page 下的资源路径作为路由,比如下面这样的路径:

└── pages
    ├── users
       ├── index.vue
       └── _id.vue

会转换成下面这样的前端路由:

routes: [
    {
      name: 'users',
      path: '/',
      component: 'pages/users/index.vue'
    },
    {
      name: 'users-id',
      path: '/users/:id?',
      component: 'pages/users/_id.vue'
    }
]

同时会生成相应的后端路由文件 -- build/main.js,并且将文件夹的分析结果写入内存,然后注入 web 容器中:

const app = express();
app.use(nuxt.render);

所以需要注意后端的数据接口和页面接口一定不能相同,比较好的办法是给数据接口加统一的前缀(比如官方示例给的方案: /api )。

第二个是拼 html 层面,nuxt.config.js 代替了原来的 index.html,同时 webpack 中的一些配置也移到了这个文件中,然后 layouts 下放全局的导航和版权之类的信息,<nuxt/> 作为同构的标识点,nuxt 内部是这样处理的:

const renderer = require('vue-server-renderer').createRenderer();
// 定义一个 vue 组件
const app = new Vue({
  // 读取数据库数据
  async asyncData () {
    let { data } = await axios.get('/api/users')
    return { users: data }
  },
  data: {
    url: req.url
  },
  template: `<div>Hello world.</div>`
});
renderer.renderToString(app, (err, html) => {
  // 其中的 html 就是我们页面需要的 html 片段
  // 其中不包含公共部分,将上面的 html 片段放在页面中是由 nuxt 来做的
});

在服务端, nuxt 会先执行 asyncData 函数,然后将拿到的数据放入 data 中,最后调用 renderToString 函数输出 html 片段。

提速

页面的首屏时间中有 80% 消耗在网络上,也就是如果一个网页的白屏时间是一秒,那么大概 800ms 在网络上,150 毫秒左右在后端读取数据,50 毫秒左右浏览器渲染,要优化后面两项比较困难,优化网络时间是效果最明显的手段,传统的 Ajax 是先请求 js 再由 js 发起数据请求,两项时间加起来再加上渲染时间才是首屏时间,这样的流程首屏时间降到一秒一下是比较困难的,主要受限于网络大环境,在 5G 普及之前不大可能被解决,但是如果将两个请求合并为一个那么就有可能办到了,这也是 SSR 受欢迎的重要原因,毕竟低首屏时间意味着高到达率,到达率影响转化率,进而和企业收益直接相关。

上面提到只有首屏用了服务器端渲染,后面的页面还是异步数据渲染逻辑,而异步渲染比同步渲染要慢,那么这是为什么呢,为什么不全部采用服务器端同步渲染?

先分析一下官方给出的示例,直接访问页面采用服务器端渲染白屏时间大约是 500ms,采用异步数据的方式是 244 + 206 = 460 (js加载时间 + 数据加载时间),异步比同步还要快一点,主要的原因是同步加载是整个页面重新加载,而异步加载是局部刷新。非首屏异步加载还有一个另外的好处,就是主 js 常驻浏览器内存可以实现页面之间的跳转动画,而直接跳页面不可以。

再进一步思考,非首屏页面可不可以更快,能不能从 244 + 206 变为 244?从原理上来说是可以的,切换路由的时候先请求一段 html,还要带上 css,前端将拿到的片段放到页面中。当前 nuxt 还没有实现,这将是一个很复杂的功能,这段逻辑在前后端都要提供公共的支持,还有多级路由的动态响应,页面上有交互的元素需要业务开发人员来做处理,防止看到了不能点的情况出现,这无疑会加大系统开发的复杂度。

你可能有疑惑的点

js 分片之后做的是预加载,而不是按需加载。也就是你打开列表页之后,除了加载列表页的 js 模块还会加载详情页的 js 模块,但是不会影响列表的的渲染速度,优点是下一页比较快,缺点是费流量。开关在 nuxt.config.js 中配置:

render: {
  resourceHints: false
},

适用不适用

最后总结一下哪些场景适用哪些场景需要变通。对于首屏而言,如果对 SEO 有强需求,SSR 引入的复杂度对比其他方案是最小的。对于非首屏 SEO 的需求是能满足的,但是如果两个页面没有公共部分或公共部分很少的情况下速度要比多页慢一点,大概 40%,SSR 的一个附带优点是可以做专场动画,所以这一点需要根据业务场景做权衡。

SSR 需要部署 Node 服务器支持,需要投入部署和运维的人力。

你可能遇到的坑

启动 ip

在正式部署的时候遇到一个坑,明明部署成功了却访问失败,用 telnet ip port 是不通的,但用 telnet 127.0.0.1 port 是通的,一番排查过后原因是 express 启动的时候指定ip 有问题,关键代码如下:

// server/index.js
import express from 'express';

const host = process.env.HOST || '127.0.0.1';
const app = express();
app.listen(port, host);
console.log('Server listening on ' + host + ':' + port);

那么就有两种方案了,一种是启动的时候带上 HOST 参数,一种是自动获取 HOST,我们采取第二种,引入库 get-ip,然后代码改为如下:

// server/index.js
import express from 'express';
import getIp from 'get-ip';

const host = getIp() || '127.0.0.1';
const app = express();
app.listen(port);
console.log('Server listening on ' + host + ':' + port);

还有一处需要修改,在 client/plugin/axios.js,此文件配置全局数据请求接口的调用和预处理,其中如果在服务端调用需要写全 ip 和端口,所以会有下面这段代码:

// The server-side needs a full url to works
if (process.server) {
    options.baseURL = `http://${process.env.HOST || 'localhost'}:${process.env.PORT || 4000}`;
}

和上面一样,我们替换 HOST 的获取方式就可以,具体如下:

// The server-side needs a full url to works
if (process.server) {
    const host = getIp() || '127.0.0.1';
    options.baseURL = `http://${host}:${process.env.PORT || 8099}`;
}

黑盒的坑

nuxt 是个黑盒,你也许会遇到一些奇怪的问题而无法追查原因,这确实是一个潜在的风险。nuxt 的目标就是无感知的实现同构,所以将其设计为黑盒是合理的,vue 和 react 也是个黑盒,我们能对输入和输出有准确预判,并且它实现了这个预判,那它就是个合格的黑盒。其实最多的问题多数出现在 UI 组件上,很多组件开发的时候可能都没有考虑过要兼容 nuxt(一般直接使用 vue 的 API 应该是没有问题的)。

所以关于黑盒的锅让 nuxt 背确实不合适。如果按照 nuxt 规范开发的组件依然不能实现同构渲染,那就是 nuxt 的 bug,你可以去提 issue,如果你够牛可以 fork 下来自己修复然后再 pull request,整个开源界都会感谢你。

使用 nuxt 是有门槛的,如果你确定要使用它,最好先测试一下你用的 UI 库中的组件是不是能支持。

本篇完。

想要学习更多面试知识,可点击
java开发实习工程师http://www.gtalent.cn/exam/interview/jdAgwJtErNal1znK
JavaScript大厂算法题(快手/拼多多/喜马拉雅fm):http://www.gtalent.cn/exam/interview/PJ68xt5fleuNRC9a
c++大厂算法题(字节跳动/网易/百度/快手):http://www.gtalent.cn/exam/interview/dlK2B8TxsYq6cEM9
web前端工程师(电商):http://www.gtalent.cn/exam/interview/1rWl4RimyZYpg6t9