Разработчик из консалтинговой компании в области разработки This Dot Labs рассказывает, как использовать canvas в Svelte и как превратить многословный API Canvas в краткий, более декларативный. Подробности — к старту нашего курса по фронтенду.
Элемент <canvas>
и Canvas API позволяют рисовать на JavaScript, а с помощью Svelte его императивный API можно преобразовать в декларативный. Это потребует от вас знания Renderless-компонентов — компонентов, которые не отрисовываются.
Renderless
Все разделы файла .svelte
, включая шаблон, необязательны. Поэтому можно создать компонент, который не отображается, но содержит логику в теге <script>
.
Давайте создадим новый проект Svelte с помощью Vite:
npm init vite
Project name: canvas-svelte
Select a framework: › svelte
Select a variant: › svelte-ts
cd canvas-svelte
npm i
И новый компонент — Renderless
:
<!-- src/lib/Renderless.svelte -->
<script>
console.log("No template");
</script>
После инициализации компонента выведем сообщение. Для этого перепишем точку входа App
:
// src/main.ts
// import App from './App.svelte'
import Renderless from './lib/Renderless.svelte'
const app = new Renderless({
target: document.getElementById('app')
})
export default app
Теперь запускаем сервер, открываем инструменты разработчика — и видим сообщение:
Работает.
Обратите внимание, что методы жизненного цикла компонента по-прежнему доступны, то есть компонент ведёт себя как обычный, даже когда у него нет шаблона.
Проверим это:
<!-- src/lib/Renderless.svelte -->
<script>
import { onMount } from "svelte";
console.log("No template");
onMount(() => {
console.log("Component mounted");
});
</script>
После монтирования Renderless отображает второе сообщение, оба сообщения выводятся в ожидаемом порядке:
Это означает, что Renderless можно использовать как любой другой компонент Svelte. Вернём изменения main.ts
и "отрисуем" компонент внутри App
:
// src/main.ts
import App from './App.svelte'
const app = new App({
target: document.getElementById('app')
})
export default app
<!-- src/App.svelte -->
<script lang="ts">
import { onMount } from "svelte";
import Renderless from "./lib/Renderless.svelte";
console.log("App: initialized");
onMount(() => {
console.log("App: mounted");
});
</script>
<main>
<Renderless />
</main>
Перепишем Renderless, чтобы логировать важные сообщения:
<!-- src/lib/Renderless.svelte -->
<script>
import { onMount } from "svelte";
console.log("Renderless: initialized");
onMount(() => {
console.log("Renderless: mounted");
});
</script>
При создании компонентов без отрисовки и Canvas важно обратить внимание на порядок инициализации и монтирования компонентов.
Ещё один способ монтировать компонент — передать его как дочерний элемент другого компонента. Такая передача называется проекцией контента. Эту проекцию сделаем с помощью slot
.
Напишем компонент Container
, который будет отрисовывать добавленные в слот элементы:
<!-- src/lib/Container.svelte -->
<script>
import { onMount } from "svelte";
console.log("Container: initialized");
onMount(() => {
console.log("Container: mounted");
});
</script>
<h1>The container of things</h1>
<slot />
<p>invisible things</p>
С помощью prop
добавим в компонент Renderless
идентификатор:
<!-- src/lib/Renderless.svelte -->
<script lang="ts">
import { onMount } from "svelte";
export let id:string = "NoId"
console.log(`Renderless ${id}: initialized`);
onMount(() => {
console.log(`Renderless ${id}: mounted`);
});
</script>
Перепишем App
для контейнера, затем передадим в App
несколько экземпляров Renderless
:
<!-- src/App.svelte -->
<script lang="ts">
import { onMount } from "svelte";
import Container from "./lib/Container.svelte";
import Renderless from "./lib/Renderless.svelte";
console.log("App: initialized");
onMount(() => {
console.log("App: mounted");
});
</script>
<main>
<Container>
<Renderless id="Foo"/>
<Renderless id="Bar"/>
<Renderless id="Baz"/>
</Container>
</main>
Ниже видно и Container
, и компоненты без отрисовки, которые при инициализации и монтировании пишут в лог:
А теперь воспользуемся компонентами без отрисовки в сочетании с <canvas>
.
HTML canvas и Canvas API
Элемент canvas
не может содержать никаких дочерних элементов, кроме резервного элемента для отрисовки. Всё, что хочется показать в canvas, должно быть написано на императивном API.
Создадим новый компонент Canvas и отрисуем canvas:
<!-- src/lib/Canvas.svelte -->
<script>
import { onMount } from "svelte";
console.log("Canvas: initialized");
onMount(() => {
console.log("Canvas: mounted");
});
</script>
<canvas />
Обновим App
, чтобы воспользоваться Canvas
:
<!-- src/App.svelte -->
<script lang="ts">
import { onMount } from "svelte";
import Canvas from "./lib/Canvas.svelte";
console.log("App: initialized");
onMount(() => {
console.log("App: mounted");
});
</script>
<main>
<Canvas />
</main>
А теперь откроем инструменты разработчика:
Отрисовка элементов внутри canvas
Как уже говорилось, добавлять элементы прямо в canvas нельзя. Чтобы рисовать, нужно работать с API.
Ссылку на элемент получим через bind:this
. Важно понимать, что для работы с API элемент должен быть доступен, то есть рисовать придётся после монтирования компонента:
<script lang="ts">
import { onMount } from "svelte";
let canvasElement: HTMLCanvasElement
console.log("1", canvasElement) // undefined!!!
console.log("Canvas: initialized");
onMount(() => {
console.log("2", canvasElement) // OK!!!
console.log("Canvas: mounted");
});
</script>
<canvas bind:this={canvasElement}/>
Нарисуем линию. Для наглядности я убрал всё логирование:
<script lang="ts">
import { onMount } from "svelte";
let canvasElement: HTMLCanvasElement
onMount(() => {
// get canvas context
let ctx = canvasElement.getContext("2d")
// draw line
ctx.beginPath();
ctx.moveTo(10, 20); // line will start here
ctx.lineTo(150, 100); // line ends here
ctx.stroke(); // draw it
});
</script>
<canvas bind:this={canvasElement}/>
Чтобы рисовать, canvas нужен контекст, так что делать это можно только после монтирования компонента:
Если захочется добавить вторую строку, придётся дописать и новый блок кода:
<script lang="ts">
import { onMount } from "svelte";
let canvasElement: HTMLCanvasElement
onMount(() => {
// get canvas context
let ctx = canvasElement.getContext("2d")
// draw first line
ctx.beginPath();
ctx.moveTo(10, 20); // line will start here
ctx.lineTo(150, 100); // line ends here
ctx.stroke(); // draw it
// draw second line
ctx.beginPath();
ctx.moveTo(10, 40); // line will start here
ctx.lineTo(150, 120); // line ends here
ctx.stroke(); // draw it
});
</script>
Мы рисуем простые фигуры — а кода в компоненте всё больше и больше. Можно написать вспомогательные функции, сокращающие код линий:
<script lang="ts">
import { onMount } from "svelte";
let canvasElement: HTMLCanvasElement;
onMount(() => {
// get canvas context
let ctx = canvasElement.getContext("2d");
// draw first line
drawLine(ctx, [10, 20], [150, 100]);
// draw second line
drawLine(ctx, [10, 40], [150, 120]);
});
type Point = [number, number];
function drawLine(ctx: CanvasRenderingContext2D, start: Point, end: Point) {
ctx.beginPath();
ctx.moveTo(...start); // line will start here
ctx.lineTo(...end); // line ends here
ctx.stroke(); // draw it
}
</script>
<canvas bind:this={canvasElement} />
Читать код легче, но вся ответственность по-прежнему делегируется Canvas
, а это приводит к большой сложности компонента. Избежать большой сложности помогут компоненты без отрисовки и API Context.
И вот что нам уже известно:
Для рисования нам нужен Canvas.
Получить контекст можно после монтирования компонента.
Дочерние компоненты монтируются перед родительским компонентом.
Родительские компоненты инициализируются перед дочерними компонентами.
Дочерние компоненты можно использовать при монтировании.
Наш компонент нужно разделить на несколько компонентов. Здесь хочется, чтобы Line
рисовал сам себя.
Canvas
и Line
связаны. Line
нельзя отрисовать без Canvas, и ему нужен контекст canvas
. Но контекст недоступен, когда монтируется дочерний компонент, ведь Line
монтируется перед Canvas
. Поэтому подход нужен другой.
Вместо передачи контекста для отрисовки самого себя сообщим родительскому компоненту, что рисовать нужно дочерний компонент. Canvas
и Line
соединим через Context
.
Context
— это способ взаимодействия двух и более компонентов. Его можно установить или получить только во время инициализации, а это нам и нужно: Canvas
инициализируется перед Line
.
Сначала давайте перенесём отрисовку линии в отдельный компонент, а некоторые типы — в их собственный файл, чтобы сделать их общими для компонентов:
// src/types.ts
export type Point = [number, number];
export type DrawFn = (ctx: CanvasRenderingContext2D) => void;
export type CanvasContext = {
addDrawFn: (fn: DrawFn) => void;
removeDrawFn: (fn: DrawFn) => void;
};
<!-- src/lib/Line.svelte -->
<script lang="ts">
import type { Point } from "./types";
export let start: Point;
export let end: Point;
function draw(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.moveTo(...start);
ctx.lineTo(...end);
ctx.stroke();
}
</script>
Это очень похоже на то, что было в Canvas
, но абстрагировано до компонента. Теперь нужно организовать коммуникацию Canvas
и Line
.
Canvas
будет работать как оркестратор всей отрисовки. Он инициализирует все дочерние компоненты, собирает функции отрисовки и выполняет отрисовку, когда это нужно:
<script lang="ts">
import { onMount, setContext } from "svelte";
import type { DrawFn } from "./types";
let canvasElement: HTMLCanvasElement;
let fnsToDraw = [] as DrawFn[];
setContext("canvas", {
addDrawFn: (fn: DrawFn) => {
fnsToDraw.push(fn);
},
removeDrawFn: (fn: DrawFn) => {
let index = fnsToDraw.indexOf(fn);
if (index > -1){
fnsToDraw.splice(index, 1);
}
},
});
onMount(() => {
// get canvas context
let ctx = canvasElement.getContext("2d");
draw(ctx);
});
function draw(ctx){
fnsToDraw.forEach(draw => draw(ctx));
}
</script>
<canvas bind:this={canvasElement} />
<slot />
Первое, что нужно отметить, — шаблон изменился, рядом с canvas
появился элемент <slot>
. Он будет использоваться для монтирования любых дочерних элементов, которые передаются в canvas
, — это компоненты Line
. Эти Line
не добавят никаких элементов HTML.
Массив
let fnsToDraw = [] as DrawFn[]
в<script>
хранит все функции отрисовки.
Мы установили и новый контекст. Делать это нужно во время инициализации. Canvas
инициализируется до Line
, поэтому здесь устанавливаются два метода — для добавления и удаления функции из DrawFn[]
. После этого любой их дочерний компонент будет иметь доступ к этому контексту и вызывать его методы. Именно это делается в Line
:
<script lang="ts">
import { getContext, onDestroy, onMount } from "svelte";
import type { Point, CanvasContext } from "./types";
export let start: Point;
export let end: Point;
let canvasContext = getContext("canvas") as CanvasContext;
onMount(() => {
canvasContext.addDrawFn(draw);
});
onDestroy(() => {
canvasContext.removeDrawFn(draw);
});
function draw(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.moveTo(...start);
ctx.lineTo(...end);
ctx.stroke();
}
</script>
Функция регистрируется с помощью контекста, установленного Canvas, когда компонент монтируется. Выполнить регистрацию можно было и при инициализации, ведь контекст доступен в любом случае, но я предпочитаю делать это после монтирования компонента. Когда элемент уничтожается, он удаляет себя из списка функций отрисовки.
А теперь дополним App
компонентами Canvas
и Line
:
<script lang="ts">
import Canvas from "./lib/Canvas.svelte";
import Line from "./lib/Line.svelte";
</script>
<main>
<Canvas>
<Line start={[10, 20]} end={[150, 100]} />
<Line start={[10, 40]} end={[150, 120]} />
</Canvas>
</main>
Компонент Canvas
обновлён для декларативного программирования, но рисуем мы только один раз, когда он смонтирован.
А нам нужно, чтобы canvas отрисовывался часто и обновлялся при изменениях, если только вы не хотите обратного. Обратите внимание, что частую отрисовку пришлось бы делать с выбранным подходом или без него.
И вот распространённый способ обновления содержимого canvas
:
<script lang="ts">
// NOTE: some code removed for readability
// ...
let frameId: number
// ...
onMount(() => {
// get canvas context
let ctx = canvasElement.getContext("2d");
frameId = requestAnimationFrame(() => draw(ctx));
});
onDestroy(() => {
if (frameId){
cancelAnimationFrame(frameId)
}
})
function draw(ctx: CanvasRenderingContext2D) {
if clearFrames {
ctx.clearRect(0,0,canvasElement.width, canvasElement.width)
}
fnsToDraw.forEach((fn) => fn(ctx));
frameId = requestAnimationFrame(() => draw(ctx))
}
</script>
Это достигается повторной отрисовкой canvas
через requestAnimationFrame
. Переданная функция запускается до перерисовки браузером. Новая переменная для текущего frameId
потребуется при отмене анимации. Затем, когда компонент монтируется, вызывается requestAnimationFrame
, и возвращённый идентификатор присваивается нашей переменной.
Пока конечный результат такой же, как и раньше. Отличие — в функции отрисовки, которая запрашивает новый кадр анимации после каждой отрисовки. Canvas
очищается, а иначе при анимации каждый кадр отрисовывается поверх другого. Этот эффект может быть желательным — тогда установите clearFrame
в false
. Наш Canvas
будет обновлять каждый кадр до уничтожения компонента и погашения текущей анимации с помощью сохранённого идентификатора.
Больше функциональности
Базовая функциональность компонентов работает, но мы можем захотеть большего.
В этом примере представлены события onmousemove
и onmouseleave
. Чтобы они работали, измените canvas
вот так:
<canvas on:mousemove on:mouseleave bind:this={canvasElement} />
Теперь эти события можно обрабатывать в App
:
<script lang="ts">
import Canvas from "./lib/Canvas.svelte";
import Line from "./lib/Line.svelte";
import type { Point } from "./lib/types";
function followMouse(e) {
let rect = e.target.getBoundingClientRect();
end = [e.clientX - rect.left, e.clientY - rect.top];
}
let start = [0, 0] as Point;
let end = [0, 0] as Point;
</script>
<main>
<Canvas
on:mousemove={(e) => followMouse(e)}
on:mouseleave={() => {
end = [0, 0];
}}
>
<Line {start} {end} />
</Canvas>
</main>
Svelte отвечает за обновление конечного положения линии. Но Canvas используется для обновления содержимого canvas через requestAnimationFrame
:
Итоги
Надеюсь, это руководство поможет вам как введение в применение canvas в Svelte, а также поможет понять, как превратить библиотеку с императивным API в более декларативную.
Есть примеры сложнее, например svelte-cubed или svelte-leaflet. Из документации svelte-cubed
:
Это:
import * as THREE from 'three';
function render(element) {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
45,
element.clientWidth / element.clientHeight,
0.1,
2000
);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(element.clientWidth / element.clientHeight);
element.appendChild(renderer.domElement);
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshNormalMaterial();
const box = new THREE.Mesh(geometry, material);
scene.add(box);
camera.position.x = 2;
camera.position.y = 2;
camera.position.z = 5;
camera.lookAt(new THREE.Vector3(0, 0, 0));
renderer.render(scene, camera);
}
Превращается в:
<script>
import * as THREE from 'three';
import * as SC from 'svelte-cubed';
</script>
<SC.Canvas>
<SC.Mesh geometry={new THREE.BoxGeometry()} />
<SC.PerspectiveCamera position={[1, 1, 3]} />
</SC.Canvas>
Canvas API можно расширить и даже создать библиотеку.
А мы поможем прокачать ваши навыки или с самого начала освоить профессию, актуальную в любое время: