Vue.js の描画関数を使って Pjax を連携する

最終更新日
2017.10.28

SPA 化がつらいサイトで Vue と Pjax を一緒に動かす方法を模索してみました。

ひさしぶりの記事です。この1ヶ月ほど人生初めての入院&手術とか体験してしたので勉強が進んでません。

私のサイトは SPA サイトにする仕組みが出来ていないのでお手軽に Pjax を導入できないかなーと前々から考えていたんですけど Vue で作り込む場合やっぱり SPA と SSR にしてしまう人が多いみたいで Vue+Pjax の情報が全然見つからない・・・。結局導入はしていないんですけど記事にしたいと思います。

デモ

遷移先が取得されるまでは現在のページはそのまま表示しておきたいんですけど、たまにパース前の状態がチラっと見えてしまうのでもうちょっとタイミングを模索したいところ。

※ IEは非対応

Pjaxの設定

Pjax のライブラリは falsandtru製の Pjax-Api を使用しました。公式サイトはこちら。ツイッターカードなどヘッダーの内容も書き換えてくれる高機能なライブラリです。他のライブラリを使っても同じような感じで実装出来ると思います。ちなみに、Pjax-Api は polyfill を使った場合でも IE10 以下は非対応です。うちのようなWeb情報系サイトならIEのシェアは限りなく低いので気にしなくてもいいと思いますが、気になる方はフォールバックや別のライブラリを使うと良いかもです。

const Pjax = require('pjax-api').Pjax
new Pjax({
  link: 'a:not([target]):not([href*="#"]):not([href^="http"])',
  areas: ['#pjax-content']
})

このライブラリは複数のエリアと優先エリアを設定出来るんですが、ひとまずメインのエリア #pjax-content だけ遷移させる事にします。

親テンプレートの一部をテンプレートとして使う

記事本文に Vue の記述が含まれていて、既存の構造から出来るだけ手を加えずに記事本文の内容だけ使いたいのですが、親コンポーネントの内側にそのまま記述してあると親のテンプレートのスコープ内と認識されてしまってデータが存在しないよ!と怒られます。inline-templatev-pre を使って親が無視するようにします。Pjax 側の仕様で template や x-template は使用出来ませんでした。

<pjax-component :template="template" :loaded="loaded">
  <div id="pjax-content" v-pre>
    <p>コンテンツ</p>
  </div>
</pjax-component>

Pjax用のコンポーネント pjax-component を作成してその中に遷移コンテンツが入るようにしました。リンクをクリックすると Pjax でこの #pjax-content 部分がまるっと遷移先のコンテンツと入れ替わります。

取得したコンテンツをテンプレートとして使う

次は記事本文に Vue の記述を含めたいので Pjax で取得したコンテンツをデータではなくテンプレートとして認識させます。最初のうちは pjax:ready のイベントハンドラで 動的コンポーネントの方法でインスタンスごと作り直してましたが、途中で描画関数を試したくなったので描画関数の中でコンポーネント本体を作成するようにしてみました(๑'ᴗ'๑)

描画関数は難しそうで今まで使ったことがなかったんですけど動的なテンプレートを作る場合にすごい便利!

ユーザー入力の無いサイトを想定しているので取得したHTMLはテンプレートとしてコンパイルしますが、コメント表示のようなものをそのまま一緒にコンパイルすれば XSS が起きる危険があります。ユーザーが入力したデータも一緒に取得する場合は v-pre を使ったりエスケープするなど通常の XSS 対策をする必要があります。

描画関数でテンプレートを作成する

テンプレート本体のデータは親が template で持っていて props で Pjax 用コンポーネントに渡して、親の template が変更されると Pjax 用コンポーネントのテンプレートが変更されるようになります。

テンプレートで使う内容を props からほしかったのでコンポーネント自体には functional を設定しています。

// Pjax用のコンポーネント
Vue.component('pjax-component', {
  functional: true,
  props: { template : String, loaded: Number },
  render(h, context) {
    return h({
      name: 'PjaxComponent',
      template: `
      <div id="pjax-wrap" :data-pjax="${context.props.loaded}">
        <div id="pjax-content">${context.props.template}</div>
      </div>`
    })
  }
})

親(ルート)インスタンス

new Vue({
  el: '#app',
  data: {
    title: 'demo1',
    loaded: 1,
    template: document.getElementById('pjax-content').innerHTML
  },
  methods: {
    // Pjax開始
    pjaxFetch() {
      this.loaded = 0
    },
    // Pjax終了
    pjaxLoad() {
      this.template = document.getElementById('pjax-content').innerHTML
      this.loaded = 1
    }
  },
  mounted() {
    window.addEventListener('pjax:fetch', this.pjaxFetch, false)
    window.addEventListener('pjax:load', this.pjaxLoad, false)
  },
  beforeDestroy() {
    window.removeEventListener('pjax:fetch', this.pjaxFetch, false)
    window.removeEventListener('pjax:load', this.pjaxLoad, false)
  }
})

データの扱いとか

普通の Vue で構築するように Vuex も使用できます。レンダー関数で作ったコンポーネントは props で受け取っているデータや自分自身が使用するストアのデータが変わると再描画されるみたいです。なので props で渡しているテンプレートが変わったタイミングで再描画出来るのですね。

それから functional の場合 $emit は コンポーネント作成時に context.data.on.eventName でバインドする必要がありました。

this.$on('event-name', context.data.on.eventName)

アニメーションを付ける

折角なのでアニメーションも付けてみました。Vue のトランジションを使うとIDが重複して Pjax との折り合いが面倒くさそうだったので CSS のみにしました。

<div id="pjax-wrap" :data-pjax="${context.props.loaded}">
  <div id="pjax-content">${context.props.template}</div>
</div>

pjax:fetchpjax:load を使ってトランジションキーを更新しています。

@keyframes pjax-in {
  0% {
    transform: translateX(-20px);
    opacity: 0
  }
  100% {
    transform: translateX(0);
    opacity: 1
  }
}
#pjax-wrap[data-pjax="0"] {
  opacity: 0;
}
#pjax-wrap[data-pjax="1"] {
  animation: pjax-in 1s;
}

ここらへんは SPA も一緒ですが GitHub みたいに本文はアニメーションしないでパッと変わるけど上にローディングのバーを表示するみたいなのも面白そう。

単一ファイルコンポーネントでCSSを書く時の注意

今回使ったライブラリではヘッダーのスタイルタグが書き換えられるの単一ファイルコンポーネントに記述してヘッダーに加えられる CSS はページ切り替え時に消えてしまいます。 vue-cli の webpack テンプレートのように外部ファイルに CSS を抽出する必要があります。こちらのページの下の方に書いています。

コード全体

ちょっと長いですがコード全体はこんな感じです。

// 記事中のコンポーネント
Vue.component('inner-component', {
  data() {
    return {
      count: 0,
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
})

// Pjax用のコンポーネント
/*
const pjaxComponentContent = {
  functional: true,
  render(h, context) {
    return h({
      name: 'PjaxComponentContent',
      template: '<div id="pjax-wrap"><div id="pjax-content">' + context.props.template + '</div></div>'
    })
  }
}*/
Vue.component('pjax-component', {
  functional: true,
  props: { template : String, loaded: Number },
  render(h, context) {
    return h({
      name: 'PjaxComponentContent',
      template: `
      <div id="pjax-wrap" :data-pjax="${context.props.loaded}">
        <div id="pjax-content">${context.props.template}</div>
      </div>
      `
    })
  }
})

// Pjaxの設定
const Pjax = require('pjax-api').Pjax
new Pjax({
  link: 'a:not([target]):not([href*="#"]):not([href^="http"])',
  areas: ['#pjax-content'],
})

new Vue({
  el: '#app',
  data: {
    title: 'demo1',
    loaded: 1,
    template: document.getElementById('pjax-content').innerHTML
  },
  methods: {
    // Pjax開始
    pjaxFetch() {
      this.loaded = 0
    },
    // Pjax終了
    pjaxLoad() {
      this.template = document.getElementById('pjax-content').innerHTML
      this.loaded = 1
    }
  },
  mounted() {
    window.addEventListener('pjax:fetch', this.pjaxFetch, false)
    window.addEventListener('pjax:load', this.pjaxLoad, false)
  },
  beforeDestroy() {
    window.removeEventListener('pjax:fetch', this.pjaxFetch, false)
    window.removeEventListener('pjax:load', this.pjaxLoad, false)
  }
})

改善したいところ

上にも書いたとおり使っている Pjax ライブラリでは Vue が生成するヘッダーのスタイルシートが遷移後に消えてしまうんですが、開発には Vue-cli を使っていて開発段階だと CSS はドキュメントのヘッダに入るため、とりあえず開発モードの Pjax 遷移した時だけビルドしてある CSS を読み込むようにしてあります。ホットリロードで CSS の更新を見たい場合はリロードして一旦ランディングページにする必要があります。遷移後のページでも動的な CSS を読めるようにうまい事考えたいです。

if (process.env.NODE_ENV === 'development') {
  const newStyle = document.createElement('link')
  document.head.appendChild(newStyle)
  newStyle.href = '/dist/static/css/app.css'
  newStyle.rel = 'stylesheet'
  newStyle.type = 'text/css'
}

まとめ

少し作り直したんですけど、切り替えの時に2つのコンポーネントが両方共切り替え後の内容になってしまうので leave は表示しないようにちょっとごまかしてます。もう少しスッキリ書けるといいんですけど・・・あとでまた考えてみたい。既にある程度作り込んでいるサイトとかだとなかなか SPA サイトに出来ない事もあると思うので、そんな時は Vue+Pjax という選択肢もありじゃないかなぁ~と思います。