うしろのこの本ください

なんでもかきます

vite + preactで理解するviteの依存管理

やった

github.com

本当はviteで作る超速モノレポ環境!!をやりたかったが、やっていくうちに一番大事なところが微妙になったので調査の知見を残すことにした(なった)

viteとは

github.com

そもそもviteがなにかというと、今までバンドル作ってそれをwatchしてたけどなんらかの手段でHMRが実現できたら開発時はバンドルせんでもよくね?という発想から生まれた、バンドルを作らない開発サーバである。(alt VuePressであるVitePressがやりたかっただけ説もあるけど)

仕組みとしては、ブラウザが解釈できる形になんらかの方法でファイルを加工(tsxならtscsfcなら@vue/compiler-sfc)してESM形式で読み込ませることでバンドルを作らずにブラウザに即時反映している(と理解している)。

バンドルを作らないためwebpackのhmrの比ではないほどの反映速度で、体験的にはSnowpackに近い。今回はモノレポで親側をvite、子をpackages配下からルートのnode_modules経由でシムリンクにしてコンポーネントライブラリにしたらめちゃくちゃ体験いいんじゃねって思ってやったが、後述する依存最適化周りでうーんって感じになった。

モノレポの説明とかツールとかの説明は省きます。yarn workspace: trueにしてlernaコマンド書いてるだけ。

環境構築

viteのエントリーポイントがある方のpackage.jsonにはコンポーネントライブラリとなる@vite-playgorund/baseとpreactをdepsに入れる。 preactを使う場合は--jsx-factory=hフラグをつける。

バージョン指定で"*"をつけると、パブリッシュしていなくてもモノレポ内部から同名のpackage.jsonを読み出してシムリンクを張ってくれる。これで@vite-playgorund/base側のコンポーネントをいちいちビルドせずとも直接読み出すことができ、親側で巻き込んでビルドすることができる。

app/vite-app/package.json

{
  "name": "vite-playground",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite --jsx-factory=h",
  },
  "dependencies": {
    "@vite-playground/base": "*",
    "preact": "^10.4.1"
  }
}

vite側ではindex.htmlでエントリーポイントを読み込む。

app/vite-app/index.html

<div id="app"></div>
<script type="module" src="./main.tsx"></script>

通常のReactアプリケーションと同様にmain.tsxでルートコンポーネントをマウントすれば良い。

app/vite-app/main.tsx

import { h, render } from 'preact'
import { useState } from 'preact/hooks'

// @ts-ignore
import { BaseButton } from '@vite-playground/base'

const Counter = () => {
  const [count, setCount] = useState(0)
  const increment = () => setCount(count + 1)
  const decrement = () => setCount((currentCount) => currentCount - 1)

  return (
    <div>
      <p>Count: {count}</p>
      <BaseButton text="Increment" handleClick={increment} />
      <BaseButton text="Decrement" handleClick={decrement} />
    </div>
  )
}

// @ts-ignore
render(<Counter />, document.getElementById('app'))

@vite-playground/baseからコンポーネントを読み込んでいる。re export用のindex.jsとcomponents/BaseButton.tsxがあるだけのシンプルな内部パッケージで、自力でビルドする環境はない。

packages/base/BaseButton.tsx

import { h } from 'preact'

interface Props {
  handleClick: () => void;
  text: string;
}

const BaseButton = (props: Props) => {
return <button onClick={() => props.handleClick()}>{props.text}</button>
}

export default BaseButton

この状態でyarn devすると、viteが起動してBaseButtonの解決も行われてエラーもなく動作する。

f:id:apple19940820:20200526013102g:plain

これでBaseButton.tsxを変更すれば瞬時に画面反映されるんじゃ!やっぱモノレポなんだよなぁと息巻いていたが普通に変更無視されて虚無になった。くやしいので原因を書く。

depOptimizer

depOptimizerはそもそもページロードの高速化のための関数だが、事前にバンドルされていない依存が通るとそれをビルドする。生成物はキャッシュとしてnode_modules/.vite_opt_cacheの配下に置かれる。

vite.config.jsで無効にできるが、無効にするとビルドされていないnode_modules配下のコンポーネントなどは当然読み込むことができないためエラーになる。(やたらとtext/planeを読み込んどるぞというコンソール出力を目にすることになる)

https://github.com/vitejs/vite/blob/master/src/node/depOptimizer.ts

依存がバンドルを生成しているとキャッシュは作られないので普通にvite側に反映される。ただしページは自前でリロードする必要があるし、結局子はバンドルしとるのでお得感があんまりない。

@vite-component/base側で以下のようなrollupの設定を書いてwatchしたらBaseButton.tsxの編集がちゃんと反映されるようになった。

packages/base/rollup.config.js

import preact from 'rollup-plugin-preact'
import typescript from 'rollup-plugin-typescript2'

export default {
  input: {
    ui: 'index.ts',
  },
  output: {
    dir: 'dist',
    entryFileNames: 'bundle.js',
    format: 'esm',
    sourcemap: false,
  },
  plugins: [
    typescript(),
    preact(),
  ],
  external: ['preact', 'preact-compat', 'preact-compat2'],
}

f:id:apple19940820:20200526024840g:plain

ちなみにwebpackなどでesm形式以外でビルドしていた場合、以下のような丁寧な標準出力を吐いてくれる。

vite-playground: [vite] The following dependencies seem to be CommonJS modules that
vite-playground: do not provide ESM-friendly file formats:
vite-playground:   @vite-playground/base
vite-playground: - If you are not using them in browser code, you can move them
vite-playground: to devDependencies or exclude them from this check by adding
vite-playground: them to optimizeDeps.exclude in vue.config.js.
vite-playground: - If you do intend to use them in the browser, you can try adding
vite-playground: them to optimizeDeps.commonJSWhitelist in vue.config.js but they
vite-playground: may fail to bundle or work properly. Consider choosing more modern
vite-playground: alternatives that provide ES module build formts.

最適化から外すなりホワイトリストに入れるなりなんかした方がいいぜって感じ。気を使ってexcludeに入れてくるのでキャッシュも作られない。優しすぎない? 依存側もrollup使っておけば問題はなさそう。webpackの場合はめんどくさい。

おわり

理想はviteだけで全てのモノレポ内依存が解決され超速hmrが動き出荷するときにvite build一発で終わる世界だったが、そんなに美味しい話はなかった。

今回preactを使ったのはVueでやるとcompiler-sfcの依存とかが必要でめんどくさいのと前にdepOptimizerを見たときは拡張子決め打ちで.vueを対応してなくてpreactうごくyoって言ってたから。いつのまにかvitejs/viteにリポジトリも移動してるしVueだけのものとして扱わない方針っぽい。

冒頭でも触れたけどSnowpackとは今後連携して動作するようにしていくらしいので、引き続き理想のモノレポ環境を試していきたい。

余談

vite、ヴァイトだと思ってたけどヴィッテ、ヴィーテが発音的には正しいらしい。

はやそう

f:id:apple19940820:20200526103313p:plain

Vue 3 + vue-router-nextを動かす

Vue 3のbeta版がリリースされて、あわせて周辺ツールがalphaからbetaへ作業中とのことだったのでvue-router動くかなと思ってやってみた。

github.com

github.com

以下素振りりぽじとり

github.com

プロジェクトのセットアップ

必要なものをyarn addする。

yarn add vue@next vue-router@next

あと開発用にいつもの。lint周りはお好みなので省略

yarn add -D webpack webpack-cli webpack-dev-server ts-loader vue-loader clean-webpack-plugin html-webpack-plugin typescript

webpackの設定書く

webpack.config.js

/* eslint-disable @typescript-eslint/no-var-requires */
const { resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
const webpack = require('webpack')

const outputPath = resolve(__dirname, 'dist')

/** @type {import('webpack').ConfigurationFactory} */
const config = (env = {}) => ({
  mode: env.prod ? 'production' : 'development',
  devtool: env.prod ? 'source-map' : 'inline-source-map',
  devServer: {
    contentBase: outputPath,
    historyApiFallback: true,
    hot: true,
    stats: 'minimal',
  },
  output: {
    path: outputPath,
    publicPath: '/',
    filename: 'bundle.js',
  },
  entry: [resolve(__dirname, 'src/main.ts')],
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules|vue\/src/,
        use: [
          {
            loader: 'ts-loader',
            options: {
              appendTsSuffixTo: [/\.vue$/],
              transpileOnly: true,
            },
          },
        ],
      },
      {
        test: /\.vue$/,
        use: 'vue-loader',
      },
    ],
  },
  resolve: {
    alias: {
      vue: '@vue/runtime-dom',
      '~': resolve('src'),
    },
    extensions: ['.ts', 'd.ts', '.tsx', '.js', '.vue'],
  },
  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: resolve(__dirname, 'src/index.html'),
    }),
    new CleanWebpackPlugin(),
  ],
})

module.exports = config

適当にエイリアスの設定とかもしておく。

package.jsonに開発鯖起動用のスクリプト書く。

"scripts": {
    "dev": "webpack-dev-server --mode=development",
}

これでsrc/main.tsをエントリポイントとしてサーバーが立ち上がるようになるはず。

composition api + vue-router

viewsにindex.htmlを適当に用意。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Poketto</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

エントリポイントを定義する。従来とは若干apiが変わっているため注意。

createAppに<router-view />が定義されているメインのコンポーネントを渡し、rootにマウントする。やっていることは今までのVueと同じ。

main.ts

import { createApp } from 'vue'
import App from '~/App.vue'
import { route } from '~/router'

const app = createApp(App)
app.use(route)
app.mount('#root')

現時点でApp.vueもrouterもないので定義していく。

App.vue

<script>
export default {
  name: 'App',
}
</script>

<template>
  <div>
    <router-view />
  </div>
</template>

ページコンポーネントを定義する。

今回はカレントパスとなるindex.vueとサブページsub.vueを作る。なんか表示したかったので適当にcomputedを利用したreadonlyなデータを吐く関数も用意した。(useAppConfig)

vue-routerは既存のthis.$routeからのアクセスではなくなり、useRouterというnamed exportされている関数を用いることでjavascript側からhistoryの操作をすることができる。 router-linkは今まで通りに使えるが、特に型が効いたりはしない。

views/useAppConfig

import { computed } from 'vue'

export const useAppConfig =
  computed(() => {
    return {
      name: 'poketto',
      version: '0.0.1',
      mode: process.env.NODE_ENV,
    }
  })

views/index.vue

<script lang="ts">
import { defineComponent } from 'vue'
import { useAppConfig } from '~/views/useAppConfig'
import { useRouter } from 'vue-router'

export default defineComponent({
  name: 'Index',
  setup() {
    const router = useRouter()
    const toSub = () => router.push({ name: 'sub' })
    return {
      useAppConfig,
      toSub
    }
  },
})
</script>

<template>
  <div>
    <p>{{ useAppConfig.name }}</p>
    <p>{{ useAppConfig.version }}</p>
    <p>{{ useAppConfig.mode }}</p>
    <router-link :to="{ name: 'sub' }">
      to sub link
    </router-link>
    <div>
      <button @click="toSub">
        to sub button
      </button>
    </div>
  </div>
</template>

views/sub.vue

<script lang="ts">
import { defineComponent } from 'vue'
import { useRouter } from 'vue-router'

export default defineComponent({
  name: 'Sub',
  setup() {
    const router = useRouter()
    const toHome = () => router.push({ path: '/' })
    return {
      toHome
    }
  },
})
</script>

<template>
  <div>
    <p>Sub Page</p>
    <router-link :to="{ path: '/' }">
      home
    </router-link>
    <div>
      <button @click="toHome">
        to sub button
      </button>
    </div>
  </div>
</template>

routerの定義。

useRouterと同様に、新しくrouter作成用の関数などがnamed exportされるようになっているため、これらを使う。

router.ts

import { createRouter, createWebHistory } from 'vue-router'
import Index from '~/views/index.vue'
import Sub from '~/views/sub.vue'

export const routerHistory = createWebHistory()

export const route = createRouter({
  history: routerHistory,
  routes: [
    {
      path: '/home',
      redirect: '/',
    },
    {
      path: '/',
      name: 'index',
      component: Index,
    },
    {
      path: '/sub',
      name: 'sub',
      component: Sub,
    },
  ],
})

ここまでできたらyarn devで動作確認。

f:id:apple19940820:20200428191849g:plain

うごく

おわり

vue-routerはまだalphaなので大きくapiが変わる可能性もあるが、現時点ではちゃんと動作する。

別でフルtsxで書いてみたけどvue-routerはなんか動かなかった。あまり追えてない。

github.com

font-awesomeをtree shakingする

メモ

Nuxtのpluginsでfont-awesome(pro)を読み込んでいたが、脳死で全部importしていてくそでかall.jsが鎮座し虚無だった。 不要なフォントをtree shakingするようにした。

befor

import '@fortawesome/fontawesome-pro/js/all'

after

import { dom, library } from '@fortawesome/fontawesome-svg-core'
import { faAngleRight } from '@fortawesome/pro-regular-svg-icons/faAngleRight'
import { faAngleLeft } from '@fortawesome/pro-regular-svg-icons/faAngleLeft'

library.add(faAngleRight, faAngleLeft)
dom.watch()

これでプロジェクト内ではdeep importされているアイコンが使え、残りはwebpackのビルドで落ちる。

before f:id:apple19940820:20200328003027p:plain

after f:id:apple19940820:20200328003218p:plain

2MBくらい減って笑顔になった。font周りは下手なjsライブラリよりもビルドサイズ肥大化に貢献してくるため注意したい。

参考 https://fontawesome.com/how-to-use/with-the-api/other/tree-shaking

Vueコンポーネントのmethodsだけテストする

VueはReactと違いコンポーネントからロジックを剥がそうとするモチベーションがあまり起きない作りだなーと感じている。SFCファイルに全部閉じ込めてしまった方が気持ち良い。しかしテストをmethodsやcomputedの入出力に対して行いたいものの、外出ししようとすると以下のようなthisを受け取る関数を作ることになったりする。テストはやっぱり関数に対して書きたいしなるべくvue-test-utilsに頼りたくない。

const validatePath = (path: string) => /^\/.+$/.test(path)


export default {
  data() {
    return {
      path: '/'
    }
  },
  methods: {
    validatePath() {
      return validatePath(this.path)
    },
  }
}

これはこれで良いが、Vueコンポーネントはマウントせずにmethodsやcomputedを分割代入で取り出すこともできる。テストファイルで以下のようにすれば、ロジックを関数に抽出していなくてもとりあえずテストを書くことができる。

// Login.vue
<template>
  <div>
    <input v-model="path" />
    <button @click="validatePath(path)" />
  </div>
</template>

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

// Login.spec.ts
import Login from '~/pages/Login'

describe('ログインページのテスト', () => {
  const { methods: { validatePath } } = Login as any

  test('validatePathメソッドが先頭スラッシュの文字列でTrueを返す', () => {
    const valid = validatePath('/Hello')
    expect(valid).toBeTruthy()
  })

  test('validatePathメソッドが先頭スラッシュじゃない文字列でFalseを返す', () => {
    const valid = validatePath('Hello/vue')
    expect(valid).toBeFalsy()
  })
})

ただし、前述のように常に関数として切り出しておいた方がテスト自体書きやすいしそもそもテストファイルにコンポーネントをimportしなくて済む。

コンポーネント設計時にテストを書くことが考慮されていなかった場合、methodsが副作用を持っていたりしてテスタブルじゃなくなっていることも多いためなるべく関数に分解することが大切。特にVueはthisというコンテキストに依存するライブラリなのでこれがじわじわ効いてくる。

殆どの問題はVue3で解消するが、今のうちにテスタブルな関数の勘所を抑えておくと違和感なく移行できると思う。

lernaコマンドの標準出力が微妙な時はstreamオプションをつける

TL;DR タイトル

lernaはモノレポ管理下にあるpackage.jsonのコマンドを同時に実行することができる。

lerna run --scope s-* lint

とするとpackage.jsonのnameがs-で始まるすべてのワークスペースnpm run lintが実行される。(s-はオレオレパッケージのプレフィックスです)

f:id:apple19940820:20200120131838p:plain

モノレポではワークスペースでlintの設定を統一したり、ビルドのコマンドを統一したりするのでlernaのコマンド実行は便利。

しかし問題もなくはない。

標準出力がしょぼい

一部コマンドではルートのpackage.jsonにlernaコマンドを書いて実行すると、Warningになっていても標準出力に出ずsuccess!とだけ表示される。鬱なのでなんとかしたい。

lernaからの実行では一見パーフェクトに見えるが f:id:apple19940820:20200120125453p:plain

ワークスペースに潜ってyarn generateするとバンドルサイズ周りでwebpackが警告出していたという罠 f:id:apple19940820:20200120125604p:plain

--stream オプションをつけて子の標準出力をlernaに送る

runコマンドはオプションで--streamをつけると子プロセスの出力をストリーミングすることができる。各ワークスペースのコマンド実行ログがそのまま流れるため、lernaからの実行時でも重要な標準出力を見逃すことがない。

出力がごちゃ混ぜになってやばいみたいなこともない。

f:id:apple19940820:20200120130206p:plain

他にも並列実行のオプションや実行時のパフォーマンスを測定するオプションもある。需要に応じて使ってみると良いかもしれない。

あけおめ

12月に一回もブログ更新しなかったんですがQiitaに2本アドベントカレンダーかいてました。

Nuxt.jsで権限管理 - Qiita

Composition APIってなんだ - Qiita

去年はミリシタへの課金額振り返ったりロードマップのやつ書いたりしてましたが、どちらもそれほどモチベはないので今年はやりません。個人的に1月2日は1年で一番自由で後ろを考えなくてよくて好きです。今は実家でポケモンで遊んでます。

今年の抱負とかはあんまり思い浮かばないんですけど、年末〜年始にかけて萎びた心の状態を元に戻して元気に働けたらなと思います。

そういえば地元の友達たちと久しぶりに会って気づかされたんですが、「年の割に落ち着いているね」みたいなのは数年後は「年相応だね」になってとくに目立ったものではなりますね。

いつまでも大学生みたいなやんちゃな奴が長い目で見れば魅力的に映るんじゃないだろうか。僕みたいな騒いだり今日くらいハメを外してみたいなノリを楽しめない人は上に立つのに向いてなさそうだなって思わされた年末でした、しらんけど。

今年も書きたいことがあれば書いていきます。

lerna link convertを理解する

最近案件でモノレポ化が盛んになっていて、自分はフロントエンド周りを少しずつ進めています。

構成はlerna + yarn workspaceの基本的なものですが社内ライブラリをパブリッシュしない方向で進めることになり、モノレポ内ですべて完結させる方針です。

で、今までバラバラだったライブラリが一括管理できるので依存関係とかも全部合わせようとなりました。いわゆるFixed modeです。

イメージとしてはこんな感じ

frontend/*
packages
        /a
        /b
        /c
node_modules/*
lerna.json
package.json
yarn.lock

packagesの配下に社内ライブラリが複数入る感じ。workspaceのスコープですがfrontendの直下すべてとpackagesの直下すべてになっていて、lerna run lintとかやると全てのディレクトリでlintが実行されます。

yarn workspaceを使うと各ディレクトリにはnode_modulesが作られず、ルートに巻き上げられます。yarn.lockを見てみるとこのようになっています。(例です)

"a-lib@file:packages/a":
  version "1.0.0"
  dependencies:
    cross-env "^5.2.0"
    vue "^2.6.0"

dependenciesはこんな感じでルートのlockファイルにまとめて記述されます。ではdevDependenciesはどうかというと、普通に記述されます。要するにdevDependenciesに関してはモジュールの実体がルートのnode_modulesに入るだけで個別のスコープに紐づくわけではないということです。共通化されます。

devDependencies自体が共通化されることは良いんですが、共通化するならそれぞれのpackage.jsonに書かれているやつもルートにまとめたいですよね。ということで使うのが lerna link convert です。実行するとワークスペース内のすべてのdevDependenciesがルートへ集約されます。

これでdependencies(パッケージ固有の依存)もdevDependencies(モノレポで使う開発用依存)も良い感じになりました。

lerna link convert、どう動いてるの?

ドキュメントが薄くて裏で何が起こっているか全くわからなかったのでコード読んだ。コマンド自体は @lerna/link のもので、convertはオプションになります。

github.com

READMEには特に書かれていませんね。

コードを読むとなんとなく何をやっているかは分かります。lernaはCommandというクラスを継承してオーバーライドしています。

  execute() {
    if (this.options._.pop() === "convert") {
      return this.convertLinksToFileSpecs();
    }

    return symlinkDependencies(this.allPackages, this.targetGraph, this.logger.newItem("link dependencies"));
  }

オプションが convert なら convertLinksToFileSpecs を呼び出すようになってますね。そうでなければシムリンクを貼るような挙動になっています。つまり lerna link です。

convertLinksToFileSpecs() {
    const rootPkg = this.project.manifest;
    const rootDependencies = {};
    const hoisted = {};
    const changed = new Set();
    const savePrefix = "file:";

    for (const targetNode of this.targetGraph.values()) {
      const resolved = { name: targetNode.name, type: "directory" };

      // install root file: specifiers to avoid bootstrap
      rootDependencies[targetNode.name] = targetNode.pkg.resolved.saveSpec;

      for (const depNode of targetNode.localDependents.values()) {
        const depVersion = slash(path.relative(depNode.pkg.location, targetNode.pkg.location));
        // console.log("\n%s\n  %j: %j", depNode.name, name, `${savePrefix}${depVersion}`);

        depNode.pkg.updateLocalDependency(resolved, depVersion, savePrefix);
        changed.add(depNode);
      }

      if (targetNode.pkg.devDependencies) {
        // hoist _all_ devDependencies to the root
        Object.assign(hoisted, targetNode.pkg.devDependencies);
        targetNode.pkg.set("devDependencies", {});
        changed.add(targetNode);
      }
    }

    // mutate project manifest, completely overwriting existing dependencies
    rootPkg.set("dependencies", rootDependencies);
    rootPkg.set("devDependencies", Object.assign(rootPkg.get("devDependencies") || {}, hoisted));

    return pMap(changed, node => node.pkg.serialize()).then(() => rootPkg.serialize());
  }

まあざっくりとした理解ですが単にdevDependenciesをルートにマージして上書きしているようです。元からは消す。"巻き上げ"の正体は本当に記述を移動しているだけっぽいですね。あとは各パッケージのdependenciesへの参照を file:path/パッケージ名の形で追記しています。

おわり

lernaは基本複数のパッケージに対して同じコマンドを実行するためのツールとして使うのが良いと思いますが、依存の巻き上げなどでも使えて楽で良いです。 ただしルートにすでに同じパッケージが存在する場合バージョンが低い方に上書きされるため注意です。(直近babel周りで死にまくっている)

Nuxtのpwa-moduleでプッシュ通知令和版

直近プッシュ通知の実装が必要になったためハマったところ中心に結果をメモる。

受け取り側

pwa-moduleはOneSignalをサポートしているため、Nuxtでプッシュ通知をやりたい場合これを使うのが一番簡単。

pwa.nuxtjs.org

とりあえずpwa moduleとone signal moduleを入れてnuxt.config.jsに設定を書けば受け取り側の設定は終わる。

yarn add @nuxtjs/onesignal @nuxtjs/pwa

nuxt.config.js

// 省略
  modules:
      [
          '@nuxtjs/onesignal',
          '@nuxtjs/pwa'
      ],
  oneSignal: {
    init: {
      appId: 'One Signalコンソールで発行したID',
      allowLocalhostAsSecureOrigin: true, // localhostで動作確認する場合true
      welcomeNotification: {
        disable: true
      },
    },
    importScripts: ['sw.js'], // 後述、必須
  },
  pwa: {
    workbox: {
      dev: true, // devモードで起動した時でもServiceWorkerを有効にする
    },
    manifest: {
      name: 'test',
      short_name: 'test',
      title: 'test',
      'og:title': 'test',
      description: 'test',
      'og:description': 'test',
      lang: 'ja',
      theme_color: '#ffffff',
      background_color: '#ffffff'
    },
  },
// 省略

ワーカーが複数ある場合、メインのワーカーに他のファイルをマージする必要がある。OneSignalのSDKが取り込めるように自動生成してくれるが、デフォルトのパス指定が/sw.js?xxxxxxxxのような形で生成されうまく読み込めないので、importScriptsで明示的に指定する。

うまくいっていればdeveloper consoleのApplicationタブで確認することができる。

f:id:apple19940820:20191101110929p:plain

送信側

OneSignalにサインアップする。GitHubアカウントやGoogleアカウントで登録できる。

Add a New Appでアプリケーションを作成する。適当に名前いれてADD APP。

その後どのプラットフォーム向けに作るかの選択肢がでるためWeb Pushを選ぶ。Choose IntegrationはTypical Siteで。

Site Setupは以下のように

f:id:apple19940820:20191101112158p:plain

ローカルで確認したいので、localhosthttps扱いにする。

Permission Prompt SetupはとりあえずNative Propmtで。カスタマイズもできるがブラウザデフォルトであるメリットの方が大きい。

これ

f:id:apple19940820:20191101114516p:plain

Welcome Notificationは受け取り側で無効にしたためスルーでOK。

Advancedは少し気をつける必要がある。ServiceWorkerでキャッシュしたいページがネストしてる場合ここで設定しないとうまく動かない。今回/mediaでやりたいので以下のように設定する。

f:id:apple19940820:20191101113407p:plain

前と後ろに/をつけないと壊れる。OneSignalSDKWorker.jsとかは自動生成されるCDNからSDKをとってくるためのワーカーファイルで、importScriptsのパスがあーだこーだはこのファイルの話。

ここまでできたらSAVEを押す。するとWeb Push設定用のページに行くのでここでappIdをコピペして、クライアント側のnuxt.config.jsのOneSignalの設定に追記する。

FINISHで設定完了。

プッシュ

OneSignalのヘッダーからMessagesに行ってNewPushを押すと設定画面にいける。

f:id:apple19940820:20191101144812p:plain

適当にtitleとか埋めたらSchedleでいつプッシュするかを決められる。今回はすぐ見たいのでデフォルトのままで。

f:id:apple19940820:20191101114202p:plain

誰に対して投げるか、購読しているユーザー数、内容とブラウザーなどを確認してSEND MESSAGEでプッシュ通知が飛ぶ。

f:id:apple19940820:20191101144824p:plain

いざプッシュ

f:id:apple19940820:20191101144439p:plain

できた

おわり

パス周りでわりとハマったものの受け取り側はモジュール入れて設定ちょっと書くだけで良いしついでにPWA化もできてコスパ良い。(プッシュ通知の是非は置いておいて)

OneSignalも技術的な知見がなくても操作できるコンソールが用意されているのでマーケティング担当者が自由に通知できる。

NuxtとComposition APIとtsxで素振り

した

github.com

setupとtsxを紐づけるため別途プラグインが必要だが、普通にかける。

以下の流れで環境を作れる。

プロジェクト生成

npx create-nuxt-app

必要なモジュールのインストール

yarn add @nuxt/typescript-runtime @vue/composition-api
yarn add -D @nuxt/typescript-build babel-preset-vca-jsx

package.jsonを修正

  "scripts": {
    "dev": "nuxt-ts",
    "build": "nuxt-ts build",
    "start": "nuxt-ts start",
    "generate": "nuxt-ts generate"
  },

composition apiプラグインに登録

滅多にないだろうけど.tsにしとけばtypoが減る。

import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'

Vue.use(VueCompositionApi)

nuxt.config.jsを修正(ついでにts化)

拡張子を.tsに変更

import { Configuration } from '@nuxt/types'

const config: Configuration = {
  buildModules: ['@nuxt/typescript-build'],

  ~省略~

  /*
  ** Plugins to load before mounting the App
  */
  plugins: ['@/plugins/composition-api'],

  ~省略~

  build: {
    babel: {
      presets({ isServer }) {
        return [
          [require.resolve('babel-preset-vca-jsx')],
          [
            require.resolve('@nuxt/babel-preset-app'),
            {
              targets: isServer ? { node: 'current' } : { ie: '9' },
            },
          ],
        ]
      },
    },
  },
}

export default config

tsconfig.jsonをルートに作成

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "jsx": "preserve", // これないと動きません
    "lib": [
      "esnext",
      "esnext.asynciterable",
      "dom"
    ],
    "esModuleInterop": true,
    "allowJs": true,
    "sourceMap": true,
    "strict": true,
    "noEmit": true,
    "baseUrl": ".",
    "paths": {
      "~/*": [
        "./*"
      ],
      "@/*": [
        "./*"
      ]
    },
    "types": [
      "@types/node",
      "@nuxt/types"
    ]
  },
  "exclude": [
    "node_modules"
  ]
}

ルートにshimsを置いてtsx用の型を拡張

shims-tsx.d.ts

import Vue, { VNode } from 'vue'
import { ComponentRenderProxy } from '@vue/composition-api'

declare global {
  namespace JSX {
    interface Element extends VNode {}
    interface ElementClass extends ComponentRenderProxy {}
    interface ElementAttributesProperty {
      $props: any;
    }
    interface IntrinsicElements {
      [elem: string]: any;
    }
  }
}

あとはお好みでtypescript-eslintを入れたりする。今回は省略。

書き方

SFCではなくtsxファイルで書く。exportするオブジェクトは createComponent でラップすると型推論が効くようになる。

propsは PropType に型を渡してアサーションすることで型付けする。setup()の第一引数内でも型推論してくれるようになるためpropsが増えまくってもtypoとかはなさそう。第二引数にコンテキストが入っていてemitとか使える。tsxはまあtsxって感じ。

import { createComponent, PropType, reactive } from '@vue/composition-api'

interface IncrementalObjProps {
  buttonText: string
  value: number
}

export default createComponent({
  props: {
    incrementalObj: {
      type: Object as PropType<IncrementalObjProps>,
      required: true
    },
  setup({ incrementalObj },{ emit }) {
    const r = reactive({count: incrementalObj.value})
    return () => (
      <div>
        <div>{r.count}</div>
        <button onClick={() => {
            r.count++
            emit('countUp', { count:r.count } )
          } } >{incrementalObj.buttonText}</button>
      </div>
    )
  }
})

読み込み側では普通にimportしてコンポーネントとして利用できる。SFCから読み込むことも可能。

Composition APIの出来が良くてReactじゃなくVueで書きたいけどテンプレートは嫌いなんだぜって人は良さそう。

余談

サンプルのGanahaBirthday.tsxは微妙に動かないんだけどいまいち原因がわからない。setupが1度しか評価されないのでpropsで渡ってきても変更を追えてない気がする。compositionはスマートにかけてとても良いんだけど今までのVueの直感とは微妙に違う点でハマりそうな気がしてる。

それからComposition APIでやるならTSは必須だと思っていて、Option APIでは必ず値の出所がわかる(methods,computedなど)ようになっていたけどそれがsetup()に埋もれてしまい、どこから出てきた値なのかがコードで追い辛い。型情報があればマシになるだろうという考え。

ミリオンのワンナイトクルーズはガシャと比べてコスパが良いのか確認する

クルーズのステートプランがミリシタのガシャ天井より安いと話題なので、ガシャと比べてどれくらいお得なのかをざっくり調べてみる。

ガシャは狙いのSSRが出るまで引くものとすると、"好きなSSRが出る期待値"とクルーズの値段を比べてクルーズの方が下回っている場合ガシャよりコスパがいいとする。

前提条件

クルーズ側

  • ステートJ3名利用(61,600)

ガシャ側

  • SSRの排出率3%
  • ピックアップ排出率0.495%
  • 天井あり
  • 有償ジュエルのみ(消費税増税後)

計算に利用するサイト

ガチャの期待値の計算(天井付きガチャ用) - 高精度計算サイト

結果

f:id:apple19940820:20190924134439p:plain

クルーズの値段61,600円に対してSSRの期待値の値段は46,455円となった。期待値計算ではSSR取得の方がコスパが良いように見える。

しかしSSRは出た時点で実質無料となるためクルーズはガシャに比べ61,600円お得である。

おわりに

100万払っても買えないものが船にはあるはず(ドヤァ

NuxtMeetUpに登壇してきました

これに株式会社ROXX枠でLTしてきました。Composition APIについて、Nuxtと合わせて素振りした感想みたいな感じ。

nuxt-meetup.connpass.com

スライド

slides.com

初LTの割にはうまくやれたかなと思います。poaroファンとしてアナ尻遵守を心がけましたが15秒漏れました。(そもそも押してたけど)

懇親会でも色々な方と喋る機会があり色々と収穫があって良かったです。半分以上ラジオ/アイマスの話が流れるTLですがTwitterフォローしてくださった方はありがとうございます。

内容ですが意外とfunction api、つまり現composition apiについて知らない方が多かったなという印象です。まあまだRFCですし、プロダクトでのみVueやNuxtを使っている人には無用の長物というのはそれはそうなのでこんなもんかなーと。

このスライドは2週間前には完成していて、直後(ちょうど自分の誕生日)にNuxtが2.9にあがってしまい、TS周りのことも書いていたためかなり修正に時間をとられてしまいました。

さらに言うとこれは登壇後に気が付いたんですがvue-function-apiがcomposition-apiに変わっており、apiもいくつか変更されていたりして帰宅してから慌てて使用済みスライドを直すなどなかなかな体験でした。

composition apiのドキュメントまであります。(まだ薄めだけど)

vue-composition-api-rfc.netlify.com

こんな感じで簡単に破壊的変更が起こるのがこの界隈の今って思ってます。今の主役はVue3.0で、それに向けて周りが慌ただしく動いている最中です。 それでも現時点でcomposition apiの出来に関してはとても良いなとは思っていて、よく関数でやるならReactでよくないかと言われますが、言うのもまだ時期尚早な段階でcomposition apiがどういった変化をVue界隈にもたらすのかちゃんと観測してからそういうのやろうね、というのが自分の意見です。(何を言うのかは個人の自由です) 面白いおもちゃが手に入るんだからやってみようぜっていう。

Reactには似たAPIにReact Hooksがありますが、お試しで出てからの期間を考えるとユーザーに根付くまで十分な時間が経っています。Vueユーザーにはこの「HooksのようなAPI」について勘所や知見などまだ何もないのが現状で、ここが成長し成熟していくのがコミュニティであり技術だと考えています。何が言いたいかと言うと俺と一緒に人柱やらないか?ってことです。

何はともあれ数百人規模のイベントが無事に終わり、しっかり撤収まで出来たのは会場を提供いただいたメルペイの皆様のおかげですし、静かに見守っていただいた来場者の皆様のおかげですし、弊社開発チームとCTOメンバーのサポートあってのことですね。自分一人だけでは何もかも経験できないことと感じました。

次があればまた登壇したいです。あまりにも会社の話をしてなくてアレだったので今度はそっちよりの話ができればいいかも。

イベントレポート

techblog.scouter.co.jp

おわりだよ〜

Svelte3のストアを触ってみる

自分が書いたタイミングがv3リリース後すぐだったので今のSvelteと差異があるかも。

svelte.dev

つくったやつリポジトリ

github.com

つくったやつ

ushironoko-svelte-sample.netlify.com

つくったってほどでもないけど一応netlifyに投げた。

消えるフレームワークことSvelteが少し前にv3になったということで、少し前に触っていた。中でもstore周りが面白かったのでメモがてらに書き起こしてみる。

Svelteとは

Write less code、No VDOM、Truly reactiveを掲げる新し目のWebアプリケーションフレームワークで、ビルドするとランタイムが消える。つまりプレーンなjsアプリケーションとして動かすことができる。いつの間にかランタイム実装周りがTS化してた。

面白いのはSvelte本体がstoreを内包しているところで、実装自体も200行弱ととてもシンプル。

github.com

Svelteでは.svelteファイルの中にscriptとhtmlの双方を書くことができる。例えばカウントの値をstoreに管理させるコードはこうなる。

<script>
    import { count } from '../store/stores.js'
    export let incrementalButtonText
    export let decrementalButtonText
    export let resetButtonText
</script>

<p>count: {$count}</p>

<button on:click={count.increment}>{incrementalButtonText}</button>
<button on:click={count.decrement}>{decrementalButtonText}</button>
<button on:click={count.reset}>{resetButtonText}</button>

こんな風にscript内にロジックを記述して、外にJSXライクなマークアップを書く。これはボタンコンポーネントとしての実装だとして、ルートコンポーネントで使うときはこう。

<script>
    import Buttons from './components/Button.svelte'
    let incrementalButtonText = ' + '
    let decrementalButtonText = ' - '
    let resetButtonText = ' 0 '
</script>

<Buttons {incrementalButtonText} {decrementalButtonText} {resetButtonText} />

子でexportした変数を親からpropsで渡す。結構独特な記述。

肝心のstoreはこうなっている。

import { writable } from 'svelte/store';

function createCount() {
  const { subscribe, set, update } = writable(0);

  return {
    subscribe,
    increment: () => update(s => s + 1),
    decrement: () => update(s => s - 1),
    reset: () => set(0)
  };
}

export const count = createCount();

svelte/storeから必要なAPIをimportする。例えばwritableは初期値を渡すとsubscribesetupdateの3つを吐き、それぞれリアクティブに動作する関数を定義することができる。

ユニークな点としてreadableという読み取り専用ストアとderivedというストアを派生させるAPIが用意されている。派生させたストアは依存関係が更新されると引数にとったコールバックが実行される。

https://svelte.dev/docs#derived

TSで再実装されているのでTSで書けばストア周りの型が効くはず。

所感

シンプルかつ高速で型も効いて良さげ。store周りはコンポーネントごとに状態管理を持つ感じで、最近流行ってるやつだとは思う。

バケツリレー時のemitの記述量が少なくなるようなアプローチをとっている点もユニークで面白い。またアニメーション、モーション、トランジション等のサポートもコアに含まれているので、Vue飽きたなって人はこっちでそういうのやってみても面白いかもしれない。

すぐに試してみたい時は公式チュートリアルがあってブラウザ上で色々学べる。a tour of go的な。

svelte.dev

あとは公式REPLがあってこれもブラウザで試せる。

svelte.dev

ちなみに読み方はスヴェルテっぽい。フランス語でシュッとした的な意味らしい。

ミリシタAPI Princess の型定義かいた

書いた。

github.com

別にDefinitelyTypedとかにはあげてないしパッケージ化もしてないので使うときはクローンするか、index.d.tsをコピペでよろしく。

ドキュメントの型を型定義ファイルに落としただけ。

api.matsurihi.me

でれぽとかはやってなくて、あくまでミリシタのAPI部分のみ。やりたい人はPRで。 でれぽだけじゃなくて普通にissueとかもあればどんどん投げてください。周年イベラン中に書いたから自信ない。

使い方

types/princessとか切ってそこにindex.d.tsを配置、使うところで必要な型をimport

以下の設定をtsconfig.jsに書くといちいちディレクトリ構造を書かなくてもよくなる

"baseUrl": "./",
"paths": {"princess": ["types/princess"]},
"typeRoots": ["types", "node_modules/@types"],
import { Cards } from 'princess'
import axios from 'axios'

const card: Cards = axios.get('https://api.matsurihi.me/mltd/v1/cards/250')

みたいな。

レスポンスの型定義のみなのでメソッドとかはない。気が向いたらクエリメソッドの型も書くかも。

Role-Based Access Control (RBAC) をVue.jsで表現する

元ネタ

auth0.com

最近仕事で権限ごとに表示できるコンポーネントを制御する必要がでてきて、さてどうするかというタイミングでチームメンバーがRBACのことを教えてくれた。

Roleは1つ以上の権限を持ち、権限はコンポーネントの表示を制御する。まあ難しい考えかたは必要なくて、単にロールと権限の定義と、ログインユーザーのロール、コンポーネントを表示できる権限があれば簡単に実装できる。

以下は動的な制御が不要な場合の例

rbac.js

import rules from "./rbac-rules"

const check = (role, action) => {
  const permissions = rules[role]
  if (!permissions) {
    // ロールが存在しないためfalse
    return false
  }

  if (permissions.includes(action)) {
    // ロールが渡された権限を持つためtrue
    return true
  }

  return false
}

export default check

rbac-rules.js

export default {
  visitor: [
    'contents_read'
  ],
  writer: [
    'contents_read',
    'contents_update'
  ],
  admin: [
    'contents_read',
    'contents_update',
    'user_update'
  ]
}

Vueではslotを使ってコンポーネントを渡し、中でcheckで権限を見て出し分けると良い感じになる。そのためのラッパーコンポーネントを作る。

Can.vue

<template>
  <div>
    <slot v-if="checkRule" name="yes" />
    <slot v-else name="no" />
  </div>
</template>

<script>
import check from '~/auth/rbac'

export default {
  props: {
    role: {
      type: String
    },
    action: {
      type: String
    }
  },
  computed: {
    checkRule() {
      return check(this.role, this.action)
    }
  }
}
</script>

これで一応コンポーネント2つ渡せば権限NGだった時に任意のコンポーネントにフォールバックさせることができる。不要なら2つ目は渡さなくても良い。

Can.vueを使った例

Hoge.vue

<template> 
 <div>
    <Can role="visitor" action="contents_read">
      <template #yes>
        <Text text="権限あるよ" />
      </template>
      <template #no>
        <Text text="権限ないよ" />
      </template>
    </Can>
  </div>
</template>

<script>
import Can from '~/components/Can'
import Text from '~/components/Text'
export default {
  components: {
    Can,
    Text
  }
}

今回の場合ロールが複数あると判定できないので、ロールを配列で渡してキーとマッチングして取れた配列を単一の配列にマージする、もしくは同じことをするreducerを書くとかする必要がある。ロールはpropsじゃなくてCanからstoreを見るなりしても良い。

また、動的な権限制御が必要な時は定義にstaticとdynamicの概念を追加する必要がある。元ネタの方に載っているので興味ある人は実装してみると良いと思う。

おわり

Flutter始めたので導入周りメモ

Flutterがfor webを発表し名実ともにマルチプラットフォーム対応になったのでいっちょやるかと思い立ち、環境構築した。環境はmacOS64bit。

flutter.dev

FlutterはDartという言語を用いてマルチプラットフォームGUIアプリケーションを作ることができるGoogle製モバイルアプリケーションフレームワーク。モバイルとはいえ昨今はWebもモバイルファーストばかりなので問題なし。

環境は基本公式のドキュメントを見ながらやれば特につまづくことはないはず。

flutter.dev

今回はユーザーの下に落としたzipをunzipした。Goも同じ場所にあるしここでいいかみたいな感じで。ほんとはDeveloper直下とかのが良いはず。。。

パスを通すとflutterコマンドが使えるようになる。手順にはexportでやるとあるけど揮発するので.bash_profileに追記する方が良い。

その後はflutter doctorで足りない依存を検査してくれる。これがかなり親切で、○○が足りないから××をやれと教えてくれる。

自分の場合はAndroid Studioが入っていなかったので、以下のようになった。

f:id:apple19940820:20190512194328p:plain

VSCodeは入ってる人多いと思うけどなかったら最新版入れる。入れるだけでOK。

Visual Studio Code - Code Editing. Redefined

Android SDKのためにAndroid Studioを入れる。

Download Android Studio and SDK tools

Android Studio入れるだけではダメで、FlutterプラグインDartプラグインが必要らしい。セットアップ後pluginsからマーケットプレイス開けるのでそこでFlutterを入れるとついでにDartプラグインも入る。

f:id:apple19940820:20190512200037p:plain

あとxcodeのインストールが不完全とかbrewで色々いれろコマンドはこれだとかとにかく親切。よっぽど酒が回ってない限りは大丈夫なはず。

自分はターミナル分割して上で逐次doctorしながら下でbrewで必要なものを入れてインストーラあるやつは別でみたいな感じで作業を進めた。昨今はインストール作業自体軽くなってて同時に進行してもクソスペックPCじゃなければ固まることもない。(ブログも並行して書いてる)

f:id:apple19940820:20190512200400p:plain

特にcocoa podsのセットアップは時間かかるので先にやった方が良い。XCodeはクソ重くて後回し。

connected deviseは適当なスマホをPCに繋げればいい(はず)。色々やってもだめならflutter doctor -vで認識する(はず)。

自分のflutter doctor -vの結果

f:id:apple19940820:20190512203621p:plain

もろもろセットアップが終わったらflutter createでプロジェクトを生成でき、flutter runで起動するが、xcodeで署名を作った少し設定をいじる必要がある。詳細は以下が詳しい。実機で動かしたい場合も参考になる。

qiita.com

f:id:apple19940820:20190512203740p:plain

使うエディタは公式的にはAndroidStudio or Intelli J or VSCodeVSCodeがお手軽だしWebから来た人はflutterプラグイン入れるだけで良くておすすめ。プロジェクトを生成すると以下のような構成になる。

f:id:apple19940820:20190512204632p:plain

今気づいたけど、VSCodeは刺しているデバイスを検知して表示してくれるっぽい。

f:id:apple19940820:20190512204759p:plain

VSCodeのコマンドパレットでflutter Lunch EmulatorとするとAndroidか、iOSエミュレータが勝手に起動する。

Webを始める場合はflutter New Web Projectで作れる。すごい。

f:id:apple19940820:20190512210224p:plain

デプロイは面倒でまだやってない。けどドキュメントに各プラットフォーム向けに書かれている。

あとはDartゴリゴリ書いていくだけ。頑張ろう。

おわり