ストリーミングレンダリング
まず、ストリーミングレンダリングとは何かを簡単に紹介します。
ストリーミングレンダリングは、http1.1
のチャンク転送エンコーディングの特性を利用し、サーバーがhtml
をチャンクに分けて返すことで、ブラウザが受信時にページを段階的にレンダリングできるようにするものです。これにより、ページのファーストビュー表示が向上し、ユーザーエクスペリエンスが向上します。
チャンク転送エンコーディング
チャンク転送エンコーディング(Chunked transfer encoding)は、ハイパーテキスト転送プロトコル(HTTP)におけるデータ転送メカニズムの一つであり、HTTPウェブサーバーからクライアントアプリケーション(通常はウェブブラウザ)へ送信されるデータを複数の部分に分割することを可能にします。チャンク転送エンコーディングは、HTTPプロトコル1.1バージョン(HTTP/1.1)でのみ提供されます。
チャンク転送フォーマット
HTTPメッセージ(クライアントから送信されるリクエストメッセージ、またはサーバーから返されるレスポンスメッセージを含む)の
Transfer-Encoding
メッセージヘッダーの値がchunked
である場合、メッセージボディは不定数のチャンクで構成され、最後のサイズが0のチャンクで終了します。 各非空のチャンクは、そのチャンクに含まれるデータのバイト数(16進数で表現)から始まり、CRLF(キャリッジリターンとラインフィード)が続き、その後にデータ自体が続き、最後にチャンクCRLFで終わります。一部の実装では、チャンクサイズとCRLFの間に空白(0x20)が埋め込まれています。 最後のチャンクは単一行で、チャンクサイズ(0)、いくつかのオプションの空白、およびCRLFで構成されます。最後のチャンクにはデータは含まれませんが、メッセージヘッダーフィールドを含むオプションのトレーラーを送信できます。 メッセージは最後にCRLFで終わります。
具体例
店舗のモバイルおよびPCサイトの両方でストリーミングレンダリングの改修が行われ、ファーストビューの表示速度が約200ms向上し、ビジネスデータもそれに伴い向上しました。
店舗の通常レンダリングとストリーミングレンダリングの比較シーケンス図:
エンジニアリング的解決策 Spark
ストリーミングレンダリングの利点は明らかですが、各アプリケーションに導入するには一定のコストがかかります。そこで、店舗の改修アプローチから、ストリーミングレンダリングを迅速に導入できるソリューションを抽出することにしました。
ソリューションの事前検討
ストリーミングレンダリング自体は非常にシンプルで、バックエンドがHTMLを分割して出力するだけです。問題は、ビジネス開発者がいかに効率的かつスムーズに導入できるかです。当初、いくつかのソリューションを検討し、週次ミーティングでチームと共有しました。
案 a
ミドルウェアまたはプラグインを提供し、その中でストリーミングレンダリング関連のロジックを実装します。
呼び出し元はミドルウェアを導入し、手動で設定を定義する方法で、テンプレートとデータの関連付けを実装します。その後、サーバーサイド(NodeまたはJava)が設定に基づいてページをストリーミングレンダリングします。
egg.jsを例にとると:
xxx Controller {
test() {
// spark 为流式渲染中间件挂载在 ctx 上的方法
const pipe = this.ctx.spark();
this.ctx.body = pipe;
// 设置分块渲染的配置
pipe.setPageConfig([{
tpl: 'admin/index-pipe/pipe-1.nj',
getData: (ctx) => Promise.resolve(mergerCommonContext({}, ctx)),
}, {
tpl: 'admin/index-pipe/pipe-2.nj',
getData: ctx => bindGetPageData(ctx),
}]);
// 调用流式渲染方法
pipe.render()
}
}
利点:
- 実装コストが低い
- ビジネスロジックへの侵入が少ない:ビジネスロジックを変更する必要がない
欠点:
- テンプレートの分割が必要
案 b
テンプレートに規約を設けることでページのチャンク化を実現し、サーバーサイドでテンプレートをレンダリングする際に、テンプレートのチャンク内容に基づいてデータを挿入し、チャンクレンダリングを実現します。
例: テンプレート
<!-- spark:start block0 -->
<!doctype html>
<html>
<head>
<title>Test</title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="renderer" content="webkit" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<link
rel="shortcut icon"
href="//is.alicdn.com/favicon.ico"
type="image/x-icon"
/>
</head>
<body>
<!-- spark:end block0 -->
<!-- spark:start block1 -->
<div style="width: 100%">
{% for item in pageData %}
<img
src="http://design3d-zhangbei.oss-cn-zhangjiakou.aliyuncs.com/{{ item.thumbnailOssKey }}"
style="
width: 200px;
height: 200px;
display: inline-block;
vertical-align: top;
"
/>
{% endfor %}
</div>
<!-- spark:end block1 -->
<!-- spark:start block2 -->
<div style="width: 100%">
{% for item in pageData %}
<img
src="http://design3d-zhangbei.oss-cn-zhangjiakou.aliyuncs.com/{{ item.thumbnailOssKey }}"
style="
width: 200px;
height: 200px;
display: inline-block;
vertical-align: top;
"
/>
{% endfor %}
</div>
<!-- spark:end block2 -->
<!-- spark:start block3 -->
<script>
window._PAGE_DATA_ = {{ pageData | default({}) | dump | replace(r/<\/script/ig, '<\\\/script') | safe }};
</script>
</body>
</html>
<!-- spark:start block3 -->
サーバーサイドの呼び出し:
xxx Controller {
test() {
// spark 为流式渲染中间件挂载在 ctx 上的方法
const pipe = this.ctx.spark();
this.ctx.body = pipe;
// 设置分块渲染的配置
pipe.renderBlock( 'block0', {xxx})
pipe.renderBlock( 'block1', {xxx})
pipe.renderBlock( 'block2', {xxx})
pipe.renderBlock( 'block3', {xxx})
}
}
bigPipe
ストリーミングレンダリングは、依然としてサーバーサイドの返却順序に依存します。複数のページブロックでデータ取得とレンダリングが必要な場合、直列に呼び出しと返却を行う必要があります。 ストリーミングレンダリングをさらに一歩進めたものがbigPipeです。これはバックエンドの返却順序に依存しません。データ取得とレンダリングが完了したブロックから順に、そのHTMLをブラウザに返却してレンダリングします。
原理は非常にシンプルで、サーバーサイドはまずページのレイアウトを返却します。その後、各ページブロックがレンダリングされると、script
タグの形式でブラウザに返却されます。例えば:
<script>
spark.render("#id", "<div>page1</div>");
</script>
フロントエンドと合意したメソッドを通じて、JavaScriptでページの特定の部分にHTMLを挿入し、ページのプログレッシブレンダリングを実現します。 利点:
- HTMLの順序に縛られず、より早くコンテンツをブラウザに返却してレンダリングできる 欠点:
- JavaScriptによって挿入されたHTML内のインライン
script
タグ内のJavaScriptは実行されません。例えば、以下のonclick
イベントはトリガーされません:
<script>
spark.render('#id', '<div>page1<script>console.log(123)</script></div>');
</script>
script
タグの内容を別途取り出してeval
しないと実行できません。- フロントエンドコードへの侵入が必要です:ページが順不同で返却されるため、元々
script
の挿入順序に依存して実行順序を保証していたメカニズムが機能しなくなる可能性があります。JavaScriptの実行順序を保証するために、イベントやその他の方法でページのレンダリング状況を通知する必要があり、複雑さが増します。
結論
同僚との検討の結果、主に以下の点が提案されました:
- 案Aと案Bでは、複数のテンプレートを分割する必要がないため、案Bの方が好まれましたが、テンプレート自体を変更する必要があることには抵抗がありました。
- 一部のビジネスでは、複数のビジネスシナリオで一つのテンプレートをレイアウトとして使用するケースがあり、異なるページ分割が必要な場合、案Bには問題があります。
- bigPipeに対する要望は強くありませんでした。なぜなら、改善効果は限定的であるにもかかわらず、コストが大幅に増加するためです。
これらの意見に基づき、既存のソリューションを以下のように改善しました:
- テンプレートは、ストリーミングレンダリングページで使用されるかどうかを気にする必要がありません。
- bigPipeの今後のサポートを削除しました。
最終的なソリューションは以下の通りです:
- まず、プラグインが提供する
SparkRender
メソッドを呼び出し、regList
とdataList
を渡すと、すぐにReadable Stream
がブラウザに返却されます。 regList
に基づいてHTMLを分割し、dataList
と組み合わせてレンダリング待ちのblockQueue
を構成します。blockQueue
の順序に従って、RenderBlock
メソッドを直列に呼び出し、レンダリング結果をReadable Stream
にpush
します。blockQueue
のレンダリングが完了したら、ストリームを閉じ、ストリーミングレンダリングプロセス全体が終了します。
サポート状況
現在、egg.jsベースのNodeアプリケーションのみをサポートしています。
適用シナリオ
パフォーマンスを追求する、バックエンドでのテンプレートレンダリングが必要なすべてのシナリオ。
使用方法
egg.jsベースのNodeアプリケーションの場合、わずか2ステップで導入が完了します:
- アプリケーションに
egg-spark
プラグインを導入し、有効にします:
tnpm install egg-spark
在 `config/plugin.js` 中开启插件
exports.spark = {
enable: true,
package: 'egg-spark',
};
- ストリーミングレンダリングを有効にしたいページのコントローラーにわずかな変更を加えます:既存の
render
メソッドをSparkプラグインが提供するspark.render(tpl, regList, dataList):void
に置き換えます。
引数:
パラメータ | 型 | 意味 |
---|---|---|
tplPath | string | レンダリングするテンプレートのパス。フレームワークが提供するパラメータと同じ意味で、変更不要です。 |
regList | reg[] | ページを分割するための正規表現リスト。regList の長さに基づいてページをregList.length+1 個に分割します。各分割は、現在の正規表現が最初にマッチした文字を基準とします。 |
dataList | object[] / function[] | regList で分割されたページブロックに対応する、レンダリングテンプレートに必要なデータを渡します。データのobject を直接渡すことも、同期または非同期のデータメソッドを渡すこともできます。 |
例:
//原代码:
this.ctx.render("admin/index.nj", mergerCommonContext({ pageData }, this.ctx));
//修改后代码:
this.ctx.spark.render("admin/index.nj", {
// regList 是一个用来切分页面的正则列表,根据 regList 的长度将页面切分为 regList.length+1 份
regList: [/id="root"/],
// dataList:根据 regList 切分的页面块,来传入对应的渲染模板需要的数据。可以传入数据 object ,也可以传入同步或者异步的数据方法
dataList: [
mergerCommonContext({}, this.ctx),
async () => getPageData.call(this),
],
});
これであなたのページはストリーミングレンダリングをサポートします。はい、これほど簡単です。
ベストプラクティス
一般的に、ページは2つのブロックに分割するだけで十分です:
- まず、ページの
css
タグを含む<head>
部分を返却します。これにより、ブラウザはcss
リソースの読み込みと解析を開始できます。 - 同時に、バックエンドはデータ取得などの時間のかかる操作を開始します。
最近リリースされたカスタマイズページを例にとり、Sparkを通じてストリーミングレンダリングを導入する方法を実際に見てみましょう。これはカスタマイズ管理バックエンドのテンプレートadmin/index.nj
です:
<!doctype html>
<html>
<head>
<script>
window._customizePerfTimeTTfb = Date.now();
</script>
...
<link
rel="shortcut icon"
href="//is.alicdn.com/favicon.ico"
type="image/x-icon"
/>
{% block cssContents %}
<link rel="stylesheet" href="{{cssFile}}" />
{% endblock %}
<script>
window._customizePerfTimeCss = Date.now();
</script>
</head>
<body data-spm="customize" class="{{ ctx.session.locale }}">
{% block header %}
<div class="page-header">{% using "mm-sc-new-header-v5" %}</div>
{% endblock %}
<div class="page-main">
<div class="content">
{% block content %}
<div class="page-root" id="root"></div>
<script>
window._PAGE_DATA_ = {{ pageData | default({}) | dump | replace(r/<\/script/ig, '<\\\/script') | safe }};
{% if pageConfig %}
window._PAGE_CONFIG_ = {{ pageConfig | default({}) | dump | safe }};
{% endif %}
</script>
{% endblock %}
</div>
</div>
</body>
</html>
まず<head>
部分の内容を返却し、次に<body>
を返却します。<head>
は静的リソースのバージョンにのみ依存し、ほとんど時間がかかりません。一方、<body>
は時間のかかるデータ取得ロジックに依存します。行うべきことは、admin
ページのcontroller
を変更することです:
module.exports = app => class AdminController extends app.Controller {
async index() {
// 首先对用户进行鉴权
const previliges = await this.service.userInfo.checkPrevilige();
// 调用插件提供的 rende 方法
this.ctx.spark.render('admin/index.nj', {
// 要先返回 head 部分,所以正则需要匹配 head 紧跟着的第一个元素,也就是 <body
regList: [/<body data-spm="customize"/],
dataList: [
// 由于第一部分需要的数据是现成的,直接挂载在 ctx 上,所以直接传入就好了
this.ctx,
// 第二部分是复杂的取数逻辑,将取数的逻辑封装成一个异步方法传进来就好了
async () => getPageData.call(this),
],
});
async function getPageData() {
...耗时逻辑
const pageData = {
...
};
return mergerCommonContext({ pageData }, this.ctx);
}
}
};
これにより、ブラウザがadmin
ページをリクエストすると、指定された正規表現に従ってページが分割され、ストリーミングで返却されます。
注意事項
- リクエストの認証は、
render
メソッドを呼び出す前に行う必要があります。なぜなら、一度ページの返却が始まってしまうと、認証アクションを行うのは手遅れになるからです。
まとめ
当初、この作業を行うことに大きな熱意はありませんでしたが、完成させてみると、わずか半日で2つのビジネスに成功裏に導入でき、ページパフォーマンスの向上も達成できました。結果には非常に満足しています。
この記事は 2021年3月23日 に公開され、2021年3月23日 に最終更新されました。1657 日が経過しており、内容が古くなっている可能性があります。