Open3

Scriptableのモジュール分割で起きる問題とその解決

aromariousaromarious

9/10 課題 モジュール分割したい

課題とそれに伴う問題点

  • スクリプトが長くなりがち→モジュールを分けたい
  • Scriptableのモジュールインポートの問題点
    • 通常のJavaScriptのようなimport文が使えず、importModule()を使わねばならない
    • importModule()でインポートされたモジュールは他と独立したスコープになっており、グローバル変数を使うことができない
    • 初期化に非同期処理が含まれている場合に文法エラーが多発する(後述の制約のため)。どう書くべきか検討し結論を出したい

検討の上、結論

  1. グローバルに使う情報はクラス変数に格納する
  2. とはいえ、クラス変数を濫用せず、Singletonクラスを作って管理する(これは検討したわけではなく単なる好み)
  3. モジュールの初期化をしたければ、module.exportsの右辺をasync IIFE(即時実行関数、Intermediate Invoked Function Expression)として記述し、その中に書く

下に検討の内容を記す。1.の検討を「Scriptableのグローバル変数」節に、3.を「新たな問題 asyncな初期化が書きづらい」節に記した。


Scriptableのグローバル変数

モジュールインポート時にグローバル変数で情報の共有をしたいと思い次を試したができなかった。

  1. トップレベルスクリプトでグローバル変数をセットする
  2. モジュールをインポートする
  3. モジュール内でグローバル変数を取得する→❌できない

調査と動作確認で分かったこと

  • もちろん逆もできない。スコープが完全に分かれており、共有しているスコープがないとのこと
  • 必要な変数をモジュールに渡すしか方法がない
  • importModule()の引数か何かで渡せないか→できない

解決策の案2つ

  1. モジュール内に初期化関数を定義する
  2. ⭕️クラスの静的プロパティをグローバル変数として利用する

基本的に2を使うことにした。

以下、2案の検討を書く。

その1 モジュール内に初期化関数を定義する

  1. インポートされるモジュール内に、必要な情報を受け取る初期化関数init(varsToUseInModule)を定義しておく
  2. インポートする側でimportModule()の後、その初期化関数を呼び出すことでモジュール側に渡す
  3. インポートされる側で適切なスコープの変数にセットして利用する
//module.js
let var1
function init(varsToUseInModule) {
  var1 = varsToUseInModule
}
function func1(){
  console.log(var1)
}
module.exports = {
  init, func1
}
//TopLevelScript.js
const toplevelVar = "i want to be global, but can't🥲"
const module = importModule('module')
module.init(toplevelVar)
module.func1()

解決策その2 クラスの静的プロパティをグローバル変数として利用する

グローバル変数は利用できないが、ただし、クラスは全モジュールで共有されるとのこと。

⭕️クラスの静的プロパティをグローバル変数として利用する

//classA.js
class ClassA {
  static var1
  static func1() {
    return this.var1
  }
}
module.exports = ClassA
//TopLevelScript.js
const toplevelVar = "i want to be global, but can't🥲"
const ClassA = importModule('classA')
ClassA.var1 = toplevelVar
console.log(ClassA.func1())

新たな問題 async な初期化が書きづらい

クラスの静的プロパティをグローバル変数として利用できるようになった。しかし、そのプロパティの初期化処理中に非同期処理が含まれている場合に次の課題が発生する。

  • 静的プロパティの宣言時の初期化で await は書けない
  • static ブロックにも await は書けない
  • モジュールのトップレベルスコープにも await は書けない
  • Scriptableの場合、トップレベルスクリプト(Scriptableが直接実行するスクリプト)に関してのみ、トップレベルスコープの await が許されている

この縛りを守りながら非同期初期化処理を書く。

解決策の案3つ

  1. asyncの初期化用static関数を定義し、その中に await を書く
  2. ⭕️module.exports定義の右辺をIIFEにしてその中に書く
  3. クラス定義のstaticブロックをIIFEにしてその中に初期化を書く

2.の「module.exportsをIIFEにする」を採用。

以下、それぞれの案について検討を書く。

  1. asyncの初期化用static関数を定義し、その中にawaitを書く

インポートする側から明示的に初期化を呼び出す。実行は呼び出し側になり、トップレベルスクリプトのトップレベルスコープでのawaitで処理する。

この方法はトリッキーなところがなく、だれが読んでも分かりやすい点が良い。とはいえ importModule() だけではモジュールを使うことができず、もう1行書かねばならない。インポートが複数並んだりすると行数が増えて煩雑になる。

// classA.js
class ClassA {
  static async initFunc() {
    await fetch()
  }
}
module.exports = ClassA
// TopLevelScript.js
const ClassA = importModule('classA')
await ClassA.initFunc()

また、インポートの構造が

TopLevelScript.js → other.js → classA.js

となった場合に、other.js → classA.jsのインポートでawaitを書けない(基本的にトップレベルスコープでのawaitは禁止)。その場合は初期化関数を引き継ぐ。

// other.js
const ClassA = importModule('classA');
async function initFunc() {
  await ClassA.initFunc();
}
module.exports = { ClassA, initFunc };

トップレベルスクリプトが非同期実行(await)を引き受ける。

// TopLevelScript.js
const other = importModule('other.js');
await other.initFunc();

こうなると、なぜこうしているのか、少し分かりづらい。誰が読んでも分かりやすいというわけにはいかなくなる。

other.js のトップレベルスコープにIIFEを書いて初期化するのはどうかと考えたが、

// other.js
const ClassA = importModule('classA');
(async () => {
  await ClassA.initFunc();
})()
// TopLevelScript.js
const other = importModule('other.js');

この書き方だと other.js のIIFEはインポート時に評価されるが、ただキックされるだけになる。評価結果の Promise は誰にも受け取られず、完了を待つことができない。TopLevelScript.jsimportModule() 呼び出しの直後ではこの初期化が完了していない可能性があり、安全に使えない。

  1. ⭕️module.exports 定義の右辺を async IIFE にしてその中に書く

module.export の定義は importModule() の戻り値の定義と言ってよい。 importModule()を呼び出すとmodule.exportsの右辺が評価され、その値が返される。ここをasync IIFEとして初期化を記述し、インポートする側のimportModule()呼び出しをawait付きにする。

// classA.js
class ClassA {
  static async initFunc() {
    await fetch()
  }
}
module.exports = (async () => {
  const init = await ClassA.initFunc()
  return ClassA
})()
// TopLevelScript.js
const ClassA = await importModule('classA')

この方法では importModule() 自体が非同期になるため、await つきで呼び出す必要があるが、この書き方のおかげでインポートが終わった時には初期化が完了していることが保証されており、安全。

インポートの構造が

TopLevelScript.js → other.js → classA.js

のとき、other.jsのトップレベルで classA.js のインポートをしようと思っても await を書けないため、その場合は classA.js のインポートを module.exports のasync IIFEで書くことになる。

// other.js 
let ClassA

function func1(){
  const a = ClassA.staticPropertyA
  return a
}

module.exports = (async () => {
  ClassA = await importModule('classA')
  return { func1 }
})();
// TopLevelScript.js
const other = await importModule('other')
console.log(other.func1())

インポートしたモジュールを格納する変数をIIFE内で宣言すると他で使えなくなるので、それを避けるためにトップレベルで変数宣言だけしておいて、IIFE内でその変数にインポートモジュールを代入する。

  1. クラス定義のstaticブロック内にIIFEを定義し、その中に初期化を書く
// classA.js
class ClassA {
  static {
    (async () => {
      await fetch()
    })()
  }
}
module.exports = ClassA
// TopLevelScript.js
const c = importModule('classA')

staticブロックのIIFEはクラス評価時に実行され、戻り値は誰も受け取らない。確かに文法エラーにはならないが、インポートする側としては初期化処理の完了を知ることはできないため、インポート直後にモジュールを利用できない可能性があって安全とは言いがたく、使用は避けることにした。

aromariousaromarious

この考察検討が必要だった根本原因

普段 TypeScript を使っていて、モジュールインポートはimport文で実施する。Scriptableの処理系ではimportModule()関数で実施する。今回の困ったこと

  • モジュール内でグローバル変数にアクセスできない
  • モジュールのトップレベルスコープでの実行でawaitできない

はこの違いに起因する。

ESM だとimport文でモジュールをインポートする。コード実行前にモジュールを静的に解析して依存関係を確定する。または、import()で実行時に動的にインポートする。CJSではrequire()、見かけによらず実行時の動的インポートではなく、モジュール読み込み時にのみインポートできる。

ScriptableのimportModule()関数は実行時動的インポートという点でESMのimport()に似ている。その点では似ているが、インポート後の動作は違っている。

モジュール内でグローバル変数にアクセスできない

この動作から推測して、ScriptableのimportModule()関数でインポートされたモジュールは何らかのサンドボックス化された環境内で実行されている可能性がある。

トップレベルスコープでの実行

Scriptableでは必ずしもインポートをトップレベルスコープで実施しなければならないわけではない。が、今回のインポートの目的(グローバルスコープの代替)を鑑みるに、インポート及び初期化をトップレベルスコープに置くべきである。

それでトップレベルスコープでawaitするはめになるのだ。

aromariousaromarious

ショートカットから呼び出されるスクリプトのモジュール分割

ショートカットからScriptableを呼び出す

Scriptableのスクリプトはショートカットから呼び出すことができる。

ショートカットからは辞書を渡すことが多い。辞書はJSONに変換されて渡る。

Scriptable側ではJSONがparseされてargs.shortcutParameterへ格納された状態で開始される。

Scriptable側からショートカットへ何か返したいときは Script.setShortcutOutput(value)をコールする。その後、Scriptable側の実行の完了を知らせるためにScript.complete()をコールする。

FancyScriptableScript.js
// ショートカットから渡されたパラメータを受け取る
const argsFromShortcut = args.shortcutParameter
// ショートカットへ返す値をセットする
Script.setShortcutOutput(argsFromShortcut)
// Scriptable側の実行の完了を知らせる
Script.complete()

実行するとこう。

モジュール分割した場合の注意点

Scriptable側からショートカットへ何か返したいときは Script.setShortcutOutput(value)をコールする。その後、Scriptable側の実行の完了を知ら
せるためにScript.complete()をコールする。

インポートしたモジュール内でこれらを呼び出しても無効らしく、タイムアウトが発生する。

  • Script.setShortcutOutput(value)
  • Script.complete()

これらはモジュール内に記述しない。トップレベルスクリプトにのみ記述すること。