November 1, 2024

PostBuild проверки на target сборки приложения 

Одной из существующих проблем современной Frontend разработки является сборка нашего приложения под разные окружения.

С одной стороны у нас появился современный стандарт EcmaScript, множество новых браузерных API. А с другой у нас есть поддержка старых браузеров.

И казалось бы, ну а в чем тут проблема? Мы хотим пользоваться всеми современными возможностями JavaScript уже сейчас и инструменты сборки нам это позволяют.

С помощью использования core-js и транспайлинга кода мы можем на этапе сборки генерировать валидный код для старых браузеров.

Например, следующий код

let a = 5;
const b = 7;
const sum = (a, b) => a + b;

После транспайлинга превратится в

var a = 5;
var b = 7;
var sum = function sum(a, b) {
  return a + b;
};

Пропуская код через сборку мы можем быть уверены, что все будет работать и в старых браузерах. В зависимости от наших задач мы используем в качестве target сборки конкретную версию EcmaScript. Например, для старых браузеров это 5 версия, а для более современных 2015 или выше. Именно так, например, работает target сборки TS:

tsconfig.json

{
  "": {
    "target": "es5"
  }
}

Однако у всей этой схемы есть недостаток - в нашем проекте всегда есть код, который мы подключаем в обход сборочного пайплайна. Самый простой пример это зависимости из node_modules, они уже поставляются предсобранными, либо какой-то js код мы можем вставлять в наш бандл as is, это могут быть какие-то очень простые скрипты не нуждающиеся в сборке.

Проблемы

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

Простой пример такой ситуации, это маленький скрипт который занимается инициализацией рекламы:

initAdWords('adWordsId');

Он очень простой и его нужно вставлять в самое-самое начало html, поэтому нет смысла прогонять его через сборку

<html>
  <head>
    <script>initAdWords('adWordsId');</script>
  </head>
</html>

Представим что мы собираемся в target ES5, и появилась необходимость добавить некоторую простую логику в этот счетчик

let isLocalhost = window.location.includes('localhost');

if (!isLocalhost) {
  initAdWord('adWordsId');
}

На первый взгляд проблемы нет, но если посмотреть внимательнее мы увидим let, а это уже ES2015 синтаксис. Разработчики уже привыкли использовать современные возможности языка, а на ревью никто не заметил или не вспомнил, что этот код не собирается, а инлайнится как есть. В итоге мы либо заметим проблему на этапе тестирования, что еще хорошо, либо сломаем страницу некоторой части пользователей в продакшене: там будет белый экран в самом начале инициализации js и возможно мы вообще об этом не узнаем (поэтому код обработки исключений на странице всегда должен быть раньше всего, еще до инициализации рекламы/аналитики/основого кода приложения).

Более сложная проблема - зависимости тоже подключаются в конечный бандл как есть. Как правило, они поставляются предсобранными в конкретный таргет и сборщик попросту не анализирует их (вспомните как у babel мы всегда пишем exclude: 'node_modules'). Например, внутри NPM пакета Vue есть папка dist, где находятся предсобранные версии библиотеки. И понять в какой target они собраны довольно затруднительно.

Варианты

Для простых инлайн скриптов первое решение которое приходит в голову это все таки добавлять их в сборку. Это неплохой вариант, но выглядит как overkill: нужно не забывать добавлять новые подобные скрипты, это должен быть отдельный entrypoint, туда может добавиться еще и рантайм который там не нужен и все это ради того, чтобы превратить один let в var.

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

И тут к нам на помощь внезапно спешит Eslint, но не совсем в привычном сценарии запуска.

PostBuild проверки

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

Мне же сразу в голову пришел Eslint, у него в аргументах есть возможность указать версию парсера:

// eslint.config.js
export default [
  {
     languageOptions: {
       ecmaVersion: 5
      }
  }
];

Или через CLI аргумент:

npx eslint --no-eslintrc --parser-options ecmaVersion:5

И я попросту запустил eslint с этим аргументом на собранные для прода ассеты:

npx eslint --no-eslintrc --parser-options ecmaVersion:5 ./dist

И получил точное место где был невалидный синтаксис.

/project/dist/bundle.js
  256:18  error  Parsing error: The keyword 'const' is reserved

Затем я затаскивал эту проверку во все проекты куда приходил, в качестве CI джобы которая запускается после сборки. По сути она заключается в одной простой команде и выполняется всего за несколько секунд!

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

Идея проверять уже собранный код крепко засела в моей голове и затем я добавлял еще несколько проверок подобного рода обычно давая этой группе название postBuild.

Со временем мне так же удалось найти инструменты, которые серьезно улучшают эту проверку.

Валидируем собранный код

В базовом варианте проверка показывала себя уже довольно неплохо: у нее не бывает false positive срабатываний; у нее понятный сигнал - если она упала, значит с кодом реально что-то не так. Однако оставалась проблема с тем, что сообщение об ошибке выглядит не очень информативным, плюс как оказалось есть кейсы которые она не учитывала:

/project/dist/bundle.js
  184:10  error  Parsing error: Unexpected token *

Для того чтобы улучшить в этом месте UX есть плагин eslint-plugin-es-x. Он делает ровно то же самое, что и ecmaVersion, но выдает читаемую ошибку, поэтому что пошло не так можно понять попросту взглянув на отчет:

/project/dist/bundle.js
  576:12  error  ES2016 exponential operators are forbidden   es-x/no-exponential-operators
  175:9  error  ES2015 block-scoped variables are forbidden  es-x/no-block-scoped-variables
  683:10  error  ES2015 block-scoped variables are forbidden  es-x/no-block-scoped-variables

Для этого подключаем в конфиге eslint этот плагин и включаем нужное правило для нашего target (есть пресеты под все esX стандарты)

const eslintPluginExample = require("./my-eslint-plugin");
const pluginESx = require('eslint-plugin-es-x');

module.exports = [
  {
    ...pluginESx.configs['flat/restrict-to-es5']
  }
]

Но тут в дело вступают нюансы.

Транспайлинг и полифиллы

Когда мы преобраузем наш код под старые окружения мы должны сделать 2 действия:

1. Затранспайлить код, то есть перевести неподдерживаемые синтаксические возможности языка под старую версию

let a = 5 => var a = 5
const d = 5 => var d = 5
const c = () => {} => function c() {}

2. Добавить полифиллы для неподдерживаемых методов прототипов и сущностей:

array.flat() => Array.prototype.flat = function()...;
new Promise() => globalThis.Promise = new Class()...;

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

Представьте что вам нужно самим реализовать этот синтаксис никак не трогая при этом исходный код.

// some code

var f = [5, [4]].flat();

Например, встроенный метод flat вполне можно реализовать своими силами и добавить в прототип перед вызовом кода:

array.prototype.flat = function() {}

var f = [5, [4]].flat();

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

// some code ?

let a = 5;

Нюанс же заключается в том, что eslint с аргументом ecmaVersion проверяет лишь те сущности языка, которые нельзя затранспайлить. То есть если у вас в коде есть let, const, function* и прочие новые возможности языка, то он выдаст ошибку (с точки зрения парсера это неизвестные токены и он попросту не может построить AST дерево кода). А вот все остальные возможности он проверить, увы, не может и у вас остается другая проблема - отсутствие полифилла для неподдерживаемой функциональности языка.

Eslint-plugin-es-x же как раз умеет проверять и второй вариант с помощью опции aggresive. При включенной настройке он проверяет использование встроенных методов и ругается на вызовы array.flat(), Object.assign и так далее.

Но на самом деле в большинстве современных сборок используется автоматическая доставка полифиллов под нужный таргет (так например по умолчанию делает typescript), поэтому эта проверка будет иметь для вас смысл только если вы управляете доставкой полифиллов вручную (например сами импортите полифиллы из core.js `import 'core-js/es/array/flat').

Browserslist

Ситуация становится сложнее если вы для сборки используете browserslist. Browserslist это технология, которая позволяет вам использовать в качестве target вашей сборки конкретные версии браузеров.

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

Далее вы указываете в конфиге browserslist что вам нужно собрать ваш код под chrome 55

`chrome >= 55`

Browserslist конфиг умеют учитывать почти все современные сборщики: babel, swc, postcss (увы typescript не умеет). Так и в чем разница спросите вы? А в том, что target в виде браузера позволяет точнее выполнить транспайлинг и подключить полифиллы, которых реально нет в браузере, давайте сравним это с традиционным target.

Для примера возьмем синтаксис возведения в степень: **, var f = 5 ** 5. Знали ли вы, что этот синтаксис это аж ES2016 с точки зрения спецификации языка и если вы указываете target ES6 то этот синтаксис будет преобразован в Math.pow(), однако если посмотреть на реальную поддержку этой фичи в браузерах, то она появилась уже в 52 хроме (для информации о поддержке той или иной возможности языка рекомендую использовать https://caniuse.com).

То есть указание browserslist позволяет вам выполнять меньше преобразований кода и доставлять меньше полифиллов пользователю тем самым уменьшая размер бандла и количество исполняемого кода.

Однако при использовании browserslist появляется проблема - теперь проверка через ecmaVersion или eslint-plugin-es-x нам не подходит, им нужна именно версия ecmaVersion, а с browserslist мы мыслим в парадигме версии браузера, а не языка.

Собираем проверку под browserslist

EcmaVersion аргумент из eslint нам теперь совсем не подходит, однако для eslint-plugin-es-x мне удалось найти решение это готовый конфиг - eslint-config-target-es. Все это вместе работает довольно причудливым образом. Для начала устанавливаем конфиг:

npm i -D eslint-plugin-es-x @automattic/eslint-config-target-es

Затем для проверки в CI я обычно собираю отдельный скрипт, который используя node api eslint делает необходимую проверку:

const {ESLint} = require('eslint');

const eslint = new ESLint({
  useEslintrc: false,
  ignore: false,
  resolvePluginsRelativeTo: __dirname,
  baseConfig: {
    parserOptions: { ecmaVersion: 'latest' },
    plugins: ['es-x']
  }
})

И здесь вместо указания конкретного пресета для es2018/es2015 мы собираем список нужных правил под наш browserslist таргет с помощью готового хелпера getRules:

const {ESLint} = require('eslint');
const getRules = require('@automattic/eslint-config-target-es/functions');

const rules = getRules({query, builtins: false});

const eslint = new ESlint({
  useEslintrc: false,
  ignore: false,
  resolvePluginsRelativeTo: __dirname,
  baseConfig: {
    parserOptions: { ecmaVersion: 'latest' },
    plugins: ['es-x'],
    rules
  }
})

В качестве аргумента query мы как раз и передаем browserslist выражение и библиотека собирает кастомный список правил для линтинга под конкретный браузер.

Таким образом мы получили валидный линтер под browserslist target и понятный лог ошибок.

One more thing

Еще одна полезная postBuild проверка которую можно положить тут же это проверка на использование браузерных API. В отличие от API самого JavaScript для автоматической доставки API браузера нет никакого централизованного решения, обычно их доставкой управляют вручную (какая-то часть есть в core-js, но далеко не всё).

Для этого есть eslint-plugin-compat.

Устанавливаем пакет:

npm i -D eslint-plugin-compat

И точно так же подключаем в нашу готовую проверку:

const {ESLint} = require('eslint');
const getRules = require('@automattic/eslint-config-target-es/functions');

const rules = getRules({query, builtins: false});

const eslint = new ESlint({
  useEslintrc: false,
  ignore: false,
  resolvePluginsRelativeTo: __dirname,
  baseConfig: {
    parserOptions: { ecmaVersion: 'latest' },
    plugins: [
      'es-x',
      'compat'
    ],
    rules,
    settings: {
      polyfills: [
        'Promise',
        'navigator',
        // ...
      ]
    }
  }
})

В отличие от eslint-plugin-es-x эта библиотека как раз сравнивает browserslist таргет и используемый API браузера. Её есть смысл использовать и как обычную проверку во время написания кода, так и postBuild проверку, потому что вам все так же необходимо валидировать собранные ассеты.

При этом список полифиллов для которых у вас уже есть полифиллы нужно вести вручную в поле settings.

Итого

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