JavaScript で学ぶ正規表現

正規表現とは、ある文字列が一定のパターンに一致するかどうかを検索する手法です。一致判定だけでなく、その箇所を置換したりすることもできます。

この記事では、最初に正規表現のルールをを紹介して、その後の練習問題で実際に正規表現を使っていきます。ルールは最初から全部覚える必要はありません。正規表現は書いて覚えるものなので、できるだけ多くのパターンの正規表現を書いてみるのをおすすめします。

正規表現の動作確認には、ブラウザのコンソールを使用するか、https://regex101.com のように一致した部分を可視化してくれるサイトを活用すると便利です。

正規表現の書き方

JavaScript の正規表現の書き方は2通りあります。

正規表現リテラル

/pattern/flags

スラッシュで囲んでパターンを書く方法です。最後の flags は検索オプションです(後述)。

// 「a」という文字を含むパターン
let reg = /a/

RegExpオブジェクト

new RegExp('pattern', 'flags')

RegExp クラスのコンストラクタで定義する方法です。

let reg = new RegExp('a')

この記事では他の言語で使用することも考慮して、正規表現リテラルを使用します。ちなみにどちらの書き方でも RegExp オブジェクトが返ります。

メタ文字

正規表現のパターンの中では、メタ文字 と呼ばれる特別な意味を持つ文字がたくさんあります。正規表現を理解するには、普通の文字なのか、メタ文字なのかを区別できるようになることが必須となります。

パターン 意味
[abc] a, b, c のどれかの1文字
[a-z] a〜z の26文字。ちなみに [A-Z] はA〜Z、[0-9] は 0〜9。
(abc) “abc”という文字列(正規表現のグループ化)
abc|def “abc” または “def”
[^abc] a でも b でも c でもない任意の1文字
^ 先頭(ただし、上記のように[ ]内で使われると否定)
$ 末尾
. 任意の1文字
{n} 直前の文字が n 回続く
{n,} 直前の文字が n 回以上続く
{m,n} 直前の文字が m〜n 回続く
? 直前の文字がないか、1つだけ存在する
* 直前の文字がないか、1回以上続く
+ 直前の文字が1回以上続く
\n 改行
\t タブ
\d 数字( [0-9] と同じ)
\D 数字以外
\w 英数字と _ ( [a-zA-z0-9_]と同じ)
\W 英数字と _ 以外
\s 半角スペース、タブ、改行
\S 半角スペース、タブ、改行以外
\メタ文字 エスケープしてメタ文字を普通の文字として扱う
【エスケープする必要があるメタ文字】 / . * + ^ $ – | ? { } [ ] ( ) \
*? または +? 最短マッチ(後述)

正規表現のフラグ

フラグ 意味
i 大文字、小文字を区別しない
g 全部マッチさせる(デフォルトだと最初の1つだけ)
m 複数行に渡ってマッチさせる。
先頭および末尾を示す文字( ^ や $ ) が、( \n や \r で区切られる)複数の行で機能するようになる。

正規表現の判定方法

用途によって使い分けます。

RegExp.test

パターンに一致するかどうかを true/false で返します。

/a/.test('abc')
=> true

RegExp.exec

パターンに一致した文字列を配列で返します。一致しなかった場合は null を返します。

/a/.exec('abc')
=> ["a"]

String.match

パターンに一致した文字列を配列で返します。一致しなかった場合は null を返します。(※ RegExp.exec と同じ)

'abc'.match(/a/)
=> ["a"]

String.search

パターンに一致した文字列のインデックス番号を返します。一致しなかった場合は -1 を返します。

'abc'.search(/a/)
=> 0

String.replace

パターンに一致した文字列を置換します。第2引数に置換する文字列を指定します。

'abc'.replace(/a/, 'A')
=> "Abc"

また、( )で囲むことで、第2引数で ‘$1’, ‘$2’… というように変数として参照することができます。これを 後方参照 といい、後方参照できるように括弧で囲むことを キャプチャする といいます。数字は一致した順番を示します。引用符で囲む必要があるので注意しましょう。

'abc'.replace(/(a)(b)(c)/, '$1, $2, $3')
=> "a, b, c"

$1, $2, というように部分的に取り出すのではなく、単純に一致した部分を取り出す場合は $& が使えます。

'abcde'.replace(/[bd]/g, '[$&]')
=> "a[b]c[d]e"

もっと細かく処理したい場合は、第2引数を無名関数にすると、キャプチャ箇所を arguments で参照することができます。

'abc'.replace(/(a)(b)(c)/, function() {
  return arguments[1] + "-" + arguments[2] + "-" + arguments[3]
})
=> "a-b-c"

replace 以外でも、一致後にキャプチャを RegExp.$1, RegExp.$2, … で参照することができます。

/(a)(b)(c)/.test('abc')
RegExp.$1 // "a" 
RegExp.$2 // "b"
RegExp.$3 // "c"

( )は単純にパターンをグループ化(後述)するのにも使うので、別にキャプチャしたくないという場合は (?:pattern) というように、最初の括弧の後に ?: をつけます。

String.split

パターンに一致した文字列を区切り文字として使用し、文字列を分割します。

'abc'.split(/b/)
=> ["a", "c"]

先読み・後読み

pattern の後ろにある pattern を指定するのが「先読み」で、pattern の直前にある pattern を指定するのが「後読み」です。要するに、本来検索したい pattern の前後にさらに pattern を指定して、より厳密な検索が行えるようにします。括弧で囲みますが、これはキャプチャではないので別に参照できるわけではありません。

書き方
先読み pattern(?=pattern)
否定先読み pattern(?!pattern)
後読み (?<=pattern)pattern
否定後読み (?<!pattern)pattern
// 先読み: 後ろに「456」がある「123」
/123(?=456)/.test('123456') // true

// 否定先読み: 後ろに「456」がない「123」
/123(?!456)/.test('123456') // false
/123(?!456)/.test('123457') // true

// 後読み: 前に「123」がある「456」
/(?<=123)456/.test('123456') // true

// 否定後読み: 前に「123」がない「456」
/(?<!123)456/.test('123456') // false
/(?<!123)456/.test('333456') // true

練習

上記のパターンやフラグを使って、実際に正規表現を書いて動作を確認していきましょう。

メタ文字を理解しよう

問題: "folder/sample.jpeg" というファイルパスの文字列から、フォルダ名「folder」とファイル名「sample」と拡張子「jpeg」を抽出してください。

パターンに一致する文字を取得するという問題なので、今回は String.match を使っていきます。最初なので、いきなり正解ではなく一歩ずつ進めていきます。まず、文字列を見てみると、「/」の直前の文字列がフォルダ名、「/」の直後から「.」の直前までがファイル名、「.」の後が拡張子になっているので、「/」と「.」でうまく文字列を区切れば取得できそうです。単純に以下のように書いてみました。

"folder/sample.jpeg".match(/(.+)/(.+).(.+)/)

「.」は任意の文字で、それに「+」がついているので、任意の文字が1文字以上繰り返されるパターンとなります。それを「/」が現れる直前で止め、括弧で囲んで(=キャプチャ)パターンの文字列を取り出せるようにしておきます。ファイル名も同様に、「/」の後から「.」が現れる直前までの連続する文字列を括弧で囲んでおきます。拡張子は「.」以降の連続する文字列を囲っておきます。

さて、この正規表現をブラウザのコンソールなどで実行してみると実はエラーになります。これは、「/」がメタ文字と呼ばれる特殊な文字で、文字列として扱う場合は直前にバックスラッシュ(\)をつけてエスケープする必要があるからです。「/」はエスケープされないと正規表現のパターンを囲む文字として認識されて、2個目の「/」の時点で正規表現が終わった、と認識されてしまいシンタックスエラーとなります。なので、フォルダ名とファイル名の間のスラッシュを「\/」としてエスケープしてみます。

"folder/sample.jpeg".match(/(.+)\/(.+).(.+)/)

しかし、結果は [“folder/sample.jpeg”, “folder”, “sample.jp”, “g”] となって「sample」がうまく取得できていないようです。どこがおかしいのでしょうか。もうお気づきかと思いますが、「.」も任意の文字を示すメタ文字なので、文字列のドットとするには「\.」とエスケープする必要があります。

"folder/sample.jpeg".match(/(.+)\/(.+)\.(.+)/)

結果を確認すると、[“folder/sample.jpeg”, “folder”, “sample”, “jpeg”] となり、出題されていたフォルダ名、ファイル名、拡張子を取得することができました。ちなみに最初の要素にすべてのファイルパスが入っているのは、これも指定した正規表現に一致しているからです。文字列全体で一致していたらそれが最初に要素に入り、次に括弧で囲んだ文字列が要素に入ります。

const result =  "folder/sample.jpeg".match(/(.+)\/(.+)\.(.+)/)

result[1] => "folder"
result[2] => "sample"
result[3] => "jpeg"

このように、正規表現のパターン内で特殊な意味を持つ文字はエスケープする必要がある、というのを覚えておきましょう。

正規表現をグループ化してみよう

括弧を使うことで、パターンをひとまとまりで表現することができます。たとえば abc{3} は「”ab”の後に”c”を3回繰り返すパターン」なので「abccc」がマッチするパターンになりますが、(abc){3}なら「”abc”を3回繰り返すパターン」なので「abcabcabc」がマッチするパターンになります。これを踏まえた上で、以下の問題を解いてみましょう。

問題: とある拡張子つきのファイル名から画像かどうかを判定する正規表現を作ってください。なお、画像の拡張子は「jpeg」「png」「gif」とします。

今回はパターンが一致するかどうかの判定をするだけなので、RegExp.test を使用します。上記の問題の正規表現を少し変えて、拡張子が「jpeg」または「png」または「gif」になっているかどうかを調べるだけでよさそうです。とりあえず拡張子の判定部分を ??? としておくと以下のようになります。

/.+\.???/.test("ファイルパス")

今回はファイル名だけなので、スラッシュ(/)部分は取り除きました。最初の「.+」がファイル名、「\.」が拡張子の前にあるドットで、あとは ??? の部分に「jpeg」または「png」または「gif」というパターンを書くだけです。このように複数の条件のうちのいずれかにあてはまる、というパターンを書くには | を使用します。では | を使って実際に文字列をマッチさせてみましょう。

/.+\.jpeg|png|gif/.test("test.png") // true
/.+\.jpeg|png|gif/.test("png") // true

最初の判定が true なので一見良さそうに見えますが、2つ目の「png」だけの文字列も一致してしまいました。これは、パターンの意味が「/.+\.jpeg」または「png」または「gif」になっているからです。ためしに「jpeg」だけで判定したら false になります。パターンの判定を「/.+\.」 + 「jpeg または png または gif」にしたい場合は、後半部分を括弧で囲んでグルーピングします。

/.+\.(jpeg|png|gif)/.test("test.png") // true
/.+\.(jpeg|png|gif)/.test("png") // false

このように「|」で区切った文字列を括弧でグルーピングすることで、括弧内の文字列のどれか1つ、という表現ができるようになります。

繰り返し回数を指定してみよう

問題: 以下の電話番号の形式に一致する正規表現を作ってください。

0312345678
03-1234-5678
03(1234)5678
080-123-4567

まずは共通する箇所を探してみましょう。数字は全部で8つ、数字のみのものと記号があるものとに分類できます。記号があるものは、数字が2個または3個続いた後に「-」または「(」があり、その後数字が3個または4個続いたあとに「-」または「)」の記号があり、最後は4桁の数字で終わる、という形式になっています。

このように、文字があったりなかったりするときは、文字の直後に「?」を使用します。この例で言うと、/03[-(]?/ とすることで、03の次に「-」か「(」がある、もしくは両方ともない、という表現ができます。

/03[-(]?/.test("03") // true
/03[-(]?/.test("03-") // true
/03[-(]?/.test("03(") // true

次に、半角数字の1文字は全部 \d で表現することができます。文字が連続する回数は直後に {n} をつけ、数字が2回連続する場合は \d{2}、2回〜4回連続する場合は \d{2,4} というように書くことができます。これらを使えばもう正解のパターンを書くことができます。

/\d{2,3}[-(]?\d{3,4}[-(]?\d{4}/

最初に数字が2個または3個続き、その後に「-」もしくは「(」があるか両方ともない、その次に数字が3個または4個続き、その後に「-」もしくは「)」があるか両方ともない、そして最後に数字が4個続く、というパターンです。

全部一致するか試してみましょう。

/\d{2,3}[-(]?\d{3,4}[-(]?\d{4}/.test("0312345678") // true
/\d{2,3}[-(]?\d{3,4}[-(]?\d{4}/.test("03-1234-5678") // true
/\d{2,3}[-(]?\d{3,4}[-)]?\d{4}/.test("03(1234)5678") // true
/\d{2,3}[-(]?\d{3,4}[-(]?\d{4}/.test("080-123-4567") // true

フラグを使いこなそう

問題: "abcabc\nAbcAbc" という文字列で、行頭の「a」と「A」をマッチさせる正規表現を書いてください

RegExp.test でも良いですが、デバッグしやすいようにパターンに一致する文字を確認できる
String.match を使っていきます。まず、「a」と「A」を一致させるので、単純に書くと以下のようになります。

"abcabc\nAbcAbc".match(/[aA]/) // ["a"]

[ ] で囲まれた文字を文字クラス といい、「いずれか1文字」を意味します。もし [ ] で囲まなければ、”aA”という連続した2文字を示すことになるので、どこにも一致しません。

しかし、match した文字列を見てみると [“a”] とだけ表示されます。これは、正規表現の検索が最初に一致した時点でストップしてしまう仕様だからです。1回一致した後も検索を続けるには、フラグの g (global match)を使用します。

"abcabc\nAbcAbc".match(/[aA]/g) // ["a", "a", "A", "A"] 

しかし、今度は [“a”, “a”, “A”, “A”] と、すべての「a」「A」に一致してしまいました。これを、各行先頭の「a」「A」に絞り込みたいので、先頭を示す^ (キャレット)を指定してみます。

"abcabc\nAbcAbc".match(/^[aA]/g) // ["a"]

すると、今度は [“a”] となり、最初の行の「a」しか一致していないようです。これは、^ が「文字列全体の先頭」を意味しており、「各行の先頭」ではないからです。各行の先頭を示すようにするには、フラグの m (multiline)を使用します。

"abcabc\nAbcAbc".match(/^[aA]/gm) // ["a", "A"]

これで、[“a”, “A”] となって問題の正解にたどり着いたようです。ただ、「a」と「A」は大文字・小文字の関係です。両方指定するのはめんどうなので、大文字・小文字を無視するフラグの i (ignore case)を使用すると、以下のように書けます。

"abcabc\nAbcAbc".match(/^a/gmi) // ["a", "A"]

これでも [“a”, “A”] となっています。パターンに指定する文字が1文字になったことで、[a] と書いても単純に a と書いても変わらなくなったので、[ ]も外しました。

このようにフラグを使いこなせば、よりシンプルにパターンを書くことができます。

最短マッチと String.match の罠

問題: 次のHTMLのclass属性の値を抽出してください

"<div class='test'><p class='test2'></p></div>"

最初にねたばらししますが、この問題は上記の知識だけでは解けません。ただ、いつか同じ問題にぶつかる可能性があるのであえてご紹介しておきます。

まず、class=”xxx” の xxx を取得すれば良いので、String.match を使って普通に書いてみましょう。

"<div class='test'><p class='test2'></p></div>".match(/class='(.+)'/g) 
// ["class='test'><p class='test2'"]

マッチ箇所が2箇所なので g フラグを使用しています。しかし、実行してみると1箇所しか一致していないようです。しかも class 属性もうまく取得できていません。g フラグをつけたのになぜでしょうか。

原因は2つあります。

まず、文字の繰り返しを指定する「*」や「+」は、一致しなくなるまで検索を最後まで続けてしまいます。「.」は任意の1文字なので、最初の「’」から最後の「’」までの文字列が全部マッチしてしまい、結果的に1箇所しか一致しないのです。

これを防ぐために、「.*?」、「.+?」のように繰り返し文字に ? をつける 最短マッチ という記法を使います。これは、指定された後ろの文字列が出てきた時点で一旦検索を終わらせ、その続きから2回目の検索を走らせることができます。今回の例では ‘(.+?)’ とすることで、後ろに「’」が出てきたらそこで検索を一旦終わらせることができます。では、修正して再度実行してみましょう。

"<div class='test'><p class='test2'></p></div>".match(/class='(.+?)'/g)
// ["class='test'", "class='test2'"]

「.+?」としたことで、2箇所マッチするようになりましたが、まだキャプチャした中身が取得できていません。さて、ここからは仕様の問題なのでちょっと理解しづらいかもしれませんが、そういうものだと思って読んで下さい。実は g フラグをつけると String.match のキャプチャが無効になってしまうのです。

なので、今回の場合は String.match は使用できません。代わりに RegExp.exec メソッドを使用します。こちらのメソッドを使うときも g フラグを使うわけですが、 RegExp.Exec で g フラグを使うと返り値が少し特殊で、「最初にマッチした文字列」「キャプチャした文字列」の要素が入った配列になります。さらに、一度マッチすると、RegExp オブジェクトに「次回検索開始位置」(lastIndexプロパティ)が設定されます。

どういうことなのか、以下のソースを見ていただくとわかりやすいと思います。

const str = "<div class='test'><p class='test2'></p></div>"
const regexp = /class='(.+?)'/g

let match = []
while (match = regexp.exec(str)) {
  console.log(match[0]) // 1回目は 「class='test'」、2回目は 「class='test2'」
  console.log(match[1]) // 1回目は 「test」、2回目は 「test2」
  console.log(regexp.lastIndex) // 1回目は 「17」、2回目は 「34」
}

exec でマッチングを繰り返す度に返り値が変わっています。これは、exec が 自身の lastIndex プロパティに設定された位置から検索を開始する、という仕組みになっているためです。具体的には、最初は0 がセットされているので最初の文字から検索を開始し、1回マッチした時点で RegExp オブジェクトの lastIndex を更新し、マッチした結果を返して一旦処理を終了します。そして、再度 exec すると、更新された lastIndex から検索を始めます。マッチに失敗したら lastIndex を0に戻して null を返すので、その時に while の条件式が false になってループが止まります。

この仕組みを利用すると、class属性の値が取得できます。なかなか本や記事でも紹介されていないテーマですが、知っておけば後々思い出すかもしれないので、あえてご紹介しました。

以上で正規表現の説明は終わりです。まだ紹介していないパターンもありますが、あとはプログラム開発の現場で鍛えていきましょう。