Опыт Loopin в применении облегчённых записей в Vault

Опыт Loopin в применении облегчённых записей в Vault

— Как мы исправили проблему подтягивания всего пакета ради одной кнопки скачивания —

1. Введение

В этом квартале я отвечал за обновление Loopin до версии 7.2 — нашего внутреннего сервиса обмена сообщениями. Одним из требований было следующее: «файлы, полученные в чатах, также должны оставаться в My Files».

Такая функциональность уже существовала. Компонент FileDownloadItem в vault, домене хранения файлов, отображал имя и размер файла, скачивал файл при нажатии кнопки скачивания, автоматически регистрировал его в папке Stash пользователя и даже отображал другой значок для файлов, которые уже были получены. Это был небольшой компонент, состоящий примерно из 150 строк исходного кода. Поскольку не было причин создавать то же самое заново в чате, мы решили повторно его использовать.

Однако добавление всего одной строки импорта увеличило pnpm-lock.yaml на 828 строк, а в результатах сборки появился файл рабочего процесса средства просмотра PDF размером 1 МБ, который чат не использовал. В этой статье описывается процесс изменения ситуации, при которой ради использования одного компонента из 150 строк подтягивался весь пакет, чтобы импортировались только необходимые части.

2. Предпосылки

Наш фронтенд представляет собой монорепозиторий на основе pnpm workspace и Turborepo, а пакеты организованы по следующим уровням.

episodes/*              실제 실행 앱 (엔트리)
  └─ collages/*         앱 레이아웃과 도메인 조합 계층
       └─ dramas/*-view     화면 컴포넌트 (React/MUI)
            └─ dramas/*-state → dramas/*-stub

Loopin и vault распространяются как отдельные пакеты с именами loopin-view и vault-view. Чтобы использовать компонент из vault в чате, можно добавить опубликованный во внутреннем реестре @vizendjs/vault-view как зависимость. Изначально весь написанный мной код выглядел так.

import { FileDownloadItem } from '@vizendjs/vault-view';

3. Поиск причины — граф модулей, созданный barrel-файлом

Я думал, что поскольку импортировал только нужный мне компонент, tree shaking отфильтрует остальные, но это оказалось не так.

Не зная, с чего начать, я сначала проверил то, что было видно. Просматривая недавно добавленные пакеты в diff-файле pnpm-lock.yaml, я обнаружил несвязанные пакеты, такие как pdfjs-dist и react-syntax-highlighter, а в dist пакета vault-view — файл размером 1 МБ с именем pdf.worker.min.mjs. Я пошёл назад, чтобы выяснить, кто импортирует эти библиотеки, и причиной оказался barrel-файл.

Цепочка от публичной точки входа vault-view до фактически тяжёлых библиотек выглядела так.

src/index.ts
  export * from './components'
      └─ components/index.ts
           ├─ export * from './common'
           │     ├─ CodeBlock.tsx        → react-syntax-highlighter
           │     ├─ AgGridCheck.tsx      → ag-grid
           │     └─ FileDownloadItem.tsx   ← 내가 필요한 건 이것 하나
           ├─ export * from './stash'
           │     └─ mydisk/modal/LightboxPage.tsx  → pdfjs-dist, react-zoom-pan-pinch
           │     └─ mydisk/MyDiskViewList.tsx      → ag-grid-react
           ├─ export * from './minipix'
           ├─ export * from './policy'
           ├─ export * from './mydisk'
           └─ export * from './cabinet'
  export * from './utils'
      ├─ date-utils.ts   → dayjs
      ├─ stash-utils.ts  → @vizendjs/vault-stub
      └─ file-utils.ts   → (의존성 없음)

Поскольку export * делает весь модуль кандидатом для вычисления, даже импорт одного компонента помещает всё это дерево в граф модулей. Например, LightboxPage.tsx начинается так.

import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker';

pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;

Как и в последней строке, когда код на верхнем уровне модуля изменяет глобальные настройки, сборщик считает, что модуль имеет побочные эффекты, и не может безопасно удалить его. Кроме того, vault-view объявлял тяжёлые библиотеки как peerDependencies и помечал их как внешние во время сборки. external означает, что они не включаются в бандл и в нём остаются только операторы импорта. Поэтому, хотя сам dist пакета vault-view выглядит небольшим, приложение, использующее этот dist, должно разрешить оставшиеся операторы импорта в собственном бандле. В конечном итоге такая структура заставляла потребителя, Loopin, нести эти затраты.

4. Решение 1 — точка входа под subpath, содержащая только необходимое

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

// dramas/vault-view/src/common.ts
// @vizendjs/vault-view/common — 채팅용 경량 엔트리.
// components/common/index.ts 배럴(CodeBlock·AgGridCheck 포함)을 거치지 않고
// 필요한 것만 직접 재export하여 pdfjs-dist / react-syntax-highlighter 가
// 이 청크로 유입되지 않게 한다.
export { FileDownloadItem } from './components/common/FileDownloadItem';
export type { FileDownloadItemProps } from './components/common/FileDownloadItem';

// 배럴(~/utils)이 아닌 파일 직접 경로 → dayjs/vault-stub 유입 차단
export { formatFileSize } from './utils/file-utils';

Самым важным здесь было явно прописать путь к файлу вплоть до концаСначала я указал только './components/common', но размер чанка почти не изменился. При проверке выяснилось, что этот barrel также экспортировал CodeBlock и AgGridCheck, поэтому все соседние модули подтягивались даже через один промежуточный уровень.

Я также проверил, что именно использует сам FileDownloadItem. У этого компонента было всего четыре импорта.

// dramas/vault-view/src/components/common/FileDownloadItem.tsx
import { useState } from 'react';
import styled from '@emotion/styled';
import { useClipFileDownload, useStash } from '@vizendjs/vault-state';
import { formatFileSize } from '~/utils/file-utils';

Он не использовал тяжёлых библиотек и зависел только от vault-state, в котором содержались хуки vault для скачивания и Stash. Этого было достаточно, чтобы убедиться в возможности его извлечения, и я продолжил работу.

По той же причине я записал formatFileSize как './utils/file-utils', а не как './utils'. Barrel-файл utils/index.ts экспортирует вместе date-utils (dayjs) и stash-utils (vault-stub), тогда как нужный мне файл file-utils.ts — это файл с чистой функцией, не имеющий импортов. Переход через barrel подтягивает несвязанный dayjs, а прямое указание на файл больше ничего не подтягивает. Больше всего меня тогда впечатлило, что результат может отличаться просто из-за разницы в один уровень пути, даже при импорте одной и той же функции.

5. Решение 2 — регистрация второй точки входа в сборке

Простого создания файла было недостаточно, чтобы импортировать его как @vizendjs/vault-view/common. Точку входа также нужно было выпустить как отдельный файл в результатах сборки.

// dramas/vault-view/vite.config.mts
build: {
  lib: {
    entry: {
      index: './src/index.ts',
      common: './src/common.ts',
    },
    name: packageJson.name,
    formats: ['es'],
    fileName: (format, entryName) => `${entryName}.${format}.js`,
  },
  rollupOptions: {
    external: [
      ...Object.keys(packageJson.devDependencies),
      ...Object.keys(packageJson.peerDependencies),
    ],
  },
}

Когда я изменил entry со строки на объект, сборка завершилась ошибкой, и я довольно долго не мог понять причину. Причиной был UMD. Поскольку UMD требует прикрепить все экспорты к одной глобальной переменной, он не мог поддерживать несколько точек входа: при наличии двух точек не было способа определить глобальное имя. Поскольку все приложения-потребители использовали ESM-сборщики, я зафиксировал formats как ['es'] и также обновил поле main в package.json, чтобы оно указывало на ES-результат.

6. Решение 3 — предоставление потребителям доступа через эту точку входа

Даже если файл был создан в результатах сборки, он должен был быть доступен извне пакета по пути @vizendjs/vault-view/common. Я добавил subpath в поле exports.

{
  "main": "./dist/index.es.js",
  "module": "./dist/index.es.js",
  "exports": {
    ".": {
      "types": "./dist/@types/src/index.d.ts",
      "import": "./dist/index.es.js",
      "require": "./dist/index.es.js"
    },
    "./common": {
      "types": "./dist/@types/src/common.d.ts",
      "import": "./dist/common.es.js"
    },
    "./styles/all.css": "./dist/vault-view.css"
  },
  "typesVersions": {
    "*": {
      "common": ["./dist/@types/src/common.d.ts"]
    }
  }
}

На этом этапе сборка проходила, но редактор продолжал сообщать, что не может найти модуль. После некоторого расследования я узнал, что условие types в exports разрешается только при значении moduleResolution bundler или выше. Некоторые наши пакеты всё ещё использовали устаревшее разрешение, поэтому ошибка исчезла только после того, как мы ещё раз объявили то же сопоставление в typesVersions. Эти два поля могут выглядеть избыточными, но они обслуживали разных потребителей.

7. Проверка — подтверждение того, что ничего лишнего не было подтянуто

Закончив работу, я напрямую открыл dist, чтобы проверить, действительно ли всё работает. Две точки входа ссылались на разные чанки.

dramas/vault-view/dist/
├─ index.es.js                  1.5 KB   → index-D-3TlXGH.mjs 참조
├─ index-D-3TlXGH.mjs           3.7 MB
├─ pdf.worker.min.mjs           1.0 MB
├─ common.es.js                 120 B    → FileDownloadItem-B17vE2-_.mjs 참조
└─ FileDownloadItem-B17vE2-_.mjs  49 KB

common.es.js содержит всего четыре строки.

import { F as a, f } from "./FileDownloadItem-B17vE2-_.mjs";
export {
  a as FileDownloadItem,
  f as formatFileSize
};

Более надёжным основанием, чем размер, был список библиотек, которые каждый чанк требовал извне. Как упоминалось ранее, тяжёлые библиотеки были вынесены наружу и оставались только в виде операторов импорта; этот список отражал нагрузку, которую должно было нести приложение-потребитель.

[배럴 엔트리] index-D-3TlXGH.mjs 가 요구하는 것 (15개)
  react, react-dom, @emotion/styled, @emotion/react, @vizendjs/accent,
  @tanstack/react-query, axios, notistack, jotai, lodash, dayjs,
  ag-grid-react, react-zoom-pan-pinch, pdfjs-dist, react-syntax-highlighter

[경량 엔트리] FileDownloadItem-B17vE2-_.mjs 가 요구하는 것 (5개)
  react, @emotion/styled, @tanstack/react-query, axios, @vizendjs/accent

pdfjs-dist, react-syntax-highlighter, react-zoom-pan-pinch, ag-grid-react, dayjs, lodash и jotai исчезли из списка. Было странно, что у компонента из 150 строк оказался чанк размером 49 КБ, но я понял почему, увидев, что @vizendjs/vault-state отсутствует в списке. external применяется только к devDependencies и peerDependencies, а vault-state был dependency, поэтому код хуков, использовавшихся для скачивания и регистрации файлов в Stash, был включён в чанк. Оставшиеся пять библиотек уже использовались экраном чата, поэтому фактическая дополнительная стоимость была почти нулевой. Проверка списка, а не чисел, ясно показала, что именно исчезло и почему, поэтому с тех пор при анализе бандлов я сначала смотрю на этот список, а уже потом на размер чанка.

8. Со стороны потребителя

Со стороны потребителя требуется лишь добавить /common к пути импорта.

import { FileDownloadItem } from '@vizendjs/vault-view/common';

Однако мы разместили этот импорт в collage, на более высоком уровне, а не в компоненте экрана чата. Прямой импорт из loopin-view создал бы зависимость между пакетами одного уровня из разных доменов, нарушив правила монорепозитория. Изменив дизайн так, чтобы collage получал реализацию и внедрял её через Context, мы избавились от необходимости повторно развёртывать loopin-view даже при обновлении версии vault-view с 7.2.6 до 7.2.21.

Был ещё один момент, требовавший внимания. Облегчённая точка входа напрямую импортирует axios внутри, и этот axios должен быть тем же экземпляром, что и в хост-приложении. Мы добавляем токены аутентификации и идентификаторы акторов в перехватчике запросов глобального экземпляра axios, поэтому, если пакет использует собственную копию, эти запросы отправляются без Authorization и Actorid и приводят к 401. Поэтому мы объявили axios как peerDependencies, а не как dependencies.

9. Результаты и извлечённые уроки

Пункт

Barrel-точка входа

Облегчённая точка входа

Путь импорта

@vizendjs/vault-view

@vizendjs/vault-view/common

Размер указанного чанка

Примерно 3,7 МБ

Примерно 49 КБ

Внешние библиотеки, нагрузку по которым несёт приложение-потребитель

15

5

pdfjs-dist / pdf.worker

Включено

Отсутствует

Функциональная часть также удовлетворила требованию о том, что «файлы, полученные в чате, остаются в моём файловом хранилище», без какой-либо отдельной реализации в чате. Мы просто повторно использовали компонент из 150 строк без изменений, одновременно снизив затраты.

Похоже, публичный API пакета — это граф модулей, а не список экспортов. Одна строка `export *` удобна автору пакета, но для потребителей всё, что находится за ней, оборачивается дополнительными затратами. Отныне при создании barrel-файла в общем пакете я сначала планирую проверять, «что подключается вместе с ним».

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

Остались вещи, которые я ещё не систематизировал. Сейчас состав облегчённой точки входа зависит от моего решения, поэтому если кто-то позже неосторожно импортирует barrel-файл из common.ts, он снова станет тяжёлым. Я хотел бы добавить проверку, которая во время сборки анализирует список внешних импортов чанка и завершается с ошибкой, если он превышает заранее установленное количество, но мне нужно изучить, как лучше всего это настроить.

messi

Site footer