В разработке приложений на Typescript всегда есть этап сборки проекта. Обычно для этого используются системы сборки и автоматизации workflow, такие как webpack или gulp, обвешанные достаточным количеством плагинов, либо процесс сборки размазывается в командах package.json и шелл-скриптах с использованием нативного tsc или команд CLI используемого в проекте фреймворка. Все эти решения имеют свои плюсы и минусы. Зачастую в процессе сборки нужно сделать что-то нестандартное, и оказывается, что используемая система сборки не предоставляет нужную функциональность из коробки, а имеющиеся плагины делают не совсем то, что надо. В такие моменты работа над проектом встает, и начинается судорожное ковыряние в конфигах и поиск подходящего плагина. В какой-то момент понимаешь, что за время, потраченное на поиск подходящего костыля, можно было написать свое решение.
Во многих случаях критичные процессы в проекте можно автоматизировать скриптами на javascript, выразительность и функциональность которого вполне позволяет описать нужный workflow и выбирать из всего разнообразия библиотек, не заморачиваясь наличием для них плагинов под конкретную систему сборки. Важное преимущество такого подхода – полный контроль над процессами и максимальная гибкость. Для проектов, в которых используется Typescript в качестве основного языка разработки, возникает вопрос, как встроить процесс его компиляции в свой workflow. Здесь на помощь приходит Typescript Compiler API. В этой статье мы посмотрим, как его можно использовать для того, чтобы выполнить компиляцию проекта, реализованного на Typescript, взаимодействуя с компилятором на разных этапах его работы и напишем скрипт для hot-reloading’а REST-сервера, разработанного на Nest.js.
У меня есть проект на ноде, над которым я работаю в свободное время. Изначально он предназначался для небольшого бизнеса, который так и не взлетел, а теперь я использую его как полигон для своих экспериментов. Проект, который изначально строился на Nuxt.js в связке с Fastify, пережил множество трансформаций. Здесь и переход на typescript и преобразование в монорепозиторий, и замена Fastify на Nest.js. Поскольку проект небольшой и серьезной нагрузки не предполагалось, все это должно было крутится на одном сервере, поэтому я использовал единый инстанс Express как для REST сервера так для отдачи фронта через Nuxt.js. Для этого Express, который работает под капотом Nest.js, получает рендер-функцию Nuxt.js.
Это решение хорошо работает в продакшене, но мне, поскольку я работал над проектом один, было удобно параллельно писать функции API и делать интерфейс. Для это перезапуск Nest.js при изменении файлов не должен приводить к перезапуску работающего в режиме HMR Nuxt.js, иначе он начинал процесс сборки заново, что лишало смысла всю затею. Но тут возникла проблема, дело в том, что хотя в CLI Nest.js есть режим watch, который умеет делать hot-reload, загружает он его в отдельном процессе, который перезапускается при перекомпиляции проекта. Из-за этого каждый перезапуск сервера в таком режиме будет приводить к потере контекста для Nuxt и затем будет запускаться его полная сборка.
После того, как все это выяснилось, я решил сделать в проекте свой hot-reload. Первоначально я попробовал реализовать решение с использованием webpack, как предлагается в документации Nest.js , но оказалось, что start-server-webpack-plugin, который там используется для реализации перезапуска использовал ту же стратегию, что CLI, т.е. запускал его в отдельном процессе и перезапускал по необходимости. Этот плагин я переписал, чтобы он работал как нужно мне, но с использованием Webpack решение получилось довольно тяжеловесно и не без проблем, которые выплывали тут и там, мешая сосредоточится на разработке проекта.
Тогда я решил, что надо менять подход, мне хотелось напрямую использовать механизмы, заложенные в компилятор tsc, который всегда работал как часы быстро и стабильно. В итоге я нашел хорошую статью, в которой были примеры использования TypeScript Compiler API. Оказалось, что инструменты, встроенные в библиотеку Typescript позволяют реализовать нужные мне функции и достаточно удобны в применении. К сожалению, я столкнулся с тем, что по Typescript Compiler API очень мало информации, поэтому решил поделится тем, что мне удалось узнать. Отмечу также что данный API на момент написания статьи находится в процессе разработки и некоторые моменты со временем могут поменяться.
Простая компиляция
Чтобы разобраться с принципами работы TypeScript Compiler API для начала попробуем просто выполнить с его помощью компиляцию проекта. Проект, на котором я буду экспериментировать, имеет стандартную для nest.js структуру: исходники расположены в папке src, точка входа – файл ./src/main.ts.
Для того, чтобы компилятор успешно скомпилировал проект, ему надо передать параметры компиляции. Самый простой способ, это захаркодить его прямо в скрипте. Создадим файл tsconfig.js:
const {ModuleResolutionKind, ModuleKind, ScriptTarget} = require("typescript") module.exports = { moduleResolution: ModuleResolutionKind.NodeJs, module: ModuleKind.CommonJS, target: ScriptTarget.ES2019, declaration: true, removeComments: true, emitDecoratorMetadata: true, experimentalDecorators: true, sourceMap: true, outDir: "./dist", baseUrl: "./", allowJs: true, skipLibCheck: true, }
Обратите внимание, что некоторые параметры, такие как moduleResolution, module, target определяются через перечисления, поэтому напрямую забрать параметры из tsconfig.json, где они прописаны в виде строк не выйдет, так что для реализации простейшего примера я его использовать не буду. Как корректно забрать параметры из tsconfig.json мы разберемся немного позже.
Теперь у нас есть параметры компиляции и можно перейти к следующему шагу. Для этого создадим файл ts-compiler.js со следующим содержимым:
const ts = require('typescript'); function compile() { const compilerOptions = require('./tsconfig'); const program = ts.createProgram(['./src/main.ts'], compilerOptions); const emitResult = program.emit(); } compile();
Если теперь запустить этот скрипт на исполнение, в директории ./dist должны появится скомпилированные файлы программы. Компиляция происходит в два этапа: сначала команда createProgram анализирует исходные файлы и создает список файлов для компиляции, после чего выполняем компиляцию и сохранение скомпилированных файлов при помощи функции emit. Довольно просто.
Для повышения удобства использования теперь надо реализовать загрузку параметров компиляции из файла tsconfig.json. Добавим в скрипт следующую функцию:
const formatHost = { getCanonicalFileName: path => path, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine, }; function getTSConfig() { const configPath = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json'); const readConfigFileResult = ts.readConfigFile(configPath, ts.sys.readFile); if (readConfigFileResult.error) { throw new Error(ts.formatDiagnostic(readConfigFileResult.error, formatHost)); } const jsonConfig = readConfigFileResult.config; const convertResult = ts.convertCompilerOptionsFromJson(jsonConfig.compilerOptions, './'); if (convertResult.errors && convertResult.errors.length > 0) { throw new Error(ts.formatDiagnostics(convertResult.errors, formatHost)); } const compilerOptions = convertResult.options; return compilerOptions; }
Для работы с файлами конфигурации API предоставляет ряд функций, которые позволяют найти, загрузить и преобразовать конфигурацию в тот вид, который понимает компилятор. Здесь используется функция findConfigFile чтобы определить путь к файлу конфигурации, потом он загружается как json при помощи readConfigFile, и далее, если нет ошибок, при помощи convertCompilerOptionsFromJson получаем параметры компиляции.
В случае, если при загрузке конфига что-то пошло не так, наша функция генерирует исключения, используя механизмы передачи сообщений об ошибках, заложенные в API. Все сообщения об ошибках и предупреждения библиотека возвращает в виде объектов класса Diagnostic. Чтобы на их основе сформировать сообщение для вывода в консоль, можно использовать стандартные функции форматирования библиотеки, в данном случае это formatDiagnostic для одного сообщения и formatDiagnostics для массива. Для корректной работы функций форматирования требуется объект formatHost, который содержит важные для форматирования сообщения параметры.
Теперь, чтобы получить параметры компилятора, мы можем вызвать в нашем скрипте функцию getTSConfig.
function compile() { const compilerOptions = getTSConfig(); … }
На данном этапе, если компилятор найдет ошибки в коде, мы об этом не узнаем, давайте исправим это. Все ошибки в коде, которые были обнаружены на этапе компиляции функция emit возвращает в поле diagnostcs объекта EmitResult. Для красивого отображения ошибок в коде можно использовать функцию formatDiagnosticsWithColorAndContext. Также имеет смысл проверить ошибки, обнаруженные на этапе создания программы. Для этого используем функцию getPreEmitDiagnostics. Дополним наш скрипт следующим образом:
function compile() { const compilerOptions = getTSConfig(); const program = ts.createProgram(['./src/main.ts'], compilerOptions); console.log( ts.formatDiagnosticsWithColorAndContext( ts.getPreEmitDiagnostics(program), formatHost, ), ); const emitResult = program.emit(); console.log( ts.formatDiagnosticsWithColorAndContext(emitResult.diagnostics, formatHost), ); return emitResult.diagnostics.length === 0; }
Если теперь внести в наши исходники ошибку, получим в консоли красиво отформатированное сообщение:

Итак, мы получили скрипт, который будет компилировать наши исходники и отображать в консоли ошибки в случае их возникновения. Как видите, такой подход дает возможность очень гибко реализовать процесс компиляции, поскольку у нас есть доступ ко всем этапам и результатам компиляции. Дополнительной гибкости можно достичь, кастомизировав CompilerHost, который содержит необходимый компилятору набор функций. Допустим, мы захотели отображать в консоли пути к файлам, которые читает компилятор в процессе работы. Это несложно сделать, передав в функцию createProgram кастомизированный CompilerHost. Посмотрим, как это работает на практике.
Я не хочу засорять вывод портянкой из сотен строк путей к файлам, поэтому напишу функцию, которая будет выводить пути в одной строчке:
function displayFilename(originalFunc, operationName) { let displayEnabled = false; function displayFunction() { const fileName = arguments[0]; if (displayEnabled) { process.stdout.clearLine(); process.stdout.cursorTo(0); process.stdout.write(`${operationName}: ${fileName}`); } return originalFunc(...arguments); } displayFunction.originalFunc = originalFunc; displayFunction.enableDisplay = () => { if (process.stdout.isTTY) { displayEnabled = true; } }; displayFunction.disableDisplay = () => { if (displayEnabled) { displayEnabled = false; process.stdout.clearLine(); process.stdout.cursorTo(0); } }; return displayFunction; }
Теперь дополним код функции compile().
function compile() { const compilerOptions = getTSConfig(); const compilerHost = ts.createCompilerHost(compilerOptions); compilerHost.readFile = displayFilename(compilerHost.readFile, 'Reading'); compilerHost.readFile.enableDisplay(); const program = ts.createProgram( ['./src/main.ts'], compilerOptions, compilerHost, ); compilerHost.readFile.disableDisplay(); console.log( ts.formatDiagnosticsWithColorAndContext( ts.getPreEmitDiagnostics(program), formatHost, ), ); compilerHost.writeFile = displayFilename(compilerHost.writeFile, 'Emitting'); compilerHost.writeFile.enableDisplay() const emitResult = program.emit(); compilerHost.writeFile.disableDisplay(); console.log( ts.formatDiagnosticsWithColorAndContext(emitResult.diagnostics, formatHost), ); return emitResult.diagnostics.length === 0; }
Итак, теперь мы создаем хост с помощью функции createCompilerHost и заменяем нашей реализацией функции readFile и writeFile. Также функция compile() возвращает истину, если после окончания работы не обнаружено ошибок. Теперь в случае, если компиляция прошла без ошибок можно сразу запустить скомпилированный сервер. Посмотрим, что получилось:

Вот полный код получившегося скрипта:
const ts = require('typescript'); const formatHost = { getCanonicalFileName: path => path, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine, }; function getTSConfig() { const configPath = ts.findConfigFile( './', ts.sys.fileExists, 'tsconfig.json', ); const readConfigFileResult = ts.readConfigFile(configPath, ts.sys.readFile); if (readConfigFileResult.error) { throw new Error( ts.formatDiagnostic(readConfigFileResult.error, formatHost), ); } const jsonConfig = readConfigFileResult.config; const convertResult = ts.convertCompilerOptionsFromJson( jsonConfig.compilerOptions, './', ); if (convertResult.errors && convertResult.errors.length > 0) { throw new Error(ts.formatDiagnostics(convertResult.errors, formatHost)); } const compilerOptions = convertResult.options; return compilerOptions; } function displayFilename(originalFunc, operationName) { let displayEnabled = false; function displayFunction() { const fileName = arguments[0]; if (displayEnabled) { process.stdout.clearLine(); process.stdout.cursorTo(0); process.stdout.write(`${operationName}: ${fileName}`); } return originalFunc(...arguments); } displayFunction.originalFunc = originalFunc; displayFunction.enableDisplay = () => { if (process.stdout.isTTY) { displayEnabled = true; } }; displayFunction.disableDisplay = () => { if (displayEnabled) { displayEnabled = false; process.stdout.clearLine(); process.stdout.cursorTo(0); } }; return displayFunction; } function compile() { const compilerOptions = getTSConfig(); const compilerHost = ts.createCompilerHost(compilerOptions); compilerHost.readFile = displayFilename(compilerHost.readFile, 'Reading'); compilerHost.readFile.enableDisplay(); const program = ts.createProgram( ['./src/main.ts'], compilerOptions, compilerHost, ); compilerHost.readFile.disableDisplay(); console.log( ts.formatDiagnosticsWithColorAndContext( ts.getPreEmitDiagnostics(program), formatHost, ), ); compilerHost.writeFile = displayFilename(compilerHost.writeFile, 'Emitting'); compilerHost.writeFile.enableDisplay() const emitResult = program.emit(); compilerHost.writeFile.disableDisplay(); console.log( ts.formatDiagnosticsWithColorAndContext(emitResult.diagnostics, formatHost), ); return emitResult.diagnostics.length === 0; } compile() && require('./dist/main');
Incremental program watcher своими руками
В предыдущем разделе мы посмотрели, как можно использовать TypeScript Compiler API для того, чтобы настроить процесс компиляции Typescript. Теперь пришла пора сделать нечто более насущное и полезное, что реально может повысить скорость и удобство разработки. В процессе компиляции программы компилятору необходимо разрешить все зависимости в программе.
Чтобы это сделать он последовательно считывает и анализирует файлы проекта и файлы библиотек. Даже для компиляции относительно небольшого проекта как у меня на этапе подготовки компилятору нужно считать, распарсить и проанализировать более 2000 файлов. Это приводит к значительному замедлению процесса компиляции, например у меня на ноутбуке компиляция занимает приблизительно 20 секунд. При этом в процессе разработки обычно между запусками компилятора меняются всего несколько файлов, поэтому логично, что начиная с версии 2.7 в Typescript появилась возможность инкрементальной компиляции, которая совместно с watch-режимом, позволяющим следить за изменениями файлов на диске, значительно повысила скорость повторной компиляции.
Эти функции использует и CLI Nest.js, а начать работу в этому режиме можно при помощи команды
nest start --watch
Другая возможность использовать преимущества такого режима разработки – запускать tsc c ключом —watch. Но, как я уже писал во вступлении, если возможностей стандартных команд в какой-то момент перестает хватать, самое время заглянуть под капот Typescript и узнать, как там оно работает.
Для начала, как и в предыдущем случае, создадим ts-wather.js со следующим содержимым:
const ts = require('typescript'); const formatHost = { getCanonicalFileName: path => path, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine, }; async function watchMain() { const configPath = ts.findConfigFile( './', ts.sys.fileExists, 'tsconfig.json', ); const host = ts.createWatchCompilerHost( configPath, {}, ts.sys, ts.createEmitAndSemanticDiagnosticsBuilderProgram, diagnostic => console.log( ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost), ), diagnostic => console.log( 'Watch status: ', ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost), ), ); ts.createWatchProgram(host); } watchMain();
Запустим скрипт и посмотрим, как всё работает:

После запуска компилятор отслеживает и перекомпилирует измененные файлы, выдает красиво оформленные сообщения об ошибках, в общем работает очень похоже на tsc с опцией —watch. Обратите внимание, как быстро происходит повторная компиляция! Но какой же профит нам от этого, спросите вы, если наш скрипт делает все тоже самое что tsc или nest start —watch, не проще ли использовать готовый инструмент? А профит наш в том, что мы теперь можем с помощью хуков делать разные штуки, которые изрядно облегчат нам жизнь.
Для того, чтобы нам было удобно хукать разные штуки в хосте, давайте напишем небольшой хелпер:
function on(host, functionName, before, after) { const originalFunction = host[functionName]; host[functionName] = function() { before && before(...arguments); const result = originalFunction && originalFunction(...arguments); after && after(result); return result; }; }
Он позволит выполнять нужные нам действия до и после вызываемой функции в хосте, при этом получать аргументы, которые ей передают и результат выполнения. Теперь этот хэлпер можно использовать, чтобы удобно вмешиваться в работу компилятора на разных этапах. Для примера давайте добавим сообщения в консоль на этапе подготовки к компиляции:
… on( host, 'createProgram', () => { console.log("** We're about to create the program! **"); }, () => { console.log('** Program created **'); }, ); …
Теперь посмотрим, что поменялось:

Работает! А теперь давайте добавим строку с отображением читаемых файлов. Для этого воспользуемся функцией displayFilename из предыдущего примера:
host.readFile = displayFilename(host.readFile, 'Reading'); on( host, 'createProgram', () => { console.log("** We're about to create the program! **"); host.readFile.enableDisplay(); }, () => { host.readFile.disableDisplay(); console.log('** Program created **'); }, );
Запустим и посмотрим, что получилось:

Хорошо! Теперь сделаем то, ради чего все затевалось – перезапуск скомпилированной программы. Для этого надо сделать хук, который будет срабатывать после окончания записи на диск скомпилированных файлов.
let currentProgram; on( host, 'afterProgramCreate', (program) => { console.log('** We finished making the program! Emitting... **'); currentProgram = program; }, () => { console.log('** Emit complete! **'); const onAppClosed = () => { if (app) { setTimeout(onAppClosed, 100); } else { clearCache(); require('./dist/bootstrap') .bootstrap() .then((res) => { app = res; }); } }; if (currentProgram && currentProgram.getSemanticDiagnostics().length === 0) { onAppClosed(); } }, );
Давайте разбираться, что здесь происходит. Во-первых – переменная app, в которую мы запишем результат выполнения функции bootstrap нам понадобится для того, чтобы потом, после изменения кода и начала повторной компиляции корректно закрыть сервер. В ней будет содержаться хэндлер для вызова команды остановки сервера. Во-вторых – запуск сервера происходит только в том случае, если в процессе компиляции не найдены ошибки в коде. Для этого мы проверяем при помощи getSemanticDiagnostics чтобы количество диагностик равнялось нулю. Ну и в-третьих – поскольку модульная система кэширует загруженные модули, после внесения изменений в код нам надо кэш почистить от зависимостей. Для этого я написал небольшую вспомогательную функцию clearCache, которую мы предварительно вызываем.
function clearCache() { const cacheKeys = Object.keys(require.cache); const paths = [ join(__dirname, 'dist'), dirname(require.resolve('typeorm')), dirname(require.resolve('@nestjs/typeorm')), ]; cacheKeys .filter((item) => paths.filter((p) => item.startsWith(p)).length > 0) .forEach((item, index, arr) => { delete require.cache[item]; ); }); }
Здесь производится очистка кэша от зависимостей, которые могут нам помешать корректно перезапустить сервер. В нашем случае из кэша надо удалить все модули нашего проекта из каталога dist. Также я удаляю модули typeorm, поскольку особенности их реализации мешают серверу перезапуститься (почему так происходит, это уже другая история). При помощи таймаута дожидаемся момента, когда сервер будет закрыт и переменная app будет очищена. Закрытие сервера мы будем производить в момент, когда компилятор обнаруживает изменения файлов и приступает к созданию программы с измененными файлами. Для этого дополним хук на функции createProgram.
… let app; on( host, 'createProgram', () => { console.log("** We're about to create the program! **"); app && app.close().then(() => (app = undefined)); host.readFile.enableDisplay(); }, () => { host.readFile.disableDisplay(); console.log('** Program created **'); }, ); …
Как видите – функция закрытия сервера – асинхронная, именно поэтому нам приходится городить огород с таймаутом.
Последний штрих – это подмена process.exit().
process.exit = (code) => { console.log('!!!!!!!!!!!!!!!!!!!!!!! WARNING!!!!!!!!!!!!!!!!!!!!!!!!!'); console.trace(`Process try to exit with code ${code}.`); };
Вообще так делать не стоит, но Nest.js имеет дурную привычку ее вызывать в случае, если в процессе запуска что-то пошло не так, поэтому мы просто заменим функцию выхода из программы на сообщение об ошибке. В этом случае наш скрипт продолжит работать после вызова process.exit(). Это надо сделать перед вызовом функции watchMain.
Итак, теперь запустим наш скрипт и посмотрим, что получилось:

Тут я вношу ошибку в код сервера, после этого сервер не запускается и выводятся сообщения об ошибках, после того как ошибка исправлена, сервер вновь успешно запускается. Ниже привожу получившийся в итоге скрипт:
const ts = require('typescript'); const { join, dirname } = require('path'); const { exit } = require('process'); const formatHost = { getCanonicalFileName: (path) => path, getCurrentDirectory: ts.sys.getCurrentDirectory, getNewLine: () => ts.sys.newLine, }; function on(host, functionName, before, after) { const originalFunction = host[functionName]; host[functionName] = function () { before && before(...arguments); const result = originalFunction && originalFunction(...arguments); after && after(result); return result; }; } function clearCache() { const cacheKeys = Object.keys(require.cache); const paths = [ join(__dirname, 'dist'), dirname(require.resolve('typeorm')), dirname(require.resolve('@nestjs/typeorm')), ]; cacheKeys .filter((item) => paths.filter((p) => item.startsWith(p)).length > 0) .forEach((item, index, arr) => { delete require.cache[item]; process.stdout.clearLine(); // clear current text process.stdout.cursorTo(0); // move cursor to beginning of line process.stdout.write( `Clearing cache ${Math.floor((index * 100) / arr.length + 1)}%`, ); }); process.stdout.write(' finished.\n'); } function displayFilename(originalFunc, operationName) { let displayEnabled = false; let counter = 0; function displayFunction() { const fileName = arguments[0]; if (displayEnabled) { process.stdout.clearLine(); process.stdout.cursorTo(0); process.stdout.write(`${operationName}: ${fileName}`); } counter++; return originalFunc(...arguments); } displayFunction.originalFunc = originalFunc; displayFunction.enableDisplay = () => { counter = 0; if (process.stdout.isTTY) { displayEnabled = true; } }; displayFunction.disableDisplay = () => { if (displayEnabled) { displayEnabled = false; process.stdout.clearLine(); process.stdout.cursorTo(0); } console.log(`${counter} times function was called`); }; return displayFunction; } async function watchMain() { const configPath = ts.findConfigFile( './', ts.sys.fileExists, 'tsconfig.json', ); const host = ts.createWatchCompilerHost( configPath, {}, ts.sys, ts.createEmitAndSemanticDiagnosticsBuilderProgram, (diagnostic) => console.log( ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost), ), (diagnostic) => console.log( 'Watch status: ', ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost), ), ); host.readFile = displayFilename(host.readFile, 'Reading'); let app; on( host, 'createProgram', () => { console.log("** We're about to create the program! **"); app && app.close().then(() => (app = undefined)); host.readFile.enableDisplay(); }, () => { host.readFile.disableDisplay(); console.log('** Program created **'); }, ); let currentProgram; on( host, 'afterProgramCreate', (program) => { console.log('** We finished making the program! Emitting... **'); currentProgram = program; }, () => { console.log('** Emit complete! **'); const onAppClosed = () => { if (app) { setTimeout(onAppClosed, 100); } else { clearCache(); require('./dist/bootstrap') .bootstrap() .then((res) => { app = res; }); } }; if (currentProgram && currentProgram.getSemanticDiagnostics().length === 0) { onAppClosed(); } }, ); ts.createWatchProgram(host); } process.exit = (code) => { console.log('!!!!!!!!!!!!!!!!!!!!!!! WARNING!!!!!!!!!!!!!!!!!!!!!!!!!'); console.trace(`Process try to exit with code ${code}.`); }; watchMain();
Выводы
В этом туториале мы посмотрели, как можно использовать возможности Typescript Compiler API для того, чтобы гибко организовать процесс отладки и сборки кода. Как видите, разработчики Typescript дают нам достаточно мощный и удобный в использовании API, чтобы не было необходимости привлекать для этих целей сторонние библиотеки и при этом заметно повысить удобство и скорость разработки. Надеюсь, что приведенная здесь информация будет вам полезна, спасибо, что дочитали до конца!
ссылка на оригинал статьи https://habr.com/ru/post/508484/
Добавить комментарий