◆ SSR で出力された HTML と JavaScript を見てみた

前回 Nuxt を軽く使ったときに 開発用のビルドで出力された HTML は見ましたが 本番用のビルドで出力されたものを見てなかったなと思いました
なんとなく気になったので見てみることにしました

ページ

コンポーネントの組み合わせまでは別に必要ないので単純なページで 3 種類用意しました
SSR であることがわかりやすいように時刻を埋め込んでます

[pages/page10.vue]
<template>
<div data-page="page10">
<div>now: {{new Date().toLocaleString()}}</div>
</div>
</template>

[pages/page11.vue]
<template>
<div data-page="page11">
<div>now: {{new Date().toLocaleString()}}</div>
<NuxtLink to="page12">link</NuxtLink>
</div>
</template>

[pages/page12.vue]
<template>
<div data-page="page12">
<div>now: {{now.toLocaleString()}}</div>
</div>
</template>

<script>
export default {
data() {
return {
now: new Date()
}
},
mounted() {
this.id = setInterval(() => {
this.now = new Date()
}, 1000)
},
destroyed() {
clearInterval(this.id)
},
}
</script>

page11 は page12 への NuxtLink ありで page12 は毎秒画面が更新されるようにしています

ちょっと困ったところで clearInterval を unmounted に指定していたら呼び出されませんでした
タイマー解除しなくても画面上困らず気づけもしないのですが console.log で呼び出し確認していたので気づきました
原因は Vue のバージョンで v3 で名前が変わっていました
unmounted は v3 からです
v2 では destroyed という別の名前です
v3 は一応もうリリースされているのですが Nuxt の依存関係でインストールされるのはまだ v2 のようです

SSR 結果

本番設定でサーブして SSR で出力される HTML を見てみます
設定は特になしで pages フォルダを作って上のファイルを配置しただけです

nuxt の build と start を実行します
start が本番用ビルドのサーバを起動するコマンドですが 事前に build を実行しておく必要があって 未実行だと build が必要と言われます

yarn nuxt build
yarn nuxt start

page10

「/page10」 を開いたときの HTML はこうなっていました
実際は改行なしで minify 済みですがフォーマットしています

<!DOCTYPE html>
<html data-n-head-ssr>
<head>
<link rel="preload" href="/_nuxt/e3bab60.js" as="script" />
<link rel="preload" href="/_nuxt/724e4af.js" as="script" />
<link rel="preload" href="/_nuxt/163c00a.js" as="script" />
<link rel="preload" href="/_nuxt/f4c066b.js" as="script" />
<style data-vue-ssr-id="3191d5ad:0">
.nuxt-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
width: 0;
opacity: 1;
transition: width 0.1s, opacity 0.4s;
background-color: #000;
z-index: 999999;
}
.nuxt-progress.nuxt-progress-notransition {
transition: none;
}
.nuxt-progress-failed {
background-color: red;
}
</style>
</head>
<body>
<div data-server-rendered="true" id="__nuxt">
<!---->
<div id="__layout">
<div data-page="page10"><div>now: 2020/11/30 12:44:32</div></div>
</div>
</div>
<script>
window.__NUXT__ = {
layout: "default",
data: [{}],
fetch: [],
error: null,
serverRendered: true,
routePath: "\u002Fpage10",
config: {},
}
</script>
<script src="/_nuxt/e3bab60.js" defer></script>
<script src="/_nuxt/f4c066b.js" defer></script>
<script src="/_nuxt/724e4af.js" defer></script>
<script src="/_nuxt/163c00a.js" defer></script>
</body>
</html>

ページコンポーネントのテンプレートは id="__nuxt" の中の id="__layout" の中に配置されています
時刻部分が実行したタイミングの時刻になっています

script は 4 つの .js ファイルを preload しておいて最後に defer 付きでロードしてます
preload は自分では使ったことない機能でしたが HTML パースの最初の方にロードは始めてるので script タグが body の最後にあっても実行開始できるまでの時間が短縮されそうです
今回はほぼ body の中身がないですが SSR する以上 数 MB の body になるようなケースもありえますし
ただ defer をつけるなら script タグのダウンロードは処理をブロックせずに行われて body の DOM 構築後に実行されるので preload のところに script タグを書いても同じようになりそうに思うのですが違うものなんでしょうか?

page10.vue ファイルの中身は f4c066b.js に入っていました
残りの 3 つは vue や nuxt や webpack などライブラリ部分のようです

[/_nuxt/f4c066b.js]
(window.webpackJsonp = window.webpackJsonp || []).push([[4], {
150: function(t, e, n) {
"use strict";
n.r(e);
var l = n(19)
, component = Object(l.a)({}, (function() {
var t = this.$createElement
, e = this._self._c || t;
return e("div", {
attrs: {
"data-page": "page10"
}
}, [e("div", [this._v("now: " + this._s((new Date).toLocaleString()))])])
}
), [], !1, null, null, null);
e.default = component.exports
}
}]);

page11

次は 「/page11」 です
ソースコード的には NuxtLink を追加しただけです

<!DOCTYPE html>
<html data-n-head-ssr>
<head>
<link rel="preload" href="/_nuxt/e3bab60.js" as="script" />
<link rel="preload" href="/_nuxt/724e4af.js" as="script" />
<link rel="preload" href="/_nuxt/163c00a.js" as="script" />
<link rel="preload" href="/_nuxt/1d832ce.js" as="script" />
<style data-vue-ssr-id="3191d5ad:0">
.nuxt-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
width: 0;
opacity: 1;
transition: width 0.1s, opacity 0.4s;
background-color: #000;
z-index: 999999;
}
.nuxt-progress.nuxt-progress-notransition {
transition: none;
}
.nuxt-progress-failed {
background-color: red;
}
</style>
</head>
<body>
<div data-server-rendered="true" id="__nuxt">
<!---->
<div id="__layout">
<div data-page="page11">
<div>now: 2020/11/30 12:46:47</div>
<a href="/page12">link</a>
</div>
</div>
</div>
<script>
window.__NUXT__ = {
layout: "default",
data: [{}],
fetch: [],
error: null,
serverRendered: true,
routePath: "\u002Fpage11",
config: {},
}
</script>
<script src="/_nuxt/e3bab60.js" defer></script>
<script src="/_nuxt/1d832ce.js" defer></script>
<script src="/_nuxt/724e4af.js" defer></script>
<script src="/_nuxt/163c00a.js" defer></script>
</body>
</html>

ほぼ同じですが ページコンポーネントに対応していた .js ファイルは 1d832ce.js になっています
NuxtLink の部分はただの a タグになっていて 「/page12」 のロードは HTML 側にはありません
あとは window.__NUXT__ に入ってる情報の routePath が 「/page11」 に変わってます

[/_nuxt/1d832ce.js]
(window.webpackJsonp = window.webpackJsonp || []).push([[5], {
151: function(t, e, n) {
"use strict";
n.r(e);
var l = n(19)
, component = Object(l.a)({}, (function() {
var t = this.$createElement
, e = this._self._c || t;
return e("div", {
attrs: {
"data-page": "page11"
}
}, [e("div", [this._v("now: " + this._s((new Date).toLocaleString()))]), this._v(" "), e("NuxtLink", {
attrs: {
to: "page12"
}
}, [this._v("link")])], 1)
}
), [], !1, null, null, null);
e.default = component.exports
}
}]);

クライアント側のレンダリングのために NuxtLink の情報がありますが ここで 「/page12」 の事前ロードをしてるのでしょうか

page12

HTML はほぼ変わらないのですが一応載せます

<!DOCTYPE html>
<html data-n-head-ssr>
<head>
<link rel="preload" href="/_nuxt/e3bab60.js" as="script" />
<link rel="preload" href="/_nuxt/724e4af.js" as="script" />
<link rel="preload" href="/_nuxt/163c00a.js" as="script" />
<link rel="preload" href="/_nuxt/81ced89.js" as="script" />
<style data-vue-ssr-id="3191d5ad:0">
.nuxt-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
width: 0;
opacity: 1;
transition: width 0.1s, opacity 0.4s;
background-color: #000;
z-index: 999999;
}
.nuxt-progress.nuxt-progress-notransition {
transition: none;
}
.nuxt-progress-failed {
background-color: red;
}
</style>
</head>
<body>
<div data-server-rendered="true" id="__nuxt">
<!---->
<div id="__layout">
<div data-page="page12"><div>now: 2020/11/30 12:47:03</div></div>
</div>
</div>
<script>
window.__NUXT__ = {
layout: "default",
data: [{}],
fetch: [],
error: null,
serverRendered: true,
routePath: "\u002Fpage12",
config: {},
}
</script>
<script src="/_nuxt/e3bab60.js" defer></script>
<script src="/_nuxt/81ced89.js" defer></script>
<script src="/_nuxt/724e4af.js" defer></script>
<script src="/_nuxt/163c00a.js" defer></script>
</body>
</html>

JavaScript 側の処理で毎秒時刻を更新するだけで NuxtLink はないので 「/page10」 とほぼ同じです
data-page と routePath と .js ファイルの名前の違いだけです

[/_nuxt/81ced89.js]
(window.webpackJsonp = window.webpackJsonp || []).push([[6], {
148: function(t, n, e) {
"use strict";
e.r(n);
var o = {
data: function() {
return {
now: new Date
}
},
mounted: function() {
var t = this;
this.id = setInterval((function() {
t.now = new Date,
}
), 1e3)
},
destroyed: function() {
clearInterval(this.id)
}
}
, r = e(19)
, component = Object(r.a)(o, (function() {
var t = this.$createElement
, n = this._self._c || t;
return n("div", {
attrs: {
"data-page": "page12"
}
}, [n("div", [this._v("now: " + this._s(this.now.toLocaleString()))])])
}
), [], !1, null, null, null);
n.default = component.exports
}
}]);

page12 では .vue ファイルに script タグで data や mounted を定義してるのでその分少し長めです
それでも全体的には 思っていたよりシンプルなものでした
一応 nuxt や vue 自体のコードは別の .js ファイルになってるというのもありますが もう少しごちゃごちゃしてるものが出力されると思ってました

サーバとの処理の違い

SSR は Node.js で処理するので基本クライアントのレンダリングと同じ結果になるはずです
しかし 今回使った toLocaleString を見て気づきましたが 環境によっては同じにならないこともありえますね

toLocaleString() は locale 依存なのでサーバが別のタイムゾーンだったら SSR された初回表示のみサーバの locale での表示になります
限られた範囲でしかアクセスできないものならともかく 一般公開するならウェブは全世界からアクセスできるわけでクライアントの locale はバラバラになることは十分ありえます
デフォルトでブラウザはリクエストヘッダで accept-language を送ってるみたいなので サーバ側で判断はできそうですが少し面倒そうです

やっぱり SSR は別になくていいかな

こういう事も考えると SSR ってやっぱり面倒も多そうです
Nuxt のようなツールを使うと楽にできると言っても 完全にそれだけに任せてしまえるわけでもないようですし

以前どこかの記事で書いたように そもそも SSR ってたいして必要性を感じないです
Google は JavaScript を実行してインデックスしてくれますし その他のマイナーなクローラはどうでもいいです
そもそも SEO 自体にたいして興味がないので SSR する理由としては弱いです

パフォーマンス面でも クライアントでレンダリングしても劇的に速度が変わるようには思いませんし むしろできるだけサーバ側の負荷は減らしたいです
あと 基本的にはサーバサイドの言語が Node.js 固定されます
API は別のサーバにできますが 2 種類になるのも管理しづらいと思います

それに将来的に WebComponents が普及して ShadowDOM を使うのがメインになると SSR のしようがないです
ShadowDOM は JavaScript で動的に作るしかないですし ShadowDOM を使わず HTML を出力しても CSS のスコープが違いますし 正しく表示できません
ShadowDOM の外だけを SSR するとしても作りによっては ルートコンポーネント一つだけになることもありえます
body タグの中が app-root タグだけの HTML を SSR で出力しても何も嬉しくないです

Nuxt みたいなお手軽に SSR 対応してるツールを使うなら 上に書いた locale みたいな SSR とレンダリング結果が異なる問題が起きなければ わざわざ SSR 機能を無効にまではしないと思います
しかし こういうツールなしで SPA ライブラリに SSR 用のライブラリを追加・設定してまで SSR する価値があるようにはやっぱり思えませんでした