20191127のvue.jsに関する記事は14件です。

vue.js component間のデータの受け渡し

vue.js component間のデータの受け渡し(備忘録)

環境

端末:MacBook Pro (15, 2017)
OS: Mac OS Mojave(version 10.14.6)
npm: 6.9.0
Vue CLI version: 3.9.0
Vue.js version: 2.6.10
vuetify:2.1.0

ブラウザで見やすい様にvuetifyを使っています。

vue.jsの特徴であるコンポーネント。アプリケーションのレイアウトを分子レベルに分解して、再利用性を高めることが求められます。検索機能一つとってもツールとして、パーツとして考えることができれば何度も同じコードを記述することもなく、再利用できますが、各コンポーネントがどの様にデータを受け取り、渡すことができるのかが必須となります。基礎中の基礎ですが、備忘録として記します。なおvue.2.6がリリースされてv-slot機能が備わりました。コンポーネント間のデータの受け渡しは今後も改善されていく可能性がありますし、気づいたことも追記していきます。

親コンポーネントから子コンポーネントへ

props

型: Array | Object

配列でdataのプロパティを渡す

親コンポーネントの設定

① 子コンポーネントを親コンポーネントにimportする
② components:{}に子コンポーネントを指定する
③ データを送りたい子コンポーネントのタグを用意する(この場合Props.vue)

src/views/App.vue
<template>
  <v-app>
    <v-container>
      <v-layout justify-center mt-10>
        <div>
          <h2>App.vue</h2>
          <p>親コンポーネントのnumberの値({{ number }})</p>
          <p>objectsの値</p>
          <ul>
            <template v-for="value in objects">
              <li :key="value">
                {{ value }}
              </li>
            </template>
          </ul>
          <!-----③これよりProps------>
          <props :number="number" :objects="objects"> </props>
        </div>
      </v-layout>
    </v-container>
  </v-app>
</template>

<script>
// ①
import Props from '@/views/Props';

export default {
  name: 'App',
  // ②
  components: {
    Props
  },
  data() {
    return {
      number: 1,
      objects: {
        id: 1,
        number: 10,
        name: '山田太郎'
      }
    };
  }
};
</script>

解説

子コンポーネントに渡したいデータは親コンポーネントにあります。number:1を子コンポーネントに渡します。まず親コンポーネントのプロパティデータを渡すにはimportした子コンポーネントタグに渡したいプロパティをv-bindします。バインドする変数はコロンをつけて渡します。ローワーキャメルケースが良いと思います。

<props :number="number"></props>
<!-----変数はなんでも好きなもので--->
<props :hoge="number"></props>

<script>
 // 省略
data(){
  return {
    number: 1,
    objects: {
      id: 1,
      number: 10,
      name: '山田太郎'
    }
  }
}
</script>

子コンポーネントの設定

① script内でprops:[]にプロパティをセットする(Array配列)
② template内で表示させる

src/views/Props.vue
<template>
  <v-app>
    <v-container>
      <div>
        <h2>Props.vue</h2>
        <p>子コンポーネントに渡された値:({{ number }})</p>
        <ul>
          <li v-for="(value, key) in objects" :key="value">
            {{ key }}-{{ value }}
          </li>
        </ul>
      </div>
    </v-container>
  </v-app>
</template>
<script>
export default {
  name: 'Props',
  props: ['number', 'objects']
};
</script>

ブラウザで確認

スクリーンショット 2019-11-27 21.00.08.png

objectでプロパティを受け取る

vue.js公式

オブジェクトでpropsの受け取り方法は受け取りたい型を指定する様です。まだまだ使いこめていないので今後追記していきたいと思います。

オブジェクトでプロパティを受け取る

src/views/App.vue
<script>
// 親コンポーネント
data() {
    return {
      number: 1,
      objects: {
        number: 10,
        id: 1,
        name: '山田太郎'
      }
    };
  },
</script>
src/views/Props.vue
<script>
// 子コンポーネント
export default {
    props: {
      number: Number, 
      type: Number,
      objects: Object
    }
};
</script>

子コンポーネントから親コンポーネントへデータを渡す

データを渡したい子コンポーネント

子コンポーネントのデータを親コンポーネントへ渡し、親コンポーネントのイベントを発火させることができます

  • 子コンポーネントから渡されたmyNumberの値を使って+10するボタンを配置
  • $emitメソッドを使用
  • $emitは引数をとる

Props.vue

  • templateにボタンを追加
  • clickイベントを作成
src/views/Props.vue
<!---追加----->
<template>
  <v-btn @click="incrementByEmit" class="mt-3" small>+10</v-btn>
</template>
  • methodsにイベントを定義
  • $emitメソッドを使用して親コンポーネントへイベントを渡します
src/views/Props.vue
// 省略
<script>
data() {
    return {
      myNumber: 0
    };
  },
  methods: {
    incrementByEmit() {
      this.$emit('my-click', (this.myNumber += 10));
    }
  }
</script>
  • 第一引数に、親コンポーネントで発火するメソッド名をカスタムで作成
  • 第二引数に、親コンポーネントから渡されたnumberに+10するイベントの内容を指定

App.vue

  • v-onで子コンポーネントのイベントを受け取ります。
  • 受け取ったmy-clickイベントをemitEventに定義します

子コンポーネントから受け取ったイベントはカスタムイベントとなって親コンポーネントのイベントを発火させ機能します。

src/views/App.vue
<template>
 <props
   :number="number"
   :objects="objects"
   @my-click="emitEvent"
   >
</props>
</template>
  • script内で受け取るmyNumberの初期値を指定します。
  • methodsで受け取るデータの扱いを定義します。
src/views/App.vue
<script>
data() {
    return {
      number: 1,
      objects: {
        id: 1,
        number: 10,
        name: '山田太郎'
      },
      // 受け取ったデータを格納する初期値
      myNumber: 0
    };
  },
 // 受け取ったデータを引数に指定し、親コンポーネントのmyNumberに格納します
  methods: {
    emitEvent(myNumber) {
      this.myNumber = myNumber;
    }
  }
</script>

ブラウザで確認

qiita-sample-480.gif

slotでhtmlや中括弧の中の値を渡す

propsと違ってまとまったhtmlそのものを親コンポーネントから子コンポーネントへ渡すことができます。

親コンポーネントの設定

前提
コンポーネントの親子関係を作成

  • 子コンポーネントタグで渡したいhtmlを挟む
src/view/Parent.vue
<template>
  <v-app>
    <h3 id="parent-h3">Parent.vue</h3>
    <!---子コンポーネント start----->
    <child>
      <h3 id="child-h3">ここからChild.vue</h3>
    </child>
    <!---子コンポーネント end----->
  </v-app>
</template>

<script>
import Child from './Child'
export default {
  name: 'Parent',
  components: {Child}
}
</script>

<style scoped>
#parent-h3 {
  background-color: lightgray;
}
#child-h3 {
  background-color: gray;
}
</style>

子コンポーネントの設定

渡されたデータを受け取るためにslotタグを配置

src/views/Child.vue
<template>
  <v-app>
    <slot></slot>
  </v-app>
</template>
<script>
export default {
  name: 'Child',
  data() {
    return {};
  }
};
</script>

ブラウザで確認

スクリーンショット 2019-11-22 16.12.23.png

複数のslotは可能か

子コンポーネント側のslotタグを複数用意することで量産できます。

画像で確認

スクリーンショット 2019-11-22 16.21.11.png

別のhtmlも追加したい時はどうか

/src/views/Parent.vue
<!--templateのみ--->
<template>
  <v-app>
    <h3 id="parent-h3">Parent.vue</h3>
    <child>
      <h3 id="child-h3">ここからChild.vue</h3>
    </child>
    <child>
      <p>もう一つのChildコンポーネント</p>
    </child>
  </v-app>
</template>

結果は反映されません。

解決策

slotに名前をつけて判別します。

  • 親コンポーネント側
  • 子コンポーネントのchildタグ内でtemplateタグを用意し、渡したいhtmlを挟む
  • templateタグにv-slot:名前を付与する(v-slotディレクティブ)
  • vue version 2.6.0以上
/src/views/Parent.vue
<!--templateのみ--->
<template>
  <v-app>
    <h3 id="parent-h3">Parent.vue</h3>
    <child>
      <template v-slot:child>
        <h3 id="child-h3">ここからChild.vue</h3>
      </template>
      <template v-slot:other>
        <h3 id="other-slot-h3">もう一つのslot</h3>
      </template>
    </child>
  </v-app>
</template>
  • 子コンポーネント側
/src/views/Child.vue
<template>
  <v-app>
    <slot name="child"></slot>
    <slot name="other"></slot>
  </v-app>
</template>
<script>
export default {
  name: "Child",
  data() {
    return {};
  }
};
</script>

ブラウザで確認

スクリーンショット 2019-11-22 16.59.35.png

注意点
必ずtemplateタグにする必要があります。divタグではできません。

v-slotディレクティブによって判別機能が備わり、任意の場所でslotを使うことができる様になります。
slotの位置関係を変更することができます。まさにコンポーネントの再利用性が発揮されています。

defalut slot

前提
子コンポーネントのタグに挟まれている要素に対してdefault slotが作成されます。

  • 子コンポーネントタグのtemplateタグ内に挟まれていない要素はdefalut slotとして扱われる
  • この場合は①、②。
/src/views/Parent.vue
<template>
  <v-app>
    <h3 id="parent-h3">Parent.vue</h3>
    <child>
      <p>①templateタグで挟まれていない</p>
      <template v-slot:child>
        <h3 id="child-h3">ここからChild.vue</h3>
      </template>
      <p>②templateに挟まれていない</p>
      <template v-slot:other>
        <h3 id="slot-p">もう一つのslot</h3>
      </template>
    </child>
  </v-app>
</template>

このままではtemplate内に収められていない要素は表示すらされません。表示するには子コンポーネントにslotを配置します。

スクリーンショット 2019-11-22 17.58.17.png

/src/views/Child.vue
<template>
  <v-app>
   <h3>Chile.vue</h3>
    <slot name="childTitle"></slot>
    <p>子コンポーネント</p>
    <!----新たに用意したslot start------>
    <slot></slot>
    <!----新たに用意したslot end------>
    <slot name="other-title"></slot>
  </v-app>
</template>

ブラウザで確認

スクリーンショット 2019-11-22 17.58.26.png

この様に子コンポーネントにhtml要素が渡され表示されます。

  • 子コンポーネントタグ内のテンプレートタグ内に配置されていないhtml要素はslotタグの位置にひとまとめにされて表示されます。

理由はvue.jsがdefault名でslotを認識するためです。

Child.vue
<!----子コンポーネント---->
<slot name="default" />
<!---親コンポーネント---->
<template v-slot="default" />

slotPropsを使って子コンポーネントから親コンポーネントへデータを渡す

  • v-bindで名前をつけて親コンポーネントへ渡す

注意: slotPropsを使用するときは指定したslotにのみ反映されるもので、連動しません

/src/views/Child.vue
<template>
  <v-app>
    <h3>Chile.vue</h3>
    <slot name="childTitle" />
    <p>子コンポーネント</p>
    <slot></slot>
    <!----userプロパティに名前をつける---->
    <slot name="other" :user="user" />
  </v-app>
</template>
<script>
export default {
  name: "Child",
  data() {
    return {
      // 親コンポーネントへ渡したいデータ
      user: {         
        firstName: "太郎", 
        lastName: "山田"
      }
    };
  }
};
</script>

親コンポーネント側

  • slotProps(渡ってきたデータを受け取るため)
/src/views/Parent.vue
<template>
  <v-app>
    <h3 id="parent-h3">Parent.vue</h3>
    <child>
      <p>①templateタグで挟まれていない</p>
      <template v-slot:child>
        <h3 id="child-h3">ここからChild.vue</h3>
      </template>
      <p>②templateに挟まれていない</p>
      <!----渡ってきたデータを受け取るためv-slot:otherにslotPropsとする----->
      <template v-slot:other="slotProps">
        <h3 id="slot-p">もう一つのslot</h3>
    <!-----{{}}マスタッシュ構文で表示させる------>
        <p>{{slotProps.user.lastName}}{{slotProps.user.firstName}}</p>
      </template>
    </child>
  </v-app>
</template>

名前付きslotのv-slotを未使用かつslotPropsを使用する時の省略記法

  • これまでのtemplateタグで挟んだ名前付きv-slotを削除しています。
  • slotPlopsを受け取るにはv-slotが必要なのですが、templateタグでなく子コンポーネントタグに指定して、省略します。
src/views/Parent.vue
<!---親コンポーネント---->
<template>
  <v-app>
    <h3 id="parent-h3">Parent.vue</h3>
    <child v-slot:default="slotProps">
      <p>①templateタグで挟まれていない</p>
      <p>②templateに挟まれていない</p>
      <p>{{ slotProps.user.lastName}}さん</p>
    </child>
  </v-app>
</template>

  • 子コンポーネント側でもこれまでのv-slotは削除しています。
  • slotPropsを使用していなければvue.jsが認識してname="defalut"とします。
  • この場合はslotPropsを使用するのでslotにnameを付与するのは必然となりますので、default以外でも名前は自由につけられます。
src/views/Child.vue
<!---子コンポーネント---->
<template>
  <v-app>
    <h3>Chile.vue</h3>
    <p>子コンポーネント</p>
    <slot name="default" :user="user" />
  </v-app>
</template>
<script>
export default {
  name: "Child",
  data() {
    return {
      user: {
        firstName: "太郎",
        lastName: "山田"
      }
    };
  }
};
</script>

ブラウザで確認

sample.png

この様にv-slotとtemplateタグは、二つに一つでの使用からdefault slotかつslotPropsを受け取りたい場合においてtemplateタグを省略でき、子コンポーネントタグに配置することができる様になります。

ここで省略は終わらず、さらに省略できます。

  • name属性のdefalutを省略できます。
src/views/Parent.vue
<!---親コンポーネント---->
<template>
  <v-app>
    <h3 id="parent-h3">Parent.vue</h3>
    <!--name属性defaultの記述を省略---->
    <child v-slot="slotProps">
      <p>Vue.js</p>
      <p>slot default</p>
      {{ slotProps.user.lastName}}さん
    </child>
  </v-app>
</template>

さらに省略は続きます。

  • v-slotは#に置換できます。
  • default slotの場合は#default="slotProps"とします
src/views/Parent.vue
<!---親コンポーネント---->
<template>
  <v-app>
    <h3 id="parent-h3">Parent.vue</h3>
    <!--v-slotを#に置換---->
    <child #default="slotProps">
      <p>Vue.js</p>
      <p>slot default</p>
      {{ slotProps.user.lastName}}さん
    </child>
  </v-app>
</template>
src/views/Child.vue
<!---子コンポーネント---->
<template>
  <v-app>
    <h3>Chile.vue</h3>
    <p>子コンポーネント</p>
   <!--name属性defaultの記述を省略---->
    <slot :user="user"></slot>
  </v-app>
</template>

ブラウザで確認

sample2.png

まとめ

この様に親コンポーネントと子コンポーネントの間でデータを渡し合うことができることを記述してきました。
ざっとやってまいりましたが、propsを使った親コンポーネントから子コンポーネントへのデータを渡すことだったり、$emitを使った子コンポーネントのデータを使いながら親コンポーネントのイベントを発火させたり、slotによってhtmlやデータ、名前付きv-slotでslotの識別、slotPropsによる子コンポーネントのデータを受け取ったりと様々です。しかしながら、ネストが深くなる様なコンポーネントの設計となると、バインディングされたtemplateが見辛くなるように思えます。そのような大掛かりな設計となるとvuexのstore機能を検討すると良いかもしれません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

東大の学園祭のウェブサイト部門の技術がPHPからNuxtになるまで

前書き

この記事は、純粋に技術を紹介するというよりは、「いかに技術を浸透させたか」「つらみにどう対処したか」という「メタ技術」的な話になります。そのため、要素要素の技術の詳細には立ち入りません。ご了承ください。

また、長々と書いていますが、まとめだけ見たい人は一番したの「まとめ」までスクロールしてください。

対象読者

  • これから情報システム部門を立ち上げようとしている人
  • 情報システム部門にいて、新しく技術を導入したいけれど、技術的な「負の遺産」に悩んでいる人
  • 人の入れ替わりが激しく、継続的な技術の蓄積が難しいと感じている人
  • 情報システム部門にいるけど、他の部署からの圧力による要件追加などに悩んでいる人

なぜ「学園祭」なのか

大学の学園祭の運営をしている、と聞くと、皆さんはどんなことを思い浮かべますか?
学園祭の楽しい記憶などがあっても、運営、と聞くと「?」となってしまう人が多いと思います。

今回のアドベントカレンダーは、そんな、普段目につかないようなところで頑張っている大学の学園祭の中でも、さらに一段と見えないところで頑張っている情報部門のアドベントカレンダーです。

ほとんどの企業が自分のウェブサイトを持ち、旅行するときは乗換検索、料理は食べログやGoogle Mapsに当たり前に頼るようになった2019年末の現在でも、情報部門をもつ学園祭運営団体はあまり多くないのが現状です。

経験からすると、それは「学園祭の運営団体でITをやるのはつらい」に起因するように思います。しかし、その学園祭という特殊な環境にありながら、その「つらみ」はIT企業であれば当たり前に遭遇するような普遍的なものばかりです。

  • 人がすぐ辞める
  • スキルの高い人が少ない
  • 他の部署からの圧力がある
  • 謎の秘伝のコードがある

etc...

今回は、そんな「つらみ」といかに折り合いをつけながらITをやっていくのかという問題に対して、「技術選択」でどのように対処したのか。
僕が在籍し、技術選択を行った2017-18年にかけての東京大学の駒場祭/五月祭実行委員会の事例を紹介します。

時系列

〜2016年 PHPの時代

2016年まで、駒場祭ではウェブサイトは基本的にはPHPで作られていました。いつからPHPで作られているのか、そしていつからそのようになっているのか厳密なことは分かりませんが、委員会内部の断片的な記録を辿ると、2000年代の後半には既にそのような形態になっていたことが伺えます。

さて、「PHPで開発」と言ってもいろいろな状況があり得ます。僕が駒場祭委員会に入った大学1年生の時の状況は、基本的には以下のような状況でした。

開発者のスキル・人数

1,2年生合わせて5人程度、ほとんどは大学でプログラミングをはじめた人です。もちろん、委員会でも何もしないわけではなく、入会してしばらくは「講習会」と称して2-3ヶ月ほど研修のようなものがあります。しかし、IT企業でのような集中的なものではなく、1週間に1-2時間程度のもので、さらにはJavaScriptしか基本的には教えないという極めて不十分なものでした。つまり、PHPに関してはほぼ全員が教育もなく先輩のコードを見様見真似したりする程度でした。

技術スタックと技術蓄積の状況

PHP-FPMで書かれていました。ざっくりいうとCGIです。「Perlで掲示板を作ろう!」みたいな本で一通り実装したことのある世代には非常に馴染みの深いものだと思います。

これだけでもかなりレガシーな匂いがしますが、それに加えて、パッケージマネージャーもないという状況でした。つまり、ライブラリを入れたりするのが非常に大変だったわけです。(流石にGitはありました。)

それでも腐っても東大生なので、頑張れば無理やり求める実装が出来上がってしまいます。結果として秘伝のタレのようなコードが多くあり、しかも初心者には読み解けない。メンテナンスができない。という状況でした。

しかも、駒場祭委員会には大学1,2年生しか在籍できないため、それらをリファクタできるレベルの技術に達する人もなかなか現れませんでした。東大は(2年はともかく)1年生の、特に理系のカリキュラムはかなり重いため、必然的にリファクタできるレベルの技術を手にする人はかなり限られていました。

2017年 Node.js(Express & Pug)への移行

PHP→JS

先ほどの状況の問題を整理すると以下のようになります。

  • PHPを書ける人材を育てられていない
  • パッケージマネージャーがない
  • 秘伝のタレコードが多い

一つ目は、そもそも委員会ではPHPを書いているのに、研修ではJavaScriptしか教えていないことに由来する問題です。しかし、実質教えることのリソースを増やしてPHPを教えるというのもとても厳しいものでした。これは、2-3ヶ月以上教えると、今度はウェブサイトを作る時間が取れなくなってしまうからです。
そのため、推定10年弱続いた伝統を断ち切って、フロントエンドをJSだけで実装できるように改めて技術選定を行いました。

具体的にはExpressとPugを採用しています。ビルドはGulpで行っています。
当時もReact,Angular(Vueはまだ日本では比較的普及している途中だったようです)などのフロントエンドフレームワークはかなり有名でしたが、「教えきれない」という理由でウェブサイトでの導入を断念しています。(とはいえ、他のより小規模なシステムには採用しています。)
ExpressもPugも学習コストの少ないフレームワークだったので、移行そのものは比較的楽でした。

二つ目についても、PHPの場合はComposerなどのツールがありますが、Node.jsの場合はnpmでかなり簡単にライブラリをいれることができるようになりました。

三つ目もかなり状況が改善されます。PHP時代のコードのほとんどに対し、対応するライブラリを見つけられたため、そもそも実装が減ったので、秘伝のタレコードなるものも自然と減ります。

秘伝のタレ、再び

一見良いことづくめで、実際最初の1-2ヶ月は良い感じに回っていましたが、結論からいうとExpress & Pugも狼人間に対する銀の弾丸ではありませんでした。

ライブラリの大量投入により実装そのものは大幅に減り、僕らはより難しい、抽象的な問題に集中できるようになりました。しかし、今まではみんながしなければいけなかった「難しい実装」が特定の人に集中する、ということが起こります。集中するだけならこれはこれで良いのですが、問題は、難しい実装をする人が多忙により後世に知見を引き継げないということでした。

具体的には、Expressのgulpのスクリプトがそうなりました。当時の駒場祭委員会では、gulpで「ファイルが保存されたら再ビルド & リロード」などのタスクを定義していました。しかし、かなり複雑な実装だったため、それらを理解できる人がいないという問題が発生しました。

政治的圧力による要件追加

これはコミュニケーションの不足なのですが、Twitterカードなどを表示するためのOGPコードの導入が後付けで決まります。ウェブサイトを実装するのは駒場祭委員会のシステム局の管轄ですが、局長である僕はその要求を断ることができませんでした。これは、ウェブサイトの内容などを決める他の局の方が基本的に発言力が強かったことに由来します。

要件に入ってなかったのを半強制的に入れることになったため、当然、かなりの実装コストを強いられました。これにより、他の実装に回せたはずのリソースを吸われてしまい、スケジュールの遅延などが悪化しました。

2018年 VueとNuxtの導入

先ほども言ったように、駒場祭委員会に在籍できるのは大学の1,2年生だけなので、これからの話は五月祭実行委員会での出来事になります。

秘伝のタレの解消、その2(Nuxt.jsへの移行)

五月祭実行委員会では、今度は1-3年が12-6月にかけて祭りの運営をしています。
その中で2年生として入った僕は今度は、中間学年として改めてウェブサイトの技術選定を行うことになります。

しかし、五月祭でExpress & PugでまたGulpを書き、秘伝のタレを生み出すのも気が引けます。何より、そのGulpスクリプトによる構成はOGPコードの実装が非常に面倒な構成を前提としていたので悩みました。

そんな時に出会ったのがNuxt.jsでした。

2018年の1月当時、Nuxt.jsはv1になったばかりという非常に新しいフレームワークでしたが、ビルドやページ追加などの指針が明確に整理され、ウェブサイトの基本的な構造を非常に簡単に作れるという点で非常に優れていました。これにより、さらに秘伝のタレコードを減らすことができるようになりました。

Vue.jsの導入

Nuxt.jsはVue.jsを前提にしたフレームワークですので、当然チームで使う際にはVue.jsの概念、コンポーネントやディレクティブ、プロパティー渡しなどを普及させる必要があります。

VueはReactなどに比べるとHTML & CSSからの移行は楽なのですが、JS部分の移行に関してはそうは行きません。しかし、これについてもVue.jsの公式ドキュメントが非常に充実していたため、教える側としてはかなり楽でした。

政治的圧力への対処

政治的圧力がしんどい場合の解決策はいろいろありますが、当時の僕が最初にとった手段は「そもそも政治的圧力が発生しないようにどうにかする」というものでした。

つまり、事前に要件は決めつつ、「後からの要件追加は、実装コストによっては却下します。なぜなら他の、より優先度の高い実装の障害になるからです。」という方針でまずは他の担当の合意を取り付けました。
これにより、要件の決定権がなく、勝手に仕事のゴールポストをずらされるという状況がかなり改善します。

その上でもごねてきた連中に対しては合意を盾に容赦無く提案を却下しました。
面白いのは、普通なら信頼されなくなると思うところが、これにより優先度の高い実装に優先的にリソースを回すことができるようになり、結果として信頼関係が強化されたことです。

冷静に考えてみるとこれは当たり前で、能力を超えた仕事を請負っても失敗するのだから、わかっている場合は最初から請けない方が「請負った仕事は終わる」確率は上がります。(いささか力技でしたが)

まとめ

つらいところ

  • 初心者が大量に入ってくる
  • 2年程度しかいないのでスキルの高い人が少ない
  • 秘伝のタレコードがある
  • 政治的圧力をかけられて要件が追加されたりする

どうしたか

  • 初心者でもできる方法を模索する
    • 駒場祭/五月祭委員会の場合は、それがNuxt.jsだった
  • 秘伝のタレコードは思い切って捨てる
    • 学習コストの少ないものを採用すると移りやすい
  • 要件追加の方法を合意し、ごねる人には毅然とした態度で
    • 長期的な信頼関係を築くにはむしろその方がよい
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vue2-leafletでGeoJsonデータを表示・操作する

はじめに

ウェブ上で簡単に地図を表示する人気ライブラリ「Leaflet.js」をVue.js環境でお手軽に使えるラッパーライブラリ「vue2-leaflet」を5日くらい色々いじって得た知見を本記事にまとめます。いじった結果のゴール地点は以下の画像のようなウェブアプリケーションです。①地図エリアに.geojsonファイルをドラッグドロップすると地図上に地物を表示、②読み込んだGeoJsonレイヤを一覧表示、③地物をクリックするとその地物の属性をすべて表示、という以上の三機能をVue.jsの特性を活かし実装しました。本記事ではvue2-leafletの導入・実装と、機能①に焦点を絞って解説したいと思います。
スクリーンショット 2019-11-27 19.58.33.png

環境

  • npm 6.12.0
  • @vue/cli 4.0.5
  • leaflet 1.6.0

- vue2-leaflet 2.2.1

「vue cli」環境で開発しました。「vue cli」環境構築については本記事では掲載しません(以下の記事が詳しいです)。
Vue.js #001 – Vue CLI 3で環境構築

導入

npm install leaflet vue2-leaflet

インストール後、main.jsにてcssを読み込ませます

main.js
import Vue from 'vue'
import App from './App.vue'

//ここから
import { Icon }  from 'leaflet'
import 'leaflet/dist/leaflet.css'
//ここまで

// this part resolve an issue where the markers would not appear
delete Icon.Default.prototype._getIconUrl;

Icon.Default.mergeOptions({
    iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
    iconUrl: require('leaflet/dist/images/marker-icon.png'),
    shadowUrl: require('leaflet/dist/images/marker-shadow.png')
});

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

以上で導入は完了です。

実装

すべて単一ファイルコンポーネントとして記載しています。

script

<script>
    import {
        LMap,
        LTileLayer,
        LControlLayers,
        LControlScale,
        LGeoJson,
    } from 'vue2-leaflet';

    export default {
        name: 'MapPane',
        components: {
            LMap,
            LTileLayer,
            LControlLayers,
            LControlScale,
            LGeoJson,
        },
        data() {
            return {
                center: [38, 140],
                zoom:5,
                options: {
                    onEachFeature: function(feature, layer) {
                        layer.options.smoothFactor = 2;
                    }
                },
                geojson: null,
            }
        },
</script>

template

<template>
    <div class="mapPane"
        @dragover.prevent="dragover"
        @drop.prevent="drop"
        >
        <l-map
            :zoom="zoom"
            :center="center"
            :preferCanvas="true"
        >

            <l-control-layers
                position="topright"
                :collapsed="false"
            ></l-control-layers>
            <l-control-scale
                position="bottomleft"
                :imperial="false"
                :metric="true"
            ></l-control-scale>

            <l-tile-layer
                name="MIERUNE MONO"
                visible="true"
                url="https://tile.mierune.co.jp/mierune_mono/{z}/{x}/{y}.png"
                attribution="Maptiles by <a href='http://mierune.co.jp/' target='_blank'>MIERUNE</a>, under CC BY. Data by <a 
                href='http://osm.org/copyright' target='_blank'>OpenStreetMap</a> contributors, under ODbL."
                layer-type="base"
            ></l-tile-layer>
            <l-geo-json
                :geojson="geojson"
                :options="options"
                :options-style="styleFunction"
                @click="onFeatureClick"
            ></l-geo-json>
        </l-map>
    </div>
</template>

長いので分解してみましょう。
まずLeafletマップ本体はl-mapです。l-mapの内側に、必要なUIパーツや表示したいレイヤーを記述します。

<l-map
    :zoom="zoom"
    :center="center"
    :preferCanvas="true"
>
<!-- ここにタイルレイヤーだとかコントロールだとかを追加する -->
</l-map>

素のl-mapだけだと背景地図すら表示されません。なのでまずタイルレイヤーを追加してみましょう。タイルレイヤーはl-tile-layerをl-map内に記述する事で追加されます。

<l-tile-layer
    name="MIERUNE MONO"
    visible="true"
    url="https://tile.mierune.co.jp/mierune_mono/{z}/{x}/{y}.png"
    attribution="Maptiles by <a href='http://mierune.co.jp/' target='_blank'>MIERUNE</a>, under CC BY. Data by <a 
    href='http://osm.org/copyright' target='_blank'>OpenStreetMap</a> contributors, under ODbL."
    layer-type="base"
></l-tile-layer>

このとおり、l-map内に各種コンポーネントを追加記述して、望む機能をもつ地図をつくれる訳ですね。おなじみのレイヤーコントロール(レイヤー一覧、L.control.layers)は、前述の例のとおりl-control-layersで実装できたりと、基本的な機能をvueの記法で実装出来ます。そういったビルトインのUIパーツなどはおそらく実装には困らないと思いますので、本記事ではGeoJsonレイヤーの取扱いについて掘り下げていきたいと思います。

GeoJsonレイヤーの取扱

実装

<l-geo-json
    :geojson="geojson"
    :options="options"
    :options-style="styleFunction"
    @click="onFeatureClick"
></l-geo-json>

l-geo-jsonをl-map内に追加します。v-bindでgeojsonオブジェクトを渡してやる必要があります。ここで、このgeojsonはリアクティブです。つまりdata内のgeojsonの変更がマップに即時反映されます。vue2-leafletの前にvue-mapboxで遊んでいて、同様にgeojsonレイヤーのコンポーネントはあるのですが、リアクティブではありませんでした。この一点だけでもLeafletを優先して使う価値があると思います(GeoJsonビューア愛好家として)。

という訳でまずは.geojsonファイルのドラッグドロップ機能を実装しましょう。

<div class="mapPane"
    @dragover.prevent="dragover"
    @drop.prevent="drop"
    >
    <!-- l-mapなどなど -->
</div>

vueでドラッグドロップイベントを実装する場合はdragoverとdropをv-onで書きます。ここではドロップ時にdropというメソッドを実行せよ、という意味になります。.preventはブラウザの基本機能の実行を防ぐ構文です(例:ファイルをドロップすると、ブラウザ自体がそのファイルを開こうとするため)。さてここで、ドロップイベントだけ監視したいのだから、dragoverは不要ではないか?と考えると思います。しかしながらそれでは動作しません。おそらくdragover時にブラウザ処理が先行してしまうから(.preventが走らないから)だと思います。

さて、dropメソッドは、script内のmethodsにて宣言します。

methods: {
    drop: function(event) {
        let fileList = event.dataTransfer.files;
        let vm = this
        for ( let i = 0; i < fileList.length; i++ ) {
            let reader=new FileReader()
            reader.onload=function(e){
                let geojson = JSON.parse(reader.result)
                vm.geojson = geojson
            }
            reader.readAsText(fileList[i])
        }
    },
}

drop内の無名関数の引数eventにはドロップされたファイルの情報などが含まれています。File APIにより、ドロップされた.geojsonファイルからgeojson形式のオブジェクトを取得します。File APIについての解説はここでは省きます。
さて、ここで

let vm = this

この文の意味ですが、本当ならfor文内でもvueコンポーネントをthisで呼び出したい訳ですが、スコープが(良い言葉が思いつきませんがイメージ的には)一段深くなっており、thisで参照出来ません。そこで、thisでコンポーネントを参照出来るうちにvmという変数で保持している訳です。
さて、FileAPIでの読み込みが完了すると

let geojson = JSON.parse(reader.result)
vm.geojson = geojson

このとおり、vueコンポーネント内の、data内の、geojsonに、たった今File APIで取得したGeoJson型オブジェクトを突っ込みます。するとl-geo-jsonのgeojsonはリアクティブなので地図に地物が追加されます。

地物ごとの処理(onEachFeature)

l-geo-jsonのoptionsは、その他のコンポーネントと異なり、v-bindで:optionsに、オブジェクトをまとめて渡してやらなければなりません。

<!-- l-geo-json内 -->
:options="options"
//data()内
options: {
    onEachFeature: function(feature, layer) {
        layer.options.smoothFactor = 2;
    }
},

この例では、LeafletにおけるL.GeoJSONでおなじみのonEachFeature()を設定しています。地物ごとに個別の処理を行える関数です。ここでは、各地物の描画を簡素化しています。

まとめ

とてもとても長くなってしまいましたが、vue2-leafletの使い方を色々まとめました。どうやら私は、MapboxでもなんでもGISフレームワークの勉強の際は、とりあえず手持ちのGeoJsonやらを表示させるまでをチュートリアルと考えているフシがあります。vue.jsは使い始めですが、すげぇ便利だなって…。ここ数ヶ月はコードを書くばかりで知識のアウトプットもとい備忘録の作成を怠っていたため、来たるアドベントカレンダーへ向け、溜まっている下書きを清書していきたいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vue2-leafletの使い方、GeoJsonデータの表示・操作など

はじめに

ウェブ上で簡単に地図を表示する人気ライブラリ「Leaflet.js」をVue.js環境でお手軽に使えるラッパーライブラリ「vue2-leaflet」を5日くらい色々いじって得た知見を本記事にまとめます。いじった結果のゴール地点は以下の画像のようなウェブアプリケーションです。①地図エリアに.geojsonファイルをドラッグドロップすると地図上に地物を表示、②読み込んだGeoJsonレイヤを一覧表示、③地物をクリックするとその地物の属性をすべて表示、という以上の三機能をVue.jsの特性を活かし実装しました。本記事ではvue2-leafletの導入・実装と、機能①に焦点を絞って解説したいと思います。
スクリーンショット 2019-11-27 19.58.33.png

環境

  • npm 6.12.0
  • @vue/cli 4.0.5
  • leaflet 1.6.0

- vue2-leaflet 2.2.1

「vue cli」環境で開発しました。「vue cli」環境構築については本記事では掲載しません(以下の記事が詳しいです)。
Vue.js #001 – Vue CLI 3で環境構築

導入

npm install leaflet vue2-leaflet

インストール後、main.jsにてcssを読み込ませます

main.js
import Vue from 'vue'
import App from './App.vue'

//ここから
import { Icon }  from 'leaflet'
import 'leaflet/dist/leaflet.css'
//ここまで

// this part resolve an issue where the markers would not appear
delete Icon.Default.prototype._getIconUrl;

Icon.Default.mergeOptions({
    iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
    iconUrl: require('leaflet/dist/images/marker-icon.png'),
    shadowUrl: require('leaflet/dist/images/marker-shadow.png')
});

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

以上で導入は完了です。

実装

すべて単一ファイルコンポーネントとして記載しています。

script

<script>
    import {
        LMap,
        LTileLayer,
        LControlLayers,
        LControlScale,
        LGeoJson,
    } from 'vue2-leaflet';

    export default {
        name: 'MapPane',
        components: {
            LMap,
            LTileLayer,
            LControlLayers,
            LControlScale,
            LGeoJson,
        },
        data() {
            return {
                center: [38, 140],
                zoom:5,
                options: {
                    onEachFeature: function(feature, layer) {
                        layer.options.smoothFactor = 2;
                    }
                },
                geojson: null,
            }
        },
</script>

template

<template>
    <div class="mapPane"
        @dragover.prevent="dragover"
        @drop.prevent="drop"
        >
        <l-map
            :zoom="zoom"
            :center="center"
            :preferCanvas="true"
        >

            <l-control-layers
                position="topright"
                :collapsed="false"
            ></l-control-layers>
            <l-control-scale
                position="bottomleft"
                :imperial="false"
                :metric="true"
            ></l-control-scale>

            <l-tile-layer
                name="MIERUNE MONO"
                visible="true"
                url="https://tile.mierune.co.jp/mierune_mono/{z}/{x}/{y}.png"
                attribution="Maptiles by <a href='http://mierune.co.jp/' target='_blank'>MIERUNE</a>, under CC BY. Data by <a 
                href='http://osm.org/copyright' target='_blank'>OpenStreetMap</a> contributors, under ODbL."
                layer-type="base"
            ></l-tile-layer>
            <l-geo-json
                :geojson="geojson"
                :options="options"
                :options-style="styleFunction"
                @click="onFeatureClick"
            ></l-geo-json>
        </l-map>
    </div>
</template>

長いので分解してみましょう。
まずLeafletマップ本体はl-mapです。l-mapの内側に、必要なUIパーツや表示したいレイヤーを記述します。

<l-map
    :zoom="zoom"
    :center="center"
    :preferCanvas="true"
>
<!-- ここにタイルレイヤーだとかコントロールだとかを追加する -->
</l-map>

素のl-mapだけだと背景地図すら表示されません。なのでまずタイルレイヤーを追加してみましょう。タイルレイヤーはl-tile-layerをl-map内に記述する事で追加されます。

<l-tile-layer
    name="MIERUNE MONO"
    visible="true"
    url="https://tile.mierune.co.jp/mierune_mono/{z}/{x}/{y}.png"
    attribution="Maptiles by <a href='http://mierune.co.jp/' target='_blank'>MIERUNE</a>, under CC BY. Data by <a 
    href='http://osm.org/copyright' target='_blank'>OpenStreetMap</a> contributors, under ODbL."
    layer-type="base"
></l-tile-layer>

このとおり、l-map内に各種コンポーネントを追加記述して、望む機能をもつ地図をつくれる訳ですね。おなじみのレイヤーコントロール(レイヤー一覧、L.control.layers)は、前述の例のとおりl-control-layersで実装できたりと、基本的な機能をvueの記法で実装出来ます。そういったビルトインのUIパーツなどはおそらく実装には困らないと思いますので、本記事ではGeoJsonレイヤーの取扱いについて掘り下げていきたいと思います。

GeoJsonレイヤーの取扱

実装

<l-geo-json
    :geojson="geojson"
    :options="options"
    :options-style="styleFunction"
    @click="onFeatureClick"
></l-geo-json>

l-geo-jsonをl-map内に追加します。v-bindでgeojsonオブジェクトを渡してやる必要があります。ここで、このgeojsonはリアクティブです。つまりdata内のgeojsonの変更がマップに即時反映されます。vue2-leafletの前にvue-mapboxで遊んでいて、同様にgeojsonレイヤーのコンポーネントはあるのですが、リアクティブではありませんでした。この一点だけでもLeafletを優先して使う価値があると思います(GeoJsonビューア愛好家として)。

という訳でまずは.geojsonファイルのドラッグドロップ機能を実装しましょう。

<div class="mapPane"
    @dragover.prevent="dragover"
    @drop.prevent="drop"
    >
    <!-- l-mapなどなど -->
</div>

vueでドラッグドロップイベントを実装する場合はdragoverとdropをv-onで書きます。ここではドロップ時にdropというメソッドを実行せよ、という意味になります。.preventはブラウザの基本機能の実行を防ぐ構文です(例:ファイルをドロップすると、ブラウザ自体がそのファイルを開こうとするため)。さてここで、ドロップイベントだけ監視したいのだから、dragoverは不要ではないか?と考えると思います。しかしながらそれでは動作しません。おそらくdragover時にブラウザ処理が先行してしまうから(.preventが走らないから)だと思います。

さて、dropメソッドは、script内のmethodsにて宣言します。

methods: {
    drop: function(event) {
        let fileList = event.dataTransfer.files;
        let vm = this
        for ( let i = 0; i < fileList.length; i++ ) {
            let reader=new FileReader()
            reader.onload=function(e){
                let geojson = JSON.parse(reader.result)
                vm.geojson = geojson
            }
            reader.readAsText(fileList[i])
        }
    },
}

drop内の無名関数の引数eventにはドロップされたファイルの情報などが含まれています。File APIにより、ドロップされた.geojsonファイルからgeojson形式のオブジェクトを取得します。File APIについての解説はここでは省きます。
さて、ここで

let vm = this

この文の意味ですが、本当ならfor文内でもvueコンポーネントをthisで呼び出したい訳ですが、スコープが(良い言葉が思いつきませんがイメージ的には)一段深くなっており、thisで参照出来ません。そこで、thisでコンポーネントを参照出来るうちにvmという変数で保持している訳です。
さて、FileAPIでの読み込みが完了すると

let geojson = JSON.parse(reader.result)
vm.geojson = geojson

このとおり、vueコンポーネント内の、data内の、geojsonに、たった今File APIで取得したGeoJson型オブジェクトを突っ込みます。するとl-geo-jsonのgeojsonはリアクティブなので地図に地物が追加されます。

地物ごとの処理(onEachFeature)

l-geo-jsonのoptionsは、その他のコンポーネントと異なり、v-bindで:optionsに、オブジェクトをまとめて渡してやらなければなりません。

<!-- l-geo-json内 -->
:options="options"
//data()内
options: {
    onEachFeature: function(feature, layer) {
        layer.options.smoothFactor = 2;
    }
},

この例では、LeafletにおけるL.GeoJSONでおなじみのonEachFeature()を設定しています。地物ごとに個別の処理を行える関数です。ここでは、各地物の描画を簡素化しています。

参考

公式ドキュメントですが、あまり詳しい事は書いてありません。あんまり複雑な事しないなら早いか。

ソースですが、ここにexampleが多数ありとてもかなり非常に参考になります。

まとめ

とてもとても長くなってしまいましたが、vue2-leafletの使い方を色々まとめました。どうやら私は、MapboxでもなんでもGISフレームワークの勉強の際は、とりあえず手持ちのGeoJsonやらを表示させるまでをチュートリアルと考えているフシがあります。vue.jsは使い始めですが、すげぇ便利だなって…。ここ数ヶ月はコードを書くばかりで知識のアウトプットもとい備忘録の作成を怠っていたため、来たるアドベントカレンダーへ向け、溜まっている下書きを清書していきたいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js ユニットテストの基本まとめ

Vue.js アプリでユニットテストを書くには、Vue Test UtilsJest など、知っておくべきことがそれなりにあります。

現在、Vue CLI でアプリを作っていますが、ユニットテストを書くために色々と調べないといけませんでした。

今回はその過程で理解した Vue.js でのユニットテストの基本を以下にまとめます。

Vue.js のユニットテスト

まず、Vue.js では何を「ユニットテスト」として考えるのかを整理します。

ユニットテストの単位

Vue.js アプリは、複数のコンポーネントで構成され、それぞれのコンポーネントが連動しながら動きます。

そのため、ユニットテストの単位は「コンポーネント」となり、コンポーネントごとにテストを書いていきます。

何をテストすべきか?

コンポーネントごとにユニットテストを書くということですが、コンポーネントのどの部分に対してテストを書くべきでしょうか?

もちろん、細かいことはそれぞれの開発方針によって異なりますが、基本的には、パブリックインターフェースの部分(インプット / アウトプット)についてユニットテストを書くべきです。

ユニットテストの目的は、各コンポーネントが意図したとおり動くかどうかです。なので、内部のビジネスロジックや各関数を1行1行気にするのではなく、インプットに応じた適切なアウトプットを得られるかどうかに着目します。

Vue コンポーネントの具体的なインプット・アウトプットの例は次のとおりです。

インプットの例

  • コンポーネントの data
  • コンポーネントの props
  • ユーザのアクション(ボタンクリックなど)
  • ライフサイクルメソッド(mounted() , created() など)
  • ステート管理のデータ
  • ルーティングのパラメータ

Unit Testing in Vue: What to Test? - Vue Test Utils」より引用

アウトプットの例

  • DOMへの描画
  • コンポーネントから呼び出すイベント
  • ルーティングの変化
  • ステートの更新
  • 子コンポーネントとの連動

Unit Testing in Vue: What to Test? - Vue Test Utils」より引用

Vue.js のテストツール

続いて、Vue.js で使われる一般的なテストツールを整理します。

Vue Test Utils

Vue.js では一般的に、標準テストライブラリの Vue Test Utils を使ってテストを書きます。

具体的な書き方は後述しますが、テストコードは次のような感じで書きます。

Guide - Vue Test Utils」より引用

test.js
import { mount } from '@vue/test-utils'
import Counter from './counter'

describe('Counter', () => {
  // Now mount the component and you have the wrapper
  const wrapper = mount(Counter)

  it('renders the correct markup', () => {
    expect(wrapper.html()).toContain('<span class="count">0</span>')
  })

  // it's also easy to check for the existence of elements
  it('has a button', () => {
    expect(wrapper.contains('button')).toBe(true)
  })
})

Jest

Jest は Facebook によって作られたオープンソースの JavaScript テストフレームワークです。

Vue.js のアプリでは、この Jest を使って Vue Test Utils で書いたテストコードを実行するのが一般的です(テストランナー)。

公式ドキュメントでは、mocha-webpack というテストランナーも紹介されていますが、 Jest の方が初期設定が簡単(らしい)ので、以下では Jest を使っていきます。

Vue CLI のユニットテスト

続いて、実際に Vue CLI でユニットテストを書くために必要なことを整理します。

コード例はこちら(GitHub)

1. Vue CLI アプリ作成

まず Vue CLI でアプリを作成します。

Vue CLI をインストールしてない場合は、そのインストールからおこないます。(ついでに @vue/cli-init もインストールします。)

sudo npm install -g @vue/cli @vue/cli-init

バージョン確認できれば、正常にインストールできています。

vue --version
@vue/cli 4.0.5

インストール後、 webpack-simple でアプリを作成します。

vue init webpack-simple [APP_NAME]

2. Vue Test Utils & Jest のインストール

アプリのディレクトリに移動し、Vue Test Utils と Jest をインストールします。

cd [APP_NAME]
npm install --save-dev jest @vue/test-utils

3. Jest の設定

テストランナーとして Jest を利用するために、package.jsonscripts に設定を追加します。

package.json
{
  "scripts": {
    "test": "jest"
  }
}

また、Jest に .vue ファイルの扱い方を知ってもらうために、vue-jest もインストールします。

npm install --save-dev vue-jest

インストール後、package.jsonjest の設定を追加します。

package.json
{
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "vue"
    ],
    "transform": {
      ".*\\.(vue)$": "vue-jest"
    },
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1"
    }
  }
}

moduleNameMapper を上のように書くことで、src/ のパスを @/ で書けるようになります。

// ../src/App -> @/App
import Component from '@/App'

最後に、ESモジュールを使えるようにするために babel-jest をインストールします。

npm install --save-dev babel-jest

インストール後、package.jsonbabel-jest の設定を追加します。

package.json
{
  "jest": {
    "transform": {
      "^.+\\.js$": "<rootDir>/node_modules/babel-jest"
    }
  }
}

テスト環境でこの設定が有効になるように、.babelrc を次のように更新します。

.babelrc
{
  "presets": [["env", { "modules": false }]],
  "env": {
    "test": {
      "presets": [["env", { "targets": { "node": "current" } }]]
    }
  }
}

これで設定は完了です。

4. テストファイルの構成

Jest はデフォルトで、アプリのディレクトリ配下にある .spec.js もしくは .test.js のテストを実行していきます。

また、Jest は __tests__ ディレクトリ内にテストコードを置くことを推奨しています。その例にならって、 ルートディレクトリに __tests__ を作成します。

mkdir __tests__

5. ユニットテストを書く

それでは実際に __tests__App.vue のテストコードを書いてみます。

__tests__/App.spec.js
import { mount } from '@vue/test-utils';
import Component from '@/App.vue'

describe('Testing App component', () => {
    it('is a Vue instance', () => {
      const wrapper = mount(Component)
      expect(wrapper.isVueInstance).toBeTruthy()
    })
})

mount メソッドにコンポーネントを渡せば、Vue インスタンスと DOM ノードが返されます。この返り値の wrapper を使って様々なテストコードを書いていきます。

書いたテストコードは npm test で実行できます。

npm test

コード例はこちら(GitHub)

Vue Test Utils の書き方

最後に、Vue Test Utils でのテストの書き方について基本的なことをざっと整理します。

より細かいことは公式ドキュメントをご参照ください。

mount vs shallowMount

mount メソッドと同様に、shallowMount メソッドでも wrapper を受け取れます。

mountshallowMount の違いは、子コンポーネントをスタブ化できることです。

ユニットテストでは基本的に他コンポーネントの影響を排除すべきなので、多くの場合では、子コンポーネントの描画を無視できる shallowMount を用いた方がよいでしょう。

beforeEach & afterEach

各テストケースで同じ wrapper を定義している場合は、beforeEach メソッド内でDRYに書けます。(毎テスト後の処理は afterEach に書きます。)

__tests__/App.spec.js
import { shallowMount } from '@vue/test-utils';
import Component from '@/App.vue'

let wrapper

beforeEach(() => {
  wrapper = shallowMount(Component)
})

afterEach(() => {
  wrapper.destroy();
})

describe('Testing App component', () => {
    it('is a Vue instance', () => {
      expect(wrapper.isVueInstance).toBeTruthy()
    })
})

data の更新

例えば、コンポーネントの data.msg の値を更新したい場合は、wrapper.vm を使って次のように書きます。

const wrapper = shallowMount(Component)
wrapper.vm // the mounted Vue instance
wrapper.vm.msg = 'Hello World!'

イベントトリガーのテスト

DOMイベントのテストは、まず発火させたい処理を jest.fn() でモック関数として定義します。そのモック関数が、wrapper.find('button').trigger('click') などのイベントトリガーによって正しく呼び出されるかを検証します。

Testing Dom events in Vue.js using Jest and vue-test-utils - Reactgo」より引用

__tests__/App.spec.js
import { shallowMount } from '@vue/test-utils';
import Component from '@/App.vue'

describe('Testing native dom events', () => {
    const wrapper = shallowMount(Component)

    it('calls increment method when button is clicked', () => {
        const increment = jest.fn() // mock function

        // updating method with mock function
        wrapper.setMethods({ increment })

        //find the button and trigger click event
        wrapper.find('button').trigger('click')
        expect(increment).toBeCalled()
    })
})

Vuex テストのセットアップ

Vuex でステートマネジメントしているコンポーネントでは、セットアップ時に VuexcreateLocalVue を追加して、shallowMount の引数として使います。

__tests__/App.spec.js
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from "vuex"
import Component from '@/App.vue'

const localVue = createLocalVue()
localVue.use(Vuex)

let wrapper
let store
let actions
let mutations
let state
let getters

beforeEach(() => {
  actions = {}
  mutations = {}
  state = {
    entries: {
      sales: 1000,
      cost:  500
  },
  getters = {
    entries(state) { return state.entries }
  }
  store = new Vuex.Store({
    actions,
    mutations,
    state,
    getters
  })
  wrapper = shallowMount(Component, {
    store,
    localVue,    
  })
})

afterEach(() => {
  wrapper.destroy();
})

describe('Testing App component', () => {
    it('is a Vue instance', () => {
      expect(wrapper.isVueInstance).toBeTruthy()
    })
    // ...
})

actions のトリガーテスト

イベントトリガーの時と同様に、actionsjest.fn() を使って呼び出しを検証します。

// ...

beforeEach(() => {
  // ...
  actions = {
    updateEntries: jest.fn()
  }
  // ...
})

// ...

describe('Testing App component', () => {
  // ...
  it('calls updateEntries when inputs are changed', () => {
    wrapper.vm.sales = 2000

    expect(actions.updateEntries).toHaveBeenCalled()
  })
})

factory メソッドでデータを用意

テストごとにステートなどの設定を変えたい場合は、beforeEach でなく、factory メソッドで wrapper を用意するのが効率的です。

__tests__/App.spec.js
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from "vuex"
import Component from '@/App.vue'

const localVue = createLocalVue()
localVue.use(Vuex)

let store
let actions
let mutations
let state
let getters

const exampleEntries = {
  sales: 1000,
  cost:  500
}

// factory メソッド
const factory = (entries = exampleEntries) => {
  actions = {}
  mutations = {}
  state = {
    entries: {
      ...entries
    }  
  }
  getters = {
    entries(state) { return state.entries }
  }
  store = new Vuex.Store({
    actions,
    getters,
    mutations,
    state
  })
  return shallowMount(Component, {
    store,
    localVue
  }) 
}

describe('Testing App component', () => {
    it('is a Vue instance', () => {
      const wrapper = factory()
      expect(wrapper.isVueInstance).toBeTruthy()
    })
    // ...
})

References

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vuetifyのv-messagesのmin-heightを0pxにしたい

背景

  • エラー文言とかを入れるdivがvuetify使うとデフォルトでついてくる
  • デフォルトはmin-height 12pxだからレイアウトが結構崩れるので0pxにしたい

解決

  • deep selectorで解決
<style>
>>>.v-messages {
  min-height: 0px !important;
}
</style>

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

個人開発のWebサービスをリリースして2ヶ月で見つかったバグを一挙紹介する

この記事は、ひとり開発 Advent Calendar 2019の2日目の記事になります。
1日目の記事はhimataniさんのひとり開発でも諦めない、これからのプロダクトのつくり方でした。

はじめに

こんにちは、ぷらす (@p1ass)です。

皆さんは個人開発をする時にしっかりとテストやデバッグをしていると言い切れますか?

私は趣味レベルで開発しているときはスピード優先でテストをないがしろにしていまうときがあります。しかし、大抵バグを埋め込んでしまい、後々大変なことになってしまいます

この記事では、私が個人で開発しているMemoito(めもいと)を開発・運用していく上で見つかったバグ・不具合を自戒を込めて一挙紹介していきます。

この記事を読んで、今一度、自分の開発しているサービスを見直すきっかけになれば幸いです。

Memoitoとは

最初に、この記事で紹介していくバグが発見されたWebサービスであるMemoitoについて先に紹介しておきます。

MemoitoはTwitter連携を用いて、メモをフォローしている人と紐付けて保存することができるWebサービスです。

紹介資料

Twitterで気軽に連絡先を交換できるようになりましたが、徐々にフォローしている人が増えてきて、「この人誰だっけ?」となる経験はないでしょうか?

Memoitoはフォローしている人と紐付けてメモを保存できるので、スマホの標準メモアプリでメモするより簡単にメモを取ることができます。

勉強会やミートアップ、カンファレンス等で会った人のことを、その時話した内容と一緒にメモを取れば、後から見直すことができてとても便利です。

公式Twitterもあります。

アーキテクチャ

サービスのアーキテクチャが分かっていた方が記事を読みやすいと思うので、軽く説明します。詳しいことは、今度書く予定の記事を参照してもらうとして、こんな感じのアーキテクチャになっています。

フロントエンド

  • Nuxt.js
  • Netlify

バックエンド

  • Go

インフラ

  • GKE
  • CloudSQL

デプロイはCircleCIに寄せていて、Dockerイメージのpushやkubectl applyなどを実行しています。また、GCPのインフラ構成定義にはTerraformを使っています。

個人ではオーバーエンジニアリングですが、頑張って今風なアーキテクチャにしています。

見つかったバグ

さて、ここからは見つかったバグを紹介していきます。

リリース前の検証ユーザがDBに残っている

問題

リリース前に無効なIDを持つTwitterのユーザ情報をインサートしてしまっていて、それがDBに残ったままでした。

mysql> select id from twitter_users where id = 0;
+----+
| id |
+----+
|  0 |
+----+
1 row in set (0.04 sec)

ここでのidはTwitter側の内部IDと同じ値なので、0になることはありません。
おそらく、ゼロ値の構造体をそのままインサートしてしまっていたが原因と考えられます。

対応

直接本番DBのコンソールからrowを消しました。

ただ、この作業はさくっとSQLを1回発行するだけではダメでした。
SQLの制約がいくつか張られていたので、他のテーブルのrowを先に削除する必要がありました。

間違って他のユーザのデータを消すわけにはいかないので、入念に調査をして、冪等性があるクエリでrowを削除するようにしました。

無効な値を持つレコードがDBに格納されている

問題

これも上と似ていますが、本来存在し得ない値が書き込まれていました。

たとえ話ですが、ある状態を表すカラムstatusは本来、[0, 2]の値を取るはずが、3が書き込まれている、といった状況でした。

これは、JSON APIでPOSTされた値のバリデーションが漏れていたのが原因でした。

対応

全てのフィールドに対して、値が有効であるかを確認するバリデーション関数を実装し、テストもしっかりと書きました。

いくらクライアントサイドでバリデーションしているとはいえ、サーバ側でも実装しなきゃなという気持ちになりました(当たり前)。

1対1で対応しているはずのテーブルのレコード数が違う

問題

Memoitoは、将来的に他のSNSの対応を見据えているため、ユーザ情報は汎用的なusersテーブルとTwitter固有の情報を格納しているtwitter_usersテーブルに分けて保存しています。

現時点では、Twitterしか対応していないため、usersテーブルとtwitter_usersテーブルのrowの数は一致するはずですが、なぜか一致していませんでした。

mysql> select count(id) from users;
+-----------+
| count(id) |
+-----------+
|       200 |
+-----------+
1 row in set (0.05 sec)

mysql> select count(id) from twitter_users;
+-----------+
| count(id) |
+-----------+
|       184 |
+-----------+
1 row in set (0.04 sec)

これは、トランザクションが正しく貼れていなかったことが原因で、片方が失敗した場合に不整合が生じていました。

対応

まず、2つのテーブルに対するインサートを同じトランザクションで実行するようにアプリケーションを修正しました。

その後、おかしいrowを探し出し、SQLでパッチを当てていきました。

具体的には、

  1. 不必要なusersのrowを探す
  2. そのidが使われているメモ(notesテーブル)を探す
  3. notesuser_idを正しいものに変更する
  4. 参照が全てなくなった不必要なusersのrowを削除

という手順で対応しました。

これが、一番修正が面倒くさいバグでした。
おかしなrowの洗い出しが大変でしたし、SQLの制約を考慮しつつパッチを当てなくてないけないのでなかなか骨の折れる作業でした?

Twitterのプロフィールが更新されない (未解決)

問題

MemoitoではTwitterのプロフィールをこちら側のDBに保存しています。
登録ユーザはログインするたびに新しいプロフィールに更新されるようになっています。

しかし、メモを取った相手は必ずしも登録ユーザではないため、プロフィールが更新されていませんでした。

結果として、プロフィールアイコンが404で表示されないという不具合がありました。

対応

実はこの不具合はまだ修正できていません。せっせと実装中です。

DBに保存されているユーザの全てのプロフィール情報をTwitter APIから取得して、プロフィール情報を更新する方法を考えています。

おそらく、一番オーソドックスな方法なのではないでしょうか。他に良い方法があれば教えて下さい。

おわりに

ここまで、リリースしてから見つかったバグを紹介してきました。

特に整合性周りがきちんと実装していないと、不具合が生まれがちなので次からはしっかりと考えて実装しようと思いました。(対応大変なので、、、)

皆さんもこの記事を反面教師として、今一度自分のアプリケーションを見直してみましょう。何かヤバいものが見つかるかもしれません、

最後になりますが、よかったらMemoito使ってみてください!

明日はひとり開発 Advent Calendar 2019はbinnmtiさんの担当です。お楽しみに。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

仮想DOMの時代にDOMを返すAPIを扱う(CSR編)

DMMグループ Advent Calendar 2019 12日目の記事です。

アドカレ映えしない、地味~な内容ですが、よろしくお願いします。

TL;DR

Angular / React / Vue製サイトにWebAPI経由で返されたDOMを挿入する

Angular React Vue
image.png image.png image.png

前書き

大規模Webサイトの各ページへ横断的に要素を挿入したい要件があるとします。

  • ブランドロゴ、ナビゲーション、トラッキングタグなど
  • 大規模なので、ページ間で管轄が分かれていたり、別々のアーキテクチャで構築されていたりする
  • 要素の管轄は一部署に集約したい

これらの要件を満たす手段として、 DOM Stringを返すようなAPIが提供される ことがあります。

他の手段としては「DOMを構築・挿入するスクリプトを配布する」という方法もありますが、それと比べて以下のようなメリットが期待できます。

  • 呼び出しタイミングや組み込み位置をある程度制御できる
  • サーバサイド・クライアントサイドのどちらからでも呼び出せる

「クライアントサイドからでも呼び出せる」とはいえ、必ずしもすんなりできるとは限らず、特に昨今のJSフレームワークで開発されたWebサービスに組み込むにはハマりどころが色々あります。

今回はAngular, React, Vue製でクライアントサイドレンダリングするようなWebサイトにそのようなAPIを組み込む際の方法や注意を纏めています。

[全般]DomStringをDOMとしてrenderされるようにinnerHtmlラッパを使う

いずれのフレームワークもXSS対策のため、テンプレートやJSX中で使用される変数はHTMLエスケープされるようになっています。

そのため、APIレスポンスのDOM stringをそのままテンプレートやJSXへ書き出そうとしても、HTMLがプレーンテキストとして表示されるだけでHTML要素としては読み込まれません。

そこで、変数中のHTMLをHTML要素として表示するための機能が提供されておりますので、そちらを利用します。

大前提としてAPI側のXSS対策はなされているものとします。

Angular React Vue
innerHTML dangerouslySetInnerHTML v-html

[linkタグ]Angularの場合はlinkタグの扱いに注意

APIレスポンスの中に、スタイルシートを読み込むためのlinkタグが含まれる場合の話です。

React, Vueは上記の方法でlinkタグを出力することが出来るのですが、AngularではinnerHTMLでHTML要素を出力する際に、やはりXSS対策のためlinkタグやscriptタグが削除されるようになっています。

これを回避する方法も提供されており、DomSanitizerのbypassSecurityTrustHtmlメソッドを利用することでlinkタグを出力することができます。

[scriptタグ]innerHTMLではJSを実行出来ない

APIレスポンスにscriptタグが含まれる場合は厄介です。

innerHTML(やこれまで挙げたFWのinnerHTMLラッパ)を利用することで、DOMやスタイルを反映させることができますが、scriptタグ経由のJSを呼び出すことは出来ません。

これはW3CのHTML5 scriptタグの仕様として記述されています。

When inserted using the document.write() method, script elements execute (typically synchronously), but when inserted using innerHTML and outerHTML attributes, they do not execute at all.

これについては、フレームワーク側のサポートはなく、動的にJSを実行する方法などに記載されているように、

  1. document.createElement('script'); でscript要素を作成、
  2. domStringをパースしてscript要素を組み立てていき、
  3. 最終的に document.body.appendChild などで完成したscript要素を挿入する

という、骨の折れる作業をする必要がありそうです。

[実践]ヘッダー・フッターでコンテンツを挟み込む

ここで少し具体的な話になります。

APIのレスポンスとして、複数のUI、例えばヘッダーとフッターが一遍に返されるような場合はどのように実装すればよいでしょうか。

image.png

この場合、ヘッダー・フッターを1つのコンポーネントとして扱うのが良さそうです。

各フレームワークとも、似たようなかたちで任意のコンテンツを挟み込むようなコンポーネントを実装出来ます。

Angular React Vue
ng-content props.children slot

実装例 / Angular

app.component.html
<navigation>
  <!-- サイトコンテンツ -->
</navigation>
navigation.component.html
<div [innerHTML]='header'></div>
<ng-content></ng-content>
<div [innerHTML]='footer'></div>

実装例 / React

App.js(内のJSX)
<Navigation>
  <!-- サイトコンテンツ -->
</Navigation>
Navigation.js(内のJSX)
<div dangerouslySetInnerHTML={{ __html: header }} />
  {this.props.children}
<div dangerouslySetInnerHTML={{ __html: footer }} />

実装例 / Vue

index.html
<navigation>
  <!-- サイトコンテンツ -->
</navigation>
template
<div v-html="header"></div>
  <slot></slot>
<div v-html="footer"></div>

おわり

  • 本記事はDOMを返すようなAPIをmanageする、というのがテーマであり、共通UIを提供する手段としてDOMを返すAPIの設計を推奨するものではありません。
  • アプリケーションをSSRする場合は更なる課題がありそうですので、それはまた次の機会に。
  • 明日は @uruha さんより、Virtual DOMの時代の楽しい話が聞けるんじゃないかと思うので、乞うご期待!

サンプルアプリの元ネタ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vue-stripe-elements-plusで複数の<card>を使うとエラーになる問題の解決方法

背景

  1. vueでstripeのカード入力UIを表示するために、vue-stripe-elements-plus を使っていた。
  2. 一つの画面で複数のカード入力UIを表示するとエラーになるので治したい

結論

https://github.com/fromatob/vue-stripe-elements
の代わりに
https://github.com/Cl3MM/vue-stripe-better-elements
を使うと良い。

注意

<card>タグの代わりに<stripe-element type="card">タグを使う必要がある。

オリジナルでは以下のように<card>タグが定義されている。vue-stripe-better-elementsではこれが無いので、<stripe-element type="card">と書く必要がある。

https://github.com/fromAtoB/vue-stripe-elements/blob/master/src/Card.vue

createTokenの代わりに(stripe-elementのvueインスタンス).elements.createTokenを使う必要がある

stripe-elementのvueインスタンスは以下のようになっている。
elements配下にcreateToken関数があるらしい。

Each component looks like this:
{
type: 'card', // element type (card, iban...)
key: 'pk_test_xxxx', // the stripe key used for this component,
element: {/../}, // the stripe element created by the Stripe library,
elements: {/../}, // the stripe elements created by the Stripe library,
createToken: fn, // the element's createToken function
createSource: fn, // the element's createSource function
retrieveSource: fn // the element's retrieveSource function
}
https://github.com/Cl3MM/vue-stripe-better-elements#what-you-get-for-free

解説

Moreover vue-stripe-elements, prevents you from using multiple components on the same page, as there is only one instance of the createToken and createSource methods needed to further process payment.
https://github.com/Cl3MM/vue-stripe-better-elements#why

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PandocでMarkdownからVueのSFCを生成してみた

以前、FAQみたいなページをmarkdownで書けるようにしていたけど、
ランタイムでコンパイルするのはやっぱり重いなと思い、
軽量化の一環で前処理にしてみたときの備忘録。

事前にPandocでVueのSFCを生成すればよかった(´ω`)

インストール

$ brew install pandoc

テンプレートを用意する

こんな感じのファイルを用意する。
$body$にmarkdownで書いたHTMLが挿入される感じ

<!-- ./vue-sfc.vue-->
<template>
  <div>$body$</div>
</template>

<script lang="ts">
import { Component, Vue } from "nuxt-property-decorator";

@Component
export default class extends Vue {}
</script>

Pandocで.vueを生成する

こんなMarkdownファイル(faq.md)を例に。

# よくある質問 / FAQ

## 積読本しか登録してはいけないのですか?

こんな感じで実行すると

$ pandoc -f markdown -t html --template=./vue-sfc.vue ./faq.md > ./FaqContent.vue
# -f ... 入力ファイルのフォーマット
# -t ... 出力のフォーマット
# --template ... テンプレートファイル

こんなファイルが生成されます。

<!-- FaqContent.vue -->
<template>
  <div><h1>よくある質問 / FAQ</h1>
<h2>積読本しか登録してはいけないのですか?</h2><div>
</template>

<script lang="ts">
import { Component, Vue } from "nuxt-property-decorator";

@Component
export default class extends Vue {}
</script>

あとは、このコンポーネント(FaqContent.vue)を表示するページにimportすればOK!

ランタイムでコンパイルしなくても、前処理でいけた(´ω`)

小ネタ

npm run build時に生成する

手動で実行するのはめんどうなので、package.jsonscriptsに追加した。

{
  "scripts": {
    "dev": "npm run build:md && nuxt",
    "build": "npm run build:md && nuxt generate",
    "build:md": "pandoc -f markdown -t html --template=./vue-sfc.vue ./faq.md > ./FaqContent.vue",
  }
}
.md内で別のコンポーネントを使う

別のコンポーネントを使いたいときは、テンプレートに追加しておく。
今回は、vue-tweet-embedを使いたかったので追加。

<template>
  <div>$body$</div>
</template>

<script lang="ts">
import { Component, Vue } from "nuxt-property-decorator";
import { Tweet } from "vue-tweet-embed";

@Component({ components: { Tweet } })
export default class extends Vue {}
</script>

生のHTMLを表示したいときは、入力フォーマットに+raw_htmlをつけるらしい

$ pandoc -f markdown+raw_html -t html --template=./vue-sfc.vue ./faq.md > ./FaqContent.vue

ただ、ちゃんと変換されないことがあるので、sedで置換したりしてる...

$ pandoc -f markdown+raw_html -t html --template=./vue-sfc.vue ./faq.md | sed 's/&lt;Tweet/<Tweet/g' | sed 's:&gt;</Tweet>:></Tweet>:g' > ./FaqContent.vue

Markdownの書き方などで回避できるかもしれないけど、暫定的にこの対応。。

以上!!

こんなのつくってます!!

積読用の読書管理アプリ 『積読ハウマッチ』をリリースしました!
積読ハウマッチは、Nuxt.js+Firebaseで開発してます!

もしよかったら、遊んでみてくださいヽ(=´▽`=)ノ

要望・感想・アドバイスなどあれば、
公式アカウント(@MemoryLoverz)や開発者(@kira_puka)まで♪

参考にしたサイト様

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vuex のストアの定義方法をそのままに型付きで Vuex を利用する方法を考えてみた

TS, TSX, Vuex とかの雑感

  • 昨今のコードが肥大化するようなフロントエンド開発では、コードのモジュール分割は必須。型情報をベースにモジュール間の参照・連携もしてくれ、品質や生産性を向上してくれる TypeScript や VS Code のようなエディタも必須だと感じてる
  • 3 大フレームワークの選定においても、まずは TS の対応状況がどうなってるのかが気になる
  • ただ作る内容によっては 3 大フレームワークは使わず、素の TSX だけ使えば良いのでは?とか思う時もある
  • Angular とかだと HTML 周りで型がはずれることで辛い思いをすることもしばしあり、TSX が欲しくなる
  • マークアップする人には TSX(JSX)は不人気な印象もあり、仕事で扱う場合ではケースバイケースで素の HTML と使い分ける必要がありそう
  • Vue で TSX 使うとなると「それなら React でいいじゃん」ってなりがちだけど、素の HTML でも TSX でも簡単に使い分けられる Vue はある意味優秀なのではとも思う
    • (ただ Vue の場合、他の 2 つと比べると AngularJS 的な匂いの昔ながらの機能が雑多に盛り込まれてる印象もあり、実装指針とか決めとかないとトラブルの原因にも...)
  • そういう意味では他の FW より選択肢のある Vue だけど Vuex の TS 対応が厳しい問題がある(公式の対応に期待したい)
  • ストアの定義については、普通に型付きで書けば良いだけなので問題ない(actions から呼ぶ dispatch や commit は別)
import { bar, barModulePath } from './modules/bar';

export const rootState = {
  count: 5,
};
export type RootState = typeof rootState;

export const root = {
  state: rootState,
  getters: {
    count(state: RootState) {
      return state.count;
    },
  },
  mutations: {
    addCount(state: RootState, payload: { qty: number }) {
      state.count += payload.qty;
    },
  },
  modules: {
    [barModulePath]: bar,
  },
};
  • ただ、これらを参照したり実行したりする側のコードが、型の効かない辛い記述になってしまう
@Component
export default class Counter extends Vue {
  get rootCount(): number {
    return this.$store.getters.count;
  }
  get barCount(): number {
    return this.$store.getters['bar/count'];
  }
  rootCountUp() {
    this.$store.commit('addCount', { qty: 1 });
  }
  ...
  • TS で Vue 使うなら下手に Vuex は使わないで、独自のストアパターンで実装した方が良い気がする
  • ただ、Vuex を使う場合、公式故に使い方も広く世に知られており、特定プロジェクトのための独自仕様に学習コストをかけなくても良いというメリットもある
  • Vuex になんとかして型付けしてくるライブラリはいろいろあるようだけど、Vuex 本来の書き方と大きく変わっちゃてたら、上記のようなメリットはあまり感じられない
  • ので、ストアの定義方法はそのままに、ストアを参照・実行する側のみにうまい具合に型付きで実行できるライブラリがあったらうれしいのだけど...

Vuex の型問題で解決したい部分

  • たとえば、ストアの定義方法はそのままに、以下みたいな記述でタイプセーフに mutations を呼び出せたらうれしい(この書き方なら VS Code の名前変更機能でプロパティ名を変更した場合、全ての参照箇所を自動修正できるし)
const rootCommitters = getCommitters(store, root.mutations);
...
export default class Counter extends Vue {
  rootCountUp() {
    rootCommitters.addCount({ qty: 10 });
  }
  ...
  • とりあえず型を無視していいなら以下のコードで実現できる
export const getCommitters = (
  context: any,
  mutations: { [name: string]: (state: any, payload: any) => void },
  options?: {
    modulePath?: string;
    root?: boolean;
  },
) => {
  return Object.keys(mutations).reduce((output: { [name: string]: any }, key: string) => {
    output[key] = (payload: any) => {
      const name = options && options.modulePath ? `${options.modulePath}/${key}` : key;
      context.commit(name as string, payload, options || {});
    };
    return output;
  }, {})
};

型パズルで型を後付けしてみる

type Mutations = { [name: string]: (state: any, payload: any) => void };
type MutationsAdapter<M extends Mutations> = {
  [P in keyof M]: (payload: any) => void;
};

const rootCommitters = getCommitters(store, root.mutations) as MutationsAdapter<typeof root.mutations>
  • これだと payload が any 固定になっちゃうので、Conditional Types と infer を使って payload の型を得るようにしてみる
type MutationsPayload<T> = T extends (state: any, payload: infer U) => void ? U : never;
...
type MutationsAdapter<M extends Mutations> = {
  [P in keyof M]: (payload: MutationsPayload<M[P]>) => void;
};
  • payload 不要な mutations もありえるので、Conditional Types で出し分けてみる
type MutationsAdapter<M extends Mutations> = {
  [P in keyof M]: M[P] extends (state: any) => void
    ? () => void
    : (payload: MutationsPayload<M[P]>) => void;
};
  • これで型が効いてコードも補完されるようになる、めでたし!(getters も actions も同じような方法で型付けすれば良さそう)

vuex-adapter-1.gif

ライブラリ化してみた

// ルートモジュール
const rootStore = new VuexAdapter(store, root);
rootStore.getters.count;
rootStore.committers.addCount({ qty: 1 });

// サブモジュール
const barStore = new VuexAdapter(store, bar, { modulePath: 'bar' });
barStore.committers.addCount({ qty: 10 });
barStore.dispatchers.passCountToRoot();
  • 面倒なのでモジュール単位で指定できるようにしてみた
  • actions から commit や dispatch をする場合も、上記と同じ方法で実行可能(あるいは第一引数に store じゃなくて、actions が実行される都度 actionContext を渡しても OK)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vuex のストア定義方法をそのままに型付きで Vuex を利用する方法を考えてみた

TS、TSX、Vuex とかの雑感

  • 昨今のコードが肥大化するようなフロントエンド開発では、コードのモジュール分割は必須。型情報ベースにモジュール間の参照・連携をし、品質や保守性を向上してくれる TypeScript や VS Code のようなエディタも必須と感じている
  • 3 大フレームワークの選定の際も、まずは TS の対応状況がどうなってるのかが気になる
    • 作るモノによってはこれらは使わず、素の TSX だけ使えば良いのでは?とか思う時もある
  • Angular とかだと HTML 周りで型がはずれちゃうことで辛い経験をすることもしばしあり、TSX が欲しくなる
  • マークアップする人には TSX(JSX)は不人気な印象あり、、、仕事で扱う場合ではケースバイケースで素の HTML と使い分ける必要がありそう
  • Vue で TSX 使うとなると「それなら React でいいじゃん」ってなりがちだけど、素の HTML でも TSX でも簡単に使い分けられる Vue はある意味優秀とも思う
    • (ただ Vue の場合、他の 2 つと比べると昔ながらの AngularJS 的な匂いの機能も雑多に盛り込まれてる印象もあり、実装指針とか決めとかないと後のバグの原因になることも...)
  • そういう意味では他の FW より選択肢のある Vue だけど Vuex の TS 対応が厳しい問題がある(公式の対応に期待したい)

Vuex の型問題

  • Vuex のストアの定義については、普通に型付きで書けば良いだけなので問題ない(actions から呼ぶ dispatch や commit は別)
import { bar, barModulePath } from './modules/bar';

export const rootState = {
  count: 5,
};
export type RootState = typeof rootState;

export const root = {
  state: rootState,
  getters: {
    count(state: RootState) {
      return state.count;
    },
  },
  mutations: {
    addCount(state: RootState, payload: { qty: number }) {
      state.count += payload.qty;
    },
  },
  modules: {
    [barModulePath]: bar,
  },
};
  • ただ、これらを参照したり実行したりする側のコードでは、型の効かない辛い記述になってしまう
@Component
export default class Counter extends Vue {
  get rootCount(): number {
    return this.$store.getters.count; // 型が効かない
  }
  get barCount(): number {
    return this.$store.getters['bar/count']; // 型が効かない
  }
  rootCountUp() {
    this.$store.commit('addCount', { qty: 1 }); // 型が効かない
  }
  ...
  • TS で Vue 使うなら下手に Vuex は使わないで、独自のストアパターンで実装した方が良い気がする
  • ただ、Vuex を使う場合、公式故に使い方も広く世に知られており、特定プロジェクトのための独自仕様に学習コストをかけなくても済むいというメリットもある
  • Vuex になんとかして型付けしてくれるライブラリはいろいろあるようだけど、Vuex 本来の書き方と大きく変わっちゃてたら、上記のようなメリットはあまり感じられない
  • ので、ストアの定義方法はそのままに、ストアを参照・実行する側のみにうまい具合に型付きで実行できるライブラリがあったらうれしいのだけど...

Vuex の型問題で解決したいところ

  • たとえば、ストアの定義方法はそのままに、以下みたいな記述でタイプセーフに mutations を呼び出せたらうれしい(タイプセーフ前提でのこの書き方なら、VS Code の名前変更機能でプロパティ名を変更した場合、全ての参照箇所を自動修正できたりもするので、プロパティ名を引数渡しする形より良さそう)
const rootCommitters = getCommitters(store, root.mutations);
...
export default class Counter extends Vue {
  rootCountUp() {
    rootCommitters.addCount({ qty: 10 });
  }
  ...
  • とりあえず型を無視していいなら以下のコードで実現できる
export const getCommitters = (
  context: any, // `Store<S> | ActionContext<S, R>`とかにしたほうが良いけどとりあえず any で
  mutations: { [name: string]: (state: any, payload: any) => void },
  options?: {
    modulePath?: string;
    root?: boolean;
  },
) => {
  return Object.keys(mutations).reduce((output: { [name: string]: any }, key: string) => {
    output[key] = (payload: any) => {
      const name = options && options.modulePath ? `${options.modulePath}/${key}` : key;
      context.commit(name as string, payload, options || {});
    };
    return output;
  }, {})
};

型パズルで型を後付けしてみる

type Mutations = { [name: string]: (state: any, payload: any) => void };
type MutationsAdapter<M extends Mutations> = {
  [P in keyof M]: (payload: any) => void;
};

const rootCommitters =
  getCommitters(store, root.mutations) as MutationsAdapter<typeof root.mutations>

rootCommitters.addCount({ qty: 10 }); // メソッド名は補完されるけど、payload は any のまま
  • これだと payload が any 固定になっちゃうので、Conditional Types と infer を使って payload の型を得るようにしてみる
// T が (state: any, payload: infer U) => void に代入可能なら U そうでないなら never
type MutationsPayload<T> = T extends (state: any, payload: infer U) => void ? U : never;
...
type MutationsAdapter<M extends Mutations> = {
  [P in keyof M]: (payload: MutationsPayload<M[P]>) => void;
};
  • payload 不要な mutations もありえるので、Conditional Types で出し分けてみる
type MutationsAdapter<M extends Mutations> = {
  // mutations(`M[P]`)が`(state: any) => void`に代入可能なら A そうでないなら B
  [P in keyof M]: M[P] extends (state: any) => void
    ? () => void  // A
    : (payload: MutationsPayload<M[P]>) => void;  // B
};
  • これで型が効いてコードも補完されるようになる、めでたし!(getters も actions も同じような方法で型付けすれば良さそう)

vuex-adapter-1.gif

ライブラリ化してみた

// ルートモジュール
const rootStore = new VuexAdapter(store, root);
rootStore.getters.count;
rootStore.committers.addCount({ qty: 1 });

// サブモジュール
const barStore = new VuexAdapter(store, bar, { modulePath: 'bar' });
barStore.committers.addCount({ qty: 10 });
barStore.dispatchers.passCountToRoot();
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

propsで親コンポーネントから子コンポーネントへデータを伝播するときに静的な値を渡せばいいだけなのにv-bindを使ってしまって変数として認識されてエラーになってしまった

表題の件

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

propsで親コンポーネントから子コンポーネントへデータを伝播するときに、静的な値を渡せばいいだけなのにv-bindを使ってしまい変数として認識されて、エラーになってしまった

propsで親コンポーネントから子コンポーネントへデータを伝播するときに、v-bindの使い方をよくわかっていないことに気が付いたのでメモ :pencil:

修正前

  • v-bindでプロパティを指定すると、JavaScriptと認識されて(変数と認識されて)、動的にプロパティを受け渡すことができるが、なぜか勘違いしていて以下のようなコードを書いてしまっていた(動的に受け渡す必要がなくて、静的に受け渡しかった)
  • これでundefined errorが発生してた :rolling_eyes:
<blog-post :blog-name="{{ $user->getBlogName() }}"></blog-post>

修正後

  • プロパティで静的な値を渡しかったので以下がのようなコードを書けばよかった(: を取り除いた)
<blog-post blog-name="{{ $user->getBlogName() }}"></blog-post>

参考

もっとちゃんとドキュメントを読まないと :pray:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む