Однажды я захотел создать небольшую NPM библиотеку по всем “best practices” - с покрытием тестами, написанием документации, ведением нормального версионирования и changelog'а и т.п. Даже написал пару статей, которые в деталях описали, какие вопросы решает библиотека и как её использовать. И одной из интересных для меня меня задач при создании библиотеки была задача по максимальному уменьшению размера выходного NPM пакета - того, что в конечном итоге в теории будет использовать другой программист. И в этой статье я бы хотел описать, к каким методам я прибегал для того, чтобы достигнуть желанной цели.

Сам я считаю, что разработчики NPM пакетов должны уделять особое внимание тому, какой размер конечного пакета они предоставляют другим разработчикам. А все потому что если разработчики конечного продукта будут использовать даже самые продвинутые минификаторы, они не всегда смогут достичь максимальной оптимизации объема выходного файла, в случае если разработчики NPM пакетов сами не постараются.

Часть первая. Общие советы

Статью я решил разбить на две части. В первой я расскажу про общие советы - настройку зависимостей, сборочного процесса и т.п. К ним прибегать нужно обязательно. А во второй я опишу то, как можно писать код, чтобы сделать пакет ещё меньше. И в ней часть советов как раз будут именно про “экстремальное” уменьшение - к которому прибегать вовсе не обязательно, т.к. оно может повлиять на читаемость вашего кода, а значит усложнить дальнейшую разработку, но в конечном итоге способно позитивно повлиять на размер вашего пакета.

Импорт сторонних пакетов

Начнем с самого простого и понятного. Есть несколько простых правил по тому, как вы должны импортировать код сторонних пакетов.

Во-первых, если вы создаете такую библиотеку, в которой вы уверены, что и вы, и разработчик конечного продукта будут использовать определенный сторонний пакет, вы должны пометить его как внешнюю зависимость при сборке. Например, если вы разрабатываете UI Kit для React приложений, вы должны пометить 'react' внешней зависимостью. Ниже я приложил пример по настройке зависимостей в Rollup.

Пример
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

export default {
  input: 'main.js',

  output: {
    file: 'bundle.js',
    format: 'iife',
    name: 'MyModule'
  },

  plugins: [
    // nodeResolve и commonjs необходимы для того,
    // чтобы совершать фактический импорт в ваш пакет
    nodeResolve(),
    commonjs(),
  ],

  // А в externals можно указать те пакеты, которые
  // фактически импортировать в ваш пакет нельзя
  external: [
    'react',
  ],
};

Во-вторых, импортов в вашей библиотеке должно быть минимальное количество. Об этом далее.

Полифилы

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

При этом могут быть ситуации, когда наличие некоторых полифилов критически важно для правильного функционирования вашего пакета. Например, если в нем используются декораторы, весьма вероятно, что вам потребуется пакет 'reflect-medata' Но даже в таком случае импортировать самостоятельно ничего нужно. Лучше опишите в документации своего пакета то, что он должен использоваться совместно с определенным полифилом. Ответственность за импорт полифилов всегда должна ложиться на плечи разработчика конечного продукта.

Что же делать, когда вы используете TypeScript, и в вашем пакете нужна типизация из полифила? Тот же 'reflect-metadata', например, расширяет типизацию объекта Reflect. И может показаться, что раз импортировать пакет нельзя, то и получить из него типизацию не получится. Но нет, импортировать типизацию, не импортируя JavaScript код, вполне возможно.

Для этого достаточно создать файл с расширением *.d.ts, в котором вы должны импортировать полифил, а затем вы должны указать этот файл в вашем tsconfig.json. И voilà! В вашем проекте появилась нужная типизация, а кода из стороннего пакета не будет, т.к. файлы с расширением *.d.ts в принципе не могут генерировать JavaScript код.

Пример импорта типизации без импорта JavaScript кода

global.d.ts

import 'reflect-metadata';

tsconfig.json

{
  "compilerOptions": {
    ...
  },
  "include": [
    "src",
    // Важно указать ваш созданный d.ts файл
    "global.d.ts"
  ],
}

Утилитарные библиотеки

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

Взгляните на пример ниже. В функциональном плане я написал 2 одинаковых импорта. Однако, в первом случае после импорта размер пакета увеличивается на 70 Кбайт, а во втором всего лишь на 73 байта. Почти в 1000 раз меньше. А все потому, что в первом случае вы импортируете всю библиотеку целиком, а во втором лишь определенный файл.

import { find } from 'lodash';
import find from 'lodash/find';

В примере я использовал 'lodash', чего я в принципе не рекомендую делать при разработке NPM пакета в 2023 году. Однако, этот пример хорошо подходит для описания проблемы импортов.

Тут важно знать пару простых правил.

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

Во-вторых, старайтесь использовать библиотеки, в которых доступен механизм Tree Shaking. Этот механизм позволит удалить импортированный сторонний код, который по факту не используется. Если говорить упрощенно, то каждый раз, когда вы пишете import { … } from 'package'. Вы ссылаетесь на файл, которых содержит все экспортируемые сущности библиотеки - функции, классы и т.п. А значит в реальности в ваш конечный бандл попадают сразу все эти сущности, даже если импортируете только одну функцию. Но благодаря Tree Shaking на этапе компиляции в production версии не используемые импорты просто напросто удаляются. Доступен же этот механизм, если используемый пакет собран в формате ESM, т.е. использующий синтаксис import/export

В-третьих, если пакет собран все-таки не в формате ESM, постарайтесь импортировать только нужный код, как я сделал в своем примере. 'lodash' поступил весьма гуманно и разбил функции на отдельные файлы. И если вы можете импортировать только нужный файл, то так и делайте.

В-четвертых, если вы хотите использовать пакет, который написан в формате CommonJS и который состоит всего из одного файла, то это плохой пакет. Не используйте его. Разработчик этого пакета не подумал о том, как его будут использовать другие разработчики. Вернитесь к первому пункту и сами напишите или скопируйте функцию. Но! сейчас разумеется, речь идет лишь о тех пакетах, где помимо нужной вам функциональности во внешнем пакете есть ненужный код. Если вам нужна вся библиотека целиком, вы можете таким не страдать.

Минификаторы

Если вы все ещё не знаете, что такое минификатор, то вот его объяснение вкратце - он нужен для уменьшения размера JavaScript файла.

Демон таится в деталях. Сейчас уже существует несколько популярных минификаторов, и они продолжают появляться: более привычные - написанные на JavaScript - Terser и UglifyJS, есть даже у Babel своя версия минификатора, ещё есть более современные SWC (написан на Rust) и ESBuild (написан на Go), и куча других менее известных минификаторов. И я рекомендую вам посмотреть на этот репозиторий. В нем содержатся актуальные результаты тестирования различных популярных минификаторов.

А для ленивых я опишу краткую характеристику этих тестов.

  • Разные минификаторы могут дать разный выигрыш по объему памяти. Разница в выигрыше при этом в среднем составляет у топ 5 минификаторов составляет 1-2%, но в целом разница может достигать и 10%.

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

Если вы являетесь разработчиком конечного продукта, читаете эту статью для расширения кругозора и вам интересно, какой же лучше тогда выбрать минификатор, то я рекомендую вам спросить себя, а лучше ещё парочку людей, важен ли этот 1-2% в размере выходного бандла. Если да, то можете поиграться с разными минификаторами и найти, какой из них предоставит вам максимальное сжатие.

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

Так думал я. И как же я ошибался... Я начинал разрабатывать пакет с минификатором Terser, но потом попробовал использовать SWC. И оказалось, что Terser генерирует лишние скобки для некоторых выражений, т.к. по умолчанию он, руководствуясь результатами бенчмарка OptimizeJS, добавляет лишние скобки вокруг функций. При этом разработчики V8 подвергают критике подобную оптимизацию, потому и я решил от нее отказаться. Заменив минификатор на SWC помимо более быстрой компиляции и отказа от скобок я получил ещё парочку преимуществ. И суммарно мне удалось уменьшить размер пакета ещё на 4%.

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

Версия EcmaScript

Те фичи EcmaScript, для которых можно организовать обратную совместимость для старых браузеров, можно условно поделить на 2 группы - те, что добавляют новые объекты или расширяют их API, и те, что изменяют синтаксис языка. Вот ещё один репозиторий, в нем удобно собраны все фичи EcmaScript по годам с их описанием и примерами. Если посмотреть на обновление ES2017, то к группе первых фичей относятся фичи Object.values или Object.entries, а ко второй - асинхронные функции.

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

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

Пример
Исходный код (версия ES - ES2018)
const func = async ({ a, b, ...other }) => {
    console.log(a, b, other);
};

Скомпилированный код (версия ES - ES5)
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
var __generator = (this && this.__generator) || function (thisArg, body) {
    var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
    return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
    function verb(n) { return function (v) { return step([n, v]); }; }
    function step(op) {
        if (f) throw new TypeError("Generator is already executing.");
        while (g && (g = 0, op[0] && (_ = 0)), _) try {
            if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
            if (y = 0, t) op = [op[0] & 2, t.value];
            switch (op[0]) {
                case 0: case 1: t = op; break;
                case 4: _.label++; return { value: op[1], done: false };
                case 5: _.label++; y = op[1]; op = [0]; continue;
                case 7: op = _.ops.pop(); _.trys.pop(); continue;
                default:
                    if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
                    if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
                    if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
                    if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
                    if (t[2]) _.ops.pop();
                    _.trys.pop(); continue;
            }
            op = body.call(thisArg, _);
        } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
        if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
    }
};
var __rest = (this && this.__rest) || function (s, e) {
    var t = {};
    for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
        t[p] = s[p];
    if (s != null && typeof Object.getOwnPropertySymbols === "function")
        for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
            if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
                t[p[i]] = s[p[i]];
        }
    return t;
};
var func = function (_a) { return __awaiter(void 0, void 0, void 0, function () {
    var a = _a.a, b = _a.b, other = __rest(_a, ["a", "b"]);
    return __generator(this, function (_b) {
        console.log(a, b, other);
        return [2 /*return*/];
    });
}); };

Во втором случае компилятору пришлось создать несколько дополнительных конструкций для реализации возможности создания асинхронных функций (__awaiter, __generator) и для возможности использования rest оператора (__rest). Также компилятор написал дополнительный код, чтобы вместо использования деструктора объекта в аргументе функции, был ES5 совместимый синтаксис.

Задача разработчика NPM пакета - решить, в какую версию ES нужно скомпилировать его код. Если вы использовали асинхронные функции, и затем скомпилировали библиотеку под ES6, то вы создадите лишний код. Если разработчик конечного продукта сделает тоже самое, код для работы асинхронных функций добавится дважды. А если, наоборот, он будет компилировать библиотеку под ES2017, окажется, что ваш код в итоге можно было и не добавлять, а просто использовать современный синтаксис с async/await.

“Староверы” могут подумать, что все-таки логичнее всего компилировать код в ES5, ведь таким образом разработчик конечного продукта может не использовать инструменты, подобные Babel, если ему нужен ES5. И, по мне так, они будут ошибаться. ES6 (ES2015) сейчас поддерживается 98% браузерами; поддержка тех браузеров, что его не используют, либо заканчивается, либо планируется к завершению; и потому сейчас явно прослеживается тенденция от отказа в компиляции в ES5. При этом компиляция ES6 —> ES5 может оказать самое существенное влияние на размер пакета в сравнении с другими переходами, т.к. ES6 привнес много доработок в синтаксис языка. К тому же, синтаксис ES6 необходим для описываемых во второй части статьи советов.

Тогда под какую версию ES нужно компилировать код? Если вы пишете код для собственного использования - для себя или для проекта, в котором вы работаете, - укажите ту версию, которая указана в основном проекте. А для остальных разработчиков я бы посоветовал как раз-таки использовать ES6. Все во имя совместимости. Главное, укажите в документации своего пакета, какую именно версию ES вы используете.

Но это не все. Вы не просто должны компилировать код под определенную версию. Ещё вы должны с осторожностью использовать синтаксис более поздних версий ES в сравнении с той, под которую вы собираетесь. Например, если вы компилируете пакет под ES2015, вам не стоит использовать фичи из ES2017, а следовательно, например, асинхронные функции. И делается это все ещё по той же причине, чтобы компилятор не создавал код дважды.

Но все не так страшно

Большинство фич в новых версиях ES являются исключительно "сахаром". Вместо асинхронных функций вы можете использовать Promise'ы, вместо Object.entries - Object.keys, вместо Array.prototype.includes - Array.prototype.find. А если аналога функционала все-таки нет, вы можете написать его сами.

// ESNext syntax
const func = async () => {
  const result = await otherFunc();
  console.log(result);
  return result.data;
};

// ES6 syntax
const func = () => {
  return new Promise(resolve => {
    otherFunc().then(result => {
      console.log(result);
      then(result.data);
    });
  });
};

// ==================

// ESNext syntax
if ([1, 2, 3].includes(anyConst)) { /* ... */ }

// ES6 syntax
if (!![1, 2, 3].find(it => it === anyConst)) { /* ... */ }

// ==================

// ESNext syntax
Object.entries(anyObj).forEach(([key, value]) => { /* ... */ });

// ES6 syntax
Object.keys(anyObj).forEach(key => {
  const value = anyObj[key];
  /* ... */
});

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

Разделение Production и Development сборок

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

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

Пример реализации деления сборок
Исходный код
export const someFunc = (a: number, b: number) => {
  if (__DEV__) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      console.error('Incorrect usage of someFunc');
    }
  }

  console.log(a, b);
  return a + b;
};

rollup.config.js
import typescript from '@rollup/plugin-typescript';
import define from 'rollup-plugin-define';

export default [false, true].map(isDev => ({
  input: 'src/index.tsx',
  output: {
    file: `dist/react-vvm.${isDev ? 'development' : 'production'}.js`,
    preserveModulesRoot: 'src',
    format: 'esm',
  },
  plugins: [
    typescript(),
    define({
      replacements: {
        __DEV__: JSON.stringify(isDev),
      },
    }),
  ],
}));

Development версия пакета
const someFunc = (a, b) => {
    {
        if (typeof a !== 'number' || typeof b !== 'number') {
            console.error('Incorrect usage of someFunc');
        }
    }
    console.log(a, b);
    return a + b;
};

export { someFunc };

Production версия пакета
const someFunc = (a, b) => {
    console.log(a, b);
    return a + b;
};

export { someFunc };

Как видите, в Production версии валидация не попала

А теперь формализую. Вам достаточно на этапе компиляции вашего кода заменять некоторые последовательности символов (в моем случае __DEV__) на нужное значение - true или false. Далее нужно использовать созданный флаг в условии. При подставлении флага в коде получаются условия if (true) { … } и if (false) { … }. И далее происходит отсечение кода if (false) { … }, т.к. он никогда не будет вызываться.

Имея два файла нужно как-то подставлять их в сборку разработчика конечного продукта. До этого достаточно обратиться к переменной окружения NODE_ENV в вашем главном файле пакета. При этом разработчику конечного продукта необязательно настраивать эту переменную при использовании вашего пакета - Webpack, например, сам по себе её настраивает.

Настройка выдачи нужного файла в зависимости от версии сборки

package.json

{
  "name": "@yoskutik/react-vvm",
  // ...
  "main": "index.js"
}

И теперь осталось только использовать нужную версию пакета.

index.js

'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./dist/react-vvm.production.js');
} else {
  module.exports = require('./dist/react-vvm.development.js');
}

В дополнение ещё могу сказать, что минифицировать dev сборку вашего пакета не нужно. Она на то и dev, чтобы разработчик с ней активно взаимодействовал. А с минизированным кодом взаимодействовать крайне затруднительно.

Часть вторая. Код нужно писать определенным образом

Разработчик конечного продукта может себе позволить писать код так, чтобы ему было удобно - у него по умолчанию будет гораздо большая кодовая база. У разработчика NPM пакета кодовая база меньше, а потому задумываться о написании кода и о выходном файле гораздо проще. А следовательно, разработчику NPM пакета следует больше задумываться о том, как он пишет код.

А обусловленно это тем, что минификатор - не волшебный инструмент. Разработчик должен работать с ним в тандеме - он пишет код определенным образом, чтобы минификатор смог ещё сильнее ужать его код.

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

Это, конечно, общая практика, но она в том числе влияет и на размер пакета. Повторяемого кода быть не должно. С оговоркой, правда.

Выделение функций

Если в коде есть куски кода, которые повторяются - частично или полноценно, попробуйте выделить повторяемую функциональность в отдельную функцию. Думаю, в примере этот пункт не нуждается.

При этом могут быть обратные ситуации, когда повторяемый кусок настолько мал, что выделение его в отдельную функцию может негативно сказаться на размере пакета. И хоть по “best practices” даже в таком случае стоит вынести ее в отдельную функцию, для достижения “экстремально” минимального размера файла, этого делать не стоит.

Пример увеличения размера пакета после выделения функции

Согласно документации React useMemo не является семантической гарантией, и в будущем React может решить "забыть" рассчитанное ранее значение. Поэтому вместо useMemo(() => { ... }, []) в своей библиотеке я дважды использовал в коде useState(() => { ... })[0].

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

Поля объектов

С объектами тоже есть пара интересностей. Если вы в коде используете структуру object.subObject.field, минификатор сможет ужать это выражение максимум до o.subObject.field, т.к. минификатор не знает, безопасно ли дальнейшее сжатие. Поэтому, если вы часто ссылаетесь на одно и то же поле в объекте, создайте под него отдельную переменную и используйте её.

Пример
До оптимизации

Исходный код:

import { SomeClass } from 'some-class';

export const func = () => {
  const obj = new SomeClass();
  console.log(obj.subObject.field1);
  console.log(obj.subObject.field2);
  console.log(obj.subObject.field3);
};

Минифицированный код (182 байта):

Для наглядности я добавил переносы строк и отступы, но размер файла указан без них

import {SomeClass as o} from "some-class";

const e = () => {
    const e = new o;
    console.log(e.subObject.field1), console.log(e.subObject.field2), console.log(e.subObject.field3)
};
export {e as func};

После оптимизации

Исходный код:

import { SomeClass } from 'some-class';

export const func = () => {
  const obj = new SomeClass();
  const sub = obj.subObject;
  console.log(sub.field1);
  console.log(sub.field2);
  console.log(sub.field3);
};

Минифицированный код (164 байта):

Для наглядности я добавил переносы строк и отступы, но размер файла указан без них

import {SomeClass as o} from "some-class";

const e = () => {
    const e = (new o).subObject;
    console.log(e.field1), console.log(e.field2), console.log(e.field3)
};
export {e as func};

Далее я расскажу про оптимизацию из разряда “экстремальной”.

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

Пример
До оптимизации

Исходный код:

export class MyClass {
  private field4 = [];

  private field5 = 'field5';

  constructor(
    private field1: number,
    private field2: string,
    private field3: any,
  ) {
    this.afterInit();
  }

  afterInit() {}
}

Минифицированный код (158 байта):

class i {
    constructor(i, t, e) {
        this.field1 = i, this.field2 = t, this.field3 = e, this.field4 = [], this.field5 = "field5", this.afterInit()
    }

    afterInit() {
    }
}

export {i as MyClass};

После оптимизации

Исходный код:

export class MyClass {
  private field1: number;

  private field2: string;

  private field3: any;

  private field4: any[];

  private field5: string;

  constructor(field1: number, field2: string, field3: any) {
    const self = this;
    self.field1 = field1;
    self.field2 = field2;
    self.field3 = field3;
    self.field4 = [];
    self.field5 = 'field5';
    self.afterInit();
  }

  afterInit() {}
}

Минифицированный код (153 байта):

class e {
    constructor(e, i, t) {
        const f = this;
        f.field1 = e, f.field2 = i, f.field3 = t, f.field4 = [], f.field5 = "field5", f.afterInit()
    }

    afterInit() {
    }
}

export {e as MyClass};

Чем больше раз применяется this в коде, тем более явно видны последствия оптимизации. Особенно она заметна в приватных методах, где можно прибегнуть к совету, позволяющему отказаться от декларирования переменных через const и let.

В вашем коде могут быть случаи, когда применить совет выше и объявить переменную будет нельзя. Например, если вы должны присвоить значение в поле объекта - object.field = ... - или при вызове метода -object.method() в случае если method не является стрелочной функцией.

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

Пример
До оптимизации

Исходный код:

import { useEffect, useLayoutEffect, useRef } from 'react';

export const useRenderCounter = () => {
  const ref = useRef<number>();

  useLayoutEffect(() => {
    ref.current++;
  });

  useEffect(() => {
    ref.current++;
  });

  console.log(ref.current);
};

Минифицированный код (193 байта):

import {useRef as r, useLayoutEffect as o, useEffect as t} from "react";

const c = () => {
    const c = r();
    o((() => {
        c.current++
    })), t((() => {
        c.current++
    })), console.log(c.current)
};
export {c as useRenderCounter};

После оптимизации

Исходный код:

import { useEffect, useLayoutEffect, useRef } from 'react';

const CURRENT = 'current' as const;

export const useRenderCounter = () => {
  const ref = useRef<number>();

  useLayoutEffect(() => {
    ref[CURRENT]++;
  });

  useEffect(() => {
    ref[CURRENT]++;
  });

  console.log(ref[CURRENT]);
};

Минифицированный код (190 байт):

import {useRef as o, useLayoutEffect as r, useEffect as t} from "react";

const c = "current", e = () => {
    const e = o();
    r((() => {
        e[c]++
    })), t((() => {
        e[c]++
    })), console.log(e[c])
};
export {e as useRenderCounter};

Чем больше раз будет использоваться current, тем эффективнее будет эта оптимизация.

И под часто я понимаю уже от 2-3 раз. Значение плавающее, т.к. не всегда при употреблении одного поля дважды подобная оптимизация сократит размер файла, особенно если имя поля достаточно короткое.

Использование синтаксиса ES6

Использование синтаксиса ES6 совместно с минификатором может неплохо так сократить ваш код.

Использование стрелочных функций

В плане способности к сжатию стрелочные функции лучше классических во всем. По двум причинам. Во-первых, при объявлении подряд через const или let стрелочных функций, все последующие const или let кроме первого сокращаются. Во-вторых, стрелочные функции могут возвращать значения не используя ключевое слово return.

Пример
До оптимизации

Исходный файл:

export function fun1() {
  return 1;
}

export function fun2() {
  console.log(2);
}

export function fun3() {
  console.log(3);
  return 3;
}

Минифицированный файл (126 байта):

function n() {
    return 1
}

function o() {
    console.log(2)
}

function t() {
    return console.log(3), 3
}

export {n as fun1, o as fun2, t as fun3};

После оптимизации

Исходный файл:

export const fun1 = () => 1;

export const fun2 = () => {
  console.log(2);
};

export const fun3 = () => {
  console.log(3);
  return 3;
}

Минифицированный файл (101 байт):

const o = () => 1, l = () => {
    console.log(2)
}, c = () => (console.log(3), 3);
export {o as fun1, l as fun2, c as fun3};

Object.assign и spread оператор

Это частный случай общего правила, описываемого в первой части. Т.к. в ES6 spread оператор ещё не мог использоваться в объектах, его использование может оказаться не таким прозрачным, как вы думаете. Поэтому если вы компилируете вашу библиотеку под ES6, я бы рекомендовал вам использовать Object.assign вместо этого оператора.

Пример
До оптимизации

Исходный код:

export const fun = (a: Record<string, number>, b = 1) => {
  return { ...a, b };
};

Минифицированный код (76 байта):

const s = (s, t = 1) => Object.assign(Object.assign({}, s), {b: t});
export {s as fun};

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

После оптимизации

Исходный код:

export const fun = (a: Record<string, number>, b = 1) => {
  return Object.assign({}, a, { b });
};

Минифицированный код (61 байт):

const s = (s, t = 1) => Object.assign({}, s, {b: t});
export {s as fun};

Теперь Object.assign применяется только 1 раз.

Старайтесь возвращать значение в стрелочной функции

Очередная оптимизация из разряда “экстремальных”. Если это не повлияет на функциональную составляющую, вы можете возвращать значение из функции. Экономия будет небольшая, но она будет. Работает, правда, в случаях, когда в функции только 1 выражение.

Пример
До оптимизации

Исходный код:

document.body.addEventListener('click', () => {
  console.log('click');
});

Минифицированный код (70 байт):

document.body.addEventListener("click",(()=>{console.log("click")}));

После оптимизации

Исходный код:

document.body.addEventListener('click', () => {
  return console.log('click');
});

Минифицированный код (68 байт):

document.body.addEventListener("click",(()=>console.log("click")));

Отказ от создания переменных в функциях

Очередная оптимизация из разряда “экстремальных”. Вообще, пытаться уменьшить количество переменных это нормальная идея для оптимизации -минификатор избавляется от них, если может сделать inline вставку кода. Однако, самостоятельно от всех переменных минификатор избавиться не может по все тем же соображениям о безопасности. Но вы можете ему помочь.

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

Пример
До оптимизации

Исходный код:

export const fun = (a: number, b: number) => {
  const c = a + b;
  console.log(c);
  return c;
};

Минифицированный код (71 байт):

const o=(o,n)=>{const t=o+n;return console.log(t),t};
export{o as fun};

После оптимизации

Исходный код:

export const fun = (a: number, b: number, c = a + b) => {
  console.log(c);
  return c;
};

Минифицированный код (58 байт):

const o = (o, c, e = o + c) => (console.log(e), e);
export {o as fun};

Оптимизация довольно сильная, т.к. после нее из кода уходят все const и return.

Это довольно сильная оптимизация, т.к. в конечном итоге она может помочь избавиться не только от const, но и от return в собранном файле. Но учтите, такую оптимизации стоит применять только на приватных методах классов и на функциях, которые из вашей библиотеки не экспортируются, т.к. вы не должны усложнять своей оптимизацией понимание API вашей библиотеки.

Минимальное использование констант

Снова “экстремальный” совет. В собранном коде в принципе должно быть минимальное количество использований let и const. А для этого, например, все константы можно объявлять в одном месте. При этом совет становится экстремальным, только если мы стараемся объявить буквально все константы в одном месте.

Пример
До оптимизации

Исходный код:

export const a = 'A';

export class Class {}

export const b = 'B';

Минифицированный код (67 байт):

const s = "A";

class c {}

const o = "B";
export {c as Class, s as a, o as b};

После оптимизации

Исходный код:

export const a = 'A';
export const b = 'B';

export class Class {}

Минифицированный код (61 байт):

const s = "A", c = "B";

class o {}

export {o as Class, s as a, c as b};

Общий совет для экстремального уменьшения

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

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

Во-вторых, количество не сокращаемого кода должно быть сведено к минимуму. Сюда относится и часто повторяемое использование this, использование вложенных объектов или методов и т.п. Крайне желательно, чтобы в минимизированном файле были исключительно однобуквенные выражения.

В-третьих, количество выражений function, return, const и let также должно сводиться к минимуму. Используйте стрелочные функции, объявленые через const, объявляйте константы подряд, используйте аргументы вместо объявлений констант в функции и т.п.

Ну и самое главное. К экстремальному уменьшению есть смысл прибегать только когда все остальные оптимизации уже применены и только если она не повлияет на функциональность вашего пакета. А так же, ещё раз повторюсь, оптимизация не должна влиять на API (и, следовательно, типизацию) тех ваших сущностей библиотеки, которые из неё экспортируются.

Заключение

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

Но в конечном итоге прибегать или нет к использованию “экстремальных” советов - решать вам. Для меня это был скорее вызов самому себе о том, смогу ли я достичь минимально возможного объема файла. Но на случай если вам все же интересно насколько они полезны, то могу сказать, что мне они помогли уменьшить размер своей библиотеки с 2172 байт до 1594. Что с одной стороны всего лишь 578 байт, но с другой целых 27% от объема всего пакета.

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

Дополнение 1

Ранее в статье я написал, что до применения "экстремальных" советов библиотека весила 1772 байта, но тогда я не учел одного приема - того, как я накладывал декораторы. После отката вообще всех "экстремальных" правок вес файла составил 2172 байта.

Спасибо @Pongo за то что указал очевидную вещь, про которую я в статье написать забыл. Большинство современных серверов при выдаче контента используют сжатие gzip, которое позволяет сильно сократить объем передаваемого файла. Даже в бенчмарке, на который я ссылался, эта метрика присутствовала, но я почему-то о ней написал.

Итак, насколько имеет смысл прибегать к "экстремальной" минификации, учитывая, что в теории все повторяемые function, const, let, return и другие выражения, gzip может сжать? В моем случае после применения сжатия (по методу из бенчмарка) получились результаты до 1060 и 908 байт соответственно для сборок без и с применением "экстремальных" приемов. В общем, мы вернулись к изначальным самым 10%.

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

Комментарии (32)


  1. Boilerplate
    09.01.2023 13:57
    +2

    А потом этот минифицированный код вставят в spa, которое грузится по 5 секунд из-за картинок по 5 метров...


    1. Yoskutik Автор
      09.01.2023 14:04
      +2

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

      К тому же, картинки наверняка закэшируются при первой загрузке сайта, а вот бандл с JavaScript кодом будет обновляться регулярно.


    1. popuguytheparrot
      12.01.2023 11:47

      Для этого просто делают lib.min.js доступной по cdn


  1. just-a-dev
    09.01.2023 14:14

    Вы написали, что импорт полифиллов - ответственность потребителя NPM пакета. Имхо это на 100% относится и к минификации - незачем NPM пакет минимизировать. Минимизируют сам бандл конечного продукта


    1. Yoskutik Автор
      09.01.2023 14:17

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


    1. Yoskutik Автор
      09.01.2023 14:21

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


      1. just-a-dev
        09.01.2023 15:23
        +1

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

        Почему в реакте выбрали другой путь - я полагаю, чтобы пользователь мог сделать 2 импорта в своём html

        • редко изменяющийся статичный реакт с cdn

        • часто меняющийся собранный бандл приложения без реакта

        Такой подход может быть оправдан в ряде случаев. Но вашу маленькую либу никто не будет импортировать отдельным <script>

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


        1. Yoskutik Автор
          09.01.2023 15:40

          По поводу исходников, я вашу мысль не понял. Вам же не кто не мешает их смотреть. Просто не нужно смотреть исходники в собранном файле. Он на то и собранный, что уже перестал быть исходником.

          По поводу дедупликации тут можно порассуждать. Есть несколько проблем.

          • Самая важная - "если все разработчики начнут" - это очень серьезное условие. Все разработчики не начнут никогда. Например, ES6 вышел 7 лет назад, а некоторые разработчики все ещё под ES5 пишут.

          • С точки зрения минимизации кода, было бы благоприятнее всего, если все зависимости были внешними. Тогда бы дубликатов быть не могло. Но тут появляется проблема неудобства использования NPM пакетов - два NPM пакета могут использовать разные версии другого пакета. И разработчик конечного продукта совсем не должен решать подобные конфликты.

          • Проблема разных версий рождает ещё одну проблему. Если зависимость будет все-таки не внешней, и код будет встраиваться, но в разных пакетах будет встраиваться код разных версий пакетов, то как в таком случае быть? Здесь никак код сокращать нельзя.

          К тому же даже если 2 функции будут целиком друг друга копировать "буковка-в-буковку", то не так уж просто будет избавиться от одной из них. Каждая функция в JavaScript - объект. А некоторые из них ещё и с собственным контекстом.

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


          1. just-a-dev
            09.01.2023 20:04
            +2

            По поводу исходников, я вашу мысль не понял

            Допустим, я использую сторонний DatePicker. В любой современной IDE я могу быстро открыть его детали имплементации (node_modules/custompicker/DatePicker.js). Для небольших npm пакетов - исходный код является лучшей документацией.

            Возможно, вы в своем пакете поставляете одновременно и исходники, и бандл - тогда вы молодец. Но если вы поставляете только minified.js + typings.d.ts, то это плохо. Когда мне потребуется взглянуть на имплементацию, придется идти на гитхаб и ещё там искать срез именно той версии, которая используется у меня


            1. Yoskutik Автор
              09.01.2023 20:43

              Понял вас. Вообще я считаю, что в выкладывании исходников всё-таки смысла нет. Достаточно прикладывать файл с типизацией, production версию пакета и development. Причем минифицированию должна подлежать только prod версия.


              1. inferusvv
                10.01.2023 00:52
                -1

                Ох как часто я негодую, когда вместо того, чтобы увидеть исходник мне открывается d.ts файл, а рядом нет исходника. И да, я угрюмый открываю github и ищу исходный код нужной мне функции. И ещё хочу дополнить, что тоже писал либу react native, использовал готовый starter template. Автор шаблона считает что папку с исходниками отправлять в npm нужно.


                1. Yoskutik Автор
                  10.01.2023 00:56
                  -2

                  Вы можете негодовать, но у этого есть и обратная сторона. Мне приходилось разворачивать сайты на VDS с объем HDD в 500 Мбайт, и из-за того, что некоторые разработчики хранят в своем пакете нефункциональный код, мне приходилось прибегать к различным ухищрениям, чтоб сделать базовый `npm install`.

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


                  1. Yoskutik Автор
                    10.01.2023 11:50

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


              1. Semigradsky
                10.01.2023 01:47

                Если в случае Reactа и можно довериться ребятам, что production-версия это та же development, без специально добавленных уязвимостей. То с noname библиотекой всё равно придётся делать аудит неминифицированного кода и использовать его, а не подготовленный автором библиотеки.


                1. Yoskutik Автор
                  10.01.2023 11:59

                  Я правильно понимаю, что вы делаете аудит всех NPM пакетов, что используются в проекте? В том числе и тех пакетов, что используются вашими пакетами? Т.е. буквально все пакеты в node_modules


                  1. Semigradsky
                    10.01.2023 22:25

                    Нет, только тех пакетов, код которых попадёт в бандл.


                    1. Yoskutik Автор
                      10.01.2023 23:18

                      Что ж, вы проделываете невероятно большую работу. Могу только позавидовать вашему упорству


        1. Vitalics
          10.01.2023 01:02

          Согласен, вставлю свои 5 копеек.

          Как разработчик на Node.JS, иногда во время дебага приходится лазить в исходники других npm пакетов, чтобы понять что условно результат устраивает меня(да, гипотечески это решается тестированием, но от багов никто не застрахован). В таком случае достаточно будет перейти в github и там создать issue на функционал либы. Плюс если пользуетесь yarn,pnpm пакетными менеджерами у них есть функционал по патчингу пакетов что позволяет не ждать фикса вашего любимого\критически важного пакета. Минификация в этом случае будет наоборот мешать вам инспектировать проблему другой либы.


          1. Yoskutik Автор
            10.01.2023 01:06

            Скажу честно, как фронтенд разработчик даже не задумывался над такой проблемой. Но в целом я согласен с вами, минификация Node.JS файлов может быть неоправданна.

            Хотя с другой стороны я говорил об использовании сразу 2 версий пакета - dev и prod. И дебаг было бы логично делать как раз в dev версии, которую минифицировать вовсе не обязательно.


            1. Vitalics
              10.01.2023 11:18

              Тогда вопрос на засыпку. Вы написали библиотеку, сделали 2 сборки. dev и prod.
              Я как разработчик нашел вашу либу, по функционалу все круто, то что я искал. Пишу `<пакертный менеджер установи либу>`. А затем импортирую в свой проект.

              получится как-то так:

              // src/index.ts
              import something from 'yourlib';
              
              /// rest code

              Вопрос. Какая тут сборка будет: dev или prod?


              1. Yoskutik Автор
                10.01.2023 11:40

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


                1. Vitalics
                  10.01.2023 15:53

                  Ваш ответ отличается от того, что будет в реальности. Если внимательно посмотреть на документацию к node.js в раздел про то как резолвятся зависимости из node_modules можно понять, что будет взят фаил исполняемый из "main" или "exports" package.json файла. Это значит, что если проект фронтедовский - там уже наверняка есть сборщик, который сминифицирует код как надо(например вебпак). Если проект на ноде, то с вероятностью 90% код не надо минифицировать, а если и надо, то скорее всего что-то не так и проблема в другом.

                  Получается Ваша статья лишена смысла, поскольку заниматься минифицированием это:

                  1) себе дороже, неблагодарный труд, особенно если библиотека и для node.js и для браузера

                  2) сборщик и так сминифицирует код (зачем дважды делать одно и тоже)


                  1. Yoskutik Автор
                    10.01.2023 17:43

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

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

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

                    А если дело именно в Node.JS, то по поводу минификации пакета, используемого для него, я уже согласился, так что не понимаю, зачем вы в очередной раз пытаетесь доказать свою правоту.


                    1. just-a-dev
                      10.01.2023 17:46

                      Ваша статья полезная, но вот конкретно пассаж о минификации подрывает ценность. В остальном советы более-менее точны


  1. Pongo
    09.01.2023 17:43
    +2

    уменьшить размер своей библиотеки с 1772 байт до 1594

    а с включенным gzip какая разница? и если включен gzip (а он включен почти всегда и везде), то может и он function нет смысла отказываться?


    1. Yoskutik Автор
      10.01.2023 11:41

      Очень хорошая мысль, спасибо, что указали. Я дополнил статью, можете почитать результаты.

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


  1. Semigradsky
    10.01.2023 01:55

    оказалось, что Terser зачем-то генерирует лишние скобки для некоторых выражений

    Может это оптимизация скорости парсинга наподобие такой? https://github.com/nolanlawson/optimize-js
    Я бы предпочёл ускорение работы, чем экономию сотни байт)


    1. rock
      10.01.2023 02:09

      Видимо, флаг wrap_func_args у Terser автор не снял -)


      1. Yoskutik Автор
        10.01.2023 08:02

        Вот этот момент я упустил. Хотя все равно о своем решении не жалею. SWC всё-таки показывает лучшие результаты в сравнении с Terser, да и побыстрее будет


    1. Yoskutik Автор
      10.01.2023 07:56

      Вот только SWC будет всегда работать быстрее Terser'а в виду того, что написан он на Rust


      1. Semigradsky
        10.01.2023 22:26

        "ускорение работы" (за счёт дополнительных скобок) - это не про скорость минификации, а про скорость парсинга кода в браузере у конечного пользователя.


        1. Yoskutik Автор
          11.01.2023 10:50

          Понял вас. Но судя по информации из Readme в репозитории, на который вы сами и сослались, прибегать к такому приему не стоит. Разработчики V8 подвергают критике применение практик, описываемых в OptimizeJS. И по мне так, если честно, разработчики V8 вызывают гораздо больше доверия, чем разработчик, прямым текстом говорящий в своем репозитории, что поддержка его библиотеки не планируется