前端 SSR 在之家主站的应用-缓存及其性能监测

语言: CN / TW / HK

关注“之家技术”,获取更多技术干货

总篇150篇 2022年第25篇

★ 目录 ★

01

前言

02

基础概念

03

接口层的缓存方案

3.1 细化场景

3.2 实现路径

04

请求耗时直观化

4.1 直观的ServerTiming

4.2 实现路径

05

结语

一、前言

汽车之家用户产品中心的前端团队,将 SSR 同构技术应用在 PC & M 主站中。相对于原有技术方案,在页面渲染性能、白屏时间、可维护性、用户体验都有大幅度的提升。我们结合公司技术基础设施和自身特点,经过长时间的升级与优化,逐渐沉淀出了一套最佳实践。本文即介绍其中的两个技术环节,以飨读者,分别是:

  1. 接口层的缓存技术方案- 如何在应用中使用缓存优化接口性能

  2. 请求耗时可视化的实现- 如何使用浏览器方便的看到接口请求耗时

二、基础概念

随着前端复杂度的不断升高,浏览器能力被无限放大和利用,伴随着 NodeJS 的强势崛起,各种应用框架的层出不穷,从 CSR 到 SSR,从 SSR 再到同构。放眼当下,在不同的业务场景中,充斥着它们不同的身影。本次分享的开篇,我们先从以下几种常见的渲染模式讲起:

  • SPA :单页面应用(Single Page Application)。动态重写当前的页面来与用户交互,而不需要重新加载整个页面。单页应用做到了前后端分离,后端只负责处理数据提供接口,页面逻辑和页面渲染都交给了前端。

  • CSR :客户端渲染  (Client Side Render)。顾名思义,渲染过程全部交给浏览器进行处理,服务器不参与任何渲染。页面初始加载的 HTML 文档中无内容,需要下载执行 JS 文件,由浏览器动态生成页面,并通过 JS 进行页面交互事件与状态管理。

  • SSR :服务端渲染  (Server Side Render)。DOM 树在服务端拼接生成完整的 HTML 之后再返回,即当前页面的内容是服务器生成好一次性地给到浏览器进行渲染。

  • Prerender :预渲染,常见的有 SSG 等,在打包的阶段就预先渲染页面,所以在请求到 index.html 时就已经是渲染过的内容。

  • SSR 同构 :客户端渲染和服务器端渲染的结合,在服务器端执行一次,用于实现服务器端渲染(首屏直出),在客户端再执行一次,用于接管页面交互(绑定事件),核心解决 SEO 和首屏渲染慢的问题;后续页面交互可复用已加载的静态资源,最大程度地平衡用户体验和性能。

在 SSR 同构应用架构中,我们将缓存技术应用在了诸多技术环节,其中包括: CDN 层Nginx 反向代理层SSR 渲染服务层接口 API 缓存层 等,通过它们,共同实现了 SSR 性能提升质的飞跃。

三、接口层的缓存方案

本篇文章,我们着重将目光聚焦于源站中的一环 - 接口 API 缓存层 (上图中红色图标部分),在整个缓存链路中,API 接口缓存也极为重要: 因为它可以保障接口查询耗时稳定高效,不因程序复杂或数据庞大而产生耗时波动;同时,它还可以在源接口发生故障时,通过返回历史数据的方式提供容灾能力,减少由此带来的影响,从而提升终端用户使用体验。

3.1 细化场景

通过实际的调研分析,我们整理了一些缓存的应用场景,并对其进行了一一实现:

  • 可实现一键全部开启缓存,及单接口个性化配置,可覆盖全局配置或开关缓存

  • 可实现在不同宿主环境下选择不同介质进行存储:Redis、Node Cache、浏览器内存等

  • 默认对 GET 请求类型生效,当然,也可以通过高级配置参数支持更多

  • 当源接口响应失败,可选择性开启容灾功能

  • 自动同步源接口响应头中 cache-control 字段,作为缓存有效期

除了以上的一些核心功能,对于基础的 TTL 有效期、缓存 Key 生成规则、日志输出等,均已内置支持。

3.2 实现路径

Axios 是一个非常优秀而又被广泛使用的请求库,我们选择其的原因还在于它天然对 Node 端和浏览器端的双重支持。

那么,它是如何实现对 Node 端和浏览器端的双重支持的呢?那就是,内置适配器 - adapter;

我们先通过一张图来了解适配器在 Axios 中扮演的角色:

从图上可以看出,若想实现对接口请求的拦截和响应,大致有两种方式:

  1. 通过拦截器处理,在请求拦截器和响应拦截器分别添加缓存相关逻辑代码

  2. 实现自定义适配器,内部代理原适配器,执行缓存相关逻辑,并进行请求及响应处理

在实际业务场景中,拦截器往往是用于处理业务相关的通用数据逻辑,若想由此实现该功能,需要在请求拦截器和响应拦截器中,分别加入相关代码,对业务逻辑有一定的侵入性,集成方式繁琐,不够灵活,我们更期望的是一种零侵入、插拔式的集成方案。

因此,我们实现了一个自定义适配器, 在不改变现有业务代码前提下,通过一行代码即可实现开启 / 关闭全应用的数据缓存。

如果你对适配器还不甚了解,可以先移步官方文档进行进一步了解。下面是一个自定义适配器的最简单的示例:

module.exports = function coustomAdapter(config) {
  return new Promise(function dispatchRequest(resolve, reject) {
    // ...other code
  }
}

为了方便集成到项目中,我们发布并提供了一个 Npm 包,并向外暴露出了配置器;除此之外,结合之家的接口数据规范,还对 Axios 进行了业务上的封装,可以更方便的应用到日常的业务开发中。

import { installCache, installRequest } from '@ace/request';

const { adapter } = installCache({
ttl: 3 * 1000,
// ...other cache 参数
});

// 通过以下方式,可以单独将适配器集成到已有项目中
const reqIns = axios.create({
baseURL: 'https://api.autohome.com.cn',
// 自定义 adapter
adapter,
// other options
});

// ...other code

四、请求耗时直观化

4.1 直观的 ServerTiming

以往的方式,如果我们想了解接口性能,只能在应用中打日志来记录相关指标数据,而该数据的获取相对繁琐且滞后。为了解决这个问题,可以用浏览器直观快速的获取,我们引入了 ServerTiming 参考链接见文末

当 adapter 被集成使用后,为了能够实时分析 SSR 中接口请求的耗时情况,为此,我们开发了一个工具 - ServerTiming-Loader。

顾名思义,它是一个 Webpack Loader,通过它,便可以在不侵入业务代码的前提下,由构建层前置将耗时统计代码注入到业务代码中,为页面请求追加 ServerTiming  响应头,并在浏览器的开发者面板中实时体现出来。

如下图所示,以某次页面加载为例,页面首屏接口耗时为 11ms,使用这个时间指标,往往在 FCP、LCP、TTFB 等性能分析时,非常具有参考意义。

4.2 实现路径

得益于 Next.js 框架本身的一些强约束,在 Webpack 编译过程中,我们可以很方便而又准确的定位到哪些模块属于页面级源文件,同时在页面源文件中能够通过静态分析得出 getServerSideProps 方法代码段。参考 Babel 的核心三步的工作流程(如下图),我们修改了其中的内部实现:通过 Babel 对 AST 解析及转换的能力,将 getServerSideProps 包装为高阶函数,并在函数内部追加上耗时统计代码。

Babel 工作流程:Parse(解析源文件)-> Transfrom(转换)-> Generator(生成新文件)

最终,我们将会生成如下模板代码:

/**
 * 定义新的 getServerSideProps,并调用 API 执行
 */
const getServerPropsTempl = astTempl(
  `    
    /**
    * 追加 getServerSideProps 耗时到 Chrome Tools / Timing
    */
    const getPropsWithTimingLoader = async ( cxt ) => {
      const startTime = Date.now();
      const returnProps = await originalGetServerSideProps(cxt);
      const { res } = cxt;

      setServerTimingHeader(res, {
        key: 'API',
        value: \`dur=\${Date.now() - startTime}\`,
      });
      return returnProps;
    };

    export const getServerSideProps = getPropsWithTimingLoader;
  `,
  { placeholderPattern: false }
);

在 AST 的遍历过程中,我们还需要识别出 getServerSideProps 的不同声明形式,比如:

export const getServerSideProps = async () => {
  // ...code
}

export async function getServerSideProps (){ 
  // ...code
}

对于无法匹配或者匹配失败的语法类型,将原代码进行返回,保证页面功能的正常执行。

astTraverse(pageAST, {
  /**
   * 去除掉 getServerSideProps 的模块导出
   * 示例:
   * --------------------------------------------------------------------------------------
   * 转换前:export const getServerSideProps = async () => { ... }
   * 转换后:const getServerSideProps = async () => { ... }
   * ---------------------------------------------------------------------------------------
   * 转换前:export async function getServerSideProps (){ ... }
   * 转换后:async function getServerSideProps (){ ... }
   */
  ExportNamedDeclaration(path) {
    // ...codes
  },

  /**
   * 将原 getServerSideProps 方法重命名为 originalGetServerSideProps
   * 当前为字面量形式声明
   */
  VariableDeclarator(path) {
    // ...codes
  },

  /**
   * 将原 getServerSideProps 方法重命名为 originalGetServerSideProps
   * 当前为 function name 形式
   */
  Identifier(path) {
    // ...codes
  },
});

五、结语

在 SSR 项目中,接口请求可能出现在 Node 和 浏览器等不同的宿主环境中,借助 Axios 请求库的一些特性,我们对其进行了一些封装,通过不同的缓存介质,实现了请求劫持、数据缓存和精细化场景的缓存配置,在 Server 端,通过 Redis 还可以实现多实例的缓存数据共享;同时,通过在构建层的一系列预处理操作,在 SSR 应用的运行时,通过 Server Timing 还可实现追踪页面 Server 端实时的请求耗时,并体现到浏览器的开发者面板中。

至此,API 接口缓存技术层在 SSR (请求)中的应用,我们已经介绍完了,在日常的开发中,我们可以视业务场景进行选择性开启,针对 CSR / SSR 不同项目类型上的调用方式也并无差异;当然,以上内容只是 SSR 中应用缓存链路的某一个环节,在 Render 层与更前置的 HTTP Server 层,我们有着更丰富的缓存技术方案在应用,期待下一次的分享。

参考文档

  • axios(https://github.com/axios/axios)

  • next.js(https://nextjs.org/)

  • Server-Timing(https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Server-Timing)

  • Babel(https://babeljs.io/)

  • Webpack(https://webpack.js.org/)

  • npm docs(https://docs.npmjs.com/cli/v8/configuring-npm/package-json)

作者简介

汽车之家

米梦宇

用户产品中心-App技术部

2019 年加入汽车之家,目前任职于用户产品中心-App技术部-前端开发团队-产品库前端开发组。

主要参与汽车之家产品库相关前端开发工作。

阅读更多:

▼ 关注「 之家技术 」,获取更多技术干货