概要
Windows Script Host(WSH)は、Windowsに標準搭載されているスクリプト実行環境です。Visual BasicをベースとしたVBScriptや、ES3相当のJavaScriptをベースとしたJScriptで記述することができる、ちょっちかしこいバッチファイルだと考えてください。Windowsに関わるほとんどなんでも実現することができます。
- ファイル操作
- レジストリ操作
- シェルコマンドの実行
- ExcelをはじめとしたCOMオートメーション
- (がんばれば)Windows APIの呼び出し
- など...
バッチファイルと同程度に、Windowsであればどこでも実行できることが強みです。
ただVisual BasicもES3もひどくレガシーであり、現代のプログラマーであるわれわれにとってこれらを扱うのは本当に苦痛です。なんとかこの強力なスクリプトを、快適に書くことはできないものでしょうか。
目標
着目するのはJScriptです。ES3相当とはいえ、JavaScriptであればTypeScriptの恩恵を受けることができるでしょう。モダンWindowsスクリプティングを標榜して、以下を目標に設定します。
- WSH JScriptをTypeScriptで書く
- 実行環境に遠慮せず、新しいJavaScriptで書く
- 最終出力はES3
- ES Modulesを使えるようにする
TypeScriptで書くことで、型にまつわる不毛なミスを減らすことができます。型定義はnpmに揃っています。なければ自分で書きましょう。
仕様の穴を突くような方法でES2015相当のJavaScriptをWSHで実行する方法もありますが、終了コードを返すWScript.Quit
を利用できないことが痛手です。今回は素直にES3にトランスパイルすることにします。Babelを利用し、記法の変換やポリフィルを任せてしまいます。
中規模以上の開発にはモジュール機能が欠かせません。WSHはモジュールに対応していないため、単一ファイルにまとめてくれるモジュールバンドラーが必要です。Webpackなどいくつか選択肢があり、どれでも実現できると思いますが、今回はバンドル後の可読性を評価してRollupを利用します。
環境
C:\>ver
Microsoft Windows [Version 10.0.22000.675]
C:\>node -v
v17.4.0
実践
Node.jsのインストール
JScriptは標準搭載ですが、上に挙げたツールを利用するためにはNode.jsが必要です。最近はなんでもwingetでインストールできて助かります。
winget install -e --id OpenJS.NodeJS
プロジェクト作成
hogeフォルダを作り、空のファイルを作成しておきます。
index.ts
がエントリーポイントになります。各ファイルはUTF-8で編集し、バンドル時にUTF-16 LEに変換されます。
mkdir hoge
cd hoge
New-Item -Name src -ItemType Directory
New-Item -Name dist -ItemType Directory
New-Item src/index.ts -ItemType File
New-Item rollup.config.js -ItemType File
New-Item .babelrc -ItemType File
開発ツールのインストール
開発に使用するツールをインストールします。
npm init --yes
npm install --save-dev @babel/core @babel/preset-env @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-typescript rollup tslib typescript
npm install core-js
npx tsc --init
@babel/core
@babel/preset-env
Babel本体と、指定した環境に合わせて自動で適切なポリフィルを導入するプリセットです。
@rollup/plugin-babel
@rollup/plugin-commonjs
@rollup/plugin-typescript
rollup
Rollup本体と、各ツールをRollupと連携させるプラグインです。@rollup/plugin-commonjs
は、ポリフィルの導入時に追加されるCommonJSモジュールをES Modulesに揃えます。
tslib
typescript
TypeScript関連です。tslib
はRollupでTypeScriptをトランスパイルするために必要です。
core-js
ポリフィルです。上記のツールは--save-dev
(package.json
における"devDependencies"
)にインストールされますが、core-js
は"dependencies"
にインストールする必要があります。
tsconfig.json
の編集
-
"module"
を"commonjs"
から"esnext"
に変更します。 -
"compilerOptions"
と同じ階層に"include"
を追加します。拡張子*.ts
のファイルがトランスパイル対象になります。
{
"compilerOptions" {
// 省略
"module": "esnext",
// 省略
},
"include": ["src/**/*.ts"]
}
.babelrc
の編集
{
"presets": [
[
"@babel/preset-env",
{
// モジュールの処理はRollupに任せます。
"modules": false,
// 可能な限りObject.definePropertiesを使用しないES3に変換します。
"targets": {
"ie": 8
},
"loose": true,
// この設定によって、core-jsから自動でポリフィルが導入されます。
"useBuiltIns": "usage",
"corejs": 3
}
]
]
}
rollup.config.js
の編集
import typescript from "@rollup/plugin-typescript";
import babel from "@rollup/plugin-babel";
import commonjs from "@rollup/plugin-commonjs";
export default [
{
input: "src/index.ts",
output: {
file: "dist/index.js",
format: 'es'
},
plugins: [
// tsconfig.jsonに従って、TypeScriptがトランスパイルされます。
typescript(),
// .babelrcに従って、ES3へトランスパイルされます。
babel({
babelHelpers: 'bundled',
extensions: ['.js', '.ts'],
}),
// CommonJSモジュールをES Modulesに揃えます。
commonjs()
]
}
];
package.json
の編集
"type": "module"
を追加し、"scripts"
に"build"
を追加します。
JScriptではObject.defineProperty
という重要なメソッドが使えず、おそらくBabelもそれを承知でトランスパイルしてくれるようなのですが、その後RollupがバンドルにObject.defineProperty
を利用しようとするため、JScriptで実行できないものを出力してしまうようです。
なので、バンドル結果のindex.js
の先頭に簡易ポリフィルを追加するスクリプトを折り込みました。npm run build
の正体は最小化されたバッチファイルであり、そもそもWindowsでしか使えないスクリプトなので他OSへの移植性は気にしていません。(それが伝わるように、わざとDOSコマンドを大文字で書いています。)
{
"type": "module"
// 省略
"scripts": {
// 省略
"build": "ECHO Object.defineProperty=function(o,p,d){o[p]=d.value}; > _.js && rollup -c && COPY /B _.js+dist\\index.js _.js && powershell \"Get-Content -Encoding UTF8 _.js | Set-Content -Encoding Unicode dist\\index.js\" && DEL /Q _.js"
}
// 省略
}
REM 簡易ポリフィルを記載した一時ファイルを用意します。
ECHO Object.defineProperty=function(o,p,d){o[p]=d.value}; > _.js
REM Rollupの結果、dist\index.js が生成されます。
rollup -c
REM 一時ファイルとindex.jsを連結して、一時ファイルに上書きします。
COPY /B _.js+dist\index.js _.js
REM 後述の日本語対応のために、一時ファイルの文字コードをUTF-8からUTF-16 LEに変換して、index.jsを上書きします。
powershell "Get-Content -Encoding UTF8 _.js | Set-Content -Encoding Unicode dist\index.js"
REM 一時ファイルを削除します。
DEL /Q _.js
index.js
の生成
以下を実行することで、トランスパイルとバンドル及び文字コードの変換が走り、dist\index.js
が生成されます。
npm run build
このファイルをwscript/cscriptで実行することができます。
wscript .\dist\index.js
日本語対応
先頭に載せたリポジトリでも説明しているのですが、本来WSHはASCII
、Shift_JIS
、UTF-16 LE
あたりしか読むことができず、一般的にUTF-8
を前提とするJavaScriptツールチェインとは相性が悪いです。UTF-8
で保存されたスクリプトで日本語の文字列を表示すると、WSHでは文字化けします。
ただし今回の方式では、どこかでツールが気を利かせてくれて、UTF-8でもWSHで文字化けしないように変換されています。
ふが => \u3075\u304C
しかしバンドル後ファイルの可読性も落ちますし、他コンポーネントとの連携に支障があるかもしれません。念のため、package.json
の"scripts"
で文字コードを変換しています。
サンプル
この記事の環境構築を適用したプロジェクトのサンプルとして、WSH JScriptで実行されるバイナリビュアーを作成しました。TypeScriptではアロー関数やArray.forEach
、テンプレートリテラル、letやconstをふんだんに利用していますが、最終的にはJScriptとして実行可能な形式に変換されます。
WSH JScriptでは本来バイナリファイルを扱うことは難しいのですが、一工夫することでNumberに落とし込むことができます。この部分をモジュールに切り出し、再利用しやすいようにまとめることができました。
余談: Windows APIについて
従来、WSHでWindows APIを利用する方法として、SFC miniやExcel経由で利用することが提案されていました。しかしせっかくWSHは標準搭載なのに、Windows APIの実行を外部のソフトウェアに頼るのは残念ですよね。
これを解決する手段として、まだ途上なのですが、PowerShellのP/Invoke経由で実行する方法を考えています。
WScript.CreateObject("WScript.Shell").Run("cmd.exe /c powershell -Nologo (Add-Type -Name _ -MemberDefinition '[DllImport(\"\"\"\"user32.dll\"\"\"\")] public static extern int MessageBoxA(IntPtr param0, string param1, string param2, UInt32 param3);' -PassThru)::MessageBoxA(0, \"\"\"\"ほげ\"\"\"\", \"\"\"\"ふが\"\"\"\", 0) 1>stdout 2>stderr", 0, true);
一時ファイルstdout
に戻り値、stderr
にエラー出力が格納されます。これらを解析することで、成否を判断することができます。個々の単純なAPIに対してラッパーを用意するのは簡単なのですが、戻り値を引数に返してきたりするものへの対応を考えているところです。
まとめ
WSH JScriptを、かなり現代のJavaScriptに寄せて書くことができるようになりました。書きやすいバッチファイルとしてもよし、Google Apps ScriptやOffice ScriptよろしくExcel VBAをモダンに書くこともできます。