はらへり日記

腹に弾丸

今さら聞けないChromeエクステンションの作り方

この記事は

JavaScriptアドベントカレンダーの1日目の記事です。

qiita.com

JavaScriptというよりはChromeの話かもしれませんが、最近エクステンションを作って楽しかったので簡単に作り方を書いてみます。

Chromeエクステンションとは

github-label-creater

私は普段、GitHubで開発するときにIssueのラベルを自分好みに変えてから使うのですが、

毎回作り直すのが面倒でブックマークレットを作ろうとしました。

ただ、ブックマークレットだとソースコードの量が限られてしまうので、Chromeエクステンションにしようと思い立ち、github-label-createrなるエクステンションを作成しました。

github.com

こんな感じで使えます。

エクステンションの種類

一言でエクステンションと言っても、いくつか種類があります。

とてもざっくり言うと以下の3種類のタイプが存在します。

Browser Action

追加するとURLのバーの横にアイコンが追加され、それでいろいろ機能が使えるタイプ。

はてブのエクステンションなんかはこれですね。

Page actions

特定のページでのみ動くエクステンションです。

Override Pages

Chromeの内部ページを書き換えるタイプのエクステンションです。

作りたいもの

今回作りたいものの要件は以下の通り。

  • https://github.com/{username}/{reponame}/labelsでのみ動けばよい
  • ページ上のDOMを操作してラベルを作り替える
  • アイコンをクリックして出てくるボタンを押すとラベルが作り替わってほしい

ということで、先ほどのPage ActionsとBrowser Actionの機能を組み合わせて作っていきます。

雑な実装イメージ図

構造的にはこんな感じで作っていきます

Content Scripts

まずは特定のページ上で読み込まれるContent Scriptsを作成します。

Chromeエクステンションではエクステンションの情報や読み込むスクリプトの情報をmanifest.jsonというファイル名で作成します。

{
  "manifest_version": 2,
  "name": "GitHub label creater",
  "version": "0.0.1",

  "description": "Create labels of GitHub.",

  "author": "sota1235"
}

ここにcontent_scriptsという名前で情報を追加します。

今回はhttps://github.com/*/*/labelsに一致するページでdist/index.jsを読み込ませたいので以下のように書きます。

URL matchの記法はドキュメントで確認できます。

{
  "manifest_version": 2,
  "name": "GitHub label creater",
  "version": "0.0.1",

  "description": "Create labels of GitHub.",

  "content_scripts": [
    {
      "matches": [
        "https://github.com/*/*/labels"
      ],
      "js": ["dist/index.js"]
    }
  ],

  "author": "sota1235"
}

これでGitHubのLabelページでdist/index.jsが読み込まれるようになりました。

Browser Action

今回は「アイコンをクリックして出てくるボタンをクリックしたらWebページにアクションを起こす」といった形で実装するので、Browser Actionのスクリプトと設定を追加します。

Browser Actionではアイコンがクリックされたときに表示するHTMLを設定できます。

今回はpopup.htmlを表示するようにします。

manifest.jsonに以下の設定を追加しましょう。

{
  "browser_action": {
    "default_icon": {
    },
    "default_title": "GitHub label creater",
    "default_popup": "popup.html"
  }
}

default_iconのところにはアイコンに表示したい画像を入れることができますが、無い場合は空でも動きます(誰かアイコン作って…|ω・`))。

popup.html上ではボタンがクリックしたときの処理をしたいので<script>タグを使ってdist/popup.jsを読み込んでいます。

Browser ActionとContent Scriptsで通信する

さて、ここから少しChromeエクステンションならではの処理が出てきます。

仕様上、Browser Actionからページ上のDOMに直接アクセスすることはできません。

なのでBrowser Actionからイベントを発火し、Content Script側でそれを監視する形で実装を行います。

Browser Actionでのイベント発火

API等のドキュメントはこちら

import domready from 'domready';
import $ from 'jquery';

domready(async () => {
  // 作成するラベルデータ一覧
  const labels = await get('labels');

  $('button.create-labels').on('click', () => {
    
    // 
    chrome.tabs.query({active: true, currentWindow: true}, tabs => {
      chrome.tabs.sendMessage(tabs[0].id, { type: 'CLICK_POPUP', labels });
    });
  });
});

ただ、あらかじめmanifest.jsonchrome.tabsAPIの使用を許可する必要があります。

以下のように追記しましょう。

{
  "permissions": [
    "tabs", "https://github.com/*/*/labels"
  ]
}

これで現在表示されてるタブのContent ScriptにイベントをPublishできます。

Content Scriptでのイベント監視

Content Script側でのイベント監視はこんな感じ。

import domready from 'domready';
import { createNewLabels, deleteLabels } from './label-creater';

domready(() => {
  // popup.jsからのイベント監視
  chrome.runtime.onMessage.addListener(message => {
    // コールバックでpopup.jsからの値を受け取れる
    if (message.type !== 'CLICK_POPUP') {
      return;
    }

    // ここでページ上のDOMを書き換える
    if (window.confirm('All labels will be overwritten. Are you OK?')) {
      deleteLabels();
      createNewLabels(message.labels);
    }
  });
});

これでアイコン上のボタンを押すとContent Scriptが実行される流れが作れました!

History APIの罠

ただ、このままだと特定の条件で動作することができません。

Content Scriptはそのページに対してHTTPリクエストが飛んだ際に読み込まれますが、History APIによるURL書き換えの際は読み込まれません。

GitHubではIssueページからLabelページに行くタイミング等ではHistory APIによる遷移を行っているため、Content Scriptが読み込まれません。

そこでBackground Scriptを使って少しハックします。

Chromeエクステンションではchrome.webNavigationというAPIが用意されており、これに生えてるonHistoryStateUpdated.addListenerでURL履歴が変更された際のイベントをcatchできます。

なのでこれを利用し、History update時にContent Scriptを読み込むBackground Scriptを作成します。

まずmanifest.jsonに以下を書き足します。

{
  "background": {
    "scripts": ["background.js"]
  }
}

webNavigatoinAPIの使用を許可するため、permissionsに値を足しましょう。

{
  "permissions": [
    "https://github.com/*/*/labels", "tabs", "webNavigation"
  ]
}

そして以下のようなシンプルなBackground Scriptを作成します。

/** @var {boolean} Is the content script always executed. */
let isExecuted = false;

chrome.webNavigation.onHistoryStateUpdated.addListener(() => {
  if (!isExecuted) {
    chrome.tabs.executeScript(null, { file: "dist/index.js"} );
  }

  isExecuted = true;
});

ページ遷移するたびにContent Scriptを実行するとイベントが多重に登録されてしまうので、変数でイベント登録済みかどうか判定しています。

これでHTTPリクエストを伴わないページ遷移時にもContent Scriptが呼ばれるようになりました!めでたし!

開発の時に便利だったもの

Content Scriptはページで読み込まれるのでconsole.logデバッグできますが、Browser Actionでは同じようにデバッグができません。

そこで、chromeにエクステンションを読み込ませた際に発行されるextension IDを利用して直接、popup.htmlにアクセスすることができます。

下記画像のID部分を

chrome-extension://{エクステンションID}/popup.htmlといった形式でアクセスします。

これでデバッグ作業ができます!

まとめ

独自APIとか仕様に振り回されそうな印象が強く、今まで避けてたエクステンション開発でしたが思ったより簡単だなというのが僕の感想です。

古い情報とかも多いので困ったら公式ドキュメントを読めばだいたい解決するのでみなさんもぜひ何か作ってみてください。

ブックマークレットと違ってnpmライブラリを使えたりするので幅は広いんじゃないかなと思います。

参考サイト

Google Chrome Extensionを作ってみた-その2(デバッグ)- | Developers.IO

Chromeのオリジナル拡張機能を開発しよう(ソースコードあり) | 株式会社LIG

javascript - Chrome extension Content Script not loaded until page is refreshed - Stack Overflow

Webpackで複数のファイルをそのままバンドルする

やりたいこと

お仕事でページごとに必要なモジュールのみimportしたJSを実装し、それぞれコンパイルしたいという場面があった。

イメージ的にはsrc/(pc|sp)/**/*.jsをフォルダ構成やファイル名をそのままpublic/js配下に吐きだすといった感じ。

Webpackは基本的に複数ファイルをよしなに1ファイルにバンドルするものなので結構情報が少なくて詰まったのでメモっておく。

実装方針

Webpackでは複数ファイルをコンパイル対象として、それぞれ特定フォルダに吐き出すといったことをサポートしている。

multiple entry points

公式からコードを引用するとこんな感じ。

{
    entry: {
        a: "./a",
        b: "./b",
        c: ["./c", "./d"]
    },
    output: {
        path: path.join(__dirname, "dist"),
        filename: "[name].entry.js"
    }
}

こんな感じで頑張ってentryに書いていってもいいんだがこれだと2つ問題がある。

  • ファイルが増えれば増えるほど自分で書き足していかなきゃいけない
  • entryのkey名でファイルが吐きだされるのですべてoutputで定義されたフォルダに吐き出される

2つ目の問題この記事がわかりやすく説明してくれてる。

webdesign-dackel.com

この記事ではファイルがそこまで多くない想定なので手でentryの項目を書いているが、私のケースだとファイルが大量にあったので以下の方針で設定を試みた。

  • globを使ってコンパイル対象のファイルを全て取得する
  • globで取った配列からoutputを基準として出力してほしいフォルダ + ファイル名をkey, コンパイル対象のファイルパスをvalueとした連想配列を作る
  • それをentryに渡す

コード

要するにこんな感じです。

この例はsrc/pc/*.jssrc/sp/*.jspublic/js/配下にフォルダ構成そのままに出力する例です。

import webpack from 'webpack';
import path    from 'path';
import glob    from 'glob';

const jsBasePath = path.resolve(__dirname, 'src/');
const jsCompileFolders = ['pc', 'sp'];

const targets = glob.sync(`${jsBasePath}/+(${jsCompileFolders.join('|')})/*.js`);
const entries = {};
targets.forEach(value => {
  const re = new RegExp(`${jsBasePath}/`);
  const key = value.replace(re, '');
  entries[key] = value;
});

export default {
  entry: entries,
  output: {
    path: path.join(__dirname, 'public/js'),
    filename: '[name]',
  },
}

色々省略してますが、まずはコンパイル対象のフォルダを取る部分。

const targets = glob.sync(`${jsBasePath}/+(${jsCompileFolders.join('|')})/*.js`);

ここで同期処理でコンパイルしたいJSファイルの一覧を取ってきてます。 これを{[outputを基準として吐き出したいフォルダ+ファイル名]: [コンパイルするファイル]}となるkey-valueをここで作ります。

const entries = {};
targets.forEach(value => {
  const re = new RegExp(`${jsBasePath}/`);
  const key = value.replace(re, '');
  entries[key] = value;
});

もっといい方法ありそうなんですが、愚直にkey-valueをオブジェクトに突っ込んでるだけです。 あとはこれをwebpackのconfigに渡して終わり。

サンプルコード

超必要最低限書いたサンプルコード置いておきます。

github.com

まとめ

webpack楽しいぞい

webpackのDefinePluginとbabel-plugin-transform-environment-variablesの併用には注意

やりたいこと

webpackを使ってフロント用のファイルをバンドルしたい。

そのとき、環境変数に合わせて値を変えたいという場面があった。

ノリ的には以下のようなコード。

if (process.env.NODE_ENV === 'development') {
  console.log('Debug message');
}

この例だと開発か本番かの判定だが、他にもステージングの時はこのAPIのエンドポイント、といったことがしたかった。

ハマった

webpack初心者だったのでこの記事を参考に書いてみた。

細かいところは違うけどほぼ内容一緒なのでコードは割愛。

geta6.hatenablog.com

そしてコンパイルされたコードを見るために以下のように適当にコード書いて動かしてみる。

console.log(process.env.NODE_ENV);

そしてビルドし、吐き出されたコードを見てみる。

npm run buildpackage.json"build": "webpack"とだけ定義してある。

npm run build
node dist/index.js
> undefined

なんで!!!!!!

原因

原因はなんてことなくて、これを併用していたせいだった。

babeljs.io

こいつはBabelでコンパイルする際、環境変数を静的に置き換えてくれる。

WebpackでBundleするとき、内部的な処理順は

という順序になっている。

私はDefinePluginとtransform-inline-environment-variablesを併用、かつビルド実行時に環境変数をexportしていなかったので

  • transform-inline-environment-variablesがprocess.env.HOGEを静的に置き換え(exportされてないのでundefined)
  • DefinePluginが実行(でもprocess.env.HOGEは全部置換済みなので何も起きない)

という感じになっていた。

なのでやり方的には

  • webpack実行時に環境変数にすべてexport & transform-inline-environment-variables
  • transform-inline-environment-variablesは使わずDefinePluginにすべてkey-valueで渡す

の2通り。どっちがいいかは好みや場合によりけりかな…

まとめ

圧縮の道はツライ。

ISUCON6予選に出た

結果

正確なスコアはメモし忘れたんですが、最高点が15000点ぐらいでした。

後半はずっと14000点を前後してた

何をしたか

自分用に覚えてる範囲で軽くメモ。

  • まずalp, pt-query-digestを仕込んでベンチを叩く。スコア200くらい
  • DB周りの@aboyとミドルウェア、OSの@ktarow、アプリの自分で各々予め決めてたことをやる
  • アプリを最初に触ってみる。はてな感すごい
  • DBのデータ量が少ないこと、データ数もテーブルも少ないことに戸惑う
  • 2つWebサーバが立ってることにとまどう
  • とりあえずインデックスを貼ったりnginx周りをチューニングしてもらったりDBにメモリ振ったり
  • 2つのWebサーバ間でお互いを叩いていて無駄だったので消す

ここまででちゃんとアプリケーションコードを読み切れていなかったり、作戦会議の詰めが甘くてFAIL地獄にハマる。

  • 昼頃にFAIL地獄を抜けて11000点くらいになる
  • この時点で去年のスコア(一概に比べられないけど)の10倍以上だったのでハイタッチする
  • ここからインフラのやることがあまりなくなってくる

メモリを食わせてもスループットがあがらなかったり、はちゃめちゃに遅いクエリが無くてここらへんでやっときちんとアプリコードを精査する。おそすぎた。

  • entry本文を表示するために毎回7000語で正規表現をかけていることに気づく
  • ハッシュへの置換とハッシュからリンクへの置換を一挙にやろうとしたがやってみて失敗したことに気づく

例えば1ってkeywordがあると、<a href="/keyword/%10"></a>みたいなリンクがあった時にリンクの内容を置換してしまう。

よく考えればわかることだった…

  • ここらへんからこまめにRedisにデータを突っ込んでいく
  • 突っ込んだ後、それを利用できるものは利用するよう書き換えていく
  • なんやかんやでちょっとずつスコアがあがり15000点になってから2時間ぐらいエラーと戦う

という感じでした。超雑。

なんか知らないけど平尾山のページでめっちゃエラー指摘されました。もうしばらく平尾山は見たくない

所感

木曜から休暇取ってたのでずっとISUCON5の予選で練習してたから頭がDB脳だったのがあまりよくなかった。

始まってみるとDBで改善できるところが少なすぎて結構戸惑った。

去年の反省生かして最初の30分くらいは何もせずにアプリコード読んでたけど、1時間半くらいかけてもよかったかも。

きちんと読み切った上でガッツリ作戦立てたほうがよかった。全部Redisにのっけるとか。

あと、終わって冷静になってから気づいたけど

  • htmlifyの内容を全てキャッシュ
  • POST /keywordが来たらDBからそのキーワードを含むものだけ探してキャッシュを作り直す(Twitterでやったって人見た)
  • ただ、リクエストの中でキャッシュ作り直すとレスポンス遅すぎて減点が痛いのであらかじめPHPでデーモン立ててそこに食わせるようにすればよかった

って思った。ただ、デーモン立てるっていってもそのスクリプト用意してなかったし今度から用意しようと思った。

ループ見たときはPHP並列できない…つらい…ってなってた。

トラブル

東新宿コワーキングスペースでやったんだけど、途中で充電器抜いたらスパークして死ぬかと思った。

f:id:sota1235:20160919013246j:plain

すごい音なったしISUCON中に死ぬとこだった。まじで。

反省会

盛大に反省した。

f:id:sota1235:20160919013356j:plain

悔しいポイントは本当に多くて、予選突破まで行かなくても人権が得られる得点には届きたい人生だった。

ただ、去年は本当に無知で何もできなかったけど今年は終始頭を使えてたので成長を感じた。

何よりスコアが15倍ですからね!!!!!!!!!!!

来年もきっとあると信じて引き続き精進したい。

まとめ

エンジニアになるために人権勝ち取っていこうな。

npm scriptsでエラーログを表示させたくない話

npm run hogeでエラーを出したくない

eslintでのチェックやトランスパイルの実行は下のような感じでpackage.jsonに書いてnpm run lint等で実行するようにしてる。

{
  "scripts": [
    "lint": "eslint src/",
    "build": "babel src --out-dir dest"
  }
}

その際、eslint実行等の場合はコマンドの実行自体がコケるだけでnpmが鬱陶しいエラーを出してくる。

npm run lint実行結果

欲しいのはnpm scriptの結果で、下のはnpm初心者には無益で紛らわしいログでしかない

これをどうにかしたい

解決策を調べる

どうにかできないか調べてみた。

解決策その1:--silentオプションをつける

npmにはloglevelという概念があり、これにオプションを指定するとログの出力形式を変更できる。

docs.npmjs.com

その中の1つに--silentがあるのでnpm scriptを実行する際にこのオプションをつける。

省略形で-sでもよい。

--slientオプションつきnpm run lint

これで鬱陶しさはなくなったが

  • 毎回オプションをつけなければいけない
  • npm-debug.logを吐き出さないので本当にnpm由来のエラーが発生したらオプション外してもう一回実行しなきゃいけない

というデメリットがある

解決策その2:.npmrcを設定する

最近こんな記事を読んだ。

qiita.com

今まで知らなかったのが本当にもったいないくらい最高の話で、ローカルにnpmコマンドの設定を保持できる。

なので.npmrcを作成し、以下の用なオプションを指定すると常時、loglevelsilentになる

loglevel=silent

これで毎回オプションを足す手間は省ける。

ただし、これにもデメリットがあって

  • CI等でこうするとエラーが発生したときにログが読めない
  • npm-debug.logが吐き出されない

という問題が依然として残る

解決策その3:aliasでごまかす

後にも言うけどこの問題に根本的解決策は現状ないです。

なので上記デメリットを吸収できる方法は思いつく限りだとshellのaliadを指定する方法です。

要は単純で、alias名は何でもいいんだけど例えば下記のようなものを各々のdotfilesに追記する。

alias npm-run='npm run --silent $*'

これでnpm scriptsを実行したいときはnpm-run lintとかで実行する。

ログが欲しい時やCIではnpm run lintを使う。

解決策番外編:pipeで無理やり成功させる

これは全然解決策じゃなくて、絶対にやめたほうがよいのであえて書いた。

何かというと、npmのissueやstackoverflowを眺めてると「npm scriptに|| true足せばいいよ」ってのがあって

npm scriptの実行結果を無理やりtrueに持っていけばエラーじゃないからログも出ないぜという話。

{
  "scripts" [
    "lint": "eslint src/ || true"
  ]
}

ただ、確かにログは吐かなくなるんだけどnpm scriptの実行が失敗してもコマンドがこけないので

CI等で実行してる場合は例えばeslintが失敗してもそれを補足できなくなる。

絶対にやめような^^

結論

現状、「標準出力にいらんエラーを吐かせず、npm-debug.logはちゃんと残してくれるnpm scriptの書き方」はない。

ので以下の2策に逃げるしか無い気がする。

  • npm script実行時は-sオプションをつける
  • .npmrcloglevel=silentを指定する

ただし、前者は複数人開発だと周知が面倒だし後者はいざというときにログが無くて死ぬ可能性がある。

npmのissueにもこの話題はあがっていて、dev環境用のnpm scriptsを用意しようとかいろいろ提案されているみたい。

run-scripts are too noisy while used in development · Issue #8821 · npm/npm · GitHub

個人的にはnpm慣れてるので、個人ではshellのaliasで逃げつつチームの時はこのブログ記事ぶん投げようかなという感じ。

追記

匿名の方より以下のコメントをいただきました。

f:id:sota1235:20170127155929p:plain

これであれば「標準出力にいらんエラーを吐かせず、npm-debug.logはちゃんと残してくれるnpm scriptの書き方」を実現できそうです。

具体的にはこんな感じのshellを書いて使う。

#!/bin/zsh

###
# npm run with slim
###
DOTFILES_NPM_ERROR_LOG="$HOME/.dotfiles/dist/npm_error.log"

function npmrun() {
  npm run $1 2>$DOTFILES_NPM_ERROR_LOG

  if [ ! $? -eq 0 ]; then
    echo "npm error log recorded at $DOTFILES_NPM_ERROR_LOG"
    return 1
  fi
}

もしかしたらもっと良い書き方があるかもだが、これでひとまず解決した。最高!