20200315のvue.jsに関する記事は12件です。

vue cliでmixinを作成し、インポートする方法

備忘録です。

したいこと

・vue cliでmixinを作成し、componentファイルで使いたい

同じ動作を複数箇所で使用したい場合は、mixinが便利です。SCSS/SASSで使うmixinと同じ役割です。

Step 1: mixinはjsファイルに書く

mixinはvueファイルではなく、jsファイルに書きます。ここではmyFirstMixin.jsという名前のファイルを作りました。

補足:src のフォルダー内に mixinフォルダー を作成し、その中に mixinを書いたjsファイルたち を保存しておくと管理しやすそうです。

書き方はVueファイルで作ったコンポーネントの、scriptタグの中身と一緒です。ここではcomputedを使ったmixinを書いています。mixinしたい内容を書きます。

myFirstMixin.jsファイル内

export default {
    computed: {
        filteredBlogs: function(){
            return this.blogs.filter((blog) => {
                return blog.title.match(this.search);
            })
        }
    }
}

Step 2: コンポーネントファイルで読み込む

component.vueファイル内

<script>
import myFirstMixin from '../mixins/myFirstMixin';//ここでインポートする

export default {
  data() {
    return {
        //
    }
  },
  methods: {
  //
  },
mixins: [
    myFirstMixin
]
}//ここで登録する
</script>

これで使えるようになりました:grin:
初心者なので、何か誤解・勘違いがあれば、ご指摘いただけると幸いです:bow:

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

vue cliのプロジェクトにaxiosを読み込む方法

備忘録です。

したいこと

・vue cliのプロジェクトでHTTP Requestを行いたい

方法

vue.jsにはHTTP Requestをする機能が備わっていないので、HTTP RequestができるようJavascriptのライブラリを読み込む必要があります。簡単にHTTP Requestを行えることからaxiosが多くの人に好まれているようです。

まずはaxiosをプロジェクトにインストールします。

ステップ1

プロジェクト内のターミナルからaxiosをインストール?
(npmはすでにインストール済みと仮定します)
補足:Visual Studio Codeを使用している場合、プロジェクトファイルを開いた状態で表示 ? ターミナルに行くと、ターミナルが開けます。私の場合は、vuejsというファイルがプロジェクトファイルなので、そのなかにaxiosをインストールします。

USER-no-MacBook-Air-2:vuejs user$ npm install axios

数秒待つとインストールされます。

ステップ2

使用するためにはインストールするだけでなく、インポートする必要があります。プロジェクト内で使えるようにするにはmain.js内にインポートします。

main.jsファイル内
import Axios from 'axios'
Vue.use(Axios)

ステップ3

さらにcomponentsのなか、あるいはrootとなるapp.vueなどで使う場合は、そこにもインポートする必要があります。

app.vue内など、HTTP Requestを行いたいvueファイルの中
<script>
import axios from 'axios';//ここで読み込む

export default {
  data() {
    return {
        //
    } 
  },
 methods: {
        //
    }
    }

 }
</script>

これで使える状態になります。

補足: .getして情報を画面に表示させる

ここではJSON Placeholderというダミーテキストを返してくれるAPIサーバーを例に説明します。ここではJSON Placeholderの/postsを使います。

たとえば#showBlogsというdivのなかにある、<h2></h2> には JSON Placeholder/postsのtitle を、<article></article>には JSON Placeholder/postsのbody を表示したい場合。

.getメソッドで情報を取得しなければいけません。そのためにはcreated(){}export default内に使います。

<template>
  <div id="showBlogs">
      <h1>Blog Articles</h1>
      <div v-for="(blog, idx) in blogs" v-bind:key="idx" class="single-blog">
          <h2>{{ blog.title }}</h2>//ここで情報を表示する
          <article>{{ blog.body }}</article>//ここで情報を表示する
      </div>
  </div>
</template>

<script>
import axios from 'axios';

export default {
  data() {
    return {
        blogs:[]
    }
  },
  methods: {
  //
  },
  created(){
      axios.get('https://jsonplaceholder.typicode.com/posts').then(response => {
          this.blogs = response.data
      })//ここで情報をくれ〜〜〜というリクエストをする
}
}
</script>

このようにして私は無事画面に情報を表示することができました。とはいえ初心者なので何かあればご指摘いただけると幸いです?‍♀️

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

Webpack設定

Webpack設定

webpack.config.js
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin')

module.exports = {
  mode: 'development',
  entry: ['@babel/polyfill', './src/index.js'],
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  },
  devtool: 'inline-source-map',
  devServer: {
    contentBase: './dist'
  },
  module: {
    rules: [
      {
        enforce: 'pre',
        test: /\.(js|vue)$/,
        loader: 'eslint-loader',
        exclude: /node_modules/,
        options: {
          formatter: require('eslint/lib/formatters/stylish'),
          fix: true
        }
      },{
        test: /\.vue$/,
        loader: 'vue-loader'
      },{
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            preset: ['@babel/preset-env']
          }
        }
      },{
        test: /\.(css|scss|sass)$/,
        use: [
          'vue-style-loader',
          'style-loader',
          'css-loader',
          {
            loader: 'sass-loader',
            options: {
              implementation: require('sass'),
              fiber: require('fibers')
            }
          }
        ]
      },{
        test: /\.(png|svg|jqg|gif|eot|ttf|woff|woff2)$/,
        use: [{
           loader: 'file-loader',
           options: {
             name: './font/[hash].[ext]'
           }
        }]
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ],
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js'
    }
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

COTOHA APIだけでおじさんになろう

はじめに

  • 読みやすさ重視のため、本文におじさん構文は登場しません。期待された方には深くお詫び申し上げます。
  • また、デモサイトやバックエンドに関することは後日別記事にしようと思います。
  • 前提知識 => JavaScriptのみ(!)
  • 記事中に登場するコードは、axiosとfsが動くnode環境ならコピペで実行可能です。COTOHAの気軽さをお試しください。
  • 筆者は趣味でコードを書く大学生です。動けばいいやの精神が強いのですが、ITの世界に進むならこれじゃあかんやろと思っているので、コードにとどまらずいろいろご指摘くだされば幸いです。

おじさん:wink:と一緒:kissing_heart:に考えよう:thinking:

普通の文章を入れたらその内容がおじさん構文になったらおもしろくね?
ということでCOTOHA APIのみを使ってデモページを作ってみました。

注意:thinking:したこと・開発動機:triumph:

COTOHA APIの記事はキャンペーンの影響で数多作成されており、この記事もその例外ではありません。しかし、「COTOHA API だけで言語を処理する」記事はあまり見当たりませんでした。僕自身が自然言語処理に全く知識がなく、また、pythonを使うことができない(jsで書いています)ため、他のライブラリを使うことができませんでした。なにより僕の頭の中では

そもそもCOTOHA APIって、僕みたいになにも知らなくても簡単気楽に実装できるところがいいんじゃないの?

という気持ちが強くありました。そういうわけで、この記事ではJavaScriptさえ読めれば誰でもわかることを目指し、自然言語処理に関わることはCOTOHA API以外のライブラリ、APIなどは使わずに実装しました。読者対象は「なんとなく興味を持っているけれど」の層です。難しい話はなしです。他のライブラリと合わせることでより真価を引き出し素晴らしいプロダクトを開発されている方は他にたくさんいらっしゃいますので、この記事で興味を持たれた方はぜひそちらもご覧ください。
開発動機は純粋にプレゼントほしいぃという気持ちもありつつ、最近僕の中で話題だった、おじさん構文を作れるかもしれない!とリファレンスを読みながら思ったからです。プロダクトは未完成ですが、雰囲気を楽しんでいただけたらなと思います。
3/15 22時追記 ほぼ完成しました。

デモサイト

https://storage.googleapis.com/vue-oji-cotoha/index.html

使い方

  1. COTOHA APIの利用登録を済ませ、Client ID とClient secretを取得する
  2. デモページにてIDとsecretを入力し、「Access Tokenを取得」を押下
  3. 「アクセストークンの取得に成功しました!」が表示されたら、おじさん構文にしたい文書を入力して「おじさん構文化」を押下
  4. :heart_eyes:おじさん:heart_eyes:

構成

最初はウェブページ上にすべての機能を実装してやろうと考えていたのですが、Cross-Origin Resource Sharing (CORS)に引っかかるじゃん!ということで、いったんGoogle Cloud Platformのfunctionに情報を渡して結果をフロントに表示する構成にしています。 このせいでClient IDやsecretをHTTP通信してますやべー

おじさん構文化の仕組み

本記事のメインです。

そもそもおじさん構文って?

実際に存在しているおじさん構文を見たことがないため、Twitterなどで見かける「私たちがおじさん構文と認識するもの」をおじさん構文として定義し、これを再現することを目標とします。
高校の友達に、いま某外国語学科に所属している変態言語オタクがいたので、「おじさん構文を自動作成しようと思うんだけど、定式化できないかな」と相談したところ、以下のレポートを頂戴しました。言語系に進んでいらっしゃる方は、筆者デアショコがフィードバックが欲しいということでしたので、コメント欄にてよろしくお願いします。

「おじさん構文解析」全文展開

題「おじさん構文解析」
著者 デアショコ

1,序論

何が「おじさん構文」を「おじさん構文」たらしめているかについて、実例を見ながら分析していき、一定のルールを見つけ出す。その上で、「適当な文をおじさん構文に変換する」という今回の目的のため、日常に使う適当な文を1、2文例に取り、違和感の無いおじさん構文化することを本稿の目的とする。

2,具体例とそこに共通して認められるルールについて

具体例1

Aチャン❗️オハヨ?✨✨
天本のお寿司?、おいしかったネ??

そうそう❗️❗️昨日も話したけど、Aちゃんと今度ゴルフ行きたいな❗️❗️⛳?クルマはおじさんのアウディ?✨で行こうね??

あとあと❗️❗️今月のハワイ旅行✈✨だけど、25日(月)に出て、30日(土)に戻ってくるで大丈夫かな❓❓?

海にも入るから水着も忘れないでね??オジサン、ウルフギャングも予約しちゃいまーす❗️?Aちゃんは来てくれるだけでOK牧場だよ❗️❗️??
(笑)よろしくね❗️❗️?✨

具体例2

ベイビーちゃんおはよう☀
昨日マック?食べたいけど、ダイエット中だから我慢?して、おにぎり?にしたよ~❗️❗️褒めて~?✨

でも、その後おにぎり?食べたこと忘れて、ビッグマック?のLセット食べちゃった?
アンビリーバブルだよ~~?ベイビーちゃんに会えないから。ボケちゃったのかも❗️❓
来週一緒にマック?行こうね?クーポン券?あるから、ポテトL奢ってあげるよ✌
聖母マリアのような優しい笑み?が恋しくて、毎日恋ダンス踊ってるよ?
世界で一番愛してる。
ギュッ?

その1:最初の挨拶

呼びかけから入るが、その際、相手を「ちゃん付け」する傾向が認められる。名前にちゃん付けするか、そのままあだ名的に呼ぶなどの揺れは認められる。テンプレ化を目指すのならば、「ベイビーちゃん」に統一するのも一つの手と思われる。

その2:文末

 文末に付く「!」は、基本的に二つ付く傾向が認められる。これは具体例1に特に顕著である。具体例2においても、語尾の「!」が二つ付いているのが散見される。また、文章の内容や発話者の感情に応じて、顔文字が使い分けられているのが確認できる。これは「おじさん構文」に限らず普通の発話においても見られる特徴だが、その顔文字がほぼ全文にあり、多用されているという点が「おじさん構文」の特徴と考えられる。具体例2に見られる面白い特徴として挙げられるのが、文末では無く、名詞の後に顔文字が使用されている点である。具体例2の「~ダイエット中だから我慢?して、~」がその例である。これまで、普通名詞の後にその名詞を表す絵文字が使用されるのは指摘されてきた。今回の例は、普通名詞というよりは、発話者の感情を表すものであると言える。我慢をすることに対する発話者のネガティブな感情が顔文字?に現れている。単なる名詞の具現化だけでなく、聞いてもいない感情を自ら表しにきているところが、「おじさん構文」らしさの1つと、ここから言える。無論、感情を顔文字の使用により表すのはごく自然なことだが、
・その多用
・名詞の直後に持ってくることによるその感情の強調
が、普通の文章と「おじさん構文」の違いだと考えることができる。

その3:✨と?の使用の区別の仕方

 相手に対して誘いや依頼をする際は、?が使用されることが多いと考えられる。これは、お願いする相手(おそらく女性)に対する好意を明確に示すものだと思われる。
・具体例1:クルマはおじさんのアウディ?✨で行こうね??(勧誘)
・具体例2:来週一緒にマック?行こうね?(勧誘)
・具体例1:海にも入るから水着も忘れないでね??(依頼)

対して、✨は、単純な感情の高まりとそれによる事柄の強調、もしくは、自分自身のことを強調する際に使用されると思われる。
 ・具体例1:今月のハワイ旅行✈✨だけど、(強調)
 ・具体例1:よろしくね❗️❗️?✨(強調的)
 ・具体例2:褒めて~?✨(自身の強調)

3. 以上のルールを踏まえた上での、「おじさん構文」化

例1

「コーディングが楽しくてやめられないけど、ひたすら眠くて仕方ないジレンマ」
→「コーディング(?)が楽しくて?やめられない?けど、ひたすら眠くて?仕方ないジレンマ??」

解説

本来はコーディングの後に何かしらコーディングを表す絵文字が欲しかったが見つけることができなかったため割愛。今回は「楽しくて」「やめられない」「眠くて」「ジレンマ」等、発話者の感情を表す単語が多い。ポイントは「やめられない」と「ジレンマ」の箇所である。今回の「やめられない」は、直前の「楽しくて」から推測されるようにネガティブな意味では無いと思われるため、泣き笑いの表情を選択した。「ジレンマ」の箇所については、ジレンマは、『眠ることができず、困っている』という意味で使用されていることが明白なため、その『困っている』発話者の感情を表現するため、汗をかいている顔文字と、汗の絵文字を使用した。

例2

「図書館でいろんな本を借りてたけど、これだけはどうしても欲しくなって買ってしまいました。これ一冊は必ず仕上げようと思います!」
→「図書館?でいろんな本?を借りてた✨けど、これだけはどうしても?欲しくなって?買ってしまいました?✨。これ一冊?は必ず仕上げようと思います❗️ ❗️」

解説

 今回は「本」というわかりやすい普通名詞の登場で、絵文字を置きやすくなった。「借りてた」の箇所に関しては、『いろんな本を借りる』という文言を『自分の行為の強調』と捉えたため、✨を置いた。「どうしても」の箇所は賛否あるとは思うが、『他は買わないがこれだけは買う』という意思を表すため含みのある?を採用した。

4. まとめ

 今回、様々な文章に対応しうる「おじさん構文」のルールをある程度見つけ出すことを目的に、「おじさん構文」の分析を行い、通常の文章を違和感のないおじさん構文に変換することを試みた。選択する顔文字や付け加えた絵文字に関してはまだまだ賛否や議論の余地があると思うので、その洗練は今後の課題としたい。

5.出典、資料

https://neetola.com/ojisan/
具体例として扱わせていただいた
https://twitter.com/gyozaisgood/status/1236355783090008064?s=21
https://twitter.com/gyozaisgood/status/1232326974636359680?s=21
「おじさん構文」化の例に使用させていただいた。

資料:今回使用した顔文字一覧
???????????

普通名詞の絵文字に関しては省略した。


展開した文章はここまで、以下本文


要点は

  • 名前をチャン呼びする
  • 全文に顔文字
  • 自身の感情を絵文字で表現

この三つになりそうです。ここから、文章への加工内容を考えると以下のようになりました。

  • 文末にその文に対応した顔文字の付与
  • 各名詞や特徴的な表現の後ろに、対応するEmojiの付与
  • 人名が出た際、「チャン」付けする

この加工を以下で行っていきます。

1,渡された文章を文にする

ユーザーは文だけでなく、長い文章などを入力することが考えられます。この場合、文の文末に付与する絵文字が名詞の後ろに付与するものを除けば一つだけになってしまい、おじさん感がありません。そのため、与えられた文章を文に区切る必要があります。
文の区切りを判定するために、今回は「終助詞」を選びました。他にも適切なものがあればコメント欄で教えていただきたいです。この開発ではCOTOHA APIに依存することをテーマにしているので、終助詞判定も当然COTOHA APIにしてもらいます。この判定には「構文解析」APIを利用します。

index.js
/*コピペ前にすること
npm install fs axios
*/
const axios = require('axios')
const fs = require('fs')
class Cotoha{
  constructor(sentence,cotoha_token){
    this.sentence = sentence
    this.cotoha_token = cotoha_token
  }
  client(){
    const axiosConfig = axios.create({
      headers:{
        "Authorization": `Bearer ${this.cotoha_token}`,
        "Content-Type": "application/json"
      },
      baseURL:"https://api.ce-cotoha.com/api/dev/nlp/v1",
    });
    return axiosConfig;
  }
  async parse(){
    const axiosBase = await this.client();
    try{
      const res = await axiosBase.post("/parse",{"sentence":this.sentence})//parseが構文解析のリクエスト
      const result = res.data.result;
      return result
    }catch(e){
      console.log(e)
    }
  }
}
//cotoha_token(Access token)はデモサイトにて取得できます。Client IDとClient Secretを入力して
//ボタン押下してから、サイト下部のLogをご覧ください
const main = async () => {
  const inputMsg = "今日のランチはハンバーガーだった、優美ちゃんはなんだった?"
  const cotoha_token = "hogehogehogehoge"
  const cotoha = new Cotoha(inputMsg, cotoha_token)
  const outputMsg = await cotoha.parse()
  fs.writeFile("./parse.json",JSON.stringify(outputMsg,null,"\t"),()=>{console.log("fs end")})
}
main()
/*実行コマンド
node .
*/

レスポンスは以下のようになっています。

parse.json※長い!!
parse.json
[
    {
        "chunk_info": {
            "id": 0,
            "head": 1,
            "dep": "D",
            "chunk_head": 0,
            "chunk_func": 1,
            "links": []
        },
        "tokens": [
            {
                "id": 0,
                "form": "今日",
                "kana": "キョウ",
                "lemma": "今日",
                "pos": "名詞",
                "features": [
                    "日時"
                ],
                "dependency_labels": [
                    {
                        "token_id": 1,
                        "label": "case"
                    }
                ],
                "attributes": {}
            },
            {
                "id": 1,
                "form": "の",
                "kana": "ノ",
                "lemma": "の",
                "pos": "格助詞",
                "features": [
                    "連体"
                ],
                "attributes": {}
            }
        ]
    },
    {
        "chunk_info": {
            "id": 1,
            "head": 2,
            "dep": "D",
            "chunk_head": 0,
            "chunk_func": 1,
            "links": [
                {
                    "link": 0,
                    "label": "adjectivals"
                }
            ]
        },
        "tokens": [
            {
                "id": 2,
                "form": "ランチ",
                "kana": "ランチ",
                "lemma": "ランチ",
                "pos": "名詞",
                "features": [],
                "dependency_labels": [
                    {
                        "token_id": 0,
                        "label": "nmod"
                    },
                    {
                        "token_id": 3,
                        "label": "case"
                    }
                ],
                "attributes": {}
            },
            {
                "id": 3,
                "form": "は",
                "kana": "ハ",
                "lemma": "は",
                "pos": "連用助詞",
                "features": [],
                "attributes": {}
            }
        ]
    },
    {
        "chunk_info": {
            "id": 2,
            "head": 3,
            "dep": "D",
            "chunk_head": 0,
            "chunk_func": 1,
            "links": [
                {
                    "link": 1,
                    "label": "agent"
                }
            ],
            "predicate": [
                "past"
            ]
        },
        "tokens": [
            {
                "id": 4,
                "form": "ハンバーガー",
                "kana": "ハンバーガー",
                "lemma": "ハンバーガー",
                "pos": "名詞",
                "features": [],
                "dependency_labels": [
                    {
                        "token_id": 2,
                        "label": "nsubj"
                    },
                    {
                        "token_id": 5,
                        "label": "cop"
                    },
                    {
                        "token_id": 6,
                        "label": "punct"
                    }
                ],
                "attributes": {}
            },
            {
                "id": 5,
                "form": "だった",
                "kana": "ダッタ",
                "lemma": "だった",
                "pos": "判定詞",
                "features": [
                    "連体"
                ],
                "attributes": {}
            },
            {
                "id": 6,
                "form": "、",
                "kana": "",
                "lemma": "、",
                "pos": "読点",
                "features": [],
                "attributes": {}
            }
        ]
    },
    {
        "chunk_info": {
            "id": 3,
            "head": 4,
            "dep": "D",
            "chunk_head": 1,
            "chunk_func": 2,
            "links": [
                {
                    "link": 2,
                    "label": "adjectivals"
                }
            ]
        },
        "tokens": [
            {
                "id": 7,
                "form": "優美",
                "kana": "ユミ",
                "lemma": "優美",
                "pos": "名詞",
                "features": [
                    "名",
                    "固有"
                ],
                "attributes": {}
            },
            {
                "id": 8,
                "form": "ちゃん",
                "kana": "チャン",
                "lemma": "ちゃん",
                "pos": "名詞接尾辞",
                "features": [
                    "名詞"
                ],
                "dependency_labels": [
                    {
                        "token_id": 4,
                        "label": "acl"
                    },
                    {
                        "token_id": 7,
                        "label": "name"
                    },
                    {
                        "token_id": 9,
                        "label": "case"
                    }
                ],
                "attributes": {}
            },
            {
                "id": 9,
                "form": "は",
                "kana": "ハ",
                "lemma": "は",
                "pos": "連用助詞",
                "features": [],
                "attributes": {}
            }
        ]
    },
    {
        "chunk_info": {
            "id": 4,
            "head": -1,
            "dep": "O",
            "chunk_head": 0,
            "chunk_func": 1,
            "links": [
                {
                    "link": 3,
                    "label": "aobject"
                }
            ],
            "predicate": [
                "past"
            ]
        },
        "tokens": [
            {
                "id": 10,
                "form": "なん",
                "kana": "ナン",
                "lemma": "何",
                "pos": "名詞",
                "features": [],
                "dependency_labels": [
                    {
                        "token_id": 8,
                        "label": "nmod"
                    },
                    {
                        "token_id": 11,
                        "label": "cop"
                    },
                    {
                        "token_id": 12,
                        "label": "punct"
                    }
                ],
                "attributes": {}
            },
            {
                "id": 11,
                "form": "だった",
                "kana": "ダッタ",
                "lemma": "だった",
                "pos": "判定詞",
                "features": [
                    "終止"
                ],
                "attributes": {}
            },
            {
                "id": 12,
                "form": "?",
                "kana": "",
                "lemma": "?",
                "pos": "句点",
                "features": [
                    "疑問符"
                ],
                "attributes": {}
            }
        ]
    }
]


APIをたたくときに、解析してほしい文章を入れるだけでいいのはとても魅力的です。
これから終助詞の場所を判定し、文に切り分けています。

2,切り分けた各文がどんな文か調べる

次は切り分けられた文の末尾に絵文字を付けていきます。このとき、各文がどんな文かによって付与する顔文字も変えていきます。
COTOHA APIには文タイプ判定という、

挨拶や同意、約束などの発話行為のタイプを判定します。
同時に、叙述文、命令文、質問文などの文タイプを出力します。

( API一覧より)、その文がどんな文なのか調べるAPIがあるので、この結果をもとに顔文字を付け替えていきます。

index.js
/*コピペ前にすること
npm install fs axios
*/
const axios = require('axios')
const fs = require('fs')
class Cotoha{
  constructor(sentence,cotoha_token){
    this.sentence = sentence
    this.cotoha_token = cotoha_token
  }
  client(){
    const axiosConfig = axios.create({
      headers:{
        "Authorization": `Bearer ${this.cotoha_token}`,
        "Content-Type": "application/json"
      },
      baseURL:"https://api.ce-cotoha.com/api/dev/nlp/v1",
    });
    return axiosConfig;
  }
  async sentenceType(){
    const axiosBase = await this.client();
    try{
      const res = await axiosBase.post("/sentence_type",{
        "sentence":this.sentence,
      });
      const result = res.data.result
      return result
    }catch(e){
      console.log(e)
    }
  }
}
//cotoha_tokne(Access token)はデモサイトにて取得できます。Client IDとClient Secretを入力して
//ボタン押下してから、サイト下部のLogをご覧ください
const main = async () => {
  const inputMsg = "今日のランチはハンバーガーだった、優美ちゃんはなんだった?"
  const cotoha_token = "0oTUaaBrA5zALXOGyxxnkcgxAhVH"
  const cotoha = new Cotoha(inputMsg, cotoha_token)
  const outputMsg = await cotoha.sentenceType()
  fs.writeFile("./sentenceType.json",JSON.stringify(outputMsg,null,"\t"),()=>{console.log("fs end")})
}
main()
/*実行コマンド
node .
*/

sentenceType.json
{
    "modality": "interrogative",
    "dialog_act": [
        "information-seeking"
    ]
}

modalityは投げた文が「叙述」「質問」「命令」の3つのどれかかを判別した結果を返してくれます。dialog_actは投げた文のタイプをより詳細に判別した結果を返してくれます。今回はdialog_actの結果を利用します。

返り値 日本語説明 Emoji
greeting 挨拶 ?
information-providing 情報提供 ❗❗
feedback フィードバック/相槌 ?
information-seeking 情報獲得 ?
agreement 同意 ?
feedbackElicitation 理解確認 ?
commissive 約束 ?
acceptOffer 受領 ?
selfCorrection 言い直し ?
thanking 感謝 ? ?
apology 謝罪 ?
stalling 時間埋め ?
directive 指示 ?
goodbye 挨拶(別れ) ?
declineOffer 否認 ?
turnAssign ターン譲渡 ?
pausing 中断
acceptApology 謝罪受領 ?
acceptThanking 感謝受領 ?

このどれかが返ってくるので、対応するEmoji(上記Emoji列)を文末に付与します。

3,名詞の後ろにいい感じのEmojiを付与

文末に絵文字を付与しただけでは、おじさん感がありません。文中いたるところに絵文字を付与して、よりおじさんに近づきましょう。構文解析の結果は品詞分解されているため、適当な名詞の後ろに適当な絵文字を付与すればいいおじさんになれそうですが、この「適当な絵文字」をそれぞれの名詞につけるのは自然言語処理の力がないとできそうにありません。
COTOHA APIには「固有表現抽出」というAPIがあり、これを使って文中の名詞の判定とどんな名詞なのかを調べようと思います。

index.js
/*コピペ前にすること
npm install fs axios
*/
const axios = require('axios')
const fs = require('fs')
class Cotoha{
  constructor(sentence,cotoha_token){
    this.sentence = sentence
    this.cotoha_token = cotoha_token
  }
  client(){
    const axiosConfig = axios.create({
      headers:{
        "Authorization": `Bearer ${this.cotoha_token}`,
        "Content-Type": "application/json"
      },
      baseURL:"https://api.ce-cotoha.com/api/dev/nlp/v1",
    });
    return axiosConfig;
  }
  async unique(){
    const axiosBase = await this.client();
    try{
      const res = await axiosBase.post("/ne",{
        "sentence":this.sentence
      })
      await fs.writeFile("./output/unique.json",JSON.stringify(res.data,null,"\t"));
      const result = res.data.result;
      return result
    }catch(e){
      return e
    }
  }
}
//cotoha_tokne(Access token)はデモサイトにて取得できます。Client IDとClient Secretを入力して
//ボタン押下してから、サイト下部のLogをご覧ください
const main = async () => {
  const inputMsg = "今日のランチはハンバーガーだった、優美ちゃんはなんだった?"
  const cotoha_token = "hogehoge"
  const cotoha = new Cotoha(inputMsg, cotoha_token)
  const outputMsg = await cotoha.unique()
  fs.writeFile("./unique.json",JSON.stringify(outputMsg,null,"\t"),()=>{console.log("fs end")})
}
main()
/*実行コマンド
node .
*/

unique.json
[
    {
        "begin_pos": 0,
        "end_pos": 2,
        "form": "今日",
        "std_form": "今日",
        "class": "DAT",
        "extended_class": "",
        "source": "basic"
    },
    {
        "begin_pos": 17,
        "end_pos": 19,
        "form": "優美",
        "std_form": "優美",
        "class": "PSN",
        "extended_class": "",
        "source": "basic"
    },
    {
        "begin_pos": 3,
        "end_pos": 6,
        "form": "ランチ",
        "std_form": "ランチ",
        "class": "ART",
        "extended_class": "Dish",
        "source": "basic"
    },
    {
        "begin_pos": 7,
        "end_pos": 13,
        "form": "ハンバーガー",
        "std_form": "ハンバーガー",
        "class": "ART",
        "extended_class": "Dish",
        "source": "basic"
    },
    {
        "begin_pos": 19,
        "end_pos": 22,
        "form": "ちゃん",
        "std_form": "ちゃん",
        "class": "ART",
        "extended_class": "Title_Other",
        "source": "basic"
    }
]

返り値の"extended_class"は、対象の語が「人」「食べ物」「神」「島名」などを数多くの選択肢(100以上)からどれに類するかを返してくれます。(割り当てがないものもあります。上記例だと「今日」は"extended_class"がありません)
あとは返ってきた値に対してあらかじめ決めておいたEmojiを付与します。



100以上の項目に対応する絵文字を決めるのが途方もなさすぎるので、一旦リリースすることにいたしました...絶賛絵文字付与中です。

4,実行結果

入力文
今日のランチはハンバーガーだったよ、優美ちゃんはなんだった?

出力文(2020年3月15日、名詞後置修飾未実装)
今日のランチはハンバーガーだったよ?優美ちゃんはなんだった??

理想の出力文(名詞後置修飾完全実装後)
今日のランチ:fork_and_knife:はハンバーガー:fork_and_knife:だったよ?優美チャンはなんだった??
(ランチもハンバーガーもdishのため、同じ絵文字の予定

終わりに

感想

名詞に対して自分でEmojiを選ぶのが非常にめんどうでした。これは人工知能やAIだとかいうのを使えばいいのか、はたまた今回僕が作ったものも人工知能の一角なのか...難しい世界です。
難しい世界ですが、今回のプロダクトを作るのは非常に簡単でした。リファレンスも日本語で簡潔だし、リクエスト文も簡単、レスポンスも明確、いいことずくめでした。 むしろasync/awaitの非同期に手こずりました IDやsecretをユーザーに依存しないサービスにしようとすると月10万円のCOTOHAの利用料が飛ぶのでできそうにありませんが、エンジニア専用だったり利用数の少ないサービスならいろいろ面白いことができそうなので、挑戦してみようと思いました。
実装方法については後日記事にする予定ですので、もしよろしければご覧ください。

Special Thanks

高校の同級生、デアショコ君には定式化、もとい、僕のやりたいことをコードに落とすプロセスを手伝ってもらっただけでなく、絵文字選定のお手伝いまでしてくれて...本当に助かりました。ありがとう。賞がもらえて退院したら焼き肉行こう。

コード

index.js全文
index.js
/*コピペ前にすること
npm install fs axios
*/
const axios = require('axios')
const fs = require('fs')
class Cotoha{
  constructor(sentence,cotoha_token){
    this.sentence = sentence
    this.cotoha_token = cotoha_token
  }
  client(){
    const axiosConfig = axios.create({
      headers:{
        "Authorization": `Bearer ${this.cotoha_token}`,
        "Content-Type": "application/json"
      },
      baseURL:"https://api.ce-cotoha.com/api/dev/nlp/v1",
    });
    return axiosConfig;
  }
  async parse(){
    const axiosBase = await this.client();
    try{
      const res = await axiosBase.post("/parse",{"sentence":this.sentence})//parseが構文解析のリクエスト
      const result = res.data.result;
      return result
    }catch(e){
      console.log(e)
    }
  }
  async sentenceType(){
    const axiosBase = await this.client();
    try{
      const res = await axiosBase.post("/sentence_type",{
        "sentence":this.sentence,
      });
      const result = res.data.result
      return result
    }catch(e){
      console.log(e)
    }
  }
  async unique(){
    const axiosBase = await this.client();
    try{
      const res = await axiosBase.post("/ne",{
        "sentence":this.sentence
      })
      const result = res.data.result;
      //console.log(result)
      return result
    }catch(e){
      return e
    }
  }
}
/*
cotoha_tokne(Access token)はデモサイトにて取得できます。Client IDとClient Secretを入力して
ボタン押下してから、サイト下部のLogをご覧ください
*/
const main = async () => {
  const inputMsg = "今日のランチはハンバーガーだった、優美ちゃんはなんだった?"
  const cotoha_token = "hogehoge"
  const cotoha = new Cotoha(inputMsg, cotoha_token)
  const outputMsg = await cotoha.unique()//すきなメソッドを選らんで実行してください。
  fs.writeFile("./output.json",JSON.stringify(outputMsg,null,"\t"),()=>{console.log("fs end")})
}
main()
/*実行コマンド
node .
*/

リンク集

製作物
https://storage.googleapis.com/vue-oji-cotoha/index.html
COTOHA API 利用者登録(↑サービス利用前に利用者登録をお済ませください)
https://api.ce-cotoha.com/contents/developers/index.html
本記事を書くきっかけになったプレゼント企画
https://zine.qiita.com/event/collaboration-cotoha-api/
COTOHA APIについてより知りたい方はこちらから
https://api.ce-cotoha.com/
COTOHA APIの機能一覧(オススメ!!)
https://api.ce-cotoha.com/contents/api-all.html
作者Twitter(最近は開発に関わることもつぶやいてます)
https://twitter.com/gyozaIsGood
定式化やEmoji選定協力のデアショコ
https://twitter.com/der_schoco

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

Vue.extend で TypeScript がエラーを吐いたときに確認すべきこと

Vue の単一ファイルコンポーネントを TypeScript で書く方法はいくつかある。僕はなるべく JS に近い書き方をしたくて、Vue.extend を使っているのだが、時々下のような型エラーが発生する。

Property 'XXX' does not exist on type 'Vue'.

なお、ここで言う XXX とは、SFC 内にある computed の変数だったり methods にある関数だったりする。このようなときに確認するべき場所を自分の備忘録も兼ねてまとめておく。

computed や methods の関数にアノテーションをつけているか

Vue.extend() で型の推論を行っている場合、引数として書いたオブジェクトをもとに、そのオブジェクト内で使われる this の推論が行われる。そのため、型推論に頼っていると、しばしば失敗して this の型が正しく認識されなくなるのである。(雑な理解なので、詳しい方いたら教えてください:bow:)

対処法としては、computedmethodsに書いた関数には、しっかり下のようにアノテーションを書いてあげれば良い。これは Vue の TypeScript サポートに関するページにも記載があるが、ちょくちょくフォーラムでも同様の質問があり、案外見落としがち(自分もハマった)。

import Vue from 'vue';

export default Vue.extend({
  data() {
    return {
      msg: 'Hello'
    };
  },
  methods: {
    // アノテーションをしっかり書く
    greet(): string {
      return this.msg + ' world';
              //  ~~~ ← 書かないとここがエラーになることがある
    }
  },
  computed: {
    // ここもアノテーションをしっかり書く
    greeting(): string {
      return this.greet() + '!';
              //  ~~~~~ ← 書かないとここがエラーになることがある
    }
  }
});

TypeScript のサポート — Vue.js

オプションなどをスペルミスしていないか

昨日ハマったのはこっち。例えば、下のようにpropsのオプションを書き間違えたとする。

export default Vue.extend({
  props: {
    imageUrl: {
      type: String as PropType<string>,
      require: true    // ← 本当は require"d" !!!
    }
  },
});

この場合にも、冒頭に書いたエラーがthisに生えている変数や関数すべてに発生してしまう。同様に typetyep にスペルミスしたときとかにも起こるので気をつけたい。

参考: Vue/Typescript 3.5.1 Error: 'property' does not exist on type 'Vue' - Get Help - Vue Forum
https://forum.vuejs.org/t/vue-typescript-3-5-1-error-property-does-not-exist-on-type-vue/65591

まとめ

エラーメッセージが直接的ではない分、原因の特定に時間を食いがちなので気をつけましょう?

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

mountedでアロー関数を使ったらエラーになった

SSGとしてnuxt.jsを使う前提です。

良くない書き方

  mounted: () => {
    window.addEventListener('keydown', this.hoge, false)
  },
  methods: {
    hoge () {
      // 何かしらの処理
    }
  }

この書き方だと、NUXT-LINKでリンク先へ飛んだ際に
Cannot read property 'hoge' of undefined
というエラーが発生しました。

ここで、「ムムッ」と思い、次は下記のように書いてみました。

良くない書き方2

  beforeMount () {
    window.addEventListener('keydown', this.hoge, false)
  },
  methods: {
    hoge () {
      // 何かしらの処理
    }
  }

今となってはどうしてこのように書いたのか覚えていないのですが、とりあえず上記のやり方の場合は、
Cannot read property 'addEventListener' of null
というエラーが表示されました。

ここで、ページ遷移をNUXT-LINKからAタグに書き換えたところ、ようやくエラーが表示されなくなったのです。
なんでだろう?

良い書き方

良い、というよりも、最終的に目指していた挙動になった書き方は下記です。

  mounted () {
    window.addEventListener('keydown', this.hoge, false)
  },
  methods: {
    hoge () {
      // 何かしらの処理
    }
  }

どうやらネット上では、

mounted: () => {}
// or
mounted () {} 

の2通りの書き方が混在しているようです。

前者だとなぜエラーが出るのか分かりかねますが、とりあえず後者の () {} を使っていれば問題なさそう。

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

趣味WebエンジニアがVue.js+Flask(&GCP)でWebアプリ開発

きっかけ

 ここ1年くらい、Twitter等のSNSでいろんな方が発信されている自作Webアプリを色々見ているせいか、筆者の中でWeb技術・Web開発に対する熱が高く、なにか便利なWebアプリを作りたいという想いがあります。ただ、バックエンドのAPサーバやDBサーバを構築するための知識は現時点でそこまでありません。
 そこで、「コーディングはなるべくHTMLやJavaScript、CSS(とそれらのフレームワーク)のようなフロントエンド中心に行い、なおかつ積極的にSaaSを使うことでサーバレスでさくっとWebアプリを構築できないか」を模索し、実際にアプリを作成・公開したのでここに手法を書き記します。

作成するWebアプリ

 今回作るアプリですが、友人からアイデアをもらい「こんな内容のマンガが読みたい、というざっくりとしたきもちからオススメの作品を推薦するアプリ」を協力して作ることにしました。機能はひとまずシンプルで良いと思ったので、次のような要件で作成します。

  • スマホでのアクセス・閲覧を前提とするがPCでも見られる
     つまりモバイルファーストのレスポンシブデザインである。

  • Webアプリ内でユーザ認証はなし
     URLにアクセスするだけで誰でも使える。その代わりサービス内でユーザデータは保持しない(DBサーバの不使用)。

  • ユーザのキーワード入力に対し、必要なものを検索してサイトに表示する
     ただし、検索はDBに対してではなくHTMLやJSにハードコーディングされたもの、またはデータ検索用に公開されているWebAPIに対して行い、その結果データを取得し表示することとする(DBサーバの不使用)。

  • クライアント(ブラウザ)側で動的なページ構築を行う
     JavaScriptを使い、取得データを表示するためのhtmlをユーザ側で動的に作成し表示する(APサーバの不使用)。

使用するWeb要素技術

 要件を踏まえ、今回使用した言語【フレームワーク】を以下に記します。

  • HTML

  • CSS【Bootstrap】
     公式ページはこちら(Bootstrap - 世界で最も人気のあるフロントエンドのコンポーネントライブラリ)。Bootstrapを使うと、整ったデザイン・レスポンシブデザインを楽に作ることができる。

  • JavaScript【Vue.js】
     公式ページはこちら(Vue.js)。Vue.jsを使うと、JavaScriptがクライアント(ブラウザ)側でhtmlを操作する動作をかなり直感的に記述できる(データバインディングという仕組みによりJavaScript上のデータを更新するとhtmlも同時に更新される)。

  • Python【Flask】
     公式ページはこちら(Flaskへ ようこそ — Flask v0.5.1 documentation)。Flaskを使うと、Pythonの文法を使いシンプルな記述でWebAPIを記述することができるので、もし必要なデータを公開しているWebAPIがなかったらFlaskを使って自分で作成できる。今回はキーワードをもとに作品名一覧を返却するAPIを作成した。

Webアプリ構築に使用するSaaS

 今回の要件でWebアプリを構築するために使用可能なSaaSの例を以下に記します。

  • さくらインターネット(今回はレンタルサーバライトプランで可)
     公式ページはこちら(さくらのレンタルサーバ | 高速・安定WordPressなら!無料2週間お試し)。HTML、CSS、JavaScript等を配置して公開することができるWebホスティングサービス。その他に、独自ドメイン取得やSSL証明書の設定オプションサービスもある。各種設定は、基本Webインタフェースでポチポチ操作できるので初心者としてはとっつきやすい。無料お試し期間のあとは月額定額制。
     その他、類似のホスティングSaaSとしては、Firebase Hosting(公式ページ)や、AWS(公式ページ)等があります。これらは、従量課金制で無料枠もあったりするので初期費用を抑えられる。

  • Google App Engine
     サービスの概要はこちら(App Engine  |  Google Cloud)。
     上述のFlaskで記述したWebAPIを配置して公開するために利用できる。アクセスや負荷に応じて自動でスケールしてくれ、料金は従量課金制。

システム構成図

 これまでの話を踏まえ、フレームワーク・SaaSを活用したWebアプリの構成図(例)は以下のようになります。
システム構成図.png

 FlaskでWebAPIを公開している部分ですが、Google App Engineにアップロードしているファイル構造は次のようなかんじです。
キャプチャ.PNG
data_list.csvが作品名とその特徴量(タグ)が入ったリストデータ、main.pyがFlaskのpythonコード、それ以外はApp Engineのお作法で用意する設定ファイルです。設定ファイル作成には、次の記事を参考にさせていただきました。

main.pyの中身は次のようになっており、ユーザがWebAPIに対して作品検索用のデータ(json)を投げてきたときに、結果データ(json)を返却するように記述しています。

main.py
from flask import Flask, jsonify, request
from flask_cors import CORS
import os
import csv


# このスクリプトが在るディレクトリの絶対パスが入る変数
CWD = os.path.dirname(__file__)

# 作品データのファイル名
DATA_LIST_FILE = "data_list.csv"
LEARN_DATA = None  # 推薦に使うデータオブジェクト

# CSVのパスを受け取って読み込み
def loadStractualData(target_file):
    global LEARN_DATA  # グローバル変数に代入するために必要な宣言
    csv_list = []  # 単純にCSVをリストに変換しただけのリスト
    with open(target_file, 'r', encoding='utf-8', newline="") as f:
        csv_list = [row for row in csv.reader(f)]  # 2次元リスト
    output = []
    for row in csv_list:  # CSVを一行ずつ処理
        #################################################
        # CSVの行を構造化データにしてoutputに格納していく処理(割愛)
        #################################################
    print("file loading finished!")
    LEARN_DATA = output

###############################
##  ここからサーバプロセスの設定  ##
###############################
loadStractualData(os.path.join(CWD, LEARNED_FILE)) # CSVファイルを読み込む
app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False  # 出力JSONの日本語を文字化けさせない設定
CORS(app)  # Access-Control-Allow-Originの設定

# HTTPのPOSTで/post_tagsにユーザ選択タグが送られて来たときの処理
@app.route('/post_tags', methods=['POST'])
def post_tags():
    json = request.get_json()  # POSTされたJSONを取得
    input_tags = json["tags"]  # ユーザが入力したタグのリスト
    ###########################################################
    # ユーザの送ってきたタグで作品リストをフィルタしout_listに格納する処理(割愛)
    ###########################################################
    return jsonify({"title_num": len(out_list), "titles": out_list}) #jsonを返す

# python実行時のエントリーポイント
if __name__ == "__main__":
    print(" * Flask starting server...")
    app.run()  # サーバプロセス起動

Vue.jsからFlaskで作成したWebAPIにリクエストを投げる部分は、Axiosを使用しています。以下の記事を参考にしました。

完成したアプリ

 完成したアプリはこんな感じです(emore | "きもち"で探すマンガ検索)。トップページ、検索ページ、検索結果ページからなるシンプルなアプリで、要件にあったようにスマホ前提のレスポンシブデザインになっています(Bootstrapのおかげ)。また、検索ページでは、特にVue.jsによるブラウザ側での動的描画が活かせている(ユーザがタグを選択すると逐一WebAPIに送信し結果の表示を更新するような、動きがあるデザインになっている)と思うので、是非見ていただければ幸いです。

所感

 Webアプリ開発における、フロントエンド技術やSaaSがかなり発達していて、個人開発でも手軽にある程度のアプリは作れるようになっていると感じます。今回要件に含めなかったユーザ認証にしても、例えばSaaSのFirebase Authenticationを使えば、マルチプラットフォームログイン(Twitterでログイン、Facebookでログイン等)を実装できますし、ユーザデータを保存するDBにしてもFirebase,FireStore等のWebAPI経由で利用できるものがあります。
 Vue.jsもFlaskも、高機能なWebサービスを作ろうと思ったらいくらでも技術的な発展性を含んでいるので、今後も適度に学んだ知識を作品としてアウトプットしつつ、引き続き技術にもチャッチアップして学習していこうと思います(とりあえず、Vue.jsのフレームワークであるNuxt.jsや、Flaskより高機能なDjangoについて勉強中)。

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

【Vue】web1weekに参加しました

Webサービスを1週間で作ろうというイベントがあったので、参加しました。
私は「ひきこもりのいちにち」という育成ゲーム的な何かを作ってみました。以下のリンクからどうぞ。
https://aiandrox.github.io/hikikomori_oneday/
image.png
せっかくなので、どのように作ったか記録として残しておこうと思います。
技術的なことは一切書いておりませんので、ご了承ください。
また、ネタバレを含みますので、初見の方は是非一度遊んでほしいなと思います。

使ったもの

Google Fontsはタイトルをかわいい感じにしたかったので使用。
FontAwesomeではなくマテリアルアイコンを使った理由は、前者をCDNで使おうとすると登録が必要で面倒だったから。

ゲームについて

点数は最大150点くらいになるようになっています。
ゲームオーバーのフラグは3つあります。コメントはゲームオーバー含めて全13種類です。ぜひコンプリートを目指してください。

作成記録

入れたかった機能は、ゲームオーバーとTwitterシェア機能です。
初見の人には是非ゲームオーバーになってほしかった。
また、せっかく総評が出るならTwitterでシェアしたい→点数も出たらおもしろいのでは?ということで点数も追加しました。

作業時間

3/13 約7.5時間
ひたすらindex.htmlにHTMLとVueを書く
3/14 約8時間
CSSで肉付けしながら手直し

作成の流れ

3月9日に「Home」というテーマが発表されました。
最初は「家から出られないあなたはどのように1日を使いますか?」というコンセプトでノートにメモしていました。
1584198070504.jpg
この時点では、Vue.jpを少し触ったことがある程度でデータをビューに同期させる方法が全くわかりませんでした。なので、案は考えたけど作るのは無理だろうと思っていました。

最近、JavaScriptを勉強しており、JS-Proというサイト使っていました。ここのVue.js Iを終えたところでデータの渡し方がなんとなくわかり、行けそうと思ったので作成することにしました。
「行ける」というのは「まあ大体思った通りの挙動をしてくれる」という意味でしかありませんが、「出せばOK」の精神でやりました。

感想

作業時間は意外とそんなにかからない。ただし、機能が明確になっていて、許容範囲が広い場合のみ。
GItHub Pagesは初めて使ったのですが、設定が簡単で驚きました。まじで数分でできます。
とにもかくにも提出できたのでよかったです。楽しく制作させていただきました。

最後になりますが、このような機会を設けてくださった主催者のだら様に感謝申し上げます。

課題

  • index.htmlの長さがやばい
  • コンポーネント不使用
  • 24時を越えると時計のレイアウトが崩れる
  • 時計を右寄せにしたかった

追加したかった機能

  • データベースを使ったランキング
  • コマンドを増やす(筋トレを追加しました)
  • コマンドに応じて棒人間の画像を変える
  • ステータスを増やす(たくましさを追加しました)
  • ステータスをレーダー表示する
  • レスポンシブ対応(スマホ幅対応しました)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JSON Schema で設定ファイルを定義して良い感じの設定インターフェースを作ろう! ~ Vue.js 編 ~

はじめよう! JSON Schema

JSON Schema は JSON 形式のドキュメントにアノテーションとバリデーションの仕組みを与えるスキーマ言語です。近年では設定ファイルなどで利用されることも多い JSON ですが、JSON にはコメントを書くことができません。 そのため、JSON Schema による補完が魅力的な選択肢になりえます。

LoopBack 3 を例に考えてみましょう。LoopBack は IBM の提供する Node.js フレームワークで、JSON ファイルで定義することで REST API を 手軽に構築できます。ここでは、簡単なモデル定義の例を表します:

customer.json
{
    "name": "Customer",
    "idInjection": false,
    "http": {
        "path": "/foo/bar"
    }
}

LoopBack ではこのように JSON ファイルを利用してモデル定義を記述します。JSON による記述はわかりやすくていいですよね。しかし、コメントがないため、どのように設定を記述すればいいのかわかりません。たとえば、どのようなプロパティが利用可能なのか、型が何であるのか、必須項目はあるのか、といった情報がないのです。もちろんリファレンスを参照すればいいのですが、できれば入力画面そのものが教えてくれたほうがありがたいですよね。そこで、LoopBack 3 JSON Schemas という JSON Schema が提供されているので試してみましょう。JSON Schema を利用したい場合、以下のような記述を行います:

customer.json
{
    "$schema": "https://raw.githubusercontent.com/Sequoia/loopback-json-schemas/master/dist/loopback-model-definition.json"
}

$schema プロパティの値として、JSON Schema ファイルの場所を指定します。これは URL でもローカルファイルでも構いません。JSON Schema に対応したエディタを利用している場合、編集中の文書に機能が動作します。Visual Stadio Code1 で編集してみましょう:

動画

キー名のサジェストやバリデーション(型、必須)などの機能が働いていることがわかります。これで設定ミスを未然に防ぐことができますね!

Vue Form JSON Schema で設定インターフェースを作る

本題です。せっかく設定ファイルのスキーマを用意したなら、それをフロントエンドの設定インターフェース設計にも流用したくなることもあるかもしれません。つまり、JSON Schema を元に HTML 等で設定インターフェースを生成し、その画面で入力された設定を JSON として返却してほしい、というアイデアです。 著名なものとして JSON Editor というライブラリがあります。こちらのデモページがその強力さをよく表しています。コードの記述量を減らすことができ、とても素敵ですね!

JSON Editor
(出典:https://github.com/json-editor/json-editor)

さて、JSON Editor は生の DOM を操作するので Vue.js を利用したアプリケーションとは相性がよくありません。また、フォーム部品として Vuetify のような Vue コンポーネントを利用したい場合もあるでしょう。そのため、Vue Form JSON Schema という専用のライブラリ(Vue コンポーネント)を利用します。

vue-form-json-schema.jpg
(出典:https://codesandbox.io/s/4rykx7jj19)

Vue Form JSON Schema は公式リファレンスデモページが大変充実しています。そのため本記事で詳しい説明は避けますが、触りとして Readme 付属の使用例の参考訳を示します:

<template>
    <vue-form-json-schema
      v-model="model"
      :schema="schema"
      :ui-schema="uiSchema"
    >
  </vue-form-json-schema>
</template>

<script>
  export default {
    data() {
      return {
        // フォームの値を保持するオブジェクト
        model: {},
        // JSON Schema として正しいオブジェクト
        // 訳注)一般に #/definitions/Schema に記述する箇所を抜き出します。
        schema: {
          type: 'object',
          properties: {
            firstName: {
              type: 'string',
            },
          },
        },
        // HTML 要素、または Vue COmponent の配列
        uiSchema: [{
          component: 'input',
          model: 'firstName',
          // Vue.js の[描画関数](https://jp.vuejs.org/v2/guide/render-function.html) と同じ API
          // 訳注)createElement() の引数 Object と同形式(データオブジェクト)であることを表しています。
          fieldOptions: {
            class: ['form-control'],
            on: ['input'],
            attrs: {
              placeholder: 'Please enter your first name',
            },
          },
        }],
      };
    }
  };
</script>

JSON Schema のほかに uiSchema という画面定義用のスキーマを持つのが特徴です。コンポーネントと JSON Schema のプロパティを 1:1 で対応させることで、クラスやスタイルなどを柔軟に適用することが可能になっています。また、ajv を利用した高度なバリデーション機能を備えているほか、ajv-i18n による国際化対応も可能です。

ui-schema-generator のご紹介

Vue Form JSON Schema は非常に便利なライブラリですが、コンポーネントと JSON Schema のプロパティを 1:1 で対応させなければならないという仕様上、設定項目が増えれば増えるほど uiSchema の記述が冗長になってしまうという欠点があります。たとえば、上記スクリーンショットの画面を構成している uiSchema は以下のようになっています:

サンプルコード(出典:https://4rykx7jj19.codesandbox.io/)
[
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "label",
        "fieldOptions": {
          "attrs": {
            "for": "first-name"
          },
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "First name"
          }
        }
      },
      {
        "component": "input",
        "model": "firstName",
        "errorOptions": {
          "class": [
            "is-invalid"
          ]
        },
        "fieldOptions": {
          "attrs": {
            "id": "first-name"
          },
          "class": [
            "form-control"
          ],
          "on": [
            "input"
          ]
        }
      },
      {
        "component": "small",
        "fieldOptions": {
          "class": [
            "text-muted"
          ],
          "domProps": {
            "innerHTML": "Please enter your first name"
          }
        }
      }
    ]
  },
  {
    "component": "transition",
    "fieldOptions": {
      "props": {
        "name": "fade"
      }
    },
    "children": [
      {
        "component": "div",
        "model": "firstName",
        "errorHandler": true,
        "displayOptions": {
          "model": "firstName",
          "schema": {
            "not": {
              "type": "string"
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "This field is required"
              }
            }
          }
        ]
      }
    ]
  },
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "label",
        "fieldOptions": {
          "attrs": {
            "for": "last-name"
          },
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "Last name"
          }
        }
      },
      {
        "component": "input",
        "model": "lastName",
        "errorOptions": {
          "class": [
            "is-invalid"
          ]
        },
        "fieldOptions": {
          "attrs": {
            "id": "last-name"
          },
          "class": [
            "form-control"
          ],
          "on": [
            "input"
          ]
        }
      },
      {
        "component": "small",
        "fieldOptions": {
          "class": [
            "text-muted"
          ],
          "domProps": {
            "innerHTML": "Please enter your last name"
          }
        }
      }
    ]
  },
  {
    "component": "transition",
    "fieldOptions": {
      "props": {
        "name": "fade"
      }
    },
    "children": [
      {
        "component": "div",
        "model": "lastName",
        "errorHandler": true,
        "displayOptions": {
          "model": "lastName",
          "schema": {
            "not": {
              "type": "string"
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "This field is required"
              }
            }
          }
        ]
      }
    ]
  },
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "label",
        "fieldOptions": {
          "attrs": {
            "for": "age"
          },
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "Age"
          }
        }
      },
      {
        "component": "input",
        "model": "age",
        "errorOptions": {
          "class": [
            "is-invalid"
          ]
        },
        "fieldOptions": {
          "attrs": {
            "id": "age",
            "type": "number",
            "min": 0
          },
          "class": [
            "form-control"
          ],
          "on": [
            "input"
          ]
        }
      },
      {
        "component": "small",
        "fieldOptions": {
          "class": [
            "text-muted"
          ],
          "domProps": {
            "innerHTML": "Please confirm that you are over 18 years of age"
          }
        }
      }
    ]
  },
  {
    "component": "transition",
    "fieldOptions": {
      "props": {
        "name": "fade",
        "mode": "out-in"
      }
    },
    "children": [
      {
        "component": "div",
        "model": "age",
        "errorHandler": true,
        "displayOptions": {
          "model": "age",
          "schema": {
            "not": {
              "type": "number"
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "This field is required"
              }
            }
          }
        ]
      },
      {
        "component": "div",
        "model": "age",
        "errorHandler": true,
        "displayOptions": {
          "model": "age",
          "schema": {
            "type": "number",
            "not": {
              "minimum": 18
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "You must be 18 or older to submit this form"
              }
            }
          }
        ]
      }
    ]
  },
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "div",
        "fieldOptions": {
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "Message (optional)"
          }
        }
      },
      {
        "component": "textarea",
        "model": "message",
        "fieldOptions": {
          "attrs": {
            "placeholder": "Type a message here..."
          },
          "class": [
            "form-control"
          ],
          "on": [
            "input"
          ]
        }
      }
    ]
  },
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "div",
        "fieldOptions": {
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "Terms and conditions"
          }
        }
      },
      {
        "component": "div",
        "children": [
          {
            "component": "span",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "Please acknowledge that you have read and accept our "
              }
            }
          },
          {
            "component": "a",
            "fieldOptions": {
              "attrs": {
                "href": "#"
              },
              "domProps": {
                "innerHTML": "Terms and conditions"
              }
            }
          }
        ]
      },
      {
        "component": "div",
        "fieldOptions": {
          "class": [
            "form-check"
          ]
        },
        "children": [
          {
            "component": "input",
            "model": "consent",
            "errorOptions": {
              "class": [
                "is-invalid"
              ]
            },
            "valueProp": "checked",
            "fieldOptions": {
              "class": [
                "form-check-input"
              ],
              "on": "change",
              "attrs": {
                "id": "consent-yes",
                "name": "name",
                "type": "radio"
              },
              "domProps": {
                "value": true
              }
            }
          },
          {
            "component": "label",
            "fieldOptions": {
              "attrs": {
                "for": "consent-yes"
              },
              "class": [
                "form-check-label"
              ],
              "domProps": {
                "innerHTML": "Yes, I agree"
              }
            }
          }
        ]
      },
      {
        "component": "div",
        "fieldOptions": {
          "class": [
            "form-check"
          ]
        },
        "children": [
          {
            "component": "input",
            "model": "consent",
            "errorOptions": {
              "class": [
                "is-invalid"
              ]
            },
            "valueProp": "checked",
            "fieldOptions": {
              "class": [
                "form-check-input"
              ],
              "on": "change",
              "attrs": {
                "id": "consent-no",
                "name": "name",
                "type": "radio"
              },
              "domProps": {
                "value": false
              }
            }
          },
          {
            "component": "label",
            "fieldOptions": {
              "attrs": {
                "for": "consent-no"
              },
              "class": [
                "form-check-label"
              ],
              "domProps": {
                "innerHTML": "No, I do not agree"
              }
            }
          }
        ]
      }
    ]
  },
  {
    "component": "transition",
    "fieldOptions": {
      "props": {
        "name": "fade",
        "mode": "out-in"
      }
    },
    "children": [
      {
        "component": "div",
        "model": "consent",
        "errorHandler": true,
        "displayOptions": {
          "model": "consent",
          "schema": {
            "not": {
              "type": "boolean"
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "This field is required"
              }
            }
          }
        ]
      },
      {
        "component": "div",
        "model": "consent",
        "errorHandler": true,
        "displayOptions": {
          "model": "consent",
          "schema": {
            "const": false
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "You must consent to our terms and conditions to submit this form."
              }
            }
          }
        ]
      }
    ]
  }
]

同じような記述が重複してしまっていることがわかります。モデルの型とコンポーネントは完全に 1:1 の関係にあるわけではありませんが、ある程度はまとめて記述してしまいたいですよね。そこで、手前味噌はありますが、uiSchema の記述を楽にするヘルパーライブラリ ui-schema-generatorGitHubNPM 上で公開しています。利用例です:

<template>
  <v-app>
    <v-container>
      <vue-form-json-schema v-model="model" :schema="schema" :ui-schema="uiSchema" />
    </v-container>
  </v-app>
</template>

<script>
import VueFormJsonSchema from "vue-form-json-schema";
import "vuetify/dist/vuetify.min.css";

import JsonSchema from "./schema.json";
const Schema = jsonschema.definitions.Schema;

import generator from "ui-schema-generator";

export default {
  components: {
    VueFormJsonSchema
  },
  data() {
    return {
      model: {},
      schema: Schema,
      uiSchema: new generator(JsonSchema)
        // データオブジェクトのデフォルト値をセット
        .setDefaultFieldOptions({
          attrs: {
            outlined: true,
            // 値として function(model) を取ることもできる
            label: model => model,
            hint: model => Schema.properties[model].description
          },
          class: "mt-5"
        })
        // エラーオプションのデフォルト値をセット
        .setDefaultErrorOptions({
          attrs: {
            error: true
          }
        })
        // uiSchema を生成
        .generate(
          "div", // HTML タグ名
          undefined, // 要素と紐付けるモデル。未定義の場合は紐付けない
          // データオブジェクト
          {
            style: { backgroundColor: "#043c78", color: "white" },
            class: "pl-1"
          },
          // 子要素。UiSchemaGenerator のネストも可能
          new generator(JsonSchema)
            .generate("h1", [], { domProps: { innerHTML: "見出し" } })
            .toArray()
        )
        // 同じような uiSchema はまとめて生成することも可能
        .generate("v-text-field", ["firstName","familyName","address","country"], {
          on: "input",
          attrs: {
            clearable: true
          }
        })
        .toArray()
    };
  }
};
</script>

データオブジェクトのデフォルト値を設定できたり、uiSchema をまとめて出力できるようになったので、コードの記述量を大きく削減できるようになったかと思います。よろしければ使ってみてくださいね。

参考リンク


  1. Palenight テーマを利用しています?かわいいね 

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

JSON Schema で楽して良い感じの設定画面を作ろう! ~ Vue.js 編 ~

はじめよう! JSON Schema

JSON Schema は JSON 形式のドキュメントにアノテーションとバリデーションの仕組みを与えるスキーマ言語です。近年では設定ファイルなどで利用されることも多い JSON ですが、JSON にはコメントを書くことができません。 そのため、JSON Schema による補完が魅力的な選択肢になりえます。

LoopBack 3 を例に考えてみましょう。LoopBack は IBM の提供する Node.js フレームワークで、JSON ファイルで定義することで REST API を 手軽に構築できます。ここでは、簡単なモデル定義の例を表します:

customer.json
{
    "name": "Customer",
    "idInjection": false,
    "http": {
        "path": "/foo/bar"
    }
}

LoopBack ではこのように JSON ファイルを利用してモデル定義を記述します。JSON による記述はわかりやすくていいですよね。しかし、コメントがないため、どのように設定を記述すればいいのかわかりません。たとえば、どのようなプロパティが利用可能なのか、型が何であるのか、必須項目はあるのか、といった情報がないのです。もちろんリファレンスを参照すればいいのですが、できれば入力画面そのものが教えてくれたほうがありがたいですよね。そこで、LoopBack 3 JSON Schemas という JSON Schema が提供されているので試してみましょう。JSON Schema を利用したい場合、以下のような記述を行います:

customer.json
{
    "$schema": "https://raw.githubusercontent.com/Sequoia/loopback-json-schemas/master/dist/loopback-model-definition.json"
}

$schema プロパティの値として、JSON Schema ファイルの場所を指定します。これは URL でもローカルファイルでも構いません。JSON Schema に対応したエディタを利用している場合、編集中の文書に機能が動作します。Visual Stadio Code1 で編集してみましょう:

動画

キー名のサジェストやバリデーション(型、必須)などの機能が働いていることがわかります。これで設定ミスを未然に防ぐことができますね!

Vue Form JSON Schema で設定インターフェースを作る

本題です。せっかく設定ファイルのスキーマを用意したなら、それをフロントエンドの設定インターフェース設計にも流用したくなることもあるかもしれません。つまり、JSON Schema を元に HTML 等で設定インターフェースを生成し、その画面で入力された設定を JSON として返却してほしい、というアイデアです。 著名なものとして JSON Editor というライブラリがあります。こちらのデモページがその強力さをよく表しています。コードの記述量を減らすことができ、とても素敵ですね!

JSON Editor
(出典:https://github.com/json-editor/json-editor)

さて、JSON Editor は生の DOM を操作するので Vue.js を利用したアプリケーションとは相性がよくありません。また、フォーム部品として Vuetify のような Vue コンポーネントを利用したい場合もあるでしょう。そのため、Vue Form JSON Schema という専用のライブラリ(Vue コンポーネント)を利用します。

vue-form-json-schema.jpg
(出典:https://codesandbox.io/s/4rykx7jj19)

Vue Form JSON Schema は公式リファレンスデモページが大変充実しています。そのため本記事で詳しい説明は避けますが、触りとして Readme 付属の使用例の参考訳を示します:

<template>
    <vue-form-json-schema
      v-model="model"
      :schema="schema"
      :ui-schema="uiSchema"
    >
  </vue-form-json-schema>
</template>

<script>
  export default {
    data() {
      return {
        // フォームの値を保持するオブジェクト
        model: {},
        // JSON Schema として正しいオブジェクト
        // 訳注)一般に #/definitions/Schema に記述する箇所を抜き出します。
        schema: {
          type: 'object',
          properties: {
            firstName: {
              type: 'string',
            },
          },
        },
        // HTML 要素、または Vue COmponent の配列
        uiSchema: [{
          component: 'input',
          model: 'firstName',
          // Vue.js の[描画関数](https://jp.vuejs.org/v2/guide/render-function.html) と同じ API
          // 訳注)createElement() の引数 Object と同形式(データオブジェクト)であることを表しています。
          fieldOptions: {
            class: ['form-control'],
            on: ['input'],
            attrs: {
              placeholder: 'Please enter your first name',
            },
          },
        }],
      };
    }
  };
</script>

JSON Schema のほかに uiSchema という画面定義用のスキーマを持つのが特徴です。コンポーネントと JSON Schema のプロパティを 1:1 で対応させることで、クラスやスタイルなどを柔軟に適用することが可能になっています。また、ajv を利用した高度なバリデーション機能を備えているほか、ajv-i18n による国際化対応も可能です。

ui-schema-generator のご紹介

Vue Form JSON Schema は非常に便利なライブラリですが、コンポーネントと JSON Schema のプロパティを 1:1 で対応させなければならないという仕様上、設定項目が増えれば増えるほど uiSchema の記述が冗長になってしまうという欠点があります。たとえば、上記スクリーンショットの画面を構成している uiSchema は以下のようになっています:

サンプルコード(出典:https://4rykx7jj19.codesandbox.io/)
[
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "label",
        "fieldOptions": {
          "attrs": {
            "for": "first-name"
          },
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "First name"
          }
        }
      },
      {
        "component": "input",
        "model": "firstName",
        "errorOptions": {
          "class": [
            "is-invalid"
          ]
        },
        "fieldOptions": {
          "attrs": {
            "id": "first-name"
          },
          "class": [
            "form-control"
          ],
          "on": [
            "input"
          ]
        }
      },
      {
        "component": "small",
        "fieldOptions": {
          "class": [
            "text-muted"
          ],
          "domProps": {
            "innerHTML": "Please enter your first name"
          }
        }
      }
    ]
  },
  {
    "component": "transition",
    "fieldOptions": {
      "props": {
        "name": "fade"
      }
    },
    "children": [
      {
        "component": "div",
        "model": "firstName",
        "errorHandler": true,
        "displayOptions": {
          "model": "firstName",
          "schema": {
            "not": {
              "type": "string"
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "This field is required"
              }
            }
          }
        ]
      }
    ]
  },
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "label",
        "fieldOptions": {
          "attrs": {
            "for": "last-name"
          },
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "Last name"
          }
        }
      },
      {
        "component": "input",
        "model": "lastName",
        "errorOptions": {
          "class": [
            "is-invalid"
          ]
        },
        "fieldOptions": {
          "attrs": {
            "id": "last-name"
          },
          "class": [
            "form-control"
          ],
          "on": [
            "input"
          ]
        }
      },
      {
        "component": "small",
        "fieldOptions": {
          "class": [
            "text-muted"
          ],
          "domProps": {
            "innerHTML": "Please enter your last name"
          }
        }
      }
    ]
  },
  {
    "component": "transition",
    "fieldOptions": {
      "props": {
        "name": "fade"
      }
    },
    "children": [
      {
        "component": "div",
        "model": "lastName",
        "errorHandler": true,
        "displayOptions": {
          "model": "lastName",
          "schema": {
            "not": {
              "type": "string"
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "This field is required"
              }
            }
          }
        ]
      }
    ]
  },
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "label",
        "fieldOptions": {
          "attrs": {
            "for": "age"
          },
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "Age"
          }
        }
      },
      {
        "component": "input",
        "model": "age",
        "errorOptions": {
          "class": [
            "is-invalid"
          ]
        },
        "fieldOptions": {
          "attrs": {
            "id": "age",
            "type": "number",
            "min": 0
          },
          "class": [
            "form-control"
          ],
          "on": [
            "input"
          ]
        }
      },
      {
        "component": "small",
        "fieldOptions": {
          "class": [
            "text-muted"
          ],
          "domProps": {
            "innerHTML": "Please confirm that you are over 18 years of age"
          }
        }
      }
    ]
  },
  {
    "component": "transition",
    "fieldOptions": {
      "props": {
        "name": "fade",
        "mode": "out-in"
      }
    },
    "children": [
      {
        "component": "div",
        "model": "age",
        "errorHandler": true,
        "displayOptions": {
          "model": "age",
          "schema": {
            "not": {
              "type": "number"
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "This field is required"
              }
            }
          }
        ]
      },
      {
        "component": "div",
        "model": "age",
        "errorHandler": true,
        "displayOptions": {
          "model": "age",
          "schema": {
            "type": "number",
            "not": {
              "minimum": 18
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "You must be 18 or older to submit this form"
              }
            }
          }
        ]
      }
    ]
  },
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "div",
        "fieldOptions": {
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "Message (optional)"
          }
        }
      },
      {
        "component": "textarea",
        "model": "message",
        "fieldOptions": {
          "attrs": {
            "placeholder": "Type a message here..."
          },
          "class": [
            "form-control"
          ],
          "on": [
            "input"
          ]
        }
      }
    ]
  },
  {
    "component": "div",
    "fieldOptions": {
      "class": [
        "form-group"
      ]
    },
    "children": [
      {
        "component": "div",
        "fieldOptions": {
          "class": [
            "font-weight-bold"
          ],
          "domProps": {
            "innerHTML": "Terms and conditions"
          }
        }
      },
      {
        "component": "div",
        "children": [
          {
            "component": "span",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "Please acknowledge that you have read and accept our "
              }
            }
          },
          {
            "component": "a",
            "fieldOptions": {
              "attrs": {
                "href": "#"
              },
              "domProps": {
                "innerHTML": "Terms and conditions"
              }
            }
          }
        ]
      },
      {
        "component": "div",
        "fieldOptions": {
          "class": [
            "form-check"
          ]
        },
        "children": [
          {
            "component": "input",
            "model": "consent",
            "errorOptions": {
              "class": [
                "is-invalid"
              ]
            },
            "valueProp": "checked",
            "fieldOptions": {
              "class": [
                "form-check-input"
              ],
              "on": "change",
              "attrs": {
                "id": "consent-yes",
                "name": "name",
                "type": "radio"
              },
              "domProps": {
                "value": true
              }
            }
          },
          {
            "component": "label",
            "fieldOptions": {
              "attrs": {
                "for": "consent-yes"
              },
              "class": [
                "form-check-label"
              ],
              "domProps": {
                "innerHTML": "Yes, I agree"
              }
            }
          }
        ]
      },
      {
        "component": "div",
        "fieldOptions": {
          "class": [
            "form-check"
          ]
        },
        "children": [
          {
            "component": "input",
            "model": "consent",
            "errorOptions": {
              "class": [
                "is-invalid"
              ]
            },
            "valueProp": "checked",
            "fieldOptions": {
              "class": [
                "form-check-input"
              ],
              "on": "change",
              "attrs": {
                "id": "consent-no",
                "name": "name",
                "type": "radio"
              },
              "domProps": {
                "value": false
              }
            }
          },
          {
            "component": "label",
            "fieldOptions": {
              "attrs": {
                "for": "consent-no"
              },
              "class": [
                "form-check-label"
              ],
              "domProps": {
                "innerHTML": "No, I do not agree"
              }
            }
          }
        ]
      }
    ]
  },
  {
    "component": "transition",
    "fieldOptions": {
      "props": {
        "name": "fade",
        "mode": "out-in"
      }
    },
    "children": [
      {
        "component": "div",
        "model": "consent",
        "errorHandler": true,
        "displayOptions": {
          "model": "consent",
          "schema": {
            "not": {
              "type": "boolean"
            }
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "This field is required"
              }
            }
          }
        ]
      },
      {
        "component": "div",
        "model": "consent",
        "errorHandler": true,
        "displayOptions": {
          "model": "consent",
          "schema": {
            "const": false
          }
        },
        "fieldOptions": {
          "class": [
            "alert alert-danger"
          ]
        },
        "children": [
          {
            "component": "div",
            "fieldOptions": {
              "domProps": {
                "innerHTML": "You must consent to our terms and conditions to submit this form."
              }
            }
          }
        ]
      }
    ]
  }
]

同じような記述が重複してしまっていることがわかります。モデルの型とコンポーネントは完全に 1:1 の関係にあるわけではありませんが、ある程度はまとめて記述してしまいたいですよね。そこで、手前味噌はありますが、uiSchema の記述を楽にするヘルパーライブラリ ui-schema-generatorGitHubNPM 上で公開しています。利用例です:

<template>
  <v-app>
    <v-container>
      <vue-form-json-schema v-model="model" :schema="schema" :ui-schema="uiSchema" />
    </v-container>
  </v-app>
</template>

<script>
import VueFormJsonSchema from "vue-form-json-schema";
import "vuetify/dist/vuetify.min.css";

import JsonSchema from "./schema.json";
const Schema = jsonschema.definitions.Schema;

import generator from "ui-schema-generator";

export default {
  components: {
    VueFormJsonSchema
  },
  data() {
    return {
      model: {},
      schema: Schema,
      uiSchema: new generator(JsonSchema)
        // データオブジェクトのデフォルト値をセット
        .setDefaultFieldOptions({
          attrs: {
            outlined: true,
            // 値として function(model) を取ることもできる
            label: model => model,
            hint: model => Schema.properties[model].description
          },
          class: "mt-5"
        })
        // エラーオプションのデフォルト値をセット
        .setDefaultErrorOptions({
          attrs: {
            error: true
          }
        })
        // uiSchema を生成
        .generate(
          "div", // HTML タグ名
          undefined, // 要素と紐付けるモデル。未定義の場合は紐付けない
          // データオブジェクト
          {
            style: { backgroundColor: "#043c78", color: "white" },
            class: "pl-1"
          },
          // 子要素。UiSchemaGenerator のネストも可能
          new generator(JsonSchema)
            .generate("h1", [], { domProps: { innerHTML: "見出し" } })
            .toArray()
        )
        // 同じような uiSchema はまとめて生成することも可能
        .generate("v-text-field", ["firstName","familyName","address","country"], {
          on: "input",
          attrs: {
            clearable: true
          }
        })
        .toArray()
    };
  }
};
</script>

データオブジェクトのデフォルト値を設定できたり、uiSchema をまとめて出力できるようになったので、コードの記述量を大きく削減できるようになったかと思います。よろしければ使ってみてくださいね。

参考リンク


  1. Palenight テーマを利用しています?かわいいね 

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

【eslint-plugin-vue】Vue.js 3.xサポートを始めました

eslint-plugin-vueはVue.jsバージョン3.x(アルファ)(いわゆるvue-next)のサポートを始めました。
eslint-plugin-vueバージョン7.0.0-alpha.0以降で利用できます。

インストール or アップデート

まだalphaリリースなので@nextタグをつけてインストールしてください。

npm install -D eslint-plugin-vue@next

使い方

( 詳細はこちらを参照してください。 https://eslint.vuejs.org/user-guide/

ESLint設定

ESLint設定ファイル.eslintrc.*extendsに下記の設定を追加します。
( 詳細はこちらを参照してください。 https://eslint.vuejs.org/user-guide/#usage

.eslintrc.jsの場合:

.eslintrc.js
module.exports = {
  extends: [
    // ...
    // ↓追加
    'plugin:vue/vue3-recommended',
    // または、
    'plugin:vue/vue3-strongly-recommended',
    // または
    'plugin:vue/vue3-essential',
  ],
  // ...
}

すでに利用していて、extendsにVue.js 2.x用の設定を入れている場合は、Vue.js 3.x用の設定に置き換えてください。

.eslintrc.js
module.exports = {
  extends: [
    // ...
-    'plugin:vue/recommended',
+    'plugin:vue/vue3-recommended', // <- 'vue3-'ってつけてください。
  ],
  // ...
}

Vue.js 3.x用に追加されたルールセット

(上でも書きましたが)Vue.js 3.x用に下記のルールセットが利用できるようになりました。

下のルールセットの方が有効になるルールが多いです。

Vue.js 3.x用に追加された検証ルール

詳細はvuejs/eslint-plugin-vue - Release v7.0.0-alpha.0を参照してください。

vue/no-deprecated-filter

このルールはフィルターの使用を禁止します。

Vue.js 2.xまで使用できたフィルターですが、Vue.js 3.xでは廃止されます。(詳細はRFC0015を参照してください。)
このルールでVue.js 2.xの癖でうっかりフィルター書いてしまった場合や、Vue.js 3.xに移行する際に書き換えないといけない箇所として、検出できます。

この制限として、逆に<template>内でビット演算のOR|もレポートされてしまいますし、そもそもうまく解析できない場合があります。

vue/no-deprecated-v-bind-sync

このルールはv-bind:foo.sync.sync修飾子の使用を禁止します。

そもそも.sync修飾子が何なのか(何だったのか)はこちらの公式ドキュメントを参照してください。

この構文は、Vue.js 3.xでv-model:fooのような書き方に置き換えられます。(詳細はRFC0005を参照してください。)
なので、古い書き方は利用できなくなります。このルールは自動修正をサポートしているので、Vue.js 3.xへの移行で利用できると思います。

vue/no-lifecycle-after-await

RFC0013のComposition APIのonMountedなどのライフサイクルフックは非同期でコールしても登録されない制限があるそうです。
そのため明らかに非同期であるとわかるawaitの後のライフサイクルフックをエラーとして検出します。

async setup() {
  /* ✓ GOOD */
  onMounted(() => { /* ... */ })

  await doSomething()

  /* ✗ BAD */
  onMounted(() => { /* ... */ })
}

vue/no-ref-as-operand

RFC0013のComposition APIのrefでラップされた値は.valueを経由して値を扱います。TypeScriptを使っていれば概ねエラー検出されるような気もしますが、このルールでもいくつかの間違ったパターンをエラーとして検出します。

const count = ref(0)
const ok = ref(true)

/* ✓ GOOD */
count.value++
count.value + 1
1 + count.value
var msg = ok.value ? 'yes' : 'no'

/* ✗ BAD */
count++
count + 1
1 + count
var msg = ok ? 'yes' : 'no'

vue/no-setup-props-destructure

RFC0013のComposition APIのpropssetupの引数で与えられます。しかし、この値の取り出し方を間違えるとリアクティブに動作しなくなってしまうため、そのパターンをいくつかエラーとして検出します。

常に、propsからメンバーを取り出すように記述すると問題ありません。

/* ✓ GOOD */
setup(props) {
  watch(() => {
    console.log(props.count)
  })

  return () => {
    return h('div', props.count)
  }
}

次のように引数を分割代入にしたりするとリアクティブに動作しません。このルールではこのパターンをエラーとして検出します。

/* ✗ BAD */
setup({ count }) {
  watch(() => {
    console.log(count) // countの変更は検知されません。
  })

  return () => {
    return h('div', count) // countが変更されても更新されません。
  }
}

また次のようにトップレベルで値を取り出して利用する場合もリアクティブに動作しません。このルールではこのパターンもエラーとして検出します。

setup(props) {
  /* ✗ BAD */
  const { count } = props

  watch(() => {
    console.log(count) // countの変更は検知されません。
  })

  return () => {
    return h('div', count) // countが変更されても更新されません。
  }
}

Vue.js 3.x用に変更された検証ルール

vue/valid-template-root

Vue.js 2.xではテンプレートのトップレベルの要素は一つまででしたが、Vue.js 3.xではこの制限が無くなり、複数要素やテキストノードをルートに設定することができるようです。

このルールは今まで、「トップレベルの要素は一つ」というのをチェックしていましたが、このロジックはvue/no-multiple-template-rootという別のルールに切り出されvue/valid-template-rootではチェックしなくなりました。

vue/valid-v-model

RFC0005RFC0011v-modelAPIの動作が変更され、引数とカスタム修飾子が受け入れられるようになります。
vue/valid-v-modelではVue.js 2.xまで受け入れられなかった引数とカスタム修飾子をエラーとして報告していましたが、Vue.js 3.xでエラーにならないパターンは別のルールに切り出され、vue/valid-v-modelでは検出しないように変更されました。

Vue.js 3.x用に変更されたその他

あとは細かいことなのでリリースノートを参照してください。

最後に

Vue.js 3.x使っていてこんなルールも必要だ!というアイディアがある方
ぜひissueの投稿をお願いします!

あと、まだまだやることが残っているので、誰か手伝ってくれる方がいればプルリクお願いします!
https://github.com/vuejs/eslint-plugin-vue/issues/1035

なんなら僕のプルリクにレビューコメント書いてくれるだけでも嬉しいです!

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

toHaveBeenCalledWithを使った ...mapActions()の引数付きテスト

コンポーネントでのVuexのテスト

Vuexを使用した際に、Vueコンポーネント上でVuexのactionなどが正常に呼び出されたかどうかテストしたい場合があります。

methods: {
  setInitialYear() {
    //setYearが正常に呼び出されているかテストしたい
    this.setYear(initialYear);
  },
  ...mapActions("events", ["setYear"])
}

呼び出されたかどうかだけを調べる場合は、テスト上でコンポーネントを作成する時に、モックしたストアをlocalVueに渡すことで検証が可能です。

参考: Vuex と一緒に使用する

import { mount, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import Id from "@/pages/_id.vue";

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

let store;
let events;

const idFactory = () => {
  events = {
    namespaced: true,
    actions: {
      setYear: jest.fn()
    },
  };
  store = new Vuex.Store({
    modules: {
      events
    }
  });

  return mount(Id, {
    store,
    localVue
  });
};

describe("_id.vue", () => {
  it("setInitialYearを呼んだ際にsetYearが呼ばれる", () => {
    const wrapper = idFactory();
    wrapper.vm.setInitialYear();
    //名前空間.actions.メソッド名で、storeのモックを呼び出せる
    expect(events.actions.setYear).toHaveBeenCalled();
  });
});

しかし、ここはsetYear()が呼び出されているかどうかだけでなく、正しい引数が渡されているかどうかも確認したいところです。

モックしたsetYearが正しい引数を与えられて呼び出されたかどうかを確認するにはtoHaveBeenCalledWith()を使用します。

describe("_id.vue", () => {
  it("setInitialYearを呼んだ際にsetYearが呼ばれる", () => {
    const wrapper = idFactory();
    wrapper.vm.setInitialYear();
    //toHaveBeenCalledWithに変更
    expect(events.actions.setYear).toHaveBeenCalledWith(
      expect.any(Object),
      "2019",
      undefined
    );
  });
});

toHaveBeenCalledWith()の第一引数にはexpect.any(Object)、第二引数にsetYear()に渡している引数、第三引数にはundefinedを設定します。

第一引数のexpect.any()は、引数内で指定したコンストラクタで生成されたものすべてに一致します。つまり、Objectクラスのオブジェクトならマッチします。

わざとテストを失敗させると、第一引数にはcontextオブジェクトが入っていることがわかりました。

 {"commit": [Function anonymous], "dispatch": [Function anonymous], "getters": {}, "rootGetters": {}, "rootState": {"instructions": {}, "users": {}}, "state": {}}

ただ、ここでどうして第一引数にcontextオブジェクトが入り、第三引数にundefinedが入るのか分かりませんでした。どなたかアドバイスをいただけると嬉しいです。

参考: Update examples with module namespace and add unit tests

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