Scriptableのモジュール分割で起きる問題とその解決
9/10 課題 モジュール分割したい
課題とそれに伴う問題点
- スクリプトが長くなりがち→モジュールを分けたい
- Scriptableのモジュールインポートの問題点
- 通常のJavaScriptのような
import
文が使えず、importModule()
を使わねばならない -
importModule()
でインポートされたモジュールは他と独立したスコープになっており、グローバル変数を使うことができない - 初期化に非同期処理が含まれている場合に文法エラーが多発する(後述の制約のため)。どう書くべきか検討し結論を出したい
- 通常のJavaScriptのような
検討の上、結論
- グローバルに使う情報はクラス変数に格納する
- とはいえ、クラス変数を濫用せず、Singletonクラスを作って管理する(これは検討したわけではなく単なる好み)
- モジュールの初期化をしたければ、
module.exports
の右辺をasync IIFE(即時実行関数、Intermediate Invoked Function Expression)として記述し、その中に書く
下に検討の内容を記す。1.の検討を「Scriptableのグローバル変数」節に、3.を「新たな問題 asyncな初期化が書きづらい」節に記した。
Scriptableのグローバル変数
モジュールインポート時にグローバル変数で情報の共有をしたいと思い次を試したができなかった。
- トップレベルスクリプトでグローバル変数をセットする
- モジュールをインポートする
- モジュール内でグローバル変数を取得する→❌できない
調査と動作確認で分かったこと
- もちろん逆もできない。スコープが完全に分かれており、共有しているスコープがないとのこと
- 必要な変数をモジュールに渡すしか方法がない
-
importModule()
の引数か何かで渡せないか→できない
解決策の案2つ
- モジュール内に初期化関数を定義する
- ⭕️クラスの静的プロパティをグローバル変数として利用する
基本的に2を使うことにした。
以下、2案の検討を書く。
その1 モジュール内に初期化関数を定義する
- インポートされるモジュール内に、必要な情報を受け取る初期化関数
init(varsToUseInModule)
を定義しておく - インポートする側で
importModule()
の後、その初期化関数を呼び出すことでモジュール側に渡す - インポートされる側で適切なスコープの変数にセットして利用する
//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つ
- asyncの初期化用static関数を定義し、その中に
await
を書く - ⭕️
module.exports
定義の右辺をIIFEにしてその中に書く - クラス定義のstaticブロックをIIFEにしてその中に初期化を書く
2.の「module.exports
をIIFEにする」を採用。
以下、それぞれの案について検討を書く。
- 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.js
の importModule()
呼び出しの直後ではこの初期化が完了していない可能性があり、安全に使えない。
- ⭕️
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内でその変数にインポートモジュールを代入する。
- クラス定義のstaticブロック内にIIFEを定義し、その中に初期化を書く
// classA.js
class ClassA {
static {
(async () => {
await fetch()
})()
}
}
module.exports = ClassA
// TopLevelScript.js
const c = importModule('classA')
staticブロックのIIFEはクラス評価時に実行され、戻り値は誰も受け取らない。確かに文法エラーにはならないが、インポートする側としては初期化処理の完了を知ることはできないため、インポート直後にモジュールを利用できない可能性があって安全とは言いがたく、使用は避けることにした。
この考察検討が必要だった根本原因
普段 TypeScript を使っていて、モジュールインポートはimport
文で実施する。Scriptableの処理系ではimportModule()
関数で実施する。今回の困ったこと
- モジュール内でグローバル変数にアクセスできない
- モジュールのトップレベルスコープでの実行で
await
できない
はこの違いに起因する。
ESM だとimport
文でモジュールをインポートする。コード実行前にモジュールを静的に解析して依存関係を確定する。または、import()
で実行時に動的にインポートする。CJSではrequire()
、見かけによらず実行時の動的インポートではなく、モジュール読み込み時にのみインポートできる。
ScriptableのimportModule()
関数は実行時の動的インポートという点でESMのimport()
に似ている。その点では似ているが、インポート後の動作は違っている。
モジュール内でグローバル変数にアクセスできない
この動作から推測して、ScriptableのimportModule()
関数でインポートされたモジュールは何らかのサンドボックス化された環境内で実行されている可能性がある。
トップレベルスコープでの実行
Scriptableでは必ずしもインポートをトップレベルスコープで実施しなければならないわけではない。が、今回のインポートの目的(グローバルスコープの代替)を鑑みるに、インポート及び初期化をトップレベルスコープに置くべきである。
それでトップレベルスコープでawait
するはめになるのだ。
ショートカットから呼び出されるスクリプトのモジュール分割
ショートカットからScriptableを呼び出す
Scriptableのスクリプトはショートカットから呼び出すことができる。
ショートカットからは辞書を渡すことが多い。辞書はJSONに変換されて渡る。
Scriptable側ではJSONがparseされてargs.shortcutParameter
へ格納された状態で開始される。
Scriptable側からショートカットへ何か返したいときは Script.setShortcutOutput(value)
をコールする。その後、Scriptable側の実行の完了を知らせるためにScript.complete()
をコールする。
// ショートカットから渡されたパラメータを受け取る
const argsFromShortcut = args.shortcutParameter
// ショートカットへ返す値をセットする
Script.setShortcutOutput(argsFromShortcut)
// Scriptable側の実行の完了を知らせる
Script.complete()
実行するとこう。
モジュール分割した場合の注意点
Scriptable側からショートカットへ何か返したいときは
Script.setShortcutOutput(value)
をコールする。その後、Scriptable側の実行の完了を知ら
せるためにScript.complete()
をコールする。
インポートしたモジュール内でこれらを呼び出しても無効らしく、タイムアウトが発生する。
Script.setShortcutOutput(value)
Script.complete()
これらはモジュール内に記述しない。トップレベルスクリプトにのみ記述すること。