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

更新日
2017.09.08
作成日
2017.04.07

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

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

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

出来上がったデモ

※ IE10以下は非対応

Pjaxの設定

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

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

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

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

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

~ヘッダーとか~
<pjax-component>
<div id="pjax-content" v-pre>
 <p>コンテンツ</p>
</div>
</pjax-component>
~フッターとか~

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

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

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

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

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

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

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

コンポーネント内容の作成は描画関数でするのでコンポーネント自体には functional を設定しています。

var pjaxComponent = {
  functional: true,
  props: { template : String },
  render: function (h, context) {
    var component = {
      template: '<div id="page-wrap"><div id="pjax-content">'+ context.props.template + '</div></div>',
      data: function () {
        return {}
      },
    }
    return h(component)
  }
}

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

var app = new Vue({
  store: store,
  data: {
    title: 'demo1',
    contentTemplate: document.getElementById('pjax-content').innerHTML,
  },
  components: {
    'pjax-component': pjaxComponent
  },
  methods: {
    pjaxLoad: function () {
      var html = document.getElementById('pjax-content').innerHTML
      var update = (html == this.contentTemplate)
      this.contentTemplate = html
      if (update) {
        this.$forceUpdate()
      }
    },
  },
  mounted: function () {
    window.addEventListener('pjax:load', this.pjaxLoad, false)
  },
  beforeDestroy: function () {
    window.removeEventListener('pjax:load', this.pjaxLoad, false)
  }
}).$mount('#app')

同じページをクリックした時テンプレートがパースされないので this.$forceUpdate() してます。 他にいい方法無いかな…。

データの扱いとか

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

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

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

アニメーションを付ける

折角なのでアニメーションも付けてみました(•ө•)ノ あまり長くても本末転倒なので400msでススっと変わる程度です。v-cloak と同じような感じで CSS で簡単に付けました。

template: '<div id="page-wrap" :data-enabled="loaded"> ...

と設定してコンポーネント内の mounted のタイミングで変更するようにしておきます。詳しくは一番下のコードを見てください。

@keyframes pjax-load {
0% { opacity: 0 }
100% { opacity: 1 }
}
#page-wrap{
opacity: 1;
animation: .4s pjax-load;
}
#page-wrap[data-enabled="0"] {
opacity: 0;
}

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

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

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

コード全体

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

// 全体で使うデータはVuexとかで管理
var store = new Vuex.Store({
  state: {
    count: 0,
  },
  mutations: {
    increment: function(state) {
      state.count++
    }
  }
})

// 記事中のコンポーネント
var innerComponent = Vue.extend({
  template: '<p><span style="color:red"><slot/><span></p>'
})

// Pjax用のコンポーネント
var pjaxComponent = {
  functional: true,
  props: { template : String },
  render: function (h, context) {
    var component = {
      template: '<div id="page-wrap" :data-enabled="loaded"><div id="pjax-content">' + context.props.template + '</div></div>',
      data: function () {
        return {
          count: 0,
          loaded: 0,
        }
      },
      computed: {
        storeCount: function () { return this.$store.state.count }
      },
      methods: {
        increment: function () {
          this.count++
        },
        incrementStore: function () {
          this.$store.commit('increment')
        },
        pjaxReady: function () {
          // Pjax の準備が出来たら一旦表示を消す
          this.loaded = 0
        }
      },
      created: function () {
        document.addEventListener('pjax:ready', this.pjaxReady, false)
      },
      mounted: function() {
        // 表示する
        this.loaded = 1
      },
      beforeDestroy: function () {
        document.removeEventListener('pjax:ready', this.pjaxReady, false)
      },
      components: { 'inner-component': innerComponent },
    }
    return h(component)
  }
}

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

// ルート
var app = new Vue({
  store: store,
  data: {
    title: 'demo1',
    contentTemplate: document.getElementById('pjax-content').innerHTML,
  },
  components: {
    'pjax-component': pjaxComponent
  },
  methods: {
    pjaxLoad: function () {
      // template を更新
      var html = document.getElementById('pjax-content').innerHTML
      // 同じページで更新されない対策
      var update = (html == this.contentTemplate)
      this.contentTemplate = html
      if (update) {
        this.$forceUpdate()
      }
    },
  },
  mounted: function () {
    window.addEventListener('pjax:load', this.pjaxLoad, false)
  },
  beforeDestroy: function () {
    window.removeEventListener('pjax:load', this.pjaxLoad, false)
  }
}).$mount('#app')

改善したいところ

上にも書いたとおり使っている 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'
}

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

まとめ

ネイティブな要素の操作部分はカスタムディレクティブとかで Vue っぽく出来ないかなぁと考え中です。でもやりたい事が大体出来そうなのであとでサイトでも動かしてみたいと思います。既にある程度作り込んでいるサイトとかだとなかなか SPA サイトに出来ない事もあると思うので、そんな時は Vue+Pjax という選択肢もありじゃないかなぁ~と思います\(*⁰▿⁰*)/