Aleph.js + urql + chakra-ui

こんにちは。桐生です。久々の投稿となりました。

最近Next.js+urql+chakra-uiで環境を構築する機会があったのですが、Deno上にも同じような環境が作れないかと思い、Aleph.jsを使っても同じようにやれるのか試してみたので、その内容を共有したいと思います。

そもそもDenoとは?については、以前ブログを書きましたので、合わせてご覧ください。

Aleph.jsとは

Aleph.js is a fullstack framework in Deno, inspired by Next.js.

Aleph.jsとは、公式ドキュメントにある通り、Next.jsに着想を得たDeno上で動くReactフレームワークです。

公開されてから既に1年以上経っており、いろいろな方々がAleph.jsを試して記事にされていたりするので存在を知っている方も多いのではないでしょうか。

使い方はシンプルで、

# Aleph.jsのインストール
deno run -A https://deno.land/x/aleph/install.ts

# 新規アプリケーション作成
aleph init

# `development` mode で実行
aleph dev

# `production` mode で実行
aleph start

などのコマンドが用意されています。

aleph devを実行したときに、esm.shのロードの挙動にハマったので後述します。

esm.shとは

A fast, global content delivery network to transform NPM packages to standard ES Modules by esbuild.

esm.shとはCDNの一つで、npmパッケージにあるモジュールをesbuildを使ってESMに変換して配信しています。Denoでサードパーティライブラリを使用する際によく使われるCDNで、他にもskypackなどがあります。

ただし、もともとnpmパッケージはDenoで動かすことを想定していないので、Denoで動かない、Denoでそもそもインポート時にエラーになるといったこともよくあります。が、esm.shのバージョンが上がるにつれて解消されていろいろ使えるようになっていっています。

Github Issue を覗いてみると Failed to import - <package name> というタイトルのIssueが多く立っているので、自分の使いたいnpmパッケージでエラーが出るようなことがある場合は、同じようにIssueを立てておくと、今後Fixしてくれるかもしれません。

またAleph.jsのメンバー自体がesm.shの開発にも携わっていることもあり、Aleph.shでもesm.sh固有の処理があったりします。

今回の環境

> deno --version
deno 1.17.0 (release, x86_64-apple-darwin)
v8 9.7.106.15
typescript 4.5.2

> aleph -v
aleph.js v0.3.0-beta.19

また、検証時のesm.shの最新v61を使用しました。

Aleph.jsでurqlを使う

これまでApollo Clientを使っていましたが、他のGraphQLクライアントの知見も貯めておきたいと思いurqlを使うことにしました。 https://formidable.com/open-source/urql/docs/comparison/#framework-bindingsにある通りReact Suspenseに対応しているのがいいですね。Suspenseとの組み合わせによりロード中の状態を宣言的に記述できるようになって、コンポーネント実装がシンプルになることが期待できます。

ただし、Aleph.jsはデフォルトではSSRモードで動くため、そのままではSuspenseが使えません。そこでaleph.config.tsというファイルを作って(aleph initでは作られません)でSSRをオフにセットしておく必要があります。

// aleph.config.ts
import { Config } from 'https://deno.land/x/aleph@v0.3.0-beta.19/types.d.ts';

export default <Config>{
  ssr: false,
};

それではurqlを使っていきましょう。

まずはesm.shからimportするため、import_map.jsonに追加します。

// import_map.json
{
  "imports": {
    ...
    "urql": "https://esm.sh/urql"
  },
}

実はAleph.js特有の処理はこれくらいで、あとは普通に実装していくだけです。

続いて、Clientの作成とProviderの設定です。フリーのGraphQLエンドポイントとしてSpaceX Land APIを使っています(このAPISpaceXの打ち上げデータなどを取得できる)。また、Suspenseを有効にするためsuspense: trueをセットします。

// app.tsx
import React, { FC } from 'react';
import { createClient, Provider } from 'urql';

const client = createClient({
  url: 'https://api.spacex.land/graphql/',
  // enable suspense
  suspense: true,
});

export default function App({ Page, pageProps }: { Page: FC; pageProps: Record<string, unknown> }) {
  return (
    <Provider value={client}>
      <main>
        <head>
          <meta name="viewport" content="width=device-width" />
        </head>
        <Page {...pageProps} />
      </main>
    </Provider>
  );
}

Queryする側の実装です。SpaceXコンポーネントを作りuseQueryを使ってデータ取得する実装を行います。ローディング中状態はSuspenseに任せることにし、ここで実装はしません。コンポーネント内から分岐処理がなくなりとてもシンプルになりました。素敵ですね。

// components/SpaceX.tsx
import React from 'react';
import { useQuery } from 'urql';

const LaunchesPastQuery = `
{
  launchesPast(limit: 10) {
    mission_name
    launch_date_local
    links {
      video_link
      article_link
    }
    rocket {
      rocket_name
    }
    details
  }
}
`;

export function SpaceX() {
  const [result] = useQuery({
    query: LaunchesPastQuery,
  });
  return (
    <>
      {result.data?.launchesPast?.map(({ mission_name, launch_date_local, links, rocket, details }) => {
        return (
          <article key={mission_name}>
            <h2>Mission: {mission_name}</h2>
            <section>
              <p>
                {new Date(launch_date_local).toLocaleDateString()} | <strong>{rocket?.rocket_name}</strong>
              </p>
              <p>{details}</p>
              <div>
                <a href="{links.video_link}" target="_blank" rel="noopener">
                  video
                </a>{' '}
                <a href="{links.article_link}" target="_blank" rel="noopener">
                  article
                </a>
              </div>
            </section>
            <hr />
          </article>
        );
      })}
    </>
  );
}

最後にSpaceXコンポーネントの組み込みです。Suspenseでラップしてローディング中の状態を実装します。

// pages/index.tsx
import React, { Suspense } from 'react';
import { SpaceX } from '../components/SpaceX.tsx';

export default function Home() {
  return (
    <Suspense fallback={<p>loading...</p>}>
      <SpaceX />
    </Suspense>
  );
}

aleph devで実行してみると、loading...としばらく表示されたあと、SpaceXの打ち上げ情報がリスト表示されました。無事Aleph.js上でurql(とSuspense)が動いているのを確認できました。

余談1

実は最初、importするURLをhttpsではなくhttpと記述していたために、なぜかuseQueryの実行時にエラーになる、という事象に陥りました。

// import_map.json
{
  "imports": {
    ...
    "react": "https://esm.sh/react@17.0.2", 
    "react-dom": "https://esm.sh/react-dom@17.0.2",
    "urql": "http://esm.sh/urql" // http にしてしまっていた
  },
}

Aleph.jsの実装を追っかけてみると、esm.sh経由でimportしたモジュールについては、aleph dev(dev mode)aleph start(production mode))とで、ロードするモジュールのモードを切り替えている、ということがわかりました。

https://github.com/alephjs/aleph.js/blob/v0.3.0-beta.19/server/aleph.ts#L993-L1001 の実装を見てみてください。

    // append `dev` query for development mode
    if (this.isDev && specifier.startsWith('https://esm.sh/')) {
      const u = new URL(specifier)
      if (!u.searchParams.has('dev')) {
        u.searchParams.set('dev', '')
        u.search = u.search.replace('dev=', 'dev')
        specifier = u.toString()
      }
    }

aleph devで実行している場合、https://esm.sh/から始まるimport urlについてはAleph.js?devというクエリストリングを付与するようになっています。

一方esm.shは、urlにdevクエリストリングが含まれている場合、Development modeのモジュールを返すという機能があるので、aleph devで実行した場合はDevelopment modeのモジュールがロードされるようになっています。

import_map.jsonをもう一度確認すると、

// import_map.json
{
    "react": "https://esm.sh/react@17.0.2", 
    "react-dom": "https://esm.sh/react-dom@17.0.2",
    "urql": "http://esm.sh/urql"
}

reacthttps://esm.shにマッチするので、aleph devでDevelopment modeのモジュールがロードされます。

一方で、urqlhttp://esm.shだったので上記条件にマッチせず、Production modeのurqlがロードされるようになっていました。さらに、urqlreactを依存モジュールとして持っていたので、同じくProduction modeのreactがロードされることになりました(ここがややこしかった)。

これにより、Aleph.js本体は dev mode の react で実行されているにもかかわらず、urqlおよびその依存モジュールであるreactは prod mode で同居する形になり、その結果 Context が共有されなくなり useQuery をコールしたタイミングで実行時エラーが出ていた、というわけでした。

本当につまらないミスで、エラー解消までに多大な時間と労力を消費してしまいました。とはいえ、これがきっかけでAleph.jsの内部処理を知ることができたので、良しとしましょう。

Aleph.jsでchakra-uiを使う

気を取り直して chakra-ui を入れてみましょう。まずはimport_map.jsonに以下のように追加します。

// import_map.json
{
  "imports": {
    ...
    "chakra-ui": "https://esm.sh/@chakra-ui/react",
    "emotion/react": "https://esm.sh/@emotion/react",
    "emotion/styled": "https://esm.sh/@emotion/styled",
    "framer-motion": "https://esm.sh/framer-motion"
  },
}

Aleph.js固有の処理はこれだけで、あとは通常通り実装していくだけです。

続いてChakraProviderの設定です。特別なことはありません。

// app.tsx
import React, { FC } from 'react';
import { createClient, Provider } from 'urql';
import { ChakraProvider } from 'chakra-ui';

...

export default function App({ Page, pageProps }: { Page: FC; pageProps: Record<string, unknown> }) {
  return (
    <Provider value={client}>
      <ChakraProvider>
        <main>
          <head>
            <meta name="viewport" content="width=device-width" />
          </head>
          <Page {...pageProps} />
        </main>
      </ChakraProvider>
    </Provider>
  );
}

最後にchakra-uiを使ってSpaceXコンポーネントをスタイリングしていきます。こちらも特別なことはなし。

import React from 'react';
import { useQuery } from 'urql';
import { Badge, Flex, Heading, HStack, Link, Text, VStack } from 'chakra-ui';

...

export function SpaceX() {
  const [result] = useQuery({
    query: LaunchesPastQuery,
  });
  return (
    <VStack spacing={4} align="stretch" p={4}>
      {result.data?.launchesPast?.map(({ mission_name, launch_date_local, links, rocket, details }) => {
        return (
          <Flex as="article" direction="column" gap={2} p="4" borderWidth="1px" borderRadius="lg" key={mission_name}>
            <Heading as="h2" size="lg">
              {mission_name}
            </Heading>
            <Flex direction="column" gap={2}>
              <HStack spacing={2}>
                <Text fontSize="sm">{new Date(launch_date_local).toLocaleDateString()}</Text>
                <Badge colorScheme="blue" borderRadius="full">
                  {rocket?.rocket_name}
                </Badge>
              </HStack>
              <Text>{details}</Text>
              <HStack spacing={2}>
                <Link href={links.video_link} isExternal color="blue">
                  video
                </Link>
                <Link href={links.article_link} isExternal color="blue">
                  article
                </Link>
              </HStack>
            </Flex>
          </Flex>
        );
      })}
    </VStack>
  );
}

以上で実装終わりで、aleph devで実行してみると、きちんとスタイリングされた状態でUIが表示されました。素晴らしい。

余談2

実は年末に何度かchakra-ui の適用にチャレンジしていたのですが、断念していました。その時は、当時の最新 esm.sh v58chakra-uiを使っていたのですが、どうやってもうまくいかずでした。

Aleph.jsにはPluginという機能があり、Aleph.jsの各ライフサイクルのタイミングで処理をHookすることができるので、chakra-ui用のPluginを書けばうまくいくのかも、なんてぼんやりと思っていたのですが、今年に入ってesm.shv61が出たのでそちらで改めてトライしたら、見事動くようになっていました。

Denoでesm.shskypack経由のモジュールを使ってエラーが発生した場合は、まずそれらCDNのIssueを確認してみたり、該当するものがなければIssueを登録するなどしていくのが良さそうですね。 また、使うCDNによっても結果は違ってきたりするので、諦めずに別のCDNからインポートしてみると良さそうです(ちなみに年末はskypackからのインポートも試しましたがダメでした、そういうこともありますよね)。

おわりに

今回やってみて、urqlchakra-uiが割とすんなり使えることがわかりました。特に言及していませんでしたが、ビルドも速くHMRなども効いており、そこまでストレスなく開発できる感触を得ました。

今回の検証Repoは以下のリンクから見れますので、興味ある方は覗いてみてください。 https://github.com/tkiryu/evaluate-aleph

ところで、Aleph.jsの将来性はどうなんでしょうか?

実は、GitHub の最終コミットが 20 Oct 2021 となっており、3ヶ月近く更新がない状態です。開発がアクティブでなければ安心して使っていくのは難しいところですが、どうやらリデザイン中のようで、GitHub Issue でコメントされていました。

https://github.com/alephjs/aleph.js/issues/429#issuecomment-967794820

at alephjs side, i decided to re-design the framework, the new system will be powdered by wasm that can run any edge network, for example deno deploy, and it will support any UI frameworks like react/vue/sevlte... i almost finish the compiler layer MVP, will publish it soon.

https://github.com/alephjs/aleph.js/issues/409#issuecomment-979803656

i am redesigning the framework to support deno deploy, in fact it will support any edge worker for example cloudflear

今後大きく変わる可能性があるため、今すぐ実践投入するのはやめておいたほうがよさそうですが、個人的には今後の動向に注目していきたいフレームワークです。何かアップデートがあれば、またブログにしたためようかと思います。

今回は以上です。