20191202のvue.jsに関する記事は11件です。

Composition APIってなんだ

これはVue Advent Calendar 2019 4日目の記事です。

主語がでかそうなんですが大した内容じゃないです、文字多め。Composition API(旧Function-based API)のrfcが出てからそこそこ時間が過ぎ、現在はvue-nextに取り込まれメンテされています。正式リリースは来年だそうです。

ところでそもそもComposition APIってなんなんでしょうか。なんで実装されるのか。ここら辺の認識をせっかくなのでしっかり合わせて正式リリース日を迎えましょうというのがこの記事の趣旨になります。

Composition APIとは何か

そんなに難しいものでもなく、そもそもしっかりREADMEにコンセプトは書かれています。※抜粋

https://vue-composition-api-rfc.netlify.com/#motivation

Logic Reuse & Code Organization
1. The code of complex components become harder to reason about as features grow over time. This happens particularly when developers are reading code they did not write themselves. The root cause is that Vue's existing API forces code organization by options, but in some cases it makes more sense to organize code by logical concerns.
2. Lack of a clean and cost-free mechanism for extracting and reusing logic between multiple components. (More details in Logic Extraction and Reuse)

特に2つ目、「複数のコンポーネント間でロジックを抽出して再利用するための、クリーンでコストのかからないメカニズムの欠如」は、成長するVueアプリケーションにおいて致命的な問題になりえます。

現Option API(今の書き方)では、コンポーネントはロジックとviewを閉じ込めた単一のファイルであり、それ以上はどう頑張っても分解することができません。

これを解決しようとしたのがPresentational ComponentとContainer Componentのような設計手法です。見た目に関心を持つコンポーネントとアプリケーションの動作に関心を持つコンポーネントを分離していい感じにしようみたいな。

しかしこの設計はもっともテストをしたいContainer Componentの方がアプリケーションの成長に合わせて肥大化し、複雑になり、テスタビリティが失われてしまうという欠点があります。(設計の頑張り次第ではあります)

SFCという設計そのものがVueユーザーのニーズ、アプリケーションの規模について来られなくなったとも言えます。覚えている人も多いかと思いますがそもそもVueは当初から小〜中規模向けのような扱いをされていました。しかし、スモールスタートしたプロダクトが中規模、大規模と成長することは自然なことであり、誰もが到達しうるものです。

というわけで、現状のVueが抱える問題を解決するために生まれたのがComposition APIです。ミソは問題を解決するためにという部分で、既存の置き換えではないということです。不要な人には不要。

SFCからの脱出

Composition APIではもはやSFCという制約はなく、自由にロジックを抽出することができます。(ライフサイクルさえも!)

このコードはドキュメントの設計セクションにあるサンプルです。

export default {
  setup() { 
    // Network
    const { networkState } = useNetworkState()

    // Folder
    const { folders, currentFolderData } = useCurrentFolderData(networkState)
    const folderNavigation = useFolderNavigation({ networkState, currentFolderData })
    const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData)
    const { showHiddenFolders } = useHiddenFolders()
    const createFolder = useCreateFolder(folderNavigation.openFolder)

    // Current working directory
    resetCwdOnLeave()
    const { updateOnCwdChanged } = useCwdUtils()

    // Utils
    const { slicePath } = usePathUtils()

    return {
      networkState,
      folders,
      currentFolderData,
      folderNavigation,
      favoriteFolders,
      toggleFavorite,
      showHiddenFolders,
      createFolder,
      updateOnCwdChanged,
      slicePath
    }
  }
}

function useCurrentFolderData(networkState) { // computedとかwatchとか中で使う
}

function useFolderNavigation({ networkState, currentFolderData }) { // ...
}

function useFavoriteFolder(currentFolderData) { // ...
}

function useHiddenFolders() { // ...
}

function useCreateFolder(openFolder) { // ...
}

ここではuseのプレフィックスがついた関数がそれぞれCompositionと呼ばれる関数になります。どの関数がリアクティブな値を扱うcomposition(合成関数)なのか判別するためにuseを用いることが推奨されています。多分。

setup()の中では呼び出す関数内でもライフサイクルメソッドや各種Vue APIを利用できます。これは外部ファイルにもできます。

つまり、

import { 
  useCurrentFolderData,
  useFolderNavigation,
  useFavoriteFolder,
  useHiddenFolders,
  useCreateFolder
} from '~/compositions/folders'

import { 
  useCwdUtils,
  usePathUtils
} from '~/compositions/utils'

export default {
  setup() { 
    // Network
    const { networkState } = useNetworkState()

    // Folder
    const { folders, currentFolderData } = useCurrentFolderData(networkState)
    const folderNavigation = useFolderNavigation({ networkState, currentFolderData })
    const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData)
    const { showHiddenFolders } = useHiddenFolders()
    const createFolder = useCreateFolder(folderNavigation.openFolder)

    // Current working directory
    resetCwdOnLeave()
    const { updateOnCwdChanged } = useCwdUtils()

    // Utils
    const { slicePath } = usePathUtils()

    return {
      networkState,
      folders,
      currentFolderData,
      folderNavigation,
      favoriteFolders,
      toggleFavorite,
      showHiddenFolders,
      createFolder,
      updateOnCwdChanged,
      slicePath
    }
  }
}

こんな感じでまるごと外部ファイルとして抽出することもできます。setup()はいわばエントリポイントで、各種アプリケーションロジックを利用しtemplateへ値を返すだけの役割として機能させることができます。抽出した関数はjestのような純粋なテストルーツのみを利用してテストをすることができ、関数はJavaScriptユーザーが普段行っている粒度管理で構成できるため、テスタビリティの高い設計を目指すことができます。

viewとロジックが分離されたので、テストもレンダリングのみを確認するものとロジックのI/Oを見るものに分けることができます。

共通化が絶対ではない

基本的に共通化の文脈でComposition APIは語られますが、共通化が絶対というわけではなく単にテストがしやすく、複雑な状態管理をコンポーネントから追いやる目的の利用もありです。

共通化するということは複数の参照を持つということであり、依存が増えれば増えるほど影響範囲が分からなくなって手が付けられないという自体に陥ることもあります。mixinがわかりやすい例ですね。

共通化するものはアプリケーションコアとして提供して、それ以外は単一のコンポーネント、またはページ内のコンポーネント、Vuexの参照が同じページ同士での共通化に留める等、スコープを意識することが重要になりそうです。

おわりに

話が長くなってきたのでまとめるとこうです。

  • Composition APIはコンポーネントからロジックを抽出して管理できるAPIである
  • 中規模以上のVueアプリケーションにおいてぶつかる問題に対処するためのツールである

利用することで以下のような恩恵を受けられます。

  • Presentational Component/Container Componentを用いずにviewとアプリケーションロジックを分離できる
  • アプリケーションロジックのテストがより容易になる

さらに既存APIよりも優れた面がいくつかあります。

  • thisがない(人によってはデメリットになりうるかも?)
  • TypeScriptサポートの充実
  • フルで書いた場合ランタイムが小さくなる
  • JavaScript設計の知見がそのままVueアプリケーションの設計に生かせる

最後に開発者(Evan?)からのComposition APIに関するコメントです(Googel翻訳)※一部抜粋
https://vue-composition-api-rfc.netlify.com/#more-flexibility-requires-more-discipline

多くのユーザーが指摘したように、Composition APIはコード編成の柔軟性を高めますが、開発者が「正しく行う」ためにはより多くの規律が必要です。スパゲッティコードにつながることを心配する人もいます。つまり、Composition APIはコード品質の上限を引き上げる一方で、下限も引き下げます。

ある程度同意します。ただし、次のことを信じています。

1.上限のゲインは、下限のロスをはるかに上回ります。

2.適切なドキュメントとコミュニティガイダンスにより、コード編成の問題に効果的に対処できます。

充実したドキュメントもVueの良さの1つなので、期待して待ってます。

明日は@taaiさんです。

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

Nuxt.jsで作っているサイトにKARTEのタグを入れてみるメモ

Nuxt.jsで作っているWebサイトKARTEを入れてみたときのメモです。

NuxtにGoogle アナリティクスを導入する手順とほぼ同じで出来た

公式のGoogle アナリティクスを使うには?が参考になります。

読んでみると

他のトラッキングサービスでも、同様の方法を使うことができます。

って書いてますね。

1. plugins/karte.jsを作成

こんな雰囲気でpluginsの中にkarte.jsを作成します。

スクリーンショット 2019-12-02 22.53.40.png

KARTEの管理画面で計測タグを探してスクリプトタグの内部を見つけましょう。

plugins/karte.js
export default ({ app }) => {
    /*
    ** クライアントサイドかつプロダクションモードでのみ実行
    */
    if (process.env.NODE_ENV !== 'production') return
    /*
    ** karteのスクリプトをインクルード
    */
    (function(){var t,e,n,r,a;for(t=function(){var t;return t=[],function(){var e,n,r,a;for(n=["init","start","stop","user","track","action","event","goal","chat","buy","page","view","admin","group","alias","ready","link","form","click","submit","cmd","emit","on","send","css","js","style","option","get","set","collection"],e=function(e){return function(){return t.push([e].concat(Array.prototype.slice.call(arguments,0)))}},r=0,a=[];r<n.length;)t[n[r]]=e(n[r]),a.push(r++);return a}(),t.init=function(e,n){var r,a;return t.api_key=e,t.options=n||{},a=document.createElement("script"),a.type="text/javascript",a.async=!0,a.charset="utf-8",a.src=t.options.tracker_url||"https://static.karte.io/libs/tracker.js",r=document.getElementsByTagName("script")[0],r.parentNode.insertBefore(a,r)},t},r=window.karte_tracker_names||["tracker"],e=0,n=r.length;n>e;e++)a=r[e],window[a]||(window[a]=t());tracker.init("xxxxxxxxxxxxxxxxxxxxxxxxxx")}).call(this);
   /* ↑計測タグの中身をコピペ*/ 

}

IDっぽいところをxxxx~~にしてますが、こんな感じで入れ込みます。

ここのタグは人によって権限っぽい指定の箇所が変わるかもしれないので、この記事からよりは、大元からのコピペが良いと思います。

2. nuxt.config.jsの設定

利用するにあたり、nuxt.config.jsにも以下の記載をします。

nuxt.config.js
省略

  plugins: [
    { src: '~plugins/karte.js', mode: 'client' }
  ],

省略

以上!

所感

割と簡単に出来ました。

実際に僕の場合はnuxt generateで静的サイトにしてホスティングしていますが、静的ホスティングじゃない場合でも同様に動くと思います。(たぶん)

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

絵文字だけプログラミング!!を作ってみた&得られた知見

Kaomojy

スクリーンショット 2019-12-02 21.12.25.png
上はfizzbuzzです

内容

Kaomojy
顔文字だけでプログラム書くことができます!!!
ただのそれだけ。。。

モチベーション

Brainfxxkを出会った時に衝撃がすごすぎて、GoでBrainfxxk用のコマンド作ったりしていたのですが、今回はJavascript(vue)版を作ってみました!

絵文字だけで文字のやり取りできるの、暗号ぽくって面白い!!!
とか思って絵文字にしました

得られた知見

絵文字のsplit

絵文字を分割して、配列として保持して置くという処理を実現するときに
当初

const commands = inputStr.split("")

このようなコードを書いていました。

しかし、このコードだと文字化けしてしまいます。調べてみると、jsは2バイトを一文字として受け取る仕組みですが、顔文字は例外的に4バイトで表されているそうです。(詳しくは→文字列を配列化する)

そのため、

const commands = Array.from(inputStr)

とする必要があります!

memoryの初期化

Brainfxxkでは、処理の結果を擬似的なメモリとして配列で保持しています。
Goの場合int型のスライスを初期化する時、自動で0が挿入されます

memory := make([]int,100)//[0,0,0,...]

一方javascriptでは、

let memory = new Array(100) //[undefined,undefined...]

となってしまい、数値が入っていないため上手くメモリが動きませんでした。(書きながら考えていたのですが、上のコードだとそもそも型定義してないから、undefinedは納得ですw)

そのため、初期化時、0を挿入する処理を記述しました

let memory = Array.apply(null, Array(100)).map(function(){return 0;});

まとめ

楽しくコードを書きながら、思わぬところで言語仕様の勉強ができました!

Goの実装については、以下にあるので、
https://github.com/ryomak/brainfuck-go
(悟空が使う言葉でプログラムを書く)Go空語があるので、是非お試しください!(こちらは正規表現を使って無駄なコードも受け入れる設定になっています)

またKaomojyのコードはhttps://github.com/ryomak/brainfuck-web
にありますので、修正やexampleコードの追加待ってます!

ts設定入れているのに、ゴリゴリjsで書いてますww

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

椅子も立派な仕事道具〜Good Hip makes Good Code〜

こんにちは。estieでco-workerとしてフロントエンドを書いているt-poyoです。
突然ですがみなさん、体の調子はいかがですか?

お尻が痛い?腰の調子が悪い?

だめです、みなさんエンジニアならお尻の下にある最も身近な道具…、椅子にこだわるべき。
睡眠時間と同じか、それより長い時間コード書いてますよね?\はーい/
良い枕を選び、良いPCを選ぶように、良い椅子を選んで快適なエンジニアライフを送りましょう!

TL;DR

  • ハーマン・ミラーのアーロンチェアがマジでお勧め!!

そもそもハーマン・ミラーとは

1905年、デザイン・ディレクターのジョージ・ネルソンによりアメリカ合衆国ミシガン州で創業されたモダン家具メーカー。おそらくモダニズム家具の最も多作で影響力の強いメーカーです。

Wikipedia:ハーマンミラー

創立者のジョージ・ネルソンは自身もデザイナーとして活躍し、1940~60年代のアメリカ、のちに ミッドセンチュリー と呼ばれるいち時代を築いたイームズ夫妻を見出すなど、インテリアの歴史に名を残しています。

美術館などでよく見かける Nelson Platform Bench ↓も、ジョージ・ネルソン作。街中でも最近よく見かける気がします。

Screen Shot 2019-12-02 at 19.23.39.png

なぜハーマン・ミラーなのか

1. スタイルに合わせたラインナップ

後述のように多彩な調整機能を持つハーマン・ミラーのオフィスチェアですが、まずそもそもの用途に分けて、的確なラインナップがあります。

  • 普通に(深く傾けず)座る人には、定番!「アーロンチェア」
    Screen Shot 2019-12-02 at 18.06.59.png

  • 深く後ろに倒して座る人には、「エンボディチェア」

筆者は後傾気味に座るので、このチェアが好きです。

Screen Shot 2019-12-02 at 18.07.36.png

  • カラーリングを重視、低コストで楽しみたい人には、「セイルチェア」

Screen Shot 2019-12-02 at 18.07.18.png

他には、エクゼクティブの使用を想定した「エクゼクティブチェア」
軽やかな見た目と座り心地の「ミラ2チェア」などがあります。

ハーマンミラー公式サイト

2. 圧倒的なカスタマイズ性

アーロンチェア(リマスタード)は、調整可能部位が本当に多い!
体にぴったりフィット、ストレスなく体重が分散された状態でキーボードを叩くことができます。

  • 座面の高さ
  • 前, 後ろのリクライニングのON/OFF
  • リクライニングの強さ
  • アームレストの位置(上下・左右・前後)
  • 腰・背中の支え(ポスチャーフィット)の強さ

また、チェア本体は体の大きさに合わせ3サイズが展開され、
車輪や素材、色など数カ所をカスタマイズできます。

3. 長期保証

アーロンチェア(リマスタード)は12年保証。
(製品の材料または製品の瑕疵や、正常な使用において不具合が生じた場合)
仮に12年使うとすれば、新品のアーロンチェアを購入しても1ヶ月2,000円かからない計算ですね。(笑)

4. 芸術的価値

オフィス家具でありながら、さまざまな賞を受賞しているのもアーロンチェアのすごいところ。
芸術品をお尻にしいたら、芸術的なコードが書けそうな予感がしませんか?(いい意味で)

座りに行ってみた

丸の内にあるというハーマンミラー公式ストアにお邪魔してきました。


ハーマンミラーストア Access

1階は主にオフィス用ではないインテリアが並んでいました。ポップなカラーリングが目を引きます。
2階には主にオフィス家具。コンポーネントに分解されたチェアパーツが目を引きます。

故障の際もパーツごとの交換が基本対応のようです。
コンポーネント指向なところもモダンかつプログラミングと相性が良い気がしますね。

撮った写真は上記2枚だけなのですが、さまざまな椅子を試座させていただき、店員さんに細かく説明を受けることができました。
オフィスでの利用を想定し、 常設してあるキーボードやディスプレイアームなどの位置も調整しながら 自分にあった椅子を探すことができました。
サイズの違いもありますので、可能ならば購入前の試座をお勧めします。

注意事項

12年保証は購入店舗経由での対応となり、 ブランドとしての対応ではない そうです。
つまり、例えばAmazonで購入したアーロンチェアに不具合があった場合、丸の内ハーマンミラーストアに連絡しても無償対応が難しいとのこと。
この場合Amazonの保証窓口への問合せになりますね。

保証の内容や対応方法には気をつけましょう。

estieについて

estieでは、 フロントエンドに深い造詣のあるエンジニア を常に募集しています!
アーロンチェアをご用意してお待ちしております!

https://www.wantedly.com/companies/company_6314859/projects

estie -> https://www.estie.jp
estiepro -> https://pro.estie.jp
会社サイト -> https://www.estie.co.jp

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

【Laravel6】Vueがデフォルトでインストールされなくなっていた件【Vue.js】

前提

Laravelは5.3以降、yarn(npm)でインストールコマンドを実行すると
vueがデフォルトでインストールされるようになっていました。

package.jsonから色々消えた

Laravel6ではいくつかのパッケージがデフォルトから除外されたようで、
package.jsonの中身がすっきりしています。

Vue.js

masterのpackage.jsonからvueがいなくなっていました

2019/6/28に消されたようで、
当然、この日付以降に取得したLaravelでは、
yarn installをしてもvueでプロジェクトが作れません。
なので、手動で追加する必要があります。

例(追記参照:非正攻法のようです。)
$yarn add --dev vue vue-router

一方、Laravel5.8を確認してみるとvueは残っており、
更新履歴的に特に消える気配はなさそうなので、Laravel6からの方針と思われます。

他の消えたパッケージ

上記コミットを見てわかる通り、下記パッケージも同様にデフォルトでインストールされなくなりました。

  • bootstrap
  • jquery
  • popper.js

以上。
コミットメッセージを見ても特に消された経緯がわからなかったので、
本件について何か情報をお持ちの方がいらしたらコメントを頂けると幸いです。

追記

既存記事に解決策がありました

Laravel6でBootstrap, jQueryを使う方法

composer require laravel/ui
php artisan ui vue

を使うのが正攻法のようです。

公式記事に解説がありました

https://readouble.com/laravel/6.x/ja/frontend.html

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

Laravel+Vue.js+MySQLで入力内容の途中保存機能を実装してみた

グレンジ Advent Calendar 2019 4日目担当の soyo と申します。
グレンジでクライアントエンジニアをしております。
とはいえ、今年の記事もクライアントとはまったく関係ありません。

普段Googleフォームなどでアンケートを回答する際に、
「あれ、途中で保存することができないの?」って自分はたまに思います。

ユーザーが一項目ずつ入力したらサーバーに送信してデータベースに記録するから、
ページに再度アクセスしたら記録されている情報を自動的に反映するまで、
PHPを使って簡単に実装してみました。

目標

「ラジオボタンの選択内容」と「テキストの入力内容」を途中保存できるようにする
2.png

開発環境

  • macOS 10.14.6
  • PHP 7.3.8
  • Laravel 6.6.0
  • MySQL 8.0.18

フロントエンド

Vue.jsで入力内容の操作

今回の戦場はLaravelプロジェクトのwelcome画面にします。
まずはそこにVue.jsを導入して、ラジオボタン3つとテキストボックス1つを置きます。

resources/views/welcome.blade.php
...
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
...
<div id="app">
    ラジオボタン<br/>
    <input type="radio" value="1" v-model="radio">選択肢1<br/>
    <input type="radio" value="2" v-model="radio">選択肢2<br/>
    <input type="radio" value="3" v-model="radio">選択肢3<br/>
    <br/>
    テキスト入力<br/>
    <input type="text" v-model="text" placeholder="内容を入力">
    <br/>
</div>
...
public/js/main.js
const app = new Vue({
    el: '#app',
    data: {
        radio: '2',
        text: 'あいうえお'
    },
});

これでradiotextでラジオボタンとテキストボックスを操作することができます。
1.png

UUIDの作成と保存

javascriptで適当なUUIDを生成する方法がありまして、
生成したUUIDをJavaScript Cookieでローカルに保存するようにします。

resources/views/welcome.blade.php
...
<script src="https://cdn.jsdelivr.net/npm/js-cookie@beta/dist/js.cookie.min.js"></script>
...
public/js/main.js
const app = new Vue({
    el: '#app',
    data: {
        radio: '',
        text: '',
        uuid: ''
    },
    methods: {
        initUUID: function() {
            if (Cookies.get('uuid') !== undefined) {
                this.uuid = Cookies.get('uuid');
                return;
            }

            var d = new Date().getTime();
            var d2 = (performance && performance.now && (performance.now() * 1000)) || 0;
            this.uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
                var r = Math.random() * 16;
                if (d > 0){
                    r = (d + r) % 16 | 0;
                    d = Math.floor(d / 16);
                } else {
                    r = (d2 + r) % 16 | 0;
                    d2 = Math.floor(d2 / 16);
                }
                return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
            });

            // とりあえず期限を10年にする
            Cookies.set('uuid', this.uuid, { expires: 3650 });
        }
    }
});

app.initUUID();

これで画面を開く度にcookieからuuidを取得し、存在しない場合は生成できるようになりました。

サーバーとの通信

サーバーとの通信はaxiosで行います。

resources/views/welcome.blade.php
...
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js"></script>
...
public/js/main.js
const app = new Vue({
...
    methods: {
        saveData: function(key, value) {
            let postData = {
                'user_id': this.uuid,
                'key': key,
                'value': value
            };

            axios.post("/saveData", postData).then(response => {
                // 成功
            }).catch(error => {
                // 失敗
            });
        },

        loadData: function () {
            let postData = {
                'user_id': this.uuid
            };

            axios.post("/loadData", postData).then(response => {
                // 成功
            }).catch(error => {
                // 失敗
            });
        }
    }
});

送信する内容についてですが、
文字を入力する度に送信してしまうとサーバーに負荷をかける可能性がありますので、
今回は連続する入力を無視してくれるLodashdebounceで制御します。

resources/views/welcome.blade.php
...
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js"></script>
...
<div id="app">
    ラジオボタン<br/>
    <input type="radio" value="1" v-model="radio" @click="isRadioSelecting = true">選択肢1<br/>
    <input type="radio" value="2" v-model="radio" @click="isRadioSelecting = true">選択肢2<br/>
    <input type="radio" value="3" v-model="radio" @click="isRadioSelecting = true">選択肢3<br/>
    <br/>
    テキスト入力<br/>
    <input type="text" v-model="text" @input="isTextTyping = true" placeholder="内容を入力">
    <br/>
</div>
...
public/js/main.js
const app = new Vue({
    el: '#app',
    data: {
        ...
        isTextTyping: false,
        isRadioSelecting: false,
        ...
    },
    watch: {
        radio: _.debounce(function() {
            this.isRadioSelecting = false;
        }, 1000),

        text: _.debounce(function() {
            this.isTextTyping = false;
        }, 2000),

        isRadioSelecting: function(selecting) {
            if (selecting) {
                return;
            }
            this.saveData('radio', this.radio);
        },

        isTextTyping: function(typing) {
            if (typing) {
                return;
            }
            this.saveData('text', this.text);
        },
    },
    ...
});

これでラジオボタンは選択停止後1秒、テキストボックスは入力停止後2秒からサーバーにデータを送るようになりました。

最後に、ステータスをわかるためにvue2-notifyを使ってプッシュ通知を表示させます。

resources/views/welcome.blade.php
...
<script src="https://cdnjs.cloudflare.com/ajax/libs/element-ui/2.4.0/index.js"></script>
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
...

使い方の例

this.$notify.info({
    title: '受信',
    message: '内容読み取り完了'
});

これで、フロントエンドの方は必要な機能を揃えました。
完成したコードはこの記事の最後にまとめております。

サーバーサイド

データベース構造

テストのため、すごくシンプルなテーブルを作ります。

+------------+
| database() |
+------------+
| vue_test   |
+------------+

+------------+
| TABLE_NAME |
+------------+
| user_input |
+------------+

+-------------+-----------+
| COLUMN_NAME | DATA_TYPE |
+-------------+-----------+
| id          | int       |
| user_id     | varchar   |
| radio       | int       |
| text        | varchar   |
+-------------+-----------+

リクエストデータ処理クラス

ユーザー入力内容をデータベースに書き込む・読み取り処理を行います。

app/Http/Controllers/UserInputController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class UserInputController extends Controller
{
    public function saveData(Request $request)
    {
        DB::table('user_input')->updateOrInsert(
            [
                'user_id' => $request->input('user_id')
            ],
            [
                $request->input('key') => $request->input('value')
            ]
        );
    }

    public function loadData(Request $request)
    {
        $user_id = $request->input('user_id');
        $data = [
            'result' => DB::table('user_input')->where('user_id', $user_id)->first()
        ];
        return $data;
    }
}

ルーティング

routes/web.php
...
Route::post('/saveData', 'UserInputController@saveData');
Route::post('/loadData', 'UserInputController@loadData');
...

コードまとめ

resources/views/welcome.blade.php
<!DOCTYPE html>
<html>
    <head>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/js-cookie@beta/dist/js.cookie.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/element-ui/2.4.0/index.js"></script>
        <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
    </head>
    <body>
        <div id="app">
            ラジオボタン<br/>
            <input type="radio" value="1" v-model="radio" @click="isRadioSelecting = true">選択肢1<br/>
            <input type="radio" value="2" v-model="radio" @click="isRadioSelecting = true">選択肢2<br/>
            <input type="radio" value="3" v-model="radio" @click="isRadioSelecting = true">選択肢3<br/>
            <br/>
            テキスト入力<br/>
            <input type="text" v-model="text" @input="isTextTyping = true" placeholder="内容を入力">
            <br/>
        </div>
    </body>
    <script src="{{ asset('/js/main.js') }}"></script>
</html>

public/js/main.js
const app = new Vue({
    el: '#app',
    data: {
        radio: '',
        text: '',
        isTextTyping: false,
        isRadioSelecting: false,

        uuid: ''
    },
    watch: {
        radio: _.debounce(function() {
            this.isRadioSelecting = false;
        }, 1000),

        text: _.debounce(function() {
            this.isTextTyping = false;
        }, 2000),

        isRadioSelecting: function(selecting) {
            if (selecting) {
                return;
            }
            this.saveData('radio', this.radio, 'ラジオボタン');
        },

        isTextTyping: function(typing) {
            if (typing) {
                return;
            }
            this.saveData('text', this.text, 'テキスト入力');
        },
    },
    methods: {
        initUUID: function() {
            if (Cookies.get('uuid') !== undefined) {
                this.uuid = Cookies.get('uuid');
                return;
            }

            var d = new Date().getTime();
            var d2 = (performance && performance.now && (performance.now() * 1000)) || 0;
            this.uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
                var r = Math.random() * 16;
                if (d > 0){
                    r = (d + r) % 16 | 0;
                    d = Math.floor(d / 16);
                } else {
                    r = (d2 + r) % 16 | 0;
                    d2 = Math.floor(d2 / 16);
                }
                return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
            });


            Cookies.set('uuid', this.uuid, { expires: 3650 });
        },

        saveData: function(key, value, description) {
            let postData = {
                'user_id': this.uuid,
                'key': key,
                'value': value
            };
            axios.post("/saveData", postData).then(response => {
                this.$notify.info({
                    title: '送信',
                    message: '内容保存済み:' + description
                });
            }).catch(error => {
                this.$notify.error({
                    title: '送信',
                    message: '送信に失敗しました'
                })
            });
        },

        loadData: function () {
            let postData = {
                'user_id': this.uuid
            };

            axios.post("/loadData", postData).then(response => {
                let data = response.data['result'];
                if (data == null) {
                    this.$notify.info({
                        title: '受信',
                        message: '新規ユーザー'
                    });
                    return;
                }

                this.radio = data['radio'];
                this.text = data['text'];
                this.$notify.info({
                    title: '受信',
                    message: '内容読み取り完了'
                });
            }).catch(error => {
                this.$notify.error({
                    title: '受信',
                    message: '受信に失敗しました'
                })
            });
        }
    }
});

app.initUUID();
app.loadData();
app/Http/Controllers/UserInputController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class UserInputController extends Controller
{
    public function saveData(Request $request)
    {
        DB::table('user_input')->updateOrInsert(
            [
                'user_id' => $request->input('user_id')
            ],
            [
                $request->input('key') => $request->input('value')
            ]
        );
    }

    public function loadData(Request $request)
    {
        $user_id = $request->input('user_id');
        $data = [
            'result' => DB::table('user_input')->where('user_id', $user_id)->first()
        ];
        return $data;
    }
}
routes/web.php
<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Route::post('/saveData', 'UserInputController@saveData');
Route::post('/loadData', 'UserInputController@loadData');

最後に

Vue.jsが使いやすくて、サードパーティのライブラリもたくさんあって、
導入と実装がかなり楽でした(cocos2d-xとunityと比べるとねw)

また、項目を増やす度にテーブルにカラムを追加するのはさすがに面倒ですね。
その場合はテーブルのスキーマをユーザーID、項目ID、内容にして、
select文でユーザーIDと項目IDで検索して、その結果を処理して反映すればいいと思います。

ありがとうございました。

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

フロントエンド開発をjQueryからVue.jsへ乗り換えたので比較してみる

この記事は、Vue #2 Advent Calendar 2019 の3日目の記事です。

Advent Calendar初参加です!

経緯

自社で開発しているサービスの機能が増えてきて、ユーザーの利便性が問われることが多くなりました。
UI改善と言っても、そのサービスのフロントエンドはjQueryで出来ており、一新しようとしてもできる範囲に限界があります。そこで、部分的にVue.jsを導入していき、段階をとってUI改善をしていく方向性となりました。

Vue.jsは他のjavascriptのフレームワークと比べても学習コストが低く、初心者、経験者問わず、すぐに開発に使用することができるという特徴があります。
私も本来はサーバーサイドエンジニアですので、まずは学習から始まったのですが比較的すぐに技術を習得することが出来ました。頑張れば一日で基本の項目(Componentあたり?)まで習得できるかなと思います。

では、jQueryとVue.jsがどう違うか比較してみましょう。

jQueryとVue.jsの違い

jQuery

DOMを直接操作します。セレクタを指定してDOM操作をするので、イベントが起こるたびにページ全体から要素を探します。そのため、要素が増えてくると状態の管理が大変になってしまいます。
コードが直感的なので、知識が全くなくても簡単なアニメーションならばすぐに実装することが出来ます。

Vue.js

仮想DOMで構成されており、データが変わればその部分を認識し、変更された部分のみを変更します。他の変更されていない部分は使い回しされるので、jQueryに比べて表示速度が早いです。
簡単にいえばjavascriptとHTMLの要素を紐付けて、データが変われば勝手に表示に反映してくれます。

比べるとjQueryは要素が変わったらDOMを丸ごと書き換えなければいけないのに対して、Vue.jsはデータの中身だけ変更することができます。

では、実際にコードを書いて比較してみましょう。

実際にコードを書いて比較してみた

Toggle

See the Pen Toggle by nagisa-ito (@nagisa-ito) on CodePen.

この程度の操作ですとコード量に違いはありません。むしろjQueryの方がパッと見で何をしているのかわかる気がします。

inputの値を表示

See the Pen show real-time input by nagisa-ito (@nagisa-ito) on CodePen.

まだそんなに差はありませんが、jsファイルの方はVue.jsのほうがシンプルになってきています。

配列の操作

See the Pen abzoQNL by nagisa-ito (@nagisa-ito) on CodePen.

このあたりから明確な差がでてきます。
jQueryは

  • ボタンをクリックした時の関数
  • 削除ボタンをクリックしたときの関数
  • リストの長さを計算する関数
  • リストを追加するときの関数

の4つの関数が記述されているのに対して、
Vue.jsは

  • リストの長さを計算する関数
  • リストを追加する関数

の二つしか定義されていません。非常にシンプルです。
さらにjQueryは

javascript
$('#list').append('<li>' + name + ' <button class="remove">x</button></li>')

このDOMを直接記述している部分がもやもやポイントですね。

リアルタイム検索

See the Pen real-time search by nagisa-ito (@nagisa-ito) on CodePen.

こちらもjQueryとVue.jsで大きな違いがあります。
jQueryはリストの中身を初期化してDOMを書き換えているのに対して、Vue.jsはリストの操作しかしておりません。

jQueryは、

  1. inputの中身が変更されたのを感知する
  2. inputの中身(検索ワード)を取得する
  3. リストの中から検索ワードを含むものを配列に格納
  4. 検索結果の配列を表示するDOMを作成
  5. リストの表示場所を探す
  6. リストの表示場所に検索結果のDOMを書き換える
  7. 検索結果の件数を表示する場所を探す
  8. 検索結果の件数を書き換える

それに対してVue.jsは

  1. inputの中身が変更されたのを感知する
  2. リストの中から検索ワードを含むものを配列に書き換える(computedの部分)
  3. computedのプロパティが書き換えられたので検索結果とヒット件数の値が自動的に更新される

とかなり動作が省略されます。

まとめ

ここまでjQueyとVue.jsの違いをみてみました。ここまでは基本中の基本ですが、実際に比較してみて

  • jQueryは簡単なDOM操作やアニメーションを実装する時に優位
  • Vue.jsはインタラクティブなページや状態管理が活躍する時に優位

かなと思います。ですので大規模なアプリケーションになればなるほどにはVue.jsが向いていると思われます。
アプリ開発をする際にどのフレームワークを選択するか要件や規模などによって使い分けるのがいいと思います。

しかし、Vue.jsは日本語マニュアルも豊富ですし、学習コストも低いのでとりあえずやってみるのをおすすめします!

今回Vue.jsに触れてみたのはUI改善したいのにフロントエンジニアがいないことがきっかけでしたので、そういう環境に置かれている人は是非勉強して導入してみて欲しいです!  

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

Nuxt.js+TypeScriptのアプリケーションのためのコーディングガイドライン

この記事は食べログ Advent Calendar 2019 3日目の記事です。

はじめまして。食べログのフロントエンドチームに所属している@empitsu88です。

食べログの各種サービスのフロントエンド領域の設計・開発を担当しています。

先日、「食べログ テイクアウト」という新しいサービスをリリースしました

こちらはiOSアプリですが、管理画面側をNuxt.js + TypeScriptで開発しています。
Nuxt.js + TypeScriptをプロダクトコードに使用するのは初めての試みだったので、メンテナブルなアプリケーションにするにはどうしたらいいか、日々模索しています。
今回はその技術を選定するに至った経緯や、チームで運用しているコーディングガイドラインの一部をご紹介します。

技術スタック

  • Nuxt.js
  • TypeScript
  • Class API
  • Sass + scoped CSS
  • Atomic Design

それぞれ採用した理由

Nuxt.js

  • Vue.jsはReactやAngularよりも学習コストが低そうだと感じたため。
  • アプリケーション開発当初はスピード感が求められていたため。
  • 制約やルールが決められているFrameWorkを導入することで、プロダクトの保守性を担保したかったため。
  • 十分枯れた技術であり、あと数年は持ちそうだと感じたため。

まずReactやAngularを差し置いてVue.jsを採用した理由は、フロントエンドを専門としない、バックエンドエンジニアやネイティブアプリエンジニア、デザイナーもコードベースを触る可能性があったためです。
React/Angularと比較するとVue.jsは直感的に触れるため、複数人のエンジニア・デザイナーでスピード感を以って開発を進めていくには適していると考えました。
ただ、Vue.jsはFrameWorkというよりライブラリに近いため、使う側次第でいかようにも使えてしまうのがデメリットでした。
そこで制約やルールが定められているFrameWorkであるNuxt.jsを導入し保守性を担保しました。

また、Nuxt.jsはすでに書籍も複数冊出版され世間での採用事例も多く、少なくともあと3年くらいは使い続けていけそうだと感じたため導入を決定しました。

TypeScript

堅牢さを保つにはもはやTypeScriptの導入は必須と言っても良い時代です。
サーバーサイドエンジニア、アプリエンジニアも型には馴染みがあるため導入には抵抗がありませんでした。
型を守ればユニットテストの工数を減らせるという嬉しい効果もあります。

Vue.jsではTypeScriptの力を最大限発揮できないというデメリットはありますが、書籍「 実践TypeScript 」の「実践編」を参考にVuexの型定義を実装しています。

型定義を書いてからVuexの実装をすれば、あとはエディタが関数名などをサジェストしてくれるのでそれに従って書いていけば済んじゃいます。
控えめに言って最高です。

Sass

食べログではもともとSass(SCSS記法)を使っていたため、CSSは以下の観点で検討しました。

  • Sass(SCSS記法)で書かれた既存の資産を流用できるもの
    • 機械的な置換や変換ができるならOK
  • デザイナーの学習コストが低いもの
    • Sass(SCSS記法)と同じノリで書けると良い

実は、当初はpostCSSの採用を検討していました。
追加のpackageのinstallもほとんど不要ですし、Sassとはsyntaxが少々異なるもののネスト記法(nesting rules)や変数(custom properties )も使えます。

上記の観点を満たしていると思ったのです。

しかし、nesting rulesは&と文字列を連結させたセレクタの指定ができません。

❌以下のような記述は正しくコンパイルされない

.v-heading {
  &--1 {
    font-size: 2rem;
  }
}
.v-btn {
  &#{&}--full {
    width: 100%;
  }
}

postCSSで動的にセレクタを指定できる機能としては :matches pseudo-classcustom selectors もありますが、「親セレクタの名前と任意の文字列を効率的に連結させる」用途としては使えなさそうです。

いずれにしてもpostCSSだとSassのソースを流用するには書き換えのためのコストがかかりそうだったため、素直にSassを導入しました。

Class API

Nuxt TypeScriptのcomponentsのページではOptions API Composition API Class API 3種類のサンプルが紹介されていますが、その中のClass APIを採用しています。

上記ページで言及されているvue-property-decorator のほかにvuex-classも追加でインストールし、出来るだけデコレータを使うようにしています。

どの関数でEmitが使われているかなど見通しが良くなりますし、特にstoreとの接続の処理はデコレータでクラスコンポーネントの最上部にまとめることで、明示的になると考えたためです。

export default class extends Vue {
  @Getter('loading/getIsLoading') isLoading!: boolean

  @Action('loading/setIsLoading') setIsLoading!: (payload: {
    isLoading: boolean
  }) => void
 ...
  @Emit('submit')
  onClickSubmitBtn(): void {
    // do nothing
  }

fetchasyncDataの内部では使えないのが悩ましいですが…。

大事にしているポイント

以上が技術スタックと選定した理由となります。

このアプリケーションで技術的な決定をする際は以下の観点を重要視し、迷ったときは判断軸がぶれないようにしています。

  • 様々な職種の開発者が複数人でメンテナンスしていくため、壊れにくいアプリケーションにできるかどうか。
  • 見た目とロジックを分離できるかどうか。
    • -> デザイナーとの分業をしやすいかどうか。

これは言い換えると、上記の「堅牢さ」や「分業のしやすさ」を優先した結果「開発スピード」「コードの簡潔さ」「最新の技術であること」が多少犠牲になるとしても、それを許容するということになります。

もちろん「大幅に工期が遅れてしまう」、「完全に時代遅れの技術スタックを今から採用する」なんてことは避けたいためある程度バランスはとりたいところですが、「このアプリケーションではどんな観点を重視するか」の共通認識をチームで持っておくことで、迅速に判断ができるようになります。

コーディングガイドラインに定めていること

さて、ここからはコーディングガイドラインに定めている一部の内容をご紹介します。

コンポーネントの命名

ファイル名

.vueファイルを格納するディレクトリ名は、V + コンポーネント名 をパスカルケースで記載します。

components/
  └ atoms/
    └ VBtn/
      └ index.vue

Vue.jsのスタイルガイドの以下の項目を参考にしています。

単一ファイルコンポーネント のファイル名は、すべてパスカルケース (PascalCase) にするか、すべてケバブケース (kebab-case) にするべきです。

引用元:スタイルガイド - 単一ファイルコンポーネントのファイル名の形式

ルートの App コンポーネントや、Vue が提供する <transition><component> のようなビルトインコンポーネントを除き、コンポーネント名は常に複数単語とするべきです。

引用元:スタイルガイド - 複数単語コンポーネント名

Base 、 App 、V などの固有のプレフィックスで始まるべきです。

引用元:スタイルガイド - 基底コンポーネントの名前

親コンポーネントと密結合した子コンポーネントには、親コンポーネントの名前をプレフィックスとして含むべきです。

引用元:スタイルガイド - 結合コンポーネントの名前

テンプレート内でのコンポーネント名の形式

<template> タグの中ではパスカルケースで統一しています。

<template>
  <VBtn></VBtn>
</template>

参考:

パスカルケースには、ケバブケースよりも優れた点がいくつかあります:

引用元:スタイルガイド - テンプレート内でのコンポーネント名の形式

参考にしている語彙

余談ですが、コンポーネント名に使う語彙はVuetifyを参考にしています。
「ボタンはButtonにするか?Btnにするか?」
「ダイアログUIはModalにするか?Dialogにするか?」
などなど命名には迷いがちなのでひとつの指針があると議論の時間を削減できます。

クラスの命名規則

CSSのクラスの命名規則はMindBEMdingを採用しています。
コンポーネント名とMindBEMdingのBlock名は一致させるようにしています。

<ul class="v-menu-list">
  <li class="v-menu-list__item"></li>
</ul>

導入した理由

scoped CSSはローカルスコープを生成してくれますが、子要素のルート要素にはスタイルが適用できるため、被りやすいクラス名や要素セレクタをむやみに使うとバグの原因になります。

もともと食べログではMindBEMDing + FLOCSS を使っておりデザイナーが慣れているため、Nuxt.jsのアプリケーションでも踏襲しています。

コンポーネントはAtomic Designに従って分類する

componentの分類にはAtomic Designを採用しています。

分類 ディレクトリ 説明
atoms components/atoms/ 汎用的に使えるcomponent。
抽象的な機能を提供する。
他のcomponentに依存していなければatoms。
molecules components/molecules/ 複数のAtomsを組み合わせたcomponent。
organisms components/organisms/ 他のAtoms/Molecules/Organismsで構成される。
独立して成立するコンテンツを提供する。
templates components/templates/ pagesの最上位に設置する。同一ページ内で1度しか使えないcomponent。
pages pages/ 最上位のcomponent。

分類の方針は以下の書籍を参考にしています。

参考文献:「Atomic Design ~堅牢で使いやすいUIを効率良く設計する

導入した理由

  • 抽象化されている汎用的なコンポーネントを明確にするため。
    • -> atomsが該当します。
  • どのコンポーネントならstoreに接続してよいか明確にするため。
    • -> 本アプリケーションではpagesのみとしています。

ただし、どこに分類すべきか悩みすぎない=分類に時間をかけすぎないように 気をつけています。

導入した理由から考えると、大事なのは どれがatomsでどれかpagesなのか のみで、悩みがちな moleculesかorganisms どっちすべきか、というのは些末な問題です。

最近では「moleculesを使わない」とか、「Atomic Designをあえて採用せず、『汎用的なコンポーネント』『それ以外』でのみ分ける」などの例も見聞きします。
もしも「コンポーネントをどこに分類すべきか悩むコスト」が今後深刻になってきたら、Atomic Designの使用を再考することも視野に入れています。

storeに接続するのはpages componentのみ

先程の項目で少し触れましたが、Vuexのstoreに接続するのはpagesのコンポーネントのみとしています。

受け取ったデータはpropsとしてtemplates > organisms ...と下位層に流します。

こうすることで、以下のようにコンポーネントの責務を区別することができます。

コンポーネントの種類 責務 storeへの接続
pages APIから取得する動的なデータに関心がある
templates以下 propsとして与えられたデータを表示することに特化する ×

この考え方は書籍「Atomic Design ~堅牢で使いやすいUIを効率良く設計する」を参考にしています。

最上位であるpagesコンポーネントでしかstoreに接続できないというのは、emitで下位層からデータを上位にバケツリレーしなければならない回数が増えるという所謂emit地獄を引き起こすリスクはあります。
ですが、現状のアプリケーションはコンポーネントの階層がそこまで深くないというのと、多少冗長になったとしても「壊れにくさ」を重視したいためこの方針にしています。

子要素のスタイルはなるべく上書きしない

atoms のコンポーネントの内部には、配置を定める関心を分離するためmarginやpositionなどの指定は含めないようにしています。

親側でatomsのコンポーネントを設置しマージンなどを設定する際は、ラッパー要素を使い直接子要素に当てないようにした上でスタイリングします。

⭕OK:

<div class="v-login-content__btn-wrap">
  <VBtn>ログイン</VBtn>
</div>
.v-login-content {
  &__btn-wrap {
    margin: 1.5rem auto 3rem;
  }
}

なぜ子コンポーネントに直接スタイルをあてないようにするのかは次項で詳しく説明します。

子コンポーネントのスタイルを上書きする際は必ず詳細度をあげる

色やpaddingなど子コンポーネント自体のスタイルを書き換えたいときは、セレクタを二重に書くなどして詳細度を上げて対応します。

<VBtn class="v-top-content__btn-logout">ログアウト</VBtn>
.v-top-content {
  &__btn-logout#{&}__btn-logout {
    min-width: 14rem;
    &::before {
      margin-right: 0.5rem;
      vertical-align: middle;
      content: url('~assets/images/common/v_icon_logout.svg');
    }
  }
}

先述した通りscoped CSSは子コンポーネントのルート要素のスタイルの上書きが可能ですが、スタイルの記述順は保証されません。
コンポーネントがレンダリングされる順序によりスタイルの順序が変わるため、
「とある操作をしたときだけデザイン崩れが発生する」
「とある操作ではデザイン崩れは発生しない」
ということが起こりえます。

よってセレクタの記載を工夫して親側のスタイルの詳細度をあげます。
!importantを使うという選択肢もありますが、!importantを使っているスタイル同士の優先付けができないため「まじでどうしようもなかったときの最終手段」としています。

ただ、「子コンポーネントの上書きの際は詳細度がちゃんと上がっているか」を実装者やレビューアーが人力でチェックし続けるのは正直しんどくなってきました。

子コンポーネントのスタイルの上書きを一律禁止にすることも含めて検討しています。

TypeScriptでasでのキャストは極力使わない

tsconfig.json の設定内容は Nuxt TypeScriptのセットアップ>設定 に記載の内容からほとんど変えていません。

よって厳し目の設定にはなりますが、実際の型定義と異なるのに関わらずasを使って型を変換するのはNGとしています。

ただし、as で指定した型と実際の型が一致していることを人間が保証できているならOKです。

⭕OK

await (store as ExStore).dispatch('account/asyncFetchAccount')

APIからGETするJSON、PUT/POSTするJSONにはそれぞれ型を定義する

APIのinterfaceの定義によっては、同じ画面のGET/PUT/POSTの型定義がほとんど同じになる場合があります。

GETではidが存在するが、POSTでは存在しない、程度の差だったり…。

その場合id?を使って定義を共通化したくなりますが、実際には「GETではidは必要」「POSTでは不要」なのが正しい仕様になるため、
仕様と型定義を一致させるためにそれぞれ個別に定義するようにしています。

❌NG

export interface Item {
  id?: number
  name: string
  price: number
}

⭕OK

export interface ItemForSend {
  name: string
  price: number
}

export interface ItemFromFetch {
  id: number
  name: string
  price: number
}

delete演算子でオブジェクトのkeyは削除しない

delete演算子は型推論が正常に働かなくなるので使用しないようにしています。

keyを削除したいときは分割代入+スプレッド演算子を使っています。

eslintでno-unused-varsの警告が出てしまいますが、disableを使うのは許容しています。

❌NG

delete item._id

⭕OK

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, ...restItem } = item

子コンポーネントの内部では$routeを参照しない

templates以下の層であるcomponents/配下のコンポーネントは、独立して存在できることが望ましいです。
templates以下のコンポーネント内部で$routeを参照すると、Vue Routerに依存したつくりになってしまいます。

また、そのままだとstorybook上でエラーを吐いてしまうため、storyファイル上でコンポーネントにVue Routerのインスタンスを渡してやるなどの対応が必要になります。

❌NG

  • components/molecules/VMenuItem/index.vue
get isActive(){
 return this.$route.params.id === this.id
}

⭕OK

  • pageコンポーネントでのみ$routeを参照し、propsで子コンポーネントに渡す。
  • <nuxt-link>にactive時のスタイルを当てたいときはnuxt-link-activeクラスや、active-class属性を活用する。

storybookは必ず更新する

storybookは以下の理由で導入しています。

  • アプリケーションで使えるコンポーネントをカタログ化してUIデザイナーが参照しやすくするため。
  • propsごとに異なる見た目の動作確認を簡便にするため。

メリットを最大限活かすには常にstoryファイルを最新に保つことが重要です。
更新忘れを防ぐため、.vueファイルを修正するとPullRequestにstorybookのURLが自動でコメントされる仕組みを入れています。
そのおかげで、propsの追加などstorybook にも反映すべき修正をしたときは実装者もレビュアーも気付きやすくなっています。

以下の記事は大いに参考にさせていただきました。

参考:PRごとにCIでStorybookをビルドしてデザイナーとインタラクションまで作っていく話

アクセス修飾子は必ず記載する

コンポーネントに生やしたメソッドや変数を外から参照する、なんてことは普通しないため、付ける/付けないで動作に影響はないのですが、可読性のために付与しています。

public template から参照されるメソッド・data
private コンポーネントのメソッドからのみ呼ばれるメソッド
public localValue = 0

public get computedValue():number {
  ...
}

private calculate(value: number): number {
  ...
}

静的なclass属性と:class属性は分ける

アプリケーションのコードベースはフロントエンドエンジニアだけでなくデザイナーも触ります。

フロントエンドエンジニアでコンポーネントをざっくり実装→DOM構造やCSSをデザイナーに調整してもらう というフローで開発することが多いため、見た目とロジックはできるだけ分離するようにしています。

class属性も「純粋なスタイリングのためだけのclass」と「動的なclass」をできるだけ別の属性に分けて書くようにしています。

⭕OK:

<li
 class="v-menu__item"
 :class="{ 'is-active': isItemActive }"
>

❌NG:

<li :class="[
  'v-menu__item',
  { 'is-active': isItemActive }
]"
>

classと:classが適切にマージされるというのは、公式ドキュメントのクラスとスタイルのバインディングのページにも載っています。

template内での直接の値(data)の書き換えはしない

見た目とロジックを分離するという観点で、template内にロジックを書くのはできるだけ控えます。
特に、dataを直接書き換える処理はMUSTで避けています。
副作用を生む処理がどこで行われているのかわかりやすくするためです。

❌NG:

<button @click="isOpen = !isOpen">btn</button>

⭕OK:

<button @click="togglePanel">btn</button>
public togglePanel(): void {
  this.isOpen = !this.isOpen
}

routerインスタンスを使ったナビゲーションの制御時はnameで指定する

path指定だと末尾のスラッシュありなしに揺れが生じる恐れがあるため、名前付きルートで指定しています。

❌NG:

// 文字列パス
router.push('/')
router.push({ path: 'home' })
<nuxt-link :to="/">Index</nuxt-link>

⭕OK:

// 名前付きルート
router.push({ name: 'index' })
router.push({ name: 'user', params: { userId: '123' } })
<nuxt-link :to="{ name: 'user', params: { userId: 123 }}">User</nuxt-link>

まとめ

一部ではありますが、以上が「食べログ テイクアウト」のアプリケーションで運用しているコーディングガイドラインのご紹介になります。

技術選定にせよ、コーディングガイドラインの策定にせよ、「このアプリケーションで重要視する判断軸はなにか」を明確にしておくとスムーズに判断できるということを実感しています。

よりよいアプリケーションにしていくため、コーディングガイドラインの内容はブラッシュアップし続けていきたいと考えています。

明日は、@itume さんの「そうだ Flutter、やろう。」です。よろしくおねがいします!

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

Vuejsのcomputedでsliceを使う方法

はじめに

メモ程度に置いときます。

成功したコード

home.vue
<template>
 <div v-for="group in limitedGroups" :key="groups.id">
  <h1>{{ group.name }}</h1>
 </div>
</template>
<script>
export default {
// めんどくさいんでAPI叩いてるところは省略します
 computed: {
  limitedGroups() {
   return this.groups.slice(0, 2)
  }
 }
}

失敗したコード

home.vue
<template>
 <div v-for="group in limitedGroups" :key="groups.id">
  <h1>{{ group.name }}</h1>
 </div>
</template>
<script>
export default {
// めんどくさいんでAPI叩いてるところは省略します
 computed: {
  limitedGroups: function() {
   return this.groups.slice(0, 2)
  }
 }
}

まとめ

特になし!
誰かの参考になればいいな。

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

GitHub Pagesをビジュアルリグレッションテストをしてみた

モチベーション

こんにちは@glassmonkeyです。
Github ActionといえばCI。
せっかくCIするなら画像使ったものをやってみたかったので、簡単にビジュアルリグレッションテストをやってみました。
突貫で作ったのでご意見ご感想あればありがたいです。

ビジュアルリグレッションテストとは

アスタミューゼ様のブログでの説明がわかりやすかったので引用させていただきます。

ビジュアルリグレッションテストとは視覚的な回帰テストのことで、具体的にはスクリーンショットを撮影して差分抽出して行うテストです。

つまりGithub Actionの環境でビジュアルリグレッションテストを行うことは
仮想環境上で以下の画像を生成することになります。
* 変更前と変更後のスクリーンショット
* 変更前と変更後の差分画像

この記事では
* クロール及びスクリーンショットはSelenium + Headless Chrome
* 差分画像生成(画像処理)には OpenCV
を扱いました。

どのようにしたか

サンプル

実際作ってみたものが下記にあたります。
https://github.com/glassmonkey/vue-sample/pull/3
私のVuejsの勉強のために作ったアプリケーションに追加してみました。

今回は下記のように変更点がわかりやすく矩形で囲ってみました。

元画像 変更後画像 差分画像
元画像 変更画像 差分画像

要件

https://github.com/glassmonkey/vue-sample/blob/master/.github/workflows/test.yml#L19-L20

BASE_URL: https://glassmonkey.github.io/vue-sample

DIFF_URL: http://localhost:8080

テストの内容

テストの流れ

テスト用のymlは下記になります。
https://github.com/glassmonkey/vue-sample/blob/master/.github/workflows/test.yml

name: test

on: pull_request
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1 # ①
      - name: develop run # ②
        run: |
          docker-compose up -d
      - name: run test # ③
        run: |
          cd tests && \
          docker-compose build && \
          docker-compose run app \
        env:
          WINDOW_SIZE: 1024,768
          BASE_URL: https://glassmonkey.github.io/vue-sample/
          DIFF_URL: http://localhost:8080
      - uses: jakejarvis/s3-sync-action@master # ④
        env:
          AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: 'ap-northeast-1'
          SOURCE_DIR: './tests/dist'
          DEST_DIR:  ${{github.repository}}/${{github.sha}}
      - name: post maessage # ⑤
        run: |
          cd tests && bash post.sh
        env:
          S3_PATH: https://${{ secrets.AWS_S3_BUCKET }}.s3-ap-northeast-1.amazonaws.com/${{github.repository}}/${{github.sha}}
          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

認証情報はGithub上settings > secretsから下記の通り設定させる必要はあります。
スクリーンショット 2019-12-02 12.30.20.png

簡単に解説すると

  1. 初期化
  2. PRの内容で仮想環境を構築
  3. ビジュアルリグレッションテスト用コンテナの構築及び実行
  4. 生成した画像をs3にアップロード
  5. PRに画像をポスト

の流れになります。3の画像生成と5のPRにポストの内容に関して解説します。

画像生成

スクーンショットの撮影および、差分画像生成は下記で行っています。
https://github.com/glassmonkey/vue-sample/blob/master/tests/src/main.py

今回テスト用に実行するコンテナはローカルで構築したアプリとは独立させたので、localhostの名前解決ができないので下記で無理やりテスト側で書き換えました。

    if "localhost" in url: 
         host_addr = subprocess.run(["ip route | awk 'NR==1 {print $3}'"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True).stdout.decode("utf8").rstrip('\n')
         url = url.replace("localhost", host_addr)

スクリーンショットについて

Docker上でSeleniumとHeadless ChromeとPython3を動かすの記事を参考にさせていただきました
特筆すべき点としては下記のオプション項目は調整必須な点です。
特にヘッドレスクロームで起動する場合は起動字のタイミングでウィンドウサイズ指定が必須な点は少しハマりました。

   options.add_argument('--headless')
   options.add_argument('--no-sandbox')
   options.add_argument('--disable-dev-shm-usage')
   options.add_argument('--hide-scrollbars')
   options.add_argument('--window-size={}'.format(os.environ['WINDOW_SIZE']))

今回は下記のようにURLを静的にスクリーンショットにしましたが、スクリーンショット対象のDom要素の指定などもできるようなので外部からカスタマイズできるようにしてもいいのかなとは思いました。

    driver.get(url)
    driver.save_screenshot(filename)

差分画像生成について

こちらの記事を参考にさせていただきました。 下記の関数でスクリーンショット画像の差分画像を生成するようにしています。

def diff_images(base_image_path, diff_image_path):
    """
    referer to https://www.pyimagesearch.com/2017/06/19/image-difference-with-opencv-and-python/
    :param base_image_path:
    :param diff_image_path:
    :return:
    """
    # load the two input images
    base_image = cv2.imread(base_image_path)
    diff_image = cv2.imread(diff_image_path)

    # convert the images to grayscale
    grayA = cv2.cvtColor(base_image, cv2.COLOR_BGR2GRAY)
    grayB = cv2.cvtColor(diff_image, cv2.COLOR_BGR2GRAY)

    (score, sub) = compare_ssim(grayA, grayB, full=True)
    sub = (sub * 255).astype("uint8")
    print("SSIM: {}".format(score))
    thresh = cv2.threshold(sub, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
    cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    cnts = imutils.grab_contours(cnts)

    for c in cnts:
        # compute the bounding box of the contour and then draw the
        # bounding box on both input images to represent where the two
        # images differ
        (x, y, w, h) = cv2.boundingRect(c)
        cv2.rectangle(base_image, (x, y), (x + w, y + h), (0, 0, 255), 2)
        cv2.rectangle(diff_image, (x, y), (x + w, y + h), (0, 0, 255), 2)
    cv2.imwrite("/app/dist/base.png", base_image)
    cv2.imwrite("/app/dist/diff.png", diff_image)
    cv2.imwrite("/app/dist/sub.png", sub)

PRへPOST

https://github.com/glassmonkey/vue-sample/blob/master/tests/post.sh

s3にアップロードするアクションを用いてs3にアップした画像をGithubのApi経由でアップさせた画像をPostしています。
処理にはjessfraz/shaking-finger-actionを参考にしました。

※ 今回使用したバケットの設定は適当に作ったので割愛とさせていただきます。
コミットの度に画像を再生成する仕様なのでコミットのSHAでディレクトリ別にはしています。

結果

このPRに無事に投下されました。
https://github.com/glassmonkey/vue-sample/pull/3
スクリーンショット 2019-12-02 12.49.26.png

まとめ

簡単なビジュアルリグレッションテストをおこなうことができた。
せっかかくなのでちゃんとactionとして別リポジトリにしてもいいかもしれない。
まともにGithubのApiを触ったことがなかったので画像のPostをどうしようかすごい悩んだ。
今回はs3にしましたが、別のアプローチもありかもしれない。
Opencvのインストールが煩雑になるので断念したんですが、遅いのでテスト用コンテナをalpineから諦めましたがalpineにしたいですね。

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

サンプルアプリをいじりながらVue.js+Electronを学ぼう! 環境構築編

はじめに

私は「昔のJavascript」しか触った事のないJavaプログラマーです。
Windowsデスクトップアプリを作るのに、Adobe Airをよく使っていましたが、Javascriptも試してみようと思い、勉強してみる事にしました。
手を動かさないと続かない人なので、サンプルアプリをベースに、プログラムを少しずついじりながら勉強していくつもりです。
同じような境遇の方を対象読者とし、学んだ事を記事にしていきます。
まずは環境の構築とサンプルアプリを動かしてみるところから始めましょう。

環境

  • Windows10
  • VS Code
  • TypeScript

前提

本記事では、vue-cli3と「Vue Cli 3 plugin for Electron」を使用します。
vue-cli2系の頃は、electron-vueがよく使われていたようですが、vue-cli3系には対応していないようですので、ご注意ください。

環境構築手順

まずはアプリが動く実行環境と、サンプルアプリケーションをインストールして動かしてみます。

Vue Cli 3 plugin for Electronのインストール

npmを使いますので、インストールしていない人はインストールしてください。

コマンドプロンプトを立ち上げ、↓の手順でコマンドを打っていきます。

Vue CLI 3のインストール

>npm install -g @vue/cli

プロジェクト作成

「next」という名前のプロジェクトを作成します。簡単なVueアプリケーションが作成されます。
下記コマンドを叩くと、最初に色々聞かれるので、各自好みや環境に応じて回答してください。
この記事ではTypeScriptとRouterは使いますので、選択しておきましょう。
私が選択した内容も載せておきます。

>vue create next


Vue CLI v3.9.1
┌────────────────────────────┐
│  Update available: 3.11.0  │
└────────────────────────────┘
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, Router, Vuex, CSS Pre-processors, Linter
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) No
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with node-sass)
? Pick a linter / formatter config: TSLint
? Pick additional lint features: Lint on save, Lint and fix on commit (requires Git)
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In package.json
? Save this as a preset for future projects? No

electron-builderのインストール

Vueのアプリケーションにelectronを追加し、デスクトップアプリとして動作するようにします。

>cd next
>vue add electron-builder

サンプルアプリを起動する

上記までの手順で、既にサンプルアプリがインストールされ、動かせる状態になっています。
下記のコマンドでサンプルアプリが起動します。

>npm run electron:serve

コンソールに、いくつかコンパイルの警告が出ますが、無事アプリが起動します。

image.png

開発環境を構築する

プログラムを編集するエディタ等の環境を作り、開発する準備を整えます。

VS Codeをインストール

VS Codeをインストールし、ソースコードを編集する環境を整えます。
まずは下記でインストーラーをダウンロードし、VS Codeをインストールしてください。
https://code.visualstudio.com/

サンプルアプリプロジェクトを開く

インストールしたVS Codeを立ち上げます。
左上の"Explorer"アイコンをクリックすると、"Open Folder"ボタンが表示されるので、これを押してください。
image.png

vue createで作成したサンプルアプリのプロジェクトフォルダ(next)を指定します。
srcフォルダにソースコードが入っていますので、ひとまず「App.vue」を開いてみましょう。
image.png

Veturのインストール

".vue"ファイルを開く事で、拡張機能"Vetur"のインストールをレコメンドしてくれると思いますので、このままインストールします。
image.png
image.png

シンタックスハイライトされるようになって、いい感じですね。
image.png

拡張機能npmのインストール

Ctrl + Shift + Xで拡張機能の検索バーを出し、npmと入力します。
同名の拡張機能がいくつかhitしますが、下記を選択してインストールしましょう。
image.png

VS Codeからサンプルアプリを起動

左下の「NPM SCRIPTS」でelectron:serveを起動します。
npm run electron:serveを叩いた時と同じく、アプリケーションが起動します。
image.png

これで準備が整いました。
次回の記事から、実際にこのサンプルアプリを改修しながら、Vue.jsとElectronを学習していきます。

参考になったサイト

Vue CLI 3 で electron-vue の環境を構築する方法
Electron+Vue.jsを使ったデスクトップアプリ開発を始める手順

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