Привет, Хабр! Меня зовут Дима, я Head of Frontend в Dodo Engineering. Моя команда создаёт инструменты для удобной работы с фронтендами, унифицирует подходы к разработке, помогает другим командам в создании удобных пользовательских интерфейсов Dodo IS.
Недавно мне срочно понадобилось написать плагин для TypeScript. Начал я с того, что погуглил, как это сделать. По пути боролся с повышенным потреблением памяти и искал недостающие файлы в массиве, переписывал Proxy и не только, а закончил на… А впрочем это вы узнаете в конце.
Чтобы засинхрониться, оставляю вам в начале статьи ссылку на код плагина. Открывайте его и давайте вместе разбираться, зачем мне вообще понадобился плагин и как я его писал.
Почему решил написать?
TypeScript — странный язык. Он помогает разработчикам, но при этом вызывает много вопросов. Он добавляет статическую типизацию в JavaScript, что позволяет IDE заранее «ловить» ошибки и подсказывать разработчику, как лучше писать код. Это делает код более предсказуемым и помогает избежать множества багов на этапе написания.
Однако у TypeScript есть обратная сторона. Строгая типизация усложняет написание кода, особенно тем, кто привык к гибкости JavaScript. Порой описание типов занимает больше времени, чем решение задачи. Некоторые разработчики осознанно отказываются от TypeScript в своих проектах по этой причине.
Кроме того, TypeScript — это как дополнительный слой в разработке. Сам по себе он не может работать в браузере или на сервере, его сначала нужно преобразовать в обычный JavaScript. И хотя TypeScript помогает писать более чистый код, в итоге весь процесс выглядит лишним шагом, причём не всегда оправданным.
Тем не менее TypeScript в каком-то смысле является дополнением к тестам и документации, особенно в больших проектах, но только если его правильно настроить. Одна из самых важных настроек в TypeScript — опция strict
. Она заслуживает особого внимания, когда речь идёт о масштабных проектах. Эта опция включает в себя несколько других.
strictNullChecks
Одна из самых главных опций. Без неё все | null | undefined
(nullable) типы просто игнорируются:
function findUserName(): string | null { return null; // здесь может быть обращение к бэкенду } const userName = getUserName(); console.log(userName.toUpperCase()); // без включённого strict мы не увидим ошибку в IDE, хотя userName может оказаться null. А вот в рантайме упадём с ошибкой
noImplicitAny
function stringToUpper(name) { // с включённым strict TypeScript не позволит передать любой аргумент в name, запретит неявный any-тип return name.toUpperCase(); } stringToUpper(42); // здесь мы упадём с ошибкой в рантайме
Существует ряд других опций, которые также критичны, но требуют более детального рассмотрения. Чтобы не увеличивать объём статьи, я приведу их список со ссылками на официальную документацию:
Но что делать, если на этапе зарождения проекта strict
не был включён? Ведь по началу может казаться, что это лишняя нагрузка: проект небольшой, всё под контролем, и строгая типизация кажется избыточной. К тому же переводить проект с JavaScript на TypeScript намного проще с выключенным strict
— просто переименовать .js
файлы в .ts
.
Но когда проект вырастает до значительных размеров, исправлять эти ошибки становится всё сложнее. И если команда решит на каком-то этапе включить strict
, она столкнётся с тем, что нужно переписывать огромные участки кода, чтобы удовлетворить требованиям строгой типизации. Это может сильно затормозить разработку и усложнить поддержку кода.
С такой проблемой я и столкнулся на одном из проектов. Несколько лет назад мы перевели его с JavaScript на TypeScript, не включив опцию strict
. Возможно, мы сделали это случайно — в пустом tsconfig
она отключена по умолчанию, а возможно осознанно — из-за описанных выше причин. Так или иначе, команда начала сталкиваться с проблемами — в проде начали вылезать ошибки, связанные со strictNullChecks
. Дело было в переменных. В коде они были нулевыми, но в какой-то момент nullable терялся, так как IDE и TypeScript никак об этом не предупреждали.
В проекте больше 3000 файлов. Простое включение strict
подсветило сотни ошибок. Их исправление заняло бы недели, а может и месяцы. Возник вопрос: а как перевести только часть проекта, содержащую критичную функциональность? К сожалению, TypeScript не позволяет переопределять tsconfig
для определённых папок, так как он воспринимает весь код как целостный проект, где друг с другом связан каждый файл и каждый тип. У TypeScript есть issue на эту тему, открытый в 2019 году.
Одно из решений: раздробить один монолит-проект на мелкие части, workspaces. И уже в этих частях внедрять strict
. Но из-за тесной связанности компонентов проекта, эта задача заняла бы ещё больше времени. Хоть мы и смогли собрать низко висящие фрукты — выделили независящие от других частей проекта утилиты и UI-компоненты — этого всё равно было мало. Оставалась потребность переопределять tsconfig
только для определённых частей проекта и постепенно переводить на strict
.
В какой-то момент я вспомнил о возможности писать свои плагины для TypeScript. И пошёл разбираться…
Плагины для IDE
По запросу «TypeScript plugin» в гугле, мы попадаем на официальный гайд по написанию плагина. Это не самый подробный гайд. Он задаёт только базовый вектор в создании плагинов. Поэтому я как слепой котёнок вооружился исходниками TypeScript, IDE, и пошёл писать плагин.
Быстрый набросок плагина
Первым делом я создал два workspace. В одном из них находится код моего плагина (packages/plugin
), а в другом (packages/example
) — этот плагин подключается как обычный npm-пакет. Весь код плагина содержится в index.ts
файле и собирается обычной командой tsc
. В example
добавляем в tsconfig
следующие настройки:
{ "compilerOptions": { "plugins": [ { "name": "ts-plugin", // название workspace в packages/plugin/package.json }, ] }, }
Самый простой код плагина может выглядеть так:
import type ts from 'typescript/lib/tsserverlibrary'; const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({ create: pluginCreateInfo => new Proxy(pluginCreateInfo.languageService, { get: (target, property: keyof ts.LanguageService) => { if (property === `getSemanticDiagnostics`) { return (fileName: string) => { const originalDiagnostic = target.getSemanticDiagnostics(fileName); return originalDiagnostic.map(diagnostic => ({ ...diagnostic, messageText: `Привет, Хабр! ${diagnostic.messageText}`, })); }; } return target[property]; }, }), }); export = plugin;
По итогу получаем следующий результат:
Здесь и далее я использую Proxy для модификации оригинального languageService
. Однако можно воспользоваться и «классическим» копированием объекта. Оно описано в официальном гайде.
Разбор основных параметров
Разберём подробнее приведённый выше код, чтобы узнать, какие функции нам доступны:
-
ts.server.PluginModuleFactory
— функция, аргумент которой содержит текущий экземплярtypescript
. Он нам понадобится в дальнейшем. Возвращать функция должна объект со следующими ключами: -
getExternalFiles
— необязательная функция. Она возвращает список файлов, которые плагин хочет рассматривать в процессе работы. Они могут и не быть частью проекта TypeScript. Например, если мы пишем плагин дляvue
, все файлыvue
должны быть включены в проект; -
onConfigurationChanged
— необязательная функция. Вызывается, когда изменяется конфигурация плагина. Когда TypeScript сервер получает новую конфигурацию через файлtsconfig.json
, эта функция может реагировать на изменения настроек и применять их к работе плагина; -
create
— обязательная функция и самая важная для разработчика плагина. Её аргумент — объектPluginCreateInfo
. Его мы разберём позже. Функция должна вернутьLanguageService
— это основной объект, с которым мы будем работать. Он предоставляет основные инструменты для работы с кодом: автодополнение, навигацию по коду (go to definition), показ ошибок, показ типов и документацию; -
PluginCreateInfo
содержит следующие ключи: -
project
— предоставляет доступ к данным текущего проекта, включая список файлов, информацию о зависимостях и другие проектные метаданные. Плагин может использовать эту информацию, чтобы адаптироваться к структуре проекта и выполнять операции, требующие понимания проектной организации; -
languageService
— оригинальныйLanguageService
был описан выше; -
languageServiceHost
— предоставляет информацию, необходимую для работыlanguageService
. С его помощью плагин может запрашивать файлы, настройки компиляции, а также доступ к синтаксическим и семантическим данным проекта; -
serverHost
— предоставляет доступ к низкоуровневым операциям с файловой системой и проектом. Плагин может использовать его для чтения файлов, управления директориями и выполнения других операций с файловой системой, которые требуются для работы плагина; -
session
— это ключ, который предоставляет доступ к сессии сервера TypeScript. Сессия отслеживает текущее состояние работы редактора с проектом, управляет запросами между IDE и сервером TypeScript. Она позволяет плагину взаимодействовать с редактором и использовать такие функции, как регистрация команд или расширение возможностей обработки запросов; -
config
— содержит пользовательские настройки плагина, которые могут быть переданы черезtsconfig.json
. Эти параметры позволяют гибко настроить поведение плагина в зависимости от потребностей конкретного проекта.
Начинаем писать плагин для переопределения tsconfig.json
Из всего многообразия доступных параметров нам нужны: typescript
из аргумента функции плагина, languageService
, languageServiceHost
и config
из функции create
.
Основная наша задача — создать отдельные languageService
со своими настройками для каждой переопределённой папки.
Начнём с простого — определим настройки для нашего плагина. Это будет простой интерфейс:
import type ts from 'typescript/lib/tsserverlibrary'; export interface Override { files: string[]; compilerOptions: ts.server.protocol.CompilerOptions; } export interface PluginConfig { overrides: Override[]; }
ts.server.protocol.CompilerOptions
— это любые настройки, доступные в tsconfig.json
Передаём эти настройки в tsconfig.json
:
{ "compilerOptions": { "strict": false, "plugins": [ { "name": "ts-overrides-plugin", "overrides": [ { "files": ["src/modern/**/*.{ts,tsx}"], "compilerOptions": { "strict": true, }, }, ] }, ] }, }
Для определения путей я решил использовать glob
-паттерн. Для разработчиков он более привычен как по самому tsconfig
(поля files
, includes
, exclude
, etc), так и по eslint.config
.
Теперь мы можем получить эти настройки внутри плагина:
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({ create: info => { const { overrides } = info.config as PluginConfig; console.log(overrides); return info.languageService; }, }); export = plugin;
Далее нам нужно создать отдельный languageService
для каждой папки, требующей переопределения настроек TypeScript. Создадим функцию getOverrideLanguageServices
и подробно её разберём:
import outmatch from 'outmatch'; const getOverrideLanguageServices = ( typescript: typeof ts, overrides: Override[], languageServiceHost: ts.LanguageServiceHost, ): ts.LanguageService[] => [...overrides].reverse().map(override => { const overrideLanguageServiceHost: ts.LanguageServiceHost = { fileExists: path => !!languageServiceHost.fileExists?.(path), getCurrentDirectory: (): string => languageServiceHost.getCurrentDirectory(), getDefaultLibFileName: (options: ts.CompilerOptions): string => languageServiceHost.getDefaultLibFileName(options), getScriptSnapshot: fileName => languageServiceHost.getScriptSnapshot(fileName), getScriptVersion: fileName => languageServiceHost.getScriptVersion(fileName), readFile: (path, encoding) => languageServiceHost.readFile?.(path, encoding), getCompilationSettings: () => ({ ...languageServiceHost.getCompilationSettings(), ...typescript.convertCompilerOptionsFromJson( override.compilerOptions, languageServiceHost.getCurrentDirectory(), ).options, }), getScriptFileNames: () => { const originalFiles = languageServiceHost.getScriptFileNames(); const isMatch = outmatch(override.files); return originalFiles.filter(fileName => isMatch(relative(languageServiceHost.getCurrentDirectory(), fileName)), ); }, }; return typescript.createLanguageService(overrideLanguageServiceHost); });
Функция принимает параметры:
-
typescript
. Он нужен нам для создания экземпляраlanguageService
; -
overrides
— массив наших переопределений. Для каждого мы создадим отдельныйlanguageService
; -
languageServiceHost
. Он нужен для созданияlanguageService
. Как упоминалось выше, он позволит нам обращаться к файлам проекта и его конфигурации. С его помощью мы создадим собственныйlanguageServiceHost
, в котором переопределим функции для получения конфигурации и списка файлов проекта.
Для начала сделаем reverse
массива overrides
. Это нужно для того, чтобы при поиске через find
мы получали последний languageService
, способный обработать файл на наличие ошибок (по аналогии с eslint
).
Далее для каждого override
создадим languageServiceHost
, определив обязательные методы:
-
fileExists
должен вернуть при вызовеtrue
, если файл существует. Здесь, как и много где дальше, мы можем просто вызвать метод из оригинальногоlanguageServiceHost
. Стоит учесть и то, что вtypescript ≤ 4
этот метод был необязательным. Поэтому при вызове используемoptional chaining
; -
getCurrentDirectory
получает текущую директорию проекта (корневую папку). Используем оригинальный метод; -
getDefaultLibFileName
возвращает имя файла библиотеки по умолчанию (обычноlib.d.ts
). Используется для определения файла стандартной библиотеки TypeScript, который подключается к проекту. Используем оригинальный метод; -
getScriptSnapshot
возвращает снимок текущего состояния файла. Снимки используются TypeScript для анализа изменений в коде без необходимости перечитывать файл каждый раз. Используем оригинальный метод; -
getScriptVersion
возвращает текущую версию скрипта (файла), по которой TypeScript может определить, изменился ли файл. Эта строка позволяет TypeScript определять, изменился ли файл, и нужно ли его анализировать повторно; -
readFile
читает содержимое файла по указанному путиpath
с возможностью указать кодировку файла (encoding
). Как и в случае сfileExists
, здесь используется безопасный вызов с проверкой на наличие метода, так как вtypescript ≤ 4
этот метод был необязательным. Используем оригинальный метод; -
getCompilationSettings
возвращает объект настроек компиляции TypeScript (ts.CompilerOptions
). Этот метод объединяет настройки, которые предоставляет оригинальныйlanguageServiceHost
, с нашими настройками компиляции из объектаoverride.compilerOptions
. По сути, здесь происходит основная «магия» нашего плагина. Метод использует функциюconvertCompilerOptionsFromJson
, которая преобразует JSON-объект с настройками в валидные параметры компилятора TypeScript. Например, полеmodule
, которое можно задать разными способами (CommonJS
,commonjs
), он преобразует в число; -
getScriptFileNames
возвращает массив имён всех файлов скриптов в проекте. Сначала вызывается оригинальный методgetScriptFileNames
изlanguageServiceHost
, который возвращает список всех файлов, известных TypeScript. Затем с помощью функцииoutmatch
проверяется, какие из этих файлов соответствуют паттернам, указанным вoverride.files
. Фильтрация позволяет плагину работать только с файлами, которые соответствуют этим паттернам. Вместоoutmatch
можно было использовать любую другую библиотеку для работы сglob
-паттернами.
На основе созданного объекта overrideLanguageServiceHost
, мы создаём languageService
с помощью typescript.createLanguageService(overrideLanguageServiceHost)
. Далее именно его мы будем вызывать для файлов, в которых нужно переопределить настройки.
Определим функцию для получения languageService
по имени файла:
const getLanguageServiceForFile = ( fileName: string, overrideLanguageServices: ts.LanguageService[], originalLanguageService: ts.LanguageService, ): ts.LanguageService => { const overrideServiceForFile = overrideLanguageServices.find( override => override.getProgram()?.getRootFileNames().includes(fileName), ); if (overrideServiceForFile) { return overrideServiceForFile; } return originalLanguageService; };
Здесь мы проходимся по массиву созданных выше languageService
и ищем тот, что готов проанализировать файл. Если не находим, берём оригинальный. На этом почти всё. Нам осталось только определить основную функцию плагина:
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({ create: info => { const { overrides } = info.config as IdePluginConfig; const overrideLanguageServices = getOverrideLanguageServices(typescript, overrides, info.languageServiceHost); return new Proxy(info.languageService, { get: (target, property: keyof ts.LanguageService) => { if (property === `getSemanticDiagnostics`) { return (fileName: string) => { const overrideForFile = getLanguageServiceForFile(fileName, overrideLanguageServices, target); return overrideForFile.getSemanticDiagnostics(fileName); }; } return target[property]; }, }); }, }); export = plugin;
Здесь мы переопределяем оригинальный метод getSemanticDiagnostics
, который возвращает диагностику по файлу. В нём мы получаем переопределённый languageService
, созданный нами, и проводим с его помощью диагностику. По итогу в одном проекте мы видим разный вывод ошибок для разных папок:
Устраняем проблемы
Плагин выполняет свою основную задачу — с его помощью мы получаем разную диагностику для разных файлов. Он был написан на коленке, а потому с ним возникли некоторые проблемы. Разберём их подробнее.
Память
Взглянув на код, можно определить источник теоретических проблем — повышенное потребление памяти. Мы создаём несколько languageServiceHost
и languageService
, каждый из которых хранит состояние проекта: информацию о типах, содержимое файлов и т.д. На большом проекте и с большим количеством overrides
потребление памяти может сильно возрасти.
Как сгладить ситуацию? Например, с помощью typescript.createDocumentRegistry()
. Он возвращает DocumentRegistry
, который может хранить состояние файлов, их версии, выгружать неиспользуемые, а самое главное — шарить это состояние между разными экземплярами languageService
. К сожалению, мы не можем получить DocumentRegistry
из оригинальногоlanguageService
, поэтому создадим новый и будем использовать его для всех наших languageService
:
const getOverrideLanguageServices = ( typescript: typeof ts, overridesFromConfig: Override[], languageServiceHost: ts.LanguageServiceHost, docRegistry: ts.DocumentRegistry, ): ts.LanguageService[] => [...overridesFromConfig].reverse().map(override => { // ... return typescript.createLanguageService(overrideLanguageServiceHost, docRegistry); }); const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({ create: info => { // ... const docRegistry = typescript.createDocumentRegistry(); const overrideLanguageServices = getOverrideLanguageServices( typescript, overrides, info.languageServiceHost, docRegistry, ); const originalLanguageServiceWithDocRegistry = typescript.createLanguageService( info.languageServiceHost, docRegistry, ); //... }, });
Таким образом, мы сильно оптимизируем работу с памятью.
Глобальные типы
При тестировании на реальном проекте вскрылась проблема — TypeScript
начал ругаться на отсутствие глобальных типов, описанных в d.ts
файлах. Ошибка была в создании LanguageServiceHost
, в формировании массива файлов:
getScriptFileNames: () => { const originalFiles = project.getScriptFileNames(); const isMatch = outmatch(override.files); return originalFiles.filter(fileName => isMatch(relative(project.getCurrentDirectory(), fileName))); },
Здесь мы не учитываем d.ts
файлы, если они явно не указаны в override.files
. Проблема решается их явным добавлением в массив:
getScriptFileNames: () => { const originalFiles = project.getScriptFileNames(); const isMatch = outmatch(override.files); return originalFiles.filter( fileName => fileName.endsWith(`.d.ts`) || isMatch(relative(project.getCurrentDirectory(), fileName)), ); }
Подсветка типов
Мы переопределили только метод getSemanticDiagnostics
, который выводит ошибки. Но забыли про подсказки о типах. Например, создадим файл legacy.ts
, в котором strict
выключен:
Из-за отсутствия strictNullChecks
, итоговый тип переменной из string | undefined
превращается в string
. Импортируем эту переменную из другого файла modern.ts
, где strict
включён:
Ошибка отображается верно, но тип переменной всё ещё остаётся string
. Чтобы это исправить, переопределим ещё один метод в languageService
:
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({ create: info => { // ... return new Proxy(originalLanguageServiceWithDocRegistry, { get: (target, property: keyof ts.LanguageService) => { if (property === `getQuickInfoAtPosition`) { return ((fileName: string, position: number) => { const overrideForFile = getLanguageServiceForFile(fileName, overrideLanguageServices, target); return overrideForFile.getQuickInfoAtPosition(fileName, position); }); } // ... return target[property as keyof ts.LanguageService]; }, }); }, });
Теперь и ошибка, и тип отображается корректно:
Дебаггинг
Поскольку плагин работает в IDE, обычный console.log или точка остановы не помогут нам в его отладке. Можно использовать 2 решения для отладки:
-
писать логи в файл средствами
nodejs
; -
использовать
info.project.projectService.logger.info
, запустить VSCode или WebStorm в режиме отладки и читать логи также из файла.
Совместимость с разными версиями TypeScript
Изначально я писал плагин только под версию TypeScript 5.3
и использовал Proxy
для создания не только languageService
, но и languageServiceHost
— всё работало хорошо. В обновлении TypeScript 5.4
разработчики изменили поведение languageServiceHost
и при использовании плагина любые ошибки типов перестали выводиться. Почитав исходники TypeScript
, я нашёл участок кода, который косвенно запрещает использование одного экземпляра languageServiceHost
несколькими languageService
. Это сделано с целью оптимизации. Поэтому я переписал Proxy на создание нового объекта с нуля.
Как было описано ранее, нужно быть осторожным с более старыми версиями TypeScript
— некоторые методы там отсутствовали или были опциональными.
В целом плагин, который вмешивается в стандартное поведение TypeScript
, достаточно шаткая история. Каждое изменение версии TypeScript
требует отладки.
Неправильное понимание Program
Изначально я пытался создать ts.Program
с помощью typescript.createProgram()
, передав в него переопределённые настройки, а не создавать новые languageService
. Далее в методе getSemanticDiagnostics
я пытался вызывать методы из созданной ts.Program
. Что-то вроде этого:
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({ create: info => { // ... const newProgram = typescript.createProgram([`modern.ts`], { ...defaultOptions, ...overrideOptions, }); return new Proxy(info.languageService, { get: (target, property: keyof ts.LanguageService) => { if (property === `getSemanticDiagnostics`) { return ((fileName: string) => { // ... const sourceFile = newProgram.getSourceFile(fileName); return newProgram.getSemanticDiagnostics(sourceFile); }); } return target[property]; }, }); }, });
Но этот способ переставал работать при первом же изменении файла, расположение диагностики уезжало:
Дело в том, что typescript.createProgram()
создаёт «слепок» всего проекта на момент создания. Он не предназначен для использования в реальном времени с постоянно меняющимися файлами. Можно бы было создавать новый ts.Program
при каждом вызове getSemanticDiagnostics
или при изменении файлов, но такой способ оказался очень ресурсозатратным и не нативным.
Бонус. Игнорирование файлов из проверки типов
Существуют редкие ситуации, когда нам нужно исключить файл из проверки типов. Например, это может быть JS-код, который нужно переписать на TypeScript
. Если файл один — это не проблема. Но если это целая папка, в некоторых файлах захочется временно отключить проверку типов.
TypeScript
позволяет заглушать ошибки для конкретных строк (// @ts-ignore
) и для всего файла (// ts-nocheck
). Но такие комментарии будут разбросаны по файлам — их легко потерять. С помощью плагина можно держать такие исключения в одном месте, в tsconfig.json
:
"compilerOptions": { "plugins": [ { "name": "ts-overrides-plugin", "ignores": ["src/ignored/**/*.{ts,tsx}"] ] }
Что касается реализации — к вышеупомянутому коду добавляется пара строк:
const { overrides, ignores } = info.config as IdePluginConfig; const ignoresMatcher = ignores ? outmatch(ignores) : null; // ... if (property === `getSemanticDiagnostics`) { return (fileName => { if (ignoresMatcher?.(relative(info.project.getCurrentDirectory(), fileName))) { return []; } const overrideForFile = getLanguageServiceForFile(fileName, overrideLanguageServices, target); return overrideForFile.getSemanticDiagnostics(fileName); }) as ts.LanguageService['getSemanticDiagnostics']; }
Что же дальше?
А дальше мы запускаем команду tsc
и видим, что наш плагин не работает. И это ожидаемо — плагин предназначен только для IDE.
Мы уже можем отлавливать новые ошибки на этапе разработки, но на этапе транспиляции они всё ещё могут проскользнуть. Нас это не устраивает. А написание плагина для транспиляции — это совершенно другой мир со своими тонкостями и костылями. О нём я расскажу в следующей статье.
P.S. А с кодом плагина можно можно ознакомиться по ссылке.
ссылка на оригинал статьи https://habr.com/ru/articles/858308/
Добавить комментарий