跳过正文
  1. 前端工程体验优化实战/

前端渲染进化史:SSR进阶优化

hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录
渲染与SSR - 这篇文章属于一个选集。
§ 2: 本文

## 4. Node.js服务端渲染进阶优化:

1. 服务端静态站点生成 Static Site Generation (SSG)
#

SSR架构的应用中或多或少会有一些内容长期不变的纯静态页面,例如博客文章页、活动页和规则条例页等等。

对于这些千人一面的静态页面,每次用户的请求时都消耗服务端资源进行SSR渲染,生成重复的HTML字符串,显然是一种对计算资源的浪费,因此就有了服务端静态站点生成 (Static Site Generation SSG)这项优化技术。

SSG允许开发者指定部分页面,在项目打包编译时,就提前生成静态页面HTML文件,随服务端应用部署后,直接将HTML文件作为响应返回给用户。

从而避免执行服务端渲染,减少运行开销,节省服务器硬件资源,减少金钱开销。

在SSR的基础上实现SSG并不复杂,下面我们在上文SSR示例项目的基础上,继续演示如何为其实现SSG,将这一博客项目的指定文章页通过SSG处理成静态页面。

完整示例请参考《feat: SSR to SSG》:https://github.com/JuniorTour/fe-optimization-demo/pull/5

1. 生成静态页面 staticGenerator
#

首先,我们需要编写核心的生成静态页面逻辑,原理是利用renderToStringAPI 和Node.js的文件读写能力fs模块,将单次渲染的结果,保存为独立HTML文件,供后续用户响应复用:

// server\staticGenerator.tsx
import { renderToString } from 'react-dom/server';
import { App } from '../src/app/ui/app/app';
import { history } from '../src/shared/router';
import { staticPages } from './staticPagesConfig';
import { getIndexHTMLTemplate, saveToFile, urlToFileName } from './utils';

async function staticGenerator({ url, getStaticData }) {
  // eslint-disable-next-line no-console
  console.log(`staticGenerator start for ${url}`);

  history.push(url);
  // 参考 Next.js 的 getStaticProps:https://nextjs.org/docs/pages/building-your-application/rendering/static-site-generation
  await getStaticData({ url });
  // eslint-disable-next-line no-console
  console.log(`staticGenerator getStaticData finish for ${url}`);

  const markup = renderToString(<App />);

  const fileName = urlToFileName(url);
  saveToFile(
    fileName,
    getIndexHTMLTemplate().replace(
      '<div id="root"></div>',
      `<div id="root">${markup}</div>`,
    ),
  );
  // eslint-disable-next-line no-console
  console.log(`staticGenerator finish for ${fileName}`);
}

// staticGenerator('article/example', async () => {
//   // 取回异步数据,供渲染DOM
// });

这段代码中,我们声明了staticGenerator函数,接收一个对象作为参数,从中获取2个属性:

  • url:需要生成静态页面的URL,例如某一篇文章article/If-we-quantify-the-alarm...
  • getStaticData:为静态页面获取异步数据,例如文章页的文章内容,以便完整渲染DOM。

另外,为了独立运行SSG的逻辑,我们也要为其配置独立的Webpack配置和package.json脚本命令,有了上文中SSR的基础,我们的配置并不复杂,代码如下:

// webpack\server-static-generation.config.js
const { merge } = require('webpack-merge');
const path = require('path');
const serverConfig = require('./server.config');

module.exports = merge(serverConfig, {
  entry: path.resolve(__dirname, '../server/staticGenerator.tsx'),
  output: {
    filename: 'staticGenerator.js',
  },
});
"scripts": {
    "build-ssg": "cross-env NODE_ENV=production BUILD_SERVER=true webpack --config ./webpack/server-static-generation.config.js",
    "run-ssg": "node ./dist/staticGenerator.js",
    "build-run-ssg": "npm run build-ssg && npm run run-ssg",
}    

2. 处理服务端客户端状态同步问题
#

SSG 的一大难点就是如何处理好服务端客户端数据同步问题。

具体来说,我们想要实现对博客文章页进行SGG处理,那就需要我们能在服务端获取到文章页所需的各类数据,以便渲染出包括文章页内容文字在内的完整DOM。

通常的解决方案是,配合前端应用的状态管理工具,在服务端加载所需数据,供renderToString时获取并渲染。

对于我们的示例项目来说,我们只需要在服务端renderToString运行前,调用前端更新状态用的getArticleFx方法,提前获取到文章页所需的文章内容数据即可。

我们通过staticGenerator()接受的getStaticData方法来实现这一逻辑,在指定的SSG页面配置staticPages中,我们将getStaticData赋值为调用await getArticleFx()的异步函数。

// server\staticPagesConfig.ts
import { getArticleFx } from '../src/pages/article/model/store';

export const staticPagesURL = [
  '/article/If-we-quantify-the-alarm-we-can-get-to-the-FTP-pixel-through-the-online-SSL-interface!-120863',
  '/article/You-cant-transmit-the-firewall-without-copying-the-1080p-SDD-interface!-120863',
];

async function articlPageGetStaticData({ url }) {
  await getArticleFx(url.split('article/')?.[1]);
}

export const staticPages = [
  {
    url: staticPagesURL[0],
    getStaticData: articlPageGetStaticData,
  },
  {
    url: staticPagesURL[1],
    getStaticData: articlPageGetStaticData,
  },
];

这样,当我们执行npm run build-run-ssg,服务端运行staticGenerator({ url, getStaticData })时,就能在renderToString执行渲染前,加载好文章页所需文章内容数据,最终渲染得到的DOM中,就会包含我们想要的文章内容DOM:

3. 改造SSR逻辑跳过SSG页面渲染
#

最后,对于已经生成了静态HTML的页面,我们还要改造SSR逻辑,对这些页面跳过执行renderToString的逻辑,直接读取本地文件系统中的静态HTML,作为HTTP响应,返回给用户的请求。

// server\renderer.tsx
export async function serverRenderer(req, res) {
  const context: StaticRouterContext = {};

  const reqUrl = req.url;
  if (staticPagesURL.includes(reqUrl)) {
    // eslint-disable-next-line no-console
    console.log(`SSG run for: (${reqUrl})`);

    res.send(
      getDistHTMLContent(`./${StaticPagesFolderName}/${urlToFileName(reqUrl)}`),
    );

    return;
  }

  history.push(reqUrl);

  const markup = renderToString(<App />);
  // ...
}

通过这样的改造,每一次用户请求文章页时,服务器就不再需要重复执行renderToString(),可以节省服务器SSR运行耗时,减少服务器负担,从而节省服务器硬件资源,解决SSR的部分痛点。

很多著名的静态站点生成工具,都是基于类似的代码逻辑实现的,例如:

但是,SSG也有其痛点:

  • 首先,SSG只适用于完全静态的页面,如果页面中一小部分需要在SSR时动态变化,SSG就难以实现。
  • 其次,对于有大量 SSG 页面的前端项目,每个页面都需要等待异步数据加载完成,并执行renderToString渲染,会导致项目的完整构建耗时相当漫长,开发体验较差。

2. 增量静态页面再生 ISR Incremental Static Regeneration
#

为了进一步解决SSG的这些痛点,增量静态页面再生(ISR,Incremental Static Regeneration) 技术应运而生。

ISR通过将单次renderToString()渲染的结果缓存,并设置缓存有效期,来实现既减少重复渲染,节省服务端资源,又避免构建耗时太长的需求。

简单来说,ISR的逻辑用这9行伪代码就可以说清:

function ISRRender(context) {
    let ret
    const hasCache = validateCache(context)
    if (hasCache) {
        ret = getCache(context)
    } else {
        ret = doRender(context)
    }
    return ret
}

ISR可以应用于内容更新的实时性不强,能接受一定时间更新延迟的非静态页面 例如商品详情页、用户信息页、热门榜单页等,这些页面:

  • 一定时间内,内容不会更新,处于相对静止状态,服务端渲染的结果可以被缓存下来复用。
  • 但也需要定时地更新内容,例如商品销量增加、热榜内容变化、用户信息修改时,那么服务端渲染就要再次执行,从而渲染出最新的页面DOM。

下面我们继续基于示例项目,演示实现ISR的主要流程和核心原理。

完整代码示例《feat: SSR to ISR》:https://github.com/JuniorTour/fe-optimization-demo/pull/6

1. LRU缓存
#

首先我们要基于LRU缓存实现一套高效的缓存系统,用于读写SSR的渲染结果。

注:LRU缓存(Least Recently Used Cache)是一种致力于提高缓存命中率、并节省读写缓存成本的缓存算法策略。

示例代码:

// server\IncrementalStaticRegeneration\cache.ts
import { log } from 'console';
import LRUCache from 'lru-cache';

interface Cache<CacheVal> {
  lruCache: LRUCache<string, CacheVal>;
}

class Cache<CacheVal> implements Cache<CacheVal> {
  constructor({ max }: { max: number }) {
    this.lruCache = new LRUCache({
      noDisposeOnSet: true, // 只在超过 max 时,触发 dispose
      max,
      dispose: (key: string) => {
        log(`ISR cache ${key} dispose`);
      },
    });
  }

  remove(key: string) {
    this.lruCache.del(key);
  }

  // maxAge unit: ms
  set(path: string, data: CacheVal, maxAge?: number) {
    if (!data) return;

    this.lruCache.set(path, data, maxAge);
  }

  get(path: string) {
    if (!path) return null;

    return this.lruCache.get(path);
  }

  size() {
    return this.lruCache.length;
  }
}

export default Cache;

使用上述代码,我们以面向对象的模式,实现了一个Cache类,封装了开源库lru-cache的读(get)写(set)逻辑,并利用其自带的maxAge参数来实现ISR对服务端渲染结果定期更新的需求。

注:lru-cache默认将缓存数据保存在内存中,用占据内存空间换取节省CPU运行时间。

如果担心内存占用太多,我们可以自行修改缓存读写实现逻辑,替换LRU的保存在内存中的形式。

例如,基于Node.js的fs模块,改为缓存到本地文件系统中。

2. 封装带缓存的ISR渲染器类
#

其次,为了方便地使用上述缓存类,我们继续封装一个ISR渲染器类,为SSR渲染接入提供方便的配置和方法,核心代码逻辑如下:

import Cache from './cache';

/**
 * 静态再生渲染器
 * 支持将「doRender()」参数的返回结果缓存到内存中,
 * 并指定缓存有效时长(revalidate)和缓存的key(staticPath)。
 *
 * 可用于缓存指定路由的 SSR 渲染结果,以节省服务端资源。
 *
 * RFC:TODO
 * 使用文档:TODO
 */
class IncrementalStaticRegenerationRender<CacheVal = string> {
  cache;

  doRender: DoRender<CacheVal>;

  normalizeRenderResult?: NormalizeRenderResult<CacheVal>;

  renderName: string;

  constructor({
    max,
    doRender,
    normalizeRenderResult,
    renderName,
  }: {
    max: number;
    doRender: DoRender<CacheVal>;
    normalizeRenderResult?: NormalizeRenderResult<CacheVal>;
    renderName: string;
  }) {
    this.cache = new Cache<CacheVal>({ max });
    this.doRender = doRender;
    if (normalizeRenderResult) {
      this.normalizeRenderResult = normalizeRenderResult;
    }
    this.renderName = renderName || 'Unknown';
  }

  saveCache(
    data: RenderData<CacheVal>,
    staticPath: string,
    renderResult: CacheVal,
    revalidate: number,
  ) {
    // 使用 staticPath 作为缓存的 key
    this.cache.set(staticPath, renderResult, revalidate);
  }

  // eslint-disable-next-line class-methods-use-this
  getOptions(
    data: RenderData<CacheVal>,
    path: string,
    pathConfig: PathConfig<CacheVal> | undefined,
  ) {
    const revalidate = pathConfig?.revalidate || 0;
    const getStaticPath = pathConfig?.getStaticPath || (() => path);
    const staticPath = getStaticPath(path, data);

    return {
      staticPath,
      revalidate,
    };
  }

  cacheSize(): number {
    return this.cache.size();
  }

  removeCache(key: string) {
    this.cache.remove(key);
  }

  render(data: RenderData<CacheVal>): CacheVal {
    const { revalidate, staticPath } = this.getOptions(
      data,
      data.path,
      data.pathConfig,
    );

    const routeIsStatic = Boolean(revalidate);
    const cache = routeIsStatic ? this.cache.get(staticPath) : null;
    const hasCache = Boolean(cache);
    // eslint-disable-next-line no-param-reassign
    data.routeIsStatic = routeIsStatic; // 用于 normalizeRenderResult 内部判断

    let res = cache || this.doRender(data);

    if (routeIsStatic && !hasCache) {
      this.saveCache(data, staticPath, res, revalidate);
    }

    if (typeof this.normalizeRenderResult === 'function') {
      // eslint-disable-next-line no-param-reassign
      data.res = res;
      res = this.normalizeRenderResult(data);
    }

    return res;
  }
}

export default IncrementalStaticRegenerationRender;

在这段代码中,我们把LRU缓存类声明成了ISR渲染器类的属性this.cache = new Cache<CacheVal>({ max });,并通过提供render(data: RenderData<CacheVal>): CacheVal 供SSR运行时调用,获取带有缓存的渲染结果。

这个类的核心逻辑是:

  • const cache = routeIsStatic ? this.cache.get(staticPath) : null;
  • let res = cache || this.doRender(data);

这2行代码,其判断SSR渲染的目标,是否已经有缓存,如果有,则直接读取返回缓存。如果没有,再执行渲染逻辑(doRender)。

3. ISR引入SSR
#

最后,我们改造已有的SSR逻辑,引入ISR渲染器,并提供需要开启ISR的路由配置。

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouterContext } from 'react-router';
import { log } from 'console';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { App } from '../src/app/ui/app/app';
import { history } from '../src/shared/router';
import { DIST } from '../webpack/constants';
import IncrementalStaticRegenerationRender, {
  PathConfig,
} from './IncrementalStaticRegeneration/renderer';

function doRender({ ctx }) {
  log(`doRender run`);
  let markup = '';
  try {
    markup = renderToString(<App />);
  } catch (err) {
    console.error('renderToString', err, ctx.url);
  }

  return {
    // 如有需要,可以自由添加其他数据用于缓存,例如 react-helmet 的渲染结果
    markup,
  };
}

export const appRenderer = new IncrementalStaticRegenerationRender({
  // 如果「缓存数量超过最大值删除次数」:https://gf.in.zhihu.com/d/VyQnbK1nz/jing-tai-ye-mian-zai-sheng-isr?orgId=1&var-app=heifetz-errorpage&var-RouteName=NotFoundErrorPage&from=now-15d&to=now&viewPanel=19
  // 其中的 dispose.start.count 指标触发数量较高,就该考虑加大「缓存最大数量:max」了
  max: 100 * 1000,
  doRender,
  renderName: 'AppRender',
});

const PathConfigs: PathConfig<{ markup: string }>[] = [
  {
    matchPath(path) {
      return /article/(.*)/.test(path);
    },
    // 对于文章页,我们期望每 10 秒,更新一次缓存,也即更新一次服务端渲染的结果:
    revalidate: 10 * 1000, // 10 seconds
    getStaticPath(path /* , renderData: RenderData<string> */) {
      // 对于每一个文章页,都以其path作为缓存的 key
      return path;
    },
  },
  {
    matchPath(path) {
      return /^/login$/.test(path);
    },
    // 对于登录页,我们期望每 180 秒,更新一次缓存,也即更新一次服务端渲染的结果:
    revalidate: 180 * 1000, // 180 seconds
    getStaticPath() {
      // 对于登录页,将固定值 'login' 作为缓存的 key
      return 'login';
    },
  },
  // // 热门榜单页示例
  // {
  //   matchPath(path) {
  //     return /hot/.test(path);
  //   },
  //   // 对于热门榜单页,我们期望每 1 秒,更新一次缓存,也即更新一次服务端渲染的结果:
  //   revalidate: 1 * 1000, // 1 seconds
  //   getStaticPath() {
  //     return 'hot';
  //   },
  // },
];

function getPathConfig(path) {
  return PathConfigs.find((config) => config.matchPath(path));
}

function getISRRenderResult({ path, ctx }) {
  const { markup } = appRenderer.render({
    path,
    ctx,
    pathConfig: getPathConfig(path),
  });

  return markup;
}

export function serverRenderer(req, res) {
  const context: StaticRouterContext = {};

  const reqUrl = req.url;
  history.push(reqUrl);
  log(`reqUrl=${reqUrl}`);

  const markup = getISRRenderResult({
    path: reqUrl,
    ctx: {
      history,
      redirect(statusCode, url) {
        return res.redirect(statusCode, url);
      },
    },
  });
 
  // res.send(...)
}

在对SSR的改造中,我们把调用renderToString(<App />)的逻辑封装成了独立的方法doRender({ ctx })

并把这一方法作为了声明ISR渲染器的参数,得到了appRenderer这个ISR渲染器实例:

export const appRenderer = new IncrementalStaticRegenerationRender({
  // 如果「缓存数量超过最大值删除次数」:https://gf.in.zhihu.com/d/VyQnbK1nz/jing-tai-ye-mian-zai-sheng-isr?orgId=1&var-app=heifetz-errorpage&var-RouteName=NotFoundErrorPage&from=now-15d&to=now&viewPanel=19
  // 其中的 dispose.start.count 指标触发数量较高,就该考虑加大「缓存最大数量:max」了
  max: 100 * 1000,
  doRender,
  renderName: 'AppRender',
});

SSR服务器运行时,我们改为使用appRenderer.render()方法来获取渲染结果,当用户重复请求我们通过PathConfigs指定的有缓存页面时,appRenderer.render()方法会直接从内存中读取缓存,而不是再次重复执行doRender()

例如下面这段代码就是,我们在PathConfigs中对一个页面声明了开启ISR的配置:

  {
    matchPath(path) {
      return /article/(.*)/.test(path);
    },
    // 对于文章页,我们期望每 10 秒,更新一次缓存,也即更新一次服务端渲染的结果:
    revalidate: 10 * 1000, // 10 seconds
    getStaticPath(path /* , renderData: RenderData<string> */) {
      // 对于每一个文章页,都以其path作为缓存的 key
      return path;
    },
  },

这段配置通过matchPath方法,判断用户请求页面的URL是否需要被ISR缓存,并通过revalidate属性指定缓存时长为10秒钟,即每10秒钟,通过再次执行doRender()更新一次缓存。

并且,每个文章页的缓存用各自的URL作为key保存,每一篇文章都有各自的缓存,就避免了缓存重复或覆盖。

ISR的配置还可以通过前端路由的属性配置,可以使用react-router自带的match()方法实现,例如:

// 404页面:固定 key cache
<Route
  revalidateTime={10 * 1000}
  getStaticPath={() => 'NotFoundPage'}
  key='/404'
  name='NotFoundErrorPage'
  path='/404'
  component={NotFoundPage}
/>

基于以上代码逻辑,ISR在服务器运行时对SSR结果进行缓存,既避免了SSG项目构建时较长的打包编译耗时,又通过缓存避免重复执行renderToString,节省了服务器计算资源。

5. 验证量化和评估
#

1. 验证
#

1. 确认功能逻辑正常
#

前端项目应用SSR、SSG、ISR等优化后,底层渲染方式有颠覆性的变化,所以验证要做的第一件事就是确保原有的功能逻辑正常,尤其是以下3方面:

  • 前端路由:例如react-routervue-router等前端路由库的跳转、重定向功能是否正常。
  • 前端状态管理:例如reduxvuexpinia等前端状态管理库保存的登录状态是否在服务端和客户端保持一致。
  • 依赖前端组件生命周期的业务逻辑:例如react框架的useEffect()useLayoutEffect()等基于组件渲染生命周期执行的逻辑。

2. 对比优化前后FCP、LCP值
#

本地环境的FCP变化可能不明显,因为静态资源加载的网络耗时在本地无法体现,导致SSR首屏内容不依赖JS执行,从而较快渲染的特性无法发挥。

但是部署到生产环境后,SSR优化前后,FCP、LCP指标的差异将非常显著,为了便于准确评估对比优化前后的状态,我们选择使用指标的值,作为统计指标。

另外,也可以通过在本地环境,给静态资源加载加上统一的延迟时间,来模拟生产环境CDN加载的状态。在此基础上进行10次以上的测量,统计FCP、LCP的值,预计能观察显著的变化。

笔者基于上述验证方式,在本地环境,测量了示例项目首页的数据,观察到了显著的优化效果,FCP值减少了72%:

对比项 / FCP值优化前(CSR)(单位:秒)优化后(SSR差异
平均值3.941.08-2.86s (-72%)
最大值4.111.14-2.97s (-72%)
最小值3.781.07-2.71s (-71%)
数据来源

LCP 值也有38%以上显著的减少:

对比项 / FCP值优化前(CSR)(单位:秒)优化后(SSR差异
平均值6.342.85-3.39s (-55.0%)
最大值8.235.03-3.2s (-38.9%)
最小值5.211.07-4.14s (-79.5%)
数据来源

当然这些数据来自于本地环境的单台设备,不足以代表生产环境中海量真实用户的体验,想要准确评估优化效果,还需要从生产环境中收集更多数据,并配合其他量化措施。

2. 量化
#

1. FCP和LCP的评分指标
#

本节介绍到SSR相关优化,因为直接将渲染后的HTML作为响应返回用户,所以会使页面的初始化渲染显著变快,进而对FCP和LCP指标产生明显的优化效果。

我们可以继续使用第2节《前端优化数据量化必备神器-用户体验数据收集与可视化》建立的web-vitals叠百分比图作为量化指标。

2. 自定义服务端渲染耗时指标
#

我们还可以用之前章节介绍过的Performance API的performance.measure()方法,建立自定义指标,统计Node.js执行SSR的耗时,量化SSG、ISR优化的效果。

首先我们声明3个辅助函数,分别创建开始markStart()、结束markEnd()、测量结果measureSSRRenderDuration()3类性能记录,并且在reportGauge()中复用我们的收集数据的HTTP接口,用来在服务端上报数据到Grafana。

完整代码示例如下:

import fetch from 'node-fetch';

const SSRRenderMark = 'SSRRenderMarks';
const SSRRenderMarks = {
  start: `${SSRRenderMark}_start`,
  end: `${SSRRenderMark}_end`,
};

function reportGauge(name, help, labels, value) {
  fetch('http://localhost:4001/gauge-metric', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name,
      help,
      labels,
      value,
    }),
  });
}

export function markStart(detail) {
  performance.mark(SSRRenderMarks.start, { detail });
}
export function markEnd(detail?) {
  performance.mark(SSRRenderMarks.end, { detail });
}

const SSRRenderDurationName = 'ssr-render-duration';
export function measureSSRRenderDuration() {
  const ret = performance.measure(SSRRenderDurationName, {
    start: SSRRenderMarks.start,
    end: SSRRenderMarks.end,
  });

  // 还可以通过 getEntriesByName 获取请求URL reqUrl
  // performance.getEntriesByName(SSRRenderMarks.start)?.[0].detail.reqUrl

  reportGauge(SSRRenderDurationName, `SSR render time cost`, {}, ret.duration);
}

使用时,在任意模块的对应SSR开始、结束的代码逻辑中调用即可获取渲染耗时数据,示例代码如下:

app.get('*', (req, res) => {
  // 演示 performance.mark() 方法跨模块自由调用的特性
  markSSRStart({ reqUrl: req.url });
  serverRenderer(req, res);
});

export function serverRenderer(req, res) {
  // const markup = getISRRenderResult({...
  markSSREnd();
  
  // res.send(...
  measureSSRRenderDuration();
}

3. 评估
#

1. FCP和LCP评分显著改善
#

通过应用SSR相关优化,我们应该能观察到web-vitals叠百分比图中的FCP和LCP用户体验指标显著改善,表示用户在客户端访问页面时,页面渲染内容更快,用户体验更好。

2. 服务端渲染耗时大幅减少
#

另外,通过performance.measure()统计的服务端渲染耗时指标,通过SSG和ISR优化,预期也会有显著的优化,笔者在本地开发环境观察到服务端渲染耗时,平均减少了34%左右,优化效果显著。

对比项 / SSR 渲染耗时优化前(仅SSR)(单位:秒)优化后(ISR差异
平均值3.242.12-1.12s (-34.6%)
最大值5.054.38-0.67s (-13.3%)
最小值1.620.64-0.98s (-60.5%)
数据来源

这一指标的减少,表示服务端优化后,在相同的时间内,能处理更多的用户请求,服务器的负载能力更高,相应地可以节省一部分的服务器硬件开销。

小结
#

这2节《前端渲染进化史》中,我们首先回顾了前端应用渲染方式的简略历史。

接下来就转向深入了解SSR的原理,并将示例的CSR项目重构成了SSR项目,讲解了其核心流程。

又进一步介绍了SSG、ISR等服务端渲染进阶优化方案及其代码实现和改造示例。

渲染与SSR - 这篇文章属于一个选集。
§ 2: 本文

相关文章