← Блог

Рендеринг React в console.log

Text.js рисует приложение React в консоли DevTools одним стилизованным вызовом console.log на кадр.

Adam

Text.js рендерит React-приложения в консоли Chrome DevTools. Кроме скрытого поля ввода, на странице нет интерфейса. Каждый кадр приложения — один вызов console.log.

Консоль как дисплей

console.log принимает строку формата. Каждый %c применяет CSS из следующего аргумента к тексту после него, вплоть до следующего %c:

console.log("%c▼ Text.js%c ready", "color:#ededed;font-weight:700", "color:#45a557");

Эти стили позволяют задавать цвета, фон, полужирное и курсивное начертание и подчёркивание, а шрифт консоли — моноширинный (Menlo в macOS). Благодаря моноширинному шрифту и цветам, которые могут меняться для каждой последовательности символов, консоль работает как сетка символьных ячеек со стилями. На ней можно рисовать границы, панели, поля ввода и графики.

Кадр как сетка ячеек

Рендерер хранит каждый кадр в CellBuffer. Символы находятся в одном типизированном массиве, цвета текста и фона — ещё в двух, а атрибуты вроде полужирного начертания и подчёркивания — в четвёртом. Массивы выделяются один раз для заданного размера и повторно используются для каждого кадра.

Чтобы вывести кадр, драйвер обходит каждую строку и объединяет соседние ячейки с одинаковым стилем в последовательности. Каждая последовательность становится одним span с %c, а строки соединяются через \n. Наша тестовая панель размером 100×32, то есть 3200 ячеек, кодируется примерно в 200 span.

Один вызов на кадр

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

Сначала в тексте нужно экранировать две вещи. Символ % записывается как %%, чтобы текст вроде 100% не мог поглотить аргумент стиля. DevTools ещё и превращает URL в ссылки и перекрашивает их в свои цвета, поэтому в :// и www. добавляется word joiner (U+2060), у которого нет ширины:

export function escapeConsoleText(text: string, allowLink = false): string {  let s = text;  if (s.includes("%")) s = s.replaceAll("%", "%%");  if (!allowLink) {    if (s.includes(LINK_SCHEME)) s = s.replaceAll(LINK_SCHEME, LINK_SCHEME_SAFE);    if (s.includes(WWW) || s.includes("WWW.")) s = s.replace(/www\./gi, (m) => `${m.slice(0, 3)}\u2060.`);  }  return s;}

Кадры без изменений без вывода в консоль

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

Если строка изменилась, драйвер очищает консоль и выводит новый кадр:

const { frame, dirtyRows } = this.encode(buffer, options);if (frame === null) return SKIPPED;if (this.clearEachFrame) target.clear();target.log(frame.format, ...frame.args);

Повторно кодируются только строки, у которых изменился хеш. Остальные повторно используют span из предыдущего кадра.

Ввод

Консоль не получает нажатия клавиш, поэтому их собирает страница. Скрытый <textarea> принимает нажатия клавиш, вставку и композицию IME и превращает их в события клавиатуры для приложения. Щелчок по странице переводит на него фокус.

Дальнейшие планы

Прототипы @textjs/core 0.0.x в npm содержат всё в одном пакете. Text.js 0.1 переносит описанный здесь рендерер в @textjs/core, реконсилер React в @textjs/react и добавляет App Router.