typeof Diary

VimとかJSとか。やったことのメモ。自分のため。

スクロールを連動させる

Qiitaなんかである入力エリアとプレビューエリアのスクロールが連動するやつ。
要件は違ったんですが、スクロール同期という点では同じだったのでやってみたので書いておきます。

単純に連動する

まずは単純に同じサイズの要素を連動させてみます。
単純な連動デモ

const areaA = document.getElementById('area-a')
const areaB = document.getElementById('area-b')

areaA.addEventListener('scroll', e => {
  const scrollTop = e.target.scrollTop
  areaB.scrollTo(0, scrollTop)
})

同じサイズのA→Bと連動させるなら、AのスクロールイベントでscrollTopをとって、BのscrollTo()に放り込んであげるだけです。  

サイズが違う要素を連動する

次にQiitaみたいな要素の大きさが異なる場合の連動を見ていきます。
サイズ違いの連動デモ

const areaA = document.getElementById('area-a')
const areaB = document.getElementById('area-b')

areaA.addEventListener('scroll', e => {
  const scrollMaxA = e.target.scrollHeight - e.target.clientHeight
  const scrollMaxB = areaB.scrollHeight - areaB.clientHeight
  const percent = e.target.scrollTop / scrollMaxA

  areaB.scrollTo(0, scrollMaxB * percent)
})

さっきと異なるのがサイズが異なるので、何%スクロールしたのかを求めることが必要です。
まずはscrollHeight - clientHeightで、どれだけスクロールできるのかをそれぞれ求めます。
次にAのscrollTop / scrollMaxAで%を計算。
最後にBのscrollToscrollMaxB * percent)でスクロール量を放り込んであげて完成です!

相互連動させる

最後に相互に連動させる必要がある場合です。
相互連動デモ

const areaA = document.getElementById('area-a')
const areaB = document.getElementById('area-b')

function scrollA(e) {
  const scrollTop = e.target.scrollTop
  areaB.scrollTo(0, scrollTop)
}

function scrollB(e) {
  const scrollTop = e.target.scrollTop
  areaA.scrollTo(0, scrollTop)
}

areaA.addEventListener('scroll', scrollA)
areaB.addEventListener('scroll', scrollB)

areaA.addEventListener('mouseenter', e => {
  areaB.removeEventListener('scroll', scrollB)
})
areaB.addEventListener('mouseenter', e => {
  areaA.removeEventListener('scroll', scrollA)
})

areaA.addEventListener('mouseleave', e => {
  areaB.addEventListener('scroll', scrollB)
})
areaB.addEventListener('mouseleave', e => {
  areaA.addEventListener('scroll', scrollA)
})

ちょっと面倒な書き方してるかもしれないですが。。。
相互連動するとA→Bをしたときに、Bのスクロールイベントがトリガーされます。
デモぐらい単純な中身なら気にはなりませんが、たまーにズレたりします。

そこで、AをスクロールさせたときはBのイベントをトリガーしない。
BをスクロールしたときはAのイベントをトリガーしない。みたいなことが必要になります。

方法は

  • どこからスクロールされているのかを明確にする
  • そもそもイベント自体を消してしまう

どっちかだと思いますが、今回は後者です。

各要素にmouseenterしたら別要素のイベントをremoveEventListenerで削除。
mouseleaveしたら別要素にイベントをaddEventListenerで付与しています。

Electronアプリを自動アップデートする

Electronのアプリの自動アップデートについて調べる機会があったので書いておきます。

いくつか方法があって

  • Electronに元々入っているautoUpdater
  • electron-builder + electron-updater
  • update-electron-app

今回は、electron-builder + electron-updaterを試してみました。

electron-builder + electron-updater

npm install electron-updater

Githubでやる場合、package.jsonはこんな感じで。

"build": {
  "publish": {
    "provider": "github",
    "owner": "owner",
    "repo": "reponame"
  }
}

トークン求められるので、適宜Githubトークン発行できるところからトークン発行しましょう。
.envGH_TOKEN=xxxxxxの形で置いておけばOKです。
GH_TOKENがあるだけで、勝手にGitHub使われるっぽいですが・・・。

あとはビルドのオプションに--publish alwaysとかしとくと、ビルドする度にGithubのReleaseにビルドされたファイル上げてくれます。
それが嫌な場合はneverとか、他はタグつけたときだけとか指定できます。

その他、自前のサーバとかに置く場合はこんな感じに。

"build": {
  "publish": {
    "provider: "generic",
    "url": "http://xxx"
  }
}

(HTTPSじゃないとだめ、みたいな記事も見たけど、そうでもない?)

あとはurlで指定しているとこに、ビルドしてできたファイル群とlatest.yml、latest-mac.ymlを配置しておきます。
versionに-alpha、-betaあたりをつけると、alpha.yml、beta.ymlが作られます。

Vue CLI3

参考までにVue CLI3でプロジェクト作ってて、vue add electron-builderしてる場合はこんな感じです。
(この構成でやってます)

vue.config.jsに

pluginOptions: {
  electronBuilder: {
    builderOptions: {
      publish: {
        provider: 'generic',
        url: 'http://xxxxx'
      }
    }
  }
}

そんなに変わらないですね。

コードの方

メインプロセスの方に各イベントごとに処理を記述していきます。

一旦↓ここを参考にしつつ、update-downloadedのイベントだけ足してあげます。
electron-updater-example

よくあるアップデートがあったら再起動するかを聞いてくるやつにしたいので、

autoUpdater.on('update-downloaded', ({ version, files, path, sha512, releaseName, releaseNotes, releaseDate }) => {
  const detail = `${app.getName()} ${version} ${releaseDate}`
  
  dialog.showMessageBox(
    win, // new BrowserWindow
    {
      type: 'question',
      buttons: ['再起動', 'あとで'],
      defaultId: 0,
      cancelId: 999,
      message: '新しいバージョンをダウンロードしました。再起動しますか?',
      detail
    },
    res => {
      if (res  === 0) {
        autoUpdater.quitAndInstall()
      }
    }
  )
)

const min = 10
app.on('ready', () => {
  setInterval(() => autoUpdater.checkForUpdatesAndNotify(), 1000 * 60 * min)
})

10分に一回ペースで見に行って、あれば聞いてくれます。

update-downloadedにくるのはlatest.ymlに書いてあるもの。
何があるか分かりやすいようにしてます。

やってみて

思っていたよりも簡単にできました。
自前でアップデートがあるかを確認するAPIが必要?という情報で知識が止まっていたので...。

Windowsはこれでも大丈夫ですが、Macはコード署名が必要らしく、そこはまだ試せていません。

https://www.electron.build/configuration/configuration

ng-japan2019に参加した

今年は初のカンファレンスはnj-japanになりました。
お仕事の方でAngularを触る機会があり、その辺から行くことは決めてました。

その後いろいろあって、やってたPJはなくなってしまって、同時にAngular触る機会はなくなったのですが・・・

f:id:lisia:20190714094811j:plain

会場はGoogleのTokyo Office。 こういうカンファレンスに参加すると、他社の中に入れる機会があるので楽しいですよね!

六本木ヒルズなんか普段入れませんから・・・。
今日だけヒルズぞく!!!

f:id:lisia:20190714095125j:plain

印象的だったのは、メトロノームの発表。
Angularとかに関わらず、自分で好きなように機能実装してみて、機能追加していって。。。
チュートリアル終わってから何したらいいか分からない人、実際めっちゃ多いと思いますし、
そういう人たちの参考になったことでしょう。
少なくとも、そうだよこれだよ!って僕は思いました。

本当ならスライドのまとめなんかもしたかったけど、誰かがやっている・・・?

ここ数年、カンファレンスには参加するようにしていて思うのが、「英語リスニングできない」問題。
正直、聞いていても内容入ってこなくて、感覚でしか聞けてないのが辛いです。
言語の壁が高い。

とはいえ、ツイッターの#ng-japan2019には助けられました。
タグ追ってれば誰かしら反応してるのでw

英語に関しては、どうにかこうにかしたいと思うけど、実際に行動に移すこともできず。
英語セッション、なんとなくでも意味理解しながら聞けたら楽しいんだろうなーと。

というような感じなので、セッションひとつひとつ細かくレポートはできないです。。。

みんなどうやって英語できるようになったの・・・?
本当に知りたい。

なんやかんや、ng-japanは参加してよかったです。
Angular個人的に触りたい欲は終わってからもあったので、その刺激になりました。
それと、TypeScript書いていこうと強く思いました。

社内でTypeScript書いてる人間はいないので、次はここで抜けていかないと。
NestJSも触ってみようかな?

キッカケは与えてもらってると思うので、少しずつやっていきます。

f:id:lisia:20190714095222j:plain

Electron、メモリリークの対策?

Electronのアプリをレンダラ側はVueで書いています。
ログイン画面→トップ画面みたいな2画面構成。

ログイン、ログアウトを繰り返すとやたらとメモリリークしていました。 何かが破棄しきれていないんだろうけど、スナップショットから見つけるのも結構苦労しそうでした。

VUE_DEV_TOOLだったり、global:appみたいなのだったり・・・。

画面をリロード

画面をリロードしてやれば、メモリリークは一時しのぎでもなんとかなりそうです。

{ remote } from 'electron'
router from '@/router'

export default {
  // ...
  handleClickLogout() {
    // Vuexストアのリセットとか...
    remote.getCurrentWindow().reload()
    router.push('/login')
  }
}

こんな感じにしておけば、リロードされてメモリは開放されます。

ひとつ問題が。
これだとタイミング次第で、元のページがリロード→ログイン画面にいく場合と、ログイン画面にいく→リロード→ログイン画面が再描画。
みたいなことになりました。

前者はそこまで違和感はないですが、後者はユーザ的には良くない動きでしょう。
気になる人はめっちゃ気になるはず。

画面遷移の違和感をなくす

この違和感を取り去りましょう。

空divだけのコンポーネントを間に挟んでみます。

<template>
  <div></div>
</template>

<script>
import router from '@/router'

export default {
  name: 'Empty',

  mounted() {
    setTimeout(() => router.push('/login'), 1000)
  }
}
</script>
handleClickLogout() {
  // ...
  remote.getCurrentWindow().reload()
  router.push('/empty')
}

routerに/emptyは追加したとして、こんな感じで。
これだと、一旦空のページを挟むので、自然と遷移しているように見えるハズ。

無理矢理ではあるけど、こういうのもテクニック(?)

リークの原因はちゃんと調べないと、そのうちやばい手に負えない感じになってしまいそう・・・。

非同期を同期的に

今更ながら非同期でハマったのでメモ。

非同期処理を同期的に書くなら、async/awaitで良いのですが、
これがディレクトリの中身を読むやつみたいな場合...

const entries = []
if (entry.isDirectory) {
  const reader = entry.createReader()
  reader.readEntries(entry => {
    entries.push(entry)
  })
}

// entriesを使った処理

このままだと、readEntriesが非同期なので、後半の処理は思い通りにはいきません。

const _entries = new Promise(resolve => reader.readEntries(entry => resolve(entry)))
entries.push(..._entries)

async/awaitPromiseを上手く組み合わせると良いです。 それぞれ単体で使うことは知ってても、組み合わせはハマって初めて知る気がします・・・。

追記

↑のコードだと、ディレクトリ下に100件以上あると全て取得できない。

developer.mozilla.org

ドキュメントにも書いてあった。

Note that to read all files in a directory, readEntries needs to be called repeatedly until it returns an empty array. In Chromium-based browsers, the following example will only return a max of 100 entries.

if (entry.isDirectory) {
  const reader = entry.createReader()
  const readDir = async _entries => {
    await new Promise(resolve => {
      reader.readEntries(async entry => {
        if (entry.length !== 0) {
          _entries.push(...entry)
          await readDir(_entries)
        }
        return resolve(_entries)
      })
    })
    return Promise.resolve(_entries)
  }
  entries.push(...(await readDir([])))
} else {
  entries.push(entry)
}

こんな感じでどうですかね。。。
もっとスマートなやり方ありますか・・・。

さらに加えると、これ、いきなりfor (let item of e.dataTransfer.items)すると、
複数ドラッグしてるとき、最初以外が消えます。
なので、最初にitem.webkitGetAsEntry()するなりして、配列に落としておいた方が無難かも。

Vue Programmaticで出したモーダルのprops変更

最近コンポーネントライブラリにBuefyをちょいちょい触っている中で、
モーダルでちょっとハマったのでメモ。

Programmaticにモーダルを出す

Buefyのドキュメント読むと分かりますが、

vm.$modal.open({
  parent: true,
  component: Component,
  props: {
    // props
  },
  events: {
    // emit event
  }
})

要はこんな感じで書けば、プログラムからモーダルが呼び出せます。
よくあるやつですね。

だいたいこの手のやつは

<b-modal :visible.sync="dialogVisible">
</b-modal>

みたいな感じにして、stateのtrue/falseで出したり消したりができます。 自分はこのテンプレートに書くやり方があまり好きではなくて、どちらかというとvim.$modal.openとかで呼びたい派。
まぁ、全てが全てではないです。

propsが更新されない

ここで問題が発生。

モーダルの中で何かを切り替えると、API叩き直して一覧更新するようなUIのとき、
そのデータをpropsで渡していた場合に更新されずハマりました。

vm.$modal.open({
  props: {
    categoryList: this.categoryList,
    itemList: this.itemList
  },
  events: {
    'change-category': categoryId => {
      // categoryIdを元にitemListを更新するような処理
    }
  }
})

中でchangeがemitされると、itemListが更新されるので、モーダルに渡してるitemListも更新されるかと思いきや・・・。
うんともすんとも言いません。

解決

issueに同じようなことが書いてあって、そこの通りなのですが。

const $modal = vm.$modal.open({
  props: {
    categoryList: this.categoryList,
    itemList: this.itemList
  },
  events: {
    'change-category': categoryId => 
      // categoryIdを元にitemListを更新するような処理
      // propsを時前で更新
      $modal.props.itemList = this.itemList
      $modal.$forceUpdate()
    }
  }
})

自前でprops入れて、モーダルのインスタンスを$forceUpdateでいけました。

templateに書いておくのと比べると、programmaticにやると、vm.$elをappendChildなりしてるからですかね・・・。
ちょっとつらい感じですけど、一応これでいけました。。。

Component modal props won't update

Vueで自前のUtil関数をテンプレートで使う

Vueやってると出てくる、Util関数をテンプレートで呼びたい問題。

何が問題かというと・・・順番に見ていきましょう。

問題

まずはこんな関数を定義。
日付表記をYYYY/MM/DDにするだけの関数です。

import dayjs from 'dayjs'

export function formatDate(date) {
  return dayjs(date).format('YYYY/MM/DD')
}

さて、これを他のファイルで使いたい。。。
Vueのスクリプト部分だと単純にimportして使えば良いんですが、テンプレートで使いたいとなると、
一旦メソッド作るしかなくて、「あれ、Utilの意味・・・」となりがちです。

<table>
  <thead>
    <th>名前</th>
    <th>作成日</th>
  </thead>
  <tbody>
    <tr v-for="data in dataList">
      <td>{{ data.name }}</td>
      <td>{{ formatDate(data.insert_date) }}</td>
    </tr>
  </dbody>
</teble>
import { formatDate } from 'util'
export default {
  name: 'Foo',
  methods: {
    formatDate(date) {
      return formatDate(date)
    }
  }
}

意味ないやんこれ。。。

解決

UtilをプラグインとしてVueに登録してやりましょう。

import dayjs from 'dayjs'
export function formatDate(date) {
  return dayjs(date).format('YYYY/MM/DD')
}

export default {
  formatDate
}
import Util from 'util'

const myUtil = {
  install(Vue, options) {
    Vue.prototype.$myUtil = Util
  }
}
Vue.use(myUtil)

new Vue({
  // ...
})

これで、this.$myUtil.dateFormatで使えます。
さっきのコードはこれだけで良く、script部分からはimportもわざわざmethod作る必要もないです。

<table>
  <thead>
    <th>名前</th>
    <th>作成日</th>
  </thead>
  <tbody>
    <tr v-for="data in dataList">
      <td>{{ data.name }}</td>
      <td>{{ $myUtil.formatDate(data.insert_date) }}</td>
    </tr>
  </dbody>
</teble>

もっと良い解決方法あればいいけど・・・。
prototypeにぶち込むので、できるだけ被らない名前にしないとなーとぐらいは思いました。