GAミント至上主義

コストにうるさいWEBアプリ開発者。最近はPython, Vue.js, Kubernetesがメイン@株式会社ビズオーシャン。https://github.com/uyamazak/

uyamazak自己紹介(先頭固定)

株式会社ビズオーシャンのエンジニアリング部で技術系リーダー。
HTML+CSSからLinuxサーバー、フロントエンド、サービス企画、運用まで新規サービス作るマン。
OSはLinuxDebian系)、プログラムはPython, JavaScript, PHP, Goとシェルを少々。インフラはGCPメイン。

ビズオーシャンで関わってるサービス

yagish履歴書

スマホでも履歴書が作れるPWA

プログラムとサーバー構築などシステム系全般担当
そのほかデザイン・コーディング等はhosogai (id:shellme)
rirekisho.yagish.jp

Temply

パスワード付のファイル送付サービス

一部難しいとこサポートとかコードレビュー
tminami (id:takuya_minami373)がメインで開発
そのほかデザイン・コーディング等はhosogai (id:shellme)

temply.bizocean.jp

ありがとうポスト

WEB上から和紙のはがきでお礼状とか送れるサービス
企画、運用、ユーザーサポート、一部印刷とかだいたい全部。
開発・サーバー運用はソニックガーデン

www.aripo.jp

略歴

Google Cloud FunctionsにPython3.7が追加されてる件

今日Cloud FunctionsってNodeのバージョン選べるっけと思ってみたら、Python3.7があることに気づく。

f:id:uyamazak:20180720184424p:plain

package.jsonの代わりにrequirements.txtがありpipのパッケージを指定できるっぽい。

Nodeは6系と古いままのにPythonは6月ごろに出たばっかの最新の3.7とこの扱いの差はなんなんだろう。Googleの中の人のご要望だろうか。

HTTPトリガーのサンプルコードは下記のようになっていた。
使ったことないけどflaskをでラップしてくれてる感じか。

def hello_world(request):
    """Responds to any HTTP request.
    Args:
        request (flask.Request): HTTP request object.
    Returns:
        The response text or any set of values that can be turned into a
        Response object using
        `make_response <http://flask.pocoo.org/docs/0.12/api/#flask.Flask.make_response>`.
    """
    request_json = request.get_json()
    if request.args and 'message' in request.args:
        return request.args.get('message')
    elif request_json and 'message' in request_json:
        return request_json['message']
    else:
        return f'Hello World!'

Nodeのバージョン古いなぁと悩むぐらいならPythonを使うのもありかも。

さっそく、この前作ったRSSJSONにするやつをPythonで作ってみて速さなどを確認したいと思う。

uyamazak.hatenablog.com

ブログのRSSをCloud Functionsを使ってJSONに変換してajaxで使う

yagish履歴書で今後のアップデートのお知らせ用にはてなブログProを利用しようと考えている。

rirekisho.yagish.jp
(関係ないけどはてブ800超えてうれしい)

もちろんRSSがあるので、これをVue.jsアプリ内で読み込んでもいい。
でも、RSSとかXMLとかを扱うとなると、クライアント側にそれだけのためにライブラリがいくつも必要になる。
せっかくLighthouse対策でjsを減らすのがんばったのに嫌。

uyamazak.hatenablog.com

そこでCloud Functionsを使って、jsonに変換して返せないか考えた。
そうすれば、もともと使っているaxiosだけで済むし、コードも減るし、使いやすい。

まず、RSSJSONに変換するのはそのまんまのライブラリがあった。
すごいシンプルで言うことない。

www.npmjs.com

次にajaxで読み込むのでCORS対策。
Firebase HostingでCloud Functionsにリライトして接続すれば同一ドメインになるのでいらない。

Cloud Functions による動的コンテンツの配信  |  Firebase

でも今回はローカルでもテストしやすいのでCORSで許可することにした。

検索したらまさにのstackoverflowがあった。
stackoverflow.com

複数ドメインを動的に処理するのはcorsのドキュメントにあったのを使う。
github.com

これを組み合わせたのが下記。
これをCloud Functionsに作ってHTTPトリガーでデプロイする。
RSSの量によるかもだけどメモリは128MBでも良さげ。

Cloud FunctionsはCDNを通してるのでCache-Controlをつけておけば爆速で返せるので成功時だけつけておく。
期限は必要に応じてもっと短くてもいいかも。

res.set('Cache-Control', 'public, max-age=300, s-maxage=600');

index.js

const Feed = require('rss-to-json');
const RssURL = 'https://blog.yagish.jp/rss';
const whitelist = ['http://192.168.0.1', 'https://rirekisho.yagish.jp/']
const corsOptions = {
  origin: function (origin, callback) {
    if (whitelist.indexOf(origin) !== -1) {
      callback(null, true)
    } else {
      callback(new Error('Not allowed by CORS'))
    }
  }
}
const cors = require('cors')(corsOptions)

/**
 * Responds to any HTTP request.
 *
 * @param {!Object} req HTTP request context.
 * @param {!Object} res HTTP response context.
 */
exports.RssToJson = (req, res) => {
  Feed.load(RssURL, function(err, rss){
    cors(req, res, () => {});
    if(err){
      res.status(503).send(err);
    }else{
      res.set('Cache-Control', 'public, max-age=300, s-maxage=600');
      res.status(200).send(rss);
    }
  });
};

package.json

{
  "name": "sample-http",
  "version": "0.0.1",
  "dependencies": {
   "rss-to-json": "^1.0.4",
   "cors": "^2.8.4"
 }
}

whitelistを変えてみてエラーになったり、ならなかったりを確認した。

元のRSS
https://blog.yagish.jp/rss

<?xml version="1.0"?>
<rss version="2.0">
  <channel>
    <title>yagishのブログ</title>
    <link>https://blog.yagish.jp/</link>
    <description></description>
    <lastBuildDate>Wed, 18 Jul 2018 18:36:13 +0900</lastBuildDate>
    <docs>http://blogs.law.harvard.edu/tech/rss</docs>
    <generator>Hatena::Blog</generator>
    
      
      
        <item>
          <title>バックアップ機能アップデートのお知らせ</title>
          <link>https://blog.yagish.jp/entry/2018/07/18/183613?utm_source=feed</link>          <description>&lt;p&gt;バックアップ機能アップデートのお知らせ&lt;/p&gt;
</description>          <pubDate>Wed, 18 Jul 2018 18:36:13 +0900</pubDate>
          <guid isPermalink="false">hatenablog://entry/10257846132602357979</guid>
          
          <enclosure url="https://cdn.blog.st-hatena.com/images/theme/og-image-1500.png" type="image/png" length="0" />
        </item>
      
    
  </channel>
</rss>

こんなのが返ってきた。まだ記事数も文章もないので速度は速い。

{
    "items": [
        {
            "title": "バックアップ機能アップデートのお知らせ",
            "description": "<p>バックアップ機能アップデートのお知らせ</p>",
            "link": "https://blog.yagish.jp/entry/2018/07/18/183613?utm_source=feed",
            "url": "https://blog.yagish.jp/entry/2018/07/18/183613?utm_source=feed",
            "created": 1531906573000,
            "enclosures": [
                {
                    "url": "https://cdn.blog.st-hatena.com/images/theme/og-image-1500.png",
                    "type": "image/png",
                    "length": "0"
                }
            ]
        }
    ],
    "title": "yagishのブログ",
    "description": "",
    "url": "https://blog.yagish.jp/",
    "image": ""
}

pubDateはタイムスタンプに変換されcreatedという名前になってる。

こういうクライアント側でもできなくはないけど、サーバーでやった方がユーザー&クライアント側に優しいものにCloud Functionsは最適なので今後も使い所を探していこうと思う。

yagish履歴書がはてブ(IT)1位でうれしい

rirekisho.yagish.jp

リリース出したのは7/11だけど、なぜか今朝になっていつも見てるはてブ上位にいた
f:id:uyamazak:20180712101522p:plain

うれしい

ファビコンがルートに置いてないせいか出てないから直そう

2018/7/12 12:43追記

テクノロジー1位やったー
この記事のタイトルも変えた

f:id:uyamazak:20180712124513p:plain

なんでこんなにブクマつくんだろう

Vue.jsのPWAでLighthouse100点を目指したがPerformance77点で挫折した

WEB上だと空のプロジェクトとかでLighthouseの高得点を目指す記事はあるけど、実用的なPWAアプリでの情報は少ないので試したことをまとめてみる。

履歴書をスマホで入力できてPDFで出せる新サービスyagish履歴書。
まだまだ改良の余地はあるけど使える状態。

rirekisho.yagish.jp

まず結果。
5日ぐらいがんばった後のレポートはこれ。
Chrome拡張でLihghtHouseのバージョンは3.0.1。
ユーザーの第一印象となるトップページだけがんばった。

f:id:uyamazak:20180704102245p:plain
実行のたびに結果は変わる(Peformance 50~77点ぐらい)

Performance以外の100点は意外と簡単だった。
ただ足りないのを追加したり、よくないのを変えていく感じ。
Performanceはコード量とか処理を減らせというので難しい。

でも45点だった最初と比べると、スマホでも初回5秒以上かかっていた読み込みが体感できるほど早くなり、やってよかったと思っている。
緑(75点)以上で妥協するのも全然いいと思う。

Vueは下記PWAテンプレートをもとに開発している。
GitHub - vuejs-templates/pwa: PWA template for vue-cli based on the webpack template

これは現在すでにメンテモードになってしまい、下記プラグイン形式に移るので今からはじめるならこっち。
大きいところだとWebpackが3だったのが4になったりして、そのままは移せない。
vue-cli/packages/@vue/cli-plugin-pwa at dev · vuejs/vue-cli · GitHub

いろいろ試行錯誤したけど、効果が大きかったのは下記3つ。

1.画像は可能な限り小さくする

最初は履歴書のサムネがjpgで60KBあったけど、PhotoshopでPDFを開き、幅480pxに縮小、PNGで保存し、さらに下記サービスで圧縮したところ、10KB前後まで削減でき、文句言われなくなった。
色が少なく、はっきりしている画像はやっぱりPNGが強い。

PNGイメージをオンラインで圧縮する

ロゴやヤギなど他の画像はVGを使っているため、この手法は使えなかった。

2. vue-routerで遅延評価

js側だとこれが一番効いた気がする。
公式ドキュメント通り、ページで使うVueコンポーネントをPromise返しにするだけでおk。
動作もほとんど影響しないが、これまでトップページでも読み込まれていた他のページのファイルが分割されて20点ぐらいあがった。

formatList はトップページで使うコンポーネントのため、直接importのままにした。どっちにしても数字的にはたいして影響しなかった気がする。

router.vuejs.org

router/index.js

before

import Vue from 'vue'
import Router from 'vue-router'
import formatList from '@/components/FormatList'
import formatDetail from '@/components/FormatDetail'
import formatEdit from '@/components/FormatEdit'
import notFound from '@/components/NotFound'
import helpPage from '@/components/pages/Help'
import termsPage from '@/components/pages/Terms'
import serviceInfoPage from '@/components/pages/ServiceInfo'

const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      component: formatList,
      name: 'formatList',
      meta: {
        title: SITE_TITLE
      }
    },
    {
      path: '/format/:id',
      component: formatDetail,
      props: true,
      beforeEnter: formatExistsOr404,
      name: 'formatDetail',
      meta: {
        title: '書式詳細 - ' + SITE_TITLE
      }
    },
// 省略

after

import Vue from 'vue'
import Router from 'vue-router'
import formatList from '@/components/FormatList'
import formatsData from '@/assets/formats/index'

const formatDetail = () => import('@/components/FormatDetail')
const formatEdit = () => import('@/components/FormatEdit')
const notFound = () => import('@/components/NotFound')
const termsPage = () => import('@/components/pages/Terms')
const serviceInfoPage = () => import('@/components/pages/ServiceInfo')
const helpPage = () => import('@/components/pages/Help')

// 省略

router以外でも動的コンポーネントを使っている箇所も遅延にしてみたけど、大して変わらなかった。

WEBフォントは必須にしない

今回のサービスの特質上、ユーザーとPDFサーバーでフォントをそろえる必要があった。
GoogleのWEBフォントを使っており、サイト上でもメインで使っていたけど、読み込まれるまで文字が表示されないため、大きく減点されていた。
そこでWEBフォントはどうしても必要な履歴書のプレビュー画面だけにして、他の箇所では優先順位をさげた。

あと原因が分かったけど改善が難しいもの。

vendorファイルのサイズ削減

つかってるjsのライブラリがまとまるvendorファイル。
これが3MBありビルド中もtoo Large的な警告が出ていた。
中身を確認するには下記コマンド(Webpack Bundle Analyzer)がいいらしく、今回のPWAテンプレートにも入っていたので実行してみた。

How to troubleshoot a large vendor file? · Issue #1297 · vuejs-templates/webpack · GitHub

Webpack Bundle Analyzerについて詳しくはこれ。今回ホスト名、ポート番号は設定で変えた。
www.npmjs.com

絶望の結果がこれ。
f:id:uyamazak:20180704105314p:plain

element-uiとfirebaseが2大巨頭だがどっちも深く使っているため大きく削れない。
elementは使ってないものは省けるが、commonがでかすぎて、他を削ったところで数十kb削減にしかならず、効果は薄かった。
firebaseは公式にある通り使っているものだけrequireしている。
Firebase を JavaScript プロジェクトに追加する  |  Firebase

でもPerformanceには1点も効果がなかった。

あと小さいけどlodashも使っている機能が限られているので、下記のように個別に読み込んでサイズを小さくできたが、これも結果はほぼ変わらなかった。

const MyLodash = {
  forEach: require('lodash/forEach'),
  compact: require('lodash/compact'),
  debounce: require('lodash/debounce'),
  orderBy: require('lodash/orderBy'),
  defaultsDeep: require('lodash/defaultsDeep')
}
module.exports = MyLodash

トップページの高速化ならPWA +AMP

というわけで、Vueなどを使ったSPAではライブラリの容量が大きくロード時間などで100点は難しい。
そのため、これ以上の高速化となるとAMPを組み合わせるしかない。

詳しくは公式ドキュメント
www.ampproject.org

これを使えばAMPで高速に表示して、その間にService Workerでロードしつつ、リンク先ではSPAが動くという形にでき、最初の画面表示を爆速にできるはず。

Vue + PWAの開発環境をvue-cli + pwa plugin + yarnを使ってDocker上で構築する

uyamazak.hatenablog.com
2018年の2月ごろ、VueでPWAの履歴書作成アプリを作り始めて、もうすぐ本番公開だけど改めて調べると環境がすでに大きく変わっていた。
作ったのこれ↓
rirekisho.yagish.jp

半年もたってないけどこの変化だからなるべく早く変更する準備をした方がよさそう。
大きいのは下記4点。

vue-cliのテンプレートではなくプラグインになった

以前使ったテンプレート形式のもの
github.com

新しいvue-cliプラグイン形式
vue-cli/packages/@vue/cli-plugin-pwa at dev · vuejs/vue-cli · GitHub

まだrc版なせいもあるのか、ドキュメントちょっとしか書いてない

WebPack3が4に

せっかく分かってきたところで大きく変わった
yarn.lockを見たらwebpack "^4.8.2"で入っていた

Service WorkerがWorkbox使う形に

テンプレートの時はService Workerのスクリプトは直書きされていたが、プラグインGoogleが開発しているWorkboxというService Workerを使いやすくするライブラリを使用している。
developers.google.com

PWAを推進しているGoogleが作ってるし、これは今後使っていった方が良さげ。

yarn使った方がいいかも

npm歴も浅いので違いがよくわかってないが、いろいろ良いという話は見るので、さっそく使ってみる。

ということで、さっそくDockerを使って環境を作ってみる

Dockerfile

FROM node:9-slim
RUN mkdir /pwa/
RUN apt-get update --fix-missing && apt-get -y upgrade
RUN apt-get install -y \
    git \
    bzip2

WORKDIR /pwa/

EXPOSE 8080
CMD ["bash"]

とりあえずnodeをもとにイメージを作る。
gitとbzip2は以前必要になったのでそのまま。今回はいらないかも。
適当なイメージ名をタグつけてビルドする

sudo docker build -t pwa-201807:latest .

run

作ったファイルを残しておくためappディレクトリを作ってマウント。
あと設定ファイルがホームディレクトリにできるようなので、それもマウントしておく。
rootで実行しているので/root。
本番にはビルドしたファイルのみアップし、このイメージはそのまま使わないのでrootで問題ないと思う。

mkdir app
mkdir root
sudo docker run -it --rm \
        -v `pwd`/app:/pwa/app \
        -v `pwd`/root:/root \
        -p 2106:8080 \
        --name pwa-201807 \
        pwa-201807:latest

yarnアップデート

なんかもともとyarn入ってたけど古いのでアップデートする。
1.7.0になった。

root@e8a2e26d5310:/shanyang# yarn
yarn install v1.5.1
info No lockfile found.
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
info Lockfile not saved, no dependencies.
Done in 0.06s.
root@e8a2e26d5310:/shanyang# npm install -g yarn
/usr/local/bin/yarnpkg -> /usr/local/lib/node_modules/yarn/bin/yarn.js
/usr/local/bin/yarn -> /usr/local/lib/node_modules/yarn/bin/yarn.js
+ yarn@1.7.0
added 1 package in 0.439s
root@e8a2e26d5310:/shanyang#

vue/cliインストール

ドキュメント通り
cli.vuejs.org

root@e8a2e26d5310:/shanyang# yarn global add @vue/cli
yarn global v1.7.0
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@1.2.4: The platform "linux" is incompatible with this module.
info "fsevents@1.2.4" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Installed "@vue/cli@3.0.0-rc.3" with binaries:
      - vue
Done in 8.34s.

@vue/cli@3.0.0-rc.3が入った

プロジェクト作る

名前は何でもいいけど、マウントしてあるappで作る

root@e8a2e26d5310:/shanyang# vue create app
Please pick a preset:
  default (babel, eslint)
❯ Manually select features

? Please pick a preset: Manually select features
? Check the features needed for your project:
 ◉ Babel
 ◯ TypeScript
 ◉ Progressive Web App (PWA) Support
 ◉ Router
 ◯ Vuex
 ◉ CSS Pre-processors
 ◉ Linter / Formatter
 ◉ Unit Testing
❯◉ E2E Testing

yarn使うとかいろいろ聞かれるので適当に答える。
デフォルトだとrouterとか入らないので再度やり直して、選択した。
ここにもPWAが出てきたので選択しておく

とりあえず開発サーバー起動

設定したポート(2106)にアクセスすると下記の画面が出た。

f:id:uyamazak:20180709185959p:plain

PWAプラグイン追加

インストールは
各設定をvue.config.jsかpackage.jsonのvueに書き、下記コマンドでいいらしい。

root@e8a2e26d5310:/shanyang/app# vue add @vue/pwa 

逃  Installing @vue/cli-plugin-pwa...

yarn add v1.7.0
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@1.2.4: The platform "linux" is incompatible with this module.
info "fsevents@1.2.4" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
[4/4] Building fresh packages...

success Saved lockfile.
success Saved 17 new dependencies.
info Direct dependencies
└─ @vue/cli-plugin-pwa@3.0.0-rc.3
info All dependencies
├─ @vue/cli-plugin-pwa@3.0.0-rc.3
├─ babel-runtime@6.26.0
├─ common-tags@1.8.0
├─ json-stable-stringify@1.0.1
├─ lodash.template@4.4.0
├─ lodash.templatesettings@4.1.0
├─ pretty-bytes@4.0.2
├─ workbox-broadcast-cache-update@3.3.1
├─ workbox-build@3.3.1
├─ workbox-cache-expiration@3.3.1
├─ workbox-cacheable-response@3.3.1
├─ workbox-google-analytics@3.3.1
├─ workbox-precaching@3.3.1
├─ workbox-range-requests@3.3.1
├─ workbox-streams@3.3.1
├─ workbox-sw@3.3.1
└─ workbox-webpack-plugin@3.3.1
Done in 3.54s.

✔  Successfully installed plugin: @vue/cli-plugin-pwa

噫  Invoking generator for @vue/cli-plugin-pwa...
逃  Installing additional dependencies...
yarn install v1.7.0
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@1.2.4: The platform "linux" is incompatible with this module.
info "fsevents@1.2.4" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
Done in 2.24s.

   Successfully invoked generator for plugin: @vue/cli-plugin-pwa
   The following files have been updated / added:

     public/img/icons/android-chrome-192x192.png
     public/img/icons/android-chrome-512x512.png
     public/img/icons/apple-touch-icon-120x120.png
     public/img/icons/apple-touch-icon-152x152.png
     public/img/icons/apple-touch-icon-180x180.png
     public/img/icons/apple-touch-icon-60x60.png
     public/img/icons/apple-touch-icon-76x76.png
     public/img/icons/apple-touch-icon.png
     public/img/icons/favicon-16x16.png
     public/img/icons/favicon-32x32.png
     public/img/icons/msapplication-icon-144x144.png
     public/img/icons/mstile-150x150.png
     public/img/icons/safari-pinned-tab.svg
     public/manifest.json
     src/registerServiceWorker.js
     package.json
     src/main.js
     yarn.lock

   You should review these changes with git diff and commit them.

ところどころ文字がバグってるけどいろいろPWAのファイルができた

src ディレクトリ構成

% tree src
src
├── App.vue
├── assets
│   └── logo.png
├── components
│   └── HelloWorld.vue
├── main.js
├── registerServiceWorker.js
├── router.js
└── views
    ├── About.vue
    └── Home.vue

viewsというディレクトリは所見。router用かな?
今作っているアプリはcomponentsに詰め込んでいるのでrouterで使うのはviewsに入れる形にした方がきれいかもしれない。
でも口で言うとvueと一緒だから紛らわしそう。

build時のディレクトリ構成

root@e8a2e26d5310:/shanyang/app# yarn build
yarn run v1.7.0
$ vue-cli-service build

⠦  Building for production...

 DONE  Compiled successfully in 4395ms                                                                                                                               10:04:03

  File                                      Size             Gzipped

  dist/js/chunk-vendors.217e7126.js         99.37 kb         35.09 kb
  dist/js/app.3bb5f26d.js                   5.93 kb          2.07 kb
  dist/service-worker.js                    0.94 kb          0.53 kb
  dist/precache-manifest.c4b9ea04d7e2938    0.46 kb          0.24 kb
  96813a60dd3863afb.js
  dist/css/app.928b9db9.css                 0.42 kb          0.26 kb

  Images and other types of assets omitted.

 DONE  Build complete. The dist directory is ready to be deployed.

Done in 6.60s.

package.json

root@e8a2e26d5310:/shanyang/app# cat package.json
{
  "name": "app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "test:unit": "vue-cli-service test:unit",
    "test:e2e": "vue-cli-service test:e2e"
  },
  "dependencies": {
    "register-service-worker": "^1.0.0",
    "vue": "^2.5.16",
    "vue-router": "^3.0.1"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "^3.0.0-beta.15",
    "@vue/cli-plugin-e2e-cypress": "^3.0.0-beta.15",
    "@vue/cli-plugin-eslint": "^3.0.0-beta.15",
    "@vue/cli-plugin-pwa": "^3.0.0-beta.15",
    "@vue/cli-plugin-unit-mocha": "^3.0.0-beta.15",
    "@vue/cli-service": "^3.0.0-beta.15",
    "@vue/eslint-config-standard": "^3.0.0-rc.3",
    "@vue/test-utils": "^1.0.0-beta.16",
    "chai": "^4.1.2",
    "node-sass": "^4.9.0",
    "sass-loader": "^7.0.1",
    "vue-template-compiler": "^2.5.16"
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not ie <= 8"
  ]
}

vue ui

今のところ必要ないけどGUIでもいろいろできるようになったみたい
f:id:uyamazak:20180709192035p:plain


で、これから設定を開発中のものと揃えたいのだけれど、webpack系の環境変数とかの設定ファイルが見つからず、どこに書いたらいいかわからないので、今日はここまで

Vue.jsで要素の高さを使って入力値バリデーションする

現在、近日公開予定(サービス名も未定)の履歴書作成アプリをVue.jsとFirebaseでつくっている。

2018/07/12追記 公開しました

rirekisho.yagish.jp

その中で志望動機欄のような自由記述のテキストエリアのバリデーションがなんとかできたのでメモ。

こんな感じの欄。文字数や改行で高さがオーバーしたら一段階文字サイズを小さくして、大丈夫ならそのまま、それでもはみ出るならエラーを出したかった
f:id:uyamazak:20180618160130p:plain

単純に文字数、行数だけでは制限できない

テキストエリアなので自由に改行できるので文字数が少なくてもオーバーする場合がある
行数も計算しようと思ったが、文字が多い時は一段階は文字が小さくなるようにもしたいし、いろんなCSSプロパティが絡んできて何行になるかはよくわからない。
またCSSで文字サイズや行の高さを変えるたびにプログラム側で数字の調整を行うのは大変。

欄の高さは履歴書という紙への出力という目的上、そう変わらないからここが一定を超えたらエラーを出すようにしてみる。

例(この場で打った擬似コード)として、表示している箇所は下記のようなコンポーネントにしてある。

<template>
<div>
  <pre ref="reason" v-model="data.reason"></pre>
</div>
</template>

入力は親のコンポーネントでtextareaでやってるけど普通なので省略。

refを使ってclientHeightを取得する

vue側からアクセスできるようにref属性reasonをふる。

jp.vuejs.org


そうすれば、this.$refs.reason.clientHeight でclientHeightを取得できるのでこれを使う。

developer.mozilla.org

clientHeight の代わり?にgetBoundingClientRect()を使っても要素の位置やサイズが取得でき、高さも取れる。

今回試す限り同じ数値だったのでとりあえずclientHeight でいく。

また今回はtextarea内の改行をそのまま表示できるようにpreタグを使ってる。CSSでやってもいい。

$nextTickを使って描画変更後に高さを取得する

文字サイズを小さくするclassをつけても、すぐには反映されないので、$nextTickで次フレームの処理に入れる。
$nextTickは入れ子にもできる。

あとコメントで。

<script>
// 省略
  data: function () {
    return {
      data:{
        reason:''
      },
      classObject: {
        reason: {},
      },
      // 高さをpxで
      textareaMaxHeights: {
        reason: 180
      },
      errorMessages: {
        reason: '',
      }
    }
  },
  methods: {
    adjustFontSize: function (refName) {
      // 文字サイズを小さくするclass用のオブジェクトを初期化
      this.classObject[refName] = {}
      // エラーメッセージをいれるもの。まずは空で初期化
      this.errorMessages[refName] = ''
      // まずclassなしで描画されたものを$nextTickで確認
      this.$nextTick(function () {
        if (!this.$refs[refName]) return
        const maxHeight = this.textareaMaxHeights[refName]
        // 高さがオーバーしてたらfont-size-sm classを付ける
        if (this.$refs[refName].clientHeight > maxHeight) {
          this.classObject[refName] = {
            'font-size-sm': true
          }
        } else {
        // オーバーしてなかったら取る
          this.classObject[refName] = {}
        }
       // 文字サイズが反映されたら再度確認してオーバーしてたらエラーメッセージを入れる
        this.$nextTick(function () {
          if (this.$refs[refName].clientHeight > maxHeight) {
            this.errorMessages[refName] = '入力欄をオーバーしてます'
          }
        })
      })
    }
  },
  // 入力欄をwatchして変更のたびに実行。lodashのdebounceで連続実行を制御してもいいかもしれない。
  watch: {
    'data.reason': function () {
      this.adjustFontSize('reason')
    }
  }
}
</script>

これで入力画面からはerrorMessagesを見てエラーの出し分けなどをする。複雑になってきたらvuexとか使ったほうがいいかも。

$nextTickの使い方で試行錯誤しまくったのでまだバグがありそう。

ちょっとはまったのが、clientHeight がv-if,v-showで隠してしまっていると取れないこと。
v-ifでfalseだとそもそも要素がなく、v-showだとdisplay:noneで0になってしまう。

今回入力画面と、表示確認画面の表示を切り替えるUIだったので同時に表示ができなかった。

そのため表示する場所を隠したい場合は下記CSSで隠した。

visibility: hidden;
overflow: hidden;
height: 0;

FirestoreでDateを保存すると数字になったり独自のTimestampになって困った

Firestoreはjavascriptのデータをだいたいそのままぶち込んで保存ができるので便利。

だけど、Dateを保存していると、数字型(1528444872883みたいな)や独自の下記Timestamp型になって、読み込み時にエラーになって困った。

Timestamp  |  Firebase

このキーだけ、みたいな状況ならTimestampがもってるtoDate()で変換ができるけど、入れ子が深い中にあったり、型を決められないときは全データを回して処理しなきゃいけないので対処がきつい。

今回は深いデータをまるっとバックアップするのが目的だったので保存時に

JSON.stringify(backupData)

して、テキストとして保存し、読み込み時に

JSON.parse(documentSnapshot.data().backupData)
||
してごまかした。


** 2018年6月13日追記
入れ子じゃないところでもどこかで変わってしまうらしいので、返ってきたdataのtoDateメソッドがあるかどうかを見て、無かったら文字列だからnew Date()にぶち込むという苦肉の策しか思いつかない。
どこで変更されているのか、または確実にTimestamp型で保存する方法を引き続き調査する。
>||
this.db.collection('path/to/collection')
            .where('uid', '==', this.user.uid)
            .orderBy('timestamp', 'desc')
            .limit(1)
            .get()
            .then(
              function (querySnapshot) {
                querySnapshot.forEach(function (doc) {
                  if (!doc.exists) {
                    console.log('no backup exists')
                    throw new Error('noBackup')
                  }
                  console.log('doc.data().lastSaveDate', doc.id)
                  if (typeof doc.data().lastSaveDate.toDate !== 'undefined') {
                    app.lastBackupSaveDate = doc.data().lastSaveDate.toDate()
                  } else {
                    console.log('lastSaveDate is String', new Date(doc.data().lastSaveDate))
                    app.lastBackupSaveDate = new Date(doc.data().lastSaveDate)
                  }
                })
              }).catch(function (e) {
              console.error(e)
            })