Предистория
Была у меня курсовая по веб-разработке, делать очередной интернет-магазин как-то не хотелось, и решил я написать помощник миграции из Vue 2 (options-api) в Vue 3 (composition-api) с авторазделением на композиции с помощью алгоритма Косарайю по поиску областей сильной связности
Для тех, кто не в теме, поясню, так выглядит код с options-api:
export default { data () { return { foo: 0, bar: 'hello', } }, watch: { ... }, methods: { log(v) { console.log(v); }, }, mounted () { this.log('Hello'); }}
и примерно так с composition-api:
export default { setup (props) { const foo = reactive(0); const bar = reactive('hello'); watch(...); const log = (v) => { console.log(v); }; onMounted(() => { log('hello'); }); return { foo, bar, log, }; }}
Автоматическое разделение на композиции
Дабы не отходить от самой идеи композиций, помимо трансляции кода под новый синтаксис composition-api, было принято решение добавить и возможность разделения монолитного компонента на самостоятельные композиции, и их последующее переиспользование в главном компоненте. Как же это сделать?
Сначала зададимся вопросом, что же такое композиции? Для себя я ответил так:
Композиции это самодостаточная группа блоков кода, отвечающих за один функционал, зависящих только друг от друга. Зависимости тут самое главное!
Блоками кода в нашем случае будем считать: свойства data, методы, вотчеры, хуки, и все то, из чего строится компонент Vue.
Теперь определимся на счёт зависимостей блоков кода между собой. С этим во Vue достаточно просто:
-
Если computed, method, hook, provide свойство внутри себя использует другие свойства, то оно от них и зависит
-
Если на свойство навешен вотчер, то вотчер зависит от наблюдаемого им свойства
-
и так далее :)
data: () => ({ array: ['Hello', 'World'], // block 1}),watch: { array() { // block 2 (watch handler) depends on block 1 console.log('array changed'); },},computed: { arrayCount() { // block 3 return this.array.length; // block 3 depends on block 1 },},methods: { arrayToString() { // block 4 return this.array.join(' '); // block 4 depends on block 1 }},
Допустим, мы смогли пройтись по коду и выделить все-все зависимости свойств между собой. Как всё это делить на композиции?
А теперь абстрагируемся от Vue, проблемы миграции, синтаксиса и т.д. Оставим только сами свойства и их зависимости друг с другом.
Выделим из этого ориентированный граф, где вершинами будут свойства, а ребрами - зависимости между свойствами. А теперь самое интересное!
Алгоритм Косарайю
Алгоритм поискаобластей сильной связностив ориентированном графе. Заключается он в двух проходах в глубину по исходному и транспонированному графам и небольшой магии.
Никогда бы не подумал, что простое переписывание реализации из C на TS может быть таким проблемным :)
Так вот. Применяя данный алгоритм, мы и получим заветные композиции, состоящие из сгруппированных по связям свойств. Если же свойство оказалось одиноким и без пары, мы отнесем его к самому будущему компоненту, если же нет выделим группу в одну композицию, которую будем переиспользовать.
Поиск зависимостей
Примечание: во всех функциях компонента в options-api
свойства доступны через this
Здесь немного грусти, поскольку искать зависимости в .js приходится так:
const splitter = /this.[0-9a-zA-Z]{0,}/const splitterThis = 'this.'export const findDepsByString = ( vueExpression: string, instanceDeps: InstanceDeps): ConnectionsType | undefined => { return vueExpression .match(splitter) ?.map((match) => match.split(splitterThis)[1]) .filter((value) => instanceDeps[value]) .map((value) => value)
Да, просто проходясь регуляркой по строкому представлению
функции в поисках всего, что идет после this.
:(
Более продвинутый вариант, но такой же костыльный:
export const findDeps = ( vueExpression: Noop, instanceDeps: InstanceDeps): ConnectionsType | undefined => { const target = {} const proxy = new Proxy(target, { // прокси, который записывает в объект вызываемые им свойства get(target: any, name) { target[name] = 'get' return true }, set(target: any, name) { target[name] = 'set' return true } }) try { vueExpression.bind(proxy)() // вызываем функцию в скоупе прокси return Object.keys(target) || [] // все свойства которые вызвались при this. } catch (e) { // при ошибке возвращаемся к первому способу return findDepsByString(vueExpression.toString(), instanceDeps) || [] }}
При использовании прокси вышло несколько проблем:
-
не работает с анонимными функциями
-
при использовании вызывается сама функция а если вы там пентагон взламываете?
Создание файлов и кода
Вспомним зачем мы тут собрались: миграция.
Используя все вышеописанное, получив разбитые по полочкам свойства, нужно составить новый код в синтаксисе composition-api, то есть собрать строки, которые в конечном счете будут являться содержимыми файлов в проекте.
Для этого надо уметь представлять экземпляры объектов, строк, массивов и всего остального в их естественном, кодовом, виде. Вот эта функция:
const toString = (item: any): string => { if (Array.isArray(item)) { // array const builder: string[] = [] item.forEach((_) => { builder.push(toString(_)) // wow, it's recursion! }) return `[${builder.join(',')}]` } if (typeof item === 'object' && item !== null) { // object const builder: string[] = [] Object.keys(item).forEach((name) => { builder.push(`${name}: ${toString(item[name])}`) // wow, it's recursion! }) return `{${builder.join(',')}}` } if (typeof item === 'string') { // string return `'${item}'` } return item // number, float, boolean}// Exampleconsole.log(toString([{ foo: { bar: 'hello', baz: 'hello', }}, 1]);// [{foo:{bar: 'hello',baz: 'hello'}},1] т.е. то же самое, что и в коде
Про остальной говнокод я тактично промолчу :)
Итоговые строки мы записываем в новые файлы через простой
fs.writeFile()
в ноде и получаем результат
Пример работы
Собрав всё это в пакет, протестировав и опубликовав,
можно наконец увидеть результат работы.
Ставим пакет vue2-to-3 глобально (иначе не будет работать через консоль) и проверяем!
Пример HelloWorld.js
:
export default { name: 'HelloWorld', data: () => ({ some: 0, another: 0, foo: ['potato'], }), methods: { somePlus() { this.some++; }, anotherPlus() { this.another++; }, },};
Пишем в консоли: migrate ./HelloWorld.js
и получаем
на выход 3 файла:
// CompositionSome.jsimport { reactive } from 'vue';export const CompositionSome = () => { const some = reactive(0); const somePlus = () => { some++ }; return { some, somePlus, };};// CompositionAnother.jsimport { reactive } from 'vue';export const CompositionAnother = () => { const another = reactive(0); const anotherPlus = () => { another++ }; return { another, anotherPlus, };};// HelloWorld.jsimport { reactive } from 'vue';import { CompositionSome } from './CompositionSome.js'import { CompositionAnother } from './CompositionAnother.js'export default { name: 'HelloWorld', setup() { const _CompositionSome = CompositionSome(); const _CompositionAnother = CompositionAnother(); const foo = reactive(['potato']); return { foo, some: _CompositionSome.some, somePlus: _CompositionSome.somePlus, another: _CompositionAnother.another, anotherPlus: _CompositionAnother.anotherPlus, }; },};
Итого
На данный момент все это доступно и работает, но ещё есть некоторые баги со строковым представлением не анонимных функций и путями (в некоторых случаях фатально для linux систем)
В планах запилить миграцию для
single-file-components
и .ts
файлов
(сейчас работает только для .js
)
Спасибо за внимание!