mizdra's blog

ぽよぐらみんぐ

ESLint の Suggestions から学ぶ、コードの自動修正の奥深さ

これは、はてなエンジニアアドベントカレンダー2023 4日目の記事です。

3日目は id:mechairoi さんの「SQLiteでLinderaを使った日本語全文検索」でした。

blog.chairoi.me


今日のテーマは、JavaScript 向けの Linter 「ESLint」についてです。ESLint を使うと、JavaScript で書かれたコードを静的解析して、よくある間違いを検出したり、コーディングスタイルを統一できます。

通常、ESLint のルールによって報告された問題 (error や warn) は人が手で修正します。ただし、ルールが報告する問題の中には「fixable」な性質を持ったものがあります。こうした fixable な問題は、eslint --fix で自動修正できます。例えば、object-shorthand ルールによって報告された問題は、以下のように自動修正できます。

 const foo = 'str1';
 const obj = {
-   foo: foo,
+   foo,
   bar: 'str2',
 };

エディタの ESLint の拡張機能を使えば、ファイルの保存時に自動修正を適用する、なんてこともできます。

fixable なもの、fixable ではないもの

基本的に ESLint は、安全に修正できる問題にしか自動修正を提供しません。噛み砕いて言うと、コードの実行時の挙動を変えない修正方法がある問題だけが fixable になってます。

これは sort-imports ルールを触ってみると分かりやすいと思います。sort-imports は「import 文の { ... } の中がソートされているか」「from '...' で指定されたモジュール名がアルファベット順になるよう import 文がソートされているか」を検証するルールです。このうち前者は fixable ですが、import 文の順序を変えるとモジュールの評価順が変わり、実行時の挙動が変わる恐れがあるため、後者は fixable ではありません。

/*eslint sort-imports: "error"*/
import {d, c} from 'foo.js';
//      ^^^^
// `c, d` の順で書けとエラーが報告される。これは fixable。
import {a, b} from 'bar.js';
//                  ^^^^^^
//  `bar.js` は `foo.js` よりも前で import しろとエラーが報告される。
//  これは fixable ではない。

Suggestions

とはいえコードを自動修正できれば、日々のコーディングの手間を軽減できます。ときには実行時の挙動が変わってしまうことを覚悟してでも、自動修正したいこともあるでしょう。

そうしたケースのために、ESLint は「Suggestions」という機能を提供してます。

suggestions を持つ問題は、fixable な問題と同じようにコードを自動修正できますが、eslint --fix からはできません。エディタ上から手動でのみ自動修正を適用できます。

そんな問題を報告するルールあったっけ? と思っている人も多いかもしれませんが、皆さんご存知 eslint-plugin-react-hooks plugin の exhaustive-deps ルールが、まさにそれです。useEffect の第二引数の書き換えは実行時の挙動を変える恐れがあるため、この修正が suggestions として提供されてます。

exhaustive-deps ルールによる自動修正の様子。VS Code の Code Action のメニューからボタンを押すことで修正を適用する。
exhaustive-deps ルールによる自動修正の様子。VS Code の Code Action のメニューから手動で修正を適用する。

fixable な問題と違い、CLI から一括で適用するコマンドは用意されていません *1。本質的に安全ではない書き換えなので、人の目で確かめてから適用せよ、という訳です。

自動修正の候補が複数あるものも扱える

Suggestions が扱う自動修正は、なにも「実行時の挙動を変える恐れがあるもの」だけではありません。Suggestions は「問題の自動修正の候補が複数あるもの」も扱えます。

例えば no-useless-escape ルールは "\'" というコードを "'""\\'" のどちらかに修正できると suggestions を使って報告してくれます。

エディタの Code Action のメニューに自動修正の候補が複数表示される様子。

自動修正先が複数あるというの、言われてみたらたしかにあるか、という気持ちになりますが、普通そんなものが存在すると考えることもないと思います。こうしたエッジケースも ESLint でカバーされているというのは面白いですね。

そうは言っても Suggestions も一括適用したい!

「安全ではないから」「人がどれを適用するか見極める必要があるから」。そういった理由で suggestions は CLI から一括で自動修正を適用する方法が適用されていない訳ですが、そうはいっても一括で適用したい時はあるでしょう。

例えば sort-imports。import 文のソートをしない気持ちはまあ分かりますが...別にソートしたって大抵は挙動は変わりません。polyfill として読み込んでいるモジュールを先頭にすることさえ気をつければ、あとは自由に並べ替えてよいケースが多いでしょう。そうしたプロジェクトでは、挙動が変わるのを覚悟の上で一括適用する手段が欲しくなります。

...というような声を受けてか、3rd part の ESLint plugin では「安全ではない自動修正」を fixable として扱うものがあります。eslint-plugin-importorder ルールとかがそうです。

また、ESLint の Node.js API から suggestions の情報を取れるので、それを使って suggestions の一括適用ツールのようなものも作れます。というか作りました。大量の ESLint の問題を効率よく修正するeslint-interactive というツールがあるのですが、これに suggestions を一括適用するモードを実装してます。一括適用モードを起動すると suggestions をエディタ *2 でスクリプトファイルが開かれます。このスクリプトファイルでどの suggestions を適用するか、捨てるかを記述します。完成したらスクリプトファイルを保存して閉じると、一括適用が走ります (git commit する時にエディタが開いて commit message を編集できるあの機能を真似て作ってます。)。 格好良いですね。

Image from Gyazo

www.mizdra.net

余談: Biome における自動修正の分類

Biome には ESLint の Suggestions のような機能はありませんが、自動修正を安全なもの (safe fixes) と安全でないもの (unsafe fixes) に分けて扱うようになってます。safe fixes だけを適用する biome check --apply とは別に、safe fixes と unsafe fixes の両方を適用する biome check --apply-unsafe が用意されています。ESLint では安全ではない自動修正を CLI から一括適用することは (3rd-party の plugin を使わない限り) できないので、ここは Biome ならではという感じがします。

biomejs.dev

余談: Suggestions の実装の経緯

exhaustive-deps ルールが実装された当初は ESLint に Suggestions はありませんでした。しかしそれでは困るということで React 開発チームから ESLint 側に要望が出され、Suggestions が実装されたという背景があります *3。Suggestions の RFC の共著者に Dan Abramov が名を連ねているのはそのためです。

Authors: Ilya Volodin (@ilyavolodin), Dan Abramov (@gaearon) https://github.com/eslint/rfcs/blob/a6497bf1053e9a7ab30803ebbf405e9a91bafa40/designs/2019-suggestions/README.md#summary

余談: Suggestions が扱えるその他の自動修正

「実行時の挙動を変える恐れがあるもの」「問題の自動修正の候補が複数あるもの」以外にも、「自動修正はできるが、適用するかは議論が分かれる (opinionated) もの」なども suggestions で扱うのが適しています。他にも色々あるかもしれません。基本的には人が目で見て適用するか確認すべきものであれば、Suggestions で扱うべきでしょう。

まとめ

ひとえに自動修正、といっても色々あることを紹介しました。皆さんも Linter を触ったり、カスタムルールを書いたりする時に、ここで紹介したことを思い出してもらえればと思います。

以上、はてなエンジニアアドベントカレンダー2023 4日目の記事でした。 次は id:maku693 さんです。

*1:Suggestions の RFC でも、そのようなコマンドを用意しないと明に述べられています。 https://github.com/eslint/rfcs/blob/a6497bf1053e9a7ab30803ebbf405e9a91bafa40/designs/2019-suggestions/README.md#summary

*2:EDITOR 環境変数で指定しているエディタが起動します。

*3:もちろん、Suggestions 相当の機能の提案自体は昔から ESLint の issue で議論されていましたが、細かい設計や機能の形は決めきれていませんでした。そこに React 開発チームによる貢献があり、RFC が作られ、実装が開始されました。

GraphQL のレスポンスのモックデータの作成を補助する TypeScript ライブラリを作った

GraphQL を使って Web アプリケーションを実装していると、GraphQL API のリクエストをモックしたいことがあると思います。

  • ユニットテストのために、ダミーレスポンスに差し替えたい
  • ビジュアルリグレッションテストのために、ダミーレスポンスに差し替えたい
  • Storybook で story を書くために、ダミーレスポンスに差し替えたい
  • バックエンドの resolver 実装を待たずにフロントエンド側の開発を始めるために、ダミーレスポンスに差し替えたい

一般には GraphQL Client にモックするための機能が実装されてるので、そうしたものを使うことが多いと思います。

zenn.dev

また最近は Client よりも外側のレイヤーでリクエストを interrupt してモックする「msw」を使うケースも増えてきてます *1

blog.engineer.adways.net

モックデータ作成に手間がかかる

モックする時によく煩わしく感じるのが、モックデータの作成です。例えばビジュアルリグレッションテストのために GraphQL API をモックする場合、期待通りにぽページが描画されるようなモックデータを作成する必要があります。

import { graphql } from "msw";

// msw を使ったモックの例
export const handlers = [
  graphql.query("BookListPageQuery", (req, res, ctx) => {
    return res(
      ctx.data({
        books: [
          {
            __typename: "Book",
            id: "Book-0",
            title: "ゆゆ式 1巻",
            author: {
              __typename: "Author",
              id: "Author-0",
              name: "三上小又",
            },
          },
          {
            __typename: "Book",
            id: "Book-0",
            title: "ゆゆ式 2巻",
            author: {
              __typename: "Author",
              id: "Author-0",
              name: "三上小又",
            },
          },
        ],
      })
    );
  }),
  graphql.query("BookDetailPageQuery", (req, res, ctx) => {
    return res(
      ctx.data({
        book: {
          __typename: "Book",
          id: "Book-0",
          title: "ゆゆ式 1巻",
          author: {
            __typename: "Author",
            id: "Author-0",
            name: "三上小又",
          },
        },
      })
    );
  }),
];

ただ、このようにモックデータをベタ書きしていくと、コードの重複が増えていきます。例えば上記の例では、id: "Book-0" の node と id: "Author-0" の node が重複しています。今くらいの行数なら大したことはないですが、数が増えてくると可読性やメンテナンス性に影響が出てきます。

一般にはこうした問題を回避するために、type 単位のダミーレスポンスを作成する補助関数 (factory みたいなやつ) を自作している人が多いんじゃないかと思います。

// 事前に graphql-code-generator の typescript plugin で type ごとの型定義を生成しておく
import { Book, Author } from './__generated__/type.ts';
// Book: `{ __typename: 'Book', id: string, title: string, author?: Author }`

function fakeBook(args?: Partial<Book>): Book {
  return {
    __typename: 'Book',
    id: 'Book-0',
    title: 'ゆゆ式 0巻',
    ...args,
  };
}
function fakeAuthor(args?: Partial<Author>): Author {
  return {
    __typename: 'Author',
    id: 'Author-0',
    name: '0上小又',
    ...args,
  };
}

// こうやって使う
fakeBook({
  title: 'ゆゆ式 1巻',
  author: fakeAuthor({
    name: '1上小又',
  }),
});
// {
//   __typename: 'Book',
//   id: 'Book-0',
//   title: 'ゆゆ式 1巻',
//   author: { __typename: 'Author', id: 'Author-0', name: '1上小又' },
// }

これで幾分か楽になりますが、factory 関数を時前で実装したりメンテナンスしていくのは手間です。また、「id をオートインクリメントしつつモックデータを作成したい」「Book を N 個詰め込んだ配列を作って欲しい」など等色々な要件が出てくると、factory 関数の実装が複雑になってきます。

世の中には factory 関数を自動生成してくれる graphql-codegen-typescript-mock-data という GraphQL Code Generator のプラグインがあったりするのですが、微妙に使い勝手が悪く、個人的には理想的なものではないと感じています。

そこで graphql-codegen-typescript-fabbrica

もっと factory 関数を簡単に実装できるようにしたいと思い、graphql-codegen-typescript-fabbrica を作りました。

graphql-codegen-typescript-fabbrica は、factory 関数を定義するための utility を生成する GraphQL Code Generator プラグインです。これを使うと以下のような factory 関数を定義できます。

import { defineBookFactory, defineAuthorFactory, dynamic } from '../__generated__/fabbrica';
import { faker } from '@faker-js/faker';

const BookFactory = defineBookFactory({
  defaultFields: {
    __typename: 'Book',
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(() => faker.word.noun()),
    author: undefined,
  },
});
const AuthorFactory = defineAuthorFactory({
  defaultFields: {
    __typename: 'Author',
    id: dynamic(({ seq }) => `Author-${seq}`),
    name: dynamic(() => faker.person.firstName()),
    books: undefined,
  },
});

Factory の build() メソッドを呼び出すと、モックデータを生成できます。生成されるモックデータは、defaultFieldsbuild() メソッドに渡された field の値に応じて、厳密に型付けされます。

// simple
const book0 = await BookFactory.build();
expect(book0).toStrictEqual({
  __typename: 'Book',
  id: 'Book-0',
  title: expect.any(String),
  author: undefined,
});
expectTypeOf(book0).toEqualTypeOf<{
  __typename: 'Book';
  id: string;
  title: string;
  author: undefined;
}>();

// nested
const book1 = await BookFactory.build({
  author: await AuthorFactory.build(),
});
expect(book1).toStrictEqual({
  __typename: 'Book',
  id: 'Book-1',
  title: expect.any(String),
  author: {
    __typename: 'Author',
    id: 'Author-0',
    name: expect.any(String),
    books: undefined,
  },
});
expectTypeOf(book1).toEqualTypeOf<{
  __typename: 'Book';
  id: string;
  title: string;
  author: {
    __typename: 'Author';
    id: string;
    name: string;
    books: undefined;
  };
}>();

便利な機能一覧

factory 関数を片手間で自作するのでは実現が難しい、多数の便利な機能を提供しています。基本的には FactoryBot, prisma-fabbrica から輸入してきた機能です。

Sequences

seq パラメータを使うと、連番のモックデータを生成できます。id の生成に使うと便利です。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(async ({ seq }) => Promise.resolve(`ゆゆ式 ${seq}巻`)),
  },
});
expect(await BookFactory.build()).toStrictEqual({
  id: 'Book-0',
  title: 'ゆゆ式 0巻',
});
expect(await BookFactory.build()).toStrictEqual({
  id: 'Book-1',
  title: 'ゆゆ式 1巻',
});

Dependent Fields

get 関数を使うと、別の field に依存した値を生成できます。ある field の値から別の field の値を自動生成したい時に便利です。

const UserFactory = defineUserFactory({
  defaultFields: {
    name: 'yukari',
    email: dynamic(async ({ get }) => `${(await get('name')) ?? 'defaultName'}@yuyushiki.net`),
  },
});
expect(await UserFactory.build()).toStrictEqual({
  name: 'yukari',
  email: 'yukari@yuyushiki.net',
});
expect(await UserFactory.build({ name: 'yui' })).toStrictEqual({
  name: 'yui',
  email: 'yui@yuyushiki.net',
});

Building lists

buildList 関数を使うと、モックデータを N 個詰め込んだ配列を生成できます。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(({ seq }) => `ゆゆ式 ${seq}巻`),
  },
});
expect(await BookFactory.buildList(3)).toStrictEqual([
  { id: 'Book-0', title: 'ゆゆ式 0巻' },
  { id: 'Book-1', title: 'ゆゆ式 1巻' },
  { id: 'Book-2', title: 'ゆゆ式 2巻' },
]);

Associations (関連する type のモックデータの同時生成)

defaultFields には別の Factory を使ってモックデータを生成する関数を渡せます。これにより、関連する type のモックデータを同時に生成できます。セットで使うような type 同士の factory 関数を定義するのに便利です。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(({ seq }) => `ゆゆ式 ${seq}巻`),
    author: undefined,
  },
});
const AuthorFactory = defineAuthorFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Author-${seq}`),
    name: dynamic(({ seq }) => `${seq}上小又`),
    // 関連する Book type のモックデータを生成
    books: dynamic(async () => BookFactory.buildList(1)),
  },
});
expect(await AuthorFactory.build()).toStrictEqual({
  id: 'Author-0',
  name: '0上小又',
  books: [{ id: 'Book-0', title: 'ゆゆ式 0巻', author: undefined }],
});

Transient Fields

Transient Fields は factory 関数内でのみ利用可能な field を定義する機能です。build() の引数に渡す事はできますが、返り値には含まれない特殊な field です。get 関数と組み合わせると、結構面白いことができます。

Traits

Traits は field のデフォルト値をグループ化しておく機能です。TypeFactory.use('traitName').build() と書くと、そのデフォルト値を適用したモックデータが得られます。

よく画像を表す Image type などを GraphQL スキーマで定義することがあると思いますが、「ユーザアバターを生成する trait」をそれぞれ定義しておけば、ImageFactory.use('avatar').build() で簡単にユーザアバターのモックデータを生成できます。

import I_SPACER from '../assets/spacer.gif';
import I_AVATAR from '../assets/dummy/avatar.png';
import I_BANNER from '../assets/dummy/banner.png';

const ImageFactory = defineImageFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Image-${seq}`),
    url: I_SPACER.src,
    width: I_SPACER.width,
    height: I_SPACER.height,
  },
  traits: {
    avatar: {
      defaultFields: {
        url: I_AVATAR.src,
        width: I_AVATAR.width,
        height: I_AVATAR.height,
      },
    },
    banner: {
      defaultFields: {
        url: I_BANNER.src,
        width: I_BANNER.width,
        height: I_BANNER.height,
      },
    },
  },
});
expect(await ImageFactory.build()).toStrictEqual({
  id: 'Image-0',
  url: I_SPACER.src,
  width: I_SPACER.width,
  height: I_SPACER.height,
});
expect(await ImageFactory.use('avatar').build()).toStrictEqual({
  id: 'Image-1',
  url: I_AVATAR.src,
  width: I_AVATAR.width,
  height: I_AVATAR.height,
});
expect(await ImageFactory.use('banner').build()).toStrictEqual({
  id: 'Image-2',
  url: I_BANNER.src,
  width: I_BANNER.width,
  height: I_BANNER.height,
});

おわりに

ミニマムな API セットながらも、いくつもの強力な機能が実装された面白いライブラリなんじゃないかと思います。ぜひ使ってみてください。

*1:msw は Node.js、ブラウザ両方で任意のリクエストを interrupt できるライブラリです。Node.js 上では fetch などネットワークリクエスト API にモンキーパッチを当てて、ブラウザでは ServiceWorker を使ってリクエストに介入し、モックを実現してます。

react-relay の useFragment で本来取得できるはずの field が欠損する現象について

react-relay を使っていると、稀に「本来取得できるはずの field が欠けた状態のオブジェクトが useFragment から返される」現象に遭遇することがあります。

例えば、以下のようなエントリーのタイトルと本文を表示するエントリーページがあったと仮定します。デフォルトではタイトルのみが表示されていて、「本文を表示」ボタンを押すと、GraphQL API から本文が fetch されて表示されます。

import {PreloadedQuery, usePreloadedQuery, graphql, useFragment, useLazyLoadQuery} from 'react-relay';
import { useLocation, AppInternalError, AppNotFoundError } from '../util';
import { type BlogTopPageQuery } from './__generated__/EntryPageQuery.graphql';
import { type EntryHeader_entry$key } from './__generated__/EntryHeader_entry.graphql';

import { type EntryBodyQuery } from './__generated__/EntryBodyQuery.graphql';

export function EntryPage () {
  const { query: { entryId } } = useLocation(); // entryId === 'entry:1'
  if (typeof entryId !== 'string') throw new AppInternalError('entryId(${entryId}) is not string');

  const { entry } = useLazyLoadQuery<BlogTopPageQuery>(graphql`
    query BlogTopPageQuery($entryId: ID!) {
      entry(id: $entryId) {
       ...EntryHeader_entry
      }
    }
  `, { entryId });
  if (entry === null) throw new AppNotFoundError(`Entry(${entryId}) not found`);

  return (
    <article>
      <EntryHeader entry={entry} />
      <EntryBody entryId={entryId} />
    </article>
  );
}

type EntryHeaderProps = {
  entry: EntryHeader_entry$key,
};
function EntryHeader(props: EntryHeaderProps) {
  const data = useFragment(graphql`
    fragment EntryHeader_entry on Entry {
      title
    }
  `, props.entry);
  return <h2>{data.title.toUpperCase()}</h2>;
}

const EntryBodyQuery = graphql`
  query EntryBodyQuery(entryId: ID!) {
    entry(id: $entryId) {
      body
    }
  }
`;
type EntryBodyProps = {
  entryId: string,
};
function EntryBody(props: EntryBodyProps) {
  const [queryReference, loadQuery] = useQueryLoader<EntryBodyQuery>(EntryBodyQuery);

  if (queryReference === null) {
    return <div><button onClick={() => loadQuery({ entryId: props.entryId })}>本文を表示</button></div>
  }
  return (
    <div>
      <Suspense fallback="Loading...">
        <EntryBodyInner queryReference={queryReference} />
      </Suspense>
    </div>
  );
}

type EntryBodyInnerProps = {
  queryReference: PreloadedQuery<EntryBodyQuery>,
};
function EntryBodyInner(props: EntryBodyInnerProps) {
  const data = usePreloadedQuery<EntryBodyQuery>(EntryBodyQuery, props.queryReference);
  return <div>{data.body}</div>;
}

このページは、全部で 2 回クエリが fetch される機会があります。

  1. ページにアクセスした直後に fetch される BlogTopPageQuery
    • 例: entry(id: "entry:1") { title }
  2. 「本文を表示」を押した時に fetch される EntryBodyQuery
    • 例: entry(id: "entry:1") { body }

しかしこの EntryBodyQuery の fetch が行われると、その直後に EntryHeader コンポーネント内で Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') という実行時エラーが発生します。

  • TODO: あとでサンプルアプリケーションの URL を貼る

なぜ実行時エラーが起きるのか

この現象は、1 のクエリと 2 のクエリを実行した時に、Relay Client 内の id: entry:1 の node のキャッシュと <EntryHeader> 内で実行している useFragment の戻り値が、以下のように変化することに起因してます。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { body: "大吉でした" }
    • <EntryHeader>useFragment の戻り値: { }
  3. <EntryHeader> にて data.title === undefined となり、Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') が出る

なんと後続のクエリで同一 id の node を fetch すると、前のクエリで fetch したフィールドの情報を吹き飛ばしてしまうんですね。マジかよ。

バグではなく仕様

公式の Issue を漁ってみたところ、以下でこの現象について議論されているようでした。

github.com

Relay の開発チームからのコメントも寄せられているのですが、それによるとこの現象は開発チームの間で「missing data」と呼ばれていて、「バグではなく仕様である」そうです。マジかよ。

長々と書いてありますが、簡単に主張をまとめると以下のようになります。

  • クライアントサイドのキャッシュを扱う際に、以下の3つの要素が求められる
    • データの完全性 (各コンポーネントが要求したすべてのデータを持っているか)
    • 一貫性 (すべてのコンポーネントが一貫した世界観を表現しているか)
    • パフォーマンス (適切な量のデータでこれらの性質を達成できるか)
  • ただしキャッシュの性質上、3つのうち最大2つまでしか同時に達成できない
  • missing data 現象においては、一貫性とパフォーマンスを優先し、完全性を諦める方針を取ってる

完全性を優先して、一貫性かパフォーマンスのどちらかを諦めた場合どうなるかを想像すると、より理解しやすいかなと思います。例えば完全性とパフォーマンスを取ることととし、キャッシュをマージする実装にした場合、途中でデータの更新が起きた際に一貫性が失われることになります。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. ユーザがエントリーを以下のように更新した
    • {title: "2023年のおみくじは", body: "小吉でした" }
  3. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは", body: "小吉でした" } (注: 前のキャッシュとマージしてる)
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  4. ページの表示が「2022年のおみくじは / 小吉でした」になる
    • 2023 年は大吉であるはずなので、データの不整合が発生してる (一貫性の喪失)

逆に完全性と一貫性を取ろうと思うと、常にエントリーが更新されていないか fetch し続ける必要があり、パフォーマンスの問題が発生します。

どちらも Relay としては許容できないということで、一貫性とパフォーマンスを優先し、完全性の欠如 (missing data 現象) を許容する選択を取っている訳です。

完全性の欠如に対する緩和策

とはいえ、突然実行時エラーが起きるようでは困ります。その緩和策として、Relay 開発チームは「後続のクエリに、前のクエリの fragment を含めて fetch せよ」と主張してます *1。つまり、EntryBodyQueryEntryHeader_entry を含めろと言っている訳です。

const EntryBodyQuery = graphql`
  query EntryBodyQuery(entryId: ID!) {
    entry(id: $entryId) {
      body
      ...EntryHeader_entry
    }
  }
`;

こうすれば、EntryBodyQuery を fetch した際に、BlogTopPageQuery の分の field もfetch され、ユーザレベルで完全性が担保できるようになります。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. ユーザがエントリーを以下のように更新した
    • {title: "2023年のおみくじは", body: "小吉でした" }
  3. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { title: "2023年のおみくじは", body: "小吉でした" } (注: title field も fetch されてる)
    • <EntryHeader>useFragment の戻り値: { title: "2023年のおみくじは" }
  4. ページの表示が「2023年のおみくじは / 小吉でした」になる
    • 一貫性も完全性もある状態になってる

感想

気持ちはわかるものの、いきなり Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') とだけ言われても何が起きているのか分からないのが微妙な感じがします。「missing data 現象が発生しました。後続のクエリに前のクエリの fragment を含めてください。」みたいな丁寧なエラーメッセージを出すくらいはやってほしいかな...

Next.js で言語ごとに異なるアセット画像を埋め込む

多言語対応している Web アプリで、あらかじめファイルに書き出しておいた画像 (アセット) が言語別にあって、それをページに埋め込むにはどうするか、という話題です。基本は言語ごとに別々の画像を出すことないと思いますが、たまーにあるんですよね。例えばGoogle Play のバッジが言語ごとに違うので、これを言語ごとに出し分けたいとか。

色々方法があるので、それをいくつか紹介します。

方法1. import 文を言語の数だけ書く

シンプルにやるなら、言語の数だけ import 文を書いて、next/image<Image> コンポーネントで読み込むコードを書くかと思います。

// pages/index.ts
import Image from 'next/image';
import GooglePlayJaJP from '../assets/ja-JP/google-play.png';
import GooglePlayEnUS from '../assets/en-US/google-play.png';
import GooglePlayZhCN from '../assets/zh-CN/google-play.png';
import GooglePlayDeDE from '../assets/de-DE/google-play.png';
import GooglePlayItIT from '../assets/it-IT/google-play.png';
// ...

const GooglePlayMap = {
  'ja-JP': GooglePlayJaJP,
  'en-US': GooglePlayEnUS,
  'zh-CN': GooglePlayZhCN,
  'de-DE': GooglePlayDeDE,
  'it-IT': GooglePlayItIT,
  // ...
} as const

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <Image src={GooglePlayMap[lang]} alt="Google Play" />
    </div>
  );
}

Next.js way に沿っている一方で、言語の数に比例してコード量が増えるのがイマイチですね。

余談: import 文で読み込むと cache-busting パターン対応をやってくれる

import 文を使って画像を import すると、フレームワークのビルドツールが GooglePlay 変数にアセットへのパスを割り当て、<Image src="/assets/google-play.c53d93.png" alt="Google Play /> というコードに変換します (c53d93は content hash)。加えてアセットも <ビルド成果物の出力先ディレクトリ>/google-play./logo.c53d93.png へとコピーします。要は cache-busting パターンに対応した変換をやってくれる訳です。

方法2. public ディレクトリと img タグを使う

Next.js には public ディレクトリという static なファイル置き場があります。例えば public/assets/ja-JP/google-play.png にアセットを置くと、/assets/ja-JP/google-play.png という URL で配信されます。

これを利用すると、言語ごとに異なる画像を全部 public ディレクトリに置き、それを img タグで参照する、というアプローチが取れます。

// pages/index.ts

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <img src={`/assets/${lang}/google-play.png`} alt="Google Play" />
    </div>
  );
}

ただこのように書くと、画像の URL が /assets/ja-JP/google-play.png, /assets/en-US/google-play.png, ... などに固定されてしまいます。これは HTTPキャッシュを使用して不要なネットワーク要求を防ぐ のようにアセットを Cache-Control: max-age=31536000 で長期キャッシュしている場合に問題になります。もしそのように長期キャッシュしている場合、画像が新しい内容に置き換わっても、古いキャッシュがブラウザから使われ続けてしまうため、ユーザからは古い画像が見えたままになってしまいます。

そのため、もしこのアプローチを採用するなら、古いキャッシュが使われないよう工夫しなければいけません。例えば Cache-Control: no-cache で毎回キャッシュが古くなっていないか検証するよう要求するとかですね。

あるいは (雑ですが) アセットの URL の末尾に git の commit hash をつけるとかでも良いと思います。デプロイする度に変わる値なので、デプロイするごとにキャッシュが無効になってしまいますが、まあそれは仕方ないということで。ただ、git の commit hash を環境変数から参照できるようにする仕組みの整備はちょっと面倒だと思います。

// pages/index.ts

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <img src={`/assets/${lang}/google-play.png?${process.env.GIT_COMMIT_HASH}`} alt="Google Play" />
    </div>
  );
}

方法3. import 文を自動生成する

やっぱり Next.js のお作法に則って import 文を使った方法で書きたい、cache-busting パターンにも自動で対応させたい、という人向けの方法です。import 文を自動生成する CLI ツールを作りましょう。

// scripts/generate-i18n-asset-map.ts

// 言語ごとに異なるアセットの map を生成するツール。

const assetPatterns = [
  // 言語ごとに異なるアセットが増えたら、ここに追加してください。
  // プロジェクトのルートディレクトリからの相対パスで書くこと。
  'assets/[lang]/google-play.png',
]
const langs = ['ja-JP', 'en-US', 'zh-CN', 'de-DE', 'it-IT']

import {parse, resolve} from 'path'
import {mkdir, rm, writeFile} from 'fs/promises'

import {pascalCase} from 'change-case'


const rootDir = resolve(__dirname, '..')
const distDir = resolve(rootDir, '__generated__', 'i18n-asset-map')

async function generateI18nAssetMap(assetPattern: string) {
  // name は`'./assets/[lang]/google-play.png'` から `'google-play'` のみ取り出した文字列。
  const {name} = parse(assetPattern)
  const distFilename = resolve(distDir, name + '.ts')

  const importStatements = langs.map(lang => {
    const path = assetPattern.replace('[lang]', lang)
    const identifier = pascalCase(name + lang)
    return `import ${identifier} from '../../${path}'`
  })
  const importMapProps = langs.map(lang => {
    const key = lang
    const value = pascalCase(name + lang)
    return `  '${key}': ${value},`
  })
  const importMapName = pascalCase(name + 'Map')
  const content = `
${importStatements.join('\n')}

const ${importMapName} = {
${importMapProps.join('\n')}
} as const
export default ${importMapName}
  `.trim()

  await writeFile(distFilename, content)
}

// map ファイルの出力先は `__generated__/i18n-asset-map/<拡張子を除いたファイル名>.ts` なので、
// ファイル名が同じ assetPattern が複数あると、同じ場所に出力されてしまう。
// それを防ぐために、assetPattern のファイル名が被っていないかチェックする。
function validateAssetPatterns() {
  const visitedNames = new Set<string>()
  for (const assetPattern of assetPatterns) {
    const {name} = parse(assetPattern)
    if (visitedNames.has(name)) {
      console.error(
        `${name} という名前のファイルの assetPattern が複数検知されました。ファイル名は assetPattern ごとに被らないようにしてください。`
      )
      process.exit(1)
    }
    visitedNames.add(name)
  }
}

async function main() {
  validateAssetPatterns()

  await rm(distDir, {recursive: true, force: true}) // 古いファイルを削除
  await mkdir(distDir, {recursive: true})
  for (const assetPattern of assetPatterns) {
    await generateI18nAssetMap(assetPattern)
  }
}

main().catch(console.error)

これを ts-node scripts/generate-i18n-asset-map.ts で実行すると、以下のようなファイルが生成されます。

// ui/__generated__/i18n-asset-map/google-play.ts
import GooglePlayjaJp from '../../assets/ja-JP/google-play.png'
import GooglePlayenUs from '../../assets/en-US/google-play.png'
import GooglePlayzhCn from '../../assets/zh-CN/google-play.png'
import GooglePlaydeDe from '../../assets/de-DE/google-play.png'
import GooglePlayitIt from '../../assets/it-IT/google-play.png'

const GooglePlayMap = {
  'ja-JP': GooglePlayjaJp,
  'en-US': GooglePlayenUs,
  'zh-CN': GooglePlayzhCn,
  'de-DE': GooglePlaydeDe,
  'it-IT': GooglePlayitIt,
} as const
export default GooglePlayMap

こういう感じで使えます。

// pages/index.ts
import Image from 'next/image';
import GooglePlayMap from '../__generated__/i18n-asset-map/google-play';

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <Image src={GooglePlayMap[lang]} alt="Google Play" />
    </div>
  );
}

<Image> がそのまま使えて、かつ cache-busting パターン対応もやってくれます。良いですね。

Relay の %future added value, %other との向き合い方

前提

  • まず議論の土台は https://github.com/facebook/relay/issues/2351
    • GraphQL では Enum に対する variant の追加が破壊的変更になりうる
      • 新しい variant が来ることを考慮してなくて、突然アプリケーションが壊れる可能性がある
      • 自分たちで実装している GraphQL API なら、variant の追加タイミングを調整できるので問題にならないようにもできる
      • しかし、外部の GraphQL API サーバーを利用している場合は、追加タイミングを予知できないので、ある日突然壊れる
    • そのため、Relay は未知の値が来ても壊れないよう実装することを推奨してる
      • それを開発者に意識させるために、relay-compiler が生成する Enum の型には'%future added value'という文字列が追加されてる
        • // from: https://github.com/facebook/relay/issues/2351#issue-300717245
          // ./src/events/__generated__/my_generated.graphql.js
          ...
          export type EVENT_TYPES = ('activity' | 'formation' | '%future added value');
          export type Event = {
            type: EVENT_TYPES
          }
          
      • このおかげで、未知の値を意識しながらコードを書くことになる
        • export const stringifyEvent = (eventType: EVENT_TYPES) => {
            if (eventType === 'activity') return 'アクティビティ'
            if (eventType === 'formation') return '編成'
            return ''
          
      • あるいは以下のように網羅性チェックをしても良い
        • この書き方だと、突然未知の値が来ても表示崩れが起きなくて、かつ tsc でコンパイルエラーにもできる
        • export const stringifyEvent = (eventType: EVENT_TYPES) => {
            if (eventType === 'activity') return 'アクティビティ'
            if (eventType === 'formation') return '編成'
            const _exhaustiveCheck: '%future added value' = eventType
            return ''
          }
          
    • とはいえいちいち未知の値のハンドリングをするのは面倒という議論もある
      • そもそも、GraphQL API を自分たちで作っているなら、variant の追加タイミングも制御可能なので、未知の値のハンドリングは適当でも良いはず
      • そうしたユースケースのために noFutureProofEnums オプションがある
      • noFutureProofEnums: trueを使うなら、以下のように書ける
        • export type EVENT_TYPES = ('activity' | 'formation');
          export type Event = {
            type: EVENT_TYPES
          }
          
        • export const stringifyEvent = (eventType: EVENT_TYPES) => {
            if (eventType === 'activity') return 'アクティビティ'
            if (eventType === 'formation') return '編成'
            return unreachable(eventType)
          }
          
      • 将来EVENT_TYPESが取りうる variant が増えたら、コンパイルエラーになる
        • export type EVENT_TYPES = ('activity' | 'formation' | 'notification');
          export type Event = {
            type: EVENT_TYPES
          }
          
        • export const stringifyEvent = (eventType: EVENT_TYPES) => {
            if (eventType === 'activity') return 'アクティビティ'
            if (eventType === 'formation') return '編成'
            return unreachable(eventType)
            //                 ^^^^^^^^^
            //                 Argument of type 'string' is not assignable to parameter of type 'never'.
          }
          

'%future added value' の仲間

Relay には '%future added value' 以外にも、'%other' という未知の値を表す文字列がある。前者が Enum の variant 向けであるのに対し、後者は interface の __typename 向けの文字列となってる。どちらも未知の値が来ても壊れないよう実装することを強制するために存在する文字列で、noFutureProofEnums オプションで生成を抑制できる。

君たちはどう '%future added value', '%other' と向き合うのか

以上を踏まえて、どう'%future added value', '%other' と向き合うべきか。

  • 3つ書き方を紹介したが、どれでも良い気はする
  • 改めて 3 つについて要点を抑えながら取り上げ直すと...
  • 未知の値だったら シンプルにfallback するのが、最もオーソドックス
    • export type EVENT_TYPES = ('activity' | 'formation' | '%future added value');
      export const stringifyEvent = (eventType: EVENT_TYPES) => {
        if (eventType === 'activity') return 'アクティビティ'
        if (eventType === 'formation') return '編成'
        return ''
      }
      
  • 素朴に網羅性チェックをしたいなら以下
    • export const stringifyEvent = (eventType: EVENT_TYPES) => {
        if (eventType === 'activity') return 'アクティビティ'
        if (eventType === 'formation') return '編成'
        const _exhaustiveCheck: '%future added value' = eventType
        return ''
      }
      
    • variant が追加されたらコンパイルエラーにして、コードを変更するべきところを検出できるのは便利だが... variant を追加するのと一緒に、コンパイルエラーが出たところを修正していかないといけないのはちょっと大変
      • 同時にやるべき作業量が多い
      • まあでもこれはそういうものかも。歯を食いしばって修正していったらよい。
      • 面倒だったら// @ts-expect-error TODO: あとで直すとか書いておけば良い
  • GraphQL API を自分たちで作っていて、noFutureProofEnums オプションをオンにする覚悟があるなら、以下がオススメ
    • export type EVENT_TYPES = ('activity' | 'formation');
      export type Event = {
        type: EVENT_TYPES
      }
      
    • export const stringifyEvent = (eventType: EVENT_TYPES) => {
        if (eventType === 'activity') return 'アクティビティ'
        if (eventType === 'formation') return '編成'
        return unreachable(eventType)
      }
      
    • 未知の variant のハンドリングをしなくて良いのでシンプル
    • けど variant を追加した瞬間にアプリケーションが壊れる可能性があるので注意
      • ダウンタイムを可能な限り小さくするには、事前にフロントエンド側を改修して、新規の variant に対応したコードに更新しておくとか、PR を用意しておいて、variant が追加された直後にデプロイできるようにしておくとか、そういう運用が必要
      • バックエンドとフロントエンドをモノレポで運用しているなら、バックエンドで variant を追加する時に、フロントエンドも一緒に直してしまう
    • とはいえ多少のダウンタイムを許容できるならアリ
      • GraphQL API が variant を追加してから、フロントエンド側の改修が完了するまでの間、開発環境が壊れるのは許容する、とか
      • 本番環境にデプロイするまでには直ってるから OK、みたいな
    • そもそもスキーマ上 variant が追加されても、実際にその variant のデータが GraphQL API から返ってくるまでには、通常猶予がある
      • スキーマだけ変更しても、まだその variant を返す実装が入ってないとか
      • いきなりページが見れなくなるほどぶっ壊れることはそうないはず
  • あとこれはオプションだけど、素朴に if 使って書くのが面倒なら、ts-pattern 導入するとかが良いと思う

感想

3つ目の noFutureProofEnums オプションを ON にしつつ、網羅性チェックするのかかなり格好良いが、本当に noFutureProofEnums オプションを ON にして運用上問題が起きないのか、正直自信がない。

一般的な Relay 仕草から外れるからには、それ相応の自信を持ってやらないといけないが、その自信を形成するだけの判断材料が僕にはない。直感では大丈夫だと思うけど...実際どうかな...。「noFutureProofEnums オプション ON にしてやってるけど、全然困ってないぜ!」という人が居たら教えてください。

自分たちの管理下にない GraphQL API を参照しているアプリケーションを作っているのなら、1つ目か2つ目の書き方のどちらかを選択すると良いと思う。

アセットの 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 を参照してください。

Node.js でフィクスチャーファイルを簡単に作成するライブラリを作った

よく Node.js で CLI ツールを作っているのだけど、そのテストコードを書くのが大変だなあという課題感があった。例えば、ESLint のエラーをルールごとにグルーピングして、ルール単位で修正できるツール (eslint-interactive) を作ったときは、以下のようなテストコードを書いていた。

// fixtures/semi.js
const val = 0
// fixtures/prefer-const.js
let val = 0;
// fixtures/.eslintrc.js
module.exports = {
  root: true,
  parserOptions: { ecmaVersion: 2022 },
  rules: {
    'semi': 'error',
    'prefer-const': 'error',
  },
};
// src/core.test.ts
import { Core, takeRuleStatistics } from './core.js';
import { describe, it } from 'vitest';
import dedent from 'dedent';
import { join } from 'node:path';

const __dirname = new URL('.', import.meta.url).pathname;
const fixtureDir = join(__dirname, '../fixtures');

describe('Core', () => {
  const core = new Core({
    patterns: [fixtureDir],
  });
  const results = await core.lint();

  it('lint 結果の概要を出力できる', async () => {
    const formatted = await core.formatResultSummary(results);
    expect(formatted).toBe(dedent`
    ╔══════════════╤═══════╤═════════╤════════════╤═════════════════╗
    ║ Rule         │ Error │ Warning │ is fixable │ has suggestions ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ semi         │ 1     │ 0       │ 1          │ 0               ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ prefer-const │ 1     │ 0       │ 1          │ 0               ║
    ╚══════════════╧═══════╧═════════╧════════════╧═════════════════╝
  `);
  });
  test('rule ごとの lint 結果を返せる', async () => {
    const statistics = takeRuleStatistics(results);
    expect(statistics).toStrictEqual([
      expect.objectContaining({ ruleId: 'semi', errorCount: 1 }),
      expect.objectContaining({ ruleId: 'prefer-const', errorCount: 1 }),
    ]);
  });
});

eslint-interactive は *.js を ESLint で lint するということを内部的にやっているので、テストするには *.js (いわゆるフィクスチャーファイル) を事前に用意しておく必要がある。上記のテストコードでは、事前に fixtures ディレクトリに *.js を置いておく戦略を取っている。

fixtures ディレクトリの問題点

シンプルなアプローチなのだけど、個人的には以下のような問題点があると感じている。

  • テストコードを読むだけだとテストの挙動が分かりにくい
    • fixtures ディレクトリにはどんなファイルがあって、その内容はどうなっているかは、テストコードからは分からない
      • fixtures ディレクトリに見に行かないといけない
    • ファイルを何度も行き来してようやく挙動を理解できる...みたいな
  • テストケースごとに利用するフィクスチャーファイルを変更しづらい
    • こっちのテストケースでは fixtures/semi.js は要らないので除外したい...が手軽にできない
    • fixtures/test-case-1 のようにテストケースごとにディレクトリを切れば可能だが...
      • 逆にテストケース間でフィクスチャーファイルの共有が難しくなる

という訳で、これらの問題点を解決するために、@mizdra/inline-fixture-files というライブラリを作った。

@mizdra/inline-fixture-files とは

@mizdra/inline-fixture-files はフィクスチャーファイルをテストコードの中に埋め込むことができるライブラリである。

github.com

先程のテストコードを @mizdra/inline-fixture-files を使って書き換えると以下のようになる。

// src/core.test.ts
import { Core, takeRuleStatistics } from './core.js';
import { describe, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import dedent from 'dedent';
import { createIFF } from '@mizdra/inline-fixture-files';

const fixtureDir = join(tmpdir(), 'inline-fs-fixtures', process.env['VITEST_POOL_ID']!);

await createIFF(
  {
    'semi.js': dedent`
      const val = 0
    `,
    'prefer-const.js': dedent`
      let val = 0;
    `,
    '.eslintrc.js': dedent`
      module.exports = {
        root: true,
        parserOptions: { ecmaVersion: 2022 },
        rules: {
          'semi': 'error',
          'prefer-const': 'error',
        },
      };
    `,
  },
  { rootDir: fixtureDir },
);

describe('Core', () => {
  const core = new Core({
    patterns: [createIFF.rootDir],
  });
  const results = await core.lint();

  it('lint 結果の概要を出力できる', async () => {
    const formatted = await core.formatResultSummary(results);
    expect(formatted).toBe(dedent`
    ╔══════════════╤═══════╤═════════╤════════════╤═════════════════╗
    ║ Rule         │ Error │ Warning │ is fixable │ has suggestions ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ semi         │ 1     │ 0       │ 1          │ 0               ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ prefer-const │ 1     │ 0       │ 1          │ 0               ║
    ╚══════════════╧═══════╧═════════╧════════════╧═════════════════╝
  `);
  });
  test('rule ごとの lint 結果を返せる', async () => {
    const statistics = takeRuleStatistics(results);
    expect(statistics).toStrictEqual([
      expect.objectContaining({ ruleId: 'semi', errorCount: 1 }),
      expect.objectContaining({ ruleId: 'prefer-const', errorCount: 1 }),
    ]);
  });
});

フィクスチャーファイルがインラインで書かれているので、ファイルを行ったり来たりせずとも、テストコードを読むだけでテストの挙動が分かるようになった。

フィクスチャーファイルのパスに型安全にアクセスする

createIFF の戻り値に paths というプロパティがある。これはフィクスチャーファイルの生のパスを保持するオブジェクトである。厳密に型付けされていて、フィクスチャーファイルの生のパスに型安全にアクセスできる。

const iff = await createIFF(
  {
    'semi.js': dedent`
      const val = 0
    `,
    'prefer-const.js': dedent`
      let val = 0;
    `,
  },
  { rootDir: fixtureDir },
);
iff.paths['semi.js']; // `join(fixtureDir, 'semi.js')` と同じ
iff.paths['prefer-const.js']; // `join(fixtureDir, 'prefer-const.js')` と同じ
iff.paths['not-exist.js']; // コンパイルエラー

rootDir をランダムに切り替える

@mizdra/inline-fixture-files をそのまま使っても便利だが、rootDir をランダムに切り替えるユーティリティを作ると、より便利になる。rootDir がランダムに切り替わるので、各テストケースを独立に保てる (あるテストケースでのフィクスチャーの変更が、他のテストケースに影響しない)。

// example/util/create-iff-by-random-root-dir.ts
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createIFF, Directory } from '@mizdra/inline-fixture-files';

/**
 * NOTE: `fixtureDir` の肥大化を防ぐため、テストの実行毎に `fixtureDir` を削除する
 * コードを仕込んでおくと良い。
 * ```typescript
 * // vitest.setup.ts
 * import { rm } from 'node:fs/promises';
 * await rm(fixtureDir, { recursive: true, force: true });
 * ```
 */
const fixtureDir = join(tmpdir(), 'inline-fs-fixtures', process.env['VITEST_POOL_ID']!);

/** `rootDir` をランダムに切り替えつつ `createIFF` を呼び出すユーティリティ */
export async function createIFFByRandomRootDir<const T extends Directory>(directory: T) {
  const getRandomRootDir = () => join(fixtureDir, randomUUID());
  const iff = await createIFF(directory, { rootDir: getRandomRootDir() });
  return {
    ...iff,
    fork: async function forkImpl<const U extends Directory>(additionalDirectory: U) {
      const forkedIff = await iff.fork(additionalDirectory, { rootDir: getRandomRootDir() });
      return { ...forkedIff, fork: forkImpl };
    },
  };
// example/02-random-root-dir.test.ts
import dedent from 'dedent';
import { ESLint } from 'eslint';
import { expect, test } from 'vitest';
import { createIFFByRandomRootDir } from './util/create-iff-by-random-root-dir.js';

test('eslint reports lint errors', async () => {
  const iff = await createIFFByRandomRootDir({
    '.eslintrc.cjs': `module.exports = { root: true, rules: { semi: 'error' } };`,
    'src': {
      'semi.js': dedent`
        var withSemicolon = 1;
        var withoutSemicolon = 2
      `,
    },
  });

  const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true });
  const results = await eslint.lintFiles([iff.paths['src/semi.js']]);
  const formatter = await eslint.loadFormatter('unix');
  const resultText = formatter.format(results);
  expect(resultText).toStrictEqual(dedent`
    ${iff.paths['src/semi.js']}:2:25: Missing semicolon. [Error/semi]

    1 problem
  `);
});

テストケース間でフィクスチャーファイルを共有する

iff.fork という、以前に作成されたフィクスチャファイルを引き継ぎながらルートディレクトリを変更する API がある。これにより、フィクスチャファイルをテストケース間で共有できる。

// example/03-share-fixtures-between-test-cases.test.ts
import { readFile } from 'node:fs/promises';
import dedent from 'dedent';
import { ESLint } from 'eslint';
import { describe, expect, it } from 'vitest';
import { createIFFByRandomRootDir } from './util/create-iff-by-random-root-dir.js';

describe('eslint', async () => {
  // `.eslintrc.cjs` をテストケース間で共有する
  const baseIFF = await createIFFByRandomRootDir({
    '.eslintrc.cjs': `module.exports = { root: true, rules: { semi: 'error' } };`,
  });

  it('reports lint errors', async () => {
    // fork` を使うと、`baseIFF` からフィクスチャを継承しながら、
    // フィクスチャの `rootDir` を変更できる。
    const iff = await baseIFF.fork({
      src: {
        'semi.js': dedent`
          var withSemicolon = 1;
          var withoutSemicolon = 2
        `,
      },
    });
    const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true });
    const results = await eslint.lintFiles([iff.paths['src/semi.js']]);
    const formatter = await eslint.loadFormatter('unix');
    const resultText = formatter.format(results);
    expect(resultText).toStrictEqual(dedent`
      ${iff.paths['src/semi.js']}:2:25: Missing semicolon. [Error/semi]
  
      1 problem
    `);
  });
  it('fix lint errors', async () => {
    const iff = await baseIFF.fork({
      src: {
        'semi.js': dedent`
          var withoutSemicolon = 2
        `,
      },
    });
    const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true, fix: true });
    const results = await eslint.lintFiles([iff.paths['src/semi.js']]);

    expect(await readFile(iff.paths['src/semi.js'], 'utf8')).toMatchInlineSnapshot('"var withoutSemicolon = 2"');
    await ESLint.outputFixes(results);
    expect(await readFile(iff.paths['src/semi.js'], 'utf8')).toMatchInlineSnapshot('"var withoutSemicolon = 2;"');
  });
});

内部的には「以前に作成されたフィクスチャファイルを引き継ぐ」ために、baseIFF からファイルをコピーしてきている。そのため fork する度にコピーのコストが発生する。ただ、baseIFF からのコピーは (システムが Copy-on-Write をサポートしていれば) Copy-on-Write で行われるので、多くの場合は無視できるレベルのコストだと思う。

まとめ

  • fixtures ディレクトリパターンはテストコードの可読性とメンテナンス性に課題がある
  • @mizdra/inline-fixture-files を使うと、フィクスチャーファイルをテストコードの中に埋め込める
  • 型安全にフィクスチャーファイルのパスにアクセスできたり、fork でフィクスチャーファイルをテストケース間で共有できたりと、便利な機能がある

どうぞお使いください。

github.com

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

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