いろいろできるぞexecuteScript()

いろいろできるぞexecuteScript()

WebExtensions のtabs.executeScript()メソッドは、Background Script から Content Script 上に JavaScript を注入できます。 たまたま調べる機会があって挙動が面白かったので簡単に記事にまとめました。

基本動作

executeScript()は Content Script 上で任意のスクリプトを実行して最後に評価した値を返します。 executeScript()は Promise を返す非同期関数で、評価した値は then の引数から取得できます。 最後に評価した値は、例えば以下の例では'my result'になります。

var foo = "my result"
foo

executeScript() の結果は各フレームで評価された値が Array に格納されます(フレームを指定しないとトップウィンドウのみで実行します)。 以下のコードはすべてのタブの URL が Background Script のコンソールに表示されます。

// すべてのタブを取得
browser.tabs.query({}).then(tabs => {
  for (let tab of tabs) {
    // 各タブでwindow.location.hrefを返す
    browser.tabs
      .executeScript(tab.id, {
        code: `window.location.href`,
      })
      .then(values => {
        // 返した値をコンソールに出力
        console.log(values[0])
      })
  }
})

executeScript()の第 1 引数は省略可能で、指定しないとアクティブなタブ上で実行します。 次の例はアクティブなタブの URL を Background Script のコンソールに表示します。

browser.tabs
  .executeScript({
    code: `window.location.href`,
  })
  .then(values => {
    // 返した値をコンソールに出力
    console.log(values[0])
  })

返す値の型

executeScript() は文字列以外の型を返すことができます。 返すことのできる値の型はstructured clonableという制約があります。

// Number
browser.tabs
  .executeScript({ code: `100 + 200` })
  .then(values => console.log(values[0]))

// String
browser.tabs
  .executeScript({ code: `'Hello,' + ' world'` })
  .then(values => console.log(values[0]))

// Array
browser.tabs
  .executeScript({ code: `[10, 20].concat([30])` })
  .then(values => console.log(values[0]))

// Object
browser.tabs
  .executeScript({ code: `({ key1: 123, key2: 456})` })
  .then(values => console.log(values[0]))

// RegExp
browser.tabs
  .executeScript({ code: `/-pattern-/` })
  .then(values => console.log(values[0]))

一方 Function は structured clonable ではないので返すことができません。

browser.tabs.executeScript({ code: `() => {}` }).catch(e => console.error(e))
// Error: Script '<anonymous code>' result is non-structured-clonable data

例外

実行したスクリプト内で発生した例外も、Background Script で受け取ることができます。 受け取るにはexecuteScript()が返す Promise で例外をキャッチします。 ただしスタックトレースは正しく取得できません。

browser.tabs
  .executeScript({ code: `throw new Error('err')` })
  .catch(e => console.error(e))

非同期処理

返す値は即値だけでなく Promise を使って非同期で返すことができます。 Promise が最後に解決した値を Background Script に返します。

browser.tabs
  .executeScript({
    code: `new Promise((resolve, reject) => setTimeout(() => resolve(new Date()), 3000))`,
  })
  .then(values => console.log(values[0]))

もちろん reject もできます。

browser.tabs
  .executeScript({
    code: `new Promise((resolve, reject) => setTimeout(() => reject(new Error('err')), 3000))`,
  })
  .catch(e => console.error(e))

ファイルからロードする

codeではなくfileプロパティを指定すると、スクリプトをファイルからロードします。

// page.js
fetch("https://example.com/user/1").then(resp => resp.json())
// background.js
browser.tabs
  .executeScript({
    file: "page.js",
  })
  .then(values => console.log(values[0]))
// Object { id: 1, name: "alice", age: 12 }

ただしロードするスクリプトを記述するとき、トランスパイルなどの利用には注意が必要です。 トランスパイルは振る舞いが等価なコードを生成しますが、最後に評価する値が変わることがあります。

Webpack では以下のように(function(){ ... })()で囲われたコードが出力されます。 これは名前空間の汚染対策ですが、function 内で return をしてないため、最終的に評価される値undefiniedとなります。 そのため Background Script で値が返ってくることを期待してもundefiniedしか返ってきません。

// 変換前
"Hello, world"
// 変換後
;(function () {
  "Hello, world"
})()

まとめ

executeScript()は柔軟でいろいろな応用ができそうです。 しかし複雑な処理が必要になったときは、素直に Content Script を書いて非同期メッセージを送ったほうが懸命です。 このあたりの使い分けはスクリプトの規模感やビルド環境に合わせて調整してください。


Profile picture

Shin'ya Ueoka

B2B向けSaaSを提供する会社の、元Webエンジニア。今はエンジニアリング組織のマネジメントをしている。