This commit is contained in:
Ilya Kantor 2019-03-30 07:26:03 +03:00
parent 0873d43d72
commit 65671ab7ba
28 changed files with 447 additions and 9 deletions

View file

@ -0,0 +1,76 @@
# From the orbital height
This section describes a set of modern standards for "web components".
As of now, these standards are under development. Some features are well-supported and integrated into the modern HTML/DOM standard, while others are yet in draft stage. You can try examples in any browser, Google Chrome is probably the most up to date with these features. Guess, that's because Google fellows are behind many of the related specifications.
The whole component idea is nothing new. It's used in many frameworks and elsewhere.
## What's common between...
Before we move to implementation details, take a look at this great achievement of humanity:
![](satellite.jpg)
That's the International Space Station (ISS).
And this is how it's made inside (approximately):
![](satellite-expanded.jpg)
The International Space Station:
- Consists of many components.
- Each component, in its turn, has many smaller details inside.
- The components are very complex, much more complicated than most websites.
- Components are developed internationally, by teams from different countries, speaking different languages.
...And this thing is flying, keeping humans alive in space!
How such complex devices are created?
Which principles we could borrow, to make our development same-level reliable and scalable? Or, at least, close to it.
## Component architecture
The well known rule for developing complex software is: don't make complex software.
If something becomes complex -- split it into simpler parts and connect in the most obvious way.
**A good architect is the one who can make the complex simple.**
We can split a user interface into components -- visual entities, each of them has own place on the page, can "do" a well-described task, and is separate from the others.
Let's take a look at a website, for example Twitter.
It naturally splits into components:
![](web-components-twitter.png)
1. Top navigation.
2. User info.
3. Follow suggestions.
4. Submit form.
5. And also 6, 7 - messages.
Components may have subcomponents, e.g. messages may be parts of a higher-level "message list" component. A clickable user picture itself may be a component, and so on.
How do we decide, what is a component? That comes from intuition, experience and common sense. In the case above, the page has blocks, each of them plays its own role.
So, what comprises a component?
- A component has its own JavaScript class.
- DOM structure, managed solely by its class, outside code doesn't access it ("encapsulation" principle).
- CSS styles, applied to the component.
- API: events, class methods etc, to interact with other components.
"Web components" provide built-in browser capabilities for components:
- [Custom elements](https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements) -- to define custom HTML elements.
- [Shadow DOM](https://dom.spec.whatwg.org/#shadow-trees) -- to create an internal DOM for the component, hidden from the others.
- [CSS Scoping](https://drafts.csswg.org/css-scoping/) -- to declare styles that only apply inside the component.
There exist many frameworks and development methodologies that aim to do the similar thing, each one with its own bells and whistles. Usually, special CSS classes and conventions are used to provide "component feel" -- CSS scoping and DOM encapsulation.
Web components provide built-in browser capabilities for that, so we don't have to emulate them any more.
In the next chapter we'll go into details of "Custom Elements" -- the fundamental and well-supported feature of web components, good on its own.

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

View file

@ -0,0 +1,32 @@
class LiveTimer extends HTMLElement {
render() {
this.innerHTML = `
<time-formatted hour="numeric" minute="numeric" second="numeric">
</time-formatted>
`;
this.timerElem = this.firstElementChild;
}
connectedCallback() { // (2)
if (!this.rendered) {
this.render();
this.rendered = true;
}
this.timer = setInterval(() => this.update(), 1000);
}
update() {
this.date = new Date();
this.timerElem.setAttribute('datetime', this.date);
this.dispatchEvent(new CustomEvent('tick', { detail: this.date }));
}
disconnectedCallback() {
clearInterval(this.timer); // important to let the element be garbage-collected
}
}
customElements.define("live-timer", LiveTimer);

View file

@ -0,0 +1,34 @@
class TimeFormatted extends HTMLElement {
render() {
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
connectedCallback() { // (2)
if (!this.rendered) {
this.render();
this.rendered = true;
}
}
static get observedAttributes() { // (3)
return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
}
attributeChangedCallback(name, oldValue, newValue) { // (4)
this.render();
}
}
customElements.define("time-formatted", TimeFormatted);

View file

@ -0,0 +1,489 @@
# Custom elements
We can create our own class for a custom HTML element with its own methods and properties, events and so on.
There are two kinds of custom elements:
1. **Autonomous custom elements** -- "all-new" elements, extending the abstract `HTMLElement` class.
2. **Customized built-in elements** -- extending built-in elements, like customized `HTMLButtonElement` etc.
First we'll see how autonomous elements are made, and then the customized built-in ones.
For a class to describe an element, it should support so-called "custom element reactions" -- methods that the browser calls when our element is created/added/removed from DOM.
That's easy, as there are only few of them. Here's a sketch with the full list:
```js
class MyElement extends HTMLElement {
constructor() {
// element created
}
connectedCallback() {
// browser calls it when the element is added to the document
// (can be called many times if an element is repeatedly added/removed)
}
disconnectedCallback() {
// browser calls it when the element is removed from the document
// (can be called many times if an element is repeatedly added/removed)
}
static get observedAttributes() {
return [/* array of attribute names to monitor for changes */];
}
attributeChangedCallback(name, oldValue, newValue) {
// called when one of attributes listed above is modified
}
adoptedCallback() {
// called when the element is moved to a new document
// (document.adoptNode call, very rarely used)
}
}
```
Then we need to register the element:
```js
// let the browser know that <my-element> is served by our new class
customElements.define("my-element", MyElement);
```
Now for any new elements with tag `my-element`, an instance of `MyElement` is created, and the aforementioned methods are called.
```smart header="Custom element name must contain a hyphen `-`"
Custom element name must have a hyphen `-`, e.g. `my-element` and `super-button` are valid names, but `myelement` is not.
That's to ensure that there are no name conflicts between built-in and custom HTML elements.
```
## Example: "time-formatted"
For example, there already exists `<time>` element in HTML, for date/time. But it doesn't do any formatting by itself.
Let's create `<time-formatted>` element that does the formatting:
```html run height=50 autorun
<script>
class TimeFormatted extends HTMLElement {
connectedCallback() {
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
}
customElements.define("time-formatted", TimeFormatted);
</script>
<time-formatted
datetime="2019-12-01"
year="numeric" month="long" day="numeric"
hour="numeric" minute="numeric" second="numeric"
time-zone-name="short"
></time-formatted>
```
As the result, `<time-formatted>` shows a nicely formatted time, according to the browser timezone and locale. We use the built-in [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) data formatter, well-supported across the browsers.
```smart header="Custom elements upgrade"
If the browser encounters any `<time-formatted>` elements before `customElements.define` call, they are yet unknown, just like any non-standard tag.
They can be styled with CSS selector `:not(:defined)`.
When `customElement.define` is called, they are "upgraded": a new instance of `TimeFormatted`
is created for each, and `connectedCallback` is called. They become `:defined`.
To track custom elements from JavaScript, there are methods:
- `customElements.get(name)` -- returns the class for a defined custom element with the given `name`,
- `customElements.whenDefined(name)` -- returns a promise that resolves (without value) when a custom element with the given `name` is defined.
```
```smart header="Rendering in `connectedCallback` instead of `constructor`"
In the example above, element content is rendered (created) in `connectedCallback`.
Why not in the `constructor`?
First, that might be better performance-wise -- to delay the work until its really needed.
The `connectedCallback` triggers when the element is in the document, not just appended to another element as a child. So we can build detached DOM, create elements and prepare them for later use. They will only be actually rendered when they make it into the page.
Second, when the element is on the page, we can get geometry information about it, e.g. sizes (`elem.offsetWidth/offsetHeight`), so rendering at this stage is more powerful.
On the other hand, `constructor` is the right place to initialize internal data structures, do lightweight jobs.
```
## Observing attributes
Please note that in the current implementation, after the element is rendered, further attribute changes don't have any effect. That's strange for an HTML element. Usually, when we change an attribute, like `a.href`, the change is immediately visible. So let's fix this.
We can observe attributes by providing their list in `observedAttributes()` static getter, and then update the element in `attributeChangedCallback`. It's called for changes only in the listed attributes for performance reasons.
```html run autorun height=50
<script>
class TimeFormatted extends HTMLElement {
*!*
render() { // (1)
*/!*
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
*!*
connectedCallback() { // (2)
*/!*
if (!this.rendered) {
this.render();
this.rendered = true;
}
}
*!*
static get observedAttributes() { // (3)
*/!*
return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
}
*!*
attributeChangedCallback(name, oldValue, newValue) { // (4)
*/!*
this.render();
}
}
customElements.define("time-formatted", TimeFormatted);
</script>
<time-formatted id="elem" hour="numeric" minute="numeric" second="numeric"></time-formatted>
<script>
*!*
setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5)
*/!*
</script>
```
1. The rendering logic is moved to `render()` helper method.
2. We call it once when the element is inserted into page.
3. For a change of an attribute, listed in `observedAttributes()`, `attributeChangedCallback` triggers.
4. ...and re-renders the element.
5. At the end, we can easily make a live timer.
## Customized built-in elements
New custom elements like `<time-formatted>` don't have any associated semantics. They are totally new to search engines and accessibility devices.
We could use special [ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) attributes to describe the semantic. But if we're going to make a special button, why not extend a `<button>` element itself?
Built-in elements can be customized by inheriting from their classes. HTML buttons are instances of `HTMLButtonElement`, so let's extend it:
```html run autorun
<script>
// The button that says "hello" on click
class HelloButton extends HTMLButtonElement {
*!*
constructor() { // (1)
*/!*
super();
this.addEventListener('click', () => alert("Hello!"));
}
}
*!*
customElements.define('hello-button', HelloButton, {extends: 'button'}); // 2
*/!*
</script>
<!-- 3 -->
*!*
<button is="hello-button">Click me</button>
*/!*
<!-- 4 -->
*!*
<button is="hello-button" disabled>Disabled</button>
*/!*
```
1. We constructor add an event listener to the element. Please note: we must call `super()` before anything else (that's pure JS requirement).
2. To extend a built-in element, we must specify `{extends: '<tag>'}` in the define. Some tags share the same HTML class, so we need to be precise here.
3. Now we can use a regular `<button>` tag, labelled with `is="hello-button"`.
4. Our buttons extend built-in ones, so they retain the standard features like `disabled` attribute.
For example, we can create `<custom-dropdown>` -- a nice-looking dropdown select, `<phone-input>` -- an input element for a phone number, and other graphical components.
There are two kinds of custom elements:
An autonomous custom element, which is defined with no extends option. These types of custom elements have a local name equal to their defined name.
A customized built-in element, which is defined with an extends option. These types of custom elements have a local name equal to the value passed in their extends option, and their defined name is used as the value of the is attribute, which therefore must be a valid custom element name.
```js run
class DateLocal extends HTMLElement {
constructor() {
// element is created/upgraded
super();
}
}
## "tel" example
For ins, we'd like to show a date in visitor's time zone.
The time zone is
A critical reader may wonder: "Do we need custom HTML elements? We can create interfaces from existing ones".
Критично настроенный читатель скажет: "Зачем ещё стандарт для своих типов элементов? Я могу создать любой элемент и прямо сейчас! В любом из современных браузеров можно писать любой HTML, используя свои теги: `<mytag>`. Или создавать элементы из JavaScript при помощи `document.createElement('mytag')`."
Однако, по умолчанию элемент с нестандартным названием (например `<mytag>`) воспринимается браузером, как нечто неопределённо-непонятное. Ему соответствует класс [HTMLUnknownElement](http://www.w3.org/TR/html5/dom.html#htmlunknownelement), и у него нет каких-либо особых методов.
**Стандарт Custom Elements позволяет описывать для новых элементов свои свойства, методы, объявлять свой DOM, подобие конструктора и многое другое.**
Давайте посмотрим это на примерах.
```warn header="Для примеров рекомендуется Chrome"
Так как спецификация не окончательна, то для запуска примеров рекомендуется использовать Google Chrome, лучше -- последнюю сборку [Chrome Canary](https://www.google.ru/chrome/browser/canary.html), в которой, как правило, отражены последние изменения.
```
## Новый элемент
Для описания нового элемента используется вызов `document.registerElement(имя, { prototype: прототип })`.
Здесь:
- `имя` -- имя нового тега, например `"mega-select"`. Оно обязано содержать дефис `"-"`. Спецификация требует дефис, чтобы избежать в будущем конфликтов со стандартными элементами HTML. Нельзя создать элемент `timer` или `myTimer` -- будет ошибка.
- `прототип` -- объект-прототип для нового элемента, он должен наследовать от `HTMLElement`, чтобы у элемента были стандартные свойства и методы.
Вот, к примеру, новый элемент `<my-timer>`:
```html run
<script>
*!*
// прототип с методами для нового элемента
*/!*
var MyTimerProto = Object.create(HTMLElement.prototype);
MyTimerProto.tick = function() { // *!*свой метод tick*/!*
this.innerHTML++;
};
*!*
// регистрируем новый элемент в браузере
*/!*
document.registerElement("my-timer", {
prototype: MyTimerProto
});
</script>
*!*
<!-- теперь используем новый элемент -->
*/!*
<my-timer id="timer">0</my-timer>
<script>
*!*
// вызовем метод tick() на элементе
*/!*
setInterval(function() {
timer.tick();
}, 1000);
</script>
```
Использовать новый элемент в HTML можно и до его объявления через `registerElement`.
Для этого в браузере предусмотрен специальный режим "обновления" существующих элементов.
Если браузер видит элемент с неизвестным именем, в котором есть дефис `-` (такие элементы называются "unresolved"), то:
- Он ставит такому элементу специальный CSS-псевдокласс `:unresolved`, для того, чтобы через CSS можно было показать, что он ещё "не подгрузился".
- При вызове `registerElement` такие элементы автоматически обновятся до нужного класса.
В примере ниже регистрация элемента происходит через 2 секунды после его появления в разметке:
```html run no-beautify
<style>
*!*
/* стиль для :unresolved элемента (до регистрации) */
*/!*
hello-world:unresolved {
color: white;
}
hello-world {
transition: color 3s;
}
</style>
<hello-world id="hello">Hello, world!</hello-world>
<script>
*!*
// регистрация произойдёт через 2 сек
*/!*
setTimeout(function() {
document.registerElement("hello-world", {
prototype: {
__proto__: HTMLElement.prototype,
sayHi: function() { alert('Привет!'); }
}
});
*!*
// у нового типа элементов есть метод sayHi
*/!*
hello.sayHi();
}, 2000);
</script>
```
Можно создавать такие элементы и в JavaScript -- обычным вызовом `createElement`:
```js
var timer = document.createElement('my-timer');
```
## Расширение встроенных элементов
Выше мы видели пример создания элемента на основе базового `HTMLElement`. Но можно расширить и другие, более конкретные HTML-элементы.
Для расширения встроенных элементов у `registerElement` предусмотрен параметр `extends`, в котором можно задать, какой тег мы расширяем.
Например, кнопку:
```html run
<script>
var MyTimerProto = Object.create(*!*HTMLButtonElement.prototype*/!*);
MyTimerProto.tick = function() {
this.innerHTML++;
};
document.registerElement("my-timer", {
prototype: MyTimerProto,
*!*
extends: 'button'
*/!*
});
</script>
<button *!*is="my-timer"*/!* id="timer">0</button>
<script>
setInterval(function() {
timer.tick();
}, 1000);
timer.onclick = function() {
alert("Текущее значение: " + this.innerHTML);
};
</script>
```
Важные детали:
Прототип теперь наследует не от `HTMLElement`, а от `HTMLButtonElement`
: Чтобы расширить элемент, нужно унаследовать прототип от его класса.
В HTML указывается при помощи атрибута `is="..."`
: Это принципиальное отличие разметки от обычного объявления без `extends`. Теперь `<my-timer>` работать не будет, нужно использовать исходный тег и `is`.
Работают методы, стили и события кнопки.
: При клике на кнопку её не отличишь от встроенной. И всё же, это новый элемент, со своими методами, в данном случае `tick`.
При создании нового элемента в JS, если используется `extends`, необходимо указать и исходный тег в том числе:
```js
var timer = document.createElement("button", "my-timer");
```
## Жизненный цикл
В прототипе своего элемента мы можем задать специальные методы, которые будут вызываться при создании, добавлении и удалении элемента из DOM:
<table>
<tr><td><code>createdCallback</code></td><td>Элемент создан</td></tr>
<tr><td><code>attachedCallback</code></td><td>Элемент добавлен в документ</td></tr>
<tr><td><code>detachedCallback</code></td><td>Элемент удалён из документа</td></tr>
<tr><td><code>attributeChangedCallback(name, prevValue, newValue)</code></td><td>Атрибут добавлен, изменён или удалён</td></tr>
</table>
Как вы, наверняка, заметили, `createdCallback` является подобием конструктора. Он вызывается только при создании элемента, поэтому всю дополнительную инициализацию имеет смысл описывать в нём.
Давайте используем `createdCallback`, чтобы инициализировать таймер, а `attachedCallback` -- чтобы автоматически запускать таймер при вставке в документ:
```html run
<script>
var MyTimerProto = Object.create(HTMLElement.prototype);
MyTimerProto.tick = function() {
this.timer++;
this.innerHTML = this.timer;
};
*!*
MyTimerProto.createdCallback = function() {
this.timer = 0;
};
*/!*
*!*
MyTimerProto.attachedCallback = function() {
setInterval(this.tick.bind(this), 1000);
};
*/!*
document.registerElement("my-timer", {
prototype: MyTimerProto
});
</script>
<my-timer id="timer">0</my-timer>
```
## Итого
Мы рассмотрели, как создавать свои DOM-элементы при помощи стандарта [Custom Elements](http://www.w3.org/TR/custom-elements/).
Далее мы перейдём к изучению дополнительных возможностей по работе с DOM.

View file

@ -0,0 +1,38 @@
<script>
/*
class TimeFormatted extends HTMLElement {
render() {
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
connectedCallback() { // (2)
if (!this.rendered) {
this.render();
this.rendered = true;
}
}
static get observedAttributes() { // (3)
return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
}
attributeChangedCallback(name, oldValue, newValue) { // (4)
this.render();
}
}
window.customElements && customElements.define("time-formatted", TimeFormatted);
*/
</script>

View file

@ -0,0 +1,163 @@
# Shadow DOM
Спецификация [Shadow DOM](http://w3c.github.io/webcomponents/spec/shadow/) является отдельным стандартом. Частично он уже используется для обычных DOM-элементов, но также применяется для создания веб-компонентов.
*Shadow DOM* -- это внутренний DOM элемента, который существует отдельно от внешнего документа. В нём могут быть свои ID, свои стили и так далее. Причём снаружи его, без применения специальных техник, не видно, поэтому не возникает конфликтов.
## Внутри браузера
Концепция Shadow DOM начала применяться довольно давно внутри самих браузеров. Когда браузер показывает сложные элементы управления, наподобие слайдера `<input type="range">` или календаря `<input type="date">` -- внутри себя он конструирует их из самых обычных стилизованных `<div>`, `<span>` и так далее.
С первого взгляда они незаметны, но если в настройках Chrome Development Tools выбрать показ Shadow DOM, то их можно легко увидеть.
Например, вот такое содержимое будет у `<input type="date">`:
![](shadow-dom-chrome.png)
То, что находится под `#shadow-root` -- это и есть Shadow DOM.
**Получить элементы из Shadow DOM можно только при помощи специальных JavaScript-вызовов или селекторов. Это не обычные дети, а намного более мощное средство отделения содержимого.**
В Shadow DOM выше можно увидеть полезный атрибут `pseudo`. Он нестандартный, существует по историческим причинам. С его помощью можно стилизовать подэлементы через CSS, например, сделаем поле редактирования даты красным:
```html run no-beautify
<style>
*!*
input::-webkit-datetime-edit {
*/!*
background: red;
}
</style>
<input type="date">
```
Ещё раз заметим, что `pseudo` -- нестандартный атрибут. Если говорить хронологически, то сначала браузеры начали экспериментировать внутри себя с инкапсуляцией внутренних DOM-структур, а уже потом, через некоторое время, появился стандарт Shadow DOM, который позволяет делать то же самое разработчикам.
Далее мы рассмотрим работу с Shadow DOM из JavaScript, по стандарту [Shadow DOM](http://w3c.github.io/webcomponents/spec/shadow/).
## Создание Shadow DOM
Shadow DOM можно создать внутри любого элемента вызовом `elem.createShadowRoot()`.
Например:
```html run autorun="no-epub"
<p id="elem">Доброе утро, страна!</p>
<script>
var root = elem.createShadowRoot();
root.innerHTML = "<p>Привет из подполья!</p>";
</script>
```
Если вы запустите этот пример, то увидите, что изначальное содержимое элемента куда-то исчезло и показывается только "Привет из подполья!". Это потому, что у элемента есть Shadow DOM.
**С момента создания Shadow DOM обычное содержимое (дети) элемента не отображается, а показывается только Shadow DOM.**
Внутрь этого Shadow DOM, при желании, можно поместить обычное содержимое. Для этого нужно указать, куда. В Shadow DOM это делается через "точку вставки" (insertion point). Она объявляется при помощи тега `<content>`, например:
```html run autorun="no-epub"
<p id="elem">Доброе утро, страна!</p>
<script>
var root = elem.createShadowRoot();
root.innerHTML = "<h3>*!*<content></content>*/!*</h3> <p>Привет из подполья!</p>";
</script>
```
Теперь вы увидите две строчки: "Доброе утро, страна!" в заголовке, а затем "Привет из подполья".
Shadow DOM примера выше в инструментах разработки:
![](shadow-content.png)
Важные детали:
- Тег `<content>` влияет только на отображение, он не перемещает узлы физически. Как видно из картинки выше, текстовый узел "Доброе утро, страна!" остался внутри `p#elem`. Его можно даже получить при помощи `elem.firstElementChild`.
- Внутри `<content>` показывается не элемент целиком `<p id="elem">`, а его содержимое, то есть в данном случае текст "Доброе утро, страна!".
**В `<content>` атрибутом `select` можно указать конкретный селектор содержимого, которое нужно переносить. Например, `<content select="h3"></content>` перенесёт только заголовки.**
Внутри Shadow DOM можно использовать `<content>` много раз с разными значениями `select`, указывая таким образом, где конкретно какие части исходного содержимого разместить. Но при этом дублирование узлов невозможно. Если узел показан в одном `<content>`, то в следующем он будет пропущен.
Например, если сначала идёт `<content select="h3.title">`, а затем `<content select="h3">`, то в первом `<content>` будут показаны заголовки `<h3>` с классом `title`, а во втором -- все остальные, кроме уже показанных.</li>
В примере выше тег `<content></content>` внутри пуст. Если в нём указать содержимое, то оно будет показано только в том случае, если узлов для вставки нет. Например потому что ни один узел не подпал под указанный `select`, или все они уже отображены другими, более ранними `<content>`.
Например:
```html run autorun="no-epub" no-beautify
<section id="elem">
<h1>Новости</h1>
<article>Жили-были <i>старик со старухой</i>, но недавно...</article>
</section>
<script>
var root = elem.createShadowRoot();
root.innerHTML = "<content select='h1'></content> \
<content select='.author'>Без автора.</content> \
<content></content>";
</script>
<button onclick="alert(root.innerHTML)">root.innerHTML</button>
```
При запуске мы увидим, что:
- Первый `<content select='h1'>` выведет заголовок.
- Второй `<content select=".author">` вывел бы автора, но так как такого элемента нет -- выводится содержимое самого `<content select=".author">`, то есть "Без автора".
- Третий `<content>` выведет остальное содержимое исходного элемента -- уже без заголовка `<h1>`, он выведен ранее!
Ещё раз обратим внимание, что `<content>` физически не перемещает узлы по DOM. Он только показывает, где их отображать, а также, как мы увидим далее, влияет на применение стилей.
## Корень shadowRoot
После создания корень внутреннего DOM-дерева доступен как `elem.shadowRoot`.
Он представляет собой специальный объект, поддерживающий основные методы CSS-запросов и подробно описанный в стандарте как [ShadowRoot](http://w3c.github.io/webcomponents/spec/shadow/#shadowroot-object).
Если нужно работать с содержимым в Shadow DOM, то нужно перейти к нему через `elem.shadowRoot`. Можно и создать новое Shadow DOM-дерево из JavaScript, например:
```html run autorun="no-epub"
<p id="elem">Доброе утро, страна!</p>
<script>
*!*
// создать новое дерево Shadow DOM для elem
*/!*
var root = elem.createShadowRoot();
root.innerHTML = "<h3><content></content></h3> <p>Привет из подполья!</p> <hr>";
</script>
<script>
*!*
// прочитать данные из Shadow DOM для elem
*/!*
var root = elem.shadowRoot;
// Привет из подполья!
document.write("<p>p:" + root.querySelector('p').innerHTML);
// пусто, так как физически узлы - вне content
document.write("<p>content:" + root.querySelector('content').innerHTML);
</script>
```
```warn header="Внутрь встроенных элементов так \"залезть\" нельзя"
На момент написания статьи `shadowRoot` можно получить только для Shadow DOM, созданного описанным выше способом, но не встроенного, как в элементах типа `<input type="date">`.
```
## Итого
Shadow DOM -- это средство для создания отдельного DOM-дерева внутри элемента, которое не видно снаружи без применения специальных методов.
- Ряд браузерных элементов со сложной структурой уже имеют Shadow DOM.
- Можно создать Shadow DOM внутри любого элемента вызовом `elem.createShadowRoot()`. В дальнейшем его корень будет доступен как `elem.shadowRoot`. У встроенных элементов он недоступен.
- Как только у элемента появляется Shadow DOM, его изначальное содержимое скрывается. Теперь показывается только Shadow DOM, который может указать, какое содержимое хозяина куда вставлять, при помощи элемента `<content>`. Можно указать селектор `<content select="селектор">` и размещать разное содержимое в разных местах Shadow DOM.
- Элемент `<content>` перемещает содержимое исходного элемента в Shadow DOM только визуально, в структуре DOM оно остаётся на тех же местах.
Подробнее спецификация описана по адресу <http://w3c.github.io/webcomponents/spec/shadow/>.
Далее мы рассмотрим работу с шаблонами, которые также являются частью платформы Web Components и не заменяют существующие шаблонные системы, но дополняют их важными встроенными в браузер возможностями.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -0,0 +1,53 @@
# Шаблоны <template>
Элемент `<template>` предназначен для хранения "образца" разметки, невидимого и предназначенного для вставки куда-либо.
Конечно, есть много способов записать произвольный невидимый текст в HTML. В чём же особенность `<template>`?
Его отличие от обычных тегов в том, что его содержимое обрабатывается особым образом. Оно не только скрыто, но и считается находящимся вообще "вне документа". А при вставке автоматически "оживает", выполняются из него скрипты, начинает проигрываться видео и т.п.
Содержимое тега `<template>`, в отличие, к примеру, от шаблонов или `<script type="неизвестный тип">`, обрабатывается браузером. А значит, должно быть корректным HTML.
Оно доступно как `DocumentFragment` в свойстве тега `content`. Предполагается, что мы, при необходимости, возьмём `content` и вставим, куда надо.
## Вставка шаблона
Пример вставки шаблона `tmpl` в Shadow DOM элемента `elem`:
```html run autorun="no-epub"
<p id="elem">
Доброе утро, страна!</p>
<template id="tmpl">
<h3><content></content></h3>
<p>Привет из подполья!</p>
<script>
document.write('...document.write:Новость!');
</script>
</template>
<script>
var root = elem.createShadowRoot();
root.appendChild(tmpl.content.cloneNode(true));
</script>
```
У нас получилось, что:
1. В элементе `#elem` содержатся данные в некоторой оговорённой разметке.
2. Шаблон `#tmpl` указывает, как их отобразить, куда и в какие HTML-теги завернуть содержимое `#elem`.
3. Здесь шаблон показывается в Shadow DOM тега. Технически, это не обязательно, шаблон можно использовать и без Shadow DOM, но тогда не сработает тег `<content>`.
Можно также заметить, что скрипт из шаблона выполнился. Это важнейшее отличие вставки шаблона от вставки HTML через `innerHTML` и от обычного `DocumentFragment`.
Также мы вставили не сам `tmpl.content`, а его клон. Это обычная практика, чтобы можно было использовать один шаблон много раз.
## Итого
Тег `<template>` не призван заменить системы шаблонизации. В нём нет хитрых операторов итерации, привязок к данным.
Его основная особенность -- это возможность вставки "живого" содержимого, вместе со скриптами.
И, конечно, мелочь, но удобно, что он не требует никаких библиотек.

View file

@ -0,0 +1,294 @@
# Веб-компонент в сборе
В этой главе мы посмотрим на итоговый пример веб-компонента, включающий в себя описанные ранее технологии: Custom Elements, Shadow DOM, CSS Scoping и, конечно же, Imports.
## Компонент ui-message
Компонент `ui-message` будет описан в отдельном файле `ui-message.html`.
Его использование будет выглядеть следующим образом:
```html
<link rel="import" id="link" href="ui-message.html">
<style>
ui-message {
width: 80%;
margin: auto;
}
</style>
*!*
<ui-message class="info">Доброе утро, страна!</ui-message>
*/!*
*!*
<ui-message class="warning">Внимание-внимание! Говорит информбюро!</ui-message>
*/!*
```
Этот код ничем не отличается от использования обычного элемента, поэтому перейдём дальше, к содержимому `ui-message.html`
## Шаблон для ui-message
Файл `ui-message.html` можно начать с шаблона:
```html
<template id="tmpl">
<style>
.content {
min-height: 20px;
padding: 19px;
margin-bottom: 20px;
background-color: #f5f5f5;
border: 1px solid #e3e3e3;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);
}
:host {
display: block;
}
:host(.info) .content {
color: green;
}
:host(.warning) .content {
color: red;
}
</style>
<div class="content">
<content></content>
</div>
</template>
```
Этот шаблон рисует `<div class="content">` и заполняет его содержимым элемента-хозяина.
Важные детали:
- Самое важное правило здесь `:host { display:block }`.
Оно обязательно! . Это правило задаёт, что корень DOM-дерева будет иметь `display:block`. По умолчанию `:host` не создаёт CSS-блок, а это значит, что ни ширину ни отступы указать не получится.
- Последующие правила `:host(.info) .content` и `:host(.warning) .content` стилизуют содержимое в зависимости от того, какой на хозяине класс.
## Скрипт для ui-message
В файле `ui-message.html` мы создадим новый элемент `<ui-message>`:
```js
// (1) получить шаблон
var localDocument = document.currentScript.ownerDocument;
var tmpl = localDocument.getElementById('tmpl');
// (2) создать элемент
var MessageProto = Object.create(HTMLElement.prototype);
MessageProto.createdCallback = function() {
var root = this.createShadowRoot();
root.appendChild(tmpl.content.cloneNode(true));
};
// (3) зарегистрировать в DOM
document.registerElement('ui-message', {
prototype: MessageProto
});
```
Все компоненты этого кода мы подробно разбирали ранее:
1. Получаем шаблон из текущего документа, то есть из самого импорта.
2. Описываем элемент. Он довольно прост -- при создании записывает в свой `Shadow DOM` шаблон. При этом содержимое исходного элемента будет показано в `<content>`, но делать правила на сам `content` бессмысленно -- они не сработают. Нужно либо перейти внутрь `<content>` при помощи `::content`-селектора, либо указать для внешнего элемента `.content`, что в данном случае и сделано.
3. С момента регистрации все уже существующие элементы `<ui-message>` будут превращены в описанные здесь. И будущие, конечно, тоже.
Компонент в действии:
[codetabs src="message" height=200]
## Компонент ui-slider с jQuery
Компонент может использовать и внешние библиотеки.
Для примера создадим слайдер с использованием библиотеки [jQuery UI](http://jqueryui.com).
Компонент `ui-slider` будет показывать слайдер с минимальным и максимальным значением из атрибутов `min/max` и генерировать событие `slide` при его перемещении.
Использование:
```html
<link rel="import" id="link" href="ui-slider.html">
<ui-slider min="0" max="1000" id="elem"></ui-slider>
<script>
elem.addEventListener("slide", function(e) {
value.innerHTML = e.detail.value;
});
</script>
<div id="value">0</div>
```
## Файл компонента ui-slider
Файл `ui-slider.html`, задающий компонент, мы разберём по частям.
### Заголовок
В начале подключим jQuery и jQuery UI.
Мы импортируем в слайдер `jquery.html`, который содержит теги `<script>` вместо того, чтобы явным образом прописывать загрузку скриптов:
```html
<head>
<link rel="import" href="jquery.html">
</head>
```
Это сделано для того, чтобы другие компоненты, которым тоже могут понадобится эти библиотеки, также могли импортировать `jquery.html`. При повторном импорте ничего не произойдёт, скрипты не будут подгружены и исполнены два раза.
То есть, это средство оптимизации.
Содержимое `jquery.html`:
[html src="ui-slider/jquery.html"]
### Шаблон
Шаблон будет помещён в Shadow DOM. В нём должны быть стили и элементы, необходимые слайдеру.
Конкретно для слайдера из разметки достаточно одного элемента `<div id="slider"></div>`, который затем будет обработан jQuery UI.
Кроме того, в шаблоне должны быть стили:
```html
<template id="tmpl">
<style>
@import url(https://code.jquery.com/ui/1.11.4/themes/ui-lightness/jquery-ui.css);
:host {
display: block;
}
</style>
<div id="slider"></div>
</template>
```
### Скрипт
Скрипт для нового элемента похож на тот, что делали раньше, но теперь он использует jQuery UI для создания слайдера внутри своего Shadow DOM.
Для его понимания желательно знать jQuery, хотя в коде ниже я намеренно свёл использование этой библиотеки к минимуму.
```js
var localDocument = document.currentScript.ownerDocument;
var tmpl = localDocument.getElementById('tmpl');
var SliderProto = Object.create(HTMLElement.prototype);
SliderProto.createdCallback = function() {
// (1) инициализировать Shadow DOM, получить из него #slider
var root = this.createShadowRoot();
root.appendChild(tmpl.content.cloneNode(true));
this.$slider = $(root.getElementById('slider'));
var self = this;
// (2) инициализировать слайдер, пробросить параметры
this.$slider.slider({
min: this.getAttribute('min') || 0,
max: this.getAttribute('max') || 100,
value: this.getAttribute('value') || 0,
slide: function() {
// (3) пробросить событие
var event = new CustomEvent("slide", {
detail: {
value: self.$slider.slider("option", "value")
},
bubbles: true
});
self.dispatchEvent(event);
}
});
};
document.registerElement('ui-slider', {
prototype: SliderProto
});
```
Функция `createdCallback` по шагам:
1. Создаём Shadow DOM, элемент `#slider` получаем из него, он не в основном документе.
2. Используя jQuery UI, слайдер создаётся вызовом [jQuery UI методом slider](http://jqueryui.com/slider/), который имеет вид `$elem.slider({...параметры...});`. Параметры получаем из атрибутов `<ui-slider>` (он же `this`) и отдаём библиотеке. Она делает всю работу.
3. Параметр `slide` задаёт функцию-коллбэк, которая вызывается при передвижении слайдера и будет генерировать DOM-событие на элементе, на которое можно будет поставить обработчик при помощи `addEventListener`. В его деталях мы указываем новое значение слайдера.
Полный код с примером:
[codetabs src="ui-slider" height=300]
Его можно далее улучшать, например добавить геттер и сеттер для значения `value`:
```js
Object.defineProperty(SliderProto, 'value', {
get: function() {
return this.$slider.slider("option", "value");
},
set: function(value) {
this.$slider.slider('option', 'value', value);
}
});
```
Если добавить этот код, то к значению `<ui-slider>` можно будет обращаться как `elem.value`, аналогично всяким встроенным `<input>`.
## Проблема с jQuery
Попробуйте пример выше. Он не совсем работает. Слайдер прокручивается первый раз, но второй раз он как-то странно "прыгает".
Чтобы понять, почему это происходит, я заглянул в исходники jQuery UI и, после отладки происходящего, натолкнулся на проблемный код.
Он был в методе [offset](http://api.jquery.com/offset/), который предназначен для того, чтобы определять координаты элемента. Этот метод не срабатывал, поскольку в нём есть проверка, которая выглядит примерно так:
```js
var box = {
top: 0,
left: 0
};
...
// Make sure it's not a disconnected DOM node
if(!jQuery.contains(elem.ownerDocument, elem)) {
return box;
}
```
То есть, jQuery проверяет, находится ли элемент `elem` внутри своего документа `elem.ownerDocument`. Если нет -- то считается, что элемент вне DOM, и его размеры равны нулю.
Если копнуть чуть глубже, то `jQuery.contains` в современных браузерах сводится к обычному вызову [contains](https://developer.mozilla.org/en-US/docs/Web/API/Node/contains).
Парадокс с Shadow DOM заключается в том, что вызов `elem.ownerDocument.contains(elem)` вернёт `false`!
Получилось, что элемент не в документе и одновременно он имеет размеры. Такого разработчики jQuery не предусмотрели.
Можно, конечно, побежать исправлять jQuery, но давайте подумаем, может быть так оно и должно быть?
С точки зрения здравого смысла, Shadow DOM является частью текущего документа. Это соответствует и духу [текущей спецификации](http://w3c.github.io/webcomponents/spec/shadow/), где shadow tree рассматривается в контексте document tree.
Поэтому на самом деле `document.contains(elem)` следовало бы возвращать `true`.
Почему же `false`? Причина проста -- описанный в [другом стандарте](http://www.w3.org/TR/dom/#dom-node-contains) механизм работы `contains` по сути состоит в проходе вверх от `elem` по цепочке `parentNode`, пока либо встретим искомый элемент, тогда ответ `true`, а иначе `false`. В случае с Shadow DOM этот путь закончится на корне Shadow DOM-дерева, оно ведь не является потомком хозяина.
**Метод `contains` описан стандартом без учёта Shadow DOM, поэтому возвратил неверный результат `false`.**
Это один из тех небольших, но важных нюансов, которые показывают, что стандарты всё ещё в разработке.
## Итого
- С использованием современных технологий можно делать компоненты. Но это, всё же, дело будущего. Все стандарты находятся в процессе доработки, готовятся новые.
- Можно использовать произвольную библиотеку, такую как jQuery, и работать с Shadow DOM с её использованием. Но возможны проблемки. Выше была продемонстрирована одна из них, могут быть и другие.
Пока веб-компоненты ещё не являются законченными стандартами, можно попробовать [Polymer](http://www.polymer-project.org) -- это самый известный из полифиллов на тему веб-компонент.
Он старается их эмулировать по возможности кросс-браузерно, но пока что это довольно-таки сложно, в частности, необходима дополнительная разметка.

View file

@ -0,0 +1,23 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link rel="import" id="link" href="ui-message.html">
<style>
ui-message {
width: 80%;
margin: auto;
}
</style>
</head>
<body>
<ui-message class="info">Доброе утро, страна!</ui-message>
<ui-message class="warning">Внимание-внимание! Говорит информбюро!</ui-message>
</body>
</html>

View file

@ -0,0 +1,55 @@
<!DOCTYPE HTML>
<html>
<body>
<template id="tmpl">
<style>
.content {
min-height: 20px;
padding: 19px;
margin-bottom: 20px;
background-color: #f5f5f5;
border: 1px solid #e3e3e3;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);
}
:host {
display: block;
}
:host(.info) .content {
color: green;
}
:host(.warning) .content {
color: red;
}
</style>
<div class="content">
<content></content>
</div>
</template>
<script>
! function() {
var localDocument = document.currentScript.ownerDocument;
var tmpl = localDocument.getElementById('tmpl');
var MessageProto = Object.create(HTMLElement.prototype);
MessageProto.createdCallback = function() {
var root = this.createShadowRoot();
root.appendChild(tmpl.content.cloneNode(true));
};
document.registerElement('ui-message', {
prototype: MessageProto
});
}();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link rel="import" id="link" href="ui-slider.html">
<style>
ui-slider {
width: 300px;
margin: 10px;
}
</style>
</head>
<body>
<ui-slider min="0" max="1000" id="elem"></ui-slider>
<script>
elem.addEventListener("slide", function(e) {
value.innerHTML = e.detail.value; // = this.value
});
</script>
<div id="value">0</div>
</body>
</html>

View file

@ -0,0 +1,2 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.11.4/jquery-ui.js"></script>

View file

@ -0,0 +1,60 @@
<!DOCTYPE HTML>
<html>
<head>
<link rel="import" href="jquery.html">
</head>
<body>
<template id="tmpl">
<style>
@import url(https://code.jquery.com/ui/1.11.4/themes/ui-lightness/jquery-ui.css);
:host {
display: block;
}
</style>
<div id="slider"></div>
</template>
<script>
! function() {
var localDocument = document.currentScript.ownerDocument;
var tmpl = localDocument.getElementById('tmpl');
var SliderProto = Object.create(HTMLElement.prototype);
SliderProto.createdCallback = function() {
var root = this.createShadowRoot();
root.appendChild(tmpl.content.cloneNode(true));
this.$slider = $(root.getElementById('slider'));
var self = this;
this.$slider.slider({
min: +this.getAttribute('min') || 0,
max: +this.getAttribute('max') || 100,
value: this.getAttribute('value') || 0,
slide: function() {
var event = new CustomEvent("slide", {
detail: {
value: self.$slider.slider("option", "value")
},
bubbles: true
});
self.dispatchEvent(event);
}
});
};
document.registerElement('ui-slider', {
prototype: SliderProto
});
}();
</script>
</body>
</html>

View file

@ -0,0 +1,3 @@
# Web components
Web components is a set of standards to make self-contained components: custom HTML-elements with their own properties and methods, encapsulated DOM and styles.