Всем привет! Меня зовут Кирилл и я работаю фронтенд-разработчиком. Я расскажу о том, как мы перевели несколько тысяч файлов, написанных на JavaScript, с легаси кода, который использовал goog.module, на новые ES6-модули с помощью построения и преобразования абстрактного синтаксического дерева.

Эта статья будет полезна тем, у кого тоже возникла потребность в рефакторинге большого количества кода.

Причины, почему мы решили переводить нашу кодовую базу

В нашем проекте мы используем Google Closure Library. Google Closure Library — это JavaScript-библиотека, предоставляющая инструменты для работы с модулями. Подробнее об этом я расскажу ниже, но также можно прочитать документацию здесь.

Минусы goog.module:

  1. Длинные пространства имён

  2. C goog-модулями плохо работают подсказки IDE

  3. Появление скрытых зависимостей

  4. Актуальность технологий

В итоге у нас появилась потребность перевода кодовой базы на ES6, но нас немного пугала необходимость внести изменения в большую кодовую базу (900 000 строк кода) и ничего при этом не сломать.

Выбор инструмента

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

Ручное изменение файлов проекта

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

Регулярные выражения

Этот вариант может оказаться очень даже привлекательным, и сначала нам тоже так казалось. Модули, объявленные с помощью goog.module, перевести с помощью регулярных выражений легко, так как нам нужно только удалить объявление модуля и поменять формат экспорта. Пример goog.module:

goog.module('my.module.Foo') // Достаточно удалить goog.module
const googArray = goog.require('goog.array')
class Foo {}
exports = Foo // И поменять exports = Foo на export {Foo}

Основные проблемы появляются, когда нужно переводить старые модули, в которых встречались конструкции, сложные для обработки регулярными выражениями:

goog.provide('old.module.Foo') // Нужно удалить goog.provide
goog.require('goog.array')
// Поменять goog.require('goog.array') на const array = goog.require('goog.array')

goog.scope(() => { // Удалить goog.scope
  const array = goog.array // Удалить синоним
  // Избавиться от namespace'а
  old.module.Foo = goog.defineClass(null, { // Изменить синтаксис классов
	constructor: function Foo() {
      const numbers = [1, 2, 3, 4, 5];
      const evenNumbers = array.filter(numbers, num => num % 2 === 0);
      console.log('Четные числа:', evenNumbers);
	}
  })
})

// Добавить экспорт export {Foo}
goog.provide('myapp.utils') // Нужно удалить goog.provide

// Избавиться от namespace'а function sum() {}
myapp.utils.sum = function (a, b) { return a + b }

// Избавиться от namespace'а function mul() {}
myapp.utils.mul = function (a, b) { return a * b }

// Добавить экспорт export {sum, mul}

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

Рефакторинг через преобразование структуры программы

Мы воспользовались утилитой jscodeshift. Это инструмент командной строки для автоматизированной модификации JavaScript-кода с помощью шаблонов.

jscodeshift строит абстрактное синтаксическое дерево (AST) из исходного кода, которое затем можно преобразовывать при помощи кодмодов (codemod), скриптов, которые манипулируют абстрактными синтаксическими деревьями.

Плюсы jscodeshift:

  • Декларативный стиль написания кодмодов (удобство чтения).

  • Многопоточная обработка файлов.

  • Возможность запускать кодмоды в режиме “dry run”, то есть без перезаписи обрабатываемых файлов. Это помогает во время написания кодмодов.

Минусы jscodeshift:

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

Далее расскажем подробнее про абстрактное синтаксическое дерево и jscodeshift.

Абстрактное синтаксическое дерево

Абстрактное синтаксическое дерево (Abstract Syntax Tree, AST) представляет собой структуру данных, которая описывает синтаксическую структуру программы или её фрагмента.

Вот несколько ключевых понятий AST в контексте JavaScript:

  1. Узлы: Узлы AST представляют конструкции языка JavaScript, такие как вызовы функций, объявления переменных, операторы присваивания и т.д.

  2. Типы узлов: Каждый узел AST имеет свой тип, который указывает на конкретный элемент языка JavaScript. Примеры типов узлов: "FunctionDeclaration", "VariableDeclaration", "BinaryExpression" и т.д.

  3. Листья: Листья AST представляют элементарные конструкции. Например, листьями могут быть идентификаторы переменных, строковые литералы, числовые литералы и т. д.

  4. Древовидная структура: AST представляет собой иерархическую структуру, где каждый узел может иметь ноль или более дочерних узлов. Например, узел "FunctionDeclaration" может иметь дочерние узлы для имени функции, списка параметров и тела функции.

Принцип работы jscodeshift

jscodeshift работает в несколько этапов:

  1. Строит AST из исходного кода

  2. Преобразует AST с помощью кодмода

  3. Превращает AST обратно в код

Untitled

Теперь разберём представление AST в JavaScript и написание кодмода на таком примере:

goog.provide('math.add')

Визуально AST для этой программы выглядит так:

Untitled

Это представление применяется при работе с деревом из кодмод-скриптов:

{
  type: 'CallExpression',
  callee: {
    type: 'MemberExpression',
    object: {
      type: 'Identifier',
      name: 'goog'
    },
    property: {
      type: 'Identifier',
      name: 'provide'
    }
  }
}

Пример кодмода, удаляющего все вызовы goog.provide:

// src/removeGoogProvideTransform.js
// Пример кодмода, удаляющего все вызовы goog.provide
export default removeGoogProvide(file, api) {
	const jscs = api.jscodeshift
	const root = jscs(file.source) // Получаем AST-дерево
	return root
		// Ищем CallExpression (вызовы функций)
		// по шаблону {object: goog, property: provide}
		.find(jscs.CallExpression, {
			callee: {
    			object: {
					name: 'goog'
				},
				property: {
					name: 'provide'
    			}
			}
		})
		// Удаляем все найденные элементы
		.remove()
		// Преврашаем AST в файл
		.toSource()
}

Этот вызов утилиты jscodeshift удаляет из файла math/Add.js все вызовы функции goog.provide, используя написанный выше кодмод:

jscodeshift -t src/removeGoogProvideTransform.js math/Add.js
# флаг -t указывает путь к кодмоду

Процесс перевода

Мы также применили jscodeshift, чтобы автоматически модернизировать объявления классов, так как в нашем коде оставалось много мест, где классы объявлялись с помощью функции goog.defineClass:

var Foo = goog.defineClass(Bar, {
  constructor: function() {...}
  doSomething: function() {...}
}) 

Кодмод преобразовывал объявление классов функцией goog.defineClass в объявление с синтаксис ES6:

class Foo extends Bar {
    constructor() {}
    doSomething() {}
}

После того, как мы написали скрипт, мы запустили его на трёх небольших проектах (всего около 500 файлов). Перевод занял 1 час, который ушёл на запуск скрипта и ручную правку некоторых сложных моментов.

Затем мы провели обновление кода самого большого из наших проектов. Эта работа прошла в несколько этапов:

  1. Автоматическое обновление исходников с помощью кодмодов.

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

Тестирование основных пользовательских кейсов проекта заняло около недели. После чего мы зарелизились и, кажется, ничего не сломали?.

Итоги

Всего на данный момент мы изменили 3 908 файлов в 4 проектах. Мы планируем продолжать рефакторинг, чтобы в конечном итоге полностью избавиться от старых модулей в коде.

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

Советы

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

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

  2. Умейте остановиться. Рекомендуем автоматически трансформировать только часто встречающиеся паттерны. Недочёты, остающиеся в коде в нескольких местах, оказалось исправить вручную быстрее, чем писать и отлаживать кодмод.

Полезные ссылки

Мы собрали список ссылок с полезными материалами, которые могут помочь вам в переводе goog-модулей на ES6-модули

  1. https://github.com/google/closure-compiler/wiki/Migrating-from-goog.modules-to-ES6-modules - Статья от Google по миграции с goog-модулей на ES6-синтаксис

  2. https://github.com/facebook/jscodeshift - jscodeshift

  3. https://www.youtube.com/watch?v=-YZt2DW75h8 - доклад от Александра Мышова по jscodeshift, который поможет понять принцип работы с jscodeshift

  4. https://github.com/schmidtk/opensphere-jscodeshift/ - мы вдохновлялся фрагментами кода из этого репозитория, где разработчики решали проблему подобную нашей

  5. https://astexplorer.net - построение AST дерева кода, полезно при использовании jscodeshift

  6. https://doc.esdoc.org/github.com/mason-lang/esast/ - Описание узлов синтаксического дерева для JavaScript

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


  1. punzik
    19.06.2024 20:03

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


    1. alexeymalov
      19.06.2024 20:03
      +2

      Можете раскрыть свою мысль подробнее? Как здесь помог бы lisp?


      1. punzik
        19.06.2024 20:03

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