Привет ?, это статья про progressive enchancement с помощью petite-vue. Тут я расскажу про его прикольные фичи (как отдельного инструмента, так и в составе Vue). Конечно, было бы прикольно, если бы ты прочитал(а) предыдущую статью по Petite-vue, там много чего расказано про либу в целом, есть какие-то базовые примеры, но "it's okay" не читать её. Если соображаешь что-то во Vue - тут не так уж и много отличий (о которых, помимо прочего, тут и пойдёт речь).
Ну, я надеюсь ты "ready to action", так что давай сразу запрыгнем в код.
Простая реализация progressive enchancementа
<title> Petite Vue Progressive Enchancement </title>
<style>
[v-cloak] {display:none}
body { background: #fff!important }
</style>
<script src="https://unpkg.com/petite-vue" defer init></script>
<script>
const SCRIPTS = [
"https://ahfarmer.github.io/calculator/static/js/main.b319222a.js",
]
const CSS = [
"https://ahfarmer.github.io/calculator/static/css/main.b51b4a8b.css",
]
function embedScript(mount, src) {
const tag = document.createElement("script")
tag.src = src
return mount.appendChild(tag)
}
function embedCSS(mount, src) {
const tag = document.createElement("link")
tag.rel = "stylesheet"
tag.href = src
return mount.appendChild(tag)
}
function tryToLoadSPA() {
setTimeout(loadSPA, 1000)
}
function loadSPA() {
const mount = document.getElementsByTagName("head")[0]
SCRIPTS.map(src => embedScript(mount, src))
CSS.map(src => embedCSS(mount, src))
}
</script>
<body>
<div id="root" v-scope="{num: 0}" v-cloak @mounted="tryToLoadSPA">
{{ num }} <button @click="num++">+</button>
</div>
</body>
Тут, наверное, очень много всего непонятного. Наверное поможет если ты зайдёшь сразу посмотреть на готовое приложение, потыкаешься.
Что происходит по сути: пока не загрузился petite-vue - ничего не показываем, после загрузки petite-vue - показываем приложение-counter и ставим на загрузку стили и скрипты мощного React приложения через N (=1) секунд, после загрузки React приложения - petite-vue пропадает.
В качестве подопытного React приложения я взял (первая ссылка из документации :)) вот этот калькулятор
Так, ну а теперь давай разбираться, что же тут написано и как можно это улучшить.
<style>
[v-cloak] {display:none}
body { background: #fff!important }
</style>
Атрибут v-cloak
позволяет скрывать часть дерева до момента, когда petite-vue её распарсил. По сути можно задать там любой стиль, но самое логичное - скрывать элемент, который не будет работать как ожидает пользователь (эта директива есть и в обычном Vue). Ещё я задал белый бэкграунд, так как стили калькулятора его меняют, а я хочу смотреть, остаётся ли страница responsive пока я подгружаю скрипты и стили (просто на чёрном не видно чёрный текст моего счётчика (короче забей)).
После стилей видно данный джаваскрипт:
const SCRIPTS = [
"https://ahfarmer.github.io/calculator/static/js/main.b319222a.js",
]
const CSS = [
"https://ahfarmer.github.io/calculator/static/css/main.b51b4a8b.css",
]
function embedScript(mount, src) {
const tag = document.createElement("script")
tag.src = src
return mount.appendChild(tag)
}
function embedCSS(mount, src) {
const tag = document.createElement("link")
tag.rel = "stylesheet"
tag.href = src
return mount.appendChild(tag)
}
function tryToLoadSPA() {
setTimeout(loadSPA, 1000)
}
function loadSPA() {
const mount = document.getElementsByTagName("head")[0]
SCRIPTS.map(src => embedScript(mount, src))
CSS.map(src => embedCSS(mount, src))
}
По сути тут создаётся глобальная функция tryToLoadSPA
, которая будет загружать большое SPA приложение опираясь на какую-то логику (у меня стоит таймаут в демонстрационных целях). Туда можно поставить данные performance
, чтобы грузить SPA в зависимости от FCP или TTI или ещё чего угодно... Можно на этом моменте делать всплывающее окно, в котором спрашивать пользователя или он хочет загрузить расширенную версию сайта. В общем суть понятна. Костыли для асинхронной загрузки jsа и cssок я взял с stackoverflow.
Ну и заключающий кусок - уже бородатый counter:
<div id="root" v-scope="{num: 0}" v-cloak @mounted="tryToLoadSPA">
{{ num }} <button @click="num++">+</button>
</div>
Если вдруг впервые видишь v-scope
- это фича исключительно petite-vue. По сути ты задаёшь $data
поле, одновременно инициализируя дочернюю часть DOM дерева как Vue компонент (подробнее в статье x).
Тут же очередная эксклюзивная штука - @mounted
. Это директива, которая исполнит JS код каждый раз, когда компонент будет замаунтен (в нашем случае только 1 раз). В обычном Vue это поле mounted
структурки компонента. По аналогии с другими названиями директив может показаться, что есть event onmounted
, но это не так - только petite-vue может обработать/послать это событие. Точно также есть событие @unmounted
, которое срабатывает когда элемент удаляется из дерева.
Заметим, что данный элемент имеет id="root"
. Это не случайно - когда придёт React приложение, оно перетрёт всё что мы пишем на petite-vue и не возникнет никаких артефактов.
Давай посмотрим на получившийся график производительности
v-effect
Конечно, стоит согласиться, что в предыдущем примере мы получили очень мощный фундамент для progressive приложения, но я всё-таки не соглашусь. Первый вопрос, который покажет несостоятельность системы - я использую колбек @mounted
, а что если он уже будет занят???
Давай попробуем придумать решение с помощью ещё одного эксклюзива Petite-vue v-effect
. Это очень мощная директива, которая позволяет исполнять реактивные скрипты.
Если ты знаешь про useEffect
из Reactа, то v-effect
это такой useEffect
на минималках, который сам определяет массив зависимостей и прогоняет инлайн скрипт при изменении какой-то зависимости. Давай посмотрим пример из документации:
<div v-scope="{ count: 0 }">
<div v-effect="$el.textContent = count"></div>
<button @click="count++">++</button>
</div>
Тут массив зависимостей (входные/внешние переменные) определяется как [ $data.count ]
и при каждом обновлении этой переменной скриптик $el.textContent = count
будет снова и снова исполняться.
Давай теперь применим этот инструмент для нашего прогрессивного примера:
<div
id="root"
v-scope="{num: 0}"
v-cloak
v-effect="tryToLoadSPA()"
@mounted="console.log('hi')"
>
{{ num }} <button @click="num++">+</button>
</div>
Теперь мы получили тот же результат, что и в первом решении, но имеем свободный колбек для @mounted
.
Можно удостоверится что этот пример рабочий на странице.
Кастомные директивы
Ну а что если я хочу использовать и v-effect
и @mounted
??? Неужели всё-таки придётся писать костыльно? А что если я хочу автоматически собирать приложение, а не прописывать каждый раз entrypointы вручную прямо в Джаваскрипте?
В таком случае можно сделать кастомную директиву. В обычном Vue это тоже возможно, но там, очевидно, другой набор возможностей :). В общем давай попробуем сделать хоть что-то - потом разберёмся.
<title> Petite Vue Progressive Enchancement </title>
<style>
[v-cloak] {display:none}
body { background: #fff!important }
</style>
<script type="module">
import { createApp } from "https://unpkg.com/petite-vue?module"
const SCRIPTS = [
"https://ahfarmer.github.io/calculator/static/js/main.b319222a.js",
]
const CSS = [
"https://ahfarmer.github.io/calculator/static/css/main.b51b4a8b.css",
]
function embedScript(mount, src) {
const tag = document.createElement("script")
tag.src = src
return mount.appendChild(tag)
}
function embedCSS(mount, src) {
const tag = document.createElement("link")
tag.href = src
tag.rel = "stylesheet"
return mount.appendChild(tag)
}
function tryToLoadSPA() {
setTimeout(loadSPA, 1000)
}
function loadSPA() {
const mount = document.getElementsByTagName("head")[0]
SCRIPTS.map(src => embedScript(mount, src))
CSS.map(src => embedCSS(mount, src))
}
function App() {
return { num: 0 }
}
const progressiveDirective = (ctx) => {
tryToLoadSPA()
}
createApp({ App, tryToLoadSPA })
.directive('progressive', progressiveDirective)
.mount()
</script>
<body>
<div id="root" v-scope="App()" v-cloak v-progressive>
{{ num }} <button @click="num++">+</button>
</div>
</body>
Вот, мы заменили v-effect
и @mounted
на v-progressive
. Это директива, которую мы добавили вот так:
const progressiveDirective = (ctx) => {
tryToLoadSPA()
}
createApp({ App, tryToLoadSPA })
.directive('progressive', progressiveDirective)
.mount()
У нас очень простой случай - нам нужно исполнить что-то на mountе элемента, к которому привязана директива, поэтому мы не используем ctx
контекст доступный директиве, а просто вызываем там необходимую функцию.
Но в ctx
передаётся куча полезностей (из доки):
const myDirective = (ctx) => {
// элемент, на который привязана директива
ctx.el
// необработанное значение, передающееся в директиву
// для v-my-dir="x" это будет "x"
ctx.exp
// дополнительный аргумент через ":"
// v-my-dir:foo -> "foo"
ctx.arg
// массив модификаторов
// v-my-dir.mod -> { mod: true }
ctx.modifiers
// можно вычислить выражение ctx.exp
ctx.get()
// можно вычислить произвольное выражение
ctx.get(`${ctx.exp} + 10`)
ctx.effect(() => {
// это аналог v-effect, будет вызыватся при изменении значения ctx.get()
console.log(ctx.get())
})
return () => {
// колбек, который вызывается при unmountе элемента
}
}
// добавляем директиву к глобальной области petite-vue
createApp().directive('my-dir', myDirective).mount()
Можно усовершенствовать наш пример если не хардкодить константы SCRIPTS
и CSS
, а передавать внутрь директивы v-progressive
массив entrypointов и автоматически всё парсить и подгружать (cssки отдельно от jsа). Но это очень много кода, который не хочется просто вставлять сюда - идея понятна :).
Кастомные ограничители
Вроде разобрались со всем что касается расширяемости, теперь наметилась ещё одна проблема: я использую mustache/handlebars/jinja/ещё что-то где уже есть ограничители {{
и }}
.
В таком случае можно изменить ограничители petite-vue, передав...
createApp({
$delimiters: ['$<', '>$']
}).mount()
На месте $<
и >$
естественно может быть что угодно. Лучше чтобы длина была покороче и в шаблоне такие символы встречались не слишком часто (нужно подумать о скорости поиска в строке :) ).
Заключение.min.js
Не хочу что-то тут писать, потому что рассказал не про всё что есть в petite-vue. Однако рассказал обо всех уникальных (отличительных от Vue) особенностях, которые позволяют строиться прямо поверх DOMа быстрее и эффективнее. В общем нормально...
Мои примеры можно посмотреть тут.