はじめに
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)
の値を見ていただくとわかるようにuserId
と42
が紐づいていることがわかります。これでパラメータ名から値を取得できる連想配列を作成することができました。
実行
これで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であればフレームワークがやってくれるためあまり考えなくてもいいと思いますが、多機能なライブラリを導入しなくても自作できるということは嬉しい場面もあるかと思います。
正規表現を活かせる場面があれば今後は臆せず試してみようと思います。