Nuxt.jsアプリケーションのレスポンスを断片的なHTMLになるようにカスタマイズしてみた

Vue.jsのフレームワークであるNuxt.jsを利用し、SSR(Server Side Rendering)のレスポンスとして全体的なHTMLではなく、断片的なHTMLが欲しい状況に遭遇し、レスポンスをカスタマイズする方法を調べました。
検証に使用したNuxt.jsのバージョンはv2.8.1です。

断片的なHTMLの例

<div class="container">
  Hello World
</div>

Nuxt.jsデフォルトの状態で試す

まずNuxt.jsのデフォルトのレスポンスを確認するために、プロジェクトを作って試します。

$ npx create-nuxt-app nuxt-sample
create-nuxt-app v2.9.0
✨  Generating Nuxt.js project in nuxt-sample
? Project name nuxt-sample
? Project description My peachy Nuxt.js project
? Author name shoito
? Choose the package manager Yarn
? Choose UI framework None
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Choose linting tools (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Choose test framework None
? Choose rendering mode Universal (SSR)

$ cd nuxt-sample
$ yarn dev
yarn run v1.16.0
$ nuxt

   ╭─────────────────────────────────────────────╮
   │                                             │
   │   Nuxt.js v2.8.1                            │
   │   Running in development mode (universal)   │
   │                                             │
   │   Listening on: http://localhost:3000/      │
   │                                             │
   ╰─────────────────────────────────────────────╯

これでプロジェクトの雛形とindexページが作られ、3000番ポートでListening状態になりました。
プログラムが起動できることは確認したので、 pages/index.vue を検証用に編集してレスポンスをシンプルにします。

変更後の pages/index.vue

<template>
  <div class="container">
    Hello World
  </div>
</template>

次に、curlコマンドでレスポンスを確認します。

$ curl localhost:3000
<!doctype html>
<html data-n-head-ssr data-n-head="">
  <head data-n-head="">
    <title data-n-head="true">nuxt-sample</title><meta data-n-head="true" charset="utf-8"><meta data-n-head="true" name="viewport" content="width=device-width, initial-scale=1"><meta data-n-head="true" data-hid="description" name="description" content="My peachy Nuxt.js project"><link data-n-head="true" rel="icon" type="image/x-icon" href="/favicon.ico"><link rel="preload" href="/_nuxt/runtime.js" as="script"><link rel="preload" href="/_nuxt/commons.app.js" as="script"><link rel="preload" href="/_nuxt/vendors.app.js" as="script"><link rel="preload" href="/_nuxt/app.js" as="script"><link rel="preload" href="/_nuxt/pages/index.js" as="script"><link rel="preload" href="/_nuxt/pages/index.398aef1ad33db90ea3e7.hot-update.js" as="script"><style data-vue-ssr-id="17cfdfa9:0">
.nuxt-progress {
  position: fixed;
  top: 0px;
  left: 0px;
  right: 0px;
  height: 2px;
  width: 0%;
  opacity: 1;
  transition: width 0.1s, opacity 0.4s;
  background-color: #fff;
  z-index: 999999;
}
.nuxt-progress.nuxt-progress-notransition {
  transition: none;
}
.nuxt-progress-failed {
  background-color: red;
}
</style><style data-vue-ssr-id="6da220d7:0">
.nuxt__build_indicator[data-v-71e9e103] {
  box-sizing: border-box;
  position: absolute;
  font-family: monospace;
  bottom: 20px;
  right: 20px;
  background-color: #2E495E;
  padding: 5px 10px;
  border-radius: 5px;
  box-shadow: 1px 1px 2px 0px rgba(0,0,0,0.2);
  color: #00C48D;
  width: 84px;
  z-index: 2147483647;
}
.v-enter-active[data-v-71e9e103], .v-leave-active[data-v-71e9e103] {
  transition-delay: 0.2s;
  transition-property: all;
  transition-duration: 0.3s;
}
.v-leave-to[data-v-71e9e103] {
  opacity: 0;
  transform: translateY(20px);
}
svg[data-v-71e9e103] {
  width: 1.1em;
  position: relative;
  top: 1px;
}
</style><style data-vue-ssr-id="aab9a468:0">
html {
  font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI',
    Roboto, 'Helvetica Neue', Arial, sans-serif;
  font-size: 16px;
  word-spacing: 1px;
  -ms-text-size-adjust: 100%;
  -webkit-text-size-adjust: 100%;
  -moz-osx-font-smoothing: grayscale;
  -webkit-font-smoothing: antialiased;
  box-sizing: border-box;
}
*,
*:before,
*:after {
  box-sizing: border-box;
  margin: 0;
}
.button--green {
  display: inline-block;
  border-radius: 4px;
  border: 1px solid #3b8070;
  color: #3b8070;
  text-decoration: none;
  padding: 10px 30px;
}
.button--green:hover {
  color: #fff;
  background-color: #3b8070;
}
.button--grey {
  display: inline-block;
  border-radius: 4px;
  border: 1px solid #35495e;
  color: #35495e;
  text-decoration: none;
  padding: 10px 30px;
  margin-left: 15px;
}
.button--grey:hover {
  color: #fff;
  background-color: #35495e;
}
</style>
  </head>
  <body data-n-head="">
    <div data-server-rendered="true" id="__nuxt"><!----><!----><div id="__layout"><div><div class="container">
  Hello World
</div></div></div></div><script>window.__NUXT__={layout:"default",data:[{}],error:null,serverRendered:true,logs:[]};</script><script src="/_nuxt/runtime.js" defer></script><script src="/_nuxt/pages/index.js" defer></script><script src="/_nuxt/pages/index.398aef1ad33db90ea3e7.hot-update.js" defer></script><script src="/_nuxt/commons.app.js" defer></script><script src="/_nuxt/vendors.app.js" defer></script><script src="/_nuxt/app.js" defer></script>
  </body>
</html>

Hello World とそれをラップするdivタグ以外にも沢山の情報が付いてきてますね。
これは期待するレスポンスではありません。

レイアウトを変更して試す

Nuxt.jsはレイアウトの機能があり、ページ毎にレイアウトを指定することができます。
https://ja.nuxtjs.org/api/pages-layout/

上記で編集した pages/index.vue では layout の指定はしていないのでデフォルト( layouts/default.vue )が使われます。

変更前の layouts/default.vue

<template>
  <div>
    <nuxt />
  </div>
</template>

<style>
html {
  font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI',
    Roboto, 'Helvetica Neue', Arial, sans-serif;
  font-size: 16px;
  word-spacing: 1px;
  -ms-text-size-adjust: 100%;
  -webkit-text-size-adjust: 100%;
  -moz-osx-font-smoothing: grayscale;
  -webkit-font-smoothing: antialiased;
  box-sizing: border-box;
}

*,
*:before,
*:after {
  box-sizing: border-box;
  margin: 0;
}

.button--green {
  display: inline-block;
  border-radius: 4px;
  border: 1px solid #3b8070;
  color: #3b8070;
  text-decoration: none;
  padding: 10px 30px;
}

.button--green:hover {
  color: #fff;
  background-color: #3b8070;
}

.button--grey {
  display: inline-block;
  border-radius: 4px;
  border: 1px solid #35495e;
  color: #35495e;
  text-decoration: none;
  padding: 10px 30px;
  margin-left: 15px;
}

.button--grey:hover {
  color: #fff;
  background-color: #35495e;
}
</style>

curlコマンドのレスポンスに含まれていた <style> の一部は layouts/default.vue で指定されているものでした。
今回の検証では不要なので、消してシンプルにします。

変更後の layouts/default.vue

<template>
  <nuxt />
</template>

この状態で、再度curlコマンドでレスポンスを確認します。

$ curl localhost:3000
<!doctype html>
<html data-n-head-ssr data-n-head="">
  <head data-n-head="">
    <title data-n-head="true">nuxt-sample</title><meta data-n-head="true" charset="utf-8"><meta data-n-head="true" name="viewport" content="width=device-width, initial-scale=1"><meta data-n-head="true" data-hid="description" name="description" content="My peachy Nuxt.js project"><link data-n-head="true" rel="icon" type="image/x-icon" href="/favicon.ico"><link rel="preload" href="/_nuxt/runtime.js" as="script"><link rel="preload" href="/_nuxt/commons.app.js" as="script"><link rel="preload" href="/_nuxt/vendors.app.js" as="script"><link rel="preload" href="/_nuxt/app.js" as="script"><link rel="preload" href="/_nuxt/app.45da00be42239c2f97e8.hot-update.js" as="script"><link rel="preload" href="/_nuxt/pages/index.js" as="script"><style data-vue-ssr-id="17cfdfa9:0">
.nuxt-progress {
  position: fixed;
  top: 0px;
  left: 0px;
  right: 0px;
  height: 2px;
  width: 0%;
  opacity: 1;
  transition: width 0.1s, opacity 0.4s;
  background-color: #fff;
  z-index: 999999;
}
.nuxt-progress.nuxt-progress-notransition {
  transition: none;
}
.nuxt-progress-failed {
  background-color: red;
}
</style><style data-vue-ssr-id="6da220d7:0">
.nuxt__build_indicator[data-v-71e9e103] {
  box-sizing: border-box;
  position: absolute;
  font-family: monospace;
  bottom: 20px;
  right: 20px;
  background-color: #2E495E;
  padding: 5px 10px;
  border-radius: 5px;
  box-shadow: 1px 1px 2px 0px rgba(0,0,0,0.2);
  color: #00C48D;
  width: 84px;
  z-index: 2147483647;
}
.v-enter-active[data-v-71e9e103], .v-leave-active[data-v-71e9e103] {
  transition-delay: 0.2s;
  transition-property: all;
  transition-duration: 0.3s;
}
.v-leave-to[data-v-71e9e103] {
  opacity: 0;
  transform: translateY(20px);
}
svg[data-v-71e9e103] {
  width: 1.1em;
  position: relative;
  top: 1px;
}
</style>
  </head>
  <body data-n-head="">
    <div data-server-rendered="true" id="__nuxt"><!----><!----><div id="__layout"><div class="container">
  Hello World
</div></div></div><script>window.__NUXT__={layout:"default",data:[{}],error:null,serverRendered:true,logs:[]};</script><script src="/_nuxt/runtime.js" defer></script><script src="/_nuxt/pages/index.js" defer></script><script src="/_nuxt/commons.app.js" defer></script><script src="/_nuxt/vendors.app.js" defer></script><script src="/_nuxt/app.js" defer></script><script src="/_nuxt/app.45da00be42239c2f97e8.hot-update.js" defer></script>
  </body>
</html>

layouts/default.vue から削除した <style> は含まれていませんが、まだまだ不要な情報があります。
これも期待するレスポンスではありません。

アプリテンプレートを変更して試す

次にNuxt.jsのアプリテンプレートを変更します。
https://ja.nuxtjs.org/guide/views/#アプリテンプレート

デフォルトのテンプレートとしてこちらが使われます。
https://github.com/nuxt/nuxt.js/blob/2.x/packages/vue-app/template/views/app.template.html

<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
  <head {{ HEAD_ATTRS }}>
    {{ HEAD }}
  </head>
  <body {{ BODY_ATTRS }}>
    {{ APP }}
  </body>
</html>

テンプレートを変更するためには、プロジェクトのルートフォルダに app.html を作成するとのことなので、断片的なHTMLを返すように以下のようにします。

{{ APP }}

この状態で、再度curlコマンドでレスポンスを確認します。

$ curl localhost:3000
<div data-server-rendered="true" id="__nuxt"><!----><!----><div id="__layout"><div class="container">
  Hello World
</div></div></div><script>window.__NUXT__={layout:"default",data:[{}],error:null,serverRendered:true,logs:[]};</script><script src="/_nuxt/runtime.js" defer></script><script src="/_nuxt/pages/index.js" defer></script><script src="/_nuxt/commons.app.js" defer></script><script src="/_nuxt/vendors.app.js" defer></script><script src="/_nuxt/app.js" defer></script>

期待しているレスポンスに近づいてきましたが、まだ不要な <script> タグがあります。
これも期待するレスポンスではありません。

injectScriptsオプションを変更して試す

この <script> タグはNuxt.jsで挿入されているタグなので、 nuxt.config.jsrender プロパティの injectScripts オプションに false を指定することで取り除けます。

https://ja.nuxtjs.org/api/configuration-render/#injectscripts

変更後の nuxt.config.js の一部抜粋

export default {
  mode: 'universal',
  render: {
    injectScripts: false
  },
  ...

この状態で、再度curlコマンドでレスポンスを確認します。

$ curl localhost:3000
<div data-server-rendered="true" id="__nuxt"><!----><!----><div id="__layout"><div class="container">
  Hello World
</div></div></div>

前の <div data-server-rendered="true" id="__nuxt"><!----><!----><div id="__layout"> と後ろの </div></div> が余計ですが、だいぶ削れました。
(これはNuxt.jsアプリケーションのルート要素とレイアウト要素になります)

しかし、これも期待するレスポンスではありません。
あと、もう一歩!

bundleRendererオプションを変更して試す

時間内に、残ってしまったNuxt.jsアプリケーションのルート要素である <div data-server-rendered="true" id="__nuxt">... を付与しないオプションは見つけられませんでした。

app.html アプリテンプレートに残した、 {{ App }} ですが、Nuxt.jsのコードを見ると、こちらのrender関数を使ってる限り変更はできなそうに見えました。

https://github.com/nuxt/nuxt.js/blob/2.x/packages/vue-app/template/App.js#L24

最終手段として nuxt.config.jsrender プロパティの bundleRenderer オプションの template を変更して、 String.prototype.replace() により前後のdivタグを削ってみました。

https://ja.nuxtjs.org/api/configuration-render/#bundlerenderer https://ssr.vuejs.org/ja/api/#レンダラオプション

export default {
  mode: 'universal',
  render: {
    injectScripts: false,
    bundleRenderer: {
      template: (result, context) => {
        result = result.replace(
          /^<div data-server-rendered="true" id="[^>]+">[<!->]*<div id="__layout">/i,
          ''
        )
        result = result.replace(/(<\/div>){2}$/i, '')
        return `${result}`
      }
    }
  },

なお、 render:routehooks でHTMLを書き換える形で同様のことは可能です。
https://ja.nuxtjs.org/api/internals-renderer/#フック

この状態で、再度curlコマンドでレスポンスを確認します。

$ curl localhost:3000
<div class="container">
  Hello World
</div>

無理やり感満載ですが、やっと求めていた断片的なHTMLがレスポンスとして返りました!

さいごに

今回のレスポンスのカスタマイズのやり方では、アプリテンプレート(app.html)を変更しているので、Nuxt.jsアプリケーション全体のレスポンスが断片的なHTMLに変わってしまいます。
このやり方では、ルーティング次第で全体的なHTMLを返すエンドポイントと、断片的なHTMLを返すエンドポイントがNuxt.jsアプリケーション内で共存はできないので注意が必要です。