12k
All articles

Теговые шаблонные литералы: создание мини-DSL на JavaScript

Tagged template literals в JavaScript: cooked и raw строки, кеш WeakMap, безопасный HTML, SQL и примеры мини DSL.

OpenReplay Team
OpenReplay Team
Теговые шаблонные литералы: создание мини-DSL на JavaScript

Теговый шаблонный литерал — это вызов функции, аргументы которой поступают из шаблонного литерала: во время выполнения среда разбивает литерал на части по каждой границе ${...}, передаёт статические строковые сегменты и вычисленные интерполяции в указанную вами функцию, позволяя ей вернуть что угодно. Именно в этом последнем пункте и заключается весь смысл. Когда вы пишете styled.div`...` или gql`...`, вы вызываете функцию, которая получает части шаблона и возвращает не строку, а React-компонент или разобранный GraphQL-документ. В этой статье подробно объясняется механика вызова, а затем на её основе создаются четыре рабочих мини-DSL, включая параметризованный SQL-тег, который по своей конструкции защищён от инъекций через значения.

Вы уже использовали этот синтаксис. Далее рассказывается о том, как он работает и как создавать собственные теги.

Ключевые выводы

  • Теговый шаблон tag`...` является синтаксическим сахаром для tag(strings, ...values), где strings — массив статических сегментов, а ...values — интерполированные выражения, вычисляемые слева направо.
  • Массив strings всегда содержит ровно на один элемент больше, чем valuesstrings.length === values.length + 1 — поскольку шаблонный литерал всегда начинается и заканчивается строковым сегментом, даже если он пустой.
  • Теговые функции не обязаны возвращать строки: styled-components возвращает React-компонент, graphql-tag — AST типа DocumentNode, а lit-htmlTemplateResult.
  • Поскольку один и тот же замороженный объект шаблона повторно используется при каждом вычислении конкретного тегового шаблона, WeakMap с ключом по массиву strings позволяет кешировать результаты разбора для данного литерала.
  • Безопасный SQL-тег предотвращает SQL-инъекции через значения, превращая интерполяции в параметры запроса вместо их конкатенации в текст SQL — при условии, что тег всегда параметризует значения и никогда не вставляет в запрос сырой SQL или идентификаторы.

Сигнатура вызова: tag`...` разворачивается в tag(strings, ...values)

Теговый шаблон является синтаксическим сахаром для обычного вызова функции. Выражение tag`Hi, ${name}!` эквивалентно вызову tag со статическими сегментами в качестве первого аргумента и каждым интерполированным значением в качестве последующих аргументов. Два приведённых ниже варианта примерно эквивалентны для иллюстрации сигнатуры вызова:

const name = "Zach";

// Теговый шаблонный литерал
tag`Hi, ${name}!`;

// Эквивалентный развёрнутый вариант
tag(["Hi, ", "!"], name);

Первый параметр — массив статических строковых сегментов, разбитых по каждому ${...}. Остальные аргументы — интерполированные значения, которые собираются с помощью rest-параметра. (Документация MDN по теговым шаблонам описывает эту форму вызова.)

function tag(strings, ...values) {
  console.log(strings); // ["Hi, ", "!"]
  console.log(values);  // ["Zach"]
}

const name = "Zach";
tag`Hi, ${name}!`;

Интерполяции вычисляются слева направо в момент вызова и передаются как реальные значения — без приведения к строке. Тег получает объект функции, массив или объект именно в том виде, в каком они записаны:

function tag(strings, ...values) {
  return values;
}

tag`${() => 42} and ${[1, 2, 3]}`;
// [ [Function], [1, 2, 3] ] — реальные значения, не строки

Это первое практическое отличие от обычного шаблонного литерала, который всегда приводит интерполяции к строкам и всегда возвращает строку. Тег перехватывает части до того, как произойдёт какое-либо приведение типов.

Инвариант strings.length === values.length + 1

Массив strings всегда содержит ровно на один элемент больше, чем массив valuesstrings.length === values.length + 1 — поскольку шаблонный литерал всегда начинается и заканчивается строковым сегментом, даже если этот сегмент является пустой строкой. Литерал с тремя интерполяциями имеет четыре строковых сегмента; литерал, начинающийся с ${x}, просто имеет пустую строку в качестве первого сегмента:

function tag(strings, ...values) {
  console.log(strings.length, values.length);
}

tag`${1}${2}`;
// strings: ["", "", ""]  →  3
// values:  [1, 2]        →  2

Именно этот инвариант делает свёртку по strings наиболее чистым способом чередования сегментов и значений: проходим по строкам и добавляем values[i] после каждой, кроме последней.

Обработанные и сырые строки: когда разница принципиальна

Массив strings содержит обработанную (cooked) интерпретацию, где \n — это перевод строки, а \t — табуляция. Свойство strings.raw содержит сырые (raw) исходные символы, где \n — это двухсимвольная последовательность из обратного слеша и буквы n. Это различие важно всякий раз, когда ваш DSL обрабатывает текст, в котором обратные слеши используются структурно — паттерны регулярных выражений, разметка LaTeX или пути к файлам в Windows.

function showBoth(strings) {
  console.log(strings[0]);     // обработанная: реальный перевод строки
  console.log(strings.raw[0]); // сырая: символы \ и n
}

showBoth`line\nbreak`;
// cooked → "line", затем "break" на следующей строке
// raw    → "line\nbreak"

В JavaScript есть ровно один встроенный тег для этой цели: String.raw. Он возвращает сырую интерпретацию литерала, поэтому управляющие последовательности сохраняются как буквальные символы. (Справочник MDN по String.raw документирует его как единственный встроенный теговый шаблон; он ведёт себя как тег-идентичность только тогда, когда литерал не содержит управляющих последовательностей.)

const winPath = String.raw`C:\Users\dev\project`;
console.log(winPath); // "C:\Users\dev\project" — обратные слеши сохранены

const pattern = new RegExp(String.raw`\d{3}-\d{4}`);
pattern.test("555-1234"); // true — двойное экранирование не нужно

Без String.raw источник регулярного выражения потребовал бы записи \\d{3}-\\d{4}, чтобы пережить обработку. Любой написанный вами тег может обеспечить аналогичное поведение, читая из strings.raw вместо strings.

Теговые функции могут возвращать что угодно

Теговая функция — это просто функция, и её возвращаемое значение определяется автором. Именно это единственное наблюдение превращает теговые шаблоны из любопытного инструмента форматирования строк в средство создания предметно-ориентированных языков. Статические сегменты и значения — это исходный материал; тег решает, что из них построить. styled-components возвращает React-компонент, graphql-tag — AST типа DocumentNode, а lit-htmlTemplateResult.

Четыре DSL ниже постепенно усложняются — от строкового вывода до объектов параметризованных запросов, демонстрируя типы возвращаемых значений, которые обычный шаблонный литерал никогда не сможет произвести.

Тег экранирования HTML (защита от XSS)

Тег экранирования HTML оборачивает каждое интерполированное значение в HTML-кодирование сущностей до того, как оно попадёт в DOM, поэтому пользовательский ввод не может выйти за пределы своего текстового контекста. Ошибка, которую он предотвращает: прямая интерполяция ненадёжных данных в разметку является вектором хранимой или отражённой XSS-атаки — полезная нагрузка в значении выполняется при вставке строки в DOM:

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = `<div>${userInput}</div>`;  
// element.innerHTML = markup → обработчик onerror выполняется

Тег safeHtml перехватывает каждую интерполяцию и экранирует её до попадания в вывод, оставляя статические сегменты — написанные разработчиком, а не пользователем — нетронутыми:

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function safeHtml(strings, ...values) {
  return strings.reduce((acc, segment, i) => {
    const value = i < values.length ? escapeHtml(values[i]) : "";
    return acc + segment + value;
  }, "");
}

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = safeHtml`<div>${userInput}</div>`;  
// "<div>&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;</div>" — безопасно

Асимметрия здесь принципиальна: статические сегменты считаются доверенными (вы их написали), интерполяции — нет (они могут содержать пользовательский ввод), и тег является единственным местом, где это различие применяется.

Тег безопасного SQL (параметризованные запросы по конструкции)

Тег безопасного SQL предотвращает SQL-инъекции через значения, превращая интерполяции в параметры запроса вместо их конкатенации в текст SQL. Версия запроса с конкатенацией строк — это классическая уязвимость SQL-инъекции: конкатенация значения в текст запроса позволяет этому значению изменить структуру запроса:

// НЕПРАВИЛЬНО — не использовать
const id = "1 OR 1=1";
const query = `SELECT * FROM users WHERE id = ${id}`;
// "SELECT * FROM users WHERE id = 1 OR 1=1" → возвращает все строки

Безопасный SQL-тег предотвращает SQL-инъекции через значения, превращая интерполяции в параметры запроса вместо их конкатенации в текст SQL — при условии, что тег всегда параметризует интерполированные значения и никогда не вставляет в запрос сырой SQL или идентификаторы. Тег возвращает объект { text, values }: поле text содержит плейсхолдеры ($1, $2, …), а values — фактические данные, хранящиеся отдельно от структуры запроса:

function sql(strings, ...values) {
  const text = strings.reduce((acc, segment, i) => {
    return acc + segment + (i < values.length ? `$${i + 1}` : "");
  }, "");
  return { text, values };
}

const id = "1 OR 1=1";
const query = sql`SELECT * FROM users WHERE id = ${id}`;
// {
//   text: "SELECT * FROM users WHERE id = $1",
//   values: ["1 OR 1=1"]
// }

Структура { text, values } — это именно то, что node-postgres принимает в качестве параметризованного запроса: client.query(query) передаёт текст запроса и значения параметров в PostgreSQL раздельно, и база данных никогда не интерпретирует значение параметра как SQL-синтаксис. Вредоносная строка "1 OR 1=1" обрабатывается как единственное значение параметра, а не как SQL-синтаксис, поэтому она не может стать частью структуры запроса.

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

Тег i18n с кешированием по идентичности через WeakMap

Поскольку один и тот же замороженный объект шаблона повторно используется при каждом вычислении конкретного тегового шаблона — поведение, определённое в спецификации ECMAScript — WeakMap с ключом по массиву strings позволяет кешировать результаты разбора для данного литерала: разобрать его один раз и повторно использовать кешированный результат при последующих вычислениях. Именно этот приём с идентичностью позволяет библиотекам на основе шаблонов избегать повторного разбора одного и того же шаблона при каждом рендере.

Повторное использование можно наблюдать непосредственно. Один и тот же массив strings является референциально идентичным при разных вызовах одного и того же тегового шаблона:

let first, second;
function capture(strings) {
  return strings;
}

function render() {
  return capture`hello ${1} world`;
}

first = render();
second = render();
console.log(first === second); // true — один и тот же замороженный объект массива

Это можно проверить в любой консоли браузера или REPL Node.js; это определённое спецификацией повторное использование объекта шаблона, а не особенность реализации. (Семантика вычисления шаблонов в спецификации ECMAScript определяет, что для данного шаблонного литерала возвращается один и тот же объект шаблона.)

WeakMap с ключом по strings превращает это в кеш. Здесь тег i18n вычисляет строку формата один раз для каждого литерала и повторно использует её:

const cache = new WeakMap();

function t(strings, ...values) {
  let key = cache.get(strings);
  if (key === undefined) {
    // дорогостоящая работа выполняется один раз для каждого уникального литерала
    key = strings.join("{}");
    cache.set(strings, key);
  }
  return lookupTranslation(key, values);
}

WeakMap удерживает свои ключи слабо, поэтому записи кеша становятся доступными для сборки мусора, как только на объект шаблона больше нет ссылок — без ручного вытеснения, без коллизий строковых ключей.

Тег стилей CSS-in-JS

Тег стилей может возвращать простой объект, который потребитель компонует в другом месте, воспроизводя принцип работы styled-components и Emotion, которые превращают CSS-литерал в нечто, пригодное для использования в компонентной системе. Вместо возврата строки тег разбирает объявления в объект «ключ — значение»:

function css(strings, ...values) {
  const cssText = strings.reduce(
    (acc, segment, i) => acc + segment + (values[i] ?? ""),
    ""
  );
  return Object.fromEntries(
    cssText
      .split(";")
      .map((d) => d.trim())
      .filter(Boolean)
      .map((d) => {
        const [prop, val] = d.split(":").map((s) => s.trim());
        return [prop, val];
      })
  );
}

const styles = css`
  display: flex;
  align-items: center;
`;
// { display: "flex", "align-items": "center" }

Это и есть суть реального подхода. В styled-components вызов styled.div`...` возвращает React-компонент; документация styled-components описывает полный API. Механика идентична приведённому выше примеру — теговая функция потребляет CSS-литерал и возвращает не строку, а значение, с которым умеет работать фреймворк.

Реальные библиотеки, построенные на теговых шаблонах

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

БиблиотекаЧто тегируетсяЧто возвращаетсяОбласть применения
styled-componentsCSSReact-компонентCSS-in-JS с областью видимости компонента
EmotionCSSИмя класса / styled-компонентCSS-in-JS с API объектов и теговых шаблонов
lit-htmlHTMLTemplateResultЭффективный рендеринг DOM
graphql-tagGraphQLAST типа DocumentNodeРазбор запросов для GraphQL-клиентов
sql-template-stringsSQLОбъект подготовленного запросаПараметризованный SQL (примечание: активная поддержка прекращена)
common-tagsТекстФорматированные строкиПереиспользуемые теговые функции для шаблонных литералов ES2015+

Сигнатура TypeScript для теговой функции

Сигнатура TypeScript для теговой функции имеет вид function tag(strings: TemplateStringsArray, ...values: unknown[]): T, где TemplateStringsArray типизирован как ReadonlyArray<string> с дополнительным членом readonly raw: readonly string[]. Этот тип встроен в стандартную библиотеку TypeScript и не требует импорта.

// Обобщённая форма теговой функции
type Tag<T> = (
  strings: TemplateStringsArray,
  ...values: unknown[]
) => T;

// Применительно к тегу safe-sql:
interface SqlQuery {
  text: string;
  values: unknown[];
}

function sql(strings: TemplateStringsArray, ...values: unknown[]): SqlQuery {
  const text = strings.reduce(
    (acc, segment, i) => acc + segment + (i < values.length ? `$${i + 1}` : ""),
    ""
  );
  return { text, values };
}

TemplateStringsArray определён в lib.es5.d.ts и присутствует в TypeScript с ранних версий. (Примечания к выпуску TypeScript 1.5 используют TemplateStringsArray в примере с теговым шаблоном.) Обратите внимание, что моделирование readonly здесь — это описание на уровне типов: заморозка объекта шаблона во время выполнения является отдельным поведением ECMAScript, которое не определяется типом TypeScript.

Когда не стоит использовать теговые шаблоны

Если ваш DSL требует настоящей грамматики — приоритета операторов, предпросмотра или рекурсивных структур — теговые шаблоны не подходят для этой задачи. Теговая функция получает плоский список строковых сегментов и вычисленных значений, а не дерево разбора, поэтому всё, что требует большего, чем вычисление интерполяций слева направо, относится к области комбинаторов парсеров или PEG-грамматик. Обращайтесь к специализированным парсерам, таким как nearley или Peggy, когда сталкиваетесь с полноценным языком.

Три конкретных признака того, что вы переросли теговые шаблоны:

  1. Вам нужна грамматика. Приоритет операторов, вложенные области видимости или возврат с перебором вариантов не могут быть выражены как плоское разбиение на строки и значения. Используйте парсер.
  2. Порядок вычислений важен и не является строго левосторонним. Интерполяции вычисляются слева направо в момент вызова, до запуска тега. Тег не может откладывать или переупорядочивать их — он видит только результаты.
  3. Вы находитесь внутри горячего цикла. Интерполированные выражения вычисляются при каждом вызове, даже несмотря на то, что один и тот же объект шаблона используется повторно, поэтому дорогостоящая интерполяция внутри плотного цикла рендеринга несёт свои затраты на каждой итерации. Массив strings кешируется по идентичности; значения — нет.

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

Начните с тега безопасного SQL. Он достаточно мал, чтобы прочитать его за один раз, возвращает объект { text, values }, совместимый с node-postgres, и демонстрирует свойство, ради которого стоит использовать теговые шаблоны: значение, пересекающее границу ${...}, может быть принудительно помещено в безопасную роль, которую конкатенация строк никогда не сможет гарантировать.

Часто задаваемые вопросы

Почему strings.length всегда на единицу больше, чем values.length в теговом шаблоне?

Шаблонный литерал всегда начинается и заканчивается строковым сегментом, даже если этот сегмент пустой, поэтому статические сегменты обрамляют каждую интерполяцию с обеих сторон. При n интерполяциях существует n плюс 1 строковых сегментов, что даёт инвариант strings.length === values.length + 1. Литерал вида tag`${x}` порождает пустую строку перед интерполяцией и пустую строку после неё, поэтому две интерполяции дают три строковых сегмента.

В чём разница между обработанными и сырыми строками в теговой функции?

Обработанный массив, доступный как strings, интерпретирует управляющие последовательности: \n становится реальным переводом строки, а \t — табуляцией. Сырой массив, доступный как strings.raw, сохраняет буквальные исходные символы, поэтому \n остаётся двумя символами — обратным слешем и n. Это различие принципиально, когда DSL обрабатывает текст, в котором обратные слеши используются структурно — например, паттерны регулярных выражений, разметка LaTeX или пути к файлам в Windows, где обработка исказила бы входные данные.

Работают ли теговые шаблонные литералы в TypeScript без дополнительной настройки?

Да. Сигнатура теговой функции имеет вид function tag(strings: TemplateStringsArray, ...values: unknown[]): T, где TemplateStringsArray определён в файле стандартной библиотеки TypeScript lib.es5.d.ts и не требует импорта. Он типизирован как ReadonlyArray строк с дополнительным членом readonly raw. Моделирование readonly является только типовым; заморозка объекта шаблона во время выполнения — это отдельное поведение ECMAScript, которое тип TypeScript не обеспечивает.

Может ли теговая функция возвращать что-то, кроме строки?

Да. Теговая функция является обычной функцией и может возвращать любое значение по выбору автора. styled-components возвращает React-компонент, graphql-tag — AST типа DocumentNode, а lit-html — TemplateResult. Безопасный SQL-тег может возвращать объект с полями text и values, совместимый с node-postgres. Обычные шаблонные литералы всегда приводятся к строке, но тег перехватывает сегменты и значения до приведения типов, поэтому тип возвращаемого значения полностью определяется реализацией.

We use cookies to improve your experience. By using our site, you accept cookies.