Как стать автором
Обновить

Экстремально уменьшаем размер NPM пакета

Время на прочтение21 мин
Количество просмотров7.7K

Однажды я захотел создать небольшую 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%.

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

Теги:
Хабы:
Всего голосов 10: ↑7 и ↓3+7
Комментарии32

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань