mizdra's blog

ぽよぐらみんぐ

アセットの import を簡単にする TypeScript Language Service Plugin を作った

Web ページを作るときに、あらかじめファイルに書き出しておいた画像 (アセット) をページに埋め込みたいことがよくあると思います。例えばヘッダーにサービスのロゴ画像を埋め込む場合、以下のようなコードを書くと思います。

// src/components/Header.tsx
export function Header() {
  return (
    <header>
      <img src="/assets/logo.png" alt="Logo image" />
      {/* ... */}
    </header>
  );
}

一方で、最近のWeb フロントエンドフレームワーク (例: Next.js, Remix) を使う場合は、import 文を用いて以下のように書くことが多いと思います。

// src/components/Header.tsx
import I_LOGO from '../assets/logo.png';
export function Header() {
  return (
    <header>
      <img src={I_LOGO} alt="Logo image" />
      {/* ... */}
    </header>
  );
}

このように書いてビルドすると、フレームワークのビルドツール *1I_LOGOにアセットへのパスを割り当て、<img src="/assets/logo.c53d93.png" alt="Logo image" />というコードに変換します (c53d93content hash)。加えてアセットも<ビルド成果物の出力先ディレクトリ>/assets/logo.c53d93.png へとコピーします。要は cache-busting パターンに対応した変換をやってくれる訳です。

面倒な cache-busting パターン対応を自動でやってくれる、ということで最近はこの方法でアセットを埋め込むのが主流になっていると思います。

アセットの import 文が書きづらい

前置きが長くなりましたが、いよいよ本題です。先程のコードを実際にエディタで書いてみると分かるのですが、アセットの import 文が書きづらいです。import 文が補完されないので、地道に書かないといけません。

youtu.be

.js/.ts ファイルの import の挙動と比較すると、アセットの import の体験がどれほど理想から遠ざかっているかがよくわかります。.js/.ts ファイルの import では、export されているアイテムの名前を打ち込むだけで、補完の候補にそれが出ます。加えて、候補に出たアイテムを選択すると import 文も自動挿入されます。

youtu.be

@mizdra/typescript-plugin-asset の紹介

この体験を何とか改善できないかと思い、作ったのが @mizdra/typescript-plugin-asset です。

github.com

これを使うと、.js/.ts の import と同等の体験を asset の import にもたらします。

youtu.be

使い方も簡単で、npm install -D @mizdra/typescript-plugin-asset してtsconfig.jsonにちょっとした設定を書くだけです。

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "plugins": [
      {
        "name": "@mizdra/typescript-plugin-asset",
        // 補完に出したいアセットをグロブパターンで指定する
        "include": ["src/assets/**/*"],
        // 補完に出すアセットの拡張子を指定する
        "extensions": [".png", ".jpg", ".svg"],
      }
    ]
  }
}

ただ VSCode を使用している場合は、TypeScript Language Service Plugin が使えるように追加のセットアップが必要です。

  1. VSCode を開いて、コマンドパレットから「TypeScript: Select TypeScript Version」 を実行
  2. 表示されたメニューから「Use Workspace Version」を選択

使い方の説明は以上です。以降ではこのツールをどうやって作っていったのかについて解説していきます。

なぜアセットの import は .js/.ts ファイルの import のようにできないのか

最初に@mizdra/typescript-plugin-assetを作ろうと思った時点では、どうやったらアセットの import 文の自動挿入が実現できるかまだ分かっていませんでした。そこでまずは手がかりを掴もうと、アセットの import が .js/.ts ファイルの import のようにできない理由を探ることにしました。

調べてみると、どうやら JavaScript/TypeScript の Language Server がアセットを「プロジェクトを構成するファイルの1つ」と見なしていないのが原因らしいことが分かってきました。

(そもそも Language Server とは何かについてはこちらを参照):

JavaScript/TypeScript ファイル上で開発者がコード補完を要求した際に、どういう補完候補を出すのかは全て Language Server が決めています。そしてその補完候補を出すために、Language Server は「tsconfig.json の includeオプションやfilesオプションで指定されている .js/.ts ファイル *2」を解析して、export されている item を収集しています *3

よって、.js/.ts から export されているアイテムなら補完候補に出てきますが、それ以外のファイルは補完候補に出てきません。これがアセットの import が .js/.ts ファイルの import のようにできない理由です。

アセットでも import 文の自動挿入を実現する方法

import 文の自動挿入ができない原因が分かったので、その原因を何とかして回避できないか考えることにしました。幸いなことに、いくつかの回避方法があることが分かりました。

方法1: アセットを再 export する .ts ファイルを作る

.tsファイルから export されていれば補完候補に出てくる」という点に着目し、一度中間となる.tsファイルにアセットを import して、そこから再 export し、アセットの import 文の補完を実現する方法です。

// src/lib/assets.ts
import I_LOGO from '../assets/logo.png';
export { I_LOGO };

youtu.be

実際に import 元に書かれる import specifier (fromの後ろの文字列) は../assets/logo.pngではなく../lib/assetsになってしまうのですが...やりたいことは実現できています。

方法2: .d.ts ファイルを作成する

アセットが置かれているディレクトリに.d.tsファイルを併置すると *4、TypeScript の Language Server がアセットを「プロジェクトを構成するファイルの1つ」として扱ってくれます。これを利用しても、アセットの import 文の補完を実現できます。

// src/assets/logo.png.d.ts
// logo.png というモジュールの型定義ファイル。
// ここでは I_LOGO という string 型の変数を default export していると定義している。
declare const I_LOGO: string;
export default I_LOGO;

youtu.be

方法3: TypeScript Language Service Plugin を使う

TypeScript の Language Server には「TypeScript Language Service Plugin」という機能があります。これを使うと、ユーザレベルで Language Server の振る舞いをカスタマイズできます。具体的には補完候補に介入して本来の補完候補にはないものを挿入したり、あるいはトランスパイルを挟むことで .js/.ts 以外のファイルも扱えるよう拡張できます。

現に Vue.js で使われている TypeScript Vue Plugin は、.ts ファイルから .vue ファイルから export されているアイテムを補完候補に出したり、import 文を補完したりといった機能を提供してます。

youtu.be

アセットではなく .vue の import になってしまっていますが、原理上はアセットでも同等のことができるはずです。

@mizdra/typescript-plugin-asset でどれを採用するか

いずれも良い方法だと思うのですが、方法1, 2 は以下のような理由でイマイチかなーと感じました。

  • 方法1 は (難しすぎるので詳しくは解説しませんが) 未使用のアセットがビルド成果物に含まれてしまう可能性があるのが気になった
  • 方法2 は.d.tsを置くために生じるトラブルがあるのが気になった
    • PR の差分に含まれないよう.gitignoreしないといけなかったりとか
    • lint 対象に含まれないように.eslintignoreしないといけないとか
    • やれば良いことではあるけど、やらなくて良いに越したことはない

そこで @mizdra/typescript-plugin-assetでは、余計なファイルを作成せずとも機能し、扱いやすい方法3を採用することにしました。

OSS に上手く乗っかって楽をする

...といっても何となく実装が大変そうなイメージがあって、実装が億劫でした。そこで何とかして楽に作る方法がないか探ることにしました。

一縷の望みに賭けて TypeScript Vue Plugin のコードを読んでみると、コア部分が Volar.js という OSS に切り出されていることが分かりました。

volarjs.dev

Volar.js は組み込み言語の開発支援ツールを作るためのフレームワークです。組み込み言語というのが聞き慣れないですが、「ある言語の中に別の言語が埋め込まれているようなもの」のことです。例えば埋め込み言語の 1 つである .vue では、<script><style>で JavaScript/CSS といった言語を"埋め込む"ことができます。Volar.js はこうした組み込み言語の Language Server を簡単に作るための仕組みを、.vue に限らず任意の言語に向けて提供します。

この OSS を見て、ふと「アセットを埋め込み言語とみなせば良いのでは」というアイデアが思い浮かんできました。つまりアセットは TypeScript 言語を"埋め込む"言語とみなす訳です。

 // assets/logo.png
 <script setup lang="ts">
   declare const I_LOGO: string;
   export default I_LOGO;
 </script>
// assets/logo.png...
Text is not SVG - cannot display

アセットファイルが ts コードを埋め込む「埋め込み言語」のファイルとして扱う。

こうすれば Volar.js にうまく乗っかって 楽に Language Server を実装できそうということで、このアイデアを元に実装することにしました。

実装

ここまで来ればあとはやるだけです!

適当にアセット (.png, .svg など) は .ts を埋め込む言語であるというコード (VirtualFile class) を書いて...

それを Volar.js の API を使って、TypeScript Language Service Plugin の API と繋ぎ込むだけです。

Volar.js の世界観の知識がないと結構書くのが難しいですが... TypeScript Vue Plugin のコードを見ながら書けばなんとか...みたいな感じです。それさえ書いてしまえば、面倒なことは全部 Volar.js がやってくれます。全部で 400 行くらいなのでまあまあ手軽に実装できてると思います。

やり残していること

沢山あります!

今は exportedNamePrefix を一通りしか設定できないのですが、拡張子によって何通りかに変えたい場合もあると思っていて、そのための仕組みの実装を予定しています。が、まだそこまで手が伸びていないです。

コードの品質もイマイチで、殴り書きしてますし、TypeScript Language Service Plugin の API はよく分からずに触ってます。あと TypeScript Language Service Plugin や Volar.js との結合部分のテストコードはないです。

また、多分特定の環境によっては全然動かないとかがあると思ってます!世の中のエディタの環境は本当に多様なのですが、まだそういうケースを想定できていないです。

そういう感じなので、品質には期待しないでください!動かなければ Issue を送ってもらえると嬉しいですが、諸事情あって当面は開発リソースがないので対応は先送りになるかもしれません。

おわりに

この記事では、アセットの import を簡単にするためのツールの紹介、そしてそのツールをどうやって実装したのかについて解説しました。埋め込み言語の Language Server をイチから作ろうとするとかなり大変なのですが、Volar.js に乗っかり楽に作れたのがすごく良かったです。

余談ですが、Astro の Language Server も Volar.js を使って構築する作業が進んでいるようです。Vue.js コミュニティ発の OSS が、コミュニティを超えて影響を与えている、という点で素晴らしい出来事だと思います。

Volar.js 以外でも Vite/Vitest などの OSS も Vue.js コミュニティ発です。そして、そちらに至っては本当に多くの人に使われています。実は我々は Vue.js コミュニティの多大な尽力の上に立っている...ということを痛感させられますね。Vue.js コミュニティに感謝。

*1:正確には webpack や vite といった bundler が行っている作業です。非 JavaScript ファイルの import は ECMAScript 仕様では規定されておらず、bundler による拡張です。詳しくは https://webpack.js.org/guides/asset-modules/https://ja.vitejs.dev/guide/assets.html を読んでみてください。

*2:厳密には .cjs/.mjs/.cts/.mts/.tsx なども対象に含まれます。ただ例外を挙げるとキリがないので、ここでは省略してます。

*3:もちろんプロジェクトを構成するファイルに .js/.ts 以外を含めないかは Language Server の実装次第です。ただ、少なくとも JavaScript/TypeScript の Language Server の事実上の標準である tsserver は、.js/.ts 以外を含めない挙動になってます。VSCode で使われる JavaScript/TypeScript の Language Server は tsserver ですし、その他のエディタでもそれが使われることがほとんどです。

*4:--moduleResolution node では .d.ts ファイルを作成すれば良いですが、--moduleResolution node16 では --allowArbitraryExtensions オプションを有効化し、ファイル名も logo.d.png.ts にする必要があります。詳しくは https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#allowarbitraryextensions を参照してください。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.