0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

急にVueの中身でなにかする必要が生まれた時に見る記事

Posted at

概要

みなさんはVueのファイル自体をパースしたり中身を書き換えたりしたくなったことはありますか?
私はあります。
みなさんはそういった極限の状況に追い込まれないように願っていますが、どうしてもというときのために記録を残すことにしました。

ケース

Gitのリビジョン間でVueファイルに加えられた差分を構造的に検出し、条件にマッチした場合はそのコミットのrevert commitを差し込む処理のために必要でした。

実装

今回はTypeScriptを使います。

環境

  • npm 10.8.2
  • TypeScript 5.5.4
  • NodeJS 22.6.0

セットアップ

今回はあえて npm を使います。気に食わない人は脳内でyarnpnpmなどのお好みのパッケージマネージャーに読み替えてください。

  1. npm init で必要事項を入力します
  2. npm install -D typescript します
  3. npx tsc --init します
  4. tsconfig.json で次のことをします。ただしこれらは必須でなく任意です
    1. "target""esnext" にします (最新のバージョンを使うため)
    2. "module""NodeNext" にします (ESM)
    3. "moduleResolution""NodeNext"にします (ESM)
    4. "rootDir""./src" にします (散らかるため)
  5. package.json で次のことをします
    1. "type": "module" を追記します (ESM)
    2. "scripts""launch""npx tsc && node ./src/index.js" にします [*]
    3. "main""src/index.js" にします (散らかるため)

[*] 実行する時は常に npm run launch で呼び出します。

Vue SFCのパース

さて、ここからはソースコードの話に移ります。

Vueのファイル、より正式にはVue SFCはおおまかに

  1. <template>内部で宣言されたコンポーネント
  2. <script>内部のcomposition APIのsetupスクリプト
  3. <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-typescript2@types/babel__core3 を依存に加えて自分で呼び出してこの問題は解決できます。型チェックは走らずに、型アノテーションだけが削ぎ落とされます。

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.programbabel.types.Program 型を持ち、食わせたJavaScriptの抽象構文木のルートです。通常はここを探索の起点にするでしょう。

要件に合わせてデータを加工する

VueのDOMもBabelのASTも、どちらも一方向の木構造になっているので、うまいこと必要な階層まで木構造を掘ってください。必要であれば位置情報もあります。

あとがき

本題とは関係ありませんが、if文をネストさせると地獄になるので早い段階で随時early-returnに書き直しましょう。

この記事はこれで終わりです。楽しい構文木ライフを!

  1. 詳しく調べていません

  2. インストールしておかないと動的インポートで失敗します

  3. これがないと@babel/coreの型が解決できない!と怒ってきます

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?