• 19
    Like
  • 0
    Comment

はじめに

JavaScript Advent Calendar の 11 日目が空いていたので急遽代わりに投稿させて頂きました)

JavaScript Advent Calendar 1 日目に sota1235 さんが「今さら聞けない Chrome エクステンションの作り方」という記事を投稿されているので、今回は最近開発したばかりの NewsPicks の Chrome 拡張について、工夫したポイントなどを書いてみようかと思います。

NewsPicks Chrome 拡張の機能紹介

以下簡単に主要な 3 つの機能を紹介します。詳細を知りたい方はこちらの記事 もしくは実際に Chrome 拡張をインストールして頂ければ簡単な利用ガイドがありますのでご覧ください。

サイドバー

サイドバーを使って現在表示中の URL に関連する情報(コメント・企業情報・財務チャートなど)を表示することが出来ます。

sidebar.gif

via GIPHY

オムニボックス

オムニボックスを利用して NewsPicks の検索機能を利用することが出来ます。ページングに加えてニュース/コメント/ユーザー/企業情報などに簡単に到達出来るようなショートカットも実装しています。

omnibox.gif

via GIPHY

ダッシュボード

新規タブを開く際に任意の NewsPicks のコンテンツを表示することが出来ます。

dashboard.gif

via GIPHY

ちなみに、こちらは ProductHunt の Chrome 拡張に同様の機能が存在し、実際に利用していて非常に便利だったため NewsPicks でも実装してみたものです。

開発の準備

ざっくり以下の技術を使用しました。

  • ES 2016(Babel)
  • Webpack
  • yarn
  • SCSS
  • ESLint(ちなみに standard 派です)

ライブラリとしては主に以下を利用しました。

  • React
  • axios

それほど規模が大きいものではないので Redux 等の Flux フレームワークは利用していません。シンプルに各画面毎にステートフルな Container コンポーネントを用意し、イベントを dispatch して pure な Component をレンダリングするようにしています。

サイドバー

Chrome 拡張を作る際にサイドバーをどのように実装するかという知識は意外と世間に共有されていないのではないかと思いますので、今回は NewsPicks の Chrome 拡張での実装方法を紹介します。世間の拡張機能での実装方法を幾つか見てみた結果、どれも同様の実装方法をとっているようでした。

Browser Action と Content Scripts の連携

まずは Browser Action を契機に Contnet Scripts にメッセージを飛ばします。manifest.json で Browser Action と Content Scripts を許可した上で、以下のようにメッセージの送信を行います。

// background.js

import { TOGGLE_SIDEBAR } from './messages'

chrome.browserAction.onClicked.addListener(tab => {
  toggleSidebar(tab)
})

const toggleSidebar = tab => {
  chrome.tabs.sendMessage(tab.id, TOGGLE_SIDEBAR)
}

これを受けて Content Scripts では以下のようにサイドバーを開閉します。

// contentscript.js

import { TOGGLE_SIDEBAR } from './messages'

const EXTENSION_ID = 'np-extension'

class Extension {

  toggle () {
    if (window.top !== window.self) {
      return
    }
    if (this.$extension) {
      this.toggleVisibility()
    } else {
      this.install()
    }
  }

  toggleVisibility () {
     // ...
  }

  install () {
    const $extension = this.$extension = document.createElement('iframe')
    $extension.id = EXTENSION_ID
    $extension.src = this.src()
    document.body.appendChild($extension)
  }

  src () {
    return chrome.extension.getURL(`pages/sidebar.html?url=${encodeURIComponent(window.location.href)}`)
  }

}

const extension = new Extension()
chrome.runtime.onMessage.addListener(message => {
  switch (message) {
    case TOGGLE_SIDEBAR:
      extension.toggle()
      break
    default:
      break
  }
})

ポイントは iframe で pages/sidebar.html を表示する際、URL を渡しているところです。なお、sidebar.html を表示するために manifest.jsonweb_accessible_resources として pages/sidebar.html を追加しています。

サイドバーの描画

sidebar.html 自体はシンプルな React.Component です。エントリポイントは以下のようになります。

// sidebar.js

import React from 'react'
import ReactDOM from 'react-dom'
import SideBarApp from './sidebar/index.jsx'

const parseQueryParams = (q || '') => {
  const query = q && q.startsWith('?') ? q.substring(1) : q
  if (query) {
    return query.split('&').reduce((params, kv) => {
      const [ head, ...rest ] = kv.split('=')
      params[head] = rest.map(v => decodeURIComponent(v)).join('=')
      return params
    }, {})
  }
  return {}
}

const params = parseQueryParams(window.location.search)
ReactDOM.render(
  <SideBarApp { ...params } />,
  document.getElementById('app')
)

注意点

当初サイドバーを実装する際、window.fetch を使って NewsPicks の API を叩いていたのですが、ローカルファイルからの通信となるため originnull となってしまい、CORS フィルタで弾かれるという事象が発生したため、止む無く window.fetch の利用を諦めて axios に切り替えました(別に superagent でも何でも良いです)。

チャート

ちなみにサイドバーに表示されているチャートは、企業・業界分析プラットフォームである SPEEDA のデータを利用して描画しています。こちらについては以前勉強会で発表した資料があるので良ければご覧ください。

サーバーサイドはマイクロサービスアーキテクチャで SPEEDA の各種 API と連携しつつ、BFF として Vert.x を利用したチャート専用の API サーバを開発しました。チャートクライアントは Plottable.js を利用して開発した独自のライブラリで描画しています。あまり利用している事例を見かけませんが、Plottable.js はなかなか良く出来たチャートライブラリなので、少し凝ったチャートを作りたい場合などにオススメです。

chart.png

SPEEDA の場合は統計情報と財務情報などでプロットする期を合わせたり、二軸グラフが多いので左右で tick を合わせるなどの工夫を行っています(意外とこれが大変)。

オムニボックス

オムニボックスについてはほぼ Chrome の API をそのまま利用しているだけです。詳細は省略しますがざっくりでは以下のような処理になっています。

// omnibox/index.js

import debounce from 'lodash.debounce'
import Omnibox from './omnibox'

const omnibox = new Omnibox()

const onInputChanged = (text, suggest) => {
  if (text) {
    suggest(omnibox.suggest(text))
  }
}

const onInputEntered = text => {
  if (text) {
    chrome.tabs.getSelected(null, tab => {
      chrome.tabs.update(tab.id, {
        url: toURL(text)
      })
    })
  }
}

chrome.omnibox.onInputChanged.addListener(debounce(onInputChanged, 300))
chrome.omnibox.onInputEntered.addListener(onInputEntered)

Omnibox では与えられたクエリのパースやキャッシュなどを行っています。今回は . を利用してページング出来るようにしているのですが、Chrome は 5 件ずつしか候補を表示出来ないため、利用上のストレスを少しでも減らすために事前に 20 件程度 API から取得した表示候補をメモリにキャッシュしておき、必要に応じてキャッシュから次の検索候補を表示するようにしています。

ダッシュボード

新規タブを開く際の挙動を拡張機能で制御したい場合、manifest.jsonchrome_url_overrides を利用します。今回は chrome_url_overridespages/newtab.html を指定しました。これで次のように新規タブをシンプルな React.Component として描画することが出来ます。

表示コンテンツの制御

今回は次のようなオプション機能から表示したいコンテンツを選ぶことが出来るようにしました。表示対象コンテンツは chrome.storage.sync に保存しています。

option.png

ちなみに chrome.storagePromise を実装しておらずやや使いづらいので、簡単にラップしたコンポーネントを実装して利用しました(一部割愛)。

import moment from 'moment'

const existy = value => value !== null && value !== undefined

export default class Storage {

  constructor (id, sync = false) {
    this.id = `np:extension:${id}`
    this.storage = sync ? chrome.storage.sync : chrome.storage.local
  }

  set (key, value, expire) {
    return new Promise((resolve, reject) => {
      const item = {
        value: JSON.stringify(value),
        expiresAt: expire ? moment().add(expire, 'ms').valueOf() : null
      }
      this.storage.set({ [this.keyOf(key)]: item }, () => resolve(value))
    })
  }

  get (key) {
    return new Promise((resolve, reject) => {
      const k = this.keyOf(key)
      this.storage.get(k, items => {
        if (existy(items) && existy(items[k]) && existy(items[k].value)) {
          const item = items[k]
          const value = JSON.parse(item.value)
          const expiresAt = moment(item.expiresAt)
          if (expiresAt && expiresAt.isBefore(moment())) {
            console.log(`The value of key (${k}) has been expired at ${expiresAt.format('YYYY-MM-DD hh:mm:ss')}`)
            this.remove(key).then(reject, reject)
          } else {
            resolve(value)
          }
        } else {
          reject()
        }
      })
    })
  }

  remove (key) {
    return new Promise((resolve, reject) => {
      this.storage.remove(this.keyOf(key), () => { resolve() })
    })
  }

  clear () {
    return new Promise((resolve, reject) => {
      this.storage.get(null, items => {
        const keys = Object.keys(items)
        Promise.all(keys.map(k => this.remove(k))).then(() => {
          resolve(keys.length)
        })
      })
    })
  }

  keyOf (key) {
    return `${this.id}:${key}`
  }

}

素早くコンテンツを表示する

新規タブを制御して任意のコンテンツを表示する場合、素早くコンテンツが表示されないとストレスが溜まります。そこで、background.js で定期的にコンテンツをリロードし、キャッシュするようにしています。

新規タブ制御の ON/OFF

NewsPicks の Chrome 拡張機能では、新規タブを開く際に NewsPicks のコンテンツを表示せずにデフォルトのタブを表示することも出来るようになっています。余り推奨されたやり方では無いのですが、この場合、chrome-search://local-ntp/local-ntp.html にリダイレクトさせるようにしています。

const redirectTo = url => chrome.tabs.update({ url })
redirectTo('chrome-search://local-ntp/local-ntp.html')

ただし、この方法ではあくまで Chrome のデフォルトタブに遷移させているだけなので、他の拡張機能が新規タブの制御を奪っている場合には対応できません。何か良い対応策があれば教えて頂けると幸いです。

まとめ

簡単ではありますが、最近開発した Chrome 拡張機能の裏側についてのご紹介でした。Chrome 拡張は API ドキュメントも良く整備されており、とても手軽に作ることが出来ますが、意外と「これはどうやったら実装できるんだろう?」となることもあります。この記事が皆さんのお役に立てれば幸いです。