概要
みなさんはVueのファイル自体をパースしたり中身を書き換えたりしたくなったことはありますか?
私はあります。
みなさんはそういった極限の状況に追い込まれないように願っていますが、どうしてもというときのために記録を残すことにしました。
ケース
Gitのリビジョン間でVueファイルに加えられた差分を構造的に検出し、条件にマッチした場合はそのコミットのrevert commitを差し込む処理のために必要でした。
実装
今回はTypeScriptを使います。
環境
- npm 10.8.2
- TypeScript 5.5.4
- NodeJS 22.6.0
セットアップ
今回はあえて npm
を使います。気に食わない人は脳内でyarn
やpnpm
などのお好みのパッケージマネージャーに読み替えてください。
-
npm init
で必要事項を入力します -
npm install -D typescript
します -
npx tsc --init
します -
tsconfig.json
で次のことをします。ただしこれらは必須でなく任意です-
"target"
を"esnext"
にします (最新のバージョンを使うため) -
"module"
を"NodeNext"
にします (ESM) -
"moduleResolution"
を"NodeNext"
にします (ESM) -
"rootDir"
を"./src"
にします (散らかるため)
-
-
package.json
で次のことをします-
"type": "module"
を追記します (ESM) -
"scripts"
の"launch"
を"npx tsc && node ./src/index.js"
にします [*] -
"main"
を"src/index.js"
にします (散らかるため)
-
[*] 実行する時は常に npm run launch
で呼び出します。
Vue SFCのパース
さて、ここからはソースコードの話に移ります。
Vueのファイル、より正式にはVue SFCはおおまかに
-
<template>
内部で宣言されたコンポーネント -
<script>
内部のcomposition APIのsetupスクリプト -
<style>
内部のスタイル
というパートに分かれています。私のケースではコンポーネント及びsetupスクリプトを触る必要がありました。
まずは @vue/compiler-dom
を依存に追加し、SFC全体をパースさせます。
import { parse as parseVueDOM, type RootNode } from '@vue/compiler-dom';
const document: RootNode = parseVueDOM(source);
失敗したら例外が投げられるはず1です。今回は解説のため、また予めvalidなVue SFCを読み込めるという前提に私が立てるためエラーハンドリングは省略します。
RootNode
などの全てのノードはtype
プロパティでその種別が区別されます。詳しいことはd.ts
を読んでもらうとして、NodeTypes
と比較するのが定石です。例えばノードが要素であるかどうかを知りたい時は
function isElement(node: Node): e is ElementNode {
return e.type === NodeTypes.ELEMENT;
}
とします。
また、要素名は tag
プロパティで取れます。
setupスクリプトのTypeScriptをトランスパイルする
厄介なことに、setup
スクリプトがTypeScriptの場合は自分でトランスパイルしなければなりません。
幸い、@babel/core
、@babel/preset-typescript
2、@types/babel__core
3 を依存に加えて自分で呼び出してこの問題は解決できます。型チェックは走らずに、型アノテーションだけが削ぎ落とされます。
import babel from '@babel/core';
import { type ElementNode } from '@vue/compiler-dom';
const setupScriptCandidate = document.children.filter(n => n.type === NodeTypes.ELEMENT && n.tag === "script");
if (!(setupScriptCandidate.length === 1 && setupScriptCandidate[0].type === NodeTypes.ELEMENT)) {
throw new Error('script');
}
const scriptElement = setupScriptCandidate[0];
if (!(scriptElement.children.length === 1 && scriptElement.children[0].type === NodeTypes.TEXT)) {
throw new Error('script');
}
const language: string = scriptElement.props
.filter(p => p.type === NodeTypes.ATTRIBUTE)
.filter(p => p.name === "lang")[0]
?.value?.content ?? 'js';
const untypedScript = language === "ts"
? babel.transformSync(script, {
presets: ["@babel/preset-typescript"],
filename: 'input.ts',
})
: { code: script };
untypedScript.code
が通常のJavaScriptであることに注意すると、あとは普通にパースしてもらうだけです。
const parsed = babel.parseSync(untypedScript.code);
if (!parsed) {
throw new Error('babel failed to parse script!');
}
parsed.program
は babel.types.Program
型を持ち、食わせたJavaScriptの抽象構文木のルートです。通常はここを探索の起点にするでしょう。
要件に合わせてデータを加工する
VueのDOMもBabelのASTも、どちらも一方向の木構造になっているので、うまいこと必要な階層まで木構造を掘ってください。必要であれば位置情報もあります。
あとがき
本題とは関係ありませんが、if
文をネストさせると地獄になるので早い段階で随時early-return
に書き直しましょう。
この記事はこれで終わりです。楽しい構文木ライフを!