RareJob Tech Blog

レアジョブのエンジニア・デザイナーによる技術ブログです

Vueプロジェクトで使えるちょっと便利なTips集

はじめまして、開発本部 APP/UXチームの一員として、フロントエンドエンジニアとして活動させて頂いております、 田原(ドンキーorDKというあだ名で生きております)と申します。
先日の弊社ブログのコチラの記事にも紹介があります、 WebRTCを利用したレッスンルームというプロダクトにおける、Webアプリケーションの開発をメイン業務としております。

当Webアプリケーションはフロントエンドフレームワークとして、Vue.jsを採用し開発をおこなっておりますので、 今回はVue.jsでアプリケーションを実装していくにあたっていくつか(備忘録も兼ねた)Tipsのご紹介をさせて頂ければと思います。

お手柔らかにお願いします。

watch immediateについて

コンポーネントの初期化のタイミング(lifecycle hookのcreated)で何かしらのrouteのparamsを引数にしてdataを取得してきていると仮定した場合、記述は以下のようになるかと思います。 ※ここでは教材のデータを取得しているとします。

created() {
  this.fetchLessonMaterialData(this.$route.params.id)
}

Vueでは遷移先が同一コンポーネントである場合、ex:/lesson_material/1234から/lesson_material/5678に 遷移した場合、コンポーネントのcreatedが再び呼ばれることはなく(仮想DOM等の差分を検知できず、インスタンスを使いまわそうとするため) fetchすべきdataをうまく取得できないということが起きます。 このため以下の様にwatchの処理の中にdata fetchの処理も書く必要があります。

watch:{
  $route($route) {
    this.fetchLessonMaterialData($route.params.id)
  }
}

immediateのプロパティを使用して以下のように記述することで コンポーネントが作成されるとすぐにハンドラが呼び出されるようになります。 これにより、createdで行っていたdata fetchの処理と同様の処理が走るようになりますので createdで記述していたdata fetchの処理を削除することができ、少し見通しがよくなります。 ※ただ、この即時watcherのイベントハンドラは順序的にはcreatedの処理の直後に実行されることになるため、注意が必要です。

watch:{
  $route: {
    handler($route) {
      this.fetchLessonMaterialData($route.params.id)
    },
    Immediate: true
  }
}

基底componentの自動登録について

コチラについては公式のコチラに詳細の説明がある通り、 bundle時のエントリーポイントで基底のコンポーネントをグローバルにインポートする方法です。

const requireComponent = require.context(
  './components/',
  true,
  /\.vue$/
)
requireComponent.keys().forEach(relativeFilePath => {
  const componentConfig = requireComponent(relativeFilePath)
  const fileName = relativeFilePath.split('/').pop()
  if (fileName) {
    const componentName = upperFirst(camelCase(fileName.replace(/\.\w+$/, '')))
    Vue.component(
      componentName,
      componentConfig.default || componentConfig
    )
  }
})

directory sample

├── atoms
│   └── HogeButton.vue
├── molecules
│   └── HogeChatBox.vue
├── organisms
│   └── HogeHeader.vue
├── pages
│   ├── Lesson.vue
└── templates
    └── HogeLayout.vue

弊社、WebRTCのプロダクトにおいてcomponent毎に別のcomponentを参照する頻度が多い為、この方法を採用して実装しております。 componentのdirectory構造については、上記sampleに記載にあるようなAtomicDesignを踏襲している為、relativeFilePathの末尾のファイル名を component名として登録しております。 この方法については懸念としてはglobalに全てのcomponentをimportすることになるため、bundleファイルのサイズが増大してしまうことが上げられますが、 ほぼ単一ページのWebアプリケーションである為、この方法を採用しております。

this.$on('hook')を利用し、他のlifecycle hookの定義を避ける方法

lifecycle hookの処理の中のmountedのタイミングでカスタムイベントリスナーを追加し、メモリリーク発生を防ぐ為に beforDestoryのタイミングでイベントリスナーを破棄しなければならない時など、通常はlifecycle hookの定義を並べて書く必要がありますが、 $on('hook:)を使用するとlifecycle hookの定義を避けることができます。 これにより、Vueのlifecycle hook処理の見通しがよくなります。

並べて書く場合

mounted() {
  this.cunstomEvent = cunstomEvent()
},
beforeDestory() {
  this.cunstomEvent
}

$on('hook:)の利用

mounted() {
  const cuntomEvent = customElements()
  this.$on('hook:beforeDestory', () => {
    cuntomEvent
  })
}

同一リポジトリ内で2つ以上のAppを立ち上げる場合のpackage.json設定について (vue-cli3系)

弊社ではプロジェクトを構成するツールとしてvue-cli(3系)を採用しております。 初期の準備が全て整った後、(global install・create等によるプロジェクトの準備についての説明については公式をご確認ください。) 通常、serve コマンドで一つのアプリケーションが立ち上がるよう設定がなされておりますが、 PC・SPとアプリケーションを切り分けて立ち上げたい時など(弊社の場合は生徒側と講師側のアプリケーションを同一directoryで立ち上げたかった) 初期状態のままでは、期待する動作が行えない為、package.jsonに少し修正を加える必要があります。

初期のpackage.json

"scripts": {
    "serve": "vue-cli-service serve",
    ︙

上記のままではserveコマンドで立ち上がるアプリケーションを分けることが出来ないため、以下の様に変更を加えます。

device毎にserve(アプリケーションの立ち上げを分けた場合)

"scripts": {
    "serve-pc": "VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-pc.js",
    "serve-sp": "VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-sp.js",
    ︙

上記のように環境変数であるVUE_CLI_SERVICE_CONFIG_PATHにルールとして設定したいvue.config.js設定を絶対Pathで指定します。 ※次の項目にも後述しますが、vue.config.jsはdevice毎や環境毎に自由に設定出来ますので任意のvue.config.jsを作成してください。 (尚、ここではpc/spと環境を分けた場合を想定して指定した場合としております。) VUE_CLI_SERVICE_CONFIG_PATHはvue-cli内部のコチラで定義されており、これに 読み込ませたいvue.config.jsのpathを代入している形になります。

また、上記の指定のままではwindows上でserveコマンドを叩いた際に失敗するのでnpm modulesのcross-envなどを利用します。

windows対応ver(cross-envをmodules importした上で)

"scripts": {
    "serve-pc": "cross-env VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-pc.js",
    "serve-sp": "cross-env VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-sp.js",
    ︙

serveコマンドだけでなく、testbuildについても同様に対応が可能です。 懸念としてはpackage.json内の記載がどうしても増えてしまうので、別にシェルスクリプトなどを準備するとスッキリします。

vue.config.jsの設定周辺について

上記のpackage.jsonの説明にあります通り、vue-cliではbundle条件のレシピとして、vue.config.jsの指定が必要になります。 これはvue-cliでcreateしたアプリケーション毎に一つだけしかダメということはなく、(上記にあります通り)同一directory内で 別アプリケーションとしてserveコマンドによる立ち上げを行いたい時など、複数指定することが可能です。

その際、以下の様にどのコマンドでどのvue.config.jsを読み込ませるかの指定をする必要があります。 ※以下の場合はプロジェクトrootにconfig/vue.config-xx.jsを指定していると想定した場合になります。

"scripts": {
    "serve-pc": "cross-env VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-pc.js",
    "serve-sp": "cross-env VUE_CLI_SERVICE_CONFIG_PATH=$PWD/config/vue.config-sp.js",
    ︙

vue.config.jsの周辺設定については公式に記法についての詳細が書かれており、 とても参考になります。 参考までに雛形を以下に用意しております。(pathやfile名は適宜読み替えてください)

module.exports = {
  // local serveでの起動時のwebpack devServerの設定
  devServer: {
    port: 8081,
    https: false
  },
  // 吐き出されるindex.htmlが読み込むjsやcssのrootにもなる。
  publicPath: '/sample',
  // build時のdistファイルのアウトプット先
  outputDir: 'dist/',
  // sourcemap出力の可否
  productionSourceMap: false,
  // 対象のappファイル等の設定
  pages: {
    index: {
      // vue.config.jsをdeviceで分けている場合などentryをどの基底ファイルにするか
      entry: 'src/pc/app.ts',
      template: 'public/pc/index.html',
      // production build時に拡張子変更したい場合など
      filename: process.env.NODE_ENV === 'production' ? 'index.php' : 'index.html',
      chunks: ['chunk-vendors', 'chunk-common', 'index']
    }
  },
  css: {
    loaderOptions: {
      sass: {
        // 全てのscssに読み込ませておきたいscssを設定
        data: `
          @import '@/pc/styles/material/_base.scss';
          @import '@/pc/styles/material/_reset.scss';
        `
      }
    }
  },
  configureWebpack: {
    resolve: {
      alias: {
        "@pc": require('path').join(__dirname, '..', 'src/pc')
      }
    }
  },
  chainWebpack: config => {
    // dist のindex.htmlが重複して吐き出されないようしている。
    config.plugin('copy')
      .tap(args => {
        args[0][0].ignore.push('index.html')
        return args
      })
    // 画像ファイルがある場合、hash値を付けるinline化
    config.module
      .rule('images')
      .test(/\.(png|jpe?g|gif)(\?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 10000,
        name: 'img/[name].[hash:7].[ext]'
      })
    // svgファイルがある場合、hash値を付けるinline化
    config.module
      .rule('svg')
      .test(/\.(svg)(\?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 10000,
        name: 'img/[name].[hash:7].[ext]'
      })
    // mediaファイルがある場合、hash値を付けるinline化
    config.module
      .rule('media')
      .test(/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 10000,
        name: 'media/[name].[hash:7].[ext]'
      })
    // fontファイルがある場合、hash値を付けるinline化
    config.module
      .rule('fonts')
      .test(/\.(woff2?|eot|ttf|otf)(\?.*)?$/)
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 10000,
        name: 'fonts/[name].[hash:7].[ext]'
      })
  },
  // webpackBundleAnalyzerの出力設定。
  pluginOptions: {
    webpackBundleAnalyzer: {
      analyzerMode: 'static',
      openAnalyzer: false
    }
  }
}

webpack likeに記述できるのであまり迷うことも無いと思います。

vuex-module-decoratorを利用した際のError handlingについて

Vueをベースとしていることと併せて、弊社でも実装にはTypescriptを採用しております。 Typescriptを採用するにあたり、コンポーネントだけではなく、vuexもTypescriptで型を入れたいなということで、vuex-module-decoratorsを利用しております。 サードパーティーではありますが、非常に便利で使い勝手の良いライブラリです。 詳しい使い方はライブラリの公式ページに預けるとして、こちらでは実装を進めていく中でちょっと困ることがあったので、以下、それについての説明をさせて頂きます。 記述方法は少し変わりますがvuexの拡張ライブラリなのでお馴染みの通り【state/getter/action/mutation】は全て利用できます。(当ライブラリ独自のMutationActionたるものも使えます。)

actionの記述については以下の様に記載するのですが

@Action
sampleAction() {
  //ここにAction内で行う処理を書いていく
}

以下の様にactionメソッド内で何かしらの処理を行った結果、errorをthrowしたい場合(例えば何かしらの非同期処理など)

@Action
async sampleAction() {
  const sampleData = await fetchSampleData()
    .catch((error) => {
      throw error
    })
}

これでerrorがthrowされて捕捉できると思っていたのですが、ライブラリのAction Modelに指定されている rawErrorにdefaultでfalseが入っている為、この部分三項演算子で必ず 以下の記述の方を通ることになり、

: new Error(
              'ERR_ACTION_ACCESS_UNDEFINED: Are you trying to access ' +
                'this.someMutation() or this.someGetter inside an @Action? \n' +
                'That works only in dynamic modules. \n' +
                'If not dynamic use this.context.commit("mutationName", payload) ' +
                'and this.context.getters["getterName"]' +
                '\n' +
                new Error(`Could not perform action ${key.toString()}`).stack +
                '\n' +
                e.stack
            )

ライブラリ内部で定義されている固定文言がErrorとしてthrowされてしまうため、期待したErrorを捕捉することができません。

解決方としては@Actionの引数に指定を渡してActionを設定します。

@Action({ rawError: true })
async sampleAction() {
  const sampleData = await fetchSampleData()
    .catch((error) =>{
      throw error
    })
}

これによりこの部分三項演算子

? e

と判定され、オリジナルのErrorを捕捉できるようになります。
公式ページに基本的な使い方は載せてくれているのですがこの指定についての記述はなく中身を確認する必要があったので、実装の際にちょっと困りました。

まとめ

備忘録も兼ねた紹介になりましたが、Vue(特にvue-cli)を利用し、プロダクトを作る際の参考にして頂ければ幸いです。 最後まで目を通して頂きましてありがとうございます!!