RegExp lastIndex и флаги: состояние, что меняет ответ
Проведите шаблон через test, exec и matchAll: как lastIndex растет под флагами global и sticky, почему переиспользованный global шаблон меняет ответ, и как unicode считает.
BotBrowser Team
Нужна структурированная документация по теме Отпечатки?
Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.
BotBrowser поддерживает повтор authorized same-origin фикстуры под объявленным профилем и сохраняет ограниченные наблюдения о том, совпал ли шаблон и где, но он не может изменить, как среда реализует алгоритм RegExp, не может заставить global шаблон перестать нести состояние между вызовами и не превращает позицию совпадения в сигнал идентичности. Этот обзор проводит набор шаблонов через test, exec, match, replace и matchAll, затем фиксирует, как lastIndex растет под флагами global и sticky, почему каждый метод String сбрасывает или игнорирует это состояние по-разному, почему переиспользованный global шаблон меняет ответ на том же входе, и как флаг unicode меняет то, что считают . и квантификатор.
Кратко
- Global шаблон несет
lastIndex, которыйtestиexecпродвигают; три вызоваtestна одной строке возвращаютtrue,true, затемfalse, потому что третий начинается после последнего совпадения. - Не-global шаблон держит
lastIndexна 0, поэтому три вызоваtestна одной строке возвращают один и тот же ответ. - Флаг sticky привязывает каждое совпадение к
lastIndex, поэтому/b/yпадает на смещении 0 наabи совпадает только когдаlastIndexравен 1. String.matchиString.replaceс флагом global сбрасываютlastIndexв 0 после, поэтому они не утекают состояние так, как переиспользованныйtest.matchAllбросаетTypeErrorна не-global шаблоне, потому что ему нужен флаг global для обхода совпадений./^.$/падает на эмодзи, а/^.$/uсовпадает, потому что без флага unicode.считает единицу UTF-16, а эмодзи это две.- Источник и флаги шаблона это стабильные читаемые строки, поэтому запись может хранить их точно.
Содержание
- Где lastIndex несет состояние между вызовами
- Как флаг sticky привязывает к lastIndex
- Как методы String сбрасывают или игнорируют состояние
- Где флаг unicode меняет то, что считает шаблон
- Почему переиспользованный global шаблон меняет ответ
- Записывайте показания совпадения
Где lastIndex несет состояние между вызовами
Шаблон не является чистой функцией своего входа, когда он несет флаг global или sticky. Объект RegExp хранит число lastIndex, и test и exec читают его как позицию старта и перезаписывают как позицию после совпадения. Проба вызывает test три раза на одной строке с global шаблоном:
RegExp=function node=v26.9.0
fresh /a/g lastIndex=0
test("a a") -> true lastIndex=1
test("a a") again -> true lastIndex=3
test("a a") third -> false lastIndex=0
non-global lastIndex after test=true then lastIndex=0
Первый вызов ищет с индекса 0, находит a в 0, возвращает true и ставит lastIndex в 1. Второй ищет с 1, находит a в 2, возвращает true и ставит lastIndex в 3. Третий ищет с 3, ничего не находит, возвращает false и сбрасывает lastIndex в 0. Три вызова, одна строка, два разных ответа, и единственное, что изменилось, это число внутри объекта шаблона.
Это самый частый дефект в пути кода с регулярными выражениями: шаблон, созданный один раз на уровне модуля с флагом global и переиспользуемый в цикле, который читает его как чистый предикат. Валидатор, который строит /^[a-z]+$/g один раз и вызывает test для каждого отправленного имени, примет первое имя, затем отклонит второе, начинающееся там, где закончилось последнее совпадение первого, и отклоненное имя выглядит идентично принятому. Сбой прерывистый так, что следует за предыдущим входом, что делает его трудным для воспроизведения одним тестовым случаем.
Строка не-global это контроль. Шаблон без флага global игнорирует lastIndex на входе и оставляет его на 0, поэтому три вызова test на одной строке возвращают один и тот же булев результат каждый раз. Состояние живет в объекте шаблона, а не в строке, поэтому два шаблона с тем же источником, но разными флагами ведут себя по-разному на идентичном входе. Правило, которое держит предикат безопасным, это не ставить флаг global, когда шаблон только тестируется, и добавлять его только когда намерение обойти каждое совпадение через exec в цикле.
Как флаг sticky привязывает к lastIndex
Флаг sticky это родственник global с другим правилом привязки. Там, где global шаблон ищет вперед с lastIndex, sticky шаблон совпадает только точно на lastIndex и падает, если совпадение должно начаться позже. Проба ставит lastIndex вручную, чтобы показать оба исхода на одной строке из двух символов:
sticky /b/y at 0 on "ab" -> false
sticky /b/y at 1 on "ab" -> true
exec loop indices on "a a a" -> 0,2,4
С lastIndex на 0 sticky /b/y против ab падает, потому что символ в индексе 0 это a, а не b, и sticky совпадение не может пропустить к b в индексе 1. С lastIndex на 1 тот же шаблон совпадает, потому что символ в индексе 1 это ровно b. Шаблон и строка никогда не менялись; правило привязки превратило поиск в проверку точной позиции, а позиция пришла из состояния, которое поставил вызывающий.
Эта привязка делает флаг sticky полезным для токенизации: лексер продвигает lastIndex намеренно после каждого токена и ждет, что следующий шаблон совпадет точно там, где закончился предыдущий токен, поэтому разрыв или перекрытие это синтаксическая ошибка, а не тихий пропуск. Она же делает sticky шаблон хрупким в коде, который ждет поиска: тот, кто ставит lastIndex на устаревшее смещение и ждет поиска вперед, получает сбой, указывающий на смещение, а не на строку.
Строка цикла exec показывает благовоспитанный способ использовать global шаблон. Поскольку exec возвращает объект совпадения и продвигает lastIndex, цикл while, который крутится до того, как exec вернет null, собирает каждое совпадение по порядку и заканчивает с lastIndex обратно на 0. Индексы 0, 2, 4 это ровно позиции трех символов a, и цикл завершился потому, что четвертый exec ничего не нашел и сбросил состояние. Цикл, который вызывает exec один раз за итерацию без условия завершения, или который делит шаблон с кодом, тоже вызывающим test, ломает этот контракт и пропускает или повторяет совпадения.
Как методы String сбрасывают или игнорируют состояние
Методы String обрабатывают lastIndex не так, как test и exec, и эта разница объясняет, почему тот же global шаблон безопасен в одном месте и нет в другом. Проба ставит lastIndex в 2, затем вызывает match и replace:
before match: lastIndex=2
String.match with /g -> ["a","a","a"] lastIndex after=0
String.replace with /g -> "X X" lastIndex after=0
String.match с global шаблоном игнорирует входящий lastIndex, возвращает каждое совпадение строки как массив только совпавших подстрок, и сбрасывает lastIndex в 0 после. String.replace с global шаблоном заменяет каждое совпадение, тоже сбрасывает lastIndex в 0, и возвращает переписанную строку. Оба метода оставляют шаблон в чистом состоянии, поэтому global шаблон, который используется только через match и replace, не утекает перевороты, которые производит переиспользованный test.
Эта очистка удобна и ее легко прочитать неверно. Тот, кто запускает replace на global шаблоне, затем вызывает test на том же шаблоне, видит, что test начинается с 0, что верно, но тот, кто ставит lastIndex намеренно перед match в ожидании, что совпадение начнется там, удивляется, потому что match проигнорировал смещение. Смещение, которое уважают test и exec, это не смещение, которое уважают match и replace, поэтому путь кода, который смешивает две точки входа на одном global шаблоне, имеет два понятия "откуда начинать" в одном объекте.
matchAll строже match и требует флаг global на входе. Проба вызывает его на не-global шаблоне и получает исключение, затем собирает совпадения из global шаблона с группами захвата:
matchAll on non-global -> THREW TypeError
matchAll groups -> a=1@0 b=2@3 c=3@6
matchAll бросает TypeError на не-global шаблоне, потому что возвращает итератор, который обходит каждое совпадение, и ему нужен флаг global, чтобы определить этот обход. На global шаблоне он дает объект совпадения на каждое совпадение, с группами захвата и индексом каждого совпадения, поэтому собранное a=1@0 b=2@3 c=3@6 показывает три совпадения с их двумя группами и стартовыми позициями. В отличие от цикла while на exec, matchAll не оставляет вызывающему ответственность продвигать lastIndex, поэтому это более безопасный способ обойти каждое совпадение у шаблона, который может использоваться и в другом месте.
Где флаг unicode меняет то, что считает шаблон
Флаг unicode меняет единицу, которую считает шаблон, с единицы кода UTF-16 на кодовую точку Unicode, и это меняет и ., и любой квантификатор над символом. Проба тестирует шаблон на один символ и квантификатор на два символа против эмодзи:
/^.$/.test("😀") -> false
/^.$/u.test("😀") -> true
/^.{2}$/u.test("😀") -> false (length=2)
Без флага unicode . совпадает с одной единицей кода UTF-16, а эмодзи это суррогатная пара из двух единиц, поэтому /^.$/ не может совпасть со строкой из двух единиц шаблоном на одну единицу и возвращает false. С флагом unicode . совпадает с одной кодовой точкой, а эмодзи это одна кодовая точка, поэтому /^.$/u возвращает true. Тот же эмодзи, который простой шаблон видит как две единицы, unicode шаблон видит как один символ, и валидатор, построенный на /^.$/, отклоняет эмодзи, который читатель считает одним символом.
Третья строка показывает обратную ловушку. Квантификатор, записанный как {2} под флагом unicode, считает две кодовые точки, а не две единицы, поэтому /^.{2}$/u против эмодзи из одной кодовой точки возвращает false, хотя .length строки равен 2. Тот, кто читает String.length (который всегда счетчик единиц UTF-16) и пишет квантификатор, чтобы его сравнять, получает шаблон, который расходится с собственной длиной строки под флагом unicode. Это тот же разрыв единицы против символа, который записывает декодер текста, и это место, где шаблон молча считает не то, что предположил код, который его написал.
Вместе эти две строки говорят, что единица, которую считает шаблон, есть свойство флага, а не текста шаблона. Проверка длины, квантификатор .{n} и . все считают единицы UTF-16 без флага и кодовые точки с ним, поэтому шаблон, который смешивает проверку String.length против u шаблона, сравнивает две разные единицы. Запись флага рядом с шаблоном это то, что позволяет последующему читателю воспроизвести ту же единицу.
Почему переиспользованный global шаблон меняет ответ
Самая опасная форма дефекта состояния это global шаблон, переиспользованный между вызовами, потому что он производит чередующиеся ответы на идентичном входе. Проба вызывает test три раза на строке 1 с global /\d/g:
reused /\d/g test three times on "1" -> true,false,true
Первый вызов совпадает с цифрой, ставит lastIndex в 1 и возвращает true. Второй начинается с 1, строка длиной 1, поэтому искать нечего, он возвращает false и сбрасывает lastIndex в 0. Третий начинается с 0 снова и возвращает true. Шаблон верен, строка никогда не менялась, а ответ чередуется true, false, true только потому, что шаблон помнит, где остановился.
Шаблон с пустым совпадением нуждается в добавочном правиле, и проба показывает результат его применения:
empty-match walk -> [0:"a"] [1:""] [2:""] [3:""]
/a*/g может совпасть с пустой строкой на каждой позиции, и exec зацикливался бы бесконечно, если бы lastIndex не продвигался за совпадение длиной ноль. Проба проверяет, равен ли lastIndex все еще индексу совпадения, и, если да, увеличивает его, поэтому обход продвигается [0:"a"], затем пустое совпадение на 1, на 2 и на 3 вместо повтора на том же смещении. Без этого ручного приращения цикл exec на шаблоне с пустым совпадением никогда не завершается, потому что каждый вызов возвращает совпадение длиной ноль на том же lastIndex, и позиция никогда не двигается.
Исправление дефекта переиспользованного шаблона в том, чтобы держать состояние вне общего объекта. Global шаблон, используемый только внутри одного цикла while на exec, это нормально, потому что этот цикл владеет lastIndex. Global шаблон, используемый как переиспользуемый предикат, ошибочен, и исправление либо убрать флаг global, либо сбрасывать lastIndex в 0 перед каждым test. Свойства source и flags это дешевый способ записать, чем был шаблон, потому что это стабильные читаемые строки, которые переживают обход через запись.
Записывайте показания совпадения
Показания совпадения поддерживают узкое утверждение: эта среда, этот текст шаблона, эти флаги, для этого входа, произвели это совпадение в этой позиции. Шесть полей сохраняют их проверяемыми, и ни одно не зависит от приватного состояния общего объекта:
sourceшаблона и егоflags, записанные как две строки, которые объект предоставляет, потому что набор флагов решает и единицу, которую считает шаблон, и несет ли он состояние.- Входная строка как предоставлена, желательно с ее кодовыми точками, а не отрендеренными глифами, потому что два входа могут рендериться одинаково, а шаблон считает единицы или кодовые точки по своим флагам.
- Точка входа, использованная для запуска шаблона,
test,exec,match,replaceилиmatchAll, потому что каждая обрабатываетlastIndexпо-разному. lastIndexдо и после вызова, когда шаблон global или sticky, потому что это число, где живет состояние, и сохраненный ответ без него не воспроизводится.- Индекс совпадения и захваченные группы, а не только совпавшая подстрока, потому что два совпадения одного текста в разных позициях это разные наблюдения.
- Требовало ли совпадение длиной ноль ручного приращения
lastIndex, потому что шаблон с пустым совпадением нуждается в этом шаге для завершения, а запись, которая его пропускает, описывает цикл, который завис бы.
Чего такие показания не могут показать, так это ничего о том, как сервер или вторая среда интерпретирует тот же шаблон. Шаблон запускается одним движком с одним набором семантик флагов, а прокси, WAF или серверный фреймворк может разбирать ту же строку по своим правилам. О байтовых слоях, по которым шаблон часто бежит, TextDecoder и TextEncoder показывает, почему те же байты декодируются в одну строку под одной меткой и в неверно закодированную строку под другой, а ArrayBuffer и TypedArray показывает, почему те же восемь байтов несут разный счетчик элементов под каждым видом. Шаблон по любому из двух типов данных наследует разрыв единицы, который измеряют эти статьи.
Две привычки сохраняют показания честными со временем. Во-первых, записывайте флаги и точку входа вместе с ответом, потому что true от global test и true от не-global test значат разное для следующего вызова. Во-вторых, предпочитайте matchAll или один цикл while на exec для обхода совпадений и не-global шаблон для переиспользуемого предиката, чтобы состояние, меняющее ответы, никогда не дошло до второго вызывающего, который предположил чистую функцию.
Публичные источники
- MDN: RegExp
- MDN: RegExp.prototype.test
- MDN: RegExp.prototype.exec
- MDN: String.prototype.matchAll
- MDN: флаг sticky у RegExp
- BotBrowser: справка по согласованности автоматизации
Итоги
Относитесь к набору флагов как к части контракта данных: global или sticky шаблон несет lastIndex, который test и exec продвигают, поэтому три вызова на одной строке возвращают true, true, затем false, а не-global шаблон держит lastIndex на 0 и возвращает один и тот же ответ каждый раз. Знайте, что флаг sticky привязывает к lastIndex, что match и replace сбрасывают lastIndex в 0, а matchAll требует флаг global, и что флаг unicode заставляет . и квантификатор считать кодовые точки там, где по умолчанию считаются единицы UTF-16. Держите флаг global вне переиспользуемого предиката, обходите совпадения через matchAll или цикл exec, который владеет состоянием, и записывайте источник, флаги, точку входа и lastIndex до и после, чтобы сохраненный ответ воспроизводился, а не читался как чистый результат.
Читать дальше
О текстовом слое, по которому часто бежит шаблон, читайте TextDecoder и TextEncoder; о двоичных видах, которые шаблон может сканировать, читайте ArrayBuffer и TypedArray; о порядке, который сериализатор дает ключам объекта, который совпал шаблон, читайте Порядок ключей объекта.
Эта статья входит в руководство: Что такое отпечаток браузера?
Похожие статьи
Unicode: четыре идентичности одного текста
ОтпечаткиstructuredClone против JSON: что сохраняет глубокая копия
ОтпечаткиИсточник случайности в crypto-js: ветка сборки
Отпечаткиua-parser-js: Client Hints, боты и предел 500
ОтпечаткиЧто такое отпечаток браузера?
ИдентичностьПредзаполнение истории: история сеанса и глобальная
Переведите BotBrowser из исследований в продакшн
Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.