惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
V
V2EX
V
Visual Studio Blog
博客园_首页
Last Week in AI
Last Week in AI
Apple Machine Learning Research
Apple Machine Learning Research
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
S
SegmentFault 最新的问题
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Martin Fowler
Martin Fowler
Recent Announcements
Recent Announcements
J
Java Code Geeks
月光博客
月光博客
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
F
Fortinet All Blogs
P
Privacy & Cybersecurity Law Blog
C
CERT Recently Published Vulnerability Notes
C
CXSECURITY Database RSS Feed - CXSecurity.com
B
Blog RSS Feed
S
Schneier on Security
酷 壳 – CoolShell
酷 壳 – CoolShell
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
W
WeLiveSecurity
A
Arctic Wolf
U
Unit 42
博客园 - 司徒正美
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
有赞技术团队
有赞技术团队
Recorded Future
Recorded Future
Engineering at Meta
Engineering at Meta
Google DeepMind News
Google DeepMind News
大猫的无限游戏
大猫的无限游戏
Microsoft Security Blog
Microsoft Security Blog
Hacker News: Ask HN
Hacker News: Ask HN
量子位
B
Blog
T
The Exploit Database - CXSecurity.com
C
Cisco Blogs
博客园 - 三生石上(FineUI控件)
H
Help Net Security
博客园 - 叶小钗
C
Cyber Attacks, Cyber Crime and Cyber Security
L
LINUX DO - 热门话题
Hugging Face - Blog
Hugging Face - Blog
Google DeepMind News
Google DeepMind News
小众软件
小众软件
雷峰网
雷峰网
TaoSecurity Blog
TaoSecurity Blog
Schneier on Security
Schneier on Security

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Написание конечного автомата для разбора HTML
Trusow · 2026-05-05 · via Все публикации подряд на Хабре

Доброго времени суток, дорогие читатели.

Пришла идея поделиться опытом написания конечного автомата. Скажу сразу же, я из мира фронтенд разработки, поэтому в моей реализации могут быть косяки, которые не допустят, например, бородатые сишники) Поэтому, критика, если она по делу да и к тому же от людей уже имеющих подобный опыт, категорически приветствуется. Думаю, не одному мне она будет интересна.

Здесь и далее весь код будет приводиться на html/js, но код на js может быть легко портирован на любой другой язык, поскольку он не будет содержать специфичных для js конструкций.

Тут будет приведен упрощенный пример конечного автомата. Например, в нем не будет учитываться, что значение атрибута может заключаться в одинарных кавычках или быть вовсе без них. Или что в разметке могут быть комментарии. Основная цель статьи, поделиться своим опытом и идеями.

Постановка задачи

Есть некая разметка

<div class="mb-4">
	<span>Text</span>
</div>

Которую нужно распарсить за один while/for не прибегая к специальным возможностям работы со строками, типа сплита и прочего. То есть в идеале код должен будет выглядеть как-то так:

function getNextStatus(ch, status) {
	// вот тут и будет написан конечный автомат, который будет принимать на вход символ и текущий статус и возвращать следующий статус в зависимости от символа

	return status;
}

function parseHtml(text) {
	let status = 0;
	for(let i = 0; i < text.length; i++) {
		status = getNextStatus(text[i], status);

		// тут будет обрабатываться результат работы конечного автомата
	}
}

Мы должны написать код, который будет нам сообщать о том, что в переданном тексте есть тег div, у которого есть атрибут class со значением mb-4. Также есть тег span с текстом Text.

Анализ

Рассмотрим эту строку

<div class="mb-4">

Зададимся несколькими вопросами:

Как эту строку преобразовать в набор событий? Как эти события будут называться и сколько их будет?

Мы знаем что теги состоят из угловых скобок <, > и самого названия внутри. Название должно начинаться с буквы английского алфавита и может содержать в себе буквы английского алфавита, цифры, знаки - и _. Также в тегах могут присутствовать атрибуты, которые состоят из имени и значения разделенные знаком = (или иногда просто из имени, опустим это). Само значение должно быть в кавычках двойных или одинарных. (да, может быть и без кавычек, но тут мы пишем упрощенную версию автомата)

Попробуем перевести эти знания в последовательность событий

  • Тег открывается


  • Начало имени тега

  • Продолжение имени тега

  • Конец имени тега


  • Начало имени атрибута

  • Продолжение имени атрибута

  • Конец имени атрибута


  • Начало значения атрибута

  • Продолжение значения атрибута

  • Конец значения атрибута


  • Тег закрывается

Причем для тега <b> набор будет уже другим

  • Тег открывается

  • Начало имени тега

  • Конец имени тега | Тег закрывается

Обратите внимание на последнюю строку, там 2 события наступают одновременно, так как на символе > можно точно сказать, что тег закрыт и у него есть имя.

Попробуем написать начальную версию конечного автомата, который будет распознавать начало и конец тега.

Учимся считывать угловые скобки

Для начала введем константы, которые будут обозначать состояния автомата, назовем их, например, TAG_START, TAG_END.

const TAG_START = 1;
const TAG_END = TAG_START * 2;

Странная запись на второй строке, не правда ли? Можно было бы написать просто 2, зачем умножение на 2?

А дело в том, что сама переменная статуса будет в себе содержать одновременно несколько значений. Как это возможно? Все дело в битовых масках. Именно их мы и будем использовать на всем протяжении написания нашего автомата.

Итак, переписав нашу функцию getNextStatus мы получим что-то типа такого

function getNextStatus(ch, status) {
	if(ch === '<') {
		// Всегда будем возвращать TAG_START, если встретим символ '<'
		return TAG_START;
	} else if(ch === '>') {
		if(status & TAG_START) { // Если тег был открыт
			status ^= TAG_START; // Снимаем флаг начала тега
			status |= TAG_END; // Добавляем флаг закрытия тега
			return status;
		}
	}

	return 0; // Если что-то пошло не так, возвращаем 0
}
Полная версия
const TAG_START = 1;
const TAG_END = TAG_START * 2;

function getNextStatus(ch, status) {
	if(ch === '<') {
		return TAG_START;
	} else if(ch === '>') {
		if(status & TAG_START) {
			status ^= TAG_START;
			status |= TAG_END;
			return status;
		}
	}

	return 0;
}

function parseHtml(text) {
	let status = 0;
	for(let i = 0; i < text.length; i++) {
		status = getNextStatus(text[i], status);

		if(status & TAG_START) {
			console.log('Начало тега');
		} else if(status & TAG_END) {
			console.log('Конец тега');
		}
	}
}

Откройте в браузере консоль и вставьте этот код.

Если выполнить parseHtml('<>'), то мы увидим

Начало тега
Конец тега

Если же выполним parseHtml('><'), то увидим только Начало тега, поскольку начало тега действительно присутствует.

Если же выполним parseHtml('>>>'), то не увидим ничего.

Достаем имя тега

После того, как мы научились определять начало и конец тега самое то научиться определять его имя.

Как уже было написано ранее имя тега должно начинаться с буквы английского алфавита и т.д. и т.п.

Введем новые константы для статуса

const NAME_START = TAG_END * 2; // начало имени тега
const NAME_CONT = NAME_START * 2; // продолжение имени тега, если оно больше 1 символа
const NAME_END = NAME_CONT * 2; // конец имени тега

И допишем функцию конечного автомата

function getNextStatus(ch, status) {
	if(ch === '<') {
		...
	} else if(ch === '>') {
		...
		} else if(status & (NAME_START|NAME_CONT)) {
			// Дополняем условие если есть имя тега
			// Удаляем флаги про начало и продолжение имени тега
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			status |= TAG_END|NAME_END; // Сетаем флаги конца тега и конца имени тега
			return status;
		}
	} else if(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z') {
		// Допустимые символы начала и продолжения имени тега
		if(status & NAME_CONT) {
			// Если это продолжение имени тега, то ничего не трогаем, оставляем статус текущим
			return status;
		} else if(status & TAG_START) {
			// Если это первый символ имени тега 
			status ^= TAG_START; // Удаляем флаг начала тега
			status |= NAME_START; // Сетаем флаг начала имени тега
			return status;
		} else if(status & NAME_START) {
			// Если это не первый символ имени тега
			status ^= NAME_START; // Удаляем флаг начала имени тега
			status |= NAME_CONT; // Сетаем флаг продолжения имени тега
			return status;
		}
	} else if(ch >= '0' && ch <= '9' || ch === '-' || ch === '_') {
		// Допустимые символы в продолжении имени тега
		if(status & NAME_CONT) {
			// Если стоит флаг продолжения, то оставляем все как есть
			return status;
		} else if(status & NAME_START) {
			// Если это второй символ имени тега
			status ^= NAME_START; // Удаляем флаг начала имени тега
			status |= NAME_CONT; // Сетаем флаг продолжения имени тега
			return status;
		}
	}

	return 0;
}
Полная версия
const TAG_START = 1;
const TAG_END = TAG_START * 2;
const NAME_START = TAG_END * 2;
const NAME_CONT = NAME_START * 2;
const NAME_END = NAME_CONT * 2;

function getNextStatus(ch, status) {
	if(ch === '<') {
		return TAG_START;
	} else if(ch === '>') {
		if(status & TAG_START) {
			status ^= TAG_START;
			status |= TAG_END;
			return status;
		} else if(status & (NAME_START|NAME_CONT)) {
			status |= TAG_END|NAME_END;
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			return status;
		}
	} else if(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z') {
		if(status & NAME_CONT) {
			return status;
		} else if(status & TAG_START) {
			status ^= TAG_START;
			status |= NAME_START;
			return status;
		} else if(status & NAME_START) {
			status ^= NAME_START;
			status |= NAME_CONT;
			return status;
		}
	} else if(ch >= '0' && ch <= '9' || ch === '-' || ch === '_') {
		if(status & NAME_CONT) {
			return status;
		} else if(status & NAME_START) {
			status ^= NAME_START;
			status |= NAME_CONT;
			return status;
		}
	}

	return 0;
}

function parseHtml(text) {
	let status = 0;
	for(let i = 0; i < text.length; i++) {
		status = getNextStatus(text[i], status);

		if(status & TAG_START) {
			console.log('Начало тега');
		} else if(status & TAG_END) {
			if(status & NAME_END) {
				console.log('Конец имени тега');
			}
			console.log('Конец тега');
		} else if(status & NAME_START) {
			console.log(`Начало имени тега "${text[i]}"`);
		} else if(status & NAME_CONT) {
			console.log(`Продолжение имени тега "${text[i]}"`);
		} else if(status & NAME_END) {
			console.log('Конец имени тега');
		}
	}
}

Запустим наш код в консоли браузера и выполним следующие команды

Ввод

parseHtml('<b>')

Результат

Начало тега
Начало имени тега "b"
Конец имени тега
Конец тега

Ввод

parseHtml('<div>')

Результат

Начало тега
Начало имени тега "d"
Продолжение имени тега "i"
Продолжение имени тега "v"
Конец имени тега
Конец тега

Ввод

parseHtml('<1div>')

Результат

Начало тега

Как мы видим наш конечный автомат корректно обработал ситуацию с невалидным именем тега.

Получаем имена атрибутов

Реализация этого пункта во многом будет напоминать предыдущий. Добавим три новых константы статуса

const ATTR_NAME_START = NAME_END * 2; // Начало имени атрибута
const ATTR_NAME_CONT = ATTR_NAME_START * 2; // Продолжение имени атрибута
const ATTR_NAME_END = ATTR_NAME_CONT * 2; // Конец имени атрибута
const WAITING_ATTR_NAME = ATTR_NAME_END * 2; // Ожидание имени атрибута
И, конечно же, допишем функцию конечного автомата
function getNextStatus(ch, status) {
	if(ch === '<') {
		...
	} else if(ch === '>') {
		...
		} else if(status & WAITING_ATTR_NAME) {
			// Пробел могу поставить перед закрытием тега,
			// учитываем это и удаляем флаги дублирующихся событий
			status ^= WAITING_ATTR_NAME;
			status ^= status & NAME_END;
			status ^= status & ATTR_NAME_END;
			status |= TAG_END;
			return status;
		} else if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			// А тут если нет пробела перед закрытием тега, но уже написано имя атрибута
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= ATTR_NAME_END|TAG_END;
			return status;
		}
	} else if(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z') {
		...
		} else if(status & ATTR_NAME_START) {
			// тут все то же самое по аналогии с именем тега
			status ^= ATTR_NAME_START;
			status |= ATTR_NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			// тут у нас могут быть флаги конца имени атрибута или тега,
			// поэтому снимаем их и устанавливаем флаг начала имени атрибута
			status ^= status & WAITING_ATTR_NAME;
			status ^= status & ATTR_NAME_END;
			status ^= status & NAME_END;
			status |= ATTR_NAME_START;
			return status;
		}
	} else if(ch >= '0' && ch <= '9' || ch === '-' || ch === '_') {
		...
		} else if(status & ATTR_NAME_START) {
			// Те же самые операции, что и с именем тега
			status ^= ATTR_NAME_START;
			status |= ATTR_NAME_CONT;
			return status;
		}
	} else if(ch === ' ') {
		// Начинаем смотреть на пробел, как на разделитель
		if(status & (NAME_START|NAME_CONT)) {
			// Если пробел стоит после имени тега
			// то сбрасываем флаги имена тега
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			// и сетаем флаг конца имени тега и ожидаем, что нам прилетит имя атрибута
			status |= WAITING_ATTR_NAME|NAME_END;
			return status;
		} else if(status & (ATTR_NAME_END|NAME_END|WAITING_ATTR_NAME)) {
			// на случай если пробелы введены более 1 раза, снимаем событийные флаги
			// чтобы не дублировать произошедшие события
			status ^= status & ATTR_NAME_END;
			status ^= status & NAME_END;
			return status;
		} else if(status & (ATTR_NAME_CONT|ATTR_NAME_START)) {
			// если у нас уже есть имя тега, снимаем флаги про его имя
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			// сетаем флаг конца имени атрибута и начинаем заново ожидать новое имя атрибута
			status |= WAITING_ATTR_NAME|ATTR_NAME_END;
			return status;
		}
	}

	return 0;
}
Полная версия
const TAG_START = 1;
const TAG_END = TAG_START * 2;
const NAME_START = TAG_END * 2;
const NAME_CONT = NAME_START * 2;
const NAME_END = NAME_CONT * 2;
const ATTR_NAME_START = NAME_END * 2;
const ATTR_NAME_CONT = ATTR_NAME_START * 2;
const ATTR_NAME_END = ATTR_NAME_CONT * 2;
const WAITING_ATTR_NAME = ATTR_NAME_END * 2;

function getNextStatus(ch, status) {
	if(ch === '<') {
		return TAG_START;
	} else if(ch === '>') {
		if(status & TAG_START) {
			status ^= TAG_START;
			status |= TAG_END;
			return status;
		} else if(status & (NAME_START|NAME_CONT)) {
			status |= TAG_END|NAME_END;
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			status ^= WAITING_ATTR_NAME;
			status ^= status & NAME_END;
			status ^= status & ATTR_NAME_END;
			status |= TAG_END;
			return status;
		} else if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= ATTR_NAME_END|TAG_END;
			return status;
		}
	} else if(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z') {
		if(status & (NAME_CONT|ATTR_NAME_CONT)) {
			return status;
		} else if(status & TAG_START) {
			status ^= TAG_START;
			status |= NAME_START;
			return status;
		} else if(status & NAME_START) {
			status ^= NAME_START;
			status |= NAME_CONT;
			return status;
		} else if(status & ATTR_NAME_START) {
			status ^= ATTR_NAME_START;
			status |= ATTR_NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			status ^= status & WAITING_ATTR_NAME;
			status ^= status & ATTR_NAME_END;
			status ^= status & NAME_END;
			status |= ATTR_NAME_START;
			return status;
		}
	} else if(ch >= '0' && ch <= '9' || ch === '-' || ch === '_') {
		if(status & (NAME_CONT|ATTR_NAME_CONT)) {
			return status;
		} else if(status & NAME_START) {
			status ^= NAME_START;
			status |= NAME_CONT;
			return status;
		} else if(status & ATTR_NAME_START) {
			status ^= ATTR_NAME_START;
			status |= ATTR_NAME_CONT;
			return status;
		}
	} else if(ch === ' ') {
		if(status & (NAME_START|NAME_CONT)) {
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			status |= WAITING_ATTR_NAME|NAME_END;
			return status;
		} else if(status & (ATTR_NAME_END|NAME_END|WAITING_ATTR_NAME)) {
			status ^= status & ATTR_NAME_END;
			status ^= status & NAME_END;
			return status;
		} else if(status & (ATTR_NAME_CONT|ATTR_NAME_START)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= WAITING_ATTR_NAME|ATTR_NAME_END;
			return status;
		}
	}

	return 0;
}

function parseHtml(text) {
	let status = 0;
	for(let i = 0; i < text.length; i++) {
		status = getNextStatus(text[i], status);

		if(status & TAG_START) {
			console.log('Начало тега');
		} else if(status & TAG_END) {
			if(status & NAME_END) {
				console.log('Конец имени тега');
			} else if(status & ATTR_NAME_END) {
				console.log('Конец имени атрибута');
			}
			console.log('Конец тега');
		} else if(status & NAME_START) {
			console.log(`Начало имени тега "${text[i]}"`);
		} else if(status & NAME_CONT) {
			console.log(`Продолжение имени тега "${text[i]}"`);
		} else if(status & NAME_END) {
			console.log('Конец имени тега');
		} else if(status & ATTR_NAME_START) {
			console.log(`Начало имени атрибута "${text[i]}"`);
		} else if(status & ATTR_NAME_CONT) {
			console.log(`Продолжение имени атрибута "${text[i]}"`);
		} else if(status & ATTR_NAME_END) {
			console.log('Конец имени атрибута');
		}
	}
}

Протестируем наш код

Ввод

parseHtml('<div test>')

Результат

Начало тега
Начало имени тега "d"
Продолжение имени тега "i"
Продолжение имени тега "v"
Конец имени тега
Начало имени атрибута "t"
Продолжение имени атрибута "e"
Продолжение имени атрибута "s"
Продолжение имени атрибута "t"
Конец имени атрибута
Конец тега

Попробуем запутать наш автомат пробелами

parseHtml('<div b   b2     b4    >')

Результат

Начало тега
Начало имени тега "d"
Продолжение имени тега "i"
Продолжение имени тега "v"
Конец имени тега
Начало имени атрибута "b"
Конец имени атрибута
Начало имени атрибута "b"
Продолжение имени атрибута "2"
Конец имени атрибута
Начало имени атрибута "b"
Продолжение имени атрибута "4"
Конец имени атрибута
Конец тега

Как мы видим, не получилось, значит автомат отработал корректно.

Получаем значения атрибутов

Пожалуй, это будет самый заковыристый пункт. Вспомним, что в нашей реализации значение атрибута должно быть ограничено двойными кавычками. Немного усложним задачу добавив условие, что в значении может быть экранированная обратным слешом двойная кавычка.

Дополним список статусов

const ATTR_VAL_START = WAITING_ATTR_NAME * 2; // Начало значения атрибута
const ATTR_VAL_CONT = ATTR_VAL_START * 2; // Продолжение значения атрибута
const ATTR_VAL_END = ATTR_VAL_CONT * 2; // Конец значения атрибута
const ATTR_VAL_EMPTY = ATTR_VAL_END * 2; // Пустое значение атрибута
const WAITING_ATTR_VAL = ATTR_VAL_EMPTY * 2; // Ожидание начала значения атрибута после двойной кавычки
const WAITING_ATTR_VAL_QUOTE = WAITING_ATTR_VAL * 2; // Ожидание двойной кавычки после знака =
const WAITING_ATTR_VAL_ESC = WAITING_ATTR_VAL_QUOTE * 2; // Ожидание двойного обратно слеша (для экранирования двойной кавычки)
Дописываем автомат
function getNextStatus(ch, status) {
	if(ch === '<') {
		// Поскольку в значении атрибута может быть любой символ, дописываем это условие
		if(status & WAITING_ATTR_VAL) {
			// Если находися в режиме ожидания начала атритуба
			// снимаем флаг ожидания
			status ^= WAITING_ATTR_VAL;
			// вешаем флаг начала
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			// если у нас начало значения атрибута
			// сбрасываем флаг начала
			status ^= ATTR_VAL_START;
			// сбрасываем флаг обратного слеша
			status ^= status & WAITING_ATTR_VAL_ESC;
			// вешаем флаг продолжения значения атрибута
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			// тут просто сбрасываем флаг обратного слеша
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else {
			...
		}
	} else if(ch === '>') {
		...
		} else if(status & WAITING_ATTR_VAL) {
			// тоже самое и тут
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & (ATTR_VAL_END|ATTR_VAL_EMPTY)) {
			// здесь если у есть событие конца значения атрибута
			// снимаем флаг этого события
			status ^= status & ATTR_VAL_END;
			status ^= status & ATTR_VAL_EMPTY;
			// и сетаем флаг конца тега
			status |= TAG_END;
			return status;
		}
	} else if(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z') {
		...
		} else if(status & WAITING_ATTR_VAL) {
			// тоже самое что и в начале тега
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch >= '0' && ch <= '9' || ch === '-' || ch === '_') {
		...
		} else if(status & WAITING_ATTR_VAL) {
			// копипаста
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch === ' ') {
		...
		} else if(status & WAITING_ATTR_VAL) {
			// копипаста
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & ATTR_VAL_END) {
			status ^= ATTR_VAL_END;
			status |= WAITING_ATTR_NAME;
			return status;
		} else if(status & ATTR_VAL_EMPTY) {
			status ^= ATTR_VAL_EMPTY;
			status |= WAITING_ATTR_NAME;
			return status;
		}
	} else if(ch === '=') {
		// здесь начинаем смотреть на символ =
		if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			// если у нас уже есть имя атрибута
			// сбрасываем флаги про имена атрибута
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			// сетаем флаг, что имя атрибута закончено и ждем кавычку
			status |= ATTR_NAME_END|WAITING_ATTR_VAL_QUOTE;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			// тут знакомая копипаста
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch === '"') {
		// смотрим на двойную кавычку
		if(status & WAITING_ATTR_VAL_QUOTE) {
			// если мы ее ожидали
			// то снимаем флаг ожидания и конца имени атрибута
			status ^= status & WAITING_ATTR_VAL_QUOTE;
			status ^= status & ATTR_NAME_END;
			// и вешаем флаг, что ожиданием значения атрибута
			status |= WAITING_ATTR_VAL;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			// если у нас пустая строка
			// то удаляем флаг ожидания значения атрибута
			status ^= WAITING_ATTR_VAL
			// сетаем флаг пустой строки
			status |= ATTR_VAL_EMPTY;
			return status;
		} else if(status & WAITING_ATTR_VAL_ESC) {
			// если было экранирование, то сбрасываем этот флаг
			status ^= WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & (ATTR_VAL_START|ATTR_VAL_CONT)) {
			// если у нас уже что-то есть в атрибуте
			// удаляем флаги про атрибуты
			status ^= status & ATTR_VAL_START;
			status ^= status & ATTR_VAL_CONT;
			// сетаем флаг конца значения атрибута
			status |= ATTR_VAL_END;
			return status;
		}
	} else if(ch === '\\') {
		// если прилетел обратный слеш
		if(status & WAITING_ATTR_VAL) {
			// а мы находимся после открывающейся кавычки
			// удаляем этот флаг
			status ^= WAITING_ATTR_VAL;
			// сетаем флаг о том, что значение атрибута начато и ждем обратного слеша
			status |= ATTR_VAL_START|WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & ATTR_VAL_START) {
			// если уже значение атрибута начато
			// сбрасываем этот флаг
			status ^= ATTR_VAL_START; 
			// инвертим значение ожидания обратного слеша
			status = status & WAITING_ATTR_VAL_ESC ? status ^ WAITING_ATTR_VAL_ESC : status | WAITING_ATTR_VAL_ESC;
			// сетаем флаг продолжения атрибута
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			// тут просто инверт ожидания обратного слеша
			status = status & WAITING_ATTR_VAL_ESC ? status ^ WAITING_ATTR_VAL_ESC : status | WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else {
		// тут любой символ
		// копипаста
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	}

	return 0;
}
Полная версия
const TAG_START = 1;
const TAG_END = TAG_START * 2;
const NAME_START = TAG_END * 2;
const NAME_CONT = NAME_START * 2;
const NAME_END = NAME_CONT * 2;
const ATTR_NAME_START = NAME_END * 2;
const ATTR_NAME_CONT = ATTR_NAME_START * 2;
const ATTR_NAME_END = ATTR_NAME_CONT * 2;
const WAITING_ATTR_NAME = ATTR_NAME_END * 2;
const ATTR_VAL_START = WAITING_ATTR_NAME * 2;
const ATTR_VAL_CONT = ATTR_VAL_START * 2;
const ATTR_VAL_END = ATTR_VAL_CONT * 2;
const ATTR_VAL_EMPTY = ATTR_VAL_END * 2;
const WAITING_ATTR_VAL = ATTR_VAL_EMPTY * 2;
const WAITING_ATTR_VAL_QUOTE = WAITING_ATTR_VAL * 2;
const WAITING_ATTR_VAL_ESC = WAITING_ATTR_VAL_QUOTE * 2;

function getNextStatus(ch, status) {
	if(ch === '<') {
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else {
			return TAG_START;
		}
	} else if(ch === '>') {
		if(status & TAG_START) {
			status ^= TAG_START;
			status |= TAG_END;
			return status;
		} else if(status & (NAME_START|NAME_CONT)) {
			status |= TAG_END|NAME_END;
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			status ^= WAITING_ATTR_NAME;
			status ^= status & NAME_END;
			status ^= status & ATTR_NAME_END;
			status |= TAG_END;
			return status;
		} else if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= ATTR_NAME_END|TAG_END;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & (ATTR_VAL_END|ATTR_VAL_EMPTY)) {
			status ^= status & ATTR_VAL_END;
			status ^= status & ATTR_VAL_EMPTY;
			status |= TAG_END;
			return status;
		}
	} else if(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z') {
		if(status & (NAME_CONT|ATTR_NAME_CONT)) {
			return status;
		} else if(status & TAG_START) {
			status ^= TAG_START;
			status |= NAME_START;
			return status;
		} else if(status & NAME_START) {
			status ^= NAME_START;
			status |= NAME_CONT;
			return status;
		} else if(status & ATTR_NAME_START) {
			status ^= ATTR_NAME_START;
			status |= ATTR_NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			status ^= status & WAITING_ATTR_NAME;
			status ^= status & ATTR_NAME_END;
			status ^= status & NAME_END;
			status |= ATTR_NAME_START;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch >= '0' && ch <= '9' || ch === '-' || ch === '_') {
		if(status & (NAME_CONT|ATTR_NAME_CONT)) {
			return status;
		} else if(status & NAME_START) {
			status ^= NAME_START;
			status |= NAME_CONT;
			return status;
		} else if(status & ATTR_NAME_START) {
			status ^= ATTR_NAME_START;
			status |= ATTR_NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch === ' ') {
		if(status & (NAME_START|NAME_CONT)) {
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			status |= WAITING_ATTR_NAME|NAME_END;
			return status;
		} else if(status & (ATTR_NAME_END|NAME_END|WAITING_ATTR_NAME)) {
			status ^= status & ATTR_NAME_END;
			status ^= status & NAME_END;
			return status;
		} else if(status & (ATTR_NAME_CONT|ATTR_NAME_START)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= WAITING_ATTR_NAME|ATTR_NAME_END;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & ATTR_VAL_END) {
			status ^= ATTR_VAL_END;
			status |= WAITING_ATTR_NAME;
			return status;
		} else if(status & ATTR_VAL_EMPTY) {
			status ^= ATTR_VAL_EMPTY;
			status |= WAITING_ATTR_NAME;
			return status;
		}
	} else if(ch === '=') {
		if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= ATTR_NAME_END|WAITING_ATTR_VAL_QUOTE;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch === '"') {
		if(status & WAITING_ATTR_VAL_QUOTE) {
			status ^= status & WAITING_ATTR_VAL_QUOTE;
			status ^= status & ATTR_NAME_END;
			status |= WAITING_ATTR_VAL;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL
			status |= ATTR_VAL_EMPTY;
			return status;
		} else if(status & WAITING_ATTR_VAL_ESC) {
			status ^= WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & (ATTR_VAL_START|ATTR_VAL_CONT)) {
			status ^= status & ATTR_VAL_START;
			status ^= status & ATTR_VAL_CONT;
			status |= ATTR_VAL_END;
			return status;
		}
	} else if(ch === '\\') {
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START|WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START; 
			status = status & WAITING_ATTR_VAL_ESC ? status ^ WAITING_ATTR_VAL_ESC : status | WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status = status & WAITING_ATTR_VAL_ESC ? status ^ WAITING_ATTR_VAL_ESC : status | WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else {
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	}

	return 0;
}

function parseHtml(text) {
	let status = 0;
	for(let i = 0; i < text.length; i++) {
		status = getNextStatus(text[i], status);

		if(status & TAG_START) {
			console.log('Начало тега');
		} else if(status & TAG_END) {
			if(status & NAME_END) {
				console.log('Конец имени тега');
			} else if(status & ATTR_NAME_END) {
				console.log('Конец имени атрибута');
			}
			console.log('Конец тега');
		} else if(status & NAME_START) {
			console.log(`Начало имени тега "${text[i]}"`);
		} else if(status & NAME_CONT) {
			console.log(`Продолжение имени тега "${text[i]}"`);
		} else if(status & NAME_END) {
			console.log('Конец имени тега');
		} else if(status & ATTR_NAME_START) {
			console.log(`Начало имени атрибута "${text[i]}"`);
		} else if(status & ATTR_NAME_CONT) {
			console.log(`Продолжение имени атрибута "${text[i]}"`);
		} else if(status & ATTR_NAME_END) {
			console.log('Конец имени атрибута');
		} else if(status & ATTR_VAL_START) {
			console.log(`Начало значения атрибута "${text[i]}"`);
		} else if(status & ATTR_VAL_CONT) {
			console.log(`Продолжение значения атрибута "${text[i]}"`);
		} else if(status & ATTR_VAL_END) {
			console.log('Конец значения атрибута');
		}
	}
}

Ну и давайте протестируем то что мы написали

Ввод

parseHtml('<div attr="name">')

Результат

Начало тега
Начало имени тега "d"
Продолжение имени тега "i"
Продолжение имени тега "v"
Конец имени тега
Начало имени атрибута "a"
Продолжение имени атрибута "t"
Продолжение имени атрибута "t"
Продолжение имени атрибута "r"
Конец имени атрибута
Начало значения атрибута "n"
Продолжение значения атрибута "a"
Продолжение значения атрибута "m"
Продолжение значения атрибута "e"
Конец значения атрибута
Конец тега

Ну и попытаемся запутать автомат

parseHtml('<div attr="<div attr=\\"test\\">">')

Результат

Начало тега
Начало имени тега "d"
Продолжение имени тега "i"
Продолжение имени тега "v"
Конец имени тега
Начало имени атрибута "a"
Продолжение имени атрибута "t"
Продолжение имени атрибута "t"
Продолжение имени атрибута "r"
Конец имени атрибута
Начало значения атрибута "<"
Продолжение значения атрибута "d"
Продолжение значения атрибута "i"
Продолжение значения атрибута "v"
Продолжение значения атрибута " "
Продолжение значения атрибута "a"
Продолжение значения атрибута "t"
Продолжение значения атрибута "t"
Продолжение значения атрибута "r"
Продолжение значения атрибута "="
Продолжение значения атрибута "\"
Продолжение значения атрибута """
Продолжение значения атрибута "t"
Продолжение значения атрибута "e"
Продолжение значения атрибута "s"
Продолжение значения атрибута "t"
Продолжение значения атрибута "\"
Продолжение значения атрибута """
Продолжение значения атрибута ">"
Конец значения атрибута
Конец тега

Ничего не получилось, автомат отработал верно

Определяем, закрывающийся ли это тег

Надеюсь, этот пункт будет немного полегче предыдущих

Дополняем статусы

const SINGLE_CLOSE_TAG = WAITING_ATTR_VAL_ESC * 2; // Одиночный закрывающийся тег
const NORMAL_CLOSE_TAG = SINGLE_CLOSE_TAG * 2; // Обычный закрывающийся тег
И дописываем автомат
function getNextStatus(ch, status) {
	if(ch === '<') {
		...
	} else if(ch === '>') {
		...
		} else if(status & SINGLE_CLOSE_TAG) {
			// если это одиночный закрывающийся тег, то сбрасываем флаги про атрибуты и устанавливаем флаг, что тег закрыт
			status ^= status & ATTR_NAME_END;
			status ^= status & ATTR_VAL_END;
			status ^= status & ATTR_VAL_EMPTY;
			status ^= status & NAME_END;
			status |= TAG_END;
			return status;
		}
	} else if(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z') {
		...
		} else if(status & NORMAL_CLOSE_TAG && !(status & TAG_END)) {
			// проверяем флаг установки закрытия тега и что мы еще внутри тега
			status |= NAME_START;
			return status;
		}
	} else if(ch >= '0' && ch <= '9' || ch === '-' || ch === '_') {
		...
	} else if(ch === ' ') {
		if(status & (NAME_START|NAME_CONT) && !(status & NORMAL_CLOSE_TAG)) {
			// дописываем условие проверкой на флаг, что тег на закрывающий
			...
		} else if(status & (ATTR_NAME_END|NAME_END|WAITING_ATTR_NAME) && !(status & SINGLE_CLOSE_TAG) && !(status & TAG_END)) {
			// тоже самое плюс проверка что мы еще внутри тега
			...
		...
		} else if(status & ATTR_VAL_END && !(status & SINGLE_CLOSE_TAG)) {
			// тоже самое
			...
		} else if(status & ATTR_VAL_EMPTY && !(status & SINGLE_CLOSE_TAG)) {
			// тоже самое
			...
		}
	} else if(ch === '=') {
		...
	} else if(ch === '"') {
		...
	} else if(ch === '\\') {
		...
	} else if(ch === '/') {
		// начинаем смотреть на слеш
		// учитываем, если читаем значение атрибута
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & TAG_START) {
			// если слеш идет сразу после открытия тега, выставляем флаг закрывающего тега
			status ^= TAG_START;
			status |= NORMAL_CLOSE_TAG;
			return status;
		} else if(status & (NAME_START|NAME_CONT)) {
			// если уже есть имя атрибута, выставляем флаг одиночного закрывающегося тега
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			status |= NAME_END|SINGLE_CLOSE_TAG;
			return status;
		} else if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			// тоже самое, если слеш идет сразу же после имени атрибута
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= ATTR_NAME_END|SINGLE_CLOSE_TAG;
			return status;
		} else if(status & (ATTR_NAME_END|ATTR_VAL_END|ATTR_VAL_EMPTY)) {
			// или его значения
			status ^= status & ATTR_NAME_END;
			status ^= status & ATTR_VAL_END;
			status ^= status & ATTR_VAL_EMPTY;
			status |= SINGLE_CLOSE_TAG;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			// или после пробела, который идет за атрибутом
			status ^= status & WAITING_ATTR_NAME;
			status |= SINGLE_CLOSE_TAG;
			return status;
		}
	} else {
		...
	}

	return 0;
}
Полная версия
const TAG_START = 1;
const TAG_END = TAG_START * 2;
const NAME_START = TAG_END * 2;
const NAME_CONT = NAME_START * 2;
const NAME_END = NAME_CONT * 2;
const ATTR_NAME_START = NAME_END * 2;
const ATTR_NAME_CONT = ATTR_NAME_START * 2;
const ATTR_NAME_END = ATTR_NAME_CONT * 2;
const WAITING_ATTR_NAME = ATTR_NAME_END * 2;
const ATTR_VAL_START = WAITING_ATTR_NAME * 2;
const ATTR_VAL_CONT = ATTR_VAL_START * 2;
const ATTR_VAL_END = ATTR_VAL_CONT * 2;
const ATTR_VAL_EMPTY = ATTR_VAL_END * 2;
const WAITING_ATTR_VAL = ATTR_VAL_EMPTY * 2;
const WAITING_ATTR_VAL_QUOTE = WAITING_ATTR_VAL * 2;
const WAITING_ATTR_VAL_ESC = WAITING_ATTR_VAL_QUOTE * 2;
const SINGLE_CLOSE_TAG = WAITING_ATTR_VAL_ESC * 2;
const NORMAL_CLOSE_TAG = SINGLE_CLOSE_TAG * 2;

function getNextStatus(ch, status) {
	if(ch === '<') {
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else {
			return TAG_START;
		}
	} else if(ch === '>') {
		if(status & TAG_START) {
			status ^= TAG_START;
			status |= TAG_END;
			return status;
		} else if(status & (NAME_START|NAME_CONT)) {
			status |= TAG_END|NAME_END;
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			status ^= WAITING_ATTR_NAME;
			status ^= status & NAME_END;
			status ^= status & ATTR_NAME_END;
			status |= TAG_END;
			return status;
		} else if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= ATTR_NAME_END|TAG_END;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & (ATTR_VAL_END|ATTR_VAL_EMPTY)) {
			status ^= status & ATTR_VAL_END;
			status ^= status & ATTR_VAL_EMPTY;
			status |= TAG_END;
			return status;
		} else if(status & SINGLE_CLOSE_TAG) {
			status ^= status & ATTR_NAME_END;
			status ^= status & ATTR_VAL_END;
			status ^= status & ATTR_VAL_EMPTY;
			status ^= status & NAME_END;
			status |= TAG_END;
			return status;
		}
	} else if(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z') {
		if(status & (NAME_CONT|ATTR_NAME_CONT)) {
			return status;
		} else if(status & TAG_START) {
			status ^= TAG_START;
			status |= NAME_START;
			return status;
		} else if(status & NAME_START) {
			status ^= NAME_START;
			status |= NAME_CONT;
			return status;
		} else if(status & ATTR_NAME_START) {
			status ^= ATTR_NAME_START;
			status |= ATTR_NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			status ^= status & WAITING_ATTR_NAME;
			status ^= status & ATTR_NAME_END;
			status ^= status & NAME_END;
			status |= ATTR_NAME_START;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & NORMAL_CLOSE_TAG && !(status & TAG_END)) {
			status |= NAME_START;
			return status;
		}
	} else if(ch >= '0' && ch <= '9' || ch === '-' || ch === '_') {
		if(status & (NAME_CONT|ATTR_NAME_CONT)) {
			return status;
		} else if(status & NAME_START) {
			status ^= NAME_START;
			status |= NAME_CONT;
			return status;
		} else if(status & ATTR_NAME_START) {
			status ^= ATTR_NAME_START;
			status |= ATTR_NAME_CONT;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch === ' ') {
		if(status & (NAME_START|NAME_CONT) && !(status & NORMAL_CLOSE_TAG)) {
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			status |= WAITING_ATTR_NAME|NAME_END;
			return status;
		} else if(status & (ATTR_NAME_END|NAME_END|WAITING_ATTR_NAME) && !(status & SINGLE_CLOSE_TAG) && !(status & TAG_END)) {
			status ^= status & ATTR_NAME_END;
			status ^= status & NAME_END;
			return status;
		} else if(status & (ATTR_NAME_CONT|ATTR_NAME_START)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= WAITING_ATTR_NAME|ATTR_NAME_END;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & ATTR_VAL_END && !(status & SINGLE_CLOSE_TAG)) {
			status ^= ATTR_VAL_END;
			status |= WAITING_ATTR_NAME;
			return status;
		} else if(status & ATTR_VAL_EMPTY && !(status & SINGLE_CLOSE_TAG)) {
			status ^= ATTR_VAL_EMPTY;
			status |= WAITING_ATTR_NAME;
			return status;
		}
	} else if(ch === '=') {
		if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= ATTR_NAME_END|WAITING_ATTR_VAL_QUOTE;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch === '"') {
		if(status & WAITING_ATTR_VAL_QUOTE) {
			status ^= status & WAITING_ATTR_VAL_QUOTE;
			status ^= status & ATTR_NAME_END;
			status |= WAITING_ATTR_VAL;
			return status;
		} else if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL
			status |= ATTR_VAL_EMPTY;
			return status;
		} else if(status & WAITING_ATTR_VAL_ESC) {
			status ^= WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & (ATTR_VAL_START|ATTR_VAL_CONT)) {
			status ^= status & ATTR_VAL_START;
			status ^= status & ATTR_VAL_CONT;
			status |= ATTR_VAL_END;
			return status;
		}
	} else if(ch === '\\') {
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START|WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START; 
			status = status & WAITING_ATTR_VAL_ESC ? status ^ WAITING_ATTR_VAL_ESC : status | WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status = status & WAITING_ATTR_VAL_ESC ? status ^ WAITING_ATTR_VAL_ESC : status | WAITING_ATTR_VAL_ESC;
			return status;
		}
	} else if(ch === '/') {
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		} else if(status & TAG_START) {
			status ^= TAG_START;
			status |= NORMAL_CLOSE_TAG;
			return status;
		} else if(status & (NAME_START|NAME_CONT)) {
			status ^= status & NAME_START;
			status ^= status & NAME_CONT;
			status |= NAME_END|SINGLE_CLOSE_TAG;
			return status;
		} else if(status & (ATTR_NAME_START|ATTR_NAME_CONT)) {
			status ^= status & ATTR_NAME_START;
			status ^= status & ATTR_NAME_CONT;
			status |= ATTR_NAME_END|SINGLE_CLOSE_TAG;
			return status;
		} else if(status & (ATTR_NAME_END|ATTR_VAL_END|ATTR_VAL_EMPTY)) {
			status ^= status & ATTR_NAME_END;
			status ^= status & ATTR_VAL_END;
			status ^= status & ATTR_VAL_EMPTY;
			status |= SINGLE_CLOSE_TAG;
			return status;
		} else if(status & WAITING_ATTR_NAME) {
			status ^= status & WAITING_ATTR_NAME;
			status |= SINGLE_CLOSE_TAG;
			return status;
		}
	} else {
		if(status & WAITING_ATTR_VAL) {
			status ^= WAITING_ATTR_VAL;
			status |= ATTR_VAL_START;
			return status;
		} else if(status & ATTR_VAL_START) {
			status ^= ATTR_VAL_START;
			status ^= status & WAITING_ATTR_VAL_ESC;
			status |= ATTR_VAL_CONT;
			return status;
		} else if(status & ATTR_VAL_CONT) {
			status ^= status & WAITING_ATTR_VAL_ESC;
			return status;
		}
	}

	return 0;
}

function parseHtml(text) {
	let status = 0;
	for(let i = 0; i < text.length; i++) {
		status = getNextStatus(text[i], status);

		if(status & TAG_START) {
			console.log('Начало тега');
		} else if(status & TAG_END) {
			if(status & NORMAL_CLOSE_TAG) {
				console.log('Закрывающийся тег');
			} else if(status & SINGLE_CLOSE_TAG) {
				console.log('Одиночный закрывающийся тег');
			}
			if(status & NAME_END) {
				console.log('Конец имени тега');
			} else if(status & ATTR_NAME_END) {
				console.log('Конец имени атрибута');
			}
			console.log('Конец тега');
		} else if(status & NAME_START) {
			console.log(`Начало имени тега "${text[i]}"`);
		} else if(status & NAME_CONT) {
			console.log(`Продолжение имени тега "${text[i]}"`);
		} else if(status & NAME_END) {
			console.log('Конец имени тега');
		} else if(status & ATTR_NAME_START) {
			console.log(`Начало имени атрибута "${text[i]}"`);
		} else if(status & ATTR_NAME_CONT) {
			console.log(`Продолжение имени атрибута "${text[i]}"`);
		} else if(status & ATTR_NAME_END) {
			console.log('Конец имени атрибута');
		} else if(status & ATTR_VAL_START) {
			console.log(`Начало значения атрибута "${text[i]}"`);
		} else if(status & ATTR_VAL_CONT) {
			console.log(`Продолжение значения атрибута "${text[i]}"`);
		} else if(status & ATTR_VAL_END) {
			console.log('Конец значения атрибута');
		}
	}
}

Посмотрим на результат выполнения

Ввод

parseHtml('<div />');

Результат

Начало тега
Начало имени тега "d"
Продолжение имени тега "i"
Продолжение имени тега "v"
Конец имени тега
Конец имени тега
Одиночный закрывающийся тег
Конец тега
parseHtml('</div>');
Начало тега
Начало имени тега "d"
Продолжение имени тега "i"
Продолжение имени тега "v"
Закрывающийся тег
Конец имени тега
Конец тега

А как же текст?

Наш автомат умеет многое: определять теги, их имена и атрибуты, понимать, закрывающийся это тег или нет. Осталось получить, что не относится к тегу, а именно текст.

Можно смотреть по прежнему на статус и если он равен нулю, понимать, что мы сейчас читаем текст. Но что будет, если откроется тег, и он окажется битым? Предлагаю переписать функцию обработки статуса конечного автомата.

Обновленная функция parseHtml
function parseHtml(text) {
	let status = 0;
	let textBufferList = [];
	let attributes = [];
	let attrName = '';
	let attrVal = '';
	let tagName = '';
	let textBufferItem = '';

	for(let i = 0; i < text.length; i++) {
		const ch = text[i];
		status = getNextStatus(ch, status);

		if(status & TAG_START) {
			if(textBufferItem) textBufferList.push(textBufferItem);
			if(textBufferList.length) console.log(`Текст: ${textBufferList.join('')}`);
			tagName = '';
			textBufferItem = ch;
			attrName = '';
			attributes = [];
			textBufferList = [];
		} else if(status & TAG_END) {
			textBufferItem = '';
			textBufferList = [];
			if(status & NORMAL_CLOSE_TAG) {
				console.log(`Закрывающий тег: ${tagName}`);
			} else if(status & SINGLE_CLOSE_TAG) {
				console.log(`Одиночный закрывающий тег: ${tagName}`);
			} else {
				console.log(`Открывающий тег: ${tagName}`);
			}
			if(attrName) {
				if(attrVal) {
					attributes.push({name: attrName, val: attrVal});
				} else {
					attributes.push({name: attrName});
				}
			}
			if(attributes.length) {
				console.log('Атрибуты');
				attributes.forEach(attr => {
					if(attr.val) {
						console.log(`	Имя: ${attr.name}, значение: ${attr.val}`);
					} else {
						console.log(`	Имя: ${attr.name}`);
					}
				});
			}
		} else if(status & NAME_START) {
			tagName = ch;
			textBufferItem += ch;
		} else if(status & NAME_CONT) {
			tagName += ch;
			textBufferItem += ch;
		} else if(status & ATTR_NAME_START) {
			if(attrName) {
				if(attrVal) {
					attributes.push({name: attrName, val: attrVal});
				} else {
					attributes.push({name: attrName});
				}
			}
			attrName = ch;
			attrVal = '';
			textBufferItem += ch;
		} else if(status & ATTR_NAME_CONT) {
			attrName += ch;
			textBufferItem += ch;
		} else if(status & ATTR_VAL_START) {
			attrVal = ch;
			textBufferItem += ch;
		} else if(status & ATTR_VAL_CONT) {
			attrVal += ch;
			textBufferItem += ch;
		} else {
			textBufferItem += ch;
		}
	}

	if(textBufferItem) textBufferList.push(textBufferItem);
	if(textBufferList.length) console.log(`Текст: ${textBufferList.join('')}`);
}

Попробуем скормить нашему автомату изначальный пример

Ввод

parseHtml('<div class="mb-4"><span>Text</span></div>');

Результат

Открывающийся тег: div
Атрибуты
	Имя: class, значение: mb-4
Открывающийся тег: span
Текст: Text
Закрывающийся тег: span
Закрывающийся тег: div

Попробуем подсунуть битый тег

parseHtml('<123div class="mb-4" />123<span title="Привет" data-test>мир</span>');

Результат

Текст: <123div class="mb-4" />123
Открывающий тег: span
Атрибуты
	Имя: title, значение: Привет
	Имя: data-test
Текст: мир
Закрывающий тег: span

Автомат отработал корректно и это все за один прогон цикла.

Заключение

То, что мы написали отдаленно напоминает SAX-парсер. Как видите, писать конечные автоматы в некоторых случаях, как, например, в этом не так уж тяжело, как это может показаться. Главное запастись терпением, быть внимательным и все получится.

Конечно в текущей реализации не хватает поддержки парсинга комментариев, одинарных кавычек в атрибутах и прочих мелочей. Но главной целью статьи было поделиться собственным опытом.

Спасибо всем за внимание.