苦手なJavaScriptで大嫌いな正規表現を克服する為のまとめ

公開:2020/12/22
更新:2020/12/22
38 min読了の目安(約34200字TECH技術記事 2

最初に

これは無職の非エンジニアが参考書を元に苦手なJavaScriptで大嫌いな正規表現をやったら理解するのに1ヶ月かかった話である。

未経験の方に

正規表現をやったことない人ならこの記事を読めば早速、明日から正規表現を読むことが出来るようになると思う。

正規表現を大嫌いな人が理解するために書いた記事なので参考書でつまずいた箇所などがわかるまで細かく噛み砕いているのでわかりやすくまとまってると信じている。

すでにエンジニアの方に

Zennにいるような、つよつよエンジニアのみなさんなら当たり前の内容だと思うが初学者がどこでつまずき、どうやって教えるとわかって貰えるのか考える手助けになるかもしれない。参考書で分からない箇所が出ると心の叫びも残しておいたからだ。そんな時、自分はGoogle先生、参考書、スタックオーバーフローに助けて貰った。

それでもし、未経験からエンジニアに転職出来た際には優しく教えて欲しい。いっちょ前にこのご時世なのでフルリモートを希望している。

必要だと思った経緯

大嫌いな正規表現をこれからやっていこうと思う。アプリの制作で文字列を扱うのでやむ終えず学んで行こうと思う。そして正規表現はどのプログラミング言語でも似たような記述(PythonとJavaScriptの正規表現を見ただけで言っている。)なので汎用性が高く言語が変わってもなくならない技術だと思い、学んでおいても損はないはず。

単純なマッチングと置換

まずは正規表現を使わずに単純な文字列の置換を行う。 String.prototype のメソッドを使う。

文字列中に特定の部分文字列があるかどうかを判断するだけなら、上記のメソッドで事足りる。

  • startsWith:先頭にあるか
  • endWith:終端にあるか
  • includes:含むか
  • indexOf:何文字目から始まるか

実際にコードに落とし込んでいく。

const input = "As I was going to Saint Ives";//0~28番目まで文字がある。29文字ある。
console.log(input.startsWith("As"));//true
console.log(input.endsWith("Ives"));//true
/*スタートから9番目にgoingがあるのか?*/
console.log(input.startsWith("going", 9));//true
/*

スタートから数えて14番目がgoingの最後gになっているか
これハマるね。後ろからじゃないんだ。

*/
console.log(input.endsWith("going", 14));//true

/*文字列にgoingが含まれるか。これは良く使いそう。*/
console.log(input.includes("going"));//true
console.log(input.includes("going", 10));//false 10文字目以降にあるか
console.log(input.indexOf("going"));//9 goingが何文字目から始まってるか
console.log(input.indexOf("going", 10));//-1 10文字目以降でgoingは何文字目か。ない場合は-1
console.log(input.indexOf("nope"));//-1

const input2 = "セント・アイヴスはイギリスにある街の名前です。";//0~22番目まで文字がある。23文字ある。
console.log(input2.startsWith("セント"));//true
console.log(input2.endWith("です。"));//true
console.log(input2.startsWith("イギリス", 9));//true 9文字目(厳格)からはじめてイギリスの単語はあるか
console.log(input2.includes("アメリカ"));// false
console.log(input2.includes("町", 10));//true 10文字目以降で町と言う文字があるか
console.log(input2.indexOf("町"));//16 町は何文字目から始まるのか
console.log(input2.indexOf("町", 10));//16 10文字目以降で最初から数えて何文字目に町はあるか
console.log(input2.indexOf("アメリカ"));//-1

上記にあげたメソッドで文字列を処理する場合は大文字、小文字を分けて使用する。分けて使用したくない場合は一度文字列を小文字に変換して使用する。

console.log(input.toLowerCase().startsWith("as")); //true

上記の操作を行っても元の文字列には影響を与えません。

正規表現でのマッチング

JavaScriptで正規表現関連の処理を行うにはクラスRegExpを利用する。RegExpのコンストラクタを使って正規表現を生成する事も出来ますが、通常は正規表現を /.../ で囲んだリテラル表記を使います。

const re1 = /going/; //goingにマッチする正規表現
const re2 = new RegExp("going"); //上記と同じ意味
const re3 = /イギリス/;

DEFという文字列を含んでいるかを検査する。

str = "ABCDEFG";
re = new RegExp("DEF", "ig");
console.log(re.test(str));         // => true

こういった感じに簡素に書き換える事も出来る。

str = "ABCDEF";
re = /DEF/ig;
console.log(re.test(str));         // => true
  • regexp.exec(str)
  • str.match(regexp)

マッチングを行い、マッチした部分文字列(g フラグ指定時は配列)を返します。exec() で str を省略した場合は、RegExp.inputで指定された文字列に対してマッチングを行います。

re = /[0-9]+/;
//下記のコードは文字列を書く場所が違うだけで意味は同じ?
console.log(re.exec("abc123"));         // => 123
console.log("abc123".match(re));        // => 123

RegExp.input(非推奨)

123にマッチした文字列をRegExpオブジェクトに格納する???

var re = /123/g;
re.test("abc123def");
console.log(RegExp.input);         // => "abc123def"
re.test("abc456def");              // マッチしないので index は変化しない
console.log(RegExp.input);         // => "abc123def"

まず /\w{3,}/ig と言う正規表現を例として使う。これは大文字小文字を区別せずに、3文字以上のすべての単語(word)にマッチする。なお単語は「英数字・_ (アンダースコア)」からなる文字列を指し漢字・仮名はマッチしない。これを使って次のような検索ができる。

const input "As I was going to Saint Ives";//0~28番目まである29文字(空白もカウント)
const re /\w{3,}/ig;

//マッチした単語を配列にして返す。
console.log(input.match(re)); //['was', 'going', 'Saint', 'Ives']

console.log(input.search(re)); //5(最初に見つかる3文字以上の単語は5番目から始まる)
/*
下記のコードを実行するとlastIndexプロパティに値(最初に3文字以上の文字が始まる番地)
が追加されます。この場合はwasのWが始まる場所なので5が代入されます。
*/
console.log(re.test(input)); //true (inputに格納された文字列が3文字以上に該当するか)

let a = re.exec(input); //execは文字位置をlastIndexに記憶する。
console.log(a)

/* 実行結果

[ 'was', 'going', 'Saint', 'Ives' ]
5
true //ここでlastIndexが5になる。
//そのため5番目以降で3文字から始まる文字goingが返却される。
[ 
  'going',
  index: 9,
  input: 'As I was going to Saint Ives',
  groups: undefined
]

*/

console.log(a[0]); //going
console.log(a.index); //9
console.log(a.input); //As I was going to Saint Ives
console.log(a[1]); // undefined

console.log(re.exec(input));
//['Saint', index: 18, input: 'As I was going to Saint Ives']
console.log(re.exec(input));
//['Ives', index: 24, input: 'As I was going to Saint Ives']
console.log(re.exec(input));//null(24番目以降でもうマッチするのがない)

//正規表現のリテラルを直接使うことが出来る。
console.log(input.match(/\w{3,}/ig)); //[ 'was', 'going', 'Saint', 'Ives']
console.log(input.match(/\w{3,}/i)); //gオプションなし
//['was', index: 5, input: 'As I was going to Saint Ives']

console.log(input.search(/\w{3,}/i));//5

基本的には match()test() が使用される。

グローバルフラグを持つ正規表現の test() の使用

上記のコードと出力が違うのでとてもハマった。

const input = "As I was going to Saint Ives";
const re = /\w{3,}/ig;

let a = re.exec(input);
console.log(a);

//実行結果
[
  'was',
  index: 5,
  input: 'As I was going to Saint Ives',
  groups: undefined
]

test()exec() を使用する際はマッチするとlastIndexに値が加算される。そして次回再び実行するとlastIndexに格納された数値以降の番地からマッチするものを返す。再びマッチすればlastIndexは再び加算される。マッチしない値の場合は false となりlastIndexの値は 0 になる。

正規表現での置換

4文字以上の単語を **** に置き換える。

const input = "As I was going to Saint Ives"
const output = input.replace(/\w{4,}/ig, '****');
console.log(output);// As I was **** to **** ****

平仮名の「は」「を」「に」「で」を全角のスペースに置換する。

const input2 = "セント・アイブスはイギリスに存在する町の名前です。";
const output2 = input2.replace(/[はをにで]/g, ' ');
console.log(output2);//セント・アイブス イギリス 存在する町の名前 す。

入力文字列の「消費」正規表現のアルゴリズム

???よく分からない。

正規表現の一般的解釈「文字列中の条件を満たす部分文字列を見つけるもの」

ただ正規表現を「入力文字列を消費するためのパターン」と解釈する方が分かりやすい場合がある。

「XJANLIONATUREJXEELNP」

人間はこの中から英単語になる文字列を探すのが得意です。即座に「LION」「NATURE」「EEL」といったものをパッと見つける。(自分は出来なかった。)

これを正規表現にやらせます。人間とは少し違いハンデがありますが、すでに「LION」「NATURE」「EEL」があるのを知っている前提でそれらが文字列中のどこにあるのか探って行きます。

最初の文字Xから始めます。探している単語にXで始まるものはないので「マッチするものはない」と判断します。次にJ同様にマッチなし、Aに進む。こうして進んだ文字は「消費された」と呼ぶ。ここまでにX, J, Aが消費された。

Lまで来ると「これはLION」かもしれないと推測します。なのでLは「消費せず」次に進みます。I, O, Nがマッチします。これで「LION」と認識されます。その時に「マッチした!」となり、この単語(LION)が丸ごと消費されます。

LIONとNATUREは文字列中で「N」が重なっています。そして「N」は先ほど「LION」で消費されてしまったので戻って再度マッチするかどうか確認しません。なので「NATURE」を見つけることが出来ません。最後にEELを見つけて終了します。

上の例で「LION」の「O」を「X」に置換してみます。すると正規表現がL, Iは消費せずに進みます。そしてXでマッチしないと判定します。

すると正規表現はマッチの可能性があったLまで戻り消費して次に進む。このケースではLIONは見つからず、代わりにNATUREがマッチします。NがLIONの一部として消費されなかったためです。

正規表現アルゴリズム

  • 文字列は、左から右に消費される。
  • 一旦、ある文字が消費されてしまったら、その文字に戻ることはない。
  • マッチするものがなければ、正規表は一文字ずつ進んでマッチするものを探す。
  • マッチしたものがあれば、正規表現はマッチした文字の全てを一度に消費し、その先の文字に進みマッチングを進める。(グローバルな正規表現に限る)

ORを表す正規表現

メタ文字の説明になる。

複数のパターンのどれかとマッチさせる場合はORを表す「|」を使用する。

使用例:A|B|Cのように記述するとA, B, Cのいずれかにマッチしたらとなります。

具体的な使用例

const html = 'HTML with <a href="/one">one link</a>, and some JavaScript.' + '<script src="stuff.js"></script>';
const matches = html.match(/area|a|link|script|source/ig);
console.log(matches);

//実行結果
[
  'a',      'link',
  'a',      'a',
  'a',      'a',
  'Script', 'script',
  'script'
]

ig は大文字小文字を区別せず(i:ignore case)、全体を(g:global)検索することを示している。gがない場合は最初にマッチしたものだけが返される。

上記の正規表現は大文字小文字を区別せずに「area, a, link, script, source」のいずれかにマッチする文字列の集合を意味している。

配置の順番にも気を付ける。

(/area|a|link|script|source/ig) の順番にも気を付ける必要があります。aの前にareaを置かないとareaの単語が来た際、先にaでマッチしてaが消費されるためareaにマッチすることが出来なくなる。

上記の正規表現ではhtmlのタグを検索するのには期待しない、他の文字にもマッチしてしまうので下記のように変更します。

(/<area|<a|<link|<script|<source/<ig) これで期待通りのタグだけを抽出出来ます。

ただ正規表現だけで構文解析は難しい事も頭の片隅に置いておきましょう。

文字集合

(/0|1|2|3|4|5|6|7|8|9/g); これだと大変なので (/[0-9]/g); とする事でより簡素に記述出来ます。

さらにアルファベット(a-z)、「.」、「-」も含めることが出来ます。

(/[0-9a-z\-.]/ig) とする事でマッチさせることが出来ます。 \ は特殊文字としてでなく普通の横棒と認識させるために前に置きます。iも指定する事で大小を無視します。文字集合の指定に順番による結果が変わる事はありません。

文字集合のもう一つ強力な機能として ^ (記号の読み方はキャレットまたはハット)があります。

(/[^ 0-9a-z]/g) とする事で空白、0~9、a~zの文字にマッチしなくなります。

片仮名、平仮名を取り除く正規表現を書いてみる。

(/[^0-9ァ-ヴぁ-ん。、]+/g); で0~9、 ァ-ヴ片仮名(ほとんど)、ぁ-ん 平仮名(ほとんど)とマッチするので ^ が付いてる事もあり、マッチしたものを取り除くことができる。

文字集合には略記法がある。(可読性さがるやん)

略記号

文字 意味 備考
\d [0-9] 数字
\D [^0-9] 数字以外
\s ホワイトスペース文字 半角スペース、タブ、垂直タブ、改行文字、全角スペースなど
\S 非ホワイトスペース文字 半角スペース、タブ、垂直タブ、改行文字、全角スペースなど以外
\w [a-zA-Z_] 「英単語」。ダッシュやピリオドには含まれない(ドメイン名、CSSのクラスなどのマッチングには不十分)
\W [^a-zA-Z_] 「英単語」。ダッシュやピリオドには含まれない(ドメイン名、CSSのクラスなどのマッチングには不十分)以外

\D は電話番号等で無駄な記号等を取り除くのに役立ちます。

const phoneNumber1 = "(0269)99-9876";
const PhoneNumber2 = "0269-99-9875";

console.log(phoneNumber1.replace(/\D/g, ''));//0269999876
console.log(phoneNumber2.replace(/\D/g, ''));//0269999875

繰り返し

正規表現のメタ文字「+」を使うことで、「直前の文字」の「1回以上の繰り返し」にマッチさせることが出来ます。

const beer99 = "99 bottles of beer on the wall" + 
	"take 1 down and pass it around -- " + 
	"98 bottles of beer on the wall.";
const matches = beer99.match(/[0-9]+/g);
console.log(matches);//['99', '1', '98']

//+がないと分割されて表示される。
[ '9', '9', '1', '9', '8' ]

繰り返しの指定

文字 説明
{n} 直前文字のn回繰り返し /\d{5}/は5個の数字(例:米国郵便番号)にマッチする。
{n,} 直前文字のn回以上の繰り返し /\d{5,}/は5個以上の数字にマッチする。
{n, m} 直前文字のn回以上, m回以下の繰り返し /\d{2, 5}/は2個~5個の数字にマッチする。
? 直前文字の0回もしくは1回の出現{0, 1}と同じ /[a-z]\d?/iは英字1文字の後ろに0個~1個の数字が続く文字列にマッチする。
* 直前文字の0回以上の繰り返し /[a-z]\d*/iは英字1文字の後ろに0個以上の数字が続く文字列にマッチする。
+ 直前文字の1回以上の繰り返し /[a-z]\d+/iは英字1文字の後ろに1個以上の数字が続く文字列にマッチする。そしてa-zのような指定だと汎用性があるがaのみだとaa等にしかマッチしなくなるので直前に指定する文字が大事になってくる。

メタ文字「.」とエスケープ

正規表現では . は改行以外あらゆるものにマッチする特殊文字で、特に入力文字列中の不要部分を飛ばすためによく用いられます。

例を考えて見ましょう。

const input = "Address: 333 Main St., Anywhere, NY, 55532. Phone: 555-555-2525.";
const match = input.match(/.*\d{5}/);
console.log(match)

//実行結果
[
  'Address: 333 Main St., Anywhere, NY, 55532',
  index: 0,
  input: 'Address: 333 Main St., Anywhere, NY, 55532. Phone: 555-555-2525.',
  groups: undefined
]

ここでつまずいてしまい。期待したのは 55532 のみだったのでスタックオーバーフローで訪ねた。

任意の文字の任意の繰り返しの後、数字が5桁並んだところを終端とする文字列を指定していることになります。

つまり 55532 を見つけるまでに消費した文字は全てマッチすることになる。

ワイルドカード

ピリオドは「改行を除くあらゆる文字」にマッチしますが、「改行を含めたあらゆる文字」にマッチさせるには[\s\S]を利用する方法が一般的です。「全てのホワイトスペースおよび、ホワイトスペース以外の全ての文字」となり全ての文字にマッチするようになります。だったら全ての文字を最初から用意しておいてくれたらいいのにと思います。

グループ化

グループ化すると部分表現を構成でき、それをひとつの単位のように(1文字であるかのように)扱うことができます。

グループ化する事で後から再び利用することが出来るようにグループ結果をキャプチャ出来ます。横文字多すぎてわからん。

まずはキャプチャなしのグループ説明から行います。こちらの方がパフォーマンスはいいです。

グループは () を使って指定しますが、キャプチャなしのグループは (?:) のように指定します

例を見る。ドメイン名のうち.com, .org, .eduを含むものだけにマッチさせたいとします。

const text = "Visit oreilly.com today!";
//a~z0~9の文字が1以上続いて.com, .org, .eduに大小関係なくマッチするものを全体から探す(複数あればマッチする)。
const match = text.match(/[a-z0-9]+(?:\.com|\.org|\.edu/ig);
console.log(match);

// 実行結果
['oreilly.com']
const matches = html.match(/(?:https?:)?\/\/[a-z0-9][a-z0-9.-]+[a-z0-9]+/ig);
console.log(matches);

//実行結果 htmlは書いてないけど、aタグ等が3つほどあるのをイメージする。
['http://insecure.com', 'https://secure.com', '//anything.com']

(?:https?:)? からみていく右の ?:)? に注目してほしいニコちゃんマーク :)が隠れているのが分かるだろう。今のは全く関係ないので無視してくれ。ニコちゃんマークの左側にある ? に注目してくれこれは s?s に引っ付いている。意味は「sの0回もしくは1回の出現」を意味している。メタ文字(?)は左の文字に作用する。2つ目のニコちゃんマークの右側「?」はグループ「(?:https?:)」全体に適用される。「()内があるかないかの時にマッチする。」()内はhttpかhttpsにマッチするため2つを足すと

  • 空文字(「https?:」の0回出現)
  • https:
  • http

いずれかにマッチする。

そしてこの [a-z0-9][a-z0-9.-]+[a-z0-9]+ 一見助長過ぎる記述も意味がある。ドメインは最初英数字から始まるのが決まっていてその間に -. が使用される例えば [abc-mart.com](http://abc-mart.com) のような感じだそしてドメインの最後は英数字で終わるので最後も [a-z0-9]+ で挟んであげる。

最長マッチ、最短マッチ

最長マッチだとHTMLで下記のようなDOMがあるとする。

const input = "hello<i>test1</i>and<i>test2</i>world.";
const output = input.replace(/<i>(.*)<\/i>/ig, '<strong>$1</strong>');
console.log(output);
//実行結果
hello<strong>test1</i>and<i>test2</strong>world.

<i>タグを全て<strong>タグに置き換えたかった。

だがなんか、思ってたのと違う。そうこれは最長マッチのため最初の <i> と最後の </i> にマッチしている。上記の正規表現は最長マッチ

これを修正する方法は *? として最短マッチにする。

const output = input.replace(/<i>(.*?)<\/i>/ig, '<strong>$1</strong>');
console.log(output);
//実行結果
hello<strong>test1</strong>and<strong>test2</strong>world.

後方参照

<img>タグやalt属性等をまとめて正規表現を用いて取り出したい時があるでしょう。属性値の引用には「'」「"」も使用出来ますが、先頭と最後は対応しなければいけないです。その際に使用出来るのが後方参照です。

const html = `<img alt='A "simple" example.'>` + `<img alt="Don't abuse it!">`;
const imageTags = html.match(/<img alt=(['"]).*?\1>/g);
imageTags.forEach(imagetag => console.log(imagetag));

正規表現はalt=のあとに続く (['"]) で「'」「"」のいずれかにマッチして、それが記憶される。続く .*? は0文字以上の文字に最短マッチして \1 が最初に出現する所までマッチします。 \1 が後方参照で最初の (...) この場合 (['"]) でマッチしたものを記憶して (...) が出てくる順番で \ の後ろの番号を指定します。今回の場合は一番目にマッチした物を後方でも使用したいので \1 と指定します。

そして \1 は今回では ' でマッチしているので ' が出現する所までとなります。

グループの置換

let chap2 = "第2章初めてのJavaScriptアプリ\n"
			+ "第1章では、JavaScriptの開発環境について説明しました。...\n"
			+ "詳しくは第23章を参照してください。...\n"
			+ "第3章では変数や定数について説明します。";
chap2 = chap2.replace(/第(\d+)章/g, '$1章');
console.log(chap2);

//実行結果
2章初めてのJavaScriptアプリ
1章では、JavaScriptの開発環境について説明しました。...
詳しくは23章を参照してください。...
3章では変数や定数について説明します

上記のコードは「第2章→2章」のように変換するコードです。replaceの第一引数では (\d+) と一桁以上の数字にマッチします。そいて $1 はマッチした数字に置き換わる。

replaceではマッチが進むごとに $1 の値が変化していきます。 最初のマッチでは後方参照マッチしたグループの番号は \1 であり $1 となる。次にマッチするとグループ番号は2となり \2 となり $2 になる。このように第二引数の値が変化することがコードからは読み取れないので注意が必要です。

今度はhref以外の属性を取り除いて見ます。

let html1 = `<a class='abc' href="/www.xx.yyy">サイトxx</a>`;
let html2 = `<a class='abc' href='/www.xx.yyy'>サイトxx</a>`;

/*グループの中にグループを入れて引用符とマッチさせる。*/
r = html1.replace(/<a .*?(href=(["']).*?\2).*?>/, '<a $1>');
console.log(r);
r1 = html2.replace(/<a .*?(href=(["']).*?\2).*?>/, '<a $1>');
console.log(r1);

//実行結果
<a href="/www.xx.yyy">サイトxx</a>
<a href='/www.xx.yyy'>サイトxx</a>

まず最初の .*? は hrefが最初から始まると限らないのでhrefがみつかる最短の間にあるほぼ全ての文字にマッチする。その次に現れる .*? はhref="ここにある文字列にマッチする"そして \2(["'}) にマッチする。 \2 は全体を囲っている。 (href=(["']).*?\2) にマッチする。でマッチした部分が <a ここに代入> 代入される。

let html1 = `<a class='abc' id="s" href="/www.xx.yyy">サイトxx</a>`;
r = html1.replace(/<a .*?(class=(["']).*?\2).*?(href=(["']).*?\4).*?>/, '<a $3 $1>');
console.log(r);

//実行結果
<a href="/www.xx.yyy" class='abc'>サイトxx</a>

上記のコードはclassとhrefの順番を入れ替えるものです。

最初からclassとhrefという順番で出現する必要があります。

$書き方オプション

$を使用してさらに高度なマッチングがあります。

const input = "One two three";
let r = input.replace(/two/, '($`)');
console.log(r);
//実行結果
One (One ) three//Oneの後ろにある空白も含まれる。

r = input.replace(/\w+/g, '($&)');
console.log(r);
//実行結果
(One) (two) (three)

r = input.replace(/two/, "($')");
console.log(r);
//実行結果
One ( three) three//threeの手前にある空白も含まれる。

r = input.replace(/two/, "($$)");
console.log(r);
//実行結果
One ($) three

関数を用いた置換

最もいかれた正規表現の記述方法で複雑な正規表現がかみかぜ入れずに関数内に出現するので頭が爆発するは必須だ。これからそれをお題と共に書いていこうと思う。

お題「全てのリンクタグ<a>を特殊な形式に変換するプログラムを書く、class, id, hrefの属性を残し他は全て取り除く」

const html =
	`〇〇<a onclick="alert('!!')" class="cl1" href="/foo" id="id1">XXX</a>△△`;
console.log(sanitizeATag(html))//< class="cl1" href="/foo" id="id1">XXX</a>

function sanitizeATag(aTag) {
	/*(.*?)で囲まれた値がpartsに配列で入る。*/
	const parts = aTag.match(/<a\s+(.*?)>(.*?)<\/a>/i);
	console.log(`parts[1]=${parts[1]}`);//parts[1]=onclick="alert('!!')" class="cl1" href="/foo" id="id1"
	console.log(`parts[2]=${parts[2]}`);//parts[2]=XXX

	const attributes = parts[1].split(/\s+/);//空白で分割する。
	console.log(attributes);//[ `onclick="alert('!!')"`, 'class="cl1"', 'href="/foo"', 'id="id1"' ]
	return '<a ' + attributes.filter(attr => /^(?:class|id|href)[\s=]/i.test(attr)).join(' ') + '>' + parts[2] + '</a>';
}

参考書はコードだけ残し説明を丸投げしたので一行ずつ見ていこうと思う。

return まではなんとか雰囲気で分かるがそこからは .filter.test そして .join と今まで出て来なかったのをさらっと使用してやがる。

.filter()メソッド

引数にcallback関数を取ってその関数が True を返した値は配列に代入される。 False の場合は取り除かれる。

使い方

.filter(element => callback(element))

const words = ['spray', 'limit', 'elite', 'exuberant', 'destruction', 'present'];

const result = words.filter(word => word.length > 6);

console.log(result);
// expected output: Array ["exuberant", "destruction", "present"]

.test()メソッド

正規表現と指定された文字列の一致を調べる検索を実行します。一致するものがあれば True なければ False を返す。

今回の場合正規表現は /^(?:class|id|href)[\s=]/i となっており [^] と似ているがこれは [] 内での使用ではないため違う。文字列の先頭から検索するという意味になる。 先頭にマッチする

対象じゃないものが含まれればするーされる。例: https にマッチしたいとして、開始に さhttps と対象じゃない物が入ると後半でマッチしてもするーする。つまり最初は絶対に https から始まらないとダメらしい。

行頭の最初の文字だけマッチさせる

let str = `kIt was the best of times, it was the woekst of times`;

console.log(str.match(/^\w+/) );

キャプチャグループ

?: の説明をする前にキャプチャグループについて説明する。

正規表現ではパターン全体にマッチした文字列を取得できるがパターンマッチしたさらにその一部の部分をキャプチャグループと呼ばれる (...) で囲うことで括弧内に書かれたパターンにマッチした文字列を取得することができる。

キャプチャグループを使用しない例

そのため下記のコードではパターン全体にマッチした文字列を取得してる。

const regex = /\d{4}-\d{2}-\d{2}/;

let result = regex.exec('My birthday is 1994-07-25');
console.log(result[0]);
console.log(result);

//実行結果
1994-07-25
[
  '1994-07-25',
  index: 15,
  input: 'My birthday is 1994-07-25',
  groups: undefined
]

.execメソッドは正規表現にマッチした文字列の情報を配列に格納して返す。

キャプチャグループ使用例

これでマッチした文字列の事をキャプチャと呼ぶ。下記のコードだと [1]~[3] を指す。

const regex = /(\d{4})-(\d{2})-(\d{2})/;

let result = regex.exec('My birthday is 1994-07-25');
console.log(result[0]);
// 1994-07-25
console.log(result[1]);
// 1994
console.log(result[2]);
// 07
console.log(result[3]);
// 25

ここでようやく?:の登場

マッチした文字列からさらに細かくグループ分けしてキャプチャを作成するのがキャプチャグループでした。

そこでキャプチャを作成しても利用しないパターンが出てきます。本来であればそのまま利用しなければ済む話なのですが、キャプチャが不要なものはキャプチャしないように設定する事でキャプチャを取得する記述をよりシンプルにしたい。そんな時に ?: は利用します。

const regex = /(?:\d{4})-(\d{2})-(\d{2})/;

let result = regex.exec('My birthday is 1994-07-25');
console.log(result[0]);
// 1994-07-25
console.log(result[1]);
// 07
console.log(result[2]);
// 25

1994 はキャプチャされないので、使わないキャプチャが含まれるより検索等で(for文で回す時)の処理をする際に少しでも軽くなる。

説明がだいぶそれてしまったが今回の正規表現 /^(?:class|id|href)[\s=]/i では先頭から始め、マッチはするが「class, id, href」のキャプチャは作成しないということがわかった。そして次に [] 角括弧ないのいずれかにマッチする。この場合は \s 半角スペース、タブ、垂直タブ、改行文字、全角スペースそして = のいずれかにマッチする。 i は大小関係なくマッチする。

まとめると

先頭から始めて、大文字小文字関係なく「class, id, href」にマッチし(キャプチャは作成しない) その後の文字は半角スペース、タブ、垂直タブ、改行文字、全角スペース、=のいずれかになる物にマッチする。

マッチすれば True を返す。そしてfilterメソッドで True の要素は配列に格納される。

joinメソッド

配列の中身を足してくれる。

const array = ["テスト", "てすと"];
let result = array.join('');
/*
左に配列で引数に結合する際の何を使用するか今回は空なので
テストてすとの間に何もない。
*/
console.log(result);

//実行結果
テストてすと

関数の中では return '<a ' + attributes.filter(attr => /^(?:class|id|href)[\s=]/i.test(attr)).join(' ') + '>' + parts[2] + '</a>'; のように使用され空白で結合するようになっている。配列はfilterメソッドが作成したもので中身は [ 'class="cl1"', 'href="/foo"', 'id="id1"' ] こうなっている。なので結果は <a class="cl1" href="/foo" id="id1">XXX</a> が出力される。

ここで話はreplace()の第二引数に関数を渡すことが出来る。

とても長かった話が終わって突然ですが、正規表現の関数という事でreplace()の第二引数に関数を渡して処理をする方法を学ぶ。これを使うと先ほど作成した sanitizeATag() 関数の引数に渡す前に <a> タグのみに絞って渡すことが出来る。ただそんな事しなくても上記のコードの aTag.match(/<a\s+(.*?)>(.*?)<\/a>/i); この部分でaタグのみに絞られるのでなくていい。

一応、replace() 使うとこんな事も出来るよという感じで見て欲しい。

const html =
	`〇〇<a onclick="alert('!!')" class="cl1" href="/foo" id="id1">XXX</a>△△`;

html.replace(/<a .*?>(.*?)<\/a>/ig, function(match, group1, offset, origin) {
	console.log(`<a>タグが${offset+1}文字目から見つかった`);
	console.log(`リンク対象文字列は「${group1}`);
	console.log(`元々の文字列は「${origin}`);
	console.log(`マッチしたのは「${match}`);
});

//実行結果
<a>タグが3文字目から見つかった
リンク対象文字列は「XXX」
元々の文字列は「〇〇<a onclick="alert('!!')" class="cl1" href="/foo" id="id1">XXX</a>△△」
マッチしたのは「<a onclick="alert('!!')" class="cl1" href="/foo" id="id1">XXX</a>

String.prototype.replace に渡す関数は、次の引数を順に受け取る。prototypeって何だろう?JavaScriptやってると結構出てくる。

  1. マッチした文字列全て($&と同じ)
  2. マッチしたグループ(グループが指定してある時)。グループの数と同じだけの引数(キャプチャ)を書く。
  3. マッチした文字列のオフセット(0から始まる)
  4. 元の文字列(滅多に使われない)

これを使って先ほどのコードにreplace()を組み込んで見ます。

const html =
	`〇〇<a onclick="alert('!!')" class="cl1" href="/foo" id="id1">XXX</a>△△`;

//ここが新しい。
const r = html.replace(/<a .*?<\/a>/ig, sanitizeAtag)
console.log(r)//< class="cl1" href="/foo" id="id1">XXX</a>

function sanitizeATag(aTag) {
	/*(.*?)で囲まれた値がpartsに配列で入る。*/
	const parts = aTag.match(/<a\s+(.*?)>(.*?)<\/a>/i);
	console.log(`parts[1]=${parts[1]}`);//parts[1]=onclick="alert('!!')" class="cl1" href="/foo" id="id1"
	console.log(`parts[2]=${parts[2]}`);//parts[2]=XXX

	const attributes = parts[1].split(/\s+/);//空白で分割する。
	console.log(attributes);//[ `onclick="alert('!!')"`, 'class="cl1"', 'href="/foo"', 'id="id1"' ]
	return '<a ' + attributes.filter(attr => /^(?:class|id|href)[\s=]/i.test(attr)).join(' ') + '>' + parts[2] + '</a>';
}

これでreplace()の第二引数にsanitizeAtagを渡す事ができました。sanitizeAtagの引数 aTag には 引数 match が渡る。

行頭や行末とのマッチング

先ほど先頭(行頭)でマッチングする ^ を紹介したがもう一つ $ は行末にマッチする。

const input = "It was the best of times, it was the worst of times";
const beginning = input.match(/^\w+/);
/*配列の先頭にしてるのはmatchが返すのが配列でそこにはマッチした文字以外の情報が含まれている。*/
console.log(beginning[0]);  //先頭の文字列Itにマッチしてから文字が続くまでだから空白で区切られる。
const end = input.match(/\w+$);
console.log(end[0]); //times 同様にマッチするが後ろからになる。
const everything = input.match(/^.*$/); //文字列全体にマッチする。
console.log(everything[0])
const nomatch1 = input.match(/^best/i);
console.log(nomatch1); // 先頭からのスタートにはないのでNullが返る。
const nomatch2 = input.match(/worst$/i);
console.log(nomatch2); //null

const input2 = "あの頃が最高だったな~。まぁ、あの頃が最悪でもあったな〜。";
const beginning2 = input2.match(/^.*?[はが]/)
console.log(beginning2[0]); //あの頃が
const end2 = input2.match(/[^。]+。$/);
console.log(end2[0]); //まぁ、あの頃は最悪でもあったな〜。

個人的には input2.match(/[^。]+。$/); ここの正規表現で引っかかった。

角括弧 [] の中で ^ を使用すると括弧内の文字以外という否定の意味になる。先頭を表すものではない事に注意が必要だ。

そのため上記の場合は 以外となる。続きは 以外つまり文字列等が1文字以上繰り返され最後は から始まる。もちろん行末から検索をするので

まとめると

スタートで 以外(。以外なので文字列、空白とかもマッチする)を一回以上繰り返す所までマッチする。

改行で区切られた文字列を複数業として扱いたい場合は、フラグ「m (multiline)」を使う。

const input = "One Line\nTwo lines\nThree lines\nFour";
const beginnings = input.match(/^\w+/mg);
console.log(beginnings);//改行ごとに先頭から文字列を抽出
const endings = input.match(/\w+$/mg);
console.log(endings);//改行ごとに後方からマッチする文字列を抽出

//実行結果
[ 'One', 'Two', 'Three', 'Four' ]
[ 'Line', 'lines', 'lines', 'Four' ]

英単語の境界のマッチング

ここ説明がよくわからなかったかたコードから見ていこうと思う。

const inputs = [
		"john@doe.com", 
		"john@doe.com is my email",
		"my email is john@doe.com",
		"use john@doe.com, my email",
		"my email:john@doe.com.",
];

const emailMatcher = 
	/\b[a-z][a-z0-9._-]*@[a-z][a-z][a-z0-9_-]+\.[a-z]+(?:\.[a-z]+)?\b/ig;
const r = inputs.map(s => s.replace(emailMatcher, '<a href="mailto:$&">$&</a>'));
console.log(r);

//実行結果
[
  '<a href="mailto:john@doe.com">john@doe.com</a>',
  '<a href="mailto:john@doe.com">john@doe.com</a> is my email',
  'my email is <a href="mailto:john@doe.com">john@doe.com</a>',
  'use <a href="mailto:john@doe.com">john@doe.com</a>, my email',
  'my email:<a href="mailto:john@doe.com">john@doe.com</a>.'
]

上記のコード説明も読者に任せるタイプだったのでやっていこうと思う。

早速 /\b[a-z][a-z0-9._-]*@[a-z][a-z][a-z0-9_-]+\.[a-z]+(?:\.[a-z]+)?\b/ig;こんなに長い正規表現を見せられて気絶しそうだった。 先ほど重い関数を乗り越えたと思ったらこれだから後半に畳み掛けるタイプの参考書だなと思う。

ただ上の方で紹介したURLのマッチングと似たような構成になっているので目を凝らせば読めないこともないと思う。これまで書いてきた技術を思いだせば読めると思う。

まず \b これは今まで出てきた \w ([a-zA-Z_]「英単語」。ダッシュ、ピリオドは含まれない。)等のメタ文字と同じだ。単語の区切りにマッチするという意味のわからない機能を兼ね備えている。

空白で区切ればいいのではと考えてしまう。マッチしたい単語の直後が空白かどうかを検索するみたいな正規表現で同じ機能実装できるじゃんと思ったが、実際には空白のみ出なく「。.」等も区切りとして認識される。

console.log("Let it be.".match(/\bit\b/));
console.log("Let it。be.".match(/\bit\b/));
console.log("Let itt be.".match(/\bit\b/));

//実行結果
[ 'it', index: 4, input: 'Let it be.', groups: undefined ]
[ 'it', index: 4, input: 'Let it。be.', groups: undefined ]
null

続きを見ていく区切り文字があって次にa-zの一文字が来る。その次はa-z、0-9、あらゆる文字、_(アンダーバー)、-(横棒)が0回以上繰り返され@まで続く。つまり a@ みたいな1文字のメールアドレスにもヒットする。

なぜ最初にa-zで固定しているかと言うとメールアドレスの最初は1文字目は英文字というルールから来ている。もしメールアドレスの最初を数字から始めれるなら上記の正規表現の意味は私には分からない。

@の次は英文字1文字、そして再びa-z、0-9、あらゆる文字、_(アンダーバー)、-(横棒)が1回以上繰り返されドットまで続くなぜ0回以上ではなく1回以上なのか不明だが @a. というようなメールアドレスは作れないルールか何かに沿っているんだと思う。

ドットの次はa-zの1文字以上、次に.(ドット)、a-zが1文字以上続くのがある場合とない場合にマッチする。つまり co.jp.com で終わってもマッチするようになっている。グループのキャプチャはしない。最後に区切り文字でマッチして大文字小文字関係なくグローバルに検索する。

一瞬 (?:\.[a-z]+)? の最後の ? は最短マッチかと思ったけど違った最短マッチはあらゆるメタ文字(*, +, ?, {n}, {n,}, {n, m})の後ろに付けるとそのメタ文字の機能を最短でのマッチにすることができる。今回はグループの後ろに付いているので ? はメタ文字として直前文字の0回もしくは1回の出現となる。

この辺はかなりややこしい。同じ記号でも付ける位置が変わることで別の意味をなすことになる。

mapメソッド

与えられた配列から要素を取り出してコールバック関数に渡すなりして新しく返ってきた値で新しく配列を生成する。

forEach との違いは map は返された値で配列を作る。 forEach の場合はその必要がない。

なので返ってきた値で配列を作成するなら map そうじゃないなら forEach を作るのが無難です。

const array1 = [1, 4, 9, 16];

// pass a function to map
const map1 = array1.map(x => x * 2);

console.log(map1);
// expected output: Array [2, 8, 18, 32]

上記のコードでは単純に配列の要素を1つずつreplaceメソッドに渡している。replaceには先ほどの正規表現渡しマッチした箇所を <a href="mailto:$&">$&</a> で置き換える。 $& にはマッチしたメールアドレスが入るので <a href="mailto:John@doe.com">john@doe.com</a> に置き換わる。

そのため出力結果が下記のようになる。

[
  '<a href="mailto:john@doe.com">john@doe.com</a>',
  '<a href="mailto:john@doe.com">john@doe.com</a> is my email',
  'my email is <a href="mailto:john@doe.com">john@doe.com</a>',
  'use <a href="mailto:john@doe.com">john@doe.com</a>, my email',
  'my email:<a href="mailto:john@doe.com">john@doe.com</a>.'
]

先読み

「先読みは行頭・行末や単語の境界のメタ文字のように、入力文字列を消費しない」??これ正規表現が文字列を消費しながらマッチすることを指して使ってる言葉だと思うんだけど消費しないとどんなメリットがあるのかいまいち理解できない。

これも最初の説明の意味が分からないから、コードから読んでいこうと思う。

function validpassword(p) {
	return /[A-Z]/.test(p) &&
		/[0-9]/.test(p) &&
		/[a-z]/.test(p) &&
		!/[^a-zA-Z0-9]/.test(p); //それ以外の文字が含まれない。
}

console.log(vaildpassword("aiueo")); //false
console.log(vaildpassword("3aiuEo")); //true
console.log(vaildpassword("traveLer2")); //true
console.log(vaildpassword("日本語3aB")); //false
console.log(vaildpassword("Poke3")); //true
console.log(vaildpassword("Poké3")); //false

条件分岐を return で行い TrueFalse を返す。

これを一つの正規表現にまとめたいとする。

function vaildpassword(p) {
	return /[A-Z].*[0-9].*[a-z]/.test(p);
}

これは上手く行かない。

これにマッチするには、小文字の前に数字が、さらにその前に大文字が来なければなりません。さらに他の文字が入ってはいけないことをチェック出来ていない。

なぜこうなるのかというとそれは上記の正規表現が文字列を消費しながら検索を進めていくためである。

こういった場合に入力文字列を消費しない正規表現である「先読み(lookahead)」を利用できる。JavaScriptにおいては (?=...) のように指定します。「否定先読み」もあり、 (?!...) は後ろに指定の表現が続かないものだけにマッチします。

先読みを使うとパスワード認証の正規表現を、簡潔にまとめることができる。

function vaildpassword(p) {
	return /(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z])^[a-zA-Z0-9]+$/.test(p);
}

//先ほどと同じように実行してみる。
console.log(vaildpassword("aiueo")); //false
console.log(vaildpassword("3aiuEo")); //true
console.log(vaildpassword("traveLer2")); //true
console.log(vaildpassword("日本語3aB")); //false
console.log(vaildpassword("Poke3")); //true
console.log(vaildpassword("Poké3")); //false

これを見たときに /(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z])^[a-zA-Z0-9]+$/ あれこれ重なってる部分あるんじゃないかなと思った。

前半・後半で下記のように分けて見る。

/(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z])//^[a-zA-Z0-9]+$/ で分けれるのではと思ってしまった。

前半は先読みが使われていて、 (?=.*[A-Z]) 何か文字列が0文字以上で次にA-Zのどれかが入る。んっ?最初はどんな文字でもよくて次にくるのがA-Zにマッチする??単細胞の私はこれだと「あああK」とかにしかマッチしないんじゃないかと思う。文字が消費される場合はそれでいいのだが、今回は文字が消費されないので「あKああ」でもマッチする。なんなら「K」でもマッチする。なぜなら * が0文字以上なので最悪なくていいためです。「あ」これはマッチしないです。 [A-Z] がないためです。これと似たような感じで (?=.*[0~9])(?=.*[a-z]) と続きます。文字列が消費されないということで毎回最初から検索をかけているんだと思います。

後半では /^[a-zA-Z0-9]+$/ となっておりa-z、A-Z、0-9の文字列いずれかが一文字以上で文字列全体でマッチするとなっています。なのであれっこれ前半のやつと被ってないかと思ってしまいますが、そんなことはありません。実際に分割してコードを実行して見ました。

前半

function vaildpassword(p) {
	return /(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z])/.test(p);
}

//右側のboolean型は後半もある場合、実際の実行結果と比較しやすいように用意しました。
console.log(vaildpassword("aiueo")); //false
console.log(vaildpassword("3aiuEo")); //true
console.log(vaildpassword("traveLer2")); //true
console.log(vaildpassword("日本語3aB")); //false
console.log(vaildpassword("Poke3")); //true
console.log(vaildpassword("Poké3")); //false

//実際の実行結果
false
true
true
true
true
true

console.log(vaildpassword("日本語3aB"));console.log(vaildpassword("Poké3")); の実行結果が /(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z])^[a-zA-Z0-9]+$/ とは異なります。

理由は「日本語、é」が入っていても /.*/ ではマッチしてしまうからです。なので後半ではそれを取り除くために使われている文字列は「a-zA-Z0-9」のいずれかのみという指定をしています。

後半

function vaildpassword(p) {
	return /^[a-zA-Z0-9]+$/.test(p);
}

//右側のboolean型は前半もある場合、実際の実行結果と比較しやすいように用意しました。
console.log(vaildpassword("aiueo")); //false
console.log(vaildpassword("3aiuEo")); //true
console.log(vaildpassword("traveLer2")); //true
console.log(vaildpassword("日本語3aB")); //false
console.log(vaildpassword("Poke3")); //true
console.log(vaildpassword("Poké3")); //false

//実際の実行結果
true
true
true
false
true
false

逆にこの場合はa-zA-Z0-9のいずれかが使用されていればマッチするので条件がガバガバになってしまいます。

なので、前半の1文字ずつ含まれているか確認する動作が必要になります。

そのため、前半後半必要だということが分かりました。

正規表現の動的生成

記事の最初の方に正規表現のリテラルを使用する方が、RegExpのコンストラクタを使用するよりも一般的であると書かれていたが、その理由の一つとしてリテラル表現を使えば「\」をエスケープする必要がないという点があげられる。

よく意味が分からないがMDNによると、正規表現リテラルはスクリプトのロード時にコンパイルされるのに対し、コンストラクタを使用した場合は実行時にコンパイルされる。したがって、固定された正規表現を使う場合は、リテラルを使った方が効率よくなる。

まとめると

固定された正規表現ではリテラルの方が効率が良い。

RegExpのコンストラクタを本当に使用する場面は正規表現を動的に生成したい場合です。

例えばユーザが入力した文字列に対してマッチング操作を行うといった場合です。

次の例では、 ユーザ入力にしたいのですが、説明を簡素にするためにマッチする名前は変数(users、users2)に代入してあります。

const users = ["mary", "nick", "arthur", "sam", "yvette"];
const userRegex = new RegExp(`@(?:${users.join('|')})\\b`, 'g');
console.log(userRegex);// /@(?:mary|nick|arthur|sam|yvette)\b/g

const text = "User @arthur started the backup and 15:15, " +
						"and @nick and @yvette restored it at 18:35.";

console.log(text.match(userRegex)); //["@arthur", "@nick", "@yvette"]

const users2 = ["浦島太郎", "一寸法師", "桃太郎", "金太郎", "かぐや姫"];
const userRegex2 = new RegExp(`(?:${users2.join('|')})`, 'g');
console.log(userRegex2);// /(?:浦島太郎|一寸法師|桃太郎|金太郎|かぐや姫)/g

const text2 = "浦島太郎がバックアップを開始(15:15)\n " +
						"かぐや姫と金太郎がリストア(18:35)\n";

console.log(text2.match(userRegex2)); //["浦島太郎", "かぐや姫", "金太郎"]

userRegex は@から始まり。配列に格納されたユーザ情報のいずれかに一致して、最後は文字の区切りで終わる正規表現をしており、注意する点は特殊文字として使用する \b をコンパイル前はただの文字列として認識して欲しいので \\b としている。キャプチャしない設定にすることで若干の効率アップを計っている。

userRegex2 は先ほどの物に@と文字区切りを抜いて、配列内のいずれかにマッチすればマッチした部分が抽出される。

おわりに

とても長かった、正規表現で何度も挫けそうになった。アプリで少し正規表現を使用したいだけなのに、理解して使えるようにならないと気が済まない為ここまで広範囲に学ぶことになった。これでは一向にアプリが完成しそうにない。

今回、参考書の内容は理解したが、まだ全然使いこなせる段階ではないのでこの記事を何度も見返しながら自分で正規表現を組み立てて、プログラムに組み込んで行こうと思います。

他にも紹介されてない正規表現もあると思います。ある程度使いこなせるようになったら次の段階に進めたらと思います。

途中で自分の感想等が入ったりするので文章が「である。」と「です。ます。」が入り混じって気持ち悪い文章になっています。ご了承下さい。かなり長文なので、誤字脱字あると思います。見かけた際は教えてくださると助かります。

この記事を書くことで1ヶ月の間、正規表現と共に過ごすことが出来たので、昔ほど嫌悪感がなくなったことが嬉しい。

最後まで記事を読んで下さりありがとうございました。

記事に関するコメント等は

🕊:Twitter
📺:Youtube
📸:Instagram
👨🏻‍💻:Github
😥:Stackoverflow

でも受け付けています。どこかにはいます。

参照

コードの引用は下記の参照元からです。

一番参考している本です。

1)Ethan Brown. Learning JavaScript, 3rd Edition. O'Reilly. イーサン ブラウン ムシャ ヒロユキ ムシャ ルミ (訳) 2017. 「17章 正規表現」.『初めてのJavascript』. 第3版. オライリージャパン. pp 279-303.

正規表現(RegExp) - とほほのWWW入門

Array.prototype.filter()

正規表現:「行頭」「行末」の表現と、応用例

RegExp.prototype.exec()

キャプチャグループを使って正規表現パターンの一部にマッチした文字列を取得する

あんま関係ないけど面白い記事だった。

キャプチャしない正規表現は()にする・・・って、え? - 負け犬プログラマーの歩み

RegExp.prototype.test()

JavaScriptで文字列の連結、join()を使う方法【初心者向け】

正規表現:ドット「.」の意味と使い方。

正規表現の最短マッチ - Qiita

ホンヤク社 - 正規表現「¥b」(単語の境界)編│原文ファイルのトリセツ

正規表現で単語の区切り - Qiita

Array.prototype.map()

JavaScriptで文字列の連結、join()を使う方法【初心者向け】

記号の読み方辞典(音訳の部屋)

この辺は正規表現を可視化してくれる。図で出してくれるけど、その図がいまいちよく分かんなくて結局分からない図が出てくるだけで使いこなせてない。

Regex101

Regulex:JavaScript Regular Expression Visualizer

Javaだけど日本語で分かりやすい。

Regular Expression Test Drive