服务端渲染SSR之UmiJS预渲染

UmiJS 服务端渲染

本文主要介绍 UmiJS 的预渲染功能。

一、什么是服务端渲染?

服务端渲染(Server-Side Rendering),是指由 服务端 完成页面的 HTML 结构拼接的页面处理技术,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。

二、CSR & SSR

CSR:Client Side Rendering 客户端渲染,流程如下:

CSR

SSR:Server Side Rendering 服务端渲染,流程如下:

CSR

三、SSR 的优缺点及使用场景

3.1 优点

  • 更快的首屏加载速度:无需等待 JS 完成下载且执行才显示内容,更快地看到完整渲染的页面,有更好的用户体验。
  • 更友好的 SEO
    • 爬虫可以直接抓取渲染之后的页面,CSR 首次返回的 HTML 文件中,root 节点为空,不包含内容;而 SSR 返回渲染之后的 HTML 片段,内容完整,能更好地被爬虫分析与索引

3.2 缺点

  • 对服务器性能消耗较高
  • 项目复杂度变高,多了一个 node 中间层
  • 需要考虑 SSR 及其的运维、申请、扩容,增加了运维成本

3.3 UmiJS 预渲染

服务端渲染,首先得有后端服务器(一般是 Node.js)才可以使用,而没有后端服务器的情况下,可以使用预渲染

预渲染与服务端渲染唯一的不同点在于 渲染时机,服务端渲染的时机是在用户访问时执行渲染(即实时渲染,数据一般是最新的),预渲染的时机是在项目构建时,当用户访问时,数据不一定是最新的(如果数据没有实时性,可以直接考虑预渲染)。

预渲染在构建时执行渲染,将渲染后的 HTML 片段生成静态 HTML 文件。无需使用 web 服务器实时动态编译 HTML,适用于 静态站点生成

四、Umi 服务端渲染

Umi3 在 SSR 上做了大量优化及开发体验的提升,具有以下特性:

  • 开箱即用:内置 SSR,一键开启,可在 umi dev 中预览,方便调试开发。
  • 服务端框架无关:Umi 不耦合服务端框架(如 Egg.js、Express、Koa),无论是哪种框架或者 Serverless 模式,都可以非常简单的进行集成。
  • 支持应用和页面级数据预获取
  • 支持按需加载:开启 dynamicImport (按需加载)后,Umi 3 会根据不同路由加载对应的资源文件(css/js)。
  • 内置预渲染功能:Umi 3 中内置了预渲染功能,不再通过安装额外插件使用,同时开启 ssrexportStatic,在 umi build 构建时会编译出渲染后的 HTML。
  • 支持渲染降级:优先使用 SSR,如果服务端渲染失败,自动降级为客户端渲染(CSR),不影响正常业务流程。
  • 支持流式渲染ssr: { mode: 'stream' } 即可开启流式渲染,流式 SSR 较正常 SSR 有更少的 TTFB(发送页面请求到接收到应用数据第一个字节所花费的毫秒数) 时间。
  • 兼容客户端动态加载:可同时使用 SSR 和 dynamicImport。
  • SSR 功能插件化:可通过提供的 API 来自定义 SSR 功能。

4.1 启用服务端渲染

默认情况下,服务端渲染时关闭的,可通过配置开启:

export default {
  ssr: {
    // 开发模式下的服务端渲染,默认为 true
    devServerRender: false,
  },
};

4.2 数据预获取

服务端渲染的数据获取方式与 SPA(单页应用) 有所不同,为了让客户端和服务端都能获取到同一份数据,Umi 提供了页面级数据的预获取。

页面级数据获取 - 使用

每个页面可能有单独的数据预获取逻辑,这里我们会获取页面组件上的 getInitialProps 静态方法,执行后将结果注入到该页面组件的 props 中,如:

// pages/index.tsx 函数组件
import { IGetInitialProps } from "umi";
import React from "react";

const Home = (props) => {
  const { data } = props;
  return <div>{data.title}</div>;
};

Home.getInitialProps = (async (ctx) => {
  return Promise.resolve({
    data: {
      title: "Hello World!",
    },
  });
}) as IGetInitialProps;

export default Home;
// pages/index.tsx 类组件
import { IGetInitialProps } from "umi";
import React from "react";

class Home extends React.Component {
  static getInitialProps = (async (ctx) => {
    return Promise.resolve({
      data: {
        title: "Hello World",
      },
    });
  }) as IGetInitialProps;

  render() {
    const { data } = props;
    return <div>{data.title}</div>;
  }
}

export default Home;

getInitialProps 有几个固定参数:

  • match:与客户端页面 props 中的 match 保持一致,保存当前路由的相关数据
  • isServer:是否为服务端在执行该方法
  • route:当前路由对象
  • history:history 对象

扩展 ctx 参数

为了结合数据流框架,我们提供了 modifyGetInitialPropsCtx 方法,由插件或应用来扩展 ctx 参数,以 dva 为例:

// plugin-dva/runtime.ts
export const ssr = {
  modifyGetInitialPropsCtx: async (ctx) => {
    ctx.store = getApp()._store;
  },
};

然后在页面中,可以获取到 store

// pages/index.tsx
const Home = () => <div />;

Home.getInitialProps = async (ctx) => {
  const state = ctx.store.getState();
  return state;
};

export default Home;

同时也可以在自身应用中进行扩展:

// app.ts
export const ssr = {
  modifyGetInitialPropsCtx: async (ctx) => {
    ctx.title = "params";
    return ctx;
  },
};

同时可以使用 getInitialPropsCtx 将服务端参数扩展到 ctx 中,例如:

app.use(async (req, res) => {
  // 或者从 CDN 上下载到 server 端
  const render = require("./dist/umi.server");
  res.setHeader("Content-Type", "text/html");

  const context = {};
  const { html, error, rootContainer } = await render({
    path: req.url,
    query: {},
    context,
    getInitialPropsCtx: {
      req,
    },
  });
});

在使用的时候,就有 req 对象,不过需要注意的是,只在服务端执行时才有此参数:

Page.getInitialProps = async (ctx) => {
  if (ctx.isServer) {
    // console.log(ctx.req);
  }
  return {};
};

则在执行 getInitialProps 方法时,除了以上两个固定参数外,还会获取到 titlestore 参数。

关于 getInitialProps 执行逻辑和时机,需要注意:

  • 开启 ssr,且执行成功
    • 未开启 forceInitial,首屏不触发 getInitialProps,切换页面时会执行请求,和客户端渲染逻辑保持一致。
    • 开启 forceInitial,无论是首屏还是页面切换,都会触发 getInitialProps,目的是始终以客户端请求的数据为准。(有用在静态页面站点的实时数据请求上)
  • 未开启 ssr 时,只要页面中有 getInitialProps 静态方法,则会执行该方法。

4.3 部署

执行 umi build,除了正常的 umi.js 外,会多一个服务端文件:umi.server.js(相当于服务端入口文件)。然后在后端框架中,引用该文件:

// Express
app.use(async (req, res) => {
  const render = require("./dist/umi.server");
  res.setHeader("Content-Type", "text/html");

  const context = {};
  const { html, error, rootContainer } = await render({
    // 有需要可带上query
    path: req.url,
    context,
    // 可自定义 html 模板
    // htmlTemplate: defaultHtml,

    // 启用流式渲染
    // mode: 'stream',

    // HTML 片段静态标记(适用于静态站点生成)
    // staticMarkup: false,

    // 扩展 getInitialProps 在服务端渲染中的参数
    // getInitialPropsCtx: {},

    // manifest,正常情况下不需要
  });

  // support stream content
  if (content instanceof Stream) {
    html.pice(res);
    html.on("end", function () {
      res.end();
    });
  } else {
    res.send(res);
  }
});

render 方法参数和返回值如下:

// 参数:
{
    // 渲染页面路由,支持 `base` 和带 query 的路由,通过 umi 配置
    path: string;
    // 可选,初始化数据,传到 getInitialProps 方法的参数中
    initialData?: object;
    // 自定义 html 模板
    htmlTemplate?: string;
    // 页面内容挂载节点,与 htmlTemplate 配合使用,默认为 root
    mountElementId?: string;
    // 上下文数据,可用来标记服务端渲染页面时的状态
    context?: object;
    // ${protocol}://${host} 扩展 location 对象
    origin?: string;
}
// 返回值:
{
    // html 内容,服务端渲染错误后,会返回原始 html
    html?: string | Stream;
    // 挂载节点中的渲染内容(ssr渲染实际上只是渲染挂载节点中的内容),同时你也可以用该值来拼接自定义模板
    rootContainer: string | Stream;
    // 错误对象,服务端渲染错误后,值不为 null
    error?: Error;
}

4.4 动态加载、流式渲染、预渲染

完美兼容客户端动态加载,配置如下:

// .umirc.ts
export default {
  ssr: {},
  // 开启动态加载,使用动态加载后,启动和构建会自动开启 manifest 配置,并在 dist 目录中生成 `asset-manifest.json` 做资源映射,并自动将页面对应的资源注入到 HTML 中,避免开启动态加载后,页面首屏闪烁问题。
  dynamicImport: {},
  // 开启流式渲染功能
  mode: "stream",
  // 开启预渲染,默认情况下,预渲染后会删除 umi.server.js 服务端入口文件,可通过 `RM_SERVER_FILE=none` 来保留 `umi.server.js`
  exportStatic: {
    // 预渲染动态路由:默认情况下,预渲染不会渲染动态路由里的所有页面,如果需要渲染动态路由中的页面,可通过配置 `extraRoutePaths`
    extraRoutePaths: async () => {
      // const result = await request('https://your-api/news/list');
      return Promise.resolve(["/news/1", "/news/2"]);
    },
  },
};

4.5 页面标题渲染

@umijs/preset-react 插件集中已内置对标题的渲染,通过以下步骤使用:

// pages/bar.tsx
import React from "react";
import { Helmet } from "umi";

export default (props) => {
  return (
    <div>
      {/* 可自定义需不需要编码 */}
      <Helmet encodeSpecialCharacters={false}>
        <html lang="en" data-direction="666" />
        <title>Hello Umi Bar Title</title>
      </Helmet>
    </div>
  );
};

4.6 与 dva 结合使用

@umijs/preset-react 插件集中已内置 dva

export default {
  ssr: {},
  // 开启dva,并在 modules 目录下创建 dva model
  dva: {},
};

这时候 getInitialProps(ctx) 中的 ctx 就会有 store 属性,可执行 dispatch,并返回初始化数据。

Page.getInitialProps = async (ctx) => {
  const { store } = ctx;
  store.dispatch({
    type: "bar/getData",
  });
  return store.getState();
};

4.7 包大小分析

Umi 同时支持对服务端和客户端包大小的分析

# 服务端包大小分析
$ ANALYZE_SSR=1 umi build

# 客户端包大小分析
$ ANALYZE=1 umi build

参考资料

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 228,786评论 6 534
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 98,656评论 3 419
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 176,697评论 0 379
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 63,098评论 1 314
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 71,855评论 6 410
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 55,254评论 1 324
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 43,322评论 3 442
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 42,473评论 0 289
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 49,014评论 1 335
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 40,833评论 3 355
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 43,016评论 1 371
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 38,568评论 5 362
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 44,273评论 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 34,680评论 0 26
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 35,946评论 1 288
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 51,730评论 3 393
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 48,006评论 2 374

推荐阅读更多精彩内容