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

フロント開発がもっと楽しくなる!Vue.js 学習メモ。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 でもこれやったら捗りそう。

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

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

テンプレート本体のデータは親が 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() してます。 他にいい方法無いかな…。

データの扱いとか

内部データは遷移する時にリセットされるので遷移先でも保持したいデータはストアなどの外部データオブジェクトで持つようにします。レンダーで作成していると親に持たせて props でデータを渡す場合内容が変わると再描画されるみたいです。なので props で渡しているテンプレートが変わったタイミングで再描画出来るのですね。

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

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

なおIE10以下でフォールバックする場合は当然ながらストアのデータは保持されません(´•ω•`)

アニメーションを付ける

折角なのでアニメーションも付けてみました(•ө•)ノ あまり長くても本末転倒なので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 みたいに本文はアニメーションしないでパッと変わるけど上にローディングのバーを表示するみたいなのも面白そう。

以前やった nextTick を使ってもうちょっと細かく指定すれば高さが変わるアニメーションも出来そうですが、遷移に時間がかかるのもストレスたまるのでそこまで凝らないでも良いかな…。

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

単一ファイルコンポーネントで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')
// ↓はデモページ用
pagesetup()

まとめ

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

Edited on 2017.04.17 Created on 2017.04.07 Vue.js JavaScript Webデザイン Pjax