Vue.js+Vuetify+TypeScript+FirebaseでChrome Extensionを作った

任意のWEBサイトに自分用のメモを残すことができる Chrome Extension「memorun」を作って公開しました。plaintextモード or markdownモードでメモを取ることができます。よければ使ってみてください。この記事では、このChrome拡張開発のコアな部分が出来上がるまでについて紹介していこうと思います。

Web Storeリンク
chrome.google.com

紹介動画
youtu.be

使用している技術

  • Chrome Extension
  • Vue.js + Vuetify
  • TypeScript
  • Firebase
    • Authentication
    • Cloud Firestore
    • Cloud Function(この記事では出てきません)

Vue.jsを使ったChrome Extensionの雛形作成

ゼロから構築しようかと思ったのですが、そもそもChrome拡張のことをよく分かっておらず、標準的な構成を知るためにも、vue-web-exetnsionを使ってプロジェクトを作成をすることにしました。

npmインストール

まずはこちらを参照に、npmをインストールします。

vue-cliのインストール

vue-web-extensionはvue-cliが事前にインストールされている必要がありますので、vue-cliをインストールします。

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

テンプレートプロジェクト作成

1コマンドでテンプレートプロジェクトが作成されます。TypeScriptを指定することができないので、この時点ではJavaScriptが出力されます。

vue init kocal/vue-web-extension memorun_extension

? Project name memorun_extension
? Project description A Extension to take a memo.
? Author rinoguchi
? License 
? Use Mozilla's web-extension polyfill? (https://github.com/mozilla/webextension-polyfill) Yes
? Provide an options page? (https://developer.chrome.com/extensions/options) Yes
? Install vue-router? No
? Install vuex? No
? Install axios? No
? Install ESLint? Yes
? Pick an ESLint preset Standard
? Install Prettier? No
? Automatically install dependencies? npm

プロジェクトフォルダに移動して、development用にExtensionをbuildします。

cd memorun_extension
npm run build:dev

これにより、/dist/にExtensionコードが出力されます。

開発中の拡張機能を動作させる

chrome://extensions/ にアクセスし、右上のデベロッパーモードをONにすると、パッケージ化されていない拡張機能を読み込むボタンが表示されます。

そのボタンをクリックし、さきほど作成されたdistフォルダを指定すると、開発中の拡張機能がインストールされます。

f:id:rinoguchi:20200504161239p:plain

拡張機能のアイコンをクリックすると、「Hello world!」と表示されます。

f:id:rinoguchi:20200504161535p:plain

Hot Reload

vue-web-extensionで作ったプロジェクトでは、npm run watch:devで変更を検知してリランしてくれます。さらに、webpack-extension-reloaderを使って、ブラウザに登録しているExtensionを更新してくれます。
これを毎回手作業でやるのはやる気が起きなかったのでとても助かります。

TypeScript化

vue-cliでTypeScriptのプロジェクトを作成してみて、差分を比較しながらTypeScript化していきました。変更/追加したファイルの変更点を列挙していきます。

package.json

  • webextension-polyfillは、W3 Browser Extensions groupで標準化されたWebExtension APIを使用するExtensionを、最小限の変更で各ブラウザ上で動かすためのものらしいですが、これはTypeScript版に変えています
  • class-style syntaxでVue Componentを記載できるようにするためにvue-class-componentを、propsなどをデコレータで記載できるようにするためにvue-property-decoratorをインストールしています。
    • vue-property-decoratorはvue-class-componentに依存しているためpackage.jsonには記載不要
    • Class APIはVue 3のRFCから落ちたため、Vue 3にバージョンアップする際は変更が必要
   "dependencies": {
-    "webextension-polyfill": "^0.3.1"
+    "webextension-polyfill-ts": "^0.15.0",
+    "vue-property-decorator": "^8.4.2"
   },
  "devDependencies": {
+    "ts-loader": "^7.0.2",
+    "typescript": "^3.8.3"
  },

webpack.config.js

  • entryのファイル名を*.js*.tsに変更しています
  • import文で拡張子を書かなくて済むようにresolveに.tsを追加します
  • ts-loaderでTypeScriptをcompileします。また、.vueをtsモジュールとしてcompile対象にしています
 const config = {
   entry: {
-    'background': './background.js',
-    'popup/popup': './popup/popup.js',
-    'options/options': './options/options.js',
+    'background': './background.ts',
+    'popup/popup': './popup/popup.ts',
+    'options/options': './options/options.ts',
   },
   output: {
     path: __dirname + '/dist',
     filename: '[name].js',
   },
   resolve: {
-    extensions: ['.js', '.vue'],
+    extensions: ['.js', '.vue', '.ts'],
   },
   module: {
     rules: [
       {
         test: /\.vue$/,
         loader: 'vue-loader',
       },
+      {
+        test: /\.ts$/,
+        use: [
+          {
+            loader:'ts-loader',
+            options: {
+              appendTsSuffixTo: [/\.vue$/]
+            }
+          }
+        ],
+        exclude: /node_modules/
+      },
      ]
  }

tsconfig.json

  • typescriptのcompile設定です。基本はVue.jsの推奨構成に合わせてあります
  • experimentalDecoratorsvue-property-decoratorを使ったデコレーションでワーニングが発生しないようにするためのオプションです
{
  "compilerOptions": {
    "target": "es5",
    "strict": true,
    "module": "es2015",
    "moduleResolution": "node",
    "experimentalDecorators": true,
  }
}

shims-vue.d.ts

eslintでCannot find moduleエラーが出ることを回避する目的で追加しています。

declare module '*.vue' {
  import Vue from 'vue'
  export default Vue
}

App.vue

  • ほぼ同じ実装のpopup/App.vueとoptions/App.vueがありますが、同様に変更します。
  • Vueインターフェースを継承する形でclassを定義し、@Componentで装飾しています。もともとdata()で管理してたプロパティはclassプロパティとして定義します(今回はmessageというプロパティを追加してますが)。
 <template>
   <div>
-    <p>Hello world!</p>
+    <p>{{ message }}</p>
   </div>
 </template>

-<script>
-export default {
-  data () {
-    return {}
-  }
+<script lang="ts">
+import { Component, Vue } from 'vue-property-decorator'
+
+@Component
+export default class App extends Vue {
+  message = 'Hello world with TypeScript!'
 }
 </script>

popup.ts, options.ts

  • 拡張子を*.jsから*.tsに変更して、型を記載します
  • 2ファイルとも同様に、browserオブジェクトをwebextension-polyfill-tsのものに変更します
 import Vue from 'vue'
-import App from './App'
+import App from './App.vue'
+import { browser } from "webextension-polyfill-ts"
 
-global.browser = require('webextension-polyfill')
-Vue.prototype.$browser = global.browser
+Vue.prototype.$browser = browser
 
 /* eslint-disable no-new */
 new Vue({
   el: '#app',
 
-   render: h => h(App)
+  render: (h: CreateElement): VNode => h(App),
 })

background.js

とりあえず、この時点ではやりたい処理もないので、拡張子だけ'.ts'に変えて処理は全削除します。

動作確認

ここまで実装すれば一旦TypeScript化完了です。

f:id:rinoguchi:20200506120651p:plain

ESLintとPrettierの設定

本格的に開発を開始する前に、LinterとFormatterを設定しようと思います。 こちらの記事をありがたく参考にさせていただきつつ、エラーが出たところなどを修正して適用しました。

まずは必要なライブラリを追加します。

npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-vue
npm install --save-dev prettier eslint-plugin-prettier eslint-config-prettier

次にeslintrc.jsの記述を修正します。以下の設定にしてあります。

  • 文字列はシングリクォートで囲み、セミコロンは省略する
  • prettier側の設定に合わせ、attributeは3つまで1行に記載OK
 module.exports = {
   root: true,
+  parser:  'vue-eslint-parser',
   parserOptions: {
-    parser: 'babel-eslint'
+    parser: '@typescript-eslint/parser'
   },
   env: {
     browser: true,
     webextensions: true,
   },
   extends: [
     // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
     // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
-    'plugin:vue/essential',
+    'plugin:vue/recommended',
     // https://github.com/standard/standard/blob/master/docs/RULES-en.md
-    'standard'  ],
+    'standard',
+    'plugin:@typescript-eslint/recommended',
+    'prettier/@typescript-eslint',
+    'plugin:prettier/recommended',
+  ],
   // required to lint *.vue files
   plugins: [
     'vue'
   ],
   // add your custom rules here
   rules: {
     // allow async-await
     'generator-star-spacing': 'off',
     // allow debugger during development
-    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
+    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
+    'prettier/prettier': [
+      'error',
+      {
+        'singleQuote': true,
+        'semi': false
+      }
+    ],
+    "vue/max-attributes-per-line": ["error", {
+      "singleline": 3,
+    }],
   }
 }

これでES Lintは有効になっていますが、Prettierによるautofixがまだ有効になってないので、最後にVS Codeのsettings.jsonを修正します。

{
    "editor.formatOnSave": false,
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    },
    "eslint.validate": [
        "javascript",
        "typescript",
        "vue",
    ]
}

これで設定完了です。大量にerrorやwarningがでますが、ファイル開いて保存し直すとautofixされて綺麗なコードになります。

メモ表示・編集画面を作成

マテリアルデザインのVue用UIライブラリであるVuetifyを使って画面を作っていこうと思います。

Vuetifyを導入

こちらにしたがって導入をします。

VuetifyのUI Componentを利用する箇所で毎回moduleをimportせず、自動的にimportしてくれるvuetify-loaderを利用するケースについて記載しています。

依存ライブラリを追加して、

npm install --save vuetify
npm install --save-dev sass sass-loader fibers deepmerge -D
npm install --save-dev vuetify-loader
npm install --save @mdi/font -D # Material Design Iconsを使う場合

webpack.config.jsのrulespluginsを変更します。

+const VuetifyLoaderPlugin = require('vuetify-loader/lib/plugin')

 const config = {
   module: {
     rules: [
       {
-        test: /\.css$/,
-        use: [MiniCssExtractPlugin.loader, 'css-loader'],
-      },
-      {
-        test: /\.scss$/,
-        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
-      },
-      {
-        test: /\.sass$/,
-        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader?indentedSyntax'],
+        test: /\.(css|scss|sass)$/,
+        use: [
+          'vue-style-loader',
+          'css-loader',
+          {
+            loader: 'sass-loader',
+            // Requires sass-loader@^7.0.0
+            options: {
+              implementation: require('sass'),
+              fiber: require('fibers'),
+              indentedSyntax: true // optional
+            },
+            // Requires sass-loader@^8.0.0
+            options: {
+              implementation: require('sass'),
+              sassOptions: {
+                fiber: require('fibers'),
+                indentedSyntax: true // optional
+              },
+            },
+          },
+        ],
       },
     ]
   },
   plugins: [
+     new VuetifyLoaderPlugin(),
   ],
 }

また、/src/plugins/vuetify.tsファイルを作成します。Material Design Iconsを使う場合の設定になってます。

import '@mdi/font/css/materialdesignicons.css' // mdiのiconを使う場合
import Vue from 'vue'
import Vuetify from 'vuetify/lib'

Vue.use(Vuetify)

export default new Vuetify({
  icons: { // mdiのiconを使う場合
    iconfont: 'mdi',
  },
})

このままだと、Could not find a declaration file for module 'vuetify/lib'.と怒られるので、tsconfig.jsoncompilerOptionsに以下の設定を追加します。

{
  "compilerOptions": {
+    "types": [
+      "vuetify"
+    ],
  }
}

ここまでで一旦Vuetifyそのものの導入は完了です。

メモ表示・編集画面を作成

この時点では、以下のような画面を作成しました。

f:id:rinoguchi:20200507142533g:plain

/src/components/Memo.vueを作成し、Vueコンポーネントを作ります。

VuetifyのUIコンポーネントを使って作成してます。シンプルな内容なので見ればわかると思います。

<template>
  <v-container fluid>
    <v-row>
      <v-col cols="12">
        <v-card height="500">
          <v-card-text>
            <template v-if="memo">
              <div v-show="editing">
                <v-form v-show="editing">
                  <v-textarea
                    v-model="memo.body"
                    full-width
                    rows="16"
                    dense
                    filled
                    no-resize
                    placeholder="Input memo here."
                  />
                </v-form>
              </div>
              <div v-show="!editing">
                <pre v-show="memo.body">{{ memo.body }}</pre>
                <pre v-show="!memo.body">
Click <v-icon>mdi-pencil</v-icon> and take a memo.
                </pre>
              </div>
              <v-btn
                small
                fab
                absolute
                top
                style="right: 60px;"
                color="info"
                :disabled="editing"
                @click="handleEditBtn"
              >
                <v-icon>mdi-pencil</v-icon>
              </v-btn>
              <v-btn
                small
                fab
                absolute
                top
                right
                color="success"
                :disabled="!editing"
                @click="handleSaveBtn"
              >
                <v-icon>mdi-content-save</v-icon>
              </v-btn>
            </template>
          </v-card-text>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'

class MemoModel {
  id: string | null = null
  body = ''
  mode = 'plane' // TODO: 将来的にmarkdownやwysiwygを選べるように
}

@Component
export default class Memo extends Vue {
  @Prop() private uid!: string // この時点では使わない
  @Prop() private url!: string // この時点では使わない
  private memo: MemoModel | null = null
  private editing = false

  created(): void {
    this.memo = new MemoModel() // TODO: DBから取得
  }

  handleEditBtn(): void {
    this.editing = true
  }

  handleSaveBtn(): void {
    this.editing = false
    // TODO: DBに保存
  }
}
</script>

最後に、/src/popup/App.vueで先ほど作成したMemoコンポーネントを利用するように変更します。<v-app>配下でVuetifyコンポーネントが使えます。

 <template>
-  <p>{{ message }}</p>
+  <v-app>
+    <Memo :uid="'dummy'" :url="'dummy'" />
+  </v-app>
 </template>
 
 <script lang="ts">
 import { Component, Vue } from 'vue-property-decorator'
+import Memo from '../components/Memo.vue'
 
-@Component
+@Component({
+  components: { Memo }
+})
 export default class App extends Vue {
-  message = 'Hello world with TypeScript!'
 }
 </script>

Firebase AuthenticationでGoogle OAuth認証

メモの内容をユーザ毎にCloud Firestoreに保存したいと思います。そのためにはFirebase Authenticationで認証を行う必要があります。Chrome拡張なのでGoogle OAuth認証を利用することにしました。

処理の流れ

別記事でも書いたのですが、色々な方法があって悩みましたが、こちらのサイトの方法がポップアップ画面も表示されず、ユーザ体験が一番スマートだと思うので、これを採用させてもらいました。

具体的には、ポップアップページのcreatedフックで、chrome.identity APIを使ってaccess tokenを取得し、access tokenを元にFirebase Authenticationを使ってGoogle OAuth認証を行う形です。

Firebaseコンソールでの設定

  1. Firebase プロジェクト作成
  2. アプリを登録
    • Firebaseコンソールのプロジェクトの概要ページのWEBアイコン(<>)からアプリを登録します。Hostingは今回利用しないのでOFFにしました。
  3. Google ログインを有効化
    • Firebaseコンソールの Authentication -> Sign-in method で Google を有効にします
  4. Chrome拡張を承認済みDomainに追加
    • こちらに書いてある通り、Authentication -> Sign-in method -> 承認済みドメインchrome-extension://CHROME_EXTENSION_IDの形式でドメインを追加します
    • CHROME_EXTENSION_IDChrome拡張の管理ページで確認できます

アプリでFirebaseを使えるようにする

こちらをみながら作業を進めます。

まずはライブラリを追加します

npm install --save firebase

次に/src/plugins/firebase.tsを追加します。firebaseConfigの設定内容は、Firebaseコンソールで プロジェクトの概要 -> 対象のアプリ のSetting画面を開くとその中ほどに表示されているので、それをコピペします。
Firebase側で承認済みドメインを設定しているものの、ソースコード上にapiKeyが露出していることに不安を感じますが、こちらを参照すると大丈夫なようです。ただし、Firestoreを使う時にはセキュリティルールをちゃんと設定する必要はありそうです。

import * as firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'

const firebaseConfig = {
  apiKey: 'xxxxx',
  authDomain: 'xxxxx.firebaseapp.com',
  databaseURL: 'https://xxxxx.firebaseio.com',
  projectId: 'xxxxx',
  storageBucket: 'xxxxx.appspot.com',
  messagingSenderId: 'xxxxx',
  appId: 'xxxxx'
}

firebase.initializeApp(firebaseConfig)

export default firebase

最後に、webpack.config.js内でbuild時にcontent_security_policyを上書きする処理があるので、そこを書き換えます。(vue-web-extensionを使っていない場合は、このwebpack.config.jsの処理は存在しないので、manifest.jsonを直接書き換えれば良いです)

     if (config.mode === 'development') {
-      jsonContent['content_security_policy'] = "script-src 'self' 'unsafe-eval'; object-src 'self'";
+      jsonContent['content_security_policy'] = "script-src 'self' https://apis.google.com 'unsafe-eval'; object-src 'self'";
+    } else {
+      jsonContent['content_security_policy'] = "script-src 'self' https://apis.google.com; object-src 'self'";
     }

ここまでで、Firebaseの機能をアプリ内で使うことができるようになりました。

OAuth クライアントIDを作成

こちらから、

  • 認証情報を作成
  • OAuth クライアントID
  • Chrome アプリ
  • アプリケーションIDにCHROME_EXTENSION_IDを指定

を行い、OAuth クライアントIDを作成します。

manifest.jsonに設定追加

さらに、manifest.jsonの記述を追加して、chrome.identity APIとOAuth2認証を有効にします。
認証とは直接関係ないのですが、後ほど開いているタブのURLを取得する必要があるので、chrome.tabs APIも有効にしています。
利用可能なchromeAPIの種類はこちらを、Google OAuth2 認証のスコープはこちらを参照ください。

+  },
+  "permissions": [
+    "identity",
+    "tabs"
+  ],
+  "oauth2": {
+    "client_id": "********************************.apps.googleusercontent.com",
+    "scopes": ["https://www.googleapis.com/auth/userinfo.email"]
   }
 }

ちなみに、manifest.jsonに"key"を指定すると、Chrome拡張のIDを固定することができるらしく、こちらのサイトでもそうしろと書いてあるのですが、webpack-extension-reloaderとの相性が良くないらしくどんどん新しく別IDのChrome拡張がChromeに登録されていくので、自分は設定していません。

認証処理を追加

まずは、chrome.identityを利用するため、chromeモジュールの型定義を追加して

npm install --save-dev @types/chrome

tsconfig.jsonに以下を追記します。

     "types": [
-      "vuetify"
+      "vuetify", "chrome"
     ],

最後に、/src/popup/App.vueを以下のように修正し、ポップアップページで認証を行うようにしました。

  • uidが取得できてなければ(=未認証なら)ローディングiconを表示する
  • createdフックで、chrome.tabs APIを使って開いているタブのURLを取得し、さらに、firebase.auth().onAuthStateChangedを使ってFirebase Authenticationの認証状態を監視スタートした上で、実際の認証処理(startAuth)を実行する
  • startAuthの中では、まずchrome.identity.getAuthTokenaccess tokenを取得する(もしブラウザがGoogleログインしてない状態であればGoogle認証画面が別タブで表示され、Google認証を行った上で、access tokenを取得する)。取得したaccess tokenを元にFirebaseログインを実行する
 <template>
   <v-app>
-    <Memo :uid="'dummy'" :url="'dummy'" />
+    <template v-if="uid && url">
+      <Memo :uid="uid" :url="url" />
+    </template>
+    <template v-else>
+      <v-container fluid>
+        <v-row>
+          <v-col cols="12" class="text-center">
+            <v-progress-circular
+              :size="100"
+              :width="15"
+              color="grey"
+              indeterminate
+            />
+          </v-col>
+        </v-row>
+      </v-container>
+    </template>
   </v-app>
 </template>
 
 <script lang="ts">
 import { Component, Vue } from 'vue-property-decorator'
 import Memo from '../components/Memo.vue'
+import firebase from '../plugins/firebase'
+
 
 @Component({
   components: { Memo }
 })
 export default class App extends Vue {
-  message = 'Hello world with TypeScript!'
+  private uid: string | null = null
+  private url: string | null = null
+  
+  created(): void {
+    chrome.tabs.getSelected((tab: chrome.tabs.Tab) => {
+      if (!tab.url) {
+        throw new Error('Could not get tab url.')
+      }
+      this.url = encodeURIComponent(tab.url.split(/[?#]/)[0])
+    })
+
+    firebase.auth().onAuthStateChanged((authUser: firebase.User | null) => {
+      if (authUser === null) {
+        this.uid = null
+      } else {
+        this.uid = authUser.uid
+      }
+    })
+  
+    this.startAuth(true)
+  }
+  
+  startAuth(interactive: boolean): void {
+    chrome.identity.getAuthToken(
+      { interactive: !!interactive },
+      (token: string) => {
+        if (chrome.runtime.lastError && !interactive) {
+          console.log('It was not possible to get a token programmatically.')
+        } else if (chrome.runtime.lastError) {
+          console.error(chrome.runtime.lastError)
+        } else if (token) {
+          const credential = firebase.auth.GoogleAuthProvider.credential(
+            null,
+            token
+          )
+          firebase
+            .auth()
+            .signInWithCredential(credential)
+            .catch((error: any) => {
+              if (error.code === 'auth/invalid-credential') {
+                chrome.identity.removeCachedAuthToken({ token: token }, () => {
+                  this.startAuth(interactive)
+                })
+              }
+            })
+        } else {
+          console.error('The OAuth Token was null')
+        }
+      }
+    )
+  }
 }
 </script>

これでやっとFirebase Authenticationを使ってGoogle OAuth認証を行うことができました。

Firestoreにデータ保存

Cloud Firestoreにデータを保存するようにしたいと思います。LocalStorageやIndexedDBに保存するかはだいぶ迷ったのですが、同じGoogleアカウントで別マシンでも利用できるようにしたいので、Firestoreにすることにしました。

データモデル

こちらを参照しつつデータモデルを決めました。usersの下にmemosが位置するようなシンプルな形です。
documentとは値にマッピングされるフィールドを含む軽量なレコード、collectionとはdocumentのコンテナです。documentの中にsub collectionを持つこともできます。

collection: users // ユーザを格納するcollectiondocument id: "xxxxx1"document id: "xxxxx2" // Google OAuth認証で取得したuidをdocument idとして利用
  ┣ defaultMode: "plain" // デフォルトの記載モード。いずれmarkdownやwysiwygモードを追加予定
  ┗ sub collection: memos // メモを格納するsub_collection
    ┣ document id: "https%3A%2F%2Fhoge.com"
    ┗ document id: "https%3A%2F%2Ffuga.com" // エンコードしたURLをidとして利用
      ┣ mode: "plain": // 記載モード
      ┗ body: "xxxxx" // メモ本文

データベース作成

こちらを参考に設定をしました。
まずコンソールから先ほど作成したプロジェクトを選択し、Databaseセクションでデータベースの作成を行います。 その際、セキュリティルールは本番環境で開始、ロケーションはasia-east2を選択することにします。

セキュリティルールで本番環境で開始を選んだ場合、常にread/writeできない設定になっていますので、こちらを参考に、Authenticationで認証したuidに紐づくメモだけをread/writeできるようにセキュリティルールを変更しました。

  • ワイルドカード変数には該当するdocumentのIDがセットされるので、uidにはuser documentのIDがセットされます
  • request変数にはクライアントからのリクエスト情報がセットされます。request.authには認証済みであれば認証情報がセットされます
  • 今回使っていませんが、resource変数には、データベースのデータの中身がセットされますので、documentのID以外のプロパティと何か比較する場合は利用することになりそうです。
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{uid} {
      allow create: if request.auth.uid != null;
      allow read, update, delete: if request.auth.uid == uid;
    }
    match /users/{uid}/memos/{memo} {
      allow read, create, update, delete: if request.auth.uid == uid;
    }
  }
}

User Documentが存在しなかったらデータ追加

認証が完了した際に、User Documentが存在しなかったら追加するようにします。ポップアップページで認証を行なっているので、/src/popupApp.vueに処理を組み込んでいきます。

  • ログインユーザのuidを元にUser Documentを検索し、存在しなかったらデータを追加しています
  • User情報を格納するためのUserModelクラスとUser DocumentにアクセスするためのUserRepositoryクラスを作成しています。個人的にはこの辺はクラス化しておいた方が責務が明確になり見通しがよくなる気がします。将来的には別ディレクトリに移動するかもしれません
  • UserRepositoryクラスは、User Documentを参照するためのDocumentReferenceを通じて、Documentを取得したり保存したりする関数を提供します。DocumentReference.set()関数は、データが存在しなければ追加、存在すれば更新してくれるのが便利です
  • API呼び出し系は基本どれも非同期で動くため、callbackのnestが気になってくるので、async、awaitを使って同期的に呼び出す形にしてあります
 <template>
   <v-app>
-    <template v-if="uid && url">
-      <Memo :uid="uid" :url="url" />
+    <template v-if="user && user.id && url">
+      <Memo :uid="user.id" :url="url" />
     </template>
     <template v-else>
(中略)
 import firebase from '../plugins/firebase'
+import DocumentReference = firebase.firestore.DocumentReference
+import DocumentData = firebase.firestore.DocumentData
+import DocumentSnapshot = firebase.firestore.DocumentSnapshot

+class UserModel {
+  id: string | null = null
+  defaultMode = 'plane' // TODO: 将来的にmarkdownやwysiwygを選べるように
+}
+
+class UserRepository {
+  userRef: DocumentReference<DocumentData>
+  constructor(uid: string) {
+    this.userRef = firebase.firestore().collection('users').doc(uid)
+  }
+
+  async get(): Promise<UserModel> {
+    return this.userRef
+      .get()
+      .then((userData: DocumentSnapshot<DocumentData>) => {
+        const user = new UserModel()
+        if (userData.exists) {
+          user.id = userData.id
+          user.defaultMode = userData.get('defaultMode')
+        }
+        return user
+      })
+      .catch((error: any) => {
+        throw new Error(`Error getting user:, ${error}`)
+      })
+  }
+
+  async save(user: UserModel): Promise<UserModel> {
+    return this.userRef
+      .set({
+        defaultMode: user.defaultMode,
+      })
+      .then(() => {
+        console.log('User successfully saved!')
+        return this.get()
+      })
+      .catch((error: any) => {
+        throw new Error(`Error saving memo:, ${error}`)
+      })
+  }
+}

 @Component({
   components: { Memo },
 })
 export default class App extends Vue {
-  private uid: string | null = null
+  private user: UserModel | null = null
   private url: string | null = null
 
-  created(): Promise<void> {
+  async created(): Promise<void> {
     chrome.tabs.getSelected((tab: chrome.tabs.Tab) => {
       if (!tab.url) {
         throw new Error('Could not get tab url.')
       }
       this.url = encodeURIComponent(tab.url.split(/[?#]/)[0])
     })

-    firebase.auth().onAuthStateChanged((authUser: firebase.User | null) => {
+    firebase.auth().onAuthStateChanged(async (authUser: firebase.User | null) => {
       if (authUser === null) {
-         this.uid = null
+         this.user = null
       } else {
-         this.uid = authUser.uid
+        const userRepository = new UserRepository(authUser.uid)
+        this.user = await userRepository.get()
+        if (!this.user.id) {
+          await userRepository.save(this.user)
+        }
+      } 
     })

Memo Documentを取得および保存

メモ画面Memo.vueも似たような感じで処理を追加していきます。

  • createdフックでFirestoreからメモデータを取得し、保存ボタンがクリッククリックされたらデータを保存します。
  • Userの場合と同様にMemoModelMemoRepositoryを作成して、それらを利用してMemoの取得や保存を行うようにします。
 <script lang="ts">
 import { Component, Prop, Vue } from 'vue-property-decorator'
+import firebase from '../plugins/firebase'
+import DocumentReference = firebase.firestore.DocumentReference
+import DocumentData = firebase.firestore.DocumentData
+import DocumentSnapshot = firebase.firestore.DocumentSnapshot
(中略)

+class MemoRepository {
+  memoRef: DocumentReference<DocumentData>
+  constructor(uid: string, url: string) {
+    this.memoRef = firebase
+      .firestore()
+      .collection('users')
+      .doc(uid)
+      .collection('memos')
+      .doc(url)
+  }
+
+  async get(): Promise<MemoModel> {
+    return this.memoRef
+      .get()
+      .then((memoData: DocumentSnapshot<DocumentData>) => {
+        const memo = new MemoModel()
+        if (memoData.exists) {
+          memo.id = memoData.id
+          memo.body = memoData.get('body')
+          memo.mode = memoData.get('mode')
+        }
+        return memo
+      })
+      .catch((error: any) => {
+        throw new Error(`Error getting memo:, ${error}`)
+      })
+  }
+
+  async save(memo: MemoModel): Promise<MemoModel> {
+    return this.memoRef
+      .set({
+        body: memo.body,
+        mode: memo.mode,
+      })
+      .then(() => {
+        console.log('Memo successfully saved!')
+        return this.get()
+      })
+      .catch((error: any) => {
+        throw new Error(`Error saving memo:, ${error}`)
+      })
+  }
+}

 @Component
 export default class Memo extends Vue {
   @Prop() private uid!: string
   @Prop() private url!: string
   private memo: MemoModel | null = null
   private editing = false
+  private memoRepository: MemoRepository = new MemoRepository(
+    this.uid,
+    this.url
+  ) 

-  created(): void {
-    this.memo = new MemoModel() // TODO: DBから取得
+  async created(): void {
+    this.memo = await this.memoRepository.get()
   }

   handleEditBtn(): void {
     this.editing = true
   }
 
   handleSaveBtn(): void {
     this.editing = false
-    // TODO: DBに保存
+    if (this.memo) this.memo = await this.memoRepository.save(this.memo)
   }
 }
 </script>

不正アクセステスト

ポップアップ画面を表示した際の、createdフックで自分以外のuid(test_user)にアクセスする実装を書いてみて動作させて、セキュリティルールが機能しているか確かめたいと思います。

  created(): void {
    firebase
      .firestore()
      .collection('users')
      .doc('other users uid')
      .get()
      .then((doc) => {
        console.log(doc)
      })
      .catch((error: any) => {
        console.log('Error getting document:', error)
      })
  }

上記実装して、画面を表示したところ、ちゃんと権限が足りないというエラーメッセージが出力されましたので、大丈夫そうです。

Error getting document: FirebaseError: Missing or insufficient permissions.

Chrome拡張の公開

ここまでで最低限の機能はできたので、Chrome Web Storeに公開することにしました。公開手順については、別で記事を起こしましたので、そちらを参照ください。

rinoguchi.hatenablog.com

トラブルシューティング

上記の記事では解決後の結果だけ書いてますが、解決に時間がかかったやつはエラーの内容や原因も含めてまとめておきます。

.vue モジュールがNot Foundになってしまう

TS2307: Cannot find module './App.vue'

import App from './App.vue'のような感じで.vueファイルをimportしようとするとmoduleが見つからないというエラーが発生します。これはts-loaderが.vueをtsファイルとして認識させるようにすれば良いみたいす。

こちらを参考にwebpack.config.jsに

  module: {
    rules: [
      {
        test: /\.ts$/,
        loader: "ts-loader",
        options: { appendTsSuffixTo: [/\.vue$/] },
        exclude: /node_modules/
      }
    ]
  }

のように実装すると今度は、次のエラーが発生します。

Error: options/query provided without loader (use loader + options)

loaderとoptionsを一緒に指定する必要があるっぽいです。useを使って以下のようにloaderとoptionsをまとめてセットすると回避できました。

  module: {
    rules: [
      {
        test: /\.ts$/,
        use: [
          {
            loader:'ts-loader',
            options: {
              appendTsSuffixTo: [/\.vue$/]
            }
          }
        ],
        exclude: /node_modules/
      }
    ]
  }

VS Code上のエラーが消えない

上記対応をすると、アプリケーションは正常に動くようになるのですが、VS Code上のエラーが消えてくれません。 f:id:rinoguchi:20200504192154p:plain

これを解決するために、型定義ファイルshims-vue.d.tsを作成しました。

declare module '*.vue' {
  import Vue from 'vue'
  export default Vue
}

content_security_policyの設定がおかしくてChrome拡張から外部スクリプトを読み込めない

manifest.json

"content_security_policy": "script-src 'self' https://apis.google.com/; object-src 'self'",

と記載しているにも関わらず、以下のエラーが発生しました。

Refused to load the script 'https://apis.google.com/js/api.js' because it violates the following Content Security Policy directive: "script-src 'self' 'unsafe-eval'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

実際動いているものはどうなってるのかと、npm run build:dev/dist/にcopyされたmanifest.jsonを参照すると、なんと

"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"

に置き換わってしまっていました。

誰がこれを置き換えているのかというと、webpack.config.js

if (config.mode === 'development') {
  jsonContent['content_security_policy'] = "script-src 'self' 'unsafe-eval'; object-src 'self'";
}

という処理だったので、いらんことするな!とこの処理を消してみると今度は

Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' https://apis.google.com".

という別のエラーが発生します。developmentモードでは文字列をevalしてるみたいです。

というわけで最終的には、manifest.jsonからはcontent_security_policyの設定は削除し、webpack.config.jsを以下のように書き換え、developmentモードではevalを許可するようにました。

if (config.mode === 'development') {
  jsonContent['content_security_policy'] = "script-src 'self' https://apis.google.com 'unsafe-eval'; object-src 'self'";
} else {
  jsonContent['content_security_policy'] = "script-src 'self' https://apis.google.com; object-src 'self'";
}

ちなみに、content_security_policyの設定の仕方はこちらがわかりやすかったです。script-src 'self' https://apis.google.com 'unsafe-eval';の部分は、scriptのsourceは、以下の三つを許可するというような意味になります。

  • self: 自分自身(同一オリジン)が提供するsource
  • https://apis.google.com: このドメインが提供するsource
  • unsafe-eval: eval()を使って文字列からsource codeを作る

webpack-extension-reloaderでChromeExtensionをリロードする際にbackground.jsでWebSocket 接続エラー

ISSUEにも上がっているのですが以下のようなエラーが発生します。

WebSocket connection to 'ws://localhost:9090/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED

npm run watch:devでビルドしたbackground.jsには、Chrome側でws://localhost:9090を監視して、変更があったら拡張をリロードするような処理が組み込まれているのですが、npm run watch:devを停止している時はws://localhost:9090も停止するため、上記のエラーが発生します。なのでこのエラーは気にしなくて大丈夫です。

ちなみに、npm run build:devでビルドしたコードには、上記の監視処理は含まれないのでこのエラーは発生しません。

バンドルサイズが大きすぎる

本番用コードをnpm run buildで出力したところ、バンドルサイズが大きすぎるよという警告が出ましので、バンドルサイズを削減する必要があります。

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB). This can impact web performance.
WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.

こちらに関しては、長くなるので別記事でまとめました。

Vue+Firebaseで作ったフロントエンドアプリのバンドルサイズを削減する - rinoguchiのメモブログ

TS2705: An async function or method in ES5/ES3 requires the 'Promise' constructor. Make sure you have a declaration for the 'Promise' constructor

こちらを参考に、tsconfig.jsonに以下の設定を加えることでエラーが出なくなりました。

{
  "compilerOptions": {
    "lib": [
      "es2015"
    ]
  }
}

また、この対応をしたところ、console.log()を使っている箇所で

TS2584: Cannot find name 'console'. Do you need to change your target library? Try changing the `lib` compiler option to include 'dom'.

という別のエラーが出るようになったため、domlibに追加しました。

{
  "compilerOptions": {
    "lib": [
      "es2015", "dom"
    ]
  }
}

オプションページで<v-btn>のcolorがおかしい

この記事ではオプションページについて書いてないのですが、vuetifyの<v-btn>を使ってボタンを表示すると、指定した色にならず、必ず灰色になってしまう現象に見舞われました。chromeのdeveloper-toolで根気よくみていると、

f:id:rinoguchi:20200605000533p:plain

のように、injected stylesheetbackground-image が指定されているのが原因でした。

で、このstylesheetをinjectしている箇所を探したところ、manifest.jsonのオプションページの設定のchrome_style: trueが犯人でした。

  "options_ui": {
    "page": "options/options.html",
    "chrome_style": true
  },

この設定はchromeっぽい外観にしたい場合に設定するものでデフォルトはfalseらしいのですが、vue-web-extensionを使った場合trueが設定されます。少なくともvuetifyとは相性がよくないことが判明したので、falseに設定し問題を解決しました。

関連記事

コアな部分を作り込みながらこの記事を書いていたのですが、その後ブラッシュアップしていく中で、別記事を起こしたものがありますので、紹介しておきます。

rinoguchi.hatenablog.com

rinoguchi.hatenablog.com

rinoguchi.hatenablog.com

rinoguchi.hatenablog.com

rinoguchi.hatenablog.com

さいごに

今回、開発・公開したChrome拡張は、ここに記載したものから多少機能追加していますが、根幹の部分は説明できたかなと思います。(ソースは思わぬ脆弱性とか見つけられて攻撃されると怖いので、公開しません。)

Chrome拡張+Firebaseを使うことでフロントエンドサーバもバックエンドサーバも不要になり、サーバレスでサービスを構築できたことは非常にありがたかったです。Vue + Vuetifyも特に違和感なく使いやすかったですし、今回の技術選定は結構あたりだったかな、と思います。

typescriptプロジェクトにreCAPTCHA v2 & v3を導入

reCAPTCHAはスパムやBOTなどからサイトを保護するためのもので、v2とv3があります。今回は自分がtypescriptで作っているフロントエンドアプリ(Chrome拡張)をBOTアクセスから保護する目的でreCAPTCHAのv2とv3の両方を同じ画面に導入しました。

f:id:rinoguchi:20200801143807p:plain

v2とv3

  • v2はWEBサイトでみかけるやつで「この中から車が写っている画像を選んでください」みたいな感じで、ユーザ操作の結果でBOTアクセスなのかどうか(True/False)を返してくれます。

  • v3は、ユーザフリクション(本来の目的を妨げるような事象。)は一切なく、スコア(1が正常で、0.0に近づくほどBOTの可能性が高い)を返してくれます。

処理の流れ

  1. DBにアクセスする3つのアクション(Chrome拡張起動、保存ボタン、削除ボタン)の際にv3を使ってスコアを計算する
    • フロントエンド(Chrome拡張のポップアップページないのJS)でtokenを取得
    • サーバサイド(Cloud Function)でtokenを受け取りシークレットキーを使ってreCAPCHA APIを呼び出してスコアを取得
  2. v3スコアが閾値よりも小さい場合はBOTの可能性が高いと判断し、v2を使って画像選択画面を表示してユーザに画像を選択してもらう
  3. v2でBOTと判断されたら処理を中断する

reCAPTCHA v3の導入

キーの作成

まずはこちらからキーを作成します。サイトキーとシークレットキーの二つが作られます。
サイトキーはフロントエンドで、シークレットキーはバックエンドで使用します。

バックエンド実装

まずは、サーバサイド(Cloud Function)でtokenを受け取りシークレットキーを使ってreCAPCHA APIを呼び出してスコアを取得する処理を実装します。この処理は、フロントエンドでも実装可能なのですが、どうしてもシークレットキーが露出してしまうめ、バックエンドで実装せざるをえません。

自分が作っているフロントエンドアプリには、バックエンドサーバは存在しないので、Cloud Functions for Firebaseを利用することにしました。こちらをベースに作業を進めていきます。

Cloud Functions for Firebaseを使える状態にする

プロジェクト作成

こちらからFirebaseのプロジェクトを作成します

Firebaseのクライアントツールをインストール

npm install -g firebase-tools
firebase login # ブラウザが起動するので、対象googleアカウントを選択し、パーミッションを与える

プロジェクトのCloud Functions関係のソース一式を初期化

以下のコマンドで、firebase.jsonやfunctionsフォルダが作成されます。

cd {path_to_project_dir}
firebase init functions

firebase.jsonを実際のフォルダパス(defaultだとfunctions)に修正します。

{
  "functions": {
    "predeploy": "npm --prefix functions run build"
  }
}

必要なライブラリを開発用としてinstallします。

npm install firebase-functions --save-dev
npm install firebase-admin --save-dev
npm install axios --save-dev  # HTTPアクセスに利用

試しにhelloWorld関数をデプロイ

試しにhelloWorld関数をデプロイしてみます。
index.tsにhelloWorld関数がコメントアウトされた状態で出力されているので、コメントアウトを外します。

import * as functions from 'firebase-functions'

export const helloWorld = functions.https.onRequest((request, response) => {
  functions.logger.info('Hello logs!', { structuredData: true })
  response.send('Hello from Firebase!')
})

デプロイを実行します。

firebase deploy --only functions

typescriptがインストールされてない場合、sh: tsc: command not foundというエラーが発生しますが、以下で解決できます。

npm install -g typescript
vi ~/.bash_profile
> + export PATH=`npm bin -g`:$PATH
source ~/.bash_profile

さらに、無課金のSparkプランじゃCloud Functionsは使えないから、従量課金制のBlazeプランに変更してね、というエラーメッセージが出てデプロイできませんでした(涙)

Error: Cloud Functions deployment requires the pay-as-you-go (Blaze) billing plan. To upgrade your project, visit the following URL:

しょうがないので、ここからプロジェクトの概要ページを開き、使用量と請求額ページでプランをSpark=>Blazeに変更しました。

ここまでやって、もう一度firebase deploy --only functionsを実行すると無事にhelloWorld関数がFirebaesコンソール画面に関数が追加されました。

reCAPTCHAのsiteverify APIを呼び出す処理を書く

サンプルのhelloWorldは無事にデプロイできたので、次はreCAPTCHAのsiteverify APIを呼び出す処理を記載していきます。

まずは、コードに露出させたくないので環境構成変数としてシークレットキーを設定します。

# 設定
firebase functions:config:set recaptcha.v3.secret="xxxxxxxxxxxxxxxx"

# 確認
firebase functions:config:get

index.tsを以下のように書き換えます。普通にpostパラメータを{ secret = secret }のように記載すると、'error-codes': [ 'missing-input-response', 'missing-input-secret' ]というエラーが出てしまいました。パラメータはクエリストリングで渡す必要があるようです。

/* eslint-disable @typescript-eslint/no-explicit-any */
import * as functions from 'firebase-functions'
import axios, { AxiosResponse } from 'axios'

export const callSiteVerify3 = functions.https.onCall(async (data: any) => {
  const secret: string = functions.config().recaptcha.v3.secret // 環境構成変数からシークレットキーを取得
  const response: AxiosResponse<any> = await axios.post(
    `https://www.google.com/recaptcha/api/siteverify?secret=${secret}&response=${data.token}`,
    {}
  )
  return response.data
})

あとは、デプロイを実行するだけです。Firebaseのコンソール画面で関数が追加されていれば成功です。

firebase deploy --only functions

フロントエンド実装

次に、フロントエンド(Chrome拡張のポップアップページないのJS)でtokenを取得して、先ほどCloud Functionsで作った関数(API)を呼び出す処理を書きます。

ライブラリ追加

まずライブラリをインストールします。

npm install recaptcha-v3
npm install firebase

firebaseの初期化

次にfirebase.tsを作ってFirebaseを初期化します。 このコードは、Firebaseのコンソール画面のプロジェクト設定のセクションからそのままコピペできます。

import * as firebase from 'firebase'

const firebaseConfig: { [key: string]: string } = {
  apiKey: 'xxxxx',
  authDomain: 'xxxxx',
  databaseURL: 'xxxxx',
  projectId: 'xxxxx',
  storageBucket: 'xxxxx',
  messagingSenderId: 'xxxxx',
  appId: 'xxxxx',
}

firebase.initializeApp(firebaseConfig)

export default firebase

Cloud Functionsの関数呼び出し

あとは、tokenを取得して、Cloud Functionsで作ったAPIを呼び出すだけです。

import { load } from 'recaptcha-v3'
import firebase from './firebase'

// reCAPTCHA のsiteverify APIのレスポンスを格納するための入れ物
interface CallSiteVerifyResponse {
  success: boolean
  challenge_ts: string
  hostname: string
  score: number
  action: string
  'error-codes': []
}

const recaptcha = await load('xxxxxxxxxx') // 事前に作ったreCAPTCHA v3のサイトキー
const token = await recaptcha.execute('xxxxx') // 任意のアクション名
const func = firebase
  .app()
  .functions('asia-northeast1') // Functionsを実行するロケーション。デフォルトはus-central1
  .httpsCallable('callSiteVerifyV3')
const response: CallSiteVerifyResponse = await func({ token: token }).then(
  async (response): Promise<CallSiteVerifyResponse> => {
    return response.data as CallSiteVerifyResponse
  }
)

if (response.success === false) {
  throw new Error(`Recaptcha error: ${response['error-codes']}`)
}
console.log(`reCAPCHA v3 score: ${response.score}`)
// -> reCAPCHA v3 score: 0.9

無事スコアを取得することができました。

当初は取得したスコアが閾値(とりあえず0.5としました)以下の場合、BOTアクセスと判断してService is not avairable.としようかと考えていたのですが、ググってみるとスコアが原因不明に低下することがあるようなので、なんの救済措置もなくBOTアクセスと判断するとBOTではないサービス利用者を締め出してしまう可能性がありそうです。

それも怖いので、まずはreCAPTCHA v3で一定のスコアを下回ったら、reCAPTCHA v2で画像を選択してもらい、それでBOTアクセスかどうかを最終的に判断することにしました。

reCAPTCHA v2の導入

reCAPTCHA v2はてっきりサーバサイドの実装は不要なのかと思ってたのですが、v3と同様に必要でした。。基本的にはv3と同じような実装をしてあげればOKです。

キーの作成

v3と同様にこちらからキーを作成します。
reCAPTCHA v2 は3種類のパターンがあるようです。

  1. 「私はロボットではありません」チェックボックス
    • => チェックボックスをクリックさせてリクエストを検証し、ロボットの可能性がある場合と判断されたらチャレンジが実行される(=画像選択画面が表示される)
  2. 非表示 reCAPTCHA バッジ
    • => バックグラウンドでリクエストを検証し、ロボットの可能性があると判断されたらチャレンジが実行される(=画像選択画面が表示される)
  3. reCAPTCHA Android

今回は、v3でBOTの可能性が高い場合に画像選択させるのが目的なのですが、わざわざチェックボックスを表示させるよりは直接画像選択させた方がシンプルだと思い、「2. 非表示 reCAPTCHA バッジ」を利用することにしました。

設定を進めるとサイトキーとシークレットキーが表示されます。

また、v3で既にBOTの可能性が高いと判断された後なので、セキュリティの設定ではセキュリティ重視を選択することにしました。

バックエンドの実装

ここは使うシークレットキーが違うだけで、v3と全く同じです。

環境構成変数を設定して

firebase functions:config:set recaptcha.v3.secret="xxxxxxxxxxxxxxxx"

index.tsにv2用の関数を追加して

export const callSiteVerifyV2 = functions.https.onCall(async (data: any) => {
  const secret: string = functions.config().recaptcha.v2.secret // 環境構成変数からシークレットキーを取得
  const response: AxiosResponse<any> = await axios.post(
    `https://www.google.com/recaptcha/api/siteverify?secret=${secret}&response=${data.token}`,
    {}
  )
  return response.data
})

デプロイを実行するだけです。

firebase deploy --only functions

フロントエンドの実装

ここはWeb上に欲しい情報がほとんど見つからず、だいぶ苦労しました。

候補1: recaptcha-v2 => ボツ

まず、普通にv3と同じように recaptcha-v2 を利用すれば良いかと思ったのですが、このライブラリ5年前から更新されていません。
利用方法を見ると以下のような感じで、普通にシークレットキーを指定する必要があり、キーが露出してしまいます。怖いので利用をやめました。

var recaptcha = new Recaptcha(PUBLIC_KEY, PRIVATE_KEY);

候補2: grecaptcha => ボツ

次に、grecaptchaというライブラリを発見したのですが、こちらも利用方法を見ると以下のようにシークレットキーが露出するので、利用をやめました。

const client = new Grecaptcha('secret')

候補3: CDN利用 => 採用

結局ライブラリの利用をやめて、CDNを利用することにしました。

今回はv3でダメだったらv2をチャレンジするという形なので、明示的にreCAPTHCAウィジェットを表示する必要があるので、公式サイトではこちらの手順がベースになります。

CDNからスクリプト読み込み

まずは、htmlファイルでCDNからスクリプトを読み込みます。

公式サイトでは、以下のようにapi.jsを読み込み、recaptchaの準備が完了する(グローバル変数grecaptchaが生成される)のを待ってからonloadCallbackで本来やりたい処理を書くようなことを期待されているのですが、ここにvueやreactの初期化処理を書くのはだいぶ嫌な感じです。。

<div id="recaptcha-v2-container"></div>
<script type="text/javascript">
  var onloadCallback = function() {
    alert("grecaptcha is ready!"); // ここで本来やりたい処理を書く
  };
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer></script>

なので、onloadコールバックを利用するのはやめて以下のようにしました。ちなみに、divタグはウィジェットレンダリング先です。

<div id="recaptcha-v2-container"></div>
<script src="https://www.google.com/recaptcha/api.js?render=explicit" defer></script>

型の解決

typescriptで実装しているので、そのままだとimport するとCannot Find Moduleエラーが発生してしまいますので、index.d.tsを作成して型定義をしておきます

declare module 'grecaptcha'

ちなみに、@types/grecaptchaというものがあって、これを利用する方が良さそうなのですが、定義内容は完璧なのにexportされてないのでそのままでは利用することができません。 試しに、npm install @types/grecaptcha --save-devして、node_modules/@types/grecaptcha/index.d.tsの最後の行に

export default grecaptcha

を追加してあげると普通にimportして利用できました。
とはいえ、node_modulesに手を入れるのも違うと思うし、exportされてないものをimportする方法も自分には分からないので、こちらは断念しました。

grecaptchaをバンドル対象外に

また、バンドル時にCDNからスクリプトを取り込むようwebpack.config.jsに除外設定を追加します。
これによりgrecaptchaモジュールに関しては、グローバル変数grecaptchaを利用してくれる形になります。

  externals: {
    grecaptcha: 'grecaptcha',
  }

firebaseの初期化

これは v3と同じ なので省略

Cloud Functionsの関数呼び出し

あとは、tokenを取得して、Cloud Functionsで作ったAPIを呼び出します。 ポイントがいくつかあるので、列挙しておきます。

  • grecaptcha.readyでgrecaptchaオブジェクトの準備が完了する(=グローバル変数のgrecapthcaができる)のを待ち合わせる
  • grecaptcha.renderでreRECAPTCHAウィジェットレンダリングして、widgetIdを取得する
    • 第一引数はコンテナで、ウィジェットレンダリングする対象のHTMLエレメントもしくはIDを指定する
    • 第二引数には、こちらのパラメータを指定する。今回はsiteKeysizecallbackをしている。
  • grecaptcha.execute()に取得したwidgetIdを渡して検証を行う
    • widgetIdを指定せずに実行することも可能。その場合画面ないの最初のウィジェットが利用される
    • 今回はv3とv2を同一の画面で同時に実行しているため、widgetIdを指定せずに実行するとv3のwidgetが利用されて、サイトキーとシークレットキーがマッチせずエラーとなった
import grecaptcha from 'grecaptcha'

// reCAPTCHA のsiteverify APIのレスポンスを格納するための入れ物 ※v3と共通
interface CallSiteVerifyResponse {
  success: boolean
  challenge_ts: string
  hostname: string
  score: number
  action: string
  'error-codes': []
}

grecaptcha.ready((): void => {
  const widgetId: number = grecaptcha.render('recaptcha-v2-container', {
    sitekey: 'xxxxx', // ここでサイトキーを指定する
    size: 'invisible', // チェックボックスは非表示
    callback: async (token: string) => {
      const func = firebase
        .app()
        .functions('asia-northeast1')
        .httpsCallable('callSiteVerifyV2') // v2用のCloud Functionsの関数

      const response: CallSiteVerifyResponse = await func({
        token: token,
      }).then(
        async (response): Promise<CallSiteVerifyResponse> => {
          return response.data as CallSiteVerifyResponse
        }
      )

      if (response.success) {
        console.log('V2 check succeeded.')
        return
      }

      throw new Error(`BOT access detected. response: ${JSON.stringify(response)}`)
    },
  })

  grecaptcha.execute(widgetId)
  // -> V2 check succeeded.
  // or 
  // -> Uncaught Error: BOT access detected. 
})

これで、無事にreCAPTCHA v2での検証を行うことができました。

さいごに

それなりに苦労しましたが、typescriptで作ったフロントエンドアプリにreCAPTCHA v2 と v3を両方同時に導入することができました。
特にv2の方はtypescriptのプロジェクトに導入するケースが全くWEB上でまったく見つからなかったので、この記事が誰かの助けになればいいなと思います。

IndexedDBをつかうならDexie.jsが便利

ブラウザでデータを保管する場合、Web Storage(sessionStorageやlocalStorage )を使うことが多かったと思いますが、IndexedDBが登場したことであえてWeb Storageを使う理由がなくなりました。

IndexedDBを使う場合、Dexie.jsを使用することでだいぶ実装がスッキリするので、使い方を紹介したいと思います。

Web StorageとIndexedDBの違い

そもそも本当にIndexedDBを使うのが良いのか、という疑問があるので念のため、Web StorageIndexedDBの違いを調べてみました。調べた全ての点でIndexedDBの方が良いので、やはりIndexedDB一択ですね。

項目 Web Storage IndexedDB
文字列のみ boolean、数値、文字列、dateなど色々
RDBのテーブル なし。key-valueで全てフラットに管理 RDBのテーブルに相当するオブジェクトストアがあり、その下にレコードを格納する
容量制限 Originあたり10MB ディスクサイズに依存(普通は10MBよりは大きい)
devtoolでの変更 追加・変更・削除などなんでもできる クリアや削除はできるが追加変更はできない
アクセス範囲 同一オリジン内。JavaScriptから全てのキーを取得できる 同一オリジン内。ただし、データベース名とバージョンを知らないとアクセスできない

chromeのケースで調べてます。間違ってたらすいません。

Dexieの使い方

DexieはIndexedDB利用を非常に楽にしてくれるライブラリです。自分としては、IndexedDBのレコードをTypescriptのクラスにO/R Mappingしてくれる点が非常にありがたいと思いました。

インストール

npmでパッケージをインストールします。

npm install dexie

DB定義およびスタート

dexie.tsを作って以下のような感じでDB定義およびスタートを行います。

import Dexie from 'dexie'

// スキーマの修正がある場合、この値を変更する必要あり
const SCHEMA_VERSION = 1

// オブジェクトストアに対応するクラスを作成。interfaceでもOK。実際にはmodelは別ファイルに定義してあります
export class AccessCountModel {
  key: number
  readCount: number
  updateCount: number
  deleteCount: number
  constructor(key: number, readCount = 0, updateCount = 0, deleteCount = 0) {
    this.key = key
    this.readCount = readCount
    this.updateCount = updateCount
    this.deleteCount = deleteCount
  }
}
interface HogeDatabase extends Dexie {
  accessCounts: Dexie.Table<AccessCountModel, number> // ここでオブジェクトストアとモデルクラスを対応づけている。numberはキーの型
}

const dexieDb = new Dexie('hoge-database') as HogeDatabase
dexieDb.version(SCHEMA_VERSION).stores({
  accessCounts: 'key,readCount,updateCount,deleteCount', // オブジェクトストアの定義
})
export default dexieDb

repositoryクラスの作成

直接、dexieDbを使ってもいいと思いますが、毎回thencatchを書くのも大変なので、repositoryクラスを作ります。

import dexieDb, { AccessCountModel } from './dexie'

export class AccessCountRepository {
  // データ取得
  async get(key: number): Promise<AccessCountModel | undefined> {
    return dexieDb.accessCounts
      .get(key)
      .then(async (count: AccessCountModel | undefined) => {
        return count
      })
      .catch((error) => {
        throw new Error(`Error getting access count:, ${error}`)
      })
  }

  // データ更新
  async save(accessCount: AccessCountModel): Promise<AccessCountModel> {
    await dexieDb.accessCounts.update(accessCount.key, accessCount)
    return await this.get(accessCount.key)
  }
}

利用

実際の利用箇所では、repositoryクラスをimportして使います。

import { AccessCountRepository } './repositories'
import { AccessCountModel } from './dexie'

class Hoge {
  accessCountRepository = new AccessCountRepository()
  async fuga(): Promise<void> {
    const key = 3600
    const accessCount: AccessCountModel = await this.accessCountRepository.get(key)
    console.log(accessCount)
  }
}

CDN利用

dexieはCDNも提供されているので、そちらを使うこともできます。 htmlでdexie.min.jsを読み込んで、

<body>
    <script src="https://unpkg.com/dexie@latest/dist/dexie.min.js"></script>
</body>

webpack.config.jsのexternalsにdexieを追加します。global変数名はDexieなのでそれを指定してます。

  externals: {
    dexie: 'Dexie',
  }

これで、バンドルファイルからは除外され、CDNから読み込むようになります。

トラブルシューティング

オブジェクトストアが存在しない

存在しないオブジェクトストアにアクセスしようとすると以下のエラーが発生します。

NotFoundError: Failed to execute 'transaction' on 'IDBDatabase': One of the specified object stores was not found.

このような場合、自分の経験的には原因は以下の二つのどちらかでした。

  • DB定義が間違っている(typoや定義し忘れ)
  • DB定義を変更したのに、スキーマバージョンを変更しわすれている

最後に

サーバサイドかと思うぐらいかっちりした感じで実装することができました。
ただ、今回紹介した内容は、個々のクエリが個別のtransactionで実行される状態なので、一連の処理を一つのtransaction内で実行する場合はもう少し工夫が必要そうです。

vue+typescriptのアプリにmarkdown editorを導入

vue + typescriptで作っているフロントエンドアプリにmarkdown editorを導入したので少し紹介します。
XSS対策をちゃんとできるかという点に着目しつつ、mavonEditorEasyMDEを試して最終的にEasyMDEの方を採用しました。
f:id:rinoguchi:20200615085009g:plain

mavonEditor

2020年6月時点でstar数も3.9kあり実績十分ぽかったのでまずはこれを試しました。

導入手順

こちらをベースにやっていくわけですが、typescript+開発時はnode_modules・本番はCDNという前提で導入手順を記載しておきます。

ライブラリをインストールし、

npm install mavon-editor --save-dev

mavonEditor.tsに以下のように記述します。cssCDNから読み込む予定なのでここではimportしません。

import Vue from 'vue'
import mavonEditor from 'mavon-editor'

Vue.use(mavonEditor)

app.tsではこのmavonEditor.tsを読み込んでnew Vue()します。直接関係ないimport文などは省略してます。

import '../plugins/mavonEditor'

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

次に、webpack.config.jsにmavon-editorは外部から読み込む設定を追加します。

  externals: {
    'mavon-editor': 'MavonEditor'
  }

さらに、本番用にhtmlでCDNからCSSスクリプトを読み込んでおきます。

<html lang="en">
  <head>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/2.9.0/github-markdown.min.css" rel="stylesheet">
  </head>
  <body>
    <script defer src="https://unpkg.com/mavon-editor@2.9.0/dist/mavon-editor.js"></script>
    <script defer src="app.js"></script>
  </body>
</html>

MarkdownEditor コンポーネントを作ります。ここではエディタ機能だけを記載してます。
実際に使うときは、@saveイベントで入力文字列やHTMLを取得して、どこかに保存したりemitを使って親コンポーネントの関数を呼び出したりなどの実装が必要です。

<template>
  <div>
    <mavon-editor
      v-model="body"
      :toolbars="toolbarsOption"
      language="en"
    />
  </div>
</template>

import { Component, Prop, Vue } from 'vue-property-decorator'

@Component
export default class MarkdownEditor extends Vue {
  @Prop() private body!: string
  private toolbarsOption = {
    undo: true,
    redo: true,
    bold: true,
    underline: true,
    // ここにツールバーに表示したいアイコンを列挙
  }
}
</script>

あとは好きなところにMarkdownEditorコンポーネントを差し込むだけです。とても簡単ですね。

<template>
  <div>
    <MarkdownEditor :body="body" />
  </div>
</template>

<script lang="ts">
import MarkdownEditor from '../components/MarkdownEditor.vue'
@Component({
  components: { MarkdownEditor },
})
export default class Hoge extends Vue {
  private body = ''
</script>

XSS対策

導入は簡単なのですが、npm installした際にfound 1 high severity vulnerabilityと言われます。
npm auditで詳細を確認するとCross-Site Scripting脆弱性が報告されているようです。

┌───────────────┬───────────────────────┐
│ High                    │ Cross-Site Scripting                  │
├───────────────┼───────────────────────┤
│ Package                 │ mavon-editor                          │
├───────────────┼───────────────────────┤
│ Patched in              │ No patch available                    │
├───────────────┼───────────────────────┤
│ Dependency of           │ mavon-editor [dev]                    │
├───────────────┼───────────────────────┤
│ Path                    │ mavon-editor                          │
├───────────────┼───────────────────────┤
│ More info               │ https://npmjs.com/advisories/1169     │
└───────────────┴───────────────────────┘

More Infoに書いてあるhttps://npmjs.com/advisories/1169 にアクセスすると、github issue へのリンクが貼ってあって、なにやらまだ解決してない模様です。

実際に、markdown editorのtextareaに<img onerror=alert() src=1>を貼り付けてみると見事にscriptが実行されます。これは対策が必要だということで、二つ考えました。

mavonEditorが提供しているxss-optionsを使う

mavonEditorはxss-optionsを提供しています。documentにあまり記載がないのですが、入力値をjs-xssを使ってサニタイズしてくれます。js-xssはこのチートシートXSSには対応しているようなので、自前で何かやるよりは良さそうです。

設定方法は簡単で以下のようにするだけです。

<mavon-editor xss-options="{}" />

指定できるオプションはこちらを参考にすると、サニタイズ対象外にするタグのホワイトリストを登録したりすることができるみたいですが、とりあえずデフォルトのままで試してみます。

実際に前述のチートシートの内容をそのまま貼り付けて実行してみると、以下の二つのパターンでエラーが発生しました。

<IMG SRC=/ onerror="alert(String.fromCharCode(88,83,83))"></img>
<img src="/" =_=" title="onerror='prompt(1)'">

この二つについても、ぱっと見issueには上がってませんでしたが、開発も続いているようなのでいずれ修正されることを期待しても良いかもしれません。

で、これで完了としたいところですが、mavonEditorのxss-optionsには欠陥があります。
mavonEditorは入力値に対してサニタイズをして入力値を置き換えてしまうため、入力したそばから入力値が変換されるという謎動作が発生してしまいます。
たとえば<を入力しようとすると&lt;が入力されてしまいます。一般ユーザから見たらバグと取られそうです。。

このサニタイズ処理をプレビュー時や@changeや@saveのイベントで取得できるHTMLにだけ適用するオプションを探したのですが見当たりません。
ソースコードこのへんを見ても、入力値そのものを書き換えてしまうようです(涙)

というわけで、2020年6月時点のxss-optionsの仕様だと厳しいと判断し、xss-optionsを使うのは諦めました。
とはいえ、このコミットをみるとxss-optionsの実装変更は2020年4月に入ったばかりのようなので、今後改善されるかもしれません。

xss-optionsを使わないで、自前でどうにかする

xss-optionsを使わない場合、リアルタイムプレビューでHTMLが生成されスクリプトが実行されるのが邪魔です。
なので、プレビューをOFFにするオプションを探したのですがtoolbarからpreviewアイコンを消しただけでは特に動作も変わらず、ソースコードこのへんからたどっていっても、プレビュー用のHTML描画を止めることはできそうにありません。また、プレビュー用のHTMLの内容を変更する関数を定義するためのフックもなさそうです。

というわけで、こちらも諦めました。

結論

結局、XSSチートシートの内2件は対応できてないこと、現状ではxss-optionsを使うと入力値そのものがサニタイズされてしまうこと、が気になるのでmavonEditorの導入は見送ることにしました。

EasyMDE

最初にSimpleMDEを見つけました。これはエディター機能もありpreview時のHTMLのカスタマイズもできそうなのですが、如何せん開発が止まってしまっています。
もう少し探していると、SimpleMDEをforkしたEasyMDEを発見しました。こちらはstar数こそ653と少ないですが、ガンガン開発が進んでるのが良いです。機能もSimpleMDEをforkしているだけあって問題なさそうなので、こちらを試してみることにしました。

導入手順

こちらも、typescript+開発時はnode_modules・本番はCDNという前提で導入手順を記載しておきます。

EasyMDE、サニタイズ用のDOMPurify、HTML変換用のmarkedをインストールします。

npm install easymde --save-dev
//  サニタイズ用
npm install dompurify --save-dev
npm install @dompurify/marked --save-dev
// HTML変換用
npm install marked --save-dev
npm install @types/marked --save-dev

まず、webpack.config.jsにeasymdeは外部から読み込むよう設定を追加します。

  externals: {
    easymde: 'EasyMDE',
    DOMPurify: 'DOMPurify',
    marked: 'marked'
  }

さらに、本番用にhtmlでCDNからCSSスクリプトを読み込んでおきます。
markdown用のCSSgithubののものを利用させていただくことにします。

<html lang="en">
  <head>
    <link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css">
  </head>
  <body>
    <script defer src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
    <script defer src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.0.11/purify.min.js"></script><!-- サニタイズ -->
    <script defer src="https://cdnjs.cloudflare.com/ajax/libs/marked/1.1.0/marked.min.js"></script><!-- HTML変換 -->
    <script defer src="app.js"></script>
  </body>
</html>

また、tsconfig.json"allowSyntheticDefaultImports": trueを指定します。

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
  }
}

これを設定しないと、実際にeasymdeをimportしようとする以下のようなエラーが発生します。

TS1259: Module '"/xxxx/xxxx/node_modules/easymde/types/easymde"' can only be default-imported using the 'allowSyntheticDefaultImports' flag

そして、本命の MarkdownEditor コンポーネントを作ります。

  • プレビュー時とHTML変換時にサニタイズ。詳細はXSS対策の項に記載
  • targetのtextareaがDOMに追加されている必要があるので、mounted()でeasyMDEを初期化
  • saveボタンを用意して、エディタに入力した文字列をHTMLに変換
  • 日本語はほぼspell checkエラーになるので(背景色ピンク)、spellChecker: falseを指定してspell checkを無効化
  • previewClassには、easyMDEのeditor-preview classと、githubmarkdown-body classの両方を指定(editor-previewを指定しないと表示がおかしくなる)
<template>
  <div>
    <textarea id="easymde-area" />
    <button @click="handleSaveBtn">save</button>
  </div>
</template>

import { Component, Prop, Vue } from 'vue-property-decorator'
import EasyMDE from 'easymde'
import DOMPurify from 'DOMPurify'
import * as marked from 'marked'

@Component
export default class MarkdownEditor extends Vue {
  @Prop() private body!: string
  private html = ''
  private mde: EasyMDE | null = null

  private mounted(): void {
    const target: HTMLElement | null = this.$el.querySelector(
      'textarea#easymde-area'
    )
    if (target !== null) {
      const config: EasyMDE.Options = {
        element: target,
        initialValue: this.body,
        autofocus: true,
        spellChecker: false,
        previewClass: ['editor-preview', 'markdown-body'],
        renderingConfig: {
          singleLineBreaks: true,
          codeSyntaxHighlighting: false,
        },
        toolbar: [
          'bold',
          'italic',
          'strikethrough',
          'heading',
          'code',
          'quote',
          'unordered-list',
          'ordered-list',
          'table',
          'horizontal-rule',
          'preview',
        ],
        renderingConfig: {
          sanitizerFunction: (renderedHTML: string): string => {
            return this.sanitizeHtml(renderedHTML)
          },
        },
      }
      this.mde = new EasyMDE(config)
    }
  }

  private sanitizeHtml(html: string): string {
    const sanitizedHtml: string = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: [
        'b',
        'li',
        'ol',
        'ul',
        'p',
        'strong',
        'em',
        'del',
        'h1',
        'h2',
        'h3',
        'h4',
        'h5',
        'h6',
        'pre',
        'code',
        'blockquote',
        'table',
        'thead',
        'tbody',
        'tr',
        'th',
        'td',
        'hr',
      ],
    })
    return sanitizedHtml
  }

  handleSaveBtn(): void {
    if (this.mde !== null) {
      this.body = marked(this.mde.value())
      this.html = this.sanitizeHtml(marked(this.body))
    }
  }
}
</script>

あとは好きなところにMarkdownEditorコンポーネントを差し込むだけです。

<template>
  <div>
    <MarkdownEditor :body="body" />
  </div>
</template>

<script lang="ts">
import MarkdownEditor from '../components/MarkdownEditor.vue'
@Component({
  components: { MarkdownEditor },
})
export default class Hoge extends Vue {
  private body = ''
</script>

XSS対策部分を除くと、導入の手間自体はMavonEditorとほとんど差はありませんでした。

XSS対策

MavonEditorではできなかったPreview用のHTMLに対してサニタイズを行うためのsanitizerFunctionというフックが用意されていますのでその中でサニタイズを行うことにしました。
サニタイズを自前で実装するのは大変なので、ライブラリを導入しました。js-xssDOMPurifyを検討したのですが、js-xssの方は、XSSチートシートの内容で2件ほどエラーが出たので、DOMPurifyを採用しています。
単純にDOMPurify.sanitize()すると、全てのタグがサニタイズされて、マークダウンとしての機能を果たさなくなってしまうため、XSSの対象にならない安全なHTMLタグはサニタイズ対象から除外するとともに、ツールバーにもアイコンを表示するようにしました。(<a><img>XSSの対象になりうるので、除外しています)
実装後、チートシートの内容を貼り付けてプレビューしても、1件もエラーは発生しませんでした。

HTML変換

EasyMDEはMavonEditorと違ってHTMLに変換した値を受け取ることはできないので、saveボタンを用意して、ボタンクリック時に、markedを使ってHTMLに変換するようにしました。この際、プレビュー時と同様にサニタイズをしています。

結論

EasyMDEはプレビュー時のXSS対策をちゃんとできるので、安心して使えます。markdown editorとしての機能もMavonEditorと遜色ないので、こちらを採用することしました。

Vue+TypeScriptのプロジェクトにVuetifyを導入

2020年6月追記:記事の内容がだいぶ古くなっていたので、最新の情報に見直しました。

Vue+TypeScriptで作ったフロントエンドアプリの見た目をよくしたかったので、Vuetifyを導入しました。
Quick Startにいっぱい書いてあって、どれをどうやればいいかが結構分かりにくかったので、導入手順を記載しておきます。

インストール

こちらにしたがって導入をします。

VuetifyのUI Componentを利用する箇所で毎回moduleをimportしなくても、自動的にimportしてくれるvuetify-loaderを利用するケースについて記載しています。

依存ライブラリを追加します。

npm install --save vuetify
npm install --save-dev sass sass-loader fibers deepmerge -D
npm install --save-dev vuetify-loader
npm install --save @mdi/font -D # Material Design Iconsを使う場合

webpack.config.jsの設定

rulesにsass-loader、pluginsにVuetifyLoaderPluginの設定を追加します。

const VuetifyLoaderPlugin = require('vuetify-loader/lib/plugin')

module.exports = {
  rules: [
    {
      test: /\.s(c|a)ss$/,
      use: [
        'vue-style-loader',
        'css-loader',
        {
          loader: 'sass-loader',
          // Requires sass-loader@^7.0.0
          options: {
            implementation: require('sass'),
            fiber: require('fibers'),
            indentedSyntax: true // optional
          },
          // Requires sass-loader@^8.0.0
          options: {
            implementation: require('sass'),
            sassOptions: {
              fiber: require('fibers'),
              indentedSyntax: true // optional
            },
          },
        },
      ],
    },
  ],
  plugins: [
    new VuetifyLoaderPlugin(),
  ]
}

vuetifyの組み込み

plugins/vuetify.tsを作成して、以下のように記載します。

import Vue from 'vue'
import Vuetify from 'vuetify/lib'
import '@mdi/font/css/materialdesignicons.css' // mdi-iconを使う場合、バンドルに含めるためにimport必要

Vue.use(Vuetify)

export default new Vuetify({
  icons: {
    iconfont: 'mdi', // mdi-iconを使う場合
  },
})

このままだと、Could not find a declaration file for module 'vuetify/lib'.と怒られるので、tsconfig.jsoncompilerOptionsに以下の設定を追加します。

{
  "compilerOptions": {
+    "types": [
+      "vuetify"
+    ],
  }
}

app.tsで先ほど作成したplugins/vuetify.tsを読み込みます。

import Vue, { CreateElement } from 'vue'
import vuetify from '../plugins/vuetify'
import { VNode } from 'vue/types/umd'

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

vuetifyを適用する範囲を指定

これが一番大事だと思うのですが、vuetifyを適用する範囲を<v-app>で囲む必要があります。
App.vueの一番トップレベルを<v-app>で囲むことにしました。

<template>
  <v-app>
    <Hoge />
    <Fuga />
  </v-app>
</template>

これで、App.vue配下でvuetifyが使えるようになりました。

UIコンポーネントを利用

あとは、こちらを参考にして、UIコンポーネントを使っていけば大丈夫です。importせずとも使えます。

例えば、テキストエリアに被せるようにSAVEボタンを表示するケースであれば、<v-card>と<v-btn>を使って以下のように書けば動きました。

<template>
  <v-card>
    <v-btn small fab color="info" absolute top right @click=handleSaveButtion>save</v-btn>
    <v-card-text>
      <v-textarea label="自由にメモを記載してください。" v-model=memo rows=15 ></v-textarea>
    </v-card-text>
  </v-card>
</template>

こんな感じでまあまあいい感じの見た目になったかな。。

f:id:rinoguchi:20190531200644p:plain

[参考] CDNを利用してバンドルサイズを削減

CDNを利用してバンドルサイズを削減することを考えることもあると思います。別記事を書いてまして、そちらでvuetifyをCDNから読み込むように変更していますので、興味があれば参照してください。

rinoguchi.hatenablog.com

Vue.js+TypeScriptでフロントエンドアプリを構築するにあたってのTips

現在プライベートで作っているWEBサービスのフロントエンドを作るにあたって、Vue.js+TypeScriptを試してみることにした。 基本的には公式サイトを参照させていただいた。

作るもの

メモを編集する画面を作成する。

  • 初期表示
    • メモ表示欄とEDITボタンが表示されている
    • メモ表示欄にはメモ取得APIで取得したメモの内容を表示する
      • APIで取得する内容はこんな感じ。{ id: 123, body: "これはサンプルです"}
  • EDITボタンクリック
    • 表示欄+EDITボタンを非表示にし、編集欄(textarea)+SAVEボタンを表示する
  • SAVEボタンクリック
    • 編集欄+SAVEボタンを非表示にし、表示欄+EDITボタンを表示する
    • メモ保存APIでメモを保存する

プロジェクト作成

npmのインストール

npmのインストール

vue-cliのインストール
npm install -g @vue/cli
プロジェクトを作成

以下のコマンドで対話型で作成するプロジェクトの内容を決めていく。

vue create frontend

以下の設定で作成した。

? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, Linter
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript for auto-detected polyfills? Yes
? Pick a linter / formatter config: Standard
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i
> to invert selection)Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedica
ted config files
? Save this as a preset for future projects? (y/N) N
アプリ起動

サーバを起動する。

cd frontend
npm run serve

サイトにアクセスすると、とりあえずサンプル画面が表示される。
http://localhost:8081/

自動生成された設定ファイル群の説明

.browserslistrc

異なるフロントエンドツールにおいて、ターゲットブラウザやNode.jsのバージョンをシェアするための設定を記載するもの。
下記設定は、ブラウザの利用シェアが1%より大きく、かつ、直近2バージョンをターゲットとして指定している。
詳細はこちらを参照のこと。

> 1%
last 2 versions
.editorconfig

EditorConfigは異なるエディタやIDEで一貫したコーディングスタイルを維持するための設定を記載するもの。
ファイル保存時にフォーマットされるようにするには以下の作業をする必要がある。

  • Editor Config for VS Code拡張をインストール
  • Code -> Preference -> Settings -> Text Editor -> Formatting -> Format on Saveをチェック
    • これをやると、保存時にindentもやってくれるが、"->'変換や行末に;追加などいらんことをするようになってしまう
    • こちらの記事のようにすれば回避できる模様
    • ただ、lintの定義が増えてしまうので今回自分は Format on Save は行わないことにした

下記設定は、特定の拡張子において、インデントのスタイルや行末のスペース、最終行の改行などの扱いを設定している。
詳細はこちらを参照のこと。

[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
.eslintrc.js

EsLintは、JavaScriptを実行せずに静的にJavaScriptコードの問題を発見するためツール。
EsLintを実行する方法はいくつかある。

  • npm run serveを実行する。サーバが立ち上がりファイルが保存されるたびにeslintが走る。
  • npm run lintを実行する。
  • VS CodeESlint拡張をインストールする(デフォルトでSettingsのEslint: Enableになっている。)

下記設定の意味はコード上にコメントで記載。詳細はこちらを参照のこと。

module.exports = {
  root: true, // このフォルダがrootだという指定。つまり親フォルダの設定ファイルを探しに行かない
  env: {
    node: true // 事前定義されているグローバル変数をNode.jsグローバル変数にする。`browser`なども指定可能。
  },
  'extends': [ // vue用の拡張。基本これに従っておけばいいのだと思う。
    'plugin:vue/essential',
    '@vue/standard',
    '@vue/typescript'
  ],
  rules: { 
    'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', // productionの場合はerrorレベルで出力。それ以外は普通に出力
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 
  },
  parserOptions: {
    parser: '@typescript-eslint/parser' // typescript用のparserを指定
  }
}
.gitignore

git管理対象外のファイルを指定してくれている。

babel.config.js

BabelはJavaScriptコンパイラ。古いブラウザでES2015以降の新しいバージョンのJavaScriptを動かせるよう、後方互換性のあるバージョンのJavaScriptコンパイルしてくれる。
ここではvue用のプリセットが設定されている。詳細はこちらを参照のこと。

module.exports = {
  presets: [
    '@vue/app'
  ]
}
package.json

npmでインストールする対象のJavaScriptパッケージとタスクの設定。
詳細はこちらを参照のこと。

{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve", // アプリを起動するタスク
    "build": "vue-cli-service build", // production用にjavascriptをコンパイルするタスク
    "lint": "vue-cli-service lint" // lintを実行するタスク
  },
  "dependencies": {
    "core-js": "^2.6.5",
    "vue": "^2.6.10",
    (省略)
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "^3.7.0",
    (省略)
  }
}
postcss.config.js

PostCSSはJSプラグインでスタイルを変換するためのツール。変数はmixinをサポートしており、将来のCSSシンタックスcssを記述できるらしい。
今回はVuetifyを利用するので、独自にスタイルを定義しない方針。
詳細はこちらを参照のこと。

module.exports = {
  plugins: {
    autoprefixer: {}
  }
}
tsconfig.json

TypeScriptに関する設定。詳細はこちらを参照のこと。

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "strict": true, // strictモード
    "jsx": "preserve",
    "importHelpers": true,
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true, // mapファイルを出力する
    "baseUrl": ".",
    "types": [
      "webpack-env"
    ],
    "paths": {
      "@/*": [
        "src/*" // ランタイムにおいて、"@/hoge" -> "./src/hoge"としてパスを解決する
      ]
    },
    "lib": [ // コンパイルに含めるライブラリのリスト
      "esnext",
      "dom",
      "dom.iterable",
      "scripthost"
    ]
  },
  "include": [ // コンパイル対象のファイル
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "tests/**/*.ts",
    "tests/**/*.tsx"
  ],
  "exclude": [ // 対象外にするフォルダ
    "node_modules"
  ]
}

入れておいた方がいいVS Codeの拡張

  • VueVetur拡張をインストールする
  • Editor Config for VS Code拡張をインストールする

実装Tips

やりたいこと別にTipsを追記していく方式にしようと思う。

Vueインスタンスに外部からパラメータを渡す

こちらを参考に、main.tsにてComponentOptionsの定義をすれば良い。
下記サンプルは、urlというパラメータをオプションとして追加している。?:で定義しないとエラーになるので注意。

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

Vue.config.productionTip = false

// ここでComponentOptionsにurlというオプションを追加している
declare module 'vue/types/options' {
  interface ComponentOptions<V extends Vue> {
    url?: string
  }
}

// urlというオプションに値を渡している。
new Vue({
  url: 'http://rinoguchi.com/hoge/fuga',
  render: h => h(App)
}).$mount('#app')

これにより、Vueインスタンスからいつでもoptionの値を取得できるようになる。

export default class App extends Vue {
  private url?: string = this.$root.$options.url
}
コンポーネントから親コンポーネントの処理を呼び出す

今回は、Loginコンポーネントでログインが完了したら、親のAppコンポーネントloggedInプロパティをtrueにするというケースを説明する。

まずは、子コンポーネントLoginコンポーネントにて、@Emitを使ってsucceededイベントを定義する。
下記サンプルでは、loginボタンをクリックしたら、ログイン処理が実行され、処理が終わったらthis.succeeded()を実行している。

<template>
  <div>
    <button @click=login>login</button>
  </div>
</template>

<script lang="ts">
@Component
export default class Login extends Vue {
  @Emit() private succeeded () {}
  login () {
    // login処理が実装してある
    (省略)
    // login処理が終わったらsucceededを実行する
    this.succeeded()
  }
}
</script>

次に、親コンポーネントAppコンポーネントにて、Loginコンポーネント@succeededイベントに対してloginSucceeded()を紐づける。
これによりLoginコンポーネントにてsucceededイベントが発火されたら、親コンポーネント側のloginSucceeded()が実行されることになる。

<template>
  <div id="app">
    <template v-if=loggedIn>
      <Memo>
    </template>
    <template v-else>
      <Login @succeeded=loginSucceeded />
    </template>
  </div>
</template>

<script lang="ts">
export default class App extends Vue {
  private loggedIn: boolean = false
  loginSucceeded (): void {
    this.loggedIn = true
    private url?: string = this.$root.$options.url
  }
}
</script>
axiosを使ってAJAX通信を行う

axiosを使ってAPIを呼び出す。
今回は、mountedにてメモを取得するAPIを呼び出し、その結果を画面に描画するようにしている。

まずは、依存関係を追加する。

npm install axios --save
  • templateでは、v-ifを使ってAPIでメモを取得するまではloading...を表示し、取得したらmemoを描画している。
  • scriptでは、@Propで親コンポーネントから渡ってきたmemoIdをプロパティとして定義している。
  • ライフサイクルフックのmountedにてAPIを呼び出している。
    • 200以外が返ってきたらalertを表示してアーリーリターンする。
    • 200の場合は、responseデータのmemoをプロパティのmemoに設定している
    • finallythis.loading = falseを設定し、メモが表示される。
<template>
  <div>
    <template v-if=loading>
      <pre>loading...</pre>
    </template>
    <template v-else>
      <pre>{{ memo }}</pre> 
    </template>  
  </div>
</template>

<script lang="ts">
export default class Memo extends Vue {
  @Prop() private memoId!: string
  private loading: boolean = false
  private memo?: string
  
  mounted (): void = {
    axios
      .get(`http://rinoguchi.com/memos`, { params: { id: this.memoId } })
      .then(response => {
        if (response.status !== 200) {
          alert('api call error')
          return
        }
        this.body = response.data.body
      })
      .finally(() => {
        this.loading = false
      })
  }
}
typescriptを使っているのでModelクラスを定義してみる

今回は、idbodyの二つのプロパティを持つMemoModelを定義してみようと思う。
いくつかの定義パターンが考えられるので、解説しようと思う。

1. プロパティがundefinedでもOKな場合

class MemoModel {
  id?: number
  body?: string
}

const memo = new MemoModel()
console.log(memo.body) // -> undefined
memo.body = 'xyz'
console.log(memo.body) // -> 'xyz'

2. プロパティがundefindedだと困るけど、初期値を設定できる場合

class MemoModel {
  id: number = -1
  body: string = 'default'
}

const memo = new MemoModel()
console.log(memo.body) // -> `default`
memo.body = 'xyz'
console.log(memo.body) // -> 'xyz'

3. プロパティがundefindedだと困るけど、初期値を設定できない場合

class MemoModel {
  id: number = -1
  body: string = 'default'
  constructor(theId: number, theBody: string) {
    this.id = theId
    this.body = theBody
  }
}

const memo = new MemoModel(123, 'xyz')
console.log(memo.body) // -> `xyz`

今回は、プロパティがundefindedだと困るけど、初期値は設定しても問題なかったので、2.のパターンで実装した。
もちろん、Modelクラスは別ファイルで定義して、importして使っても良い。

<template>
  <div>{{ memo.body }}</div>
</template>

<script lang="ts">
class MemoModel {
  id: number = -1
  body: string = 'default'
}

@Component
export default class Memo extends Vue {
  private memo: MemoModel = new MemoModel()

  mounted (): void {
    axios
      .get(`http://rinoguchi.com/memos/latest`)
      .then(response => {
        if (response.status !== 200) {
          alert('api call error')
          return
        }
        this.memo.id = response.data.id
        this.memo.body = response.data.body
      })
      .finally(() => {
        this.loading = false
      })
  }
}
環境変数を読み込む

今回はvue-cliを使っているので、こちらを参考にした。

以下の記述がほぼ全てを表していると思う。

.env # loaded in all cases
.env.local # loaded in all cases, ignored by git
.env.[mode] # only loaded in specified mode
.env.[mode].local # only loaded in specified mode, ignored by git

今回は、developmemtモードで動かしている際の環境変数を定義した。
変数名はVUE_APPで始まっている必要があるので要注意!!

APIのベースURLは、.env.developmentにて管理することにした。

VUE_APP_BASE_URL=https://rinoguchi.com/

Google OAuth認証のクライアントIDは、センシティブな情報なのでGITにcommitしたくない。そのため、.env.development.localにて管理することにした。

VUE_APP_GOOGLE_CLIENT_ID=***********.apps.googleusercontent.com

あとは、process.envを使ってどこからでも利用できる。

console.log(process.env.VUE_APP_BASE_URL)
console.log(process.env.VUE_APP_GOOGLE_CLIENT_ID)