0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

正規表現でSPAのフロントエンドのルーティングを実装した話

Posted at

はじめに

42Tokyoの課題でSPAを実装する際にフロントエンドのルーティングのために正規表現を使わなければいけない場面に遭遇しました。
今までは正規表現をできるだけ避けてきた人生でしたがこれを機に正規表現について勉強することにしました。

問題

SPAではURLによってどのスクリプトを呼び出すのかをフロントエンドで管理する必要があります。
単に/profile /loginなどのURLであれば以下のようなテーブルを用意して文字列を比較するだけで済みます。

const routes: {[key: string]: (appDiv: HTMLElement) => Promise<void>} = {
	'/': renderLobby,
	'/login': renderLogin,
	'/profile': renderProfile
};

しかしユーザーIDが2のユーザーのプロフィール画面を示す/profile/2などのURLはどうでしょうか。
本来は/profile/:userId: renderProfile,を追加すれば済むことが望ましいですが、この場合の:userIdは単なる文字列であるためURLとテーブルの文字列が一致しません。
この問題を解消するために正規表現が登場します。

おことわり

この記事では正規表現の基礎については取り扱わず、正規表現の基礎をある程度理解した人が実際にWebアプリで使ったケーススタディとして記録することが目的です。
私が正規表現を勉強した記事を紹介しておくので正規表現にあまり触れたことがない人は先にそちらを読むことをおすすめします。

サルにもわかる正規表現入門

サンプルコード

私がプロジェクトで作成したコードを少し簡略化したサンプルコードを用意しました。
順を追って解説していきます。

// ルート定義:パスと対応するハンドラ(ハンドラ本体は省略)
type Params = {[key: string]: string};

type Route = {
	path: string;
	handler: (params?: Params) => void;
};

const routes: Route[] = [
	{ path: '/', handler: () => console.log('Lobby') },
	{ path: '/profile/:userId', handler: (params) => console.log('Profile userId: ', params?.userId) },
	{ path: '/match/:matchId', handler: (params) => console.log('Match matchId: ', params?.matchId) },
];

/**
 * パス文字列を正規表現に変換します。
 * 例: '/profile/:id' → /^\/profile\/([^\/]+)$/
 */
function pathToRegex(path: string): RegExp {
	return new RegExp('^' + path.replace(/:\w+/g, '([^/]+)') + '$');
}

/**
 * パスパラメータを抽出します。
 */
function getParams(path: string, url: string): Record<string, string> {
	const values = url.match(pathToRegex(path));
	if (!values) return {};

	const keys = Array.from(path.matchAll(/:(\w+)/g)).map((m) => m[1]);
	return keys.reduce((acc, key, i) => {
		acc[key] = values[i + 1];
		return acc;
	}, {} as Record<string, string>);
}

/**
 * URLに一致するルートを探し、ハンドラを実行します。
 */
function route(url: string): void {
	for (const route of routes) {
		if (pathToRegex(route.path).test(url)) {
			const params = getParams(route.path, url);
			route.handler(params);
			return;
		}
	}
	console.log('404 Not Found');
}

パスを正規表現に置き換える

まずはパスを表す文字列についてですが、動的なパラメータとして使用したい箇所は:userIdのようにコロンをパラメータ名の前につけることを前提としています。これはバックエンドのサーバーなどの慣習を則っただけであり、コロン以外の文字でも問題はありません。

const routes: Route[] = [
	{ path: '/', handler: () => console.log('Lobby') },
	{ path: '/profile/:userId', handler: (params) => console.log('Profile', params) },
	{ path: '/match/:matchId', handler: (params) => console.log('Match', params) },
];

このままだと従来と変わらずただの文字列であり動的にURLとマッチさせることができないため、このroutesを正規表現に置き換えるための関数を通します。
それが以下のpathToRegex()です。

function pathToRegex(path: string): RegExp {
	return new RegExp('^' + path.replace(/:\w+/g, '([^/]+)') + '$');
}

この関数では引数pathに含まれる:\w+([^/]+)で置換してグルーピングしています。
つまり/profile/:userId\/profile\/([^\/])+$になります。
こうすることによってpathに含まれるコロンで始まるパラメータを動的にマッチさせることができます。
しかしこれだけだとマッチさせることができてもそのパラメータの値を取得することができません。/profile/2であれば2をどうにかして取得する必要があります。

グルーピングから連想配列を作る

前項の最後の問題を解決するための関数がgetParams()です。

function getParams(path: string, url: string): Record<string, string> {
	const values = url.match(pathToRegex(path));
	if (!values) return {};

	const keys = Array.from(path.matchAll(/:(\w+)/g)).map((m) => m[1]);
	return keys.reduce((acc, key, i) => {
		acc[key] = values[i + 1];
		return acc;
	}, {} as Record<string, string>);
}

少し複雑なのでコードを分解しながらプリントデバッグで実際の値を確認していきましょう。

getParams('/profile/:userId', '/profiile/42');

function getParams(path: string, url: string): Record<string, string> {
	console.log(path); // /profile/:userId
	console.log(url); // /profile/42
	const regex = pathToRegex(path);
	console.log(regex); // /^\/profile\/([^/]+)$/
	const values = url.match(regex);
	if (!values) return {};
	console.log(values); // ['/profile/42', '42', index: 0, input: '/profile/42', groups: undefined]
	const matches = Array.from(path.matchAll(/:(\w+)/g));
	console.log(matches); // [[':userId', 'userId', index: 9, input: '/profile/:userId', groups: undefined]]
	const keys = matches.map((m) => m[1]);
	console.log(keys); // [ 'userId' ]
	return keys.reduce((acc, key, i) => {
		acc[key] = values[i + 1];
		console.log(key); // userId
		console.log(values[i + 1]); // 42
        console.log(acc); // { userId: '42' }
		return acc;
	}, {} as Record<string, string>);
}

※ Array.prototype.reduce()は触ったことがない方は別途調べていただくことをおすすめします
console.log(acc)の値を見ていただくとわかるようにuserId42が紐づいていることがわかります。これでパラメータ名から値を取得できる連想配列を作成することができました。

実行

これでroutes()を実行することでroute.handlerでパラメータ名を使用してそれに応じたapiを呼び出すなどのことができるようになりました。

function route(url: string): void {
	for (const route of routes) {
		if (pathToRegex(route.path).test(url)) {
			const params = getParams(route.path, url);
			route.handler(params);
			return;
		}
	}
	console.log('404 Not Found');
}

route('/profile/42'); // Profile userId: 42
route('/match/abc123'); // Match matchId: abc123

最後に

普段はあまりお世話にならない正規表現について勉強するとても貴重な機会になりました。
このあたりのルーティングについてはwebであればフレームワークがやってくれるためあまり考えなくてもいいと思いますが、多機能なライブラリを導入しなくても自作できるということは嬉しい場面もあるかと思います。
正規表現を活かせる場面があれば今後は臆せず試してみようと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?