Расширенные Веб-компоненты
GitHub | GitFlic | GitVerse | NpmJS | Скачать⤵️
Creaton (сокр. Ctn) – это фреймворк JavaScript для быстрого создания Веб-компонентов. Он поддерживает все методы и свойства, которые предоставляются стандартными Веб-компонентами. Кроме этого, фреймворк содержит ряд дополнительных методов и реализует рендеринг Веб-компонентов на стороне сервера.
Ниже представлен пример создания простого компонента:
class WHello {
// инициализация свойств объекта состояния
message = 'Creaton'
color = 'orange'
static mode = 'open' // добавить Теневой DOM
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
<style>
h1 {
color: ${ this.color };
}
</style>
`
}
}
- Быстрый старт
- Состояние компонента
- Циклы
- Примеси
- Статические свойства
- Специальные методы
- Эмиттер событий
- Маршрутизатор
- Серверный рендеринг
Быстрый старт
Для создания компонентов применяются классы. Классы могут быть как встроенными в основной скрипт, так и импортированы из внешнего модуля. Создайте новый рабочий каталог, например, с названием app, и скачайте в этот каталог файл ctn.global.js.
Добавьте в каталог файл index.html со следующим содержимым:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- подключить компонент Hello к документу -->
<w-hello></w-hello>
<script src="ctn.global.js"></script>
<script>
class WHello {
// инициализация свойств объекта состояния
message = 'Creaton'
color = 'orange'
static mode = 'open' // добавить Теневой DOM
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
<style>
h1 {
color: ${ this.color };
}
</style>
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
</script>
</body>
</html>
Чтобы гарантировать отсутствие конфликтов имён между стандартными и пользовательскими HTML-элементами, имя компонента должно содержать дефис «-», например, “my-element” и “super-button” – это правильные имена, а “myelement” – нет.
В большинстве примеров этого руководства, префикс будет состоять из одной буквы «w-». т.е. компонент Hello будет называться “w-hello”.
При определении класса компонента, его префикс и имя должны начинаться с заглавной буквы. WHello – это правильное название класса, а wHello – нет.
Открыв файл index.html в браузере, на экране отобразится созданное в компоненте Hello сообщение:
Привет, Creaton!
Компоненты можно выносить в отдельные модули. В этом случае, файл компонента Hello выглядел бы как показано ниже:
export default class WHello {
// инициализация свойств объекта состояния
message = 'Creaton'
color = 'orange'
static mode = 'open' // добавить Теневой DOM
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
<style>
h1 {
color: ${ this.color };
}
</style>
`
}
}
Для работы с внешними компонентами, вам потребуется любой разработочный сервер, такой, например, как lite-server.
Установить данный сервер можно с помощью команды в терминале:
npm install --global lite-server
Запуск сервера из каталога, в котором находится приложение, осуществляется с помощью команды в терминале:
lite-server
Кроме этого, фреймворк поддерживает однофайловые компоненты, которые могут быть использованы наравне с модульными, при создании проекта в системе сборки webpack.
Ниже показан пример простого однофайлового компонента:
<h1>Привет, ${ this.message }!</h1>
<style>
h1 {
color: ${ this.color };
}
</style>
<script>
exports = class WHello {
// инициализация свойств объекта состояния
message = 'Creaton'
color = 'orange'
static mode = 'open' // добавить Теневой DOM
}
</script>
Однофайловый компонент должен присваивать свой класс переменной exports. Эта переменная будет автоматически объявлена во время создания структуры компонента в системе сборки проекта.
В однофайловых компонентах можно использовать инструкцию import, например:
<script>
// импортировать из модуля объект по умолчанию
import obj from './module.js'
exports = class WHello {
// инициализация свойств объекта состояния
message = obj.message
color = obj.color
static mode = 'open' // добавить Теневой DOM
}
</script>
Однофайловые компоненты позволяют выделить HTML-разметку из логики компонента. Однако, такие компоненты не могут работать в браузере напрямую. Они требуют специального обработчика, который подключается в webpack.
Чтобы иметь возможность работать в браузере с компонентами, в которых логика отделена от HTML-содержимого, существуют встроенные компоненты.
Ниже показан пример простого встроенного компонента:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- подключить компонент Hello к документу -->
<w-hello></w-hello>
<!-- определить шаблон компонента Hello -->
<template id="tempHello">
<h1>Привет, ${ this.message }!</h1>
<style>
h1 {
color: ${ this.color };
}
</style>
<script>
return class WHello {
// инициализация свойств объекта состояния
message = 'Creaton'
color = 'orange'
static mode = 'open' // добавить Теневой DOM
}
</script>
</template>
<script src="ctn.global.js"></script>
<script>
// передать шаблон компонента Hello функции Ctn
Ctn(tempHello)
</script>
</body>
</html>
Встроенный компонент должен возвращать свой класс, а содержимое его тега <script> можно рассматривать как функцию. Однако, встроенные компоненты не подходят для рендеринга на стороне сервера и, кроме этого, в них нельзя использовать инструкцию import, но допускается использование выражения import(), например:
<script>
// импортировать модуль и сохранить его объект в переменной
let obj = await import('./module.js')
return class WHello {
// инициализация свойств объекта состояния
message = obj.message
color = obj.color
static mode = 'open' // добавить Теневой DOM
}
</script>
Независимо от типа компонента, при использовании в HTML-разметке символа обратная кавычка «`», он должен быть экранирован символом обратного слэша «\», как показано ниже:
// вернуть HTML-разметку компонента
static template() {
return `
<h1>\`Привет\`, ${ this.message }!</h1>
<style>
h1 {
color: ${ this.color };
}
</style>
`
}
Это связано с тем, что HTML-разметка любого компонента, всегда размещается внутри шаблонной строки. Для однофайловых и встроенных компонентов, это делается на уровне их преобразования в обычный класс компонента.
Для быстрого доступа к компоненту, достаточно добавить идентификатор к элементу, который подключает компонент к документу, как показано ниже:
<!-- подключить компонент Hello к документу -->
<w-hello id="hello"></w-hello>
Теперь откройте консоль браузера и последовательно введите команды:
hello.$state.message = 'Веб-компоненты'
hello.$state.color = 'green'
hello.$update()
Цвет и содержимое заголовка изменятся:
Привет, Веб-компоненты!
Состояние компонента
Каждый компонент может содержать изменяющиеся данные, которые называются состоянием. Состояние можно определить в конструкторе класса компонента:
class WHello {
constructor() {
// инициализация свойств объекта состояния
this.message = 'Creaton'
this.color = 'orange'
}
...
}
В качестве альтернативы, используя новый синтаксис, можно определить состояние непосредственно в самом классе:
class WHello {
// инициализация свойств объекта состояния
message = 'Creaton'
color = 'orange'
...
}
Методы компонента не являются состоянием. Они предназначены для выполнения действий с состоянием компонента и хранятся в прототипе объекта состояния:
class WHello {
// инициализация свойства объекта состояния
message = 'Creaton'
// определить метод объекта состояния
printStr(str) {
return this.message
}
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.printStr() }!</h1>
`
}
}
Для доступа к объекту состояния, применяется специальное свойство $state. С помощью этого свойства, можно получить или присвоить новое значение состоянию, как показано ниже:
hello.$state.message = 'Веб-компоненты'
Для обновления содержимого компонента на основе нового состояния, применяется специальный метод $update(), например:
hello.$update()
Когда содержимое компонента обновляется, то его старый DOM не удаляется. Вместо этого, создаётся временный виртуальный DOM, на основе возвращаемого содержимого статического метода template() и обновлённого объекта состояния. Это означает, что обработчики, назначенные элементам внутри компонента, сохраняются, поскольку старый элемент не заменяется новым элементом.
В примере ниже, обработчик элемента <h1> будет работать и после обновления компонента. Поскольку обновление изменит только старое значение его атрибута и текстового содержимого:
class WHello {
// инициализация свойства объекта состояния
message = 'Creaton'
/* этот метод выполняется после подключения компонента к документу
когда для компонента уже создан DOM, из которого можно выбирать элементы */
static connected() {
this.$('h1').addEventListener('click', e => console.log(e.target))
}
// вернуть HTML-разметку компонента
static template() {
return `
<h1 title="${ this.message }">Привет, ${ this.message }!</h1>
`
}
}
Циклы
Для вывода циклов, применяются методы map() и join() или метод reduce().
При использовании метода map(), необходимо в конце добавлять метод join() с аргументом пустой строки, чтобы удалить запятые между элементами массива:
class WHello {
// инициализация свойства объекта состояния
rgb = ['Красный', 'Зелёный', 'Синий']
// вернуть HTML-разметку компонента
static template() {
return `
<ul>
${ this.rgb.map(col => `<li>${ col }</li>`).join('') }
</ul>
`
}
}
При использовании метода reduce(), добавлять в конце метод join() не нужно:
class WHello {
// инициализация свойства объекта состояния
rgb = ['Красный', 'Зелёный', 'Синий']
// вернуть HTML-разметку компонента
static template() {
return `
<ul>
${ this.rgb.reduce((str, col) => str += `<li>${ col }</li>`, '') }
</ul>
`
}
}
Можно воспользоваться специальной теговой функцией $tag, которая автоматически добавляет метод join() всем массивам и может вызывать методы объекта состояния, когда они указаны в HTML-разметке без скобок “()”, например:
class WHello {
// инициализация свойств объекта состояния
rgb = ['Красный', 'Зелёный', 'Синий']
message = 'Creaton'
// определить метод объекта состояния
printStr(str) {
return this.message
}
// вернуть HTML-разметку компонента
static template() {
return this.$tag`
<h1>Привет, ${ this.printStr }!</h1>
<ul>
${ this.rgb.map(col => `<li>${ col }</li>`) }
</ul>
`
}
}
По умолчанию, все встроенные и однофайловые компоненты, используют для создания своего HTML-содержимого данную функцию:
<ul>
${ this.printArr }
</ul>
<script>
exports = class WHello {
// инициализация свойства объекта состояния
rgb = ['Красный', 'Зелёный', 'Синий']
// определить метод объекта состояния
printArr() {
return this.rgb.map(col => `<li>${ col }</li>`)
}
}
</script>
Для вывода объектов, используется метод Object.keys(), как показано ниже:
class WHello {
// инициализация свойства объекта состояния
user = {
name: 'Иван',
age: 36
}
// определить метод объекта состояния
printObj() {
const { user } = this
return Object.keys(user).map(prop => `<li>${ prop } – ${ user[prop] }</li>`)
}
// вернуть HTML-разметку компонента
static template() {
return this.$tag`
<ul>${ this.printObj }</ul>
`
}
}
или цикл for-in, например:
// определить метод объекта состояния
printObj() {
const { user } = this
let str = ''
for (const prop in user) {
str += `<li>${ prop } – ${ user[prop] }</li>`
}
return str
}
Примеси
Примесь – общий термин в объектно-ориентированном программировании: класс, который содержит в себе методы для других классов. Эти методы могут использовать разные компоненты, что позволяет не создавать методы с одинаковым функционалом для каждого компонента отдельно.
В примере ниже, метод printName() из примеси используют компоненты Hello и Goodbye:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- подключить компонент Hello к документу -->
<w-hello></w-hello>
<!-- подключить компонент Goodbye к документу -->
<w-goodbye></w-goodbye>
<script src="ctn.global.js"></script>
<script>
// определить класс Mixin для общих методов
class Mixin {
printName() {
return this.userName
}
}
// расширить класс компонента Hello от класса Mixin
class WHello extends Mixin {
// инициализация свойства объекта состояния
userName = 'Анна'
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.printName() }!</h1>
`
}
}
// расширить класс компонента Goodbye от класса Mixin
class WGoodbye extends Mixin {
// инициализация свойства объекта состояния
userName = 'Иван'
// вернуть HTML-разметку компонента
static template() {
return `
<p>До свидания, ${ this.printName() }...</p>
`
}
}
// передать классы компонентов Hello и Goodbye функции Ctn
Ctn(WHello, WGoodbye)
</script>
</body>
</html>
Статические свойства
name – это статическое свойство используется, например, когда функции Ctn передаётся анонимный класс, как показано ниже:
// передать анонимный класс компонента Hello функции Ctn
Ctn(class {
static name = 'w-hello' // название компонента
// инициализация свойства объекта состояния
message = 'Creaton'
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
`
}
})
mode – это статическое свойство отвечает за добавление компоненту Теневого DOM. Оно может содержать два значения: “open” или “closed”. В последнем случае, когда компонент является закрытым, то невозможно получить доступ из консоли к свойствам его объекта состояния, методам выборки элементов и обновления содержимого.
Доступ к свойствам объекта состояния, методам выборки и обновления содержимого компонента, в закрытых компонентах возможен только из статических методов, например:
class WHello {
static mode = 'closed' // добавить закрытый Теневой DOM
// выполняется в конце подключения компонента к документу
static connected() {
// получить элемент с помощью метода выборки
const elem = this.$('h1')
// добавить элементу обработчик события
elem.addEventListener('click', e => console.log(e.target))
}
// инициализация свойства объекта состояния
message = 'Creaton'
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
`
}
}
Только компоненты имеющие Теневой DOM, могут содержать локальные стили.
extends – это статическое свойство отвечает за создание модифицированных компонентов, т.е. таких, которые встраиваются в стандартные HTML-элементы, например:
<body>
<!-- встроить компонент Hello в элемент header -->
<header is="w-hello"></header>
<script src="ctn.global.js"></script>
<script>
class WHello {
static extends = 'header' // название встраиваемого элемента
// инициализация свойства объекта состояния
message = 'Creaton'
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
</script>
</body>
Свойство должно содержать название встраиваемого элемента, а сам встраиваемый элемент, должен содержать атрибут is со значением, равным названию встраиваемого в него компонента.
serializable – это статическое свойство отвечает за сериализацию Теневого DOM компонента с помощью метода getHTML(). По умолчанию имеет значение “false”.
template() – этот статический метод возвращает будущее HTML-содержимое компонента:
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
`
}
startConnect() – этот статический метод выполняется в самом начале подключения компонента к документу, до формирования HTML-содержимого компонента и вызова статического метода connected(), но после создания объекта состояния компонента.
В нём можно инициализировать свойства объекта состояния имеющимися значениями:
class WHello {
// выполняется в начале подключения компонента к документу
static startConnect() {
// инициализация свойства объекта состояния
this.message = 'Creaton'
}
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
`
}
}
или получить данные с сервера для их инициализации. Но в этом случае, метод должен быть асинхронным:
class WHello {
// выполняется в начале подключения компонента к документу
static async startConnect() {
// инициализация свойства объекта состояния данными с условного сервера
this.message = await new Promise(ok => setTimeout(() => ok('Creaton'), 1000))
}
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
`
}
}
Это единственный статический метод, который можеть быть асинхронным.
connected() – этот статический метод выполняется в самом конце подключения компонента к документу, после формирования HTML-содержимого компонента и вызова статического метода startConnect().
В нём можно добавлять обработчики событий внутренним элементам компонента:
class WHello {
// выполняется в конце подключения компонента к документу
static connected() {
// получить элемент с помощью метода выборки
const elem = this.$('h1')
// добавить элементу обработчик события
elem.addEventListener('click', e => console.log(e.target))
}
// инициализация свойства объекта состояния
message = 'Creaton'
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
`
}
}
Этот и все последующие статические методы, являются сокращениями стандартных статических методов компонента.
disconnected() – этот статический метод выполняется при удалении компонента из документа.
adopted() – этот статический метод выполняется при перемещении компонента в новый документ.
changed() – этот статический метод выполняется при изменении одного из отслеживаемых атрибутов.
attributes – этот статический массив содержит имена отслеживаемых атрибутов, например:
<body>
<!-- подключить компонент Hello к документу -->
<w-hello data-message="Creaton"></w-hello>
<script src="ctn.global.js"></script>
<script>
class WHello {
// выполняется в начале подключения компонента к документу
static startConnect() {
// инициализация свойства объекта состояния
this.message = this.$data.message
}
// выполняется в конце подключения компонента к документу
static connected() {
// получить элемент с помощью метода выборки
const elem = this.$('h1')
// добавить элементу обработчик события
elem.addEventListener('click', e => this.$data.message = 'Веб-компоненты')
}
// выполняется при изменении одного из отслеживаемых атрибутов
static changed(name, oldValue, newValue) {
// если новое значение атрибута не равно старому значению
if (newValue !== oldValue) {
// изменить значение свойства объекта состояния
this.message = newValue
this.$update() // обновить HTML-содержимое компонента
}
}
// содержит имена отслеживаемых атрибутов
static attributes = ['data-message']
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.message }!</h1>
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
</script>
</body>
Все статические методы вызываются в контексте прокси объекта состояния компонента. Это означает, что если необходимое свойство не обнаруживается в объекте состояния, то поиск происходит в самом компоненте.
В примере ниже, свойства id не существует в объекте состояния компонента. Поэтому оно запрашивается из самого компонента:
<body>
<!-- подключить компонент Hello к документу -->
<w-hello id="hello"></w-hello>
<script src="ctn.global.js"></script>
<script>
class WHello {
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, компонент с ID ${ this.id }!</h1>
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
</script>
</body>
Специальные методы
Все специальные методы и свойства начинаются с символа доллара «$», за которым следует название метода или свойства.
$update() – этот специальный метод выполняется для обновления содержимого компонента, после изменения его состояния:
hello.$state.message = 'Веб-компоненты'
hello.$update()
Этот метод обновляет содержимое закрытых компонентов, только если он вызывается из статических методов класса компонента. Для всех остальных типов компонентов, он возвращает количество миллисекунд, за которое содержимое компонента было обновлено.
$() – этот специальный метод выбирает элемент из содержимого компонента по указаному селектору, например, для добавления элементу обработчика события:
// выполняется в конце подключения компонента к документу
static connected() {
// получить элемент с помощью метода выборки
const elem = this.$('h1')
// добавить элементу обработчик события
elem.addEventListener('click', e => console.log(e.target))
}
Этот метод выбирает содержимое закрытых компонентов, только если он вызывается из статических методов класса компонента.
$$() – этот специальный метод выбирает все элементы из содержимого компонента по указаному селектору, например, для добавления элементам обработчиков событий при переборе их в цикле:
// выполняется в конце подключения компонента к документу
static connected() {
// получить все элементы с помощью метода выборки
const elems = this.$$('h1')
// перебрать коллекцию элементов в цикле
for (const elem of elems) {
// добавить элементу обработчик события
elem.addEventListener('click', e => console.log(e.target))
}
}
Этот метод выбирает содержимое закрытых компонентов, только если он вызывается из статических методов класса компонента.
$entities() – этот специальный метод обезвреживает строку, содержащую HTML-содержимое полученное из недостоверных источников. По умолчанию, экранируется символ амперсанда «&», символы меньше «<» и больше «>», двойные «"» и одинарные кавычки «'», например:
class WHello {
// выполняется в начале подключения компонента к документу
static async startConnect() {
// получение HTML-содержимого с условного сервера
const html = await new Promise(ok => setTimeout(() => ok('<script>опасный код<\/script>'), 1000))
// инициализация свойства объекта состояния обезвреженным HTML-содержимым
this.message = this.$entities(html)
}
// вернуть HTML-разметку компонента
static template() {
return this.message
}
}
Кроме вышеперечисленных символов, можно экранировать любые символы, передав во втором и последующих аргументах массив вида: [регулярное выражение, строка замены], например:
this.$entities(html, [/\(/g, '('], [/\)/g, ')'])
Этот метод доступен в качестве свойства функции Ctn, как показано ниже:
Ctn.entities(html)
или именованного импорта, когда используется модульная версия фреймворка:
<body>
<!-- подключить компонент Hello к документу -->
<w-hello></w-hello>
<script type="module">
import Ctn, { Entities } from "./ctn.esm.js"
class WHello {
// вернуть HTML-разметку компонента
static template() {
return `
${ Entities('<script>опасный код<\/script>') }
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
</script>
</body>
$tag() – этот специальный метод является теговой функцией, которая автоматически добавляет метод join() с аргументом пустой строки всем массивам, для удаления запятых между элементами, и может вызывать методы объекта состояния, когда они указаны в HTML-разметке без скобок “()”, например:
class WHello {
// инициализация свойств объекта состояния
rgb = ['Красный', 'Зелёный', 'Синий']
message = 'Creaton'
// определить метод объекта состояния
printStr(str) {
return this.message
}
// вернуть HTML-разметку компонента
static template() {
return this.$tag`
<h1>Привет, ${ this.printStr }!</h1>
<ul>
${ this.rgb.map(col => `<li>${ col }</li>`) }
</ul>
`
}
}
Специальные методы: $event(), $router() и $render(), будут рассмотрены в следующих разделах. Как и для метода $entities(), для них так же имеются свои именованные импорты:
import Ctn, { Tag, Event, Router, Render } from "./ctn.esm.js"
Функция Ctn всегда импортируется по умолчанию.
$state – это специальное свойство ссылается на прокси объекта состояния компонента. Это означает, что если необходимое свойство не обнаруживается в объекте состояния, то поиск происходит в самом компоненте.
В примере ниже, свойства id не существует в объекте состояния компонента. Поэтому оно запрашивается из самого компонента:
<body>
<!-- подключить компонент Hello к документу -->
<w-hello id="hello"></w-hello>
<script src="ctn.global.js"></script>
<script>
class WHello {
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, компонент с ID ${ this.id }!</h1>
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
</script>
</body>
$host – это специальное свойство ссылается на элемент, который подключает компонент к документу, т.е. на элемент компонента. Это может пригодиться, если свойства с одинаковыми именами имеются и объекте состояния и в компоненте.
Прокси объекта состояния изначально ищет свойство в самом объекте состояния, это значит, что для получения одноимённого свойства из элемента компонента, необходимо использовать специальное свойство $host, как показано ниже:
<body>
<!-- подключить компонент Hello к документу -->
<w-hello id="hello"></w-hello>
<script src="ctn.global.js"></script>
<script>
class WHello {
// инициализация свойства объекта состояния
id = 'Creaton'
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, свойство ID со значением ${ this.id }!</h1>
<h2>Привет, компонент с ID ${ this.$host.id }!</h2>
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
</script>
</body>
$shadow – это специальное свойство ссылается на Теневой DOM компонента:
hello.$shadow
Для закрытых компонентов и компонентов без Теневого DOM, это свойство возвращает значение “null”.
$data – это специальное свойство ссылается на объект dataset компонента, который используется для доступа к пользовательским атрибутам, например:
<body>
<!-- подключить компонент Hello к документу -->
<w-hello data-message="Creaton"></w-hello>
<script src="ctn.global.js"></script>
<script>
class WHello {
// вернуть HTML-разметку компонента
static template() {
return `
<h1>Привет, ${ this.$data.message }!</h1>
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
</script>
</body>
Эмиттер событий
Чтобы компоненты могли взаимодействовать между собой и обмениваться друг с другом данными, применяются пользовательские события. Для создания пользовательских событий, используется специальный метод $event(), который доступен в качестве свойства функции Ctn.
Если метод вызывается как конструктор, то он возвращает новый объект эмиттера, который будет генерировать и отслеживать пользовательские события, например:
const emit = new Ctn.event()
В роли эмиттера выступает обычный фрагмент документа. Можно создавать сколько угодно новых эмиттеров, и каждый эмиттер может генерировать и отслеживать сколько угодно новых пользовательских событий.
Когда метод $event() вызывается как обычная функция, то в первом аргументе он получает эмиттер, во втором передаётся название пользовательского события, а в третьем аргументе можно передавать любые данные:
this.$event(emit, 'new-array', ['Оранжевый', 'Фиолетовый'])
Эти данные затем будут доступны в обработчике пользовательского события, в качестве свойства detail объекта события Event, как показано ниже:
emit.addEventListener('new-array', event => {
this.rgb = event.detail
this.$update()
})
В системе сборки webpack, эмиттер можно экспортировать из отдельного модуля, например, из файла Events.js:
import { Event } from 'creaton-js'
export const Emit = new Event()
для последующего импорта эмиттера в файлы компонентов, которые будут его использовать:
import { Emit } from './Events'
В примере ниже, каждой кнопке из компонента Hello добавляется обработчик события “click”, внутри которого, происходит запуск соответствующего пользовательского события объекта эмиттера.
Для отслеживания пользовательских событий, эмиттеру назначаются соответствующие обработчики в компоненте Colors. В последнем обработчике, через свойство detail объекта события Event, свойству состояния присваивается новый массив:
<body>
<!-- подключить компонент Hello к документу -->
<w-hello></w-hello>
<!-- подключить компонент Colors к документу -->
<w-colors></w-colors>
<script src="ctn.global.js"></script>
<script>
// создать новый объект эмиттера
const emit = new Ctn.event()
class WHello {
// вернуть HTML-разметку компонента
static template() {
return `
<button id="reverse">Обратить массив</button>
<button id="new-array">Новый массив</button>
`
}
// выполняется в конце подключения компонента к документу
static connected() {
// добавить обработчик события кнопке "Обратить массив"
this.$('#reverse').addEventListener('click', () => {
// инициировать событие "reverse"
this.$event(emit, 'reverse')
})
// добавить обработчик события кнопке "Новый массив"
this.$('#new-array').addEventListener('click', () => {
// инициировать событие "new-array"
this.$event(emit, 'new-array', ['Оранжевый', 'Фиолетовый'])
})
}
}
class WColors {
// инициализация свойства объекта состояния
rgb = ['Красный', 'Зелёный', 'Синий']
// вернуть HTML-разметку компонента
static template() {
return `
<ul>
${ this.rgb.reduce((str, col) => str += `<li>${ col }</li>`, '') }
</ul>
`
}
// выполняется в конце подключения компонента к документу
static connected() {
// добавить эмиттеру обработчик события "reverse"
emit.addEventListener('reverse', () => {
this.rgb.reverse() // обратить массив
this.$update() // обновить компонент
})
// добавить эмиттеру обработчик события "new-array"
emit.addEventListener('new-array', event => {
this.rgb = event.detail // присвоить свойству новый массив
this.$update() // обновить компонент
})
}
}
// передать классы компонентов Hello и Colors функции Ctn
Ctn(WHello, WColors)
</script>
</body>
Маршрутизатор
В основе маршрутизатора лежат пользовательские события. Для создания маршрутных событий, используется специальный метод $router(), который доступен в качестве свойства функции Ctn.
Если метод вызывается как конструктор, то он возвращает новый объект эмиттера с переопределённым методом addEventListener(), который будет генерировать и отслеживать маршрутные события, например:
const emitRouter = new Ctn.router()
Когда метод $router() вызывается как обычная функция, то в первом аргументе он получает эмиттер, во втором передаётся название маршрутного события, а в третьем аргументе можно передавать любые данные:
this.$router(emitRouter, '/about', ['Оранжевый', 'Фиолетовый'])
В реальном приложении, название маршрутного события указывается не непосредственно, как в примере выше, а берётся из значения атрибута href ссылки, по которой был произведён клик, например:
this.$router(emitRouter, event.target.href, ['Оранжевый', 'Фиолетовый'])
Передаваемые в последнем аргументе метода $router() пользовательские данные, будут доступны в обработчике маршрутного события, в качестве свойства detail объекта события Event, как показано ниже:
emitRouter.addEventListener('/about', event => {
const arr = event.detail
...
})
Начальный слэш «/» в названии маршрутного события не является обязательным:
emitRouter.addEventListener('about', event => {
const arr = event.detail
...
})
Вся остальная часть названия маршрутного события, кроме начального слэша, должна до конца совпадать со значением атрибута href ссылки, после нажатия на которую, сработает соответствующий этому значению обработчик:
<a href="/about">О нас</a>
Разница между пользовательскими и маршрутными событиями в том, что строка указанная в обработчике маршрутного события преобразуется в регулярное выражение и может содержать специальные символы регулярных выражений, как показано ниже:
emitRouter.addEventListener('/abou\\w', event => {
...
})
Чтобы не приходилось дважды использовать символ обратного слэша в обычной строке для экранирования специальных символов регулярных выражений, можно воспользоваться теговой функцией raw() встроенного объекта String, заключив название маршрутного события в шаблонную строку, например:
emitRouter.addEventListener(String.raw`/abou\w`, event => {
...
})
или так:
const raw = String.raw
emitRouter.addEventListener(raw`/abou\w`, event => {
...
})
Кроме свойства detail, объект события Event имеет дополнительное свойство params, для получения параметров маршрутов, как показано ниже:
emitRouter.addEventListener('/categories/:catId/products/:prodId', event => {
const catId = event.params["catId"]
const prodId = event.params["prodId"]
...
})
Этот обработчик будет выполняться для всех ссылок вида:
<a href="/categories/5/products/7">Продукт</a>
тогда catId будет иметь значение 5, а prodId значение 7.
Для поддержки параметров запросов, объект события Event имеет дополнительное свойство search, которое является короткой ссылкой на свойство searchParams встроенного класса URL, например:
const raw = String.raw
emitRouter.addEventListener(raw`/categories\?catId=\d&prodId=\d`, event => {
const catId = event.search.get("catId")
const prodId = event.search.get("prodId")
...
})
Этот обработчик будет выполняться для всех ссылок вида:
<a href="/categories?catId=5&prodId=7">Продукт</a>
тогда catId будет иметь значение 5, а prodId значение 7.
Последнее дополнительное свойство объекта события Event называется url, оно является объектом встроенного класса URL и помогает разобрать запрос на составляющие:
emitRouter.addEventListener('/about', event => {
const hostname = event.url.hostname
const origin = event.url.origin
...
})
Ниже показан пример создания простого маршрутизатора для трёх компонентов страниц:
<body>
<!-- подключить компонент Menu к документу -->
<w-menu></w-menu>
<!-- подключить компонент Content к документу -->
<w-content></w-content>
<script src="ctn.global.js"></script>
<script>
class WHome {
// вернуть HTML-разметку компонента
static template() {
return `<h1>Главная</h1>`
}
}
class WAbout {
// вернуть HTML-разметку компонента
static template() {
return `<h1>О нас</h1>`
}
}
class WContacts {
// вернуть HTML-разметку компонента
static template() {
return `<h1>Контакты</h1>`
}
}
// создать новый объект эмиттера для маршрутизатора
const emitRouter = new Ctn.router()
class WMenu {
// выполняется в конце подключения компонента к документу
static connected() {
// добавить обработчик события "click" для элемента NAV
this.$('nav').addEventListener('click', event => {
event.preventDefault() // отменить действие по умолчанию
// инициировать событие для значения "href" текущей ссылки
this.$router(emitRouter, event.target.href)
})
}
// вернуть HTML-разметку компонента
static template() {
return `
<nav>
<a href="/">Главная</a>
<a href="/about">О нас</a>
<a href="/contacts">Контакты</a>
</nav>
`
}
}
class WContent {
// инициализация свойства объекта состояния
page = ''
// выполняется в конце подключения компонента к документу
static connected() {
// добавить эмиттеру обработчик события с необязательным параметром маршрута
emitRouter.addEventListener(`(:page)?`, event => {
// присвоить свойству название компонента страницы
this.page = `w-${event.params.page || 'home'}`
this.$update() // обновить компонент
})
// инициировать событие для значения "href" текущей страницы
this.$router(emitRouter, location.href)
}
// вернуть HTML-разметку компонента
static template() {
// если свойство содержит название страницы
if (this.page) {
return `<${this.page} />`
}
}
}
// передать классы компонентов функции Ctn
Ctn(WHome, WAbout, WContacts, WMenu, WContent)
</script>
</body>
Для обработки маршрутов этих страниц, эмиттеру маршрутизатора назначается обработчик с необязательным параметром маршрута в компоненте Content:
// добавить эмиттеру обработчик события с необязательным параметром маршрута
emitRouter.addEventListener(`(:page)?`, event => {
// присвоить свойству название компонента страницы
this.page = `w-${event.params.page || 'home'}`
this.$update() // обновить компонент
})
Для того, чтобы этот обработчик срабатывал сразу при открытии приложения и подключал соответствующий маршруту компонент страницы, в конце статического метода connected(), инициируется событие для адреса текущего маршрута из свойства href объекта location:
// инициировать событие для значения "href" текущей страницы
this.$router(emitRouter, location.href)
Остальные компоненты страниц загружаются при клике по соответствующей им ссылке в компоненте Menu:
// добавить обработчик события "click" для элемента NAV
this.$('nav').addEventListener('click', event => {
event.preventDefault() // отменить действие по умолчанию
// инициировать событие для значения "href" текущей ссылки
this.$router(emitRouter, event.target.href)
})
Чтобы при открытии приложения не создавался компонент страницы с неопределённым названием, используется условная проверка на значение свойства page объекта состояния компонента Content:
// вернуть HTML-разметку компонента
static template() {
// если свойство содержит название страницы
if (this.page) {
return `<${this.page} />`
}
}
В этом примере используется самозакрывающийся тег для подключаемого элемента компонента:
`<${this.page} />`
Если бы подключаемый компонент содержал слоты, в которое передавалось бы какое-нибудь HTML-содержимое, то необходимо было бы использовать открывающий и закрывающий теги элемента компонента:
// вернуть HTML-разметку компонента
static template() {
// если свойство содержит название страницы
if (this.page) {
return `
<${this.page}>
<div>Какое-то HTML-содержимое</div>
</${this.page}>
`
}
}
Когда статический метод template() ничего не возвращает, то создаётся компонент с пустым HTML-содержимым.
Передавать HTML-содержимое в слоты можно только для компонентов имеющих Теневой DOM. Это означает, что при обновлении компонента, HTML-содержимое передаваемое с компоненты без Теневого DOM, просто игнорируется и никакие изменения в него не вносятся.
Чтобы передавать данные в любые компоненты, можно воспользоваться пользовательскими атрибутами, например:
// вернуть HTML-разметку компонента
static template() {
// если свойство содержит название страницы
if (this.page) {
// передать через пользовательский атрибут "data-color" значение цвета
return `<${this.page} data-color="${this.color}" />`
}
}
В отличие от HTML-содержимого, атрибуты элемента любого компонента обновляются всегда.
Серверный рендеринг
SSR (Server Side Rendering) – это методика разработки, при которой содержимое веб-страницы отрисовывается на сервере, а не в браузере клиента. Для рендеринга содержимого веб-страниц используется метод render(), который доступен в качестве свойства функции Ctn. Этот метод работает как на стороне сервера, так и в браузере клиента.
В примере ниже, данный метод выводит содержимое всей страницы на консоль браузера:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- подключить компонент Hello к документу -->
<w-hello>
<!-- передаваемое в слот HTML-содержимое -->
<span>Расширенные Веб-компоненты</span>
</w-hello>
<script src="ctn.global.js"></script>
<script>
class WHello {
// инициализация свойств объекта состояния
message = 'Creaton'
color = 'orange'
static mode = 'open' // добавить Теневой DOM
// вернуть HTML-разметку компонента
static template() {
return `
<h1>${ this.message } – это <slot></slot></h1>
<style>
h1 {
color: ${ this.color };
}
</style>
`
}
}
// передать класс компонента Hello функции Ctn
Ctn(WHello)
// вывести HTML-содержимое страницы на консоль браузера
Ctn.render().then(html => console.log(html))
</script>
</body>
</html>
Также этот метод доступен в качестве именованного импорта, при использовании модульной версии фреймворка:
import { Render } from "./ctn.esm.js"
Метод возвращает промис, который разрешается после того, когда HTML-содержимое всех используемых компонентов для текущего маршрута приложения будет доступно:
Ctn.render().then(html => console.log(html))
Компоненты других страниц, не соответствующих текущему маршруту, если приложение использует маршрутизатор, или компоненты, не принимающие участия в формировании содержимого при открытии приложения, в промисе учитываться не будут, иначе этот промис никогда бы не разрешился.
Чтобы вывести в консоль браузера содержимое не всего документа, а только начиная с определённого элемента, необходимо передать в метод объект с параметром parent, значением которого будет элемент, с которого начинается вывод.
В примере ниже, содержимое документа выводится начиная с элемента BODY:
Ctn.render({ parent: document.body }).then(html => console.log(html))
По умолчанию, метод выводит очищенное HTML-содержимое документа, т.е. такое, в которых удалены теги: STYLE, SCRIPT и TEMPLATE. Чтобы метод выводил полное HTML-содержимое, необходимо передать ему объект с параметром clean и значением “false”, как показано ниже:
Ctn.render({ clean: false }).then(html => console.log(html))
Во всех примерах выше, передаваемое в слот содержимое выводилось без самих тегов SLOT. Чтобы передаваемое содержимое выводилось внутри этих тегов, т.е. в полном соответствии со структурой расположения данного содержимого в компоненте, методу необходимо передать объект с параметром slots и значением “true”, например:
Ctn.render({ slots: true }).then(html => console.log(html))
Все три параметра можно передавать одновременно:
Ctn.render({
parent: document.body,
clean: false,
slots: true
}).then(html => console.log(html))
Проект готового приложения расположен по этой ссылке. Для установки всех завимостей, включая и зависимости для сервера, используется команда:
npm i
Для запуска приложения в режиме разработки, используется команда:
npm start
а для финальной сборки, со всеми минимизациями приложения в режиме продакшен, команда:
npm run build
Это обычный проект с использованием менеджера задач Gulp и сборщика модулей Webpack. Код сервера находится в файле app.js, а сам сервер написан с помощью фреймворка Express.
Файл сервера представляет собой типичное приложение на фреймворке Express:
const express = require('express')
const jsdom = require('jsdom')
const { JSDOM } = require('jsdom')
const fs = require('fs')
const port = process.env.PORT || 3000
// connect database file
let DB = JSON.parse(fs.readFileSync(__dirname + '/db.json').toString())
// create an Express application object
const app = express()
// create a parser for application/x-www-form-urlencoded data
const urlencodedParser = express.urlencoded({ extended: false })
// define directory for static files and ignore index.html file
app.use(express.static(__dirname + '/public', { index: false }))
// define an array of bot names that will receive the rendered content
const listBots = [
'Yandex', 'YaDirectFetcher', 'Google', 'Yahoo', 'Mail.RU_Bot', 'bingbot', 'Accoona', 'Lighthouse',
'ia_archiver', 'Ask Jeeves', 'OmniExplorer_Bot', 'W3C_Validator', 'WebAlta', 'Ezooms', 'Tourlentabot', 'MJ12bot',
'AhrefsBot', 'SearchBot', 'SiteStatus', 'Nigma.ru', 'Baiduspider', 'Statsbot', 'SISTRIX', 'AcoonBot', 'findlinks',
'proximic', 'OpenindexSpider', 'statdom.ru', 'Exabot', 'Spider', 'SeznamBot', 'oBot', 'C-T bot', 'Updownerbot',
'Snoopy', 'heritrix', 'Yeti', 'DomainVader', 'DCPbot', 'PaperLiBot', 'StackRambler', 'msnbot'
]
// loads only scripts and ignores all other resources
class CustomResourceLoader extends jsdom.ResourceLoader {
fetch(url, options) {
return regJSFile.test(url) ? super.fetch(url, options) : null
}
}
// define the bot agent string to test
const testAgent = process.argv[2] === 'test' ? 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' : ''
// define a regular expression to search for bot names in a string
const regBots = new RegExp(`(${listBots.join(')|(')})`, 'i')
// search for script file extensions
const regJSFile = /\.m?js$/
// favicon request
app.get('/favicon.ico', (req, res) => res.sendStatus(204))
// database request
app.get('/db', (req, res) => res.send(DB[req.query.page]))
// all other requests
app.use(async (req, res) => {
// if the request comes from a bot
if (regBots.test(testAgent || req.get('User-Agent'))) {
// define a new JSDOM object with parameters
const dom = await JSDOM.fromFile('index.html', {
url: req.protocol + '://' + req.get('host') + req.originalUrl, // determine the full URL
resources: new CustomResourceLoader(), // loading only scripts
runScripts: 'dangerously' // allow page scripts to execute
})
// return the rendered HTML content of the page
dom.window.onload = async () => res.send(await dom.window._$CtnRender_())
}
// otherwise, if the request comes from a user
else {
// return the main page file of the application
res.sendFile(__dirname + '/index.html')
}
})
// start the server
app.listen(port, () => console.log(`The server is running at http://localhost:${port}/`))
Чтобы метод render() мог работать на сервере, используется jsdom – это реализация Веб-стандартов на JavaScript.
Обычным пользователям не нужно отдавать отрендеренное содержимое страницы. Оно необходимо только поисковым ботам и другим автоматическим системам учёта анализа HTML-содержимого. Список этих систем находится в массиве, который можно пополнить дополнительными названиями:
// define an array of bot names that will receive the rendered content
const listBots = [
'Yandex', 'YaDirectFetcher', 'Google', 'Yahoo', 'Mail.RU_Bot', 'bingbot', 'Accoona', 'Lighthouse',
'ia_archiver', 'Ask Jeeves', 'OmniExplorer_Bot', 'W3C_Validator', 'WebAlta', 'Ezooms', 'Tourlentabot', 'MJ12bot',
'AhrefsBot', 'SearchBot', 'SiteStatus', 'Nigma.ru', 'Baiduspider', 'Statsbot', 'SISTRIX', 'AcoonBot', 'findlinks',
'proximic', 'OpenindexSpider', 'statdom.ru', 'Exabot', 'Spider', 'SeznamBot', 'oBot', 'C-T bot', 'Updownerbot',
'Snoopy', 'heritrix', 'Yeti', 'DomainVader', 'DCPbot', 'PaperLiBot', 'StackRambler', 'msnbot'
]
Если в заголовке запроса будет присутствовать любое из этих названий, то сервер будет отдавать отрендеренное HTML-содержимое:
// if the request comes from a bot
if (regBots.test(testAgent || req.get('User-Agent'))) {
// define a new JSDOM object with parameters
const dom = await JSDOM.fromFile('index.html', {
url: req.protocol + '://' + req.get('host') + req.originalUrl, // determine the full URL
resources: new CustomResourceLoader(), // loading only scripts
runScripts: 'dangerously' // allow page scripts to execute
})
// return the rendered HTML content of the page
dom.window.onload = async () => res.send(await dom.window._$CtnRender_())
}
Для всех остальных запросов, сервер будет возвращать файл index.html, который является единственным html-файлом в этом одностраничном приложении:
// otherwise, if the request comes from a user
else {
// return the main page file of the application
res.sendFile(__dirname + '/index.html')
}
Рендеринг осуществляется с помощью функции _$CtnRender_() глобального объекта window, как показано ниже:
// return the rendered HTML content of the page
dom.window.onload = async () => res.send(await dom.window._$CtnRender_())
Эта функция назначается объекту в файле index.js, который является главным файлом всего приложения:
// add the Render method as a property of the window object
window._$CtnRender_ = Render
Рендеринг не поддерживает динамические импорты, вместо них необходимо использовать обычные инструкции импорта и экспорта модулей. Кроме этого, рендеринг не поддерживает глобальный метод fetch(). Вместо него необходимо использовать встроенный объект XMLHttpRequest.
Объект XMLHttpRequest можно обернуть в функцию и затем, вызывать эту функцию вместо того, чтобы каждый раз писать код запроса этого объекта вручную, как показано в файле helpers.js, например:
export const httpRequest = (url, method = 'GET', type = 'json') => {
const xhr = new XMLHttpRequest()
xhr.open(method, url)
xhr.responseType = type
xhr.send()
return new Promise(ok => xhr.onload = () => ok(xhr.response))
}
После установки всех зависимостей приложения из файла package.json, для запуска сервера в обычном режиме, необходимо открыть консоль из каталога /server, и ввести в ней следующую команду:
node app
а чтобы посмотреть, как сервер рендерит содержимое для поисковых систем, необходимо ввести команду:
node app test