Qwik

Qwik City でブログアプリを作る

Qwik City は Qwik のメタフレームワークです。React における Next.js、Vue.js における Nuxt.js のような関係と同等です。

Qwik City(またの名を Qwik Router)は Qwik のメタフレームワークです。React における Next.js、Vue.js における Nuxt.js のような関係と同等です。Qwik は長期的で安定したプリミティブに焦点を当てており、breaking changes なしで安定した状態を保つことができます。一方 Qwik City は大規模なサイトを構築するための意見とパフォーマンスの高い方法をもたらします。

Qwik City は Qwik のアーキテクチャのおかげで、余分な JavaScript をブラウザに配信することはありません。体感として、Remix のアーキテクチャによく似ているようにも感じます。

Qwik City のインストール

それでは早速 Qwik City でアプリケーションを作成しましょう。以下のコマンドから Qick City のテンプレートを作成できます。

npm create qwik@latest

starter を選択するように聞かれるので「Basic」を選択しました。

🐰 Let's create a Qwik app 🐇   v0.11.0
 
✔ Where would you like to create your new project? … ./qwik-app
 
? Select a starter › (use ↓↑ arrows, hit enter)
❯   Basic
    Recommended for your first Qwik app (comes with Qwik City)
    Documentation Site
    Library

インストールが完了したら以下コマンドで開発サーバーを起動します。

cd qwik-app
npm run dev

http://localhost:5173 を訪れてみましょう。次のように表示されているはずです!

スクリーンショット 2022-10-15 9.31.08

Static Site Generation (SSG) インテグレーションの追加

qwik add コマンドにより、さまざまなインテグレーションを追加できます。現在追加可能なインテグレーションは次のとおりです。

今回は Static Site Generation (SSG)インテグレーションを追加します。Sstatic Site Generation は日本語では静的サイトジェネレーションと呼ばれ、ビルド時に静的な HTML を生成する仕組みです。Server Side Rendering(SSR)と異なり、ユーザーがサイトに訪れるたびに HTML を構築する必要がなく、また「hydration」も必要がないのでパフォーマンス上メリットがあります。一方でログインしているユーザーの情報に応じてコンテンツを出し分けるなどは行うことができません。そのため、すべてのユーザーに同じ内容を提供するブログやドキュメントサイトに適しています。

以下のコマンドで SSG インテグレーションを追加しましょう。

npm run qwik add static-node

コマンドを実行すると entry.static.tsx ファイルが追加されています。このファイルがビルド時のエントリーポイントとなります。

import { qwikCityGenerate } from '@builder.io/qwik-city/static/node';
import render from './entry.ssr';
import { fileURLToPath } from 'url';
import { join } from 'path';
 
// Execute Qwik City Static Site Generator
qwikCityGenerate(render, {
  origin: 'https://qwik.builder.io',
  outDir: join(fileURLToPath(import.meta.url), '..', '..', 'dist'),
});

SSG を実行する qwikCityGenerate 関数は @builder.io/qwik-city/static/node モジュールからインポートされています。このことは将来 Node.js に限らず Deno や Bun などをビルド時のランタイムとして使用できるようになることを示しています。

また package.jsonbuild.static コマンドが実行されており、このコマンドにより SSG のビルドを実行できます。

ディレクトリ構造

まずはじめに Qwik City のディレクトリ後続を確認しておきましょう。

src/routes

Qwik City は Next.js のようにファイルシステムベースのルーティングを採用しています。ファイルシステムベースのルーティングとは、ある特定のディレクトリの構造がそのままパスの構造となることを指します。

Qwik Coty では src/routes 配下のディレクトリが対象となります。例えば src/routes/about/index.tsx という場所にファイルを作成すると、自動的に /about というパスを担当することになります。実際に src/routes/about/index.tsx にファイルを作成して試してみましょう。

import { component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
 
export const head: DocumentHead = {
  title: "About ",
  meta: [
    {
      name: "description",
      content: "This is about page.",
    },
  ],
};
 
export default component$(() => {
  return (
    <div>
      <h1>This is about page.</h1>
    </div>
  );
});

静的な <head> を生成する場合には head オブジェクトを export します。

それでは http://localhost:5173/about にアクセスしてみましょう。src/pages/about/index.tsx に作成した内容が表示されています。

! 原因は不明ですが、新しいページを作成した後該当のパスを訪れても 404 エラーとなり何も表示されません。ターミナルのログには QWIK WARN A unsupported value was passed to the JSX, skipping render. Value: Symbol(skip render) と表示されているかと思います。このような場合には、一度ジョブを終了してから再度 npm run dev を実行すると表示されるようです。

スクリーンショット 2022-10-15 9.53.46

Qwik City ではその他のファイルシステムベースのルーティングとは異なり、ファイル名は必ず index.[filetype] とする必要があります。例えば、src/routes/contact.tsx というファイルを作成しても、/contract というパスは登録されません。必ず src/routes/contract/index.tsx のような形式にする必要があります。

この方式を採用する利点として、routes フォルダ配下にページに関連するコンポーネントなどを配置できる点があげられます。Next.js の方式の場合、pages ディレクトリ配下に作成したファイルはすべて自動的にルーティングが登録されてしまうため、ページ関連以外のファイルを配置できません。Qwik City の形式の場合、index.[filetype] 以外の形式のファイルは無視されるため、気にせず配置できます。

Qwik City は .mdx 形式のファイルもサポートしています。MDX はマークダウンと jsx でページを作成する機能です。src/routes/contact/index.mdx ファイルを作成して Contarct ページを MDX で作ってみましょう。

---
title: Contact
---
 
import Form from "./Form";
 
# Contact
 
For more information, _please contact_:
 
<Form />
 

一般的なマークダウン記法とともに jsx を書くことができるので、<Form /> コンポーネントをインポートして問い合わせフォームを表示しています。<Form /> コンポーネントを作成しましょう。前述のとおり、Qwik City では src/routes 配下にページ以外のファイルを配置できるので、src/pages/contact/Form.tsx に作成します。

import { component$, useStylesScoped$ } from "@builder.io/qwik";
import styles from "./form.css?inline";
 
export default component$(() => {
  useStylesScoped$(styles);
 
  return (
    <form>
      <label for="name">Name</label>
      <input id="name" name="name" type="text" />
      <label for="email">Email</label>
      <input id="email" name="email" type="email" />
      <label for="message">Message</label>
      <textarea id="message" name="message" />
      <button type="submit">Send</button>
    </form>
  );
});

中身はよくあるフォームです。useStylesScoped$ フックを使用することで、特定のコンポーネント内にのみスタイルが適用される scoped styles としてスタイルを適用できます。

src/pages/contract/form.css ファイルも作成しておきましょう。

form {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 500px;
  margin: 0 auto;
}
 
input, textarea {
  margin: 0 0 1rem;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}
 
button {
  padding: 0.5rem 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #fff;
  cursor: pointer;
}

これで http://localhost:5173/contract を訪れると、Contact ページが作成されていることがわかります。

スクリーンショット 2022-10-17 19.57.05

layouts.tsx

src/ruotes 配下に存在する layouts.tsx ファイルも特別な意味を持つファイルです。laytouts.tsx はページ間で再利用されるナビゲーションバーやフッターをレイアウトとして提供します。デフォルトの layout.tsx は次のようになっています。

import { component$, Slot } from '@builder.io/qwik';
import Header from '../components/header/header';
 
export default component$(() => {
  return (
    <>
      <main>
        <Header />
        <section>
          <Slot />
        </section>
      </main>
      <footer>
        <a href="https://www.builder.io/" target="_blank">
          Made with ♡ by Builder.io
        </a>
      </footer>
    </>
  );
});

<Slot /> コンポーネントを配置した箇所にそれぞれのルートのページが描画されます。せっかくなので、ヘッダーとフッターを少し編集しておきましょう。

import { component$, useStylesScoped$ } from "@builder.io/qwik";
import { QwikLogo } from "../icons/qwik";
import { Link } from "@builder.io/qwik-city";
import styles from "./header.css?inline";
 
type NavItemProps = {
  href: string;
  label: string;
};
 
export const NavItem = ({ href, label }: NavItemProps) => {
  return (
    <li>
      <Link href={href}>{label}</Link>
    </li>
  );
};
 
export default component$(() => {
  useStylesScoped$(styles);
 
  return (
    <header>
      <div class="logo">
        <a href="https://qwik.builder.io/" target="_blank">
          <QwikLogo />
        </a>
      </div>
      <ul>
        <NavItem href="/" label="Home" />
        <NavItem href="/about" label="About" />
        <NavItem href="/contact" label="Contact" />
      </ul>
    </header>
  );
});

内部のリンクは @builder.io/qwik-city からインポートして <Link /> コンポーネントを使用します。1 つ注意しなければいけない点として、コンポーネントはすべて export する必要があります。<NavItem /> コンポーネントはこのヘッダーコンポーネント内部でのみ使用するのにも関わらず export しているのはそのためです。これは Qwik の遅延ロードのためのオプティマイザの制約によるものです。詳しくは以下を参照してください。

レイアウトのネスト

レイアウトはネストすることも可能です。routes ディレクトリ内の階層に対して layout.tsx を作成することでさらにレイアウトを適用できます。

例えば src/routes/about/layout.tsx を作成した場合 /about ページには次の順番でレイアウトが適用されます。

  1. src/routes/layout.tsx
  2. src/routes/about/layout.tsx

実際に src/routes/about/layout.tsx ファイルを作成して試してみましょう。/about 配下のすべてのページにはトップに画像を表示することにしてみましょう。

import { Slot, component$ } from "@builder.io/qwik";
 
export default component$(() => {
  return (
    <>
      <img
        src="https://i.picsum.photos/id/1006/3000/2000.jpg?hmac=x83pQQ7LW1UTo8HxBcIWuRIVeN_uCg0cG6keXvNvM8g"
        width={600}
        height={400}
        alt="Dog and woman standing next to each other looking at the landscape from a cliff"
      />
      <Slot />
    </>
  );
});

http://localhost:5173/about にアクセスしてみると、たしかに作成したレイアウトが使用されています。

スクリーンショット 2022-10-15 14.54.56

root.tsx

root.tsx ファイルは Next.js の _document.tsxApp.tsx のようにアプリケーション全体の設定を行えます。例えばグローバル CSS を読み込んだり <head> の設定を行うために利用されます。

import { component$ } from '@builder.io/qwik';
import { QwikCity, RouterOutlet, ServiceWorkerRegister } from '@builder.io/qwik-city';
import { RouterHead } from './components/router-head/router-head';
 
import './global.css';
 
export default component$(() => {
  /**
   * The root of a QwikCity site always start with the <QwikCity> component,
   * immediately followed by the document's <head> and <body>.
   *
   * Dont remove the `<head>` and `<body>` elements.
   */
  return (
    <QwikCity>
      <head>
        <meta charSet="utf-8" />
        <RouterHead />
      </head>
      <body lang="en">
        <RouterOutlet />
        <ServiceWorkerRegister />
      </body>
    </QwikCity>
  );
});

src/components

コンポーネントを配置するためのディレクトリとして推奨されています。ここに配置したファイルが特別な意味を持つことはありません。

public

画像など、静的なファイルを配置するディレクトリです。Vite public directory も参考にしてください。

microCMS のセットアップ

ブログのコンテンツを管理するために microCMS と呼ばれるヘッドレス CMS(Content Management System)を利用します。ヘッドレス CMS とは、コンテンツを管理するバックエンド側のみを提供する仕組み。従来の WordPress のような通常の CMS では、コンテンツを管理する仕組みとコンテンツを表示する仕組みのどちらも提供していましたが、ヘッドレス CMS ではコンテンツを表示する仕組みを自由に管理できるのが特徴です。

一般にヘッドレス CMS では管理画面からコンテンツを作成した後、API を用いてコンテンツ情報を取得して表示することになります。

はじめに microCMS のサインイン画面からアカウントを登録しておきましょう。

スクリーンショット 2022-10-16 11.30.10

アカウントの登録が完了したら、続いてサービスを作成します。エンドポイント名は後から使用するで控えておくと良いでしょう。 サービス名は任意で構いません。

スクリーンショット 2022-10-15 17.10.20

API のスキーマを作成します。ヘッドレス CMS ではコンテンツの形式を自由に決定できるのが特徴ですが、ここでは簡単に進めるためにあらかじめ用意されている「ブログ」テンプレートを使用しましょう。

スクリーンショット 2022-10-15 16.56.14

作成ボタンからいくつか記事を作成しておきましょう。

スクリーンショット 2022-10-15 16.58.32

サイドメニュの「1 個の API キー」から API キーを取得して控えておきます。この API キーは公開されてはいけません。

スクリーンショット 2022-10-15 17.03.18

ここまでの作業で microCMS 側での作業は完了です。Qwik City のコードに戻り、.env ファイルを作成して控えておいた API キーとエンドポイント名を記述します。

VITE_API_KEY=XXXXXX
VITE_SERVICE_DOMAIN=XXXXXX

.env ファイルに記述した内容は import.meta.env から取得できます。

API クライアントの作成

microCMS の API から記事を取得する API クライアントを作成します。API の仕様は以下のドキュメントから参照できます。

src/lib/client.ts にファイルを作成します。

import { createClient } from "microcms-js-sdk";
 
export type Response<T> = {
  contents: T[];
  totalCount: number;
  offset: number;
  limit: number;
};
 
export type Post = {
  id: string;
  title: string;
  content: string;
  publishedAt: string;
  revisedAt: string;
  updatedAt: string;
};
 
const baseUrl = `https://${
  import.meta.env.VITE_SERVICE_DOMAIN
}.microcms.io/api/v1/blogs`;
 
const requestInit: RequestInit = {
  headers: {
    "X-API-KEY": import.meta.env.VITE_API_KEY,
  },
};
 
export const getPostList = async (): Promise<ContentsResponse<Post>> => {
  const response = await fetch(baseUrl, requestInit);
  const data = await response.json();
  return data;
};
 
export const getPost = async (id: string): Promise<Post> => {
  const response = await fetch(`${baseUrl}/${id}`, requestInit);
  const data = await response.json();
  return data;
};

ContentsResponse は microCMS コンテンツ API のレスポンスの型で、Post がコンテンツの形式です。

API のエンドポイントは https://{サービスドメイン名}.microcms.io/api/v1/{エンドポイント名} の形式となっております。このエンドポイントを baseUrl として設定しておきます。

リクエストヘッダーには X-MICROCMS-API-KEY: {API キー} を認証情報として含める必要があります。共通のヘッダーを requestInit そして設定します。

getPostListgetPost はそれぞれ記事の一覧の取得、特定のコンテンツの取得を行う関数です。特定のコンテンツを取得するためにはコンテンツの ID を使用するので、getPost 関数の引数で id を受け取るようにしています。

記事一覧の取得

microCMS のセットアップが完了したので、早速トップ画面で記事の一覧を取得してみましょう。Qwik City においてコンポーネントが描画される前にサーバーサイドでデータを取得には以下の HTTP メソッドに対応したリクエストハンドラ関数を実装します。

  • onGet
  • onPost
  • onPut
  • onRequest

GET メソッドでルートが呼び出された場合には onGet 関数が、Post メソッドでルートが呼び出された場合には onPost 関数がそれぞれ呼ばれます。onRequest 関数はすべてのリクエストメソッドに対応しており、フォールバックとして使用できます。

またこれらのリクエストハンドラ関数はページコンポーネントやレイアウトコンポーネントなど pages ディレクトリ配下のみで利用できます。

記事一覧ページにおいては、以下のように onGet 関数を実装して export します。

import { RequestHandler } from "@builder.io/qwik-city";
import { getPostList, Post } from "~/lib/client";
 
export const onGet: RequestHandler<Post[]> = async () => {
  const data = await getPostList();
  return data.contents;
};

onGet 関数で取得したデータをコンポーネントから利用するためには useEndpoint フックを使います。useEndpoint フックは ResourceReturn 型のオブジェクトを返します。ResourceReturn 型は Promise ライクで Qwik によりシリアライズ可能なオブジェクトです。resource.state はリソースの状態を表しており、以下のいずれかとなります。

  • pending - データを取得中である
  • resolved - データを利用可能である
  • rejected - エラーまたはタイムアウトのためデータを利用できない

ResourceReturn 型のオブジェクトは 要素を使用して、リソースの状態に応じて描画できます。onPending,onRejected,OnResolved はそれぞれ resource.statepending,resolved,rejected に対応しています。

import { component$, Resource } from "@builder.io/qwik";
import { RequestHandler, useEndpoint } from "@builder.io/qwik-city";
 
export default component$(() => {
  const resource = useEndpoint<Post[]>();
 
  return (
    <div>
      <h1>Post List</h1>
 
      <Resource
        value={resource}
        onPending={() => <div>Loading...</div>}
        onRejected={(error) => <div>Error: {error.message}</div>}
        onResolved={(posts) => (
          <ul>
            {posts.map((post) => (
              <PostItem key={post.id} post={post} />
            ))}
          </ul>
        )}
      />
    </div>
  );
});

一覧ページの全体像は以下のとおりです。

import { component$, Resource } from "@builder.io/qwik";
import {
  DocumentHead,
  RequestHandler,
  useEndpoint,
} from "@builder.io/qwik-city";
import { Link } from "@builder.io/qwik-city";
import { client, ContentsResponse, Post } from "~/lib/client";
 
export const onGet: RequestHandler<Post[]> = async () => {
  const data = await client.get<ContentsResponse<Post>>({ endpoint: "blogs" });
  return data.contents;
};
 
export const head: DocumentHead = {
  title: "Qwik Blog",
};
 
export const PostItem = component$((props: { post: Post }) => {
  const { post } = props;
  return (
    <li>
      <Link href={`/posts/${post.id}`}>{post.title}</Link>
    </li>
  );
});
 
export default component$(() => {
  const value = useEndpoint<Post[]>();
 
  return (
    <div>
      <h1>Post List</h1>
 
      <Resource
        value={value}
        onPending={() => <div>Loading...</div>}
        onRejected={(error) => <div>Error: {error.message}</div>}
        onResolved={(posts) => (
          <ul>
            {posts.map((post) => (
              <PostItem key={post.id} post={post} />
            ))}
          </ul>
        )}
      />
    </div>
  );
});

実際に記事の一覧が表示されていることを確認しましょう。

スクリーンショット 2022-10-16 12.42.33

記事詳細画面

続いて、ブログの詳細を表示する画面を作成しましょう。src/pages/posts/[id]/index.tsx ファイルを作成します。[id] のようにディレクトリ名を [] で囲った場合パスパラメータとして使用できます。ルート名をパスパラメータとした場合、以下のように動的にルートにマッチします。

  • /posts/i62f_oybcsx
  • /posts/y-e7ewlx7
  • /posts/10pj070vden

記事の取得

microCMS の API では content_id を指定して特定のコンテンツを取得できるので、ブログ詳細画面ではこのパスパラメータを利用してコンテンツを取得します。

パスパラメータは onGet 関数の引数の params から取得できます。

export const onGet: RequestHandler<Post> = async ({ params }) => {
  const data = await getPost(params.id);
  return data;
};

SSG におおける動的ルーティング

SSG においてはパスパラメータを用いて動的にルートにマッチングさせる場合にも、ビルド時にあらかじめどのようなパラメータ id でアクセスが行われるか把握して各ページを生成しておく必要があります。これを実現するために、onStaticGenerate 関数を実装します。

onStaticGenerate 関数内では getPostList 関数ですべての記事を取得し、id の配列として返却します。ここで返却した id のリストが事前生成されるページのパスとなります。

export const onStaticGenerate: StaticGenerateHandler = async () => {
  const data = await getPostList();
  const ids = data.contents.map((post) => post.id);
  return {
    params: ids.map((id) => {
      return { id };
    }),
  };
};

動的な Head の設定

<head> に設定する値があらかじめ決まっているならば前述のとおり head オブジェクトを export すればよいのですが、ここでは API から取得した値を動的に設定する必要があります。そのような場合には、head を関数として利用します。

export const head: DocumentHead<Post> = ({ data }) => {
  return {
    title: data.title,
    meta: [
      {
        name: "description",
        content: data.content.slice(0, 100) + "...",
      },
    ],
  };
};

関数に引数の data には onGet の返り値が渡されるので、API から取得した値を使って <head> を設定できます。

コンポーネントの実装

最後に取得した記事データをもとにコンポーネントを実装しましょう。記事一覧の場合と同様に useEndPoint フックから onGet 関数で取得したデータを ResourceReturn<T> として取得できます。

記事の内容は post.content に HTML 形式で入っているため dangerouslySetInnerHTML で HTML として描画できます。

export default component$(() => {
  const resource = useEndpoint<Post>();
 
  return (
    <Resource
      value={resource}
      onPending={() => <div>Loading...</div>}
      onRejected={(error) => <div>Error: {error.message}</div>}
      onResolved={(post) => (
        <article>
          <h1>{post.title}</h1>
          <p>
            <time>
              {new Date(post.publishedAt).toLocaleDateString("en-US", {
                year: "numeric",
                month: "long",
                day: "numeric",
              })}
            </time>
          </p>
          <div dangerouslySetInnerHTML={post.content} />
        </article>
      )}
    />
  );
});

最終的な内容は以下のようになります。

import { component$, Resource } from "@builder.io/qwik";
import {
  DocumentHead,
  RequestHandler,
  StaticGenerateHandler,
  useEndpoint,
} from "@builder.io/qwik-city";
import { getPost, getPostList, Post } from "~/lib/client";
 
export const onGet: RequestHandler<Post> = async ({ params }) => {
  const data = await getPost(params.id);
  return data;
};
 
export const onStaticGenerate: StaticGenerateHandler = async () => {
  const data = await getPostList();
  const ids = data.contents.map((post) => post.id);
  return {
    params: ids.map((id) => {
      return { id };
    }),
  };
};
 
export const head: DocumentHead<Post> = ({ data }) => {
  return {
    title: data.title,
    meta: [
      {
        name: "description",
        content: data.content.slice(0, 100) + "...",
      },
    ],
  };
};
 
export default component$(() => {
  const resource = useEndpoint<Post>();
 
  return (
    <Resource
      value={resource}
      onPending={() => <div>Loading...</div>}
      onRejected={(error) => <div>Error: {error.message}</div>}
      onResolved={(post) => (
        <article>
          <h1>{post.title}</h1>
          <p>
            <time>
              {new Date(post.publishedAt).toLocaleDateString("en-US", {
                year: "numeric",
                month: "long",
                day: "numeric",
              })}
            </time>
          </p>
          <div dangerouslySetInnerHTML={post.content} />
        </article>
      )}
    />
  );
});

次のように、作成した記事の内容が表示されていることでしょう。

スクリーンショット 2022-10-16 12.53.00

デプロイ

最後に作成したアプリケーションをデプロイしましょう。デプロイ先には Vercel を選択しました。サインインしてアカウントを作成しておきます。

作成したアプリケーションはあらかじめ GitHub レポジトリに公開しておきます。新しいプロジェクトを作成する際に「Continue with GitHub」を選択して、公開したレポジトリを選択します。

スクリーンショット 2022-10-16 13.03.51

「Build and Output Settings」からビルドの設定します。

  • BUILD COMMAND - npm run build.client && npm run build.static && npm run ssg
  • OUTPUT DIRECTORY - dist

スクリーンショット 2022-10-16 13.31.09

Environment Variables には .env に設定した値を設定します。

スクリーンショット 2022-10-16 13.07.55

設定が完了したら「Deploy」ボタンでデプロイを実行します。

以下の画面が表示されていればデプロイは成功しています。

スクリーンショット 2022-10-16 13.33.56

デプロイしたアプリケーションを確認してみましょう!


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事