Доброго времени суток, друзья!
ES6-модули, использующие синтаксис «import/export», являются довольно мощным инструментом и достойным конкурентом компонентам популярных фреймворков.
Позвольте мне продемонстрировать это на примере рисования различных фигур на холсте.
Источник вдохновения — этот раздел руководства по JavaScript от MDN.
Вот какой функционал будет реализован в нашем небольшом приложении:
- автоматическое создание холста заданных размеров и его рендеринг на странице
- возможность рисовать на холсте квадраты, круги и треугольники заданного размера и цвета
- разделение кода на модули, содержащие логические части приложения
В процессе создания приложения мы уделим особое внимание дефолтному и именованному экспорту/импорту, а также статическому и динамическому импорту. Большая часть приложения будет написана с использованием синтаксиса классов.
Желательно, чтобы вы имели хотя бы общее представление о работе с классами и холстом.
Код проекта находится здесь.
Демо приложения можно посмотреть здесь.
Начнем с поддержки.
В целом, довольно неплохо. В среднем, около 93%.
Структура проекта будет следующей (вы можете создать все файлы сразу или создавать их по мере необходимости):
modules
helpers
convert.js
shapes
circle.js
square.js
triangle.js
canvas.js
index.html
main.js
main.css
Разметка выглядит так:
<div>
<section>
<h3>Circle</h3>
<label>
X:
<input type="number" value="75" data-prop="x" />
</label>
<label>
Y:
<input type="number" value="75" data-prop="y" />
</label>
<label>
Radius:
<input type="number" value="50" data-prop="radius" />
</label>
<label>
Color:
<input type="color" value="#ff0000" data-prop="color" />
</label>
<button data-btn="circle" class="draw_btn">Draw</button>
</section>
<section>
<h3>Square</h3>
<label>
X:
<input type="number" value="275" data-prop="x" />
</label>
<label>
Y:
<input type="number" value="175" data-prop="y" />
</label>
<label>
Length:
<input type="number" value="100" data-prop="length" />
</label>
<label>
Color:
<input type="color" value="#00ff00" data-prop="color" />
</label>
<button data-btn="square" class="draw_btn">Draw</button>
</section>
<section>
<h3>Triangle</h3>
<label>
X:
<input type="number" value="150" data-prop="x" />
</label>
<label>
Y:
<input type="number" value="100" data-prop="y" />
</label>
<label>
Length:
<input type="number" value="125" data-prop="length" />
</label>
<label>
Color:
<input type="color" value="#0000ff" data-prop="color" />
</label>
<button data-btn="triangle" class="draw_btn">Draw</button>
</section>
</div>
<button>Clear Canvas</button>
<script src="main.js" type="module"></script>
На что здесь следует обратить внимание?
Для каждой фигуры создается отдельная секция с полями для ввода необходимых данных и кнопкой для запуска процесса рисования фигуры на холсте. На примере секции для круга такими данными являются: начальные координаты, радиус и цвет. Мы устанавливаем полям для ввода начальные значения для обеспечения возможности быстрого тестирования работоспособности приложения. Атрибуты «data-prop» предназначены для получения значений полей для ввода в скрипте. Атрибуты «data-btn» предназначены для определения того, какая кнопка была нажата. Последняя кнопка служит для очистки холста.
Обратите внимание на то, как подключается скрипт. Атрибут «type» со значением «module» является обязательным. Атрибут «defer» в данном случае не требуется, поскольку загрузка модулей производится отложенно (т.е. после полной загрузки страницы) по умолчанию. Также обратите внимание, что мы подключаем к странице только файл «main.js». Другие файлы используются внутри «main.js» в качестве модулей.
Одна из главных особенности модулей состоит в том, что каждый модуль имеет собственную область видимости (контекст), включая «main.js». С одной стороны, это хорошо, поскольку позволяет избежать загрязнения глобального пространства имен и, следовательно, предотвратить возникновение конфликтов между одноименными переменными и функциями. С другой стороны, при необходимости доступа разных модулей к одним и тем же элементам DOM, например, приходится либо создавать отдельный скрипт с глобальными переменными и подключать его к странице перед основным модулем, либо явно создавать глобальные переменные (window.variable = value), либо создавать одинаковые переменные внутри каждого модуля, либо обмениваться переменными между модулями (что мы, собственно, и будем делать).
Существует также четвертый подход: получать доступ к элементам DOM по идентификатору напрямую. Вы знали о такой возможности? Например, если у нас в разметке имеется элемент с идентификатором «main», мы можем обращаться к нему просто как к main (main.innerHTML = "<p>Some Awesome Content<p/>") без предварительного определения (поиска) элемента с помощью «document.getElementById()» или аналогичных методов. Однако, данный подход является нестандартным и к использованию не рекомендуется, поскольку неизвестно, будет ли он поддерживаться в будущем, хотя лично мне такая возможность кажется очень удобной.
Другой особенностью статических модулей является то, что они могут быть импортированы только один раз. Повторный импорт будет проигнорирован.
И, наконец, третья особенность модулей заключается в том, что код модуля не может быть изменен после импорта. Другими словами, переменные и функции, объявленные в модуле, могут быть изменены только в этом модуле, там, куда они импортируются, сделать это не получится. Это чем-то напоминает шаблон проектирования «Модуль», реализованный с помощью объекта, содержащего частные переменные и функции, или с помощью класса с частными полями и методами.
Двигаемся дальше. Добавим минимальные стили:
body {
max-width: 768px;
margin: 0 auto;
color: #222;
text-align: center;
}
canvas {
display: block;
margin: 1rem auto;
border: 1px dashed #222;
border-radius: 4px;
}
div {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
section {
padding: 1rem;
}
label {
display: block;
}
input {
margin: 0.25rem 0;
}
input:not([type="color"]) {
width: 50px;
}
button {
margin: 0.25rem auto;
cursor: pointer;
}
ul {
list-style: none;
}
li {
margin: 0.5rem auto;
width: 320px;
border-bottom: 1px dotted #222;
}
p {
margin: 0.25rem 0;
}
Тут ничего особенного. Можете навести красоту по своему вкусу.
Переходим к модулям.
Файл «canvas.js» содержит код класса для создания и рендеринга холста, а также списка для сообщений, выводимых при создании той или иной фигуры (данные сообщения представляют собой информацию о площади и периметре (условно) фигуры):
// export default указывает на экспорт модуля по умолчанию
// данная инструкция может быть указана либо перед объявлением класса или функции, включая IIFE (но не выражения класса, переменной или функционального выражения)
// либо в конце, т.е. сначала class ClassName..., затем export default ClassName
export default class Canvas {
// конструктор класса принимает три параметра: родительский элемент, ширину и высоту
constructor(parent, width, height) {
this.parent = parent;
this.width = width;
this.height = height;
// контекст для рисования
this.ctx = null;
// список для сообщений
this.listEl = null;
// функция очистки холста должна быть привязана к экземпляру, поскольку будет вызываться из обработчика нажатия кнопки в стрелочной функции
this.clearCanvas = this.clearCanvas.bind(this);
}
// метод создания и рендеринга холста
createCanvas() {
// если контекст для рисования уже существует
// в принципе, мы могли бы обойтись без этой проверки
// поскольку будем автоматически создавать холст только один раз
if (this.ctx !== null) {
console.log("Canvas already created!");
return;
} else {
// создаем элемент "canvas"
const canvasEl = document.createElement("canvas");
// ширина и высота холста должны быть указаны в атрибутах
// указание их в свойствах может привести к проблемам
canvasEl.setAttribute("width", this.width);
canvasEl.setAttribute("height", this.height);
// добавляем холст в родительский элемент
this.parent.append(canvasEl);
// получаем двумерный контекст для рисования
this.ctx = canvasEl.getContext("2d");
}
// возвращаем экземпляр для обеспечения возможности использования цепочки вызовов методов класса
return this;
}
// функция создания списка для сообщений
// без этой проверки мы также могли бы обойтись
createReportList() {
if (this.listEl !== null) {
console.log("Report list already created!");
return;
} else {
const listEl = document.createElement("ul");
this.parent.append(listEl);
this.listEl = listEl;
}
return this;
}
// функция очистки холста и списка для сообщений
clearCanvas() {
this.ctx.clearRect(0, 0, this.width, this.height);
this.listEl.innerHTML = "";
}
}
Файл «convert.js» содержит функцию преобразования градусов в радианы:
// именованный экспорт
export const convert = (degrees) => (degrees * Math.PI) / 180;
Каждый файл в директории «shapes» представляет собой модуль конкретной фигуры. В целом, код этих модулей идентичен, за исключением методов рисования, а также формул расчета площади и периметра фигуры. Рассмотрим модуль, содержащий код для рисования круга (circle.js):
// именованный импорт вспомогательной функции
// использование фигурных скобок является обязательным
// здесь мы имеем дело с деструктуризацией - импортируемая сущность является свойством объекта "Module"
import { convert } from "../helpers/convert.js";
// именованный экспорт
// здесь мы могли бы использовать и экспорт по умолчанию
export class Circle {
// конструктор принимает объект с "настройками" фигуры
// посредством деструктуризации получаем необходимые свойства
// обратите внимание на то, что конструктор принимает "глобальные" переменные ctx и listEl
constructor({ ctx, listEl, radius, x, y, color }) {
this.ctx = ctx;
this.listEl = listEl;
this.radius = radius;
this.x = x;
this.y = y;
this.color = color;
// название фигуры
this.name = "Circle";
// элемент для сообщения
this.listItemEl = document.createElement("li");
}
// метод для рисования фигуры
draw() {
// устанавливаем цвет заливки
this.ctx.fillStyle = this.color;
// начинаем рисовать
this.ctx.beginPath();
// метод arc принимает 6 параметров:
// начальная координата по оси "x", начальная координата по оси "y", радиус, начальный угол, конечный угол
// (без вспомогательной функции это выглядит как "0, 2 * Math.PI")
// необязательное логическое значение, определяющее направление рисования круга: по часовой стрелке или против
this.ctx.arc(this.x, this.y, this.radius, convert(0), convert(360));
// выполняем заливку
this.ctx.fill();
}
// функция вывода сообщения о площади и периметре фигуры
report() {
// площадь
this.listItemEl.innerHTML = `<p>${this.name} area is ${Math.round(Math.PI * (this.radius * this.radius))}px squared.</p>`;
// периметр
this.listItemEl.innerHTML += `<p>${this.name} circumference is ${Math.round(2 * Math.PI * this.radius)}px.</p>`;
this.listEl.append(this.listItemEl);
}
}
Наконец, в файле «main.js» осуществляется статический дефолтный импорт модуля-класса «Canvas», создание экземпляра этого класса и обработка нажатия кнопок, которая заключается в динамическом импорте соответствующего модуля-класса фигуры и вызове ее методов:
// импорт по умолчанию
// обратите внимание на отсутствие фигурных скобок
import Canvas from "./modules/canvas.js";
// создаем экземпляр класса, вызываем его методы создания холста и списка для сообщений
// и сразу извлекаем необходимые переменные:
// контекст для рисования, список для сообщений и функцию очистки холста
const { ctx, listEl, clearCanvas } = new Canvas(document.body, 400, 300).createCanvas().createReportList();
// делегируем обработку события "клик" документу
// обратите внимание на то, что функция обработки события является асинхронной
// на что указывает ключевое слово "async"
document.addEventListener("click", async (e) => {
// нас интересует только нажатие кнопки
if (e.target.tagName !== "BUTTON") return;
// если нажата кнопка рисования фигуры
if (e.target.className === "draw_btn") {
// получаем название кнопки
// обратите внимание, что мы изменяем название переменной
// это сделано исключительно для удобочитаемости
const { btn: btnName } = e.target.dataset;
// формируем названием фигуры из названия кнопки
// отличие состоит в первой букве - для фигуры она должна быть заглавной
const shapeName = `${btnName[0].toUpperCase()}${btnName.slice(1)}`;
// определяем объект для настроек фигуры
const shapeParams = {};
// определяем соответствующие поля для ввода
const inputsEl = e.target.parentElement.querySelectorAll("input");
// перебираем их
inputsEl.forEach((input) => {
// для каждого поля
// получаем название свойства
const { prop } = input.dataset;
// получаем значение свойства
// все значения, кроме значения цвета, должны быть числами
const value = !isNaN(input.value) ? input.valueAsNumber : input.value;
// добавляем свойство и значение в объект
shapeParams[prop] = value;
});
// добавляем в объект контекст для рисования и список для сообщений
shapeParams.ctx = ctx;
shapeParams.listEl = listEl;
console.log(shapeParams);
// динамический импорт
// возвращает объект "Module"
const ShapeModule = await import(`./modules/shapes/${btnName}.js`);
// чтобы не указывать название конкретного модуля-класса при импорте
// мы используем небольшую хитрость
// создаем экземпляр класса с помощью указания названия фигуры в качестве свойства объекта "Module" (используя скобочную нотацию) и передавая ему объект с настройками фигуры
const shape = new ShapeModule[shapeName](shapeParams);
// вызываем метод рисования
shape.draw();
// вызываем метод вывода сообщения о площади и периметре фигуры
shape.report();
} else {
// если нажата кнопка очистки холста
// вызываем соответствующий метод класса "Canvas"
clearCanvas();
}
});
Поиграть с кодом можно здесь.
Как видите, ES6-модули предоставляют довольно интересные возможности, связанные с разделением кода на относительно автономные блоки, содержащие логические части приложения, которые могут загружаться сразу или по необходимости. В тандеме с шаблонными литералами они являются хорошей альтернативой компонентам популярных фреймворков. Я имею ввиду, прежде всего, рендеринг страниц на стороне клиента. Более того, такой подход позволяет повторно отрисовывать только те элементы DOM, которые подверглись изменениям, что, в свою очередь, позволяет обойтись без виртуального DOM. Но об этом в одной из следующих статей.
Надеюсь, вы нашли для себя что-нибудь интересное. Благодарю за внимание.