自分用の備忘録

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

MutationObserverでDOM変更検知

最近、JSでDOMが書き換えられるサイトが多すぎて、純粋にスクレイピングしようとすると結構辛かったりします。
スクレイピングに限らず、実務であっても、なぜか効かせたいJSが効かない問題はおおよそタイミングの問題であることがあります。
微妙にsetTimeout入れたりして、疑似スリープしたりして解決するわけですが・・・。

そこでMutationObserver

MutationObserver

https://developer.mozilla.org/ja/docs/Web/API/MutationObserver

対象にするDOMとオプションを指定して、コールバック書いて、「変更があったらこれやってね」と書くだけです。簡単。

// 変更検知するDOM
const $targetNode = document.getElementById('target-dom');
const config = { attributes: true, childList: true, subtree: true };

const observer = new MutationObserver(((mutation, observer) => {
  alert('対象のDOMが変更されました');
});

observer.observe($targetNode, config);

mutationにはDOMの情報が入ってくるので、もっと詳しいことがしたいならそこで頑張りましょう。

CSSでDOMを非表示にするパターン比較的なやつ

JavaScript書いていると、「DOMを非表示にしておいて、特定条件で表示する」みたいなのってよく書きます。
一個一個は単純でも、複数になるとそこそこ面倒。。。

今回は、この非表示にする方法にフォーカスしてみるやつです。
なんでもかんでもdisplay: none;使ってない!?ちょっと考えて使おうね!が趣旨。

CSSで非表示する方法

以下の3つ。

  • display: none;
  • visibility: hidden;
  • opacity: 0;

display:noneはおなじみですね。
他は?opacityはなんとなく使ったことあるけど、visibilityってあまり馴染みないような。

だけど、意外と使い分けが大事な場面もあったり。
それぞれ違いを見ていきます。

違いは?

単純な違い

まずdisplay:none
display:noneのデモ 触ってもらえば分かると思いますが、DOMが消えて、「高さ」なども消滅しています。

次にvisibility: hidden
visibility: hiddenのデモ
display:noneと違って、表示は消えても「高さ」などは残っていることが分かります。

最後にopacity:0
opacity: 0のデモ
これも表示は消えて、「高さ」などは残ることが分かります。

ここまでで分かるのは、がっつり消してしまいたいならdisplay: none
高さとか残しときたいならvisibility: hiddenopacity: 0ってな具合でしょう。

これだけなら好みでどうぞ!って感じなんですが、そんなわけないです。

イベント発火

イベント発火の例

次にそれぞれにイベントを付与してみます。
toggleを押す前、押した後で、clickしてみると違いが浮き彫りに。

display: noneはそもそも何もないので、イベントはおきない。
visibility: hiddenも同じくイベントはおきない。
opacity: 0だけはイベントが発生。

opacityは透過度を指定しているだけなので、実態はそこにある状態。
なのでイベントも効いてきますね(考えてみたら当然)。

対象にイベントが付与されている場合は、display:noneか、visibility:hidden
一昔前の隠しリンク的なのがやりたいならopacity:0ですね。

DOMの取得とか

サイズとかポジションとる例

まずGetSizeして、toggleしてGetSizeしてみると分かると思いますが。
display: noneだとサイズもポジションも取れないので、DOMのポジション使って何かするような処理書いてる場合は注意です。

例えば、対象要素の真下にドロップダウンリスト出すーとか、そんなパターンのやつ。

意外と

サンプル書いて、こうやって並べてみると当たり前に思えるけど、実際ハマることがあるので、
何が適切か考えながら使おうねって話でした。

JavaScriptのProxyって結構便利なのでは

Proxyオブジェクトが結構便利だったので書いてみます。

Proxy

Proxy オブジェクトは、基本的な操作 (例えばプロパティの検索、代入、列挙、関数の起動など) について独自の動作を定義するために使用します。

const proxy = new Proxy(targetObject, handler)

targetObjectに対象のオブジェクト、handlerにはそのオブジェクトに対してどうするのか、トラップと呼ばれるメソッドを定義して渡します。
よく使うであろうgetsetだけ紹介しときます。

get

const user = {
  name: 'Alice',
  age: 30
}

const proxy = new Proxy(user, {
  get(target, name) {
    if (target[name] <= 30) return 17
    return target[name]
  }
})

console.log(proxy.age) // 17

getconsole.logなど、そのプロパティにアクセスがあったとき、どうやって返すのかを定義できます。
例の場合はageが30以上なら17と表示されます。永遠のなんとやら・・・。

set

次にset。

const user = {
   name: 'Alice',
   age: null
}

const proxy = new Proxy(user, {
  set(target, name, value) {
    if (name === 'age') {
      target[name] = 17
      return true
    }
    return Reflect.set(...arguments)
  }
})

proxy.age = 30
console.log(proxy.age) // 17

例は何をいれても17になります(永遠の...)

使い所

表なりなんなり、データを一覧で表示しないといけないときとか。
条件として、nullとか空文字は-で表示してー!みたいな場合。

普通に書くと。。。

const rows = dataList.map(data => {
  return `
    <tr>
      <td>${data.A !== null ? data.A : '-'}</td>
      <td>${data.B !== null ? data.B : '-'}</td>
      <td>${data.C !== null ? data.C : '-'}</td>
      <td>${data.D !== null ? data.D : '-'}</td>
    </tr>
  `
})

// tbodyなりにrowsをappend

何も意識せず書くとこんな感じでテーブルのデータ組むと思います。
まとめるならこんな感じ。

const formatter = value => value !== null ? value : '-'
const rows = dataList.map(data => {
  return `
    <tr>
      <td>${formatter(data.A)}</td>
      <td>${formatter(data.B)}</td>
      <td>${formatter(data.C)}</td>
      <td>${formatter(data.D)}</td>
    </tr>
  `
})

やってることが単純なので、このレベルならこれで十分ですね正直。。。

Proxyを使うと、

const rows = dataList.map(data => {
  const p = new Proxy(data, {
    get(target, name) {
      if (target[name] === null) return '-'
      return target[name]
    }
  })
  return `
    <tr>
      <td>${p.A}</td>
      <td>${p.B}</td>
      <td>${p.C}</td>
      <td>${p.D}</td>
    </tr>
  `
})

HTML作ってる部分はスッキリしますね。

その他

ループでnewしまくるのが良いかどうのかってのはちょっとありそうですが、便利な機能なのでもう少し突き詰めたいところ 。

setなんかはオブジェクトの変更監視もできる気がするので、いわゆるオレオレstore的なのが作れるかも?
Vue3.0でProxyが使われてるとかそんな話を聞いたことがあるようなないような。

そもそもProxyってこんな使い方して良いのかなという疑問は残るんですが。
どうなんでしょう?

VuexのcreateNamespacedHelpersでdispatchの記述を短くしたい

初めてVuexを触ったとき、とくにnamespaceも使わずやってました。

ちなみにmapActionsは使わず、this.$store.dispatchでしたい派。
this.$store.dispatchの方がdispatchしてるわー!今dispatchしてるわー感があるので好き。
というのは嘘で、コード見たときに、「アクション叩いてる」っていう部分が、ぱっと見分かりやすかったってのがあります。

問題

namespaced: trueにしてみると、namespace + actionNameと書く必要がでてきます。

methods: {
  handleClickBefore(e) {
    this.$store.dispatch('actionName', payload)
  },

  handleClickAfter(e) {
    this.$store.dispatch('namespace/actionName', payload)
  }
}

こう見てみると、「そんな嫌か?」ってレベルに見えるんです。

typesを分ける

実際開発しだすと、直接文字列で指定することは少なくて、types.jsみたいなのをつくって、中にconstでミューテーションとかアクション名を書いていきます。

export const namespace = 'File/'

export const SET_FILE_LIST = 'SET_FILE_LIST'
export const FETCH_FILE_LIST = ' FETCH_FILE_LIST'

こうすると、dispatchの部分は

import * as fileTypes from '@/store/modules/File/types'
// ...
methods: {
  handleClick(e) {
    this.$dispatch(fileTypes.namespace + fileTypes.FETCH_FILE_LIST)
  }
}

このレベルだとまだマシですが、中にはPREFIXがついて長くなるアクション名がでてきます。

this.$dispatch(fooTypes.namespace + fooTypes.PREFIX_PREFIX_ACTION_NAME_FOR_XXX)

ここまでなると、さすがにdispatchが良いとは言い辛い状況になってきました。

解決

createNamespacedHelpersというのがVuexに用意されていることを知りました。
Vuex/modules

import { createNamespacedHelpers } from 'vuex'
import * as fileTypes from '@/store/modules/file/types'

const { mapActions: mapActionsOfFile } = createNamespacedHelpers(fileTypes.namspace)

//...
methods: {
  ...mapActionsOfFile([fileTypes.FETCH_FILE_LIST]),

  handleClick(e) {
    this[fileTypes.FETCH_FILE_LIST]()
  }
}

結局mapActionsに屈したわけですが、思っていたほど見辛いこともなく、楽に書けるので良いかなといった具合です。
(Vuex噛んでいるところはthis[types.XXXX]ってなるので、他とか区別できているので)

その他も

mapStatemapGetteresmapState(namespace, [xxxx])としていたのも同じく解決です。
また、複数のモジュールを使うときも明確になりますね。

import * as fileTypes from '@/store/modules/File/types'
import * as userTypes from '@/store/modules/User/types'

const { mapState: mapStateOfFile, mapGetters: mapGettersOfFile } = createNamespacedHelpers(fileTypes.namesace)
const { mapState: mapStateOfUser, mapGetters: mapGettersOfUser } = createNamespacedHelpers(userTypes.namesace)

//...
computed: {
  ...mapStateOfFile(['fileList']),
  ...mapStateOfUser(['userList']),
  ...mapGettersOfFile(['imageList', 'pdfList'])
}

みたいな具合で。
一つのモジュールしか使わないなら、import * as types from '@/store/modules/xxxx'でいいんですけどね。

参考記事

スクロールを連動させる

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

非同期を同期的に

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

非同期処理を同期的に書くなら、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()するなりして、配列に落としておいた方が無難かも。